freertos-colas-queues-estructuras-punteros

Cómo enviar estructuras y punteros por colas de FreeRTOS

  • 6 min

Las colas con estructuras y punteros son una forma de enviar datos complejos entre tareas sin convertir el programa en una colección de variables globales compartidas.

En la entrada anterior aprendimos a pasar un simple int de una tarea a otra. Está muy bien para empezar, pero en el mundo real, los datos rara vez viajan solos.

Piensa en un sistema de telemetría. No nos basta con enviar “25.5”. Necesitamos enviar:

  1. El valor (25.5).
  2. El ID del sensor (para saber si es temperatura o humedad).
  3. El timestamp (hora de la lectura).
  4. Un código de estado.

Si enviáramos estos datos por 4 colas distintas, sincronizarlas sería una pesadilla. Lo lógico es agruparlos en un paquete.

Además, ¿qué pasa si queremos enviar una cadena de texto larga o una imagen capturada por una cámara? Si intentamos copiar esos datos byte a byte dentro de la cola, saturaremos la CPU y la RAM.

Hoy vamos a ver las dos técnicas profesionales para gestionar esto: el paso de Estructuras y el paso de Punteros.

Enviar estructuras (struct)

Como vimos, FreeRTOS copia los datos “por valor”. Esto significa que si definimos una cola para que almacene una estructura de C++, FreeRTOS copiará toda la estructura dentro de la cola.

Esta es la forma más segura y robusta de pasar grupos de datos pequeños o medianos.

Definir el paquete

Primero definimos nuestro tipo de dato personalizado.

typedef struct {
  uint8_t idSensor;
  float valor;
  unsigned long timestamp;
} LecturaSensor;
Copied!

Crear la cola para ese tipo

Al crear la cola, usamos sizeof() para decirle a FreeRTOS cuánto ocupa nuestra estructura.

QueueHandle_t colaSensores;

void setup() {
  // Creamos una cola con hueco para 10 estructuras
  colaSensores = xQueueCreate(10, sizeof(LecturaSensor));
}
Copied!

Enviar y recibir

El funcionamiento es idéntico al de los tipos básicos.

void TareaEmisor(void *p) {
  LecturaSensor lectura;
  
  for(;;) {
    // Rellenamos la estructura localmente
    lectura.idSensor = 1;
    lectura.valor = 23.5;
    lectura.timestamp = millis();
    
    // Enviamos. FreeRTOS hace una COPIA de 'lectura' dentro de la cola.
    // Podemos reutilizar la variable 'lectura' en la siguiente vuelta sin miedo.
    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);
    }
  }
}
Copied!

Esta técnica es ideal para estructuras pequeñas y triviales, sin punteros ni recursos internos. Como la cola guarda una copia byte a byte, el emisor puede modificar su variable original después de enviar sin afectar al receptor.

Enviar punteros

¿Pero qué pasa si queremos enviar un texto largo (ej. un JSON de 512 bytes) o un buffer de audio?

Si hacemos una cola de estructuras de 512 bytes:

  1. Reservamos mucha RAM para el almacenamiento de la cola (10 huecos de 512 bytes ya requieren más de 5 KB, además de la estructura de control).
  2. Perdemos tiempo de CPU haciendo memcpy de 512 bytes en cada envío y recepción.

La solución es no mover los datos, sino mover la dirección de los datos. Enviamos un Puntero.

En lugar de enviar la carta entera por la tubería, enviamos un papelito que dice “La carta está en el buzón 3”.

La cola de punteros

Aquí la cola no almacena el dato grande, sino que almacena direcciones de memoria (char*, struct Grande*, etc). Una dirección de memoria en ESP32 ocupa siempre 4 bytes, independientemente de lo grande que sea el dato al que apunta.

// La cola almacenará punteros a char (char*)
QueueHandle_t colaPunteros;
colaPunteros = xQueueCreate(10, sizeof(char*)); 
Copied!

