news 2026/10/28 18:26:19

深入理解Windows API底层契约与Win32编程本质

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解Windows API底层契约与Win32编程本质

简介:本资源是《精通Windows API-函数、接口、编程实例(源码)》一书的配套完整工程源码包,面向C++中级开发者及Windows系统编程学习者,聚焦Win32平台底层开发能力提升,解决API调用不熟、COM接口实践缺失、消息机制理解抽象等核心痛点。压缩包共1789个文件,涵盖174个可执行exe(含GUI/控制台/系统托盘等典型场景)、186个obj与174个idb/ilk/pdb等编译中间产物(便于调试分析)、88个vcproj与17个sln工程文件(支持VS2008~2015多版本复现),以及大量C/CPP源码、资源脚本(rc/ico/bmp)、注册表(reg)与驱动相关inf文件,整体24.29MB,结构完整、即开即用。已有376人下载学习。读者可直接运行百余个典型实例——如SystemTraySDK(系统托盘)、Commctrl(通用控件)、PrtScrn(屏幕截图)、Enum(枚举设备)等,深入理解窗口创建、消息循环、DLL动态加载、COM组件交互及多线程协同等关键机制,快速构建扎实的Windows原生开发能力。

1. Windows API 不是“封装好的轮子”,而是操作系统内核与用户态程序之间唯一被允许的“握手协议”

你写过CreateFile打开一个文件,却从没想过:为什么传入的是LPCWSTR而不是std::filesystem::path?为什么ReadFile返回BOOL却要靠GetLastError()才知道真实错误?为什么同一个WM_PAINT消息,在不同 DPI 缩放下PAINTSTRUCT.rcPaint的坐标系行为像玄学?——这些不是设计缺陷,而是 Windows API 作为 Win32 子系统最底层契约的必然体现。它不提供抽象,只提供确定性边界:内核通过ntdll.dll暴露的系统调用号(如NtCreateFile)被kernel32.dll封装为CreateFileW,再经由user32.dll/gdi32.dll分流到窗口管理与图形绘制;而所有这些 DLL 的导出函数,本质都是对内核对象句柄(HANDLE)和结构体偏移量(如WNDCLASSEXW.cbWndExtra)的字节级操作约定。这份《精通Windows API-函数、接口、编程实例(源码)》的价值,正在于它把这套“契约”拆解成可调试、可单步、可修改的 C++ 实例——不是教你调用MessageBoxW,而是让你亲眼看见MessageBoxW内部如何调用CreateWindowExW创建临时对话框窗口,再如何向该窗口发送WM_INITDIALOG并等待DialogProc返回。搞懂它,你才能在驱动开发、逆向分析、甚至现代 UI 框架(如 WinUI 3 的CoreWindow层)中,一眼识别出哪一层在透传、哪一层在拦截、哪一层在伪造句柄。这不是怀旧,是理解 Windows 运行时的底层语法。

2. Win32 编程的本质:在用户态模拟内核调度语义,用结构体布局替代面向对象

Win32 API 表面是 C 风格函数集,实则是微软用 C 语言实现的一套“运行时 ABI 协议”。它不依赖 C++ RTTI 或虚函数表,而靠结构体字段顺序、内存对齐、以及 HANDLE 的整数语义完成所有状态传递。比如CreateWindowExW的第 5 个参数HMENU hMenu,在创建子窗口时实际被解释为子控件 ID(INT_PTR),而同一字段在创建顶层窗口时才真正作为菜单句柄使用——这种语义重载,完全由调用方传入的dwStyle(如WS_CHILD)决定,内核根本不校验hMenu是否真指向一个有效菜单对象。这种设计动机很直接:避免每次调用都做类型检查,把语义解析成本压给开发者,换来内核路径的极致轻量。

2.1 WNDCLASSEXW:窗口类注册背后的“元数据注册表”

窗口类(WNDCLASS/WNDCLASSEX)不是 C++ 类,而是一块被RegisterClassExW复制进内核窗口管理器(win32k.sys)的只读内存块。关键字段如下:

