☰
NTRIP 2.0协议解析:HTTP与RTSP/RTP差分改正流传输及排障实践
2026/10/28 3:26:24 网站建设 项目流程

简介:面向GNSS高精度定位从业者与导航通信开发者的NTRIP协议标准文档,聚焦通过互联网传输RTCM差分数据的规范与实现依据。文件为1个PDF,压缩包约863KB,内容为RTCM 10410.1《Networked Transport of RTCM via Internet Protocol (Ntrip) Version 2.0》2021年1月版,属于单一标准正文型资料,便于检索条款与修订说明。已有1340人学习,说明其在GNSS数据服务、参考站网和接收机联调场景中具有一定参考需求。文档围绕Ntrip系统概念、服务器与客户端角色、数据流管理、身份验证、速率控制及RTCM消息等核心内容展开,并保留Amendment 1、Amendment 2与2021年最新修订信息,可帮助读者对照协议字段、梳理数据链路和排查连接问题,也适合作为教学引用与开发实现的案头资料。

1. NTRIP 2.0:一条差分改正流为什么不直接开个 TCP 端口

第一次抓 NTRIP 的包,多数人会愣一下:满屏明文,GET /BASE01 HTTP/1.1,跟着User-Agent、Authorization、Transfer-Encoding: chunked,就是一段被拉长的 HTTP 请求。这不是省事,是刻意为之。NTRIP 2.0(RTCM 10410.1)从头到尾没打算发明新协议,它把 HTTP/1.1 和 RTSP 砍到只剩“拉一条永不结束的流”所需要的那一小撮语义,然后在上面跑 GNSS 改正数。

值得较真的是这件事背后的约束:厘米级定位依赖每一两秒刷新一次的差分改正,这段数据要穿过移动网络、NAT、代理、鉴权,还要一对多地分发给成百上千个流动站。自己造一套私有二进制协议,这些轮子得重造一遍;套在 HTTP 上,代理、抓包工具、curl 全都白捡。RINEX 是事后复盘的观测文件,NTRIP 是同一套数据在实时链路上的形态,两边对照着看,消息号和历元才咬得合。

2. 四个角色与 mountpoint:Caster 在中间到底挡下了什么

标准把系统拆成四块:NtripSource、NtripServer、NtripCaster、NtripClient。名字看着像四个程序,其实只有三个是软件实体,第一个是逻辑概念。这个不对齐的地方,是理解整套分发模型的第一道坎。

2.1 NtripSource 不是一个程序:四个角色的边界怎么划

NtripSource 指的是“在某个物理位置产生数据流的那件事”,可能是一台参考站接收机,可能是网络 RTK 算法进程,也可能是一段回放的历史数据。它本身不联网、不懂 HTTP,只负责按秒吐 RTCM 帧。真正把它接上互联网的是 NtripServer——一个上传端程序,从串口、本地 TCP 端口或内存队列里捞出字节,用一条 HTTP 风格的 PUT/POST 长连接推到 Caster,并声明“我推的是哪个 mountpoint”。

这么切分的好处很直接:参考站换机器、换串口卡、换采集程序,只要 Server 还推同一个 mountpoint,下游几百个流动站不需要改任何配置。反过来,如果 Source 和 Server 合成一个程序,更换硬件就等于更换服务地址,运维成本会立刻变得难以控制。Caster 是唯一常驻的中心组件,它维护 mountpoint 到“当前推流那条 TCP 连接”的映射表,并把这一个上行流复制给 N 个下行连接。所谓一对多分发,本质上就是在这个进程里做扇出。

Client 端通常是接收机固件里的一个精简实现,也可能是跑在手机或工控机上的软件。它对协议的要求极低:能发一个 GET、能读字节、能按 RTCM 帧头0xD3切帧就行。标准反复强调“easy to implement with limited client or server resources”,说的就是这条:Client 侧的内存和算力预算往往只有几十 KB。

2.2 mountpoint:把“一条流”变成可寻址、可鉴权的资源

mountpoint 是一个字符串标识符,形如BASE01,挂在 Caster 的根路径下,客户端用GET /BASE01请求它。这个设计把“流”降维成了“资源”,于是 HTTP 那一整套东西——路径、鉴权、状态码、缓存控制——全部可以原样套用。

