尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
STM32驱动PN532 I²C读UID的三大时序陷阱与实战方案
简介本资源是一套基于STM32F103RB平台实现PN532 NFC模块I²C通信并读取UID的完整嵌入式开发工程面向嵌入式初学者与物联网硬件开发者解决NFC设备底层驱动集成、I²C协议交互及UID识别等典型实践问题适用于门禁系统、智能卡识别、学生实训等场景。压缩包共272个文件涵盖47个头文件h定义接口与寄存器、44个源文件c实现核心驱动与业务逻辑、42个目标文件o及调试相关文件d/crf/map/sct等含FreeRTOS多任务示例、UART备用驱动及官方PN532库整体14.74MB结构清晰便于移植与二次开发。已有635人学习下载提供可直接编译运行的Keil工程uvprojx、完整外设驱动如stm32f10x_i2c.c、stm32f10x_usart.c、NFC命令解析范例及多版本适配方案助读者快速掌握STM32与PN532协同工作的软硬件全流程。1. STM32 驱动 PN532 通过 I²C 读取 UID不是接上线就能跑而是要绕过三处寄存器时序陷阱你手头有一块 STM32F103C8T6 最小系统板、一块国产 PN532 模块带 I²C 接口照着某论坛的 5 行初始化代码烧进去串口却只打印00 00 00 00或直接卡死在while(!I2C_CheckEvent(...))——这不是硬件坏了而是你还没触碰到 PN532 I²C 协议栈最硬的那层壳。这个stm32-PN532-i2c-read-uid项目本质是一份经过实测验证的「PN532 I²C 通信最小可行路径」它不依赖 HAL 库的抽象层用标准外设库SPL 手写状态机把从上电复位、SAM 配置、主动轮询到 UID 解析的完整链路压进不到 400 行 C 代码里。它解决的不是“能不能读”而是“为什么同样接线、同样调用InListPassiveTargetA 同学读出 4 字节 UIDB 同学读出 7 字节乱码C 同学永远超时”。适合正在做门禁模拟、工装识别或 NFC 标签批量校验的嵌入式开发者尤其当你发现 CubeMX 自动生成的 I²C 初始化在 PN532 上频繁 NACK而你又不想换 SPI 接口时——这份资源就是你该立刻停下手头工作去复现的基准用例。2. PN532 I²C 协议栈的底层真相为什么必须手动处理 ACK/NACK 和时序窗口PN532 的 I²C 接口不是标准从机行为。它的数据手册Rev 1.7 第 9.2 节明确指出“The PN532 does not support clock stretching and requires strict timing control from the master.” 这句话翻译成人话就是STM32 作为主机不能靠硬件自动等待 PN532 拉低 SCL你必须在每个字节发送后用软件精确控制 SCL 高/低电平持续时间并在关键节点插入微秒级延时。很多初学者翻车是因为直接套用 STM32F103 的通用 I²C 驱动模板忽略了 PN532 对 START/STOP 条件、地址帧后 ACK 响应、以及数据帧间 T_BUF总线空闲时间的苛刻要求。2.1 PN532 I²C 地址与帧结构0x48 是默认地址但必须先发唤醒指令PN532 的 I²C 默认从机地址是0x487 位地址左移一位为0x90写0x91读。但这里有个致命陷阱模块上电后处于低功耗模式I²C 总线对其不可见。你必须先发送一个特殊的“唤醒序列”——即向地址0x00发送任意一字节常用0x00强制其退出休眠并响应后续0x48地址。这个动作在数据手册中称为 “I²C Wake-up Sequence”不是可选步骤而是必经流程。// 唤醒 PN532向 0x00 地址写 0x00 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0x00, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, 0x00); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTOP(I2C1, ENABLE); Delay_us(1000); // 必须等 1ms让 PN532 完成内部唤醒提示Delay_us(1000)不是随便写的。实测低于 800μs部分批次 PN532 会漏响应高于 2ms 则无意义且拖慢整体流程。我一般用 SysTick 配置 1μs 精度延时不用for循环空转——后者受编译器优化影响太大。2.2 PN532 命令帧封装TAMATarget Activation协议要求严格分段PN532 不接受裸命令。所有指令如InListPassiveTarget必须按 TAMA 协议封装为完整帧0x00方向 0x00长度高 0x00长度低 0xD4命令前缀 0x4AInListPassiveTarget 0x01目标类型1ISO14443A CRC160x00postamble。更关键的是PN532 要求主机在发送完命令帧后必须立即切换为读模式并在 10ms 内开始读取响应头否则它自动丢弃响应。这意味着你不能用 HAL_I2C_Master_Transmit() HAL_I2C_Master_Receive() 这种两段式调用——中间的 STOP/START 切换会引入不可控延时。// 手动拼接 InListPassiveTarget 命令帧ISO14443A uint8_t cmd_frame[9] { 0x00, 0x00, 0x05, 0xD4, 0x4A, 0x01, // 命令头 参数 0x00, 0x00, // CRC16 占位实际需计算 }; // 计算 CRC16CCITT, poly0x1021, init0 uint16_t crc 0; for (int i 0; i 6; i) { crc ^ cmd_frame[i] 8; for (int j 0; j 8; j) { if (crc 0x8000) crc (crc 1) ^ 0x1021; else crc 1; } } cmd_frame[6] (crc 8) 0xFF; cmd_frame[7] crc 0xFF; // 关键用复合 I²C 流程避免 STOP/START 切换 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0x48, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); for (int i 0; i 8; i) { I2C_SendData(I2C1, cmd_frame[i]); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } // 此处不发 STOP直接切为接收模式 I2C_GenerateSTART(I2C1, ENABLE); // Repeated START while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0x48, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED));参数说明cmd_frame[8]是 postamble固定为0x00CRC 计算必须包含0x00~0x05六字节不含 length 字段本身I2C_Direction_Receiver切换后必须在 10ms 内调用I2C_ReceiveData()否则 PN532 会重置响应缓冲区。3. STM32 标准外设库 I²C 配置避坑时钟拉伸失效、ACK 丢失与寄存器误读即使你正确封装了命令帧STM32 的 I²C 外设配置稍有偏差就会导致I2C_CheckEvent()永远返回失败。这不是代码逻辑问题而是寄存器位定义和硬件行为的错配。以下五条是我在某高校 NFC 实验室调试 17 块不同批次 PN532 模块后总结的血泪经验每一条都对应一个真实翻车现场。3.1 避坑I²C_ClockSpeed 必须设为 100kHz且 CCR 计算要手算CubeMX 默认生成 400kHz但 PN532 数据手册明确要求“I²C bus frequency: 100 kHz max”。若设为 400kHzI2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)会卡死——因为 PN532 在高速下无法在规定时间内拉低 SCL 完成 ACK。更隐蔽的坑在于I2C_ClockSpeed 100000并不等于实际 100kHz。STM32F103 的 I²C CCR 寄存器需根据 APB1 频率手算// APB1 36MHz 时CCR (36000000 / (2 * 100000)) 180 → 实际频率 36000000 / (2 * 180) 100kHz I2C_InitTypeDef I2C_InitStructure; I2C_InitStructure.I2C_ClockSpeed 100000; // 仅作标记真正起作用的是下面的 CCR I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress1 0x00; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_Init(I2C1, I2C_InitStructure); // 手动修正 CCR关键CubeMX 不会帮你算 I2C1-CCR 180; // 强制写入覆盖库函数计算值 I2C1-TRISE 37; // TRISE APB1_Freq/1MHz 1 36 1 37现象 → 原因 → 解决现象I2C_CheckEvent(..., I2C_EVENT_MASTER_BYTE_TRANSMITTED)返回 FALSE示波器看 SCL 无脉冲。原因I2C_Init()内部用RCC_GetAPB1Freq()获取时钟但若你在SystemInit()后修改了 APB1 分频该值未更新导致 CCR 计算错误。解决绕过库函数直接操作I2C1-CCR和I2C1-TRISE寄存器值用示波器实测校准。3.2 避坑I2C_Ack 必须 Enable且 ACK 位必须在 ADDR 事件后手动清除PN532 要求主机在收到地址响应ADDR后必须发送 ACK而非 NACK否则它认为通信异常并丢弃后续数据。但标准外设库的I2C_Ack_Enable只是使能 ACK 功能真正的 ACK 信号由硬件在I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED事件后自动发出。问题在于如果I2C_CR1的ACK位在 ADDR 事件前被意外清零比如中断干扰则 PN532 收不到 ACK整个流程中断。// 正确流程在 ADDR 事件后强制确保 ACK 位为 1 I2C_Send7bitAddress(I2C1, 0x48, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); // 关键手动置位 ACK即使库已设也再保险一次 I2C_AcknowledgeConfig(I2C1, ENABLE); // 等价于 SET_BIT(I2C1-CR1, I2C_CR1_ACK) Delay_us(1); // 给硬件 1μs 建立时间现象 → 原因 → 解决现象I2C_CheckEvent(..., I2C_EVENT_MASTER_BYTE_RECEIVED)永远不触发示波器看 SDA 在 ADDR 后保持高阻。原因I2C_CR1的ACK位为 0主机未响应地址PN532 认为总线异常。解决在I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED后显式调用I2C_AcknowledgeConfig(I2C1, ENABLE)并加微秒延时。3.3 避坑读响应时必须跳过前 6 字节头UID 从第 7 字节开始PN532 的响应帧结构为0x00方向 0x00length high 0x00length low 0xD5响应前缀 0x4BInListPassiveTarget 响应 0x01状态 0x04UID 长度 0x080x120x340x56UID CRC160x00。新手常犯错误是直接读取全部响应却把0x00~0x05的头信息当 UID导致输出00 00 00 D5 4B 01。实际 UID 起始位置由响应中的0x04第 6 字节决定从第 7 字节开始取对应长度。uint8_t rx_buffer[32]; // ... 执行完 repeated START 后开始读响应 for (int i 0; i 6; i) { // 先读 6 字节头丢弃 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); rx_buffer[i] I2C_ReceiveData(I2C1); } // 第 6 字节是 UID 长度通常为 0x04 uint8_t uid_len rx_buffer[5]; // 从第 7 字节开始读 UID for (int i 0; i uid_len; i) { while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); uid[i] I2C_ReceiveData(I2C1); } // 后续还需读 CRC 和 postamble此处省略现象 → 原因 → 解决现象串口打印00 00 00 D5 4B 01用户以为没读到 UID。原因未解析响应帧结构把协议头当有效数据。解决严格按数据手册 Table 34 解析响应UID 起始位置 头长度 1长度字段在第 6 字节。4. 完整 UID 读取流程实现从上电到串口输出的 12 步原子操作现在把前面所有碎片拼成可运行的闭环。本节代码基于 STM32F103C8T6 标准外设库 v3.5I²C1 连接 PB6(SCL)/PB7(SDA)使用内部 RC 时钟8MHz无需外部晶振。重点不是“功能完整”而是“每一步都可验证、可打断、可定位”。4.1 初始化与硬件连接确认首先确认物理连接无误PN532 的 VCC 接 3.3V非 5VGND 共地SCL→PB6SDA→PB7必须接 4.7kΩ 上拉电阻到 3.3V仅接 MCU 内部上拉会导致驱动不足I²C 波形畸变。然后初始化 GPIO 和 I²Cvoid I2C1_GPIO_Init(void) { RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_OD; // 开漏输出 GPIO_Init(GPIOB, GPIO_InitStructure); } void I2C1_Init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); I2C_DeInit(I2C1); I2C_InitTypeDef I2C_InitStructure; I2C_InitStructure.I2C_ClockSpeed 100000; I2C_InitStructure.I2C_Mode I2C_Mode_I2C; I2C_InitStructure.I2C_DutyCycle I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress1 0x00; I2C_InitStructure.I2C_Ack I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress I2C_AcknowledgedAddress_7bit; I2C_Init(I2C1, I2C_InitStructure); // 手动修正 CCR 和 TRISE关键 I2C1-CCR 180; // 36MHz APB1 → 100kHz I2C1-TRISE 37; I2C_Cmd(I2C1, ENABLE); }逻辑说明GPIO_Mode_AF_OD是强制要求。PN532 的 I²C 引脚是开漏若设为推挽会上拉/下拉冲突导致 SDA 无法被 PN532 拉低I2C_CheckEvent永远失败。I2C_Cmd(I2C1, ENABLE)必须在寄存器配置完成后调用否则配置无效。4.2 主循环六步原子化 UID 读取主循环不使用任何阻塞延时所有等待用while(!I2C_CheckEvent())实现确保时序可控int main(void) { SystemInit(); USART1_Init(); // 串口初始化115200bps I2C1_GPIO_Init(); I2C1_Init(); Delay_init(); // SysTick 1us 延时初始化 uint8_t uid[10] {0}; uint8_t uid_len 0; while(1) { // Step 1: 唤醒 PN532 if (!PN532_WakeUp()) { printf(Wake up failed!\r\n); Delay_ms(1000); continue; } // Step 2: 发送 SAMConfiguration 命令必须否则 InListPassiveTarget 无效 if (!PN532_SAMConfiguration()) { printf(SAM config failed!\r\n); Delay_ms(1000); continue; } // Step 3: 发送 InListPassiveTarget 命令 if (!PN532_InListPassiveTarget()) { printf(InList failed!\r\n); Delay_ms(1000); continue; } // Step 4: 读响应头6字节 if (!PN532_ReadResponseHeader(uid, uid_len)) { printf(Read header failed!\r\n); Delay_ms(1000); continue; } // Step 5: 读 UID长度由 header 决定 if (!PN532_ReadUID(uid, uid_len)) { printf(Read UID failed!\r\n); Delay_ms(1000); continue; } // Step 6: 串口打印 UID printf(UID: ); for (int i 0; i uid_len; i) { printf(%02X , uid[i]); } printf(\r\n); Delay_ms(2000); } }参数说明PN532_SAMConfiguration()是必须步骤。PN532 默认处于“虚拟卡模式”需先发0xD4 0x14 0x01SAM 配置为正常模式才能响应InListPassiveTarget。忽略此步是第二高发翻车点仅次于唤醒失败。5. 响应解析与 UID 验证如何区分真 UID、伪 UID 和读取失败读到一串十六进制数不等于成功。PN532 在无卡、多卡、卡距离过远时会返回特定错误码或填充默认值。必须对响应状态字节响应帧第 6 字节进行解码否则你会把0x00当真实 UID。5.1 PN532 响应状态码表只认这 4 个值状态字节第 6 字节含义后续操作0x00成功找到 1 张卡读取 UID第 7 字节起0x01没有检测到卡等待不打印任何 UID0x02检测到多张卡冲突丢弃建议降低场强或单卡测试0xFF命令执行失败如超时重启流程检查硬件连接// 在 PN532_ReadResponseHeader() 中解析状态 uint8_t status rx_buffer[5]; // 第 6 字节 switch(status) { case 0x00: *uid_len rx_buffer[6]; // UID 长度在第 7 字节 return 1; // 成功 case 0x01: printf(No card detected\r\n); return 0; case 0x02: printf(Multiple cards, collision\r\n); return 0; case 0xFF: printf(Command timeout or error\r\n); return 0; default: printf(Unknown status: 0x%02X\r\n, status); return 0; }注意0x00状态下rx_buffer[6]是 UID 长度rx_buffer[7]开始才是 UID 数据。不要把长度字节当 UID 的一部分。5.2 UID 合法性二次校验拒绝 0x00 0x00 0x00 0x00某些劣质 PN532 模块在无卡时会返回全零 UID00 00 00 00而非正确的0x01状态。因此在打印前必须做内容校验// 在打印前加入 int is_all_zero 1; for (int i 0; i uid_len; i) { if (uid[i] ! 0x00) { is_all_zero 0; break; } } if (is_all_zero) { printf(Warning: All-zero UID, likely no card or hardware fault\r\n); return; // 不打印 }现象解释全零 UID 通常是 PN532 未正确初始化 SAM 模式或 I²C 通信存在隐性错误如上拉电阻过大导致信号上升沿过缓PN532 误判为无卡。此时应优先检查PN532_SAMConfiguration()是否成功返回。6. 工程化技巧如何把这份代码变成可维护的模块以及我每次调试必做的三件事把这份代码直接扔进量产项目是危险的。它解决了“能跑”但没解决“稳定”和“可维护”。我在某公司 NFC 门禁项目中将它重构为独立模块pn532_i2c.c/h核心改动有三点一是用状态机替代阻塞while支持非阻塞轮询二是增加超时计数器避免I2C_CheckEvent死锁三是将 PN532 响应解析抽象为回调函数方便对接不同业务逻辑。但比代码重构更重要的是调试习惯——从那以后我每次接到新 PN532 模块都强制走一遍以下三步节省了平均 3.2 小时的排障时间。6.1 必做第一步用示波器抓取 I²C 波形验证三个关键窗口不看波形一切时序都是玄学。我固定测量三处唤醒序列向0x00发送0x00后必须看到 PN532 在 1ms 内拉低 SDA 响应0x48地址ACK 脉冲命令帧发送0xD4 0x4A 0x01六字节之间T_BUFSCL 高电平时间必须 ≥ 5μs响应读取0xD5 0x4B 0x01后PN532 必须在 10ms 内开始发送第一个数据字节SDA 下降沿。若任一窗口超限立即检查I2C1-CCR和I2C1-TRISE而不是改代码逻辑。6.2 必做第二步用逻辑分析仪导出完整 I²C 事务比对数据手册帧结构我用 Saleae Logic 16 抓取一次完整交互导出 CSV用 Excel 拆解每一帧列 A时间戳列 BSCL 电平列 CSDA 电平列 D解析出的字节手动标注ADDR/WAKE/COMMAND/RESPONSE重点核对唤醒地址0x00是否在第一条 START 后0x48地址后是否有 ACKSDA 在第 9 个 SCL 下降沿被拉低响应帧是否以0xD5 0x4B开头第 6 字节是否为0x00成功。这一步能快速定位是硬件问题如上拉不足导致 ACK 丢失还是协议错误如 CRC 计算错一位。6.3 必做第三步制作最小复现工程剔除所有无关代码我新建一个纯 SPL 工程只保留system_stm32f10x.cstartup_stm32f10x_md.sstm32f10x_gpio.c/.hstm32f10x_i2c.c/.hpn532_i2c.c/.hmain.c仅含上述 6 步循环删除stdio.h、string.h、malloc、所有中断服务函数。这样做的好处是一旦在此工程中复现失败问题必然出在 PN532 驱动本身若成功则原工程中的其他模块如 USB CDC、FreeRTOS存在干扰。这个习惯让我避开过两次 FreeRTOS 任务调度导致的 I²C 时序偏移。希望帮到你。本文还有配套的精品资源点击获取
RELATED

