The queues with structures and pointers are a way to send complex data between tasks without turning the program into a collection of shared global variables.
In the previous post we learned how to pass a simple int from one task to another. That’s fine for a start, but in the real world, data rarely travels alone.
Think about a telemetry system. It’s not enough to send “25.5”. We need to send:
- The value (25.5).
- The sensor ID (to know if it’s temperature or humidity).
- The timestamp (time of the reading).
- A status code.
If we were to send this data through 4 different queues, synchronizing them would be a nightmare. The logical thing to do is group them into a single packet.
Furthermore, what if we want to send a long text string or an image captured by a camera? If we try to copy that data byte by byte into the queue, we will saturate the CPU and RAM.
Today we are going to look at the two professional techniques for managing this: passing Structures and passing Pointers.
Sending structures (struct)
As we saw, FreeRTOS copies data “by value”. This means that if we define a queue to store a C++ struct, FreeRTOS will copy the entire structure into the queue.
This is the safest and most robust way to pass small or medium-sized groups of data.
Define the packet
First, we define our custom data type.
typedef struct {
uint8_t idSensor;
float valor;
unsigned long timestamp;
} LecturaSensor;Create the queue for that type
When creating the queue, we use sizeof() to tell FreeRTOS how much space our structure occupies.
QueueHandle_t colaSensores;
void setup() {
// Create a queue with space for 10 structures
colaSensores = xQueueCreate(10, sizeof(LecturaSensor));
}Send and receive
The operation is identical to that of basic types.
void TareaEmisor(void *p) {
LecturaSensor lectura;
for(;;) {
// Fill the structure locally
lectura.idSensor = 1;
lectura.valor = 23.5;
lectura.timestamp = millis();
// Send it. FreeRTOS makes a COPY of 'lectura' inside the queue.
// We can reuse the 'lectura' variable on the next iteration without worry.
xQueueSend(colaSensores, &lectura, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
void TareaReceptor(void *p) {
LecturaSensor datoRecibido;
for(;;) {
if(xQueueReceive(colaSensores, &datoRecibido, portMAX_DELAY)) {
Serial.printf("Sensor: %d, Valor: %.2f, Tiempo: %lu\n",
datoRecibido.idSensor, datoRecibido.valor, datoRecibido.timestamp);
}
}
}This technique is ideal for small, trivial structures, without pointers or internal resources. Since the queue stores a byte-byte copy, the sender can modify its original variable after sending without affecting the receiver.
Sending pointers
But what if we want to send a long text (e.g., a 512-byte JSON) or an audio buffer?
If we make a queue of 512-byte structures:
- We reserve a lot of RAM for the queue storage (10 slots of 512 bytes already require more than 5 KB, plus the control structure).
- We waste CPU time doing
memcpyof 512 bytes on each send and receive.
The solution is not to move the data, but to move the data’s address. We send a Pointer.
Instead of sending the whole letter through the pipe, we send a slip of paper saying “The letter is in mailbox 3”.
The pointer queue
Here, the queue does not store the large data, but stores memory addresses (char*, struct Grande*, etc.). A memory address on an ESP32 always occupies 4 bytes, regardless of how large the data it points to is.
// The queue will store pointers to char (char*)
QueueHandle_t colaPunteros;
colaPunteros = xQueueCreate(10, sizeof(char*)); The lifecycle of the pointer
Passing pointers is where many errors appear. Memory is shared, so we must be very clear about who the owner is at each moment to avoid leaks or corruption.
The correct flow is usually:
Sender: Reserve dynamic memory (malloc).
Sender: Fill the data.
Sender: Send the pointer to the queue. (Transfer ownership).
Receiver: Receive the pointer.
Receiver: Read/Process the data.
Receiver: Free the memory (free). (Destroy the data).
Example: Sending variable-length text messages
QueueHandle_t colaMensajes;
void TareaGenerador(void *p) {
for(;;) {
// 1. Reserve memory on the HEAP for the message
// Imagine this size is variable or much larger.
char *mensaje = (char*) malloc(50 * sizeof(char));
if(mensaje != NULL) {
// 2. Write to that reserved memory
snprintf(mensaje, 50, "Mensaje generado en %lu ms", millis());
// 3. Send THE POINTER (the address) to the queue
// Note the syntax: &mensaje (we pass the address of the pointer)
if(xQueueSend(colaMensajes, &mensaje, pdMS_TO_TICKS(100)) != pdTRUE) {
// If the queue is full and we couldn't send,
// we must free the memory ourselves to avoid losing it (Leak)
free(mensaje);
}
}
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
void TareaImpresora(void *p) {
char *punteroRecibido;
for(;;) {
// 4. Receive the pointer
if(xQueueReceive(colaMensajes, &punteroRecibido, portMAX_DELAY)) {
// 5. Use the data (access the memory it points to)
Serial.println(punteroRecibido);
// 6. CRITICAL! Free the memory
// If we don't do this, we will run out of RAM in minutes.
free(punteroRecibido);
}
}
}
void setup() {
Serial.begin(115200);
// Create queue to store pointers (char*)
colaMensajes = xQueueCreate(5, sizeof(char*));
xTaskCreate(TareaGenerador, "Gen", 2048, NULL, 1, NULL);
xTaskCreate(TareaImpresora, "Imp", 2048, NULL, 1, NULL);
}
void loop() {}Common mistake: Pointers to local variables
Never do this:
void TareaMala() {
char bufferLocal[50]; // Memory on the task's STACK
sprintf(bufferLocal, "Hola");
char *p = bufferLocal;
xQueueSend(cola, &p, 0);
} // <--- Here 'bufferLocal' ceases to existWhen the receiver gets the pointer, it will point to a memory area that has already been freed or overwritten by another function, because it was local memory from the Stack. Result: Garbage or Crash.
Rule: If you send pointers, document who owns the buffer before and after each operation. You can use dynamic memory or a pool of static buffers, but you need a protocol that prevents them from being reused while the receiver processes them.
Copy or pointer?
| Feature | Copy (struct) | Pointer (malloc) |
|---|---|---|
| Complexity | Low (Easy to use) | Medium/High (Manual management) |
| RAM Usage | Queue contains each complete data item | Queue contains the pointer; the buffer exists separately |
| Speed | Copy cost increases with data size | Small copy, but buffer management overhead |
| Safety | High for trivial types | Risk of leaks and use-after-free |
| Ideal Use | Small trivial types | Large buffers with well-defined ownership |