1. 项目概述:从“仿真里能飞”到“板子上能跑”的完整闭环
先说说我为什么会对这个项目感兴趣。飞控圈子里有个老段子:仿真是“薛定谔的稳定”,你在Gazebo里调好的参数,一上真机就发散;你在真机上飞得好好的,回头看仿真曲线却一团糟。而神经网络控制这个方向,看似把“调参”变成了“训练”,但真正动手做过的人都知道,它把原本的PID调参问题,换成了一个更大的坑——怎么把训练好的网络搬进飞控的嵌入式环境里,还能稳定跑起来不掉帧、不爆内存、不触发看门狗。
这篇文章要讲的,就是一条从PX4仿真环境出发,完成神经网络控制器设计、训练,再把它部署到嵌入式飞控硬件上的完整路径。我踩过的坑、绕过的弯、实测过能用的方法,都会原原本本写出来。如果你正在做PX4二次开发,或者想试试用神经网络替代传统控制回路,这篇内容可以帮你少走不少弯路。
这个项目适合谁看?我觉得有三类人最对口:
- 已经入门PX4,想在姿态控制或位置控制上做算法创新的同学;
- 做嵌入式开发,想了解神经网络模型如何落地到实时系统中的工程师;
- 以及单纯对“仿真到实物”迁移问题感兴趣,想看看这条链路里到底有哪些隐形门槛的人。
先交代一下我的实际环境,方便后面说细节:主机是Ubuntu 20.04,PX4固件版本用的1.13.x,仿真环境是Gazebo 9配合QGroundControl地面站,飞控硬件是Pixhawk 4(STM32F765),通过HITL(Hardware-In-The-Loop)模式先验证部署代码,再上真机。
2. 方案选型:为什么用神经网络,又为什么选PX4
2.1 传统控制方案的边界在哪里
在聊神经网络之前,得先搞清楚它要解决什么问题。PX4默认的姿态控制器是级联PID结构:外环算角度误差,内环算角速度误差,输出期望力矩,再通过混合器映射到四个电机的PWM值。这个结构在绝大多数场景下是够用的,简单、稳定、可解释性强。
但它有几个绕不开的短板:
- 强耦合环境下调参困难。四旋翼本身是欠驱动系统,横滚、俯仰、偏航之间存在耦合,PID参数一旦在某个工况下调好,换到重载、大风或者不对称布局时往往要重新整定。
- 非线性处理能力弱。PID本质是线性控制器,对气动阻尼、电机饱和、桨叶失速这些非线性因素,只能靠积分项和限幅去“硬扛”。
- 多约束优化难做。比如你想同时满足“姿态误差最小”和“控制能耗最低”两个目标,PID很难直接给出帕累托最优解。
神经网络控制器的思路,是用一个能逼近任意非线性映射的网络,去学习“状态误差到控制量”之间的关系。换句话说,你在仿真里给它看一万组“状态–动作”数据,它就能自己归纳出一套控制策略,这套策略天然包含了对耦合和非线性的补偿。而且前向推理的延迟是固定的,只要算力够,实时性反而比某些迭代优化算法更可控。
2.2 项目选型的三层考量
选PX4而不是选APM,选Gazebo而不是选其他仿真器,选STM32F7而不是选树莓派,这些选择都有明确的逻辑,不是拍脑袋定的。
第一层,PX4的架构更适合算法注入。PX4内部用uORB做模块间通信,姿态控制器、位置控制器、混合器都是独立模块,替换掉某个模块不会影响整体框架。想要验证神经网络控制器,只需要把自己的算法模块注册进PX4构建系统,订阅姿态估计消息,发布控制指令消息,剩下的调度、消息传递、失败保护机制都是现成的。
第二层,Gazebo配合PX4的仿真生态最成熟。PX4官方维护了一套sitl仿真工具链,从MAVLink消息转发到传感器仿真插件都封装好了,跑起来不需要自己造轮子。对比下来,ArduPilot的仿真链路虽然也能用,但在模型频率、传感器噪声模拟、外部控制器接入的灵活性上,PX4这套更适合做算法验证。
第三层,STM32F765这颗芯片是当前消费级和准工业级飞控的主流配置,Cortex-M7内核,主频216MHz,带FPU和DSP指令集。它跑得动轻量化的神经网络推理,又不像树莓派那样是完整的Linux系统,实时性和功耗控制更好。选它作为部署目标,具备代表性,跑通了这套流程,换到F405、H7系列飞控只是适配问题。
2.3 模型结构的现实约束
在仿真里训练一个层数很深的网络,几秒钟跑一次推理一点问题都没有。但到了STM32上,一切都要换一套逻辑:Flash装不下多大的权重、RAM放不下多大的中间激活值、一次前向推理不能超过控制回路的周期预算。
我最终选用的网络结构是四层全连接网络:
- 输入层:12个节点,对应姿态误差、角速度误差、上一时刻控制量等状态量;
- 隐藏层:第一层32个节点,tanh激活;第二层16个节点,ReLU激活;
- 输出层:4个节点,对应四个电机的期望推力或者三轴力矩加总推力;
- 总参数量:12×32 + 32 + 32×16 + 16 + 16×4 + 4 = 692个权重和偏置。
这个规模,用FP32存下来大约2.7KB,放在Flash里毫无压力,算力需求在STM32F765上实测每次推理约0.35ms,远小于250Hz控制周期(4ms)的预算。网络虽然小,但对单一路径的姿态控制任务来说,拟合能力已经足够。
3. 仿真环境搭建:先把PX4跑起来,再谈控制算法
3.1 开发环境准备的几个关键点
PX4开发环境的搭建,官方文档写得很全,但有几个坑文档里没细说。我用的Ubuntu 20.04,终端执行官方脚本一键安装依赖工具链:
git clone --recursive https://github.com/PX4/PX4-Autopilot.git cd PX4-Autopilot bash ./Tools/setup/ubuntu.sh脚本执行过程中会自动安装ROS、Gazebo、MAVLink工具链,整个过程大概需要20到30分钟。这里面最容易出问题的是Python包版本冲突,特别是numpy和jinja2的版本,PX4的构建系统依赖特定版本,如果之前装过ROS或者其他Python包,建议用虚拟环境隔离。
仿真跑起来用这个命令:
make px4_sitl gazebo启动成功后,会出现PX4的nsh控制台,同时自动弹出Gazebo窗口,里面有一台静态的四旋翼模型。此时打开QGroundControl,能看到飞控已经处于“模拟飞行”状态,姿态和位置信息实时更新。到这一步,仿真环境就算跑通了。
3.2 给仿真加“料”:自定义模型参数
默认的四旋翼模型参数(质量、惯量、电机响应曲线)是通用的,但如果你想验证的神经网络控制器未来要部署到特定机架上,最好先把模型参数改过来。
PX4 Gazebo模型文件的位置在:
Tools/sitl_gazebo/models/quadrotor/quadrotor.sdf里面每个<inertia>标签对应的就是惯量矩阵,<mass>标签是质量,<rotor>标签里定义了电机推力系数和阻力系数。我按自己用的5寸机架数据,把质量从默认的1.0kg改到了1.35kg,惯量也做了相应调整。这一步非常重要,后面训练数据收集和控制器验证都会基于这个“虚拟机型”,如果参数不准,仿真里练出来的控制器上了真机就要翻车。
3.3 HITL模式:提前验证代码与硬件的兼容性
在真正把固件烧写到飞控之前,强烈建议先跑一遍HITL(Hardware-In-The-Loop)模式。这个模式的意思是,飞控硬件是真的(PX4固件在STM32上真实运行),但传感器数据来自仿真器。它能提前暴露嵌入式部署时的编译问题、内存问题、外设配置问题,而不用冒炸机的风险。
启用HITL需要在QGroundControl里把飞控设置为“HITL模式”,然后在PX4源码目录下执行:
make px4_fmu-v5_hitl烧写固件后,飞控通过USB连接电脑,Gazebo作为仿真后端提供传感器数据。此时你写的神经网络推理代码,跑的就是STM32上的真实二进制,但“飞机”还在电脑里。这一步的适配工作量,和直接上真机完全一样,但安全性高了不止一个数量级。
4. 神经网络控制器的训练过程:从数据到模型
4.1 训练数据从哪来:仿真日志是最好的起点
训练神经网络控制器,首先要有数据。数据来源通常有三种:人工飞行日志、仿真数据、模型预测控制(MPC)等传统算法生成的数据。我采用的是第三种——先让一个调好的PID控制器在Gazebo里带随机扰动飞,记录所有状态和控制量,作为训练集的“专家轨迹”。
具体做法:在PX4的nsh控制台里启动任务:
param set COM_DISARM_LAND 0 param set MIS_YAW_ERR 1然后在QGroundControl里规划一条包含悬停、前后左右平移、偏航旋转的航线,让飞机自动飞行。飞行过程中通过rosbag record记录/mavros/local_position/pose和/mavros/rc/out等话题,这些数据包含了期望状态、实际状态和最终控制量。
飞完后,用Python脚本离线处理bag包,把数据整理成固定时间步长的样本:每个样本包含当前姿态误差(3维)、角速度误差(3维)、上一时刻控制量(4维)和当前目标控制量(4维),共14个输入、4个输出。这样一组数据,飞行200秒大约能产出一万多个有效样本,足够训练不太深的网络。
4.2 训练细节:框架选型与超参设置
训练部分我用的是PyTorch,版本1.12,在PC上跑。模型定义、数据加载和训练循环的完整代码,我这里贴一个精简但可直接运行的版本。
import torch import torch.nn as nn import torch.optim as optim import numpy as np class Net(nn.Module): def __init__(self, input_dim=14, output_dim=4): super().__init__() self.fc1 = nn.Linear(input_dim, 32) self.fc2 = nn.Linear(32, 16) self.out = nn.Linear(16, output_dim) def forward(self, x): x = torch.tanh(self.fc1(x)) x = torch.relu(self.fc2(x)) return self.out(x) # 数据加载,假设你已经把样本整理成npy格式 data = np.load("training_data.npy", allow_pickle=True).item() x_train = torch.tensor(data["states"], dtype=torch.float32) y_train = torch.tensor(data["controls"], dtype=torch.float32) model = Net() optimizer = optim.Adam(model.parameters(), lr=1e-3) loss_fn = nn.MSELoss() # 训练100个epoch,batch size 256 dataset = torch.utils.data.TensorDataset(x_train, y_train) dataloader = torch.utils.data.DataLoader(dataset, batch_size=256, shuffle=True) for epoch in range(100): for xb, yb in dataloader: optimizer.zero_grad() loss = loss_fn(model(xb), yb) loss.backward() optimizer.step() if epoch % 10 == 0: print(f"epoch {epoch}, loss {loss.item():.6f}") torch.save(model.state_dict(), f"ctrl_net_epoch{epoch}.pt")训练过程中有几个细节值得注意。首先是损失函数的选择,我一开始直接对四个输出通道做均方误差,结果模型学偏了:对推力通道拟合得很好,对力矩通道则误差很大。原因是不同通道的量纲和数值范围差异较大。解决办法是给损失函数加权重,推力权重0.4,三轴力矩各0.2,或者更简单的做法是先对标签做归一化,让每个输出通道在[0,1]范围内。
其次,训练集要覆盖足够多的工作点。只飞平飞状态,模型就只能学到一个窄域内的映射;一旦遇到大角度机动,输出直接发散。我的经验是航线里混入几段快速横滚和俯仰机动,让模型在极端状态附近也有数据兜底。实测证明,这比单纯增加总数据量更能提升鲁棒性。
4.3 仿真验证:先离线回放,再在线干预
模型训练完以后,不要急着接到PX4里。先在离线环境下做一次“开环回放”:用之前录好的真实状态序列喂给神经网络,看它输出和PID输出差多少。这一步能快速筛选掉那些明显没学好的模型——如果离线输出都对不上,在线闭环就更不可能稳定。
离线测试通过后,再进入在线验证。PX4里预留了mavros的外部控制接口,可以通过MAVLink的消息直接在外部覆盖姿态控制器的输出。我习惯在测试阶段保留PID控制逻辑,把神经网络推理结果作为“建议量”叠加在PID输出上,先积分一个很小的系数,逐步增加神经网络的控制权重,观察飞机的响应。
这个“软介入”策略非常重要,它能让你在控制器切换过程中随时退出而不至于炸机。我的做法是写一个简单的Python脚本,运行在机载电脑上,通过MAVLink发送vehicle_attitude_setpoint消息,同时订阅飞机姿态数据,在电脑上实时对比神经网络输出和PID输出。确认神经网络的输出趋势与PID一致,且超调量在可接受范围内,才开始考虑去掉PID,把控制权完全交给神经网络。
5. 嵌入式部署:把神经网络模型塞进飞控的完整过程
5.1 模型转换:从PyTorch到裸机C代码
训练好的PyTorch模型是以张量形式保存的,要在STM32上运行,必须把它转换成纯C语言的结构体。我用的工具链分两步走:先把PyTorch模型转成ONNX格式,再用onnx2c工具把ONNX导出成独立的C文件。如果你不想引入太多依赖,也可以直接用Python脚本把权重矩阵和偏置向量导出为C语言数组,然后手写一个极简的前向推理函数。
以我最终部署的模型为例,导出后的C代码结构大致是:
typedef struct { float weight[32][14]; float bias[32]; } Layer1; typedef struct { float weight[16][32]; float bias[16]; } Layer2; typedef struct { float weight[4][16]; float bias[4]; } Layer3; // 前向推理函数,输入14维状态,输出4维控制量 void nn_control(float* input, float* output) { float h1[32], h2[16]; int i, j; // 第一层:全连接 + tanh for (i = 0; i < 32; i++) { float acc = layer1.bias[i]; for (j = 0; j < 14; j++) { acc += layer1.weight[i][j] * input[j]; } h1[i] = tanhf(acc); } // 第二层:全连接 + ReLU for (i = 0; i < 16; i++) { float acc = layer2.bias[i]; for (j = 0; j < 32; j++) { acc += layer2.weight[i][j] * h1[j]; } h2[i] = acc > 0 ? acc : 0.0f; } // 第三层:线性输出 for (i = 0; i < 4; i++) { float acc = layer3.bias[i]; for (j = 0; j < 16; j++) { acc += layer3.weight[i][j] * h2[j]; } output[i] = acc; } }这段代码看起来简单,但有几个地方必须小心。第一,tanhf在STM32的ARM编译器下可以直接调用,但如果你用的是更老的编译器或者想要极致性能,建议用查表法替代——误差在可接受范围内,速度能快一倍以上。第二,权重数组必须放到.rodata段,不要定义成局部变量或全局变量放到.bss段,否则RAM会白白多占2.7KB。第三,__attribute__((aligned(4)))对齐声明加上,否则部分Cortex-M内核读取未对齐的浮点数组时会触发硬件错误。
5.2 与PX4控制架构的集成
PX4的固件结构里,姿态控制相关的模块主要两个:mc_att_control负责姿态角控制,mc_pos_control负责位置控制。我选择在mc_att_control里做替换,因为姿态控制是整个控制链路的内部环,频率高、实时性强,更容易展现神经网络的性能优势。
PX4模块之间的通信全部走uORB协议。模块启动时订阅vehicle_attitude_setpoint(期望姿态)、vehicle_attitude(实际姿态)、vehicle_angular_velocity(角速度)等消息,发布的则是actuator_controls_0(电机控制量)或vehicle_torque_setpoint(力矩期望)。
集成神经网络推理的修改核心,是在ModuleParams初始化阶段调用nn_control前将权重加载好,然后在run()函数的控制循环内,把原先PID控制律计算的部分替换成对nn_control的调用。控制循环主频是250Hz,也就是说每4ms要完成一次完整的推理和控制量输出。
修改完成后,先编译一个SITL目标,在Gazebo里做闭环测试:
make px4_sitl gazebo如果Gazebo里飞机的姿态能稳定收敛到目标值,再编译HITL目标,在真飞控上验证嵌入式性能。需要特别提醒的是,PX4的编译系统对代码规范要求很严格,新增的C文件要记得写进CMakeLists.txt,不然链接阶段会报未定义引用,这个错误很隐蔽,第一次做的人容易卡住。
5.3 实机调参的几条重要经验
第一次在真机上跑神经网络控制,不要直接起飞,先做桨叶锁定测试或者低油门测试。我把电机桨叶拆掉,在仿真模式下先跑控制循环,观察输出是否在预期范围内。确认没有异常输出后,再接电池真机验证。
这里有几个我在HITL和真机上总结出来的经验:
- 启动时最危险。飞控上电瞬间,如果没有状态估计,姿态角速度接近零,神经网络可能会输出一个异常的大指令。必须在外层加防呆逻辑:当状态估计无效或陀螺仪数据未就绪时,强制输出零控制量。
- 留意控制输出的斜率限制。神经网络的输出是连续值,不像PID有积分饱和和限幅保护,直接给电机可能造成电流冲击。我用的方法是,在神经网络输出后面串一个一阶低通滤波器,时间常数取0.05秒,实测能显著降低电机啸叫声和机身抖动。
- 记录日志的习惯要提前养成。PX4自带ULog日志系统,在
param set SYS_LOGGER 1开启扩展日志后,能把神经网络输入、输出的实时数据记录下来。上真机之前一定把日志配置好,不然后面出了问题根本无从排查。
6. 常见问题与踩坑实录
6.1 仿真发散的根源排查
我训练的第一个神经网络模型,在Gazebo里一接上就发散,飞机瞬间翻转冲天。当时第一反应是“模型没训练好”,但重新训练了三个版本还是一样,后来冷静下来排查才发现,问题根本不在网络本身,而在数据预处理。
我训练时用的是归一化后的状态数据,输入范围在[-1,1]之间,但部署时忘了在推理前面加上同样的归一化步骤——原始角度误差是0.5弧度,直接丢给网络,第一层加权求和后的值一下就饱和了,tanh输出全部卡死在±1,模型自然就废了。
这个问题想提醒大家:仿真到部署的迁移过程中,“数据管线的一致性”比模型精度更重要。训练时做了哪些预处理,部署时就必须照搬,一个都不能少。
6.2 浮点性能与算力瓶颈
STM32F765的FPU能处理单精度浮点,但处理速度远不能和桌面CPU相比。我的网络是692个参数,每次推理约3700次乘加运算,实测耗时0.35ms,占控制周期8.75%,完全没问题。但如果你想用更深的网络或者更大输入维度,就要认真做性能预算了。
一个典型的性能预估方法:4ms控制周期内,预留50%给神经网络推理,也就是2ms,换算成乘加次数大约是2ms×400MHz×1次/周期/2(FMAC算一条指令)≈40万次MAC。这意味着全连接网络参数上限大概在40万左右,超过这个规模就得考虑模型压缩了。我用的量化和剪枝策略比较简单:把权重从FP32转为int8,同时把tanh换成查表激活函数,推理时间降到0.2ms,精度损失在控制回路里几乎感知不到,代价是部署代码复杂度上升了一些。
6.3 仿真与实机的迁移差距
仿真里调得再好,真机上手还是会发现差距。几个最常见的“仿真到现实”鸿沟,我按严重程度排序:
首先是传感器噪声。Gazebo的传感器模型太“干净”,陀螺仪和加速度计的噪声功率谱和真实MEMS器件差距很大。神经网络如果学到了对传感器信号的过度敏感特征,在真机上就会被噪声完全淹没。解决办法是在仿真里故意加大传感器噪声系数,或者在数据增强阶段给训练样本加随机扰动。
其次是电机和执行器的延迟。仿真里的电机模型是理想化的响应曲线,真机的电调(ESC)有约0.1到0.2秒的响应滞后,桨叶也有转动惯量。神经网络控制器如果在训练时没见过这个延迟,输出会“领先”于实际执行,表现为持续震荡。我的处理方式是在训练数据里引入一个时间偏移量:训练输入使用t时刻的状态,输出对应t+Δt时刻的真实控制量,相当于让模型学会“预测”执行器延迟后的效果。
最后是机架振动。真机上高转速电机会产生高频振动,这些振动会通过机架传到IMU,如果模型输出对这些高频成分敏感,飞机会抖得像筛子一样。这方面没有完美的仿真替代方案,只能在真机测试中逐步调整滤波器参数,或者在网络输入侧加一个移动平均滤波器。
6.4 常见问题速查表
| 问题 | 可能原因 | 排查方法 |
|---|---|---|
| 仿真接入神经网络后立即发散 | 输入未归一化 / 控制方向接反 | 对比PID日志和NN输出的符号与幅值 |
| 训练loss已经很低,但闭环不稳 | 训练数据覆盖不全 / 输入输出时间对齐错误 | 可视化输入输出曲线,检查相位差 |
| STM32上推理时间超过控制周期 | 网络层数过深 / 用了double类型 | 打印每层耗时,改用FP32或int8 |
| 真机悬停时高频抖动 | 传感器噪声被模型放大 | 调低控制回路上限频率,增加低通滤波 |
| 飞控上电后电机立刻全速转 | 状态估计无效时NN输出未钳制 | 增加初始化保护和输出限幅逻辑 |
| 编译通过但链接报未定义引用 | 新增C文件未加入CMakeLists | 检查模块的CMakeLists中SOURCES列表 |
7. 项目可扩展的方向
神经网络控制这个方向,做完姿态环替换之后还能延伸出很多玩法。我目前正在尝试的是把神经网络用于位置环,也就是直接学习“期望位置到期望姿态”的映射。这个任务对数据量的要求更高,输入是位置误差、速度误差、加速度前馈,输出是横滚、俯仰、偏航角指令,训练难度比姿态环上了一个台阶。
另外,我还在考虑在PX4里部署强化学习模型的可能性。传统监督学习需要先有专家数据,强化学习则是让智能体在仿真中自己探索策略。PX4结合OpenAI Gym的接口可以用RL训练端到端的控制器,但部署时会遇到一个难题:RNN或者带LSTM的结构在STM32上推理成本较高,目前还在调研TinyML的推理引擎方案。
还有一个很实用的方向是异常状态检测。既然能把神经网络搬进飞控,自然也可以训练一个小型自编码器,用来监控IMU数据分布,提前发现传感器异常或剧烈振动,作为现有失败保护机制的补充。这类模型更小,部署难度更低,个人觉得比替换主控制器更容易落地。
8. 写在最后的一点体会
这个项目做完,我最大的感受是:神经网络控制真正难的,不是那一堆数学公式,也不是模型的训练技巧,而是整个工程链条的闭环能力。你在PC上把网络调得再好,只要中间任何一节的格式不对、时序不对、精度不够,到了飞控上就是炸机或者完全不动。这也是为什么我在这篇文章里花了很多篇幅在讲部署细节而不是训练理论——在飞控这个场景里,工程能力往往比paper里的算法更能决定项目的成败。
另外还有一点想分享给正在做类似项目的朋友:从仿真到实机,不要指望一次成功。每次迁移跨越一个坎,都要留足时间和耐心去观察分解每一个异常。我最初的训练-部署周期是一周一次,后来把测试管线理顺后,能做到一天一个迭代。这个提速靠的正是前面那些规范化、日志化、可观测化的基础工作。
如果你也在做PX4相关的二次开发,或者正打算把神经网络往嵌入式设备上搬,欢迎在实际操作中遇到问题时多交流。这个方向还很新,大家踩过的坑,都是这个领域共同的宝贵经验。