4、传感器驱动开发环境搭建:交叉编译工具链配置、Linux内核驱动模块基础、设备树编写

好,咱们进入正题。这一章要聊的是传感器驱动开发的基础设施——说白了,就是让你写的代码能在嵌入式平台上跑起来。我见过不少新手,上来就写驱动代码,结果编译都过不了,或者烧进去系统直接崩了。为什么?环境没搭对。

嵌入式开发不是PC开发。你想想看,你的开发机是x86架构,目标板可能是ARM、RISC-V或者MIPS。代码得在开发机上编译,生成目标平台能执行的二进制文件。这就需要一个关键工具——交叉编译工具链

4.1 交叉编译工具链配置

交叉编译工具链,我习惯叫它“跨平台编译器全家桶”。它包含编译器(gcc)、链接器(ld)、汇编器(as)、调试器(gdb)以及各种库文件。你从芯片厂商(比如NXP、TI、Allwinner)的SDK里拿到的,通常是一个预编译好的工具链压缩包。

配置步骤其实不复杂,但细节容易翻车。

  1. 下载解压:拿到类似 gcc-linaro-7.5.0-2019.12-x86_64_arm-linux-gnueabihf.tar.xz 的文件,解压到 /opt//usr/local/ 下。我个人习惯放 /opt/,方便管理。
  2. 设置环境变量:把工具链的bin目录加到 PATH 里。编辑 ~/.bashrc~/.zshrc,加上一行:
    export PATH=$PATH:/opt/gcc-linaro-7.5.0-2019.12-x86_64_arm-linux-gnueabihf/bin
    然后 source ~/.bashrc 让它生效。
  3. 验证安装:运行 arm-linux-gnueabihf-gcc --version。如果能看到版本信息,说明环境变量配对了。
注意: 我曾经遇到过一个问题——工具链版本和内核版本不匹配。比如你用gcc 9.x去编译一个Linux 4.9的内核,可能会报一些奇怪的语法错误。建议优先使用芯片厂商推荐的工具链版本。

还有一个容易忽略的点:工具链的命名规则。比如 arm-linux-gnueabihf 这个前缀,它告诉编译器目标架构是ARM,使用Linux系统调用,支持硬件浮点(hf)。如果你的目标板是软浮点(比如某些Cortex-A5),就得用 arm-linux-gnueabi。搞错了,编译出来的程序一运行就崩。

4.2 Linux内核驱动模块基础

传感器驱动在Linux里通常以内核模块的形式存在。为什么?因为模块可以动态加载卸载,不用每次改驱动都重新编译整个内核。调试起来方便多了。

一个最简单的内核模块长什么样?我给你看个例子:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>

static int __init sensor_driver_init(void)
{
    printk(KERN_INFO "Sensor driver loaded!\n");
    return 0;
}

static void __exit sensor_driver_exit(void)
{
    printk(KERN_INFO "Sensor driver unloaded!\n");
}

