教程 18:从 udp_sendto() 到 etharp_output()——Multi-netif IPv4 路由、Default Netif、Gateway 与下一跳
摘要:从 UDP/TCP 发送入口追踪 IPv4 multi-netif 出口选择、默认接口、源地址与 Gateway 下一跳,厘清 route 与 ARP 的职责边界。
[TOC]
Multi-netif 表示同一个 lwIP 实例同时存在多个 struct netif 网络接口。本文标题中的 Default Netif(默认接口) 是没有更具体匹配时的 fallback outgoing interface;Gateway(网关) 是已经选定某张接口以后,在当前二层链路上需要直接发送给的下一跳 IP。两者不是同一个对象,也不等价于“默认路由 = 网关地址”。
Stage 18 只回答 IPv4 发送方向上的一个问题:应用没有显式固定接口时,lwIP 怎样先选择 outgoing netif,然后怎样决定 Ethernet 下一跳是 destination 本身还是该接口的 gateway。前者是 route selection(出口接口选择),后者是 next-hop selection(二层下一跳选择)。S1
0. 进入源码前先建立 route / next-hop 模型
推荐先浏览两份 lwIP 官方资料,它们用于确认 API/hook 契约,不是理解正文的前置条件:
- lwIP IPv4 API —
ip4_route()/ip4_route_src():确认默认实现如何扫描接口,以及 source-based routing(源 IPv4 地址也参与出口接口选择)的扩展边界。S3 - lwIP Hooks:重点看
LWIP_HOOK_IP4_ROUTE[_SRC]与LWIP_HOOK_ETHARP_GET_GW,它们分别位于“选接口”和“选下一跳”两个阶段。S4
先区分五个概念:
| 概念 | 当前文章中的含义 |
|---|---|
netif_list |
lwIP 当前所有候选接口的链表 |
netif_default |
没有更具体 route match 时的 fallback interface |
| directly connected subnet | destination 与某接口 IPv4/netmask 匹配,可在该接口链路上直接到达 |
| route selection | 根据 destination、可选 source、protocol control block(PCB,协议控制块)的接口绑定等条件选择 outgoing netif |
| next hop | 已选定接口后,Ethernet/ARP 这一跳真正要解析 MAC 的 IPv4 地址 |
lwIP 默认 ip4_route() 不是 Linux 那种带 prefix/metric/policy 的完整 FIB(Forwarding Information Base,转发表)。默认实现主要线性扫描 netif_list,寻找与 destination 同 subnet 的接口;没有直连匹配时再退到 netif_default。如果项目需要 source policy、复杂前缀或 destination-specific gateway,可以通过 hook 扩展。S3S4
ARP(Address Resolution Protocol,地址解析协议)在 Ethernet IPv4 路径中负责把“本链路下一跳 IPv4 地址”解析成目标 MAC 地址。因而 route selection 只选接口,真正决定 ARP destination 还是 gateway 要等到 next-hop selection。
完整发送模型可以先压成四步:
1 | flowchart LR |
关键点是:route 返回的是接口,不是最终二层 MAC,也不会把 IPv4 header 的 destination 改成 gateway。 etharp_output() 只是在 off-link 情况下把 ARP 对象改成 gateway;线上 IPv4 destination 仍是原始远端地址。S1
下面从真实 udp_sendto() 入口验证这四步怎样落到代码。
1. 从 udp_sendto() 开始:发包前先要得到一个 outgoing netif
重新进入 Stage 5 已经使用过的公共 API udp_sendto()。当前源码在启用 checksum-on-copy 时把它转入 udp_sendto_chksum();无论具体条件编译怎样展开,后面的发送主体都首先要得到一个 struct netif *netif。S1
1 | err_t |
本文只跟踪 IPv4 单播。继续阅读 udp_sendto_chksum() 中的接口选择主干。下面是按这一执行条件裁剪后的阅读版,不是未经修改的上游完整函数;它删除了本文不需要的 multicast override 分支,只保留接口选择的主干:S1
1 | /* 执行路径阅读版:只保留本文 IPv4 单播主线 */ |
因此 UDP 在真正构造并向下发送 datagram 前,先面对一个非常具体的问题:
1 | flowchart TD |
这意味着自动路由并不是所有发送都必须经过的步骤。PCB 如果已经绑定接口,UDP 可以直接使用那张 netif。
2. SO_BINDTODEVICE 为什么能绕过普通 route lookup
Socket API 的 SO_BINDTODEVICE 最终会把接口约束写进协议 PCB,而不是只在 Socket 层保存一个接口名。S1
继续阅读 lwip_setsockopt_impl() 的 SO_BINDTODEVICE 分支。它先通过 netif_find() 找到接口,再根据协议类型调用 tcp_bind_netif()、udp_bind_netif() 或 raw_bind_netif():S1
1 | case SO_BINDTODEVICE: { |
进入 udp_bind_netif():S1
1 | void |
因此下面三个概念不能混为一谈:
| 动作 | 约束对象 | 主要效果 |
|---|---|---|
bind() 到本地 IPv4 |
source address | 限制/指定源 IPv4 |
SO_BINDTODEVICE / *_bind_netif() |
pcb->netif_idx |
固定 outgoing interface |
| 普通 route lookup | destination + route state | 自动选择 outgoing netif |
3. 为什么当前 Unix example 平时感觉不到 multi-netif route
当前 contrib/ports/unix/example_app/default_netif.c 只有一个文件静态 struct netif netif。init_default_netif() 创建它以后立即设成 default:S2
1 | static struct netif netif; |
只有一张接口时,运行时看起来往往就是:
1 | application |
但 Core 默认仍保留 multi-netif 数据结构。netif_add() 会把新接口挂入 netif_list,netif_default 则单独保存默认出口。S1
1 |
|
这两个对象的职责不同:
| 对象 | 含义 |
|---|---|
netif_list |
所有候选网络接口,route lookup 可以遍历 |
netif_default |
没有更具体路径时的 fallback interface |
netif_default 不是“唯一接口”,也不是“链表第一项”。
4. netif_set_default() 设置的是 fallback interface,不是 Gateway
进入 netif_set_default():S1
1 | void |
假设有两张接口:
1 | en0 = 192.0.2.2/24, gw = 192.0.2.1 |
这里有两个名字很容易混淆:
| 名称 | 当前回答的问题 |
|---|---|
netif_default = en1 |
没有更具体 route 时,从哪张接口出去 |
en1->gw = 198.51.100.1 |
已经决定从 en1 出去且目标不在本地链路时,这一跳交给谁 |
因此 netif_default 和 default gateway 不是同一个东西。
5. IPv4 自动 route:默认先扫描所有接口的直连 subnet
对于本文的 IPv4 发送路径,普通 route 最终进入 ip4_route_src() / ip4_route()。如果项目没有定义 source-routing hook,ip4_route_src() 直接退化为 ip4_route(dest):S1
1 |
|
进入 ip4_route()。默认算法先遍历 netif_list,只考虑处于 up/link-up 且拥有有效 IPv4 地址的接口,然后检查 destination 是否落在该接口的本地 subnet:S1
1 | NETIF_FOREACH(netif) { |
例如:
1 | en0 = 192.0.2.2/24 |
192.0.2.80 与 en0 同属 192.0.2.0/24,因此 ip4_route() 直接返回 en0。此时根本不需要使用 netif_default。
5.1 默认实现不是一个完整的 longest-prefix route table
lwIP 官方 API 对 ip4_route() 的描述同样是“线性搜索 network interface list”。S3 当前目标源码进一步显示其具体早退行为:
1 | NETIF_FOREACH |
它不是:
1 | 收集所有匹配项 |
而 netif_add() 又会把新接口插到 netif_list 头部,因此 overlapping subnet 场景下,默认结果可能受接口链表顺序影响。S1
需要真正的静态路由表、metric、policy 或 longest-prefix match 时,应通过 route hook 接入项目自己的 route table,而不是依赖接口添加顺序。
6. 没有直连匹配时,ip4_route() 才退到 netif_default
普通 subnet 没命中后,ip4_route() 先给项目 route hook 接管机会:S1
1 |
|
继续阅读 ip4_route() 的最后 fallback:S1
1 | if ((netif_default == NULL) || !netif_is_up(netif_default) || !netif_is_link_up(netif_default) || |
现在使用贯穿本文的目标地址:
1 | destination = 203.0.113.80 |
它既不属于 192.0.2.0/24,也不属于 198.51.100.0/24,因此没有 direct subnet match。没有额外 route hook 时:
1 | 203.0.113.80 |
注意,此时 ip4_route() 只回答了:
这个 packet 从 en1 出去。
它还没有回答:
en1 在 Ethernet 上应该把 frame 交给谁。
这正是 Gateway 下一步才出现的原因。
7. ip4_output() 先用 route 选接口,再让 source address 与接口对齐
ip4_output() 会调用 ip4_route_src() 取得接口,然后把该 netif 传给 ip4_output_if():S1
1 | err_t |
进入 ip4_output_if()。如果 caller 没有指定 source IPv4,它使用刚选出的接口地址:S1
1 | const ip4_addr_t *src_used = src; |
因此普通自动发送可以先建立这条关系:
1 | destination |
对于 203.0.113.80 的例子,route 返回 en1 后,如果 source 原本是 ANY,最终 source 就会使用 198.51.100.2。
8. 为什么 203.0.113.80 → en1 之后突然又跟 Gateway 有关系
这是本篇最关键的跨层边界。
已知:
1 | en1 IP = 198.51.100.2 |
/24 表示 en1 当前直接连接的 IPv4 subnet 是:
1 | 198.51.100.0/24 |
203.0.113.80 不属于这个 subnet,因此它是 off-link destination:目标 IP 不是当前 Ethernet 链路上的直接邻居。
这时不能把两个“目的”混成一个概念:
| 层次 | 当前目的 |
|---|---|
| IPv4 最终目的 | 203.0.113.80 |
| 当前 Ethernet 下一跳 | 198.51.100.1,即 en1 的 gateway |
ARP 解决的是当前二层链路上某个 IPv4 下一跳对应哪个 MAC。既然 203.0.113.80 不在 en1 的本地 subnet,当前主机不能指望通过本地 ARP 直接得到远端主机的 MAC;它必须先把这个 IP packet 交给本地链路上可达的路由器 198.51.100.1。
因此完整关系不是:
1 | 203.0.113.80 |
而是:
1 | flowchart TD |
所以 Gateway 不是在替换最终目的地址,而是在回答:
这个远端 IPv4 packet 离开本机的第一跳应该先交给谁?
9. 进入 etharp_output():off-link 时把 ARP 对象改成 netif->gw
ip4_output_if_src() 最终调用:
1 | LWIP_DEBUGF(IP_DEBUG, ("ip4_output_if: call netif->output()\n")); |
Ethernet netif 的 IPv4 output 通常指向 etharp_output()。进入它的 unicast 路径后,函数再次拿 destination 与已经选定 netif 的地址/掩码比较。S1
1 | if (!ip4_addr_net_eq(ipaddr, netif_ip4_addr(netif), netif_ip4_netmask(netif)) && |
在 203.0.113.80 这个例子里:
1 | ipaddr = 203.0.113.80 |
条件成立,于是:
1 | dst_addr = en1->gw |
继续阅读 etharp_output()。后面的 ARP cache lookup / etharp_query() 使用的是 dst_addr,因此实际被解析成 MAC 的 IPv4 地址已经变成 gateway:S1
1 | for (i = 0; i < ARP_TABLE_SIZE; i++) { |
因此真正发生的是:
1 | ARP target IPv4 = 198.51.100.1 |
而不是 ARP 203.0.113.80。
10. Gateway 只改变二层下一跳,IP header 的 destination 仍然是 203.0.113.80
为了确认 Gateway 没有把最终 IP 目的地址改掉,需要回到 ip4_output_if_src() 构造 IPv4 header 的位置。
继续阅读 ip4_output_if_src()。在调用 netif->output() 之前,代码已经把原始 dest 写入 IPv4 header:S1
1 | /* dest cannot be NULL here */ |
继续阅读 ip4_output_if_src() 的末尾,随后才进入:
1 | return netif->output(netif, p, dest); |
而 etharp_output() 做的事情,是根据这个 dest 决定 dst_addr 应该指向 destination 本身还是 gateway,再把 dst_addr 解析成 Ethernet destination MAC。
因此线上第一跳的 packet/frame 可以理解为:
1 | IPv4 Header |
Gateway 收到 frame 后查看 IPv4 header,仍然知道真正目标是 203.0.113.80,于是继续执行下一跳转发。
这就是为什么:
1 | 最终 IP destination |
11. 同网段 destination 为什么完全不需要 Gateway
把 destination 改成:
1 | 198.51.100.80 |
它属于 en1 的 198.51.100.0/24。此时:
1 | ip4_route() |
最终:
1 | IPv4 destination = 198.51.100.80 |
所以是否使用 Gateway,不是由“用了 default netif”决定,而是由:
destination 对于当前已经选定的 netif 来说,是 on-link 还是 off-link。
12. 一个真正的 IPv4 route entry 往往需要同时回答“接口”和“下一跳”
lwIP 给 advanced routing 留了两个不同 hook;这两个 hook 的职责也由官方 Hooks 文档分别定义。S1S4
1 | LWIP_HOOK_IP4_ROUTE / LWIP_HOOK_IP4_ROUTE_SRC |
这正好对应真实静态路由项通常需要表达的三个核心字段:
1 | prefix / mask |
只实现 route hook,虽然能让 203.0.113.80 走某张指定接口,但如果后面的 etharp_output() 仍一律使用该接口自己的 netif->gw,就无法表达“同一接口针对不同 prefix 使用不同 gateway”的完整路由策略。
13. LWIP_HOOK_IP4_ROUTE_SRC:source 也可以参与接口选择
如果项目定义了 LWIP_HOOK_IP4_ROUTE_SRC,ip4_route_src() 会先把 source 与 destination 一起交给 hook:S1
1 | struct netif * |
这允许项目表达类似:
1 | source A + destination X → en0 |
但 route table、metric 和 policy rule 仍是项目自己的实现;hook 只是 lwIP Core 留出的决策注入点。
14. TCP 在发 SYN 以前也必须先完成同一类接口选择
进入 tcp_connect()。TCP 在真正发 SYN 之前先检查 PCB 是否固定接口,否则做 route lookup;得到 netif 后,如果 local IP 仍是 ANY,再从该接口获得 source address。S1
1 | if (pcb->netif_idx != NETIF_NO_INDEX) { |
因此 UDP 与 TCP 在 multi-netif 上共享同一层核心认知:
1 | 固定 netif? |
15. 用三个 destination 把 direct route、default interface 与 Gateway 一次区分
继续使用双接口阅读模型:
1 | en0 = 192.0.2.2/24, gw = 192.0.2.1 |
15.1 192.0.2.80:direct match 到 en0
1 | 192.0.2.80 |
15.2 198.51.100.80:direct match 到 en1
1 | 198.51.100.80 |
15.3 203.0.113.80:没有 direct match,先选 default interface,再选 Gateway
1 | 203.0.113.80 |
这里最容易漏掉的中间判断就是:
1 | 已经选出 en1 |
ip4_route() 的结果是 interface;etharp_output() 才在该 interface 上决定 next hop。
如果 Socket 通过 SO_BINDTODEVICE("en0") 固定接口,则普通 route lookup 被绕过,但 next-hop 判断仍然存在:203.0.113.80 对 en0 同样是 off-link,所以后续会使用 en0->gw = 192.0.2.1。S1
16. ERR_RTE 也要区分“没选出接口”还是“选出接口但没有 Gateway”
同一个 ERR_RTE 可以来自不同阶段:
1 | 阶段 A:interface selection |
因此多网口设备出现 ERR_RTE 时,不能只问“路由有没有找到”,还要继续确认:
1 | route 返回了哪张 netif? |
当前 Unix example 只有一个静态 netif;要真实观察 en0/en1 自动选择,需要后续把 Host 实验扩展为双 TAP/netif。本篇的双接口拓扑用于源码推演,不把它写成已经执行过的运行证据。S2
17. 把 Stage 18 压缩成四步:Route、Source、Next Hop、ARP
完成源码链后,一次普通 IPv4 发送可以压缩成下面四层决策:
1 | flowchart TD |
四步分别回答:
- Route selection:这个 packet 从哪张
netif出去; - Source selection:该 packet 使用哪个本地 IPv4;
- Next-hop selection:当前链路上直接交给 destination 还是 gateway;
- ARP:把那个 next-hop IPv4 解析成 Ethernet MAC。
因此下面几种故障也属于不同层:
1 | 选错 netif |
而 203.0.113.80 → en1 → 198.51.100.1 的真正含义现在可以精确表达成:
1 | 203.0.113.80 |
18. 下一阶段:从选定接口进入 Checksum / Hardware Offload
Stage 18 到这里完成的是软件协议栈发送前半程:
1 | destination |
再继续向 Driver 下钻时,新的独立问题变成:
1 | IP/TCP/UDP checksum 谁计算 |
这属于 Stage 19 的 Checksum / Hardware Offload 主线。
资料来源
[S1] lwIP Core IPv4 route、协议输出、ARP 与 Socket/PCB 源码
- 类型:目标版本上游源码
- 版本:commit
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - 定位:
src/core/netif.c:netif_add()、netif_set_default();src/include/lwip/ip4.h:ip4_route_src()配置;src/core/ipv4/ip4.c:ip4_route_src()、ip4_route()、ip4_output()、ip4_output_if()、ip4_output_if_src();src/core/ipv4/etharp.c:etharp_output();src/core/udp.c:udp_sendto()、udp_sendto_chksum()、udp_bind_netif();src/core/tcp.c:tcp_connect();src/api/sockets.c:SO_BINDTODEVICE - URL/文档:lwIP upstream commit
- 使用位置:IPv4 interface selection、default fallback、source address、Gateway/next-hop、ARP、PCB interface binding
- 支撑内容:证明 route 只返回 outgoing
netif,而etharp_output()会在 off-link 场景把 ARP/二层下一跳切换为 gateway;同时证明 IPv4 header 的 destination 在进入 link output 前保持原始目标地址
[S2] Unix example 默认 netif 与 lwIP netif 配置
- 类型:目标版本上游 example 与配置
- 版本:commit
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - 定位:
contrib/ports/unix/example_app/default_netif.c;src/include/lwip/netif.h;src/include/lwip/opt.h - URL/文档:Unix example_app
- 使用位置:“当前 Unix example”“netif_list/netif_default”“双接口阅读模型边界”
- 支撑内容:证明当前 Unix example 只创建一个默认 netif,而 lwIP Core 仍保留 multi-netif 数据结构与默认接口语义
[S3] lwIP 官方 IPv4 API 文档
- 类型:upstream 官方 API 文档
- 版本:lwIP 2.1.x Doxygen,访问日期 2026-10-03
- URL/文档:lwIP IPv4 API — ip4_route / ip4_route_src
- 使用位置:开篇 route contract、
ip4_route()默认扫描行为、source-based routing 边界 - 支撑内容:官方说明
ip4_route()线性搜索接口列表并按掩码匹配 destination;ip4_route_src()的 source-based routing 需要通过 hook 完整实现
[S4] lwIP 官方 Hooks 文档
- 类型:upstream 官方配置/API 文档
- 版本:lwIP 2.1.x Doxygen,访问日期 2026-10-03
- URL/文档:lwIP Hooks
- 使用位置:route hook、source route hook、per-destination gateway 扩展边界
- 支撑内容:定义
LWIP_HOOK_IP4_ROUTE/LWIP_HOOK_IP4_ROUTE_SRC返回 outgoing netif,并定义LWIP_HOOK_ETHARP_GET_GW在已选 netif 上返回 destination-specific gateway









