3. MAVLink 心跳协议详解:HEARTBEAT 消息结构、系统状态、自定义模式

心跳,是 MAVLink 通信中最基础也最容易被忽视的协议。说白了,它就是无人机和地面站之间的「你还活着吗?」——每隔一段时间问一次,对方回一句「我还活着,状态是 XX」。但就这么一个简单的机制,背后藏着不少门道。

我刚开始做地面站的时候,觉得心跳就是个定时器发个包,没什么好研究的。直到有一次在野外测试,飞机飞出去两公里突然断连,地面站显示「连接已断开」,但飞机其实还在正常飞。后来排查发现,是心跳超时阈值设得太短,加上链路有轻微丢包,地面站误判了断连。嗯,从那以后,我对心跳协议的每一个字节都不敢马虎。

3.1 HEARTBEAT 消息结构

HEARTBEAT 的消息 ID 是 0,在 MAVLink 协议栈里排第一位。它的 payload 固定为 9 字节,结构如下:

字段名 类型 字节数 说明
type uint8_t 1 系统类型(飞控、地面站、相机等)
autopilot uint8_t 1 自驾仪类型(PX4、ArduPilot 等)
base_mode uint8_t 1 系统基础模式(位掩码)
custom_mode uint32_t 4 自定义模式(飞控特有)
system_status uint8_t 1 系统状态(已解锁、紧急降落等)
mavlink_version uint8_t 1 MAVLink 协议版本(目前固定为 3)

你看,总共才 9 个字节,但信息量很大。我个人的习惯是,在调试地面站时,先把心跳包完整解析出来打印到日志里,看看每个字段的值是否合理。很多连接问题,其实从心跳数据里就能看出端倪。

关键点:心跳包是双向的。地面站也要发心跳给飞控,告诉飞控「我还连着呢」。如果飞控收不到地面站的心跳,它可能会触发 failsafe 机制——比如自动返航或降落。

3.2 系统状态(system_status)

system_status 字段只有 1 个字节,但它反映了无人机当前最核心的状态。MAVLink 标准定义了一组枚举值:

枚举值 名称 含义
0 MAV_STATE_UNINIT 未初始化,系统刚上电
1 MAV_STATE_BOOT 正在启动中
2 MAV_STATE_CALIBRATING 正在校准传感器
3 MAV_STATE_STANDBY 待机,已就绪但未解锁
4 MAV_STATE_ACTIVE 已解锁,正在飞行或运行
5 MAV_STATE_CRITICAL 严重告警,但还能控制
6 MAV_STATE_EMERGENCY 紧急状态,可能已失控
7 MAV_STATE_POWEROFF 正在关机
8 MAV_STATE_FLIGHT_TERMINATION 飞行终止(炸机或主动终止)

我在做重连逻辑时,特别关注 MAV_STATE_CRITICAL 和 MAV_STATE_EMERGENCY。如果重连后收到的心跳显示系统处于这两个状态,地面站应该立即弹出告警,而不是默默恢复显示。你想想看,飞机都快炸了,地面站还在那慢悠悠地刷新地图,那可就出大事了。

注意:有些飞控在紧急状态下会降低心跳发送频率,甚至停止发送。地面站不能单纯依赖心跳超时来判断断连,还要结合其他消息(如 SYS_STATUS、GPS_RAW_INT)做综合判断。

3.3 自定义模式(custom_mode)

custom_mode 是 4 字节的 uint32_t,标准协议里没规定它的具体含义。说白了,这是留给飞控厂商自己发挥的空间。PX4 和 ArduPilot 对它的编码方式完全不同。

我举个例子,PX4 的自定义模式是这样编码的:

// PX4 自定义模式编码示例
// 主模式:0-15,子模式:16-31
uint32_t px4_custom_mode = (main_mode & 0xFF) | (sub_mode & 0xFF) << 8;

而 ArduPilot 则直接用枚举值:

// ArduPilot 自定义模式枚举
enum ArduPlaneMode {
    MANUAL        = 0,
    CIRCLE        = 1,
    STABILIZE     = 2,
    TRAINING      = 3,
    AUTO          = 10,
    RTL           = 11,
    LOITER        = 12,
    // ... 更多模式
};

