简介围绕AIS自动识别系统从射频信号到应用解析的完整处理链路这份资料面向海事信息化、船舶监控及无线通信方向的开发者与学习者帮助解决从硬件驱动到数据解码、再到上层解析的核心技术衔接问题。压缩包共3个文件包含2个txt说明文档与1个cpp源文件整体体积仅5KB虽小巧但覆盖解码、驱动与解析三类环节适合作为最小参考实现目前已有1036人浏览学习。cpp文件展示了AIS报文解码与解析的具体实现两个txt文档分别对应驱动控制说明与数据格式/解码协议注释可用于理解ITU-R M.1371标准下的二进制报文结构、坐标转换和MMSI信息提取流程。对于刚接触AIS协议栈的开发者而言这份资源能够快速建立从设备驱动到解码输出的整体认知节省大量翻阅协议文档与调试时间。1. AIS 教程的核心链路驱动、解码、解析一篇文章跑通把 USB AIS 接收机插到电脑上串口工具里唰唰滚动的是各种!AIVDM开头、看起来像乱码的字符串。这些字符串不是乱码每一条都携带一艘船的 MMSI、经纬度、船速、航向甚至还有目的港和预计到达时间。但要把它们变成能用的业务数据中间隔着三件事驱动把接收机当串口或网络设备读出来、解码把 6-bit 编码翻译成字段、解析把字段组织成结构化对象。这篇文章写给做海事数据采集、船舶监控系统或 AIS 模拟数据准备的开发者按驱动、解码、解析的顺序把链路拆开并给出可直接复现的代码和解码参数。2. AIS 报文格式先搞懂 AIVDM 语句结构和 6-bit 编码2.1 一条 AIVDM 语句拆开看AIS 接收机吐出来的原始数据属于 0183 协议簇一行语句以\r\n结尾。常见的一帧位置报文长这样!AIVDM,1,1,,A,177KQJ5000G?tOKRA1wUbN0TKH,0*5C这行看起来像随机字符但每个字段都有明确含义。按逗号切分后位置示例含义第 0 段!AIVDM语句类型!AIVDO表示本机发出的数据第 1 段1总帧数1 表示多帧消息第 2 段1当前帧序号第 3 段空多帧组标识用于拼接多帧第 4 段A接收通道第 5 段177KQJ...有效载荷 payload第 6 段0填充位数0-5第 7 段5C校验和真实抓包时接收机可能同时输出 GPS 语句以$GPGGA、$GPRMC开头和 AIS 语句以!AI开头。解析程序里统一按!AI前缀过滤即可。注意第 6 段填充位数经常被忽略但它和多帧拼接有关后面会详细说。2.2 6-bit 编码原理与字符映射payload 里每个字符都不是普通 ASCII 文本而是把二进制数据按 6 位一组映射成字符对应关系定义在 AIS 标准协议里。字符表和 ASCII 码不是连续一一对应而是分两个区间ASCII 码0x30到0x57字符0到W→ 值 ASCII 码 - 48对应取值 0 到 39ASCII 码0x60到0x77字符到w→ 值 ASCII 码 - 56对应取值 40 到 63中间的0x58到0x5F不参与映射非法字符应该在解码时直接报错。常见实现有写成常数表的也有写成偏移计算的。我习惯在代码里显式判断区间这样看到报错时能快速定位是哪一步出了问题def char_to_bits(ch: str) - int: code ord(ch) if 0x30 code 0x57: return code - 48 if 0x60 code 0x77: return code - 56 raise ValueError(finvalid payload char: {ch})这块逻辑是整个解码器的地基。我见过有人直接ord(ch) - 48遇到一个字面量时解出的数值偏大后续经纬度全部错位排查了很久才发现是映射规则的问题。字符映射正确了后面位流切分才有意义。2.3 自研解码与现成库的边界刚接触 AIS 时用现成开源库能快速跑通流程。但用一段时间会发现几个边界点多帧重组策略不透明、Type 8/Type 14 这类二进制载荷需要特定扩展、有的库对填充位处理不够严谨。如果项目只需展示几条船位用库完全没问题如果要持续采集数据并做质量审计还是建议把核心 payload 解码握在自己手里。这篇文章的路线是校验和外层逻辑自己写6-bit 位流转换自己写消息结构按字段表自行解析。每一层都可以单独做单元测试拿到一条真实报文时能一步一步核对而不是把整条语句丢给黑匣子。下面从驱动接入开始把整条链路串起来。3. 驱动接入串口、网络两种数据源与连接参数3.1 设备识别先确认系统认不认拿到 AIS 接收机第一步不是写解码器而是确认电脑能读到数据流。Linux 下先用dmesg看内核识别结果dmesg | grep -i tty | tail -20如果看到/dev/ttyUSB0说明 USB 转串口芯片已被识别。Windows 上则到设备管理器里看端口COM 号。识别不到时八成是 USB 转串口芯片的驱动没装好。某国产 USB 转串口芯片在 Windows 下需要厂商驱动插上后如果只显示未知设备去设备管理器更新驱动即可。Linux 下更常见的坑是没有串口权限ls -l /dev/ttyUSB0显示属主是dialout组而当前用户不在这个组里sudo usermod -aG dialout 当前用户名改完需要重新登录终端不是source ~/.bashrc能解决的。系统识别到设备后最直接的验证方式是读原始数据。如果设备已经被其他程序占用cat会报Device or resource busy如果接线正常但没有数据优先怀疑波特率配错。cat /dev/ttyUSB0能看到!AI开头或$GP开头的文本流说明物理链路通了可以进入下一步。3.2 串口参数与 Python 读取AIS 接收机的串口波特率通常是 38400数据位 8、无校验、1 停止位常写作 8N1。这和传统 GPS 模块的 4800 波特率不一样用错波特率时串口工具里只会出现乱码。用 Python 读取时timeout参数很关键它决定readline()在长时间没有数据时何时返回设得太长会让主线程卡住设得太短会出现大量空行。import serial ser serial.Serial( port/dev/ttyUSB0, baudrate38400, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout0.5 ) with ser: while True: line ser.readline() if line.startswith(b!AI): print(line.decode(ascii, errorsignore).strip())readline()按\r\n结束符读取一行也就是一条完整的 0183 语句。timeout0.5表示半秒内没读到结束符就返回当前缓冲区内容因此拿到的行可能是半截数据需要在外层判断startswith(b!AI)后再交给后续解析模块。3.3 网络接入TCP 与 UDP部分 AIS 接收机是网关盒子形态通过网口输出数据。常见两种对接方式TCP server 模式设备监听固定端口UDP 广播模式设备持续向局域网某个端口发数据。TCP 连接简单但要注意recv返回的包不保证按行对齐一个包可能包含两条半语句也可能一条语句被拆成两次到达。class LineBuffer: def __init__(self): self.buf b def feed(self, chunk: bytes): self.buf chunk lines self.buf.split(b\n) # 最后一段可能是半截行留到下一次 self.buf lines.pop() return [ ln.decode(ascii, errorsignore).strip() for ln in lines if ln.startswith(b!AI) ]使用LineBuffer时只需要在recv(4096)接到数据后调用feed就能逐条取到完整的 AIS 语句。TCP 连接的异常断开也要考虑实际项目中我会在except里做指数退避重连避免接收机重启后进程静默退出。3.4 连通性验证与帧率基线数据接入后先看帧率而不是急着解析内容。一个简单的方法timeout 10 cat /dev/ttyUSB0 | grep -c !AI这里统计的是 10 秒内 AIS 语句条数。繁忙航道或港口区域每秒几十条很正常偏僻水域每秒几条也正常但如果连续 10 秒只有个位数甚至零条物理链路多半有问题。天线从窗边挪到楼顶帧率从 1 条/秒跳到 40 条/秒的情况我遇过不止一次物理层的问题不用代码修。帧率确认稳定后再进入报文解析环节。4. 解码与解析从 AIVDM 到 JSON 的完整实现4.1 校验和先丢掉坏帧0183 语句的校验和是!与*之间所有字符的异或结果用两位十六进制表示。任何一帧解析之前都应该先过校验因为串口干扰或数据截断都会产生坏帧直接解析只会得到污染数据。def check_nmea_checksum(sentence: str) - bool: body, _, expected sentence.rstrip().partition(*) checksum 0 for ch in body[1:]: checksum ^ ord(ch) return f{checksum:02X} expected.upper()body[1:]跳过开头的!因为校验范围不含起始符号。比对时对计算值做02X格式化保证结果至少两位大写十六进制与语句签名匹配。4.2 从 payload 到二进制位流AIS 解析的本质是把 payload 拆成二进制流再按字段位偏移取值。先把每个字符换算成 6 位二进制并拼接def payload_to_bitstream(payload: str, fill_bits: int) - str: bits [] for ch in payload: code ord(ch) if 0x30 code 0x57: val code - 48 elif 0x60 code 0x77: val code - 56 else: raise ValueError(finvalid payload char: {ch}) bits.append(f{val:06b}) stream .join(bits) if fill_bits: stream stream[:-fill_bits] return streamfill_bits来自语句第 6 段。协议规定总位数必须是 6 的倍数不足时尾部补 0-5 个 bit解码前要裁掉。多帧消息拼接后填充分段只在尾帧出现拼接完再统一裁剪。有了位流还要按字段类型读整数。AIS 里存在无符号和有符号两种字段直接转int会把负值解析成很大的正数def bits_to_int(stream: str, start: int, length: int, signed: bool False) - int: raw int(stream[start:start length], 2) if signed and raw (1 (length - 1)): raw - (1 length) return rawsignedTrue时如果最高位为 1就按补码规则减去2^length得到正确的有符号值。4.3 Type 1/2/3 位置报文逐字段解析Type 1/2/3 是 A 类船舶位置报告也是出现频率最高的 AIS 消息。它的位布局比较固定解析时按偏移切字段字段起始位(bit)位宽(bit)说明消息类型061、2、3重复指示62常为 0MMSI830船舶唯一标识导航状态3840-15转向率 ROT428有符号-128 表示不可用对地速度 SOG50100.1 节位置精度6010低精度1高精度经度6128有符号单位 1/600000 度纬度8927有符号单位 1/600000 度航向 COG116120.1 度真航向12890-359UTC 秒13760-59经度纬度使用 1/600000 度作为最小单位本质上就是 1/10000 角分。解析实现如下def parse_position_report(bits: str) - dict: lon_raw bits_to_int(bits, 61, 28, signedTrue) lat_raw bits_to_int(bits, 89, 27, signedTrue) sog_raw bits_to_int(bits, 50, 10) cog_raw bits_to_int(bits, 116, 12) heading_raw bits_to_int(bits, 128, 9) lon_deg lon_raw / 600000.0 lat_deg lat_raw / 600000.0 if not (-180 lon_deg 180) or not (-90 lat_deg 90): lon_deg lat_deg None return { type: bits_to_int(bits, 0, 6), repeat: bits_to_int(bits, 6, 2), mmsi: bits_to_int(bits, 8, 30), nav_status: bits_to_int(bits, 38, 4), rot: bits_to_int(bits, 42, 8, signedTrue), sog: None if sog_raw 1023 else sog_raw / 10.0, lon: lon_deg, lat: lat_deg, cog: None if cog_raw in (3600, 4095) else cog_raw / 10.0, heading: None if heading_raw 511 else heading_raw, utc_sec: bits_to_int(bits, 137, 6), }SOG 的 1023、COG 的 3600/4095、heading 的 511 都是协议定义的不可用值先置为None再做单位换算。把经纬度判断放在换算之后比放在原始值判断更直观省得记 181 度对应的原始整数。4.4 Type 5 静态报文与统一入口A 类静态数据Type 5携带船舶名称、IMO 编号、呼号、目的地、吃水等信息。船名和目的地按 6-bit ASCII 编码解码后要去掉尾部连续的填充符和空格。Type 5 采用多帧传输必须合并完所有帧后再解析否则字段全部错位。统一入口函数负责校验和、payload 位流转换和按消息类型分发def decode_ais(line: str): if not check_nmea_checksum(line): return None fields line.rstrip().split(,) payload fields[5] fill_bits int(fields[6]) bits payload_to_bitstream(payload, fill_bits) msg_type bits_to_int(bits, 0, 6) if msg_type in (1, 2, 3): return parse_position_report(bits) if msg_type 5: return parse_static_report(bits) return {type: msg_type, raw: line}未支持的 Type 4、9、18、21 等先保留原始行和类型方便后续扩展。这样做的好处是新报文类型出现时不需要改动主流程只需要加一个分支。5. 避坑与排查AIS 解码最容易翻车的五个地方5.1 多帧拼接错误导致 Type 5 解析失败现象Type 5 报文解出来船名是一串乱码字段长度对不上某些字段干脆报错。原因AIS 静态报文按 424/422 位组织经常被拆成两帧。如果只取了第一帧就直接解析位流长度不足解析出来的 MMSI 或船名全是错的。解决需要在外层做一个多帧重组器按语句里的总帧数、当前帧序号和组标识收集帧齐了再拼接import time class SentenceAssembler: def __init__(self): self.parts {} def feed(self, line: str): fields line.rstrip().split(,) total int(fields[1]) idx int(fields[2]) seq fields[3] chan fields[4] if total 1: return fields[5], int(fields[6]) key (seq, chan) if key not in self.parts: self.parts[key] {total: total, frames: {}, ts: time.time()} self.parts[key][frames][idx] fields[5] if len(self.parts[key][frames]) total: item self.parts.pop(key) payload .join(item[frames][i] for i in range(1, total 1)) return payload, int(fields[6]) return None, None多帧场景下必须按帧序号拼接!AIVDM,2,1和!AIVDM,2,2的顺序不能颠倒。组标识为空时用通道字段做 key。实际部署时还要定期清理超过 5 秒未完成的分包防止内存里堆积垃圾帧。5.2 把“不可用”值当成了真值现象某条船速显示 1023 节航向显示 4095 度经度显示 181 度业务系统把这些数据当成真实告警触发。原因AIS 协议用边界值表示传感器不可用SOG1023、COG3600 或 4095、heading511、经纬度超出正常范围都表示无效不是真实测量值。解决解析阶段先判可用性不满足条件一律置None宁可丢数据也不要把脏值放进数据库。这个判断放在单位换算之前还是之后都可以关键是形成一个统一的字段可用性约定避免每个下游模块重复判断。5.3 有符号数按无符号解现象转向率明明是负的解出来却是 200 多西经的船被解到东经。原因ROT、经度、纬度都使用二进制补码表示负数直接用int(bits[start:startlen], 2)拿到的无符号整数值不对。解决统一使用bits_to_int(..., signedTrue)。有符号判断只看最高位raw 1 (length - 1)时减去2^length。这一条不只在 AIS 里适用任何按位拆字段的协议都会遇到写完位解析后第一件事就是拿负数样本做单测。5.4 校验和一直失败语法却看不出问题现象某些!AIVDM语句肉眼看着完整但校验和怎么都不通过而且失败率忽高忽低。原因串口readline在超时情况下可能返回不完整行语句缺少\r\n结尾或者缓冲区不够大半条语句混在一条数据里。校验和算的是残缺内容自然不对。解决维护按行分割的缓冲区和 TCP 场景一样处理串口数据。校验和失败直接被丢不要进入解析流程。如果失败率持续超过 5%从物理层检查波特率、接线长度和终端电阻而不是反复调解析器。5.5 时间戳在不同系统之间打架现象后端记录的时间比实际早 8 小时前端展示时又减了一次最后整条轨迹时间线完全对不上。原因AIS 的 UTC 秒字段只有秒没有日期日期要靠接收机本机时间。有的接收机把本地时间当成 UTC 输出有的接收机设置了对外开放的时区多设备接入时时间基准不一致。解决业务系统统一以 UTC 存储时间戳本地时区只在展示层转换。AIS 的 UTC 秒字段可以作为数据对齐的参考但不能把它当成完整时间源否则数据融合时字段错开。6. 进阶验证用离线报文全链路跑通解码器再画轨迹6.1 冒烟测试先行把第 4 章的解析函数存成ais_parser.py在main里加一段固定报文先跑通离线链路再上真机if __name__ __main__: test_line !AIVDM,1,1,,A,177KQJ5000G?tOKRA1wUbN0TKH,0*5C print(decode_ais(test_line))在终端执行时要注意AIS payload 里可能包含反引号外层必须用单引号包裹否则会被 shell 当成命令替换执行。预期输出一个包含type、mmsi、sog、lon、lat等键的 dict。值不一定要完全符合某条真实船舶但字段结构要完整类型要正确。6.2 按 MMSI 聚合轨迹真机数据通常夹杂着多条船的位置报文解析后先按 MMSI 分组再做轨迹拼接和数据质量过滤from collections import defaultdict vessels defaultdict(list) for raw_line in open(ais_sample.log, encodingascii, errorsignore): data decode_ais(raw_line) if not data or data.get(sog) is None: continue vessels[data[mmsi]].append(data)过滤条件是sog是否为None这一步把传感器不可用的位置点挡在轨迹外避免轨迹图上出现无规律的乱跳。6.3 在地图上标点验证处理完毕后用轻量级地图库快速可视化坐标中心先用零点再根据自己的数据范围调整import folium m folium.Map(location[0.0, 0.0], zoom_start4) for mmsi, points in vessels.items(): valid_points [(p[lat], p[lon]) for p in points if p[lat] is not None and p[lon] is not None] if len(valid_points) 1: folium.PolyLine(valid_points, popupstr(mmsi)).add_to(m) m.save(ais_map.html)如果多条船的轨迹能按 MMSI 正确分层显示说明驱动、解码、解析整条链路是通的。这个验证比盯着日志字段检查直观得多也更容易发现经纬度单位或符号位的问题。某次我在告警模块里直接用了 SOG 字段恰好一条船的 SOG 是 1023系统弹出一条 1023 节的离谱告警。从那以后解码器里每个字段都先过可用性判断再进业务每次接入新设备也先用一段离线回放跑一遍冒烟测试。希望帮到你。本文还有配套的精品资源点击获取