简介JGB28181是一套基于Java实现的GB28181平台源码面向音视频流媒体、安防监控方向的开发者及学习国标协议的初、中级工程师帮助其快速理解并搭建GB28181信令与媒体交互环境。压缩包共49个文件大小约64KB其中43个Java源文件构成平台核心逻辑辅以2个XML、1个properties、1个yml及README等配置与说明文件结构紧凑、便于按模块阅读。资源已支持设备注册、目录查询以及TCP被动和UDP方式的实时视频流使用者只需修改config.properties中的配置信息编译运行即可开展联调与验证。目前已有4241人学习下载说明其在国标平台入门与二次开发场景中具有一定参考价值。对于希望梳理SIP信令流程、设备接入、流媒体传输链路的读者这份源码可作为可运行的实验底稿也便于在此基础上扩展业务功能与排错分析。1. 从三台摄像机只注册上一台说起去年帮一个园区做联网改造设备侧按 GB28181 开了接入平台日志里翻来覆去只有两行一条 REGISTER 发过去 401 挑战然后就再没下文另一条更干脆REGISTER 收到了也回了 200 OK可十秒后又收到一模一样的包。抓包才发现第一台的 Authorization 里 nonce 是空的第二次注册压根没带摘要第二台是 Contact 头里写的端口和实际发包的源端口对不上。这类问题要解决并不难难点在于链路上每一个字段都得对得上。JGB28181 是一套用 Java 写的 GB28181 平台实现信令收发和媒体收流放在同一个进程里不依赖额外中间件适合当自建联网平台的底座小规模设备接入、按需点播、把取流能力嵌进自己的业务系统。下面按信令骨架、目录点播、收流落地、避坑、工程化、验证的顺序一层层拆开。2. SIP 信令骨架注册、心跳和一张带双过期时间的在线表2.1 为什么先做 Registrar而不是先怼流媒体很多人拿到这类平台源码第一反应是去找收流那段觉得画面出来就算跑通了。实际顺序应该反过来注册不通后面全是空转。设备的逻辑很死板注册不成功它不会发心跳不发心跳平台就不会认为它在线不在线你就查不到目录没目录就没有 INVITE 的目标整条链路断在第一步。所以整套东西的入口是一张在线表而不是一个播放器。GB28181 的信令走 SIP默认 5060 端口报文是纯文本。平台端在这里身兼两职对设备它是服务端要接 REGISTER 和 MESSAGE对设备它又是客户端要主动发 Catalog 查询、发 INVITE 点播、发 PTZ 控制指令。这种双向角色是排错时最容易搞混的地方——同一条 200 OK可能是你回给设备的也可能是设备回给你的看 CSeq 和 Call-ID 才能分清。选型上我一般不建议上完整的 SIP 协议栈。GB28181 用到的 SIP 方法就那几个事务层、Dialog 层那一整套状态机带来的复杂度远大于收益反而把报文里真正要改的字段藏起来了。常见做法是用 Netty 起一个 UDP Channel 加一个 TCP Channel自己做一层薄薄的文本解析把起始行、头域、body 三段切开够用而且好调试。方法方向典型用途实现优先级REGISTER设备 → 平台注册、注销、刷新有效期必须MESSAGE双向心跳、目录查询与应答、云台控制、报警必须INVITE平台 → 设备实时点播、历史回放、语音广播必须ACK双向确认 INVITE 的 200 OK必须BYE双向主动结束会话必须SUBSCRIBE / NOTIFY双向报警订阅、目录变更通知可选解析这一层有三个字段必须原样回填不能自己重新生成Via 里的 branch、From/To 里的 tag、以及 CSeq 的序号。设备侧很多实现是严格比对的branch 一变它就把你的响应当成无效包丢掉日志上看就是“发了响应但设备还在重传”。这套判断标准听着玄学抓两次包就明白了。2.2 REGISTER 与 401 摘要认证一次注册要过几道关设备第一次 REGISTER 通常不带认证信息平台要回 401 并在 WWW-Authenticate 头里给出 realm、nonce 和 algorithm。设备拿这几个值算一遍摘要第二次 REGISTER 带上 Authorization平台验过才回 200。中间任何一步字段写错设备就卡在重试里。// SipDispatcher.java —— 只做请求分发不引入完整 SIP 事务层 public void onRequest(SipRequest req, ChannelHandlerContext ctx) { switch (req.getMethod()) { case REGISTER: { // 第一次注册不带 Authorization回 401 挑战 if (req.header(Authorization) null) { String nonce nonceSeq.incrementAndGet() : System.currentTimeMillis(); ctx.writeAndFlush(SipResponse.challenge(req, realm, nonce)); return; } String deviceId req.fromUser(); // From 头里的设备 ID String password deviceConfig.passwordOf(deviceId); // 设备侧配的 SIP 密码 if (!Digest.verify(req, realm, password)) { ctx.writeAndFlush(SipResponse.of(req, 403, Forbidden)); return; } int expires req.expires(3600); // 头域 Expires缺省一小时 if (expires 0) { // Expires: 0 是注销 registry.remove(deviceId); } else { registry.register(deviceId, ctx.channel().remoteAddress(), expires); } // 200 OK 里必须带 Contact 和 Expires否则部分设备不认 ctx.writeAndFlush(SipResponse.of(req, 200, OK) .header(Contact, req.header(Contact)) .header(Expires, String.valueOf(expires))); return; } case MESSAGE: onMessage(req, ctx); // 心跳、目录应答、报警都走这里 return; default: ctx.writeAndFlush(SipResponse.of(req, 405, Method Not Allowed)); } }摘要的算法是标准的 MD5 三段式HA1 MD5(username:realm:password)HA2 MD5(method:uri)最终 response MD5(HA1:nonce:HA2)。这里有两个参数容易设错。username 用的是设备 ID 而不是什么账号名设备侧填的是同一个值realm 由平台自己定但一旦定了就不能改改了所有设备都得重新配。密码是设备侧单独配的 SIP 密码跟网页登录密码不是一回事现场调试时问错密码是最常见的翻车点。还有一个细节Expires 为 0 表示注销这时候不要回 200 之后就完事一定要把在线表里的记录删掉不然后面查目录会查到一个已经下线的设备白白等超时。2.3 心跳与在线表两个时间戳谁先到算谁设备注册成功后按固定间隔发 MESSAGE 心跳body 是 XMLCmdType 为 KeepaliveStatus 为 OK。平台收到回 200 就算一次心跳成功。在线表要维护两个时间注册到期时间和最近心跳时间任意一个超了就判离线。// Registry.java —— 在线表注册有效期和心跳超时任一失效即判离线 public class Registry { private final MapString, Entry table new ConcurrentHashMap(); static class Entry { final String deviceId; volatile InetSocketAddress addr; volatile long registerExpireAt; // 注册有效期截止的绝对毫秒 volatile long lastKeepaliveAt; // 最近一次心跳的绝对毫秒 } public void register(String deviceId, InetSocketAddress addr, int expiresSec) { Entry e table.computeIfAbsent(deviceId, k - new Entry(k)); e.addr addr; e.registerExpireAt System.currentTimeMillis() expiresSec * 1000L; e.lastKeepaliveAt System.currentTimeMillis(); } public void keepalive(String deviceId) { Entry e table.get(deviceId); if (e null) return; // 没注册过的心跳直接丢弃不要回 200 e.lastKeepaliveAt System.currentTimeMillis(); } // 每 5 秒扫一次两个阈值都超了才移除 public void sweep(long keepaliveTimeoutMs) { long now System.currentTimeMillis(); table.entrySet().removeIf(kv - { Entry e kv.getValue(); return now e.registerExpireAt || now - e.lastKeepaliveAt keepaliveTimeoutMs; }); } }心跳超时阈值不要照抄设备的心跳间隔。现场设备的心跳间隔常见是 60 秒偶尔有设成 5 秒或 3600 秒的取间隔的 3 倍比较稳妥60 秒心跳就配 180 秒超时。设成 1.5 倍会在网络抖动时误判离线设成 10 倍又会导致设备真掉线了平台上还显示在线点播一直超时。这个参数写进配置文件不要硬编码因为不同厂家的默认心跳间隔能差出一个数量级。3. 目录查询到实时点播一条链路上四个必须对齐的地方3.1 Catalog分页、SN 和编码目录查询是平台主动发 MESSAGE 给设备body 里带 CmdType、SN、DeviceID。设备回的 MESSAGE body 是一段 Catalog XML里面 DeviceList 挂着若干 Item每个 Item 描述一路通道。报文的 XML 声明里编码写 GB2312 还是 UTF-8各家设备态度不一样这是最容易踩的一个坑。// CatalogCodec.java —— 查询报文编码声明和 CRLF 都不能省 public static String buildQuery(String deviceId, int sn) { return ?xml version\1.0\ encoding\GB2312\?\r\n Query\r\n CmdTypeCatalog/CmdType\r\n SN sn /SN\r\n DeviceID deviceId /DeviceID\r\n /Query\r\n; }这段看着简单三处细节决定了能不能拿到应答。第一行尾必须是\r\n只写\n有些设备解析器会直接返回 400。第二SN 是消息序号同一次会话的查询和应答要用同一个值自己做递增并在应答里比对不然并发查多个设备时分不清哪条应答是谁的。第三编码声明写 GB2312body 也真按 GB2312 编码不要声明一套实际发另一套中文通道名会变成乱码。应答解析要盯两个字段SumNum 是通道总数DeviceList 里的 Item 数是本次返回数。两者不相等说明设备做了分页需要用同一个 SN 再发一次查询每次把已收到的数量带回去。不少新手在这里丢通道明明设备有 16 路平台只显示 8 路就是因为没处理 SumNum。字段含义用途DeviceID通道 ID通常 20 位点播时 INVITE 的目标Name通道名称界面展示注意编码StatusON / OFF过滤掉离线通道避免无效点播ParentID所属设备 ID组装设备与通道的树形结构Parental是否有子设备判断要不要继续下钻3.2 INVITE 点播SDP 里那几个不能写错的字段点播是平台发 INVITEbody 是 SDP设备回 200 OK 带它自己的 SDP平台再回 ACK媒体流才开始往平台指定的 IP 和端口上打。SDP 里几个字段是全链路的命门。// SdpBuilder.java —— 实时点播的 SDP注意 y 和 artpmap 的配合 public static String buildPlaySdp(String platformIp, int rtpPort, long ssrc, String deviceId) { return v0\r\n o deviceId 0 0 IN IP4 platformIp \r\n sPlay\r\n // 实时点播固定写 Play cIN IP4 platformIp \r\n // 收流地址必须是平台真实网卡 IP t0 0\r\n mvideo rtpPort RTP/AVP 96\r\n // 端口要和端口池分配的一致 arecvonly\r\n // 平台只收不发 artpmap:96 PS/90000\r\n // 负载类型 96 对应 PS 流时钟 90kHz y ssrc \r\n; // 10 位十进制 SSRC }s这一行经常被忽略实时点播写Play历史回放写Playback下载写Download写错设备会返回 488 或者直接不响应。c里的地址必须是平台在这台设备看来可达的那个网卡 IP多网卡机器上最容易错——设备在 192.168 网段平台却填了 10 网段的地址SDP 协商成功流就是不来。y是 SSRC设备按这个值给 RTP 打标记平台收流时用它过滤串流某些设备不认这个字段会自己生成一个所以收流侧不能硬依赖要做成“有值则校验、无值则放行”。INVITE 发出去如果收到 100 Trying 就说明设备在处理之后等 200 OK。超过 32 秒还没 200别傻等直接发 CANCEL 再重试一次这个超时时间在协议里有约定。3.3 收流RTP 端口分配与 PS 剥包落盘收流侧每个会话开一个 UDP 端口从端口池里取用完归还。收到包先剥 12 字节 RTP 头再按 payload type 过滤最后把 PS 数据写出去。// RtpReceiver.java —— 单会话单端口按 PT 和 SSRC 双重过滤 public void onDatagram(DatagramPacket p) { byte[] b p.getData(); int len p.getLength(); if (len 12) return; int pt b[1] 0x7F; if (pt ! 96) return; // 只认 PS别的丢了 long ssrc ((b[8] 0xFFL) 24) | ((b[9] 0xFFL) 16) | ((b[10] 0xFFL) 8) | (b[11] 0xFFL); if (sessionSsrc ! 0 ssrc ! sessionSsrc) return; // 端口被串流时不误写 int cc b[0] 0x0F; // CSRC 计数 int off 12 cc * 4 ((b[0] 0x10) ! 0 ? 4 : 0); // 扩展头再 4 字节 if (off len) return; psWriter.write(b, off, len - off); // 直接按到达顺序追加 }这套逻辑有个刻意的取舍不做重排、不做抖动缓冲收到就按顺序写。落盘成.ps文件拿去分析足够了真要实时推给播放器得按 RTP 序列号做一次排序窗口否则解码端会花屏。落盘文件想验证是否完整用ffprobe -f ps 文件名.ps看一眼能读出 H.264 流信息基本就说明 PS 剥得对。PS 流里靠起始码找边界00 00 01 BA是 Pack Header00 00 01 E0是视频 PES00 00 01 C0是音频 PES。只做落盘不用解析这些但要判断“第一帧是不是 I 帧”就得往下拆到 NAL 层看类型 5 和 7。我一般会在写文件前缓存前若干个包找到第一个带 SPS 的 PES 再开始落盘这样切出来的文件能直接播。4. 避坑与排查五条血泪记录4.1 信令层的三条现象设备一直重发 REGISTER平台日志显示已经回了 200 OK。原因响应里的 Via、From tag、CSeq 有一个和请求不一致设备当成垃圾包丢了。另一种情况是 200 OK 少回了 Contact 和 Expires。 解决把这四个头域做成从请求原样拷贝不要重新生成。抓包对比第二条 REGISTER 和你的 200 OK逐字节比一遍头域顺序和大小写。现象目录查询发出去毫无反应连 100 Trying 都没有。原因body 里 XML 声明写了 UTF-8 但实际按 GBK 编码或者行尾只有\n也可能是 Content-Length 按字符数算而不是字节数。 解决统一用 GB2312 声明加 GB2312 编码\r\n结尾Content-Length 用body.getBytes(GB2312).length计算。改完还是没反应就看目标端口对不对5060 是默认值但现场有改成别的。现象点播时 INVITE 收到 200 OKACK 也发了就是没有流。原因SDP 里c填的 IP 设备不可达或者mvideo的端口和平台实际监听的端口不一致。 解决先用tcpdump -i any udp port 收流端口看有没有包进来。一个包都没有就是地址或端口问题有包但写不出文件就是 RTP 解析问题这两类现象要分开查别混在一起猜。4.2 媒体层的两条现象同一个收流端口上两路设备的流混在一起文件花屏。原因为了省端口复用了同一个 UDP 端口给多个会话或者端口池回收时上一个会话的流还没停。 解决一会话一端口会话结束先发 BYE等 2 秒再归还端口。收流侧保留 SSRC 校验即使串了也能丢掉不属于本会话的包。现象落盘的 PS 文件前几秒能播后面全绿。原因点播开始时设备先发的是 P 帧平台从半个 GOP 中间开始收缺 I 帧。 解决缓存到第一个含 SPS/PPS 的 PES 再开始写文件或者干脆在 INVITE 之前先让设备停一下等 ACK 之后重新计数。这个坑很隐蔽因为文件大小、包数看着都正常。5. 平台侧工程化配置、线程模型和没有真机时的压测5.1 配置集中到一个文件散落在代码里的端口、密码、超时是后期维护的噩梦。我一般把跟现场绑定的东西全抽到一个 YAML 里代码只读不写。sip: listen-port: 5060 transport: udp # 现场多数设备只支持 UDPTCP 作为备选 realm: platform.local # 定了就别改改一次所有设备要重配 domain: 34020000002000000001 media: port-range: 30000-30100 # 100 路并发起步按实际规模放大 ip: 192.168.10.20 # 必须是与设备同网段的真实网卡地址 write-path: /data/ps device: keepalive-timeout: 180 # 秒取设备心跳间隔的 3 倍 default-password: # 留空则按 deviceId 从设备表里查media.ip是最值得单独拎出来说的。这个值不能填0.0.0.0也不能填127.0.0.1设备拿到这个地址会直接往那儿打流填错了就是协商成功但没有画面。多网卡机器上建议做成按设备来源网段选本机地址比全局一个值稳。5.2 线程模型与端口池Netty 的 worker 线程只负责收包别在里面做写文件这类阻塞操作否则一个慢磁盘能把整条收流链路拖垮。常见的分法是IO 线程收到 RTP 包后丢进一个按会话划分的有界队列由单独的写线程批量落盘。队列满了直接丢包而不是阻塞丢包最多花一下阻塞会让整个会话卡死。端口池用BlockingQueueInteger就够初始化时把 30000 到 30100 全部塞进去会话建立时poll会话结束时offer归还。要注意归还前先关闭对应的 DatagramSocket否则新会话可能复用端口时撞上残留的包。5.3 没有真机时怎么压测设备只有两三台怎么验证平台能扛多少路自己写个假设备。假设备只要做三件事按时注册、按时心跳、收到 INVITE 后往指定端口发一堆构造好的 RTP 包。// FakeDevice.java —— 压测用N 个虚拟设备轮流注册与心跳 for (int i 0; i n; i) { String id String.format(3402000000132000%04d, i); sip.send(register(id)); // 首次注册 scheduler.scheduleAtFixedRate( () - sip.send(keepalive(id)), 60, 60, TimeUnit.SECONDS); // 60 秒心跳 } // 收到 INVITE 后按 25 帧/秒发 RTPpayload 用固定长度的假 PS 数据 // 目的是压出端口分配、线程队列、磁盘写入的瓶颈不追求画面能解码这套假设备的价值在于把“协议对不对”和“扛不扛得住”两件事拆开了。协议对不对得用真机验扛不扛得住假设备跑一遍心里就有数了。我一般会从 50 路开始每次加 50 路盯着三个指标端口池有没有耗尽、写队列有没有持续堆积、GC 后堆内存有没有回落。6. 上线前我必走的三步验证协议这东西代码写完跑通了不算完得有一条能重复走的验证路径。我现在固定走三步缺一步心里都不踏实。第一步抓包看信令。在平台机器上抓 5060 端口的 UDP 包用 Wireshark 的 SIP 解析器打开重点看三条REGISTER 的 401 和 200 是否成对、MESSAGE 心跳是否稳定在同一个间隔、INVITE 到 ACK 之间有没有超过 32 秒的空档。抓包文件保留出问题时和设备厂家对数的时候这是唯一的证据。# 只抓 SIP 和收流端口避免文件过大 tcpdump -i any -s 0 -w /tmp/gb28181.pcap \ udp port 5060 or (udp portrange 30000-30100)第二步验 PS 文件。点播 30 秒把落盘的文件拉出来跑一次 ffprobe看三件事能不能识别出 H.264、分辨率对不对、时长和实际点播时长差多少。时长差得离谱通常是 RTP 时间戳没参与计算只是按包数估算分辨率不对则多半是 SDP 里的artpmap写错了负载类型。这一步能拦住大部分“看着有文件其实是垃圾数据”的情况。ffprobe -v error -show_streams -select_streams v:0 /data/ps/34020000001320000001_play.ps第三步用十六进制看头。文件前几百字节里应该出现00 00 01 BA往下能找到00 00 01 E0。如果开头是一堆杂乱字节说明 RTP 头没剥干净或者 CSRC/扩展头没算对偏移。这个检查很土但比读日志快得多尤其适合判断“是收流侧的问题还是设备发的问题”——如果设备抓包里的 PS 结构正常平台落盘的不正常问题就在自己这边。xxd -l 256 /data/ps/34020000001320000001_play.ps血泪教训是有一回现场反馈“偶发花屏”我按经验去查网络丢包折腾了两天最后发现是端口池回收时没等 BYE 完成两路流短暂抢了同一个端口收流侧虽然做了 SSRC 过滤但过滤前已经往同一个文件句柄写了一部分。从那以后我每次改收流相关代码都强制把这三步走一遍尤其是最后一步的十六进制检查——它不解释原因但它能最快告诉你结果对不对。希望这些能帮到你少走几段我走过的弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?