正面攻克NVMe阻塞启动之谜——RK3588移植内核6.12纪实"

admin 2026-10-24 11:30:33 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

正面攻克NVMe阻塞启动之谜——RK3588移植内核6.12纪实

原创

Cris 陈显森
Cris 陈显森

格友

2026年10月16日 22:29
上海

去阅读

同一台幽兰、同一块 S3000,5.10 能启动,6.12 却停在 Logo显示。取回现场数据后发现,PCIe 已枚举到 SSD,NVMe 控制器却始终未报告就绪:旧系统约 918 毫秒的等待,在 6.12 中持续 128 秒仍失败。保持同一份 Image,仅在设备树增加 SSD 重新供电前的关闭间隔后,原盘分区恢复,读写及正常重启校验通过。

这次排查从张银奎老师的一条消息开始。瑞芯微在 GitHub 发布了 Linux 6.12 分支,老师编译后在幽兰笔记本上试跑,发现 NVMe 硬盘识别不到分区。 正好我手头也有一台幽兰 RK3588 笔记本,便提出国庆假期在本地搭建 6.12 编译环境,尝试复现这个问题,参与调试。 张老师随后发来幽兰的设备树文件 rk3588-yourland.dts,以及瑞芯微的 develop-6.12 分支地址。[2] 设备树描述板上的硬件连接;这份文件从 5.x 内核沿用过来,张老师提醒其中有些配置可能还需要调整。

张老师反馈NVMe分区识别问题并提供幽兰设备树

我这台幽兰原本运行太乙系统的%20Linux%205.10.110,能够从%20NVMe%20硬盘正常启动。换用%206.12%20后,同一台机器、同一块硬盘,屏幕却停在%20Logo,迟迟没有出现登录界面。

屏幕保留着开机画面,内核的执行位置仍需通过启动日志和%20CPU%20状态确认。接下来的排查重点是%20Linux%20接管硬盘后的初始化过程。%20本次使用幽兰%20RK3588%20笔记本,启动盘为时创意(SCY)的%20S3000%20512%20GB%20固态硬盘,测试内核为基于瑞芯微源码构建的%20Linux%206.12.69。[13]

图%201:本次排查使用的时创意%20S3000%20512%20GB%20NVMe%20硬盘。

这块硬盘插在幽兰的%20M.2%20插槽上,硬盘与%20RK3588%20之间则通过%20PCIe(PCI%20Express)通信。PCIe%20是高速串行接口,显卡、网卡也常用它。[10]%20读写这块硬盘时,Linux%20驱动按%20NVMe%20协议提交命令,并取得执行结果。

一、从上电到进入系统的三个阶段

这台幽兰的内核和系统文件都保存在%20NVMe%20硬盘上。U-Boot%20负责把内核读入内存,Linux%20运行后再用自己的驱动访问这块硬盘。

1.%20启动代码和固件准备环境,启动%20U-Boot

电脑上电后,RK3588%20首先执行芯片内置的启动代码,再由后续固件准备内存等基本环境。环境准备完成后,固件启动%20U-Boot。

2.%20U-Boot%20加载内核和设备树

U-Boot%20按启动配置,从%20NVMe%20硬盘读取内核镜像 Image 和设备树文件 DTB,把它们放进内存。Image 包含%20Linux%20的代码,设备树记录主板的硬件配置,供内核初始化设备时使用。

后面的诊断实验还会让%20U-Boot%20加载%20initramfs,即初始内存文件系统。它的程序和文件都在内存中,可以在%20NVMe%20根分区挂载前使用。

文件加载完成后,U-Boot%20传入设备树等启动信息,并把执行权交给%20Linux。

3.%20Linux%20初始化硬件,启动系统服务

Linux%20开始运行后,建立自己的内存管理和中断环境,再初始化设备驱动。驱动根据设备树,取得%20PCIe%20控制器所需的时钟、复位资源,以及控制%20SSD%20供电的%20GPIO%20管脚。GPIO%20是可以由软件控制的通用管脚。

对于这块启动盘,PCIe%20主机驱动先初始化%20RK3588%20一侧的控制器,识别连接的%20SSD。NVMe%20驱动随后配置%20SSD%20内部的存储控制器,等待它报告就绪。控制器就绪后,Linux%20继续识别存储空间、注册块设备,并读取盘上的分区表。

找到保存系统文件的根分区后,Linux%20将它挂载,开始使用其中的程序和文件。内核接着启动系统服务管理程序%20systemd,由它启动网络、图形登录等服务。图形登录服务启动后,屏幕出现登录界面,用户登录后进入桌面。

