Introduction — Raising a Score With a Single Finger
This series is a hands-on learning journey building FingerScore, a BLE ring device that records racket-sports scores (tennis, badminton, squash, table tennis) through finger gestures. Part 1 covered the overall system and component selection, and Part 2 covered the PCB and power design. This third part covers the piece that breathes life into all that hardware: the MCU firmware and gesture input processing.
On the surface, what FingerScore does is simple. When the user scores, they flick the finger wearing the ring or press a button, the score goes up by one, and that value is sent to a phone over BLE. But to make this simple action last for weeks on a single coin-cell battery, the firmware must spend most of its time asleep. An event-driven design that "wakes only when needed" is the central theme of this post.
This article explains concepts from the ground up so that newcomers to embedded development can follow along, but the code aims to compile and run for real. Every piece of C code lives inside a code block, so you can copy it as you read.
The Big Picture of MCU Firmware
MCU (Micro Controller Unit) firmware almost always shares the same skeleton: an initialization phase that runs once at power-on, a main loop that runs forever afterward, and interrupts that react to external events.
POWER ON
|
v
[ INIT ] <- clock, GPIO, timers, BLE stack, sensors
|
v
[ MAIN LOOP ] ---> when nothing to do, go to [ SLEEP ]
^ |
| v
+------ [ WAKE BY INTERRUPT ] <----+
(button press / IMU motion / timer)
A traditional Arduino sketch where `loop()` spins without rest is a polling approach. It is easy to learn, but the CPU stays awake and draws a lot of current. For a device like FingerScore where battery life matters, we use a structure where the main loop drops straight into sleep when it has nothing to do and only works when an interrupt wakes it.
Polling vs. Interrupts vs. Event-Driven
| Approach | Behavior | Current | Responsiveness | Good for |
| --- | --- | --- | --- | --- |
| Polling | Loop keeps checking state | High | Fast | Simple prototypes |
| Interrupts | Handler called on event | Medium | Very fast | Buttons, sensor input |
| Event-driven sleep | Usually asleep, woken by interrupt | Very low | Fast | Battery wearables |
FingerScore takes the third approach. It normally stays in deep sleep, and when a button press or the IMU's motion-detection signal arrives as a GPIO interrupt, it wakes, processes the event, and goes back to sleep.
The Firmware Skeleton
First, look at the top-level skeleton. This example is C that is close to pseudocode, assuming an ARM Cortex-M family part (for instance Nordic nRF52), but the structure itself applies to any MCU.
#include <stdint.h>
#include <stdbool.h>
/* Global event flags. Interrupts set them; the main loop handles them. */
volatile bool g_button_event = false;
volatile bool g_motion_event = false;
static void system_init(void);
static void enter_low_power_sleep(void);
static void handle_score_increment(void);
int main(void)
{
system_init(); /* init clock, GPIO, timers, BLE, IMU */
for (;;) { /* the forever-running main loop */
if (g_button_event) {
g_button_event = false;
handle_score_increment();
}
if (g_motion_event) {
g_motion_event = false;
handle_score_increment();
}
/* When there is no event to handle, enter sleep.
Execution resumes after this line when an interrupt arrives. */
enter_low_power_sleep();
}
return 0; /* never actually reached */
}
The key is the `volatile` keyword. When an interrupt handler and the main loop share the same variable, it stops the compiler from caching the value in a register and skipping the memory re-read. Forget it and you get the infamous bug where "you pressed the button but the main loop never notices."
The Role of system_init
static void system_init(void)
{
clock_init(); /* pick a low-power clock source (e.g. 32.768kHz) */
gpio_init(); /* button pin as input + pull-up, IMU interrupt pin */
timer_init(); /* timer for debounce */
ble_stack_init(); /* enable BLE stack, set advertising parameters */
imu_init(); /* accelerometer threshold, enable motion interrupt */
/* a boot signal: briefly turn the LED on then off at power-up */
led_blink_once();
}
The details inside each `_init()` differ by MCU SDK, but the ordering has reasons. The clock must come first so that timers and communication peripherals have an accurate time base. It is safer to configure GPIO and interrupts before the BLE stack.
Detecting Buttons With GPIO Interrupts
A button is the most intuitive input. Pressing it changes the pin voltage, and the MCU detects that change (an edge) and raises an interrupt. The FingerScore button connects one side to a GPIO pin and the other to GND. With an internal pull-up resistor, the pin normally stays HIGH (1) and drops to LOW (0) when pressed. This is called a pull-up, active-low configuration.
VCC
|
[internal pull-up resistor]
|
+---- GPIO2 (MCU input pin) --- normally HIGH
|
[button]
|
GND <- when pressed, GPIO2 drops to LOW (falling edge)
If we configure the interrupt to fire on the falling edge, the handler is called the moment the button is pressed.
/* GPIO interrupt handler.
This function is called automatically when the hardware sees a falling edge. */
void GPIO_IRQHandler(void)
{
if (gpio_interrupt_pending(BUTTON_PIN)) {
gpio_clear_interrupt(BUTTON_PIN); /* clear the interrupt flag (required) */
/* Do no heavy work inside the handler.
Just set a flag and exit quickly. */
g_button_event = true;
}
if (gpio_interrupt_pending(IMU_INT_PIN)) {
gpio_clear_interrupt(IMU_INT_PIN);
g_motion_event = true;
}
}
The golden rule of writing an interrupt handler (ISR, Interrupt Service Routine) is "short and fast." If you do heavy work like a BLE transmission inside an ISR, other interrupts get blocked and the system becomes unstable. So the ISR only sets a flag and defers the real work to the main loop. This pattern is commonly called "deferred work" or "bottom-half."
Another easy thing to forget is clearing the interrupt flag with `gpio_clear_interrupt()`. Skip it and the same interrupt re-fires endlessly, making the system appear frozen.
Debouncing — One Press Counted Many Times
Physical buttons hold a trap that catches everyone at least once. To the human eye it looks like one press, but at the moment the metal contacts touch they bounce microscopically, swinging between HIGH and LOW several times over a few milliseconds. This is called chattering or bounce. Without debouncing, a single click raises the score by two or three.
ideal button: ----+________________
|
real button: ----+_|‾|_|‾|________ <- bounces briefly several times
<-- 5~20ms -->
There are two solutions: hardware debounce (RC filter, Schmitt trigger) and software debounce. To save parts, FingerScore uses software debounce. The most common approach is "if not enough time (say 30ms) has passed since the last input, ignore it."
#include <stdint.h>
#include <stdbool.h>
#define DEBOUNCE_MS 30u
extern uint32_t millis(void); /* returns milliseconds elapsed since boot */
static uint32_t s_last_press_ms = 0;
/* Debounce decision function called from the main loop.
Returns true for a valid new input, false for chatter. */
static bool button_debounced_accept(void)
{
uint32_t now = millis();
if ((now - s_last_press_ms) < DEBOUNCE_MS) {
return false; /* came back too soon -> treat as chatter, ignore */
}
s_last_press_ms = now;
return true; /* valid input */
}
`millis()` returns the milliseconds elapsed since boot, usually a counter incremented every 1ms by a timer interrupt. Unsigned integer subtraction like `now - s_last_press_ms` has the nice property of working correctly even when the counter wraps around (overflows), which is why it is a common idiom in embedded code.
The main loop's button handling slots the debounce in like this.
if (g_button_event) {
g_button_event = false;
if (button_debounced_accept()) {
handle_score_increment(); /* only valid clicks affect the score */
}
}
Time-Based vs. Stabilization-Based Debounce
| Approach | Principle | Pro | Con |
| --- | --- | --- | --- |
| Time-based | Ignore re-input within a window | Simple to implement | May miss a fast double click |
| Stabilization (sampling) | Confirm only when N reads agree | Robust to noise | Needs timer polling |
For a wearable score keeper where a fast double click is not important, time-based is enough. In a very noisy environment, sampling the pin multiple times at a fixed interval and accepting only when consecutive reads match is more robust.
Recognizing Finger Gestures With an IMU
A button is the most certain input, but FingerScore's real charm is raising the score with a finger flick, no button required. For this we use an IMU (Inertial Measurement Unit) sensor. An IMU typically packs a 3-axis accelerometer and a 3-axis gyroscope into a single chip.
- Accelerometer: measures linear acceleration including gravity (which direction and how fast). It reads 1g of gravity even at rest.
- Gyroscope: measures angular velocity (how fast it is rotating).
A quick finger flick shows up as a short, strong change in acceleration. The simplest recognition method is "if the overall acceleration magnitude crosses a threshold, treat it as a gesture." Combining the three axes as the square root of the sum of squares gives a direction-independent magnitude.
magnitude = sqrt(ax^2 + ay^2 + az^2)
at rest: about 1.0 g (gravity only)
light wobble: 1.2 ~ 1.5 g
deliberate flick: 2.0 g and up <- pick this point as the threshold
A Simple Threshold State Machine
If we treat "crossed the threshold once = gesture," one motion gets caught several times, and everyday tremors leak into the score. So we use a state machine. From IDLE, crossing a high acceleration enters ACTIVE; when things calm down again (staying below a low threshold for a while), we confirm a single gesture and return to IDLE.
[IDLE] --(magnitude > HIGH_TH)--> [ACTIVE]
^ |
| (magnitude < LOW_TH held |
| for QUIET_MS) |
+----- confirm 1 gesture <---------+
Using different HIGH and LOW thresholds is called hysteresis. With a single threshold, the state toggles wildly when the signal hovers near the boundary; with a margin between two thresholds, it locks in cleanly just once.
#include <stdint.h>
#include <stdbool.h>
#include <math.h>
#define HIGH_TH_G 2.0f /* acceleration magnitude that starts a gesture */
#define LOW_TH_G 1.3f /* acceleration magnitude considered "motion ended" */
#define QUIET_MS 120u /* held below LOW for this long means motion done */
typedef enum {
GESTURE_IDLE = 0,
GESTURE_ACTIVE
} gesture_state_t;
static gesture_state_t s_state = GESTURE_IDLE;
static uint32_t s_quiet_start_ms = 0;
extern uint32_t millis(void);
/* Takes 3-axis acceleration (in g) and returns true when one gesture completes */
static bool gesture_update(float ax, float ay, float az)
{
float magnitude = sqrtf(ax * ax + ay * ay + az * az);
uint32_t now = millis();
switch (s_state) {
case GESTURE_IDLE:
if (magnitude > HIGH_TH_G) {
s_state = GESTURE_ACTIVE; /* detected start of motion */
}
return false;
case GESTURE_ACTIVE:
if (magnitude > LOW_TH_G) {
/* still moving -> reset the quiet timer */
s_quiet_start_ms = now;
return false;
}
/* below LOW now. has it been quiet long enough? */
if ((now - s_quiet_start_ms) >= QUIET_MS) {
s_state = GESTURE_IDLE; /* one gesture complete, wait again */
return true;
}
return false;
default:
s_state = GESTURE_IDLE;
return false;
}
}
This function is called from the main loop each time fresh IMU data arrives. When it returns `true`, we treat it as one intended gesture and raise the score. The thresholds and `QUIET_MS` are values you must tune by actually wearing it and waving. There is no perfect number up front, so logging the real acceleration magnitudes and tuning from there is essential.
The IMU's Motion Wake-Up Feature
Many low-power IMUs (for instance ST LSM6DSO, Bosch BMI270) include a hardware feature that "toggles an interrupt pin when acceleration above a set level is detected" inside the chip. Using this, the MCU stays in deep sleep and wakes only on meaningful motion. That is, the MCU does not need to poll acceleration itself, saving substantial power. This is why we configure that threshold and interrupt inside `imu_init()`.
Touch and Flex Sensors as Alternatives
If IMU gestures feel like too much, there are simpler input methods.
| Sensor | Principle | Pro | Con |
| --- | --- | --- | --- |
| Tactile button | Mechanical contact | Clear click feel, cheap | Chatter, wear |
| Capacitive touch | Finger capacitance change | No moving parts | Sensitive to sweat/gloves |
| Flex sensor | Resistance changes when bent | Senses finger bend | Bulk, durability |
Capacitive touch is supported by many MCUs as a built-in peripheral, so it can be done with a single pin, and with no moving parts it is durable. It can waver, though, with heavy sweat or while wearing gloves. A flex sensor can directly read the finger-bending motion itself but is too bulky for a ring form factor. For the first FingerScore prototype, a staged approach is recommended: validate the action with a tactile button, then add IMU gestures in the second iteration.
From Event to Score Increment
However the input arrives, it all converges on "one valid input = score +1 = BLE transmission." We gather this flow into a single function.
#include <stdint.h>
static uint16_t s_score = 0;
extern void ble_send_score(uint16_t score);
extern void led_blink_once(void);
/* Called when a valid input (button or gesture) is confirmed. */
static void handle_score_increment(void)
{
s_score++; /* raise the score by 1 */
led_blink_once(); /* feedback to the user that input was recognized */
ble_send_score(s_score); /* send the current score to the phone */
}
The important point here is that the BLE transmission happens in the main loop context, not in the ISR. Since the ISR earlier only set a flag, the actual heavy wireless transmission runs in the safe main loop. Resetting the score to zero, or handling when one side wins a game, are logic for a later stage, but the smallest unit is this one-line increment.
Put together, the full main loop looks like this.
for (;;) {
if (g_button_event) {
g_button_event = false;
if (button_debounced_accept()) {
handle_score_increment();
}
}
if (g_motion_event) {
g_motion_event = false;
float ax, ay, az;
imu_read_accel(&ax, &ay, &az); /* read latest acceleration */
if (gesture_update(ax, ay, az)) {
handle_score_increment();
}
}
enter_low_power_sleep(); /* work done, sleep again */
}
Low-Power Design — The Art of Sleeping
In a wearable, power is usability. Nobody wears a score ring that must be charged every day. The heart of low-power design is "minimize the time the CPU stays awake."
active: a few mA ~ tens of mA (CPU + radio active)
idle: a few hundred uA
deep sleep: around 1 uA <- the usual resting state
Note the units. Deep sleep's microamps (uA) are roughly a thousand times smaller than active milliamps (mA). Even if the awake time is only 1 percent of the day, battery life changes dramatically.
The inside of `enter_low_power_sleep()` differs by MCU, but the concept is the same: stop the CPU core, turn off clocks for unneeded peripherals, and keep alive only the circuit that receives interrupts.
static void enter_low_power_sleep(void)
{
/* If there is a pending event, do not sleep (avoid a race condition). */
if (g_button_event || g_motion_event) {
return;
}
disable_unused_peripherals(); /* cut off unneeded clocks/power */
/* WFI = Wait For Interrupt. Stop the CPU and wait for an interrupt.
When one arrives, execution resumes after this line. */
__asm volatile ("wfi");
}
`WFI` (Wait For Interrupt) is an ARM Cortex-M instruction that puts the CPU in a low-power state and halts until an interrupt arrives. The reason we re-check the event flags right before entering is a race condition. If an interrupt slips in between checking the flag and entering sleep, you might miss the reason to wake and sleep forever. This re-check closes that gap.
Setting Up a Firmware Development Environment
The environment matters as much as writing the code. It is also the most daunting part when first starting embedded.
- SDK / toolchain: use the SDK provided by the MCU vendor. For Nordic nRF52 the standard is the nRF Connect SDK (based on Zephyr); for Espressif ESP32 it is ESP-IDF. The compiler is usually GCC (arm-none-eabi-gcc).
- Debugger / programmer: to flash code onto the chip and single-step, you need a hardware debugger. Use a SEGGER J-Link, ST-Link, or the debug probe built into the board. With GDB you set breakpoints and inspect variables.
- Logging: embedded `printf` usually sends messages to a PC over UART (serial) or RTT (Real-Time Transfer). A one-line log saying "execution reached here" is half of debugging. But logging itself draws current, so trim or disable it in production firmware.
RTOS or Bare Metal?
| Approach | Characteristics | Good when |
| --- | --- | --- |
| Bare metal | No RTOS, just main loop + interrupts | Small firmware, learning stage |
| RTOS (FreeRTOS, etc.) | Task scheduling, sync tools | Many concurrent, complex tasks |
For FingerScore's first firmware, bare metal (main loop + interrupts) is plenty. That said, on SDKs where the BLE stack internally requires an RTOS (such as Zephyr), you naturally write on top of tasks. Either way, the interrupt, debounce, and state-machine concepts covered here apply directly.
Testing — Before You Shake It By Hand
Firmware testing is trickier than PC software. How do we verify code running on a chip?
- Unit tests: pure logic independent of hardware, like the debounce function or the gesture state machine, can be tested on a PC. By swapping `millis()` for a mock function so you control time freely, you can automatically verify questions like "are two inputs within 30ms counted only once?"
- HIL (Hardware-in-the-Loop): connect the real hardware to test equipment, inject signals, and check results. For example, apply a defined pulse to the button pin and automatically measure whether the score goes out correctly over BLE. Introduce this in a serious production stage.
At the learning stage, having unit tests for the pure logic alone is a big help. If you lay out the state machine's boundary values (just above/below the threshold, just before/after the quiet time) in a table as test cases, you can catch nearly all logic errors before ever shaking it by hand.
Common Pitfalls
Here are the traps embedded beginners fall into most often.
- Switch chatter: skip debouncing and one click is counted as several scores. The most common problem and the first to suspect.
- Forgetting to clear the interrupt flag: if the ISR does not clear the flag, the same interrupt re-fires endlessly and the system looks frozen.
- Missing `volatile`: leave `volatile` off a variable shared between an interrupt and the main loop, and optimization makes you miss events forever.
- Current leakage: leaving an input pin floating without a pull-up/pull-down lets the pin voltage drift, leaking small currents and spiking the sleep current. Even unused pins must be tied to a defined state.
- Heavy work in the ISR: doing a BLE transmission or long computation inside a handler blocks other interrupts and wrecks system responsiveness. Set a flag and exit.
- Race condition right before sleep: missing an interrupt in the gap between checking events and entering sleep makes you sleep forever. The re-check just before entry prevents it.
Wrapping Up
This post covered the brain of FingerScore: the MCU firmware. We walked through the skeleton of init, main loop, interrupts, and sleep; detecting a button with a GPIO interrupt; software debounce to catch chatter; a gesture state machine using IMU thresholds and hysteresis; and the flow from event to score increment, all with compilable-grade C code. The core philosophy is just one thing: an event-driven, low-power design that "sleeps normally and wakes only for meaningful events."
In the next Part 4, we dive deep into the BLE communication that actually carries the score value built here all the way to the phone: GATT service and characteristic design, advertising and connection parameters, and power optimization over the air. We will continue by unpacking how the firmware talks to the outside world.
References
- Nordic Semiconductor developer docs: https://docs.nordicsemi.com
- Espressif ESP-IDF programming guide: https://docs.espressif.com
- FreeRTOS official documentation: https://www.freertos.org
- Memfault Interrupt embedded blog: https://interrupt.memfault.com
- Adafruit Learn (sensor/embedded tutorials): https://learn.adafruit.com
- ST LSM6DSO IMU datasheet: https://www.st.com/resource/en/datasheet/lsm6dso.pdf
- Bosch BMI270 IMU product page: https://www.bosch-sensortec.com/products/motion-sensors/imus/bmi270/
현재 단락 (1/255)
This series is a hands-on learning journey building FingerScore, a BLE ring device that records rack...