linux下socket编程使用教程:新手完成第一张作品的方法

百科问答10小时前发布 jdkjadmin
0 00
linux下socket编程使用教程:新手完成第一张作品的方法

linux下socket编程使用教程:新手完成第一张作品的方法

“完成第一张作品”不必从复杂的聊天软件或多人游戏开始。本文会带你在 Linux 上用 C 写一个最小但完整的 TCP 服务端和客户端:客户端连接服务端,发送一行文字,服务端返回一行应答。你可以亲手编译、运行和观察整个通信过程,同时了解 Socket 编程里最容易被忽略的边界:一次发送不一定对应一次接收、端口不是安全措施、监听队列也不是并发能力。

示例优先保证容易阅读和运行,适合学习网络编程入门概念。它不是生产环境服务器:采用阻塞 I/O、一次处理一个连接、没有用户认证和加密。文中会说明这些限制以及下一步该如何改进。

一、Socket 适合解决什么问题

Socket(套接字)是程序进行网络通信的一种接口。它可以让同一台机器上的进程通信,也可以让不同机器上的程序通过网络交换数据。开发者通常通过操作系统提供的 Socket API 创建通信端点,再使用地址和端口找到另一端。

TCP Socket 适合需要可靠、有序字节流的场景,例如:

  • 客户端向服务端提交请求,服务端返回结果。
  • 内部服务之间传递控制指令或业务数据。
  • 学习 HTTP 等应用层协议底层的连接与数据传输机制。

UDP Socket 则以数据报为单位,不建立 TCP 那样的连接,也不保证数据一定到达或按顺序到达。它适合能够容忍丢包、需要控制传输方式,或采用自身机制管理可靠性的应用。本文选择 TCP,是因为它更容易用于理解“建立连接—收发数据—关闭连接”的基本流程。

选择 TCP 并不意味着应用层问题都自动解决。TCP 提供的是有序字节流,不知道你的消息在哪里结束,也不负责用户身份验证、数据加密或业务权限判断。应用程序必须自己定义消息格式并处理安全问题。

二、运行环境和通信流程

示例使用 Linux、C 编译器和 IPv4 回环地址。常见发行版通常已经安装 GCC;如果没有,可使用发行版的软件包管理器安装对应的 C 编译工具。

一次 TCP 通信的大致步骤如下:

  1. 服务端创建 Socket,绑定本机 IP 地址和端口。
  2. 服务端进入监听状态,等待客户端连接。
  3. 客户端创建 Socket,连接服务端地址和端口。
  4. 服务端接受连接;双方通过连接 Socket 收发字节。
  5. 通信结束后关闭 Socket。

服务端用于监听的 Socket 和 accept 返回的连接 Socket 不是同一个用途:前者等待新连接,后者用于与某个客户端传输数据。本文示例采用阻塞方式,调用 accept、recv 等函数时,程序可能等待操作完成。

三、先定义一个简单的消息格式

TCP 连接上传输的是连续字节,不会保留应用程序每次调用 send 时的消息边界。比如客户端调用一次 send 发送“hello”,服务端可能分两次 recv 收到,也可能把多次发送的数据合并在一次读取中。

因此,不能假设“发送一次,接收一次,就得到一条完整消息”。本例采用简单的换行分帧规则:一行文本以换行符 \n 结束,消息最大为 1023 字节(不含换行符)。服务端收到一行后返回 收到:原文\n。这个规则只用于演示;真实协议还需要明确字符编码、字段格式、长度限制和错误处理。

四、编写 TCP 服务端

将下列代码保存为 server.c。它只监听 IPv4 回环地址 127.0.0.1,因此通常只能由本机访问。服务端一次处理一个连接,并且每个连接只读取一行消息。

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

#define PORT 9090
#define BACKLOG 16
#define LINE_CAP 1024

