AirPlay 投屏设备发现
背景:设备发现到底是什么?
AirPlay 设备发现不是"手机去扫局域网里所有 IP",而是通过 mDNS/DNS-SD 完成的。
可以把它理解成局域网里的"小广播查询":
- Apple 设备向
224.0.0.251:5353或 IPv6 的ff02::fb:5353发 UDP 组播查询。 - 查询内容是"谁提供
_airplay._tcp.local服务?"以及"谁提供_raop._tcp.local服务?"。 - Airplay投屏器/APP 收到查询后,也向
224.0.0.251:5353或查询方单播地址回复。 - 回复里包含服务名、端口、主机名、IP 地址和能力字段。
- Apple 设备拿到这些信息后,才知道后续 AirPlay/RAOP 连接该连到哪里。
这里有两个关键词。
mDNS 是 multicast DNS,即组播 DNS。它不依赖路由器上的 DNS 服务器,而是在本地链路里用 224.0.0.251:5353 和 ff02::fb:5353 直接问答。
DNS-SD 是 DNS Service Discovery,即用 DNS 记录描述"这里有什么服务",AirPlay 设备发现用的就是 DNS-SD。
一、为什么要重做
原来的做法依赖外部 DNS-SD 实现,常见路径是:
- Linux 发行版上通过 Avahi 的
avahi-compat-libdns_sd - macOS 上通过系统 Bonjour
- Windows 上通过 Bonjour SDK 或
dnssd.dll
这个方案在桌面系统上可用,但移植到 Android 或轻量环境时会遇到几个实际问题。
第一,avahi-daemon 对这个场景偏重。它是系统级 daemon,功能完整,但 Airplay投屏接收端只需要回答两个服务类型的查询:_airplay._tcp.local 和 _raop._tcp.local。
第二,Android 环境不适合假设存在 Avahi。Android 应用或 Native 服务更常见的部署方式是自己创建 socket,自己处理网络协议,而不是要求系统额外安装并运行一个 mDNS daemon。
第三,跨平台依赖变复杂。Linux、macOS、Windows、Android 的 DNS-SD 库和头文件都不一样。项目里为了处理这些差异,需要保留很多动态加载、条件编译和错误分支。
第四,排查成本高。一旦发现失败,要同时考虑libdns_sd、Avahi daemon、系统服务状态、动态库路径、权限、端口占用、防火墙和组播转发。问题边界不清楚。
所以这次重做的目标很明确:
- 去掉 Avahi/Bonjour SDK/libdns_sd 运行时依赖。
- 内置一个只覆盖 Airplay投屏接收器/APP 所需能力的 mDNS responder。
- 代码保持简单,不做通用 mDNS 框架。
- 对外行为尽量保持和抓包里的 AirPlay 设备期望一致。
二、抓包环境
抓包现场有两个关键地址:
- Apple 设备:
192.168.1.102,IPv6 link-local 地址为fe80::893:1c69:90d7:c4b7 - Airplay投屏器主机:
192.168.1.103,IPv6 link-local 地址为fe80::65f3:e0b8:8891:bb61
其它敏感字段在本文里会脱敏,比如 MAC 地址、公钥等只展示结构,不展示完整值。
这份抓包可以直接确认三点。
第一,mDNS 发现同时走了 IPv4 和 IPv6。 在 15:00:05.516 附近同时向 IPv4 组播 224.0.0.251:5353 和 IPv6 组播 ff02::fb:5353 公告 _airplay._tcp.local、_raop._tcp.local,对应抓包帧号是 12 到 15。响应里同时包含 A 192.168.1.103 和 AAAA fe80::65f3:e0b8:8891:bb61。
第二,Apple 设备也同时用 IPv4 和 IPv6 查询。比如 15:00:45.519 可以看到 192.168.1.102 -> 224.0.0.251:5353 和 fe80::893:1c69:90d7:c4b7 -> ff02::fb:5353 两份查询,内容都是 _raop._tcp.local 和 _airplay._tcp.local。
第三,后续 AirPlay 连接实际使用了 IPv6。mDNS 的 SRV 记录给出的服务端口是 39851,随后在帧 461 到 463 出现了 Apple 设备从 [fe80::893:1c69:90d7:c4b7]:49156 到投屏器 [fe80::65f3:e0b8:8891:bb61]:39851 的 TCP 三次握手,并继续传输数据。随后在帧 490 到 492 还有一条从 [fe80::893:1c69:90d7:c4b7]:49157 到 [fe80::65f3:e0b8:8891:bb61]:37759 的 IPv6 TCP 数据流。反向检查 192.168.1.102 和 192.168.1.103 之间的 TCP,没有看到对应的 IPv4 会话。因此这份抓包不是只发布 IPv6 地址,而是客户端确实选择了 IPv6 link-local 地址完成连接。
三、设备发现总流程
先用一张时序图看完整流程。
@startuml
title AirPlay mDNS/DNS-SD discovery
actor "Apple 设备\n192.168.1.102\nfe80::893:..." as Apple
participant "mDNS 组播地址\n224.0.0.251:5353\nff02::fb:5353" as MDNS
participant "UxPlay 主机\n192.168.1.103\nfe80::65f3:..." as UxPlay
Apple -> MDNS: PTR query\n_airplay._tcp.local\n_raop._tcp.local\nclass IN + QU
MDNS -> UxPlay: 局域网组播转发
UxPlay -> MDNS: PTR/SRV/TXT/A/AAAA response\nAirPlay + RAOP
MDNS -> Apple: Apple 设备收到服务描述
Apple -> UxPlay: 后续连接 AirPlay/RAOP 端口
@enduml
关键点是:设备发现阶段只解决"服务在哪里"和"服务能力是什么",并不传输投屏画面,真正的投屏连接发生在设备发现之后。
四、从抓包时间线还原发现过程
只看单个包容易丢掉上下文。设备发现要按时间线看,才能理解 Apple 设备和 Airplay投屏器/APP 各自在做什么。
下面是抓包里和 AirPlay/RAOP 发现相关的一段简化时间线。为了避免泄漏设备标识,服务实例里的 MAC 和公钥都做了脱敏。
| 帧 | 相对时间 | 源地址 | 目的地址 | 类型 | 关键信息 |
|---|---|---|---|---|---|
| 12 | 3.573s | 192.168.1.103 | 224.0.0.251 | IPv4 response | RAOP 公告,包含 A 和 AAAA |
| 13 | 3.573s | fe80::65f3:e0b8:8891:bb61 | ff02::fb | IPv6 response | 同一组 RAOP 公告发到 IPv6 mDNS 组播 |
| 14 | 3.573s | 192.168.1.103 | 224.0.0.251 | IPv4 response | AirPlay + RAOP 合并响应,SRV 端口 39851 |
| 15 | 3.573s | fe80::65f3:e0b8:8891:bb61 | ff02::fb | IPv6 response | 同一组合并响应发到 IPv6 mDNS 组播 |
| 309 | 24.289s | fe80::893:1c69:90d7:c4b7 | ff02::fb | IPv6 query | Apple 设备查询 _airplay、_raop 等服务 |
| 313 | 24.306s | fe80::65f3:e0b8:8891:bb61 | fe80::893:1c69:90d7:c4b7 | IPv6 unicast response | UxPlay 按 QU 查询单播回复 Apple 设备 |
| 402/403 | 27.615s | 192.168.1.102 / fe80::893:... | 224.0.0.251 / ff02::fb | dual-stack query | Apple 设备同时发 IPv4 和 IPv6 mDNS 查询 |
| 435/436 | 28.637s | 192.168.1.103 | 192.168.1.102 | IPv4 unicast response | UxPlay 对 IPv4 QU 查询单播回复 |
| 461-463 | 31.843s | fe80::893:... | fe80::65f3:... | IPv6 TCP | Apple 设备连接 UxPlay 的 SRV 端口 39851 |
这条时间线说明两件事。
第一,服务端不能只在启动时公告一次。局域网里 mDNS 是 UDP 组播,包可能丢,客户端也可能在任意时刻打开投屏面板。启动时多次公告和查询时即时响应都需要支持。
第二,Apple 设备不是只问 _airplay._tcp.local。它会同时问 _raop._tcp.local。如果只发布 AirPlay,不发布 RAOP,客户端可能认为设备能力不完整。
设备发现可以画成更细的状态图:
@startuml
title Discovery states from packet capture
[*] --> ServerStarts
ServerStarts --> ProbeAndAnnounce : UxPlay starts
ProbeAndAnnounce --> MulticastVisible : PTR/SRV/TXT/A/AAAA announced
MulticastVisible --> ClientQueries : Apple opens AirPlay device list
ClientQueries --> AnswerMatched : _airplay/_raop PTR matched
AnswerMatched --> ClientCaches : client caches TXT/SRV/A
ClientCaches --> ConnectProtocol : client connects AirPlay/RAOP port
ConnectProtocol --> [*]
ClientQueries --> ClientQueries : repeated mDNS query
ProbeAndAnnounce --> ProbeAndAnnounce : repeated announcement
@enduml五、抓包分析:Apple 设备怎么问
抓包里一个典型查询包来自 192.168.1.102,发往 224.0.0.251:5353。
简化后的字段如下:
Multicast Domain Name System (query)
Transaction ID: 0x0000
Flags: 0x0000 Standard query
Questions: 2
Answer RRs: 2
Additional RRs: 1
Queries:
_airplay._tcp.local: type PTR, class IN, QU question
_raop._tcp.local: type PTR, class IN, QU question
Known answers:
_airplay._tcp.local -> UxPlay@debian._airplay._tcp.local
_raop._tcp.local -> <mac>@UxPlay@debian._raop._tcp.local
这几个字段很重要。
Transaction ID 是 0x0000。mDNS 通常使用 0,因为它不是传统 DNS 那种客户端发一个随机 ID、服务器原样带回的请求响应模型。
Questions: 2 表示一个包里同时问了两个服务类型:
_airplay._tcp.local_raop._tcp.local
type PTR 表示它不是在问"某个域名的 IP 是什么",而是在问"这个服务类型下面有哪些实例"。这就是 DNS-SD 的入口。
QU question 表示查询方希望收到 unicast response。也就是说,除了普通组播回复以外,服务端最好也支持直接回给查询方。
Answer RRs: 2 在 query 包里不是普通响应,而是 known-answer。Apple 设备告诉局域网里的 responder:"我已经知道这些答案了,如果没有变化,可以按 mDNS 规则减少重复回复"。实现上可以先不做复杂抑制,但必须能正确解析这种包,不能因为 query 包里带了 answers 就误判成 response。
这里的 known-answer 对排查很有价值。如果抓包里看到 Apple 设备一直带着旧服务实例名重复查询,但 UxPlay 侧没有新的响应,通常要检查:
- responder 是否真的绑定到了 UDP 5353。
- 是否加入了
224.0.0.251组播。 - query 包里带 answers 时,解析逻辑是否提前返回。
- QU 位是否被正确识别。
- 防火墙或 Wi-Fi AP 是否丢弃了 mDNS 组播。
从协议角度看,query 包里带 known-answer 是正常行为,不能把它当成异常包。
六、抓包分析:服务端应该怎么答
一个完整的 AirPlay/RAOP DNS-SD 服务描述不是单条记录,而是一组记录。
@startuml
title DNS-SD records used by AirPlay discovery
rectangle "_services._dns-sd._udp.local" as Services
rectangle "_airplay._tcp.local" as AirPlayType
rectangle "_raop._tcp.local" as RaopType
rectangle "UxPlay@hostname._airplay._tcp.local" as AirPlayInst
rectangle "<mac>@UxPlay@hostname._raop._tcp.local" as RaopInst
rectangle "hostname.local" as Host
rectangle "192.168.x.x" as IPv4
rectangle "fe80::...%iface" as IPv6
Services --> AirPlayType : PTR
Services --> RaopType : PTR
AirPlayType --> AirPlayInst : PTR
RaopType --> RaopInst : PTR
AirPlayInst --> Host : SRV + port
RaopInst --> Host : SRV + port
AirPlayInst --> AirPlayInst : TXT
RaopInst --> RaopInst : TXT
Host --> IPv4 : A
Host --> IPv6 : AAAA
@enduml
这里每种记录有不同作用。
PTR 记录把服务类型映射到服务实例。例如 _airplay._tcp.local 指向 UxPlay@debian._airplay._tcp.local。
SRV 记录告诉客户端服务运行在哪台主机、哪个端口。例如实例指向 debian.local:39851。
TXT 记录描述能力。AirPlay/RAOP 很多能力不是靠后续连接猜出来的,而是在 TXT 里先告诉客户端。
A 记录把 debian.local 解析成 IPv4 地址 192.168.1.103。
AAAA 记录把 debian.local 解析成 IPv6 地址。mDNS 的 IPv6 发现发生在本地链路上,所以常见地址是 fe80::/64 link-local 地址,发送到 ff02::fb:5353 时必须带接口 scope。
6.1 RAOP 响应包
本次抓包里 RAOP 响应包大约 576 字节,包含 6 条 answer。
简化后如下:
Flags: 0x8400 Standard query response, authoritative, no error
Answer RRs: 6
TXT <mac>@UxPlay@debian._raop._tcp.local
PTR _raop._tcp.local -> <mac>@UxPlay@debian._raop._tcp.local
SRV <mac>@UxPlay@debian._raop._tcp.local -> debian.local:39851
AAAA debian.local -> <ipv6 address>
A debian.local -> 192.168.1.103
PTR _services._dns-sd._udp.local -> _raop._tcp.local
其中 TXT 字段包括:
ch=2
cn=0,1,2,3
da=true
et=0,3,5
vv=2
ft=0x527FFEE6,0x0
am=AppleTV3,2
md=0,1,2
rhd=5.6.0.0
pw=false
sr=44100
ss=16
sv=false
tp=UDP
txtvers=1
sf=0x4
vs=220.68
vn=65537
pk=<redacted>
这些字段的含义可以粗略理解为:
| 字段 | 含义 |
|---|---|
ch | 声道数 |
cn | 支持的音频编码能力集合 |
et | 支持的加密方式 |
ft | feature bits,设备能力位 |
am | 设备型号 |
md | metadata 能力 |
pw | 是否需要密码 |
sr | 采样率 |
ss | sample size |
tp | 传输协议 |
sf | service flags |
vs | server version |
pk | 公钥,本文脱敏 |
RAOP 主要对应音频能力。即使 AirPlay 镜像是主目标,RAOP 记录也会影响客户端对设备能力的判断。
6.2 AirPlay 响应包
本次抓包里 AirPlay 响应包大约 532 字节,包含 6 条 answer。
简化后如下:
Flags: 0x8400 Standard query response, authoritative, no error
Answer RRs: 6
TXT UxPlay@debian._airplay._tcp.local
PTR _airplay._tcp.local -> UxPlay@debian._airplay._tcp.local
SRV UxPlay@debian._airplay._tcp.local -> debian.local:39851
A debian.local -> 192.168.1.103
AAAA debian.local -> <ipv6 address>
PTR _services._dns-sd._udp.local -> _airplay._tcp.local
TXT 字段包括:
deviceid=<mac-redacted>
features=0x527FFEE6,0x0
pw=false
flags=0x4
model=AppleTV3,2
pk=<redacted>
pi=2e388006-13ba-4041-9a67-25dd4a43d536
srcvers=220.68
vv=2
AirPlay 记录更偏向视频、镜像、配对和设备身份描述。
6.3 TTL 和 cache flush
抓包里能看到两个常见 TTL:
- 服务和 TXT 的 TTL:
4500秒 - 主机 A/SRV 相关记录 TTL:
120秒
这符合 mDNS/DNS-SD 的常见做法:服务描述可以缓存更久,主机地址和具体连接目标缓存短一点。
还有一个容易忽略的字段是 cache flush。
对 TXT、SRV、A 这类唯一记录,响应里会带 cache flush。它的意思是:客户端如果缓存了同名旧记录,应该用这条新记录替换。
对 PTR 这类"一个服务类型可以对应多个实例"的记录,不应该带 cache flush。否则可能把局域网里其他同类型服务错误清掉。
6.4 合并响应还是拆包响应
抓包里能看到 UxPlay 主机会有几种响应:
| 包类型 | 大小 | answer 数 |
|---|---|---|
| RAOP 单独响应 | 约 576 字节 | 6 |
| AirPlay 单独响应 | 约 532 字节 | 6 |
| AirPlay + RAOP 合并响应 | 约 1028 字节 | 10 |
原始抓包里的合并响应不算太大,一个重要原因是 DNS 名称可以压缩。自己实现一个简单 responder 时,如果一开始不做 DNS name compression,合并包会明显变大。
这里有两种工程选择。
第一种是拆包响应:
- AirPlay 查询命中时,发 AirPlay 记录包。
- RAOP 查询命中时,发 RAOP 记录包。
- 两者都命中时,分别发两个包。
拆包的优点是每个包都小,不做 DNS name compression 也几乎没有 MTU 风险。缺点是重复 DNS header、重复 host A 记录,也会多一次 sendto()。
第二种是合并响应:
- 一个 response 同时携带 AirPlay 和 RAOP 的 PTR/SRV/TXT。
- host A 记录只带一次。
- 客户端一次就能拿到完整服务描述。
合并响应的体验更接近抓包里的 Bonjour 行为,也减少组播包数量。风险是单个 UDP payload 变大。如果包接近 MTU,局域网里的 UDP multicast 分片会更脆弱。
当前实现采用折中方案:优先合并 AirPlay + RAOP 响应,但不做 DNS name compression;合并包超过 1200 字节时,自动回退到拆包响应。
1200 不是 DNS 协议硬限制,而是一个保守工程阈值。普通 Ethernet IPv4 下,避免分片的 UDP payload 上限大约是:
1500 - 20(IP header) - 8(UDP header) = 1472 bytes
选择 1200 可以给 IPv6、VLAN、隧道、未来 additional records 留出余量。对当前 AirPlay + RAOP + A/AAAA 记录的组合来说,典型合并包大约在 1KB 左右,不需要马上引入 DNS 压缩。
七、设计方案
代码上把职责拆成两层。
@startuml
title UxPlay mDNS implementation split
package "UxPlay main" {
[uxplay.cpp] as UxPlay
}
package "DNS-SD service layer" {
[lib/dnssd.c] as Dnssd
[lib/dnssd.h] as DnssdH
}
package "mDNS protocol layer" {
[lib/mdnsd.c] as Mdnsd
[lib/mdnsd.h] as MdnsdH
}
UxPlay --> Dnssd : dnssd_init/register/unregister
Dnssd --> Mdnsd : mdnsd_set_services\nmdnsd_start\nmdnsd_announce
Mdnsd --> "UDP 5353\nIPv4 + IPv6" : socket\nmulticast\nsendto/recvfrom
DnssdH ..> Dnssd
MdnsdH ..> Mdnsd
@enduml
dnssd.c 保留业务含义:
- 设备名
- MAC 地址
- AirPlay/RAOP 实例名
- TXT 字段
- feature bits
- PIN/密码策略
mdnsd.c 只处理协议:
- DNS name 编码和解析
- PTR/SRV/TXT/A/AAAA 记录编码
- UDP 5353 socket
- 加入
224.0.0.251和ff02::fb组播 - mDNS 查询解析
- 组播响应和 QU 单播响应
- goodbye 包
这样拆完后,dnssd.c 不需要知道 DNS 包怎么拼,mdnsd.c 也不需要理解 AirPlay feature bit 的业务含义。
旧方案和新方案的边界差异如下。
@startuml
title Old external DNS-SD dependency vs built-in responder
rectangle "Old design" {
[UxPlay] as OldUx
[libdns_sd API] as Libdns
[Avahi daemon / Bonjour] as Daemon
[UDP 5353] as OldUdp
OldUx --> Libdns
Libdns --> Daemon
Daemon --> OldUdp
}
rectangle "New design" {
[UxPlay] as NewUx
[dnssd.c\nservice data] as NewDnssd
[mdnsd.c\npacket + socket] as NewMdnsd
[UDP 5353\nIPv4 + IPv6] as NewUdp
NewUx --> NewDnssd
NewDnssd --> NewMdnsd
NewMdnsd --> NewUdp
}
@enduml
旧方案把发现能力交给系统服务,新方案把发现能力收回到 UxPlay 进程内。这样做不是为了重复造一个完整 Avahi,而是因为 UxPlay 只需要一个很小的协议子集。
这个子集包括:
_services._dns-sd._udp.local的 PTR 枚举。_airplay._tcp.local的 PTR 枚举。_raop._tcp.local的 PTR 枚举。- AirPlay/RAOP 实例的 SRV/TXT。
- hostname 的 A 记录。
- hostname 的 AAAA 记录。
- AirPlay + RAOP 的合并响应,以及超过阈值时的拆包回退。
- goodbye TTL=0。
不包括:
- 通用 DNS resolver。
- 跨网段 DNS-SD。
- 完整 service browser。
- 完整 DNS cache。
- 全量 name compression。
八、实现流程
运行时流程可以画成这样:
@startuml
title Runtime flow
start
:uxplay.cpp 初始化设备名和硬件地址;
:dnssd_init();
:生成 hostname.local;
:生成 AirPlay/RAOP 实例名;
if (注册 RAOP?) then (yes)
:构造 RAOP TXT;
:mdnsd_set_services();
:mdnsd_start();
:发送 RAOP announce;
endif
if (注册 AirPlay?) then (yes)
:构造 AirPlay TXT;
:mdnsd_set_services();
:mdnsd_start();
:发送 AirPlay announce;
endif
repeat
:select IPv4/IPv6 UDP 5353;
:recvfrom 命中的 socket;
:解析 DNS header 和 questions;
if (命中 _airplay 或 _raop?) then (yes)
if (同时命中 AirPlay 和 RAOP?) then (yes)
:尝试构造合并响应包;
if (包长 <= 1200 bytes?) then (yes)
:发送合并响应;
else (no)
:回退为 AirPlay/RAOP 拆包响应;
endif
else (no)
:按服务构造响应包;
endif
:发组播响应;
if (QU 或源端口不是 5353?) then (yes)
:发单播响应给查询方;
endif
endif
repeat while (UxPlay running)
:unregister 时发送 goodbye TTL=0;
stop
@enduml
实现里没有做成通用 mDNS 框架,原因是 UxPlay 不需要完整框架。它只需要服务发现所需的最小闭环。
8.1 关键实现点
第一,打开 UDP socket。
mDNS 使用固定端口 5353,IPv4 组播地址是 224.0.0.251,IPv6 组播地址是 ff02::fb。responder 启动时需要:
- 创建 UDP socket
- 设置
SO_REUSEADDR - 设置 multicast TTL
- 设置 multicast loop
- bind 到
0.0.0.0:5353 - 加入
224.0.0.251 - bind 到
[::]:5353 - 选择一个 IPv6 link-local 接口
- 设置
IPV6_MULTICAST_IF - 加入
ff02::fb
第二,选择出站地址。
本机可能有多张网卡,代码用 UDP connect() 到 224.0.0.251:5353 的方式让内核选择默认出站接口,再通过 getsockname() 得到本机 IPv4 地址。这个地址用于 A 记录。
IPv6 不能只照搬这个办法。ff02::fb 是 link-local multicast,发送时需要明确接口 scope。实现里通过 getifaddrs() 枚举 AF_INET6 地址,跳过 loopback,优先选择支持 multicast 的 link-local 地址,并记录 sin6_scope_id。这个地址用于 AAAA 记录,这个 scope 用于 IPV6_MULTICAST_IF 和向 ff02::fb 发送组播。
第三,解析 DNS name。
DNS name 是 label 格式,例如:
_airplay._tcp.local
在线上包里会编码成:
8 "_airplay"
4 "_tcp"
5 "local"
0
解析时还要处理 DNS compression pointer。即使当前实现生成响应时为了简单不做压缩,也必须能解析别人发来的压缩名称。
第四,识别查询类型。
主要命中条件是:
_services._dns-sd._udp.local的 PTR 查询_airplay._tcp.local的 PTR 查询_raop._tcp.local的 PTR 查询- 服务实例名的 TXT/SRV 查询
- hostname 的 A 查询
- hostname 的 AAAA 查询
第五,构造响应记录。
响应包 header 使用:
Transaction ID: 0x0000
Flags: 0x8400
Questions: 0
Answer RRs: N
0x8400 表示这是 response,并且是 authoritative answer。
代码里不要直接写 0x8400 这种 magic number,更清楚的写法是拆成两个 flags:
#define DNS_FLAG_RESPONSE 0x8000
#define DNS_FLAG_AUTHORITATIVE 0x0400
构造 header 时使用:
DNS_FLAG_RESPONSE | DNS_FLAG_AUTHORITATIVE
这样以后读代码时可以直接看到语义,而不是反查 DNS header bit。
第六,控制合并包大小。
AirPlay 和 RAOP 同时命中时,responder 会先尝试构造一个合并包。构造完成后检查 payload 长度,如果超过 1200 字节,就丢弃这个临时包,回到原来的拆包发送路径。
这个逻辑有两个好处:
- 正常情况下减少一半左右的响应包数量。
- 极端设备名、未来新增记录或 TXT 增长时,不会让 multicast UDP 包逼近 MTU。
第七,处理 QU。
Apple 设备的查询带 QU。实现上如果发现 query class 最高位是 0x8000,就除了组播响应外,再向查询来源地址发一份单播响应。
第八,处理 goodbye。
服务取消注册时发送 TTL 为 0 的记录,告诉客户端清掉缓存。否则客户端可能在一段时间内继续显示已经退出的设备。
第九,双栈发送。
IPv4 和 IPv6 共享同一组 AirPlay/RAOP 服务数据,但不能共享同一个 socket。实现上 mdnsd 内部维护两个 fd:一个加入 224.0.0.251,一个加入 ff02::fb。线程里用 select() 同时等待两个 fd,收到 IPv4 查询就回 IPv4 响应,收到 IPv6 查询就回 IPv6 响应。公告和 goodbye 也分别向两个组播地址发送。
8.2 为什么不直接实现完整 DNS 压缩
DNS name compression 可以显著减少包大小,但它会引入更多状态:
- 需要记录每个 name suffix 在包里的 offset
- 要避免生成非法 pointer
- 要处理压缩指针嵌套和循环风险
- 要保证不同 RR 的 name 和 rdata 都能正确引用
对 UxPlay 当前需求来说,这个复杂度可以暂时不引入。合并响应已经通过 1200 字节阈值保护住 MTU 风险;一旦超过阈值,代码会回退拆包。
这是一个有意的取舍:先用简单方案解决真实问题,而不是先做一个完整 DNS 库。后续如果要加入 NSEC、多地址 additional records,或者 TXT 继续膨胀,再实现发送端 DNS name compression 会更合适。
8.3 Android 平台考虑
Android 上最重要的约束是部署形态不同。
在传统 Linux 发行版里,可以说"请安装 Avahi 并启动服务"。但 Android 上通常不能这么要求。即使设备有 root 或定制系统,把 Avahi daemon 带进去也会引入额外问题:
- daemon 生命周期谁管理
- SELinux 策略怎么放行
- UDP 5353 权限和组播权限怎么处理
- 多应用或多服务争用 5353 怎么协调
- 动态库和头文件怎么随系统构建
内建 mDNS responder 后,UxPlay 只需要依赖 socket 能力。对 Android Native 层来说,这比外部 daemon 更可控。
当然,Android 仍然要注意:
- Wi-Fi 网络必须允许 multicast
- 进程需要具备加入组播的网络权限
- 如果系统已有 mDNSResponder,需要确认 UDP 5353 复用策略
- 某些设备的省电策略可能影响组播收发
这些是网络和系统权限问题,但至少不再额外依赖 Avahi。
九、验证方式
编译验证:
cmake -B build
cmake --build build
运行验证:
timeout 4 ./build/uxplay -n UxPlay -nh -vs 0 -as 0
抓包验证可以用:
tshark -i <iface> -f "udp port 5353"
如果要专门看 IPv6 mDNS,可以过滤:
tshark -i <iface> -f "udp port 5353 and ip6"
也可以用字段过滤看 mDNS 记录:
tshark -r airplay_mdns.pcapng \
-Y "mdns && ip.src == 192.168.1.103" \
-T fields \
-e frame.number \
-e frame.len \
-e dns.count.answers \
-e dns.resp.name \
-e dns.ptr.domain_name \
-e dns.srv.target \
-e dns.srv.port \
-e dns.a \
-e dns.aaaa \
-e dns.txt
验证重点不是"有没有包",而是以下几项都要成立:
- Apple 设备会查询
_airplay._tcp.local和_raop._tcp.local。 - UxPlay 会响应 PTR、SRV、TXT、A、AAAA 记录。
- IPv4 查询走
224.0.0.251:5353,IPv6 查询走ff02::fb:5353。 - 后续 AirPlay TCP 连接是否选择了 SRV 记录对应的 IPv4 或 IPv6 地址;本次抓包中客户端选择了 IPv6 link-local 地址连接
39851。 - 同时查询 AirPlay 和 RAOP 时,优先看到合并响应;如果包长超过阈值,会回退为拆包响应。
- SRV 端口和 UxPlay 实际监听端口一致。
- TXT 字段里 feature、model、version、password 标志符合预期。
- QU 查询能收到响应。
- unregister 或退出时能发送 TTL 为
0的 goodbye。
十、代码下载
总结
这次重做的核心不是"自己写一个 mDNS 库",而是把 UxPlay 需要的设备发现闭环收进项目内部。
从抓包看,AirPlay 发现流程并不神秘:Apple 设备问 _airplay._tcp.local 和 _raop._tcp.local,服务端回复 PTR/SRV/TXT/A/AAAA。只要把这些记录按 mDNS/DNS-SD 规则说清楚,客户端就能发现设备。
从工程角度看,内建 responder 带来的收益更明显:
- Linux 上少一个外部 daemon 依赖。
- Android 和嵌入式平台更容易部署。
- 发现问题时边界更清楚。
- 代码结构更直接,
dnssd.c负责业务,mdnsd.c负责协议。
这也是这次选择重做 mDNS 发现方案的根本原因。
评论