☰
Miracast投屏协议解析:Source与Sink端到端代码实战
2026/10/20 8:19:00 网站建设 项目流程

简介:一份面向无线显示开发者的Miracast投屏双端实现代码示例,覆盖Source源端与Sink接收端的关键流程,适合正在研究Wi-Fi Direct投屏、Android屏幕镜像或想自建投屏方案的工程师参考。资源压缩包共64个文件,压缩后212KB,以C++源码为主(19个cpp、16个h),同时包含Java、XML、mk构建脚本及说明文档,代码结构较为完整。已有5538人学习使用。包内Sink端实现涉及设备初始化、Wi-Fi Direct连接管理、Miracast协议处理与视频流解码显示,并附带WifiDisplayController相关修改与框架层代码,便于理解Android系统如何集成Miracast。通过研读这些示例,开发者可以掌握Source端视频采集编码、Sink端解码渲染的基本架构,以及两者间会话建立和数据传输的细节,对排查投屏延迟、连接不稳定、兼容性适配等问题也有直接帮助。

1. Miracast 投屏的 Source 与 Sink:两端代码到底差在哪

Miracast(行业里通常就叫 WiFi Display)投屏看起来是一个动作,代码上却是两个独立进程在配合:Source 端负责采集编码和推送,Sink 端负责接收解码和渲染,中间的 RTSP 会话、RTP 传输、能力协商,任何一端理解不到位,投屏就断在某个不起眼的握手环节。这份资源把 Miracast Source 和 Miracast Sink 两端的代码示例都拆开了,从 RTSP 会话建立到视频流收发,每一步都有可抄的代码和参数。新手能照着跑通手机到盒子的完整投屏链路,熟手能直接借鉴 Sink 端的协议状态机和 Source 端的码率控制思路。如果你正卡在「RTSP 握手成功但画面黑屏」或者「Sink 收不到 RTP 包」这类问题上,这份资源值得花两个小时过一遍。

2. 先看协议再写代码:Miracast 的四层链路与两个角色划分

2.1 从 P2P 到 RTP:一条投屏数据到底经过几层

Miracast 走的是 WiFi Direct 通道,不是局域网 TCP 广播。整个投屏链路从上到下拆成四层,调任何一个环节的问题,都得先清楚自己站在哪一层:

层次协议/标准承载方式两端分工
连接层Wi-Fi Direct (P2P)2.4/5G 信道GO 协商、设备发现、PIN 配对
控制面RTSP / WFD 扩展TCP 7236GET_PARAMETER、SET_PARAMETER、SETUP、PLAY
数据面RTP / RTCPUDP 动态端口H.264 视频流、AAC 音频流
编解码H.264 / AAC芯片或软解Source 编码,Sink 解码渲染

新手最容易绕晕的点在于:RTSP 是 TCP 长连接,只传控制指令;真正的视频数据走 UDP。所以「RTSP 连接成功」不等于「画面能出来」,连接成功只是把双方能力、端口、编解码器谈妥了,后续的 RTP 数据完全在另一条独立的 UDP 通道上跑。你抓包的时候如果只盯 TCP 7236,会误以为没在传数据,其实数据全在动态 UDP 端口上。

资源里的代码示例默认你已经通过 P2P 完成配对并拿到对端 IP,把 RTSP 会话当作独立模块来写。实际项目里如果设备发现失败,要先回头查 P2P 状态,而不是在 RTSP 代码里排查。我一般会先跑一遍wpa_cli p2p_find确认能看到对端,再进 RTSP 流程,这样能把「链路不存在」和「协议没谈拢」两类问题快速分开。

2.2 Source 是主动方,Sink 是被动方:别把 P2P 的 GO 混进来

源码里能清楚看到两者的角色差异。Source(手机、笔记本这类发送端)是 RTSP 的主动发起方,扮演 TCP client,主动 connect Sink 的 7236 端口;Sink(电视、盒子这类接收端)监听 7236,扮演 TCP server,只响应不主动发起。

