4、头文件设计:创建通用的 SoC 级 .dtsi 头文件

好,咱们接着聊。上一章我们把 SoC 的骨架搭起来了,也就是那个 .dtsi 文件的基本结构。但说实话,光有骨架还不够。你想想看,一个 SoC 里动辄几十个外设,每个外设的寄存器地址、中断号、时钟频率都不一样。如果把这些东西全写在 .dtsi 里,那文件会变得又臭又长,而且很难维护。

那怎么办?我的答案是:用头文件。

说白了,就是把那些“硬编码”的数值,比如 reg = <0x1c00000 0x1000> 里的地址,定义成有意义的宏。这样代码的可读性会好很多,而且复用起来也方便。我个人习惯把 SoC 级的头文件分成两类:一类是 寄存器地址宏,另一类是 中断号与时钟宏。

4.1 为什么要用头文件?

你可能觉得,直接在 .dtsi 里写数字也没什么大不了的。嗯,我以前也这么想。直到有一次,我在一个项目里需要把 UART0 的地址从 0x1c00000 改成 0x1c01000。你猜怎么着?我全局搜索了三次,改了六个地方,结果还是漏了一个。那次调试花了我整整一个下午。

从那以后,我就养成了一个习惯:所有 SoC 相关的硬件资源,都用宏定义。这样做有几个好处:

  • 可读性:看到 UART0_BASE 比看到 0x1c00000 直观得多。
  • 可维护性:改地址只需要改一个地方。
  • 可复用性:同一个 SoC 的不同板级文件,直接 #include 就行。

核心原则:头文件里只放“定义”,不放“逻辑”。也就是说,只定义宏,不要写设备节点。

4.2 头文件的结构设计

我一般会为每个 SoC 系列创建两个头文件:

  • soc_regs.h:存放所有外设的基地址、偏移量、寄存器位定义。
  • soc_irq.h:存放中断号、时钟 ID、DMA 通道号等。

当然,你也可以把它们合并成一个。但我个人觉得分开更清晰。毕竟,中断号和寄存器地址的变更频率不一样,分开管理更灵活。

下面是一个典型的 soc_regs.h 示例:

// soc_regs.h - 通用 SoC 寄存器地址定义
#ifndef __SOC_REGS_H__
#define __SOC_REGS_H__

// UART 模块基地址
#define UART0_BASE          0x1c00000
#define UART1_BASE          0x1c01000
#define UART2_BASE          0x1c02000

// I2C 模块基地址
#define I2C0_BASE           0x1c03000
#define I2C1_BASE           0x1c04000

// SPI 模块基地址
#define SPI0_BASE           0x1c05000
#define SPI1_BASE           0x1c06000

// GPIO 模块基地址
#define GPIO0_BASE          0x1c07000
#define GPIO1_BASE          0x1c08000

// 定时器模块基地址
#define TIMER0_BASE         0x1c09000
#define TIMER1_BASE         0x1c0a000

// 寄存器偏移量(以 UART 为例)
#define UART_TX_REG         0x00
#define UART_RX_REG         0x04
#define UART_STATUS_REG     0x08
#define UART_CTRL_REG       0x0C

#endif /* __SOC_REGS_H__ */

你看,这样定义之后,在 .dtsi 里引用就非常清晰了:

// 在 soc.dtsi 中引用头文件
#include "soc_regs.h"
#include "soc_irq.h"

/ {
    soc {
        uart0: serial@1c00000 {
            compatible = "myvendor,uart";
            reg = ;
            interrupts = ;
            clocks = <&clk UART0_CLK>;
            status = "disabled";
        };

        uart1: serial@1c01000 {
            compatible = "myvendor,uart";
            reg = ;
            interrupts = ;
            clocks = <&clk UART1_CLK>;
            status = "disabled";
        };
    };
};

嗯,这里要注意:reg 属性的第二个参数是地址长度,一般用 0x1000 就够了。但有些 SoC 的外设地址空间可能更大,需要根据实际情况调整。

4.3 中断号与时钟宏的设计

中断号这块,我踩过不少坑。最典型的一个问题是:中断号是 SPI 还是 PPI? 很多新手容易搞混。