这就带来一个问题:地面站怎么知道当前飞控用的是哪种编码方式?答案是——看 autopilot 字段。如果 autopilot 是 MAV_AUTOPILOT_PX4,就用 PX4 的解析方式;如果是 MAV_AUTOPILOT_ARDUPILOTMEGA,就用 ArduPilot 的方式。

我的经验:在写地面站时,不要硬编码自定义模式的解析逻辑。最好做一个插件化的模式解析器,每种飞控注册自己的解析函数。这样以后支持新飞控时,只需要加一个插件,不用改核心代码。

3.4 心跳与重连机制的关系

心跳在重连机制中扮演什么角色?我画了一张图,帮你理清思路:

心跳在重连机制中的角色 正常连接 心跳周期 1Hz 心跳丢失 连续 N 次未收到 触发重连 启动重连流程 重连成功 收到有效心跳 重连失败 超时或收到无效心跳 重试策略 指数退避 关键判断逻辑: 1. 心跳超时阈值:建议设为 3-5 个心跳周期(3-5 秒) 2. 重连成功标志:连续收到 2 个有效心跳包 3. 重连失败处理:记录失败次数,按指数退避策略增加重试间隔 4. 特殊处理:如果心跳中 system_status 为 EMERGENCY,立即告警

从这张图你可以看到,心跳是整个重连机制的「触发器」和「验证器」。没有心跳,地面站就不知道连接是否还活着;收到心跳,才能确认重连是否成功。

我个人的做法是,在地面站里维护一个心跳计数器:

// 心跳状态机伪代码
typedef struct {
    uint32_t last_heartbeat_ms;   // 上次收到心跳的时间戳
    uint32_t heartbeat_timeout_ms; // 超时阈值,默认 5000ms
    uint8_t missed_count;         // 连续丢失次数
    uint8_t reconnect_attempts;   // 重连尝试次数
    bool is_connected;            // 当前连接状态
} HeartbeatMonitor;

// 每次收到心跳包时调用
void on_heartbeat_received(HeartbeatMonitor *mon) {
    mon->last_heartbeat_ms = get_time_ms();
    mon->missed_count = 0;
    
    if (!mon->is_connected) {
        // 之前断连,现在收到心跳,说明重连成功
        mon->is_connected = true;
        mon->reconnect_attempts = 0;
        // 触发重连成功回调
        on_reconnect_success();
    }
}

// 主循环中每秒检查一次
void check_heartbeat_timeout(HeartbeatMonitor *mon) {
    uint32_t elapsed = get_time_ms() - mon->last_heartbeat_ms;
    if (elapsed > mon->heartbeat_timeout_ms) {
        mon->missed_count++;
        if (mon->is_connected) {
            mon->is_connected = false;
            // 触发断连回调,启动重连
            on_connection_lost();
        }
    }
}

核心要点:重连成功不能只看一个心跳包。我曾经遇到过链路抖动,偶尔能收到一个心跳,但下一秒又断了。如果地面站收到一个心跳就认为重连成功,会导致频繁的状态切换。我建议连续收到 2-3 个心跳包,才确认重连成功。

3.5 自定义模式在重连中的特殊处理

重连成功后,地面站需要重新获取飞控的完整状态。但心跳包里的 custom_mode 只有 4 个字节,信息有限。我建议的做法是:

  1. 先解析心跳,获取基本的系统状态和模式信息
  2. 再主动请求 MAV_CMD_REQUEST_MESSAGE,获取完整的模式名称和参数
  3. 更新 UI,显示正确的飞行模式

为什么不能只靠心跳?因为 PX4 的 custom_mode 编码里,主模式和子模式是分开的。比如主模式是 MAV_MODE_POSITION_HOLD,子模式可能是某个具体的悬停模式。地面站需要查表才能显示成「定高悬停」这样的用户友好文字。

一个小技巧:在重连成功后,不要立即发送大量请求消息。先发一个 MAV_CMD_REQUEST_MESSAGE 请求 AUTOPILOT_VERSION,确认飞控版本和功能集,再根据版本决定后续请求哪些消息。这样可以避免在链路不稳定时造成拥塞。

好了,关于心跳协议的核心内容就这些。你可能会问:心跳超时阈值到底设多少合适?这个没有标准答案,取决于你的应用场景。室内测试可以设短一点(3 秒),野外长距离飞行建议设长一点(5-10 秒)。我个人的经验是,先设 5 秒,然后在实际测试中根据丢包率调整。