In this guide, we’ll introduce you to and explain how to use FreeRTOS semaphores with the ESP32, using the Arduino IDE. Semaphores are like signals (or flags) that allow you to synchronize tasks and manage events. They can be used to indicate that an event has occurred or that a resource is available. Unlike queues, semaphores don’t carry data.

There are two types of semaphores: binary semaphores and counting semaphores. In this tutorial, we’ll create and explore two different examples to show you how these two types of semaphores work.
New to FreeRTOS? Start with this tutorial: ESP32 with FreeRTOS (Arduino IDE)—Getting Started Guide: Creating Tasks.
Introducing Semaphores
Semaphores are signaling tools in FreeRTOS used to coordinate tasks. They are commonly used to indicate that an event has occurred or that a resource is available. Unlike queues, semaphores don’t carry data, only a “count” , or “flag”, or “signal” (or whatever you want to call it) used to trigger actions when something happens.

They allow tasks to wait for events, such as a button press or motion detection, or to signal that an event has occurred. This makes them especially useful in scenarios involving interrupts and task synchronization.
Since semaphores don’t store data, they consume less memory than queues and are ideal for lightweight event signaling between tasks.
There are two types of semaphores:
- Binary semaphore: signals a single event. It is a synchronization tool that can be either empty (0) or full (1). It’s like a signal that a task is waiting for before it can proceed.
- Counting semaphore: tracks multiple events (it can be the same event several times). It’s like a queue of events up to a maximum count that you define. Contrary to FreeRTOS queues, these don’t carry data. Only a synchronization signal. You’ll better understand how this works later in the example.
Semaphores Basic Functions
Here are some basic functions of binary and counting semaphores when using the ESP32 with Arduino IDE. We’ll explore these functions in practical examples next.
Creating a Binary Semaphore
To create a binary semaphore, use the xSemaphoreCreateBinary() function. It returns a SemaphoreHandle_t handle if successful, or NULL if the creation fails.
Creating a Counting Semaphore
To create a counting semaphore, use the xSemaphoreCreateCounting() function. It returns a SemaphoreHandle_t handle if successful, or NULL if the creation fails. Pass as an argument the maximum count.
Taking a Semaphore (Getting from the Semaphore)
Use the xSemaphoreTake(semaphore, timeout) function in a task to wait for or take a semaphore. For a binary semaphore, it blocks until the semaphore is available (state 1), setting it to 0 when taken.
For a counting semaphore, it decrements the count if greater than 0, or blocks if the count is 0. The timeout parameter specifies how long to wait (in ticks); portMAX_DELAY means wait indefinitely. This means the task will be blocked until there’s a semaphore value to take.
Giving a Semaphore
To give a semaphore use the xSemaphoreGive() function if inside a task, or xSemaphoreGiveFromISR() if used in ISRs (interrupt service routine functions).
For a binary semaphore, it sets the state to 1, unblocking a waiting task (or ignored if the semaphore is already 1). For a counting semaphore, it increments the count up to maxCount, unblocking a waiting task (ignored if at maxCount).
Example 1: Binary Semaphore – Toggling an LED with a Button Press
In this section, we’ll build a simple example to demonstrate how a binary semaphore works and how to implement it in a practical application. This example will toggle an LED once when the pushbutton is pressed. To signal the button press, we’ll use a semaphore. Simultaneously, we’ll have another task blinking an LED to demonstrate that we can have multiple tasks running simultaneously.

So, here’s an overview of the example:
- We’ll add an interrupt to a pushbutton. When the pushbutton is pressed, the corresponding ISR will give the semaphore (will set it to 1).
- There’s another task, called LEDToggleTask(), that will be waiting for the semaphore to toggle the state of the LED. When the semaphore is given by the ISR, this task will run and the semaphore will be reset to 0. Only when the semaphore is set to 1, when the button is pressed, this task will run again.
- Simultaneously, we have another task called LEDBlinkTask() that will increase and decrease the brightness of another LED.
Parts Required
Here’s a list of the parts required for this example:
- ESP32 Board of your choice – read Best ESP32 Development Boards
- 2x LED
- 2x 220 Ohm resistor or similar value
- Pushbutton
- Breadboard
- Jumper wires
You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!
Wiring the Circuit
Wire the following circuit:
- Red LED connected to GPIO 2
- Blue LED connected to GPIO 4
- Pushbutton connected to GPIO 23
You can follow the next schematic diagram.

