AtomVM: la BEAM dentro de un microcontrolador
AtomVM: la BEAM dentro de un microcontrolador
En el capítulo 12 cerramos el bloque de herramientas: diseñamos piezas en CAD, revisamos dónde comprar componentes, entendimos qué aporta un RTOS frente a un bucle infinito sobre metal desnudo y vimos cómo ROS coordina nodos en un robot grande. Ese recorrido dejó una pregunta abierta: si un RTOS ya nos da tareas concurrentes, colas y semáforos en C, ¿qué gana un proyecto por escribir su firmware en Elixir? La respuesta no es sintaxis bonita. Es un modelo de concurrencia donde cada tarea es un proceso aislado con su propia memoria, donde un fallo no corrompe al vecino y donde la supervisión es parte del lenguaje y no una convención del equipo. Ese modelo vive en la máquina virtual BEAM, y hasta hace poco la BEAM no cabía en un microcontrolador. AtomVM es la pieza que cambia eso.
Este capítulo es la puerta de entrada al bloque de AtomVM del curso. Vamos a responder tres cosas con precisión: qué es exactamente AtomVM y qué no es, cómo está construido por dentro, y cómo dejar una estación de trabajo capaz de compilar Elixir, empaquetarlo y grabarlo en un ESP32 en menos de treinta segundos por iteración. Al final del capítulo vas a tener un LED parpadeando y un valor de brillo variable controlado desde código Elixir corriendo en un chip de dos dólares.
Qué es AtomVM
AtomVM es una máquina virtual que ejecuta bytecode BEAM. Es decir: toma los archivos .beam que produce el compilador de Erlang, de Elixir o de LFE — sin modificarlos, sin un compilador especial, sin un transpilador — y los interpreta sobre hardware donde la máquina virtual oficial de Erlang/OTP jamás podría arrancar.
Conviene fijar la definición desde el primer minuto, porque casi todos los malentendidos vienen de aquí:
- AtomVM no es un compilador. Sigues usando
elixircomix compile. El bytecode que genera Elixir es el mismo que consumiría OTP. - AtomVM no es un puerto de OTP. No es la BEAM oficial recortada. Es una reimplementación independiente, escrita en C, que implementa un subconjunto estricto del conjunto de instrucciones BEAM y una fracción de la librería estándar.
- AtomVM no es un runtime de Elixir “parecido”. Los procesos son procesos de verdad: aislados, con su propio heap, con su propio recolector de basura, planificados de forma preventiva, comunicándose por mensajes inmutables.
- AtomVM no reemplaza a Nerves. Nerves corre la BEAM completa sobre Linux embebido, en un Raspberry Pi o similar, con decenas de megabytes de RAM. AtomVM corre donde hay 300 KB de RAM y no hay sistema operativo. Son escalones distintos de la misma escalera, y los veremos juntos más adelante en el curso.
El proyecto fue iniciado por Davide Bettio en 2018 y hoy es un proyecto comunitario con soporte para ESP32 y toda su familia de variantes, para la Raspberry Pi Pico basada en RP2040 y RP2350, para microcontroladores STM32, para WebAssembly vía Emscripten y para escritorios Unix (Linux y macOS), este último pensado sobre todo para probar código sin hardware conectado.
El número que lo explica todo
La cifra que se repite en la documentación de AtomVM es que el VM ocupa menos de 500 KB. Ese número, por sí solo, no dice mucho. Cobra sentido al compararlo con lo que se necesita para arrancar la BEAM oficial.
| Aspecto | BEAM oficial (OTP) | AtomVM |
|---|---|---|
| Tamaño del runtime | decenas de MB con OTP completo | menos de 500 KB de imagen |
| RAM mínima realista | ~100 MB para algo útil | funciona con ~100 KB libres |
| Sistema operativo | requiere Linux, macOS, Windows o similar | corre sobre FreeRTOS o sin SO |
| Compilación JIT | sí, desde OTP 24 en x86/ARM64 | no, solo interpretación |
| Hot code reloading | sí | no |
| REPL / shell interactiva | sí | no |
| Enteros grandes (bignums) | ilimitados | limitados a 64 bits |
| Átomos | longitud amplia | máximo 255 bytes |
| Librería estándar | OTP completo | subconjunto en estdlib |
| Acceso a GPIO, I2C, SPI, PWM | vía NIFs o puertos externos | integrado en eavmlib |
| Destino típico | servidores, contenedores | ESP32, RP2040, STM32 |
Las restricciones de la columna derecha no son defectos: son el precio explícito de caber en un microcontrolador. Cuando escribes firmware en AtomVM no vas a recargar código en caliente ni a manipular enteros de 300 dígitos. Vas a leer sensores, mover actuadores y hablar por WiFi, y para eso el subconjunto alcanza.
Qué gana realmente el proyecto
El argumento a favor de AtomVM en un dispositivo IoT se sostiene en tres puntos concretos:
- Aislamiento por proceso. En C sobre FreeRTOS, una tarea que escribe fuera de su buffer corrompe la memoria de otra tarea y el fallo aparece minutos después en un lugar sin relación. En AtomVM cada proceso tiene su propio heap; un proceso que muere no arrastra a los demás.
- Supervisión declarativa. El árbol de supervisión no es una convención del equipo, es una estructura del runtime. Si el proceso que lee el sensor de temperatura muere porque el sensor se desconectó, el supervisor lo reinicia con una política que tú declaraste, y el resto del sistema no se entera.
- Concurrencia sin secciones críticas. No hay memoria compartida entre procesos, por lo tanto no hay mutex que olvidar, ni condición de carrera por una variable global, ni inversión de prioridad por un semáforo mal ordenado.
Nada de esto es gratuito. Un intérprete de bytecode es más lento que C compilado, el recolector de basura introduce pausas y el consumo de RAM por proceso no es cero. La decisión es de compromiso, exactamente como discutimos al comparar lenguajes en el capítulo 5.
Cómo llega el código Elixir al microcontrolador
Esta es la parte que más confusión genera, así que vale la pena seguir el camino completo desde el archivo .ex que escribes hasta la instrucción que ejecuta el chip.
flowchart TD
A["lib/blink.ex<br/>codigo fuente Elixir"] --> B["compilador de Elixir<br/>mix compile"]
B --> C["_build/dev/lib/blink/ebin/<br/>Elixir.Blink.beam"]
C --> D["ExAtomVM<br/>mix atomvm.packbeam"]
E["dependencias .beam<br/>del proyecto"] --> D
F["archivos de recursos<br/>opcionales"] --> D
D --> G["blink.avm<br/>archivo Packbeam"]
G --> H["esptool<br/>write_flash en 0x250000"]
H --> I["particion main.avm<br/>en la flash del ESP32"]
J["AtomVM-esp32-elixir.img<br/>grabado una sola vez"] --> K["particiones del VM<br/>bootloader y firmware"]
K --> L["AtomVM arranca"]
I --> L
L --> M["busca el primer modulo<br/>que exporta start/0"]
M --> N["ejecuta start/0<br/>en un proceso BEAM"]
Los puntos clave del diagrama:
- El compilador de Elixir es el estándar. No hay una versión “para AtomVM”. El
.beamque sale demix compilees idéntico al que correría en un servidor. - El paso de empaquetado toma uno o más
.beamy produce un único archivo.avm. Ese formato se llama Packbeam y lo detallamos más abajo. - La imagen del VM (
AtomVM-esp32-elixir-vX.Y.Z.img) se graba una sola vez por dispositivo. Contiene el bootloader, la tabla de particiones y el firmware del intérprete. No la vuelves a tocar salvo que cambies de versión de AtomVM. - Tu aplicación se graba en una partición separada, en cada iteración de desarrollo. Por eso el ciclo editar-desplegar-probar es rápido: solo mueves tu
.avm, que pesa unos pocos kilobytes.
El punto de entrada: start/0
AtomVM no tiene una shell ni un main. Al arrancar recorre los módulos del archivo Packbeam en orden y ejecuta la función start/0 del primer módulo que la exporte. Ese es todo el contrato.
En Elixir, start/0 es una función pública de un módulo:
defmodule Blink do
def start do
:io.format(~c"AtomVM arrancó~n")
:ok
end
end
Tres detalles importantes:
- El módulo se declara en
mix.exscon la opciónstart:para que ExAtomVM lo coloque primero en el Packbeam. Si no lo declaras, el orden depende del sistema de archivos y el resultado es impredecible. - El valor de retorno importa en algunas plataformas: devolver algo distinto de
:okpuede provocar un reinicio del dispositivo. Tratastart/0como el punto donde arrancas tu árbol de procesos y luego bloqueas, no como una función que “termina”. ~c"texto"es un charlist en Elixir moderno. Las funciones de Erlang como:io.format/1esperan charlists, no binarios. Este es uno de los tropiezos más frecuentes al llegar desde Elixir puro.
Si tu start/0 retorna inmediatamente, el dispositivo se queda sin nada que hacer. El patrón habitual es lanzar procesos y luego mantener vivo al proceso inicial:
defmodule Blink do
def start do
spawn(fn -> parpadear(2) end)
spawn(fn -> reportar() end)
esperar_para_siempre()
end
defp parpadear(pin) do
:gpio.set_pin_mode(pin, :output)
ciclo_led(pin, :low)
end
defp ciclo_led(pin, estado) do
:gpio.digital_write(pin, estado)
:timer.sleep(500)
siguiente = if estado == :low, do: :high, else: :low
ciclo_led(pin, siguiente)
end
defp reportar do
:io.format(~c"memoria libre: ~p bytes~n", [:erlang.memory(:binary)])
:timer.sleep(5000)
reportar()
end
defp esperar_para_siempre do
receive do
_ -> esperar_para_siempre()
end
end
end
Fíjate en la forma de los bucles: no hay while. Un bucle infinito en la BEAM es una función que se llama a sí misma en posición de cola, y el runtime la ejecuta sin consumir pila. Es el mismo idioma que usarías en un servidor Elixir.
Anatomía de AtomVM: sus componentes
AtomVM no es un binario monolítico. Está organizado en capas bien separadas, y entenderlas te permite saber dónde buscar cuando algo no funciona: si el problema es del intérprete, de la plataforma o de tu código.
flowchart TB
subgraph app["Tu aplicacion"]
A1["modulos .beam empaquetados en un .avm"]
end
subgraph libs["Librerias en Erlang"]
L1["estdlib<br/>lists, maps, gen_server,<br/>supervisor, timer, io, string"]
L2["eavmlib<br/>gpio, ledc, i2c, spi, uart,<br/>network, esp, esp_adc, console"]
L3["alisp<br/>interprete Lisp opcional"]
L4["etest<br/>eunit reducido"]
end
subgraph core["libAtomVM - nucleo en C"]
C1["cargador de modulos<br/>lee el formato BEAM"]
C2["interprete de opcodes<br/>subconjunto de la BEAM"]
C3["planificador de procesos<br/>preventivo por reducciones"]
C4["recolector de basura<br/>uno por proceso"]
C5["tabla de atomos<br/>y gestion de memoria"]
C6["puertos y NIFs<br/>puente hacia C"]
end
subgraph plat["Capa de plataforma"]
P1["esp32<br/>ESP-IDF y FreeRTOS"]
P2["rp2040 / rp2350<br/>Pico SDK"]
P3["stm32<br/>libopencm3"]
P4["generic_unix<br/>Linux y macOS"]
P5["emscripten<br/>WebAssembly"]
end
app --> libs
libs --> core
core --> plat
plat --> HW["Hardware:<br/>GPIO, timers, ADC,<br/>I2C, SPI, WiFi"]
libAtomVM: el núcleo
Es el corazón escrito en C y es idéntico en todas las plataformas. Sus responsabilidades:
Cargador de módulos. Lee el formato de archivo BEAM: las secciones AtU8 con la tabla de átomos, Code con el bytecode, LitT con los literales comprimidos, ImpT y ExpT con las tablas de importación y exportación. Al cargar, resuelve referencias entre módulos y construye las estructuras que el intérprete usa en tiempo de ejecución.
Intérprete de opcodes. Implementa un subconjunto estricto de las instrucciones de la BEAM. “Estricto” significa que si tu código usa una instrucción no implementada, el VM falla explícitamente en lugar de comportarse mal. Esto es deliberado: prefiere un error claro a un resultado silenciosamente incorrecto.
Planificador. Reparte tiempo de CPU entre procesos usando el mismo modelo que la BEAM: cada proceso recibe un presupuesto de reducciones (aproximadamente, llamadas a función) y cuando lo agota cede el control. Esto hace que la concurrencia sea preventiva: un proceso que hace un cálculo largo no bloquea a los demás. En plataformas con múltiples núcleos, como el ESP32 clásico o el RP2040, AtomVM puede planificar sobre ambos.
Recolector de basura. Cada proceso tiene su propio heap y su propio ciclo de recolección. Es la diferencia arquitectónica más importante frente a un lenguaje con GC global: la pausa de recolección afecta a un solo proceso, no al sistema entero. En un dispositivo que debe responder a una interrupción en milisegundos, esto es determinante.
Puertos y NIFs. Son los dos mecanismos por los que el código Erlang llega al hardware. Un NIF (Native Implemented Function) es una función C que se invoca de forma síncrona, como si fuera una función normal. Un puerto es un proceso nativo con el que hablas por mensajes de forma asíncrona. Los drivers de I2C y SPI de AtomVM son puertos; gpio:digital_write/2 es un NIF. Profundizaremos en la escritura de NIFs propios en el siguiente capítulo.
La capa de plataforma
Aquí vive todo lo específico del chip: cómo se accede a la flash, cómo se configura un timer, cómo se inicializa el WiFi, cómo se pide memoria al sistema. En ESP32, esta capa se apoya en ESP-IDF, el framework oficial de Espressif, que a su vez corre sobre FreeRTOS. El VM se ejecuta como una tarea de FreeRTOS y usa sus primitivas por debajo, pero tu código Elixir nunca ve FreeRTOS: ve procesos BEAM.
Que la capa esté aislada es lo que permite que el mismo .avm corra en tu escritorio con la plataforma generic_unix y en el ESP32 sin recompilar, siempre que no uses APIs específicas del chip.
Las librerías en Erlang
Estas son las que usas a diario desde Elixir. Se dividen en cuatro paquetes:
| Librería | Aproximado de módulos | Qué contiene | Ejemplos de módulos |
|---|---|---|---|
estdlib | ~44 | Reimplementación reducida de OTP | lists, maps, binary, string, calendar, timer, io, gen_server, gen_statem, supervisor, application, gen_tcp, gen_udp, inet, crypto, math, ets, base64, logger |
eavmlib | ~24 | APIs propias de AtomVM y del hardware | gpio, ledc, i2c, spi, uart, esp, esp_adc, network, console, atomvm, port, json_encoder, http_server, ahttp_client, websocket, mdns, avm_pubsub, pico, emscripten |
alisp | 6 | Intérprete de un dialecto Lisp sobre AtomVM | alisp, alisp_stdlib, arepl, sexp_lexer, sexp_parser, sexp_serializer |
etest | 2 | Pruebas reducidas | etest, eunit |
Los módulos de estdlib mantienen compatibilidad de API con OTP donde tiene sentido, pero no implementan todas las funciones. lists existe, pero no todas sus funciones; maps existe, pero con menos operaciones. Cuando dudes, la referencia autoritativa es la documentación de API de AtomVM, no la de OTP.
Los módulos de eavmlib son los que no tienen equivalente en OTP porque OTP nunca tuvo que encender un LED. Los vas a usar constantemente en el resto del curso.
El formato Packbeam
Un microcontrolador no tiene sistema de archivos por defecto ni un directorio ebin donde buscar módulos. Necesita todo el código en un solo bloque contiguo de flash. Ese bloque es el archivo AVM, producido por la herramienta packbeam.
Un archivo .avm es un contenedor simple que concatena:
- Los módulos
.beamde tu aplicación. - Los
.beamde las dependencias que uses. - Opcionalmente, archivos de recursos arbitrarios: una fuente para el display, un certificado, un archivo de configuración.
flowchart LR
subgraph avm["main.avm - archivo Packbeam"]
direction TB
H["cabecera del archivo"]
M1["Elixir.Blink.beam<br/>PRIMERO: exporta start/0"]
M2["Elixir.Sensor.beam"]
M3["Elixir.Supervisor.Arbol.beam"]
R1["priv/fuente.bin<br/>recurso embebido"]
H --> M1 --> M2 --> M3 --> R1
end
avm --> F["flash del ESP32<br/>particion main.avm"]
F --> VM["AtomVM recorre en orden<br/>y arranca en M1"]
El orden dentro del archivo no es decorativo: el VM ejecuta el start/0 del primer módulo que lo exporte. Por eso ExAtomVM ordena el Packbeam según la opción start: de tu mix.exs.
Para leer recursos embebidos desde tu código Elixir se usa :atomvm.read_priv/2, que recibe el nombre de la aplicación y la ruta relativa dentro de priv. Es la forma de llevar archivos binarios al dispositivo sin necesitar un sistema de archivos.
El mapa de la flash del ESP32
Antes de grabar nada, conviene saber qué se escribe y dónde. La flash de un ESP32 con AtomVM queda organizada así:
| Región | Contenido | Se graba |
|---|---|---|
| Offset del bootloader (varía por chip) | Segundo bootloader de Espressif | Una vez, con la imagen del VM |
| Tabla de particiones | Describe el resto del mapa | Una vez, con la imagen del VM |
| Partición de aplicación (factory) | El firmware de AtomVM: libAtomVM y las librerías Erlang | Una vez, con la imagen del VM |
Partición boot.avm | Librerías del sistema en formato AVM | Una vez, con la imagen del VM |
Partición main.avm | Tu aplicación | En cada despliegue |
| NVS | Almacenamiento clave-valor no volátil | En tiempo de ejecución |
El offset del bootloader depende del chip, y equivocarse aquí es una de las causas más frecuentes de que la placa no arranque:
| Chip | Offset del bootloader |
|---|---|
| ESP32 | 0x1000 |
| ESP32-S2 | 0x1000 |
| ESP32-S3 | 0x0 |
| ESP32-C2 | 0x0 |
| ESP32-C3 | 0x0 |
| ESP32-C6 | 0x0 |
| ESP32-H2 | 0x0 |
| ESP32-P4 | 0x2000 |
El offset de la partición main.avm, donde va tu aplicación, depende de la imagen que hayas grabado:
| Tipo de imagen | Offset de main.avm |
|---|---|
| Imagen estándar (solo Erlang) | 0x210000 |
| Imagen con módulos de Elixir | 0x250000 |
La razón de la diferencia es simple: la imagen para Elixir incluye además los .beam del núcleo de Elixir — Kernel, Enum reducido, protocolos básicos — que ocupan espacio adicional en la partición de arranque y desplazan la partición de la aplicación. Si grabas la imagen de Elixir y luego escribes tu .avm en 0x210000, tu código sobrescribe librerías del sistema y el dispositivo entra en un ciclo de reinicios. Es el error número uno de quien empieza.
Montar el entorno de desarrollo
Vamos a lo práctico. Necesitas cinco cosas en tu máquina:
- Erlang/OTP y Elixir. El compilador de Elixir debe correr en tu escritorio.
- esptool. La herramienta de Espressif para escribir en la flash por USB.
- Un monitor serie.
picocom,screen,minicomoidf.py monitor. - La imagen de AtomVM para tu chip, descargada desde las releases del proyecto.
- ExAtomVM, el plugin de Mix que empaqueta y graba.
Nada de esto requiere instalar ESP-IDF completo, que es una descarga de varios gigabytes. ESP-IDF solo hace falta si vas a compilar AtomVM desde el código fuente o a escribir NIFs en C, tema del capítulo siguiente.
Opción A: Devenv y Nix
Si trabajas en Linux o macOS y quieres un entorno reproducible que no ensucie el sistema, Devenv sobre Nix es la vía más limpia. Un archivo devenv.nix en la raíz del proyecto declara las dependencias y los comandos:
{ pkgs, ... }:
{
packages = with pkgs; [
elixir
erlang
esptool
picocom
git
];
scripts.flash.exec = ''
mix atomvm.packbeam
mix atomvm.esp32.flash --port ''${PUERTO:-/dev/ttyUSB0} --baud 921600
'';
scripts.monitor.exec = ''
picocom -b 115200 ''${PUERTO:-/dev/ttyUSB0}
'';
scripts.borrar.exec = ''
esptool --chip auto --port ''${PUERTO:-/dev/ttyUSB0} --baud 921600 erase-flash
'';
enterShell = ''
echo "Entorno AtomVM listo. Comandos: flash, monitor, borrar"
'';
}
Entras al entorno con devenv shell y desde ahí flash, monitor y borrar están disponibles como comandos. La ventaja real no es la comodidad: es que cualquier persona del equipo obtiene exactamente las mismas versiones de las herramientas.
En macOS puede ser necesario agregar frameworks del sistema a la lista de paquetes para que algunas dependencias nativas compilen; Nix lo señala con un error explícito cuando ocurre.
Opción B: gestor de versiones y pipx
Si prefieres no usar Nix, la combinación de un gestor de versiones para Elixir y pipx para las herramientas de Python funciona bien.
# Elixir y Erlang con mise (o asdf, la mecánica es equivalente)
mise use -g erlang@27
mise use -g [email protected]
# esptool aislado en su propio entorno de Python
pipx install esptool
# monitor serie (Debian/Ubuntu)
sudo apt install picocom
# monitor serie (Arch)
sudo pacman -S picocom
# monitor serie (macOS)
brew install picocom
Verifica que todo quedó accesible:
elixir --version
esptool version
picocom --help | head -1
Opción C: Windows
AtomVM no tiene un flujo nativo cómodo en Windows. La recomendación práctica es WSL2 con una distribución Linux, siguiendo la opción B dentro de ella. El punto delicado es el acceso al puerto USB: WSL2 no ve los dispositivos serie por defecto y hay que exponerlos con usbipd-win desde PowerShell como administrador.
# listar dispositivos USB conectados
usbipd list
# compartir el dispositivo (una sola vez por dispositivo)
usbipd bind --busid 1-4
# adjuntarlo a WSL (tras cada reconexión física)
usbipd attach --wsl --busid 1-4
Dentro de WSL el dispositivo aparece como /dev/ttyUSB0 o /dev/ttyACM0. La alternativa es una máquina virtual Linux con paso directo de USB, o simplemente ejecutar esptool desde Windows y compilar en WSL, aunque eso complica el uso de las tareas de Mix.
Permisos del puerto serie en Linux
Si esptool responde con un error de permiso denegado sobre /dev/ttyUSB0, tu usuario no pertenece al grupo que controla el dispositivo. Se resuelve una vez:
# identificar el grupo del dispositivo
ls -l /dev/ttyUSB0
# ejemplo de salida: crw-rw---- 1 root dialout 188, 0 ...
# agregar tu usuario a ese grupo
sudo usermod -aG dialout $USER
# cerrar sesión y volver a entrar, o aplicar en la sesión actual
newgrp dialout
En algunas distribuciones el grupo es uucp en lugar de dialout. Usar sudo en cada llamada a esptool funciona, pero rompe las tareas de Mix, que no elevan privilegios.
Identificar el puerto correcto
Antes de grabar, confirma qué archivo de dispositivo corresponde a tu placa:
# antes de conectar la placa
ls /dev/tty*
# conecta la placa por USB y repite
ls /dev/tty*
# el archivo nuevo es tu placa; también sirve:
dmesg | tail -20
Los nombres habituales son /dev/ttyUSB0 para placas con conversor CP2102 o CH340, y /dev/ttyACM0 para placas con USB nativo como muchas ESP32-S3 y ESP32-C3. En macOS son /dev/cu.usbserial-XXXX o /dev/cu.usbmodemXXXX.
Grabar la imagen de AtomVM en el ESP32
Este paso se hace una vez por dispositivo, o cuando actualizas la versión de AtomVM.
Paso 1: descargar y verificar la imagen
Las imágenes se publican en la sección de releases del repositorio de AtomVM en GitHub. Los nombres siguen el patrón AtomVM-esp32-vX.Y.Z.img para la imagen estándar y AtomVM-esp32-elixir-vX.Y.Z.img para la que incluye los módulos de Elixir. Fíjate también en la variante del chip: hay imágenes distintas para esp32, esp32s3, esp32c3 y las demás.
Como vamos a escribir Elixir, descargamos la imagen con Elixir incluido:
mkdir -p ~/atomvm && cd ~/atomvm
# ajusta la versión y la variante del chip a tu caso
curl -LO https://github.com/atomvm/AtomVM/releases/download/v0.6.6/AtomVM-esp32-elixir-v0.6.6.img
# verifica la integridad contra el hash publicado en la release
sha256sum AtomVM-esp32-elixir-v0.6.6.img
Comparar el hash no es paranoia: una descarga truncada produce una imagen que parece grabarse bien y luego deja la placa en un ciclo de reinicios sin mensaje útil.
Paso 2: borrar la flash
esptool --chip auto --port /dev/ttyUSB0 --baud 921600 erase-flash
Borrar completamente evita que restos de un firmware anterior — una tabla de particiones distinta, por ejemplo — interfieran con la nueva imagen.
Una nota sobre la sintaxis: a partir de la versión 5 de esptool los subcomandos usan guiones (erase-flash, write-flash) y el ejecutable se llama esptool. En versiones anteriores el ejecutable era esptool.py y los subcomandos usaban guion bajo (erase_flash, write_flash). Mucha documentación en circulación usa la forma antigua. Si un comando falla con un error de subcomando desconocido, prueba la otra forma.
Paso 3: escribir la imagen
esptool \
--chip auto \
--port /dev/ttyUSB0 \
--baud 921600 \
--before default_reset \
--after hard_reset \
write-flash -u \
--flash_mode dio \
--flash_freq 40m \
--flash_size detect \
0x1000 ~/atomvm/AtomVM-esp32-elixir-v0.6.6.img
Qué hace cada opción:
| Opción | Significado |
|---|---|
--chip auto | Detecta la variante del ESP32 conectada |
--port | Archivo de dispositivo serie |
--baud 921600 | Velocidad de grabación; si falla, baja a 460800 o 115200 |
--before default_reset | Reinicia la placa en modo bootloader antes de escribir |
--after hard_reset | Reinicia la placa al terminar para que arranque el firmware |
write-flash -u | Escribe sin comprimir; algunos bootloaders lo requieren |
--flash_mode dio | Modo de acceso a la flash SPI, compatible con casi todas las placas |
--flash_freq 40m | Frecuencia del bus de flash |
--flash_size detect | Lee el tamaño real de la flash del chip |
0x1000 | Offset de destino, según la tabla de chips de más arriba |
Recuerda cambiar 0x1000 por 0x0 si tu placa es una S3, C2, C3, C6 o H2, y por 0x2000 en una P4.
Paso 4: confirmar que el VM arrancó
picocom -b 115200 /dev/ttyUSB0
Deberías ver la traza de arranque de ESP-IDF y luego un mensaje de AtomVM indicando que no encontró una aplicación para ejecutar, porque todavía no grabamos ninguna. Ese mensaje es la señal de éxito.
Para salir de picocom se usa la combinación Ctrl-A seguida de Ctrl-X. Si usas screen, la secuencia es Ctrl-A seguida de k. Dejar el monitor abierto impide que esptool tome el puerto, así que ciérralo antes de grabar.
Primer proyecto: Hello World con Mix
Con el VM en la placa, el ciclo de desarrollo pasa a ser puro Elixir.
mix new hello_atomvm --module HelloAtomvm
cd hello_atomvm
Editamos mix.exs:
defmodule HelloAtomvm.MixProject do
use Mix.Project
def project do
[
app: :hello_atomvm,
version: "0.1.0",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
deps: deps(),
atomvm: [
start: HelloAtomvm,
flash_offset: 0x250000
]
]
end
def application do
[]
end
defp deps do
[
{:exatomvm, github: "atomvm/ExAtomVM", runtime: false}
]
end
end
Los dos elementos que importan:
atomvm: [start: HelloAtomvm, ...]declara qué módulo lleva elstart/0y por lo tanto va primero en el Packbeam.flash_offset: 0x250000corresponde a la imagen con Elixir. Si grabaste la imagen estándar, el valor es0x210000.
También conviene vaciar application/0: en AtomVM no hay un árbol de aplicaciones de OTP arrancando por ti, y dejar la configuración por defecto de mix new puede arrastrar dependencias de OTP que no existen en el dispositivo.
Ahora lib/hello_atomvm.ex:
defmodule HelloAtomvm do
def start do
:io.format(~c"Hola desde AtomVM~n")
:io.format(~c"plataforma: ~p~n", [:atomvm.platform()])
bucle(0)
end
defp bucle(n) do
:io.format(~c"tick ~p~n", [n])
:timer.sleep(1000)
bucle(n + 1)
end
end
Y desplegamos:
mix deps.get
mix atomvm.packbeam
mix atomvm.esp32.flash --port /dev/ttyUSB0 --baud 921600
picocom -b 115200 /dev/ttyUSB0
mix atomvm.packbeam compila el proyecto y produce hello_atomvm.avm en la raíz. mix atomvm.esp32.flash invoca esptool por debajo con el offset que declaraste. Si todo salió bien, el monitor muestra un tick por segundo.
El ciclo de trabajo diario
sequenceDiagram
participant Dev as Desarrollador
participant Mix as mix + ExAtomVM
participant Esp as esptool
participant Chip as ESP32
participant Mon as picocom
Dev->>Mix: edita lib/*.ex y ejecuta mix atomvm.packbeam
Mix->>Mix: mix compile genera los .beam
Mix->>Mix: packbeam ordena y produce app.avm
Dev->>Mon: cierra el monitor para liberar el puerto
Dev->>Mix: mix atomvm.esp32.flash --port /dev/ttyUSB0
Mix->>Esp: write-flash en 0x250000
Esp->>Chip: escribe la particion main.avm
Esp->>Chip: hard reset
Chip->>Chip: bootloader carga AtomVM
Chip->>Chip: AtomVM busca el primer start/0
Chip->>Chip: ejecuta la aplicacion
Dev->>Mon: picocom -b 115200 /dev/ttyUSB0
Chip-->>Mon: salida de io:format por UART
Mon-->>Dev: lee la traza y decide el siguiente cambio
El punto que más tiempo cuesta a quien empieza es el paso de cerrar el monitor. El puerto serie es un recurso exclusivo: mientras picocom lo tenga abierto, esptool no puede usarlo y falla con un error de dispositivo ocupado.
Segundo proyecto: parpadeo de un LED con GPIO
Ahora hardware de verdad. Conecta un LED con su resistencia de 220 Ω entre el GPIO 2 y tierra. En muchas placas de desarrollo el GPIO 2 ya tiene un LED integrado, así que puede que no necesites cablear nada.
lib/blink.ex:
defmodule Blink do
@led_pin 2
@intervalo 500
def start do
:io.format(~c"iniciando parpadeo en GPIO ~p~n", [@led_pin])
:gpio.set_pin_mode(@led_pin, :output)
bucle(:low)
end
defp bucle(estado) do
:gpio.digital_write(@led_pin, estado)
:timer.sleep(@intervalo)
bucle(invertir(estado))
end
defp invertir(:low), do: :high
defp invertir(:high), do: :low
end
En mix.exs, cambia start: HelloAtomvm por start: Blink.
Las tres funciones de GPIO que vas a usar todo el tiempo:
| Función | Uso |
|---|---|
:gpio.set_pin_mode(pin, modo) | Configura el pin como :input o :output |
:gpio.digital_write(pin, nivel) | Escribe :high o :low en un pin de salida |
:gpio.digital_read(pin) | Lee :high o :low de un pin de entrada |
:gpio.set_pin_pull(pin, resistencia) | Activa la resistencia interna: :up, :down, :floating |
:gpio.set_int(gpio, pin, disparo) | Registra una interrupción con disparo :rising, :falling, :both o :low/:high |
La interrupción merece un párrafo aparte porque es donde AtomVM se separa de C. En C escribes una función de callback que corre en contexto de interrupción, con todas las restricciones que eso implica. En AtomVM, :gpio.set_int/3 hace que el proceso que la llamó reciba un mensaje cuando el pin cambia. El manejo ocurre en un proceso normal, sin restricciones de contexto de interrupción:
defmodule Boton do
@pin 0
def start do
gpio = :gpio.open()
:gpio.set_pin_mode(@pin, :input)
:gpio.set_pin_pull(@pin, :up)
:gpio.set_int(gpio, @pin, :falling)
escuchar(0)
end
defp escuchar(conteo) do
receive do
{:gpio_interrupt, @pin} ->
nuevo = conteo + 1
:io.format(~c"pulsacion numero ~p~n", [nuevo])
escuchar(nuevo)
end
end
end
Este patrón — traducir un evento de hardware a un mensaje de proceso — es el corazón de la propuesta de AtomVM. Un botón, un sensor y un temporizador se vuelven tres fuentes de mensajes que un mismo receive puede atender, y el árbol de supervisión puede reiniciar el proceso si algo falla. Vamos a construir sobre esta idea en todo el resto del curso.
Tercer proyecto: brillo variable con PWM
El GPIO digital solo entrega dos niveles. Para regular brillo, velocidad de motor o posición de servo se necesita PWM, y en el ESP32 el periférico que lo genera se llama LEDC. AtomVM lo expone en el módulo :ledc.
defmodule Fade do
@gpio_led 2
@resolucion 13
@frecuencia 5000
@canal 0
@timer 0
def start do
modo = :ledc.low_speed_mode()
:ledc.timer_config([
{:duty_resolution, @resolucion},
{:freq_hz, @frecuencia},
{:speed_mode, modo},
{:timer_num, @timer}
])
:ledc.channel_config([
{:channel, @canal},
{:duty, 0},
{:gpio_num, @gpio_led},
{:speed_mode, modo},
{:hpoint, 0},
{:timer_sel, @timer}
])
maximo = trunc(:math.pow(2, @resolucion)) - 1
ciclo(modo, maximo)
end
defp ciclo(modo, maximo) do
subir(modo, 0, maximo)
bajar(modo, maximo, maximo)
ciclo(modo, maximo)
end
defp subir(_modo, valor, maximo) when valor > maximo, do: :ok
defp subir(modo, valor, maximo) do
aplicar(modo, valor)
:timer.sleep(10)
subir(modo, valor + div(maximo, 100), maximo)
end
defp bajar(_modo, valor, _maximo) when valor <= 0, do: :ok
defp bajar(modo, valor, maximo) do
aplicar(modo, valor)
:timer.sleep(10)
bajar(modo, valor - div(maximo, 100), maximo)
end
defp aplicar(modo, valor) do
:ledc.set_duty(modo, 0, valor)
:ledc.update_duty(modo, 0)
end
end
Los conceptos detrás de la configuración:
duty_resolutiones la cantidad de bits del contador de ciclo de trabajo. Con 13 bits el rango va de 0 a 8191. Más resolución da control más fino, pero limita la frecuencia máxima alcanzable, porque el contador debe completar su ciclo dentro del periodo.freq_hzes la frecuencia de la señal. Para un LED, cualquier valor sobre 1 kHz evita el parpadeo visible. Para un servomotor estándar, la frecuencia es 50 Hz, algo que veremos en detalle cuando conectemos actuadores.speed_modedistingue entre los grupos de temporizadores de alta y baja velocidad del periférico.:ledc.low_speed_mode()está disponible en todas las variantes del ESP32; el modo de alta velocidad solo existe en el ESP32 clásico.timer_selychannelte permiten tener varias señales PWM independientes. Varios canales pueden compartir un temporizador si necesitan la misma frecuencia, algo típico al manejar un motor con dos entradas.- La pareja
set_dutyyupdate_dutyno es redundante: la primera carga el valor, la segunda lo aplica. Esto permite preparar varios canales y activarlos de forma coordinada.
Probar sin hardware: AtomVM en tu escritorio
Compilar AtomVM localmente para la plataforma generic_unix te permite ejecutar tu .avm en el computador. No vas a poder usar :gpio ni :ledc, pero sí toda la lógica pura: máquinas de estado, parseo de tramas, cálculos, procesos y mensajes. Es la forma de escribir pruebas rápidas sin conectar nada.
Dependencias de compilación:
# Debian / Ubuntu
sudo apt install build-essential cmake gperf zlib1g-dev libmbedtls-dev erlang rebar3
# Arch
sudo pacman -S base-devel cmake gperf zlib mbedtls erlang rebar3
# macOS
brew install cmake gperf mbedtls erlang rebar3
Compilación:
git clone https://github.com/atomvm/AtomVM.git
cd AtomVM
mkdir build && cd build
cmake ..
make -j$(nproc)
Al terminar tendrás el ejecutable en build/src/AtomVM y la librería del sistema empaquetada en build/libs/atomvmlib.avm. Para ejecutar tu aplicación necesitas ambos:
./src/AtomVM /ruta/a/tu/proyecto/hello_atomvm.avm ./libs/atomvmlib.avm
Escribir esa línea completa en cada prueba es incómodo. Un envoltorio lo resuelve:
sudo tee /usr/local/bin/avm > /dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
ATOMVM_HOME="${ATOMVM_HOME:-$HOME/src/AtomVM/build}"
if [ $# -lt 1 ]; then
echo "uso: avm <archivo.avm>" >&2
exit 1
fi
exec "$ATOMVM_HOME/src/AtomVM" "$1" "$ATOMVM_HOME/libs/atomvmlib.avm"
EOF
sudo chmod +x /usr/local/bin/avm
Ajusta ATOMVM_HOME a la ruta donde clonaste el repositorio. Ahora el ciclo local es:
mix atomvm.packbeam
avm hello_atomvm.avm
Un módulo pensado para probarse en ambos entornos separa la lógica del hardware:
defmodule Termostato do
@moduledoc """
Lógica pura del termostato. No toca hardware, por lo que puede
ejecutarse tanto en el escritorio como en el ESP32.
"""
def decidir(temperatura, objetivo, histeresis) do
cond do
temperatura < objetivo - histeresis -> :encender
temperatura > objetivo + histeresis -> :apagar
true -> :mantener
end
end
def start do
casos = [
{18.0, 22.0, 1.0},
{22.5, 22.0, 1.0},
{24.0, 22.0, 1.0}
]
Enum.each(casos, fn {t, o, h} ->
:io.format(~c"temp=~p objetivo=~p -> ~p~n", [t, o, decidir(t, o, h)])
end)
:ok
end
end
La capa que llama a :gpio.digital_write/2 vive en otro módulo y solo se prueba en la placa. Esta separación no es una preferencia estética: reduce drásticamente cuántas veces necesitas grabar la flash para verificar una decisión lógica.
Qué hay y qué falta de Elixir en AtomVM
Esta es la tabla que evita la mayoría de las frustraciones iniciales. AtomVM implementa un subconjunto, y el subconjunto es más pequeño de lo que la costumbre de escribir Elixir en servidores hace esperar.
| Elemento de Elixir | Estado en AtomVM | Alternativa |
|---|---|---|
Pattern matching, guards, case, cond, with | Disponible | — |
Procesos: spawn, send, receive, link, monitor | Disponible | — |
GenServer y Supervisor | Disponible vía estdlib | — |
Structs y defstruct | Disponible | — |
| Protocolos | Soporte limitado | Usar despacho explícito por pattern matching |
Enum | Parcial y dependiente de la versión de la imagen | Usar :lists.map/2, :lists.foldl/3, :lists.filter/2 |
Stream | No disponible | Recursión explícita |
Regex | No disponible | :binary.match/2, :binary.split/2, parseo manual |
String con soporte Unicode completo | Parcial | Trabajar con binarios y :binary |
| Enteros arbitrariamente grandes | No, límite de 64 bits | Reformular el cálculo |
| Átomos de más de 255 bytes | No | Usar binarios como identificadores |
| Recarga de código en caliente | No | Regrabar la aplicación |
IEx / REPL | No | Depurar por :io.format/2 sobre el UART |
Task y Agent | No garantizados | spawn y GenServer directamente |
Logger | Vía estdlib, no la implementación de Elixir | :logger o :io.format |
mix test sobre el dispositivo | No | Probar lógica pura en generic_unix |
La regla práctica: cuando una función de la librería estándar de Elixir no exista, busca el módulo Erlang equivalente. Elixir se construyó sobre esos módulos, así que la traducción suele ser directa. Enum.map/2 es :lists.map/2 con los argumentos en el mismo orden. String.split/2 sobre binarios es :binary.split/3. Integer.to_string/1 es :erlang.integer_to_binary/1.
Un ejemplo de la misma lógica escrita de las dos formas:
# Estilo Elixir habitual: puede fallar en AtomVM según la versión de la imagen
def promedio_elixir(lecturas) do
Enum.sum(lecturas) / Enum.count(lecturas)
end
# Estilo compatible: apoyado solo en módulos de Erlang presentes en estdlib
def promedio_seguro([]), do: 0.0
def promedio_seguro(lecturas) do
suma = :lists.foldl(fn valor, acc -> valor + acc end, 0, lecturas)
suma / length(lecturas)
end
La segunda versión es un poco más larga y funciona con certeza. En firmware, la certeza pesa más que la elegancia.
Errores comunes
| Síntoma | Causa | Solución |
|---|---|---|
| La placa se reinicia en bucle tras grabar la aplicación | El flash_offset no corresponde a la imagen grabada: aplicación escrita en 0x210000 sobre una imagen de Elixir | Fijar flash_offset: 0x250000 en mix.exs para imágenes con Elixir, 0x210000 para las estándar |
esptool responde con dispositivo ocupado o permiso denegado sobre el puerto | El monitor serie tiene el puerto abierto, o el usuario no está en el grupo dialout | Cerrar picocom con Ctrl-A Ctrl-X; ejecutar sudo usermod -aG dialout $USER y reiniciar sesión |
esptool no detecta el chip o falla al conectar | La placa no entró en modo bootloader | Mantener presionado BOOT, pulsar y soltar EN, soltar BOOT; o bajar --baud a 115200 |
| El VM arranca pero no ejecuta nada | Ningún módulo del Packbeam exporta start/0, o el módulo declarado en start: no es el primero | Verificar que la función sea pública, sin argumentos, y que coincida con atomvm: [start: ...] |
Error de función no definida en Enum o Regex | El módulo no existe en AtomVM | Reemplazar por el módulo Erlang equivalente: :lists, :binary, :maps |
io.format imprime basura o nada | Se pasó un binario donde se esperaba un charlist | Usar ~c"texto" en lugar de "texto" al llamar funciones de Erlang |
| El monitor muestra caracteres ilegibles | Velocidad del puerto serie incorrecta | Abrir a 115200 baudios; la traza del bootloader de ESP-IDF sale a otra velocidad y siempre se ve rara |
La aplicación funciona en generic_unix pero falla en el ESP32 | Se usó una API que solo existe en una plataforma, o se agotó la memoria | Separar la lógica pura del acceso a hardware; revisar el tamaño del heap por proceso |
mix atomvm.packbeam no encuentra la tarea | ExAtomVM no está instalado o falta mix deps.get | Agregar {:exatomvm, github: "atomvm/ExAtomVM", runtime: false} y ejecutar mix deps.get |
| La imagen descargada no arranca y no hay salida por serie | Descarga corrupta, u offset del bootloader equivocado para la variante del chip | Verificar el sha256sum contra la release; usar 0x0 en S3/C3/C6, 0x1000 en ESP32 clásico, 0x2000 en P4 |
| Un proceso muere y se lleva la aplicación completa | El proceso inicial estaba enlazado sin supervisión | Envolver los procesos de trabajo en un Supervisor de estdlib |
| El LED con PWM parpadea de forma visible | Frecuencia de LEDC demasiado baja | Subir freq_hz por sobre 1000 |
mix new genera un proyecto que falla al compilar para AtomVM | La configuración por defecto arrastra aplicaciones de OTP inexistentes | Dejar def application do [] end vacío |
Diagnóstico: qué mirar cuando algo falla
stateDiagram-v2
[*] --> Conectada: placa conectada por USB
Conectada --> SinPuerto: no aparece /dev/ttyUSB*
SinPuerto --> Conectada: revisar cable de datos y driver CP2102/CH340
Conectada --> Flasheando: esptool detecta el chip
Conectada --> SinDeteccion: falla la conexion
SinDeteccion --> Flasheando: modo bootloader manual, bajar baudrate
Flasheando --> VMArrancado: imagen escrita en el offset correcto
Flasheando --> BootLoop: offset del bootloader equivocado
BootLoop --> Flasheando: erase-flash y regrabar con el offset del chip
VMArrancado --> SinApp: mensaje de aplicacion no encontrada
SinApp --> AppCargada: grabar el .avm en el flash_offset correcto
VMArrancado --> AppCargada: aplicacion presente
AppCargada --> Ejecutando: existe un modulo con start/0
AppCargada --> SinEntrada: ningun start/0 exportado
SinEntrada --> AppCargada: revisar atomvm start en mix.exs
Ejecutando --> Crash: excepcion no capturada
Crash --> Ejecutando: leer la traza por UART y supervisar el proceso
Ejecutando --> [*]
La secuencia de verificación cuando algo no anda, en este orden:
- ¿Aparece el dispositivo en
/dev? Si no, el problema es el cable o el driver del conversor USB-serie. Muchos cables USB baratos solo llevan alimentación. - ¿
esptooldetecta el chip? Pruebaesptool --port /dev/ttyUSB0 chip-id. Si falla, es modo bootloader o velocidad. - ¿El VM arranca? Abre el monitor y busca la traza. Si no hay nada, revisa el offset del bootloader.
- ¿El VM encuentra la aplicación? Si el mensaje dice que no hay aplicación, es el
flash_offset. - ¿La aplicación ejecuta
start/0? Si arranca y no hace nada, revisa la opciónstart:y que la función sea pública. - Solo después de todo esto, el problema está en tu código.
Ejercicios propuestos
-
Inventario del entorno. Escribe un script
verificar.shque compruebe queelixir,esptoolypicocomestán instalados, imprima la versión de cada uno y liste los dispositivos/dev/tty*que aparecieron en los últimos cinco minutos. Debe fallar con un mensaje claro por cada herramienta ausente. -
Mapa de tu placa. Identifica la variante exacta de tu ESP32 con
esptool --port /dev/ttyUSB0 chip-idy documenta en una tabla propia: variante, offset de bootloader que corresponde, tamaño de flash, cantidad de núcleos y si soporta el modo de alta velocidad de LEDC. -
Semáforo con tres procesos. Escribe una aplicación con tres LED (rojo, amarillo, verde) donde cada color sea manejado por un proceso independiente y un cuarto proceso coordinador les envíe mensajes indicando cuándo encenderse. Ningún proceso debe llamar a
:timer.sleeppara esperar su turno: la sincronización debe ser por mensajes. -
Pulso variable. Modifica el ejemplo de PWM para que la resolución sea configurable como parámetro y verifica experimentalmente a partir de qué frecuencia el LED deja de parpadear visiblemente para ti. Anota el resultado junto con la resolución usada.
-
Lógica probable en escritorio. Toma la función
Termostato.decidir/3y agrégale histéresis asimétrica: un umbral para encender y otro distinto para apagar. Escríbela como módulo puro, pruébala con al menos ocho casos ejecutandoavmen tu escritorio y solo entonces conéctala a un GPIO. -
Traducción a Erlang. Toma un módulo Elixir tuyo que use
Enum.map/2,Enum.filter/2,Enum.reduce/3yString.split/2, y reescríbelo usando exclusivamente:listsy:binary. Compara la longitud de ambas versiones y anota qué construcciones no tuvieron traducción directa. -
Contador persistente. Investiga el módulo
:espdeeavmliby su acceso a NVS. Escribe una aplicación que cuente cuántas veces se ha reiniciado el dispositivo, guarde el valor en almacenamiento no volátil y lo imprima al arrancar. -
Entorno reproducible. Escribe un
devenv.nixo unDockerfileque deje listo el entorno completo — Elixir, esptool, ExAtomVM ya descargado — de forma que otra persona pueda clonar el repositorio, entrar al entorno y grabar la placa sin instalar nada más en su sistema.
Lo que viene
En este capítulo pasamos de no saber qué es AtomVM a tener un ESP32 ejecutando código Elixir con un LED variando su brillo. Vimos que el bytecode que corre en el chip es el mismo que produce el compilador de siempre, que el VM está construido en capas bien delimitadas — núcleo en C, capa de plataforma, librerías en Erlang — y que el ciclo de trabajo se reduce a empaquetar un .avm y escribirlo en una partición conocida de la flash. También delimitamos el terreno: sabemos qué partes de Elixir están disponibles, cuáles no, y cómo traducir las que faltan a sus equivalentes de Erlang.
Lo que todavía no hicimos es salir del subconjunto. Cuando una librería de Erlang no alcanza — porque necesitas hablar con un sensor que usa un protocolo propietario, porque un cálculo debe correr a velocidad de C, o porque quieres exponer un periférico que eavmlib no cubre — hay que bajar al nivel nativo. En el capítulo 14 vamos exactamente ahí: escribimos NIFs propios en C y los enlazamos al VM, revisamos Popcorn y cómo AtomVM llega al navegador vía WebAssembly, y construimos un esquema de control remoto sobre WiFi para gobernar el dispositivo desde fuera. El índice completo del curso está en /tecnologias/elixir-robotics/00-indice/.