news 2026/9/20 6:46:29

以太网温湿度传感器通信校验:CRC16与CRC32选型及STM32实现踩坑复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
以太网温湿度传感器通信校验:CRC16与CRC32选型及STM32实现踩坑复盘

以太网温湿度传感器这个品类,看起来简单——不就是个带网口的温湿度探头嘛,能有多复杂?但真做过量产项目的人都知道,通信可靠性才是这类产品最要命的地方。我前后做过三款以太网温湿度采集设备,从最早的裸UDP裸发数据,到后来走TCP长连接加校验,中间因为CRC选型和处理方式踩过的坑,足够写一篇完整的复盘。这篇就围绕CRC16和CRC32在以太网温湿度传感器通信中的选型、代码实现和实际踩坑,把我知道的都倒出来。

先说清楚适用人群:如果你正在做STM32或者类似MCU平台上的以太网温湿度采集设备,需要把DHT11、SHT30这类传感器的数据通过以太网可靠地传到上位机或云端,那这篇内容基本就是给你写的。如果你只是用现成模块跑个Demo,可能感受不深,但一旦进入多设备组网、长线缆、工业环境的场景,校验策略的差异会立刻暴露出来。

1. 为什么温湿度数据在以太网上也需要校验

1.1 以太网本身有校验,为什么还不够

很多人第一反应是:以太网帧不是自带FCS(帧校验序列)吗?底层已经做了CRC32校验,上层还操什么心?这个想法在实验室环境下没问题,但放到实际项目里就站不住脚了。

以太网帧的FCS只保护帧头到帧尾这一段链路层数据,它保证的是"这一跳"的完整性。但你的数据从传感器到MCU,从MCU的协议栈到PHY芯片,再经过交换机、路由器,最后到达上位机应用层,中间经历的拷贝、缓冲、协议栈处理环节太多了。任何一个环节出现内存位翻转、DMA描述符错位、缓冲区越界,FCS都管不着——因为FCS在到达这些环节之前就已经被网卡剥掉了。

我遇到过最典型的一次:STM32F407的以太网DMA在高速收发时,描述符环配置有细微问题,导致偶发的数据错位。现象是上位机收到的温度值偶尔跳变成离谱的数字,比如25.3℃变成89.7℃。用Wireshark抓包看,帧的FCS完全正常,因为错位发生在MAC层之后的应用缓冲区里。这种问题,只有应用层自己做校验才能兜住。

1.2 温湿度数据的特殊性:小包、高频、容错低

温湿度传感器数据有个特点:单次数据量极小。DHT11一次返回40位(5字节),SHT30一次返回6字节,就算加上时间戳和设备ID,一个完整数据包也就二三十字节。这种小包在以太网上传输,帧的有效载荷利用率很低,但发送频率可能很高——工业场景下每秒采样一次甚至更快。

小包高频带来的问题是:你不可能对每个包都做重量级的完整性保护,但你又不能不做。而且温湿度数据对"静默错误"的容忍度极低。温度差0.5℃可能触发空调系统的误动作,湿度差5%可能让除湿机疯狂启停。一个位翻转导致的数据错误,如果没被检出,后果比丢包严重得多——丢包可以重传,错误数据直接被当成真值用了。

所以结论很明确:应用层校验不是可选项,是必选项。问题只在于选CRC16还是CRC32,以及怎么实现。

2. CRC16与CRC32的选型逻辑:不只看碰撞概率

2.1 碰撞概率的数学账

先算一笔最基础的账。CRC16的校验位宽是16位,理论上能检出所有单比特、双比特错误,以及所有奇数位错误和长度小于等于16位的突发错误。对于随机错误,漏检概率大约是2的负16次方,也就是约1/65536。CRC32的漏检概率是2的负32次方,约1/42.9亿。

单看这个数字,CRC32碾压CRC16。但实际选型不能只看这个。你的数据包总共才二三十字节,出现多位随机错误的概率本身就很低。在工业现场,更常见的错误模式是突发错误——比如电磁干扰导致连续几个字节出错。CRC16对长度不超过16位的突发错误是100%检出的,而你的数据包总共也就160到240位,超过16位的突发错误概率并不高。

