简介:面向无人机、机器人及遥控设备开发者的STM32 SBUS协议实现参考代码,聚焦Futaba S.BUS多通道遥控信号在STM32F407平台上的完整解析与编码。资源包共2个文件,以1个c源文件和1个h头文件为主,分别承载UART与DMA配置、中断处理、数据解码编码等具体实现,以及函数声明与结构体定义,压缩包约4KB,体量精简便于直接嵌入工程。内容覆盖250Kbps波特率下的帧结构处理、18通道反码数据提取、DMA减轻CPU负担的接收机制,以及发送端的编码与错误检测恢复思路,适合具备一定嵌入式基础、需要快速打通遥控信号链路的开发者参考移植。目前已有7637人学习下载,可作为理解SBUS协议细节与STM32外设协同的实用起点。
1. 遥控信号进 MCU 的那一脚:SBUS 到底难在哪
拿到一块 STM32 开发板,想把航模接收机输出的 SBUS 信号接进来读通道值,很多人第一反应是“串口收字节而已”。真上手才发现翻车点密集:串口助手能收到一串十六进制,但通道值怎么算都不对;明明接了反相器,波形却始终是高电平;偶尔能读出几通道,一动摇杆数据就乱跳。SBUS 的坑不在协议本身多复杂,而在于它把几个容易忽略的细节叠在了一起——100000 波特率、8E2 帧格式、25 字节定长帧、字节按位取反、11 位通道值跨字节拼接、还有那个反相电平。这份资源围绕 STM32 把 SBUS 的解析与编码两头都做完整,从 USART 配置、DMA 收帧、位操作解包,到反向组帧发送,适合已经能点灯、能跑串口,但卡在“数据能收不会算”这一步的嵌入式开发者。下面按我实际拆这套东西的顺序讲,参数和边界都给到能直接抄的程度。
2. 协议先立住:25 字节里到底装了什么
2.1 帧结构与 11 位通道的位序
SBUS 一帧固定 25 字节,这个“固定”是后面所有解析逻辑的地基。开头 0x0F、结尾 0x00 是帧头和帧尾标志,中间 22 字节承载 16 个比例通道,剩下 2 字节是数字通道和状态标志。真正让人算错的是通道值的排布:16 个通道每个 11 位,共 176 位,正好塞进 22 字节(176 位),但它们是按位连续打包的,不按字节对齐。
具体来说,第 1 通道占 byte1 的全部 8 位加 byte2 的低 3 位,第 2 通道从 byte2 的第 3 位开始,跨到 byte3……这种“错位拼接”如果按字节去读,永远对不上。常见做法是把 22 字节当成一个 176 位的位流,用移位和掩码逐段抠。我一般会先把整帧拷进一个 25 字节缓冲,再对 payload 做位提取,避免边收边算导致索引错乱。
| 字段 | 字节位置 | 说明 |
|---|---|---|
| 帧头 | byte0 | 固定 0x0F |
| 通道数据 | byte1~byte22 | 16 通道 × 11 位,按位连续打包 |
| 数字通道 | byte23 | 开关量,按位解析 |
| 状态标志 | byte24 | 帧丢失、失效保护等标志位 |
| 帧尾 | byte24 之后 | 固定 0x00(部分实现与状态位共用末字节,需按实际帧核对) |
提示:不同接收机对末字节的处理略有差异,解析前先用逻辑分析仪或串口抓一帧原始数据核对帧头帧尾,别默认所有设备完全一致。
2.2 为什么必须 100000 波特率 + 8E2 + 反相
SBUS 的物理层是串口的“非标用法”。波特率 100000 而不是常见的 115200,帧格式是 8 数据位、偶校验、2 停止位(8E2),这三点任何一个配错,收到的就是乱码或直接收不到。更隐蔽的是电平反相:SBUS 信号在传输时是反相的,进 MCU 的 USART 之前通常要经过一个反相电路(三极管或专用反相器),或者在软件层面确认硬件已处理。我见过太多人卡在“串口助手一片 0xFF”,最后发现是没反相。
STM32 这边,USART 要配成 100000 波特、8E2,并且开启接收。由于帧是定长 25 字节且以 0x0F 开头,用 DMA + 空闲中断是最稳的收帧方式:DMA 把字节搬进缓冲,空闲中断告诉你“这一帧收完了”,再在中断里校验帧头帧尾。轮询方式在通道多、刷新快时容易丢字节,不建议。
3. 收帧落地:USART + DMA + 空闲中断的完整配置
3.1 串口与 DMA 初始化
下面这段是 HAL 库下的核心配置,波特率、校验、停止位三个参数是重点,其余按芯片型号微调。代码里保留了关键注释,参数含义在代码后说明。
// USART1 初始化:100000 8E2,用于 SBUS 接收 huart1.Instance = USART1; huart1.Init.BaudRate = 100000; // SBUS 固定 100k,不是 115200 huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_2; // 2 停止位 huart1.Init.Parity = UART_PARITY_EVEN; // 偶校验 huart1.Init.Mode = UART_MODE_RX; // 只收,发送另开一路 huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; HAL_UART_Init(&huart1); // DMA 接收:循环模式,长度 25,配合空闲中断判帧 HAL_UART_Receive_DMA(&huart1, sbus_buf, 25); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); // 开空闲中断参数说明:BaudRate必须是 100000,写成 115200 会因采样点偏移导致误码;Parity选偶校验,配合 8 数据位正好是 8E2;DMA 用循环模式,这样一帧收完缓冲自动回卷,不会因为没及时重启 DMA 而丢下一帧。空闲中断的使能是收帧判定的关键,它在一帧结束、总线空闲时触发,比按固定长度判断更可靠。
3.2 空闲中断里做帧校验与解包
中断服务里不要做重活,只做“确认这是一帧”和“置标志”,解包放到主循环。下面这段是中断回调的骨架:
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 校验帧头帧尾,通过则置新帧标志 if (sbus_buf[0] == 0x0F && sbus_buf[24] == 0x00) { sbus_frame_ready = 1; } } HAL_UART_IRQHandler(&huart1); }逻辑说明:先清空闲标志,再检查sbus_buf[0]是否为 0x0F、末字节是否为 0x00。两个条件都满足才认为是一帧有效数据,置sbus_frame_ready供主循环消费。这里有个细节——DMA 是循环的,缓冲会被下一帧覆盖,所以主循环必须在下一帧到来前把数据拷走或解完。如果主循环负担重,可以开双缓冲,一帧解包一帧接收,避免竞争。
3.3 位操作解出 16 个通道
解包的核心是把 22 字节 payload 当位流,逐通道取 11 位。下面这个函数是可直接用的写法:
void sbus_decode(const uint8_t *frame, uint16_t *channels) { // payload 从 frame[1] 开始,共 22 字节 const uint8_t *p = &frame[1]; for (int i = 0; i < 16; i++) { int bit_pos = i * 11; // 第 i 通道起始位 int byte_idx = bit_pos / 8; // 起始字节 int bit_off = bit_pos % 8; // 字节内偏移 uint32_t raw = p[byte_idx] | (p[byte_idx + 1] << 8) | (p[byte_idx + 2] << 16); channels[i] = (raw >> bit_off) & 0x7FF; // 取 11 位 } }参数说明:bit_pos是通道在位流中的起点,byte_idx和bit_off把它换算成“从第几个字节的第几位开始”。因为一个 11 位值最多跨 3 个字节,所以一次拼 3 字节成 32 位再右移掩码,是最省事的写法。0x7FF是 11 位全 1 掩码,保证只取 11 位。注意p[byte_idx + 2]在最后一个通道时可能越界到 payload 之外,实际实现里要么给缓冲多留 2 字节余量,要么对 i=15 单独处理,这是很容易忽略的边界。
4. 编码回写:把通道值组回 25 字节帧
4.1 编码的位拼接与范围钳位
解析是拆,编码是装,两者互为逆运算,但编码多一个坑:通道值范围。SBUS 比例通道的原始值通常在 172~1811 之间(对应摇杆行程),中位约 992。如果你从上层拿到的是 1000~2000 的脉宽值,必须先映射再装帧,否则装出来的帧对方解出来是错的。我一般会在编码入口做一次钳位,防止越界值把相邻通道的位污染。
void sbus_encode(uint16_t *channels, uint8_t *frame) { frame[0] = 0x0F; memset(&frame[1], 0, 22); for (int i = 0; i < 16; i++) { uint16_t v = channels[i] & 0x7FF; // 钳到 11 位 int bit_pos = i * 11; int byte_idx = bit_pos / 8; int bit_off = bit_pos % 8; uint32_t raw = (uint32_t)v << bit_off; frame[1 + byte_idx] |= raw & 0xFF; frame[1 + byte_idx + 1] |= (raw >> 8) & 0xFF; frame[1 + byte_idx + 2] |= (raw >> 16) & 0xFF; } frame[23] = 0x00; // 数字通道,按需填 frame[24] = 0x00; // 状态/帧尾,按实际协议核对 }逻辑说明:先清空 payload,再逐通道把 11 位值左移到对应位偏移,然后按字节或进缓冲。用|=而不是=,是因为相邻通道的位会落在同一字节的不同位上,必须叠加。& 0x7FF是钳位,防止传入超过 11 位的值把高位溢出到别的通道。末两字节按你的设备协议填,数字通道和状态位各设备定义不同,别照抄。
4.2 发送侧的串口与反相
编码出来的帧要发出去,发送串口同样配 100000 8E2,但方向反过来。如果目标是驱动舵机或另一台设备,硬件上同样要注意反相——接收端期待的是反相后的 SBUS 电平。软件层面,发送用 DMA 或阻塞发送都行,帧长固定 25 字节,阻塞发送的耗时在 100k 波特下约 2.5ms,对多数控制循环可接受;如果控制周期紧,用 DMA 发送避免占用 CPU。
注意:收发如果共用一路 USART,反相电路的方向要处理好,否则自己发的帧会被自己按反相逻辑收成乱码。稳妥做法是收發各用一路串口,硬件上分别处理反相。
5. 避坑排查:那些让通道值乱跳的细节
5.1 现象:串口能收到数据,但通道值全是 0 或固定值
原因通常是位序算错或帧偏移错。SBUS 的 payload 从 frame[1] 开始,不是 frame[0];通道位流也是从 payload 第 0 位起算。如果解包时把帧头 0x0F 也算进 payload,所有通道整体错位 8 位,解出来就是一堆看似有规律实则错误的值。解决:打印原始 25 字节,手工按位核对第 1 通道是否等于 byte1 | ((byte2 & 0x07) << 8),对不上就回头查偏移。
5.2 现象:偶尔能读,一动摇杆就跳变或丢帧
多半是 DMA 缓冲被覆盖。循环 DMA 下,如果主循环解包慢于帧率(SBUS 常见 7ms 一帧,约 140Hz),下一帧会直接覆盖正在读的缓冲,表现为数据跳变。解决:开双缓冲,DMA 收 A 缓冲时主循环解 B 缓冲,空闲中断里交换;或者把解包放进中断后的高优先级任务,缩短缓冲占用时间。
5.3 现象:接上反相器仍然收不到,串口助手全是 0xFF
先确认反相器供电和方向,再确认串口参数。8E2 里“2 停止位”经常被漏配成 1 停止位,表现就是持续收到 0xFF 或帧错误。另一个高频原因是波特率写成 115200——SBUS 是 100000,差 15% 的波特率在偶校验下误码率极高。解决:逐项核对波特率、校验、停止位三参数,用示波器看 TX 波形位宽是否为 10us。
5.4 现象:编码发出的帧对方解出来通道错位
编码时用了=覆盖而不是|=叠加,后一个通道把前一个通道落在同一字节的位冲掉了。SBUS 相邻通道共享字节是常态,必须按位或。解决:编码前 memset 清零,循环内一律|=,并在编码后做一次自解包回读,把编码结果用解析函数解一遍,比对通道值是否一致,这是最省事的后悔药。
5.5 现象:末字节校验总失败,帧被判无效
不同接收机对第 25 字节(状态/帧尾)的定义不完全一致,有的固定 0x00,有的把失效保护标志放在这里。如果你的校验写死frame[24] == 0x00,遇到带标志位的设备就会整帧丢弃。解决:校验只认帧头 0x0F 和长度,末字节按位解析状态而不是当帧尾硬判;或者先抓一段真实数据统计末字节分布,再决定校验策略。
6. 进阶验证:用回环自测把解析和编码焊死
写完解析和编码,最怕的是“两边各自看起来都对,合起来不对”。我的习惯是做一个软件回环:构造一组已知通道值,先编码成 25 字节帧,再喂给解析函数,比对解出的通道值和原始值是否逐通道相等。这个自测不需要任何硬件,能在上板前把位序、偏移、钳位三类错误全部暴露。
void sbus_selftest(void) { uint16_t tx[16], rx[16]; uint8_t frame[25]; for (int i = 0; i < 16; i++) tx[i] = 172 + i * 100; // 构造递增测试值 sbus_encode(tx, frame); sbus_decode(frame, rx); for (int i = 0; i < 16; i++) { if (tx[i] != rx[i]) { printf("ch%d mismatch: tx=%u rx=%u\n", i, tx[i], rx[i]); } } }这段自测的价值在于它把“编码正确”和“解析正确”解耦成可验证的命题:只要回环通过,位序和偏移就是自洽的;回环不过,问题一定在某一侧的位操作,而不是硬件或串口。测试值选递增且覆盖 11 位范围(172 到 172+1500),能顺带验证跨字节边界和最高位。上板后再用逻辑分析仪抓真实帧,把解析结果和遥控器摇杆位置对照,中位、满行程、边界三个点各验一次,基本就能确认整条链路通了。
几个我踩过的边界值得单独记一下:第 16 通道(i=15)的位偏移是 165,跨到 payload 第 20、21、22 字节,p[byte_idx + 2]正好落在 payload 末尾,如果缓冲只给 22 字节就会越界读,所以解析函数的入参缓冲建议按 25 字节整帧传,别只传 payload 指针。另外通道值钳位要在编码前做,别指望接收端帮你截断,越界值溢出到相邻通道是那种“看起来只是某个通道偶尔抖一下”的玄学问题,查起来非常费时间。从那以后我每次改位操作相关代码,都强制先跑一遍这个回环自测再上板,省下的调试时间远比写测试多。希望帮到你。
本文还有配套的精品资源,点击获取