Introducción a los SO en tiempo real: manejo de memoria

Algo que debemos tomar en cuenta cuando codificamos un programa para un RTOS es el manejo de memoria. Entender donde y cómo se alojan los datos nos permitirá evitar errores y asegurar el buen funcionamiento de nuestro programa.

Concepto

La memoria volátil (por ejemplo, la RAM) en la mayoria de los sistemas que usan microcontroladores se divide en 3 partes: static, heap y stack.

Definición de las distintas secciones de memoria

La memoria static es usada para guardar variables globales y variables que designemos específicamente como «static» en el código (estas variables persisten entre llamadas de funciones).

El stack es usado para alojamiento automático de variables locales. La memoria en el stack se organiza como un sistema LIFO (last-in-first-out) para que las variables de una función puedan ser empujadas al stack cuando se llama a una nueva función. Cuando se vuelve a la función inicial, las variables de esa función pueden sacarse, por lo que la función se puede reanudar en donde se detuvo.

La memoria heap debe alojarse explícitamente por el programador. Con mucha frecuencia en C se usará la función malloc() para reservar memoria heap para las variables, buffers, etc. Esto se conoce como «guardado dinámico». Nótese que en lenguajes de programación que no tienen sistema de recolección de basura (C o C++) debes liberar memoria heap cuando ya no la uses. Si no, esto causará desbordes en la memoria, causando efectos indefinidos, como corromper otras partes de la memoria.

Manejo de memoria en FreeRTOS

Cuando hacemos un programa en C los datos se pueden almacenar en un grupo Static, en el Stack o en el Heap. Los datos en Static permanecen a los largo de todo el programa, mientras que los de Stack y Heap se van modificando dinámicamente a medida que se necesitan.

En un RTOS nosotros creamos espacios de memoria para cada tarea en el Heap, cada tarea tendrá también un stack. Tenemos distintos bloques heap con los que podemos organizar la memoria.

Para comprobar como se manejan los espacios en la memoria podemos realizar este ejercicio en un programa de Arduino. En el vamos a hacer algunos ajustes para ver como se acaba la memoria y como podemos liberarla para que nuestro programa funcione sin problemas.

Empecemos con este programa, en el que creamos una función para nuestra tarea, donde creamos un arreglo de 100 entradas de números enteros. Después en la configuración creamos la tarea propiamente y le asignamos un espacio de memoria de 1024 bytes, que como veremos, no será suficiente.

// Use only core 1 for demo purposes
#if CONFIG_FREERTOS_UNICORE
  static const BaseType_t app_cpu = 0;
#else
  static const BaseType_t app_cpu = 1;
#endif

// Task: Perform some mundane task
void testTask(void *parameter) {
  while (1) {
    int a = 1;
    int b[100];

    // Do something with array so it's not optimized out by the compiler
    for (int i = 0; i < 100; i++) {
      b[i] = a + 1;
    }
    Serial.println(b[0]);
  }
}

void setup() {

  // Configure Serial
  Serial.begin(115200);

  // Wait a moment to start (so we don't miss Serial output)
  vTaskDelay(1000 / portTICK_PERIOD_MS);
  Serial.println();
  Serial.println("---FreeRTOS Memory Demo---");

  // Start the only other task
  xTaskCreatePinnedToCore(testTask,
                          "Test Task",
                          1024,
                          NULL,
                          1,
                          NULL,
                          app_cpu);
  
  // Delete "setup and loop" task
  vTaskDelete(NULL);
}

void loop() {
  // Execution should never get here
}

Al cargar el programa y al iniciar el monitor serial notamos que se despliegan los números del arreglo, pero llega un momento en que se rompe el programa y se debe reiniciar la tarjeta. Esto se debe a que no asignamos suficiente memoria para la tarea, ya que el arreglo desborda la memoria que asignamos al crearla. Podemos solucionar esto simplemente agregando más memoria en la configuración, en este caso 1500 bytes serán suficientes.

Una forma alternativa de guardar espacio en memoria es usar la funcion malloc(). Para demostrar cómo funciona debes agregar esta porción de código después de imprimir el contenido del arreglo. Lo que hace este código es mostrar cuanto espacio en memoria queda disponible para la tarea y cuanto podemos usar del heap para asignarle. Cuando nos falta memoria usamos malloc() para guardar una porción mpas de código y que siga funcionando nuestro programa.

