Socket、TCP 与 UDP 实操:用 C 语言完成 4 个最小通信程序
在理解 socket、TCP 和 UDP 的概念之后,最有效的学习方式不是继续停留在术语层面,而是亲手完成一组最小可运行的通信程序。
本文基于 C 语言给出 4 个可以直接编译和验证的示例:
- 一个最小
TCP server - 一个最小
TCP client - 一个最小
UDP server - 一个最小
UDP client
这组示例的目标并不是构建完整的网络服务,而是帮助读者建立最基础、最重要的认知:
- TCP 和 UDP 在代码结构上到底有什么差异
- 服务端和客户端各自承担什么职责
- 哪些步骤属于"建立连接",哪些步骤属于"收发数据"
- 为什么很多上层协议会选择构建在 TCP 之上
本文所有示例都刻意保持在"最小闭环"范围内,便于读者快速跑通、观察行为,再逐步扩展到更接近实际工程的版本。
一、代码组织与编译方式
为了让文章本身自包含,下面直接给出完整代码。读者可以新建一个目录,例如 socket_examples,然后按以下文件名保存:
Makefiletcp_server.ctcp_client.cudp_server.cudp_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_serverbuild/tcp_clientbuild/udp_serverbuild/udp_client
这些程序只依赖标准 socket API,不依赖第三方网络库,因此非常适合作为 C 网络编程的第一组练习。
二、TCP 实验:从监听到连接建立
TCP 的核心特征包括:
- 面向连接
- 可靠传输
- 面向字节流
因此,TCP 程序的重点不只是"发送数据",而是先完成连接建立,再在连接上进行收发。
1. TCP 服务端的职责
tcp_server.c 展示了一个最小 TCP 服务端的标准流程:
- 创建监听 socket
- 绑定本地地址和端口
- 进入监听状态
- 接受客户端连接
- 从连接中读取数据
- 返回一条响应消息
核心调用顺序如下:
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 的结构则更加直接:
- 创建 socket
- 连接远端服务端
- 发送消息
- 读取响应
核心代码如下:
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 的工作流程非常直接:
- 创建 UDP socket
- 绑定本地端口
- 接收客户端发来的数据报
- 根据源地址回发响应
核心代码如下:
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 只需要完成三件事:
- 创建 socket
- 向目标地址发送数据报
- 接收服务端回包
核心代码如下:
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 -lnt、ss -lun、netstat 或 nc 等工具辅助排查,通常会比直接猜测问题更高效。
五、这组示例真正帮助理解什么
从代码长度看,这 4 个程序都很短;但从学习价值看,它们覆盖了网络编程里最基本也最重要的一组区别。
1. TCP 比 UDP 多出一整段"连接管理"
TCP 示例中多出的 listen()、accept() 和 connect(),本质上对应的是连接建立与连接管理。
这也是为什么 TCP 更适合需要稳定会话和可靠交付的场景。
2. TCP 面向字节流,UDP 面向数据报
本示例中的消息都很短,因此还看不出 TCP 粘包、拆包等问题;但一旦进入真实业务场景,TCP 的"无消息边界"会很快成为必须处理的工程问题。
UDP 则天然保留数据报边界,因此在消息模型上更直接。
3. 服务端和客户端的职责是不同的
在这组示例中,读者可以非常明确地看到:
- 服务端负责绑定端口并等待外部请求
- 客户端负责构造目标地址并主动发起访问
这套角色分工会在后续几乎所有网络协议与中间件中重复出现。
4. API 简单不代表系统简单
UDP 的代码量更少,但它并没有自动解决重试、顺序、可靠性、重复包过滤等问题。
这些问题如果在业务上不可忽略,就必须由应用层自己承担。
六、从教学示例走向工程实践,还缺什么
本文的代码刻意压缩在"最小闭环"内,因此并没有覆盖真实工程中必须面对的一些问题。例如:
- 循环处理多个请求
- 并发连接
- 超时控制
- 错误恢复
- 粘包与拆包
- 连接中断后的资源回收
- 更完整的协议设计
因此,这组代码更适合作为"建立第一性理解"的入口,而不是直接作为生产代码模板。
如果读者准备继续深入,推荐按下面的顺序逐步扩展:
- 让 TCP 服务端支持循环接收多个客户端连接
- 为 TCP 设计明确的消息边界,例如"长度字段 + 消息体"
- 让 UDP 服务端进入持续接收循环,而不是只处理单次请求
- 为 UDP 消息增加序号、超时和重发机制
- 在更高层引入应用协议,例如 MQTT 或自定义二进制协议
七、与 MQTT 的关系
如果读者此前已经接触过 MQTT,这组示例还有一个重要价值:它能帮助你把"应用层协议"和"传输层通信"真正区分开。
以 MQTT 为例,它通常建立在 TCP 之上。也就是说:
- 应用层由 MQTT 定义消息格式和交互规则
- 传输层由 TCP 提供可靠字节流
- 程序实现上通常通过 socket API 完成底层通信
因此,学习原始 socket/TCP/UDP 编程,并不是为了替代 MQTT 这样的高层协议,而是为了理解这些协议背后依赖的运行基础。
八、结语
对于 C 语言网络编程的初学者来说,最难的部分往往不是 API 数量,而是没有建立清晰的分层模型。
这组 4 个最小示例的意义,正是在于把最核心的结构先固定下来:
TCP serverTCP clientUDP serverUDP client
当这四种角色和它们的调用路径变得清晰之后,后续无论继续学习粘包处理、并发模型、MQTT、HTTP 还是更复杂的网络服务,都会顺畅得多。
从这个角度看,最小示例并不是"过于简单的玩具代码",而是理解网络编程抽象层次的起点。