Code
Upload the following code to the Arduino IDE.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-freertos-semaphores-arduino/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#define BUTTON_PIN 23
#define LED1_PIN 2 // Toggled LED
#define LED2_PIN 4 // Blinking LED
#define DEBOUNCE_DELAY 200
SemaphoreHandle_t buttonSemaphore = NULL;
volatile uint32_t lastInterruptTime = 0;
void IRAM_ATTR buttonISR() {
uint32_t currentTime = millis();
if (currentTime - lastInterruptTime > DEBOUNCE_DELAY) {
BaseType_t higherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(buttonSemaphore, &higherPriorityTaskWoken);
lastInterruptTime = currentTime;
if (higherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
}
void LEDToggleTask(void *parameter) {
pinMode(LED1_PIN, OUTPUT);
bool ledState = false;
for (;;) {
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
ledState = !ledState;
digitalWrite(LED1_PIN, ledState ? HIGH : LOW);
Serial.print("LEDToggleTask: LED1 ");
Serial.println(ledState ? "ON" : "OFF");
}
}
}
void LEDBlinkTask(void *parameter) {
pinMode(LED2_PIN, OUTPUT);
for (;;) {
digitalWrite(LED2_PIN, HIGH);
Serial.println("LEDBlinkTask: LED2 ON");
vTaskDelay(250 / portTICK_PERIOD_MS);
digitalWrite(LED2_PIN, LOW);
Serial.println("LEDBlinkTask: LED2 OFF");
vTaskDelay(250 / portTICK_PERIOD_MS);
}
}
void setup() {
Serial.begin(115200);
// Defining the button as an interrupt
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
buttonSemaphore = xSemaphoreCreateBinary();
if (buttonSemaphore == NULL) {
Serial.println("Failed to create semaphore!");
while (1);
}
xTaskCreatePinnedToCore(
LEDToggleTask, // Task function
"LEDToggleTask", // Task name
3000, // Stack size
NULL, // Task parameters
2, // Higher priority
NULL, // Task handle
1 // Core ID
);
xTaskCreatePinnedToCore(
LEDBlinkTask, // Task function
"LEDBlinkTask", // Task name
3000, // Stack size
NULL, // Task parameters
1, // Medium priority
NULL, // Task handle
1 // Core ID
);
}
void loop() {
}
How Does the Code Work?
Start by defining the pins for the pushbutton, for the toggled LED, and for the blinking LED.
#define BUTTON_PIN 23
#define LED1_PIN 2 // Toggled LED
#define LED2_PIN 4 // Blinking LED
Define the debounce delay for the pushbutton in milliseconds.
#define DEBOUNCE_DELAY 200
Create an handle for the semaphore called buttonSemaphore.
SemaphoreHandle_t buttonSemaphore = NULL;
setup()
Let’s explain the setup() first, and then analyse the tasks.
First, set the pushbutton as an interrupt and set its callback function (ISR). In this case, it’s called buttonISR.
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
We create a binary semaphore using the xSemaphoreCreateBinary() function on the buttonSemaphore handle we’ve created previously.
buttonSemaphore = xSemaphoreCreateBinary();
if (buttonSemaphore == NULL) {
Serial.println("Failed to create semaphore!");
while (1);
}
Then, we create the LedToggleTask and the LEDBlinkTask with different priorities.
xTaskCreatePinnedToCore(
LEDToggleTask, // Task function
"LEDToggleTask", // Task name
3000, // Stack size
NULL, // Task parameters
2, // Higher priority
NULL, // Task handle
1 // Core ID
);
xTaskCreatePinnedToCore(
LEDBlinkTask, // Task function
"LEDBlinkTask", // Task name
3000, // Stack size
NULL, // Task parameters
1, // Medium priority
NULL, // Task handle
1 // Core ID
);
buttonISR()
The following lines create the buttonISR() function.
void IRAM_ATTR buttonISR() {
uint32_t currentTime = millis();
if (currentTime - lastInterruptTime > DEBOUNCE_DELAY) {
BaseType_t higherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(buttonSemaphore, &higherPriorityTaskWoken);
lastInterruptTime = currentTime;
if (higherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
}
When the button is pressed, the buttonISR() function will run. If we have a valid press, it uses FreeRTOS semaphore functions to signal another task that the button event has occurred.
Giving the Semaphore
The following line is the key semaphore action. It “gives” the semaphore. This semaphore is like a signal that tells another part of the program (usually a FreeRTOS task—in our case, it’s the LEDToggleTask) that the button was pressed.
xSemaphoreGiveFromISR(buttonSemaphore, &higherPriorityTaskWoken);
higherPriorityTaskWoken and portYIELD_FROM_ISR
The higherPriorityTaskWoken variable is used to check if giving the semaphore will wake up a task that has a higher priority than the currently running task. If it does, we call portYIELD_FROM_ISR() to let the system immediately switch to that higher-priority task right after the interrupt finishes. In our case, we want to switch immediately to the LEDToggleTask.
In other words:
- Basically, higherPriorityTaskWoken is used to check if giving the semaphore unblocked a more important task.
- We pass it to xSemaphoreGiveFromISR() so it can update the value.
- If it’s pdTRUE, we call portYIELD_FROM_ISR() to let FreeRTOS switch to that task immediately.
This is how FreeRTOS lets interrupts safely trigger high-priority tasks without causing problems in task scheduling.
LEDToggleTask
The LEDToggleTask() will toggle the state of LED1 when there’s a value on the semaphore.
void LEDToggleTask(void *parameter) {
pinMode(LED1_PIN, OUTPUT);
bool ledState = false;
for (;;) {
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
ledState = !ledState;
digitalWrite(LED1_PIN, ledState ? HIGH : LOW);
Serial.print("LEDToggleTask: LED1 ");
Serial.println(ledState ? "ON" : "OFF");
}
}
}
When the task LEDToggleTask() runs, it sets up the LED pin as an output and starts an infinite loop. Inside the loop, it waits for the semaphore using xSemaphoreTake(buttonSemaphore, portMAX_DELAY). The portMAX_DELAY means that the task will wait indefinitely until there’s a value on the semaphore (until the button is pressed).
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
When the semaphore is received, the task will toggle the state of the LED and print it to the Serial Monitor.
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
ledState = !ledState;
digitalWrite(LED1_PIN, ledState ? HIGH : LOW);
Serial.print("LEDToggleTask: LED1 ");
Serial.println(ledState ? "ON" : "OFF");
LEDBlinkTask
Besides the other task, we have the LEDBlinkTask that runs independently and simultaneously, blinking an LED indefinitely.
void LEDBlinkTask(void *parameter) {
pinMode(LED2_PIN, OUTPUT);
for (;;) {
digitalWrite(LED2_PIN, HIGH);
Serial.println("LEDBlinkTask: LED2 ON");
vTaskDelay(250 / portTICK_PERIOD_MS);
digitalWrite(LED2_PIN, LOW);
Serial.println("LEDBlinkTask: LED2 OFF");
vTaskDelay(250 / portTICK_PERIOD_MS);
}
}
Demonstration
Upload the code to your board. After uploading, open the Serial Monitor at a baud rate of 115200. Press the ESP32 RST button so that it starts running the code.

The LED connected to GPIO 4 will be blinking every 250 milliseconds. Press the pushbutton to toggle the state of the LED connected to GPIO 2. You can see a little demonstration in the short video below.
In the Serial Monitor, you should get something similar.

Example 2: Counting Semaphore
In this section, we’ll build a simple example to demonstrate how counting semaphores work. We’ll create a counting semaphore with a maximum count of 5. That semaphore will take up to 5 button presses. There is another task that will consume that semaphore to blink an LED as many times as the values in the semaphore. When a value is consumed from the semaphore, another value can be added.
In summary, here’s an overview of how the project works:
- We’ll attach an interrupt to a pushbutton. When the pushbutton is pressed, the interrupt service routine (ISR) will give a semaphore—up to a maximum count of 5.
- The LEDBlinkTask will wait for the semaphore. Each time it receives one, it will blink the LED. The LED will blink once for each count currently available in the semaphore.
- When the LEDBlinkTask consumes a value from the semaphore, there is “space” for a new count added by the press of a pushbutton.
- Simultaneously, we’ll have another task called LEDFadeTask that will fade another LED. This task is used to demonstrate the power of FreeRTOS to handle multitasking.
Parts Required and Wiring Diagram
The same as the previous example.
Code
Copy the following code to the Arduino IDE.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-freertos-semaphores-arduino/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#define BUTTON_PIN 23
#define LED1_PIN 2 // Blinking LED
#define LED2_PIN 4 // Fading LED
#define DEBOUNCE_DELAY 200 // debounce for the pushbutton in milliseconds
#define SEMAPHORE_MAX_COUNT 5
SemaphoreHandle_t buttonSemaphore = NULL;
volatile uint32_t lastInterruptTime = 0;
void IRAM_ATTR buttonISR() {
uint32_t currentTime = millis();
if (currentTime - lastInterruptTime > DEBOUNCE_DELAY) {
BaseType_t higherPriorityTaskWoken = pdFALSE;
if (xSemaphoreGiveFromISR(buttonSemaphore, &higherPriorityTaskWoken) == pdTRUE) {
Serial.println("buttonISR: Gave semaphore token");
} else {
Serial.println("buttonISR: Semaphore full");
}
lastInterruptTime = currentTime;
if (higherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
}
void LEDBlinkTask(void *parameter) {
pinMode(LED1_PIN, OUTPUT);
for (;;) {
// Get and print the current semaphore count
UBaseType_t count = uxSemaphoreGetCount(buttonSemaphore);
Serial.print("LEDBlinkTask: Current semaphore count = ");
Serial.println(count);
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
Serial.println("LEDBlinkTask: Blinking LED1 for button press");
vTaskDelay(500 / portTICK_PERIOD_MS);
digitalWrite(LED1_PIN, HIGH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
digitalWrite(LED1_PIN, LOW);
vTaskDelay(500 / portTICK_PERIOD_MS);
}
}
}
void LEDFadeTask(void *parameter) {
pinMode(LED2_PIN, OUTPUT);
for (;;) {
// Fade up (0 to 255)
for (int duty = 0; duty <= 255; duty += 5) {
analogWrite(LED2_PIN, duty);
if (duty % 50 == 0) { // Print every 10th step
Serial.print("LEDFadeTask: Fading LED2, duty=");
Serial.println(duty);
}
vTaskDelay(50 / portTICK_PERIOD_MS);
}
// Fade down (255 to 0)
for (int duty = 255; duty >= 0; duty -= 5) {
analogWrite(LED2_PIN, duty);
if (duty % 50 == 0 || duty == 255) {
Serial.print("LEDFadeTask: Fading LED2, duty=");
Serial.println(duty);
}
vTaskDelay(50 / portTICK_PERIOD_MS);
}
}
}
void setup() {
Serial.begin(115200); // Higher baud rate
delay(1000);
Serial.println("Starting FreeRTOS: Counting Semaphore");
pinMode(BUTTON_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), buttonISR, FALLING);
buttonSemaphore = xSemaphoreCreateCounting(SEMAPHORE_MAX_COUNT, 0);
if (buttonSemaphore == NULL) {
Serial.println("Failed to create semaphore!");
while (1);
}
xTaskCreatePinnedToCore(
LEDBlinkTask, // Task function
"LEDBlinkTask", // Task name
3000, // Stack size
NULL, // Task parameters
2, // Higher priority
NULL, // Task handle
1 // Core ID
);
xTaskCreatePinnedToCore(
LEDFadeTask, // Task function
"LEDFadeTask", // Task name
3000, // Stack size
NULL, // Task parameters
1, // Lower priority
NULL, // Task handle
1 // Core ID
);
}
void loop() {}
How Does the Code Work?
This code is quite similar to the previous one. We’ll just take a look at the important sections related to the counting semaphore.
Creating the Counting Semaphore
In the setup(), we create a counting semaphore with a maximum count of 5 (SEMAPHORE_MAX_COUNT) starting at 0. We do that using the xSemaphoreCreateCounting() function.
buttonSemaphore = xSemaphoreCreateCounting(SEMAPHORE_MAX_COUNT, 0);
if (buttonSemaphore == NULL) {
Serial.println("Failed to create semaphore!");
while (1);
}
Creating Tasks
Still in the setup(), we create our tasks and assign them to a core. The LEDBlinkTask has a higher priority than the LEDFadeTask.
xTaskCreatePinnedToCore(
LEDBlinkTask, // Task function
"LEDBlinkTask", // Task name
3000, // Stack size
NULL, // Task parameters
2, // Higher priority
NULL, // Task handle
1 // Core ID
);
xTaskCreatePinnedToCore(
LEDFadeTask, // Task function
"LEDFadeTask", // Task name
3000, // Stack size
NULL, // Task parameters
1, // Lower priority
NULL, // Task handle
1 // Core ID
);
Button ISR and the Counting Semaphore
When you press the pushbutton, the buttonISR() function will run. If we have a valid button press, we give it to the counting semaphore. The semaphore will take up to five counts. We use the same function we used in the previous example xSemaphoreGiveFromISR().
if (xSemaphoreGiveFromISR(buttonSemaphore, &higherPriorityTaskWoken) == pdTRUE) {
Serial.println("buttonISR: Gave semaphore token");
} else {
Serial.println("buttonISR: Semaphore full");
}
LEDBlinkTask and Taking the Semaphore
The LEDBlinkTask waits indefinitely until we have a count on the semaphore. When there’s a count on the semaphore, we take it and blink the LED.
if (xSemaphoreTake(buttonSemaphore, portMAX_DELAY)) {
Serial.println("LEDBlinkTask: Blinking LED1 for button press");
vTaskDelay(500 / portTICK_PERIOD_MS);
digitalWrite(LED1_PIN, HIGH);
vTaskDelay(1000 / portTICK_PERIOD_MS);
digitalWrite(LED1_PIN, LOW);
vTaskDelay(500 / portTICK_PERIOD_MS);
}
Since we have a counting semaphore, it will blink the LED as many times as the counts currently available on the semaphore.
Each time it takes from the semaphore, there’s a new space available to add a new count (via a pushbutton press).
Inside this task, we also print the current semaphore count by calling the uxSemaphoreGetCount() function and passing the semaphore handle as an argument.
// Get and print the current semaphore count
UBaseType_t count = uxSemaphoreGetCount(buttonSemaphore);
Serial.print("LEDBlinkTask: Current semaphore count = ");
Serial.println(count);
LEDFadeTask
Simultaneously, we have another independent task called LEDFadeTask that simply fades the other LED.
void LEDFadeTask(void *parameter) {
pinMode(LED2_PIN, OUTPUT);
for (;;) {
// Fade up (0 to 255)
for (int duty = 0; duty <= 255; duty += 5) {
analogWrite(LED2_PIN, duty);
if (duty % 50 == 0) { // Print every 10th step
Serial.print("LEDFadeTask: Fading LED2, duty=");
Serial.println(duty);
}
vTaskDelay(50 / portTICK_PERIOD_MS);
}
// Fade down (255 to 0)
for (int duty = 255; duty >= 0; duty -= 5) {
analogWrite(LED2_PIN, duty);
if (duty % 50 == 0 || duty == 255) {
Serial.print("LEDFadeTask: Fading LED2, duty=");
Serial.println(duty);
}
vTaskDelay(50 / portTICK_PERIOD_MS);
}
}
}
Demonstration
Upload the code to your board. After uploading, open the Serial Monitor at a baud rate of 115200. Press the ESP32 RST button so that it starts running the code.

The LED connected to GPIO 4 will be constantly fading.
Press the pushbutton several times to blink the LED connected to GPIO 2 as many times as many button presses on the semaphore queue.
You can see a little demonstration in the short video below.
In the Serial Monitor, you should get something similar. The semaphore counting will decrease as the LED blinks (if you don’t continue pressing the pushbutton).

Wrapping Up
In this guide, you learned about FreeRTOS binary and counting semaphores and how to implement them with the ESP32 programmed with Arduino IDE.
Semaphores allow us to synchronize tasks to signal when a resource is available, when an event occurred, or a point in a task where the other should run.
We’ve shown you two simple examples to demonstrate how semaphores work. This can be applied to much more complex applications with multiple tasks giving and taking from the semaphore.
We hope you’ve found this tutorial useful to start implementing FreeRTOS programming on your ESP32 sketches. We have more tutorials on this FreeRTOS series that you may like:
- ESP32 with FreeRTOS (Arduino IDE)—Getting Started Guide: Creating Tasks
- ESP32 with FreeRTOS Queues: Inter-Task Communication (Arduino IDE)
To learn more about the ESP32, make sure to check out our resources:
Thank you for this tutorial — I haven’t delved into it much yet, but a question: how is a semaphore different from using a global variable that tasks (i.e. functions) listen to? e.g. a button is pushed by one task (function) and that task sets the buttonPushed variable hi. Task 2 (i.e. function 2) is in a while loop reading that variable, and when it goes hi, it turns on an LED, then sets the variable false. Thanks for clarification :–)