Feb 9 / Wadix Technologies

FreeRTOS Queue Sets Explained. Waiting on multiple event sources in one task

FreeRTOS Queue Sets Explained. Waiting on multiple event sources in one task

1.Introduction

In real embedded products, tasks rarely wait for just one thing. A communication task might need to react to incoming data from a driver and also to control commands from the application. A service task might wait for work requests from several producers. A control task might need to respond to a sensor update or to a shutdown request. These situations are not edge cases. They are normal in any system that connects multiple subsystems and timing domains.

FreeRTOS gives you solid primitives like queues and semaphores, but each of them represents a single blocking point. A task can sleep waiting for one queue to receive data or one semaphore to be given. As soon as a task needs to wait for more than one independent source of work, the design becomes less straightforward. Developers often fall back to polling, short timeouts, or layered dispatcher tasks. These approaches work, but they tend to increase latency, waste CPU time, or make the control flow harder to understand and maintain.

This is the gap that Queue Sets are meant to fill. They provide a way for a task to block efficiently until any one of several queues or semaphores becomes ready, without turning the task into a polling loop and without introducing extra scheduling layers. To use them well, it helps to understand what problem they actually solve, how they fit into the FreeRTOS model, and where their limits are in real system designs.


2. What a Queue Set is and why it exists:

In FreeRTOS, a task normally blocks on a single queue or a single semaphore. This works well as long as there is only one reason for the task to wake up. In real systems, this assumption often breaks down. A task may need to process incoming data but also react to control commands. It may need to wait for a hardware completion signal but also for a shutdown request. When there is more than one legitimate wakeup condition, the simple one object, one blocking point model starts to show its limits.

A common workaround is to use short timeouts and check multiple objects in a loop. The task wakes up, probes one queue, then another, and then goes back to sleep. This keeps the system responsive, but it turns a clean event driven design into a timing dependent polling loop. Another approach is to introduce an extra dispatcher task that waits on one source and forwards events to a common queue. This can restore a single blocking point, but it also adds complexity, extra context switches, and another layer of logic to reason about.

Queue Sets exist to solve this coordination problem directly. A Queue Set is a kernel object that represents a group of queues and semaphores. Instead of blocking on one specific object, a task can block on the set. The kernel then wakes the task when any member of the set becomes ready. From the task point of view, there is again a single, clean blocking point, but that point now represents several independent sources of work.

You can think of a Queue Set as playing a role similar to the select call in Linux or POSIX systems. Instead of blocking on a single file descriptor, select lets a process sleep until any one of several descriptors becomes ready. A Queue Set applies the same idea to FreeRTOS queues and semaphores. The task blocks once, the kernel monitors several objects, and the task wakes up when one of them can be serviced.

It is important to understand what a Queue Set is not. It does not store messages and it does not merge data streams. It is only a waiting mechanism. When the task wakes up, it is told which queue or semaphore caused the wakeup, and it must then perform a normal receive or take operation on that object. The actual data and signaling still live in the original queues and semaphores. The Queue Set only changes how the task waits.Fig 1. A Queue Set lets a task block on several queues


3. How this works inside FreeRTOS

A Queue Set is a small kernel object that links several existing queues or semaphores together for the purpose of blocking. Each member object knows whether it belongs to a set, and the set keeps track of its members. When a task blocks on a Queue Set, it is placed on a waiting list associated with the set, not with any individual queue or semaphore.

When data is sent to a queue or a semaphore is given, the kernel checks whether that object is part of a Queue Set. If it is, the kernel wakes the highest priority task waiting on the set and records which object became ready. The set itself does not move any data. It only signals that one of its members can now be serviced.

When the task runs again, it receives the handle of the queue or semaphore that caused the wakeup and then performs a normal receive or take operation on that object. This keeps the behavior of queues and semaphores unchanged. The Queue Set only changes how the task waits, not how data or signals are delivered.            Fig2. Queue Set wakeup flow


4. Practical example. One task handling data and control:

