Skip to content

How ZenMaster Talks to the SMU

Le Khanh Binh edited this page Jul 2, 2026 · 1 revision

How ZenMaster Talks to the SMU

Every platform ends up doing the same thing: write an opcode to a register, poke a message register to trigger it, and poll a response register until the SMU answers. The mailbox protocol itself lives in one place, zenmaster/mailbox.py. What differs between Linux, Windows, and macOS is only how you read and write a register in the first place. This page walks through both halves: the shared protocol, then each platform's plumbing underneath it.

The mailbox handshake

An MP1 or RSMU mailbox is three registers: a message register, a response register, and a block of argument registers (six of them, NARGS = 6). Sending a command looks like this:

  1. Clear the response register.
  2. Write your argument into the first argument register, zero the rest.
  3. Write the opcode into the message register. This is what actually triggers the SMU.
  4. Poll the response register until it stops being zero. That value is the status: 0x01 means OK.

A query (any get-* argument) does the same thing, then reads the argument registers back afterward to pick up whatever the SMU wrote there.

There's one wrinkle: some operations, most often PM table transfers, can come back SMU_REJECTED_PREREQ if you ask before the SMU is ready. transfer_with_retry() handles this by trying again after a short delay (10ms, then 100ms) instead of giving up on the first rejection.

All of this, mailbox_send, mailbox_query, poll_response, transfer_with_retry, is written once and takes the register read/write functions as arguments. Each backend hands it its own smn_read/smn_write (or whatever it calls them) and its own polling constants, and gets identical behavior. Before this got factored out, macOS and Windows each carried their own near-copy of this logic; now a fix here only has to happen once.

The per-family register addresses (which offset is the message register for Raphael, which is the response register for Rembrandt, and so on) also live in mailbox.py, in the MP1 and RSMU dicts, with MP1_DEFAULT/RSMU_DEFAULT as fallbacks for families not listed explicitly.

Linux: two backends, chosen by Secure Boot

Linux has to reach into PCI config space or a kernel module's sysfs interface, and which one it's allowed to use depends entirely on Secure Boot:

  • Secure Boot off → PCI direct access. ZenMaster opens /sys/bus/pci/devices/0000:00:00.0/config and writes to the northbridge's address/data register pair at offsets 0xB8/0xBC. This needs root but no signed kernel code at all, and init() confirms it actually works with a round-trip write-probe at register 0x47 before committing to this backend.
  • Secure Boot on → the ryzen_smu kernel module, via /sys/kernel/ryzen_smu_drv/smn. Kernel lockdown (which Secure Boot enables) blocks unprivileged raw PCI writes outright, so the PCI path simply isn't available here. A kernel module gets around that, but only if it's signed and the signature is trusted, which is why ryzen_smu has to be built, signed with a MOK key, and enrolled rather than just installed. ZenMaster checks the module's drv_version and refuses anything older than 0.1.7, since that's the release that added the /smn interface it needs.

A loaded ryzen_smu module is not preferred when Secure Boot is off. The selection is by Secure Boot state, full stop, not by "whatever happens to be loaded."

Each register access used to open and close a file descriptor on its own. That file descriptor is now opened once per process and reused (_get_fd), which matters if something is hammering the SMU on a timer, like a sensor-polling loop.

The PM table on Linux comes from wherever the mailbox came from: ryzen_smu exposes it directly as a pm_table sysfs file, while the PCI path has to ask the SMU for the table's physical address and then read that address back through /dev/mem (subject to whatever CONFIG_STRICT_DEVMEM allows on that kernel).

Windows: PawnIO instead of WinRing0

Windows doesn't let user-space code touch PCI config space or physical memory directly, so RyzenAdj and older tools relied on WinRing0, a driver with a well-documented history of CVEs (arbitrary read/write to physical memory from any unprivileged process). ZenMaster uses PawnIO instead: a Microsoft-signed kernel driver that only exposes a narrow ioctl interface, no raw memory mapping, nothing an antivirus vendor is going to flag on sight.

PawnIO doesn't know anything about AMD SMUs by itself. It's a generic driver that loads a small bytecode module and executes named functions inside it. ZenMaster ships that module, RyzenSMU.bin, right at zenmaster/RyzenSMU.bin (there used to be three more .bin files for older AMD chip families sitting alongside it, inherited from a reference project; none of them were ever actually loaded by any code path, ZenMaster's or the project they came from, so they were removed).

init() opens the PawnIO device (\\?\GLOBALROOT\Device\PawnIO, falling back to the older \\.\PawnIO path), loads RyzenSMU.bin into it via one ioctl, and from then on every register read/write is a DeviceIoControl call naming a function inside that module (ioctl_read_smu_register, ioctl_write_smu_register, ioctl_read_pm_table). Everything on Windows needs an elevated (Administrator) process, since that's what PawnIO itself requires.

macOS: DirectHW, or a kext-free fallback

This is Hackintosh-only. AMD doesn't exist in real Macs, so detect() here reads machdep.cpu.family/machdep.cpu.model straight off CPUID via sysctlbyname, the same instruction Linux and Windows are ultimately reading too, just through a different syscall. That matters on a Hackintosh specifically because OpenCore can present a spoofed SMBIOS to make the OS think it's real Apple hardware, but it can't spoof what the CPU itself reports through CPUID. That's the actual chip talking, not a config file.

Reaching the SMU needs one of two things:

  • DirectHW.kext, reached through its IOKit user client. This is the full-featured path: it can do the same config-space port I/O the other two OSes do (kReadIO/kWriteIO selectors against ports 0xCF8/0xCFC, the legacy PCI configuration mechanism #1), and it can also map physical memory (kPrepareMap + IOConnectMapMemory), which is what makes PM table reads and --sensors/--table work. It needs the kext loaded, and on a Hackintosh, SIP has to be configured to allow unsigned kexts.
  • IOPCIBridge, kext-free, for when DirectHW isn't installed. This opens Apple's own IOPCIBridge service through its IOPCIDiagnostics client and does config-space reads/writes through that instead. It works for tuning, since tuning is just config-space mailbox writes, but it has no way to map physical memory, so PM table reads and --sensors simply aren't available on this path. It also needs the debug=0x144 boot-arg set, without which init() refuses to use it at all.

Backend selection tries DirectHW first (if loaded), then falls back to IOPCIBridge (if the boot-arg is set), and only raises BackendUnavailable with setup instructions if neither is usable. The CLI's --iopci flag forces the kext-free path even when DirectHW is available, mainly useful for testing without the kext installed.

Once either path is open, the mailbox layer above it is identical to every other platform, same mailbox_send/mailbox_query, same retry logic, same register address tables. Only _smn_read/_smn_write differ, and even those are just port I/O through whichever IOKit client is active.

Why this is worth knowing

If you're debugging a "backend unavailable" error, the actual failure is almost always in this layer, not in the mailbox protocol above it: Secure Boot state on Linux, PawnIO not installed or not elevated on Windows, DirectHW/SIP/boot-arg on macOS. smu.init() raises BackendUnavailable with a message specific to whichever of these it hit, and smu.unavailable_reason() gives you that same message without needing a try/except. See Troubleshooting for the concrete fixes.

If you're extending ZenMaster to a new backend or chasing a mailbox bug, this page plus zenmaster/mailbox.py is the whole protocol; linux.py, windows.py, and macos.py are just three different ways of getting a byte in and out of a register. See Architecture for how this fits into the rest of the package.

Clone this wiki locally