Socket编程基础:实现一个简单的TCP回射服务器

Socket编程,说白了就是让两台机器通过网络聊天。我刚开始接触网络编程时,总觉得这玩意儿很神秘——数据怎么就从一台电脑跑到另一台电脑上了?后来亲手写了一个回射服务器,才真正理解了TCP通信的完整流程。

今天咱们就手把手实现一个TCP回射服务器。所谓回射,就是客户端发什么,服务器原封不动地返回什么。虽然功能简单,但五脏俱全,能让你把socket()、bind()、listen()、accept()、connect()这几个核心函数彻底搞明白。

TCP Socket通信的核心流程

先看一张流程图,把整体脉络理清楚:

服务器端 socket() 创建套接字 bind() 绑定地址和端口 listen() 监听连接请求 accept() 接受客户端连接 read() / write() 通信 客户端 socket() 创建套接字 connect() 连接服务器 read() / write() 通信 TCP三次握手

这张图我画了好几次才满意。你注意看,服务器端和客户端的流程是不对称的——服务器先启动,等着别人来找它;客户端主动发起连接。这个区别很重要,很多新手搞混了。

五个核心函数详解

1. socket() — 创建通信端点

socket()函数创建一个套接字,相当于打开了一扇门。它的原型是:

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

参数说明:

参数 常用值 含义
domain AF_INET IPv4协议
type SOCK_STREAM 面向连接的TCP流
protocol 0 自动选择协议

返回值是一个文件描述符。在Linux里,套接字本质上就是文件,所以你可以用read()、write()来操作它。我记得刚学的时候,总把socket()返回的值当成什么神秘句柄,后来才明白——它就是个数,跟open()返回的文件描述符没区别。

小技巧: 我习惯把socket()返回的fd保存下来,后面所有操作都依赖它。如果返回-1,说明创建失败,记得检查errno。

2. bind() — 绑定地址和端口

bind()把套接字和一个具体的地址、端口绑在一起。服务器必须调用bind(),客户端通常不需要。

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

这里有个坑——struct sockaddr是个通用结构,实际使用时需要填充struct sockaddr_in(IPv4)。我见过不少新手直接传struct sockaddr_in的指针,忘了做类型转换,编译报警告也不管,结果运行时bind失败。

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
servaddr.sin_port = htons(8888);               // 端口号

bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
注意: 端口号要用htons()转换字节序!我曾经在一个项目里忘了这步,结果在x86机器上测试没问题,换到ARM板子上就死活连不上。查了两天才发现是大小端的问题。

3. listen() — 监听连接

listen()告诉内核,这个套接字要进入监听状态,准备接受客户端的连接请求。

int listen(int sockfd, int backlog);

backlog参数指定连接队列的最大长度。什么意思呢?当多个客户端同时连接时,内核会维护一个队列,backlog就是这个队列的上限。我建议设成5或10,太大反而浪费资源。

调用listen()之后,套接字就变成了被动套接字——它不再主动发起连接,而是等着别人来找它。

4. accept() — 接受连接

accept()从连接队列里取出一个已完成三次握手的连接,返回一个新的套接字描述符。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

这里要注意:accept()返回的是新套接字,原来的监听套接字还在继续监听。我刚开始写服务器时,以为accept()返回的就是原来的sockfd,结果用那个fd去通信,怎么都收不到数据。后来才明白——监听套接字只负责接客,真正干活的是新套接字。

核心概念: 监听套接字 vs 已连接套接字
  • 监听套接字:只调用accept(),不读写数据
  • 已连接套接字:负责和客户端通信,每个客户端一个

5. connect() — 发起连接

connect()是客户端用的,主动向服务器发起TCP连接。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect()会触发TCP三次握手。如果服务器没启动,connect()会返回-1,errno设置为ECONNREFUSED。我在调试时经常遇到这个错误,第一反应就是——服务器没跑起来。

完整代码实现:TCP回射服务器

