传输层,从端口号到 UDP 协议 目录一、传输层网络通信的 “快递分拣站”二、端口号传输层的 “门牌号”2.1 什么是端口号2.2 五元组唯一标识一次通信2.3 端口号的范围划分1知名端口号0-1023“公共服务的固定门牌号”2动态端口号1024-65535“客户端的临时门牌号”2.4 两个常见问题端口号的 “绑定规则”问题 1一个进程能绑定多个端口号吗—— 可以问题 2一个端口号能被多个进程绑定吗—— 通常不可以三、UDP 协议传输层的 “快速快递”3.1 UDP 的报头数据的 “快递面单”3.2 UDP 的三大核心特点1无连接“不用提前打招呼直接发”2不可靠“发完不管丢了不补”3面向数据报“整包发整包收不能拆”3.3 UDP 的缓冲区“临时存放数据的小仓库”1发送缓冲区“没有真正的缓存直接交内核”2接收缓冲区“临时存数据供应用层读取”3.4 UDP 的 “全双工” 特性“同时收发互不干扰”3.5 UDP 的使用注意事项1数据长度限制单次最大传 64K2基于 UDP 的常见应用层协议四、实战手把手写 UDP 客户端和服务器4.1 核心函数详解1创建 UDP 套接字socket()2绑定端口bind()3发送数据sendto()4接收数据recvfrom()5关闭套接字close()4.2 完整代码UDP 服务器4.3 完整代码UDP 客户端4.4 代码运行步骤1编译代码2启动服务器3启动客户端新终端4查看通信效果五、总结在网络通信中有一层 “承上启下” 的关键环节 ——传输层。它一边接收应用层比如 HTTP、QQ的 “数据包裹”一边通过网络层IP 协议把包裹送到目标主机还得确保 “包裹” 能精准交到对方的应用程序手上。这篇博客会从传输层的 “门牌号”端口号讲起深入 UDP 协议的原理最后用实战代码带你亲手实现 UDP 通信让你彻底搞懂传输层是怎么工作的。一、传输层网络通信的 “快递分拣站”先搞明白传输层的核心作用承上从应用层接收数据给数据 “贴标签”比如端口号告诉网络层 “这个数据要交给对方的哪个应用”启下接收网络层传来的外部数据根据 “标签” 把数据交给本机对应的应用层程序核心目标解决 “数据交给谁” 的问题定位应用程序并提供不同的传输策略比如 UDP 的 “快”、TCP 的 “稳”。举个生活类比你在淘宝买了东西商家应用层把快递数据交给快递站传输层。快递站会贴上面单 —— 收件人地址IP、收件人门牌号端口号、快递类型协议比如 UDP/TCP然后交给物流网络层运输。对方快递站收到后根据门牌号把快递交给收件人对方应用程序。二、端口号传输层的 “门牌号”要让数据精准找到对方的应用程序就需要一个 “唯一标识”——端口号Port。它就像你家的门牌号快递员传输层根据门牌号才能把快递数据准确送到你家应用程序而不是邻居家。2.1 什么是端口号端口号是一个16 位的整数范围 0-65535用于标识一台主机上的 “唯一应用程序”。每个需要网络通信的应用比如浏览器、微信、服务器都会占用一个或多个端口号。比如浏览器访问百度时百度服务器的 HTTP 服务用80 端口你用微信发消息时微信客户端会被系统分配一个动态端口号比如 56789。2.2 五元组唯一标识一次通信传输层要区分 “哪个应用的哪次通信”靠的是五元组—— 用 5 个信息组合唯一确定一次网络通信组成部分作用类比举例源 IP 地址寄件人地址你的电脑 IP192.168.1.100源端口号寄件人门牌号微信客户端端口56789目的 IP 地址收件人地址微信服务器 IP123.123.123.123目的端口号收件人门牌号微信服务器端口8080协议号快递类型如顺丰、中通UDP 协议号17TCP 协议号6为什么需要五元组比如你同时用浏览器端口 5000和微信端口 56789访问网络浏览器→百度80 端口的通信五元组是192.168.1.100:5000 → 202.108.22.5:80TCP微信→微信服务器8080 端口的通信五元组是192.168.1.100:56789 → 123.123.123.123:8080UDP传输层通过五元组能准确区分这两次通信不会把百度的响应发给微信也不会把微信的消息发给浏览器。查看五元组的实战命令在 Linux/macOS 终端输入netstat -n就能看到当前所有通信的五元组信息plaintextProto Recv-Q Send-Q Local Address Foreign Address State udp 0 0 192.168.1.100:56789 123.123.123.123:8080 ESTABLISHED tcp 0 0 192.168.1.100:5000 202.108.22.5:80 ESTABLISHEDLocal Address源 IP: 源端口Foreign Address目的 IP: 目的端口Proto协议号udp/tcp。2.3 端口号的范围划分端口号总共 65536 个0-65535按用途分为两类1知名端口号0-1023“公共服务的固定门牌号”这些端口号是 “约定俗成” 的对应常用的公共服务比如端口号对应服务应用场景22SSH远程登录远程管理 Linux 服务器21FTP文件传输服务器文件上传下载23Telnet远程终端早期远程登录已被 SSH 替代80HTTP网页服务浏览器访问网页443HTTPS安全网页淘宝、支付宝等安全访问这些端口号一般由系统或服务器程序占用普通客户端程序不能用。2动态端口号1024-65535“客户端的临时门牌号”普通客户端程序比如你的浏览器、微信、游戏启动时会由操作系统随机分配一个这个范围的端口号通信结束后释放。比如你打开浏览器访问百度操作系统可能给浏览器分配 5000 端口关闭浏览器后5000 端口就会被释放下次打开浏览器可能分配 5001 端口。2.4 两个常见问题端口号的 “绑定规则”问题 1一个进程能绑定多个端口号吗—— 可以比如一个服务器程序如 Nginx可以同时绑定 80 端口HTTP和 443 端口HTTPS这样客户端既能用 HTTP 访问也能用 HTTPS 访问。原理进程可以创建多个 “套接字Socket”每个套接字绑定一个不同的端口号独立接收数据。问题 2一个端口号能被多个进程绑定吗—— 通常不可以端口号的核心作用是 “唯一标识一个应用程序”如果一个端口号被多个进程绑定传输层收到数据后就不知道该交给哪个进程了。例外特殊场景下如负载均衡可以通过内核参数配置 “端口复用”但这是高级操作普通开发不用考虑。三、UDP 协议传输层的 “快速快递”传输层有两个核心协议UDP 和 TCP。今天先讲UDP用户数据报协议—— 它的特点是 “快、简单”适合对实时性要求高、允许少量数据丢失的场景比如视频通话、DNS 查询。3.1 UDP 的报头数据的 “快递面单”UDP 数据在传输时会在应用层数据前加一个8 字节的报头相当于快递面单报头包含 4 个字段每个字段都是 16 位2 字节字段长度作用说明源端口号16 位数据从哪个端口发来可选0 表示无目的端口号16 位数据要送到哪个端口必须有UDP 长度16 位整个 UDP 数据报的长度报头 数据UDP 校验和16 位检查数据是否损坏出错则丢弃报头的作用传输层收到 UDP 数据后先读 8 字节报头根据 “目的端口号” 找到对应的应用程序通过 “UDP 长度” 知道后续要读多少字节的应用层数据通过 “校验和” 判断数据在传输中是否被篡改比如网络干扰如果出错直接丢弃数据不通知应用层。3.2 UDP 的三大核心特点1无连接“不用提前打招呼直接发”UDP 通信前不需要像 TCP 那样 “三次握手” 建立连接 —— 只要知道对方的 IP 和端口号就能直接发送数据就像发短信不用先拨号编辑好内容直接发给对方号码。优点通信延迟低适合实时场景比如语音聊天不能等连接建立再说话缺点不知道对方是否在线可能发了 “空包”对方没收到。2不可靠“发完不管丢了不补”UDP 没有 “确认机制” 和 “重传机制”发送方发完数据不知道对方有没有收到如果数据在传输中丢失比如网络拥堵UDP 不会重传也不会告诉应用层 “数据丢了”。举例你用 UDP 发 10 条消息对方可能只收到 8 条丢了 2 条但你完全不知道。适合场景允许少量丢失的场景比如视频通话丢几帧画面不影响整体观看DNS 查询丢了再查一次就行。3面向数据报“整包发整包收不能拆”UDP 把应用层的数据当作 “一个完整的数据包”数据报既不拆分也不合并发送方调用一次sendto发送函数发送 100 字节数据UDP 就把这 100 字节当作一个数据报发送接收方必须调用一次recvfrom接收函数一次性接收这 100 字节不能分 10 次每次收 10 字节。反例如果发送方分两次各发 50 字节接收方不能一次收 100 字节 —— 会只收到第一个 50 字节第二个 50 字节下次收。3.3 UDP 的缓冲区“临时存放数据的小仓库”UDP 的发送和接收缓冲区是分开的作用不同很多人容易搞混这里重点讲1发送缓冲区“没有真正的缓存直接交内核”UDP 没有 “真正的发送缓冲区”—— 应用层调用sendto发送数据时数据会直接拷贝到内核缓冲区内核尽快把数据发给网络层发送完成后内核缓冲区的 data 就会被释放不会保留不像 TCP 会保留数据用于重传。注意如果内核缓冲区满了sendto会阻塞默认或返回错误需要等内核腾出空间。2接收缓冲区“临时存数据供应用层读取”UDP 有 “接收缓冲区”—— 内核收到 UDP 数据后会把数据报放到接收缓冲区应用层调用recvfrom时从缓冲区里取一个数据报。接收缓冲区的两个关键特性无序存放数据报的接收顺序可能和发送顺序不一致比如网络路由不同缓冲区会按 “收到的顺序” 存放不管发送顺序满了就丢如果缓冲区满了新收到的 UDP 数据报会直接被丢弃不会通知应用层。为什么需要接收缓冲区如果没有接收缓冲区应用层必须 “实时读取” 数据 —— 一旦应用层忙比如处理其他任务内核收到的数据就会被丢弃浪费网络资源。有了缓冲区数据可以临时存放应用层空闲时再读。3.4 UDP 的 “全双工” 特性“同时收发互不干扰”UDP 的套接字Socket既能发送数据也能接收数据这种特性叫 “全双工”就像打电话你可以一边说话发送一边听对方说话接收不用等对方说完再开口。举例UDP 客户端和服务器可以同时给对方发消息不用等对方回复再发。3.5 UDP 的使用注意事项1数据长度限制单次最大传 64KUDP 报头的 “UDP 长度” 是 16 位最大能表示 65535 字节64K所以单次 UDP 数据报的最大长度是 64K包含 8 字节报头实际应用层数据最大约 64K-865527 字节。如果要传超过 64K 的数据比如传一个 100K 的文件需要在应用层手动分包发送方把 100K 文件分成 2 个包64K 和 36K每次发一个给每个包加 “包序号”比如 1、2接收方收到包后按 “包序号” 拼接成完整文件。2基于 UDP 的常见应用层协议UDP 虽然不可靠但因为 “快”被很多核心协议采用协议名称用途为什么用 UDPDNS域名解析如百度→IP查询快丢了再查一次延迟低DHCP自动分配 IP 地址不需要可靠一次广播就能分配 IPTFTP简单文件传输协议简单适合小文件如路由器固件视频 / 语音实时通话实时性优先丢几帧不影响体验四、实战手把手写 UDP 客户端和服务器我们用 C 写一个简单的 UDP 通信程序服务器绑定 8888 端口接收客户端消息返回 “收到了XXX”客户端输入消息发给服务器接收服务器的回复并显示。4.1 核心函数详解在写代码前先搞懂 UDP 编程的 4 个核心函数socket、bind、sendto、recvfrom、close。1创建 UDP 套接字socket()#include sys/socket.h int socket(int domain, int type, int protocol);作用创建一个 UDP “通信管道”套接字用于收发数据。参数domain协议族必须是AF_INETIPv4type套接字类型SOCK_DGRAM表示 UDPprotocol协议填 0自动选择 UDP 协议返回值成功返回 “套接字描述符”非负整数类似文件句柄失败返回 - 1错误原因存在errno中。示例int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd -1) { perror(socket创建失败); // 打印错误原因 exit(1); }2绑定端口bind()#include sys/socket.h int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);作用给 UDP 套接字绑定一个固定的 IP 和端口号服务器必须绑定客户端可选。参数sockfdsocket()返回的套接字描述符addr存放 IP 和端口的结构体struct sockaddr_inaddrlen结构体的大小sizeof(struct sockaddr_in)返回值成功0失败-1。示例服务器绑定 8888 端口struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); // 初始化结构体 server_addr.sin_family AF_INET; // IPv4 server_addr.sin_port htons(8888); // 端口号转网络字节序 server_addr.sin_addr.s_addr INADDR_ANY; // 绑定所有网卡的IP0.0.0.0 if (bind(sockfd, (struct sockaddr*)server_addr, sizeof(server_addr)) -1) { perror(绑定端口失败); close(sockfd); exit(1); }为什么服务器必须绑定端口客户端需要知道服务器的端口号才能发消息如果服务器不绑定固定端口每次启动端口号都变客户端找不到服务器。3发送数据sendto()#include sys/socket.h ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);作用通过 UDP 套接字给指定 IP 和端口的目标发送数据。参数sockfd套接字描述符buf要发送的数据缓冲区比如字符串len数据的长度字节数flags发送标志填 0默认dest_addr目标的 IP 和端口结构体客户端填服务器的服务器填客户端的addrlen目标结构体的大小返回值成功返回实际发送的字节数失败返回 - 1。示例客户端发消息给服务器const char *msg Hello UDP Server!; struct sockaddr_in server_addr; // 服务器的IP和端口 // 初始化server_addr略见客户端代码 ssize_t n sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr*)server_addr, sizeof(server_addr)); if (n -1) { perror(发送失败); close(sockfd); exit(1); } printf(发送了%d字节%s\n, (int)n, msg);4接收数据recvfrom()#include sys/socket.h ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);作用从 UDP 套接字接收数据并获取发送方的 IP 和端口。参数sockfd套接字描述符buf存放接收数据的缓冲区len缓冲区的大小避免溢出flags接收标志填 0默认阻塞等待数据src_addr输出参数存放发送方的 IP 和端口addrlen输入输出参数先填sizeof(src_addr)接收后存实际长度返回值成功返回实际接收的字节数失败返回 - 1注意UDP 没有 “连接关闭” 的概念不会返回 0。示例服务器接收客户端消息char buf[1024] {0}; // 缓冲区大小1024字节 struct sockaddr_in client_addr; // 存放客户端的IP和端口 socklen_t client_len sizeof(client_addr); ssize_t n recvfrom(sockfd, buf, sizeof(buf)-1, 0, (struct sockaddr*)client_addr, client_len); if (n -1) { perror(接收失败); close(sockfd); exit(1); } buf[n] \0; // 加字符串结束符 // 把客户端IP从网络字节序转成字符串 char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, INET_ADDRSTRLEN); printf(收到来自%s:%d的消息%d字节%s\n, client_ip, ntohs(client_addr.sin_port), (int)n, buf);5关闭套接字close()#include unistd.h int close(int fd);作用关闭 UDP 套接字释放资源。参数fd套接字描述符返回值成功 0失败 - 1示例close(sockfd);4.2 完整代码UDP 服务器#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 // 服务器绑定的端口号 #define BUF_SIZE 1024 // 缓冲区大小 int main() { // 1. 创建UDP套接字 int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd -1) { perror(socket创建失败); exit(1); } printf(UDP套接字创建成功fd%d\n, sockfd); // 2. 绑定端口服务器必须绑定 struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; // IPv4 server_addr.sin_port htons(PORT); // 端口号转网络字节序 server_addr.sin_addr.s_addr INADDR_ANY; // 绑定所有网卡IP0.0.0.0 if (bind(sockfd, (struct sockaddr*)server_addr, sizeof(server_addr)) -1) { perror(绑定端口失败); close(sockfd); exit(1); } printf(服务器绑定端口%d成功等待客户端消息...\n, PORT); // 3. 循环接收客户端消息并发回响应 while (1) { char buf[BUF_SIZE] {0}; struct sockaddr_in client_addr; // 存放客户端信息 socklen_t client_len sizeof(client_addr); // 接收客户端消息 ssize_t recv_len recvfrom(sockfd, buf, BUF_SIZE-1, 0, (struct sockaddr*)client_addr, client_len); if (recv_len -1) { perror(接收消息失败); continue; } buf[recv_len] \0; // 加字符串结束符 // 解析客户端IP和端口 char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, client_addr.sin_addr, client_ip, INET_ADDRSTRLEN); uint16_t client_port ntohs(client_addr.sin_port); printf(收到来自%s:%d的消息%s\n, client_ip, client_port, buf); // 构造响应消息在客户端消息前加“收到了” char resp[BUF_SIZE]; snprintf(resp, BUF_SIZE, 收到了%s, buf); // 发送响应给客户端 ssize_t send_len sendto(sockfd, resp, strlen(resp), 0, (struct sockaddr*)client_addr, client_len); if (send_len -1) { perror(发送响应失败); continue; } printf(给%s:%d发送响应%s\n, client_ip, client_port, resp); } // 4. 关闭套接字循环不会退出实际不会执行 close(sockfd); return 0; }4.3 完整代码UDP 客户端#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_IP 127.0.0.1 // 服务器IP本地测试用127.0.0.1 #define SERVER_PORT 8888 // 服务器端口号 #define BUF_SIZE 1024 // 缓冲区大小 int main() { // 1. 创建UDP套接字 int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd -1) { perror(socket创建失败); exit(1); } printf(UDP套接字创建成功fd%d\n, sockfd); // 2. 初始化服务器地址结构体 struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; // IPv4 server_addr.sin_port htons(SERVER_PORT); // 服务器端口转网络字节序 // 服务器IP转网络字节序 if (inet_pton(AF_INET, SERVER_IP, server_addr.sin_addr) 0) { perror(服务器IP无效); close(sockfd); exit(1); } // 3. 循环输入消息发送给服务器并接收响应 char buf[BUF_SIZE]; while (1) { // 输入要发送的消息 printf(请输入要发送的消息输入q退出); fgets(buf, BUF_SIZE, stdin); // 去掉fgets自带的换行符 buf[strcspn(buf, \n)] \0; // 输入q退出 if (strcmp(buf, q) 0) { printf(客户端退出\n); break; } // 发送消息给服务器 ssize_t send_len sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr*)server_addr, sizeof(server_addr)); if (send_len -1) { perror(发送消息失败); continue; } printf(发送给服务器%s:%d的消息%s\n, SERVER_IP, SERVER_PORT, buf); // 接收服务器的响应 char resp[BUF_SIZE] {0}; struct sockaddr_in server_resp_addr; // 存放服务器响应的地址可忽略 socklen_t server_len sizeof(server_resp_addr); ssize_t recv_len recvfrom(sockfd, resp, BUF_SIZE-1, 0, (struct sockaddr*)server_resp_addr, server_len); if (recv_len -1) { perror(接收响应失败); continue; } resp[recv_len] \0; printf(收到服务器的响应%s\n, resp); } // 4. 关闭套接字 close(sockfd); return 0; }4.4 代码运行步骤1编译代码在 Linux/macOS 终端输入# 编译服务器 gcc udp_server.c -o udp_server # 编译客户端 gcc udp_client.c -o udp_client2启动服务器./udp_server # 输出 # UDP套接字创建成功fd3 # 服务器绑定端口8888成功等待客户端消息...3启动客户端新终端./udp_client # 输出 # UDP套接字创建成功fd3 # 请输入要发送的消息输入q退出Hello UDP!4查看通信效果客户端输入Hello UDP!后会显示plaintext发送给服务器127.0.0.1:8888的消息Hello UDP! 收到服务器的响应收到了Hello UDP!服务器会显示plaintext收到来自127.0.0.1:56789的消息Hello UDP! 给127.0.0.1:56789发送响应收到了Hello UDP!注客户端的端口号 56789 是系统动态分配的每次启动可能不同五、总结这篇博客从传输层的基础讲起带你掌握了两个核心知识点端口号传输层的 “门牌号”用 16 位整数标识应用程序五元组唯一确定一次通信知名端口号是公共服务的固定端口UDP 协议传输层的 “快速快递”无连接、不可靠、面向数据报适合实时场景核心函数是socket、bind、sendto、recvfrom。---------------------------------------------------------------------------------------------------------------------------------通过实战代码你亲手实现了 UDP 的客户端和服务器理解了 UDP 通信的完整流程。下一篇我们会讲传输层的另一个核心协议 ——TCP它的 “可靠传输” 特性是如何实现的以及和 UDP 的区别。