C++多线程死锁排查实战:从原理到GDB调试全解析
1. 项目概述当你的多线程程序“卡死”时如果你写过C多线程程序大概率遇到过这种情况程序运行得好好的突然某个时刻就“卡”住了界面无响应日志不输出CPU占用率可能还很低就像程序进入了永恒的等待。作为开发者你心里清楚十有八九是遇到了那个经典的、令人头疼的“幽灵”——死锁。死锁不是C的专利但C标准库提供的强大并发工具如std::mutex、std::lock_guard在给我们带来便利的同时也因其使用不当而成为死锁的“高发区”。更棘手的是死锁问题往往难以稳定复现它可能只在特定负载、特定时序下才会“显形”给排查带来了巨大挑战。这个项目就是一次从问题现象出发穿越代码迷雾最终利用GDB这把“手术刀”精准定位死锁现场的完整实战记录。它不仅仅是一份操作指南更是一次调试思维的训练旨在让你下次面对“卡死”的程序时能有一套清晰、可执行的排查路径而不是盲目地重启或添加无意义的sleep。2. 死锁原理与C锁机制深度解析要排查死锁首先得彻底理解它是如何产生的。死锁的经典定义是四个必要条件同时满足互斥、持有并等待、不可剥夺、循环等待。在C多线程编程的语境下我们通常将其简化为一个更直观的场景两个或更多线程各自持有一部分资源锁同时又在等待对方释放其持有的资源从而形成一个僵持不下的循环等待链。2.1std::lock_guard与std::unique_lock的陷阱C11引入的RAII资源获取即初始化锁管理器如std::lock_guard和std::unique_lock极大地简化了锁的管理避免了手动lock/unlock不匹配导致的资源泄漏。然而它们也是死锁的“重灾区”。std::lock_guard在构造时自动加锁析构时自动解锁生命周期即锁周期。它的优点是简单、零开销。但缺点也源于此它不支持手动解锁和重复加锁。这意味着如果你需要同时获取多个锁并且希望以固定的、全局一致的顺序来避免死锁std::lock_guard本身无法帮你。你必须在外层代码逻辑上保证所有线程都以相同的顺序例如先锁mutex A再锁mutex B来获取这些lock_guard。一旦顺序不一致死锁风险陡增。// 线程1的执行顺序 std::lock_guardstd::mutex lk1(mutex_a); // 先锁A std::lock_guardstd::mutex lk2(mutex_b); // 再锁B // 线程2的执行顺序危险 std::lock_guardstd::mutex lk2(mutex_b); // 先锁B std::lock_guardstd::mutex lk1(mutex_a); // 再锁A此时可能死锁std::unique_lock则提供了更大的灵活性它可以延迟加锁通过std::defer_lock、手动加解锁lock(),unlock()、尝试加锁try_lock()以及所有权转移。正是这种灵活性使得std::unique_lock可以与std::lock函数配合实现死锁避免的算法。std::lock函数可以一次性锁定两个或更多的std::unique_lock对象并且内部采用了特定的算法如避免饥饿的算法来保证无论以何种顺序传入锁都不会导致死锁。这是解决多锁获取顺序问题的标准方案。std::unique_lockstd::mutex lk1(mutex_a, std::defer_lock); std::unique_lockstd::mutex lk2(mutex_b, std::defer_lock); std::lock(lk1, lk2); // 一次性原子性地锁定两个锁避免死锁实操心得很多新手会疑惑到底用哪个。我的经验法则是单锁场景无脑用std::lock_guard需要同时获取多个锁或者需要灵活控制锁的生命周期如条件变量则使用std::unique_lock并结合std::lock。永远不要手动嵌套多个lock_guard去获取多个锁除非你能百分百保证全局顺序。2.2 锁的粒度与持有时间死锁的概率与锁的持有时间成正比。一个锁如果被长时间持有例如在锁保护区内进行文件I/O、网络请求或复杂计算其他需要该锁的线程就必须等待更久这期间如果该线程又去请求其他锁就极易与其他线程形成循环等待。因此设计锁的粒度至关重要。锁的粒度应该尽可能小只保护真正共享的、需要互斥访问的数据而不是保护整个函数或整个对象生命周期。在进入临界区后尽快完成对共享数据的操作并释放锁。对于耗时操作应将其移到锁保护区之外。// 不好的做法锁粒度太大 void processData(const Data d) { std::lock_guardstd::mutex lock(data_mutex); // 耗时操作1 auto result timeConsumingCalculation(d); // 耗时操作2 writeToLog(result); // 才更新真正需要保护的数据 shared_data result; } // 改进做法缩小锁粒度 void processDataBetter(const Data d) { // 耗时操作放在锁外 auto result timeConsumingCalculation(d); writeToLog(result); // 如果日志不是竞争资源也应放在外面 { // 锁只保护最终的写操作 std::lock_guardstd::mutex lock(data_mutex); shared_data result; } }3. 死锁排查实战从现象到定位当程序发生死锁时它并不会崩溃而是“静默”地停止响应。我们的任务就是让这个“静默”的过程变得“可观测”。3.1 初步判断与信息收集首先你需要确认程序是否真的死锁了而不是因为无限循环、阻塞IO或其它原因导致的卡顿。观察系统状态使用top或htop命令查看进程的CPU和内存占用。一个典型的死锁线程可能处于Sleep或D不可中断睡眠通常是在等待IO但等待锁也可能状态且CPU使用率很低。获取线程转储在Linux下最直接的方法是向进程发送SIGQUIT信号kill -3 PID。对于控制台程序这通常会在标准输出产生一个线程调用栈的转储。对于后台服务你需要确保其日志重定向到了文件。在Windows下你可以使用任务管理器创建转储文件或使用Debug Diagnostic Tool。分析线程栈查看线程转储寻找那些卡在pthread_mutex_lock、__lll_lock_waitglibc实现或类似锁等待函数上的线程。如果多个线程的栈显示它们都在互相等待对方持有的锁那么死锁的嫌疑就非常大了。3.2 使用GDB附加到运行进程进行动态分析对于不能轻易重启的线上服务或者需要更精细分析的场景GDBGNU调试器是我们的终极武器。GDB可以附加到正在运行的进程检查其内部状态包括所有线程的调用栈和锁的持有情况。步骤一附加进程gdb -p PID其中PID是你的C程序的进程ID。步骤二查看所有线程信息在GDB提示符下输入(gdb) info threads这会列出所有线程显示其ID、状态如runningwaiting on conditionin __lll_lock_wait以及当前函数。死锁相关的线程通常会卡在锁相关的函数里。步骤三切换线程并查看调用栈假设info threads显示线程3和线程7状态可疑。(gdb) thread 3 (gdb) btbtbacktrace命令会打印该线程的完整调用栈。仔细查看栈帧找到你的业务代码在哪里调用了锁操作。通常你会看到类似std::lock_guardstd::mutex::lock_guard这样的构造函数调用。对另一个可疑线程如线程7重复此操作。(gdb) thread 7 (gdb) bt步骤四分析锁的持有与等待关系关键步骤这是GDB排查死锁最核心的一步。我们需要知道每个锁被谁持有谁在等待它。这需要查看mutex的内部状态。对于std::mutex通常是pthread_mutex_t的封装我们可以使用GDB的print命令来检查。首先在你的代码或栈帧中找到mutex变量的地址或符号名。例如从栈帧中你看到死锁发生在MyClass::update()函数而该函数里使用了成员mutexm_dataMutex。找到mutex对象你可能需要先打印持有该mutex的类实例的this指针然后计算mutex成员的偏移量。更简单的方法是如果mutex是全局或静态变量GDB可以直接通过符号名访问。检查mutex状态std::mutex的实现依赖系统库。以glibc的pthread_mutex_t为例你可以尝试(gdb) p *(pthread_mutex_t*)0x7fffe4b2a710输出可能包含__data.__owner字段它表示当前持有该锁的线程IDLWP ID。如果__owner为0表示锁未被持有如果非0其值就是持有者的线程ID。注意mutex的内部结构是平台和实现相关的上述方法在Linux glibc环境下常见但并非绝对。有时需要查阅对应版本的glibc源码或调试符号。关联线程与锁记下线程7等待的锁地址然后切换到线程3检查线程3持有的锁地址。如果线程3持有锁A并等待锁B而线程7持有锁B并等待锁A那么一个经典的AB-BA死锁就确认了。步骤五使用thread apply all bt进行全局快照一个更快捷的命令可以一次性打印所有线程的栈方便你整体浏览死锁的全貌(gdb) thread apply all bt这个输出可能会很长但你可以将其重定向到文件然后慢慢分析其中互相等待的锁依赖环。避坑技巧在编译你的C程序时务必加上-g选项以包含调试符号。否则GDB输出的调用栈将是难以理解的十六进制地址而不是清晰的函数名和行号这会让排查工作变得极其困难。对于生产环境可以考虑使用-g配合-O2或者保留一份带调试符号的二进制文件用于调试。4. 高级技巧与预防策略定位死锁只是第一步如何修复和预防才是根本。4.1 使用std::scoped_lockC17C17引入了std::scoped_lock它是std::lock_guard的增强版专门用于解决多锁死锁问题。它可以接收任意数量的mutex并在构造时使用std::lock的算法一次性锁定所有mutex从而天然避免死锁。这是现代C中处理多个互斥量的首选方式。// 安全、简洁无需担心顺序 std::scoped_lock lock(mutex_a, mutex_b);4.2 设计时避免锁嵌套这是最根本的预防措施。在系统设计阶段就应尽量避免让一个函数在持有一个锁的情况下去调用另一个需要获取其他锁的函数。如果无法避免必须建立一个全局的、严格的锁获取顺序并在团队内形成编码规范。可以使用层次化锁为每个锁分配一个全局唯一的层级编号线程只能按编号递增顺序获取锁来强制实现这一点。4.3 使用锁超时机制std::mutex本身不支持超时。但std::timed_mutex和std::recursive_timed_mutex提供了try_lock_for和try_lock_until方法。std::unique_lock在配合std::defer_lock策略后也可以使用try_lock_for。虽然超时后回退释放已持有的锁、重试或返回错误的逻辑会复杂一些但这可以防止线程无限期等待至少能让程序不至于完全卡死并有机会记录错误日志为诊断提供线索。std::timed_mutex mutex_a mutex_b; std::unique_lockstd::timed_mutex lk_a(mutex_a std::defer_lock); std::unique_lockstd::timed_mutex lk_b(mutex_b std::defer_lock); auto timeout std::chrono::milliseconds(100); if (std::try_lock(lk_a lk_b)) { // 成功获取所有锁 } else { // 获取失败至少有一个锁在超时时间内未获取到 // 此时lk_a和lk_b可能处于未锁定状态取决于try_lock的实现 // 必须执行回退逻辑释放可能已持有的锁 if (lk_a.owns_lock()) lk_a.unlock(); if (lk_b.owns_lock()) lk_b.unlock(); // 记录错误重试或返回失败 log_error(“Failed to acquire locks within timeout”); }4.4 借助工具进行静态分析与动态检测静态分析一些现代静态代码分析工具如Clang Static Analyzer, Coverity可以识别出潜在的锁顺序不一致问题。动态检测Valgrind的Helgrind工具和DRD工具是检测多线程问题如数据竞争、死锁的神器。它们会在程序运行时监控锁操作并报告潜在的锁顺序问题以及实际发生的死锁。虽然会极大降低程序运行速度通常慢10-50倍但在测试阶段使用它们可以发现绝大多数并发BUG。TSANThreadSanitizerClang/LLVM和GCC都集成了ThreadSanitizer它是一个运行时的数据竞争检测器。虽然其主要目标是数据竞争但也能检测到一部分死锁。通过编译时添加-fsanitizethread标志即可使用。5. 一个完整的死锁排查案例实录假设我们有一个简单的资源管理程序出现了间歇性卡死。1. 问题现象 程序在运行几分钟到几小时后随机停止响应。top显示进程CPU使用率接近0%状态为S睡眠。2. 初步分析 通过kill -3 PID获取线程转储发现两个工作线程的栈如下Thread 1 (LWP 12345): #0 __lll_lock_wait () at ../nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.S:135 #1 0x00007f8b3a5b4a7b in __GI___pthread_mutex_lock (mutex0x55e4a3d8e740 g_resourceA_mutex) at ../nptl/pthread_mutex_lock.c:115 #2 0x000055e4a2c5b1ae in std::mutex::lock (this0x55e4a3d8e740 g_resourceA_mutex) at /usr/include/c/9/bits/std_mutex.h:103 #3 0x000055e4a2c5b225 in std::lock_guardstd::mutex::lock_guard (this0x7f8b28fbbd70 __m...) at /usr/include/c/9/bits/std_mutex.h:197 #4 0x000055e4a2c5a0ff in ResourceManager::accessResourceB() () at src/manager.cpp:150 ... Thread 2 (LWP 12346): #0 __lll_lock_wait () at ../nptl/sysdeps/unix/sysv/linux/x86_64/lowlevellock.S:135 #1 0x00007f8b3a5b4a7b in __GI___pthread_mutex_lock (mutex0x55e4a3d8e780 g_resourceB_mutex) at ../nptl/pthread_mutex_lock.c:115 #2 0x000055e4a2c5b1ae in std::mutex::lock (this0x55e4a3d8e780 g_resourceB_mutex) at /usr/include/c/9/bits/std_mutex.h:103 #3 0x000055e4a2c5b225 in std::lock_guardstd::mutex::lock_guard (this0x7f8b28f7ac70 __m...) at /usr/include/c/9/bits/std_mutex.h:197 #4 0x000055e4a2c59f5a in ResourceManager::accessResourceA() () at src/manager.cpp:120从栈帧可以看到线程1在accessResourceB中试图锁g_resourceA_mutex地址0x55e4a3d8e740时阻塞而线程2在accessResourceA中试图锁g_resourceB_mutex地址0x55e4a3d8e780时阻塞。这强烈暗示了死锁。3. GDB深入探查gdb -p PID (gdb) info threads # 确认线程1和2的状态是waiting on condition或类似。 (gdb) thread 1 (gdb) p *(pthread_mutex_t*)0x55e4a3d8e740 # 假设输出显示 __owner 12346 (线程2的LWP ID) (gdb) thread 2 (gdb) p *(pthread_mutex_t*)0x55e4a3d8e780 # 假设输出显示 __owner 12345 (线程1的LWP ID)结论线程1持有锁B等待锁A线程2持有锁A等待锁B。经典的AB-BA死锁确认。4. 代码审查与修复 查看src/manager.cpp第120行和第150行附近的代码// 线程2执行的路径 void ResourceManager::accessResourceA() { std::lock_guardstd::mutex lockB(g_resourceB_mutex); // 先锁B // ... 一些操作 ... std::lock_guardstd::mutex lockA(g_resourceA_mutex); // 再锁A // ... 操作资源A ... } // 线程1执行的路径 void ResourceManager::accessResourceB() { std::lock_guardstd::mutex lockA(g_resourceA_mutex); // 先锁A // ... 一些操作 ... std::lock_guardstd::mutex lockB(g_resourceB_mutex); // 再锁B // ... 操作资源B ... }问题一目了然两个函数以相反的顺序获取锁。修复方案制定全局锁顺序规定必须先锁A再锁B。修改accessResourceA函数调整锁获取顺序与accessResourceB一致。或者更优雅地使用std::scoped_lock一次性锁定两个mutex让标准库来处理死锁避免。void ResourceManager::accessResourceA() { std::scoped_lock lock(g_resourceA_mutex, g_resourceB_mutex); // 顺序无关紧要了 // ... 操作资源A可能也涉及资源B ... }5. 验证与总结 修复后进行长时间的压力测试死锁不再复现。同时在代码评审中加入了“多锁获取必须使用std::scoped_lock或严格规定顺序”的规则。排查死锁的过程就像一场侦探游戏。你需要从程序“死亡”的现场卡死状态出发利用线程转储和GDB这些“勘察工具”收集线索调用栈、锁状态最终推理出“凶手”的身份有问题的代码段和“作案手法”锁的顺序依赖。掌握这套方法并辅以良好的编程习惯和预防性设计你就能让多线程程序变得更加健壮和可靠。

