ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux内核层定制实战指南:从配置裁剪到模块补丁与性能优化

Linux内核层定制实战指南:从配置裁剪到模块补丁与性能优化

这两年我陆陆续续接到不少朋友问同一个问题:发行版内核用得挺好,为什么非要自己折腾Linux内核层定制?说实话,这个问题如果放在五年前,我可能也会犹豫一下。但现在,无论是跑AI推理的边缘盒子、搞软路由、做工业数据采集,还是单纯想把老笔记本的续航再抠出两小时,默认内核往往卡在一个尴尬的位置——功能全但臃肿,稳定但保守。我自己的第一份内核定制需求,是被一台跑着定制实时采集系统的工控机逼出来的。默认内核的调度延迟不达标,中断处理路径太绕,想调preempt模式却发现发行版编译时压根没开对应选项。从那时起,我开始系统性地研究内核层定制,从改配置到写补丁,从编译到签名,踩过的坑叠起来比内核源码的changelog还厚。这篇文章就把我认为最实用、最容易被忽略的部分整理出来,希望能帮你少走几个月的弯路。

这篇文章的核心内容,是围绕"Linux内核层定制"这一主题,从需求判断、环境搭建、配置裁剪、模块补丁、编译安装、实测排错这几个维度展开。适合这几类读者:第一次尝试编译内核的初学者、想为嵌入式设备精简内核的工程师、需要调优特定场景性能的运维或驱动开发同学。我不会一上来就贴一堆config选项,也不会把内核源码从头讲一遍,那样没意义。我只想讲清楚一件事:当你决定动内核那一刻起,整个决策链路应该怎么走,每一步背后的道理是什么。

1. 内核层定制到底在改什么:先分清三类需求再动手

很多人一听到内核定制,第一反应就是"我要去改内核源码"。这个直觉其实害了不少人。我见过有人在网上下了个第三方调度器补丁,花了一个通宵编译,结果系统起不来,最后发现他需要的只是把内核的CONFIG_HZ从250改成1000。改源码只是内核定制最重的一种手段,不是唯一手段。动手前,先搞清楚你属于哪一类需求。

1.1 配置级定制:大多数场景的真正答案

配置级定制指的是,在不改一行C代码的前提下,通过调整内核的Kconfig选项来改变内核的编译结果。这是门槛最低、风险最小、收益往往最明显的方式。典型场景有:裁剪掉不需要的驱动以缩小内核体积、打开某个默认关闭的调度或网络选项、调整内核抢占模型、关闭调试信息以减少开销。

举个例子,我帮朋友调过一台跑KVM的宿主机。默认内核里CONFIG_PREEMPT_VOLUNTARY是开启的,这个选项对桌面交互友好,但对虚拟化场景来说会产生不必要的调度开销。把它改成CONFIG_PREEMPT_NONE,配合CONFIG_NO_HZ_FULL,同配置下某延迟敏感业务的P99延迟降低了约11%。这个改动不涉及任何源码修改,纯配置级定制。

1.2 模块级定制:不污染主线内核的轻量手段

当配置选项解决不了问题时,比如你想为某个外设写一个驱动,或者想挂一个审计钩子,模块级定制就是下一步。内核模块的好处是动态加载,出问题可以rmmod,不用重启机器,开发迭代速度比改内核源码快得多。很多所谓的内核定制,本质上是写了一个或多个内核模块,而不是真的去改内核本体。

这里我特别想说一句:如果你要做的功能用模块能实现,就尽量不要编译进内核。原因很简单,模块和内核本体之间的接口是通过EXPORT_SYMBOL暴露的,你的模块可以调用内核导出的函数,但反过来,模块无法轻易修改内核已有的行为。一旦你需要"修改内核已有行为",那才到了真正的源码级定制范畴。判断自己是不是到了这一步,可以做个小测试:如果这个改动涉及修改kernel/、fs/、mm/、net/这些目录下的现有函数逻辑,那就不是写模块能搞定的了。

1.3 源码级定制:三条必须想清楚的边界

源码级定制是所有手段里最灵活、也最需要敬畏的。它意味着你要维护一个脱离主线内核的fork,或者至少在每次内核升级时重复打补丁。我会在后面第3章详细讲补丁管理的实操,这里先讲三条边界:

第一条,长期维护成本。主线内核平均每两三个月发一个版本,安全补丁源源不断。你维护的定制内核,每次同步主线代码都可能产生冲突。我见过一些团队为了保证一个调度补丁,硬是在一个老内核版本上停留了三年,后果就是公开漏洞列表越来越长。

