diff --git a/examples/uart_tx.cpp b/examples/uart_tx.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..333adb021ee639cd9a45ab2051472404c9cd39ff
--- /dev/null
+++ b/examples/uart_tx.cpp
@@ -0,0 +1,16 @@
+#include "libnux/vx/time.h"
+#include "libnux/vx/uart.h"
+
+using namespace libnux::vx;
+
+auto uart = SoftUartTx(115'200, UART_8N1);
+
+int start()
+{
+	sleep_cycles(100'000 * default_ppu_cycles_per_us);
+
+	uart.printf("hello world");
+
+	sleep_cycles(100'000 * default_ppu_cycles_per_us);
+	return 0;
+}
diff --git a/include/libnux/vx/uart.h b/include/libnux/vx/uart.h
new file mode 100644
index 0000000000000000000000000000000000000000..4e8267d179dc248691054846b860df6dde1b73be
--- /dev/null
+++ b/include/libnux/vx/uart.h
@@ -0,0 +1,103 @@
+#pragma once
+
+#include "libnux/vx/mailbox.h"
+#include "libnux/vx/spr.h"
+
+#include <cstddef>
+#include <cstdint>
+#include <memory>
+
+namespace libnux::vx {
+
+using uart_data_rate_t = unsigned long;
+using uart_word_t = uint16_t;
+
+
+enum class Parity : uint8_t
+{
+	NONE,
+	EVEN,
+	ODD,
+};
+
+/**
+ * Configuration of a UART interface.
+ */
+struct UartConfiguration
+{
+	/**
+	 * Creates the configuration for a UART interface.
+	 *
+	 * @param width Width of the interface. Must not exceed the width of `uart_word_t`.
+	 * @param parity Parity setting for the communication.
+	 * @param stop_bits Amount of stop bits to send/expect.
+	 */
+	constexpr UartConfiguration(size_t const width, Parity const parity, size_t const stop_bits) :
+	    width(width), parity(parity), stop_bits(stop_bits)
+	{
+		if (width > std::numeric_limits<uart_word_t>::digits) {
+			mailbox_write_string("ERROR: UART width exceeds capacity of the underlying data type!");
+			exit(1);
+		}
+	};
+
+	size_t const width;
+	Parity const parity;
+	size_t const stop_bits;
+};
+
+constexpr UartConfiguration UART_8N1{8, Parity::NONE, 1};
+
+/**
+ * Software defined UART for transmitting serial data via the PPU's GPIO pin.
+ */
+class SoftUartTx
+{
+public:
+	/**
+	 * Creates a SoftUartTx instance.
+	 *
+	 * We eagerly allocate a buffer for printf-formatting to ensure that printing does not require
+	 * additional excessive memory allocation.
+	 *
+	 * @param data_rate Data rate (in baud) to be used.
+	 * @param config UART configuration, defaults to 8N1: 8bit, no parity, one stop bit.
+	 * @param invert_physical Invert the signal on the line (idle 'low' if true).
+	 * @param buffer_size Size of the string buffer (in byte) for printf formatting.
+	 */
+	explicit SoftUartTx(
+	    uart_data_rate_t data_rate,
+	    UartConfiguration const& config = UART_8N1,
+	    bool invert_physical = false,
+	    size_t buffer_size = 128);
+
+	~SoftUartTx();
+
+	/**
+	 * Transmit a single word through the UART. Blocks until the transmission is done.
+	 *
+	 * @param data Data to be written.
+	 */
+	void write(uart_word_t data) const;
+
+	/**
+	 * Write a printf-formatted string via this UART. Blocks until the transmission is done.
+	 *
+	 * @param format A printf-style format string
+	 * @return Number of bytes that resulted from formatting. In case of truncation, this number can
+	 *         be larger than the `buffer_size` specified on construction. Can be negative in case
+	 *         of formatting errors.
+	 */
+	int printf(char const* format, ...) const;
+
+private:
+	uart_data_rate_t const data_rate;
+	time_base_t const cycles_per_bit;
+	UartConfiguration const& config;
+	bool const invert_physical;
+	size_t const buffer_size;
+	std::unique_ptr<char[]> string_buffer;
+	[[nodiscard]] constexpr bool logic2physical(bool value) const;
+};
+
+} // namespace libnux::vx
diff --git a/src/libnux/vx/uart.cpp b/src/libnux/vx/uart.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..320011111853efc1fcac8589c980d062bbdfd902
--- /dev/null
+++ b/src/libnux/vx/uart.cpp
@@ -0,0 +1,107 @@
+#include "libnux/vx/uart.h"
+#include "libnux/vx/time.h"
+
+#include <cstdarg>
+#include <cstdio>
+#include <vector>
+
+namespace libnux::vx {
+
+SoftUartTx::SoftUartTx(
+    uart_data_rate_t const data_rate,
+    UartConfiguration const& config,
+    bool const invert_physical,
+    size_t const buffer_size) :
+    data_rate(data_rate),
+    cycles_per_bit(default_ppu_cycles_per_us * 1'000'000 / data_rate),
+    config(config),
+    invert_physical(invert_physical),
+    buffer_size(buffer_size),
+    string_buffer(std::make_unique<char[]>(buffer_size))
+{
+	// configure output mode for GPIO
+	set_goe(false); // inverted
+
+	// default logic 'high'
+	set_gout(logic2physical(true));
+}
+
+SoftUartTx::~SoftUartTx()
+{
+	// should not be necessary, cf. issue #4051
+	string_buffer.reset();
+}
+
+constexpr bool SoftUartTx::logic2physical(bool const value) const
+{
+	if (invert_physical) {
+		return (!value);
+	}
+	return (value);
+}
+
+void SoftUartTx::write(uart_word_t const data) const
+{
+	std::vector<bool> bits_to_write;
+
+	// start bit
+	bits_to_write.push_back(logic2physical(false));
+
+	// data frame
+	for (size_t bit_idx = 0; bit_idx < config.width; ++bit_idx) {
+		// Check if the i-th bit is set
+		const bool bit_value = (data & (1 << bit_idx)) != 0;
+		bits_to_write.push_back(logic2physical(bit_value));
+	}
+
+	// (optional) parity bit
+	switch (config.parity) {
+		case Parity::NONE:
+			break;
+		case Parity::EVEN:
+			bits_to_write.push_back(logic2physical(__builtin_parity(data)));
+			break;
+		case Parity::ODD:
+			bits_to_write.push_back(logic2physical(!__builtin_parity(data)));
+			break;
+	}
+
+	// stop bits
+	for (size_t stop_bit = 0; stop_bit < config.stop_bits; ++stop_bit) {
+		bits_to_write.push_back(logic2physical(true));
+	}
+
+	// warm cache
+	sleep_cycles(10);
+	set_gout(logic2physical(true));
+
+	// write bits
+	for (auto const value : bits_to_write) {
+		set_gout(value);
+		sleep_cycles(cycles_per_bit);
+	}
+}
+
+int SoftUartTx::printf(char const* const format, ...) const
+{
+	va_list args;
+
+	va_start(args, format);
+	const int retval = vsnprintf(string_buffer.get(), buffer_size, format, args);
+	va_end(args);
+
+	if (retval < 0) {
+		// formatting failed, don't output anything
+		return retval;
+	}
+
+	char const* encoded = string_buffer.get();
+	while (*encoded != '\0') {
+		write(*encoded);
+		++encoded;
+	}
+
+	return retval;
+}
+
+} // namespace libnux::vx
diff --git a/tests/hw/libnux/vx/test_uart.cpp b/tests/hw/libnux/vx/test_uart.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c2b9a25665bc93b95f1378aa899faccc3e967c7f
--- /dev/null
+++ b/tests/hw/libnux/vx/test_uart.cpp
@@ -0,0 +1,38 @@
+#include "libnux/vx/uart.h"
+#include "libnux/vx/unittest.h"
+
+using namespace libnux::vx;
+
+auto uart = SoftUartTx(115'200, UART_8N1);
+
+void test_uart_send_byte()
+{
+	testcase_begin("uart_send_byte");
+	uart.write(0xAA);
+	testcase_end();
+}
+
+void test_uart_send_string()
+{
+	testcase_begin("uart_send_string");
+	size_t bytes_sent;
+
+	bytes_sent = uart.printf("hello");
+	test_equal(bytes_sent, 5u);
+
+	bytes_sent = uart.printf("abc%d", 123);
+	test_equal(bytes_sent, 6u);
+
+	bytes_sent = uart.printf("%.2f", 3.1416);
+	test_equal(bytes_sent, 4u);
+	testcase_end();
+}
+
+void start()
+{
+	test_init();
+	test_uart_send_byte();
+	test_uart_send_string();
+	test_summary();
+	test_shutdown();
+}
diff --git a/wscript b/wscript
index 19838c4bc73e1f1dd78bd8bfb1a9acb32c965a2a..dff4aaf92d5f3a4b1f8356d47497823df487b2b7 100644
--- a/wscript
+++ b/wscript
@@ -217,7 +217,7 @@ def build(bld):
             env=env,
         )
 
-        program_list = ["examples/stdp.cpp"]
+        program_list = ["examples/stdp.cpp", "examples/uart_tx.cpp"]
 
         for program in program_list:
             bld.program(