从 bzImage 到嵌套虚拟机:用 QEMU/KVM 搭建 Subaru 车机调试环境
本文研究的 Subaru DENSO 车机是一套由多个操作系统和专用硬件共同组成的系统:Intel x86 平台上运行着 QNX Hypervisor、QNX Neutrino 和 Linux,应用又依赖图形、音频、网络和认证芯片等组件。直接在这样的量产设备上调试,首先就会遇到调试条件的问题:shell、串口和调试器的访问通常受限,一次服务崩溃或系统重启又可能打断车机的正常使用。要反复观察程序、比较配置和收集崩溃现场,一套独立于实车的实验环境会方便很多。
QEMU/KVM 给了我们一个把固件搬到电脑上运行的办法。这套环境可以围绕研究问题逐层搭建:研究 Linux 里的网络服务,就先运行原厂内核和根文件系统,通过串口控制程序;遇到认证芯片等硬件依赖,再在相应接口处加入实验替身;需要观察 QNX 启动、驱动和虚拟机管理时,继续补齐外层环境。屏幕、GPU、音频和车辆总线各有自己的驱动与依赖,可以随着研究目标逐步接入。本文沿着下面三个阶段展开:
| 实验阶段 | 启动内容 | 主要观察的问题 |
|---|---|---|
| 独立 Linux | 原厂 Linux 内核与 ext4 rootfs | 用户态服务、网络请求、进程状态和崩溃现场 |
| QNX 启动 | 精简的 QNX 宿主镜像与必要驱动 | 启动脚本、串口、设备发现和存储访问 |
| 嵌套 Linux | QEMU/KVM 中的 QNX Hypervisor,再由 qvm 启动 Linux | 虚拟化能力、guest 配置和内层启动过程 |
我们会从社区公开的 2024 年更新包开始,完成解包、镜像识别和启动,再把日志、core dump 与调试工具接入这套环境。过程中最费时间的,往往是原平台默认具备、搬到虚拟机后才显露出来的运行条件:一组内核参数、一个本地网络接口、接口函数写回的长度,或者宿主提供的虚拟化能力。文中会先给出整理后的操作步骤,再结合几次典型故障,复盘怎样从日志和调用栈找到线索,怎样用后续输出验证判断。
文章面向熟悉 PVE、QEMU 或普通 Linux 虚拟机,刚开始接触嵌入式固件的读者;IFS、接口替身和嵌套虚拟化等概念会随操作一起解释。
1. 先弄清楚我们要运行的是什么
1.1 车机的软硬件架构
本文使用的是 Subaru DENSO CP1.5 系列车机,采用 Intel x86 平台,固件中能找到 Apollo Lake 相关的启动代码和驱动。软件由 QNX Hypervisor/QNX Neutrino 与 Linux 共同组成:QNX 负责底层启动、驱动和一部分用户态服务,qvm 在这个环境中创建虚拟机,运行 Linux 内核及其应用。后面的调试会沿着这层关系展开:
真实车机的 Intel 平台
└── QNX Hypervisor / Neutrino
├── QNX 启动、驱动及用户态服务
└── qvm
└── Linux guest
├── Linux 4.9.52
├── ext4 rootfs
└── HMI、网络及其他应用服务
先把几个名称对应起来,后面读文件时会清楚很多。本样本的 Linux 发行信息是 ADIT embedded Linux Platform / Genivi6,这是我们在文中使用的名称;QNX 一侧,Neutrino 7 指运行环境,QNX6 指磁盘文件系统格式。更新包中的 FHI-sd.ifs 还提供了一套可用于辅助实验的 QNX 环境,下文会从中提取通用串口驱动和 PCI 模块。
1.2 可以先把 Linux 从原来的虚拟机里拿出来
如果目标是观察 Linux 里的某个服务,我们可以换一层虚拟硬件:
Linux 开发机
└── QEMU(TCG 或 KVM)
└── 原厂 Linux 内核 + 原厂 ext4 rootfs
这份 Linux 内核原本就运行在虚拟机里,已经带有 virtio 块设备和网络驱动。QEMU 可以提供相应接口,让原厂内核继续使用原厂 rootfs,我们就能先在熟悉的 Linux 环境中调试服务、检查网络和分析崩溃。等研究进入 QNX 驱动、qvm 或宿主启动流程时,再补上外层,构成下面的嵌套关系:
Intel 物理开发机 / PVE(L0)
└── QEMU + KVM
└── QNX Hypervisor(L1)
└── qvm
└── Linux(L2)
前一条路线适合快速建立用户态实验,后一条路线让我们观察宿主与 guest 之间的运行关系。本文也按这个顺序推进,前半篇得到的镜像识别、串口和现场收集方法,到后半篇仍然会用到。
1.3 调试需要什么,就先还原什么
观察一个 TCP 服务时,主要依赖内核、动态链接器、共享库、运行目录和网络接口;显示、音频、摄像头和车辆总线可以随着研究目标逐步加入。以图形为例,这台车机使用 Apollo Lake 图形硬件和 GVT-g 接口,接通画面还涉及驱动、图形库和应用配置,是一项值得单独展开的工作。我们先通过串口控制系统,通过正常网络请求判断服务状态,等执行路径确实遇到某个硬件依赖时,再选择补驱动、调整配置或编写接口替身。
把固件搬到另一种执行环境运行,通常称为 firmware rehosting。它的实用之处在于可以围绕具体问题安排仿真范围:研究协议,就先让协议服务接受输入;研究启动,就补齐对应的驱动和脚本。有关这种按目标重建环境的方法,可以参考 Jetset 的研究;汽车固件领域也有 MetaEmu 这样的工作。
2. 准备公开样本和实验目录
2.1 样本从哪里来
读者可以从社区分享的 June 2024 更新目录 开始,这个入口也列在 Gen6 Firmware 页面 的 June 2024、CP1.5 更新项下。目录中的 FM1WxM073-980.zip 包含不同车机配置的内容,本文使用其中的 ALLUPD-NAVI.pkg,包内各组版本为 FM1WMM073-980。以这个公开样本完成后续操作,就能建立文章中的基础实验环境。
下载后先记录文件大小和哈希,再用解包工具确认组版本。本文本地样本的指纹如下,可以据此选择同一基线;使用其他版本时,也可以沿着镜像类型、内核版本和启动配置逐项比较:
文件名:ALLUPD-NAVI.pkg
大小:4,667,449,176 字节
SHA-256:c95d1b431d56747fcdb9f6e06ebdee6d8e83bcce3bdc3729c12fc63d8b48b9ab
组版本:FM1WMM073-980
2.2 宿主环境
前半篇按 Debian 12 的 x86-64 Linux 环境编写;Ubuntu 上的软件包名通常相近。安装:
sudo apt-get update
sudo apt-get install \
qemu-system-x86 qemu-utils python3 file binutils e2fsprogs fdisk less \
gdb socat unzip gcc libc6-dev make git
建议准备至少 25 GiB 可用磁盘空间。解压更新包、导出镜像和保存现场都会占空间;如果保留多个完整镜像副本,预留更多。
记录环境:
uname -a
qemu-system-x86_64 --version
qemu-img --version
ls -l /dev/kvm
我最早的 Linux 实验使用 QEMU 7.2 的 TCG,在开发虚拟机里完成了内核启动和服务调试。TCG 以软件执行 guest 指令,适合先把流程跑通;KVM 借助处理器虚拟化能力执行同架构 guest,运行速度更快。两种方式都由 QEMU 提供内存布局、设备和外部接口。前半篇使用 TCG,读者可以先在现有 Linux 环境中操作;后半篇涉及 QNX 再创建虚拟机,会转到满足嵌套虚拟化条件的 Intel KVM 宿主。
2.3 文件布局
将文章配套的 scripts/ 和 examples/ 放到你选择的实验目录中,然后执行:
export LAB="$HOME/subaru-qemu-lab"
mkdir -p "$LAB"
cd "$LAB"
mkdir -p originals pkg work logs exports tools
后续命令都假设宿主当前目录是 $LAB。originals/ 保存下载文件,pkg/ 保存包内提取的原始内容,work/ 保存实验产物。
解开下载的 ZIP,找到包:
unzip -l originals/FM1WxM073-980.zip
unzip originals/FM1WxM073-980.zip -d originals/update
find originals/update -name ALLUPD-NAVI.pkg -print
如果列出多个包,选择对应 Mid 车机、内部版本与上述基线一致的那个。将路径赋给变量,例如:
export PACKAGE="$LAB/originals/update/你实际找到的目录/ALLUPD-NAVI.pkg"
sha256sum "$PACKAGE" | tee logs/package.sha256
只有上面这一行路径需要按 ZIP 的实际目录调整。
3. 从更新包中提取内核与文件系统
3.1 先列目录,再解包
ALLUPD-NAVI.pkg 是一个带大端序索引的容器。它包含组名、组版本、文件长度和文件偏移。我们提供一个只依赖 Python 标准库的解析器:
python3 scripts/unpack_pkg.py "$PACKAGE" > logs/package-index.json
python3 -m json.tool logs/package-index.json | less
确认组名和版本后提取:
python3 scripts/unpack_pkg.py "$PACKAGE" --extract pkg \
> logs/package-extract.json
python3 scripts/verify_hashlistex.py pkg/*/HASHLISTEX.DAT
第二个脚本会读取包内 HASHLISTEX.DAT,重新计算各文件的 CRC-32 并与记录比较,便于在启动前发现提取错误或内容损坏。完成后,先关注下面这些文件,其他语音数据和应用资源可以留到后续按需接入:
| 文件 | 类型 | 在实验里的用途 |
|---|---|---|
pkg/NAVIHVOS/bzImage | Linux x86 启动内核 | 独立 Linux 与嵌套 Linux 共用的内核输入 |
pkg/NAVILRFS/Linux_rfs.img | ext4 文件系统镜像 | Linux 的 / |
pkg/NAVIQRFS/QNX_rfs.img | QNX6 文件系统镜像 | 辅助 QNX 文件和用户态内容 |
pkg/NAVIHVOS/hvImage_abl_emmc.img | ext2 文件系统镜像 | 内部存放 QNX 宿主启动文件 |
pkg/NAVIHVOS/linux.config_* | qvm 文本配置 | 了解原平台如何安排 Linux guest |
pkg/NAVIVR/Linux_vr.img | ext4 文件系统镜像 | 语音数据;本次最小实验暂不接入 |
3.2 识别镜像的层次
执行:
file pkg/NAVIHVOS/bzImage \
pkg/NAVILRFS/Linux_rfs.img \
pkg/NAVIQRFS/QNX_rfs.img \
pkg/NAVIHVOS/hvImage_abl_emmc.img
fdisk -l pkg/NAVILRFS/Linux_rfs.img
dumpe2fs -h pkg/NAVILRFS/Linux_rfs.img
几个文件虽然都带着 .img 后缀,内容却处在不同层次。Linux_rfs.img 从 ext4 文件系统的起点开始,可以直接作为 QEMU 的第一块 virtio 盘,guest 中整块设备 /dev/vda 就承载着根文件系统,所以启动参数写 root=/dev/vda。hvImage_abl_emmc.img 的外层则是 ext2,需要先从里面提取启动文件,再处理该文件的包装和 ELF 结构。读懂更新容器、整盘、分区文件系统、启动镜像和目录归档之间的关系,后面就容易判断一个文件该交给 QEMU、挂载工具,还是解包程序。
3.3 读取内核与发行信息
ext4 镜像可以用 debugfs 直接读取:
debugfs -R 'cat /etc/os-release' pkg/NAVILRFS/Linux_rfs.img
本样本包含:
ID="mel-automotive-adit"
NAME="ADIT embedded Linux Platform - Genivi6 - MGC drop COS2"
VERSION_ID="2017.02"
file bzImage 可以看到 4.9.52-i915-gvt_qnx-gdecfd5b 内核版本,qvm 配置则提供了 virtio 设备、根设备和原机内核命令行。它们解释了这份内核适合如何重新启动:virtio-blk、virtio-net、ext4 和串口控制台等驱动都已编译进内核,可以在启动过程中直接找到并挂载根文件系统,因此本文可以省去额外的 initramfs。这个检查思路也适用于其他固件:先确认启动所需的驱动位于内核还是模块中;采用模块的系统,则由 initramfs 在挂载根之前完成加载。
4. 第一阶段:原厂 Linux 内核与 rootfs 直接启动
4.1 第一条启动命令
在宿主执行:
qemu-system-x86_64 \
-machine pc -accel tcg -cpu max -m 2048 -smp 2 \
-kernel pkg/NAVIHVOS/bzImage \
-drive file=pkg/NAVILRFS/Linux_rfs.img,format=raw,if=virtio,snapshot=on \
-append 'root=/dev/vda rootfstype=ext4 rw rootwait console=ttyS0,115200 init=/bin/sh virtionet=a0:b0:c0:d0:e0:f1;eth_inter;10.0.2.15;255.255.255.0' \
-netdev user,id=n0,hostfwd=tcp:127.0.0.1:17000-:7000 \
-device virtio-net-pci,netdev=n0,mac=a0:b0:c0:d0:e0:f1 \
-display none -serial stdio -monitor none -no-reboot
预期先看到 Linux 启动输出,然后得到 shell。可能出现:
/bin/sh: can't access tty; job control turned off
这是当前 PID 1 shell 关于终端作业控制的提示。此时内核已经挂载根文件系统并执行了 /bin/sh,我们可以开始输入命令、补齐运行环境。先结合启动结果看一下命令中的主要参数:
| 参数 | 为什么需要 |
|---|---|
-kernel / -append | 直接提供内核和内核命令行,省去制作启动盘 |
if=virtio / root=/dev/vda | 选择内核已支持的虚拟磁盘接口和根设备 |
init=/bin/sh | 挂载根后先执行 shell,便于逐项补齐运行环境 |
console=ttyS0,115200 | 把内核控制台接到虚拟串口 |
snapshot=on | 把这块盘的写入放进临时写层,退出后丢弃 |
-netdev user | 提供 QEMU 用户态网络,省去先配置桥接和 TAP |
hostfwd=... | 宿主的 127.0.0.1:17000 转到 guest 的 TCP 7000 |
这里将根盘设为 snapshot=on,本轮运行产生的写入进入临时写层,QEMU 退出后即可重新从相同基线开始。后面会再接入一块持久写入的磁盘保存日志和 core,让系统状态与实验输出各有去处。有关内核、根盘和命令行如何配合,也可以对照 QEMU Direct Linux Boot 文档。
4.2 复盘:加上一块网卡,内核为什么就 panic 了
上面的命令已经包含调试后确定的 virtionet= 参数。我的早期命令只提供了根设备、串口和 init=/bin/sh,再让 QEMU 加上一块 virtio 网卡。对于熟悉普通 Linux 镜像的人,这样的配置很自然:先进入 shell,随后用 ip 配置接口和地址。但这份内核在网络初始化期间就 panic,连手工配置的机会都没有留下。
这时最有用的信息是 panic 所在的阶段和调用链。程序还没有进入用户态,串口中与故障相关的调用可以概括为:
do_setup_if → ic_setup_if_routes → mac_pton(NULL)
mac_pton 负责把文本形式的 MAC 地址转换为字节,这里收到的却是空指针。顺着它向上看,内核正在执行一段网络自动配置逻辑。这个线索把注意力引向了原机怎样提供网络配置:我们在包内的 qvm 配置中找到了 virtionet=,它是厂商增加的内核命令行接口,将 MAC、接口名、IPv4 地址和掩码一起传进去:
virtionet=<MAC>;<接口名>;<IPv4 地址>;<掩码>
原平台的启动流程一直会传入这组值,定制代码也沿用了这个前提;我们单独取出内核时,把这项依赖遗漏了。补回参数,让其中的 MAC 与 QEMU 网卡一致,再把地址改到实验使用的 10.0.2.0/24 网段,内核便能跨过原来的故障位置,进入 shell。这里完成修复的是启动配置,原厂内核可以继续使用。
这次经历也改变了我阅读固件配置的方式。qvm 的配置文件除了说明设备如何连接,还保存了厂商内核预期接收的输入。把其中的命令行与最小启动命令逐项比较,往往比先尝试更换虚拟硬件更容易找到线索。后续换一个内核构建时,我会先做同样的比较,再决定沿用哪些参数。
4.3 补齐最小启动环境
后续本节命令在 guest shell 中执行:
export PATH=/sbin:/usr/sbin:/bin:/usr/bin
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mkdir -p /var/volatile/tmp /var/volatile/log /run /data/tel
ip link set lo up
这些命令补上了原来由 init 和启动脚本负责的基础环境:/proc 和 /sys 为工具提供进程、设备和网络状态,易失目录容纳临时文件与日志,loopback 则支撑本地通信。样本里的 /tmp 指向 /var/volatile/tmp,所以需要把目标目录一起建好。若 proc 或 sysfs 已由其他初始化步骤挂载,用 mount 查看现状后继续即可。
init=/bin/sh 让我们直接接管 PID 1,便于手工启动服务、观察每一步的结果。原 init 承担的服务依赖、进程回收和关机管理,需要随实验目标逐步补齐;等到研究 systemd unit、服务重启或 D-Bus 时,再恢复相应的启动流程。先通过下面的命令确认内核、根盘和文件版本:
uname -a
cat /proc/cmdline
cat /etc/os-release
mount
ip addr show
sha256sum /bin/carplayd /lib64/libcarplay-plugin-core.so
共享库的实际位置随构建可能是 /lib64、/lib 或 /usr/lib64,可以通过 ls 和程序加载结果确认。把版本和哈希随实验日志一起保存,后面分析 core 时就能找到对应的程序和库。现在第一个阶段已经完成:原厂内核启动、根文件系统挂载,shell 可以读取系统信息,接下来可以把注意力放到用户态服务上。
5. 第二阶段:让一个原厂服务真正处理请求
5.1 从单个服务开始
我选用固件中的 carplayd 作为例子。这里仅发送正常的 OPTIONS 请求,检查服务是否能够处理网络输入。
在 guest 中执行:
export XDG_RUNTIME_DIR=/tmp
export LD_LIBRARY_PATH=/usr/lib64
/bin/carplayd >/tmp/carplayd.log 2>&1 &
SERVICE_PID=$!
echo "$SERVICE_PID"
sleep 2
tail -40 /tmp/carplayd.log
启动时直接运行程序即可;这里的 -v 用来输出版本并退出,详细的运行状态从日志和进程信息中观察。日志里会出现摄像头、媒体节点等缺失的错误,我们可以把它们与当前目标联系起来判断:先查看进程是否继续运行、网络监听是否建立,再沿着确实阻塞服务的依赖往下排查。这样每一项环境调整都有对应的现象和验证方法。
5.2 复盘:CoreFoundation 的崩溃,最后落到了一条网络命令
内核能进入 shell 之后,故障就转到了用户态。早期一次运行中,carplayd 在启动阶段退出,调用栈落在 CoreFoundation 的 CFRunLoopAddSource。单看这个位置,问题像是事件循环对象或调用参数出了差错;但我们刚刚接管了原 init 的工作,许多原本由启动脚本准备的条件还没有恢复,库内部的崩溃也可能是更早一次初始化失败的后果。
继续沿初始化过程往回看,原因逐渐具体起来:CFRunLoopEnsureInitialized 要绑定一个 127.0.0.1 本地地址,loopback 尚未启用使这一步失败,CFRunLoopGetCurrent() 随后返回 NULL,而后续代码仍然拿这个结果调用 CFRunLoopAddSource。把与这次故障有关的先后关系展开,就能看到环境缺口怎样传到最终的崩溃位置:
loopback 尚未启用
→ CFRunLoop 初始化时绑定 127.0.0.1 失败
→ CFRunLoopGetCurrent() 返回 NULL
→ 后续 CFRunLoopAddSource 使用空对象,进程崩溃
这里值得核对的是 guest 自己的本地网络。QEMU 的端口转发和 virtio 网卡处理宿主发来的请求,程序内部使用的 127.0.0.1 则依赖 guest 的 lo 接口。在 guest 中运行 ip addr show lo 就能检查这层条件;补上前面那条 ip link set lo up,再启动服务,原来的初始化崩溃便消失了。接下来才能继续检查监听和正常请求。
修复只用了一条命令,定位时却需要把崩溃栈往前追到对象创建的地方。使用 init=/bin/sh 时,我们接过了原启动脚本的责任:临时目录、proc/sysfs 和 loopback 都要逐项准备。它们看起来是环境搭建里的小事,程序却会把这些条件当成既成事实。第 6 节会把进程状态、系统调用和 core 的观察方法补齐,遇到类似故障时,就能从“进程退出了”继续追到具体的失败操作。
5.3 先确认监听,再检查端口转发
guest 里检查:
pidof carplayd
grep -i ':1B58' /proc/net/tcp
iptables -L INPUT -n -v
1B58 是 7000 的十六进制,/proc/net/tcp 中 0A 表示 LISTEN;有 ss -lntp 时,也可以直接查看更易读的输出。监听建立后再看防火墙:本样本的规则区分无线接口和内部接口,而实验网卡叫 eth_inter,需要为这个入口安排对应的放行规则。在 guest 中增加下面这一条:
iptables -I INPUT 1 -i eth_inter -p tcp --dport 7000 -j ACCEPT
回到 宿主 的另一个终端:
cd "$LAB"
python3 scripts/probe_rtsp.py --wait 30
预期得到以此开头的响应:
RTSP/1.0 200 OK
服务从进程出现到建立监听还需要初始化时间,--wait 30 会在这段时间内重试正常请求,直到收到有效响应。这次请求依次经过宿主 loopback 端口、QEMU 转发、guest 网卡、防火墙、监听 socket 和应用处理逻辑,RTSP 200 响应把整条路径连了起来。它也比单独检查 TCP connect 更有用:QEMU 能先接受连接,而应用层响应可以进一步确认 guest 中的服务已经开始处理输入。第二个阶段到这里完成,原厂服务已经能够接受宿主发来的普通请求。
5.4 复盘:给 MFi 认证芯片做替身,先把 ABI 对齐
收到 OPTIONS 响应之后,基础网络路径已经连通。继续观察会话初始化时,才碰到了另一个层次的依赖:车上的 CarPlay 服务会通过 libipod-auth.so 访问 MFi 认证芯片,请它提供证书和签名,底层涉及 I²C/GPIO。QEMU 的虚拟 PC 环境里缺少这颗芯片,早期实验中的认证请求因此返回 403 Forbidden。前面排查过的地址、接口和防火墙已经足以让请求到达服务,这次要补的是应用调用的硬件接口。
这条依赖在用户态有一个很清楚的边界:MFiPlatform_Initialize、MFiPlatform_CopyCertificate、MFiPlatform_CreateSignature 等动态导出函数。我们可以围绕这些函数实现一个共享库,作为硬件接口的实验替身,也就是 stub。启动时通过 LD_PRELOAD 指定这个库,动态链接器便会优先用其中的同名函数解析相应调用。这样,程序仍然从原来的调用位置取得返回值,我们把需要准备的环境缩小到了这一组接口:
原来的调用:CarPlay 服务 → MFiPlatform_* → libipod-auth → I²C/GPIO → 认证芯片
实验中的调用:CarPlay 服务 → 同名的 MFiPlatform_* 替身 → 占位证书和签名
当时的 stub 为初始化和结束操作返回相应状态,为证书、签名接口分配缓冲区,再通过输出参数返回指针和长度。实验客户端跳过了证书和签名的真实性校验,因此我们可以用占位数据观察硬件接口之后的服务流程;正常 CarPlay 客户端还会校验这些数据。编写替身时,返回缓冲区的所有权也要和原接口一致,因为调用方还会在使用完之后释放它。
第一版替身又带来了一个新的崩溃。输出的内容已经放进缓冲区,长度也看似写回了,后续加密处理却读到了一个异常大的长度。继续核对原函数的反汇编,才发现容易混淆的两件事:函数内部读取的长度字段是 16 位,但它会先把这个值零扩展,再用 64 位写入调用方提供的输出地址。替身只写了低两字节,高六字节还保留着旧内容,调用方按完整的 64 位读取时,就得到了另一个数。
以 128 字节为例,在这个 x86-64 小端环境中,两种写法留下的内存可以表示为:
只写 16 位:80 00 ?? ?? ?? ?? ?? ??
写满 64 位:80 00 00 00 00 00 00 00
修正时,把两个替身函数定义中的输出长度指针都改成 uint64_t *,让实现中的 *out_len = ... 写满八字节。用函数声明概括,这项修改就是:
- int MFiPlatform_CopyCertificate(void **out_ptr, uint16_t *out_len);
+ int MFiPlatform_CopyCertificate(void **out_ptr, uint64_t *out_len);
- int MFiPlatform_CreateSignature(const void *data, uint64_t data_len, void **out_ptr, uint16_t *out_len);
+ int MFiPlatform_CreateSignature(const void *data, uint64_t data_len, void **out_ptr, uint64_t *out_len);
补齐写入宽度后,这次由 stub 制造的巨大长度和越界崩溃就消失了。这个错误离最终崩溃位置有一段距离:接口返回时少写了六个字节,后面的代码却一直把它当成正常输出使用。因此,核对替身时要把调用方和原函数放在一起读,确认参数宽度、指针层级、内存释放方式和返回语义。这里涉及的约定通常称为 ABI(应用二进制接口);即使函数名相同、返回值看起来正确,这些约定仍然决定了替身能否与原程序配合。
教程中的正常 OPTIONS 请求已经可以用原程序完成,MFi 这段复盘对应的是进一步接入硬件依赖时的工作。接下来回到已能响应请求的服务,为它建立稳定的串口、日志和现场收集通道。
6. 给调试环境补上观测和输出通道
6.1 将串口从当前终端分离
第一次启动时,交互终端能让我们直接看到输出并尝试命令;进入重复实验后,更方便的方式是把串口交给宿主程序,由它发送命令、读取退出码,并保存完整日志。先在 guest 中停止刚才启动的服务并同步文件系统,随后换一种串口连接方式重启 QEMU:
kill "$SERVICE_PID"
sync
在当前使用 -serial stdio 的命令下,宿主终端的 Ctrl+C 会结束 QEMU,根盘也随临时写层恢复到原样。重新启动时,把串口放到 TCP socket,把 monitor 放到 Unix socket:
qemu-system-x86_64 \
-machine pc -accel tcg -cpu max -m 2048 -smp 2 \
-kernel pkg/NAVIHVOS/bzImage \
-drive file=pkg/NAVILRFS/Linux_rfs.img,format=raw,if=virtio,snapshot=on \
-append 'root=/dev/vda rootfstype=ext4 rw rootwait console=ttyS0,115200 init=/bin/sh virtionet=a0:b0:c0:d0:e0:f1;eth_inter;10.0.2.15;255.255.255.0' \
-netdev user,id=n0,hostfwd=tcp:127.0.0.1:17000-:7000 \
-device virtio-net-pci,netdev=n0,mac=a0:b0:c0:d0:e0:f1 \
-chardev socket,id=console,host=127.0.0.1,port=17001,server=on,wait=off,logfile=logs/linux-serial.log \
-serial chardev:console \
-monitor unix:work/linux-monitor.sock,server=on,wait=off \
-display none -no-reboot
QEMU 前台暂时没有输出是正常的:串口已搬到另一个连接。另开宿主终端:
cd "$LAB"
python3 scripts/serial_cmd.py 'echo GUEST_READY'
python3 scripts/serial_cmd.py 'cat /proc/version'
tail -f logs/linux-serial.log
serial_cmd.py 给每次命令附加随机完成标记和 $?,收到标记展开后的退出码才返回。这样就能区分终端回显和命令完成:回显中保留的是发出的文本,shell 执行后才会产生带具体退出码的标记。这种方法适合逐条运行能够返回的 shell 命令,超时则提示我们进一步查看串口日志;涉及交互式输入时,可以直接换用 socat 接管终端:
socat STDIO,raw,echo=0,escape=0x1d TCP:127.0.0.1:17001
按 Ctrl+] 退出 socat,即可将串口交回 serial_cmd.py。这个连接方式让交互操作和脚本操作可以轮流使用同一个入口,调试时也能保留统一的串口日志。
6.2 monitor 和串口是两条不同的通道
连接 monitor:
socat - UNIX-CONNECT:work/linux-monitor.sock
在其提示符中输入:
info status
info cpus
info registers
这两条通道各有用途:串口接到 guest 的 shell,适合读取进程、文件和网络状态;monitor 接到 QEMU,适合查看虚拟 CPU 和整台机器的运行状态。即使 guest 的 shell 已经失去响应,我们仍可以从 monitor 中读取寄存器、暂停执行或用 quit 结束虚拟机。上面使用的是交互式 HMP,自动化控制还可以使用结构化的 QMP 接口;选用哪一种,就按相应接口的格式发送命令。
6.3 加一块专门保存实验输出的盘
我们希望根盘每次恢复原样,而日志、工具和 core 可以留下。最容易理解的办法是添加第二块磁盘:
/dev/vda 原始 Linux rootfs 的临时写层
/dev/vdb 实验输出盘,保存工具、日志和 core
先结束 QEMU。在宿主准备一个带调试符号的练习程序和输出盘:
mkdir -p work/share-seed/bin work/share-seed/cores
gcc -static -g -O0 -o work/share-seed/bin/abort-demo examples/abort-demo.c
truncate -s 512M work/share.img
mkfs.ext4 -F -O ^metadata_csum_seed,^orphan_file \
-d work/share-seed work/share.img
这里用 Debian 12 的 e2fsprogs 制作 ext4,并关闭较新的 metadata_csum_seed 和 orphan_file 特性,让输出盘适配 guest 的老内核。-d 直接用目录内容填充文件系统,准备过程很方便;采用其他版本的 e2fsprogs 时,可以先查看支持的特性名。挂载时若报出格式或特性错误,也就有了一条明确的排查方向。
盘中的 abort-demo 是专门为这个练习编写的小程序:它保留调试符号,主动触发 SIGABRT,方便我们观察完整的 core 生成过程。这里采用静态链接,把练习所需的运行库一起放进程序,使它能直接运行在 guest 的旧用户态环境中。随后,在上一条 QEMU 命令的第一块 -drive 后增加输出盘:
-drive file=work/share.img,format=raw,if=virtio \
这块盘采用持久写入方式,程序和 core 会随磁盘保留下来。启动后重新执行第 4.3 节的初始化,再在 guest 中挂载:
mkdir -p /mnt/share
mount -t ext4 /dev/vdb /mnt/share
ls /mnt/share/bin
6.4 采集 core dump 并还原调用栈
在 guest 中:
mkdir -p /mnt/share/cores
ulimit -c unlimited
echo '/mnt/share/cores/core.%e.%p.%t' > /proc/sys/kernel/core_pattern
cp /mnt/share/bin/abort-demo /tmp/abort-demo
/tmp/abort-demo
ls -lh /mnt/share/cores
sync
运行后会看到程序打印的 marker、SIGABRT 退出提示,以及生成的 core.abort-demo.* 文件。这几步把现场收集安排得很明确:ulimit 为当前 shell 启动的子进程设置 core 大小限制,core_pattern 指定输出位置与文件名,程序则复制到 guest 的 /tmp 中执行。以后调试原厂服务时,也可以从配置好的 shell 启动它,让服务继承相应限制。收集完成后,在 guest 中同步并卸载输出盘:
sync
umount /mnt/share
然后在 QEMU monitor 中 quit。输出盘不再被 QEMU 使用后,在宿主读取:
debugfs -R 'ls -l /cores' work/share.img
debugfs -R 'rdump /cores exports' work/share.img
find exports -type f -name 'core.abort-demo.*' -print
选择刚刚产生的文件,交给 GDB:
gdb work/share-seed/bin/abort-demo exports/cores/实际的core文件名
GDB 中:
bt
thread apply all bt
info registers
frame 2
info args
list
本次复验中,bt 的第 2 帧是 finish_experiment(marker=2024)。找到这个函数所在帧后,info args 能读到参数,list 可以显示对应源码;不同构建的帧编号可能变化,按自己的回溯结果选择即可。到这里,“运行、收集现场、导出、离线分析”已经连成一条完整流程,换成原厂程序时,主要增加的准备就是保存对应版本的可执行文件、动态链接器和共享库。可以将原始 ext4 镜像只读挂载,作为 GDB 的分析环境:
mkdir -p work/linux-root
sudo mount -t ext4 -o ro,noload,loop \
pkg/NAVILRFS/Linux_rfs.img work/linux-root
在 GDB 中将 set sysroot 指向这个目录,再加载程序与 core,调试器就能使用同一构建的运行库解释现场。这里的 ext4 挂载参数包含 noload,让只读分析保持镜像当时的日志状态。整个数据流也遵循同样的顺序:guest 写入并同步,卸载输出盘,结束 QEMU,再由宿主读取;每一步都有明确的文件系统使用者。
6.5 用进程状态和协议响应判断服务健康
我最后通常会同时看几项指标:
| 观察 | 能回答的问题 |
|---|---|
| QEMU 进程存在 | 外层模拟器是否仍在运行 |
| guest 串口命令有退出码 | guest 用户态是否还能调度并执行命令 |
| 目标 PID 和重启记录 | 服务是否退出,是否被重新启动 |
| LISTEN socket | 服务是否建立了监听端口 |
| 正常请求与有效响应 | 当前应用路径是否真正工作 |
| 线程状态、日志和 core | 超时或退出发生在哪里 |
例如在 guest 中记录:
pidof carplayd
ps -T -p "$(pidof carplayd)"
cat /proc/"$(pidof carplayd)"/status
ls -l /proc/"$(pidof carplayd)"/fd
这些信息可以组合起来判断故障发生在哪一层。/proc/<pid>/task/ 下每个线程的 status 和 wchan 能补充 ps 的输出,帮助我们看出线程是在等待 socket、等待其他事件,还是进程已经退出。接下来若要知道程序在访问哪个文件、建立什么连接,可以停止当前实例,改从 strace 下启动:
strace -f -tt -s 128 -e trace=network,file \
-o /tmp/carplayd.strace /bin/carplayd
-f 跟踪子执行上下文,-tt 给出时间,网络和文件调用则让我们顺着系统调用看到程序在找什么、连到哪里。比如日志中同样表现为启动失败,调用记录可以进一步区分设备文件缺失、共享库加载失败和连接超时。为了方便重复实验,还可以加一个简单 supervisor 自动重启服务,并记录每次启动时间、PID 和退出状态;把这些记录与请求时间对应起来,就能识别服务经历过的崩溃和恢复。
6.6 进程调试与内核调试
GDB 的连接位置取决于要观察的执行上下文。调试用户态进程,可以使用 guest 中的 GDB,或者放入与目标 ABI、glibc 兼容的 gdbserver,再从宿主连接;这样调试器围绕一个进程展示线程和地址空间。调试内核启动、驱动初始化时,则连接 QEMU 提供的 gdbstub,从虚拟 CPU 的角度观察执行。在 QEMU 启动参数中增加下面两项,就能让它暂停并等待 GDB:
-gdb tcp:127.0.0.1:1234 -S \
宿主连接:
gdb
set architecture i386:x86-64
target remote 127.0.0.1:1234
info registers
info threads
continue
-S 让 CPU 在开始执行前等待,因此连接后要先用 continue 推进启动。这个入口展示的是虚拟 CPU 的寄存器和执行状态,info threads 中的线程也主要对应 vCPU;再配上与 guest 匹配的 ELF 和符号信息,就能按内核函数名定位。前面启动用的压缩 bzImage 负责引导,而按源码调试通常使用相应的 vmlinux。我的研究主要靠串口、进程状态和 core 完成定位,gdbstub 可以作为继续深入启动与驱动问题的入口,具体用法可参考 QEMU GDB 文档。
7. 第二条路线:先让 QNX 本身在 QEMU 中运行
7.1 IFS、startup 和磁盘 rootfs
前面的 Linux 启动过程可以理解为内核找到块设备、挂载 ext4 根,再执行 init。QNX 则把 startup、内核、启动脚本和最先需要的一批驱动、程序一起打包成 IFS(Image Filesystem),在早期加载到内存中。IFS 负责建立基础运行环境,再启动其他驱动、挂载后续文件系统;更新包中的 QNX6 rootfs 就是这些磁盘内容的一部分。理解这两者的先后关系,读启动脚本时就容易看出程序和数据来自哪里,具体组成也可以对照 QNX 的 IFS 说明。先在宿主中,从外层 ext2 提取启动文件:
debugfs -R 'ls -l /' pkg/NAVIHVOS/hvImage_abl_emmc.img
debugfs -R 'dump /hvImage_abl_emmc work/hvImage.wrapped' \
pkg/NAVIHVOS/hvImage_abl_emmc.img
本样本内部文件带有 36 字节的 ipk. 包装。下面先检查标记,再写出 ELF:
python3 - <<'PY'
from pathlib import Path
data = Path('work/hvImage.wrapped').read_bytes()
assert data[:4] == b'ipk.', '不是本文识别过的外层包装'
assert data[0x24:0x28] == b'\x7fELF', '预期位置不是 ELF,请重新检查样本'
Path('work/hv-original.elf').write_bytes(data[0x24:])
PY
file work/hv-original.elf
readelf -h -l work/hv-original.elf
readelf 会显示一个带 Multiboot 信息的 ELF32 启动包装,startup 随后负责进入 64 位 QNX。这个包装给加载器描述入口和加载段,操作系统的后续执行模式则由启动代码安排。我们先把提取出的原始 ELF 交给 QEMU,观察它能推进到哪里。
7.2 从 CPU 状态确认 QNX 的启动进度
qemu-system-x86_64 \
-machine pc -accel tcg -cpu max -m 8192 -smp 4 \
-kernel work/hv-original.elf \
-display none -serial stdio \
-monitor unix:work/qnx-original-monitor.sock,server=on,wait=off \
-no-reboot
这次启动很可能停留在安静的终端上:原厂镜像期待的是自己的存储和外设,串口也沿用原车配置。此时可以另开终端连接 monitor,读取 CPU 状态;我的早期实验能看到执行已经进入 QNX 的高地址区域,随后处于 HLT 等待。这给出了一个有用的进度点:启动包装已经执行,下一步应当把 QNX 的控制台和启动依赖接通,再从用户态日志判断它在等什么。结束这台 QEMU 后,我们就来制作一份带交互串口的实验镜像。
7.3 准备 IFS 工具与通用驱动
这里使用开源的 qnx_dumpers 中的 ifsdump,后面的列目录、提取和展开命令都按这个工具编写。QNX SDP 也提供官方的 dumpifs;采用其他工具时,可以对照各自的参数完成同样的操作。先安装依赖、构建本文使用的版本,并记录提交号:
sudo apt-get install zlib1g-dev liblzo2-dev libucl-dev
git clone https://github.com/jtang613/qnx_dumpers.git tools/qnx_dumpers
git -C tools/qnx_dumpers checkout 68424a67efbcc0ff0f63e45223a530a0f3bde3fe
make -C tools/qnx_dumpers ifsdump
git -C tools/qnx_dumpers rev-parse HEAD > logs/qnx-dumpers-revision.txt
从宿主文件得到未压缩镜像和目录清单:
tools/qnx_dumpers/ifsdump work/hv-original.elf > logs/hv-ifs-list.txt
tools/qnx_dumpers/ifsdump -u work/hv-uncompressed.img \
work/hv-original.elf
还需要辅助 FHI-sd.ifs 中的通用 8250 串口驱动和 PCI 模块。先只读挂载包内的 QNX6 文件系统:
sudo modprobe qnx6
mkdir -p work/qnx-root
sudo mount -t qnx6 -o ro,loop pkg/NAVIQRFS/QNX_rfs.img work/qnx-root
cp work/qnx-root/dos/FHI-sd.ifs work/FHI-sd.ifs
sudo umount work/qnx-root
tools/qnx_dumpers/ifsdump -x -d work/fhi work/FHI-sd.ifs
这一步依赖宿主内核的 QNX6 文件系统模块,modprobe 与 mount 的结果可以帮助确认支持情况;也可以在已有的 QNX 环境中提取同一个 FHI-sd.ifs,再回到宿主继续展开。它在这里相当于一份驱动来源,其中的通用 8250 串口驱动和 PCI 模块正好适合我们的虚拟 PC 环境。确认提取出的两个文件:
file work/fhi/sbin/devc-ser8250 \
work/fhi/lib/dll/pci/pci_hw-Intel_x86.so.2.0
7.4 制作一个专门用于实验的宿主镜像
这份实验镜像保留 QNX 内核、qvm 和必要驱动,把两个传统 PC 串口接通,并将存储驱动和 Linux 的启动交给我们手工控制。图形、音频及原车自动挂载等服务先暂停,这样每次添加设备或启动程序时,都能直接观察它带来的变化。配套脚本从刚才提取的文件生成这个环境:
python3 scripts/prepare_qnx_host.py \
--elf work/hv-original.elf \
--uncompressed work/hv-uncompressed.img \
--fhi-dir work/fhi \
--kernel pkg/NAVIHVOS/bzImage \
--config examples/linux-qvm.conf \
--output work/hv-lab.elf
脚本会生成 ELF 和一份 JSON 修改清单,后者记录替换的条目、长度和文件哈希,便于对照这轮实验使用的内容。重复尝试时,可以给输出取一个新名字,保留不同配置。主要调整如下:
| 调整 | 目的 |
|---|---|
用通用 devc-ser8250 代替原来的串口程序内容 | 使用 QEMU 能提供的传统 PC 串口 |
| 使用辅助镜像的通用 Intel x86 PCI 模块 | 减少对 APL 专用 PCI 模块的依赖 |
| 将早期脚本改成建立串口 shell | 为实验提供独立的控制通道 |
| 暂停图形、音频及原厂自动挂载等服务 | 将启动依赖集中到本次接入的设备 |
| 把 Linux 内核和精简 qvm 配置放进实验 IFS | 从内存中的文件直接启动 guest |
| 更新大小、压缩标记、ELF 加载段和 imagefs 校验和 | 让加载器按修改后的镜像布局工作 |
这里的修改集中在驱动选择、脚本和镜像布局,qvm 保持原版。IFS 把目录和文件内容一起组织在一个镜像中,编辑时需要同时照顾条目、偏移、大小和校验,所以配套脚本采用了一个针对本样本的做法:保留原目录区域,重新利用本轮停用组件的条目,把新文件附加到 imagefs 数据区,再更新相关字段。脚本会检查输入结构与预期条目,修改清单则让我们能回头核对每个变化。这样得到的是一套由我们明确配置、便于观察和逐步扩展的 QNX 宿主。
8. QNX → Linux:嵌套虚拟化的实际操作
8.1 这一步换到满足条件的 Intel KVM 宿主
进入嵌套实验前,先把命令所在的层次画清楚。直接从物理 PVE 的 shell 启动 QEMU 时,PVE 是 L0,里面的 QNX 是 L1,qvm 启动的 Linux 是 L2;如果在 PVE 的 Debian 虚拟机里再执行这条命令,外面就多了一层。SSH 可以连接到其中任意一层,所以判断依据是目标机器的运行关系。我的可用环境是 Intel Xeon E5-2683 v4 的物理 PVE,QEMU 11.0.3,先在该宿主上记录以下信息:
qemu-system-x86_64 --version
test -r /dev/kvm && test -w /dev/kvm && echo KVM_ACCESS_OK
cat /sys/module/kvm_intel/parameters/nested
cat /sys/module/kvm_intel/parameters/enable_apicv
cat /sys/module/kvm_intel/parameters/enable_shadow_vmcs
这个环境中最后三项为 Y,与 CPU 可见的 VMX 能力、KVM 访问权限一起,构成后续排障时需要比较的宿主条件。启动时使用 -enable-kvm -cpu host 将相应能力提供给 QNX,qvm 再据此创建内层机器。关于 nested、能力传递和 L0/L1/L2 的具体关系,可以对照 Linux 内核文档。准备好宿主后,把文章配套目录、原始 Linux rootfs 和生成的 hv-lab.elf 一起放过去,保持相同的 $LAB 目录约定,并核对传输后的文件哈希。
8.2 用一张只有 Linux rootfs 的虚拟 SD 卡简化存储
原车布局含有多个文件系统和分区,恢复整套布局会带来额外的依赖。为了只验证 qvm 启动 Linux,我们采用更小的构造:一张 SD 卡,从字节零开始就是 Linux ext4 rootfs,QNX 将整张卡转成 Linux 的一块 virtio 磁盘。
宿主执行:
qemu-img create -f qcow2 -F raw \
-b "$LAB/pkg/NAVILRFS/Linux_rfs.img" work/linux-sd.qcow2
qemu-img resize work/linux-sd.qcow2 4G
qemu-img info --backing-chain work/linux-sd.qcow2
qcow2 把原始 ext4 镜像作为 backing file,保存本次实验产生的变化,因此刚创建时文件很小,底层内容仍由原镜像提供。扩展到 4 GiB 是为了满足这里使用的 QEMU SD 卡容量要求:虚拟块设备变大后,原来的 ext4 文件系统可以继续按自身大小挂载。迁移实验目录时,将 qcow2 与原始镜像一起保留,并确认 backing 路径能够解析。这张盘最终在 QNX 中对应 /dev/hda,再经 qvm 提供给 Linux,后面就会看到 /dev/vda 这一块根盘。
8.3 一台 QEMU、两个串口
在 Intel KVM 宿主启动:
qemu-system-x86_64 \
-machine pc -enable-kvm -cpu host -m 8192 -smp 4 \
-kernel work/hv-lab.elf \
-device sdhci-pci \
-drive if=none,id=sd0,file=work/linux-sd.qcow2,format=qcow2,snapshot=on \
-device sd-card,drive=sd0 \
-chardev socket,id=qnxser,host=127.0.0.1,port=18001,server=on,wait=off,logfile=logs/qnx-host-serial.log \
-serial chardev:qnxser \
-chardev socket,id=linuxser,host=127.0.0.1,port=18002,server=on,wait=off,logfile=logs/qnx-linux-serial.log \
-serial chardev:linuxser \
-monitor unix:work/qnx-monitor.sock,server=on,wait=off \
-display none -no-reboot
这条命令为外层 QNX 提供 4 个 vCPU,内层 Linux 的 CPU 数量稍后由 qvm 配置单独安排。两个串口也各有归属:第一个留给 QNX shell,第二个承接 Linux 控制台的输出,让我们能在内层启动期间继续观察和操作宿主。先连接第一个串口,确认 QNX 用户态已经可用:
python3 scripts/serial_cmd.py --port 18001 'echo QNX_HOST_READY'
最小 IFS 里保留的命令比较精简,shell 内建的 echo 很适合做第一次验证。等串口驱动初始化完成后再发送命令;若过早发送导致开头字符在启动期间丢失,再发一次即可。看到完成标记和退出码,就说明串口、shell 和命令执行已经连通;第二个串口则等到 qvm 启动后再开始输出 Linux 日志。
8.4 让原厂 SD 驱动绑定 QEMU 控制器
在 QNX 的第一个串口 中运行:
devb-sdmmc blk cache=16m,maxio=1024,naming="a0#" \
cam quiet,pnp disk maxio=1024 \
sdio verbose=0 sdmmc verbose=0 &
waitfor /dev/hda
ls -l /dev/hda
运行这组参数后,原厂 devb-sdmmc 就能绑定 QEMU 的 sdhci-pci,创建根盘对应的 /dev/hda。不过,找到这条命令花了一些时间。早期实验中,设备节点始终没有出现,驱动反复报告:
xpt_configure: sdmmc SIM attach failure
这是一版面向 Apollo Lake 平台的老驱动,遇到绑定失败,很容易先怀疑它是否支持虚拟控制器。当时也考虑过从辅助 QNX 镜像里取出 devb-virtio,换一条存储路线。但回头比较控制器的 PCI 标识与启动脚本,就发现了一个可以先验证的差异:原车命令带着 idx=0,vid=0x8086,did=0x5acc,而 QEMU 提供的是另一组标识。
| 控制器 | vendor:device |
|---|---|
| 原车脚本筛选的 Apollo Lake 设备 | 8086:5acc |
QEMU 的 sdhci-pci | 1b36:0007 |
这些参数会限制驱动寻找哪一个控制器。保留原车的筛选,相当于已经告诉驱动只接收原车那块设备;即使 PCI 总线上存在一个兼容的 SDHCI 控制器,它也可能在匹配阶段被排除。去掉 idx、vid、did,让驱动自行扫描之后,原厂驱动便创建了磁盘节点。这个对照把问题收敛到了设备筛选,后续可以继续沿用原驱动和 QEMU SDHCI。
还有一个小细节也保留在最终命令里:sdio verbose=0。这版参数解析器要求 sdio 段至少带一个实际选项,否则后面的 sdmmc 可能被当成 sdio 的选项解析。原脚本里的参数看起来只是普通字符串,实际却同时决定了设备匹配和驱动内部的配置分段;迁移时逐项理解它们,比整条照搬更可靠。
磁盘出现后,再用设备节点和后续读取结果确认驱动状态。QNX 的许多驱动以用户态进程运行,通过资源管理器向 /dev 名字空间提供接口;有的程序建立后台服务后,启动它的 shell 作业会显示 Done。所以这里的观察顺序是:启动命令有没有报错,/dev/hda 是否出现,qvm 能否从它读取内层根文件系统。下一步就把这块盘接给 Linux。
8.5 精简 qvm 配置,然后启动内层 Linux
配套的 examples/linux-qvm.conf 已被放进宿主 IFS 的 /scripts/linux.config,核心设置是:
cpu
sched 11r
runmask 2,3
load /scripts/bzImage
cmdline "root=/dev/vda rootfstype=ext4 rootwait rw init=/bin/sh console=ttyS0,115200n8"
vdev ser8250
hostdev /dev/ser2
loc io:0x3f8
intr myioapic:4
vdev virtio-blk
hostdev /dev/hda
上面摘出的是 CPU、内核、串口和磁盘设置,实际运行使用配套的完整配置,其中还安排了内存区域、ACPI、定时器和中断控制器。cpu 块出现一次,就创建一个 vCPU;runmask 2,3 则规定这个 vCPU 的执行线程可以在哪些 QNX CPU 上运行。外层提供 4 个 CPU,控制 shell 安排在前面的 CPU 上,后面的 CPU 留给 guest 执行线程。内存部分使用的 linuxram_low、linuxram_high,是该平台 startup 建立的命名区域,qvm 通过 QNX 系统信息取得它们,因而与本样本的 BSP 相对应。
再看设备之间的接线:hostdev /dev/ser2 把 Linux 虚拟 UART 接到 QNX 的第二个串口,hostdev /dev/hda 则把实验 SD 卡作为 guest 的根盘。内层由一块磁盘、串口和基础时钟中断设备组成,先完成内核与用户态的启动。virtio-net 可以在后续网络实验中加入,同时带回前半篇用到的 virtionet= 参数和接口配置。
在 QNX 第一个串口中:
qvm @/scripts/linux.config &
另开宿主终端观察:
tail -f logs/qnx-linux-serial.log
预期在第二个串口中依次看到:
Linux version 4.9.52-i915-gvt_qnx-gdecfd5b ...
...
VFS: Mounted root (ext4 filesystem) ...
...
sh-3.2#
实际命令验证:
python3 scripts/serial_cmd.py --port 18002 'echo LINUX_GUEST_READY'
python3 scripts/serial_cmd.py --port 18002 'uname -a'
挂载 proc 之后,还可以读到内层看到的块设备和 CPU:
mount -t proc proc /proc
cat /proc/partitions
cat /proc/cpuinfo
cat /proc/cmdline
这些输出把嵌套启动的过程串了起来:第一个串口可以操作 QNX,qvm 创建内层机器,第二个串口显示 Linux 内核启动和根盘挂载,随后 shell 能执行命令、读取设备与 CPU 信息。保存这两路日志,下次调整配置时就有了一套可以逐项比较的启动基线。
8.6 复盘:qvm 通过了检查,Linux 为什么仍然没有输出
前面的步骤使用物理 PVE,并给内层 Linux 配置一个 vCPU。这套组合来自搭建过程中的两次卡点:一次发生在 qvm 的能力检查,另一次发生在 Linux 的多核初始化。最初承载 QEMU 的 Linux 本身也是虚拟机,QNX 已能进入 shell,存储驱动和第二串口也准备好了,qvm 却在创建内层机器时直接报告:
secondary procbased controls can not turn on 0x1
这条错误发生在 Linux 内核开始输出之前,来自 qvm 设置 VMX 控制位时的能力检查。QEMU 能启动 QNX,说明外层虚拟机已经在运行;QNX 再用 qvm 创建一台机器,还要依赖另一层虚拟化能力。回到承载 QEMU 的 Linux 查看 KVM 参数,当时 nested=Y,但 enable_apicv=N,enable_shadow_vmcs=N。单独看到 nested 已开启,还不足以描述 qvm 将要使用的整组运行条件。
中间做过一次诊断性修改,放宽 qvm 对 APIC 相关能力的检查。原来的报错消失了,进程也继续运行,但内层串口一直没有 Linux 输出。进一步观察,vCPU 线程停留在 RUNNING 状态,打印出的 VMCS 字段却全为零。VMCS 是 Intel 硬件虚拟化使用的控制结构,保存 guest 的控制项和执行状态;这次输出意味着还需要继续检查虚拟机是否真正进入了可运行的状态,进程存在本身只能作为其中一个观察点。
串口静默还有一个容易混在一起的解释:Linux 已经执行,只是我们没有接到输出。为拆开这两件事,我从 QNX 一侧做了 /dev/ser2 回环测试,外部能收到 SER2_TEST。这验证了 QNX 串口驱动到 QEMU 第二串口的外层通道,接下来便把排查重点放回 qvm 的运行状态和宿主能力。修改检查条件让我们多看到了一些状态,却还没有得到 Linux 正在执行的证据。
随后迁移到物理 PVE,先恢复原版 qvm,再比较两边记录的条件:
| 观察项 | 早期虚拟化 Linux 环境 | 后来使用的物理 PVE |
|---|---|---|
KVM nested | Y | Y |
KVM enable_apicv | N | Y |
KVM enable_shadow_vmcs | N | Y |
| 原版 qvm 的能力检查 | 报错退出 | 通过,内层开始输出 Linux 日志 |
这次迁移同时换了 CPU、宿主内核和虚拟化能力组合,表中记录的是排障时关注的几项差异。在这套 PVE 环境中,原版 qvm 可以通过检查,第二串口也终于出现了 Linux 启动日志,调试由此进入了 guest 内核。前面的失败尝试保留下来,最有用的是它提供了几个不同的进度点:qvm 检查通过、vCPU 线程存在、串口通道可用,以及 Linux 开始执行,都有各自对应的观察方法。
Linux 开始输出后,还遇到了另一个独立问题。内层配置两个 vCPU 时,串口停在启动第二个 CPU 的位置,日志最后与 SMP 有关的部分是:
x86: Booting SMP configuration:
.... node #0, CPUs: #1
到这里,内层已经进入 Linux 的多核初始化,排查位置与此前的 qvm 能力检查又有了区别。把内层第二个 cpu 块去掉,保留一个 vCPU,同一轮实验的内核和根盘便能继续到 VFS: Mounted root,随后进入 shell;外层 QEMU 仍然保留 4 个 vCPU。这个对照让我们先得到一套能重复启动的配置,多核 bring-up 的原因则留作后续问题。教程因此从单核开始,等根盘、串口和用户态都可以稳定观察,再增加 CPU 或其他设备。
8.7 完整存储布局中的排障经验
在更接近原车的实验里,我曾合成带多个分区的磁盘,逐项处理 QNX 设备命名、扩展分区链和 QNX6 挂载。这里还发现了自己工具中的一个错误:早期磁盘生成器把 next-EBR 的偏移基准写错,导致后面的逻辑分区无法枚举。修正为相对扩展分区起点后,分区陆续出现,这也与 Linux 分区解析代码中的处理一致。遇到陌生系统的磁盘问题时,把镜像生成过程和文件系统解析过程一起检查,往往能找到很具体的线索。
另一项工作是适配 QNX6 对存储同步能力的要求,需要结合虚拟控制器提供的接口来选择挂载选项,并记录相应的同步语义。完整启动流程会把这些因素带到一起,所以本文先用一张 ext4 SD 根盘建立 qvm 的启动基线。后续研究进入原厂挂载脚本时,就可以依次加入分区表、QNX6 文件系统和自动挂载步骤,每加一层都与当前已知可用的环境比较。
9. 把实验做成可以比较、可以解释的结果
9.1 按层次定位故障
有了前面的观察通道,“程序没有响应”就可以沿运行关系逐层展开。先看整台机器,再看 guest 用户态和目标进程,最后进入应用处理与崩溃现场;每一层都对应一项具体检查:
- QEMU 是否还在执行?
- guest shell 能否执行命令?
- 目标程序是否存在,PID 是否变化?
- 监听是否建立,接口和防火墙是否符合预期?
- 普通请求有没有有效响应?
- 若退出,core 和退出状态说明了什么?
这样排查时,一个端口超时就成了定位的起点:通过串口确认 guest 能执行命令,查看 PID 和监听状态,再结合日志判断请求停在哪一层。QNX 双串口也提供了类似的分工,内层输出停住时,可以从外层 shell 和 qvm 日志继续观察;外层串口连通有问题,则先检查 QEMU 的连接和串口驱动。每一步都利用上一层已经确认的状态,范围会逐渐收敛。
9.2 记录实验配置与验证结果
为了让后续实验沿着同一条线推进,我会把环境调整与适合观察的内容一起记录。本文中的配置可以整理成下面这张表,研究目标扩大时,再围绕它补回相应组件:
| 层次 | 实验调整 | 适合观察的内容 |
|---|---|---|
| Linux 启动 | 直接加载内核,以 shell 代替原 init | 内核启动和手工启动的用户态服务 |
| Linux 网络 | 使用实验地址与具体端口放行规则 | 宿主到服务的请求处理路径 |
| 文件写入 | 原始 rootfs 配合临时写层,输出盘单独保存 | 在同一基线上比较日志和故障现场 |
| 硬件依赖 | 按执行路径逐步接入设备或接口替身 | 当前目标功能所需的运行条件 |
| QNX 用户态 | 添加串口,暂停若干原厂自动启动服务 | 驱动初始化、设备发现和 qvm 启动 |
| qvm | 使用单 vCPU、单根盘、串口的配置 | 内层 Linux 的启动与基本运行 |
实验结果也可以按“内核启动”“服务响应”“功能执行”“完整启动流程”逐步积累。这样每次写下成功时,都能同时指出当时使用的配置和对应输出,后面加入 systemd、网络或更多设备时,就有清楚的比较依据。
9.3 从公开样本迁移到实车构建
研究后期,我还导出了自己车机的内核、文件归档和原始块设备镜像,它们分别帮助核对启动内容、查看程序与保留文件系统布局。文件级导出时,硬链接、符号链接、所有者和权限都值得一同保存,因为这些信息也会影响系统重建后的行为。把内核、rootfs 和应用程序分别记录版本与哈希,就能在比较不同构建时找到具体差异。
公开更新包与车上正在使用的构建之间,可能存在共享库布局、目录路径和初始化条件的变化。前面建立的实验台正好适合做这种迁移:先核对镜像和依赖,再用相同的启动检查与正常请求比较行为。读者可以先沿着本文的 2024 公开样本完成整条基础流程,将来拿到其他构建时,再逐项替换输入并观察结果。
9.4 组织日志与崩溃现场
每轮实验可以单独建一个记录目录,保存样本哈希、QEMU 和宿主版本、完整启动命令、串口日志、服务启动命令与探测结果,修改过的配置也放在一起。发生故障时,再加入 core、进程与线程状态、加载映射及对应共享库。core 中的寄存器、内存和调用栈都属于当时的那个构建,把这些文件一起归档,GDB 才能用匹配的程序解释现场。
多层系统还需要一个方便对齐的时间基准。精简 QNX 环境的时钟可能与宿主有差异,可以同时记录宿主接收时间、内核启动后的相对时间,以及每次实验主动打印的标记。收集结束后,依次同步、卸载输出盘、结束 QEMU,保留原始基线和命名明确的写层。这样下一次重做时,就能从同一状态开始,并把新的变化对应到具体配置。
10. 继续阅读
完成第 4—6 节后,我们已经有了一套可以启动原厂内核、运行服务并分析 core 的 Linux 实验环境;第 7—8 节则把观察范围扩展到 QNX 和 qvm。接下来可以按兴趣继续补齐设备:图形和输入方面,有 《ROOT Tesla OS on QEMU Part 2: Debugging + fixing》 与 tesla-qemu 项目这样的实战案例;希望从可构建的车载 Linux 入手,则可以参考 AGL 的 x86/QEMU 构建说明。将它们与本文的厂商镜像对照,也有助于理解通用虚拟硬件与平台定制部分各自负责什么。
本文用到的主要文档与工具:
- QEMU Direct Linux Boot:直接加载 Linux 内核。
- QEMU Disk Images:磁盘格式、写层和快照。
- QEMU Monitor:从外部查看和控制虚拟机。
- QEMU GDB usage:系统调试接口及其与进程调试的区别。
- Running nested guests with KVM:嵌套层级和所需环境信息。
- QNX Image Filesystem:理解 IFS 与启动组件。
- QNX Virtual machines:qvm 和虚拟硬件的关系。
- qnx_dumpers:本文使用的开源 IFS 查看与提取工具。
从一份更新包走到可调试的系统,最有帮助的是始终保留一条看得见的执行路径:加载哪个内核、挂载哪块盘、由谁启动程序、请求经过哪些接口、故障现场保存在哪里。每补齐一层环境,都给它安排一个可以重复的检查;遇到问题,再沿这条路径逐层定位。这样,原本封闭的车机软件就逐渐变成了我们能够观察、比较和反复运行的实验对象。