下图展示从这块硬盘启动到进入桌面的顺序。图中只列出启动盘相关的步骤,其他设备的初始化可以并行进行。

Linux启动流程

图%202:本机从%20NVMe%20启动到进入桌面的主要步骤。

U-Boot%20已经从硬盘读出内核,Linux%20仍可能在初始化同一块硬盘时失败。 Linux%20使用自己的驱动,重新配置供电、复位和命令队列;U-Boot%20读文件成功只能说明前一阶段的访问正常。

显示控制器可以继续输出%20U-Boot%20留下的%20Logo,此时%20CPU%20可能已经执行%20Linux%20的代码。画面仍停在%20Logo,实际进度需要通过%20CPU%20指令地址或内核日志确认。

ARM64%20启动接口规定了固件准备内存、传入设备树和进入内核的要求。[1]%20本次使用幽兰原有的%20U-Boot%20启动方式。

二、PCIe%20设备枚举后,NVMe%20驱动等待控制器就绪 1.%20PCIe%20主机驱动准备通信资源,Linux%20枚举%20SSD

RK3588%20一侧的%20PCIe%20主机控制器负责与%20SSD%20通信。主机驱动准备时钟、复位和物理接口后,双方建立%20PCIe%20连接,系统才能读取设备标识并分配地址资源。这个发现和配置设备的过程称为枚举。

SSD%20内部的%20NVMe%20控制器执行存储命令。设备枚举后,Linux%20的%20NVMe%20驱动访问它的寄存器,设置命令队列并请求启用控制器,再读取状态寄存器等待就绪。

我们后来取得的现场显示,PCIe%20连接已经建立,SSD%20标识能够读到,连接参数为%20Gen3%20×4。接下来的%20NVMe%20初始化却停在了等待就绪这一步:驱动发出启用请求后,控制器一直没有报告%20Ready。Gen3%20×4%20描述连接速率和通道数量,Ready%20则表示%20SSD%20的存储控制器已经准备好处理命令。

瑞芯微的%20RK3588%20TRM(Technical%20Reference%20Manual,技术参考手册)给出了主机侧的实际结构。下面原图左侧是芯片内部互连,中间是五个%20PCIe%20控制器,右侧是负责物理信号收发的%20PHY。本次%20NVMe%20使用设备树中的 pcie3x4,对应图中的四通道控制器%20PCIe30X4;其他控制器及其%20PHY%20选择也画在图里,便于核对具体端口。

瑞芯微RK3588%20TRM中的PCIe控制器子系统

图%203:RK3588%20的%20PCIe%20控制器子系统。Interconnect%20为片上互连,Bridge%20为桥接逻辑,PHY%20为物理接口。图描述主机侧,SSD%20内部的%20NVMe%20控制器另由前文说明。[9]

PCIe%20主机初始化时,需要准备参考时钟和%20PHY,释放相关复位,再开始连接训练,由两端协商通信状态。设备枚举后,还要打开内存访问和总线主控权限。[9]

RK3588手册中的PCIe主机上电复位步骤

图%203a:手册中的主机上电复位步骤:释放复位后,需要等待一段时间,或检查可用的锁相环(PLL)锁定标志。[9]

2.%20驱动配置管理队列,再请求控制器启用

NVMe%20驱动写入寄存器,设置管理队列参数并请求启用%20SSD%20控制器;再读取状态寄存器,检查控制器的就绪状态。

为了访问这些寄存器,Linux%20将%20SSD%20的%20BAR%200%20地址资源映射成内核可访问的地址,之后用 readl()、writel() 读取状态、写入配置。BAR%20是%20Base%20Address%20Register,基地址寄存器,用于描述%20PCI%20设备的地址资源;按地址访问设备寄存器的方式称为%20MMIO,即内存映射%20I/O。

接下来,驱动在主机内存中分配管理提交队列和管理完成队列。SSD%20从提交队列读取命令,把执行结果写入完成队列。为了让%20SSD%20访问这两块内存,驱动把设备可用的队列地址写进控制器寄存器。

SSD%20对主机内存的访问使用%20DMA,即直接内存访问:CPU%20准备队列和地址,设备通过%20PCIe%20自己读取命令、写回结果。初始化使用的管理队列承担%20Identify%20等操作,Identify%20用于取得控制器及存储空间的信息。普通文件读写稍后使用另外的%20I/O%20队列。

这里有两个方向的访问:主机写入%20SSD%20控制器的寄存器,SSD%20控制器则通过%20DMA%20读取命令、写回数据和完成项。

