资讯详情

AIS驱动、NMEA 0183解码与报文解析全流程实战

📅 2026/10/6 13:16:10 | 华诺云谱 👁 阅读
AIS驱动、NMEA 0183解码与报文解析全流程实战
简介AIS船舶自动识别系统涉及无线通信、信号解码与上层解析等多个环节这份资料包面向海事信息化、航运监控、港口管理及物联网方向的开发者帮助理解从射频信号到船舶信息可视化的完整链路。资源共3个文件以txt文档和cpp示例代码为主整体仅5KB轻量却覆盖协议要点、驱动开发思路、解码逻辑与解析方法txt文件可用于阅读AIS报文结构、ITU-R M.1371编码规则及驱动接口说明cpp文件则展示了实际解码、坐标转换与数据解析的实现片段。目前已有1033人学习下载适合正在接触AIS协议或需要参考解码算法的初学者与嵌入式开发者。通过阅读这份资料可快速建立AIS数据格式、报文类型、动态与静态信息提取的基本认知并借鉴代码示例完成自己的驱动适配、解码函数或解析模块为后续海上交通监控与避碰预警开发打下基础。1. AIS教程驱动、解码、解析到底在解决什么AIS船舶自动识别系统是海事领域最基本的信息源。岸边基站、航道监控、港口调度甚至水上无人机巡航都要靠AIS拿到船舶的MMSI、位置、航速和船名。这套教程要拆的三个动作是把AIS接收机的驱动装好让串口数据不再是一个黑匣子把收上来的二进制帧解码成标准的NMEA 0183语句再把语句解析成结构化位置报告落到数据库或地图上。适合刚接触AIS的嵌入式工程师、做水上视觉监控的集成商以及想自己搭一套船位追踪系统的无线电爱好者。读完你至少能在一个小时内把一台USB接口的AIS接收机接到服务器上跑出实时的船名、经纬度和航速。2. 先把AIS接收链路搭起来驱动安装与串口形态确认2.1 接收链路的三段式射频→基带→串口AIS工作在VHF频段161.975MHz和162.025MHz两个信道承载全部船舶报告。天线进来的射频信号在接收机内部完成解调和基带处理输出的是NMEA 0183格式的文本帧再经串口透传到主机。大多数USB接口的AIS接收机内部就是一颗USB转串口芯片常见的是CP210x、FT232、CH340这三家。驱动没装对后面全白搭。这里想强调一个容易忽略的前提驱动解决的是USB芯片的识别问题不是AIS数据能不能解出来的问题。lsusb能看到芯片不等于串口数据是对的。我见过太多人卡在“驱动装好了但minicom一片空白”最后发现是天线没接。所以这一章把驱动和串口验证分开做先让设备在系统里出现再确认数据真的在流。2.2 Linux下把AIS接收机变成本地ttyUSB设备Linux对这三颗芯片的内核驱动支持都很好一般插上就能识别出/dev/ttyUSB0。用下面这组命令确认芯片和驱动加载情况lsusb | grep -i -E cp210|ftdi|ch340 dmesg | tail -20 # 如果看到 cp210x 或 ftdi_sio 字样说明内核驱动已加载 # 如果没有先确认是不是 USB 线只供电不传数据 sudo apt install -y socat minicomdmesg | tail -20里如果出现usb 1-1: cp210x converter now attached to ttyUSB0说明设备节点已经建好。常见做法是先用socat把串口数据落盘避免终端干扰后续解析# 单向模式读串口把原始数据实时写进日志后面离线解析用 socat -u /dev/ttyUSB0,b38400,rawer,crtscts0 OPEN:/tmp/ais_raw.log,creat1,append1 socat -u表示单向传输rawer让数据原样透传不做行缓冲crtscts0关闭硬件流控——有些AIS接收机开了流控会一直不出数据这是第一个玄学点。b38400是AIS最常见的波特率但后面你会发现不少设备出厂是9600到时两个都试。落盘的好处是后续可以反复回放不用天天蹲在接收机旁边。2.3 Windows下驱动对不上的排查路径Windows下面最常见的翻车场景是设备管理器里能看到设备但第三方软件永远读不到串口。原因多半是Windows自带的usbser.sys和芯片厂商驱动抢设备句柄。CP210x的驱动装了两遍设备就变成“未知设备”或者带黄色感叹号。我的处理习惯是先用USBDeview或者设备管理器把旧的USB串口驱动彻底卸载类似DDU清显卡驱动那样清理干净再重装芯片厂商驱动。注意只看VID/PID来确认芯片不要看设备名——AIS接收机厂商经常把设备名改成自己的型号但VID还是10C4Silicon Labs或0403FTDI。另外装完驱动后把厂商自带的串口配置工具关掉再开自己的解析程序这类工具会独占串口句柄你的程序打开串口时会报“设备被占用”。2.4 用minicom和Python确认串口数据真的在跑驱动层验证完接下来确认串口层。先手动打开minicom -D /dev/ttyUSB0 -b 38400能看到!AIVDM,1,1,,A,177KQJ5000G?tOKRA1wUbN0TKH,0*5C 这样以感叹号开头的一行行文本链路就算通了。minicom里如果全是乱码先退出来换波特率再进。确认有数据后用Python把这层固化下来import serial ser serial.Serial(/dev/ttyUSB0, baudrate38400, timeout1) while True: line ser.readline() if line.startswith(b!AIVDM) or line.startswith(b!AIVDO): print(line.decode(errorsreplace).rstrip())baudrate38400是AIS标准波特率个别老设备用9600数据不对就两个都试。timeout1防止readline在串口静默时永久阻塞。errorsreplace处理波特率不匹配时读到的半个字符先把链路跑通后面再严格校验。到这一步驱动和串口形态都确认了下一章开始处理真正的解码问题。3. AIS解码从串口字节到NMEA 0183的VDM语句3.1 VHF数据链路如何把二进制封装成NMEA 0183AIS消息在VHF数据链路上是二进制比特流但为了通过串口传输被封装成NMEA 0183文本语句。最常见的两类是!AIVDM他船消息和!AIVDO本船消息。看一条实际语句!AIVDM,1,1,,A,177KQJ5000G?tOKRA1wUbN0TKH,0*5C逐字段拆解第一个1是总语句数第二个1是当前语句序号空字段是消息组ID单语句消息留空A是信道177KQJ5000G?tOKRA1wUbN0TKH是由六位二进制ASCII码组成的消息体0是填充位数5C 是校验和。要注意第五字段虽然长得像Base64但它和Base64只是思路相似——都是把二进制数据映射成可见ASCII字符映射表完全不同别拿Base64解码器硬套。3.2 单条VDM语句的校验与字段拆解NMEA 0183的校验规则是对!之后、*之前的全部字符逐字节异或得到的8位十六进制值就是*后面的两个字符。下面这段代码把校验和拆字段一次做完def parse_nmea_line(raw: str): raw raw.strip() star raw.rfind(*) if star 0: return None body, chk raw[1:star], raw[star1:star3] calc 0 for ch in body: calc ^ ord(ch) if (calc 0xFF) ! int(chk, 16): return None # 校验失败直接丢弃 parts body.split(,) if parts[0] not in (AIVDM, AIVDO): return None return partsraw[1:star]是故意把开头的!去掉再参与异或因为NMEA规范要求校验范围从!之后开始。int(chk, 16)把校验字符串转成整数和计算值低8位比对。这里最常见的坑是行尾污染用文件日志和串口工具时\r\n混进行导致校验算不对所以进门先strip()。3.3 多语句消息拼接与超时清理类型5的静态消息船名、目的地长达289比特会拆成两到三条VDM语句传输。比如!AIVDM,2,1,5,B,55P5TL01VIaAL7WKOmBplUPD,0*4C !AIVDM,2,2,5,B,10000000000000,2*1D第一行字段2的2表示这是两条中的第一条字段3的2是第二条字段4的5用来把两条关联到同一个消息组。我用一个字典做缓存pending {} def collect_payload(parts): total int(parts[1]) seq int(parts[2]) msg_id parts[3] # 消息组ID channel parts[4] # A/B信道 key (msg_id, channel) if key not in pending: pending[key] [] * total pending[key][seq - 1] parts[5] if all(pending[key]): payload .join(pending[key]) del pending[key] # 拼完立即释放别让它积在内存里 return payload, int(parts[6]) return None, None注意这个缓存绝不能只增不减。有的设备信号不好时第二条一直不来pending会越积越大。我一般给每个key记一个时间戳超过3秒没凑齐就删掉。拼装时seq从1开始数组下标要减1这个细节错了会导致多语句消息永远拼不齐。解码层到这里就完整了下面进入真正让很多人头疼的六位二进制ASCII解析。4. AIS报文解析六位二进制ASCII与消息类型解码4.1 六位二进制ASCII码的映射关系AIS消息体不是普通ASCII码而是把二进制比特流按6位一组映射成ASCII可见字符。映射规则是ASCII值在48到87之间的直接减48得到0到39ASCII值在88到119之间的减56得到32到63。写成Python就是def payload_to_bits(payload: str) - str: bits for ch in payload: v ord(ch) if v 87: v - 48 else: v - 56 bits f{v:06b} # 每个字符输出固定6位 return bits这里有个边界坑?的ASCII值是63小于87走减48分支K的ASCII值是75也走减48分支。真正走减56分支的是反引号和w这类字符。分界线是ASCII 87不是字母大小写分界抄代码时容易记错。填充位在消息最后第3章里parts[6]就是填充位数解析位字段时要先去掉这几位再用。4.2 类型1/2/3位置报告MMSI、经纬度、航速航向类型1、2、3是AIS最核心的位置报告三者数据结构相同只是用途不同类型1是常规位置报告类型2是调度位置报告类型3是特别位置报告。位字段布局是固定的用下面这个整数取位函数最直接def bits_int(bits: str, start: int, length: int) - int: return int(bits[start:start length], 2) def parse_position_report(payload: str, fill_bits: int) - dict: bits payload_to_bits(payload) if fill_bits: bits bits[:-fill_bits] msg_type bits_int(bits, 0, 6) if msg_type not in (1, 2, 3): return None mmsi bits_int(bits, 8, 30) status_code bits_int(bits, 38, 4) speed_raw bits_int(bits, 50, 10) # 单位0.1节 speed speed_raw / 10.0 lon_raw bits_int(bits, 60, 28) if lon_raw 0x8000000: lon_raw - 0x10000000 lon lon_raw / 600000.0 lat_raw bits_int(bits, 89, 27) if lat_raw 0x4000000: lat_raw - 0x8000000 lat lat_raw / 600000.0 course_raw bits_int(bits, 116, 12) course course_raw / 10.0 return { type: msg_type, mmsi: mmsi, status: status_code, speed_kn: speed, lon: lon, lat: lat, course: course, }参数说明两个重点。第一位置精度是1/600000度也就是1/10000分所以原始整数除以600000才得到十进制度数很多初学者除以60000导致坐标漂移一整条街。第二经纬度用二进制补码表示符号经度是28位最高位是符号位大于0x8000000表示西经要减去0x10000000纬度是27位大于0x4000000表示南纬减去0x8000000。这两行代码的边界值抄错是解析结果“跑到非洲”的最常见原因。提示转向率字段用的是带符号的每秒度数取值范围是-128到127其中-128表示“不可用”解析时要单独处理别直接当数值算进转角。4.3 类型5静态信息船名、呼号、目的地类型5消息携带船舶静态信息是六位ASCII文本解析的主战场。船名占20个字符从位70开始呼号从42开始占7个字符目的地从169开始占20个字符。六位ASCII转字符串的规则是1到26映射为A到Z0映射为32到63映射为数字和标点其余值填充空格def sixbit_to_text(bits: str, start: int, char_count: int) - str: out [] for i in range(char_count): v bits_int(bits, start i * 6, 6) if 1 v 26: out.append(chr(ord(A) v - 1)) elif v 0: out.append() elif 32 v 63: out.append(chr(v)) else: out.append( ) return .join(out).rstrip( ) def parse_static_report(payload: str, fill_bits: int) - dict: bits payload_to_bits(payload) if fill_bits: bits bits[:-fill_bits] mmsi bits_int(bits, 8, 30) callsign sixbit_to_text(bits, 42, 7).strip() shipname sixbit_to_text(bits, 70, 20).strip() dest sixbit_to_text(bits, 169, 20).strip() return {mmsi: mmsi, callsign: callsign, ship_name: shipname, destination: dest}船名的20个字符不能全信有些船在AIS里根本没填名字解析出来就是连续符号和空格所以rstrip( )这步不能省。位起点42、70、169这几个数字如果错了后面的字段全错位而且错位的现象是“船名里有数字、呼号里有乱码”很难肉眼定位。4.4 多消息合并成一条动态轨迹位置报告和静态报告是两类独立消息用MMSI作为关联键合并成完整船舶档案ships {} def ingest(payload: str, fill_bits: int, ts: float): bits payload_to_bits(payload) if fill_bits: bits bits[:-fill_bits] msg_type bits_int(bits, 0, 6) if msg_type in (1, 2, 3): rep parse_position_report(payload, fill_bits) if rep: ships.setdefault(rep[mmsi], {}).update(rep) ships[rep[mmsi]][last_ts] ts elif msg_type 5: stat parse_static_report(payload, fill_bits) if stat: ships.setdefault(stat[mmsi], {}).update(stat)内存里这个ships字典要定期清理。VHF信道每秒钟有几十条消息长期跑下来僵尸MMSI会拖垮内存。我一般用collections.OrderedDict每次收到消息就move_to_end超过15分钟没更新的船舶从字典里弹出。这样既能保证轨迹查询实时性又不会把内存吃光。5. 常见问题与避坑驱动、解码、解析三层的故障排查5.1 驱动识别正常但串口永远没有数据现象lsusb能看到设备dmesg里驱动加载也正常但minicom打开后一片空白什么字符都不出。原因大多数USB接口的AIS接收机默认工作在静默模式。厂商出厂前为了测试方便会把设备设置成不发射也不上报需要用户用配置工具打开连续上报模式。另外接收机需要一个有源天线或者外接5V馈电天线没供电时射频前端完全不工作串口当然不出数据。解决先用厂商的Windows配置工具连接设备看有没有“连续输出”“Continuous Output”这样的开关打开后再回到终端里看数据流。天线方面用万用表量一下天线馈电端有没有5V输出没有就加一个Bias-T电源注入器。这一步排查顺序要在驱动之前因为串口无数据九成是前端问题驱动只负责USB枚举。5.2 串口里全是乱码而不是 !AIVDM现象终端里能看到字符在翻滚但内容是\x01之类的乱码完全看不到!AIVDM开头。原因波特率不匹配。AIS接收机出厂波特率有的用38400有的用9600还有老设备用4800。USB转串口芯片在两个波特率之间会解出完全不同的比特流。解决把socat和minicom的波特率参数逐个试一遍38400、9600、19200都试。注意socat里的写法是b38400minicom是-b 38400两个工具参数风格不同改漏一个会出现数据时有时无的假故障。用Python的pyserial测试时改baudrate参数后一定要重新open()部分驱动在Windows下改波特率不生效。5.3 校验和频繁失败导致数据利用率极低现象解析日志里每天都有大量“checksum failed”明明同一个设备、同一个串口昨天还好好的今天一半数据进不来。原因两个源头。第一串口工具以行缓冲模式读取时会把长消息截断导致*后面的校验字符丢失第二USB转串口芯片在高负载下偶尔会把消息里的\r\n拆开你的readline读到半行就返回了。解决读取时用rawer模式绕开行缓冲并把数据落盘为二进制文件再离线解析。我在生产环境里的做法是socat一边把原始字节写进ais_raw.log一边开一个Python进程从同一份文件里按\r\n分帧解析这样即使在线解析崩了原始日志还在往后任何时候都能重新灌数据。5.4 解析出的经纬度在非洲大陆上刷屏现象解析程序跑通了数据一条接一条但地图上所有船都在非洲西海岸或者赤道附近排成一条直线。原因位置原始值处理错了。不是网络问题也没有翻车纯粹是代码里经度方位判断逻辑写反了。常见错误是把经度28位的补码转换边界0x8000000错写成0x80000000或者除以600000的时候少写一个0。解决先写单元测试用一条已知船位的数据验证取一条真实日志手工用在线AIS解码器解出经纬度再跑自己的解析函数对比。把这条测试数据固化进测试集里以后改解析代码跑一遍回归。我自己的测试集里有三条一条北半球东经、一条南半球西经、一条静止船舶覆盖三个典型分支。5.5 多语句消息永远拼不齐现象类型5静态消息一条都解析不出来日志里全是“pending timeout”但位置报告正常。原因类型5消息拆成两条时中间可能插入若干条单语句消息。有些解析程序用“全局变量存一条收到下一条就覆盖”的写法第二条还没到就被第三条冲掉了。另一种情况是消息组ID解析错了把分组ID当成信道去判断导致同一消息的两条分到两个key下。解决用第3章的字典缓存方案key用(msg_id, channel)元组不要只用一个字段。超时时间设3秒都够因为两条语句间隔通常不超过几百毫秒。还有一个隐蔽问题关联字段里B和b的大小写在某些设备上混用缓存key生成时统一upper()一次。6. 从解析到落地把实时位置写入SQLite并回放验证解析器跑通只是第一步真正能投入使用的标志是数据能落到存储里并且能用历史日志回放验证正确性。我习惯用SQLite做轻量落地单表按MMSI和时间戳去重import sqlite3 import time conn sqlite3.connect(ais.db) conn.execute(CREATE TABLE IF NOT EXISTS positions ( mmsi INTEGER, ts INTEGER, lon REAL, lat REAL, speed_kn REAL, course REAL, PRIMARY KEY (mmsi, ts) )) def store_position(mmsi, ts, lon, lat, speed_kn, course): conn.execute( INSERT OR REPLACE INTO positions VALUES (?, ?, ?, ?, ?, ?), (mmsi, int(ts), lon, lat, speed_kn, course) ) conn.commit()INSERT OR REPLACE配合复合主键保证同一秒内重复收到的报文只保留最后一条靠它天然做到幂等。回放验证的流程是把第2章落盘的ais_raw.log按时间排序逐行喂给解析函数再用time.sleep模拟实时节奏和当时观察到的AIS软件截图对比船位。这个闭环做一次你对这套解析流程的信任度会直线上升。验证时重点关注两类数据一是航速超过40节的船确认高速段没有因为消息截断丢点二是两个港区之间的常驻引航船确认静止状态没有被错误过滤掉。我做AIS解析的最大体会是硬件链路的问题永远比代码多驱动、解码、解析三层要分开验证别等最后一层出问题才回头排查线缆和天线。后来我养成了给每台接收机留24小时原始日志的习惯出了问题直接回放对比比现场抓包省力得多。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