Building VegaConsole
Now that we know the basics of UART, let’s build something cool: a read-eval-print loop (REPL) command interpreter. This interpreter will read a line from the user, determine if they passed a valid pre-defined command, then run the corresponding code for the given command and print its result. That’s all a shell really is. Let’s build it!
The full source lives at apps/vegaconsole/main.c in the accompanying vega-quickstart repository.
Getting loopy
Every line-oriented REPL ever written has the same skeleton:
1. print a prompt
2. read characters one at a time, into a buffer
3. when the user presses Enter, terminate the buffer with '\0'
4. split it into argv[0..n-1]
5. look argv[0] up in a command table; call its handler
6. go to 1
That is exactly what main.c does. The interesting part is what commands we decide to support (the options are limitless) and what actually happens when the user enters one of these commands.
The command table
Commands are defined in a single table that the rest of the code automatically searches and supports. Each entry is a triple of name, one-line help, and function pointer:
typedef int (*cmd_fn)(int argc, char **argv);
typedef struct {
const char *name;
const char *help;
cmd_fn run;
} command_t;
const command_t commands[] = {
{ "help", "list available commands", cmd_help },
{ "led", "led <color> <on|off>", cmd_led },
{ "echo", "echo <text...>", cmd_echo },
};
The classic argc (argument count) and argv (argument array of strings) makes the command bodies look like miniature main functions. If you’ve written a Unix CLI tool before, you’ve likely seen this exact dispatch pattern.
help walks the table and prints each entry. led looks the color up in a small {name, pin} array and calls GPIO_WritePinOutput. echo glues argv[1..] back together with spaces.
Line buffering and the editor
Reading characters one at a time means we get to decide what counts as “a line”. The minimum viable rule is “everything until the user presses Enter”, but a serial terminal sends a few control bytes that are worth handling explicitly. Here’s the loop, lightly trimmed:
char line[LINE_MAX];
int len = 0;
PRINTF(PROMPT);
for (;;) {
int c = GETCHAR();
if (c == '\r' || c == '\n') {
PRINTF("\r\n");
line[len] = '\0';
dispatch(line);
len = 0;
PRINTF(PROMPT);
} else if ((c == 0x7f || c == 0x08) && len > 0) { /* DEL or BS */
--len;
PRINTF("\b \b");
} else if (c >= 0x20 && c < 0x7f && len < LINE_MAX - 1) {
line[len++] = (char)c;
PUTCHAR(c);
}
/* anything else is silently dropped */
}
The three branches:
- Enter (
\rfrom most terminals): null-terminate, hand the buffer todispatch, reset, print a fresh prompt. - Backspace (DEL
0x7for BS0x08, depending on the terminal): drop the last character if there is one, then emit the\b \bsequence to make the user’s screen agree with us. - Anything printable: append to the buffer and echo. Without that echo, the user types into the void.
PUTCHAR and GETCHAR are macros from fsl_debug_console.h that resolve to DbgConsole_Putchar and DbgConsole_Getchar. They reach the same LPUART_WriteByte / LPUART_ReadByte we traced last page.
Note
What your terminal actually sends. Pressing Enter on most modern terminals sends a single
\r(0x0D); a few send\r\n. Backspace varies even more: macOS Terminal, modern Linux terminals, and minicom send DEL (0x7f) by default; some older or stricter terminals send BS (0x08). The0x20-0x7eprintable range covers everything you’d expect.Usually, if you press any arrow key, you’ll see things like
^[[A(e.g., if you press up); this is because they send a escape sequence of bytes. The loop we made actually has one additional branch to drop every byte in these escape sequences. Real shells parse those sequences to give you history and cursor movement, we keep it simple and skip that in our implementation.
Tokenizing without strtok
Splitting "led red on" into argv is the kind of thing you’d reach for strtok from the C standard library. Given we don’t have a standard library, we do something similar to strtok with a tiny custom tokenizer:
static int tokenize(char *line, char **argv, int max) {
int argc = 0;
char *p = line;
while (*p && argc < max) {
while (*p == ' ' || *p == '\t') *p++ = '\0';
if (!*p) break;
argv[argc++] = p;
while (*p && *p != ' ' && *p != '\t') ++p;
}
return argc;
}
It iterates over the line buffer, replacing runs of whitespace with '\0' and pointing each argv[i] at the start of a token. After it returns, the buffer looks like "led\0red\0on" and argv points into the right places. The great thing about this is that we don’t do any allocations or copies!
dispatch then linear-searches the command table for argv[0] and calls the handler. With such few commands a linear search is obviously fine, but if we were to grow this command table a lot…we might want to reach for a hash or sorted lookup data structure instead.
Try it
Build and run, either path:
make sim-vegaconsole # in Renode
make flash-vegaconsole # to a real board, then `make serial`
A session looks like:
=== VegaConsole ===
type 'help' to see what's available.
vega> help
commands:
help list available commands
led led <color> <on|off>
echo echo <text...>
vega> led blue on
vega> echo hello there
hello there
Note
Don’t forget if simulating with Renode, you need to use commands like
lpuart0 WriteLine "led blue on" Trueto write to the serial port.
In Renode, the LED state shows up on the simulated GPIOA peripheral; you can confirm it from the monitor by inspecting PDOR (sysbus ReadDoubleWord 0x48020000). On real hardware, you should see the actual LED change.
What’s wrong with this design?
Great, we’ve got our fancy REPL command interpreter working…however, a few things are not great about our current implementation:
The CPU is busy-waiting on a human. Every GETCHAR call spins on the STAT register until you press a key. While it spins, main cannot do anything else. There is no option to have something like “meanwhile, blink an LED in the background” or “meanwhile, sample a sensor every millisecond”. The simplest way to see that this is true is to add a command that continually does something in a loop. For example, if we added a blink <hz> command that toggles the LED in a loop with delay(): while it is running, the REPL will be frozen until it returns. The worst part is that the CPU isn’t even doing anything during the spin in GETCHAR; it’s just rejecting the same STAT bit (tens of millions of times a second)!
The whole loop is foreground work. Even if we wanted background work, there is no mechanism for it. Every line of code we’ve written since blinky has been on the main thread, scheduled by main and nothing else. That’s fine for a simple blinky and it’s kinda fine for our REPL, but the moment we want something more, like a heartbeat tick and a REPL and a button reaction, we are doomed.
The fix to both problems is the same mechanism: interrupts. The next section is about defining work and teaching the chip to call us back when that work is done or something happens, instead of asking us to keep checking. We’ll continue with the VegaConsole code as an example, but now we’ll just take the busy-wait out of GETCHAR and learn how to let the CPU sleep while it has nothing to do.
TLDR
- A REPL (read-eval-print loop) is a fixed loop, in our case we: read, tokenize, dispatch, then print.
- A command table of
{name, help, fn}gives you readable code, easyhelpoutput, and a single place to register new commands. Linear search is fine until the table gets big. - For line editing on a serial terminal, it’s nice/necessary to handle Enter, Backspace (DEL or BS), and ignoring escape sequences for things such as arrow keys.
tokenizerewrites the input buffer in place to produceargv. Simple, and no memory allocator required.GETCHARis busy-waiting on the UART status register. That works, but it pinsmainin a forever busy-loop and leaves no room for anything else. We are going to fix this in the next section.