AtomVM: la BEAM dentro de un microcontrolador

Por: Artiko
elixirroboticaiotelectronicaatomvmesp32beampackbeamexatomvmfirmware

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 elixirc o mix 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.

AspectoBEAM oficial (OTP)AtomVM
Tamaño del runtimedecenas de MB con OTP completomenos de 500 KB de imagen
RAM mínima realista~100 MB para algo útilfunciona con ~100 KB libres
Sistema operativorequiere Linux, macOS, Windows o similarcorre sobre FreeRTOS o sin SO
Compilación JITsí, desde OTP 24 en x86/ARM64no, solo interpretación
Hot code reloadingno
REPL / shell interactivano
Enteros grandes (bignums)ilimitadoslimitados a 64 bits
Átomoslongitud ampliamáximo 255 bytes
Librería estándarOTP completosubconjunto en estdlib
Acceso a GPIO, I2C, SPI, PWMvía NIFs o puertos externosintegrado en eavmlib
Destino típicoservidores, contenedoresESP32, 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:

  1. 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.
  2. 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.
  3. 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 .beam que sale de mix compile es idéntico al que correría en un servidor.
  • El paso de empaquetado toma uno o más .beam y 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.exs con la opción start: 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 :ok puede provocar un reinicio del dispositivo. Trata start/0 como 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/1 esperan 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íaAproximado de módulosQué contieneEjemplos de módulos
estdlib~44Reimplementación reducida de OTPlists, maps, binary, string, calendar, timer, io, gen_server, gen_statem, supervisor, application, gen_tcp, gen_udp, inet, crypto, math, ets, base64, logger
eavmlib~24APIs propias de AtomVM y del hardwaregpio, ledc, i2c, spi, uart, esp, esp_adc, network, console, atomvm, port, json_encoder, http_server, ahttp_client, websocket, mdns, avm_pubsub, pico, emscripten
alisp6Intérprete de un dialecto Lisp sobre AtomVMalisp, alisp_stdlib, arepl, sexp_lexer, sexp_parser, sexp_serializer
etest2Pruebas reducidasetest, 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 .beam de tu aplicación.
  • Los .beam de 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ónContenidoSe graba
Offset del bootloader (varía por chip)Segundo bootloader de EspressifUna vez, con la imagen del VM
Tabla de particionesDescribe el resto del mapaUna vez, con la imagen del VM
Partición de aplicación (factory)El firmware de AtomVM: libAtomVM y las librerías ErlangUna vez, con la imagen del VM
Partición boot.avmLibrerías del sistema en formato AVMUna vez, con la imagen del VM
Partición main.avmTu aplicaciónEn cada despliegue
NVSAlmacenamiento clave-valor no volátilEn 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:

ChipOffset del bootloader
ESP320x1000
ESP32-S20x1000
ESP32-S30x0
ESP32-C20x0
ESP32-C30x0
ESP32-C60x0
ESP32-H20x0
ESP32-P40x2000

El offset de la partición main.avm, donde va tu aplicación, depende de la imagen que hayas grabado:

Tipo de imagenOffset de main.avm
Imagen estándar (solo Erlang)0x210000
Imagen con módulos de Elixir0x250000

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:

  1. Erlang/OTP y Elixir. El compilador de Elixir debe correr en tu escritorio.
  2. esptool. La herramienta de Espressif para escribir en la flash por USB.
  3. Un monitor serie. picocom, screen, minicom o idf.py monitor.
  4. La imagen de AtomVM para tu chip, descargada desde las releases del proyecto.
  5. 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ónSignificado
