资讯动态

RISC-V 无 MMU Linux 实践:从内核编译到用户态

发布时间:2026/10/28 1:07:36 来源:尧图企业网站定制
最近把 RISC-V 无 MMU 的 Linux 从内核一路跑到用户态完整走了一遍过程中踩了不少坑也把整个路径理顺了。这个项目有意思的点在于大部分人接触 RISC-V Linux 都是带 MMU 的标准形态但无 MMU 场景在 MCU 级别的 RISC-V 芯片上非常常见国内做 IoT、嵌入式控制和极简 Linux 网关的团队基本都会碰到。这篇文章把我的完整路径写下来从内核编译到根文件系统再到在无 MMU 环境里跑通一个普通 C 程序学习路径完整、可复现希望对正在入门 RISC-V 无 MMU Linux 的同行有帮助。这个项目要解决的问题很明确在去掉 MMU 和页表机制之后Linux 内核能否正常完成启动、挂载根文件系统、拉起用户态进程并且让用户态应用正常读写文件、访问外设。适合三类读者一是嵌入式方向想搞懂 noMMU 内核差异的二是准备在低成本 RISC-V 芯片上跑 Linux 的开发者三是纯粹想加深对内核内存管理理解的爱好者。1. 项目背景与整体思路设计1.1 无 MMU Linux 是什么为什么要跑这个项目MMU 全称 Memory Management Unit负责虚拟地址到物理地址的转换同时提供页级权限保护。传统 Linux 依赖 MMU 实现进程隔离、按页分配内存、写时复制这些机制。但在很多低成本 RISC-V 芯片上比如部分 MCU 级别的内核E31 这类单核 RV32根本没有 MMU 模块SRAM 或者内嵌 Flash 直接连在总线上所有代码和数据跑在物理地址空间里。没有 MMU 时 Linux 依然能工作只是内核换成 noMMU 配置进程不再有独立的虚拟地址空间所有进程共享同一物理地址空间。这意味着没有 page fault 缺页异常、没有按页的访问保护malloc 的实现也要跟着改变。这类系统通常叫 uClinuxMicroController Linux最早在 m68k 上出现后来移植到 ARM、RISC-V 等架构。我跑这个项目的直接动因是手头一块 RISC-V 开发板RAM 只有 8MB带 MMU 的 Linux kernel 虽然也能编出来但跑起来光页表分配就要吃掉不少内存而且很多经典启动流程在这种硬件上相当别扭。后来查到 RISC-V 内核本身就支持 nommu 配置于是决定完整梳理一遍从内核源码配置开始到用户态编译全部手动搞一遍不依赖现成 BSP 镜像彻底弄明白每个环节在干什么。1.2 传统 MMU 与 noMMU 的技术差异先把两种模式的核心差异摆在桌面上后续所有配置和代码都围绕这些差异展开对比项标准 MMU LinuxnoMMU Linux地址转换虚拟地址通过页表映射到物理地址无页表CPU 直接访问物理地址进程隔离每个进程独立地址空间互不可见所有进程共享地址空间无硬件隔离内存保护页级读写权限、内核/用户隔离几乎没有页级保护只能靠代码约束缺页异常支持按需调页虚拟内存可大于物理内存不支持所有内存必须物理存在可执行文件加载ELF 按节加载可加载到任意虚拟地址要求链接地址与运行地址匹配通常用平坦 ELFfork 实现依靠写时复制fork 必须完整拷贝地址空间代价很大通常替代方案是 vfork适用场景桌面、服务器、主流应用MCU、低 RAM、实时性要求高的嵌入式控制对开发体验影响最大的就是最后几条。普通 Linux 应用在 MMU 环境下从编译到运行基本无感知但 noMMU 下稍大一点的程序就可能因为地址空间重叠或者堆栈越界产生极其诡异的行为而且没有段错误这种明确的报错机制排查全靠经验。1.3 本次项目选择的硬件与软件环境我这次选择在 QEMU 虚拟环境里跑 rv32 架构的 noMMU Linux之后再放到真实板卡上验证。原因很简单QEMU 可以直接指定 CPU 特性模拟无 MMU 的 RISC-V 核很方便而且调试内核崩溃时可以用 gdb 连接 QEMU定位比实体板卡高效太多。软件栈选型如下内核Linux 6.1 LTSriscv 架构配置 nommu工具链riscv32-unknown-linux-gnu配合 uClibc-ng 库noMMU 场景最常用根文件系统initramfs 方案内置 busybox 和自定义测试程序模拟器QEMU 7.2qemu-system-riscv32构建辅助Buildroot 可以整体拉起来但为了展开细节我先手动构建一遍最后用 Buildroot 做对照验证之所以选 uClibc-ng 而不是 glibc是因为 glibc 在 noMMU 下的支持一直不完整尤其动态链接、线程实现这些都对 MMU 有依赖。uClibc-ng 专门为嵌入式设计noMMU 支持成熟RISC-V 支持也很完整。musl 其实也部分支持 noMMU但当时在 RISC-V 平台上还有不少坑后面会提到。2. 工具链准备与基础环境搭建2.1 交叉工具链的两种获取方式构建 noMMU Linux 第一步是准备交叉编译工具链。这里有两条路我建议新手直接用 Buildroot 构建经验足够的可以手动安装或使用发行版交叉编译器。方式一直接安装发行版工具链。Ubuntu 上可以安装gcc-riscv64-unknown-elf但注意这个工具链通常是 bare-metal 用的没有 Linux 用户态的 glibc/uClibc 支持编译内核可以编译用户态应用就做不到了。所以更推荐安装riscv64-linux-gnu-gcc这类带 Linux 库的工具链然后在编译应用时做成静态链接绕开动态加载器在 noMMU 下的兼容问题。方式二Buildroot 统一构建。这是我在项目后期采用的方案Buildroot 会自动处理内核、工具链、uClibc-ng、busybox、应用的全部构建并且针对 noMMU 有专门的BR2_RISCV_32、BR2_USE_MMU这类配置项。具体到本次手动搭建我用的是 Buildroot 生成的 riscv32 noMMU 工具链好处是 uClibc-ng 的配置和内核匹配不会出现库函数编译期依赖了 MMU 特性的情况。2.2 内核源码获取与分支选择内核我直接选了 6.1 LTS这个版本对 RISC-V nommu 的支持已经比较稳定。Linux 的 RISC-V noMMU 支持从 5.x 时代就开始合并到 6.1 已经相当成熟Kconfig 里的依赖关系捋得比较清楚。获取源码后需要先确认三个关键 Kconfig 项的状态CONFIG_MMUn CONFIG_CPU_HAS_NO_MMUy CONFIG_SYSVIPCy # 可选如果应用需要 SysV IPC 则开启noMMU 下部分 IPC 有限制这里有个关键点CONFIG_MMU是全局内存管理开关RISC-V 的 Kconfig 会根据 CPU 能力自动限制。如果你选的 CPU 类型不支持 MMU那么CONFIG_MMU会被强制设为 n相关依赖项也会跟着联动。我一开始不理解为什么核内明明有 MMU 选项但配置不生效后来打开arch/riscv/Kconfig仔细看才发现它读的是CPU_SUPPORTS_MMU这个能力位如果 SoC 型号声明里没有这个标志后续一切都不会有。2.3 内核配置文件生成noMMU 内核不是直接make defconfig就能出来的需要先选对基础配置再做裁剪。RISC-V 架构默认的 defconfig 是针对带 MMU 的通用平台直接编译会要求 MMU 相关功能而且默认配置会打开大量用不到的设备驱动拉长编译时间。我的操作路径是make ARCHriscv CROSS_COMPILEriscv32-linux- nommu_virt_defconfigLinux 6.1 里没有直接的nommu_virt_defconfig这个目标名不存在正确做法是先make ARCHriscv virt_defconfig再手动改配置。打开menuconfig后重点改以下几项General setup --- [*] Initial RAM filesystem and RAM disk support [*] Support INITRAMFS with gzip Kernel Features --- [ ] Use the MMU (取消勾选) Executable file formats --- [*] Use ELF format for user binaries [ ] (如果有 FDPIC 选项noMMU 下用户态可以用 FDPIC后面选型会细说) Device Drivers --- 关闭不需要的块设备、网络设备驱动减少编译时间取消 MMU 后内核会自动弹出Memory Model的选择。noMMU 下通常选Flat Memory Model这是最简单直接的内存模型整个物理内存就当成一段连续空间不搞 NUMA 和多段映射。选完保存退出。执行make -j$(nproc)之前建议先确认CROSS_COMPILE环境变量是否正确。我在第一次编译时因为CROSS_COMPILE设成了riscv64-linux-编译过程报了一堆寄存器宽度不匹配的错误这里必须严格用 riscv32 的交叉前缀。2.4 内核链接地址与运行地址的关系noMMU 内核与 MMU 内核有一个本质差别内核镜像的运行地址必须等于链接地址。带 MMU 时内核可以链接在虚拟地址0xffffffff80000000运行时通过页表映射到底层物理地址而没有 MMU 时CPU 取指直接用物理地址链接器写的地址就是 CPU 看到的地址必须确保链接脚本里的地址段落在目标板 RAM 的物理地址范围内。QEMU virt 平台的virt机器默认 RAM 起始地址是0x80000000所以内核默认链接地址就是这里。如果换成真实芯片一定要查数据手册确认内部 SRAM 的基地址然后可能需要修改arch/riscv/kernel/vmlinux.lds.S里的内存布局段或者通过设备树中/memory节点调整。这个坑我在真实板卡上踩过一次编译没问题QEMU 上跑得好好的烧到板子上就是起不来最后加串口打印才发现第一条指令就取指失败了。根源就是链接地址 0x80000000 在芯片上根本没有内存映射。3. 内核编译与启动验证3.1 编译命令与产物说明配置完成后执行编译。完整命令如下make ARCHriscv CROSS_COMPILEriscv32-linux- -j8编译产物中最重要的是两个文件vmlinux带有调试信息的完整 ELFQEMU 可以直接加载arch/riscv/boot/Image去掉了调试信息的原始内核镜像QEMU noMMU 启动推荐直接用vmlinux或者Image不要用uImage或者vmlinuz因为 noMMU 平台通常也没有 decompressor 支持直接用 ELF 最省事。编译期间如果遇到fatal error: linux/compiler-gcc.h: No such file or directory通常是工具链和内核版本不匹配检查一下交叉编译器版本。Linux 6.1 要求 GCC 版本在 5.1 以上我用的是 GCC 12没问题。编译时间取决于机器我实机 8 核编译大概三分钟完成比较快因为裁剪了大量驱动。3.2 QEMU 启动参数与现象解读内核编译完成后先做一个最小启动测试不挂任何根文件系统看内核能不能跑到最后的 panicqemu-system-riscv32 -M virt -kernel vmlinux -nographic -m 64M注意这里-m 64M给的是 64MB RAM对 noMMU Linux 来说已经算非常宽裕。设置完内存后观察串口输出正常启动到最后会停在Kernel panic - not syncing: VFS: Unable to mount root fs这说明内核已经完成了 CPU 初始化、内存初始化、中断控制器初始化只是找不到根文件系统。到这里内核侧已经通了。QEMU 的-M virt平台对 noMMU 内核有一个需要注意的地方virt 平台的设备树里默认描述了很多外设包括 PCIe、virtio 等但 noMMU 内核没有启用对应的驱动时这些节点会被忽略不会造成问题。还有一个细节启动时如果看到No working init found. Try passing init option to kernel说明内核已经成功挂载了根文件系统但找不到 init 程序这是下一步要解决的。3.3 initramfs 最小根文件系统要让 Linux 完整启动到用户态最简单的方式是制作 initramfs把它编进内核镜像里这样不需要额外的存储介质尤其适合 noMMU 平台——很多低端芯片根本没有磁盘控制器从内存直接加载 initramfs 是最可靠的路径。我采用usr/gen_initramfs.sh脚本把预先做好的根文件系统目录打包进去。步骤是mkdir -p rootfs/{bin,sbin,etc,proc,sys,dev,usr/bin,usr/sbin,lib} # 将 busybox 可执行文件和测试程序放进去 ./usr/gen_initramfs.sh -o usr/initramfs_data.cpio.gz rootfs/再回到内核配置里确认CONFIG_INITRAMFS_SOURCE指向这个 cpio 文件重新编译内核。也可以不重新编译直接在 QEMU 命令行加-initrd rootfs.cpio.gz并且在内核启动参数中指定rdinit/init。我更推荐先用-initrd方式做验证因为改根文件系统不用重编内核迭代速度快。下决心把 initramfs 内置也是后面才改的因为真实板卡上没有 bootloader 可以方便地加载外部 initrd直接烧进内核镜像最省事。4. 根文件系统构建与 BusyBox 配置4.1 BusyBox 的交叉编译细节BusyBox 是 noMMU Linux 用户态的基本盘提供了 shell、文件工具、网络工具等。编译时需要注意几个点这些都是实际踩过之后才总结出来的。首先BusyBox 默认会尝试使用CONFIG_FORK_PTRACE这类依赖 MMU 的功能编译时要在配置里关掉。其次BusyBox 的编译器检测可能会尝试用-fstack-protectornoMMU 下 stack protector 依赖特定库支持直接关掉最省心。我编译 BusyBox 的配置流程make ARCHriscv CROSS_COMPILEriscv32-linux- defconfig make ARCHriscv CROSS_COMPILEriscv32-linux- menuconfig在 menuconfig 里调整Settings --- [ ] Build static binary (no shared libs) (推荐静态编译) [ ] 如果你必须用动态库则确保 uClibc-ng 的共享库路径正确 Shells --- [*] ash (首选busybox 自带轻量 shell)我这里为了简单直接选择静态编译这样可执行文件不依赖动态加载器减少 noMMU 下动态链接的坑。静态编译的缺点是文件体积大实测 busybox 静态编译后大约 800KB在 noMMU 平台普遍 8MB-16MB 内存下还是可以接受的。编译完成后将busybox复制到根文件系统bin/busybox然后在bin目录下创建符号链接cd rootfs/bin ln -s busybox sh ln -s busybox ls ln -s busybox mount ln -s busybox ps实际上完整做法是执行busybox --install -s它会自动创建所有集成命令的符号链接。如果目标根文件系统目录还没有/bin先建目录再安装。4.2 init 脚本与设备节点内核启动到用户态后会执行/init或者rdinit指定的程序。我写了一个最小化的 init 脚本核心只有三件事挂载关键文件系统、设置主机名、启动 shell。#!/bin/sh mount -t proc proc /proc mount -t sysfs sysfs /sys mount -t devtmpfs devtmpfs /dev echo Welcome to RISC-V NOMMU Linux exec /bin/sh注意这里 devtmpfs 的挂载。noMMU 下内核会配置CONFIG_DEVTMPFS这是设备节点自动管理的机制挂载后/dev下会自动出现 console、null、zero 等节点否则串口输出可能都找不到设备文件。init 脚本需要有执行权限并且建议所有涉及路径的工具都用绝对路径避免 PATH 还没初始化时找不到命令。设备节点的另一种方式是手动创建mknod -m 666 dev/console c 5 1 mknod -m 666 dev/null c 1 3如果CONFIG_DEVTMPFS_MOUNT没开或者挂载失败手动 mknod 是兜底方案。我在部分极简内核配置下遇到过 devtmpfs 不可用的情况手动创建节点后问题解决。4.3 普通 C 程序在 noMMU 下的编译方式跑通 shell 之后最关键的一步是编译并运行一个自己的 C 程序。这个环节最能感受到 noMMU 和 MMU 的差异——链接地址、内存布局、堆栈都和有 MMU 时不一样。以最经典的 Hello World 为例#include stdio.h #include unistd.h int main(void) { printf(Hello from RISC-V NOMMU!\n); sleep(1); return 0; }编译命令riscv32-linux-gcc -static -o hello hello.c这里必须加-static。noMMU 下动态链接需要加载器把共享库映射到进程地址空间而进程地址空间本身没有 MMU 保护随意加载很容易和栈或者堆冲突。静态链接直接从根本回避了这个问题。编译完成后把 hello 放进 rootfs/bin在 shell 里直接执行。如果你的程序正常打印出字符串整个链路就通了。但这个简单程序对 noMMU 来说其实没有真正挑战到难点。我建议实际测试一个稍大的程序比如用 malloc 分配 1MB 内存再写入数据或者写一个递归程序把栈耗尽体验一下 noMMU 下资源受限的真实表现。5. noMMU 应用开发的特殊注意事项5.1 fork/vfork 的选择与进程创建noMMU 上多进程编程是最容易出问题的地方。标准 Linux 的 fork 依赖写时复制子进程只需要复制父进程的页表虚拟内存可以共享物理页创建过程非常快。但 noMMU 下 fork 必须把父进程的整个地址空间完整拷贝一遍包括所有堆和栈内存如果父进程占用了 8MB 内存fork 一次就要拷贝 8MB既慢又容易失败。正确的做法是用 vfork。vfork 的特点是不拷贝地址空间父进程挂起子进程直接使用父进程的内存子进程调用 exec 后地址空间被替换再交还父进程。但 vfork 的使用有严格限制子进程在 exec 之前不能修改任何全局变量不能从当前函数返回只能直接调用 exec 系列函数或者_exit。实际上在 noMMU 平台上包括 BusyBox 内部的大量程序都会自动使用 vfork 来避免 fork 失败。你可以用strace观察在普通 MMU 系统上ls | grep x会看到 fork而在 noMMU 系统上同一命令会用 vfork。共享内存的进程间通信也要注意。noMMU 下内核配置CONFIG_SYSVIPC后可以支持信号量和消息队列但共享内存是有限制的。我在测试时发现shmget偶尔失败原因是共享内存段需要固定在物理地址上多个进程映射同一物理地址时如果地址冲突就会失败。实际项目中如果要在 noMMU 多进程间传数据优先考虑管道、消息队列或者直接读写文件。5.2 堆栈与内存布局对应用的影响noMMU 内核创建用户态进程时会为每个进程分配一块独立的区域作为栈和堆而且地址是固定的。这与 MMU 系统每个进程都从相同虚拟地址比如0x7fff...开始完全不同。我用一个简单的内存地址打印程序做了测试#include stdio.h #include stdlib.h int global_var; int main(void) { int stack_var; int *heap_var malloc(128); printf(stack: %p\n, stack_var); printf(heap: %p\n, heap_var); printf(global: %p\n, global_var); return 0; }在 MMU Linux 上两个独立进程分别运行这个程序打印出的地址往往一模一样。但在 noMMU Linux 上地址很可能不同因为内核为每个进程选择的加载地址和栈顶地址都取决于当时的内存分配情况。这给调试带来一个很大的麻烦noMMU 下程序崩溃时没有页表翻译的层级关系可供回溯也没有“非法访问”这类明确的错误码往往是直接挂掉或者跳到一个奇怪地址。我调试时用的笨但有效的方法是在程序里大量加入打印函数二分定位到最后一个正常打印的位置再缩小范围。应用层的栈大小也需要显式控制。noMMU 下进程栈不是按需增长的而是创建进程时一次性分配好。如果程序递归深度大或者申明了大的局部数组要么用 ulimit 调整栈上限要么在编译时通过链接脚本控制。我见过一个同事写的程序局部变量声明了一个 2MB 的数组在 MMU 系统上跑得好好的到 noMMU 系统上直接启动失败——后来查明白内核为这个进程分配的栈空间根本不够用修改链接脚本给这个程序分配更大的栈后解决。5.3 动态库与 FDPIC 的取舍noMMU 平台并不是完全不能用动态库只是需要特殊格式比如 FDPIC。FDPIC 是一种允许共享库加载到不同地址的 ELF 变体ARM 的 noMMU 平台用得比较多RISC-V 的支持也在完善中。我的建议是除非你对动态链接机制非常熟悉否则项目初期全部静态编译。静态编译唯一的缺点是镜像大但在 noMMU 场景下一个程序的体积增大换来的是部署和调试的复杂度大幅降低。等系统跑通、内存空间允许之后再考虑把应用层换成动态编译内核里开启 FDPIC 支持可以省不少 Flash 空间。实际对比数据供参考一个简单的 bzip2 工具静态编译约 220KB动态编译加共享库整体约 80KB。如果根文件系统只有 1MB 空间动态化的收益是比较明显的。6. 完整启动验证与常见问题排查6.1 完整启动流程图与验证清单整个系统跑通后启动日志里会有几个关键的“里程碑”节点我把它整理成一个检查清单方便对照排错序号日志特征说明1Linux version 6.1.0内核入口代码执行成功2CPU: rv32或相关架构信息CPU 探测完成无 MMU3Built 1 zonelists, mobility grouping on内存初始化完成4Kernel command line:启动参数解析完成5Freeing unused kernel image memory内核启动最后阶段6Run /init as init process切换到用户态7Welcome to RISC-V NOMMU Linuxinit 脚本执行成功8Hello from RISC-V NOMMU!自定义应用运行成功如果你启动到一个阶段就卡住直接对照这个表格就能缩小范围卡在 2 之前是内核入口问题卡在 5-6 之间大概率是 rootfs 问题卡在 6 之后是 init 脚本或者用户态程序问题。6.2 QEMU 常见启动失败清单我整个过程中遇到的最典型的启动失败有以下几类每类都附上排查方法第一种启动立刻报错串口只有几个字符或者完全无输出。这种通常是 QEMU 参数不对或者内核链接地址与 QEMU 内存不匹配。检查-m指定的内存地址范围是否覆盖了内核链接地址 0x80000000如果不放心就用-d in_asm让 QEMU 打印执行的指令看第一条指令地址。第二种内核启动到Kernel panic - not syncing: VFS: Unable to mount root fs。这说明整条内核初始化路径没问题rootfs 没进去。确认 initramfs 是否编进去了或者-initrd参数是否拼写正确。第三种Run /init as init process之后立刻出现Kernel panic - not syncing: Attempted to kill init!。这说明内核找到了 init但 init 程序无法运行。大概率是 init 缺少执行权限、解释器找不到或者 init 链接的库不存在。把 init 换成静态编译的 busybox 试试最容易定位。第四种程序运行过程中突然无响应或者 QEMU 退出了。noMMU 下用户态程序直接访问了非法地址触发 CPU 异常。这种情况在 MMU 系统上会以段错误形式体现noMMU 下则经常表现为系统直接挂死。处理方式是打开内核的CONFIG_DEBUG_USER选项让内核在用户态异常时打印更多信息能帮助你定位是哪个程序、哪条指令出的问题。6.3 真实板卡与 QEMU 的差异最后特别提醒一下 QEMU 和真实芯片的差异。QEMU 的 virt 平台是一个通用模拟平台CPU 特性、内存布局、外设都比较规整。真实 RISC-V noMMU 芯片往往有更多限制内部 SRAM 可能只有 64KB 到 2MBinitramfs 都放不下这时要考虑 XIPexecute in place让内核直接从 Flash 执行真实芯片的串口寄存器地址和 virt 平台完全不同必须配置正确的 earlycon 参数否则从开机就看不到任何输出芯片厂商的 BSP 很可能基于老版本内核noMMU 配置项的名称和位置都可能有差异不要想着把 6.1 的 config 直接套到厂商 4.x 内核上我在真实板卡上调试时最有效的工具就是加一个 GPIO 翻转函数放在启动早期代码里配合示波器观察程序执行到了哪里。这个办法虽然原始但比盲调效率高十倍。6.4 后续扩展方向项目跑通到“内核起来、用户态应用跑一遍”只是第一步后续还有一批值得深入的方向。我简单列几个供想继续往下走的人参考把根文件系统从 initramfs 换成外部 SPI Flash 挂载更贴近真实产品形态在 noMMU 内核里跑一个简单的网络协议栈验证 TCP/IP 在无隔离环境下的稳定性用 Buildroot 把整体构建脚本固化下来实现一键产出内核和 rootfs对比 glibc、uClibc-ng、musl 三者在 noMMU RISC-V 上的运行差异为具体项目选型提供数据支撑我个人在实际操作中的体会是noMMU Linux 并不神秘它只是把“内存管理”这层外衣脱掉让开发者直接面对物理地址空间。这在带来挑战的同时也迫使你更深入地理解进程、内存、编译链接之间的关系。跑通了之后很多以前看内核源码时模模糊糊的概念现在都变得非常具体。比如“页表”“写时复制”“进程地址空间”这些词在有 MMU 时是抽象的机制在 noMMU 下你会直观地看到它们被一个个取消掉之后系统是怎么工作的。如果刚接触这方向强烈建议找一块便宜的 RISC-V 开发板从无 MMU 的配置入手亲手编译、亲手烧录完整跑一遍收获会比只看文档大得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