Linux Socket API 用法详解

linux 在进行网络应用程序开发时,常用到以下的 linux 网络 API:

  1. socket():用于初始化一个新的套接字
  2. bind():用于将套接字与一个本地地址绑定
  3. listen():用于将套接字标记为被动套接字,接受来自客户端的连接请求
  4. accept():用于接受来自客户端的连接请求,并返回一个新的已连接套接字,与客户端进行通信
  5. connect():用于客户端连接到服务端的被动套接字,以建立和服务端连接
  6. send():发送数据到已连接套接字
  7. recv():从已连接套接字或接收数据
  8. setsockopt():设置套接字选项,如超时、缓冲区大小等。
  9. close():关闭套接字描述符,释放相关资源

使用这些函数我们可以创建各种类型的网络应用程序,如 Web 服务器、邮件服务器、聊天应用程序等等。

1. socket 函数原理

int socket (int __domain, int __type, int __protocol) 
  • __domain:指定套接字的协议族,如 AF_INET 表示 IPv4 地址族,AF_INET6 表示 IPv6 地址族,AF_UNIX 表示本地通信地址族等。
  • __type:指定套接字的类型,如 SOCK_STREAM 表示面向连接的 TCP 套接字,SOCK_DGRAM 表示无连接的 UDP 套接字等。
  • __protocol:指定协议类型,通常为 0,表示使用默认协议。

函数如果成功,则返回文件描述符,如果失败,则返回 -1. 示例代码如下:

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }
}

2. bind 函数

函数 bind 用于将套接字绑定到一个本地的 IP 地址和端口,函数原型如下:

int bind (int __fd, const struct sockaddr *__addr, socklen_t __len)
  • __fd:套接字描述符。
  • __addr:指向存储本地 IP 地址和端口号的 sockaddr 结构体的指针,可以使用 sockaddr_in 结构体表示 IPv4 地址和端口号,使用 sockaddr_in6 结构体表示 IPv6 地址和端口号。
  • __len:地址结构体的长度,可以使用 sizeof() 函数计算。

struct sockaddr 结构体如下:

struct sockaddr {
    sa_family_t sa_family;  // 地址族,如 AF_INET、AF_INET6
    char sa_data[14];       // 14 字节的协议特定地址
};

在构造第二个参数 addr 时,我们并不是直接构造 strcut sockaddr 结构体,而是根据使用的是 IPv4 还是 IPv6,从而选择下面的结构体:

// 如果使用的是 IPv4,则使用下面的结构体
struct sockaddr_in {
    sa_family_t sin_family; // 地址族,AF_INET
    in_port_t sin_port;     // 16 位端口号,网络字节序
    struct in_addr sin_addr;// 32 位 IP 地址,网络字节序
    char sin_zero[8];       // 保留字段,一般填充为 0
};

// 如果使用的是 IPv6,则使用下面的结构体
struct sockaddr_in6 {
    sa_family_t sin6_family;   // 地址族,AF_INET6
    in_port_t sin6_port;       // 16 位端口号,网络字节序
    uint32_t sin6_flowinfo;    // 32 位 IPV6 流信息
    struct in6_addr sin6_addr; // 128 位 IPV6 地址
    uint32_t sin6_scope_id;    // 32 位作用域标识
};

我们看这两个结构体中,端口是 in_port_t 类型,该类型为 uint16_t,并且我们还要将端口从本地字节序改为网络字节序。这一步操作,使用下面的函数来完成:

// n 表示 net 网络, h 表示 host 本机
// l 表示输入类型为 long 类型
// s 表示输入参数为 short 类型
extern uint32_t ntohl (uint32_t __netlong)
extern uint16_t ntohs (uint16_t __netshort)
extern uint32_t htonl (uint32_t __hostlong)
extern uint16_t htons (uint16_t __hostshort)

另外 sin_addr 参数用于设置 IP 地址,这个是个结构体,定义如下:

typedef uint32_t in_addr_t;
struct in_addr
{
  in_addr_t s_addr;
};

我们理解中 IP 地址应该是一个字符串,为啥这里是个 uint32_t 类型呢?这是因为字符串类型的 IP 地址也需要转换为网络字节序,转换之后就是一个 4 字节的字节序列,我们可以将其看作是 uint32_t 类型。这里将字符串类型 IP 地址转换为网络字节序的工作,可以由 inet_addr 函数来完成:

// 将字符串类型 IP 地址转换为网络字节序表示
in_addr_t inet_addr (const char *__cp)

// 将网络字节序表示的 IP 转换为字符串类型
char *inet_ntoa (struct in_addr __in) 

下面为这两个函数的示例代码:

#include <netinet/in.h>
#include <arpa/inet.h>

int main()
{
    
    const char* ip = "192.168.0.156";
    // 将字符串 IP 地址转换为网络字节序表示
    in_addr_t address = inet_addr(ip);
    printf("IP 网络字节序:0x%X\n", address);

    // 将网络字节序表示的 IP 地址转换为字符串类型
    struct in_addr addr;
    addr.s_addr = address;
    ip = inet_ntoa(addr);
    printf("IP 字符串类型: %s\n", ip);


    return 0;
}

程序输出结果:

IP 网络字节序:0x9C00A8C0
IP 字符串类型: 192.168.0.156

IP 地址在设置时,我们也可以直接使用 Linux 预先转换好的几个特殊 IP,省去了转换过程:

  • INADDR_ANY 表示本地地址,其值为 0.0.0.0,可以被用于绑定 socket 的本地地址,表示可以接收任意远程主机发送的数据包。
  • INADDR_BROADCAST 表示广播地址,其值为 255.255.255.255,可以被用于向本地网络中的所有主机发送广播数据包。
  • INADDR_NONE 表示非法地址,其值为 -1,在某些情况下可以用来表示地址解析失败。

接下来,是该函数的调用示例:

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }
    
    // 套接字绑定 IP 地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(9000);

    int ret = bind(fd, (const sockaddr*)&addr, sizeof(addr));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
    }
}

3. listen 函数

函数 listen 函数用于将套接字转换为监听套接字,或者说将套接字转换为被动套接字,此时该套接字就开始监听外部的网络连接。函数声明如下:

int listen (int __fd, int __n) 
  • __fd:套接字描述符
  • __n:等待连接队列的最大长度,它表示已经连接但是还没有被 accept() 接受的连接的数量,通常被称为未完成连接队列或者半连接队列。当连接数超过最大长度时,连接会被拒绝

一般来说,__n 参数在设置时,可以根据服务器的处理能力网络流量来确定未完成连接队列的长度。

  1. 如果服务器的处理能力很强,网络流量很大,可以适当增大未完成连接队列的长度
  2. 如果服务器的处理能力比较弱,或者网络流量比较小,可以适当减小未完成连接队列的长度
  3. 一般来说,未完成连接队列的长度设置在 5-200 之间比较合适

示例代码:

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }
    
    // 套接字绑定 IP 地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(9000);

    int ret = bind(fd, (const sockaddr*)&addr, sizeof(addr));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 将套接字转换为被动套接字
    ret = listen(fd, 128);
    if (-1 == ret)
    {
        std::cout << "listen error" << std::endl;
        return;
    }
}

4. accept 函数

函数 accept 用于在一个监听套接字上等待并接受客户端连接。accept 究竟做了哪些事情呢?

  1. Linux 维护的连接队列有两个,一个是连接未完成的队列,一个是连接已完成的队列,如果已完成连接的队列为空,那么 accept 函数将会阻塞,否则就会从已完成连接的队列中获得该连接并返回
  2. 创建一个新的套接字,用于和建立连接的客户端套接字进行通信

下面是函数声明:

int accept (int __fd, struct sockaddr *__addr, socklen_t * __addr_len)
  • __fd:套接字描述符。
  • __addr:指向用于存储客户端 IP 地址和端口号的 sockaddr 结构体的指针,可以为 NULL,表示不需要获取客户端地址信息。
  • __addr_len:结构体的长度,可以使用 sizeof() 函数计算。

示例代码:

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }
    
    // 套接字绑定 IP 地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(9000);

    int ret = bind(fd, (const sockaddr*)&addr, sizeof(addr));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 将套接字转换为被动套接字
    ret = listen(fd, 128);
    if (-1 == ret)
    {
        std::cout << "listen error" << std::endl;
        return;
    }

    // 等待处理外部连接
    struct sockaddr_in client_addr;
    socklen_t len;
    int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
    if (-1 == fd)
    {
        std::cout << "accept error" << std::endl;
        return;
    }

    printf("客户端地址: %s\n", inet_ntoa(client_addr.sin_addr));
    printf("客户端端口: %d\n", ntohs(client_addr.sin_port));
}

