3、消息帧结构深度解析:起始标志、载荷长度、序列号、系统ID、组件ID、消息ID、载荷、校验

各位同学,今天我们来啃一块硬骨头——MAVLink的消息帧结构。说实话,我刚接触MAVLink那会儿,看着这一串字节流,脑子里全是问号。但后来我发现,只要你把每个字段的「脾气」摸透了,这东西其实挺可爱的。

先给你看一个完整的帧结构长什么样。嗯,我习惯把它想象成一封挂号信:信封上有邮戳、收件人地址,里面才是真正的内容。

| 起始标志 | 载荷长度 | 序列号 | 系统ID | 组件ID | 消息ID | 载荷 | 校验 |
|  0xFE   |   1字节  | 1字节  | 1字节  | 1字节  | 1字节  | N字节 | 2字节 |

一共8个字段,加起来最少8个字节(载荷为0时),最多263个字节。别被这个数字吓到,我们一个一个拆开看。

3.1 起始标志:0xFE 的执念

起始标志固定为 0xFE,也就是十进制的254。为什么是254?不是255也不是253?

我个人猜测,设计者选这个值是为了避免和常见的串口数据冲突。你想想看,串口通信里0x00和0xFF经常被用作特殊控制字符,0xFE刚好是个「中间值」,误触发的概率低。

我在项目中遇到过一个坑:有个同事为了省事,把起始标志写成了0xFF。结果无人机在天上飞着飞着,地面站突然收到一堆乱码。排查了半天,发现是串口缓冲区里残留的0xFF被当成了帧头。从那以后,我团队里谁改起始标志我跟谁急。

⚠️ 避坑指南: 我曾经在自定义消息时试图修改起始标志,结果导致和QGroundControl无法握手。记住:0xFE是MAVLink的「身份证」,别乱动。

3.2 载荷长度:别小看这1个字节

载荷长度字段占1个字节,取值范围0~255。它表示后面跟着的「载荷」有多少个字节。

这里有个细节:载荷长度不包括校验字段。也就是说,如果你发送一个空消息(载荷长度为0),帧结构里依然会有2个字节的校验。

我刚开始做的时候,总以为载荷长度是整个帧的长度。结果算校验和的时候,怎么都对不上。后来翻源码才发现,这个长度只算载荷部分。说白了,就是告诉接收端:「嘿,后面N个字节是真正的数据,再往后2个字节是校验,别搞混了。」

载荷长度值 实际载荷字节数 整帧长度(不含起始标志)
0 0字节 6字节
255 255字节 261字节
典型值(如HEARTBEAT) 9字节 15字节

3.3 序列号:防丢包的第一道防线

序列号占1个字节,从0开始,每发送一条消息就加1。加到255后归零,循环使用。

你可能会问:1个字节够用吗?255个序号循环,万一丢包了怎么办?

嗯,这里要说明一下:MAVLink的序列号不是用来做可靠传输的。它更像一个「心跳检测器」。接收端通过检查序列号是否连续,可以判断链路是否丢包。但MAVLink本身不重传,丢了就丢了。

我记得有一次做编队飞行,3架无人机同时发数据,地面站收到的序列号乱成一锅粥。后来发现是串口缓冲区溢出,导致部分帧被丢弃。通过序列号跳变,我们很快定位到了问题。所以,别看它小,关键时刻能救命。

3.4 系统ID与组件ID:谁在说话?

这两个字段放在一起讲,因为它们是一对「组合拳」。

  • 系统ID(1字节):标识一个独立的系统。比如一架无人机、一个地面站、一个摄像头。取值范围1~255,0保留。
  • 组件ID(1字节):标识系统内部的某个组件。比如飞控、GPS模块、云台。取值范围0~255。

举个例子:系统ID=1,组件ID=1,通常表示「1号无人机的飞控」。系统ID=1,组件ID=5,可能表示「1号无人机的GPS模块」。

我个人的习惯是:系统ID用固定编号,比如1号机、2号机。组件ID则按照MAVLink官方定义来,别自己瞎编。否则地面站解析的时候会一脸懵。

💡 小技巧: 如果你在做多机协同,建议把系统ID和无人机编号对应起来。比如1号机系统ID=1,2号机系统ID=2。这样地面站一看系统ID就知道是哪架飞机在发数据。

3.5 消息ID:消息的「身份证号」

