Testing UI firmware with GDB/MI
Recently, in my (over-engineered) altimeter firmware project, I started to implement a state machine to describe the UI and its various menus. As part of this development, I need to be able to interact with the UI to perform some basic usability and integration testing, checking that the UI "feels" right, and that the rest of the firmware behaves correctly during UI input events.
qemu-system-avr -s -S -nographic -machine mega2560 -bios build/altimeter_sim.elf
,--------------------.
|-51.2 m _1 m/s |
|1019.41 hPa |
|Set 1013.25 hPa |
|12.3 C |
`--------------------'
I've long had the mock LCD interface which sends out a basic view of a 20x4 LCD (above) like the one that is present on the physical hardware platform, and this has been useful for preparing the display logic so far in qemu-avr simulating an Atmega 2560. But it's time to be able to have some fun with the firmware, and that means we need to accept UI events somehow.
GDB gets me started
At first, before I had any part of the user input code developed, I just wanted to move the altimeter setting around to different values and see that the display refresh logic caught the change and updated the display appropriately. This only required me to declare the variable as volatile
and suddenly I could interrupt the program with remote GDB, tweak the value, and continue the program.
Once I started testing UI logic, I decided that it wasn't a bad idea to continue down this path for faking input events.
Poking the UI
Brief note: I intend the altimeter to be controlled with a rotary encoder knob with a push button. The functionality the altimeter has to support is fairly limited, so a clicky knob like this should be sufficient, and makes the state machine a little less complex.
I intend to use the clicky rotary knob to let the user encode the notion of increasing and decreasing a number or selected menu option, as well as accepting/confirming and cancelling/exiting. I decided this can be encoded with four UI events which will eventually be posted by some GPIO-aware logic once the firmware is ported to the final hardware. At the time of writing, the UI declares:
enum ui_input_event {
UI_INPUT_EVENT_UP,
UI_INPUT_EVENT_DOWN,
UI_INPUT_EVENT_PRESS,
UI_INPUT_EVENT_HOLD,
};
void ui_input_event(struct ui_ctx*, struct data_ctx *dctx, enum ui_input_event);
With some basic logic to handle these events as edges on the state machine, we can use GDB to remote into qemu-avr and call ui_input_event
ourselves with whichever event we want to fire. Something like
Program received signal SIGINT, Interrupt.
0x000007c0 in main () at altimeter.c:68
68 sleep_mode();
(gdb) call ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_PRESS)
(gdb) call ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_UP)
(gdb) c
would have entered the mode for changing the altimeter setting, and increased it by one. On continue, we'd see the mocked screen update:
,--------------------.
|-42.8 m _1 m/s |
|1019.41 hPa |
|Set>1014.25<hPa |
|12.3 C |
`--------------------'
The ><
marks around 1014.25
are currently how the firmware displays to the user that turning the rotary encoder will change this value up and down. We can exit this mode by sending PRESS again:
Program received signal SIGINT, Interrupt.
0x000007c0 in main () at altimeter.c:68
68 sleep_mode();
(gdb) call ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_PRESS)
(gdb) c
...
,--------------------.
|-42.2 m _1 m/s |
|1019.33 hPa |
|Set 1014.25 hPa |
|12.3 C |
`--------------------'
Looks OK to me.
Making it better
Entering full function calls for GDB to run is boring, especially with the level of repetition there is in sending the same parameters (ui_ctx
and dctx
) each time. My first way around this was to declare wrapper functions up
, down
, press
and hold
and call these instead.
void up(void){ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_UP);ui_update(&ui_ctx, &dctx);}
void down(void){ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_DOWN);ui_update(&ui_ctx, &dctx);}
void press(void){ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_PRESS);ui_update(&ui_ctx, &dctx);}
void hold(void){ui_input_event(&ui_ctx, &dctx, UI_INPUT_EVENT_HOLD);ui_update(&ui_ctx, &dctx);}
and so:
Program received signal SIGINT, Interrupt.
0x000007c0 in main () at altimeter.c:68
68 sleep_mode();
(gdb) call press()
(gdb) call up()
(gdb) call up()
(gdb) call up()
(gdb) c
This is definitely better, but still a bit clunky. I have to interrupt the program, hit several keystrokes, and then continue the program. For quicker UI testing it would be nice if I had a program that would read keypresses, translate them into UI actions, and send the right commands to GDB for these UI actions to run in the firmware.
Making it better-er: GDB/MI
That's exactly what I did: write a quick script that wraps GDB with its machine interface aka MI or GDB/MI, takes keypresses with ncurses, and pushes call
commands at GDB when needed, wrapping it with interrupt
and continue &
.
I achieved this with the pygdbmi library and the resulting script was around 100 lines. At first, pygdbmi starts a subprocess of avr-gdb and speaks MI to it, telling it the path to the ELF binary and the address for remote debugging of the QEMU instance. We then enter a getch loop where WASD keystrokes are translated into the four UI event types. On a recognised keypress event, we ask pygdbmi to interrupt the program, call a function to trigger the right UI event, and finally background-continue the program so the MI comms can continue for interrupting the program on the next event.
You can find the script here. It's a little more complicated than I set out here, with multithreading that I touch on below.
Conclusion
Relying on GDB for pushing UI events to firmware is somewhat high latency. Just calling interrupt
takes around 1 second on my setup, and completing the call
commands in GDB takes a few hundred milliseconds. To somewhat work around this issue, I actually ended up threading my Python script. One thread handles the GDB/MI command issuing, reading commands from a queue shared with the main thread. This allows the GDB/MI thread to potentially issue multiple call
commands for one interruption of the inferior, decreasing average latency when many UI events are to be submitted in quick succession.
The threading definitely improves the user experience, but it's still slower than it could be. However, as a basic developer tool for smoke-style testing, I think it is sufficient for now.
An additional concern that I have about this method of testing is that, as far as I know, GDB can break into the program execution at any point, including during an ISR. This means that we have a small chance of firing our UI event in the middle of a half-finished ISR, which would never be allowed to happen on the AVR platform. My firmware so far takes advantage of this assumption to avoid the need for locking primitives on some of the contexts. There is some potential for glitches/bugs to appear if e.g. a UI event redraws the screen with a half-updated data context. Any glitches seen in this area should be rare enough (ISR fires once per second and takes milliseconds), and hopefully be limited to brief display glitches, such as the altitude being incorrect for the displayed pressure for up to one second. I'm not too worried about this given the low risk and intended user of the tool.
Perhaps the next yak I'll shave will be the one of custom QEMU hardware peripherals so that the UI can be tested without GDB's latency in the way.