TCP Socket 编程:服务端与客户端核心流程
这一章我们聊聊 TCP Socket 编程里最基础、也最核心的部分——服务端和客户端怎么建立连接,以及数据怎么收发。说白了,就是那四个经典的系统调用:socket、bind、listen、accept,再加上客户端的 socket 和 connect,最后是 send 和 recv。
嗯,这些接口我几乎每天都在用。但说实话,很多人只是机械地调用它们,并不清楚每个步骤背后发生了什么。今天我就带你走一遍完整的流程,顺便聊聊我踩过的坑。
核心要点:TCP 服务端和客户端是一对「被动等待」与「主动发起」的关系。服务端先启动,绑定地址,监听端口,然后阻塞等待连接;客户端主动发起连接请求。三次握手完成后,双方才能开始收发数据。
服务端创建流程:socket → bind → listen → accept
服务端的创建流程,我习惯把它比作「开一家店」。你得先有个店面(socket),然后挂上招牌(bind),告诉别人你在这营业(listen),最后等着客人上门(accept)。
1. socket():创建套接字
socket() 是第一步。它创建一个通信端点,返回一个文件描述符。你想想看,在 Linux 里一切皆文件,网络连接也不例外。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
参数很简单:AF_INET 表示 IPv4,SOCK_STREAM 表示 TCP,第三个参数传 0 让系统自动选择协议。嗯,这里要注意,SOCK_STREAM 对应的是 TCP,SOCK_DGRAM 才是 UDP,别搞混了。
个人经验:我刚开始写 Socket 程序时,经常忘记检查 socket() 的返回值。有一次在嵌入式设备上跑,系统文件描述符上限很低,socket() 返回 -1 我都没发现,结果后面所有操作都报错。从那以后,我每个系统调用都会检查返回值。
2. bind():绑定地址和端口
bind() 把套接字和一个具体的 IP 地址、端口号绑定在一起。说白了,就是给你的「店面」一个固定的门牌号。
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网络接口
server_addr.sin_port = htons(8080); // 端口号
if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
exit(EXIT_FAILURE);
}
这里有几个坑,我一个个说。
- 字节序问题:网络字节序是大端(Big Endian),而 x86 机器是小端。所以要用
htons()和htonl()转换。我曾经见过有人直接传 8080,结果 bind 到了一个莫名其妙的端口上。 - INADDR_ANY:表示监听所有网络接口。如果你只想监听某个特定 IP,可以传具体的地址。
- 端口冲突:如果端口已经被占用,
bind()会返回 -1,错误码是EADDRINUSE。
避坑指南:我曾经在调试一个服务端程序时,每次重启都报 Address already in use。原因是 TCP 的 TIME_WAIT 状态。解决方案是在 socket() 之后、bind() 之前设置 SO_REUSEADDR 选项:
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
3. listen():监听连接
listen() 把套接字从「主动」模式切换到「被动」模式,告诉内核这个套接字是用来接受客户端连接的。同时,它指定了连接请求队列的最大长度。
if (listen(sockfd, 10) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
第二个参数是 backlog,表示内核为这个套接字排队的最大连接数。嗯,这里有个细节:backlog 到底指什么?
| 概念 | 说明 |
|---|---|
| 未完成连接队列 | 正在三次握手、尚未完成的连接(SYN_RCVD 状态) |
| 已完成连接队列 | 三次握手完成、等待 accept() 取走的连接(ESTABLISHED 状态) |
| backlog | 两个队列长度之和的上限(具体实现有差异) |
我个人习惯把 backlog 设大一点,比如 128 或 256,尤其是在高并发场景下。太小的话,客户端连接会被内核直接拒绝。
4. accept():接受连接
accept() 从已完成连接队列中取出一个连接,返回一个新的套接字文件描述符。这个新套接字专门用于和这个客户端通信,而原来的监听套接字继续等待其他连接。
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("新连接来自 %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
注意,accept() 默认是阻塞的。如果没有新连接,它会一直等在那里。你想想看,这在高并发场景下肯定不行。后面我们会讲怎么用非阻塞 I/O 或者多线程来解决。
关键理解:accept() 返回的是一个新的文件描述符,不是原来的监听套接字。原来的监听套接字还在继续监听。每个客户端连接都有一个独立的套接字,这样服务端才能同时和多个客户端通信。
客户端创建流程:socket → connect
客户端的流程简单得多。你只需要创建一个套接字,然后主动去连接服务端。
1. socket():创建套接字
和服务端一样,客户端也需要先创建一个套接字。参数完全一样:AF_INET、SOCK_STREAM、0。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
2. connect():发起连接
connect() 是客户端主动发起连接的关键一步。它会触发 TCP 三次握手。
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
exit(EXIT_FAILURE);
}
这里有几个要点:
- 目标地址:用
inet_pton()把点分十进制字符串转换成网络字节序的二进制地址。老代码可能用inet_addr(),但那个函数已经废弃了。 - 阻塞行为:
connect()默认是阻塞的,直到三次握手完成或超时失败才会返回。 - 错误处理:如果服务端没启动、端口不对、或者网络不通,
connect()会返回 -1。常见的错误码有ECONNREFUSED(连接被拒绝)、ETIMEDOUT(超时)、EHOSTUNREACH(主机不可达)。
个人经验:我在写客户端程序时,习惯给 connect() 设置一个超时时间。默认情况下,如果服务端没响应,客户端可能会等很久(比如几十秒)。用非阻塞套接字配合 select() 或 poll() 可以实现自定义超时,这个后面会详细讲。
数据收发:send() 和 recv()
连接建立之后,就可以用 send() 和 recv() 收发数据了。这两个函数是 TCP 数据收发的核心。
send():发送数据
const char *msg = "Hello, Server!";
ssize_t sent = send(client_fd, msg, strlen(msg), 0);
if (sent < 0) {
perror("send");
// 处理错误
} else {
printf("成功发送 %zd 字节\n", sent);
}
send() 的返回值是实际发送的字节数。注意,它可能小于你请求发送的字节数。嗯,这种情况在非阻塞模式下很常见。你需要循环发送,直到所有数据都发完。
recv():接收数据
char buffer[1024];
ssize_t received = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (received > 0) {
buffer[received] = '\0';
printf("收到数据: %s\n", buffer);
} else if (received == 0) {
printf("对方关闭了连接\n");
close(client_fd);
} else {
perror("recv");
}
recv() 的返回值有三种情况:
| 返回值 | 含义 | 处理方式 |
|---|---|---|
| > 0 | 成功收到数据,返回实际字节数 | 处理数据,继续接收 |
| 0 | 对方关闭了连接(FIN 包) | 关闭套接字,清理资源 |
| -1 | 出错 | 检查 errno,处理错误 |
避坑指南:TCP 是流式协议,没有消息边界。你发送 100 字节,对端可能一次收到 100 字节,也可能分两次收到 50+50 字节。我曾经在做一个聊天程序时,假设一次 recv() 就能收到完整消息,结果出现了消息粘连和截断的问题。解决方案是自定义应用层协议,比如在每个消息前面加上长度字段。
完整示例:一个简单的回声服务端和客户端
说了这么多,不如看一个完整的例子。下面是一个简单的回声服务端——它把客户端发来的数据原样返回。
服务端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
// 1. socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) { perror("socket"); exit(1); }
// 设置 SO_REUSEADDR
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 2. bind
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8080);
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind"); close(listen_fd); exit(1);
}
// 3. listen
if (listen(listen_fd, 10) < 0) {
perror("listen"); close(listen_fd); exit(1);
}
printf("服务端已启动,监听端口 8080...\n");
// 4. accept
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 < 0) { perror("accept"); close(listen_fd); exit(1); }
printf("新连接来自 %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
// 5. 数据收发
char buffer[1024];
ssize_t n;
while ((n = recv(client_fd, buffer, sizeof(buffer), 0)) > 0) {
send(client_fd, buffer, n, 0); // 原样返回
}
if (n == 0) {
printf("客户端关闭了连接\n");
} else {
perror("recv");
}
close(client_fd);
close(listen_fd);
return 0;
}
客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
// 1. socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }
// 2. connect
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (connect(sockfd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect"); close(sockfd); exit(1);
}
printf("已连接到服务端\n");
// 3. 发送数据
const char *msg = "Hello, TCP Server!";
send(sockfd, msg, strlen(msg), 0);
printf("已发送: %s\n", msg);
// 4. 接收回声
char buffer[1024];
ssize_t n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
if (n > 0) {
buffer[n] = '\0';
printf("收到回声: %s\n", buffer);
}
close(sockfd);
return 0;
}
运行步骤:先在一个终端运行服务端程序,然后在另一个终端运行客户端程序。你会看到客户端发送的消息被服务端原样返回。这就是最简单的 TCP 通信模型。
本章小结
这一章我们走完了 TCP Socket 编程的完整流程。从服务端的 socket → bind → listen → accept,到客户端的 socket → connect,再到数据收发的 send/recv。嗯,这些是 Socket 编程的基石,后面的所有高级话题——多线程、事件驱动、性能优化——都建立在这个基础之上。
我个人建议你亲手敲一遍上面的代码,跑起来看看效果。编程这东西,光看是学不会的。遇到问题不要怕,每个坑都是成长的机会。