嵌入式开发学习:UDP和TCP
本文介绍了C语言中UDP和TCP套接字编程的基本实现。
一、什么是套接字 (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()来为每个客户端创建一个单独的处理流程?
更多推荐
所有评论(0)