news 2026/10/15 1:47:59

iTOP-4412 U-Boot移植:重定位机制与UART驱动实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
iTOP-4412 U-Boot移植:重定位机制与UART驱动实战

1. 移植前的整体思路与重定位机制拆解

1.1 为什么要先搞懂重定位再动 UART

很多人在移植 U-Boot 的时候,习惯先把串口调通,看到有打印输出就觉得成功了一大半。这个思路本身没错,但如果你跳过重定位这一环直接去调 UART,后面大概率会遇到一个很尴尬的情况:串口能打印,但打印到一半就挂了,或者打印出来的地址全是乱的。原因很简单,U-Boot 的代码在启动过程中会经历一次“搬家”,也就是重定位。搬家的过程中,代码段、数据段、BSS 段的位置都会发生变化,如果你不清楚它搬到了哪里,UART 驱动的基地址、寄存器映射、甚至栈指针都可能指向错误的位置。

iTOP-4412 这款板子用的是三星 Exynos 4412 芯片,四核 Cortex-A9 架构。它的启动流程比较特殊,芯片内部有一块 iRAM(内部 SRAM),上电后先跑 BL1,然后 BL2 会把 U-Boot 的 SPL 阶段加载到 iRAM 里运行。SPL 做完 DRAM 初始化之后,才会把完整的 U-Boot 镜像搬到 DRAM 中执行。这个“搬到 DRAM”的过程就是重定位的核心。U-Boot 2020.07 这个版本的重定位机制和早期版本相比有不少变化,特别是引入了CONFIG_SYS_RELOCATION_ADDR和gd->relocaddr这套东西之后,如果你还按老版本的思路去改,很容易踩坑。

我个人的习惯是,在动任何外设驱动之前,先把重定位的日志打开,确认relocaddr的值和实际 DRAM 布局对得上。这一步花不了多少时间,但能帮你省掉后面大量的调试功夫。具体来说,你需要在board_init_f阶段把gd->relocaddr打印出来,同时确认CONFIG_SYS_SDRAM_BASE和CONFIG_SYS_TEXT_BASE这两个宏的定义是否匹配你的板子。iTOP-4412 的 DRAM 起始地址是0x40000000,U-Boot 的链接地址通常设在0x43E00000附近,这个地址不是随便选的,它要保证重定位后的代码不会覆盖 SPL 阶段还在用的数据。

1.2 重定位的核心逻辑与关键数据结构

U-Boot 的重定位本质上就是一次“代码搬家”。在board_init_f阶段,U-Boot 还在 iRAM 或者 DRAM 的低地址区域运行,此时它会计算最终要搬到的位置,也就是gd->relocaddr。这个地址的计算依赖于 DRAM 的总容量、U-Boot 自身的大小、以及一些保留区域(比如 framebuffer、malloc 区、栈区等)。计算完成后,board_init_r阶段会调用relocate_code函数,把代码段从当前位置复制到relocaddr,然后跳转过去继续执行。

这里有一个关键点:重定位之前,全局数据指针gd指向的是 iRAM 里的一个临时结构体;重定位之后,gd会被更新为指向 DRAM 中的新位置。如果你在重定位之前就初始化了 UART,并且把 UART 的私有数据结构挂在了gd上,那么重定位之后这个指针就会失效。这就是为什么很多人调 UART 的时候,前面能打印,后面突然就没输出了。解决办法有两种:要么把 UART 初始化放在重定位之后,要么在重定位过程中手动修正 UART 相关的指针。我一般推荐第一种,简单直接,不容易出错。