第二条,功能取舍的不可逆性。配置裁剪和模块编译错了,顶多重来。源码级的改动一旦进去,可能影响内核的全局行为,比如内存分配器、RCU机制这种底层基础设施,牵一发动全身。

第三条,与上游社区的兼容性。如果你的定制是长期项目的一部分,尽量把补丁做成通用性强的形式向上游提交。哪怕一开始被拒,也能在后续内核版本中降低合并冲突的概率。这一点不是为了情怀,是为了你自己省事。

2. 编译环境与工具链准备:版本匹配是省时间的核心

工具链这节,我放在配置和源码讲解前面,是因为我见过太多人在这一步翻车。内核源码本身对工具链的版本要求很挑剔,不是随便一个gcc就能编过的。

2.1 开发环境选型:容器里编内核可行但别乱来

先回答一个高频问题:能不能在Docker容器里编译内核?答案是能,但要注意两点。第一,容器里编内核不影响宿主机,方便极了;第二,由于内核编译会调用一些需要特殊权限的操作,如果遇到失败,不要立刻怀疑工具链,先检查是不是容器环境缺了什么东西。

我个人的习惯是:在物理机上开一个干净的虚拟机做内核编译,分配4核以上、8G以上内存,磁盘至少留出40G。为什么不用容器?因为编译内核时会生成/boot下的镜像、安装模块到/lib/modules,这些操作在容器里并非不能做,但每次都要挂载和映射,烦。而且虚拟机快照可以让你在搞砸配置后一键回滚,这个优势对新手极其友好。用VirtualBox还是QEMU都行,选你顺手的就好。

2.2 工具链版本匹配:别小看gcc、make、libelf

官方文档里有一个"不确定就用它"的基准:如果你下载的源码是当前稳定版,那就用当前主流发行版自带的gcc、binutils、make、flex、bison、pahole、libssl-dev、libelf-dev等工具包,基本不会出差错。我这里稍微展开说一下为什么版本不匹配会坑你。

内核里的include/linux/compiler-gcc.h会根据gcc版本启用不同的优化路径和内置函数。比如在老内核(比如4.x)上用了太新的gcc(比如12.x),某些内联函数的行为可能会变化,编译时不一定报错,但运行时可能出诡异panic。反过来,新内核用老gcc,有的新语法和属性老编译器根本不认识,直接编译失败。我建议去内核源码根目录看Documentation/process/changes.rst或者Documentation/conf.py,那里列了每个版本的最低工具链版本要求。别嫌这文件啰嗦,它比任何博客都权威。

还有一个小坑:pahole这个工具,如果你要生成BTF信息(CONFIG_DEBUG_INFO_BTF),就必须装。否则即使你开了这个选项,编译也会在生成vmlinux时失败,而且报错信息比较晦涩,容易让人误以为是磁盘空间不足。

2.3 获取内核源码与校验完整性

下载内核源码,我推荐用kernel.org的官方tarball或git仓库。注意一定要校验.tar.sign文件。用tarball的话,签名校验命令大概是:

gpg --locate-keys torvalds@kernel.org gpg --verify linux-6.x.tar.sign

git方式的话,克隆完git tag -v v6.x也可以验证。有人觉得这只是抗恶意篡改,但对你自己的开发流程也有好处——一旦发现补丁打不上或者编译行为奇怪,你能确定至少源码本身是干净的,排查范围瞬间缩小。

3. 配置裁剪的艺术:从Kconfig依赖关系倒推性价比

配置裁剪是整个内核定制里花时间最多、也最讲究"为什么"的部分。很多人喜欢打开menuconfig一顿乱勾乱取消,最后编译出来的内核要么少了关键驱动启动失败,要么体积没减多少还丢了功能。这里我讲一套可复制的思路。

3.1 我先教你读懂Kconfig依赖树

Kconfig文件不只是简单的"选项开或关"。它是带依赖关系的树。举个例子,你想开CONFIG_NFS_FS,它可能依赖CONFIG_NET、CONFIG_INET,而CONFIG_INET又依赖CONFIG_NET。menuconfig的好处是它会自动隐藏不满足依赖的选项,但这同时带来一个问题:你看到的选项未必是全部。

我建议在改配置前先做一次"最小配置探索"。内核提供了一堆现成的底配置:

  • make defconfig:架构默认配置
  • make tinyconfig:极小配置,适合嵌入式学习
  • make allnoconfig:全部关闭,再从零打开

