[PATCH v2 2/2] x86/early_printk: Add earlyprintk=tdx to drive the UART with TDVMCALLs

From: Vishal Verma

Date: Mon Sep 21 2026 - 15:54:31 EST


A TDX guest cannot execute port I/O instructions directly, but
earlyprintk's serial console still issues plain inb()/outb() and lets
each one fault into the #VE handler to be emulated as a TDVMCALL.

While that works, it is a roundabout way to get a character out.
early_serial_putc() polls the LSR, and then writes a byte, but since the
TDX guest can't directly do port I/O, a #VE exception is raised. The #VE
handler must call TDG.VP.VEINFO.GET to find out what faulted, and then
it can issue the TDVMCALL that does the actual work.

This makes #VE a functional mechanism for doing I/O, which is not
desirable, is unnecessarily complicated and fragile, and results in
twice the number of calls into the TDX module.

Instead, add an earlyprintk=tdx option, which can issue the TDVMCALLs
directly. Add a pair of tdx_serial_in() and tdx_serial_out() accessors
and set them up in this case.

Note that the output does not appear any earlier - "earlyprintk=" is an
early_param(), so the console is still registered from
parse_early_param(). This only changes how the bytes leave the guest
once it is up.

LLMs were used under supervision to create this patch, to help
understand the scope and mechanisms, create testing instrumentation
(throwaway) to count #VEs in the serial vs tdx earlyprintk setups,
and to drive lab machines to do this testing.

Signed-off-by: Vishal Verma <vishal.l.verma@xxxxxxxxx>
---
Documentation/admin-guide/kernel-parameters.txt | 9 ++++++
arch/x86/include/asm/tdx.h | 3 ++
arch/x86/coco/tdx/tdx.c | 39 +++++++++++++++++++++++++
arch/x86/kernel/early_printk.c | 37 +++++++++++++++++++++++
4 files changed, 88 insertions(+)

diff --git a/Documentation/admin-guide/kernel-parameters.txt b/Documentation/admin-guide/kernel-parameters.txt
index 33cd30996e47..263a806afd2a 100644
--- a/Documentation/admin-guide/kernel-parameters.txt
+++ b/Documentation/admin-guide/kernel-parameters.txt
@@ -1543,6 +1543,7 @@ Kernel parameters
earlyprintk=serial[,ttySn[,baudrate]]
earlyprintk=serial[,0x...[,baudrate]]
earlyprintk=ttySn[,baudrate]
+ earlyprintk=tdx[,ttySn[,baudrate]]
earlyprintk=dbgp[debugController#]
earlyprintk=mmio32,membase[,{nocfg|baudrate}]
earlyprintk=pciserial[,force],bus:device.function[,{nocfg|baudrate}]
@@ -1556,6 +1557,14 @@ Kernel parameters
Use "nocfg" to skip UART configuration, assume
BIOS/firmware has configured UART correctly.

+ On x86, "tdx" is equivalent to "serial", except that
+ in a TDX guest the serial port is driven with
+ TDVMCALLs rather than port I/O instructions, avoiding
+ the #VE exception that would otherwise be taken to
+ emulate every access. Outside a TDX guest it falls
+ back to port I/O and behaves exactly like "serial".
+ If CONFIG_INTEL_TDX_GUEST is not set, it is ignored.
+
Append ",keep" to not disable it when the real console
takes over.

diff --git a/arch/x86/include/asm/tdx.h b/arch/x86/include/asm/tdx.h
index 89e97d5761d8..325dd7c5929f 100644
--- a/arch/x86/include/asm/tdx.h
+++ b/arch/x86/include/asm/tdx.h
@@ -83,6 +83,9 @@ int tdx_mcall_extend_rtmr(u8 index, u8 *data);

u64 tdx_hcall_get_quote(u8 *buf, size_t size);

+u8 tdx_inb(u16 port);
+void tdx_outb(u8 value, u16 port);
+
void __init tdx_dump_attributes(u64 td_attr);
void __init tdx_dump_td_ctls(u64 td_ctls);

diff --git a/arch/x86/coco/tdx/tdx.c b/arch/x86/coco/tdx/tdx.c
index 7d1a93ee2534..4377fcca2c99 100644
--- a/arch/x86/coco/tdx/tdx.c
+++ b/arch/x86/coco/tdx/tdx.c
@@ -194,6 +194,45 @@ u64 tdx_hcall_get_quote(u8 *buf, size_t size)
}
EXPORT_SYMBOL_GPL(tdx_hcall_get_quote);

