Embedded C track

Lesson 24 · Part 4: Applications & Patterns

Capstone — Control LEDs & Timers over UART Commands

MDK capstone

Part 4: Applications & Patterns7 min readFree lesson

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/\r for 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

  1. Project → New µVision Project… → name it app_capstone.
  2. Device: STM32F103C8.
  3. Manage Run-Time Environment → CMSIS:CORE, Device:Startup.
  4. Add 4 source files to the project: main.c, ringbuffer.c, command.c (+ header in the same folder). pc_test.c is for PC verification and should not be included in the Keil project.
  5. In C/C++ → Define, include STM32F10X_MD. Add the source folder to the header path (Include Paths).
  6. Debug → Use Simulator, -pSTM32F103C8.
  7. 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, and LED ON responds, turning PC13 of GPIOC ODR to 0 (on).
  • Enter blink → PC13 toggles every 500ms (verified with Logic Analyzer). Enter off → 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!

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/app_capstone/command.cC
#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";
    }
}
src/app_capstone/command.hC header
#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 */
src/app_capstone/main.cC
#include "stm32f10x.h"
#include "ringbuffer.h"
#include "command.h"

/*
 *Lecture 24 Capstone — Control LED and timer with UART commands.
 *Target: STM32F103C8, Compiler: Keil MDK-ARM.
 *
 *Overall flow (summary of previous lectures):
 *USART1 RX interrupt (19/18) → push to ring buffer (22)
 *Main loop: pop → assemble line (22) → parse command (command.c) → execute action
 *SysTick (16): toggle LED every 500ms in blink mode
 *LED control (14): PC13 active low
 *
 *Commands: on / off / blink / help (one line ends with newline)
 */

#define LED_PIN    13u
#define BAUD_BRR   7500u      /* 72MHz / 9600 */
#define LINE_MAX   32u

static ringbuffer_t      rx_rb;
static volatile uint32_t g_ms = 0;
static volatile uint8_t  blink_mode = 0;

/*---- Interrupt handler ----*/
void SysTick_Handler(void)
{
    g_ms++;
    if (blink_mode && ((g_ms % 500u) == 0u)) {
        GPIOC->ODR ^= (1u << LED_PIN);     /*blink mode: 500ms toggle*/
    }
}

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*/
    }
}

/*---- Hardware initialization ----*/
static void led_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
    GPIOC->CRH &= ~(0xFu << ((LED_PIN - 8u) * 4u));
    GPIOC->CRH |=  (0x2u << ((LED_PIN - 8u) * 4u));
}

static void usart1_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN | RCC_APB2ENR_USART1EN;

    GPIOA->CRH &= ~((0xFu << ((9u - 8u) * 4u)) | (0xFu << ((10u - 8u) * 4u)));
    GPIOA->CRH |=  (0xBu << ((9u - 8u) * 4u));    /*PA9 TX AF Push-Pull*/
    GPIOA->CRH |=  (0x4u << ((10u - 8u) * 4u));   /*PA10 RX Floating input*/

    USART1->BRR = BAUD_BRR;
    USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE | USART_CR1_UE;
    NVIC_EnableIRQ(USART1_IRQn);
}

/*---- UART transmission helper ----*/
static void send_char(char c)
{
    while (!(USART1->SR & USART_SR_TXE)) { }
    USART1->DR = (uint16_t)c;
}

static void send_str(const char *s)
{
    while (*s) {
        send_char(*s++);
    }
}

/*---- LED operation ----*/
static void led_on(void)  { GPIOC->BRR  = (1u << LED_PIN); }   /*Active low ON*/
static void led_off(void) { GPIOC->BSRR = (1u << LED_PIN); }   /* OFF */

