之前写了两篇推流文章(RTSP 推流、RTMP 推流),这次补齐——RTMP 拉流。拉流和推流方向相反,但协议的坑一点不少。
本文按拉流的实际时序展开,每一步都给出真实的十六进制字节并逐字段中文注释。读完你能彻底搞懂:
RTMP 三次握手的字节交换过程
Chunk 分片如何重组为完整消息
AMF0 二进制编码的每条命令
AVCC → AnnexB 的转换(拉流方向)
AVCDecoderConfigurationRecord 的字段含义
全文覆盖四条主线:握手 → 信令 → Chunk 重组 → 载荷转换。
一、RTMP vs RTSP 对比
先建立全局认知:
| 维度 | RTSP | RTMP |
|---|---|---|
| 传输层 | TCP 信令 + UDP/TCP RTP | 纯 TCP |
| 握手 | 无(直接 OPTIONS) | C0/C1/C2 + S0/S1/S2 |
| 信令编码 | 文本(RTSP 头) | AMF0 二进制 |
| 载荷格式 | RTP 分片 | Chunk 分片 |
| H.264 格式 | AnnexB | AVCC |
| config 传法 | SDPsprop-parameter-sets | 独立 Sequence Header 消息 |
| 端口 | 554 | 1935 |
| 拉流信令 | OPTIONS → DESCRIBE → SETUP → PLAY | connect → createStream → play |
核心洞察:
RTSP 靠"文本协议 + RTP 分片",RTMP 靠"二进制协议 + Chunk 分片"。两者对 H.264 的表示方式完全不同,这是所有坑的根源。
二、RTMP 四层模型
┌──────────────────────────────────┐ │ Application Layer │ connect / createStream / play │ (AMF0 命令消息) │ ├──────────────────────────────────┤ │ Message Layer │ typeId + length + streamId + body ├──────────────────────────────────┤ │ Chunk Layer │ ★ 把 Message 切成固定大小 Chunk ├──────────────────────────────────┤ │ Transport Layer │ 纯 TCP └──────────────────────────────────┘
四个关键概念:
| 概念 | 含义 |
|---|---|
| Message | 一条完整逻辑消息(一个视频帧 / 一个命令) |
| Chunk | Message 的分片,每个最多chunkSize字节 |
| Chunk Stream | 用CSID区分多条逻辑通道 |
| Message Stream | 用MSID区分同一连接的多个流 |
为什么要分 Chunk?TCP 是字节流,一次写太大会阻塞低优先级消息。分片后可以交错发送不同 Chunk Stream 的数据。
默认 chunkSize = 128,握手后双方可用SetChunkSize消息修改。
三、★ Chunk 结构详解
每个 Chunk 三部分:
┌──────────────────┬──────────────────┬─────────────┐ │ Basic Header │ Message Header │ Chunk Data │ │ (1~3 字节) │ (0/3/7/11 字节) │ (≤ chunkSize)│ └──────────────────┴──────────────────┴─────────────┘
3.1 Basic Header(1~3 字节)
第 1 字节 bit 布局:
0 1 2 3 4 5 6 7 +-+-+-+-+-+-+-+-+ | fmt | csid | +-+-+-+-+-+-+-+-+ 2bit 6bit
fmt(2 bit):Message Header 类型,0~3
csid(6 bit):Chunk Stream ID,1~63
csid 三种编码:
| csid 值 | 字节数 | 编码 |
|---|---|---|
| 2~63 | 1 | 直接写在低 6 bit |
| 64~319 | 2 | 低 6 bit 填 0,第 2 字节 = csid-64 |
| 320~65599 | 3 | 低 6 bit 填 1,第 2/3 字节 = (csid-64) 小端 |
中文示例:
csid = 5 → 1 字节: 0x05 csid = 63 → 1 字节: 0x3F csid = 64 → 2 字节: 0x00 0x00 csid = 100 → 2 字节: 0x00 0x24 (100 - 64 = 36 = 0x24) csid = 320 → 3 字节: 0x01 0x00 0x01
常用 CSID:
| CSID | 用途 |
|---|---|
| 2 | 协议控制(SetChunkSize/Ack/WinSize) |
| 3 | 命令(connect/createStream/play) |
| 4 | 音频数据 |
| 5 | 视频数据 |
3.2 Message Header(0/3/7/11 字节)
fmt 决定大小:
| fmt | 大小 | 包含字段 |
|---|---|---|
| 0 | 11 | timestamp + length + typeId + streamId |
| 1 | 7 | timestamp delta + length + typeId |
| 2 | 3 | timestamp delta |
| 3 | 0 | 无(完全继承) |
fmt=0(11 字节)逐字段:
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | timestamp (3B, 大端) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | message length (3B, 大端) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | message type id (1B) | | +-+-+-+-+-+-+-+-+-+-+-+-+ + | message stream id (4B, ★小端) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 字段 | 大小 | 字节序 | 含义 |
|---|---|---|---|
| timestamp | 3 | 大端 | 绝对时间戳(毫秒),0xFFFFFF 用扩展 |
| message length | 3 | 大端 | 消息体字节数 |
| message type id | 1 | - | 8=音频, 9=视频, 20=命令 |
| message stream id | 4 | 小端 | Message Stream ID(唯一小端字段) |
Extended Timestamp(4 字节):当 timestamp ==0xFFFFFF时追加。
fmt=3 特殊规则:如果上一个 Chunk 用了扩展时间戳,本 Chunk 也必须带。
3.3 ★ 完整分片示例(重点)
假设:300 字节视频帧,chunkSize=128,csid=5,streamId=1,timestamp=1000。
分片计算:
第 1 片:128 字节
第 2 片:128 字节
第 3 片:44 字节
第 1 个 Chunk(fmt=0):
偏移 字节值 含义 ───────────────────────────────────── [0] 0x05 Basic Header: fmt=0, csid=5 [1] 0x00 timestamp 高位 [2] 0x03 timestamp 中位 [3] 0xE8 timestamp 低位 (1000 = 0x0003E8) [4] 0x00 length 高位 [5] 0x01 length 中位 [6] 0x2C length 低位 (300 = 0x00012C) [7] 0x09 type = 9 (MSG_VIDEO) [8] 0x01 streamId 小端第 1 字节 (1 = 0x00000001) [9] 0x00 streamId 第 2 字节 [10] 0x00 streamId 第 3 字节 [11] 0x00 streamId 第 4 字节 [12..139] 视频数据前 128 字节
十六进制拼接:
05 00 03 E8 00 01 2C 09 01 00 00 00 <128B 数据>
第 2 个 Chunk(fmt=3):
[0] 0xC5 Basic Header: fmt=3, csid=5 (0b11<<6|5 = 0xC5) [1..128] 视频数据第 129~256 字节
第 3 个 Chunk(fmt=3):
[0] 0xC5 Basic Header [1..44] 视频数据剩余 44 字节
核心结论:第一片带完整 11 字节头,后续片只带 1 字节 Basic Header。
四、RTMP 三次握手
4.1 握手流程
C0 (1B: 0x03) + C1 (1536B) → S0 (1B) + S1 (1536B) + S2 (1536B) C2 (1536B) → 完成
4.2 C0+C1(客户端 → 服务器)
实际字节(前 32 字节示例):
┌──────────────────────────────────────────────────────────────────┐ │ 03 ← C0: 版本号 0x03 │ ├──────────────────────────────────────────────────────────────────┤ │ 00 00 00 00 ← C1 前 4 字节:客户端时间戳(毫秒) │ │ 00 00 00 00 ← C1 第 5~8 字节:保留,必须为 0 │ │ XX XX XX XX XX XX XX XX ... ← C1 第 9~1536 字节:随机字节 │ │ ... │ └──────────────────────────────────────────────────────────────────┘
中文说明:
| 字段 | 大小 | 含义 |
|---|---|---|
| C0.version | 1B | 固定 0x03 |
| C1.timestamp | 4B | 客户端启动时间戳 |
| C1.zero | 4B | 固定 0x00000000 |
| C1.random | 1528B | 随机字节 |
⚠️ 关键:ZLMediaKit 会嗅探 C1 判断是否支持复杂握手。建议 C1 用全 0 或固定字节,避免随机数碰巧匹配:
QByteArray c1(1536, '\0'); // 全填 0 uint32_t now = QDateTime::currentMSecsSinceEpoch(); ByteOrder::set_be32(c1.data(), now); // 前 4 字节时间戳
4.3 S0+S1+S2(服务器 → 客户端)
实际字节:
03 ← S0: 版本号 0x03 00 00 00 00 ← S1 前 4 字节:服务器时间戳 00 00 00 00 ← S1 第 5~8 字节:保留零 A1 C2 3F 88 90 11 5E 7B ... ← S1 第 9~1536 字节:随机 ... ← 直到 1536 字节 <C1 的完整 1536 字节数据> ← S2: 服务器对 C1 的回显
总长度:1 + 1536 + 1536 = 3073 字节
4.4 C2(客户端 → 服务器)
简单握手:C2 = S1
<S1 的完整 1536 字节数据>
握手成功日志:
RtmpClient: handshake finished
五、AMF0 编解码
RTMP 命令消息用AMF0(Action Message Format 0)编码。
5.1 类型标记
| 值 | 名称 | 说明 |
|---|---|---|
| 0x00 | Number | 8 字节 double 大端 |
| 0x01 | Boolean | 1 字节 |
| 0x02 | String | 2 字节长度 + UTF-8 |
| 0x03 | Object | 键值对,00 00 09结尾 |
| 0x05 | Null | 无附加 |
| 0x08 | ECMA Array | 4 字节 count + 键值对 |
| 0x0A | Strict Array | 4 字节 count + 元素 |
5.2 connect 命令的中文示例
客户端发送内容:
enc.writeString("connect"); enc.writeNumber(1.0); AmfValue obj = AmfValue::makeObject(); obj.set("app", AmfValue::makeString("live")); obj.set("tcUrl", AmfValue::makeString("rtmp://127.0.0.1:1935/live")); obj.set("flashVer", AmfValue::makeString("FMLE/3.0")); obj.set("audioCodecs", AmfValue::makeNumber(0x0400)); obj.set("videoCodecs", AmfValue::makeNumber(0x0080)); enc.writeObject(obj);生成的字节流(逐字节中文注释):
02 00 07 63 6F 6E 6E 65 63 74 ← String "connect"(长度 7) 00 3F F0 00 00 00 00 00 00 ← Number 1.0(8B double 大端) 03 ← Object 开始 00 03 61 70 70 ← key "app" 02 00 04 6C 69 76 65 ← String "live" 00 05 74 63 55 72 6C ← key "tcUrl" 02 00 1D 72 74 6D 70 3A 2F 2F ... ← String "rtmp://127.0.0.1:1935/live" 00 08 66 6C 61 73 68 56 65 72 ← key "flashVer" 02 00 0C 46 4D 4C 45 2F 33 2E 30 ← String "FMLE/3.0" 00 0B 61 75 64 69 6F 43 6F 64 65 63 73 ← key "audioCodecs" 00 40 B0 00 00 00 00 00 00 ← Number 1024 (0x400 = AAC) 00 0B 76 69 64 65 6F 43 6F 64 65 63 73 ← key "videoCodecs" 00 40 80 00 00 00 00 00 00 ← Number 128 (0x80 = H.264) 00 00 09 ← 空 key + ObjectEnd
关键:
02 00 07:String 类型 + 长度 7(2 字节大端)00 3F F0 ...:Number 类型 + double 大端(1.0 = 0x3FF0000000000000)00 00 09:Object 结束标记
六、完整信令流程
6.1 时序图
客户端 服务器 │ │ │──── TCP 连接 ──────────────────────────►│ │ │ │──── C0+C1 (1+1536B) ──────────────────►│ 握手 │◄─── S0+S1+S2 (1+1536+1536B) ───────────│ │──── C2 (= S1, 1536B) ────────────────►│ │ │ │──── connect (AMF0) ──────────────────►│ 建连 │◄─── _result (Success) ─────────────────│ │◄─── Window Ack Size ──────────────────│ │◄─── Set Peer Bandwidth ───────────────│ │ │ │──── createStream (AMF0) ─────────────►│ 建流 │◄─── _result (streamId=1) ─────────────│ │ │ │──── play "test" -2000 (AMF0) ────────►│ 播放 │◄─── onStatus (Play.Reset) ────────────│ │◄─── onStatus (Play.Start) ────────────│ │◄─── onStatus (Data.Start) ────────────│ │◄─── onMetaData ───────────────────────│ │◄─── Video Sequence Header (avcC) ─────│ │◄─── Audio Sequence Header (ASC) ──────│ │ │ │◄─── Video Frame (AVCC) ───────────────│ 数据 │◄─── Audio Frame (raw AAC) ────────────│ │◄─── ... 持续 ... │
6.2 connect 命令
客户端发(Chunk 头 + AMF0 数据):
03 ← Basic Header: fmt=0, csid=3 00 00 00 ← timestamp = 0 00 00 C0 ← length = 192 (0xC0) 14 ← type = 20 (MSG_CMD) 00 00 00 00 ← streamId = 0 (小端) <上面 5.2 节的 AMF0 数据>
服务器回:
text
03 00 00 00 00 00 B8 14 00 00 00 00 02 00 07 5F 72 65 73 75 6C 74 ← "_result" 00 3F F0 00 00 00 00 00 00 ← txn = 1 05 ← Null 03 ... 00 00 09 ← 服务器信息 03 ... 00 00 09 ← 结果信息 level = "status" code = "NetConnection.Connect.Success" ← ★ 成功标志
6.3 createStream 命令
客户端发:
03 00 00 00 00 00 19 14 00 00 00 00 02 00 0C 63 72 65 61 74 65 53 74 72 65 61 6D ← "createStream" 00 40 00 00 00 00 00 00 00 ← txn = 2 05 ← Null
服务器回:
03 00 00 00 00 00 15 14 00 00 00 00 02 00 07 "_result" 00 40 00 00 00 00 00 00 00 ← txn = 2 05 ← Null 00 3F F0 00 00 00 00 00 00 ← ★ streamId = 1
6.4 play 命令
客户端发:
03 00 00 00 00 00 20 14 01 00 00 00 ← ★ streamId = 1 (小端) 02 00 04 70 6C 61 79 ← "play" 00 40 08 00 00 00 00 00 00 ← txn = 3 05 ← Null 02 00 04 74 65 73 74 ← String "test" 00 C0 9F 40 00 00 00 00 00 ← Number -2000.0(负数=live)
6.5 服务器响应:三个 onStatus
02 00 08 6F 6E 53 74 61 74 75 73 ← "onStatus" 00 00 00 00 00 00 00 00 00 ← txn = 0 05 ← Null 03 ... 00 00 09 ← Object level = "status" code = "NetStream.Play.Start" ← ★ 播放开始
七、媒体描述信息
7.1 onMetaData 示例
03 00 00 00 00 01 06 12 00 00 00 00 02 00 0D 40 73 65 74 44 61 74 61 46 72 61 6D 65 ← "@setDataFrame" 02 00 0A 6F 6E 4D 65 74 61 44 61 74 61 ← "onMetaData" 08 ← ECMA Array 00 00 00 07 ← count = 7 00 08 64 75 72 61 74 69 6F 6E ← key "duration" 00 00 00 00 00 00 00 00 ← Number 0(直播) 00 05 77 69 64 74 68 ← key "width" 00 40 88 00 00 00 00 00 00 ← Number 1920 00 06 68 65 69 67 68 74 ← key "height" 00 40 87 00 00 00 00 00 00 ← Number 1080 00 09 66 72 61 6D 65 72 61 74 65 ← key "framerate" 00 40 39 00 00 00 00 00 00 ← Number 25.0 00 0C 76 69 64 65 6F 63 6F 64 65 63 69 64 ← key "videocodecid" 00 40 1C 00 00 00 00 00 00 ← Number 7 (H.264) 00 0C 61 75 64 69 6F 63 6F 64 65 63 69 64 ← key "audiocodecid" 00 40 24 00 00 00 00 00 00 ← Number 10 (AAC) 00 00 09 ← ECMA Array 结束
7.2 ★ Video Sequence Header(最重要的 config 帧)
05 ← csid=5 (视频通道) 00 00 00 ← timestamp = 0 00 00 2A ← length = 42 09 ← type = 9 (MSG_VIDEO) 01 00 00 00 ← streamId = 1 ├─────────────────────────────────────────────────────┤ │ 17 ← ★ Video Tag Header │ │ │ bit7-4 = 1 (key) │ │ │ bit3-0 = 7 (AVC) │ │ 00 ← AVCPacketType = 0 │ │ │ (0 = Sequence Header)│ │ 00 00 00 ← CompositionTime = 0 │ │ 01 ← ★ configurationVersion=1 │ │ 42 C0 1E ← profile/compat/level │ │ FF ← lengthSizeMinusOne=3 │ │ E1 ← numOfSPS = 1 │ │ 00 19 ← SPS 长度 = 25 │ │ 67 42 C0 1E D9 00 50 05 BB 01 10 00 00 03 00 10 00 00 03 03 C8 F1 22 6A 2C │ │ ← SPS 数据(25 字节) │ │ 01 ← numOfPPS = 1 │ │ 00 05 ← PPS 长度 = 5 │ │ 68 CE 3C 80 ← PPS 数据(5 字节) │ └─────────────────────────────────────────────────────┘
关键:data[0]必须是0x01(configurationVersion)。服务器会检查,推流端发 AnnexB 会被拒绝。
7.3 Audio Sequence Header
04 ← csid=4 (音频通道) 00 00 00 ← timestamp = 0 00 00 04 ← length = 4 08 ← type = 8 (MSG_AUDIO) 01 00 00 00 ← streamId = 1 ├─────────────────────────────────────────────┤ │ AF ← Audio Tag Header │ │ │ 10=AAC │ │ │ 3=44kHz │ │ │ 1=16bit │ │ │ 1=stereo │ │ 00 ← AACPacketType = 0 │ │ 12 10 ← AudioSpecificConfig │ └─────────────────────────────────────────────┘
ASC 解析(12 10=0001 0010 0001 0000):
audioObjectType = 2 (AAC-LC) samplingFreqIdx = 4 (44100 Hz) channelConfig = 2 (stereo)
八、★ AVCC → AnnexB 转换
8.1 两种格式对比
AVCC(RTMP 里):
[00 00 00 19] | 67 42 C0 1E ... ← 4 字节长度 + SPS [00 00 00 05] | 68 CE 3C 80 ... ← 4 字节长度 + PPS [00 00 0A B4] | 65 88 84 ... ← 4 字节长度 + IDR
AnnexB(RTSP / 裸流用):
00 00 00 01 | 67 42 C0 1E ... ← 起始码 + SPS 00 00 00 01 | 68 CE 3C 80 ... ← 起始码 + PPS 00 00 00 01 | 65 88 84 ... ← 起始码 + IDR
核心区别:起始码 ↔ 4 字节长度前缀(大端)。
8.2 转换代码
static QByteArray avccToAnnexB(const QByteArray& avcc, int naluLenSize) { QByteArray out; if (naluLenSize != 1 && naluLenSize != 2 && naluLenSize != 4) naluLenSize = 4; const uint8_t* p = reinterpret_cast<const uint8_t*>(avcc.constData()); int size = avcc.size(); int off = 0; static const uint8_t kStartCode[4] = {0,0,0,1}; while (off + naluLenSize <= size) { // 读长度字段(大端) uint32_t len = 0; if (naluLenSize == 1) len = p[off]; else if (naluLenSize == 2) len = (p[off] << 8) | p[off+1]; else len = (p[off] << 24) | (p[off+1] << 16) | (p[off+2] << 8) | p[off+3]; off += naluLenSize; if (off + int(len) > size) break; out.append((const char*)kStartCode, 4); // 加起始码 out.append((const char*)(p + off), len); // 加 NALU off += len; } return out; }九、★ AVCDecoderConfigurationRecord 解析
9.1 结构
[0] configurationVersion = 0x01 [1] AVCProfileIndication = SPS[1] [2] profile_compatibility = SPS[2] [3] AVCLevelIndication = SPS[3] [4] 0xFF (reserved(6) | lengthSizeMinusOne(2)) [5] 0xE1 (reserved(3) | numOfSPS(5)) [6..7] SPS length (大端) [8..] SPS 数据 [...] 0x01 (numOfPPS=1) [...] PPS length [...] PPS 数据
9.2 解析代码
static QByteArray parseAvcConfig(const QByteArray& cfg, int& naluLenSize) { if (cfg.size() < 7) return QByteArray(); const uint8_t* p = reinterpret_cast<const uint8_t*>(cfg.constData()); // lengthSizeMinusOne 在 [4] 的低 2 bit naluLenSize = (p[4] & 0x03) + 1; int off = 5; int numSps = p[off++] & 0x1F; QByteArray out; static const uint8_t kStartCode[4] = {0,0,0,1}; // 解析 SPS for (int i = 0; i < numSps && off + 2 <= cfg.size(); ++i) { int len = (p[off] << 8) | p[off+1]; off += 2; if (off + len > cfg.size()) return out; out.append((const char*)kStartCode, 4); out.append((const char*)(p + off), len); off += len; } // 解析 PPS if (off >= cfg.size()) return out; int numPps = p[off++]; for (int i = 0; i < numPps && off + 2 <= cfg.size(); ++i) { int len = (p[off] << 8) | p[off+1]; off += 2; if (off + len > cfg.size()) return out; out.append((const char*)kStartCode, 4); out.append((const char*)(p + off), len); off += len; } return out; }9.3 实际收到字节(示例)
17 ← FrameType=1(key) | Codec=7(AVC) 00 ← AVCPacketType=0 (Sequence Header) 00 00 00 ← CompositionTime=0 01 ← ★ configurationVersion=1 42 C0 1E ← profile/compat/level FF ← lengthSizeMinusOne=3 E1 ← numOfSPS=1 00 19 ← SPS 长度=25 67 42 C0 1E D9 ... ← SPS 数据(25B) 01 ← numOfPPS=1 00 05 ← PPS 长度=5 68 CE 3C 80 ← PPS 数据(5B)
转换后的 AnnexB:
00 00 00 01 | 67 42 C0 1E D9 ... ← 起始码 + SPS 00 00 00 01 | 68 CE 3C 80 ← 起始码 + PPS
| 现象 | 原因 | 修复 |
|---|---|---|
| 握手后立即断开 | C1 随机字节触发复杂握手 | C1 用固定字节 |
_result收不到 | connect 参数不合法 | 检查 app/tcUrl |
| play 后无数据 | app/stream 拼错 | 检查 URL |
| 黑屏 | Sequence Header 没收到 | 检查 avcType==0 分支 |
| 花屏 | AVCC → AnnexB 转换错 | 检查长度前缀处理 |
| 音频无声 | ASC 没解析对 | 从 ASC 取采样率 |
| 时间戳跳变 | RTMP 用毫秒 | dts_90k / 90 |
RTMP 拉流信令的四个阶段:
| 阶段 | 命令 | 关键点 |
|---|---|---|
| 握手 | C0/C1/C2 ↔ S0/S1/S2 | C1 别用随机字节 |
| 建连 | connect → _result | AMF0 编码,检查 Success |
| 建流 | createStream → _result | 拿 streamId |
| 播放 | play → onStatus | 收 Sequence Header |
三层要点:
Chunk 层:fmt=0 首片 + fmt=3 续片,streamId 小端
AMF 层:二进制编码,String 2 字节长度,Number 8 字节 double
载荷层:AVCC ↔ AnnexB,avcC 首字节
0x01
与推流端的镜像关系:
| 推流 | 拉流 |
|---|---|
buildAvcC()拼 avcC | parseAvcConfig()拆 avcC |
annexBToAvcc()转 AVCC | avccToAnnexB()转 AnnexB |
sendVideoConfig()发 | handleVideo(avcType==0)收 |
sendVideoFrame()发 AVCC | handleVideo(avcType==1)转 AnnexB |
核心一句话:RTMP 拉流 =握手 + AMF0 信令 + Chunk 重组 + AVCC 转 AnnexB。四条主线和 RTSP 一一对应,只是每层都换了一套编码方式。把这三层理清,RTMP 拉流就不再是黑盒。
遇到协议问题,先看日志的字节偏移和字段名,几乎都能定位到具体层。
rtmp拉流