Skip to main content

Command Palette

Search for a command to run...

Common Mistakes in Embedded C Development: Missing synchronisation in producer-consumer scenarios

Published
β€’4 min read
Common Mistakes in Embedded C Development: Missing synchronisation in producer-consumer scenarios

πŸ“˜ Introduction

This is Part 3 of our 5-part series on concurrency and timing mistakes in Embedded C. In Part 2, we discussed how race conditions on global variables can silently corrupt data. In this part, we focus on another frequent issue: broken producer-consumer logic due to missing synchronisation.

🧠 Missing synchronisation in producer-consumer scenarios

🐞 The Problem

Many embedded systems involve interrupt-driven data producers (e.g., sensor readings, UART receive handlers) and main-loop consumers (e.g., processing or forwarding data). A common pattern is using a circular buffer or queue to store events or bytes between the producer and consumer.

However, without proper synchronisation between the producer (often running in an ISR) and the consumer (running in the main loop), you risk:

  • Data corruption (e.g., head/tail pointers updated unsafely),

  • Buffer overflows or underflows, and

  • Unreliable or lost communication between parts of the system.

Let’s simulate a real-world example: a UART receive interrupt writes incoming bytes into a buffer, while the main loop reads and processes them.

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

#define BUF_SIZE 64
volatile uint8_t rx_buffer[BUF_SIZE];
volatile uint8_t head = 0;
volatile uint8_t tail = 0;

// ISR: called when a new byte is received via UART
void uart_rx_isr(uint8_t byte) {
    rx_buffer[head] = byte;
    head = (head + 1) % BUF_SIZE;  // May overwrite unread data
}

// Main loop: processes received bytes
void loop() {
    if (head != tail) {
        uint8_t byte = rx_buffer[tail];
        tail = (tail + 1) % BUF_SIZE;
        process_byte(byte);
    }
}

What goes wrong:

  • Access to the head and tail is not protected; both the ISR and the main code update shared indexes.

  • If the ISR modifies the head while the main loop reads it, the buffer can become corrupted.

  • There’s no overflow protection β€” unread data can be overwritten.

  • This can result in lost bytes, corrupted data streams, or crashes if pointers get out of sync.

Solution: Use a Lockless Ring Buffer with Synchronisation

A correct and efficient way to handle this is to implement a lockless ring buffer using careful synchronisation rules:

  • Only the producer (ISR) updates the head.

  • Only the consumer (main loop) updates the tail.

  • Shared access is protected by declaring indexes as volatile and using buffer full/empty checks.

  • The ISR checks for space before writing to prevent overwriting unread data.

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

#define BUF_SIZE 64
volatile uint8_t rx_buffer[BUF_SIZE];
volatile uint8_t head = 0;
volatile uint8_t tail = 0;

// ISR: called when a new byte is received via UART
void uart_rx_isr(uint8_t byte) {
    rx_buffer[head] = byte;
    head = (head + 1) % BUF_SIZE;  // May overwrite unread data
}

// Main loop: processes received bytes
void loop() {
    if (head != tail) {
        uint8_t byte = rx_buffer[tail];
        tail = (tail + 1) % BUF_SIZE;
        process_byte(byte);
    }
}

Solution: Use a Queue implementation for Safe Producer-Consumer Communication

If your project uses FreeRTOS, the cleanest way to implement safe communication between an ISR and a task (main loop) is through a FreeRTOS queue. Queues are thread-safe, ISR-aware, and eliminate the need for manual pointer and buffer management.

#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"

#define QUEUE_LENGTH 64

QueueHandle_t uart_rx_queue;

// ISR: send received byte to queue (from ISR context)
void uart_rx_isr(uint8_t byte) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xQueueSendFromISR(uart_rx_queue, &byte, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// Task or main loop: receive bytes from queue
void uart_task(void* arg) {
    uint8_t byte;
    while (true) {
        if (xQueueReceive(uart_rx_queue, &byte, portMAX_DELAY)) {
            process_byte(byte);
        }
    }
}

void app_main() {
    uart_rx_queue = xQueueCreate(QUEUE_LENGTH, sizeof(uint8_t));
    xTaskCreate(uart_task, "uart_task", 2048, NULL, 10, NULL);
}

πŸ’‘ Takeaway

  • Producer-consumer patterns require careful synchronisation between ISRs and main/task code.

  • In bare-metal systems, use a lockless ring buffer:

    • Let the ISR write head, main code write tail.

    • Always check for overflow and underflow.

  • In FreeRTOS-based systems, use queues:

    • xQueueSendFromISR() for producers, xQueueReceive() for consumers.

    • Safe, clean, and preferred when an RTOS is available.

πŸš€
Call to action: Choose the solution that fits your system and always make data integrity and safety your first priority.

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.

More from this blog

I

Itransition IoT

12 posts