1. 从"能跑就行"到"榨干每一滴性能":厂商内核定制的真实动机
很多人第一次接触内核定制,都是被一个很朴素的需求逼出来的:官方内核跑分不差,但一到自己的业务场景就拉胯。比如某款移动设备在实验室环境跑分很漂亮,可一旦放到实际产线上做图像采集,帧率就断崖式下跌;又比如某款工控板卡,官方内核默认配置下USB传输延迟忽高忽低,导致数据丢包。这些问题往往不是硬件不行,而是内核里那些"通用配置"根本没考虑你的具体场景。
厂商内核与驱动定制这件事,说白了就是在芯片原厂或板卡厂商提供的BSP(Board Support Package)基础上,针对自己的产品形态、业务负载和硬件组合,做一轮"精装修"。原厂给的是毛坯房,水电通了、墙砌好了,但插座位置不一定顺手,承重墙也不一定留对了地方。你要做的,是在不破坏主体结构的前提下,把内核配置、驱动参数、电源管理策略、中断亲和性这些东西调到最贴合自己产品的状态。
这个领域涉及的核心技术点非常密集:内核配置裁剪(Kconfig)、设备树覆盖(Device Tree Overlay)、驱动模块的编译与签名、电源域与时钟树的调整、中断控制器的路由配置、DMA缓冲区的对齐与缓存一致性处理、以及不同内核版本之间API变更带来的移植成本。应用场景主要集中在嵌入式设备、边缘计算盒子、工业控制主板、车载终端、以及各类定制化ARM/x86平台。适合阅读这篇文章的人,应该已经具备基本的Linux内核编译经验,能看懂Makefile和Kconfig,至少手动编译过一次内核,否则后面很多操作会像看天书。
我见过太多团队在这件事上走弯路:要么直接拿原厂配置硬上,出了问题就加延时、加重试,把软件写成"玄学调参";要么过度裁剪,把一些看似无关的子系统删掉,结果某个驱动隐式依赖被破坏,调试三天三夜找不到原因。下面我就按实际项目中的操作顺序,把厂商内核定制的完整链路拆开讲,重点放在那些文档里不会写、但踩过一次就忘不掉的经验上。
2. 拿到BSP之后的第一件事:不是编译,是建立基线
2.1 为什么必须先跑通原厂配置再动刀
很多人的习惯是拿到BSP压缩包,解压之后直接改配置、加驱动、编译烧录,然后开始调试。这个顺序是错的。正确的做法是:在完全不动任何配置的情况下,先用原厂默认配置编译一次,烧到目标板上,跑一遍基本功能测试,记录下所有能观测到的指标。这一步的目的不是验证原厂代码好不好,而是建立一个"已知基线"。
为什么基线这么重要?因为后面你每改一个配置项,都需要知道这个改动到底带来了什么影响。如果没有基线,你改了三个参数之后系统起不来了,你根本不知道是哪个参数导致的。有了基线,你可以用二分法快速定位:先回退一半改动,看是否恢复;再回退一半的一半,逐步缩小范围。这个思路听起来简单,但实际项目中我见过太多人跳过这一步,最后在十几个改动里靠猜。
基线测试要记录的东西包括:启动时间(从uboot到用户态init完成)、各主要外设的枚举情况(用lsusb、lspci、ls /sys/class/等)、CPU空闲时的功耗(如果有功耗计)、内存占用分布(cat /proc/meminfo)、中断分布(cat /proc/interrupts连续采样几次)、以及关键业务的吞吐或延迟指标。这些数据后面每改一版都要对比,所以最好写成一个脚本自动采集。
2.2 原厂内核源码的目录结构陷阱
原厂BSP的内核源码目录往往和主线内核有差异。常见的坑包括:厂商把驱动放在drivers/下自己新建的目录里,而不是遵循主线的子系统划分;设备树文件可能放在arch/arm64/boot/dts/下的厂商子目录,而不是按芯片型号命名;有些厂商还会修改顶层Makefile的编译选项,加入自己的优化标志。
我建议在动手之前,先做三件事。第一,用git log --oneline -20看看厂商最近改了什么,如果BSP是基于某个主线版本打的补丁,提交历史里通常能看到主线版本号。第二,对比arch/arm64/configs/下的defconfig文件和主线同名文件,用diff看看厂商加了哪些配置项。第三,检查drivers/下有没有厂商自己新增的目录,这些通常是板级特有的驱动,比如某个特定的PMIC、传感器或接口芯片。
注意:有些厂商的BSP里,内核源码和设备树是分开两个仓库管理的,设备树可能放在uboot仓库或者独立的dts仓库里。拿到BSP第一件事是确认内核版本、设备树位置、以及编译工具链的版本要求,这三样对不上,后面全是坑。
2.3 工具链版本匹配的隐性成本
原厂BSP通常会指定一个推荐的工具链版本,比如"gcc 9.3.0"或"aarch64-none-linux-gnu 10.2"。很多人觉得工具链版本差不多就行,用自己习惯的版本编译,结果遇到各种奇怪的链接错误或运行时崩溃。内核和工具链之间的ABI兼容性虽然大体稳定,但不同版本对某些内联汇编、结构体对齐、以及栈保护策略的处理是有差异的。
我的做法是:如果原厂指定了工具链,就严格用那个版本,哪怕它比较老。如果原厂没指定,就用BSP发布前后半年内的主流版本。千万不要用太新的工具链去编译老内核,反过来也一样。如果实在需要用新工具链,至少要把CONFIG_STACKPROTECTOR、CONFIG_FORTIFY_SOURCE这些安全相关的选项先关掉,排除工具链引入的变量,等系统跑通后再逐个打开验证。
3. 内核配置裁剪:删得越多不一定越好
3.1 从defconfig到精简配置的正确路径
内核配置裁剪的目标是减小镜像体积、缩短启动时间、降低内存占用。但"裁剪"不等于"乱删"。正确的路径是:先基于原厂defconfig生成完整配置,然后用make savedefconfig得到一个最小化的defconfig,再在这个基础上按需增删。
具体操作上,我通常分三步走。第一步,make ARCH=arm64 defconfig生成默认配置,然后make ARCH=arm64 menuconfig打开图形界面,但先不做任何修改,直接保存退出,这样会生成一个完整的.config。第二步,用make ARCH=arm64 savedefconfig生成defconfig文件,这个文件只包含与默认值不同的配置项,非常精简。第三步,把这个精简后的defconfig作为新的基线,后续所有修改都基于它。
这样做的好处是,你清楚地知道哪些配置是"非默认"的,哪些是内核默认值。很多人直接改.config,改了几百项之后根本分不清哪些是必须的、哪些是顺手加的。用savedefconfig之后,配置文件通常只有几百行,可读性大幅提升。
3.2 那些看起来没用但绝对不能删的配置
裁剪时最容易犯的错误,是删掉一些"看起来没用"的配置,结果导致某个驱动隐式依赖被破坏。我列几个典型的例子:
CONFIG_ARM64_VA_BITS:虚拟地址位宽,看起来和业务无关,但如果你用的驱动里有大块DMA映射,位宽不够会导致映射失败。CONFIG_CMA:连续内存分配器,很多多媒体驱动和DMA驱动依赖它,删掉之后驱动加载时才会报错,而且报错信息往往指向别处。CONFIG_PM和CONFIG_PM_SLEEP:电源管理框架,即使你的设备不需要休眠,某些驱动在初始化时会调用PM相关的API,删掉会导致编译失败或运行时空指针。CONFIG_DEBUG_FS:调试文件系统,生产环境可以关,但调试阶段一定要开,很多驱动通过debugfs暴露内部状态。CONFIG_FTRACE和CONFIG_FUNCTION_TRACER:函数跟踪,裁剪时容易被当成"调试功能"删掉,但有些性能分析工具依赖它。
我的经验是:第一轮裁剪只删那些明确知道用途且确定不需要的子系统,比如不需要的 filesystem(如CONFIG_EXT4_FS如果只用squashfs)、不需要的网络协议(如CONFIG_IPV6如果只用IPv4)、不需要的输入设备(如CONFIG_INPUT_MOUSE如果是无头设备)。第二轮再根据启动日志和运行时行为,逐步删掉那些确实没被调用的模块。
3.3 模块化与内建的取舍逻辑
内核配置里每个选项都有三种状态:内建(y)、模块(m)、不编译(n)。什么时候用y,什么时候用m,这个决策直接影响启动时间和内存占用。
内建(y)的优点是启动时直接初始化,不需要用户态加载,适合那些启动早期就需要的驱动,比如存储控制器、串口、时钟控制器、中断控制器。缺点是内核镜像变大,所有内建驱动都会占用内核内存空间,而且无法在运行时卸载。
模块(m)的优点是按需加载,节省启动时的内存和初始化时间,适合那些不是启动必须的驱动,比如USB外设、网络协议族、文件系统。缺点是加载时需要用户态配合,如果根文件系统还没挂载就需要这个驱动,那就不能用模块。
我的判断标准很简单:如果这个驱动在init进程启动之前就需要工作,用y;否则用m。比如eMMC控制器必须内建,因为根文件系统在上面;而USB摄像头驱动可以做成模块,等系统起来后再加载。另外,频繁使用的驱动建议内建,避免每次加载的开销;很少使用的驱动做成模块,需要时再加载。
4. 设备树定制:硬件描述的艺术
4.1 设备树覆盖的编译与加载机制
设备树(Device Tree)是ARM平台描述硬件拓扑的标准方式。厂商BSP通常会提供一个基础设备树文件(.dts),描述芯片内部的外设控制器和引脚复用。你的板级定制,通常是通过设备树覆盖(Overlay)或者直接修改板级.dts来实现。
设备树覆盖的编译流程是:先写.dts文件,用dtc编译器编译成.dtbo(设备树二进制覆盖),然后在uboot或内核启动时加载。加载方式有两种:一种是在uboot里用fdt apply命令应用覆盖,另一种是在内核启动参数里指定overlays=。前者更灵活,可以在启动时根据硬件版本选择不同的覆盖;后者更简单,适合固定配置。
我通常推荐在uboot里应用覆盖,因为这样可以在启动早期就确定硬件配置,内核启动时看到的设备树就是最终版本。具体操作是在uboot的环境变量里加一行fdt apply /boot/overlays/xxx.dtbo,注意路径要相对于uboot能访问的文件系统。
4.2 引脚复用配置的常见错误
引脚复用(Pin Muxing)是设备树里最容易出错的部分。一个引脚通常可以配置成多种功能,比如GPIO、UART、I2C、PWM等。配置错了,轻则外设不工作,重则引脚冲突导致系统不稳定。
常见的错误包括:忘记配置引脚的电气属性(上拉、下拉、驱动能力);多个设备树节点引用了同一个引脚但没有做互斥;引脚的function和group名称写错,导致配置被静默忽略。
我的排查方法是:在系统启动后,用cat /sys/kernel/debug/pinctrl/*/pinmux-pins查看每个引脚的实际复用状态。如果某个引脚显示为"UNCLAIMED",说明没有驱动认领它;如果显示的功能和你预期的不一样,说明设备树配置有问题。另外,/sys/kernel/debug/pinctrl/*/pinconf-pins可以看到引脚的电气配置,比如上拉、下拉、驱动强度。
注意:有些SoC的引脚复用寄存器是写保护的,需要先解锁才能配置。厂商BSP通常会在uboot或内核早期做解锁操作,如果你自己写覆盖,要确认解锁步骤没有被跳过。
4.3 设备树与驱动匹配的调试技巧
设备树节点和驱动之间的匹配,是通过compatible属性完成的。驱动里会定义一个of_device_id数组,里面列出它支持的compatible字符串。如果设备树里的compatible和驱动里的不匹配,驱动就不会被加载。
调试这个问题的第一步是看/sys/firmware/devicetree/base/下的节点是否存在。如果节点存在但驱动没加载,用dmesg | grep -i "of"看内核有没有报匹配失败。第二步是检查驱动是否被编译进内核或模块,用lsmod或cat /lib/modules/$(uname -r)/modules.builtin确认。第三步是看驱动的of_device_id表,确认compatible字符串是否完全一致,包括大小写和连字符。
我遇到过一种情况:设备树里的compatible写的是"vendor,device-v2",但驱动里只支持"vendor,device",结果驱动不加载。这种问题在厂商BSP里很常见,因为厂商可能更新了硬件版本但忘了更新驱动。解决办法是在设备树里同时写多个compatible,把通用的放在后面作为fallback。
5. 驱动移植与适配:从能编译到能工作
5.1 内核版本差异带来的API变更
厂商BSP的内核版本往往比主线落后不少,比如主线已经到6.x,BSP还在5.10或5.15。当你需要把主线上的某个新驱动移植到BSP内核时,API差异就是第一道坎。
常见的API变更包括:struct file_operations的成员变化、probe函数的参数从struct platform_device *变成struct platform_device *加struct device *、gpio接口从整数编号变成struct gpio_desc *、clock框架的clk_get参数变化、以及DMA引擎的slave_config结构体字段调整。
我的移植策略是:先看驱动在主线上的提交历史,找到它最后一次大改的时间点,然后对比BSP内核版本和那个时间点之间的API差异。如果差异太大,考虑找一个中间版本的驱动作为过渡。另外,内核源码里的Documentation/目录下有各个子系统的API文档,虽然不一定最新,但比盲目搜索靠谱。
5.2 中断与DMA的适配要点
中断和DMA是驱动里最容易出问题的部分,也是厂商内核定制中最需要关注的环节。
中断方面,ARM平台通常使用GIC(Generic Interrupt Controller),设备树里通过interrupt-parent和interrupts属性描述。常见问题包括:中断号写错、触发方式(上升沿、下降沿、高电平、低电平)配置错误、以及中断亲和性(affinity)没有设置导致所有中断都跑到同一个CPU核心上。后者在高负载场景下会成为瓶颈,解决办法是在/proc/irq/*/smp_affinity里设置中断到CPU的映射,或者用irqbalance服务自动分配。
DMA方面,关键是缓存一致性。ARM平台通常需要软件维护缓存一致性,驱动里要用dma_map_single、dma_unmap_single、dma_sync_single_for_cpu、dma_sync_single_for_device这些API。如果漏掉了sync操作,CPU和DMA控制器看到的数据可能不一致,表现为数据随机出错。另外,DMA缓冲区的对齐要求也要注意,有些控制器要求缓冲区按cache line对齐,不对齐会导致性能下降甚至数据损坏。
5.3 电源管理驱动的定制策略
电源管理是厂商内核定制里最容易被忽视、但对实际产品影响最大的部分。一个典型的场景是:设备在空闲时功耗偏高,电池续航不达标。排查下来往往是某个驱动没有实现runtime PM(运行时电源管理),导致外设一直处于活跃状态。
Runtime PM的核心思想是:当设备空闲时,自动把它切换到低功耗状态;当有请求时,再唤醒。驱动里需要实现runtime_suspend和runtime_resume回调,并在合适的时机调用pm_runtime_get_sync和pm_runtime_put_sync。厂商BSP里的驱动往往只实现了系统级休眠(system suspend),没有实现runtime PM,这就需要你自己补上。
补的时候要注意:pm_runtime_get_sync会增加引用计数,pm_runtime_put_sync会减少,计数归零时才会触发suspend。如果get和put不配对,设备要么一直不睡,要么过早睡下去导致访问出错。我通常会在驱动的open和close、或者read和write的边界处加get/put,确保每次访问前设备是活跃的,访问后允许它休眠。
6. 编译、签名与烧录:量产前的最后一公里
6.1 内核镜像格式与压缩方式的选择
ARM平台的内核镜像格式有好几种:Image(未压缩)、Image.gz(gzip压缩)、Image.lz4(lz4压缩)、Image.zst(zstd压缩)。选择哪种格式,取决于uboot的支持情况和启动时间要求。
未压缩的Image启动最快,因为不需要解压,但体积最大。压缩格式可以减小存储占用,但启动时需要解压时间。在存储空间紧张或者需要通过网络加载内核的场景下,压缩格式更有优势。lz4的解压速度比gzip快,但压缩率略低;zstd的压缩率和解压速度都比较均衡,但需要uboot支持。
我的建议是:如果uboot支持lz4,优先用lz4,因为解压速度快,对启动时间影响小。如果不支持,用gzip作为fallback。zstd虽然好,但uboot的支持不如前两者普遍,用之前要确认。
6.2 内核模块签名与安全启动
如果产品需要支持安全启动(Secure Boot),内核模块的签名就是必须的。安全启动的链条是:BootROM验证uboot签名,uboot验证内核签名,内核验证模块签名。任何一环签名不匹配,启动就会中断。
模块签名的流程是:先生成一对密钥(私钥签名,公钥验证),把公钥编译进内核(通过CONFIG_SYSTEM_TRUSTED_KEYS),然后用私钥对每个.ko文件签名(用scripts/sign-file工具)。内核加载模块时,会用内建的公钥验证模块签名,验证失败就拒绝加载。
这里有个坑:如果你在开发阶段关了签名验证,量产时打开,之前编译的模块全部要重新签名。所以最好在项目初期就把签名流程搭好,开发阶段用测试密钥,量产时换成正式密钥。另外,私钥一定要保管好,泄露了就等于安全启动形同虚设。
6.3 烧录工具与分区布局的注意事项
烧录工具的选择取决于芯片平台。常见的工具有fastboot、rkdeveloptool、sunxi-fel、以及厂商自己的烧录软件。不管用哪种,分区布局都是需要仔细规划的部分。
典型的分区包括:bootloader分区、boot分区(内核+设备树)、rootfs分区、data分区、以及可能的recovery分区。每个分区的大小要在编译前确定,因为uboot和内核启动参数里会引用分区偏移和大小。如果分区大小变了,启动参数也要跟着改,否则会挂载失败。
我遇到过一种情况:rootfs分区初始给了512MB,后来系统功能增加,rootfs超过512MB,烧录时被截断,导致文件系统损坏。解决办法是在规划阶段留足余量,或者把rootfs做成只读的squashfs,data分区单独挂载可写。另外,如果支持OTA升级,还要考虑A/B分区的设计,确保升级失败可以回滚。
7. 实测中的那些"玄学"问题与排查思路
7.1 启动卡死但没有任何日志输出
这是最让人头疼的问题之一。系统上电后串口没有任何输出,或者输出到某一行就卡住。排查思路要从最底层开始:
第一步,确认串口配置是否正确。波特率、数据位、停止位、校验位要和uboot和内核的配置一致。如果uboot有输出但内核没输出,说明uboot到内核的交接出了问题,可能是内核加载地址不对、设备树地址不对、或者内核镜像格式不被uboot识别。
第二步,如果uboot也没有输出,检查DDR初始化是否成功。DDR参数不对会导致uboot在初始化内存时卡死。厂商BSP通常会提供一个已知可用的DDR配置,如果你改了硬件(比如换了DDR颗粒),需要重新生成DDR参数。
第三步,用JTAG或调试器连接,看CPU的PC指针停在哪里。如果停在BootROM,说明启动介质(eMMC、SPI Flash、SD卡)没有被正确识别;如果停在uboot的某一行,说明uboot的某个驱动初始化失败。
7.2 外设时好时坏的不稳定现象
外设偶尔工作、偶尔不工作,这种问题最难排查,因为它不是必现的。常见原因包括:电源不稳定、时钟抖动、信号完整性差、以及软件里的竞态条件。
软件层面的排查,我通常先看中断计数。用cat /proc/interrupts连续采样几次,如果某个中断的计数增长不均匀,说明中断有丢失或延迟。然后看dmesg里有没有timeout、reset、error之类的关键字。如果驱动里有重试逻辑,把重试次数和重试间隔打印出来,看是否频繁触发。
硬件层面的排查,需要示波器或逻辑分析仪。重点看电源纹波、时钟频率和抖动、以及信号线的上升沿和下降沿。如果信号完整性差,可能需要调整驱动能力、加匹配电阻、或者缩短走线。
7.3 性能不达标的系统性排查方法
性能问题往往不是单一原因造成的,而是多个因素叠加。我的排查顺序是:CPU -> 内存 -> I/O -> 中断 -> 调度。
CPU方面,用top或perf top看哪个进程占用最高,用perf stat看IPC(每周期指令数)和cache命中率。如果IPC低、cache命中率低,说明代码局部性差或者内存带宽不足。
内存方面,用free看可用内存,用vmstat看swap和page fault。如果频繁swap,说明内存不够;如果page fault高,说明内存访问模式不好。
I/O方面,用iostat看磁盘吞吐和延迟,用dd或fio做基准测试。如果I/O延迟高,可能是存储介质慢、文件系统碎片多、或者I/O调度器不合适。
中断方面,用/proc/interrupts看中断分布,如果某个CPU核心的中断特别多,用smp_affinity重新分配。
调度方面,用perf sched看调度延迟,如果延迟高,可能是CPU频率不够、或者有实时进程抢占。
8. 从项目实践中沉淀下来的几条硬经验
第一,永远保留一个"已知可用"的内核镜像和对应的设备树。每次改动之前,先备份当前可用的版本。如果改坏了,可以快速回退,而不是从头编译。我通常会在/boot目录下保留最近三个版本的镜像,命名里带上日期和改动摘要。
第二,内核配置的改动要记录原因。不要只写"改了XXX配置",要写"因为XXX场景下出现XXX问题,所以打开/关闭XXX配置"。半年后回头看,你根本记不住当时为什么改。我习惯在defconfig文件头部用注释记录改动历史,格式是# YYYY-MM-DD: 改动内容 - 原因。
第三,设备树里的每个节点都要有注释,说明它对应哪个硬件、哪个引脚、哪个驱动。厂商BSP里的设备树往往注释很少,自己定制时一定要补上。否则硬件改版后,你根本不知道哪个节点要跟着改。
第四,驱动调试时优先用dev_dbg而不是printk。dev_dbg可以通过动态调试(dynamic debug)在运行时开关,不需要重新编译内核。用echo 'file xxx.c +p' > /sys/kernel/debug/dynamic_debug/control就可以打开某个文件的调试输出。
第五,性能优化之前先做基准测试。没有基准数据,你无法判断优化是否有效。基准测试要覆盖典型业务场景,而不是只跑分。比如图像采集设备,基准测试应该是连续采集一小时,统计帧率、丢帧数、CPU占用、内存占用,而不是只跑一个dd命令。
第六,量产固件一定要做老化测试。至少连续运行72小时,覆盖高低温、电压波动、频繁重启等场景。很多问题只在长时间运行或极端条件下才会暴露,比如内存泄漏、文件系统损坏、看门狗误触发。
第七,文档和脚本要跟着代码一起维护。编译脚本、烧录脚本、测试脚本,都要纳入版本管理。我见过太多项目,代码在git里,但编译命令只存在于某个人的终端历史里,人一走,项目就编译不出来了。
这些经验听起来都是常识,但实际项目中能全部做到位的团队少之又少。厂商内核与驱动定制这件事,技术深度是一方面,工程规范是另一方面。两者都到位,才能做出稳定可靠的产品。