做飞控二次开发这么久,我越来越觉得ArduPilot的RC输入模块是被低估的一块代码。很多人一上来就扎进EKF、姿态解算,被一堆矩阵劝退,却忽略了最底层的一个事实:多旋翼、固定翼、车、船、直升机,所有载具的控制逻辑,起点都是遥控器拨杆产生的那一串PWM脉宽。这套信号从接收机引脚进来,经过解码、校验、映射、归一化,最后变成姿态控制器嘴里的目标角。这条链路上任何一个环节出问题,后面所有高级算法全是空转。这篇文章是系列第五篇,我就沿着"信号从物理引脚进来之后到底经历了什么"这条线,把ArduPilot的RC输入代码链路完整拆开,适合正在啃源码的开发者,也适合想搞懂"为什么校准之后通道才准"的玩家。
1. 先看信号从哪里进:RC输入模块在整个飞控代码地图里的位置
很多人读ArduPilot源码有个误区,喜欢按照目录结构从头读到尾,结果在libraries里迷路。RC输入这部分其实很适合反着读:先从载具主循环里找一个叫read_input或者update_rc_input的调用点,然后一路往回追函数调用链,最后才会碰到硬件抽象层。
1.1 从物理引脚到统一接口:AP_HAL的RCInput层
ArduPilot把硬件相关的代码全部收在AP_HAL层,RC输入也不例外。无论你用的是哪款飞控板,只要底层实现了RCInput接口的read()、new_input()、num_channels()这几个方法,上层代码就不用关心你接的是PPM接收机还是串口SBus接收机。
这里有一个关键点:接收机的物理协议五花八门,传统的PWM每个通道一根信号线,PPM把所有通道叠在一根线上靠脉宽间隔区分,SBus和DSM则是串口数字协议,CRSF更进一步把RC和遥测合并。ArduPilot在ChibiOS版本里用AP_RCProtocol这个库把所有协议解析统一收口,它的职责就是"无论什么样的物理波形,最终都吐出一个uint16_t的数组,每个元素表示一个通道的PWM值,范围通常是800到2200微秒"。
// 示意代码,AP_HAL风格 class RCInput { public: virtual uint16_t read(uint8_t ch) = 0; // 读指定通道最新PWM值 virtual uint8_t num_channels() = 0; // 当前有效通道数 virtual bool new_input() = 0; // 是否有新数据到达 virtual bool set_overrides(const uint16_t *overrides, uint8_t num) = 0; };我在做某个模拟飞控项目时,需要把串口模拟摇杆数据灌进主循环,就是在这一层动手的:自己写一个假的RCInput实现类,在read()里返回虚拟的PWM值,上层的姿态控制代码一点不用改。这个抽象设计是RC输入部分最值得先理解的地方——协议层和解耦层分开,你才不会被各种接收机的差异带偏。
1.2 新旧版本的协议解析:AP_RCProtocol与RCInput的分工变化
老版本ArduPilot里,协议解析逻辑直接写在AP_HAL_ChibiOS/RCInput.cpp里,代码会按照配置去初始化定时器捕获通道,然后用中断或者DMA读取脉冲宽度,再自己写状态机解析PPM帧。到了4.x之后,协议解码被拆到独立的AP_RCProtocol库,RCInput只负责维护采样结果和DMA缓冲区。这样做的直接好处是:SITL仿真、Linux环境、ChibiOS平台可以复用同一套协议解析代码,不必每个平台各写一套。
如果你想验证自己板子上的RC输入有没有信号,不用急着上示波器,直接在飞控控制台跑一下rc prot这类诊断命令,能看到当前协议类型、帧率、通道数和每个通道的裸值。这个命令背后的实现就是遍历AP_RCProtocol的状态结构体,把num_channels()和read()结果打印出来。先确认协议层已经锁定了,再往上查问题,效率会高很多。
2. RC_Channel:整条RC输入链路的"户口本"
如果你理解了协议层只是"翻译官",那RC_Channel就是转译之后的"户籍系统"。ArduPilot里整个RC输入的处理,几乎都围绕RC_Channel类展开。这个类做的事情远比一个简单的数据结构复杂:它承载了通道校准参数、辅助功能绑定、输入校验、反向和死区逻辑。
2.1 物理通道号与语义通道的映射
接收机解码出来的0号通道、1号通道,本身没有任何语义。飞控必须知道"第3个通道是油门"才能做出正确反应。ArduPilot用一组RCMAP_参数完成这个映射,比如RCMAP_PITCH、RCMAP_ROLL、RCMAP_THROTTLE、RCMAP_YAW,它们的值默认是接收机通道序号。映射这一步由AP_RCMapper完成,注意它返回的是"通道号对应关系",而不是直接返回PWM值。
// 示意代码:逻辑通道号到物理通道号的映射 uint8_t mappable_channel = RCMAP_THROTTLE; // 假设用户设置 RCMAP_THROTTLE = 3,这里返回3, // 载具再拿3去RC_Channel的一维数组里取值我踩过一个很典型的坑:调试某个地面车辆项目时,换了接收机之后发现推油门车子反着走,检查半天才发现问题是新接收机油门通道输出反向了。这个问题的本质不在RC_Channel层,而在于很多接收机允许用户单独反转某个通道的输出方向。所以遇到"通道反了",先查接收机端设置,再回头看飞控的RCn_REVERSED参数,不要一上来就改代码。
2.2 通道校验、反向和死区这些逻辑在类里面怎么落位
RC_Channel这个类内部,每个通道对应一组参数,包括RCn_MIN、RCn_MAX、RCn_TRIM、RCn_REVERSED、RCn_DZ。这些参数不只是给地面站校准用的,运行时它们会在多个函数里被反复消费。
get_radio_in():返回经过反向处理之前的原始PWM值,主要用于地面站显示。norm_input():把当前输入值归一化到-1到1的范围,中间会代入MIN、MAX、TRIM进行线性计算。reversed标志位:在归一化之前或之后乘以-1,具体取决于版本,但作用一致。dead_zone:在摇杆中位附近定义一个"无效区间",小于这个区间内的微小波动会被强制当作中位值,避免悬停时的通道抖动传导进控制器。
这里有个值得展开的细节:norm_input()计算时分母不是简单的(MAX - MIN)。APM的经典做法是考虑摇杆中位两侧不对称的情况,用它得到"满行程对应的斜率"。如果你只是简单套用线性映射,忽略TRIM不对称,推杆到中位时会发现输出不是0。类似的非线性问题在早期PID调参时很容易被当作PID问题处理,其实源头在通道映射。
2.3 辅助通道的RC_Channel_Aux:不是所有通道都用于飞行控制
除了最基本的四个飞行控制通道,RC_Channel还派生了RC_Channel_Aux,用于Channel Option功能,比如锁定、解锁、返航、自动模式切换。这些功能通过RCx_OPTION参数绑定,代码上就是一个从option值到功能回调的switch分发。这个设计让辅助通道的价值大增,一条3段拨杆开关就能切换飞行模式,而不需要在地面站里临时去写逻辑。
理解RC_Channel_Aux有一点很重要:辅助通道和飞行控制通道走的是同一套read_input()流程,但消费方式完全不同。飞行控制通道的值被送入姿态期望生成,辅助通道的值则被翻译成离散状态,比如"开关位置是否越过阈值50%"。所以辅助通道的TRIM和DZ通常影响不大,重要的是MIN/MAX是否合理。
3. 从摇杆动作到控制量输出:RC输入的主执行路径
现在把所有代码片段串起来,看一条完整的调用链:你推油门,接收机发出PPM/SBus信号,飞控解码,RC_Channel读入,然后归一化,最终变成目标油门或目标角度。这里面的每一步都在哪里发生,非常值得细细梳理。
3.1 read_input():RC输入处理的主入口
在载具主循环的高频率段,会调用一个统一入口函数,名字在不同版本里略有差别,但核心逻辑都落在RC_Channel::read_input()。它的基本行为可以概括为四件事:从HAL层读最新数据、判定有没有新帧、把PWM值按通道填入对应的RC_Channel成员、更新输入状态标志。
// 示意代码:read_input()的逻辑骨架,非完整源码 void RC_Channel::read_input() { if (!hal.rcin->new_input()) { // 没有新数据,通常说明接收机掉线或协议未锁定 return; } for (uint8_t i = 0; i < num_channels_available; i++) { RC_Channel *chan = &rc_channels[i]; uint16_t pwm = hal.rcin->read(channel_map[i]); if (pwm >= RC_CHANNEL_MIN && pwm <= RC_CHANNEL_MAX) { chan->set_radio_in(pwm); } else { chan->set_radio_in(0); // 非法值清空 } } }这段示意代码里,RC_CHANNEL_MIN和RC_CHANNEL_MAX是一对底线范围值,正常情况下大约是800到2200微秒。注意它和用户校准出来的RCn_MIN/MAX不是一回事:一个是物理层"是否像正常PWM"的粗过滤,另一个是语义层"行程范围"的精细映射。理解这个双层校验,后面排查"通道值不在范围内"时会少走弯路。
3.2 主循环里RC输入和姿态控制的交接点
在ArduCopter代码里,主循环的调度顺序大概是:快速循环读取传感器、更新RC输入、执行模式逻辑、姿态解算、输出电机。RC输入通常在姿态控制之前执行,因为姿态控制器需要最新的目标值。
具体到代码位置,Copter里有一个update_rc_input()或者类似命名的函数,它会把get_radio_in()获得的原始PWM值通过norm_input()和angle_max转换为目标角度。比如横滚通道:
// 示意代码:横滚通道如何变成期望姿态角 float roll_input = channel_roll->norm_input(); // -1 到 1 target_roll = roll_input * aparm.angle_max; // 乘以最大倾斜角这行代码看起来简单,却是RC输入整条链路的核心价值所在。很多新手改完RC映射后,发现拨杆没反应,排查时却忘了先看norm_input()输出到底是不是预期范围。如果RCn_MIN/MAX/TRIM没有正确标定,norm_input()返回的值可能永远是偏的,姿态控制自然不正常。
3.3 不同载具对RC输入的消费差异
虽然RC输入的入口是统一的,但不同载具对通道的消费方式不同。多旋翼的油门通道经过曲线映射后直接关系电机转速期望,固定翼则是把升降舵和副翼通道映射成舵面偏转角,车和船通常只关心油门和转向两个通道。这些差异不在RC输入模块本身,而体现在载具类型各自的set_servo或者控制律代码里。所以当你读RC输入代码时,不要指望找到"油门到底怎么影响电机PWM"的最终答案,那属于输出混控阶段。RC输入层的职责到"产生归一化目标值"为止。
4. 信号丢失和通道数据异常:RC输入代码里的"兜底机制"
RC输入最怕的事情不是值不精准,而是信号突然没了。ArduPilot对这个问题做了多层防护:协议层报"无新数据",RC_Channel层报"通道值无效",载具层再决定要不要触发failsafe。这三层各有各的职责,缺一不可。
4.1 协议层如何感知接收机失联
AP_RCProtocol内部维护帧间隔,正常PPM或SBus信号会周期性产生新帧。如果超过一定时间没有完整帧到达,new_input()就会返回false。问题来了:有的接收机在失联时会输出固定不变的PWM保持值,此时协议层觉得"有输入",但值没变,这个状态单靠RC输入模块是无法判断的,需要在更高层的数据监控去感知。
所以在实际项目里,我会把RC输入的"有没有新数据"和"数值是否变化"分开看。如果板子串口里打印new_input()==true但数值恒定,多半是接收机保持模式在作祟,而不是代码bug。处理方式有两种:一是用接收机失控保护功能设置一个超出发送范围的PWM(比如低于800),另一个是在载具代码里加"数值长期不变"的判定逻辑。
4.2 通道值非法时RC_Channel怎么处理
读入的PWM如果不在800到2200的范围内,set_radio_in(0)这个处理很关键。因为在ArduPilot的约定里,radio_in == 0通常表示该通道无效或者从未收到过有效值。很多辅助功能判断"通道是否可用"就依赖这个0值标记,而不是随机填入的异常数值。
这种设计的好处是:即使某个物理通道坏了,载具依然可以用其他正常通道维持基本功能。但副作用是,如果你在二次开发时直接给radio_in赋一个2000这样的合法值,却不更新radio_in_last,后续的failsafe逻辑可能认为通道从未更新,从而触发保护。我见过有人为了方便测试直接改通道值,结果飞机一直进入failsafe,找半天才明白是这个原因。
4.3 failsafe的触发权在载具层而不是RC输入层
RC输入模块通常会更新一些全局状态,例如"遥控器是否可用"、最近一次信号时间,但最终是否执行RTL、是否切自稳模式,决定权在载具自己的failsafe逻辑。以Copter为例,它会在主循环里调用类似failsafe_radio的检查,根据自己的FS_THR_ENABLE参数决定动作。
这个分层设计值得写进你的代码阅读笔记:下层只负责"报告事实",上层才负责"做决策"。如果哪个版本把决策逻辑写进RC输入里,代码很快就会变得一团糟。理解了这一点,你再去看遥控器失联后的行为,就不会纠结"为什么RC模块没触发RTL"这种错位问题。
5. 校准参数的代码之旅:RCn_MIN/MAX/TRIM到底在哪里发挥作用
很多人在地面站做过遥控器校准,以为那就是"把滑块拉到两端、点确认"的简单操作。其实校准动作背后对应着一系列参数写入,而这些参数在RC_Channel运行代码里有一整套消费逻辑。
5.1 地面站校准与代码参数的关系
地面站的RC校准流程本质上是在做一次"观察采样":让你把每个摇杆推到最大、最小、中位,然后把采样到的PWM值固化成参数。比如你推油门到最高,地面站看到的当前值如果是2000,那它就把RC3_MAX写成2000。之后运行时norm_input()就按这个2000计算满行程。
这个机制意味着,更换接收机或者修改了接收机端点之后必须重新校准,否则映射基准全是错的。我遇到过一个很刁钻的场景:某遥控器有两个油门曲线模式,用户来回切换后忘了重新校准,结果起飞时油门中位对应的PWM不是1500,变成1600,导致无人机一直有上升趋势。这本质上不是代码问题,而是校准数据与物理设备不匹配。
5.2 参数在运行代码里的消费顺序
下面这张表总结了我调试RC问题时最常用的几个参数和它们的代码消费场景:
| 参数 | 含义 | 主要消费位置 |
|---|---|---|
RCn_MIN | 行程最低点PWM | norm_input()归一化计算 |
RCn_MAX | 行程最高点PWM | norm_input()归一化计算 |
RCn_TRIM | 中位PWM | norm_input()中位偏移修正 |
RCn_REVERSED | 方向反向 | 归一化后乘-1 |
RCn_DZ | 死区宽度 | norm_input()中位附近零化 |
RCMAP_* | 物理通道到逻辑通道映射 | 通道取值前的索引映射 |
注意RCn_TRIM和物理上的信号中位不是一回事。遥控器的微调会改变发射机输出的中位值,所以校准TRIM的目的就是把"你觉得是中位的位置"对应的PWM标定出来。如果微调动了但没重新校准,飞控眼里中位就是偏的。
5.3 三个最容易被忽视的校准问题
第一,机械微调归零问题:有的遥控器即使你把摇杆物理放中,输出也可能是1510,不校准就没法修正。第二,多段开关的校准问题:辅助通道如果用的是两段或三段开关,校准时要拨到每一段都采样,否则后续Channel Option阈值判断会出错。第三,PWM极值被接收机压缩的问题:部分接收机在低电压时会压缩输出范围,校准后如果供电不稳,实际数值会飘,这就不是软件能完全兜住的了,需要检查供电。
6. 调试技巧与排错经验:遇到RC相关故障时我常用的定位方法
最后分享一些我实际排查RC输入问题时的思路。这部分内容教不会,只能靠踩坑积累,但你可以先按这个框架建立自己的debug清单。
6.1 分清问题所在层级:物理层、协议层、应用层
发现通道不动、数值乱跳,先别急着改代码。我的习惯是把问题拆成三层:物理层看接线、供电、引脚;协议层看new_input()、num_channels()、协议类型是否锁定;应用层看RCx_MIN/MAX/TRIM、映射、辅助功能。从上层往下排查容易出现"绕远路"的情况,比如明明是接收机PWM输出线没插好,却花一个晚上看归一化代码。
如果条件允许,我会直接接一个USB地面站在接收机端观察裸PWM值,和飞控读到的值做对比。如果地面站的显示和飞控读取一致,问题大概率在接收机或者遥控器设置;如果不一致,再往飞控的协议解析和硬件接口查。
6.2 用日志和串口打印快速定位"某通道无值"
ArduPilot比较大的优势是日志系统齐全。我调试某个模拟地面站项目时,遇到1通道随机掉值,直接用MAV_CMD_DEBUG或者临时往scheduler里加一段printf打印hal.rcin->read(0)和num_channels(),很快发现是DMA缓冲被串口日志输出占用导致采样率不稳。这种问题如果不逐层打印,单靠猜很难定位。
- 看一下飞控提示的"RC"相关告警。
- 打印
new_input()和num_channels(),确认协议层是否有新数据。 - 打印每个通道的
get_radio_in(),确认数据是否进入RC_Channel。 - 打印
norm_input()结果,确认校准参数是否正确消费。
6.3 二次开发时关于RC输入的一个最常见误区
很多开发者在做接第三方设备、写特殊摇杆协议时,喜欢直接在HAL层改通道值,比如收到串口命令后直接hal.rcin->set_override()或者修改底层数组。这是最危险的入口,因为会绕过整个RC_Channel状态机,导致failsafe判断、通道映射全部失效。
正确做法是在协议层之外增加一个稳定的数据源,通过set_overrides()这类官方支持的口子覆盖通道值,或者在RC_Channel::read_input()之后再做二次映射。这也呼应了前面说的抽象原则:每一层只做自己的事,跨层修改永远是最后的妥协方案。
RC输入这部分的代码,说难不难,说简单也不简单。它最值得学习的不是某个具体函数,而是这种"物理信号进、统一数据出,校验之后再消费"的分层思想。等你把这条链路完全打通,再看EKF怎么消费传感器数据、姿态控制器怎么消费RC目标值,会发现整个ArduPilot的代码风格都是同一种套路。希望这篇解析能让你下次从日志里看到RC值异常时,心里已经有了一张清晰的排查地图。