news 2026/10/2 23:27:41

U-Boot启动流程深度解析:从_start汇编入口到C语言世界的进阶指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
U-Boot启动流程深度解析:从_start汇编入口到C语言世界的进阶指南

1. 为什么嵌入式开发总要啃U-Boot启动流程

做嵌入式Linux开发的人,几乎都绕不开U-Boot。它不是操作系统,却决定了你的内核能不能跑起来、DDR初始化对不对、启动参数传没传对。很多新手卡在"U-Boot到底是怎么跑起来的"这个问题上,有经验的工程师也常常说"把U-Boot启动流程理解了,整个嵌入式底层开发就算入门了"。

这句话我是认同的,因为U-Boot的启动链路几乎涵盖了嵌入式底层开发的全部核心技能:异常向量、ARM处理器模式切换、MMU与Cache控制、DDR初始化、栈建立、BSS清零、重定位、C语言运行时环境构建。说白了,它就是一份活生生的ARM启动代码教材。

我当年刚接触这块时,也干过直接copy板卡配置、然后对着编译错误发呆的事。真正让我把整个体系串起来的,还是静下心来逐行读_start那段汇编代码。这篇文章我就按照自己当初的阅读路径,从_start汇编入口带你一步步走进U-Boot的C语言世界。你不需要预先掌握多少汇编知识,只要有基本的ARM概念,跟着走一遍,启动流程的大框架就能立起来。

2. _start入口:复位向量与异常向量表的底层逻辑

2.1 为什么一切从_start开始

U-Boot编译链接之后,生成的u-boot.bin文件有一个链接地址,例如很多ARM平台把它放在0x87800000或者0xC0000000。CPU上电或者复位之后,第一条取指地址由硬件决定,通常是0x00000000(或者SoC内部ROM指定的启动地址)。要保证第一条指令落在U-Boot的代码里,就要通过链接脚本把入口点指到_start标号。

你可以在u-boot根目录下的u-boot.lds(或者arch/arm/cpu/armv7/u-boot.lds等,具体路径因平台而异)中看到类似这样的定义:

OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm") OUTPUT_ARCH(arm) ENTRY(_start) SECTIONS { . = 0x00000000; . = ALIGN(4); .text : { *(.__image_copy_start) *(.vectors) CPUDIR/start.o (.text*) *(.text*) } }

关键就是这个ENTRY(_start),它告诉链接器U-Boot的入口是_start标号。而*(.vectors)把异常向量表也放到了最前面。ARM处理器复位后会直接跳转到0地址(或映射地址)读取第一条指令执行,所以_start就必须是一个可以正确初始化处理器的入口,而不是任意的普通函数。

2.2 异常向量表:一张跳转扑克牌

ARM架构要求内存最前面的32字节(在armv7中是4字节乘8个异常向量)放一张异常向量表。U-Boot在start.S里直接用汇编代码写出来了,比如armv7的start.S:

_start: #ifdef CONFIG_USE_IRQ ldr pc, _undefined_instruction ldr pc, _software_interrupt ldr pc, _prefetch_abort ldr pc, _data_abort ldr pc, _not_used ldr pc, _irq ldr pc, _fiq #else b reset ldr pc, _undefined_instruction ldr pc, _software_interrupt ldr pc, _prefetch_abort ldr pc, _data_abort ldr pc, _not_used ldr pc, _irq ldr pc, _fiq #endif

这段代码做了几件事:

  • 如果配置了中断(CONFIG_USE_IRQ),每个向量位置用ldr pc, 标号直接加载跳转地址;
  • 如果没有配置中断,第一个向量位置用b reset,后面的异常向量依然加载地址。

为什么第一个入口用b reset而不是ldr pc, _reset?因为b是相对跳转指令,跳转范围受限但位置无关,在最早期可以不用依赖绝对地址,比较保险。而ldr pc, 某个地址则需要把目标地址存放在某个位置,如果那段内存还没准备好,就会出问题。

2.3 reset入口的设置CPU模式:SVC模式的讲究

从_start跳转到reset标号之后,Linux平台和很多其它平台的U-Boot都会在reset代码里做同样的事:切换CPU模式、关中断、关MMU。