+/**
+ * tdx_inb() - Read a byte from an I/O port without a #VE
+ * @port: I/O port to read from
+ *
+ * Ask the VMM to perform the read with TDG.VP.VMCALL<Instruction.IO>, rather
+ * than executing an IN instruction and having the resulting #VE emulate it.
+ *
+ * Return: the byte read, or 0xff if the hypercall failed.
+ */
+u8 tdx_inb(u16 port)
+{
+ struct tdx_module_args args = {
+ .r10 = TDX_HYPERCALL_STANDARD,
+ .r11 = hcall_func(EXIT_REASON_IO_INSTRUCTION),
+ .r12 = 1,
+ .r13 = TDVMCALL_PORT_READ,
+ .r14 = port,
+ };
+
+ if (__tdx_hypercall(&args))
+ return 0xff;
+
+ return args.r11;
+}
+
+/**
+ * tdx_outb() - Write a byte to an I/O port without a #VE
+ * @value: byte to write
+ * @port: I/O port to write to
+ *
+ * Ask the VMM to perform the write with TDG.VP.VMCALL<Instruction.IO>, rather
+ * than executing an OUT instruction and having the resulting #VE emulate it.
+ */
+void tdx_outb(u8 value, u16 port)
+{
+ _tdx_hypercall(hcall_func(EXIT_REASON_IO_INSTRUCTION), 1,
+ TDVMCALL_PORT_WRITE, port, value);
+}
+
static void __noreturn tdx_panic(const char *msg)
{
struct tdx_module_args args = {
diff --git a/arch/x86/kernel/early_printk.c b/arch/x86/kernel/early_printk.c
index cba75306e5b6..2721e48783cd 100644
--- a/arch/x86/kernel/early_printk.c
+++ b/arch/x86/kernel/early_printk.c
@@ -21,6 +21,7 @@
#include <linux/usb/xhci-dbgp.h>
#include <asm/pci_x86.h>
#include <linux/static_call.h>
+#include <asm/tdx.h>

/* Simple VGA output */
#define VGABASE (__ISA_IO_base + 0xb8000)
@@ -111,6 +112,32 @@ ANNOTATE_NOENDBR_SYM(io_serial_out);
DEFINE_STATIC_CALL(serial_in, io_serial_in);
DEFINE_STATIC_CALL(serial_out, io_serial_out);

+#ifdef CONFIG_INTEL_TDX_GUEST
+/*
+ * A TDX guest cannot execute port I/O instructions, so ask the VMM to do it.
+ */
+static __noendbr unsigned int tdx_serial_in(unsigned long addr, int offset)
+{
+ return tdx_inb(addr + offset);
+}
+ANNOTATE_NOENDBR_SYM(tdx_serial_in);
+
+static __noendbr void tdx_serial_out(unsigned long addr, int offset, int value)
+{
+ tdx_outb(value, addr + offset);
+}
+ANNOTATE_NOENDBR_SYM(tdx_serial_out);
+
+static __init void early_serial_tdx_init(void)
+{
+ if (!cpu_feature_enabled(X86_FEATURE_TDX_GUEST))
+ return;
+
+ static_call_update(serial_in, tdx_serial_in);
+ static_call_update(serial_out, tdx_serial_out);
+}
+#endif /* CONFIG_INTEL_TDX_GUEST */
+
static int early_serial_putc(unsigned char ch)
{
unsigned timeout = 0xffff;
@@ -414,6 +441,16 @@ static int __init setup_early_printk(char *buf)
early_serial_init(buf + 4);
early_console_register(&early_serial_console, keep);
}
+#ifdef CONFIG_INTEL_TDX_GUEST
+ if (!strncmp(buf, "tdx", 3)) {
+ buf += 3;
+ early_serial_tdx_init();
+ early_serial_init(buf);
+ early_console_register(&early_serial_console, keep);
+ if (!strncmp(buf, ",ttyS", 5))
+ buf += 5;
+ }
+#endif
#ifdef CONFIG_PCI
if (!strncmp(buf, "pciserial", 9)) {
buf += 9; /* Keep from match the above "pciserial" */

--
2.55.0