AIS数据链实战:从串口驱动到报文解析的完整指南
简介这份资源面向希望系统掌握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/ttyS0Windows 下的COM3。驱动层要做的只有一件事把这个串口设备打开按正确的波特率、数据位、停止位、校验位读字节流并且保证不丢包、不粘包、不因为缓冲区溢出而断流。听起来简单但实际翻车点非常集中。最常见的是波特率不对。AIS 的串口输出常见波特率是 38400但有些设备出厂默认是 9600 或 115200你按 38400 打开读到的就是乱码或者干脆没数据。第二个坑是流控。很多 AIS 设备默认开启 RTS/CTS 硬件流控如果你的程序没有正确配置设备可能根本不往外发数据。第三个坑是读取方式。用阻塞读还是非阻塞读一次读多少字节超时设多少直接决定了你在高并发目标环境下会不会丢帧。我一般会先用一个最小串口读取脚本把原始字节流打出来确认物理层通了再往上做解码。这一步不要跳过跳过的人后面会在解码层浪费大量时间。2.2 用 Python 打开 AIS 串口的最小可用代码下面这段代码是我常用的串口读取骨架基于pyserial。它的作用不是解码只是把原始字节流稳定地读出来并打印十六进制用来验证驱动层是否正常。import serial import time # 打开串口参数必须和 AIS 设备实际输出一致 # port: Linux 下通常是 /dev/ttyUSB0Windows 下是 COMx # baudrate: AIS 常见 38400但务必确认设备手册或实际测试 # timeout: 读超时设 0.5 秒避免阻塞死等 ser serial.Serial( port/dev/ttyUSB0, baudrate38400, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.5, rtsctsFalse, # 先关闭硬件流控如果设备需要再打开 dsrdtrFalse ) # 清空缓冲区避免上电前的残留数据干扰 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;88MD5MTDww2D0l,0*5C这行文本里!AIVDM是帧头1,1,,A是分片信息13aG?P0P00PD;88MD5MTDww2D0l是载荷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;88MD5MTDww2D0l,0*5C 输出: 13aG?P0P00PD;88MD5MTDww2D0l 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;88MD5MTDww2D0l,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,55P5TL01VIaAL7WKOmBplUPDhh000000001S;AJ::4A80?4iE53,0*3E !AIVDM,2,2,3,B,10000000000000,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 里最常用的位置报告结构基本一致。解析时按位切分每个字段有固定的起始位和长度。下面这张表列出最关键的几个字段方便你对照代码。字段起始位长度说明单位消息类型061/2/3-MMSI830船舶识别号-航行状态3840-15-转向率428有符号-对地速度50100-1022节位置精度6010/1-经度6128有符号1/600000 度纬度8927有符号1/600000 度对地航向116120-35990.1 度时间戳13760-59秒解析时用位操作从比特流里取值。比如经度是 28 位有符号整数取值后除以 600000 得到度数。纬度是 27 位有符号整数同样除以 600000。对地速度是 10 位无符号整数除以 10 得到节。这些换算系数是固定的写死在代码里即可。4.2 用位操作解析消息类型 1 的完整示例下面这段代码在上一节解码器的基础上继续解析消息类型 1 的关键字段。它假设你已经拿到了比特流字符串。def bits_to_int(bits, start, length, signedFalse): 从比特流中提取一个整数 bits: 比特流字符串 start: 起始位 length: 长度 signed: 是否有符号 segment bits[start:startlength] 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, signedTrue) sog bits_to_int(bits, 50, 10) lon bits_to_int(bits, 61, 28, signedTrue) lat bits_to_int(bits, 89, 27, signedTrue) 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 小时就认为跨天了日期加一天或减一天。这个逻辑不复杂但漏了就会在午夜前后出现时间错乱。希望这些经验能帮到你少走一点弯路。本文还有配套的精品资源点击获取