bigywater.tech
网络协议 · 35

mDNS 与 Bonjour:.local 名字是怎么被组播解析的

“xxx.local”这样的名字是谁解析的?设备怎么在局域网里互相发现?mDNS 和 DNS-SD 分别做什么?这篇在 en0 上被动听 60 秒的真实 mDNS,自己发 PTR / SRV / TXT / A 查询走完 Bonjour 发现的全链条,并量系统解析器对 .local 的耗时。

3450 包 / 60 秒
本机所在局域网上 mDNS 的流量(185 个源地址,约 57.5 包/秒)
38 个应答
问一次 _services._dns-sd._udp.local 收到的应答个数,来自 32 个设备,发现 20 种服务类型
约 5 秒
.local 名字没人回答时系统解析器等多久才放弃(普通 DNS 不存在的名字只要 2 ms)

mDNS 是什么

实测 · 本机执行输出
  60 秒共 3450 个 mDNS 包({'IPv4': 2541, 'IPv6': 909}),185 个不同的源地址;57.5 包/秒;包长 中位 464 / 最大 1498 字节
  IP 目的:{'224.0.0.251': 1836, '单播': 705, 'ff02::fb': 909};TTL / 跳数限制取值:{255: 2217, 1: 579, 64: 635, 254: 19}——mDNS 要求 255,接收方据此确认包没有经过路由器
  DNS 报文类型:{'应答': 1395, '查询': 767};问题总数 1496,类型分布 {'PTR': 788, 'A': 191, 'AAAA': 77, 'ANY': 136, 'TXT': 292, 'SRV': 12};带 QU 位(请求单播应答)的问题 639 个
  问题名字里,服务类型名(形如 _xxx._tcp.local)出现 807 次,最多的:_companion-link._tcp.local 220;_rdlink._tcp.local 204;_ipp._tcp.local 59;_ipps._tcp.local 59;_printer._tcp.local 58;_pdl-datastream._tcp.local 58;指向具体设备 / 服务实例的名字 689 次(名字不写出)

用 DNS 报文格式,发往 224.0.0.251:5353(IPv6 是 ff02::fb),TTL 255。局域网里的每台设备都既是客户端又是服务器。实测 60 秒内 185 个设备共发了 3450 个包。

DNS-SD 的三步

实测 · 本机执行输出
  查询共 46 字节(DNS 头 12 + 名字 30 + 类型 / 类 4);源端口是随机的 61704(不是 5353),所以应答是单播的
  4 秒内收到 38 个应答,来自 32 个不同的源地址;第一个应答在 64 ms 后到达;最后一个在 1047 ms
  应答头:{'ID=0 QR=1 AA=1': 38}(传统单播应答里 ID 回显了查询的 ID = 0,AA = 1 表示权威);记录 TTL 取值 [10](传统单播应答的 TTL 不超过 10 秒)
  发现的服务类型 20 种,被多少个应答提到:_airplay._tcp.local ×31;_raop._tcp.local ×31;_companion-link._tcp.local ×21;_asquic._udp.local ×7;_rfb._tcp.local ×3;_printer._tcp.local ×2;_pdl-datastream._tcp.local ×2;_http._tcp.local ×2

PTR 列出服务实例,SRV + TXT 给出实例所在的主机、端口和参数,A / AAAA 给出主机的地址。应答里常附带后面几步的答案,所以几十毫秒就能走完。

解析本机名字

实测 · 本机执行输出
  第 1 步 PTR 问 _airplay._tcp.local:查询 37 字节,2 秒内 31 个应答(26 个设备),发现 26 个不同的服务实例;第一个应答 76 ms 后到达;应答里附带的“附加记录”(提前帮你答了后面几步)类型分布 {'SRV': 31, 'TXT': 51, 'A': 31, 'AAAA': 20}
  实例 1  第 2 步 SRV + TXT:查询 54 字节(一个报文里问 2 个问题);SRV 在 77 ms 后到达,优先级 0、权重 0、端口 47000;TXT 在 77 ms 后到达,9 个键值对共 203 字节
          第 3 步 A + AAAA 问那台主机:第一个地址在 78 ms 后到达;IPv4 地址 2 个、IPv6 地址 0 个
  实例 2  第 2 步 SRV + TXT:查询 88 字节(一个报文里问 2 个问题);SRV 在 68 ms 后到达,优先级 0、权重 0、端口 47000;TXT 在 68 ms 后到达,9 个键值对共 203 字节
          第 3 步 A + AAAA 问那台主机:第一个地址在 86 ms 后到达;IPv4 地址 2 个、IPv6 地址 0 个
  三步合起来就是 Bonjour 的“浏览 → 解析 → 地址”:先知道有哪些实例(PTR),再知道实例的主机和端口(SRV)与参数(TXT),最后拿到主机的地址(A / AAAA);每一步都是一次组播查询

用 getaddrinfo 解析 .local 名字,没人回答时约 5 秒失败;本机自己的名字也没能解析,原因没查清。

减少噪音的机制

实测 · 本机执行输出
  本机的 .local 名字:5006 ms,失败:gaierror: nodename nor servname provided, or not known
  本机的 .local 名字(第二次):5003 ms,失败:gaierror: nodename nor servname provided, or not known
  不存在的 .local 名字:5003 ms,失败:gaierror: nodename nor servname provided, or not known
  不存在的 .local 名字(第二次):5003 ms,失败:gaierror: nodename nor servname provided, or not known
  不存在的普通名字(对照,走 DNS):2 ms,失败:gaierror: nodename nor servname provided, or not known

已知答案抑制、QU 位(请求单播应答)、cache-flush 位、TTL 为 0 的告别包。实测 767 个查询里有 153 个带已知答案,639 个问题带 QU 位。

.local 的代价

实测 · 本机执行输出
  查询报文 767 个,其中带“已知答案”(查询里已经带了应答记录,告诉对方“这些我有了,不用再答”)的 153 个;TC(截断)位置位的报文 2 个
  应答报文里共 3574 条应答记录,TTL 分布:4500 × 2593;120 × 725;其它 × 188;0(告别) × 68;cache-flush 位置位的记录 1570 条
  已知答案抑制让多个设备都在问同一个服务时,不会让所有人重复回答已经缓存的内容;TTL 为 0 的记录是“告别包”,通知别人立刻删掉缓存;cache-flush 位表示这条记录是该名字下唯一的一组,可以替换旧缓存

.local 走 mDNS,不走 DNS 服务器;没人回答时要等约 5 秒才失败,而普通 DNS 不存在的名字只要 2 ms。

分步演示

mDNS 与 Bonjour
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一、五节是 60 秒被动抓包,别的设备的名字与内容一律不写出,只统计个数与服务类型名;第二、三节是主动用随机源端口发出的传统单播 mDNS 查询(不影响别人的缓存),发现的设备数随当时在线的设备变化;第三节的服务实例、主机和地址都不写出
    • 没有验证:本机自己的 .local 名字为什么解析失败(Python 的 getaddrinfo、dscacheutil、ping 都失败,原因没查清,可能与本地网络权限或 mDNSResponder 的状态有关);探测与冲突解决(Probing / Announcing);组播应答与单播应答的选择(QU 位的实际效果);Wi-Fi 接入点对 mDNS 的转换(5353 的帧在 en0 上是广播 MAC)
    京ICP备2026067502号-1京公网安备11010502063487号