约束藏在细节里。一个 mountpoint 在同一时刻只允许一个 Server 推流,否则 Caster 无法判断该把哪条数据复制给客户端,常见的实现是后到的 Server 被拒绝,或者把先到的踢掉,两种策略在不同 Caster 上都能见到,写对接代码时不能想当然。mountpoint 一般大小写敏感,字符集限制在字母、数字和少数符号,长度也有上限;把空格、斜杠、#塞进去,轻则注册失败,重则被当成路径分隔符解析成另一个资源。

提示:客户端配置里多敲一个尾随空格,表现是“认证一直失败”而不是“mountpoint 不存在”,因为路径匹配失败常常被实现归到鉴权分支里统一返回 401。这个坑后面第 5 章还会碰到。

2.3 sourcetable:为什么服务发现用纯文本而不是接口

Caster 必须让客户端知道“这里有哪些流、分别是什么格式、在哪个国家、大概多少码率”。标准给出的答案是 sourcetable:一份纯文本表,客户端GET /就能拿到,表体以ENDSOURCETABLE结尾。它有三类记录,每行一条,分号分隔:

CAS;caster.example.net;2101;Demo Caster;demo-op;0;DEU;12.34;56.78;fallback.example.net;2101; NET;BASE01;demo-op;B;N;none;http://caster.example.net; STR;BASE01;Demo Site;RTCM 3.3;1004(1),1005(10),1012(5),1033(30),1074(1),1084(1),1094(1),1124(1);2;GPS+GLO+GAL+BDS;BASE01;DEU;12.34;56.78;0;0;sNTRIP;none;B;N;9800;

CAS 描述 Caster 自身(主机、端口、运营商、备用地址),NET 描述一个网络运营方(认证方式、是否收费、主页),STR 描述一条具体的流。真正的信息密度在 STR 上:FORMAT声明是大类(RTCM 2.x / RTCM 3.x / 其他),FORMAT-DETAILS列出这条流里实际会出现哪些消息号以及各自的周期,比如1004(1)表示每秒一条 GPS 载波相位改正,1074(1)表示每秒一条 GPS MSM4 观测。括号里是秒数,不是频率——这个反直觉的写法坑过不少人。

用文本而不是 JSON 接口,取舍很清楚:任何 Caster 都能在几十行代码里拼出这张表,任何客户端用一个readline加split(';')就能解析,出了问题用 curl 一看便知。代价是没有类型、没有转义,字段里出现分号就完蛋,扩展字段只能靠末尾的MISC兜着,历史上不同厂商往里塞过各种私有内容。

2.4 鉴权放在 Caster:凭据在哪一层生效

上传和下载用的是两套凭据。Server 推流时带的是“我有权发布这个 mountpoint”的凭据,Client 拉流时带的是“我有权订阅”的凭据,两者都在 Caster 上校验,Source 和 Server 之间通常不做认证。

放在 Caster 的理由是它掌握全局:只有它知道当前有哪些 mountpoint、谁在线、某个账号是否已经超过了并发连接数。如果鉴权下放到 Server,就得在每个参考站侧维护一份用户表,几百个站点的账号同步会立刻变成运维灾难。代价是 Caster 成为单点,认证逻辑的任何性能问题都会放大到全部连接上,所以真实系统里这一层前面往往还挡着一层 TLS 终结和限流。

3. HTTP 通道:GET /MOUNTPOINT 之后那几百毫秒里发生了什么

这是绝大多数 NTRIP 客户端实际走的路,也是标准里被改得最多的一段。1.0 到 2.0 之间那些“清掉的设计问题和 HTTP 协议违规”,八成集中在这里。

3.1 请求行与 Ntrip-Version:1.0 与 2.0 的分水岭就在这一行

一个 2.0 客户端发出的请求大致长这样:

GET /BASE01 HTTP/1.1 Host: caster.example.net:2101 Ntrip-Version: Ntrip/2.0 User-Agent: NTRIP demo-client/0.1 Accept: */* Connection: close Authorization: Basic dXNlcjpwYXNz

对应的成功响应:

HTTP/1.1 200 OK Ntrip-Version: Ntrip/2.0 Server: NTRIP demo-caster/1.0 Date: Mon, 11 Jan 2021 08:00:00 GMT Cache-Control: no-store, no-cache, max-age=0 Pragma: no-cache Connection: close Content-Type: gnss/data Transfer-Encoding: chunked

Ntrip-Version: Ntrip/2.0这一行是整个协议的开关。Caster 读到它,才允许启用 chunked 编码、才按 2.0 的 header 语义回包;读不到,就退化成 1.0 兼容模式,用最保守的方式往外吐字节。这也是为什么很多“明明 Caster 支持 2.0,我的程序却在收 1.0 行为”的问题,根因只是请求头漏了一行。

响应里的Content-Type: gnss/data是 NTRIP 自定义的 MIME 类型,不是标准注册类型,但它有实际作用:中间的代理和防火墙看到这个类型,就知道这是一条不该缓冲、不该缓存的流。Cache-Control: no-store和Pragma: no-cache是配套的保险,因为改正数一旦被缓存,客户端拿到的就是过期星历。

sourcetable 请求走的是同一个入口,只是路径是/。1.0 时代这里返回的状态行是SOURCETABLE 200 OK,一个不合法但被广泛容忍的写法;2.0 改成了标准的HTTP/1.1 200 OK加Content-Type: text/plain,并且因为表体长度有限,可以正常给Content-Length,不需要 chunked。这个改动看似洁癖,实际解决的是“通用 HTTP 客户端解析不了这张表”的问题。

3.2 反向 NMEA:客户端为什么要把自己的位置发给 Caster

网络 RTK 的核心是“按流动站的位置生成虚拟观测值”,所以 Caster 必须知道客户端大概在哪。HTTP 是请求-响应模型,请求发完之后理论上只能读,但 TCP 连接本身是全双工的,NTRIP 2.0 就利用这一点:连接建立后,客户端可以持续往这条连接的上行方向写 NMEA GGA 语句,Caster 解析出经纬度高程,转给对应的 Server,Server 再据此生成改正数。

这个设计的巧妙之处在于零额外连接:不需要为“上报位置”再建一条链路,也不需要定义新的协议动词,上行数据就是裸文本行。条款里专门为此留了位置,说明它不是灰色 hack,而是被正式承认的能力。

频率上常见的做法是 1 Hz,高动态场景会提到 5 Hz 甚至 10 Hz。发太快没意义——虚拟观测值的历元间隔受源端观测采样率限制,把 GGA 刷到 20 Hz 只会让 Caster 多做无用解析;发太慢则会在移动过程中拿到滞后于真实位置的改正数,表现为“停车时精度很好,一开起来就飘”。还有一个容易忽略的点:GGA 里的定位质量指示和卫星数如果填得离谱(比如固定填 0 颗星),部分 Caster 会直接判定为无效语句丢弃,链路看起来是通的,实际根本没生效。

3.3 chunked:一条永不结束的响应怎么划边界

这是 2.0 相对 1.0 最实质的一处改动。GNSS 流没有终点,Content-Length无从填起;1.0 的做法是干脆不给长度,让客户端一直读到连接关闭为止,靠 EOF 判断结束。这个方案在直连时能用,一遇到代理就崩:代理不知道响应体多长,要么一直缓冲到连接关闭(于是客户端一个字节都收不到),要么按自己的超时把连接掐掉。

Transfer-Encoding: chunked把流切成自描述的块,每块前面一行十六进制长度,块尾回一个 CRLF,长度为 0 的块表示结束。代理可以边收边转,客户端也能在每块边界上做一次 flush,而不必猜“这堆字节到底是不是完整的一帧”。

import socket HOST, PORT, MOUNT = "caster.example.net", 2101, "BASE01" req = ( f"GET /{MOUNT} HTTP/1.1\r\n" f"Host: {HOST}:{PORT}\r\n" "Ntrip-Version: Ntrip/2.0\r\n" # 漏掉这行,Caster 会按 1.0 行为回包 "User-Agent: NTRIP demo/0.1\r\n" "Authorization: Basic dXNlcjpwYXNz\r\n" # base64(user:pass),不是明文 "\r\n" ) sock = socket.create_connection((HOST, PORT), timeout=10) sock.sendall(req.encode("ascii")) f = sock.makefile("rb") # 1) 先把响应头读干净:逐行读到空行为止,状态码和 Transfer-Encoding 都在这里 while True: line = f.readline() if line in (b"\r\n", b"\n", b""): break # 2) 之后进入 chunked 帧:<十六进制长度>[;扩展]\r\n<payload>\r\n while True: size_line = f.readline().strip() size = int(size_line.split(b";")[0], 16) # 分号后面是 chunk 扩展,可忽略 if size == 0: break # 0 长度块 = 流结束 payload = f.read(size) f.read(2) # 吃掉块尾固定两字节 CRLF feed_rtcm(payload) # 交给 RTCM 3.x 帧解析器

几个参数值得盯住:size_line.split(b";")[0]必须写,因为允许出现 chunk 扩展;f.read(size)要用精确读,用read(4096)会把下一块的长度行一起吃进去;f.read(2)那两字节不能省,省掉之后下一次readline读到的是\r\n拼上长度,解析会一路错位。feed_rtcm这层要做的事是按0xD3找帧头、读 10 位长度、校验 CRC24Q,然后按消息号分发——这一步和 NTRIP 无关,属于 RTCM 本身的帧格式。

还有一个现实约束:chunk 大小由 Caster 决定,可能一条 RTCM 消息一个 chunk,也可能攒几百毫秒一个 chunk。客户端不能假设“一个 chunk 正好一帧”,必须自己维护缓冲区做粘包拆包。

3.4 认证与错误处理:401 之后 Caster 到底在等什么

标准列了两种认证:Basic 和 Digest。Basic 把user:password做一次 base64 塞进Authorization头,本质是明文,只能靠外层 TLS 保命;Digest 用 nonce 做挑战应答,密码不上链路,但需要服务端保存明文口令或等价物,实现复杂度也高。

现实里绝大多数 Caster 只支持 Basic,于是部署方式变成“Basic 认证 + TLS 前置”。这套组合不是协议规定的,是被生态逼出来的:接收机固件里塞一个完整的 Digest 实现成本不低,而运营商又不太愿意在公网裸奔密码。

错误处理遵循 HTTP 状态码语义:凭据缺失或错误回 401 并带WWW-Authenticate,mountpoint 不存在回 404,请求头畸形回 400,Caster 侧源端掉线或达到连接上限则常见 503 之类。这里有个容易误解的行为:401 之后 Caster 可能不会立刻断开,而是保持连接等你重发带凭据的请求,具体是关还是留取决于实现。写客户端时不要假设“401 意味着连接已断”,重试之前先确认 socket 状态,否则会踩到向半关闭连接写数据的坑。

4. RTSP/RTP 通道:标准作者为什么又开了第二条路

如果 HTTP 已经够用,2.0 没必要引入 RTSP。这条分支存在,说明纯 TCP 方案在某些场景下确实不合适。

4.1 在 HTTP 之外再开一条路的动机

差分改正是“最新值有效”的数据:一个 3 秒前的改正数,如果这 3 秒里卫星几何或电离层已经变了,它的价值远低于刚生成的那个。而 TCP 的可靠传输语义是“不丢、按序”,一旦中间某段丢包触发重传,后面已经攒好的新数据全部堵在接收缓冲区里排队,等重传补上才继续交付。这就是队头阻塞在高实时流上的具体表现:带宽没满,延迟却涨了。

RTP over UDP 把这条约束反过来:丢了就丢了,接收端拿到的永远是当前最新的包,不会为了补一个过期包而卡住后面的。代价是要自己处理乱序、丢包统计和会话保活——这些正是 RTSP 那部分控制逻辑要干的活。

4.2 SETUP / PLAY / TEARDOWN:控制面与数据面被拆开

RTSP 的分工很明确:控制走 TCP,数据走 UDP,两条通道各管各的。

# 1) 建立会话:告诉 Caster 我在哪个 UDP 端口上等数据 SETUP rtsp://caster.example.net:2101/BASE01 RTSP/1.0 CSeq: 1 Transport: RTP/UDP; unicast; client_port=6000-6001 # 2) Caster 回自己那一侧的端口,并给出会话号 RTSP/1.0 200 OK CSeq: 1 Transport: RTP/UDP; unicast; client_port=6000-6001; server_port=7000-7001 Session: 12345678 # 3) 开始推送 PLAY rtsp://caster.example.net:2101/BASE01 RTSP/1.0 CSeq: 2 Session: 12345678 # 4) 结束 TEARDOWN rtsp://caster.example.net:2101/BASE01 RTSP/1.0 CSeq: 3 Session: 12345678

client_port给的是成对的端口:偶数给 RTP 数据,下一个奇数给 RTCP 控制报文。Caster 收到PLAY之后开始往这对端口打 UDP 包,TEARDOWN之后停止。Session是后续所有请求的凭据,没有它 Caster 认不出你属于哪个会话。

这个拆分带来一个明显好处:控制通道可以承载鉴权、错误回执、参数协商,而数据通道保持极简,接收端只需要一个 UDP socket 加一个 RTCM 解析器。坏处是两条通道的生命周期要自己对齐,一条断了另一条未必知道——这正是下一节要说的。

4.3 Keep-Alive 与初始 RTP 包:UDP 上没有“连接”这回事

UDP 是无连接的,Caster 无法通过 socket 状态判断客户端还在不在。它唯一的信息来源是 RTSP 控制连接上的保活。客户端必须周期性地发保活请求(常见实现是发一个带Session头的空 RTSP 请求,或用GET_PARAMETER探测),间隔要短于 Caster 的超时阈值。具体数值各家不同,标准给的是原则性建议,工程上一般取几十秒量级,配置时以对端文档为准。

漏发保活的后果很典型:数据流跑了几十分钟,突然安静,客户端 socket 还开着,就是收不到包。原因是 Caster 侧判定会话超时,回收了 UDP 发送通道,但控制连接可能还没断干净,于是出现“连接活着、数据死了”的假象。

初始 RTP 包解决的是另一个问题。客户端大多在 NAT 后面,NAT 只为“从内向外发过包的端口”保留映射。如果客户端只发了SETUP和PLAY就干等,NAT 上可能还没有 6000 端口出向流量的记录,Caster 打过来的第一个 UDP 包会被直接丢弃。标准的做法是 Caster 在正式开始推流前,先往客户端端口发一个初始 RTP 包,把 NAT 的映射关系“捅”出来。这个包可能不含有效载荷,序号和时间戳也可能是占位值,接收端必须能识别并丢弃它,而不是当成第一帧数据塞进解析器。

4.4 Plain RTP:砍掉控制面之后剩下什么

第三种模式更彻底:不要 RTSP,Caster 直接往一个事先约定好的地址和端口推 RTP。适合的场景很明确——固定接收端、内网分发、一对多的单向广播,比如把一路改正数同时推给同一网段里的多台设备。

砍掉控制面之后,鉴权、会话管理、按需启停全没了,取而代之的是静态配置。它的可靠性完全依赖网络本身:接收端重启,Caster 不知道;Caster 重启,接收端也不知道,只能靠超时自己发现没数据了再重建。所以这种模式在生产环境里通常只出现在受控网段,公网场景还是得走前两种。

5. 排查:NTRIP 链路上最容易翻车的五种现场

协议本身不复杂,复杂的是它横跨了应用层、传输层和网络边界。下面这五类现象,基本覆盖了对接新 Caster 时八成以上的时间消耗。

5.1 现象:200 OK 回来了,读到的却是十六进制长度行

现象:连接建立、状态码 200,但读出来的前几个字节不是0xD3开头,而是一串像1a4这样的十六进制数字加 CRLF。

原因:Caster 回了Transfer-Encoding: chunked,客户端没解帧,把 chunk 长度行当成了 RTCM 数据。1.0 时代的代码直接裸读 socket,升级到 2.0 后就暴露了。

解决:先在响应头里查Transfer-Encoding,有 chunked 就走解帧路径;同时确认请求里带了Ntrip-Version: Ntrip/2.0,否则某些 Caster 会用不带 chunked 的老方式回包,你的解帧逻辑反而会读错。

# 只看响应头,确认 Caster 到底用了哪种 framing curl -s -o /dev/null -D - --max-time 5 \ -H "Ntrip-Version: Ntrip/2.0" -u user:pass \ http://caster.example.net:2101/BASE01

5.2 现象:凭据明明对,Caster 一直回 401

现象:用户名密码在别处能登,这里反复 401,日志里看不到任何请求到达业务逻辑。

原因:三种可能——base64 编码前拼错了分隔符;mountpoint 带了尾随空格或大小写不匹配,被路径匹配失败统一归到鉴权分支;密码里含@、:、#等字符,配置文件的解析环节把它截断了。

解决:用 curl 的-u而不是手写Authorization头,先排除编码问题;再单独请求/拉 sourcetable(很多 Caster 对 sourcetable 不鉴权),确认表里确实有你想要的 mountpoint 原始拼写,逐字符比对,注意大小写。

5.3 现象:跑了半小时好好的,突然静默

现象:数据流正常接收几十分钟后停止,TCP 连接没断,也没有 FIN,就是没有新数据。

原因:Caster 侧会话超时被回收。走 HTTP 通道时可能是触发了最大连接时长限制;走 RTSP 通道时是保活没发或间隔太长。也可能是源端 Server 掉线,Caster 没关客户端连接,只是不再有数据可转发。

解决:客户端侧加一个“数据静默超时”,超过 N 秒没收到完整帧就主动断开重连,别指望对端通知你。RTSP 通道按对端要求周期发保活。同时向运维确认源端在线状态——区分“我这边断了”和“上游没数据”,这一步能省掉大量无效排查。

5.4 现象:定位只有米级,怎么调都不是厘米级

现象:链路完全正常,消息也在解,但固定解始终拿不到,最好也就是浮点解或者米级。

原因:多数是拿错了流。sourcetable 里FORMAT-DETAILS决定了这条流里到底有什么:如果只有1004(GPS L1/L2 观测)而没有1074、1084这类 MSM 消息,多系统多频的整周模糊度就固定不了;如果流动站距离参考站几十公里以上,电离层和对流层的空间去相关会让误差直接吃掉厘米级余量。

解决:把 sourcetable 里目标 STR 记录的FORMAT-DETAILS抄出来,和接收机实际解出的消息号列表逐项对照。缺 MSM 就换流,距离太远就换更近的站或改用网络 RTK 服务,并且确认 GGA 一直在正常上行。

5.5 现象:RTSP 走通了,UDP 端口一个包都没收到

现象:SETUP和PLAY都回 200,Session也拿到了,抓包看本机 UDP 端口干干净净。

原因:NAT 或防火墙把入向 UDP 挡了。客户端的 6000 端口还没产生出向流量,映射不存在;或者企业防火墙策略默认拒绝所有入向 UDP。

解决:先确认接收端确实 bind 在client_port指定的偶数端口上,且先于PLAY完成;再检查 Caster 是否发了初始 RTP 包(很多实现有开关);实在不行退到 RTSP over TCP 或直接回 HTTP 通道,用可控的延迟换连通性,别在 UDP 上死磕。

6. 设计取舍:把流协议做成 HTTP 子集,代价付在哪里

NTRIP 的设计哲学可以用一句话概括:用最小的实现成本,换取最大的生态兼容性。这个选择带来的收益在二十年后依然明显——任何一台能发 HTTP 请求的设备都能当客户端,任何懂 HTTP 的工程师半天就能看懂抓包。但代价也是实打实的,值得在选型时想清楚。

维度HTTP 通道RTSP/RTP 通道Plain RTP
传输TCP控制 TCP + 数据 UDPUDP
丢包行为重传,可能队头阻塞丢弃,拿最新值丢弃,拿最新值
鉴权Basic/Digest,成熟借 RTSP 头承载无
会话管理无,靠连接本身Session + Keep-Alive无
穿透性好,走标准端口需处理 NAT 与 UDP 策略差
适合场景公网、移动网络、通用客户端低延迟、内网、可控网络固定接收端广播

真正值得琢磨的是第二列和第一列的差异。同样的数据,走 TCP 会为了补一个已经过期的包而拖慢后面的新包,这在“最新值有效”的流上是纯粹的成本;但换成 UDP 就要自己补上会话、保活、NAT 穿透这三块,复杂度和排障难度成

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询