简介:面向AIS(船舶自动识别系统)开发与学习的小型资料包,适合需要快速理解VHF信号接收、ITU-R M.1371报文解码以及AIS数据解析的工程师或爱好者。资源共3个文件,包含2个txt文本和1个cpp源文件,压缩包仅5KB;txt文件可作协议说明或示例数据参考,cpp文件提供了解码或输出相关的实现示例,便于对照学习,也能直接观察代码与数据的配合方式。已有1036人学习下载。通过该资源可熟悉AIS报文结构、动态/静态信息的二进制解析逻辑,以及从原始信号到可视化位置信息的处理思路;也能了解驱动层与上层应用如何衔接,包括如何从硬件读取原始射频数据、进行解调与差错处理,最终提取MMSI、船名、经纬度、航速航向等关键字段,对从事海上交通监控、航海安全或AIS应用开发的入门者有一定参考价值。
1. AIS解码不是转码,而是一条完整的协议链
打开串口助手,一屏!AIVDM,1,1,,B,15M67FC000G?ufbEFepT@3n00Sa,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 | 数据源指示 |
| 字段1 | 2 | 总句数,最长2句 |
| 字段2 | 1 | 当前句序号 |
| 字段3 | 1 | 多句序号标识,同一消息的多句话相同 |
| 字段4 | B | 信道标识,A或B |
| 字段5 | 载荷 | 6位编码的AIS信息字段 |
| 字段6 | 0 | 尾部填充位数,取值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到W;6位取值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到95(X到_)以及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?ufbEFepT@3n00Sa,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是专用信号。解码器拿到这三种类型时,走的是同一套字段偏移。完整布局如下:
| 字段 | 起始位 | 长度 | 说明 |
|---|---|---|---|
| 消息类型 | 0 | 6 | 值为1/2/3 |
| 重复指示符 | 6 | 2 | 时隙跳变指示 |
| MMSI | 8 | 30 | 船舶识别码 |
| 导航状态 | 38 | 4 | 锚泊、航行等状态码 |
| 转向率ROT | 42 | 8 | 有符号,需按规范查表 |
| 对地速度SOG | 50 | 10 | 分辨率0.1节 |
| 位置精度 | 60 | 1 | 1为高精度 |
| 经度 | 61 | 28 | 单位是万分之一角分 |
| 纬度 | 89 | 27 | 单位是万分之一角分 |
| 对地航向COG | 116 | 12 | 分辨率0.1度 |
| 真航向 | 128 | 9 | 整数度 |
| UTC秒 | 137 | 6 | 0到59,60以上为无效 |
| 机动指示 | 143 | 2 | 0为不可用 |
| RAIM | 145 | 1 | 定位完整性标志 |
| 无线电状态 | 146 | 22 | 通信系统内部状态 |
解析时最核心的是按位切片。下面这段代码给出两类基础操作:读取无符号整数,读取有符号补码整数。
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不直接发明文JSON
AIS走的是9600bit/s的窄带VHF信道,一个时隙只有几毫秒,能塞下的比特数极其有限。如果每条船的位置报告都用明文JSON或NMEA字符直传,光是经纬度一个字段就要消耗几十个ASCII字节,同等内容用28位二进制字段编码,加上符号位不到4个字节。这是无线信道资源约束下的必然取舍:能压缩的字段全部压成定长二进制,文本字段用6位字符集压缩,剩下一点冗余空间留给“不可用”之类的语义标记。
经纬度单位选万分之一角分而不是万分之一度,也是导航领域的习惯延续。海图坐标本来就用度分表示,角分的万分之一正好对应约1米的定位精度,这比用浮点小数表示更省比特。所以解码时那个除以600000,本质是把体制性的单位换算写进了缩放系数里。AIS不追求通用性,它追求在极端带宽预算下尽量多带信息,这与你日常处理JSON数据时“可读性优先”的取向是两种极端。
我自己写解码器时养成了一个习惯:手里永远留几条构造出来的测试向量,用已知经纬度、已知MMSI反向编码成AIVDM句子,再让解码器解析回去,两边不一致就说明哪里错了。第一次调类型5时,我也是迷信某个在线解码器,结果对方用的旧版字段表,我反而把代码改错了方向。从那以后,我每改一次解析逻辑,都强制走一遍“构造→编码→解码→对比原始字段”的验证闭环。这份教程如果把驱动到解析的完整链路讲透,最值得你带走的就是这个验证思路——数据格式再晦涩,只要你能控制输入,解析器的对错就是一眼的事。希望帮到你。
本文还有配套的精品资源,点击获取