Common Mistakes in Embedded C Development: Race conditions with global variables

Ilya KatlinskiIlya Katlinski
3 min read

📘 Introduction

This is Part 2 of our 5-part series on concurrency and timing mistakes in Embedded C. In Part 1, we discussed the dangers of using delay() or busy loops for timing.

In this part, we’ll dive into a more subtle and dangerous issue: race conditions when accessing global variables across interrupts and main code.

🧠 Race conditions with global variables

🐞 The Problem

Global variables are often used in embedded systems to share data between the main loop and interrupt service routines (ISRs). But unless access to those variables is carefully synchronised, race conditions can occur, where the result of an operation depends on the exact timing of interrupts. These bugs are unpredictable, hard to reproduce, and can cause corrupted data, missed events, or system crashes.

Let’s consider a real-world case: you want to count how many times a user presses a button, and each press is handled via a GPIO interrupt. The main loop checks the button press count and performs actions accordingly (e.g., toggling a mode or sending data).

Seems simple, but if you modify the same counter from both the ISR and the main loop, a race condition can occur.

#include <stdint.h>
#include <stdbool.h>

volatile uint32_t button_press_count = 0;

void IRAM_ATTR gpio_isr_handler(void* arg) {
    // ISR called on button press (rising edge)
    button_press_count++;  // Not atomic
}

void loop() {
    if (button_press_count > 0) {
        button_press_count--;   // Not atomic
        handle_button_press();
    }
}

What goes wrong:

  • button_press_count++ and button_press_count-- are read-modify-write operations.

  • If the main loop and the ISR access button_press_count at the same time, updates may be lost or corrupted.

  • For example, two presses may increment it, but one may get dropped due to a corrupted decrement.

Solution: Use C11 Atomics for Safe Access in Embedded C

The better solution is to replace the shared volatile counter with a C11 atomic type and use atomic operations to guarantee consistent updates.

#include <stdint.h>
#include <stdatomic.h>

_Atomic uint32_t button_press_count = 0;

void IRAM_ATTR gpio_isr_handler(void* arg) {
    atomic_fetch_add(&button_press_count, 1); // Safely increment
}

void loop() {
    // Atomically fetch current count and reset to 0
    uint32_t presses = atomic_exchange(&button_press_count, 0);

    // Handle all pending button presses
    for (uint32_t i = 0; i < presses; ++i) {
        handle_button_press();
    }
}

💡 Takeaway

  • Never assume ++ or -- is atomic, even on 32-bit variables.

  • Using volatile only ensures the compiler won’t optimise the variable away; it doesn’t protect against concurrency issues.

  • Use C11 atomic functions like atomic_fetch_add() and atomic_exchange().

🚀
Call to action: When interrupts and main code share data, use atomic operations, not assumptions; it’s the simplest way to avoid silent, hard-to-find bugs.

At Itransition, we build IoT solutions with all these challenges in mind, ensuring our clients receive reliable, scalable systems with minimal maintenance overhead. Learn more about our approach at https://www.itransition.com/iot.

0
Subscribe to my newsletter

Read articles from Ilya Katlinski directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ilya Katlinski
Ilya Katlinski