linux 在进行网络应用程序开发时,常用到以下的 linux 网络 API:
socket()
:用于初始化一个新的套接字bind()
:用于将套接字与一个本地地址绑定listen()
:用于将套接字标记为被动套接字,接受来自客户端的连接请求accept()
:用于接受来自客户端的连接请求,并返回一个新的已连接套接字,与客户端进行通信connect()
:用于客户端连接到服务端的被动套接字,以建立和服务端连接send()
:发送数据到已连接套接字recv()
:从已连接套接字或接收数据setsockopt()
:设置套接字选项,如超时、缓冲区大小等。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 参数在设置时,可以根据服务器的处理能力和网络流量来确定未完成连接队列的长度。
- 如果服务器的处理能力很强,网络流量很大,可以适当增大未完成连接队列的长度
- 如果服务器的处理能力比较弱,或者网络流量比较小,可以适当减小未完成连接队列的长度
- 一般来说,未完成连接队列的长度设置在 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 究竟做了哪些事情呢?
- Linux 维护的连接队列有两个,一个是连接未完成的队列,一个是连接已完成的队列,如果已完成连接的队列为空,那么 accept 函数将会阻塞,否则就会从已完成连接的队列中获得该连接并返回
- 创建一个新的套接字,用于和建立连接的客户端套接字进行通信
下面是函数声明:
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 用在客户端程序开发,作用是建立和客户端的连接。这地方提到连接,那么连接的含义是什么呢?
连接并不是在客户端和服务端之间重新拉起一根网线,而是彼此确认的一个过程。这个过程就是我们经常提到的 “三次握手”,具体握手的过程如下:
简单来说,通过三次握手,服务端和客户端能够确定彼此的收发数据功能是否正常:
- 第一次握手,表示服务端确定了客户端的发送功能是正常
- 第二次握手,表示客户端确定了自己的发送功能正常,服务端的接收功能正常,并且服务端询问自己的发送功能是否正常
- 第三次握手,表示服务端确定自己的发送功能正常,客户端的接收功能正常
这个过程完成之后,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 用于从对端接收数据。在数据接收时,也会涉及到几个缓冲区:
- 应用层缓冲区:即应用程序中用于存储接收数据的缓冲区。这个缓冲区是应用程序自己分配的,大小和分配方式都由应用程序控制。
- 内核缓冲区:即内核中用于存储接收数据的缓冲区。这个缓冲区是由内核自动管理的,其大小和分配方式由系统内核参数控制。
- 网络缓冲区:在数据从网络上到达套接字时,网络协议栈会使用一个或多个缓冲区来存储数据。这些缓冲区也是由内核管理的,其大小和分配方式也由系统内核参数控制。
当应用程序调用 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 变量来获取错误信息。
关闭的过程就是我们经常提到的四次挥手过程:
- 第一次挥手(FIN):当一方决定关闭连接时,它会向另一方发送一个FIN报文段,表示自己已经没有数据要发送了。
- 第二次挥手(ACK):另一方接收到FIN报文段后,会发送一个ACK报文段作为响应,表示已经收到了关闭请求。
- 第三次挥手(FIN):另一方发送一个FIN报文段,表示自己也没有数据要发送了。
- 第四次挥手(ACK):第一方接收到另一方的FIN报文段后,会发送一个ACK报文段作为响应,表示已经收到了关闭请求。
我们可能思考,为什么断开连接不是想建立连接时,使用3次通信来完成呢?我们以客户端发起断开连接为例:
- 第一次挥手,告诉了服务端,客户端不再发送数据,即:客户端关闭的发送功能,但是还能够接收数据
- 第二次挥手,服务端告诉客户端,我知道了,既然你不再发送数据,我就可以关闭接收数据的功能了。即:服务端关闭接收功能
- 这个过程中…服务端可能仍然有数据需要发送给客户端
- 第三次挥手,服务端把该发的数据都发送完了,再发送一个报文告诉客户端,我不再发送数据。即:服务端关闭发送功能
- 第四次挥手,客户端收到报文知道对方不再发送,就关闭接收功能,即:客户端关闭接收功能
服务端完整代码如下:
#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; }