reset: /* * set the cpu to SVC32 mode */ mrs r0, cpsr bic r0, r0, #0x1f orr r0, r0, #0xd3 msr cpsr, r0

这行代码的意图是把CPSR的模式位改成SVC模式,同时屏蔽IRQ和FIQ中断。0xd3对应的二进制是1101 0011,其中bit[4:0]=10011是SVC模式,bit7=1表示禁止IRQ,bit6=1表示禁止FIQ。还有一路是清掉I bit、F bit,但这里的0xd3明显是把中断关了。

我用一个不太严谨但很好记的比喻:ARM处理器模式就像你在公司里的不同角色,用户模式是普通员工,SVC模式是高权限的管理员。U-Boot在启动早期就必须切到SVC模式,因为后期要配置MMU、访问协处理器,这些操作在用户模式下是特权指令,根本执行不了。

这里的经验是:不同平台可能用不同的模式切换方式,但目的一样。如果你做的是Cortex-M系列,可能看不到这段代码,而是直接进Handler模式。所以读_start时要先确认自己的ARM产品家族。

3. 汇编阶段的硬件大扫除:关MMU、清TLB、关Cache

3.1 为什么一上电就要关MMU和Cache

很多刚接触的人会问:MMU不是用来做虚拟内存映射的吗?功能这么好,为什么要关掉?

原因很简单:上电复位那一刻,MMU和Cache的状态是不确定的。尤其在被引导加载程序接管之前,我们根本不知道上一段代码把MMU和页表配置成什么样子。DDR可能还没初始化,页表可能存放在一个无效地址上,此时开着MMU就相当于蒙着眼睛走路。

U-Boot的处理方式是先统一把MMU关掉,把指令Cache和数据Cache也关掉,等后面DDR初始化完成、页表建好、进入C语言重定位流程之后,再在合适时机开启MMU和Cache。整个过程是可控的、一步一步来的。

在start.S里常见的代码片段如下(armv7):

/* * disable MMU and caches */ mrc p15, 0, r0, c1, c0, 0 bic r0, r0, #0x0005 mcr p15, 0, r0, c1, c0, 0

这里是对协处理器CP15的SCTLR寄存器(System Control Register,系统控制寄存器)操作。bit0是MMU使能位,bit2是数据Cache使能位,bic掉0x0005就同时关闭了MMU和D-Cache。还可能加上指令Cache、分支预测等位。这里不同ARM版本位定义略有不同,但是大思路一样。

3.2 清理TLB:别让旧地址映射拖后腿

TLB(Translation Lookaside Buffer,快表)是MMU用来缓存最近使用的地址转换结果的一块硬件。如果MMU之前被用过,TLB里可能残留一些地址转换条目。直接关掉MMU再开,或者重新开MMU的时候,这些残留条目会让新的映射表失效。稳妥的做法是使用CP15的TLBIALL指令无效化整个TLB:

mcr p15, 0, r0, c8, c7, 0 @ invalidate TLB

在U-Boot早期汇编阶段,清理TLB这个动作不是必须重复多次,但在从ROM代码跳转过来的场景里,强烈建议做一次。我之前遇到过一块板子,启动时偶尔出现莫名其妙的地址错乱,最后发现是TSP(Tightly-Coupled Memory,紧耦合内存)引导ROM代码留下的TLB缓存干扰。后来在_start里强制刷新TLB,问题消失了。

3.3 关中断的另一个隐藏原因:异常向量表还没准备好

除了模式切换时关中断,这里还有一个隐含原因:异常向量表的重定向问题。默认情况下,ARM在复位后把异常向量表放在0x00000000位置(或特定平台的固定位置)。但U-Boot在链接时可能把自己的镜像放在DDR高位地址。也就是说,向量表要么还没拷贝到DDR,要么还没设置VBAR(Vector Base Address Register,向量基地址寄存器)指向新位置。

在这个过渡期,任何中断或异常进来,CPU都会去找一个位置不对的向量表,然后执行错误的处理流程。所以必须关掉所有中断,直到后面代码安全地建立了新的向量表和中断处理机制。

