news 2026/10/24 6:17:14

一个UEFI引导程序的实现:从BIOS到64位内核的实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一个UEFI引导程序的实现:从BIOS到64位内核的实战解析

简介:《一个UEFI引导程序的实现》是一本面向操作系统开发者和爱好者的技术PDF,系统讲解如何从零构建UEFI引导程序。书中从UEFI与BIOS的结构、启动流程对比入手,说明UEFI在性能、扩展性与安全上的优势,并逐步展开TianoCore EDK2环境搭建、GPT分区布局、ESP文件系统、UEFI镜像与系统表、调用约定等重点内容。随后以实例指导读者实现显示模式配置、物理内存获取、存储介质文件读取、64位平台处理和系统内核引导启动,形成完整的UEFI引导程序编写路径。压缩包内为单个PDF文件,大小2.36MB,适合具备一定操作系统基础、希望深入理解固件启动机制或为学习Linux内核源码做好铺垫的读者。目前已有2379人学习下载,是配套《一个64位操作系统的设计与实现》进行实践升级的实用参考资料。

1. 一个 UEFI 引导程序:从 BIOS 遗产到自研内核的第一道门

UEFI 引导程序是操作系统接管硬件前的最后一段固件逻辑。很多从零写内核的开发者,卡住的地方不是内核代码本身,而是拿不到内存图、读不出分区文件、跳进 64 位入口就白屏。

《一个UEFI引导程序的实现》把这条链路从头串到尾。它先讲清 BIOS 与 UEFI 在结构和启动流程上的差异,再带你在 EDK2 里编译并运行 UEFI Shell,随后逐步配置显示模式、获取物理内存、从存储介质读取内核文件,最终把控制权交给 64 位内核。

做操作系统实验,或想给固件、引导器加 UEFI 支持的人,都可以拿它当主线来跟。

2. UEFI 与 BIOS:结构、启动流程和取代原因

2.1 结构组成:中断服务与数据表的两条路线

BIOS 是一组固化在主板 ROM 里的程序代码。上电后它运行在实模式,数据位宽 16 位,物理寻址范围只有 0~1MB,其中 0x0C0000~0x0FFFFF 保留给 BIOS 自身使用。它的能力通过中断服务暴露出来:应用程序要读磁盘、操作串口,直接发 INT 指令就行,硬件差异都由 BIOS 在中断里抹平。BIOS 的主体由四块组成:加电自检 POST、系统初始化与中断向量创建、即插即用设备适配、CMOS 设置存取。

UEFI 的接口形态完全不同。它本质上是平台固件提供给系统的一组数据表,包含平台相关信息、引导服务和运行时服务。设备访问不再靠中断号,而是用「句柄 Handle + 协议 Protocol」抽象:一个设备对应一个 Handle,Handle 上挂着若干 Protocol,应用程序拿到协议接口再调函数。

对比项BIOSUEFI
运行模式16 位实模式32 位保护模式或 IA-32e 长模式
设备服务方式INT 中断调用引导服务 / 运行时服务
磁盘分区MBR,2TB 上限GPT,支持大容量盘
驱动方式静态链接、固定 ROM 地址模块化驱动,可动态加载
可执行格式实模式代码PE/COFF 格式镜像
安全机制无Secure Boot 证书校验

这套设计决定了后续写引导程序的姿势。你不需要像 BIOS 时代那样拼实模式汇编,也不需要维护中断向量表,而是用 C 语言直接调协议函数。

2.2 启动流程:BIOS 的 8 步操作与 UEFI 的 7 个阶段

BIOS 的启动流程几乎固定是 8 步。CPU 上电后跳到物理地址 0xFFFF0,那里通常是一条跳转指令,进入真正的 BIOS 入口;接下来做 POST,检测关键设备和 CMOS 配置;然后初始化显示设备并打印显卡信息;再检测 CPU、内存,接着检测硬盘、光驱、串并口等标准设备;随后检测即插即用设备,并为它们分配中断号、I/O 端口和 DMA 通道;硬件配置有变化就写回 CMOS;最后按启动顺序读取设备引导程序,把控制权交给 MBR。

