MRAM与PIC18F97J94工业存储方案:SPI驱动、掉电保护与日志设计

发布时间:2026/10/4 13:01:39

MRAM与PIC18F97J94工业存储方案:SPI驱动、掉电保护与日志设计 1. 项目缘起与方案选型为什么是 MRAM 加 PIC18F97J941.1 一个真实的需求场景工业现场的数据记录仪、电力监测终端、医疗设备日志模块这类设备有一个共同特点需要频繁写入小批量关键数据断电不能丢现场环境还经常伴随高温、振动和电磁干扰。我接手过一个配电柜监测模块的改造项目原来的方案是铁电存储器加后备电池运行两年后陆续出现电池失效导致数据丢失的投诉。拆机分析发现问题不在电路设计而是电池本身在高温环境下的寿命衰减远超预期。这个痛点直接把我推向了 MRAM 方案。MRAM 的全称是磁阻随机存取存储器它的存储原理和传统 Flash、EEPROM 完全不同——利用磁性隧道结的磁化方向来记录数据而不是靠电荷。这个物理机制带来了几个对工业应用极其友好的特性写入速度是纳秒级、擦写寿命近乎无限、不需要擦除操作、断电后数据保持十年以上、工作温度范围宽。这些特性恰好对应了工业场景里最头疼的几个问题。选定了存储介质接下来是主控。PIC18F97J94 是 Microchip 旗下一颗 8 位单片机主频最高 64MHz自带 SPI 外设有 128KB Flash 和近 4KB RAM外设资源丰富封装从 64 脚到 100 脚都有。它在工业控制领域出货量大、供货稳定、开发工具链成熟用 C18 或 XC8 编译器就能开发对中小批量项目非常友好。MR25H40CDF 是 Everspin 的 4Mbit512KBSPI MRAM标准 SPI 接口最高 40MHz 时钟SOIC-8 封装和常见的 SPI Flash 引脚兼容。这两颗芯片搭配构成了一个主控够用、存储可靠、接口简单的组合。1.2 为什么不用 SPI Flash 或 EEPROM很多人第一反应是存数据用 SPI Flash 不就行了便宜量大。这个想法在消费类产品里没问题但在工业场景里会踩坑。SPI Flash 的擦写寿命通常在 10 万次量级而且写入前必须先擦除整个扇区擦除时间长毫秒级擦除过程中如果断电整个扇区的数据都可能损坏。EEPROM 虽然可以字节级写入寿命约 100 万次但写入速度慢每次写字节需要几毫秒频繁写入的场景下 CPU 会被拖住。MR25H40CDF 的写入不需要擦除直接覆盖单次写入时间在纳秒到微秒级寿命标称 10^14 次以上。这意味着你可以把它当成 RAM 一样用随时写、随便写不用担心寿命和擦除问题。对于需要记录运行日志、故障快照、参数变更历史的设备这个特性是决定性的。1.3 整体架构设计思路整个方案的核心思路是PIC18F97J94 通过硬件 SPI 接口连接 MR25H40CDF把 MRAM 当作一个非易失的扩展 RAM来使用。软件层面分三层底层是 SPI 驱动负责字节收发和片选控制中间层是 MRAM 读写接口封装地址操作和页写入逻辑上层是应用数据管理负责数据结构定义、校验和掉电保护。这个分层的好处是底层驱动和 MRAM 芯片解耦将来换成其他 SPI 存储器件只需要改中间层。上层应用不关心底层是 MRAM 还是别的介质只调用读写接口。这种设计在项目迭代和器件替换时能省下大量返工时间。提示MRAM 虽然写入不需要擦除但它的写入时序对片选信号和时钟极性有严格要求配置错误会导致写入数据错乱这一点在调试初期最容易踩坑。2. 硬件连接与 SPI 时序关键细节2.1 引脚连接与片选策略MR25H40CDF 是 SOIC-8 封装引脚定义很简洁CS片选、SO数据输出、SI数据输入、SCK时钟、VCC、GND加上 WP写保护和 HOLD保持。实际连接时CS 接到 PIC18F97J94 的一个普通 GPIOSO/SI/SCK 接到 SPI 外设对应的引脚上。这里有个关键选择片选用硬件片选还是软件片选。PIC18F97J94 的 MSSP 模块支持硬件片选但硬件片选在某些情况下时序不够灵活。我实测下来用普通 GPIO 做软件片选更可控因为 MRAM 的读写操作需要精确控制 CS 的拉低和拉高时机软件片选能让你在字节之间自由控制避免硬件片选自动翻转带来的时序问题。WP 引脚建议接高电平禁用写保护HOLD 引脚也接高电平禁用保持功能。如果产品有特殊安全需求可以把 WP 接到 GPIO 上通过软件控制写保护状态防止误写。2.2 SPI 模式选择与时钟配置MR25H40CDF 支持 SPI 模式 0CPOL0CPHA0和模式 3CPOL1CPHA1。这两种模式的区别在于时钟空闲电平和采样边沿。我一般选模式 0因为它是绝大多数 SPI 器件的默认模式调试时用逻辑分析仪抓波形更容易对照。时钟频率方面MR25H40CDF 最高支持 40MHz但 PIC18F97J94 在 64MHz 主频下SPI 时钟分频后的实际频率需要计算。假设系统时钟 64MHzSPI 分频系数设为 4则 SCK 频率为 16MHz。这个频率下 MRAM 读写完全没问题而且留有余量。如果追求极致速度可以把分频系数设为 2SCK 达到 32MHz但要注意 PCB 走线和信号完整性。参数推荐值说明SPI 模式模式 0CPOL0, CPHA0最通用时钟频率16MHz分频系数 4稳定可靠片选方式软件 GPIO时序可控调试方便WP 引脚接高电平禁用写保护HOLD 引脚接高电平禁用保持功能2.3 PCB 布局注意事项SPI 信号线属于高速信号虽然 16MHz 不算特别高但在工业环境下走线不当会引入干扰。我的经验是SCK 线尽量短避免和模拟信号线平行走线SO/SI 线如果长度超过 5cm建议串联 22 欧姆电阻做阻抗匹配MRAM 的电源引脚旁边必须放 0.1uF 去耦电容越近越好。地平面处理也很关键。如果 PCB 是双层板MRAM 下方尽量铺地如果是四层板把 MRAM 放在靠近地平面的层。工业现场的电磁干扰复杂这些细节在实验室里看不出问题到了现场就可能出现偶发读写错误。注意MRAM 对电源纹波比较敏感如果系统里有大功率开关器件建议给 MRAM 单独加一级 LDO 供电或者在电源入口加磁珠和电容滤波。3. 底层驱动实现从字节收发到页写入3.1 SPI 字节收发函数底层驱动的基础是字节收发。PIC18F97J94 的 MSSP 模块提供了 SSPBUF 寄存器写入数据后硬件自动发送同时接收数据也存入 SSPBUF。下面是一个典型的字节收发函数unsigned char SPI_ExchangeByte(unsigned char data) { SSPBUF data; // 写入发送数据 while (!SSPIF); // 等待发送完成 SSPIF 0; // 清除中断标志 return SSPBUF; // 返回接收数据 }这个函数看起来简单但有几个细节需要注意。SSPIF 标志位的清除时机很关键必须在读取 SSPBUF 之前清除否则可能读到旧数据。另外如果 SPI 时钟太快while 循环可能来不及响应建议在初始化时把 SPI 时钟先设低一点调试通过后再提高。3.2 MRAM 写使能与状态检查MR25H40CDF 的写入操作需要先发送写使能命令WREN命令码 0x06然后才能执行写操作。写操作完成后芯片进入内部写周期此时状态寄存器的 WIP 位Write In Progress为 1表示正在写入。虽然 MRAM 的写周期极短但为了保险还是建议检查 WIP 位。void MRAM_WriteEnable(void) { CS_LOW(); SPI_ExchangeByte(0x06); // WREN 命令 CS_HIGH(); } void MRAM_WaitReady(void) { CS_LOW(); SPI_ExchangeByte(0x05); // RDSR 命令 while (SPI_ExchangeByte(0xFF) 0x01); // 等待 WIP 位清零 CS_HIGH(); }这里有个实操心得MRAM 的写周期通常在几百纳秒内完成实际上调用 MRAM_WaitReady 时 WIP 位往往已经清零了。但为了代码的健壮性这个检查不能省尤其是在低温环境下MRAM 的写入速度会略有下降。3.3 页写入与地址管理MR25H40CDF 的存储空间是 512KB地址范围 0x00000 到 0x7FFFF需要 19 位地址。SPI 命令中写命令WRITE命令码 0x02后面跟 3 个字节的地址然后是数据。MRAM 支持页写入每页 256 字节但也可以跨页连续写入因为它没有 Flash 那种页边界限制。void MRAM_WriteData(unsigned long addr, unsigned char *buf, unsigned int len) { MRAM_WriteEnable(); CS_LOW(); SPI_ExchangeByte(0x02); // WRITE 命令 SPI_ExchangeByte((addr 16) 0xFF); SPI_ExchangeByte((addr 8) 0xFF); SPI_ExchangeByte(addr 0xFF); for (unsigned int i 0; i len; i) { SPI_ExchangeByte(buf[i]); } CS_HIGH(); MRAM_WaitReady(); }地址的高位处理要注意PIC18F97J94 是 8 位机unsigned long 是 32 位但实际地址只有 19 位有效。移位操作时确保高位补零避免地址错乱。另外写入长度 len 如果超过 256 字节MRAM 会自动递增地址不需要手动分页这是 MRAM 相比 Flash 的一大优势。3.4 读取操作的实现读取操作比写入简单发送读命令READ命令码 0x03和地址后直接连续读取数据即可。MRAM 支持任意地址起始的连续读取没有页边界限制。void MRAM_ReadData(unsigned long addr, unsigned char *buf, unsigned int len) { CS_LOW(); SPI_ExchangeByte(0x03); // READ 命令 SPI_ExchangeByte((addr 16) 0xFF); SPI_ExchangeByte((addr 8) 0xFF); SPI_ExchangeByte(addr 0xFF); for (unsigned int i 0; i len; i) { buf[i] SPI_ExchangeByte(0xFF); } CS_HIGH(); }读取时发送 0xFF 是 SPI 的标准做法因为主机需要产生时钟信号才能接收数据发送什么内容无所谓。但要注意有些 SPI 从器件对发送内容有要求MRAM 没有这个限制0xFF 或 0x00 都可以。4. 应用层数据管理校验、掉电保护与日志结构4.1 数据帧结构与校验和把 MRAM 当裸存储用没问题但实际项目里需要一套数据管理机制。我的做法是定义固定长度的数据帧每帧包含帧头、数据长度、数据内容、校验和。帧头用两个字节的固定值比如 0xAA55校验和用 CRC16 或者简单的累加和。typedef struct { unsigned short header; // 0xAA55 unsigned short length; // 数据长度 unsigned char data[64]; // 数据内容 unsigned short crc; // CRC16 校验 } DataFrame;写入时先构造帧计算 CRC然后整帧写入 MRAM。读取时先读帧头验证帧头正确后再读长度和 CRC校验通过才认为数据有效。这套机制能有效防止掉电导致的半写数据被误读。4.2 掉电保护策略工业设备最怕的是写入过程中掉电导致数据处于写了一半的状态。MRAM 的写入速度极快掉电窗口很小但理论上仍然存在。我的做法是采用双备份加标志位策略数据同时写入两个区域每个区域有一个状态标志。写入时先写数据区再写标志位读取时检查标志位如果标志位无效则读取备份区。具体实现上把 MRAM 分成两个 bank每个 bank 存储相同的数据结构。写入时先写 bank A更新 bank A 的标志位再写 bank B更新 bank B 的标志位。读取时优先读 bank A如果标志位无效则读 bank B。这样即使写入过程中掉电至少有一个 bank 的数据是完整的。提示标志位的写入顺序很重要必须先写数据再写标志位。如果先写标志位再写数据掉电后标志位有效但数据不完整会导致误判。4.3 日志存储的环形缓冲设计对于需要记录运行日志的场景环形缓冲是最合适的数据结构。在 MRAM 里划出一块区域作为日志区每个日志条目固定长度写满后从头覆盖。维护一个写指针每次写入后指针递增超过区域大小时归零。环形缓冲的好处是不需要擦除写入永远是覆盖操作MRAM 的无限寿命特性在这里发挥得淋漓尽致。日志区的大小根据需求定比如 64KB 可以存 1024 条 64 字节的日志。写指针本身也需要存储可以放在 MRAM 的固定位置每次写入日志后更新指针。typedef struct { unsigned long write_ptr; // 写指针 unsigned long log_count; // 日志总数 } LogHeader; void Log_Write(unsigned char *entry) { LogHeader header; MRAM_ReadData(LOG_HEADER_ADDR, (unsigned char*)header, sizeof(header)); unsigned long addr LOG_BASE_ADDR (header.write_ptr % LOG_MAX_ENTRIES) * LOG_ENTRY_SIZE; MRAM_WriteData(addr, entry, LOG_ENTRY_SIZE); header.write_ptr; header.log_count; MRAM_WriteData(LOG_HEADER_ADDR, (unsigned char*)header, sizeof(header)); }这个设计里日志头部的更新是每次写入都要做的所以日志头部的写入频率很高。MRAM 的无限寿命让这个设计成为可能换成 EEPROM 的话头部区域很快就会写坏。5. 调试实录常见问题与排查技巧5.1 读写数据错乱的排查调试初期最常见的问题是读出来的数据和写入的不一致。排查思路按以下顺序进行先确认 SPI 模式是否正确用逻辑分析仪抓 SCK 和 SI 的波形对照 MRAM 数据手册的时序图再检查片选信号确保 CS 在命令和数据传输期间保持低电平传输结束后才拉高最后检查地址字节顺序MRAM 是高位在前如果地址字节顺序搞反读写地址就会错位。我遇到过一次诡异的问题单字节读写正常连续读写就出错。查了半天发现是 CS 拉高的时机不对在最后一个字节的时钟边沿还没结束时 CS 就拉高了导致最后一个字节丢失。解决办法是在 SPI_ExchangeByte 返回后加一个微秒级延时再拉高 CS。5.2 写入后立即读取返回旧数据这个问题通常是因为没有等待写周期完成。虽然 MRAM 写周期很短但如果代码里写入后立即读取而 SPI 时钟又比较快可能在 WIP 位还没清零时就发起了读操作。解决办法是在写入后调用 MRAM_WaitReady或者加一个短延时。另一个可能的原因是 SPI 时钟太快MRAM 还没完成内部写入主机就开始下一次传输了。可以尝试降低 SPI 时钟频率如果问题消失说明是时序余量不足。5.3 工业现场偶发数据错误实验室里一切正常到了现场偶尔出现数据错误这类问题最难查。我的经验是先从电源入手用示波器看 MRAM 电源引脚上的纹波如果纹波超过 50mV就需要加强滤波。再从信号完整性入手检查 SCK 和 SO/SI 线上是否有过冲和振铃必要时串联电阻或加电容。还有一个容易被忽略的点是地线。工业现场的地电位差可能很大如果 MRAM 和主控的地线处理不当SPI 信号的电平参考就会漂移。建议在 MRAM 的 GND 引脚附近单独打孔接地不要和功率地混在一起。问题现象可能原因排查方法解决方案读写数据错乱SPI 模式错误逻辑分析仪抓波形改为模式 0连续读写出错CS 时序不对检查 CS 拉高时机加延时后拉高写入后读旧数据未等待写周期检查 WIP 位调用 WaitReady现场偶发错误电源纹波大示波器看电源加强滤波现场偶发错误地线干扰检查接地单独接地5.4 调试工具与技巧调试 SPI 器件逻辑分析仪是必备工具。我用的是 8 通道 24MHz 的逻辑分析仪价格不贵但足够用。抓波形时重点看 CS、SCK、SI、SO 四根线对照数据手册的时序图逐段核对。如果条件允许用带 SPI 解码功能的示波器更省事可以直接把波形翻译成十六进制数据。另一个技巧是在代码里加调试打印把每次读写的地址、长度、数据内容通过串口打印出来。这样即使没有逻辑分析仪也能通过串口日志定位问题。但要注意打印本身会占用时间可能影响 SPI 时序所以调试打印要在问题定位后移除。6. 性能优化与扩展思路6.1 提高读写速度的几种方法SPI 时钟频率是最直接的影响因素。PIC18F97J94 的 SPI 时钟最高可以到系统时钟的四分之一64MHz 主频下就是 16MHz。如果系统时钟可以超频到 80MHzSPI 时钟就能到 20MHz。但超频会带来功耗和稳定性问题工业场景下不建议。另一个方法是减少片选翻转次数。每次读写操作都需要拉低再拉高 CS这个动作本身有时间开销。如果需要连续读写多个不连续地址可以把它们合并成一次传输中间不拉高 CS。MRAM 支持连续地址自动递增但不支持随机地址跳转所以合并的前提是地址连续。还可以用 DMA 来搬运数据但 PIC18F97J94 没有 DMA 控制器这个方案不适用。如果换成带 DMA 的 PIC32 系列SPI 传输可以完全不占 CPU效率提升明显。6.2 多器件共享 SPI 总线一个系统里往往不止一个 SPI 器件比如 MRAM、显示屏、传感器可能都挂在同一条 SPI 总线上。这时候片选信号的管理就很重要。每个器件单独一根 CS 线同一时刻只能有一个 CS 有效。软件上要确保在切换器件时先拉高当前器件的 CS再拉低目标器件的 CS中间加一个小延时。如果 SPI 总线上的器件比较多GPIO 不够用可以用译码器来扩展片选。比如 3-8 译码器可以用 3 根 GPIO 控制 8 个片选信号。但译码器的输出有延时高速 SPI 下可能来不及响应需要选高速译码器。6.3 从 MRAM 到其他存储介质的迁移如果项目后期需要换存储介质比如换成 FRAM 或 NVSRAM底层驱动的改动量取决于抽象层设计得好不好。我的做法是把 MRAM 的读写接口定义成函数指针或者宏上层应用只调用接口不直接操作 SPI。这样换器件时只需要重写底层驱动上层代码不动。MRAM 和 FRAM 的 SPI 命令集不完全一样FRAM 通常有更丰富的命令比如睡眠模式、设备 ID 读取等。迁移时要注意命令码的差异以及时序参数的差异。FRAM 的写入速度也很快但寿命比 MRAM 低几个数量级如果应用场景是频繁写入MRAM 仍然是更好的选择。6.4 低功耗场景的考虑电池供电的工业设备对功耗敏感。MR25H40CDF 的待机电流很低但 SPI 时钟翻转时会消耗动态功耗。降低功耗的方法包括降低 SPI 时钟频率、减少不必要的读写操作、在空闲时把 CS 拉高让 MRAM 进入待机模式。PIC18F97J94 本身有睡眠模式在睡眠模式下 SPI 外设可以关闭。如果系统大部分时间处于空闲状态可以设计成唤醒-读写-睡眠的循环把平均功耗降到微安级。但要注意MRAM 从待机模式唤醒需要时间如果唤醒太频繁反而会增加功耗。7. 项目落地后的几点体会这套方案我在三个项目里实际用过最长的已经稳定运行四年多现场没有出现过数据丢失的投诉。MRAM 的可靠性确实对得起它的价格虽然单颗成本比 SPI Flash 高不少但省掉了电池、省掉了擦除逻辑、省掉了数据丢失的售后成本综合算下来是划算的。调试过程中最大的坑是 SPI 时序尤其是 CS 的控制。数据手册上的时序图看起来很清晰但实际代码里如果 CS 拉高早了一个时钟周期数据就会丢。我的建议是调试初期把 SPI 时钟降到 1MHz 以下用逻辑分析仪把每个字节的波形都看清楚确认时序完全正确后再逐步提高频率。还有一个经验是MRAM 的写入虽然不需要擦除但写入前必须发 WREN 命令这个命令不能省。我见过有人为了省事初始化时发一次 WREN 就以为后面都能写了结果只有第一次写入成功。WREN 是每次写入前都要发的这是 MRAM 和 EEPROM 的一个区别。最后分享一个小技巧如果项目里需要存储配置参数可以把参数区放在 MRAM 的起始地址日志区放在后面。配置参数用双备份加 CRC 校验日志区用环形缓冲。这样一套存储结构可以覆盖大多数工业应用的需求代码复用率高新项目直接移植就行。
延伸阅读

更多相关文章

2026/10/4 13:01:39

STM32H750XB 驱动 MR25H40CDF MRAM 实战:SPI 接口配置与工业数据存储

1. 为什么 MRAM 在嵌入式存储里越来越受关注搞嵌入式存储的兄弟应该都有体会,选型的时候翻来覆去就那么几类:EEPROM 擦写寿命长但容量小、速度慢;NOR Flash 读取快但写入前必须擦除,还得整块整块地擦;NAND Flash 容量大…

2026/10/4 12:56:39

深度学习量化投资策略实战:从数据、模型到回测的完整指南

简介:这份资源是面向高校学生与量化投资初学者的一套完整项目源码,适用于毕业设计、期末大作业或人工智能与金融交叉方向的实践练习。项目以深度学习技术构建量化投资策略,涵盖数据预处理、模型搭建、训练调优与回测评估等核心环节&#xff0…

2026/10/4 12:56:39

基于Java的微信外卖小程序开发全流程:从需求分析到答辩演示

简介:这份PPT答辩资源围绕基于Java和MySQL的微信外卖小程序毕业设计/课程项目展开,面向需要完成系统开发、答辩展示或了解同类项目架构的学生与开发者。内容涵盖管理员、商家、用户三个端的功能模块设计,以及微信开发者工具使用、Java技术特性…

2026/10/4 14:01:41

Spring Boot美食分享系统开发实战:从毕设选题到部署上线全流程

前几天帮一个朋友梳理他手头的毕设项目,题目是《基于Spring Boot河南特色美食分享系统》。第一眼看到这个题目,我其实挺有好感的——相比千篇一律的“XX管理系统”,这个题目既有明确的地域文化属性,又有真实的内容社区逻辑&#x…

2026/10/4 14:01:41

插件加载机制与failed to load plugins报错排查

做开发这些年,plugins 这个词几乎每天都在见。写代码的库要插件,编辑器要插件,播放器要插件,CI/CD 流水线也要插件。最近后台连着收到几条跟插件有关的问题,每条都不长,但都问到了点上:iar plug…

2026/10/4 14:01:41

有店才敢做线上:实体店如何成为电商底气

实体店关店潮和“必须做电商”的声音喊了好几年,身边不少朋友都问我同一个问题:现在不开网店是不是就活不下去了?我的答案正好相反——这几年我观察下来,真正在线上做出成绩的,几乎都是手里先有店的人。“不开网店活不…

2026/10/4 0:01:02

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/4 0:01:02

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/4 1:01:05

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

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

2026/10/4 0:01:02

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/4 0:01:02

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/4 1:01:05

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

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

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