← Índice del curso

Sistemas Embebidos · Tema 32 · Bloque 6

Optimizar el uso de memoria

Cerramos el bloque de memoria con el costado práctico: cuando el programa no entra en el chip, o entra pero sin margen, ¿qué se hace? La respuesta no es «activar la optimización» ni reescribir todo a mano. Es un método: medir qué ocupa cada byte, atacar primero lo que más pesa y verificar que cada cambio redujo lo que se quería reducir. En este tema vemos las palancas que existen (el compilador, el enlazador, las bibliotecas, los tipos de datos, las tablas y los textos) y cuánto cuesta cada una.

01 · El primer paso

Primero medir: qué recurso falta

«Optimizar» no es una sola cosa. Hay tres recursos que pueden faltar, y cada uno se arregla con herramientas distintas. Antes de tocar nada hay que saber cuál es, porque una mejora de uno suele empeorar otro:

Qué recurso falta y dónde están las palancas.
FaltaSíntomaPalancas principalesEfecto colateral posible
FlashEl enlace falla con region 'FLASH' overflowed, o no queda lugar para una función nueva o para el bootloaderCompilar con -Os, eliminar código muerto, recortar bibliotecas, tablas más chicas, textos compactosCódigo algo más lento
RAMEl enlace falla con region 'RAM' overflowed, o la pila y el heap no tienen margen (tema 29)Reordenar estructuras, buffers dimensionados con cuentas, constantes a flash, pilas medidasMenos buffers: más riesgo de pérdida de datos si se dimensiona mal
TiempoUn plazo de tiempo real no se cumple (tema 13)Optimización por velocidad, tablas precalculadas, funciones en RAM, hardware (DMA, temporizadores)Más flash y más RAM

Observá que los intercambios son cruzados: una tabla precalculada gana tiempo y cuesta flash; un buffer más chico ahorra RAM y arriesga datos; una optimización por tamaño ahorra flash y cuesta ciclos. No existe «el programa más chico y más rápido»: existe el que cumple los requisitos con el recurso más caro de ese producto. Por eso el primer trabajo es ponerle números al problema: cuánta flash y cuánta RAM faltan, o cuántos microsegundos sobran.

Y hay una regla que evita meses de trabajo inútil: no optimizar sin medir. Los programadores acertamos poco sobre qué ocupa memoria. Una biblioteca de formato de texto puede ocupar más que todo el código propio; una tabla olvidada, más que diez funciones. La intuición manda a optimizar lo que se ve; las herramientas muestran lo que pesa.

02 · Mirar con lupa

Dónde se va la flash

El tema 29 presentó las herramientas (size, nm, el .map). Acá las usamos con un objetivo: encontrar los consumidores. El resumen más directo lo da el propio enlazador, con una opción que imprime cuánto se usó de cada región:

$ arm-none-eabi-gcc ... -Wl,--print-memory-usage -o firmware.elf
Memory region         Used Size  Region Size  %age Used
           FLASH:        3852 B        64 KB      5.88%
             RAM:         368 B        20 KB      1.80%

Es la primera línea que conviene ver en cada compilación, y se puede exigir en la integración automática: un cambio que sube la flash por encima de un umbral falla el proceso. Para saber qué ocupa el espacio, se ordenan los símbolos por tamaño:

$ arm-none-eabi-nm --size-sort --reverse-sort --print-size firmware.elf | head -8
08002a10 00000300 r tabla_seno          # 'r': constantes en flash (.rodata)
08000b40 000002a0 T vfprintf            # 'T': código en flash
20000070 00000400 b rx_buffer           # 'b': RAM sin inicializar (.bss)
...                                     # (salida de ejemplo; el formato es real, los símbolos dependen de tu proyecto)

La letra indica en qué sección está cada símbolo: mayúscula si es global y minúscula si es local. Las que importan son T/t (código, flash), R/r (constantes, flash), D/d (datos inicializados, RAM y flash) y B/b (.bss, RAM). La Parte 2 del programa de Python de este tema analiza una salida como esta, separa flash de RAM y señala los candidatos.

Casi siempre aparecen las mismas categorías, y conviene saber de memoria cuáles son las sospechosas habituales:

Las sospechosas habituales de un firmware demasiado grande.
CategoríaCómo se reconoceQué hacer
Formato de texto (printf y familia)Símbolos como vfprintf, _svfprintf_r, __ssvfscanf_r; de varios KBnewlib-nano, sin coma flotante, o reemplazarlo por funciones mínimas
Coma flotante por softwareSímbolos __aeabi_fadd, __aeabi_fmul, __aeabi_dadd, __aeabi_ddiv, __aeabi_f2dAritmética de punto fijo; o un chip con unidad de punto flotante y solo float
Memoria dinámicamalloc, _malloc_r, _sbrk, __malloc_lockEliminarla (tema 30)
Tablas y textos grandesSímbolos r de varios cientos de bytes: tablas de seno, fuentes, mensajesSimetrías, interpolación, compresión, códigos en lugar de textos
Soporte de C++ o de excepcionesSecciones .ARM.exidx, .eh_frame, símbolos __cxa_*, _Unwind_*Desactivar excepciones y RTTI, o programar en C
Una función de la biblioteca arrastrada por una sola llamadaEl .map muestra de qué archivo objeto viene cada símbolo: uno solo trajo diezReemplazarla por una versión propia mínima

03 · La perilla del compilador

Los niveles de optimización

El compilador GCC (y Clang) ofrece niveles de optimización que se eligen con una sola opción. Cada uno es un paquete de transformaciones, y lo que cambia no es solo el tamaño: también la velocidad, la facilidad de depuración y hasta el comportamiento de programas con errores latentes:

Niveles de optimización de GCC y qué buscan.
OpciónObjetivoCuándo se usaCosto o advertencia
-O0Ninguna optimización; el código sigue línea por línea al fuenteDepuración más fielEl código más grande y lento; las variables viven en la pila (más RAM de pila)
-OgOptimizar sin entorpecer la depuraciónDesarrollo diarioModerado; algunas variables pueden aparecer «optimizadas» al depurar
-O1Optimizaciones básicas sin aumentar mucho el tiempo de compilaciónPunto intermedio—
-O2Optimiza para velocidad sin crecer el código de modo notorioCódigo de producción en generalPuede inflar el tamaño con inlining y alineaciones
-O3Optimización agresiva: más inlining, desenrollado de bucles, vectorizaciónCálculo intensivo donde la velocidad mandaSuele agrandar mucho el código; en un microcontrolador casi nunca vale la pena
-OsComo -O2, pero sin las transformaciones que aumentan el tamañoLa opción por defecto en firmware con flash escasaAlgo más lento que -O2 en ciertos bucles
-OzReducir el tamaño aún más, a costa de velocidad (Clang, y GCC en versiones recientes)Flash muy justaMás lento; conviene medir si hay plazos