module_init(sensor_driver_init);
module_exit(sensor_driver_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple sensor driver module");

嗯,这里要注意几个关键点:

  • module_initmodule_exit 是入口和出口。驱动加载时调用init,卸载时调用exit。
  • printk 是内核的打印函数,相当于用户空间的 printf。但它的日志级别(KERN_INFO)决定了消息会不会显示在控制台。
  • MODULE_LICENSE 必须声明。不声明的话,内核会报“tainted kernel”警告。

编译这个模块,你需要一个Makefile。我项目中常用的模板是这样的:

obj-m += sensor_driver.o

KDIR := /path/to/your/kernel/source
CROSS_COMPILE := arm-linux-gnueabihf-
CC := $(CROSS_COMPILE)gcc

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

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

这里 KDIR 指向你目标板的内核源码树。注意,这个源码树必须是已经编译过的,否则会报缺少 Module.symvers 的错误。我刚开始做驱动时,就卡在这一步好几天——后来发现是内核源码没先编译一遍。

小技巧: 如果你用的是树莓派或类似的板子,可以直接在板子上编译模块。但大多数嵌入式平台资源有限,还是建议交叉编译。编译完成后,把 .ko 文件拷贝到板子上,用 insmodmodprobe 加载。

4.3 设备树编写

设备树(Device Tree)是什么?说白了,它就是一份描述硬件信息的“配置文件”。以前的老内核(2.6时代)把硬件信息硬编码在C代码里,换一个板子就得改代码重新编译。现在好了,设备树把硬件描述和驱动代码分离开。

设备树文件通常以 .dts 结尾,编译后生成 .dtb 二进制文件。内核启动时,bootloader会把 .dtb 传给内核,内核解析后就知道有哪些硬件设备了。

假设我们有一个I2C接口的传感器,挂载在I2C总线上,地址是0x48。设备树里怎么写?

/dts-v1/;

/ {
    model = "MyEmbeddedBoard";
    compatible = "vendor,myboard";

    i2c0: i2c@ff000000 {
        compatible = "vendor,i2c-controller";
        reg = <0xff000000 0x1000>;
        #address-cells = <1>;
        #size-cells = <0>;

        temperature_sensor: sensor@48 {
            compatible = "vendor,temperature-sensor";
            reg = <0x48>;
            interrupt-parent = <&gpio1>;
            interrupts = <5 IRQ_TYPE_EDGE_RISING>;
        };
    };
};

我来解释一下关键字段:

  • compatible:驱动和设备树匹配的关键。驱动里会声明 of_match_table,内核根据这个字符串找到对应的驱动。
  • reg:地址信息。对于I2C设备,就是从设备地址(0x48)。对于内存映射设备,是基地址和范围。
  • interrupts:中断配置。这里指定了GPIO1的第5号引脚,上升沿触发中断。
重点: 设备树里的 reg 顺序和驱动里 platform_get_resource 读取的顺序是一一对应的。我曾经犯过一个错——设备树里写了两个reg区域,驱动里读取时顺序搞反了,结果读到的寄存器地址全是错的。排查了整整一个下午。

编译设备树也很简单:

dtc -I dts -O dtb -o myboard.dtb myboard.dts

dtc 是设备树编译器,通常在Linux内核源码的 scripts/dtc/ 目录下。你也可以单独安装 device-tree-compiler 包。

4.4 整合:从驱动到设备树的配合

驱动和设备树是怎么配合的?我举个例子。假设你的传感器驱动里这样写:

static const struct of_device_id sensor_of_match[] = {
    { .compatible = "vendor,temperature-sensor", },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, sensor_of_match);

static int sensor_probe(struct platform_device *pdev)
{
    struct device *dev = &pdev->dev;
    struct i2c_client *client;

    // 从设备树获取I2C设备信息
    client = of_find_i2c_device_by_node(dev->of_node);
    if (!client) {
        dev_err(dev, "Failed to find I2C client\n");
        return -ENODEV;
    }

    // 读取中断号
    int irq = platform_get_irq(pdev, 0);
    if (irq < 0) {
        dev_err(dev, "Failed to get IRQ\n");
        return irq;
    }

    // 注册中断处理
    devm_request_irq(dev, irq, sensor_isr, IRQF_TRIGGER_RISING,
                     "temperature_sensor", client);

    return 0;
}

你看,驱动里通过 of_match_table 匹配设备树中的 compatible 字符串。匹配成功后,内核调用 probe 函数。在 probe 里,驱动从设备树中读取中断号、I2C地址等信息。这样,硬件配置变了,只需要改设备树,驱动代码不用动。

我的经验: 调试设备树时,可以在内核启动参数里加 earlyconignore_loglevel,这样能看到更详细的设备树解析日志。如果设备树写错了,内核通常会打印类似 OF: fdt: not found /soc/i2c@... 的提示。

好了,这一章的内容就这些。环境搭好了,下一章我们就可以真正开始写传感器驱动了。记住,交叉编译工具链、内核模块框架、设备树,这三样东西是嵌入式驱动开发的“三驾马车”。任何一个环节出问题,你的传感器都跑不起来。