相关新闻

C++大数相加算法精解:从LeetCode面试题到高精度计算实践

C++大数相加算法精解:从LeetCode面试题到高精度计算实践

1. 项目概述:从一道经典面试题说起最近在带新人刷LeetCode,发现“大数字相加”(LeetCode 第2题“两数相加”的变种或第415题“字符串相加”)这道题,几乎成了检验C选手基本功的“试金石”。表面看,它不就是小…

2026/10/10 15:08:04 阅读更多 →
BQ28Z610-R1 AFE硬件保护配置详解:从原理到实战避坑

BQ28Z610-R1 AFE硬件保护配置详解:从原理到实战避坑

1. 项目概述:BQ28Z610-R1 AFE硬件保护机制的核心价值在锂离子电池包的设计与应用中,安全永远是第一位的。电池管理系统(BMS)作为电池包的“大脑”和“守护神”,其核心职责之一就是实时监控电池状态,并在异常…

2026/10/9 14:00:06 阅读更多 →
C++实现企业级内容安全过滤系统:架构设计与性能优化

C++实现企业级内容安全过滤系统:架构设计与性能优化

1. 项目概述:为什么企业需要自己的内容安全“守门员”在数字化办公和业务线上化成为常态的今天,企业每天都要处理海量的文本数据。这些数据可能来自内部员工的即时通讯、邮件往来、文档协作,也可能来自外部的客户咨询、社交媒体评论、用户生成…