4. 从CPU到板级:低级初始化里那些必须提前干完的事

4.1 时钟初始化为什么不能拖

很多人误以为时钟初始化是在C语言阶段的board_init_f里做的。其实在部分平台中,U-Boot在汇编阶段就会调用一个叫lowlevel_init(或者cpu_init_cp15、s_init等)的函数做最基础的CPU初始化。时钟初始化也可能在这里完成,取决于厂商的移植方式。

时钟为什么要这么早做?因为DDR控制器需要时钟才能工作,UART外设需要时钟才能输出打印信息,定时器需要时钟才能计算延时。整个系统都依靠PLL(Phase-Locked Loop,锁相环)把外部低频晶振倍频到高频总线频率。如果时钟没配好,后面DDR参数配置就无从谈起。

常见的路径在arch/arm/cpu/armv7/start.S中是这样的:

bl lowlevel_init

lowlevel_init在板级目录里(比如board/xxx/xxx/lowlevel_init.S),里面通常会设置PLL、初始化DDR控制器以及其它必须提前就绪的硬件。

4.2 DDR初始化是通往C世界的先决条件

这一点值得单独强调:大部分U-Boot流程里,DDR初始化发生在汇编阶段,或者在进入C后board_init_f早期完成。为什么不能等到C语言阶段再慢慢配置?

因为C语言运行需要栈指针(SP)、需要局部变量放在内存里、需要全局变量放在内存里。而一开始SP的值可能是未知的(或者说只适配内部SRAM),如果DDR没有初始化,程序规模一大就无处安放数据。因此在跳转C之前,必须保证DDR可用。

在armv7平台上,低级初始化流程大致是:

  1. 根据SoC的PLL配置寄存器设置CPU频率、AXI/AHB/APB总线频率;
  2. 根据DDR芯片的规格(容量、位宽、时序参数)配置DDR控制器;
  3. 执行DDR控制器的训练/校准流程;
  4. 等待DDR就绪后,把栈指针设置到DDR空间的安全位置。

这里最容易踩的坑是DDR时序参数填错。比如把CAS延迟(Column Address Strobe latency)配大了,系统能跑但是性能低;配小了,数据不稳定,会出现随机死机、内存校验失败。我在调试一块四层板的时候,遇到DDR初始化偶尔失败,后来发现是板子走线长度差异大,需要调整DDR控制器里的时序补偿寄存器,而不是单纯改主时钟。

4.3 串口初始化:为什么有些平台会提前开

有一个比较有意思的细节:早期U-Boot在汇编阶段可能就已经把UART初始化好了,甚至在lowlevel_init里打印一个字符。这是为了方便调试——如果DDR初始化失败、代码卡死,至少能通过串口输出判断卡在哪一步。

如果UART没有提前初始化,那么后续一旦DDR代码出了问题,你只能干瞪眼,用示波器看波形、用JTAG调试器去查PC指针,效率低很多。

所以如果你的板卡支持,我建议在lowlevel_init阶段就初始化UART并且打印一个"U-Boot starting..."之类的字符。这样启动失败时,你至少能知道自己走到了哪一层。这不算是标准流程的必须,但却是实际调试里非常管用的一个技巧。

5. 栈的建立与BSS段清零:踏进C世界之前的两张入场券

5.1 栈不是想设哪就设哪,要避开镜像和DDR空洞

跳进C语言世界,最核心的不是"调用一个函数",而是这个函数执行时能有自己的局部变量。局部变量放在栈上。ARM的ATPCS规则(ARM-Thumb Procedure Call Standard,ARM过程调用标准)规定栈是向下生长的,SP寄存器指向栈顶地址,push时减小SP、pop时增大SP。

在start.S中,你会看到类似这样的代码:

ldr r0, _TEXT_BASE ldr sp, _TEXT_BASE sub sp, sp, #CONFIG_SYS_MALLOC_F_LEN

这里的_TEXT_BASE是U-Boot链接是的代码基地址,比如0x87800000。把SP设置到代码基地址上,再减去一段预留空间(CONFIG_SYS_MALLOC_F_LEN是早期malloc区域长度),就得到了可行的栈起点。为什么要减一段空间?因为栈向下生长,预留出的这一小段空间可以放早期malloc数据,不会和函数调用栈冲突。

