一、什么是套接字 (Socket)?

在深入代码之前,我们必须理解“套接字”是什么。

在 Linux (以及所有 Unix) 哲学中,“一切皆文件”。网络连接也不例外。当你创建一个套接字 (Socket) 时,操作系统(内核)会返回一个文件描述符 (File Descriptor, FD)。这只是一个整数,是你的进程打开文件表的一个索引。

  • 你对这个文件描述符read(),就是从网络连接中接收数据。

  • 你对这个文件描述符write(),就是向网络连接中发送数据。

  • close()这个文件描述符,就是关闭网络连接。

内核的网络堆栈负责将你写入这个“文件”的数据,打包成数据包(添加 TCP/IP 头部等),并通过网卡发送出去;反之,它也会将从网卡收到的数据包解包,放入缓冲区,等待你来“读取”。

二、UDP 编程 (用户数据报协议)

UDP 是一个无连接的 (Connectionless)不可靠的 (Unreliable) 协议。

  • 无连接: 发送数据前不需要建立连接。就像寄明信片,你写好地址(IP 和端口)扔进邮筒就行,不需要先打电话告诉对方“我要寄了”。

  • 不可靠: 它不保证数据包一定到达,也不保证按顺序到达,更不保证数据包的完整性(可能会损坏)。

  • 优点: 简单、开销小、速度快。非常适合:DNS、视频直播、在线游戏(快速的位置更新)等。

1. UDP 服务器 (udp_server.c)

服务器的工作是:创建一个套接字,将其绑定 (Bind) 到一个众所周知的端口,然后循环等待客户端发来数据,收到后回复一个消息。

