2. 第一个TCP Socket程序:从零搭建一次完整通信

说实话,很多初学者学Socket,上来就背API,结果写出来的程序要么连不上,要么一跑就崩。我个人习惯是——先动手写一个能跑通的例子,再回头理解每一行代码的意义。这一章我们就来干这件事。

我们会写两个程序:一个服务端,一个客户端。服务端等着别人来连,客户端主动去连。连上之后,客户端发一句"你好",服务端收到后回一句"收到",然后两边各自关闭。就这么简单,但背后涉及了Socket编程最核心的七个步骤。

本章核心流程:服务端创建Socket → 绑定地址与端口 → 监听 → 接受连接 → 客户端创建Socket → 连接服务器 → 发送/接收数据 → 关闭Socket

TCP Socket 通信流程 TCP Socket 通信流程 服务端 1. socket() 创建 2. bind() 绑定地址端口 3. listen() 监听 4. accept() 等待连接 5. recv() / send() 通信 6. close() 关闭 客户端 1. socket() 创建 2. connect() 连接服务器 3. send() / recv() 通信 4. close() 关闭 三次握手 数据收发 服务端先启动,调用 accept() 阻塞等待 客户端调用 connect() 发起三次握手

2.1 服务端:先搭好架子

服务端是"被动方"。它得先启动,然后一直等着。你想想看,如果服务端还没起来客户端就发请求,那肯定连不上。所以顺序很重要。

2.1.1 创建Socket

第一步,调用 socket() 函数。它返回一个文件描述符,后面所有操作都靠它。

#include <sys/socket.h>
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
    perror("socket");
    exit(1);
}

参数解释:

  • AF_INET:IPv4协议族。你要是用IPv6就写AF_INET6。
  • SOCK_STREAM:流式套接字,对应TCP。SOCK_DGRAM才是UDP。
  • 0:协议自动选择。TCP的话填0就行。

我的习惯:每次创建完socket,我都会检查返回值。曾经有一次我忘了检查,结果后面bind一直失败,排查了半天才发现是socket没创建成功。嗯,血的教训。

2.1.2 绑定地址与端口

Socket创建好了,但它还没跟任何地址关联。你得告诉操作系统:"我这个Socket要监听哪个IP和哪个端口"。这就是 bind() 干的事。

#include <netinet/in.h>

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);               // 端口8888

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

这里有几个坑,我一个个说:

  • htonl / htons:主机字节序转网络字节序。说白了,你的电脑可能是小端序,网络是大端序,得转一下。我刚开始学的时候老忘,结果bind上去的端口跟我想的不一样。
  • INADDR_ANY:表示监听本机所有IP地址。如果你只想让某个特定IP连,可以写成 inet_addr("192.168.1.100")。
  • 端口号:1024以下需要root权限。我建议你开发时用8000-9999之间的端口,省得每次都要sudo。

注意:如果程序崩溃退出后立刻重启,可能会报"Address already in use"。这是因为端口还没释放。你可以设置SO_REUSEADDR选项来解决,后面章节会讲。

2.1.3 监听连接

绑定完之后,Socket还不会主动接收连接。你得调用 listen() 告诉内核:"我准备好了,有人连我就通知我"。

if (listen(sockfd, 5) < 0) {
    perror("listen");
    exit(1);
}
printf("Server listening on port 8888...\n");

第二个参数是backlog,表示连接请求队列的最大长度。比如填5,就意味着最多有5个客户端在排队等待accept。超过的会被拒绝。我在项目中一般设128或256,具体看并发量。

2.1.4 接受客户端连接

这一步会阻塞。程序停在这里,直到有客户端连上来。

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");
    exit(1);
}
printf("Client connected: %s:%d\n",
       inet_ntoa(cli_addr.sin_addr),
       ntohs(cli_addr.sin_port));

accept() 返回一个新的Socket描述符 connfd。原来的 sockfd 继续监听,新的 connfd 用来跟这个客户端通信。这是多并发的关键——每个客户端都有自己的专属通道。

2.2 客户端:主动出击

客户端就简单多了。它不需要bind,也不需要listen。直接创建Socket,然后connect过去就行。

