5、Android Automotive内核与设备树:Linux内核在车载场景下的定制、设备树(Device Tree)语法与实战、内核模块编译与加载

说实话,做车载系统这几年,我最大的感触就是——内核这玩意儿,真不是拿来就能用的。你从高通或者NXP拿到的BSP,里面塞了一大堆用不上的驱动,车载场景下要的是精简、稳定、实时。我见过不少团队,直接把公版内核怼上去,结果启动慢、功耗高,最后被客户怼回来改。

这一章,我就带你走一遍车载内核定制的核心流程。咱们不扯虚的,直接上手。

5.1 车载场景下的内核定制思路

车载Linux内核和手机、服务器上的内核,区别在哪?说白了就三点:启动速度、实时性、外设管控

  • 启动速度:车机冷启动,用户希望5秒内看到Logo。内核必须裁剪,不必要的驱动全干掉。
  • 实时性:仪表盘、ADAS这些,延迟超过10ms就可能出问题。所以PREEMPT_RT补丁几乎是标配。
  • 外设管控:车载外设多——CAN、LIN、以太网AVB、摄像头、显示屏。每个都得在内核里配好。

我个人的习惯是,拿到一块新板子,先跑一遍make menuconfig,把不用的架构、驱动、文件系统支持全关掉。你想想看,一个车载系统要什么虚拟化?要什么NFS服务器?统统关掉。

核心原则:能静态编译的,绝不动态加载。车载系统讲究确定性,模块化加载虽然灵活,但启动顺序和依赖关系容易出问题。

5.2 设备树(Device Tree)语法与实战

设备树这玩意儿,刚接触的人会觉得它啰嗦。但做久了你会发现,它其实是硬件描述的最佳实践。尤其是车载平台,同一款SoC可能用在十几个车型上,每个车型的外设引脚都不一样。没有设备树,你改一个GPIO就得重新编译内核,那得多痛苦?

5.2.1 设备树基本结构

一个典型的设备树节点长这样:

/dts-v1/;

/ {
    model = "Qualcomm SA8155P Automotive Board";
    compatible = "qcom,sa8155p";

    chosen {
        bootargs = "console=ttyMSM0,115200n8 root=/dev/mmcblk0p2 rw";
    };

    soc {
        #address-cells = <2>;
        #size-cells = <2>;

        uart0: serial@a84000 {
            compatible = "qcom,msm-uartdm-v1.4";
            reg = <0x0 0x00a84000 0x0 0x1000>;
            interrupts = <0 113 0x4>;
            clocks = <&gcc GCC_BLSP1_UART1_APPS_CLK>;
            status = "okay";
        };

        i2c0: i2c@a88000 {
            compatible = "qcom,i2c-qup-v2.2.1";
            reg = <0x0 0x00a88000 0x0 0x1000>;
            interrupts = <0 115 0x4>;
            #address-cells = <1>;
            #size-cells = <0>;
            status = "okay";

            touch@38 {
                compatible = "goodix,gt911";
                reg = <0x38>;
                interrupt-parent = <&tlmm>;
                interrupts = <65 2>;
                reset-gpios = <&tlmm 64 GPIO_ACTIVE_LOW>;
            };
        };
    };
};

这里有几个关键点,我重点说一下:

  • compatible:驱动匹配的关键。内核通过这个字符串找到对应的驱动。命名规范是"厂商,设备型号"。
  • reg:描述地址和大小。注意#address-cells和#size-cells决定了地址和长度的位数。
  • status:我见过有人把不用的设备也配成"okay",结果内核去初始化一个不存在的硬件,直接卡死。不用的设备,要么删掉节点,要么设成"disabled"。
避坑指南:我曾经在一个项目里,因为设备树里interrupts的触发类型写错了(写了0x4表示高电平触发,但硬件实际是上升沿触发),导致触摸屏中断风暴,CPU占用率直接飙到100%。排查了整整两天才发现。所以,设备树里的每一个数字,都要和硬件手册一一核对

5.2.2 设备树实战:添加一个车载摄像头

假设我们要在SA8155P平台上添加一个OV10640摄像头,通过CSI接口连接。设备树里需要这样配:

&cam_sensor0 {
    cell-index = <0>;
    compatible = "ovti,ov10640";
    reg = <0x60>;
    clocks = <&camcc CAM_CC_MCLK0_CLK>;
    clock-names = "xvclk";
    clock-frequency = <24000000>;
    vdda-supply = <&pm8150_l7>;
    vddd-supply = <&pm8150_l8>;
    reset-gpios = <&tlmm 104 GPIO_ACTIVE_LOW>;
    powerdown-gpios = <&tlmm 105 GPIO_ACTIVE_HIGH>;
    status = "okay";

    port {
        sensor0_ep: endpoint {
            remote-endpoint = <&csiphy0_ep>;
            data-lanes = <1 2 3 4>;
            link-frequencies = /bits/ 64 <480000000>;
        };
    };
};

这里要注意的是data-laneslink-frequencies。我遇到过有人把data-lanes配成<1 2 3 4 5>,但CSI接口只支持4 lane,结果内核直接报错。还有link-frequencies,这个值必须和传感器实际输出的MIPI时钟匹配,否则图像全是雪花。

5.3 内核模块编译与加载

