简介:这份资源面向希望系统掌握AIS船舶自动识别系统的开发者与学习者,围绕驱动、解码、解析三个核心环节展开,帮助读者理解从VHF射频信号接收、数字信号解调,到按ITU-R M.1371标准提取船舶静态与动态信息的完整链路。压缩包为gz格式,共3个文件,包含2个txt文本与1个cpp源码,整体约5KB,其中cpp文件可用于参考解码与解析逻辑的实现思路,txt文件则承载数据与说明内容,便于对照代码理解报文结构与坐标转换等关键步骤。目前已有1033人学习下载,说明该主题在海洋信息化与无线通信领域具有一定关注度。资源虽小,但覆盖了AIS驱动交互、二进制报文解析、MMSI查询及数据过滤报警等工程要点,适合作为入门AIS解码开发的轻量参考,也可为海上交通监控、港口管理与防碰撞预警等应用场景提供基础思路。
1. AIS教程、驱动、解码、解析:一条数据链上的四个关键环节
很多人第一次接触 AIS,是从“收不到船”开始的。设备接上了,天线也架了,软件里却只有零星几条目标,或者干脆一片空白。这时候大多数人会去查“AIS教程”,但真正的问题往往不在教程本身,而在一条完整的数据链上:驱动层有没有把串口数据稳定读上来,解码层有没有把二进制帧还原成字段,解析层有没有把字段映射成可用的业务信息。这四个环节——教程、驱动、解码、解析——不是并列的四个知识点,而是一条从物理层到应用层的流水线,任何一环断了,后面都白搭。
这篇文章面向的是想自己动手把 AIS 数据接进系统的人:可能是做船舶监控的开发者,可能是做港口调度的工程师,也可能是做数据采集的爱好者。我会按“先讲清每一层在干什么,再给可复现的代码和参数,最后说坑在哪”的顺序展开。不堆术语,不抄手册,只讲我实际搭过、调过、翻过车的那部分。
2. 驱动层:把 AIS 串口数据稳定读上来
2.1 为什么驱动层是整条链最容易翻车的地方
AIS 数据的物理来源通常是两类:一类是船载 AIS 设备通过 RS-422 或 RS-232 串口输出,另一类是 AIS 接收机通过 USB 转串口输出。不管哪种,到了操作系统层面,你面对的都是一个串口设备节点,比如 Linux 下的/dev/ttyUSB0或/dev/ttyS0,Windows 下的COM3。驱动层要做的只有一件事:把这个串口设备打开,按正确的波特率、数据位、停止位、校验位读字节流,并且保证不丢包、不粘包、不因为缓冲区溢出而断流。
听起来简单,但实际翻车点非常集中。最常见的是波特率不对。AIS 的串口输出常见波特率是 38400,但有些设备出厂默认是 9600 或 115200,你按 38400 打开,读到的就是乱码或者干脆没数据。第二个坑是流控。很多 AIS 设备默认开启 RTS/CTS 硬件流控,如果你的程序没有正确配置,设备可能根本不往外发数据。第三个坑是读取方式。用阻塞读还是非阻塞读,一次读多少字节,超时设多少,直接决定了你在高并发目标环境下会不会丢帧。
我一般会先用一个最小串口读取脚本把原始字节流打出来,确认物理层通了,再往上做解码。这一步不要跳过,跳过的人后面会在解码层浪费大量时间。
2.2 用 Python 打开 AIS 串口的最小可用代码
下面这段代码是我常用的串口读取骨架,基于pyserial。它的作用不是解码,只是把原始字节流稳定地读出来并打印十六进制,用来验证驱动层是否正常。
import serial import time # 打开串口,参数必须和 AIS 设备实际输出一致 # port: Linux 下通常是 /dev/ttyUSB0,Windows 下是 COMx # baudrate: AIS 常见 38400,但务必确认设备手册或实际测试 # timeout: 读超时,设 0.5 秒避免阻塞死等 ser = serial.Serial( port='/dev/ttyUSB0', baudrate=38400, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.5, rtscts=False, # 先关闭硬件流控,如果设备需要再打开 dsrdtr=False ) # 清空缓冲区,避免上电前的残留数据干扰 ser.reset_input_buffer() ser.reset_output_buffer() try: while True: # 一次读 1024 字节,读不到就等超时 data = ser.read(1024) if data: # 打印十六进制,方便肉眼确认是否有 AIS 帧头 hex_str = data.hex() print(f"[{time.strftime('%H:%M:%S')}] {len(data)} bytes: {hex_str[:120]}...") else: # 超时无数据,说明物理层可能没通 print("no data, check wiring/baudrate/flow control") except KeyboardInterrupt: ser.close() print("closed")这段代码的逻辑很直白:打开串口,循环读,读到就打印十六进制,读不到就提示。关键参数有三个。baudrate必须和 AIS 设备一致,不确定就逐个试 38400、9600、115200。rtscts先设False,如果读不到数据再尝试True,因为有些设备必须靠 RTS 信号触发发送。timeout设 0.5 秒,太短会导致频繁空读,太长会让你误以为设备没数据。
运行后,如果你看到类似21 7e ...或a1 a2 ...的字节流,说明驱动层通了。如果一直是no data,先查线序,再查波特率,最后查流控。这一步没有玄学,只有耐心。
2.3 驱动层参数速查与常见误配
| 参数 | 常见值 | 误配后果 | 确认方法 |
|---|---|---|---|
| 波特率 | 38400 / 9600 / 115200 | 乱码或无数据 | 逐个尝试,看十六进制是否有规律 |
| 数据位 | 8 | 帧错位 | 默认 8,一般不用改 |
| 停止位 | 1 | 帧边界错 | 默认 1,一般不用改 |
| 校验位 | None | 数据被丢弃 | AIS 通常无校验,设 None |
| 流控 | 无 / RTS-CTS | 设备不发送 | 先关流控,不行再开 |
| 读取超时 | 0.5s | 空读或阻塞 | 根据数据频率调整 |
这张表建议在调试时放在手边。我见过太多人卡在“设备明明有输出但程序读不到”,最后发现是流控没配对。驱动层的问题,90% 都能靠这张表定位。
3. 解码层:从二进制帧到 AIS 报文
3.1 AIS 帧结构:为什么不能直接按字符串处理
驱动层读上来的是字节流,但 AIS 数据不是 ASCII 文本,而是二进制帧。常见的 AIS 帧格式有两种:一种是!AIVDM开头的 NMEA 0183 封装格式,另一种是原始二进制格式。大多数接收机输出的是 NMEA 封装格式,看起来像这样:
!AIVDM,1,1,,A,13aG?P0P00PD;88MD5MTDww@2D0l,0*5C这行文本里,!AIVDM是帧头,1,1,,A是分片信息,13aG?P0P00PD;88MD5MTDww@2D0l是载荷,0*5C是填充位和校验和。载荷本身是 6-bit ASCII 编码的二进制数据,不是普通字符串。你不能直接split逗号然后当文本用,必须先把载荷按 6-bit 字符表还原成比特流,再按 AIS 消息定义逐字段解析。
这就是解码层要做的事:把 NMEA 封装里的载荷字符串,还原成原始比特流,再根据消息类型(1、2、3、5、18、24 等)切分出字段。很多人跳过这一步,直接在网上找现成的解析库,结果遇到分片消息或者非标准帧就崩了。自己写一遍解码,不是为了重复造轮子,而是为了在出问题时知道该看哪。
3.2 手写一个 AIS 6-bit 载荷解码器
下面这段 Python 代码实现两个功能:第一,从 NMEA 行里提取载荷;第二,把载荷按 6-bit 字符表还原成比特流。这是解码层最核心的一步。
# AIS 6-bit ASCII 字符表,索引 0-63 对应字符 # 这个表是 AIS 标准定义的,不能改 SIXBIT_TABLE = "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !\"#$%&'()*+,-./0123456789:;<=>?" def extract_payload(nmea_line): """ 从 NMEA 行中提取 AIS 载荷字符串 输入: '!AIVDM,1,1,,A,13aG?P0P00PD;88MD5MTDww@2D0l,0*5C' 输出: '13aG?P0P00PD;88MD5MTDww@2D0l' """ line = nmea_line.strip() if not line.startswith('!AIVDM') and not line.startswith('!AIVDO'): return None parts = line.split(',') if len(parts) < 6: return None # 第 5 个字段(索引 5)是载荷 payload = parts[5] # 去掉可能的校验和部分 if '*' in payload: payload = payload.split('*')[0] return payload def payload_to_bits(payload): """ 把 6-bit 载荷字符串还原成比特流字符串 每个字符查表得到 0-63 的值,再转成 6 位二进制 """ bits = '' for ch in payload: if ch not in SIXBIT_TABLE: # 遇到非法字符,跳过或报错 continue val = SIXBIT_TABLE.index(ch) bits += format(val, '06b') return bits # 测试 nmea = '!AIVDM,1,1,,A,13aG?P0P00PD;88MD5MTDww@2D0l,0*5C' payload = extract_payload(nmea) print('payload:', payload) bits = payload_to_bits(payload) print('bits length:', len(bits)) print('first 60 bits:', bits[:60])这段代码的关键在于SIXBIT_TABLE。AIS 的 6-bit 编码不是标准 Base64,它的字符表是固定的,从@到?共 64 个字符。每个字符对应一个 0 到 63 的数值,再转成 6 位二进制。比如字符1在表中的索引是 1,二进制是000001;字符3索引是 3,二进制是000011。把载荷里每个字符都转成 6 位,拼起来就是原始比特流。
拿到比特流后,就可以按 AIS 消息定义切字段了。比如消息类型 1 的前 6 位是消息 ID,接下来 30 位是 MMSI,再往后是航行状态、转向率、对地速度、经纬度等。每个字段的起始位和长度在 AIS 标准里都有明确定义。你不需要背,但需要知道去哪里查,以及怎么用位操作切出来。
3.3 分片消息的处理:为什么一条船的数据会分成多行
AIS 消息有长短之分。消息类型 5(静态和航程数据)和类型 24(静态数据)比较长,超过一条 NMEA 行的容量,就会被拆成多个分片。比如:
!AIVDM,2,1,3,B,55P5TL01VIaAL@7WKO@mBplU@<PDhh000000001S;AJ::4A80?4i@E53,0*3E !AIVDM,2,2,3,B,1@0000000000000,2*55第一行里2,1,3表示这是 2 个分片中的第 1 个,序列号是 3。第二行2,2,3表示第 2 个分片,序列号相同。解码时必须把同一序列号的分片按顺序拼接,再还原比特流。如果只处理单行,类型 5 的消息就会丢字段。
处理分片的常见做法是维护一个字典,key 是序列号,value 是分片列表。收到新分片时追加,收齐后拼接载荷再解码。注意分片可能乱序到达,也可能因为丢包永远收不齐,所以需要超时清理机制。我一般设 10 秒超时,超过就丢弃该序列号的所有分片,避免内存泄漏。
4. 解析层:把比特流映射成业务字段
4.1 消息类型 1/2/3:位置报告的核心字段
消息类型 1、2、3 是 AIS 里最常用的位置报告,结构基本一致。解析时按位切分,每个字段有固定的起始位和长度。下面这张表列出最关键的几个字段,方便你对照代码。
| 字段 | 起始位 | 长度 | 说明 | 单位 |
|---|---|---|---|---|
| 消息类型 | 0 | 6 | 1/2/3 | - |
| MMSI | 8 | 30 | 船舶识别号 | - |
| 航行状态 | 38 | 4 | 0-15 | - |
| 转向率 | 42 | 8 | 有符号 | - |
| 对地速度 | 50 | 10 | 0-1022 | 节 |
| 位置精度 | 60 | 1 | 0/1 | - |
| 经度 | 61 | 28 | 有符号 | 1/600000 度 |
| 纬度 | 89 | 27 | 有符号 | 1/600000 度 |
| 对地航向 | 116 | 12 | 0-3599 | 0.1 度 |
| 时间戳 | 137 | 6 | 0-59 | 秒 |
解析时用位操作从比特流里取值。比如经度是 28 位有符号整数,取值后除以 600000 得到度数。纬度是 27 位有符号整数,同样除以 600000。对地速度是 10 位无符号整数,除以 10 得到节。这些换算系数是固定的,写死在代码里即可。
4.2 用位操作解析消息类型 1 的完整示例
下面这段代码在上一节解码器的基础上,继续解析消息类型 1 的关键字段。它假设你已经拿到了比特流字符串。
def bits_to_int(bits, start, length, signed=False): """ 从比特流中提取一个整数 bits: 比特流字符串 start: 起始位 length: 长度 signed: 是否有符号 """ segment = bits[start:start+length] if len(segment) < length: return None val = int(segment, 2) if signed: # 有符号数,最高位是符号位 if segment[0] == '1': val = val - (1 << length) return val def parse_msg_1(bits): """ 解析消息类型 1/2/3 的关键字段 """ msg_type = bits_to_int(bits, 0, 6) if msg_type not in (1, 2, 3): return None mmsi = bits_to_int(bits, 8, 30) nav_status = bits_to_int(bits, 38, 4) rot = bits_to_int(bits, 42, 8, signed=True) sog = bits_to_int(bits, 50, 10) lon = bits_to_int(bits, 61, 28, signed=True) lat = bits_to_int(bits, 89, 27, signed=True) cog = bits_to_int(bits, 116, 12) timestamp = bits_to_int(bits, 137, 6) return { 'msg_type': msg_type, 'mmsi': mmsi, 'nav_status': nav_status, 'rot': rot, 'sog': sog / 10.0 if sog is not None else None, 'lon': lon / 600000.0 if lon is not None else None, 'lat': lat / 600000.0 if lat is not None else None, 'cog': cog / 10.0 if cog is not None else None, 'timestamp': timestamp } # 假设 bits 是上一节解码得到的比特流 # 这里用一段示例比特流演示 sample_bits = '000001' + '000000000000000000000000000001' + '0000' + '00000000' + '0000000000' + '0' + '0000000000000000000000000000' + '000000000000000000000000000' + '000000000000' + '000000' result = parse_msg_1(sample_bits) print(result)这段代码的核心是bits_to_int函数。它从比特流里切出指定长度的片段,转成整数,如果是符号数就做补码转换。然后parse_msg_1按字段表逐个取值,最后做单位换算。注意经度和纬度的换算系数是 600000,对地速度和对地航向是 10。这些系数不能错,错了位置就偏到海里去了。
实际使用时,比特流长度可能因为分片或填充位不足而短于预期,所以每个字段取值后都要判空。我一般会在解析前先检查比特流长度是否足够,不够就丢弃或等待分片补齐。
4.3 解析层的数据校验与异常处理
解析层最容易出的问题不是算错,而是拿到脏数据。比如 MMSI 为 0,经纬度超出合理范围,对地速度是 1023(表示不可用),这些都需要在解析后做校验。我的习惯是设几个硬边界:纬度在 -90 到 90 之间,经度在 -180 到 180 之间,对地速度在 0 到 102.2 节之间,超出就标记为无效。MMSI 必须是 9 位数字,不足 9 位的前面补零。
另外,AIS 消息里的时间戳是秒数,但可能不是当前时间,而是 UTC 秒。如果你要做实时监控,需要结合接收时间来判断数据新鲜度。我一般会在解析结果里加一个recv_time字段,记录本地接收时间,后续做超时判断。
5. 避坑与排查:AIS 数据链上最常见的五个问题
5.1 串口有数据但全是乱码
现象:驱动层能读到字节,但十六进制看起来没有规律,或者偶尔出现!AIVDM但后面字符错乱。原因:波特率不匹配,或者数据位/停止位/校验位配置错误。解决:先确认设备手册的串口参数,逐个尝试 38400、9600、115200,同时检查数据位是否为 8、停止位是否为 1、校验位是否为 None。如果仍然乱码,换一根短接线,排除线缆干扰。
5.2 能收到单条消息但收不到类型 5
现象:消息类型 1/2/3 正常解析,但类型 5 的静态数据始终没有。原因:类型 5 是分片消息,你的代码只处理了单行,没有做分片拼接。解决:在解码层维护分片字典,按序列号收集,收齐后再解码。注意分片可能乱序,也可能丢包,设 10 秒超时清理。
5.3 解析出的经纬度明显偏离
现象:MMSI 和速度看起来正常,但经纬度落在沙漠或海洋中间。原因:经纬度是有符号数,解析时没有做补码转换,或者换算系数用错。解决:确认经度是 28 位有符号,纬度是 27 位有符号,换算系数是 600000。检查bits_to_int的signed参数是否传了True。
5.4 程序运行一段时间后丢数据
现象:刚开始正常,跑几小时后目标数量下降,或者串口读取报错。原因:串口缓冲区溢出,或者没有及时读取导致驱动层丢包。解决:把读取循环放在独立线程,读到的数据立刻放入队列,解码和解析在另一个线程处理。读取超时设短一点,比如 0.1 秒,保证高频读取。同时检查串口缓冲区大小,必要时用set_buffer_size调大。
5.5 多设备同时接入时串口冲突
现象:接一个 AIS 设备正常,接两个就互相干扰或程序崩溃。原因:多个进程同时打开同一个串口,或者串口设备节点被占用。解决:确保一个串口只被一个进程打开。如果是多设备,用不同的设备节点,比如/dev/ttyUSB0和/dev/ttyUSB1,并在代码里分别打开。不要用同一个串口对象去读多个设备。
6. 进阶技巧:把 AIS 数据链做成可复用的管道
6.1 用队列解耦驱动、解码、解析三层
当你把驱动、解码、解析都跑通后,下一步是让它们不要互相阻塞。我的做法是用三个线程加两个队列:驱动线程只负责读串口,读到原始字节就放入raw_queue;解码线程从raw_queue取数据,还原成比特流后放入bits_queue;解析线程从bits_queue取比特流,解析成业务字段后写入数据库或消息队列。这样任何一层短暂卡顿都不会影响其他层,整体吞吐量会明显提升。
队列长度要设上限,比如 1000,满了就丢弃最旧的数据,避免内存无限增长。同时给每个队列加一个计数器,定期打印处理速率,方便发现瓶颈。
6.2 用配置文件管理串口参数和消息类型
不要把波特率、设备节点、消息类型这些硬编码在代码里。我一般用一个 YAML 或 JSON 配置文件,把每个 AIS 设备的串口参数、需要解析的消息类型、输出目标都写进去。这样换设备或加设备时不用改代码,只改配置。下面是一个配置示例:
ais_devices: - name: "receiver_a" port: "/dev/ttyUSB0" baudrate: 38400 rtscts: false msg_types: [1, 2, 3, 5, 18, 24] - name: "receiver_b" port: "/dev/ttyUSB1" baudrate: 38400 rtscts: true msg_types: [1, 2, 3, 18]解析线程根据msg_types决定是否处理某类消息,不需要的可以直接跳过,节省 CPU。这个习惯让我在后期扩展时省了很多重复劳动。
6.3 验证解析结果是否正确的三个方法
第一个方法是对照公开的 AIS 解码工具。把你收到的原始 NMEA 行复制到在线解码器里,对比 MMSI、经纬度、速度是否一致。第二个方法是看数据连续性。同一艘船的经纬度在短时间内应该是连续变化的,如果跳变超过合理范围,说明解析有问题。第三个方法是统计消息类型分布。正常情况下,类型 1/2/3 占大多数,类型 5 和 24 较少。如果类型 5 完全为零,检查分片处理;如果类型 18 突然增多,可能是接收到了 Class B 船台。
我一般会先跑一天数据,把统计结果打出来,确认没有异常后再接入业务系统。这一步花的时间,比后面修数据问题的时间少得多。
6.4 一个我踩过的坑:时区与时间戳
AIS 消息里的时间戳是 UTC 秒,但很多业务系统用本地时间。我一开始直接把时间戳当本地时间用,结果船位时间差了 8 小时,排查了半天才发现是时区问题。后来我在解析层统一转成 UTC 时间,存储时也存 UTC,只在展示层做本地化。这个习惯建议你从一开始就养成,不然后面数据对不上时,后悔药可不好吃。
另外,AIS 的时间戳只有秒,没有日期。跨天的时候需要结合接收时间判断是哪一天。我的做法是:如果时间戳和接收时间的 UTC 秒差超过 12 小时,就认为跨天了,日期加一天或减一天。这个逻辑不复杂,但漏了就会在午夜前后出现时间错乱。
希望这些经验能帮到你,少走一点弯路。
本文还有配套的精品资源,点击获取