另外,CONFIG_SYS_TEXT_BASE这个宏在 2020.07 版本里已经不再是唯一的链接地址决定了。如果你开启了CONFIG_POSITION_INDEPENDENT,U-Boot 会编译成位置无关码,重定位的时候就不需要修改绝对地址引用。但 Exynos 4412 这种 ARMv7 架构的芯片,默认是不开位置无关的,所以你还是得老老实实处理重定位偏移。具体来说,relocate_code函数会计算一个偏移量reloc_offset,然后把所有需要重定位的段都加上这个偏移量。你在写 UART 驱动的时候,如果用了全局变量来保存寄存器基地址,这个全局变量本身也会被重定位,所以不用担心它指向错误的位置。但如果你用的是宏定义直接写死的地址,那就更没问题了,因为宏在编译期就展开了,不涉及运行时重定位。

1.3 iTOP-4412 的 DRAM 布局与重定位地址选择

iTOP-4412 板载的 DRAM 通常是 1GB 或者 2GB,起始地址0x40000000。U-Boot 的重定位地址不能随便选,它要避开几个区域:最上面是保留给内核和文件系统的,中间是 U-Boot 自己的 malloc 区和栈区,最下面是 SPL 阶段可能还在用的区域。在 2020.07 版本里,board_init_f会调用dram_init_banksize来填充 DRAM 的 bank 信息,然后setup_dest_addr会计算gd->ram_top和gd->relocaddr。

我实测下来,对于 1GB DRAM 的 iTOP-4412,gd->relocaddr通常落在0x4FF00000附近,也就是 DRAM 的最顶端往下留 1MB 左右。这个值不是固定的,它会根据你开启的功能(比如 LCD、USB、网络)动态调整。如果你发现重定位后的地址和预期不符,可以在setup_dest_addr里加打印,看看gd->ram_size、gd->mon_len、gd->irq_sp这些值对不对。特别是gd->mon_len,它表示 U-Boot 镜像的总大小,如果这个值算错了,重定位地址就会偏,严重的话会直接把 U-Boot 自己覆盖掉,导致跑飞。

还有一点要注意:Exynos 4412 的 DRAM 控制器在 SPL 阶段已经初始化好了,但如果你在 U-Boot 里重新初始化了 DRAM 控制器,可能会改变 DRAM 的映射关系。我建议在 U-Boot 阶段不要再动 DRAM 控制器,直接用 SPL 初始化好的配置就行。这样重定位的时候,DRAM 的访问时序是稳定的,不会出现读写错误。

2. UART 驱动移植的核心细节与实操要点

2.1 Exynos 4412 的 UART 控制器特性

Exynos 4412 内部有多个 UART 控制器,iTOP-4412 板子引出了 UART2 作为调试串口,对应的寄存器基地址是0x13820000。这个 UART 控制器兼容 8250 系列,但有一些自己的特性,比如 FIFO 模式、DMA 模式、红外模式等。在 U-Boot 里,我们只需要用到最基本的轮询发送和接收功能,所以配置起来不算复杂。

关键寄存器有这么几个:ULCON(线路控制寄存器)用来设置数据位、停止位、校验位;UCON(控制寄存器)用来设置发送/接收模式、时钟源;UFCON(FIFO 控制寄存器)用来使能 FIFO;UMCON(Modem 控制寄存器)一般用不到;UTRSTAT(发送/接收状态寄存器)用来判断发送缓冲区是否为空、接收缓冲区是否有数据;UTXH和URXH分别是发送和接收数据寄存器;UBRDIV和UFRACVAL用来设置波特率。

在 2020.07 版本的 U-Boot 里,串口驱动框架已经比较完善了,你只需要实现serial_ops结构体里的几个回调函数就行。具体来说,需要实现serial_setbrg(设置波特率)、serial_putc(发送一个字符)、serial_getc(接收一个字符)、serial_tstc(判断是否有数据)、serial_init(初始化串口)。这些函数的实现直接操作寄存器即可,不需要依赖中断或 DMA。

2.2 波特率计算与时钟源选择

