《Linux系统编程篇》Linux Socket 网络编程03(Linux 进程间通信(IPC))——基础篇

Socket、TCP 与 UDP 实操:用 C 语言完成 4 个最小通信程序

在理解 socketTCPUDP 的概念之后,最有效的学习方式不是继续停留在术语层面,而是亲手完成一组最小可运行的通信程序。

本文基于 C 语言给出 4 个可以直接编译和验证的示例:

  • 一个最小 TCP server
  • 一个最小 TCP client
  • 一个最小 UDP server
  • 一个最小 UDP client

这组示例的目标并不是构建完整的网络服务,而是帮助读者建立最基础、最重要的认知:

  1. TCP 和 UDP 在代码结构上到底有什么差异
  2. 服务端和客户端各自承担什么职责
  3. 哪些步骤属于"建立连接",哪些步骤属于"收发数据"
  4. 为什么很多上层协议会选择构建在 TCP 之上

本文所有示例都刻意保持在"最小闭环"范围内,便于读者快速跑通、观察行为,再逐步扩展到更接近实际工程的版本。


一、代码组织与编译方式

为了让文章本身自包含,下面直接给出完整代码。读者可以新建一个目录,例如 socket_examples,然后按以下文件名保存:

  • Makefile
  • tcp_server.c
  • tcp_client.c
  • udp_server.c
  • udp_client.c

推荐先创建目录:

bash 复制代码
mkdir -p socket_examples
cd socket_examples

1. Makefile

makefile 复制代码
CC := cc
CFLAGS := -Wall -Wextra -Wpedantic -std=c11 -O2
BUILD_DIR := build

TARGETS := \
	$(BUILD_DIR)/tcp_server \
	$(BUILD_DIR)/tcp_client \
	$(BUILD_DIR)/udp_server \
	$(BUILD_DIR)/udp_client

.PHONY: all clean

all: $(TARGETS)

$(BUILD_DIR):
	mkdir -p $(BUILD_DIR)

$(BUILD_DIR)/tcp_server: tcp_server.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -o $@ $<

$(BUILD_DIR)/tcp_client: tcp_client.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -o $@ $<

$(BUILD_DIR)/udp_server: udp_server.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -o $@ $<

$(BUILD_DIR)/udp_client: udp_client.c | $(BUILD_DIR)
	$(CC) $(CFLAGS) -o $@ $<

clean:
	rm -rf $(BUILD_DIR)

保存完上述文件后,直接编译:

bash 复制代码
make

编译完成后会生成:

  • build/tcp_server
  • build/tcp_client
  • build/udp_server
  • build/udp_client

这些程序只依赖标准 socket API,不依赖第三方网络库,因此非常适合作为 C 网络编程的第一组练习。


二、TCP 实验:从监听到连接建立

TCP 的核心特征包括:

  • 面向连接
  • 可靠传输
  • 面向字节流

因此,TCP 程序的重点不只是"发送数据",而是先完成连接建立,再在连接上进行收发。

1. TCP 服务端的职责

tcp_server.c 展示了一个最小 TCP 服务端的标准流程:

  1. 创建监听 socket
  2. 绑定本地地址和端口
  3. 进入监听状态
  4. 接受客户端连接
  5. 从连接中读取数据
  6. 返回一条响应消息

核心调用顺序如下:

c 复制代码
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(listen_fd, 5);

conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
nread = recv(conn_fd, buffer, sizeof(buffer) - 1, 0);
send(conn_fd, reply, (size_t)written, 0);

这里最值得初学者注意的是,服务端实际上会使用两个不同的 socket 描述符:

  • listen_fd:负责监听端口
  • conn_fd:负责和某个客户端进行实际通信

这也是 TCP 服务端编程最容易被忽略、但又最基础的一个点。listen() 并不意味着已经获得可通信的连接,真正的通信对象来自 accept() 的返回值。

完整代码如下:

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

static void usage(const char *program)
{
    fprintf(stderr, "usage: %s <port>\n", program);
}

