教程 14:从 dns_gethostbyname() 到 dns_found()——DNS Resolver、UDP PCB、Cache、Timer 与异步回调
摘要:从 example_app 的 DNS 请求入口追踪 resolver、UDP PCB、Cache、Timer 与异步回调,并用 6 帧真实 PCAP 验证 DHCP Option 6、随机源端口、TXID、A 记录和 TTL。
[TOC]
DNS(Domain Name System,域名系统)把人类可读的 hostname 映射为 Resource Record(资源记录)等结构化数据。当前 lwIP 实现的是 stub resolver:应用把 hostname 交给 lwIP,lwIP 向已经配置好的 DNS Server 发送 Query;这个 DNS Server 通常是 recursive resolver(递归解析器),它可以代表 Client 继续访问 root、TLD(Top-Level Domain,顶级域)与 authoritative server(权威服务器),而 lwIP 本身并不实现那条递归/迭代查询链。S11S12
本篇只追踪普通 unicast DNS 查询。当前实验通过 UDP 发送 DNS message,Server 使用目标端口 53;请求使用 TXID(Transaction ID,事务标识符)与响应关联。Question 中的 QNAME 表示要查询的域名,QTYPE 表示记录类型,本文主线使用 A Record 查询 IPv4 地址,lwIP 也支持 AAAA Record 查询 IPv6 地址;Response 的 Answer 中携带 Resource Record 和 DNS TTL(缓存生存时间)。这里的 DNS TTL 与 IPv4 Header 的 TTL 不是同一个字段。S7S8S13
0. 阅读源码前:先建立 DNS resolver 协议模型
0.1 建议提前阅读
- Cloudflare Learning Center — What is DNS?
- 用途:建立 Client、recursive resolver、root/TLD/authoritative server 与缓存的整体关系。S11
- RFC 1034 — Domain Names: Concepts and Facilities
- 用途:核对 namespace、resolver、name server 与 Resource Record 的概念边界。S12
- RFC 1035 — Domain Names: Implementation and Specification
- 用途:核对 DNS Header、Question/Answer、QNAME、QTYPE、Flags、TTL 与 wire format。S7
- RFC 5452 — Measures for Making DNS More Resilient against Forged Answers
- 用途:理解随机 TXID、随机 UDP source port 与 Response matching 为什么是 resolver 的安全边界。S8
这些资料用于权威核对和继续深入;不打开外链也不影响下面理解当前 DNS Query/Response 与 lwIP 源码链。
0.2 一份 DNS Query/Response 最少要认识哪些字段
DNS message 由 Header 和若干 section 组成。本文只需要当前路径真正读取或构造的字段;A Record 的基础格式来自 RFC 1035,AAAA Record 的 IPv6 扩展由 RFC 3596 定义:S7S13
| 对象 | 当前含义 | 本文中的源码作用 |
|---|---|---|
| TXID | 16-bit transaction identifier | dns_send() 写入 Query,dns_recv() 用于定位当前 resolver entry |
| QR flag | Query/Response 标志 | 0 表示 Query,1 表示 Response |
| RD flag | Recursion Desired | Client 请求 DNS Server 代表自己继续递归解析 |
| QNAME | 查询名称的 label 编码 | 3com.com 在线上不是带点字符串,而是按 label 长度编码 |
| QTYPE | 要查询的 Resource Record 类型 | A 查询 IPv4,AAAA 查询 IPv6 |
| QCLASS | 记录类别 | 本文使用 IN(Internet)class |
| Answer TTL | 该 Resource Record 可缓存多久 | lwIP 转换为 dns_table[] 的 cache 生命周期 |
| RDATA | Answer 的实际记录数据 | A Record 中最终解析为 IPv4 address |
Cache hit 与 cache miss 也必须先区分:如果 dns_table[] 已有仍有效的结果,dns_gethostbyname() 可以同步返回;cache miss 才创建异步 Query,未来由 dns_recv() 解析 Response 并调用 application callback。S2
0.3 一次 cache miss 的协议总流程
1 | sequenceDiagram |
DNS Server 地址并不是凭空出现。当前 Host 实验中,Stage 13 的 DHCPACK Option 6 调用 dns_setserver(),把 Server 地址写入 dns_servers[];Stage 14 再从这个配置继续完成 Query。S5S9
0.4 协议动作怎样映射到 lwIP resolver
UDP PCB(Protocol Control Block,协议控制块)保存 resolver 使用的本地 UDP port、地址绑定和 receive callback;随机 source port 模式下,cache miss 时会按需创建这样的 PCB。
| 协议/生命周期阶段 | lwIP 入口 | 关键对象 | 下一步 |
|---|---|---|---|
| DNS Server 配置 | dhcp_handle_ack() → dns_setserver() |
dns_servers[] |
resolver 获得目标 Server |
| 应用查询 | dns_gethostbyname() |
hostname、callback | cache hit 同步返回或进入 enqueue |
| 建立异步 transaction | dns_enqueue() / dns_check_entry() |
dns_table[]、request callback |
分配 TXID 并发送 Query |
| 构造 Query | dns_send() |
DNS Header、QNAME、QTYPE、UDP PCB | udp_sendto() 到 Server:53 |
| 接收 Response | udp_input() → dns_recv() |
TXID、source endpoint、flags、Answer | 校验并解析 Resource Record |
| 更新缓存 | dns_correct_response() |
address、TTL、entry state | cache 可供后续同步命中 |
| 返回应用 | dns_call_found() |
callback / callback_arg | dns_found() |
| retry 与过期 | dns_tmr() |
retry counter / cache TTL | 重发、失败 callback 或删除 cache |
下面进入 Source-driven 主线后,将沿真实调用顺序继续追踪这些阶段,而不是再单独另写一份“DNS 协议篇”。
1. 真实应用入口:apps_init() 先注册 5 秒 timeout,而不是立即查询 DNS
当前 example_app 的 DNS demo 由 LWIP_DNS_APP && LWIP_DNS 控制。apps_init() 中并不是直接调用 dns_gethostbyname(),而是先注册一个 one-shot software timeout:S1
1 |
|
这一行必须和 Stage 11 的 timer 模型联系起来:
1 | apps_init() |
所以不存在“DNS thread 5 秒后醒来”。当前 example 只是借 lwIP 通用 timeout scheduler,把 dns_dorequest() 安排到约 5 秒后在 Core execution context 执行。S3
这里的 5 秒也不是 DNS 协议要求。它只是 example 为 DHCP、AutoIP、PPP 等接口配置过程留出的等待时间。S1
2. Resolver 更早就初始化了:lwip_init() 会调用 dns_init()
dns_dorequest() 发生在应用初始化之后,但 DNS Core 本身早在 lwip_init() 阶段已经初始化。S4
当前初始化链可以简化为:
1 | flowchart TD |
进入 dns_init(),上游连续源码片段如下:S2
1 | void |
这里需要注意当前默认安全配置:LWIP_DNS_SECURE_RAND_SRC_PORT 默认启用,因此长期 dns_pcbs[0] 这条固定 PCB 初始化分支在当前 build 中不会采用;cache miss 时 resolver 会按需分配带随机 UDP source port 的 PCB。S6
但两种配置的共同点没有变化:dns_init() 的固定-PCB 分支或 dns_alloc_random_port() 的随机端口分支,最终都会给 DNS UDP PCB 注册同一个 receive callback:
1 | udp_recv(pcb, dns_recv, NULL); |
也就是把:
1 | dns_recv |
保存进:
1 | pcb->recv |
后面 UDP response 到达时,Stage 5 展开的 udp_input() 会先根据 local/remote endpoint 找到该 PCB,再通过 pcb->recv(...) 调进 dns_recv()。
3. DNS Server 地址来自哪里:Stage 13 的 DHCPACK 在这里接上 DNS
dns_init() 支持编译期 DNS_SERVER_ADDRESS,但当前 Host 实验最直观的来源是 DHCP Option 6。S5
Stage 13 的 dhcp_handle_ack() 中存在下面的上游连续源码片段:S5
1 |
|
dns_setserver() 再把地址写入 resolver 的 module-level server table:S2
1 | void |
因此 Stage 13 与 Stage 14 的真正连接是:
1 | flowchart LR |
struct dhcp 并不长期“拥有 DNS resolver”。DHCP 只负责提供配置来源,DNS Core 自己维护 dns_servers[]、query table、request callbacks 和 UDP PCBs。
4. 5 秒后进入 dns_dorequest():为什么 ERR_OK 和 ERR_INPROGRESS 完全不同
sys_check_timeouts() 到期调用 dns_dorequest() 后,example 真实代码是:S1
1 | static void |
dns_gethostbyname() 的官方源码注释直接说明它是 NON-BLOCKING callback version。S2
它返回以后有两个最重要的正常路径:
| 返回值 | 当前含义 | 地址从哪里返回 |
|---|---|---|
ERR_OK |
地址现在已经知道,不需要发 DNS Query | 直接写到 dnsresp,example 立即自己调用 dns_found() |
ERR_INPROGRESS |
cache miss,异步 DNS Query 已经建立 | 未来由 resolver 内部调用 dns_found() |
所以:
1 | dns_gethostbyname() |
不是传统意义上:
1 | 阻塞等待 DNS response |
而是:
1 | 先同步查本地/literal/cache |
5. dns_gethostbyname() 只是薄封装,真正逻辑在 dns_gethostbyname_addrtype()
当前函数:S2
1 | err_t |
所以进入 dns_gethostbyname_addrtype()。
它不会一上来就发 UDP。上游连续源码片段先处理 localhost、IP literal 和 cache:S2
1 |
|
因此第一层决策是:
1 | flowchart TD |
6. dns_table[] 是 resolver state + cache,不只是“域名数组”
当前默认 DNS_TABLE_SIZE=4。S6
每个 entry 保存的重点包括:
1 | name |
主要 state:
1 | DNS_STATE_UNUSED |
理解方式:
| State | 语义 |
|---|---|
UNUSED |
slot 空闲 |
NEW |
新请求已写入 table,马上准备发送 |
ASKING |
Query 已发出,等待 response/retry |
DONE |
已得到地址,可供后续 cache hit |
所以同一个 dns_table[] 既保存 outstanding query 的状态,又保存已经完成的短期 cache。
7. Cache miss 进入 dns_enqueue():先把 callback 与 query state 绑定
dns_gethostbyname_addrtype() 确认需要真正查询后进入 dns_enqueue()。S2
继续阅读 dns_enqueue() 中创建 entry 的关键片段:
1 | entry->state = DNS_STATE_NEW; |
这里有两个不同数组:S2
1 | dns_table[] |
如果 query 最终成功,后面的 dns_call_found() 就依赖 dns_requests[] 把结果送回最初调用者。
8. 当前默认安全配置还会按需创建随机 UDP source port PCB
当前默认 LWIP_DNS_SECURE 包含 LWIP_DNS_SECURE_RAND_SRC_PORT 与随机 TXID。S6S8
因此 dns_enqueue() 会执行:
1 |
|
继续进入 dns_alloc_random_port():S2
1 | static struct udp_pcb * |
现在 dns_recv() 为什么会在 response 到达时被调用就有完整来源了:
1 | dns_alloc_random_port() |
本次成功 PCAP 的 Frame 5 实际使用 198.18.0.149:9936 -> 198.18.0.1:53,Frame 6 再从 198.18.0.1:53 返回 198.18.0.149:9936。因此这里的“随机 local port”可以直接映射到本次运行中的 UDP source port 9936。S9
RFC 5452 建议 resolver 使用不可预测的 query ID 与 source port 增大伪造 response 需要猜中的空间;lwIP 当前默认安全配置与这类设计目标一致。S8
9. dns_enqueue() 结尾直接进入 dns_check_entry(),第一次 Query 不需要等 1 秒 Timer
继续阅读 dns_enqueue() 的结尾:S2
1 | dns_seqno++; |
所以:
1 | ERR_INPROGRESS |
不是“只是放进队列,下一秒才发”。真正的第一份 Query 通常已经在本次 API 调用栈中进入 dns_check_entry() / dns_send()。
10. dns_check_entry():NEW → ASKING,生成 TXID 并发送
新 entry 当前是 DNS_STATE_NEW。进入 dns_check_entry() 后:S2
1 | case DNS_STATE_NEW: |
此时 query state 变成:
1 | hostname = 3com.com |
然后直接进入 dns_send(i)。本次成功抓包中,Frame 5 的 Transaction ID 为 0xB53B,Frame 6 Response 使用完全相同的 0xB53B,正好对应 entry->txid 用于 Query/Response 匹配的职责。S9
11. 进入 dns_send():先建立 DNS Header,再解释 Recursion Desired
dns_send() 先分配 PBUF_TRANSPORT,填 DNS Header:S2
1 | p = pbuf_alloc(PBUF_TRANSPORT, (u16_t)(SIZEOF_DNS_HDR + strlen(entry->name) + 2 + |
DNS_FLAG1_RD 对应 RFC 1035 的 Recursion Desired bit。RD=1 表示 stub resolver 请求当前 DNS Server 代表 Client 继续完成递归解析;它并不表示 lwIP 自己去访问 root/TLD/authoritative server。S7 本次 Frame 5 的 Flags 实测为 0x0100,其中 QR=0、RD=1,与 hdr.flags1 = DNS_FLAG1_RD 直接对应。S9
11.1 当前 lwIP 实现的是 stub resolver,而不是 recursive resolver
Cloudflare 与 RFC 1034 已经把 recursive resolver、root/TLD/authoritative server 的关系讲清楚,本篇不再复述那条通用解析链。S11S12 对当前源码只需要建立一条实现边界:dns.c 把 Query 发给 dns_servers[] 中已经配置好的 resolver server,并等待最终 Response;它本身没有实现从 root 到 authoritative server 的递归/迭代解析过程。S2
12. 3com.com 在线上不是普通带点字符串:QNAME 使用 label 编码
继续阅读 dns_send():S2
1 | hostname = entry->name; |
QNAME 不是把 3com.com 这串文本原样写进报文,而是按 label 编码:每个 label 前先写一个长度 byte,最后用 0 结束。因此 3com.com 对应“长度 4 + 3com + 长度 3 + com + 结束 0”。dns_send() 的循环正是在构造这一格式。S7 Frame 5 的 DNS payload 中可以直接找到:S9
1 | 04 33 63 6f 6d 03 63 6f 6d 00 |
其中 04 是 3com 的长度,03 是 com 的长度,末尾 00 结束 QNAME。
13. QTYPE 决定查 IPv4 A 还是 IPv6 AAAA
继续阅读 dns_send():S2
1 | if (LWIP_DNS_ADDRTYPE_IS_IPV6(entry->reqaddrtype)) { |
QTYPE 决定希望得到哪一种 Resource Record:A 返回 IPv4 address,AAAA 返回 IPv6 address;QCLASS=IN 表示 Internet class。S7S13 当前代码分支因此在 IPv4 request 写 DNS_RRTYPE_A,IPv6 request 写 DNS_RRTYPE_AAAA。本次 Frame 5 的 Question 区实测 QTYPE=0x0001、QCLASS=0x0001,即 A / IN,与当前 IPv4 路径一致。S9
14. dns_send() 最终只是把 DNS Payload 交给 UDP,目的端口固定 53
继续阅读 dns_send() 的发送尾部。下面是普通 unicast DNS 的执行路径阅读版:只裁掉与本文主线无关的 mDNS destination 分支,保留当前路径的真实语句顺序。S2
1 |
|
普通 unicast DNS 因而形成:
1 | UDP Source Port = resolver 随机 local port |
本次 Frame 5 把三个值全部落到了线上:source port=9936,destination port=53,destination IP=198.18.0.1。S9
这里再回到 Stage 5 的 UDP output;DNS Core 并不自己构造 IPv4/Ethernet Header。
15. Response 到达后,udp_input() 怎样识别这是 DNS PCB
这一段必须和 Stage 5 的完整 PCB demultiplex 连起来,不能从:
1 | DNS Response 到达 |
突然跳到:
1 | dns_recv() |
真实链路是:S3
1 | flowchart TD |
本次成功 PCAP 不需要再假设端口,Frame 5/6 已经给出真实 endpoint:S9
1 | Query: lwIP 198.18.0.149:9936 -> DNS 198.18.0.1:53 |
进入 udp_input() 处理 Frame 6 时:
1 | src = 53 |
对本次 DNS PCB 来说,真正参与 UDP demultiplex 的关键值是:
1 | packet Destination Port 9936 <-> pcb->local_port = 9936 |
原因是 dns_alloc_random_port() 只执行 udp_bind(),没有 udp_connect();这个 PCB 属于 unconnected UDP PCB。S2 因而不能把 Frame 6 的 source 198.18.0.1:53 写成 udp_input() 对 DNS PCB 的 connected remote endpoint 匹配条件。当前 udp_input() 的 fully-connected / unconnected PCB 优先级、local/remote IP 匹配和 move-to-front 优化已经在 Stage 5 展开,本篇只保留与 DNS 当前路径有关的边界。S3
16. 进入 dns_recv():不是看到 UDP/53 就立刻信任答案
udp_input() 已经移除 UDP Header,因此进入:
1 | dns_recv(void *arg, struct udp_pcb *pcb, struct pbuf *p, |
时:
1 | p->payload -> DNS Header |
dns_recv() 先根据 Transaction ID 找当前 DNS_STATE_ASKING entry,再检查 response、question 与 server 信息。S2
本次 Frame 6 的 UDP source 是 198.18.0.1:53,DNS Transaction ID 仍为 0xB53B;DNS Flags 为 0x8580,即 QR=1、AA=1、RD=1、RA=1、RCODE=0,并且 QDCOUNT=1、ANCOUNT=1。S9 这些是本次 dnsmasq 响应的实测字段,不应泛化为所有 DNS Server 都会设置 AA 或 RA。
当前目标版本的 dns_recv() 有一个容易被“安全设计目标”掩盖的实现细节:S2
1 | LWIP_UNUSED_ARG(pcb); |
也就是说,dns_recv() 没有使用传入的 UDP source port 做额外比较。它实际检查的关键条件包括:
1 | entry state = ASKING |
随机 UDP local port 的价值仍然存在:攻击者若想把伪造 response 送进这个 DNS PCB,首先需要命中本次随机绑定的 destination port 9936;但不要把这一点误写成“当前 dns_recv() 又校验了一次 response source port=53”。S2S9
RFC 5452 从 resolver 防伪造角度强调 query ID、query name、地址和端口等匹配维度,并建议增加 source-port entropy。S8 本文这里严格区分 RFC 的安全建议与当前 lwIP 目标版本的实际代码路径。
17. Answer 中的 A Record 最终写进 dns_table[i].ipaddr
当 dns_recv() 找到符合当前 IPv4 query 的 A Record 后,会从 pbuf 复制 4-byte IPv4 address 到对齐对象,再保存进 cache entry。S2
这条数据变化可以简化为:
1 | DNS Answer RDATA bytes |
继续阅读 dns_recv() 的成功 Answer 分支:地址复制完成后,函数直接调用:
1 | dns_correct_response(i, lwip_ntohl(ans.ttl)); |
18. 进入 dns_correct_response():TTL 是“DNS 缓存生存时间”,不是 IPv4 Header TTL
TTL 全称也是 Time To Live,但这里属于 DNS Resource Record 的缓存生存时间,不是 IPv4 Header 里每过一个 router 减 1 的 TTL。S7
两者必须立即消歧:
| 名称 | 单位/变化方式 | 解决什么问题 |
|---|---|---|
| IPv4 Header TTL | 每经过 router 减 1 | 防止 packet 无限路由循环 |
| DNS RR TTL | 秒;resolver cache 随时间过期 | 决定解析结果可以缓存多久 |
RFC 1035 的 DNS TTL 表示这条 Resource Record 在重新查询源之前可以缓存多少秒。S7
进入 dns_correct_response():S2
1 | static void |
18.1 本次 Frame 6 的 TTL=0:结果只用于当前 transaction,不进入持续 cache
本次 DNS Response 的 Answer Resource Record 实测字段为:S9
1 | NAME = c0 0c -> 压缩指针,指回 Question 中的 3com.com |
这使 dns_correct_response() 的两个动作在同一次成功 response 中连续发生:
1 | entry->state = DNS_STATE_DONE |
因此这次实验虽然解析成功,但这条 A Record 不会作为可持续命中的 DNS cache entry 保留下来。这正好把前面 TTL=0 的源码分支从抽象条件变成了真实线上证据。S2S9
18.2 DNS_MAX_TTL=604800 到底是什么意思
当前 dns.c 定义:S2
1 |
604800 秒等于:
1 | 7 天 |
因此:
1 | Server TTL = 300 s |
英文源码分析里常把:
1 | entry->ttl 被限制在最大值 |
写成 clamp to DNS_MAX_TTL。
文章里不需要保留这个英文词。更清楚的中文是:
如果 DNS Server 返回的 TTL 大于
DNS_MAX_TTL,lwIP 会把缓存时间限制到604800秒(7 天),不会按更大的服务器 TTL 缓存。
这里的 604800 是当前 lwIP implementation limit,不是 RFC 1035 规定“所有 DNS cache 最多 7 天”。S2S7
19. dns_call_found():这里才真正回到 example 的 dns_found()
dns_enqueue() 保存过:
1 | req->found = found; |
成功 response 进入 dns_correct_response() 后,又调用:
1 | dns_call_found(idx, &entry->ipaddr); |
dns_call_found() 最终执行:S2
1 | if (dns_requests[i].found && (dns_requests[i].dns_table_idx == idx)) { |
当前 example 最开始传入的 found 就是:
1 | dns_found |
所以异步闭环是:
1 | sequenceDiagram |
如果 Query 最终失败/超时,则 callback 可以得到:
1 | addr == NULL |
example 因而打印:
1 | 3com.com: <not found> |
本次成功实验走的是前面的成功路径:Frame 6 返回 A Record 198.18.0.1 后,终端实际输出 3com.com: 198.18.0.1。S9
20. 第二次查相同 hostname 为什么可能完全不发 UDP/53
第一次成功以后:
1 | dns_table[i].state = DNS_STATE_DONE |
只要 TTL 还没归零,再调用同一个 API:
1 | dns_gethostbyname("3com.com", ...) |
就可能在 dns_lookup() 同步命中,直接:
1 | return ERR_OK |
当前 example 看到 ERR_OK 后立即自己调用 dns_found():
1 | if (dns_gethostbyname(dnsname, &dnsresp, dns_found, NULL) == ERR_OK) { |
所以同一个 dns_found() 有两条到达方式:
1 | cache hit |
但本次实验是一个很有价值的反例:Frame 6 的 Answer TTL=0,所以 dns_correct_response() 在 callback 返回后立即把 entry 置回 DNS_STATE_UNUSED。S2S9 因此如果紧接着再次解析 3com.com,不能期待本次结果走 dns_lookup() cache-hit 路径;要观察真正的 cache hit,需要让 DNS Server 返回大于 0 的 TTL。
21. dns_tmr() 每 1 秒运行一次:同时推进 Query Retry 与 Cache TTL
Stage 11 已经把 sys_check_timeouts() 展开。DNS 自己的周期入口非常短:S2S3
1 | void |
当前:
1 | DNS_TMR_INTERVAL = 1000 ms |
都是 lwIP 默认配置值。S6
对于:
1 | DNS_STATE_ASKING |
timer 推进:
1 | tmr |
达到当前 server 的 retry 上限后,如果还有备用 dns_servers[],可以切换 server;全部失败以后调用:
1 | dns_call_found(i, NULL) |
对于:
1 | DNS_STATE_DONE |
同一个 dns_tmr() 让:
1 | entry->ttl-- |
到 0 后 entry 变回 UNUSED,下一次解析就必须重新走网络 Query。S2
因此同一个周期 timer 根据 state 做的是完全不同的工作:
1 | flowchart TD |
22. Host 实验必须使用三个同时运行的终端
Stage 14 实验最容易犯的错误不是协议错误,而是把提供服务或抓包的前台进程提前 Ctrl+C 停掉。
推荐明确分成三个终端。
22.1 终端 A:启动 dnsmasq,保持运行,不要先按 Ctrl+C
1 | sudo dnsmasq \ |
这个进程需要同时承担:S10
1 | UDP/67 DHCP Server |
--address=/3com.com/198.18.0.1 让实验不依赖公网 recursive lookup,3com.com 可以确定性返回 198.18.0.1。
22.2 终端 B:启动抓包,也保持运行
1 | mkdir -p captures |
不要在 example_app 启动前停止 tcpdump,否则 PCAP 根本不会包含最终 DNS Query/Response。
22.3 终端 C:最后启动 lwIP example
1 | PRECONFIGURED_TAPIF=lwip0 \ |
成功路径应当是:
1 | DHCPDISCOVER / OFFER / REQUEST / ACK |
只有等终端 C 已经得到 DNS 结果,才停止终端 B 的 tcpdump,再停止终端 A 的 dnsmasq。
本次按这个顺序得到的成功抓包已经保存为 assets/stage14-dns.pcap。S9
23. 本次成功抓包:6 帧把 DHCP Option 6 到 DNS A Record 完整闭环
本次 stage14-dns.pcap 使用过滤器:
1 | udp port 53 or udp port 67 or udp port 68 |
因此它只保留 DHCP 与 DNS,不包含 Stage 13 中 ACD 使用的 ARP Probe/Announcement。PCAP 一共正好 6 帧:前 4 帧完成 DHCP DORA,后 2 帧完成一次 DNS Query/Response。S9
| Frame | 相对时间 | 长度 | 线上事件 | 关键字段 |
|---|---|---|---|---|
| 1 | 0.000000 s | 350 B | DHCPDISCOVER | 0.0.0.0:68 -> 255.255.255.255:67,XID=0x5A19369E |
| 2 | 0.001113 s | 342 B | DHCPOFFER | yiaddr=198.18.0.149,Server/Gateway/DNS=198.18.0.1,lease=3600 s |
| 3 | 0.001374 s | 350 B | DHCPREQUEST | Requested IP=198.18.0.149,Server Identifier=198.18.0.1 |
| 4 | 0.009692 s | 342 B | DHCPACK | netmask=255.255.255.0,Gateway/DNS=198.18.0.1,lease=3600 s |
| 5 | 7.999869 s | 68 B | DNS Query | 198.18.0.149:9936 -> 198.18.0.1:53,TXID=0xB53B,A/IN,RD=1 |
| 6 | 8.000006 s | 84 B | DNS Response | 198.18.0.1:53 -> 198.18.0.149:9936,TXID=0xB53B,Answer=198.18.0.1,TTL=0 |
23.1 Frames 1~4:DHCP 不只给地址,还把 DNS Server 真正送进 resolver
四个 DHCP packet 使用同一个 XID 0x5A19369E,因此属于同一笔 DORA transaction。S9
Frame 2 的 DHCPOFFER 与 Frame 4 的 DHCPACK 都携带:
1 | Subnet Mask = 255.255.255.0 |
其中对本篇最关键的是 DHCP Option 6:
1 | DNS Server = 198.18.0.1 |
这与第 3 节的源码链直接闭环:
1 | flowchart LR |
这里能够从同一份 PCAP 同时看到“配置从哪里来”和“后续 Query 实际发向哪里”,比单独看 DHCPACK 或 DNS Query 都更完整。S5S9
需要注意 Frame 4 到 Frame 5 相隔约 7.990177 s。这个值是从线上 DHCPACK 到第一份可见 DNS packet 的抓包间隔,不能直接解释成 sys_timeout(5000, ...) 的实际超时值:PCAP 看不到 timeout 注册/触发,也看不到在 Ethernet TX 之前失败的内部发送尝试;同时本次过滤器还主动排除了 ARP/ACD 流量。能够确认的事实只有 Frame 5 是本次抓包中第一份真正上线的 UDP/53 Query。S1S9
23.2 Frame 5:一份 26-byte DNS Query 可以逐字段对应 dns_send()
Frame 5 的 Ethernet/IPv4/UDP endpoint 是:S9
1 | Ethernet 02:12:34:56:78:ab -> 0e:df:2b:29:78:22 |
DNS payload 共 26 byte:
1 | b5 3b 01 00 00 01 00 00 00 00 00 00 |
按 dns_send() 的构造顺序拆开:
| 字节 | 解析 | 对应源码/语义 |
|---|---|---|
b5 3b |
TXID=0xB53B |
entry->txid = dns_create_txid() |
01 00 |
Flags=0x0100 |
RD=1,即 DNS_FLAG1_RD |
00 01 |
QDCOUNT=1 |
一个 Question |
00 00 00 00 00 00 |
AN/NS/AR=0 |
Query 尚无 Answer |
04 33 63 6f 6d 03 63 6f 6d 00 |
3com.com |
QNAME label 编码 |
00 01 |
QTYPE=A |
IPv4 address query |
00 01 |
QCLASS=IN |
Internet class |
这份报文同时证明了三个此前只能从源码看到的实现细节:随机 UDP source port 本次取值为 9936,TXID 本次取值为 0xB53B,QNAME 确实采用 label-length 编码而不是带点 ASCII 字符串。S2S9
23.3 Frame 6:137 微秒后返回 A Record,TXID 保持一致、UDP 方向反转
Frame 6 到达时间为 8.000006 s,与 Frame 5 相差约 137 us。这是 TAP + 本机 dnsmasq 的单次实验测量,不代表真实网络的固定 DNS RTT。S9
线上方向已经反转:
1 | IPv4 198.18.0.1:53 -> 198.18.0.149:9936 |
DNS payload 为:
1 | b5 3b 85 80 00 01 00 01 00 00 00 00 |
Header 与 Answer 可以拆成:
| 字段 | 实测值 | 含义 |
|---|---|---|
| Transaction ID | 0xB53B |
与 Frame 5 完全一致 |
| Flags | 0x8580 |
QR=1, AA=1, RD=1, RA=1, RCODE=0 |
| QDCOUNT | 1 |
保留原 Question |
| ANCOUNT | 1 |
一个 Answer RR |
| Answer NAME | c0 0c |
DNS name compression pointer,指回 offset 12 的 QNAME |
| TYPE / CLASS | A / IN |
IPv4 Internet address |
| TTL | 0 |
结果只用于当前 transaction,不持续缓存 |
| RDLENGTH | 4 |
IPv4 RDATA 为 4 byte |
| RDATA | c6 12 00 01 |
198.18.0.1 |
所以 Frame 5/6 可以形成严格的线上对应关系:
1 | Client local port: 9936 <--------------------> 9936 |
其中 local port 9936 的一致性首先由 UDP demultiplex 保证;dns_recv() 再继续校验 source DNS Server IP、TXID、Question name 与 QTYPE/QCLASS,而不是重新检查 source UDP port。S2
随后 dns_recv() 读取 A Record,把 c6 12 00 01 写入 dns_table[i].ipaddr,dns_correct_response() 调用 dns_call_found(),最终与终端输出 3com.com: 198.18.0.1 闭环。S2S9
24. 把本次 PCAP 字段逐项映射回 lwIP 源码
这份成功抓包已经可以把本文前面的主要源码对象逐项落到真实值上:S2S5S9
| 抓包证据 | 本次实测值 | lwIP 源码 | 证明什么 |
|---|---|---|---|
| DHCP Option 6 | 198.18.0.1 |
dhcp_handle_ack() → dns_setserver() |
DNS Server 配置来源 |
| Client IPv4 | 198.18.0.149 |
dhcp_handle_ack() / 后续地址绑定 |
DNS Query 的 source IP |
| UDP Source Port | 9936 |
dns_alloc_random_port() |
本次 DNS PCB local port |
| UDP Destination Port | 53 |
dns_send() |
普通 unicast DNS server port |
| DNS Transaction ID | 0xB53B |
dns_create_txid() / entry->txid |
Query/Response transaction 匹配 |
| Query Flags | 0x0100 |
hdr.flags1 = DNS_FLAG1_RD |
RD=1 |
| QNAME | 04 33 63 6f 6d 03 63 6f 6d 00 |
dns_send() label loop |
3com.com wire format |
| QTYPE/QCLASS | A / IN |
DNS_RRTYPE_A / DNS_RRCLASS_IN |
当前请求 IPv4 address |
| Response endpoint | 198.18.0.1:53 -> 198.18.0.149:9936 |
udp_input() → DNS PCB |
response 回到同一 local port |
| Answer RDATA | 198.18.0.1 |
dns_recv() |
写入 entry->ipaddr |
| Answer TTL | 0 |
dns_correct_response() |
callback 后立即 flush,不保留持续 cache |
| Console output | 3com.com: 198.18.0.1 |
dns_call_found() → dns_found() |
异步结果回到应用 |
这比“抓到了 DNS Query/Response”更重要:同一份证据已经把 DHCP 配置、DNS request state、UDP PCB endpoint、DNS wire format、response matching、A Record 解析、TTL cache 语义与应用 callback 串成一条可复核的数据路径。
25. 把完整调用链重新连起来
1 | flowchart TD |
到这里,DNS 不再是“调用 dns_gethostbyname() 然后神秘地得到 IP”。它实际串起了:
1 | Stage 5 UDP PCB / udp_input demultiplex |
下一篇进入 IPv6 后,DNS 的 A/AAAA address type 会自然成为连接点,但 IPv6 的 packet input、Next Header、ICMPv6 和 Neighbor Discovery 会重新建立新的网络层主线。
资料来源
[S1] lwIP upstream example_app DNS 入口
- 类型:目标版本源码
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
contrib/examples/example_app/test.c - 关键符号:
apps_init()、dns_dorequest()、dns_found() - 使用位置:DNS demo 入口、5 秒 timeout、同步/异步返回
- 支撑内容:证明当前 example 如何注册 DNS 请求并消费
dns_gethostbyname()的返回值
[S2] lwIP DNS resolver Core
- 类型:目标版本源码
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/core/dns.c、src/include/lwip/dns.h - 关键符号:
dns_init()、dns_gethostbyname_addrtype()、dns_enqueue()、dns_alloc_random_port()、dns_check_entry()、dns_send()、dns_recv()、dns_correct_response()、dns_call_found()、dns_tmr() - 使用位置:全文 DNS 主调用链
- 支撑内容:DNS PCB、resolver table、Query/Response、TTL、retry、callback 与 cache 行为
[S3] lwIP UDP demultiplex 与 timeout execution bridge
- 类型:目标版本源码
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/core/udp.c、src/api/tcpip.c、src/core/timeouts.c - 使用位置:UDP PCB 匹配、
pcb->recv()、sys_check_timeouts()与dns_dorequest()execution context - 支撑内容:证明 DNS response 如何从 UDP 进入
dns_recv(),以及 5 秒 request callback 与 DNS timer 如何运行
[S4] lwIP Core 初始化
- 类型:目标版本源码
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/core/init.c - 使用位置:
dns_init()初始化时机 - 支撑内容:证明 DNS Core 在
lwip_init()阶段初始化
[S5] DHCP 向 DNS resolver 注入 server address
- 类型:目标版本源码
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/core/ipv4/dhcp.c - 关键符号:
dhcp_handle_ack()、dns_setserver() - 使用位置:Stage 13 → Stage 14 桥接
- 支撑内容:证明 DHCP Option 6 如何进入
dns_servers[]
[S6] lwIP DNS 配置默认值
- 类型:目标版本源码
- 版本:
d08f4773edd0182b7910fc8f046eed82ffcd67c9 - URL/文档:
src/include/lwip/opt.h、src/core/dns.c - 使用位置:
DNS_TABLE_SIZE、DNS_MAX_RETRIES、DNS_TMR_INTERVAL、LWIP_DNS_SECURE、DNS_MAX_TTL - 支撑内容:当前 lwIP 默认容量、retry、timer 与 7 天 TTL 上限
[S7] RFC 1035 — Domain Names: Implementation and Specification
- 类型:Internet Standard
- 版本:RFC 1035,1987-11
- URL/文档:RFC 1035
- 使用位置:QNAME label、RD、Resource Record TTL、A Query 基本语义
- 支撑内容:DNS wire format、Recursion Desired、A Record 与 DNS cache TTL 的标准定义
[S8] RFC 5452 — Measures for Making DNS More Resilient against Forged Answers
- 类型:IETF Standards Track
- 版本:RFC 5452,2009-01
- URL/文档:RFC 5452
- 使用位置:随机 Query ID、随机 UDP source port 与 response matching
- 支撑内容:解释 resolver 使用不可预测端口/ID 与多字段匹配的安全背景
[S9] Stage 14 成功 DHCP + DNS 实验日志与 PCAP
- 类型:用户实际实验抓包与终端输出
- 时间:2026-10-02
- 文件:
assets/stage14-dns.pcap - 抓包过滤:
udp port 53 or udp port 67 or udp port 68 - 使用位置:DHCP Option 6、Frames 1~6、随机 UDP source port、TXID、RD、QNAME、A Record、TTL 与 callback 闭环
- 支撑内容:PCAP 共 6 帧;Frames 1~4 完成 DHCP DORA 并下发
198.18.0.1作为 DNS Server;Frame 5 为198.18.0.149:9936 -> 198.18.0.1:53的3com.com A/INQuery,TXID=0xB53B;Frame 6 在约137 us后返回198.18.0.1,TTL=0;终端最终输出3com.com: 198.18.0.1
[S10] dnsmasq 官方手册
- 类型:工具官方文档
- 版本:在线手册,访问日期 2026-10-02
- URL/文档:dnsmasq man page
- 使用位置:Host DHCP+DNS 实验中的
--interface、--bind-interfaces、--listen-address、--dhcp-option与--address - 支撑内容:说明实验 dnsmasq 如何约束监听接口并同时提供 DHCP/DNS 配置
[S11] Cloudflare Learning Center — DNS 整体工作模型
- 类型:高质量公开学习资料
- 版本:在线资料,访问日期 2026-10-02
- URL/文档:What is DNS?
- 使用位置:文章开头的 DNS 前置阅读、stub resolver 与 recursive/authoritative 角色边界
- 支撑内容:DNS client、recursive resolver、root/TLD/authoritative server、缓存与一次典型 DNS lookup 的整体心智模型。
[S12] RFC 1034 — Domain Names: Concepts and Facilities
- 类型:Internet Standard 基础规范
- 版本:RFC 1034,1987-11
- URL/文档:RFC 1034
- 使用位置:文章开头的 DNS 前置阅读、resolver/name server 概念边界
- 支撑内容:DNS namespace、resolver、name server、resource record 与查询体系的概念模型;具体 wire format 继续由 RFC 1035 支撑。
[S13] RFC 3596 — DNS Extensions to Support IP Version 6
- 类型:IETF Standards Track
- 版本:RFC 3596,2003-10
- URL/文档:RFC 3596
- 使用位置:协议基线、QTYPE A/AAAA 对照
- 支撑内容:AAAA Resource Record 与 IPv6 address query 的标准定义;A Record 仍由 RFC 1035 支撑。









