
Unix网络编程入门:用套接字实现客户端与服务器通信
套接字(socket)是操作系统提供的通信接口。程序通过它发送和接收数据;数据可以在同一台机器上的进程之间传递,也可以经网络到达另一台机器。本文从 Unix/Linux 上常见的 TCP 套接字入手,说明客户端与服务器如何建立连接,并给出一组可编译运行的 C 示例。
示例采用简单的“按行”协议:客户端发送一行文字,服务器返回带有固定前缀的响应。它适合学习连接、读写和错误处理,不是生产级网络服务。文中也会说明这种写法的限制,以及哪些安全问题不能靠“用了套接字”自动解决。
一、套接字适合什么场景
当两个程序需要交换数据时,套接字是一种通用的通信方式。常见场景包括:本机进程间通信、客户端访问远程服务、开发简单的控制接口,以及实现自定义 TCP 协议。对只在同一台机器上通信的程序,Unix 域套接字(AF_UNIX)通常能避免 TCP/IP 地址与端口管理,也可以利用文件系统权限控制访问;跨主机通信通常使用 IPv4 或 IPv6 套接字。
套接字本身不规定应用数据的含义。TCP 只提供可靠、有序的字节流传输,不知道“请求”“响应”或“消息”是什么。应用需要自己定义协议,例如规定文本行以换行符结束,或者规定固定长度、长度前缀、分隔符与转义规则。
如果需求是浏览器访问网站,通常应使用 HTTP 库或 Web 框架,而不是从头手写 HTTP。如果需求是高吞吐、数万并发连接,简单的阻塞式示例也不够,需要评估非阻塞 I/O、事件循环、线程或进程模型,以及运行环境的资源限制。
二、TCP 通信的基本流程
服务器通常先创建套接字,再绑定本地地址与端口,随后进入监听状态,最后通过 accept 接受客户端连接。客户端创建套接字后调用 connect。连接建立后,两端使用 send/recv 或 write/read 交换数据,结束时关闭连接。
socket:创建通信端点。TCP 常用AF_INET或AF_INET6地址族,并使用SOCK_STREAM。bind:将服务器套接字关联到本地 IP 地址和端口。客户端一般不需要显式绑定,系统会选择本地地址和临时端口。listen:将套接字设为被动监听状态。它只用于服务器端。accept:等待并取出一个已建立连接。返回的是新的连接套接字;原监听套接字仍用于接受后续连接。connect:客户端请求连接到指定服务器地址与端口。send/recv:在已连接的套接字上读写字节。close:释放文件描述符。关闭一端并不意味着应用协议中的事务已正确完成。
下面的程序使用 POSIX 接口和 IPv4/IPv6 地址解析函数 getaddrinfo。它可以在常见 Unix-like 系统上编译;具体系统的编译器、头文件和链接参数可能略有差异。
三、服务器示例:监听并处理一行请求
服务器程序接受一个可选端口参数,缺省为 9000。它绑定可用地址、循环接受连接,并一次处理一个客户端。请求必须以换行符结束,最多允许 1023 个数据字节(不含结尾的 NUL 字符,换行符计入容量)。过长请求会被丢弃到行尾,再返回错误文本。
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#define LINE_CAP 1024
/* 返回 1 表示读到完整一行,0 表示对端提前关闭,
-1 表示系统错误,-2 表示行超过缓冲区容量。 */
static int read_line(int fd, char *buf, size_t cap)
{
size_t n = 0;
int too_long = 0;
for (;;) {
char ch;
ssize_t r = recv(fd, &ch, 1, 0);
if (r == 0) {
return 0;
}
if (r < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
if (!too_long) {
if (n + 1 < cap) {
buf[n++] = ch;
} else {
too_long = 1;
}
}
if (ch == '\n') {
if (too_long) {
return -2;
}
buf[n] = '\0';
return 1;
}
}
}
/* 处理 write/send 可能只写出部分数据的情况。 */
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, 0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
if (n == 0) {
return -1;
}
sent += (size_t)n;
}
return 0;
}
static int parse_port(const char *s)
{
char *end = NULL;
long value;
errno = 0;
value = strtol(s, &end, 10);
if (errno != 0 || end == s || *end != '\0' ||
value < 1 || value > 65535) {
return -1;
}
return (int)value;
}
int main(int argc, char **argv)
{
const char *port = "9000";
struct addrinfo hints, *list = NULL, *ai;
int listen_fd = -1;
int gai_err;
if (argc > 2) {
fprintf(stderr, "用法: %s [端口]\n", argv[0]);
return 2;
}
if (argc == 2) {
if (parse_port(argv[1]) < 0) {
fprintf(stderr, "端口必须是 1 到 65535 之间的整数\n");
return 2;
}
port = argv[1];
}
/* 避免客户端突然断开时,send 导致进程因 SIGPIPE 退出。 */
if (signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
perror("signal");
return 1;
}
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
gai_err = getaddrinfo(NULL, port, &hints, &list);
if (gai_err != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(gai_err));
return 1;
}
for (ai = list; ai != NULL; ai = ai->ai_next) {
int yes = 1;
listen_fd = socket(ai->ai_family, ai->ai_socktype,
ai->ai_protocol);
if (listen_fd < 0) {
continue;
}
(void)setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR,
&yes, sizeof yes);
if (bind(listen_fd, ai->ai_addr, ai->ai_addrlen) == 0) {
break;
}
close(listen_fd);
listen_fd = -1;
}
freeaddrinfo(list);
if (listen_fd < 0) {
perror("socket/bind");
return 1;
}
if (listen(listen_fd, SOMAXCONN) < 0) {
perror("listen");
close(listen_fd);
return 1;
}
printf("服务器正在监听端口 %s\n", port);
for (;;) {
int client_fd;
char line[LINE_CAP];
int result;
client_fd = accept(listen_fd, NULL, NULL);
if (client_fd < 0) {
if (errno == EINTR) {
continue;
}
perror("accept");
continue;
}
result = read_line(client_fd, line, sizeof line);
if (result == 1) {
static const char prefix[] = "server received: ";
if (send_all(client_fd, prefix, sizeof prefix - 1) < 0 ||
send_all(client_fd, line, strlen(line)) < 0) {
perror("send");
}
} else if (result == -2) {
static const char msg[] = "ERR line too long\n";
if (send_all(client_fd, msg, sizeof msg - 1) < 0) {
perror("send");
}
} else if (result == -1) {
perror("recv");
}
close(client_fd);
}
}
这里设置 SO_REUSEADDR 是为了在常见重启场景下更容易重新绑定端口;它不是“允许任意多个程序无冲突地监听同一端口”的通用开关。程序忽略了该选项设置失败的情况,实际部署时可根据系统行为决定是否将失败视为启动错误。
listen 的第二个参数是待处理连接队列长度的提示值,实际队列容量还受操作系统限制。它不等于服务器能够同时处理的客户端数量。这个示例在处理一个客户端期间不会调用 accept 接受其他连接,也没有并发处理能力。
四、客户端示例:连接并收取响应
客户端从标准输入读取一行文字,连接到指定主机和端口,发送这一行并读取服务器响应。它使用 getaddrinfo 遍历解析得到的候选地址,因此不需要自行把主机名转换成 IPv4 数字地址。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <netdb.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#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, 0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
return -1;
}
if (n == 0) {
return -1;
}
sent += (size_t)n;
}
return 0;
}
int main(int argc, char **argv)
{
struct addrinfo hints, *list = NULL, *ai;
int fd = -1;
int gai_err;
char line[LINE_CAP];
size_t len;
int saw_newline = 0;
if (argc != 3) {
fprintf(stderr, "用法: %s 主机 端口\n", argv[0]);
return 2;
}
if (signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
perror("signal");
return 1;
}
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
gai_err = getaddrinfo(argv[1], argv[2], &hints, &list);
if (gai_err != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(gai_err));
return 1;
}
for (ai = list; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0) {
continue;
}
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
break;
}
close(fd);
fd = -1;
}
freeaddrinfo(list);
if (fd < 0) {
fprintf(stderr, "无法连接到服务器\n");
return 1;
}
if (fgets(line, sizeof line, stdin) == NULL) {
fprintf(stderr, "没有读到输入\n");
close(fd);
return 1;
}
len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
saw_newline = 1;
} else if (!feof(stdin)) {
fprintf(stderr, "输入过长;请将一行限制在 %d 字节以内\n",
LINE_CAP - 2);
close(fd);
return 1;
}
if (!saw_newline) {
if (len + 1 >= sizeof line) {
fprintf(stderr, "输入过长\n");
close(fd);
return 1;
}
line[len++] = '\n';
line[len] = '\0';
}
if (send_all(fd, line, len) < 0) {
perror("send");
close(fd);
return 1;
}
/* 按行协议接收;缓冲区满或对端关闭时结束。 */
for (;;) {
char buf[256];
ssize_t n = recv(fd, buf, sizeof buf, 0);
if (n < 0) {
if (errno == EINTR) {
continue;
}
perror("recv");
close(fd);
return 1;
}
if (n == 0) {
break;
}
if (fwrite(buf, 1, (size_t)n, stdout) != (size_t)n) {
perror("fwrite");
close(fd);
return 1;
}
if (memchr(buf, '\n', (size_t)n) != NULL) {
break;
}
}
close(fd);
return 0;
}
客户端在输入行未以换行结束时补上换行符。示例的最大行长度由固定缓冲区决定,并且并未为多行输入设计。读取标准输入时,若一行超过缓冲区,程序会拒绝发送;生产程序还应妥善处理剩余输入,避免把下一次读取误认为新的一行。
五、编译与运行
将两段代码分别保存为 server.c 和 client.c,使用支持 C11 的编译器编译:
cc -std=c11 -Wall -Wextra -O2 server.c -o server
cc -std=c11 -Wall -Wextra -O2 client.c -o client
先在一个终端启动服务器:
./server 9000
再打开另一个终端运行客户端。连接到本机时可以使用 127.0.0.1:
printf 'hello socket\n' | ./client 127.0.0.1 9000
正常情况下会看到:
server received: hello socket
测试其他机器时,把 127.0.0.1 换成服务器的主机名或 IP 地址,并确认网络路由、防火墙规则和服务器绑定地址允许连接。若客户端报告连接失败,可检查服务器是否启动、端口是否一致、服务是否只绑定在回环地址,以及防火墙是否拦截流量。
六、重要边界:TCP 是字节流,不是消息队列
TCP 保证按序交付字节,但不保留发送端一次 send 对应接收端一次 recv 的关系。客户端发送 100 字节,服务器可能分多次收到;客户端连续发送两段数据,服务器也可能一次读到。网络分段、缓冲区状态和调度时机都会影响每次调用返回的数据量。
因此,不能把“recv 返回了数据”当成“收到了一条完整消息”。示例定义了以换行符为边界的协议,并在服务器端逐字节寻找换行符;这种实现容易理解,但每个字节调用一次 recv 效率较低。正式程序通常会使用较大的接收缓冲区,将数据暂存起来,再从缓冲区中解析一行或多条消息,同时保留未读完的部分。
换行分隔协议也有边界:如果内容中允许出现换行,就必须规定转义方式,或改用长度前缀等编码。长度前缀协议同样要检查长度:收到长度字段后,必须确认长度在允许范围内,再分配内存或读取正文。不能因为对端声称要发送很大的数据,就直接按该数值分配内存。
示例的服务器读到连接关闭时返回“未读到完整行”,不处理不完整的最后一行;客户端也只读取到响应中的第一个换行符。实际协议必须明确 EOF 的含义、是否允许多条消息、响应如何结束,以及连接关闭前后各方的职责。
七、常见错误与排查思路
- 忽略部分读写。
send或write成功返回的字节数可能小于请求长度。发送完整缓冲区时应循环写入,直到写完或遇到错误。接收端则应根据协议循环读取,而不是期待一次调用读全。 - 把错误返回值混为一谈。
recv返回 0 表示对端有序关闭连接;返回 -1 表示错误,需检查errno。遇到EINTR时通常可以重试。非阻塞套接字还需要处理EAGAIN/EWOULDBLOCK,本文代码没有使用非阻塞模式。 - 没有处理 SIGPIPE。 对端已关闭连接时再写入,某些 Unix 系统会向进程发送
SIGPIPE,默认行为可能终止进程。示例通过忽略该信号,让写入失败以错误码报告。其他系统也可能提供如MSG_NOSIGNAL这样的机制,但可用性并非所有平台一致。 - 把监听套接字和连接套接字弄混。
accept返回的新描述符用于和某个客户端通信;监听描述符应继续用于接受连接。关闭前者不会自动停止服务器监听。 - 误解绑定地址。 服务器使用
AI_PASSIVE并将主机名设为NULL,会尝试绑定通配地址。这通常意味着本机多个网络接口都可能接收连接,而不只是本地回环接口。若只做本机测试,应显式绑定回环地址,或配置防火墙限制来源。 - 端口和权限配置错误。 TCP/UDP 端口号范围为 0 到 65535;端口 0 常用于请求系统分配临时端口,本文服务器要求 1 到 65535。Unix 系统对低端口的绑定权限规则依发行版和配置而异,不应假设所有环境都能以普通用户绑定任意端口。
- 把 backlog 当成并发上限。
listen的 backlog 与等待处理的连接队列有关,并受系统限制。它不会让顺序执行的服务器自动获得并发能力,也不能直接代表最大连接数。 - 没有设置超时。 示例使用阻塞式调用。客户端可能在连接或接收时等待较长时间;生产程序应根据需求使用套接字超时、非阻塞 I/O 配合
poll/select/epoll等机制,并设定连接和请求的时间预算。
八、代码适用范围与安全注意事项
示例面向教学和受控测试:服务器单线程、每次只处理一个客户端,不验证用户身份,不加密传输,也没有请求速率限制、日志轮转或服务监督配置。它没有因而适合直接暴露到公网。即使程序没有明显内存越界,开放网络服务仍可能遭遇慢速发送、连接耗尽、资源滥用和协议滥用。
通信内容默认以明文传输。网络中间节点可能观察或篡改数据;若涉及口令、个人信息或敏感操作,应使用经过审核的 TLS 实现和正确的证书验证,或使用受信任的安全传输通道。自行设计加密算法并不能替代成熟协议与正确的密钥管理。
绑定所有接口会扩大可访问范围;端口能否从公网访问还受路由器、云安全组和主机防火墙影响。部署前应明确服务需要被哪些网络访问,优先绑定必要接口,并只开放必要端口。需要限制客户端身份时,应在协议层加入可靠的认证与授权,不能把“只有知道端口的人才能连上”当作安全措施。
服务器的逐字节读取方式在低流量演示中便于解释,但生产环境应使用缓冲式解析,并对每个连接设置最大消息长度、空闲超时和资源预算。若采用线程或进程并发,还要管理最大并发数、关闭时的资源回收和共享状态。长时间运行的服务也需要对文件描述符耗尽、连接重置、解析失败和日志中敏感内容等情况有明确策略。
九、下一步:从示例走向实际程序
可以先把服务器改为一次处理多行请求,观察为什么需要在一次 recv 中保存并解析多条消息;再加入有界缓冲、超时和并发处理。也可以把 IPv4/IPv6 地址族固定为某一种,或改写为 Unix 域套接字,体会不同通信范围与权限模型。
理解套接字的关键不是记住一组函数调用,而是明确三件事:连接由谁建立和关闭,数据如何划分为应用层消息,以及每种错误和资源上限如何处理。把这三点定义清楚,才能从一个可运行的演示程序,逐步构建可靠的网络服务。









