El caso de uso: seguridad de memoria en firmware, frente a C y a Rust
Por qué un fallo de memoria en firmware es cualitativamente peor que en una aplicación, qué garantiza exactamente Embedded Swift y qué cuesta en flash y en determinismo, la comparación honesta con C y con su ecosistema certificado, el contraste con un Rust embebido mucho más maduro, y el criterio real para elegir en cada proyecto.
La pregunta que cierra este nivel no es técnica sino de ingeniería aplicada: dado que existen tres lenguajes capaces de escribir firmware con garantías distintas, cuál conviene y en qué circunstancias. Contestarla exige antes entender por qué el firmware es el peor lugar posible para un fallo de memoria, un argumento que no se reduce a que el dispositivo se cuelgue. Un desbordamiento de búfer en una aplicación de escritorio se encuentra con espacio de direcciones aleatorizado, páginas no ejecutables, canarios de pila, un informe de fallo automático y una actualización disponible en horas. El mismo desbordamiento en un microcontrolador se encuentra con una tabla de vectores en una dirección fija, ninguna mitigación, ningún informe, nadie mirando, y un dispositivo que quizá lleve ocho años instalado en un sitio al que hay que llegar con una escalera.
- Argumentar por qué la ausencia de mitigaciones cambia la gravedad de un fallo de memoria en firmware.
- Enumerar las garantías concretas de Embedded Swift y su coste en tamaño y en determinismo.
- Contrastar con C considerando su ecosistema, su certificación y sus disciplinas de mitigación.
- Contrastar con Rust en madurez embebida, modelo de coste y auditabilidad de la frontera insegura.
Por qué aquí duele más
En un servidor o en un teléfono, treinta años de trabajo defensivo han construido capas que convierten muchos errores de memoria en caídas ruidosas en lugar de en ejecución de código ajeno. En un microcontrolador esas capas sencillamente no existen. No hay unidad de gestión de memoria que separe procesos, porque no hay procesos. No hay aleatorización de direcciones, porque el mapa de memoria está grabado en el guion de enlazado y es idéntico en las cien mil unidades fabricadas. Rara vez hay páginas no ejecutables. Y la pila y los búferes de recepción de la red conviven en los mismos 264 KB.
El resultado es que la cadena entre error y compromiso total es mucho más corta. Un desbordamiento de índice al procesar una trama recibida por radio sobreescribe la pila; la pila contiene la dirección de retorno; la dirección de retorno lleva a donde el atacante quiera; y no hay nada entre esos cuatro pasos. Por eso las categorías de escritura y lectura fuera de límites encabezan año tras año las listas de debilidades más peligrosas, y por eso las agencias de ciberseguridad de varios gobiernos han pasado, entre 2023 y 2024, de recomendar buenas prácticas a recomendar explícitamente lenguajes con seguridad de memoria para software de infraestructura.
Conviene ver la cadena entera escrita, porque cada eslabón está presente en cualquier dispositivo conectado y ninguno depende de una sofisticación especial del atacante.
Trama recibida por radio
-> longitud tomada del propio paquete sin validar
-> copia a un buffer de pila de tamaño fijo
-> se sobreescribe la direccion de retorno
-> el procesador salta donde diga el atacante
-> sin MMU, sin ASLR, sin pagina no ejecutable, sin informe
El segundo agravante es temporal. Un dispositivo empotrado tiene una vida de servicio que se mide en décadas, con un canal de actualización que a menudo no existe o que exige presencia física. La ventana entre el descubrimiento de un fallo y su desaparición del parque instalado no se mide en días sino en el ritmo de reemplazo del hardware.
Conviene distinguir tres desenlaces de un error de memoria. El primero es la corrupción silenciosa: el programa sigue funcionando con datos incorrectos, y en un sensor de proceso industrial eso puede ser peor que apagarse. El segundo es la caída determinista, que es ruidosa, diagnosticable y honesta. El tercero es la ejecución de código ajeno. Cualquier mecanismo que convierta casos del primer y del tercer tipo en casos del segundo es una mejora enorme aunque a primera vista parezca que solo añade formas de fallar.
Qué garantiza Swift y qué cuesta
Las garantías de Embedded Swift son las del lenguaje completo, no una versión rebajada, y eso es lo importante: el recorte del nivel afectó al dinamismo, no a la seguridad. Los accesos a colecciones comprueban límites. Las variables no se pueden leer sin haber sido inicializadas, y el compilador lo demuestra por cada camino de ejecución. La ausencia de valor es un Optional que hay que desenvolver, de modo que la desreferencia de nulo desaparece como categoría. La aritmética entera atrapa el desbordamiento en lugar de envolverlo en silencio, y quien quiere envolvimiento lo pide con operadores explícitos. La ley de exclusividad impide el aliasing mutable, con verificación estática donde se puede y dinámica donde no. Y el recuento de referencias hace que el uso después de liberar no ocurra en código seguro.
El coste tiene tres partidas y ninguna es despreciable. La primera es flash: cada comprobación de límites es un par de instrucciones y una rama, y sobre un bucle apretado se nota. La segunda es ARC: incrementar y decrementar contadores es tráfico que C no genera, aunque en un procesador de un solo núcleo esas operaciones no necesitan ser atómicas y el compilador elimina muchas parejas. La tercera, y la más delicada en tiempo real, es el determinismo del montículo: la destrucción en ARC ocurre en un punto conocido, lo cual es una ventaja frente a un recolector, pero la llamada a liberar que se produce entonces puede no ser de tiempo constante, y en una ruta con plazo estricto eso importa. La respuesta profesional no es medir mejor sino no asignar en esas rutas.
// El trato: el limite se comprueba y el fallo es determinista
func checksum(_ trama: UnsafeBufferPointer<UInt8>, hasta n: Int) -> UInt8 {
var acc: UInt8 = 0
for i in 0..<n { acc &+= trama[i] } // si n excede, atrapa aqui
return acc
}
Cuando una comprobación falla, el código generado ejecuta una instrucción de excepción que en un Cortex-M acaba en el manejador de fallo grave. Ahí no hay consola ni informe: si no escribes ese manejador, el dispositivo se queda parado en un bucle infinito. Un firmware serio implementa el manejador para registrar la dirección de la instrucción culpable en memoria no volátil y reiniciar de forma controlada. La bandera -Ounchecked elimina las comprobaciones y, con ellas, el argumento entero de este nivel; usarla en producción es cambiar de lenguaje sin decirlo.
Frente a C
Comparar con C exige honestidad en las dos direcciones. C conserva ventajas que no son inercia. Existe un compilador de C para absolutamente cualquier microcontrolador que se fabrique, incluidos los de 8 y 16 bits donde Swift no llega. Los SDK, los ejemplos, los controladores y los foros están escritos en C. Y, sobre todo, existe un ecosistema de certificación que no tiene equivalente: compiladores cualificados para normas de seguridad funcional en aeronáutica, automoción e industria, y una disciplina como MISRA C que restringe el lenguaje a un subconjunto analizable con herramientas maduras.
Pero esa disciplina revela la asimetría fundamental. MISRA, el análisis estático y las revisiones cruzadas son mecanismos que intentan aproximar por fuera lo que un sistema de tipos hace por dentro, y su cobertura es incompleta por construcción: prohíben construcciones sospechosas en lugar de demostrar propiedades. Un índice fuera de rango calculado a partir de una longitud recibida por la red no lo detiene ninguna regla de estilo. La diferencia entre las dos aproximaciones no es de grado: una reduce la probabilidad, la otra elimina la categoría.
El ejemplo canónico cabe en cuatro líneas y aparece en casi todos los avisos de seguridad de dispositivos conectados.
// La longitud viene de la red y nadie la valida
void recibir(const uint8_t *trama) {
uint8_t destino[64];
memcpy(destino, trama + 2, trama[1]); // trama[1] puede valer 255
}
// El mismo descuido, otro desenlace
func recibir(_ trama: UnsafeBufferPointer<UInt8>) {
var destino = [UInt8](repeating: 0, count: 64)
let n = Int(trama[1])
for i in 0..<n { destino[i] = trama[i + 2] } // atrapa al llegar a 64
}
Ninguna de las dos versiones es correcta: las dos tienen el mismo error de validación y las dos deberían rechazar la trama antes de copiar. Lo que cambia es el desenlace. La primera sobreescribe la pila y entrega el control del dispositivo. La segunda se detiene en el índice 64 con una trampa determinista, en la instrucción exacta donde estaba el fallo, y deja al equipo un reinicio registrado en lugar de una intrusión que nadie detectó.
C
Universal, certificable, sin sorpresas de tamaño. Ninguna garantía: la disciplina la pone el equipo.
Rust
Seguridad sin recuento, sin montículo obligatorio, ecosistema embebido maduro y frontera insegura visible.
Swift
Seguridad con ARC, curva suave, macros de registro excelentes y código compartido con la aplicación.
Frente a Rust
Aquí la comparación es más exigente porque Rust juega en el mismo campo y lleva años en él. Su ecosistema embebido está maduro de una forma que Embedded Swift todavía no: la ausencia de biblioteca estándar es un modo de primera clase con años de rodaje, hay una capa de abstracción de hardware común que permite escribir controladores portables entre familias de chips, hay marcos de concurrencia pensados para interrupciones y para asincronía sin sistema operativo, y hay herramientas de grabación y de traza que funcionan sin ceremonia.
Al modelo de coste le pasa lo mismo. Rust obtiene su seguridad de memoria en compilación, sin recuento de referencias y sin exigir montículo, de modo que un firmware sin asignación dinámica es el caso normal y no un ejercicio de disciplina. Swift consigue seguridad equivalente pero paga ARC allí donde usa clases, y esa diferencia se nota en el binario y en la previsibilidad de los tiempos.
Hay además una asimetría de auditabilidad que conviene nombrar. En Rust lo inseguro se marca con una palabra clave que se puede buscar mecánicamente y contar; una revisión de seguridad sabe exactamente dónde mirar. En Swift la superficie insegura está repartida entre los tipos de puntero no seguros, la exportación con convención de C y las anotaciones que desactivan las comprobaciones de concurrencia, y no existe un único término que la delimite. Es perfectamente auditable, pero exige que el equipo defina su propia convención en lugar de heredarla del lenguaje.
Y está la certificación, donde Rust ya dispone de toolchains cualificados para normas de seguridad funcional exigentes mientras que Embedded Swift sigue detrás de una bandera experimental. Para un proyecto sometido a certificación, esa sola línea decide.
La comparación deja de ser abstracta en cuanto se mide. Extrae de tu base de código el número de líneas que manipulan punteros no seguros, que exportan símbolos con convención de C o que desactivan una comprobación del compilador, y divídelo entre el total. Ese porcentaje describe tu proyecto mejor que el nombre del lenguaje que aparece en el manifiesto, y es además el único número que un auditor externo puede verificar sin conocer tu dominio.
Lo que Swift aporta a cambio no es marginal. La curva de aprendizaje es sensiblemente menor porque la copia por omisión evita que el modelo de propiedad sea un peaje de entrada, y ese modelo está disponible cuando se necesita. La interoperabilidad con C es directa y sin capa intermedia. Las macros producen el mejor modelado de registros del panorama, con campos tipados y sin coste en ejecución. Y existe un argumento que ningún otro lenguaje puede ofrecer: si el dispositivo se controla desde una aplicación escrita en Swift, el modelo de datos, el analizador del protocolo y la máquina de estados pueden ser el mismo fichero compilado dos veces, lo que elimina de un plumazo la clase entera de errores de desincronización entre firmware y app.
flowchart TB
a[Que firmware vas a escribir]
a --> b{Restricciones dominantes}
b -->|Certificacion o chip de 8 bits| c[C]
b -->|Sin monticulo y tiempo real duro| d[Rust]
b -->|Ecosistema de controladores amplio| d
c --> e[Disciplina externa MISRA y analisis]
d --> f[Garantia estatica sin recuento]
b -->|Equipo Swift y app companera| g[Embedded Swift]
g --> h[Codigo compartido firmware y aplicacion]
g --> i[Macros de registro tipadas]
g --> j[Coste de ARC y ecosistema joven]Si hay certificación de seguridad funcional de por medio, o el objetivo es un chip de 8 o 16 bits, la respuesta hoy es C. Si el firmware es complejo, sin montículo y con plazos duros, y el equipo puede pagar la curva, la respuesta es Rust. Si el equipo ya escribe Swift, el dispositivo tiene una aplicación compañera y el chip es un Cortex-M o un RISC-V de 32 bits razonablemente holgado, Embedded Swift es una elección defendible y cada vez más.
El debate sobre qué lenguaje es seguro está mal planteado y conviene desmontarlo, porque los tres admiten escribir en una dirección arbitraria y los tres se usan para hacerlo: sin esa capacidad no habría controladores. Lo que distingue a un lenguaje de otro no es si permite lo inseguro sino tres cosas medibles: qué ocurre por omisión cuando nadie declara nada, cuánto cuesta expresar la frontera entre lo verificado y lo que no lo está, y si esa frontera se puede contar. En C la frontera es el programa entero, de modo que su tamaño es el número de líneas y la revisión de seguridad nunca termina. En Rust y en Swift la frontera se concentra en una capa que puede tener trescientas líneas dentro de un proyecto de veinte mil, y esa proporción, no el nombre del lenguaje, es lo que cambia el resultado de una auditoría. De ahí se sigue el corolario práctico que suele pasarse por alto: adoptar un lenguaje seguro sin diseñar la frontera produce un proyecto que solo es seguro nominalmente, con punteros no seguros repartidos por todas partes y una falsa sensación de haber resuelto algo. Y de ahí se sigue también el argumento más fuerte para el firmware, que no es la elegancia sino la aritmética del riesgo: la seguridad de memoria convierte una clase de comportamientos indefinidos, cuyo peor desenlace es la ejecución de código ajeno en un dispositivo sin mitigaciones ni actualizaciones, en una clase de trampas deterministas cuyo peor desenlace es un reinicio registrado. Se cambia disponibilidad por integridad, y ese intercambio parece una pérdida hasta que se cuenta cuántas veces un reinicio ha comprometido una red y cuántas lo ha hecho un desbordamiento. La respuesta a la primera es cero.
En firmware no hay mitigaciones, ni informes, ni actualizaciones fáciles, y la vida útil se mide en décadas, de modo que un fallo de memoria escala a compromiso total con pocos pasos. Swift aporta comprobación de límites, inicialización definida, opcionales, aritmética que atrapa y exclusividad, y cobra en flash, en tráfico de ARC y en determinismo del montículo. C mantiene universalidad y certificación pero sustituye garantías por disciplina. Rust ofrece seguridad sin recuento, ecosistema embebido maduro y frontera insegura contable. Swift gana cuando el equipo ya es de Swift, hay aplicación compañera y el chip es holgado.
- Toma una rutina de análisis de trama en C de un proyecto real y localiza cada punto donde un índice podría salirse.
- Reescríbela en Swift y compara el tamaño de la sección de código con y sin
-Ounchecked. - Implementa el manejador de fallo grave que registre la dirección culpable y reinicie de forma controlada.
- Cuenta las líneas de tu proyecto que manipulan punteros no seguros y expresa el resultado como porcentaje del total.
- Escribe la justificación de una página que presentarías a tu equipo eligiendo entre los tres lenguajes para un producto concreto, con las restricciones reales de ese producto.