简介:面向希望吃透流媒体服务底层实现的C语言开发者,这份RTSP服务器源码材料围绕rtsp、rtsp_server主题,梳理从协议请求解析到媒体数据传输的完整实现路径,适合具备一定网络编程基础、想理解实时流服务如何落地的中高级读者。包内共414个文件,以167个C源文件与45个头文件为主体,配合121个目标文件、15个静态库及多份Makefile、ini配置,压缩包约1.26MB,编译脚本与模块划分便于对照理解工程组织。已有1060人学习下载。源码覆盖服务器初始化,DESCRIBE、SETUP、PLAY、PAUSE、TEARDOWN等方法处理,RTP/RTCP传输,会话状态机,多线程与锁同步等关键模块,读者可据此追踪报文解析、SDP生成及并发资源保护的实现细节,并借助编译运行与请求测试验证行为,形成从协议概念到代码落地的完整认知。
1. 一个 RTSP 服务器最难的从来不是发流,而是记住“谁在哪一步”
翻 rtsp_server 的 C 源码,最容易产生的一个误判是:它不过是个把文件读出来、往 socket 里写的转发器。真正读进去才会发现,发送逻辑只占几百行,剩下的几千行几乎都在干同一件事——记住每个连接此刻处于什么状态。RTSP 是一个带会话、带外控制的协议:播放地址要先被 DESCRIBE 描述成 SDP,媒体通道要先被 SETUP 协商出端口或通道号,PLAY 之后服务器还得在同一个 TCP 连接上同时吐文本响应和二进制 RTP 包。搞懂这份源码,本质上就是搞懂“状态、通道、打包”这三件事如何用 C 拼在一起,以及每一处为什么不能图省事。
2. RTSP 报文解析:为什么它长得像 HTTP 却不能按 HTTP 写
把 RTSP 当成“带命令的 HTTP”来写,前 200 行通常很顺,第 201 行开始翻车。原因在于三处结构性差异:第一,RTSP 的 URI 是绝对地址且带会话语义,服务器还要从Transport头里读出通道号并回写;第二,RTSP 是双向的,服务器可以主动向客户端发请求(比如用反向GET_PARAMETER探活),这意味着解析器不能假定“读到的一定是请求”;第三,也是最要命的一点,TCP 传输模式下文本报文和二进制 RTP 包复用同一条连接。
2.1 请求行与头部:分词、大小写与 Content-Length 的三角关系
请求行的格式是METHOD SP URI SP RTSP/1.0 CRLF。看着简单,但很多实现栽在分词上。strtok是最顺手也最危险的选择:它会把连续分隔符合并、会往原缓冲区写\0,一旦你后面还要拿原始报文做日志或重放,就已经被改坏了。这份源码里的做法是手写指针扫描,遇到空格就记一个边界,扫描结束后一次性把 method、uri、version 三段切出来,原缓冲区不动。
URI 也不能整串当 key 用。rtsp://192.0.2.10:8554/live/ch0里真正对路由有用的是/live/ch0,前面那截是给客户端确认的。常见做法是找到第三个/之后的部分作为资源路径,同时保留完整串用于拼接Content-Base。
头部查找必须大小写不敏感。客户端的实现五花八门,Content-Length、content-length、CONTENT-LENGTH都会出现,如果实现用strncmp就会漏。我一般会这么理解:把头部名统一折叠成小写再比较,比每次都用strncasecmp更省事,代价是多一次拷贝。
真正决定解析边界的是Content-Length。RTSP 报文允许带 body——ANNOUNCE带 SDP、SET_PARAMETER带参数——而 body 的长度只能由这个头决定,绝不能“recv 一次读多少算多少”。TCP 是字节流,半包和粘包是常态,一个 4KB 的 SDP 很可能分三次才到齐。
/* 单次可读事件里的完整解析循环:先判交错帧,再判 RTSP 文本 */ int on_readable(conn_t *c) { ssize_t n = recv(c->fd, c->rbuf + c->rlen, sizeof(c->rbuf) - c->rlen, 0); if (n == 0) return 0; /* 对端关闭 */ if (n < 0) return (errno == EAGAIN || errno == EWOULDBLOCK) ? 1 : -1; c->rlen += n; while (c->rlen > 0) { if (c->rbuf[0] == '$') { /* 0x24:交错通道帧,不是文本 */ if (c->rlen < 4) break; /* 连帧头都没收全 */ int ch = c->rbuf[1]; int len = (c->rbuf[2] << 8) | c->rbuf[3]; if (c->rlen < 4 + len) break; /* 载荷没到齐,等下一次可读 */ handle_interleaved(c, ch, (uint8_t *)c->rbuf + 4, len); consume(c, 4 + len); /* memmove 左移,rlen 同步减 */ } else { char *end = find_header_end(c->rbuf, c->rlen); /* 找 \r\n\r\n */ if (!end) break; int hlen = (int)(end - c->rbuf) + 4; int clen = content_length(c->rbuf, hlen); /* 无该头则为 0 */ if (c->rlen < hlen + clen) break; /* body 不完整 */ handle_request(c, c->rbuf, hlen, c->rbuf + hlen, clen); consume(c, hlen + clen); } } return 1; }这段循环的关键在于while而不是if。一次recv里可能同时到达一条完整的PLAY请求和十几帧 RTP 数据,只处理一条就会把后续数据留在缓冲区里等下个事件——而下个事件可能根本不会来,因为客户端已经在等你的响应了。consume()用memmove把剩余字节左移,rlen同步减去已消费长度;缓冲区大小固定的实现里,还要额外判断“剩余空间不足以容纳下一个报文头”时是否要扩容或直接断开。
2.2 方法分发与状态机:CSeq 和 Session 才是真正的骨架
RTSP 的方法不多,但每个方法都有明确的状态前置条件。源码里通常是一张函数指针表加一个state字段,下面是这份实现里允许的组合,照着它可以快速判断客户端是不是在乱发:
| 方法 | 允许的会话状态 | 必须回的关键头 |
|---|---|---|
| OPTIONS | 任意 | Public(列出支持的方法) |
| DESCRIBE | INIT / READY | Content-Type: application/sdp、Content-Base |
| SETUP | INIT / READY | Transport(回写 server_port 或 interleaved) |
| PLAY | READY / PAUSE | Session、Range |
| PAUSE | PLAYING | Session |
| TEARDOWN | 任意 | Session |
| GET_PARAMETER / SET_PARAMETER | 任意 | Session(可省略,用于保活) |
状态非法时返回455 Method Not Valid in This State,Session头缺失或对不上时返回454 Session Not Found。这两个错误码在真实抓包里出现频率不低,尤其是客户端断开重连后还拿着旧 session id 发TEARDOWN的场景。
CSeq必须原样回显。它不是序号生成器,而是一个事务匹配编号:客户端发CSeq: 7,服务器的响应就必须是CSeq: 7,否则严格一点的客户端会直接判定响应错乱然后把连接关掉。这一点和 HTTP 的流水线模型完全不同,理解错了后续所有调试都会跑偏。
2.3 交错帧:同一个 socket 上混着文本和二进制怎么办
SETUP请求里的Transport: RTP/AVP/TCP;unicast;interleaved=0-1一旦被接受,后面的 RTP 就不再走 UDP,而是塞进这条 TCP 连接。此时线上流量的形态是:
$ <channel:1字节> <length:2字节大端> <RTP 包体>首字节固定是0x24,也就是 ASCII 的$。这就是 2.1 里那段代码一上来先判断c->rbuf[0] == '$'的原因——如果不判,0x24会被当成某个方法名的首字母,解析器会去找空格和\r\n,然后在几百字节的二进制里“找到”一堆莫名其妙的头部,最终返回 400 或者干脆越界。
channel是SETUP协商出来的编号,惯例是偶数给 RTP、奇数给 RTCP(interleaved=0-1就是 RTP 走 0、RTCP 走 1)。服务器发 RTCP 的 SR 时也必须走这条通道,不能另开 socket。
注意:交错模式下每条 RTP 包都要额外加 4 字节头,长度字段是 16 位,单包不能超过 65535 字节。按 MTU 分片的实现不会碰到这个上限,但如果有人图省事把一整帧塞进去,就会在这里静默截断。
3. RTP 打包:一个 NAL 单元是怎么被拆成几十个包的
解析层只是入口,真正决定画面能不能看的是打包层。H.264 的一帧可能只有几百字节(P 帧),也可能有几十万字节(I 帧),而以太网 MTU 扣掉 IP 头 20 字节、UDP 头 8 字节、RTP 头 12 字节,留给载荷的只剩 1460 字节左右。超过这个长度的 NAL 单元必须分片,分片规则写在 RFC 6184 里,落到代码上就是一堆位运算。
3.1 从裸流到 NAL:起始码、长度前缀与两种封装格式
从文件或编码器拿到的码流通常是两种封装之一。Annex-B 用起始码分隔,可能是00 00 00 01也可能是00 00 01,前者常见于关键帧前,后者用于普通 NAL。AVCC 则用 4 字节大端长度前缀。这份源码里只处理 Annex-B,扫描逻辑是逐字节找起始码,找到就把上一个 NAL 的区间记下来。
这里有个容易忽略的细节:00 00 00 01里包含了00 00 01,如果扫描器先匹配短的那个,就会把长起始码切成“一个空 NAL + 一个正常 NAL”,多出一个长度为 0 的条目。空 NAL 送去打包会生成一个只有 RTP 头、没有载荷的包,播放器收到后一般会忽略,但某些严格实现会直接报错断流。正确做法是先尝试匹配 4 字节,失败再退回 3 字节。
扫描出来的 NAL 列表里,SPS 和 PPS 要单独拎出来,因为DESCRIBE的 SDP 里需要它们:
a=fmtp:96 packetization-mode=1;sprop-parameter-sets=<base64(SPS)>,<base64(PPS)>sprop-parameter-sets是客户端在PLAY之前唯一能拿到解码参数的机会。少了它,很多播放器会一直等到第一个 I 帧里的 SPS 才开始解码,表现出来就是“点了播放要愣一两秒才出画面”。
3.2 FU-A 分片的位运算:NRI 丢了就花屏
FU-A 的规则是:把一个 NAL 的头字节拆成两部分,指示字节(Indicator)保留 F 位和 NRI,类型固定写成 28;分片头(FU header)里放 S(起始)、E(结束)、R(保留,必须为 0)和原始 NAL 的类型。
#define RTP_HDR 12 #define MTU_PAYLOAD 1400 /* 保守值:1500 - 20(IP) - 8(UDP) - 12(RTP) 再留余量 */ /* 把一个 NAL 单元切成若干 RTP 包,一帧的最后一个包置 marker 位 */ int packetize_nal(session_t *s, const uint8_t *nal, int len, int is_last_of_frame, uint32_t ts) { if (len <= MTU_PAYLOAD) /* 小 NAL:单包,载荷就是 NAL 本身 */ return rtp_send(s, nal, len, is_last_of_frame, ts); uint8_t ind = (uint8_t)((nal[0] & 0xE0) | 28); /* F|NRI 原样保留,type 改 28 */ int off = 1; /* 跳过原 NAL 头,它已被拆进两个头里 */ while (off < len) { int chunk = len - off; if (chunk > MTU_PAYLOAD) chunk = MTU_PAYLOAD; uint8_t fuh = (uint8_t)(nal[0] & 0x1F); /* 原始 NAL type,低 5 位 */ if (off == 1) fuh |= 0x80; /* S=1,首片 */ if (off + chunk == len) fuh |= 0x40; /* E=1,末片 */ uint8_t buf[MTU_PAYLOAD + 2]; buf[0] = ind; buf[1] = fuh; memcpy(buf + 2, nal + off, chunk); int last = is_last_of_frame && (off + chunk == len); if (rtp_send(s, buf, chunk + 2, last, ts) < 0) return -1; off += chunk; } return 0; }参数里ts是整帧共享的时间戳,is_last_of_frame标记这一帧是否是本帧最后一个 NAL,marker位只在“帧的最后一个 NAL 的最后一个分片”上置 1。源码里用last变量把这两个条件与在一起,这是最容易写错的一行——如果每个 NAL 的末片都置 marker,播放器会把一帧拆成好几帧来渲染,表现是画面撕裂或者帧率翻倍。
ind里保留 NRI 是必须的。NRI 表示 NAL 的重要性,值为 3 时是关键帧。丢了它,接收端在做丢包隐藏时会把关键帧当普通帧处理,画面会出现大块马赛克并且要等到下一个 I 帧才能恢复。
3.3 时间戳、序列号与 marker:播放器靠什么判断一帧结束
RTP 头里的三个字段各有分工。seq是 16 位无符号数,每发一个包加一,到 65535 回绕到 0。不要用有符号比较判断新旧,回绕点附近会出错,正确写法是(int16_t)(a - b) > 0这类差值比较。
timestamp是 90kHz 时钟,25fps 时每帧加 3600。它只在一帧的第一个包上递增,同一帧的所有分片共享同一个值。有些实现图省事在每个分片上都加一次,接收端算出来的帧间隔会变成 1/25 的几十倍,播放速度直接飞天。
marker位是给接收端的一个提示:这一帧的数据到此为止。播放器一般会攒够一个 marker 才开始解码,所以 marker 漏置会导致“画面延迟逐渐增大直到卡死”,多置则是“画面碎成一块块”。这两个症状在调试时很容易被误判成网络问题。
4. 会话与并发:单线程事件循环怎么扛住几十路并发
RTSP 服务器的并发压力其实不大——一路 1080p、25fps 的流,打包后大约 2~4 Mbps,几十路也就是百兆级别,瓶颈从来不在 CPU 而在“别把任何一个客户端卡住”。所以这份源码选择了单线程事件循环而不是每连接一线程,这是一个相当务实的选择。
4.1 session 结构体:把一个会话需要记住的东西一次列清
typedef struct session { char id[17]; /* 16 字节随机 hex + '\0',SETUP 时生成 */ uint32_t ssrc; /* 每会话独立,RTCP 的 SR 里必须带 */ int fd; /* 复用的那条 TCP 连接 */ int state; /* INIT / READY / PLAYING / TEARDOWN */ int ch_rtp, ch_rtcp; /* 交错通道号,来自 Transport 头 */ uint16_t seq; /* 16 位,会回绕,别用 int 比较大小 */ uint32_t ts; /* 90kHz 时钟,一帧只加一次 */ uint64_t last_active_ms; /* 超时扫描用,长时间不发包就回收 */ struct session *next; /* 哈希桶链,按 id 前两字节散列 */ } session_t;这个结构体是整个服务器的状态中枢。ssrc必须在整个服务器生命周期内唯一,重复了会导致客户端把两路流的统计混在一起,RTCP 的丢包率算出来是错的。last_active_ms是超时回收的依据:客户端拔网线不会发TEARDOWN,没有这个字段,session 会一直挂在哈希表里,fd 也一直占着。
4.2 非阻塞发送与发送队列:EAGAIN 不是错误
事件循环里所有 fd 都必须设成非阻塞。send()在缓冲区满时返回 -1 且errno == EAGAIN,这表示“现在发不出去,等可写事件再来”,不是失败。翻车最多的写法是:
send(fd, pkt, len, 0); /* 返回值没看,短写没处理 */短写(返回值小于 len)在 TCP 上完全合法,内核只把能塞进发送缓冲的那部分拷走了。剩下的字节如果被丢弃,接收端就会看到一个长度不匹配的包,直接断流;如果不丢弃,就要在连接对象上挂一个发送队列,把剩下的字节存起来,注册EPOLLOUT,等下一次可写事件接着发。
队列本身要有个上限。客户端下载慢的时候,队列会越积越长,最后把服务器内存吃光。常见做法是设一个字节水位(比如 2MB),超过就丢弃非关键帧的 RTP 包——反正丢了会花屏,但不会卡死,比 OOM 好。
4.3 定时器与 RTCP:5 秒一次的 SR 到底在干什么
事件循环里通常用一个“最近超时时间”变量配合epoll_wait的超时参数来实现软定时器。这份源码里挂了两个周期任务:每 5 秒给所有 PLAYING 状态的会话发一次 RTCP SR,每 1 秒扫一遍 session 表做超时回收。
RTCP SR(Sender Report)里带的是 NTP 时间戳和对应的 RTP 时间戳,作用是把 90kHz 的相对时钟对齐到绝对时间。只有视频的时候它看着没什么用,一旦有音频,接收端就是靠这两个值的映射关系做音画同步的。SR 里还要带包数和字节数统计,客户端的 jitter buffer 会参考这些值调整缓存深度。不发 SR 的服务器,多数播放器也能播,但音画同步会随播放时长逐渐漂移——这个现象很难归因,因为它要播上几分钟才明显。
超时回收的阈值一般取 60 秒。太短会误杀正在缓冲的客户端,太长则 fd 泄漏。判定依据是last_active_ms,而它的更新点要包括“收到任何 RTSP 请求”和“成功发出一批 RTP 包”,只更新其中一个是常见疏漏。
5. 排查实录:五个把 RTSP 服务器拖进坑里的细节
5.1 现象一:DESCRIBE 返回 200,PLAY 之后黑屏
现象是抓包看信令全对,SETUP也返回了 200,但画面一直是黑的,播放器日志里通常有一句“no codec parameters”。
原因有两类。一是 SDP 里的a=control:写的是track1,而SETUP请求里的 URI 是rtsp://.../trackID=0,两边对不上,服务器在SETUP时找不到对应的轨道,却因为没做校验而返回了 200。二是sprop-parameter-sets缺失或者 base64 编码时把换行也编进去了,客户端解析失败。
解决方式是让a=control:的值和SETUP的解析逻辑共用同一个函数,SETUP拿不到轨道就返回 404 而不是 200;base64 编码后要显式去掉\r\n。
5.2 现象二:播放几秒后卡住不动
现象是前几秒正常,然后画面定格,抓包看服务器已经不再发包,但 TCP 连接还在。
原因是 4.2 里说的短写没处理。前几秒因为缓冲区空,send每次都能全量写回;等到客户端消费速度跟不上(比如解码慢),发送缓冲区满了,send开始返回部分写入,剩下的字节被丢弃,接收端的 RTP 包长度字段对不上,直接停止解析。看起来像“网络卡了”,其实是自己把流截断了。
解决是把发送逻辑改成“写不完就入队 + 注册 EPOLLOUT”,并给队列加水位限制。
5.3 现象三:进程内存缓慢上涨,几天后 OOM
现象是 RSS 每小时涨几 MB,重启就恢复,抓不到明显泄漏点。
原因是 session 的回收路径不完整。TEARDOWN时释放了 session,但客户端直接断开(recv返回 0)的路径上没有走同一段清理代码,哈希桶里的节点没摘、fd没关、发送队列没释放。另一个常见来源是交错帧处理里为每个包malloc了一块,出错返回时忘了free。
解决办法是把“销毁会话”收敛成一个函数,所有退出路径都调它;再用valgrind --leak-check=full挂上真实客户端跑一轮完整的PLAY、PAUSE、TEARDOWN、拔网线四种退出场景。
5.4 现象四:画面花屏、马赛克,且只在运动剧烈时出现
现象是静态画面正常,镜头一动就出现方块,过一两秒自己恢复。
原因是 FU-A 分片时 NRI 位没保留。运动剧烈时码率上升,I 帧和 P 帧变大,走分片路径的概率变高,NRI 一旦丢失,接收端做丢包隐藏时无法识别这是参考帧,后续 P 帧全部解码错误。另一种可能是 marker 位在每个 NAL 的末片上都置了 1,播放器提前切帧。
解决办法是对着 SPS 里的profile_idc和实际码流写一个单测,把一个 100KB 的 I 帧喂进packetize_nal,检查输出的每个包指示字节的高 3 位是否与原 NAL 头一致、FU header 的 S/E 组合是否正确。
5.5 现象五:偶发解析失败,连接被服务端重置
现象是大部分客户端正常,个别客户端一出问题就断,日志里是 400 或者根本没日志。
原因是解析循环一上来就把首字节当成方法名。当客户端使用 TCP 交错模式时,$开头的二进制帧会先到;如果代码里没有 2.3 提到的那个分支,就会在二进制里找空格,通常几百字节后就撞上越界。另一种情况是Content-Length读成了负数(客户端发了非法值),hlen + clen变成小于hlen的数,循环消费长度算错,缓冲区错位。
解决办法是对所有来自网络的长度字段做范围校验,超过缓冲区容量就直接回 400 并关闭连接;解析器入口的第一行永远是判0x24。
6. 设计取舍:为什么用 C 手写协议栈,以及怎么验证它真的对
用 C 从零写一个 RTSP 服务器,在今天看是个“不划算”的选择——现成的库一大把,接入成本可能只有几百行。但翻完这份源码会发现,它换来的东西很具体:整个状态机、缓冲区生命周期、打包粒度都在自己手里,内存占用可以压到几百 KB,能塞进资源受限的设备;出问题时不用穿过三层抽象去猜是哪一层把包丢了。
代价同样明确。手写意味着你要自己对每一个边界负责:seq的回绕、Content-Length的合法范围、send的短写、session 的四条退出路径。这些在成熟库里是别人替你踩过的坑,在这里全是你自己的坑。
验证方式我一般分三层。第一层是单元级的:把packetize_nal单独拎出来,喂边界长度的 NAL(1 字节、1400 字节、1401 字节、100KB),断言分片数、S/E 位、marker 位。第二层是协议级的:用抓包工具看一整轮OPTIONS → DESCRIBE → SETUP → PLAY → TEARDOWN,重点检查CSeq是否严格一一对应、RTP 的seq是否连续、timestamp的增量是否等于90000/fps。第三层是体验级的:用两个不同实现的播放器同时拉流,跑满一小时,观察音画同步是否漂移——漂移基本只有一个原因,RTCP SR 没发或者 NTP 时间戳算错。
提示:调 RTSP 时先看时间戳再看丢包。绝大多数“卡顿”和“花屏”最终都能归到
ts增量错误或marker位错误上,而不是网络。
从那以后我每次改打包相关的代码,都强制走一遍“边界长度单测 + 一小时长跑”这两步,哪怕只改了一个位运算。这份带完整注释的 C 源码已经把状态机、打包、会话管理拆得比较清楚,对着跑一遍、在关键位置打断点看变量,比读十遍协议文档来得实在。希望帮到你。
本文还有配套的精品资源,点击获取