Skip to content

UART and console

The APBUART peripheral is the path between guest software and the host. Every byte a guest writes to a UART's transmit register is handed to that UART's injected ICharacterDevice; every byte the host makes available through the device is delivered to the UART's receive register. This page covers the device interface, the per-SoC UART map, routing, capturing, and suppression.

The ICharacterDevice interface

A UART talks to the host through a tiny byte-stream interface (src/interfaces/include/tero/icharacter_device.hpp):

class ICharacterDevice {
public:
    virtual ~ICharacterDevice() = default;

    /// Push one character to the sink (UART TX → host).
    virtual void write_char(char c) = 0;

    /// Pop one character from the source (host → UART RX), or
    /// std::nullopt if none is ready.
    virtual std::optional<char> read_char() = 0;

    /// True if the next read_char() would return a value.
    [[nodiscard]] virtual bool has_input() const = 0;
};

Three methods, no framing or baud-rate semantics. Implement it to send guest output anywhere (stdout, a file, a socket, a PTY, an SMP2 field) and to feed input back in.

Word-only MMIO on APBUART

APBUART (like IRQMP, GPTIMER, and MemCtrl) accepts word-sized accesses only; a byte/half-word MMIO access returns AlignmentError. Guests use ld/st on the data register. This matters mostly for hand-written assembly tests.

The built-in devices (tero_defaults)

Class TX behaviour RX behaviour
StdoutCharDevice Writes each char to host stdout. Always empty (read_charnullopt, has_inputfalse).
LabeledStdoutCharDevice Line-buffered: accumulates until \n/\r, then emits <prefix><line>\n to stdout. Always empty.

StdoutCharDevice is what tero-emu wires on the console UART (UART0) by default. LabeledStdoutCharDevice is what the hello-gr712rc / hello-gr740 examples use to tag every UART's output so interleaved lines stay readable.