El ciclo de vida del puntero

Al pasar punteros es donde aparecen muchos errores. La memoria es compartida, así que debemos tener muy claro quién es el dueño en cada momento para evitar fugas o corrupción.

El flujo correcto suele ser:

Emisor: Reserva memoria dinámica (malloc).

Emisor: Rellena los datos.

Emisor: Envía el puntero a la cola. (Cede la propiedad).

Receptor: Recibe el puntero.

Receptor: Lee/Procesa los datos.

Receptor: Libera la memoria (free). (Destruye el dato).

Ejemplo: Enviando mensajes de texto variables

QueueHandle_t colaMensajes;

void TareaGenerador(void *p) {
  for(;;) {
    // 1. Reservamos memoria en el HEAP para el mensaje
    // Imagina que este tamaño es variable o mucho mayor.
    char *mensaje = (char*) malloc(50 * sizeof(char));
    
    if(mensaje != NULL) {
      // 2. Escribimos en esa memoria reservada
      snprintf(mensaje, 50, "Mensaje generado en %lu ms", millis());
      
      // 3. Enviamos EL PUNTERO (la dirección) a la cola
      // Ojo a la sintaxis: &mensaje (pasamos la dirección del puntero)
      if(xQueueSend(colaMensajes, &mensaje, pdMS_TO_TICKS(100)) != pdTRUE) {
         // Si la cola está llena y no pudimos enviar, 
         // debemos liberar nosotros la memoria para no perderla (Leak)
         free(mensaje); 
      }
    }
    
    vTaskDelay(pdMS_TO_TICKS(2000));
  }
}

void TareaImpresora(void *p) {
  char *punteroRecibido;
  
  for(;;) {
    // 4. Recibimos el puntero
    if(xQueueReceive(colaMensajes, &punteroRecibido, portMAX_DELAY)) {
      
      // 5. Usamos los datos (accedemos a la memoria a la que apunta)
      Serial.println(punteroRecibido);
      
      // 6. ¡CRÍTICO! Liberar la memoria
      // Si no hacemos esto, nos quedaremos sin RAM en minutos.
      free(punteroRecibido); 
    }
  }
}

void setup() {
    Serial.begin(115200);
    // Creamos cola para guardar punteros (char*)
    colaMensajes = xQueueCreate(5, sizeof(char*));
    
    xTaskCreate(TareaGenerador, "Gen", 2048, NULL, 1, NULL);
    xTaskCreate(TareaImpresora, "Imp", 2048, NULL, 1, NULL);
}

void loop() {}
Copied!

Error común: Punteros a variables locales

Nunca hagas esto:

void TareaMala() {
    char bufferLocal[50]; // Memoria en el STACK de la tarea
    sprintf(bufferLocal, "Hola");
    
    char *p = bufferLocal;
    xQueueSend(cola, &p, 0); 
} // <--- Aquí 'bufferLocal' deja de existir
Copied!

Cuando el receptor reciba el puntero, apuntará a una zona de memoria que ya ha sido liberada o sobrescrita por otra función, porque era memoria local del Stack. Resultado: Basura o Crash.

Regla: Si envías punteros, documenta quién posee el buffer antes y después de cada operación. Puedes usar memoria dinámica o un pool de buffers estáticos, pero necesitas un protocolo que impida reutilizarlos mientras el receptor los procesa.

¿Copia o puntero?

CaracterísticaCopia (struct)Puntero (malloc)
ComplejidadBaja (Fácil de usar)Media/Alta (Gestión manual)
Uso de RAMLa cola contiene cada dato completoLa cola contiene el puntero; el buffer existe aparte
VelocidadLa copia cuesta más cuanto mayor sea el datoCopia pequeña, pero hay que gestionar el buffer
SeguridadAlta para tipos trivialesRiesgo de fugas y uso después de liberar
Uso idealTipos triviales pequeñosBuffers grandes con propiedad bien definida