UEFI 的启动过程被拆成 7 个阶段,职责划分非常清晰:

  1. SEC(验证):此时内存尚未初始化,拿处理器缓存或一部分物理内存做临时存储,同时把 CPU 切换到保护模式。
  2. PEI(EFI 预初始化):初始化 CPU、内存、芯片组,确定引导路径,代码要尽量精简。
  3. DXE(驱动运行环境):加载大部分驱动和固件模块,UEFI 的大部分功能在这里就绪。
  4. BDS(启动设备选择):初始化控制台,按环境变量里的启动项和启动设备找操作系统加载器。
  5. TSL(临时系统运行):UEFI Shell 和操作系统加载器都运行在这个阶段,系统还没交出控制权。
  6. RT(运行时):操作系统调用 ExitBootServices 后进入,引导服务销毁,只剩运行时服务和系统表。
  7. AL(后世):调用 ResetSystem 或 ACPI 睡眠状态时进入,服务器和工作站里常见。

这张阶段图对引导程序的意义在于:你自己写的 UEFI 应用基本都跑在 TSL 阶段。此时引导服务还在,可以随便用内存分配、协议查询这些接口;一旦调用了 ExitBootServices,自己就是唯一的主角,不能再依赖固件帮忙。

2.3 为什么 UEFI 取代 BIOS:性能、扩展性、安全性与 2TB 上限

BIOS 被取代并不是因为它老,而是它已经扛不住现代硬件的几个硬性要求。BIOS 代码用汇编写,跟具体芯片耦合太深,加一个设备就要改 ROM 代码、重新编译,开发效率极低;所有外设操作走中断,开销大且没有异步机制,大量时间浪费在等待上。安全方面更是空白,引导阶段没有任何可信校验。还有个工程上的硬限制:BIOS 磁盘寻址用 32 位 LBA,最大逻辑块地址是 2 的 32 次方,乘上 512 字节扇区就是 2TB,超过这个容量的磁盘根本无法从 BIOS 引导。

UEFI 对应地给出了解决方案。绝大多数固件代码改成 C 语言维护,上层应用甚至可以用 C++;驱动按模块独立加载,随用随调,不依赖固定地址;外设操作改成「事件 + 异步」,只保留时钟中断;启动时只遍历必须的启动设备;Secure Boot 打开后,固件会校验驱动和应用镜像的数字签名,签名可信才给执行。UEFI 还引入了 ESP 系统分区和 GPT 布局,为引导程序提供了标准化的文件存放位置。

实际写引导程序时,这些优点会直接转化为开发体验。驱动按模块加载,意味着你可以根据需要 OpenProtocol 拿串口、拿 GOP、拿文件系统,而不必在代码里硬编码设备地址;表和协议都有版本号,接口升级有据可查,编译期和运行期都能校验版本是否匹配。在现在这个时间点还从 MBR 引导新写的 64 位内核,等于主动放弃大容量磁盘、放弃安全启动、放弃现代化驱动管理。

3. 搭建 EDK2 环境:编译 UEFI Shell 并让 U 盘启动它

3.1 依赖、分支与 target.txt 三个关键配置

TianoCore EDK2 是 UEFI 开发的常用环境,自带一套编译系统,支持 GCC、MinGW、MSVC 等编译器,既能编 UEFI 应用,也能编 UEFI 固件。以 CentOS 系 Linux 发行版为例,先把依赖装齐,再从 git 仓库拉指定分支。

# CentOS 系列 yum install gcc make git uuid-devel iasl nasm libuuid-devel # Ubuntu/Debian 系列 apt-get install build-essential git uuid-dev iasl nasm uuid-dev git clone https://github.com/tianocore/edk2.git cd edk2/ git checkout UDK2018 make -C BaseTools/ source edksetup.sh

第一行装的是编译器和工具链依赖,iasl 是 ACPI 表编译器,nasm 是汇编器,后面编译 OVMF 固件时会用到。clone 之后显式切到 UDK2018 分支,因为这个分支的模块结构比较稳定,配套实验大多基于它。make 出来的是 EDK2 自己的编译工具集,source edksetup.sh 则是把编译环境变量导入当前 Shell。