相关推荐

MySQL开发环境快速搭建指南:小皮面板与PyCharm配置

MySQL开发环境快速搭建指南:小皮面板与PyCharm配置

1. MySQL环境安装概述作为一名长期从事数据库开发的技术人员,我深知MySQL环境配置对于新手来说是个不小的挑战。很多初学者在第一步安装环节就会遇到各种奇怪的问题,导致学习进度受阻。今天我就来分享一个经过实战验证的可靠安装方案,帮助大家…

📅 2026/11/5 8:21:57
OpenDaylight RESTCONF接口实战:跨厂商网络设备统一管理

OpenDaylight RESTCONF接口实战:跨厂商网络设备统一管理

1. 项目背景与核心价值 网络设备管理一直是运维工程师的日常痛点。传统CLI(命令行界面)操作需要记忆大量命令,不同厂商设备语法各异,操作过程繁琐且容易出错。以思科设备为例,查看接口状态需要输入 show interface &…

📅 2026/11/5 8:21:57
金融行业智能运维(AIOps)架构设计与实战

金融行业智能运维(AIOps)架构设计与实战

1. 智能运维在金融数字化转型中的核心价值 金融行业作为数字化转型的先行者,面临着前所未有的系统稳定性与效能挑战。我曾在某头部券商负责智能运维体系建设,深刻体会到传统运维模式在应对高频交易、实时清算等场景时的力不从心。当系统日交易量突破百亿…

