ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ACPI模拟DTS:用PRP0001桥接设备树与ACPI驱动匹配

ACPI模拟DTS:用PRP0001桥接设备树与ACPI驱动匹配

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映射目标
compatiblevendor,xdma_HID=PRP0001 + _DSD里的compatible
reg0x40400000, 0x10000_CRS里的Memory32Fixed
interruptsSPI 44 LEVEL_HIGH_CRS里的Interrupt资源
dma-channels8_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。

检查项命令/方法预期结果
表加载dmesgACPI: Added SSDT
设备枚举ls /sys/bus/acpi/devices/PRP0001:xx
设备存在性ls /sys/bus/platform/devices/xdma设备节点
驱动绑定dmesgprobe成功
of_nodecat /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表排错最怕的就是怀疑自己写错了但想不起改过什么,版本管理能帮你快速定位。这个方法陪我熬过了不少个定位到深夜的问题,现在也推荐给你。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进