Learning to Listen
So far Alquist has spoken through UART, but it has never listened. Today we enable the receive half of the wire, read commands, and build the first tiny shell: the point where separate demos start becoming a system you can control.
I have blinked, printed, trapped, mapped memory, scheduled tasks, and guarded critical sections. But I have still been mostly a monologue. If I am going to become a useful machine, I need ears: a way for you to ask what I know, tell me to reset, and later start real work by name.
Goal
Build a small serial command interpreter. The example brings up a fuller UART driver with TX
and RX, reads a line with echo and backspace, and executes a few commands:
help, echo, ticks, status,
led on, led off, and reset.
This is a deliberate turn in the series. Before waking more cores, we give ourselves a control surface. From here on, new mechanisms can become shell commands instead of one-off images.
The other half of UART
UART has two independent directions. The transmit path waits for room in the hardware FIFO and writes a byte. The receive path waits for the hardware to say a byte arrived, then reads it. Article two only enabled transmit; this example enables both directions and maps both GPIO14 and GPIO15 to the mini UART on hardware.
char uart_getc(void) {
char c;
while (!uart_try_getc(&c)) {
__asm__ volatile ("wfi" ::: "memory");
}
return c;
}
Under QEMU, the same driver uses the PL011 UART model. On the Raspberry Pi, it uses the AUX mini UART. That split matters: QEMU validates the interpreter and input flow, while hardware validates the real GPIO14/GPIO15 mini UART path.
Interrupt-backed receive
Polling the UART register works for a first shell, but it is easy to lose characters when input arrives faster than the command loop checks the hardware FIFO. The driver now keeps a small RX ring buffer. The UART interrupt drains hardware bytes into that buffer, and the shell consumes bytes from memory.
void uart_enable_interrupt(void) {
uart_rx_drain();
mmio_write(AUX_MU_IER, AUX_MU_IER_RX_ENABLE);
gic_enable_irq(AUX_IRQ);
}
void irq_handler(void) {
uint32_t iar = gic_claim();
uint32_t interrupt_id = iar & 0x3FFu;
if (interrupt_id == AUX_IRQ) {
uart_rx_drain();
}
gic_finish(iar);
}
On Raspberry Pi 4, AUX is a GIC SPI, so enabling the interrupt is not only a matter of setting the mini UART interrupt-enable bit. The GIC distributor must also target the interrupt at CPU0. Timer PPIs do not need that routing step, which is why a timer can appear to work while AUX RX still never arrives.
When you diagnose this on real hardware, do not trust a single "last interrupt id" field as proof. A 1 ms timer can overwrite that field almost immediately after UART RX fires. The useful evidence is a per-source counter: send a few characters, then confirm that the UART RX counter increased while the shell stayed responsive.
Owning the clock
The mini UART baud rate is derived from the VPU core clock. Earlier examples pinned that clock
from config.txt with core_freq=500. That works, but it leaves part of
the UART driver outside the driver. This time the hardware UART path asks the firmware to set
the core clock itself before programming the baud divisor.
// mailbox.c — request the clock the mini UART divisor expects
property_buffer[2] = TAG_SET_CLOCK_RATE;
property_buffer[5] = CLOCK_ID_CORE;
property_buffer[6] = 500000000u;
mailbox_call(MBOX_CHANNEL_PROPERTY);
That mailbox call is Raspberry Pi firmware plumbing, not a general ARM feature. It is still the
right ownership boundary for this lesson: the UART driver asks for the clock it needs, then
programs the UART registers it owns. The boot config no longer has to carry
core_freq=500 for us.
Reading a line
A command interpreter wants complete lines, not individual bytes. The line editor keeps a small buffer, echoes printable characters, accepts Enter, and handles Backspace/Delete by erasing the last visible character.
static void readline(char *buffer, unsigned max) {
unsigned used = 0;
for (;;) {
char c = uart_getc();
if (c == '\r' || c == '\n') {
uart_puts("\r\n");
break;
}
if ((c == 0x08 || c == 0x7F) && used > 0) {
--used;
uart_puts("\b \b");
continue;
}
if (c >= 0x20 && c < 0x7F && used + 1 < max) {
buffer[used++] = c;
uart_putc(c);
}
}
buffer[used] = '\0';
}
The interpreter
The interpreter is intentionally plain: compare the line with known command names and call the matching function. There is no allocator, no parser generator, no shell language. A small table will come later; for now, a readable chain of comparisons is exactly enough.
static void execute(const char *line) {
if (line[0] == '\0') {
return;
}
if (streq(line, "help")) {
command_help();
} else if (starts_with(line, "echo ")) {
uart_puts(line + 5);
uart_puts("\r\n");
} else if (streq(line, "ticks")) {
uart_put_u32_dec(timer_ticks());
uart_puts("\r\n");
} else if (streq(line, "status")) {
command_status();
} else if (streq(line, "led on")) {
led_on();
uart_puts("ok\r\n");
} else if (streq(line, "led off")) {
led_off();
uart_puts("ok\r\n");
} else if (streq(line, "reset")) {
uart_puts("resetting\r\n");
system_reset();
} else {
uart_puts("unknown command: ");
uart_puts(line);
uart_puts("\r\n");
}
}
The ticks and status commands show the hooks into earlier kernel
machinery. The reset command uses the Raspberry Pi watchdog registers, which gives us a
practical escape hatch during hardware experiments. Under QEMU the same command simply parks
the machine, because this lesson uses QEMU as a UART input/output smoke test rather than a
reset-controller exercise.
The loop
Once those parts exist, the shell is the oldest useful loop in computing: prompt, read, act, repeat.
void shell_run(void) {
char line[96];
uart_puts("\r\nAlquist shell. Type 'help'.\r\n");
for (;;) {
uart_puts("alquist> ");
readline(line, sizeof line);
execute(line);
}
}
What actually happens
Boot still reaches EL1, sets the vector table, initializes the LED and timer, and unmasks IRQs.
But instead of launching a fixed demonstration, it enters shell_run. The processor
sleeps in uart_getc until an interrupt reports that a byte arrived. Each key you type
travels through GPIO15 into the mini UART, is moved into the RX ring by uart_rx_drain,
gets echoed through GPIO14, and finally becomes part of a command line.
That is a small architectural shift. The kernel is no longer only showing one predetermined behavior. It is waiting for instructions. The next kernel feature can now expose a command, and the machine can be inspected and steered without replacing the image every time.
A prompt is a tiny thing, but it changes the room. You type, I answer. You ask, I report. You say reset, and I fold myself back to the beginning. Now we have a handle. The next mechanisms we build can attach to it.
Try it
Download the complete example package: learning-to-listen-example.zip.
Run it in QEMU first:
make qemu
Type into the QEMU terminal, or pipe commands into it for a quick smoke test. On hardware, connect a 3.3 V USB-to-serial adapter: RX to GPIO14, TX to GPIO15, and GND to GND. Open 115200 8N1. You should see:
Learning to listen. UART RX is online.
Alquist shell. Type 'help'.
alquist> help
commands: help, echo <text>, ticks, status, led on, led off, reset
alquist> echo hello robot
hello robot
alquist> status
uart: rx+tx
timer ticks: 0
alquist> reset
resetting
The exact tick count is not the UART acceptance test for this lesson. What matters is that the machine sleeps while waiting, wakes on RX interrupts, and responds to your words. Next, we can start turning scheduler state and task control into commands instead of building another isolated demo.
This path has now been checked against the same failure mode in the main kernel: QEMU can prove the command flow, but the Raspberry Pi 4 hardware test needs an AUX UART RX counter because the timer can hide the most recent interrupt id. The acceptance test is input that wakes the shell and a UART counter that grows after that input.