接手一份别人的内核源码,第一件事往往不是看代码,而是看配置。我遇到过太多次这样的场面:甲方扔过来一个 tar 包,里面躺着一份.config;硬件工程师手里又有一份xxx_defconfig;两边各自编译,出来的镜像能差出好几兆,启动日志里的驱动加载顺序都不一样。更离谱的是有人直接问"哪个文件才是真正的配置",因为他在源码根目录改了半天.config,make之后发现改动全没了。这些问题的根子,都在于没搞清楚.config和defconfig这两个东西在构建链条里各自扮演什么角色。这篇就把 Kconfig、.config、defconfig三者的关系、常用 make 目标的实际行为、内核裁剪的落地方法和版本升级时容易翻车的细节,一次性讲透,适合刚接触内核编译的嵌入式开发者,也适合已经在维护 BSP 但想把配置管理做规范的人。
1. 从一个"同一个源码包、两份镜像"的现场说起
1.1 构建系统找不到.config时到底在抱怨什么
最典型的报错长这样:*** Configuration file ".config" not found!紧接着一句*** Please run some configurator (e.g. "make oldconfig" or "make menuconfig" or "make xconfig").。这不是链接错误也不是编译错误,而是构建系统在进入真正的编译阶段之前,发现自己缺了一份"输入契约"。内核的 Makefile 在展开obj-$(CONFIG_XXX)这类语句时,依赖include/config/auto.conf提供符号取值,而auto.conf是从.config生成的。没有.config,整条依赖链在前几步就断了。
这里有个新手经常踩的坑:.config的位置跟构建目录绑定,而不是跟源码目录绑定。只要你用了make O=out这种外部构建方式,那份.config就躺在out/目录里,源码根目录是找不到的。我在一个项目里见过同事反复ls -a源码根目录,说"文件明明在啊",最后发现他是在另一台机器上跑的外部构建。所以排查这类"配置改了不生效"的问题,第一步永远是确认当前命令实际使用的 objtree 在哪,而不是盯着源码目录看。
提示:不确定当前构建用的是哪份
.config,可以加V=1让 Makefile 把实际执行的 kconfig 命令打出来,命令行里--defconfig=或KCONFIG_CONFIG=的参数会暴露真实路径。
1.2 一份是"这一次的完整快照",一份是"这一类板子的推荐起点"
把.config理解成一次点餐的完整订单:每道菜要不要、辣度多少、加不加香菜,全部逐条写清楚,任何人照着这份订单去做,出来的东西跟你想的一样。它本质上是一次构建的全部输入,包含了几千行取值,绝大多数是各符号的默认值被显式展开的结果。
defconfig则是另一回事。它放在arch/$ARCH/configs/下面,是一份经过精简的初始化模板,只把那些"与默认值不同"的项钉下来。用点餐类比,它更像贴在店门口的推荐点法:只写"辣度三颗星、不要香菜,其余按店里标准来"。同一家店换个时间营业,标准菜可能变了,但这个模板照样能用,因为它没把所有细节都写死。
理解了这个差别,很多现象就顺了:为什么defconfig只有几百行,而make defconfig跑完之后生成的.config有几千行;为什么升级内核版本之后.config的 diff 有上万行,而defconfig的 diff 通常只有几十行。
1.3 只保留其中一份会出什么问题
有人图省事,只留.config进版本库。短期没问题,长期是灾难:跨版本升级时 diff 里混着大量"某个符号的默认值从 y 变成 n"这类噪声,评审的人根本看不清你真正改了什么;而且不同开发者机器上的默认值可能因为 ARCH、工具链配置不同而分叉,同一份.config在不同环境下编译结果并不完全一致。
反过来,只留defconfig也不行。生产环境要复现某次发布的镜像,必须能拿到当时那份完整的配置快照。defconfig展开时依赖当时的 Kconfig 默认值,而默认值会随版本变动,所以"用同一个 defconfig 在两个版本上编译出完全一样的二进制"是不成立的。我的做法是:仓库里只提交defconfig及其 fragment,同时每次正式发布时把展开后的.config归档到发布包旁边,两者缺一不可。
2. Kconfig 才是源头,另两份都只是它的产物
2.1 Kconfig 里几个真正影响结果的写法
很多人把 Kconfig 当成纯文档,其实它是带执行语义的。几类写法必须熟:
bool/tristate/int/string/hex决定符号类型,tristate多了m(编译成模块)这一档。default决定没被显式指定时取什么值,可以有条件表达式,也可以依赖其他符号。depends on是可见性与取值的前提,依赖不满足时符号可能直接不进候选菜单。select是强制点亮,它会绕过对方的depends on,这是很多"我明明把依赖关掉了,这个项还是被打开"现象的根源。menuconfig与menu主要影响菜单层级;choice是一组互斥选项;source用来把子目录的 Kconfig 拉进来。
select这个点值得多说一句。它对人友好,对排错不友好。当 Aselect B而 B 又有自己的依赖时,配置系统会强行把 B 设为 y,但 B 的依赖可能没满足,于是要么编译报错,要么运行期行为诡异。排查这类问题,最快的方式是去drivers/或fs/下 grep 一下select 符号名,看看到底是谁在替你做决定。
2.2.config的书写规则:这一行"不存在"和"写着 not set"不是一回事
.config里符号有三种表达:
CONFIG_FOO=y或CONFIG_FOO=m:内置或模块。# CONFIG_FOO is not set:显式取 n。- 完全没有
CONFIG_FOO这一行:不表态,让默认值说话;或者是依赖不满足导致该符号压根没进入候选集。
第三种情况是手工改配置时最大的陷阱。我见过太多人在.config末尾追加一行CONFIG_XXX=y就以为搞定了,跑一遍make olddefconfig之后那行被悄悄抹掉,因为他没注意到CONFIG_XXX依赖的某个总线或子系统是 n。手动加配置项,一定要"改完立刻make olddefconfig再diff一次",看你的改动是留下了还是被回滚了,这个动作五秒钟,能省掉半天排错。
提示:判断一个符号为什么打不开,最直接的办法是
make menuconfig里按/搜索符号名,界面会把这个符号的依赖链和"被谁 select"完整列出来。
2.3 从.config到代码:autoconf.h与auto.conf
.config本身不参与编译。构建系统会把它转成两个中间产物:
include/generated/autoconf.h:给 C 代码用的宏定义,#define CONFIG_FOO 1这种形式。include/config/auto.conf:给 Kbuild 用的变量,obj-$(CONFIG_FOO) += foo.o里的$(CONFIG_FOO)就是从这儿来的。
这两个文件的位置同样在 objtree 里。排"代码里的#ifdef CONFIG_FOO明明配置打开了却不生效"这类问题时,直接去看autoconf.h里有没有那个宏,比猜快得多。另外不少内核还带include/config/下的空目录结构,那是给 Makefile 做依赖追踪用的,不用手动碰。
2.4 常用 make 目标对照表
配置相关的目标多且容易混,我把实际用得到的整理成一张表,方便对照:
| 目标 | 作用 | 实际使用场景 |
|---|---|---|
make menuconfig | 文本菜单界面 | 日常最常用,服务器环境也稳 |
make nconfig | 新版文本界面,支持更顺手的搜索 | 远程终端上体验更好 |
make xconfig | 图形界面,依赖 Qt | 本地有桌面环境时比较直观 |
make defconfig | 按架构默认配置展开 | x86 上一般走arch/x86/configs下约定的那份 |
make xxx_defconfig | 找arch/$ARCH/configs/xxx_defconfig | 板级配置的标准入口 |
make oldconfig | 只交互询问新增/变化的符号 | 版本升级后第一次配置 |
make olddefconfig | 新符号一律取默认值,不交互 | 手工改完.config后的收尾 |
make savedefconfig | 生成精简版defconfig | 配置定稿后收敛 |
make allnoconfig/allyesconfig/allmodconfig | 全关/全开/全模块 | 覆盖率测试、编译问题复现 |
make tinyconfig | 生成体积极小的配置 | 只作为裁剪起点,不能直接当产品配置 |
make localmodconfig | 依据当前系统已加载模块生成配置 | 开发机自用,不适合出货 |
make listnewconfig/helpnewconfig | 列出新增配置项 | 大版本升级时快速过一遍 |
make mrproper | 清理包括.config在内的所有生成物 | 要小心,配置会被删掉 |
3. 生成和修改.config的几条路子,以及各自的适用边界
3.1 图形界面的三条路:menuconfig、nconfig、xconfig
menuconfig依赖 ncurses,在干净的容器里经常报"找不到 curses.h",装包即可,Debian 系是libncurses-dev,RHEL 系是ncurses-devel。xconfig依赖 Qt 开发库,装起来比 ncurses 重得多,除非你确实需要鼠标操作和树状视图,否则不值得为一个配置界面在服务器上装一堆图形库。我个人在远程环境下更愿意用nconfig,它的搜索和高亮比老版 menuconfig 舒服,尤其是符号名记不全、要靠关键词捞的时候。
不管用哪个界面,核心技巧是搜索键。想确认一个功能到底叫什么符号、藏在哪个菜单、依赖什么,直接搜比一层层翻菜单快十倍。搜出来的结果里,"Depends on:"和"Selected by:"两行信息量最大,前者告诉你为什么它不可选,后者告诉你谁在偷偷把它打开。
3.2 不开图形界面的做法:oldconfig、olddefconfig 和 scripts/config
批量、可脚本化的场景,图形界面反而是累赘。内核自带scripts/config工具,可以在不改写整个文件的前提下按符号名增删改:
# 关闭模块签名校验,打开运行时配置读取能力 scripts/config --file .config --disable MODULE_SIG scripts/config --file .config --enable IKCONFIG scripts/config --file .config --enable IKCONFIG_PROC # 修改数值型符号 scripts/config --file .config --set-val FOO_BUFFER_SIZE 4096 # 必须收尾,否则依赖关系没被重新求解 make ARCH=arm64 olddefconfig注意scripts/config传入的是不带CONFIG_前缀的符号名,它自己会拼上去。这条路子最大的价值在于可复现:把一系列配置修改写成脚本进仓库,比让每个同事手动点菜单可靠得多,也方便 CI 里自动产出多种变体配置。
make oldconfig的用法就一个:在版本跨得比较大时,把新增符号一个一个问过去。虽然烦,但安全相关的默认值经常就在这些新符号里,无脑olddefconfig会错过。
3.3 手工编辑.config的真实风险
直接改文本并非不行,我在处理"只差一两项"的时候也会这么干,但必须清楚三个后果。
第一,依赖不满足的改动会被静默抹掉,前面已经讲过。第二,类型写错会报警告但不会阻止构建,比如把int型符号写成字符串,配置系统会给一条 warning,然后取默认值,你可能根本没注意。第三,改完不跑olddefconfig直接编译,构建系统会自己同步,同步过程中如果有新符号需要回答,它会打印一句提示让你回去跑make oldconfig,此时你的编译其实用的是一份还没定稿的配置。
经验做法是:手改只改取值,不改结构;改完立刻make olddefconfig;然后git diff .config看一眼净变化。三步走下来,基本不会出现"我改了但没生效"的诡异情况。
3.4localmodconfig的用途和它的边界
make localmodconfig会读当前系统的已加载模块,把不需要的驱动关掉,生成一份"贴着这台机器"的配置。思路很聪明,我用它给开发机做过一次大幅瘦身,编译时间从四十多分钟降到十几分钟。
但它的边界也很清楚:它只认当前这一刻在场的硬件。如果目标板上有 USB 网卡、第二块存储控制器、串口扩展这类"平时不挂载"的设备,它们的驱动会被一刀切掉,换台机器或者换个使用场景就起不来。所以它适合"我自己这台开发机自用",绝不适合产品出货配置。用完之后照样要savedefconfig收敛、人工审查一遍被关掉的项。
4.defconfig的正确制作方式:savedefconfig而不是复制粘贴
4.1 为什么直接把.config改名叫defconfig是个坏习惯
这是最常见的错误做法,看起来能跑通——make defconfig确实会读你把.config改名来的文件——但代价很大。
文件会膨胀到几千行,里面绝大部分是"默认值被显式写出来"以及一堆# CONFIG_XXX is not set。跨版本升级时,别人的默认值改了,你这几千行里就有几百行变成噪声,真正的改动被淹没。更麻烦的是,某些符号的默认值会跟随机器环境变化(比如跟 CPU 特性或工具链能力挂钩),你把当前机器的展开结果硬编码进去,等于把一份环境相关的东西伪装成板级配置。评审的时候没人愿意读这种 diff。
4.2savedefconfig到底"省"掉了什么
它的逻辑很朴素:只保留与当前默认值不同的项。默认值由当前的 Kconfig 定义和ARCH共同决定,所以同一个配置在不同架构上收敛出来的defconfig是不一样的。
make ARCH=arm64 menuconfig # 正常配置、裁剪 make ARCH=arm64 savedefconfig # 在构建目录生成精简后的 defconfig cp defconfig arch/arm64/configs/myboard_defconfig产物默认写在构建目录(用了O=就在输出目录里),文件名就叫defconfig,需要你手动拷贝到arch/$ARCH/configs/下并按规范改名。收敛完的典型体量是几百行,评审时一眼能看完。
提示:
savedefconfig的"省"是有前提的,它省掉的是"和当前默认值相同"的项。所以内核大版本升级后,旧的.config展开再收敛,结果会跟以前不一样,这是正常的,不是工具出了问题。
4.3 命名、路径与make xxx_defconfig的查找规则
make myboard_defconfig会去arch/$ARCH/configs/myboard_defconfig找文件,文件名必须以_defconfig结尾,这是 pattern rule 的匹配要求,命名成myboard.config是找不到的。而make defconfig走的是另一条路径,通常由arch/$ARCH/Makefile里的KBUILD_DEFCONFIG变量指定(x86 上一般对应x86_64_defconfig一类),可以尝试用命令行覆盖,但更稳妥的做法还是显式写make myboard_defconfig。
这里必须重点提醒ARCH的问题。make在不指定ARCH时默认按宿主机架构展开,在一台 x86 服务器上给 ARM 板子做配置却忘了加ARCH=arm64,你会得到一份看起来很正常、实际上全是 x86 选项的配置,然后编译时各种诡异的头文件找不到。我养成的习惯是:所有配置命令前面一律带ARCH=和CROSS_COMPILE=,宁可冗余也不省。
4.4 多产品线场景下的 fragment 合并
一个产品线往往有多个硬件变体,摄像头、实时补丁、调试开关各不相同,如果每个变体都维护一份完整的defconfig,重复内容占八成,改一处基础项要同步改五份。这时候用配置片段(fragment)更合理,内核源码里就带了一个合并脚本。
# 基础配置 + 若干增量片段,后出现的覆盖先出现的 ARCH=arm64 scripts/kconfig/merge_config.sh -m \ arch/arm64/configs/base_defconfig \ frag/camera.config frag/rt.config make ARCH=arm64 olddefconfig-m表示只做合并、不自动跑一遍默认值求解,所以合并完必须自己make olddefconfig把依赖重新解一遍,最后再savedefconfig收敛出这个变体最终要提交的文件。目录上我一般这样组织:arch/$ARCH/configs/下只放收敛后的成品,frag/下放片段,每个片段文件里用注释写清"为哪个硬件/哪个功能准备",半年后回头看也不会懵。
提示:片段文件里不要写
is not set形式的关闭项,除非你确实需要在合并时强制覆盖别人打开的值。只写需要打开的项,合并结果更可预测。
5. 内核裁剪:从配置项出发把镜像和启动时间压下去
5.1 先量化,再动手
裁剪最大的忌讳是一上来就关选项。我一般先做三件事:size vmlinux看各段(text/data/bss)占比;在 objtree 里按体积排序看哪些目录贡献最大;用scripts/bloat-o-meter对比裁剪前后的符号级变化。启动时间方面,给内核命令行加initcall_debug,从 dmesg 里能看到每个初始化函数的耗时,比拍脑袋关驱动靠谱得多。
顺序上,CONFIG_DEBUG_INFO通常是第一个该关的。它不改变功能,只是给二进制塞调试信息,关掉之后 vmlinux 体积经常能小掉一半以上。开发调试阶段留着,出货配置里一定要去掉。同理,DEBUG_KERNEL下面挂着的一堆运行时检查项(各种内存/锁检测工具、函数追踪)也会带来可观的开销,出货时按需保留最少的一两个。
5.2select和依赖导致的"删不掉",以及正确的处理顺序
裁剪时经常遇到这样的情况:你在菜单里把某个子系统关掉了,保存后重新打开,它又变回 y。这几乎一定是两个原因之一——被别的符号select了,或者它本身不是可选项而是某项功能的必然依赖。
处理顺序建议从上往下:先关上层应用功能(某个具体驱动、某个文件系统、某个协议族),再回头看下层公共组件。反过来的话,你关掉公共组件,上层会把它 select 回来,来回拉锯。判断依据可以用make savedefconfig之后的 diff:如果某一行反复出现在你的改动里又被回滚,说明它有强依赖或强 select,去 grep Kconfig 找源头。
5.3 一份可参考的裁剪清单与"千万别关"名单
不同产品差异很大,但下面这张表在多数嵌入式场景下都适用:
| 类别 | 典型选项 | 处理建议 |
|---|---|---|
| 调试信息 | DEBUG_INFO及其子项 | 出货配置一律关闭 |
| 运行时检测 | 内存/锁检测、函数追踪类选项 | 除专门排障版本外关闭 |
| 示例与测试 | SAMPLES等 | 关闭 |
| 无关文件系统 | 产品用不到的文件系统 | 逐个关闭,保留根文件系统所需 |
| 无线与音视频 | 无线子系统、声卡、显示相关 | 无对应硬件时关闭 |
| 体积优化 | 内核压缩方式、模块压缩 | 按启动性能需求权衡 |
| 模块支持 | MODULES | 有使用modprobe的需求时保留 |
| 容器与系统服务 | cgroups、命名空间、SYSFS | 跑 systemd 或容器运行时必须保留 |
| 基础能力 | BINFMT_ELF、PRINTK、TMPFS、PROC_FS | 绝对不能关 |
最后一行要单独强调。我见过有人为了缩体积把PRINTK关掉,结果目标板启动失败时一条日志都没有,只能靠点灯定位,排障成本翻了几倍。也有人关掉BINFMT_ELF,内核起来了但用户态第一个程序就跑不起来。还有一些是"不跑容器就以为不用"的,比如事件通知、信号量相关的几个基础设施,很多运行时和上层框架都在用,关掉之后表现为"某个服务莫名起不来"。
提示:
make tinyconfig生成的配置体积非常小,但那是构建系统能接受的下限,不是能启动的下限,只适合当裁剪起点或者做配置系统本身的测试。
5.4 分阶段裁剪与回归验证的方法
我自己的节奏是:一次只关一个类别,编译通过后立刻启动一次,拿到 dmesg 与基线版本对比,确认没有新增报错、没有驱动加载失败、关键外设功能正常,再进入下一类。这样出问题时能立刻锁定到刚关掉的那一类,而不是面对几十个改动去二分。
基线管理也很重要:始终保留一份"已知可用的最小配置",每轮裁剪从它出发,验证通过后把它更新成新的基线。目标板上如果打开了运行时配置读取能力,可以直接在板子上核对当前运行的配置,避免出现"我以为用的是这份配置,实际烧的是另一份"这种低级但高发的事故。
还有一个容易被忽略的点:编译通过不等于功能完好。有些驱动是编译期内置但只在特定运行时场景才被调用,裁剪时看代码依赖看不出来。所以回归测试至少要覆盖产品的核心业务流程,而不只是"能起来"。
6. 版本升级、协作和日常维护中容易翻车的细节
6.1 新符号出现时的那句提示,别当成错误
大版本升级后第一次构建,经常会看到类似"配置文件已变化,请运行配置工具"的提示,随后构建系统自己跑一遍同步。这不是报错,而是它在处理 Kconfig 里新增或语义变化的符号。同步完之后如果还有未决的新符号,它会明确告诉你回去跑一次交互式配置。
我的建议是:跨大版本升级时,老老实实跑一次make oldconfig逐项看完新增项,再savedefconfig收敛。为什么呢?新增项里的默认值不一定对产品合适,尤其是安全和资源相关的开关,有的默认开启会带来额外开销,有的默认关闭会导致某个新硬件用不了。这一遍花二十分钟,能避免上线后一个诡异的性能回退。
6.2 配置漂移:外部构建、多份.config与缓存不一致
外部构建(make O=)能让源码目录保持干净,非常适合一个源码树同时产出多个产品配置。但它也带来两个坑:一是前面说的.config位置问题,二是两份配置来回切换时的缓存残留。切换配置后,如果不做一次彻底的清理,上一份配置编译出的目标文件可能被复用,产出的镜像里混着两种配置的代码。
配置系统确实有依赖追踪机制,理论上.config变了会自动触发相关目标重建,但它追踪的是配置层面的变化,跨产品线的大改动我仍然建议切配置后跑一次清理再全量编译一次,确认无误后再用增量。另外,绝对不要在两个终端里一边menuconfig一边make,两边同时读写同一份配置和中间产物,结果不可预测。
至于清理命令的记忆点:make clean保留.config,make mrproper连.config一起删。我见过同事习惯性敲make mrproper当"大扫除",然后把辛苦调了两天的配置清掉了——如果确实需要彻底清理又想保住配置,先把.config拷出来再清。
6.3 仓库里该提交什么、不该提交什么
结论很明确:提交arch/$ARCH/configs/下的xxx_defconfig以及自己维护的 fragment,不提交.config。内核源码自带的忽略规则已经处理了.config及其备份文件,你不需要额外配置。提交前务必用savedefconfig收敛一次,让 diff 只反映真实意图,这是对下一个读代码的人最基本的尊重。
另一个细节是提交粒度:一次提交只改一类东西。把"关闭调试信息"和"打开某个外设驱动"塞进同一个提交,回退的时候会很难受。配置文件的 diff 不像代码那样有清晰的结构,只能靠提交信息来补,所以在提交信息里写清楚"为什么关掉某项"比写"更新配置"有用一百倍。
我自己一直保持的一个小习惯,分享出来:每次改完配置准备提交前,先跑一次savedefconfig,再跑一次git diff,然后问自己一个问题——这些变化里,有哪几行是半年后的我还能一眼看懂原因的?看不懂的那几行,我就补上注释或者单独拆一个提交。配置文件的维护成本从来不在改的那一刻,而在一年后有人需要基于它做二次开发的那一刻。