typedef struct tagWNDCLASSEXW { UINT cbSize; // 必须设为 sizeof(WNDCLASSEXW),否则内核拒绝注册 UINT style; // CS_HREDRAW | CS_VREDRAW 等位掩码,控制重绘时机 WNDPROC lpfnWndProc; // 函数指针!必须是进程内可执行地址,且需标记为 CALLBACK int cbClsExtra; // 额外字节数,供内核在窗口类结构后预留空间(如用于存储类私有数据) int cbWndExtra; // 更关键!每个窗口实例额外分配的字节数,存于 HWND 对应内核对象头部 HINSTANCE hInstance; // 模块句柄,用于加载图标/光标资源 HICON hIcon; // 图标句柄,但注意:此处仅用于 Alt+Tab 切换显示,非任务栏图标 HCURSOR hCursor; // 光标句柄,但仅当鼠标进入窗口客户区时生效 HBRUSH hbrBackground; // 背景画刷,若为 NULL 则窗口不擦除背景(导致闪烁!) LPCWSTR lpszMenuName; // 菜单资源名,仅对顶层窗口有效;子窗口此字段被忽略 LPCWSTR lpszClassName; // 类名字符串,内核据此查找已注册类 HICON hIconSm; // 小图标,用于任务栏缩略图(Windows 7+) } WNDCLASSEXW;

提示:cbWndExtra是 Win32 窗口模型的“私有数据槽”。SetWindowLongPtrW(hwnd, GWLP_USERDATA, (LONG_PTR)pMyData)实际就是将pMyData写入该预留区域的起始位置。内核不关心内容,只保证这块内存随窗口生命周期存在。

2.2 消息循环:不是事件队列,而是线程局部的“消息分发状态机”

GetMessage/PeekMessage从线程消息队列(THREADINFO结构中的pq字段)取消息,但关键在于:消息是否被投递,取决于目标窗口所属线程是否正在调用GetMessage或WaitMessage。如果线程在Sleep(1000),消息会堆积在队列中,直到线程唤醒并调用GetMessage。更隐蔽的是SendMessage:它不入队列,而是直接调用目标窗口的lpfnWndProc(即你在WNDCLASSEXW中注册的函数),且调用栈完全在发送线程上下文中——这意味着SendMessage(WM_SETTEXT, ...)期间,如果WndProc内部又调用SendMessage,就会形成同步嵌套调用,极易死锁。这也是为什么PostMessage(异步入队)和SendMessageTimeout(带超时的同步)成为必须掌握的变体。

2.3 句柄(HANDLE):32 位整数背后的内核对象索引表

HANDLE在用户态是void*,但其低 2 位恒为 0(对齐要求),高 30 位实为内核中HANDLE_TABLE_ENTRY数组的索引。CreateFileW返回的HANDLE,本质是ObReferenceObjectByHandle成功后,内核在当前进程的句柄表中分配的一个槽位编号。CloseHandle并非“销毁对象”,而是调用ObfDereferenceObject减少引用计数,仅当计数归零时内核才真正释放FILE_OBJECT。因此,DuplicateHandle的作用就清晰了:它在目标进程中创建一个新的句柄表项,指向同一个内核对象——这正是跨进程共享文件、事件、互斥体的底层机制。源码包中handle_inheritance_demo.cpp演示了如何用bInheritHandle=TRUE让子进程继承父进程的CreateEventW句柄,其核心就是设置STARTUPINFOEXW.lpAttributeList中的PROC_THREAD_ATTRIBUTE_HANDLE_LIST。

3. GDI 绘图:像素操作的“状态机陷阱”与设备上下文(DC)的隐式绑定

GDI 不是现代 GPU 渲染管线,而是一套基于 CPU 的、状态驱动的 2D 绘图引擎。它的核心是HDC(设备上下文句柄),但这个句柄背后绑定着至少 4 层状态:设备描述(DEVMODEW)、当前画笔/画刷/字体(HPEN/HBRUSH/HFONT)、坐标映射模式(MM_TEXT,MM_ANISOTROPIC)、以及裁剪区域(HRGN)。任何一次SelectObject(hdc, hPen)都会覆盖前一个画笔,且DeleteObject(hPen)后若该画笔仍被选中,后续绘图将触发 GDI 错误(GetLastError()返回ERROR_INVALID_HANDLE)。更危险的是BeginPaint/EndPaint:它们不仅返回HDC,还会自动设置裁剪区域为PAINTSTRUCT.rcPaint,并清除CS_HREDRAW | CS_VREDRAW样式以防止重入。源码包中gdi_double_buffer.cpp的关键修复点,正是避免在WM_PAINT处理中直接调用BitBlt到屏幕 DC,而是先CreateCompatibleDC创建内存 DC,SelectObject选入位图,绘图完成后再BitBlt一次拷贝——这绕开了 GDI 的重绘状态污染。

3.1 文本输出:TextOutW的字符宽度预估与GetTextExtentPoint32W的必要性

TextOutW(hdc, x, y, L"Hello", 5)看似简单,但x坐标是文本左上角,而y是基线(baseline)位置。GetTextMetricsW可获取tmAscent(上伸部高度)、tmDescent(下伸部高度),但单个字符宽度受字体lfWidth和lfPitchAndFamily影响极大。等宽字体(FIXED_PITCH)下GetCharWidthW可精确查表,但比例字体(VARIABLE_PITCH)必须用GetTextExtentPoint32W测量实际字符串宽度。源码包text_layout_demo.cpp中,DrawCenteredText函数先调用GetTextExtentPoint32W获取SIZE size,再计算(rc.right - rc.left - size.cx) / 2作为居中偏移——若跳过测量直接估算,多字节字符(如中文)或连字(ligature)会导致严重错位。

3.2 位图操作:BITMAPINFOHEADER的biHeight符号陷阱

CreateDIBSection创建 DIB 位图时,BITMAPINFOHEADER.biHeight若为正数,表示位图数据按自顶向下存储(首行是图像顶部);若为负数,则为自底向上(首行是图像底部)。绝大多数 Windows API(如StretchBlt)默认期望负值。若错误设为正值,位图会垂直翻转。源码包dib_create_demo.cpp中明确写出:

BITMAPINFOHEADER bi = {0}; bi.biSize = sizeof(BITMAPINFOHEADER); bi.biWidth = 800; bi.biHeight = -600; // 关键!负值表示 top-down DIB bi.biPlanes = 1; bi.biBitCount = 32; bi.biCompression = BI_RGB; // ... 后续 CreateDIBSection 成功后,pBits 指向的内存布局即为标准 BMP 数据

注意:biHeight的符号决定了pBits中第 0 行对应图像的哪一行。GDI 内部处理时,负值会自动转换为正高度并调整扫描线偏移,这是历史兼容性设计。

3.3 设备上下文(DC)的生命周期:GetDC/ReleaseDC与BeginPaint/EndPaint的严格配对

GetDC(NULL)获取屏幕 DC 是全局资源,必须ReleaseDC(NULL, hdc)配对;而BeginPaint(hwnd, &ps)获取的 DC 专用于WM_PAINT,必须EndPaint(hwnd, &ps)配对。混用会导致 GDI 句柄泄漏(GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)可监控)。源码包dc_leak_test.cpp包含一个故意泄漏的测试:在WM_PAINT中调用GetDC(hwnd)而非BeginPaint,并在EndPaint后未ReleaseDC,运行 100 次后GR_GDIOBJECTS值稳定增长——这证明每个GetDC都消耗一个内核 GDI 句柄,而BeginPaint使用的是窗口私有 DC 缓存,不增加计数。

