
简介面向无人机、机器人及遥控设备开发者STM32平台上的完整SBUS协议解析与编码实现可直接复用至飞控、云台和地面站通信模块适合熟悉STM32但需要快速落地S.BUS总线协议的中高级嵌入式工程师。压缩包共2个文件由C源码与头文件组成代码结构精简便于在STM32F407等Cortex-M4工程中直接调用包体仅4KB轻量易学适合逐行阅读和移植。已有7608人学习下载说明这套方案获得了不少开发者的认可与验证。源码覆盖250Kbps波特率UART配置、DMA接收中断、数据帧校验、反码转换等关键环节并给出18通道值解码和SBUS编码发送的完整实现同时包括错误检测与状态更新逻辑可帮助读者理清从串口底层到协议帧解析的完整链路减少调试验证工作量。 搞航模、穿越机或者开源飞控的朋友对SBUS这个词应该再熟悉不过了。Futaba最早把S.BUS做进接收机里一根线就能把遥控器的通道数据全部送到飞控比传统PWM排线简洁太多也比PPM这种模拟脉宽信号更抗干扰。不过真要把SBUS接到STM32上做解析和编码会发现它并不是一个“标准”串口协议——反相电平、100000波特率、8E2帧格式、11bit通道打包每一样都藏着坑。这篇文章把我做STM32端SBUS完整解析和编码的过程、代码和踩过的坑整理出来给准备做飞控遥控协议栈、模拟遥控器输出或者转发器的朋友做个参考。1. 先搞清楚SBUS到底是个什么协议1.1 协议出身和适用场景SBUS最初是Futaba为实现遥控接收机和飞控、舵机之间高速数字通信推出的串行总线协议后来大量开源飞控和航模设备都把它作为标准遥控输入接口。它最直接的价值是把传统十几个通道的PWM线束压缩成一根信号线同时还能携带数字通道和帧丢失、失控保护这些状态信息。实际项目里我接触到SBUS主要在三类场景一是飞控采集遥控器信号也就是接收机SBUS输出接到STM32二是用单片机模拟遥控接收机输出比如给飞控灌入自动飞行指令或做舵机测试三是做通道转发、信号中继把一个接收机的SBUS数据解析后重新编码发给别的设备。无论哪种场景核心都绕不开两个动作解析和编码。1.2 电气层和帧结构为什么它不太“标准”SBUS的电气层有两个和普通UART明显不一样的地方也是很多人第一次上手就卡住的地方。先看波特率和帧格式SBUS使用100000波特率8个数据位偶校验2个停止位也就是常说的8E2。注意这个100000不是市面上常见的9600、115200这类标准串口波特率配置时必须手动指定。另外8E2意味着一个字节要占12个位时隙而不是常规8N1的10个位时隙。再看电平极性SBUS是反相UART信号空闲状态为低电平起始位是高电平。而STM32的USART外设遵循标准UART电平逻辑空闲为高起始位为低。如果直接把Futaba接收机的SBUS输出接到STM32的RX引脚USART会一直认为总线处于“忙”状态根本收不到有效数据。这也是为什么SBUS信号不能像普通串口那样直接对接必须先做反相处理。帧结构方面完整的一帧SBUS数据固定25字节结构如下字节位置内容说明字节00x0F帧头字节1~22通道数据16个通道每通道11bit共176bit字节23标志字节高通道、帧丢失、失控保护状态字节240x00帧尾标志字节的位定义常见如下bit0对应第17通道bit1对应第18通道bit2表示帧丢失bit3表示失控保护。很多飞控程序里会直接读这个字节判断遥控链路状态。2. 硬件上没接对代码写得再好也白搭2.1 反相问题SBUS与STM32的“第一次冲突”我第一次接SBUS的时候没看协议细节直接把接收机的SBUS引脚飞线到STM32的PA10串口初始化成100000波特率结果中断里一帧数据都收不到读到的寄存器状态一直是RXNE不置位。折腾半天才反应过来是反相电平的问题。反相的本质很简单SBUS在物理层把UART电平反了过来所以接入标准USART外设前必须“再反一次”。实现方式有硬件和软件两种思路。软件方式是用外部中断加定时器测量每个脉冲宽度自己解码时序但实现复杂且占用CPU串口本身的硬件校验、错误检测都用不上不推荐。硬件方式最常见的就是加一级反相器把信号翻回来让USART看到正常的UART电平。2.2 我自己用的反相电路和接线建议在STM32开发环境里最省事的反相电路是用一颗NPN三极管搭反相器电路参数也很常规SBUS信号先串1k电阻到三极管基极集电极通过10k上拉电阻接3.3V同时集电极引出作为STM32的RX输入发射极直接接地。这样SBUS低电平时三极管截止集电极被上拉到高电平SBUS高电平时三极管导通集电极拉低正好完成反相。如果你的设备使用5V系统注意上拉电阻要接到对应IO电平别让集电极电压超过STM32的耐压。如果要求更高可以用SN74LVC1G04这种单路施密特非门芯片波形沿更陡抗干扰能力更强代价是多个贴片元件和一笔采购成本。很多成品飞控板已经把反相电路画在板子上了动手前先看一眼原理图省得重复劳动。接线时还有两个细节要注意一是SBUS线尽量短最好在30cm以内过长容易受电机、电调干扰二是在信号线上并联一个10k左右的下拉或上拉避免接收机未上电时信号线悬空导致误触发具体方向看你的反相电路拓扑。3. 解析端从字节流还原出16个通道3.1 接收方式选择中断、DMA还是别的SBUS一帧25字节假如接收机按100Hz刷新每秒就是2500字节这个数据量对STM32来说非常小。我见过有人用DMA加空闲中断做接收也有人用逐字节中断加状态机。两种方案都能跑但工程上的取舍不一样。逐字节中断方案优点是逻辑直观每收一个字节进一次中断在回调里跑状态机掉帧、错帧都能精细处理。2500Hz的中断频率对STM32来说几乎不占资源哪怕同时跑PID和控制逻辑也完全够用。DMA加IDLE中断的优点是CPU参与更少整个帧交给DMA空闲中断到了就表示一帧结束但处理一帧数据里夹杂多个0x0F帧头的情况反而要额外写校验逻辑。我自己的项目最终选了逐字节中断加状态机理由是代码可读性好后期加超时判断、错误统计都很方便。3.2 状态机SBUS接收的正确打开方式状态机接收的核心思路是不信任任何一次单字节匹配而是严格按照帧头、数据段、标志字节、帧尾的顺序推进只有完整走完一帧且帧尾正确才认为这一帧有效。先定义状态typedef enum { SBUS_STATE_IDLE 0, SBUS_STATE_SYNC, SBUS_STATE_DATA, SBUS_STATE_FLAG, SBUS_STATE_END } sbus_state_t;中断回调里按状态转移#define SBUS_FRAME_SIZE 25 #define SBUS_DATA_BYTES 22 #define SBUS_HEADER 0x0F #define SBUS_FOOTER 0x00 uint8_t sbus_frame[SBUS_FRAME_SIZE]; volatile uint8_t sbus_frame_ready 0; static uint8_t sbus_rx_byte; static sbus_state_t sbus_state SBUS_STATE_IDLE; static uint8_t sbus_idx; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { switch (sbus_state) { case SBUS_STATE_IDLE: if (sbus_rx_byte SBUS_HEADER) { sbus_frame[0] sbus_rx_byte; sbus_idx 1; sbus_state SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: if (sbus_idx 23) { sbus_frame[sbus_idx] sbus_rx_byte; if (sbus_idx 23) { sbus_state SBUS_STATE_FLAG; } } else { sbus_state SBUS_STATE_END; } break; case SBUS_STATE_FLAG: sbus_frame[23] sbus_rx_byte; sbus_state SBUS_STATE_END; break; case SBUS_STATE_END: sbus_frame[24] sbus_rx_byte; if (sbus_rx_byte SBUS_FOOTER) { sbus_frame_ready 1; } sbus_state SBUS_STATE_IDLE; break; } HAL_UART_Receive_IT(huart1, sbus_rx_byte, 1); } }这里有一个很容易被忽略的点DATA状态里我判断的是idx 23而不是idx 25。因为字节0已经存了帧头字节1到22是22个数据字节所以当idx增长到23时正好说明数据段填满下一个字节应该进入标志字节处理。很多人的状态机写错就是把边界条件弄混导致整个帧错位。3.3 把22字节拆成16路11bit通道SBUS的通道数据不是按字节对齐存储的而是16个通道每个通道11bit按LSB first顺序紧密排列在22字节里。11bit就是判断范围通道原始值0到2047至于具体对应多大舵量不同接收机有自己的映射关系飞控端一般会再做一次归一化。用数学算一下16乘以11等于176bit176除以8正好等于22字节所以数据区没有任何填充位每个通道都可能横跨两个相邻字节。提取通道值最直观的方法是从一个起始bit位置连续取11位uint16_t sbus_channels[16]; void sbus_parse_channels(void) { uint32_t bit_pos 0; const uint8_t *data sbus_frame[1]; for (int ch 0; ch 16; ch) { uint32_t byte_index bit_pos / 8; uint32_t bit_offset bit_pos % 8; uint16_t raw data[byte_index] bit_offset; raw | (uint16_t)data[byte_index 1] (8 - bit_offset); sbus_channels[ch] raw 0x07FF; bit_pos 11; } }这段代码的原理是先把当前bit位置所在的第一个字节右移bit_offset取出低位部分再把下一字节左移8减bit_offset拼接到高位。由于11bit最多只会跨两个字节所以一个通道最多只需要取两个字节参与计算。最后用0x07FF把结果控制在11bit范围内避免后续字节高位被带入。解析完通道后还需要读取标志字节uint8_t sbus_flags sbus_frame[23]; uint8_t ch17 (sbus_flags 0) 1; uint8_t ch18 (sbus_flags 1) 1; uint8_t frame_lost (sbus_flags 2) 1; uint8_t failsafe (sbus_flags 3) 1;如果frame_lost频繁置位说明遥控链路质量不好如果failsafe置位说明遥控器已经进入失控保护状态飞控应该立即切换安全策略。4. 编码端把16个通道值打包发出去4.1 打包思路从通道值到位流编码就是把解析过程反过来把16个11bit通道值按同样的小端位序依次填进22字节的payload区域。这一步如果直接用移位加按位或很容易出现高位置污染的问题我第一版代码就是写多了几个bit导致对端解码出来通道值偶尔跳变。为了保证正确我推荐用一个位池bit pool的思路先把当前通道值放入一个64位变量然后持续取整字节输出逻辑清晰也不容易错void sbus_build_payload(uint8_t *payload, const uint16_t *channels) { uint64_t bit_pool 0; uint8_t pool_bits 0; uint32_t byte_out 0; memset(payload, 0, SBUS_DATA_BYTES); for (int ch 0; ch 16; ch) { bit_pool | (uint64_t)(channels[ch] 0x07FF) pool_bits; pool_bits 11; while (pool_bits 8) { payload[byte_out] (uint8_t)(bit_pool 0xFF); bit_pool 8; pool_bits - 8; } } if (pool_bits 0) { payload[byte_out] (uint8_t)(bit_pool 0xFF); } }这个实现里bit_pool里的低pool_bits位始终是还没输出的有效数据。每加入一个11bit通道值后pool_bits可能超过8就循环吐出完整的字节。最后可能剩余不足8位的尾数据需要手动补到payload末尾。实测下来这个方案不会有跨字节时的多余bit问题也更方便扩展到其他位宽协议。4.2 100000波特率下STM32的USART配置发送端的串口配置看似简单但8E2在STM32 HAL库里有个容易踩的坑要发送8个数据位加偶校验的8E2格式应该把WordLength配置为9位数据长度而不是8位。UART_HandleTypeDef huart1; huart1.Instance USART1; huart1.Init.BaudRate 100000; huart1.Init.WordLength UART_WORDLENGTH_9B; huart1.Init.StopBits UART_STOPBITS_2; huart1.Init.Parity UART_PARITY_EVEN; huart1.Init.Mode UART_MODE_TX_RX; huart1.Init.HwFlowCtl UART_HWCONTROL_NONE; huart1.Init.OverSampling UART_OVERSAMPLING_16; HAL_UART_Init(huart1);这是因为STM32的WordLength指的是“数据位加校验位”的总长度。8E2里数据位8位校验位1位合计9位所以要用9位模式。配置成8位模式再加偶校验硬件实际发送的是7位数据加1位校验对端解析出来通道值全是乱的而且这种错误从波形上看非常隐蔽。校验位本身由硬件自动计算软件层读写还是8bit数据不需要自己处理。波特率方面100000虽然非标准但STM32很好配置。以USART1挂在APB2时钟72MHz为例分频系数就是72000000除以100000等于720整数分频误差为零。如果使用USART2或USART3这类挂在APB1上的外设时钟36MHz也能整数分频为360。理论上任何一个整数分频的时钟都能做到精确100000波特率不用太担心。4.3 发送周期和帧尾校验的处理编码端最终要把25字节整帧发出去。帧头固定0x0Fpayload用上面的函数生成标志字节按实际状态填写帧尾固定0x00。发送时机建议用定时器控制常见接收机的SBUS刷新率在7ms到14ms之间也就是约70Hz到140Hz很多飞控按100Hz做接收。我一般用TIM定时10ms每10ms触发一次发送稳定且好算。void sbus_send_frame(void) { uint8_t buf[SBUS_FRAME_SIZE]; buf[0] SBUS_HEADER; sbus_build_payload(buf[1], channel_output); buf[23] sbus_flags; buf[24] SBUS_FOOTER; HAL_UART_Transmit(huart1, buf, SBUS_FRAME_SIZE, 1); }这里要注意如果发送一帧需要的时间超过定时器周期会造成数据堆积。100000波特率下25字节按8E2总共300个位时隙每个位10微秒一帧大约3毫秒10ms周期完全有余量。实际测试中发送函数用阻塞模式即可不需要DMACPU占用很低。5. 调试实录我在这个项目里踩过的坑5.1 波形看起来“反了”怎么办第一次把逻辑分析仪夹在SBUS信号线上会看到和普通UART完全不同的波形空闲电平不是高而是低帧头0x0F对应的起始位是一个明显的上升沿。绝大多数逻辑分析仪的UART解码器默认空闲高直接解SBUS会得到一堆乱码。解决办法是看你的逻辑分析仪软件有没有“反相信号”或“Invert Signal”选项有的话打开再解码就能看到正常的0x0F开头、0x00结尾的25字节帧。如果你用的是只支持标准UART的低端分析工具也可以用两个通道分别接反相前后的波形同时观察对比确认硬件反相电路是否真的在工作。5.2 偶发错位、通道跳变的排查顺序如果通道值大部分正确但偶尔跳变或者解码出来通道值整体偏移我建议按下面的顺序排查。第一确认状态机是否正确校验帧尾0x00。SBUS数据区里完全可能碰巧出现0x0F如果状态机在数据区看到0x0F就重新同步会把本来正常的帧拦腰截断。正确做法是一帧内遇到0x0F也只能当普通数据处理只有完整收到25字节且帧尾为0x00才认为一帧有效。想更稳可以在主循环里连续校验两帧两帧都通过才更新输出。第二检查波特率误差。100000这个波特率虽然可以被72MHz或36MHz整除但如果你的STM32外部晶振不是标准的8MHz或者PLL配置有偏差USART实际波特率会和期望值产生误差。误差过大时解码器偶尔会在字节边界错位导致通道值跳变。使用逻辑分析仪实际测量一帧的总时长能直接看出波特率准不准。第三排查干扰和接地。SBUS线靠近电调、电机电源线时会受到较大的开关噪声干扰。反相电路的三极管如果基极没有串电阻或者上拉电阻取值过大信号边沿会变缓导致USART采样出错。我的经验是基极串1k、集电极上拉10k信号线用双绞线或者屏蔽线干扰问题能解决大半。5.3 收尾的一点经验如果让我重新做一次这个项目我会先把编码端写成一个自测程序用固定的通道值打包发送再用另一路USART配合反相电路回环接收自己解自己发的数据确认无误后再接真实接收机。这样能把编码问题和硬件问题彻底隔离调试效率高很多。SBUS这套东西真正让新手卡住的往往不是代码逻辑而是8E2的配置和反相电平这两个硬件层面的细节。把这两点理解透了剩下的状态机和位操作就都是常规操作了。本文还有配套的精品资源点击获取