/*
 * 返回值:
 *   1:读取到一行,out 是以 NUL 结尾的字符串
 *   0:对端关闭连接,且没有未完成的内容
 *  -1:接收失败
 *  -2:一行超过缓冲区上限
 */
static int read_line(int fd, char *out, size_t cap)
{
    size_t used = 0;

    if (cap < 2) {
        errno = EINVAL;
        return -1;
    }

    for (;;) {
        char ch;
        ssize_t n = recv(fd, &ch, 1, 0);

        if (n == 0) {
            if (used == 0)
                return 0;
            out[used] = '\0';
            return 1;
        }

        if (n < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }

        if (ch == '\n') {
            out[used] = '\0';
            return 1;
        }

        if (used >= cap - 1)
            return -2;

        out[used++] = ch;
    }
}

static int send_all(int fd, const char *buf, size_t len)
{
    size_t sent = 0;

    while (sent < len) {
        ssize_t n = send(fd, buf + sent, len - sent, MSG_NOSIGNAL);

        if (n < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }

        if (n == 0) {
            errno = EPIPE;
            return -1;
        }

        sent += (size_t)n;
    }

    return 0;
}

int main(void)
{
    int listen_fd = -1;
    struct sockaddr_in addr;

    /*
     * MSG_NOSIGNAL 用于 send;同时忽略 SIGPIPE,
     * 避免向已关闭的连接写数据时进程被信号终止。
     */
    signal(SIGPIPE, SIG_IGN);

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        perror("socket");
        return 1;
    }

    /*
     * 允许在某些重启场景下更方便地重新绑定本地地址。
     * 这不是访问控制,也不表示端口可被多个普通服务
     * 无条件同时占用。
     */
    int reuse = 1;
    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR,
                   &reuse, sizeof(reuse)) < 0) {
        perror("setsockopt");
        close(listen_fd);
        return 1;
    }

    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);

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

    if (listen(listen_fd, BACKLOG) < 0) {
        perror("listen");
        close(listen_fd);
        return 1;
    }

    printf("服务端正在监听 127.0.0.1:%d\n", PORT);

    for (;;) {
        int client_fd;
        struct sockaddr_in peer;
        socklen_t peer_len = sizeof(peer);

        client_fd = accept(listen_fd, (struct sockaddr *)&peer, &peer_len);
        if (client_fd < 0) {
            if (errno == EINTR)
                continue;
            perror("accept");
            continue;
        }

        char line[LINE_CAP];
        int result = read_line(client_fd, line, sizeof(line));

        if (result == 1) {
            char response[LINE_CAP + 32];
            int count = snprintf(response, sizeof(response),
                                 "收到:%s\n", line);

            if (count < 0 || (size_t)count >= sizeof(response)) {
                fprintf(stderr, "响应内容超过缓冲区\n");
            } else if (send_all(client_fd, response, (size_t)count) < 0) {
                perror("send");
            }
        } else if (result == -2) {
            fprintf(stderr, "客户端发送的单行内容超出上限\n");
        } else if (result == -1) {
            perror("recv");
        }

        close(client_fd);
    }

    close(listen_fd);
    return 0;
}

理解服务端中的几个关键调用

  • socket(AF_INET, SOCK_STREAM, 0) 创建 IPv4 TCP Socket。AF_INET 表示 IPv4 地址族,SOCK_STREAM 表示流式 Socket;对于该组合,协议参数为 0 时通常由系统选择相应协议。
  • htons(PORT) 把 16 位端口值转换成网络字节序;htonl 用于转换 32 位值。网络协议使用大端字节序,不能随意把主机整数直接填入网络地址结构。
  • bind 将监听 Socket 绑定到地址和端口。若端口已经被占用、地址不可用或进程权限不足,调用可能失败;应检查返回值并查看错误信息。
  • listen 将 Socket 设置为被动监听状态。BACKLOG 是等待应用程序接受的连接队列相关参数,不是服务端最多能处理的客户端数,也不是应用并发能力保证。实际行为还受系统实现和配置影响。
  • accept 接受一个待处理连接,并返回新的连接 Socket。程序还没调用 accept 时,内核可以暂存一定数量的连接请求,但队列满时新连接仍可能被拒绝或延迟。
  • recv 和 send 的返回值可能小于请求字节数。send_all 通过循环处理部分发送;read_line 循环读取到换行符或连接结束。