5. connect 函数

函数 connect 用在客户端程序开发,作用是建立和客户端的连接。这地方提到连接,那么连接的含义是什么呢?

连接并不是在客户端和服务端之间重新拉起一根网线,而是彼此确认的一个过程。这个过程就是我们经常提到的 “三次握手”,具体握手的过程如下:

简单来说,通过三次握手,服务端和客户端能够确定彼此的收发数据功能是否正常:

  1. 第一次握手,表示服务端确定了客户端的发送功能是正常
  2. 第二次握手,表示客户端确定了自己的发送功能正常,服务端的接收功能正常,并且服务端询问自己的发送功能是否正常
  3. 第三次握手,表示服务端确定自己的发送功能正常,客户端的接收功能正常

这个过程完成之后,accept 函数就会将未连接队列中的客户端连接移除,并加入到已连接队列中。函数声明如下:

int connect (int __fd, const struct sockaddr * __addr, socklen_t __len)
  • __fd:套接字描述符。
  • __addr:指向远程 IP 地址和端口号的 sockaddr 结构体的指针,可以使用 sockaddr_in 结构体表示 IPv4 地址和端口号,使用 sockaddr_in6 结构体表示 IPv6 地址和端口号。
  • __len:地址结构体的长度,可以使用 sizeof() 函数计算

示例代码(客户端):

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }

    // 连接服务端
    struct sockaddr_in server_address;
    server_address.sin_family = AF_INET;
    server_address.sin_port = htons(9000);
    server_address.sin_addr.s_addr = INADDR_ANY;
    int ret = connect(fd, (const struct sockaddr*)&server_address, sizeof(server_address));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }
   

6. send 函数

函数 send 用于向对端发送数据。注意的是,Linux 内核提供了两个缓冲区来管理发送数据:发送缓冲区套接字缓冲区。发送缓冲区位于应用程序的地址空间中,套接字缓冲区则位于内核空间中。

当 send 函数执行时,数据首先被复制到发送缓冲区中,然后由内核异步地将数据从发送缓冲区复制到套接字缓冲区中,并发送到目标socket。如果发送缓冲区已满,send 函数将会阻塞。

Linux内核会定期检查发送缓冲区中是否有数据需要发送,一般通过定时器来实现。当检测到发送缓冲区中有数据时,内核会将这些数据异步地复制到套接字缓冲区中,并尝试将它们发送到目标 socket。如果发送缓冲区中的数据量很小,那么内核可能会等待一段时间,以便将多个小数据包合并为一个更大的数据包,从而提高网络传输效率。

需要注意的是,发送缓冲区和套接字缓冲区是两个独立的缓冲区。当应用程序调用 send 函数时,数据首先被复制到发送缓冲区中,然后send函数立即返回,不会等待数据被复制到套接字缓冲区中。这意味着,如果应用程序在数据发送之前就结束了,那么发送缓冲区中的数据将会丢失。因此,应用程序需要确保在数据发送完成之前,保持与目标socket的连接处于活动状态,以确保数据能够被成功传输。

函数 send 声明如下:

ssize_t send (int __fd, const void *__buf, size_t __n, int __flags);
  • __fd:套接字描述符。
  • __buf:指向待发送数据的缓冲区。
  • __n:待发送数据的长度。
  • __flags:指定发送数据时的选项,如 MSG_NOSIGNAL 表示忽略 SIGPIPE 信号,避免进程退出。

示例代码(客户端):

#include <iostream>

extern "C"
{
	#include <sys/socket.h>
	#include <netinet/in.h>
	#include <arpa/inet.h>
    #include <string.h>
}

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }

    // 连接服务端
    struct sockaddr_in server_address;
    server_address.sin_family = AF_INET;
    server_address.sin_port = htons(9000);
    server_address.sin_addr.s_addr = INADDR_ANY;
    int ret = connect(fd, (const struct sockaddr*)&server_address, sizeof(server_address));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 发送数据
    const char* message = "hello world";
    ret = send(fd, message, strlen(message) + 1, 0);
    if (-1 == ret)
    {
        std::cout << "send error" << std::endl;
        return;
    }
    printf("发送了 %d 字节数据到服务端!\n", ret);
}

