跳转到主要内容

在虚拟机里换上自己的 Linux 内核

先把 VMware Ubuntu 的 NAT 网络配稳,再在这台机器上编译安装自定义内核,最后加载一个 hello 模块——操作系统实验的一条完整路径。

这篇要完成的事只有一件:在 VMware 里的 Ubuntu 上,编译安装自己的内核,并加载一个自己写的 .ko

编译和换内核都在这台虚拟机里做,耗 CPU、内存和磁盘,还要进 GRUB。网络不稳时,长时间编译和 SSH 容易中断;内核没换上,模块也对不上正在跑的版本。因此先配静态 IP,再换内核,最后写模块。

从虚拟机到自定义内核
主机 → Ubuntu 虚拟机 → 自己的 vmlinuz → hello.ko
虚构示例

文中 ip1(宿主机在 VMnet8 上的地址)、ip2(NAT 网关)、ip3(虚拟机静态地址)、username 均为占位。请换成你 Virtual Network Editor 里看到的网段。

为何在虚拟机中编译内核

内核是操作系统最里面那一层:管 CPU、内存、磁盘、网卡和进程调度。终端、浏览器、apt 都在用户态,不能直接碰硬件,只能通过 readwritemmap 等系统调用请内核代劳。

层级例子权限
用户态bash、浏览器、Python不能直接碰硬件
内核vmlinuz、调度器、驱动唯一能直接操作硬件
硬件CPU、内存、网卡、磁盘

uname -r 打出来的就是当前正在跑的内核版本。后面编模块时,.ko 必须和这份内核用同一套头文件与 ABI,所以「先换成自己的内核」不是可选步骤。

主机是 Windows。虽然装过 Ubuntu 双系统,但更换内核可能影响已有软件依赖,所以没有在双系统那套 Ubuntu 上做这次实验,而是单独建了一台 VMware 虚拟机。编译仍会占磁盘、耗时间,但坏了可以回快照,不必拿日常环境试错。下面先给这台虚拟机配静态 IP,避免长时间编译时 DHCP 换地址、SSH 断开。

为虚拟机配置静态 IP

VMware 的 VMnet8 默认是 NAT:虚拟机走宿主机上网。网卡只能跟同一网段的设备直接说话,出网段必须交给默认网关。包的路径是:

Ubuntu → Gateway(通常是网段的 .2)→ VMware NAT → Windows 真实网卡 → 外网

记录 VMnet8 网段与网关

打开 Virtual Network Editor,选 VMnet8:

字段含义本文占位
Subnet IPNAT 网段,形如 x.x.x.0网段本身
Gateway IP虚拟交换机网关,通常是 .2ip2

再看 Windows:控制面板 → 网络和共享中心 → 更改适配器 → VMware Network Adapter VMnet8,宿主机在这张网上一般是 .1,本文写作 ip1

适配器用途
VMnet0桥接,进真实局域网,一般不用在编辑器里划网段
VMnet1仅主机,默认可 DHCP、不出网
VMnet8NAT,虚拟机经宿主机访问外网
警告

网关填成 ip1(宿主机)而不是 ip2(NAT 网关)时,常见现象是:能 ping 通 Windows,但上不了网。

配置 Ubuntu 静态 IPv4

设置 → Network → IPv4 → Manual

  1. Address:ip3(同一网段,且不要占用 .0 / ip1 / ip2,也避开 DHCP 池)
  2. Netmask:255.255.255.0(前 24 位)
  3. Gateway:ip2
  4. DNS:公共解析即可,例如 8.8.8.8223.5.5.5(这是告诉系统「域名翻译服务器在哪」,不是你的机器 IP)

新开终端检查:

hostname -I

应能看到 ip3。至此这台实验机地址固定,可以安心编几个小时内核。

编译并安装自定义内核

内核不必做成一块巨大的二进制。功能可以编进内核([*])、编成模块 [M](生成 .ko,用时再加载)、或不编 [ ]lsmod 看已加载的模块。

准备源码与 .config

查看当前版本,到 kernel.org接近的版本(下面以 5.15.221 为例;uname -r 若是 5.15.0-139-generic,选 5.15.x 比跨大版本更省事):

uname -r
tar -Jxvf linux-5.15.221.tar.xz
cd linux-5.15.221
cp /boot/config-$(uname -r) .config
make olddefconfig
tar 选项含义
-Jxz 压缩
-x解包
-v列出文件名
-f后面是归档文件

make olddefconfig:在已有 .config 上补全新选项的默认值,不弹问答。它只更新配置,不编译内核。

kernel.org 原版源码里没有 Ubuntu 那两份证书文件,需要把信任链路径清空,否则配置会指向不存在的文件:

./scripts/config --file .config --set-str SYSTEM_TRUSTED_KEYS ''
./scripts/config --file .config --set-str SYSTEM_REVOCATION_KEYS ''
选项原本作用
CONFIG_SYSTEM_TRUSTED_KEYS把可信 CA 编进内核,校验模块签名等
CONFIG_SYSTEM_REVOCATION_KEYS编进已吊销的证书

要确认「开机的是自己编的那份」,可在 init/main.cstart_kernel() 开头加一行(行号随版本会变):

