【网络编程】二、socket编程
事先说明:博客并不做规范的知识讲解,仅仅是用于我个人的笔记,可能并不适用于所有人,内容也会存在错误的可能性,大家可以指正并探讨
2 Socket
进程与进程间想要进行通信(数据交换),需要通过调用Socket来完成
Socket就是OS提供的一个系统调用,位于各种通信协议之上,是网络编程的入口
Socket可以提供同一个主机上的不同进程间的通信,不同主机上的进程间的通信等等
2.1 网络字节序与主机字节序
网络字节序
网络字节序是固定的大端字节序(Big-Endian),即数据的高位字节存储在低地址,低位字节存储在高地址。例如,对于 32 位整数0x12345678,在内存中的存储顺序为:
低地址 → 0x12 0x34 0x56 0x78 → 高地址
这种统一的格式确保了不同架构的设备在网络中传输数据时能正确解析。
主机字节序
主机字节序由 CPU 架构决定,分为两种:
不同主机的字节序可能不同,直接传输会导致数据解析错误(如端口号、IP 地址被误读)。
-
大端字节序:如 PowerPC、SPARC 等架构,与网络字节序一致;
-
小端字节序(Little-Endian):如x86、x86_64 等主流架构,数据的低位字节存储在低地址。例如,0x12345678在内存中的存储顺序为:
低地址 → 0x78 0x56 0x34 0x12 → 高地址
在网络协议头部处理(端口、IP)时必须转换,否则会导致数据解析错误(如端口号被解析为错误值)。
核心函数:htons()/htonl()(发送时)和ntohs()/ntohl()(接收时)
是网络编程的基础工具。
2.2 socket函数
Linux内核提供的socket的接口
#include <sys/types.h>
#include <sys/socket.h>int socket(int domain, int type, int protocol);
socket() 建立一个用于交流的端点并且返回一个描述符。
- domain:通信协议的类型,如IPv4、IPv6、本地通信、内核用户界面设备…
- type:连接类型,如UDP连接(流式),TCP连接(数据报)…
- protocol:协议类型,但一般设置为0,因为socket可自动根据
type
参数推导需要的协议
返回值
成功时,会返回新套接字的文件描述符。
错误时,返回 -1 ,同时 errno 会被适当设置。
因为linux一切皆文件,所以,进程进行通信可以看作是向一个文件读写数据。
当套接字创建成功时,会返回一个文件描述符,而系统的文件描述符0(stdin)、1(stdout)、2(stderr)
是默认打开的,所以返回的文件描述符一定从3
开始
在完成这个函数后,在系统看来仅仅是创建了一个文件,想要实现通信,还需要将这个文件和网络(网卡)关联起来
2.3 bind函数
#include <sys/types.h>
#include <sys/socket.h>int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
- sockfd:就是通过socket建立并返回得到的文件描述符(套接字)
- my_addr:需要绑定的
协议类型、IP地址、Port端口
- addrlen:my_addr指向的对象的字节长度
返回值
函数执行成功返回0,否则返回-1, 并设置错误代码.
从进程视角看,bind类似向内核提交 “网络服务登记表”,内核通过该表实现 “数据包→进程” 的精准路由,同时通过资源锁定机制避免冲突,是进程从 “本地运行” 走向 “网络交互” 的核心步骤。
因为一台主机,存在多个网卡,即会存在多个网络环境中,所以需要绑定IP,然后要将信息交付到指定的套接字,所以需要绑定Port,IP:Port
就为一个套接字确定了多个网络环境中的唯一身份标识
一个进程可以创建多个套接字socket(可以接受多个IP+Port的数据),一个套接字只能绑定唯一一个IP:Port
调用bind函数,会建立对应哈希表,当接收并解包数据时,可以利用对应的IP:Port:类型
获得对应的套接字指针,然后可以把这个数据包的指针载入对应套接字的队列中
协议 | 哈希表名称 | 键(K) | 值(V) | 核心用途 |
---|---|---|---|---|
UDP | udp_hash | {本地 IP, 本地端口,UDP} | struct udp_sock* | 精确匹配目标 IP:PORT 的 UDP 套接字 |
UDP | udp_port_hash | {本地端口,UDP} | 套接字链表头 | 处理广播 / 通配 IP 的 UDP 数据包 |
TCP | listen_hash | {本地 IP, 本地端口,TCP} | struct tcp_sock* | 匹配监听特定 IP:PORT 的 TCP 套接字 |
TCP | ehash | {源 IP, 源端口,目标 IP, 目标端口,TCP} | struct tcp_sock* | 匹配已建立连接的 TCP 数据包 |
TCP | bhash | {本地 IP, 本地端口,TCP} | struct inet_bind_bucket* | 检查端口绑定冲突 |
2.4 recv/recvfrom函数
在应用层,通过系统调用recvfrom实现,将数据从内核缓冲区加载到用户缓冲区
ssize_t recv(size_t size;int sockfd, void buf[size], size_t size,int flags);
ssize_t recvfrom(size_t size;int sockfd,//接收端的socket,用于将接收的数据接收指定的队列(缓冲区) void buf[restrict size], size_t size,//开辟的用户缓冲区空间及大小,用于接收对端发送的数据int flags,struct sockaddr *_Nullable restrict src_addr,//获得对端的socket,ip,port,不用时为空(TCP时需要,UDP时不需要/双向连接时需要,单向连接不用)socklen_t *_Nullable restrict addrlen);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
在TCP连接时,需要获得对端的socket,用于描述对端的socket的连接状态等一系列信息,用于判断是否可以发送数据
有个peer socket,就让进程认为直接从peer的socketaddr中获取接收的数据,因为TCP是需要两个进行通信的
或者一个进程需要维护多对双向连接
【接收数据全流程】主机接受数据,然后网卡中的数据链路层将比特信号转成帧信号,然后由数据链路层进一步封装成skb数据,并利用DMA直接复制到内核空间中…
2.5 send/sendto函数
ssize_t send(size_t size;int sockfd, const void buf[size], size_t size, int flags);
ssize_t sendto(size_t size;int sockfd, const void buf[size], size_t size, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
sendto
// 客户端无需bind(),直接发送数据
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
struct sockaddr_in server_addr = {.sin_family = AF_INET,.sin_port = htons(8080),.sin_addr.s_addr = inet_addr("192.168.1.100")
};
sendto(sockfd, "hello", 5, 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
当UDP协议时,客户端一般作为发送方,用于获取服务端的数据,所以不用显示的绑定port。
对于多数 UDP 场景(如 DNS 查询、日志发送),客户端只需关注 “向谁发送数据”,无需关心自身端口。内核自动绑定机制减少了不必要的代码
send的流程是上图发送的逆流程
- 获得发送的数据data,对端的
ip:port
,发送端的套接字sockfd - 通过系统调用,将data从用户缓冲区,加载到内核中的sockfd指向的缓冲区,然后内核会将data封装成skb,然后把skb的指针传给网络协议栈
- 传输层、网络层、数据链路层,通过获取的skb的指针进行逐层封包
- DMA直接从内核sockfd缓冲区加载到网卡上的DMA缓冲区(网卡驱动预先分配,实际在主机的内存上)
- 网卡读取DMA缓冲区,转成电信号后直接发送,然后释放资源
【通过指针完成】在网络协议栈对 skb 进行逐层封包和解包的过程中,skb 所指向的底层数据缓冲区的物理地址和内核虚拟地址均不会改变,变化的是 skb 结构体中用于标识数据边界的指针(如data、head、tail等)。
INADDR_ANY
对于云服务器而言,其IP由其服务器厂商提供,IP不能直接被绑定,如果需要绑定,只能绑定INADDR_ANY=0.0.0.0
,即将一个套接字PORT绑定所有的网络,任何访问指定PORT的数据都可以被接收,要求{协议,port}
一致
绑定INADDR_ANY(0.0.0.0): 表示套接字监听本机所有网络接口的指定端口,此时任何 IP地址发送到该端口的数据包(匹配协议类型,如 TCP/UDP)都会被接收。 例如:绑定INADDR_ANY:8080的 UDP套接字,会接收来自192.168.1.1、10.0.0.5等所有 IP 发送到8080端口的 UDP 包。
2.6 listen函数
在一个套接字上,监听,并获得一个TCP连接
#include <sys/socket.h>int listen( int s, //监听的套接字 int backlog); //可容纳连接队列的最大长度
返回值
函数执行成功时返回0.错误时返回-1,并置相应错误代码. errno
TCP连接步骤
- 创建一个TCP协议的套接字
- 绑定一个合适的IP与Port
- 使用listen监听该套接字,并构建一个连接的队列,最大可以容纳backlog个连接套接字
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址和端口
struct sockaddr_in server_addr = { ... };
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 转为监听状态,允许最多10个未完成连接
listen(listen_fd, 10);
2.7 accept函数
从当前已连接的套接字上,获取一个新的连接/套接字(文件描述符)
#include <sys/types.h>
#include <sys/socket.h>int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
返回值
此调用在发生错误时返回-1.若成功则返回一个非负整数标识这个 连接套接字.
for(;;)
{struct sockaddr_in client_addr;socklen_t client_len = sizeof(client_addr);// 从已完成队列中取出连接,获取客户端地址int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);if (client_fd == -1) {perror("accept failed");exit(1);}// 通过 client_fd 与客户端通信(read/write/send/recv)
}
2.8 connect函数
int connect( int sockfd, const struct sockaddr *addr,socklen_t addrlen);
通过sockfd套接字,与addr指向的IP:Port
建立连接,传输协议类型为TCP
返回值
如果连接或绑定成功,则返回零。出错时, 返回 -1,并设置 errno 以指示错误。
2.9 read/write函数
read
在文件描述符上执行读操作
#include <unistd.h>ssize_t read( int fd, //指定的文件描述符,网络上就是套接字(sockfc)void *buf, //读取到的数据放入的缓冲区size_t count); //期望读取的字节数量(缓冲区最大的字节数量)
返回值
成功时返回读取到的字节数(为零表示读到文件描述符), 此返回值受文件剩余字节数限制.
当返回值小于指定的字节数时 并不意味着错误;这可能是因为当前可读取的字节数小于指定的 字节数(比如已经接近文件结尾,或者正在从管道或者终端读取数 据,或者 read()被信号中断).
返回值等于0时,说明对端关闭连接
发生错误时返回-1,并置 errno 为相应值.
write
在一个文件描述符上执行写操作
#include <unistd.h>ssize_t write(int fd, const void *buf, size_t count);
同时,客户端/服务端可以对于一个socket进行读写数据,因为其封装后的模式是全双工的
2.10 TCP协议流程
三次握手-建立连接
listen/connect/accept在三次握手中的配合流程
- 服务器:socket() → bind() → listen() → 进入监听状态,等待连接请求。
- 客户端:socket() → connect() → 发送 SYN 报文,触发三次握手,connect()调用。
- 服务器:收到 SYN 后,内核自动回应 SYN+ACK,将连接放入请求队列。
- 客户端:收到 SYN+ACK 后,内核自动发送 ACK,三次握手完成,connect() 返回。
- 服务器:accept() 从队列中取出已完成的连接,返回新套接字,服务器与客户端开始数据传输。
双方发送数据
发送方
- write()->写入Data,进行数据发送
- read()->阻塞等待对端数据应答
- read()返回,获得ACK应答
接收方
- read()->阻塞等待对端的数据
- read()返回,表明收到数据
- write()->写入数据应答ACK