波特率的计算是 UART 移植里最容易出错的地方。Exynos 4412 的 UART 时钟源可以选 PCLK、SCLK_UART0/1/2/3 等,具体取决于UCON寄存器里的时钟选择位。iTOP-4412 默认用的是 PCLK,频率通常是 100MHz 或者 66MHz,这个值取决于你的时钟配置。如果你在 SPL 阶段已经配置好了时钟,U-Boot 阶段直接沿用就行,不需要重新配置。

波特率的计算公式是:UBRDIV + UFRACVAL/16 = (时钟频率 / (波特率 * 16)) - 1。举个例子,假设时钟频率是 100MHz,波特率是 115200,那么100000000 / (115200 * 16) = 54.25,减去 1 得到 53.25。所以UBRDIV = 53,UFRACVAL = 0.25 * 16 = 4。这个计算过程看起来简单,但实际调试的时候,如果时钟频率不对,算出来的波特率就会有偏差,导致串口输出乱码。

我踩过的一个坑是:SPL 阶段把 UART 时钟源设成了 SCLK_UART,但 U-Boot 阶段默认用的是 PCLK,结果两边频率不一致,串口打印全是乱码。后来我在serial_init里显式设置了UCON的时钟选择位,确保和 SPL 阶段一致,问题就解决了。所以你在移植的时候,一定要先确认 SPL 阶段用的是什么时钟源,然后在 U-Boot 里保持一致。

2.3 串口引脚复用与 GPIO 配置

Exynos 4412 的引脚是多功能复用的,UART2 的 TX 和 RX 引脚默认可能是 GPIO 功能,需要配置成 UART 功能才能正常通信。这个配置通常在board_init_f或者board_init_r的早期阶段完成,通过设置 GPIO 控制器的功能选择寄存器来实现。iTOP-4412 的 UART2 TX 对应的是 GPG14,RX 对应的是 GPG15,你需要把这两个引脚的功能选择位设成 UART 模式。

具体操作是找到 GPG 控制器的基地址(通常是0x11000000加上偏移),然后修改GPGCON寄存器的对应位。这个步骤在 SPL 阶段可能已经做过了,但为了保险起见,我建议在 U-Boot 的serial_init里再确认一次。因为有些板子在 SPL 和 U-Boot 之间会复位 GPIO 配置,如果你不重新设置,串口就没法工作。

还有一个细节:UART 的 TX 引脚在空闲状态下应该是高电平,如果配置成 UART 功能后一直是低电平,可能是引脚方向或者上拉电阻没设置对。Exynos 4412 的 GPIO 控制器有上下拉配置寄存器,你可以把 TX 和 RX 都设成上拉,这样空闲状态下就是高电平,符合 UART 协议的要求。

3. 重定位与 UART 的实操过程与核心环节实现

3.1 重定位地址的确认与打印调试

在开始改代码之前,我建议先确认一下当前的重定位地址。你可以在board_init_f函数的末尾加上这样一段打印:

printf("relocaddr = 0x%08lx\n", gd->relocaddr); printf("ram_top = 0x%08lx\n", gd->ram_top); printf("mon_len = 0x%08lx\n", gd->mon_len);

注意,这段打印必须放在串口初始化之后才能看到输出。如果你还没调 UART,可以用 LED 或者 JTAG 来观察。我一般会先用 JTAG 连上板子,在board_init_f里打个断点,看看gd->relocaddr的值是不是在 DRAM 范围内。确认无误之后,再去调 UART。

如果relocaddr的值明显不对,比如落在了 iRAM 范围内(0x02000000附近),那说明gd->ram_size没算对,或者CONFIG_SYS_SDRAM_BASE定义错了。iTOP-4412 的 DRAM 起始地址是0x40000000,如果你定义成了0x00000000,那重定位地址就会算到错误的地方。这个宏在include/configs/itop_4412.h里定义,你需要根据板子的实际情况修改。

3.2 UART 驱动的完整实现与配置