示例逐字节调用 recv,便于直观展示换行分帧,但对大量数据并不高效。实际程序通常会批量读取到缓冲区,再在应用层解析消息边界,并保留尚未处理完的数据。

五、编写 TCP 客户端

将以下代码保存为 client.c。客户端连接本机的 127.0.0.1:9090,发送固定文本并读取服务端应答。

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

#define PORT 9090
#define LINE_CAP 1024

static int send_all(int fd, const char *buf, size_t len)
{
    size_t sent = 0;

    while (sent < len) {
        ssize_t n = send(fd, buf + sent, len - sent, MSG_NOSIGNAL);

        if (n < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }

        if (n == 0) {
            errno = EPIPE;
            return -1;
        }

        sent += (size_t)n;
    }

    return 0;
}

static int read_line(int fd, char *out, size_t cap)
{
    size_t used = 0;

    if (cap < 2) {
        errno = EINVAL;
        return -1;
    }

    for (;;) {
        char ch;
        ssize_t n = recv(fd, &ch, 1, 0);

        if (n == 0) {
            if (used == 0)
                return 0;
            out[used] = '\0';
            return 1;
        }

        if (n < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }

        if (ch == '\n') {
            out[used] = '\0';
            return 1;
        }

        if (used >= cap - 1)
            return -2;

        out[used++] = ch;
    }
}

int main(void)
{
    int fd;
    struct sockaddr_in server_addr;
    const char message[] = "你好,Socket!\n";

    signal(SIGPIPE, SIG_IGN);

    fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) {
        perror("socket");
        return 1;
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);

    if (inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr) != 1) {
        fprintf(stderr, "无法解析服务端 IPv4 地址\n");
        close(fd);
        return 1;
    }

    if (connect(fd, (struct sockaddr *)&server_addr,
                sizeof(server_addr)) < 0) {
        perror("connect");
        close(fd);
        return 1;
    }

    if (send_all(fd, message, sizeof(message) - 1) < 0) {
        perror("send");
        close(fd);
        return 1;
    }

    char reply[LINE_CAP];
    int result = read_line(fd, reply, sizeof(reply));

    if (result == 1) {
        printf("服务端回复:%s\n", reply);
    } else if (result == 0) {
        fprintf(stderr, "服务端关闭连接,未收到回复\n");
    } else if (result == -2) {
        fprintf(stderr, "服务端回复超过缓冲区上限\n");
    } else {
        perror("recv");
    }

    close(fd);
    return result == 1 ? 0 : 1;
}

客户端中的 inet_pton 将点分十进制 IPv4 地址转换为网络地址结构。它只接受合法地址;返回 1 表示转换成功。connect 发起连接,服务端未运行、地址不可达或端口没有监听时,通常会失败。具体错误可通过 perror 输出。

六、编译并运行第一份作品

在源文件所在目录编译:

gcc -Wall -Wextra -O2 server.c -o server
gcc -Wall -Wextra -O2 client.c -o client

-Wall -Wextra 用于打开常见编译警告,-O2 开启常用优化。学习阶段不要忽略警告;如果遇到警告,先理解原因再决定是否修复。

先在终端一启动服务端:

./server

看到类似输出表示服务端已进入监听状态:

服务端正在监听 127.0.0.1:9090

另开终端二运行客户端:

./client

客户端预期输出:

服务端回复:收到:你好,Socket!

每运行一次客户端,服务端接受一个连接、读取一行、发送一行回复,然后关闭该连接。按 Ctrl+C 可以停止服务端。