printk(KERN_ALERT "Hello, World! This is a custom kernel!\n");

编译与磁盘空间

第一次编译很久,虚拟机核心数、内存、剩余磁盘都要够。先看空间:

df -h /

实测:

配置磁盘
保留默认 debug 信息40G 虚拟盘不够
关闭调试信息后再编大约 30G 可以编完

不够就关掉调试信息后再 make olddefconfig

./scripts/config --disable DEBUG_INFO_BTF_MODULES
./scripts/config --disable DEBUG_INFO_BTF
./scripts/config --disable DEBUG_INFO_DWARF4
./scripts/config --disable DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT
./scripts/config --disable DEBUG_INFO_REDUCED
./scripts/config --disable DEBUG_INFO_COMPRESSED
./scripts/config --disable DEBUG_INFO_SPLIT
./scripts/config --disable DEBUG_INFO
./scripts/config --disable NFSD
make olddefconfig

然后(-j 不要超过虚拟机 CPU 核数):

sudo make -j8

编完源码根目录应出现 modules.order(完整编完模块后的清单)。GCC 在这里的角色很简单:把 .c 译成内核/模块能跑的机器码。

磁盘写满后的扩容

编译把根分区写满时,不要立刻关机去做虚拟机扩容。系统空间为 0 时直接关机,可能再也进不去桌面(黑屏)。

应先在还能进系统时清出一点空间(例如删临时文件、未完成的编译产物),再关机。然后在 VMware 虚拟机设置里把虚拟磁盘调大。扩容只增大 .vmdk,客户机里多出来的是未分配空间,还要用分区工具并进文件系统。

  1. 仍能进入 Ubuntu 时:安装并打开 GParted,把根分区扩展到未分配区域。
  2. 已经黑屏、无法进入系统时:用 Ubuntu 安装镜像启动,选 Try Ubuntu,在 Live 环境里打开 GParted,同样把扩容后多出的未分配空间并进根分区。
警告

先清空间再关机,再改虚拟机磁盘大小。满盘关机后再扩容,常见结果是进不了系统,只能走 Live USB / 映像的 Try Ubuntu。

安装、更新 GRUB、验证

sudo make INSTALL_MOD_STRIP=1 modules_install
sudo make install
ls /lib/modules/5.15.221
sudo update-grub

重启后应能进新内核。make install 之后,GRUB 第一项通常已经是刚编的版本。

补充(本次未用)

若要进 BIOS 改启动项,可在虚拟机关机后编辑对应 .vmx,末尾加一行(一次性:开机后会被改回 FALSE):bios.forceSetupOnce = "TRUE"。Windows 若看不见后缀:资源管理器 → 查看 → 显示 → 勾选「文件扩展名」。

加载时应能在内核日志里看到自定义 printk。确认:

uname -r
sudo dmesg | grep -i hello

uname -r 变成 5.15.221(或你编的版本),dmesg 里有那句 Hello,这一段才算完成。后面的模块会按 uname -r 去找 /lib/modules/.../build,所以必须先走到这一步。

编写并加载内核模块

模块是「不必重编整颗内核、又能跑在内核里」的那一层。它会 insmod正在运行的内核,因此必须走内核自带的 Kbuild,而不是 gcc hello.c -o hello。目录用当前内核的构建树:/lib/modules/$(uname -r)/build

源码与 Makefile

在干净目录创建 hello_module.c

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init hello_init(void)
{
	printk(KERN_INFO "Hello, Kernel!\n");
	return 0;
}

static void __exit hello_exit(void)
{
	printk(KERN_INFO "Goodbye, Kernel!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("username");
MODULE_DESCRIPTION("A simple hello world Linux kernel module");
MODULE_VERSION("1.0");
看不见的空格

从网页复制时,行里可能混入不间断空格(NBSP)。gcc 会把 #include 行末的 NBSP 当成多余符号,把行首 NBSP 报成 stray '\302'。用普通空格即可。

同一目录 Makefile(配方行必须是 Tab,不能是空格):

obj-m += hello_module.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
片段含义
obj-m编成模块 .kom = module;obj-y 才是编进 vmlinuz
-C .../build进入当前正在跑的内核的 Kbuild
M=$(PWD)模块源码在当前目录(out-of-tree)
$(shell uname -r)自动对齐版本,避免和上一节装的内核错位

加载与卸载

make
sudo insmod hello_module.ko
sudo dmesg | tail
sudo rmmod hello_module
sudo dmesg | tail
make clean

成功时日志里会有 Hello, Kernel! 和卸载后的 Goodbye, Kernel!make 日志里 Entering directory 应指向你上一节安装的那份内核源码/build,而不是发行版自带的另一棵树。

小结

步骤产出下一步为什么需要它
VMnet8 静态 IP固定的 ip3,NAT 能上网长时间编译、SSH 进虚拟机时地址不会飘
自定义内核安装成功uname -r 变成自己编的版本模块必须跟正在跑的内核同一套 ABI
hello_module.ko + insmoddmesg 里看到 Hello证明「内核 + 模块」整条实验链通了

下次若 insmod 失败,先查 uname -rls /lib/modules/$(uname -r)/build,确认是否还在发行版内核上,或 Makefile 是否指错了构建树。