简介:面向海洋信息化方向开发者与学习者的AIS入门实践资料,围绕船舶自动识别系统的驱动、解码与解析三个环节展开,帮助读者理解从VHF射频信号接收、按ITU-R M.1371标准解调报文,到提取MMSI、船名、位置、航向、速度等静态与动态信息的完整链路,适合具备一定通信与编程基础、希望切入海上交通监控与防碰撞预警场景的中级学习者。压缩包共3个文件,以2个txt数据与说明文本、1个cpp解码实现源码为主,整体约5KB,体量轻便,便于直接阅读与二次调试。目前已有1037人学习下载。内容侧重解码函数中二进制解析、报文结构还原与坐标转换等关键步骤,并涉及驱动与硬件交互、上层解析展示的衔接思路,可作为搭建AIS数据处理流程、排查解码问题的参考素材。
1. AIS 教程、驱动、解码、解析:从一条报文到可用数据的完整链路
船位数据拿不到,十有八九不是硬件坏了,而是链路中间某一环断了。AIS 这条链路比多数人想的要长:射频前端收到 VHF 信号,驱动层把串口或网口字节流读上来,解码层把 NMEA 语句拆成字段,解析层再把字段映射成船位、航向、船名这些业务数据。任何一环参数对不上,最后拿到的就是一堆乱码或者空值。这份 AIS 教程资源把驱动、解码、解析三段拆开讲,配套可运行的代码和报文样本,适合正在做船舶监控、港口调度、航道数据采集的从业者,也适合刚接手 AIS 模块、被 NMEA 语句绕晕的新手。下面按“先跑通、再抠参数、最后避坑”的顺序拆。
2. 驱动层:串口与网口两种接入方式的参数怎么定
AIS 接收设备常见两种输出形态:一种是接收机通过串口(RS-232 或 USB 转串口)吐 NMEA 语句,另一种是船载设备或岸基基站通过网口以 TCP/UDP 推流。驱动层要解决的核心问题只有一个——把字节流稳定、不丢包地读进来。这一步看着简单,参数设错就是后面所有解析失败的总源头。
2.1 串口接入:波特率、数据位与流控的固定组合
AIS 的 NMEA 0183 输出默认是 4800 波特率、8 数据位、无校验、1 停止位,这是行业里几乎不写在文档里但必须遵守的约定。部分高速设备会跑到 38400,但 4800 是绝大多数接收机的出厂值。我一般先用 4800 试,读不到有效语句再往上试 38400,不要一上来就猜。
Python 下用 pyserial 打开串口,关键是把超时设短,避免主线程被阻塞:
import serial # 4800-8-N-1 是 NMEA 0183 的标准组合,先按出厂值试 ser = serial.Serial( port="/dev/ttyUSB0", # Windows 下换成 COM3 之类 baudrate=4800, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=1, # 读超时 1 秒,避免卡死 ) while True: line = ser.readline() # 按行读,NMEA 以 \r\n 结尾 if not line: continue # 超时返回空,继续下一轮 try: text = line.decode("ascii").strip() except UnicodeDecodeError: continue # 半包或干扰字节直接丢弃 if text.startswith(("!AIVDM", "!AIVDO")): print(text)这段代码的逻辑是:按行读取、按 ASCII 解码、只保留 AIVDM(他船)和 AIVDO(本船)两类语句。timeout=1是血泪经验——设成 None 会让readline永久阻塞,程序看着在跑其实早卡死了。decode("ascii")而不是 utf-8,是因为 NMEA 语句是纯 ASCII,用 utf-8 遇到干扰字节会抛异常,虽然能捕获但多一层开销。串口读到的半包问题很常见,readline本身会等到换行符,但如果设备中途复位,可能读到半截,所以解码失败直接continue丢弃,不要试图拼接。
流控(RTS/CTS)在 AIS 场景一般关掉,除非设备手册明确要求。开了流控但设备不支持,表现是读不到任何数据,很容易误判成设备坏。
2.2 网口接入:TCP 长连接与 UDP 组播的取舍
岸基基站和部分船载网关走网口。TCP 的好处是可靠、有序,坏处是断线要自己重连;UDP 的好处是低延迟、支持组播,坏处是丢包不管。做监控类应用我倾向 TCP,做多客户端分发用 UDP 组播。
TCP 客户端要处理的核心是重连和粘包。NMEA 语句以\r\n分隔,TCP 是字节流,一次recv可能拿到一条半或三条,必须自己按分隔符切:
import socket def tcp_reader(host, port, buffer_size=4096): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) sock.connect((host, port)) buf = b"" while True: try: chunk = sock.recv(buffer_size) except socket.timeout: continue # 超时不算断线,继续等 if not chunk: break # 对端关闭 buf += chunk while b"\r\n" in buf: line, buf = buf.split(b"\r\n", 1) # 按分隔符切,剩余留缓冲区 text = line.decode("ascii", errors="ignore").strip() if text.startswith(("!AIVDM", "!AIVDO")): yield textbuf这个缓冲区是粘包处理的关键,不能每次recv就当一条完整语句。errors="ignore"比 try/except 更省事,干扰字节直接跳过。settimeout(5)配合continue,让空闲连接不会因为长时间没数据被误判断开。真正断线时recv返回空字节,跳出循环,外层再套重连逻辑。
UDP 组播则要setsockopt加入组播组,SO_REUSEADDR允许多进程绑定同一端口,适合一台机器跑多个解析服务。组播地址和端口由基站配置决定,常见是 239 段地址,这个必须跟运维确认,猜不出来。
提示:串口和网口都建议先抓原始字节存文件,再离线解析。线上直接解析出问题,没有原始报文做对照,排查会非常被动。
3. 解码层:AIVDM 语句的字段拆解与六位 ASCII 还原
驱动层拿到的是!AIVDM,1,1,,A,15M...这样的字符串,解码层要把它拆成“这条报文属于哪个句子、总共几句、载荷是什么、填充几位”。这一步是纯字符串处理,但字段含义不搞清楚,后面解析必错。
3.1 AIVDM 语句结构:七个逗号分隔字段逐个说
一条典型的 AIVDM 语句长这样:
!AIVDM,1,1,,A,15Muq?P0000G?t6...0,0*5C按逗号切分后各字段含义如下:
| 序号 | 示例值 | 含义 | 常见坑 |
|---|---|---|---|
| 1 | !AIVDM | 语句类型,AIVDM 为他船,AIVDO 为本船 | 别把 AIVDO 当干扰丢掉 |
| 2 | 1 | 本组总句数 | 多句报文必须凑齐再解 |
| 3 | 1 | 当前句序号 | 乱序到达要按序号排 |
| 4 | 空 | 多句关联 ID,单句为空 | 多句时靠它配对 |
| 5 | A | 信道,A/B 或空 | 不影响解码,但日志要留 |
| 6 | 15Muq... | 载荷,六位 ASCII 编码 | 长度必须是偶数位有效 |
| 7 | 0 | 填充位数,0~5 | 非零要去掉末尾填充位 |
| 8 | *5C | 校验和 | 校验不过整条丢弃 |
校验和算法是把!和*之间的所有字符做异或,再转两位十六进制。校验不过的语句直接丢,不要“修一修再用”,AIS 报文没有纠错能力,改一个字符可能解出完全错误的船位。
3.2 六位 ASCII 还原:把字符变成二进制位流
载荷里的每个字符不是普通 ASCII,而是“六位 ASCII”——字符值减 48,若结果大于 40 再减 8,得到 0~63 的六位值。这一步是把文本还原成二进制位流的前置操作,写错了后面所有字段全错位。
def payload_to_bits(payload, fill_bits): bits = "" for ch in payload: val = ord(ch) - 48 if val > 40: val -= 8 # 六位 ASCII 的偏移修正 if not 0 <= val <= 63: raise ValueError(f"非法载荷字符: {ch}") bits += format(val, "06b") # 每个字符固定 6 位 if fill_bits: bits = bits[:-fill_bits] # 去掉末尾填充位 return bitsord(ch) - 48是基础偏移,val > 40时再减 8 是六位 ASCII 的特殊规则,这两步缺一不可。format(val, "06b")保证每个字符补足 6 位,不能省前导零,否则位流长度不对。fill_bits来自语句第 7 字段,非零时必须从末尾截掉,否则最后一个字段会多出几位垃圾数据。我见过有人把填充位当校验位处理,结果船位经纬度总是差一点点,查了半天才发现是这里。
多句报文(总句数大于 1)要先按关联 ID 缓存,凑齐后按句序号拼接载荷再统一还原。缓存要设过期时间,比如 30 秒,防止某句丢失导致缓存无限增长。这个过期清理不做,长时间运行内存会慢慢涨上去,属于典型的慢性翻车。
4. 解析层:消息类型判定与核心字段的位偏移映射
位流拿到后,解析层按消息类型走不同分支。AIS 有二十多种消息类型,实际业务里高频的就那么几种:类型 1/2/3 是船位报告,类型 5 是静态与航程数据(船名、呼号、目的地),类型 18/19 是 Class B 船位,类型 24 是静态数据分两部分。先把这几种吃透,其余按需扩展。
4.1 消息类型与 MMSI:前 38 位的固定布局
所有 AIS 消息的前 6 位是消息类型,紧接着 2 位是重复指示,再往后 30 位是 MMSI(海上移动通信业务标识)。这三段位置固定,先解出来判断走哪个分支:
def parse_header(bits): msg_type = int(bits[0:6], 2) repeat = int(bits[6:8], 2) mmsi = int(bits[8:38], 2) return msg_type, repeat, mmsi位偏移是左闭右开,bits[0:6]取前 6 位。MMSI 是 30 位无符号整数,直接int(..., 2)即可,不需要补零处理。这里最容易错的是把重复指示当成消息类型的一部分,导致类型判断偏移 2 位,解出来的类型值全是错的。判断消息类型后,类型 1/2/3 走船位分支,类型 5 走静态分支,其余先记录类型和 MMSI 存库,后续再补解析逻辑。
4.2 类型 1/2/3 船位报告:经纬度、航速、航向的缩放系数
船位报告的核心字段和缩放关系必须记牢,缩放系数错了数据看着“像那么回事”但完全不能用:
| 字段 | 位范围 | 缩放 | 说明 |
|---|---|---|---|
| 航行状态 | 38:42 | 无 | 0~15,15 为未定义 |
| 转向率 | 42:50 | 特殊 | 有符号,需按 ROT 公式换算 |
| 对地航速 | 50:60 | ÷10 | 单位节,1023 表示不可用 |
| 位置精度 | 60:61 | 无 | 0 低精度,1 高精度 |
| 经度 | 61:89 | ÷600000 | 有符号,181 度表示不可用 |
| 纬度 | 89:116 | ÷600000 | 有符号,91 度表示不可用 |
| 对地航向 | 116:128 | ÷10 | 单位度,3600 表示不可用 |
经纬度是 28 位和 27 位有符号整数,除以 600000 得到度。有符号处理不能忘,西经和南纬是负值,直接当无符号解会得到完全错误的位置。不可用值(经度 181、纬度 91、航速 1023、航向 3600)要在业务层过滤掉,不能直接入库,否则地图上会出现一堆漂移点。
def parse_position(bits): def signed(seg): v = int(seg, 2) n = len(seg) return v - (1 << n) if v & (1 << (n - 1)) else v sog = int(bits[50:60], 2) / 10.0 lon = signed(bits[61:89]) / 600000.0 lat = signed(bits[89:116]) / 600000.0 cog = int(bits[116:128], 2) / 10.0 if abs(lon) > 180 or abs(lat) > 90: return None # 不可用值直接丢弃 return {"sog": sog, "lon": lon, "lat": lat, "cog": cog}signed函数用最高位判断符号,这是二进制补码的标准做法,比自己写分支更不容易错。abs(lon) > 180这个判断同时覆盖了 181 不可用值和越界异常值,比精确等于 181 更稳。航速 1023 表示不可用,业务上一般当 0 处理或标记为未知,不要直接显示 102.3 节。
4.3 类型 5 静态数据:船名与目的地的字符还原
类型 5 是双句报文,载荷拼好后解出船名、呼号、目的地等文本字段。这些字段是六位 ASCII 编码的定长字符串,末尾用@填充,还原时要截掉填充符:
def decode_text(bits, start, length): chars = [] for i in range(start, start + length, 6): val = int(bits[i:i+6], 2) if val == 0: break # 0 表示字符串结束 chars.append(chr(val + 48 if val < 32 else val + 56)) return "".join(chars).replace("@", "").strip()字符还原是编码的逆过程:值小于 32 加 48,否则加 56,这是六位 ASCII 的映射规则。遇到 0 直接截断,因为 0 是字符串终止符。replace("@", "")处理末尾填充,strip()去掉可能的首尾空格。船名里带@的情况极少,这个替换在实战里够用。类型 5 的字段偏移比船位报告复杂,建议对着资源里的字段表逐位核对,不要凭记忆写偏移。
5. 避坑与排查:五条踩过的坑,每条都有现象和根因
5.1 现象:串口能打开但读不到任何语句
原因通常是波特率不对或流控误开。AIS 接收机出厂 4800,但有些设备被改过;流控开了但设备不支持,数据根本进不来。解决:先用 4800-8-N-1 无流控试,读不到换 38400,再不行用串口调试工具看原始字节,确认设备到底在不在吐数据。别在代码里反复改,先用工具定位是硬件还是参数问题。
5.2 现象:解析出的经纬度总是差零点几度
根因是填充位没去掉或位偏移错位。填充位非零时没截掉,末尾多出的位会让后续字段整体偏移;或者把重复指示算进了消息类型,导致所有偏移错 2 位。解决:打印位流总长度,对照消息类型的标准长度核对,差几位就是填充位或头部偏移的问题。这个坑很隐蔽,因为解出来的值“看起来像坐标”,只是不准。
5.3 现象:多句报文解析时船名是乱码或空
原因是多句没凑齐就解析,或者关联 ID 配对错误。类型 5 是两句,只拿到一句就解,载荷不完整,文本字段自然是垃圾。解决:按关联 ID 缓存,凑齐总句数再拼,缓存设 30 秒过期。另外注意句序号从 1 开始,拼接时按序号排序,不能按到达顺序。
5.4 现象:程序跑几小时后内存持续上涨
根因是多句缓存没有过期清理,或者 TCP 缓冲区无限增长。丢失的句子永远等不到配对,缓存条目只增不减。解决:缓存加时间戳,定期清理超过 30 秒的条目;TCP 的buf也要设上限,超过比如 64KB 就清空重来,防止异常数据撑爆内存。这类问题短时间测不出来,上线几天才暴露。
5.5 现象:校验和偶尔不过,丢弃后数据有缺口
原因是串口干扰或网口半包导致字符损坏。AIS 报文没有纠错能力,校验不过只能丢。解决:接受少量丢包,这是链路特性决定的;如果丢包率过高,检查串口线屏蔽、接地,或网口是否有大量重传。不要试图“修复”校验失败的报文,改一个字符可能解出完全错误的船位,比丢包危险得多。
6. 进阶验证:用已知报文做回归,把解析器当黑匣子测
解析器写完不能只看“能跑”,要拿已知答案的报文做回归。资源里带的样本报文就是干这个用的:每条报文对应一组预期字段值,解析结果逐字段比对,差一位都能定位到具体偏移。我一般会建一个小的测试表,把消息类型、MMSI、经纬度、航速作为断言项,每次改代码跑一遍。
import pytest CASES = [ # (原始语句, 期望类型, 期望 MMSI, 期望经度, 期望纬度) ("!AIVDM,1,1,,A,15Muq?P0000G?t6...0,0*5C", 1, 123456789, 121.5, 31.2), ] @pytest.mark.parametrize("raw,etype,emmsi,elon,elat", CASES) def test_parse(raw, etype, emmsi, elon, elat): bits = payload_to_bits(*extract_payload(raw)) mtype, _, mmsi = parse_header(bits) assert mtype == etype assert mmsi == emmsi pos = parse_position(bits) assert abs(pos["lon"] - elon) < 1e-4 assert abs(pos["lat"] - elat) < 1e-4浮点比较用容差而不是相等,因为缩放除法有精度损失。这个测试表的价值在于:以后加新消息类型、改偏移,跑一遍就知道有没有把老逻辑改坏。没有回归测试的解析器,改一处错一片是常态。
另一个习惯是保留原始报文日志。线上解析出异常数据时,把原始语句捞出来离线重放,能快速区分是解码错还是业务层过滤错。这个日志我一般按天切分、压缩存储,保留一周,排查够用又不占太多空间。从那以后我每次改解析逻辑,都强制先跑一遍样本回归再上线,宁可多花十分钟,也不想半夜被叫起来查漂移点。希望帮到你。
本文还有配套的精品资源,点击获取