NVM%20Express%20的规范用下面这幅图展示队列与控制器的关系。左侧是一对管理队列,右侧是普通%20I/O%20队列;队列位于主机内存,SSD%20控制器读取提交项并写回完成项。

NVM%20Express官方管理队列与I/O队列图

图%204:NVMe%20管理队列与%20I/O%20队列。Host%20表示主机,Controller%20表示设备控制器;图展示队列一对一对应的示例,实际队列数量由驱动与硬件共同决定。[6]

初始化时,有几个寄存器直接关系到我们的故障:

  • CC 是控制器配置寄存器,CC.EN 表示主机要求启用控制器。
  • CSTS 是控制器状态寄存器,CSTS.RDY 为%201%20时表示控制器已就绪。
  • AQA 保存管理队列的大小;ASQ 和 ACQ 分别保存管理提交队列、管理完成队列的设备可访问地址。

这些寄存器属于%20SSD,访问要经过%20PCIe。设备树中的 pcie-apb 则供驱动配置%20RK3588%20本地控制器、读取连接状态。这两组寄存器记录分别用于检查%20PCIe%20连接状态和%20NVMe%20控制器状态。

队列准备完成后,主机写入 CC.EN=1,表示要求启用控制器。这只是请求,SSD%20还要完成内部准备;驱动随后反复读取 CSTS,等待 RDY 变成%201。只有观察到就绪,才继续提交%20Identify%20管理命令。

NVMe规范对控制器Ready状态的判定

图%204a:规范以 CSTS.RDY=1 判定控制器已准备好处理命令。[6]

NVMe%20把设备暴露的存储空间称为%20namespace。Linux%20在找到%20namespace%20后注册块设备,再识别其中的分区。我们的原盘对应 nvme0n1,系统使用其中的第二个分区作为根分区。

NVMe初始化流程

图%205:NVMe%20初始化的主要控制顺序。CC、CSTS%20等字段来自%20NVMe%20寄存器定义;本次故障停在等待控制器就绪的步骤,驱动尚未提交第一条%20Identify%20命令。

3.%20管理命令通过后,文件读写才能开始

前面的初始化让%20Linux%20找到块设备和分区。接下来,应用的文件读写经过文件系统和块设备层,交给%20NVMe%20驱动。驱动先把命令写入主机内存中的%20I/O%20提交队列,再把更新后的队列尾部位置编号写入%20SSD%20的通知寄存器。该寄存器在%20NVMe%20规范中称为%20Submission%20Queue%20Tail%20Doorbell,以下简称%20Doorbell%20寄存器。SSD%20根据队列位置的变化,确定需要读取的新命令。[6]

SSD%20读取队列里的命令,按命令提供的地址传输数据,然后写入完成队列,并通过中断通知%20Linux。驱动处理完成项后,上层读写请求才能结束。命令、文件数据和完成结果分别保存在自己的内存区域,下图把它们分开画出。

NVMe读写数据流

图%206:正常%20I/O%20的逻辑数据流。实线表示请求、命令、数据和结果的传递;虚线表示驱动写入新队列位置通知%20SSD,或%20SSD%20通过中断通知驱动。数据方向随读写请求变化;完成通知也可由驱动轮询取得,图中展示使用中断的路径。

回到本次故障,驱动发出启用请求后一直等不到%20Ready,尚未提交第一条%20Identify%20命令,原盘的块设备和分区也还没有注册。我们因此先追踪启用请求和%20Ready%20等待。普通%20I/O%20队列、文件系统和桌面服务,要等这一步通过后再验证。

这些步骤对应%20NVMe%20PCI%20驱动的 pci.c 和%20NVMe%20核心代码的 core.c;文末列出了本次源码版本及两个文件的原始链接。[3]

三、挥码枪从内存中取回了启动现场

现在已经知道应当观察%20NVMe%20初始化,但机器停在%20Logo,没有进入可以运行采集命令的系统。通常这时会先接串口,把%20U-Boot%20和内核的文字输出送到另一台电脑。

但接上串口后,电脑虽然识别到了探针的%20USB%20设备,终端却长时间没有启动日志。我们排查了设备树的引脚配置、SD%20转接线和探针固件,仍没能稳定收到输出。于是先用挥码枪的%20SWD%20接口读取%20CPU%20状态和内存中的日志,定位%20CPU%20所在函数,并取回已经保存的启动记录。

挥码枪是格蠹公司的硬件调试探针,正式产品名称是%20Nano%20Target%20Probe,简称%20NTP。NanoCode%20是配合探针使用的调试%20IDE。电脑上的调试软件通过%20USB%20发出请求,探针再通过%20SWD(Serial%20Wire%20Debug,串行线调试)访问目标芯片的调试单元。本次专用%20SD%20转接线把幽兰的%20SWD%20信号引到探针,两根信号分别承担时钟和数据,另需共同的地参考。

