panic: blink the keyboard lights, and mark the screen on the way in

A laptop has no serial port, and the machine we are trying to boot stops with
the loader's splash still on screen. Three completely different things look
exactly like that: the jump never landed, the kernel started and hung, or the
kernel started and panicked with a full report drawn into a framebuffer nobody
can see. They call for different next steps, so the kernel now says which
without needing a console at all.

**A bar, as the kernel's first act.** Eight lines of accent colour across the
top of the framebuffer, written straight through the handoff block before a
single subsystem exists -- no gfx, no scanout, no allocator, nothing that can
itself fail. If it appears over the splash, the jump landed and the kernel is
executing. If it does not, nothing below that point is worth looking at.
Skipped when the framebuffer is above the direct map, because a fault there
leaves the machine quieter still, and free on a boot that works: the console
clears the screen a few lines later.

**Blinking keyboard lights, on panic.** Scroll, Num and Caps together, about
three times a second, for ever. It is the one output every PC has had since
before any of this existed -- no driver, no interrupt, no memory -- and it
works when the graphics do not. Linux does the same thing for the same reason.

Every wait in it is bounded. It runs after `panicked` is set so there is
nothing left to protect, but a machine that hangs inside its own death rattle
tells you less than one that stops quietly. The delay is microdelay, which
spins on the TSC: no timer, no interrupt, no calibration -- all three of which
a kernel that died during boot may not have.

That makes the three cases distinguishable from across the room: no bar and no
blink is a jump that did not land; a bar and no blink is a kernel that hung; a
blinking keyboard is a panic, and the report is in /proc/kmsg after a restart.

**And the loader now describes the mode it is actually handing over**, not only
the one the firmware started in. On a machine where changing the resolution
loses the splash entirely, the difference between those two lines is the bug
report; on a boot that works nobody sees either, because ClearScreen runs a few
lines later.

Verified: `echo c > /proc/sysrq`, then QMP polled for five and a half seconds
afterwards -- the machine answers throughout and a screendump still shows the
panic report intact, so the blink loop runs without faulting, resetting, or
scribbling on what it is there to accompany. Both supported configurations
boot clean with the early bar in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-09-24 15:46:38 +02:00
commit 3c5212a7c7
4 changed files with 151 additions and 2 deletions

View file

@ -1509,6 +1509,13 @@ setup_framebuffer(void)
// Re-read after any mode change: Info and FrameBufferBase both describe the mode // Re-read after any mode change: Info and FrameBufferBase both describe the mode
// that is current, and the framebuffer can move when the mode changes. // that is current, and the framebuffer can move when the mode changes.
//
// And say so again. The description printed before the menu is of the mode
// the firmware left; this one is of the mode actually being handed to the
// kernel, and on a machine where changing the resolution loses the splash
// entirely, the difference between the two lines is the bug report. It
// costs nothing on a boot that works -- ClearScreen is a few lines below.
describe_gop(gop, L"schos: booting with");
EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info = gop->Mode->Info; EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *info = gop->Mode->Info;
// **Sixty-four bits, and not truncated.** `(UINT32)FrameBufferBase` is // **Sixty-four bits, and not truncated.** `(UINT32)FrameBufferBase` is

View file

@ -122,6 +122,44 @@ when the early console is set up. So a low framebuffer is attached exactly as
before, and a high one is attached a few dozen instructions later, immediately before, and a high one is attached a few dozen instructions later, immediately
after `kvmalloc()` — still thousands of lines before anything else happens. after `kvmalloc()` — still thousands of lines before anything else happens.
### Two signals for a machine that cannot speak
A laptop has no serial port. When it stops with the loader's splash still on
screen, three very different things look identical: the jump never landed, the
kernel started and hung, or the kernel started and *panicked* with a full
report drawn into a framebuffer nobody can see. Each calls for a different next
step, so the kernel now produces two signals that need no console at all.
**A bar, as the kernel's first act.** Eight lines of accent colour across the
top of the framebuffer, written straight through the handoff block before a
single subsystem exists — no gfx, no scanout, no allocator. If it appears over
the loader's splash, the jump landed and the kernel is executing; if it does
not, nothing below that point is worth looking at. It is skipped when the
framebuffer is above the direct map, because a fault there would leave the
machine quieter still, and it costs nothing on a boot that works: the console
clears the screen a few lines later.
**Blinking keyboard lights, on panic.** `panic_blink()` toggles Scroll, Num and
Caps together, about three times a second, for ever. It is the one output every
PC has had since before any of this existed: no driver, no interrupt, no
memory, and it works when the graphics do not. Linux does the same thing for
the same reason.
Every wait in it is bounded. It runs after `panicked` is set, so there is
nothing left to protect — but a machine that hangs inside its own death rattle
tells you less than one that stops quietly, so no loop in there can be entered
that cannot be left. The delay is `microdelay`, which spins on the TSC and
needs no timer, no interrupt and no calibration — all three of which a kernel
that died during boot may not have.
So the three cases separate:
| What you see | What happened |
| --- | --- |
| No bar, no blink | The jump did not land. The loader's last label says where. |
| Bar, no blink | The kernel is executing and hung. Not a panic. |
| Blinking lights | The kernel panicked. The report is in `/proc/kmsg` after a restart. |
### The loader says which stage it is in ### The loader says which stage it is in
After the splash, `print()` goes to the serial port and nowhere else — writing After the splash, `print()` goes to the serial port and nowhere else — writing