/* udp_server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr, client_addr;
    char buffer[BUFFER_SIZE];
    socklen_t client_addr_len;

    // 1. 创建套接字 (AF_INET: IPv4, SOCK_DGRAM: UDP)
    // 底层实现:
    // 这启动了一个系统调用 (syscall)。内核会分配内存来管理这个套接字
    // (例如,一个 'struct sock' 结构),并返回一个文件描述符 (一个整数)
    // 供用户空间引用。
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    // 准备服务器地址结构
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
    server_addr.sin_port = htons(PORT);       // 端口号

    // 2. 绑定 (Bind)
    // 底层实现:
    // 告诉内核:“请将这个套接字(sockfd)与本地的 8080 端口关联起来”。
    // 内核会检查该端口是否已被占用。如果没有,它会更新其内部的端口映射表。
    // 从此刻起,任何发送到本机 8080 端口的 UDP 数据包都将被定向到这个套接字。
    if (bind(sockfd, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    printf("UDP Server listening on port %d...\n", PORT);

    while (1) {
        client_addr_len = sizeof(client_addr);

        // 3. 接收数据 (recvfrom)
        // 底层实现:
        // 这是一个 *阻塞* (blocking) 调用。进程会在此处“睡眠”。
        // 当一个 UDP 包到达网卡,内核的网络堆栈处理它,发现它的目标是 8080 端口。
        // 内核查询映射表,找到我们的 sockfd,然后将数据包内容从内核空间
        // 复制到我们提供的用户空间 'buffer' 中。
        // 同时,它还将 'client_addr' 结构体填充上发送方的 IP 和端口。
        // 复制完成后,内核唤醒我们的进程,recvfrom 返回。
        int n = recvfrom(sockfd, buffer, BUFFER_SIZE, 0,
                         (struct sockaddr *)&client_addr, &client_addr_len);

        if (n < 0) {
            perror("recvfrom failed");
            continue;
        }

        buffer[n] = '\0'; // 确保字符串以 null 结尾
        printf("Client said: %s\n", buffer);

        // 4. 发送数据 (sendto)
        // 底层实现:
        // 我们必须指定 *目标地址* (client_addr),因为 UDP 是无连接的。
        // 内核获取我们的数据,加上 UDP 头部,再加上 IP 头部(目标 IP 从 client_addr 获取),
        // 然后交给数据链路层(如以太网)打包成帧,最后通过网卡发送出去。
        // 这个调用 *立即* 返回,它不关心数据包是否真的到达了对方。
        const char *message = "Hello from server";
        sendto(sockfd, message, strlen(message), 0,
               (const struct sockaddr *)&client_addr, client_addr_len);
    }

    close(sockfd);
    return 0;
}

2. UDP 客户端 (udp_client.c)

客户端的工作是:创建一个套接字,直接向服务器的 IP 和端口发送数据,然后等待回复。

/* udp_client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define SERVER_IP "127.0.0.1" // 本地回环地址
#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    const char *message = "Hello from client";

    // 1. 创建套接字
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    // 准备服务器地址结构
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    // 将字符串 IP "127.0.0.1" 转换为网络字节序的二进制
    if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
        perror("invalid address");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 2. 发送数据 (sendto)
    // 客户端 *不需要* bind(),除非它想从一个特定端口发送。
    // 如果不 bind,内核会自动为它分配一个临时的、随机的端口号。
    // 我们直接告诉内核要把数据发给谁。
    sendto(sockfd, message, strlen(message), 0,
           (const struct sockaddr *)&server_addr, sizeof(server_addr));

    printf("Message sent to server.\n");

    // 3. 接收回复 (recvfrom)
    socklen_t server_addr_len = sizeof(server_addr);
    int n = recvfrom(sockfd, buffer, BUFFER_SIZE, 0,
                     (struct sockaddr *)&server_addr, &server_addr_len);

    if (n < 0) {
        perror("recvfrom failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

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

    close(sockfd);
    return 0;
}

三、TCP 编程 (传输控制协议)

TCP 是一个面向连接的 (Connection-Oriented)可靠的 (Reliable) 协议。

  • 面向连接: 在发送数据之前,必须先通过三次握手 (Three-Way Handshake) 建立连接。就像打电话,必须先拨号、对方接听,双方确认“喂,听得到吗?”之后才能开始通话。

  • 可靠: TCP 保证所有数据包按顺序无差错地送达。

    • 底层实现: 它通过序列号 (Sequence Numbers) 来重排乱序的数据包,通过确认应答 (ACK) 来确认收到。如果发送方在一定时间内没收到 ACK,它会超时重传 (Retransmission)。它还通过滑动窗口 (Sliding Window) 来进行流量控制 (Flow Control),确保不会淹没接收方。

  • 数据流 (Stream): TCP 不关心你 write() 了多少次。它把数据看作一个连续的字节流。你调用 write() 10次,每次 10 字节,接收方可能一次 read() 就读出了 100 字节。

1. TCP 服务器 (tcp_server.c)

TCP 服务器的流程更复杂:socket() -> bind() -> listen() -> accept() -> read/write() -> close()

/* tcp_server.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int server_fd, new_socket;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len;
    char buffer[BUFFER_SIZE] = {0};

    // 1. 创建套接字 (SOCK_STREAM: TCP)
    // 内核知道这是一个 TCP 套接字,将为其准备 TCP 控制块 (TCP Control Block, TCB)
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        perror("socket failed");
        exit(EXIT_FAILURE);
    }

    // 准备服务器地址结构
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 2. 绑定 (Bind)
    // 与 UDP 类似,将套接字与 8080 端口关联
    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 3. 监听 (Listen)
    // 底层实现:这是 TCP 服务器的 *关键* 步骤。
    // 它告诉内核:“我(server_fd)是一个服务器。请开始接受此端口的连接请求”。
    // 内核会为此套接字创建两个队列:
    //   a. SYN 队列 (半连接队列): 存放已收到 SYN 但尚未完成握手的客户端。
    //   b. Accept 队列 (全连接队列): 存放已完成三次握手的客户端连接,
    //      等待应用程序调用 accept() 来取走。
    // '10' 是 backlog,暗示 Accept 队列的大小。
    if (listen(server_fd, 10) < 0) {
        perror("listen failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    printf("TCP Server listening on port %d...\n", PORT);

    // 4. 接受 (Accept)
    // 底层实现:
    // 这是一个 *阻塞* 调用。进程在此“睡眠”。
    // 它从 Accept 队列中取出一个已完成握手的连接。
    // 最重要的是:它会 *创建一个全新的套接字 (new_socket)*,
    // 这个新套接字专用于与 *这一个* 客户端通信。
    // 原来的 server_fd (称为“监听套接字”)则 *保持不变*,继续监听其他
    // 客户端的连接请求。这就是服务器能同时处理多个客户端的原理。
    client_addr_len = sizeof(client_addr);
    if ((new_socket = accept(server_fd, (struct sockaddr *)&client_addr, &client_addr_len)) < 0) {
        perror("accept failed");
        close(server_fd);
        exit(EXIT_FAILURE);
    }

    // 5. 读/写 (Read/Write)
    // 注意:我们现在使用的是 new_socket,而不是 server_fd
    // 'read()' 是一个阻塞调用,等待客户端发送数据
    int n = read(new_socket, buffer, BUFFER_SIZE);
    if (n < 0) {
        perror("read failed");
    } else {
        buffer[n] = '\0';
        printf("Client said: %s\n", buffer);
    }

    // 'write()' (或 send) 通过这个已建立的连接发送数据
    // 底层实现:数据被复制到内核的“发送缓冲区”。TCP 协议栈会
    // 自动将其分段、打包、发送,并处理所有 ACK 和重传。
    const char *message = "Hello from server";
    write(new_socket, message, strlen(message));

    // 6. 关闭
    // 先关闭与特定客户端通信的套接字
    close(new_socket);
    // (在一个真实的服务器中,这里会循环回到 accept() 等待下一个客户端)
    // 最后关闭监听套接字
    close(server_fd);

    return 0;
}

2. TCP 客户端 (tcp_client.c)

客户端的流程:socket() -> connect() -> write/read() -> close()

/* tcp_client.c */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define SERVER_IP "127.0.0.1"
#define PORT 8080
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    char buffer[BUFFER_SIZE];
    const char *message = "Hello from client";

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

    // 准备服务器地址结构
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
        perror("invalid address");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 2. 连接 (Connect)
    // 底层实现:这是客户端的 *关键* 步骤。
    // 这是一个 *阻塞* 调用,它会触发 TCP 的三次握手:
    //   a. 客户端发送 [SYN] 包给服务器。
    //   b. 客户端“睡眠”,等待服务器回复。
    //   c. 服务器回复 [SYN, ACK] 包。
    //   d. 客户端内核收到后,回复 [ACK] 包,并 *唤醒* 进程,
    //      connect() 调用成功返回。
    // 此后,sockfd 就与服务器的 IP 和端口“绑定”了,
    // 之后的所有 read/write 都不再需要指定地址。
    if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("connection failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    printf("Connected to server.\n");

    // 3. 写/读 (Write/Read)
    // 'write()' (或 send) 发送数据。数据进入 TCP 发送缓冲区。
    write(sockfd, message, strlen(message));

    // 'read()' 阻塞,等待服务器回复。
    // 内核从 TCP 接收缓冲区读取数据。
    int n = read(sockfd, buffer, BUFFER_SIZE);
    if (n < 0) {
        perror("read failed");
    } else {
        buffer[n] = '\0';
        printf("Server replied: %s\n", buffer);
    }

    // 4. 关闭
    // 底层实现:
    // 'close()' 会触发 TCP 的四次挥手,以“优雅地”断开连接。
    // (发送 FIN -> 等待 ACK -> 等待对方的 FIN -> 发送 ACK)
    close(sockfd);

    return 0;
}

