4. Socket编程入门:Socket概念、地址结构与常用API

4.1 从“管道”到“插座”:理解Socket的本质

在TCP/IP网络中,两个进程要通信,必须经过操作系统内核的网络协议栈。Socket(套接字)正是操作系统提供给应用程序的编程接口,它把复杂的TCP/IP协议细节封装起来,让开发者像操作文件一样操作网络连接。之所以叫“套接字”,是因为它一端“插”在应用进程上,另一端“接”在协议栈上,形成一个双向的数据通道。

从数据流的角度看,Socket是应用层与传输层之间的“门”。发送数据时,应用把字节流写入Socket,内核负责按TCP或UDP协议封装、路由并发送;接收数据时,内核把到达的数据按序放入Socket接收缓冲区,应用通过read/recv取走。理解这一点,就能明白为什么Socket编程中“缓冲区”“阻塞”“非阻塞”如此重要。

类比记忆:Socket就像电话机。socket()是安装电话,bind()是给电话分配一个号码(IP+端口),listen()是打开铃声等待来电,accept()是接听并建立通话,connect()是主动拨号。通话建立后,双方通过这个“电话线”(已连接的Socket)收发数据。
客户端进程 应用层 HTTP / FTP / 自定义协议 Socket 接口 socket() → connect() send() / recv() 内核协议栈 TCP 状态机 IP 路由 / 分片 网卡驱动 服务端进程 应用层 业务逻辑 / 响应处理 Socket 接口 socket() → bind() → listen() accept() → recv() / send() 内核协议栈 TCP 状态机 IP 路由 / 分片 网卡驱动 物理网络传输 TCP 三次握手 / 四次挥手 数据包封装与校验 connect accept 数据双向传输(字节流) Socket 是应用进程与内核协议栈之间的双向数据通道,连接建立后即可双向收发数据

4.2 地址结构:sockaddr_in 与网络字节序

Socket编程中,地址结构是连接“IP+端口”与系统调用之间的桥梁。IPv4环境下最常用的是 struct sockaddr_in,它定义在 <netinet/in.h> 中。其核心字段包括:sin_family(地址族,AF_INET)、sin_port(16位端口号)、sin_addr(32位IPv4地址)。

这里有一个极易踩坑的点:网络字节序。TCP/IP协议规定,多字节整数在网络上传输时必须使用大端序(Big-Endian)。而x86等主流CPU使用小端序(Little-Endian)。因此,在填充端口号和IP地址时,必须调用转换函数:

  • htons():Host to Network Short,将主机字节序的16位整数转为网络字节序(常用于端口号)。
  • htonl():Host to Network Long,将主机字节序的32位整数转为网络字节序(常用于IP地址)。
  • ntohs() / ntohl():反向转换,用于从网络数据中解析端口和IP。
// 服务端地址结构初始化示例
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));   // 清零
server_addr.sin_family = AF_INET;               // IPv4协议族
server_addr.sin_port = htons(8080);             // 端口8080,转为网络字节序
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有本地接口
常见错误:直接写 server_addr.sin_port = 8080; 而不调用 htons(),在小端机器上实际端口会变成 0x901F(即 0x1F90 的字节序反转),导致 bind 失败或监听在错误端口。同理,inet_pton(AF_INET, "192.168.1.10", &server_addr.sin_addr) 会自动完成字节序转换,而手动赋值时必须使用 htonl()。

4.3 核心API调用流程:从socket到accept

TCP服务端的标准调用序列是:socket() → bind() → listen() → accept()。客户端则是:socket() → connect()。下面逐一说明每个调用的作用与关键参数。

API 函数原型 核心作用 关键参数
socket() int socket(int domain, int type, int protocol) 创建套接字描述符 domain=AF_INET, type=SOCK_STREAM, protocol=0
bind() int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen) 绑定本地IP和端口 addr需强转为sockaddr*
listen() int listen(int sockfd, int backlog) 将套接字转为被动监听状态 backlog为连接队列最大长度
accept() int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) 从队列取出一个已完成握手的连接 返回新的已连接套接字fd
connect() int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) 客户端主动发起TCP连接 addr为目标服务器地址

