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/ 来组织多板级文件。嗯,到时候见。