简介:面向AIS(船舶自动识别系统)开发者与航海信息化初学者的入门资料包,围绕从射频信号到业务数据的完整链路展开讲解。内容包括ITU-R M.1371 VHF数据传输协议、二进制报文解调与解码算法、接收设备驱动接口,以及经纬度坐标转换与船舶信息解析,可用于理解海上交通监控、港口管理和防碰撞预警等场景下的典型工程实现。压缩包仅5KB,共3个文件:两个TXT文档分别整理协议要点与报文解析步骤,一个C++源文件提供核心解码逻辑,便于对照研究二进制报文解析、错误检测与坐标换算。目前已有1036人学习下载。依托这份精简内容,读者能快速建立AIS数据从解调、驱动采集到业务展示的整体认知,也可为后续驱动调试或解析软件开发提供可运行的入门参考,适合希望快速上手海洋信息化开发的学习者。
1. 先立框架:AIS 从射频信号到可用数据的完整链路
不少人下载 AIS 教程和驱动包,上手第一步就卡在「收到一串 $AIVDM 开头的字符串,看着像乱码,不知道下一步该干嘛」。这套资源的价值在于,它把驱动、解码、解析三条线一次性串起来了——从接收机串口读到原始 6-bit 编码数据,到切分成字段、还原成船舶的完整 AIS 解码报文,再到输出 MMSI、船位、航速、船名这些结构化信息。换句话说,它解决的是「收了一堆报文却用不起来」的断层问题。适合正在做船舶监控、水域态势分析,或者想自建 AIS 接收节点但没有现成解析代码的开发者。整套链路里,解码和解析是核心,驱动和配置是前置,下面的内容完全按照“收到原始字符串之后”这个真实场景来展开。
2. 解码前的必修课:AIS 报文协议结构与六位二进制编码
2.1 AIS 报文的物理层与数据链路层基础
AIS 工作在 VHF 频段,分 AIS 1(161.975 MHz)和 AIS 2(162.025 MHz)两个频道,采用自组织时分多址(SOTDMA)机制。每艘船在固定的时隙内发送自己的状态信息,接收机只需要在这两个频点上轮流监听并解调出二进制数据流。这套教程里附带驱动如果正常工作,你会在串口或者网络端口上看到连续的 NMEA 0183 语句,关键字是!AIVDM或者!AIVDO。前者是收到的他船信息,后者是本机发送的信息,绝大部分对接场景只需要关心前者。
在进入解码之前,必须理解 AIS 报文的承载格式。一个标准的 AIS NMEA 语句长这样:
!AIVDM,1,1,,B,15M67FC000G?ufbE`FepTTwN0>0N,0*51这里每个字段都用逗号分隔,含义是:语句标识!AIVDM、消息总数、当前消息编号、消息顺序 ID、接收频道、数据内容(payload)、填充位、校验和前的内容。真正需要解码的是第 6 个字段15M67FC000G?ufbEFepTTwN0>0N`,这就是 AIS 原始二进制数据的 6-bit 字符表示。后续所有解析都要从这串字符入手。
2.2 6-bit ASCII 编码规则与字段位宽表
AIS 的 payload 不是明文字符串,而是用 6-bit ASCII 表示的二进制数据。每个字符的取值范围是 ASCII 码 48 到 119,对应二进制值 0x00 到 0x3F。解码的时候要先把每个字符转成对应的 6-bit 数值,再按位拼接成完整的二进制串,然后按照消息类型对应的字段位宽去切分。
字符到 6-bit 数值的换算规则很简单:把字符的 ASCII 码值减去 48,如果结果大于 40 则再减去 8。比如字符H的 ASCII 码是 72,72 - 48 = 24,24 < 40,所以H对应二进制值 24,即 011000。需要把这个规则写成一个最基础的函数,后续所有解码都依赖它。教程里给的驱动和解析工具包一般会封装好这个函数,但是自己实现一遍有助于排查乱码问题。
常见的 AIS 消息类型中,类型 1、2、3 是位置报告,类型 5 是静态与航次信息,类型 18 是 B 类设备位置报告。它们共用一个头部结构:前 6 位是消息类型(MMSI 类型),接着的 30 位是用户 ID(MMSI)。从这里开始,不同消息类型的字段位宽就不一样了。下表列了位置报告类消息(1/2/3)的核心字段:
| 字段 | 起始位 | 位宽 | 含义 | 比例/单位 |
|---|---|---|---|---|
| 消息类型 | 0 | 6 | 1/2/3 | 无 |
| MMSI | 6 | 30 | 船台识别码 | 无 |
| 导航状态 | 38 | 4 | 0-15 对应不同状态 | 无 |
| 经度 | 61 | 28 | 1/600000 分 | 东经为正 |
| 纬度 | 89 | 27 | 1/600000 分 | 北纬为正 |
| 对地航速 | 50 | 10 | 0.1 节 | 无 |
| 对地航向 | 116 | 12 | 0.1 度 | 无 |
经纬度在 AIS 协议里是不带符号的整数,用 1/600000 分作为单位。解码时要根据最高位判断正负,把超出范围的数值判为无效数据。某厂商的接收机偶尔会发送一条经度等于 181 度的错误报文,这就是没做有效性判断导致的。
2.3 校验和计算与语句合并逻辑
NMEA 语句里的校验和是*后面两位十六进制数,计算方式是从!之后到*之前所有字符的异或结果。写解析器的时候必须先做校验和验证,再进解码流程。如果校验失败说明数据在传输中被破坏,常见原因是串口波特率不匹配导致字节错位,或者是 USB 转串口的驱动丢字节。
消息条数(第一个数字)和消息编号(第二个数字)决定了一条拆成多帧的 AIS 消息怎么合并。类型 5 的消息因为 payload 超过 82 个字符,往往被拆成两条 NMEA 语句,此时需要把两帧的 payload 字符串按顺序拼接,再去掉填充位(最后一个字段的值)再做 6-bit 转换。教程里通常会给一个合并函数,接收一个语句列表,返回拼接后的 payload 字符串。这个逻辑在批量处理历史日志时特别容易出问题,后面避坑章节会细说。
3. 驱动与数据采集:从串口和网络端口读取 AIS 原始数据
3.1 串口接收驱动的通用配置参数
AIS 接收机通过 USB 转串口模块连到电脑后,在 Linux 上会生成/dev/ttyUSB0或/dev/ttyACM0设备节点,在 Windows 上是 COM 口。AIS 接收机最常见的串口参数是 38400 波特率、8 位数据位、1 位停止位、无校验。不过频率很高的问题是:接收机被配置成了 9600 波特率,或者用户拿之前用过的 GPS 驱动配置(4800 波特率)去连 AIS,结果什么都读不到或者全是乱码。拿到设备先看说明书或者驱动文档里默认的波特率,我就是先在串口助手里换四个常用波特率逐一测试,而不是直接改代码。
Linux 下用 Python 的 pyserial 库读取串口数据是一个常见做法,核心配置如下:
import serial # 打开串口,超时设短一点便于按行读取 ser = serial.Serial( port='/dev/ttyUSB0', baudrate=38400, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.5 ) with ser: while True: line = ser.readline() if not line: continue # strip 去掉换行符,保留完整的NMEA语句 nmea_str = line.decode('ascii', errors='ignore').strip() if nmea_str.startswith('!AIVDM'): print(nmea_str)这段代码最关键的是timeout=0.5这个参数。AIS 接收机的报文不是匀速到达的,船舶密集的水域一秒可能来几十条,空旷水域几秒才一条。如果 timeout 设为 0,readline 会一直阻塞等待直到读到换行符,这会占用循环导致后续处理卡顿;如果设得太大,实时性又受影响。0.5 秒是一个在吞吐量和响应速度之间比较平衡的取值。第二个要点是errors='ignore',AIS 报文在某些驱动下会在行首出现零字节,直接 decode 会抛异常,忽略掉不可解码字符能保证程序不中断。
3.2 网络 UDP 与 TCP 接收方式的差异
如果用的是带网络接口的 AIS 接收机,或者接了信号转发器(比如通过局域网共享 AIS 数据),接收方式就变成 UDP 监听或 TCP 连接。这两种方式在稳定性上差别挺大。UDP 适合不要求可靠性的场景——AIS 数据本身是周期广播的,丢一条位置报告下一分钟还会再发,所以很多雷达和监控系统用 UDP 就够了。TCP 则保证字节顺序和完整性,适合做数据落地存储或交给下游解析模块的场景。
UDP 接收的代码比串口更短,因为不需要管串口参数,只需绑定 IP 和端口:
import socket # 监听本机 10110 端口,这个端口常用于 AIS 数据流 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(('0.0.0.0', 10110)) sock.settimeout(1.0) while True: try: data, addr = sock.recvfrom(4096) text = data.decode('ascii', errors='ignore') # 网络流可能一条 datagram 里有多条 NMEA,按换行拆分 for nmea in text.splitlines(): if nmea.startswith('!AIVDM'): print(nmea) except socket.timeout: continue这段代码相比串口版有一个特别容易踩坑的地方:网络数据可能一次收一个包里包含很多条语句,也可能一条语句被拆成两个 TCP 段。所以我先用splitlines()拆行,保证每条 NMEA 语句是完整的。如果直接用recvfrom拿到的整块数据去做校验和和解析,大概率在校验和这一步就开始报错。
3.3 驱动异常的最快定位手段
如果程序跑起来什么都没有,首先不要怀疑解码逻辑,先把数据流抓出来看。最直接的判断方式是看原始输出里有没有!AIVDM前缀的语句。没有,前面的链路就有问题:设备没连上、驱动没装好、串口被占用、或者接收机天线没接。有语句但是解码结果不对,才是解析逻辑的问题。用dmesg | grep tty在 Linux 上可以确认 USB 设备是否被识别为串口。如果在 Windows 上,设备管理器里看 COM 口旁边有没有黄色感叹号,有感叹号就是驱动不对,先换驱动而不是调程序。
4. 解码与解析:把 AIVDM 变成船舶结构化信息
4.1 核心解码器构建:从字符串到二进制流
完成字符串到二进制的转换是整个解码器最基础的一层。把每个 6-bit 字符还原成对应数值之后,还要把它们拼成一个连续的二进制字符串,再按字段位宽去切片。这个过程如果全部用 Python 的字符串操作来做,虽然直观但性能差一些;用位运算来做则要处理跨字节边界,代码复杂度高一些。常规做法是先把所有 6-bit 值累积成一个整数列表,然后用循环切片。
下面这段实现了核心转换和类型 1 消息的位置解析:
def ascii_6bit_to_int(ch): """ 将AIS 6-bit字符转为数值 规则:ASCII码减48,超过40则再减8 """ v = ord(ch) - 48 if v > 40: v -= 8 return v def payload_to_bits(payload): """将payload字符串拼接为连续二进制字符串""" bits = '' for ch in payload: v = ascii_6bit_to_int(ch) bits += format(v, '06b') return bits def parse_type1(bits): msg_type = int(bits[0:6], 2) mmsi = int(bits[6:36], 2) # 经纬度字段带符号,直接转int后判断并转换单位 lon_raw = int(bits[61:89], 2) lat_raw = int(bits[89:116], 2) if lon_raw >= 0x4000000: # 检查符号位 lon_raw -= 0x8000000 if lat_raw >= 0x2000000: lat_raw -= 0x4000000 return { 'type': msg_type, 'mmsi': mmsi, 'lon': lon_raw / 600000.0, 'lat': lat_raw / 600000.0, 'sog': int(bits[50:60], 2) / 10.0, 'cog': int(bits[116:128], 2) / 10.0, }这段代码里有几个值得注意的边界处理。首先是经纬度符号判定,AIS 协议用二进制补码表示负值,经度 28 位中最高位是符号位,纬度 27 位中符号位在 27 位这一组里的最高位。所以我用半范围的数值来判断正负,然后减去完整范围的补码偏移量。如果直接按 int 解析,负的经度会被解成一个 1.8 亿左右的大正数,再除以 600000 就变成 300 多度,明显错得离谱。另一个是航速字段的处理,SOG实际值是 0 到 1023,其中 1023 表示不可用,所以要加个判断:如果等于 1023 则输出 None 而不是 102.3 节。
4.2 位置报告与静态信息消息的差异化解析
类型的区分是解析器的分水岭。前面 parse_type1 其实对类型 2、3 也适用,因为它们的字段布局完全相同。而类型 5(静态与航次信息)的字段布局完全不同,它的 payload 更长且拆分方式不同。类型 5 消息的核心字段从第 38 位开始是 IMO 编号,接着 42 位是船名(6-bit 转字符,6 位一组),再往后是船型、船舶尺寸、吃水、目的地等。船名需要把 6-bit 值再映射回 ASCII 字符,规则是:值在 1 到 26 之间对应 A 到 Z,值 32 对应空格,值 32 以上的按 ASCII 表映射。
实际写解析器时,通常会先读消息类型,然后把每条消息路由到对应的解析函数。下面是一个简单的 dispatch 逻辑:
def decode_aivdm(payload, fill_bits): bits = payload_to_bits(payload) # 去掉填充位,避免填充0干扰字段解析 bits = bits[:-fill_bits] if fill_bits else bits msg_type = int(bits[0:6], 2) if msg_type in (1, 2, 3): return parse_type1(bits) elif msg_type == 4: return parse_type4(bits) elif msg_type == 5: return parse_type5(bits) elif msg_type == 18: return parse_type18(bits) else: # 保留消息类型编号方便排查,不中断循环 return {'type': msg_type, 'mmsi': int(bits[6:36], 2)}删掉填充位的操作容易漏掉。AIS 消息总长度不一定恰好是 6 的整数倍,当最后一帧的比特数不够时,会用 0 补齐,同时 NMEA 语句最后一个字段给出填充位的数量。如果不去掉这些占位 0,整数解析时会把填充位当成有效位,导致最后一个字段解析多出几个比特,数值会偏大。我见过的最典型翻车案例是航向字段没去填充位,所有船的航向都是 409.5 度,其实是 10 位字段里混入了两个多余的 0 位。
4.3 数据类型转换与输出格式标准化
解码后的数据要落地,就涉及数据类型转换。AIS 原始字段大多是整数或低精度定点数,但输出对接数据库或者前端展示时要统一成标准的 JSON 或 CSV。经纬度统一转成十进制度并保留 6 位小数,MMSI 用字符串存储避免前后端数值精度问题。下面是输出格式化的示例:
def to_json(parsed): return { 'mmsi': str(parsed['mmsi']), 'lat': round(parsed['lat'], 6), 'lon': round(parsed['lon'], 6), 'sog': round(parsed['sog'], 1) if parsed['sog'] is not None else None, 'cog': round(parsed['cog'], 1) if parsed['cog'] is not None else None, }这里把 MMSI 转换为字符串是有实际原因的。MMSI 是一个 9 位数字,在 JavaScript 或某些前端框架里如果当数字处理,超过 2^53 会有精度风险,虽然 MMSI 只有 9 位暂时还好,但字符串是最安全的方式。SOG 和 COG 的空值则保持为 None,不要用 0 或 1023 这类魔法值去替代,否则后续做航速统计时会出现一堆速度为 0 的无效点。
5. 常见问题与排查:驱动、乱码、丢包的四个坑
5.1 读串口没有任何输出
现象:程序跑了,串口打开,但控制台没有任何 AIS 语句打印。用串口助手也收不到数据。
原因:最常见的是波特率配置错误。AIS 接收机的默认波特率不统一,不同厂商差异很大,部分老设备只有 4800,多数现代模块是 38400,少数支持 57600。其次是 USB 转串口的芯片驱动没装上,在 Linux 下表现为设备节点没创建,在 Windows 下表现为设备管理器里 COM 口是灰色的。
解决:先用dmesg | grep tty确认设备节点,再依次尝试全部常用波特率。我在做一个多船监控模拟项目时,某款接收机就是 4800 波特率,而教程默认 38400,换了之后数据马上出来了。如果设备有指示灯,收数据时灯会闪,也可以用来判断前端是否在正常工作。
5.2 能收到 AIVDM 但解析出的坐标在赤道附近跳变
现象:解码后经纬度全部落在 0 度附近,或者坐标点一会儿在赤道,一会儿在非洲大陆。
原因:经纬度符号位处理错误。这是 AIS 解码特有的一个典型问题——经度 28 位、纬度 27 位都用二进制补码表示,但很多人会直接把它当无符号整数解析,导致所有负坐标都变成超级大的正坐标,最终除以 600000 后落在异常区域。
解决:回到 4.1 中的 parse_type1,对经度字段先判断 raw 值是否大于0x4000000(即符号位为 1),是则减去0x8000000;纬度同理,用0x2000000和0x4000000做判断。我写了一个通用的补码转有符号整数函数,把字段切片和补码转换封装在一起,一次改好到处复用,不再每次手写判断。
5.3 类型 5 消息缺失,或者船名解析出来是乱码
现象:能收到类型 1/2/3 的位置信息,但类型 5 的船名、目的地字段全是@或乱码,部分语句直接报 CRC 校验错误。
原因:类型 5 消息被拆成两条 NMEA 语句,而解析逻辑没有做拼接,而是直接对第一帧的 payload 做了解码。多余的帧被当成另一条独立消息传给解码器,后半段字段自然对不上。另外船名长度不足 20 个字符时 AIS 协议会用@@填充,如果不对@做截断处理,数据库里的船名字段就会带着尾巴。
解决:写一个按消息 ID 缓存帧的拼包逻辑,仅当接收到的帧数等于消息总帧数时,将全部 payload 拼接后交给解码器。对船名字段解析输出时做清洗,遇到@直接截断。代码实现很简单,但要注意用 dict 管理帧缓冲时,长时间收不到最后一帧的缓存要设定超时清理,防止内存增长。
5.4 校验和一致失败,但语句格式看起来沒问题
现象:所有 NMEA 语句都能显示,但*后面的校验和跟本地计算的结果永远对不上,而且检查字符和算式也没问题。
原因:串口数据中混入了\r\n,或者 USB 驱动把两个 AIS 频道的原始数据在帧间插入了不可见字符。更有一种情形是,接收机输出的不是 NMEA 封装,而是原始 6-bit 数据流,此时每一行本身就不是完整语句,自然没有合法的校验和。
解决:在读取串口后先做一次数据清洗,把\r去掉,只保留\n作为行分隔符。如果发现多行数据挤在一个 buffer 里,用splitlines()逐行分离再逐条校验。要是数据来源是原始 AIS 帧而非 NMEA 封装,就需要先做位同步和去填充,再按 6-bit 组装 payload。这种情况通常在低成本的 DIY 接收模块上遇到,换用官方驱动可解决。
6. 最后的进阶:离线重放与解析验证
拿到教程和工具包之后,最值得先做的一件事不是直接对接真实接收机,而是用一段历史 NMEA 日志做离线重放验证。我一般会在/tmp放一个ais_sample.log,里面存几十条真实场景的 AIVDM 语句,然后用脚本逐行回放给解析器。这样做的好处是:不依赖外设,可以随时回归测试;对比已知的正确输出,能快速定位是驱动问题还是解析逻辑问题。
重放脚本的核心是构造一个假的串口或网络源,把文件里的每一行当作实时到达的数据喂给解析模块。这样你可以在没有硬件的情况下,把整个数据链路跑通,也能在改了解码逻辑后快速验证有没有引入回归。常见做法是用 Python 的队列加定时器,模拟真实接收间隔:
import queue import time import threading replay_queue = queue.Queue() def load_log(path): with open(path, 'r', encoding='utf-8') as f: lines = [line.strip() for line in f if line.startswith('!AIVDM')] for line in lines: replay_queue.put(line) time.sleep(0.05) def worker(): while True: line = replay_queue.get() if line is None: break decoded = decode_aivdm(line) if decoded and decoded.get('lat') and decoded.get('lon'): print(decoded)这里load_log用了time.sleep(0.05),也就是模拟 20 条每秒的接收频率,非常接近真实航道的中等密度场景。如果要模拟繁忙水域,可以去掉 sleep 或者降到 0.01 秒。
另外一个我强烈推荐的做法是抓取同一艘船的类型 1 和类型 5 消息做交叉校验。位置消息里只有 MMSI 没有船名,静态消息里有 MMSI 和船名,通过 MMSI 把两边的记录关联起来,就可以验证类型 5 的拼接逻辑是否正确。如果在重放日志里发现某条船的类型 5 消息总是只解析出半截船名,那基本可以确定拼包逻辑漏了帧。
离开这个项目后,我养成的习惯是:每次拿到新的 AIS 数据集或驱动包,第一件事不是接硬件,而是先重放一遍已知输出。只要日志和解析器都在,五分钟就能判断这套资源是否值得深入投入。希望你也能在 AIS 解码这条链路上少走几个弯,希望这些经验对你有帮助。
本文还有配套的精品资源,点击获取