第29章:音视频网络传输——RTP/RTCP、RTMP、HLS与简易视频流服务器

音视频网络传输,说白了就是把摄像头拍到的一帧帧画面,加上麦克风录到的声音,打包扔到网络上,让另一端能流畅地看到、听到。这事儿看着简单,做起来坑不少。我早年做视频监控项目时,就被卡顿、花屏、音画不同步折磨得够呛。后来才明白,选对协议、理解协议背后的设计思想,比闷头写代码重要得多。

29.1 为什么需要专门的传输协议?

你想想看,HTTP传个文件,丢了包大不了重传,慢一点也无所谓。但视频不行——你正看着直播,突然画面卡住等重传,那体验就崩了。音视频对实时性要求极高,对丢包却有一定容忍度。所以,传统的TCP那套“保证可靠、顺序到达”的机制,反而成了累赘。

嗯,这里要记住一个核心原则:音视频传输,实时性优先于可靠性。丢一两个包,画面花一下,用户能忍;但卡顿几秒钟,用户直接关页面。

核心矛盾: 音视频数据量大、实时性要求高,而网络带宽有限、延迟不稳定。专用协议就是为了在“快”和“稳”之间找到平衡点。

29.2 三大主流协议:RTP/RTCP、RTMP、HLS

目前业界用得最多的,就是这三兄弟。它们各有各的脾气,适用场景也不同。我一个个说。

29.2.1 RTP/RTCP:实时传输的基石

RTP(Real-time Transport Protocol)是真正的“传输工”。它负责把音视频数据切成小包,加上时间戳、序列号等信息,扔给UDP发出去。RTCP(RTP Control Protocol)则是“监工”,负责统计丢包率、延迟、抖动等质量参数,让发送端能动态调整码率。

我在项目中遇到过一个问题:用RTP传H.264视频,解码端总是花屏。查了半天,发现是RTP的负载类型(Payload Type)没配对。H.264的PT值是96,我写成了0,解码器根本不认。这种细节,文档里写得清清楚楚,但一上手就容易忽略。

个人习惯: 我写RTP相关代码时,一定会先抓包看RTP头部的前12个字节。版本号、填充位、扩展位、CSRC计数、标记位、负载类型、序列号、时间戳、SSRC——这些字段一个都不能错。用Wireshark看一眼,比调试半天代码都管用。

RTP头部结构(固定12字节):

 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
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|V=2|P|X|  CC   |M|     PT      |       sequence number         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           timestamp                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           synchronization source (SSRC) identifier            |
+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
|            contributing source (CSRC) identifiers             |
|                             ....                              |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

RTCP的SR(Sender Report)包也很关键,它包含了发送端的NTP时间戳和RTP时间戳的对应关系。接收端靠这个来同步音视频。我曾经调试音画不同步问题,就是发现RTCP的SR包没按时发送,导致接收端时间戳映射出错。

29.2.2 RTMP:直播界的“老将”

RTMP(Real-Time Messaging Protocol)是Adobe搞出来的,基于TCP。它最大的优点是低延迟——端到端能做到1-3秒。早期直播平台几乎清一色用RTMP。但它也有硬伤:基于TCP,握手复杂,而且Adobe Flash Player已经凉了。

不过,RTMP作为推流协议,至今仍是主流。很多编码器、推流软件(比如OBS)都支持RTMP推流。服务器收到RTMP流后,再转成HLS或WebRTC分发给用户。

避坑指南: 我曾经在RTMP握手阶段踩过坑。RTMP的握手是C0/C1/C2/S0/S1/S2六步,每一步的字节顺序、版本号校验都不能错。特别是C1的timestamp字段,很多实现会忽略它,但有些服务器会校验。建议直接用成熟的库(如librtmp),别自己手写握手逻辑。

RTMP消息格式:

// RTMP Chunk Header (基本头)
// 0: fmt (2 bits) + cs id (6 bits)
// 1-2: timestamp (3 bytes, 大端)
// 3-5: message length (3 bytes, 大端)
// 6: message type id (1 byte)
// 7-10: message stream id (4 bytes, 小端)

// 一个典型的视频数据消息
// fmt=0, cs_id=4, timestamp=0x123456
// message_length=0x001000 (4096字节)
// message_type_id=0x09 (Video)
// message_stream_id=0x00000001

29.2.3 HLS:苹果的“切片”方案

HLS(HTTP Live Streaming)是苹果推出的。它把视频切成一个个小片段(通常是2-10秒的TS文件),通过HTTP分发。客户端下载一个m3u8索引文件,然后按顺序下载TS片段播放。延迟比RTMP高(通常10-30秒),但兼容性极好——所有现代浏览器都支持,而且能轻松穿透防火墙。

HLS的核心是m3u8文件。它是个文本文件,里面列出了所有TS片段的URL和时长。服务器端需要不断生成新的m3u8文件和TS片段,客户端则定期刷新m3u8,获取新的片段。

一个简单的m3u8示例:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:10.000,
segment_000.ts
#EXTINF:10.000,
segment_001.ts
#EXTINF:10.000,
segment_002.ts
#EXT-X-ENDLIST
个人经验: 做HLS服务器时,TS片段的时长一定要稳定。我见过有人用ffmpeg切片,结果每个片段时长不一样,客户端播放时就会出现卡顿或缓冲。建议用-segment_time 10强制固定时长,同时加上-break_non_keyframes 0确保每个片段从关键帧开始。

