From 45e4df9a331208d20ecb9f5ead8110eb50a5b86d Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Fri, 27 Mar 2026 18:48:28 +0000 Subject: [PATCH] stub: auto-detect console device and append console= to kernel command line MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit The Linux kernel does not reliably auto-detect serial consoles on headless systems. While the docs claim serial is used as a fallback when no VGA card is found, in practice CONFIG_VT's dummy console (dummycon) registers early and satisfies the kernel's console requirement, preventing the serial fallback from ever triggering. The ACPI SPCR table can help on ARM/RISC-V where QEMU generates it, but x86 QEMU does not produce SPCR, and SPCR cannot describe virtio consoles at all. This means UKIs booted via sd-stub in headless VMs produce no visible console output unless console= is explicitly passed on the kernel command line. Fix this by having sd-stub auto-detect the console type and append an appropriate console= argument when one isn't already present. Detection priority: 1. VirtIO console PCI device (vendor 0x1AF4, device 0x1003): if exactly one is found, append console=hvc0. This takes highest priority since a VirtIO console is explicitly configured by the VMM (e.g. systemd-vmspawn's virtconsole device). If multiple VirtIO console devices exist, we cannot determine which hvc index is correct, so we skip this path entirely. 2. EFI Graphics Output Protocol (GOP): if present, don't add any console= argument. The kernel will use the framebuffer console by default, and adding a serial console= would redirect the primary console away from the display. 3. Serial console: first, we count the total number of serial devices via EFI_SERIAL_IO_PROTOCOL. If there are zero or more than one, we bail out — with multiple UARTs, the kernel assigns ttyS indices based on its own enumeration order and we cannot determine which index the console UART will receive. Only when exactly one serial device exists (guaranteeing it will be ttyS0) do we proceed to verify it's actually used as a console by checking for UART device path nodes (MESSAGING_DEVICE_PATH + MSG_UART_DP). The firmware's ConOut handle is checked first; if it has no device path (common with OVMF's ConSplitter virtual handle when using -nographic -nodefaults), we fall back to enumerating all EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL handles and checking each one's device path. The architecture-specific console argument is then appended: - x86: console=ttyS0 - ARM: console=ttyAMA0 - Others: console=ttyS0 (RISC-V, LoongArch, MIPS all use ttyS0) Note on OVMF's VirtioSerialDxe: it exposes virtio serial ports with the same UART device path nodes as real serial ports (ACPI PNP 0x0501 + MSG_UART_DP), making them indistinguishable from real UARTs via device path inspection alone. This is why we check for the VirtIO console PCI device via EFI_PCI_IO_PROTOCOL before falling back to device path analysis. Also add a minimal EFI_PCI_IO_PROTOCOL definition (proto/pci-io.h) with just enough to call Pci.Read for vendor/device ID enumeration, and add the MSG_UART_DP subtype to the device path header. Co-developed-by: Claude Opus 4.6 --- src/boot/proto/device-path.h | 1 + src/boot/proto/pci-io.h | 43 +++++++ src/boot/proto/serial-io.h | 7 ++ src/boot/stub.c | 224 +++++++++++++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 src/boot/proto/pci-io.h create mode 100644 src/boot/proto/serial-io.h diff --git a/src/boot/proto/device-path.h b/src/boot/proto/device-path.h index b56c217082d..531ff3d003b 100644 --- a/src/boot/proto/device-path.h +++ b/src/boot/proto/device-path.h @@ -33,6 +33,7 @@ enum { MEDIA_PIWG_FW_FILE_DP = 0x06, MEDIA_PIWG_FW_VOL_DP = 0x07, + MSG_UART_DP = 0x0e, MSG_URI_DP = 24, }; diff --git a/src/boot/proto/pci-io.h b/src/boot/proto/pci-io.h new file mode 100644 index 00000000000..d05f0a683ce --- /dev/null +++ b/src/boot/proto/pci-io.h @@ -0,0 +1,43 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "efi.h" + +#define EFI_PCI_IO_PROTOCOL_GUID \ + GUID_DEF(0x4cf5b200, 0x68b8, 0x4ca5, 0x9e, 0xec, 0xb2, 0x3e, 0x3f, 0x50, 0x02, 0x9a) + +typedef enum { + EfiPciIoWidthUint8 = 0, + EfiPciIoWidthUint16, + EfiPciIoWidthUint32, + EfiPciIoWidthUint64, +} EFI_PCI_IO_PROTOCOL_WIDTH; + +typedef struct EFI_PCI_IO_PROTOCOL EFI_PCI_IO_PROTOCOL; + +typedef EFI_STATUS (EFIAPI *EFI_PCI_IO_PROTOCOL_CONFIG)( + EFI_PCI_IO_PROTOCOL *This, + EFI_PCI_IO_PROTOCOL_WIDTH Width, + uint32_t Offset, + size_t Count, + void *Buffer); + +typedef struct { + EFI_PCI_IO_PROTOCOL_CONFIG Read; + EFI_PCI_IO_PROTOCOL_CONFIG Write; +} EFI_PCI_IO_PROTOCOL_CONFIG_ACCESS; + +/* Minimal definition — only Pci.Read is used. Fields before Pci must be correctly sized + * (one function pointer each for PollMem/PollIo, two for Mem.Read/Write, two for Io.Read/Write) + * to ensure Pci is at the right offset. */ +struct EFI_PCI_IO_PROTOCOL { + void *PollMem; + void *PollIo; + EFI_PCI_IO_PROTOCOL_CONFIG_ACCESS Mem; + EFI_PCI_IO_PROTOCOL_CONFIG_ACCESS Io; + EFI_PCI_IO_PROTOCOL_CONFIG_ACCESS Pci; + /* remaining fields omitted */ +}; + +#define PCI_VENDOR_ID_REDHAT 0x1af4U +#define PCI_DEVICE_ID_VIRTIO_CONSOLE 0x1003U diff --git a/src/boot/proto/serial-io.h b/src/boot/proto/serial-io.h new file mode 100644 index 00000000000..98690c108c2 --- /dev/null +++ b/src/boot/proto/serial-io.h @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "efi.h" + +#define EFI_SERIAL_IO_PROTOCOL_GUID \ + GUID_DEF(0xbb25cf6f, 0xf1d4, 0x11d2, 0x9a, 0x0c, 0x00, 0x90, 0x27, 0x3f, 0xc1, 0xfd) diff --git a/src/boot/stub.c b/src/boot/stub.c index 66b20805d53..6b5ae0a6878 100644 --- a/src/boot/stub.c +++ b/src/boot/stub.c @@ -6,6 +6,7 @@ #include "devicetree.h" #include "efi-efivars.h" #include "efi-log.h" +#include "efi-string.h" #include "export-vars.h" #include "graphics.h" #include "iovec-util-fundamental.h" @@ -14,6 +15,9 @@ #include "memory-util-fundamental.h" #include "part-discovery.h" #include "pe.h" +#include "proto/graphics-output.h" +#include "proto/pci-io.h" +#include "proto/serial-io.h" /* IWYU pragma: keep */ #include "proto/shell-parameters.h" #include "random-seed.h" #include "sbat.h" @@ -1242,6 +1246,220 @@ static void measure_profile(unsigned profile, int *parameters_measured) { combine_measured_flag(parameters_measured, m); } +static bool has_virtio_console_pci_device(void) { + _cleanup_free_ EFI_HANDLE *handles = NULL; + size_t n_handles = 0; + + EFI_STATUS err = BS->LocateHandleBuffer( + ByProtocol, + MAKE_GUID_PTR(EFI_PCI_IO_PROTOCOL), + NULL, + &n_handles, + &handles); + if (err != EFI_SUCCESS) { + log_debug_status(err, "Failed to locate PCI I/O protocol handles, assuming no VirtIO console: %m"); + return false; + } + + if (n_handles == 0) { + log_debug("No PCI devices found, not scanning for VirtIO console."); + return false; + } + + log_debug("Found %zu PCI devices, scanning for VirtIO console...", n_handles); + + size_t n_virtio_console = 0; + + for (size_t i = 0; i < n_handles; i++) { + EFI_PCI_IO_PROTOCOL *pci_io = NULL; + + if (BS->HandleProtocol(handles[i], MAKE_GUID_PTR(EFI_PCI_IO_PROTOCOL), (void **) &pci_io) != EFI_SUCCESS) + continue; + + uint16_t vendor_id = 0, device_id = 0; + if (pci_io->Pci.Read(pci_io, EfiPciIoWidthUint16, 0x00, 1, &vendor_id) != EFI_SUCCESS) + continue; + if (pci_io->Pci.Read(pci_io, EfiPciIoWidthUint16, 0x02, 1, &device_id) != EFI_SUCCESS) + continue; + + log_debug("PCI device %zu: vendor=%04x device=%04x", i, vendor_id, device_id); + + if (vendor_id == PCI_VENDOR_ID_REDHAT && device_id == PCI_DEVICE_ID_VIRTIO_CONSOLE) + n_virtio_console++; + + if (n_virtio_console > 1) { + log_debug("There is more than one VirtIO console PCI device, cannot determine which one is the console."); + return false; + } + } + + if (n_virtio_console == 0) { + log_debug("No VirtIO console PCI device found."); + return false; + } + + log_debug("Found exactly one VirtIO console PCI device."); + return true; +} + +static bool device_path_has_uart(const EFI_DEVICE_PATH *dp) { + for (const EFI_DEVICE_PATH *node = dp; !device_path_is_end(node); node = device_path_next_node(node)) + if (node->Type == MESSAGING_DEVICE_PATH && node->SubType == MSG_UART_DP) + return true; + + return false; +} + +static size_t count_serial_devices(void) { + _cleanup_free_ EFI_HANDLE *handles = NULL; + size_t n_handles = 0; + + if (BS->LocateHandleBuffer( + ByProtocol, + MAKE_GUID_PTR(EFI_SERIAL_IO_PROTOCOL), + NULL, + &n_handles, + &handles) != EFI_SUCCESS) + return 0; + + log_debug("Found %zu serial I/O devices in total.", n_handles); + return n_handles; +} + +static bool has_single_serial_console(void) { + /* Even if we find exactly one serial console, we can only confidently map it to ttyS0 + * if there's only one serial device in the entire system. With multiple UARTs, the + * kernel assigns ttyS indices based on its own discovery order, so the console UART + * might end up as ttyS1 or higher. */ + size_t n_serial_devices = count_serial_devices(); + if (n_serial_devices == 0) { + log_debug("No serial I/O devices found."); + return false; + } + if (n_serial_devices > 1) { + log_debug("Found %zu serial I/O devices, cannot determine ttyS index.", n_serial_devices); + return false; + } + + /* Exactly one serial device in the system. Verify it's actually used as a console + * by checking if ConOut or any text output handle has a UART device path. */ + + /* First try the ConOut handle directly */ + EFI_DEVICE_PATH *dp = NULL; + if (BS->HandleProtocol(ST->ConsoleOutHandle, MAKE_GUID_PTR(EFI_DEVICE_PATH_PROTOCOL), (void **) &dp) == EFI_SUCCESS) { + _cleanup_free_ char16_t *dp_str = NULL; + (void) device_path_to_str(dp, &dp_str); + log_debug("ConOut device path: %ls", strempty(dp_str)); + + if (device_path_has_uart(dp)) { + log_debug("ConOut device path contains UART node."); + return true; + } + + log_debug("ConOut device path does not contain UART node."); + return false; + } + + /* ConOut handle has no device path (e.g. ConSplitter virtual handle). Enumerate all + * text output handles and check if any of them is a serial console. */ + log_debug("ConOut handle has no device path, enumerating text output handles..."); + + _cleanup_free_ EFI_HANDLE *handles = NULL; + size_t n_handles = 0; + if (BS->LocateHandleBuffer( + ByProtocol, + MAKE_GUID_PTR(EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL), + NULL, + &n_handles, + &handles) != EFI_SUCCESS) { + log_debug("Failed to enumerate text output handles."); + return false; + } + + for (size_t i = 0; i < n_handles; i++) { + dp = NULL; + if (BS->HandleProtocol(handles[i], MAKE_GUID_PTR(EFI_DEVICE_PATH_PROTOCOL), (void **) &dp) != EFI_SUCCESS) + continue; + + _cleanup_free_ char16_t *dp_str = NULL; + (void) device_path_to_str(dp, &dp_str); + log_debug("Text output handle %zu device path: %ls", i, strempty(dp_str)); + + if (device_path_has_uart(dp)) { + log_debug("Text output handle %zu is a serial console.", i); + return true; + } + } + + log_debug("No serial console found among text output handles."); + return false; +} + +static bool has_graphics_output(void) { + EFI_GRAPHICS_OUTPUT_PROTOCOL *gop = NULL; + EFI_STATUS err; + + err = BS->LocateProtocol(MAKE_GUID_PTR(EFI_GRAPHICS_OUTPUT_PROTOCOL), NULL, (void **) &gop); + if (err != EFI_SUCCESS) { + log_debug_status(err, "No EFI Graphics Output Protocol found: %m"); + return false; + } + + log_debug("EFI Graphics Output Protocol found."); + return true; +} + +static const char16_t *serial_console_arg(void) { +#if defined(__arm__) || defined(__aarch64__) + return u"console=ttyAMA0"; +#else + return u"console=ttyS0"; +#endif +} + +/* If there's no console= in the command line yet, try to detect the appropriate console device. + * + * Detection order: + * 1. If exactly one VirtIO console PCI device exists → console=hvc0 + * 2. If there's graphical output (GOP) → don't add console=, the kernel defaults are fine + * 3. If exactly one serial console exists → arch-specific serial (ttyS0, ttyAMA0, etc.) + * 4. Otherwise → don't add console=, let the user handle it + * + * VirtIO console takes priority since it's explicitly configured by the VMM. Graphics is + * checked before serial to avoid accidentally redirecting output away from a graphical + * console by adding a serial console= argument. */ +static void cmdline_append_console(char16_t **cmdline) { + assert(cmdline); + + if (*cmdline && (efi_fnmatch(u"console=*", *cmdline) || efi_fnmatch(u"* console=*", *cmdline))) { + log_debug("Kernel command line already contains console=, not adding one."); + return; + } + + const char16_t *console_arg = NULL; + + if (has_virtio_console_pci_device()) + console_arg = u"console=hvc0"; + else if (has_graphics_output()) { + log_debug("Graphical output available, not adding console= to kernel command line."); + return; + } else if (has_single_serial_console()) + console_arg = serial_console_arg(); + + if (!console_arg) { + log_debug("Cannot determine console type, not adding console= to kernel command line."); + return; + } + + log_debug("Appending %ls to kernel command line.", console_arg); + + _cleanup_free_ char16_t *old = TAKE_PTR(*cmdline); + if (isempty(old)) + *cmdline = xstrdup16(console_arg); + else + *cmdline = xasprintf("%ls %ls", old, console_arg); +} + static EFI_STATUS run(EFI_HANDLE image) { int sections_measured = -1, parameters_measured = -1, sysext_measured = -1, confext_measured = -1; _cleanup_(devicetree_cleanup) struct devicetree_state dt_state = {}; @@ -1304,6 +1522,12 @@ static EFI_STATUS run(EFI_HANDLE image) { cmdline_append_and_measure_addons(cmdline_addons, &cmdline, ¶meters_measured); cmdline_append_and_measure_smbios(&cmdline, ¶meters_measured); + /* Console auto-detection is intentionally not TPM-measured. The value is deterministically + * derived from firmware-reported hardware state (PCI device enumeration, GOP presence, serial + * device paths), so it doesn't represent an independent input that could be manipulated + * without also changing the firmware environment that TPM already captures. */ + cmdline_append_console(&cmdline); + export_common_variables(loaded_image); export_stub_variables(loaded_image, profile); -- 2.47.3