四、如何编译和运行 (在 Ubuntu 16.04)

  • 保存代码:将上面的四个代码块分别保存为 udp_server.c, udp_client.c, tcp_server.c, tcp_client.c

  • 编译:打开你的终端,使用 gcc 编译它们。

# 编译 UDP 程序
gcc udp_server.c -o udp_server
gcc udp_client.c -o udp_client

# 编译 TCP 程序
gcc tcp_server.c -o tcp_server
gcc tcp_client.c -o tcp_client

运行

  • 你需要两个终端。

  • 测试 UDP:

    • 终端 1 中运行服务器:./udp_server

    • 终端 2 中运行客户端:./udp_client

    • 观察两个终端的输出。

  • 测试 TCP:

    • 终端 1 中运行服务器:./tcp_server

    • 终端 2 中运行客户端:./tcp_client

    • 观察两个终端的输出。

五、总结:TCP vs UDP 关键系统调用

步骤 TCP 服务器 TCP 客户端 UDP 服务器 UDP 客户端
1 socket(SOCK_STREAM) socket(SOCK_STREAM) socket(SOCK_DGRAM)

socket(SOCK_DGRAM)

2 bind() bind()
3 listen()
4 accept() connect()
5 read() / write() write() / read() recvfrom() sendto()
6 close() close() close() close()

这些就是最基础的 C 语言网络编程。底层的实现涉及到了操作系统的系统调用进程调度(阻塞/唤醒)内核空间与用户空间的数据复制,以及**网络协议栈(TCP/IP)**的复杂工作。

这只是一个开始,更高级的主题包括:

  • 非阻塞 I/O:如何让 accept()read() 在没有数据时立即返回,而不是“睡眠”?

  • I/O 多路复用:如何让一个服务器进程同时管理 成千上万 个客户端连接?(这需要 select(), poll() 或更高效的 epoll())

  • 多进程/多线程服务器:如何使用 fork()pthread_create() 来为每个客户端创建一个单独的处理流程?

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