/*---- Command execution ----*/
static void handle_command(command_t cmd)
{
    switch (cmd) {
        case CMD_ON:    blink_mode = 0u; led_on();  send_str("LED ON\r\n");          break;
        case CMD_OFF:   blink_mode = 0u; led_off(); send_str("LED OFF\r\n");         break;
        case CMD_BLINK: blink_mode = 1u;            send_str("BLINK mode\r\n");      break;
        case CMD_HELP:  send_str("cmds: on off blink help\r\n");                     break;
        case CMD_NONE:  /*Ignore empty line*/                                             break;
        default:        send_str("unknown command\r\n");                            break;
    }
}

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);   /*1ms tick*/

    send_str("Capstone ready. Type: on/off/blink/help\r\n");

    while (1) {
        if (rb_pop(&rx_rb, &c)) {
            if (c == '\n' || c == '\r') {       /*Line end → process command*/
                if (idx > 0u) {
                    line[idx] = '\0';
                    handle_command(command_parse(line));
                    idx = 0u;
                }
            } else if (idx < (LINE_MAX - 1u)) {
                line[idx++] = (char)c;          /*Accumulate characters*/
            }
        }
    }
}
src/app_capstone/pc_test.cC
#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;
}
src/app_capstone/ringbuffer.cC
#include "ringbuffer.h"

#define RB_MASK  (RB_SIZE - 1u)

void rb_init(ringbuffer_t *rb)
{
    rb->head = 0u;
    rb->tail = 0u;
}

uint8_t rb_push(ringbuffer_t *rb, uint8_t data)
{
    uint16_t next = (uint16_t)((rb->head + 1u) & RB_MASK);
    if (next == rb->tail) {
        return 0u;               /*Full*/
    }
    rb->buf[rb->head] = data;
    rb->head = next;
    return 1u;
}

uint8_t rb_pop(ringbuffer_t *rb, uint8_t *out)
{
    if (rb->head == rb->tail) {
        return 0u;               /*Empty*/
    }
    *out = rb->buf[rb->tail];
    rb->tail = (uint16_t)((rb->tail + 1u) & RB_MASK);
    return 1u;
}
src/app_capstone/ringbuffer.hC header
#ifndef RINGBUFFER_H
#define RINGBUFFER_H

#include <stdint.h>

/*22 pieces of ring buffer — FIFO connecting UART RX interrupt and main processing (SPSC safe).*/

#define RB_SIZE  32u            /*Power of 2*/

typedef struct {
    uint8_t  buf[RB_SIZE];
    volatile uint16_t head;     /*Producer (ISR)*/
    volatile uint16_t tail;     /*Consumer (main)*/
} ringbuffer_t;

void     rb_init(ringbuffer_t *rb);
uint8_t  rb_push(ringbuffer_t *rb, uint8_t data);   /*Success 1, full 0*/
uint8_t  rb_pop(ringbuffer_t *rb, uint8_t *out);     /*Success 1, empty 0*/

#endif /* RINGBUFFER_H */

Assignment

Problem 1 is pure logic (PC gcc), Problem 2 is MDK (STM32F103C8, μVision simulator).


Problem 1 — Command Parser with Arguments

  • File name: homework_01/main.c
  • Key concept: parsing command + numeric argument, range clamp
  • Platform: Common (PC gcc)

Requirements

  • on, off are recognized without arguments (value 0).
  • dim N accepts a brightness argument from 0 to 100. If it exceeds 100, clamp to 100.
  • Others are UNKNOWN.
  • Format "Line" -> Command (Value) must match the expected output character by character.

Expected output

"on" -> ON (0)
"off" -> OFF (0)
"dim 50" -> DIM (50)
"dim 100" -> DIM (100)
"dim 200" -> DIM (100)
"foo" -> UNKNOWN (0)

Hint

  • Check the dim prefix and parse the number directly after it (v = v*10 + (ch-'0')).
  • If the parsed value is larger than 100, cut it to 100.

Problem 2 — Output Number in Decimal via UART

  • File name: homework_02/main.c
  • Key concept: integer → decimal string, UART transmission
  • Platform: MDK (STM32F103C8)

Requirements

  • Initialize USART1 (9600 8N1) and implement send_dec() to send a 16-bit integer as decimal text.
  • Send 0, 42, 1000 separated by spaces and end the line (\r\n).
  • This is the key helper used when sending status values (e.g., counter, ADC values) for human readability.

Expected Operation (Simulator)

  • 0 42 1000 is output to the UART #1 window with a newline.

Hint

  • 0 is treated specially ('0' as one character). Otherwise, take digits from the end into the buffer and send in reverse order as v % 10.
  • Transmit backwards after while (v > 0) { buf[i++] = '0' + v % 10; v /= 10; }.

Check your answers

After solving it yourself, compare it with the reference solution below. The solution files include comments on key points and common mistakes.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

homework_01/main.cC
#include <stdio.h>