4. 进程与线程:CreateProcessW的启动参数解析与INFINITE的真实含义

CreateProcessW是 Win32 进程创建的终极入口,但它暴露的远不止“启动一个程序”。其lpCommandLine参数被kernel32.dll内部的ParseCmdLine函数解析:空格分隔参数,双引号包裹含空格的参数(如"C:\Program Files\App\app.exe"),反斜杠转义双引号(\")。lpApplicationName若为NULL,则lpCommandLine的第一个标记被当作程序名——这意味着你可以传入"notepad.exe file.txt",CreateProcessW会自动提取notepad.exe为应用名,file.txt为参数。源码包process_spawn_demo.cpp演示了如何用CREATE_SUSPENDED标志创建挂起进程,再用WriteProcessMemory注入代码到其内存空间,最后ResumeThread启动——这是调试器和注入工具的基础。

4.1 线程同步原语:WaitForSingleObject的INFINITE不是“永远等待”,而是“等待直到信号”

INFINITE定义为0xFFFFFFFF,在WaitForSingleObject内部被解释为“无限超时”,但其实现并非轮询。内核为每个可等待对象(EVENT,MUTEX,SEMAPHORE)维护一个等待者链表。当线程调用WaitForSingleObject(hEvent, INFINITE),内核将其线程控制块(ETHREAD)插入该事件的等待链表,并将线程状态置为Waiting;当另一线程调用SetEvent(hEvent),内核遍历等待链表,将所有等待该事件的线程状态改为Ready,并加入调度队列。INFINITE的本质是“不设置超时定时器”,避免内核维护额外的定时器对象。源码包event_wait_demo.cpp中,WaitForSingleObject(hEvent, 100)与WaitForSingleObject(hEvent, INFINITE)的性能差异微乎其微,因为等待本身不消耗 CPU,真正的开销在SetEvent唤醒时的链表遍历。

4.2 临界区(CRITICAL_SECTION):用户态自旋 + 内核事件的混合锁

InitializeCriticalSection初始化的临界区,前几次争用会尝试自旋(SpinCount默认为 0,可通过InitializeCriticalSectionAndSpinCount设置),自旋失败后才调用NtCreateEvent创建一个内核事件对象,并NtWaitForSingleObject等待。EnterCriticalSection的汇编实现包含cmpxchg原子操作检测LockCount字段,成功则获得锁,失败则进入等待路径。源码包critical_section_demo.cpp对比了CRITICAL_SECTION与CreateMutexW的性能:在无争用场景下,临界区耗时约 10ns,而互斥体因涉及内核调用,耗时约 1000ns——这解释了为何 Windows SDK 推荐“短临界区用 CRITICAL_SECTION,长临界区或跨进程用 MUTEX”。

4.3 进程间通信(IPC):命名对象的“全局命名空间”与SECURITY_ATTRIBUTES

CreateEventW(L"Global\\MyEvent", ...)中的Global\\前缀,将事件对象创建在GLOBAL命名空间,使其他会话(Session)的进程也能访问(需权限)。而Local\\前缀则限制在当前会话。SECURITY_ATTRIBUTES结构中的lpSecurityDescriptor字段,若为NULL,则对象使用默认安全描述符(通常允许当前会话所有进程访问);若显式设置,可用ConvertStringSecurityDescriptorToSecurityDescriptorW从 SDDL 字符串(如"D:(A;;GA;;;WD)")创建描述符,精确控制谁可以OpenEventW。源码包ipc_security_demo.cpp演示了如何创建一个仅限Administrators组访问的事件:SetSecurityDescriptorDacl设置 DACL,AddAccessAllowedAce添加管理员 ACE,最终CreateEventW传入该描述符——普通用户进程调用OpenEventW将返回NULL,GetLastError()为ERROR_ACCESS_DENIED。

5. 常见问题排查:那些让 Win32 开发者深夜抓狂的“幽灵 Bug”

Win32 编程的调试难点在于:错误现象与根本原因常隔着数层抽象。以下是在源码包实战和某高校课程实验中高频出现的 5 类问题,每条均按“现象 → 原因 → 解决”给出可复现的验证方法。

5.1 现象:CreateWindowExW返回NULL,GetLastError()却是0(ERROR_SUCCESS)

  • 原因:CreateWindowExW内部调用RegisterClassExW失败时,会静默失败并返回NULL,但不重置GetLastError()。常见于WNDCLASSEXW.hInstance未正确赋值(如传入NULL而非GetModuleHandleW(NULL)),或lpfnWndProc函数未声明为CALLBACK(导致调用约定不匹配,内核无法安全调用)。
  • 验证:在CreateWindowExW前加断点,单步进入,观察RegisterClassExW返回值;或用OutputDebugStringW打印hInstance值。
  • 解决:确保WNDCLASSEXW.hInstance = GetModuleHandleW(NULL);WndProc声明为LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM)。

