简介:这是一份面向船舶通信、岸基监控及海事信息化开发者的自动识别系统数据协议解析技术资料,系统讲解报文封装格式、语句类型、信道指示、数据部分长度限制、八位循环冗余校验字段与语句结束标志,并给出动态信息与静态信息的分类及对应报文类型,可解决从原始AIS语句中拆包、解码和校验的实际难题。资源包内仅一个DOC文档,大小678KB,篇幅紧凑且层次分明,便于作为协议字典随时查阅。文档后半部分提供了基于C语言的解析思路与关键函数示例,涵盖八位字节流转换为六位字节流、报文头字段提取以及按消息标识符解析本船他船动态静态信息等流程,并以5号包分两帧发送后的合并解析为具体案例,呈现从物理层语句到上层应用数据的完整映射过程,工程实用性强。该资料已有3749人学习,适合正在开发船舶自动识别系统、船舶交通管理系统或港口管理相关功能的研发人员参考。
1. AIS数据协议解析:一条AIVDM语句如何变成可用的船舶动态
做船舶监控、VTS系统或者航运数据分析的同行,应该都有过这种体验:AIS接收机插上电,串口里刷刷往外吐数据,一眼看过去全是!AIVDM开头的字符串,但想从里面拿一条船名或经纬度,却发现自己和这些字符之间隔着一层说不清的黑匣子。这套AIS数据协议解析,就是把ITU-R M.1371定义的消息结构、6bit编码和NMEA 0183外壳一层层剥开,最终还原成结构化字段。这份《AIS数据协议解析完整版.doc》覆盖了AIS常见消息类型、字段位宽、编码规则与解析边界,适合做船舶定位、海事监管、航运数据分析和AIS设备集成的工程人员。照着下面的步骤走下来,你能独立完成从NMEA语句到结构化船舶动态的完整解析链路。
2. 从NMEA外壳到6bit编码:拆开AIS报文的两个必须层
AIS报文和很多工业协议一样,外部套了一层NMEA 0183的壳,内部才是真正的数据载荷。先分清这两层,后面解析才不会绕路。
2.1 NMEA语句的七段结构:!AIVDM后面的值分别是什么
一条完整的AIS NMEA语句长这样:
!AIVDM,1,1,,A,15M67FC000G?ufbE`FepT@3n00Sb,0*4A按逗号拆开,每一段的含义在解析器里几乎都能一一对应到代码变量:
| 位置 | 内容 | 说明 |
|---|---|---|
| 前缀 | !AIVDM | 消息来源标识,AIVDM表示本机接收到的AIS消息,AIVDO表示本机发送 |
| 字段1 | 1 | 本条消息的总帧数,分包消息会大于1 |
| 字段2 | 1 | 当前帧序号,从1开始 |
| 字段3 | 空 | 多消息标识(0-9),用于关联同一逻辑消息,单帧常为空 |
| 字段4 | A | 接收通道,A或B |
| 字段5 | 15M67... | 有效载荷payload,由6bit字符组成 |
| 字段6 | 0 | 填充位数(0-5),告诉解码器末尾要舍弃几个bit |
| 校验 | *4A | 星号后两个十六进制字符,校验和 |
字段5的payload是整个协议的核心,字段6的填充位容易被忽略。payload被转成位串后,末尾会多出0-5个无意义的填充bit,必须在按字段切分前丢掉,否则后面的字段整体错位一位,这也是新手最容易翻车的点之一。
2.2 6bit编码表:为什么你看到的字符是大写字母加标点
payload里每个字符并不直接对应ASCII,而是对应一个0到63的数字,每个数字再转成6bit。AIS用的字符集是固定的:
| 值 | 0 | 1 | 2 | ... | 25 | 26 | 27 | ... | 31 | 32 | ... | 57 | ... | 63 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 字符 | 0 | 1 | 2 | ... | Z | : | ; | ... | ? | @ | ... | y的位置由值决定 | ... | w |
更严谨地说,索引表是0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ,第0个字符是0,第12个字符是<,第32个字符是@。解码时把每个字符在这个表里的下标取出来,转成6位二进制,按顺序拼成一条长位串。
举个例子,字符1在表中索引为1,二进制是000001;字符5索引是5,二进制是000101。拼接后的位串就是后续按位提取字段的原料。这个过程不复杂,但序列化方向是固定的:从payload第一个字符的高位开始,逐字符向低位拼接,不能倒过来。
2.3 消息类型速览:先知道有哪些类型,再决定解码分支
消息ID占据位串最前面的6bit,取值范围1到27。实际数据流里最常见的是下面几类:
| 类型 | 名称 | 关键内容 |
|---|---|---|
| 1/2/3 | A类位置报告 | MMSI、经纬度、SOG、COG、HDG、航行状态 |
| 4 | 基站报告 | 基站位置、UTC时间 |
| 5 | A类静态数据 | 船名、呼号、IMO、船舶类型、尺寸 |
| 18 | B类位置报告 | B类设备的位置和速度 |
| 19 | B类扩展位置报告 | 位置加船型、尺寸 |
| 24 | B类静态数据 | 分Part A和Part B,含船名、呼号、尺寸 |
| 27 | 远程位置报告 | 长距离、低功耗场景,只有粗略位置 |
一般来说,解析器的主分支只需关心1/2/3、5、18、24这六类,其余消息类型可以走一个通用兜底逻辑,只提取MMSI和消息ID,避免程序被未知类型打断。
3. 字段级解码:经纬度、航速、船名与呼号的位级换算
进入正题之前,先把一个概念说清楚:AIS报文的位串是从第0位开始编号的,每个字段都有一组确定的起止位。解码本质上就是按偏移量切片,再按各自的比例尺换算成物理量。
3.1 类型1/2/3位置报告:28bit经度与27bit纬度是最后的大头
类型1/2/3的位偏移,我按标准字段顺序整理了一张简化表,实际解析时直接对着取数就行:
| 字段 | 起始位 | 位宽 | 单位与换算 |
|---|---|---|---|
| 消息ID | 0 | 6 | 1/2/3 |
| 重复指示 | 6 | 2 | 0-3 |
| MMSI | 8 | 30 | 十进制整数 |
| 导航状态 | 38 | 4 | 0-15 |
| ROT旋回率 | 42 | 8 | 带符号,见下文说明 |
| SOG对地航速 | 50 | 10 | 值 × 0.1,单位节 |
| 位置精度 | 60 | 1 | 0/1 |
| 经度 | 61 | 28 | 值 / 600000,单位度 |
| 纬度 | 89 | 27 | 值 / 600000,单位度 |
| COG对地航向 | 116 | 12 | 值 × 0.1,单位度 |
| HDG船首向 | 128 | 9 | 值,单位度 |
| UTC秒 | 137 | 6 | 0-59 |
经纬度的换算最容易让人疑惑。AIS编码里经纬度单位是1/10000分钟,而1分钟等于1/60度,所以字段值要除以600000才能得到十进制度数。例如经度字段是114000000,实际经度就是190度?不对,这个数偏大,实际海上经度字段通常是1亿左右的量级,除以600000后正好落在合理的度数范围内。纬度同理。
SOG字段是10bit的无符号数,最大值1023。其中1022表示速度大于等于102.2节,1023表示不可用。COG字段3600表示不可用。HDG字段511表示不可用。这些特殊值在做数据入库前必须处理掉,不然你会在一张动态表里看到一条船以1023节的速度在陆地上漂移。
ROT字段按标准需要做一个平方根变换,常见公式是ROT = 4.733 × sqrt(ROT_field),方向由符号位决定。实际业务里多数场景只关心船是否在旋回,不关心具体旋回率,所以把这个字段原值输出即可。如果将来要做旋回告警,再按标准做完整换算。
3.2 类型5静态数据:船名解码的20字符规则
类型5的位串长度达到424bit,字段布局和类型1完全不同。核心字段如下:
| 字段 | 起始位 | 位宽 | 说明 |
|---|---|---|---|
| IMO编号 | 38 | 30 | 十进制 |
| 呼号 | 68 | 42 | 7个6bit字符 |
| 船名 | 110 | 120 | 20个6bit字符 |
| 船舶类型 | 230 | 8 | 查码表 |
| 到船首距离 | 238 | 9 | 单位米 |
| 到船尾距离 | 247 | 9 | 单位米 |
| 到左舷距离 | 256 | 6 | 单位米 |
| 到右舷距离 | 262 | 6 | 单位米 |
船名和呼号不是ASCII,而是用6bit字符编码。文本解码的规则是:取出每个6bit值,0到31映射到字符表@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_,32到63映射到空格!"#$%&'()*+,-./0123456789:;<=>?。实际实现时,值小于32就用chr(value + 64)转换,大于等于32直接chr(value)转换。船名字段不足20个字符时用@填充,所以解码后要把尾部的@去掉,否则入库的船名会带一串尾巴。
3.3 类型18与类型24:B类设备的报文边界
B类船舶设备是AIS数据流里数量最多的信号源,它们的报文和A类有一处关键差异:类型24分成Part A和Part B两条消息。
Part A里,位偏移38到39是部件号,值为0,后面跟着船名、船舶类型和尺寸。Part B的部件号为1,后面是呼号和设备类型。解析时必须先读这个2bit的Part字段,再决定后面的字段如何解释。很多初学者把类型24当成一条完整消息去解,结果Part B的数据被强行按Part A的字段宽度切,船名和呼号全乱。
类型18是B类位置报告,字段布局接近类型1,但没有导航状态和ROT,且MMSI后面直接跟经纬度。写解析器时,类型18的偏移表必须单独建,不能和类型1共用一套切片配置。
4. 校验和与数据质量排查:三个最容易翻车的解析点
我把这部分单独拿出来,是因为AIS解析的坑几乎不在协议本身,而在数据质量的边界处理上。下面三条是我在实际项目中真实踩过的,每一条都足够让一天的排查白费。
4.1 校验和算不对:程序没错,是异或范围错了
现象:解析器连续把几十条NMEA语句判为校验和不匹配,换了好几种校验算法都一样。
原因:AIS的校验和是!到*之间所有字符的ASCII码按位异或,注意是!之后到*之前,不包括!本身,也不包括*。有人从!开始异或,有人把逗号漏掉,还有人把*后面那两个十六进制字符也当成参与校验的字节。这三种都是常见误用。
解决:按这个顺序写,基本不会错——先取*左边的部分,再从索引1开始遍历到末尾,逐个字符与累计值异或,最后取低8位转成两位大写十六进制和星号后的字段比对。这一步如果放在解析入口统一做,后面所有解码分支的数据源就是干净的。
4.2 分包消息重组错乱:序列号被忽略,船名拼成乱码
现象:类型5的船名偶尔正常偶尔乱码,或者MMSI和船名对不上号。
原因:类型5和类型24里一部分消息是分帧的,一个payload可能被切成两条NMEA语句输出。如果解析器每收到一帧就立刻解码,那么第二条分片会拿一半位串去解,所有字段全部错位。另一个常见原因是分包后用线程逐条处理,不同消息的分片交替到达,线程各自缓存,序列号没有用来归类。
解决:看到字段1大于1时,把这条帧先送到一个按序列号字段分组的缓存里,收齐后按帧序号顺序拼接payload,再进入解码流程。缓存要做超时清理,比如超过10秒没凑齐就丢弃,防止内存被异常网络慢慢吃满。
4.3 无效值当作真实数据:SOG=1023和纬度91度的玄学
现象:数据库里出现航速1023节、经度190度、船首向511度的记录,做统计时这些点把平均航速拉高好几倍。
原因:这些值对应的是协议里的特殊标记。SOG=1022表示大于102.2节,1023表示传感器不可用;COG=3600表示不可用;HDG=511表示无船首向信息;经纬度字段超出正常值域时表示位置无效。如果不加判断,这些数字会直接进业务逻辑。
解决:在解码完成之后统一过一遍值域过滤器。SOG大于等于102.2置空,COG大于等于3600置空,HDG等于511置空,纬度绝对值大于90或经度绝对值大于180直接置空。这一步做完,后续关联、插值、告警才不会拿脏数据当输入。
5. 用Python把协议跑起来:解析器骨架与接入参数调优
文档对应的内容最终要落到能跑的代码上。这里给出一版我平时用的解析器骨架,只保留核心解码分支,端口和串口接入部分单独说明。
5.1 从语句到字典:核心解析器代码
import re _CHARSET = "0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ" class AisParser: """AIS NMEA 语句解析器:单帧消息解码,分包重组由上层完成。""" @staticmethod def checksum_ok(sentence: str) -> bool: body, check = sentence.rsplit("*", 1) cksum = 0 # 从 '!' 之后开始异或,到 '*' 之前结束 for ch in body[1:]: cksum ^= ord(ch) return f"{cksum:02X}" == check.strip().upper() @staticmethod def bits_from_payload(payload: str) -> str: bits = "" for c in payload: v = _CHARSET.index(c) bits += f"{v:06b}" # 每个字符补足 6 位,顺序拼接 return bits @staticmethod def field(bits: str, start: int, length: int) -> int: return int(bits[start:start + length], 2) @staticmethod def text6(bits: str, start: int, length: int) -> str: # 按 6bit 切分,还原成可读文本,去掉尾部 @ 填充 chars = [] for pos in range(0, length, 6): v = int(bits[start + pos:start + pos + 6], 2) chars.append(chr(v + 64) if v < 32 else chr(v)) return "".join(chars).rstrip("@").strip() def parse(self, sentence: str) -> dict: sentence = sentence.strip() if not self.checksum_ok(sentence): raise ValueError("checksum mismatch") parts = sentence.split(",") total = int(parts[1]) seq = parts[3] payload = parts[5] fill = int(parts[6].split("*")[0]) if total > 1: # 分帧消息先返回缓存标识,由上层收齐后合并 payload 再解析 return {"fragment": True, "total": total, "seq": seq} bits = self.bits_from_payload(payload) if fill: bits = bits[:-fill] # 丢弃末尾填充位 msg_type = self.field(bits, 0, 6) if msg_type in (1, 2, 3): return self._parse_position(bits, msg_type) if msg_type == 5: return self._parse_static(bits) if msg_type == 24: return self._parse_type24(bits) return {"type": msg_type, "mmsi": self.field(bits, 8, 30)} def _parse_position(self, bits, msg_type): d = { "type": msg_type, "repeat": self.field(bits, 6, 2), "mmsi": self.field(bits, 8, 30), "nav_status": self.field(bits, 38, 4), "sog": self.field(bits, 50, 10) / 10.0, "lon": self.field(bits, 61, 28) / 600000.0, "lat": self.field(bits, 89, 27) / 600000.0, "cog": self.field(bits, 116, 12) / 10.0, "hdg": self.field(bits, 128, 9), "utc_sec": self.field(bits, 137, 6), } # 协议特殊值统一置空 if d["sog"] >= 102.2: d["sog"] = None if d["cog"] >= 3600: d["cog"] = None if d["hdg"] == 511: d["hdg"] = None if abs(d["lat"]) > 90 or abs(d["lon"]) > 180: d["lat"] = d["lon"] = None return d def _parse_static(self, bits): return { "type": 5, "mmsi": self.field(bits, 8, 30), "imo": self.field(bits, 38, 30), "callsign": self.text6(bits, 68, 42), "shipname": self.text6(bits, 110, 120), "shiptype": self.field(bits, 230, 8), } def _parse_type24(self, bits): part = self.field(bits, 38, 2) if part == 0: return { "type": 24, "part": "A", "mmsi": self.field(bits, 8, 30), "shipname": self.text6(bits, 40, 120), "shiptype": self.field(bits, 160, 8), } return { "type": 24, "part": "B", "mmsi": self.field(bits, 8, 30), "callsign": self.text6(bits, 40, 42), }这段代码里,checksum_ok放在最前面,保证非法语句不进业务逻辑。bits_from_payload把payload整串变成二进制位串,fill参数在后面裁掉填充位。field函数是取数主入口,三个参数分别是位串、起始位、位宽。text6专解船名呼号这类文本字段,对@填充做了清除。
使用时候注意几点:一是field的起始位和位宽要严格对照协议偏移表,类型5的船名从110位起、宽120位,这类硬编码参数最好抽成常量,方便后续对照标准维护。二是分帧消息在parse里只返回了fragment标记,实际项目中需要在外面维护一个字典,以seq为键缓存分片,收齐后把多个payload合并再一次调用bits_from_payload。三是_parse_position里对特殊值的过滤放在最后统一处理,这样上层拿到的字典里就不可能出现超出物理意义的数值。
5.2 时间戳与去重:从真实数据流里找增量
位置报告里的UTC秒字段只精确到秒,没有日期部分。真实项目必须结合接收端本地时间和船位判断来推算日期,否则同一秒内多条船舶消息的时间会重叠。常见做法是用本地时间兜底,当消息跨零点时优先保留上一次收到的日期。
重复消息去重方面,AIS同一个MMSI的消息会以不同速率到达,静态消息可能几分钟才发一次,位置报告则可能每秒一条。建议用(mmsi, type, sog, lon, lat, cog)做一次指纹去重,时间窗口设3秒。这个组合对正常航行中的船几乎不会误伤,但能有效滤掉同一秒内的冗余广播。
5.3 串口与UDP接入时的参数调优
AIS接收机最常见的输出是串口,波特率一般用38400,8数据位、无校验、1停止位。如果接到多船共用设备,可能要用4800波特率的老协议,这个要先看设备说明书。
网络接入时,AIS共享服务和部分接收设备支持UDP输出,常见端口是10110。UDP接收要设置一个稍大的缓冲区,因为AIS广播峰值时一秒可能到几十条。我一般会把接收socket的SO_RCVBUF调到512KB以上,再配一个阻塞队列做缓冲,解析线程从队列取数据。如果直接同步解析,UDP突发流量一来,回调里处理不过来的消息直接丢,数据完整性很难保证。
6. 用回放验证解析结果:三条检查习惯与进阶收口
代码写完之后,真正花时间的往往是验证。我给自己的工作要求是,每接入一个新的AIS数据源,必须强制走一遍下面三条检查。
第一条,跑一次校验和统计。拿原始数据流跑一个小时,统计校验和不通过的语句数量。正常数据源这个比例应该在千分之一以下。如果超过百分之一,先别急着分析业务数据,回到数据源更新固件或调整串口配置。这一条能过滤掉八成脏数据问题。
第二条,挑三条不同类型的消息做人工比对。每条真实NMEA语句解析出的MMSI、船名、经纬度,去AIS在线查询平台核对一下,确认船名一致、位置落在合理海域。我见过解析器偏移表配错,所有船的船名都是乱码,但动态数据看着正常,这种问题只能靠静态消息比对暴露。
第三条,看单船轨迹连续性。把同一MMSI的消息按时间排序,检查两分钟内轨迹点之间是否出现超过一海里的跳变。如果出现大量跳点,说明要么经纬度换算比例尺配错,要么该船的AIS设备定位质量本来就差。前者改代码,后者在业务侧标记数据可信度。
验证通过之后,剩下的就是围绕解析结果的工程优化了。AIS动态数据可以做轨迹插值和停留点识别,静态数据可以按船舶类型、船长分组做港口船舶画像。这些都需要先有一套稳定、可复现的解析器作基础。我最早做AIS解析时,就是跳过了校验和直接解payload,结果被脏数据坑了一天。从那以后我每次拿到新数据源,都强制先跑一遍校验和统计和无效值扫描,再谈后续。希望帮到你。
本文还有配套的精品资源,点击获取