ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 SBUS协议解析:DMA+IDLE中断与状态机实现

STM32 SBUS协议解析:DMA+IDLE中断与状态机实现 1. SBUS 协议为什么普通串口收发在这里不灵1.1 帧结构25 字节里的信息密度SBUS 是航模遥控器接收机输出的一种串行总线协议Futaba 主导推广现在几乎所有主流接收机都支持。它的最大特点是把 16 个比例通道加两个开关通道压缩在一帧里帧长固定 25 字节刷新率约 3ms 一帧也就是 300Hz 左右。对于飞控来说这个刷新率用来跑姿态环完全够用。标准 SBUS 帧结构如下字节偏移长度内容说明010x0F帧头1~2222通道数据16 通道 × 11bit 连续位流打包231flagsbit0ch17, bit1ch18, bit2帧丢失, bit3Failsafe2410x00帧尾很多人第一次看这个表会觉得奇怪16 个通道每个通道 11bit那不应该是 16 × 11 176bit 22 字节吗没错这 22 字节的数据不是按“每两个字节一个通道”简单分割的而是一个连续位流。通道 0 占第 1~11bit通道 1 占第 12~22bit中间会横跨字节边界。这也是为什么网上很多手工移位公式容易写错后面我会给一个更稳的位流解析法。flags 字节在调飞控时特别重要尤其是 Failsafe 和帧丢失标志。如果你在测试时发现通道值偶尔跳变先别急着怀疑 DMA可能只是接收机在丢帧。状态机里必须把这些标志位单独拿出来。1.2 100000 波特率、8E2、反相信号三个易错点SBUS 的串口参数并不是常见的 115200 8N1而是以下三样波特率 1000008 数据位 偶校验2 停止位也就是通常说的 100000 8E2。每个字节实际占 8 数据位 1 校验位 2 停止位 12bit。这里有一个很隐蔽的坑很多新手拿到 SBUS 接收机直接拿 USB 转 TTL 接电脑串口助手默认 115200 8N1打开后屏幕上全是乱码。因为 115200 和 100000 差了 15.2%UART 硬件根本对不齐。另一个坑是校验位SBUS 的偶校验不是可选项是协议的一部分。用 STM32 HAL 库配置时Parity 选 EvenStop Bits 选 2数据位还是 8。还有一个让很多人折腾半天的点SBUS 信号是反相的。UART 空闲电平是高电平起始位是低电平而 SBUS 线空闲时是低电平起始位反而占一个“伪高电平”逻辑说白了就是整个串口电平逻辑反了。STM32 的 UART 硬件不支持输入反相所以不能直接把接收机 SBUS 线接到 MCU 的 RX 引脚必须先做一级反相电路。这个问题我放在后面的硬件章节细讲。1.3 IDLE 中断能不能用来断帧先算一笔时间账SBUS 帧长 25 字节 × 12bit 300bit在 100000bps 下正好是 3ms。协议标称的帧周期也是 3ms。也就是说理论上帧尾停止位结束后下一帧的起始位紧接着就到中间几乎没有空闲。那为什么还要用 IDLE 中断来断帧IDLE 中断的触发条件是总线空闲超过一个完整字节时间在 8E2 下就是 120µs。实际接收机并非所有型号都会严格卡在 3.000ms为了射频调度方便很多接收机会把每帧放在独立的发送窗口里帧与帧之间会留出几十到几百微秒的间隔。我实测 FrSky X4R、Futaba R7008SB 都能稳定触发 IDLE但也有一些廉价接收机帧间空闲不够 120µsIDLE 偶尔不触发。所以稳妥的设计思路不是把 IDLE 当成唯一断帧手段而是当成“主力手段 兜底校验”DMA 循环接收管数据搬运IDLE 中断尽量标记帧边界万一 IDLE 丢失靠 DMA 的传输完成回调和超时机制重新同步。这也是这套组合方案比单纯用 25 字节 DMA 半满/全满中断更稳的原因。2. 硬件与 CubeMX 配置万事俱备才能谈代码2.1 SBUS 信号反相一个三极管解决的事SBUS 线接到 STM32 前必须反相。最常见的做法是一个 NPN 三极管反相器电路思路很简单接收机 SBUS 输出接三极管基极通过 1kΩ 电阻限流。三极管发射极接地。集电极通过 4.7kΩ 上拉电阻接 3.3V。集电极输出接 STM32 的 RX 引脚。这样 SBUS 低电平时三极管截止集电极被上拉到高电平SBUS 高电平时三极管导通集电极被拉到低电平。正好实现了电平反相。选三极管时注意频率9018、2N3904 这类小信号管都可以别拿大功率管开关速度跟不上 100kHz 的串口信号。如果你用的是可以配置输出模式的接收机比如部分 FrSky 接收机支持“SBUS non-inverted”那就可以省掉反相电路直接连 STM32 RX。不过大部分接收机默认输出还是反相 SBUS硬件反相是最通用的做法。2.2 CubeMX 里的串口DMA 参数细节打开 CubeMX按下面配置选择你用的 UART比如 USART2。Mode 选 Asynchronous。参数区 Baud Rate 填 100000Word Length 选 8 BitsParity 选 EvenStop Bits 选 2。DMA Settings 选项卡里添加 USART2_RXDirection 选 Peripheral To MemoryMode 选 CircularData Width 选 Byte。这里最容易被忽略的就是 DMA Mode。很多人默认选 Normal结果 DMA 接收 256 字节后停下来后面数据全丢。SBUS 是持续数据流必须用 Circular 模式让 DMA 自己转圈写缓冲区。NVIC 设置里把 USART2 global interrupt 和 DMA interrupt 都打开。IDLE 中断属于 UART 外设自己的事件所以 UART 全局中断必须开DMA 中断用于传输完成回调和错误回调也建议开。中断优先级方面我习惯把 UART 中断优先级设成抢占 1、子优先级 0DMA 中断设成抢占 1、子优先级 1。不要把它们放在抢占 0因为有些项目里 SysTick 和其他控制任务也需要及时响应优先级太高容易互相挤占。实测下来 SBUS 的 IDLE 事件晚几个微秒处理完全没关系下一帧还会触发。2.3 中断优先级和启动顺序启动流程上有一个小细节先清一次 IDLE 标志再启动 DMA 接收。否则如果总线上正好有残留空闲IDLE 标志已经置位启动后立刻进中断容易给人“代码写错了”的错觉。__HAL_UART_CLEAR_IDLEFLAG(huart2); HAL_UART_Receive_DMA(huart2, sbus_dma_buf, SBUS_DMA_BUF_SIZE);sbus_dma_buf是全局缓冲区大小我建议 256 字节。SBUS 一帧 25 字节256 字节可以缓存大约 8 帧足够覆盖帧间隔不确定的情况。选 256 还有一个好处取模运算可以用按位与虽然编译器大概率会优化但可读性上也舒服一些。3. HAL 代码实现环形缓冲里抠出最新一帧3.1 在 IRQHandler 里捕获 IDLE 事件CubeMX 会生成USART2_IRQHandler我们只需要追加 IDLE 事件处理。注意顺序先调HAL_UART_IRQHandler处理 HAL 自己的逻辑再检查 IDLE 标志。void USART2_IRQHandler(void) { HAL_UART_IRQHandler(huart2); if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart2); sbus_handle_idle(); } }为什么要先调用HAL_UART_IRQHandler因为 HAL 库把错误中断、DMA 传输完成中断的处理都放在这个函数里。如果遇到奇偶校验错误它会在里面停止 DMA 并回调HAL_UART_ErrorCallback我们需要在这个回调里做恢复。先处理这些再处理 IDLE逻辑上更安全。__HAL_UART_CLEAR_IDLEFLAG这个宏在部分 HAL 版本里不存在如果编译报错可以手动清uint32_t tmp huart2.Instance-SR; (void)tmp; huart2.Instance-DR;原理是先读状态寄存器再读数据寄存器UART 外设的清标志机制就是“读 SR 再读 DR”。3.2 根据 DMA 计数器定位帧尾IDLE 中断触发时DMA 正好停留在某处。通过__HAL_DMA_GET_COUNTER可以拿到剩余未传输的字节数从而算出当前写位置uint16_t dma_cnt __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t write_pos (SBUS_DMA_BUF_SIZE - dma_cnt) % SBUS_DMA_BUF_SIZE; uint16_t tail (write_pos SBUS_DMA_BUF_SIZE - 1) % SBUS_DMA_BUF_SIZE;write_pos表示下一个字节将要写入的位置。IDLE 发生时总线已经空闲DMA 不会继续写所以最新一个有效字节就停在write_pos - 1也就是tail的位置。理论上这个位置就是当前帧的帧尾 0x00。这里要做一个去重判断如果tail和上一次解析过的帧尾相同说明 IDLE 是重复触发没有新数据直接返回。static uint16_t sbus_last_tail 0; static void sbus_handle_idle(void) { uint16_t dma_cnt __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t write_pos (SBUS_DMA_BUF_SIZE - dma_cnt) % SBUS_DMA_BUF_SIZE; uint16_t tail (write_pos SBUS_DMA_BUF_SIZE - 1) % SBUS_DMA_BUF_SIZE; if (tail sbus_last_tail) { return; } sbus_last_tail tail; sbus_extract_latest_frame(tail); }3.3 环形拷贝与帧校验最关键的步骤从环形缓冲区里把最新 25 字节完整抠出来。由于缓冲区是环形的帧起始位置可能跨过 255 到 0 的边界所以必须分两段拷贝。static void sbus_extract_latest_frame(uint16_t tail) { uint8_t raw[SBUS_FRAME_LEN]; uint16_t start (tail SBUS_DMA_BUF_SIZE - (SBUS_FRAME_LEN - 1)) % SBUS_DMA_BUF_SIZE; if (start SBUS_FRAME_LEN SBUS_DMA_BUF_SIZE) { memcpy(raw, sbus_dma_buf[start], SBUS_FRAME_LEN); } else { uint16_t first_part SBUS_DMA_BUF_SIZE - start; memcpy(raw, sbus_dma_buf[start], first_part); memcpy(raw first_part, sbus_dma_buf, SBUS_FRAME_LEN - first_part); } if (raw[0] 0x0F raw[24] 0x00) { memcpy(sbus_raw_frame, raw, SBUS_FRAME_LEN); sbus_frame_ready 1; } else { sbus_sync_error_count; } }start tail - 24也就是从帧尾往前推 24 字节得到帧头位置。帧头必须是 0x0F帧尾必须是 0x00两者同时满足才认为这一帧格式正确。如果校验失败说明什么可能 IDLE 触发时总线并不是真正的帧间空闲而是某帧中途出现了超过一个字符时间的停顿导致 DMA 当前写入位置并不对应帧尾。这种情况直接丢弃本帧等下一个 IDLE。SBUS 是 300Hz 高频数据流丢一帧完全不影响控制千万不要为了“补救”去跨帧拼接。3.4 兜底RxCplt 回调应付 IDLE 丢失前面说过有些接收机帧间空闲不够 120µsIDLE 不触发。这样sbus_handle_idle一直不会被调用怎么办靠 DMA 传输完成回调。DMA 循环模式下一圈绕完 256 字节会触发一次性传输完成中断回调HAL_UART_RxCpltCallback。此时我们同样可以从缓冲区里取最新 25 字节void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { uint16_t dma_cnt __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t write_pos (SBUS_DMA_BUF_SIZE - dma_cnt) % SBUS_DMA_BUF_SIZE; uint16_t tail (write_pos SBUS_DMA_BUF_SIZE - 1) % SBUS_DMA_BUF_SIZE; if (tail sbus_last_tail) { return; } sbus_last_tail tail; sbus_extract_latest_frame(tail); } }其实可以把sbus_handle_idle里的公共逻辑抽成一个函数两个地方都调用。这样无论 IDLE 正常触发还是 DMA 转完一圈才兜底都能拿到最新帧。兜底解析出的帧不会是最实时的可能在缓冲区里躺了几个毫秒但对飞控来说完全够用。4. 状态机让解析逻辑自己知道自己该干嘛4.1 状态划分和转换条件SBUS 解析的难点不只是“把字节收进来”而是要知道当前链路处于什么状态是在正常收帧还是已经超时失锁还是收到了带 Failsafe 的帧。如果不用状态机所有逻辑堆在主循环里很容易写出“看起来很对但一抖就乱”的代码。我常用的状态机就三个状态当前状态触发事件执行动作下一状态IDLE收到一帧且校验通过解析通道数据记录时间戳FRAME_READYFRAME_READY上层消费完通道数据清除消费标志IDLEIDLE/FRAME_READY超过 10ms 没有有效帧置 Failsafe/失锁标志FAILSAFEFAILSAFE重新收到有效帧清失锁标志IDLE这里我给超时阈值设的是 10ms。SBUS 正常帧间隔 3ms如果连续 3 帧没收到判定链路失锁很合理。你可以根据产品需求调整但不要低于 6ms否则偶发的丢帧就会误报失锁。状态枚举和数据结构这样定义typedef enum { SBUS_SM_IDLE 0, SBUS_SM_FRAME_READY, SBUS_SM_FAILSAFE } sbus_sm_state_t; typedef struct { sbus_sm_state_t state; uint16_t channels[18]; uint8_t frame_lost; uint8_t failsafe; uint16_t error_count; uint32_t last_valid_ms; } sbus_t; static sbus_t sbus; static volatile uint8_t sbus_frame_ready; static uint8_t sbus_raw_frame[SBUS_FRAME_LEN];4.2 处理函数一毫秒调一次最舒服状态机处理函数建议放在 1ms 定时器里或者主循环里带时间戳调用。伪代码结构如下void sbus_poll(uint32_t now_ms) { if (sbus.state ! SBUS_SM_FAILSAFE now_ms - sbus.last_valid_ms SBUS_TIMEOUT_MS) { sbus.state SBUS_SM_FAILSAFE; sbus.failsafe 1; } if (sbus_frame_ready) { sbus_frame_ready 0; if (sbus_parse_frame(sbus, sbus_raw_frame)) { sbus.last_valid_ms now_ms; sbus.failsafe 0; sbus.frame_lost 0; sbus.state SBUS_SM_FRAME_READY; } else { sbus.error_count; } } }实际工程里“上层消费完数据”这个事件我一般用一个简单机制飞控主循环读取通道值时调sbus_get_channels()读完后自动把状态从 FRAME_READY 切回 IDLE。这样状态机的状态转移完全由数据流驱动不用额外加一个“消费标志”。4.3 超时失锁与 Failsafe 处理Failsafe 和失锁是两回事注意区分失锁frame lost最近一段时间没有收到任何有效帧链路很可能断了。Failsafe接收机自己触发了失控保护帧里的 flags 字节 bit3 置 1通道值通常会变为接收机预设的保存值。在状态机里超时置 FAILSAFE 状态是一种软件层面的兜底而收到有效帧但 flags 里带 Failsafe 时通道数据其实是“假数据”不能直接用于控制。我建议的做法是解析时把 flags 单独存下来上层看到sbus.failsafe 1就直接切换到安全策略比如电机怠速或者维持上一帧而不是猛打舵。实现上很简单sbus-frame_lost (raw[23] 0x04) ? 1 : 0; sbus-failsafe (raw[23] 0x08) ? 1 : 0;同时把 17、18 两个开关通道也解出来。不同接收机对 bit0/bit1 的定义可能相反建议以自己接收机手册为准。4.4 11bit 通道位流解码这是 SBUS 解析的核心算法。我不用手工移位公式而是用位缓冲循环提取代码短且不容易错static uint8_t sbus_parse_frame(sbus_t *sbus, const uint8_t *raw) { uint8_t ch_data[22]; if (raw[0] ! 0x0F || raw[24] ! 0x00) { return 0; } memcpy(ch_data, raw[1], 22); uint32_t bit_buf 0; uint32_t bit_cnt 0; uint16_t ch_idx 0; for (uint32_t i 0; i 22; i) { bit_buf | ((uint32_t)ch_data[i]) bit_cnt; bit_cnt 8; while (bit_cnt 11 ch_idx 16) { sbus-channels[ch_idx] bit_buf 0x07FF; bit_buf 11; bit_cnt - 11; ch_idx; } } sbus-channels[16] (raw[23] 0x01) ? 2047 : 0; sbus-channels[17] (raw[23] 0x02) ? 2047 : 0; sbus-frame_lost (raw[23] 0x04) ? 1 : 0; sbus-failsafe (raw[23] 0x08) ? 1 : 0; return (ch_idx 16) ? 1 : 0; }这段代码的原理是一次读入一个字节到bit_buf只要累积位数够 11bit就提取一个通道。22 字节总共 176bit16 个通道正好 176bit所以循环结束后ch_idx一定是 16。如果因为某些原因不满足说明帧数据有问题返回解析失败。很多老手会用一个手工移位公式ch[0] data[0] | (data[1] 8); ch[1] (data[1] 3) | (data[2] 5);这种公式背起来容易写错一个右移位数就全乱。用位缓冲方式就不依赖记忆逻辑清晰移植到其他协议只要改通道位数和通道数就行。5. 实调实录五个踩坑和验证方法5.1 最常见问题信号没反相现象SBUS 接上去串口中断里完全不进 DMA 回调或者一进串口就报一堆错误。用示波器看 RX 引脚发现空闲时是低电平而不是 UART 标准的空闲高电平。这就是没做反相。反相解决后再用逻辑分析仪看波形起始位和停止位应该是正常 UART 的样子。这一步没确认好后面所有代码都是白调。5.2 忘了清 IDLE 标志现象进了一次 UART 中断后再也出不来程序卡死或者主循环被疯狂打断。原因就是 IDLE 标志没有清除。HAL 库的HAL_UART_IRQHandler不处理 IDLE所以一定要在 IRQHandler 里自己调__HAL_UART_CLEAR_IDLEFLAG。如果用了老版本 HAL 没有这个宏就用“读 SR 再读 DR”的方式清。这个坑几乎每个初学 DMAIDLE 的人都会踩一次我在论坛上看到至少十几个帖子问同样的问题。5.3 奇偶校验错误导致 DMA 停摆现象跑一段时间后 SBUS 通道值不再更新重新初始化又好一阵。打开调试器看问题出在HAL_UART_ErrorCallback。SBUS 的偶校验由硬件处理如果线路受到干扰或者波特率偏差超过容忍范围UART 会收到奇偶校验错误并置 PE 标志。HAL 库在错误中断里会停止 DMA之后就不再接收了。解决办法是在错误回调里处理但注意不要在中断里直接重启 DMA。安全做法是置一个标志static volatile uint8_t sbus_uart_error; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { sbus_uart_error 1; } }在主循环里检测到标志后先HAL_UART_DMAStop清 IDLE再重新HAL_UART_Receive_DMA。这样能避开中断嵌套重新启动 DMA 可能导致的 BUSY 状态问题。5.4 用逻辑分析仪确认帧边界调协议解析最怕的是“代码看着对但数据源本来就不对”。我强烈建议在电脑上先用 USB 转 TTL 接 SBUS 反相输出用串口工具选 100000 8E2 看原始帧。正常应该能看到0F 00 00 ...这种规律。然后你把第一帧 25 个字节完整拉出来和接收机说明书对比。如果串口工具不支持 100000 这个非标波特率可以用逻辑分析仪采样率设置 1MHz 以上协议解码里选 UART 100000 8E2。这一步能快速区分“SBUS 信号问题”和“STM32 软件问题”省掉大量无意义调试。5.5 通道值不连续或抖动的排查顺序如果通道值一直有但偶尔跳变按这个顺序排查看frame_lost标志是不是频繁置位。是说明接收机到飞控的链路本身在丢帧先查干扰和接线。看failsafe标志。是接收机进入失控保护通道值跳变是正常的。前两个都正常检查你的通道映射。SBUS 的通道值是 0~2047中位一般在 1024 左右。如果摇杆中位读出来是 600 或者 1400大概率是遥控器端通道方向/行程没设置好不是协议解析问题。最后才是怀疑 DMA 环形缓冲区跨边界拷贝。可以故意把缓冲区大小改成 25强制每次传输完成都触发一次对比解析结果。如果改小后反而正常那就是跨边界处理有漏洞。在实际项目里继续扩展这套 DMA IDLE 状态机结构跑通后后续扩展非常方便。比如要把 SBUS 换成 ELRS 的 CRSF 协议只需要换掉串口参数和帧解析函数状态机骨架可以原封不动。如果想上 FreeRTOS可以把sbus_poll丢到单独任务里通过队列把解析好的通道值发给控制任务中断里只置标志任务里做实际解析。我个人的一个小建议是调试时在 1ms 定时里把“距上次有效帧时间”打出来这个数值比通道值本身更能反映链路健康度。我最早就是靠这个字段定位到偶发丢帧问题而不是纠结通道值抖动。SBUS 解析这种活儿数据链路稳了协议层其实没那么复杂。
返回列表