Consider a small device that streams sensor samples over UART to a host, but is controlled over I2C by another MCU in the system. The host side UART traffic is continuous and bursty, while the I2C side sends occasional commands such as start streaming, stop streaming, or change the reporting rate. The communication task is responsible for both. It must drain the UART RX queue fast enough to avoid overflow, and it must react quickly to I2C commands so the controller can change behavior immediately.

If this task blocks only on the UART RX queue, an I2C stop command can be delayed until more UART data arrives. If it blocks only on the I2C command queue, UART data can accumulate and eventually overflow. Using short timeouts to alternate between the two works, but it turns responsiveness into a tuning problem. The timeout value becomes part of the system behavior, and the task wakes up periodically even when nothing is happening.

A Queue Set makes the intent explicit. The task blocks once and wakes when either the UART RX queue has data or the I2C command queue has a message. When it wakes, it does not receive anything from the set itself. The kernel returns the handle of the object that caused the wakeup, and the task then performs a normal receive from that queue. This two step flow is what Queue Sets add.

#define UART_RX_QUEUE_LEN 64U

#define I2C_CMD_QUEUE_LEN 8U

#define QUEUE_SET_LEN (UART_RX_QUEUE_LEN + I2C_CMD_QUEUE_LEN)



typedef enum

{

  CMD_START,

  CMD_STOP,

  CMD_SET_RATE

} cmd_t;



/* Produced by UART driver or ISR-to-task path */

static QueueHandle_t qUartRx;



/* Produced by an I2C command parser task (fed by an I2C ISR/driver) */

static QueueHandle_t qI2cCmd;



static QueueSetHandle_t qset;

void CommsTask(void *arg)

{

  (void)arg;

  for (;;)

  {

    /* Block here until either UART RX or I2C command becomes ready */

    QueueSetMemberHandle_t ready =

    xQueueSelectFromSet(qset, portMAX_DELAY);

    if (ready == qI2cCmd)

    {

      cmd_t cmd;

      if (xQueueReceive(qI2cCmd, &cmd, 0) == pdPASS)

      {

        HandleCommandFromI2C(cmd);

       }

    }

    else if (ready == qUartRx)

   {

      uint8_t b;

      if (xQueueReceive(qUartRx, &b, 0) == pdPASS)

      {

         ProcessUartByte(b);

       }

    }

  }

}

void AppInit(void)

{

  qUartRx = xQueueCreate(UART_RX_QUEUE_LEN, sizeof(uint8_t));

  qI2cCmd = xQueueCreate(I2C_CMD_QUEUE_LEN, sizeof(cmd_t));


  qset = xQueueCreateSet(QUEUE_SET_LEN);

  xQueueAddToSet(qUartRx, qset);

  xQueueAddToSet(qI2cCmd, qset);


  xTaskCreate(CommsTask, "comms", 512, NULL, 2, NULL);

  }

The set length is not the number of member objects. It is the maximum number of items that could be waiting across all member queues at the same time. Here, the UART RX queue can hold 64 bytes and the I2C command queue can hold 8 commands, so the set is created with 72. If the set is undersized, the kernel may fail to signal the set correctly when member queues fill, and the waiting task can remain blocked even though data exists.

This pattern is useful because it keeps one clear blocking point for a task that owns multiple input channels. The communication task sleeps until either UART data arrives or an I2C command needs attention, then services exactly the source that woke it. No polling, no timeout tuning, and no extra dispatcher task.

Fig3. UART RX bytes and I2C control commands feed two separate queues, and the CommsTask blocks on a Queue Set to react immediately to whichever source becomes ready.


5. Conclusion

Queue Sets exist to solve a narrow but very common coordination problem in FreeRTOS based systems. They let a task block on several queues or semaphores at once and wake up when any one of them becomes ready. This keeps the task structure event driven and avoids the need for polling loops, chained timeouts, or extra dispatcher tasks that only move events around.

They work best when one task is the clear owner of several input channels and needs a single, clean blocking point that represents all of them. In that role, a Queue Set makes the code easier to read and the timing behavior easier to reason about. The task sleeps until something relevant happens, then services exactly the source that caused the wakeup.

Created with