AIS解码全链路解析:从NMEA句子到船位经纬度的协议还原技术
简介面向AIS船舶自动识别系统开发与学习的小型资料包适合需要快速理解VHF信号接收、ITU-R M.1371报文解码以及AIS数据解析的工程师或爱好者。资源共3个文件包含2个txt文本和1个cpp源文件压缩包仅5KBtxt文件可作协议说明或示例数据参考cpp文件提供了解码或输出相关的实现示例便于对照学习也能直接观察代码与数据的配合方式。已有1036人学习下载。通过该资源可熟悉AIS报文结构、动态/静态信息的二进制解析逻辑以及从原始信号到可视化位置信息的处理思路也能了解驱动层与上层应用如何衔接包括如何从硬件读取原始射频数据、进行解调与差错处理最终提取MMSI、船名、经纬度、航速航向等关键字段对从事海上交通监控、航海安全或AIS应用开发的入门者有一定参考价值。1. AIS解码不是转码而是一条完整的协议链打开串口助手一屏!AIVDM,1,1,,B,15M67FC000G?ufbEFepT3n00Sa,0*5C挤进来。想从这一串字符里掏出某条船的位置第一反应往往是查ASCII表做字符到数字的映射结果拼出来的经纬度要么是乱码要么直接卡在CRC校验上。根本原因在于AIS解码不是“字符转码”而是从NMEA句子、6位二进制编码、位填充、字段缩放一路还原到坐标的完整协议链任何一层假设错了后面的数据全是废的。驱动一次串口采集拿到的字节只有经过这条链才能变成你能用的船位信息。理解了这几层封装再回头看AIS教程里的解码代码那些减48、除以600000的魔法数字就都有了来路你才算会真正解析AIS。2. 物理链路到数据帧串口里的数据包是怎么组成的2.1 从天线到串口驱动层输出的不是文本而是HDLC信息段AIS接收机工作在VHF频段使用两个专用信道161.975MHz与162.025MHz物理层调制方式是9600bit/s的GMSK。所谓驱动在普通开发者看来就是接收机通过串口或网口往外部吐数据常见串口参数是38400波特、8数据位、无校验、1停止位数据以NMEA 0183句子为单元每句以换行结束。驱动层真正做的事情远比“转发文本”复杂。它要把射频信号做GMSK解调把软判决变成硬比特流再从比特流里寻找HDLC帧的边界标志0x7E拆出帧内容并做校验最后才把帧内的信息字段按AIS的6位编码规则转成可打印ASCII字符。换句话说你在串口上看到的每个AIVDM句子已经是驱动完成了HDLC解封装之后的“半成品”。驱动环节出问题时症状往往不在驱动本身而在它下游的解析器。最常见的是串口缓冲溢出导致字节丢失整行数据长度不对NMEA校验和必然算不对另一种是接收机与主机之间的USB桥接延迟抖动把一句话切成了两半多句消息只剩其中一句。遇到这类问题先确认数据流边界是否是完整的“行”而不是一上来就怀疑解码算法。2.2 HDLC帧结构、0x7E标志与位填充AIS在无线链路上使用HDLC式的帧封装。帧结构可以概括为段长度作用标志8位固定0x7E标识帧起始与结束地址字段8位链路层占位不参与AIS信息内容控制字段8位固定为0x03的无编号帧信息字段可变真正的AIS消息位流帧校验序列16位CRC-16-CCITT覆盖除标志外的所有位HDLC层最容易被误解的是位填充机制。为了不让数据中恰好出现“01111110”这种与标志相同的序列发送端在数据里每出现连续的5个1之后就强行插入一个0。接收端看到连续的5个1后面跟的是0就能判断这个0是填充位要把它去掉才能恢复原始数据。def destuff(bits): out [] consec_ones 0 for b in bits: if b 1: consec_ones 1 out.append(b) else: if consec_ones 5: # 这个 0 是发送端插入的填充位必须丢弃 consec_ones 0 continue consec_ones 0 out.append(b) return out逻辑说明consec_ones统计连续1的个数遇到0时如果前面正好是5个1说明这个0是填充位直接跳过否则正常输出。这里有一个容易被忽略的边界当连续1已经累积到5个又遇到1时说明数据被破坏因为合法数据不会出现连续6个1。参数说明bits是去除0x7E标志后、帧内剩余部分的比特序列做完去除填充后数据长度不一定再是8的整数倍这正是AIS消息字段可以按任意位偏移读取的原因。这里必须泼一盆冷水不要拿AIVDM载荷再去做一遍destuff。成品接收机输出的NMEA payload是内部已完成HDLC解封装后的信息字段。你如果对15M67FC...再做一次去除填充会吞掉正常的数据0整个消息位错解出来的消息类型都是错的。不少初学者在这里翻车把正常的句子解成毫无意义的数字却死活找不到原因。2.3 NMEA句子结构AIVDM、AIVDO与多句消息驱动层输出的行以!AIVDM或!AIVDO开头。!AIVDM表示这条消息来自其他船舶!AIVDO表示这是本设备自身要发送的报文。对解码器来说两者的载荷格式完全一样只是语义归属不同。句子各字段用逗号分隔位置示例值含义句头!AIVDM数据源指示字段12总句数最长2句字段21当前句序号字段31多句序号标识同一消息的多句话相同字段4B信道标识A或B字段5载荷6位编码的AIS信息字段字段60尾部填充位数取值0到5校验和5C句头后到星号前所有字符的异或值多句消息的处理必须靠字段3这个序号标识。比如一条类型5静态消息本身有400多位超过一个NMEA句子的容量就得拆成两句传输序号标识相同解码器先把两段载荷和各自的填充位拼成一个整体再进入6位解码。校验和的计算逻辑是取$或!之后、*之前的所有字符逐字节异或结果与星号后面的两位十六进制数比较。这一行校验只说明句子文本在串口传输过程中没有被改坏并不代表AIS消息内容本身合法。内容是否有效要靠位级字段的取值判断。3. 六位二进制AIVDM载荷的编码机制3.1 为什么载荷里是0到W然后突然跳到反引号AIVDM载荷中的每个字符都表示6个比特6位二进制最大能表达0到63共64种取值。如果把这些值直接映射到任意ASCII码句子会包含控制字符无法在串口里干净显示和传输。所以AIS采用两段映射6位取值0到39映射到ASCII码48到87也就是字符0到W6位取值40到63映射到ASCII码96到119也就是字符到w。def ascii_to_sixbit(ch): code ord(ch) if 0x30 code 0x57: return code - 0x30 # 0(48) - 0, W(87) - 39 if 0x60 code 0x77: return code - 0x30 - 8 # (96) - 40, w(119) - 63 raise ValueError(f非法AIS字符: {repr(ch)})逻辑说明第一段直接减48得到0到39第二段减48后再减8等价于减56得到40到63。参数说明ASCII码88到95X到_以及120以上不在合法载荷范围内一旦出现说明数据已经损坏直接抛异常是正确的防御式写法。设计上为什么要留中间这段空白因为可打印区域不是连续的AIS选择了两个不相交的可打印区间这样既能覆盖0到63全部取值又不会与换行、回车等控制字符冲突。这份教程里几乎每个字符映射函数都是这个思路看似只是一个减法实际是在兑现一条射频协议里最基础的带宽压缩决策。3.2 从字符流拼成比特流MSB在前把每个字符转成6位值之后下一步是把这些6位值拼接成连续的比特流。AIS协议规定每个6位值内部先传最高位也就是MSB在前多个字符按句子顺序排列。不要在中间做字节缓冲直接在比特级拼接。def payload_to_bits(payload, pad_bits): bits [] for ch in payload: v ascii_to_sixbit(ch) for shift in (5, 4, 3, 2, 1, 0): bits.append((v shift) 1) if pad_bits: bits bits[:-pad_bits] return bits逻辑说明for shift in (5, 4, ...)保证每个6位值从高位到低位逐位写出。最后根据NMEA句子字段6给出来的填充位数删掉比特流尾部的几个占位比特。参数说明pad_bits范围是0到5它存在的根本原因是AIS消息信息字段的位数不一定是6的倍数。以类型5消息为例它几百位的信息字段对6取模通常有余数发射时要补几个占位位凑成6的倍数才能在AIVDM里完整表达。解码器先拼接所有字符产生比特流再按填充位字段砍掉尾部多余的位得到真实的AIS消息。如果你漏掉这一步后果是消息尾部多出几个垃圾位最后一段字段的值必然错而且错得毫无规律。3.3 前6位就是消息类型先认门再进房比特流拼好之后第一件事永远是读最前面的6个比特它就是AIS消息类型。常见类型与行为对应如下类型1、2、3是船舶位置报告信息字段168位类型4是基站报告168位类型5是静态与航次数据信息字段长得多类型9是搜救航空器位置报告类型18是B类船舶位置报告。不同类型的字段布局完全不同解码器必须按类型走对应的分支。拿!AIVDM,1,1,,B,15M67FC000G?ufbEFepT3n00Sa,0*5C做例子。载荷第一个字符1映射为6位值000001也即比特流前6位是000001二进制值等于1。所以这就是一条类型1位置报告接下来所有字段都要按类型1的168位布局去拆。如果你解出来的消息类型与预期完全对不上先别怀疑数据来源检查位顺序是否拼反了。把同一段载荷用LSB在前的方式去拼位流前6位往往会变成一堆乱序的数值类型判断结果毫无意义。这个错误在自定义实现里出现频率极高因为ASCII到6位的映射表本身看不出位方向只有拼到位流那一层才暴露。4. 字段级解析与坐标还原船位是怎么从比特流里走出来的4.1 类型1/2/3的168位布局与位偏移类型1、2、3虽是三种不同消息编号但位布局完全一致差别只在触发方式类型1是定时自主报告类型2是查询后的应答类型3是专用信号。解码器拿到这三种类型时走的是同一套字段偏移。完整布局如下字段起始位长度说明消息类型06值为1/2/3重复指示符62时隙跳变指示MMSI830船舶识别码导航状态384锚泊、航行等状态码转向率ROT428有符号需按规范查表对地速度SOG5010分辨率0.1节位置精度6011为高精度经度6128单位是万分之一角分纬度8927单位是万分之一角分对地航向COG11612分辨率0.1度真航向1289整数度UTC秒13760到5960以上为无效机动指示14320为不可用RAIM1451定位完整性标志无线电状态14622通信系统内部状态解析时最核心的是按位切片。下面这段代码给出两类基础操作读取无符号整数读取有符号补码整数。def bits_to_int(bits, pos, length): v 0 for i in range(pos, pos length): v (v 1) | bits[i] return v def bits_to_signed(bits, pos, length): v bits_to_int(bits, pos, length) if v (1 (length - 1)): v - (1 length) return v逻辑说明bits_to_signed先解出无符号值再判断最高位是否为1如果是就减去2的length次方得到补码对应的负值。参数说明pos来自上面表格的起始位length是字段长度MMSI、导航状态、SOG这类无符号字段用bits_to_int经纬度、转向率这类可负字段必须用bits_to_signed。位偏移错一位MMSI就会变成一个完全不同的数字而这种错误通过肉眼很难发现。4.2 经纬度换算为什么要除以600000经纬度字段的原始单位是万分之一角分不是万分之一度。1度等于60角分所以一个“万分之一角分”转换成度必须先除以10000得到角分再除以60得到度合并起来就是除以600000。这就是解码代码里那个看似古怪的大数的真正来源。lon bits_to_signed(bits, 61, 28) / 600000.0 lat bits_to_signed(bits, 89, 27) / 600000.0逻辑说明经度字段28位能表达的正负范围约±223度覆盖全球经度纬度字段27位范围约±111度同样超出实际纬度上限。这个冗余是补码表示带来的正常现象。参数说明除以600000.0时一定要保留浮点运算如果写成整数除法600000经度会直接变成0这是Python 2时代最常见的一类隐蔽bug。这里有一个经典的坐标坑有人按“万分之一度”去理解直接把字段值除以10000结果东经121度解出来成了远大于实际的值偏差接近2度。如果解出来的经度是181、纬度是91这种临界异常值那不是缩放系数选错了而是位流层面的错位比如填充位没砍干净、MSB/LSB拼反导致后续字段整体串位。这两类错误要用不同思路去排查前者检查换算系数后者检查位流构造。4.3 速度、航向的缩放与不可用值SOG字段10位存储的是0.1节的整数倍解析时乘以0.1就得到节单位的对地速度。取值1023被保留为“不可用”所以在画轨迹或做统计时要过滤。COG字段12位存储0.1度的整数倍乘以0.1得到角度取值3600同样表示无效。真航向9位存整数度511表示无效。这几个“不可用”值不是随意安排的而是协议给“数据缺失”留出的码点。解码器拿到1023或3600时不应该参与船舶行为计算直接置空或标记为无效即可。如果你把这些特殊值当真后面无论是做碰撞预警还是轨迹回放都会出现瞬间跳到诡异位置的假轨迹。转向率ROT是8位有符号字段但它的编码不是线性刻度。协议中的转向率值要经过分段公式才能换算成“度每30秒”的实际转向速率绝对值越大说明转向越快正负区分左转和右转。最常犯的错误是对这个字段直接做补码除以一个固定系数那样只能覆盖其中一个分段其它取值解出来是失真的。遇到ROT需求老老实实对照规范的分段公式或者只把它当作定性指标负值表示左转、正值表示右转、0表示直航。4.4 类型5静态消息文本域和数字域交织类型5消息是静态与航次数据信息长度远大于位置报告在AIVDM里经常要拆成两句话传输。它的字段顺序是消息类型、重复指示符、MMSI、AIS版本号、IMO编号、呼号、船名、船型、船舶尺寸、电子定位装置类型、ETA、吃水、目的地、DTE和保留位。这块最容易出问题的是文本域。呼号、船名、目的地不是普通ASCII而是AIS自己的6位ASCII子集每个字符占6比特与前面AIVDM字符映射表是两套完全不同的映射。船名占120位也就是20个6位字符不足时用填充目的地同样是20个字符。直接把6位值当标准ASCII打印只能得到一堆乱码。这是类型5解析器最经典的翻车点我见过有人调试两三天最后发现是少查了一张映射表。数字域与文本域交替出现要求解码器严格按偏移读取。文本域多取一位后面的船型、吃水、ETA全部串位。所以类型5解码的正确姿势是先按二进制位流把每个字段切出来文本字段再单独做6位字符映射而不是先试图把整段载荷转换成字符串再按字符处理。5. 解码常见问题与排查从报错到出海数据5.1 现象NMEA校验和不通过整行数据被丢弃原因串口驱动缓存溢出或者USB转换器丢字节句子在传输途中被截断或篡改。校验和只覆盖当前这一行字符任何一位变化都会导致最终异或结果不符。解决先在驱动层抓原始流确认每一行是否以\r\n结尾、行长度是否稳定。串口参数要从38400转到4800等不同速率逐一排查某些接收机默认速率不是38400。排除驱动问题后再检查自己的校验和实现要排除!本身和*后的内容只异或两者之间的字符。如果这两步都没问题才轮到怀疑数据源质量。5.2 现象解出来的经度是181度纬度是91度原因这不是缩放系数错误而是位流层面整体错位。常见的来源有三个MSB/LSB拼反、填充位没按字段处理、把HDLC的位填充错误地又做了一遍导致误删正常比特。解决拿一条已知的测试句子手动逐位展开前24个比特对照消息类型和MMSI字段偏移确认位顺序是否与协议一致。再检查payload_to_bits里填充位删除的位置——删除发生在拼接完成之后而不是每个字符解码时。最后确认你没有对AIVDM载荷调用destuff函数。5.3 现象MMSI变成负数或者船名充满乱码原因MMSI使用30位无符号整数如果用了有符号工具函数当最高位为1时就会解出负值船名乱码则是把6位AIS字符子集当成了标准ASCII没有做二次映射。解决统一区分bits_to_int和bits_to_signed的使用场景MMSI、船型、IMO号全部用无符号版本。文本域单独写一个字符映射函数先查AIS 6位ASCII表再把码值转为具体字符解码后对填充符做strip处理。5.4 现象一条类型5消息解析结果时好时坏原因多句消息没有被正确拼接。类型5信息长经常拆成两个AIVDM句子。如果解码器逐句独立解析第二句的载荷会被当成一条完整消息处理字段全部错位。解决用句子字段3的序号标识做缓存字段1和字段2分别表示总句数和当前句号必须等同一序号标识的两句话都到齐才能合并载荷和填充位。缓存期间还要加超时机制防止丢句导致缓存放不出去。5.5 现象同一批数据两个解码器给出的结果不一致原因最常见的是对填充位和消息长度的处理不一致。AIS各类消息的实际信息位数不同有的不是6的整数倍AIVDM句子字段6标记了这个差值另一个常见差异是文本域尾部填充符要不要保留。解决用原始十六进制或二进制位流做对照基准不要拿另一个软件的解码结果当标准。先把两边的位流打印出来逐段对比分别检查消息类型、MMSI、经度、纬度四个关键字段的原始位切片是否一致。哪一段切出来的位不同问题就定位在哪一段的偏移上。6. 设计取舍为什么AIS不直接发明文JSONAIS走的是9600bit/s的窄带VHF信道一个时隙只有几毫秒能塞下的比特数极其有限。如果每条船的位置报告都用明文JSON或NMEA字符直传光是经纬度一个字段就要消耗几十个ASCII字节同等内容用28位二进制字段编码加上符号位不到4个字节。这是无线信道资源约束下的必然取舍能压缩的字段全部压成定长二进制文本字段用6位字符集压缩剩下一点冗余空间留给“不可用”之类的语义标记。经纬度单位选万分之一角分而不是万分之一度也是导航领域的习惯延续。海图坐标本来就用度分表示角分的万分之一正好对应约1米的定位精度这比用浮点小数表示更省比特。所以解码时那个除以600000本质是把体制性的单位换算写进了缩放系数里。AIS不追求通用性它追求在极端带宽预算下尽量多带信息这与你日常处理JSON数据时“可读性优先”的取向是两种极端。我自己写解码器时养成了一个习惯手里永远留几条构造出来的测试向量用已知经纬度、已知MMSI反向编码成AIVDM句子再让解码器解析回去两边不一致就说明哪里错了。第一次调类型5时我也是迷信某个在线解码器结果对方用的旧版字段表我反而把代码改错了方向。从那以后我每改一次解析逻辑都强制走一遍“构造→编码→解码→对比原始字段”的验证闭环。这份教程如果把驱动到解析的完整链路讲透最值得你带走的就是这个验证思路——数据格式再晦涩只要你能控制输入解析器的对错就是一眼的事。希望帮到你。本文还有配套的精品资源点击获取