一个关键点是,栈地址必须避免与代码段、数据段重叠。如果U-Boot镜像在0x87800000,DDR实际空间到0x8FFFFFFF,那么栈放在0x87800000之上、镜像末尾之下都行。但如果你不小心把栈设到了镜像中间,镜像被覆盖,程序会随机跑飞。

我建议初学者在分析时,把自己板卡的DDR地址范围画出来,再把U-Boot镜像的加载地址和链接地址标上,然后核对栈的初值是否落在合理区间。这一步虽然基础,却可以帮你避免后面很多奇怪的疑难杂症。

5.2 BSS段清0:C语言全局变量默认值是0的前提

C语言里未初始化或者初始化为0的全局变量被放在BSS段。在C语言标准里,它们的初始值是0。但这个语义对硬件来说不是天然成立的——SRAM或DDR上电后的内容是随机的。所以U-Boot必须在跳进C之前手动把BSS段清零。

start.S里有一段经典写法:

ldr r0, _bss_start ldr r1, _bss_end mov r2, #0 clear_bss: cmp r0, r1 bhs clear_bss_done str r2, [r0], #4 b clear_bss clear_bss_done:

_bss_start和_bss_end这两个符号来自链接脚本,分别表示BSS段的起始和结束地址。代码逐字地把这段内存写成0。有些平台会用bss_start和bss_end,名字大同小异,功能一样。

为什么这是一张"入场券"?因为一旦你跳过BSS清零直接进C,所有未初始化全局变量都是随机值。后续比如某个中断处理函数检查g_irq_counter,它可能是一个巨大的随机数,直接导致逻辑错误。这类问题一旦出现,定位起来非常痛苦,因为它不是必现的,和你操作板的时序有关。所以一定不要省略这个步骤。

5.3 可选的early stack与大栈的切换

U-Boot在早期阶段会使用一个小栈,通常建立在内部SRAM或者DDR前段的安全区域。这个栈足够支撑board_init_f等早期C函数使用。等完整重定位之后,栈会切到DDR上的最终栈地址。

有的平台在start.S里会先设置一个临时SP,然后进C代码之后再通过relocate_code等函数更新SP。我当年在读代码时就困惑过:既然最终都要用DDR内存当栈,为什么不在_start里一次配好?现在理解了,因为部分平台在早期连DDR都还没初始化,只能用SRAM做栈;而DDR初始化完成后,栈再迁移过去。不同平台的做法反映了其硬件初始化的不同时序约束。

6. 从汇编到C的临界点:start_armboot与board_init_f的调用背后

6.1 汇编函数跳转C函数的ABI规则:r0到r3传递参数

在start.S的尾部,有一段典型的跳转代码:

bl board_init_f

这条指令执行之前,你可以看到通常会有对r0、r1参数的赋值,比如传递DDR起始地址或重定位标志。ARM的ABI规定前4个参数分别由r0、r1、r2、r3传递,超过4个的部分用栈传递。所以bl board_init_f之前,必须确保r0-r3中的值符合函数签名要求。

跳转指令bl做了两件事:先把下一条指令的地址保存到链接寄存器r14(LR),然后修改PC跳转到目标函数。当C函数要返回时,它会把LR里的值恢复给PC。

这也是汇编和C交汇时需要特别注意的地方。如果汇编阶段没有维护好LR,或者中间调用了子函数导致LR被覆盖,那C函数的返回就会跑飞到不知道哪里去。所以U-Boot在初始化阶段也有一句:

mov lr, #0

它会先把LR清零,避免随机值干扰。但因为board_init_f一般不返回,这个清零操作更多是无害的保险。

6.2 board_init_f究竟做了什么:启动初期的一站式调度

进入C世界后,第一个重量级函数就是board_init_f。它通常在common/board_f.c,名字里的f代表"init_f",意思是"在重定位之前"的初始化阶段。

