linux 在进行网络应用程序开发时,常用到以下的 linux 网络 API:
socket()
:用于初始化一个新的套接字bind()
:用于将套接字与一个本地地址绑定listen()
:用于将套接字标记为被动套接字,接受来自客户端的连接请求accept()
:用于接受来自客户端的连接请求,并返回一个新的已连接套接字,与客户端进行通信connect()
:用于客户端连接到服务端的被动套接字,以建立和服务端连接send()
:发送数据到已连接套接字recv()
:从已连接套接字或接收数据setsockopt()
:设置套接字选项,如超时、缓冲区大小等。close()
:关闭套接字描述符,释放相关资源
使用这些函数我们可以创建各种类型的网络应用程序,如 Web 服务器、邮件服务器、聊天应用程序等等。
- socket 函数原理 {#title-0} =========================
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;
}
}
- bind 函数 {#title-1} =====================
函数 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;
}
}
- listen 函数 {#title-2} =======================
函数 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;
}
}
- accept 函数 {#title-3} =======================
函数 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));
}
- connect 函数 {#title-4} ========================
函数 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;
}
- send 函数 {#title-5} =====================
函数 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;
}
- recv 函数 {#title-6} =====================
函数 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;
}
- close 函数 {#title-7} ======================
函数 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;
}
- setsockopt 函数 {#title-8} ===========================
函数 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;
}