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.
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.