在 U-Boot 2020.07 里,串口驱动的框架在drivers/serial/目录下。你可以参考serial_s5p.c这个文件,它是三星 S5P 系列芯片的串口驱动,Exynos 4412 也属于这个系列,所以可以直接拿来改。具体来说,你需要创建一个serial_exynos4412.c文件,然后实现serial_ops结构体。

核心的serial_putc函数实现如下:

static void exynos4412_serial_putc(struct udevice *dev, const char ch) { struct exynos4412_uart *priv = dev_get_priv(dev); struct exynos4412_uart_regs *regs = priv->regs; if (ch == '\n') exynos4412_serial_putc(dev, '\r'); while (!(readl(&regs->utrstat) & UTRSTAT_TXBE)) ; writel(ch, &regs->utxh); }

这段代码的逻辑很简单:先判断发送缓冲区是否为空,如果不为空就等待,直到可以发送为止。然后往UTXH寄存器写入字符。注意这里处理了换行符,因为串口终端通常需要\r\n才能正确换行,只发\n的话光标不会回到行首。

serial_setbrg函数用来设置波特率,实现如下:

static int exynos4412_serial_setbrg(struct udevice *dev, int baudrate) { struct exynos4412_uart *priv = dev_get_priv(dev); struct exynos4412_uart_regs *regs = priv->regs; unsigned long uclk = priv->clock; unsigned int ubrdiv, ufracval; unsigned long tmp; tmp = uclk / (baudrate * 16); ubrdiv = tmp - 1; ufracval = (uclk % (baudrate * 16)) / baudrate; writel(ubrdiv, &regs->ubrdiv); writel(ufracval, &regs->ufracval); return 0; }

这里的priv->clock是 UART 的时钟频率,需要在probe函数里从设备树或者平台数据里获取。iTOP-4412 的 UART2 时钟通常是 100MHz,你可以在设备树里写死,也可以从时钟框架里动态获取。

3.3 设备树配置与驱动绑定

U-Boot 2020.07 已经全面支持设备树了,所以你需要修改arch/arm/dts/exynos4412-itop.dts文件,添加 UART2 的节点:

serial@13820000 { compatible = "samsung,exynos4412-uart"; reg = <0x13820000 0x100>; interrupts = <0 28 0>; clocks = <&clock 100>; clock-names = "uart"; status = "okay"; };

然后在drivers/serial/Kconfig里添加对应的配置项,在Makefile里添加编译规则。最后在include/configs/itop_4412.h里定义CONFIG_BAUDRATE为 115200,CONFIG_SYS_SERIAL0为 UART2 的基地址。

设备树匹配的时候,compatible属性要和驱动里的of_match表一致。如果你发现驱动没有绑定成功,可以在serial_probe函数里加打印,看看dev->name和dev->driver是否匹配。常见的问题是设备树节点没使能(status不是okay),或者compatible字符串拼写错误。

4. 常见问题与排查技巧实录

4.1 串口无输出或输出乱码的排查思路

串口无输出是最常见的问题,排查起来其实有章可循。我一般按这个顺序来:先确认硬件连接,TX 和 RX 有没有接反,GND 有没有共地;然后确认波特率,两边是不是都是 115200;再确认时钟源,U-Boot 和 SPL 用的是不是同一个时钟;最后确认引脚复用,TX 和 RX 有没有配置成 UART 功能。

如果输出乱码,大概率是波特率不对。你可以用示波器量一下 TX 引脚上的波形,算一下实际波特率是多少。如果实际波特率是 57600 而不是 115200,那说明时钟频率算错了,或者UBRDIV和UFRACVAL的计算公式用错了。Exynos 4412 的 UART 时钟分频系数和标准 8250 有点不一样,你需要仔细看芯片手册里的公式。

还有一种情况是输出几个字符后就卡死。这个通常是重定位导致的,U-Boot 搬到新地址后,UART 的私有数据结构指针失效了。解决办法是把 UART 初始化放在重定位之后,或者在重定位过程中手动修正指针。我一般会在board_init_r的开头重新调用一次serial_init,确保 UART 在重定位后仍然可用。

