A binary semaphore is a synchronization mechanism that can only be available or taken, like a simple signal between tasks or between an interrupt and a task.
We start Block 4: Synchronization and Shared Resources. This is one of the most delicate parts, because it’s where most errors appear in real-time system programming.
Until now, our tasks ran in parallel but each one did its own thing. The problem arises when two tasks want to access the same resource at the same time (writing to the same Serial port, modifying the same global variable) or when a task must wait for another to finish something.
If we don’t control this, we have chaos: corrupted data, lockups, and random behavior. To bring order, FreeRTOS offers us Semaphores. Today we start with the simplest one: the Binary Semaphore.
What is a binary semaphore?
Imagine a binary semaphore as a key hanging on a board.
- There is only one key.
- The key can be on the board (Semaphore Available / Given).
- Or someone can have the key (Semaphore Taken).
It does not store data (it’s not a queue), it only stores State: 0 (Empty) or 1 (Full).
Basic operations
- Take: A task tries to grab the key.
- If the key is there, it grabs it and continues. The board becomes empty.
- If the key is not there (someone else already has it), the task Blocks (sleeps) waiting for someone to return the key.
- Give: A task (or interrupt) returns the key to the board.
- If someone was waiting for it, that task wakes up immediately and grabs it.
Synchronization and mutual exclusion
Although technically we can use a binary semaphore to protect a variable (Mutual Exclusion), in FreeRTOS it is not its recommended use (for that there are Mutexes, which we will see later).
The main use of the Binary Semaphore is Synchronization:
“A task waits for an event generated by another task or interrupt.”
That is, we use it as a signaling flag.
Creation and usage
To use semaphores, we first need to include the library (if not in native ESP32) and declare the SemaphoreHandle_t object.
SemaphoreHandle_t mySemaphore;
void setup() {
// 1. Create the semaphore
mySemaphore = xSemaphoreCreateBinary();
// BEWARE! Binary semaphores are created "EMPTY" (Taken).
// If you do a Take right after creating it, you will block.
// Sometimes it's useful to do an initial Give:
// xSemaphoreGive(mySemaphore);
}API Functions
xSemaphoreTake(handle, wait_time): Attempts to take the semaphore. ReturnspdTRUEif successful.xSemaphoreGive(handle): Releases the semaphore.
Practical case: deferred interrupt processing
This is one of the most important design patterns in this block.
Interrupts (ISRs) must be very short. You cannot put a Serial.print or complex calculations inside an interrupt because it blocks the entire system.
What’s the solution?
- The Interrupt occurs, does the minimum (clears flags) and does a
Giveto the semaphore. - A normal Task is blocked doing a
Takeon the semaphore. - Upon receiving the
Give, the task wakes up and does the heavy work.
This is called Deferring Interrupt Processing.
// Define the semaphore
SemaphoreHandle_t buttonSemaphore;
// --- Interrupt (ISR) ---
// IRAM_ATTR allows placing the ISR in RAM when the design requires it.
void IRAM_ATTR isrButton() {
// Necessary variable for FreeRTOS in ISRs
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// Give the semaphore from the interrupt
// Use the "FromISR" version
xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
// If giving the semaphore woke up an important task,
// force a context switch NOW, so it executes when leaving the ISR.
if(xHigherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
// --- Handler Task ---
void ButtonTask(void *pvParameters) {
for(;;) {
// The task lives blocked here. Doesn't consume CPU.
// Waits forever (portMAX_DELAY) for the semaphore to be available.
if(xSemaphoreTake(buttonSemaphore, portMAX_DELAY) == pdTRUE) {
// If we are here, someone pressed the button.
// Now we can do slow things without worry.
Serial.println("Button pressed! Processing complex event...");
// Simulate heavy work
for(int i=0; i<10; i++) {
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
vTaskDelay(pdMS_TO_TICKS(100));
}
}
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
pinMode(0, INPUT_PULLUP); // BOOT button
// 1. Create the semaphore
buttonSemaphore = xSemaphoreCreateBinary();
if (buttonSemaphore == NULL) {
Serial.println("Could not create semaphore");
return;
}
// 2. Create the task that will process the event
// Give it high priority so it responds quickly after the interrupt
xTaskCreate(ButtonTask, "HandleButton", 2048, NULL, 2, NULL);
// 3. Configure the interrupt
attachInterrupt(digitalPinToInterrupt(0), isrButton, FALLING);
}
void loop() {}Code Analysis
- Idle: Nobody touches the button. The
ButtonTaskis blocked onxSemaphoreTakeand consumes no CPU. - Event: You press the button. The CPU jumps to
isrButton. - Signaling: The ISR gives the semaphore (
Give) and finishes in microseconds. - Reaction: The Scheduler sees that the semaphore is available and wakes up
ButtonTask. - Processing:
ButtonTaskexits the block, prints to the serial, and blinks the LED. When theforloop finishes, it returns to theTakeand goes back to sleep.
Notice portYIELD_FROM_ISR. If the ISR has unblocked a higher priority task, it allows the context switch to occur when leaving the interrupt. Without this request, the task would wait until the next scheduling opportunity.
Binary semaphore and task notification
In the previous article we saw that we could do the same thing with vTaskNotifyGiveFromISR. So why use a Semaphore?
- Decoupling: With Task Notification, the interrupt needs to know the
TaskHandleof the specific task. With a Semaphore, the interrupt just gives the key to the semaphore object. It doesn’t care who picks it up. We could change the consuming task to another one, and the ISR wouldn’t even notice. - Multiple Consumers: (Rare with binary, but possible). Several tasks could be waiting for the same semaphore; the first one to grab it wins.
Common problems
- Forgetting
xSemaphoreCreateBinary: If you try to use a semaphore without creating it (variable set to NULL), the ESP32 will fail. - Losing events: A binary semaphore only stores 1 or 0. If the interrupt fires 5 times very quickly before the task can process the first one, the other 4
Givecalls are lost (the semaphore was already full).
- Solution: Use a Counting Semaphore (we’ll see it soon) or a Queue.