简介:这份资源面向希望系统掌握AIS船舶自动识别系统的开发者与学习者,围绕驱动、解码、解析三个环节展开,帮助读者理解从射频信号接收、数字信号处理到报文信息提取与应用的完整链路。压缩包共3个文件,以txt文本与cpp源码为主,前者可用于存放原始数据与解码输出样本,后者承载解码与解析的核心实现,整体约5KB,体量轻便,便于快速阅读与二次开发。目前已有1037人学习下载,具备一定的参考基础。内容涉及ITU-R M.1371报文格式、二进制数据解析、坐标转换、错误检测与纠正,以及驱动与硬件交互、解析软件的数据过滤与报警思路,适合作为AIS入门与工程实践的对照材料,帮助读者建立从信号到可用船舶信息的整体认知。
1. AIS 解码:为什么一条 256 位的报文能讲清一艘船的全部行踪
海上交通里有一类报文,长度固定、字段紧凑、没有分隔符,接收端拿到的往往只是一串十六进制字符。很多人第一次接触 AIS 解析,会以为难点在“把字段切开”,真正上手才发现,切字段只是表层,底层是一套围绕比特位定义的自描述编码体系:同样的 168 位或 256 位载荷,前 6 位决定报文类型,后面每一段的偏移和长度都随类型变化。搞懂这套机制,你才能明白为什么解码器必须先判类型再取字段、为什么驱动层要做位对齐、为什么一条报文解析错一位后面全乱。AIS 解码教程、驱动、解析这三个词,本质上对应接收链路的三层:物理层收比特、链路层组帧、应用层还原语义。把这三层拆开看,一条报文如何变成船位、航向、船名,就不再是黑匣子。
2. 报文结构:6 位类型码如何决定后面所有字段的偏移
AIS 报文不是定长结构体,而是一棵以报文类型为根的分发树。接收端先读前 6 位,得到 0 到 63 之间的类型号,再查表决定后续字段的布局。这个设计的好处是扩展性强,新增报文类型不影响旧类型解析;代价是解码器必须维护一张类型到字段表的映射,不能用一个通用结构体硬套。
2.1 六位字符集与比特打包:为什么不能按字节切
AIS 载荷里的文本字段用的是 6 位 ASCII 子集,不是标准 8 位字符。一个字符占 6 位,意味着它不落在字节边界上。常见的实现思路是先把整段载荷展开成比特流,再按偏移取位,而不是直接对字节数组做切片。下面这段 Python 展示了最基础的取位逻辑:
def get_bits(payload_bits, start, length): # payload_bits 是 '0'/'1' 组成的字符串,start 为起始位,length 为位长 chunk = payload_bits[start:start + length] if len(chunk) < length: raise ValueError("payload too short") return int(chunk, 2) def get_sixbit_text(payload_bits, start, char_count): # 6 位字符集:值 0-63 映射到可打印字符,这里只做示意映射 table = "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ !\"#$%&'()*+,-./0123456789:;<=>?" out = [] for i in range(char_count): v = get_bits(payload_bits, start + i * 6, 6) out.append(table[v]) return "".join(out).strip("@ ")get_bits的start和length都以位为单位,不是字节。get_sixbit_text里每个字符固定跨 6 位,所以第 i 个字符的起始位是start + i * 6。这段代码的关键不在映射表,而在“先转比特串再取位”这个顺序。如果先按字节切再拼,跨字节的 6 位字符会被切断,解析出来的船名会出现乱码,这是血泪经验里最常见的一类翻车。
2.2 类型 1/2/3 位置报文的字段布局
位置报文是最常被解析的类型,载荷 168 位。前 6 位是类型号,紧接着 2 位是重复指示,然后 30 位 MMSI,4 位航行状态,8 位转向率,10 位对地航速,1 位位置精度,28 位经度,27 位纬度,12 位对地航向,9 位真航向,6 位时间戳。这些字段的偏移是固定的,但每个字段的物理量纲不同,解码时要做缩放。
| 字段 | 起始位 | 位长 | 缩放 | 说明 |
|---|---|---|---|---|
| 类型号 | 0 | 6 | 无 | 1/2/3 为位置报文 |
| MMSI | 8 | 30 | 无 | 船舶唯一标识 |
| 对地航速 | 50 | 10 | 除以 10 | 单位节,1023 表示不可用 |
| 经度 | 61 | 28 | 除以 600000 | 单位度,181 度表示不可用 |
| 纬度 | 89 | 27 | 除以 600000 | 单位度,91 度表示不可用 |
| 对地航向 | 116 | 12 | 除以 10 | 单位度,3600 表示不可用 |
这张表说明一个设计取舍:AIS 用定点整数传输物理量,接收端负责缩放。经度用 28 位、除以 600000,精度约 1.8 米,足够海上避碰使用,又比浮点传输省位。解码器如果忘了缩放,得到的经度会是几十万量级,看起来像坐标但完全不能用。
2.3 多分片报文:类型 5 和类型 24 为什么要拼
船名、呼号、目的地这类文本字段较长,单条报文放不下,于是拆成多分片。类型 5 静态与航程报文占 424 位,分 2 片;类型 24 静态数据报分 A、B 两部分。分片报文带分片计数和分片序号,接收端要按 MMSI 缓存,等所有分片到齐再拼。
常见的实现思路是用一个字典做重组缓冲,键是 MMSI 加分片类型,值是已收到的分片位串。拼的时候按分片序号排序,去掉每片的头部再连接。这里有个容易忽略的点:分片可能乱序到达,也可能丢片。解码器要设超时清理,否则缓冲会一直涨。拼装逻辑本身不复杂,复杂的是边界处理,比如同一艘船短时间内发了两组分片,键冲突会导致新旧数据混拼。
3. 驱动层:从射频比特到完整报文的组帧过程
驱动层做的事,是把接收机吐出的原始比特流变成一条条边界清晰的报文。这一层不关心字段含义,只关心帧头、帧尾、校验和转义。很多人解析失败,问题不在应用层字段表,而在驱动层组帧没做对。
3.1 HDLC 风格组帧与比特填充
AIS 链路层借鉴了 HDLC 的帧结构,用 01111110 作为帧标志。载荷里如果出现连续 5 个 1,发送端会插入一个 0,接收端要删掉,这叫比特填充。驱动层如果只按帧标志切分而不做去填充,载荷的位偏移会整体错位,后面所有字段全错。
def destuff(bits): # 去掉发送端插入的 0:连续 5 个 1 后的 0 是填充位 out = [] ones = 0 for b in bits: if b == '1': ones += 1 out.append(b) else: if ones == 5: ones = 0 # 这个 0 是填充位,丢弃 continue ones = 0 out.append(b) return "".join(out)destuff遍历比特串,维护连续 1 的计数。当遇到 0 且前面正好 5 个 1 时,这个 0 被丢弃,计数归零。注意丢弃后不把 0 写进输出,这是去填充和普通遍历的区别。如果驱动层漏了这一步,载荷会多出若干 0,类型号可能从 1 变成 2 或别的值,表现为“报文类型莫名其妙不对”。
3.2 CRC 校验:为什么错一位就整帧丢弃
组帧完成后要校验。AIS 用 16 位 CRC,覆盖除帧标志外的载荷。校验不通过时,正确做法是整帧丢弃,而不是尝试纠错。原因是 AIS 报文没有重传机制,错帧的字段偏移已经不可信,强行解析只会产出错误船位。
驱动层通常把 CRC 计算和比对放在组帧之后、上交之前。实现上可以用查表法加速,也可以逐位算。关键参数是初始值和多项式,不同实现如果初始值不一致,会出现“同一帧一边能过一边过不了”的玄学现象。排查这类问题时,先拿一帧已知正确的报文做基准,对比 CRC 中间值,比盲改代码有效。
3.3 通道与时间戳:驱动层为什么要打标
一条报文从射频到应用层,中间有缓存和排队。驱动层在收帧时打上通道号和时间戳,应用层才能判断这条位置报文的时效。时间戳如果等到应用层再打,排队延迟会让“当前船位”变成“历史船位”。这个设计动机很实际:海上避碰对时效敏感,几百毫秒的偏差可能影响判断。
常见的实现是在中断或收包回调里取单调时钟,和报文一起入队。应用层解析时用时间戳做过期判断,超过一定窗口的位置报文不再参与显示。这一步不做,界面上会出现船位跳变,看起来像解析错误,实际是时序问题。
4. 应用层解析:字段语义还原与状态机
应用层拿到的是已经校验通过的比特串,要做的是按类型分发、按字段表取值、做量纲转换、维护船舶状态。这一层最容易出的问题不是取位,而是状态管理。
4.1 类型分发与字段表驱动
解析器的骨架通常是一张类型表,每个类型对应一个字段描述列表,每个描述包含名称、起始位、位长、类型、缩放因子。解析时遍历描述列表,统一取值。这种表驱动写法比手写 if-else 好维护,新增类型只加表项。
POSITION_FIELDS = [ ("msg_type", 0, 6, "uint", 1), ("mmsi", 8, 30, "uint", 1), ("sog", 50, 10, "uint", 0.1), ("lon", 61, 28, "int", 1 / 600000), ("lat", 89, 27, "int", 1 / 600000), ("cog", 116, 12, "uint", 0.1), ] def parse_position(bits): result = {} for name, start, length, kind, scale in POSITION_FIELDS: raw = get_bits(bits, start, length) if kind == "int" and raw >= (1 << (length - 1)): raw -= (1 << length) # 有符号字段补码还原 result[name] = raw * scale return resultkind为int时要处理补码,经度和纬度是有符号的,直接按无符号取会得到巨大正值。scale在表里直接写成缩放因子,避免解析后再散落各处做除法。这段代码的取舍是把“取位”和“语义”分开,get_bits只管位,parse_position只管语义,两层职责清晰,排查时容易定位是位偏移错还是缩放错。
4.2 不可用值与边界判断
AIS 协议为很多字段定义了“不可用”编码,比如航速 1023、经度 181 度、纬度 91 度、航向 3600。解析器如果直接把不可用值当有效值缩放,会得到离谱的坐标或速度。正确做法是在字段表里标记不可用阈值,解析后置空或标记无效。
这个细节在教程里常被一笔带过,但实际影响很大。一条船刚开机、位置未定位时,发的就是不可用值。界面如果显示成 181 度经度,用户会以为解析器坏了。排查时先看原始值是否等于不可用编码,再看缩放,顺序反了会白费很多时间。
4.3 船舶状态缓存与更新策略
解析出的单条报文只是快照,实际应用要维护每艘船的最新状态。常见做法是以 MMSI 为键建状态表,位置报文更新动态字段,静态报文更新船名呼号,分片报文拼完再更新。更新时要注意字段来源优先级,比如类型 5 和类型 24 都带船名,后到的覆盖先到的,但要防止旧分片覆盖新数据。
状态表还要处理过期。船驶出接收范围后不再有报文,状态表如果不清,界面会一直显示旧船位。常见的实现是给每条船记最后更新时间,定时扫描清理超过窗口的条目。这个策略没有统一标准,取决于接收范围和业务需求,但必须有,否则内存和显示都会出问题。
5. 避坑与排查:解码链路上最容易翻车的几个点
5.1 现象:船名解析出乱码,位置却正常
原因通常是 6 位字符集映射表写错,或者文本字段的起始位算错。位置字段是数值,缩放后看起来合理,掩盖了位偏移问题。解决方法是单独拿一条类型 5 报文,把载荷展开成比特串,手工数前 6 位类型、后面各字段偏移,和代码里的表逐项对齐。字符集映射表要确认 0 到 63 的完整顺序,不能只抄一部分。
5.2 现象:同一帧报文有时能解析有时不能
先查 CRC 初始值和多项式是否和发送端一致,再查比特填充是否在 CRC 计算之前去掉。顺序错了会导致 CRC 覆盖的位串和发送端不一致。另一个常见原因是驱动层把帧标志也纳入了载荷,多算了 8 位。排查时打印 CRC 计算前的比特串长度,和协议规定的载荷长度对比,长度不对就是组帧边界错了。
5.3 现象:分片报文拼出来的船名缺字或多字
分片重组时每片的头部长度没去掉,或者分片序号排序错。类型 5 的每个分片都带自己的头部,拼的时候要去掉每片的头部再连接载荷部分。如果直接把整片位串首尾相接,中间会多出头部位,表现为船名中间插入乱字符。解决方法是重组前先按分片序号排序,逐片去掉头部,再连接,最后按总长度截断。
5.4 现象:位置显示在陆地上或漂移
先确认经纬度缩放因子是不是 600000,再确认有符号处理是否正确。经度 28 位、纬度 27 位,纬度位长少一位,补码判断的阈值不同,用同一个阈值会出错。另一个原因是把不可用值当有效值,181 度和 91 度要单独判断。排查时打印原始整数和缩放后的值,对照已知船舶的真实位置,偏差量级能直接指向缩放还是符号问题。
5.5 现象:状态表里船越来越多,内存持续上涨
没有过期清理,或者清理窗口设得太大。接收范围外的船不再发报文,但状态表条目还在。解决方法是给每条船记最后更新时间,定时扫描删除超过窗口的条目。窗口大小要结合接收半径和报文频率定,太小会导致船位闪烁,太大则内存涨。这个参数没有标准答案,需要按实际场景调。
6. 设计取舍:为什么 AIS 解析器要分层而不是一把梭
把整条链路摊开看,AIS 解析的每个设计选择背后都有明确的约束。6 位字符集和定点整数是为了在窄带信道上省位,分片机制是为了让长文本能传输,CRC 整帧丢弃是因为没有重传通道,驱动层打时间戳是因为海上避碰对时效敏感。这些约束决定了实现上必须分层:驱动层管比特和帧边界,应用层管语义和状态,中间用校验和类型分发隔开。
分层带来的直接好处是可排查。解析出错时,先看驱动层组帧后的比特串长度和 CRC,再看应用层字段偏移和缩放,两层各自有明确的输入输出,不用在一大坨代码里猜。代价是层间接口要设计好,比如比特串的表示方式、时间戳的传递、分片缓冲的归属,这些接口定不好,分层反而增加复杂度。
我一般会这么理解:AIS 解析器不是一个“把十六进制转成 JSON”的函数,而是一条有状态的处理流水线。流水线上每一站只做一件事,每件事都有可验证的中间结果。验证方法也很直接,拿已知正确的报文做基准,逐层对比中间输出,哪一层对不上就停在哪一层查。从那以后我每次接新的 AIS 数据源,都强制先跑一遍基准报文,确认驱动层组帧和 CRC 没问题,再往上查字段,这个习惯省掉了大量来回猜的时间。希望帮到你。
本文还有配套的精品资源,点击获取