5.2 现象:SendMessageW(hwnd, WM_GETTEXT, ...)返回文本长度,但缓冲区内容全为0

  • 原因:WM_GETTEXT的wParam是缓冲区大小(字符数),lParam是缓冲区地址。若wParam设为sizeof(buffer)(字节数),而非sizeof(buffer)/sizeof(wchar_t)(字符数),则内核认为缓冲区只能存 1 个字符,只写入L'\0'。
  • 验证:用sizeof(buffer)除以sizeof(wchar_t),确认结果是否等于预期字符数;或用WideCharToMultiByte将缓冲区转为 ANSI 查看实际内容。
  • 解决:wParam = (WPARAM)(sizeof(buffer) / sizeof(wchar_t));或直接用GetWindowTextW(hwnd, buffer, _countof(buffer))替代。

5.3 现象:EnumWindows回调函数中调用GetWindowTextW总是失败,GetLastError()为ERROR_INVALID_WINDOW_HANDLE

  • 原因:EnumWindows枚举的窗口句柄可能在回调执行期间已被销毁(如用户关闭了该窗口)。IsWindow(hwnd)应在GetWindowTextW前调用,但IsWindow本身也有竞态:句柄可能在IsWindow返回TRUE后、GetWindowTextW调用前失效。
  • 验证:在回调中添加if (!IsWindow(hwnd)) return TRUE;,再测试;或用try/catch(需/EHsc)捕获STATUS_ACCESS_VIOLATION异常。
  • 解决:IsWindow+GetWindowTextLengthW双重检查;或改用EnumDesktopWindows配合GetWindowThreadProcessId过滤目标进程窗口。

