第四章:设备树实战——为一个虚拟 ARM 平台编写设备树
好,前面几章我们把设备树的理论基础讲了个七七八八。说实话,光看文档是学不会设备树的。你得动手写,踩几个坑,才能真正理解它。
这一章,我们就来真刀真枪地干一场。我会带你为一个虚拟的 ARM 平台编写完整的设备树。这个平台叫 vexpress-a9,是 QEMU 模拟器里常用的一个模型。你不需要有真实的开发板,只要装个 QEMU 就能跑。
本章目标:从零开始,手写一个能让 Linux 内核在 vexpress-a9 上启动的设备树文件。最终你会得到一个 .dts 文件,并能在 QEMU 中验证它。
4.1 平台概览:先搞清楚我们要描述什么
写设备树之前,你得先了解硬件。vexpress-a9 这个平台,说白了就是一个 Cortex-A9 双核处理器,挂了一些标准外设。我当年第一次接触它时,第一反应是:这玩意儿跟真实开发板比,简单太多了。
它的核心部件包括:
- CPU:ARM Cortex-A9 双核,带 GIC(通用中断控制器)
- 内存:默认 128MB 或 256MB,起始地址 0x60000000
- 串口:PL011 UART,地址 0x10009000
- 定时器:SP804 双定时器,地址 0x10011000
- 中断控制器:GIC-400,地址 0x2C001000
- 系统控制:系统寄存器,地址 0x10000000
嗯,这里要注意:内存地址是 0x60000000 开头,而不是常见的 0x80000000。我第一次写的时候想当然用了 0x80000000,结果内核死活起不来。后来查了 QEMU 的源码才发现问题。
下面这张图,是我画的一个简化版硬件拓扑。你看一眼,心里就有谱了。
4.2 从骨架开始:根节点和 /dts-v1/
每个设备树文件,第一行必须是 /dts-v1/;。这告诉编译器:我用的是设备树 v1 语法。没有这行,编译会报错。
然后就是根节点 /。它代表整个板子。我个人习惯在根节点里放三样东西:
- model:板子型号,比如 "ARM Versatile Express"
- compatible:兼容性字符串,内核靠它匹配板子
- #address-cells 和 #size-cells:地址和长度用几个 32 位整数表示
来看代码:
/dts-v1/;
/ {
model = "ARM Versatile Express";
compatible = "arm,vexpress-v2p-ca9", "arm,vexpress";
#address-cells = <2>;
#size-cells = <2>;
// 后续节点都写在这里面
};
小提示:compatible 字符串的顺序很重要。内核会从左到右匹配,第一个匹配上的就用哪个。所以最具体的型号放最前面。
这里 #address-cells = <2> 意味着地址用两个 32 位整数表示,也就是 64 位地址。为什么?因为 vexpress-a9 的某些外设地址空间确实需要 64 位寻址。我在项目中遇到过有人图省事写成 1,结果高地址外设根本访问不到。
4.3 CPU 节点:告诉内核有几个核
接下来是 CPU 节点。这部分容易写错,我踩过坑。
CPU 节点要放在 cpus 子节点下,每个 CPU 核心一个子节点。对于双核 Cortex-A9,写法如下:
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a9";
reg = <0x0>;
clocks = <&cpu_clk>;
};
cpu@1 {
device_type = "cpu";
compatible = "arm,cortex-a9";
reg = <0x1>;
clocks = <&cpu_clk>;
};
};
注意几个细节:
device_type = "cpu"是必须的,这是设备树规范要求的reg的值对应 CPU 的 MPIDR 寄存器中的亲和性值clocks引用了一个时钟节点,我们后面会定义
我曾经犯过一个错误:把 #address-cells 写成 2,结果 reg 的值怎么都对不上。内核启动时只认第一个 CPU,第二个直接忽略。查了两天才发现是地址宽度的问题。
4.4 内存节点:简单但容易翻车
内存节点看起来简单,就几行代码:
memory@60000000 {
device_type = "memory";
reg = <0x0 0x60000000 0x0 0x08000000>;
};
这里 reg 的四个值分别是:地址高32位、地址低32位、长度高32位、长度低32位。因为根节点里 #address-cells = <2>,所以地址要拆成两个 32 位整数。
0x60000000 是起始地址,0x08000000 是 128MB 大小。你想想看,如果 QEMU 启动时指定了 256MB 内存,这里就得改成 0x10000000。
注意:内存节点的 device_type = "memory" 不能少。少了这行,内核不会把它当作内存,而是当成普通外设。我见过有人把内存地址写错,结果内核 panic,提示 "No memory device"。其实就是 device_type 丢了。
4.5 中断控制器:GIC 的配置
GIC 是 ARM 平台的中枢神经。vexpress-a9 用的是 GIC-400,它有两个地址空间:一个是分发器(distributor),一个是 CPU 接口(CPU interface)。
gic: interrupt-controller@2c001000 {
compatible = "arm,cortex-a9-gic";
#interrupt-cells = <3>;
interrupt-controller;
reg = <0x0 0x2c001000 0x0 0x1000>,
<0x0 0x2c002000 0x0 0x100>;
};
这里 #interrupt-cells = <3> 表示每个中断描述需要三个 32 位整数:中断类型(0=SPI,1=PPI)、中断号、触发方式(1=上升沿,4=高电平)。
嗯,这里要注意:GIC 的 reg 有两个区域。第一个是分发器,第二个是 CPU 接口。地址不能写反,否则中断完全收不到。我调试时遇到过中断不响应,用 QEMU 的 monitor 命令看 GIC 寄存器,发现地址根本不对。
4.6 串口节点:让内核能打印信息
串口是嵌入式开发者的好朋友。没有它,你连内核启动到哪了都不知道。vexpress-a9 用的是 PL011 UART:
uart0: serial@10009000 {
compatible = "arm,pl011", "arm,primecell";
reg = <0x0 0x10009000 0x0 0x1000>;
interrupts = <0 44 4>;
clocks = <&uart_clk &uart_clk>;
clock-names = "uartclk", "apb_pclk";
};
interrupts 的三个值:0 表示 SPI 中断,44 是中断号,4 是高电平触发。这个中断号怎么来的?我是查了 vexpress 的硬件手册。你如果自己写设备树,也得去翻芯片手册。
clocks 有两个时钟:一个是串口自己的工作时钟,一个是 APB 总线时钟。名字不能写错,驱动里是按名字找时钟的。
4.7 定时器节点:系统的心跳
SP804 双定时器,在 vexpress-a9 上用作系统定时器:
timer0: timer@10011000 {
compatible = "arm,sp804", "arm,primecell";
reg = <0x0 0x10011000 0x0 0x1000>;
interrupts = <0 46 4>;
clocks = <&timer_clk>;
clock-names = "apb_pclk";
};
这里中断号是 46。你可能会问:为什么不是 44 或 45?因为每个外设的中断号都是固定的,由硬件设计决定。写设备树时,这些数字必须跟硬件手册一一对应。
4.8 时钟节点:给外设提供心跳
前面我们引用了 cpu_clk、uart_clk、timer_clk,现在得把它们定义出来。在 vexpress-a9 上,时钟通常由系统控制模块提供:
clocks {
cpu_clk: cpu-clk {
compatible = "fixed-clock";
#clock-cells = <0>;
clock-frequency = <1000000000>; // 1GHz
};
uart_clk: uart-clk {
compatible = "fixed-clock";
#clock-cells = <0>;
clock-frequency = <24000000>; // 24MHz
};
timer_clk: timer-clk {
compatible = "fixed-clock";
#clock-cells = <0>;
clock-frequency = <1000000>; // 1MHz
};
};
这里用了 fixed-clock,意思是频率固定不变。实际硬件中,时钟可能来自 PLL,但为了简化,我们直接用固定频率。
经验之谈:时钟频率一定要写对。串口波特率就是靠这个频率算出来的。我见过有人把 24MHz 写成 24,结果串口输出全是乱码。调试时还以为是驱动问题,折腾了半天才发现是时钟频率少写了三个零。
4.9 完整设备树文件
好了,把上面所有节点拼起来,就是一个完整的设备树文件。我把它命名为 vexpress-a9.dts:
/dts-v1/;
/ {
model = "ARM Versatile Express";
compatible = "arm,vexpress-v2p-ca9", "arm,vexpress";
#address-cells = <2>;
#size-cells = <2>;
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a9";
reg = <0x0>;
clocks = <&cpu_clk>;
};
cpu@1 {
device_type = "cpu";
compatible = "arm,cortex-a9";
reg = <0x1>;
clocks = <&cpu_clk>;
};
};
memory@60000000 {
device_type = "memory";
reg = <0x0 0x60000000 0x0 0x08000000>;
};
gic: interrupt-controller@2c001000 {
compatible = "arm,cortex-a9-gic";
#interrupt-cells = <3>;
interrupt-controller;
reg = <0x0 0x2c001000 0x0 0x1000>,
<0x0 0x2c002000 0x0 0x100>;
};
uart0: serial@10009000 {
compatible = "arm,pl011", "arm,primecell";
reg = <0x0 0x10009000 0x0 0x1000>;
interrupts = <0 44 4>;
clocks = <&uart_clk &uart_clk>;
clock-names = "uartclk", "apb_pclk";
};
timer0: timer@10011000 {
compatible = "arm,sp804", "arm,primecell";
reg = <0x0 0x10011000 0x0 0x1000>;
interrupts = <0 46 4>;
clocks = <&timer_clk>;
clock-names = "apb_pclk";
};
clocks {
cpu_clk: cpu-clk {
compatible = "fixed-clock";
#clock-cells = <0>;
clock-frequency = <1000000000>;
};
uart_clk: uart-clk {
compatible = "fixed-clock";
#clock-cells = <0>;
clock-frequency = <24000000>;
};
timer_clk: timer-clk {
compatible = "fixed-clock";
#clock-cells = <0>;
clock-frequency = <1000000>;
};
};
};
4.10 编译与验证
写完了,怎么验证对不对?用设备树编译器 dtc:
dtc -I dts -O dtb -o vexpress-a9.dtb vexpress-a9.dts
如果没有报错,会生成 vexpress-a9.dtb。然后你可以用 QEMU 启动内核:
qemu-system-arm -M vexpress-a9 -kernel zImage -dtb vexpress-a9.dtb -nographic -append "console=ttyAMA0"
如果一切正常,你会看到内核启动日志,最后出现一个 shell 提示符。那一刻,你会觉得之前所有的调试都值了。
验证要点:
- 内核能识别双核 CPU(dmesg 中看到 "Brought up 2 CPUs")
- 串口能正常输出(console=ttyAMA0)
- 定时器中断正常工作(系统时钟不卡死)
说实话,我第一次成功启动时,看到内核日志里出现 "CPU1: Booted secondary processor",心里还是挺激动的。虽然只是个虚拟平台,但那种从零到一的感觉,跟真实硬件没什么两样。
好了,这一章的内容就到这里。设备树写起来不难,难的是每个细节都准确。你只要多写几次,自然就熟练了。
公众号:蓝海资料掘金营,微信deep3321