好了,理论说完了,咱们直接上代码。这是一个完整的回射服务器,客户端发什么,它就回什么。

#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 listen_fd, conn_fd;
    struct sockaddr_in servaddr, cliaddr;
    socklen_t cliaddr_len;
    char buffer[BUFFER_SIZE];
    ssize_t n;

    // 1. 创建套接字
    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 绑定地址
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(PORT);

    if (bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind");
        close(listen_fd);
        exit(1);
    }

    // 3. 开始监听
    if (listen(listen_fd, 5) < 0) {
        perror("listen");
        close(listen_fd);
        exit(1);
    }

    printf("服务器已启动,监听端口 %d...\n", PORT);

    // 4. 循环接受连接
    while (1) {
        cliaddr_len = sizeof(cliaddr);
        conn_fd = accept(listen_fd, (struct sockaddr *)&cliaddr, &cliaddr_len);
        if (conn_fd < 0) {
            perror("accept");
            continue;
        }

        printf("客户端已连接: %s:%d\n",
               inet_ntoa(cliaddr.sin_addr),
               ntohs(cliaddr.sin_port));

        // 5. 回射数据
        while ((n = read(conn_fd, buffer, BUFFER_SIZE)) > 0) {
            write(conn_fd, buffer, n);
        }

        printf("客户端断开连接\n");
        close(conn_fd);
    }

    close(listen_fd);
    return 0;
}

对应的客户端代码:

#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 sock_fd;
    struct sockaddr_in servaddr;
    char buffer[BUFFER_SIZE];
    ssize_t n;

    // 1. 创建套接字
    sock_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (sock_fd < 0) {
        perror("socket");
        exit(1);
    }

    // 2. 连接服务器
    memset(&servaddr, 0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);

    if (connect(sock_fd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("connect");
        close(sock_fd);
        exit(1);
    }

    printf("已连接到服务器\n");

    // 3. 发送数据并接收回射
    while (1) {
        printf("请输入消息: ");
        if (fgets(buffer, BUFFER_SIZE, stdin) == NULL) {
            break;
        }

        write(sock_fd, buffer, strlen(buffer));
        n = read(sock_fd, buffer, BUFFER_SIZE);
        if (n <= 0) {
            break;
        }

        buffer[n] = '\0';
        printf("服务器回射: %s", buffer);
    }

    close(sock_fd);
    return 0;
}

编译和运行

把服务器代码保存为server.c,客户端代码保存为client.c,然后:

gcc -o server server.c
gcc -o client client.c

# 先启动服务器
./server

# 再开一个终端运行客户端
./client

运行效果:客户端输入"hello",服务器返回"hello"。就这么简单。

避坑指南

写Socket程序,有几个坑我踩过不止一次:

  • 端口被占用:程序退出后端口不会立即释放,等几分钟或用setsockopt()设置SO_REUSEADDR
  • 粘包问题:TCP是流协议,多次write()可能合并成一次read()。回射服务器简单,但实际项目要考虑消息边界
  • 阻塞问题:默认情况下read()是阻塞的,如果客户端不发数据,服务器就卡在那。可以用非阻塞IO或多线程解决
  • 信号处理:子进程退出时会发送SIGCHLD信号,如果不处理,僵尸进程会越来越多
我的经验: 调试Socket程序时,先用telnet连一下服务器,看能不能通。telnet 127.0.0.1 8888,如果连不上,八成是bind()或listen()出问题了。

总结

TCP回射服务器虽然简单,但它是所有网络服务的基础。你想想看,HTTP服务器、FTP服务器、WebSocket服务器,本质上都是这个流程——创建套接字、绑定、监听、接受连接、读写数据。区别只在于应用层协议不同。

我个人建议,把这个代码亲手敲一遍,然后试着改一改——比如改成并发处理多个客户端,或者加上超时机制。只有亲手踩过坑,才能真正理解Socket编程的精髓。


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