Internado de cadenas en MicroPython

MicroPython usa el string interning (internado de cadenas) para ahorrar tanto RAM como ROM. Esto evita tener que almacenar copias duplicadas de la misma cadena. Principalmente, esto se aplica a los identificadores de tu código, ya que algo como el nombre de una función o de una variable es muy probable que aparezca en varios lugares del código. En MicroPython una cadena internada se denomina QSTR (uniQue STRing).

Un valor QSTR (de tipo qstr) es un índice dentro de una lista enlazada de grupos (pools) de QSTR. Los QSTR almacenan su longitud y un hash de su contenido para una comparación rápida durante el proceso de deduplicación. Todas las operaciones de bytecode que trabajan con cadenas usan un argumento QSTR.

Generación de QSTR en tiempo de compilación

En el código C de MicroPython, cualquier cadena que deba internarse en el firmware final se escribe como MP_QSTR_Foo. En tiempo de compilación esto se evalúa como un valor qstr que apunta al índice de "Foo" en el grupo de QSTR.

Un proceso de varios pasos en el Makefile hace que esto funcione. En resumen, este proceso tiene tres partes:

  1. Encontrar todos los tokens MP_QSTR_Foo en el código.

  2. Generar un grupo estático de QSTR que contenga todos los datos de las cadenas (incluyendo longitudes y hashes).

  3. Reemplazar todos los MP_QSTR_Foo (mediante el preprocesador) por su índice correspondiente.

Los tokens MP_QSTR_Foo se buscan en dos fuentes:

  1. Todos los archivos referenciados en $(SRC_QSTR). Esto incluye todo el código C (es decir, py, extmod, ports/stm32) pero no el código de terceros como lib.

  2. Los $(QSTR_GLOBAL_DEPENDENCIES) adicionales (que incluyen mpconfig*.h).

Nota: frozen_mpy.c (generado por mpy-tool.py) tiene su propia generación y grupo de QSTR.

Algunas cadenas adicionales que no pueden expresarse usando la sintaxis MP_QSTR_Foo (por ejemplo, porque contienen caracteres no alfanuméricos) se proporcionan explícitamente en qstrdefs.h y qstrdefsport.h mediante la variable $(QSTR_DEFS).

El procesamiento ocurre en las siguientes etapas:

  1. qstr.i.last es la concatenación resultante de pasar cada uno de los archivos de entrada por el preprocesador de C. Esto significa que cualquier código desactivado condicionalmente se eliminará y las macros se expandirán. Esto significa que no añadimos al grupo cadenas que no se usarán en el firmware final. Porque en esta etapa (gracias a la macro NO_QSTR añadida por QSTR_GEN_CFLAGS) no hay definición para MP_QSTR_Foo, este pasa por esta etapa sin verse afectado. Este archivo también incluye comentarios del preprocesador que contienen información sobre los números de línea. Ten en cuenta que este paso solo usa archivos que han cambiado, lo que significa que qstr.i.last solo contendrá datos de los archivos que hayan cambiado desde la última compilación.

  2. qstr.split es un archivo vacío creado tras ejecutar makeqstrdefs.py split sobre qstr.i.last. Solo se usa como dependencia para indicar que el paso se ejecutó. Este script genera un archivo por cada archivo C de entrada, genhdr/qstr/...file.c.qstr, que contiene únicamente los QSTR coincidentes. Cada QSTR se imprime como Q(Foo). Este paso es necesario para combinar los archivos existentes con los nuevos datos generados a partir de la actualización incremental en qstr.i.last.

  3. qstrdefs.collected.h es el resultado de concatenar genhdr/qstr/* usando makeqstrdefs.py cat. Este es ahora el conjunto completo de MP_QSTR_Foo encontrados en el código, ahora formateados como Q(Foo), uno por línea, con duplicados. Este archivo solo se actualiza si el conjunto de qstrs ha cambiado. Se escribe un hash de los datos de QSTR en otro archivo (qstrdefs.collected.h.hash) que permite rastrear los cambios entre compilaciones.

  4. Genera una enumeración, cada entrada de la cual asigna un MP_QSTR_Foo a su índice correspondiente. Concatena qstrdefs.collected.h con qstrdefs*.h y luego transforma cada línea de Q(Foo) a "Q(Foo)" para que pasen por el preprocesador sin cambios. Después se usa el preprocesador para tratar cualquier compilación condicional en qstrdefs*.h. Luego se deshace la transformación de vuelta a Q(Foo) y se guarda como qstrdefs.preprocessed.h.

  5. qstrdefs.generated.h es el resultado de makeqstrdata.py. Por cada Q(Foo) en qstrdefs.preprocessed.h (más algunos otros codificados directamente), genera QDEF(MP_QSTR_Foo, (const byte*)"hash" "Foo").

Después, en la compilación principal, ocurren dos cosas con qstrdefs.generated.h:

  1. En qstr.h, cada QDEF se convierte en una entrada de un enum, lo que hace que MP_QSTR_Foo esté disponible para el código y sea igual al índice de esa cadena en la tabla de QSTR.

  2. En qstr.c, la tabla real de datos de QSTR se genera como elementos de mp_qstr_const_pool->qstrs.

Generación de QSTR en tiempo de ejecución

Se pueden crear grupos adicionales de QSTR en tiempo de ejecución para poder añadirles cadenas. Por ejemplo, el código:

foo[x] = 3

Necesitará crear un QSTR para el valor de x para que pueda usarlo el bytecode «load attr».

Además, al compilar código Python, los identificadores y literales necesitan tener QSTR creados. Nota: solo los literales de menos de 10 caracteres se convierten en QSTR. Esto se debe a que una cadena normal en el montón (heap) siempre ocupa un mínimo de 16 bytes (un bloque de GC), mientras que los QSTR permiten empaquetarlos de forma más eficiente en el grupo.

Los grupos de QSTR (y los «chunks» subyacentes que almacenan los datos de las cadenas) se asignan bajo demanda en el montón (heap) con un tamaño mínimo.