有一说一,操作系统实验做到“Linux 内核编译及运行”这一步,才算真正开始摸到操作系统的门槛。前面那些进程、线程、调度算法的实验,大多还是在“用”操作系统,而这个实验是在“改”操作系统。整条链路下来:拿源码、给配置、触发编译、装模块、更新引导、重启验证,每一步都有讲究,任何一环出问题,轻则编译失败,重则开机直接进不了系统。
我这次在虚拟机里完整跑了一遍这个实验,用到的环境是某高校操作系统课程实验指导书里推荐的配置:宿主机开 VMware Workstation,客户机装 Ubuntu,内核源码选的是当前稳定版的 Linux 主线内核。整个过程做下来大概花了一个下午加一个晚上,中间踩了磁盘空间不足、编译选项冲突、重启后进不了新内核这几个典型的坑。这篇就把从零到一的操作记录、每一步的底层逻辑、以及踩坑之后的完整排查思路都写出来,给后面做同样实验的同学当个参考,也欢迎已经跑通的朋友一起交流。
1. 实验目标与前置准备:先搞清楚内核编译到底在“编”什么
1.1 内核编译不是“编译一个程序”那么简单
很多同学第一次看到内核编译的实验指导书,第一反应是“这不就是 make 一下吗”,实际上远不止。Linux 内核本身是一个宏内核,但它不是一整个单一的可执行文件,而是由核心映像(vmlinuz)、可加载内核模块(.ko 文件)、头文件、以及一堆构建期生成的中间文件共同组成的综合体。
编译内核的本质,是把成千上万个 C 源文件,按照你给定的配置选项(就是那个 .config 文件)组合成一套可启动的系统核心。这套核心决定了操作系统能识别哪些硬件、支持哪些文件系统、启用了哪些调度策略、包含哪些安全模块。换句话说,你在 menuconfig 里做的每一个选择,最后都会直接影响你重启之后那个系统的“性格”。
实验指导书要求“编译并能运行”,深层目的是让我们理解这么几件事:
- 内核的构建体系(Kconfig / Kbuild)是如何工作的
- .config 配置选项如何决定内核的功能集合
- 模块机制如何让内核功能可以按需加载、无需重编整个内核
- 引导加载程序如何找到内核映像并完成启动
理解了这几件事,后面每一步操作才有意义,而不是机械地敲命令。
1.2 环境清单与磁盘空间预估
我在实验里使用的环境如下,仅供参考:
- 宿主机:Windows 11,VMware Workstation 17
- 虚拟机:某发行版 Linux,内核版本 5.15,分配 4 核 CPU、8GB 内存
- 内核源码:Linux 6.x 稳定版(从内核官网下载的 tar.xz 源码包)
- 磁盘规划:根分区分配 40GB 虚拟磁盘,/boot 分区独立分配 1GB
这里特别强调磁盘空间,因为这是我整场实验第一个坑。首先,内核源码包解压之后大概 1.3GB 左右,如果你开启了 CONFIG_DEBUG_INFO 这种调试信息选项,编译中间产物会膨胀得非常快,/usr/src 目录下面最后可能吃掉 5 到 8GB 空间。再加上模块安装到 /lib/modules 下,以及 /boot 下生成的 vmlinuz、initrd.img、System.map,我建议虚拟机的根分区至少给到 20GB 以上,/boot 分区独立给 500MB 以上,如果磁盘宽裕,直接给 40GB 根分区最省心。
这也是为什么我一直建议用虚拟机而不是物理机做这个实验的原因。虚拟机可以提前打快照,编译失败、引导损坏、重启起不来,一键回滚,心理压力小很多。物理机如果没做好引导备份,一旦 grub 配置写错或者 initrd 生成失败,运气不好就是开不了机,处理起来非常麻烦。
1.3 虚拟机里的“快照思维”要提前建立
做内核编译这种高危实验,强烈建议在动手之前先打一个干净快照。我的习惯是这样:
- 刚装完系统、能正常上网的时候,打第一个快照,叫“clean-base”
- 安装完编译工具链(build-essential、libncurses-dev、bc 等)之后,再打一个快照,叫“with-toolchain”
- 每次准备改引导配置之前,手动再补一个快照
这样做的原因是,内核编译实验中很多问题不是“编译失败”这种温和错误,而是“重启之后直接进不了系统”的严重故障。有了快照,最坏情况也就是回到十分钟之前的状态,重来一遍。没有快照,你可能要在 grub 命令行里手忙脚乱地 rescue,甚至重装系统。
整个实验流程我梳理成了下面这张表,方便你心里先有个全貌:
| 阶段 | 关键动作 | 产出物 | 常见失败点 |
|---|---|---|---|
| 源码准备 | 下载、校验、解压 | 内核源码树 | 磁盘空间不足 |
| 配置生成 | make menuconfig 或复制现有 config | .config 文件 | 配置依赖冲突、选项丢失 |
| 编译 | make -jN | bzImage、内核模块 | 工具链缺失、磁盘写满 |
| 模块安装 | make modules_install | /lib/modules/版本号/ | 目录权限不足 |
| 内核安装 | make install | /boot/vmlinuz、initrd.img、grub 菜单更新 | GRUB 配置失败 |
| 重启验证 | 重启选新内核并登录 | 新内核运行环境 | 进不去系统、无网卡、无模块 |
2. 内核源码获取与配置策略:从哪下载、怎么配置、为什么这样配
2.1 源码下载与完整性校验
内核源码的获取方式有很多,常见的有三种:直接从内核官网下载 tar.xz 包、用发行版仓库里的 linux-source 包、或者 git clone 官方仓库。
我这次用的是官网下载 tar.xz 的方式。原因很简单:实验报告需要体现“完整下载并编译官方主线内核”这个动作,用发行版自带的 linux-source 包虽然省事,但很多时候源码版本和当前系统内核版本几乎一样,实验价值会打折扣。
下载之后不要急着解压,先做校验。tar.xz 包旁边一般有对应的 .tar.sign 签名文件,正规做法是用 gpg 验证签名,但对学生实验来说,至少要做一次 sha256sum 校验,确认下载的文件没有损坏。我当时没做这一步,结果编译到一半报了一个奇怪的语法错误,查了半天发现是源码包在下载过程中损坏了,白费了一个小时,血泪教训。
解压的时候注意目录命名。内核源码编译有一个不那么起眼但很重要的规则:源码路径里最好别有中文、空格、特殊字符,否则会有一些 Makefile 层面的兼容问题。我习惯解压到 /usr/src 下,命令大概是:
sudo tar -xvf linux-6.x.tar.xz -C /usr/src cd /usr/src/linux-6.x如果提示权限不够,记得用 sudo,或者在 /usr/src 下先授权给自己。
2.2 为什么我建议复制当前 config,而不是 make defconfig
拿到源码后的第一件事不是编译,而是生成配置文件。这个 .config 文件是内核编译的“总开关”,里面每一行 CONFIG_XXX=y/m/n 都对应一个功能模块的取舍。
很多教程上来就说 make defconfig,这个命令会生成一个默认配置,理论上能编过,但实际跑起来很可能缺驱动、缺功能,尤其是在虚拟机环境里,网卡驱动、图形支持、虚拟机工具模块可能都没编进去。更稳的做法是复制当前系统的配置文件作为起点:
cp /boot/config-$(uname -r) .config make olddefconfig这几行命令的逻辑是:当前系统正在跑的内核,它的配置一定是最适合当前硬件环境的。把它作为基线,然后用 olddefconfig 把源码新增的配置项填成默认值,既保留了系统原本能用的所有硬件支持,又不会因为漏了新选项导致配置不完整。
这里解释一下 olddefconfig 和 oldconfig 的区别。oldconfig 是逐条交互式询问新增选项,会卡在终端里一条一条按回车;olddefconfig 则是不询问,直接把没显式设置的选项置为默认值。实验场景下直接用 olddefconfig 更顺滑,不用守着终端按键。
如果你对内核比较熟悉,想顺手体验一下裁剪,可以再执行:
make menuconfigmenuconfig 是基于 ncurses 的图形化配置界面,按空格切换 y/m/n,y 表示编译进内核,m 表示编译成模块,n 表示完全不编译。实验报告里如果能写清楚如何在 menuconfig 里添加对某个文件系统的支持,比单纯贴命令更有说服力。
2.3 menuconfig 里真正值得动一动的选项
虽然复制当前配置之后不做修改也能编,但我建议在 menuconfig 里至少做两三个有意义的改动,这样实验报告才有“你主动配置过内核”的证据。
比较自然的选择是调整 CPU 相关的优化选项。在 “Processor type and features” 里把 “Processor family” 选成和虚拟机 CPU 匹配的型号,比如 Generic-x86-64 或者某个具体型号。这个选项会影响编译优化,选得越准,编译出的内核理论上执行效率越高。
另一个值得动的是 “General setup” 里的 “Local version - append to kernel release”。在这里加一个自定义后缀,比如 -exp,这样编译安装之后,uname -r 会显示类似 6.x.x-exp 的版本号。这个后缀最大的好处是:你能一眼区分当前跑的是原版内核还是自己编译的内核,也方便在实验中重复编译不同版本而不会互相覆盖。
做完配置改动之后,记得检查一下 .config 里的关键项。比如 CONFIG_MODULES=y,这是内核模块机制的总开关,如果被设成 n,后续 make modules_install 会直接失败。我见过有同学在 menuconfig 里手滑把模块支持关掉的,编译全程都在报错,所以这个点可以多留个心眼。
3. 全量编译、模块安装与引导更新:核心操作流程复盘
3.1 并行编译参数与资源分配
配置生成之后,正式编译。直接裸跑 make 也能编,但速度慢得让人崩溃,尤其是现在内核动辄几万个源文件,单线程编译可能需要两三个小时以上。所以要善用并行编译参数:
make -j$(nproc)nproc 会输出 CPU 逻辑核数,-j 让 make 同时跑多个编译任务。我给虚拟机分配了 4 核,所以这里实际执行的是 make -j4,整个内核编译大约用了 25 到 30 分钟。如果你给虚拟机分配了 8 核,时间能压缩到 15 分钟以内。
但这里有个度。如果 -j 参数超过实际核数太多,比如 4 核机器直接 -j16,反而会因为频繁切换任务、内存吃紧导致编译变慢,甚至触发 OOM。我一般遵循“逻辑核数乘以 1 到 1.5”这个经验值。
编译过程中屏幕上会滚过大量信息,大致长这样:
CC init/main.o CC arch/x86/entry/syscalls/syscall_64.o ... LD vmlinux OBJCOPY arch/x86/boot/bzImage看到 LD vmlinux 之后又出现 OBJCOPY arch/x86/boot/bzImage,说明核心内核映像已经构建成功,这是好消息。bzImage 是 x86 平台上实际参与引导的压缩内核映像,后面 make install 主要就是把它安装到 /boot 目录。
3.2 两次 make 之间的分工:编译核心、编译模块、安装各司其职
内核模块的编译和核心映像的编译是同时进行的,都是在执行 make 的时候完成。但安装阶段一定要分两步,而且要按顺序:
sudo make modules_install sudo make install第一步 modules_install 会把编译出来的 .ko 内核模块安装到 /lib/modules/<内核版本>/ 目录下。所有你配置为 =m 的功能,都会以模块文件的形式出现在这里。这个目录可以理解为内核功能的“外挂仓库”,内核启动后再按需加载。
第二步 make install 做的事情更多:
- 把 arch/x86/boot/bzImage 拷贝成 /boot/vmlinuz-<版本号>
- 生成对应的 /boot/initrd.img-<版本号>(初始 RAM 磁盘)
- 拷贝 System.map 和 .config 到 /boot 下
- 自动执行 update-grub,把新内核添加到 GRUB 菜单
为什么要先装模块再 make install?因为 initrd.img 的生成过程需要读取模块目录。如果你先执行 make install,系统会尝试生成 initrd 但里面对应的新内核模块目录还不存在,生成的 initrd 很可能缺关键的磁盘驱动模块,重启时就会在挂载根文件系统这一环节卡住。
顺序很重要,先 modules_install,再 install,不要颠倒。
3.3 make install 之后的引导更新逻辑
make install 最后一步会调用 update-grub,这个命令的本质是扫描 /boot 目录下所有内核映像,生成 GRUB 菜单项。操作系统的 GRUB 工作流程其实是多级引导链条:
BIOS/UEFI 固件启动 → 读取磁盘开头引导代码 → 加载 GRUB 核心 → GRUB 读取 /boot/grub/grub.cfg → 按菜单加载 vmlinuz 和 initrd.img → 内核初始化 → 挂载根文件系统 → 启动 init 进程
所以只要任何一个环节对不上,就进不了系统。比如 /boot 下有了新的 vmlinuz,但 initrd.img 没生成,或者 grub.cfg 里没能扫描到新内核,重启之后 GRUB 菜单里可能压根看不到新内核选项。
实验过程中我建议在重启前先手动查看一下 /boot 目录:
ls -lh /boot正常情况下应该能看到 vmlinuz-<版本号>、initrd.img-<版本号>、System.map-<版本号>、config-<版本号> 四个文件齐全。如果缺哪个,不要急着重启,先把缺的补上,否则大概率要在 GRUB rescue 里折腾。
4. 新内核启动验证与信息读取:如何确认你编出来的内核真的在跑
4.1 从 uname 到 /proc:验证内核版本
编译安装完成,重启虚拟机,在 GRUB 菜单里选择新内核。如果一切顺利,系统会正常登录。但“看起来正常”不算数,要用几条命令验证当前跑的到底是不是你刚编译出来的内核。
uname -r uname -v cat /proc/versionuname -r 显示内核版本号,如果显示的是你配置时加的 Local version 后缀(比如 6.x.x-exp),说明新内核已经生效了。uname -v 会显示内核的编译时间,如果你这次编译是在今天下午完成的,这里显示的时间也对得上,那就是没跑错。
cat /proc/version 则给出更完整的信息,包括编译器版本、构建主机信息。这里我特别想提醒一点:如果 uname -r 显示的依然是系统自带的老版本号,而你明明在 GRUB 菜单里选了新内核,最常见的原因有两个,一是没真正选到新内核(默认启动项还是旧内核),二是 make install 时 vmlinuz 没有被正确覆盖或命名。
默认启动项的问题,可以这样查:
grep 'menuentry ' /boot/grub/grub.cfg看看新内核的菜单项排在第几位,然后确认 /etc/default/grub 里 GRUB_DEFAULT 指向的是不是它。如果希望新内核默认启动,把 GRUB_DEFAULT 设为对应菜单项的编号,比如 "1>0" 这种形式,再执行 sudo update-grub。
4.2 dmesg 日志里的启动痕迹
验证新内核在跑,除了 uname,还有一条信息量更大的路子:dmesg。
dmesg 是内核环形缓冲区,里面存着内核从启动到当前时刻打印的日志,是理解系统启动过程的第一手资料。比如开机第一条日志类似:
dmesg | head -20你能看到 Linux version 那一行,直接写明内核版本、编译者、编译器版本,以及构建时间和构建主机。这和我前面说的 uname -v 是对应的。
再往下翻,dmesg 里还能看到:
- 内存检测信息(Memory: ... available)
- CPU 型号与特性(smpboot: Allowing 4 CPUs)
- ACPI 表解析结果
- 各类设备驱动注册(例如网卡、磁盘控制器、虚拟化相关驱动)
- 文件系统挂载信息
实验报告里如果要求“截图证明内核编译成功并运行”,uname -r 加 dmesg 头部信息就足够了。还可以结合 /proc/cmdline 看一下引导参数,比如:
cat /proc/cmdline一般会看到 BOOT_IMAGE=/boot/vmlinuz-<版本号>、ro、root=UUID=xxx 之类的内容。这行内容就是 GRUB 在启动内核时传给内核的命令行参数,能帮你确认引导用的确实是新的 vmlinuz。
4.3 模块目录与新内核的“指纹”一致性
还有一个坑值得专门说一下,就是模块目录和内核版本不一致的问题。
Linux 在加载模块时,会去 /lib/modules/$(uname -r)/ 这个特定目录下找 .ko 文件,目录名必须和当前运行的内核版本完全一致。如果你编译安装完成之后,ls /lib/modules/ 看到的目录名和 uname -r 对不上,说明 modules_install 可能没成功,或者你之前手动清理过。
我用这条命令验证:
ls /lib/modules/ uname -r正常情况下会有至少两个目录,一个是系统原装内核对应的版本,一个是你新编译内核对应的版本。如果你发现新版本目录是空的,或者目录根本不存在,那就是 modules_install 出了问题,需要重新执行。
有了新内核版本目录之后,还可以用 modinfo 检查某个具体模块有没有安装成功,比如:
modinfo /lib/modules/$(uname -r)/kernel/fs/ext4/ext4.ko能正常输出模块描述、作者、依赖信息,说明模块安装链路是通的。如果提示文件不存在,说明你编译配置里那个文件系统被设成了内置(=y),本来就编进内核了,不在模块目录里,这也是正常现象。
5. 编译过程中最常见的四类问题排查链路
5.1 磁盘空间告急:在编译前就该做的检查
我这次第一个踩的坑就是磁盘空间。编译到一半,终端滚动刷出几百行类似 No space left on device 的错误,然后整个编译进程退出,人直接愣住。
当时我的完整排查链路是这样的,供后面的人参考。
第一步,先看文件系统使用率:
df -h结果发现根分区 / 已经到 100%,/boot 也只剩几十 MB,基本什么都写不进去了。第二步,找出空间被什么吃了:
du -sh /usr/src/linux-6.x du -sh /lib/modules du -sh /boot一查发现源码树和模块目录占了大头,其中源码树编译中间产物就有 4 个多 G。第三步,根据具体情况处理。我的虚拟机磁盘才 20GB,根分区确实不够,最终解决方案是给虚拟机扩展虚拟磁盘到 40GB,然后用 GParted 之类的工具把根分区扩大,重新编译,中间步骤颇多。
事后复盘,这个坑完全是可以在动手之前就避掉的。建议在解压源码之前,先执行 df -h 看一下根分区剩余空间,低于 15GB 就优先扩容。另外,如果只是编译实验用,不在源码树里反复折腾,可以把这个选项关掉:
scripts/config --disable DEBUG_INFO关掉 debug info 之后,整个编译产物体积能缩水一半以上,实验场景完全够用。这也是我后来在报告里写“为了控制编译时间与磁盘占用,关闭内核调试信息”这句话的由来。
5.2 编译中断与报错:先看配置依赖,再谈工具链
编译过程中报错是家常便饭,但报错的原因绝大多数可以归为两类:配置依赖冲突、工具链缺组件。
我遇到过一次比较诡异的情况,编译到某个驱动模块时报了 undefined reference 的错误,看着像是代码问题,实际上是因为某个内置符号被配置成了模块,而模块又依赖了另一个没选的特性。
这种问题的排查思路,我总结成三步:
第一步,先定位报错文件。在满屏的编译日志里往上翻,找到包含 error: 的行,定位到具体目录和源文件。
第二步,用带详细输出的方式重新编译:
make V=1V=1 会让 make 输出完整的编译命令行,比如具体的 gcc 参数、头文件搜索路径、宏定义,很多隐性问题能从这里看出来。
第三步,分析配置依赖。内核 Kconfig 体系里的选项之间有 strict 依赖关系,你在 menuconfig 里把一个选项改成 =m,它依赖的另一个选项如果还是 =n,编译就可能挂掉。遇到这种,最简单的办法是回到 menuconfig 里检查该选项的依赖提示,把依赖项一并打开。
还有一个更省力的兜底策略:如果你改了不少配置,越改越乱,干脆重新复制当前系统的 config,再 make olddefconfig,放弃自定义改动,以稳定为优先。实验的核心目标是“编出来并跑起来”,不是“编出一个惊世骇俗的自定义内核”,目标要分清。
5.3 重启进不了新内核:检查引导链条
这个坑是内核编译实验里最刺激的。我第一次做实验时不巧,新内核写进了 GRUB 菜单,但重启后直接黑屏,屏幕上只剩一个光标在闪,系统完全起不来。
当时的排查链路是这样的:先在 GRUB 菜单里选择旧的 Linux 内核版本进入系统,确保系统还能用,然后开始分析新内核为什么起不来。
GRUB 菜单里按 e 进入编辑模式,在 linux 那一行末尾删掉 quiet splash,加上一个空格再写上:
systemd.log_level=debug或者干脆加一个 break=top 之类的参数,让 initramfs 阶段停下来,看看卡在哪一步。实际跑起来之后,发现是 initrd.img 的问题——启动时内核尝试挂载根文件系统,但没有对应的块设备驱动,盘符不存在,自然挂不上根。
根源就是 make install 生成 initrd 的时机不对。前面说过,如果 modules_install 和 make install 顺序颠倒,或者新内核模块目录缺失,initrd 里就打包不进磁盘驱动模块。这个问题的根治方法:
sudo mkinitramfs -o /boot/initrd.img-<版本号> <版本号>手动重新生成 initrd,确认输出文件大小正常。如果 initrd 只有几十 KB,几乎可以肯定里面是空的;正常的内核 initrd 应该有几十 MB 的大小。
处理完 initrd,再执行 sudo update-grub,重新生成引导菜单,重启应该就能进新内核了。整个过程的核心就是:引导是一个链条,哪一环断了都会启动失败,而 initrd 是最容易出问题的环节。
5.4 新内核启动后没有网络:驱动模块缺失的定位方法
进得了系统但没网络,这个问题出现的概率非常高。我身边有好几个同学都栽在这上面,因为新配置复制了旧系统的 config 理论上不应该丢网卡驱动,但如果你自己裁剪过内核配置,或者用的是 make defconfig 起步,网卡驱动极有可能没编进去。
排查链路如下。首先确认网卡设备在系统里存不存在:
lspci | grep -i ethernet如果能看到网卡设备,说明硬件在。然后看驱动加载情况:
lspci -k这一行会显示设备的 kernel driver in use 和 kernel modules。如果驱动显示为空白,或者模块列表为空,说明新内核里确实没有这个网卡的驱动。
对于 VMware 虚拟机,常见网卡驱动是 e1000、e1000e、vmxnet3。解决方法是回到源码目录,menuconfig 里找到对应的驱动选项(Device Drivers → Network device support → Ethernet driver support),把对应驱动改成 =y 或 =m,重新编译,再走一遍模块安装和内核安装流程,重启即可。
如果不想重新编译整个内核,还有一个临时方案:从旧内核的模块目录里手动加载驱动:
modprobe e1000e前提是旧内核模块和新内核模块兼容。但实验场景下我更推荐直接重新编译一次,反正也就二三十分钟,顺便体验一下增量编译的速度——第一次全量编译可能要半小时,第二次只编译改动过的部分,几分钟就完成了。
6. 实验之外:内核编译能继续延伸的方向
跑完一遍完整的内核编译和启动,操作系统实验的基础目标就达成了。但如果不想让这个实验停留在“照着课件敲命令”的层面,我建议在剩余时间里往这几个方向再走一步。
第一个方向是内核模块编程。编译完内核之后,你的系统已经有了完整的内核头文件和模块构建环境,可以直接写一个最简单的 helloworld 内核模块,用 Makefile 配合 Kbuild 编译,然后 insmod/rmmod 加载卸载,在 dmesg 里看到输出。这一步做出来,“操作系统”在你眼里会从抽象概念变成一组可观察、可控制的内核机制。
第二个方向是系统调用。实验指导书里如果后续有“添加一个系统调用”的实验,这次内核编译就是最好的铺垫。改内核源码里 syscall 表,加一个自己的调用号,在用户态写个 C 程序触发它,再通过 dmesg 验证调用是否进入内核,整套链路能把操作系统从用户态到内核态的路径真正串起来。
第三个方向是内核裁剪。如果对嵌入式感兴趣,可以尝试用 make menuconfig 把大量设备驱动去掉,只保留基本串口、RAM 盘、initramfs 支持,编译一个极小的内核,配合一个简单的 initramfs 启动到最小 shell。这个过程能让你直观感受到“配置选择如何改变内核体积与功能边界”。
不管往哪个方向延伸,这次编译实验积累下的经验和踩坑意识都会派上用场。最后再分享一个我自己的小习惯:每次编译前把终端里敲过的命令同步记录到实验报告的草稿里,每条命令旁边留一行写下“为什么这样写”,编译完成后直接整理成文,比事后回忆高效得多。