我做过一个粗略的统计:在同一个电磁环境较差的车间里,用同一条线缆、同样的发送频率,CRC16漏检导致上位机收到错误数据的概率大约是每百万包1到2次,CRC32在这个场景下没有观察到漏检。但注意,这个"百万分之一"是在极端恶劣环境下测的,正常办公或轻工业环境下,CRC16的漏检率低到几乎测不出来。

2.2 计算开销与MCU资源的权衡

选型必须考虑MCU的实际算力。我主要用STM32F1和F407两个平台,F1是Cortex-M3内核,72MHz主频,没有硬件CRC外设(部分型号有,但F103没有)。F407有硬件CRC外设,但只支持固定多项式。

在F103上用软件查表法算CRC16,256字节的表,每次计算二三十字节的数据,耗时大约几微秒。CRC32如果用查表法,表大小是1KB(256个32位条目),计算耗时略高,但也在可接受范围内。如果用逐位计算法,CRC32的耗时大约是CRC16的两倍,因为要多循环16次。

这里有个关键点:如果你的采样频率是1Hz,那几微秒和十几微秒的差异毫无意义。但如果你的设备要做多通道采集,比如同时接8路温湿度传感器,每路100Hz采样,那计算开销就需要认真评估了。我实测过,F103在72MHz下,用逐位法算CRC32,处理8路×100Hz的数据,CPU占用率会到3%左右;用查表法可以降到1%以下。对于这种场景,要么用查表法,要么直接上CRC16。

2.3 协议兼容性与上位机生态

还有一个容易被忽略的因素:上位机和云端服务对CRC类型的支持程度。如果你用Python写上位机,binascii.crc32()是标准库自带的,一行代码搞定。CRC16就没有这么统一的标准了,不同库用的多项式不一样,有CRC-16/CCITT、CRC-16/IBM、CRC-16/MODBUS等等,你必须和固件端严格对齐。

我踩过这个坑:固件端用的是CRC-16/CCITT-FALSE(多项式0x1021,初值0xFFFF),上位机用了一个开源库默认的CRC-16/ARC(多项式0x8005,初值0x0000),结果校验永远不过。排查了半天才发现是多项式不一致。所以选CRC16的话,必须在协议文档里把多项式、初值、输入反转、输出反转、结果异或这五个参数全部写死,少一个都可能出问题。

CRC32在这方面省心很多,以太网、ZIP、PNG用的都是同一个标准(多项式0x04C11DB7,初值0xFFFFFFFF,输入输出反转,结果异或0xFFFFFFFF),Python、C#、Java的标准库实现都一致。

2.4 我的实际选型结论

综合以上几点,我的选型策略是这样的:

场景推荐方案理由
单路温湿度,采样率≤10Hz,办公/轻工业环境CRC16/CCITT-FALSE开销小,够用,协议简单
多路采集,采样率≥50Hz,或电磁环境复杂CRC32漏检率极低,标准统一
与云端HTTP/JSON对接CRC32与标准库无缝衔接
极低功耗电池设备CRC16计算量小,省电
车载以太网场景CRC32与车载协议栈校验策略一致

这个表不是拍脑袋定的,是几个项目实际跑下来总结的。下面分别讲两种CRC的具体实现。

3. CRC16在STM32上的查表法实现与优化

3.1 多项式选择:为什么我最终锁定CCITT-FALSE

CRC16的多项式选择让人眼花缭乱。我试过MODBUS用的0x8005,也试过CCITT的0x1021,最终在温湿度传感器项目里锁定CCITT-FALSE,原因有三个。

第一,CCITT-FALSE对短数据包的检错能力在统计上略优于MODBUS多项式。有论文做过仿真,对于长度小于64字节的数据包,CCITT的汉明距离表现更好。第二,很多传感器厂商的参考代码用的是CCITT,比如SHT30的某些驱动例程,直接复用可以减少对接成本。第三,CCITT-FALSE的初值是0xFFFF,全1初值在硬件实现上更自然,如果你后续要换成硬件CRC外设,移植更方便。

CCITT-FALSE的完整参数是:多项式0x1021,初值0xFFFF,输入不反转,输出不反转,结果不异或。这五个参数必须和上位机完全一致。

3.2 查表法的表生成与内存布局