这里有个容易翻车的混淆点:P2P 的 group owner(GO)不等于 Sink。GO 是 WiFi Direct 层面选出的「组长」,负责信道管理和时钟同步,但 RTSP 层面的 server 一定是 Sink。也就是说,哪怕 Source 是 group owner,它照样要主动发起 RTSP 连接;哪怕 Sink 是 group client,它照样在 7236 上等连接。三个角色横跨两层概念,别把它们映射到同一个维度上。

还有一个资源里特别标注的步骤:P2P 配对过程中的 PIN 码确认。Source 发起 P2P 协商时携带 PIN,Sink 侧确认后链路才真正可用。代码里体现为 GO 协商完成后要等一个配对确认信号,再开始 RTSP 的 OPTIONS。跳过这一步,后续连接可能时通时不通,而且不好复现——你以为是代码问题,其实是配对状态没就绪。

2.3 能力协商:wfd_video_formats 解析是第一个坑

RTSP 握手的关键参数集中在wfd_video_formats和wfd_audio_codecs。Source 在 M1(GET_PARAMETER)里拿到 Sink 的能力列表,然后在 M3(SET_PARAMETER)里声明选用的格式。常见做法是 Sink 返回一组格式,Source 选第一个两端都支持的组合。

def parse_wfd_video_formats(attr_value): # 完整格式示例(空格分段): # wfd_video_formats: 00 0000 0001 00000101 00000000 00000000 00000000 00 0000 0000 # 首段 native_format,第 4 段是 profile/level 掩码,后面是分辨率和刷新率组合 parts = attr_value.strip().split() if len(parts) < 8: return None native_format = int(parts[0], 16) # Sink 的原生显示格式索引 profile_mask = parts[3] # 支持的 H.264 profile 位掩码 # 分辨率段按 3 个十六进制字符一组解析,资源里给了逐组比对函数 return { "native": native_format, "profile_mask": profile_mask, "raw_parts": parts, }

这段代码的逻辑是把 WFD 规范里的固定分段格式拆开:第一个十六进制数是原生格式索引,第四段是 profile 掩码,后面的段描述了支持的分辨率、帧率和刷新率组合。解析时一定要按空格分段再逐位比对,不要整行做字符串匹配。参数里常见的几个取值:00000101表示支持 1080p60 的 profile L3 到 L4;如果 Sink 只支持到 720p,Source 硬选 1080p,M3 响应会直接给 403。

音频侧wfd_audio_codecs也一样,Source 必须从 Sink 列出的编解码器列表里挑一个匹配的。两处有一处不匹配,能力协商就失败,表现为握手中途断开,或者 PLAY 之后画面不出。资源里的 Sink 代码默认回的是 LPCM 优先、AAC 可选的组合,Source 端如果强制 AAC,需要在 M3 之前把音频参数改一致。

WFD 的时序和标准 RTSP 还有个明显差异:Source 不能在 M1 之后直接发 SETUP,得先在 M3 里带wfd_trigger_method: SETUP让 Sink 知道「接下来要建立传输通道了」,Sink 收到后才允许 SETUP。如果 M3 里没带这个参数直接发 SETUP,Sink 会回 454 Session Not Ready。这个错误在移植标准 RTSP 库的代码里特别常见,因为标准 RTSP 没有这个中间步骤。资源里把 M3 的 body 内容完整列出来了,照着抄就不会踩这个坑。

3. Source 端代码:RTSP 主动握手到 RTP 推流

3.1 构建 RTSP 客户端:M1 到 M5 的状态推进

Source 的 RTSP 流程是典型的「请求-响应」推进。OPTIONS 查询 Sink 支持的方法,M1 拉取能力,M3 声明 Source 的选择并携带触发指令,M4 SETUP 协商传输端口,M5 PLAY 正式推流。核心代码:

import socket def recv_response(sock): # 读取到 \r\n\r\n 为止,按 Content-Length 读完整 body data = b"" while b"\r\n\r\n" not in data: chunk = sock.recv(4096) if not chunk: break data += chunk # 简单处理:如果带 body,再读 Content-Length 指定的字节 header, _, body = data.partition(b"\r\n\r\n") for line in header.split(b"\r\n"): if line.lower().startswith(b"content-length"): need = int(line.split(b":")[1].strip()) while len(body) < need: body += sock.recv(4096) return header.decode() + "\r\n\r\n" + body.decode() def start_rtsp_session(sink_ip, sink_port=7236): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((sink_ip, sink_port)) cseq = 1 # 第一步:OPTIONS 探测 Sink 支持的方法 sock.send(f"OPTIONS * RTSP/1.0\r\nCSeq: {cseq}\r\n\r\n".encode()) resp = recv_response(sock) # Sink 会返回 Public: GET_PARAMETER, SET_PARAMETER, SETUP, PLAY # 第二步:M1 GET_PARAMETER,获取 Sink 能力 cseq += 1 sock.send((f"GET_PARAMETER rtsp://localhost/wfd1.0 RTSP/1.0\r\n" f"CSeq: {cseq}\r\n" f"Content-Type: text/parameters\r\n\r\n" f"wfd_video_formats\r\nwfd_audio_codecs\r\n").encode()) m1 = recv_response(sock) # 从响应里解析出 video_formats 和 audio_codecs,保存备用 # 第三步:M3 SET_PARAMETER,声明 Source 选用的格式 cseq += 1 sock.send((f"SET_PARAMETER rtsp://localhost/wfd1.0 RTSP/1.0\r\n" f"CSeq: {cseq}\r\n" f"Content-Type: text/parameters\r\n\r\n" f"wfd_video_formats: 00 0000 0001 00000101 00000000 00000000 00000000 00 0000 0000\r\n" f"wfd_audio_codecs: LPCM 00000003 00\r\n" f"wfd_trigger_method: SETUP\r\n").encode()) recv_response(sock) # 第四步:M4 SETUP,响应里的 server_port 才是 Sink 收 RTP 的端口 cseq += 1 sock.send((f"SETUP rtsp://localhost/wfd1.0 RTSP/1.0\r\n" f"CSeq: {cseq}\r\n" f"Transport: RTP/AVP/UDP;unicast;client_port=50004-50005\r\n\r\n").encode()) setup_resp = recv_response(sock) for line in setup_resp.split("\r\n"): if line.lower().startswith("transport"): # 取 server_port=50000-50001 中的 50000 sink_rtp_port = int(line.split("server_port=")[1].split("-")[0]) break # 第五步:M5 PLAY,Sink 状态切到 PLAYING cseq += 1 sock.send((f"PLAY rtsp://localhost/wfd1.0 RTSP/1.0\r\nCSeq: {cseq}\r\n\r\n").encode()) recv_response(sock) return sock, sink_rtp_port

逻辑说明:这段代码模拟了 Source 端 RTSP 会话的核心推进。要注意第四步响应里的server_port才是 Sink 真正监听 RTP 的 UDP 端口,很多新手直接拿 Sink 的 IP 加 7236 去发 RTP,结果包全丢在 TCP 端口上。第五步 PLAY 之后,Source 就可以直接往(sink_ip, sink_rtp_port)发 UDP 了。

参数说明:client_port=50004-50005中的 50004 是 Source 发送 RTP 用的源端口,这个端口由 Source 自己决定,通常绑定在发送 socket 上;Sink 响应里的server_port=50000-50001才是数据要到达的目的端口。WFD 的端口语义和标准 RTSP 是反的——这也是很多人移植标准 RTSP 库时反复翻车的地方。

3.2 H.264 编码与 RTP 分片:FU-A 是绕不开的打包方式

拿到 Sink 的 UDP 端口后,Source 要做的就是把编码器输出的 H.264 NAL 单元转成 RTP 包发送。大于 MTU 的 NAL 必须用 FU-A 分片,小于 MTU 的直接单包发送:

import socket, struct, time def send_h264_rtp(sock, dst_ip, dst_port, nal_data, seq_start=0): """发送单个 H.264 NAL,按长度自动选择单包或 FU-A 分片""" seq = seq_start nal_type = nal_data[0] & 0x1F # WFD 视频流使用 90kHz RTP 时钟,timestamp 不能用毫秒 timestamp = int(time.time() * 90000) def build_and_send(payload): nonlocal seq header = struct.pack("!BBHII", 0x80, 96, seq & 0xFFFF, timestamp, 0) sock.sendto(header + payload, (dst_ip, dst_port)) seq += 1 if len(nal_data) <= 1400: # 单包模式:RTP header 直接拼接完整 NAL build_and_send(nal_data) else: # FU-A 分片模式:切掉原 NAL 头,按 1400 字节切 payload nri = nal_data[0] & 0x60 fu_indicator = nri | 28 # 28 = FU-A 分片类型 nalu_type = nal_data[0] & 0x1F is_first = True for start in range(1, len(nal_data), 1400): chunk = nal_data[start:start + 1400] if is_first: fu_header = 0x80 | nalu_type # S=1,分片起始 is_first = False elif start + 1400 >= len(nal_data): fu_header = 0x40 | nalu_type # E=1,分片结束 else: fu_header = 0x00 | nalu_type # S=0 且 E=0 build_and_send(bytes([fu_indicator, fu_header]) + chunk) return seq

逻辑说明:单包时 RTP header 直接拼接 NAL 单元;分片时每片的 payload 由两个头字节加数据块组成。第一个头字节fu_indicator保留了原 NAL 的 NRI 位并把类型改成 28;第二个头字节fu_header通过 S 位和 E 位标记分片位置。Sink 端重组时就是反向操作,把这两个头字节还原成原始 NAL 头。

参数说明:RTP payload type 固定用 96,这是 WFD 协商后的动态 PT 值;seq & 0xFFFF是因为 RTP 序列号只有 16 位,回绕是正常的,接收端要用差值判断丢包而不是直接比较大小。timestamp 用 90kHz 时钟是因为 H.264 的采样频率就是 90kHz,和音频的 AAC 48kHz 不是一个基准,混用会导致音画不同步。

3.3 码率控制:先固定码率跑通,再上动态调整

Source 端在实际工程里不能一直全码率推,尤其是车机这类移动场景。资源里给了两套参数参考:静态码率固定 8Mbps 推 1080p30,动态码率则根据 RTP 丢包率降档。我一般会先用固定码率跑通整个链路,再切换到动态控制。动态控制的常见做法是 Source 定时解析 Sink 发来的 RTCP Receiver Report,丢包率超过 1% 就把编码器码率往下调一档(比如 8Mbps 到 4Mbps 再到 2Mbps),丢包率回落后再升档。这部分代码在资源里没有展开,但 RTCP RR 的解析逻辑和 RTP 解析同构,照着 Sink 端的解包代码改一版就能用。

4. Sink 端代码:从监听 7236 到解码渲染

4.1 RTSP 服务器:状态机式的响应

Sink 端代码的核心是监听 socket 加请求分发。WFD 规定 Sink 以 server 身份等待 Source 的 RTSP 请求。资源里的 Sink 代码维护了一个简单的状态机:IDLE → CAPABILITY → READY → PLAYING,每个状态只处理合法的请求序列:

def handle_rtsp(client_sock): state = "IDLE" while True: req = recv_response(client_sock) # 复用上一节的 recv_response method = req.split(" ")[0] cseq = 1 for line in req.split("\r\n"): if line.lower().startswith("cseq:"): cseq = int(line.split(":")[1].strip()) if method == "OPTIONS": # 返回支持的方法,注意必须包含 GET_PARAMETER 和 SET_PARAMETER client_sock.send((f"RTSP/1.0 200 OK\r\n" f"CSeq: {cseq}\r\n" f"Public: GET_PARAMETER, SET_PARAMETER, SETUP, PLAY, TEARDOWN\r\n\r\n").encode()) elif method == "GET_PARAMETER" and state == "IDLE": # M1 能力查询:如实返回 Sink 能解码的格式 body = ("wfd_video_formats: 00 0000 0001 00000101 00000000 00000000 00000000 00 0000 0000\r\n" "wfd_audio_codecs: LPCM 00000003 00\r\n" "wfd_content_protection: 0\r\n") client_sock.send((f"RTSP/1.0 200 OK\r\n" f"CSeq: {cseq}\r\n" f"Content-Type: text/parameters\r\n\r\n" + body).encode()) state = "CAPABILITY" elif method == "SET_PARAMETER": # M3:解析 body 里的 wfd_trigger_method,确认 Source 要进入 SETUP if "wfd_trigger_method: SETUP" in req: state = "READY" client_sock.send((f"RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n\r\n").encode()) elif method == "SETUP": # M4:声明 Sink 的 RTP 接收端口,source 后续往这个端口发数据 client_sock.send((f"RTSP/1.0 200 OK\r\n" f"CSeq: {cseq}\r\n" f"Transport: RTP/AVP/UDP;unicast;client_port=50004-50005;server_port=50000-50001\r\n\r\n").encode()) elif method == "PLAY": # M5:状态切到 PLAYING,接收线程正式处理 RTP 数据 client_sock.send((f"RTSP/1.0 200 OK\r\nCSeq: {cseq}\r\n\r\n").encode()) state = "PLAYING"

逻辑说明:这个状态机把 WFD 的五个关键请求串起来了。注意 GET_PARAMETER 不一定只在 IDLE 状态出现,WFD 的 keep-alive(M16)在 PLAYING 状态也会收到 GET_PARAMETER,Sink 必须照常回 200 OK,否则 Source 超时后直接 TEARDOWN。资源里在 PLAYING 状态同样处理 GET_PARAMETER,这个细节新手特别容易漏。

参数说明:wfd_video_formats这行必须和 Sink 解码器实际能力一致。00000101对应 1080p60 的 H.264 High Profile,如果 Sink 芯片只能硬解到 1080p30 或 Baseline,这里就要如实降档,否则会出现下一节要说的「协商成功但黑屏」问题。

4.2 UDP 接收与 RTP 解包:重组 FU-A 分片

Sink 在收到 SETUP 之后要立刻把 UDP socket 绑定到server_port(示例里的 50000)。接收线程循环 recvfrom,解析 RTP 头里的 payload type 和序列号,把分片拼回完整 NAL 再喂解码器:

import struct def rtp_receiver(sock, on_nal): nal_buffer = b"" last_seq = None while True: data, addr = sock.recvfrom(2048) if len(data) < 12: continue seq = struct.unpack("!H", data[2:4])[0] pt = data[1] & 0x7F payload = data[12:] # 只有 PT=96 的 RTP 包才是视频流 if pt != 96: continue # FU-A 分片判断:payload 第一个字节低 5 位等于 28 if payload[0] & 0x1F == 28: fu_header = payload[1] chunk = payload[2:] if fu_header & 0x80: # S=1:起始分片,把 NRI 和 NAL 类型拼回头部 nri = payload[0] & 0x60 nal_type = fu_header & 0x1F nal_buffer = bytes([nri | nal_type]) nal_buffer += chunk if fu_header & 0x40: # E=1:结束分片,一个完整 NAL 收齐 on_nal(nal_buffer) nal_buffer = b"" else: # 非分片模式:完整 NAL 直接交给解码器 on_nal(payload) # 丢包检测:相邻 seq 差大于 1 说明有 RTP 丢包 if last_seq is not None: gap = (seq - last_seq) & 0xFFFF if gap > 1: print(f"rtp lost: {gap-1} packets") last_seq = seq

逻辑说明:这段代码的核心是判断 RTP payload 第一个字节的低 5 位,28 就是 FU-A 分片类型。分片组装的要点是起始分片时把原 NAL 的 NRI 位和 FU header 里的类型拼回第一个字节,中间只做字节拼接,结束分片才交给解码器。丢包检测用相邻序列号的差值,注意这里(seq - last_seq) & 0xFFFF是处理 16 位回绕的标准写法,否则序列号翻到 0 的时候会误报上千个丢包。

