news 2026/10/3 17:18:15

SPD5集线器协议解析:从抓包到CRC校验的全流程实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SPD5集线器协议解析:从抓包到CRC校验的全流程实战

做嵌入式这几年,我养成了一个习惯:凡是带通讯口的设备,到手第一件事就是把它协议抠明白。前段时间调试一套多通道数据采集系统,设备端用的是一台 SPD5 集线器。说实话,这个集线器算不上高端,但它的协议设计非常典型——既有定长帧又有变长帧,还带了多通道映射和主动上报机制。我把整个解析过程整理了一遍,不是单纯贴一份寄存器表,而是把从抓包、确认帧格式、校验计算到写解析脚本的全链路都捋清楚。后面只要有人拿到同类设备,完全可以按这套思路走一遍,能少踩很多坑。

这篇文章适合正在做工业数据采集、串口设备接入、现场总线调试的朋友,哪怕你手头没有 SPD5,只要原理上懂了,换个型号的集线器也只是帧格式不同而已。我会把每一步操作都写到能直接照着做的程度,包括串口参数怎么设、原始字节流怎么找帧头、CRC 怎么算、解析脚本怎么写。

1. SPD5 集线器到底是台什么设备

1.1 设备定位与典型应用场景

SPD5 这个型号,从名字看就是 SPD 系列的第五代产品。它本质是一个集线器,位于主机和若干从设备之间,负责把多路串行总线数据汇集到一条主通信链路上。注意,这里的“集线器”不要跟网络里的 Hub 混为一谈,SPD5 做的是串行总线的汇聚,最常处理的是 RS485、RS232、TTL 电平,部分变种型号还支持 CAN 输入。

典型场景一般是这样的:主控板只有一两路 UART,但现场却要接七八个仪表或者传感器。传统做法是让主控挂多路串口,或者用 485 总线把所有设备串在一条线上,这要求每个设备都支持同一种总线协议。可现实中设备五花八门,有 Modbus 的、有自定义协议的、还有只支持 RS232 点对点的,根本没法直接串到一起。SPD5 就是来解决这个麻烦的:各从设备分别接到集线器的独立通道上,集线器统一排队、统一打包,再发给主控。

这样设计的好处很明显。物理层上,不同电平、不同电气特性的设备被隔离开了;链路层上,主控只需要处理一个串口的数据流,不用同时维护多个通信任务;数据完整性上,SPD5 会在每一帧外面再包一层自己的格式,主控可以通过帧信息判断数据来自哪个通道、长度对不对、校验是否通过。对于现场调试来说,这比裸串口透传要省心得多。

1.2 为什么一定要做协议解析

很多人拿到这种分线器、集线器,第一反应是“不就是把串口转成 TTL 吗,直接透传就行”。SPD5 恰恰不是透传型的,它会给数据做二次封装。也就是说,从设备发出来的原始数据到达主控时,外面会多一层 SPD5 的组帧信息。如果不知道这层封装格式,你打开串口助手只能看到一堆十六进制字节,根本分不清哪些是有效数据、哪些是帧头帧尾、哪些是校验位。

厂家通常会给一份协议文档,但实际项目里文档缺失、版本对不上是常有的事。我这次接触到的 SPD5 配套资料还算完整,但协议版本标注模糊,部分命令字解释得也不够清楚,最后还是靠抓包加对比才把所有字段确认下来。所以我说,协议解析这件事,本质上就是“文档 + 实测”互相印证的过程。你理解了 SPD5 的组帧思路,再去解析别家集线器也只是套模板。

1.3 我这边实测的硬件与接口形态

先说下我手里的测试环境,方便后面讲抓包时有参照。主控端我用的是电脑 USB 转 TTL 模块,接到 SPD5 的主串口,波特率默认设置成 115200,8 位数据位、无校验、1 位停止位。SPD5 下挂了 4 路 RS485 从设备,分别是一台温湿度传感器、两台电能表、一个 RS232 转 485 的协议转换器。