5.4 现象:CreateFileW打开串口(\\\\.\\COM3)成功,但ReadFile总是超时,GetLastError()为ERROR_IO_PENDING

  • 原因:串口句柄默认为异步 I/O 模式(FILE_FLAG_OVERLAPPED),ReadFile立即返回FALSE,需GetOverlappedResult等待完成。若未传入OVERLAPPED结构,ReadFile会失败并返回ERROR_INVALID_PARAMETER,但某些驱动可能静默降级为同步模式。
  • 验证:检查CreateFileW的dwFlagsAndAttributes是否含FILE_FLAG_OVERLAPPED;用GetFileType确认句柄类型为FILE_TYPE_CHAR。
  • 解决:异步模式下,ReadFile前必须初始化OVERLAPPED结构(hEvent设为手动重置事件);或创建时去掉FILE_FLAG_OVERLAPPED,强制同步 I/O。

5.5 现象:SetTimer创建的定时器,WM_TIMER消息在窗口最小化时停止发送

  • 原因:WM_TIMER是低优先级消息,当线程消息队列中存在更高优先级消息(如WM_PAINT,WM_MOUSEMOVE)时,WM_TIMER会被延迟。窗口最小化时,系统可能向该线程发送大量WM_SIZE、WM_MOVE,导致WM_TIMER积压甚至被丢弃。
  • 验证:用PeekMessage循环检查队列,观察WM_TIMER是否被WM_PAINT等消息阻塞;或改用timeSetEvent(多媒体定时器),其精度更高且独立于消息队列。
  • 解决:SetTimer仅用于 UI 刷新等低精度场景;高精度定时用CreateWaitableTimerW+SetWaitableTimer;或在WM_TIMER处理中减少耗时操作,避免阻塞消息循环。

