Task Scheduling and Semaphores in Embedded Systems

Jan 15, 2026 min

I’ve been working with FreeRTOS on STM32 for my haptic glove thesis, and the whole task scheduling thing makes way more sense now that I’ve actually implemented it. Here’s what I’ve learned.

How task scheduling actually works

The basic problem: you have multiple tasks that all need CPU time. Something has to decide which one runs. FreeRTOS uses preemptive scheduling, which means higher priority tasks can interrupt lower priority ones at any time.

Here’s a simple example with three tasks at different priorities:

// Create tasks with different priorities
xTaskCreate(read_sensors,  "Sensors",  128, NULL, 3, NULL);  // High priority
xTaskCreate(update_display, "Display", 128, NULL, 2, NULL);  // Medium
xTaskCreate(log_data,       "Logger",  128, NULL, 1, NULL);  // Low

When the sensor task becomes ready (maybe an interrupt fired), it immediately preempts whatever else was running. No waiting. This is important for time-critical stuff like reading encoder positions or handling motor control.

Priority inversion and why it matters

This one took me a while to understand. Here’s the scenario that can happen:

Task H (high priority) needs a resource. Task L (low priority) currently holds that resource. Task M (medium priority) is doing CPU-intensive work.

What happens? Task H blocks waiting for Task L to release the resource. But Task M keeps preempting Task L, so Task L never gets to finish and release the resource. Result: the highest priority task is stuck waiting while a medium priority task runs. That’s priority inversion.

FreeRTOS mutexes solve this with priority inheritance. When Task H blocks on a mutex held by Task L, the scheduler temporarily boosts Task L’s priority to match Task H. Now Task L can preempt Task M, finish quickly, and release the resource.

// Mutex automatically handles priority inheritance
SemaphoreHandle_t resource_mutex = xSemaphoreCreateMutex();

void low_priority_task(void) {
    xSemaphoreTake(resource_mutex, portMAX_DELAY);
    // If high-priority task needs this, our priority gets boosted
    access_shared_resource();
    xSemaphoreGive(resource_mutex);
}

Semaphores for synchronization

Semaphores are basically tokens. You can take them and give them. I use three types in my project:

Binary semaphores for signaling. Perfect for ISR-to-task communication:

SemaphoreHandle_t data_ready_sem;

// In sensor interrupt
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) {
    BaseType_t higher_priority_woken = pdFALSE;
    xSemaphoreGiveFromISR(data_ready_sem, &higher_priority_woken);
    portYIELD_FROM_ISR(higher_priority_woken);
}

// In processing task
void sensor_task(void *params) {
    while(1) {
        xSemaphoreTake(data_ready_sem, portMAX_DELAY);
        uint16_t value = read_adc_result();
        process_sensor_value(value);
    }
}

The interrupt fires, gives the semaphore, the task wakes up. Clean handoff.

Counting semaphores for managing resource pools:

// UART has a 4-message transmit buffer
SemaphoreHandle_t tx_buffer_sem = xSemaphoreCreateCounting(4, 4);

void send_message(uint8_t *data, size_t len) {
    xSemaphoreTake(tx_buffer_sem, portMAX_DELAY);
    uart_queue_message(data, len);
}

// In UART TX complete interrupt
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
    BaseType_t higher_priority_woken = pdFALSE;
    xSemaphoreGiveFromISR(tx_buffer_sem, &higher_priority_woken);
    portYIELD_FROM_ISR(higher_priority_woken);
}

Four messages can queue before the sender blocks. As messages transmit, tokens come back.

Mutexes for protecting shared hardware:

SemaphoreHandle_t i2c_mutex;

void read_temp_sensor(float *temperature) {
    xSemaphoreTake(i2c_mutex, portMAX_DELAY);
    HAL_I2C_Mem_Read(&hi2c1, TEMP_ADDR, TEMP_REG, 1, data, 2, 100);
    *temperature = convert_to_celsius(data);
    xSemaphoreGive(i2c_mutex);
}

void read_humidity_sensor(float *humidity) {
    xSemaphoreTake(i2c_mutex, portMAX_DELAY);
    HAL_I2C_Mem_Read(&hi2c1, HUM_ADDR, HUM_REG, 1, data, 2, 100);
    *humidity = convert_to_percent(data);
    xSemaphoreGive(i2c_mutex);
}

Two tasks sharing the I2C bus. The mutex ensures they never transmit simultaneously.

Real example from my motor controller

For my haptic glove, I have multiple tasks running:

// Resources
SemaphoreHandle_t spi_mutex;
SemaphoreHandle_t encoder_data_ready;
QueueHandle_t motor_command_queue;

// High priority: Read encoder when data ready
void encoder_task(void *params) {
    int32_t position;
    while(1) {
        xSemaphoreTake(encoder_data_ready, portMAX_DELAY);
        
        xSemaphoreTake(spi_mutex, portMAX_DELAY);
        position = read_encoder_spi();
        xSemaphoreGive(spi_mutex);
        
        update_position_controller(position);
    }
}

// Medium priority: Process commands
void command_task(void *params) {
    MotorCommand cmd;
    while(1) {
        if(xQueueReceive(motor_command_queue, &cmd, portMAX_DELAY)) {
            set_motor_setpoint(cmd.target_position, cmd.velocity);
        }
    }
}

// Low priority: Send status to display
void display_task(void *params) {
    while(1) {
        vTaskDelay(pdMS_TO_TICKS(100));
        xSemaphoreTake(spi_mutex, portMAX_DELAY);
        update_display(get_current_position(), get_motor_current());
        xSemaphoreGive(spi_mutex);
    }
}

The encoder task runs at high priority because position control is time-critical. Display is low priority since it’s not urgent. Both share SPI via mutex so the display never corrupts encoder reads.

Things that caught me out

Never call blocking functions in ISRs. Use the FromISR() variants only. I learned this the hard way when my system froze because I called a regular xSemaphoreTake in an interrupt handler.

Deadlocks happen if you’re not careful with lock ordering. If Task A holds Mutex 1 and waits for Mutex 2, while Task B holds Mutex 2 and waits for Mutex 1, both freeze. I always acquire mutexes in the same order across all tasks now.

Low priority tasks can starve if high priority tasks run constantly. I had this issue early on where my logging task never executed because the control loop kept preempting it. Fixed it by adding rate-limiting delays in the high-priority tasks.

What actually matters

The key insight for me was matching the synchronization primitive to the problem. ISR to task signaling? Binary semaphore. Shared hardware? Mutex. Limited buffer space? Counting semaphore.

Once you understand the scheduler always picks the highest-priority ready task, and that semaphores/mutexes control when tasks are ready vs blocked, the whole system makes sense. Building responsive embedded systems becomes straightforward after that.

~Ajit George