17、TCP Socket编程:从理论到实战
大家好,我是你们的嵌入式系统工程师老友。今天我们来聊聊TCP Socket编程。说实话,TCP这块内容,我在项目里踩过的坑比走过的路还多。但别怕,今天我把这些经验都掏出来,咱们一起把TCP吃透。
一、TCP协议的特点
TCP,全称传输控制协议。它是个面向连接的、可靠的、基于字节流的传输层协议。我习惯把它比作「打电话」——你得先拨号,对方接了才能说话,说完还得挂断。
TCP的几个核心特点:
- 面向连接:通信前必须建立连接,通信结束后释放连接
- 可靠传输:有确认、重传、排序机制,保证数据不丢、不乱
- 全双工通信:双方可以同时收发数据
- 字节流服务:TCP把数据看成无结构的字节流,不保留消息边界
重点提醒:TCP是「流」协议,不是「消息」协议。这一点直接导致了后面要讲的粘包问题。我在做第一个网络项目时,就因为没搞懂这个,调试了整整两天。
二、TCP Socket通信流程
TCP Socket通信,说白了就是一套固定的流程。服务端和客户端各走各的路,但最终要「握手」成功。
服务端流程:
- 创建socket
- 绑定地址和端口(bind)
- 监听连接(listen)
- 接受连接(accept)—— 这里会阻塞
- 收发数据(read/write)
- 关闭连接(close)
客户端流程:
- 创建socket
- 连接服务器(connect)
- 收发数据(read/write)
- 关闭连接(close)
嗯,这里要注意:accept和connect是配对的。服务端accept成功,客户端connect才真正完成。这就是所谓的「三次握手」。
我的小技巧:写代码时,每次调用系统函数后一定要检查返回值。我曾经因为没检查listen的返回值,在客户现场出了大问题。从那以后,我每条系统调用后面都跟着if判断。
三、TCP服务器的实现
一个基本的TCP服务器,核心代码大概这样:
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t addr_len = sizeof(client_addr);
char buffer[1024] = {0};
// 1. 创建socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket failed");
return -1;
}
// 2. 绑定地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind failed");
return -1;
}
// 3. 监听
if (listen(server_fd, 5) < 0) {
perror("listen failed");
return -1;
}
printf("Server listening on port 8080...\n");
// 4. 接受连接
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept failed");
return -1;
}
// 5. 收发数据
read(client_fd, buffer, 1024);
printf("Received: %s\n", buffer);
write(client_fd, "Hello from server", 17);
// 6. 关闭
close(client_fd);
close(server_fd);
return 0;
}
注意:上面的代码只是演示,实际项目中要处理各种错误情况。比如bind时端口被占用怎么办?accept时被信号中断怎么办?这些都要考虑。
四、TCP客户端的实现
客户端相对简单,核心是connect调用:
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <unistd.h>
int main() {
int sock_fd;
struct sockaddr_in server_addr;
char buffer[1024] = {0};
// 1. 创建socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {
perror("socket failed");
return -1;
}
// 2. 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// 3. 连接服务器
if (connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect failed");
return -1;
}
// 4. 收发数据
write(sock_fd, "Hello from client", 17);
read(sock_fd, buffer, 1024);
printf("Received: %s\n", buffer);
// 5. 关闭
close(sock_fd);
return 0;
}
你想想看,客户端和服务端配合起来,就是一个完整的通信过程。但实际项目中,一个服务器往往要服务多个客户端,这就引出了并发服务器。
五、TCP的并发服务器(fork)
最简单的并发方式就是用fork。每来一个客户端,就fork一个子进程去处理。父进程继续accept。
while (1) {
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd < 0) {
perror("accept");
continue;
}
pid_t pid = fork();
if (pid == 0) {
// 子进程
close(server_fd); // 子进程不需要监听socket
handle_client(client_fd);
close(client_fd);
exit(0);
} else if (pid > 0) {
// 父进程
close(client_fd); // 父进程不需要客户端socket
} else {
perror("fork failed");
}
}
避坑指南:我曾经在fork后忘记关闭不需要的文件描述符,结果服务器跑了几天后,文件描述符耗尽,再也无法accept新连接。记住:子进程要关闭server_fd,父进程要关闭client_fd。
六、TCP的粘包问题
粘包,说白了就是TCP不知道哪里是消息边界。你发送了两次数据,接收方可能一次就全读走了,也可能分好几次读。
为什么会这样?因为TCP是流协议。它只保证数据按顺序到达,但不保证每次recv到的数据正好对应一次send。
解决粘包的常见方法:
- 固定长度:每个消息固定大小,不够的补零
- 长度前缀:先发4字节的消息长度,再发消息内容
- 特殊分隔符:比如用\n或\r\n作为消息结束标志
我个人最推荐长度前缀法。它在嵌入式系统中效率高,解析也简单。
七、TCP的Keep-Alive机制
Keep-Alive,就是TCP的「心跳包」。当连接长时间没有数据交换时,系统会自动发送探测包,检查对方是否还活着。
默认情况下,Keep-Alive是关闭的。开启方式:
int keepalive = 1;
setsockopt(sock_fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
还可以设置探测参数:
- 空闲多久开始探测(TCP_KEEPIDLE)
- 探测间隔(TCP_KEEPINTVL)
- 探测次数(TCP_KEEPCNT)
我的经验:在嵌入式设备上,我一般把空闲时间设为60秒,间隔10秒,探测3次。这样如果设备断网,最多90秒就能检测到。太短了浪费资源,太长了影响用户体验。
八、TCP Socket综合案例
最后,我们来看一个综合案例。这是一个简单的聊天服务器,支持多个客户端同时在线,使用fork实现并发,用长度前缀法解决粘包问题。
// 消息格式:4字节长度 + 消息内容
void send_msg(int fd, const char *msg) {
uint32_t len = htonl(strlen(msg));
write(fd, &len, 4);
write(fd, msg, strlen(msg));
}
char* recv_msg(int fd) {
uint32_t len;
if (read(fd, &len, 4) <= 0) return NULL;
len = ntohl(len);
char *buf = malloc(len + 1);
read(fd, buf, len);
buf[len] = '\0';
return buf;
}
void handle_client(int fd) {
char *msg;
while ((msg = recv_msg(fd)) != NULL) {
printf("Received: %s\n", msg);
send_msg(fd, "ACK");
free(msg);
}
close(fd);
}
这个案例虽然简单,但包含了TCP编程的核心要素:并发、粘包处理、错误处理。实际项目中,你还要考虑信号处理、僵尸进程回收、资源限制等问题。
最后说一句:TCP编程,理论是一回事,实战是另一回事。我建议你亲手敲一遍代码,跑起来,然后故意制造一些异常情况看看程序怎么反应。只有踩过坑,才能真正掌握。