
1. 从能跑就行到跑得刚好内核层定制的真实动机很多人第一次接触内核层定制都是被逼出来的。要么是板子上的某个外设死活认不到要么是系统启动时间比预期多了好几秒要么是存储空间实在塞不下一个完整发行版的内核。这时候你会发现改应用层配置、换驱动模块、调启动参数全都试过了问题还在。最后只能硬着头皮去动内核源码树里的配置项和代码。我最初也是这个路径。早期做嵌入式项目拿到一个厂商提供的默认内核配置编译出来能启动但一堆用不上的驱动全在里面镜像体积大得离谱启动日志刷屏。后来慢慢意识到内核层定制不是高级玩法而是嵌入式开发里迟早要面对的基本功。它的核心目标就三个裁剪掉不需要的东西、打开需要的东西、修改行为不符合预期的东西。这篇文章面向的是已经能在目标平台上编译并启动一个基础内核、但对内核配置项和源码修改还不太有把握的开发者。我会从配置机制讲起然后进入裁剪策略、驱动适配、启动优化、调试手段这几个实际环节把每一步的为什么和怎么做都说清楚。文中涉及的具体芯片型号和厂商名称我会用代称但操作逻辑是通用的。需要提前说明的是内核层定制和用户层开发有一个本质区别你改的东西直接影响整个系统的行为而且很多问题在编译阶段不会暴露只在运行时才显现。所以这一章的重点不只是怎么改还包括改完怎么验证和出了问题怎么定位。2. 内核配置系统的运作逻辑与常见误解2.1 Kconfig与.config的关系不是源文件与编译产物很多人以为.config就是Kconfig编译出来的东西这个理解只对了一半。Kconfig文件定义的是配置项的菜单结构和依赖关系它决定了你在menuconfig界面里能看到什么、哪些选项会互相影响。而.config是你最终选择的结果是一个纯文本的键值对集合。真正参与编译的是.config但.config里的每一项能不能生效取决于Kconfig里定义的依赖是否满足。举个例子你在.config里写了CONFIG_FOOy但Kconfig里FOO依赖于BAR而BAR没被选中那FOO实际上不会生效。编译系统不会报错只会默默忽略。这是新手最容易踩的坑之一。我建议在修改配置后养成一个习惯用grep检查关键配置项在最终.config里的状态而不是只看menuconfig界面上的勾选状态。因为menuconfig有时会显示一个选项为选中但实际依赖不满足退出保存后它会被自动取消。# 检查某个配置项是否真正生效 grep CONFIG_YOUR_TARGET .config # 查看某项的依赖关系在源码根目录执行 scripts/config --state CONFIG_YOUR_TARGET2.2 defconfig不是默认配置而是参考配置厂商提供的xxx_defconfig文件经常被误解为官方推荐配置。实际上它只是一个起点通常包含了让板子能启动的最小集合外加一堆厂商觉得你可能需要的驱动。直接用它编译出来的内核体积和启动时间往往都不是最优的。我的做法是先用defconfig编译一版能启动的内核作为基线然后通过localmodconfig或手动裁剪逐步缩小。localmodconfig的原理是读取当前运行系统的模块加载情况只保留正在使用的模块对应的配置项。这个方法在x86上很好用但在嵌入式交叉编译场景下需要额外注意——你得确保目标系统已经跑起来并且加载了你需要的所有模块。# 基于当前运行系统的模块使用情况生成精简配置 make localmodconfig # 或者基于defconfig开始然后手动调整 make xxx_defconfig make menuconfig注意localmodconfig会关掉很多当前没加载但以后可能需要的配置项。如果你在开发阶段还需要热插拔某些设备建议先手动记录这些模块生成配置后再把它们加回去。2.3 配置项的三种状态y、m、n的区别不只是编不编y表示编译进内核镜像m表示编译成可加载模块n表示不编译。很多人知道这个区别但不清楚什么时候该用m而不是y。核心判断标准是这个驱动在系统启动阶段是否需要。如果某个存储控制器驱动是挂载根文件系统所必需的那它必须是y因为模块加载需要文件系统已经就绪而文件系统又依赖存储控制器形成循环依赖。反过来USB网卡驱动、蓝牙协议栈这类在系统起来之后才用的东西用m更合适可以减小内核镜像体积也能在不需要时动态卸载。还有一个实际考量m会增加模块加载的开销和内存占用模块本身需要额外的管理结构但在多设备场景下按需加载比全部编入更节省内存。我一般的原则是启动路径上的用y其余优先用m只有确认永远不会用到的才设n。3. 裁剪策略怎么判断一个配置项该不该留3.1 从启动日志反推必需项裁剪最怕的是删过头系统起不来。我的经验是先不动手删先看启动日志。把默认配置编译出来的内核跑一遍完整记录dmesg输出然后逐条分析哪些驱动在启动阶段被真正调用了。具体做法是在启动参数里加上initcall_debug这样内核会打印每个初始化函数的调用时间和返回值。那些返回0且耗时较长的初始化项就是你需要重点关注的。返回-ENODEV的说明硬件不存在对应的配置项可以考虑关掉。# 在启动参数中添加 initcall_debug loglevel8 # 启动后查看 dmesg | grep -i initcall这个方法的好处是有数据支撑而不是凭感觉猜。我曾经在一个项目里通过这种方式发现默认配置里有一个触摸屏控制器驱动但板子上根本没接触摸屏初始化时白白浪费了将近200毫秒。关掉之后启动时间直接降了一截。3.2 用size命令量化每个配置项的体积贡献裁剪的另一个维度是体积。make menuconfig界面里虽然能看到每个选项的说明但不会告诉你它编译出来占多少空间。我的做法是编译完成后用size命令查看各个目标文件的大小然后对照配置项定位。# 查看内核各段大小 size vmlinux # 查看某个目录下所有目标文件的大小排序 find drivers/ -name *.o -exec size {} \; | sort -k1 -n -r | head -20更精细的做法是在.config里把怀疑的配置项从y改成m重新编译对比vmlinux的大小变化。虽然每次编译要花时间但对于体积敏感的项目这个投入是值得的。我整理过一个简单的判断表用来快速决定一个配置项的去留判断维度保留y或m裁剪n启动阶段是否必需是否硬件是否实际存在是否是否有替代方案无有更轻量的替代体积贡献小大且无用调试阶段是否需要是可设m否3.3 文件系统与网络协议栈的裁剪要格外小心文件系统和网络协议栈是内核里最容易牵一发动全身的部分。比如你关掉CONFIG_EXT4_FS但根文件系统恰好是ext4格式系统直接起不来。关掉CONFIG_INET所有网络功能全废包括你用来调试的telnet或ssh。我的建议是文件系统只保留实际使用的格式但至少保留两种一种用于根文件系统一种用于外部存储或恢复模式。网络协议栈方面CONFIG_INET必须保留CONFIG_IPV6如果项目不涉及可以关掉但要注意有些用户态程序会依赖IPv6的socket接口关掉后可能报错。# 查看当前系统实际挂载的文件系统类型 mount | grep -oP type \K\w | sort -u # 查看内核支持的文件系统 cat /proc/filesystems提示裁剪文件系统支持时务必保留CONFIG_BLK_DEV_INITRD和CONFIG_TMPFS前者用于initramfs后者很多用户态程序依赖。4. 驱动适配从认不到到跑得稳的完整链路4.1 设备树与驱动匹配的排查顺序在嵌入式Linux里设备认不到的第一排查点不是驱动代码而是设备树Device Tree。设备树描述了硬件连接关系驱动通过compatible字符串与设备树节点匹配。如果compatible写错了或者设备树里根本没这个节点驱动再正确也不会被调用。排查顺序应该是先确认设备树里有没有对应节点再确认compatible字符串是否与驱动里的of_device_id表匹配最后才去看驱动本身的probe函数有没有报错。# 查看设备树中某个节点的信息 ls /proc/device-tree/ cat /proc/device-tree/soc/i2c12340000/status # 查看驱动匹配情况 ls /sys/bus/platform/drivers/ ls /sys/bus/i2c/drivers/我遇到过一个案例I2C设备在设备树里节点存在compatible也匹配但驱动probe一直返回-EPROBE_DEFER。最后发现是I2C控制器的时钟没有使能导致I2C总线本身就没工作。这个问题的根源不在设备驱动而在时钟控制器驱动。设备树描述的是有什么但能不能用还取决于时钟、电源、引脚复用这些底层资源。4.2 时钟与电源域的配置陷阱现代SoC内部有复杂的时钟树和电源域管理。一个外设要工作需要时钟源使能、时钟分频正确、电源域打开、复位信号释放。这四步里任何一步没做设备都不会响应。在设备树里这些通常通过clocks、power-domains、resets属性来描述。但有些厂商的BSP里这些属性可能不完整需要手动补。我的做法是对照芯片手册的时钟树图逐个确认设备所需的时钟源和分频系数然后在设备树里补全。// 示例补全一个I2C控制器的时钟和电源配置 i2c1: i2c12340000 { compatible vendor,i2c-v2; reg 0x12340000 0x1000; clocks clk_periph 12, clk_periph 13; clock-names i2c_clk, pclk; power-domains pd_periph 3; resets rst_periph 5; status okay; };注意修改设备树后不需要重新编译内核只需要重新编译dtb并更新到启动分区。但如果你同时改了驱动代码那就需要重新编译内核模块或内核镜像。4.3 驱动probe失败的常见返回值与含义驱动probe函数的返回值直接告诉你失败原因。我整理了一个快速对照表返回值含义常见原因-ENODEV设备不存在compatible不匹配或设备树节点status为disabled-EPROBE_DEFER依赖未就绪时钟、电源、GPIO等依赖的驱动还没加载-EINVAL参数无效设备树属性值格式错误或缺失-EBUSY资源被占用寄存器地址或中断号与其他设备冲突-ENOMEM内存不足驱动申请内存失败通常是配置问题-EIO读写错误硬件通信失败检查总线和引脚-EPROBE_DEFER是最常见的也是最容易让人困惑的。它的意思是我现在还不能初始化等依赖的驱动就绪后再试。内核会把这个驱动放到延迟探测队列里等依赖驱动加载后重新尝试。如果一直重试失败通常是依赖链里有某个驱动始终没加载。# 查看延迟探测的设备 cat /sys/kernel/debug/devices_deferred # 查看驱动绑定状态 ls -l /sys/bus/platform/drivers/*/ | grep -v bind5. 启动优化从上电到用户态的时间都花在哪了5.1 用bootgraph和ftrace定位耗时大户启动优化不能靠猜。内核自带了bootgraph和ftrace工具可以精确到每个初始化函数的耗时。bootgraph的使用很简单在启动参数里加initcall_debug启动后把dmesg输出保存下来然后用内核源码里的scripts/bootgraph.pl脚本生成一个可视化图表。# 保存启动日志 dmesg boot.log # 生成启动时间图 scripts/bootgraph.pl boot.log boot.svgftrace更灵活可以跟踪特定函数或事件的耗时。对于启动优化我通常用function_graph跟踪器看看哪些函数调用链最长。# 启用function_graph跟踪器 echo function_graph /sys/kernel/debug/tracing/current_tracer echo 1 /sys/kernel/debug/tracing/tracing_on # ... 执行操作 ... echo 0 /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace5.2 并行初始化与异步探测的取舍内核默认是串行初始化大部分驱动。如果多个驱动之间没有依赖关系可以改成并行初始化来缩短总时间。方法是在驱动里把module_init替换为async_schedule或者使用deferred_probe机制。但并行初始化有个前提驱动之间不能有隐式依赖。比如两个驱动都操作同一个GPIO控制器并行初始化可能导致竞态。我的做法是先梳理驱动依赖图把确实独立的驱动改成异步有依赖的保持串行。// 将驱动初始化改为异步 static int __init my_driver_init(void) { return async_schedule(my_driver_probe_async, NULL); } late_initcall(my_driver_init);提示异步初始化会改变驱动的加载顺序如果用户态程序依赖固定的设备节点顺序如/dev/video0和/dev/video1的对应关系需要额外处理。可以用devicetree里的aliases节点固定编号。5.3 根文件系统挂载前的等待时间压缩从内核启动到挂载根文件系统之间有一段时间花在等待存储设备就绪上。如果存储设备是eMMC或SD卡内核会轮询等待设备初始化完成。这个等待时间可以通过调整轮询间隔和超时参数来压缩。# 查看存储设备初始化耗时 dmesg | grep -i mmc\|sdhci # 调整mmc轮询参数在启动参数中 sdhci.debug_quirks0x40另一个常见的耗时点是initramfs的解压和运行。如果项目不需要initramfs比如根文件系统直接在eMMC上可以完全去掉省下解压和切换根文件系统的时间。6. 调试手段内核出问题时你手里有哪些工具6.1 printk的替代方案动态调试与tracepointprintk是最直接的调试手段但它的缺点是需要重新编译内核、输出量大、可能影响时序。内核提供了dynamic_debug机制可以在运行时动态开启或关闭某个文件里某行代码的打印。# 开启某个文件的动态调试 echo file drivers/my_driver.c p /sys/kernel/debug/dynamic_debug/control # 查看当前已开启的动态调试点 cat /sys/kernel/debug/dynamic_debug/control | grep ptracepoint是更结构化的调试方式内核在关键路径上预置了静态跟踪点可以通过ftrace或perf来采集。相比printktracepoint的开销更小而且可以在不重新编译的情况下采集。# 查看可用的tracepoint cat /sys/kernel/debug/tracing/available_events # 启用某个tracepoint echo 1 /sys/kernel/debug/tracing/events/sched/sched_switch/enable6.2 内核崩溃时的现场保留与分析方法内核崩溃oops/panic时默认行为是打印调用栈然后挂起或重启。如果串口没接这些信息就丢了。我的做法是配置内核在崩溃时把日志保存到pstore或ramoops这样重启后还能从/sys/fs/pstore里读到崩溃现场。# 内核配置中启用 CONFIG_PSTOREy CONFIG_PSTORE_RAMy CONFIG_PSTORE_CONSOLEy # 启动参数中指定ramoops区域 ramoops.mem_address0x80000000 ramoops.mem_size0x100000崩溃日志里最关键的是调用栈和寄存器状态。调用栈告诉你崩溃发生在哪个函数调用链上寄存器状态告诉你当时的变量值。如果编译时保留了符号表CONFIG_DEBUG_INFOy可以用gdb或addr2line把地址翻译成源码行号。# 用addr2line翻译地址 addr2line -e vmlinux -f -i 0xffffffff812345676.3 用kgdb做源码级单步调试如果问题复杂到需要单步跟踪kgdb是终极手段。它需要两台机器目标机跑内核主机跑gdb通过串口或网络连接。配置起来比较麻烦但对于定位那些看代码怎么都对、跑起来就是不对的问题非常有效。# 目标机启动参数 kgdbocttyS0,115200 kgdbwait # 主机端连接 gdb vmlinux (gdb) target remote /dev/ttyUSB0 (gdb) break my_driver_probe (gdb) continue注意kgdb会暂停整个内核如果调试的是中断处理程序或调度器相关代码需要特别小心因为暂停后系统时钟也会停可能导致看门狗复位。7. 定制后的验证怎么确认改对了内核定制最容易出的问题是编译过了、启动也过了但某个功能悄悄坏了。所以改完之后不能只看能不能启动还要做一轮针对性验证。我的验证清单通常包括启动日志里有没有新的错误或警告、/proc/config.gz里的配置是否与预期一致、关键设备节点是否存在、dmesg里有没有驱动probe失败的记录、系统负载和内存占用是否正常。# 确认内核配置需要CONFIG_IKCONFIG_PROCy zcat /proc/config.gz | grep CONFIG_YOUR_TARGET # 检查驱动绑定情况 ls /sys/bus/*/drivers/*/ | grep -v bind | grep -v uevent # 查看启动以来的错误 dmesg | grep -i error\|fail\|warn对于启动时间优化我会用systemd-analyze如果用户态是systemd或手动打时间戳的方式对比优化前后的各阶段耗时。对于体积优化直接对比vmlinux和最终镜像的大小。还有一个容易被忽略的点回归测试。裁剪配置后之前能用的功能可能因为某个依赖被关掉而失效。我一般会保留一份功能清单每次定制后逐项验证。这个清单不需要很复杂就是列出项目实际用到的所有硬件接口和软件功能改完内核后挨个测一遍。8. 一些踩过坑之后才明白的事内核层定制这件事做得越多越觉得保守是个好习惯。我现在的原则是能不改源码就不改能通过配置解决就不动代码能保留的调试手段就不关掉。因为内核代码的耦合度很高你改一个地方可能在完全意想不到的场景下出问题。另一个体会是版本管理非常重要。每次修改.config或设备树或驱动代码都要有记录。我见过太多项目改到后面没人记得当初为什么关掉某个配置项等到需要那个功能时重新打开又引发一堆问题。我的做法是.config文件纳入版本管理每次修改写清楚原因和影响范围。最后不要迷信厂商的BSP。厂商提供的配置和驱动通常是为了让板子能跑起来而不是让板子跑得最好。很多优化空间就藏在那些默认打开的配置项里。当然修改之前先确保你理解了它的作用否则可能修好一个问题、引入三个新问题。