参数说明:on_nal是回调函数,收到完整 NAL 后解码器才能工作。这个回调里要判断 NAL 类型:type 7 是 SPS,type 8 是 PPS,type 5 是 IDR 帧。解码器初始化必须等 SPS/PPS 就位,第一次拿到 IDR 之前不要急着渲染画面,否则会出绿屏或黑屏。

4.3 解码渲染选型与音视频同步

Sink 端的最终动作是喂解码器。资源里的示例用 FFmpeg 库做软解,实际产品更常用硬件解码(板子的 VPU、GPU 解码)。不管哪种,H.264 的 SPS/PPS 要优先处理。WFD 的 RTP 流里 SPS/PPS 通常由 Source 通过 STAP-A 分片发送,Sink 端解码器初始化必须等 SPS/PPS 就位。

音视频同步是另一块硬骨头。Sink 端一般以音频时钟为基准,视频帧按 PTS 对齐。资源里的 demo 用了简单方案:收到一个视频帧就渲染,音频独立线程播放,这个方案在无网抖时能接受;实际量产得用音视频 PTS 差值做丢帧或追帧,视频落后就丢帧,视频超前就等。这部分资源里没有给完整代码,只给了思路,因为不同平台的渲染接口差异太大了。

5. 避坑记录:5 个投屏项目里最常见的翻车点

5.1 RTSP 握手成功但 Sink 收不到任何 RTP 数据

现象:M1 到 M5 全部 200 OK,但 Sink 的 UDP 端口 recvfrom 一直阻塞,日志里没有任何 RTP 包。

原因:Source 误把 7236 当成 RTP 接收端口发数据。WFD 的时序是 SETUP 响应里的server_port决定 RTP 接收端口,而不是 RTSP 端口号。把 TCP 控制端口和 UDP 数据端口搞混了。

解决:在 Source 代码里显式解析 SETUP 响应中的server_port=50000-50001,用 50000 作为发送目标端口。排错时在 Sink 侧执行ss -ulpn | grep 50000确认端口实际在监听,然后 tcpdump 抓 UDP 50000 端口看有没有包到达。

5.2 1080p 协商成功但画面黑屏

现象:wfd_video_formats 里 Source 选的是 1080p60,Sink 返回 200 OK,PLAY 也 200,但画面完全黑屏,音频却正常。

原因:Sink 侧解码器不支持 High profile 或解码能力不足,但能力集里却把不支持的组合也写上去了。Source 按它填的最高档选了格式,协商通过了,解码器却解不动。

解决:先让 Sink 能力集如实上报,去掉它解不动的组合;或者调试时 Source 端手动降到 Sink 的 native 格式。检查 Sink 解码器 profile:Baseline 能跑 1080p30,High profile 就翻车的情况非常常见,尤其是低端盒子。

5.3 投屏 1 分钟后卡顿严重

现象:前几十秒正常,之后画面断续,RTP 丢包率直线上升,音画不同步越来越明显。

原因:Source 一直在用固定高码率推流,WiFi 信道拥塞后没有降速机制。RTCP 里 Sink 每次都报了丢包反馈,但 Source 没去读,或者压根没实现 RTCP 解析。

解决:Source 端加一个 RTCP RR 解析线程,丢包率超过 1% 时按档位降码率(比如 8Mbps 到 4Mbps 再到 2Mbps),丢包率回落后再升档。不处理 RTCP 反馈的投屏方案,在真机上迟早翻车。这一步属于「不调不知道,一调吓一跳」:同一房间多几台设备,固定码率方案立刻穿帮。

5.4 PLAY 之后立刻断开

现象:M5 PLAY 返回 200 OK,画面刚出来几秒,Sink 主动关闭连接或 Source 判定链路断开,重新走配对。

