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:
| Falta | Síntoma | Palancas principales | Efecto colateral posible |
|---|---|---|---|
| Flash | El enlace falla con region 'FLASH' overflowed, o no queda lugar para una función nueva o para el bootloader | Compilar con -Os, eliminar código muerto, recortar bibliotecas, tablas más chicas, textos compactos | Código algo más lento |
| RAM | El 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 medidas | Menos buffers: más riesgo de pérdida de datos si se dimensiona mal |
| Tiempo | Un 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:
| Categoría | Cómo se reconoce | Qué hacer |
|---|---|---|
Formato de texto (printf y familia) | Símbolos como vfprintf, _svfprintf_r, __ssvfscanf_r; de varios KB | newlib-nano, sin coma flotante, o reemplazarlo por funciones mínimas |
| Coma flotante por software | Símbolos __aeabi_fadd, __aeabi_fmul, __aeabi_dadd, __aeabi_ddiv, __aeabi_f2d | Aritmética de punto fijo; o un chip con unidad de punto flotante y solo float |
| Memoria dinámica | malloc, _malloc_r, _sbrk, __malloc_lock | Eliminarla (tema 30) |
| Tablas y textos grandes | Símbolos r de varios cientos de bytes: tablas de seno, fuentes, mensajes | Simetrías, interpolación, compresión, códigos en lugar de textos |
| Soporte de C++ o de excepciones | Secciones .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 llamada | El .map muestra de qué archivo objeto viene cada símbolo: uno solo trajo diez | Reemplazarla 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:
| Opción | Objetivo | Cuándo se usa | Costo o advertencia |
|---|---|---|---|
-O0 | Ninguna optimización; el código sigue línea por línea al fuente | Depuración más fiel | El código más grande y lento; las variables viven en la pila (más RAM de pila) |
-Og | Optimizar sin entorpecer la depuración | Desarrollo diario | Moderado; algunas variables pueden aparecer «optimizadas» al depurar |
-O1 | Optimizaciones básicas sin aumentar mucho el tiempo de compilación | Punto intermedio | — |
-O2 | Optimiza para velocidad sin crecer el código de modo notorio | Código de producción en general | Puede inflar el tamaño con inlining y alineaciones |
-O3 | Optimización agresiva: más inlining, desenrollado de bucles, vectorización | Cálculo intensivo donde la velocidad manda | Suele agrandar mucho el código; en un microcontrolador casi nunca vale la pena |
-Os | Como -O2, pero sin las transformaciones que aumentan el tamaño | La opción por defecto en firmware con flash escasa | Algo más lento que -O2 en ciertos bucles |
-Oz | Reducir el tamaño aún más, a costa de velocidad (Clang, y GCC en versiones recientes) | Flash muy justa | Má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:
-O0no 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 devolatile) 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
-O0la 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:
# 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-sectionsjunto con--gc-sectionshacen 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 conKEEP(tema 7).--specs=nano.specsusa la versión reducida de la biblioteca estándar de C (newlib-nano), conprintf,mallocy otras funciones mucho más pequeñas, a cambio de omitir características. La principal: elprintfde newlib-nano no formatea números de coma flotante salvo que se pida con-u _printf_float(y-u _scanf_floatparascanf), 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))oKEEP). - 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:
- newlib-nano y sin coma flotante. Un cambio de opciones. Se pierde
%f. - Reemplazar
printfpor 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. - 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:
| Dónde | Tipo recomendado | Por qué |
|---|---|---|
| Variables locales, contadores de bucle, índices | int, 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 RAM | El 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 protocolo | Tipos 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 bits | Ocho 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_tocupa la mitad que una deuint16_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):
- 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 textosconstguarda 4 bytes por puntero más el largo exacto de cada texto (185 bytes para los mismos 8). - No repetirlos. Si «Error de comunicación» aparece en diez lugares, el compilador suele unificar los literales idénticos (
-fmerge-constantsestá 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. - 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 inliney 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 | Qué hacer | Referencia |
|---|---|---|
| Buffers grandes | Ordenar 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 respuesta | Tema 30 |
| Constantes en RAM | Cada 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 relleno | Ordenar campos de mayor a menor, sobre todo en arreglos de muchos elementos | Este tema |
| Pilas sobredimensionadas | Medir cada pila con la marca de agua (uxTaskGetStackHighWaterMark, pintado) y reducir las que tienen más del 50 % libre, dejando 25-30 % de margen | Temas 29 y 31 |
| Arreglos locales grandes | Los que consumen pila pero que se usan una sola vez: pasarlos a static compartidos entre funciones que no se superponen, o a una union | Tema 29 |
| Copias innecesarias | Pasar un mensaje de una capa a la siguiente por puntero (con dueño claro) en lugar de copiarlo en cada capa | Tema 26 |
| El heap | Eliminarlo, o reducirlo al mínimo medido | Tema 30 |
| Banderas dispersas | Agrupar los bool en bits | Este 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
-Osen general y los pocos archivos críticos con-O2, desde el sistema de compilación (en unMakefile, 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
| Qué verificar | Cómo | Resultado esperado |
|---|---|---|
| Que la reducción sea real | Anotar flash y RAM (con --print-memory-usage) antes y después de cada cambio | La cifra baja en lo que se esperaba; un cambio que no mueve la aguja se descarta |
| Que el producto final se pruebe como se entrega | Pasar el conjunto de pruebas con las mismas opciones de compilación que el firmware de producción | Todo pasa con -Os (o el nivel de entrega), no solo con -O0 |
| Que la optimización no cambie el comportamiento | Correr las pruebas por escenario del tema 27 y comparar salidas antes y después | Resultados idénticos |
| Que la velocidad siga alcanzando | Medir los tiempos críticos con un pin y un analizador (tema 18) tras cambiar de nivel | Los plazos se cumplen con margen |
| Que la pila siga teniendo margen | Repetir la medición de la marca de agua (tema 29): la optimización la cambia | Al menos el 25-30 % libre |
| Que lo descartado no se necesitaba | Revisar que las funciones de interrupción y los vectores sigan presentes en el .map | Todos los manejadores están; ninguno fue eliminado por --gc-sections o LTO |
| Que haya margen para crecer | Fijar un umbral (por ejemplo, 85 % de flash y 75 % de RAM) en la integración automática | Un cambio que lo supera falla el proceso y obliga a decidir |
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
Un firmware que funcionaba con -O0 falla con -Os | Un error latente: variable sin inicializar, falta de volatile, acceso fuera de rango, comportamiento indefinido | Compilar 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 observables | Usar un temporizador (tema 17); no usar bucles como retardos |
| Una interrupción dejó de funcionar al activar LTO | La función de interrupción fue descartada por no tener llamadas visibles | Marcarla con __attribute__((used)); verificar los vectores en el .map |
printf("%f", x) imprime vacío con newlib-nano | newlib-nano no incluye el formato de coma flotante | Evitar float en la salida (punto fijo) o agregar -u _printf_float sabiendo el costo |
| El programa creció mucho al agregar una función corta | La 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ón | Las opciones no llegan al compilador (se especificaron en otro lugar), o el tamaño lo domina una tabla o una biblioteca precompilada | Verificar 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 heredados | Ordenar 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)
Movés los campos y mirá el relleno.
Recorrido guiado
- Con el orden original (
estado,marca,flags,valor,canal), elsizeofes 16 bytes: 9 útiles y 7 de relleno (los rayados en rojo). Fijate dónde está cada hueco: tres bytes antes demarca, uno antes devalory tres al final. - 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.
- Con el botón ↑ subí
marcaal 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. - Cambiá el tipo de
valorauint32_ty 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. - Cambiá los cinco campos a
uint8_t: elsizeofes 5 y no hay relleno. El relleno solo aparece cuando se mezclan tamaños. - Volvé al orden original y activá
packed: elsizeofbaja a 9 bytes, sin relleno alguno. Pero fijate en la advertencia:marcayvalorquedaron 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 quepacked.
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
- 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.
- Medí el estado inicial con
--print-memory-usagey guardá el.mapy la salida denm --size-sort. Es tu línea de base. - Empezá por lo que no cuesta código: el nivel de optimización (
-Os),-ffunction-sections -fdata-sectionscon--gc-sections, newlib-nano y LTO. Suelen dar el mayor ahorro por menos esfuerzo. - 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?
- 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. - Después, los datos: orden de los campos, tipos adecuados, constantes en flash, tablas con simetría, textos como códigos.
- 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.
- 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.
- 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,sizeynm --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:
-Ogpara desarrollar,-Ospara flash escasa,-O2si manda la velocidad,-O3casi 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-sectionscon--gc-sections, newlib-nano y LTO. Hay que verificar que las funciones de interrupción no se descarten. - Las dependencias pesadas son
printfy el formato de texto, la coma flotante por software ymalloc. 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;
packedes más lento y riesgoso; las banderas caben en bits. - Constantes y textos:
const(constatic const char *constpara 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.