零解码与渲染(一):从协议握手到数据抵达
这一期是回忆起前几个月做过的一次基于 RK3568 开发板的音视频开发记录。
RTMP Hit
我们准备以 RTMP 流为例子。
RTMP(Real Time Messaging Protocol) 是由 Adobe 公司基于 Flash Player 播放器对应的音视频 flv 封装格式提出的一种,基于 TCP 的数据传输协议。本身具有稳定、兼容性强、高穿透的特点。常被应用于流媒体直播、点播等场景。常用于推推流方(主播)的稳定传输需求。[1]
RTMP:流媒体协议结构

RTMP 是长连接 + 分块复用的协议。一个 TCP 连接上同时跑着握手、控制消息、音频、视频,媒体数据被切成 chunk 交错发送。
以网卡的角色来看,网络连接传过来的并不是帧片,而是连绵不绝的数据流。
-
基础数据头(Basic Header):保存 CS ID、Chunk Type(决定 Msg Header 类型)
-
消息数据头(Message Header):包含被发送消息的相关信息,类型 Chunk Type 决定
-
扩展时间戳(Extended Timestamp)(32-bits):消息头携带的时间戳扩展位
-
数据分片(Chunk Data):包含音视频原始数据
在 Basic Header 中,Chunk StreamID 是 3~65599 这 65597 个标志中的其中一种。
根据持有 Chunk stream ID 的长度,RTMP 规格将基础数据头分为 3 种:
- ID 在 2~63 范围内的 1-Byte 版;
- ID 在 64~319 范围内的 2-Byte 版;
- ID 在 64~65599 范围内的 3-Byte 版。
其中 Chunk Stream ID 是用来区分消息类型的,由于 RTMP 协议的特殊性,基于 TCP 的通信要根据流分片 ID 来区分信道类型。
| Chunk Stream 名称 | cs id | 作用 |
|---|---|---|
| RTMP_CONTROL_CHANNEL | 0x02 | 控制流通道 |
| RTMP_COMMAND_CHANNEL | 0x03 | 命令信道 |
| RTMP_STREAM_CHANNEL | 0x05 | 数据流通道 |
| RTMP_VIDEO_CHANNEL | 0x06 | 视频信道 |
| RTMP_AUDIO_CHANNEL | 0x07 | 音频信道 |
消息数据头中的类型,由基础数据头中的 fmt 字段来标记。总共分为 4 种类型:
| Message Header Type | fmt | function |
|---|---|---|
| Type 0 (FULL) | 0x00 | Full 12-Byte RTMP chunk header (absolute timestamp) |
| Type 1 (Relative Large) | 0x01 | Relative 8-Byte RTMP chunk header (message stream ID is not included, timestamp delta) |
| Type 2 (Relative Timestamp Only) | 0x02 | Relative 4-Byte RTMP chunk header (only timestamp delta) |
| Type 3 (Relative Single Byte) | 0x03 | Relative 1-Byte RTMP chunk header (no “real” header, just the 1-Byte indicating chunk header type & chunk stream ID) |