虽然我前面说能静态编译就静态编译,但有些场景下,模块化是必须的。比如:

  • 调试阶段的驱动,频繁修改,不想每次都烧写整个内核
  • 第三方驱动,比如某些车载以太网芯片,厂商只提供.ko文件
  • 按需加载,比如USB摄像头驱动,不是每次启动都用

5.3.1 编译一个内核模块

假设我们要写一个简单的车载GPIO按键驱动,编译成模块:

// gpio_key.c
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/gpio/consumer.h>
#include <linux/interrupt.h>

static irqreturn_t key_irq_handler(int irq, void *dev_id)
{
    pr_info("Key pressed!\n");
    return IRQ_HANDLED;
}

static int gpio_key_probe(struct platform_device *pdev)
{
    struct gpio_desc *gpio;
    int irq, ret;

    gpio = devm_gpiod_get(&pdev->dev, "key", GPIOD_IN);
    if (IS_ERR(gpio))
        return PTR_ERR(gpio);

    irq = gpiod_to_irq(gpio);
    ret = devm_request_irq(&pdev->dev, irq, key_irq_handler,
                           IRQF_TRIGGER_FALLING, "gpio_key", NULL);
    return ret;
}

static const struct of_device_id gpio_key_of_match[] = {
    { .compatible = "custom,gpio-key" },
    { }
};
MODULE_DEVICE_TABLE(of, gpio_key_of_match);

static struct platform_driver gpio_key_driver = {
    .probe = gpio_key_probe,
    .driver = {
        .name = "gpio_key",
        .of_match_table = gpio_key_of_match,
    },
};
module_platform_driver(gpio_key_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Car GPIO Key Driver");

Makefile这样写:

obj-m += gpio_key.o

KDIR := /path/to/your/kernel/source
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

编译出来就是gpio_key.ko。注意KDIR一定要指向你实际运行的内核源码树,版本必须完全一致。我见过有人用Ubuntu自带的headers编译,然后放到嵌入式板子上,insmod直接报Invalid module format

5.3.2 模块加载与卸载

加载模块:

insmod gpio_key.ko

查看模块信息:

modinfo gpio_key.ko

卸载模块:

rmmod gpio_key

如果模块之间有依赖关系,可以用modprobe,它会自动处理依赖。但车载系统里,我建议你明确加载顺序,不要依赖modprobe的自动解析。为什么?因为自动解析依赖modules.dep文件,这个文件如果没更新,或者依赖链里有循环,系统就挂了。

注意:在Android Automotive中,内核模块通常放在/vendor/lib/modules/目录下。系统启动时,init进程会根据init.rc中的配置加载模块。如果你要添加自己的模块,记得修改init.rc或者在vendor/etc/init/下添加一个自定义的rc文件。

5.4 车载内核定制实战流程

好了,理论说完了,咱们来走一遍实际流程。假设我们要为某款车型定制一个基于SA8155P的内核:

  1. 获取BSP:从芯片厂商拿到内核源码和设备树。通常是Linux 5.4或5.10版本。
  2. 配置内核make sa8155p_defconfig,然后make menuconfig裁剪。
  3. 修改设备树:根据车型的硬件原理图,修改dts文件。比如调整GPIO、I2C地址、时钟频率。
  4. 编译内核和设备树make -j8 Image dtbs
  5. 编译模块make modules,然后把.ko文件拷贝到目标系统。
  6. 烧写验证:把Image和dtb文件烧写到板子,启动后检查dmesg,确认所有设备都正确初始化。

这里我画了一张流程图,帮你理清整个定制流程:

车载内核定制实战流程 1. 获取BSP源码 2. 内核配置裁剪 3. 修改设备树 编译 5. 烧写验证 6. 检查dmesg OK? 失败 7. 排查问题 成功 8. 发布内核镜像 虚线表示失败后回退到修改设备树步骤

这个流程看起来简单,但实际做起来,每一步都可能踩坑。我举个例子,有一次我在裁剪内核时,把CONFIG_DRM关掉了,结果显示屏死活不亮。查了半天才发现,显示驱动依赖DRM框架。所以,裁剪之前,先搞清楚依赖关系

5.5 车载内核调试技巧

调试内核,说白了就是三板斧:dmesg、/sys、/proc。但车载场景下,有些技巧你可能不知道:

  • 动态调试:如果驱动里用了pr_debug,可以在运行时通过echo 'file gpio_key.c +p' > /sys/kernel/debug/dynamic_debug/control打开调试信息,不用重新编译。
  • 设备树覆盖:调试阶段,可以用configfs动态加载设备树覆盖文件,不用每次都烧写整个dtb。这个功能在Android Automotive里默认是开启的。
  • 内核崩溃转储:如果系统挂了,kdump可以帮你保存现场。车载系统里,这个功能一定要打开,否则出了问题你连日志都拿不到。
个人经验:我习惯在开发阶段把CONFIG_DEBUG_GPIOCONFIG_DEBUG_PINCTRL打开。这两个选项能帮你看到GPIO的当前状态和复用配置。有一次客户说某个外设不工作,我一看/sys/kernel/debug/gpio,发现某个引脚被配置成了输出,但硬件上它应该是输入。这种问题,没有调试信息,你根本想不到。

好了,这一章的内容就到这里。内核定制和设备树,是车载驱动开发的基本功。你把这些搞透了,后面做音频、显示、传感器驱动,都会轻松很多。


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