1. 为什么要把 GB28181 和 RTSP 揉进同一个网关
1.1 安防视频接入的生态现状:每个摄像头都在说自己的方言
上个季度我在做智慧园区项目时,现场有四十多路摄像头,问题一下子暴露出来了。海康的老一代 DS 系列,用 RTSP 拉流经常会话超时甚至被设备主动断开;宇视和一部分第三方厂家设备只能走 GB28181 国标;还有几路是工地临时装的无线摄像头,只给了一个 rtsp:// 地址,用户名密码还得找包工头要。更麻烦的是,后续要上安全帽检测和区域入侵算法,算法部门只认 RTSP 拉流,不认 GB28181。
这时候你会发现一个很尴尬的现实:GB28181 和 RTSP 不是简单的两个协议,而是两套截然不同的生态。GB28181 是面向大规模监控系统设计的,信令走 SIP,媒体走 RTP,强调平台与平台之间、平台与设备之间的标准化对接;RTSP 则是流媒体领域的事实标准,单路拉流、播放、推流都很方便,但没有统一的设备管理模型。两者之间存在明显的“协议孤岛”。
所以这个项目的核心目标很简单:做一个网关,对上层 AI 引擎和业务平台暴露统一接口,对下层同时支持 GB28181 和 RTSP 接入。说白了,就是让算法部门不用关心摄像头是海康还是大华、是国标还是 RTSP,只要给我一路连续的视频流就行。
1.2 统一网关要解决的问题边界
架构设计的第一步,其实是划边界。我在一开始就跟团队明确了:这个网关不做录像存储,不做大规模转码,不替代现有的监控平台。它只做四件事:
- 协议接入:把 GB28181 设备和 RTSP 流源统一接进来。
- 媒体处理:解封装、解码,输出标准视频帧。
- AI 调度:把帧分发到推理引擎,回收识别结果。
- 事件输出:把告警、结构化数据推送给下游业务系统。
边界一旦明确了,很多技术选型就顺了。比如不做录像存储,那就意味着不需要跟本地磁盘 IO 掰扯;不做大规模转码,那内存和 CPU 的消耗就能控制在可接受的范围内。把边界划清楚,比上来就画一个大而全的架构图重要得多。
1.3 两种接入方式的本质差异对比
我在设计前先拉了一张表,把两种协议从信令、媒体、运维、调试工具四个维度做了对比,目的是让团队对后面的模块拆分有共同语言:
| 对比维度 | GB28181 | RTSP |
|---|---|---|
| 信令方式 | SIP 信令,复杂状态机 | RTSP 指令,相对轻量 |
| 媒体传输 | RTP/RTCP,兼顾 TCP/UDP | RTP over UDP/TCP |
| 设备管理 | 有完整目录、心跳、报警机制 | 无设备管理模型 |
| 接入模式 | 设备主动注册,平台被动等待 | 平台主动拉流 |
| 典型适用场景 | 公安、园区、大型监控平台 | 单路/小规模流媒体消费 |
| 调试工具 | Wireshark + SIP 插件 | VLC/FFprobe 可直接验证 |
从这张表可以明显看出,两者是互补的。GB28181 适合规模化接入,RTSP 适合快速整合零散流源。网关存在的价值,就是把这套差异在内部消化掉,对外不要暴露给上层。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 网关总体模块划分与数据流转链路
2.1 四层模块架构
整个网关我设计了四层,每一层只跟相邻层通信。这个模块划分直接参考了我在嵌入式媒体网关项目里常用的分层习惯:
- 接入层(Access Layer):GB28181 SIP 信令服务、RTSP 拉流客户端。这一层只负责把“外部的流”变成“网关内部的会话”,不关心 AI。
- 会话层(Session Layer):管理每路视频流的生命周期,负责流的注册、保活、重连、断开。这一层为上层提供统一的“流通道”抽象,屏蔽底层协议差异。
- 媒体处理层(Media Layer):解封装、解码、帧格式统一。GB28181 的 PS 流和 RTSP 的 H.264/H.265 裸流,到这里都变成统一的 AVFrame 或自定义 Frame 结构。
- 分析层(Analysis Layer):帧调度、AI 推理、事件判定、结果推送。
这四层有一个硬性约束:上层不能直接访问下层的协议细节。比如分析层拿到的帧,不能感知这个帧是来自 GB28181 还是 RTSP。为此我专门定义了一个数据模型,往下看。
2.2 核心数据结构设计:一切从 Channel 开始
为了让两类协议在一个模型下工作,我设计了三个核心结构体,项目代码里我用的 C++,定义大概是这样的:
cpp复制// 视频通道抽象,对应一路可分析的视频源
struct Channel {
std::string channel_id; // 网关内部唯一 ID
enum SourceType { GB28181, RTSP } source_type;
std::string device_id; // GB28181 的 DeviceID 或 RTSP 的设备标识
std::string name;
bool enabled;
// 连接参数
union {
struct {
std::string sip_server_ip;
int sip_server_port;
std::string device_ip;
std::string device_port;
} gb;
struct {
std::string rtsp_url;
std::string username;
std::string password;
} rtsp;
} conn_param;
// 分析参数
int analysis_fps; // 每秒最多分析多少帧
int skip_strategy; // 抽帧策略
};
这里最关键的一点是 Channel 把“物理摄像头”和“业务需要分析的路数”解耦了。比如 GB28181 的一个设备带两路视频,那它对应两个 Channel。RTSP 的一路流,也只对应一个 Channel。上层 AI 引擎拿到 Channel 列表后,不会感知到协议差异。
2.3 数据流的完整路径:从摄像头到 AI 结果
网关的数据流转我用一条链路说明,这条链路是设计文档的核心,也是后面排障的依据:
- 接入层收到媒体数据。GB28181 的 RTP 包被 SIP 会话接收,RTSP 的 RTP 包被拉流客户端接收。
- 会话层把数据包交给媒体处理层。这里不做格式转换,只是路由转发。
- 媒体处理层完成解封装。GB28181 的 PS 流被解析成 H.264/H.265 的 Access Unit;RTSP 的 H.264/H.265 流经过 depacketize 后同样变成 Access Unit。
- 解码器输出原始帧。H.264/H.265 解码后输出的 YUV 帧进入帧缓冲队列。
- 分析层的帧调度器按策略抽帧,把需要推理的帧送入 AI 引擎。
- AI 引擎输出结构化事件(例如检测到人员、车辆),通过消息队列推送给下游业务平台。
这条链路里最容易忽略的是“帧调度”和“丢帧策略”。后面我会专门讲。
3. GB28181 信令与媒体模块的源码级拆解
GB28181 这部分是最容易让人头秃的。很多同学一听到 SIP 就懵,其实把它拆开就两部分:信令和媒体。下面我分别拆。
3.1 SIP 信令状态机与关键交互流程
GB28181 的信令基于 SIP,但跟运营商 VoLTE 那套 SIP 不完全一样,它有自己的扩展头域和交互流程。简单来说,作为网关(SIP Server),你需要处理这几个核心交互:
- 设备注册:设备向网关发
REGISTER,网关返回200 OK。注册周期内设备会定时发心跳。 - 目录查询:网关发
MESSAGE(带目录查询 XML),设备返回通道列表。 - 实时音视频点播:网关发
INVITE,设备返回200 OK,携带 SDP 描述媒体信息,然后网关回ACK,后续 RTP 媒体流就开始传输。 - 语音对讲:网关发
INVITE,SDP 里描述 audio 媒体,设备作为音频接收方,网关作为发送方。 - 设备注销:设备发
REGISTER,Expires: 0,网关释放资源。
在我们的源码工程里,SIP 信令部分基于开源的 eXosip 库做了二次封装。核心逻辑是注册一个回调函数,处理不同类型的请求消息。我贴一段简化后的处理逻辑:
cpp复制void handle_sip_event(eXosip_event_t *ev) {
switch (ev->type) {
case EXOSIP_REGISTRATION_SUCCESS:
// 设备注册成功后,更新通道状态
channel_registry->on_device_online(ev->rid, ev->from);
break;
case EXOSIP_CALL_INVITE: {
// 收到点播请求,说明设备要往我们这边推流
SdpMessage sdp(ev->message_body);
stream_session_mgr->establish_session(ev->cid, sdp);
// 回 200 OK + ACK
eXosip_call_send_answer(ev->tid, 200, create_200_answer(sdp));
eXosip_call_ack(ev->cid);
break;
}
case EXOSIP_CALL_MESSAGE_ANSWER:
// 收到目录查询的应答 XML
parse_catalog_xml(ev->message_body);
break;
default:
break;
}
}
这里有个很重要的经验:很多设备在收到 INVITE 之后不会立刻推流,而是要求你在 ACK 之后等待一段时间。如果网关处理 ACK 的时机不对,容易出现“设备答应推流但迟迟不来 RTP”的问题。我建议在 ACK 之后开启一个 3 秒的定时器,如果超时没有 RTP 到达,就重新发一次 INVITE。这个重发逻辑在项目里解决了十几台大华设备的兼容问题。
3.2 媒体接收与 PS 流解复用:绕不开的硬骨头
GB28181 的媒体流不是裸的 H.264,而是 RTP 封装 PS 流。PS 流里又封装了 H.264/H.265 视频和 G.711/AAC 音频。要做这一步,要么用 FFmpeg 的 mpegts demuxer 试一下,要么自己写一个轻量 PS 解复用。
我这边实际采用的是自研 PS 解复用。原因不是 FFmpeg 不行,而是国标 PS 流有些实现不太标准,FFmpeg 直接探测时经常失败或产生解码延迟。自研解复用器的核心逻辑其实不复杂:PS 流以 00 00 01 BA 开头是 Pack Header,以 00 00 01 BB 开头是 System Header,以 00 00 01 E0 开头是 PES 包。把 PES 里的 H.264 NALU 提取出来,去掉 PES 头,组装成解码器需要的 Annex-B 格式即可。
核心代码片段:
cpp复制// 简化版 PS 解析:输入 RTP payload,输出 H.264 Access Unit
bool PsDemuxer::parse(const uint8_t *rtp_payload, size_t len,
vector<AccessUnit> *out_au) {
size_t offset = 0;
while (offset + 4 <= len) {
if (rtp_payload[offset] == 0x00 &&
rtp_payload[offset + 1] == 0x00 &&
rtp_payload[offset + 2] == 0x01) {
uint8_t stream_id = rtp_payload[offset + 3];
if (stream_id == 0xBA) {
offset += parse_pack_header(rtp_payload, len, &offset);
} else if (stream_id == 0xE0 || stream_id == 0xE1) {
// 视频 PES 包
offset += parse_pes_header(rtp_payload, len, &offset);
// 提取 PES payload,去掉起始码后拼到 H264 缓冲
collect_h264_nal(rtp_payload, len, &offset, out_au);
} else {
break; // 其他流,跳过
}
} else {
offset++;
}
}
return !out_au->empty();
}
这里有一个容易踩的坑:PS 包的 RTP payload 有些设备会拆分成多段,如果网关端没有做 RTP 重排序和缓存,很容易出现花屏或者解码器报错。我在会话层专门维护了一个 RTP 缓存队列,按 sequence number 排序,攒够一帧完整数据再交给上层。
3.3 语音对讲的关键链路:不只是拉流,还要送音
GB28181 的语音对讲功能在安防场景里很实用。网关先发 INVITE,SDP 里声明音频方向是 sendonly 或者 sendrecv,设备应答后,网关通过 RTP 把 G.711A 音频数据推给设备。
我这边把语音对讲与 AI 联动做了一个很实用的功能:当 AI 检测到区域入侵时,网关自动向对应通道的语音对讲会话发送“警告音”,这个警告音是预先编码好的 G.711 音频文件。实现上不复杂,但要保证音频 RTP 的时间戳单调递增,否则对讲会有杂音。
cpp复制void AudioTalkSession::send_audio(const uint8_t *g711_data, size_t len) {
uint32_t rtp_ts = last_rtp_ts_ + 320; // 每包 20ms,采样率 8000Hz
rtp_sender_->send_payload(channel_id_, kPayloadTypePCMA, rtp_ts,
g711_data, len);
}
4. RTSP 拉流模块的源码级拆解
如果说 GB28181 是“设备主动找你”,那 RTSP 就是“你主动找设备”。这部分的复杂度比 GB28181 低,但坑也不少,尤其是断线重连和传输方式选择。
4.1 核心拉流生命周期:从 URL 到 AVFrame
RTSP 客户端我直接基于 FFmpeg 的 libavformat 封装。坦白讲,自己用 RtspClient 写一套状态机也不是不行,但 FFmpeg 已经帮你处理好了 RTSP over TCP/UDP、认证、RTP depacketization 这些脏活,没必要重复造轮子。核心代码:
cpp复制// 创建拉流线程
void RtspPuller::start(const std::string& url) {
AVDictionary *opts = nullptr;
// 设置超时 5 秒
av_dict_set(&opts, "rtsp_transport", transport_.c_str(), 0);
av_dict_set(&opts, "stimeout", "5000000", 0); // 微秒
av_dict_set(&opts, "max_delay", "500000", 0);
if (avformat_open_input(&fmt_ctx_, url.c_str(), nullptr, &opts) != 0) {
on_error("open stream failed");
return;
}
if (avformat_find_stream_info(fmt_ctx_, nullptr) < 0) {
on_error("find stream info failed");
return;
}
// 找到视频流
for (unsigned i = 0; i < fmt_ctx_->nb_streams; i++) {
if (fmt_ctx_->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_stream_index_ = i;
break;
}
}
// 循环读帧
while (!stop_flag_) {
AVPacket packet;
int ret = av_read_frame(fmt_ctx_, &packet);
if (ret < 0) {
on_read_frame_error(ret);
break;
}
if (packet.stream_index == video_stream_index_) {
decode_mgr_->feed_packet(&packet);
}
av_packet_unref(&packet);
}
}
4.2 传输方式选型:TCP 优先还是 UDP 优先
RTSP 支持 RTP over UDP 和 RTP over TCP 两种传输模式。从项目经验看:
- RTP over UDP:延迟更低,适合局域网环境,但跨网段、跨 NAT 时容易丢包。
- RTP over TCP:靠 TCP 保证包的顺序和完整性,丢包时不会花屏,但有可能累积延迟。在公网拉流时,TCP 几乎是唯一可靠的选择。
我在网关里做了一个策略:局域网优先 UDP,公网优先 TCP,同时允许通过配置文件强制指定。很多第三方摄像头 RTSP 实现得一般,UDP 方式下如果设备长时间待机后重新推流,会出现 RTP 时间戳跳跃的问题,这时候切换到 TCP 反而更稳定。
cpp复制std::string build_transport_opt(const Channel& ch) {
if (ch.conn_param.rtsp.transport == "tcp") {
return "tcp";
} else if (ch.conn_param.rtsp.transport == "udp") {
return "udp";
}
// 默认:局域网 UDP,公网 TCP
return is_lan_address(ch.conn_param.rtsp.rtsp_url) ? "udp" : "tcp";
}
4.3 断线重连:指数退避 + 状态透出
RTSP 摄像头经常因为网络波动、设备重启、连接数超限而断开。断线后如果不重连,AI 分析就断了;如果重连太频繁,又容易把设备搞挂。我实现了一个带指数退避的重连逻辑,并且把连接状态实时上报给上层业务平台,这样运维人员不打开后台也能第一时间知道哪路流断了。
cpp复制void RtspPuller::on_read_frame_error(int ret) {
if (ret == AVERROR_EOF || ret == AVERROR_EXIT) {
// 正常结束,不重连
return;
}
int retry_count = 0;
int max_retry = config_.max_retry_count;
while (!stop_flag_ && retry_count < max_retry) {
int delay_seconds = 1 << std::min(retry_count, 5); // 1,2,4,8,16,32
status_reporter_->report(channel_id_, "reconnecting", delay_seconds);
std::this_thread::sleep_for(std::chrono::seconds(delay_seconds));
if (start(url_)) {
status_reporter_->report(channel_id_, "online", 0);
return;
}
retry_count++;
}
status_reporter_->report(channel_id_, "offline", 0);
}
这里有个细节:重连之前一定要强制释放旧的 AVFormatContext,有些版本的 FFmpeg 在重连时如果残留旧连接,会导致内存泄漏。我在 start() 函数开头加了 if (fmt_ctx_) avformat_close_input(&fmt_ctx_); 来保证干净。
4.4 多路拉流与全局连接数控制
如果一次性拉起几十路 RTSP,设备的并发连接数是扛不住的。海康某些机型限制单路最多 6 路主码流,大华也有类似限制。我在网关里加了一个全局信号量,控制每台设备的拉流数上限。超过上限的请求排队等待,而不是直接拒绝。
cpp复制class DeviceConnLimiter {
public:
bool try_acquire(const std::string& device_id) {
std::lock_guard<std::mutex> lock(mu_);
auto& count = conn_count_[device_id];
if (count >= max_conn_per_device_) return false;
count++;
return true;
}
void release(const std::string& device_id) {
std::lock_guard<std::mutex> lock(mu_);
conn_count_[device_id]--;
}
};
5. AI 推理引擎与网关的解耦设计
5.1 从解码帧到推理输入:内存管理是第一关
视频帧在网关内部不能到处拷贝。一路 1080p 的 YUV420P 帧大概 3MB,如果每路视频每秒 25 帧全走 memcpy,CPU 会被白白吃掉。我在媒体处理层设计了一个帧池,预分配固定数量的帧缓冲,解码器直接把数据写进缓冲区,分析层拿着缓冲区做推理,用完之后归还。
帧池结构:
cpp复制class FramePool {
public:
struct FrameBuffer {
uint8_t *data;
size_t capacity;
std::atomic<int> ref_count;
int width, height;
int64_t pts;
std::string channel_id;
};
FrameBuffer* acquire() {
// 从空闲队列取,如果不够则按需增长
}
void release(FrameBuffer* fb) {
// ref_count 减到 0 后放回空闲队列
}
};
5.2 多路并发调度与丢帧策略
AI 推理的算力不是无限的。一块 GPU 可能只能同时跑 3 路视频分析,但网关接了 40 路。所以分析层必须做两件事:抽帧和任务排队。
我的策略是:
- 每路视频默认只允许每秒分析 2 帧(
analysis_fps=2),如果有特殊需求可以单独调高。 - 帧调度器维护一个全局合并队列,按
channel_id分组,每路只保留最近的一帧。如果 GPU 没空,新的帧进来就把旧帧顶掉,保证不会无限堆积。 - GPU 推理请求尽量做 batch 合并,把同时到达的多路帧拼成一个 batch 推理,吞吐量成倍提升。
伪代码:
cpp复制void FrameScheduler::feed(FrameBuffer* fb) {
auto& slot = latest_frame_[fb->channel_id];
if (slot && slot->ref_count > 0) {
// 上一帧还没被推理,直接丢弃
frame_pool_->release(slot);
}
slot = fb;
// 唤醒推理线程
cond_.notify_one();
}
void InferenceWorker::run() {
while (!stop_) {
std::vector<FrameBuffer*> batch;
{
std::unique_lock<std::mutex> lock(mu_);
cond_.wait(lock, [&] { return !latest_frame_.empty(); });
// 收集 batch,一次最多 4 路
for (auto& [ch, fb] : latest_frame_) {
batch.push_back(fb);
if (batch.size() >= 4) break;
}
for (auto fb : batch) {
latest_frame_.erase(fb->channel_id);
}
}
auto results = infer_engine_->run(batch);
publish_events(results);
for (auto fb : batch) frame_pool_->release(fb);
}
}
5.3 事件输出协议与下游对接
AI 识别结果不能仅仅是一个内部回调,要能被业务系统消费。我在网关里定义了一套统一的事件格式,用 JSON 表示:
json复制{
"channel_id": "ch_0001",
"event_type": "intrusion",
"timestamp": 1699999999,
"bbox": [112, 98, 530, 640],
"confidence": 0.93,
"thumbnail": "/data/thumbnails/2025/11/15/ch_0001_1699999999.jpg"
}
事件通过三路对外输出:Kafka 给数据中台、Webhook 给业务平台的 HTTP 回调、WebSocket 给前端实时预览。这样设计是为了兼容不同团队的消费习惯。之前有个项目,算法组一定要吃 Kafka,业务平台只肯接 HTTP 回调,前端又需要 WebSocket 实时弹窗,三路同时输出最省事。
6. 真实项目排障记录与性能调优
6.1 国标设备接入的兼容性排查
这个项目里最折腾的一台设备是某二线厂商的 IPC,GB28181 注册成功、目录拉取成功,但是点播时设备返回 403。我开始以为是 SIP 鉴权问题,抓包看后发现,设备的 INVITE 请求里带了 Require: sec-agree,这是它们私有扩展头域。网关没有处理这个头域,设备拒绝推流。
解决办法是在 SIP 响应里透传 Require 头,并把 SDP 的 y= 字段严格按国标格式填充。这个问题查了将近一天,最后靠 Wireshark 对着国标文档一行一行比对才定位。所以做国标网关,一定要熟悉抓包工具和 SIP/SDP 报文格式,光看日志很难发现这类问题。
另一个高频问题是 REGISTER 的摘要鉴权。很多设备的密码不是明文传输,而是 MD5 摘要,国内部分设备对 username 的拼接方式非常敏感。网关端如果 username 是按 SIP URI 格式拼接的,设备每次校验都失败。我的处理方式是提供一个兼容开关,允许对特定设备使用“裸用户名”做摘要。
6.2 RTSP 拉流的资源泄漏排查
上线一段时间后,我发现内存占用缓慢上涨,两个礼拜稳定增长 20%。排查后发现是 RTSP 重连时,旧会话的 RTP depacketizer 缓存没有完全释放。FFmpeg 的 avformat_close_input() 会释放大部分资源,但我自己封装的 RTP 重组队列没有清理干净。
解决方案:在 stop() 函数里显式清空所有中间缓存,并且用 Valgrind 跑了三轮完整的“拉起-断开-拉起”流程,确保没有泄漏后再上线。
6.3 性能瓶颈定位与优化
网关的性能瓶颈主要在两个地方:解码和帧拷贝。针对不同情况我做了几轮优化:
- 硬解优先:如果部署机器有 Intel Quick Sync 或者 NVIDIA NVDEC,优先用硬解。软解 20 路 1080p 几乎能把一颗 8 核 CPU 吃满,硬解可以降到很低的占用率。
- 避免 RGB 转换:AI 模型一般要 RGB 输入,但解码器输出是 YUV。不要在解码线程做转换,把转换放到推理之前,避免占用解码线程时间。
- 内存池化:上面提到的 FramePool 一定要做。没有内存池时,一路 1080p 视频的帧反复分配释放,会产生大量内存碎片。
6.4 网关如何支撑后续扩展
协议这一层天然就是可扩展的。你接入 ONVIF、私有 SDK,本质上都是再做一次“协议适配器”,对上层暴露的 Channel 接口完全不用改。目前这个网关架构已经支持 GB28181、RTSP,后续如果要做 ONVIF 或者 WebRTC 接入,只需要在接入层新增一个模块,复用在会话层之上的所有逻辑。
我个人在实际操作中的体会是:做视频接入网关,真正的复杂度不在某个协议本身,而在多个协议共存时的抽象能力和排障能力。尤其是国标设备,厂商实现各有各的“特殊”和“私有”,离开了抓包工具和细致的状态机设计,基本寸步难行。RTSP 部分虽然标准化程度高,但设备连接数限制、断线重连策略这些细节,才是决定网关稳不稳定的关键。
最后再分享一个部署层面的小技巧:网关接入的媒体端口最好做一个固定范围的分配规划。GB28181 的 RTP 端口、RTSP 拉流的本地端口、事件回调的 HTTP 端口、WebSocket 端口,分开管理,不要全部用随机端口。这样方便防火墙和安全组配置,也方便排查对方设备到网关的网络连通性问题。我是从第一次项目半夜断流,远程排查端口半天才发现是随机端口没放行之后,才彻底改了端口规划策略。