2026/10/8 16:21:13 阅读更多 →

最新新闻

MyBatis二级缓存源码深度解析:生命周期、装饰器链与五大致命坑

MyBatis二级缓存源码深度解析:生命周期、装饰器链与五大致命坑

1. 为什么你改了SQL却没刷新缓存?——从一个线上故障说起上周五下午三点,某电商后台订单查询接口响应时间突然从80ms飙升到1200ms,监控告警连响三轮。运维同学第一时间拉出慢SQL日志,发现执行计划里多了一堆SELECT * FROM order_i…

2026/10/13 11:47:23 阅读更多 →
AI Agent实战:家居高潜流失用户挽回,下单率提升104%

AI Agent实战:家居高潜流失用户挽回,下单率提升104%

做家居行业的用户增长,有个特别拧巴的场景:你手里明明握着一批高潜流失顾客,销售跟过了、优惠给过了、直播也邀请过,可他们就是不动。后来我们把AI Agent接进了这条挽回链路,才把问题真正跑通。这个项目里,…

2026/10/13 11:47:23 阅读更多 →
C++ set/multiset 底层红黑树:内存、迭代器与性能实战剖析

C++ set/multiset 底层红黑树:内存、迭代器与性能实战剖析

几个月前我在优化模拟项目X的排行榜模块时,遇到一个诡异现象:内存占用随着在线人数持续增长,插入同样数量数据的耗时也不断拉高。一开始我怀疑是业务代码里对象拷贝太狠,后来把热点路径上的std::vector std::sort换成了std::mult…