七、地址、端口与可达范围

示例绑定的是 127.0.0.1,这是 IPv4 回环地址,通信留在本机。若希望局域网中的其他设备连接,服务端通常需要绑定机器的局域网地址,或者绑定 0.0.0.0,表示在所有适用的本机 IPv4 接口上监听。绑定全部接口会扩大暴露范围,不能只为“方便测试”就把服务直接开放到不可信网络。

若要做局域网测试,除了修改服务端绑定地址,还需要把客户端中的 127.0.0.1 换成服务端在客户端可访问的地址,并检查路由、防火墙和虚拟机或容器的网络配置。改成 0.0.0.0 只影响服务端的监听位置;客户端不能把它当成远端服务器地址使用。

端口字段是 16 位整数,网络端口编号范围为 0 到 65535。0 常用于请求系统分配临时端口,不适合作为固定服务端口。通常选择 1 到 65535 中未被占用且符合本机管理策略的端口。Linux 上绑定较低端口(传统上是 1024 以下)通常需要相应权限,具体还可能受能力配置影响。即使某个端口能成功绑定,也不代表网络上的客户端一定可以访问它。

排查本机监听状态时可以使用:

ss -ltn

其中 -l 显示监听 Socket,-t 选择 TCP,-n 显示数字地址和端口。还可以查询指定端口:

ss -ltnp

-p 尝试显示相关进程信息,某些进程信息可能需要额外权限。排查时先确认服务端是否运行、绑定的地址和端口是否正确,再检查防火墙、路由以及客户端实际连接的地址。

八、示例的边界与可以改进的地方

1. 当前服务端不支持并发处理

示例在主循环中调用 accept,再完成当前客户端的读取和回复后,才处理下一个连接。某个客户端迟迟不发送换行符时,服务端会一直阻塞在读取操作上,后续连接也可能因此得不到及时处理。

生产服务通常会使用非阻塞 Socket 配合 poll、epoll 等 I/O 多路复用机制,或者采用线程、进程等并发模型。无论采用哪种方式,都需要设计并发上限、超时策略、资源回收和过载处理。单纯把监听队列参数调大,并不能解决应用程序处理慢或资源不足的问题。

2. 阻塞读取没有超时

本例会等待客户端发出换行符或关闭连接。面对不可信客户端时,不能无限期等待;应设置读写超时,或使用非阻塞 I/O,并限制单个连接占用的时间和资源。最大行长限制只能控制消息长度,不能阻止客户端长时间不发完消息。

3. 只有简单文本格式

换行分帧不适合所有数据。如果消息内容本身可以包含换行符,就必须对换行进行转义,或改用长度前缀、固定长度结构等协议格式。对于长度前缀协议,也要先检查长度字段是否合理,再分配内存或读取数据,不能相信对端提供的长度。

4. 字符串和二进制数据要区分

示例将输入作为文本并添加 NUL 终止符,因而不能直接用于任意二进制数据。二进制协议应该始终使用明确的字节数处理缓冲区,不要把网络数据未经验证地当作 C 字符串传递给 printf("%s", ...) 或字符串函数。

5. 错误处理仍然只是入门级

示例对关键系统调用检查了返回值,并对 EINTR 做了基本处理,但没有对每一种错误进行恢复,也没有日志分级、连接统计或优雅退出机制。实际程序还应确保所有资源都能在异常路径中关闭,并对输入、输出和连接状态进行明确管理。

