简介:面向无人机、机器人等遥控设备开发者,这是一份围绕STM32F407实现的S.BUS协议解析与编码源码包。方案利用UART与DMA配合接收,覆盖25bit帧结构、反码校验、18通道值提取,同时给出从内部通道值反向编码并发送的完整逻辑,并加入错误检测与恢复处理。压缩包共2个文件,包括1个C源文件和1个头文件:源文件内含串口参数配置、DMA传输、中断服务、解码/编码函数实现,头文件则提供函数声明与通道数据结构。整个资源包仅4KB,结构紧凑,便于直接阅读和移植到其他Cortex-M平台。目前已有7635人学习下载,对于需要在嵌入式项目中快速集成SBUS遥控链路、降低CPU占用并理解DMA收发机制的开发者,是一份轻量且完整的参考实现。
1. STM32 对接 SBUS:先解决反相和 100000 波特率,再谈协议解析
SBUS 在遥控模型里几乎是标配,常被叫做“S.BUS”,但把 SBUS 信号接进 STM32 串口,十个里有八个先栽在反相电平和 100000 波特率这两个细节上。常见现象是:接收机输出直接怼到 USART 引脚,调试串口打出来的全是乱码,协议文档翻了几遍都不知道错在哪。这套 STM32 完整 SBUS 解析与编码资源,解决的是从电平反相、DMA 接收定帧、11bit 通道位拼接、再到编码反相输出的整条链路问题,适合正在做飞控、遥控车、机器人底盘或者舵机云台,想在串口上直接跑 SBUS 的嵌入式开发者。
2. SBUS 帧结构拆解:25 字节、16 通道与 11bit 位拼接
2.1 帧格式:起始字节、数据区、标志字节与结束字节
SBUS 的物理层和帧结构是两个层面。物理层是 100000 波特的串口,8 数据位、偶校验、2 停止位,信号反相;帧层则是固定 25 字节。动手写代码之前,先把这 25 个字节的分配背下来。
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 0 | 0x0F | 帧起始字节 |
| 1~22 | 通道数据区 | 16 通道 × 11bit = 176bit |
| 23 | 标志字节 | ch17、ch18、丢帧、failsafe |
| 24 | 0x00 | 帧结束字节 |
通道数据区从第 1 个字节开始,16 个通道每个占 11bit,按 LSB first 的顺序连续填入 22 个字节。所谓 LSB first,意思是 ch1 的第 0 位落在数据区第 1 个字节的第 0 位,ch1 的第 10 位会一路填到第 2 个字节的第 6 位。这个方向写反了,所有通道值都会错位,而且看起来像是“每个通道值都被重新洗牌”,非常迷惑。
标志字节在数据区之后,每一位的含义按大多数接收机的定义来:bit0 是第 17 通道开关,bit1 是第 18 通道开关,bit2 是丢帧标志,bit3 是失控保护(failsafe)。注意 ch17/ch18 只有 1bit,不是 11bit,所以它们只能表达“开/关”,不能表达模拟量。不同品牌的接收机在标志位的定义上可能有一两位的差别,拿到新设备时最好用逻辑分析仪抓一帧确认。
在串口层,参数是固定的:波特率 100000,8 数据位 + 偶校验,2 停止位。这里有一个非常容易踩的坑:STM32 的 USART 一旦使能偶校验,数据位要按 9 位字长来配置(8 数据位 + 1 校验位),否则帧格式对不上,接收端偶校验失败会直接扔帧。我见过不少人在初始化里写成 8 位字长,结果 S.BUS 信号进来后数据整体错位一位。
2.2 通道值范围、中位值与标志位的设计边界
SBUS 协议只规定了 11bit 的传输宽度,并没有规定“0~2047 对应实际舵量多少”。常见做法是 0~2047 范围,中心值有的遥控器是 1024,有的是 992,还有的直接把这个值当原始 PWM 计数传给飞控。所以代码里不要写死“中位=1024”,更不要自作主张做归一化,收到什么就原样交给上层应用,由上层决定映射关系。这个设计边界会让你的模块适配性更好。
我看到不少人问:“为什么 SBUS 通道值不是 -100 到 +100?” 因为协议层只管传 0~2047 的原始值,比例映射是遥控器发射端或飞控软件自己干的。比如有些遥控器在摇杆中位时输出 1024,有些输出 992,甚至左右行程只用到 172~1811 这个子集。如果解析层强行按 0~2047 做了线性映射,换一个遥控器就会偏。
关于帧率:多数 SBUS 接收机以约 7ms 发送一帧,也有部分设备是 14ms。判断帧边界不能只看 0x0F,还要看相邻两帧之间的间隔。这个间隔在做帧同步和超时判断时才有意义。我的习惯是:把 25 字节的帧长度校验作为第一道门,把帧间隔是否落在 5~15ms 范围内作为第二道门,两道门都过了才认为这一帧有效。
2.3 USART 外设配置:100000 波特率、9 位字长与偶校验的关系
以 STM32F103 标准外设库为例,初始化代码的关键点如下:
// SBUS 串口初始化,F103 标准外设库 USART_InitTypeDef USART_InitStructure; // 关键点:波特率 100000 不是标准波特率,必须明确写出来 USART_InitStructure.USART_BaudRate = 100000; // 偶校验存在时,数据位必须按 9 位字长配置 USART_InitStructure.USART_WordLength = USART_WordLength_9b; USART_InitStructure.USART_StopBits = USART_StopBits_2; USART_InitStructure.USART_Parity = USART_Parity_Even; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &USART_InitStructure);配错字长是第一个坑。原本的“8E2”在 STM32 外设上要写成 9 位字长 + 偶校验 + 2 停止位。如果你配成 8 位模式,接收到的字节会丢失校验位,数据错位。另一个重灾区是波特率:F103 在 72MHz 系统时钟下,100000 波特率得到的是整除的 BRR 值 720,误差为 0;如果板子用的不是 8M 晶振,或者时钟树配错,误差最好控制在 ±2% 以内,超出后一帧 25 个字节很容易出现偶发错帧。
还要提醒一点:F103 的 USART 没有硬件反相位。SBUS 是反相逻辑,空闲电平是低电平,和普通 UART 的空闲高电平恰好相反。直接用普通串口引脚接 SBUS 信号,收到的全是“反相后的乱码”。部分 F4 和 H7 型号的 USART 有 RXINV/TXINV 位,可以省掉外部反相器;F103 就得在 RX/TX 脚上各加一个反相电路。反相这件事不是“可选项”,是“必选项”,后面第 5 章第一个坑就是它。
3. 接收端解析:DMA 加空闲中断定帧与 11bit 通道解算
3.1 串口 DMA 初始化:循环模式与缓冲大小
接收端的目标很直接:把 25 字节一帧的 SBUS 数据从串口拿回来,再解成 16 个通道。我推荐的做法是 DMA 循环接收 + 串口空闲中断定帧。原因有两个:SBUS 帧间隔是稳定的,用空闲中断天然能感知“一帧结束”;DMA 保证接收过程 CPU 零干预,不会因为中断嵌套丢字节。缓冲大小取 32 字节,比 25 大一圈,足够容纳半帧残留加上新帧。
// 串口1 + DMA1 通道5,循环接收,配合空闲中断做帧定界 void sbus_uart_init(void) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; DMA_InitTypeDef dma; // 时钟和引脚复用(PA9/PA10),略过具体 GPIO 配置 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // 3. 波特率 100000,8E2 => 9 位字长 + 偶校验 usart.USART_BaudRate = 100000; usart.USART_WordLength = USART_WordLength_9b; usart.USART_StopBits = USART_StopBits_2; usart.USART_Parity = USART_Parity_Even; usart.USART_Mode = USART_Mode_RX | USART_Mode_TX; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_Init(USART1, &usart); // 4. DMA:接收缓冲 32 字节,循环模式 dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)sbus_rx_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = 32; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_Priority = DMA_Priority_High; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &dma); DMA_Cmd(DMA1_Channel5, ENABLE); // 5. 开启空闲中断和 DMA 接收请求 USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_DMACmd(USART1, USART_DMAReq_RX, ENABLE); USART_Cmd(USART1, ENABLE); }DMA 缓冲 32 字节是刻意的:如果正好 25 字节一帧,缓冲剩余计数不好处理;32 则多出 7 字节余量,给“半帧残留 + 完整新帧”留了空间。循环模式下 DMA 会自动回绕,不需要手动重置,但解析时要注意当前写入位置。这个初始化里没有开 RXNE 中断,只开了空闲中断,因为块搬运交给 DMA 就好,CPU 只需要在帧结束时介入一次。
3.2 空闲中断里做帧定界:先读 SR 再读 DR
空闲中断触发时机是“一字节接收完毕且总线空闲超过一个字节时间”。对于 7ms 一帧的 SBUS 来说,帧间空隙足够触发空闲中断。中断服务函数里最容易被忽略的是清除标志的顺序:必须先读 SR,再读 DR,否则 IDLE 标志永远清不掉,中断会被反复触发。
// 串口1 中断服务函数 void USART1_IRQHandler(void) { uint16_t remain, received; if (USART_GetITStatus(USART1, USART_IT_IDLE)) { // 必须读 SR 再读 DR,否则 IDLE 标志清不掉 (void)USART1->SR; (void)USART1->DR; // 读 DMA 当前剩余计数,反推出已接收字节数 remain = DMA_GetCurrDataCounter(DMA1_Channel5); received = 32 - remain; // 简单判断:至少 25 字节才尝试解析一帧 if (received >= 25) { sbus_parse_frame(sbus_rx_buf, sbus_channels); } } }这段是最简实现,直接取缓冲头部 25 字节解析。如果 DMA 在循环过程中发生过回绕,received 会大于 32,头部可能不是完整一帧。完整资源里我用“帧起始索引 + 帧长度”的偏移修正来处理回绕,上面这段代码是给读者先跑通逻辑用的。你可以在实际调试时加一个打印,把 received 值打出来,观察它是否稳定在一个小范围内波动。
3.3 通道解算:逐位搬运比批量移位更不容易翻车
通道解算是整个解析流程里最容易写错的部分。我见过最快的写法是用两次移位加掩码直接拼 11bit,但那个公式一般人记不住,错一位就是全错。我推荐逐位搬运,虽然肉眼看起来慢,但在 72MHz 的 F103 上解析一帧只需要几微秒,完全够用。
// sbus_parse_frame: 把 25 字节原始帧解析成通道数组 // frame[0] 必须为 0x0F,frame[24] 必须为 0x00 // channels[0~15] 输出 16 个 11bit 通道值 // channels[16]、channels[17] 为开关量(0 或 2047) void sbus_parse_frame(const uint8_t *frame, uint16_t *channels) { uint32_t bitPos = 0; int ch, b; // 跳过起始字节 frame[0],从数据区 frame + 1 开始取 176bit for (ch = 0; ch < 16; ch++) { uint16_t val = 0; for (b = 0; b < 11; b++) { // 数据区起点是 frame + 1,bitPos 从 0 递增 uint32_t idx = 1 + (bitPos >> 3); uint8_t mask = (uint8_t)(1u << (bitPos & 7)); if (frame[idx] & mask) { val |= (uint16_t)(1u << b); } bitPos++; } channels[ch] = val; } // 标志字节 frame[23],bit0=ch17,bit1=ch18 // bit2=frame lost,bit3=failsafe channels[16] = (frame[23] & 0x01) ? 2047 : 0; channels[17] = (frame[23] & 0x02) ? 2047 : 0; }这段代码的逻辑核心是 bitPos 的统一管理。bitPos 从 0 开始,每取 1bit 自增一次,idx 和 mask 都由 bitPos 直接算出来,不存在“通道 2 第几位要左移 3 位”这类手算偏移。channels[16] 和 channels[17] 只在需要 16 通道以上时才用得上,如果用不到可以直接忽略它们。把 0x0F 头校验和 0x00 尾校验放在调用端,避免解析函数每次重复判断,职责更清楚。
4. 发送端编码:从通道数组到反相 SBUS 波形
4.1 编码函数:25 字节帧的组装与标志位写入
编码是解析的逆过程。给定 16 个通道值,加上可选的 ch17/ch18 开关量,组装成 25 字节帧。这里同样用逐位写入,和解析函数对称,方便对照验证。
// sbus_build_frame: 通道数组打包成 SBUS 完整 25 字节帧 // channels[0~15] 为 11bit 通道值,channels[16]/[17] 为开关量 void sbus_build_frame(const uint16_t *channels, uint8_t *frame) { uint32_t bitPos = 0; int ch, b; int i; // 帧头帧尾 frame[0] = 0x0F; frame[24] = 0x00; // 数据区和标志字节清空 for (i = 1; i < 24; i++) frame[i] = 0x00; // 16 通道 11bit,LSB first 逐位填充 for (ch = 0; ch < 16; ch++) { uint16_t val = channels[ch] & 0x07FF; for (b = 0; b < 11; b++) { if (val & (1u << b)) { uint32_t idx = 1 + (bitPos >> 3); frame[idx] |= (uint8_t)(1u << (bitPos & 7)); } bitPos++; } } // 标志字节:bit0=ch17,bit1=ch18 frame[23] = 0x00; if (channels[16]) frame[23] |= 0x01; if (channels[17]) frame[23] |= 0x02; }编码函数不需要清零整个 frame,但清零更保险,可以避免上次残留数据污染新帧。val 一定要做& 0x07FF,如果上层传入一个 16bit 的摇杆值,比如 4096,不做掩码会把后面通道的位挤掉。标志字节只处理了 ch17/ch18,丢帧和 failsafe 位留给上层故障逻辑写,不要在底层编码函数里硬编码。
4.2 发送方向的反相问题:三条路线怎么选
STM32 的 USART TX 引脚输出的是正逻辑 UART:空闲高电平,起始位低电平。SBUS 标准要求的反相信号:空闲低电平,起始位高电平。直接拿 USART TX 接 S.BUS 舵机,舵机大概率没反应,因为极性反了。发送方向反相有三条路线。
| 方案 | 实现方式 | 适用场景 | 边界 |
|---|---|---|---|
| 外部三极管反相 | NPN 三极管一级反相,集电极上拉 | F103 及所有型号 | 需要两颗电阻,但最通用 |
| 硬件反相位 | USART 的 TXINV 位 | F4/H7 部分型号 | 先查参考手册确认支持 |
| 定时器模拟 UART | 用定时器比较中断逐 bit 输出反相时序 | 没有外部器件时 | 占用定时器和 CPU,帧间隔可能抖动 |
我一般首选三极管反相,简单可靠,成本不到一毛钱。电路只需要一个 NPN 三极管、一个基极限流电阻和一个集电极上拉电阻,输出反相后接入 S.BUS 设备。如果板子上已经有现成的反相器芯片,比如 74HC04,用其中一个门也行。换用带 TXINV 的芯片时,记得在初始化里多写一行USART_InvPinConfig,不要默认硬件支持。
定时器模拟 UART 是最后的选择。它确实能绕开反相问题,但代价是发送期间 CPU 被比较中断反复打断,如果系统里还有别的实时任务,帧间隔很容易被拉长。它适合那种“必须又省一颗三极管又没碰够引脚”的尴尬局面,不属于常规做法。
4.3 帧发送周期:7ms 一帧与帧间隔控制
SBUS 发送端的节奏控制很简单:定时器每 7ms 触发一次发送,DMA 把 25 字节一次性搬进发送寄存器。不要在中断里用while循环等 TXE 标志,那样会卡死其他任务。用 DMA 发送是正确的做法。
// 定时器回调或主循环里每 7ms 调用一次 void sbus_tx_trigger(void) { // frame 数组由上层每周期更新一次 // DMA 发送 25 字节,不需要等发送完 DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, 25); DMA_Cmd(DMA1_Channel4, ENABLE); }帧间隔不是越小越好。7ms 是常见值,部分接收机帧周期是 14ms,发送端硬要按 5ms 发,接收端反而可能因为帧间隔异常判定同步丢失。我在做这个模块时把帧周期做成了可配置参数,默认 7000 微秒,调试时用逻辑分析仪看两帧间隔做微调。要注意 DMA 发送模式下,相邻两帧之间要保持至少一个字节的低电平空闲时间,否则接收端可能把上一帧的结束字节和下一帧的起始字节并在一起看待。
5. SBUS 实战避坑:五个高频故障的现象、原因与处理
5.1 满屏乱码:SBUS 反相逻辑直接进了 USART
现象:串口助手或调试打印出来的接收数据全是 0xFF、0x00 交替,偶尔有几个看似正常的字节但稳定不下来。
原因:SBUS 是反相逻辑,空闲电平为低,而 STM32 的 USART 期望空闲高电平。F103 的 USART 没有 RXINV 位,信号直接接进串口外设后,每一位都被翻转,解析结果自然全乱。
解决:在 RX 引脚和 SBUS 输出之间加一级反相电路,NPN 三极管或者反相器芯片都行。接完以后用万用表量空闲电平,应该是高电平(3.3V 或按板子电平),这时候再重新跑代码,乱码问题基本消失。
5.2 帧总是对不上:拿 115200 或 125000 顶替 100000
现象:偶尔能解析出一帧,但解析结果不稳定,通道值整体偏移,甚至校验失败率很高。
原因:SBUS 波特率是 100000,不是标准波特率。有人手里只有 115200 或 125000 的例程,改了一位数就觉得接近了,实际上位误差会达到 15% 以上,25 字节的帧大概率错在中间某一位。
解决:初始化里老老实实写 100000。F103 在 72MHz 下 BRR 值正好是 720,误差为 0;如果时钟树配置不同,至少保证误差在 ±2% 以内。更稳妥的办法是换用外部晶振而不是内部 HSI,HSI 的温度漂移在长时间运行时可能把波特率带出容限。
5.3 空闲中断标志清不掉:中断死循环或反复触发
现象:程序进入串口中断后出不来,或者空闲中断触发频率异常高,DMA 数据还没收满一帧就反复解析。
原因:IDLE 标志的清除顺序错了。F103 要求先读 SR 再读 DR 才能清掉 IDLE 标志,很多人只读了一个寄存器,标志置 1 后中断一直被挂起。
解决:在中断服务函数里严格按照“先读 SR,再读 DR”的顺序执行。写完以后用调试器设断点,看每次进入中断是否是“收到一帧后才触发”,如果触发频率和帧率一致,说明清除逻辑正确。
5.4 帧同步错乱:数据区里混进了 0x0F
现象:解析结果时好时坏,通道值偶尔整体错位,而且错位没有规律。
原因:SBUS 数据区 22 个字节是任意值的,完全可能凑出一个 0x0F 出来。只靠找起始字节来定帧,数据区里那个假 0x0F 就会把解析起点带走。
解决:定帧逻辑至少加入三个条件:第 1 字节是 0x0F、第 25 字节是 0x00、帧间隔落在 5~15ms 范围内。连续两帧都满足这三个条件才认为同步完成。同步丢失后不要急着解析单帧,先等下一帧的起始字节,再做条件验证。
5.5 S.BUS 舵机没反应:发送端忘了反相
现象:用 STM32 发送编码好的 SBUS 帧给舵机,舵机完全不动,但同样的代码发到 USB 转串口能正常显示数据。
原因:USART TX 输出的是正逻辑波形,空闲高电平;SBUS 舵机期望的是反相波形,空闲低电平。正负逻辑接反,舵机根本识别不了帧。
解决:按 4.2 节的三种方案任选一种做反相。如果板子上用的是 RX/TX 同一组反相电路,检查一下 RX 和 TX 两路是不是都做了反相,只反相接收不反相发送是常见半吊子问题。加完反相后,用逻辑分析仪确认输出波形空闲为低电平。
6. 用逻辑分析仪验证 SBUS 输出:三个快速检查点
写完解析和编码,别急着往飞控上怼,先用逻辑分析仪把波形抓出来看,这一步能省下大量“为什么不动”的排查时间。我的验证流程固定三步。
第一,把逻辑分析仪采样率设到 1M 以上,协议解码器选 UART,波特率手填 100000,数据位 8,停止位 2,校验偶校验。部分软件需要手动输入 100000,因为标准波特率下拉里没有这个值。解码器显示的是正逻辑波形,如果直接抓的是反相输出,用软件的极性反转功能显示为正逻辑,再看解码结果。
第二,检查帧结构。一帧应该是 25 个字节,第一字节解出来是 0x0F,最后一字节是 0x00。如果第一字节不是 0x0F,说明反相没做对或者波特率有偏差。帧与帧之间的间隔应该稳定在 7ms 附近,抖动不超过 ±1ms。如果间隔乱跳,检查是不是 DMA 发送被其他中断阻塞了。
第三,做一个低成本回环验证。把编码好的帧通过同一个串口输出,外部反相后再绕回这个串口的 RX 引脚,跑一次解析函数,对比解析出来的通道值和发送前是否一致。这个回环测试能同时验证编码、反相、解析三条链路。
// 回环测试:发送帧的原数组和解析帧的结果直接比较 for (int i = 0; i < 16; i++) { if (sbus_channels[i] != sbus_tx_channels[i]) { // 打印出错通道,定位是编码端还是解析端 printf("ch%d: tx=%d rx=%d\r\n", i, sbus_tx_channels[i], sbus_channels[i]); } }顺序错一个通道值,回环测试立刻就会暴露。从那以后,我每次接一个新的遥控接收机或者 S.BUS 舵机,都会先抓波形、数空闲电平,再对一对帧长和周期,确认无误后才继续往下做。这套流程看着笨,但确实帮我少熬了很多夜,尤其是反相那一步,检查电平比对着代码猜快得多。希望帮到你。
本文还有配套的精品资源,点击获取