
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 通信的大致步骤如下:
- 服务端创建 Socket,绑定本机 IP 地址和端口。
- 服务端进入监听状态,等待客户端连接。
- 客户端创建 Socket,连接服务端地址和端口。
- 服务端接受连接;双方通过连接 Socket 收发字节。
- 通信结束后关闭 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 等成熟安全协议,并验证证书与对端身份;同时仍需在应用层执行授权、输入校验、日志记录和速率限制。不要自行设计加密算法,也不要把“连接成功”误认为“对方可信”。
十一、完成作品后的练习方向
先确保服务端和客户端在本机稳定运行,再逐步扩展功能,每次只改变一个部分:
- 让客户端从命令行读取一行输入,而不是发送固定字符串。
- 让服务端在一条连接中循环处理多行消息,并约定退出指令。
- 为读取操作增加超时,测试客户端连接后不发送数据的情况。
- 实现长度前缀协议,并测试零长度、超长以及不完整消息。
- 尝试使用
poll或epoll管理多个连接,再比较阻塞版的差异。 - 仅在可信的隔离环境中测试局域网访问,并先确认服务绑定范围和防火墙规则。
完成这些练习时,持续检查四件事:消息如何分帧、缓冲区边界在哪里、连接何时超时或关闭、输入由谁验证。能把这四个问题说清楚,才算从“能连上”迈向“能可靠地通信”。
总结
这份入门作品展示了 Linux TCP Socket 的基本路径:创建 Socket、绑定地址、监听连接、接受客户端、按约定读取一行数据、发送响应,最后关闭连接。它可以在本机直接编译运行,也刻意限制在回环地址和小型文本协议内,减少初学者第一次实践时的干扰。
最值得带走的不是某个固定代码模板,而是几个判断:TCP 是字节流,应用必须定义消息边界;系统调用可能失败或只处理部分数据;监听队列不等于并发能力;回环地址、局域网地址和通配地址的可达范围不同;Socket 本身不提供认证与加密。掌握这些边界后,再逐步加入超时、并发、协议校验和 TLS,才能把入门示例发展成适合真实场景的网络程序。









