做航模和机器人项目时,最让我头疼的不是闭环算法,而是怎么把富斯i6遥控器的摇杆数据稳定送进STM32。早期我用定时器输入捕获去量每个通道的PWM脉宽,通道一多就爆炸:一个接收机输出6路PWM就要占6个捕获引脚,中断里还要数上升沿和下降沿的时间差,电机一启动波形就开始抖,调半天都不稳定。后来我把FS-iA6B接收机切到iBus输出,用一根信号线加一个串口就把14个通道全部读回来了,这件事的复杂度直接降了一个量级。这篇文章我从接线、协议帧讲到F103的HAL库代码实现,把整个从零搭建过程完整记录下来,给同样被遥控通道读取折磨的朋友一条可复制的路。
1. 为什么放着PWM不用,偏要读iBus
1.1 PWM捕获方案的结构性痛点
用输入捕获读遥控器,本质是在测“高电平持续时间”。接收机把每个通道的舵量编码成一串脉宽信号,通常是1ms到2ms的高电平,周期大概20ms,STM32通过定时器捕获上升沿和下降沿,算出高电平时间再映射成通道值。
听起来不复杂,但实际用起来问题一大堆。
第一是硬件资源不够分。STM32F103C8T6这类芯片虽然有好几个定时器,但每个定时器的捕获通道数量有限,而且引脚还要和其他外设抢位置。想读6个通道,就得规划6个具备捕获能力的引脚,还得避开I2C、SPI、USART的默认位置,画板子和飞线的时候会非常痛苦。
第二是中断负担重。每个PWM周期20ms,一个通道就有两次捕获中断,6个通道就是每秒600次中断。虽然F103扛得住,但如果你同时还在跑PID、OLED刷新、无线透传,中断优先级和时序的调试就能让人秃头。
第三是精度和稳定性都一般。PWM信号从接收机出来,经过线缆传输,如果电机工作、电调开关噪声耦合进来,高电平时间的测量结果会跟着抖,反映到控制量上就是舵机微颤、电机转速波动。
这些痛点的根源在于,PWM是一种模拟化的信息表达方式,接收机本来已经把通道值计算好了,却非要用一个时间长度去“表达”它,STM32这边还要再花CPU去“翻译”回来,多此一举。
1.2 iBus是怎么把问题变简单的
iBus是富斯(FlySky)自家的串行总线协议,接收机内部直接把各路PWM脉宽换算成数字值,打包成一帧数据,通过一根信号线发出来。STM32要做的事情只有一个:用串口把这一串字节收下来,按协议拆包。
这么一换,之前的所有痛点基本全消失。
- 硬件上,一个USART的RX引脚就能收全部14个通道,不需要定时器捕获通道。
- CPU负担上,串口接收中断每字节只触发一次,一帧31字节也就31次中断,而且数据解析是主循环里做的,不在中断里塞逻辑。
- 稳定性上,数字帧带校验和,收到坏帧直接丢弃,不会把错误脉宽当成控制量,比PWM捕获那种“默默给错值”的方式可靠得多。
- 扩展性上,iBus一条线既能收遥控通道,也能发遥测数据,电压、转速、温度都能回传到遥控器屏幕,后面想加功能也不用重新接线。
顺带说一句,iBus和Futaba的S.Bus是两回事,S.Bus虽然也是串行总线,但帧结构、波特率、物理特性都不一样,网上搜资料时别搞混了。
2. 硬件这么接,信号才靠谱
2.1 FS-iA6B接收机的i-BUS接口
我用的接收机是富斯FS-iA6B,和富斯i6遥控器配套。接收机侧面有一个三针的i-BUS接口,丝印上写着“i-BUS”,三根线分别是红(5V)、黑(GND)、白(信号)。
信号线就是iBus数据输出线,直接接STM32的RX引脚。以STM32F103C8T6的USART1为例,PA10是RX,PA9是TX,只读遥控通道的话只要把PA10和接收机信号线连起来。
这里有一个容易忽略的点:i-BUS接口和S.Bus接口长得很像,都是三针,但协议完全不同。接线前先确认接收机型号和接口丝印,别插错口。FS-iA6B上如果有多个三针排针,只有标着i-BUS的那个口才是数字总线输出,普通的CH1-CH6口输出的还是PWM。
2.2 供电和地线处理
接收机需要5V供电,常见做法是从电调BEC取电。调试阶段也可以从STM32板子的5V引脚给接收机供电,但前提是两边的GND必须连在一起。iBus信号是单端信号,发送端和接收端必须有共同的地参考,否则波形完全没法看,轻则乱码,重则烧引脚。
我踩过的一个典型坑是:STM32用USB供电,接收机用独立5V电源供电,两边电源没共地,结果串口收到的一直是0x00和0xFF的杂波。把GND线一接,数据立刻正常。所以不管怎么供电,GND共地是底线。
接收机的供电电流其实很小,但如果你用同一个BEC同时给接收机和舵机供电,就要注意BEC的持续电流能力。大舵机堵转时电流能到好几安培,BEC如果扛不住,电压跌落会导致接收机重启,iBus信号也会跟着中断。稳妥的做法是接收机单独走一路电源,或者用大容量电容做缓冲。
2.3 电平匹配问题
FS-iA6B的iBus输出电平和STM32是匹配的,都是3.3V TTL,可以直连。但如果你用的是Arduino UNO这种5V单片机,就不能直接接,必须用电阻分压或电平转换模块,否则长期运行有烧坏风险。
判断信号电平最靠谱的办法是示波器,量一下空闲电平和数据高电平的幅度。没有示波器的话,用万用表量直流平均电压只能做个粗略参考,因为串口信号是变化的。实在条件简陋,宁可用逻辑分析仪抓一下波形。
另外,iBus信号线尽量短,不要和电调的三相输出线并行走长距离。航模上电机电流变化剧烈,会在信号线上感应出毛刺。我在桌面调试时用10cm杜邦线完全没问题,一旦把接收机装到机架上、电机大油门工作时就频繁丢帧,最后重新规划走线、把信号线从动力线旁边挪开,问题才消失。天线区域也别被金属件或碳纤维遮挡,这对接收质量影响很大。
3. iBus帧结构逐字节拆解,校验其实就一行
3.1 一帧数据的完整布局
iBus的物理层就是UART串口,波特率115200,8位数据、无校验、1位停止位。一帧遥控数据长这样:
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0 | 0x55 | 固定帧头 |
| 1 | 0x20 | 长度/标志字节 |
| 2~29 | 14个通道,每通道2字节 | CH1低字节, CH1高字节, CH2低字节, CH2高字节…… |
| 30 | 校验和 | 由前面字节计算得出 |
先解释一下0x20这个字节。很多资料说0x20代表“整帧长度32字节”,但你如果数上面的表,从帧头到校验和一共只有31字节。这个偏差在实际抓包里经常出现,我自己的理解是:这是FlySky协议头里的固定标志字节,代表这帧是标准iBus数据包,不要过分纠结它的字面数值。接收时按31字节收,校验能通过就是对的。
14个通道值都是小端序,也就是低字节在前、高字节在后。解析时要把两个字节拼起来:ch[i] = buf[2 + i * 2] | (buf[3 + i * 2] << 8)。
数值范围一般是1000到2000,单位可以理解成微秒级PWM脉宽,中位是1500。推力油门从最低到最高,对应1000到2000;舵机摇杆中位就是1500。富斯遥控器里的“端点”设置会改变这个范围,比如把端点从100%调到120%,油门最高值可能超过2000,所以后文会强调做实际校准。
3.2 校验和的计算逻辑
iBus的校验算法非常朴素:把帧头、长度字节以及28个字节的通道数据(也就是偏移0到29这30个字节)逐个求和,用0xFF减去这个和的低8位,得到校验字节。
用C语言写就是:
static uint8_t ibusChecksum(const uint8_t *buf) { uint8_t sum = 0; for (uint8_t i = 0; i < 30; i++) { sum += buf[i]; } return (uint8_t)(0xFF - sum); }接收端算出来的值如果和buf[30]相等,说明这一帧数据完整。
这里有个等价写法可以顺便记一下:因为校验字节等于“0xFF减前面所有字节的和”,所以如果把整帧31个字节全部加起来,低8位结果一定是0xFF。很多现成库就是这么判断的。两种写法都行,第一种更贴近协议定义,可读性也更好。
校验的意义在于,它让接收端能区分“好帧”和“噪声”。在电机干扰大的场景里,偶尔串口会收到插坏的字节,如果不管校验直接解析,某个通道值可能会瞬间跳到最大值,这在飞控里是致命的。有了校验,坏帧直接丢弃,系统可以保守地沿用上一帧数据或者触发失控保护。
4. STM32代码落地:CubeMX配置和中断解析
4.1 CubeMX初始化的关键选项
我用的工程以STM32F103C8T6为例,HAL库,STM32CubeMX生成。开发环境无论是Keil MDK还是STM32CubeIDE都行,核心逻辑一样。
CubeMX里需要配置的东西不多:
- RCC,开启外部高速晶振(HSE)。这个对串口波特率精度有影响,如果板子上有8MHz晶振就用外部晶振,别依赖内部RC。
- USART1,选择异步模式,波特率115200,数据位8,校验位None,停止位1。引脚默认PA9是TX、PA10是RX,这两个引脚不冲突。
- NVIC,勾选USART1全局中断,再勾选一个定时器中断用于帧超时判断。
- 定时器,我用TIM6,配置成1ms中断一次。APB1定时器时钟默认是72MHz,分频器设为72-1,自动重载值设为1000-1,这样刚好1ms进一次更新中断。
帧超时判断是必须的。因为串口是逐字节到达的,如果接收过程中丢了一个字节,后面的字节就会全部错位。加一个“3ms没有新字节”的超时机制,超时就清空当前缓存,重新等待帧头0x55,这样即使丢字节也能在下一帧恢复。
为什么不直接用空闲中断?STM32确实有IDLE中断,但HAL库里处理起来不如定时器直观。定时器超时法虽然多占一个定时器,但逻辑非常清晰,适合作为从零搭建的起点。
4.2 中断接收和帧处理代码
全局定义这部分比较常规:
#define IBUS_LEN 31 uint8_t ibusBuf[IBUS_LEN]; volatile uint8_t ibusLen = 0; volatile uint8_t ibusFrameReady = 0; volatile uint8_t ibusTimeout = 0; volatile uint16_t rcChannels[14]; uint8_t uartTemp = 0;主循环里先启动第一字节的接收:
HAL_UART_Receive_IT(&huart1, &uartTemp, 1);串口收到一字节后进入回调,这是核心逻辑:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { ibusTimeout = 0; // 收到字节,重置超时计数 // 缓存为空时,必须先收到0x55才算一帧开始 if (ibusLen == 0 && uartTemp != 0x55) { HAL_UART_Receive_IT(&huart1, &uartTemp, 1); return; } ibusBuf[ibusLen++] = uartTemp; // 收满31字节,开始校验 if (ibusLen >= IBUS_LEN) { if (ibusBuf[0] == 0x55 && ibusBuf[1] == 0x20 && ibusChecksum(ibusBuf) == ibusBuf[30]) { ibusFrameReady = 1; } ibusLen = 0; } HAL_UART_Receive_IT(&huart1, &uartTemp, 1); } }有人可能会问,为什么不检查一下长度字节是不是0x20?判断里已经加了,就是为了防止噪声数据恰好以0x55开头后被当成帧头。再加上校验,三重保险。
定时器超时中断用来清空不完整的帧:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { if (ibusLen > 0) { if (++ibusTimeout >= 3) { // 3ms无新字节 ibusLen = 0; ibusTimeout = 0; } } } }主循环里解析通道值时,需要注意中断可能会继续往ibusBuf里写新数据。我在示例里采用的方案是:检测到ibusFrameReady后,先把串口接收停掉,拷贝数据,再重新开启接收。对于这种帧间隔7ms的慢速场景,不重新开启接收期间丢掉一帧完全无所谓。
while (1) { if (ibusFrameReady) { __HAL_UART_DISABLE_IT(&huart1, UART_IT_RXNE); for (uint8_t i = 0; i < 14; i++) { rcChannels[i] = ibusBuf[2 + i * 2] | (uint16_t)(ibusBuf[3 + i * 2] << 8); } uint32_t lastFrameTick = HAL_GetTick(); ibusFrameReady = 0; __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE); HAL_UART_Receive_IT(&huart1, &uartTemp, 1); } }如果你想直接打印通道值验证,可以用HAL_UART_Transmit把格式化后的字符串发到串口助手,或者重定向printf,原理都一样。
这套逻辑移植到标准库也很容易。核心就是“串口接收中断 + 定时器超时 + 帧校验”,只要理解了这三个环节,用寄存器、标准库还是HAL只是写法不同。
5. 实测中撞上的四个坑及排查思路
5.1 校验一直失败的排查链路
我第一次把代码烧进去,串口助手倒是能收到东西,但校验几乎全挂。当时的排查步骤可以给你参考。
先加了一个打印,把收到的原始字节以十六进制打出来,看到分布的确实是一段段数据,但帧头不是0x55就是0x25这种乱值。这说明字节时序不对,第一反应是波特率问题。用示波器看了接收机信号线的波形,发现高电平幅度不到3V,低电平倒是正常,但波特率误差不大。后来才意识到是USB转TTL模块和STM32共用串口打印时,两个发送端互相干扰,把打印用的TX线断开后Wireshark? 不,是串口助手里的数据就正常了。调试时尽量让“调试打印”和“数据接收”分开通道,别让它们在同一个串口上打架。
如果原始字节全是0x00或者0xFF,大概率是接线没共地,或者接收机没上电。
如果原始字节格式正确但校验偶尔失败,就去查供电。接收机供电电压跌落的时候,信号波形会变形,校验失败率会明显上升。
5.2 电机一转就乱码
这个问题在桌面上很难复现,装了电调和电机之后就冒出来了。表现是电机中低转速时串口开始丢帧,校验失败率直线上升。
排查顺序是:先看GND有没有共地,再看信号线有没有和动力线绑在一起,最后看接收机天线位置。我这次的问题出在信号线上,接收机输出到STM32的线走了机架内壁,和电调的三相线几乎平行走了十几厘米。把信号线改成从机架另一侧单独走,绕开动力线后,丢帧率立刻降下来了。
另外,在接收机电源两端并了1000uF电解电容和0.1uF陶瓷电容,电机频繁加减速时的电压瞬变也被压住了不少。调试电机时务必把螺旋桨拆掉再上电,安全这根弦不能松。
5.3 遥控器关机后主循环卡死
如果代码里没有做信号丢失判断,遥控器一关机,接收机停止输出,ibusFrameReady再也不会置位,程序就像卡死了一样。
实际上不是卡死,只是主循环一直在空转。处理方式是在每次成功解析一帧时记录lastFrameTick,然后在主循环里检查:
if (HAL_GetTick() - lastFrameTick > 200) { // 200ms没收到新帧,认为链路丢失 // 油门通道强制回0,其他通道保持上一帧或回中 }失控保护策略看具体项目,但至少需要有个明确反应,不能油门还维持在最后的值上。
5.4 后几个通道的值看起来“不正常”
富斯i6原厂固件只有6个通道,但iBus帧里固定有14个通道的数据。CH1到CH6是实际使用的通道,CH7到CH14一般会输出默认值,可能是1000、1500或者某个固定数,不同接收机固件不一样。
所以别把14个通道全部当成有效输入去读,先打印一遍,看哪几个通道数值会随摇杆和开关变化。网上有人刷了第三方固件把富斯i6扩展到10通道,这时CH7到CH10才真正有反应。刷固件有风险,自己评估要不要折腾,我在这里不展开。
6. 拿到14个通道之后,怎么让它变成控制量
6.1 数值校准和归一化
通道值的默认范围是1000到2000,但富斯遥控器的“端点”设置会影响实际输出范围。如果直接拿1000和2000做线性映射,往往会在摇杆推到底时发现自己永远到不了满量程。
我的做法是,在首次上电时把每个通道的实测最小值、最大值打印出来,然后用实测值做归一化:
float normalized = (float)(rcChannels[0] - chMin[0]) / (float)(chMax[0] - chMin[0]);这样摇杆的物理行程和代码里的0到1就完全对应了。方向通道如果反向,就在遥控器里调,或者在程序里取反,按个人习惯来。
6.2 死区处理
摇杆中位附近机械结构多少有点回中偏差,信号本身也有微小抖动,直接拿原始值做控制,会导致舵机或者电机在静止时轻微抖动。
处理方式很简单:
if (rcChannels[2] > 1485 && rcChannels[2] < 1515) { rcChannels[2] = 1500; }死区范围取±15,对1000到2000的数值来说已经比较保守,实际可以根据手感调大调小。开关类通道不需要死区,但建议做一下按键边沿检测,避免在临界位置反复跳变。
6.3 通道到动作的映射
四个基本通道一般对应油门、副翼、升降、方向,剩下两个通道可以做成模式开关或微调。如果你是要把通道值变成STM32输出的PWM信号去驱动舵机,那就是另开一个定时器做PWM输出,把归一化后的值映射到0.5ms到2.5ms的脉宽区间,代码量也不大。
如果你要做的是小车或机械臂这种非飞行器项目,更常见的做法是把通道值映射成目标速度或目标角度,走串口发到下位机。iBus在这里只是一个“遥控输入前端”,后面的控制逻辑完全不受影响。
6.4 更进一步:iBus遥测回传
iBus是半双工协议,接收机发数据给STM32是一方向,STM32也可以组一帧0xA1开头的遥测数据发给接收机,接收机会把它转发到遥控器屏幕,这样就能在遥控器上看到电池电压、电机转速、GPS信息等数据。
这一层是我后来才玩的。先别强求,把单向通道读取做稳定,再考虑回传。方向搞反或者收发冲突时,帧会互相踩踏,信号乱成一团,需要加方向控制逻辑。有前面这趟基础,理解起来会容易很多。
我自己的体会是,从PWM捕获切到iBus之后,整个项目的稳定性和开发效率都明显上了一个台阶。以前调一个多通道遥控设备要各种检查定时器配置和中断优先级,现在只需要关心串口数据和协议拆包,操心的事少了一大半。如果让我给新手一个建议,就是先把协议帧的每一个字节吃透,再去看别人封装好的库函数。框架可以帮你省时间,但协议理解才是你遇到诡异问题时唯一靠得住的东西。