board_init_f的主要任务是:

  • 把U-Boot镜像里需要用到的内存布局计算出来;
  • 初始化早期串口(打印信息);
  • 初始化DRAM(如果在汇编阶段没有做完整);
  • 设置栈顶、堆、malloc区域、全局数据区(gd)等;
  • 为后续重定位做准备,计算U-Boot镜像要拷贝到哪个地址。

在common/board_f.c里,有一个init_sequence_f数组,它把board_init_f内部要执行的一长串初始化函数按顺序排列:

static init_fnc_t init_sequence_f[] = { setup_machine, reserve_global_data, ... initf_dram, ... setup_dest_addr, reserve_uboot, reserve_malloc, reserve_board, setup_machine, ... init_func_ram, ... initf_console_record, ... announce_early_init, ... };

这个数组在U-Boot的不同版本里略有不同,但思路完全一致:用一个函数指针数组表达初始化步骤,板级适配可以通过重载或条件编译来裁剪。

其中gd(global_data)结构体非常关键,它是U-Boot早期所有全局信息的载体——DDR起始地址与大小、栈位置、重定位地址、串口配置、环境变量位置等。setup_global_data会把这个结构体放在一个早期安全的内存位置。

6.3 gd结构体:启动早期阶段的"全局笔记本"

如果你读代码看到到处都是gd->xxx,不用慌,它就是一个精简的全局信息结构。它在include/asm-generic/global_data.h里定义。为什么要用gd而不是普通的全局变量?因为早期代码运行在位置未定或者RAM未全部初始化的状态下,普通全局变量(在BSS段)可能不可用,而gd可以放在一个固定的、提前确定的地址上。

gd的地址通常通过寄存器传递。在ARM平台,U-Boot会把gd地址放在r9寄存器里。所以你会看到很多汇编(或内联汇编)里专门维护r9不被其它代码随意使用。跳进C后,约定俗成地使用gd来实现对它成员的访问。如果你在自己开发的汇编代码里碰了r9,就会突然出现gd数据错乱的问题,这是新手比较容易踩的一个暗坑。

6.4 重定位:把自己搬到内存高处再继续跑

U-Boot早期可能从NOR Flash、SD卡或内部SRAM启动,这些地方的代码执行效率低或者空间有限。启动流程会在初始化完DDR、准备好内存布局之后,把自己整个镜像拷贝到DDR的更高地址运行。这个动作就是"重定位(relocation)"。

在board_init_f的最后,通常计算好了需要拷贝的地址,然后调用汇编函数relocate_code完成搬运。搬运之后还要更新栈指针、更新gd地址、跳转到新地址继续执行board_init_r。

这一步骤为什么重要?因为U-Boot的一个核心能力是把自己变成一个可以被内核覆盖的引导加载程序。它不能一直占着DDR的底部,否则Linux内核加载时无处落脚。所以U-Boot主动把自己放到内存的顶端区域(通常是一个比较高的地址),把底端空出来给内核和设备树。

重定位代码通常也在start.S里,比如典型的:

relocate_code: ldr r1, _TEXT_BASE ... copy_loop: ldmia r0!, {r3-r10} stmia r1!, {r3-r10} ...

它的核心就是一段高效的循环拷贝,同时考虑了cache的flush,确保指令和数据的可见性正确。

7. 常见启动异常排查:我实际遇到过的三个"卡住不说"的问题

7.1 卡在"U-Boot SPL ..."就不动了

SPL(Secondary Program Loader,二级程序加载器)是U-Boot的一个特殊构建目标,用于一些对大小有严格限制的启动场景。它把DDR初始化提前,然后再加载完整的U-Boot。如果你的板子打印了SPL头信息后就卡住,多半是在SPL阶段做DDR初始化时出了问题。

我调试过一块板子,SPL能输出但之后串口无任何信息。用示波器测DDR时钟和数据线,发现DQS信号质量很差。排查后发现板子Layout上DDR走线绕了两个过孔,stub过长导致信号反射。修改PCB之后问题解决。想说的是,遇到DDR相关的启动异常,光堆代码没用,往往要回到硬件层面做信号完整性分析。

7.2 跳入C后立刻跑飞:栈地址不对的经典症状

