1. 方案选型与协议拆解1.1 SBUS 信号长什么样搞飞控和遥控转发的人应该都被 SBUS 协议折腾过。它最大的优点就是一根线把 16 个通道全部传过来不用像 PWM 那样一路一路接。我最早接触的时候以为就是个普通 UART结果直接接 STM32 的 RX 脚死活收不到数据拿示波器一看才发现波形是反的。这就是 SBUS 的第一个坑信号电平反向。遥控接收机输出的 SBUS 信号在空闲时是低电平起始位是高电平和我们平时看到的 UART 空闲高电平正好相反所以必须先用反相器把它翻回正常 TTL 电平再进串口。SBUS 的速率是 100kbps数据格式 8E2也就是 8 个数据位、偶校验、2 个停止位。一帧固定 25 字节第 0 字节是帧头 0x0F第 1 到第 22 字节是通道数据16 个通道每个 11 bit一共 176 bit正好塞进 22 字节第 23 字节是 flags里面打包了第 17、18 两个数字通道还有 frame lost 和 failsafe 标志位第 24 字节是帧尾 0x00。遥控器一般 100Hz 刷新率也就是说每 10ms 左右就有一帧算下来每帧实际传输时间大约 3ms剩下 7ms 总线是空闲的这个空闲窗口就是我们用 IDLE 中断来切帧的关键。1.2 为什么不用串口中断逐字节接收刚开始拿到这个方案时我也纠结过SBUS 每字节才 120us 间隔1 起始位 8 数据位 1 校验位 2 停止位 12 bit100kbps 下就是 120us如果靠串口 RXNE 中断一个字节一个字节收一旦主循环里跑点费时间的代码很容易丢字节。尤其是边打印调试信息边解析时串口中断响应不过来就会出现帧头错位、通道值跳变这类问题。DMA 循环接收的意义就是把“一个一个收”这件事从 CPU 手里彻底拿走。配置好 UART 的 DMA 接收后串口硬件收到的字节自动写进内存缓冲区CPU 完全不用管。等一整帧收完了总线空闲会产生一个 IDLE 中断此时 CPU 只需要去 DMA 计数器那里算一下新数据有多长把数据交给状态机解析就行。这样每 10ms 才打断一次中断负载几乎可以忽略给其他任务留出了充足的时间。1.3 状态机在方案里的角色状态机不是必须的吗如果不做抗干扰确实可以“每次 IDLE 就认为收到一帧”。但实际环境里遥控信号经过长线传输、电机电调干扰DMA 缓冲区里可能混入噪声、半包甚至两帧拼接的情况。状态机的作用是不管来的是什么字节流它只认 0x0F 帧头然后连续收满 24 个字节再交给 11bit 解包函数。一旦中途出错它能自动回到找帧头的状态不让自己卡死。所以我最终的方案是三条腿走路DMA 干脏活累活搬数据IDLE 中断负责告诉 CPU“有一波数据到齐了”状态机负责在字节流里把合法帧挑出来。下面我会把 CubeMX 配置、核心代码、常见坑一步步讲清楚。2. CubeMX 工程配置与硬件准备2.1 串口参数和 DMA 配置我使用 STM32F103 系列做示例其他系列原理一样。在 CubeMX 里选一个带 DMA 的 USART比如 USART1。波特率填 100000数据位选 8校验位选 Even停止位选 2。这里有个容易懵的地方STM32 的寄存器里8 数据位 偶校验在 CubeMX 中会体现为 Word Length 9 bits因为校验位也在串口帧里面占一位但实际我们读到的数据寄存器只有 8 位有效数据硬件已经把校验位处理掉了所以不要选成 9 位纯数据。DMA 的设置是重中之重。添加 USART1_RX 的 DMA 请求后方向一定选 Peripheral To Memory内存地址递增开启外设地址固定数据宽度都是 Byte模式必须选Circular。循环模式意味着 DMA 装满缓冲区后会自动回卷到开头继续接收不需要软件干预。或者用 CubeMX 新一点的界面叫 Circular 模式或 Circular 模式 Disabled 后DMA 收满指定长度就停了如果不重启 DMA后续数据直接丢失所以一定要确保循环模式开启。优先级方面如果系统里还有其他 DMA建议把 USART1_RX 的优先级设成 High因为遥控数据实时性要求比较高被其他 DMA 抢占导致串口 FIFO 溢出就会丢数据。2.2 中断优先级怎么定NVIC 里需要使能 USART1 global interrupt。这里有个细节我们要的是IDLE 中断不是 DMA 中断DMA 中断可以完全不开。因为 DMA 循环接收本身不会在每帧结束时触发任何中断只有我们手动使能串口的 IDLE 中断才能在总线空闲时被及时唤醒。中断优先级建议设为 1 或者 2抢占优先级尽量高。如果跑 FreeRTOS要注意串口中断优先级不能高于configMAX_SYSCALL_INTERRUPT_PRIORITY否则在中断里调用带FromISR后缀的 API 会出问题。我这里把串口中断放在主循环解析方案里所以优先级给到 2比较听话。2.3 反相处理的两种办法SBUS 电平反转是绕不开的。硬件上最稳的是用一个三极管或者 74HC04 反相器把接收机的 SBUS 输出反相后接 STM32 的 RX。很多现成的遥控接收机转接板里面就带了这个反相电路直接买一块也行。如果你不想加硬件部分 STM32 系列串口支持 RX 输入反相。以 F103 为例可以在初始化之后手动设置huart1.Instance-CR2 | USART_CR2_RXINV;。但我个人不推荐全靠这个功能因为不同芯片、不同系列对 RXINV 的支持情况不一样HAL 库默认不处理这个位万一某个批次芯片有 bug排查起来很麻烦。我自己的习惯是硬件反相为主RXINV 只是应急备选。CubeMX 生成代码后在main()里加两行启动代码HAL_UART_Receive_DMA(huart1, sbus_dma_buf, SBUS_DMA_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);顺序很重要先启动 DMA 接收再使能 IDLE 中断。如果反过来可能第一帧数据来了但 DMA 还没准备好就会表现为前几帧丢失。3. DMA 循环接收与 IDLE 中断处理实现3.1 缓冲区大小和环形读指针计算缓冲区大小我建议取 256 字节至少是帧长的 10 倍。有人图省事把缓冲设成 25 字节刚刚好一帧但这样有个隐患如果主循环忙了几毫秒下一帧数据来了之后DMA 会立刻把这 25 字节覆盖掉旧帧数据还没来得及拷贝就没了。用 256 字节相当于给了自己一个缓冲垫10ms 一帧的情况下即使偶尔卡顿 20ms 也还有余量。DMA 循环接收时内存写指针一直在往前走绕过 256 字节边界后回卷。我们如何知道“这次新收到了哪些数据”核心是利用 DMA 的剩余计数寄存器。STM32 的 DMA 每个通道都有一个 NDTR记录还剩多少个数据没传。因为传输方向是外设到内存NDTR 每次接收一个字节就减一所以当前 DMA 写指针的位置可以用下面公式算出来uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t current_index SBUS_DMA_BUF_SIZE - remain; // 下一个要写入的位置比如缓冲区 256现在 remain 是 180说明已经写了 76 字节下一个字节会写到内存偏移 76 处。DMA 是循环的所以 current_index 在 0~255 之间跳转。计算两次 IDLE 之间新收了多少数据不需要直接比较两个计数器而是用环形差uint16_t diff (current_index SBUS_DMA_BUF_SIZE - last_index) % SBUS_DMA_BUF_SIZE;last_index 是上一次 IDLE 时的 current_index。这个 diff 就是从上一次空闲到现在串口一共收到的字节数。由于 SBUS 两帧间隔至少是 7ms正常情况下 diff 应该等于 25 左右如果噪声多可能多一点如果接收机没输出可能为 0状态机会自动忽略。3.2 IDLE 中断处理函数怎么写这里要注意 HAL 库的中断模型。传统写法是在USART1_IRQHandler里自己判断 IDLE 标志然后清标志再调用HAL_UART_IRQHandler让 HAL 帮忙处理其他错误中断。完整的代码是这样的extern DMA_HandleTypeDef hdma_usart1_rx; volatile uint8_t sbus_dma_buf[SBUS_DMA_BUF_SIZE]; volatile uint16_t sbus_last_index 0; volatile uint16_t sbus_new_data_len 0; volatile uint8_t sbus_data_ready 0; void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t curr SBUS_DMA_BUF_SIZE - remain; uint16_t received (curr SBUS_DMA_BUF_SIZE - sbus_last_index) % SBUS_DMA_BUF_SIZE; if (received 0) { // 这里不直接解析只把长度记下来数据还在 DMA 缓冲里 sbus_new_data_len received; sbus_last_index curr; sbus_data_ready 1; } } HAL_UART_IRQHandler(huart1); }中断里只做标志记录不做状态机解析这是为了缩短中断时间。解析放到主循环里即使解析慢一点也不会影响中断响应。__HAL_UART_CLEAR_IDLEFLAG这个宏对不同系列处理不同F1 上会写 SR 寄存器H7 上会处理 ISR/ICR用 HAL 宏可以避免移植问题。需要提醒的是HAL_UART_IRQHandler里本身也会检查 IDLE 标志如果我们在前面已经把 IDLE 标志清掉它就不会再触发RxEventCallback所以不用担心两边重复处理。3.3 从环形缓冲区安全取数据IDLE 只标记了长度真正要用的数据还在 DMA 缓冲区里。因为 DMA 是循环的数据可能从某个偏移开始也可能跨越缓冲区末尾绕回开头。主循环里做切片拷贝时要处理这种回绕。比如 received 等于 25last_index 是 240那么这 25 字节分布在偏移 240~255 共 16 字节再从 0~8 共 9 字节。直接 memcpy 会越界所以分成两段拷贝void sbus_copy_new_data(uint8_t *dst, uint16_t start, uint16_t len) { uint16_t first len; if (start len SBUS_DMA_BUF_SIZE) { first SBUS_DMA_BUF_SIZE - start; } memcpy(dst, (uint8_t *)sbus_dma_buf[start], first); if (first len) { memcpy(dst first, (uint8_t *)sbus_dma_buf[0], len - first); } }我在实际项目中会把 IDLE 中断里的 received 限制在一个合理上限比如 50。如果某次 diff 超过 50说明可能漏处理了很多帧这种情况直接丢弃前面的旧数据只留下最后一个有效窗口内的数据避免状态机一次吞太多无用字节。判断条件大致是if (received SBUS_MAX_CHUNK) { received SBUS_MAX_CHUNK; sbus_last_index curr; }但要注意如果强行裁剪可能丢掉半个帧头。更稳的办法是把 received 原样交给状态机状态机自己会等 0x0F 重新同步所以稍微多收点噪声问题不大。只要中断里不解析主循环处理几十字节也就几十微秒而已。4. 状态机解析 SBUS 帧4.1 有限状态机的数据结构状态机本质上就两件事找帧头收数据。所以我用一个非常简单的小结构体typedef enum { SBUS_STATE_SYNC 0, SBUS_STATE_DATA } sbus_state_t; typedef struct { sbus_state_t state; uint8_t idx; uint8_t frame[SBUS_FRAME_LEN]; } sbus_parser_t;初始化时把 state 置成 SYNCidx 清零。主循环拿到新数据后逐个字节喂给状态机void sbus_parser_feed(sbus_parser_t *p, uint8_t byte) { switch (p-state) { case SBUS_STATE_SYNC: if (byte 0x0F) { p-frame[0] byte; p-idx 1; p-state SBUS_STATE_DATA; } break; case SBUS_STATE_DATA: p-frame[p-idx] byte; if (p-idx SBUS_FRAME_LEN) { sbus_decode_frame(p-frame); p-state SBUS_STATE_SYNC; p-idx 0; } break; default: p-state SBUS_STATE_SYNC; p-idx 0; break; } }这段代码看起来简单但它有几个隐藏的防御点。比如如果在 DATA 状态中途出现其他乱码但长度还没到 25状态机不会自我纠正如果一帧数据被电调干扰劈成两半中间多余字节会让 idx 错位。实际使用中我会在 DATA 状态加一个超时计数器比如超过 20ms 还没凑满 25 字节就强制回到 SYNC重新等帧头。这个超时判断不能在字节喂养函数里做而是在主循环里周期检查if (parser.state SBUS_STATE_DATA (HAL_GetTick() - last_byte_time 20)) { parser.state SBUS_STATE_SYNC; parser.idx 0; }因为 SBUS 是 100Hz 刷新正常一帧不会超过 10ms20ms 超时足够宽容又能防止状态机卡死。4.2 对 0x0F 帧头的抗干扰处理只凭一个 0x0F 就认为是帧头在干净的环境下没问题但在电机电调附近很容易误触发。SBUS 本身没有 CRC 校验唯一能用的校验信息是帧尾 0x00以及 flags 位。我做的强化验证是如果收到完整 25 字节先检查frame[24] 0x00如果不等于 0说明这个“帧头”是噪声假货丢弃整帧并且继续回到找帧头状态。一些接收机尾字节不一定是 0x00尤其带扩展协议的设备。我查过几家遥控器Futaba SBUS 标准确实固定 0x00所以默认按 0 校验是安全的。如果你用的接收机输出异常可以把这一行检查做成条件编译或宏开关方便现场调整。还可以加一个简单的时间校验在 IDLE 数据进入状态机之前记录这次数据的系统 tick解析完一帧后判断与上一帧的间隔是否在 5ms 到 15ms 之间。如果不在大概率是干扰伪造帧直接丢弃。做飞控时我强烈建议加上这个校验因为遥控器的 100Hz 定时是稳定的干扰产生的 0x0F 不会恰好落在这个窗口内。4.3 16 通道 11bit 数据解包SBUS 的 22 字节通道数据是按位拼接的不是每个通道一两个字节所以解包最核心的算法是“从字节流的任意 bit 偏移处取 11 bit”。我写过几种实现最不容易错的是一口气读 3 字节然后右移偏移位再按 0x7FF 掩码取 11 位。uint16_t sbus_get_11bit(const uint8_t *buf, int channel) { int bit_pos 1 channel * 11; // 跳过帧头从 bit 8 开始 int byte_idx bit_pos / 8; int bit_off bit_pos % 8; uint32_t v buf[byte_idx] | (buf[byte_idx 1] 8) | (buf[byte_idx 2] 16); return (uint16_t)((v bit_off) 0x07FF); }注意这里偏移计算里加的1是因为帧头占用第 0 字节真正的通道数据从第 1 字节开始。每个通道占 11 bit所以第 0 通道从 bit 8 开始取第 15 通道从 bit 173 开始取正好能取满 22 字节。最后一个通道读取时需要 buf[byte_idx 2]其实就是 flags 字节但因为 mask 只取 11 位多读出来的高位会在 0x07FF时被丢掉不会污染结果。解析完整帧后我把结果填到一个结构体里直接给上层飞控逻辑用typedef struct { uint16_t ch[16]; uint8_t ch17; uint8_t ch18; uint8_t frame_lost; uint8_t failsafe; } sbus_channels_t; void sbus_decode_frame(const uint8_t *frame) { sbus_channels_t out; for (int i 0; i 16; i) { out.ch[i] sbus_get_11bit(frame, i); } uint8_t flags frame[23]; out.ch17 flags 0x01; out.ch18 (flags 1) 0x01; out.frame_lost (flags 2) 0x01; out.failsafe (flags 3) 0x01; }如果不追求极致内存16 通道的 11bit 值可以直接用 uint16_t 数组存后续软件要映射 PWM 或发送到地面站时直接使用。通道值范围一般是 0~2047遥控器油门中位不一定恰好是 1024要在上层根据遥控器类型做校准。4.4 主循环里如何调度解析整体调度我放在主循环里uint8_t temp_buf[SBUS_DMA_BUF_SIZE]; while (1) { if (sbus_data_ready) { sbus_data_ready 0; sbus_copy_new_data(temp_buf, (sbus_last_index - sbus_new_data_len SBUS_DMA_BUF_SIZE) % SBUS_DMA_BUF_SIZE, sbus_new_data_len); for (uint16_t i 0; i sbus_new_data_len; i) { sbus_parser_feed(parser, temp_buf[i]); } sbus_new_data_len 0; } }准备一个temp_buf的目的是防止主循环解析过程中 DMA 继续往同一个缓冲区写数据读一半被覆盖。拷贝 25~50 字节耗时极短可以接受。如果你跑 RTOS可以把这块放到一个 1ms 周期执行的低优先级任务里逻辑一样。5. 调试方法、常见问题与优化心得5.1 怎么确认串口波形正常调试 SBUS 第一步永远是查波形。用逻辑分析仪抓接收机输出的 SBUS 引脚可以看到一串 25 字节的 UART 波形波特率约 100kHz空闲时是低电平这就是“反相”特征。再用示波器看 STM32 的 RX 引脚如果波形空闲时是高电平说明反相已经处理好可以进入软件调试。没有逻辑分析仪也不要紧可以直接用串口助手接收 TTL 反相后的数据波特率 100000、8E2打开十六进制显示。正常每 10ms 左右收到一串 AA取决于数据内容其中第 0 字节固定是 0F。如果一串乱码且第 0 字节不是 0F大概率波特率或校验位没配对。这里注意很多 USB-TTL 的波特率上限和校验支持都没问题但必须先把反相弄好否则根本解不出来。5.2 数据丢帧和通道值乱跳怎么查我遇到的第一类问题是“偶尔丢一帧”。排查下来基本是两个原因一是串口优先级太低被其他中断长时间打断二是 DMA 缓冲区太小。解决办法是优先查 NVIC 配置再看缓冲区是不是只有 25 字节直接扩到 256 字节丢帧问题立刻缓解。第二类问题是“通道值乱跳、Failsafe 频繁触法”。这个多半是 0x0F 误同步导致解析错位。我建议优先检查帧尾 0x00 校验以及两帧间隔时间校验。如果乱跳出现在电机大油门瞬间大概率是电源干扰接收机到飞控之间换成双绞线或者加磁环更有效。软件层面能做的只是尽量过滤错帧但硬件抗干扰不解决解析得再对也救不了。第三类问题是“一切正常但偶发第一帧解析错误”。比如开发板上电瞬间 DMA 缓冲区里有残留数据IDLE 第一次触发时received 包含了上电以来的随机字节。状态机会先找 0x0F如果残留里恰好有 0x0F 就可能出一帧错值。解决办法是上电后先清空 DMA 缓冲区把 last_index 指向当前 DMA 写位置并且等收到至少 3 帧完整数据后再把解析结果标记为有效。5.3 HAL 库新旧版本差异我最初参考网上的代码用的是HAL_UARTEx_ReceiveToIdle_DMA配合HAL_UARTEx_RxEventCallback这个写法在较新的 HAL 库中支持得很好CubeMX 里也能直接配置。但换成老版本库就用不了于是我又回到“自己处理 IDLE 读 DMA 计数”的方式。这个方式没有 HAL 版本依赖只要能拿到 DMA 句柄就能用更适合长期维护。如果你用的是 H7 系列要知道 DMA 支持 burst 模式配置要点更多。上面这套代码在 F1、F4、G0 上都能跑H7 上要注意 DMA 时钟和 memory 类型其他逻辑相同。5.4 实际操作中的小技巧解析函数里不要加 printf否则调试输出会反过来拖慢解析。可以在主循环里用一个计数器每 1 秒打印一次接收帧数判断是否稳定在 100 左右。状态机的 frame 数组最好用普通栈变量不要声明为 volatile否则编译器会禁止很多优化。DMA 缓冲区和标志位才需要 volatile。如果在中断里调用了HAL_UART_Receive_DMA二次启动要注意锁冲突。推荐的做法是只在初始化时启动一次后续完全靠循环模式自动续传避免反复重启带来的窗口期。5.5 后续还能怎么扩展这套“DMA 循环接收 IDLE 中断 状态机”不止能解 SBUS还可以直接套用到 MAVLink、XModem、Modbus 这类固定或半固定帧协议。只要把帧头、长度、超时校验换掉其他结构不用动。如果你接下来要做 SBUS 转 PWM、SBUS 转 MAVLink甚至多路 SBUS 信号同时接入原理都是一样的把缓冲区做成分组模式一个 DMA 通道对应一个解析器即可。我个人最深的体会是不要把所有的逻辑都堆在 DMA 中断里。DMA 和 IDLE 只负责“把数据完整地搬进来”协议解析必须交给状态机慢慢磨。每次想提高处理速度时先看一眼是不是在中断里干了太多不必要的事。SBUS 解析做到后面其实没什么稀奇但把这套结构和抗干扰思路玩熟了许多串口协议项目都能举一反三。
企业数字化 ERP 产品动态
相关推荐
牛津词典数据Excel与SQL查询实操指南:从PDF到可查询词库 简介:《牛津英语词典》非PDF电子版提供Excel与SQL两种数据格式,定位为可直接使用和二次开发的词库资源,面向需要灵活处理翻译数据的语言学习者、教师及开发者。Excel工作簿适合个人学习,支持直接检索、编辑、排序和筛选࿰… · 2026/9/25 5:32:52
2026企业SD-WAN组网怎么选?12个选型要点与5种主流组网模式 本文面向负责多分支网络的 IT 负责人与网络架构师。厂商与产品信息为公开资料整理;文中案例均已脱敏,数字为参考值;技术估算基于公开模型,以实测为准。SD-WAN 已经过了"要不要上"的阶段。十年前它以"用互联网替代昂… · 2026/9/25 5:32:52
OPC-Client-X64实战:从环境配置到订阅重连的工业数据采集链路 简介:这是一份面向工业自动化领域开发者的OPC DA客户端开发资源,适用于在64位Windows环境下使用Visual Studio 2013构建与OPC服务器通信的应用程序。资源聚焦OPC Data Access标准,涉及COM/DCOM通信机制、IOPCServer与IOPCDataAccess等核心接口… · 2026/9/25 5:32:52
网御星云安全集中管理系统实战:日志接入、告警配置与运维避坑指南 简介:面向网御星云安全集中管理系统的运维人员与安全管理员,这份PDF手册围绕V3.0.7版本,系统梳理了从登录到主页模块的使用方法。内容涵盖产品特点、软件描述、License控制,并重点展开安全等级、24小时安全趋势、服务器状态和设备… · 2026/9/25 5:56:09
网络安全防御能力评价体系框架:量化评分与整改闭环实战指南 简介:《网络安全防御能力评价体系框架》PDF是360政企安全推出的实战化网络安全能力度量与评价方法,面向企业CISO、安全架构师与蓝队评估人员,解决传统等保、ISO27001等体系“看似完善却难以预判真实攻击效果”的痛点。资源为单个PDF文件&… · 2026/9/25 5:56:09
红蓝攻防全景图:一张图掌控攻防演练全流程 简介:这份PPT全景图面向网络安全攻防人员、蓝队防御工程师及企业安全管理者,系统梳理红蓝攻防实战中的攻击面、暴露面识别,边界突破/防护、横向渗透/区域控制、攻陷/强控等关键阶段,并以基础、强化、协同三层保护机制构建综合防御… · 2026/9/25 5:56:09
PaddleSpeech 的 Kaldi 兼容语音特征提取:python_kaldi_features 从原理到实战 人工智能语音音频NLP媒体生成 【免费下载链接】PaddleSpeech Easy-to-use Speech Toolkit including Self-Supervised Learning model, SOTA/Streaming ASR with punctuation, Streaming TTS with text frontend, Speaker Verification System, End-to-End Speech Translation … · 2026/9/25 5:56:03
AI PLC落地实战:确定性与智能的工业融合方法论 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 5:56:03
Plugin4Shell零点击漏洞:AI编程助手插件安全风险与防护指南 1. 从"零点击"说起:Plugin4Shell 到底在讲一件什么事先把结论摆在前面:Plugin4Shell 不是一个具体的软件产品,而是一类针对 AI 编程助手插件体系的漏洞利用思路的统称。它的核心特征在于"零点击"——也就是说,… · 2026/9/25 5:56:03
创维E900V22D刷机全攻略:S905L3SB芯片兼容性解析与救砖实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:31
MQTT协议原理与Broker服务器搭建实战:从Mosquitto到EMQX /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/25 1:00:37