简介:这份资源面向希望系统掌握AIS船舶自动识别系统的开发者与学习者,围绕驱动、解码、解析三个环节展开,帮助读者理解从VHF射频信号接收、按ITU-R M.1371标准解调报文,到提取MMSI、船名、位置、航向等静态与动态信息的完整链路,并涉及驱动层与硬件交互、上层解析软件展示数据的基本思路,适合具备一定通信与编程基础、想切入海洋信息化领域的中级读者。压缩包共3个文件,以2个txt数据与说明文本、1个cpp源码为主,整体约5KB,体量轻便,便于直接阅读与二次修改。目前已有1037人学习下载,可作为AIS解码与解析入门的参考样例,帮助读者对照代码理解报文结构、坐标转换与数据过滤等关键处理,并据此搭建自己的实验与调试环境。
1. AIS 解码:为什么一条 256 位的报文能讲清一艘船的全部动向
海图上那个缓慢移动的小三角,背后其实是一串 256 位、被切成 6 位一组的二进制碎片。很多人第一次接触 AIS,会以为它像 GPS 一样直接吐出经纬度,实际恰恰相反:AIS 是一条广播信道,船上设备把船位、航向、航速、船名、目的地等信息压进极短的报文里,用 VHF 频道定时播出去,谁收到谁自己解。也就是说,解码这件事从来不在发送端完成,而在接收端一层层还原。搞懂这套机制的价值在于,你会明白为什么同一条报文有时解出乱码、有时船位跳到陆地上、有时船名全是问号——这些玄学现象几乎都能在比特位和字段偏移里找到答案。这份资源把驱动、解码、解析三段拆开讲,正好对应「收到电波 → 拿到比特 → 还原语义」的完整链路,值得从底层啃一遍。
2. 报文结构:6 位 ASCII 与位偏移是怎么把语义钉死的
2.1 从 NMEA 语句到 6 位载荷的封装关系
AIS 在物理层之上最常见的承载形式是 NMEA 0183 语句,典型长这样:!AIVDM,1,1,,A,15N:;R0P00PD;88MD5MTDww@2D0S,0*5C。这条语句里真正有价值的只有中间那段以!AIVDM开头的载荷,其余字段是分片信息、填充位数和校验和。理解封装关系是解码的第一步:AIVDM表示这是其他船发来的报文,AIVDO表示本船自己的报文;逗号之间的1,1是分片序号和总片数,因为一条长报文可能被拆成多句;A是信道标识;末尾的0是填充位数,表示最后一个 6 位字符里有几个比特是补的、不参与解码;*5C是异或校验和。
很多人卡在这里:直接把载荷当成 ASCII 字符串去读,结果全是乱码。原因是 AIS 载荷用的是 6 位 ASCII 编码,每个字符只承载 6 个有效比特,而不是常见的 8 位。常见的实现思路是先把每个字符减去 48,如果结果大于 40 再减 8,得到一个 0 到 63 的值,然后按 6 位二进制拼进一个大整数。这个「减 48 再减 8」的修正,正是 6 位 ASCII 表在 0x30 到 0x77 之间跳过了几个控制字符造成的,属于必须记住的血泪经验。
def payload_to_bits(payload: str, fill_bits: int) -> str: bits = [] for ch in payload: v = ord(ch) - 48 # 6 位 ASCII 的基准偏移 if v > 40: v -= 8 # 跳过 0x58~0x5F 之间的空洞 bits.append(format(v, '06b')) raw = ''.join(bits) # fill_bits 表示末尾补了几个无效比特,解码前要裁掉 return raw[:len(raw) - fill_bits] if fill_bits else raw上面这段逻辑里,ord(ch) - 48把可打印字符映射回 0~63 的数值空间,v > 40的修正对应 6 位 ASCII 编码表在数值 40 之后跳过的一段区间。fill_bits的处理尤其容易被忽略:如果最后一片报文补了 2 个比特,你却按 6 位整算,后面所有字段的偏移都会整体错位,船位就会跳到莫名其妙的地方。参数上,payload是去掉!AIVDM前缀和校验和之后的纯载荷,fill_bits来自语句倒数第二个字段,这两个值必须成对传入,缺一不可。
2.2 位偏移表:为什么字段顺序不能随便改
拿到比特串之后,解码的本质是按协议规定的偏移去切片。以最常见的船位报告(消息类型 1、2、3)为例,前 6 位是消息类型,接着 2 位是重复指示,然后 30 位是 MMSI,再往后 4 位是航行状态,紧接着是有符号的经度 28 位、纬度 27 位,单位是 1/10000 分。这个顺序是协议写死的,任何一位的错位都会让后续字段全部读偏。我一般会这么理解:位偏移表就像一张地图,你手里有一串连续的比特,地图告诉你第几位到第几位属于哪个字段、用什么类型解释。
| 字段 | 起始位 | 长度 | 解释方式 |
|---|---|---|---|
| 消息类型 | 0 | 6 | 无符号整数 |
| 重复指示 | 6 | 2 | 无符号整数 |
| MMSI | 8 | 30 | 无符号整数 |
| 航行状态 | 38 | 4 | 枚举 |
| 经度 | 61 | 28 | 有符号,除以 600000 |
| 纬度 | 89 | 27 | 有符号,除以 600000 |
这张表里最值得说的是经纬度的有符号处理。28 位的经度如果最高位是 1,表示西经,需要按补码转成负数再除以 600000;纬度同理。很多解析翻车就翻在这里:用无符号整数直接读,东经变西经、北纬变南纬,船一下子跑到地球另一边。另一个细节是 181 度和 91 度这两个特殊值,协议用它们表示「位置不可用」,解码时必须判断,否则会在地图上画出一个不存在的点。
2.3 消息类型分派:一条信道里混着十几种报文
AIS 不是只有船位一种报文。消息类型 5 是静态和航程数据,包含船名、呼号、目的地,长度 424 位,通常要拆成两片;消息类型 18 是 Class B 船位报告;消息类型 24 是静态数据分 A、B 两部分;还有 4 号基站报告、21 号助航设备报告等等。解码器拿到前 6 位就要做分派,不同类型走不同的字段表。这里的设计动机很直接:VHF 带宽极其宝贵,不同类型的信息更新频率差别很大,船位几秒一次、静态信息几分钟一次,用类型字段区分才能让接收端按需处理。
实现上,常见的做法是维护一个「类型 → 解析函数」的映射表,先读类型,再查表调用。要注意的是类型 5 和类型 24 这类长报文的分片重组:同一组报文的分片序号连续、总片数一致,必须按序拼好再解,否则船名会缺一半。分片重组的超时清理也很关键,收到第一片却迟迟等不到第二片时,要有机制丢弃,不然缓存会越积越多。
3. 驱动层:串口、网络与字节流是怎么把电波变成可读数据的
3.1 接收链路的分层:从射频到应用缓冲区
把 AIS 接收机接到电脑上,中间其实隔着好几层。射频前端解调出基带比特,接收机固件把它封装成 NMEA 语句,通过串口或网络吐出来,驱动层负责把这些字节流稳定地读进应用缓冲区。这条链路上任何一层出问题,上层解码都会拿到残缺或错位的数据。常见的接入方式有两种:一种是 USB 转串口,设备在系统里表现为一个串口节点,按波特率读取;另一种是网络接入,接收机把 NMEA 语句通过 TCP 或 UDP 发出来,驱动层按行切分。
串口方式最容易踩的坑是波特率。AIS 接收机常见的输出波特率是 38400,但有些设备默认 4800,配错了读出来就是一堆乱码字节。另一个坑是行结束符:NMEA 语句以回车换行结尾,如果驱动层按固定长度读而不按行切,一条语句可能被截成两半,校验和自然对不上。我一般会这么处理:维护一个字节缓冲区,每次读到数据就追加,然后按换行符切分出完整语句,剩下的半条留在缓冲区等下一次数据。这个「粘包/半包」处理是所有串口和网络协议解析的通用套路,AIS 也不例外。
import serial class AisSource: def __init__(self, port: str, baud: int = 38400): self.ser = serial.Serial(port, baud, timeout=1) self.buf = b'' def read_sentences(self): chunk = self.ser.read(4096) # 一次尽量多读,减少系统调用 if not chunk: return [] self.buf += chunk lines = [] while b'\n' in self.buf: line, self.buf = self.buf.split(b'\n', 1) line = line.strip() if line.startswith(b'!AI'): lines.append(line.decode('ascii', 'ignore')) return lines这段代码的关键在self.buf的累积和按\n切分。read(4096)一次多读是为了降低系统调用频率,串口在高频报文下如果一次只读一个字节,很容易丢数据。startswith(b'!AI')做了一次粗过滤,把非 AIS 的语句挡在外面。参数timeout=1表示读超时一秒,避免阻塞线程永远等不到数据。要注意decode('ascii', 'ignore')里的ignore:遇到非法字节时直接丢弃而不是抛异常,这在现场环境里能防止一条坏数据把整个读取循环打断。
3.2 校验和验证:为什么不能跳过这一步
NMEA 语句末尾的*5C是校验和,计算方式是把!和*之间的所有字符做异或。这一步看起来多余,实际是驱动层最重要的防线。无线信道有噪声,串口传输也可能出错,一条语句里错一个比特,解码出来的船位可能偏差几十海里。校验和验证的成本极低,却能挡掉绝大部分脏数据。常见的实现思路是:切出$或!之后、*之前的子串,逐字符异或,再和*后面的两位十六进制比较。
这里有个容易忽略的细节:校验和比较时要把十六进制字符串转成整数再比,而不是直接比字符串,因为大小写可能不一致。另外,有些设备输出的语句不带校验和,这时候要么放行要么丢弃,取决于业务对数据完整性的要求。我的习惯是默认丢弃无校验和的语句,宁可少解几条,也不让错位数据污染后续的统计。
3.3 多源接入与时间戳:驱动层还要负责什么
实际系统里往往不止一个接收源:本地串口一台、网络远端一台,甚至多台设备同时上报。驱动层除了读数据,还要给每条语句打上接收时间戳。这个时间戳不是报文里的 UTC 时间,而是本地收到的时间,用途是判断数据新鲜度和做多源去重。同一艘船可能被多个接收站同时收到,如果不带时间戳,你无法判断哪条是最新的。
多源接入的设计取舍在于:是每个源一个线程各自读、汇总到一个队列,还是单线程轮询多个源。前者吞吐高但线程管理复杂,后者简单但可能因为某个源阻塞而拖慢整体。常见的做法是每个源一个读取线程,读到的语句连同源标识和时间戳塞进一个线程安全队列,解码线程从队列消费。这样驱动层和解码层解耦,某个源断了也不影响其他源。要注意队列要有上限,防止解码跟不上时内存无限增长,满了就丢最旧的或者直接丢新的,这个策略要按业务定。
4. 解析层:从比特到语义,字段映射与状态维护的取舍
4.1 有符号数与缩放因子:数值还原的两个陷阱
解析层做的是把比特切片翻译成有物理意义的数值。前面提到经纬度要按补码转有符号再除以 600000,这是缩放因子的问题。AIS 里几乎所有物理量都有缩放:航速单位是 0.1 节,航向单位是 0.1 度,吃水单位是 0.1 米。缩放因子用错,数值就差十倍,船速 12 节变成 1.2 节,看起来像漂在水上。有符号数的处理则集中在经度、纬度、转向率、对地航向这些可能为负的字段上。
def signed(value: int, bits: int) -> int: # 补码转有符号:最高位为 1 表示负数 if value & (1 << (bits - 1)): return value - (1 << bits) return value def decode_position(bits: str): lon_raw = int(bits[61:89], 2) lat_raw = int(bits[89:116], 2) lon = signed(lon_raw, 28) / 600000.0 lat = signed(lat_raw, 27) / 600000.0 # 181/91 是协议约定的「不可用」哨兵值 if abs(lon) > 180 or abs(lat) > 90: return None return lat, lonsigned函数是补码转换的核心,1 << (bits - 1)取最高位做符号判断,负数则减去1 << bits。decode_position里除以 600000 是协议规定的缩放,哨兵值判断放在最后,因为 181 度和 91 度本身在数值范围内,只能靠绝对值超限来识别。这里要注意浮点精度:600000 是整数除法后转浮点,顺序不能反,先除再转可能丢精度。
4.2 文本字段的 6 位 ASCII 还原:船名为什么会出现问号
消息类型 5 和 24 里有船名、呼号、目的地这些文本字段,它们同样用 6 位 ASCII 编码,但和载荷编码略有不同:每个 6 位值直接映射到字符表,值 0 表示@,值 1 到 26 是大写字母,值 32 是空格,值 48 到 57 是数字。解码时按 6 位一组取值查表,末尾的@和空格要裁掉。船名出现问号,通常是因为查表时遇到了表里没有定义的值,或者字段长度算错导致读到了别的字段的比特。
常见的实现思路是预置一张 64 项的字符表,索引就是 6 位值。要注意的是不同消息类型里文本字段的起始位不同,类型 5 的船名从第 112 位开始、长 120 位,共 20 个字符;类型 24 的船名位置又不一样。把偏移写死成常量容易出错,更好的做法是把每种消息类型的字段表定义成数据结构,解析时按表驱动,改起来只动表不动逻辑。
4.3 状态维护:为什么解析器不能是无状态的
单条报文解出来的只是快照,真正有用的信息往往要靠状态维护。比如船名在类型 5 里,船位在类型 1 里,要把两者关联到同一艘船,就得用 MMSI 做键维护一个船舶状态表。收到船位就更新位置,收到静态信息就补全船名和目的地。这个状态表还要处理过期:一艘船长时间没有新报文,位置就不该继续显示,否则海图上会留下一堆幽灵船。
状态维护的设计取舍在于内存和实时性。全量保留所有见过的船,内存会随时间增长;只保留最近活跃的船,又可能丢掉历史轨迹。常见的做法是给每条船记录最后更新时间,定期清理超过阈值的条目,同时把轨迹点落到持久化存储里。另一个细节是 MMSI 的合法性校验:MMSI 是 9 位数字,首位表示船舶类型,解析出来如果位数不对或全零,说明这条报文有问题,不该进状态表。
5. 避坑与排查:解码链路上最容易翻车的几个地方
5.1 船位跳到陆地上:填充位没裁导致整体错位
现象是解出来的经纬度落在内陆或者大洋中央,和实际航道完全对不上。原因几乎都是分片报文的填充位没处理:最后一片载荷末尾补了几个比特,解码时没裁掉,导致后续所有字段偏移整体后移。解决方法是严格按语句里的填充位字段裁剪比特串,并且在分片重组时只对最后一片做裁剪,前面的分片不裁。排查时可以打印出比特串长度,和协议规定的报文总长度对比,长度对不上就说明裁剪有问题。
5.2 船名全是乱码或问号:文本字段偏移算错
现象是船名显示成一串无意义字符。原因通常是文本字段的起始位算错,或者把 6 位 ASCII 的查表方式用成了普通 ASCII。解决方法是核对消息类型的字段表,确认船名字段的起始位和长度,并用预置字符表逐 6 位查。排查时可以把船名区域的比特单独打印出来,手工按 6 位分组查表,看是否得到可读字符,如果得到的是数字和符号混杂,基本可以确定偏移错了。
5.3 数据时有时无:串口缓冲区溢出或粘包处理不当
现象是报文间歇性丢失,或者一条语句被拆成两半导致校验失败。原因是读取频率跟不上报文速率,或者按固定长度读而没有按行切分。解决方法是增大单次读取量、用缓冲区累积并按换行切分、校验失败的语句记录日志而不是静默丢弃。排查时可以统计每秒收到的语句数和校验失败率,失败率突然升高往往意味着缓冲区处理有问题。
5.4 多源数据打架:没有时间戳导致新旧不分
现象是同一艘船的位置在两个点之间来回跳。原因是多个接收源的数据混在一起,没有按接收时间排序,旧数据覆盖了新数据。解决方法是在驱动层给每条语句打接收时间戳,状态更新时只接受时间更新的数据。排查时可以按 MMSI 分组打印报文时间和接收时间,看是否存在接收时间倒序的情况。
5.5 长报文只解出一半:分片重组缺超时清理
现象是类型 5 的船名和目的地经常缺失。原因是分片报文的第一片到了、第二片丢了,重组逻辑一直等不到完整数据。解决方法是给分片缓存加超时,超过一定时间未凑齐就丢弃,并记录丢弃计数。排查时统计分片完整率,如果完整率明显偏低,要检查接收链路是否丢包,而不是一味改解码逻辑。
6. 设计取舍:为什么解码器要分层,以及怎么验证它真的对
把驱动、解码、解析拆成三层,不是为了好看,而是因为这三层的失败模式完全不同。驱动层面对的是字节流和时序问题,解码层面对的是比特和协议表,解析层面对的是语义和状态。混在一起写,一个串口超时可能被误判成协议解析错误,排查时根本分不清是哪一层出的问题。分层之后,每层可以独立测试:驱动层喂固定字节流看切分是否正确,解码层喂已知比特串看字段是否还原,解析层喂构造报文看状态更新是否符合预期。这种可测试性是分层最大的收益,也是我坚持这么拆的原因。
验证解码器是否正确,最可靠的办法是拿已知答案的报文做回归。协议里有公开的示例报文,每条报文对应的船位、船名是确定的,把它们做成测试用例,每次改代码都跑一遍。另一个办法是交叉验证:用两个独立的解码实现解同一批数据,结果一致才放心。这里有个容易忽略的点:浮点比较不能用相等,经纬度要允许微小误差,因为不同实现的除法顺序可能不同。
| 验证手段 | 能发现的问题 | 局限 |
|---|---|---|
| 已知报文回归 | 字段偏移、缩放因子错误 | 覆盖不到异常报文 |
| 双实现交叉比对 | 系统性偏差 | 两个实现可能犯同样的错 |
| 现场数据抽样 | 真实环境下的脏数据问题 | 缺少标准答案 |
还有一点值得说:解码器的日志要足够细。一条报文解失败时,日志里应该能看到原始语句、裁剪后的比特串、消息类型和失败原因,而不是只打一个「解析失败」。黑匣子式的日志在排查现场问题时能省下大量时间。我一般会在解码入口和出口各打一条日志,入口记原始语句和接收时间,出口记解出的关键字段,中间出错就记异常点和当时的比特串。
从那以后我每次接一个新的 AIS 数据源,都强制先跑一遍校验和统计和已知报文回归,确认驱动层和解码层都没问题,再去碰解析层的业务逻辑。这个习惯帮我挡掉了好几次把串口配置错误当成协议解析 bug 的弯路。希望帮到你。
本文还有配套的精品资源,点击获取