消息ID占1个字节,范围0~255。每个消息ID对应一种消息类型。比如:

  • 0:HEARTBEAT(心跳包)
  • 1:SYS_STATUS(系统状态)
  • 2:SYSTEM_TIME(系统时间)
  • ……
  • 255:自定义消息(通常从255开始往下用)

消息ID决定了接收端如何解析后面的载荷。说白了,接收端看到消息ID=0,就知道后面9个字节是HEARTBEAT的数据。看到消息ID=255,就知道这是自定义消息,得按你定义的格式来解析。

我在项目中遇到过一个经典问题:消息ID冲突。团队里两个人各自定义了自定义消息,都用了ID=255。结果地面站收到数据后,用A的解析器去解析B的消息,直接崩溃。从那以后,我们规定自定义消息ID必须登记在案,谁用谁签字。

3.6 载荷:真正的数据

载荷就是消息的「正文」。长度由前面的「载荷长度」字段指定,最大255字节。

载荷的格式完全由消息ID决定。比如HEARTBEAT的载荷是9个字节,包含:系统类型、飞控类型、模式、状态等。而GPS_RAW_INT的载荷是30个字节,包含经纬度、海拔、速度等。

这里有个设计哲学:MAVLink的载荷是紧凑排列的,没有对齐填充。也就是说,一个uint8_t后面紧跟着一个uint32_t,不会为了对齐而插入空字节。这样做的好处是节省带宽,坏处是某些MCU上访问未对齐的数据会触发异常。

⚠️ 避坑指南: 我曾经在STM32上解析MAVLink消息时,直接强转结构体指针去读载荷。结果因为字节对齐问题,读出来的数据全是错的。后来老老实实用memcpy逐字段拷贝,问题解决。记住:别偷懒,别用指针强转。

3.7 校验:数据完整性的守护神

校验字段占2个字节,使用CRC-16-CCITT算法。它覆盖的范围是:从「载荷长度」字段开始,一直到载荷结束。起始标志不参与校验。

为什么起始标志不参与校验?因为接收端先靠起始标志找到帧头,然后才开始校验。如果起始标志也参与校验,那就成了「先有鸡还是先有蛋」的问题了。

校验的计算公式如下(我直接给你代码,省得你再去翻源码):

uint16_t mavlink_crc_calculate(const uint8_t* buffer, uint16_t length) {
    uint16_t crc = 0xFFFF;
    for (uint16_t i = 0; i < length; i++) {
        crc ^= buffer[i];
        for (uint8_t j = 0; j < 8; j++) {
            if (crc & 0x0001) {
                crc = (crc >> 1) ^ 0x8408;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc;
}

接收端收到一帧数据后,会重新计算校验和,然后和帧尾的2个字节比对。如果一致,说明数据没被篡改。如果不一致,直接丢弃整帧。

我记得有一次做野外测试,无人机飞到500米外,地面站突然开始丢帧。排查了一圈,发现是电机产生的电磁干扰导致串口数据出错。幸亏有CRC校验,地面站自动丢弃了错误帧,没有把错误数据传给飞控。要是没有校验,飞机可能就自己乱飞了。

3.8 整体流程:一帧数据的「一生」

说了这么多,我们来走一遍完整流程。假设你要发送一条HEARTBEAT消息:

  1. 填充载荷:9个字节的HEARTBEAT数据
  2. 设置载荷长度:9
  3. 设置序列号:当前发送计数,比如42
  4. 设置系统ID:1
  5. 设置组件ID:1
  6. 设置消息ID:0
  7. 计算校验:从载荷长度到载荷结束,共1+1+1+1+1+9=14个字节,算出CRC
  8. 拼接帧:0xFE + 9 + 42 + 1 + 1 + 0 + [9字节载荷] + [2字节CRC]
  9. 发送到串口

接收端收到后,先找0xFE,然后按顺序解析各个字段,最后校验CRC。全部通过,才算收到一条有效消息。

🔑 核心要点: MAVLink的帧结构设计非常精简,每个字段都有明确用途。起始标志用于同步,载荷长度用于定界,序列号用于检测丢包,系统/组件ID用于寻址,消息ID用于类型识别,校验用于完整性验证。缺一个都不行。

好了,帧结构就讲到这里。下一节我们聊聊如何用代码实现一个MAVLink解析器。嗯,到时候我会给你看一个我自己写的轻量级解析库,只有200行代码,但该有的功能一个不少。


公众号:蓝海资料掘金营,微信deep3321