从tinyconfig或allnoconfig开始,比从发行版config开始减去要直观得多。因为你是在"做加法",每个加进来的选项你都清楚它为什么在这。从发行版config做"减法"的问题在于,发行版为了兼容海量硬件开了几千个选项,你根本分不清哪个是"看起来没用但其实内核核心依赖"。

3.2 裁剪策略:先定场景,再定子系统,最后砍驱动

我给团队做内核裁剪时,常用一套"三层倒推法":

第一层,确定运行场景。是网络转发设备?是存储设备?是音频处理器?场景决定了你保留哪些大的子系统。网络转发设备必然要CONFIG_NET、CONFIG_INET及其下的路由、netfilter等;存储设备离不开块层和文件系统相关选项。

第二层,确定硬件平台。CPU架构、主板芯片组、网卡型号、磁盘控制器型号,这些对应的驱动一定要保留。这个步骤最费时间,但绝对值得。你可以在目标机器上跑lspci -nnk、lsusb、lspcmcia,把硬件列表记下来,然后逐个确认对应的内核驱动选项。

第三层,把剩下的驱动全部取消选择。听起来很粗暴,但效果好。比起手动去看上千个驱动选项,不如把所有非必需的都关掉,需要哪个再加哪个。尤其是各种非主流的USB设备、传感器、电视卡、老声卡等,默认发行版全给你编成了模块,裁剪时第一批就该删掉。

3.3 y / n / m 的取舍逻辑与真实悲喜剧

内核配置里每个选项有三个状态:y(编入内核)、n(不编)、m(编译成模块)。很多人以为m是"折中方案",其实不然。

什么时候用y?必须是启动早期就要用的功能。比如根文件系统所在的磁盘驱动、初始化内存盘相关支持、内核解压后立刻需要的CPU特性支持。这类如果做成m,启动阶段会因为没有模块可加载而直接失败。

什么时候用m?那些启动后期才需要、可以动态加载的。比如文件系统驱动,其实大部分可以做成m,前提是你的initramfs里包含了对应模块。这一点我在第4章会详细讲initramfs的坑。

什么时候用n?当前场景用不到,且可以明确判定不会影响核心功能的。

我见过一个特别典型的案例:有个人为了减小内核体积,把CONFIG_SERIAL_8250关掉了,结果启动时串口没有输出,死活不知道内核卡在哪一步。这不是配置选项选错了,而是你忘了一个现实——8250串口驱动不只是"调试输出口",很多工控板卡的通信、BIOS交互、甚至部分电源管理都依赖它。内核定制的大忌就是把"我用不到"和"这东西无关紧要"划等号。

4. 内核模块开发与补丁管理:轻量定制和深度改写的分界线

配置裁剪说到底还是在"选功能",但当你需要"写功能"时,就要进入内核模块或补丁的世界了。

4.1 第一个内核模块:从hello world到操作链表

写内核模块没那么玄乎。一个最小模块只需要两个函数:

#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init demo_init(void) { pr_info("demo module loaded\n"); return 0; } static void __exit demo_exit(void) { pr_info("demo module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");

对应的Makefile:

obj-m += demo.o KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

这里最关键的在于KERNEL_DIR指向的内核头文件是否和当前运行内核版本严格一致。怎么确认?uname -r。如果不一致,模块加载时会报version magic不匹配。比如你当前运行的是5.15.0-generic,但编译用的头文件是5.15.0-custom,那就是不行。这个问题我见的频率奇高,后面排错章节我还重点讲。

模块真正进阶的用法,不只是做个打印,而是去改内核数据结构。举个例子,我写过一个小模块,把自己挂到netfilter的hook点上,统计每个网络命名空间里经过的TCP连接数。全程没有动任何内核源码,只是注册回调函数。这就是模块级定制的威力——它让外部代码以内核身份运行,可以访问内核内部符号和数据结构,但代码本身的维护边界又很清晰。

4.2 打补丁与补丁管理:quilt vs git format-patch

什么时候你需要打补丁而不是写模块?当你要修改的函数不是EXPORT_SYMBOL导出的,或者你要修改的是某个固定的行为路径,比如更换默认的TCP拥塞控制算法实现。这时只能在内核源码上做改动,然后用补丁文件的方式维护。

我推荐直接用git管理内核源码树,用git format-patch生成补丁,再在另一个干净源码树上用git apply或patch -p1来验证。流程非常简单:

git clone --depth 1 https://github.com/torvalds/linux.git git checkout -b my-feature v6.x # ... 修改代码 ... git add -A git commit -s -m "custom: improve something" git format-patch -1

生成的那个.patch文件,就是你要持续维护的东西。移植到新内核时,在新源码树上执行:

git am -3 xxx.patch

-3参数会自动做三方合并。如果冲突,git会停下来告诉你哪些文件冲突,手动解完再用git am --continue收尾。

有人会问:为什么不用quilt这种传统工具?我的回答是:git format-patch天然携带作者信息和提交信息,在内核社区交流时大家都认这个格式。你要给上游发补丁时,git format-patch生成的邮件格式直接可用。如果只是自己用,quilt也不是不行,但至少不会让你形成邮件工作流的习惯。

4.3 不动源码的定制:启动参数和sysctl的巧劲

还有一个很多人没意识到的定制维度:不是所有行为都要靠重新编译来实现。内核本身提供了大量运行时旋钮,主要通过两个入口暴露:sysctl和内核启动参数。

sysctl在/proc/sys/下,比如net.ipv4.tcp_congestion_control可以切换TCP拥塞控制算法,vm.swappiness可以调整交换倾向。启动参数则通过GRUB的kernel命令行传入,比如nohz_full=1-3可以把指定CPU核设置为自适应tickless模式,配合rcu_nocbs可以让这些核不被RCU回调打断。这些改动可以做成/etc/sysctl.d/下的配置文件,也可以做成GRUB配置持久化。但严格来说,它们不算"内核层定制",因为你没有改变内核的编译产物。不过在实际项目中,它们往往是配置裁剪后、源码修改前,最合适的下一步。

5. 编译安装一次过的细节:initramfs、GRUB与Secure Boot

当配置和源码都准备完毕,真正的考验在编译安装这一步。很多内核定制项目不是死在写代码上,而是死在"编出来装不上"或者"装上了起不来"。

5.1 make命令选型:从-j到deb-pkg的边界

编译内核最常用的命令是:

make -j$(nproc)

-j数量不要超过CPU线程数太多,否则内存会被吃满。如果不放心,可以先用nproc查一下,减几个再填。

但如果你用的是Debian/Ubuntu系,我更推荐直接用:

make -j$(nproc) bindeb-pkg

这个命令会在上一级目录生成几个.deb包:linux-image-*、linux-headers-*、linux-libc-dev-*。直接sudo dpkg -i linux-image-*.deb安装,再update-grub。好处是包管理器会接管文件,卸载也干净。

还有一个小细节:编译之前先确认一下配置里有没有开启CONFIG_MODULES。如果你忘了开,整个内核编译过程不会报错,但make modules_install执行时会发现没有Module.symvers,然后一堆模块装不上,只能重编。

5.2 initramfs同步:启动失败的隐形首凶

编译安装完后,最怕的就是重启后卡在Loading initial ramdisk ...。大部分情况下,问题不是内核本身,而是initramfs没有跟上。

initramfs(initial RAM filesystem)的作用是在内核启动初期、根文件系统尚未挂载时,加载必要的驱动和工具。如果你把根文件系统的驱动(比如CONFIG_EXT4_FS)编译成了m(模块),那么这些模块必须打包进initramfs才能在挂载根fs之前被加载。否则内核虽然认得出磁盘,却挂载不了根分区,启动直接panic。

解决办法有两类。一是尽量把根文件系统相关的驱动编成y,这样就绕开了initramfs对某些模块的依赖。二是确保编译安装后同步生成initramfs,Debian系用update-initramfs -u。我见过有人编译安装kernel后直接重启,完全忘了更新initramfs,结果永远进入救援模式。

5.3 模块签名、Secure Boot与信任链

如果你用的是支持Secure Boot的机器,且开启了Secure Boot,内核模块签名就成了绕不开的坎。因为Secure Boot模式下,内核只会加载带有效签名的模块,或者至少是允许"未签名模块"的认证内核。具体表现是insmod时报Required key not available。

我的建议是:如果是自己测试用的机器,最简单的方式是在BIOS/UEFI设置里关闭Secure Boot。如果是生产环境必须开启Secure Boot,那就得走完整的签名链路:生成自己的签名密钥,sign-file给模块签名,再用mokutil把公钥导入机器的MOK列表。这个流程一般按照你的发行版官方文档操作就能通。注意一点,签名密钥一旦丢失,你现有的模块将无法在新内核上加载,所以一定要备份好。