真正决定编译哪个模块、用哪个工具链的,是 Conf/target.txt 里的三个变量。

配置项示例值作用
ACTIVE_PLATFORMOvmfPkg/OvmfPkgX64.dsc指定要编译的模块组 .dsc 文件
TOOL_CHAIN_TAGGCC48指定工具链,Linux 默认 GCC 4.8
TARGET_ARCHX64目标处理器架构,写 X64 才会生成 64 位镜像

TOOL_CHAIN_TAG 要和本机编译器版本对应,UDK2018 年代常用 GCC48;如果你在新发行版上装了更高版本 GCC,需要去 tools_def.txt 里确认对应工具链名称。改完 target.txt 后,第一次编译 OVMF 这类含子模块的工程,还要先拉子模块:

git submodule update --init --recursive build

编译产物会出现在 Build/OvmfX64/DEBUG_GCC48/FV/ 目录下,OVMF.fd 就是可以直接当固件用的文件,后面做虚拟机验证时它很关键。

3.2 编译 ShellPkg 并准备 GPT/ESP 引导 U 盘

UEFI Shell 本身就是一个 UEFI 应用,运行在 TSL 阶段。固件找不到系统加载器时通常会进 Shell,方便排查分区、看启动项。编译它只需要改 target.txt 里的 ACTIVE_PLATFORM:

ACTIVE_PLATFORM = ShellPkg/ShellPkg.dsc TOOL_CHAIN_TAG = GCC48 TARGET_ARCH = X64

然后执行 build,产物路径一般是 Build/Shell/DEBUG_GCC48/X64/Shell.efi。

拿到 Shell.efi 之后,怎么让固件从 U 盘执行它?这一步的坑比编译多。先清空 U 盘扇区里的数据,再用磁盘分区工具把分区表类型转换成 GUID,也就是 GPT。转换完成会提示原分区全部失效,所以操作前想清楚 U 盘里有没有重要数据。

接下来在 GPT 磁盘上创建两个分区:第一个勾选 ESP 分区,给 20MB,按 4096 扇区对齐;第二个建一个 10MB 的 FAT32 分区。UEFI 规范要求可引导块设备上必须有 ESP 分区,固件会从中按\EFI\BOOT\BOOTx64.EFI这个固定路径找启动镜像。

在 Windows 下资源管理器看不到 ESP 分区,这是系统故意屏蔽的。切到 Linux 下挂载就方便了:

ls /dev/sdb* mount /dev/sdb1 /mnt/ mkdir -p /mnt/EFI/BOOT cp /root/Desktop/edk2/Build/Shell/DEBUG_GCC48/X64/Shell.efi /mnt/EFI/BOOT/BOOTx64.EFI sync umount /mnt/

注意,fdisk 显示出来的分区表项可能是 GPT 保护分区,并不代表实际分区节点。想看真实分区,直接ls /dev/sdb*比看 fdisk 输出更可靠。确定 ESP 分区设备号后再挂载,把 Shell.efi 改名成 BOOTx64.EFI 放到EFI/BOOT/下,固件启动时找不到系统加载器,就会尝试这个路径。

3.3 第一个 UEFI 应用:Hello World 的工程结构

前面用 Shell 验证了环境链路,自己写 UEFI 应用时还需要一个最小工程模板。一个 Hello World 只需要两个文件:一个 C 源文件和一个 .inf 描述文件。

#include <Uefi.h> #include <Library/UefiApplicationEntryPoint.h> EFI_STATUS EFIAPI UefiMain(IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable) { SystemTable->ConOut->OutputString(SystemTable->ConOut, L"Hello World!\r\n"); return EFI_SUCCESS; }
[Defines] INF_VERSION = 0x00010006 BASE_NAME = HelloWorld FILE_GUID = 1E2F3A4B-5C6D-7E8F-9A0B-1C2D3E4F5A6B MODULE_TYPE = UEFI_APPLICATION ENTRY_POINT = UefiMain [Sources] HelloWorld.c [Packages] MdePkg/MdePkg.dec [LibraryClasses] UefiApplicationEntryPoint