接线的关键点有两个。第一,RS485 是差分信号,A/B 两根线不能接反,接反了现象就是所有数据都是乱码。第二,如果总线距离超过几十米,或者现场干扰比较大,需要在 485 总线末端并一个 120 欧姆终端电阻。SPD5 的每个 485 通道旁边一般都有跳线或者拨码开关来配置这个电阻,出厂默认不一定打开,我是全部打开了才把 CRC 错误率压下来。

串口参数这里要特别提醒:不是所有 SPD5 出厂都是 115200。有些批次默认 9600,有些甚至需要通过特殊命令切换。如果你连上后收到的全是乱码,先不要急着怀疑线接错了,挨个波特率试一遍,从 9600 扫到 115200,基本都能定位。

2. SPD5 的协议架构:从帧头到 CRC 的完整拆解

2.1 分层理解:物理层、链路层、应用层

解析协议的第一步,是把通信问题分层。这个思路不只在 SPD5 上有用,你以后解析任何厂家的私有协议都可以套用。

物理层很好理解,就是那些电气参数:电平标准、波特率、数据位。SPD5 支持 UART TTL 和 RS485 两种物理接口,两者在字节层面没有区别,都是按字节收发,所以在协议解析阶段不用过度关心物理层,只要保证字节流能正确进入缓冲区就行。

链路层是 SPD5 协议的核心。它定义了“帧”的边界和可靠性:一帧从哪里开始、在哪里结束、数据域多长、用什么算法校验。这一层的作用是让接收方能从连续的字节流中准确切出一帧完整的数据,同时能检查这帧数据在传输过程中有没有被干扰。

应用层则负责解释帧里的业务含义,比如命令字代表什么、通道号如何对应物理端口、数据域里的字节如何转换成温度值或电压值。我在实际解析中有一个体会:很多工程师拿到协议文档后,先把注意力放在应用层的数据解释上,结果看不明白十进制的温度是怎么算出来的,但其实链路层的帧切分才是第一道关卡,这关没过,后面全是白搭。

2.2 完整报文帧结构逐字段解析

SPD5 的帧结构我整理下来是这样的,整个框架是“帧头 + 固定信息头 + 数据域 + 校验尾”:

字段长度(字节)值范围说明
帧头20xAA 0x55固定帧同步字,用于识别一帧开始
版本号10x01 ~ 0x7F协议版本,当前常用 0x01
帧类型10x01 ~ 0x040x01 上行数据,0x02 下行命令,0x03 心跳,0x04 错误报告
通道号10x00 ~ 0x07对应 SPD5 的物理输入通道,0 代表主控侧
数据域长度20x0000 ~ 0xFFFF小端模式,表示数据域的总字节数
数据域N不定真正的业务数据,可能包着从设备原始报文
CRC1620x0000 ~ 0xFFFFModbus CRC16,低字节在前

这里最关键的是数据域长度占两个字节,而且是低字节在前。很多人第一次解析这种结构都会栽在这个坑里:明明帧长度是对的,却因为大小端搞反,把长度算成 0x0800,结果怎么切都对不上。所以拿到协议先确认大小端,这是我一再强调的习惯。

帧类型字段也需要留意。0x01 上行数据,代表从设备发给主控的数据帧;0x02 下行命令,是主控下发到某个通道从设备的命令;0x03 心跳帧,用于 SPD5 主动报告在线状态;0x04 错误报告,会在某个通道通信异常时产生。区分这些类型,能帮你快速过滤海量数据,尤其在现场只关心某一路传感器数据时,一眼就能挑出对应报文。

2.3 校验算法说明与计算示例

SPD5 的校验用的是 Modbus CRC16,这是工业总线领域非常常见的算法,多项式是 0x8005,初始值是 0xFFFF,计算结果是低字节在前发送。为什么强调“低字节在前”?因为你如果用网上现成的 CRC 计算工具,默认输出可能是高字节在前,这时候去比对帧尾怎么都对不上,来回折腾一晚上才发现是字节序问题。

