Communication interfaces (I2C, SPI, UART, etc.) are some of the most commonly used microcontroller peripherals in embedded systems. In this introductory article, we will look at how low-level drivers for such peripherals can be used in FreeRTOS.

Polled Drivers vs Interrupt-driven Drivers

Based on the method used for getting information on events (e.g errors, operation completion, etc.) from the peripheral unit, low-level drivers can be placed into two categories: polled drivers and interrupt-driven drivers. Both types can perform the same job, the difference is in their performance.

Polled Drivers

Polled drivers are the simplest way to implement a driver. This comes at the cost of reduced efficiency which could negatively impact the power consumption and the real-time responsiveness of the system. Below is a simplified code showing a polled approach to a communication driver:

#define I2C_TR_ACTIVE_Msk (0x0080)
void i2c_read_polled(uint8_t slv_addr, uint8_t * rd_buff, uint8_t len) {
   /* Configure I2C peripheral for I2C read transfer */
   /* Start transfer */  
   /* Wait for the transfer to complete.
    - I2C_STATUS_REG is the I2C status register.
    - I2C_TR_ACTIVE status bit is set to 0 when transfer completed
   while (I2C_STATUS_REG & I2C_TR_ACTIVE_Msk);
   /* Get the received data from I2C HW fifo buffer*/
   for (int i=0; i<len;i++) {
     rd_buff[i] = I2C_DATA_REG;

In a polled driver, a loop is used for implementing a wait for the occurrence of an event (e.g completion of a transfer, error detection, etc.). The event occurrence is usually indicated by the setting of a flag(bit) in a status register of the peripheral. We should mention that the polled-based approach is not necessarily a bad design practice, there may be scenarios where using it may fit the requirements of the system. This, however, is rarely the case for real-time embedded systems. Polling a status flag as shown in the example code above is wasting CPU cycles and in the context of an RTOS can prevent other lower-priority tasks from executing.

Interrupt-driven Drivers

In an interrupt-driven driver, instead of waiting for an event flag (register bit) to change its value, the hardware peripheral is configured to generate interrupts when specific events occur(e.g completion of a transfer, error detection, etc.). This approach does not waste cycles and allows the CPU to do some other work or go into sleep mode and return to servicing the driver when the configured interrupt is generated. Below is a simplified code showing an interrupt-driven approach to a communication driver:

void i2c_read(uint8_t slv_addr, uint8_t * rd_buff, uint8_t len) {
    /* Configure I2C peripheral for I2C read transfer */
    /* Enable I2C interrupts */
    /* Start transfer */ 

/* Interrupt service routine for handling an event */
void i2c_transfer_completed_isr() {
    /* Get the received data from the I2C register and store it in a buffer */
    /* Do some operations here if needed (e.g give a semaphore), and exit the isr*/

Example FreeRTOS Driver Usage

In the following example, we will look at how an interrupt-driven I2C driver can perform a read transfer within FreeRTOS. The only thing needed for the basic integration of the driver is a semaphore.

Figure 1. Basic interaction between I2C driver and FreeRTOS

The flow of the example code is the following:

  • We start with the initialization of the I2C driver. It is placed in the freertos_i2c_rd_task() task that will be used to initiate the transfers. The initialization code should be called before entering the infinite loop of the task.
  • The I2C driver function for performing the transfer is i2c_read_blocking(). It starts the read transfer and then calls xSemaphoreTake() function. This will cause the current RTOS task (freertos_i2c_rd_task()) to transition from a running state to a blocked state if the semaphore is not available to take. It is important to remember that blocking functions must be called from an RTOS task.
  • When freertos_i2c_rd_task() enters a blocked state, another task that is in a ready state will be run by the RTOS scheduler.
  • The semaphore that freertos_i2c_rd_task() is waiting on, will be given from the interrupt service routine (i2c_transfer_completed_isr()) when the I2C read transfer is completed.
  • When the semaphore is given from the ISR there are usually two scenarios:
    • if a higher priority task is currently running then the blocked task (freertos_i2c_rd_task()) must wait for its completion
    • if the blocked task has higher priority it will be unblocked and put into running state.
  • Once freertos_i2c_rd_task() starts running again, it will continue right after the code that blocks on the semaphore (see i2c_read_blocking()) by collecting the received I2C bytes in a memory buffer.
  • The I2C read transfer is now completed and we put the task into a blocked state for 1 second using vTaskDelay(). Once this time passes the I2C read transfer will be executed again.
SemaphoreHandle_t i2c_sem;
void freertos_i2c_rd_task( void * pvParameters );
void i2c_read_blocking(uint8_t slv_addr, uint8_t * rd_buff, uint8_t len);

int main() {

    TaskHandle_t i2d_rd_handle;
    xTaskCreate(freertos_i2c_rd_task,   // pvTaskCode
                "i2d_rd",               // pcName
                THREADSTACKSIZE,        // usStackDepth
                NULL,                   // pvParameters
                2,                      // uxPriority
                &i2d_rd_handle);        // pxCreatedTask

    /* Start the FreeRTOS scheduler */
    return (0);

/* Task that reads 2 bytes from an I2C slave device every second */
freertos_i2c_rd_task( void * pvParameters ) {
    uint8_t data_buff[10];

    /* Create binary semaphore */
    i2c_sem = xSemaphoreCreateBinary();
    /* Here an initialization of the I2C driver can be perfomed */

    while (1) {
        i2c_read_blocking(I2C_SLV_ADDR, &data_buff, 2);
        /* Wait for 1s and repeat the I2C read transfer */
        vTaskDelay( 1000/portTICK_PERIOD_MS );

/* Blocking I2C read function*/
void i2c_read_blocking(uint8_t slv_addr, uint8_t * rd_buff, uint8_t len) {

    /* Configure I2C peripheral for I2C read transfer */
    /* Enable I2C interrupts */
    /* Start transfer */ 

    /* Block (wait) on semaphore */
    xSemaphoreTake(i2c_sem, portMAX_DELAY);
   /* Get the received data from I2C HW fifo buffer*/
    for (int i=0; i<len;i++) {
      rd_buff[i] = I2C_DATA_REG;

/* Interrupt service routine for I2C peripheral */
void i2c_transfer_completed_isr() {
    static signed BaseType_t xHigherPriorityTaskWoken;
    xHigherPriorityTaskWoken = pdFALSE

    /* The transfer is completed so give the semaphore */
    xSemaphoreGiveFromISR(i2c_sem, &xHigherPriorityTaskWoken );
    /* Context switch if higher priority task is blocking on the semaphore */

MCU vendors usually provide low-level drivers for all hardware peripheral units. These drivers are almost exclusively interrupt-driven. They usually follow one of the following paths:

  • Interrupt-driven drivers that provide a mechanism for registering callback functions. In this case, the user has to add the semaphore synchronization mechanism (as shown in our example code above) needed for the RTOS.
  • Interrupt-driven drivers that have callback and blocking API functions. The blocking API functions integrate semaphore taking and giving using an abstraction layer independent of any RTOS distribution. In this case, the user only needs to map the actual functions from the choosen RTOS to this abstraction layer.


What type of peripheral driver should be used in an RTOS-based embedded system? In most cases, the answer is an interrupt-driven driver as it is better suited for real-time requirements. A polled driver can also find an application although limited. For example, if we have a communication driver and we want to perform a transfer that is very short (e.g 100us) and will not have a negative impact on the real-time responsiveness of the system, we can keep things simple and do polling.

At the most basic level, integrating MCU peripheral functionality with an RTOS requires the use of a semaphore and an interrupt-driven driver. More advanced driver integration may use some additional RTOS mechanism for transferring data such as buffers, queues, etc. A good approach is to also involve a DMA peripheral for managing data transfers within the memory.

Was this article helpful?