做mipsel32架构的嵌入式Linux开发手头没有真板子是常态。我以前常干的一件事就是拿buildroot拉一套工具链和根文件系统再用QEMU全系统仿真把它跑起来调试驱动、验证用户态逻辑都不耽误。这次不搞花活记录一下我如何从零构建mipsel32 QEMU全系统环境并在系统调用返回路径上通过MIPS的22号寄存器把进程信息带出来给用户态程序整个过程踩了不少坑也留了不少可复用的经验。目标是这样的模拟项目X需要一个轻量的“进程信息带外通道”要求每个用户态进程在系统调用返回后能从通用寄存器里直接读到当前任务的PID、运行状态和优先级。这个需求在真实硬件上不太好做但在QEMU全系统仿真里可以反复验证、随时打断点非常适合先把协议和实现跑通。如果你也在折腾buildroot QEMU或者想了解MIPS寄存器怎么搞自定义信息传递这篇文章应该能省你不少时间。1. mipsel32全系统仿真环境的价值与选型理由1.1 为什么偏偏是mipsel32MIPS32小端架构在宽带猫、家用网关、无线接入这类设备上非常常见指令集相对干净寄存器约定也简单直接。相比ARM的AAPCSMIPS的O32 ABI更像一张白纸适合做寄存器级实验。mipsel32的“jel”指的是小端很多路由器固件和嵌入式模块都是这个字节序交叉编译和QEMU仿真都成熟稳定出了问题网上能查到的资料也多。选择mipsel32还有一个实际原因QEMU对MIPS Malta开发板的模拟非常完善几乎是“开箱即用”。你不需要自己写设备树也不需要处理特别复杂的板级初始化buildroot里甚至直接带了针对QEMU Malta的defconfig。这意味着我可以把精力集中在那条寄存器通信链路上而不是跟启动引导死磕。1.2 全系统仿真和用户态仿真的差别踩了的第一个认知坑一开始我也想省事直接用qemu-mipsel用户态仿真跑一个静态编译的测试程序。结果发现这条路走不通。用户态仿真只能模拟单个二进制文件它对内核设备驱动、中断控制器、进程调度、动态链接器依赖统统无能为力。比如我想验证“进程被调度后寄存器里能不能看到调度状态”用户态仿真根本不产生真实的内核进程调度事件这个实验从一开始就是空中楼阁。换成QEMU全系统仿真之后等于在模拟器里跑了一个完整的Linux内核。你可以看到/proc、/sys、网络设备、串口甚至能在模拟器里启动多个进程。想要验证进程级的寄存器传递机制全系统仿真才是可信的测试环境。这个认知帮了我大忙不是“仿真越少越好”而是要看实验目标需要什么层次的模拟。1.3 buildroot在这个场景里到底管了哪些事没有buildroot之前我得手动做三件事交叉编译工具链选内核版本并配置再做一个rootfs。这三件事每一个都能耗掉一整天而且组合在一起时版本之间还会打架。buildroot的核心价值就是把这些事情串成一条流水线它生成工具链、编译内核、生成BusyBox根文件系统最后统一输出镜像全程可配置、可复现。在寄存器通信这个项目里buildroot还帮我解决了一个隐性需求我需要一个可以修改的、可重新编译的内核。标准发行版的内核镜像没法随便改代码而buildroot把内核源码放在output/build/linux-*目录下补丁打进去之后重新make就能生成新的vmlinux配合QEMU启动整个编译-运行-调试的循环非常顺。对这种内核自定义功能这条链路是最高效的。2. Buildroot的mipsel32配置与离线构建要点2.1 配置表架构、工具链、内核和根文件系统怎么选我用的buildroot版本是2024.02.x下面是核心配置项直接对着菜单勾就行。配置路径我的选择理由Target options - Target ArchitectureMIPS (little endian)对应mipsel32Target options - Target Architecture VariantGeneric MIPS32r2兼容性好QEMU默认CPU支持Target options - Target ABIO32最常见寄存器约定简单Toolchain - Toolchain typeBuildroot toolchain推荐工具链版本可控Toolchain - C libraryglibc动态库行为更通用调试顺手Kernel - Linux kernel6.1 LTS稳定defconfig匹配好Kernel - Kernel binary formatvmlinuxQEMU直接加载省去引导转换Kernel - Kernel defconfigmalta_defconfigQEMU Malta板默认配置Filesystem images - ext2/3/4 variantext2配合QEMU旧的IDE驱动最顺关键点内核defconfig选malta_defconfig不是qemu_mipsel_malta_defconfig因为我后面要手动打补丁默认配置更接近内核原版。如果你只是想快速跑起来buildroot里自带的qemu defconfig会更省事但做寄存器修改实验时默认的malta配置反而更容易控制。2.2 C库和根文件系统的裁剪思路C库我选了glibc而不是musl或uclibc原因很现实我要写一个用户态程序去读寄存器这个程序里会用到printf、syscall这些接口glibc对MIPS O32 ABI的封装最成熟。musl不是不行但遇到动态库加载和系统调用包装器的个别细节时可能会多出一些未知行为。实验环境中未知越少越好。根文件系统方面BusyBox默认配置就能满足基本需求。我会额外开启bash和tcpdump前者方便交互调试后者在排查网络问题时用得上。文件系统大小设置为128MB避免后续塞工具链或测试文件时爆掉。2.3 离线构建与下载缓存管理构建过程最容易翻车的是网络下载。buildroot每次make都要从上游拉源码包网络波动或者源码地址变更都会导致构建中断。我的做法是先单独跑一次make source把需要的tarball全部下载到本地目录然后设置BR2_PRIMARY_SITE指向本机目录mkdir -p /srv/buildroot-dl make BR2_DL_DIR/srv/buildroot-dl BR2_PRIMARY_SITEfile:///srv/buildroot-dl source之后正常构建时仍然指定同一个BR2_DL_DIRbuildroot就会优先用本地缓存不会反复走网络。这个做法在反复重编译内核、清理重建时特别值钱一次下载多次复用整个构建过程从“拼网速”变成“拼磁盘”。3. QEMU启动命令与根文件系统的联动配置3.1 一条能正常启动的QEMU命令行Buildroot构建完output/images/下有vmlinux和rootfs.ext2。命令行长这样qemu-system-mipsel \ -M malta \ -cpu 24Kf \ -kernel output/images/vmlinux \ -drive fileoutput/images/rootfs.ext2,formatraw,ifide \ -append root/dev/hda noinitrd consolettyS0 panic1 \ -nographic \ -m 256逐项解释一下-cpu 24Kf指定MIPS32r2 CPU型号稳定且支持浮点指令实测比默认CPU更不容易出奇怪异常。-kernel vmlinux直接加载ELF格式内核镜像。-drive ... ifide把ext2根文件系统接到IDE控制器上。Malta板在老内核里的IDE设备名是/dev/hda。consolettyS0把内核日志和登录终端导向串口配合-nographic显示在终端里。panic1内核panic后自动重启至少不会傻等。启动后能看到内核解压、设备初始化、最后出现BusyBox登录提示。默认没有密码直接回车或配置一句话登录都能进shell。3.2 rootfs与内核的驱动匹配问题这条命令看起来简单但很多人会卡在VFS: Cannot open root device hda上。问题大多出在内核defconfig里没有开启对应的IDE驱动。malta_defconfig在老内核版本里带了CONFIG_IDEy但换成6.1或者某些自定义配置时IDE驱动可能被编译成模块而模块在挂载根文件系统之前加载不了死循环。遇到这种情况我建议分两步排查第一步先确认/dev/hda还是/dev/sda新内核的ATA子系统经常会用sd*命名第二步打开内核配置确认CONFIG_IDEy和CONFIG_ATAy都编进内核而非模块make linux-menuconfig # Device Drivers - ATA/ATAPI/MFM/RLL support - ATA ATA/ATAPI/MFM/RLL support [*]还有更省心的替代方案让buildroot把根文件系统打成cpio格式以内置initramfs方式启动完全绕开磁盘设备识别问题。改动很小只需要把文件系统格式选成cpio the root filesystemQEMU命令行去掉-driveappend参数改成root/dev/ram0。initramfs方式特别适合验证“系统能不能启动、寄存器链路能不能跑通”这一阶段。等确认无误再切回ext2排查面会小很多。3.3 网络和串口的边界情况实验里需要把编译好的测试程序传到虚拟机上。常见办法是-net nic,modelrtl8139 -net user让虚拟机走用户态网络然后在虚拟机里执行udhcpc -i eth0拿到类似10.0.2.15的地址。配合-net user自带的SMB共享qemu-system-mipsel ... \ -net nic,modelrtl8139 \ -net user,smb/home/me/pinfo_share虚拟机里访问//10.0.2.4/qnxshare就能拿到共享目录。注意MIPS架构建出来的用户态程序是mipsel的宿主机必须用file命令确认之后再拷贝别拿x86的二进制硬跑。4. MIPS寄存器军规下$22能传递哪些进程信息4.1 MIPS32通用寄存器与O32 ABI速览MIPS有32个通用寄存器名字和调用约定是固定的。O32 ABI把其中一部分定义为调用者保存一部分定义为被调用者保存还有几个是特殊用途。核心分工看这张表寄存器编号名字ABI角色对这个项目的影响$2v0返回值 / 系统调用号不能占用返回值靠它$4-$7a0 - a3函数参数 / 系统调用参数不能占用参数靠它$16-$23s0 - s7被调用者保存寄存器可以扩展使用但要守规矩$24-$25t8 - t9调用者保存临时寄存器函数里改动无痕不可靠$28gp全局指针固定用途不可用$29sp栈指针不可碰$31ra返回地址碰了就崩我要用的$22在编号上对应s6属于被调用者保存寄存器。意思是如果一个C函数要使用s6它必须在入口保存旧值、出口恢复旧值保证调用者看到的值不变。4.2 为什么选择第22号寄存器而不是系统调用返回值位置第一直觉是直接用$2v0传进程信息省事。但$2在系统调用返回时必须放真实返回值比如getpid()的PID。如果你想同时让用户看到系统调用结果和进程信息$2就冲突了。$4-$7同理它们在返回路径上还可能保留着参数寄存器内容虽然标准规定返回后这些寄存器的值未定义但直接用它们传递信息会让调试器误以为是参数残留。选$22的理由有三点第一它是通用寄存器读写成本极低第二它的ABI角色是“被调用者保存”意味着用户态程序必须显式保存恢复反而给了我们一个相对确定的读取时机第三它不在内核系统调用返回值路径上不污染任何标准语义。用一句话概括就是在系统调用返回这条缝隙里$22是一块相对干净的可借用空间。4.3 进程信息在寄存器里长什么样单靠32位寄存器不能塞复杂结构我的做法是把它当整数信封用各位段提前约定好位0 - 15PID低16位够用位16 - 23进程状态索引对应任务状态数组下标位24 - 31优先级或者调度策略标记。这样一个32位寄存器就能承载三个关键字段。用户态程序读出来之后按位与、移位还原即可。相比传指针这个方案有几个明显好处不依赖虚拟地址映射不怕ASLR也不需要在读取时做一次额外的内存访问——寄存器本身就在眼前。5. 内核态与用户态的$22链路打通代码级演示5.1 内核侧补丁在哪里埋点最稳要让$22携带进程信息最稳妥的做法是在系统调用返回路径上修改当前进程的pt_regs。MIPS内核在每次系统调用后会把寄存器现场保存在struct pt_regs里恢复现场时从这里拷贝回真实寄存器。如果我们把值写进regs[22]最终就会被恢复成用户态可见的$22。我在内核源码里加了一个简单的导出函数static void pinfo_export(struct pt_regs *regs) { unsigned long info 0; info | (unsigned long)(current-pid 0xffff); info | ((unsigned long)task_state_index(current) 0xff) 16; info | ((unsigned long)task_prio(current) 0xff) 24; regs-regs[22] info; }task_state_index可以自己定义本质上就是把TASK_RUNNING、TASK_INTERRUPTIBLE这些状态宏映射成紧凑索引。然后把pinfo_export挂到返回路径上位置选在系统调用跟踪函数里比较稳定比如syscall_trace_leave这个入口void syscall_trace_leave(struct pt_regs *regs) { pinfo_export(regs); /* 原有的trace逻辑继续保持 */ }这里的关键细节是补丁点必须在恢复寄存器现场之前而且要保证它修改的确实是即将返回到用户态的那份现场。如果埋在太早的位置后续内核代码又把寄存器覆盖掉了等于白改。实测中我就是因为埋点太靠前导致读取到的$22一直是旧值花了不少时间才定位到是恢复顺序的问题。5.2 用户态读取一行内联汇编的ABI约束用户态这边读取$22不需要访问设备文件也不需要调用特殊接口一行内联汇编就够了#include stdio.h #include unistd.h #include sys/syscall.h static inline unsigned long read_pinfo_reg(void) { unsigned long info; asm volatile(move %0, $22 : r(info) :: memory); return info; } int main(void) { int i; for (i 0; i 10; i) { syscall(SYS_getpid); unsigned long info read_pinfo_reg(); printf(pid%lu state%lu prio%lu raw0x%lx\n, info 0xffff, (info 16) 0xff, (info 24) 0xff, info); } return 0; }编译时用mipsel-linux-gnu-gcc -O1 -fno-omit-frame-pointer pinfo.c -o pinfo。有几个约束必须满足asm volatile不能省不然编译器可能把读取操作重排到系统调用之前memory破坏列表是在告诉编译器内存可能被修改阻止它乱序。SYS_getpid的调用不能去掉因为系统调用指令本身才是触发内核返回路径的钥匙。有人会问既然$22是被调用者保存寄存器那调用printf之前它不就被覆盖了吗答案是printf如果恰好使用$22它会自己做保存和恢复所以$22的值在调用printf时还成立但如果编译器在read_pinfo_reg返回后把值暂时放到了某个栈槽里后续优化可能会滞后。因此我在read_pinfo_reg函数里用的确实是register变量它能把寄存器的值留在调用边界附近实测内存序被破坏的情况稳定规避。5.3 模块方案与重编内核的取舍如果你不想为了实验重新编译整个内核也可以尝试用内核模块挂tracepoint。但我要说句实在话MIPS官方tracepoint对pt_regs的读写限制很尴尬很多tracepoint传入的寄存器上下文并不是返回用户态前的那份。这会导致你修修改改最后还是要碰内核路径。与其绕远路不如直接在buildroot里打补丁重编内核一次make十分钟左右换来的是完全可控的程序行为。重编内核的步骤很简单在output/build/linux-*目录下手动修改源码然后回到buildroot顶层执行make。buildroot会检测到内核源文件变化并自动重新编译vmlinux无需清理整个构建。这个循环很快实测从保存补丁到拿到新vmlinux大约十分钟以内。6. 仿真环境的实测结果与坑点排错6.1 启动阶段最容易踩的坑根文件系统挂载失败排错优先级最高的一个坑就是内核启动到VFS: Cannot open root device hda。我碰上过两回一次是内核变了设备命名规则/dev/hda成/dev/sda一次是IDE驱动编成了模块。排查思路是这样的先确认内核里有没有CONFIG_BLK_DEV_SD和CONFIG_ATA再把-drive换成一个空盘符试root/dev/sda如果还不行就直接用initramfs方式跳过磁盘依赖。initramfs方式对这条实验性质的链路来说其实最干净不会让磁盘驱动干扰核心问题。6.2 编译器优化把$22的值吞掉这是我的第二坑。开启-O2编译后用户态程序经常打印出全零的info值。原因不是寄存器没写入而是编译器认为read_pinfo_reg()没有副作用把它优化到循环外面甚至直接删掉了。解决办法有两个一是用asm volatile加上内存破坏列表二是在读取之前插入一条编译器屏障。最终在刚才那段代码里两条都用上了实测稳定。6.3 内核侧写的regs[22]被后续恢复过程覆盖这个坑花了我最长时间。现象是pinfo_export函数里打印regs-regs[22]值完全正确但用户态读到的始终是没变的旧值。最后翻系统调用返回路径的汇编才明白MIPS在返回用户态之前会从pt_regs完整恢复通用寄存器如果我补丁的位置在“恢复现场”逻辑之前写入的值还在但如果补丁位置跑到了某些分支里可能在恢复动作之后那于是被覆盖。把埋点移到syscall_trace_leave后观测结果一直稳定。这里也提醒一句不要随便改arch/mips/kernel/scall32-64.S里的汇编恢复段。汇编层面的补丁要考虑中断遮断、寄存器冲突和延迟槽看起来短短几行实际调试成本比C函数高一个量级。除非你确实需要极致性能否则用C函数挂在trace路径上就够。6.4 进程后续运行为什么可能盖掉$22假设用户态程序读完$22没有立刻保存接着调用了一个复杂的C函数那这个函数一旦使用$22作为临时计算寄存器就会破坏值。前面说过$22是s6属于被调用者保存寄存器意思是函数必须先保存旧值再使用、结束后恢复。对于我们的注入值来说这个“保存和恢复”反而是坏事它在函数进入时保存了注入值离开时恢复的也是同一个注入值所以只要在读取后立刻放进局部变量printf不会破坏它。实操建议是先读后存一次性把info变量填好后面想怎么用都行。我测试程序里就是这么干的多进程连续跑几十轮也没有出现寄存器值被库函数改写的情况。6.5 用QEMU监控器验证寄存器实时值QEMU全系统仿真有个很爽的附加能力它自带监控器可以在运行中直接查看CPU寄存器。启动时加-monitor telnet:127.0.0.1:5555,server,nowait然后用telnet连进去执行info registers就能看到r22的实时值GPR 00: 00000000 00000000 00000000 00000000 ... GPR 20: 00000000 00000000 00000abc 00000000 ...$20和$21顺序排下来$22对应那一列就是我们要的。这是排查“到底写没写寄存器”的终极验证手段比在内核里加printk更快。6.6 补丁后重新构建的一键流程这里把完整流程固定下来后续有改动重复执行即可# 修改内核源码后返回buildroot根目录 make # 重启QEMU用新的vmlinux qemu-system-mipsel \ -M malta -cpu 24Kf \ -kernel output/images/vmlinux \ -drive fileoutput/images/rootfs.ext2,formatraw,ifide \ -append root/dev/hda noinitrd consolettyS0 panic1 \ -nographic -m 256如果只想验证寄存器链路不关心根文件系统可以把rootfs.ext2换成cpio格式用-append root/dev/ram0 consolettyS0代替启动速度快不少驱动层面的干扰也少。最后聊一点个人体会。用寄存器传进程信息这件事本质上是拿通用寄存器的ABI“规范”换了一个极低开销的带外通道代价是必须牢牢记住三条铁律频道内容必须提前约定、读取时机必须紧随系统调用、内核修改点必须处在正确的现场恢复顺序里。这三条都满足之后这个机制跑起来非常舒服既不需要额外的内存缓冲区也不依赖特定驱动几乎零附加延迟。这套“buildroot QEMU全系统仿真 寄存器通道”的组合我后来也用在好几个内核调试场景里哪怕只是移植到别的mipsel方案上只要把寄存器编号和位段定义重新约定一下就行。
阅读完成 · 觉得有帮助?