茜莉娅 Serelia
← Back to the beginning

零解码与渲染(一):从协议握手到数据抵达

技术笔记

45 millimeter rolling like we lost time

音视频cppFFmpeg

零解码与渲染(一):从协议握手到数据抵达

这一期是回忆起前几个月做过的一次基于 RK3568 开发板的音视频开发记录。

‍

RTMP Hit

我们准备以 RTMP 流为例子。

RTMP(Real Time Messaging Protocol) 是由 ​Adobe​ 公司基于 ​Flash Player​ 播放器对应的音视频 ​flv ​封装格式提出的一种,基于​ TCP ​的数据传输协议。本身具有稳定、兼容性强、高穿透的特点。常被应用于流媒体直播、点播等场景。常用于推推流方(主播)的稳定传输需求。[1]

RTMP:流媒体协议结构

image

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)

image

图基础源头: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​ (帧检验序列)的缩写,它是附加在数据帧末尾的一段校验码,用于​检测数据帧在传输过程中是否发生了错误​ (如比特翻转)。

帧到了本机网卡,硬件完成了三步流程:

  1. PHY 解出比特 → MAC 层拼回帧,计算 FCS,如果 FCS 错误就丢包(计入 ​rx_crc_errors)。
  2. 过滤:目的 MAC 不是自己的(且不是广播/组播,同时没开混杂模式)就丢。
  3. 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​​:指向整个 ​head buffer 的起始地址。
  • ​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 的​有状态长连接、双向实时消息协议​ 。

  1. RTMP 握手阶段(Handshake)—— 并非 HTTP 请求

    这部分仅仅完成 RTMP 协议层的握手交流

  2. 发送“命令消息”(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​​ ;如果有流,就开始下发音视频数据。
  3. 命令发完,服务器就开始源源不断地推送 ​音视频数据(Audio/Video Message) 。

    • 这些消息会被切分成很小的 Chunk(块) ,加上 Header 后再通过 TCP 发出来(这就是前文协议层所分析的 Type 0 / Type 1 / Type 2 / Type 3)。
    • RTMP 是一个持续不断的数据流,不需要像 HTTP 那样每次都发“请求”去要下一段数据。

所以到这里,我们的音视频数据就到手了。

‍


  1. (https://zhuanlan.zhihu.com/p/191542130) “知乎:流媒体:RTMP协议完全解析” ↩︎

© 泠時月 2026,采用 CC BY 4.0 许可,转载保留署名。

留言 · 0 段对话

© 2026 茜莉娅 · Powered by Serelia 茜莉娅 88x31 徽章

扫码分享

二维码