CRC 的计算范围是“帧头之后、CRC 之前”的所有字节,也就是说帧头 0xAA 0x55 不参与校验,但版本号、帧类型、通道号、长度字段、数据域统统都要算。这个细节很重要,我见过不少人在实现时把帧头也算进去了,结果校验永远过不了。

我拿之前的一帧真实数据做个示例。原始帧:

AA 55 01 01 03 08 00 01 02 03 04 05 06 07 08 9C 4A

拆开来看:

  • 0xAA 0x55:帧头
  • 0x01:版本号
  • 0x01:上行数据帧
  • 0x03:第 4 路通道
  • 0x08 0x00:数据域长度 8 字节,小端模式
  • 数据域:01 02 03 04 05 06 07 08
  • 0x9C 0x4A:CRC16,低字节在前,换算成标准值就是 0x4A9C

手工验证 CRC 对熟悉 Modbus 协议的人来说是小菜一碟,但新手很容易卡住。我的建议是找一个在线 CRC 计算器,选 CRC-16/MODBUS 这个变体,输入“01 01 03 08 00 01 02 03 04 05 06 07 08”(注意不要带帧头),如果出来的结果十六进制显示是 4A9C,那你的参数就选对了。如果显示是 9C4A,说明工具默认输出高字节在前,翻转一下顺序就能核对上。

3. 实际操作:从接线到拿到第一包有效数据

3.1 接线与串口参数配置

拿到 SPD5 之后,第一步不是急着写代码,而是先把它接到电脑上,用串口助手把原始数据抓出来看一眼。我这里用 USB 转 TTL,接 SPD5 主串口的 TX、RX、GND。有一点要先确认,SPD5 主串口到底是 TTL 还是 RS485 接口,如果是 RS485,就得用 USB 转 485 模块,不能用 TTL 直接怼。

接线完成后,打开设备管理器确认虚拟串口号。然后打开串口助手,波特率先设 115200,数据位 8、停止位 1、无校验、无流控。打开串口后,如果一切正常,你应该能看到连续不断的十六进制字节流。如果 SPD5 此时没有任何从设备接入,或者从设备没有主动上报,主串口可能一条数据都没有。

所以为了最快验证链路是通的,我建议在 SPD5 的某个通道上接一个能主动上报数据的传感器,或者干脆短接某个 485 通道的 A/B 线制造一个异常,让 SPD5 产生错误报告帧。它在很多配置下只要检测到某通道的收发异常,就会主动往主串口发 0x04 类型的帧,这就给了你一个现成的抓包对象。

3.2 上位机抓包与原始字节流分析

串口助手我一般用两种,一种是简单直接的 SSCOM,用来快速看数据;另一种是支持存档和显示时间戳的,比如 VSPD 或者自己写个小工具。解析协议这种活,数据量一大,纯靠眼睛盯屏幕根本看不过来。我的做法是先抓 10 到 20 分钟的原始数据,保存成二进制文件,然后用脚本去分析。

抓包的时候有几个参数必须设置好。时间戳一定要带,因为后面排查超时、时序问题都依赖它。显示格式要设成十六进制,ASCII 混排也可以,但不要只显示 ASCII,否则中文一乱码就什么信息都丢了。保存格式尽量选原始二进制,不要选带行号或偏移量注释的文本格式,给解析脚本增加不必要的麻烦。

拿到字节流之后,第一步是肉眼扫描有没有固定的重复模式。SPD5 的帧头是 AA 55,所以打开文件搜索 AA 55 序列,看它们之间的间隔是否有规律。如果数据量足够大,你会发现大多数帧的帧头之间的字节数是相对固定的,这个信息能帮你初步估计数据域长度的常见值。

3.3 从字节流中定位帧头和帧尾

