第四章:设备树实战——为一个虚拟 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 的源码才发现问题。

下面这张图,是我画的一个简化版硬件拓扑。你看一眼,心里就有谱了。

vexpress-a9 设备树硬件拓扑 CPU 集群 Cortex-A9 核心 0 Cortex-A9 核心 1 L2 Cache (PL310) GIC-400 中断分发器 CPU 接口 内存 DDR RAM 0x60000000 外设总线 (APB/AHB) PL011 UART 0x10009000 SP804 定时器 0x10011000 系统控制 0x10000000 其他外设 IRQ

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_clkuart_clktimer_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