Lo que el linker no hará: empaquetar cadenas i18n en un MCU
Un generador empaqueta las traducciones del firmware en un pool indexado por offsets uint16, reduciendo a la mitad la tabla índice en un MCU de 32 bits.

Mi firmware habla cinco idiomas en un microcontrolador con dos botones y una pantalla diminuta. Cinco idiomas, 273 IDs de cadena y un chip de 32 bits donde cada byte de flash está contado. La forma canónica de almacenar eso es una tabla bidimensional de punteros const char* — y la primera vez que miré el map file, me molestó que una buena parte de esos bytes se gastara en direccionar, no en un solo carácter de texto real.
Así que escribí un generador para empaquetar mejor que el linker. Luego descubrí que el linker ya hacía casi todo lo que yo había reinventado — y di con la única cosa que de verdad no puede hacer, que resultó ser la mejora que valía la pena conservar. Esta es esa historia: un pool de cadenas empaquetado y con tail-merge (fusión por sufijos), indexado con offsets uint16_t, lo que ahorró de verdad, y una contabilidad honesta de dónde vienen realmente los ahorros.
TL;DR
- El esquema i18n básico es un
const char* table[langs][ids]— en un MCU de 32 bits eso son 5.460 bytes de índice de punteros para 5×273 cadenas, con independencia del texto. - Un generador en tiempo de compilación emite un pool de cadenas empaquetado, deduplicado y con tail-merge más una tabla de offsets
uint16_t— el índice baja a 2.730 bytes (−50%), y las 1.365 relocations de la tabla básica (una dirección que fijar por celda) pasan a cero. - El firmware encogió 2.224 B netos en el
-Osde producción, sin cambios de API ni en los puntos de llamada — y el empaquetado se mantiene más pequeño en todos los niveles de optimización, de-O0a-O3. - Giro inesperado: los linkers modernos ya hacen tail-merge de los literales de cadena (GNU
ldpor defecto,ld.lldcon-O2), así que el blob empaquetado es solo 282 B más pequeño que lo que produce el linker. La mejora real, a prueba de optimizador, es estructural — reducir la tabla a la mitad y borrar las relocations — algo que ningún linker, ni siquiera LTO, puede hacer. - Coste honesto: agrupar en un pool renuncia a 500 B de deduplicación cruzada del linker; la mejora de tabla + relocations es la que produce la ganancia neta, no el blob.
La tabla canónica — y lo que cuesta el índice
La forma generada obvia es una rejilla: una fila por idioma, una columna por ID de cadena, cada celda un puntero a un literal.
// 5 languages × 273 ids
const char* const kStrings[kLangCount][kStringCount] = {
/* EN */ { "Kleidos", "OK", "Cancel", /* ... */ },
/* ES */ { "Kleidos", "OK", "Cancelar", /* ... */ },
/* ... */
};
const char* tr(uint8_t lang, uint16_t id) { return kStrings[lang][id]; }Es correcto, es legible y es exactamente lo que usa la mayoría de proyectos. Pero fíjate en lo que la tabla es: un gran array de direcciones. En un objetivo de 32 bits, un const char* ocupa cuatro bytes, y hay uno por celda tanto si la cadena detrás es "OK" como una frase entera.
El coste solo del índice
- Idiomas × IDs
5 × 273 = 1.365 celdasUn puntero por idioma, por ID de cadena.- Tamaño del puntero
4 bytes (32 bits)Cada celda es un const char* — una dirección reubicable, no texto.- Total tabla índice
5.460 bytes5 × 273 × 4. Puro overhead de direccionamiento, antes de un solo carácter de texto — y una relocation por celda.
Esos 5,5 KB no aportan ni un carácter de texto — son el coste de encontrar el texto. Los literales de cadena en sí viven en flash (.rodata) y son un coste aparte e inevitable; en MCUs con memoria ajustada, mantener los literales fuera de la RAM es una preocupación de siempre, desde el patrón PROGMEM/F() de Arduino (referencia de PROGMEM de Arduino) hasta cómo C++ embebido almacena cadenas en memoria de solo lectura. El texto tenía que pagarlo. El índice era la parte que parecía un desperdicio.
¿Pero no hace esto ya el linker?
Antes de optimizar, conviene preguntarse qué hace el toolchain gratis — y aquí es donde se desmoronó mi primera suposición.
El compilador hace un poco. El -fmerge-constants de GCC (activado por defecto en -O1 y superior), según la referencia de optimize-options de GCC, “intentará fusionar constantes idénticas” — pero solo las idénticas y completas. El trabajo más profundo ocurre en el linker. Los literales de cadena acaban en secciones marcadas SHF_MERGE | SHF_STRINGS, y el enlazador tanto deduplica cadenas idénticas como elimina las que son sufijo de otra: dados "bigdog" y "dog", la más corta se descarta y se representa con la cola de la más larga (ver la guía del linker y librerías de Oracle). Eso es tail-merge — justo el truco que creía estar inventando.
Lo agresivo que sea depende del linker y del nivel de optimización:
| Etapa | ¿Fusiona cadenas idénticas? | ¿Hace tail-merge de sufijos? |
|---|---|---|
GCC -fmerge-constants (compilador) | Sí (por unidad de traducción) | No |
GNU ld (linker) | Sí | Sí — por defecto |
ld.lld (linker) | Sí (-O1, por defecto) | Solo en -O2 |
Así que GNU ld hace tail-merge de las secciones SHF_MERGE | SHF_STRINGS por defecto, mientras que ld.lld solo lo hace con -O2 (-O1, el valor por defecto, solo fusiona cadenas idénticas; -O0 desactiva la fusión por completo) — ver las notas de ld vs lld de MaskRay y el manual de ld.lld. Además está “permitido, no obligado” — el algoritmo genérico de fusión de secciones opera sobre elementos completos (O’Dwyer sobre la fusión de cadenas en ELF), siendo el tail-merge de cadenas un camino aparte y específico que no todo enlazado ejecuta necesariamente.
Eso replantea todo el ejercicio. No estoy haciendo algo que el linker no pueda. Entonces, ¿para qué generarlo?
La primera razón es un buen extra; la segunda es la que de verdad importa.
El pool empaquetado y la tabla de offsets
El diseño son dos piezas de datos generados. Primero, cada traducción se concatena en un único blob de flash, cada cadena terminada en NUL — así que un “offset” es simplemente el inicio de una cadena C corriente. Segundo, una tabla por idioma de offsets uint16_t dentro de ese blob, que sustituye a la rejilla de punteros.
- cadena A · 6 B · @0
- \0 · 1 B · @6 — terminador NUL
- cadena B · 9 B · @7
- \0 · 1 B · @16 — terminador NUL
- más entradas · … · @17+
| Campo | Offset | Tamaño |
|---|---|---|
| cadena A | @0 | 6 B |
| \0 | @6 | 1 B |
| cadena B | @7 | 9 B |
| \0 | @16 | 1 B |
| más entradas | @17+ | … |
kPool es un solo blob: las entradas van pegadas, cada una terminada en NUL. Un offset es solo el byte donde empieza una cadena C — por eso tr() puede devolver un const char* corriente.
El pool de arriba contiene el texto — y se mantiene más o menos igual de grande tanto si lo empaquetas como si no. El cambio que de verdad compensa está un nivel por encima, en el índice: cada entrada que antes era un puntero de 4 bytes pasa a ser un offset de 2 bytes.
| Region | Segment | Size |
|---|---|---|
| puntero | const char* (una dirección) | 4 B |
| offset | uint16_t | 2 B |
El mismo texto en el pool de cualquier forma — lo que cambia es el índice: cada entrada baja de un puntero de 4 bytes a un offset de 2 bytes, reduciendo a la mitad toda la tabla.
Generación en build, lookup en runtime
La salida generada son dos constexpr std::array: el blob, y un array anidado de offsets uint16_t dentro de él. Aquí va un extracto real — fíjate en cómo, en la tabla de offsets, BRAND ("Kleidos") y CommonOk ("OK") tienen el mismo offset en todos los idiomas (se deduplican a una sola copia almacenada), mientras que CommonCancel diverge por idioma:
// All translations concatenated into one flash blob — deduplicated and
// tail-merged, emitted longest-first.
constexpr std::array<char, 13531> kPool = {
"A:avanti B:modifica tieni B:indietro\0" // @0
"A:weiter B:öffnen halten B:zurück\0" // @39
"A:weiter B:bearb. halten B:zurück\0" // @77
"j/k:nav Espace:ouvrir S:réglages\0" // @114
// ... ~1,000 more entries (longest-first) ...
// "OK" lands at @1164 (shared by all five languages); "Kleidos" at @9379.
};
// 2-byte offsets into kPool instead of 4-byte pointers. Identical strings
// collapse to one offset; a suffix points partway into a longer entry.
constexpr std::array<std::array<uint16_t, kStringCount>, kLangCount> kOffsets = {{
// BRAND CommonOk CommonCancel (… 266 more)
/* EN */ {{ 9379, 1164, 12542, /* … */ }}, // Kleidos · OK · "Cancel"
/* ES */ {{ 9379, 1164, 11279, /* … */ }}, // Kleidos · OK · "Cancelar"
/* FR */ {{ 9379, 1164, 11901, /* … */ }}, // Kleidos · OK · "Annuler"
/* DE */ {{ 9379, 1164, 10295, /* … */ }}, // Kleidos · OK · "Abbrechen"
/* IT */ {{ 9379, 1164, 11909, /* … */ }}, // Kleidos · OK · "Annulla"
}};
static_assert(kPool.size() <= UINT16_MAX, "pool exceeds uint16_t offset range");Varias cosas hacen que esto sea seguro y barato de adoptar:
- Un offset apunta al inicio de una cadena terminada en NUL, así que el
tr()público sigue devolviendo unconst char*corriente. Cada punto de llamada queda sin cambios — la sustitución es invisible para el resto del firmware. - El lookup es apenas más caro que la tabla de punteros: una carga
uint16_tmás una suma (kPool.data() + offset), frente a una carga de puntero. Eso es una sola suma entera extra, sin una segunda indirección — no cambias flash por ciclos. - Cero coste de RAM. Tanto el pool como la tabla de offsets son
constexpr.rodata— viven enteramente en flash. El único estado en runtime es un índice de “idioma actual” de un byte. (También por eso elconst char*devuelto es seguro para siempre: apunta a flash inmortal, nunca se libera.) - Un
static_assert(static_assert en cppreference) garantiza, en tiempo de compilación, que el pool se mantiene por debajo de 64 KiB para que los offsetsuint16_tpuedan direccionarlo entero. Haz crecer la tabla más allá y el build falla de forma ruidosa en vez de truncar en silencio. std::array(std::array en cppreference) es un agregado con la misma disposición que unT[N]crudo — cero overhead frente al array C al que reemplaza, ydata()devuelve un puntero a almacenamiento contiguo, así quekPool.data() + offsetestá bien definido.- Como los offsets se emiten como literales enteros de C++ y se compilan para el objetivo, no hay problema de endianness ni de serialización — a diferencia de un formato de blob binario como el
.mode gettext.
| Region | Segment | Size |
|---|---|---|
| Básico (tabla de punteros) | blob de literales | ≈ 13,8 KB |
| Básico (tabla de punteros) | tabla de punteros (4 B) | ≈ 5,5 KB |
| Empaquetado (tabla de offsets) | kPool | ≈ 13,5 KB |
| Empaquetado (tabla de offsets) | kOffsets (2 B) | ≈ 2,7 KB |
El mismo texto de cualquier forma — los dos blobs quedan a 282 B uno del otro. La diferencia visible es el índice: una tabla de punteros de 5.460 B frente a una tabla de offsets de 2.730 B. Ambas viven enteramente en flash (.rodata) y gastan 0 RAM extra más allá de un índice de idioma de 1 byte.
Recuperar una cadena
Detrás de cada lookup hay dos funciones pequeñas. La de bajo nivel, gen::string(), hace el trabajo real — una carga uint16_t más una suma — y confía en quien la llama. La pública, tr(), añade la comprobación de rango y un fallback a inglés cuando la traducción está vacía, para que quien llama pueda renderizar el resultado sin condiciones:
namespace i18n::gen {
// Low-level accessor: one uint16 load + one add. No bounds check — caller-guaranteed.
const char* string(uint8_t lang, uint16_t index) {
return kPool.data() + kOffsets[lang][index];
}
} // namespace i18n::gen
namespace i18n {
// Public API: range-check, then fall back to English for an empty translation.
const char* tr(StringId id) {
const uint16_t index = static_cast<uint16_t>(id);
if (index >= kStringCount) return ""; // out of range → empty
const char* text = gen::string(currentLang(), index);
return text[0] != '\0' ? text : gen::string(/*EN*/ 0, index);
}
} // namespace i18nCada llamada a tr() recorre el mismo camino corto — desde un punto de llamada de la UI hasta una única lectura de flash mapeada en memoria, sin heap y sin copia a RAM:
- punto de llamada UItr(StringId)
- i18n::tr()rango + fallback a EN
- i18n::gen::string()kPool.data() + kOffsets[lang][idx]
- kOffsets · kPoolconstexpr .rodata
- Flash (XIP)mapeada en memoria · 0 RAM
Una carga uint16 y una suma a la base del pool — luego quien llama renderiza el const char* devuelto. Sin segunda indirección, sin buffer de decodificación.
Los puntos de llamada traen los nombres al scope una vez, así que se leen limpios — sin ruido de i18n::, StringId:: ni Lang::. Resuelve un StringId en una variable y úsalo, exactamente como antes:
using i18n::tr;
using enum i18n::StringId; // PinEnter, PopBattLow, CommonCancel, …
const char* prompt = tr(PinEnter); // resolve once…
display::drawString(prompt, cx, titleY); // …then draw the label
popup::toast(tr(PopBattLow), nullptr, CAUTION, 3000); // or inline, for a toast
// What tr() does under the hood — Spanish "Cancel" (lang ES = 1, CommonCancel = 2):
const char* cancel = i18n::gen::string(1, 2); // kPool.data() + 11279 → "Cancelar"Ese gen::string(1, 2) es exactamente lo que hace el desensamblado: la tabla básica haría un l32i de un puntero de 32 bits; el accessor empaquetado hace un l16ui de un offset de 16 bits y lo suma a la base del pool. Ambos son una sola carga de tabla — no hay indirección extra — y la tabla de offsets, al ser la mitad de grande, toca menos líneas de D-cache en un redibujado de menú frecuente. El contrato (tr() devuelve un const char* prestado, válido durante toda la vida del programa) es lo que permite que el cambio de formato sea invisible para los 210 puntos de llamada de tr() repartidos por la UI, popups, onboarding y el portal de administración: ni uno cambió.
Dedup y tail-merge: el núcleo del generador
El empaquetado en sí son una docena de líneas de Python. El truco es colocar las cadenas de la más larga a la más corta y, para cada una, comprobar si sus bytes (más el NUL) ya aparecen en alguna parte del blob construido hasta ese momento. Si aparecen, es un duplicado o un sufijo de algo ya colocado — reutiliza esa posición. Si no, la añade al final.
# Unique strings, sorted longest-first so suffixes collapse onto longer strings.
uniq = sorted(
{entry[lang] for _, entry in rows for lang in LANGS},
key=lambda s: (-len(s.encode("utf-8")), s),
)
blob = bytearray()
offsets = {}
for s in uniq:
needle = s.encode("utf-8") + b"\0"
pos = bytes(blob).find(needle) # already present (identical OR a tail)?
if pos >= 0:
offsets[s] = pos # reuse — dedup or tail-merge
else:
offsets[s] = len(blob) # new string — append
blob += needleOrdenar de la más larga a la más corta es lo que hace que el tail-merge salga gratis: cuando le toca el turno a una cadena corta, cualquier cadena más larga que termine con ella ya está colocada, así que find() localiza la cola compartida. Es la misma forma que un formato de hace décadas — los .mo compilados de GNU gettext almacenan las traducciones exactamente como este tipo de tabla de offsets sobre un bloque de cadenas contiguo (el formato .mo de gettext).
En la tabla actual — que ha crecido hasta 273 IDs de cadena y subiendo — el efecto es concreto:
| Etapa | Cadenas distintas | Tamaño del pool |
|---|---|---|
| Todas las celdas | 1.365 | Cada par (idioma, ID) |
| Tras dedup | 1.071 | 13.813 B — "Kleidos" ×15, "OK" ×14 → almacenadas una vez cada una |
| Tras tail-merge | 1.071* | 13.531 B — "Tresor gelöscht" reutiliza la cola de "Verlust = Tresor gelöscht" |
Fíjate en las proporciones: el dedup colapsa 1.365 celdas a 1.071 cadenas distintas (−294); el tail-merge solapa luego sufijos, recortando otros 282 bytes (13.813 → 13.531 B). En texto de UI real, las cadenas totalmente idénticas (nombres de marca, "OK", etiquetas compartidas) son mucho más comunes que los sufijos compartidos — así que el dedup hace el trabajo pesado y el tail-merge es una pequeña propina encima. Encaja con la tesis de honestidad: la mejora titular es reducir el índice a la mitad, no el blob — al que, como mediremos, el linker se acerca por su cuenta hasta quedar a solo 282 bytes.
Esa propina sí plantea una pregunta justa: si "Tresor gelöscht" nunca se almacena por su cuenta, ¿cómo se recupera? Su offset simplemente apunta a un punto interior de la entrada más larga con la que comparte bytes. Como el blob termina en NUL, leer desde ese offset da exactamente la cadena más corta — sin caso especial, el mismo kPool.data() + offset que todo lo demás:
¿Quieres probarlo con tus propios datos? El string-pool packer toma una lista de cadenas (o un CSV de IDs × idiomas), muestra el coste de la tabla de punteros vs el pool empaquetado, y revela el offset de cada cadena — incluido cómo una con tail-merge se resuelve dentro de su vecina más larga.
Lo que ahorró de verdad
Estos son los números del build real sticks3 (5 idiomas, 273 IDs), empaquetado frente a la tabla de punteros básica, ambos en el -Os de producción:
| Métrica | Antes | Después | Cambio |
|---|---|---|---|
| Tabla índice | 5.460 B | 2.730 B | -50% |
| datos i18n (pool + tabla) | 19.273 B | 16.261 B | -15,6% |
| Imagen de firmware | 1.564.784 B | 1.562.560 B | -0,1% |
Básico (antes) vs empaquetado (después) en -Os. El índice se reduce a la mitad con un −2.730 B (−50%) constante; los datos i18n bajan −3.012 B; todo el firmware encoge −2.224 B — todas las métricas a favor del diseño empaquetado.
Reducir el índice a la mitad es la mejora limpia y estructural — un −2.730 B (−50%) constante que, como veremos, no se mueve con el nivel de optimización. Pero fíjate en que el delta del firmware (−2.224 B) es más pequeño que el delta de datos (−3.012 B), y hay una razón honesta:
Como la .rodata en flash del ESP32 está mapeada en memoria (execute-in-place), el firmware lee el pool con cargas normales — kPool.data() + offset es un simple desreferenciado de puntero, sin nada del baile de pgm_read_* que necesita el PROGMEM clásico de AVR.
Una dirección por celda — o ninguna
Hay una segunda diferencia estructural que los recuentos de bytes no muestran. Una tabla de punteros son datos con direcciones constantes: cada celda contiene la dirección absoluta de una cadena, y cada dirección es una relocation que el linker debe resolver. La tabla de offsets contiene enteros uint16_t sin más — independientes de la posición, nada que fijar.
| Métrica | Antes | Después | Cambio |
|---|---|---|---|
| relocations | 1.365 | 0 | -100% |
Básico (antes): un registro R_XTENSA_32 por celda — 1.365 en total, 16.380 B de .rela en el objeto. Empaquetado (después): la tabla de offsets son enteros, así que su sección de datos no lleva ninguna relocation (las únicas dos en el lado empaquetado son las cargas de dirección base del accessor — una constante que no escala).
En este firmware es una señal estructural limpia, no un coste en runtime. La imagen está enlazada estáticamente y es execute-in-place, así que el linker resuelve los 1.365 registros básicos a direcciones absolutas en tiempo de enlace — ninguno de los dos formatos arrastra relocations al .bin final, y lo que sobrevive son exactamente los +2.730 B de la tabla de punteros. Pero el conteo es el indicador honesto de “cuántas direcciones hay que materializar”: el empaquetado es O(1) (dos cargas de base en el accessor), el básico es O(celdas) — una por cadena, creciendo con cada ID y cada idioma que añades. También deja la tabla independiente de la posición, que es el único sitio donde se gana el sueldo en un dispositivo que se actualiza en campo: con delta OTA (enviar un diff binario) una tabla de punteros absolutos cambia cada vez que algo aguas arriba en flash desplaza sus direcciones, inflando el parche, mientras que los offsets enteros se quedan quietos — una ventaja estructural, aunque no he medido los tamaños de parche.
¿Escala?
Sí — y la parte que importa es aritmética exacta, no una conjetura. Cada ahorro es una fórmula cerrada en el número de celdas N, que no es más que la forma de tu CSV:
N (celdas) = ids × langs ← filas del CSV × columnas de idioma
tabla punteros = 4 × N bytes ← un puntero de 32 bits por celda
tabla offsets = w × N bytes ← w = 2 (uint16, pool ≤ 64 KiB)
4 (uint32, pools mayores)
tabla ahorrada = (4 − w) × N bytes ← (un uint24 a mano haría w = 3)
relocations = N eliminadas ← una por celda de punteroPor tanto, los números de tabla ahorrada y de relocations están calculados, no asumidos — los lees directamente de ids × langs. El único dato estimado es qué ancho de offset w aplica, porque eso depende del tamaño del pool; pasado el punto medido de hoy (273 IDs → 13.531 B, ≈ 50 B/ID) el tamaño del pool se extrapola linealmente. Proyectando (con w indicado por fila):
| IDs de cadena | Celdas | Tabla punteros | Tabla offsets | Tabla ahorrada | Relocations borradas |
|---|---|---|---|---|---|
| 273 (hoy) | 1.365 | 5.460 B | 2.730 B (uint16) | 2.730 B | 1.365 |
| 500 | 2.500 | 10.000 B | 5.000 B (uint16) | 5.000 B | 2.500 |
| 1.000 | 5.000 | 20.000 B | 10.000 B (uint16) | 10.000 B | 5.000 |
| 2.000 | 10.000 | 40.000 B | 40.000 B (uint32)† | 0 B | 10.000 |
| 10.000 | 50.000 | 200.000 B | 200.000 B (uint32)† | 0 B | 50.000 |
uint16 ya no llega al extremo lejano y salta el static_assert(kPool.size() <= UINT16_MAX). El arreglo mínimo del generador es un offset uint32 — que iguala en ancho al puntero de 32 bits, así que la ventaja de tabla desaparece y solo siguen rindiendo las relocations eliminadas y el blob con tail-merge. (Empaquetar offsets uint24 de 3 bytes recuperaría una ventaja de 1 byte por celda — direccionan 16 MB — pero el generador no los emite hoy.)Así que la respuesta honesta sobre el escalado tiene dos mitades. La eliminación de relocations escala sin límite — una por celda, siempre — 50.000 fuera con 10.000 IDs. El −50% de tabla tiene techo: solo se mantiene mientras el pool cabe en un offset de 16 bits (≲ 1.300 IDs con este perfil de texto), y pasado eso un offset uint32 iguala el ancho del puntero, así que el ahorro de índice se aplana a cero y el valor pasa por completo a la independencia de posición (relocations, buen encaje con delta-OTA) y al blob con tail-merge. Ese pool de 64 KiB es justo la frontera que el static_assert te obliga a reconsiderar — no un punto donde la técnica deja de ayudar, sino donde su forma tiene que cambiar.
Otros MCUs: a menudo una mejora mayor que aquí
Nada de esto es específico del ESP32 — el índice es una tabla de punteros frente a una tabla de enteros, así que la mejora depende de dos cosas: lo ancho que sea un puntero en tu objetivo, y lo ajustado que esté tu presupuesto de flash. Un offset uint16 son los mismos 2 bytes en todas partes, así que ahorra más cuanto mayor sea el puntero al que sustituye:
| Objetivo | Puntero en flash | Ahorro del offset uint16 / celda |
|---|---|---|
| AVR 8 bits ≤ 64 KB (ATmega328) | 2 B | 0 B — ya son 2 B; solo ayudan dedup + tail-merge |
| AVR 8 bits, far pointers (ATmega2560) | 3 B | −1 B (o un offset uint24) |
| Cortex-M 32 bits — STM32, RP2040, nRF | 4 B | −2 B (−50%) — idéntico a aquí |
| RISC-V 32 bits (ESP32-C3, GD32V) | 4 B | −2 B (−50%) |
| 64 bits (procesador de aplicación Cortex-A / SBC) | 8 B | −6 B (−75%) |
Así que en cualquier Cortex-M de 32 bits — un STM32 pequeño, el RP2040 de una Raspberry Pi Pico, un nRF de Nordic — obtienes exactamente el −50% medido aquí sin portar nada; en un objetivo de 64 bits la tabla de offsets es una cuarta parte del tamaño de la tabla de punteros.
La palanca mayor, sin embargo, es qué fracción de flash recuperas. Esos mismos 2,7 KB son un error de redondeo en un ESP32-S3 de 8–16 MB, pero son ~1% de un STM32G0 de 256 KB y ~4% de un STM32L0 de 64 KB — ahí, la tabla es la diferencia entre que quepan tus traducciones o no. Así que la respuesta honesta a “¿merecen la pena 2 KB?” depende por completo del chip: apenas, aquí; decisivamente en una pieza con poca flash y varios idiomas. Si vas a poner texto de UI en cinco idiomas en un microcontrolador con decenas de KB de flash, es justo aquí donde se gana su sitio — y la libertad de relocations importa más allí también, en cualquier objetivo que distribuya imágenes reubicables o actualizaciones delta-OTA.
Lo que ningún optimizador hará por ti
El título no es retórico. Antes de fiarme de la mejora, comprobé si un toolchain más listo podía cerrar la brecha — en cada nivel de optimización, y con los dos pases que en teoría podrían: link-time optimization y fusión agresiva de constantes.
Los datos empaquetados son byte a byte idénticos en todos los niveles -O
Extrae kPool y kOffsets del objeto en -O0, -O1, -Os, -O2 y -O3 y haz su hash: un único hash distinto cada uno. El compilador emite los arrays tal cual — nunca los reempaqueta, realinea ni fusiona — así que los datos i18n empaquetados son byte a byte idénticos en todos los niveles: 13.531 B de pool + 2.730 B de tabla = 16.261 B, siempre. Elegir -O es indiferente para los datos i18n; solo mueve código y el alineamiento de literales del lado básico. (Subir toda la imagen a -O2 por sí mismo añadiría unos 160 KB de flash para cero beneficio i18n — -Os es el valor correcto por defecto.)
El empaquetado gana en todos los niveles — y -Os es el margen más pequeño
Enlacé todo el firmware de las dos formas en los cinco niveles. El empaquetado es más pequeño siempre:
-O | firmware.bin empaquetado | firmware.bin básico | ahorro empaquetado |
|---|---|---|---|
-O0 | 1.812.736 B | 1.817.312 B | +4.576 B |
-O1 | 1.622.688 B | 1.626.656 B | +3.968 B |
-Os (enviado) | 1.562.560 B | 1.564.784 B | +2.224 B |
-O2 | 1.723.040 B | 1.726.864 B | +3.824 B |
-O3 | 1.716.576 B | 1.720.544 B | +3.968 B |
Ningún nivel empata, ninguno favorece al básico. El margen más pequeño está en el -Os de producción (+2.224 B) — el único nivel donde el dedup de literales .str1.1 del linker deja el blob básico casi tan apretado como nuestro pool, así que la brecha se estrecha hasta más o menos la diferencia de tabla por sí sola. Todos los demás niveles la ensanchan: los literales básicos se inflan (padding de alineamiento a 4 en -O1/-O2/-O3, sin fusión en -O0) mientras nuestras tablas nunca se mueven.
Ni siquiera LTO cierra la brecha
La link-time optimization es el único pase que podría, en principio, hacer la fusión cruzada que el linker normal no puede. Apliqué -flto al componente del proyecto — donde viven la tabla i18n y todo lo que la llama — y, por si acaso, el no estándar -fmerge-all-constants:
| Build | firmware.bin | tabla i18n | Δ vs su baseline |
|---|---|---|---|
| empaquetado (baseline) | 1.562.560 B | 2.730 B | — |
empaquetado + -flto | 1.562.560 B | 2.730 B | 0 B |
| básico (baseline) | 1.564.784 B | 5.460 B | — |
básico + -flto | 1.564.720 B | 5.460 B | −64 B |
básico + -fmerge-all-constants | 1.564.160 B | 5.460 B | −624 B |
LTO recorta unos insignificantes 64 B de la imagen básica, 0 B de la empaquetada y — esta es la clave — deja la tabla de punteros de 5.460 B intacta. Puede fusionar código y plegar constantes, pero no puede convertir una tabla de punteros de 32 bits en una tabla de offsets de 16 bits, ni puede hacer tail-merge de sufijos de cadena. Incluso -fmerge-all-constants — que es no conforme, ya que puede fusionar objetos distintos que casualmente comparten valor y romper la identidad de puntero — solo recorta el blob en 624 B y aun así pierde por +1.600 B. La mejora estructural sobrevive a todos los optimizadores que le eché, que es justo la idea: la forma de tu índice es lo único que el toolchain no elegirá por ti.
Por qué generarlo en tiempo de compilación
Nada de esto valdría la pena mantenerlo a mano — y mantenerlo a mano sería el bug. Las traducciones viven en un simple strings.csv (una fila por ID, una columna por idioma: fácil de comparar en diffs, editable por alguien que no programa, y añadir un idioma es añadir una columna). Un generador lo convierte en C++, conectado como un hook pre-build de PlatformIO:
[env]
extra_scripts = pre:scripts/gen_i18n.pyEl prefijo pre: (la opción extra_scripts de PlatformIO) ejecuta el script antes del build de la plataforma, así que el .cpp/.h generado siempre está sincronizado con el CSV antes de compilar. El script escribe su salida solo cuando el contenido cambia de verdad, así que no dispara recompilaciones innecesarias, y es una parte documentada del scripting avanzado de PlatformIO, no un añadido. El CSV es la única fuente de verdad; los archivos generados nunca se editan a mano.
Aquí también es donde rinde la propiedad de “antes de compilar, sin importar -O2”: el empaquetado es determinista y vive en el código fuente que subes al repositorio. (La clave de ordenación es (-byte_length, string) — ese segundo término rompe empates lexicográficamente, dando un orden total, así que el mismo CSV produce siempre una salida byte a byte idéntica con independencia del orden de iteración del hash-set.) Los ahorros no dependen del nivel de optimización con que se ejecute el enlace final.
Añadir un idioma o una cadena
Todo el flujo sigue siendo editar un CSV — sin tocar C++ a mano:
- Añade una columna para un idioma nuevo (o una fila para un ID de cadena nuevo) a
strings.csv. - Compila. El hook
pre:regenerastrings_gen.{h,cpp}, así que el nuevo valor del enumStringIdy sus offsets por idioma aparecen automáticamente. - Úsalo:
tr(StringId::MyNewLabel). Una traducción que falta no es un crash ni un hueco —tr()recae en la columna inglesa, así que un idioma a medio traducir sigue renderizando.
El generador completo
El bucle de empaquetado de arriba es el corazón, pero el generador completo vale la pena leerlo de cabo a rabo: el parseo del CSV con fallback a inglés, el enum StringId y la emisión de la cabecera, la salida estable con clang-format, y el guard write_if_changed que evita recompilaciones innecesarias. Descárgalo, o despliégalo abajo.
El gen_i18n.py completo
#!/usr/bin/env python3
# Kleidos — i18n codegen.
#
# Reads i18n/strings.csv and emits:
# src/i18n/Strings_gen.h (StringId enum + gen::string accessor decl)
# src/i18n/Strings_gen.cpp (packed, tail-merged string pool + offset table)
#
# Hooked from platformio.ini as `extra_scripts = pre:scripts/gen_i18n.py`.
# Stays a no-op when the generated files are already up-to-date.
import csv
import os
import sys
from pathlib import Path
# `__file__` may not be defined under SCons exec(); fall back to PROJECT_DIR.
try:
SCRIPT_DIR = Path(__file__).resolve().parent
except NameError:
SCRIPT_DIR = Path(os.environ.get("PROJECT_DIR", os.getcwd())) / "scripts"
ROOT = SCRIPT_DIR.parent
CSV_PATH = ROOT / "i18n" / "strings.csv"
HDR_PATH = ROOT / "src" / "i18n" / "Strings_gen.h"
CPP_PATH = ROOT / "src" / "i18n" / "Strings_gen.cpp"
LANGS = ["en", "es", "fr", "de", "it"]
def cpp_escape(s: str) -> str:
out = []
for ch in s:
if ch == "\\":
out.append("\\\\")
elif ch == '"':
out.append('\\"')
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
else:
out.append(ch)
return "".join(out)
def parse_csv(path: Path):
"""Return list of (id, {lang: text}). Skips comment rows starting with '#'."""
rows = []
with path.open("r", encoding="utf-8", newline="") as f:
reader = csv.reader(f)
header = next(reader, None)
if not header or header[0].lower() != "id":
raise SystemExit(f"i18n: missing or unexpected header {header}")
col = {h.lower(): i for i, h in enumerate(header)}
# English is the fallback column, so it must exist before parsing rows.
if "en" not in col:
raise SystemExit("i18n: CSV must contain an 'en' column")
for r in reader:
if not r or not r[0].strip() or r[0].strip().startswith("#"):
continue
sid = r[0].strip()
entry = {}
for lang in LANGS:
idx = col.get(lang)
val = r[idx] if (idx is not None and idx < len(r)) else ""
# Fall back to English when a translation is missing.
entry[lang] = val if val.strip() else (entry.get("en") or r[col["en"]])
rows.append((sid, entry))
return rows
def render_header(rows):
lines = []
lines.append("// AUTO-GENERATED by scripts/gen_i18n.py — DO NOT EDIT MANUALLY.")
lines.append("// Regenerated from i18n/strings.csv on every build.")
lines.append("#pragma once")
lines.append("#include <cstdint>")
lines.append("")
lines.append("namespace i18n {")
lines.append("")
lines.append("enum class StringId : uint16_t {")
for sid, _ in rows:
lines.append(f" {sid},")
lines.append(" COUNT")
lines.append("};")
lines.append("")
lines.append("constexpr uint16_t kStringCount = static_cast<uint16_t>( StringId::COUNT );")
lines.append("constexpr uint8_t kLangCount = 5; // EN, ES, FR, DE, IT")
lines.append("")
lines.append("namespace gen {")
lines.append("")
lines.append("/**")
lines.append(" * @brief Return translation (@p lang, @p index) as a NUL-terminated string.")
lines.append(" *")
lines.append(" * Points into the packed flash pool; the returned pointer stays valid for")
lines.append(" * the program lifetime. No bounds checking — callers must ensure")
lines.append(" * @p lang < @c kLangCount and @p index < @c kStringCount.")
lines.append(" */")
lines.append("const char* string( uint8_t lang, uint16_t index );")
lines.append("")
lines.append("} // namespace gen")
lines.append("")
lines.append("} // namespace i18n")
lines.append("")
return "\n".join(lines)
def build_pool(rows):
"""Build a tail-merged string pool.
Returns (emit, offsets) where `emit` is the list of strings appended to the
pool in order (each contributes its UTF-8 bytes + a NUL) and `offsets` maps
every unique string to its byte offset into the concatenated blob.
Strings are placed longest-first so that any string which is the suffix of
another collapses onto the longer one's bytes (e.g. "ancel" reuses the tail
of "Cancel"); identical strings are deduplicated outright.
"""
uniq = sorted(
{entry[lang] for _, entry in rows for lang in LANGS},
key=lambda s: (-len(s.encode("utf-8")), s),
)
blob = bytearray()
offsets = {}
emit = []
for s in uniq:
needle = s.encode("utf-8") + b"\0"
pos = bytes(blob).find(needle)
if pos >= 0:
offsets[s] = pos
else:
offsets[s] = len(blob)
blob += needle
emit.append(s)
return emit, offsets, len(blob)
def render_cpp(rows):
emit, offsets, blob_len = build_pool(rows)
# +1 keeps the string literal's implicit terminator so the array size is not
# one short of the initializer (which -Werror rejects).
pool_size = blob_len + 1
lines = []
lines.append("// AUTO-GENERATED by scripts/gen_i18n.py — DO NOT EDIT MANUALLY.")
lines.append('#include "Strings_gen.h"')
lines.append("")
lines.append("#include <array>")
lines.append("#include <cstdint>")
lines.append("")
lines.append("namespace i18n {")
lines.append("namespace gen {")
lines.append("")
lines.append("// Packed translation pool: all strings concatenated into one flash blob,")
lines.append("// deduplicated and tail-merged (a string that is the suffix of another")
lines.append("// shares its bytes). Adjacent string literals concatenate; the offsets")
lines.append("// below index into the resulting bytes.")
lines.append("// clang-format off")
lines.append(f"constexpr std::array<char, {pool_size}> kPool = {{")
running = 0
for s in emit:
lines.append(f' "{cpp_escape(s)}\\0" // @{running}')
running += len(s.encode("utf-8")) + 1
lines.append("};")
lines.append("// clang-format on")
lines.append("")
lines.append("// Per-language byte offsets into kPool. uint16_t keeps this table half the")
lines.append("// size of a 32-bit pointer table and free of load-time relocations.")
lines.append("// clang-format off")
lines.append(
"constexpr std::array<std::array<uint16_t, kStringCount>, kLangCount> kOffsets = { {"
)
for lang in LANGS:
offs = [offsets[entry[lang]] for _, entry in rows]
lines.append(f" /* {lang.upper()} */ {{ {{")
for i in range(0, len(offs), 12):
chunk = ", ".join(str(o) for o in offs[i : i + 12])
lines.append(f" {chunk},")
lines.append(" } },")
lines.append("} };")
lines.append("// clang-format on")
lines.append("")
lines.append(
'static_assert( kPool.size() <= UINT16_MAX, "pool exceeds uint16_t offset range" );'
)
lines.append("")
lines.append("const char* string( uint8_t lang, uint16_t index ) {")
lines.append(" return kPool.data() + kOffsets[lang][index];")
lines.append("}")
lines.append("")
lines.append("} // namespace gen")
lines.append("} // namespace i18n")
lines.append("")
return "\n".join(lines)
def write_if_changed(path: Path, content: str) -> bool:
"""Write only if content differs (avoids triggering recompilation)."""
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
old = path.read_text(encoding="utf-8")
if old == content:
return False
path.write_text(content, encoding="utf-8")
return True
def generate():
if not CSV_PATH.exists():
print(f"i18n: missing {CSV_PATH}", file=sys.stderr)
return False
rows = parse_csv(CSV_PATH)
if not rows:
print("i18n: CSV produced no rows", file=sys.stderr)
return False
h_changed = write_if_changed(HDR_PATH, render_header(rows))
c_changed = write_if_changed(CPP_PATH, render_cpp(rows))
if h_changed or c_changed:
print(f"i18n: regenerated {len(rows)} ids x {len(LANGS)} langs")
return True
# PlatformIO entry point.
try:
Import("env") # type: ignore[name-defined] # noqa: F821
generate()
except NameError:
# Standalone CLI invocation.
if __name__ == "__main__":
generate()Los caminos no tomados
Algunas alternativas parecían tentadoras y se descartaron por razones concretas:
| Enfoque | Por qué no |
|---|---|
| Mantener la tabla de punteros | Lo más simple, pero gasta los 2,7 KB de overhead de índice que la tabla de offsets recupera — y mantiene las 1.365 relocations que los offsets borran. |
Tabla de std::string_view | ”Más moderno”, pero cada view es {ptr, size} = 8 bytes en un objetivo de 32 bits — duplicaría la tabla de punteros a ~10,9 KB (4× la tabla de offsets) por una longitud que tr() nunca necesita. |
| X-macros | Codegen elegante todo-en-C, pero se apoya enteramente en macros tipo función, lo que viola la regla AUTOSAR C++14 A16-0-1 (ahora parte de MISRA C++:2023): el preprocesador es solo para includes y compilación condicional. |
| Compresión en runtime | Huffman por cadena ahorra más flash pero necesita un buffer de decodificación en RAM y rompe el contrato “tr() devuelve un puntero estable” del que dependen los puntos de llamada. No vale la pena para ~13 KB de texto. |
La opción de std::string_view es la tentadora — es el tipo de vocabulario moderno — así que vale la pena ver exactamente lo que cuesta por entrada:
- ptr · const char* · 4 B · @0
- size · size_t · 4 B · @4
sizeof = 8 B
| Member | Offset | Detail |
|---|---|---|
| ptr | 0 | const char* · 4 B |
| size | 4 | size_t · 4 B |
Un std::string_view es {puntero, longitud} = 8 bytes por celda — cuatro veces el offset de 2 bytes — para llevar una longitud que tr() nunca lee.
La disposición empaquetada, además, cumple mejor los objetivos de análisis estático del proyecto que el array C al que reemplazó: std::array en todo (en vez de arrays estilo C, según AUTOSAR A18-1-1), una comprobación de límites en tiempo de compilación vía static_assert, y sin trucos de preprocesador.
No inventé esto
En el espíritu de la contabilidad honesta: la forma que “descubrí” — un blob de cadenas contiguo más una tabla de offsets dentro de él — es una de las disposiciones más reutilizadas del software. Es como se han enviado las cadenas localizadas y de metadatos durante décadas:
| Sistema | Almacenado como blob + tabla de offset/índice |
|---|---|
GNU gettext .mo | Cada traducción es una longitud + un offset de 32 bits dentro de un bloque de cadenas — el precedente canónico de i18n. |
Android resources.arsc (ResStringPool) | Las cadenas de UI de tu app como un bloque más una tabla de offsets de 32 bits. |
Ensamblados .NET (heap #Strings de ECMA-335) | Un blob de cadenas UTF-8 deduplicado, direccionado por offset. |
Archivos .class de Java (constant pool) | Cada Utf8 almacenado una vez, referenciado por un índice de 2 bytes. |
| Resource bundles de ICU | Cadenas indexadas por offset, con un pool.res compartido que deduplica entre bundles. |
El compilador de Rust (Symbol de rustc) | Una cadena internada (interned) es un índice de 32 bits dentro de una arena — no un puntero. |
Lo que sí puedo reivindicar es la composición y un recorte específico de firmware. La mayoría de esos formatos usan offsets o índices de 32 bits, dimensionados para datos a escala de escritorio; en un pool de MCU por debajo de 64 KiB el offset cabe en un uint16, que es toda la mejora de tabla del −50%. La libertad de relocations tampoco es mía — sale de indexar con un entero en vez de un puntero, el patrón general de string interning, y la misma razón por la que existe RELR para comprimir las relocations de punteros que todos los demás siguen pagando. El tail-merge, como admitió la introducción, ya lo hace el linker. La única contribución real es darme cuenta de que las tres se aplican a la vez a un MCU de dos botones — y conectarlas de modo que el CSV siga siendo la única fuente de verdad.
Pruébalo, y la lección
- Mide antes de optimizar. El linker ya hacía casi todo lo que me propuse “inventar”.
- La forma de tu índice es tuya. Punteros frente a offsets es una elección estructural que ningún toolchain — ni siquiera LTO — hará por ti, y arrastra las relocations consigo (1.365 → 0).
- Hazte cargo de la optimización por adelantado cuando no quieres que dependa de un flag del linker — el codegen la hace determinista, y los datos empaquetados son byte a byte idénticos de
-O0a-O3. - Mantén la fuente cómoda para las personas. Un CSV más un generador supera a una tabla escrita a mano que acabarás desincronizando.
- Haz las cuentas con honestidad. El titular (−50% de índice) es real; el delta de firmware (−2.224 B en
-Os) es menor porque devuelves 500 B de dedup cruzado — y en-Osel linker ya deja el blob a 282 B, así que la tabla + relocations es la mejora que sostiene el peso, no el texto.
Esto salió de Kleidos, un gestor de contraseñas hardware que estoy construyendo — todavía no publicado. La capa i18n es un rincón pequeño de él, pero es un buen recordatorio de que en un objetivo limitado los ahorros interesantes a menudo no están en los datos que almacenas, sino en cómo los indexas.
Preguntas frecuentes
¿Qué es un pool de cadenas i18n empaquetado?
Sustituye la tabla básica de punteros const char* por dos piezas de datos generados: un único blob de flash con cada traducción terminada en NUL, y una tabla por idioma de offsets uint16_t dentro de ese blob. Un offset es solo el byte donde empieza una cadena C, así que tr() sigue devolviendo un const char* corriente.
¿Por qué usar offsets uint16_t en vez de una tabla de punteros?
En un MCU de 32 bits cada const char* ocupa 4 bytes, así que una rejilla de 5×273 cuesta 5.460 bytes de puro direccionamiento. Un offset uint16_t ocupa 2 bytes, reduciendo la tabla índice a la mitad, a 2.730 bytes (−50%) — una mejora estructural que ningún linker, ni siquiera LTO, hará por ti.
¿No deduplica y hace tail-merge el linker ya por su cuenta?
Sí — GNU ld hace tail-merge de cadenas idénticas y de sufijos por defecto, y ld.lld lo hace con -O2. Por eso el blob empaquetado es solo unos 282 B más pequeño que lo que produce el linker. La mejora real, a prueba de optimizador, es estructural: reducir la tabla índice a la mitad y borrar las relocations, algo que el linker no puede hacer.
¿Cuánta RAM usa el pool de cadenas?
Cero, más allá de un índice de idioma actual de un byte. Tanto el pool como la tabla de offsets son constexpr .rodata, así que viven enteramente en flash; en el ESP32 están mapeados en memoria (execute-in-place) y se leen con cargas normales.
¿Cuánto firmware ahorró el empaquetado en realidad?
El firmware encogió 2.224 B netos en el -Os de producción, sin cambios de API ni en los puntos de llamada. El ahorro es menor que el −2.730 B de reducir el índice a la mitad porque agrupar en un pool renuncia a unos 500 B de deduplicación cruzada del linker; la mejora neta la sostienen la tabla a la mitad y las 1.365 relocations borradas, no el blob.
¿Escala la técnica y funciona en otros MCUs?
La eliminación de relocations escala sin límite (una por celda), pero el −50% de tabla solo se mantiene mientras el pool cabe en un offset de 16 bits (unos 1.300 IDs); pasado eso un offset uint32 iguala el ancho del puntero. No es específica del ESP32: cualquier Cortex-M de 32 bits obtiene el mismo −50%, y un objetivo de 64 bits ahorra −75% por celda.