GB/T 28181平台对接实战:SIP信令与RTP媒体流全解析

发布时间:2026/10/9 6:19:34
GB/T 28181平台对接实战:SIP信令与RTP媒体流全解析 简介这份文档面向视频监控平台开发与运维人员聚焦GB/T 28181标准中上下级平台对接的信令交互细节帮助读者理解并实现平台间的注册、鉴权与保活流程。资源为单个doc文件压缩包约85KB内容以文字说明配合真实SIP信令报文示例展开便于对照抓包结果逐条分析。文档重点讲解下级平台主动向上级平台发起REGISTER注册的完整过程包括上级返回401 Unauthorized后下级携带Authorization鉴权信息重新注册、最终收到200 OK的交互链路并涉及WWW-Authenticate头域、Digest认证、MD5算法、nonce与opaque参数等关键字段。同时补充了平台心跳保活机制说明MESSAGE消息中Keepalive报文的发送周期、上下级配置一致性要求以及连续三次未收到心跳或响应即判定对端离线的处理逻辑。目前已有291人学习适合需要快速掌握28181平台对接接口实现与排错思路的开发者参考。1. 从一份“28181平台对接接口详解.doc”说起GB/T 28181 对接到底在对接什么如果你手上也躺着一份名为“28181平台对接接口详解.doc”的文档大概率说明你正卡在同一个场景里前端是海康、大华、宇视这类 IPC 或 NVR后端是自研或第三方的视频管理平台中间需要走 GB/T 28181 国标信令把设备接进来。这份文档真正要讲清楚的不是“28181 是什么”而是 SIP 信令怎么注册、目录怎么订阅、实时点播的 INVITE 怎么发、媒体流 RTP 怎么收以及平台侧接口怎么把这些能力暴露给上层业务。对接的本质是两套东西一套是 SIP 信令面负责设备注册、心跳、目录、云台、点播控制另一套是 RTP/RTCP 媒体面负责把 PS 封装的音视频流推过来。很多人第一次对接翻车不是代码写错而是没分清这两条链路——信令通了不代表有画面有画面不代表能回放。这篇笔记按“先立信令、再通媒体、最后排坑”的顺序把一份接口详解文档该有的落地细节补齐适合正在做平台对接的开发和运维照着复现。2. 信令面先跑通SIP 注册、心跳与目录订阅的最小实现2.1 为什么 SIP 注册是整条链路的“入场券”GB/T 28181 的对接第一步永远是设备向平台注册。设备侧SIP UA发 REGISTER平台侧SIP Server返回 401 带 WWW-Authenticate设备带 Authorization 再发一次 REGISTER平台返回 200 OK注册才算成立。这里最容易踩的坑是认证算法国标用的是 HTTP Digestrealm、nonce、uri、username、password 五个要素缺一不可且 uri 必须是sip:平台ID平台域这种格式写成 IP 会直接 403。注册成功后设备会周期性发 MESSAGE 心跳Keepalive平台要回 200 OK。心跳间隔一般 60 秒平台侧如果超过 3 个周期没收到就该把设备标记为离线。很多平台“设备在线但点不开”就是心跳超时没做状态清理前端还显示在线实际 SIP 会话早断了。目录订阅是第二个关键动作。平台发 SUBSCRIBE设备回 200然后设备用 NOTIFY 把通道列表推过来。目录里每个通道有 DeviceID、Name、Manufacturer、Status 等字段平台要按 DeviceID 建索引后续点播、回放全靠这个 ID 定位。2.2 用 Python 搭一个最小 SIP 注册与目录接收的验证脚本实际对接时我一般不会一上来就写完整平台而是先用脚本把注册和目录跑通确认设备愿意理我。下面这段用socket手搓 SIP 消息够土但够用能直接看到收发原文。import socket import hashlib import re # 平台侧监听配置 SIP_IP 192.168.1.100 SIP_PORT 5060 PLATFORM_ID 34020000002000000001 # 平台国标ID DEVICE_ID 34020000001320000001 # 设备国标ID PASSWORD 12345678 # 设备注册密码 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((SIP_IP, SIP_PORT)) def build_register(authNone): 构造 REGISTER 消息auth 为 None 时发首次注册 call_id reg-001192.168.1.100 if auth is None: msg ( fREGISTER sip:{PLATFORM_ID}{SIP_IP} SIP/2.0\r\n fVia: SIP/2.0/UDP {SIP_IP}:{SIP_PORT};rport\r\n fFrom: sip:{DEVICE_ID}{SIP_IP};tagfrom-tag-001\r\n fTo: sip:{DEVICE_ID}{SIP_IP}\r\n fCall-ID: {call_id}\r\n fCSeq: 1 REGISTER\r\n fContact: sip:{DEVICE_ID}{SIP_IP}:{SIP_PORT}\r\n fMax-Forwards: 70\r\n fExpires: 3600\r\n fContent-Length: 0\r\n\r\n ) else: msg ( fREGISTER sip:{PLATFORM_ID}{SIP_IP} SIP/2.0\r\n fVia: SIP/2.0/UDP {SIP_IP}:{SIP_PORT};rport\r\n fFrom: sip:{DEVICE_ID}{SIP_IP};tagfrom-tag-001\r\n fTo: sip:{DEVICE_ID}{SIP_IP}\r\n fCall-ID: {call_id}\r\n fCSeq: 2 REGISTER\r\n fContact: sip:{DEVICE_ID}{SIP_IP}:{SIP_PORT}\r\n fAuthorization: {auth}\r\n fMax-Forwards: 70\r\n fExpires: 3600\r\n fContent-Length: 0\r\n\r\n ) return msg def parse_401(data): 从 401 响应里提取 nonce 和 realm realm re.search(rrealm(.*?), data).group(1) nonce re.search(rnonce(.*?), data).group(1) return realm, nonce def make_auth(realm, nonce): 计算 Digest 认证串 ha1 hashlib.md5(f{DEVICE_ID}:{realm}:{PASSWORD}.encode()).hexdigest() ha2 hashlib.md5(fREGISTER:sip:{PLATFORM_ID}{SIP_IP}.encode()).hexdigest() response hashlib.md5(f{ha1}:{nonce}:{ha2}.encode()).hexdigest() return (fDigest username{DEVICE_ID}, realm{realm}, fnonce{nonce}, urisip:{PLATFORM_ID}{SIP_IP}, fresponse{response}, algorithmMD5) # 第一次注册预期收到 401 sock.sendto(build_register().encode(), (SIP_IP, SIP_PORT)) data, addr sock.recvfrom(4096) text data.decode(errorsignore) print(首次响应:\n, text) if 401 in text: realm, nonce parse_401(text) auth make_auth(realm, nonce) sock.sendto(build_register(auth).encode(), (SIP_IP, SIP_PORT)) data, addr sock.recvfrom(4096) print(认证后响应:\n, data.decode(errorsignore))这段代码的逻辑很直白先发无认证 REGISTER从 401 里抠出 realm 和 nonce按 Digest 规则算 response再发一次带 Authorization 的 REGISTER。参数上要注意Expires决定注册有效期平台侧一般设 3600 秒CSeq每次递增重复用同一个值部分设备会拒绝。跑通后你会看到 200 OK说明信令面入场券拿到了。2.3 目录订阅SUBSCRIBE 与 NOTIFY 的收发要点注册通了之后平台要主动发 SUBSCRIBE 订阅目录。请求里Event: Catalog、Expires: 3600设备回 200 后会用 NOTIFY 把 XML 目录推过来。NOTIFY 的 body 是MANSCDP格式的 XML里面DeviceList下挂多个Item每个 Item 的DeviceID就是通道 ID。这里有个高频坑NOTIFY 可能分多条发平台要按SN序号去重和拼接不能只处理第一条。另外目录里的Status字段是ON/OFF但很多设备这个字段不准实际在线状态还得靠心跳判断。我一般会把目录缓存到本地表字段包括 DeviceID、Name、ParentID、Status、UpdateTime后续点播直接查这张表。3. 媒体面打通INVITE 点播、PS 流解封装与 RTP 收流3.1 INVITE 点播的信令流程与 SDP 协商目录有了下一步是点播。平台向设备发 INVITEbody 是 SDP里面mvideo行声明收流端口和媒体类型artpmap声明 PS 流一般是PS/90000。设备回 200 OK带自己的 SDP然后平台发 ACK 确认媒体流就开始往平台声明的端口推了。SDP 里最容易错的是y行和f行。y是 SSRC国标要求 10 位十进制平台侧要按这个值过滤 RTP 包f是媒体描述标识这个流是实时还是回放。如果y写错你会收到一堆 RTP 但拼不出画面因为 SSRC 对不上。点播成功后平台要发 INFO 消息做云台控制或流保活设备回 200。停止点播时发 BYE设备回 200RTP 停发。整个流程里INVITE 的Subject头要带设备 ID 和通道 ID格式是设备ID:通道ID,平台ID:0写错设备会直接 400。3.2 用 Python 收 RTP 并解 PS 封装的最小验证信令通了媒体面得自己验。下面这段收 RTP、剥 PS 头、提取 H264 裸流的代码是我调试时最常用的“照妖镜”能快速判断是没收到流还是解封装错了。import socket import struct # 平台侧收流端口需与 INVITE SDP 中 mvideo 端口一致 RECV_IP 0.0.0.0 RECV_PORT 9000 SSRC 1234567890 # 与 SDP 中 y 行一致 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((RECV_IP, RECV_PORT)) def parse_rtp(pkt): 解析 RTP 头返回 SSRC、payload if len(pkt) 12: return None, None # RTP 头前 12 字节V/P/X/CC, M/PT, seq, timestamp, ssrc ssrc struct.unpack(!I, pkt[8:12])[0] payload pkt[12:] return ssrc, payload def parse_ps(payload): 从 PS 流中提取 H264 NALU简化版只找 00 00 01 起始码 nals [] i 0 while i len(payload) - 4: if payload[i:i3] b\x00\x00\x01: # 找到起始码下一个起始码之前为一个 NALU j payload.find(b\x00\x00\x01, i3) if j -1: nals.append(payload[i:]) break nals.append(payload[i:j]) i j else: i 1 return nals while True: pkt, addr sock.recvfrom(65535) ssrc, payload parse_rtp(pkt) if ssrc ! SSRC: continue # 过滤非目标 SSRC 的包 nals parse_ps(payload) for nal in nals: # 这里可写入文件或送解码器调试时打印 NALU 类型 if len(nal) 4: nal_type nal[3] 0x1F print(fNALU type{nal_type}, len{len(nal)})逻辑说明先按 RTP 头偏移取 SSRC和 SDP 里y的值比对不一致直接丢然后从 payload 里找00 00 01起始码切 NALU。参数上RECV_PORT必须和 INVITE 里声明的端口一致SSRC必须和y一致这两个对不上就是“有包无画”的经典原因。实际生产里 PS 解封装要处理 PES 头、时间戳和分片这里只做最小验证能打出 NALU 类型就说明链路通了。3.3 回放与下载RTSP 之外的国标路径实时点播跑通后回放是另一个高频需求。国标回放不是走 RTSP而是走 INVITE Play的 MANSCDP 控制。平台发 INVITE 建立会话再发 INFO 带Play命令里面指定StartTime和EndTime设备按时间段推流。回放的 SSRC 和实时不同平台要重新协商。下载类似用Download命令设备把录像文件按 PS 流推过来平台侧要按时间戳重组。回放和下载最容易踩的坑是时间格式国标要求yyyy-MM-ddTHH:mm:ss时区是本地时间写成 UTC 会查不到录像。另外设备侧录像可能分段平台要处理多个 INVITE 会话的拼接。4. 平台侧接口封装把 28181 能力暴露给上层业务4.1 接口定义从 SIP 事件到 HTTP/JSON 的映射底层 SIP 和 RTP 跑通后上层业务不可能直接操作 SIP所以要封装一层 HTTP 接口。常见做法是设备注册、心跳、目录变化这些 SIP 事件通过内部消息队列转成业务事件点播、回放、云台这些操作暴露成 REST 接口入参是设备 ID、通道 ID、时间段出参是流地址或任务 ID。接口定义里最关键的是幂等性。点播接口如果被重复调用不能每次都发 INVITE否则设备侧会话爆掉。我一般用设备ID通道ID业务类型做幂等键已存在的会话直接返回流地址。停止接口同理重复调用要返回成功而不是报错。流地址的返回方式有两种一种是返回平台自己的收流地址上层从平台拉流另一种是平台把 RTP 转成 RTMP/HTTP-FLV 再返回。前者延迟低但上层要会解 PS后者通用但多一跳。选哪种取决于上层能力我一般默认返回平台收流地址需要通用协议时再加转封装。4.2 用 Flask 写一个点播接口的骨架下面这个 Flask 骨架把点播、停止、查状态三个接口串起来重点是幂等和会话管理。from flask import Flask, request, jsonify import uuid app Flask(__name__) # 会话表key 为 设备ID:通道IDvalue 为会话信息 sessions {} app.route(/api/play/start, methods[POST]) def play_start(): 启动实时点播幂等已存在会话直接返回 data request.json device_id data[device_id] channel_id data[channel_id] key f{device_id}:{channel_id} if key in sessions: # 幂等返回已有会话 return jsonify({code: 0, stream: sessions[key][stream], msg: already playing}) # 生成 SSRC 和收流端口实际应查端口池 ssrc str(uuid.uuid4().int)[:10] recv_port 9000 # 这里调用 SIP 模块发 INVITE省略具体实现 # sip_invite(device_id, channel_id, ssrc, recv_port) stream_url frtp://平台IP:{recv_port}?ssrc{ssrc} sessions[key] {ssrc: ssrc, port: recv_port, stream: stream_url} return jsonify({code: 0, stream: stream_url}) app.route(/api/play/stop, methods[POST]) def play_stop(): 停止点播幂等不存在也返回成功 data request.json key f{data[device_id]}:{data[channel_id]} if key in sessions: # sip_bye(key) # 实际发 BYE del sessions[key] return jsonify({code: 0, msg: stopped}) app.route(/api/play/status, methods[GET]) def play_status(): 查询当前所有会话 return jsonify({code: 0, sessions: list(sessions.keys())}) if __name__ __main__: app.run(host0.0.0.0, port8080)逻辑说明play_start先查会话表存在就直接返回保证幂等不存在才生成 SSRC 和端口调 SIP 模块发 INVITE。play_stop对不存在的会话也返回成功避免上层重试报错。参数上SSRC 要保证 10 位且全局唯一端口从池里分配避免冲突。实际生产要把sessions换成 Redis支持多实例和过期清理。4.3 状态同步与超时清理接口封装完状态同步是另一个坑。SIP 会话可能因为网络抖动断掉但 HTTP 接口不知道会话表里还留着死会话。常见做法是SIP 模块收到 BYE 或心跳超时后主动回调接口层删会话同时接口层加定时任务扫描超过一定时间没更新的会话发 INFO 保活失败就清理。保活间隔一般 15 到 30 秒太频繁设备压力大太稀疏断线发现慢。清理时要注意先发 BYE 再删表否则设备侧会话泄漏时间长了设备拒绝新 INVITE。我见过设备因为会话泄漏直接不响应点播重启才好血泪经验。5. 对接避坑五条真实踩坑记录与排查路径5.1 注册成功但目录为空现象REGISTER 返回 200心跳也正常但 SUBSCRIBE 后收不到 NOTIFY或者 NOTIFY 里 DeviceList 为空。原因多数是 SUBSCRIBE 的Event头写错或者Expires为 0 导致立即过期。也有设备要求 SUBSCRIBE 的To和From都用平台 ID写成设备 ID 会被忽略。解决抓包确认 SUBSCRIBE 原文Event: Catalog必须准确Expires设 3600To用平台 ID。如果设备仍不回尝试主动发 MESSAGE 查目录部分设备对 SUBSCRIBE 支持不完整。5.2 点播返回 200 但收不到 RTP现象INVITE 收到 200 OKACK 也发了但收流端口一个包都没有。原因SDP 里mvideo的端口和实际收流端口不一致或者y的 SSRC 和 RTP 包里的对不上被过滤了。还有一种情况是设备侧 NAT 环境RTP 发到了错误地址。解决先确认收流端口监听正常用tcpdump看有没有 UDP 包进来。有包但被过滤检查 SSRC没包检查 SDP 端口和防火墙。NAT 场景要在 SDP 里带artcp和正确的连接地址。5.3 有 RTP 但解不出画面现象RTP 包正常收到SSRC 也对但解封装后 NALU 类型全是乱码或拼不出完整帧。原因PS 封装里 PES 头没跳过直接把 PES 当 NALU 解析。或者 RTP 分片没重组大 NALU 被切成多个 RTP 包直接按单包解会丢数据。解决先按 PS 的00 00 01 BA找 PES 头跳过 PES 头再找00 00 01 E0视频流最后才找 NALU 起始码。RTP 分片要看M位和序列号按序重组后再解。5.4 回放查不到录像现象回放 INVITE 成功但设备返回的流里没有数据或者直接 404。原因时间格式不对国标要求本地时间且格式严格或者录像文件不存在设备侧根本没录。解决用设备自己的客户端确认该时间段有录像再检查平台发的时间字符串必须是yyyy-MM-ddTHH:mm:ss不能带时区后缀。跨天查询要拆成多个时间段。5.5 多路点播后设备拒绝新请求现象点播几路后新 INVITE 返回 486 或超时设备像“死”了一样。原因会话泄漏之前的 BYE 没发或没发成功设备侧会话数满了。解决检查停止点播逻辑确保每次 stop 都发 BYE 并等 200。加会话超时清理定期发 INFO 保活失败主动 BYE。设备侧一般有会话上限别等满了才处理。6. 进阶技巧用抓包和日志把 28181 对接变成可复现的调试流程对接 28181 最怕的不是不会写代码而是出了问题不知道看哪。我的习惯是任何一次对接先开tcpdump抓 SIP 和 RTPSIP 走 5060 端口RTP 走协商端口抓完用 Wireshark 过滤sip和rtp信令和媒体分开看。信令看响应码和头字段媒体看 SSRC 和包间隔。日志方面SIP 模块要把收发的原文按会话 ID 打出来RTP 模块要统计收包数、丢包数、SSRC 分布。我一般会在收流线程里每 5 秒打一次统计如果收包数为 0直接看 SDP 协商如果收包数正常但 NALU 为 0看解封装。这样能把问题定位到具体环节而不是笼统地说“没画面”。验证方法上除了自己写的脚本我还会用设备厂商的客户端做对照同一路流厂商客户端能看平台不能看问题在平台都不能看问题在设备或网络。这个对照能省很多时间。最后说个具体技巧SSRC 别用随机数用设备ID后 6 位 通道ID后 4 位拼成 10 位这样抓包时一眼能看出是哪个设备哪一路排查多路问题时特别有用。这个习惯我保持了几年每次多路对接都能快速定位希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询