4.2 重定位后代码跑飞的定位方法

重定位后跑飞的表现是:串口打印了几行之后突然没输出了,或者打印出来的地址全是乱码。这种情况通常是重定位地址算错了,把 U-Boot 自己覆盖了。你可以用 JTAG 在relocate_code函数里打个断点,看看reloc_offset的值对不对,然后单步执行,观察代码搬到哪里去了。

另一个可能的原因是 BSS 段没有清零。U-Boot 在重定位之前会调用clear_bss把 BSS 段清零,但如果重定位后的 BSS 段地址算错了,清零操作就会破坏其他数据。你可以在clear_bss函数里加打印,看看__bss_start和__bss_end的值是不是在 DRAM 范围内。

还有一个坑是缓存一致性问题。Exynos 4412 有 L1 和 L2 缓存,如果重定位后没有正确刷新缓存,CPU 读到的可能还是旧数据。U-Boot 在重定位后会调用flush_cache和invalidate_cache,但如果你自己改了重定位流程,可能会漏掉这一步。我建议在relocate_code之后显式调用一次flush_dcache_all,确保数据一致性。

4.3 常见问题速查表

问题现象可能原因排查方法解决方案
串口无输出引脚复用未配置检查 GPG14/GPG15 功能选择位在 serial_init 里配置 GPIO
串口输出乱码波特率计算错误示波器测量实际波特率重新计算 UBRDIV/UFRACVAL
打印几行后卡死重定位后指针失效JTAG 断点观察 gd 指针重定位后重新初始化 UART
重定位后跑飞重定位地址算错打印 gd->relocaddr检查 CONFIG_SYS_SDRAM_BASE
代码跑飞但无打印BSS 段未清零检查 __bss_start/__bss_end确保 clear_bss 正确执行
数据读写错误缓存未刷新检查 flush_cache 调用重定位后显式刷新缓存

4.4 独家避坑技巧与实操心得

第一个技巧:在board_init_f里把关键信息打印到一个固定的内存地址,比如0x40000000附近的一块保留区域。这样即使串口挂了,你也可以用 JTAG 读内存来查看启动日志。具体做法是定义一个debug_log函数,把字符串写到固定地址,然后用 JTAG 的 memory dump 功能查看。

第二个技巧:UART 的波特率不要用 115200,先用 9600 试试。9600 对时钟误差的容忍度更高,如果 9600 能正常输出,说明时钟源和引脚配置没问题,再切到 115200 去调波特率计算。这个办法能帮你快速定位问题是出在配置上还是计算上。

第三个技巧:重定位地址不要设在 DRAM 的最顶端,留出至少 1MB 的余量。因为 U-Boot 运行过程中会动态分配内存(比如 malloc 区、栈区),如果重定位地址太靠近顶端,这些动态分配可能会越界,导致不可预测的错误。我一般会把gd->relocaddr设在ram_top - 0x100000的位置,留足安全边界。

第四个技巧:如果你用的是 Exynos 4412 的 TrustZone 功能,注意 U-Boot 运行在 Secure World 还是 Non-Secure World。如果 SPL 阶段配置了 TrustZone,U-Boot 可能运行在 Non-Secure World,此时访问某些寄存器会触发异常。你可以在serial_init里先读一下UCON寄存器,如果读出来全是 0 或者全是 F,那说明访问权限有问题,需要检查 TrustZone 配置。

第五个技巧:串口打印的日志级别要控制好。U-Boot 默认会打印很多调试信息,如果你在serial_putc里加了太多打印,可能会影响启动速度,甚至导致看门狗复位。我建议在调试阶段把CONFIG_LOGLEVEL设为 7,正式发布时改成 4 或者 3,只保留关键信息。

5. 移植后的验证与性能观察

5.1 启动日志的完整分析

