4. 飞控软件整体分层架构:硬件抽象层(HAL)、中间件层、应用层、地面站接口层
各位同学,今天我们聊聊飞控软件的整体分层。说实话,很多刚入行的朋友喜欢把代码全写在一个大循环里,觉得这样效率高。嗯,我年轻时也这么干过,结果项目后期改一个传感器驱动,整个系统都要重新编译测试,那叫一个痛苦。
分层架构,说白了就是「各司其职,互不干扰」。我习惯把飞控软件切成四层:硬件抽象层、中间件层、应用层、地面站接口层。每一层只关心自己的事,层与层之间通过标准接口通信。这样做的好处,你想想看——换一个IMU芯片,只需要改硬件抽象层,应用层的控制算法完全不用动。
核心原则:上层不关心底层怎么实现,底层不假设上层如何使用。这就是分层架构的精髓。
4.1 硬件抽象层(HAL)—— 把硬件「藏」起来
硬件抽象层是飞控软件的最底层,直接跟芯片和传感器打交道。我个人的习惯是,不管用STM32还是国产MCU,HAL层提供的API必须一模一样。比如读IMU数据,上层调用 hal_imu_read() 就行,至于底层是SPI还是I2C,上层完全不用管。
我在项目中遇到过最头疼的事:某次换了一款气压计,从MS5611换成BMP388。如果没有HAL层,你得满世界找调用了气压计驱动的地方,改几十个文件。有了HAL层,只需要重写 hal_baro_read() 这一个函数就行。
我的建议:HAL层要提供统一的错误码返回。比如读传感器超时返回 -1,数据无效返回 -2。这样上层做故障诊断时,逻辑可以统一处理。
/* 硬件抽象层接口示例 */
typedef struct {
float gyro[3]; // 陀螺仪,单位 rad/s
float accel[3]; // 加速度计,单位 m/s²
float mag[3]; // 磁力计,单位 Gauss
float baro; // 气压计,单位 Pa
float temp; // 温度,单位 ℃
} hal_sensor_data_t;
/* 统一传感器读取接口 */
int hal_sensor_read(hal_sensor_data_t *data);
/* 返回 0 成功,负值表示错误码 */
4.2 中间件层 —— 飞控的「神经系统」
中间件层是飞控软件里最容易被低估的一层。说白了,它就是负责让各个模块之间能「说话」。我最早做飞控时,用的是全局变量传数据,结果调试时经常搞不清哪个模块改了哪个变量,bug查得头皮发麻。
后来我改用消息总线架构,类似PX4的uORB。每个模块只发布自己产生的数据,需要数据的模块去订阅。这样模块之间完全解耦,想加一个新传感器?写个驱动,把数据发布到总线上就行,控制模块自动就能收到。
| 中间件组件 | 职责 | 我踩过的坑 |
|---|---|---|
| 消息总线 | 模块间数据交换 | 消息队列深度不够导致丢数据 |
| 传感器融合 | EKF/互补滤波 | 时间戳不同步导致发散 |
| 健康监控 | 心跳检测、异常上报 | 漏掉看门狗喂狗时机 |
| 时间同步 | 统一时间基准 | 不同传感器延时差异没补偿 |
注意:中间件层不要做太多「业务逻辑」。我曾经把控制算法也塞进中间件层,结果代码耦合得一塌糊涂。中间件只负责「传数据」和「做预处理」,真正的决策交给应用层。
4.3 应用层 —— 飞控的「大脑」
应用层是飞控软件里最「聪明」的一层。姿态控制、位置控制、导航规划、状态机管理,全在这里。我习惯把应用层拆成几个独立的任务(Task),每个任务跑一个固定频率。
举个例子,姿态控制任务跑 400Hz,位置控制任务跑 100Hz,导航规划跑 10Hz。为什么这样设计?你想想看,姿态变化最快,需要高频控制;位置变化慢一些,频率可以低一点;导航规划是宏观决策,10Hz 足够了。
我曾经犯过一个错误:把所有控制逻辑写在一个大循环里,结果高频的姿态控制被低频的导航任务阻塞,飞控直接炸机。嗯,从那以后我坚持用任务优先级+独立栈空间的设计。
/* 应用层任务调度示例 */
void task_attitude_control(void *arg) {
/* 400Hz 姿态控制 */
while(1) {
hal_sensor_data_t sensor;
hal_sensor_read(&sensor); // 读传感器
attitude_ekf_update(&sensor); // 姿态解算
attitude_pid_control(); // PID控制
hal_motor_output(control_output); // 输出PWM
os_delay_us(2500); // 2.5ms周期
}
}
void task_position_control(void *arg) {
/* 100Hz 位置控制 */
while(1) {
position_ekf_update();
position_pid_control();
os_delay_ms(10);
}
}
4.4 地面站接口层 —— 飞控的「窗户」
地面站接口层负责飞控和地面站之间的通信。说白了,就是让地面上的工程师能看到飞机在干什么,也能给飞机下指令。我一般用MAVLink协议,它是无人机行业的事实标准。
这一层要处理的事情挺杂的:参数读写、日志上传、遥控指令解析、心跳包维护。我建议把地面站接口层做成一个独立的任务,不要跟控制任务混在一起。为什么?因为地面站通信有时候会丢包、会重传,这些网络问题不应该影响飞控的实时性。
避坑指南:我曾经把日志上传功能直接放在控制任务里,结果日志文件太大,上传时占用了大量CPU时间,导致控制周期抖动。后来我把日志上传放到一个低优先级任务里,问题就解决了。
地面站接口层还有一个重要功能:参数管理。飞控的参数成百上千,PID系数、传感器校准值、飞行模式配置等等。我习惯用参数ID来索引,而不是用字符串名字。字符串比较太慢了,在嵌入式系统里能省则省。
/* 参数管理接口示例 */
typedef struct {
uint16_t param_id; // 参数ID
float value; // 参数值
uint8_t type; // 参数类型
} param_t;
int param_set(uint16_t id, float value);
int param_get(uint16_t id, float *value);
/* 地面站通过MAVLink协议调用这些接口 */
4.5 层与层之间的「规矩」
分层架构最怕什么?最怕层与层之间「乱搞」。我见过有人直接在应用层调用HAL层的SPI读写函数,美其名曰「提高效率」。结果呢?换了一个MCU,所有代码都得重写。
我定了几条规矩,大家可以参考:
- 单向依赖:上层可以调用下层,下层绝不能调用上层。应用层可以调中间件层,中间件层不能反过来调应用层。
- 接口稳定:层与层之间的接口一旦确定,尽量不要改。改接口意味着所有上层代码都要跟着改,成本很高。
- 数据封装:不要直接暴露内部数据结构。比如HAL层的传感器数据结构,应该通过函数返回,而不是让上层直接访问全局变量。
总结一下:分层架构不是为了好看,是为了「可维护」和「可移植」。我做了十几年飞控,换过七八种MCU、几十种传感器,每次都能快速适配,靠的就是严格的分层设计。你想想看,如果每次换硬件都要重写整个飞控,那得多痛苦?
好了,这一章的内容就到这里。分层架构是飞控软件的骨架,骨架搭好了,后面的工作才能顺利进行。下一章我们会深入中间件层的消息总线设计,那是飞控软件里最精彩的部分之一。