2、TCP Socket编程实战:服务端创建与回声服务器

好,咱们正式开始动手写代码。这一章我会带着你从零搭建一个完整的TCP回声服务器——说白了就是客户端发什么,服务端原样返回什么。别看它简单,这里面把Socket编程的核心流程全走了一遍。

本章核心脉络:服务端四步曲(socket→bind→listen→accept)→ 客户端两步走(socket→connect)→ 数据收发(send/recv)→ 完整回声服务器实现

TCP Socket 通信流程 服务端 socket() bind() listen() accept() recv() / send() close() 客户端 socket() connect() send() / recv() close() 三次握手 数据流 核心流程:服务端监听 → 客户端连接 → 数据收发 → 连接关闭

2.1 服务端创建:socket → bind → listen → accept

服务端的创建,我习惯把它叫做「四步曲」。每一步都有它的意义,少一步都不行。

2.1.1 socket() —— 创建套接字

socket() 说白了就是向操作系统申请一个「网络文件描述符」。你想想看,Linux 下一切皆文件,网络通信也不例外。

#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket");
    exit(EXIT_FAILURE);
}
参数取值含义
domainAF_INETIPv4 协议族
typeSOCK_STREAM面向连接的流式套接字(TCP)
protocol0自动选择对应协议(TCP)

个人经验:我早期写代码时总喜欢把第三个参数写成 IPPROTO_TCP,其实传 0 就够了。内核会根据前两个参数自动匹配,多写反而容易出错。

2.1.2 bind() —— 绑定地址和端口

bind() 就是把 socket 和具体的 IP、端口绑在一起。没有这一步,客户端就找不到你的服务。

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
serv_addr.sin_port = htons(8888);               // 端口号

if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
    perror("bind");
    close(sockfd);
    exit(EXIT_FAILURE);
}

注意字节序:网络字节序是大端(Big-Endian),而 x86 主机是小端。htonl() 和 htons() 就是做这个转换的。我曾经见过一个同事忘了用 htons,结果端口死活绑不上——嗯,排查了半天才发现是字节序的问题。

2.1.3 listen() —— 进入监听状态

listen() 告诉内核:这个 socket 可以接受连接请求了。第二个参数是 backlog,表示连接请求队列的最大长度。

if (listen(sockfd, 5) < 0) {
    perror("listen");
    close(sockfd);
    exit(EXIT_FAILURE);
}

backlog 设多少合适?我个人习惯在开发环境设 5,生产环境至少 128。但要注意,这个值不是越大越好——太大了反而会掩盖连接风暴的问题。

2.1.4 accept() —— 接受客户端连接

accept() 是个阻塞函数。它会一直等,直到有客户端连上来,然后返回一个新的 socket 用于通信。

struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
if (connfd < 0) {
    perror("accept");
    close(sockfd);
    exit(EXIT_FAILURE);
}

关键点:accept() 返回的是一个新的文件描述符(connfd),原来的 sockfd 还在继续监听。也就是说,一个服务端可以同时处理多个连接——当然,这需要多线程或 IO 多路复用的配合,我们后面会讲。

2.2 客户端创建:socket → connect

客户端就简单多了,两步搞定。

2.2.1 socket() —— 创建套接字

和服务端一样,先申请一个 socket。

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

2.2.2 connect() —— 发起连接

connect() 会触发 TCP 的三次握手。如果服务端没在 listen,connect 就会失败。

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
    perror("connect");
    close(sockfd);
    exit(EXIT_FAILURE);
}

避坑指南:inet_pton() 比 inet_addr() 更安全,它支持 IPv4 和 IPv6,而且返回值能告诉你地址格式是否合法。我建议你养成用 inet_pton 的习惯。

2.3 数据收发:send / recv

连接建立之后,就可以用 send() 和 recv() 来传数据了。这两个函数本质上是对 read() 和 write() 的封装,但多了 flags 参数。