查表法的核心是预计算一个256项的表格,每项是16位。生成表的代码如下:

#include <stdint.h> static uint16_t crc16_table[256]; void crc16_init_table(void) { uint16_t poly = 0x1021; for (int i = 0; i < 256; i++) { uint16_t crc = (uint16_t)(i << 8); for (int j = 0; j < 8; j++) { if (crc & 0x8000) crc = (crc << 1) ^ poly; else crc <<= 1; } crc16_table[i] = crc; } }

这个表占512字节的Flash或RAM。我一般放在Flash里,用const修饰,启动时不需要重新生成。如果放RAM,每次上电要花几百微秒生成,对于低功耗设备不太友好。

计算函数就很简单了:

uint16_t crc16_ccitt_false(const uint8_t *data, uint32_t len) { uint16_t crc = 0xFFFF; for (uint32_t i = 0; i < len; i++) { uint8_t idx = (uint8_t)((crc >> 8) ^ data[i]); crc = (crc << 8) ^ crc16_table[idx]; } return crc; }

注意这里每次取的是crc >> 8和当前字节异或,而不是crc & 0xFF。这是CCITT系列和IBM系列查表法的一个关键区别,写反了结果就完全不对。我第一次实现的时候就是这里搞错了,调了半天。

3.3 在以太网数据包中的嵌入方式

温湿度数据包的典型结构是这样的:

#pragma pack(push, 1) typedef struct { uint8_t header; // 0xAA uint8_t dev_id; // 设备地址 uint32_t timestamp; // 时间戳,秒 int16_t temperature; // 温度,单位0.1℃ uint16_t humidity; // 湿度,单位0.1% uint16_t crc; // CRC16校验值 } sensor_packet_t; #pragma pack(pop)

CRC计算的范围是headerhumidity,共10字节,不包括CRC字段本身。发送前计算,接收端重新计算并比对。

这里有个细节:结构体必须用#pragma pack(1)紧凑对齐。STM32默认对齐可能导致结构体里有填充字节,这些填充字节的值是不确定的,如果参与CRC计算,发送端和接收端的填充值可能不同,导致校验失败。我早期就因为这个原因,在F407上跑得好好的,换到F103上就偶尔校验不过——因为两个平台的编译器对齐策略有细微差异。

3.4 实测性能数据

在STM32F103C8T6,72MHz,IAR编译器,优化等级High的情况下,我实测的数据:

数据长度逐位法耗时查表法耗时
10字节约8.2μs约1.1μs
32字节约26μs约3.5μs
128字节约104μs约14μs

对于温湿度传感器这种10到32字节的包,查表法1到4微秒的耗时完全可以忽略。就算你每秒发1000包,CPU占用也不到0.5%。

4. CRC32的软件实现与硬件加速方案

4.1 标准CRC32的软件查表实现

CRC32的标准参数是:多项式0x04C11DB7,初值0xFFFFFFFF,输入反转,输出反转,结果异或0xFFFFFFFF。这个"输入反转、输出反转"是让很多人困惑的地方。

输入反转的意思是,每个字节在参与计算前,要把8个位倒过来。比如0x01变成0x80。输出反转是把最终32位结果倒过来。结果异或就是最后和0xFFFFFFFF异或。

查表法的表生成:

static uint32_t crc32_table[256]; void crc32_init_table(void) { uint32_t poly = 0x04C11DB7; for (int i = 0; i < 256; i++) { uint32_t crc = (uint32_t)i; for (int j = 0; j < 8; j++) { if (crc & 1) crc = (crc >> 1) ^ 0xEDB88320; // 反转后的多项式 else crc >>= 1; } crc32_table[i] = crc; } }

注意这里用的是反转后的多项式0xEDB88320,这是0x04C11DB7的位反转形式。用这个多项式配合右移,就自动实现了输入反转的效果。

计算函数:

uint32_t crc32_standard(const uint8_t *data, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { crc = (crc >> 8) ^ crc32_table[(crc ^ data[i]) & 0xFF]; } return crc ^ 0xFFFFFFFF; }

这个实现和Python的binascii.crc32()结果完全一致,我验证过。

4.2 STM32F407硬件CRC外设的使用与限制

F407有硬件CRC单元,但它的默认多项式是0x04C11DB7,初值0xFFFFFFFF,不支持输入反转和输出反转。这意味着硬件算出来的结果和标准CRC32不一样。

如果你要用硬件CRC,有两个选择:一是接受非标准结果,上位机也用同样的非标准算法;二是硬件算完后自己做位反转和异或。第一种方案简单但牺牲了兼容性,第二种方案需要额外的位反转操作,32位反转用查表法也要几个微秒,优势就不明显了。

我实际测试过F407硬件CRC的性能:配置好之后,写数据到CRC->DR寄存器,硬件自动计算,读CRC->DR获取结果。对于32字节数据,耗时不到1微秒,确实比软件查表快。但加上位反转的开销,总耗时和软件查表法差不多。所以除非你的协议可以接受非标准CRC32,否则硬件CRC的收益有限

4.3 大小端问题:一个隐蔽的坑

CRC32还有一个容易踩的坑是大小端。STM32是小端模式,但网络字节序是大端。如果你的CRC值要放进网络包,必须转成大端。

我遇到过的情况是:固件端算完CRC32,直接memcpy到包里,上位机用Python解析时按大端读取,结果校验不过。排查后发现是字节序问题。解决方案很简单:

uint32_t crc = crc32_standard(data, len); uint32_t crc_be = __builtin_bswap32(crc); // 或者自己写字节交换 memcpy(packet + offset, &crc_be, 4);

或者干脆在协议里约定CRC字段用小端,上位机也按小端解析。关键是协议文档要写清楚,两端要一致

5. 踩坑复盘:那些让我熬夜的CRC问题

5.1 结构体对齐导致的间歇性校验失败

这个坑我在3.3节提过,但值得展开讲,因为它太隐蔽了。现象是:设备运行几个小时后,偶尔出现校验失败,重启后恢复正常,过一段时间又出现。

排查过程:先用Wireshark抓包,发现校验失败的包,其数据内容看起来完全正常,温度湿度值都在合理范围内。把失败包的数据单独拿出来,用上位机的CRC函数重新算,结果和包里的CRC值不一致。但用固件的CRC函数算同样的数据,结果和包里的CRC值一致。

这说明固件和上位机的CRC算法本身没问题,问题出在"参与计算的数据"上。仔细对比发现,失败包的结构体里,某个填充字节的值和正常包不一样。原因是结构体定义时没有加#pragma pack(1),编译器在uint8_t dev_iduint32_t timestamp之间插入了3个填充字节。这些填充字节在栈上分配时,值是不确定的,有时候是0,有时候是垃圾值。发送端计算CRC时包含了这些填充字节,接收端解析时如果也按同样的结构体解析,填充字节的值可能不同,导致CRC不一致。

解决方案:所有用于网络传输的结构体,一律加#pragma pack(1),并且在计算CRC前用memset清零整个结构体,确保填充字节为0。

5.2 DMA缓冲区与CRC计算的数据竞争

另一个坑和以太网DMA有关。STM32的以太网外设用DMA收发数据,DMA在后台搬运数据,CPU在 foreground 计算CRC。如果DMA正在写入缓冲区,CPU同时读取同一块缓冲区计算CRC,就可能读到"半新半旧"的数据。

我遇到的现象是:高速发送时,大约每几千包出现一次CRC错误,但重发就正常。后来用逻辑分析仪抓DMA的读写时序,发现确实存在竞争窗口。

解决方案有两种:一是用双缓冲,DMA写缓冲区A时,CPU计算缓冲区B的CRC;二是等DMA传输完成中断触发后,再计算CRC。我最终用的是第二种,因为温湿度数据包很小,等DMA完成的开销可以接受。具体做法是在以太网发送完成中断里,先算CRC,再触发下一次发送。

5.3 上位机Python与固件C的CRC不一致问题

这个问题在2.3节提过,这里给出完整的排查清单。当你发现上位机和固件CRC不一致时,按以下顺序检查:

  1. 多项式是否一致:CRC16有几十种多项式,CRC32虽然标准统一,但也要确认。
  2. 初值是否一致:0x0000、0xFFFF、0x1D0F等都有可能。
  3. 输入是否反转:每个字节的位顺序是否倒置。
  4. 输出是否反转:最终结果的位顺序是否倒置。
  5. 结果是否异或:最后是否和某个值异或。
  6. 数据范围是否一致:CRC计算包含哪些字节,是否包含帧头、长度字段等。
  7. 字节序是否一致:CRC值本身在包里的存储顺序。

我建议在协议文档里用一个表格把这7项全部列出来,每项都写明具体值。下面是一个示例:

参数CRC16/CCITT-FALSECRC32/ISO-HDLC
多项式0x10210x04C11DB7
初值0xFFFF0xFFFFFFFF
输入反转
输出反转
结果异或0x00000xFFFFFFFF
计算范围帧头到数据尾帧头到数据尾
字节序小端大端

5.4 温湿度传感器本身的数据异常与CRC的混淆

有时候CRC校验失败,不一定是通信问题,可能是传感器本身返回了异常数据。DHT11在时序不满足时会返回全0或全1,SHT30在加热模式下可能返回特定的错误码。这些异常数据参与CRC计算,算出来的CRC当然和预期不符。

我的处理策略是:在CRC校验之前,先做数据合理性检查。温度值在-40到125℃之间,湿度在0到100%之间,超出范围直接丢弃,不进入CRC校验流程。这样可以避免把传感器异常误判为通信错误,减少无效的重传。

6. 协议设计与工程实践建议

6.1 包结构设计:给CRC留够空间

一个完整的温湿度数据包,我建议的结构如下:

#pragma pack(push, 1) typedef struct { uint8_t sof; // 起始字节 0xAA uint8_t dev_id; // 设备ID uint8_t seq; // 序列号,用于检测丢包 uint32_t timestamp; // 时间戳 int16_t temp; // 温度 uint16_t humi; // 湿度 uint16_t crc16; // CRC16校验 } packet_crc16_t; typedef struct { uint8_t sof; uint8_t dev_id; uint8_t seq; uint32_t timestamp; int16_t temp; uint16_t humi; uint32_t crc32; // CRC32校验 } packet_crc32_t; #pragma pack(pop)

CRC16包总共13字节,CRC32包总共15字节。对于以太网来说,这点差异可以忽略。序列号seq字段很有用,可以用来检测丢包和乱序,配合CRC可以区分"丢包"和"数据错误"两种情况。

6.2 校验失败后的处理策略

校验失败后怎么办?直接丢弃还是请求重传?这取决于你的应用场景。

如果是TCP连接,底层已经保证了可靠性,CRC失败通常意味着内存或DMA层面的问题,重传大概率还是失败,应该记录日志并告警。如果是UDP,CRC失败可以请求重传,但要设置重传次数上限,避免网络拥塞时无限重传。

我的做法是:CRC失败时,先记录失败计数和失败时的原始数据(用于事后分析),然后根据失败率决定策略。失败率低于0.1%时静默丢弃,高于0.1%时触发告警,高于1%时主动降低采样频率,减少网络负载。

6.3 测试与验证方法

CRC实现的测试不能只靠"跑起来没问题"。我建议做以下几项测试:

第一,标准测试向量验证。CRC16/CCITT-FALSE对字符串"123456789"的结果应该是0x29B1,CRC32对同样字符串的结果应该是0xCBF43926。这两个值是国际标准测试向量,必须对得上。

第二,边界测试。空数据、单字节数据、全0数据、全1数据,都要测。

第三,随机数据压力测试。用Python生成随机数据,固件和上位机分别计算CRC,比对结果。我一般跑10万组随机数据,确保没有不一致。

第四,实际通信测试。在真实网络环境下,连续运行24小时以上,统计CRC失败率。

6.4 一个实用的调试技巧

如果你在调试CRC问题时感到无从下手,我分享一个技巧:把CRC计算过程打印出来。在固件里加一个调试函数,把每一步的中间结果通过串口打印。比如CRC16查表法,打印每次循环的idxcrc值。然后在Python里用同样的算法,也打印中间值。两边一对比,立刻就能定位到是哪一步开始不一致的。

这个方法看起来笨,但比盲目猜测高效得多。我每次遇到CRC不一致,都是用这个方法在几分钟内定位问题。

7. 从CRC选型延伸出去的几个思考

7.1 CRC不是万能的:什么时候需要更强的校验

CRC能检出绝大多数随机错误和突发错误,但它不是密码学哈希,不能防篡改。如果你的温湿度数据涉及计费或安全敏感场景,CRC就不够了,需要考虑HMAC或者数字签名。不过对于绝大多数工业温湿度监测场景,CRC16或CRC32已经足够。

另外,CRC的检错能力有理论边界。对于特定长度的数据包,存在一些"碰撞"模式,即不同的数据产生相同的CRC。虽然概率极低,但在设计协议时,可以通过加入序列号、时间戳等变化字段,进一步降低碰撞风险。

7.2 与车载以太网的校验策略对比

车载以太网(比如100BASE-T1)对通信可靠性的要求比普通以太网更高。在车载场景下,除了链路层的CRC,应用层通常还会用CRC32或者更高级的校验。我参与过一个车载温湿度监测的项目,用的是AUTOSAR标准里的E2E保护机制,本质上是在CRC32的基础上加了计数器,防止重放攻击和丢包混淆。

如果你的项目是从工业以太网迁移到车载以太网,CRC策略需要重新评估。车载环境的电磁干扰更强,线缆更短但连接器更多,错误模式不一样。我的建议是直接上CRC32,并且把校验范围扩大到包含序列号和时间戳。

7.3 未来升级路径:从CRC到哈希

如果你的设备生命周期很长,未来可能需要升级校验算法。设计协议时,可以预留一个"校验类型"字段,标识当前用的是CRC16、CRC32还是其他算法。这样未来升级时,新旧设备可以共存,逐步迁移。

typedef struct { uint8_t sof; uint8_t dev_id; uint8_t seq; uint8_t crc_type; // 0=CRC16, 1=CRC32, 2=保留 uint32_t timestamp; int16_t temp; uint16_t humi; uint8_t crc_bytes[4]; // 最大支持32位校验 } packet_flexible_t;

这个设计牺牲了一点简洁性,但换来了升级灵活性。对于长生命周期产品,值得考虑。

我在实际项目里最终的选择是:单路温湿度采集用CRC16/CCITT-FALSE,多路或车载场景用CRC32/ISO-HDLC。两种实现都封装成了独立的模块,通过宏切换。测试向量验证和随机数据压力测试是每次代码合并前的必做项。踩过的坑里,结构体对齐和DMA竞争是最难排查的,但一旦解决,后续运行就非常稳定。如果你正在做类似的项目,建议先把CRC的五个参数在协议文档里写死,然后两端用同一组测试向量验证,能省掉后面大量的调试时间。

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

WorkshopDL完全指南:Steam创意工坊Mod批量下载与服务器部署

先说明一下我个人的使用场景&#xff1a;我平时既打游戏&#xff0c;也帮朋友维护一个小型联机服务器。服务器要装一堆创意工坊Mod&#xff0c;原版Steam客户端在批量部署、跨机器下载这些场景下非常难受。后来我找到WorkshopDL这个工具&#xff0c;才算是把创意工坊内容下载这…

作者头像 李华
网站建设 2026/9/20 6:45:48

抓包与接口测试用例设计:从F12到Reqable的实战指南

做测试这几年&#xff0c;我越来越觉得一个有意思的现象&#xff1a;很多人把“写测试用例”和“抓包调接口”当成两件独立的事。写用例的时候对着需求文档硬憋&#xff0c;抓包的时候又只是漫无目的地翻请求看响应。实际上这两个动作是同一件事的一体两面——抓包是在向真实系…

作者头像 李华
网站建设 2026/9/20 6:40:57

AssetRipper 入门教程:完成第一次 Unity 资源提取的完整路径

AssetRipper 入门教程&#xff1a;完成第一次 Unity 资源提取的完整路径 【免费下载链接】AssetRipper GUI application to analyze game files 项目地址: https://gitcode.com/GitHub_Trending/as/AssetRipper AssetRipper 是一款免费的 Unity 游戏文件分析与提取 GUI …

作者头像 李华
网站建设 2026/9/20 6:39:39

ARDM扩散模型图像修复:注意力-残差耦合与掩码条件注入

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

作者头像 李华