No hay un nivel «mejor»: la única respuesta fiable es compilar con cada uno y medir, en tu código y tu chip. Las diferencias dependen de la arquitectura, del estilo del programa y de la versión del compilador, y las generalizaciones fallan. Lo que sí es regular, y conviene saberlo:

  • -O0 no es «seguro», es distinto. Un programa que solo funciona sin optimizar tiene casi siempre un error latente (una variable sin inicializar, un acceso fuera de rango, una falta de volatile) que la optimización deja al descubierto. Cambiar el nivel de optimización y que algo falle es un diagnóstico, no un fallo del compilador.
  • Los retardos por conteo desaparecen. Un bucle for (i = 0; i < 100000; i++); usado como pausa se elimina por completo con optimización, porque no hace nada observable. La pausa se hace con un temporizador (tema 17), no con un bucle vacío.
  • La optimización cambia los tiempos. El tiempo de una rutina de interrupción o de un bucle de lectura de un sensor cambia con el nivel. Los tiempos se miden con el nivel de producción, no con el de depuración (tema 18).
  • Con -O0 la pila crece. Todas las variables locales se guardan en memoria en vez de registros, y los marcos son más grandes. Una medida de pila hecha sin optimización no sirve para el producto.
  • Los Cortex-M ya ahorran de por sí: usan el conjunto de instrucciones Thumb-2, que mezcla instrucciones de 16 y 32 bits y logra un código más compacto que el de 32 bits fijos, siempre que se compile con -mthumb (es lo normal).

Una recomendación práctica común: desarrollar con -Og, entregar con -Os (o -O2 si hay plazos de velocidad), y probar el producto final con las mismas opciones con que se entrega. Un firmware depurado en -O0 y entregado en -Os es otro programa.

04 · Que no entre lo que no se usa

Recortar lo que sobra: enlace y bibliotecas

Antes de reducir tu código, hay que evitar que entre código que no usás. El enlazador, por defecto, incluye cada archivo objeto entero si se usa una sola de sus funciones. Tres técnicas lo corrigen, y juntas suelen dar el mayor ahorro por menos esfuerzo:

De los fuentes al binario: dónde actúa cada palanca Un flujo de cuatro etapas: los fuentes en C, el compilador que genera una sección por función, el enlazador que une y descarta, y el binario final. Debajo de cada etapa, las palancas que actúan en ella: el nivel de optimización y las opciones de sección en el compilador; gc-sections, LTO y newlib-nano en el enlazador; y las decisiones de diseño sobre datos, tablas y textos en los fuentes. Fuentes en CCompiladorEnlazadorBinario Diseño tipos y orden de campostablas con simetríatextos como códigossin printf ni float Opciones del compilador -Os (o -Og al desarrollar)-ffunction-sections-fdata-sectionssin tablas de excepciones Opciones del enlazador --gc-sections: quita lo muerto-flto: ve todo el programanano.specs: biblioteca chica--print-memory-usage: medir Se mide aquí size, nm, .mapantes y despuésde cada cambio Cuanto más a la izquierda se decide, más ahorra y más cuesta cambiar; cuanto más a la derecha, más fácil de probar.
# 1. Una sección por función y por variable, y descartar las que nadie usa
CFLAGS  += -Os -ffunction-sections -fdata-sections
LDFLAGS += -Wl,--gc-sections

# 2. La biblioteca estándar compacta (newlib-nano): printf y malloc mucho más chicos
LDFLAGS += --specs=nano.specs

# 3. Optimización entre archivos (LTO): el compilador ve todo el programa a la vez
CFLAGS  += -flto
LDFLAGS += -flto

# Para C: no generar tablas de desenrollado de pila que no se usan
CFLAGS  += -fno-unwind-tables -fno-asynchronous-unwind-tables
# Para C++: sin excepciones ni información de tipos en ejecución
CXXFLAGS += -fno-exceptions -fno-rtti

Cada una hace algo distinto:

  • -ffunction-sections -fdata-sections junto con --gc-sections hacen que cada función y cada variable global esté en su propia sección, y que el enlazador descarte las que no son alcanzables desde el punto de entrada y los vectores de interrupción. Sin ellas, una sola función usada trae a todas las de su archivo. Es la técnica de mejor relación esfuerzo/ahorro. Para que las secciones esenciales (la tabla de vectores) no se descarten, el linker script las protege con KEEP (tema 7).
  • --specs=nano.specs usa la versión reducida de la biblioteca estándar de C (newlib-nano), con printf, malloc y otras funciones mucho más pequeñas, a cambio de omitir características. La principal: el printf de newlib-nano no formatea números de coma flotante salvo que se pida con -u _printf_float (y -u _scanf_float para scanf), lo cual agrega varios kilobytes.
  • LTO (-flto, link-time optimization) le da al compilador una vista de todo el programa: puede insertar en línea funciones de otros archivos, eliminar código que ningún camino usa y reordenar constantes. Suele reducir el tamaño, a cambio de compilaciones más lentas y una depuración más difícil. Hay que cuidar que las rutinas de interrupción y otras funciones que solo se invocan desde hardware se mantengan (con __attribute__((used)) o KEEP).
  • Las opciones sin tablas de pila ni excepciones eliminan estructuras que el compilador agrega para desenrollar la pila, que en un firmware sin excepciones no sirven para nada y ocupan flash (.ARM.exidx).

El costo de printf: el caso emblemático

printf es la función más seductora y más cara de la biblioteca estándar. Una llamada trae todo el motor de formato (una función grande que interpreta cada especificador), y con coma flotante, mucho más. Hay tres niveles de respuesta, de menos a más ahorro:

  1. newlib-nano y sin coma flotante. Un cambio de opciones. Se pierde %f.
  2. Reemplazar printf por funciones propias mínimas que solo hacen lo que se necesita: imprimir un entero, imprimir en hexadecimal, imprimir una cadena. Con unas 30 líneas de C alcanza para casi todo el registro de diagnóstico del producto.
  3. Registro con códigos en lugar de texto: en lugar de armar «Temperatura: 23.5 C» en el equipo, se envía un identificador de mensaje y los valores en binario, y quien los muestra (la PC, la aplicación) arma el texto. El firmware no guarda ni formatea ningún texto, y los mensajes son más cortos y rápidos.
/* Imprimir un entero sin printf: unas pocas instrucciones en lugar de varios KB */
void imprimir_u32(uint32_t v) {
    char buf[10];                             /* 4 294 967 295 tiene 10 cifras */
    int n = 0;
    do { buf[n++] = (char)('0' + v % 10); v /= 10; } while (v != 0);
    while (n > 0) uart_poner(buf[--n]);       /* se emite al revés: las cifras salieron de la menos significativa */
}