int main()
{
	test();
	return 0;
}

7. recv 函数

函数 recv 用于从对端接收数据。在数据接收时,也会涉及到几个缓冲区:

  1. 应用层缓冲区:即应用程序中用于存储接收数据的缓冲区。这个缓冲区是应用程序自己分配的,大小和分配方式都由应用程序控制。
  2. 内核缓冲区:即内核中用于存储接收数据的缓冲区。这个缓冲区是由内核自动管理的,其大小和分配方式由系统内核参数控制。
  3. 网络缓冲区:在数据从网络上到达套接字时,网络协议栈会使用一个或多个缓冲区来存储数据。这些缓冲区也是由内核管理的,其大小和分配方式也由系统内核参数控制。

当应用程序调用 recv 函数从套接字接收数据时,内核会将网络缓冲区中的数据复制到内核缓冲区中,然后再将内核缓冲区中的数据复制到应用层缓冲区中,最终返回给应用程序。

函数声明如下:

ssize_t recv (int __fd, void *__buf, size_t __n, int __flags)
  • __fd:套接字描述符。
  • __buf:指向存储接收数据的缓冲区。
  • __n:缓冲区的长度。
  • __flags:指定接收数据时的选项,如 MSG_PEEK 表示预览数据而不读取,MSG_WAITALL 表示等待接收完所有数据再返回。

示例代码(服务端):

#include <iostream>

extern "C"
{
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
}

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }
    
    // 套接字绑定 IP 地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(9000);

    int ret = bind(fd, (const sockaddr*)&addr, sizeof(addr));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 将套接字转换为被动套接字
    ret = listen(fd, 128);
    if (-1 == ret)
    {
        std::cout << "listen error" << std::endl;
        return;
    }

    // 等待处理外部连接
    struct sockaddr_in client_addr;
    socklen_t len;
    printf("等待连接\n");
    int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
    if (-1 == fd)
    {
        std::cout << "accept error" << std::endl;
        return;
    }

    printf("客户端地址: %s\n", inet_ntoa(client_addr.sin_addr));
    printf("客户端端口: %d\n", ntohs(client_addr.sin_port));
    
    // 接收对端数据
    char buf[1204] = { 0 };
    ssize_t num = recv(client_fd, buf, sizeof(buf), 0);
    if (-1 == num)
    {
        std::cout << "accept error" << std::endl;
        return;
    }

    printf("接收到 %u 字节数据,内容为: %s", num, buf);
}


int main()
{
    test();
    getchar();
    return 0;
}

8. close 函数

函数 close 用于关闭与对端连接。关闭文件描述符或套接字之前,内核会释放所有与该文件描述符或套接字相关的内存资源,包括缓冲区、状态信息等。当 close 函数返回时,应用程序可以确保文件描述符或套接字已经完全关闭。如果出现错误,close 函数会返回 -1 并设置相应的错误码,应用程序可以通过 errno 变量来获取错误信息。

关闭的过程就是我们经常提到的四次挥手过程:

  1. 第一次挥手(FIN):当一方决定关闭连接时,它会向另一方发送一个FIN报文段,表示自己已经没有数据要发送了。
  2. 第二次挥手(ACK):另一方接收到FIN报文段后,会发送一个ACK报文段作为响应,表示已经收到了关闭请求。
  3. 第三次挥手(FIN):另一方发送一个FIN报文段,表示自己也没有数据要发送了。
  4. 第四次挥手(ACK):第一方接收到另一方的FIN报文段后,会发送一个ACK报文段作为响应,表示已经收到了关闭请求。

我们可能思考,为什么断开连接不是想建立连接时,使用3次通信来完成呢?我们以客户端发起断开连接为例:

  1. 第一次挥手,告诉了服务端,客户端不再发送数据,即:客户端关闭的发送功能,但是还能够接收数据
  2. 第二次挥手,服务端告诉客户端,我知道了,既然你不再发送数据,我就可以关闭接收数据的功能了。即:服务端关闭接收功能
  3. 这个过程中…服务端可能仍然有数据需要发送给客户端
  4. 第三次挥手,服务端把该发的数据都发送完了,再发送一个报文告诉客户端,我不再发送数据。即:服务端关闭发送功能
  5. 第四次挥手,客户端收到报文知道对方不再发送,就关闭接收功能,即:客户端关闭接收功能