如果数据里 AA 55 出现的位置没有明显规律,也不用慌,这是正常的,因为每帧的数据域长度是可变的。这时要做的事情是用协议结构来截帧,核心逻辑是:找到帧头后,跳过固定信息头,读出数据域长度,然后按长度把整个数据域切出来,再取两个字节 CRC,最后校验。

这里有一个容易漏掉的问题:如果数据域里恰好也出现了 AA 55,该怎么处理?SPD5 的做法是帧头必须是连续的 AA 55,而数据域里的 AA 55 是否要转义,取决于协议版本。我手里的 V1.0 版本不做转义,所以解析时只要读到长度字段,就严格按长度切帧,不能因为中间又碰到 AA 55 就重新同步。

但如果协议版本更新,厂家可能会在数据域里做转义处理。你在实际解析时要注意:如果按长度切出来的帧 CRC 校验老是不通过,而把某些 AA 55 当作新帧头来切却一切一个准,那基本可以断定这个协议是“以帧头重新同步”的方式工作的。这属于协议设计差异,没有绝对的对错,关键是结合校验结果来推断。

3.4 封装一个最简单的解析脚本

我把这套逻辑写成了一个最简版的 Python 脚本,主要结构就是状态机:空闲态找帧头,找到后依次读版本、类型、通道、长度,再进数据态,最后算 CRC 并输出完整帧。

import serial import binascii import struct def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 0x0001: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def parse_buffer(buf: bytearray): i = 0 while i < len(buf): if buf[i] != 0xAA or i + 1 >= len(buf) or buf[i+1] != 0x55: i += 1 continue # 最少还需要 7 字节: 版本+类型+通道+2字节长度+2字节CRC if i + 7 >= len(buf): break version = buf[i+2] frame_type = buf[i+3] channel = buf[i+4] data_len = buf[i+5] | (buf[i+6] << 8) total_len = 7 + data_len + 2 # 帧头2 + 版本1 + 类型1 + 通道1 + 长度2 + 数据域 + CRC2 if i + total_len > len(buf): break frame = buf[i:i+total_len] data_field = bytes(frame[7:7+data_len]) crc_recv = buf[i+7+data_len] | (buf[i+7+data_len+1] << 8) crc_calc = crc16_modbus(bytes(frame[2:7+data_len])) if crc_recv == crc_calc: print(f"[OK] type=0x{frame_type:02X} ch={channel} len={data_len} data={data_field.hex()}") else: print(f"[BAD] crc=0x{crc_recv:04X} calc=0x{crc_calc:04X}") i += total_len ser = serial.Serial('COM3', 115200, timeout=1) buffer = bytearray() while True: chunk = ser.read(256) if not chunk: continue buffer.extend(chunk) parse_buffer(buffer) buffer = buffer[-16:] # 保留末尾少量字节,处理跨包帧

这段代码里我用了一个“保留末尾 16 字节”的小技巧,用来处理数据跨 read 分包到达的情况。更严谨的做法是维护一个完整接收队列,但作为最小的验证脚本,这个写法足够用。

脚本跑起来之后,如果一直打印 [BAD],先检查 CRC 算法参数,再检查大小端。如果打印 [OK] 但数据长度看起来不对,比如长度字段读出 0xFFFF 这种异常值,那多半是通道号或者帧类型偏移算错了。这时候把抓到的原始帧用 hex 打印出来,跟协议文档逐字节比对,很快就能定位。

4. 常见问题与排查技巧实录

4.1 问题现象与排查对照表

解析 SPD5 协议过程中,我踩过不少坑,也帮别人排查过几回。下面这张表基本覆盖了最常见的几类情况,按现象、可能原因、处理方法的顺序来写,现场照着查就行。