2.2.1 创建Socket

跟服务端一模一样:

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

2.2.2 连接服务器

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");  // 连接本机
serv_addr.sin_port = htons(8888);

if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
    perror("connect");
    exit(1);
}
printf("Connected to server.\n");

connect() 会触发TCP三次握手。如果服务器没启动,或者端口不对,connect会返回-1。我曾经在调试时把端口写成了8889,结果客户端一直卡在那里,超时后才报错。嗯,这种低级错误谁都犯过。

2.3 发送与接收数据

连接建立之后,两边就可以用 send() 和 recv() 通信了。这两个函数跟读写文件差不多,因为Socket在Linux里本质上就是一个文件描述符。

2.3.1 客户端发送

char *msg = "Hello, server!";
if (send(sockfd, msg, strlen(msg), 0) < 0) {
    perror("send");
    exit(1);
}
printf("Sent: %s\n", msg);

2.3.2 服务端接收并回复

char buffer[1024] = {0};
int n = recv(connfd, buffer, sizeof(buffer) - 1, 0);
if (n < 0) {
    perror("recv");
    exit(1);
}
printf("Received: %s\n", buffer);

// 回复客户端
char *reply = "Message received!";
send(connfd, reply, strlen(reply), 0);

注意:recv() 返回0表示对方已经关闭连接。这是判断连接是否断开的常用方法。我在写聊天程序时就靠这个来检测对方是否下线。

2.4 关闭Socket

通信结束,记得关闭Socket。不然文件描述符会泄漏,程序跑久了就会报"Too many open files"。

// 服务端关闭连接Socket和监听Socket
close(connfd);
close(sockfd);

// 客户端关闭
close(sockfd);

服务端有两个Socket要关:一个是 accept() 返回的连接Socket,一个是原来的监听Socket。客户端只需要关一个。

2.5 完整代码示例

我把服务端和客户端的完整代码贴出来。你可以直接复制编译运行。

服务端代码(server.c)

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

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

    // 2. 绑定地址和端口
    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"); exit(1);
    }

    // 3. 监听
    if (listen(sockfd, 5) < 0) { perror("listen"); exit(1); }
    printf("Server listening on port 8888...\n");

    // 4. 接受连接
    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"); exit(1); }
    printf("Client connected: %s:%d\n",
           inet_ntoa(cli_addr.sin_addr), ntohs(cli_addr.sin_port));

    // 5. 接收数据
    char buffer[1024] = {0};
    int n = recv(connfd, buffer, sizeof(buffer) - 1, 0);
    if (n < 0) { perror("recv"); exit(1); }
    printf("Received: %s\n", buffer);

    // 6. 回复数据
    char *reply = "Message received!";
    send(connfd, reply, strlen(reply), 0);

    // 7. 关闭
    close(connfd);
    close(sockfd);
    return 0;
}

客户端代码(client.c)

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

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

    // 2. 连接服务器
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(8888);
    if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
        perror("connect"); exit(1);
    }
    printf("Connected to server.\n");

    // 3. 发送数据
    char *msg = "Hello, server!";
    if (send(sockfd, msg, strlen(msg), 0) < 0) {
        perror("send"); exit(1);
    }
    printf("Sent: %s\n", msg);

    // 4. 接收回复
    char buffer[1024] = {0};
    int n = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
    if (n < 0) { perror("recv"); exit(1); }
    printf("Received: %s\n", buffer);

    // 5. 关闭
    close(sockfd);
    return 0;
}

2.6 编译与运行

打开两个终端,先启动服务端,再启动客户端:

# 终端1
gcc server.c -o server
./server

# 终端2
gcc client.c -o client
./client

你会看到服务端打印出"Received: Hello, server!",客户端打印出"Received: Message received!"。恭喜,你的第一个TCP Socket程序跑通了。

总结一下:这一章我们走完了TCP Socket通信的完整流程。服务端七步走:socket → bind → listen → accept → recv/send → close。客户端四步走:socket → connect → send/recv → close。别看步骤多,其实每一步都有它的道理。下一章我们会深入聊一聊这些函数背后的细节,以及常见的错误处理。