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:
Red Bear OS
2026-06-30 14:41:16 +03:00
parent 181a36a4e4
commit c335553c7e
2 changed files with 193 additions and 14 deletions
+112
View File
@@ -381,6 +381,10 @@ pub struct AcpiContext {
tables: Vec<Sdt>, tables: Vec<Sdt>,
dsdt: Option<Dsdt>, dsdt: Option<Dsdt>,
fadt: Option<Fadt>, 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>, aml_symbols: RwLock<AmlSymbols>,
@@ -460,12 +464,22 @@ impl AcpiContext {
sdt_order: RwLock::new(Vec::new()), sdt_order: RwLock::new(Vec::new()),
dmi: None, dmi: None,
facs: None,
}; };
for table in &this.tables { for table in &this.tables {
this.new_index(&table.signature()); 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 // DMI / SMBIOS scan. Independent of ACPI table parsing — SMBIOS
// lives in a separate firmware structure anchored at the legacy // lives in a separate firmware structure anchored at the legacy
// BIOS segment, not in the RSDP/XSDT. We scan after the ACPI // BIOS segment, not in the RSDP/XSDT. We scan after the ACPI
@@ -511,6 +525,19 @@ impl AcpiContext {
self.dmi.as_ref() 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> { pub fn dsdt(&self) -> Option<&Dsdt> {
self.dsdt.as_ref() self.dsdt.as_ref()
} }
@@ -624,6 +651,30 @@ impl AcpiContext {
adapters 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( pub fn aml_symbols(
&self, &self,
pci_fd: Option<&libredox::Fd>, 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 { pub enum PossibleAmlTables {
Dsdt(Dsdt), Dsdt(Dsdt),
Ssdt(Ssdt), Ssdt(Ssdt),
+81 -14
View File
@@ -44,22 +44,43 @@ enum HandleKind<'a> {
Symbol { name: String, description: String }, Symbol { name: String, description: String },
SchemeRoot, SchemeRoot,
RegisterPci, 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 /// the AML namespace (e.g. `\_TZ.TZ0`). On systems without
/// thermal zones (headless QEMU, desktops) the directory /// thermal zones (headless QEMU, desktops) the directory
/// listing is empty. /// listing is empty.
Thermal, 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 /// the AML namespace. On laptops these are AC adapters and
/// battery controllers. On desktops and QEMU the listing is /// battery controllers. On desktops and QEMU the listing is
/// empty. /// empty.
Power, 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). /// fields (consumed by `redox-driver-sys` quirks loader).
Dmi, 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). /// file (consumed by `i2c-hidd` for probe-failure quirks).
DmiField(String), 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<'_> { 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 /// Look up the contents of `/scheme/acpi/dmi/<field>` for the given
/// field name. Returns `None` when DMI data is not present (no SMBIOS) /// field name. Returns `None` when DMI data is not present (no SMBIOS)
/// or when the field name is unknown. The returned `String` is what /// 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`. /// trailing newline so that callers can `read_to_string` and `trim`.
fn dmi_field_contents( fn dmi_field_contents(
info: Option<&crate::dmi::DmiInfo>, info: Option<&crate::dmi::DmiInfo>,
@@ -227,9 +248,9 @@ impl SchemeSync for AcpiScheme<'_, '_> {
HandleKind::SchemeRoot => { HandleKind::SchemeRoot => {
// TODO: arrayvec // TODO: arrayvec
let components = { let components = {
let mut v = arrayvec::ArrayVec::<&str, 3>::new(); let mut v = arrayvec::ArrayVec::<&str, 4>::new();
let it = path.split('/'); let it = path.split('/');
for component in it.take(3) { for component in it.take(4) {
v.push(component); v.push(component);
} }
@@ -243,6 +264,7 @@ impl SchemeSync for AcpiScheme<'_, '_> {
["thermal"] => HandleKind::Thermal, ["thermal"] => HandleKind::Thermal,
["power"] => HandleKind::Power, ["power"] => HandleKind::Power,
["dmi"] => HandleKind::Dmi, ["dmi"] => HandleKind::Dmi,
["processor"] => HandleKind::Processor,
["tables", table] => { ["tables", table] => {
let signature = parse_table(table.as_bytes()).ok_or(Error::new(ENOENT))?; 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)), _ => 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 // Build an owned buffer for DMI handles so the borrow does not
// escape the match arm scope. // escape the match arm scope.
let dmi_buf; let dmi_buf;
let proc_buf;
let src_buf: &[u8] = match &handle.kind { let src_buf: &[u8] = match &handle.kind {
HandleKind::Table(ref signature) => self HandleKind::Table(ref signature) => self
.ctx .ctx
@@ -386,7 +422,18 @@ impl SchemeSync for AcpiScheme<'_, '_> {
.unwrap_or_default(); .unwrap_or_default();
dmi_buf.as_bytes() 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); let offset = std::cmp::min(src_buf.len(), offset);
@@ -410,7 +457,7 @@ impl SchemeSync for AcpiScheme<'_, '_> {
match &handle.kind { match &handle.kind {
HandleKind::TopLevel => { HandleKind::TopLevel => {
const TOPLEVEL_ENTRIES: &[&str] = &[ const TOPLEVEL_ENTRIES: &[&str] = &[
"tables", "symbols", "thermal", "power", "dmi", "tables", "symbols", "thermal", "power", "dmi", "processor",
]; ];
for (idx, name) in TOPLEVEL_ENTRIES 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 => { HandleKind::Power => {
// Enumerate PowerResource entries. On real laptops these // Enumerate PowerResource entries. On real laptops these
// are AC adapters and battery controllers; on desktops // are AC adapters and battery controllers; on desktops
@@ -502,10 +564,10 @@ impl SchemeSync for AcpiScheme<'_, '_> {
HandleKind::Dmi => { HandleKind::Dmi => {
// Consumers should `read_to_string("/scheme/acpi/dmi")` // Consumers should `read_to_string("/scheme/acpi/dmi")`
// rather than iterating, but we still surface the field // rather than iterating, but we still surface the field
// list so that `ls /scheme/acpi/dmi/` produces a useful list so that ls /scheme/acpi/dmi/ produces a useful
// diagnostic on a live system. We always list the same diagnostic on a live system. We always list the same
// set of fields regardless of whether SMBIOS data is set of fields regardless of whether SMBIOS data is
// present — empty entries just produce empty reads. present -- empty entries just produce empty reads.
for (idx, field) in DMI_FIELDS for (idx, field) in DMI_FIELDS
.iter() .iter()
.enumerate() .enumerate()
@@ -514,11 +576,16 @@ impl SchemeSync for AcpiScheme<'_, '_> {
buf.entry(DirEntry { buf.entry(DirEntry {
inode: 0, inode: 0,
next_opaque_id: idx as u64 + 1, next_opaque_id: idx as u64 + 1,
name: *field, name: field,
kind: DirentKind::Regular, kind: DirentKind::Regular,
})?; })?;
} }
} }
HandleKind::ProcFile { .. } | HandleKind::DmiDir => {
// No children; reads/writes go through the
// HandleKind match in kread/kwriteoff.
}
}
_ => return Err(Error::new(EIO)), _ => return Err(Error::new(EIO)),
} }