Finally, the last chapter. We now combine the pieces we have learned separately — USART communication (19), Interrupts (18), Ring Buffer (22), SysTick Timer (16), GPIO LED (14), Command Parsing & Module Separation (22·23) — into one small firmware. When you type commands like on, off, blink, help on a PC terminal, the board interprets the commands and controls the LED and timer accordingly.
The key design principle of this capstone is role separation. Interrupts only receive, main only processes, parser only interprets. Each module (ringbuffer, command) can be tested independently, and the command parser can be verified on a PC without hardware. By this point, you are capable of writing firmware that is "cleanly structured while directly handling registers."
Learning Objectives
- Integrate multiple peripherals and modules into a single firmware.
- Separate the roles of interrupt (receiving) and main (processing) (Producer/Consumer).
- Collect UART input using a ring buffer and assemble it line by line.
- Parse commands and connect them to LED/Timer operations.
- Separate the parser into a module and unit test it on a PC.
Target Environment
- Compiler: Keil MDK-ARM (Arm Compiler 6).
- Device: STM32F103C8 (μVision simulator).
- Verification: Use μVision Serial (UART #1) to test commands, Input/Response. parser on PC
gcc(pc_test.c).
Key Concepts
1) Overall Architecture
[PC Terminal] --UART--> RX Interrupt --push--> [Ring Buffer] --pop--> Main Loop
│
Line Assembly → command_parse() → handle_command()
│
LED(PC13) Control / blink mode / UART Response
SysTick(1ms) ──(toggle every 500ms in blink mode)──> LED
2) Module Composition
| File | Responsibility | Dependencies |
|---|---|---|
ringbuffer.c/.h |
FIFO buffer | None (pure) |
command.c/.h |
Line → command code | None (pure) |
main.c |
Hardware setup·integration | CMSIS, above module |
pc_test.c |
Parser unit test | command (pure) |
Pure modules (ringbuffer, command) do not depend on the chip and can be tested on a PC as is.
3) Data Flow — Producer/Consumer
- RX Interrupt (Producer): Just put bytes into
rb_push(briefly). - Main Loop (Consumer): Take bytes out from
rb_pop, assemble lines, and confirm a line in\n/\rfor parsing and execution.
Thanks to this separation, the interrupt ends quickly even if data floods in, and heavy processing is handled leisurely by the main loop.
4) Command Protocol
| Command | Action | Response |
|---|---|---|
on |
Turn LED on, cancel blink | LED ON |
off |
Turn LED off, cancel blink | LED OFF |
blink |
500ms auto-blink mode | BLINK mode |
help |
Command list | cmds: ... |
5) SysTick and blink mode
SysTick increments g_ms every 1ms, and if blink_mode is on, it toggles the LED every 500ms. on/off turn off blink mode and keep the LED steady. blink_mode and g_ms are shared between ISR and main, so they are volatile.
Creating a μVision Project
Project → New µVision Project…→ name itapp_capstone.- Device: STM32F103C8.
Manage Run-Time Environment→ CMSIS:CORE, Device:Startup.- Add 4 source files to the project:
main.c,ringbuffer.c,command.c(+ header in the same folder).pc_test.cis for PC verification and should not be included in the Keil project. - In
C/C++ → Define, includeSTM32F10X_MD. Add the source folder to the header path (Include Paths). Debug → Use Simulator,-pSTM32F103C8.- Rebuild(F7) → Debug(Ctrl+F5) →
View → Serial Windows → UART #1.
Examples
Example 1 — command.h / command.c: Command Parser (Standalone Module)
#ifndef COMMAND_H
#define COMMAND_H
/*
*Command parser — Converts a single line string into a command code.
*Pure logic independent of hardware, so it can be unit tested on a PC (gcc).
*/
typedef enum {
CMD_NONE = 0, /*Empty line*/
CMD_ON,
CMD_OFF,
CMD_BLINK,
CMD_HELP,
CMD_UNKNOWN
} command_t;
command_t command_parse(const char *line);
const char *command_name(command_t c);
#endif /* COMMAND_H */
#include "command.h"
/*Directly implement a simple string comparison to operate without the standard library.*/
static int streq(const char *a, const char *b)
{
while (*a != '\0' && (*a == *b)) {
a++;
b++;
}
return (*a == '\0' && *b == '\0') ? 1 : 0;
}
command_t command_parse(const char *line)
{
if (line[0] == '\0') { return CMD_NONE; }
if (streq(line, "on")) { return CMD_ON; }
if (streq(line, "off")) { return CMD_OFF; }
if (streq(line, "blink")) { return CMD_BLINK; }
if (streq(line, "help")) { return CMD_HELP; }
return CMD_UNKNOWN;
}
const char *command_name(command_t c)
{
switch (c) {
case CMD_NONE: return "NONE";
case CMD_ON: return "ON";
case CMD_OFF: return "OFF";
case CMD_BLINK: return "BLINK";
case CMD_HELP: return "HELP";
default: return "UNKNOWN";
}
}
Example 2 — main.c: Integration (Summary)
The full code is in src/app_capstone/main.c. If you move only the core flow:
void USART1_IRQHandler(void)
{
if (USART1->SR & USART_SR_RXNE) {
uint8_t b = (uint8_t)USART1->DR; /*Reading clears RXNE*/
(void)rb_push(&rx_rb, b); /*Just insert ISR — briefly*/
}
}
void SysTick_Handler(void)
{
g_ms++;
if (blink_mode && ((g_ms % 500u) == 0u)) {
GPIOC->ODR ^= (1u << LED_PIN); /*blink mode: 500ms toggle*/
}
}
int main(void)
{
char line[LINE_MAX]; uint16_t idx = 0u; uint8_t c;
rb_init(&rx_rb); led_init(); usart1_init();
SysTick_Config(SystemCoreClock / 1000u);
send_str("Capstone ready. Type: on/off/blink/help\r\n");
while (1) {
if (rb_pop(&rx_rb, &c)) {
if (c == '\n' || c == '\r') {
if (idx > 0u) { line[idx] = '\0'; handle_command(command_parse(line)); idx = 0u; }
} else if (idx < (LINE_MAX - 1u)) {
line[idx++] = (char)c;
}
}
}
}
Example 3 — pc_test.c: Parser Unit Test
#include <stdio.h>
#include "command.h"
/*
*Lecture 24 PC Verification — Command Parser Unit Test.
*Verify the logic that converts a single line of input into a command code without hardware.
*Build: gcc pc_test.c command.c -o pc_test
*/
int main(void)
{
const char *lines[6] = { "on", "off", "blink", "help", "xyz", "" };
int i;
for (i = 0; i < 6; i++) {
printf("\"%s\" -> %s\n", lines[i], command_name(command_parse(lines[i])));
}
return 0;
}
Checking with Simulator
- After starting debug, open
View → Serial Windows → UART #1. If you run it,Capstone ready...will be output. - Enter
on+ Enter in the UART #1 window, andLED ONresponds, turning PC13 of GPIOC ODR to 0 (on). - Enter
blink→ PC13 toggles every 500ms (verified with Logic Analyzer). Enteroff→ blinking stops and turns OFF. help→ prints command list. Unknown command →unknown command.- Expected Behavior: The LED status and blinking mode change immediately according to the input command, and response text is output.
Verification on a PC
gcc pc_test.c command.c -o pc_test && ./pc_test
Expected output (stdout):
"on" -> ON
"off" -> OFF
"blink" -> BLINK
"help" -> HELP
"xyz" -> UNKNOWN
"" -> NONE
Common Mistakes
Q. Nothing happens when I enter a command.
A. A line is confirmed only when the end-of-line characters (\n/\r) are present. Make sure your terminal sends a newline on Enter. Also, check whether RXNEIE and NVIC_EnableIRQ(USART1_IRQn) are enabled.
Q. Characters disappear when I type quickly.
A. Check whether the main program calls rb_pop frequently enough and whether the ring buffer size is sufficient. If heavy work is done in the ISR, the next byte will be missed (the ISR only does a push).
Q. LED doesn’t blink even in blink mode.
A. Check whether SysTick_Config was called, and whether blink_mode/g_ms is volatile. Also inspect the g_ms % 500 toggle conditions.
Q. It’s tedious to test on the board after fixing the parser.
A. That’s why the parser was separated as a pure module. Build the logic first on a PC using command.c with pc_test.c via gcc, and upload to the board afterward.
Summary
- The capstone project integrates USART, interrupts, ring buffer, SysTick, GPIO, and the parser.
- Interrupts only receive and push, while the main handles pop, assembly, parsing, and execution (division of labor).
- Pure modules (ringbuffer/command) are unit tested on a PC before uploading to the board.
- The firmware can be controlled and extended externally through the command protocol.
- Direct register control and a clean structure coexist.
Try it yourself
Solve the assignment below. The reference solution is shown below.
Congratulations — You've completed all 24 episodes of Embedded C! 🎉
Starting from Episode 1 with Hello, Embedded C!, you have worked directly with 8051 and STM32 registers, handled interrupts, communication, and timers, learned firmware patterns such as debouncing, ring buffers, HAL, and even completed comprehensive projects. Now you are able to:
- Read the register tables in datasheets and control them at the bit level,
- Configure Polling/Interrupt, Timer/PWM, UART/ADC directly,
- Verify pure logic on a PC and design structures that can be ported to HAL.
The next step is to upload these examples to real boards (Blue Pill, 8051 kit) and expand the topics to I2C/SPI sensors, RTOS, DMA, and more. You've really done a great job so far!