/* Temperatura en décimas de grado sin float: 235 se muestra como «23.5» */
void imprimir_decimas(int16_t dc) {
    if (dc < 0) { uart_poner('-'); dc = (int16_t)-dc; }
    imprimir_u32((uint32_t)(dc / 10));  uart_poner('.');  uart_poner((char)('0' + dc % 10));
}

05 · El tamaño de los datos

Tipos y estructuras de datos

Los datos ocupan RAM, y algunas decisiones triviales de declaración cambian cuánta. Tres vale la pena conocer a fondo:

El relleno de las estructuras

El procesador accede más rápido (y en algunos núcleos, solo puede acceder) a datos alineados: un uint32_t en una dirección múltiplo de 4, un uint16_t en una múltiplo de 2. Para garantizarlo, el compilador inserta bytes de relleno (padding) entre los campos de una estructura, y al final, para que en un arreglo cada elemento también quede alineado. Los campos ocupan el espacio que les toca, y el relleno es espacio que no guarda nada:

/* Como se escribió: 16 bytes (9 útiles, 7 de relleno) */
typedef struct {
    uint8_t  estado;      /* offset 0 */
                          /* 3 bytes de relleno para alinear 'marca' a 4 */
    uint32_t marca;       /* offset 4 */
    uint8_t  flags;       /* offset 8 */
                          /* 1 byte de relleno para alinear 'valor' a 2 */
    uint16_t valor;       /* offset 10 */
    uint8_t  canal;       /* offset 12 */
                          /* 3 bytes de relleno al final: el tamaño debe ser múltiplo de 4 */
} Muestra;                /* sizeof = 16 */

/* Ordenada de mayor a menor: 12 bytes (9 útiles, 3 de relleno), sin tocar la lógica */
typedef struct {
    uint32_t marca;       /* offset 0 */
    uint16_t valor;       /* offset 4 */
    uint8_t  estado;      /* offset 6 */
    uint8_t  flags;       /* offset 7 */
    uint8_t  canal;       /* offset 8 */
                          /* 3 bytes de relleno al final */
} MuestraOrdenada;        /* sizeof = 12 */

La regla práctica: ordenar los campos de mayor a menor tamaño. Con un arreglo de 200 muestras, la diferencia es de 800 bytes de RAM, un cuarto de lo que tiene un ATmega328P entero, sin tocar una línea de lógica. El laboratorio de este tema te deja mover los campos y ver el relleno en el acto. La salida del compilador puede verse con -Wpadded, que advierte cuando inserta relleno, y con pahole si está disponible.

El atributo __attribute__((packed)) elimina el relleno, y es tentador, pero tiene un precio: los campos quedan desalineados, con lo cual cada acceso es más lento, y en un Cortex-M0 un acceso a un campo desalineado por un puntero produce un HardFault (tema 31). Solo conviene para estructuras que se copian byte a byte de un formato de comunicación y no para las que se usan en el cálculo.

El tipo adecuado para cada uso

En un núcleo de 32 bits, el tamaño natural de una variable es 32 bits: las operaciones con uint8_t o uint16_t en una variable local suelen necesitar instrucciones extra para recortar el resultado. La regla que resuelve el conflicto entre ahorrar RAM y ahorrar código es separar los usos:

Qué tipo usar según dónde vive la variable.
DóndeTipo recomendadoPor qué
Variables locales, contadores de bucle, índicesint, unsigned o uint_fast8_t (suele ser un entero del tamaño natural)Viven en registros: no cuestan RAM y evitan el recorte del resultado
Campos de estructuras y arreglos grandes en RAMEl tipo más chico que alcance (uint8_t, uint16_t)Se multiplican por la cantidad de elementos: es donde el tamaño importa
Datos que se envían o reciben por un protocoloTipos de ancho fijo (uint16_t, int32_t)El formato manda; el tamaño de int varía entre plataformas
Banderas independientes (verdadero/falso)Bits de un entero (máscaras) o un campo de bitsOcho banderas caben en un byte; ocho bool ocupan ocho

Las banderas son un caso clásico: veinte bool sueltos ocupan 20 bytes, y los mismos veinte bits caben en 3. Se manejan con máscaras y desplazamientos (flags |= BIT_ALARMA;) o con campos de bits de C. Los campos de bits son cómodos pero su disposición en memoria depende de la implementación, de modo que no sirven para formatos de comunicación ni para mapear registros de hardware; las máscaras explícitas sí.

Cuando dos datos nunca están en uso a la vez (un mensaje de entrada y uno de salida de un mismo protocolo en half-duplex, un buffer de decodificación y uno de formato), una union los hace compartir el mismo espacio. Es una técnica poderosa y riesgosa: hay que estar seguro de la exclusión mutua en el tiempo, y documentarlo.

06 · Datos que no cambian

Constantes, tablas y textos

Las constantes son el mejor candidato a ahorro de RAM, porque pueden vivir en la flash (tema 28), y a la vez son una fuente importante de uso de flash, porque suelen ser tablas y textos grandes. Hay que atender las dos cosas.

Que estén donde deben

En un Cortex-M, todo lo declarado const a nivel de archivo queda en la flash sin más trámite; en un AVR hace falta PROGMEM y lectura especial (tema 28). El error clásico es olvidar const en una tabla: sin él, queda en .data, ocupando RAM y flash. La doble const merece atención en los arreglos de punteros a texto:

static const char *tabla_a[]       = { "uno", "dos", "tres" };   /* el arreglo de punteros va a .data (RAM y flash); los textos, a flash */
static const char *const tabla_b[] = { "uno", "dos", "tres" };   /* el arreglo de punteros va a .rodata: todo en flash, nada en RAM    */

Que pesen lo menos posible

Una tabla de consulta (lookup table) cambia memoria por tiempo: el resultado ya está calculado. Pero una tabla grande cuesta flash, y muchas se pueden achicar sin perder precisión, aprovechando propiedades matemáticas:

  • Simetría: una tabla de seno de 256 puntos tiene la misma información que una de un cuarto de onda. Los cuatro cuadrantes se reconstruyen con una resta y un cambio de signo. La Parte 3 del programa de Python lo prueba: 512 bytes pasan a 130 (un 75 % menos) y los 256 valores reconstruidos coinciden exactamente con la tabla completa.
  • Interpolación: en lugar de 256 puntos, una tabla de 32 puntos con interpolación lineal entre vecinos, si el error resultante cumple la precisión que pide el requisito.
  • Menos bits: si el requisito pide 8 bits de precisión, una tabla de uint8_t ocupa la mitad que una de uint16_t.
  • Cálculo en lugar de tabla: si el procesador tiene una instrucción de multiplicación rápida y el plazo lo permite, un polinomio de pocos términos puede reemplazar a una tabla de cientos de bytes. Es un intercambio tiempo contra flash, y se decide con mediciones.
  • Compresión simple: los mapas de bits (fuentes, íconos) con muchas repeticiones se comprimen con técnicas elementales (codificación por repeticiones); el descompresor ocupa algunas decenas de bytes de código.

