# GB28181Console **Repository Path**: AndroidCoderPeng/GB28181Console ## Basic Information - **Project Name**: GB28181Console - **Description**: 基于 Qt 5.15.2 / C++14 实现的 GB/T 28181-2016 协议推流示例(控制台程序,可作为自启动服务运行,已实现 SIP 自动注册)。功能涵盖:SIP 信令交互、OpenCV 相机采集与预览、FFmpeg 编码、PS 流封装、RTP 发送 PS 流至国标平台(ZLMediaKit)。 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 2 - **Forks**: 0 - **Created**: 2026-06-14 - **Last Updated**: 2026-09-01 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # GB28181Console 基于 **Qt 5 / C++14 / CMake** 实现的 GB/T 28181-2016 协议 **设备侧**推流示例(无界面控制台程序,可作为自启动服务运行)。 功能涵盖:SIP 注册与鉴权、心跳保活、目录/设备信息/状态查询应答、OpenCV 相机采集、FFmpeg H.264 软编码、Qt 音频采集、G.711 编解码、MPEG-2 PS 流封装、RTP over TCP/UDP 推流至国标平台(已验证 ZLMediaKit)、平台语音广播(对讲)接收与播放。 --- ## 一、技术栈 | 类别 | 依赖 | 用途 | |----------|-------------------------------------------|-----------------------------------| | 应用框架 | Qt 5(`Core` / `Network` / `Multimedia`) | 事件循环、线程、音视频采集与播放 | | SIP 信令 | eXosip2 / osip2 | REGISTER、MESSAGE、INVITE/ACK/BYE | | 视频采集 | OpenCV(`VideoCapture` + V4L2) | 相机取帧(`cv::Mat` BGR) | | 视频编码 | FFmpeg `libavcodec` + `libswscale` | BGR→YUV420P、H.264 软编码 | | 音频 | Qt `QAudioInput` / `QAudioOutput` | 8kHz/16bit/单声道 PCM 采集与播放 | | XML | pugixml(已内置于 `3rdparty/`) | MANSCDP+xml 消息解析 | | 构建 | CMake ≥ 3.10,C++14 | 单一可执行文件 `GB28181Console` | ## 二、编译与运行 ```bash mkdir -p build && cd build cmake .. make -j$(nproc) ./GB28181Console ``` > ⚠️ 关键提示:SIP 参数目前 **硬编码**在 `ConsoleApplication::initSipManager()` 中(`sip/SipManager` 的参数入口为 > `SipParameter`),接入自己的平台前需先修改以下字段: > `localHost`(本机 IP,会写入 SDP 的 `c=` 行)、`serverHost` / `serverPort`(平台 SIP 地址)、`serverCode` / `serverDomain`、 > `deviceCode`、`deviceName`、`password`、`longitude` / `latitude`。 ## 三、目录结构 ``` GB28181Console/ ├── main.cpp 程序入口 ├── ConsoleApplication.{hpp,cpp} 顶层装配:线程、模块接线、状态机 ├── GlobalDefinition.hpp 全局常量、NALU/Sdp/SipParameter 等数据结构 ├── Logger.{hpp,cpp} 带边框的分级日志(DEBUG/INFO/WARN/ERROR) ├── RtpSender.{hpp,cpp} RTP 封包与 TCP/UDP 发送(单例) ├── video/ │ ├── FrameCapture.{hpp,cpp} OpenCV 相机采集(独立线程 + QTimer) │ ├── FrameEncoder.{hpp,cpp} FFmpeg H.264 软编码(独立线程 + 3 帧覆盖缓冲) │ └── H264Splitter.{hpp,cpp} H.264 格式探测、NALU 切分、AVCC→AnnexB ├── audio/ │ ├── AudioCapture.{hpp,cpp} QAudioInput PCM 采集(独立线程) │ ├── AudioProcessor.{hpp,cpp} G.711 μ-law / A-law 与 PCM 互转 │ ├── AudioReceiver.{hpp,cpp} 语音对讲:TCP 收流 + 独立线程解帧 │ └── RingBuffer.{hpp,cpp} 无锁环形缓冲(读写位置为原子量) ├── muxer/ │ ├── PsMuxer.{hpp,cpp} NALU/G.711 → PES → PS 封装(单例) │ └── HeaderBuilder.{hpp,cpp} PES / System Header / PSM / PS Pack Header + CRC32 ├── sip/ │ ├── SipManager.{hpp,cpp} 信令总控(注册、心跳、事件循环) │ ├── SipContext.{hpp,cpp} eXosip 上下文与 URI 推导 │ ├── RegisterManager.{hpp,cpp}注册状态机(含 401/407 鉴权) │ ├── HeartbeatManager.{hpp,cpp}心跳线程(独立线程) │ ├── StreamManager.{hpp,cpp} 点播推流会话 + 语音广播会话 │ ├── EventDispatcher.{hpp,cpp}事件类型 → Handler 路由表 │ ├── handlers/ Registration/Message/Call/Default 四个事件处理器 │ └── utils/ ResponseSender / SdpParser / XmlBuilder / StateCode / WorkerThread └── 3rdparty/ pugixml ``` ## 四、线程模型 | 线程 | 载体 | 职责 | |------------------|--------------------------------------------|-------------------------------| | 主线程 | `ConsoleApplication`(`QCoreApplication`) | 模块装配、状态机、音频播放 | | 视频采集线程 | `QThread` + `QTimer`(40ms) | `cv::VideoCapture` 取帧 | | 视频编码线程 | `QThread` | swscale 色彩转换 + H.264 编码 | | 音频采集线程 | `QThread` + `QTimer`(10ms) | `QAudioInput` 轮询读取 PCM | | SIP 事件循环线程 | `WorkerThread`(`std::thread`) | `eXosip_event_wait` → 分发 | | SIP 心跳线程 | `WorkerThread`(`std::thread`) | 周期发送 MESSAGE 心跳 | | 音频接收线程 | `QThread`(按需创建/销毁) | 对讲时接收平台下行音频 | 模块之间一律通过 Qt 信号槽(`Qt::QueuedConnection`)通信;视频帧以 `std::shared_ptr` 传递,避免跨线程拷贝。 ## 五、上行推流全链路 ### 1. SIP 注册与保活 1. `SipContext::initialize()`:`eXosip_malloc` → `eXosip_init` → `eXosip_listen_addr(TCP, 5060)`。 > ⚠️ 关键提示:这里的 5060 只是 **信令监听端口**,与媒体流传输方式无关;被占用会导致初始化失败。 2. `RegisterManager::startRegistration()` 发送 REGISTER(`expires = REGISTER_EXPIRED_TIME = 7200`),状态机 `IDLE → SENT_INITIAL`; 3. 收到 **401/407** 视为正常鉴权:`eXosip_add_authentication_info(MD5)` 后重发,状态 `SENT_AUTH`; 4. 收到 2xx:`SUCCESS` → 启动心跳线程 → 对外抛出状态 `1000(注册成功)`; > ⚠️ 关键提示:收到 2xx 时若当前状态不是 `SENT_AUTH`,判定为 **注销**应答(expires=0),走注销回调并清理 eXosip 注册记录。 5. 心跳线程每 `HEARTBEAT_INTERVAL = 30` 秒发送一条携带设备状态的 `MESSAGE`(`XmlBuilder::buildHeartbeat`);注册 ID ≤ 0 时跳过发送。 6. 退出时 `SipManager::shutdown()`:注销 → 停心跳 → 停事件循环 → 销毁 eXosip 上下文。 > ⚠️ 关键提示:停止顺序不可颠倒,心跳回调引用了注册管理器与上下文,必须先于上下文销毁停止。 ### 2. 事件分发 `SipManager::runLoop()` 以 100ms 为粒度调用 `eXosip_event_wait`;空闲时调用 `eXosip_execute` 处理重传与超时,并每 30 秒打印一次空闲循环统计。 `EventDispatcher` 采用 **命令模式 + 注册表**,按事件类型路由: | Handler | 处理事件 | |-----------------------|------------------------------------------------------------------------------------------------------------------------------------------| | `RegistrationHandler` | `REGISTRATION_SUCCESS` / `REGISTRATION_FAILURE` | | `MessageHandler` | `MESSAGE_NEW` / `MESSAGE_ANSWERED` / `MESSAGE_REQUESTFAILURE` | | `CallHandler` | `CALL_INVITE / ANSWERED / ACK / MESSAGE_NEW / CLOSED / RELEASED / NOANSWER / CANCELLED / REQUESTFAILURE / SERVERFAILURE / GLOBALFAILURE` | | `DefaultHandler` | SUBSCRIPTION / NOTIFICATION 系列(仅日志) | > ⚠️ 关键提示:`eXosip_event_wait` 返回的事件必须调用 `eXosip_event_free` 释放,否则内存泄漏。 ### 3. 视频采集与编码 - `FrameCapture`:V4L2 打开 `/dev/video0`,强制 **MJPEG** 格式(`YUV 在 1080P 下会因 USB 带宽不足掉帧`),设定 `1920x1080@25` ,并回读实际生效值(V4L2 常静默降级)。 - `FrameEncoder`:`libx264`,`preset=ultrafast`、`tune=zerolatency`、`gop_size=25`、`max_b_frames=0`(实时流不要 B 帧)、 `bit_rate=4500000`;编码器内部维护容量 3 的覆盖式帧缓冲,采集快于编码时丢弃最旧帧,保证低延迟。 - 输出为 **带起始码的 Annex B** 帧(libx264 默认以起始码分包),因此 PS 封装阶段无需再做格式转换; `H264Splitter::detectFormat()` / `avccToAnnexB()` 作为兼容工具保留。 > ⚠️ 关键提示:H.264 帧必须带起始码(`00 00 00 01` 或 `00 00 01`),否则 PS 封装失败或平台无画面。 ### 4. 音频采集与 G.711 编码 - `AudioCapture`:`QAudioInput`,8kHz / 单声道 / 16bit / 小端 PCM;设备不支持时自动回退到 `nearestFormat`;每 10ms 轮询 `bytesReady()` 并读出全部可用数据。 - `PsMuxer::writeAudioFrame()` 内部调用 `AudioProcessor::pcmToUlaw()` 转 **G.711 μ-law**(`pcmToAlaw` 亦已实现,切换只需改一行)。 > ⚠️ 关键提示:第一个视频 IDR 帧发出前,所有音频帧会被丢弃(`_isIdrSent` 守卫),避免平台"先听到声音却迟迟不出画面"。 ### 5. PS 封装(坑最多的一环) 时间戳统一使用 **90kHz** 时基(`TIMESTAMP_BASE = 90000`): ```cpp // 视频:按帧计数换算(固定帧率) const quint32 pts_90k = _videoFrameCount * (TIMESTAMP_BASE / VIDEO_FPS); // 25fps → 每帧 3600 // 音频:以 8kHz 采样率折算成秒,再换算到 90kHz const double timestamp_sec = static_cast(_audioFrameCount) / 8000; const auto pts_90k = static_cast(timestamp_sec * 90000); ``` > ⚠️ 关键提示:GB/T 28181-2016 要求必须使用 90kHz 时基,否则推流无画面或音画不同步。 `PsMuxer::writeVideoFrame()` 处理流程: 1. `H264Splitter::splitFrame()` 按起始码切分 NALU,缓存 SPS (type 7) / PPS (type 8),归类 IDR (type 5) 与 P 帧 (type 1),丢弃 SEI (type 6); 2. **等待第一个 IDR**:在此之前所有帧直接丢弃,确保平台先收到可解码的关键帧; 3. IDR 帧:组帧为 `[起始码]+SPS` `[起始码]+PPS` `[起始码]+IDR...`,作为 **关键帧**封装,携带 **System Header + PSM**; > ⚠️ 关键提示:SPS/PPS 优先取当前帧内的,取不到才用缓存值;两者都缺失时该 IDR 被丢弃。 4. 非 IDR 帧:`[起始码]+P...`, **不带** System Header 与 PSM; 5. 音频帧:G.711 数据直接作为 PES 载荷, **不带** System Header、PSM 和起始码。 > ⚠️ 关键提示:音频帧一律视为非关键帧,加系统头/PSM 会导致平台解析异常。 包结构: ``` PS 包 = PS Pack Header(14) + [System Header + PSM(仅 IDR)] + PES 包 PES 包 = PES Header + 载荷 ``` ### 6. 分片与 RTP 发送 - PES 载荷超过 `MAX_PES_PAYLOAD_PER_PACKET = 1300` 字节时按 1300 字节切分, **每一片都重建 PES 头**并独立封装为 PS 包;关键帧标志贯穿所有分片,仅最后一片 `is_last = true`(对应 RTP Marker 位)。 - `RtpSender` 在 socket 初始化时向 `PsMuxer` 注册输出回调(未注册回调时 PS 包被丢弃并告警,通常说明会话尚未建立)。 - RTP 头 12 字节:`V=2, PT=96`,序列号随机初始化,SSRC 取自平台 SDP 的 `y=` 字段(按 **十进制**解析,解析失败则回退随机值 `0108xxxxxx`)。 - 传输方式由平台 SDP 的 `m=` 行决定: - **TCP**:设备主动 `connect` 平台(非阻塞 + `select` 5 秒超时),发送时加 4 字节交织头 `$ 0x00 len_h len_l`;`EAGAIN` 时用 `poll` 等待可写(1 秒超时)。 - **UDP**:`sendto` 到 SDP 解析出的地址,发送缓冲区设为 512KB。 - 单包上限:`MAX_RTP_PAYLOAD = 1400`、`MAX_RTP_PACKET = 1412`,超长包直接丢弃。 > ⚠️ 关键提示:务必严格按平台 SDP 协商的 IP/端口/传输协议发送,否则平台会直接丢弃收到的包。 ### 7. SDP 协商 - 解析(`SdpParser::parse`):正则提取 `c=`(IP)、`m=`(媒体类型/端口/协议)、`a=setup:`、`a=rtpmap:`(载荷号 → 编码名)、`y=` (SSRC)。 - 上行应答(`buildUpstreamSdp`):`m=video 9 TCP/RTP/AVP 96` + `a=sendonly` + `a=rtpmap:96 PS/90000` + `y=`。 > ⚠️ 关键提示:上行是 PS 流,内部已包含 H.264 视频与 G.711 音频,因此 **一个 `m=video` 行足以描述整路流**,无需再写 `m=audio`。 - 下行应答(`buildDownstreamSdp`):语音对讲时使用,`m=audio TCP/RTP/AVP 8|0 96` + `a=setup:active` + `a=recvonly` + `f=` 行。 ### 8. 点播推流时序 ``` 平台 --INVITE(SDP)--> 设备 设备:解析 SDP → 初始化 RTP socket → 发送 200 OK(SDP Answer) → 抛出 2100(开始推流) 设备 --RTP(PS/H.264+G.711μ)--> 平台 平台 --BYE--> 设备(或设备主动 stopPushStream 发送 BYE)→ 抛出 2101(停止推流) ``` - INVITE 消息体为空或 SDP 解析失败 → 回复 **488**;RTP 初始化失败 / SDP 应答构建失败 → 回复 **500** 并抛出 `2109` / `2103`。 - `_isStartStream` 为 `false` 时,采集线程仍在运行,但编码结果与音频数据不会写入 `PsMuxer`—— **收到点播信令才推流**。 ## 六、语音广播(对讲)流程 ``` 平台 --MESSAGE(Notify/CmdType=Broadcast)--> 设备 设备:先回 200 OK → 创建 AudioReceiver(bind 随机端口)→ 向平台发送 INVITE(SDP) 平台 --200 OK(SDP)--> 设备 设备:解析 SDP → connectToHost 平台 → 发送 ACK → 抛出 2200(开始接收音频)→ 开始收流播放 平台 --BYE--> 设备 → 抛出 2201(停止接收音频) ``` 1. `MessageHandler` 校验 `Content-Type` 必须为 `Application/MANSCDP+xml`(否则 415)、消息体非空(否则 400), **先回 200 OK 再处理业务**,避免平台重发; 2. 解析 XML 取 `SourceID` / `TargetID`,交给 `StreamManager::initAudioReceiver()`; 3. `AudioReceiver::initialize()` 绑定系统随机端口,该端口写入 INVITE 的 SDP 发给平台; 4. `AudioReceiver` 在 **独立 QThread** 中收流:`readAll()` → 写入 256KB `RingBuffer`(使用率过高时丢弃 1/4 旧数据)→ 按 `03 2c 80 88` 模式定位 RTP 头 → **跳过 2 字节长度前缀 + 12 字节 RTP 头** → 取出 **160 字节** G.711 帧( `G711_FRAME_SIZE`,即 20ms @ 8kHz)→ 发射 `audioFrameReadySignal`; > ⚠️ 关键提示:接收频率很高,必须 **独立线程 + 环形缓冲**,否则会成为性能瓶颈。 5. 编码类型由平台 SDP 的 `a=rtpmap` 决定:payload **8 → PCMA(A-law)**、payload **0 → PCMU(μ-law)**,均以 `decodeType` 透传; 6. 主线程 `ConsoleApplication::onG711DataReceived()` 用 `AudioProcessor::alawToPcm` / `ulawToPcm` 解码为 16-bit PCM,写入 256KB 环形缓冲; > ⚠️ 关键提示:G.711 是 8bit 字节流,PCM 是 16bit 采样(short)流,两者缓冲类型不同,勿混用。 7. `QAudioOutput`(8kHz / 单声道 / 16bit / 小端 / 有符号)消费缓冲:通过 `QTimer::singleShot(10ms)` 自循环,每次取 1KB 写入设备,实现平滑播放;缓冲不足时丢弃最旧数据。 ## 七、MESSAGE 查询应答 `MessageHandler` 处理 `Query` 根节点,按 `CmdType` 分派到 `XmlBuilder` 构造应答体,再通过 `ResponseSender::sendMessage()` 主动上报: | CmdType | 应答构造 | |----------------|----------------------------------------| | `Catalog` | 目录查询应答(含设备编码、域、经纬度) | | `DeviceInfo` | 设备信息应答(设备名称、序列号) | | `DeviceStatus` | 设备状态应答 | | 其它 | 仅打印告警,暂不支持 | `Notify` 根节点目前仅处理 `CmdType = Broadcast`(语音广播)。 ## 八、状态码 `StateCode::toString()` 统一把码值映射为可读文案,分段如下: | 区间 | 含义 | |-----------|-----------------------------------------------------------| | 100–699 | SIP 标准响应码 | | 1000–1080 | eXosip2 事件类型 | | 1100–1122 | osip2 返回码 | | 2000–2008 | 注册相关错误 | | 2100–2109 | 媒体流相关(**2100 开始推流 / 2101 停止推流**) | | 2200–2206 | 语音对讲相关(**2200 开始接收音频 / 2201 停止接收音频**) | | 2300–2309 | 消息处理错误 | | 2400–2403 | 设备控制错误 | | 3000–3005 | 系统/网络错误 | `ConsoleApplication::onSipStateChanged()` 只消费其中 6 个关键状态:`1000`(已注册)、`201`(已注销)、`2100`/`2101`(推流开关)、 `2200`/`2201`(对讲开关)。 ## 九、日志 `Logger` 提供分级(`DEBUG/INFO/WARN/ERROR`)与两种风格: ```cpp Logger::tag("PsMuxer").iFmt("Camera actual: %dx%d", w, h); // 单行带边框 Logger::tag("SipManager").dBox() // 多行带边框 .add("收到 INVITE 请求") .addFmt("Call ID: %d", cid) .addBlock(sdp_text) // 原样输出 SDP/XML 块 .print(); ``` > ⚠️ 关键提示:调试 PS 封装时,`PsMuxer` 会打印 SPS/PPS 的全部字节与最终 PES 载荷前 64 字节,是定位"平台无画面"的第一手依据。 ## 十、已知约束与排障要点 | 现象 | 排查方向 | |----------------|--------------------------------------------------------------------------------------------------------------| | 平台无画面 | H.264 是否带起始码;是否等到第一个 IDR 才开始发;SPS/PPS 是否缺失;时间戳是否 90kHz;系统头/PSM 字节是否有误 | | 平台收不到包 | SDP 协商出的 IP/端口/协议是否与实际发送一致;TCP 是否加了 4 字节交织头 | | 音画不同步 | 音频 PTS 是否按 8kHz 采样率正确折算到 90kHz;音频帧是否在首帧 IDR 之后才发出 | | 先出声后出画 | `_isIdrSent` 守卫(首帧 IDR 前丢弃音频)是否生效 | | 采集掉帧 | 相机是否走 MJPEG;实际分辨率/帧率是否被 V4L2 静默降级 | | SIP 初始化失败 | 5060 端口是否被占用;`localHost` 是否为真实本机 IP | | 注册反复失败 | 401/407 鉴权后状态是否为 `SENT_AUTH`;失败路径会复位状态以便重试 |