九、Socket 编程中的常见错误

  • 把 TCP 当成消息队列。 TCP 是字节流,单次 send 与单次 recv 不存在一一对应保证。使用明确的分帧规则,并在代码中循环读取或发送。
  • 忽略函数返回值。socket、bind、listen、accept、connect、send 和 recv 都可能失败或只处理部分数据。不要只看程序是否“没崩”。
  • 忘记端口字节序转换。 网络地址结构中的端口通常要使用 htons;IPv4 地址数值使用 htonl 等转换。直接把主机序整数写进去,在某些环境中会连接到错误端口。
  • 把监听地址和客户端地址混淆。0.0.0.0 是常用的服务端通配绑定地址,不是客户端用来连接服务端的目标地址。客户端需要使用真实可达的服务器地址。
  • 认为端口号就是防护。 换一个不常见端口不会提供身份认证或加密,也不能阻止扫描。访问控制应由防火墙、网络策略和应用层认证共同承担。
  • 没有处理客户端提前断开。 对端关闭连接后,读取可能返回 0;向已经关闭的连接发送数据也可能失败。示例用 MSG_NOSIGNAL 和忽略 SIGPIPE 避免进程被写入断开的 Socket 所触发的信号直接终止。
  • 把 SO_REUSEADDR 当作安全选项或通用端口共享开关。 本例使用它改善部分重启时的重新绑定体验。其具体绑定语义与系统和地址状态有关,它不是认证、加密或防火墙功能,也不意味着任意多个服务都能同时绑定相同地址和端口。

十、关于参数、监听队列和安全用途

Socket 参数的含义需要结合所调用的系统函数理解。listen 的 BACKLOG 表示请求监听队列的长度参数,不是“最大客户端人数”。Linux 内核会对相关值施加限制,系统配置和内核行为也会影响队列;因此把它设为很大的数字,并不能替代并发设计,也不能保证客户端永远不会遇到连接失败。

本例中的消息限制是程序自己定义的 LINE_CAP,不是 Socket API 自动给出的最大消息长度。客户端和服务端需要在协议层约定并执行一致的限制。对外提供服务时,还应设置连接数、空闲时间、每用户请求速率和总内存等资源上限,避免恶意或意外的输入造成资源耗尽。

Socket 也不是安全加密协议。本文示例没有 TLS、用户身份验证、访问控制或对输入的业务校验,消息在网络链路上的机密性和对端身份都没有保障。它只能用于受控的本机学习演示。若要在真实网络传输敏感信息,应采用经过正确配置的 TLS 等成熟安全协议,并验证证书与对端身份;同时仍需在应用层执行授权、输入校验、日志记录和速率限制。不要自行设计加密算法,也不要把“连接成功”误认为“对方可信”。

十一、完成作品后的练习方向

先确保服务端和客户端在本机稳定运行,再逐步扩展功能,每次只改变一个部分:

  1. 让客户端从命令行读取一行输入,而不是发送固定字符串。
  2. 让服务端在一条连接中循环处理多行消息,并约定退出指令。
  3. 为读取操作增加超时,测试客户端连接后不发送数据的情况。
  4. 实现长度前缀协议,并测试零长度、超长以及不完整消息。
  5. 尝试使用 poll 或 epoll 管理多个连接,再比较阻塞版的差异。
  6. 仅在可信的隔离环境中测试局域网访问,并先确认服务绑定范围和防火墙规则。

完成这些练习时,持续检查四件事:消息如何分帧、缓冲区边界在哪里、连接何时超时或关闭、输入由谁验证。能把这四个问题说清楚,才算从“能连上”迈向“能可靠地通信”。

总结

这份入门作品展示了 Linux TCP Socket 的基本路径:创建 Socket、绑定地址、监听连接、接受客户端、按约定读取一行数据、发送响应,最后关闭连接。它可以在本机直接编译运行,也刻意限制在回环地址和小型文本协议内,减少初学者第一次实践时的干扰。

最值得带走的不是某个固定代码模板,而是几个判断:TCP 是字节流,应用必须定义消息边界;系统调用可能失败或只处理部分数据;监听队列不等于并发能力;回环地址、局域网地址和通配地址的可达范围不同;Socket 本身不提供认证与加密。掌握这些边界后,再逐步加入超时、并发、协议校验和 TLS,才能把入门示例发展成适合真实场景的网络程序。

© 版权声明

相关文章