第4章 数据采集设备硬件模型:ADC、GPIO、SPI、I2C、UART在设备树中的抽象

做嵌入式这些年,我见过不少工程师拿着芯片手册直接写设备树,结果板子死活起不来。为什么?说白了,你没搞懂硬件在设备树里是怎么「翻译」的。

这一章,我们就来聊聊数据采集设备里最常见的五种外设——ADC、GPIO、SPI、I2C、UART。它们在设备树里各自长什么样?有哪些坑?我踩过的,你就别踩了。

核心观点:设备树不是硬件原理图,它是操作系统视角的「硬件抽象层」。你写的是描述,不是驱动。

4.1 整体框架:外设在设备树中的位置

先看一张图,你就明白这五个外设在设备树里是怎么组织的。

数据采集设备外设设备树结构 / (根节点) soc@0 (片上总线) adc@... ADC控制器 gpio@... GPIO控制器 spi@... SPI控制器 i2c@... I2C控制器 uart@... UART控制器 子节点:通道 或外部ADC 子节点:按键 LED、中断 子节点:SPI设备 (ADC/DAC/传感器) 子节点:I2C设备 (传感器/EEPROM) 子节点:蓝牙 GPS模块 关键抽象规则 1. 每个外设控制器是一个独立节点,挂在 SoC 总线节点下 2. 节点名称 = 外设类型@基地址,如 spi@ff110000 3. 挂在总线上的具体设备作为子节点,用 reg 指定片选/地址 4. 中断、时钟、DMA 等资源通过 phandle 引用 5. 引脚复用通过 pinctrl 子系统描述,不在外设节点内硬编码

4.2 ADC:模拟信号的数字入口

ADC 是数据采集设备的核心。设备树里描述 ADC,主要关注三件事:分辨率、采样率、通道映射。

我个人习惯把 ADC 节点分成两类:芯片内置ADC 和 外部SPI/I2C ADC。内置ADC直接挂在SoC总线节点下,外部ADC则挂在对应的SPI或I2C总线上。

4.2.1 内置ADC节点示例

adc@ff110000 {
    compatible = "rockchip,rk3399-saradc";
    reg = <0x0 0xff110000 0x0 0x100>;
    interrupts = <GIC_SPI 62 IRQ_TYPE_LEVEL_HIGH 0>;
    clocks = <&cru SCLK_SARADC>, <&cru PCLK_SARADC>;
    clock-names = "saradc", "apb_pclk";
    #io-channel-cells = <1>;
    status = "okay";
};

这里有个细节:#io-channel-cells = <1> 表示这个ADC提供1个通道索引参数。你想想看,如果ADC有8个通道,驱动怎么知道你要用哪个?就是靠这个cell。

我的经验:曾经有个项目,ADC采样值一直跳变。查了半天,发现是时钟频率配错了。内置ADC的时钟频率通常有上限,比如RK3399的SARADC最高只能到1MHz。配高了,采样结果就是乱的。

4.2.2 使用ADC通道的消费者节点

temperature-sensor {
    compatible = "some-thermistor";
    io-channels = <&adc 0>;  /* 使用ADC通道0 */
    temperature-threshold = <85000>;  /* 85度报警 */
};

4.3 GPIO:最灵活也最容易被忽略的外设

GPIO 在设备树里其实是个「控制器」。它不只是一个引脚,而是一组引脚的管理者。我见过有人把每个GPIO引脚都写成一个独立节点,那是错的。

4.3.1 GPIO控制器节点

gpio0: gpio@ff720000 {
    compatible = "rockchip,gpio-bank";
    reg = <0x0 0xff720000 0x0 0x100>;
    interrupts = <GIC_SPI 64 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&cru PCLK_GPIO0>;
    gpio-controller;
    #gpio-cells = <2>;
    interrupt-controller;
    #interrupt-cells = <2>;
};

注意 #gpio-cells = <2>。这两个cell分别代表:引脚编号和标志位。标志位用来控制是否上拉、是否开漏等。

4.3.2 在其它节点中引用GPIO

leds {
    compatible = "gpio-leds";
    status-led {
        label = "status";
        gpios = <&gpio0 RK_PA5 GPIO_ACTIVE_HIGH>;
        linux,default-trigger = "heartbeat";
    };
};

我曾经踩过的坑:GPIO的编号不是你想的那样。RK_PA5不是第5号引脚,它是Bank A的第5个引脚。不同芯片的Bank大小不一样,有的32个,有的16个。写死编号,换芯片就废了。一定要用宏定义。

4.4 SPI:高速数据通道

SPI 在数据采集设备里通常用来接高速ADC、DAC或者显示屏。设备树里SPI节点的写法比较固定,但有个容易出错的地方——片选。

4.4.1 SPI控制器节点

