1. 先理清背景:为什么会有“ACPI模拟DTS”这种奇怪需求
在ARM64平台上摸爬滚打久了,你早晚会遇到这种需求:某套固件是按UEFI+ACPI标准做的,但你要在上面跑的一个驱动、一个中间件、甚至整个BSP,只认设备树DTS。两边给的硬件描述完全不同,系统起不来,驱动不认识外设,全靠手工去查寄存器抄地址,效率低到崩溃。
这种局面并不冷门。ARM64服务器领域,ACPI是标准化启动路径,而很多SoC外设IP原本都在嵌入式设备树生态里开发,Linux内核里的驱动大量用of_match_table、of_property_read_xx这类接口读取硬件信息。到了ACPI环境,这些驱动要么跑不起来,要么只能看到残缺资源。于是“ACPI来模拟DTS”就成了一个绕不过去的话题。
先明确一个概念:模拟DTS不是要把DTS文件直接塞给内核。真正要做的是让ACPI表给出的硬件信息,在内核里以类似设备树的方式被解析、被匹配、被绑定,让那些只写了DT支持的驱动能正常工作。内核里已经有一套官方铺垫好的机制,关键是你要会用它。
我在实际项目里踩过一遍完整的坑,走了不少弯路,这里把整个思路、映射逻辑、可落地的操作流程都整理出来。无论你是在固件侧工作,还是在跑OS适配,这篇文章应该都能帮你省下几天排查时间。
1.1 ACPI和DTS到底在描述什么
要理解“模拟”,先得看这两套东西干了什么活。
设备树DTS本质是一个描述硬件拓扑的树形结构文本,经过dtc编译成二进制DTB,由引导程序传给内核。内核解析后建立device_node树,驱动通过of_xxx接口去读节点属性。DTS的表达非常自由,节点名、属性名、属性结构都可以根据硬件实际情况灵活定义,配合各种binding文档,社区生态非常成熟。
ACPI则是另一套逻辑。它以固定格式的表(FADT、DSDT、SSDT、MADT等)存放在固件里,OS在启动时解析ACPI命名空间,得到的是被定义好的设备对象、资源描述、电源管理方法。ACPI更强调规范和平台无关,尤其在中断、电源管理这些领域有非常严格的资源模型。
两套东西的差异,说白了就是:设备树像一份手写的硬件说明文档,格式灵活,谁都能加页;ACPI像一份标准化录入的数据库,字段结构固定,校验严格。你要把一份手写文档翻译成数据库条目,自然不能照抄文本,得对着各自的语义逐个对应。
1.2 典型触发场景:迁移服务器平台时遇到的两套世界观
最常见的场景之一,某公司要做ARM64服务器,固件按照行业标准从DTS切换到ACPI。内核里绝大多数驱动都是社区贡献的,很多外设驱动作者在开发时就只适配了设备树。固件切换之后,网卡、DMA控制器、I2C控制器,一个个在ACPI路径下消失,甚至有时候连设备名都找不到。
另一个场景在虚拟化和模拟器方向也常见。某开发者在QEMU里验证一个新的ACPI固件特性,但手头的客户机内核模块是从设备树时代一直维护下来的,只认DT节点。你不想为测试重写驱动,更不想整个平台切回DT启动方式,那就只能让ACPI环境尽量“伪装”成DTS环境。
还有一类更隐蔽的需求:部分BSP开发工具链、诊断脚本、自动化测试框架默认读取/proc/device-tree或者/sys/firmware/devicetree目录。ACPI启动的内核根本没有这些目录。要让老工具继续工作,也需要在ACPI命名空间里提供等价的设备信息,并让内核生成对应的of_node。
我自己的实际体会是,这类需求本质上不是“把ACPI改成DTS”,而是“让内核在ACPI启动路径下仍然能创建设备树风格的节点,并完成驱动匹配”。顺着这个思路去找解决方案,路其实很清晰。
1.3 这件事的本质与边界
ACPI模拟DTS,核心落在“桥接”两个字。Linux内核里早就提供了PRP0001机制,这是官方钦定的用于把设备树兼容性引入ACPI的方案。当你把一个ACPI设备的_HID设为“PRP0001”,并在_DSD里面提供compatible属性,内核就会为这个设备创建of_node,让设备树驱动的匹配逻辑能够生效。
所以,我们真正要做的翻译工作主要有三块:
- 设备身份:_HID + _DSD里的compatible 对应DTS里的compatible
- 资源描述:_CRS 对应 DTS里的reg、interrupts
- 自定义属性:_DSD键值对 对应 DTS里各种自定义属性
把这三块处理好,绝大多数设备树外设驱动都能在ACPI环境下重新活过来。这个方案也是我在项目里最终落地选用的,后面会讲完整实操。
2. 原理先行:ACPI要模拟DTS,核心靠哪三板斧
实操之前一定要先搞懂映射原理,不然写出来的ASL全是在碰运气。这一类问题排错太痛苦了,因为错误不一定在编译时报出来,更多是运行期静默失效,你连到底是表结构坏了还是驱动匹配失败都分不清。
2.1 设备识别:PRP0001与compatible之间的桥
设备树里,驱动靠compatible属性来找设备。比如“vendor,xdma”这个字符串,在一个有of_match_table的驱动里,只要匹配上,probe就会执行。
ACPI体系里,设备身份靠_HID来定。但_HID要求注册过的PNP ID或者ACPI ID,一般的厂商自定义设备并没有这类ID。PRP0001就是专门解决这个问题的通用HID,它告诉内核:这个ACPI设备的真实身份要看_DSD里的compatible属性。
内核扫描到_HID为“PRP0001”的设备时,会在_DSD里查找“compatible”这个键。如果存在,就以该字符串创建一个虚拟的of_node,并把所有_DSD里的属性挂到这个of_node上。后续设备驱动用of_device_is_compatible、of_property_read_xx这些接口时,实际上读的就是这个虚拟of_node的信息。
这里有个容易踩的坑:compatible字符串必须要符合设备树bindng里的大小写要求,并且格式一般是“厂商名,设备型号”,全部小写。写成“XDMA”之类大写的,内核比对时区分大小写,直接匹配不上。
2.2 资源映射:_CRS如何翻译reg和interrupts
DTS里描述外设资源比ACPI自由得多。reg描述地址范围,interrupts描述中断,dmas描述DMA通道,clocks描述时钟。ACPI里标准的资源描述都在_CRS方法返回值里,主要是Memory32Fixed和Interrupt这些资源宏。
地址翻译最简单。DTS里的reg如果只有一个地址段,两边几乎能逐一对应。你要注意_DMA和cacheable这些标志位,如果硬件是正常内存映射设备,用Memory32Fixed (ReadWrite, start, length)基本没啥问题。
中断翻译是坑最多的地方。ACPI里的Interrupt (ResourceConsumer, Level, ActiveHigh, Exclusive) 这些标志和DTS里的interrupts属性并不是完全一致的映射。DTS通常是三个字段:中断域、中断号、触发类型,其中触发类型用IRQ_TYPE_LEVEL_HIGH这种宏。ACPI资源模板里的Level/Edge、ActiveHigh/ActiveLow是分开的两个字段,顺序和含义要格外小心。
比如你知道设备树里写的IRQ_TYPE_LEVEL_HIGH是电平触发、高有效,那到了ACPI里就是Level, ActiveHigh。如果写反成ActiveLow,驱动虽然能注册,但中断触发时内核不认,表现就是设备“看起来正常,但一干活就卡死”。
2.3 DT特有属性:_DSD的完整用法
设备树驱动的很多行为依赖自定义属性,比如时钟名、DMA通道数、读写模式、内部寄存器配置。这类信息在ACPI里没有标准资源描述方式,只能靠_DSD来承载。
_DSD的全称是Device Specific Data,格式要求很严格。外层必须包含特定UUID,然后跟一个包,里面是若干键值对子包。一个标准示例:
Name (_DSD, Package () { ToUUID ("daffd814-6eba-4d8c-8a91-bc9bbf4aa301"), Package () { Package () { "compatible", "vendor,xdma" }, Package () { "dma-channels", 8 }, Package () { "status", "okay" }, } })注意下面几点,都是我自己踩过的坑:
- UUID是固定的“daffd814-6eba-4d8c-8a91-bc9bbf4aa301”,错一个字符IASL不一定报错,但运行时内核不会解析出任何属性。
- 键名与DTS的属性名要保持完全一致,包括大小写。驱动里读的是dma-channels,你写成DMA-Channels,读出来就是空。
- 字符串值外面要加双引号,整数直接写。布尔属性在DTS里只需要节点存在,在_DSD里对应整数值1或0。
- 同一个_DSD包里可以同时放compatible和普通属性,内核会一并处理。
如果驱动的自定义属性特别多,_DSD包会非常大,但别偷懒省略,因为很多驱动在probe里会逐个检查属性,缺一个可能整个设备初始化失败。
2.4 三种实现路线对比与选型
“ACPI模拟DTS”严格说不止一种实现路线。我在前期调研时列出过三个方案,最终选了第三个:
| 实现方案 | 做法 | 优势 | 劣势 |
|---|---|---|---|
| 引导阶段生成DTB | 在bootloader里扫描ACPI表,动态构建DTB传给内核 | 物理层面完全等价于DTS环境 | 偏离ACPI标准启动路径,在服务器平台基本不可行 |
| 用户空间转换工具 | 用脚本解析ACPI表输出DTS文本 | 适合离线分析、文档归档 | 不能解决运行期驱动绑定问题 |
| ACPI PRP0001机制 | 在ACPI表中用_DSD描述compatible和自定义属性,让内核创建of_node | 符合ACPI规范,内核原生支持,改动集中在表侧 | 需要深入理解ACPI ASL和内核匹配机制,门槛稍高 |
从工程落地角度,第三种方案是最可靠也最可持续的,这也是本文后面实操部分采用的方式。
3. 实操全流程:把一个DTS外设“翻译”成ACPI表
这一节我用一个虚构的DMA控制器外设来演示完整过程。硬件描述全部是模拟的,但流程和我实际项目里跑通的完全一致,你只要把设备名、寄存器地址、中断号替换成自己的就可以直接参考。
3.1 准备环境与工具
首先准备一套基础环境。最方便的是在QEMU的ARM64 virt机器上搭建,既可以用UEFI固件模拟ACPI启动,也能快速验证表加载。工具方面,必须装好ACPICA工具集,核心是iasl编译器,用来把ASL源文件编译成AML二进制表。
在常见发行版里可以直接装:
# Debian/Ubuntu apt install acpica-tools # 验证 iasl -v acpidump -s如果能正常打印版本号和ACPI表状态摘要,环境就OK了。还要确认内核编译时打开了ACPI和OF相关配置,至少需要CONFIG_ACPI、CONFIG_OF、CONFIG_ACPI_CONFIGFS这几个。内核开发者可以直接在内核配置菜单里搜PRP0001相关的选项确认。
3.2 从原始DTS提取设备信息
假设原来设备树里有这样一个节点:
xdma0: dma@40400000 { compatible = "vendor,xdma"; reg = <0x0 0x40400000 0x0 0x10000>; interrupts = <GIC_SPI 44 IRQ_TYPE_LEVEL_HIGH>; dma-channels = <8>; };这个节点描述的信息很明确:设备在0x40400000,长度0x10000,使用SPI中断44号,高电平触发,DMA通道数为8。
把这些信息提取出来之后,要决定它们各自映射到ACPI的哪个位置:
| DTS信息 | 值 | ACPI映射目标 |
|---|---|---|
| compatible | vendor,xdma | _HID=PRP0001 + _DSD里的compatible |
| reg | 0x40400000, 0x10000 | _CRS里的Memory32Fixed |
| interrupts | SPI 44 LEVEL_HIGH | _CRS里的Interrupt资源 |
| dma-channels | 8 | _DSD键值对 |
这里有一个重要的预处理:确认中断号。GIC_SPI在ACPI里通常直接用MADT提供的全局中断号表示,很多平台SPI编号和ACPI全局中断号是一致的,但还是要对照MADT表确认,不要想当然。N(私有外设中断)的情况更特殊,通常不建议直接用PRP0001模拟,尤其在多核场景下容易出问题。
3.3 编写并编译SSDT表
下面就是核心文件,一个完整的SSDT表,把刚才的DMA设备搬进ACPI命名空间。为了演示方便,我把这个设备挂在根总线_SB下面。
DefinitionBlock ("ssdt-xdma.aml", "SSDT", 2, "MYOEM", "XDMA", 0x00000001) { Scope (\_SB) { Device (XDMA) { Name (_HID, "PRP0001") Name (_UID, 0x0) Name (_CRS, ResourceTemplate () { Memory32Fixed (ReadWrite, 0x40400000, 0x10000) Interrupt (ResourceConsumer, Level, ActiveHigh, Exclusive) { 44 } }) Name (_DSD, Package () { ToUUID ("daffd814-6eba-4d8c-8a91-bc9bbf4aa301"), Package () { Package () { "compatible", "vendor,xdma" }, Package () { "dma-channels", 8 }, } }) } } }编译过程非常简单:
iasl -sa ssdt-xdma.asl如果ASL语法有错误,iasl会明确报错并给出行号。我建议先解决所有编译错误再往系统里加载。有一次我因为Device节点名XDMA与系统里已有的某个设备对象重名,编译没报错但加载后被忽略,排查了很久,所以节点名尽量选独特一些。
3.4 把SSDT放进固件/加载到系统
如果环境支持固件内置SSDT,直接把这个aml文件加到固件的ACPI表区域即可。但开发调试阶段,频繁重编固件显然不现实,用Linux内核的configfs接口动态加载表更高效。
前提是内核开启CONFIG_ACPI_CONFIGFS,加载步骤如下:
mount -t configfs configfs /sys/kernel/config mkdir /sys/kernel/config/acpi/table/xdma cat ssdt-xdma.aml > /sys/kernel/config/acpi/table/xdma/aml这个目录创建并写入aml文件后,内核会立即把表挂到ACPI命名空间,触发设备扫描。你可以用dmesg观察有没有“ACPI: Added SSDT”之类的日志。
如果configfs不可用,还有另一个办法:直接把aml放到initramfs里,通过内核启动参数acpi_override_table或自定义开机脚本加载。但该方法需要对应机制支持,不如configfs通用。
3.5 内核驱动适配与绑定验证
表加载之后,内核里应该会出现一个新的ACPI设备。首先做一轮快速验证:
# 查看ACPI设备 ls /sys/bus/acpi/devices/ dmesg | grep -i PRP0001 # 查看生成节点 ls /sys/bus/platform/devices/ cat /sys/bus/platform/devices/*xdma*/modalias正常情况下,能看到类似“PRP0001:00”的ACPI设备,并且可能注册成platform设备。关键是确认设备绑定了哪个驱动,看dmesg里有没有驱动probe成功的输出。
如果驱动没有自动绑定,先手动验证匹配关系。大多数DT驱动的of_match_table是直接可见的,在/sys/bus/platform/devices/对应设备目录下看driver_override或者用modprobe强制加载,但这只是排查手段,不建议作为长期方案。
一个比较典型的成功日志如下:
ACPI: Added ACPI_DEVICE XDMA ACPI: bus type PRP0001 registered platform PRP0001:00: Firmware registered XDMA xdma 40400000.dma: new DMA controller registered这里第一行是ACPI扫描设备,中间的platform节点绑定,最后是驱动probe完成后打印的日志。走到最后一步,说明“ACPI模拟DTS”这条路已经通畅了。
4. 常见问题与排查技巧实录
这个方案说起来原理不复杂,但实际运行时出问题的点非常多。我根据自己排障经历,把遇到过的典型问题整理成速查表,并附上排查思路。
4.1 表加载了,设备却不probe
这是最容易碰见的头号问题。表加载成功,ACPI设备也出现了,但驱动死活不probe。首先要确认一件事:设备到底有没有被识别为platform设备,以及驱动有没有机会匹配。
依次检查:
- dmesg里有没有“ACPI: Added ACPI_DEVICE”日志,没有则说明表虽然加载,但命名空间没被穷举,检查当前内核ACPI扫描是否默认跳过该设备。
- /sys/bus/acpi/devices/下是否存在PRP0001:xx,如果存在,看uevent里的MODALIAS。
- 查看设备目录下的driver_override,如果之前手动设置过,会永久覆盖匹配,删除它。
- 确认驱动模块加载了,并且CONFIG_ANDROID之类?不是,这里重点是确认驱动的of_match_table导出时,module alias里包含vendor,xdma。
另外一个非常坑的点:驱动用of_device_is_compatible去检查设备,而of_node虽然创建了,但ACPI生成的of_node的compatible属性不是_DSD里的compatible字符串,而是“PRP0001:00”这种。这种情况少见,但一旦发生,光是读of_node属性就会失败。
遇到这种问题,我建议用perf或者ftrace直接去看驱动probe里调用了哪些of_xxx接口,理性分析哪一个字节导致匹配失败,不要盲目改ASL。
| 检查项 | 命令/方法 | 预期结果 |
|---|---|---|
| 表加载 | dmesg | ACPI: Added SSDT |
| 设备枚举 | ls /sys/bus/acpi/devices/ | PRP0001:xx |
| 设备存在性 | ls /sys/bus/platform/devices/ | xdma设备节点 |
| 驱动绑定 | dmesg | probe成功 |
| of_node | cat /sys/kernel/debug/of_tree | 有xdma节点 |
4.2 中断、DMA资源对不上
设备probe成功但运行不稳定、中断不触发,大概率是_CRS里的资源描述语义不对。
DTS里中断号是相对GIC中断控制器的逻辑编号,而ACPI里的中断号要与固件MADT表保持一致。虽然很多平台SPI编号一致,但在涉及到中断重映射、虚拟化透传的情况下,两者可能偏差很大。所以,排查时一定先把MADT表dump出来,用中断控制器的全局中断号做借口,多一步验证。
触发方式同样容易错。DTS里IRQ_TYPE_LEVEL_HIGH,对应ACPI的Level, ActiveHigh;IRQ_TYPE_EDGE_RISING,对应Edge, ActiveHigh。这里要特别小心字段的顺序,ACPI的Interrupt宏里先是Level/Edge,然后是ActiveHigh/ActiveLow,和我见过的一些错误示例是反过来的。
如果你用设备树方式查过irq,可以对比/sys/irq相关目录或者用/proc/interrupts验证实际申请的中断号。如果实际申请的中断号和预期相差一个固定数,很可能是MADT里GIC重映射做了偏移。
4.3 时钟与GPIO层面掉链子
很多外设不只是地址和中断,还有时钟、GPIO、复位等依赖。ACPI对这些资源的标准描述远没有设备树那么灵活,尤其时钟框架,ACPI里没有标准的clk provider描述接口。
现实中,依赖简单的时钟通常在驱动初始化时通过clk_get_sys查一个固定的clkdev条目来获取,这种情况下只要在板级初始化里注册clkdev就能解决。如果驱动用of_clk_get从设备树节点里获取多个时钟,那在ACPI虚拟of_node下就完全失效了。这种情况下,我通常建议在驱动里加一个ACPI时钟获取的fallback分支,或者用clkdev别名绕过去,当然了,这要协调驱动维护方的接受度。
GPIO同样是重灾区。设备树用gpio-controller、gpio-cells等描述,ACPI这边标准API是_CRS里的GpioIo资源。如果驱动还是调of_xxx接口拿GPIO,建议同样在驱动里加ACPI分支,或者用一个小的补丁层把of请求转换为ACPI GPIO接口。
4.4 几个容易踩的隐藏坑
下面几个问题不一定是每个人都会遇到,但一旦遇到特别浪费时间,我在这里统一记录一下。
第一,_DSD里的compatible一定不能带大括号、空格这些特殊字符。ASL字符串里可以写,但内核解析出来后会去做字符串比较,一个多余空格都不行。建议直接照抄DTS里的原始compatible字符串。
第二,设备树里status属性如果写“disabled”,ACPI_DSD里也要对应处理,否则设备树驱动会照常probe这个本应禁用的硬件。在PRP0001机制下,status属性会映射到of_node的status,驱动如果检查这个属性,需要保持一致。
第三,One跟1的区别。ASL里很多整数常量在Table体里是字节、字、双字区分,但_DSD包里如果写成整数值,内核统一按整数解析,IASL有时候会把某些值优化成紧凑格式,不会影响功能。反而真正危险的是用Package包嵌套时括号漏掉,导致_DSD结构解析失败。
第四,加载SSDT时OEM ID不要留空。留空时有些固件/内核会自动填默认值,但跨平台兼容性很差,尤其在双固件环境下经常出现表被忽略的情况。给自己起一个简短的OEM ID和表名,例如“MYOEM”“XDMA”,能省不少麻烦。
5. 写在最后:几条实在的心得
做完这个“ACPI模拟DTS”的适配,我最大的感触是:这类问题不是纯技术问题,而是一个沟通问题。固件侧、内核侧、驱动侧、平台验证侧,各自有各自的语义模型,你要做的不是强迫某一方改变,而是把差异翻译清楚,在合适的层面做桥接。
如果以后再遇到这种“描述风格迁移”的活,我一般会先画一张映射表,明确每个DTS属性跑到ACPI哪里去,而不是急着写ASL。映射表画清楚了,后面所有工作都只是翻译。流程跑起来后,验证一定不能只看设备枚举出来,必须看到驱动probe成功、中断正常、数据流畅通,才算真正闭环。
最后分享一个实用小工具习惯:调试阶段务必保留所有版本的SSDT源文件,加注释标明每次改了什么、为什么改。ASL表排错最怕的就是怀疑自己写错了但想不起改过什么,版本管理能帮你快速定位。这个方法陪我熬过了不少个定位到深夜的问题,现在也推荐给你。