现象可能原因排查与处理
串口全是乱码波特率不匹配、RS485 A/B 接反、主串口电平类型接错先用 9600~115200 逐档扫描波特率;调换 A/B;确认 TTL 还是 485 接口
能收到数据但 CRC 一直不过校验算法选错(如用了 CRC32)、计算范围不对、大小端反了确认是 Modbus CRC16,计算范围从版本号到数据域末尾
接收到的帧偶尔丢一半主控串口接收缓冲区太小、串口助手没开时间戳加大缓冲区,硬件流控关闭,使用二进制方式抓包保存
帧头能找到但切出来长度不对长度字段大小端理解反了,或协议版本长度位宽不同拿真实帧核对,尝试高低字节互换
数据经常超时集线器轮询周期长,或某通道故障阻塞总线抓带时间戳数据,分析帧间隔;逐通道断开定位故障点
5 分钟后就收不到数据集线器有看门狗或自动休眠机制检查心跳帧;确认主控是否需定期下发保活指令

这些现象里,我遇到最多的是第一项和第三项。串口乱码八成的锅是波特率,剩下两成是接线问题;丢帧则几乎都是缓冲区溢出,因为 SPD5 一旦挂着多路从设备同时上报,瞬间数据量可以很大,串口助手读取不及时就丢了。

4.2 关于时序和竞争条件的几个坑

很多人在协议解析中容易忽略一个维度:时间。SPD5 这类集线器最考验调试者的地方,不是单个字节对不对,而是多个通道并发数据上来时,它如何排队、如何分帧。

我实测发现,SPD5 内部按照通道号轮流转发数据,但同一时刻只有一个通道的数据能上主链路。如果两个从设备同时上报,先到的通道先发,后到的要等当前帧发完。这意味着什么?意味着你抓到的帧间隔是不均匀的,有时两帧之间隔 50ms,有时隔 200ms。如果你在主控端写了严格的超时判断,比如超过 100ms 没有数据就认为链路断了,那系统就会频繁误报。

解决办法是把超时放宽到 500ms 甚至 1 秒,并且利用心跳帧做在线检测,而不是依赖业务数据的有无。SPD5 的 0x03 心跳帧这时候就有用了,它每隔固定时间上报一次,主控只要判断心跳是否超时,就能准确知道集线器是否还在线。用业务数据去推断链路状态,是最容易出误判的做法。

4.3 多通道轮询与主动上报的切换逻辑

SPD5 支持两种工作模式:主动上报模式和轮询模式。前者是各从设备有数据就往集线器推,集线器再上传给主控;后者是主控发命令,SPD5 再把命令分发到指定通道,等到从设备响应后再回传。这两种模式在实际使用中经常混着来,但混用时的行为很容易让人困惑。

比如主控发了一条 0x02 下行命令,让第 2 通道的电表回传电压值。如果此时第 3 通道正好有主动上报的数据,SPD5 是先把命令发给从设备,还是先处理主动上报的数据?我实测下来,SPD5 会先响应下行命令,优先保证命令能及时下发,然后才会处理通道的主动上报数据流。

这个优先级设计在生产环境中很关键。如果你在主控侧用轮询方式采集多路设备,同时又开启了从设备的主动上报,一定要注意主控发送命令后要立刻进入接收状态,并且要把主动上报的数据也缓存起来。不然你专心等某一路的命令响应,其他通道的数据到了却被丢弃,再想找回来就要重新查询。我建议在项目初期先把主动上报功能关掉,等轮询稳定了再逐步打开。

5. 解析脚本升级:自动识别协议版本与嵌套长度

5.1 版本字段怎么利用

前面的脚本只适合验证链路通不通,真正放到项目里跑,还需要把版本字段利用起来。SPD5 的协议版本字段不是摆设,不同版本在长度字段位宽、命令字定义、数据域内部结构上可能存在差异。

比如 V1.0 的长度字段是 2 字节,V1.1 可能引入扩展帧,长度字段变成 4 字节,甚至数据域里可能多出一个子协议类型。如果主控程序写死成一种格式,设备升级固件之后,解析就会全部错乱。所以我在解析入口会先读取版本字段,然后建立一张版本对应的解析配置表。