int main(int argc, char *argv[])
{
    int listen_fd;
    int conn_fd;
    int port;
    ssize_t nread;
    struct sockaddr_in server_addr;
    struct sockaddr_in client_addr;
    socklen_t client_len;
    char buffer[BUFFER_SIZE];
    char client_ip[INET_ADDRSTRLEN];

    if (argc != 2) {
        usage(argv[0]);
        return 1;
    }

    port = atoi(argv[1]);

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

    {
        int opt = 1;
        if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
            perror("setsockopt");
            close(listen_fd);
            return 1;
        }
    }

    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((uint16_t)port);

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

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

    printf("TCP server listening on 0.0.0.0:%d\n", port);

    client_len = sizeof(client_addr);
    conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
    if (conn_fd < 0) {
        perror("accept");
        close(listen_fd);
        return 1;
    }

    if (inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip)) == NULL) {
        strncpy(client_ip, "unknown", sizeof(client_ip));
        client_ip[sizeof(client_ip) - 1] = '\0';
    }

    printf("client connected: %s:%d\n", client_ip, ntohs(client_addr.sin_port));

    nread = recv(conn_fd, buffer, sizeof(buffer) - 1, 0);
    if (nread < 0) {
        perror("recv");
        close(conn_fd);
        close(listen_fd);
        return 1;
    }

    buffer[nread] = '\0';
    printf("received: %s\n", buffer);

    {
        char reply[BUFFER_SIZE];
        int written = snprintf(reply, sizeof(reply), "tcp server reply: %s", buffer);
        if (written < 0 || written >= (int)sizeof(reply)) {
            fprintf(stderr, "reply too long\n");
            close(conn_fd);
            close(listen_fd);
            return 1;
        }

        if (send(conn_fd, reply, (size_t)written, 0) < 0) {
            perror("send");
            close(conn_fd);
            close(listen_fd);
            return 1;
        }
    }

    close(conn_fd);
    close(listen_fd);
    return 0;
}

2. TCP 客户端的职责

tcp_client.c 的结构则更加直接:

  1. 创建 socket
  2. 连接远端服务端
  3. 发送消息
  4. 读取响应

核心代码如下:

c 复制代码
sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
send(sockfd, message, strlen(message), 0);
nread = recv(sockfd, buffer, sizeof(buffer) - 1, 0);

客户端不需要 bind()listen()accept(),因为它的职责不是提供服务,而是主动发起连接。

完整代码如下:

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

static void usage(const char *program)
{
    fprintf(stderr, "usage: %s <server_ip> <port> <message>\n", program);
}

int main(int argc, char *argv[])
{
    int sockfd;
    int port;
    ssize_t nread;
    struct sockaddr_in server_addr;
    const char *server_ip;
    const char *message;
    char buffer[BUFFER_SIZE];

    if (argc != 4) {
        usage(argv[0]);
        return 1;
    }

    server_ip = argv[1];
    port = atoi(argv[2]);
    message = argv[3];

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

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

    if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
        fprintf(stderr, "invalid server ip: %s\n", server_ip);
        close(sockfd);
        return 1;
    }

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

    printf("connected to %s:%d\n", server_ip, port);

    if (send(sockfd, message, strlen(message), 0) < 0) {
        perror("send");
        close(sockfd);
        return 1;
    }

    nread = recv(sockfd, buffer, sizeof(buffer) - 1, 0);
    if (nread < 0) {
        perror("recv");
        close(sockfd);
        return 1;
    }

    buffer[nread] = '\0';
    printf("reply: %s\n", buffer);

    close(sockfd);
    return 0;
}

3. 本机运行 TCP 示例

先在第一个终端启动服务端:

bash 复制代码
cd socket_examples
./build/tcp_server 9000

再在第二个终端运行客户端:

bash 复制代码
cd socket_examples
./build/tcp_client 127.0.0.1 9000 hello_tcp

服务端预期输出如下:

text 复制代码
TCP server listening on 0.0.0.0:9000
client connected: 127.0.0.1:xxxxx
received: hello_tcp

客户端预期输出如下:

text 复制代码
connected to 127.0.0.1:9000
reply: tcp server reply: hello_tcp

4. 通过实验理解 TCP

这组最小示例可以帮助读者快速确认 TCP 的两个基本事实:

  • 服务端必须先监听端口,客户端才能发起连接
  • 双方建立连接之后,后续的读写都发生在这条连接上

从编程模型上看,TCP 更像是"先建立会话,再在会话中交换数据"。


三、UDP 实验:直接发送数据报

与 TCP 不同,UDP 的核心特征包括:

  • 无连接
  • 不保证可靠交付
  • 面向数据报

因此,UDP 的最小示例在代码结构上会明显更短,但这并不意味着它在工程上一定更简单。很多可靠性和顺序控制问题,只是被从传输层移动到了应用层。

1. UDP 服务端的职责

udp_server.c 的工作流程非常直接:

  1. 创建 UDP socket
  2. 绑定本地端口
  3. 接收客户端发来的数据报
  4. 根据源地址回发响应

核心代码如下:

c 复制代码
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

nread = recvfrom(
    sockfd,
    buffer,
    sizeof(buffer) - 1,
    0,
    (struct sockaddr *)&client_addr,
    &client_len);

sendto(
    sockfd,
    reply,
    (size_t)written,
    0,
    (struct sockaddr *)&client_addr,
    client_len);

这里没有 listen(),也没有 accept(),因为 UDP 不需要在发送数据之前建立连接状态。

完整代码如下:

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

static void usage(const char *program)
{
    fprintf(stderr, "usage: %s <port>\n", program);
}

int main(int argc, char *argv[])
{
    int sockfd;
    int port;
    ssize_t nread;
    struct sockaddr_in server_addr;
    struct sockaddr_in client_addr;
    socklen_t client_len;
    char buffer[BUFFER_SIZE];
    char reply[BUFFER_SIZE];
    char client_ip[INET_ADDRSTRLEN];

    if (argc != 2) {
        usage(argv[0]);
        return 1;
    }

    port = atoi(argv[1]);

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

    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((uint16_t)port);

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

    printf("UDP server listening on 0.0.0.0:%d\n", port);

    client_len = sizeof(client_addr);
    nread = recvfrom(
        sockfd,
        buffer,
        sizeof(buffer) - 1,
        0,
        (struct sockaddr *)&client_addr,
        &client_len);
    if (nread < 0) {
        perror("recvfrom");
        close(sockfd);
        return 1;
    }

    buffer[nread] = '\0';

    if (inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip)) == NULL) {
        strncpy(client_ip, "unknown", sizeof(client_ip));
        client_ip[sizeof(client_ip) - 1] = '\0';
    }

    printf("datagram from %s:%d\n", client_ip, ntohs(client_addr.sin_port));
    printf("received: %s\n", buffer);

    {
        int written = snprintf(reply, sizeof(reply), "udp server reply: %s", buffer);
        if (written < 0 || written >= (int)sizeof(reply)) {
            fprintf(stderr, "reply too long\n");
            close(sockfd);
            return 1;
        }

        if (sendto(
                sockfd,
                reply,
                (size_t)written,
                0,
                (struct sockaddr *)&client_addr,
                client_len) < 0) {
            perror("sendto");
            close(sockfd);
            return 1;
        }
    }

    close(sockfd);
    return 0;
}

2. UDP 客户端的职责

udp_client.c 只需要完成三件事:

  1. 创建 socket
  2. 向目标地址发送数据报
  3. 接收服务端回包

核心代码如下:

c 复制代码
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
sendto(sockfd, message, strlen(message), 0, (struct sockaddr *)&server_addr, server_len);
nread = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, NULL, NULL);

与 TCP 客户端相比,它不需要 connect() 才能开始通信。

完整代码如下:

c 复制代码
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define BUFFER_SIZE 1024

static void usage(const char *program)
{
    fprintf(stderr, "usage: %s <server_ip> <port> <message>\n", program);
}

int main(int argc, char *argv[])
{
    int sockfd;
    int port;
    ssize_t nread;
    struct sockaddr_in server_addr;
    socklen_t server_len;
    const char *server_ip;
    const char *message;
    char buffer[BUFFER_SIZE];

    if (argc != 4) {
        usage(argv[0]);
        return 1;
    }

    server_ip = argv[1];
    port = atoi(argv[2]);
    message = argv[3];

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

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

    if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
        fprintf(stderr, "invalid server ip: %s\n", server_ip);
        close(sockfd);
        return 1;
    }

    server_len = sizeof(server_addr);

    if (sendto(
            sockfd,
            message,
            strlen(message),
            0,
            (struct sockaddr *)&server_addr,
            server_len) < 0) {
        perror("sendto");
        close(sockfd);
        return 1;
    }

    nread = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, NULL, NULL);
    if (nread < 0) {
        perror("recvfrom");
        close(sockfd);
        return 1;
    }

    buffer[nread] = '\0';
    printf("reply: %s\n", buffer);

    close(sockfd);
    return 0;
}

3. 本机运行 UDP 示例

先在第一个终端启动 UDP 服务端:

bash 复制代码
cd socket_examples
./build/udp_server 9001

再在第二个终端运行 UDP 客户端:

bash 复制代码
cd socket_examples
./build/udp_client 127.0.0.1 9001 hello_udp

服务端预期输出如下:

text 复制代码
UDP server listening on 0.0.0.0:9001
datagram from 127.0.0.1:xxxxx
received: hello_udp

客户端预期输出如下:

text 复制代码
reply: udp server reply: hello_udp

4. 通过实验理解 UDP

这组示例直观展示了 UDP 和 TCP 在编程模型上的差异:

  • UDP 不需要连接建立阶段
  • 服务端不需要 accept()
  • 一次 sendto() 对应一份独立的数据报

从编程体验上看,UDP 更接近"按地址发送一份消息",而不是"先建立连接再传输字节流"。


四、跨机器运行:从本机实验扩展到局域网

这 4 个示例并不局限于本机运行。只要两台机器网络可达、端口未被防火墙阻断,就可以直接进行跨机器测试。

假设:

  • 服务端机器 IP:192.168.64.7
  • 客户端机器 IP:192.168.64.1

TCP 跨机器测试

在服务端机器上运行:

bash 复制代码
./build/tcp_server 9000

在客户端机器上运行:

bash 复制代码
./build/tcp_client 192.168.64.7 9000 hello_tcp

UDP 跨机器测试

在服务端机器上运行:

bash 复制代码
./build/udp_server 9001

在客户端机器上运行:

bash 复制代码
./build/udp_client 192.168.64.7 9001 hello_udp

如果跨机器测试失败,建议优先检查以下项目:

  • 两台机器是否可以相互连通
  • 服务端程序是否已经启动并绑定到对应端口
  • 目标端口是否被系统防火墙或安全策略阻断

对于局域网环境,使用 ss -lntss -lunnetstatnc 等工具辅助排查,通常会比直接猜测问题更高效。


五、这组示例真正帮助理解什么

从代码长度看,这 4 个程序都很短;但从学习价值看,它们覆盖了网络编程里最基本也最重要的一组区别。

1. TCP 比 UDP 多出一整段"连接管理"

TCP 示例中多出的 listen()accept()connect(),本质上对应的是连接建立与连接管理。

这也是为什么 TCP 更适合需要稳定会话和可靠交付的场景。

2. TCP 面向字节流,UDP 面向数据报

本示例中的消息都很短,因此还看不出 TCP 粘包、拆包等问题;但一旦进入真实业务场景,TCP 的"无消息边界"会很快成为必须处理的工程问题。

UDP 则天然保留数据报边界,因此在消息模型上更直接。

