做GNSS接收机或者RTK解算的朋友,谁没跟RTCM协议打过几回交道呢?尤其是近几年多频多系统普及之后,老一套的单系统消息格式越来越不够用,RTCM SC-104委员会在3.2 Amendment 1 / 3.3版本里推的MSM(Multiple Signal Message,多信号观测消息)已经成为事实上的标准。我最早接触MSM是被一堆“1074、1124”的消息号绕晕的,后来为了解析MSM语句把协议啃了一遍,才慢慢把卫星掩码、信号掩码、单元掩码这套逻辑整理清楚。
这篇文章就以MSM语句为中心,把多信号GNSS观测数据消息格式的整体框架、关键字段、掩码机制和实际解析要点拆开讲一遍。如果你正在做协议解析、PPP/RTK终端的固件开发,或者只是被手里的接收机日志逼到想弄明白MSM到底在传什么,那这篇文章应该能帮你省下不少翻标准文档的时间。我尽量把常用的参数、单位、位域含义和踩过的坑都写出来,带着例子讲,方便你直接照着处理数据。
1. 先捋清楚整体框架
1.1 MSM是什么,为什么会出现
MSM是RTCM 3.x协议里一组专门承载GNSS原始观测数据的消息类型。2016年RTCM 3.3标准正式把完整的MSM定义纳入规范,实际产品里更早的RTCM 3.2 Amendment 1时代就已经在用了。
在没有MSM之前,RTCM 3.x用的是老一批分系统消息,比如GPS的1001/1002/1004,GLONASS的1009/1010/1012。这类消息的问题很明显:GPS、GLONASS、Galileo、北斗、SBAS、QZSS每个系统都要单独定义一套消息号,而且老消息只覆盖单频或双频,一旦要做三频、四频的观测值传输,就得不停打补丁,兼容性搞得一塌糊涂。
MSM解决这个问题的思路非常直接:设计一套统一的消息骨架,再用“系统编号 + 消息编号”来区分不同系统与不同观测类型。所有系统共享同一套字段布局,卫星掩码和信号掩码用来动态表示“这条消息里有哪些卫星、哪些频点信号”,数据量也能根据实际内容自适应压缩。简而言之,MSM的出现让“一套协议传遍所有星座、所有频段”成为可能,这也是今天RTK和PPP设备几乎都认MSM的原因。
1.2 MSM1~MSM7:从精简到完整的选择题
MSM不是一个单独的消息,而是一整族,编号从MSM1一直到MSM7。它们的结构骨架完全一样,区别在于每条消息里装载的观测数据类型不同。
- MSM1:只包含伪距观测值
- MSM2:伪距 + 载波相位
- MSM3:伪距 + 载波相位 + 多普勒频移
- MSM4:伪距 + 载波相位 + 多普勒 + 信噪比(CNR)
- MSM5:伪距 + 载波相位 + 多普勒 + 信噪比 + 半周模糊度指示
- MSM6:在MSM5基础上增加FDMA信号的质量指示字段
- MSM7:最完整的版本,包含所有观测值类型以及详细的质量指示信息
你可能注意到了,MSM5在实际RTK/NTRIP服务里非常常见,大部分基准站默认输出的就是MSM4或MSM5级别。MSM7最齐全,但数据量也最大,对链路带宽要求更高。所以选哪个类型,本质上是在“信息完整度”和“传输效率”之间做权衡。
用消息号来区分的话,每个系统都有一个MSM1~7对应序列,常见的是:
| 系统 | MSM4 消息号 | MSM5 消息号 | MSM6 消息号 | MSM7 消息号 |
|---|---|---|---|---|
| GPS | 1074 | 1075 | 1076 | 1077 |
| GLONASS | 1084 | 1085 | 1086 | 1087 |
| Galileo | 1094 | 1095 | 1096 | 1097 |
| SBAS | 1104 | 1105 | 1106 | 1107 |
| QZSS | 1114 | 1115 | 1116 | 1117 |
| BDS | 1124 | 1125 | 1126 | 1127 |
这里有个细节值得留意:同样一条“MSM4”消息,前缀数字不同,对应的星座就不同。1074是GPS、1084是GLONASS、1094是Galileo、1124是北斗。协议解析的第一步就是先看消息号,确定星座类型,再决定怎么翻译后面的卫星编号和信号编号。
1.3 MSM消息号与实际产品的对应
你在NTRIP Caster上看到的挂载点,比如“RTCM3.2-MSMS”,通常会在数据流里混着1074/1075/1084/1085/1094/1095/1124/1125等多条消息。每一颗卫星在同一历元的观测值,会按星座分别放进不同的MSM消息中。
所以,一次完整的多系统观测数据,可能是GPS一条MSM5、GLONASS一条MSM5、Galileo一条MSM5、北斗一条MSM5同时出现。它们共享同一条数据流,各自独立成帧。解析器要维护好“当前正在组包的星座上下文”,否则很容易出现时间戳错位。
2. MSM消息结构逐层拆开看
2.1 公共头:所有MSM共用的开头部分
一条RTCM 3.x消息的外层结构由三部分组成:前导码(0xD3)、12位保留字段和12位消息长度、消息体,最后是24位CRC校验。MSM消息体内部,又分为公共头、卫星段、信号段和观测值段四层。
MSM公共头包含的字段包括:
- Message Number(12bit),例如1074
- Reference Station ID(12bit),基准站编号
- GNSS Epoch Time(30bit),以毫秒为单位的历元时间
- Multiple Message(1bit),指出同一历元是否还有后续消息
- IODS(3bit),数据站播发标识
- Reserved(1bit),预留位
- GNSS Divergence-free Smoothing Indicator(1bit)
- GNSS Clock Steering Indicator(2bit)
- GNSS External Clock Indicator(2bit)
- GNSS Smoothing Indicator(1bit)
- GNSS Smoothing Interval(3bit)
GNSS Epoch Time这个字段值得专门说一下。GPS、Galileo、QZSS、北斗的时间基准以毫秒计数,而GLONASS使用的是它自己的时间系统,与UTC之间存在整3小时的偏差。解析GLONASS MSM时,Epoch Time换算不能直接套GPS的周内秒逻辑,否则历元时间会整整偏掉3个小时。很多新手在这上面栽过跟头。
Multiple Message位也很关键。如果该位为1,说明当前历元的数据比较长,被拆成了多条MSM消息,需要等待同历元的后续消息全部到齐后,才能当作完整的一帧观测数据处理。我在实际调试中就遇到过只处理单条消息导致卫星数量“随机跳动”的问题,排查半天才发现是忽略了Multiple Message标志。
2.2 卫星段与信号段:掩码是怎么工作的
公共头之后,就是MSM最核心、也最容易把人绕晕的部分:卫星掩码(Satellite Mask)、信号掩码(Signal Mask)和单元掩码(Cell Mask)。
MSM的设计思想是,不直接挨个列出每个观测值,而是先用掩码描述“有哪些卫星、哪些信号”,再按掩码定义好的顺序依次排列观测值。这样,接收机只需要根据掩码中置为1的位去对应取数,数据紧凑且无冗余。卫星掩码的每个bit代表该星座一颗预定义的卫星,信号掩码的每个bit代表该星座一个预定义的信号频率。协议标准里为每个星座定义了参考卫星编号表和参考信号编号表,解析时必须按这些表翻译。
单元掩码则是在卫星掩码和信号掩码确定之后,生成一个N颗卫星 × M种信号大小的矩阵,逐位表示“这颗卫星是否有这个信号的观测值”。由于这个矩阵是按位压缩的,解析时候的位偏移计算是最考验细心的环节。
2.3 位域排布:高位在前,按顺序铺满
RTCM 3.x协议统一采用大端位序,也就是说,所有多bit字段都是最高位在前。这个约定贯穿整个MSM消息,不管读卫星掩码、信号掩码还是后续的细观测值,都要遵循同样的位序逻辑。
很多人在实现解析器时,喜欢直接把整个消息转成字节数组,然后逐bit取。这个思路没问题,但要特别注意:MSM里有些字段跨字节边界,比如32bit的信号掩码可能横跨第4字节、第5字节、第6字节和第7字节。任何一次按字节位移的“想当然”都可能导致数据整体错位。我在工程里习惯先把整条消息的位流提取成bool数组,再做后续取段,虽然稍微浪费一点内存,但可读性和排错效率都会高很多。
如果不用位流数组,那就得牢记公式:bit_offset = byte_index * 8 + bit_index,每次读取字段前先计算好起点和长度,再按需拼接。这类逐位拼接的代码一旦出问题,表现往往是“某颗卫星的伪距突然变成天文数字”,非常烦人。
3. 卫星段核心字段:掩码如何映射到实际卫星
3.1 卫星掩码的具体排布
卫星掩码字段按照GNSS系统不同,长度也不一样,通常是64bit。GPS的64bit中,低32位对应PRN 1到32,高32位通常预留用于SBAS卫星或其他补充编号;Galileo的64bit对应G1到G36,剩余的位预留;北斗的64bit对应C1到C37等。
掩码置位的顺序很重要。解析器必须先从低位开始扫描,找到所有置为1的bit,按从低到高的顺序给这些卫星编号0、1、2……N-1。这个顺序就是后续所有卫星数据字段的分组顺序。假如卫星掩码中第1位、第5位、第9位为1,那么卫星编号顺序就是卫星1、卫星5、卫星9,绝不会是卫星9排在前面。
如果把卫星掩码解析顺序搞反,后面所有观测值都会对应到错误的卫星上。RTK解算出错时,检查卫星掩码的扫描顺序是第一步,这是我从一次真实事故里总结出来的教训:当时我们发现某颗卫星的伪距残差总是异常大,最后定位到竟是卫星掩码高低位扫描方向写反了,固件里的注释还写着“LBS first”,改完瞬间恢复正常。
3.2 卫星数据段:伪距粗值与细值
卫星掩码确定后,消息会进入卫星数据段,里面包含与每颗卫星相关的粗略观测信息,这些信息用于辅助恢复完整观测值。
MSM里伪距观测值被拆成两个部分:一个是“粗值”(Rough Range),一个是“细值”(Fine Pseudorange)。“细值”是分辨率很高的部分,单位通常为0.001米,但它的取值范围很小,不足以覆盖整段星地距离。所以消息里还需要“粗值”来提供大尺度的距离段信息。粗值的单位通常对应较大的间隔,可以简单理解成“整毫秒量级”的距离信息。
载波相位也类似,分为“粗相位”和“细相位”。“细相位”分辨率为0.0001周,“粗相位”用来确定整周部分的数值。这两个部分配合在一起,接收机才能重建出完整的伪距和载波相位观测值。很多刚接触MSM的人会问:“为什么不直接存一个完整浮点数?”答案很简单:压缩。RTCM传输走的是串口、网络链路,带宽有限,粗/细值分离编码可以在不牺牲精度的前提下显著减少字节数。GNSS差分数据要求的是“传输前后一致”,不是“最省事地传输”,所以复杂一点也值得。
3.3 伪距、载波相位、多普勒的常用单位
MSM消息里,观测值数据的单位与常规RTCM老消息不同,也更细。这里把我常用的参考值列出来:
| 观测值类型 | 分辨率(常见约定) | 说明 |
|---|---|---|
| 伪距 | 0.001米 | 毫米级精度 |
| 载波相位 | 0.0001周 | 万分之一个载波周期 |
| 多普勒 | 0.0001Hz | 足够精细的速度观测信息 |
| 信噪比 CNR | 0.001dB-Hz | 信号质量指示 |
处理的时候一定要把这些分辨率乘回实际数值。我曾经调试某条MSM5消息,发现伪距输出一直比真实值小约300米,怀疑是天线问题,后来一查,原来是解析代码里把伪距细值默认当成“已经乘过0.001米”的浮点数,实际上协议里存的是原始整数,必须自己乘回去。这类“单位是否已经换算”的问题,是MSM解析里最常见也最隐蔽的坑。
4. 信号段核心字段:从掩码到观测量的完整链路
4.1 信号掩码:你看到的不是频率,而是编号
信号掩码紧随卫星掩码之后。它的作用与卫星掩码类似,不过表示的是“这条消息里出现了该星座的哪些信号”。
每个星座在RTCM标准里都维护着一张信号定义表,比如GPS的L1 C/A、L1P、L2P、L2C、L5,Galileo的E1、E5a、E5b、E6,北斗的B1I、B1C、B2a、B2b、B3I。信号掩码的每一个bit,依次对应这些信号编号。协议本身并没有直接把频率数值写进消息,解析器必须查表才能知道bit 3代表的是L1还是L5。这也是MSM格式学习曲线较陡的原因之一。
信号掩码的长度取决于该星座支持的信号总数,常见实现是32bit或64bit。比如只支持8种信号的系统用32bit足够,而现代全频段系统可能把扩展的64bit都利用上。解析时遇到信号掩码为64bit的消息,一定不能用旧的32bit逻辑硬套,否则后面全部字段错位。
4.2 单元掩码:观测值存在的“开关矩阵”
卫星掩码和信号掩码确定后,接下来是单元掩码(Cell Mask)。这个字段本质上是一个N×M的按位矩阵,行是卫星,列是信号。比如卫星掩码里有5颗卫星,信号掩码里有4个信号,单元掩码就可能有20个bit,逐一表示“该卫星在该频率上是否有观测值”。
为什么需要单元掩码呢?因为不是每颗卫星、每个频点都有可用的观测值,比如某颗卫星的L5信号可能暂时没有锁定。如果直接把所有N×M个观测值全量发送,会浪费很多带宽。单元掩码用0/1把无效组合过滤掉,解码端就知道哪些“格子”有数据、哪些没有。
解析单元掩码时要特别注意它的读取顺序:先按卫星顺序,再按信号顺序。也就是说,第一颗卫星的所有信号位排在最前面,然后是第二颗卫星的信号位。如果顺序搞反,数据不会报错,但会把L1的数据当成L5,事后排查非常麻烦。
4.3 信号数据的“两遍读取”问题
信号掩码和卫星掩码都解析完成后,消息就到了观测值数据段,也就是伪距、载波相位、多普勒、信噪比等物理量的实际数值。
MSM的处理逻辑是:先按照卫星段中“置位卫星”的排列顺序,逐颗卫星读取每颗卫星的伪距粗值;然后按照同样的顺序,逐颗卫星读取每颗卫星的伪距细值;再读载波相位的粗值和细值;再读多普勒、信噪比。每一类观测值都是独立地、按相同卫星顺序循环一遍。
有些解析器为了减少内存拷贝,试图“读一个格子的所有值再读下一个格子”,这在MSM里是行不通的。消息的排列方式是“所有卫星的所有伪距,再所有卫星的所有载波相位”,不是“每颗卫星的所有观测值,再下一颗卫星”。我把它叫作“两遍读取逻辑”,写代码时要把这个结构刻在脑子里。第一次接触MSM时,我就在这里实现错了,输出结果看起来很有规律,但对不上卫星编号,后来对照标准里的字段顺序图才修正过来。
信号数据还会分带外频率(FDMA)跳频场景,例如GLONASS的L1/L2信号,单元掩码里每个信号对应的实际载波频率并不完全一致。解析MSM时如果要做高精度定位,光把观测值解出来还不够,还得根据卫星编号和信号编号,去星历里查对应的实际频率。这部分属于MSM与导航电文的联动,单看消息本身是解不出频率值的。
5. 实操:自己动手解析一条MSM消息
5.1 准备工作:字节序、CRC和对齐
解析RTCM 3.x消息,第一步是找帧头0xD3。找到之后,读取第2、3字节中的12bit消息长度,然后按长度取出消息体,再校验消息末尾的24bit CRC。RTCM 3.x的CRC算法是CRC-24Q,多项式为0x1864CFB,初始值为0。这一环节跟MSM本身没太大关系,但几乎所有解析器问题都藏在CRC实现的差异里。
CRC校验通过后,就可以开始按MSM的逻辑取位。我建议先把整条消息(不包括CRC)展开成一个bit数组,然后从公共头开始,一个字段一个字段地切。bit数组的index从0开始,定位公式很直观:
- 消息号:bit 0 ~ bit 11
- 基准站ID:bit 12 ~ bit 23
- GNSS历元时间:bit 24 ~ bit 53
- 后续字段依次偏移
只要公共头的字段长度和顺序记清楚,后面的偏移量就能顺势推下来。我建立一个结构体,把所有字段名、位宽、偏移量都放进去,解析时按表驱动方式读取,这样后续扩展新消息号也方便。
5.2 解析伪代码的核心逻辑
以MSM4 GPS消息(1074)为例,解析流程可以简化成下面几步:
- 读取12位消息号,确认是1074;
- 读取公共头剩余字段,得到基准站ID和历元时间;
- 读取64位卫星掩码,扫描置1位,得到卫星列表;
- 根据星座类型读取信号掩码,得到信号列表;
- 计算卫星数与信号数的乘积,读取单元掩码,得到“哪些格子有有效观测值”;
- 按卫星顺序、信号顺序依次读取伪距粗值、伪距细值、载波相位粗值、载波相位细值、多普勒、信噪比;
- 将原始整数与分辨率相乘,恢复成物理量。
这段逻辑用伪码写出来大概是这样:
# 已提取出整个消息的bit数组 bits,长度为 total_bits msg_number = read_bits(bits, 0, 12) station_id = read_bits(bits, 12, 12) epoch_time = read_bits(bits, 24, 30) sat_mask = read_bits(bits, sat_mask_start, 64) sat_list = [i for i in range(64) if (sat_mask >> i) & 1] sig_mask = read_bits(bits, sig_mask_start, sig_mask_len) sig_list = [j for j in range(sig_mask_len) if (sig_mask >> j) & 1] cell_bits = read_bits(bits, cell_mask_start, len(sat_list) * len(sig_list)) # 按行优先顺序读取cell mask,sat_idx为外层循环,sig_idx为内层循环这段代码片段虽然只是示意,但已经能反映MSM解析的核心:先掩码、后单元、再数据。实际工程里还要处理Multiple Message标志、跨消息的历元合并等问题,但骨架就是这样的。
5.3 与RTKLIB等工具的结合经验
如果你不想从零写解析器,完全可以借助RTKLIB、GNSS-SDR、pyrtcm这类开源工具库。RTKLIB的lib/rcv/rtcm3.c里对MSM消息的解析写得很完整,可以直接学习它的数据结构定义与位读取方式。pyrtcm库在Python环境下调试数据流也很方便。
我用RTKLIB调试经验是:先用它把MSM消息解成RINEX观测文件,再对照RINEX里的卫星/信号顺序,去检查自己解析器的输出是否一致。RINEX是标准中间格式,比直接看二进制容易理解得多。只要两边在相同历元、相同卫星、相同信号上的观测值一致,基本就能判定自己的解析逻辑没大问题。
如果只是偶尔分析一段日志,用RTKLIB自带的rtkrcv或者convbin转一下格式就够了。但如果是做嵌入式设备,想把MSM解析集成到自己的固件里,那就必须认真把位解析逻辑吃透,开源代码可以当参考,但不能直接照搬,因为嵌入式环境往往对内存占用和数据拷贝更敏感。
6. 常见问题与排查技巧实录
6.1 典型问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 同一颗卫星的伪距偶尔跳变 | 单元掩码读取顺序错位 | 检查Cell Mask的遍历顺序,确认外层是卫星 |
| 历元时间差3小时 | GLONASS时间基准换算错误 | 按GLONASS时间系统单独处理 |
| 解算时卫星数永远偏少 | 忽略了Multiple Message标志 | 将同历元多条消息合并后再处理 |
| 信号掩码长度不一致导致整体错位 | 使用了旧的32bit固定长度 | 读取前先按系统类型和标准确认掩码长度 |
| 伪距数值特别大或特别小 | 忘记乘分辨率 | 确认原始整数与分辨率换算逻辑 |
这张表是我多年调试经验的浓缩。遇到类似现象,优先怀疑解析顺序和单位换算,而不是怀疑信号源本身。GNSS数据链路是出了名的“数据漂亮但含义全错”的地方,表面上看每个字段都有值,实际上字段位置早就错位了。
6.2 定位错位问题的独家技巧
如果怀疑解析器字段错位,有一个很实用的自查方法:选取一颗已知位置的基准站,利用卫星星历估算某颗卫星的伪距大致范围,再对比自己解析出来的伪距是否在同一量级。如果解析结果偏离几千公里,基本可以确定是掩码或粗值处理错误;如果只差几米,那可能是天线相位中心或者钟差修正的问题,跟格式无关。
另一个技巧是打印卫星掩码和信号掩码的原始bit图形。我会在调试时把64位卫星掩码打印成“10010001...”这样一串字符串,人工核对卫星PRN顺序。掩码一旦错了,后续所有步骤都没有继续排查的意义,先确认这个是最省时间的。
6.3 建议的调试顺序
我第一次调MSM解析时,脑子很乱,后来摸索出一套顺序,现在每次都按这个来:
- 先只解析公共头和卫星掩码,打印消息号、历元时间、卫星列表;
- 确认星历验证后,再解析信号掩码和单元掩码;
- 前两步稳定后,再解析伪距,并且只输出伪距,跟RTKLIB对比;
- 伪距无误后,再逐步加入载波相位、多普勒、信噪比等其余观测值。
每一步都验证通过,再进下一步。跳步调试是MSM开发的大忌,因为位偏移是一环扣一环的,一旦中间某步错了,后面所有字段都会跟着错,排查范围会被无限放大。严格按照这个节奏来,一般半天就能完成一个可用的MSM解析模块。
我自己在实际项目里,最深的体会是:MSM的协议本身并不算特别复杂,难的是在所有边界条件下都不出错。多系统、多信号、可变掩码、多条消息拆分、跨历元拼接,这些情况叠加在一起,才是真正的考验。所以在代码里多留断言、多打日志,把每一步位偏移和字段值都记录下来,能帮你省下无数排查时间。
最后再分享一个小技巧:解析MSM的时候,不要急着把所有观测值马上换算成浮点数,尽量保留原始整数和分辨率,在真正需要计算的时候再乘回去。这样不但能避免单位换算引入的二次误差,而且在对比不同版本协议时,能直接比对原始整数,排查更高效。MSM这套格式其实已经相当稳定了,后面你再接触新系统、新信号时,会发现只要把掩码表更新一下,解析框架完全不用动。这大概就是MSM最大的设计价值吧。