Qué es Embedded Swift: el lenguaje sin su runtime
El subconjunto de Swift pensado para entornos sin runtime completo: por qué el dinamismo cuesta megabytes, cómo un modo de compilación con mundo cerrado y especialización total reduce el binario a kilobytes, qué asunciones cambia respecto al Swift de siempre y en qué clase de hardware acaba viviendo el resultado.
Swift nació con una promesa incómoda para el mundo embebido: ser un lenguaje compilado, rápido y con tipos estáticos, pero apoyado en un runtime que sabe demasiado. Ese runtime conoce el layout de cada tipo en tiempo de ejecución, sabe reflexionar sobre un valor cualquiera, sabe convertir dinámicamente y sabe ejecutar genéricos que jamás se especializaron. Todo eso se paga en bytes, y en un microcontrolador con 264 KB de RAM y 2 MB de flash no hay presupuesto para pagarlo. Embedded Swift es la respuesta: no un lenguaje nuevo ni un dialecto paralelo, sino un modo de compilación que recorta el dinamismo hasta que lo que queda cabe en un chip de dos dólares, y que a cambio te pide aceptar una asunción muy concreta sobre el mundo.
- Explicar qué componentes del runtime de Swift hacen imposible el binario diminuto y por qué existen.
- Distinguir Embedded Swift como modo de compilación frente a la idea errónea de dialecto separado.
- Describir la asunción de mundo cerrado y su consecuencia directa: la especialización obligatoria.
- Situar los entornos reales donde el modo tiene sentido, dentro y fuera del microcontrolador.
El impuesto del dinamismo
Un binario de Swift convencional no contiene solo tu código. Contiene, o enlaza contra, una maquinaria que responde preguntas en tiempo de ejecución: cuánto ocupa este tipo, cómo se copia, cómo se destruye, qué protocolos cumple, cuál es su nombre. Esa maquinaria son los metadatos de tipo, las tablas de testigos de valor y las tablas de testigos de protocolo, y existen por una razón perfectamente legítima: permiten compilar un genérico una sola vez y ejecutarlo sobre cualquier tipo, incluso uno que aparezca en otro módulo compilado después.
En macOS y en iOS ese coste es invisible porque el runtime y la biblioteca estándar viajan dentro del sistema operativo; tu aplicación solo aporta la parte propia. En cuanto sales de ese jardín la factura aparece entera. Un programa que solo imprime una línea, enlazado estáticamente en Linux, arrastra varios megabytes entre runtime, biblioteca estándar y metadatos, y ese número no baja apreciablemente por mucho que reduzcas el código propio: es un suelo, no una pendiente.
# Swift convencional, enlazado estatico: el suelo del runtime
swiftc -O -static-stdlib hola.swift -o hola
llvm-size hola # el orden de magnitud son megabytes
Para un Cortex-M0 con 264 KB de RAM, ese suelo no es caro: es directamente imposible. Y el problema no se arregla con un enlazador más agresivo, porque los metadatos son alcanzables desde el runtime por nombre y por tabla, de modo que el recolector de secciones muertas no puede demostrar que nadie los usa. El dinamismo no es un adorno que se pueda podar a posteriori; es una propiedad estructural que hay que renunciar a tener antes de compilar.
Conviene separar dos cosas que se confunden. La biblioteca estándar es código Swift corriente: Array, Optional, los enteros, los protocolos numéricos. El runtime es una biblioteca en C++ que implementa recuento de referencias, conversión dinámica, resolución de metadatos y despacho de protocolos no especializados. Embedded Swift conserva una parte sustancial de la primera y elimina casi por completo el segundo, y por eso el recorte se nota tanto.
Un modo de compilación, no un dialecto
La decisión de diseño más importante de Embedded Swift es que no inventa sintaxis. No hay una palabra clave nueva, no hay un atributo que marque el fichero, no hay un import Embedded. Se activa con una bandera del compilador y a partir de ahí el mismo código fuente se compila bajo otro contrato.
swiftc -target armv6m-none-none-eabi \
-enable-experimental-feature Embedded \
-wmo -Osize \
-Xcc -mcpu=cortex-m0plus \
-c Blink.swift -o Blink.o
Tres piezas de esa invocación son obligatorias y no decorativas. La bandera de característica activa el modo. La optimización de módulo completo, -wmo, no es una preferencia sino un requisito: sin ella el compilador no puede ver todos los usos de un genérico y por tanto no puede especializarlo. Y el objetivo none-none-eabi declara que no hay sistema operativo debajo, lo que le dice al compilador que no puede asumir hilos, memoria virtual, ficheros ni señales.
Que sea el mismo lenguaje tiene una consecuencia práctica enorme y poco publicitada: un tipo struct que modela un paquete de protocolo, una máquina de estados escrita con enum y valores asociados o una función de decodificación pura pueden compilarse sin tocar una línea tanto para el firmware como para la aplicación que lo controla. El código compartido entre firmware y app deja de ser una aspiración y pasa a ser un fichero.
Mismo lenguaje
Sintaxis idéntica, sin palabras clave nuevas. Lo que cambia es qué construcciones acepta el compilador.
Otra biblioteca
Una biblioteca estándar recompilada en modo embebido, sin las partes que exigen runtime dinámico.
Mundo cerrado
El compilador ve todo el programa. Nada se carga después, nada se resuelve por nombre.
Mundo cerrado y especialización total
Aquí está el mecanismo que hace posible todo lo demás. Swift convencional asume un mundo abierto: alguien podrá enlazar más tarde contra tu módulo, instanciar tu genérico con un tipo que tú nunca viste y preguntarle al runtime cómo copiarlo. Esa asunción obliga a emitir metadatos para todo lo que pudiera necesitarlos, que en la práctica es casi todo.
Embedded Swift asume un mundo cerrado: el programa que se está compilando es el programa completo, no habrá carga dinámica, no habrá enlazado tardío y no existirá ningún tipo que el compilador no haya visto. Bajo esa asunción, cada uso de un genérico se puede resolver a un tipo concreto en compilación, y cada genérico se puede reescribir como código monomórfico especializado. Si Array con elementos de tipo UInt8 es la única instanciación que aparece en el programa, es la única que se emite.
// Se especializa por completo: no queda ni un metadato en el binario
func sumar<T: FixedWidthInteger>(_ v: some Collection<T>) -> T {
var acc: T = 0
for x in v { acc &+= x }
return acc
}
let muestras: [UInt16] = [12, 900, 37]
let total = sumar(muestras) // se emite solo la version para UInt16
La especialización total tiene dos efectos que van en direcciones opuestas y conviene conocer los dos. El bueno: el despacho pasa a ser estático, las llamadas se pueden alinear, las tablas desaparecen y el resultado suele ser tan rápido como el equivalente en C escrito a mano. El malo: cada instanciación distinta genera código distinto, de modo que un genérico usado con doce tipos produce doce cuerpos, y el tamaño del binario puede crecer por donde no lo esperabas. La disciplina de tamaño en Embedded Swift consiste, en buena medida, en vigilar cuántas instanciaciones estás pidiendo sin darte cuenta.
flowchart TB
a[Codigo fuente Swift]
a --> b{Modo de compilacion}
b -->|Swift convencional| c[Mundo abierto]
b -->|Embedded Swift| d[Mundo cerrado]
c --> c1[Metadatos de tipo emitidos]
c --> c2[Genericos sin especializar]
c --> c3[Runtime enlazado]
c3 --> c4[Binario de megabytes]
d --> d1[Especializacion total obligatoria]
d --> d2[Sin metadatos ni reflexion]
d --> d3[Despacho estatico]
d3 --> d4[Binario de kilobytes]Sin optimización de módulo completo el compilador procesa cada fichero por separado y no puede saber con qué tipos se instanciará un genérico definido en otro archivo. La única salida sería emitir la versión no especializada, que es exactamente lo que el modo embebido prohíbe. Por eso el compilador rechaza el modo si le falta -wmo, en vez de degradarse en silencio.
Dónde vive de verdad
El destino obvio es el microcontrolador de 32 bits: Cortex-M0 hasta M7 y las familias RISC-V de 32 bits. Placas como la Raspberry Pi Pico con el RP2040, las series STM32, el nRF52 de Nordic y el ESP32-C6 son los objetivos que aparecen en las demostraciones y en los ejemplos oficiales, y el orden de magnitud del resultado es el que hacía falta: un programa que parpadea un LED y habla por un bus serie cabe en unos pocos kilobytes de flash, con el grueso ocupado por el arranque en C del fabricante, no por Swift.
El código que produce ese resultado no tiene aspecto exótico, y esa es justamente la noticia. Un programa completo de firmware se lee como Swift corriente escrito con cuidado.
@_cdecl("main")
func main() -> Int32 {
let led = Pin(numero: 25).comoSalida()
var contador: UInt32 = 0
while true {
led.alternar()
contador &+= 1
esperar(ms: contador % 4 == 0 ? 100 : 500)
}
}
Ahí hay un tipo por valor, un método con nombre argumentado, aritmética explícita sobre el desbordamiento, un operador ternario y un bucle infinito que en este contexto no es un error sino la forma normal de un programa que nunca termina porque nadie lo va a relanzar. No hay Foundation, no hay Array que crezca, no hay cadena que se interpole y no hay ninguna asignación en el montículo, de modo que el enlazador jamás pedirá un asignador.
Pero reducir Embedded Swift a los microcontroladores es perder la mitad del argumento. El modo sirve para cualquier entorno sin runtime completo, y eso incluye código que corre en modo privilegiado, gestores de arranque, entornos aislados con memoria fija, contextos donde el asignador dinámico es sospechoso y cualquier lugar donde alguien vaya a auditar el binario símbolo por símbolo. Apple ha declarado públicamente que emplea el modo en firmware de bajo nivel que se envía en productos, y ese dato importa más que cualquier medición: significa que el objetivo del proyecto no era la demostración vistosa sino el reemplazo de C en código real.
La bandera se llama experimental porque lo es. La superficie del subconjunto ha cambiado entre versiones del compilador, hay construcciones que un año no compilaban y al siguiente sí, y los diagnósticos cuando algo queda fuera todavía no siempre explican bien por qué. Fijar la versión exacta del toolchain en el proyecto no es una precaución opcional: es la única forma de que el binario de mañana se parezca al de hoy.
Lo que Embedded Swift revela, y que es difícil de ver desde una aplicación de escritorio, es que el dinamismo de un lenguaje no es una capacidad gratuita que se activa cuando la usas: es una deuda que el compilador contrae por adelantado, en cada tipo que emite, por si alguien la reclama alguna vez. Los metadatos existen no porque tu programa los pida sino porque otro programa podría pedirlos, y esa asimetría entre el coste, que es cierto y universal, y el beneficio, que es potencial y minoritario, es el corazón del asunto. La bandera de compilación embebida no añade nada; lo único que hace es cambiar una cuantificación lógica: donde antes se decía que existe algún cliente futuro capaz de preguntar, ahora se dice que para todo cliente el compilador ya lo ha visto. De ese cambio de cuantificador se deducen mecánicamente todas las consecuencias: sin cliente futuro no hace falta metadato, sin metadato no hay reflexión, sin reflexión no hay conversión dinámica arbitraria, sin genérico abierto la especialización deja de ser una optimización y pasa a ser la única implementación posible. Y de ahí viene la propiedad más elegante del diseño: Embedded Swift no es Swift mutilado, es Swift con una premisa distinta, y casi todo lo que se pierde se pierde por deducción y no por decreto. Eso explica por qué la lista de restricciones se puede memorizar en una tarde mientras que la lista de restricciones de un dialecto arbitrario habría sido inagotable, y explica también por qué la frontera se puede mover: cada vez que alguien encuentra la forma de dar una garantía estática donde antes hacía falta una pregunta en ejecución, una restricción cae sola.
Embedded Swift es un modo de compilación, no un lenguaje aparte. Se activa con una bandera experimental y exige -wmo y un objetivo sin sistema operativo. Sustituye la asunción de mundo abierto por la de mundo cerrado, lo que convierte la especialización de genéricos en obligatoria y permite eliminar metadatos, reflexión y despacho dinámico. El resultado son binarios de kilobytes aptos para microcontroladores de 32 bits y para cualquier entorno sin runtime completo.
- Compila un programa mínimo con Swift convencional y enlazado estático y anota el tamaño de cada sección con
llvm-size. - Identifica en ese binario los símbolos del runtime con
llvm-nmy clasifica cuáles corresponden a metadatos y cuáles a recuento de referencias. - Escribe un genérico usado con tres tipos concretos distintos y razona cuántos cuerpos emitiría el modo embebido.
- Explica con tus palabras por qué el recolector de secciones muertas no puede eliminar los metadatos en el modo convencional.
- Elige un fichero real de tu código de aplicación y argumenta si podría compilarse sin cambios en modo embebido, señalando la primera construcción que lo impediría.