3. 服务端和客户端的职责是不同的

在这组示例中,读者可以非常明确地看到:

  • 服务端负责绑定端口并等待外部请求
  • 客户端负责构造目标地址并主动发起访问

这套角色分工会在后续几乎所有网络协议与中间件中重复出现。

4. API 简单不代表系统简单

UDP 的代码量更少,但它并没有自动解决重试、顺序、可靠性、重复包过滤等问题。

这些问题如果在业务上不可忽略,就必须由应用层自己承担。


六、从教学示例走向工程实践,还缺什么

本文的代码刻意压缩在"最小闭环"内,因此并没有覆盖真实工程中必须面对的一些问题。例如:

  • 循环处理多个请求
  • 并发连接
  • 超时控制
  • 错误恢复
  • 粘包与拆包
  • 连接中断后的资源回收
  • 更完整的协议设计

因此,这组代码更适合作为"建立第一性理解"的入口,而不是直接作为生产代码模板。

如果读者准备继续深入,推荐按下面的顺序逐步扩展:

  1. 让 TCP 服务端支持循环接收多个客户端连接
  2. 为 TCP 设计明确的消息边界,例如"长度字段 + 消息体"
  3. 让 UDP 服务端进入持续接收循环,而不是只处理单次请求
  4. 为 UDP 消息增加序号、超时和重发机制
  5. 在更高层引入应用协议,例如 MQTT 或自定义二进制协议

七、与 MQTT 的关系

如果读者此前已经接触过 MQTT,这组示例还有一个重要价值:它能帮助你把"应用层协议"和"传输层通信"真正区分开。

以 MQTT 为例,它通常建立在 TCP 之上。也就是说:

  • 应用层由 MQTT 定义消息格式和交互规则
  • 传输层由 TCP 提供可靠字节流
  • 程序实现上通常通过 socket API 完成底层通信

因此,学习原始 socket/TCP/UDP 编程,并不是为了替代 MQTT 这样的高层协议,而是为了理解这些协议背后依赖的运行基础。


八、结语

对于 C 语言网络编程的初学者来说,最难的部分往往不是 API 数量,而是没有建立清晰的分层模型。

这组 4 个最小示例的意义,正是在于把最核心的结构先固定下来:

  • TCP server
  • TCP client
  • UDP server
  • UDP client

当这四种角色和它们的调用路径变得清晰之后,后续无论继续学习粘包处理、并发模型、MQTT、HTTP 还是更复杂的网络服务,都会顺畅得多。

从这个角度看,最小示例并不是"过于简单的玩具代码",而是理解网络编程抽象层次的起点。

相关推荐
搁浅小泽2 小时前
大电流焊点补焊要求
单片机·嵌入式硬件·可靠性工程师
Linux猿2 小时前
基于单片机浴室窗帘控制系统 | 附源码
单片机·嵌入式硬件·毕业设计·源码·课程设计·项目·基于单片机于是窗帘控制系统
Strange_Head2 小时前
《Linux系统网络协议》从 TCP 到 HTTP:理解 Web 通信的第一步——网络篇
linux·网络·网络协议
清风6666662 小时前
基于51单片机的的智能电动车充电桩系统设计
单片机·嵌入式硬件·毕业设计·51单片机·课程设计·期末大作业
@insist1232 小时前
网络工程师-广域网与接入网技术(一):核心协议与流量控制
开发语言·网络·网络工程师·软考·软件水平考试
爱吃生蚝的于勒2 小时前
【Linux】重中之重!TCP协议
linux·运维·服务器·网络·学习·tcp/ip
楼田莉子2 小时前
Linux网络:TCP协议
linux·运维·服务器·网络·tcp/ip
IMPYLH2 小时前
Linux 的 logname 命令
linux·运维·服务器·bash
杨云龙UP2 小时前
Oracle 19c:RMAN Duplicate异机复制数据库实操_20260402
linux·运维·服务器·数据库·网络协议·tcp/ip·oracle