For capturing into a string in tests there is a CapturingCharDevice in the test-support tree (not in tero_defaults, so production binaries don't link an unused collector).

Per-SoC UART map

Both SoCs ship more than one UART. Emulator::initialize() instantiates all of them (from the recipe's PeripheralSpec list) with their real silicon IRQs. Only the UART whose chardev_index resolves to a non-null device produces visible output; by default that is UART0 (the console). Auxiliary UARTs with a null chardev drop TX writes and report empty RX, but they still raise IRQs exactly as the silicon does.

GR712RC

UART MMIO base chardev_index IRQ
UART 0 0x80000100 0 (console) 2
UART 1 0x80100100 1 17 (via EIRQ)
UART 2 0x80100200 2 18 (via EIRQ)
UART 3 0x80100300 3 19 (via EIRQ)
UART 4 0x80100400 4 20 (via EIRQ)
UART 5 0x80100500 5 21 (via EIRQ)

UART0 sits on the primary APB bridge (0x800000000x800FFFFF); UART1..5 sit on the secondary APB bridge (0x801000000x801FFFFF). The recipe sizes character_devices to 6 (all nullptr); tero-emu then sets UART0 to a StdoutCharDevice.

GR740

UART MMIO base chardev_index IRQ
UART 0 0xFF900000 0 (console) 29 (via EIRQ)
UART 1 0xFF901000 1 30 (via EIRQ)

Both UARTs share APB bridge 0 (0xFF9000000xFF9FFFFF). The recipe sizes character_devices to 2.

Extended-IRQ delivery (levels 16..31)

UART IRQs above 15 are extended IRQs — they don't fit in the 4-bit SPARC V8 PSR.PIL field. The IRQ(A)MP redirects them to a regular CPU level (MPSTAT.EIRQ, default 12). When the CPU traps at that level, the IRQ(A)MP pops the highest pending extended IRQ from its pending_[16..31] vector, clears its bit, and writes the full index into the per-CPU EID register; the RTEMS handler reads EID[cpu] to discover the real source. See Traps and interrupts and the IRQMP reference.

How the UART list is shaped

There is no EmulatorConfig::uarts field. UARTs are ordinary PeripheralSpec entries in cfg.peripherals (the recipes build them with make_apbuart_spec), and the host-side devices live in the parallel cfg.character_devices pool, referenced by each UART spec's chardev_index. To change the UART set, edit those two vectors after the recipe call — for example, to keep only UART0 and UART1 on GR712RC you'd remove the unwanted APBUART specs from cfg.peripherals (and shrink cfg.character_devices to match). In practice most users just attach a device to the UART they care about and leave the rest silent.

Default: stdout / stdin (UART0)

By default tero-emu wires a StdoutCharDevice onto the console UART:

emu.set_uart_character_device(
    0, std::make_unique<tero::defaults::StdoutCharDevice>());

That means:

  • Every byte the guest writes to UART0's transmit register appears on the host's stdout.
  • UART0's read_char is the host input path; StdoutCharDevice returns empty, so by default there is no console input (supply your own device for interactive input).
  • Auxiliary UARTs receive no chardev: their TX writes are dropped and RX reads are empty, but their IRQ lines still fire (UART1..5 on GR712RC at IRQs 17..21; UART1 on GR740 at IRQ 30), so RTEMS' ambapp driver discovers and binds them.

set_character_device(dev) is sugar for set_uart_character_device(0, dev).

Routing every UART's output to the host

Each UART has its own chardev slot. set_uart_character_device(i, dev) overwrites slot i before initialize(). Pair it with LabeledStdoutCharDevice to surface every UART with a per-instance prefix:

#include "tero/defaults/labeled_stdout_char_device.hpp"
#include <fmt/format.h>

auto cfg = tero::compose::gr712rc_config();
auto emu = std::move(*tero::runtime::Emulator::create(cfg));
for (std::size_t i = 0; i < cfg.character_devices.size(); ++i) {
    emu->set_uart_character_device(i,
        std::make_unique<tero::defaults::LabeledStdoutCharDevice>(
            fmt::format("[apbuart{}] ", i)));
}
emu->initialize();

The output looks like:

[apbuart0] *** RTEMS hello ***
[apbuart1] UART1: 100
[apbuart2] UART2: 200

LabeledStdoutCharDevice is line-buffered: bytes accumulate until \n or \r, then the whole line is emitted as <prefix><line>\n. Interleaved output from different UARTs stays readable because each tagged line is atomic. Both bundled examples enable this by default — iterate over cfg.character_devices.size() and label slot i.

Capturing into a string

For test rigs and CI, replace the console device with the test-support collector and assert on its contents:

#include "tests/support/capturing_char_device.hpp"

auto cap  = std::make_unique<tero::test_support::CapturingCharDevice>();
auto* ref = cap.get();
emu->set_character_device(std::move(cap));   // console UART (UART0)
emu->initialize();
emu->load_elf("hello.elf");
emu->run_for(tero::SimTimeNs{2'000'000'000ULL});

REQUIRE(ref->captured().find("*** END OF TEST") != std::string::npos);

Suppressing output

If you don't care what the guest prints (e.g. benchmarking the core), inject a null sink — cheaper than /dev/null because the byte never leaves the emulator:

class NullCharDevice : public tero::ICharacterDevice {
    void write_char(char) override {}
    std::optional<char> read_char() override { return std::nullopt; }
    bool has_input() const override { return false; }
};

emu->set_uart_character_device(0, std::make_unique<NullCharDevice>());

A UART with no device (the default for auxiliary UARTs) behaves the same way — drop TX, empty RX.

Providing console input

To feed the guest characters, implement read_char/has_input. A queue backed by host input is the common shape:

class QueueCharDevice : public tero::ICharacterDevice {
public:
    void push(char c) { q_.push(c); }          // host side enqueues input
    void write_char(char c) override { std::fputc(c, stdout); }  // TX → stdout
    std::optional<char> read_char() override {
        if (q_.empty()) return std::nullopt;
        char c = q_.front(); q_.pop(); return c;
    }
    bool has_input() const override { return !q_.empty(); }
private:
    std::queue<char> q_;
};

The APBUART polls has_input() / read_char() and surfaces bytes through its RX register; RTEMS' console driver reads them as ordinary input.

Bridging to a real serial port (PTY)

For interactive RTEMS sessions, back the device with a host PTY so a terminal can attach (screen /dev/pts/N, etc.):

class PtyCharDevice : public tero::ICharacterDevice {
public:
    explicit PtyCharDevice(int fd) : fd_{fd} {}
    void write_char(char c) override { ::write(fd_, &c, 1); }
    std::optional<char> read_char() override {
        char c{};
        return ::read(fd_, &c, 1) == 1 ? std::optional<char>{c} : std::nullopt;
    }
    bool has_input() const override { /* poll fd_ for readability */ return false; }
private:
    int fd_;
};

Pair this with posix_openpt / grantpt / unlockpt to obtain the PTY pair.

What the model does (and doesn't)

  • TX: drains immediately to the injected ICharacterDevice::write_char — there is no timed TX FIFO, so output appears as fast as the guest writes it.
  • RX: bytes from read_char() are presented through the receive register / data-ready status; the guest reads them with ld.
  • Baud rate, parity, stop bits, hardware flow control: the registers exist and store what the guest writes, but they produce no observable timing change. RTEMS programs them; Tero stores the bytes and nothing else happens.
  • The transmit-enable bit must be set by the guest before output is emitted; RTEMS does this during boot, but raw assembly tests must set it explicitly.

If you need a more accurate UART (cycle-accurate baud, framing errors, hardware flow control), wrap the APBUART with a custom peripheral and intercept the data path — the architecture supports it without touching the core. See the APBUART peripheral reference and Custom peripherals.