--chip autoDetecta la variante del ESP32 conectada
--portArchivo de dispositivo serie
--baud 921600Velocidad de grabación; si falla, baja a 460800 o 115200
--before default_resetReinicia la placa en modo bootloader antes de escribir
--after hard_resetReinicia la placa al terminar para que arranque el firmware
write-flash -uEscribe sin comprimir; algunos bootloaders lo requieren
--flash_mode dioModo de acceso a la flash SPI, compatible con casi todas las placas
--flash_freq 40mFrecuencia del bus de flash
--flash_size detectLee el tamaño real de la flash del chip
0x1000Offset 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 el start/0 y por lo tanto va primero en el Packbeam.
  • flash_offset: 0x250000 corresponde a la imagen con Elixir. Si grabaste la imagen estándar, el valor es 0x210000.

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ónUso
: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_resolution es 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_hz es 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_mode distingue 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_sel y channel te 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_duty y update_duty no 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 ElixirEstado en AtomVMAlternativa
Pattern matching, guards, case, cond, withDisponible
Procesos: spawn, send, receive, link, monitorDisponible
GenServer y SupervisorDisponible vía estdlib
Structs y defstructDisponible
ProtocolosSoporte limitadoUsar despacho explícito por pattern matching
EnumParcial y dependiente de la versión de la imagenUsar :lists.map/2, :lists.foldl/3, :lists.filter/2
StreamNo disponibleRecursión explícita
RegexNo disponible:binary.match/2, :binary.split/2, parseo manual
String con soporte Unicode completoParcialTrabajar con binarios y :binary
Enteros arbitrariamente grandesNo, límite de 64 bitsReformular el cálculo
Átomos de más de 255 bytesNoUsar binarios como identificadores
Recarga de código en calienteNoRegrabar la aplicación
IEx / REPLNoDepurar por :io.format/2 sobre el UART
Task y AgentNo garantizadosspawn y GenServer directamente
LoggerVía estdlib, no la implementación de Elixir:logger o :io.format
mix test sobre el dispositivoNoProbar 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íntomaCausaSolución
La placa se reinicia en bucle tras grabar la aplicaciónEl flash_offset no corresponde a la imagen grabada: aplicación escrita en 0x210000 sobre una imagen de ElixirFijar 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 puertoEl monitor serie tiene el puerto abierto, o el usuario no está en el grupo dialoutCerrar picocom con Ctrl-A Ctrl-X; ejecutar sudo usermod -aG dialout $USER y reiniciar sesión
esptool no detecta el chip o falla al conectarLa placa no entró en modo bootloaderMantener presionado BOOT, pulsar y soltar EN, soltar BOOT; o bajar --baud a 115200
El VM arranca pero no ejecuta nadaNingún módulo del Packbeam exporta start/0, o el módulo declarado en start: no es el primeroVerificar que la función sea pública, sin argumentos, y que coincida con atomvm: [start: ...]
Error de función no definida en Enum o RegexEl módulo no existe en AtomVMReemplazar por el módulo Erlang equivalente: :lists, :binary, :maps
io.format imprime basura o nadaSe pasó un binario donde se esperaba un charlistUsar ~c"texto" en lugar de "texto" al llamar funciones de Erlang
El monitor muestra caracteres ilegiblesVelocidad del puerto serie incorrectaAbrir 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 ESP32Se usó una API que solo existe en una plataforma, o se agotó la memoriaSeparar la lógica pura del acceso a hardware; revisar el tamaño del heap por proceso
mix atomvm.packbeam no encuentra la tareaExAtomVM no está instalado o falta mix deps.getAgregar {:exatomvm, github: "atomvm/ExAtomVM", runtime: false} y ejecutar mix deps.get
La imagen descargada no arranca y no hay salida por serieDescarga corrupta, u offset del bootloader equivocado para la variante del chipVerificar 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 completaEl proceso inicial estaba enlazado sin supervisiónEnvolver los procesos de trabajo en un Supervisor de estdlib
El LED con PWM parpadea de forma visibleFrecuencia de LEDC demasiado bajaSubir freq_hz por sobre 1000
mix new genera un proyecto que falla al compilar para AtomVMLa configuración por defecto arrastra aplicaciones de OTP inexistentesDejar 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:

  1. ¿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.
  2. ¿esptool detecta el chip? Prueba esptool --port /dev/ttyUSB0 chip-id. Si falla, es modo bootloader o velocidad.
  3. ¿El VM arranca? Abre el monitor y busca la traza. Si no hay nada, revisa el offset del bootloader.
  4. ¿El VM encuentra la aplicación? Si el mensaje dice que no hay aplicación, es el flash_offset.
  5. ¿La aplicación ejecuta start/0? Si arranca y no hace nada, revisa la opción start: y que la función sea pública.
  6. Solo después de todo esto, el problema está en tu código.

Ejercicios propuestos

  1. Inventario del entorno. Escribe un script verificar.sh que compruebe que elixir, esptool y picocom está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.

  2. Mapa de tu placa. Identifica la variante exacta de tu ESP32 con esptool --port /dev/ttyUSB0 chip-id y 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.

  3. 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.sleep para esperar su turno: la sincronización debe ser por mensajes.

  4. 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.

  5. Lógica probable en escritorio. Toma la función Termostato.decidir/3 y 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 ejecutando avm en tu escritorio y solo entonces conéctala a un GPIO.

  6. Traducción a Erlang. Toma un módulo Elixir tuyo que use Enum.map/2, Enum.filter/2, Enum.reduce/3 y String.split/2, y reescríbelo usando exclusivamente :lists y :binary. Compara la longitud de ambas versiones y anota qué construcciones no tuvieron traducción directa.

  7. Contador persistente. Investiga el módulo :esp de eavmlib y 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.

  8. Entorno reproducible. Escribe un devenv.nix o un Dockerfile que 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/.