
搞飞控、做遥控车、玩航模接收机的人基本都绕不开SBUS这一关。我之前在STM32F103C8T6上写过一个SBUS接收模块后来移植到F407上跑四轴姿态解算代码几乎没动。这套方案的思路很明确DMA循环接收负责把串口字节流一个不漏地收进缓冲区IDLE中断在总线空闲时告诉你“这一帧已经发完”剩下的帧同步、通道提取和异常处理全部交给状态机。标题里的三样东西每一样解决一个问题合在一起就是一套非常省心的串口不定长接收框架。这篇文章适合两类朋友一是想在STM32上用HAL库从串口逐字节中断升级到DMA接收的二是第一次接触SBUS协议解析、被100000波特率和反相信号搞懵的。我会按方案选型、协议细节、工程配置、状态机实现、实测排坑的顺序写代码都是F1/F4通用的直接抄作业也行理解了原理再改也行。1. 为什么是 DMA 循环接收 IDLE 中断这套组合1.1 传统串口逐字节中断接收的瓶颈先算一笔账。SBUS的波特率是100000bps物理层按8E2算一个字节占12位也就是120us收一个字节一帧25字节大概3ms。如果按老办法用串口接收中断每来一个字节CPU就要进一次中断一次中断至少要保存现场、读数据、清标志、恢复现场在72MHz主频下少说几百个周期。一帧要打断25次遥控器刷新快的时候每秒要处理100多帧那就是每秒几千次中断对飞控这种还要跑姿态解算、控制环路的场景来说纯属浪费。更麻烦的是串口中断接收只是把字节一个个收到缓冲区里协议帧的边界还得自己维护。你每收一个字节都要判断是不是帧头0x0F收满25字节还要校验帧尾0x00稍不留神就丢一个字节整帧错位。我早期用标准库写的时候这种“收也不知道收没收到、解析也不知道从哪解析”的痛苦印象太深了。1.2 DMA 循环模式到底解决了什么DMA循环接收的思路很粗暴让DMA一直挂在串口的接收线上硬件自动把收到的每个字节搬进内存缓冲区搬满缓冲区就自动回绕到开头继续搬CPU全程不用管。这就是Circular模式和Normal模式最大的区别Normal模式传完一次就停了循环模式永远不会停。这个模式天然适合SBUS这种不定长帧、但帧长固定的协议。我们不需要知道帧从哪里开始因为DMA根本不关心帧结构它只负责把事情做完串口来了数据我就搬到内存搬满一圈再从头搬。真正有价值的其实是DMA内部那个递减计数器NDTR它记录了“缓冲区还有多少位置没写”。用缓冲区总大小减去NDTR就能算出当前DMA写到了哪个位置。这个位置信息在后面定位帧边界的时候非常关键。循环模式的另一个好处是接收和处理完全解耦。主循环在处理上一帧的通道数据时DMA还在接着收下一帧不存在“CPU来不及收就丢字节”的问题。代价是缓冲区里始终是环形覆盖的历史数据所以需要IDLE中断来标定“最新一帧在哪”。1.3 IDLE 中断如何定位帧边界IDLE中断是串口硬件检测到总线上连续空闲时触发的中断。SBUS协议的特点就是帧和帧之间有明显间隔正常模式下大约7到14ms才发一帧而一帧本身只要3ms左右也就是说总线上大部分时间是空闲的。每当一帧数据发送完总线安静下来IDLE中断就会触发。这个中断解决了一个很微妙的问题DMA循环缓冲区里存着大量的历史数据CPU无法直接判断“刚刚收到的这一帧是缓冲区里的哪一段”。但由于IDLE中断是在一帧完全结束之后才触发的此时DMA最后写入的字节必然就是当前帧的最后一个字节0x00。用当前DMA写位置往回倒推25字节理论上就能拿到完整的一帧。这是这套方案的精髓理解了这一点后面代码里的位置计算就顺理成章了。1.4 状态机的角色有了DMA和IDLE接收层面的问题解决了但解析层面的问题还在IDLE中断触发时新接收的数据不一定是一帧完整的25字节。信号线上的毛刺、DMA缓冲区覆盖、上一帧和这一帧之间多收的噪声都可能导致“IDLE之后的数据长度不等于25”。如果把解析逻辑简单写成“收到25字节就处理”早晚会在异常数据上栽跟头。状态机就是对异常数据的兜底。它的核心逻辑是找帧头0x0F然后按顺序收满25字节再检查帧尾0x00。如果中途任何一个环节不对状态机立刻丢弃当前数据回到等待帧头的初始状态重新同步。这样不管接收层面出现什么乱七八糟的字节解析层都能自己恢复不会一错到底。2. SBUS 协议细节解码前必须搞清的几件事2.1 25 字节帧结构逐段拆解SBUS一帧固定25字节结构非常规整。帧头是0x0F帧尾是0x00中间22字节承载16个通道的11位数据最后1字节是标志位。具体如下偏移字节数内容01帧头 0x0F1 ~ 222216通道数据每通道11bit低位在前231标志字节bit0通道17bit1通道18bit2帧丢失bit3故障保护241帧尾 0x00标志字节里真正有用的就4个bitbit0和bit1对应第17、18通道一般用来接遥控器上的两段开关值只有0或2047bit2是frame lost置位表示当前帧信号质量差个别通道数据可能不可靠bit3是failsafe置位表示接收机已经进入失控保护状态这时候就不能再用通道数据了必须执行预置的安全动作。我实际调试时发现不同接收机对这几个标志位的处理不完全一样有的接收机在信号正常时会把整个标志字节置0有的会把bit4到bit7也置1所以解析时只认bit0到bit3就够。2.2 波特率 100000 与 8E2 的玄机SBUS的物理层格式是8个数据位、偶校验、2个停止位也就是常说的8E2波特率100000。很多人第一次看到这个波特率会发懵串口助手里根本选不到这个值因为STM32的USART支持任意波特率把波特率寄存器填成100000就行CubeMX里直接输入数字即可。这里有个容易踩坑的细节STM32的USART在开启偶校验后数据位要配成8位或9位停止位有0.5、1、1.5、2这么几种组合。按字面理解SBUS是8数据位1校验位2停止位那应该配8位数据、偶校验、2停止位对吧但实际上STM32很多系列不支持“8位数据校验位2位停止位”这种组合配出来要么编译报错要么硬件行为不对。我实测下来最稳妥的配置是8位数据、偶校验、1位停止位也就是8E1。为什么能兼容因为UART接收数据时停止位的数量只影响波特率采样窗口多余的高电平时间并不会让数据解析错乱。SBUS接收端虽然按8E2的标准发数据但STM32用8E1模式接收时每个字节多出来的那一位停止位会被当成总线空闲处理不影响后面的起始位检测。网上很多SBUS开源库也默认用8E1接收这是经过大量验证的。2.3 反相信号和硬件反相SBUS最反直觉的一点是信号是反相的。接收机SBUS口输出的TTL电平逻辑1对应低电平逻辑0对应高电平这和常规UART完全相反。如果把SBUS信号直接接到STM32的RX引脚收到的字节大概率是乱码因为UART硬件不认识这种反相波形。解决思路就是在信号进MCU之前做一次反相最简单的办法是用一颗NPN三极管搭反相器。SBUS信号经过1k电阻接到三极管基极集电极接3.3V上拉和STM32的RX引脚发射极接地。SBUS输出高电平时三极管导通RX被拉低SBUS输出低电平时三极管截止RX被上拉到高恰好完成反相。我用S8050搭过也用过2N2222效果都行关键是基极限流电阻不要太小1k比较合适集电极上拉用4.7k到10k都可以注意上拉必须接到3.3V而不是5V否则会把MCU引脚打坏。如果没有三极管也可以用逻辑芯片比如74HC04反向器或者找一块带反相功能的串口转TTL小板。但三极管方案最便宜、最皮实飞控上几十块板子验证下来没有任何问题。如果你用的是3.3V供电的小接收机还要确认SBUS输出电平是不是3.3V最好用示波器或万用表量一下别直接把5V电平灌进STM32引脚。2.4 通道数据的位流排列解析SBUS最容易写错的就是通道提取。16个通道、每个通道11位总共176位塞在22个字节里。很多人第一反应是“每个通道占11bit那字节边界肯定要对齐到某一位”实际上位流是完全连续排列的从第1个数据字节的最低位开始装满是11位就给通道0然后接着往下排给通道1不按字节对齐。这意味着提取第0通道时要从数据字节0的最低8位和字节1的最低3位拼起来提取第1通道时又要从字节1的bit3开始取。如果手写展开公式写16个通道的移位运算非常容易出错改起来也麻烦。我的做法是用一个位缓存器每次不够11位就往缓存里塞一个字节凑够11位就取一个通道代码逻辑清晰而且换协议时改个位数就行。3. HAL 工程配置与核心接收代码3.1 CubeMX 三个关键配置点用STM32CubeMX生成工程的时候三处配置最关键漏掉任何一个后面都跑不起来。第一处是串口参数。我这里用USART1做演示Mode选Asynchronous波特率填100000Word Length选8 BitsParity选EvenStop Bits选1。这里再强调一遍虽然理论上SBUS是2个停止位但STM32配1个停止位实测完全正常千万别在停止位这里卡住。第二处是DMA配置。在DMA Settings里给USART1_RX添加一个DMA通道Direction选Peripheral To MemoryMode必须选CircularMemory Increment Address要勾上Peripheral Increment Address不用勾Data Width选Byte。缓冲区大小不在CubeMX里设置而是在代码里调用接收函数时传入。第三处是NVIC中断。把USART1 global interrupt勾上DMA相关的中断全部不用勾。很多人习惯性把DMA的Half Transfer和Transfer Complete中断也开了这套方案里完全不需要开了反而容易出问题后面排查章节会细说。3.2 初始化代码与回调函数初始化代码非常短核心就三步定义缓冲区、调用HAL_UARTEx_ReceiveToIdle_DMA启动接收然后关闭DMA半传中断。#define SBUS_FRAME_SIZE 25 #define SBUS_RX_BUF_SIZE 256 uint8_t sbus_rx_buf[SBUS_RX_BUF_SIZE]; volatile uint8_t sbus_frame_ready 0; volatile uint32_t sbus_frame_cnt 0; void sbus_uart_init(void) { HAL_UARTEx_ReceiveToIdle_DMA(huart1, sbus_rx_buf, SBUS_RX_BUF_SIZE); __HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT); }HAL_UARTEx_ReceiveToIdle_DMA这个函数是HAL库比较新版本才提供的作用是启动一次“空闲中断DMA接收”流程。它内部会自己去开IDLE中断所以我们不用手动调__HAL_UART_ENABLE_IT。调用完这个函数之后DMA就进入循环接收状态之后每检测到一次总线空闲系统就会执行一次回调。回调函数长这样void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { uint16_t pos SBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t start (pos SBUS_RX_BUF_SIZE - Size) % SBUS_RX_BUF_SIZE; for (uint16_t i 0; i Size; i) { sbus_feed_byte(sbus_parser, sbus_rx_buf[(start i) % SBUS_RX_BUF_SIZE]); } sbus_frame_cnt; HAL_UARTEx_ReceiveToIdle_DMA(huart1, sbus_rx_buf, SBUS_RX_BUF_SIZE); } }这个回调里做的事情看着简单但每一步都有讲究。Size是本次空闲事件之前新接收到的字节数也就是从上一次事件到现在DMA一共搬了多少字节。我们不需要把整块缓冲区都解析一遍只需要从新数据开始的位置喂给状态机就行。3.3 环形缓冲区中的数据定位和拷贝拿到Size之后关键问题是这Size个新字节在环形缓冲区里从哪个位置开始DMA的NDTR寄存器会告诉我们当前位置。__HAL_DMA_GET_COUNTER拿到的是“缓冲区还有多少字节没写”所以当前写位置可以用缓冲区大小 - 计数值算出来。注意这个位置指向的是“下一个要写入的地址”不是“最后一个刚写进去的地址”但用来做环形定位足够了。新数据的起点就是当前写位置往前数Size个字节也就是uint16_t pos SBUS_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t start (pos SBUS_RX_BUF_SIZE - Size) % SBUS_RX_BUF_SIZE;这里的取模运算是为了处理环形回绕。假如缓冲区256字节当前写位置在第10个字节而新数据长度是25那说明新数据是从第(10-25256)241个字节开始的一直写到第9个字节恰好跨过了缓冲区末尾。用这个公式一算起点就对了。理解了原理之后如果你只是想拿到完整的一帧做二次处理也可以把这段新数据复制到一个独立的sbus_frame[25]里但更省事的方式是直接逐字节喂给状态机让状态机自己从字节流里找帧头。我的代码选择后者因为状态机天然具备抗干扰能力就算这次的新数据里混了半个残帧它也能自己同步回来。3.4 一个容易忽略的 API 行为回调里要重新启动接收第一次用HAL_UARTEx_ReceiveToIdle_DMA的人十有八九会在回调里漏掉最后那一行重新调用的代码。原因很好理解DMA不是循环模式吗为什么还要重新启动这个疑问很合理但HAL库的状态机逻辑和底层DMA循环是两回事。DMA层面确实一直在循环接收不会停。但HAL库为了管理回调触发机制在每次触发一次RxEvent事件之后会把串口句柄的接收状态机置为ready状态。如果不重新调用一次HAL_UARTEx_ReceiveToIdle_DMA那么下一次空闲中断虽然还会发生但HAL库不会再去调用HAL_UARTEx_RxEventCallback等于事件上报失效了。这是HAL封装带来的一个隐性行为必须记住。重新调用不会丢数据因为HAL内部会基于当前DMA计数器的位置重新计算基准点之后新来的字节才会被算进下一次Size里。当然重新调用要尽量快最好在空闲中断后的几微秒内完成如果拖到下一帧都快发完了才重新启动那中间这几字节就可能被漏统计。这也是为什么回调里不要做重活的一个原因。4. 状态机解析从字节流到 16 路遥控通道4.1 状态定义与转移逻辑状态机的核心任务是从一串可能被噪声污染的字节流里找出一帧合法的SBUS数据并提取通道值。状态划分越简单越不容易出错我把它分成三个状态等待帧头、收集数据、校验帧尾。等待帧头状态下每一个输入字节都和0x0F比较不是就继续等。一旦匹配到帧头就把它暂存起来进入收集数据状态。收集数据状态下连续收后面的23个字节这23个字节包含22字节通道数据和1字节标志位收满之后进入校验帧尾状态。校验帧尾状态下只需要检查下一个字节是不是0x00。是说明整帧合法解析完成不是说明中间数据有误或者帧头是假的丢弃整个帧结构回到等待帧头重新同步。这个设计有一个好处只要帧头是真帧头那么即使中间某个字节被干扰走到帧尾校验时也一定会失败从而触发重新同步。如果解析的是其他协议把帧头和帧尾的数值换掉代码可以直接复用。4.2 状态机主体代码我用一个结构体来保存解析器状态每次喂入一个字节更新状态。结构体里只需要保存当前状态、已经收集的字节数和一个原始帧数组。typedef enum { SBUS_STATE_WAIT_HEAD 0, SBUS_STATE_COLLECT, SBUS_STATE_VERIFY_TAIL } sbus_state_t; typedef struct { sbus_state_t state; uint8_t count; uint8_t raw[SBUS_FRAME_SIZE]; } sbus_parser_t; sbus_parser_t sbus_parser; void sbus_feed_byte(sbus_parser_t *p, uint8_t byte) { switch (p-state) { case SBUS_STATE_WAIT_HEAD: if (byte 0x0F) { p-raw[0] byte; p-count 1; p-state SBUS_STATE_COLLECT; } break; case SBUS_STATE_COLLECT: p-raw[p-count] byte; if (p-count SBUS_FRAME_SIZE - 1) { p-state SBUS_STATE_VERIFY_TAIL; } break; case SBUS_STATE_VERIFY_TAIL: if (byte 0x00) { p-raw[SBUS_FRAME_SIZE - 1] byte; sbus_parse_frame(p-raw); } p-state SBUS_STATE_WAIT_HEAD; break; default: p-state SBUS_STATE_WAIT_HEAD; break; } }注意count SBUS_FRAME_SIZE - 1这个判断是在把字节写进数组之后执行的所以当count变成24时说明已经收满了一帧中的前24字节接下来需要校验最后一个字节。校验帧尾的状态很简单不管校验成不成功都必须回到等待帧头。成功的话顺便解析不成功就当无事发生继续等下一个0x0F。这个状态机配合前面的DMAIDLE接收天然形成了一个完整的处理链DMA负责收IDLE负责划分批次状态机负责在这一批字节里找到真正的帧。4.3 提取 16 通道的通用位流算法raw[0]是帧头raw[1]到raw[22]是通道数据raw[23]是标志位。提取16个通道时用位缓存器逐位拼接代码比手写16组移位简洁得多也不容易错。void sbus_parse_frame(uint8_t *raw) { uint16_t channels[16]; uint32_t bitbuf 0; uint8_t bitcnt 0; const uint8_t *p raw[1]; for (int i 0; i 16; i) { while (bitcnt 11) { bitbuf | ((uint32_t)(*p)) bitcnt; bitcnt 8; } channels[i] bitbuf 0x07FF; bitbuf 11; bitcnt - 11; } uint8_t flag raw[23]; channels[16] (flag 0x01) ? 2047 : 0; channels[17] (flag 0x02) ? 2047 : 0; uint8_t frame_lost (flag 0x04) ? 1 : 0; uint8_t failsafe (flag 0x08) ? 1 : 0; }这段代码的核心思路是维护一个变量bitbuf作为位缓存bitcnt表示缓存里有多少位是有效的。每次要提取一个11位通道时如果缓存里的位数不够11位就往缓存里塞入一个字节也就是8位直到凑满为止。然后截取低11位作为通道值再把这11位从缓存里清掉。这样16个通道循环下来刚好消费完22个字节一个不多一个不少。4.4 解析后的安全处理拿到16个通道值之后还不能直接用。SBUS通道值的范围是0到2047但遥控器实际输出的行程范围通常比这个小比如常见的FrSky接收机大约在172到1811左右中点附近约992左右。这个校准映射关系可以在飞控调参里做但至少要在解析层做两层保护。第一层是范围保护。如果一个通道值越过了0到2047说明这帧数据已经被严重干扰不能再用了应该直接用上一帧的有效值或者标记该帧无效。第二层是超时保护。如果是接收机断电、信号彻底丢失SBUS总线上会完全安静下来此时IDLE中断也不会触发状态机自然收不到任何数据。这种情况下主循环要有一个看门狗逻辑比如记录最后成功解析的时间戳超过100ms没有新帧就认为信号丢失把所有通道输出到安全值油门通道归零其他通道保持最后一次有效值。故障保护标志位failsafe一旦置位就不要再去执行通道值了执行预置的失控保护动作才是安全的。这部分逻辑虽然简单但在实际项目中往往是最重要的毕竟通道值乱跳顶多让设备动作怪异信号丢失之后还在输出任意值那是要出事的。5. 实测中踩过的坑与排查速查表5.1 回调触发了好几次但 Size 不稳定有朋友按教程抄代码结果发现HAL_UARTEx_RxEventCallback一秒钟被调用好几回而且Size一会儿25、一会儿50、甚至偶尔是0。我先怀疑的是DMA半传中断没有关。CubeMX里DMA配置默认会勾上Half Transfer中断这个中断由DMA触发会打断正常的接收流程让HAL内部的状态机误以为一次接收事件结束了。解决办法就是初始化时加一行__HAL_DMA_DISABLE_IT(hdma_usart1_rx, DMA_IT_HT)或者在CubeMX里把DMA全局中断整个取消勾选。另一个原因是串口中断优先级设得太低。如果串口中断被其他高优先级中断长期打断回调执行时可能已经拖到了下一帧开始这时候重新调用HAL_UARTEx_ReceiveToIdle_DMA拿到的基准位置就不准了。我的建议是串口中断优先级高于普通外设中断但低于SysTick定时器中断这样既不丢串口事件也不会把HAL_Delay卡死。5.2 偶校验配置错误导致数据错位如果你把串口助手接在STM32的TX脚上调试或者用逻辑分析仪抓波形发现收到的字节频率正常但0x0F帧头根本找不到多半是串口参数配错了。SBUS的偶校验是必须的如果配成无校验或者奇校验UART硬件在采样时会把校验位当成数据位的一部分整个字节流全部错位帧头帧尾全乱。排查方法很简单用逻辑分析仪抓SBUS反相之后的波形看单个字节的低电平持续时间和校验位位置然后对照串口配置。我在STLINK和逻辑分析仪上确认过8E1模式下解析完全正常千万别再折腾2停止位。5.3 反相电路导致波形边沿变差三极管反相器如果阻值选得不对会把方波变成梯形波边沿很缓高低电平转换点模糊串口采样就会不稳定。最常见的表现是偶发乱码、偶尔丢帧、帧头能找到但帧尾校验失败。我实际测下来的优化方向是基极限流电阻用1k不要用10k太大三极管开关速度会变慢集电极上拉用4.7k或2.2k上拉越大边沿越缓IRQ引脚上的杂散电容充电时间就越长三极管选择开关速度快的型号比如2N2222或者S8050别用大功率三极管。如果SBUS信号线比较长建议在接收机端加一个100nF电容对地滤波但电容不能太大否则会直接把波形滤没。5.4 丢帧与 HAL_Delay 卡死调试过程中还遇到过一个很隐蔽的问题HAL_Delay在串口接收开启之后卡死主循环直接不跑了。原因是串口中断的优先级设置得比SysTick还高而如果串口中断服务函数执行时间太长SysTick中断一直被阻塞HAL_Delay依赖的uwTick变量就永远不更新。虽然我的串口中断服务函数里只是调了一个HAL_UART_IRQHandler和回调函数时间不算长但和高优先级中断叠加起来就会出问题。解决方法是把串口中断优先级调到低于SysTick或者在中断服务函数里尽量减少处理逻辑解析都丢到主循环去做。这一点在裸机还好说如果后面上了FreeRTOS中断优先级配置更要小心。5.5 排查速查表现象可能原因解决办法一直不进回调串口中断没开NVIC里勾选USART1全局中断一直不进回调回调里漏了重新启动接收回调末尾补调用HAL_UARTEx_ReceiveToIdle_DMA收到数据全是0x00或0xFF反相电路没搭或接反检查三极管接线确认波形已反相频繁进错误中断波特率或校验配错确认100000、8位、偶校验、1停止位回调里Size会跳动DMA半传中断没关初始化时关闭DMA_IT_HTHAL_Delay卡死串口中断优先级高于SysTick调整NVIC优先级串口低于SysTick偶发丢帧串口中断优先级过低提高串口中断优先级回调里别做重活最后再分享一个我自己的调试习惯。拿到一套全新的SBUS接收机我不会先写解析代码而是先搭好反相电路用逻辑分析仪抓原始波形确认波形电平正常、帧间隔稳定然后再写初始化代码。写完接收代码也不急着解析通道先打印原始字节的十六进制确认能看到0x0F开头、0x00结尾的完整帧再往上加通道提取。这样分层验证每一步出问题都能锁定到具体模块比一把梭然后瞎猜快得多。这套DMA循环接收IDLE中断状态机的组合后来我不光用在SBUS上遇到其他固定帧长、帧间有空闲的串口协议比如部分GPS协议、数传协议我也会复用这套框架只是把帧头和帧尾校验值换一下。一个稳定的串口接收底座比任何花哨的协议解析技巧都值钱。