ARM64启动机制的进化之路:从ATAGS到设备树的彻底重构
在嵌入式系统的世界里,每一次芯片上电都是一次“创世”——内核从零开始重建整个软件宇宙。而这场重启仪式的核心,就是如何让操作系统“认识”自己运行在哪块硬件上。
你有没有想过,为什么现代ARM开发板不再需要为每一块电路板单独编译内核?为什么树莓派4B和NVIDIA Jetson Xavier可以共用同一个Linux镜像?这一切的背后,其实藏着一个鲜为人知但至关重要的技术革命: 设备树(Device Tree)取代ATAGS 。
这不仅仅是一个参数传递方式的变更,而是一场关于“硬件描述权”的根本性转移。它改变了我们编写驱动、调试板卡、发布固件的方式。今天,我们就来深入这场变革的底层逻辑,看看ARM64架构是如何通过一次优雅的设计跃迁,彻底解决困扰嵌入式行业二十年的老问题。
一、ATAGS的时代:简单却脆弱的标签链
回到2000年代初,那时的SoC还很“单纯”。一块三星S3C2410芯片配上32MB内存、一个串口、一张NAND闪存,就能撑起一台PDA或工控终端。在这种环境下,一种名为 ATAGS(Attribute Tags) 的机制应运而生。
它的设计哲学非常朴素: 用一段连续的内存块,装下所有硬件信息 。Bootloader像写简历一样,把CPU型号、内存大小、命令行参数一个个填进去,然后告诉内核:“嘿,你的配置在这里。”
📦 ATAGS长什么样?
想象一下,你在纸上列清单:
[开始]
→ 内存:从0x30000000开始,32MB
→ 命令行:console=ttySAC0,115200 root=/dev/mtdblock2
→ 序列号:123456789ABCDEF
[结束]
这就是ATAGS的本质——一个由
struct tag
组成的线性链表,每个tag都有类型和长度字段。内核启动时只需遍历这个列表,提取关键信息即可。
struct tag_header {
u32 size; // 单位是word(4字节)
u32 tag; // 标签类型,如ATAG_MEM=0x54410002
};
struct tag {
struct tag_header hdr;
union {
struct tag_core core;
struct tag_mem32 mem;
struct tag_cmdline cmdline;
// ... 其他类型
} u;
};
典型的内存布局如下图所示(虽然不能画图,但我们可以在脑中构建):
- 起始地址
0x80000100
- 第一个tag是
ATAG_CORE
,表示结构体开始
- 接着是
ATAG_MEM
描述内存区域
- 然后是
ATAG_CMDLINE
传入启动参数
- 最后以
ATAG_NONE
结束
整个结构必须对齐到4字节边界,总长度不超过16KB。看起来简洁明了,不是吗?
⚙️ Bootloader怎么构造它?
以U-Boot为例,开发者通常会写一段类似这样的代码:
void setup_atags(void) {
struct tag *t = (struct tag *)0x80000100;
// 设置核心信息
t->hdr.tag = ATAG_CORE;
t->hdr.size = tag_size(tag_core);
t->u.core.pagesize = 4096;
t = tag_next(t);
// 添加内存
t->hdr.tag = ATAG_MEM;
t->hdr.size = tag_size(tag_mem32);
t->u.mem.start = 0x80000000;
t->u.mem.size = 0x20000000; /* 512MB */
t = tag_next(t);
// 设置命令行
t->hdr.tag = ATAG_CMDLINE;
strcpy(t->u.cmdline.cmdline, "console=ttyAMA0,115200");
t->hdr.size = (strlen(t->u.cmdline.cmdline) + 1 + 3) / 4 + 1;
t = tag_next(t);
t->hdr.tag = ATAG_NONE;
t->hdr.size = 0;
}
这段代码的问题在哪?
👉 它完全
硬编码
!一旦换了主板、改了内存容量、加了个I2C触摸屏……你就得重新编译Bootloader!
更糟的是,如果厂商想添加自定义设备支持(比如LCD面板),只能自己定义私有tag值,比如
0x54410010
。不同厂家可能用了相同的数值代表完全不同含义,这就埋下了兼容性地雷。
❌ 实际使用中的三大痛点
| 外设类型 | 是否支持 | 维护难度 |
|---|---|---|
| UART串口 | ✅ 是 | 差 |
| LCD显示 | ⚠️ 部分(私有tag) | 极差 |
| I2C设备 | ❌ 否 | 不可维护 |
| SPI Flash | ❌ 否 | 不可维护 |
| GPIO配置 | ❌ 否 | 完全不行 |
你会发现,ATAGS只适合最基础的信息传递。对于复杂的外设拓扑,它无能为力。没有层次结构、无法表达依赖关系、也不能动态扩展。
二、为什么ATAGS必须被淘汰?
进入ARM64时代后,SoC变得越来越“全能”。一颗芯片内部集成了双核A78 + 四核A55、GICv3中断控制器、多个电源域、十几条I2C/SPI总线、PCIe接口……这时候再用ATAGS去描述这些硬件,简直就像用算盘处理大数据。
🔧 痛点1:灵活性为零
假设你是一家厂商,同一款SoC用于三种产品:
- 智能门锁:带指纹模块 + 蓝牙
- 工业网关:多路RS485 + CAN总线
- 医疗设备:高精度ADC + 实时时钟
按照ATAGS模式,你得准备三套不同的Bootloader镜像 + 三套内核补丁。每次更新都要测试三次,发布流程复杂到令人发指。
而设备树只需要三个
.dtb
文件,内核镜像完全通用。换一句话说:
一次编译,处处部署
。
🔀 痛点2:扩展成本极高
你想加个温控传感器?好啊,请按以下步骤操作:
1. 定义新的tag ID;
2. 修改Bootloader源码;
3. 修改内核添加解析函数;
4. 更新文档并通知上下游团队。
这个过程动辄数周,严重拖慢研发节奏。
而在设备树中呢?只需写几行DTS:
i2c1: i2c@1c234000 {
temperature-sensor@48 {
compatible = "ti,tmp102";
reg = <0x48>;
};
};
重新编译DTB,搞定。全程不碰固件也不改内核。
💸 痛点3:维护地狱
Linus Torvalds 曾公开批评ARM32时代的板级代码是“垃圾合并”(crap merge)。确实,在
arch/arm/mach-*
目录下,你能看到成百上千个几乎重复的初始化文件,仅仅因为ATAGS无法承载足够的硬件信息,导致大量配置被迫固化进内核。
ARM64吸取教训,直接规定:
所有平台必须使用设备树,禁止新增板级初始化代码
。从此,
arch/arm64/kernel/
下的代码变得异常干净,差异全部下沉到DTB中管理。
三、设备树登场:一种全新的硬件描述语言
如果说ATAGS是“简历”,那设备树就是一份完整的“工程图纸”。
它采用树状结构精确刻画系统的物理布局:
/
├── cpus
│ ├── cpu@0
│ └── cpu@1
├── memory@80000000
├── soc
│ ├── timer@2c00000
│ ├── uart@7e201000
│ └── i2c@7e804000
│ └── eeprom@50
└── chosen
└── bootargs
这种层级化表达能力,使得我们可以清晰地描述“哪个设备接在哪条总线上”、“谁依赖谁的时钟”、“中断如何路由”等复杂关系。
🛠️ DTS → DTB:文本到二进制的蜕变
设备树开发围绕三个核心元素展开:
| 名称 | 类型 | 作用 |
|---|---|---|
| DTS | 文本文件 |
人类可读的源码,
.dts
结尾
|
| DTC | 编译器 | 将DTS转为DTB |
| DTB | 二进制Blob | 运行时加载到内存 |
一个典型的DTS片段如下:
/dts-v1/;
#include "skeleton.dtsi"
/ {
model = "My ARM64 Board";
compatible = "vendor,myboard", "simple-bus";
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a53";
reg = <0x0>;
clock-frequency = <1800000000>;
};
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x80000000>; /* 2GB */
};
};
通过命令行编译:
dtc -I dts -O dtb -o board.dtb board.dts
生成的DTB包含四个部分:
1.
Header
:魔数、总大小、结构偏移等元信息
2.
Structure Block
:节点与属性的扁平化存储
3.
Strings Block
:属性名称字符串池
4.
Memory Reservation Map
:保留内存区域(如CRAMFS位置)
有趣的是,DTB是可以反向还原的!你可以用:
dtc -I dtb -O dts -o recovered.dts board.dtb
这对调试和逆向分析极为有用。
四、设备树的灵魂:compatible 字符串匹配机制
如果说设备树有一个“心脏”,那就是
compatible
属性。
它是驱动与设备之间的“婚介所”——决定哪个驱动能绑定到哪个设备。
看这个例子:
i2c@7e804000 {
compatible = "brcm,bcm2835-i2c", "snps,designware-i2c";
reg = <0x7e804000 0x1000>;
interrupts = <0x5c>;
};
当内核扫描到该节点时,会依次尝试匹配:
1. 先找叫
brcm,bcm2835-i2c
的驱动;
2. 找不到就退而求其次,匹配
snps,designware-i2c
。
这就实现了“特化 → 通用”的匹配策略。既能让特定厂商做优化,又能利用成熟的通用驱动降低开发成本。
💡 小知识:所有合法的
compatible字符串都必须在Documentation/devicetree/bindings/中注册,否则会被视为非法设备。
五、ARM64启动全流程拆解:设备树如何接管系统
现在让我们走进ARM64开机的第一秒,看看设备树在整个启动流程中扮演的角色。
🚀 第一步:U-Boot加载DTB
主流Bootloader如U-Boot早已原生支持设备树。典型启动脚本如下:
setenv bootargs console=ttyAMA0,115200 root=/dev/mmcblk0p2 rw
load mmc 0:1 $kernel_addr_r Image
load mmc 0:1 $fdt_addr_r board.dtb
booti $kernel_addr_r - $fdt_addr_r
其中
$fdt_addr_r
是DTB加载地址(建议避开内核解压区,如
0x87f00000
)。
booti
命令会验证DTB魔数(
0xd00dfeed
),确认无误后将地址存入
x0
寄存器并跳转至内核入口。
值得一提的是,U-Boot甚至允许运行时修改DTB:
=> fdt addr $fdt_addr_r
=> fdt set /memory/reg <0x80000000 0x40000000>
这条命令可以直接更改内存声明,无需重启!这是ATAGS完全做不到的动态能力。
🔍 第二步:内核早期验证DTB
内核第一条有效指令位于
arch/arm64/kernel/head.S
的
_text
入口。此时仍是汇编环境,但很快就会调用:
int __init early_init_dt_verify(void *params)
{
if (!params)
return -EINVAL;
if (be32_to_cpu(((struct fdt_header *)params)->magic) != FDT_MAGIC)
return -ENODEV;
initial_boot_params = params;
return 0;
}
只有魔数正确才会继续执行。否则打印错误日志,可能尝试fallback路径(如有CONFIG_ARM64_ATAGS_COMPAT启用)。
🌲 第三步:展开设备树为运行时结构
接下来调用
unflatten_device_tree()
,将扁平化的DTB转换为内核可用的
struct device_node
链表:
void __init unflatten_device_tree(void)
{
__unflatten_device_tree(initial_boot_params, NULL,
&of_root,
early_init_dt_alloc_memory_arch,
false);
}
生成的
of_root
成为全局根节点,后续所有设备探测都将基于此树进行。
🔎 第四步:early_init_dt_scan 扫描关键节点
在完全展开前,内核已通过
of_scan_flat_dt
快速提取必要信息:
| 函数 | 功能 |
|---|---|
early_init_dt_scan_memory()
| 解析内存节点,加入memblock |
early_init_dt_scan_chosen()
| 获取bootargs作为命令行 |
early_init_dt_scan_cpus()
| 枚举CPU数量与MPIDR |
early_init_dt_scan_root()
| 提取#address-cells等元数据 |
例如内存扫描逻辑:
int __init early_init_dt_scan_memory(unsigned long node, const char *uname,
int depth, void *data)
{
if (depth == 1 && !strcmp(uname, "memory")) {
const __be32 *reg;
int len;
reg = of_get_flat_dt_prop(node, "reg", &len);
if (reg == NULL) return 0;
early_init_dt_add_memory_arch(
be64_to_cpup((__be64*)reg),
be64_to_cpup((__be64*)(reg + 2)));
}
return 0;
}
注意这里用的是
of_get_flat_dt_prop
—— 直接从原始DTB块中读取,避免依赖尚未建立的完整结构。这是一种巧妙的“懒加载”优化。
直到
device_tree_init()
完成,完整的
of_allnodes
链表才正式对外暴露。
六、实战技巧:写出高质量的设备树
好的设备树不只是语法正确,更要具备可维护性、可移植性和健壮性。以下是工程师必须掌握的最佳实践。
✅ CPU、内存、中断控制器的标准写法
CPU节点
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
device_type = "cpu";
compatible = "arm,cortex-a72";
reg = <0x0>;
enable-method = "psci";
};
cpu@1 {
device_type = "cpu";
compatible = "arm,cortex-a72";
reg = <0x1>;
enable-method = "psci";
};
};
⚠️ 注意:
reg
值应与
MPIDR_EL1
中CPU ID一致。
内存节点
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x80000000>; /* 2GB */
};
GICv3中断控制器
intc: interrupt-controller@2f00000 {
compatible = "arm,gic-v3";
reg = <0x0 0x2f00000 0x0 0x10000>,
<0x0 0x2f10000 0x0 0x10000>;
interrupt-controller;
#interrupt-cells = <3>;
ranges;
};
#interrupt-cells = <3>
表示每个中断描述符占三个u32:类型、编号、触发方式。
🔗 使用phandle处理设备间引用
phandle是设备树中实现“对象引用”的关键机制。通过label和
<&>
语法,避免硬编码地址。
clk: clock@100 {
#clock-cells = <0>;
compatible = "fixed-clock";
clock-frequency = <24000000>;
};
uart0: serial@9000000 {
compatible = "ns16550a";
reg = <0x9000000 0x1000>;
interrupts = <0x30>;
clocks = <&clk>;
status = "okay";
};
内核可通过
of_clk_get()
自动解析
clocks = <&clk>
并完成时钟使能。即使Clock节点地址变更,链接依然有效。
| 技巧 | 说明 |
|---|---|
| 使用label而非绝对地址 | 增强可移植性 |
| 避免重复定义相同设备 | 复用已有节点 |
设置
status = "disabled"
临时关闭设备
| 调试利器 |
利用
__overlay__
支持运行时动态加载
| 适用于FPGA或热插拔 |
七、迁移策略与性能优化:平稳过渡的艺术
尽管ARM64强制要求设备树,但在实际项目中,我们仍可能遇到旧平台迁移的需求。
🔄 过渡方案:CONFIG_ARM64_ATAGS_COMPAT
Linux提供了兼容选项
CONFIG_ARM64_ATAGS_COMPAT
,允许内核解析ATAGS信息块:
// arch/arm64/kernel/atags_compat.c
static int __init parse_atags(struct setup_data *setup)
{
struct tag *tags = (struct tag *)phys_to_virt(setup->data);
if (tags->hdr.tag != ATAG_CORE)
return -EINVAL;
pr_info("ATAGS compatibility mode enabled\n");
save_atags(tags);
arm64_boot_args[1] = (u64)tags;
return 0;
}
但这只是临时手段!常见陷阱包括:
- ATAGS未正确终止(缺少ATAG_NONE)
- 物理地址转换失败(页表未建立)
- 与DTB参数冲突导致资源重复注册
建议尽快完成DTS迁移,并通过串口日志监控
[ATAGS]
提示信息。
⚡ 性能调优:让启动更快一点
随着设备树规模增长,其解析时间不容忽视。以下是一些实战优化技巧:
1. 减小DTB体积
- 删除未焊接外设的节点
- 合并冗余属性
-
使用
/delete-node/清理通用dtsi中的无用部分
Makefile优化示例:
quiet_cmd_dtb_slim = DTB $@
cmd_dtb_slim = $(CPP) -nostdinc -x assembler-with-cpp \
-I$(srctree)/arch/$(SRCARCH)/boot/dts \
$(DTC_FLAGS) -o $@.tmp $< && \
$(DTC) -O dtb -o $@ $@.tmp && \
rm $@.tmp
2. 调整节点顺序提升early init效率
确保关键节点靠前:
/ {
chosen {
bootargs = "console=ttyAMA0,115200";
};
memory@0 {
device_type = "memory";
reg = <0x0 0x80000000>;
};
cpus { /* ... */ };
soc { /* ... */ };
};
这样能让
early_init_dt_scan_*()
更快命中目标。
3. 关键寄存器提前映射
对于GIC、UART等高频访问模块,可在设备树中标记
reg
,配合
early_ioremap
或静态映射减少TLB压力。
| 设备类型 | 是否建议静态映射 | 映射大小 |
|---|---|---|
| GIC中断控制器 | ✅ 是 | 4KB–64KB |
| UART调试串口 | ✅ 是 | 4KB |
| DMA控制器 | ⚠️ 视情况 | 8KB |
| GPIO模块 | ❌ 否 | 动态映射 |
八、总结:一场静默的技术革命
从ATAGS到设备树,看似只是参数传递方式的改变,实则是嵌入式系统设计理念的一次重大跃迁。
| 对比维度 | ATAGS | Device Tree |
|---|---|---|
| 数据结构 | 线性链表 | 树形拓扑 |
| 可扩展性 | 极差 | 极强 |
| 是否需改内核 | 是 | 否 |
| 支持动态更新 | 否 | 是(overlay) |
| 多平台共用内核 | ❌ 不可能 | ✅ 完美支持 |
| 调试便利性 | 差 | 好(可反编译) |
设备树不仅解决了历史难题,更为未来留下了足够空间。如今,Yocto Project、Android Treble、OpenWrt等主流嵌入式框架都深度依赖设备树实现硬件抽象。就连RISC-V架构也直接采用了这一模型,足见其普适价值。
当你下次按下开发板电源键时,不妨想想:就在那一瞬间,一个精心构造的设备树正在默默引导内核,唤醒整个数字世界。而这背后,是一群工程师用二十年时间打磨出的智慧结晶。🛠️💡
“真正的技术进步,往往藏在看不见的地方。”
—— 致敬每一位深耕底层的系统开发者 🙇♂️
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
969

被折叠的 条评论
为什么被折叠?



