Patrick Lidstone
Self-hosted

Development log

Nothing would wake it, so the firmware's own bytecode had to run

· Path OS

The last note left S3 sleeping and coming back, proved on an emulator. On the X1 it slept perfectly and nothing would wake it. An RTC alarm fired and the machine stayed down. QEMU worked first time, which is the least useful kind of pass there is.

It is rather good watching the ThinkPad's red dot breathe as it sleeps, even when you cannot get it back.

Fixing it turned into a rabbit hole. Waking the laptop needs the code the ThinkPad itself runs on the way down, and making that work on any machine rather than this one needs an interpreter.

What the firmware actually is

When UEFI hands over it leaves a set of tables in RAM describing the machine. Some are plain data: the MADT lists the processors, the FADT says which I/O ports the power-management registers live at. Those are read with ordinary struct reads.

Two of them are not data at all. The DSDT and its companion SSDTs are bytecode. The X1 ships thirteen of these blocks totalling 238,382 bytes; QEMU ships about nine kilobytes. The bytecode is AML, ACPI Machine Language, and it is meant to be interpreted by the operating system.

Why code rather than data

Because what an operating system needs from firmware usually is not a number, it is a procedure. This is what this machine's _PTS(3) actually does, disassembled:

TRAP (2, 0x1E)                          → write a field that raises an SMI
\_SB.SGOV (0x02010003, 1)               → compute a GPIO pad's address from
                                          three method calls, map it, store
Store (1, \_SB.PCI0.LPCB.EC.HCMU)       → an embedded-controller command
Store (\_SB.PCI0.LPCB.EC.AC._PSR, \ACST) → read the AC adapter back out of it

Conditionals, arithmetic, ordering, and reads feeding writes. ACPI inverts the usual arrangement: the firmware ships the procedure and the operating system provides the runtime.

Why hardcoding it was not an option

The obvious objection is that we have the disassembly, so why not just hardcode the writes. Because HCMU and HWLB are this laptop's register names. An X220's are different, a Surface Pro 7's are different, and a BIOS update can change them. The firmware even branches on what it finds: _S3 is declared only behind If (LEqual (STY0, 0)), which reads a memory field. Hardcoding means being wrong on every other machine and after the next update.

It is also why every real operating system carries one. ACPICA, the reference implementation Linux uses, is tens of thousands of lines. This one is deliberately scoped to what these particular tables use, which is a much smaller thing, and it still took the better part of a day.

What it loads

All 238,382 bytes of the X1's tables load, and the interpreter and an independent byte scan agree on what is in them - which is the only check available when there is no second implementation to compare against. The namespace mirrors the hardware topology: \_SB.PCI0.LPCB.EC is system bus, then PCI root, then LPC bridge, then embedded controller. That is why a method can write HCMU unqualified and have it resolve to the controller.

Real firmware found faults a synthetic table never would. A dotted path is not AML, so every power resource in the tables was being skipped silently. A table's top level has control flow in it, and all of the X1's begin with it. A package built at load time cannot see a table that has not been read yet. Index had nowhere to write, and a named element was its own name. None of those is interesting on its own; all of them are the difference between loading nine kilobytes of QEMU's tables and loading this machine's.

The wake

With the interpreter running, the firmware's own _PTS(3) runs on the way down and writes the embedded controller, as it is supposed to.

The instrument problem came first, though. A resume that hangs and a machine that never woke look identical from the outside: a dark screen and no network. The keyboard lamps became the instrument instead - one lamp proves one instruction ran, and then a counter, so the number of lamps says how far it got. That is how it became clear the firmware was never calling the waking vector at all, which is a different fault from the one I had been chasing.

Two things were wrong. The sleep register has to be written twice, as the reference does. And the machine names its own wake events, which are not the ones I had armed - the USB controller has no _PRW at all, and it is the one that sees the power button. Once those were right the X1 woke, and the resume finishes properly: _WAK runs, the machine says what woke it, and the cores come back.

One of the early passes was a false positive worth recording. A hold command ran _PTS instead of sleeping, because a >= 3 comparison swallowed it, so the machine appeared to do the right thing for the wrong reason.

What is not built

The display does not come back yet. Nor does the disk. The wake is the processor and the firmware handshake, not a usable machine, and bringing the devices back is the next piece of work rather than a detail of this one.

The interpreter is scoped to what these tables use. It is lenient about loading and strict about executing, on the grounds that a table containing something unimplemented should still give up every other method in it. There will be machines whose tables it refuses, and the honest answer is that I will find out which when I try one.