29.3 协议对比与选型

选哪个协议,得看你的场景。我整理了一张表,方便你对比:

协议 传输层 延迟 优点 缺点 适用场景
RTP/RTCP UDP 低(<1s) 实时性好,支持组播 实现复杂,需处理NAT穿透 视频会议、VoIP、实时监控
RTMP TCP 中(1-3s) 低延迟,生态成熟 基于TCP,握手复杂,Flash已死 直播推流、互动直播
HLS HTTP/TCP 高(10-30s) 兼容性好,穿透防火墙 延迟高,切片有额外开销 点播、直播(非互动场景)

29.4 简易视频流服务器实战

光说不练假把式。我们来写一个最简单的视频流服务器,用RTP传输H.264裸流。这个服务器从文件读取H.264数据,封装成RTP包,通过UDP发送给客户端。

核心思路:

  1. 读取H.264文件,找到NAL单元(以0x00000001或0x000001开头)
  2. 每个NAL单元封装成一个或多个RTP包(注意MTU限制,通常1500字节)
  3. 设置RTP头部:版本号=2,负载类型=96(H.264),序列号递增,时间戳递增
  4. 通过UDP socket发送

代码示例(简化版,仅展示核心逻辑):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define RTP_HEADER_SIZE 12
#define MAX_PAYLOAD_SIZE 1400  // 留点空间给IP/UDP头部
#define RTP_PT_H264 96

// RTP头部结构
typedef struct {
    unsigned char csrccount:4;
    unsigned char extension:1;
    unsigned char padding:1;
    unsigned char version:2;
    unsigned char payloadtype:7;
    unsigned char marker:1;
    unsigned short seq;
    unsigned int timestamp;
    unsigned int ssrc;
} __attribute__((packed)) rtp_header_t;

// 发送RTP包
void send_rtp_packet(int sock, struct sockaddr_in *addr, 
                     unsigned char *data, int len,
                     unsigned short *seq, unsigned int *ts) {
    unsigned char packet[RTP_HEADER_SIZE + MAX_PAYLOAD_SIZE];
    rtp_header_t *header = (rtp_header_t *)packet;
    
    // 填充RTP头部
    header->version = 2;
    header->padding = 0;
    header->extension = 0;
    header->csrccount = 0;
    header->marker = 0;  // 非最后一包
    header->payloadtype = RTP_PT_H264;
    header->seq = htons((*seq)++);
    header->timestamp = htonl(*ts);
    header->ssrc = htonl(0x12345678);
    
    // 拷贝数据
    memcpy(packet + RTP_HEADER_SIZE, data, len);
    
    // 发送
    sendto(sock, packet, RTP_HEADER_SIZE + len, 0,
           (struct sockaddr *)addr, sizeof(*addr));
}

int main(int argc, char *argv[]) {
    // 省略:创建UDP socket、设置目标地址
    // 省略:打开H.264文件、读取NAL单元
    
    unsigned short seq = 0;
    unsigned int timestamp = 0;
    
    // 假设nal_data指向一个NAL单元,nal_len为其长度
    // 每个NAL单元增加90000/30 = 3000时间戳(假设30fps)
    send_rtp_packet(sock, &addr, nal_data, nal_len, &seq, ×tamp);
    timestamp += 3000;
    
    return 0;
}
注意: 上面的代码只是演示核心逻辑。实际项目中,H.264的NAL单元可能很大(比如一个IDR帧),需要分片发送(FU-A分片)。分片时,第一个分片的FU header的S位=1,最后一个分片的E位=1,中间分片的S和E都为0。这个细节很容易搞错,我当年调试分片时,花了一整天才发现是FU header的S/E位没设对。

29.5 知识体系总览

下面这张图,把本章的核心知识点串起来了。你看一眼,心里就有谱了。

音视频网络传输协议体系 应用场景:直播 / 点播 / 视频会议 / 监控 RTP/RTCP 实时传输 + 质量控制 RTMP TCP长连接,低延迟 HLS HTTP切片,高兼容性 UDP(不可靠,低延迟) TCP(可靠,高延迟) HTTP/TCP(可靠,高延迟) RTP头部 + 负载类型 FU-A分片 / NAL单元 握手(C0/C1/C2/S0/S1/S2) Chunk流 / 消息分片 m3u8索引 + TS切片 关键帧对齐 / 时长固定

这张图从应用场景往下,依次是协议选择、传输层、实现细节。你写代码时,就按这个层次来思考:先定场景,再选协议,最后扣细节。

29.6 写在最后

音视频传输这块,理论说破天也不如动手写一次。我建议你从最简单的RTP发送H.264裸流开始,用VLC或ffplay接收。能收到画面了,再逐步加入RTCP、分片、时间戳同步。每一步都会踩坑,但踩过了,你就真懂了。

嗯,今天就聊到这儿。代码不在多,在于精。你把这个简易服务器跑通了,后面再学RTMP、HLS,会发现很多概念是相通的。


公众号:蓝海资料掘金营,微信 deep3321