UefiMain 是 UEFI 应用的入口,参数里的EFI_SYSTEM_TABLE *SystemTable是所有系统级能力的入口。这里先理解一件事:SystemTable->ConOut 是字符输出协议,OutputString 输出的是 UTF-16 字符,所以字符串字面量要加L前缀。.inf 里的 FILE_GUID 是镜像的唯一标识,固件靠它区分不同应用,每写一个新模块都要换一个 GUID。

编译后生成的是 PE/COFF 格式的 .efi 文件,这也意味着只要工具链支持 PE/COFF 输出,理论上 UEFI 应用可以用 C、C++、Rust 来写,只不过 EDK2 原生支持最好的仍然是 C。

4. UEFI 镜像、系统表与协议:引导程序的接口基础

4.1 镜像格式与调用约定:EFIAPI 不是摆设

UEFI 应用的镜像格式是 PE/COFF,和 Windows 可执行文件同源,但子系统标记为 EFI Application。固件加载这种镜像后,不是跳到一个固定地址,而是直接按 PE 头里的入口点调用。所以早期做 UEFI Shell 相关调试时,用 Windows 的开发经验去猜布局,基本能对上。

真正要重视的是调用约定。x64 平台上 UEFI 遵循微软 x64 调用约定:前 4 个参数依次放 RCX、RDX、R8、R9,剩余参数压栈,栈 16 字节对齐。EDK2 里用EFIAPI宏标注,凡是传给固件或从固件回调的函数,声明时都要带上。漏掉 EFIAPI 的经典症状是函数能编译、运行时传参错乱,拿到的指针全是垃圾值。

4.2 EFI 系统表:Boot Services 与 Runtime Services 的分工

每个 UEFI 应用的入口都会收到一份EFI_SYSTEM_TABLE。这张表里包含固件厂商信息、控制台句柄、Boot Services 表指针、Runtime Services 表指针,以及若干配置表。EDK2 里全局变量gST可以直接访问它,gBS和gRT分别指向引导服务和运行时服务。

Boot Services 只在引导阶段可用,功能包括内存分配、协议查询、事件与定时器、图像加载。最关键的接口是ExitBootServices——操作系统接管后,所有引导服务函数立即失效。Runtime Services 则是在系统运行期间继续提供的能力,比如读写 NVRAM 变量、获取当前时间、重启系统。

代码里常见的一对组合是:

EFI_STATUS Status; EFI_GRAPHICS_OUTPUT_PROTOCOL *GOP = NULL; Status = gBS->LocateProtocol(&gEfiGraphicsOutputProtocolGuid, NULL, (VOID**)&GOP); if (EFI_ERROR(Status) || GOP == NULL) { // 图形协议不可用,退回文本模式 return Status; }

LocateProtocol 是 Boot Services 里的查询函数,按 GUID 找全局唯一的协议实例。注意这里拿到的 GOP 指针只在 ExitBootServices 之前有效,这也是引导程序必须在交出控制权前,把所有要用的资源、句柄、缓冲区都准备好的原因。

4.3 基于 Handle/Protocol 的设备访问方式

UEFI 不提供「打开设备文件」的通用接口,而是用 Handle 和 Protocol 组合描述外设。一个物理设备可以有多个 Handle,每个 Handle 上挂着若干协议实例;你要操作什么功能,就按对应的协议 GUID 去查。

比如串口调试,找EFI_SERIAL_IO_PROTOCOL_GUID;读文件系统,找EFI_SIMPLE_FILE_SYSTEM_PROTOCOL_GUID;要显示画面,找EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID。

EFI_HANDLE *HandleBuffer = NULL; UINTN HandleCount = 0; Status = gBS->LocateHandleBuffer( ByProtocol, &gEfiSimpleFileSystemProtocolGuid, NULL, &HandleCount, &HandleBuffer );

ByProtocol 表示按协议类型过滤所有 Handle,返回的是符合条件的句柄数组。拿到文件系统 Handle 后,再 OpenProtocol 取出具体协议指针,用完记得 CloseProtocol 释放。这套机制的好处是驱动和应用的耦合被拆开:固件新增一块硬件,只要提供一个实现同款协议的新驱动,引导程序不需要感知设备型号。

5. 实现引导程序的常见问题:显示、内存、读文件、切 64 位

从 Hello World 到真正引导一个内核,中间隔着一堆细节问题。下面四条按实际开发顺序排,基本每一条都让初学者翻过车。

5.1 GOP 显示模式:QueryMode 与 SetMode 之间的坑

配置图形设备的第一步是拿到 GOP 协议,然后查询可用模式。

UINTN SizeOfInfo = 0; EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *Info = NULL; Status = GOP->QueryMode(GOP, 0, &SizeOfInfo, &Info); Status = GOP->SetMode(GOP, ModeNumber);

现象:SetMode 传入一个模式号直接返回不支持,屏幕没反应。原因:模式号不是随便填的,不同显卡固件支持的模式列表长度不一样,模式 0 的分辨率也千差万别。解决:先用 QueryMode 把所有模式号遍历一遍,记录宽、高、像素格式,再挑一个内核能接受的分辨率调用 SetMode。有些环境里 GOP 协议还没加载,LocateProtocol 本身就失败,这时要先保证 BDS 阶段把显示驱动加载完再查。

5.2 GetMemoryMap 与 ExitBootServices:内存表要抓准时机

物理内存信息是引导程序最重要的产出之一。流程是先 GetMemoryMap 拿到描述符数组,整理成内核能用的内存图,最后 ExitBootServices。

UINTN MapSize = 0, MapKey = 0, DescSize = 0; UINT32 DescVersion = 0; Status = gBS->GetMemoryMap(&MapSize, NULL, &MapKey, &DescSize, &DescVersion); /* 分配足够缓冲区后,再次调用 */

现象:第一次调用 GetMemoryMap 永远返回缓冲区太小,分配好缓冲区后第二次调用又失败。原因:GetMemoryMap 本身会触发固件分配内存,MapSize 在你调用期间会变化,必须在循环里反复重试直到成功。解决:把分配和重试写成一个循环,分配时给 MapSize 加一点余量,一般加一页就够,每次失败都根据新的 DescriptorSize 调整。

另一个高频翻车点是 ExitBootServices 的 MapKey。现象:退出后内核访问内存崩溃。原因:在 GetMemoryMap 和 ExitBootServices 之间,不能调用任何会改变内存分配的函数,包括 AllocatePool,甚至某些协议查询操作,否则 MapKey 过期,固件拒绝退出或退出后状态不一致。解决:把 GetMemoryMap 放到最后一步,拿到 MapKey 后立刻调 ExitBootServices,中间不给固件改变内存布局的机会。

5.3 用 SimpleFileSystem 读内核文件:路径回车的烦恼

从 ESP 分区读内核文件,标准做法是先 OpenVolume 拿到根目录,再按路径 Open 文件。

EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *SFS = NULL; EFI_FILE_PROTOCOL *Root = NULL, *KernelFile = NULL; UINTN FileSize = 0; gBS->LocateProtocol(&gEfiSimpleFileSystemProtocolGuid, NULL, (VOID**)&SFS); SFS->OpenVolume(SFS, &Root); Status = Root->Open(Root, &KernelFile, L"\\EFI\\BOOT\\kernel.elf", EFI_FILE_MODE_READ, 0);

现象:文件明明在 ESP 分区里,Open 却返回找不到文件。原因:UEFI 的路径分隔符是反斜杠,在 C 字符串里必须写成\\,并且要使用绝对路径从根目录写起。解决:路径写成L"\\EFI\\BOOT\\kernel.elf",不要带EFI:或BOOT:这种卷名。

读文件内容时还有第二个坑。现象:第一次 Read 返回缓冲区太小,或只读到了半截。原因:File->Read 把传入的 BufferSize 理解为当前缓冲区容量,不是读满为止,大文件固件会分批返回。解决:先打开文件拿到文件大小,分配完整缓冲区后循环调用 Read,直到读完,再关闭文件,然后才校验内核格式。

5.4 从 UEFI 环境切到内核 64 位入口:GDT 与页表的准备

UEFI 在 x64 上本身运行于长模式,但不代表内核可以直接接管。你要在跳转前给内核准备一套干净的运行环境。

现象:内核入口执行第一条指令就触发异常,虚拟机黑屏或重启。原因:最常见的是没有建立临时页表就把控制权跳给了内核,或者 GDT 里代码段描述符的 L 位没有置位,导致 CPU 把 64 位代码当成 32 位解析。解决:跳转前至少做三件事——建一张覆盖物理内存的临时页表,直接把物理地址按 1:1 映射到内核虚拟地址空间;加载一个带 64 位代码段描述符的 GDT;用远跳转把 CS 刷成 64 位代码段,再把入口函数地址转成物理地址传进去。

这个阶段之后再想回头调 UEFI 引导服务已经不可能,所以我会把内核入口地址、内存图、GOP 信息放进一个约定好的内存结构里,通过寄存器或共享内存传给内核入口,而不是让内核回头读 UEFI 服务。

6. 用 OVMF 快速验证整条引导链,并留下一个复盘习惯

6.1 把 OVMF.fd 当固件,问题定位省一半时间

在真实主板上调试 UEFI 引导程序非常费劲:清 CMOS、刷固件、接串口转接线,一次折腾半小时。我自己的做法是全程用 OVMF 固件在虚拟机里测。把 EDK2 编译出的 OVMF.fd 指定为虚拟机固件,启动模式就变成 UEFI;把你做好的 ESP 分区做成磁盘镜像挂给虚拟机。启动后如果固件能进入 UEFI Shell,说明 GPT 分区、ESP 路径、BOOTx64.EFI 这条链路是通的。

之后每加一个功能,重新编译 .efi,替换镜像里的文件,再启动看打印。整个过程只需要关虚拟机、换镜像、再开机,不碰任何物理硬件。操作系统层面的验证还有个技巧:在引导程序里按阶段输出标记,比如GOP OK、MEM OK、FS OK、KERNEL ENTRY。每次启动看日志停在哪一步,就能定位是显示模式失败、内存表问题还是文件读取没走到。

6.2 一个让我少熬夜的复盘习惯

以前有段时间,我经常遇到「昨天能引导、今天改了三个地方就起不来」的情况。后来强制自己每次改动都留两样东西:一个是改动前后都能跑的 boot 镜像备份,另一个是启动时打印的完整内存图与显示模式信息。出问题时先不猜,直接对照两张图找差异。

从那以后我每次实现引导程序,都强制走一遍「先编译 → 进 Shell → 读内存图 → 读文件 → 跳内核」的检查顺序,任何一步失败就先修这一步,不急着往下写。UEFI 引导程序不是玄学,只是链路长、接口多,把每一步的验证门槛都设清楚,问题基本都能在十分钟内定位。希望这份《一个UEFI引导程序的实现》能帮到你,也祝你的内核早点在自研引导器下跑起来。

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

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

5张手机照片实现口腔三维建模:TeethDreamer技术解析

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

作者头像 李华
网站建设 2026/10/24 6:14:05

MAC地址硬刷实战:EEPROM、校验和、写保护与回滚

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

作者头像 李华
网站建设 2026/10/24 6:13:46

PCA9422 + PIC18F4553 电源管理实战:从I2C调压到低功耗设计

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

作者头像 李华
网站建设 2026/10/24 6:12:05

JVM类加载子系统核心原理与双亲委派模型实战解析

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

作者头像 李华
网站建设 2026/10/24 6:10:58

俄语字母Unicode映射与编码转换实战指南

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

作者头像 李华
网站建设 2026/10/24 6:10:43

Halcon阈值分割全攻略:从全局到局部,八大方法详解

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

作者头像 李华