简单说一下:

  • SPI(Shared Peripheral Interrupt):外设共享的中断,中断号从 32 开始(在 ARM GIC 中)。
  • PPI(Private Peripheral Interrupt):每个 CPU 核心私有的中断,比如定时器中断。

我在项目中遇到过,有人把 SPI 中断号直接写成了硬件手册上的原始编号,结果中断一直触发不了。后来才发现,设备树里需要的是 GIC 的 SPI 编号,而不是硬件手册上的绝对编号。

所以,我建议在头文件里明确区分:

// soc_irq.h - 中断号与时钟 ID 定义
#ifndef __SOC_IRQ_H__
#define __SOC_IRQ_H__

// SPI 中断号(GIC 视角)
#define UART0_IRQ           32
#define UART1_IRQ           33
#define UART2_IRQ           34
#define I2C0_IRQ            35
#define I2C1_IRQ            36
#define SPI0_IRQ            37
#define SPI1_IRQ            38
#define GPIO0_IRQ           39
#define GPIO1_IRQ           40
#define TIMER0_IRQ          41
#define TIMER1_IRQ          42

// PPI 中断号(通常固定)
#define TIMER_GLOBAL_IRQ    27
#define TIMER_PRIVATE_IRQ   29

// 时钟 ID(用于 clocks 属性)
#define UART0_CLK           0
#define UART1_CLK           1
#define I2C0_CLK            2
#define I2C1_CLK            3
#define SPI0_CLK            4
#define SPI1_CLK            5

#endif /* __SOC_IRQ_H__ */

注意:中断号的数值取决于你的 GIC 实现。有些 SoC 的 SPI 中断号从 0 开始,有些从 32 开始。一定要参考你的 SoC 数据手册,或者直接看内核里的 interrupts 属性示例。

4.4 头文件的组织与复用

头文件写好了,怎么组织呢?我一般会在 SoC 的 .dtsi 文件里统一 #include,而不是在每个板级文件里单独引用。这样,只要 SoC 不变,板级文件就不需要关心这些宏定义。

举个例子,我的目录结构通常是这样的:

dts/
├── include/
│   ├── soc_regs.h
│   └── soc_irq.h
├── soc.dtsi
├── board-a.dts
└── board-b.dts

在 soc.dtsi 的开头:

// soc.dtsi
#include "soc_regs.h"
#include "soc_irq.h"

/ {
    // ... 设备节点
};

这样,board-a.dts 和 board-b.dts 只需要 #include "soc.dtsi" 就行了,完全不用关心宏定义的存在。这就是所谓的“一次定义,到处复用”。

4.5 避坑指南:头文件中的常见错误

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

  • 宏名冲突:不同 SoC 的头文件里,宏名可能重复。比如两个 SoC 都有 UART0_BASE,但值不同。我建议在宏名前加上 SoC 前缀,比如 MY_SOC_UART0_BASE。
  • 忘记加 include guard:这个老生常谈了,但真的有人会忘。没有 #ifndef 的话,重复包含会导致编译错误。
  • 中断号写错:我前面提到了,一定要确认是 SPI 还是 PPI。另外,有些 SoC 的中断号是 1-based 的,有些是 0-based 的,千万要看清楚。
  • 地址长度不匹配:reg 属性的第二个参数是地址长度,单位是字节。如果写错了,内核可能无法正确访问外设。

小技巧:如果你不确定某个宏的值对不对,可以在编译时加 -save-temps 选项,查看预处理后的 .dts 文件。这样就能看到宏展开后的实际数值了。

4.6 本章小结

好了,这一章的内容就这些。说白了,头文件设计就是“把数字变成名字”。别小看这一步,它能让你的设备树代码变得清晰、可维护、可复用。我个人的经验是,花 10 分钟设计好头文件,能省下后面 10 小时的调试时间。

下一章,我们会聊聊如何利用 #include 和 /include/ 来组织多板级文件。嗯,到时候见。


SoC 级头文件设计核心逻辑 SoC 头文件 soc_regs.h soc_irq.h 基地址 偏移量 位定义 SPI 中断号 PPI 中断号 时钟 ID 最终在 soc.dtsi 中 #include 引用,板级文件无需关心宏定义

可可精品资料库