ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AIS数据协议解析实战:从NMEA语句到6-bit解码

2026/9/29 14:37:44 拓冰建站 浏览量
AIS数据协议解析实战:从NMEA语句到6-bit解码 简介AIS数据协议解析完整版是一份面向船舶通信研发、AIS设备调试与海事信息系统集成人员的doc技术文档用于解决AIS VHF报文难读、动态/静态信息字段定位繁琐的问题。文档系统讲解AIS报文的数据格式、VDM/VDO语句类型、A/B信道指示、填充位、8位CRC校验、多语句拆分重组及 结束标志并给出C语言实现流程覆盖字节流转六位、报文头提取、目标信息解析可直接借鉴落地。除协议条款外文档还梳理了1/2/3/4/5/18/19/21/24等报文类型的识别与解析要点以5号静态信息报文为例展示电文合并后依次解析MMSI、船名、船型、船长船宽、吃水、目的地等字段的位段划分与代码实现兼顾原理与实战。压缩包共1个doc文件大小678KB目前已有3749人学习下载适合作为AIS设备开发、船舶监控平台搭建和协议二次开发时的参考手册。1. AIS数据协议解析从一串NMEA语句里还原一条船的实时动态AISAutomatic Identification System船舶自动识别系统是海事领域最基础也最常用的数据协议之一岸基基站、船载终端、海事监管平台和航运物流系统都在跑这东西。但真正拿到AIS数据你会发现它不像HTTP那样有清晰的JSON结构而是一串以“!AIVDM”开头、逗号分隔、看起来像乱码的文本行。AIS数据协议解析的核心工作就是把这串文本按6-bit编码规则拆开还原出船名、MMSI、经纬度、航速、航向、船长船宽这些结构化字段。这篇文章我会按我实际做过的方案来讲先讲清楚协议分层和报文格式再给一套可直接复现的Python解析实现最后把调试中踩过的坑和验证方法一并说透。适合正在做AIS接入、船舶监控系统开发、海事数据清洗的工程师新手可以照步骤走熟手可以直接拿走避坑清单。2. 先把AIS报文链路理顺从VHF数据链路到一行NMEA语句2.1 AIS在物理层跑的是VHF数据链路不是IP协议AIS工作在VHF频段161.975MHz和162.025MHz两个信道采用自组织的TDMA时分多址方式让船只共享信道。每条船在自己的时隙里广播消息不需要中心调度。这种设计对解析层意味着什么意味着你拿到手的不管是串口数据、UDP数据还是日志文件本质上都是一段持续流里面每条消息彼此独立没有请求-响应关系也没有ACK机制。解析时不能假设“发了A就会回B”只能按语句一条条解。从数据链路往上协议栈大概是这样的层次物理层是GMSK调制的VHF信号数据链路层是HDLC-like帧封装网络层以上才是我们常说的高层消息——ITU-R M.1371定义的27种消息类型加上后来补充的28、29实际不超过29种。但这里要说明一下我们实际开发时99%的情况不会直接从HDLC帧开始处理。市面上绝大多数AIS接收机比如Comar、SRT、或者普通的AIS接收模块已经完成了物理层和链路层解调把数据以NMEA 0183语句的形式从串口或网口吐出来。所以“AIS数据协议解析”这个标题下的核心工作实际是在解析NMEA封装层和高层消息层而不是去解射频信号。如果你做的是类似“AIS基带信号解调”的项目那就要去看GMSK解调和TDMA时隙同步那是另一个量级的工程。本文聚焦的是拿到接收机输出之后的协议解析这也是绝大多数从业者的真实场景。2.2 NMEA语句格式VDO和VDM的六个字段必须记清AIS数据接入最常见的形式是NMEA 0183语句典型的一行长这样!AIVDM,1,1,,A,13u?etPv2;0n:dDPwUM1KV1L,0*5C拆开来看这个语句以“!”开头后面是“AIVDM”或“AIVDO”然后是逗号分隔的字段。这里先记住一个关键点!AIVDM表示来自其他船的消息!AIVDO表示本船自己的消息。在接收机日志里两者都会出现解析时一般不需要区分但如果做的是本船航迹记录AIVDO就要单独处理。六个字段的含义如下表字段序号示例值含义11语句条数一条完整消息可能被拆成多条语句发送21当前语句序号3空顺序消息ID多语句消息用用于关联4A信道编码A或B对应VHF信道513u?etPv2;0n:dDPwUM1KV1L数据载荷payload6-bit ASCII编码60填充位数数据载荷最后不足6位时补的0个数字段3在很多单语句消息里是空的这个不用管。字段5是真正的解码对象字段6非常关键——它决定了载荷二进制流的长度是否准确如果接收机填错了你解析出来的字段会整体错位。校验和是“”后面两位十六进制计算范围是从“!”之后到“”之前的所有字符的异或和。这一行的校验和是0x5C可以用下面的代码验证def nmea_checksum(sentence: str) - str: body sentence.split(*)[0].lstrip(!) cs 0 for ch in body: cs ^ ord(ch) return f{cs:02X}这个校验和的计算逻辑是逐字节异或不是累加和。很多人在这一步按CRC的思维去做算出来对不上其实AIS用的是最简单的异或代码里两行就完事。校验和的作用是在解码之前先过滤掉损坏语句确保后面6-bit解码不基于坏数据展开。2.3 高层消息类型位置报告和静态数据是解析主要目标ITU-R M.1371定义了多种消息类型但实际在AIS数据流里出现频率最高、业务价值最大的是这么几类消息1、2、3位置报告Class A船包含MMSI、经纬度、SOG、COG、真航向、航行状态、ROT等一条1/2/3类消息就能知道一条船现在在哪、往哪开、多快。消息5船舶静态和航次数据Class A船包含船名、船舶类型、船长船宽、吃水、目的地、ETA需要两条NMEA语句拼接才能完整解析。消息18Class B位置报告对应小型船只的B类设备字段比消息1/2/3少一些。消息24Class B静态数据替代了B类船没有消息5的问题分为24A和24B两段。消息4基站报告和消息21航标报告在区域监控和AIS基站网络场景里会大量出现。这里有一个我们在项目中用到的消息类型过滤策略位置消息1/2/3/18直接进实时轨迹链路静态消息5/24进船舶档案表更新模块其他消息4/11/21/27按需走旁路存储。这个分流能省掉很多不必要的解码时间在一路串口数据3000条/分钟的场景下解码性能的差距会被明显放大。2.4 搞清楚AIS报文长度和填充位的关系所有AIS消息的二进制长度是有固定区间的。Class A位置报告消息1/2/3不带填充位时是168 bit用168除以6得到28个ASCII字符。这也是为什么很多位置报告语句的payload刚好是28个字符。消息5静态数据是424 bit去掉填充位后是70到71个字符因为424除以6等于70余4需要补2位填充。如果你在解析时发现消息5的payload字符数不是70或71就要警惕是不是语句拼接顺序错乱或者是接收机输出截断。填充位字段字段6的取值范围是0到5因为它只负责补齐最后不足6位的部分。解析时先把payload每个字符转成6-bit二进制然后把所有bit串起来再根据填充位字段去掉末尾的无效位得到真正有效的数据bit流。这个过程是整个AIS解析的地基地基歪了后面所有字段都会错位。下一章我会给出完整的Python实现。3. 核心解码算法用Python把6-bit payload拆成经纬度和航速3.1 6-bit ASCII编码规则从字符到二进制的映射表AIS的payload不是直接以ASCII码传输数据而是用了一套自定义的6-bit字符表。这套表的字符顺序是0123456789:;?ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_加上空格。为什么是从“0”开始而不是从“A”开始因为AIS协议中第一个有效编码字符对应数值0第二个对应1以此类推字符“0”的ASCII码是48减去48就是0字符“A”的ASCII码是65减去48等于17正好对应表里的第18个位置。转换为二进制时规则是取该字符在表中的索引值转成6-bit二进制。比如前面的payload首字符“1”索引是1二进制是000001“u”索引是45ASCII 117减48再偏移实际按顺序算二进制是101101。实现时不用维护映射表直接用两个剪刀差转换def char_to_bits(c: str) - str: # AIS 6-bit 字符转二进制输入必须是payload字符 ascii_val ord(c) if 48 ascii_val 119: val ascii_val - 48 elif ascii_val 32: val 0 else: val ascii_val - 48 - 8 # 之后的特殊处理 return format(val, 06b)这段代码的逻辑说明AIS 6-bit字符表里“0”到“W”的索引就是ASCII减48其中“W”以后到空格之间的字符需要额外减8。实际项目里我建议直接用映射表查起来更快两个方式性能差不多但映射表更不容易出错AIS_CHARS 0123456789:;?ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_ CHAR_MAP {c: i for i, c in enumerate(AIS_CHARS)} def char_to_bits_fast(c: str) - str: return format(CHAR_MAP[c], 06b)参数说明AIS_CHARS这个字符串的顺序就是协议的字符表顺序不能调换最后一个字符是空格编码值0x20。CHAR_MAP建好后整个payload转换就是一次字符查表。3.2 按bit位精确提取字段位偏移表和字段解析函数当payload转换成一长串二进制bit流之后解码的关键就是按消息类型去切分bit。以消息1/2/3Class A位置报告为例它的位偏移表是这样的字段起始位长度单位/说明消息类型06固定值1/2/3重复指示器62一般填0MMSI830十进制航行状态3840在航1锚泊5受限等ROT428转向率单位0.1度/分钟SOG5010航速单位0.1节位置精度6011高精度10m0低精度经度6128单位1/600000度有符号纬度8927单位1/600000度有符号COG11612航向单位0.1度真航向1289单位0.1度511不可用时间戳1376UTC秒填充位1432补0注意这里说的“起始位”是0-based即从bit流的第0位开始算。经度占28位而纬度占27位是因为经度范围是±180度乘以600000后需要的bit长度和纬度不同。这个不对称是小学算术就能推导的但实现时很容易把两个长度搞反建议把这段位偏移表写死成常量数组。def extract_bits(bits: str, start: int, length: int) - int: 从bit流中截取指定字段并转成int chunk bits[start : start length] return int(chunk, 2) if chunk else 0 def decode_msg1(bits: str): msg_type extract_bits(bits, 0, 6) mmsi extract_bits(bits, 8, 30) sog extract_bits(bits, 50, 10) / 10.0 lon_raw extract_bits(bits, 61, 28) lat_raw extract_bits(bits, 89, 27) lon lon_raw / 600000.0 if lon_raw else None lat lat_raw / 600000.0 if lat_raw else None cog extract_bits(bits, 116, 12) / 10.0 true_heading extract_bits(bits, 128, 9) return { type: msg_type, mmsi: mmsi, sog_kn: sog, lon: lon, lat: lat, cog_deg: cog if cog 360 else None, heading_deg: true_heading if true_heading ! 511 else None, }逻辑说明MMSI是30位的十进制数直接转int就行不用做符号处理。SOG是10位无符号数单位0.1节转float后除以10。经纬度因为有负数存在理论上需要处理二进制补码但实际中很少看到西经或南纬的极端值简单按无符号数处理然后判断是否超范围再取负号也可以。COG的360度上限决定了一个特殊值当COG原始值等于3600时表示不可用所以用cog 360做过滤。真航向的511即原始值511表示不可用在消息里通常是“无航向信息”如果不做过滤会把511当511.1度解析出来出现一个几乎不存在的航向。3.3 消息5静态数据解析多语句拼接后再解码消息5是AIS静态数据报文里信息量最大的一条但它会被拆成两条NMEA语句发送各携带168bit和256bit加起来正好是424bit。如果只解析其中一条语句拿到的就是半截数据船名、目的地这些字段会错位。实现上要先按“语句条数”和“当前语句序号”做拼接def assemble_static_report(parts: list[str]) - str: parts: 同一消息ID下的多个payload字符串按语句序号排列 返回拼接后的完整bit流 total_bits for payload in parts: total_bits .join(char_to_bits_fast(c) for c in payload) return total_bits拼接时要注意两个边界条件第一条语句和第二条语句的填充位不同第一条通常填充位是0第二条因为总长度424mod6的原因填充位是2分别去掉各自的填充位再拼还是先拼后去尾先拼后去尾更简单因为两条语句的payload拼接后只有最后一条语句末尾才有填充位中间不存在填充位问题。但必须确认第一条语句的payload长度是28个字符第二条是42到43个字符如果接收机给的是别的长度多半是数据链路层出了问题。消息5的字段布局消息类型(6bit) MMSI(30bit) IMO(30bit) 呼号(42bit7个6-bit字符) 船名(120bit20个6-bit字符) 船舶类型(8bit) 船长船宽(30bit) 吃水(12bit) 目的地(120bit20个6-bit字符) ETA(20bit)。船名和目的地这类字符串字段是直接用6-bit字符表反向映射的也就是把每个6-bit值查表转回字符。def bits_to_text(bits: str) - str: text_chars [] for i in range(0, len(bits), 6): v int(bits[i : i 6], 2) text_chars.append(AIS_CHARS[v]) return .join(text_chars).strip( )这个函数最坑的地方是字符串末尾会带空格填充和“”填充符。AIS协议规定文本字段不足长度时用空格补位多余用符号填充。所以解析出的船名可能是“COSCO SHANGHAI”必须用strip( )把末尾的和空格清掉。但注意不能strip掉中间的空格否则“MAERSK LINE”会变成“MAERSKLINE”对船名匹配会出问题。4. 把解析器接到真实数据流串口、TCP日志和消息分发的完整工程4.1 数据源接入方式serial读取与socket接收的两套模板AIS接收机常见输出是串口RS232或USB转串口和网络TCP/UDP。串口场景下用pyserial核心参数是波特率38400、8位数据位、1位停止位、无校验。这是AIS接收机的默认配置少数设备支持切换接不上时先检查波特率再怀疑线序。import serial ser serial.Serial( port/dev/ttyUSB0, baudrate38400, bytesizeserial.EIGHTBITS, parityserial.PARITY_NONE, stopbitsserial.STOPBITS_ONE, timeout1, ) while True: line ser.readline().decode(ascii, errorsignore).strip() if line.startswith(!): process_sentence(line)这里有个性能陷阱串口readline在数据量大时会阻塞AIS数据密集时可能出现缓冲区溢出。建议把串口读取放到独立线程解析主线程用队列接收避免IO阻塞影响解码吞吐。timeout1保证即使没有数据readline最多阻塞1秒不会把线程卡死。TCP场景更简单AIS TCP服务器通常直接输出文本流用socket加readline循环即可但要做好断线重连。很多岸基AIS设备用的是TCP 4001端口或者其他自定义端口接入时要先确认设备的手册不要想当然用23。4.2 按消息类型分发从一句话到轨迹入库的管线设计一条完整的AIS解析管线至少要有三层接入层负责读取和校验解码层负责把payload转成结构化对象业务层负责按消息类型分发到轨迹缓存、船舶档案、告警模块。分发逻辑有一个常见的坑消息1/2/3都是位置报告但消息类型字段在第0到第5位必须先解出消息类型再去分发不能直接用NMEA语句的前缀VDM/ VDO判断。def process_sentence(sentence: str): if not sentence.startswith(!): return parts sentence[1:].split(*)[0].split(,) if len(parts) 7: return if nmea_checksum(sentence) ! sentence.split(*)[1][:2]: return payload parts[5] fill_bits int(parts[6]) bits .join(char_to_bits_fast(c) for c in payload) msg_type int(bits[0:6], 2) if msg_type in (1, 2, 3, 18): record decode_position_report(bits, fill_bits) route_tracker.update(record) elif msg_type 5: pending_static[parts[2]] pending_static.get(parts[2], []).append(payload) if len(pending_static[parts[2]]) 2: record decode_static_report(assemble_static_report(pending_static.pop(parts[2]))) ship_profile.update(record)逻辑说明parts[2]是NMEA语句的消息ID字段用于关联同一条消息5的两个分片。如果接收机连续发送多条消息5用这个ID做key就能避免不同船的静态数据拼错。fill_bits在这个分发层不直接参与解码因为位置报告类消息的fill_bits通常是0而消息5的fill_bits在拼接后统一处理更简单。分发的粒度建议按消息类型分表存储而不是把所有消息灌进一个大表。轨迹表通常只保留24小时内的位置报告船舶档案表则长期保留静态信息。这种设计在MySQL里加一个msg_type索引就能高效查询在海量数据场景下比一个“所有字段可空”的大宽表好维护得多。4.3 多语句消息的拼装状态管理过期清理不能少消息5需要两条语句拼装如果一个片段在传输中丢失另一片段就会一直挂在内存里浪费空间而且可能与下一条消息5的片段混淆。解决办法是给每条待拼装记录加时间戳超过5秒没凑齐全部片段就丢弃。这个5秒不是拍脑袋定的AIS的时隙分配保证一条消息的多个片段在同一个无线电时段内到达正常情况下间隔不超过几百毫秒。pending_static: dict[str, list[str]] {} def buffer_static(payload: str, msg_id: str): now time.time() item pending_static.get(msg_id) if item is None: pending_static[msg_id] {parts: [payload], ts: now} else: item[parts].append(payload) item[ts] now def cleanup_static(timeout5): for msg_id in list(pending_static): if time.time() - pending_static[msg_id][ts] timeout: del pending_static[msg_id]这里的cleanup_static要放在串口读取循环里每轮调用一次或者用定时器每30秒跑一次否则内存里堆积的失效消息会越来越多运行一天后可能有几千条垃圾数据占着内存。5. AIS解析避坑五条会让你深夜翻车的数据坑5.1 校验和明明能对上但解码出来的纬度差了几公里现象从串口读到的AIS语句校验和全部通过解码出的经纬度在赤道附近偏差很大个别点跑到撒哈拉沙漠里去了。原因AIS的payload经过6-bit编码后如果接收机输出的填充位字段第六个逗号后的数字填错了你按错误填充位裁剪bit流尾部的bit错位会影响后面所有字段。更隐蔽的是另一种情况语句里带有回车换行符如果不做strip最后一位的ASCII码参与异或时会把0x0D也算进去导致校验和计算表里错误——但恰好某个接收机在发送时也把回车换行算进了校验两边都错校验和反而能对上。解决解析前统一执行strip()去掉\r\n然后强制校验位必须是两位十六进制且校验通过才继续解析。如果发现填充位不在0到5之间这条语句直接丢弃不要试图“修正”后再解。5.2 消息5拼装一直凑不齐两个片段但日志里明明有很多消息5现象日志文件里能看到大量!AIVDM,2,1,...和!AIVDM,2,2,...开头的语句但拼装模块永远只收到第一条第二条迟迟不出现。原因AIS消息5的第二种片段字段2等于2在某些接收机固件里可能因为缓冲区太小被丢弃尤其是当接收机同时处理消息1/2/3这种高频位置报告时低优先级的长消息片段容易被挤掉。另一个常见原因是你的解析程序用了“按语句序号硬等”的逻辑如果一个片段到达顺序颠倒先2后1你的代码没有处理。解决拼装模块不要依赖到达顺序用消息ID做key两个片段都到了就触发拼接只到一条就进入等待队列。同时把等待超时设短一点3秒即可防止旧数据长期占用内存。对接收机缓冲区问题可以考虑在配置层面把AIS接收机的输出消息过滤掉消息8气象、消息27长距离等非关键类型给消息5腾出带宽。5.3 船名解析出来是“COSCO”现象船名字段解码后船名前面全是符号后面的名称顺序也乱了。原因AIS消息5的船名是固定120bit即20个字符协议规定船名靠左填写右边用空格补位。但某些船载设备尤其是改装的Class B终端会把船名靠右填写左边用填充导致你按标准解析得到的字符串前半段是后半段才是船名。解决解析文本字段时先做strip( )再用strip()清理空格且不要只做一边。正确顺序是先按字符级过滤和空格再拼接成字符串这样可以避免把“MAERSK EVERT”这种中间夹的字段拆错。更好的做法是保留内部空格只去掉首尾无效字符然后用re.sub(r[\s]$, , name)只处理尾部填充。5.4 SOG出现99.9节COG出现360度现象解析出的船速经常出现99.9航向显示360但这是在长江航道里不大可能的物理速度。原因AIS协议里SOG的可用范围是0到102.2节超过102.2时用102.3表示“不可用”COG正好是0到359.9度用360表示“不可用”。如果解码代码把原始bit直接除以10输出1023/10102.3和3600/10360就会被当正常值放进数据库。业务统计时平均值全被这些值拉高了。解决在解码函数中设置业务字段的可信范围SOG超过102.2或等于102.3直接置NoneCOG大于等于360置None真航向等于511置None。注意是将字段置None还是置0置0会让数据看起来像是“静止在水面上”影响后续追迹判断建议统一用None表示未知在数据库里存NULL。5.5 时间戳字段解析出来全是垃圾数现象消息1/2/3的时间戳字段在解码后是随机数和真实UTC时间对不上。原因AIS消息里的时间戳字段位置报告里的第137到142位是UTC秒取值为0到59。但很多船载设备根本没有GPS授时模块或者GPS信号丢失这个字段要么是0要么是60表示时间不可用。如果你直接用这个时间戳作为轨迹点的时间会出现整点时间混乱、时间倒序等情况。解决业务系统里以接收机收到语句的系统时间作为轨迹主时间戳AIS时间戳只作为辅助参考。存储时把AIS时间戳和接收时间分开两列方便后续排查信号源问题。判断时间戳是否可用的标准是值在0到59之间且接收时间与整点的分钟数相差不超过1秒才认为有效否则标记时间源不可用。6. 进阶验证技巧用回放数据校准解析器然后做消息类型扩展解析器写完不等于能上线先拿历史日志做回放验证。方法很简单从AIS接收机日志里截取一段原始数据保存为文本文件用支持回放的工具或脚本按原始时间间隔把每一行重新喂给解析函数然后把解码结果和已知的船位信息做交叉比对。你没有真实轨迹对照时可以抽几个逻辑校验点MMSI是否落在合法的9位数字区间经纬度是否在±180和±90以内SOG是否在0到102.2范围内COG是否小于360。这些很容易写成一个批量校验函数跑完一轮后统计异常率。注意回放时要保留原始语句的顺序和间隔不要一次性灌入内存后并行处理——AIS的位置报告消息1/2/3之间是有时间关联的你并行解出来的结果和真实时序不一致后续做轨迹拟合时会错乱。按行读取、按行解析、按行入库是最慢但最可靠的方式。消息类型扩展方面除了消息5之外消息24Class B静态数据也是必须补上的。它的结构和消息5有部分相似但字段长度和位置不同尤其24A和24B要区分处理——24A带船名24B带船舶类型和尺寸。如果你要接的是渔政、内河船舶监控项目Class B船的比例很高消息24比消息5更常见。具体位偏移参考ITU-R M.1371-5的Table 76和Table 77不要用网上流传的旧版字段表旧版对24B的长度定义和新版有差异。性能优化的最后一招Python里直接用int切片再转二进制的做法在每秒钟几百条语句时没问题但到了每秒上千条一个繁忙港口可能同时有几百条船每条船每2到10秒发一次位置报告应该把6-bit解码改成查表加位运算的批量实现。具体做法是把payload字符串位置和bit偏移量拆成预计算的切片索引表一次循环里用ord查表替代字符串切片性能能提升3到5倍。如果你的系统目标是单机支撑10000条船同时在线建议把那层解码逻辑用Cython或Go重写Python只做业务分发。这套解析器的最后一个习惯性动作是写监控指标每接收1000条语句记录一次有效解码率、校验失败率、多语句拼装成功率。这三个指标能直接告诉你接收机信号质量是否在恶化、协议层是否有兼容性问题比看日志定位问题快得多。我从第一次做AIS接入项目开始就养成了这个习惯后来换了几种接收机、换了几套传输链路都靠这套指标快速排掉了接入层的疑难杂症。希望帮到你。本文还有配套的精品资源点击获取