// 发送数据
char *msg = "Hello, Server!";
int n = send(connfd, msg, strlen(msg), 0);
if (n < 0) {
    perror("send");
}

// 接收数据
char buf[1024] = {0};
int n = recv(connfd, buf, sizeof(buf) - 1, 0);
if (n > 0) {
    printf("Received: %s\n", buf);
} else if (n == 0) {
    printf("Connection closed\n");
} else {
    perror("recv");
}
返回值含义处理方式
> 0成功收发 n 字节正常处理数据
0对方关闭连接关闭 socket,退出循环
-1出错(或 EINTR 信号中断)检查 errno,决定重试或退出

重要提醒:send() 和 recv() 不保证一次就能发完或收完所有数据。TCP 是流式协议,数据可能被拆分或合并。实际项目中一定要用循环来确保完整收发——这个我们后面专门讲「粘包与拆包」时会深入。

2.4 实战:完整回声服务器

好了,把上面的知识点串起来,就是一个完整的回声服务器。客户端发什么,服务端就回什么。

2.4.1 服务端代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sockfd, connfd;
    struct sockaddr_in serv_addr, cli_addr;
    socklen_t cli_len = sizeof(cli_addr);
    char buf[BUFFER_SIZE];

    // 1. socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) { perror("socket"); exit(1); }

    // 2. bind
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(PORT);
    if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("bind"); close(sockfd); exit(1);
    }

    // 3. listen
    if (listen(sockfd, 5) < 0) {
        perror("listen"); close(sockfd); exit(1);
    }
    printf("Echo server listening on port %d...\n", PORT);

    // 4. accept
    connfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
    if (connfd < 0) { perror("accept"); close(sockfd); exit(1); }
    printf("Client connected.\n");

    // 5. echo loop
    while (1) {
        memset(buf, 0, BUFFER_SIZE);
        int n = recv(connfd, buf, BUFFER_SIZE - 1, 0);
        if (n <= 0) {
            printf("Client disconnected.\n");
            break;
        }
        printf("Received: %s", buf);
        send(connfd, buf, n, 0);
    }

    close(connfd);
    close(sockfd);
    return 0;
}

2.4.2 客户端代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in serv_addr;
    char buf[BUFFER_SIZE];

    // 1. socket
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) { perror("socket"); exit(1); }

    // 2. connect
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
    if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("connect"); close(sockfd); exit(1);
    }
    printf("Connected to server.\n");

    // 3. send & recv
    while (1) {
        printf("Enter message: ");
        fgets(buf, BUFFER_SIZE, stdin);
        send(sockfd, buf, strlen(buf), 0);

        memset(buf, 0, BUFFER_SIZE);
        int n = recv(sockfd, buf, BUFFER_SIZE - 1, 0);
        if (n <= 0) break;
        printf("Echo: %s", buf);
    }

    close(sockfd);
    return 0;
}

运行效果:先启动服务端,再启动客户端。客户端输入 "Hello",服务端返回 "Hello"。输入 "exit" 也一样会原样返回——这就是回声服务器的全部逻辑。

2.5 常见问题与避坑

  • Address already in use:服务端关闭后立即重启,bind 会失败。这是因为 TIME_WAIT 状态还没结束。解决方案是用 setsockopt() 设置 SO_REUSEADDR。
  • Connection refused:客户端连不上,多半是服务端没启动或者端口不对。用 netstat -an | grep 8888 检查一下。
  • recv 返回 -1 但 errno 是 EINTR:被信号中断了,重新调用 recv 即可。这不是真正的错误。

我的习惯:每次写 Socket 程序,我都会在关键步骤后加 perror() 或 printf() 打印状态。调试阶段多打一行日志,能省下几小时的排查时间。

这一章的内容就到这里。代码虽然简单,但它是所有复杂网络应用的基础。你把这个回声服务器跑通了,后面的多线程、IO 多路复用、epoll 就都有了落脚点。