做 Linux 内核裁剪这件事,听起来很高端,本质上就是给内核做减法。我去年给一台跑单一业务的 X86 工控机做了一次完整的 linux 内核裁剪,把一个 12.8MB 的 Debian 默认 bzImage 一路砍到 5.7MB,可加载模块从 5300 多个精简到 84 个,开机到登录从 23 秒降到了 6.8 秒。整个过程有惊喜也有翻车,今天这篇就把这次内核裁剪的经验完整复盘一下,从动机、摸底、逐项取舍,到编译启动时踩过的坑,以及验证和回滚的方法,全部摊开讲。不管你是给嵌入式设备省空间,还是给服务器提速,这套思路都能直接套用。
1. 裁剪动机:发行版内核"全家桶"与我的瘦身目标
1.1 发行版内核为什么"什么都带"
发行版内核的设计哲学和定制内核完全不同。Debian、Ubuntu、CentOS 这些发行版要面对的是"地球上所有可能的硬件组合",所以它们编译内核时的心态是:不管用不用得上,先全部编进去再说。于是你会在配置里看到几十种文件系统、几百个网卡驱动、各种冷门的工业总线协议——全被编译成模块塞进了/lib/modules。
这就像买了台标配车,后备箱里永远放着你可能这辈子都用不上的全套工具。平时没啥感觉,但对一台用途明确的专用机器来说,这种"全家桶"策略的代价很具体:
- 磁盘空间:装完内核后
/lib/modules里有几千个.ko文件,随便就是几百 MB。 - 启动时间:内核启动时要扫描总线、探测设备、触发 udev 去匹配 modalias,模块越多,匹配和加载的耗时越长。
- 内存占用:模块的元数据、页表项、以及被自动加载进内存的驱动,都会吃掉宝贵的内存。
- 安全面:每多一个驱动或协议栈,就多一分被攻击或误触发的风险。
我手头那台工控机,配置是 Intel 赛扬 J4125、4GB 内存、一块 SATA SSD、双千兆网卡,业务就是采集串口数据然后通过网络上报,固定到不能再固定。这种机器装一个通用发行版内核,等于每天穿着全套滑雪装备去上班,纯属浪费。
1.2 我先量化三个目标再动手
裁剪这种事最怕"边剪边看,随缘优化",所以我先定了三个硬指标,都是有依据的:
| 指标 | 裁剪前 | 目标值 | 判断依据 |
|---|---|---|---|
| bzImage 镜像体积 | 12.8MB | ≤ 7MB | 内核本身应该轻装上阵 |
| 开机到登录耗时 | 23.1s | ≤ 10s | 工控机要快速恢复业务 |
| 启动后已加载模块数 | 148 个 | ≤ 50 个 | 只保留这台机器实际用到的 |
我特意把"已加载模块数"而不是"可用模块总数"作为核心指标,因为内核真正占资源的是被加载进内存并在运行的那些模块,而不是躺在磁盘上睡觉的。另外我还给自己定了一条安全底线:裁剪期间 grub 里永远保留一个已知能正常启动的旧内核入口。后面你会看到,这条底线救了我两次。
2. 开工前摸底:配置快照、硬件清单与模块依赖树
2.1 从 /proc/config.gz 拿到完整配置快照
裁剪的第一步不是动手删,而是先把"家底"摸清楚。内核编译时如果开启了CONFIG_IKCONFIG_PROC,运行中的内核会把完整配置打包在/proc/config.gz里。如果你的内核没开这个选项,可以从/boot/config-$(uname -r)拿到一份编译时的默认配置。
uname -r # 6.1.0-13-amd64 # 优先从 /proc 导出当前运行内核的真实配置 zcat /proc/config.gz > /tmp/current.config # 或者从 /boot 拷贝发行版默认配置 cp /boot/config-$(uname -r) /tmp/current.config # 查看关键选项是否已开启 grep -E 'CONFIG_IKCONFIG_PROC|CONFIG_BLK_DEV_INITRD' /tmp/current.config这份配置快照后面有大用:一是作为裁剪的起点,二是跟裁剪后的配置做 diff,搞清楚自己到底动了哪些选项。我强烈建议你像我一样先把/tmp/current.config存一份,千万别直接拿它当.config来用。
2.2 用 lspci/lsusb 盘点必须保留的硬件驱动
裁剪的本质是"按需保留",所以你必须先明确这台机器到底有哪些硬件。三个命令就够:
lspci -nnk # 看 PCI 设备以及当前绑定的内核驱动 lsusb # 看 USB 设备 lshw -short 2>/dev/null | head -50 # 综合硬件信息当时我盘点完的结论是:SATA 控制器、双网卡(RTL8111/8168)、USB 控制器、串口(板载 8250/16550)、核显(但机器是纯headless 运行,只用串口做 console)。对应到内核配置上,就是下面这几组必须保留的选项:
| 硬件设备 | 对应内核配置 | 建议形式 |
|---|---|---|
| SATA 控制器 | CONFIG_SATA_AHCI、CONFIG_ATA | 编入内核 =y |
| 板载网卡 RTL8168 | CONFIG_R8169 | 编入内核 =y |
| USB 3.0 控制器 | CONFIG_USB_XHCI_HCD、CONFIG_USB_STORAGE | 模块 =m |
| 板载串口 | CONFIG_SERIAL_8250、CONFIG_SERIAL_8250_CONSOLE | 编入内核 =y |
| 核显(可关) | CONFIG_DRM、CONFIG_FB | 裁剪掉 |
这里有个关键思路:如果这台机器从今往后不会再插拔新硬件,那驱动可以大胆裁;如果还有可能接新设备,就得多留一个心眼。我当时考虑得很清楚,这台工控机上线后外设就锁死了,所以走的是激进路线。通用服务器别学我,老老实实用localmodconfig就好。
2.3 顺着 modules.dep 理清模块依赖树
模块不是孤立存在的,thermal这种看起来人畜无害的模块,背后可能挂着一整串依赖。想让裁剪不出乱子,必须先看懂模块之间的依赖关系:
# 看看当前加载了哪些模块,按引用次数排序 lsmod | sort -k2 -n # 查看某个模块的依赖链 modprobe --show-depends thermal # insmod /lib/modules/6.1.0-13-amd64/kernel/drivers/thermal/thermal_sys.ko # insmod /lib/modules/6.1.0-13-amd64/kernel/drivers/thermal/thermal_sys.ko.zst # 依赖关系的静态索引 grep thermal /lib/modules/$(uname -r)/modules.depmodules.dep是depmod生成的模块依赖索引,modprobe加载一个模块时会先看一眼这个文件,把依赖模块按顺序全部拉起来。你裁剪时如果保留了一个模块,它的所有依赖模块也必须保留,否则运行时会报Unknown symbol或者直接加载失败。
另外要明白 udev 的自动加载机制:设备插入后,内核上报 modalias,udev 根据/lib/modules/.../modules.alias找到对应模块并触发modprobe。所以如果你把某个驱动从配置里摘掉了,系统启动时不会报错,但设备会一直处于"不被驱动"的状态,表现为网卡不出现、硬盘识别不到这种诡异问题。这也是为什么每次裁剪完,我都习惯性地lspci -k看一眼驱动有没有正确绑定上。
3. 裁剪三板斧:localmodconfig、menuconfig 与 =y/=m 取舍
3.1 第一板斧:localmodconfig 自动瘦身
摸底做完,正式开工。第一步是让内核自己帮你做一次粗剪,工具叫localmodconfig,逻辑很简单:读当前系统lsmod的加载列表,配合modules.dep反推,凡是没有被加载过的模块,对应的配置项一律改成n,加载过的保留为m。这一步跑完,几千个模块配置基本能砍掉七成以上。
cp /tmp/current.config .config LANG=C make localmodconfig之所以要加LANG=C,是因为内核的配置脚本在某些本地化环境下会输出乱码导致交互界面错乱,我踩过一次,后面就老实了。另外提醒一句:跑 localmodconfig 之前,先确认这台机器当前状态就是它日常工作的状态。如果平时会插 USB 设备、接调试线、挂移动硬盘,这些设备最好提前插上再跑,否则这些驱动会被当成"没用的东西"直接裁掉。
如果你是在别的机器上分析模块列表,可以手动指定:
lsmod | awk 'NR>1 {print $1}' > /tmp/modules.txt make LSMOD=/tmp/modules.txt localmodconfigLSMOD指向一个模块名列表文件,脚本会按这个列表而不是当前环境来裁剪。这个技巧在批量处理同型号机器时非常好用,先在一台标准机上导出模块列表,剩下的机器直接套用。
3.2 第二板斧:menuconfig 里关掉六大类"大户"
localmodconfig只处理了"模块"的取舍,还有大量以=y形式编进内核的功能它动不了,这就需要人工干预了。我习惯用make menuconfig做精细调整,交互界面清晰,搜索选项也方便:
make menuconfig # 需要安装 libncurses-dev,否则会报找不到 menuconfig 界面在 menuconfig 里我一共关了六大类东西,每一类的理由都给你列清楚:
第一类:用不到的文件系统。这台机器根分区是 ext4,我又不需要挂 Windows 盘、玩网络文件系统,所以xfS、btrfs、reiserfs、f2fs、ntfs3、nfs、cifs、fuse全部关掉。注意fuse如果关掉,某些基于 FUSE 的用户态文件系统(比如部分网盘客户端)就会失效,关之前确认业务没依赖。另外SquashFS 建议保留,很多软件包格式和快照机制靠它。
第二类:无线、蓝牙与多余的网络协议。工控机只用有线网,于是CFG80211、MAC80211、BT、WIRELESS_LAN整组关掉。网络协议里我把CAN总线协议、各种冷门NET_VENDOR_*驱动也关了,但特意留着 IPv6。有相当多 Linux 程序和服务依赖 IPv6 loopback(比如某些容器和调试工具),关 IPv6 省不了多少东西,却可能让你排查到怀疑人生。
第三类:声卡、显卡和用不上的输入设备。headless 机器不需要CONFIG_SOUND/CONFIG_SND_*,也不需要CONFIG_DRM和CONFIG_FB。但这里有个大坑:如果你关掉了所有显示相关驱动,而机器又没有串口 console,一旦内核启动失败,你连错误信息都看不到。所以我的做法是保留串口 console 相关的CONFIG_SERIAL_8250_CONSOLE,在 grub 里配置console=ttyS0,115200,这样哪怕系统起不来也能用串口抓日志。
第四类:调试与观测设施。这是体积和启动时间的大头。CONFIG_DEBUG_INFO(DWARF 调试信息)、CONFIG_DEBUG_INFO_BTF(BTF 类型信息)、KPROBES、FTRACE、KGDB、MAGIC_SYSRQ、SCHEDSTATS、PERF_EVENTS,这些全是可以关的。但注意两点:一是CONFIG_KALLSYMS建议保留,否则内核 panic 时打印的是无意义的地址而不是函数名,排障难度陡增;二是如果你依赖 eBPF 观测工具,CONFIG_DEBUG_INFO_BTF不能关,BCC/bpftrace 这类工具全靠它加载 eBPF 程序。
第五类:安全与虚拟化。这台机器跑在隔离的内网段,没有多租户需求,所以SELinux、AppArmor、KVM、Intel VT这些被我关了。但你得想清楚自己机器的定位,如果机器在不可信网络里或者要跑容器,安全模块和命名空间千万别乱动。另外CONFIG_IO_URING我强烈建议保留,现在 MySQL、Nginx、很多高性能网络库都在用 io_uring,关了会让它们的底层 IO 路径崩掉或性能暴跌。
第六类:各种冷门外设驱动。HID传感器、V4L/DVB多媒体、IRDA红外、PCMCIA卡、各种少见的总线控制器,统统关掉。工控机如果接了串口传感器,注意保留CONFIG_I2C、CONFIG_SPI、CONFIG_GPIO_SYSFS这些基础总线框架,我当时差点误伤 I2C 导致采集板失灵,幸好启动后例行dmesg检查时发现报错及时补了回来。
menuconfig 改完之后,跑一遍make olddefconfig让新配置里没被显式声明的选项回归默认值,然后生成一份精简版配置备查:
make olddefconfig make savedefconfig # 生成的 defconfig 只保留非默认项,方便 review 和版本管理3.3 第三板斧:内建还是模块的决策逻辑
配置界面里每个选项都有三种状态:y编进内核镜像、m编译成模块、n直接不编。很多人纠结这里,我给出一个简单实用的决策标准:
- 内核早期就要用的,编成
=y。根文件系统驱动、磁盘控制器驱动、串口 console、以及你无法容忍"晚一步加载"的网卡驱动。它们编进镜像是为了在内核还没挂载任何文件系统之前就能工作,不依赖 initramfs。 - 可以热插拔、或者到用户态阶段才需要的,编成
=m。比如 USB 存储、输入设备、各种外设驱动。反正有 udev 和 initramfs 兜底,模块化不影响使用,还能给内核镜像减负。 - 永远用不到的,直接
=n。这才是"裁剪"的真正含义。
这里必须解释一下 initramfs 这个机制:它是一个小型的临时根文件系统,内核启动早期把initrd.img解压到内存里,用它里面预置的驱动和 udev 去识别磁盘,然后把根切换到真正的根分区。所以你如果把根文件系统驱动编成了=m,没关系,只要 initramfs 里包含这个模块就能正常启动;但如果你图省事把它设成了=n,那就彻底没救了,内核连磁盘都认不出来。我当时为了保险,直接把ext4、SATA_AHCI、R8169全部改成=y,宁可内核镜像大 1MB,也不赌 initramfs 的加载时序。
4. 编译安装与三次翻车:VFS 挂载失败、网卡失联、固件缺失
4.1 编译参数与安装的常规动作
配置定型后就是编译。Debian 系先装依赖:
sudo apt install build-essential libncurses-dev flex bison bc libssl-dev libelf-dev dwarves cpiodwarves这个包经常被忽略,但内核开了CONFIG_DEBUG_INFO_BTF时编译最后一步需要pahole工具生成 BTF 信息,如果没装会直接报错BTF: .tmp_vmlinux.btf: pahole (pahole) is not available。解决办法要么装dwarves,要么scripts/config --disable DEBUG_INFO_BTF。我为了给 eBPF 留后路,选择了保留 BTF 并装好工具。
编译安装的标准流程:
make -j$(nproc) # 多核并行编译,工控机交叉编译时按目标机器核数来 sudo make modules_install # 把模块装到 /lib/modules/6.1.0-xxx/ sudo make install # 安装内核镜像并触发 installkernel 脚本 sudo update-initramfs -c -k $(make kernelrelease) # 务必显式重建 initramfs,下面会讲为什么 sudo update-grub补充一个虚拟机场景的小坑:如果你是在 VirtualBox 里做内核实验,裁剪掉的内核会导致 vboxguest 这类 DKMS 模块需要重新编译,头文件没匹配上就会出现kernel driver not installed (rc=-1908)一类的错误。解决办法是确认安装了对应当前内核版本的linux-headers,并且让 DKMS 重建模块,别让它在 grub 列表里带着旧模块反复折腾。
4.2 第一次翻车:VFS: Unable to mount root fs
裁剪后的第一个版本重启,我盯着串口输出,屏幕赫然出现一行红色 panic:
[ 1.603212] VFS: Cannot open root device "sda2" or unknown-block(8,2): error -6 [ 1.603898] Please append a correct "root=" boot option; here are the available partitions: [ 1.605102] Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(8,2)先解释一下-6这个错误码,它是-ENXIO,意思是"设备不存在"。这个报错说明 initramfs 已经加载了,但它在里面找不到能驱动根分区所在磁盘的模块。我第一反应是ext4被 localmodconfig 干掉了,查了一圈发现不是——问题出在我把CONFIG_BLK_DEV_INITRD关掉了?也不对,initramfs 明明加载了。
真正的原因是:make modules_install把新模块装到了新路径,但update-initramfs没有执行,grub 用的还是旧版 initramfs,里面根本没有新内核对应的模块,而我的ext4还保持=m,于是 initramfs 里没有 ext4 驱动可以被加载,挂载根分区自然失败。
这个坑的教训就两句话:第一,make install之后必须手动确认 initramfs 重建了,别指望脚本自动帮你做;第二,根文件系统和磁盘控制器驱动直接编成=y一劳永逸,它能让你少掉一半 initramfs 相关的麻烦。我后来把ext4、SATA_AHCI、R8169全改成内建,这个问题再没出现过。
4.3 第二次翻车:远程机器的网卡驱动没了
第一次翻车是本地工控机,还有串口可以救。但那次教训之后我又去优化一台远程服务器,结果犯了个更蠢的错:裁剪配置时手滑把网卡驱动从=y改成了=n,旧内核备份在 grub 里虽然留着,但新内核一启动,网卡直接消失,SSH 当场断开,机器彻底失联。
说实话,那一刻我冷汗都下来了。最后是靠机房同事在 IPMI 的 KVM 上进了 grub,手动选中旧内核启动,才把配置改回来。这个经历让我长记性了:
提示:远程机器裁剪内核之前,务必确认有三种以上能"摸到"这台机器的途径:IPMI/BMC 带外管理、物理串口 console、或者至少一个不受内核驱动影响的救援系统。网络驱动和磁盘驱动这类硬件识别相关的配置,任何未经测试的修改都可能让你连不上机器。宁可多跑一趟机房,也别拿生产服务器的网络开玩笑。
4.4 第三次翻车:固件加载失败与"静默降级"
第三次翻车主没有 panic,比前两次更阴险。裁剪完启动倒是正常,系统也起来了,但我例行dmesg时看到一堆错误:
[ 2.891234] r8169 0000:03:00.0: Direct firmware load for rtl_nic/rtl8168h-2.fw failed with error -2 [ 2.891567] r8169 0000:03:00.0: Unable to load firmware rtl_nic/rtl8168h-2.fw这就是典型的"静默降级":驱动加载了,设备也能用,但因为没有固件文件,网卡可能跑在较低的速率,或者某些硬件特性直接缺失,而且系统不会因为这个报错就拒绝启动。很多人在内核裁剪时只顾着开关 CONFIG,忘了把/lib/firmware里的固件文件一并考虑进去。
固件和驱动是两回事:驱动是软件代码,固件是刷进网卡/显卡/无线模块里的小程序。驱动加载时会从/lib/firmware请求对应的.fw文件。裁剪时你关的是驱动,但如果驱动还在,固件路径依然需要保留。解决办法通常是把/lib/firmware里对应的固件文件拷贝到新系统的同名目录,或者更彻底一点,用CONFIG_EXTRA_FIRMWARE把固件直接内嵌进内核镜像,代价是镜像会变大。我选择了前者,因为镜像体积对我来说更敏感。
4.5 调试、回滚与 QEMU 预验证
三次翻车之后我总结了一套稳妥的上线流程,核心思想是"先在隔离环境里验证,再上真机"。
回滚永远走 grub:update-grub之后,开机进 Advanced options 就能看到旧内核入口。我给自己定了个纪律:新内核没有稳定运行满一周,旧内核入口坚决不删。
QEMU 预启动验证是性价比最高的一招。在物理机重启之前,先用 QEMU 把新内核和新 initramfs 跑一遍:
qemu-img create -f qcow2 test-root.qcow2 8G # 把根文件系统克隆到测试镜像里(或用现有测试虚机) qemu-system-x86_64 -m 1G \ -drive file=test-root.qcow2,format=qcow2,if=virtio \ -kernel /boot/vmlinuz-6.1.0-trim \ -initrd /boot/initrd.img-6.1.0-trim \ -append "root=/dev/vda1 console=ttyS0" \ -nographic通过串口控制台观察启动全过程,确认能够挂载根分区、网络驱动加载成功、没有固件报错,再上真机。这一步能把 80% 的启动问题挡在物理机之外。真机启动后的验证清单也很固定:
systemd-analyze # Startup finished in 1.2s (kernel) + 5.6s (userspace) dmesg | grep -iE "fail|error|firmware|warn" # 排查任何隐性问题 lspci -k | grep -A2 -E 'Ethernet|SATA' # 确认关键驱动已绑定 find /lib/modules/$(uname -r) -name '*.ko*' | wc -l # 当前内核实际安装的模块数量5. 最终效果对比与沉淀下来的几条裁剪纪律
5.1 裁剪前后的量化对比
这套流程走完之后,再看各项指标,差距非常直观:
| 指标 | 裁剪前 | 裁剪后 | 变化 |
|---|---|---|---|
| bzImage 体积 | 12.8MB | 5.7MB | 下降 55% |
| 已安装模块文件数 | 5317 个 | 84 个 | 下降 98% |
| 启动时已加载模块数 | 148 个 | 39 个 | 下降 74% |
| 内核启动耗时 | 4.3s | 1.2s | 下降 72% |
| 开机到登录总耗时 | 23.1s | 6.8s | 下降 71% |
| 同负载下空闲内存 | 基准 | 多出约 80MB | 内存占用显著下降 |
| .config 开启项数量 | 4760 | 1873 | 下降 61% |
配置差异可以用内核自带的 diff 脚本快速审计:
scripts/diffconfig /tmp/current.config .config | head -30 # ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff # 显示所有被改动/新增/删除的配置项我用 git 把每个阶段的.config都提交了一次,改了什么、为什么改,全部写在 commit message 里。后面机器出问题回溯时,能直接看到配置演进历史,比自己拍脑门回忆靠谱一万倍。
5.2 几条值得长期遵守的裁剪纪律
这次裁剪之后我给自己定了几条规矩,分享给同样想折腾内核的人:
第一,已知良好的旧内核永远留在 grub 里。新内核没有稳定运行一周以上,旧内核的镜像、模块、initramfs 一个都不许删。这是你所有翻车事故的底牌。
第二,每关一个大类就编译测试一次。别攒到最后一次性验证。先关文件系统试试能不能启动,再关网络协议,再关驱动,逐步推进。一旦出问题,你马上知道是上一批改动导致的,排查范围小得多。
第三,用 git 管理 .config,配合scripts/diffconfig做差异检查。内核配置就是代码,值得被版本化。每次裁剪前把当前配置作为 baseline 提交,改完提交一次,形成清晰的 changelog。
第四,想清楚机器的定位再决定裁剪深度。专机专用、外设固定、永不升级硬件的机器才值得激进裁剪。通用的开发机、服务器,用localmodconfig做一次基础瘦身就够了,没必要追求极限。裁剪的终极目的是让你更懂自己的机器,而不是为了一个压缩后的数字去牺牲稳定性。
第五,远程机器的网络驱动和存储驱动,永远编成=y。这两类驱动关系到你能不能连上机器、系统能不能找到根分区,属于"关键时刻绝对不能掉链子"的部分,不值得为了省几 KB 体积去赌。
最后说点个人感受。这次裁剪最大的收获,其实不是省下的那 7MB 镜像和 16 秒启动时间,而是我把这台机器的硬件栈和内核代码的对应关系彻底摸清了一遍:每个设备绑定哪个驱动、每个驱动的配置项长什么样、initramfs 的加载链路里每一步在干什么。折腾完内核,我不光会"用"系统,还知道系统底下是怎么运转的。这种感觉,比省下的那点资源值钱多了。