6. 我在实测中踩过的内核定制坑:四条典型排错链路

最后这一章是全文最有"实战感"的部分。以下都是我在真实项目的编译和部署中偶尔踩过、而且不止一次有人来问我的问题。

6.1 version magic不匹配的完整排查过程

有次我给某开发板编自定义内核,模块单独编译时,insmod回报:

Error: could not insert module demo.ko: Invalid module format

用dmesg看是:

demo: version magic '6.1.0-custom SMP preempt mod_unload ' should be '6.1.0-custom-xxx SMP preempt mod_unload '

原因很简单:编译模块时用到的/lib/modules/$(uname -r)/build头文件与当前运行内核不是同一个版本(或配置)。排查时先确认三件事:

uname -r ls -l /lib/modules/$(uname -r)/build cat /lib/modules/$(uname -r)/build/.config | grep CONFIG_LOCALVERSION

只要版本字符串或配置标记不一致,就会出现这种问题。解决办法就是确保模块编译用的内核头文件,恰好来自当前运行的内核。如果是自编内核,那么make modules_install后,/lib/modules/$(uname -r)/build这个符号链接应当存在且指向源码目录。

6.2 裁剪后某驱动缺失导致启动黑屏或panic

一台做视频处理的机器,我裁剪内核时把CONFIG_DRM相关选项几乎全关了,只保留了显卡厂商的特定驱动模块。重启后,系统能起来,但X server直接黑屏。排查时发现,虽然显卡驱动编译成m装进了/lib/modules,但initramfs里没包含。启动时,内核先加载framebuffer设备,驱动还没被加载,X server自然起不来。

这类问题的通用排查逻辑是:启动过程中哪个设备最先生成、需要最小化的驱动集合,就一定要编进内核或塞进initramfs。启动早期阶段(挂载根fs前)需要的驱动,我建议无脑编y。到了用户态以后才需要的驱动,再加到initramfs里也不迟。

6.3 改配置后重编内核,沉默地变慢了

有一次我为了"精简",把CONFIG_HZ从1000调低到100,想着能减少时钟中断的开销。结果跑一个网络转发测试,吞吐量反而下降了6%。原因是这个应用对时间敏感度要求高,tick间隔变长导致定时精度下降,某些依赖jiffies的驱动路径出现了可用性劣化。

这个案例说明一个道理:内核定制不是越少越好、越"精简"越好,而是要匹配你的真实负载特征。调度类、时间类、RCU类的选项,一定要在性能测试之后再做取舍,不能只看表面上的"省电""减少开销"。

6.4 补丁冲突的解决思路:比手动改代码更重要的版本规划

最后一次源码级定制,我给内核打了一个网络栈优化补丁。因为工作基于v6.1,过了几个月尝试升到v6.5时,补丁在net/ipv4/tcp_output.c上冲突了。解冲突的时候我盯着那一堆<<<<<<<痛苦的对比。后来我想通了,与其硬解,不如去上游git仓库看看这个函数有没有被大重构。果然,相关代码已经被移到了新文件里,旧补丁自然对不上。

我的经验是,遇到补丁冲突,第一件事不是去改diff,而是去查上游的changelog和mailing list。多数冲突是因为代码结构变了,而不是行为变了。花半小时读懂上游的改动意图,比盲目解冲突省时间得多。另外,如果预期要长期维护,不要把鸡蛋放在一个版本上,尽量让补丁适配目标内核的主线版本,至少把冲突范围控制在一个文件内。

写到最后,再分享一点个人体会

内核定制这活儿,门槛不在"写代码",而在"做决策"。你选择的每一个config项、每一个模块边界、每一个补丁文件,本质上是在为你的系统定义一个专属行为契约。它不像应用层开发,错了重启就恢复;它更像是在给整栋楼重新拉水电线路,改错一根管子,整栋楼都可能受影响。所以我的建议是:先从配置裁剪和模块开发入手,不要一上来就改kernel/下的核心代码,除非你确实清楚自己在干什么,并做好了长期维护的准备。条件允许的话,把定制版本推到虚拟机里跑一轮压力测试再放到真机,快照功能是你最好的后悔药。内核源码虽然庞大,但它的文档和社区讨论足够充分,只要你的需求描述得清楚,几乎每个取舍背后都能找到明确的权威依据。希望这篇基于我个人实践经验的整理,能让你在Linux内核层定制这条路上走得稳一点、快一点。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进