spi0: spi@ff1c0000 {
    compatible = "rockchip,rk3399-spi", "rockchip,rk3066-spi";
    reg = <0x0 0xff1c0000 0x0 0x1000>;
    interrupts = <GIC_SPI 48 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&cru SCLK_SPI0>, <&cru PCLK_SPI0>;
    clock-names = "spiclk", "apb_pclk";
    #address-cells = <1>;
    #size-cells = <0>;
    status = "okay";

    spidev@0 {
        compatible = "rohm,dh2228fv";
        reg = <0>;  /* 片选0 */
        spi-max-frequency = <10000000>;
    };
};

这里 reg = <0> 表示这个设备挂在片选0上。如果还有第二个设备,就写 reg = <1>。嗯,这里要注意:片选编号和硬件连接必须一致,否则驱动会操作错误的设备。

我建议:SPI的时钟频率不要直接写最大值。先写一个保守值,比如1MHz,调通了再往上加。我有一次直接写20MHz,结果示波器一看,波形都变形了——PCB走线太长,信号完整性不行。

4.5 I2C:传感器的最爱

I2C 在数据采集设备里太常见了。温度传感器、湿度传感器、气压计、加速度计……几乎全是I2C接口。设备树里I2C的写法比SPI更简单,但地址问题是个坑。

4.5.1 I2C控制器节点

i2c0: i2c@ff3c0000 {
    compatible = "rockchip,rk3399-i2c";
    reg = <0x0 0xff3c0000 0x0 0x1000>;
    interrupts = <GIC_SPI 57 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&cru SCLK_I2C0>, <&cru PCLK_I2C0>;
    clock-names = "i2c", "pclk";
    #address-cells = <1>;
    #size-cells = <0>;
    status = "okay";

    bme280@76 {
        compatible = "bosch,bme280";
        reg = <0x76>;  /* I2C地址 */
    };
};

I2C地址是7位的,但设备树里写的是左对齐后的地址。比如BME280的7位地址是0x76,写进去就是0x76。别搞错了。

我曾经遇到的情况:同一个I2C总线上挂了两个相同型号的传感器,地址冲突了。后来发现其中一个传感器的ADDR引脚可以拉高拉低来改变地址。设备树里写不同的reg值就行,但硬件上必须对应。

4.6 UART:调试与通信的万能接口

UART 在数据采集设备里通常用来输出调试日志,或者接蓝牙模块、GPS模块。设备树里UART的配置相对简单,但流控和引脚复用容易出问题。

4.6.1 UART控制器节点

uart0: serial@ff180000 {
    compatible = "rockchip,rk3399-uart", "snps,dw-apb-uart";
    reg = <0x0 0xff180000 0x0 0x100>;
    interrupts = <GIC_SPI 55 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&cru SCLK_UART0>, <&cru PCLK_UART0>;
    clock-names = "baudclk", "apb_pclk";
    reg-shift = <2>;
    reg-io-width = <4>;
    status = "okay";
};

reg-shift 和 reg-io-width 这两个属性很多人会忽略。它们告诉驱动寄存器地址的步长和访问宽度。写错了,串口能注册成功,但收发数据全是乱的。

我的习惯:调试串口一定要在设备树里加上 stdout-path 属性,这样内核启动日志就能从串口输出了。不然你只能盲调,那感觉……嗯,你懂的。

4.7 五种外设对比总结

为了方便你快速查阅,我把这五种外设在设备树中的关键属性整理成了一张表。

外设类型 关键compatible 地址cell数 子节点标识 常见坑点
ADC rockchip,rk3399-saradc 1 io-channels引用 时钟频率超限导致采样抖动
GPIO rockchip,gpio-bank 2 gpios属性引用 引脚编号与Bank映射错误
SPI rockchip,rk3399-spi 1 reg指定片选 片选编号与硬件不匹配
I2C rockchip,rk3399-i2c 1 reg指定地址 7位地址左对齐理解错误
UART snps,dw-apb-uart 1 无子节点 reg-shift/reg-io-width配置错误

4.8 避坑指南:我踩过的五个雷

做数据采集设备这些年,我在设备树上栽过不少跟头。挑几个典型的说说,你遇到了能少走弯路。

  1. 引脚复用没配——SPI的MISO没信号?先查pinctrl。很多芯片的引脚默认是GPIO模式,必须配成SPI功能才能用。
  2. 中断类型写错——有的芯片用电平触发,有的用边沿触发。写错了,中断要么不触发,要么一直触发。我那次就是,按了按键,中断处理函数跑了100多次。
  3. 时钟名字对不上——设备树里写的clock-names必须和驱动里clk_get查找的名字完全一致。大小写都不能错。我吃过这个亏,查了两天才发现是名字少了个下划线。
  4. status忘写"okay"——节点配得再完美,不写status = "okay",内核就不使能。默认是"disabled"。这个坑新手必踩。
  5. 地址重叠——两个外设的reg地址范围重叠了,内核启动时会报错。检查地址要仔细,特别是用DTS宏定义的时候。

最后说一句:设备树配置没有捷径。多看芯片手册,多对照参考板DTS文件,多拿示波器量波形。这三多做到了,你也能成为设备树专家。