教程 05:从 udpecho_raw_init() 到 Echo Reply——UDP Raw API、PCB 与回调数据通路
摘要:从 UDP 数据报与端口分发模型出发,沿 Raw API 真实入口追踪 PCB 建立、接收匹配、回调、回包与 pbuf ownership。
[TOC]
UDP(User Datagram Protocol,用户数据报协议)是 IP 之上的无连接传输协议。它把应用一次提交的一条消息作为一个 **datagram(数据报)**发送,并在接收端保留这条消息的边界;UDP 自身不先建立连接,也不保证可靠到达、顺序或自动重传。S4S6 本篇从 upstream Raw UDP Echo 的真实入口出发,重点回答:一个发往 UDP port 7 的 datagram,怎样被 udp_pcb 匹配、交给回调,再沿同一 PCB 发回发送端。
Stage 4 已经走到 ip4_input() 的上层协议分发。本篇只改变一个关键字段:IPv4 Header 的 Protocol 为 17 时,payload 交给 UDP;Ethernet、ARP 与 IPv4 前半段不再重复展开。S3S4
阅读源码前:建议提前阅读
下面资料用于提前建立规范和 API 位置感,不是继续阅读正文的强制条件:
- RFC 768 — User Datagram Protocol:重点看 UDP Header,以及 Source Port(源端口)、Destination Port(目标端口)、Length(UDP Header + payload 总长度)、Checksum(校验字段)四个字段。S4
- lwIP 2.1.x — UDP Raw API:用于先认识
udp_new()、udp_bind()、udp_recv()与udp_sendto()这些公开 API;本文的源码事实仍以固定 commit 为准。S8 - IANA — Service Name and Transport Protocol Port Number Registry:用于确认示例使用的 UDP port 7 注册名为
echo。S7 - Cloudflare — 什么是 UDP?:第一次接触 UDP 时,可用于快速理解“无连接、datagram、可靠性由上层决定”的整体边界。S6
先建立 UDP Echo 的协议模型
Datagram、endpoint 与 port 分别是什么
UDP 的基本数据单位是 datagram。一个 datagram 由 8-byte UDP Header 加应用 payload 组成;Length 表示 UDP Header 与 payload 的总长度,Checksum 用于检测传输中的数据损坏。S4
UDP **endpoint(端点)**可以理解为“IP 地址 + UDP port”。Port 是 UDP Header 中的 16-bit 端口号,用来把到达同一台主机的 datagram 分发给不同应用端点。对本例最重要的是方向:
| UDP Header 字段 | 谁填写 | 接收端如何使用 |
|---|---|---|
| Source Port | 发送端 | 回包时作为目标 port;若 PCB 绑定了特定远端 endpoint,还会参与远端匹配 |
| Destination Port | 发送端 | 选择本地 UDP endpoint;本例最终匹配 pcb->local_port == 7 |
| Length | 发送端 | 描述当前 UDP datagram 的总长度 |
| Checksum | 发送端 | 校验 UDP 伪首部(由 IP 源/目标地址、协议号等组成且不作为额外字节在线上传输)、UDP Header 与 payload;当前正文只关注 lwIP 在 RX/TX 路径中的验证与生成位置 |
这里的 **PCB(Protocol Control Block,协议控制块)**是 lwIP Core 保存一个协议端点运行时状态的对象。UDP 的 struct udp_pcb 记录 local/remote IP、local/remote port、flags 和接收 callback;它不是 packet buffer,也不是 Socket API 的整数 fd。S2
**pbuf(packet buffer)**是 Stage 3 已经建立的 packet 数据与元数据容器;本篇只关心 UDP 如何改变 p->payload 的数据视图以及 callback 最终由谁释放 RX pbuf。
为什么 UDP Echo 不需要三次握手
UDP 是无连接协议。Host 不需要先和 lwIP 建立 UDP connection,就可以直接发送 datagram。假设 Host 使用一个临时源端口 53000,目标是 198.18.0.200:7:
1 | Host endpoint lwIP Echo endpoint |
IPv4 层先根据 Protocol = 17 把 payload 交给 UDP;UDP 再根据 Destination Port、目标 IP 以及 PCB 的连接属性选择接收 PCB。Echo callback 收到 payload 后,以原 packet 的 Source IP/Source Port 作为回包目标,因此 Echo Reply 返回 198.18.0.1:53000。S2S3
这条最小成功交互可以先画成纯协议流程:
1 | sequenceDiagram |
协议动作怎样映射到 lwIP 源码
| 协议阶段 | 协议对象/动作 | lwIP 实现位置 | 关键对象 | 完成后的下一步 |
|---|---|---|---|---|
| 建立本地端点 | 绑定本地 UDP port 7 | udp_new_ip_type() → udp_bind() |
struct udp_pcb |
注册 RX callback |
| 等待 datagram | 保存接收处理函数 | udp_recv() |
pcb->recv / recv_arg |
packet 到达后由 Core 调用 |
| IPv4 分发 | Protocol = 17 |
ip4_input() → udp_input() |
RX pbuf |
解析 UDP Header |
| UDP 分发 | Destination Port / IP / connected 条件匹配 | udp_input() |
udp_pcbs 链表 |
选出接收 PCB |
| 应用接收 | payload 交给 Echo callback | pcb->recv() → udpecho_raw_recv() |
RX pbuf |
调用 udp_sendto() |
| Echo TX | 构造新的 UDP Header 并进入 IP output | udp_sendto() |
同一 RX payload + TX Header | 返回 Host 后释放 RX pbuf 引用 |
下面进入真实入口 udpecho_raw_init(),并按这张表的执行顺序逐步下钻。
1. Raw API 的第一个真实入口:udpecho_raw_init()
upstream Raw UDP Echo 的初始化代码很短:S1
1 | udpecho_raw_pcb = udp_new_ip_type(IPADDR_TYPE_ANY); |
这三步分别建立三类状态:
1 | flowchart LR |
前面的协议模型已经定义 PCB。进入实现后,这个抽象具体落在 struct udp_pcb,其中保存 local/remote IP、port、flags 和接收 callback 等状态。S2
再次把三个容易混淆的对象放在一起:
| 对象 | 生命周期 | 主要内容 |
|---|---|---|
struct udp_pcb |
UDP endpoint 生命周期 | 地址、端口、flags、callback |
struct pbuf |
单个 packet 生命周期 | packet bytes + ownership |
| Linux/socket fd | Socket API 才出现 | application-facing descriptor |
Raw API 直接操作 PCB,所以应用代码必须遵守 lwIP Core 的线程上下文和 callback ownership 规则。
2. udp_new_ip_type():PCB 从哪里来
udp_new()/udp_new_ip_type() 最终从 MEMP_UDP_PCB 固定对象池申请 struct udp_pcb,初始化后挂进 UDP PCB 管理体系。S2
这和 Stage 3 的 PBUF_POOL 是两种完全不同的 pool:
MEMP_UDP_PCB:固定大小的协议控制对象;MEMP_PBUF_POOL:固定大小的 packet buffer。
一个 UDP endpoint 可以连续接收很多 pbuf,但通常只对应一个长期存在的 PCB。
3. udp_bind():把标准 UDP Destination Port 映射成 PCB 条件
udp_bind(pcb, IP_ANY_TYPE, 7) 把 PCB 的 local endpoint 配置成“任意本地地址 + UDP port 7”。S2 IANA 注册表中 port 7 的 UDP service name 是 echo,这正是 upstream 示例选择该端口的背景。S7
在前面的协议模型里,Destination Port 负责选择接收端点;在 lwIP 中,这个标准语义进一步落实为 udp_input() 对 pcb->local_port 的匹配条件。
4. udp_recv() 并不收包,它只是注册 callback
udp_recv(pcb, udpecho_raw_recv, NULL) 把函数指针和 callback argument 保存到 PCB。S2
这一点必须和真正的 RX 事件分开:
1 | 初始化阶段 |
Raw API 是 callback 模型,不是“应用线程阻塞在 recv()”模型。Stage 6 的 Netconn 会专门改变这一点。
5. 一个 UDP datagram 怎样走到 udp_input()
到这里完成的是协议总流程中的“本地 endpoint 已建立并注册 callback”。现在协议从初始化阶段转入 RX:Host 真正发送 datagram,下面继续追它怎样进入 UDP Core。
Host 发送到 198.18.0.200:7 后,前半段仍然是:S3
1 | TAP -> pbuf |
ip4_input() 看到:
1 | Protocol = 17 |
于是移除 IPv4 Header 后调用 udp_input()。S3
进入 udp_input() 时:
1 | p->payload -> UDP Header |
6. udp_input() 到底怎样找到应该调用哪个 PCB callback
前面的协议模型已经说明 UDP Header 的四个字段。进入 udp_input() 后,当前分发首先依赖 Source Port 与 Destination Port;下面直接看这两个字段怎样进入 PCB 匹配。S4
这里最容易产生一个实现层误解:不是 TAP RX thread 自己拿 UDP Destination Port 去找 callback。 当前 LWIP_TCPIP_CORE_LOCKING_INPUT=0 路径中,TAP/driver RX context 只负责把 pbuf 经 tcpip_input() 投递进 tcpip_mbox;真正运行 ethernet_input() -> ip4_input() -> udp_input() 的是 tcpip_thread。S3
因此本节讨论的 PCB 查找实际发生在:
1 | flowchart TD |
6.1 udp_input() 先读取 UDP Source/Destination Port
进入 udp_input() 时,Stage 4 的 IPv4 Header 已经移除,p->payload 指向 UDP Header。继续阅读 udp_input() 的上游连续源码片段:S2
1 | udphdr = (struct udp_hdr *)p->payload; |
对接收方来说两个 port 的方向必须分清:
1 | packet Source Port -> 远端端口 -> 后面与 pcb->remote_port 比较 |
例如 Host 发往 Raw Echo:
1 | Host 198.18.0.1:53000 |
进入 lwIP 的 packet 中:
1 | src = 53000 |
所以最先决定“哪个本地 UDP endpoint 有资格接收”的是 Destination Port 对 pcb->local_port,不是拿 packet Source Port 去匹配 local port。
6.2 当前实现就是线性 for 遍历 udp_pcbs 链表
继续阅读 udp_input()。当前实现没有用 hash table 按 port O(1) 查找,而是从全局 udp_pcbs 链表头开始逐项遍历:S2
1 | pcb = NULL; |
所以回答“是不是 for 循环遍历 PCB 链表”就是:是。 但匹配条件并不只有一个 port。
继续阅读 udp_input():第一层先比较本地 endpoint。S2
1 | /* compare PCB local addr+port to UDP destination addr+port */ |
这里包含:
1 | packet Destination Port <-> pcb->local_port |
继续阅读 udp_input():如果 PCB 没有 UDP_FLAGS_CONNECTED,它属于 unconnected PCB。当前实现先记录第一个 local endpoint 匹配的候选。S2
1 | if ((pcb->flags & UDP_FLAGS_CONNECTED) == 0) { |
这正对应 Raw Echo 的常见使用方式:udp_bind() 只绑定 local port 7,并没有事先绑定唯一的 remote peer,因此任意远端只要发到正确 local endpoint,都可能进入这个 PCB。
6.3 Connected PCB 还会继续比较 packet 的远端 endpoint
local endpoint 匹配以后,udp_input() 继续检查 packet source 与 PCB remote endpoint:S2
1 | /* compare PCB remote addr+port to UDP source addr+port */ |
对应关系是:
1 | pcb->local_port <-> packet Destination Port |
四个方向都满足时,源码称为 Perfect match。这种 fully matched connected PCB 优先级高于只匹配 local endpoint 的 unconnected PCB。
继续阅读 udp_input():如果整条链表没有找到 fully matched PCB,遍历结束后才回退到之前记录的 uncon_pcb。S2
1 | /* no fully matching pcb found? then look for an unconnected pcb */ |
因此“port 匹配”更准确的心智模型是:
1 | flowchart TD |
6.4 为什么完全匹配后还会把 PCB 移到链表头
继续阅读 udp_input():找到 fully matched PCB 后,当前实现还有一个很小但很实用的优化。S2
1 | /* the first fully matching PCB */ |
也就是 move-to-front:一个 connected flow 连续收很多 datagram 时,第一次命中的 PCB 会被移到 udp_pcbs 表头;下一个同 flow datagram 从头开始遍历时更可能第一项就命中。
这没有改变“线性链表匹配”的本质,只是让热点 PCB 更靠前。
6.5 选出 PCB 后,才验证 checksum、移除 UDP Header 并调用 callback
匹配出 pcb 之后,udp_input() 继续做 UDP checksum 检查。通过后才执行:S2
1 | if (pbuf_remove_header(p, UDP_HLEN)) { |
于是 p->payload 从:
1 | UDP Header |
推进成:
1 | UDP Payload |
继续阅读 udp_input() 的 callback dispatch 尾部:S2
1 | /* callback */ |
回到第 1 节的 udpecho_raw_init():初始化阶段已经执行过 callback 注册:
1 | udp_recv(pcb, udpecho_raw_recv, NULL); |
与运行阶段的真实链路终于完整对应:
1 | udp_recv() |
这一步才是 UDP demultiplex 的完整含义:同一条 IP input path 根据 packet endpoint 找到一个 PCB,再由 PCB 中预先注册的 function pointer 把 payload 分发给正确的上层 callback。
7. callback 看到的 pbuf 已经不是 UDP Header
udp_input() 在 callback 前执行 pbuf_remove_header(p, UDP_HLEN)。S2
所以 callback 收到:
1 | p->payload -> UDP payload |
而不是:
1 | p->payload -> UDP Header |
这和 Stage 4 完全相同:每一层处理完自己的 header 后,把 pbuf 数据视图推进到下一层。
callback 参数中的 addr 和 port 则保存发送方 endpoint:
1 | addr = remote IP |
Raw Echo 用它们作为回包目标。
8. udpecho_raw_recv() 的 ownership 为什么必须看清
upstream callback:S1
1 | if (p != NULL) { |
这里能直接推出一个重要 ownership 契约:callback 收到这个 RX pbuf 后,示例负责最终释放它。
udp_sendto() 并没有把这个 payload pbuf 永久接管走,所以 callback 在发送调用返回后仍然执行 pbuf_free(p)。S1S2
不要把:
1 | udp_sendto(pcb, p, ...) |
理解成“p 的 ownership 自动交给 UDP”。如果调用方后续不再需要 p,仍应按 API 语义释放自己的引用。
9. Echo TX:UDP Header 在哪里加回来
协议总流程已经完成 RX 分发并进入 Echo callback;现在从接收方向切换到回包方向。callback 把原发送端地址和 Source Port 作为目标交给 udp_sendto(),下面继续追 UDP Header 如何重新构造。
udp_sendto() 进入 UDP Core 后,会选择 source address/interface、准备 checksum,然后为 UDP Header 腾出空间并填写 source port、destination port、length/checksum,最后进入 IP output。S2
因此 callback 传入时:
1 | p->payload -> UDP payload |
UDP Core 发出时会变成:
1 | UDP Header |
再往下:
1 | udp_sendto() |
ARP cache 如果已经有 Host MAC,就不需要再次广播 ARP;如果没有,etharp_output() 会重新触发地址解析。
10. 一次 Raw UDP Echo 的完整数据与 ownership 流
1 | flowchart TD |
这张图同时回答两个不同问题:
- 数据怎样返回;
- RX pbuf 最后是谁释放。
两者不能混成“发送成功所以自动 free”。
11. Raw API 的线程边界
当前 example_app 配置 NO_SYS=0,Ethernet RX 通过 tcpip_input() 进入 tcpip_thread,udp_input() 和 Raw recv callback 都在 lwIP Core 上下文里执行。S3S5
这正是 Raw API 的关键约束:Raw callback 不是普通 application worker thread。回调里直接调用 Core API 很自然,但不能把它当成可以长时间阻塞的业务线程。
Stage 6 会从同一个 UDP Echo 问题出发,改用 Netconn:application thread 阻塞等待 mailbox,而 Core callback 只负责把数据交到 mailbox。
资料来源
[S1] upstream Raw UDP Echo 示例
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
contrib/apps/udpecho_raw/udpecho_raw.c - 使用位置:初始化、callback、Echo TX 与 pbuf 释放
- 支撑内容:
udp_new_ip_type()、udp_bind()、udp_recv()、udp_sendto()和pbuf_free()的示例调用顺序
[S2] UDP Core 实现
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/core/udp.c、src/include/lwip/udp.h - 使用位置:PCB allocation/bind/callback、RX demultiplex、TX
- 支撑内容:
udp_input()的 PCB 匹配、header removal 和 callback 调用,以及udp_sendto()的发送语义
[S3] IPv4 与 tcpip_thread 输入链
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/core/ipv4/ip4.c、src/api/tcpip.c - 使用位置:Protocol 17 分发和 callback 执行上下文
- 支撑内容:IPv4 如何进入
udp_input(),以及当前 OS 模式下 packet 如何进入 Core thread
[S4] RFC 768 — UDP
- URL/文档:RFC 768 — User Datagram Protocol
- 使用位置:UDP Header、port、length、checksum
- 支撑内容:UDP datagram 的基本报文格式与 endpoint 字段
[S5] example lwipopts.h
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
contrib/examples/example_app/lwipopts.h - 使用位置:当前
NO_SYS、UDP 和 Core threading 配置 - 支撑内容:限定本文描述的是当前 Unix
example_app构建,而不是所有 lwIP Port 的唯一线程模型
[S6] Cloudflare UDP 入门资料
- 类型:公开技术学习资料
- URL/文档:Cloudflare — 什么是 UDP?
- 使用位置:“阅读源码前”、UDP 协议模型
- 支撑内容:提供面向初学者的 UDP 无连接 datagram 与可靠性边界补充说明;正文仍独立建立当前源码所需的协议模型
[S7] IANA Service Name and Transport Protocol Port Number Registry
- 类型:IANA 官方注册表
- 版本:访问时注册表(2026-09-30 更新)
- URL/文档:IANA — echo service / port 7
- 使用位置:
udp_bind(..., 7) - 支撑内容:确认
echo同时注册于 TCP/UDP port 7;本文据此解释 upstream Raw UDP Echo 示例为何绑定本地 UDP port 7
[S8] lwIP 官方 UDP Raw API 文档
- 类型:lwIP 官方 Doxygen 文档
- 版本:2.1.x 文档;正文源码事实以固定 commit
d08f4773edd0182b7910fc8f046eed82ffcd67c9为准 - URL/文档:lwIP — UDP Raw API
- 使用位置:“阅读源码前”、公开 API 导航
- 支撑内容:说明
udp_new()、udp_bind()、udp_recv()、udp_sendto()属于 lwIP UDP Raw API;具体执行链由 [S1]~[S3] 的目标源码证明