PARSE_CONFIG = { 0x01: {"len_width": 2, "has_sub_type": False}, 0x02: {"len_width": 2, "has_sub_type": True}, 0x03: {"len_width": 4, "has_sub_type": True}, }

这样拿到一帧后,先读版本号,再根据版本配置决定数据域长度占用几个字节、数据域里有没有子类型字段。虽然 SPD5 当前大多数设备只用了 V1.0,但代码里预留好版本分支,后面固件升级就不用推翻重写了。

5.2 嵌套长度字段的解析策略

SPD5 的数据域里有时候不是简单的原始字节,而是嵌套了子帧。最典型的是:数据域的第一个字节是子帧类型,第二和第三个字节是子帧长度,后面才是真正的数据内容。这就引出了“嵌套深度”的问题。

有些现场调试设备,比如解析串口屏协议或者动态 JSON 结构时,会遇到多层嵌套,这时解析逻辑必须递归调用。SPD5 目前最多只有两层结构:外层是 SPD5 帧,内层是从设备的原始报文。但为了避免以后踩坑,我还是建议把子帧解析独立成函数,只负责从数据域里取出子类型和子长度,具体内容再交给上层业务解析。

有一个容易犯的错误是:拿到子长度后,不去校验它是否越过外层数据域的边界。如果子长度被干扰或计算错误,解析器会读到错误的位置,后面所有帧全部错位。我在代码里会做一次边界检查,子帧结束位置超过外层数据域末尾时,直接判定该帧异常,并记录错误日志,而不是强行继续解析。

5.3 长时间运行的稳定性优化

协议解析脚本如果只是调试用,随便写写没关系;但要放进主控程序里连续跑几天,稳定性就得认真打磨。我在实际运行中遇到过三个问题,这里一并说明。

第一个问题是内存持续增长。因为我用动态缓冲区不断 append 数据,如果解析失败,旧的残留数据却一直留在 buffer 里,内存就会慢慢变大。解决方法是每次解析后把 buffer 里已经消费掉的部分清空,只保留最多一帧长度的尾部数据,并对 buffer 总长度设置上限,超过后强制清空并等待新的帧头。

第二个问题是粘包和半包。串口数据是流式的,一次 read 可能包含多个完整帧,也可能只包含半帧,所以解析一定要用状态机,不能假设每次 read 都恰好是一个完整帧。我上面给的脚本已经处理了这种情况,核心思路是:每次循环都在同一个 buffer 上解析,解析到切不出完整帧时才退出当前循环,继续等待新数据。

第三个问题是错误帧对后续解析的影响。如果某帧 CRC 错误,不能直接把整个 buffer 清空重来,因为错误的帧头可能只是干扰,下一个正常帧已经在缓冲区里了。保守做法是:CRC 错误时,把字节流从帧头后移动一个字节,继续搜索下一个 AA 55,而不是把整个缓冲区删掉。

这次完整调试下来,我最大的体会是:协议解析 70% 的功夫在抓包和确认帧结构上,真正写解析代码只花了很少时间。一旦你把帧头、长度、CRC 这三个要素吃透了,后面不管是 SPD5 还是别的集线器,套路都是相通的。最后再分享一个小技巧:拿到设备别急着写全功能解析器,先用串口助手连抓 10 分钟原始数据存成文件,拿这份真实数据当测试用例反复跑,这比任何文档都靠谱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 17:16:44

基于Cadence Virtuoso的Buffer电路设计与仿真完整教程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 17:16:42

FPGA XDMA双BAR地址映射原理与实战调试指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 17:16:03

Python 3.12移除pbkdf2_hmac导致Django创建超级用户失败

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 17:15:48

步进电机控制方案:DRV8818PWPR与PIC18LF4550的软硬件协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 17:14:52

DRV8818PWPR+PIC18F97J60工业步进驱动方案解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 17:14:46

STM32F103串口接收:RXNE与IDLE中断区别及三种实战方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华