NTRIP客户端开发:从RTCM拉流到厘米级RTK定位
简介:面向高精度卫星导航定位开发者的NTRIP客户端源码包,围绕通过互联网接收实时差分修正数据这一核心需求,提供与NTRIP服务器及转发器通信的客户端实现,可用于厘米级实时动态定位的数据接入与联调。压缩包共十一个文件、约五十八千字节,包含C语言主控源码、串口通信头文件、编译构建脚本、启动脚本、说明文档及已编译产物,分别对应网络请求、串口读写、工程构建与命令行使用等环节,目录组织紧凑,便于按模块阅读。已有三百零六人学习下载,适合测绘导航、物联网终端及嵌入式定位模块的入门与进阶开发者参考。读者可据此梳理差分数据接收流程,理解串口参数配置、数据转发和启动脚本的配合方式,并基于现有代码进行二次开发、移植验证或排错分析。
1. 从米级到厘米级:ntripclient 到底替模块干了什么
板子上焊一颗双频 GNSS 模组,串口吐出来的坐标小数点后五位看着挺唬人,实际在开阔地漂一两米,车规场景根本不敢用。想让模组出 RTK 固定解,光有接收机不够——天线只负责收卫星信号,真正把误差压到厘米级的那份差分改正数,得从网络上拉回来。ntripclient 就是这么个东西:它跑在设备侧,用 TCP 连上播发服务,报上挂载点和账号,把源源不断的 RTCM 差分电文原样喂进模组串口。适合正在做农机导航、车载定位、测量杆、无人机地面站的人,平台是 Linux 或者带网络栈的 MCU 都行。协议本身不复杂,翻车的地方全在细节里。
2. NTRIP 拉流链路:从 TCP 握手到 RTCM 字节流的全貌
2.1 一次拉流里到底发生了什么
NTRIP 这协议拆开看就是「HTTP 拉一个永不结束的响应体」。客户端先跟播发服务(caster)的 2101 端口建 TCP,然后发一个带特殊头的 GET 请求,caster 校验账号和挂载点,通过之后不再返回 HTML,而是把二进制差分流直接怼过来——连接不断,数据不停。之所以说它像 HTTP,是因为握手部分真的就是 HTTP/1.1 的语法,只是响应行可能是ICY 200 OK(1.0 风格)也可能是HTTP/1.1 200 OK(2.0 风格),两种都得认。
请求报文长这样:
GET /RTCM32_TEST HTTP/1.1 Host: caster.test:2101 Ntrip-Version: Ntrip/2.0 User-Agent: NTRIP myclient/1.0 Authorization: Basic dGVzdDp0ZXN0MTIz四个头各有分工:GET后面那串是挂载点,写错了 caster 不会报错,而是非常「贴心」地返回一份源列表(SOURCETABLE)文本,你的解析代码如果只看有没有 200,就会把这份文本当差分数据喂给接收机,模组当然一动不动。Ntrip-Version决定走 1.0 还是 2.0 的行为,2.0 要求客户端主动上报位置。User-Agent必须以NTRIP开头,有些 caster 会做前缀校验。Authorization是账号密码拼成user:pass再做 Base64,注意是 Base64 不是加密,明文等价。
响应正常时是这样:
HTTP/1.1 200 OK Server: NTRIP Caster/2.0 Content-Type: gnss/data Connection: close头结束的那个空行(\r\n\r\n)之后,第一个字节就是0xD3,RTCM 帧的开始。从这一刻起,任何「按行读」「按字符串处理」的想法都会把数据搅烂,必须切到纯二进制模式。
2.2 源码目录:六个文件各管一件事
这类客户端源码规模都不大,通常一两千行 C,摊开是这么个结构:
| 文件 | 职责 | 二次开发改动频率 |
|---|---|---|
ntrip_cfg.h | 挂载点、账号、caster 地址、超时参数集中定义 | 高,换项目第一个改它 |
tcp_link.c | socket 建连、超时设置、重连退避 | 中 |
ntrip_auth.c | Base64 编码、请求报文拼装 | 低,协议定死了 |
rtcm_parse.c | 0xD3同步、长度提取、CRC-24Q 校验、电文号解析 | 低 |
ring_buf.c | 环形缓冲,解决 TCP 半包 | 低 |
uart_fwd.c | 串口打开、波特率配置、整帧写出 | 中,跟着硬件走 |
app_main.c | 线程编排、状态机、GGA 定时上报 | 中 |
分层的意义在于:协议相关的三个文件(auth、rtcm_parse、ring_buf)基本是一次写死、长期不动,真正随项目变的是 cfg 和 uart_fwd。把易变的部分挤到配置层,后面接不同模组、换不同播发服务,改的都是同一处。
2.3 线程模型:一个收、一个转发、一个看门狗
单线程能不能跑?能,用select把 socket 和串口塞进一个循环里也行。但实际项目里我更倾向三线程:
接收线程只管recv往环形缓冲里灌,不解析;解析转发线程从环形缓冲取数据、切帧、写串口;看门狗线程每秒钟检查一次「上次收到有效数据的时间戳」,超时就把 socket 关掉重连。
这么拆的好处是隔离。recv阻塞超时、串口写满阻塞、DNS 解析卡住,任何一个环节抖一下都不会把整条链路拖死。环形缓冲就是两个线程之间的唯一交点,只要它的读写是原子的,锁都可以省掉。
提示:环形缓冲别开太大。差分流是有时效的,缓冲 64KB 意味着网络抖动时你会在本地压住好几秒的旧数据,接收机拿着过期改正数算出来的固定解,比不定位还危险。
3. 核心模块拆解:建连、鉴权、响应解析三段代码逐行过
3.1 连接参数与 TCP 握手
先把配置收进一个结构体,后面所有函数只认这个结构体,换挂载点时改一处就够:
/* ntrip_cfg.h —— 所有连接参数集中在这里,方便换挂载点 */ typedef struct { char host[64]; /* caster 域名或 IP */ int port; /* 明文 2101,走 TLS 一般是 2102 */ char mountpoint[32]; /* 挂载点,不带前导斜杠,如 RTCM32_TEST */ char user[32]; char pass[32]; int conn_timeout_ms; /* 建连超时 */ int recv_timeout_ms; /* 收包超时,超过就认为链路死了 */ } ntrip_cfg_t;建连部分用getaddrinfo而不是写死 IPv4,顺便把 IPv6 一起兼容了:
static int tcp_connect(const ntrip_cfg_t *cfg) { struct addrinfo hints = {0}, *res = NULL, *p = NULL; char portstr[8]; int fd = -1; hints.ai_family = AF_UNSPEC; /* v4/v6 都试 */ hints.ai_socktype = SOCK_STREAM; snprintf(portstr, sizeof(portstr), "%d", cfg->port); if (getaddrinfo(cfg->host, portstr, &hints, &res) != 0) return -1; /* DNS 失败,上层负责退避重试 */ for (p = res; p != NULL; p = p->ai_next) { fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); if (fd < 0) continue; /* 收包超时交给内核,避免 recv 永久阻塞把转发线程挂死 */ struct timeval tv = { cfg->recv_timeout_ms / 1000, (cfg->recv_timeout_ms % 1000) * 1000 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); int one = 1; /* 差分包小、要低延迟,关掉 Nagle 别攒包 */ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break; /* 连上了 */ close(fd); fd = -1; } freeaddrinfo(res); return fd; }SO_RCVTIMEO是这里的核心。差分流正常是 1Hz 甚至 5Hz 持续推送,一旦超过 3 秒没有字节到达,基本可以判定链路已经断了(只是内核还没感知到),这时候靠超时返回让上层重连,比等 TCP 自己的重传超时快得多——后者默认可能要几分钟。
TCP_NODELAY也别省。差分电文单帧几百字节,Nagle 算法会为了攒够 MSS 把它压一压,压出来的几十毫秒延迟,在 1Hz 的改正数节奏里就是实打实的定位滞后。
conn_timeout_ms在这个函数里其实没生效,connect是阻塞调用,不受SO_SNDTIMEO约束,这是很多人第一次写网络代码的翻车点,后面避坑章节专门讲。
3.2 鉴权报文:Base64 和那三个必须的头
Base64 编码自己撸一遍也就三十行,不用引第三方库:
/* 标准 Base64,输出结尾补 '\0',够 64 字节的 user:pass 用 */ static int b64_encode(const uint8_t *in, int inlen, char *out, int outsz) { static const char tbl[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; int i, o = 0; for (i = 0; i + 2 < inlen; i += 3) { if (o + 4 >= outsz) return -1; out[o++] = tbl[in[i] >> 2]; out[o++] = tbl[((in[i] & 0x03) << 4) | (in[i+1] >> 4)]; out[o++] = tbl[((in[i+1] & 0x0F) << 2) | (in[i+2] >> 6)]; out[o++] = tbl[in[i+2] & 0x3F]; } /* 尾部剩 1 或 2 字节,补 '=' */ if (i < inlen) { if (o + 4 >= outsz) return -1; out[o++] = tbl[in[i] >> 2]; if (i + 1 < inlen) { out[o++] = tbl[((in[i] & 0x03) << 4) | (in[i+1] >> 4)]; out[o++] = tbl[(in[i+1] & 0x0F) << 2]; out[o++] = '='; } else { out[o++] = tbl[(in[i] & 0x03) << 4]; out[o++] = '='; out[o++] = '='; } } out[o] = '\0'; return o; }拼报文的时候有个坑:账号密码里如果带特殊字符(比如@、:),拼成user:pass后语义就乱了,Base64 之前没法区分哪个冒号是分隔符。实际项目里账号基本是平台下发的纯字母数字,但配置加载阶段最好拦一下,遇到冒号直接报错退出,比运行时收到 401 一脸茫然强。
static int ntrip_send_request(int fd, const ntrip_cfg_t *cfg) { char req[512], auth[96], b64[160]; int n; snprintf(auth, sizeof(auth), "%s:%s", cfg->user, cfg->pass); if (b64_encode((const uint8_t *)auth, strlen(auth), b64, sizeof(b64)) < 0) return -1; n = snprintf(req, sizeof(req), "GET /%s HTTP/1.1\r\n" /* 斜杠在前缀里,mountpoint 不带 */ "Host: %s:%d\r\n" "Ntrip-Version: Ntrip/2.0\r\n" /* 想兼容老 caster 就改成 1.0 */ "User-Agent: NTRIP myclient/1.0\r\n" "Authorization: Basic %s\r\n" "Connection: close\r\n" /* 反正也不复用连接 */ "\r\n", cfg->mountpoint, cfg->host, cfg->port, b64); return send(fd, req, n, 0) == n ? 0 : -1; }报文末尾那个空行(最后的\r\n\r\n)不能少,少一个\r\n,caster 就会一直等你的头部结束,表现为「连上了但没数据」,日志里干干净净什么都看不到。
3.3 响应头解析:认出 ICY 200 OK
这里我选择逐字节读,而不是一次recv一大块:
/* 返回 0 成功;-1 网络错;-2 鉴权失败;-3 挂载点不存在 */ static int ntrip_parse_response(int fd) { char buf[512]; int total = 0, n; /* 逐字节读到空行:响应头后面紧跟二进制流,多读一个字节都是污染 */ while (total < (int)sizeof(buf) - 1) { n = recv(fd, buf + total, 1, 0); if (n <= 0) return -1; total++; if (total >= 4 && memcmp(buf + total - 4, "\r\n\r\n", 4) == 0) break; } buf[total] = '\0'; if (strstr(buf, "401")) return -2; /* 账号密码错 */ if (strstr(buf, "404")) return -3; /* 挂载点不存在 */ if (strstr(buf, "ICY 200 OK") || strstr(buf, "200 OK")) { /* 挂载点写错时 caster 会返回源列表,这里是第二道防线 */ if (strstr(buf, "SOURCETABLE")) return -3; return 0; } return -1; }逐字节读看着低效,但响应头通常不到 200 字节,一次系统调用的开销在这个量级上完全可以忽略,换来的是「绝不多读」。如果用recv(fd, buf, 1024, 0),很可能一次把响应头和后面几百字节的差分数据一起读进来,那些字节必须手动塞回环形缓冲,一旦忘了,接收机就永远丢开头那几帧——而且看起来像随机丢包,极难查。
顺序上也要注意:先判 401/404,再判 200。因为 404 的响应体里经常带着一段 HTML 说明,里面可能恰好出现「200」字样。分状态码返回的意义在于上层能做差异化处理:401 重试没意义,直接告警;404 说明挂载点配错,该提示人工介入;只有 -1 才值得退避重连。
3.4 VRS 挂载点的 GGA 上报
挂载点分两类,一类是单基站,caster 无脑转发某一台参考站的观测值;另一类是 VRS(虚拟参考站),caster 要根据你的概略位置实时生成一份改正数。后者要求客户端周期上报自己的位置,通常就是把接收机输出的 GGA 语句转发给 caster。
/* 直接把一整条 GGA 丢进 socket,caster 自己解析,1Hz 足够,别刷太快 */ static void nmea_send_gga(int fd, const char *gga_without_crlf) { char line[128]; uint8_t sum = 0; const char *p = gga_without_crlf; int n; if (*p == '$') p++; /* 校验和不含 '$' */ while (*p && *p != '*') sum ^= (uint8_t)*p++; n = snprintf(line, sizeof(line), "$%s*%02X\r\n", gga_without_crlf, sum); send(fd, line, n, 0); }三个参数说明一下:fd就是拉流那条 socket,GGA 往同一条连接里写,不要另开一条;gga_without_crlf是从接收机串口抓到的原始语句,去掉$和*XX之后的净内容,直接把接收机的输出转出去最省事,省得自己做度分转换;校验和用异或累加,范围是从$后第一个字符到*前一个字符。
上报频率卡在 1Hz 就行。有些 caster 对 GGA 频率有硬限制,超过会直接把你踢下线,而且现象很迷惑——连上几十秒后断开,日志里没有任何错误码。定位还没固定时,上报的 GGA 质量位是 0,部分 caster 会拒绝生成 VRS,所以冷启动阶段要允许「先连上、等定位、再开始上报」这个顺序,而不是连上就立刻发一条质量位为 0 的假位置。
4. 数据面处理:0xD3 分帧、CRC-24Q 与串口转发
4.1 为什么必须按帧切:0xD3 与 CRC-24Q
TCP 是字节流,不保证你一次recv拿到的正好是整数帧。可能半帧、可能一帧半、可能三帧黏一起。如果直接把收到的字节块原样写串口,接收机那边的解析器会遇到长度字段对不上、CRC 校验失败,表现出来就是「偶尔能固定,大部分时间在浮动解」。
RTCM 3.x 的帧结构很规整:
| 字段 | 长度 | 说明 |
|---|---|---|
| 前导码 | 1 字节 | 固定0xD3 |
| 保留 + 长度 | 2 字节 | 高 6 位保留,低 10 位是载荷长度(0~1023) |
| 载荷 | N 字节 | 前 12 位是电文号,如 1005、1074、1230 |
| CRC-24Q | 3 字节 | 覆盖前导码到载荷末尾 |
解析函数就是围绕这张表写的:
#define RTCM_PREAMBLE 0xD3 /* 返回整帧长度(含头尾),0 表示数据不够等下一批,-1 表示本帧被丢弃 */ static int rtcm_parse(ring_buf_t *rb, uint8_t *out, int outsz) { while (rb->len >= 3) { if (rb_peek(rb, 0) != RTCM_PREAMBLE) { /* 不同步就滑一个字节 */ rb_drop(rb, 1); continue; } int len = ((rb_peek(rb, 1) & 0x03) << 8) | rb_peek(rb, 2); if (len == 0 || len > 1023) { /* 长度非法,假的 0xD3 */ rb_drop(rb, 1); continue; } int total = len + 6; /* 3 头 + len 载荷 + 3 CRC */ if ((int)rb->len < total) return 0; /* 半包,等下一次 recv */ if (total > outsz) { /* 调用方缓冲不够,丢弃 */ rb_drop(rb, total); continue; } uint8_t frame[1029]; rb_peek_n(rb, frame, total); uint32_t crc_calc = crc24q(frame, len + 3); uint32_t crc_recv = ((uint32_t)frame[len + 3] << 16) | ((uint32_t)frame[len + 4] << 8) | (uint32_t)frame[len + 5]; if (crc_calc != crc_recv) { rb_drop(rb, 1); /* 只丢一字节,重新同步 */ continue; } int msg = (frame[3] << 4) | (frame[4] >> 4); /* 12bit 电文号 */ (void)msg; /* 需要筛选就在这里判断 */ memcpy(out, frame, total); rb_drop(rb, total); return total; } return 0; }几个关键点值得单独说。len只有 10 位,所以最大 1023,任何解出来超过 1023 的值都说明这个0xD3是载荷里碰巧出现的假前导码,必须滑过去。CRC 校验失败时丢 1 字节而不是丢整帧,因为整帧丢会把后面真正的好帧一起带偏;滑一字节虽然慢,但最终一定能重新对上。msg是电文号,实际项目里经常要按它做过滤——比如只把 1005(测站坐标)、1074/1084(GPS 观测)、1094(北斗观测)转给模组,其他电文直接扔掉,能省不少串口带宽。
CRC-24Q 的实现用查表法,一张 256 项的 32 位表,编译进 flash 也就 1KB:
static uint32_t crc24q(const uint8_t *data, int len) { static const uint32_t tbl[256] = { /* 由多项式 0x1864CFB 生成,略 */ }; uint32_t crc = 0; for (int i = 0; i < len; i++) crc = (crc << 8) ^ tbl[((crc >> 16) ^ data[i]) & 0xFF]; return crc & 0xFFFFFF; }表格生成写在初始化里也行,但更常见的是直接固化常量表,省掉启动阶段的计算。
4.2 环形缓冲与半包处理
环形缓冲本身没什么好讲的,值得讲的是它的边界语义。我一般让rb_write返回实际写入字节数,写不下就丢掉最老的数据并返回一个负的提示值——注意是丢老的,不是丢新的。丢新数据会让当前这批帧永远缺一块,丢老数据最多是延迟多几毫秒。
typedef struct { uint8_t *buf; size_t cap; size_t head; /* 读指针 */ size_t tail; /* 写指针 */ size_t len; } ring_buf_t; /* 返回实际写入字节数;空间不足时丢弃最老数据,返回值小于 len 就是丢了 */ size_t rb_write(ring_buf_t *rb, const uint8_t *data, size_t len) { size_t written = 0; while (written < len) { if (rb->len == rb->cap) { /* 满了,挤掉最老的 1 字节 */ rb->head = (rb->head + 1) % rb->cap; rb->len--; } rb->buf[rb->tail] = data[written++]; rb->tail = (rb->tail + 1) % rb->cap; rb->len++; } return written; }容量给 8KB 到 16KB 之间比较合适。按 1Hz、每帧 300~800 字节、每秒十来帧算,16KB 能扛住一秒左右的网络抖动而不用重连。再大就变成延迟放大器了。
转发线程的主循环长这样:
static void *fwd_thread(void *arg) { app_ctx_t *ctx = (app_ctx_t *)arg; uint8_t rx[2048], frame[1029]; int n; while (ctx->running) { n = recv(ctx->sock, rx, sizeof(rx), 0); if (n <= 0) { /* 超时或对端关闭,交给上层重连 */ ctx->link_alive = 0; break; } ctx->last_rx_ms = now_ms(); /* 喂看门狗 */ rb_write(&ctx->rb, rx, n); while ((n = rtcm_parse(&ctx->rb, frame, sizeof(frame))) > 0) uart_write(ctx->uart_fd, frame, n); /* 整帧写出,不做二次拼接 */ } return NULL; }uart_write必须是同步写完的,不能丢一部分返回。串口写满了就阻塞等,或者用tcdrain确保数据真的推出去了再返回。这里有个容易忽略的细节:串口写和读如果在同一个 fd 上,多线程操作要加锁,或者干脆把串口配成原始模式(cfmakeraw)后只在一处读写。
4.3 断线重连与差分龄期
重连策略用指数退避,1s、2s、4s、8s,封顶 30s,连上之后重置。别用固定 1s 猛冲——caster 侧通常会封禁短时间内反复重连的 IP,一封就是几十分钟,比断线本身更难受。
static int backoff_ms(int attempt) { int ms = 1000 << (attempt > 5 ? 5 : attempt); return ms > 30000 ? 30000 : ms; }比重连更隐蔽的是差分龄期问题。接收机算 RTK 时会给每份改正数打时间戳,龄期超过阈值(一般 30 秒)就降级成浮动解。客户端这边如果因为重连、串口阻塞把数据在本地压了几秒,接收机看到的龄期是虚高的,表现为「网络明明通着,定位却一直在浮动」。所以转发链路上任何一处缓冲都不能大,uart_write也不能加超长队列。我见过把差分数据先写文件再异步转发的设计,本地一测没问题,上了车就废,就是被这个龄期坑的。
5. 避坑排查:五个把我按在调试台上的 NTRIP 客户端问题
5.1 连上了但一个字节都不来
现象:日志打印「连接成功」,然后recv一直超时返回 -1,caster 那边看连接是活的。
原因:请求报文少了结尾的空行,或者mountpoint前面多写了一个/,变成GET //RTCM32。前者让 caster 一直等你把 HTTP 头发完,后者挂载点匹配不上。
解决:抓一次包,把发出去的请求按十六进制打出来,确认以0d 0a 0d 0a结尾,并核对GET后面的路径只有一个斜杠。这个检查值得做成代码里的断言,第一次调通以后就再也没出过。
5.2 挂载点写错,接收机收到一堆 ASCII
现象:连上后串口有数据在动,但模组一直不定位,抓出来看是STR;RTCM32...开头的文本。
原因:挂载点不存在时 caster 返回的是源列表(SOURCETABLE),响应行仍然是 200,光判断状态码根本发现不了。
解决:两处兜底。响应头里出现SOURCETABLE直接当失败处理;进数据流后再校验第一个字节必须是0xD3,不是就说明拉错了东西,立刻断开重连并告警。
5.3 connect 卡住七十多秒
现象:拔网线再插上,程序要愣一分多钟才恢复,日志时间戳上有个巨大的空档。
原因:SO_SNDTIMEO和SO_RCVTIMEO都不作用于阻塞式connect,TCP 自己的 SYN 重传要跑完才返回。
解决:把 fd 设成非阻塞,connect返回EINPROGRESS后用select或poll等可写事件,超时就close掉换下一个候选地址。这套写法比直接阻塞多十几行,但换来的确定性很值。
5.4 高纬度或高海拔站点 GGA 上报格式不对
现象:在南方测试一切正常,换到高海拔场地后 caster 回的是单基站差分,固定解距离明显变长。
原因:自己拼 GGA 时把海拔字段填成了椭球高,或者把度分转换的整数位补零规则写错,caster 解析出的概略位置偏出去几百公里,生成的 VRS 自然不对。
解决:别自己拼,直接把接收机原始输出的 GGA 语句原样转发,顶多重新算一遍校验和。接收机自己输出的语句格式一定符合规范,这是最省事的做法。
5.5 串口波特率不够导致丢字节
现象:静态测试正常,一上车就开始间歇性丢固定解,接收机日志里有 CRC 错误计数。
原因:全星座多频电文一秒能到 4~6KB,115200 波特率理论上够,但实际有效吞吐受串口驱动和中断延迟影响,加上接收机同时在往同一串口吐 NMEA,很容易堵。
解决:差分和 NMEA 分两个串口走,或者把波特率提到 460800。另外把不需要的电文在客户端侧就过滤掉,只转观测值和测站坐标,能砍掉一半流量。
6. 二次开发:换挂载点、接 TLS、把验证固化成流程
拿到这套源码之后,最常改的三处地方我按改动成本排一下。
第一处是配置层。ntrip_cfg_t里的host、port、mountpoint、user、pass五项,实际项目里几乎都是运行时可配的,建议从配置文件或环境变量读,别编译进固件。改的时候注意mountpoint不带前导斜杠,这个约定要在注释里写死,否则接手的人一定踩。
第二处是串口层。换个模组,波特率和电文需求都变。这里可以加一张过滤表,按电文号决定转不转发:
| 电文号 | 内容 | 是否转发 |
|---|---|---|
| 1005 | 测站坐标(ARP) | 是 |
| 1074 | GPS MSM4 观测值 | 是 |
| 1084 | GLONASS MSM4 | 是 |
| 1094 | 北斗 MSM4 | 看模组是否支持 |
| 1230 | GLONASS 码偏差 | 是 |
| 1029 | Unicode 文本 | 否 |
/* 按电文号过滤,返回 1 表示要转发 */ static int rtcm_wanted(int msg) { switch (msg) { case 1005: case 1074: case 1084: case 1230: return 1; case 1094: return g_cfg.enable_beidou; /* 北斗按模组能力开关 */ default: return 0; } }第三处是安全层。明文 2101 端口在公网上跑,账号密码是 Base64,等于裸奔。要接 TLS,把tcp_connect换成 TLS 握手,端口改 2102,后续所有recv/send换成 TLS 对应接口,请求报文和解析逻辑完全不用动——这也是当初把网络层单独抽出来的价值。
验证方法我一般走三步。先离线用环形缓冲灌一段录制好的差分流,确认rtcm_parse能完整切出每一帧且 CRC 全部通过;再连真实播发服务,用tcpdump对着看请求报文有没有多余字符;最后接上模组,看定位从单点跳浮动再到固定的时间,正常冷启动应该在 30 秒内收敛。三步走完,环路上任何一环出问题都能立刻定位到是哪一层。
有个习惯是从那次高海拔翻车之后养成的:每次改完配置层,我一定先把发出去的请求报文和收到的前 64 字节打十六进制 dump 一遍,确认它以0d 0a 0d 0a结束、以d3开头,再往下测。这几十秒的功夫,能省掉一整个下午的猜测。希望帮到你。
本文还有配套的精品资源,点击获取