2026/10/13 11:47:23 阅读更多 →
Farming Code 架构解析:Language Server 与 Browser Use 如何支撑 Agent 持续耕作

Farming Code 架构解析:Language Server 与 Browser Use 如何支撑 Agent 持续耕作

1. 从 "Farming Code" 这个名字说起:它到底在解决什么问题第一次看到 "Farming Code" 这个标题,很多人会以为是某种游戏外挂或者自动化脚本合集。但结合 agent、Language Server、Browser Use、Computer Use 这几个关键词来看&#…

2026/10/13 11:47:23 阅读更多 →
CrashData:R语言清洗航空事故数据的完整实践指南

CrashData:R语言清洗航空事故数据的完整实践指南

简介:CrashData 是一份面向数据分析与航空安全研究者的 R 语言数据采集脚本资源,用于从 planecrashinfo.com 逐年抓取飞机失事的历史记录,解决公开事故数据分散、难以批量获取的问题。作者以略显取巧但可用的方式将网页内容整理为规整的 data…

2026/10/13 11:47:23 阅读更多 →
高德地图校园导航Android项目实战:从Key配置到路线规划全解析

高德地图校园导航Android项目实战:从Key配置到路线规划全解析

简介:这份基于高德地图的校园导航资源,是面向在校学生与初学者的完整项目包,适用于课程设计、毕业设计或项目初期演示。包内提供可直接运行的Android工程与APK安装包,涵盖Java源码、XML布局与配置、Gradle构建脚本及多个SO库&…

2026/10/13 11:46:22 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/12 18:11:17 阅读更多 →