Socket 编程入门:从概念到实战

Socket,中文叫套接字,说白了就是网络通信的「门」。你想想看,两台机器要聊天,总得有个入口吧?Socket 就是这个入口。我刚开始学的时候,总觉得这玩意儿很玄乎,后来写多了才发现,它就是个文件描述符——对,跟你在 Linux 下读写文件用的那个 fd 是一回事。

核心认知:Socket 是应用层与传输层之间的编程接口。你调 Socket API,底层帮你走 TCP/UDP,你只管收发数据就行。

2.1 什么是 Socket?

Socket 起源于 BSD Unix,最初就是为了让进程间能跨机器通信。它抽象了「通信端点」这个概念:

  • IP 地址:定位哪台机器
  • 端口号:定位机器上的哪个进程
  • 协议类型:TCP 还是 UDP

这三样凑一起,就是一个唯一的 Socket。我记得有次排查线上问题,发现两个服务连不上,折腾半天,结果是端口写错了。嗯,这种低级错误,谁还没犯过呢?

2.2 TCP Socket API 详解

TCP Socket 的 API 其实就六个函数,我管它叫「六脉神剑」。你把这六个玩明白了,基本所有 TCP 服务端客户端都能写。

函数 作用 服务端/客户端
socket() 创建 Socket 文件描述符 双方都用
bind() 绑定 IP 和端口 服务端
listen() 进入监听状态 服务端
accept() 接受客户端连接 服务端
connect() 发起连接请求 客户端
close() 关闭连接 双方都用

2.2.1 socket() — 开门

第一步,先拿一把钥匙——创建一个 Socket 描述符。

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
  • AF_INET:IPv4 协议族
  • SOCK_STREAM:TCP 流式套接字
  • 0:自动选择协议(TCP)

返回的 sockfd 就是个整数,跟文件描述符一样。如果返回 -1,说明创建失败了。我见过新手忘了检查返回值,结果后面各种段错误,排查半天。

2.2.2 bind() — 挂牌

服务端得告诉系统:「我在这扇门上等着,谁来都行。」这就是 bind 干的事。

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

bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

注意:端口号要用 htons() 转换字节序。网络字节序是大端,而 x86 是小端。我曾经忘了调这个,结果端口绑到了完全不同的数字上,客户端死活连不上。

2.2.3 listen() — 排队

bind 之后,服务端得说:「我准备好了,你们来吧。」listen 就是干这个的。

listen(sockfd, 5);  // 最大等待队列长度 5

第二个参数是 backlog,表示内核为这个 Socket 排队的最大连接数。如果客户端来得太快,队列满了,新的连接就会被拒绝。我建议生产环境至少设 128,甚至更大。

2.2.4 accept() — 接客

这是服务端最核心的一步——从队列里取出一个连接,创建一个新的 Socket 专门跟这个客户端通信。

struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);

注意,accept 返回的是 新 Socket,原来的监听 Socket 还在继续监听。这就是 TCP 并发的基础——一个门迎,多个服务员。

2.2.5 connect() — 敲门

客户端这边就简单了,直接拿着服务端的地址去敲门。

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("192.168.1.100");
server_addr.sin_port = htons(8080);

connect(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));

connect 成功返回 0,失败返回 -1。常见的失败原因:服务端没启动、端口被防火墙挡了、IP 地址写错了。

2.3 客户端/服务器模型

把上面六个函数串起来,就是经典的 C/S 模型。我画了一张图,你看一眼就明白了。

TCP Socket 客户端/服务器模型流程图 TCP Socket 客户端/服务器模型 服务端:socket() 服务端:bind() 服务端:listen() 服务端:accept() 服务端:recv()/send() 服务端:close() 客户端:socket() 客户端:connect() 客户端:send()/recv() 客户端:close() 三次握手 数据交换 服务端先启动,调用 accept 阻塞等待;客户端调用 connect 发起三次握手

这张图把整个流程串起来了。你注意看,服务端是「被动方」,先启动、先 bind、先 listen,然后卡在 accept 那里等着。客户端是「主动方」,创建 Socket 后直接 connect 过去。

我的经验:写 TCP 服务端时,一定要先启动服务端,再启动客户端。顺序反了,客户端 connect 会直接失败。我在测试环境经常犯这个错,后来写了个启动脚本自动检查端口是否在监听。

2.4 完整代码示例

光说不练假把式。我给你一个最简的 echo 服务端和客户端,你直接复制就能跑。

服务端代码(echo_server.c)

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

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_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");
        exit(1);
    }

    // 2. 绑定
    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(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind failed");
        close(server_fd);
        exit(1);
    }

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

    // 4. 接受连接
    client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
    if (client_fd < 0) {
        perror("accept failed");
        close(server_fd);
        exit(1);
    }
    printf("Client connected!\n");

    // 5. 收发数据
    int n = read(client_fd, buffer, sizeof(buffer));
    printf("Received: %s\n", buffer);
    send(client_fd, buffer, n, 0);

    // 6. 关闭
    close(client_fd);
    close(server_fd);
    return 0;
}

客户端代码(echo_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() {
    int sockfd;
    struct sockaddr_in server_addr;
    char *message = "Hello, TCP!";
    char buffer[1024] = {0};

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

    // 2. 连接服务端
    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 failed");
        close(sockfd);
        exit(1);
    }
    printf("Connected to server!\n");

    // 3. 发送数据
    send(sockfd, message, strlen(message), 0);
    printf("Sent: %s\n", message);

    // 4. 接收回显
    read(sockfd, buffer, sizeof(buffer));
    printf("Received echo: %s\n", buffer);

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

避坑指南:我曾经在生产环境遇到过一个问题——服务端 accept 之后,客户端发来的数据读不全。排查了半天,发现是 read 没有循环读。TCP 是流式协议,一次 read 不一定能读完所有数据。正确的做法是循环 read,直到返回 0 或 -1。

2.5 常见问题与排查思路

写 Socket 程序,90% 的问题都出在下面这几个地方:

现象 可能原因 排查方法
bind 返回 EADDRINUSE 端口被占用 netstat -tlnp | grep 端口
connect 超时 服务端没启动或防火墙拦截 telnet IP 端口 测试连通性
connect 拒绝 端口没在 listen 状态 检查服务端是否调用了 listen
read 返回 0 对方已关闭连接 检查 close 时机
数据粘包 TCP 是流,没有消息边界 应用层自己加长度头或分隔符

嗯,说到粘包,这是个经典问题。TCP 是流式协议,它不关心你发了几次数据,只关心字节流。如果你连续 send 两次,接收方可能一次 read 就把两段数据全读走了。解决办法?我一般会在每个消息前面加 4 字节的长度字段,接收方先读长度,再读指定字节的数据。

一句话总结:Socket 编程就是「创建→绑定→监听→接受→收发→关闭」这六个步骤。服务端被动等待,客户端主动连接。记住这个模型,TCP 编程你就入门了。