17、TCP Socket编程:从理论到实战

大家好,我是你们的嵌入式系统工程师老友。今天我们来聊聊TCP Socket编程。说实话,TCP这块内容,我在项目里踩过的坑比走过的路还多。但别怕,今天我把这些经验都掏出来,咱们一起把TCP吃透。

一、TCP协议的特点

TCP,全称传输控制协议。它是个面向连接的、可靠的、基于字节流的传输层协议。我习惯把它比作「打电话」——你得先拨号,对方接了才能说话,说完还得挂断。

TCP的几个核心特点:

  • 面向连接:通信前必须建立连接,通信结束后释放连接
  • 可靠传输:有确认、重传、排序机制,保证数据不丢、不乱
  • 全双工通信:双方可以同时收发数据
  • 字节流服务:TCP把数据看成无结构的字节流,不保留消息边界

重点提醒:TCP是「流」协议,不是「消息」协议。这一点直接导致了后面要讲的粘包问题。我在做第一个网络项目时,就因为没搞懂这个,调试了整整两天。

二、TCP Socket通信流程

TCP Socket通信,说白了就是一套固定的流程。服务端和客户端各走各的路,但最终要「握手」成功。

服务端流程:

  1. 创建socket
  2. 绑定地址和端口(bind)
  3. 监听连接(listen)
  4. 接受连接(accept)—— 这里会阻塞
  5. 收发数据(read/write)
  6. 关闭连接(close)

客户端流程:

  1. 创建socket
  2. 连接服务器(connect)
  3. 收发数据(read/write)
  4. 关闭连接(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编程,理论是一回事,实战是另一回事。我建议你亲手敲一遍代码,跑起来,然后故意制造一些异常情况看看程序怎么反应。只有踩过坑,才能真正掌握。

TCP Socket编程知识体系 TCP Socket编程 协议特点 通信流程 服务器实现 客户端实现 并发服务器(fork) 粘包问题 Keep-Alive机制 综合案例 固定长度 长度前缀 特殊分隔符 空闲时间/间隔/次数 聊天服务器 核心:面向连接、可靠传输、字节流服务 实战要点:错误处理、资源管理、边界条件

公众号:蓝海资料掘金营,微信deep3321