图基础源头:RTMP Specification 1.0 By Adobe
这是类型的划分。
- timestamp 消息时间戳(3-Bytes):
timestamp 标记当前消息的绝对时间戳,有效位 24 bits。
如果超出 0xFFFFFF 则启用扩展时间戳(Extended Timestamp)。
扩展位启用时,timestamp 位恒定为 16777215,通过还原 32 bits 的扩展位,加合为有效时间戳数据。
时间戳在运用上对于不同消息类型会有区分,type 0 时为绝对时间戳,type 1/2 时为相对时间戳(时间差值)。
- msg length 消息头长度(1-Byte):携带 Chunk Header 数据长度信息(单位:Byte)
- msg length (cont) 消息体长度(2-Bytes):携带 Chunk Data 数据长度信息(单位:Byte)
- msg type 消息类型(1-Byte):携带消息类型信息,这是实际消息的类型,区别于消息头。
- ms id 字段(1-Byte):消息归属消息流 ID 标志位,指定当前消息所属信道分类
- ms id (cont) 字段(3-Bytes):消息内容对应数据流 ID 标志位,指定数据所属数据流信道
消息头应用场景区分
| Message Header Type | 作用 |
|---|---|
| Type 0 (FULL) | 通信建立开启时,或切换到后台(标记当前 PTS 位置) |
| Type 1 (Relative Large) | 视频数据流标准 |
| Type 2 (Relative Timestamp Only) | 音频数据流标准(无传输切割,流的完整性) |
| Type 3 (Relative Single Byte) | 空数据标准,时差标准,延迟标记 |
从网络音视频流到网卡
STEP1:对端字节切片处理
推流端调用 send(),把一帧 100KB 的音视频数据交给自己的 TCP 栈。TCP 将要按 MSS 进行切片:
MTU (Maximum Transmission Unit) 是指网络接口层(如以太网)在一次传输中能承载的最大数据单元 (通常指 IP 包)的大小,单位是字节。
MSS (Maximum Segment Size) 是 TCP 协议的一个参数,它定义了在一个 TCP 报文段中,纯数据载荷的最大字节数 。关键在于,MSS 不包含TCP 头部和 IP 头部的大小。
MSS = MTU(1500) − IP 头(20) − TCP 头(20) = 1460 字节
100KB ÷ 1460 ≈ 70 个 TCP 段
STEP2:网卡接流
PHY 是 Physical Layer (物理层)的缩写,有时也特指 Port Physical Layer (端口物理层)。它是 OSI 网络模型中的第一层,也是最底层 ,直接与物理传输介质(如网线、光纤、无线电波)打交道。
FCS 是 Frame Check Sequence (帧检验序列)的缩写,它是附加在数据帧末尾的一段校验码,用于检测数据帧在传输过程中是否发生了错误 (如比特翻转)。
帧到了本机网卡,硬件完成了三步流程:
- PHY 解出比特 → MAC 层拼回帧,计算 FCS,如果 FCS 错误就丢包(计入
rx_crc_errors)。 - 过滤:目的 MAC 不是自己的(且不是广播/组播,同时没开混杂模式)就丢。
- DMA 写内存:这是关键。
网卡的职责并不是交付数据,而是填好一张表:
RX ring (通常包含 1024 ~ 4096 个描述符)
+----------+----------+----------+----------+
| desc[0] | desc[1] | desc[2] | ... |
+----------+----------+----------+----------+
| buf_addr | buf_addr | buf_addr | |
| length | length | length | |
| status | status | status | |
+----------+----------+----------+----------+
^
| 驱动预先分配 2KB 的 buffer,物理地址填进去.
RX ring (接收环)是网卡 (NIC)与操作系统内核之间用于接收数据包的一个环形缓冲区 。它是硬件(网卡)和软件(驱动/内核)协同工作的核心数据结构,目的是让高速到达的数据包能被高效、连续地处理,而不至于让 CPU 疲于奔命。
在这之后:
收包时,网卡硬件:
- 按
head指针找到下一个描述符; - 把整帧 DMA 写入该描述符指向的内存;
- 回写状态位(长度、校验结果),推进
head; - 触发中断(或往 event queue 写一条记录),告诉 CPU “货到了你快点来”。
接下来的路程
网卡 DMA 写入 RX ring
│ 硬中断
▼
NAPI poll(软中断 NET_RX_SOFTIRQ,一次最多处理 64 个包)
│
▼
GRO(把同一条流里连续的段合并成一个大的 skb,最大 64KB)
│
▼
netif_receive_skb → ip_rcv(校验、路由)
▼
tcp_v4_rcv(用四元组在 ehash 里找到对应的 socket)
▼
tcp_data_queue(按 seq 归位;乱序的先进 ofo 队列,等缺口补上再上移)
▼
sk_receive_queue ← 数据最终挂在这里,等应用来读
- 网卡处理数据完毕的时候触发硬中断,CPU 进入网卡驱动中断处理函数。
NAPI poll 是一个由网卡驱动注册的函数 ,负责批量处理接收到的数据包** ;而 NET_RX_SOFTIRQ 是一个软中断编号,硬中断处理程序会通过设置一个标志位来“触发”它。
NAPI 获取到数据包,把数据包构造成 sk_buff 准备向上递交给内核网络协议栈。
sk_buff 的格式
skbuff 并不是直接管理,而是通过指针来管理一个独立的内存区域,这个区域就是 head buffer。它的布局是理解 sk_buff 的基础:
+---------------------------------------------------+
| head buffer |
+----------+----------------------+-----------------+
| headroom | data (线性区) | tailroom | skb_shared_info |
+----------+----------------------+-----------------+
^ ^ ^ ^
| | | |
head data tail end
-
head:指向整个 headbuffer 的起始地址。 -
data:指向当前数据包有效载荷(或协议头)的起始位置。这个指针会在数据包穿过协议栈时移动(例如,skb_pull() 会使 data指针后移,剥离头部)。 -
tail:指向当前数据包有效载荷的结束位置的下一个字节。 -
end:指向 head buffer 的末尾,即 skb_shared_info结构的起始位置。
后面的就不多说了,是 IP 以及 TCP 层的部分。
IP 层
ip_rcv():校验、netfilter、路由决策 → 本机 TCP 则继续
TCP 层
tcp_v4_rcv():四元组在 ehash 找 socket
→ tcp_data_queue():按 seq 归位,乱序进 ofo 队列
→ 按序数据挂到 sk_receive_queue
在这之后,应用程序可以调用 read()/recv() 来获取数据。
FFmpeg 内部:从 avformat_open_input 走到 recv()
FFmpeg 在调用 avformat_open_input 会建立一个可随时读字节的 AVIOContext
avformat_open_input(&fmt, "rtmp://host/app/stream", NULL, &opts)
// libavformat/demux.c(7.x;6.x 及以前在 utils.c)
│
├─ init_input(fmt, url, &tmp)
│ │
│ ├─ fmt->pb = io_open_default(...) // 建 AVIOContext
│ │ └─ ffio_open_whitelist() // libavformat/avio.c
│ │ └─ ffurl_open_whitelist(&h, url, ...)
│ │ ├─ ffurl_alloc() → url_find_protocol() // 按 "rtmp:" 前缀匹配到 ff_rtmp_protocol
│ │ ├─ ffurl_connect() → rtmp_open() // libavformat/rtmpproto.c
│ │ │ ├─ ffurl_open_whitelist(&rt->stream, "tcp://host:1935/...")
│ │ │ │ └─ tcp_open() → ff_listen_connect() → connect() ★ 系统调用
│ │ │ ├─ rtmp_handshake() // C0/C1/C2,读写全走 ffurl_read/ffurl_write
│ │ │ └─ gen_connect() / gen_create_stream() / gen_play() // AMF0 命令
│ │ └─ ffio_fdopen(&fmt->pb, h) // 把 URLContext 包成 AVIOContext
│ │
│ ├─ av_probe_input_buffer2() // 读几百字节,试探是 flv 还是 mpegts
│ └─ fmt->iformat = flv // flv_probe 命中
│
└─ fmt->iformat->read_header() → flv_read_header()
这个看起来还是密密麻麻的,不如拆开来看。
-
init_input,从参数里获得格式、源 url。 -
fmt->pb = io_open_default 建立
AVIOContext来做持续读取。 -
ffio_open_whitelist()—— 创建 AVIOContext(I/O 抽象层)- 白名单安全检查:带有 whitelist 因为 FFmpeg 有严格的安全机制。
- 如果你传入的 URL 包含危险协议(如恶意构造的 file:///etc/passwd 或 concat:),它会被这里的白名单拦截,防止安全漏洞。
-
向下调用:内部会调用
ffurl_open_whitelist()来把网络拉起来。 -
ffurl_alloc()→url_find_protocol()(查表与分配):- 当传入
"rtmp://host/app/stream" 时,url_find_protocol() 会提取出前缀 rtmp:。 - 接着,它在 FFmpeg 全局注册的协议列表(
url_protocols 数组)中从头到尾进行匹配,找到名字为 "rtmp" 的对应结构体——也就是 ff_rtmp_protocol。 - 在找到后,
ffurl_alloc() 会在内存中分配一个 URLContext(上图变量 h),并把这个 URLContext 的内部指针 h->prot 指向刚刚找到的 ff_rtmp_protocol。
- 当传入
-
ffurl_connect()→rtmp_open()(触发连接)- 开始解析 URL 里的域名、端口、app 名字。
- 底层网络套娃:在 RTMP 协议内部,它又会调用
ffurl_open_whitelist(),传入 "tcp://host:1935"。这就回到了上一个问题的逻辑,再次触发查表,匹配到 TCP 协议,最终调用系统的 connect()建立底层 Socket。 - 握手与信令:Socket 通之后,执行 RTMP 的 C0/C1/C2 握手;握手成功,再发 AMF0 的
connect 命令(携带 app 名字等);服务端响应后,再发 createStream 和 play命令。 - 直到这一切搞定,
rtmp_open()才算返回成功。
-
rtmp_handshake():处理协议层握手。
后面就不过多叙述,因为本部分只针对处理 FFmpeg 从链接调用到读取的过程
协议对接
我们说过了 FFmpeg 从 url 获得帧数据的一个全流程。
但是如何交互呢?
RTMP 和 HTTPS 的交互模型完全不同。HTTPS(HTTP)是无状态的文本/二进制请求-响应模型 ;而 RTMP 是基于 TCP 的有状态长连接、双向实时消息协议 。
-
RTMP 握手阶段(Handshake)—— 并非 HTTP 请求
这部分仅仅完成 RTMP 协议层的握手交流
-
发送“命令消息”(Command Message)—— 这才是 RTMP 的“请求”
握手成功后,FFmpeg 会通过 TCP 发送 AMF0 格式(Action Message Format) 的命令消息。
-
connect:告诉服务器应用名(app),相当于打个招呼:“我要连你的应用”。- 服务器会返回
_result ,包含流媒体服务器的基本信息和会话 ID。
- 服务器会返回
-
createStream:告诉服务器:“给我分配一个流通道”。- 服务器返回
_result ,附带一个 Stream ID(比如 1)。
- 服务器返回
-
play:发送播放命令,带上流名字(如 stream)和 Stream ID。- 服务器如果没有流,可能返回
NetStream.Play.StreamNotFound ;如果有流,就开始下发音视频数据。
- 服务器如果没有流,可能返回
-
-
命令发完,服务器就开始源源不断地推送 音视频数据(Audio/Video Message) 。
- 这些消息会被切分成很小的 Chunk(块) ,加上 Header 后再通过 TCP 发出来(这就是前文协议层所分析的 Type 0 / Type 1 / Type 2 / Type 3)。
- RTMP 是一个持续不断的数据流,不需要像 HTTP 那样每次都发“请求”去要下一段数据。
所以到这里,我们的音视频数据就到手了。
(https://zhuanlan.zhihu.com/p/191542130) “知乎:流媒体:RTMP协议完全解析” ↩︎