服务端完整代码如下:

#include <iostream>

extern "C"
{
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <unistd.h>
}

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }
    
    // 套接字绑定 IP 地址和端口
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    addr.sin_port = htons(9000);

    int ret = bind(fd, (const sockaddr*)&addr, sizeof(addr));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 将套接字转换为被动套接字
    ret = listen(fd, 128);
    if (-1 == ret)
    {
        std::cout << "listen error" << std::endl;
        return;
    }

    // 等待处理外部连接
    struct sockaddr_in client_addr;
    socklen_t len;
    printf("等待连接\n");
    int client_fd = accept(fd, (struct sockaddr*)&client_addr, &len);
    if (-1 == fd)
    {
        std::cout << "accept error" << std::endl;
        return;
    }

    printf("客户端地址: %s\n", inet_ntoa(client_addr.sin_addr));
    printf("客户端端口: %d\n", ntohs(client_addr.sin_port));
    
    // 接收对端数据
    char buf[1204] = { 0 };
    ssize_t num = recv(client_fd, buf, sizeof(buf), 0);
    if (-1 == num)
    {
        std::cout << "accept error" << std::endl;
        return;
    }

    printf("接收到 %u 字节数据,内容为: %s", num, buf);

    // 关闭连接
    ret = close(fd);
    if (-1 == ret)
    {
        std::cout << "close error" << std::endl;
        return;
    }
}

int main()
{
    test();
    getchar();
    return 0;
}

客户端完整代码为:

#include <iostream>

extern "C"
{
	#include <sys/socket.h>
	#include <netinet/in.h>
	#include <arpa/inet.h>
    #include <string.h>
    #include <unistd.h>
}

void test()
{
    // 创建套接字
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == fd)
    {
        std::cout << "socket error" << std::endl;
        return;
    }

    // 连接服务端
    struct sockaddr_in server_address;
    server_address.sin_family = AF_INET;
    server_address.sin_port = htons(9000);
    server_address.sin_addr.s_addr = INADDR_ANY;
    int ret = connect(fd, (const struct sockaddr*)&server_address, sizeof(server_address));
    if (-1 == ret)
    {
        std::cout << "bind error" << std::endl;
        return;
    }

    // 发送数据
    const char* message = "hello world";
    ret = send(fd, message, strlen(message) + 1, 0);
    if (-1 == ret)
    {
        std::cout << "send error" << std::endl;
        return;
    }
    printf("发送了 %d 字节数据到服务端!\n", ret);


    // 关闭套接字
    ret = close(fd);
    if (-1 == ret)
    {
        std::cout << "close error" << std::endl;
        return;
    }    
}

int main()
{
	test();
	return 0;
}

9. setsockopt 函数

函数 setsockopt 用于设置套接字选项的值。套接字选项是一个以SO_开头的常量,它们用于控制网络套接字的行为。这些选项可以用于设置超时值、开启或关闭重传、开启或关闭广播等。

int setsockopt (int __fd, int __level, int __optname,  const void *__optval, socklen_t __optlen)
  • __fd:要设置选项的套接字的文件描述符
  • __level:选项的协议层,可以是 SOL_SOCKET 或其他协议层
  • __optname:选项名称
  • __optval:指向包含选项值的缓冲区的指针
  • __optlen:选项值的长度

示例代码:

#include <iostream>

extern "C"
{
    #include <sys/socket.h>
    #include <sys/types.h>

}

void test()
{
    int fd = socket(AF_INET, SOCK_STREAM, 0);

    // 允许多个套接字绑定到同一地址和端口
    int value = 1;  // 选项的值
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &value, sizeof(value));

    // 设置发送缓冲区大小
    int send_buf_size = 1024;
    setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &send_buf_size, sizeof(send_buf_size));

    // 设置接收缓冲区大小
    int recv_buf_size = 1024;
    setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &recv_buf_size, sizeof(recv_buf_size));
}


int main()
{
    test();
    return 0;
}
未经允许不得转载:一亩三分地 » Linux Socket API 用法详解
评论 (0)

4 + 1 =