// Print out remaining stack memory (words)
    Serial.print("High water mark (words): ");
    Serial.println(uxTaskGetStackHighWaterMark(NULL));

    // Print out number of free heap memory bytes before malloc
    Serial.print("Heap before malloc (bytes): ");
    Serial.println(xPortGetFreeHeapSize());
    int *ptr = (int*)pvPortMalloc(1024 * sizeof(int));

    // Do something with array so it's not optimized out by the compiler
    for (int i = 0; i < 1024; i++) {
     ptr[i] = 3;
    }

    // Print out number of free heap memory bytes after malloc
    Serial.print("Heap after malloc (bytes): "); 
    Serial.println(xPortGetFreeHeapSize());

    // Wait for a while
    vTaskDelay(100 / portTICK_PERIOD_MS);

Sin embargo, a pesar de que logramos apartar un poco de espacio en memoria de forma progresiva, el espacio que sobra va disminuyendo hasta que se rompe el programa, como podemos ver en el monitor serial.

Aquí la memoria heap disponible se va reduciendo hasta que el programa se rompe y debe reiniciar la ESP32

Para evitar esto, debemos liberar la memoria que estábamos apartando para que no se consuma la sección heap donde apartamos memoria para las tareas. Esto se puede hacer con vPortFree() de modo que siempre tengamos suficiente heap para almacenar más memoria. Vamos a añadir la función al final y entre el código un condicional que nos indique cuando quede poco espacio en heap para usar más memoria.

// Use only core 1 for demo purposes
#if CONFIG_FREERTOS_UNICORE
  static const BaseType_t app_cpu = 0;
#else
  static const BaseType_t app_cpu = 1;
#endif

// Task: Perform some mundane task
void testTask(void *parameter) {
  while (1) {
    int a = 1;
    int b[100];

    // Do something with array so it's not optimized out by the compiler
    for (int i = 0; i < 100; i++) {
      b[i] = a + 1;
    }
    Serial.println(b[0]);

    // Print out remaining stack memory (words)
    Serial.print("High water mark (words): ");
    Serial.println(uxTaskGetStackHighWaterMark(NULL));

    // Print out number of free heap memory bytes before malloc
    Serial.print("Heap before malloc (bytes): ");
    Serial.println(xPortGetFreeHeapSize());
    int *ptr = (int*)pvPortMalloc(1024 * sizeof(int));

    // One way to prevent heap overflow is to check the malloc output
    if (ptr == NULL) {
      Serial.println("Not enough heap.");
      vPortFree(NULL);
    } else {
      
      // Do something with the memory so it's not optimized out by the compiler
      for (int i = 0; i < 1024; i++) {
        ptr[i] = 3;
      }
    }

    // Print out number of free heap memory bytes after malloc
    Serial.print("Heap after malloc (bytes): "); 
    Serial.println(xPortGetFreeHeapSize());

    // Free up our allocated memory
    //vPortFree(ptr);

    // Wait for a while
    vTaskDelay(100 / portTICK_PERIOD_MS);
  }
}

void setup() {

  // Configure Serial
  Serial.begin(115200);

  // Wait a moment to start (so we don't miss Serial output)
  vTaskDelay(1000 / portTICK_PERIOD_MS);
  Serial.println();
  Serial.println("---FreeRTOS Memory Demo---");

  // Start the only other task
  xTaskCreatePinnedToCore(testTask,
                          "Test Task",
                          1500,
                          NULL,
                          1,
                          NULL,
                          app_cpu);
  
  // Delete "setup and loop" task
  vTaskDelete(NULL);
}

void loop() {
  // Execution should never get here
}

Conclusiones:

Aprendimos las distintas secciones en que se distribuye la memoria de programa y como se usan en FreeRTOS. Observamos la importancia de asignar suficiente memoria a cada tarea dependiendo de su uso y como podemos reservar dinámicamente más memoria para una tarea, usando malloc() y liberandola para su uso en otras tareas con vPortFree(). También observamos la necesidad de librerar dicha memoria reservada para evitar que el programa se rompa por un desborde de memoria.

Referencias:

Introduction to RTOS Part 4 – Memory Management | Digi-Key Electronics

FreeRTOS – Memory management

Memory in C – Stack, Heap, and Static

The C Build Process

What and where are the stack and heap?

FreeRTOS Memory Management