Los textos

Los mensajes de texto suelen ser el mayor consumidor de flash que nadie sospecha. Tres decisiones de diseño, de menor a mayor ahorro (la Parte 4 las mide):

  1. No guardarlos en arreglos de largo fijo. char mensajes[8][32] reserva 32 bytes por mensaje, tengan el largo que tengan (256 bytes). Un arreglo de punteros a textos const guarda 4 bytes por puntero más el largo exacto de cada texto (185 bytes para los mismos 8).
  2. No repetirlos. Si «Error de comunicación» aparece en diez lugares, el compilador suele unificar los literales idénticos (-fmerge-constants está activo desde -O1), pero no los que difieren en una palabra. Es mejor tener una tabla de mensajes indexada por un código de error.
  3. No guardarlos. El equipo informa un código (1 byte) y la aplicación que lo recibe muestra el texto: los 8 mensajes de la Parte 4 pasan a ocupar 8 bytes en el firmware. Además, cambiar o traducir un mensaje no obliga a reprogramar el equipo.

Una técnica clásica para mantener sincronizados los códigos y sus textos sin errores de copia es la macro de tabla (X-macro): se escribe la lista una sola vez y se la expande en cada forma que haga falta:

/* errores.h: la lista, escrita UNA sola vez. Para agregar un error, una línea. */
#define LISTA_ERRORES(X) \
    X(ERR_SENSOR,   "Sensor desconectado") \
    X(ERR_BATERIA,  "Bateria baja")        \
    X(ERR_TEMP,     "Sobretemperatura")

/* Primera expansión: el enumerado */
#define COMO_ENUM(id, texto)  id,
typedef enum { LISTA_ERRORES(COMO_ENUM) ERR_CANT } CodigoError;

/* Segunda expansión: la tabla de textos, en flash (solo si la depuración local la necesita) */
#define COMO_TEXTO(id, texto) [id] = texto,
static const char *const TEXTOS_ERROR[] = { LISTA_ERRORES(COMO_TEXTO) };

/* El código y su texto no pueden desincronizarse: salen de la misma línea. */
_Static_assert(sizeof TEXTOS_ERROR / sizeof TEXTOS_ERROR[0] == ERR_CANT, "falta un texto");

07 · El código que se escribe

Reducir el código mismo

Una vez descartado lo que sobra y elegido el nivel de optimización, queda tu propio código. Las ganancias grandes casi nunca están en micro-optimizaciones de una línea, sino en decisiones de diseño que evitan que entre código pesado:

La coma flotante

En un núcleo sin unidad de punto flotante (Cortex-M0, M0+, M3), toda operación con float o double es una llamada a una rutina de software (__aeabi_fadd, __aeabi_fmul, __aeabi_ddiv...), de cientos de bytes cada una, y lenta. Una sola multiplicación de double arrastra bibliotecas enteras. Incluso en un Cortex-M4F, que sí tiene unidad de punto flotante, esa unidad es de precisión simple: un literal como 2.5 es double en C, y con él la operación vuelve a hacerse por software. Se escribe 2.5f.

La alternativa casi siempre posible es la aritmética de punto fijo: representar los valores como enteros con una escala implícita. Una temperatura de 23,5 °C se guarda como 235 (décimas de grado) y las cuentas se hacen con enteros:

/* Temperatura en décimas de °C desde una lectura de 12 bits de un sensor lineal de -50 a +100 °C: T = -50 + 150 * crudo / 4095 */
int16_t temperatura_dc(uint16_t crudo) {
    return (int16_t)(-500 + (int32_t)crudo * 1500 / 4095);     /* entero de 32 bits para que 4095 * 1500 no desborde: 6 142 500 */
}

/* Punto fijo Q15: un número entre -1 y casi 1 como un entero de 16 bits (el valor real = n / 32768) */
static inline int16_t q15_mul(int16_t a, int16_t b) {
    return (int16_t)(((int32_t)a * b) >> 15);                   /* 0,5 (16384) por 0,25 (8192) da 4096, o sea 0,125 */
}

El punto fijo elimina las rutinas de coma flotante (la Parte 2 del programa de Python muestra 512 bytes de ellas en un ejemplo) y es mucho más rápido en núcleos sin unidad de punto flotante. El costo es de diseño: hay que elegir la escala, vigilar el desborde de los productos intermedios (de ahí el int32_t) y documentar las unidades en los nombres (_dc para décimas de grado).

