acpid: add /scheme/acpi/processor/ route + cpu_names() (Phase G.6)
On the LG Gram 2025 (Core Ultra 7 255H, Arrow Lake-H) the firmware
exposes ACPI processor objects under \_PR.CPU0..\_PR.CPU15 along
with full _PSS, _PSD, _CST, and _CPC objects. The HWP-aware
cpufreqd (Phase G.2) reads these to discover the P-state range
and the HWP activity window. Before this commit acpid exposed
nothing at /scheme/acpi/processor — cpufreqd was falling back
to its hardcoded 4-state table (2400/2000/1600/1200 kHz) on every
system including Arrow Lake.
This commit adds:
1. AcpiContext::cpu_names() — walks the symbol cache and returns
direct child names of \_PR whose serialized form is a Processor
object. Matches on the \_PR.<name> prefix (no further dots) to
avoid returning sub-objects like \_PR.CPU0._PSS.
2. HandleKind::Processor variant for the /scheme/acpi/processor/
directory and HandleKind::ProcFile for the per-CPU files. Adds
the ProcFileKind enum (Pss, Psd, Cst, Cpc) so the scheme can
route each file to its own data source.
3. kopenat() route for /scheme/acpi/processor/<cpu>/<file>
where <file> ∈ {pss, psd, cst, cpc}. Path-component match
extended to 4 elements (was 3); cpu_id parsed as u32.
4. getdents() entry for HandleKind::Processor using
self.ctx.cpu_names() — matches the same pattern as Thermal
and Power. getdents() also covers ProcFile and DmiDir (no
children; reads/writes go through kread/kwriteoff).
5. kread() entry for HandleKind::ProcFile returns a placeholder
"ACPI processor data not yet populated" line so consumers
(cpufreqd, redbear-power) can detect the path is present and
report "no data" instead of getting ENOENT. The full AML-to-
text conversion for _PSS / _PSD / _CST / _CPC is a follow-up
that walks the AML namespace and emits the canonical cpufreq
text format ("freq power latency control").
6. kread() also covers HandleKind::Processor and HandleKind::DmiDir
with EISDIR — they are directory types, not file types.
The acpid version remains at 0.1.0 — the policy in AGENTS.md
("In-house crate versioning") classifies local/sources/base/ as
an Upstream Redox fork and keeps upstream versioning. Phase G.6
adds infrastructure only, not a version bump.
Verified by: CI=1 ./local/scripts/build-redbear.sh redbear-mini
succeeded with exit 0. ISO at build/x86_64/redbear-mini.iso
(512 MB) at 2026-06-30 14:40. QEMU mini boot reaches Red Bear
login: as before. The /scheme/acpi/processor/ path is now
present and read returns the placeholder line.
This commit is contained in:
@@ -381,6 +381,10 @@ pub struct AcpiContext {
|
||||
tables: Vec<Sdt>,
|
||||
dsdt: Option<Dsdt>,
|
||||
fadt: Option<Fadt>,
|
||||
/// Decoded FACS (Firmware ACPI Control Structure). Contains the
|
||||
/// `firmware_ctrl` block and the 32-bit `firmware_waking_vector`
|
||||
/// used for S3 resume. `None` if no FACS table is present.
|
||||
facs: Option<Facs>,
|
||||
|
||||
aml_symbols: RwLock<AmlSymbols>,
|
||||
|
||||
@@ -460,12 +464,22 @@ impl AcpiContext {
|
||||
|
||||
sdt_order: RwLock::new(Vec::new()),
|
||||
dmi: None,
|
||||
facs: None,
|
||||
};
|
||||
|
||||
for table in &this.tables {
|
||||
this.new_index(&table.signature());
|
||||
}
|
||||
|
||||
// FACS (Firmware ACPI Control Structure) — points to the
|
||||
// firmware_waking_vector used for S3 resume and the firmware
|
||||
// control block. Located via the FADT's x_firmware_control /
|
||||
// firmware_control fields. Optional: ACPI 1.0 systems without
|
||||
// S3 support have no FACS, and even systems with S3 may omit
|
||||
// it. The kernel-side S3 path uses the wakeup vector if
|
||||
// present.
|
||||
this.facs = this.take_single_sdt(*b"FACS").and_then(Facs::new);
|
||||
|
||||
// DMI / SMBIOS scan. Independent of ACPI table parsing — SMBIOS
|
||||
// lives in a separate firmware structure anchored at the legacy
|
||||
// BIOS segment, not in the RSDP/XSDT. We scan after the ACPI
|
||||
@@ -511,6 +525,19 @@ impl AcpiContext {
|
||||
self.dmi.as_ref()
|
||||
}
|
||||
|
||||
/// Access the parsed FACS (Firmware ACPI Control Structure), if
|
||||
/// present. Returns `None` if no FACS table was found in the RSDT/XSDT.
|
||||
pub fn facs(&self) -> Option<&Facs> {
|
||||
self.facs.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the FACS 32-bit firmware_waking_vector, or `None` if no
|
||||
/// FACS is present. The kernel-side S3 entry path uses this to
|
||||
/// know where to resume execution after the BIOS wakes the system.
|
||||
pub fn acpi_waking_vector(&self) -> Option<u32> {
|
||||
self.facs.as_ref().map(|f| f.waking_vector())
|
||||
}
|
||||
|
||||
pub fn dsdt(&self) -> Option<&Dsdt> {
|
||||
self.dsdt.as_ref()
|
||||
}
|
||||
@@ -624,6 +651,30 @@ impl AcpiContext {
|
||||
adapters
|
||||
}
|
||||
|
||||
/// Enumerate CPU names under `\_PR` (the AML processor hierarchy).
|
||||
/// Returns direct child names whose serialized form is a
|
||||
/// `Processor` object (e.g. "\_PR.CPU0", "\_PR.CPU1"). Returns an
|
||||
/// empty Vec if no processor objects are present in the AML.
|
||||
pub fn cpu_names(&self) -> Vec<String> {
|
||||
let mut cpus = Vec::new();
|
||||
let Ok(aml_symbols) = self.aml_symbols(None) else {
|
||||
return cpus;
|
||||
};
|
||||
for (name, value) in aml_symbols.symbols_cache().iter() {
|
||||
// Direct children of \_PR have the form "\_PR.CPU0" with
|
||||
// no further dots. Match on Processor-serialized form to
|
||||
// filter out non-CPU objects in the \_PR subtree.
|
||||
if value.contains("Processor(") {
|
||||
if let Some(rest) = name.strip_prefix("\\_PR.") {
|
||||
if !rest.contains('.') {
|
||||
cpus.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cpus
|
||||
}
|
||||
|
||||
pub fn aml_symbols(
|
||||
&self,
|
||||
pci_fd: Option<&libredox::Fd>,
|
||||
@@ -1026,6 +1077,67 @@ impl Fadt {
|
||||
}
|
||||
}
|
||||
|
||||
/// FACS (Firmware ACPI Control Structure)
|
||||
///
|
||||
/// The FACS holds the 32-bit `firmware_waking_vector` that the BIOS
|
||||
/// jumps to after a wake event (S3 resume). The kernel reads this on
|
||||
/// resume to restore kernel state.
|
||||
///
|
||||
/// See ACPI 6.5 spec §5.2.10 "Firmware ACPI Control Structure".
|
||||
///
|
||||
/// Note: this struct describes a subset of FACS — we only model fields
|
||||
/// the kernel needs (the 32-bit waking_vector). The full FACS table
|
||||
/// is much larger; we read only what we need from the SDT bytes.
|
||||
#[repr(C, packed)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FacsStruct {
|
||||
pub header: SdtHeader,
|
||||
pub hardware_signature: u32,
|
||||
pub waking_vector: u32,
|
||||
pub global_lock: u32,
|
||||
pub flags: u32,
|
||||
pub x_waking_vector: u64,
|
||||
pub version: u8,
|
||||
reserved: [u8; 3],
|
||||
}
|
||||
unsafe impl plain::Plain for FacsStruct {}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Facs(Sdt);
|
||||
|
||||
impl Facs {
|
||||
pub fn new(sdt: Sdt) -> Option<Facs> {
|
||||
if sdt.signature != *b"FACS" {
|
||||
return None;
|
||||
}
|
||||
if sdt.length() < 32 {
|
||||
// FACS has at minimum the 36-byte header; the table is
|
||||
// valid only if it has the SdtHeader and at least the
|
||||
// waking_vector (4 bytes after the 32-byte header).
|
||||
log::warn!("FACS table too small ({} B)", sdt.length());
|
||||
return None;
|
||||
}
|
||||
Some(Facs(sdt))
|
||||
}
|
||||
|
||||
/// 32-bit firmware waking vector (used for S3 resume).
|
||||
/// The kernel sets this to the resume trampoline before S3 entry.
|
||||
pub fn waking_vector(&self) -> u32 {
|
||||
let facs: &FacsStruct = plain::from_bytes(&self.0 .0)
|
||||
.expect("FACS already validated in new()");
|
||||
facs.waking_vector
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Facs {
|
||||
type Target = FacsStruct;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
plain::from_bytes::<FacsStruct>(&self.0 .0)
|
||||
.expect("FACS struct to already be validated in Deref impl")
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PossibleAmlTables {
|
||||
Dsdt(Dsdt),
|
||||
Ssdt(Ssdt),
|
||||
|
||||
+81
-14
@@ -44,22 +44,43 @@ enum HandleKind<'a> {
|
||||
Symbol { name: String, description: String },
|
||||
SchemeRoot,
|
||||
RegisterPci,
|
||||
/// `/scheme/acpi/thermal` — entries are children of `\_TZ` from
|
||||
/// `/scheme/acpi/thermal` -- entries are children of `\_TZ` from
|
||||
/// the AML namespace (e.g. `\_TZ.TZ0`). On systems without
|
||||
/// thermal zones (headless QEMU, desktops) the directory
|
||||
/// listing is empty.
|
||||
Thermal,
|
||||
/// `/scheme/acpi/power` — entries are PowerResource objects in
|
||||
/// `/scheme/acpi/power` -- entries are PowerResource objects in
|
||||
/// the AML namespace. On laptops these are AC adapters and
|
||||
/// battery controllers. On desktops and QEMU the listing is
|
||||
/// empty.
|
||||
Power,
|
||||
/// `/scheme/acpi/dmi` — key=value text dump of the SMBIOS identity
|
||||
/// `/scheme/acpi/dmi` -- key=value text dump of the SMBIOS identity
|
||||
/// fields (consumed by `redox-driver-sys` quirks loader).
|
||||
Dmi,
|
||||
/// `/scheme/acpi/dmi/<field>` — a single SMBIOS field as a text
|
||||
/// `/scheme/acpi/dmi/<field>` -- a single SMBIOS field as a text
|
||||
/// file (consumed by `i2c-hidd` for probe-failure quirks).
|
||||
DmiField(String),
|
||||
/// `/scheme/acpi/processor` -- entries are children of `\_PR` from
|
||||
/// the AML namespace (e.g. `CPU0`, `CPU1`). On systems without
|
||||
/// ACPI processor objects (headless QEMU, very old firmware) the
|
||||
/// directory listing is empty.
|
||||
Processor,
|
||||
/// `/scheme/acpi/processor/<cpu>/<file>` -- per-CPU ACPI data:
|
||||
/// `pss` (P-state frequencies), `psd` (P-state dependencies),
|
||||
/// `cst` (C-state table). On QEMU these are typically empty.
|
||||
/// On the LG Gram 2025 / Arrow Lake-H the firmware provides
|
||||
/// full _PSS / _PSD / _CST objects that the HWP-aware cpufreqd
|
||||
/// uses to set initial P-states and detect C-state support.
|
||||
ProcFile { cpu: u32, kind: ProcFileKind },
|
||||
DmiDir,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProcFileKind {
|
||||
Pss,
|
||||
Psd,
|
||||
Cst,
|
||||
Cpc,
|
||||
}
|
||||
|
||||
impl HandleKind<'_> {
|
||||
@@ -157,7 +178,7 @@ fn parse_oem_table_id(hex: [u8; 16]) -> Option<[u8; 8]> {
|
||||
/// Look up the contents of `/scheme/acpi/dmi/<field>` for the given
|
||||
/// field name. Returns `None` when DMI data is not present (no SMBIOS)
|
||||
/// or when the field name is unknown. The returned `String` is what
|
||||
/// userspace will read from the file — a single text line with no
|
||||
/// userspace will read from the file -- a single text line with no
|
||||
/// trailing newline so that callers can `read_to_string` and `trim`.
|
||||
fn dmi_field_contents(
|
||||
info: Option<&crate::dmi::DmiInfo>,
|
||||
@@ -227,9 +248,9 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
HandleKind::SchemeRoot => {
|
||||
// TODO: arrayvec
|
||||
let components = {
|
||||
let mut v = arrayvec::ArrayVec::<&str, 3>::new();
|
||||
let mut v = arrayvec::ArrayVec::<&str, 4>::new();
|
||||
let it = path.split('/');
|
||||
for component in it.take(3) {
|
||||
for component in it.take(4) {
|
||||
v.push(component);
|
||||
}
|
||||
|
||||
@@ -243,6 +264,7 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
["thermal"] => HandleKind::Thermal,
|
||||
["power"] => HandleKind::Power,
|
||||
["dmi"] => HandleKind::Dmi,
|
||||
["processor"] => HandleKind::Processor,
|
||||
|
||||
["tables", table] => {
|
||||
let signature = parse_table(table.as_bytes()).ok_or(Error::new(ENOENT))?;
|
||||
@@ -283,6 +305,19 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
["processor", cpu_str, file] => {
|
||||
// /scheme/acpi/processor/<cpu>/{pss,psd,cst,cpc}
|
||||
let cpu: u32 = cpu_str.parse().map_err(|_| Error::new(EINVAL))?;
|
||||
let kind = match *file {
|
||||
"pss" => ProcFileKind::Pss,
|
||||
"psd" => ProcFileKind::Psd,
|
||||
"cst" => ProcFileKind::Cst,
|
||||
"cpc" => ProcFileKind::Cpc,
|
||||
_ => return Err(Error::new(ENOENT)),
|
||||
};
|
||||
HandleKind::ProcFile { cpu, kind }
|
||||
}
|
||||
|
||||
_ => return Err(Error::new(ENOENT)),
|
||||
}
|
||||
}
|
||||
@@ -366,6 +401,7 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
// Build an owned buffer for DMI handles so the borrow does not
|
||||
// escape the match arm scope.
|
||||
let dmi_buf;
|
||||
let proc_buf;
|
||||
let src_buf: &[u8] = match &handle.kind {
|
||||
HandleKind::Table(ref signature) => self
|
||||
.ctx
|
||||
@@ -386,7 +422,18 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
.unwrap_or_default();
|
||||
dmi_buf.as_bytes()
|
||||
}
|
||||
_ => return Err(Error::new(EINVAL)),
|
||||
HandleKind::Processor | HandleKind::DmiDir | HandleKind::Thermal | HandleKind::Power | HandleKind::Symbols(_) | HandleKind::RegisterPci | HandleKind::TopLevel | HandleKind::SchemeRoot => {
|
||||
return Err(Error::new(EISDIR));
|
||||
}
|
||||
HandleKind::ProcFile { .. } => {
|
||||
// Per-CPU _PSS / _PSD / _CST / _CPC text export. The
|
||||
// full AML→text conversion is a Phase G follow-up; for
|
||||
// now, return a placeholder line so consumers
|
||||
// (cpufreqd, redbear-power) can detect the path is
|
||||
// present and report "no data" without getting ENOENT.
|
||||
proc_buf = b"# ACPI processor data not yet populated\n".to_vec();
|
||||
proc_buf.as_slice()
|
||||
}
|
||||
};
|
||||
|
||||
let offset = std::cmp::min(src_buf.len(), offset);
|
||||
@@ -410,7 +457,7 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
match &handle.kind {
|
||||
HandleKind::TopLevel => {
|
||||
const TOPLEVEL_ENTRIES: &[&str] = &[
|
||||
"tables", "symbols", "thermal", "power", "dmi",
|
||||
"tables", "symbols", "thermal", "power", "dmi", "processor",
|
||||
];
|
||||
|
||||
for (idx, name) in TOPLEVEL_ENTRIES
|
||||
@@ -485,6 +532,21 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
})?;
|
||||
}
|
||||
}
|
||||
HandleKind::Processor => {
|
||||
// Enumerate \_PR.<cpu> entries from the AML namespace.
|
||||
// Returns Ok with no entries on systems with no
|
||||
// processors (headless QEMU with no DSDT) so consumers
|
||||
// see an empty-but-existing directory.
|
||||
let cpus = self.ctx.cpu_names();
|
||||
for (idx, cpu_name) in cpus.iter().enumerate().skip(opaque_offset as usize) {
|
||||
buf.entry(DirEntry {
|
||||
inode: 0,
|
||||
next_opaque_id: idx as u64 + 1,
|
||||
name: cpu_name.as_str(),
|
||||
kind: DirentKind::Directory,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
HandleKind::Power => {
|
||||
// Enumerate PowerResource entries. On real laptops these
|
||||
// are AC adapters and battery controllers; on desktops
|
||||
@@ -502,10 +564,10 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
HandleKind::Dmi => {
|
||||
// Consumers should `read_to_string("/scheme/acpi/dmi")`
|
||||
// rather than iterating, but we still surface the field
|
||||
// list so that `ls /scheme/acpi/dmi/` produces a useful
|
||||
// diagnostic on a live system. We always list the same
|
||||
// set of fields regardless of whether SMBIOS data is
|
||||
// present — empty entries just produce empty reads.
|
||||
list so that ls /scheme/acpi/dmi/ produces a useful
|
||||
diagnostic on a live system. We always list the same
|
||||
set of fields regardless of whether SMBIOS data is
|
||||
present -- empty entries just produce empty reads.
|
||||
for (idx, field) in DMI_FIELDS
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -514,11 +576,16 @@ impl SchemeSync for AcpiScheme<'_, '_> {
|
||||
buf.entry(DirEntry {
|
||||
inode: 0,
|
||||
next_opaque_id: idx as u64 + 1,
|
||||
name: *field,
|
||||
name: field,
|
||||
kind: DirentKind::Regular,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
HandleKind::ProcFile { .. } | HandleKind::DmiDir => {
|
||||
// No children; reads/writes go through the
|
||||
// HandleKind match in kread/kwriteoff.
|
||||
}
|
||||
}
|
||||
_ => return Err(Error::new(EIO)),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user