原因:WFD 要求 Source 每 30 秒发一次 GET_PARAMETER 作为 keep-alive(M16)。如果 Sink 在 PLAYING 状态没有响应该请求,Source 超时后就会 TEARDOWN。很多实现把 GET_PARAMETER 只放在 IDLE 状态处理,PLAY 之后就漏了。

解决:Sink 的状态机里,GET_PARAMETER 在所有状态都要返回 200 OK,不能只在 IDLE 状态处理。调试时在 Sink 侧加一条日志,打印每个请求的 CSeq 和当前状态,就能看出 keep-alive 有没有被忽略。

5.5 音频正常但视频绿屏

现象:声音播放正常,但画面是绿色条纹或马赛克,偶尔能闪出正确的帧。

原因:RTP 的 H.264 分片在重组时,NAL 头和 FU 头拼错了。最常见的是把 FU indicator 和 FU header 两个字节顺序弄反,或者把 NRI 位丢了。

解决:对照 WFD 规范的 FU-A 格式逐字节检查。起始分片的第一个字节应该是 NRI(原 NAL 的第 0 字节高三位)加类型 28;第二个字节是 S=1 加原 NAL 类型。用抓包工具看第一个 RTP 分片的两个头字节,和原 H.264 码流对比能很快定位。这类问题有个规律:如果全部帧都绿,基本是重组头字节错了;如果只是偶发绿闪,多半是解包时序竞争。

6. 验证投屏链路的好习惯:先用日志和抓包确认每一步

调试 Miracast 这事儿,最忌两眼一抹黑直接改代码。我自己的习惯是先在 Sink 侧打全链路日志:RTSP 每个请求一行、RTP 每 500 包统计一次,先确认会话层和传输层都正常,再去动编解码。这个顺序不能反,因为 70% 的问题都不在编解码,而在握手的某个隐性时序。

第一个小技巧:给 RTSP 交互加个简易日志。Source 和 Sink 两端各打印每个请求的 CSeq、method、响应状态码。拿两端日志一对比,就能看出是哪一端没按协议走。之前遇到一个诡异问题:M3 偶尔返回 403,但 Sink 侧没有任何异常日志,后来加了 body 内容打印才发现,Source 发的 wfd_video_formats 带了个换行符,把协议体截断了。这种问题不抓日志根本想不到,看着黑匣子调了两天。

第二个技巧:抓包用 tcpdump 只抓 RTSP 和 RTP:

tcpdump -i wlan0 -s 0 -w miracast.pcap 'tcp port 7236 or udp portrange 50000-50001'

导出后用 Wireshark 打开,RTSP 那几条交互一目了然,RTP 的丢包率可以直接看 Sequence 跳变,响应码能直接看出 M1 到 M5 哪一步失败。注意-s 0必须加,默认 snaplen 只有 96 字节,会把 H.264 载荷切掉,Wireshark 就解析不出 FU-A 分片了,调试价值直接打对折。

第三个技巧也是我最想安利的:源码层面加一条「最小验证路径」。投屏这种多模块联动的功能,问题经常藏在编码、封装、传输、解封装、解码、渲染六段链路的交接处。所以调试时要主动制造一个压缩路径:Source 端先不接摄像头,用一个本地 H.264 文件直接当 RTP payload 发;Sink 端先不接显示器,只打印收到的 NAL 类型和解码器是否拿到 SPS。这样能把「链路不通」和「内容不对」两个问题彻底分开。

之前某次帮同事调车机 Sink,现象是偶尔能出画、偶尔黑屏,抓包确认 RTP 一直正常,解码器也拿到 SPS/PPS,最后定位到是渲染线程的 Surface 被系统在后台回收了。这种问题应用层根本查不出来,只能靠日志里 Surface 状态的连续变化才能看到端倪(1-2 两秒断一次)。从那以后,我调试这类链路强制自己先确认控制面、数据面、解码、渲染四段各自的日志都正常,才允许去动参数。按层排查,投屏问题基本都能在两小时内找到根因。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询