Arm%20的调试访问端口称为%20DAP。主机调试软件通过它访问芯片上的调试资源;CMSIS-DAP%20则规定调试软件与探针之间的通信方式。对于本次已验证的%20RK3588%20连接,调试工具可以读取%20CPU%20寄存器和内存,也可以设置硬件断点、暂停%20CPU,再恢复执行。这个过程不依赖目标系统的%20SSH%20服务。

Arm%20的官方图展示了调试软件、探针与片上调试资源之间的连接:左侧%20Debugger%20是电脑上的调试软件,中间%20CMSIS-DAP%20是探针中的协议实现,右侧%20CoreSight%20是%20Arm%20的片上调试与跟踪组件。图中展示多种接口,本次实际连接使用%20USB%20和%20SWD。

Arm%20CMSIS-DAP官方接口图

图%207:Arm%20CMSIS-DAP%20接口图。Cortex-M、Cortex-A%20表示可连接的处理器类别。[5]

格蠹二代挥码枪的官方资料还给出了专用五芯线的实物图。N%20端接探针,T%20端接%20SD%20转接头,五根线分别传递%20SWD%20数据、SWD%20时钟、地参考以及串口收发信号。

格蠹二代挥码枪五芯SD转接线

图%208:格蠹二代挥码枪五芯线。SWD→DA3、SWK→DA2、GND→GND、TX→DA0、RX→DA1;SWK%20是探针上时钟脚的标记。[7]

挥码枪SWD调试连接

图%209:本次使用的两种路径。UART%20传递目标输出的文字;SWD%20访问芯片的调试资源。两者共用探针%20USB%20连接,但观察对象不同。图为按本机连接和%20Arm%20CMSIS-DAP%20说明绘制的原理示意。

Linux%20的 printk 日志会先写入内存,再通过串口等接口输出。即使串口终端没有文字,内存中仍可能保存着启动记录。

读取这些记录,需要准备与运行内核匹配的 vmlinux。启动用的 Image 是内核镜像,vmlinux 则保留调试所需的符号和类型信息。调试器利用这些信息,把%20CPU%20指令地址对应到函数,定位日志缓冲区并解析日志结构。换一个构建,这些地址和布局可能变化,因此每轮取证都保存对应产物。

本次使用%20NanoCode,也使用支持%20CMSIS-DAP%20的%20OpenOCD%20配合%20GDB,读取%20CPU%20状态并保存内存中的日志。板级连接按%20NanoCode%20的幽兰内核调试教程完成。[5]

驱动等待设备时,执行初始化的任务可以睡眠,CPU%20转去执行其他任务或进入空闲状态。因此,停核时看到空闲函数,还要结合%20NVMe%20日志和寄存器,核对驱动最后记录的初始化操作与当时的控制器状态。本次%20SWD%20读取的是主机%20CPU%20和内存,SSD%20内部的执行位置需要设备自己的调试资料。

暂停%20CPU%20会改变时序。测量%20NVMe%20等待时,我们让初始化先自然运行到成功或超时,再停核保存已经写下的记录,随后恢复执行。这样取得的是故障发生前的日志;停核期间经过的时间单独记录。

后来,我们确认旧探针固件处理%20USB%20设置波特率请求时会超时。按官方流程升级固件后,已经配置好的%206.12%20串口可以稳定双向收发,逐字节比对一致,重启日志也能连续保存。升级发生在%20NVMe%20首次修复之后:早期排查主要依靠%20SWD%20取回的数据,后续重启验证才有了连续的串口记录。

四、用%20ftrace%20和%20kprobe%20记录%205.10%20的正常初始化时序

通过%20SWD%20保存%206.12%20的失败现场后,我们开始采集正常系统的时序。原%205.10.110%20能从同一块盘启动,适合记录控制器启用、就绪位变化和首条管理命令完成的时间,再与%206.12%20的相应记录比较。

当时缺少完整的太乙定制内核源码,公开源码之间的差异无法代替旧系统的实际执行记录。好在旧内核提供了%20ftrace%20和%20kprobe,允许我们在保持原内核与设备树的条件下增加启动采集。

1.%20先记录初始化函数、寄存器值和管理命令