View file

@ -50,6 +50,34 @@ kmain(uint32 fb_addr)
boot_verbose = *(uchar*)P2V(SCHBOOT_VERBOSE_FLAG); boot_verbose = *(uchar*)P2V(SCHBOOT_VERBOSE_FLAG);
boot_debug = *(uchar*)P2V(SCHBOOT_DEBUG_FLAG); boot_debug = *(uchar*)P2V(SCHBOOT_DEBUG_FLAG);
// The kernel's first act: put a mark on the screen.
//
// Eight lines, before a single subsystem exists, because "the loader
// finished and the machine froze" has two completely different causes and
// nothing else here can tell them apart. If this bar appears over the
// loader's splash, the jump landed, the page tables are good and the kernel
// is executing; if it does not, none of those are true and everything below
// is the wrong place to look.
//
// It writes straight through the handoff block rather than through gfx,
// because gfx needs a scanout attached and attaching one is itself a thing
// that can fail. Skipped when the framebuffer is above the direct map -- a
// fault here would be a triple fault with the machine even quieter than it
// was. It costs nothing on a boot that works: the console clears the screen
// a few lines later.
{
uint64 pa = *(uint64*)P2V(SCHBOOT_FB_BASE);
uint32 w = *(uint32*)P2V(SCHBOOT_FB_WIDTH);
uint32 st = *(uint32*)P2V(SCHBOOT_FB_STRIDE);
if (pa && w && st && pa + (uint64)st * 8 <= 4ULL << 30) {
volatile uint32 *px = (volatile uint32 *)P2V(pa);
for (uint32 y = 0; y < 8; y++)
for (uint32 x = 0; x < w; x++)
px[y * (st / 4) + x] = 0x7C5CFF; /* SCH_ACCENT */
}
}
// On a verbose boot, put the kernel log on the screen from the first line. // On a verbose boot, put the kernel log on the screen from the first line.
// //
// Everything below talks to the serial port and nothing else until the first // Everything below talks to the serial port and nothing else until the first

View file

@ -27,6 +27,8 @@
// before there is a framebuffer at all. // before there is a framebuffer at all.
#include "types.h" #include "types.h"
#include "kbd.h"
#include "x86.h"
#include "defs.h" #include "defs.h"
#include "gfx.h" #include "gfx.h"
#include "param.h" #include "param.h"
@ -506,6 +508,8 @@ report_serial(char *reason, addr_t *pcs, int npcs)
cprintf(" System halted.\n"); cprintf(" System halted.\n");
} }
static void panic_blink(void) __attribute__((noreturn));
__attribute__((noreturn)) __attribute__((noreturn))
void void
panic(char *s) panic(char *s)
@ -534,6 +538,78 @@ panic(char *s)
} }
panicked = 1; panicked = 1;
for (;;) panic_blink();
hlt(); }
// The last thing a dead kernel can say, on a machine that cannot say anything.
//
// A laptop has no serial port. If the panic screen cannot be drawn -- no
// framebuffer yet, the wrong framebuffer, a display the firmware handed over
// in a state we did not expect -- then everything above this has produced a
// black screen and a machine that appears to have hung. "Frozen" and "panicked
// with a full report nobody can see" look identical, and they call for
// completely different next steps.
//
// So: blink the keyboard lights. It is the one output every PC has had since
// before any of this existed, it needs no driver, no interrupt and no memory,
// and it works when the graphics do not. Linux does the same thing and for the
// same reason.
//
// Every wait here is bounded. This runs after `panicked` is set, so there is
// nothing left to protect and nothing left to return to -- but a machine that
// hangs inside its own death rattle tells you even less than one that stops
// quietly, so no loop in here can be entered that cannot be left.
static void
kbd_out(uchar byte)
{
// Wait for the controller to have room, then write. Bit 1 of the status
// port is "input buffer full"; writing while it is set loses the byte.
for (int i = 0; i < 100000; i++) {
if (!(inb(KBSTATP) & 0x02)) {
outb(KBDATAP, byte);
return;
}
microdelay(10);
}
}
static void
kbd_drain_ack(void)
{
// The keyboard answers 0xFA. Reading it matters less than not leaving it in
// the buffer for the next command to mistake for its own answer.
for (int i = 0; i < 100000; i++) {
if (inb(KBSTATP) & KBS_DIB) {
(void)inb(KBDATAP);
return;
}
microdelay(10);
}
}
// Scroll, Num and Caps, which is bits 0, 1 and 2 of the byte after 0xED.
#define LED_ALL 0x07
static void
panic_blink(void)
{
int on = 0;
for (;;) {
kbd_out(0xED);
kbd_drain_ack();
kbd_out(on ? LED_ALL : 0x00);
kbd_drain_ack();
on = !on;
// About a third of a second either way. Slow enough to read across a room
// as deliberate rather than as a stuck light, fast enough that nobody
// watching a machine they think has hung will have walked away first.
//
// microdelay spins on the TSC, which needs no timer, no interrupt and no
// calibration that may not have happened yet -- all three of which a
// kernel that died during boot may be missing.
for (int i = 0; i < 300; i++)
microdelay(1000);
}
} }