四旋翼飞控系统全链路技术拆解

发布时间:2026/5/30 18:06:19

四旋翼飞控系统全链路技术拆解 我用 STM32 做了一个四旋翼飞控系统从遥控器到电机控制的完整链路拆解如果只看“飞控”这两个字很多人第一反应会是 PID、姿态解算、四元数这些控制算法。但当我真正把一个小型四旋翼项目做起来之后我越来越确定一件事能让无人机真正稳定跑起来的从来不只是某个控制公式而是从输入采集、无线传输、状态机切换到电机输出这一整条控制链路是否闭环。这篇文章想拆的就是我这个基于STM32F103 FreeRTOS的四旋翼项目。整个系统分成两个部分一个是遥控器端负责采集摇杆和按键输入并通过SI24R1无线模块发送控制数据另一个是飞控端负责接收数据、判断当前飞行状态、进行姿态解算和 PID 控制最后输出 PWM 去驱动四个电机。传感器侧主要用到了MPU6050做姿态感知用VL53L1X做定高测距。所以本文不会把重点放在“PID 公式推导”上而是更关注一个真实工程是怎么把这些模块串起来的。你会看到这并不是一个单板上的 PID demo而是一个真正具有“双端协同 实时任务调度 无线协议 状态机保护逻辑”的完整控制系统。一、先看全景这个系统到底是怎么串起来的先从最上层看整个项目的职责分工。遥控器端负责 5 件事采集四个摇杆通道的模拟量。扫描按键事件生成微调、关机和定高切换命令。把控制数据打包后通过无线模块发给飞控端。接收飞控端的回传信息并显示到 OLED 上。周期性给电源模块保活防止自动断电。飞控端负责另外 5 件事接收并校验遥控器发来的数据包。判断遥控链路是否在线并推进飞行状态机。读取 IMU 数据并完成姿态解算。执行姿态环和高度环 PID。把控制量混合成四路电机 PWM 输出并在异常时执行保护逻辑。如果把这套系统压缩成一条最核心的数据流大致就是这样摇杆/按键 - remote_data - SI24R1 无线发送 - 飞控端 remote_data - flight_state 状态决策 - 姿态解算 PID - 电机混控 - PWM 输出 - 电压/遥测回传这条链路里最重要的一点是遥控器端和飞控端并不是各写各的。两边通过同一个Remote_Data语义对齐遥控器端负责生产“合法、稳定、可解释”的控制量飞控端负责消费这些控制量并把它们纳入状态机和控制算法中。二、遥控器端不是“读 ADC 然后发出去”这么简单在源码里遥控器侧通过freertos_start()创建了 5 个任务分别是TP4336_Task电源保活任务。Com_Task无线通信任务。Key_Task按键扫描任务。Joystick_Task摇杆采样处理任务。OLED_Task屏幕显示任务。这些任务不是随便拆的而是按实时性和职责分离开的。比如通信任务 10ms 一次摇杆任务 20ms 一次OLED 100ms 一次说明作者并不想把所有业务都塞进一个大循环里而是希望不同工作按自己的节奏稳定运行。对这种实时控制系统来说这种节拍分层比“代码看起来整齐”更重要。1. 摇杆数据为什么不能直接发原始 ADC遥控器端最核心的处理函数之一是App_process_joystick_data()。这个函数做的事情不是简单地把 ADC 值读出来而是把原始采样加工成飞控端真正能用的协议数据。它的处理流程是读取 4 路摇杆 ADC 数据。把0~4095的原始值映射到0~1000。扣除校准得到的零点或中点偏移。叠加方向键带来的软件微调量。对结果再次限幅防止越界。把结果写回待发送的remote_data。这一步非常关键因为飞控端根本不应该关心“你的 ADC 这次采到 2317 是什么意思”。它只应该收到统一语义的控制量比如油门thr是0~1000偏航/俯仰/横滚是以500为中位的0~1000。这样协议才稳定两端的控制语义才不会漂。源码里甚至把这个映射关系写死成了固定规则ADC 越大输出越小。这意味着遥控器端已经帮飞控端把硬件层和协议层隔离开了。2. 为什么要做校准而且校准前先清空微调量这个项目里摇杆不是一次性标定完就结束而是支持运行中重新校准。对应的函数是App_calibrate_joystick()。校准逻辑也很朴素但很工程化连续采样多次。统计当前值与目标值之间的偏差。取平均值作为新的偏移量。其中油门的目标是0偏航、俯仰、横滚的目标是500。这和飞控端对Remote_Data的理解正好对应上。更细的一点是校准前代码会先把按键微调量清零。原因很简单校准要修的是硬件摇杆本身的零点偏差而不是人为按键操作带来的软件补偿。如果不先清空微调量校准结果就会把“人手动加上的修正”也当成“硬件本身偏了”最终反而把校准做歪。3. 为什么上下左右只微调俯仰和横滚按键处理逻辑在App_process_key_data()里。方向键不是直接控制飞行而是作为微调输入上下键调俯仰。左右键调横滚。KEY_LEFT_X短按发关机命令。KEY_RIGHT_X短按发定高切换命令。KEY_RIGHT_X长按触发摇杆重新校准。这里没有让方向键去改油门和偏航其实是个很合理的选择。因为俯仰和横滚的微调更像姿态中位补偿而油门和偏航往往更敏感、更连续不适合做这种离散按钮微调。4. 为什么这里要用临界区App_process_joystick_data()在读 ADC、做换算、再写回remote_data时使用了taskENTER_CRITICAL()。这不是为了“高级”而是为了避免 DMA 正在更新 ADC 缓冲区时本轮任务刚好读到一半旧数据、一半新数据造成通道值不一致。对于遥控器这种输入源来说一次不一致的数据也许不会立刻炸机但它会让控制信号变脏。加临界区的意义就是尽量让这一轮通道值更新保持原子性让飞控端收到的是同一时刻意义上的输入快照。5. 功能位为什么做成“沿触发”遥控器端的无线发送函数是App_TransmitData_data()。除了四个通道值它还会发送两个功能位shutdownfix_height但这两个量不是持续状态而是事件触发。源码里的处理方式很典型把它们装进本帧之后立刻清零。这样做的目的是避免重复触发。因为通信任务是周期跑的如果某个按键置位一次之后不清零那么后面每 10ms 都会继续把这个命令发出去。对飞控端来说这就不是“用户按了一次按钮”而是“用户每 10ms 都在疯狂重复按这个按钮”。像关机、定高切换这种功能显然不适合这样处理所以这里必须做成沿触发。三、无线协议不复杂但它决定两端能不能正常对话很多人一提协议就容易想到很复杂的编码、压缩或者多机通信但这个项目的协议其实非常直接只要能稳定区分合法数据、解析字段并校验完整性就已经够用了。在App_TransmitData_data()和App_ReceiveData()里整帧数据格式是这样的Byte0~2 : 3 字节固定帧头 Byte3~4 : thr Byte5~6 : yaw Byte7~8 : pit Byte9~10 : rol Byte11 : shutdown Byte12 : fix_height Byte13~16 : 前 13 字节累加和这个设计很“工程派”因为它解决的都是最实际的问题。1. 帧头解决“这包是不是我要的”前三个字节不是校验和而是固定帧头。它的意义不是检错而是快速识别“这是不是一帧合法遥控数据”。这样飞控端收到乱序、错位或者无效包时可以先第一时间过滤掉不必往后继续解析。2. 累加和解决“这包传输过程中有没有坏掉”飞控端在App_ReceiveData()里会对前 13 个字节重新求和并和帧尾 4 字节的校验值比较。如果不一致就直接认为这包无效。这个校验方式并不高级但它足够轻量非常符合 STM32 这类资源受限场景。更关键的是它把“坏包”挡在了控制逻辑之外避免状态机和 PID 去消费不可靠输入。3. 为什么发完以后要切回接收模式遥控器端在发包时会先SI24R1_TX_Mode()发完之后马上SI24R1_RX_Mode()。飞控端在成功收包后也会短暂切到发送模式把回传缓冲区back_buff发回去然后再切回接收模式。这意味着无线链路并不是单向的“我发你收”而是形成了一个很轻量的双向往返遥控器发控制命令。飞控端收命令并回传遥测信息。遥控器再读回传结果并用于显示。从系统层面看这一点很重要因为它让项目从“遥控器控制玩具”变成了“有输入也有反馈的实时控制闭环”。四、飞控端真正的核心是任务编排和状态推进飞控端上电后的主入口很简单main()初始化完外设后调用freertos_start()后续业务逻辑都交给 FreeRTOS 任务接管。从架构上看这是一种很典型也很合理的写法硬件初始化和业务控制分层主函数只负责把系统拉起来真正的飞控行为由各自职责明确的任务持续运行。1. 四个任务各管一摊但彼此又围绕共享状态协同飞控端在freertos_start()里创建了 4 个任务Power_TaskFlight_TaskLED_TaskCom_Task如果只看名字它们像是独立模块但如果看实际数据流会发现这些任务围绕 3 类共享状态在协同remote_data当前遥控输入。remote_state遥控器连接状态。flight_state当前飞行主状态。这 3 个变量几乎就定义了整个飞控端的行为边界。通信任务负责更新输入和连接状态飞行任务负责根据这些状态执行控制LED 任务负责把状态外显出来电源任务则负责对关机命令做最终动作。2. 通信任务不是“收个包”而已Com_Task其实承担了飞控端很大一部分调度工作。它的执行顺序大致是调App_ReceiveData()收并解析一帧数据。调App_process_connect_state()更新当前连接状态。如果remote_data.shutdown 1通过任务通知唤醒Power_Task执行关机。调App_process_flight_state()推进飞行状态机。读取电池电压并格式化写入back_buff用于回传。也就是说通信任务不只是一个“驱动搬运工”它实际上是飞控端“外部输入如何影响内部状态”的总入口。遥控器输入想要真正改变飞行行为必须先经过这里的收包、校验、连接判断和状态推进。3. 为什么飞控任务必须固定周期运行Flight_Task是飞控主环。它使用vTaskDelayUntil()以固定周期运行周期是 6ms大约对应 166Hz。每个周期里它按顺序做 4 件事App_flight_get_euler_angle()更新当前姿态角。App_flight_pid_process()执行姿态串级 PID。App_flight_fix_height_pid_process()在定高模式下按分频运行高度环。App_flight_control_motor()输出四个电机的最终速度。这里最值得注意的是“固定周期”这件事。控制算法不是普通业务逻辑周期抖动会直接影响微分项、积分项和整体动态响应。所以这里没有用简单的vTaskDelay()而是用了vTaskDelayUntil()去尽量保持节拍稳定这就是典型的实时控制思维。4. 电源任务为什么用任务通知而不是轮询Power_Task平时的主要工作是每隔一段时间执行 TP4336 保活防止电源自动断电。但它还有一个更敏感的职责收到关机请求后要立刻执行关机动作。这里的实现非常干净任务内部通过ulTaskNotifyTake()等待通知如果等到通知就关机如果等待超时就执行一次保活。这个设计的好处是一套阻塞等待机制同时复用了两种行为没事发生时它周期性保活。有关机请求时它及时响应。这样既避免了无意义轮询也让关机命令能以更低开销、更直接的方式送到电源任务。五、姿态解算和串级 PID决定了这架飞机“飞得稳不稳”如果把前面的协议、任务和状态机看成系统骨架那么姿态解算和 PID 控制就是这套飞控真正的“肌肉”。1. 姿态角不是直接读出来的而是算出来的在App_flight_get_euler_angle()里项目并没有直接使用某个现成欧拉角输出而是自己走了一遍典型的传感器融合流程从MPU6050读取陀螺仪和加速度计原始数据。对陀螺仪做一阶低通滤波。对加速度计三轴分别做一维卡尔曼滤波。再根据融合结果估算欧拉角。这么做的原因很现实。陀螺仪对短时动态响应快但噪声和漂移问题明显加速度计能提供重力方向信息但高频抖动更大。把两者直接裸用控制效果通常不会太好所以要先滤波再融合。这个项目里俯仰和横滚主要靠融合后的姿态结果偏航则主要依赖 Z 轴角速度积分。这里也暴露了一个系统边界因为没有磁力计参与约束所以偏航长期稳定性不会像俯仰和横滚那样好。这也是后面可以继续优化的方向之一。2. 姿态控制为什么不用单环 PIDApp_flight_pid_process()的核心不是一个简单 PID而是串级 PID。这条控制链可以概括成遥控器期望姿态 - 外环姿态角 PID - 目标角速度 - 内环角速度 PID - 控制输出具体来说外环的期望值来自遥控器例如(remote_data.pit - 500) / 50.0f。外环的测量值是当前欧拉角比如euler_angle.pitch。外环输出不会直接去控电机而是作为内环的期望角速度。内环的测量值来自陀螺仪角速度。这样做的意义在于把“稳态姿态精度”和“动态响应速度”拆开处理。外环负责飞机最终要达到什么姿态内环负责飞机以多快的速度、用多稳的方式去追这个姿态。对四旋翼这种响应快、耦合强的对象来说串级 PID 比单环更容易调出既不肉、又不炸的效果。3. 为什么高度环不是每个周期都跑高度控制函数是App_flight_fix_height_pid_process()但它并不是和姿态环同频运行。在Flight_Task里作者用了一个height_count做分频4 次姿态环才跑 1 次高度环。也就是姿态环约 166Hz而高度环约 41Hz。这个设计特别像真实工程里的取舍而不是教材里的“所有环路一起上”姿态环要求高频决定飞行器是否稳定。高度变化惯性更大不需要和姿态环一样快。VL53L1X的测距更新频率和有效信息量本身也有限。所以高度环降频运行不是“偷懒”而是为了让慢变量按慢节奏工作避免无意义地重复消耗旧数据还能减轻主控负担。六、真正保证“不失控”的是飞行状态机和保护逻辑如果说 PID 负责“怎么飞”那状态机负责的就是“什么时候允许飞、出了问题怎么办、什么时候必须停”。项目里最核心的状态机函数是App_process_flight_state()围绕Flight_State维护了 4 个状态IDLENORMALFIX_HEIGHTFAIL1.IDLE为什么要做解锁手势系统上电后不会直接让电机进入正常控制而是必须先完成一套解锁流程油门推到高位 900并保持 1 秒。然后拉到低位 100并保持 1 秒。这套逻辑在App_process_unlock()里被实现成一个小状态机而不是简单的 if 判断。原因是这里本质上是一个时序条件问题不是单点阈值判断问题。它的意义很明显防止误触发、避免上电即转、让“允许飞行”成为一个明确的用户动作。对带电机的系统来说这类解锁手势不是花哨功能而是安全边界。2.NORMAL普通飞行模式在NORMAL状态下飞控执行标准姿态控制电机输出以油门为基准再叠加俯仰、横滚和偏航修正。这个状态向外有两个主要分支收到fix_height命令进入FIX_HEIGHT。遥控掉线进入FAIL。也就是说普通飞行并不是唯一目标状态它只是主控制态。一旦需求变化或链路异常系统会立刻切换到别的行为模式。3.FIX_HEIGHT为什么进入时记录“当前高度”进入定高模式时飞控端不会从某个外部目标表里取高度而是直接用当前测距值作为fix_height。这一步特别合理因为定高切换通常不是“飞到 1 米”这样的绝对命令而更像“保持我现在这一个高度”。因此最自然的目标值就是切换瞬间的当前高度。实现上这一步就在App_process_flight_state()里完成收到remote_data.fix_height 1后切到FIX_HEIGHT并把Int_VL53L1X_GetDistance()的当前值记下来作为目标高度。4.FAIL为什么不是立刻停桨当remote_state变成掉线后系统会进入FAIL。但在App_flight_control_motor()里FAIL状态的处理并不是“所有电机立即清零”而是每个周期减 2逐步把四个电机降到 0。这个决策非常有工程味道。因为四旋翼在空中如果直接停桨结果往往不是“安全”而是“更危险”。缓慢减速至少能给飞行器一个相对平滑的失控过渡让它不是瞬间失去全部升力。另外FAIL并不是绝对死状态。如果链路恢复并且油门已经回到底位系统允许回到IDLE。这说明作者在保护逻辑里不仅考虑了“如何失败”也考虑了“失败后如何恢复到安全初态”。七、几个很能体现工程取舍的细节如果只看主流程这个项目已经完整了。但真正能把文章质量拉开的往往是那些看上去不起眼、实际上很有经验味道的小细节。1. 为什么偏航项要先单独限幅在App_flight_control_motor()里偏航输出会先被单独限制到-100 ~ 100然后再参与四电机混控。这不是多此一举而是两层不同目的的保护偏航单独限幅是为了防止偏航项过大挤占俯仰、横滚和油门的控制余量。电机总输出限幅是为了防止最终 PWM 指令超出物理范围。换句话说前者是在保护“控制分配的平衡性”后者是在保护“执行器的边界”。这两个问题不是一回事。2. 为什么低油门时要强制停机源码里定义了APP_FLIGHT_SAFE_THR 50。只要remote_data.thr 50四个电机就会被强制置 0。这一步本质上是一个安全阈值保护。它保证在低油门阶段无论其他姿态环输出怎么抖动都不会让电机误转。对于上电、落地或解锁前后的边界场景来说这个保护非常实用。3. 为什么遥控器电源保活任务要先延时再保活遥控器端的TP4336_Task在循环里不是“先按一下再延时”而是“先延时再按一下”。这背后的逻辑非常细。因为如果系统上电后立刻补发一次保活脉冲就可能和人工开机时的按键动作叠加导致电源芯片把这两次操作识别成连续短按反而误触发关机。这种问题在课本里不会教但在真实硬件系统里非常常见。很多时候系统稳定性不是败在算法而是败在这种“软件时序和硬件行为的耦合细节”上。4. 为什么这个项目的亮点不只是 PID如果让我总结这个项目最值得写进博客的地方我不会只说“我写了一个串级 PID”。更准确的说法应该是我把遥控器端的采集、校准、微调和事件触发整理成了稳定的输入源。我设计了一套足够轻量但可靠的无线协议让双端能稳定同步控制语义。我用 FreeRTOS 把通信、飞控、电源和显示按不同实时性拆开了。我用状态机和失联保护把“能飞”变成了“飞得更安全”。PID 当然重要但它只是这条链路中的一段。真正让项目完整的是前后所有模块都知道自己该做什么、不该做什么。八、总结飞控项目的难点往往在“串起来”而不是“写出来”回过头看这个四旋翼项目最让我有成就感的地方并不是某个 PID 参数终于调顺了而是整套系统真的形成了闭环遥控器端能稳定产生输入协议能可靠传输飞控端能正确推进状态控制算法能给出输出异常场景下还能进入保护逻辑最后再把信息回传回来。这说明它已经不是“几个驱动拼起来”的小练习而是一个包含任务调度、协议设计、状态机、姿态解算、控制算法和故障保护的完整实时控制系统。对于 STM32 项目来说这种从单模块功能走向系统级协同的过程往往比单独掌握某个外设驱动更有价值。如果后面还想继续把这个项目往上做我觉得至少还有几个很自然的方向引入磁力计改善偏航长期漂移问题。优化无线协议的可靠性比如加入序号、应答状态或更明确的重发策略。丰富遥测内容不只回传电压还可以回传姿态、模式、电机输出等关键状态。单独整理一篇 PID 调参记录把“为什么这组参数能飞”讲得更具体。如果你也在做类似的 STM32 飞控小项目我很推荐你不要只盯着 PID 公式看而是回过头检查一下整条控制链输入是不是稳定、协议是不是清晰、状态机是不是完整、保护逻辑是不是可靠。很多时候项目的上限就藏在这些地方。

相关新闻