From c60f352053e44f5acc410eb5d4473e03c00669df Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 20:47:31 +0800 Subject: [PATCH 01/13] prochot msr Signed-off-by: Daniel Schaefer --- framework_lib/src/lib.rs | 1 + framework_lib/src/msr.rs | 582 +++++++++++++++++++++++++++++++++++++ framework_lib/src/power.rs | 3 + 3 files changed, 586 insertions(+) create mode 100644 framework_lib/src/msr.rs diff --git a/framework_lib/src/lib.rs b/framework_lib/src/lib.rs index 64d6cb5f..ae330100 100644 --- a/framework_lib/src/lib.rs +++ b/framework_lib/src/lib.rs @@ -58,6 +58,7 @@ pub mod ec_binary; pub mod esrt; #[cfg(feature = "uefi")] pub mod fw_uefi; +pub mod msr; mod os_specific; pub mod parade_retimer; pub mod power; diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs new file mode 100644 index 00000000..46023b13 --- /dev/null +++ b/framework_lib/src/msr.rs @@ -0,0 +1,582 @@ +//! Read Intel thermal and performance limiting MSRs +//! +//! These tell us *why* the CPU is running slower than requested. Most +//! interesting on our systems is PROCHOT, which the EC asserts to throttle the +//! CPU, and the frequency clipping reasons, which record both the currently +//! active and the (sticky) previously seen limiting reasons. +//! +//! Only Intel processors are supported. AMD does not expose comparable +//! information through MSRs. +//! +//! References: +//! - Intel SDM Volume 4 (Model-Specific Registers) +//! - Panther Lake reference code, `Include/Register/Ptl/Msr/MsrRegs.h` +//! - Linux `arch/x86/include/asm/msr-index.h` and `tools/power/x86/turbostat` + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +/// Per core thermal status (IA32_THERM_STATUS) +pub const MSR_IA32_THERM_STATUS: u32 = 0x0000019C; +/// Thermal monitor reference temperature and offsets (IA32_TEMPERATURE_TARGET) +pub const MSR_IA32_TEMPERATURE_TARGET: u32 = 0x000001A2; +/// Package thermal status (IA32_PACKAGE_THERM_STATUS) +pub const MSR_IA32_PACKAGE_THERM_STATUS: u32 = 0x000001B1; +/// Power control, contains the PROCHOT configuration (MSR_POWER_CTL) +pub const MSR_POWER_CTL: u32 = 0x000001FC; +/// Indicator of frequency clipping in the processor cores +/// +/// Sandy Bridge to Broadwell use 0x690 instead. All Intel based Framework +/// systems are Tiger Lake or newer, so we only implement the modern address. +pub const MSR_IA_PERF_LIMIT_REASONS: u32 = 0x0000064F; +/// Indicator of frequency clipping in the integrated graphics +pub const MSR_GT_PERF_LIMIT_REASONS: u32 = 0x000006B0; +/// Indicator of frequency clipping in the ring interconnect (a.k.a. CLR) +pub const MSR_RING_PERF_LIMIT_REASONS: u32 = 0x000006B1; + +/// Status bits of the *_PERF_LIMIT_REASONS MSRs +/// +/// The matching sticky log bit is always 16 bits higher. +const PLR_CORE_BITS: &[(u32, &str)] = &[ + (0, "PROCHOT"), + (1, "Thermal"), + (4, "ResidencyStateRegulation"), + (5, "RunningAvgThermalLimit"), + (6, "VR-ThermalAlert"), + (7, "VR-ThermalDesignCurrent"), + (8, "Other"), + (10, "PkgPwrPL1"), + (11, "PkgPwrPL2"), + (12, "MaxTurboLimit"), + (13, "TurboTransitionAttenuation"), +]; +const PLR_GT_BITS: &[(u32, &str)] = &[ + (0, "PROCHOT"), + (1, "Thermal"), + (4, "ResidencyStateRegulation"), + (5, "RunningAvgThermalLimit"), + (6, "VR-ThermalAlert"), + (7, "VR-ThermalDesignCurrent"), + (8, "Other"), + (10, "PkgPwrPL1"), + (11, "PkgPwrPL2"), + (12, "InefficientOperation"), +]; +const PLR_RING_BITS: &[(u32, &str)] = &[ + (0, "PROCHOT"), + (1, "Thermal"), + (4, "ResidencyStateRegulation"), + (5, "RunningAvgThermalLimit"), + (6, "VR-ThermalAlert"), + (7, "VR-ThermalDesignCurrent"), + (8, "Other"), + (10, "PkgPwrPL1"), + (11, "PkgPwrPL2"), +]; + +/// Status/log bit pairs shared by IA32_THERM_STATUS and +/// IA32_PACKAGE_THERM_STATUS. Bit 12/13 differs between the two. +const THERM_STATUS_BITS: &[(u32, &str)] = &[ + (0, "Thermal Monitor"), + (2, "PROCHOT/FORCEPR"), + (4, "Critical Temp"), + (6, "Threshold #1"), + (8, "Threshold #2"), + (10, "Power Limit"), +]; + +fn bit(value: u64, bit: u32) -> bool { + (value >> bit) & 1 == 1 +} + +fn yes_no(value: bool) -> &'static str { + if value { + "Yes" + } else { + "No" + } +} + +/// Decode the set bits of a *_PERF_LIMIT_REASONS MSR +/// +/// `shift` is 0 for the currently active reasons and 16 for the sticky log. +fn decode_reasons(value: u64, bits: &[(u32, &str)], shift: u32) -> String { + let set: Vec<&str> = bits + .iter() + .filter(|(b, _)| bit(value, b + shift)) + .map(|(_, name)| *name) + .collect(); + if set.is_empty() { + "None".to_string() + } else { + set.join(", ") + } +} + +// ------------------------------------------------------------------------- +// CPUID +// ------------------------------------------------------------------------- + +/// Vendor, family and model from CPUID +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CpuId { + pub is_intel: bool, + pub family: u32, + pub model: u32, + /// CPUID.06H:EAX[0] Digital Thermal Sensor + pub has_dts: bool, + /// CPUID.06H:EAX[6] Package Thermal Management + pub has_ptm: bool, +} + +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +pub fn cpuid() -> Option { + #[cfg(target_arch = "x86")] + use core::arch::x86::__cpuid; + #[cfg(target_arch = "x86_64")] + use core::arch::x86_64::__cpuid; + + // SAFETY: CPUID leaf 0 is available on every CPU we can be running on + let leaf_0 = unsafe { __cpuid(0) }; + // "GenuineIntel" in EBX, EDX, ECX + let is_intel = + leaf_0.ebx == 0x756E6547 && leaf_0.edx == 0x49656E69 && leaf_0.ecx == 0x6C65746E; + + // SAFETY: Leaf 1 is available whenever leaf 0 reports at least 1 + let leaf_1 = unsafe { __cpuid(1) }; + let base_family = (leaf_1.eax >> 8) & 0xF; + let base_model = (leaf_1.eax >> 4) & 0xF; + let family = if base_family == 0xF { + base_family + ((leaf_1.eax >> 20) & 0xFF) + } else { + base_family + }; + let model = if base_family == 0x6 || base_family == 0xF { + base_model + (((leaf_1.eax >> 16) & 0xF) << 4) + } else { + base_model + }; + + // Leaf 6 (Thermal and Power Management) tells us whether the thermal + // status MSRs exist. Only query it if the CPU supports that leaf. + let (has_dts, has_ptm) = if leaf_0.eax >= 6 { + // SAFETY: Guarded by the maximum leaf reported above + let leaf_6 = unsafe { __cpuid(6) }; + (leaf_6.eax & 1 == 1, (leaf_6.eax >> 6) & 1 == 1) + } else { + (false, false) + }; + + Some(CpuId { + is_intel, + family, + model, + has_dts, + has_ptm, + }) +} + +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +pub fn cpuid() -> Option { + None +} + +impl CpuId { + /// Whether the *_PERF_LIMIT_REASONS MSRs at their modern addresses exist + /// + /// They are not architectural, so we only read them on Skylake (model + /// 0x4E) and newer client processors. Reading a reserved MSR raises a + /// general protection fault, which is fatal in UEFI. + pub fn has_perf_limit_reasons(&self) -> bool { + self.is_intel && self.family == 6 && self.model >= 0x4E + } +} + +// ------------------------------------------------------------------------- +// MSR access, one implementation per OS +// ------------------------------------------------------------------------- + +#[cfg(all( + target_os = "linux", + any(target_arch = "x86", target_arch = "x86_64") +))] +mod imp { + use std::fs::File; + use std::io::{Read, Seek, SeekFrom}; + + pub fn read_msr(cpu: u32, msr: u32) -> Option { + let path = format!("/dev/cpu/{}/msr", cpu); + let mut file = File::open(&path) + .map_err(|err| debug!("Failed to open {}: {:?}", path, err)) + .ok()?; + file.seek(SeekFrom::Start(msr as u64)) + .map_err(|err| debug!("Failed to seek {} to {:#X}: {:?}", path, msr, err)) + .ok()?; + let mut buf = [0u8; 8]; + file.read_exact(&mut buf) + .map_err(|err| debug!("Failed to read MSR {:#X}: {:?}", msr, err)) + .ok()?; + Some(u64::from_le_bytes(buf)) + } + + pub fn cpu_count() -> u32 { + (0..256) + .take_while(|cpu| File::open(format!("/dev/cpu/{}/msr", cpu)).is_ok()) + .count() as u32 + } + + pub fn unavailable_hint() -> &'static str { + "Cannot access /dev/cpu/0/msr. Run as root and load the msr module (modprobe msr)" + } +} + +#[cfg(all( + target_os = "freebsd", + any(target_arch = "x86", target_arch = "x86_64") +))] +mod imp { + use nix::ioctl_readwrite; + use std::fs::File; + use std::os::fd::AsRawFd; + + /// `cpuctl_msr_args_t` from `sys/sys/cpuctl.h` + #[repr(C)] + struct CpuctlMsrArgs { + msr: i32, + data: u64, + } + ioctl_readwrite!(cpuctl_rdmsr, b'c', 1, CpuctlMsrArgs); + + pub fn read_msr(cpu: u32, msr: u32) -> Option { + let path = format!("/dev/cpuctl{}", cpu); + let file = File::open(&path) + .map_err(|err| debug!("Failed to open {}: {:?}", path, err)) + .ok()?; + let mut args = CpuctlMsrArgs { + msr: msr as i32, + data: 0, + }; + // SAFETY: args matches the struct the ioctl expects + unsafe { cpuctl_rdmsr(file.as_raw_fd(), &mut args) } + .map_err(|err| debug!("Failed to read MSR {:#X}: {:?}", msr, err)) + .ok()?; + Some(args.data) + } + + pub fn cpu_count() -> u32 { + (0..256) + .take_while(|cpu| File::open(format!("/dev/cpuctl{}", cpu)).is_ok()) + .count() as u32 + } + + pub fn unavailable_hint() -> &'static str { + "Cannot access /dev/cpuctl0. Run as root and load the cpuctl module (kldload cpuctl)" + } +} + +#[cfg(all(target_os = "uefi", any(target_arch = "x86", target_arch = "x86_64")))] +mod imp { + use core::arch::asm; + + /// Read an MSR on the processor we're currently executing on + /// + /// UEFI applications run single threaded on the bootstrap processor, so we + /// can only ever read the BSP. Package scoped MSRs are unaffected by that. + pub fn read_msr(_cpu: u32, msr: u32) -> Option { + let lo: u32; + let hi: u32; + // SAFETY: Callers must make sure the MSR exists on this processor, + // otherwise this raises a general protection fault + unsafe { + asm!( + "rdmsr", + in("ecx") msr, + out("eax") lo, + out("edx") hi, + options(nomem, nostack, preserves_flags), + ); + } + Some(((hi as u64) << 32) | (lo as u64)) + } + + pub fn cpu_count() -> u32 { + 1 + } + + pub fn unavailable_hint() -> &'static str { + "Cannot read MSRs" + } +} + +// Windows needs a kernel driver to access MSRs and we don't ship one. +// Also covers non-x86, where these MSRs don't exist at all. +#[cfg(not(all( + any(target_os = "linux", target_os = "freebsd", target_os = "uefi"), + any(target_arch = "x86", target_arch = "x86_64") +)))] +mod imp { + pub fn read_msr(_cpu: u32, _msr: u32) -> Option { + None + } + + pub fn cpu_count() -> u32 { + 0 + } + + pub fn unavailable_hint() -> &'static str { + "Reading MSRs is not supported on this platform" + } +} + +/// Read a 64bit MSR of a logical processor +pub fn read_msr(cpu: u32, msr: u32) -> Option { + let value = imp::read_msr(cpu, msr); + debug!("CPU {} MSR {:#X}: {:#018X?}", cpu, msr, value); + value +} + +/// Number of logical processors we can read MSRs of +pub fn cpu_count() -> u32 { + imp::cpu_count() +} + +// ------------------------------------------------------------------------- +// Decoded structures +// ------------------------------------------------------------------------- + +/// IA32_TEMPERATURE_TARGET (0x1A2) +#[derive(Debug, Clone, Copy)] +pub struct TemperatureTarget { + /// Lowest temperature at which PROCHOT# is asserted, in degrees C + pub ref_temp: u8, + /// Offset below ref_temp at which TCC activates, in degrees C + pub tcc_offset: u8, + /// Offset below ref_temp at which fans should be engaged (T-Control) + pub fan_temp_offset: u8, + /// Allow RATL throttling below P1 + pub tcc_offset_clamping: bool, + /// The whole MSR is read-only + pub locked: bool, +} + +impl From for TemperatureTarget { + fn from(msr: u64) -> Self { + Self { + ref_temp: ((msr >> 16) & 0xFF) as u8, + // 7 bits [30:24] on Panther Lake, 6 bits since Skylake. + // Offsets that large don't occur, so mask conservatively. + tcc_offset: ((msr >> 24) & 0x3F) as u8, + fan_temp_offset: ((msr >> 8) & 0xFF) as u8, + tcc_offset_clamping: bit(msr, 7), + locked: bit(msr, 31), + } + } +} + +/// IA32_THERM_STATUS (0x19C) and IA32_PACKAGE_THERM_STATUS (0x1B1) +#[derive(Debug, Clone, Copy)] +pub struct ThermStatus { + pub raw: u64, + /// Temperature is valid + pub valid: bool, + /// Degrees C below the reference temperature + pub readout: u8, + /// Resolution of the readout in degrees C + pub resolution: u8, +} + +impl From for ThermStatus { + fn from(msr: u64) -> Self { + Self { + raw: msr, + valid: bit(msr, 31), + readout: ((msr >> 16) & 0x7F) as u8, + resolution: ((msr >> 27) & 0xF) as u8, + } + } +} + +impl ThermStatus { + /// Temperature in degrees C, needs the reference temperature from + /// IA32_TEMPERATURE_TARGET + pub fn temp(&self, ref_temp: u8) -> Option { + if !self.valid { + return None; + } + Some(ref_temp as i32 - self.readout as i32) + } + + /// Whether the core or package is currently being thermally throttled + pub fn throttling(&self) -> bool { + bit(self.raw, 0) || bit(self.raw, 2) + } +} + +// ------------------------------------------------------------------------- +// Printing +// ------------------------------------------------------------------------- + +/// Print PROCHOT status, frequency limiting reasons and thermal MSR details +/// +/// Silently does nothing if the platform doesn't have these MSRs or we can't +/// read them. Reasons are logged. +pub fn print_thermal_msrs() { + let Some(cpuid) = cpuid() else { + debug!("No CPUID, not reading thermal MSRs"); + return; + }; + if !cpuid.is_intel { + info!("Thermal MSRs are only implemented for Intel processors"); + return; + } + if !cpuid.has_dts && !cpuid.has_ptm { + info!("Processor has no digital thermal sensor, not reading thermal MSRs"); + return; + } + + let Some(target) = read_msr(0, MSR_IA32_TEMPERATURE_TARGET).map(TemperatureTarget::from) else { + info!("{}", imp::unavailable_hint()); + return; + }; + + println!(" Intel Thermal MSRs"); + println!(" TjMax: {:>4} C", target.ref_temp); + println!( + " TCC Activation: {:>4} C (Offset {} C{})", + target.ref_temp - target.tcc_offset, + target.tcc_offset, + if target.tcc_offset_clamping { + ", clamping" + } else { + "" + } + ); + println!( + " Fan Temp Target: {:>4} C (Offset {} C)", + target.ref_temp - target.fan_temp_offset, + target.fan_temp_offset + ); + debug!("TEMPERATURE_TARGET locked: {}", target.locked); + + if cpuid.has_ptm { + if let Some(pkg) = read_msr(0, MSR_IA32_PACKAGE_THERM_STATUS).map(ThermStatus::from) { + if let Some(temp) = pkg.temp(target.ref_temp) { + println!( + " Package Temp: {:>4} C (Resolution {} C)", + temp, pkg.resolution + ); + } + println!(" Package Status Now Ever"); + for (b, name) in THERM_STATUS_BITS { + println!( + " {:<24} {:>4} {:>4}", + format!("{}:", name), + yes_no(bit(pkg.raw, *b)), + yes_no(bit(pkg.raw, b + 1)) + ); + } + // Bit 12/13 is Pmax in the package MSR, Current Limit per core + println!( + " {:<24} {:>4} {:>4}", + "Pmax Limit:", + yes_no(bit(pkg.raw, 12)), + yes_no(bit(pkg.raw, 13)) + ); + } + } + + if cpuid.has_dts { + print_core_therm_status(target.ref_temp); + } + + if cpuid.has_perf_limit_reasons() { + print_perf_limit_reasons(); + } else { + debug!( + "No PERF_LIMIT_REASONS MSRs on family {:#X} model {:#X}", + cpuid.family, cpuid.model + ); + } + + print_power_ctl(); +} + +/// Print the hottest core and which cores are currently being throttled +fn print_core_therm_status(ref_temp: u8) { + let cpus = cpu_count(); + let mut hottest: Option<(u32, i32)> = None; + let mut throttling = Vec::new(); + + for cpu in 0..cpus { + let Some(status) = read_msr(cpu, MSR_IA32_THERM_STATUS).map(ThermStatus::from) else { + continue; + }; + if let Some(temp) = status.temp(ref_temp) { + match hottest { + Some((_, hottest_temp)) if temp <= hottest_temp => {} + _ => hottest = Some((cpu, temp)), + } + } + if status.throttling() { + throttling.push(format!("{}", cpu)); + } + } + + if let Some((cpu, temp)) = hottest { + println!(" Hottest Core Temp: {:>4} C (CPU {})", temp, cpu); + } + if cpus > 0 { + println!( + " Cores Throttling: {:>4}", + if throttling.is_empty() { + "None".to_string() + } else { + throttling.join(", ") + } + ); + } +} + +/// Print which limits are clipping the core, graphics and ring frequency +fn print_perf_limit_reasons() { + for (msr, name, bits) in [ + (MSR_IA_PERF_LIMIT_REASONS, "Core", PLR_CORE_BITS), + (MSR_GT_PERF_LIMIT_REASONS, "Graphics", PLR_GT_BITS), + (MSR_RING_PERF_LIMIT_REASONS, "Ring", PLR_RING_BITS), + ] { + let Some(value) = read_msr(0, msr) else { + continue; + }; + println!( + " {} Frequency Limit Reasons ({:#X}: {:#010X})", + name, msr, value + ); + println!(" Now: {}", decode_reasons(value, bits, 0)); + println!(" Ever: {}", decode_reasons(value, bits, 16)); + } +} + +/// Print how the processor is configured to react to PROCHOT +fn print_power_ctl() { + let Some(value) = read_msr(0, MSR_POWER_CTL) else { + return; + }; + println!(" PROCHOT Config ({:#X}: {:#010X})", MSR_POWER_CTL, value); + // Bidirectional PROCHOT lets the EC throttle the CPU by asserting PROCHOT# + println!( + " Bidirectional: {}", + yes_no(bit(value, 0)) + ); + println!(" Output Enabled: {}", yes_no(!bit(value, 21))); + println!( + " Response: {}", + if bit(value, 22) { + "Reduce by one P-State" + } else { + "Throttle to minimum" + } + ); + println!(" VR Therm Alert: {}", yes_no(!bit(value, 24))); + println!(" Locked: {}", yes_no(bit(value, 23))); +} diff --git a/framework_lib/src/power.rs b/framework_lib/src/power.rs index 0453165c..e7ea4616 100644 --- a/framework_lib/src/power.rs +++ b/framework_lib/src/power.rs @@ -13,6 +13,7 @@ use crate::ccgx::{AppVersion, Application, BaseVersion, ControllerVersion, MainP use crate::chromium_ec::command::EcRequestRaw; use crate::chromium_ec::commands::*; use crate::chromium_ec::*; +use crate::msr; use crate::smbios; use crate::util::PlatformFamily; @@ -530,6 +531,8 @@ pub fn print_thermal(ec: &CrosEc) { } else { println!(" Unknown"); } + + msr::print_thermal_msrs(); } pub fn get_fan_num(ec: &CrosEc) -> EcResult { From b5de3b0c7ca19848826877faa9d0d704cac2f3a8 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 21:29:41 +0800 Subject: [PATCH 02/13] fixup! prochot msr --- framework_lib/src/msr.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 46023b13..afc6ba7a 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -131,6 +131,8 @@ pub struct CpuId { } #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +// __cpuid is safe since Rust 1.87, but our MSRV is 1.81 +#[allow(unused_unsafe)] pub fn cpuid() -> Option { #[cfg(target_arch = "x86")] use core::arch::x86::__cpuid; @@ -140,8 +142,7 @@ pub fn cpuid() -> Option { // SAFETY: CPUID leaf 0 is available on every CPU we can be running on let leaf_0 = unsafe { __cpuid(0) }; // "GenuineIntel" in EBX, EDX, ECX - let is_intel = - leaf_0.ebx == 0x756E6547 && leaf_0.edx == 0x49656E69 && leaf_0.ecx == 0x6C65746E; + let is_intel = leaf_0.ebx == 0x756E6547 && leaf_0.edx == 0x49656E69 && leaf_0.ecx == 0x6C65746E; // SAFETY: Leaf 1 is available whenever leaf 0 reports at least 1 let leaf_1 = unsafe { __cpuid(1) }; @@ -197,10 +198,7 @@ impl CpuId { // MSR access, one implementation per OS // ------------------------------------------------------------------------- -#[cfg(all( - target_os = "linux", - any(target_arch = "x86", target_arch = "x86_64") -))] +#[cfg(all(target_os = "linux", any(target_arch = "x86", target_arch = "x86_64")))] mod imp { use std::fs::File; use std::io::{Read, Seek, SeekFrom}; @@ -467,10 +465,11 @@ pub fn print_thermal_msrs() { temp, pkg.resolution ); } - println!(" Package Status Now Ever"); + println!(" Package Thermal Status ({:#010X})", pkg.raw); + println!(" {:<24} {:>6} {:>6}", "Condition", "Active", "Logged"); for (b, name) in THERM_STATUS_BITS { println!( - " {:<24} {:>4} {:>4}", + " {:<24} {:>6} {:>6}", format!("{}:", name), yes_no(bit(pkg.raw, *b)), yes_no(bit(pkg.raw, b + 1)) @@ -478,7 +477,7 @@ pub fn print_thermal_msrs() { } // Bit 12/13 is Pmax in the package MSR, Current Limit per core println!( - " {:<24} {:>4} {:>4}", + " {:<24} {:>6} {:>6}", "Pmax Limit:", yes_no(bit(pkg.raw, 12)), yes_no(bit(pkg.raw, 13)) @@ -552,8 +551,11 @@ fn print_perf_limit_reasons() { " {} Frequency Limit Reasons ({:#X}: {:#010X})", name, msr, value ); - println!(" Now: {}", decode_reasons(value, bits, 0)); - println!(" Ever: {}", decode_reasons(value, bits, 16)); + println!(" Active: {}", decode_reasons(value, bits, 0)); + println!( + " Logged: {}", + decode_reasons(value, bits, 16) + ); } } @@ -564,10 +566,7 @@ fn print_power_ctl() { }; println!(" PROCHOT Config ({:#X}: {:#010X})", MSR_POWER_CTL, value); // Bidirectional PROCHOT lets the EC throttle the CPU by asserting PROCHOT# - println!( - " Bidirectional: {}", - yes_no(bit(value, 0)) - ); + println!(" Bidirectional: {}", yes_no(bit(value, 0))); println!(" Output Enabled: {}", yes_no(!bit(value, 21))); println!( " Response: {}", From e6650b637a2ad2550097dd185aef6656ab5588b8 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 21:38:04 +0800 Subject: [PATCH 03/13] fixup! fixup! prochot msr --- framework_lib/src/msr.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index afc6ba7a..3671096a 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -450,11 +450,16 @@ pub fn print_thermal_msrs() { "" } ); - println!( - " Fan Temp Target: {:>4} C (Offset {} C)", - target.ref_temp - target.fan_temp_offset, - target.fan_temp_offset - ); + // Our EC does its own fan control, so this is usually left at 0 (unused) + if target.fan_temp_offset > 0 { + println!( + " Fan Temp Target: {:>4} C (Offset {} C)", + target.ref_temp - target.fan_temp_offset, + target.fan_temp_offset + ); + } else { + debug!("Fan temperature target offset (T-Control) not programmed"); + } debug!("TEMPERATURE_TARGET locked: {}", target.locked); if cpuid.has_ptm { @@ -568,10 +573,12 @@ fn print_power_ctl() { // Bidirectional PROCHOT lets the EC throttle the CPU by asserting PROCHOT# println!(" Bidirectional: {}", yes_no(bit(value, 0))); println!(" Output Enabled: {}", yes_no(!bit(value, 21))); + // The reference code only documents this as "Prochot Configurable + // Response Enable", so don't claim to know what the response is println!( " Response: {}", if bit(value, 22) { - "Reduce by one P-State" + "Configurable" } else { "Throttle to minimum" } From b95c04a1974ffbf320733e9b7feb53f174b2b3b5 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 21:44:17 +0800 Subject: [PATCH 04/13] fixup! fixup! fixup! prochot msr --- framework_lib/src/msr.rs | 75 +++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 3671096a..b4acd7bd 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -76,7 +76,7 @@ const PLR_RING_BITS: &[(u32, &str)] = &[ ]; /// Status/log bit pairs shared by IA32_THERM_STATUS and -/// IA32_PACKAGE_THERM_STATUS. Bit 12/13 differs between the two. +/// IA32_PACKAGE_THERM_STATUS. The log bit is always one bit higher. const THERM_STATUS_BITS: &[(u32, &str)] = &[ (0, "Thermal Monitor"), (2, "PROCHOT/FORCEPR"), @@ -85,6 +85,10 @@ const THERM_STATUS_BITS: &[(u32, &str)] = &[ (8, "Threshold #2"), (10, "Power Limit"), ]; +/// Bits above 11 that only exist in the per core IA32_THERM_STATUS +const THERM_STATUS_CORE_BITS: &[(u32, &str)] = &[(12, "Current Limit"), (14, "Cross Domain Limit")]; +/// Bits above 11 that only exist in IA32_PACKAGE_THERM_STATUS +const THERM_STATUS_PKG_BITS: &[(u32, &str)] = &[(12, "Pmax Limit")]; fn bit(value: u64, bit: u32) -> bool { (value >> bit) & 1 == 1 @@ -471,22 +475,7 @@ pub fn print_thermal_msrs() { ); } println!(" Package Thermal Status ({:#010X})", pkg.raw); - println!(" {:<24} {:>6} {:>6}", "Condition", "Active", "Logged"); - for (b, name) in THERM_STATUS_BITS { - println!( - " {:<24} {:>6} {:>6}", - format!("{}:", name), - yes_no(bit(pkg.raw, *b)), - yes_no(bit(pkg.raw, b + 1)) - ); - } - // Bit 12/13 is Pmax in the package MSR, Current Limit per core - println!( - " {:<24} {:>6} {:>6}", - "Pmax Limit:", - yes_no(bit(pkg.raw, 12)), - yes_no(bit(pkg.raw, 13)) - ); + print_therm_status_table(pkg.raw, THERM_STATUS_PKG_BITS); } } @@ -506,16 +495,40 @@ pub fn print_thermal_msrs() { print_power_ctl(); } -/// Print the hottest core and which cores are currently being throttled +/// Print the Active/Logged table of a THERM_STATUS style MSR +/// +/// The bits above 11 differ between the per core and the package register, so +/// the caller passes those in. +fn print_therm_status_table(raw: u64, extra: &[(u32, &str)]) { + println!(" {:<24} {:>6} {:>6}", "Condition", "Active", "Logged"); + for (b, name) in THERM_STATUS_BITS.iter().chain(extra) { + println!( + " {:<24} {:>6} {:>6}", + format!("{}:", name), + yes_no(bit(raw, *b)), + yes_no(bit(raw, b + 1)) + ); + } +} + +/// Print the hottest core and the thermal status across all cores fn print_core_therm_status(ref_temp: u8) { let cpus = cpu_count(); let mut hottest: Option<(u32, i32)> = None; let mut throttling = Vec::new(); + // The status bits are per core, so OR them together to see whether any + // core ever hit a condition. Reading every core individually is far too + // much output on a system with dozens of them. + let mut any = 0; + let mut read = 0; for cpu in 0..cpus { let Some(status) = read_msr(cpu, MSR_IA32_THERM_STATUS).map(ThermStatus::from) else { continue; }; + read += 1; + // Mask off temperature, resolution and valid, they're not status bits + any |= status.raw & 0xFFFF; if let Some(temp) = status.temp(ref_temp) { match hottest { Some((_, hottest_temp)) if temp <= hottest_temp => {} @@ -527,19 +540,25 @@ fn print_core_therm_status(ref_temp: u8) { } } + if read == 0 { + return; + } if let Some((cpu, temp)) = hottest { println!(" Hottest Core Temp: {:>4} C (CPU {})", temp, cpu); } - if cpus > 0 { - println!( - " Cores Throttling: {:>4}", - if throttling.is_empty() { - "None".to_string() - } else { - throttling.join(", ") - } - ); - } + println!( + " Cores Throttling: {:>4}", + if throttling.is_empty() { + "None".to_string() + } else { + throttling.join(", ") + } + ); + println!( + " Core Thermal Status, any of {} cores ({:#06X})", + read, any + ); + print_therm_status_table(any, THERM_STATUS_CORE_BITS); } /// Print which limits are clipping the core, graphics and ring frequency From 2b288dfcb2adaf4244aae6dc6ffa17dea8c4a22e Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 21:50:40 +0800 Subject: [PATCH 05/13] print pl limits Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 280 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 275 insertions(+), 5 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index b4acd7bd..ffed61e9 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -34,6 +34,16 @@ pub const MSR_IA_PERF_LIMIT_REASONS: u32 = 0x0000064F; pub const MSR_GT_PERF_LIMIT_REASONS: u32 = 0x000006B0; /// Indicator of frequency clipping in the ring interconnect (a.k.a. CLR) pub const MSR_RING_PERF_LIMIT_REASONS: u32 = 0x000006B1; +/// PL4, the instantaneous peak limit (MSR_VR_CURRENT_CONFIG) +pub const MSR_VR_CURRENT_CONFIG: u32 = 0x00000601; +/// Unit multipliers for the RAPL registers (MSR_PACKAGE_POWER_SKU_UNIT) +pub const MSR_PACKAGE_POWER_SKU_UNIT: u32 = 0x00000606; +/// PL1 and PL2 package power limits (MSR_PACKAGE_RAPL_LIMIT) +pub const MSR_PACKAGE_RAPL_LIMIT: u32 = 0x00000610; +/// TDP and the power range of this SKU (MSR_PACKAGE_POWER_SKU) +pub const MSR_PACKAGE_POWER_SKU: u32 = 0x00000614; +/// PSys PL1 and PL2, limiting the whole platform instead of the package +pub const MSR_PLATFORM_POWER_LIMIT: u32 = 0x0000065C; /// Status bits of the *_PERF_LIMIT_REASONS MSRs /// @@ -188,14 +198,26 @@ pub fn cpuid() -> Option { } impl CpuId { - /// Whether the *_PERF_LIMIT_REASONS MSRs at their modern addresses exist + /// Intel Skylake (model 0x4E) or newer /// - /// They are not architectural, so we only read them on Skylake (model - /// 0x4E) and newer client processors. Reading a reserved MSR raises a - /// general protection fault, which is fatal in UEFI. - pub fn has_perf_limit_reasons(&self) -> bool { + /// Most of the MSRs we read are not architectural, so we check this before + /// touching them. Reading a reserved MSR raises a general protection + /// fault, which is fatal in UEFI. + pub fn skylake_or_newer(&self) -> bool { self.is_intel && self.family == 6 && self.model >= 0x4E } + + /// Whether the *_PERF_LIMIT_REASONS MSRs at their modern addresses exist + pub fn has_perf_limit_reasons(&self) -> bool { + self.skylake_or_newer() + } + + /// Whether the RAPL power limit MSRs exist + /// + /// RAPL was introduced with Sandy Bridge (model 0x2A). + pub fn has_rapl(&self) -> bool { + self.is_intel && self.family == 6 && self.model >= 0x2A + } } // ------------------------------------------------------------------------- @@ -415,6 +437,61 @@ impl ThermStatus { } } +/// Unit multipliers from MSR_PACKAGE_POWER_SKU_UNIT (0x606) +#[derive(Debug, Clone, Copy)] +pub struct RaplUnits { + /// Watts per LSB of the power fields, 1/8 W by default + pub power: f32, + /// Seconds per LSB of the time window fields, 1/1024 s by default + pub time: f32, +} + +impl From for RaplUnits { + fn from(msr: u64) -> Self { + Self { + power: 1.0 / (1u32 << (msr & 0xF)) as f32, + time: 1.0 / (1u32 << ((msr >> 16) & 0xF)) as f32, + } + } +} + +/// One power limit out of a RAPL limit register +#[derive(Debug, Clone, Copy)] +pub struct PowerLimit { + pub watts: f32, + pub enabled: bool, + /// Processor may go below the OS requested P-State to hold the limit + pub clamping: bool, + /// Averaging window, None if this limit has no time window field + pub time_window: Option, +} + +/// Decode the 7 bit time window field of a RAPL limit register +/// +/// Time Window = (1 + X/4) * 2^Y in units of `RaplUnits::time`, where Y is +/// bits 4:0 and X is bits 6:5 of the field. +fn time_window(field: u64, units: &RaplUnits) -> f32 { + let y = field & 0x1F; + let x = (field >> 5) & 0x3; + (1.0 + x as f32 / 4.0) * (1u64 << y) as f32 * units.time +} + +/// Decode one PL1/PL2 style limit out of a RAPL limit register +/// +/// The fields repeat every 32 bits, so `shift` is 0 for PL1 and 32 for PL2. +fn decode_limit(raw: u64, shift: u32, has_time: bool, units: &RaplUnits) -> PowerLimit { + PowerLimit { + watts: ((raw >> shift) & 0x7FFF) as f32 * units.power, + enabled: bit(raw, shift + 15), + clamping: bit(raw, shift + 16), + time_window: if has_time { + Some(time_window((raw >> (shift + 17)) & 0x7F, units)) + } else { + None + }, + } +} + // ------------------------------------------------------------------------- // Printing // ------------------------------------------------------------------------- @@ -483,6 +560,8 @@ pub fn print_thermal_msrs() { print_core_therm_status(target.ref_temp); } + print_power_limits(&cpuid); + if cpuid.has_perf_limit_reasons() { print_perf_limit_reasons(); } else { @@ -583,6 +662,97 @@ fn print_perf_limit_reasons() { } } +/// Print one PL1/PL2 style limit +fn print_limit(name: &str, limit: &PowerLimit) { + let mut notes = Vec::new(); + if !limit.enabled { + notes.push("Disabled".to_string()); + } + if limit.clamping { + notes.push("Clamping".to_string()); + } + if let Some(window) = limit.time_window { + notes.push(format!("{:.3} s window", window)); + } + println!( + " {:<20} {:>6.1} W {}", + format!("{}:", name), + limit.watts, + notes.join(", ") + ); +} + +/// Print the RAPL power limits, which is what the PkgPwr limiting reasons refer to +fn print_power_limits(cpuid: &CpuId) { + if !cpuid.has_rapl() { + debug!( + "No RAPL MSRs on family {:#X} model {:#X}", + cpuid.family, cpuid.model + ); + return; + } + let Some(units) = read_msr(0, MSR_PACKAGE_POWER_SKU_UNIT).map(RaplUnits::from) else { + return; + }; + + println!(" Power Limits"); + if let Some(sku) = read_msr(0, MSR_PACKAGE_POWER_SKU) { + println!( + " {:<20} {:>6.1} W", + "TDP (base power):", + (sku & 0x7FFF) as f32 * units.power + ); + // Not every SKU reports the power range + let min = ((sku >> 16) & 0x7FFF) as f32 * units.power; + let max = ((sku >> 32) & 0x7FFF) as f32 * units.power; + if max > 0.0 { + println!( + " {:<20} {:>6.1} W (Min {:.1} W)", + "SKU Max Power:", max, min + ); + } + } + + if let Some(raw) = read_msr(0, MSR_PACKAGE_RAPL_LIMIT) { + print_limit("PL1 (sustained)", &decode_limit(raw, 0, true, &units)); + print_limit("PL2 (burst)", &decode_limit(raw, 32, true, &units)); + if bit(raw, 63) { + println!(" {:<20} {:>6}", "PL1/PL2 Locked:", "Yes"); + } + } + + // These two are not on pre-Skylake processors + if !cpuid.skylake_or_newer() { + return; + } + + if let Some(raw) = read_msr(0, MSR_VR_CURRENT_CONFIG) { + // The reference code calls PL4 a power limit in Watts but defines the + // field in 0.125 A increments, so report Amps and include the raw value + println!( + " {:<20} {:>6.1} A ({:#06X}{})", + "PL4 (peak):", + (raw & 0xFFFF) as f32 * 0.125, + raw & 0xFFFF, + if bit(raw, 31) { ", Locked" } else { "" } + ); + } + + if let Some(raw) = read_msr(0, MSR_PLATFORM_POWER_LIMIT) { + // PSys is optional and often left unimplemented, then it reads as 0 + if raw & 0xFFFF_FFFF_FFFF != 0 { + print_limit("PSys PL1", &decode_limit(raw, 0, true, &units)); + // Bits 62:49 are reserved, PSys PL2 has no time window + print_limit("PSys PL2", &decode_limit(raw, 32, false, &units)); + if bit(raw, 63) { + println!(" {:<20} {:>6}", "PSys Locked:", "Yes"); + } + } else { + debug!("PSys power limits not implemented on this platform"); + } + } +} + /// Print how the processor is configured to react to PROCHOT fn print_power_ctl() { let Some(value) = read_msr(0, MSR_POWER_CTL) else { @@ -605,3 +775,103 @@ fn print_power_ctl() { println!(" VR Therm Alert: {}", yes_no(!bit(value, 24))); println!(" Locked: {}", yes_no(bit(value, 23))); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + // The default units from the reference code are 1/8 W and 1/1024 s + fn rapl_units() { + let units = RaplUnits::from(0x000A_0E03); + assert_eq!(units.power, 0.125); + assert_eq!(units.time, 1.0 / 1024.0); + // 1/2^0 for both + let units = RaplUnits::from(0); + assert_eq!(units.power, 1.0); + assert_eq!(units.time, 1.0); + } + + #[test] + // Time Window = (1 + X/4) * 2^Y, Y is bits 4:0 and X is bits 6:5 + fn rapl_time_window() { + let units = RaplUnits::from(0x000A_0E03); + // The reset default of 0xA is Y=10, X=0, so 1024 * 1/1024 s + assert_eq!(time_window(0x0A, &units), 1.0); + // Y=10, X=2 gives 1.5 * 1024 * 1/1024 s + assert_eq!(time_window(0x4A, &units), 1.5); + assert_eq!(time_window(0, &units), 1.0 / 1024.0); + } + + #[test] + // PL1 28 W with a 1 s window and clamping, PL2 64 W with a 2.44 ms window + fn rapl_limit() { + let units = RaplUnits::from(0x000A_0E03); + let raw = 0x8042_8200_0015_80E0; + let pl1 = decode_limit(raw, 0, true, &units); + assert_eq!(pl1.watts, 28.0); + assert!(pl1.enabled); + assert!(pl1.clamping); + assert_eq!(pl1.time_window, Some(1.0)); + + let pl2 = decode_limit(raw, 32, true, &units); + assert_eq!(pl2.watts, 64.0); + assert!(pl2.enabled); + assert!(!pl2.clamping); + // Y=1, X=1 gives 1.25 * 2 * 1/1024 s + assert_eq!(pl2.time_window, Some(1.25 * 2.0 / 1024.0)); + + // Bit 63 is the lock + assert!(bit(raw, 63)); + // A limit without a time window field + assert_eq!(decode_limit(raw, 32, false, &units).time_window, None); + } + + #[test] + // TjMax 100 C with an 8 C TCC offset, as seen on a Framework 13 + fn temperature_target() { + let target = TemperatureTarget::from(0x0864_0000); + assert_eq!(target.ref_temp, 100); + assert_eq!(target.tcc_offset, 8); + assert_eq!(target.fan_temp_offset, 0); + assert!(!target.locked); + } + + #[test] + // A real package status read: 46 C, only the power limit log bit set + fn therm_status() { + let status = ThermStatus::from(0x8836_0800); + assert!(status.valid); + assert_eq!(status.readout, 54); + assert_eq!(status.resolution, 1); + assert_eq!(status.temp(100), Some(46)); + assert!(!status.throttling()); + // Bit 11 is the power limitation log + assert!(bit(status.raw, 11)); + + // Without the valid bit there is no temperature + assert_eq!(ThermStatus::from(0x0836_0800).temp(100), None); + // Bit 0 thermal monitor and bit 2 PROCHOT both mean throttling + assert!(ThermStatus::from(0x8000_0001).throttling()); + assert!(ThermStatus::from(0x8000_0004).throttling()); + // Log bits alone are not current throttling + assert!(!ThermStatus::from(0x8000_000A).throttling()); + } + + #[test] + // The values from a Framework 13 with PROCHOT logged on all three domains + fn perf_limit_reasons() { + assert_eq!(decode_reasons(0x1803_0000, PLR_CORE_BITS, 0), "None"); + assert_eq!( + decode_reasons(0x1803_0000, PLR_CORE_BITS, 16), + "PROCHOT, Thermal, PkgPwrPL2, MaxTurboLimit" + ); + assert_eq!( + decode_reasons(0x1001_0000, PLR_GT_BITS, 16), + "PROCHOT, InefficientOperation" + ); + assert_eq!(decode_reasons(0x0001_0000, PLR_RING_BITS, 16), "PROCHOT"); + // The ring domain has no bit 12/13, so those must not be decoded + assert_eq!(decode_reasons(0x3000_0000, PLR_RING_BITS, 16), "None"); + } +} From 0c0f5681c55757d2495134857a8df1509d100bb4 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 21:54:55 +0800 Subject: [PATCH 06/13] foo Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index ffed61e9..152bfa6c 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -244,10 +244,16 @@ mod imp { Some(u64::from_le_bytes(buf)) } + /// Enumerate the directory instead of probing sequentially, so that an + /// offline CPU in the middle doesn't cut the enumeration short pub fn cpu_count() -> u32 { - (0..256) - .take_while(|cpu| File::open(format!("/dev/cpu/{}/msr", cpu)).is_ok()) - .count() as u32 + let Ok(dir) = std::fs::read_dir("/dev/cpu") else { + return 0; + }; + dir.flatten() + .filter_map(|entry| entry.file_name().to_str()?.parse::().ok()) + .max() + .map_or(0, |max| max + 1) } pub fn unavailable_hint() -> &'static str { @@ -288,10 +294,23 @@ mod imp { Some(args.data) } + /// Enumerate the devices instead of probing sequentially, so that an + /// offline CPU in the middle doesn't cut the enumeration short pub fn cpu_count() -> u32 { - (0..256) - .take_while(|cpu| File::open(format!("/dev/cpuctl{}", cpu)).is_ok()) - .count() as u32 + let Ok(dir) = std::fs::read_dir("/dev") else { + return 0; + }; + dir.flatten() + .filter_map(|entry| { + entry + .file_name() + .to_str()? + .strip_prefix("cpuctl")? + .parse::() + .ok() + }) + .max() + .map_or(0, |max| max + 1) } pub fn unavailable_hint() -> &'static str { @@ -739,8 +758,10 @@ fn print_power_limits(cpuid: &CpuId) { } if let Some(raw) = read_msr(0, MSR_PLATFORM_POWER_LIMIT) { - // PSys is optional and often left unimplemented, then it reads as 0 - if raw & 0xFFFF_FFFF_FFFF != 0 { + // PSys is optional. When the platform doesn't wire it up the limits + // read as zero, but the clamp and time window bits may still be set, + // so key off the enable bits only. + if bit(raw, 15) || bit(raw, 47) { print_limit("PSys PL1", &decode_limit(raw, 0, true, &units)); // Bits 62:49 are reserved, PSys PL2 has no time window print_limit("PSys PL2", &decode_limit(raw, 32, false, &units)); From fe525fb7d9c15c3a85bd1552b63445fe66ab0d4b Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 22:03:47 +0800 Subject: [PATCH 07/13] pl4 scaling Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 42 +++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 152bfa6c..e35beda3 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -746,30 +746,28 @@ fn print_power_limits(cpuid: &CpuId) { } if let Some(raw) = read_msr(0, MSR_VR_CURRENT_CONFIG) { - // The reference code calls PL4 a power limit in Watts but defines the - // field in 0.125 A increments, so report Amps and include the raw value + // The reference code describes the field in 0.125 A increments, but + // coreboot programs it as Watts scaled by the RAPL power unit (which + // is the same 1/8 by default) and so does the register description. println!( - " {:<20} {:>6.1} A ({:#06X}{})", + " {:<20} {:>6.1} W {}", "PL4 (peak):", - (raw & 0xFFFF) as f32 * 0.125, - raw & 0xFFFF, - if bit(raw, 31) { ", Locked" } else { "" } + (raw & 0xFFFF) as f32 * units.power, + if bit(raw, 31) { "Locked" } else { "" } ); } if let Some(raw) = read_msr(0, MSR_PLATFORM_POWER_LIMIT) { - // PSys is optional. When the platform doesn't wire it up the limits - // read as zero, but the clamp and time window bits may still be set, - // so key off the enable bits only. - if bit(raw, 15) || bit(raw, 47) { - print_limit("PSys PL1", &decode_limit(raw, 0, true, &units)); - // Bits 62:49 are reserved, PSys PL2 has no time window - print_limit("PSys PL2", &decode_limit(raw, 32, false, &units)); - if bit(raw, 63) { - println!(" {:<20} {:>6}", "PSys Locked:", "Yes"); - } - } else { - debug!("PSys power limits not implemented on this platform"); + // Whether PSys is enabled matters: it is the only limit that accounts + // for total platform power instead of just the package. With it off, + // nothing keeps the system inside the adapter budget proactively and + // the charger has to assert PROCHOT instead. So always report it, even + // (especially) when it's disabled. + print_limit("PSys PL1", &decode_limit(raw, 0, true, &units)); + // Bits 62:49 are reserved, PSys PL2 has no time window + print_limit("PSys PL2", &decode_limit(raw, 32, false, &units)); + if bit(raw, 63) { + println!(" {:<20} {:>6}", "PSys Locked:", "Yes"); } } } @@ -848,6 +846,14 @@ mod tests { assert_eq!(decode_limit(raw, 32, false, &units).time_window, None); } + #[test] + // PL4 is scaled by the RAPL power unit, like PL1 and PL2. 0x02E8 is the + // 93 W a Framework 13 reports. + fn rapl_pl4() { + let units = RaplUnits::from(0x000A_0E03); + assert_eq!((0x02E8 & 0xFFFF) as f32 * units.power, 93.0); + } + #[test] // TjMax 100 C with an 8 C TCC offset, as seen on a Framework 13 fn temperature_target() { From 0f48478b77ee0782ffebbcee16f2b2d067b14077 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 30 Jul 2026 23:25:19 +0800 Subject: [PATCH 08/13] prochot Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 48 +++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index e35beda3..108f2645 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -1,9 +1,15 @@ //! Read Intel thermal and performance limiting MSRs //! //! These tell us *why* the CPU is running slower than requested. Most -//! interesting on our systems is PROCHOT, which the EC asserts to throttle the -//! CPU, and the frequency clipping reasons, which record both the currently -//! active and the (sticky) previously seen limiting reasons. +//! interesting on our systems is PROCHOT, which the platform can assert to +//! throttle the CPU, and the frequency clipping reasons, which record both the +//! currently active and the (sticky) previously seen limiting reasons. +//! +//! Beware that the two disagree on Wildcat Lake: IA_PERF_LIMIT_REASONS reports +//! PROCHOT as an active *and* logged clipping reason across boots and repeated +//! reads, while PACKAGE_THERM_STATUS and IA32_THERM_STATUS show neither a +//! PROCHOT status nor a log bit. Which of the two is wrong is not established, +//! so we print both and point out the contradiction instead of picking one. //! //! Only Intel processors are supported. AMD does not expose comparable //! information through MSRs. @@ -562,6 +568,10 @@ pub fn print_thermal_msrs() { } debug!("TEMPERATURE_TARGET locked: {}", target.locked); + // Collect the PROCHOT status and log bits to cross check the frequency + // limit reasons against further down + let mut therm_status = 0; + if cpuid.has_ptm { if let Some(pkg) = read_msr(0, MSR_IA32_PACKAGE_THERM_STATUS).map(ThermStatus::from) { if let Some(temp) = pkg.temp(target.ref_temp) { @@ -572,17 +582,19 @@ pub fn print_thermal_msrs() { } println!(" Package Thermal Status ({:#010X})", pkg.raw); print_therm_status_table(pkg.raw, THERM_STATUS_PKG_BITS); + therm_status |= pkg.raw & 0xFFFF; } } if cpuid.has_dts { - print_core_therm_status(target.ref_temp); + therm_status |= print_core_therm_status(target.ref_temp); } print_power_limits(&cpuid); if cpuid.has_perf_limit_reasons() { - print_perf_limit_reasons(); + // Bit 2 is the PROCHOT/FORCEPR status and bit 3 its sticky log + print_perf_limit_reasons(bit(therm_status, 2) || bit(therm_status, 3)); } else { debug!( "No PERF_LIMIT_REASONS MSRs on family {:#X} model {:#X}", @@ -610,7 +622,9 @@ fn print_therm_status_table(raw: u64, extra: &[(u32, &str)]) { } /// Print the hottest core and the thermal status across all cores -fn print_core_therm_status(ref_temp: u8) { +/// +/// Returns the status bits OR'd across every core we could read. +fn print_core_therm_status(ref_temp: u8) -> u64 { let cpus = cpu_count(); let mut hottest: Option<(u32, i32)> = None; let mut throttling = Vec::new(); @@ -639,7 +653,7 @@ fn print_core_therm_status(ref_temp: u8) { } if read == 0 { - return; + return 0; } if let Some((cpu, temp)) = hottest { println!(" Hottest Core Temp: {:>4} C (CPU {})", temp, cpu); @@ -657,10 +671,15 @@ fn print_core_therm_status(ref_temp: u8) { read, any ); print_therm_status_table(any, THERM_STATUS_CORE_BITS); + any } /// Print which limits are clipping the core, graphics and ring frequency -fn print_perf_limit_reasons() { +/// +/// `prochot_seen` is whether IA32_THERM_STATUS or IA32_PACKAGE_THERM_STATUS +/// corroborate a PROCHOT assertion, either now or since the log was cleared. +fn print_perf_limit_reasons(prochot_seen: bool) { + let mut prochot_claimed = false; for (msr, name, bits) in [ (MSR_IA_PERF_LIMIT_REASONS, "Core", PLR_CORE_BITS), (MSR_GT_PERF_LIMIT_REASONS, "Graphics", PLR_GT_BITS), @@ -678,6 +697,19 @@ fn print_perf_limit_reasons() { " Logged: {}", decode_reasons(value, bits, 16) ); + // Bit 0 is the active and bit 16 the logged PROCHOT reason + prochot_claimed |= bit(value, 0) || bit(value, 16); + } + + // These MSRs and the THERM_STATUS ones should agree about PROCHOT. On + // Wildcat Lake they don't, so say so rather than let one of them be + // believed on its own. Which one is wrong is not established. + if prochot_claimed && !prochot_seen { + println!( + " Note: PROCHOT reported above is not corroborated by \ + THERM_STATUS ({:#X}/{:#X}), which shows no PROCHOT status or log", + MSR_IA32_THERM_STATUS, MSR_IA32_PACKAGE_THERM_STATUS + ); } } From f24fe725f1090c52548ea1c4e94fe131eff2dfe0 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Fri, 31 Jul 2026 09:38:31 +0800 Subject: [PATCH 09/13] thermal: Print current CPU frequency Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 184 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 108f2645..9dbb8b28 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -23,6 +23,12 @@ use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; +/// Maximum non-turbo and maximum efficiency ratio (MSR_PLATFORM_INFO) +pub const MSR_PLATFORM_INFO: u32 = 0x000000CE; +/// Counts at a fixed rate while the core is active (IA32_MPERF) +pub const MSR_IA32_MPERF: u32 = 0x000000E7; +/// Counts proportional to the actual frequency while active (IA32_APERF) +pub const MSR_IA32_APERF: u32 = 0x000000E8; /// Per core thermal status (IA32_THERM_STATUS) pub const MSR_IA32_THERM_STATUS: u32 = 0x0000019C; /// Thermal monitor reference temperature and offsets (IA32_TEMPERATURE_TARGET) @@ -148,6 +154,8 @@ pub struct CpuId { pub has_dts: bool, /// CPUID.06H:EAX[6] Package Thermal Management pub has_ptm: bool, + /// CPUID.06H:ECX[0] APERF/MPERF hardware coordination feedback + pub has_aperf: bool, } #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] @@ -181,12 +189,16 @@ pub fn cpuid() -> Option { // Leaf 6 (Thermal and Power Management) tells us whether the thermal // status MSRs exist. Only query it if the CPU supports that leaf. - let (has_dts, has_ptm) = if leaf_0.eax >= 6 { + let (has_dts, has_ptm, has_aperf) = if leaf_0.eax >= 6 { // SAFETY: Guarded by the maximum leaf reported above let leaf_6 = unsafe { __cpuid(6) }; - (leaf_6.eax & 1 == 1, (leaf_6.eax >> 6) & 1 == 1) + ( + leaf_6.eax & 1 == 1, + (leaf_6.eax >> 6) & 1 == 1, + leaf_6.ecx & 1 == 1, + ) } else { - (false, false) + (false, false, false) }; Some(CpuId { @@ -195,6 +207,7 @@ pub fn cpuid() -> Option { model, has_dts, has_ptm, + has_aperf, }) } @@ -203,6 +216,47 @@ pub fn cpuid() -> Option { None } +/// Frequency the TSC and MPERF count at, in MHz +/// +/// MPERF ticks at this rate whenever the core is active, so it is the scale +/// factor for turning an APERF/MPERF ratio into a frequency. +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +// __cpuid is safe since Rust 1.87, but our MSRV is 1.81 +#[allow(unused_unsafe)] +pub fn tsc_mhz() -> Option { + #[cfg(target_arch = "x86")] + use core::arch::x86::__cpuid; + #[cfg(target_arch = "x86_64")] + use core::arch::x86_64::__cpuid; + + // SAFETY: CPUID leaf 0 is available on every CPU we can be running on + let max_leaf = unsafe { __cpuid(0) }.eax; + + // Leaf 0x15 gives the TSC as crystal_hz * numerator / denominator + if max_leaf >= 0x15 { + // SAFETY: Guarded by the maximum leaf + let leaf = unsafe { __cpuid(0x15) }; + let (denominator, numerator, crystal_hz) = (leaf.eax, leaf.ebx, leaf.ecx); + if denominator != 0 && numerator != 0 && crystal_hz != 0 { + let hz = crystal_hz as u64 * numerator as u64 / denominator as u64; + return Some((hz / 1_000_000) as u32); + } + } + + // Otherwise the TSC runs at the maximum non-turbo (base) frequency + let base = PlatformInfo::from(read_msr(0, MSR_PLATFORM_INFO)?).base_mhz(); + if base > 0 { + Some(base) + } else { + None + } +} + +#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))] +pub fn tsc_mhz() -> Option { + None +} + impl CpuId { /// Intel Skylake (model 0x4E) or newer /// @@ -462,6 +516,75 @@ impl ThermStatus { } } +/// The ratios reported by MSR_PLATFORM_INFO (0xCE) +/// +/// All modern Intel client processors use a 100 MHz bus clock. +#[derive(Debug, Clone, Copy)] +pub struct PlatformInfo { + /// Maximum non-turbo ratio, a.k.a. the base frequency + pub base_ratio: u8, + /// Maximum efficiency ratio, a.k.a. LFM, the lowest the cores will run at + pub max_efficiency_ratio: u8, +} + +impl From for PlatformInfo { + fn from(msr: u64) -> Self { + Self { + base_ratio: ((msr >> 8) & 0xFF) as u8, + max_efficiency_ratio: ((msr >> 40) & 0xFF) as u8, + } + } +} + +impl PlatformInfo { + pub fn base_mhz(&self) -> u32 { + self.base_ratio as u32 * 100 + } + + pub fn lfm_mhz(&self) -> u32 { + self.max_efficiency_ratio as u32 * 100 + } +} + +/// Sample APERF and MPERF on every core and return the frequency each was +/// running at while it was active, in MHz +/// +/// This is the same "busy" frequency turbostat reports as Bzy_MHz, not the +/// requested or the average-over-wall-clock frequency. A core that stayed idle +/// for the whole interval reports None. +pub fn core_frequencies(tsc_mhz: u32, interval_micros: u64) -> Vec<(u32, Option)> { + let cpus = cpu_count(); + let sample = |cpu| { + Some(( + read_msr(cpu, MSR_IA32_APERF)?, + read_msr(cpu, MSR_IA32_MPERF)?, + )) + }; + + let first: Vec> = (0..cpus).map(sample).collect(); + crate::os_specific::sleep(interval_micros); + + (0..cpus) + .map(|cpu| { + let mhz = match (first[cpu as usize], sample(cpu)) { + (Some((aperf_0, mperf_0)), Some((aperf_1, mperf_1))) => { + let aperf = aperf_1.wrapping_sub(aperf_0); + let mperf = mperf_1.wrapping_sub(mperf_0); + // The ratio is self normalizing, so it doesn't matter that + // we read each core at a slightly different time. A core + // that never woke up doesn't advance MPERF at all, which + // checked_div turns into None. + (tsc_mhz as u64 * aperf) + .checked_div(mperf) + .map(|mhz| mhz as u32) + } + _ => None, + }; + (cpu, mhz) + }) + .collect() +} + /// Unit multipliers from MSR_PACKAGE_POWER_SKU_UNIT (0x606) #[derive(Debug, Clone, Copy)] pub struct RaplUnits { @@ -590,6 +713,12 @@ pub fn print_thermal_msrs() { therm_status |= print_core_therm_status(target.ref_temp); } + if cpuid.has_aperf { + print_core_frequencies(); + } else { + debug!("No APERF/MPERF support, skipping core frequencies"); + } + print_power_limits(&cpuid); if cpuid.has_perf_limit_reasons() { @@ -713,6 +842,45 @@ fn print_perf_limit_reasons(prochot_seen: bool) { } } +/// How long to sample APERF/MPERF over. Long enough to be stable, short +/// enough not to make --thermal feel sluggish. +const FREQ_SAMPLE_MICROS: u64 = 100_000; + +/// Print the frequency every core is currently running at +fn print_core_frequencies() { + let Some(tsc_mhz) = tsc_mhz() else { + debug!("Could not determine TSC frequency, skipping core frequencies"); + return; + }; + + let info = read_msr(0, MSR_PLATFORM_INFO).map(PlatformInfo::from); + let freqs = core_frequencies(tsc_mhz, FREQ_SAMPLE_MICROS); + if freqs.is_empty() { + return; + } + + println!( + " CPU Frequency (busy, {} ms sample)", + FREQ_SAMPLE_MICROS / 1000 + ); + if let Some(info) = info { + // Knowing where LFM and base are makes the numbers below meaningful. + // A core sitting at LFM is idle, not necessarily throttled. + println!( + " {:<20} {:>5} / {} MHz", + "LFM / Base:", + info.lfm_mhz(), + info.base_mhz() + ); + } + for (cpu, mhz) in freqs { + match mhz { + Some(mhz) => println!(" {:<20} {:>5} MHz", format!("CPU {}:", cpu), mhz), + None => println!(" {:<20} {:>5}", format!("CPU {}:", cpu), "Idle"), + } + } +} + /// Print one PL1/PL2 style limit fn print_limit(name: &str, limit: &PowerLimit) { let mut notes = Vec::new(); @@ -878,6 +1046,16 @@ mod tests { assert_eq!(decode_limit(raw, 32, false, &units).time_window, None); } + #[test] + // The Wildcat Lake value from a Framework 12: LFM 400, base 2000 MHz + fn platform_info() { + let info = PlatformInfo::from(0x0804043df8801400); + assert_eq!(info.base_ratio, 20); + assert_eq!(info.base_mhz(), 2000); + assert_eq!(info.max_efficiency_ratio, 4); + assert_eq!(info.lfm_mhz(), 400); + } + #[test] // PL4 is scaled by the RAPL power unit, like PL1 and PL2. 0x02E8 is the // 93 W a Framework 13 reports. From d93adffec0fe9536b0a3e6583f593828ba954948 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 3 Aug 2026 18:20:59 +0800 Subject: [PATCH 10/13] --thermal: Show psys max value Signed-off-by: Daniel Schaefer --- framework_lib/src/lib.rs | 1 + framework_lib/src/msr.rs | 15 ++ framework_lib/src/pcode.rs | 417 +++++++++++++++++++++++++++++++++++++ 3 files changed, 433 insertions(+) create mode 100644 framework_lib/src/pcode.rs diff --git a/framework_lib/src/lib.rs b/framework_lib/src/lib.rs index ae330100..2aceadb1 100644 --- a/framework_lib/src/lib.rs +++ b/framework_lib/src/lib.rs @@ -61,6 +61,7 @@ pub mod fw_uefi; pub mod msr; mod os_specific; pub mod parade_retimer; +pub mod pcode; pub mod power; #[cfg(not(feature = "uefi"))] pub mod smart_battery; diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 9dbb8b28..17d6711a 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -970,6 +970,21 @@ fn print_power_limits(cpuid: &CpuId) { println!(" {:<20} {:>6}", "PSys Locked:", "Yes"); } } + + // The full scale of all of the above PSys numbers isn't in an MSR, it only + // exists inside pcode. Worth reporting next to them, because it's what + // decides whether they mean anything. + match crate::pcode::psys_pmax() { + Some(watts) if watts > 0.0 => println!( + " {:<20} {:>6.1} W Full scale of the PSys readings", + "PSys Pmax:", watts + ), + Some(_) => println!( + " {:<20} {:>6} Firmware left it at the pcode default", + "PSys Pmax:", "Unset" + ), + None => info!("{}", crate::pcode::unavailable_hint()), + } } /// Print how the processor is configured to react to PROCHOT diff --git a/framework_lib/src/pcode.rs b/framework_lib/src/pcode.rs new file mode 100644 index 00000000..085cbbf6 --- /dev/null +++ b/framework_lib/src/pcode.rs @@ -0,0 +1,417 @@ +//! Read platform power configuration out of the pcode mailbox +//! +//! Some power management settings live neither in an MSR nor in a normal +//! register, but only inside pcode, which firmware configures through the BIOS +//! to pcode mailbox in MCHBAR. The interesting one for us is PMON PMAX, the +//! platform power that a full scale reading of the SoC's PSYS input stands for. +//! +//! The charger drives PSYS proportionally to total platform power (adapter plus +//! battery) and pcode maps that signal linearly onto PMAX. So PMAX is not the +//! AC adapter rating, even though the two are easily confused: it follows from +//! the charger's PSYS gain and the board's PSYS resistor. Get it wrong and +//! every platform power reading and every PSys power limit is off by the same +//! factor, in the direction that lets the platform draw more than the adapter +//! can supply until the charger asserts PROCHOT. +//! +//! coreboot programs it from the `psys_pmax_watts` devicetree register via the +//! FSP-M `PsysPmax` UPD. Left at 0, the FSP falls back to the value in the +//! SKU's PPM profile, which is 350 W for every Panther Lake profile and 0 +//! (keep whatever pcode defaults to) for Wildcat Lake. Reading it back is the +//! only way to tell which of those actually took effect. +//! +//! Only Intel processors have this mailbox. +//! +//! References: +//! - Panther Lake reference code, `Library/PeiDxeSmmCpuMailboxLib`, +//! `Include/Register/B2pMailbox.h` and `PeiVrLib.c` +//! - coreboot `src/soc/intel/pantherlake/systemagent.c` + +use crate::os_specific; + +/// MCHBAR, the base address register of the host bridge's MMIO range +const HOST_BRIDGE_MCHBAR: u32 = 0x48; +/// Bit 0 of MCHBAR, set while the range decodes +const MCHBAR_ENABLE: u64 = 1; +/// Address bits of MCHBAR +/// +/// The range is 32 KB on Skylake and 128 KB since Tiger Lake, so the low 15 +/// bits are reserved (and read as zero) on every processor we support. +const MCHBAR_ADDRESS_MASK: u64 = 0x0000_007F_FFFF_8000; + +/// Data register of the BIOS to pcode mailbox, an MCHBAR offset +const PCODE_MAILBOX_DATA: u64 = 0x5DA0; +/// Interface register of the BIOS to pcode mailbox, relative to the data one +const PCODE_MAILBOX_INTERFACE: usize = 4; +/// Set by the caller to hand a command over, cleared by pcode when it's done +const MAILBOX_RUN_BUSY: u32 = 1 << 31; +/// How long to wait for pcode, in microseconds +/// +/// The same timeout that coreboot and the reference code use. +const MAILBOX_TIMEOUT_US: u32 = 1000; + +/// Read and write the SVID voltage regulator configuration +const MAILBOX_CMD_SVID_VR_HANDLER: u32 = 0x18; +/// Subcommand of [MAILBOX_CMD_SVID_VR_HANDLER] to read the PSYS full scale power +const MAILBOX_SUBCMD_GET_PMON_PMAX: u32 = 0x0A; + +/// Completion code that pcode leaves in the interface register when it's done +fn completion_code(code: u32) -> &'static str { + match code { + 0x0 => "Success", + 0x1 => "Illegal command", + 0x2 => "Timeout", + 0x3 => "Illegal data", + 0x5 => "Illegal VR ID", + 0x6 => "Locked", + 0x7 => "VR error", + 0x8 => "Illegal subcommand", + _ => "Unknown", + } +} + +/// The two mailbox registers, mapped for 32 bit access +struct Mailbox { + map: imp::Mapping, +} + +impl Mailbox { + fn open() -> Option { + let mchbar = imp::host_bridge_read64(HOST_BRIDGE_MCHBAR)?; + if mchbar & MCHBAR_ENABLE == 0 { + info!("MCHBAR is not enabled ({:#X}), cannot reach pcode", mchbar); + return None; + } + let base = (mchbar & MCHBAR_ADDRESS_MASK) + PCODE_MAILBOX_DATA; + debug!("MCHBAR {:#X}, pcode mailbox at {:#X}", mchbar, base); + Some(Self { + map: imp::Mapping::new(base, 8)?, + }) + } + + /// Wait for pcode to release the mailbox + fn poll_ready(&self) -> bool { + for _ in 0..MAILBOX_TIMEOUT_US { + if self.map.read32(PCODE_MAILBOX_INTERFACE) & MAILBOX_RUN_BUSY == 0 { + return true; + } + os_specific::sleep(1); + } + false + } +} + +/// Run one pcode mailbox read command and return the data register +/// +/// Read commands only need the interface register written, pcode puts the +/// result in the data register. That write is a doorbell though, so this is not +/// a passive read: it hands a command to pcode the same way firmware does. +/// Nothing in the OS uses this mailbox, but we still follow the firmware +/// protocol and wait for it to go idle before claiming it. +/// +/// The reference code only runs mailbox commands on the bootstrap processor. +/// MCHBAR is package scoped, so it doesn't matter which core we happen to be +/// on. That rule is about serializing the users of the single mailbox. +fn mailbox_read(command: u32, param1: u32, param2: u32) -> Option { + let mailbox = Mailbox::open()?; + + if !mailbox.poll_ready() { + error!("pcode mailbox is busy"); + return None; + } + + let interface = (command & 0xFF) + | ((param1 & 0xFF) << 8) + | ((param2 & 0x1FFF) << 16) + | MAILBOX_RUN_BUSY; + debug!("pcode mailbox command {:#010X}", interface); + mailbox.map.write32(PCODE_MAILBOX_INTERFACE, interface); + + if !mailbox.poll_ready() { + error!("pcode mailbox command {:#010X} did not complete", interface); + return None; + } + + // pcode replaces the command field with the completion code + let code = mailbox.map.read32(PCODE_MAILBOX_INTERFACE) & 0xFF; + if code != 0 { + info!( + "pcode rejected mailbox command {:#010X}: {} ({:#X})", + interface, + completion_code(code), + code + ); + return None; + } + + let data = mailbox.map.read32(0); + debug!("pcode mailbox data {:#010X}", data); + Some(data) +} + +/// Decode the U10.6 fixed point Watts of the PMON PMAX mailbox data +/// +/// 10 integer and 6 fractional bits, so up to 1024 W in 1/64 W steps. +fn pmon_pmax_watts(data: u32) -> f32 { + (data & 0xFFFF) as f32 / 64.0 +} + +/// PSYS full scale power in Watts, as programmed into pcode by firmware +/// +/// This is the platform power that pcode takes a full scale PSYS reading to +/// mean, the top of the scale for every PSys measurement and limit. Not the AC +/// adapter rating, see the module documentation. +/// +/// `None` if we can't read it, `Some(0.0)` if pcode has none programmed. +pub fn psys_pmax() -> Option { + mailbox_read( + MAILBOX_CMD_SVID_VR_HANDLER, + MAILBOX_SUBCMD_GET_PMON_PMAX, + 0, + ) + .map(pmon_pmax_watts) +} + +/// Why we can't reach the pcode mailbox on this platform, for logging +pub fn unavailable_hint() -> &'static str { + imp::unavailable_hint() +} + +// Linux gives us the host bridge's config space through sysfs and physical +// memory through /dev/mem. Both need root. +#[cfg(all(target_os = "linux", any(target_arch = "x86", target_arch = "x86_64")))] +mod imp { + use std::fs::{File, OpenOptions}; + use std::io::{Read, Seek, SeekFrom}; + use std::os::unix::io::AsRawFd; + use std::ptr; + + const HOST_BRIDGE_CONFIG: &str = "/sys/bus/pci/devices/0000:00:00.0/config"; + + /// Read a 64 bit register of the host bridge's PCI configuration space + /// + /// Anything above the standard 64 byte header needs CAP_SYS_ADMIN, so this + /// only works as root. + pub fn host_bridge_read64(offset: u32) -> Option { + let mut file = match File::open(HOST_BRIDGE_CONFIG) { + Ok(file) => file, + Err(err) => { + info!("Cannot open {}: {}", HOST_BRIDGE_CONFIG, err); + return None; + } + }; + let mut buf = [0u8; 8]; + if let Err(err) = file + .seek(SeekFrom::Start(offset as u64)) + .and_then(|_| file.read_exact(&mut buf)) + { + info!("Cannot read host bridge config {:#X}: {}", offset, err); + return None; + } + Some(u64::from_le_bytes(buf)) + } + + /// A mapping of physical memory, for volatile 32 bit register access + pub struct Mapping { + page: *mut libc::c_void, + page_len: usize, + /// Where the requested address ended up inside the mapping + offset: usize, + len: usize, + } + + impl Mapping { + /// Map `len` bytes of physical memory starting at `phys` + pub fn new(phys: u64, len: usize) -> Option { + // SAFETY: sysconf() with a valid name has no preconditions + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; + let page_base = phys & !(page_size - 1); + let offset = (phys - page_base) as usize; + let page_len = ((offset + len) as u64).div_ceil(page_size) * page_size; + + let file = match OpenOptions::new().read(true).write(true).open("/dev/mem") { + Ok(file) => file, + Err(err) => { + info!("Cannot open /dev/mem: {}", err); + return None; + } + }; + + // SAFETY: A null hint lets the kernel pick the address and the + // length is a multiple of the page size. The mapping is only used + // through self.reg(), which stays inside it. + let page = unsafe { + libc::mmap( + ptr::null_mut(), + page_len as usize, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_SHARED, + file.as_raw_fd(), + page_base as libc::off_t, + ) + }; + if page == libc::MAP_FAILED { + error!( + "Cannot map {:#X} from /dev/mem: {}", + page_base, + std::io::Error::last_os_error() + ); + return None; + } + + Some(Self { + page, + page_len: page_len as usize, + offset, + len, + }) + } + + fn reg(&self, offset: usize) -> *mut u32 { + assert!(offset + 4 <= self.len); + // SAFETY: Asserted to be inside the mapping + unsafe { (self.page as *mut u8).add(self.offset + offset) as *mut u32 } + } + + pub fn read32(&self, offset: usize) -> u32 { + // SAFETY: reg() returns an aligned pointer into the mapping + unsafe { ptr::read_volatile(self.reg(offset)) } + } + + pub fn write32(&self, offset: usize, value: u32) { + // SAFETY: reg() returns an aligned pointer into the mapping + unsafe { ptr::write_volatile(self.reg(offset), value) } + } + } + + impl Drop for Mapping { + fn drop(&mut self) { + // SAFETY: Unmapping exactly what new() mapped + unsafe { + libc::munmap(self.page, self.page_len); + } + } + } + + pub fn unavailable_hint() -> &'static str { + "Must be root to read the pcode mailbox" + } +} + +#[cfg(all(target_os = "uefi", any(target_arch = "x86", target_arch = "x86_64")))] +mod imp { + use core::arch::asm; + use core::ptr; + + /// Address and data port of legacy PCI configuration space access + const PCI_CONFIG_ADDRESS: u16 = 0xCF8; + const PCI_CONFIG_DATA: u16 = 0xCFC; + + /// Read a 32 bit register of the host bridge (bus 0, device 0, function 0) + fn host_bridge_read32(offset: u32) -> u32 { + // Bit 31 enables the configuration cycle, bus/device/function are 0 + let address = 0x8000_0000 | (offset & 0xFC); + let value: u32; + // SAFETY: Reading PCI configuration space has no side effects + unsafe { + asm!( + "out dx, eax", + in("dx") PCI_CONFIG_ADDRESS, + in("eax") address, + options(nomem, nostack, preserves_flags), + ); + asm!( + "in eax, dx", + in("dx") PCI_CONFIG_DATA, + out("eax") value, + options(nomem, nostack, preserves_flags), + ); + } + value + } + + pub fn host_bridge_read64(offset: u32) -> Option { + let lo = host_bridge_read32(offset); + let hi = host_bridge_read32(offset + 4); + Some(((hi as u64) << 32) | (lo as u64)) + } + + /// Physical memory is identity mapped in UEFI, so there's nothing to map + pub struct Mapping { + base: u64, + len: usize, + } + + impl Mapping { + pub fn new(phys: u64, len: usize) -> Option { + Some(Self { base: phys, len }) + } + + fn reg(&self, offset: usize) -> *mut u32 { + assert!(offset + 4 <= self.len); + (self.base as usize + offset) as *mut u32 + } + + pub fn read32(&self, offset: usize) -> u32 { + // SAFETY: MCHBAR is identity mapped MMIO, so the address is valid + unsafe { ptr::read_volatile(self.reg(offset)) } + } + + pub fn write32(&self, offset: usize, value: u32) { + // SAFETY: MCHBAR is identity mapped MMIO, so the address is valid + unsafe { ptr::write_volatile(self.reg(offset), value) } + } + } + + pub fn unavailable_hint() -> &'static str { + "Cannot reach the pcode mailbox" + } +} + +// Windows needs a kernel driver for MMIO and we don't ship one. FreeBSD has no +// sysfs to get MCHBAR from. Also covers non-x86, which has no pcode at all. +#[cfg(not(any( + all(target_os = "linux", any(target_arch = "x86", target_arch = "x86_64")), + all(target_os = "uefi", any(target_arch = "x86", target_arch = "x86_64")), +)))] +mod imp { + pub fn host_bridge_read64(_offset: u32) -> Option { + None + } + + pub struct Mapping; + + impl Mapping { + pub fn new(_phys: u64, _len: usize) -> Option { + None + } + + pub fn read32(&self, _offset: usize) -> u32 { + 0 + } + + pub fn write32(&self, _offset: usize, _value: u32) {} + } + + pub fn unavailable_hint() -> &'static str { + "Reading the pcode mailbox is not supported on this platform" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pmon_pmax_watts() { + // What coreboot's psys_pmax_watts = 218 ends up as: 218 W in 1/8 W + // units through the FSP, then U10.6 fixed point in the mailbox + assert_eq!(pmon_pmax_watts(218 * 64), 218.0); + // The 350 W that every Panther Lake PPM profile defaults to + assert_eq!(pmon_pmax_watts(2800 * 8), 350.0); + // Fractional and unprogrammed + assert_eq!(pmon_pmax_watts(0x0020), 0.5); + assert_eq!(pmon_pmax_watts(0), 0.0); + // Reserved upper bits are ignored, the field is 16 bits + assert_eq!(pmon_pmax_watts(0xFFFF_0000 | (100 * 64)), 100.0); + } +} From 6ef9ee3ddfa1e16c4029ff23ae885a7e3a458527 Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 3 Aug 2026 18:27:48 +0800 Subject: [PATCH 11/13] --thermal: Print some more limits Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 80 +++++++-- framework_lib/src/pcode.rs | 321 ++++++++++++++++++++++++++----------- 2 files changed, 299 insertions(+), 102 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 17d6711a..cf5ff2d8 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -971,20 +971,80 @@ fn print_power_limits(cpuid: &CpuId) { } } - // The full scale of all of the above PSys numbers isn't in an MSR, it only - // exists inside pcode. Worth reporting next to them, because it's what - // decides whether they mean anything. - match crate::pcode::psys_pmax() { - Some(watts) if watts > 0.0 => println!( - " {:<20} {:>6.1} W Full scale of the PSys readings", - "PSys Pmax:", watts - ), + // The full scale of all of the above PSys numbers, and the platform's + // current limits, aren't in MSRs. Report them next to the limits anyway, + // because the full scale is what decides whether they mean anything. + print_platform_power(&units); +} + +/// Print the platform power delivery configuration that isn't in any MSR +/// +/// `units` is only needed for the Isys time window, which is encoded like a +/// RAPL one. +fn print_platform_power(units: &RaplUnits) { + let Some(power) = crate::pcode::platform_power() else { + info!("{}", crate::pcode::unavailable_hint()); + return; + }; + + match power.psys { + Some(psys) if psys.pmax > 0.0 => { + println!( + " {:<20} {:>6.1} W Full scale of the PSys readings", + "PSys Pmax:", psys.pmax + ); + // Both corrections are left at Auto (0) unless a board needs them + if psys.offset != 0.0 { + println!(" {:<20} {:>6.2} W", "PSys Offset:", psys.offset); + } + if psys.slope != 0.0 { + println!(" {:<20} {:>6.2} x", "PSys Slope:", psys.slope); + } + debug!("PSys offset {} W, slope {}", psys.offset, psys.slope); + } Some(_) => println!( - " {:<20} {:>6} Firmware left it at the pcode default", + " {:<20} {:>6} Firmware left it at the pcode default", "PSys Pmax:", "Unset" ), - None => info!("{}", crate::pcode::unavailable_hint()), + None => debug!("pcode does not report a PSys full scale"), + } + + // Vsys Max and the Isys limits are only programmed when the platform uses + // the ThETA Ibatt feature to limit battery discharge current + match power.vsys_max { + Some(volts) if volts > 0.0 => { + println!(" {:<20} {:>6.1} V", "Vsys Max:", volts) + } + _ => debug!("No maximum system voltage programmed"), } + + if let Some(isys) = power.isys { + if isys.l1_amps > 0.0 || isys.l2_amps > 0.0 { + print_isys_limit("Isys Limit L1", isys.l1_amps, isys.l1_enabled, { + Some(time_window(isys.l1_tau, units)) + }); + print_isys_limit("Isys Limit L2", isys.l2_amps, isys.l2_enabled, None); + } else { + debug!("No Isys current limits programmed"); + } + } +} + +/// Print one of the two Isys current limits, in the style of [print_limit] +fn print_isys_limit(name: &str, amps: f32, enabled: bool, time_window: Option) { + let mut notes = Vec::new(); + if !enabled { + notes.push("Disabled".to_string()); + } + if let Some(window) = time_window { + notes.push(format!("{:.3} s window", window)); + } + println!( + " {:<20} {:>6.1} A {}", + format!("{}:", name), + amps, + notes.join(", ") + ); } /// Print how the processor is configured to react to PROCHOT diff --git a/framework_lib/src/pcode.rs b/framework_lib/src/pcode.rs index 085cbbf6..5da8e837 100644 --- a/framework_lib/src/pcode.rs +++ b/framework_lib/src/pcode.rs @@ -1,4 +1,4 @@ -//! Read platform power configuration out of the pcode mailbox +//! Read platform power delivery configuration out of MCHBAR and pcode //! //! Some power management settings live neither in an MSR nor in a normal //! register, but only inside pcode, which firmware configures through the BIOS @@ -19,11 +19,16 @@ //! (keep whatever pcode defaults to) for Wildcat Lake. Reading it back is the //! only way to tell which of those actually took effect. //! -//! Only Intel processors have this mailbox. +//! The PSys power limits themselves are in MSR_PLATFORM_POWER_LIMIT, see +//! [crate::msr], and so is PL4. What's left over and only reachable here is the +//! rest of the PSYS calibration (offset and slope), the maximum system voltage, +//! and the Isys battery current limits. +//! +//! Only Intel processors have any of this. //! //! References: //! - Panther Lake reference code, `Library/PeiDxeSmmCpuMailboxLib`, -//! `Include/Register/B2pMailbox.h` and `PeiVrLib.c` +//! `Include/Register/B2pMailbox.h`, `PeiVrLib.c` and `PowerLimits.c` //! - coreboot `src/soc/intel/pantherlake/systemagent.c` use crate::os_specific; @@ -38,10 +43,17 @@ const MCHBAR_ENABLE: u64 = 1; /// bits are reserved (and read as zero) on every processor we support. const MCHBAR_ADDRESS_MASK: u64 = 0x0000_007F_FFFF_8000; -/// Data register of the BIOS to pcode mailbox, an MCHBAR offset +/// The MCHBAR page we map, holding every register below +const WINDOW_BASE: u64 = 0x5000; +const WINDOW_LEN: usize = 0x1000; + +/// Data register of the BIOS to pcode mailbox const PCODE_MAILBOX_DATA: u64 = 0x5DA0; -/// Interface register of the BIOS to pcode mailbox, relative to the data one -const PCODE_MAILBOX_INTERFACE: usize = 4; +/// Interface register of the BIOS to pcode mailbox +const PCODE_MAILBOX_INTERFACE: u64 = 0x5DA4; +/// Isys (battery) current limits, the ThETA Ibatt feature. 64 bit. +const ISYS_CONTROL: u64 = 0x5E90; + /// Set by the caller to hand a command over, cleared by pcode when it's done const MAILBOX_RUN_BUSY: u32 = 1 << 31; /// How long to wait for pcode, in microseconds @@ -51,10 +63,14 @@ const MAILBOX_TIMEOUT_US: u32 = 1000; /// Read and write the SVID voltage regulator configuration const MAILBOX_CMD_SVID_VR_HANDLER: u32 = 0x18; -/// Subcommand of [MAILBOX_CMD_SVID_VR_HANDLER] to read the PSYS full scale power +/// Read the PSYS offset and slope correction +const MAILBOX_SUBCMD_GET_PMON_CONFIG: u32 = 0x19; +/// Read the PSYS full scale power const MAILBOX_SUBCMD_GET_PMON_PMAX: u32 = 0x0A; +/// Read the maximum system voltage +const MAILBOX_SUBCMD_GET_VSYS_MAX: u32 = 0x28; -/// Completion code that pcode leaves in the interface register when it's done +/// What pcode leaves in the command field of the interface register when done fn completion_code(code: u32) -> &'static str { match code { 0x0 => "Success", @@ -69,109 +85,213 @@ fn completion_code(code: u32) -> &'static str { } } -/// The two mailbox registers, mapped for 32 bit access -struct Mailbox { +// ------------------------------------------------------------------------- +// Decoding +// ------------------------------------------------------------------------- + +/// Decode unsigned fixed point with `fraction_bits` fractional bits +/// +/// The mailbox describes its fields as U16.x.y, so U10.6 is 10 integer and 6 +/// fractional bits out of the 16 bit field. +fn unsigned_fixed_point(field: u32, fraction_bits: u32) -> f32 { + (field & 0xFFFF) as f32 / (1u32 << fraction_bits) as f32 +} + +/// Decode signed (2's complement) fixed point with `fraction_bits` fractional bits +fn signed_fixed_point(field: u32, fraction_bits: u32) -> f32 { + ((field & 0xFFFF) as u16) as i16 as f32 / (1u32 << fraction_bits) as f32 +} + +/// The PSYS calibration that pcode applies to the charger's PSYS signal +#[derive(Debug, Clone, Copy)] +pub struct PsysConfig { + /// Platform power at full scale PSYS, in Watts. 0 if pcode has none. + /// + /// PMON PMAX, U10.6 fixed point, so up to 1024 W in 1/64 W steps. + pub pmax: f32, + /// Offset correction of the PSYS reading, in Watts + /// + /// S7.8 fixed point. 0 unless firmware programmed one. + pub offset: f32, + /// Slope correction of the PSYS reading, 1.0 being none + /// + /// U1.15 fixed point. 0 unless firmware programmed one. + pub slope: f32, +} + +/// The Isys (battery) current limits of the ThETA Ibatt feature +/// +/// Two levels, each with a current limit in Amps, only the first one with a +/// time window. Firmware only programs these while on battery, so they read +/// back disabled on AC. +#[derive(Debug, Clone, Copy)] +pub struct IsysControl { + /// Level 1 limit in Amps, the field is in 1/8 A + pub l1_amps: f32, + pub l1_enabled: bool, + /// Raw 7 bit time window field of the level 1 limit + /// + /// Encoded like a RAPL time window, so decode it with the time unit from + /// MSR_PACKAGE_POWER_SKU_UNIT. + pub l1_tau: u64, + /// Level 2 limit in Amps, the field is in 1/8 A + pub l2_amps: f32, + pub l2_enabled: bool, +} + +/// Platform power delivery configuration, as firmware left it +#[derive(Debug, Clone, Copy)] +pub struct PlatformPower { + pub psys: Option, + /// Maximum system voltage in Volts, U10.6 fixed point + /// + /// Only programmed together with [IsysControl]. + pub vsys_max: Option, + pub isys: Option, +} + +// ------------------------------------------------------------------------- +// MCHBAR and the pcode mailbox +// ------------------------------------------------------------------------- + +/// A mapping of the MCHBAR page holding the registers we're after +struct Mchbar { map: imp::Mapping, } -impl Mailbox { +impl Mchbar { fn open() -> Option { let mchbar = imp::host_bridge_read64(HOST_BRIDGE_MCHBAR)?; if mchbar & MCHBAR_ENABLE == 0 { info!("MCHBAR is not enabled ({:#X}), cannot reach pcode", mchbar); return None; } - let base = (mchbar & MCHBAR_ADDRESS_MASK) + PCODE_MAILBOX_DATA; - debug!("MCHBAR {:#X}, pcode mailbox at {:#X}", mchbar, base); + let base = (mchbar & MCHBAR_ADDRESS_MASK) + WINDOW_BASE; + debug!("MCHBAR {:#X}, mapping {:#X}", mchbar, base); Some(Self { - map: imp::Mapping::new(base, 8)?, + map: imp::Mapping::new(base, WINDOW_LEN)?, }) } + fn read32(&self, offset: u64) -> u32 { + self.map.read32((offset - WINDOW_BASE) as usize) + } + + fn write32(&self, offset: u64, value: u32) { + self.map.write32((offset - WINDOW_BASE) as usize, value) + } + + /// Read a 64 bit register as its two halves + /// + /// Everything we read this way is static configuration, so it doesn't + /// matter that the two halves aren't sampled at the same instant. + fn read64(&self, offset: u64) -> u64 { + ((self.read32(offset + 4) as u64) << 32) | (self.read32(offset) as u64) + } + /// Wait for pcode to release the mailbox - fn poll_ready(&self) -> bool { + fn poll_mailbox_ready(&self) -> bool { for _ in 0..MAILBOX_TIMEOUT_US { - if self.map.read32(PCODE_MAILBOX_INTERFACE) & MAILBOX_RUN_BUSY == 0 { + if self.read32(PCODE_MAILBOX_INTERFACE) & MAILBOX_RUN_BUSY == 0 { return true; } os_specific::sleep(1); } false } -} -/// Run one pcode mailbox read command and return the data register -/// -/// Read commands only need the interface register written, pcode puts the -/// result in the data register. That write is a doorbell though, so this is not -/// a passive read: it hands a command to pcode the same way firmware does. -/// Nothing in the OS uses this mailbox, but we still follow the firmware -/// protocol and wait for it to go idle before claiming it. -/// -/// The reference code only runs mailbox commands on the bootstrap processor. -/// MCHBAR is package scoped, so it doesn't matter which core we happen to be -/// on. That rule is about serializing the users of the single mailbox. -fn mailbox_read(command: u32, param1: u32, param2: u32) -> Option { - let mailbox = Mailbox::open()?; - - if !mailbox.poll_ready() { - error!("pcode mailbox is busy"); - return None; - } + /// Run one pcode mailbox read command and return the data register + /// + /// Read commands only need the interface register written, pcode puts the + /// result in the data register. That write is a doorbell though, so this is + /// not a passive read: it hands a command to pcode the same way firmware + /// does. Nothing in the OS uses this mailbox, but we still follow the + /// firmware protocol and wait for it to go idle before claiming it. + /// + /// The reference code only runs mailbox commands on the bootstrap + /// processor. MCHBAR is package scoped, so it doesn't matter which core we + /// happen to be on. That rule is about serializing users of the one mailbox. + fn mailbox_read(&self, command: u32, param1: u32, param2: u32) -> Option { + if !self.poll_mailbox_ready() { + error!("pcode mailbox is busy"); + return None; + } - let interface = (command & 0xFF) - | ((param1 & 0xFF) << 8) - | ((param2 & 0x1FFF) << 16) - | MAILBOX_RUN_BUSY; - debug!("pcode mailbox command {:#010X}", interface); - mailbox.map.write32(PCODE_MAILBOX_INTERFACE, interface); + let interface = (command & 0xFF) + | ((param1 & 0xFF) << 8) + | ((param2 & 0x1FFF) << 16) + | MAILBOX_RUN_BUSY; + debug!("pcode mailbox command {:#010X}", interface); + self.write32(PCODE_MAILBOX_INTERFACE, interface); - if !mailbox.poll_ready() { - error!("pcode mailbox command {:#010X} did not complete", interface); - return None; - } + if !self.poll_mailbox_ready() { + error!("pcode mailbox command {:#010X} did not complete", interface); + return None; + } - // pcode replaces the command field with the completion code - let code = mailbox.map.read32(PCODE_MAILBOX_INTERFACE) & 0xFF; - if code != 0 { - info!( - "pcode rejected mailbox command {:#010X}: {} ({:#X})", - interface, - completion_code(code), - code - ); - return None; - } + // pcode replaces the command field with the completion code + let code = self.read32(PCODE_MAILBOX_INTERFACE) & 0xFF; + if code != 0 { + info!( + "pcode rejected mailbox command {:#010X}: {} ({:#X})", + interface, + completion_code(code), + code + ); + return None; + } - let data = mailbox.map.read32(0); - debug!("pcode mailbox data {:#010X}", data); - Some(data) -} + let data = self.read32(PCODE_MAILBOX_DATA); + debug!("pcode mailbox data {:#010X}", data); + Some(data) + } -/// Decode the U10.6 fixed point Watts of the PMON PMAX mailbox data -/// -/// 10 integer and 6 fractional bits, so up to 1024 W in 1/64 W steps. -fn pmon_pmax_watts(data: u32) -> f32 { - (data & 0xFFFF) as f32 / 64.0 + fn vr(&self, subcommand: u32) -> Option { + self.mailbox_read(MAILBOX_CMD_SVID_VR_HANDLER, subcommand, 0) + } } -/// PSYS full scale power in Watts, as programmed into pcode by firmware -/// -/// This is the platform power that pcode takes a full scale PSYS reading to -/// mean, the top of the scale for every PSys measurement and limit. Not the AC -/// adapter rating, see the module documentation. +/// Read back the platform power delivery configuration firmware programmed /// -/// `None` if we can't read it, `Some(0.0)` if pcode has none programmed. -pub fn psys_pmax() -> Option { - mailbox_read( - MAILBOX_CMD_SVID_VR_HANDLER, - MAILBOX_SUBCMD_GET_PMON_PMAX, - 0, - ) - .map(pmon_pmax_watts) +/// `None` if we can't reach MCHBAR at all, see [unavailable_hint]. The +/// individual values are `None` when pcode refused the command, which is how a +/// processor without that particular knob answers. +pub fn platform_power() -> Option { + let mchbar = Mchbar::open()?; + + let psys = mchbar.vr(MAILBOX_SUBCMD_GET_PMON_PMAX).map(|pmax| { + // Offset and slope are a separate command and only interesting + // alongside a full scale, so don't fail the whole thing over them + let config = mchbar.vr(MAILBOX_SUBCMD_GET_PMON_CONFIG).unwrap_or(0); + PsysConfig { + pmax: unsigned_fixed_point(pmax, 6), + offset: signed_fixed_point(config, 8), + slope: unsigned_fixed_point(config >> 16, 15), + } + }); + + let vsys_max = mchbar + .vr(MAILBOX_SUBCMD_GET_VSYS_MAX) + .map(|data| unsigned_fixed_point(data, 6)); + + let raw = mchbar.read64(ISYS_CONTROL); + debug!("ISYS_CONTROL ({:#X}): {:#018X}", ISYS_CONTROL, raw); + let isys = Some(IsysControl { + l1_amps: (raw & 0x7FFF) as f32 / 8.0, + l1_enabled: raw & (1 << 15) != 0, + l1_tau: (raw >> 16) & 0x7F, + l2_amps: ((raw >> 32) & 0x7FFF) as f32 / 8.0, + l2_enabled: raw & (1 << 47) != 0, + }); + + Some(PlatformPower { + psys, + vsys_max, + isys, + }) } -/// Why we can't reach the pcode mailbox on this platform, for logging +/// Why we can't reach MCHBAR on this platform, for logging pub fn unavailable_hint() -> &'static str { imp::unavailable_hint() } @@ -226,7 +346,7 @@ mod imp { let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as u64; let page_base = phys & !(page_size - 1); let offset = (phys - page_base) as usize; - let page_len = ((offset + len) as u64).div_ceil(page_size) * page_size; + let page_len = (((offset + len) as u64).div_ceil(page_size) * page_size) as usize; let file = match OpenOptions::new().read(true).write(true).open("/dev/mem") { Ok(file) => file, @@ -242,7 +362,7 @@ mod imp { let page = unsafe { libc::mmap( ptr::null_mut(), - page_len as usize, + page_len, libc::PROT_READ | libc::PROT_WRITE, libc::MAP_SHARED, file.as_raw_fd(), @@ -260,7 +380,7 @@ mod imp { Some(Self { page, - page_len: page_len as usize, + page_len, offset, len, }) @@ -293,7 +413,7 @@ mod imp { } pub fn unavailable_hint() -> &'static str { - "Must be root to read the pcode mailbox" + "Must be root to read MCHBAR" } } @@ -363,12 +483,12 @@ mod imp { } pub fn unavailable_hint() -> &'static str { - "Cannot reach the pcode mailbox" + "Cannot reach MCHBAR" } } // Windows needs a kernel driver for MMIO and we don't ship one. FreeBSD has no -// sysfs to get MCHBAR from. Also covers non-x86, which has no pcode at all. +// sysfs to get MCHBAR from. Also covers non-x86, which has no MCHBAR at all. #[cfg(not(any( all(target_os = "linux", any(target_arch = "x86", target_arch = "x86_64")), all(target_os = "uefi", any(target_arch = "x86", target_arch = "x86_64")), @@ -393,7 +513,7 @@ mod imp { } pub fn unavailable_hint() -> &'static str { - "Reading the pcode mailbox is not supported on this platform" + "Reading MCHBAR is not supported on this platform" } } @@ -402,16 +522,33 @@ mod tests { use super::*; #[test] - fn test_pmon_pmax_watts() { + fn test_pmon_pmax() { // What coreboot's psys_pmax_watts = 218 ends up as: 218 W in 1/8 W // units through the FSP, then U10.6 fixed point in the mailbox - assert_eq!(pmon_pmax_watts(218 * 64), 218.0); + assert_eq!(unsigned_fixed_point(218 * 64, 6), 218.0); // The 350 W that every Panther Lake PPM profile defaults to - assert_eq!(pmon_pmax_watts(2800 * 8), 350.0); + assert_eq!(unsigned_fixed_point(2800 * 8, 6), 350.0); // Fractional and unprogrammed - assert_eq!(pmon_pmax_watts(0x0020), 0.5); - assert_eq!(pmon_pmax_watts(0), 0.0); + assert_eq!(unsigned_fixed_point(0x0020, 6), 0.5); + assert_eq!(unsigned_fixed_point(0, 6), 0.0); // Reserved upper bits are ignored, the field is 16 bits - assert_eq!(pmon_pmax_watts(0xFFFF_0000 | (100 * 64)), 100.0); + assert_eq!(unsigned_fixed_point(0xFFFF_0000 | (100 * 64), 6), 100.0); + } + + #[test] + fn test_vsys_max() { + // The 24000 mV that every Panther Lake PPM profile defaults to + assert_eq!(unsigned_fixed_point(24000 * 64 / 1000, 6), 24.0); + } + + #[test] + fn test_pmon_config() { + // U1.15 slope correction, the reference code's example of 125 (1.25) + assert_eq!(unsigned_fixed_point(125 * 32768 / 100, 15), 1.25); + assert_eq!(unsigned_fixed_point(0, 15), 0.0); + // S7.8 offset, the reference code's example of 25348 (25.348 W), which + // 8 fractional bits can only get within 1/256 of + assert_eq!(signed_fixed_point(25348 * 256 / 1000, 8), 25.347656); + assert_eq!(signed_fixed_point(0xFF00, 8), -1.0); } } From 88bd1befd21344fd10bb37301c1c4850902a992f Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Mon, 3 Aug 2026 21:30:38 +0800 Subject: [PATCH 12/13] --thermal: Don't report a failed PSys correction read as zero The PMON offset and slope come from a separate mailbox command than the full scale, so a rejected read was collapsed into unwrap_or(0) and then printed as a genuine zero offset and unity-less slope. Make it an Option so we print nothing when pcode wouldn't tell us. While here, drop the claim that these read back as unprogrammed. sakura reports a 1.00x slope with no offset, because coreboot leaves both at Auto and the FSP then skips SET_PMON_CONFIG entirely, leaving pcode's own defaults in place. Print the two together, they're one calibration: PSys Pmax: 218.0 W Full scale of the PSys readings PSys Correction: 1.00 x Offset 0.00 W Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 14 +++++++------- framework_lib/src/pcode.rs | 39 ++++++++++++++++++++++++-------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index cf5ff2d8..7294325f 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -993,14 +993,14 @@ fn print_platform_power(units: &RaplUnits) { " {:<20} {:>6.1} W Full scale of the PSys readings", "PSys Pmax:", psys.pmax ); - // Both corrections are left at Auto (0) unless a board needs them - if psys.offset != 0.0 { - println!(" {:<20} {:>6.2} W", "PSys Offset:", psys.offset); - } - if psys.slope != 0.0 { - println!(" {:<20} {:>6.2} x", "PSys Slope:", psys.slope); + // Boards that don't need a correction leave both at Auto, which + // reads back as pcode's own no offset and unity slope + if let Some(correction) = psys.correction { + println!( + " {:<20} {:>6.2} x Offset {:.2} W", + "PSys Correction:", correction.slope, correction.offset + ); } - debug!("PSys offset {} W, slope {}", psys.offset, psys.slope); } Some(_) => println!( " {:<20} {:>6} Firmware left it at the pcode default", diff --git a/framework_lib/src/pcode.rs b/framework_lib/src/pcode.rs index 5da8e837..3e8f04f3 100644 --- a/framework_lib/src/pcode.rs +++ b/framework_lib/src/pcode.rs @@ -109,13 +109,21 @@ pub struct PsysConfig { /// /// PMON PMAX, U10.6 fixed point, so up to 1024 W in 1/64 W steps. pub pmax: f32, - /// Offset correction of the PSYS reading, in Watts + /// Offset and slope correction of the PSYS reading /// - /// S7.8 fixed point. 0 unless firmware programmed one. + /// `None` if pcode wouldn't tell us. Note that these read back as pcode's + /// own defaults (no offset, unity slope) rather than as unprogrammed when + /// firmware leaves both at Auto, in which case the FSP skips the write + /// altogether. + pub correction: Option, +} + +/// The offset and slope pcode applies to the PSYS reading before scaling it +#[derive(Debug, Clone, Copy)] +pub struct PsysCorrection { + /// Offset correction in Watts, S7.8 fixed point pub offset: f32, - /// Slope correction of the PSYS reading, 1.0 being none - /// - /// U1.15 fixed point. 0 unless firmware programmed one. + /// Slope correction, 1.0 being none, U1.15 fixed point pub slope: f32, } @@ -259,16 +267,19 @@ impl Mchbar { pub fn platform_power() -> Option { let mchbar = Mchbar::open()?; - let psys = mchbar.vr(MAILBOX_SUBCMD_GET_PMON_PMAX).map(|pmax| { - // Offset and slope are a separate command and only interesting - // alongside a full scale, so don't fail the whole thing over them - let config = mchbar.vr(MAILBOX_SUBCMD_GET_PMON_CONFIG).unwrap_or(0); - PsysConfig { + let psys = mchbar + .vr(MAILBOX_SUBCMD_GET_PMON_PMAX) + .map(|pmax| PsysConfig { pmax: unsigned_fixed_point(pmax, 6), - offset: signed_fixed_point(config, 8), - slope: unsigned_fixed_point(config >> 16, 15), - } - }); + // A separate command, and only interesting alongside a full scale, so + // don't fail the whole thing over it + correction: mchbar + .vr(MAILBOX_SUBCMD_GET_PMON_CONFIG) + .map(|config| PsysCorrection { + offset: signed_fixed_point(config, 8), + slope: unsigned_fixed_point(config >> 16, 15), + }), + }); let vsys_max = mchbar .vr(MAILBOX_SUBCMD_GET_VSYS_MAX) From 17b75286ad95eb545d5f027d83b9d46ad1b68b1f Mon Sep 17 00:00:00 2001 From: Daniel Schaefer Date: Thu, 13 Aug 2026 16:04:18 +0800 Subject: [PATCH 13/13] dump more thermal registers Signed-off-by: Daniel Schaefer --- framework_lib/src/msr.rs | 256 +++++++++++++++++++++++++++++++------ framework_lib/src/pcode.rs | 20 ++- 2 files changed, 234 insertions(+), 42 deletions(-) diff --git a/framework_lib/src/msr.rs b/framework_lib/src/msr.rs index 7294325f..49e639e4 100644 --- a/framework_lib/src/msr.rs +++ b/framework_lib/src/msr.rs @@ -16,7 +16,9 @@ //! //! References: //! - Intel SDM Volume 4 (Model-Specific Registers) -//! - Panther Lake reference code, `Include/Register/Ptl/Msr/MsrRegs.h` +//! - Panther Lake reference code, `Include/Register/Ptl/Msr/MsrRegs.h`, +//! `PeiCpuPowerManagementLib` and `CpuCommonLib.c` for the tau encoding +//! - coreboot `src/soc/intel/common/block/cpu/cpulib.c` and `power_limit.c` //! - Linux `arch/x86/include/asm/msr-index.h` and `tools/power/x86/turbostat` use alloc::format; @@ -54,6 +56,8 @@ pub const MSR_PACKAGE_POWER_SKU_UNIT: u32 = 0x00000606; pub const MSR_PACKAGE_RAPL_LIMIT: u32 = 0x00000610; /// TDP and the power range of this SKU (MSR_PACKAGE_POWER_SKU) pub const MSR_PACKAGE_POWER_SKU: u32 = 0x00000614; +/// PL3, how often the package may exceed a peak power (MSR_PL3_CONTROL) +pub const MSR_PL3_CONTROL: u32 = 0x00000615; /// PSys PL1 and PL2, limiting the whole platform instead of the package pub const MSR_PLATFORM_POWER_LIMIT: u32 = 0x0000065C; @@ -459,6 +463,12 @@ pub struct TemperatureTarget { pub fan_temp_offset: u8, /// Allow RATL throttling below P1 pub tcc_offset_clamping: bool, + /// Raw 7 bit tau field of the running average temperature limit (RATL) + /// + /// Zero means RATL is off. coreboot programs 0x66 here, the closest the tau + /// format gets to the 100 ms it aims for, so RATL is on for us whenever the + /// board configures a TCC offset at all. + pub ratl_tau: u64, /// The whole MSR is read-only pub locked: bool, } @@ -472,11 +482,36 @@ impl From for TemperatureTarget { tcc_offset: ((msr >> 24) & 0x3F) as u8, fan_temp_offset: ((msr >> 8) & 0xFF) as u8, tcc_offset_clamping: bit(msr, 7), + ratl_tau: msr & 0x7F, locked: bit(msr, 31), } } } +impl TemperatureTarget { + /// Whether the running average temperature limit is enabled + /// + /// RATL lets the die exceed the TCC activation temperature in bursts as + /// long as the average over the tau stays below it. The reference code + /// decides this the same way, by the tau field being nonzero. + pub fn ratl(&self) -> bool { + self.ratl_tau != 0 + } + + /// Temperature at which PROCHOT# is asserted, in degrees C + /// + /// With RATL enabled the TCC offset only applies to the running average, so + /// the instantaneous trip point is the reference temperature itself. That's + /// what the reference code's `CpuGetCrossThrottlingTripPoint` returns. + pub fn tcc_activation(&self) -> u8 { + if self.ratl() { + self.ref_temp + } else { + self.ref_temp.saturating_sub(self.tcc_offset) + } + } +} + /// IA32_THERM_STATUS (0x19C) and IA32_PACKAGE_THERM_STATUS (0x1B1) #[derive(Debug, Clone, Copy)] pub struct ThermStatus { @@ -610,20 +645,30 @@ pub struct PowerLimit { pub enabled: bool, /// Processor may go below the OS requested P-State to hold the limit pub clamping: bool, - /// Averaging window, None if this limit has no time window field - pub time_window: Option, + /// Averaging window in seconds, None if this limit has no tau field + pub tau: Option, } -/// Decode the 7 bit time window field of a RAPL limit register +/// Decode a 7 bit tau (averaging time window) field /// -/// Time Window = (1 + X/4) * 2^Y in units of `RaplUnits::time`, where Y is -/// bits 4:0 and X is bits 6:5 of the field. -fn time_window(field: u64, units: &RaplUnits) -> f32 { +/// Tau = (1 + X/4) * 2^Y in units of `unit` seconds, where Y is bits 4:0 and X +/// is bits 6:5 of the field. Every averaging window the processor has is +/// encoded this way: the RAPL power limits, the platform current limits, PL3 +/// and the running average temperature limit. +fn tau(field: u64, unit: f32) -> f32 { let y = field & 0x1F; let x = (field >> 5) & 0x3; - (1.0 + x as f32 / 4.0) * (1u64 << y) as f32 * units.time + (1.0 + x as f32 / 4.0) * (1u64 << y) as f32 * unit } +/// The time unit of the tau fields that have no unit register of their own +/// +/// The RAPL limits scale their tau by `RaplUnits::time`, which is 1/1024 s on +/// every processor we've seen. RATL and PL3 have no such register and the +/// reference code assumes 1/1024 s for them, which is what its seconds and +/// milliseconds conversion tables are built from. +const DEFAULT_TIME_UNIT: f32 = 1.0 / 1024.0; + /// Decode one PL1/PL2 style limit out of a RAPL limit register /// /// The fields repeat every 32 bits, so `shift` is 0 for PL1 and 32 for PL2. @@ -632,8 +677,8 @@ fn decode_limit(raw: u64, shift: u32, has_time: bool, units: &RaplUnits) -> Powe watts: ((raw >> shift) & 0x7FFF) as f32 * units.power, enabled: bit(raw, shift + 15), clamping: bit(raw, shift + 16), - time_window: if has_time { - Some(time_window((raw >> (shift + 17)) & 0x7F, units)) + tau: if has_time { + Some(tau((raw >> (shift + 17)) & 0x7F, units.time)) } else { None }, @@ -669,16 +714,30 @@ pub fn print_thermal_msrs() { println!(" Intel Thermal MSRs"); println!(" TjMax: {:>4} C", target.ref_temp); - println!( - " TCC Activation: {:>4} C (Offset {} C{})", - target.ref_temp - target.tcc_offset, - target.tcc_offset, - if target.tcc_offset_clamping { - ", clamping" - } else { - "" - } - ); + let clamping = if target.tcc_offset_clamping { + ", clamping" + } else { + "" + }; + if target.ratl() { + // The offset limits the average temperature over the tau instead of the + // instantaneous one, so the die may run hotter than TjMax minus offset + println!( + " TCC Activation: {:>4} C ({} C average over {:.3} s tau{})", + target.tcc_activation(), + target.ref_temp.saturating_sub(target.tcc_offset), + tau(target.ratl_tau, DEFAULT_TIME_UNIT), + clamping + ); + } else { + debug!("No RATL tau, the TCC offset limits the instantaneous temperature"); + println!( + " TCC Activation: {:>4} C (Offset {} C{})", + target.tcc_activation(), + target.tcc_offset, + clamping + ); + } // Our EC does its own fan control, so this is usually left at 0 (unused) if target.fan_temp_offset > 0 { println!( @@ -890,8 +949,8 @@ fn print_limit(name: &str, limit: &PowerLimit) { if limit.clamping { notes.push("Clamping".to_string()); } - if let Some(window) = limit.time_window { - notes.push(format!("{:.3} s window", window)); + if let Some(tau) = limit.tau { + notes.push(format!("{:.3} s tau", tau)); } println!( " {:<20} {:>6.1} W {}", @@ -914,6 +973,9 @@ fn print_power_limits(cpuid: &CpuId) { return; }; + // Read once, the limits and the platform configuration below both need it + let platform = crate::pcode::platform_power(); + println!(" Power Limits"); if let Some(sku) = read_msr(0, MSR_PACKAGE_POWER_SKU) { println!( @@ -930,6 +992,16 @@ fn print_power_limits(cpuid: &CpuId) { "SKU Max Power:", max, min ); } + // The ceiling on the PL1 tau below. Anything longer gets clamped to it, + // so a tau that looks too short may simply be all this SKU allows. + let max_win = (sku >> 48) & 0x7F; + if max_win != 0 { + println!( + " {:<20} {:>6.1} s Longest averaging window this SKU allows", + "Max Tau:", + tau(max_win, units.time) + ); + } } if let Some(raw) = read_msr(0, MSR_PACKAGE_RAPL_LIMIT) { @@ -940,11 +1012,15 @@ fn print_power_limits(cpuid: &CpuId) { } } - // These two are not on pre-Skylake processors + // These are not on pre-Skylake processors if !cpuid.skylake_or_newer() { return; } + if let Some(raw) = read_msr(0, MSR_PL3_CONTROL) { + print_pl3(raw, &units); + } + if let Some(raw) = read_msr(0, MSR_VR_CURRENT_CONFIG) { // The reference code describes the field in 0.125 A increments, but // coreboot programs it as Watts scaled by the RAPL power unit (which @@ -957,6 +1033,26 @@ fn print_power_limits(cpuid: &CpuId) { ); } + // PL1 and PL2 exist a second time in MCHBAR, in the same layout. Both + // copies are live and hold their own tau: Intel's Dual Tau Boost feature + // deliberately programs a higher PL1 with a shorter tau in MCHBAR and a + // lower PL1 with a longer tau in the MSR. On Linux this copy is also the one + // the OS gets at, as powercap's intel-rapl-mmio, so the MSR alone doesn't + // necessarily say what the package is being held to. + match platform.and_then(|power| power.package_rapl_limit) { + // Firmware programs this register alongside the MSR, so all zeroes means + // nothing is being limited through it + Some(0) => debug!("No power limits programmed in MCHBAR"), + Some(raw) => { + print_limit("MMIO PL1", &decode_limit(raw, 0, true, &units)); + print_limit("MMIO PL2", &decode_limit(raw, 32, true, &units)); + if bit(raw, 63) { + println!(" {:<20} {:>6}", "MMIO Locked:", "Yes"); + } + } + None => (), + } + if let Some(raw) = read_msr(0, MSR_PLATFORM_POWER_LIMIT) { // Whether PSys is enabled matters: it is the only limit that accounts // for total platform power instead of just the package. With it off, @@ -974,15 +1070,52 @@ fn print_power_limits(cpuid: &CpuId) { // The full scale of all of the above PSys numbers, and the platform's // current limits, aren't in MSRs. Report them next to the limits anyway, // because the full scale is what decides whether they mean anything. - print_platform_power(&units); + print_platform_power(platform, &units); +} + +/// Print PL3, the limit on how often the package may exceed a peak power +/// +/// PL3 doesn't cap power the way the other limits do. It allows the package to +/// exceed its power level for a fraction of the averaging window, so it takes a +/// duty cycle as well as a tau. Firmware leaves it disabled on our systems. +fn print_pl3(raw: u64, units: &RaplUnits) { + let mut notes = Vec::new(); + if bit(raw, 15) { + // Programmed in milliseconds, but encoded like every other tau + notes.push(format!( + "{:.3} s tau", + tau((raw >> 17) & 0x7F, DEFAULT_TIME_UNIT) + )); + notes.push(format!("{} % duty cycle", (raw >> 24) & 0x7F)); + // The reference code only documents this as how quickly pcode brings + // power back down, gradually or aggressively + notes.push( + if bit(raw, 16) { + "aggressive response" + } else { + "gradual response" + } + .to_string(), + ); + } else { + notes.push("Disabled".to_string()); + } + if bit(raw, 31) { + notes.push("Locked".to_string()); + } + println!( + " {:<20} {:>6.1} W {}", + "PL3 (occurrence):", + (raw & 0x7FFF) as f32 * units.power, + notes.join(", ") + ); } /// Print the platform power delivery configuration that isn't in any MSR /// -/// `units` is only needed for the Isys time window, which is encoded like a -/// RAPL one. -fn print_platform_power(units: &RaplUnits) { - let Some(power) = crate::pcode::platform_power() else { +/// `units` is only needed for the Isys tau, which is encoded like a RAPL one. +fn print_platform_power(power: Option, units: &RaplUnits) { + let Some(power) = power else { info!("{}", crate::pcode::unavailable_hint()); return; }; @@ -1021,7 +1154,7 @@ fn print_platform_power(units: &RaplUnits) { if let Some(isys) = power.isys { if isys.l1_amps > 0.0 || isys.l2_amps > 0.0 { print_isys_limit("Isys Limit L1", isys.l1_amps, isys.l1_enabled, { - Some(time_window(isys.l1_tau, units)) + Some(tau(isys.l1_tau, units.time)) }); print_isys_limit("Isys Limit L2", isys.l2_amps, isys.l2_enabled, None); } else { @@ -1031,13 +1164,13 @@ fn print_platform_power(units: &RaplUnits) { } /// Print one of the two Isys current limits, in the style of [print_limit] -fn print_isys_limit(name: &str, amps: f32, enabled: bool, time_window: Option) { +fn print_isys_limit(name: &str, amps: f32, enabled: bool, tau: Option) { let mut notes = Vec::new(); if !enabled { notes.push("Disabled".to_string()); } - if let Some(window) = time_window { - notes.push(format!("{:.3} s window", window)); + if let Some(tau) = tau { + notes.push(format!("{:.3} s tau", tau)); } println!( " {:<20} {:>6.1} A {}", @@ -1087,14 +1220,25 @@ mod tests { } #[test] - // Time Window = (1 + X/4) * 2^Y, Y is bits 4:0 and X is bits 6:5 - fn rapl_time_window() { + // Tau = (1 + X/4) * 2^Y, Y is bits 4:0 and X is bits 6:5 + fn rapl_tau() { let units = RaplUnits::from(0x000A_0E03); // The reset default of 0xA is Y=10, X=0, so 1024 * 1/1024 s - assert_eq!(time_window(0x0A, &units), 1.0); + assert_eq!(tau(0x0A, units.time), 1.0); // Y=10, X=2 gives 1.5 * 1024 * 1/1024 s - assert_eq!(time_window(0x4A, &units), 1.5); - assert_eq!(time_window(0, &units), 1.0 / 1024.0); + assert_eq!(tau(0x4A, units.time), 1.5); + assert_eq!(tau(0, units.time), 1.0 / 1024.0); + // The entries of the reference code's seconds conversion table, which + // only lines up with 1/1024 s units + assert_eq!(tau(0x4B, DEFAULT_TIME_UNIT), 3.0); + assert_eq!(tau(0x6E, DEFAULT_TIME_UNIT), 28.0); + assert_eq!(tau(0x71, DEFAULT_TIME_UNIT), 224.0); + // And of its milliseconds table, used for PL3 and RATL + assert_eq!(tau(0x41, DEFAULT_TIME_UNIT), 3.0 / 1024.0); + assert_eq!(tau(0x09, DEFAULT_TIME_UNIT), 0.5); + assert_eq!(tau(0x49, DEFAULT_TIME_UNIT), 0.75); + // The maximum time window MSR_PACKAGE_POWER_SKU defaults to + assert_eq!(tau(0x12, DEFAULT_TIME_UNIT), 256.0); } #[test] @@ -1106,19 +1250,19 @@ mod tests { assert_eq!(pl1.watts, 28.0); assert!(pl1.enabled); assert!(pl1.clamping); - assert_eq!(pl1.time_window, Some(1.0)); + assert_eq!(pl1.tau, Some(1.0)); let pl2 = decode_limit(raw, 32, true, &units); assert_eq!(pl2.watts, 64.0); assert!(pl2.enabled); assert!(!pl2.clamping); // Y=1, X=1 gives 1.25 * 2 * 1/1024 s - assert_eq!(pl2.time_window, Some(1.25 * 2.0 / 1024.0)); + assert_eq!(pl2.tau, Some(1.25 * 2.0 / 1024.0)); // Bit 63 is the lock assert!(bit(raw, 63)); - // A limit without a time window field - assert_eq!(decode_limit(raw, 32, false, &units).time_window, None); + // A limit without a tau field + assert_eq!(decode_limit(raw, 32, false, &units).tau, None); } #[test] @@ -1147,6 +1291,36 @@ mod tests { assert_eq!(target.tcc_offset, 8); assert_eq!(target.fan_temp_offset, 0); assert!(!target.locked); + // Without a tau, RATL is off and the offset trips PROCHOT directly + assert!(!target.ratl()); + assert_eq!(target.tcc_activation(), 92); + } + + #[test] + // What coreboot leaves behind: the same 8 C offset, but with the low byte + // written as 0xE6, which is a 0.109 s tau (its "100 ms") plus clamping. The + // offset then only limits the average, so PROCHOT# trips at TjMax. + fn temperature_target_ratl() { + let target = TemperatureTarget::from(0x0864_00E6); + assert_eq!(target.ref_temp, 100); + assert_eq!(target.tcc_offset, 8); + assert!(target.ratl()); + assert!(target.tcc_offset_clamping); + assert_eq!(tau(target.ratl_tau, DEFAULT_TIME_UNIT), 112.0 / 1024.0); + assert_eq!(target.tcc_activation(), 100); + } + + #[test] + // PL3 25 W with a 4 ms tau at a 90 % duty cycle, enabled and unlocked + fn pl3() { + let units = RaplUnits::from(0x000A_0E03); + let raw = 0x5A04_80C8; + assert_eq!((raw & 0x7FFF) as f32 * units.power, 25.0); + assert!(bit(raw, 15)); + assert!(!bit(raw, 16)); + assert_eq!(tau((raw >> 17) & 0x7F, DEFAULT_TIME_UNIT), 4.0 / 1024.0); + assert_eq!((raw >> 24) & 0x7F, 90); + assert!(!bit(raw, 31)); } #[test] diff --git a/framework_lib/src/pcode.rs b/framework_lib/src/pcode.rs index 3e8f04f3..88fe3d60 100644 --- a/framework_lib/src/pcode.rs +++ b/framework_lib/src/pcode.rs @@ -22,7 +22,8 @@ //! The PSys power limits themselves are in MSR_PLATFORM_POWER_LIMIT, see //! [crate::msr], and so is PL4. What's left over and only reachable here is the //! rest of the PSYS calibration (offset and slope), the maximum system voltage, -//! and the Isys battery current limits. +//! the Isys battery current limits, and the second copy of the package power +//! limits that lives in MCHBAR rather than in an MSR. //! //! Only Intel processors have any of this. //! @@ -53,6 +54,8 @@ const PCODE_MAILBOX_DATA: u64 = 0x5DA0; const PCODE_MAILBOX_INTERFACE: u64 = 0x5DA4; /// Isys (battery) current limits, the ThETA Ibatt feature. 64 bit. const ISYS_CONTROL: u64 = 0x5E90; +/// PL1 and PL2 again, in the same layout as MSR_PACKAGE_RAPL_LIMIT. 64 bit. +const PACKAGE_RAPL_LIMIT: u64 = 0x59A0; /// Set by the caller to hand a command over, cleared by pcode when it's done const MAILBOX_RUN_BUSY: u32 = 1 << 31; @@ -156,6 +159,11 @@ pub struct PlatformPower { /// Only programmed together with [IsysControl]. pub vsys_max: Option, pub isys: Option, + /// Raw MCHBAR copy of the package power limits + /// + /// The same fields as MSR_PACKAGE_RAPL_LIMIT, so decode it with + /// [crate::msr]. `None` if the register didn't read back plausibly. + pub package_rapl_limit: Option, } // ------------------------------------------------------------------------- @@ -295,10 +303,20 @@ pub fn platform_power() -> Option { l2_enabled: raw & (1 << 47) != 0, }); + let raw = mchbar.read64(PACKAGE_RAPL_LIMIT); + debug!( + "PACKAGE_RAPL_LIMIT ({:#X}): {:#018X}", + PACKAGE_RAPL_LIMIT, raw + ); + // An MMIO read that doesn't decode comes back as all ones, which is not a + // limit register anyone could have programmed + let package_rapl_limit = if raw == u64::MAX { None } else { Some(raw) }; + Some(PlatformPower { psys, vsys_max, isys, + package_rapl_limit, }) }