Volver al Blog
José Manuel Requena Plens

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.

Imagen de portada de Lo que el linker no hará: empaquetar cadenas i18n en un MCU

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 -Os de 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 -O0 a -O3.
  • Giro inesperado: los linkers modernos ya hacen tail-merge de los literales de cadena (GNU ld por defecto, ld.lld con -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.

strings_gen.cpp (versión básica)
// 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 celdas Un 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 bytes 5 × 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:

Qué fusiona qué, y cuándo
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í — 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.

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.

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:

strings_gen.cpp (empaquetado)
// 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 un const 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_t má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 el const 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 offsets uint16_t puedan 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 un T[N] crudo — cero overhead frente al array C al que reemplaza, y data() devuelve un puntero a almacenamiento contiguo, así que kPool.data() + offset está 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 .mo de gettext.

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:

strings_gen.cpp + i18n.cpp
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 i18n

Cada 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:

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:

src/​ui/​… (puntos de llamada reales)
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.

scripts/​gen_i18n.py (el empaquetador)
# 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 += needle

Ordenar 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:

La tabla de hoy: 5 × 273 = 1.365 celdas
EtapaCadenas distintasTamaño del pool
Todas las celdas1.365Cada par (idioma, ID)
Tras dedup1.07113.813 B — "Kleidos" ×15, "OK" ×14 → almacenadas una vez cada una
Tras tail-merge1.071*13.531 B"Tresor gelöscht" reutiliza la cola de "Verlust = Tresor gelöscht"
*El tail-merge no elimina cadenas — siguen siendo todas distintas — solapa su almacenamiento, así que el recuento se queda en 1.071 mientras el de bytes baja.

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:

kPool.data() + 1034

kPool.data() + 1044 → 'Tresor gelöscht'

kOffsets[DE][OnbPinWarn]

= 1034

kOffsets[DE][PopVaultWiped]

= 1044

kPool — one NUL-terminated blob

@1034: 'Verlust = Tresor gelöscht'

Una cadena con tail-merge es solo un offset que apunta dentro de otra más larga

¿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:

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.

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 puntero

Por 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):

Ahorro proyectado según crece la tabla (× 5 idiomas)
IDs de cadenaCeldasTabla punterosTabla offsetsTabla ahorradaRelocations borradas
273 (hoy)1.3655.460 B2.730 B (uint16)2.730 B1.365
5002.50010.000 B5.000 B (uint16)5.000 B2.500
1.0005.00020.000 B10.000 B (uint16)10.000 B5.000
2.00010.00040.000 B40.000 B (uint32)†0 B10.000
10.00050.000200.000 B200.000 B (uint32)†0 B50.000
† Pasados unos 1.300 IDs el pool supera los 64 KiB, así que un offset 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:

La mejora del offset según el ancho de puntero del objetivo
ObjetivoPuntero en flashAhorro del offset uint16 / celda
AVR 8 bits ≤ 64 KB (ATmega328)2 B0 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, nRF4 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:

Firmware completo: empaquetado vs básico en cada nivel -O
-Ofirmware.bin empaquetadofirmware.bin básicoahorro empaquetado
-O01.812.736 B1.817.312 B+4.576 B
-O11.622.688 B1.626.656 B+3.968 B
-Os (enviado)1.562.560 B1.564.784 B+2.224 B
-O21.723.040 B1.726.864 B+3.824 B
-O31.716.576 B1.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:

¿Puede un optimizador más listo rescatar la tabla básica? (-Os)
Buildfirmware.bintabla i18nΔ vs su baseline
empaquetado (baseline)1.562.560 B2.730 B
empaquetado + -flto1.562.560 B2.730 B0 B
básico (baseline)1.564.784 B5.460 B
básico + -flto1.564.720 B5.460 B−64 B
básico + -fmerge-all-constants1.564.160 B5.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:

platformio.ini
[env]
extra_scripts = pre:scripts/gen_i18n.py

El 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ñadir una traducción
  1. Añade una columna para un idioma nuevo (o una fila para un ID de cadena nuevo) a strings.csv.
  2. Compila. El hook pre: regenera strings_gen.{h,cpp}, así que el nuevo valor del enum StringId y sus offsets por idioma aparecen automáticamente.
  3. Ú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.

gen_i18n.py Codegen i18n pre-build de PlatformIO · .py · 8,3 KB · 231 líneas
Descargar
El gen_i18n.py completo
scripts/​gen_i18n.py
#!/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:

Alternativas consideradas
EnfoquePor qué no
Mantener la tabla de punterosLo 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-macrosCodegen 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 runtimeHuffman 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:

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:

Dónde ya se usa esta forma
SistemaAlmacenado como blob + tabla de offset/índice
GNU gettext .moCada 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 ICUCadenas 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

Lo que me llevaría de esto
  • 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 -O0 a -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 -Os el 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.