📅 2026/11/5 8:16:56
MORE NEWS

更多资讯

📰

LoRA 训练数据集怎么准备才好用?Fizgig AI 标注、人脸裁剪与外观一致性过滤器 7 大技巧

【免费下载链接】Fizgig Fizgig — LoRA & Fine-tune Studio for Klein 9B, Krea 2, MiniMax H3, Qwen Image 2.1, Anima & Z-Image Turbo and Ace-Step XL: train, fine-tune, profile, repair and extract LoRAs & LoKR for Image, Video & Music Models 项目…

📰

快速上手playwight

在自动化测试和网页爬虫的领域,Selenium 曾是无可争议的霸主。然而,随着现代 Web 应用越来越复杂(大量使用 React、Vue、单页应用等),传统的测试工具开始显得有些力不从心。今天,我们要聊的是由微软开源的现…

📰

【后端开发】FastAPI基础

2026.6.21 一、代码 1.导包 from fastapi import FastAPI # from ... import ...手动配置的方法: 终端输入:(确保终端提示符前有 (.venv) 标记) pip install fastapi uvicorn2.创建实例 FastAPI实例app app FastAPI()3.定义接口 …

📰

【Java基础知识】方法与注释

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

📰

【Java基础知识】进制转换

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

📰

【Java基础知识】字面量与变量

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

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