概述:相机上的 RTSP(实时流传输协议)服务是接在画面输出通路末端的一个协议处理模块,由应用线程反复调用,自己不创建线程。它的结构取决于三件事:所有定时任务共用的到期时刻怎么算、一帧画面从编码器交到网络发送时归谁管、一条连接同时传控制命令和视频时按什么顺序发。这三件事划定了设备能承受的连接数,以及一次处理能做多少工作。
服务器上有进程隔离、内核网络栈和大容量内存,相机上这些都没有。所以哪些做法能直接照搬,哪些必须自己写,要看运行环境。
本文分析一台运行 RTOS 的单芯片相机如何对外提供 H.264 视频流。设备通过以太网或 WiFi 连接,播放端使用 VLC 之类的软件。这种设备的共同点是:一颗芯片同时负责图像采集、视频编码和网络发送,没有独立的第二颗处理器,跑的是实时操作系统,可用内存按百 KB 计算。
在这样的设备上,RTSP 服务不是独立进程,也不是自己起线程的模块。它对外只提供一个函数:调用方给它一个时间预算,它处理一轮就返回。
1. 服务不建线程,画面不做拷贝
这两件事在相机上改不了,后面的麻烦大多从它们派生。
服务自己不创建线程。它对外只提供一个函数,调用一次就处理一次,处理完立即返回。服务何时运行、运行在哪个线程、分配多少栈空间,全部由调用方决定。
画面也不做二次拷贝。编码器完成后交出来的是内存地址,不是数据本身,服务按 RTP(视频传输协议)的规则把这批数据切成包直接发出。省掉整帧搬运的代价,是这块内存什么时候可以复用要由服务自己算清楚。
flowchart LR
subgraph EXEC["执行方式"]
direction TB
E1["调用方持有线程
服务只提供单次调用入口"]:::app
E2["服务不建线程
不加锁"]:::app
end
subgraph BUDGET["资源上限"]
direction TB
B1["连接数固定
编译时确定"]:::srv
B2["每次调用工作量固定
帧数与包数有上限"]:::srv
end
subgraph DATA["数据归属"]
direction TB
D1["只传地址
不复制像素"]:::media
D2["多客户端共用
同一帧信息"]:::media
end
EXEC --> BUDGET
BUDGET --> DATA
classDef app fill:#cce5ff,stroke:#0d6efd,color:#084298;
classDef srv fill:#fff5e6,stroke:#e65100,color:#4a2400;
classDef media fill:#d4edda,stroke:#28a745,color:#155724;
图 1:三组约束的关系。
不建线程,时间就得自己算准;不拷贝画面,内存什么时候能复用就得自己判断。
2. 一帧画面要过几道手
一帧画面从感光器件到网口,要经过三层模块。图中的 DMA(直接内存访问)指硬件不经过处理器、直接把数据写进内存的方式。
flowchart LR
subgraph HW["硬件"]
direction TB
H1["感光器件与图像处理单元
输出原始画面"]:::hw
H2["画面写入单元
用 DMA 直接存入内存"]:::hw
H3["H.264 编码器
硬件压缩"]:::hw
H4["存储卡 / 网卡 / 串口"]:::hw
end
subgraph DRV["驱动"]
direction TB
D1["图像处理驱动
配置出图"]:::drv
D2["画面写入驱动
管理 DMA 与中断"]:::drv
D3["编码器驱动
管理编码通道"]:::drv
D4["存储与网卡驱动
注册设备"]:::drv
end
subgraph SW["软件"]
direction TB
S1["读取编码输出
串联硬件通路"]:::sw
S2["RTSP 服务
连接管理与打包"]:::sw
S3["平台适配层
网络与文件操作"]:::sw
S4["网络协议栈与文件系统"]:::sw
end
subgraph EXT["设备外部"]
direction TB
X1["播放端"]:::ext
end
H1 --> D1
H1 --> H2
H3 --> D3
H4 --> D4
D1 --> S1
D2 --> S1
D3 --> S1
S1 --> S2
D4 --> S4
S4 --> S2
S2 --> S3
S3 --> S4
S3 --> X1
classDef hw fill:#f8d7da,stroke:#dc3545,color:#721c24;
classDef drv fill:#fff3cd,stroke:#ffc107,color:#856404;
classDef sw fill:#cce5ff,stroke:#0d6efd,color:#084298;
classDef ext fill:#d4edda,stroke:#28a745,color:#155724;
图 2:四层通路。硬件负责出图和压缩,驱动负责配置硬件与响应中断,软件负责协议与打包。
三层之间只有两条硬边界。
软件不直接读写寄存器。 上层只调用驱动注册好的设备接口。换一颗芯片时,需要改动的部分集中在驱动里。
画面数据只传地址,不传副本。 编码完成后,软件拿到的是一组信息:缓冲区地址、数据长度、时间戳、是否关键帧。一帧 1080p 的原始画面超过 3 MB,逐帧复制既吃处理器时间也吃内存带宽,所以这里只传地址。缓冲区什么时候能复用,因此必须由软件自己判断。
多客户端时还会多一层麻烦:同一块内存被几个客户端同时引用,必须等全部用完才能写入下一帧,处理方式见第 5 节。
硬件不认识网络协议,也不知道有几个客户端。协议解析和打包全在软件这一层,RTSP 服务就是其中的一段。
协议逻辑与系统调用分开
网络收发、文件操作、取时钟、休眠这些和系统相关的调用全部集中在一个适配层里,协议部分只调用适配层提供的函数。
好处是同一份协议代码能在开发电脑上编译调试,再拿到设备上运行,差别只在适配层的条件编译分支。能在电脑上先验证协议逻辑,比在设备上用串口打印调试快得多。
回调里也可能等
读画面输出的回调函数看起来是普通函数调用,内部却可能等编码器出数据,或者去抢一把互斥锁。每个外部回调都得有个耗时上限,否则这一轮要花多久根本算不出来。
画面数据是高频的大块流,控制命令是低频的小消息,两者时序要求不同,通常分开对待(这种双流的划分见已发的 嵌入式流式数据处理架构)。在这台设备上,两条流由同一个线程照顾。
3. 服务被叫一次,要做完四件事
服务对外只有一个函数,调用一次就做完一轮工作:算出这次该等多久、等待事件、处理已经就绪的事件、执行已经到时间的任务,然后返回。调用方传进来的参数是这一次最多能等多久,不是固定的执行周期。
这样设计的好处是:服务不假设自己被调用的快慢,而是把时间点记在绝对时刻上。调用方来得早一点或晚一点,只影响被唤醒的时刻,不影响任务本身安排在什么时间执行。
sequenceDiagram
autonumber
participant C as 调用线程
participant S as 服务处理函数
participant P as 事件等待
participant K as 连接与打包
participant M as 画面来源
loop 每一轮
rect rgb(230, 242, 255)
C->>S: 调用一次(给出最长等待时间)
S->>S: 汇总所有定时任务
取出最早的到期时刻
end
rect rgb(255, 247, 214)
S->>P: 等待事件(等待时间取两者中较小值)
P-->>S: 返回已经就绪的连接
S->>S: 重新取一次当前时间
end
rect rgb(226, 217, 243)
S->>K: 处理控制命令与接收报告
S->>M: 取一帧并把帧信息分发出去
S->>K: 按上限发送视频包
end
rect rgb(212, 237, 218)
S->>K: 回收超时连接与失效状态
S-->>C: 返回这一轮结果
end
end
图 3:一次调用的先后顺序。四段顺序固定,其中分发帧信息必须排在发送视频包之前。
四段里最难的是三处:等待时间怎么算准、一帧画面归谁用、发不出去的数据怎么接着发。
4. 定时任务共用一个到期时刻
等待时间算得准不准,直接决定服务准不准。如果只看视频发送时间,其他定时任务只能靠每轮顺手检查一次,实际执行时刻会随调用快慢漂移。
4.1 每个任务各自记到期时刻
| 定时任务 | 到期时刻怎么算 |
|---|---|
| 发送视频包 | 上一次发送时刻加上帧间隔 |
| 发送接收报告 | 上一次发送报告的时刻加上固定间隔 |
| 连接超时 | 最后一次收到数据的时刻加上超时上限 |
| 请求没收完就断开 | 收到不完整命令的时刻加上超时上限 |
| 文件播完后的收尾 | 读到文件末尾的时刻加上收尾时间 |
| 画面暂时没有时重试 | 取帧失败的时刻加上一小段延时 |
| 一帧分多个包没发完 | 下一个时间点,或者连接可以写入时 |
等待时间取调用方给的上限和最早的到期时刻中较小的那个。这样既尊重调用方的时间预算,也保证任何任务都不会因为调用得快慢而被推迟。
把所有到期时刻放在一起取最小值,是事件循环里的常见做法:定时器按到期时刻排成一个最小堆,等待时取堆顶(同样的结构见已发的 单线程事件循环的内核设计)。差别在于这里的定时任务只有协议自己的几项,数量固定,不需要通用的事件循环库。
4.2 两处容易写错的地方
比较时间必须用无符号数的差值。 毫秒计数是 32 位无符号数,累计到最大值后会回到零。直接比较大小,在回绕的那一瞬间会得出错误答案:
static bool TimeReached(uint32_t now, uint32_t due)
{
return ((int32_t)(now - due) >= 0);
}
两个无符号数相减,再当成有符号数判断是否大于等于零,只要两次比较相隔不超过 2 的 31 次方毫秒(约 24.8 天),就不会判断错误。各内核判断 tick 是否到期用的也是这个办法。所以在要处理回绕的代码里,要么统一记绝对时刻,要么统一记剩余时间,不要混着用。
周期任务要以上次计划时刻为基准。 如果每次都用当前时刻加上间隔重新安排,误差会一次次累积,运行久了任务的实际执行时刻会持续偏移。正确做法是以上次计划时刻为基准往前推;如果已经错过好几个周期,这一轮只执行一次,然后把计划时刻直接推到未来的最近一个周期,既避免偏移,也避免集中补做。
还有一点要注意:判断到期的动作要放在等待之后。如果先判断、再去等待,那么被等待唤醒的任务要等到下一轮才被执行,这一次唤醒就浪费了。正确顺序是先等到事件返回,重新取一次当前时间,再判断哪些任务到期。
4.3 会让时间安排失准的操作
时间安排能做到多准,取决于一次调用里有多少地方会让线程停下来等。
| 让线程停下的原因 | 具体表现 | 造成什么结果 |
|---|---|---|
| 网络连接默认是阻塞的 | 对方接收窗口满了,发送就卡住 | 线程被卡在这里,控制命令无法及时响应 |
| 发不出去就延时重试 | 发送失败后先睡一小会儿再试 | 一次调用要花多久无法预估 |
| 不等直接反复查询 | 发送受上限打断后立刻再查一次 | 处理器空转,影响其他任务 |
| 回调内部等待 | 取画面时等待编码器或互斥锁 | 问题藏在看起来普通的函数调用里 |
| 读文件 | 从存储卡读取样片 | 存储慢的时候会挡住控制命令的处理 |
这几行的共同点是:凡是可能让线程停下来等的地方,要么设成不等待,要么给它一个明确的上限。第 6 节会把同一个思路再用一次。
5. 一帧画面被多个客户端共用
画面这块内存什么时候能还给画面来源,是一轮处理里必须交代清楚的事。
给多个客户端分发画面有两种做法:各自去取,或者只取一次再分给大家。相机设备普遍选后一种,因为编码器和读取通道通常是独占的,多个客户端同时去读会互相干扰,重复复制像素也浪费内存带宽。
这样一来,内存归谁管这件事就变复杂了。同一块内存被多个客户端引用,只要有一方提前让它复用,其他客户端就会读到已经被改写的内容。由此得出两条规则:
- 分发和发送的先后不能交换:必须先把帧信息分给所有客户端,再开始发送,不能拆成两个互不相关的定时任务;
- 所有客户端都用完才能复用:最后用完的那个客户端发送完成后,这块内存才能还给画面来源;如果要把帧数据留到下一轮再用,就需要记录引用次数或者复制一份。
一帧从产生到复用的过程:
flowchart LR
A["编码器完成一帧
交出内存地址"]:::hw --> B["服务读取一次
得到帧信息"]:::sw
B --> C["把帧信息分给
各个客户端"]:::sw
C --> D1["客户端 1
发送视频包"]:::cl
C --> D2["客户端 2
发送视频包"]:::cl
C --> D3["客户端 3
发送视频包"]:::cl
D1 --> E{"所有客户端
是否都用完"}:::chk
D2 --> E
D3 --> E
E -->|"还有一个
没用完"| F["这块内存
不能复用"]:::bad
E -->|"全部用完"| G["还给画面来源
可以写入下一帧"]:::good
classDef hw fill:#f8d7da,stroke:#dc3545,color:#721c24;
classDef sw fill:#cce5ff,stroke:#0d6efd,color:#084298;
classDef cl fill:#fff5e6,stroke:#e65100,color:#4a2400;
classDef chk fill:#e2d9f3,stroke:#6f42c1,color:#3b2a6b;
classDef bad fill:#f8d7da,stroke:#dc3545,color:#721c24;
classDef good fill:#d4edda,stroke:#28a745,color:#155724;
图 4:一帧从产生到复用的过程。只要还有一个客户端没发完,这块内存就不能被下一帧覆盖。
引用计数要配一个回收点:谁把它减到零,谁就负责把内存还回去。固定容量对象池里“只传指针、定点回收”的写法可以直接照搬,见已发的 零堆分配与零拷贝事件传递。
还有一关:缓冲区落在可缓存区间里的时候,CPU 写进去的内容要先写回,DMA 才看得见;反过来,DMA 写进来的新帧,CPU 读之前要先让缓存行失效。漏一次失效,画面里可能混进上一帧的内容,而且不一定稳定复现。代价和边界见已发的 DMA 物理内存映射与 FPGA 环形缓冲。
内存很小时怎么用缓冲区
嵌入式 RTSP 实现几乎都用静态缓冲区。开源的 MCU 版实现 Micro-RTSP 用一个固定大小的缓冲区组装数据包,再把同一份数据发给所有客户端。这能成立有两个前提:只有一个线程在发送,且所有客户端看到的内容一样。一旦改成多线程发送,或者不同客户端画面不同,就必须让每个连接各持一份。
6. 一条连接上只能有一份发送顺序
发出去的时候还有一道坎。RTSP 有一种传输方式,把视频包直接封装在控制连接里,格式由 RFC 2326 规定:
+--------+---------+--------+-------------+
| 0x24 $ | channel | length | 视频或报告包 |
+--------+---------+--------+-------------+
1 字节 1 字节 2 字节 长度不固定
控制命令的回复、视频包、接收报告三种数据共用同一条数据流,每次写入都必须完整。如果让每个视频轨道各自排队、各自写,数据流里就会出现交叉,接收端无法再把包分开。
正确做法是每条连接只保留一份发送顺序:所有要发的内容都排在这一个队列里,队列记录当前发到哪、总共多长、已经发出多少。FFmpeg 的 RTSP 输出就是这样,先把包写到临时缓冲,再统一加上帧头一次写出;开源项目 oximedia-net 则让控制回复和视频写入共用同一把锁,避免封装帧在报文中间被切开。
发不完的时候怎么办
把连接设为非阻塞之后,发不进去时函数立刻返回,不让线程干等。这次发出去多少就记住多少,下次从断点接着发。
typedef struct {
const uint8_t *data; /* 要发送的数据在哪里 */
uint32_t len; /* 一共多长 */
uint32_t offset; /* 已经发出去多少字节 */
bool active; /* 是否还有数据没发完 */
} PendingTx;
发送函数的返回值要分开处理:实际发出字节数小于请求长度,说明只发出去一部分;返回零,说明对方已经关闭;返回负数,才需要看错误码判断是暂时发不进去,还是连接真的出问题了。
这种情况有个专门的名字:短写。发送缓冲区满了,一次 send 只放进去一部分,剩下的等下次。这里最容易犯的错是只记住断点,却忘了把“可以写入”重新登记进等待条件,结果剩下的数据一直等不到机会。成因与处理在已发的 TCP 非阻塞发送的 Short Write 问题 里有完整分析。
还有一条内存规则:要发送的数据必须活得比函数调用更久。控制命令的回复不能用函数内部的临时数组,函数一返回这块内存就没了,而没发完的数据可能要到下一次调用才接着处理。
TCP 连接还有一条和 UDP 不一样的性质:不能靠丢掉一个包让上层重传,丢掉任何一个字节都会让对方的解析出错。开源项目 rtp2httpd 遇到客户端处理不过来时,采用的办法是暂停读取上游数据:积压到设定的高比例就停止读取,让 TCP 自己的接收窗口去压制发送方;回落到低比例再恢复。这种办法不破坏数据流的完整性。
7. 和服务器上的 RTSP 服务相比,差在哪
同样对外提供 RTSP,放在 x86 服务器上和放在相机芯片里,差别不只是性能高低。
flowchart LR
subgraph SRV["Linux x86 服务器"]
direction TB
A1["独立进程
由系统服务管理"]:::srv
A2["epoll 等待
可用多核多线程"]:::srv
A3["内核网络栈
缓冲区和连接数都大"]:::srv
end
subgraph CAM["单芯片相机"]
direction TB
B1["依附调用线程
服务自己不建线程"]:::cam
B2["select 或 poll 等待
单线程处理"]:::cam
B3["用户态网络栈
缓冲区和连接数都小"]:::cam
end
A1 -.->|"遵守同一套协议
运行方式完全不同"| B1
classDef srv fill:#cce5ff,stroke:#0d6efd,color:#084298;
classDef cam fill:#fff3cd,stroke:#ffc107,color:#856404;
图 5:左侧是服务器上的运行方式,右侧是相机里的运行方式。协议相同,运行条件差别很大。
| 对比项 | Linux x86 服务器 | 单芯片相机 |
|---|---|---|
| 运行形态 | 独立进程,由系统服务管理器启动和重启 | 依附调用方线程,随应用一起运行 |
| 等待机制 | epoll,内核直接给出就绪的连接 | select 或 poll,逐个检查 |
| 内存与传递 | 独立地址空间,按需分配,容量按 GB 计;跨进程至少一次拷贝 | 地址空间平坦,容量按百 KB 计,多数编译期确定;可以直接传地址 |
| 时间精度 | 纳秒级单调时钟 | 依赖系统节拍,通常毫秒级 |
| 出错之后 | 只影响本进程,可重启;有调试器、性能分析、崩溃转储 | 可能影响整机,复位原因靠寄存器或日志;主要靠串口 |
| 协议范围 | 现成组件多,支持音频、多路视频、组播和认证 | 按需实现的子集,只覆盖播放所需的命令和编码格式 |
7.1 隔离与拷贝,只能选一个
服务器上的 RTSP 服务是一个独立进程,由系统服务管理器启动,可以单独重启、单独升级、单独限制资源。它崩溃时只影响自己,操作系统负责回收资源,其他服务不受牵连。
相机上的服务没有这层隔离,它和图像采集、编码、显示等模块在同一颗芯片、同一块内存上。一个越界写、一次空指针访问都可能让整机复位。也正因为没有进程边界,画面数据才能以地址的形式直接交给发送模块,省掉一次跨进程拷贝。隔离和拷贝只能选一个。
数据怎么传也随之不同。服务器上画面来自另一个进程,无论走管道还是网络,都要经过内核缓冲,至少一次拷贝;想避免拷贝就要用共享内存,但随之要处理同步。相机上所有模块在同一地址空间,画面数据可以直接以地址形式传递,只有一块内存被多方同时引用,因此需要约定好在什么时候可以复用。
7.2 等待机制决定设计重点
服务器的等待机制建立在 epoll 上。内核维护一份就绪列表,调用一次就能拿到所有就绪的连接,开销与就绪数量相关,连接数可以从几十扩到几万;并发上可以用多线程加多核,把不同连接分散到不同核心。
相机的等待机制建立在 select 或 poll 上,每轮都要把所有关注的连接检查一遍,开销与连接总数成正比,上限通常是十几到几十,并发上是单线程顺序处理。
所以两边优化的方向不一样:服务器可以把复杂度花在怎么调度大量连接上,相机只能花在怎么让一轮处理的时间可预估上。
7.3 时间精度
服务器可以取到纳秒级的单调时间,这个时间不会因为系统对时被调整,位数也足够宽,正常使用不会回绕,定时任务可以做到亚毫秒级精度。
相机的时间来自系统节拍,通常是 1 毫秒或 10 毫秒一次,误差下限就是这个节拍。计时一般是 32 位毫秒计数,大约 49.7 天回绕一次,所以比较时间要用差值,不能直接比大小(见第 4.2 节)。相机上只能保证“不早于”某个时刻,实际执行会晚一个节拍以内。
8. 参考资料
| 主题 | 来源 |
|---|---|
| 把视频包封装在控制连接里的格式 | RFC 2326 第 10.12 节 |
| 封装后统一发送的实现 | FFmpeg libavformat/rtspenc.c |
| 客户端处理不过来时暂停读取上游 | rtp2httpd 项目 PR #465 |
| 内存受限设备上的 RTSP 实现 | Micro-RTSP 项目 |
| 用管道配合等待接口的网络编程结构 | RT-Thread 官方应用笔记 AN0019 |
| 等待集合容量与连接数的关系 | lwIP src/include/lwip/sockets.h 的 FD_SETSIZE 定义 |