如果你看到U-Boot打印了极少信息后崩溃,而且PC指针跳到一个怪异的地址,大概率是栈指针设置不当。我遇到过一种情况:板卡DDR实际大小是512MB,但配置成1GB,而U-Boot把栈设置到了DDR不存在的地址。程序访问到空洞内存,数据丢失后跑飞。

这类问题排查方法是先确认__DDR_SIZE__或者CONFIG_NR_DRAM_BANKS、CONFIG_SYS_SDRAM_BASE等配置是否和板卡实际内存匹配。可以在board_init_f里临时加打印,把gd->ram_base和ram_size打出来验证。

7.3 从SD卡启动和从EMMC启动行为不一致

常有道友问:为什么我同一个U-Boot,SD卡启动正常、EMMC启动卡住?这个问题往往不是代码逻辑问题,而是EMMC初始化时序和SD不同,或者EMMC的boot分区配置与U-Boot的读取逻辑不匹配。

在这种场景下,我会建议先把启动介质差异隔离出来。用JTAG调试器直接查看SPL阶段是否成功读取了后续代码,再检查EMMC的RST_n信号、CLK频率和电压切换顺序。很多时候,EMMC无法启动是因为没有正确切换电压信号(3.3V切到1.8V),这是硬件和驱动配合的经典问题。

8. 我读start.S多年后总结的几条实用经验

如果非要给这份启动源码分析做个经验提炼,我觉得比较值得记住的有这几点。

第一,读汇编不要逐行死抠,先把大致的"路标"找出来。每一段汇编都对应一个明确的硬件目标:设模式、关MMU、清BSS、设SP、调函数。把路标之间的关系理顺了,再钻细节。

第二,利用U-Boot的打印信息作为你的定位工具。U-Boot启动过程里有很多可供开启的调试宏,比如CONFIG_DEBUG_UART、CONFIG_DEBUG_UART_BASE等。在汇编阶段也可以直接读写UART寄存器输出字符,这是最朴素的调试手段。

第三,善用JTAG调试器看PC指针和SP的值。汇编阶段的寄存器状态没法用printk看,但调试器可以直接读。遇到卡死、跑飞,先看PC跑到哪了,再反推前面的代码因为什么原因没有正确跳转。

第四,维护好r9(gd指针)和lr(链接寄存器)。在你自己写的汇编代码里一定要避免破坏这两个寄存器。否则就会出现"底层代码改一行,上层全乱"的灵异问题。

第五,有条件的话多对比几个平台的start.S。ARMv7-A、ARMv8-A、Cortex-M这些家族的启动汇编各有差异。比如ARMv8使用el3_entry、el2_entry等,把异常级别切换讲得更细。但底层逻辑是共通的——初始化CPU、初始化内存、建立运行时环境、转型给C代码。

很多人会在看完启动流程后感慨:就这么几百行汇编,背后的硬件门道数十万字都写不完。这话不假,但也不必被吓退。从一开始跟着_start的每一行指令,确认它的意图,再到把它跟硬件手册里对应的寄存器位对应起来,这个过程本身就会让你的硬件功底上一个台阶。

我到现在调试底层问题时,还是习惯性地打开start.S从头过一遍。它就像一张地图,每次走都能发现点之前没注意的细节。也希望这篇文章能成为你手里的第一版地图,带着你从汇编稳稳地踏进那个属于U-Boot的C语言世界。下次你再听到同事说"卡死在board_init_f",至少心里能立刻有个方向了。

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

数字IC手撕代码:valid/ready握手协议详解与Verilog实现

写这段文字的时候,我刚从一场线上技术交流里出来,话题又绕到了“数字IC手撕代码”。好几个朋友都在问同一个问题:面试官让现场写握手协议,到底要写到什么程度才算过关?实际上,握手协议(valid/re…

作者头像 李华
网站建设 2026/10/2 23:26:54

OpenClaw本地使用完整教程:把settings改到TaoToken

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

作者头像 李华
网站建设 2026/10/2 23:19:12

AI Skills 完全解析:用 SKILL.md 把大模型能力模块化接入 TaoToken

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

作者头像 李华