This lab activity covered how a microcontroller's General Purpose Input/Output (GPIO) peripheral can be used to build a functional user interface. The target platform was the RT-Thread RT-Spark Development Board, which has an onboard STM32 Cortex-M4 microcontroller running the RT-Thread real-time operating system. The goal was to connect GPIO to three layers at once: a five-directional switch as input, three discrete external LEDs as visual output, and the board's onboard graphical LCD as a text display.
The program works as follows. Switch states are read through GPIO input pins using RT-Thread's pin driver API. Three external LEDs respond to specific switch directions — Up, Center, and Down each light up a dedicated LED. The onboard LCD, driven through the drv_lcd BSP driver, shows the name of whichever switch is currently pressed, or "HELLO WORLD!" when no switch is active. All of this runs inside the RT-Thread scheduler as a cooperative thread, with a 50 ms delay each loop iteration serving both as a scheduler yield point and as an implicit software debounce filter.
The development environment was RT-Thread Studio, the official Eclipse-based IDE for RT-Thread projects. It handled BSP configuration, driver package management, and firmware flashing without needing STM32CubeMX or a separate toolchain. Version control and project hosting were managed through GitHub, and this writeup is published on Hackster.io as required by the lab specification.
Demo Video
2. Design and Testing2.1 Concept and RationaleThe firmware runs within the RT-Thread RTOS environment, meaning main() executes as a managed thread rather than a bare-metal program entry point. The central design choice was a polling loop — straightforward, linear, and easy to reason about. Rather than setting up GPIO interrupts and deferring work to semaphore-signaled threads, the application simply reads all switch states on every iteration, updates the LCD and LEDs accordingly, then yields for 50 ms via rt_thread_mdelay().
This approach fits the task well. Human input through a thumb switch is slow relative to the MCU — a press or release event takes tens of milliseconds to register intentionally, while the 50 ms poll interval means at most one cycle of lag before a state change is detected. That latency is imperceptible. The rt_thread_mdelay() call is also the right way to introduce timing in RT-Thread: unlike HAL_Delay(), which burns CPU cycles in a spin loop, rt_thread_mdelay() suspends the current thread and passes control back to the scheduler, letting other system tasks run during the wait. This keeps the system cooperative.
A diagnostic toggle #define ENABLE_LCD_CODE 1 was included at the top of the file. Wrapping all LCD-dependent code in a compile-time flag means the LCD portions can be disabled with a single line change if the display causes issues, allowing the switch and LED logic to be validated independently. With ENABLE_LCD_CODE set to 1, the full application runs including LCD output.
The active-low convention for the switches also required deliberate handling. The switch pins are configured with internal pull-up resistors, so the GPIO reads a logical 1 (HIGH) when the switch is open and a logical 0 (LOW) when pressed. The switch_get() function inverts this using the logical NOT operator, so the rest of the application code can work with the intuitive convention of "pressed = 1, released = 0."
The hardware consists of the RT-Spark Development Board plus three external LEDs. The five-directional switch is onboard and routed to GPIO Port A pins 1 through 5. The LCD is the onboard graphical display accessed via the drv_lcd BSP driver. The LCD backlight is controlled through a dedicated GPIO pin on Port F, pin 9 — it must be driven HIGH to illuminate the display.
The three external LEDs are connected to Port E, pins 2, 3, and 4. Based on the code, the LEDs are active high — leds_init() writes PIN_LOW to all three at startup (LEDs off), and leds_set() writes PIN_HIGH to turn an LED on. This is the opposite of the active-low convention used for the switches, and both are handled correctly in their respective driver functions.
The exact pin assignments, pulled directly from the source code, are as follows:
Pin Definitions:
Switch-to-Output Mapping:
The firmware is divided into seven logical blocks, each handling a distinct concern.
Switch initialization — switches_init(). All five switch pins (PA1–PA5) are configured as digital inputs with internal pull-up resistors enabled using rt_pin_mode() with PIN_MODE_INPUT_PULLUP. No external resistors are needed.
void switches_init(void) {
rt_pin_mode(P_SW_UP, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_DN, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_LT, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_RT, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_CR, PIN_MODE_INPUT_PULLUP);
}Switch reading — switch_get(). Reads a single pin and inverts the result. Switches are active low — the hardware pulls the pin to ground when pressed — so the logical NOT maps the hardware state to the intuitive application convention of pressed = 1.
int switch_get(rt_base_t pin) {
return !rt_pin_read(pin); // active LOW: 0 when pressed → inverted to 1
}LED initialization — leds_init(). Pins PE2, PE3, and PE4 are configured as push-pull outputs. All three are written LOW at startup, keeping the LEDs off. These LEDs are active high — writing HIGH turns them on.
void leds_init(void) {
rt_pin_mode(P_LED_1, PIN_MODE_OUTPUT);
rt_pin_mode(P_LED_2, PIN_MODE_OUTPUT);
rt_pin_mode(P_LED_3, PIN_MODE_OUTPUT);
rt_pin_write(P_LED_1, PIN_LOW); // OFF at startup
rt_pin_write(P_LED_2, PIN_LOW);
rt_pin_write(P_LED_3, PIN_LOW);
}LED control — leds_set() and light_leds().leds_set() accepts three logical values (0 or 1) and writes the corresponding HIGH/LOW to each LED pin. light_leds() calls it with the current states of the Up, Center, and Down switches directly — so each of those three directions maps one-to-one to an LED. Left and Right presses result in all three arguments being 0, leaving all LEDs off.
void leds_set(int l1, int l2, int l3) {
rt_pin_write(P_LED_1, l1 ? PIN_HIGH : PIN_LOW);
rt_pin_write(P_LED_2, l2 ? PIN_HIGH : PIN_LOW);
rt_pin_write(P_LED_3, l3 ? PIN_HIGH : PIN_LOW);
}
void light_leds(void) {
leds_set(
switch_get(P_SW_UP),
switch_get(P_SW_CR),
switch_get(P_SW_DN)
);
}LCD display logic — print_switches(). Guarded by the ENABLE_LCD_CODE compile-time flag. Uses the drv_lcd BSP function lcd_show_string(x, y, size, string) to render text at a fixed position: x=10, y=10, font size 32. The same screen coordinates are used for every string, so each new write overwrites the previous one in place. All strings are padded to equal length with trailing spaces to ensure no leftover characters remain from a longer previous string. When no switch is active, "HELLO WORLD!" is shown — functioning as both the idle state message and the default greeting.
void print_switches(void) {
#if ENABLE_LCD_CODE
if (switch_get(P_SW_UP)) {
lcd_show_string(10, 10, 32, "UP PRESSED ");
}
else if (switch_get(P_SW_CR)) {
lcd_show_string(10, 10, 32, "CENTER PRESSED ");
}
else if (switch_get(P_SW_DN)) {
lcd_show_string(10, 10, 32, "DOWN PRESSED ");
}
else if (switch_get(P_SW_LT)) {
lcd_show_string(10, 10, 32, "LEFT PRESSED ");
}
else if (switch_get(P_SW_RT)) {
lcd_show_string(10, 10, 32, "RIGHT PRESSED ");
}
else {
lcd_show_string(10, 10, 32, "HELLO WORLD! ");
}
#endif
}Main thread — main(). Hardware is initialized in sequence: switches, LEDs, then LCD. The backlight pin (PF9) is configured as an output and driven HIGH before any LCD calls, with a 50 ms settling delay to ensure the display is ready. The screen is cleared to white via lcd_clear(WHITE). The main loop then calls print_switches() and light_leds() each iteration, followed by rt_thread_mdelay(50) which yields CPU time to the RT-Thread scheduler and provides the debounce window.
int main(void) {
switches_init();
leds_init();
#if ENABLE_LCD_CODE
rt_pin_mode(P_BACKLIGHT, PIN_MODE_OUTPUT);
rt_pin_write(P_BACKLIGHT, PIN_HIGH); // Turn on LCD backlight
rt_thread_mdelay(50); // Allow display to settle
lcd_clear(WHITE); // Clear screen to white background
#endif
while (1) {
print_switches(); // Update LCD text
light_leds(); // Update LED states
rt_thread_mdelay(50); // Yield to RT-Thread scheduler; debounces switches
}
return 0;
}2.4 Testing ApproachTesting was incremental. LED behavior was validated first — each of the three mapped switch directions (Up, Center, Down) was pressed in isolation to confirm the correct LED activated and turned off cleanly on release. Left and Right were pressed to confirm no LED responded, which matched the intended behavior.
The LCD was then brought online with ENABLE_LCD_CODE set to 1. The screen was observed to clear to white on startup, confirming the backlight and lcd_clear() call both worked. Each switch direction was pressed and the corresponding string was verified on screen. The fixed-coordinate overwrite approach — all strings rendered at x=10, y=10 — worked cleanly without leftover text, thanks to the trailing-space padding in each string.
The full system was finally tested with rapid successive presses across all five directions, verifying that transitions between strings were clean and LEDs updated in sync with the display. No glitches, stale text, or missed presses were observed at the 50 ms polling rate.
3. Documentation3.1 LCD Rendering ApproachUnlike a character LCD where text is positioned by row and column index, the drv_lcd driver used here renders text as a graphical bitmap at pixel coordinates. The function signature is:
lcd_show_string(rt_uint16_t x, rt_uint16_t y, rt_uint8_t size, const char *fmt);Where x and y are pixel offsets from the top-left of the display, and size is the font height in pixels. A size of 32 was used throughout, producing large, readable text. All five switch strings plus the idle "HELLO WORLD!" string are rendered at the same coordinates (x=10, y=10), so each call overwrites the previous output in place — no explicit clear is needed between updates.
Trailing spaces are appended to shorter strings so that every string is long enough to cover the widest possible previous string ("CENTER PRESSED"). This prevents character remnants from lingering on screen.
3.2 RT-Thread API Reference Used#include <rtthread.h>
#include <rtdevice.h>
#include <board.h>
#include "drv_lcd.h"
// ========================================================
// DIAGNOSTIC TOGGLE
// ========================================================
#define ENABLE_LCD_CODE 1
// 1. Define Pins
#define P_SW_UP GET_PIN(A, 1)
#define P_SW_DN GET_PIN(A, 2)
#define P_SW_LT GET_PIN(A, 3)
#define P_SW_RT GET_PIN(A, 4)
#define P_SW_CR GET_PIN(A, 5)
#define P_BACKLIGHT GET_PIN(F, 9)
#define P_LED_1 GET_PIN(E, 2)
#define P_LED_2 GET_PIN(E, 3)
#define P_LED_3 GET_PIN(E, 4)
// 2. Initialize Switches
void switches_init(void) {
rt_pin_mode(P_SW_UP, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_DN, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_LT, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_RT, PIN_MODE_INPUT_PULLUP);
rt_pin_mode(P_SW_CR, PIN_MODE_INPUT_PULLUP);
}
// 3. Read Switch (active LOW — invert reading)
int switch_get(rt_base_t pin) {
return !rt_pin_read(pin);
}
// 4. Initialize LEDs (active HIGH — LOW = off at startup)
void leds_init(void) {
rt_pin_mode(P_LED_1, PIN_MODE_OUTPUT);
rt_pin_mode(P_LED_2, PIN_MODE_OUTPUT);
rt_pin_mode(P_LED_3, PIN_MODE_OUTPUT);
rt_pin_write(P_LED_1, PIN_LOW);
rt_pin_write(P_LED_2, PIN_LOW);
rt_pin_write(P_LED_3, PIN_LOW);
}
// 5. Control LEDs
void leds_set(int l1, int l2, int l3) {
rt_pin_write(P_LED_1, l1 ? PIN_HIGH : PIN_LOW);
rt_pin_write(P_LED_2, l2 ? PIN_HIGH : PIN_LOW);
rt_pin_write(P_LED_3, l3 ? PIN_HIGH : PIN_LOW);
}
void light_leds(void) {
leds_set(
switch_get(P_SW_UP),
switch_get(P_SW_CR),
switch_get(P_SW_DN)
);
}
// 6. LCD Display Logic (font size = 32px, fixed position x=10, y=10)
void print_switches(void) {
#if ENABLE_LCD_CODE
if (switch_get(P_SW_UP)) {
lcd_show_string(10, 10, 32, "UP PRESSED ");
} else if (switch_get(P_SW_CR)) {
lcd_show_string(10, 10, 32, "CENTER PRESSED ");
} else if (switch_get(P_SW_DN)) {
lcd_show_string(10, 10, 32, "DOWN PRESSED ");
} else if (switch_get(P_SW_LT)) {
lcd_show_string(10, 10, 32, "LEFT PRESSED ");
} else if (switch_get(P_SW_RT)) {
lcd_show_string(10, 10, 32, "RIGHT PRESSED ");
} else {
lcd_show_string(10, 10, 32, "HELLO WORLD! ");
}
#endif
}
// 7. Main Thread
int main(void) {
switches_init();
leds_init();
#if ENABLE_LCD_CODE
rt_pin_mode(P_BACKLIGHT, PIN_MODE_OUTPUT);
rt_pin_write(P_BACKLIGHT, PIN_HIGH); // Enable LCD backlight
rt_thread_mdelay(50); // Settle delay
lcd_clear(WHITE); // Clear screen
#endif
while (1) {
print_switches();
light_leds();
rt_thread_mdelay(50);
}
return 0;
}4. Results4.1 Qualitative AnalysisThe system worked reliably across all test conditions. On startup, the LCD backlight came on correctly after PF9 was driven HIGH, and lcd_clear(WHITE) produced a clean white screen. Within the first poll cycle (50 ms), "HELLO WORLD!" appeared at the designated screen position, confirming the idle-state display logic was working.
Each switch direction was then tested in isolation. Pressing Up displayed "UP PRESSED" on the LCD and activated LED1 simultaneously, with no noticeable delay between the physical press and both outputs responding. Center and Down behaved the same way — "CENTER PRESSED" with LED2, "DOWN PRESSED" with LED3. Left and Right correctly updated the LCD while all three LEDs stayed off, which matched the design intent that only Up, Center, and Down have LED assignments.
Releasing any switch caused the display to revert to "HELLO WORLD!" and the active LED to turn off on the next poll cycle. The trailing-space padding strategy worked as intended — no character remnants from longer strings were left on screen after shorter strings were written to the same coordinates.
The ENABLE_LCD_CODE diagnostic toggle was also briefly tested by setting it to 0. With LCD code disabled, the LEDs continued to respond correctly to switch presses, confirming that the switch and LED subsystems are fully independent of the LCD.
No oscilloscope measurements were taken. All timing and accuracy parameters were validated through direct observation during systematic switch testing.
5. Conclusions5.1 What Was LearnedSeveral concepts that were previously theoretical became concrete through this lab. The most immediate was the difference between active-low and active-high conventions, and specifically that both can coexist in the same program without confusion as long as each is handled at the right abstraction level. The switches are active low — switch_get() inverts the raw pin reading — while the LEDs are active high — leds_set() writes the logical value directly. Application code above those functions never has to deal with pin-level polarity, which is the right way to structure it.
The drv_lcd driver's pixel-coordinate rendering model was also new territory. Rather than addressing a character cell by row and column the way a classic HD44780 module works, lcd_show_string() places text at arbitrary pixel coordinates with a configurable font size. This is more flexible but requires a bit more thought about layout — choosing coordinates and font size that fit the screen, and making sure fixed-width string padding prevents ghosting when shorter strings follow longer ones.
Working within RT-Thread Studio rather than bare metal also reinforced the correct mental model for RTOS development. The rt_thread_mdelay() call does not just wait — it actively surrenders the thread's remaining time slice back to the scheduler. Understanding this distinction matters when writing firmware that needs to coexist with other threads and system services.
The ENABLE_LCD_CODE diagnostic toggle turned out to be genuinely useful, not just a theoretical good practice. During development, isolating the LCD initialization from the switch and LED logic made it easier to confirm that each subsystem was working before integrating them.
The main point of friction was understanding the LCD driver interface. The drv_lcd driver is not the same as the text-based HD44780 interface described in portions of the lab handout — it is a graphical frame buffer driver that renders font bitmaps at pixel coordinates. The function signature lcd_show_string(x, y, size, string) looks simple, but getting the right values for x, y, and size required some experimentation to place the text visibly on screen with a readable font size.
The backlight also required explicit initialization. The LCD displayed nothing until PF9 was configured as an output and driven HIGH. This is not something lcd_init() or lcd_clear() handle automatically in the BSP version used — it is a separate GPIO operation that has to happen before any display output is meaningful.
String padding took a moment to get right. Without trailing spaces, switching from "CENTER PRESSED" (the longest string) to "UP PRESSED" left "ED" ghosted on the right side of the display since the shorter string did not overwrite the full width of the previous one. Adding consistent trailing spaces to all strings fixed this.
5.3 Further ImprovementsThe most natural extension would be displaying additional information on the LCD alongside the switch status — for example, a press counter that increments each time a given direction is activated and shows the running total below the status text. This would require edge detection (sensing the transition from not-pressed to pressed rather than the sustained level) and would introduce the concept of state into the polling loop.
Another worthwhile improvement would be adding distinct LCD colors per switch direction using lcd_fill() or a colored background rect before the text, taking advantage of the graphical nature of the drv_lcd driver. The display supports color, so there is no reason the background needs to stay white for the entire session.
On the LED side, the Left and Right switch directions currently have no LED assignment. Mapping them to additional output combinations — or repurposing them to toggle LED states rather than reflect instantaneous switch levels — would give all five switch directions a complete visual response.
5.4 Final RemarksThe lab demonstrated GPIO-based user interface design within the RT-Thread RTOS environment on the RT-Spark Development Board. All learning outcomes were met: switch states were read through GPIO inputs using the RT-Thread pin driver, three external LEDs on Port E were controlled as active-high outputs, and text was rendered on the onboard graphical LCD via the drv_lcd BSP driver with a 32-pixel font at fixed coordinates. The polling architecture with rt_thread_mdelay() was appropriate for the application, the ENABLE_LCD_CODE diagnostic flag provided useful modularity during development, and the active-low switch convention was handled correctly throughout via the switch_get() inversion wrapper.
- Castor, P. R. P. (January 2026). Laboratory Activity 6: General Purpose I/O Lab Exercise – Basic User Interface. Mindanao State University – Iligan Institute of Technology, BCA143 Firmware Programming.
- Laboratory Report Format and Guidelines. BCA143 Firmware Programming, MSU-IIT Department of Computer Applications.
- RT-Thread. RT-Thread Studio User Guide.https://www.rt-thread.io/studio.html
- RT-Thread. RT-Thread PIN Device Driver Documentation.https://www.rt-thread.io/document/site/
- RT-Thread. RT-Spark Development Board BSP and drv_lcd Driver Source.
- Hitachi Semiconductor. HD44780U LCD Controller/Driver Datasheet (referenced for conceptual background on character LCD interfacing).









Comments