ftrace%20是%20Linux%20内核提供的跟踪设施。这里使用它的%20function%20tracer%20记录指定函数的进入顺序,又启用%20NVMe%20已有的事件,记录命令准备和完成。NVMe%20驱动的 nvme_probe 是识别和初始化设备的入口。记录保存在每个%20CPU%20的环形缓冲区中,新的事件会持续写入;空间耗尽时,旧数据可能被覆盖。

函数跟踪记录保存函数名称和进入时间,寄存器参数与读值则由动态探针采集。kprobe%20允许在指定函数或指令位置设置动态探针,命中时采集参数或寄存器;kretprobe%20用来记录函数返回值。我们把这些探针生成的事件也写入%20ftrace%20缓冲区。

观察方式 本次观察对象 记录内容
function%20tracer PCIe%20初始化、NVMe%20probe、控制器关闭与启用、Ready%20等待 函数进入顺序和发生时间
kprobe 寄存器写入请求、读取偏移和实际读值 寄存器偏移、写入值和实际读值
kretprobe 关闭、启用、等待函数的返回码 函数返回码,结合对应函数的定义解释退出结果
NVMe%20静态事件 Identify%20等命令的准备和完成 控制器%20Ready%20后的管理命令执行记录

采集寄存器值前,需要确认函数参数存在哪个%20CPU%20寄存器中,以及读取指令执行后,硬件读数保存在哪里。我们核对了目标函数的参数原型和反汇编指令。原内核保存的%20BTF%20类型信息补充了相关结构体字段的类型与偏移;参数所在的寄存器和读取指令的位置,则以这次构建的反汇编为准。

下面是本次太乙%20ARM64%20构建上验证过的%20CC%20写入探针:

p:ulan_cc%20nvme_pci_reg_write32%20c=%x0:x64%20o=%x1:x32%20v=%x2:x32

p 表示普通%20kprobe,这个定义没有指定指令偏移,因此位于函数入口;ulan_cc 是事件名。后面的三项分别采集控制器指针、寄存器偏移和写入值。我们核对过这个构建的函数原型,参数对应%20ARM64%20的%20x0、x1、x2。当偏移为 0x14 时,写入目标是%20CC,v 字段保存驱动传入的写入值。

读取%20CSTS%20时,还需要区分函数返回值和寄存器读数。nvme_pci_reg_read32() 的返回类型是 int,第三个参数 val 是保存读数的地址。所对照的瑞芯微%206.12%20源码中,这个函数的实现如下:[3]

static int nvme_pci_reg_read32(struct%20nvme_ctrl%20*ctrl,%20u32%20off,%20u32%20*val)
{
 *val%20=%20readl(to_nvme_dev(ctrl)->bar%20+%20off);
 return 0;
}

readl() 返回设备寄存器的%2032%20位读数,赋值语句将它写到 val 指向的位置,随后函数直接返回%200。实际读数为%200%20或%201%20时,函数都返回%200。因此,kretprobe%20记录到返回值%200,只能说明函数执行到了这个返回位置;判断%20Ready%20需要取得实际读数,再检查其中的 CSTS.RDY 位。把函数返回的%200%20当成%20CSTS%20的值,会把不同的寄存器状态都误记为%200。

在%205.10%20的实际采集中,我们检查原内核的机器指令,把值探针放在该函数的 +0x24 位置;在这个构建里,x1%20此时保存实际读值。这样,函数返回码与%20CSTS%20实际读值就分别保存在对应事件中。指令偏移随构建变化,换%20Image%20后要重新核对。参数取值和返回探针使用%20Linux%205.10%20的%20kprobe%20事件接口。[4]

2.%20在%20NVMe%20初始化之前启用采集

如果等%20SSH%20上线再打开%20ftrace,NVMe%20初始化早已结束。我们把%20function%20filter、NVMe%20事件和动态探针放到下一次启动的配置中,并保留原内核、设备树和根分区。

这次采集使用 trace_clock=mono,让所有%20CPU%20的事件采用统一的单调时间;每个%20CPU%20配置%201%20MiB%20缓冲区,八个%20CPU%20合计约%208%20MiB。选择指定函数和事件,避免把整个内核的函数调用全部灌进缓冲区。

下面按参数分行展示这次启动设置的部分内容;动态探针通过 kprobe_event 另行注册。它们要追加到保留的原启动参数中,具体文件写法取决于目标机的启动程序:

trace_clock=mono
trace_buf_size=1M
ftrace=function
ftrace_filter=rk_pcie_really_probe,rk_pcie_establish_link,nvme_probe,nvme_reset_work,nvme_disable_ctrl,nvme_enable_ctrl,nvme_wait_ready
trace_event=nvme:nvme_setup_cmd,nvme:nvme_complete_rq