Otras decisiones con efecto

  • Divisiones y operaciones de 64 bits. Los Cortex-M0 no tienen instrucción de división: cada / o % llama a __aeabi_idiv. Dividir por una potencia de dos con desplazamientos, o por una constante (que el compilador convierte en multiplicación), evita la rutina. Los enteros de 64 bits (uint64_t) duplican el costo de cada operación.
  • Funciones de conversión de la biblioteca (sscanf, strtod, atof): son grandes. Una conversión de texto a entero de una docena de líneas, propia, cuesta una fracción.
  • Código repetido. Cinco copias de casi la misma rutina (distintas en una constante) son cinco veces el tamaño; una función con un parámetro es una. Es el cambio de mejor relación, además de mejorar el mantenimiento.
  • Inlining. Una función corta marcada static inline y llamada en muchos lugares se copia en cada uno: ahorra la llamada pero multiplica el código. Para funciones medianas llamadas muchas veces, __attribute__((noinline)) lo evita.
  • Funciones de ayuda de depuración. Registros, volcados y verificaciones que se dejan en el producto final: se deshabilitan con una macro de compilación (#ifdef DEPURACION) para que no cuesten nada en producción.

08 · El otro recurso escaso

Reducir la RAM

La RAM suele ser el recurso que se agota primero (tema 28), y se ahorra con técnicas que ya aparecieron en el bloque. Conviene reunirlas en una lista de revisión, ordenada de mayor a menor rendimiento típico:

Dónde buscar ahorros de RAM, de mayor a menor rendimiento típico.
Dónde buscarQué hacerReferencia
Buffers grandesOrdenar los símbolos de RAM por tamaño (nm) y ver los cinco primeros. Preguntar de cada uno: ¿el tamaño sale de una cuenta o de una costumbre? Recalcular con la tasa y el peor tiempo de respuestaTema 30
Constantes en RAMCada tabla o texto que figura como d o D en nm y no se modifica: declararlo const (y static const char *const para punteros a texto)Este tema; tema 28
Estructuras con rellenoOrdenar campos de mayor a menor, sobre todo en arreglos de muchos elementosEste tema
Pilas sobredimensionadasMedir cada pila con la marca de agua (uxTaskGetStackHighWaterMark, pintado) y reducir las que tienen más del 50 % libre, dejando 25-30 % de margenTemas 29 y 31
Arreglos locales grandesLos que consumen pila pero que se usan una sola vez: pasarlos a static compartidos entre funciones que no se superponen, o a una unionTema 29
Copias innecesariasPasar un mensaje de una capa a la siguiente por puntero (con dueño claro) en lugar de copiarlo en cada capaTema 26
El heapEliminarlo, o reducirlo al mínimo medidoTema 30
Banderas dispersasAgrupar los bool en bitsEste tema

Y una advertencia sobre el margen: reducir la RAM al extremo (hasta el 95 % de uso) deja un sistema que cualquier cambio rompe. Un buen objetivo es mantener, tras todas las optimizaciones, entre el 20 y el 30 % de RAM libre para la pila y los imprevistos, y un margen similar de flash para crecer. Un producto con la memoria al 99 % ya no tiene futuro: no admite corregir un error ni agregar una función.

09 · No todo es ahorrar

Tamaño contra velocidad

Optimizar por tamaño cuesta velocidad en algunos lugares, y conviene decidir cuáles. La regla general: el tiempo de ejecución se concentra en muy pocas partes del programa. Un bucle de procesamiento de señal, una rutina de interrupción de alta frecuencia, una conversión de formato que corre miles de veces por segundo: ahí vale la velocidad. El resto (la inicialización, la interfaz de usuario, la comunicación esporádica) corre pocas veces y puede estar compilado por tamaño sin consecuencias.

Dos maneras de aplicar esa idea sin complicar el proyecto:

  • Opciones por archivo: compilar el programa con -Os en general y los pocos archivos críticos con -O2, desde el sistema de compilación (en un Makefile, con variables específicas de ese objetivo). Es una decisión visible y revisable.
  • Funciones críticas en RAM: las más sensibles al tiempo, con .ramfunc (tema 29), eliminan las esperas de la flash a cambio de ocupar RAM, que es justo el recurso que se quería cuidar. Hay que usarlo con medida.

Existe también la posibilidad de cambiar el nivel de optimización de una función con __attribute__((optimize("O2"))) o #pragma GCC optimize, pero la propia documentación de GCC desaconseja usarla en código de producción, porque su comportamiento no está garantizado en todas las combinaciones. Se prefiere la separación por archivos.

Y cuando los plazos no se cumplen, optimizar el código a mano es la última palanca, no la primera: antes están el hardware (un DMA que copie datos sin la CPU, un temporizador que genere la señal, un periférico de comunicación con FIFO) y el diseño (reducir la frecuencia de lo que corre más de lo necesario). Un bucle de 20 µs que el DMA elimina por completo vale más que uno reescrito en ensamblador para que dure 15.

10 · Comprobar que funciona

Verificación y diagnóstico

Comprobaciones al reducir la memoria de un proyecto.
Qué verificarCómoResultado esperado
Que la reducción sea realAnotar flash y RAM (con --print-memory-usage) antes y después de cada cambioLa cifra baja en lo que se esperaba; un cambio que no mueve la aguja se descarta
Que el producto final se pruebe como se entregaPasar el conjunto de pruebas con las mismas opciones de compilación que el firmware de producciónTodo pasa con -Os (o el nivel de entrega), no solo con -O0
Que la optimización no cambie el comportamientoCorrer las pruebas por escenario del tema 27 y comparar salidas antes y despuésResultados idénticos
Que la velocidad siga alcanzandoMedir los tiempos críticos con un pin y un analizador (tema 18) tras cambiar de nivelLos plazos se cumplen con margen
Que la pila siga teniendo margenRepetir la medición de la marca de agua (tema 29): la optimización la cambiaAl menos el 25-30 % libre
Que lo descartado no se necesitabaRevisar que las funciones de interrupción y los vectores sigan presentes en el .mapTodos los manejadores están; ninguno fue eliminado por --gc-sections o LTO
Que haya margen para crecerFijar un umbral (por ejemplo, 85 % de flash y 75 % de RAM) en la integración automáticaUn cambio que lo supera falla el proceso y obliga a decidir
Síntomas frecuentes y qué revisar.
SíntomaCausa probableQué hacer
Un firmware que funcionaba con -O0 falla con -OsUn error latente: variable sin inicializar, falta de volatile, acceso fuera de rango, comportamiento indefinidoCompilar con -Wall -Wextra y probar en la PC con UBSan (tema 31); revisar los volatile (tema 16)
Una pausa hecha con un bucle vacío desaparecióEl compilador eliminó un bucle sin efectos observablesUsar un temporizador (tema 17); no usar bucles como retardos
Una interrupción dejó de funcionar al activar LTOLa función de interrupción fue descartada por no tener llamadas visiblesMarcarla con __attribute__((used)); verificar los vectores en el .map
printf("%f", x) imprime vacío con newlib-nanonewlib-nano no incluye el formato de coma flotanteEvitar float en la salida (punto fijo) o agregar -u _printf_float sabiendo el costo
El programa creció mucho al agregar una función cortaLa función arrastró una biblioteca (formato, coma flotante, malloc)Mirar la diferencia del .map antes y después; reemplazar la dependencia
El tamaño no cambia al cambiar el nivel de optimizaciónLas opciones no llegan al compilador (se especificaron en otro lugar), o el tamaño lo domina una tabla o una biblioteca precompiladaVerificar con make V=1 los comandos reales; mirar los símbolos grandes
El enlace falla por RAM aunque «todo cabe»Constantes que no son const (en .data) o buffers con tamaños heredadosOrdenar con nm los símbolos d y b; revisar cada uno

11 · Analogía con programación

Probar el modelo en Python

El programa reúne cuatro herramientas de análisis, todas hechas con cálculos reales: un calculador del relleno de estructuras con las reglas de alineación de C, un analizador de una salida de nm --size-sort que separa flash de RAM y ordena los consumidores, la tabla de seno de un cuarto de onda reconstruida por simetría y comparada con la completa, y la comparación de tres maneras de guardar mensajes de texto. La salida de nm es un ejemplo escrito a mano, para que el análisis tenga qué analizar; los cálculos sobre ella, no.

Python en tu navegador. Los bloques son editables: cambiá los valores y presioná «Ejecutar Python». La primera vez tarda unos segundos en cargar.

# Cuatro herramientas de análisis para ahorrar memoria: el relleno de las estructuras, el ranking de símbolos,
# las tablas con simetría y los mensajes de texto.
import math

# ---- Parte 1: el relleno (padding) de una estructura según el orden de sus campos --------------------------
TAM = {"uint8_t": 1, "uint16_t": 2, "uint32_t": 4}
def tamano_struct(campos):
    """Reglas de C en un Cortex-M: cada campo se alinea a su tamaño; la estructura, al mayor de sus campos."""
    pos, mayor = 0, 1
    for _, tipo in campos:
        t = TAM[tipo]; mayor = max(mayor, t)
        pos = (pos + t - 1) // t * t + t            # alinear el inicio del campo y avanzar
    return (pos + mayor - 1) // mayor * mayor, sum(TAM[t] for _, t in campos)

campos = [("estado", "uint8_t"), ("marca", "uint32_t"), ("flags", "uint8_t"), ("valor", "uint16_t"), ("canal", "uint8_t")]
ordenados = sorted(campos, key=lambda c: -TAM[c[1]])
print("Parte 1: el orden de los campos cambia el tamaño")
for nombre, cs in (("como se escribió", campos), ("de mayor a menor", ordenados)):
    total, utiles = tamano_struct(cs)
    print(f"  {nombre:<17}: {', '.join(c[0] for c in cs):<42} -> {total:>2} B (útiles {utiles}, relleno {total - utiles})")
a, b = tamano_struct(campos)[0], tamano_struct(ordenados)[0]
print(f"  con un arreglo de 200 elementos: {a * 200} B contra {b * 200} B -> se ahorran {(a - b) * 200} B de RAM sin tocar una línea de lógica")

# ---- Parte 2: ranking de símbolos a partir de una salida tipo «nm --size-sort -S» -------------------------
NM = """20000070 00000400 b rx_buffer
08002a10 00000300 r tabla_seno
20000470 00000200 b log_buffer
08000b40 000002a0 T vfprintf
20000670 00000100 b tx_buffer
08000de0 00000180 T imprimir_estado
20000770 00000040 d config_por_defecto
08000f60 00000120 T __aeabi_fmul
08001080 000000e0 T __aeabi_fadd
08001160 00000060 T sensor_leer
200007b0 00000010 B contador_global
"""
FLASH = set("TtRr"); RAM = set("DdBb")
simbolos = []
for linea in NM.strip().splitlines():
    _, tam, tipo, nombre = linea.split()
    simbolos.append((int(tam, 16), tipo, nombre))
print()
print("Parte 2: ranking de símbolos (T/t código, R/r constantes en flash; D/d datos, B/b bss en RAM)")
for memoria, tipos in (("FLASH", FLASH), ("RAM", RAM)):
    lista = sorted((s for s in simbolos if s[1] in tipos), reverse=True)
    total = sum(s[0] for s in lista)
    print(f"  {memoria}: {total} B en {len(lista)} símbolos")
    for tam, tipo, nombre in lista[:3]:
        print(f"    {nombre:<20} {tam:>5} B  ({tam / total * 100:4.1f} %)")
flotante = sum(s[0] for s in simbolos if s[2].startswith("__aeabi_f"))
print(f"  rutinas de coma flotante por software (__aeabi_f*): {flotante} B de flash: se eliminan con aritmética de punto fijo")

# ---- Parte 3: una tabla de seno de 256 puntos y de un cuarto de onda ----------------------------------------
completa = [round(32767 * math.sin(2 * math.pi * i / 256)) for i in range(256)]
cuarto = [round(32767 * math.sin(2 * math.pi * i / 256)) for i in range(65)]          # 65 puntos: 0 a 90 grados
def seno_por_simetria(i):
    q, r = (i >> 6) & 3, i & 63
    base = cuarto[r] if q in (0, 2) else cuarto[64 - r]
    return base if q < 2 else -base
diferencias = sum(1 for i in range(256) if seno_por_simetria(i) != completa[i])
print()
print("Parte 3: seno de 256 puntos de 16 bits")
print(f"  tabla completa: {len(completa) * 2} B; tabla de un cuarto de onda: {len(cuarto) * 2} B ({100 - len(cuarto) * 2 / (len(completa) * 2) * 100:.0f} % menos)")
print(f"  valores distintos entre la tabla completa y la reconstruida por simetría: {diferencias} de 256")

# ---- Parte 4: mensajes de texto, tres maneras de guardarlos --------------------------------------------------
MENSAJES = ["Sensor desconectado", "Bateria baja", "Sobretemperatura", "Error de comunicacion", "Memoria llena",
            "Tension fuera de rango", "Reinicio por watchdog", "Calibracion necesaria"]
fijos = len(MENSAJES) * 32                                                         # char mensajes[N][32]
punteros = len(MENSAJES) * 4 + sum(len(m) + 1 for m in MENSAJES)                   # const char *mensajes[N]; texto en flash
codigos = len(MENSAJES)                                                            # un byte por código; el texto lo arma el PC
print()
print("Parte 4: 8 mensajes de error")
print(f"  arreglo de textos de 32 B cada uno : {fijos} B (y en un AVR, copiados a la RAM)")
print(f"  punteros a textos en flash         : {punteros} B (4 B por puntero + el texto exacto)")
print(f"  solo códigos de 1 B (el texto vive en el PC o en la aplicación): {codigos} B")

Resultado, parte 1: la estructura escrita en el orden «estado, marca, flags, valor, canal» ocupa 16 bytes (9 útiles y 7 de relleno); ordenada de mayor a menor («marca, valor, estado, flags, canal») ocupa 12 (3 de relleno). Con 200 elementos son 3200 bytes contra 2400: 800 bytes de RAM ahorrados sin tocar la lógica. Parte 2: del ejemplo de nm, la flash suma 2432 bytes en 6 símbolos, con tabla_seno (768 B, el 31,6 %), vfprintf (672 B, el 27,6 %) e imprimir_estado (384 B, el 15,8 %) a la cabeza; la RAM suma 1872 bytes en 5 símbolos, con rx_buffer (1024 B, el 54,7 %), log_buffer (512 B, el 27,4 %) y tx_buffer (256 B, el 13,7 %); y las rutinas de coma flotante por software (__aeabi_f*) pesan 512 bytes. Parte 3: la tabla de seno completa de 256 puntos de 16 bits ocupa 512 bytes y la de un cuarto de onda 130 (un 75 % menos), y la reconstrucción por simetría coincide en los 256 valores. Parte 4: ocho mensajes de error ocupan 256 bytes como arreglo de textos de 32 B, 185 bytes como punteros a textos en flash, y 8 bytes si solo se guardan códigos.

Probá agregar un campo uint32_t más al final de la estructura de la Parte 1 y mirá cómo cambia el relleno, o cambiá la tabla de la Parte 3 a 1024 puntos (hay que ajustar el 256, el 65 y las máscaras del cuadrante) para ver que el ahorro se mantiene en el 75 %.

12 · Exploración interactiva

Relleno de estructuras

El laboratorio es el calculador de relleno con la estructura del apartado 5. Cambiá el tipo de cada campo, reordenalos con las flechas y mirá el mapa de bytes: cada celda es un byte, de un color por campo, y las rayadas en rojo son relleno. Podés también activar packed y ver qué se gana y qué se pierde.

El orden de los campos y los bytes que se pierden

Campos (de arriba hacia abajo, como en la estructura)

sizeof de la estructura—
Bytes útiles—
Bytes de relleno—
Con N elementos—
Ahorro posible—

Movés los campos y mirá el relleno.

Recorrido guiado

  1. Con el orden original (estado, marca, flags, valor, canal), el sizeof es 16 bytes: 9 útiles y 7 de relleno (los rayados en rojo). Fijate dónde está cada hueco: tres bytes antes de marca, uno antes de valor y tres al final.
  2. Apretá «Ordenar de mayor a menor»: pasa a 12 bytes, con solo 3 de relleno (los del final). Con 200 elementos, ahorra 800 bytes. Es el mismo contenido, los mismos tipos y la misma lógica.
  3. Con el botón ↑ subí marca al primer lugar y mirá cómo desaparecen los tres bytes de relleno del principio. Probá también bajarlo al final: el relleno vuelve en otro lugar.
  4. Cambiá el tipo de valor a uint32_t y observá que el relleno cambia de lugar y de cantidad. Un campo más ancho puede no costar tanto como se piensa si se acomoda bien, y uno chico mal ubicado puede costar de más.
  5. Cambiá los cinco campos a uint8_t: el sizeof es 5 y no hay relleno. El relleno solo aparece cuando se mezclan tamaños.
  6. Volvé al orden original y activá packed: el sizeof baja a 9 bytes, sin relleno alguno. Pero fijate en la advertencia: marca y valor quedaron en direcciones desalineadas, y acceder a ellos por puntero es más lento (y un HardFault en un Cortex-M0). Ordenada, la estructura queda en 12 con todos los campos alineados: en la mayoría de los casos es mejor que packed.
Desafío: ¿cómo reducirías esta estructura sin packed ni reordenar?

Quitando bytes útiles. Los campos estado, flags y canal son de un byte; si estado toma menos de 16 valores y canal menos de 16, caben en un solo byte (cuatro bits cada uno), y las banderas de flags pueden compartir otro. Con marca (4 B), valor (2 B) y dos bytes de campos empaquetados con máscaras, la estructura queda en 8 bytes, sin relleno, con todo alineado. El precio es código: hay que escribir funciones de lectura y de escritura para cada campo empaquetado ((m->control >> 4) & 0x0F), y esas instrucciones cuestan flash y tiempo. Conviene solo cuando hay muchos elementos: con 200 muestras ahorra otros 800 bytes de RAM respecto de los 12 ordenados (de 2400 a 1600), a cambio de unos pocos bytes de código.

13 · Método de trabajo

Método: reducir con datos

  1. Definí el objetivo con números: cuánta flash y cuánta RAM hay que liberar, o cuántos microsegundos hay que ganar. «Hacerlo más chico» no es un objetivo.
  2. Medí el estado inicial con --print-memory-usage y guardá el .map y la salida de nm --size-sort. Es tu línea de base.
  3. Empezá por lo que no cuesta código: el nivel de optimización (-Os), -ffunction-sections -fdata-sections con --gc-sections, newlib-nano y LTO. Suelen dar el mayor ahorro por menos esfuerzo.
  4. Mirá los símbolos más grandes de flash y de RAM. Los cinco primeros suelen explicar la mayor parte del tamaño. Para cada uno, preguntá: ¿hace falta?, ¿puede ser más chico?, ¿puede estar en otro lado?
  5. Atacá las dependencias pesadas (formato de texto, coma flotante, malloc): son las que más pesan y las que menos cuesta eliminar con un cambio de diseño.
  6. Después, los datos: orden de los campos, tipos adecuados, constantes en flash, tablas con simetría, textos como códigos.
  7. Medí después de cada cambio y descartá lo que no movió la aguja. Una optimización que no se midió es una opinión.
  8. Reprobá todo. Cada cambio de optimización o de tipos puede cambiar el comportamiento: pasá las pruebas con las opciones de entrega y medí otra vez los tiempos y la pila.
  9. Dejá margen y un freno: un umbral en la integración automática que impida volver a llenar el chip sin darse cuenta.

14 · Comprensión

Confusiones frecuentes

«Con -O3 el programa es más rápido y más chico»

Más rápido, a veces. Más chico, casi nunca: -O3 desenrolla bucles e inserta funciones en línea, y el código crece. Para ahorrar flash la opción es -Os. En cualquier caso, la única respuesta confiable es compilar y medir en tu código y tu chip.

«Si el programa falla al optimizar, el compilador tiene un error»

Es casi siempre al revés: el programa tiene un error latente (una variable sin inicializar, una falta de volatile, un acceso fuera de rango) que sin optimizar quedaba enmascarado. Los errores del compilador existen, pero son mucho menos probables que los del código propio.

«Los tipos pequeños siempre ahorran memoria»

Ahorran RAM en arreglos y estructuras, pero en variables locales (que viven en registros) pueden agregar instrucciones para recortar resultados, y por lo tanto flash. Y mezclados con tipos grandes en una estructura, el relleno puede anular el ahorro. El tipo correcto depende de dónde vive la variable.

«packed es la forma de achicar las estructuras»

Elimina el relleno, pero deja los campos desalineados: más lento, y un HardFault en los núcleos que no toleran el acceso desalineado. Ordenar los campos da casi el mismo ahorro sin ese costo. packed es para estructuras que se copian de un formato externo, no para las que se calculan.

«Un printf más no pesa nada»

La primera llamada trae todo el motor de formato, y con coma flotante, varios kilobytes más. Un solo printf("%f") puede ocupar más flash que el resto del programa. Si se usa para depuración, conviene que desaparezca del producto con una macro; si se usa en producción, reemplazarlo por funciones mínimas.

«La coma flotante es gratis porque el compilador la maneja»

En un núcleo sin unidad de punto flotante, cada operación es una llamada a una rutina de software de cientos de bytes. En uno con unidad de precisión simple, un literal sin sufijo es double y vuelve a hacerse por software. Y en cualquier caso hay que tener cuidado con la precisión. El punto fijo suele ser mejor.

«Optimizar el tamaño siempre vuelve lento el programa»

No necesariamente. El código más chico entra mejor en la caché y necesita menos lecturas de flash, y en un Cortex-M con esperas de flash puede ser incluso más rápido. Lo que sí es cierto es que algunas transformaciones (desenrollado, inlining agresivo) ganan velocidad a cambio de tamaño; se evita con -Os y se mide.

«Reduje la memoria al máximo: ahora el producto es mejor»

Un producto con la memoria al 99 % no tiene margen para una corrección, una función nueva o el peor caso de la pila, y en el campo eso sale caro. El objetivo es el margen suficiente, no el mínimo absoluto: casi siempre es mejor un chip algo más grande que un firmware sin aire.

15 · Práctica guiada

Ejercicios

1. Calcular el relleno

Una estructura declara, en este orden, uint8_t a; uint16_t b; uint8_t c; uint32_t d;. ¿Cuánto vale su sizeof en un Cortex-M? ¿Cuánto ocuparía ordenada de mayor a menor? ¿Cuánto se ahorra con un arreglo de 50 elementos?

Ver solución

Orden original: a en 0; b necesita alineación a 2, así que 1 byte de relleno y queda en 2 (hasta el 4); c en 4; d necesita alineación a 4, así que 3 bytes de relleno y queda en 8 (hasta el 12). Total: 12 bytes (8 útiles y 4 de relleno). Ordenada (d, b, a, c): 4 + 2 + 1 + 1 = 8 bytes, sin relleno. Con 50 elementos: 600 contra 400, es decir, 200 bytes de RAM menos.

2. Una tabla más chica

Una tabla de seno de 1024 puntos de 16 bits se guarda completa. ¿Cuánto ocupa? ¿Y de un cuarto de onda (257 puntos para incluir el valor en 90 grados)? ¿Qué porcentaje se ahorra?

Ver solución

Completa: 1024 × 2 = 2048 bytes. De un cuarto de onda: 257 × 2 = 514 bytes. Ahorro: 1 − 514 / 2048 = 74,9 %, es decir, casi el 75 %. El costo es un poco de código (unas pocas instrucciones para elegir el cuadrante y cambiar el signo), y el resultado puede ser exactamente igual a la tabla completa, como mostró la Parte 3 del programa con 256 puntos.

3. Los mensajes

Un equipo tiene 20 mensajes de error de 18 caracteres en promedio. Calculá cuánto ocupan: (a) como char m[20][32], (b) como arreglo de punteros a textos en flash, (c) como códigos de un byte.

Ver solución

(a) 20 × 32 = 640 bytes. (b) 20 punteros × 4 = 80, más 20 textos de 18 + 1 (terminador) = 380: 460 bytes. (c) 20 bytes (un código por mensaje; el texto lo pone la aplicación que los muestra). El paso de (a) a (b) ahorra 180 bytes; el de (b) a (c), 440 más. Y en (c), traducir o corregir un texto no requiere reprogramar el equipo.

4. Leer el uso de memoria

La compilación informa: FLASH: 61440 B de 64 KB y RAM: 17408 B de 20 KB. ¿Qué porcentaje es cada uno? ¿Cuál es el más preocupante? ¿Por dónde empezarías?

Ver solución

Flash: 61 440 / 65 536 = 93,75 %. RAM: 17 408 / 20 480 = 85 %. Los dos están por encima de lo razonable (ya no hay margen), pero la RAM es más preocupante porque la cifra reportada es solo la estática: faltan la pila y el heap, que se disputan el 15 % restante (3072 bytes). Empezaría ordenando los símbolos por tamaño con nm --size-sort para ver los cinco mayores de RAM y de flash; revisando si hay constantes en .data; verificando si las opciones -Os, --gc-sections y newlib-nano están activas; y buscando printf, coma flotante y malloc en el mapa.

5. Punto fijo

En Q15 (el valor real es n / 32768), ¿qué entero representa 0,5? ¿Y 0,25? ¿Cuánto da q15_mul de esos dos y qué número real es? ¿Por qué hace falta int32_t en la cuenta intermedia?

Ver solución

0,5 es 0,5 × 32768 = 16384; 0,25 es 8192. El producto: (16384 × 8192) >> 15 = 134 217 728 / 32768 = 4096, que es 4096 / 32768 = 0,125, el resultado correcto de 0,5 × 0,25. Hace falta int32_t porque el producto de dos enteros de 16 bits puede llegar a 230, que no entra en 16 bits: con una cuenta de 16 bits el resultado se desbordaría antes del desplazamiento. Es el error clásico del punto fijo: olvidar el ancho de los resultados intermedios.

6. El retardo que desapareció

Un programa genera un pulso de LED con led_on(); for (volatile int i = 0; i < 100000; i++); led_off(); y anda bien con -O0, pero con -O2, ¿qué pasa? ¿Y si se quita el volatile? ¿Cómo se debería hacer?

Ver solución

Con volatile, el compilador debe respetar cada lectura y escritura de i, y el bucle se conserva aunque con otra duración (el tiempo depende del nivel de optimización: no es confiable). Sin volatile, el bucle no tiene efectos observables y el compilador lo elimina por completo: el LED se enciende y se apaga sin pausa, y el pulso es de unos pocos ciclos. En ambos casos el diseño es incorrecto: una pausa se hace con un temporizador de hardware (tema 17) o con la base de tiempo del sistema (tema 18), cuya duración no depende del compilador ni del nivel de optimización.

16 · Síntesis del bloque

Ideas para recordar

  • Optimizar es medir, priorizar y verificar: se mide con --print-memory-usage, size y nm --size-sort, se ataca primero lo que más pesa y se vuelve a medir. No se optimiza sin medir.
  • Hay tres recursos que pueden faltar (flash, RAM, tiempo) y los intercambios son cruzados: ganar uno suele costar otro. El objetivo es cumplir los requisitos con el recurso más caro del producto, y dejar un margen de 20-30 %.
  • Los niveles de optimización son paquetes de transformaciones: -Og para desarrollar, -Os para flash escasa, -O2 si manda la velocidad, -O3 casi nunca en microcontroladores. La única respuesta confiable es medir, y el producto se prueba con las opciones de entrega.
  • Lo que más ahorra por menos esfuerzo: -ffunction-sections -fdata-sections con --gc-sections, newlib-nano y LTO. Hay que verificar que las funciones de interrupción no se descarten.
  • Las dependencias pesadas son printf y el formato de texto, la coma flotante por software y malloc. Se reemplazan por funciones mínimas, aritmética de punto fijo y asignación estática.
  • En los datos: ordenar los campos de mayor a menor elimina el relleno sin tocar la lógica; los tipos chicos van en arreglos y estructuras, los del tamaño natural en locales; packed es más lento y riesgoso; las banderas caben en bits.
  • Constantes y textos: const (con static const char *const para punteros) los deja en flash; las tablas se achican con simetría, interpolación o menos bits; los mensajes se guardan como punteros, mejor como códigos, con una macro de tabla que mantiene todo sincronizado.
  • El bloque en conjunto: conocer los tipos de memoria y su costo (28), leer el mapa que arma el enlazador (29), decidir cómo se reserva cada byte (30), defenderse de los errores que lo corrompen (31) y recortar lo que sobra con método (32). La memoria no se administra por intuición: se mide, se presupuesta y se protege.

¿Listo para el bloque siguiente?

Antes de avanzar, tomá un proyecto tuyo, compilalo con -Wl,--print-memory-usage y anotá la línea base. Después aplicá, de a una y midiendo cada vez, las tres palancas sin código (-Os, --gc-sections, newlib-nano) y mirá los cinco símbolos más grandes de flash y de RAM. Si lográs explicar con números cuánto bajó cada cambio y por qué, ya dominás el bloque de memoria.

En el próximo bloque pasamos de la memoria a otro recurso escaso: la energía. Empezamos por entender cómo se consume: corriente activa y de reposo, potencia, presupuesto de energía y baterías.