Skip to content

第 5 章:线协议——三条 socket 上究竟发送什么

scrcpy 没有引入 Protobuf、WebRTC 或容器协议。它使用固定大端字段、长度前缀与少量位标志,把协议开销压到最低,也把版本同步责任留给源码本身。

一、协议总览

通道方向framing主要内容
video设备 → 桌面4 字节 codec id + 12 字节 session/packet 头H.264/H.265/AV1 压缩包
audio设备 → 桌面4 字节 codec id + 12 字节 packet 头Opus/AAC/FLAC/PCM
control双向1 字节 type + 类型特定字段输入、剪贴板、UHID、设备 ACK

在这些流之前,第一条启用的 socket 还可能承担 dummy byte 和固定长度设备名。它们属于连接握手,不属于媒体帧。

二、字节序和字符串规则

多字节数值统一按 big-endian 写入。Java 的 DataInputStream/DataOutputStream 默认就是大端;C 端则用 sc_write16be/sc_read32be 等辅助函数显式转换。

字符串不是 NUL 结尾,而是:

text
[length: 1/2/4 bytes][UTF-8 bytes...]

长度字段宽度由消息类型决定。文本通常使用 4 字节长度;UHID 名称和 app 名用 1 字节;HID report data 用 2 字节。这样常见小消息不必支付固定 4 字节长度,但 schema 必须预先知道类型。

三、媒体流的开场:codec id

每条媒体流首先写 4 字节 codec id。它不是 FFmpeg enum,而是可读 ASCII 压成整数:

字节/数值codec
h264H.264
h265H.265
av1AV1
vp8 / vp9VP8 / VP9
opus / aac / flac压缩音频
rawPCM S16LE
0设备主动禁用该流,可降级
1设备配置错误,必须失败

C 端映射见 sc_demuxer_to_avcodec_id(),Java 端写入见 Streamer.writeVideoHeader()

用 0/1 复用 codec id 位置传递失败语义非常紧凑:客户端甚至还没创建解码器就能区分“缺音频继续”与“配置非法终止”。

四、12 字节头的双重身份

视频流中的 12 字节块可能是 session header,也可能是 media packet header,由最高位区分。

4.1 Session header

text
byte 0..3   flags: bit31=session, bit0=client_resized
byte 4..7   width  (u32 BE)
byte 8..11  height (u32 BE)
payload     无

它只出现在视频流,用于首个尺寸和后续旋转/resize 会话。client_resized 告诉桌面窗口:这次尺寸变化是用户主动调整虚拟显示导致的,不要再自动改窗口形成反馈循环。

4.2 Media packet header

text
byte 0..7   pts_and_flags (u64 BE)
             bit62 = codec config
             bit61 = key frame
             low 61 bits = PTS in microseconds
byte 8..11  payload length (u32 BE)
payload     MediaCodec 输出字节

服务端打包见 Streamer.writeFrameMeta(),客户端解析见 demuxer.c

为什么 config 包没有普通 PTS

编码器输出的 SPS/PPS、Opus header 等 codec-specific data 不是可播放帧。C 端把 config 标志映射成 AV_NOPTS_VALUE,统一用 FFmpeg 的“无时间戳”语义表示。H.264/H.265 解码前,packet_merger 还会把 config 数据前置到下一媒体包,兼容期望 Annex B 连续数据的下游。

五、动态会话而不是重建连接

旋转或虚拟显示 resize 会导致编码器重置和尺寸改变。scrcpy 不关闭 video socket,也不新建 decoder 管线,而是在同一字节流中插入新的 session header:

旧 session packets → session(width=1920,height=1080) → 新 config packet → 新尺寸 media packets

demuxer 遇到 session header 就调用所有 packet sink 的 push_session;decoder 更新自己的 session,再继续向 frame sink 传播。连接保持,订阅关系也保持。

六、桌面到设备:23 类控制消息

控制消息总是以 1 字节 type 开始。类型值 0–22 在 C 与 Java 中逐一对齐,见 control_msg.hControlMessage.java

几个代表布局:

消息type 后字段设计点
inject keycodeaction:u8, keycode:u32, repeat:u32, meta:u32固定 14 字节
inject textlen:u32, utf8最大 300 字节
touchaction:u8, pointer:u64, x/y:i32, screen w/h:u16, pressure:u16, buttons坐标携带源尺寸
scrollposition, h/v:i16 fixed-point, buttons:u32压缩浮点范围
set clipboardsequence:u64, paste:u8, len:u32, utf8支持异步 ACK
UHID createid/vendor/product:u16, name:u8+bytes, report:u16+bytes在设备端创建虚拟硬件
resize displaywidth:u16, height:u16最新值覆盖旧请求

完整 C 序列化器在 control_msg.c,Java 解析器在 ControlMessageReader.java

七、设备到桌面:三类反向消息

反向方向没有复用上述 23 类,而定义独立 enum:

type消息字段
0clipboardlen:u32 + UTF-8
1clipboard ACKsequence:u64
2UHID outputid:u16 + len:u16 + bytes

Java writer 每写一条就 flush,见 DeviceMessageWriter。低吞吐控制通道宁可多一次 flush,也不等待缓冲攒满。

C 端 receiver 面对 TCP 粘包/拆包,维护一个 256 KiB 缓冲:反序列化器返回“消费字节数、0 表示消息不完整、-1 表示非法”;处理完后把剩余字节前移,见 receiver.c

八、协议上限既是资源边界,也是安全边界

两方向控制消息最大均为 256 KiB:

  • inject text 进一步限制为 300 字节;
  • clipboard 允许接近完整 256 KiB;
  • scan path 限 256 字节;
  • UHID report 受 16 位长度和 HID 内部上限约束。

长度在发送前按 UTF-8 边界截断,而不是任意切字节,避免生成无效文本。协议读端使用 readFully 或“剩余长度不足则等待”保证消息原子性。

九、协议为什么没有显式版本字段

client 和 server 总是来自同一个发行包,客户端启动命令还把版本作为首个参数传给 server。发布流程把它们视为不可分割的一对,因此协议依赖发行版本锁步演进,而不做跨版本协商。

收益是每帧零版本开销、实现简单;代价是不能承诺任意新 client 与旧 server 互操作。修改协议时必须双端、测试和版本一起提交。

十、本章小结

scrcpy 的协议是“最小可用 framing”:媒体用 codec id + 12 字节头,控制用 type + 类型字段,动态尺寸用内嵌 session 包。下一章进入媒体生产端,看 Android 如何把真实显示、相机或虚拟显示统一渲染到编码器 Surface,并在旋转与编码失败时重建会话。

基于 scrcpy 4.1 源码的独立学习笔记