6. 设计取舍:为什么 Win32 API 拒绝“现代化”,以及我们该如何与之共处

Win32 API 的“陈旧感”常被误解为技术落后,实则是微软在 1985 年(Windows 1.0)到 1995 年(Windows 95)间,为在 286/386 硬件上实现“多任务 GUI”所作的硬性妥协。HANDLE作为 32 位整数,源于当时保护模式下段选择子(Segment Selector)的 16 位限制,扩展为 32 位既保持兼容又满足寻址需求;WNDPROC的CALLBACK调用约定(__stdcall),是为了让内核能用固定栈帧清理方式调用任意模块的函数,避免__cdecl下调用方和被调用方对栈平衡责任不清;GetLastError的线程局部存储(TLS)实现,则是因为早期 Windows NT 内核尚未提供完善的异常处理框架,需用轻量级错误码快速定位问题。这些设计不是为了“优雅”,而是为了在 4MB 内存、20MHz CPU 上跑起一个能同时打开记事本和画图的系统。

今天,当我们用std::thread创建线程,用std::shared_ptr管理资源,用std::format格式化字符串时,Win32 API 的裸指针、全局错误码、手动内存管理确实显得笨重。但它的价值恰恰在于确定性:CreateFileW的行为在 Windows 3.1 到 Windows 11 中完全一致,SendMessageW的同步语义从未改变,HDC的状态机规则也未曾妥协。现代框架(如 .NET 的System.Drawing、Qt 的QPainter)都在 Win32 GDI 之上封装,它们的“高级”特性(如抗锯齿、透明度)最终都要翻译成SetWorldTransform、AlphaBlend等 Win32 调用。因此,学习 Win32 不是为了写新应用,而是为了读懂框架源码、调试崩溃转储、理解性能瓶颈。

我曾在一个图像处理 Demo 中,发现StretchBlt耗时突增 10 倍。用 Process Monitor 抓取,发现StretchBlt调用了NtGdiStretchBlt,而该系统调用内部触发了win32k!GreStretchBlt的软件渲染路径。进一步用 WinDbg 分析堆栈,发现原因是目标 DC 的hPalette为NULL,导致 GDI 回退到慢速的调色板匹配算法。解决方案不是换库,而是SelectPalette(hdc, hPal, FALSE)显式设置调色板。从那以后我每次调用 GDI 函数,都强制走一遍GetDeviceCaps(hdc, TECHNOLOGY)和GetObjectType(hdc),确认 DC 类型与预期一致;对任何返回FALSE的 API,必查GetLastError()并记录上下文。Win32 不是黑匣子,它是用 C 语言写的、可单步的、有文档的、有源码(ReactOS/Wine)可对照的契约。希望帮到你。

本文还有配套的精品资源,点击获取

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

PlatEMO v2.0实战:多目标进化算法实验与自定义算法接入

简介:PlatEMO v2.0(2019-3)是面向多目标优化研究的进化算法平台,适合科研人员、工程师以及高校学生用于算法开发、性能对比与教学演示。压缩包共593个文件,其中579个m源文件构成完整Matlab工具箱,另含md与t…

作者头像 李华
网站建设 2026/10/28 18:25:16

京东商品比价系统开发实战:爬虫、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/28 18:24:25

840Dsl STEP7 ToolBox 4.8.07安装配置与避坑指南

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

作者头像 李华
网站建设 2026/10/28 18:24:24

FPGA信号发生器完整设计:DDS相位累加器到PCB实现

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

作者头像 李华
网站建设 2026/10/28 18:22:47

嵌入式Linux驱动开发实战:从字符设备到子系统接入

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

作者头像 李华
网站建设 2026/10/28 18:22:20

ROS2只是入场券:2026年机器人行业最稀缺的四类嵌入式工程师

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

作者头像 李华