移植完成后,串口应该能输出完整的启动日志。我一般会关注这几个关键点:第一行是 U-Boot 的版本号和编译时间,确认镜像是对的;然后是 DRAM 的容量和重定位地址,确认内存布局正确;接着是各个外设的初始化信息,确认 UART、GPIO、时钟都正常;最后是启动命令的提示符,确认 U-Boot 进入了命令行模式。

如果日志里出现了Board: unknown或者DRAM: 0 MiB这样的信息,说明板级识别或者 DRAM 初始化有问题。iTOP-4412 的板级识别通常在board_init函数里通过读取 GPIO 或者 EEPROM 来实现,你需要确认这些代码有没有被正确编译进去。DRAM 容量识别则依赖于 SPL 阶段的初始化结果,如果 SPL 没跑通,U-Boot 阶段读到的 DRAM 容量就是 0。

5.2 串口吞吐量与稳定性测试

串口调通之后,我建议做一个简单的吞吐量测试。你可以用 U-Boot 的md命令 dump 一大块内存,然后通过串口输出到终端,观察有没有丢字符或者乱码。如果输出几万行都没问题,说明串口驱动是稳定的。如果中间出现了乱码,可能是 FIFO 溢出或者中断处理有问题。U-Boot 的串口驱动默认是轮询模式,不存在中断处理的问题,所以乱码大概率是波特率偏差累积导致的。

你还可以用loadx或者loady命令通过串口下载文件,测试接收方向的稳定性。接收方向比发送方向更容易出问题,因为接收需要及时读取URXH寄存器,如果读慢了就会溢出。U-Boot 的serial_getc函数是阻塞式的,会一直等到有数据才返回,所以一般不会溢出。但如果你在serial_tstc里判断有数据,然后隔了很久才调用serial_getc,就可能溢出。

5.3 重定位对启动速度的影响

重定位本身是一个内存拷贝操作,对启动速度的影响很小。但如果你的 U-Boot 镜像很大(比如开了很多功能),重定位的拷贝时间就会增加。我实测下来,1MB 左右的 U-Boot 镜像,重定位耗时大概在几毫秒级别,基本可以忽略不计。真正影响启动速度的是外设初始化,比如 USB、网络、LCD 这些,如果你不需要,可以在配置里关掉,能省不少时间。

另外,重定位后的代码运行在 DRAM 里,DRAM 的访问速度比 iRAM 慢,但比 Flash 快得多。所以重定位之后,U-Boot 的执行速度反而会变快。这也是为什么 U-Boot 要先把代码搬到 DRAM 再执行,而不是直接在 Flash 里跑。iTOP-4412 的 iRAM 只有 256KB,放不下完整的 U-Boot,所以重定位是必须的。

我个人在实际操作中的体会是,重定位和 UART 这两个东西,看起来一个是内存管理,一个是外设驱动,好像没什么关系,但实际上它们是紧密耦合的。重定位决定了代码在哪里运行,UART 决定了你怎么观察代码的运行状态。把这两个搞定了,后面的网络、存储、文件系统移植都会顺很多。踩过几次坑之后,我养成了一个习惯:每次移植新板子,先花半天时间把重定位日志和串口输出调通,后面再动其他外设,心里就有底了。

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

ggplot2火山图绘制避坑指南:数据预处理、图层顺序与标度设置

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

作者头像 李华
网站建设 2026/10/15 1:47:46

MP2642+STM32F746的2S电池主动均衡方案设计

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

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

requests文件下载进阶:流式传输、断点续传与踩坑指南

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

作者头像 李华
网站建设 2026/10/15 1:47:17

百度之星2020初赛第二场:算法竞赛中的建模降维与工程思维

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

作者头像 李华
网站建设 2026/10/15 1:46:35

MATLAB/Simulink Buck电路高保真仿真:基于S-Function建模开关非理想特性

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

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

KVM内存管理深度解析:超分、气球、KSM与大页调优实战

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

作者头像 李华