/*
 *Correct Answer 1 — Command Parser with Arguments
 *
 *Key Points:
 *- After checking the "dim " prefix, directly parse the number that follows.
 *- Clamp the argument to 0~100 (to prevent out-of-range input).
 *
 *Common mistake:
 *- Missing digit accumulation (v*10+digit) during number parsing.
 *- Forgetting to clamp so that values like 200 pass through as is.
 */

typedef enum { C_ON, C_OFF, C_DIM, C_UNKNOWN } cmd_t;

static int streq(const char *a, const char *b)
{
    while (*a != '\0' && (*a == *b)) { a++; b++; }
    return (*a == '\0' && *b == '\0') ? 1 : 0;
}

static int starts_with(const char *s, const char *p)
{
    while (*p != '\0') {
        if (*s != *p) { return 0; }
        s++; p++;
    }
    return 1;
}

static int parse_uint(const char *s)
{
    int v = 0;
    while (*s >= '0' && *s <= '9') {
        v = v * 10 + (*s - '0');
        s++;
    }
    return v;
}

static cmd_t parse(const char *line, int *val)
{
    *val = 0;
    if (streq(line, "on"))       { return C_ON;  }
    if (streq(line, "off"))      { return C_OFF; }
    if (starts_with(line, "dim ")) {
        int v = parse_uint(line + 4);
        if (v > 100) { v = 100; }     /*Clamp*/
        *val = v;
        return C_DIM;
    }
    return C_UNKNOWN;
}

static const char *name(cmd_t c)
{
    switch (c) {
        case C_ON:  return "ON";
        case C_OFF: return "OFF";
        case C_DIM: return "DIM";
        default:    return "UNKNOWN";
    }
}

int main(void)
{
    const char *lines[6] = { "on", "off", "dim 50", "dim 100", "dim 200", "foo" };
    int i;

    for (i = 0; i < 6; i++) {
        int v;
        cmd_t c = parse(lines[i], &v);
        printf("\"%s\" -> %s (%d)\n", lines[i], name(c), v);
    }
    return 0;
}
homework_02/main.cC
#include "stm32f10x.h"

/*
 *Answer 2 (MDK, STM32F103C8) — Output numbers in decimal via UART
 *
 *Key Points:
 *- To send an integer as a decimal string, extract the digits from the least significant digit (v%10) into a buffer and transmit in reverse order.
 *- Handle 0 as a special case (because the loop doesn't run even once).
 *- A key helper used when sending status/counter/ADC values for human readability.
 *
 *Common mistake:
 *- The case where digits are reversed because reverse-order transmission is not done.
 *- The case where nothing is output because handling for v==0 is missed.
 *
 *Expected behavior (simulator): print "0 42 1000" on UART #1, then a newline.
 */

#define BAUD_BRR   7500u   /* 72MHz / 9600 */

static void usart1_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN | RCC_APB2ENR_USART1EN;

    GPIOA->CRH &= ~((0xFu << ((9u - 8u) * 4u)) | (0xFu << ((10u - 8u) * 4u)));
    GPIOA->CRH |=  (0xBu << ((9u - 8u) * 4u));    /*PA9 TX AF push-pull*/
    GPIOA->CRH |=  (0x4u << ((10u - 8u) * 4u));   /*PA10 RX Floating input*/

    USART1->BRR = BAUD_BRR;
    USART1->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;
}

static void send_char(char c)
{
    while (!(USART1->SR & USART_SR_TXE)) { }
    USART1->DR = (uint16_t)c;
}

static void send_str(const char *s)
{
    while (*s) {
        send_char(*s++);
    }
}

static void send_dec(uint16_t v)
{
    char buf[6];
    int i = 0;

    if (v == 0u) {
        send_char('0');
        return;
    }
    while (v > 0u) {
        buf[i++] = (char)('0' + (v % 10u));   /*From the least significant digit*/
        v /= 10u;
    }
    while (i > 0) {
        send_char(buf[--i]);                  /*Transmit in reverse order*/
    }
}

int main(void)
{
    usart1_init();

    send_dec(0u);    send_char(' ');
    send_dec(42u);   send_char(' ');
    send_dec(1000u); send_str("\r\n");

    while (1) {
    }
}

Learn this lesson with a mentor

Work through the Embedded C track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now