一个典型的服务端骨架代码如下:

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

// 设置SO_REUSEADDR,避免TIME_WAIT导致bind失败
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
    perror("bind"); exit(1);
}
if (listen(listen_fd, 128) < 0) {  // backlog=128
    perror("listen"); exit(1);
}

struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (conn_fd < 0) { perror("accept"); exit(1); }

4.4 案例:实现一个最简单的回声服务器

下面通过一个完整的回声服务器(Echo Server)串联上述API。客户端发送什么,服务端就原样返回什么。这个案例能直观验证Socket通信链路是否打通。

// ===== 服务端 echo_server.c =====
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in srv, cli;
    socklen_t cli_len = sizeof(cli);
    char buf[1024];

    srv.sin_family = AF_INET;
    srv.sin_port = htons(9000);
    srv.sin_addr.s_addr = htonl(INADDR_ANY);
    bind(listen_fd, (struct sockaddr*)&srv, sizeof(srv));
    listen(listen_fd, 5);

    printf("Echo server listening on port 9000...\n");
    int conn_fd = accept(listen_fd, (struct sockaddr*)&cli, &cli_len);
    printf("Client connected: %s:%d\n",
           inet_ntoa(cli.sin_addr), ntohs(cli.sin_port));

    while (1) {
        int n = read(conn_fd, buf, sizeof(buf));
        if (n <= 0) break;
        write(conn_fd, buf, n);  // 原样返回
    }
    close(conn_fd);
    close(listen_fd);
    return 0;
}

// ===== 客户端 echo_client.c =====
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in srv;
    char buf[1024];

    srv.sin_family = AF_INET;
    srv.sin_port = htons(9000);
    inet_pton(AF_INET, "127.0.0.1", &srv.sin_addr);

    if (connect(sock, (struct sockaddr*)&srv, sizeof(srv)) < 0) {
        perror("connect"); return 1;
    }
    printf("Connected to server. Type 'quit' to exit.\n");

    while (1) {
        printf("> ");
        fgets(buf, sizeof(buf), stdin);
        write(sock, buf, strlen(buf));
        if (strncmp(buf, "quit", 4) == 0) break;
        int n = read(sock, buf, sizeof(buf));
        buf[n] = '\0';
        printf("Echo: %s", buf);
    }
    close(sock);
    return 0;
}

编译运行:先在一个终端启动 ./echo_server,再在另一个终端启动 ./echo_client。输入任意字符串,客户端会收到服务端返回的相同内容。输入 quit 退出连接。

4.5 误区与复盘:初学者的五个典型错误

误区一:忘记字节序转换。端口和IP直接赋值而不使用 htons()/htonl(),导致bind到错误端口或连接失败。复盘:所有从主机填入地址结构的数值,都必须经过主机序→网络序转换。
误区二:bind前未设置SO_REUSEADDR。服务端主动关闭后,端口进入TIME_WAIT状态,立即重启会报 Address already in use。复盘:在bind前调用 setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ...) 可解决。
误区三:混淆监听套接字与已连接套接字。listen_fd 只负责接受新连接,不能用来收发数据;accept() 返回的 conn_fd 才是真正通信的套接字。复盘:关闭时两者都要close,但作用完全不同。
误区四:忽略accept的阻塞特性。默认情况下,如果没有客户端连接,accept() 会一直阻塞。初学者常误以为程序卡死。复盘:这是正常行为,可用多线程或IO多路复用处理并发。
误区五:地址结构类型强转不匹配。bind()/connect() 的第二参数要求 struct sockaddr*,而实际使用的是 struct sockaddr_in*,必须显式强转。复盘:这是C语言“伪继承”的设计,所有地址族共用同一个接口。
本节小结:Socket编程的核心是理解“监听套接字”与“已连接套接字”的分离,以及地址结构中网络字节序的强制要求。掌握 socket → bind → listen → accept(服务端)和 socket → connect(客户端)的调用链后,就可以搭建最基本的TCP通信程序。下一阶段将在此基础上引入多进程/多线程并发模型,以及IO多路复用技术,解决“一个服务端服务多个客户端”的问题。