4. Socket编程入门:Socket概念、地址结构与常用API
4.1 从“管道”到“插座”:理解Socket的本质
在TCP/IP网络中,两个进程要通信,必须经过操作系统内核的网络协议栈。Socket(套接字)正是操作系统提供给应用程序的编程接口,它把复杂的TCP/IP协议细节封装起来,让开发者像操作文件一样操作网络连接。之所以叫“套接字”,是因为它一端“插”在应用进程上,另一端“接”在协议栈上,形成一个双向的数据通道。
从数据流的角度看,Socket是应用层与传输层之间的“门”。发送数据时,应用把字节流写入Socket,内核负责按TCP或UDP协议封装、路由并发送;接收数据时,内核把到达的数据按序放入Socket接收缓冲区,应用通过read/recv取走。理解这一点,就能明白为什么Socket编程中“缓冲区”“阻塞”“非阻塞”如此重要。
4.2 地址结构:sockaddr_in 与网络字节序
Socket编程中,地址结构是连接“IP+端口”与系统调用之间的桥梁。IPv4环境下最常用的是 struct sockaddr_in,它定义在 <netinet/in.h> 中。其核心字段包括:sin_family(地址族,AF_INET)、sin_port(16位端口号)、sin_addr(32位IPv4地址)。
这里有一个极易踩坑的点:网络字节序。TCP/IP协议规定,多字节整数在网络上传输时必须使用大端序(Big-Endian)。而x86等主流CPU使用小端序(Little-Endian)。因此,在填充端口号和IP地址时,必须调用转换函数:
htons():Host to Network Short,将主机字节序的16位整数转为网络字节序(常用于端口号)。htonl():Host to Network Long,将主机字节序的32位整数转为网络字节序(常用于IP地址)。ntohs()/ntohl():反向转换,用于从网络数据中解析端口和IP。
// 服务端地址结构初始化示例
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr)); // 清零
server_addr.sin_family = AF_INET; // IPv4协议族
server_addr.sin_port = htons(8080); // 端口8080,转为网络字节序
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有本地接口
server_addr.sin_port = 8080; 而不调用 htons(),在小端机器上实际端口会变成 0x901F(即 0x1F90 的字节序反转),导致 bind 失败或监听在错误端口。同理,inet_pton(AF_INET, "192.168.1.10", &server_addr.sin_addr) 会自动完成字节序转换,而手动赋值时必须使用 htonl()。
4.3 核心API调用流程:从socket到accept
TCP服务端的标准调用序列是:socket() → bind() → listen() → accept()。客户端则是:socket() → connect()。下面逐一说明每个调用的作用与关键参数。
| API | 函数原型 | 核心作用 | 关键参数 |
|---|---|---|---|
| socket() | int socket(int domain, int type, int protocol) |
创建套接字描述符 | domain=AF_INET, type=SOCK_STREAM, protocol=0 |
| bind() | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen) |
绑定本地IP和端口 | addr需强转为sockaddr* |
| listen() | int listen(int sockfd, int backlog) |
将套接字转为被动监听状态 | backlog为连接队列最大长度 |
| accept() | int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) |
从队列取出一个已完成握手的连接 | 返回新的已连接套接字fd |
| connect() | int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) |
客户端主动发起TCP连接 | addr为目标服务器地址 |
一个典型的服务端骨架代码如下:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); exit(1); }
// 设置SO_REUSEADDR,避免TIME_WAIT导致bind失败
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); exit(1);
}
if (listen(listen_fd, 128) < 0) { // backlog=128
perror("listen"); exit(1);
}
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (conn_fd < 0) { perror("accept"); exit(1); }
4.4 案例:实现一个最简单的回声服务器
下面通过一个完整的回声服务器(Echo Server)串联上述API。客户端发送什么,服务端就原样返回什么。这个案例能直观验证Socket通信链路是否打通。
// ===== 服务端 echo_server.c =====
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in srv, cli;
socklen_t cli_len = sizeof(cli);
char buf[1024];
srv.sin_family = AF_INET;
srv.sin_port = htons(9000);
srv.sin_addr.s_addr = htonl(INADDR_ANY);
bind(listen_fd, (struct sockaddr*)&srv, sizeof(srv));
listen(listen_fd, 5);
printf("Echo server listening on port 9000...\n");
int conn_fd = accept(listen_fd, (struct sockaddr*)&cli, &cli_len);
printf("Client connected: %s:%d\n",
inet_ntoa(cli.sin_addr), ntohs(cli.sin_port));
while (1) {
int n = read(conn_fd, buf, sizeof(buf));
if (n <= 0) break;
write(conn_fd, buf, n); // 原样返回
}
close(conn_fd);
close(listen_fd);
return 0;
}
// ===== 客户端 echo_client.c =====
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in srv;
char buf[1024];
srv.sin_family = AF_INET;
srv.sin_port = htons(9000);
inet_pton(AF_INET, "127.0.0.1", &srv.sin_addr);
if (connect(sock, (struct sockaddr*)&srv, sizeof(srv)) < 0) {
perror("connect"); return 1;
}
printf("Connected to server. Type 'quit' to exit.\n");
while (1) {
printf("> ");
fgets(buf, sizeof(buf), stdin);
write(sock, buf, strlen(buf));
if (strncmp(buf, "quit", 4) == 0) break;
int n = read(sock, buf, sizeof(buf));
buf[n] = '\0';
printf("Echo: %s", buf);
}
close(sock);
return 0;
}
编译运行:先在一个终端启动 ./echo_server,再在另一个终端启动 ./echo_client。输入任意字符串,客户端会收到服务端返回的相同内容。输入 quit 退出连接。
4.5 误区与复盘:初学者的五个典型错误
htons()/htonl(),导致bind到错误端口或连接失败。复盘:所有从主机填入地址结构的数值,都必须经过主机序→网络序转换。
Address already in use。复盘:在bind前调用 setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ...) 可解决。
listen_fd 只负责接受新连接,不能用来收发数据;accept() 返回的 conn_fd 才是真正通信的套接字。复盘:关闭时两者都要close,但作用完全不同。
accept() 会一直阻塞。初学者常误以为程序卡死。复盘:这是正常行为,可用多线程或IO多路复用处理并发。
bind()/connect() 的第二参数要求 struct sockaddr*,而实际使用的是 struct sockaddr_in*,必须显式强转。复盘:这是C语言“伪继承”的设计,所有地址族共用同一个接口。
socket → bind → listen → accept(服务端)和 socket → connect(客户端)的调用链后,就可以搭建最基本的TCP通信程序。下一阶段将在此基础上引入多进程/多线程并发模型,以及IO多路复用技术,解决“一个服务端服务多个客户端”的问题。