13、设备树与内核配置:Device Tree(DTS)、内核 defconfig 裁剪与配置

设备树和内核配置,是嵌入式Android系统开发中绕不开的两座山。我刚开始接触AOSP时,总觉得设备树就是一堆看不懂的dts文件,内核配置就是改几个开关。直到有一次,我因为漏配了一个GPIO节点,导致触摸屏在开机后完全没反应,排查了整整两天……嗯,从那以后,我再也不敢小看这两个东西了。

说白了,设备树就是告诉内核“你的硬件长什么样”,而内核配置就是告诉内核“你要支持哪些功能”。两者配合好了,你的ROM才能跑得稳、跑得快。

13.1 设备树(Device Tree)是什么?

设备树是一种描述硬件的数据结构。它用树形结构来描述CPU、内存、外设、中断控制器等硬件信息。内核在启动时解析设备树,就知道该初始化哪些设备、用哪个驱动。

你想想看,如果没有设备树,内核就得硬编码硬件信息。换一块板子就得重新编译内核。有了设备树,同一份内核镜像可以适配不同硬件,只要换一个dtb文件就行。

核心概念:

  • DTS:设备树源文件,文本格式,人类可读
  • DTC:设备树编译器,把dts编译成dtb
  • DTB:设备树二进制文件,内核实际使用的格式
  • DTBO:设备树叠加层,用于动态修改设备树

我在项目中遇到过最典型的问题:同一款SoC用在手机和平板上,外设完全不同。手机有摄像头、指纹,平板没有。这时候只需要准备两份dts文件,共用同一个内核,完美解决。

13.2 DTS文件结构详解

一个典型的dts文件长这样:

/dts-v1/;

#include "soc.dtsi"

/ {
    model = "My Android Device";
    compatible = "vendor,my-device", "vendor,soc";

    chosen {
        bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p5";
    };

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x40000000>;  // 1GB内存
    };

    i2c@ff110000 {
        compatible = "vendor,i2c-controller";
        reg = <0xff110000 0x1000>;
        interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>;
        clock-frequency = <400000>;

        touchscreen@38 {
            compatible = "goodix,gt911";
            reg = <0x38>;
            interrupt-parent = <&gpio1>;
            interrupts = <5 IRQ_TYPE_EDGE_FALLING>;
        };
    };
};

这里有几个关键点:

  • /dts-v1/:声明dts版本,必须写在第一行
  • #include:可以包含头文件或dtsi文件,dtsi是公共部分
  • compatible:匹配驱动的关键字符串,内核靠它找到对应驱动
  • reg:描述地址和大小,格式是<起始地址 长度>
  • interrupts:中断描述,包括中断类型、中断号、触发方式

个人经验:我建议把SoC公共部分放在dtsi文件中,板级差异放在dts中。这样多个项目可以复用同一个dtsi,改起来也方便。我曾经见过一个团队把所有东西都塞在一个dts里,结果维护起来简直是噩梦。

13.3 设备树编译与加载流程

设备树的编译流程其实很简单:

# 编译dts为dtb
dtc -I dts -O dtb -o my-device.dtb my-device.dts

# 反编译dtb为dts(调试用)
dtc -I dtb -O dts -o my-device.dts my-device.dtb

# 在AOSP中,设备树通常放在kernel目录下
# 编译时会自动生成dtb文件
make ARCH=arm64 my-device.dtb

内核启动时,bootloader(比如U-Boot或LK)会把dtb加载到内存中,然后把dtb的物理地址通过寄存器传递给内核。内核解析dtb后,开始初始化设备。

我记得有一次调试,内核死活不认我的触摸屏。后来发现是bootloader传的dtb地址不对,内核读到的全是乱码。嗯,这种问题最坑了,因为错误信息往往不明确。

13.4 内核defconfig裁剪与配置

内核配置,说白了就是决定内核要编译哪些模块、支持哪些功能。Android系统对内核大小有严格要求,太大的内核会影响启动速度和内存占用。

