TCP Socket 编程:服务端与客户端核心流程

这一章我们聊聊 TCP Socket 编程里最基础、也最核心的部分——服务端和客户端怎么建立连接,以及数据怎么收发。说白了,就是那四个经典的系统调用:socket、bind、listen、accept,再加上客户端的 socket 和 connect,最后是 send 和 recv。

嗯,这些接口我几乎每天都在用。但说实话,很多人只是机械地调用它们,并不清楚每个步骤背后发生了什么。今天我就带你走一遍完整的流程,顺便聊聊我踩过的坑。

核心要点:TCP 服务端和客户端是一对「被动等待」与「主动发起」的关系。服务端先启动,绑定地址,监听端口,然后阻塞等待连接;客户端主动发起连接请求。三次握手完成后,双方才能开始收发数据。

TCP Socket 编程核心流程 TCP Socket 编程核心流程 服务端 socket() bind() listen() accept() recv() / send() 客户端 socket() connect() send() / recv() 三次握手 数据流 💡 关键说明: • 服务端先启动,调用 socket → bind → listen → accept,阻塞等待客户端连接。 • 客户端调用 socket → connect,主动发起连接请求,完成三次握手后建立连接。 • 连接建立后,双方通过 send/recv 进行全双工数据收发。

服务端创建流程: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 编程的基石,后面的所有高级话题——多线程、事件驱动、性能优化——都建立在这个基础之上。

我个人建议你亲手敲一遍上面的代码,跑起来看看效果。编程这东西,光看是学不会的。遇到问题不要怕,每个坑都是成长的机会。