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_char → nullopt, has_input → false). |
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 (0x80000000–0x800FFFFF); UART1..5
sit on the secondary APB bridge (0x80100000–0x801FFFFF). 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 (0xFF900000–0xFF9FFFFF). 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:
That means:
- Every byte the guest writes to UART0's transmit register appears on the
host's
stdout. - UART0's
read_charis the host input path;StdoutCharDevicereturns 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'
ambappdriver 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:
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 withld. - 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.