Binary semaphores are one of the most common synchronization tools in FreeRTOS. They are simple in concept, but they become much more useful once you start building systems with multiple tasks, interrupts, and events that need orderly coordination.
A task may need to wait until a button is pressed, until a peripheral finishes an operation, until new data is ready, or until another task signals that some step is complete. A binary semaphore is often a clean way to represent that kind of event.
Beginners sometimes confuse binary semaphores with mutexes because both can be taken and given. They can look similar in code, but they are meant for different purposes. A mutex is mainly for protecting shared resources with ownership rules and priority inheritance. A binary semaphore is mainly for signaling. That difference is extremely important in FreeRTOS design.
This article explains what a binary semaphore is, how it works, when to use it, how it differs from a mutex, how it is often used between an interrupt and a task, and what common mistakes to avoid.
What a binary semaphore is
A binary semaphore is a synchronization object with two states. You can think of it as either available or unavailable.
That is why it is called binary. It does not count upward through many values like a counting semaphore. It only represents a simple yes-or-no condition.
A useful mental model is this:
A binary semaphore is like a signal flag that can wake a waiting task.
One context gives the semaphore.
Another context takes the semaphore.
If the semaphore is not available, the task trying to take it can wait.
This makes it very useful for event signaling.
Why binary semaphores matter
Binary semaphores matter because many embedded events are naturally one-bit events.
Examples include:
- a button press happened
- a UART transmission finished
- a DMA transfer completed
- a sensor interrupt fired
- one task has finished a step and another task may continue
In all of these cases, you are not necessarily protecting a shared resource. You are signaling that something happened.
That is exactly where a binary semaphore fits well.
The core idea
The simplest pattern is this:
A task blocks waiting on a binary semaphore.
Some other context gives the semaphore when the event happens.
The waiting task wakes up and continues.
That is the essence of binary semaphore use.
The task does not need to poll constantly. It does not need to waste CPU time checking a flag again and again. It can block efficiently until the event occurs.
This is one of the biggest reasons semaphores are useful in RTOS systems.
Binary semaphore versus mutex
This is one of the most important beginner topics.
A binary semaphore and a mutex are not the same thing, even though both can be taken and given.
A mutex is meant for mutual exclusion. It protects a shared resource and has ownership semantics. The task that takes the mutex is expected to be the one that gives it back. A mutex also supports priority inheritance.
A binary semaphore is meant for signaling. It does not represent resource ownership in the same way. It is commonly used when one context needs to wake or unblock another.
A simple rule is:
Use a mutex to protect a shared resource.
Use a binary semaphore to signal an event.
That rule will prevent many design mistakes.
Binary semaphore versus counting semaphore
A binary semaphore only represents one available signal at a time.
A counting semaphore can count multiple events.
For example, if you have a system where many identical events may happen and you need to count how many occurred, a counting semaphore may be more appropriate.
But if you only need to represent whether the event has occurred or whether a task may proceed, a binary semaphore is often enough.
For beginners, binary semaphores are usually easier to understand and use first.
Creating a binary semaphore
In FreeRTOS, a binary semaphore is typically created with xSemaphoreCreateBinary().
Example:
#include "FreeRTOS.h"
#include "semphr.h"
SemaphoreHandle_t buttonSemaphore;
void init_sync_objects(void)
{
buttonSemaphore = xSemaphoreCreateBinary();
}
This creates the semaphore and returns a handle that tasks and ISRs can use later.
As with other FreeRTOS objects, creation can fail if memory is not available, so it is good practice to check the result.
Example:
void init_sync_objects(void)
{
buttonSemaphore = xSemaphoreCreateBinary();
if (buttonSemaphore == NULL)
{
for (;;)
{
}
}
}
Taking a binary semaphore
A task usually waits on a binary semaphore using xSemaphoreTake().
Example:
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY) == pdTRUE)
{
handle_button_press();
}
This tells the task to wait until the semaphore becomes available.
The block time controls how long the task waits:
0means do not wait at all- a tick value means wait for that long
portMAX_DELAYmeans wait indefinitely
This blocking behavior is one of the strongest advantages of semaphores. The task does not waste CPU time while waiting.
Giving a binary semaphore
A binary semaphore is made available with xSemaphoreGive() when used from task context.
Example:
xSemaphoreGive(buttonSemaphore);
This signals that the event has occurred or that the waiting task may proceed.
A common use is one task signaling another after some work is complete.
A simple task-to-task example
Suppose one task performs setup work, and another task should not continue until that setup is finished.
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
SemaphoreHandle_t readySemaphore;
void InitTask(void *pvParameters)
{
(void) pvParameters;
initialize_hardware();
xSemaphoreGive(readySemaphore);
for (;;)
{
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void WorkerTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
if (xSemaphoreTake(readySemaphore, portMAX_DELAY) == pdTRUE)
{
do_main_work();
}
}
}
This is a simple synchronization pattern. WorkerTask waits until InitTask signals that the system is ready.
Binary semaphores and interrupts
One of the most useful roles for a binary semaphore is signaling from an interrupt to a task.
This is a classic RTOS design pattern.
The ISR handles the urgent minimum.
The ISR gives a semaphore.
A task waiting on that semaphore wakes and does the larger job.
This is a strong pattern because it keeps the ISR short and lets the task handle the more complex logic.
ISR-safe semaphore give
When giving a binary semaphore from an interrupt, you must use the ISR-safe API:
xSemaphoreGiveFromISR()
Example:
SemaphoreHandle_t dataReadySemaphore;
void SENSOR_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
clear_sensor_interrupt_flag();
xSemaphoreGiveFromISR(dataReadySemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
This is a very important pattern in FreeRTOS.
The ISR does not call the normal xSemaphoreGive().
It uses the ISR-safe version.
The xHigherPriorityTaskWoken variable allows the ISR to tell the system whether a higher-priority task was unblocked and should run immediately after the ISR finishes.
Task waiting on an ISR-driven semaphore
The task side looks like this:
void ProcessingTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
if (xSemaphoreTake(dataReadySemaphore, portMAX_DELAY) == pdTRUE)
{
process_sensor_data();
}
}
}
This is one of the clearest examples of deferred interrupt processing.
The interrupt says, “Data is ready.”
The task wakes and performs the real processing.
This keeps the interrupt handler clean and the task logic organized.
Why binary semaphores are good for ISR-to-task signaling
Binary semaphores are a good fit for ISR-to-task signaling because they represent a simple event well.
For example:
- conversion complete
- button pressed
- packet received
- timer expired
- DMA finished
These are not cases where you are trying to protect a shared resource. You are simply signaling that something happened.
That is exactly the kind of job binary semaphores are good at.
A button interrupt example
Suppose a button generates a GPIO interrupt. The ISR can signal a task with a binary semaphore.
ISR:
SemaphoreHandle_t buttonSemaphore;
void EXTI_Button_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
clear_button_interrupt_flag();
xSemaphoreGiveFromISR(buttonSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
Task:
void ButtonTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY) == pdTRUE)
{
handle_button_event();
}
}
}
This is a clean and efficient design. The ISR stays short. The task does the real work.
Binary semaphores are not for data transfer
A binary semaphore only signals availability. It does not carry data by itself.
If your interrupt or task needs to pass actual values, such as bytes, structures, or measurements, a queue may be a better choice.
For example, this is a good semaphore use case:
- signal that data is ready
This is not something the semaphore itself can do:
- carry the actual sensor reading value
A common design is to use a semaphore for signaling and a buffer or queue for the data itself.
A practical example: DMA completion
Imagine a task starts a DMA transfer and then waits for it to finish.
The ISR signals completion:
SemaphoreHandle_t dmaDoneSemaphore;
void DMA_IRQHandler(void)
{
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
clear_dma_interrupt_flag();
xSemaphoreGiveFromISR(dmaDoneSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
The task waits:
void TransferTask(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
start_dma_transfer();
if (xSemaphoreTake(dmaDoneSemaphore, pdMS_TO_TICKS(1000)) == pdTRUE)
{
handle_transfer_complete();
}
else
{
handle_transfer_timeout();
}
}
}
This example is useful because it shows both synchronization and timeout handling.
Timeouts with binary semaphores
A task does not always need to wait forever for a semaphore.
For example:
if (xSemaphoreTake(mySemaphore, pdMS_TO_TICKS(500)) == pdTRUE)
{
do_work();
}
else
{
handle_timeout();
}
This is useful when an expected event should happen within a certain time. If it does not, the task can recover, retry, or report an error.
Timeout-based waiting is a strong design pattern in embedded systems.
Giving before taking
A binary semaphore can be given before a task takes it. In that case, the next eligible xSemaphoreTake() can succeed immediately.
This is why semaphores are often described as signaling objects rather than only wake-up calls. They represent whether a signal is available.
That said, since a binary semaphore is only binary, repeated gives do not stack up indefinitely the way counting semaphores do. That is another reason to choose the right tool for the problem.
Binary semaphores and task ordering
Binary semaphores can also help coordinate the order of operations between tasks.
For example:
- Task A prepares data
- Task A gives a semaphore
- Task B takes the semaphore and continues
This is much cleaner than building ad hoc delay-based sequencing.
A semaphore expresses synchronization directly. Delays only guess at timing.
Why polling is often worse
Without a semaphore, a beginner might write something like this:
volatile int dataReady = 0;
void Task(void *pvParameters)
{
(void) pvParameters;
for (;;)
{
if (dataReady)
{
dataReady = 0;
process_data();
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
This works in some small cases, but it is basically polling with a flag.
A semaphore-based design is cleaner because the task can block until the event happens, rather than repeatedly checking.
That usually makes the system more efficient and easier to reason about.
Common beginner mistakes
One common mistake is using a binary semaphore where a mutex should be used. If you are protecting a shared resource such as a UART or I2C bus, a mutex is usually the correct tool, not a binary semaphore.
Another common mistake is calling xSemaphoreGive() from an ISR instead of xSemaphoreGiveFromISR().
Another is forgetting to use portYIELD_FROM_ISR() when a higher-priority task may have been unblocked.
Another is assuming the semaphore carries data. It only signals.
Another is trying to use a binary semaphore for counting multiple events. That may require a counting semaphore instead.
Another is creating a semaphore but forgetting to check whether creation succeeded.
Another is using polling and flags everywhere when a semaphore would make the design cleaner.
When to use a binary semaphore
A binary semaphore is a good choice when:
- you need to signal that an event happened
- a task should wait until some condition is triggered
- an ISR needs to wake a task
- one task should unblock another
- you do not need to transfer actual data through the object
Typical examples include:
- button press signaling
- DMA complete signaling
- ADC conversion complete signaling
- task startup sequencing
- timeout and event synchronization
When not to use a binary semaphore
A binary semaphore is usually not the best choice when:
- you are protecting a shared resource and need ownership rules
- priority inheritance matters
- you need to transfer actual data items
- you need to count multiple queued events reliably
In those cases, a mutex, queue, counting semaphore, or task notification may be more appropriate.
Conclusion
Binary semaphores in FreeRTOS are simple but very useful synchronization tools. They are best understood as event signals between tasks or between an interrupt and a task. A task can block waiting for the semaphore, and another context can give it when something important happens.
The most important lessons are straightforward. Use binary semaphores for signaling, not mutual exclusion. Use the ISR-safe APIs when working from an interrupt. Keep ISR work short and let tasks do the heavier processing. Do not expect a binary semaphore to carry data or count many separate events.