defconfig是内核的默认配置文件,放在arch/arm64/configs/目录下。比如:

# 查看当前配置
cat arch/arm64/configs/vendor_defconfig

# 修改配置
make ARCH=arm64 vendor_defconfig
make ARCH=arm64 menuconfig

# 保存新配置
make ARCH=arm64 savedefconfig
cp defconfig arch/arm64/configs/vendor_defconfig

注意:千万不要直接修改defconfig文件!正确的做法是用menuconfig修改,然后savedefconfig导出。直接改文件很容易漏掉依赖项,导致编译失败。

13.5 内核裁剪实战技巧

内核裁剪的核心原则:用不到的就不编译。但说起来容易做起来难,我见过有人为了追求极致大小,把USB驱动都裁掉了,结果adb连不上……

以下是我常用的裁剪策略:

模块 建议 说明
文件系统 只保留ext4、f2fs Android主要用这两个,其他可以去掉
网络协议 保留TCP/IP、IPv4 IPv6如果不需要可以关掉
驱动 只保留硬件需要的 lsmod查看当前加载的模块
调试功能 发布版全部关闭 比如printk、debugfs、lockdep
电源管理 保留suspend/resume Android的睡眠唤醒依赖这个

我曾经接手过一个项目,内核镜像有40MB,启动要8秒。经过裁剪后降到12MB,启动时间缩短到3秒。怎么做到的?其实就是把那些调试用的、实验性的功能全部关掉,只保留生产环境需要的。

13.6 设备树与内核配置的协同

设备树和内核配置不是孤立的。你想想看,设备树里描述了一个I2C触摸屏,但内核配置里把I2C驱动编译成模块了,那触摸屏还是用不了。

所以,我建议的流程是:

  1. 先确定硬件有哪些外设
  2. 编写设备树,描述所有外设
  3. 根据设备树,配置内核,确保所有用到的驱动都编译进去
  4. 编译测试,验证每个外设都能正常工作

避坑指南:我曾经因为设备树里写了一个不存在的GPIO号,导致内核在启动时panic。排查方法很简单:打开内核的CONFIG_OF_DEBUG,启动时会有详细的设备树解析日志。

13.7 知识体系总览

下面这张图总结了设备树与内核配置的核心逻辑:

设备树与内核配置知识体系 设备树(Device Tree) DTS 源文件 DTSI 公共头文件 DTC 编译器 → DTB 二进制 Bootloader 加载 DTB 到内存 内核解析 DTB,初始化设备 内核配置(Kernel Config) defconfig 默认配置 menuconfig 交互配置 savedefconfig 导出配置 编译内核,生成 zImage/Image 内核启动,加载驱动模块 硬件描述 驱动支持 两者协同:设备树描述硬件 → 内核配置提供驱动支持

从这张图可以看出,设备树和内核配置是两条并行的线,最终在内核启动时交汇。设备树告诉内核“我有什么硬件”,内核配置告诉内核“我能支持什么硬件”。两者必须匹配,系统才能正常工作。

13.8 常见问题与排查方法

最后,分享几个我踩过的坑:

  • 设备树节点没生效:检查compatible字符串是否和驱动匹配,大小写敏感
  • 内核配置改了没效果:记得make clean再重新编译,有时候缓存会坑人
  • 启动时卡在某个设备:打开CONFIG_DEBUG_LLCONFIG_EARLY_PRINTK,看串口输出
  • dtb文件太大:检查是否包含了不必要的节点,比如调试用的节点可以去掉

重要提醒:设备树里的reg地址一定要和硬件手册一致。我曾经因为看错了数据手册,把I2C控制器的地址写错了,结果所有I2C设备都初始化失败。这种低级错误最要命,但也是最容易犯的。

嗯,设备树和内核配置就讲到这里。这两个东西看似基础,但真正用好需要大量实践。我建议你找一块开发板,从零开始写设备树、配内核,跑一遍完整的流程。只有亲手踩过坑,才能真正理解它们的重要性。


公众号:蓝海资料掘金营,微信deep3321