ESP32, STM32 y nRF52: conectividad, consumo y criterios para elegir microcontrolador

Por: Artiko
elixirroboticaiotelectronicaesp32stm32nrf52atomvmblebajo-consumo

ESP32, STM32 y nRF52: conectividad, consumo y criterios para elegir microcontrolador

En el capítulo 7 recorrimos las dos placas con las que casi todo el mundo empieza: Arduino, un microcontrolador de 8 bits con un entorno amable y una comunidad enorme, y Raspberry Pi, un computador Linux completo capaz de correr Nerves y hablar con el mundo por red. Ese par cubre los extremos del espectro: lo muy simple y lo muy capaz. El problema aparece cuando el proyecto deja de ser un experimento y necesita algo del medio: un nodo que dure meses con una pila de botón, un controlador de motor que responda en microsegundos sin jitter, o un sensor que se conecte solo a la red WiFi de la casa.

Ese hueco lo llenan tres familias de silicio que hoy concentran la mayor parte de los diseños reales de robótica e IoT: ESP32 de Espressif, STM32 de STMicroelectronics y nRF52 de Nordic Semiconductor. No compiten exactamente en lo mismo. Cada una nació resolviendo un problema distinto, y esa diferencia de origen sigue marcando qué tan bien se comportan cuando les pides algo que no está en su zona natural.

Este capítulo es un análisis comparado de las tres. Vamos a ver qué hay dentro de cada chip, qué periféricos traen, cuánta corriente consumen en cada estado, con qué herramientas se compilan y se graban, y cómo se conectan con Elixir. Al final vas a tener un método concreto —no una intuición— para decidir cuál va en tu próximo proyecto, y un modelo de cálculo para estimar la vida de la batería antes de comprar nada.

Tres filosofías de silicio

Antes de mirar hojas de datos conviene entender la intención de diseño de cada familia, porque explica casi todas sus decisiones técnicas. El ESP32 nació como el sucesor del ESP8266, un chip que se hizo famoso por una razón brutal: era un módulo WiFi completo que costaba menos que el conector de red que reemplazaba. Espressif diseñó el ESP32 poniendo la conectividad en el centro: radios WiFi y Bluetooth integradas en el mismo die que la CPU, con toda la pila de protocolos ya escrita y probada. El resto del chip —GPIO, ADC, timers— es competente, pero es acompañamiento. Si tu problema empieza con “necesito que esto se conecte a internet”, el ESP32 es la respuesta por defecto.

El STM32 es lo opuesto. ST viene del mundo del control industrial y del automotriz, y su prioridad es el determinismo: que una interrupción llegue en un número exacto de ciclos, que un timer genere PWM complementario con tiempo muerto configurable para un puente en H de un motor trifásico, que el ADC dispare por hardware sincronizado con ese timer y guarde la muestra por DMA sin que la CPU intervenga. La radio no viene incluida (salvo en las series WB y WL) porque en una fábrica el cable es más confiable. Si tu problema empieza con “esto tiene que responder siempre en menos de X microsegundos”, el STM32 es la respuesta.

El nRF52 es un especialista en radio de bajo consumo. Nordic construyó la familia alrededor de un transceptor 2,4 GHz extremadamente eficiente y una CPU que sabe dormir de verdad. Su métrica de orgullo no son los MHz sino los microamperes: cuánta carga gasta un ciclo completo de despertar, medir un sensor, transmitir un paquete BLE y volver a dormir. Si tu problema empieza con “esto tiene que durar dos años con una CR2032”, el nRF52 es la respuesta.

flowchart TD
    P[Requisito dominante del proyecto] --> C{¿Qué manda?}
    C -->|Conectividad IP,<br/>throughput, cloud| E[ESP32<br/>WiFi + BT integrados<br/>240 MHz dual-core]
    C -->|Timing duro,<br/>control de motores,<br/>certificación industrial| S[STM32<br/>Cortex-M0 a M7<br/>timers avanzados + DMA]
    C -->|Años de batería,<br/>enlace BLE, wearable| N[nRF52<br/>Cortex-M4F 64 MHz<br/>radio de ultrabajo consumo]
    E --> E1[Costo por nodo bajo<br/>Consumo activo alto]
    S --> S1[Consumo controlable<br/>Sin radio salvo WB/WL]
    N --> N1[Consumo mínimo<br/>Sin IP nativo, sin WiFi]
    E1 --> H[Nodo hub con Linux/Nerves<br/>agrega, decide y expone]
    S1 --> H
    N1 --> H

Ese diagrama ya adelanta la conclusión práctica del capítulo: en un sistema real rara vez eliges uno. Eliges una topología donde cada silicio hace lo que sabe hacer, y un nodo con Linux —típicamente Nerves sobre Raspberry Pi o similar— coordina el conjunto.

ESP32: la conectividad como punto de partida

El ESP32 no es un chip, es una familia

Cuando alguien dice “ESP32” a secas casi siempre se refiere al chip original de 2016, el ESP32-D0WD, pero desde entonces Espressif publicó una línea completa que se divide en dos ramas según la arquitectura del núcleo.

ModeloArquitecturaNúcleos / FrecuenciaWiFiBluetooth802.15.4Rasgo distintivo
ESP32Xtensa LX62 × 240 MHz802.11 b/g/nBR/EDR + BLE 4.2NoEl clásico; Ethernet MAC, 2 DAC, touch
ESP32-S2Xtensa LX71 × 240 MHz802.11 b/g/nNoNoUSB OTG, muchos GPIO, sin Bluetooth
ESP32-S3Xtensa LX72 × 240 MHz802.11 b/g/nBLE 5NoInstrucciones vectoriales para IA, USB OTG, LCD/camera
ESP32-C3RISC-V1 × 160 MHz802.11 b/g/nBLE 5NoReemplazo económico del ESP8266
ESP32-C6RISC-V (HP + LP)160 MHz + 20 MHz802.11 ax (WiFi 6)BLE 5.3Thread y Zigbee; núcleo de bajo consumo separado
ESP32-H2RISC-V1 × 96 MHzNoBLE 5.3Solo radios de bajo consumo, sin WiFi
ESP32-P4RISC-V2 × 400 MHzNoNoNoAlto rendimiento sin radio; se acompaña de un ESP32-C6

Dos lecturas importantes de esa tabla. La primera: la rama RISC-V es la dirección de futuro, porque los chips nuevos (C3, C6, H2, P4) abandonan el núcleo propietario Xtensa por una arquitectura abierta con soporte creciente en LLVM, lo que facilita el mantenimiento de toolchains. La segunda: la rama Xtensa todavía tiene más periféricos exóticos, y el S3 sigue siendo el chip con más capacidades de interfaz (controlador LCD paralelo, interfaz de cámara, PSRAM octal) y con aceleración vectorial para inferencia de modelos pequeños.

Un detalle que confunde a mucha gente: chip, módulo y placa son cosas distintas. El chip es el ESP32-D0WD. El módulo es el ESP32-WROOM-32, que mete el chip, el cristal, la memoria flash SPI, la antena y un blindaje metálico en un paquete certificado. La placa es la DevKitC, que agrega el módulo, un conversor USB-serie, un regulador de 3,3 V, botones de BOOT y EN, y pines. Cuando compras “un ESP32” estás comprando la placa; cuando diseñas un producto compras el módulo (porque viene con la certificación de radio ya hecha, que es cara y lenta de obtener por cuenta propia).

Qué hay dentro del ESP32 original

El chip clásico se fabrica en 40 nm y organiza sus recursos así:

  • CPU: dos núcleos Tensilica Xtensa LX6 de 32 bits, configurables entre 80 y 240 MHz. Se llaman PRO_CPU (núcleo 0) y APP_CPU (núcleo 1). Por convención, la pila de red corre en el núcleo 0 y la aplicación en el núcleo 1, aunque no es obligatorio.
  • Memoria: 520 KB de SRAM interna, 448 KB de ROM con rutinas de arranque, y 16 KB de SRAM en el dominio RTC (que sobrevive al deep sleep). La flash es externa, típicamente 4 MB en un módulo WROOM, mapeada en el espacio de direcciones por un caché.
  • Coprocesador ULP: una máquina de estados minúscula que puede leer ADC y GPIO mientras los núcleos principales duermen, consumiendo microamperes. Es la pieza que permite usar el ESP32 en aplicaciones con batería sin que sea un desastre.
  • Radios: WiFi 802.11 b/g/n en 2,4 GHz y Bluetooth de doble modo (clásico BR/EDR y BLE). Comparten una sola antena y un solo front-end de RF, lo que significa que WiFi y Bluetooth se turnan el aire.
  • Rango de alimentación: 2,3 V a 3,6 V. Los GPIO no son tolerantes a 5 V; conectar un sensor de 5 V directo a un pin es una de las formas más comunes de matar el chip.

Los periféricos digitales relevantes para robótica:

PeriféricoCantidadNota práctica
GPIO34 (no todos usables)GPIO 6–11 están cableados a la flash; GPIO 34–39 son solo entrada y sin pull-up interno
ADC SAR de 12 bits2 unidades, 18 canalesADC2 queda inutilizable mientras el WiFi está activo
DAC de 8 bits2GPIO 25 y 26; útiles para audio simple o referencias
Sensores táctiles capacitivos10Detectan contacto sin componentes externos
UART3UART0 se usa para el log de arranque y el flasheo
I2C2Ambos con soporte de maestro y esclavo
SPI4 (2 disponibles)SPI0 y SPI1 quedan tomados por la flash
I2S2Audio digital, también micrófonos PDM
PWM (LEDC)16 canalesResolución configurable hasta 20 bits
MCPWM2 unidadesOrientado a control de motores, con tiempo muerto
Pulse counter8 unidadesCuenta encoders por hardware sin cargar la CPU
TWAI (compatible CAN 2.0)1Necesita un transceptor externo tipo SN65HVD230
Ethernet MAC1Necesita PHY externo (LAN8720 es el típico)

La trampa clásica del ADC2 merece una línea aparte: el segundo conversor comparte recursos con el subsistema de radio, así que cualquier lectura analógica en un canal de ADC2 falla mientras el WiFi está conectado. Si tu diseño necesita entradas analógicas y red al mismo tiempo, todas las señales deben ir a canales del ADC1 (GPIO 32 a 39). Es una restricción de silicio, no un bug de software, y hay que resolverla en el esquemático.

Los modos de energía del ESP32

El ESP32 tiene un consumo activo alto comparado con sus competidores, porque una radio WiFi transmitiendo es cara en energía. La estrategia de diseño no es reducir el consumo activo sino minimizar el tiempo que se pasa activo.

stateDiagram-v2
    [*] --> Activo
    Activo: Activo (CPU + radio)<br/>95-260 mA
    ModemSleep: Modem-sleep<br/>CPU activa, radio apagada<br/>20-68 mA
    LightSleep: Light-sleep<br/>CPU en pausa, RAM retenida<br/>~0.8 mA
    DeepSleep: Deep-sleep<br/>solo dominio RTC + ULP<br/>~10 uA
    Hibernacion: Hibernación<br/>solo timer RTC<br/>~5 uA

    Activo --> ModemSleep: apagar radio entre transmisiones
    ModemSleep --> Activo: DTIM beacon / envío
    ModemSleep --> LightSleep: sin trabajo pendiente
    LightSleep --> ModemSleep: interrupción GPIO / timer
    LightSleep --> DeepSleep: dormir largo
    DeepSleep --> Activo: reinicio desde ROM<br/>(pierde la RAM, no el RTC)
    DeepSleep --> Hibernacion: apagar también la RAM RTC
    Hibernacion --> Activo: solo timer o EXT0

Las cifras aproximadas —las exactas dependen del modelo, la tensión y la temperatura, y hay que confirmarlas en la hoja de datos del chip concreto— son del orden de:

EstadoCorriente típicaQué sigue vivoTiempo de salida
WiFi transmitiendo (802.11n, 20 dBm)180–260 mA en picosTodo
WiFi recibiendo / conectado ocioso95–110 mATodo
Modem-sleep (radio apagada, CPU 240 MHz)30–68 mACPU, RAM, periféricosinmediato
Modem-sleep (CPU 80 MHz)20–25 mACPU, RAM, periféricosinmediato
Light-sleep~0,8 mARAM, periféricos configurados~1 ms
Deep-sleep con ULP activo~150 µADominio RTC + ULP~10 ms
Deep-sleep sin ULP~10 µADominio RTC, RAM RTC~10 ms
Hibernación~5 µASolo timer RTC~10 ms

La consecuencia de diseño es directa: el ESP32 con batería solo funciona si duerme la mayor parte del tiempo. Un sensor que despierta cada 15 minutos, se conecta al WiFi, publica una medición por MQTT y vuelve a dormir puede promediar menos de 100 µA; el mismo sensor con la conexión permanente promediará 100 mA y vaciará una batería de 2000 mAh en menos de un día. Y hay un costo escondido en ese ciclo: la asociación WiFi consume energía. Reconectarse a un punto de acceso, negociar DHCP y establecer TLS puede tomar entre 1 y 5 segundos a 100 mA o más. En un ciclo de despertar de 3 segundos, la conexión es el 90 % del gasto. Optimizar eso —guardando el canal y el BSSID en la RAM RTC, usando IP estática, reutilizando sesión TLS— es lo que separa un prototipo de un producto.

Toolchains del ESP32

ToolchainLenguajeCuándo tiene sentido
ESP-IDFC / C++Es el SDK oficial, basado en FreeRTOS. Acceso completo a todo el hardware. Obligatorio para producto serio.
Arduino-ESP32C++Capa sobre ESP-IDF con la API de Arduino. Prototipado rápido, enorme cantidad de librerías.
PlatformIOC / C++Gestor de proyectos y dependencias que orquesta ESP-IDF o Arduino desde VS Code, con builds reproducibles.
MicroPythonPythonIteración interactiva por REPL. Cómodo para explorar sensores, lento para lazos de control.
AtomVMElixir / ErlangMáquina virtual BEAM reducida corriendo en el propio ESP32. Procesos, mensajes y supervisión en el microcontrolador.
Rust (esp-rs)RustSoporte oficial de Espressif, tanto bare-metal como sobre ESP-IDF.

Instalar ESP-IDF y compilar un proyecto vacío se ve así:

# Clonar el SDK oficial en una versión fija (nunca trabajes contra master)
git clone -b v5.3 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf

# Instalar los compiladores (xtensa-esp32-elf o riscv32-esp-elf según el target)
cd ~/esp/esp-idf && ./install.sh esp32,esp32c3
. ~/esp/esp-idf/export.sh          # carga el entorno en la shell actual

# Crear un proyecto desde una plantilla y compilarlo
cp -r ~/esp/esp-idf/examples/get-started/hello_world ~/proyectos/hola
cd ~/proyectos/hola && idf.py set-target esp32 && idf.py build

# Grabar y abrir el monitor serie (Ctrl+] para salir)
idf.py -p /dev/ttyUSB0 flash monitor

Si el flasheo falla con un timeout, casi siempre es porque la placa no entró en modo bootloader. En las DevKit con conversor CP2102 el autoreset funciona solo; en clones con CH340 a veces hay que mantener presionado BOOT, tocar EN y soltar BOOT.

ESP32 y Elixir: AtomVM

Aquí aparece la pieza que hace que este capítulo pertenezca a un curso de Elixir. Nerves no corre en un ESP32: Nerves construye un sistema Linux embebido, y Linux necesita una MMU y decenas de megabytes de RAM que el ESP32 no tiene. Confundir esto es el malentendido más frecuente de quien llega desde Elixir al mundo del microcontrolador.

Lo que sí corre en el ESP32 es AtomVM, una implementación reducida de la máquina virtual BEAM escrita en C, pensada para dispositivos con cientos de kilobytes de RAM. AtomVM ejecuta bytecode BEAM real —el mismo que produce elixirc— e implementa procesos ligeros, paso de mensajes, receive, monitores y una porción del OTP. No es Elixir completo: no hay compilación en el dispositivo, la biblioteca estándar está recortada y hay que cuidar la memoria. Pero el modelo de concurrencia es el mismo.

AtomVM soporta hoy ESP32 (Xtensa y RISC-V), STM32, Raspberry Pi Pico y una versión para escritorio útil para probar lógica.

Un proyecto Elixir para AtomVM se organiza con el plugin ExAtomVM:

# mix.exs
defmodule Sensor.MixProject do
  use Mix.Project

  def project do
    [
      app: :sensor,
      version: "0.1.0",
      elixir: "~> 1.16",
      deps: [{:exatomvm, github: "atomvm/ExAtomVM", runtime: false}],
      atomvm: [start: Sensor, flash_offset: 0x250000]
    ]
  end

  def application, do: [extra_applications: []]
end

Y el módulo de entrada, un parpadeo con supervisión mínima escrito con el estilo de procesos que ya conoces:

defmodule Sensor do
  @moduledoc """
  Punto de entrada para AtomVM. La función start/0 es la que arranca
  cuando el firmware bootea; no hay Application ni supervisor OTP completo,
  así que la estructura de procesos se levanta a mano.
  """

  @led_pin 2
  @periodo_ms 500

  def start do
    GPIO.set_pin_mode(@led_pin, :output)

    parpadeo = spawn(fn -> parpadear(:low) end)
    latido = spawn(fn -> latir() end)

    # El proceso principal no puede terminar: si retorna, la VM se apaga.
    supervisar([parpadeo, latido])
  end

  defp parpadear(nivel) do
    GPIO.digital_write(@led_pin, nivel)
    Process.sleep(@periodo_ms)
    parpadear(invertir(nivel))
  end

  defp invertir(:low), do: :high
  defp invertir(:high), do: :low

  # Testigo periódico: cuántos procesos vivos hay en la VM.
  defp latir do
    Process.sleep(5_000)
    :erlang.display({:vivo, :erlang.system_info(:process_count)})
    latir()
  end

  # Monitorea a los hijos y se queda bloqueado en receive para siempre.
  defp supervisar(hijos) do
    Enum.each(hijos, &Process.monitor/1)

    receive do
      {:DOWN, _ref, :process, pid, motivo} ->
        :erlang.display({:hijo_caido, pid, motivo})
        supervisar([])
    end
  end
end

El ciclo de compilación y grabado:

mix deps.get
mix atomvm.packbeam          # empaqueta los .beam en un archivo .avm
mix atomvm.esp32.flash       # graba el .avm en la partición de datos

Nota importante: el .avm con tu aplicación se graba encima de un firmware AtomVM previamente instalado. La secuencia completa la primera vez es grabar la imagen de AtomVM con esptool.py y después subir el .avm con la tarea de Mix; actualizar la aplicación no requiere volver a grabar la VM. Para conectividad, AtomVM expone el módulo :network, que envuelve la pila WiFi de ESP-IDF: se entrega una configuración de estación con SSID y clave, y se espera a que el chip obtenga IP.

defmodule Sensor.Wifi do
  @moduledoc """
  Conexión WiFi en modo estación bajo AtomVM. El módulo :network es parte
  de la biblioteca estándar de AtomVM y recibe listas de propiedades.
  """

  def conectar(ssid, clave) do
    config = [ssid: ssid, psk: clave]

    case :network.wait_for_sta(config, 30_000) do
      {:ok, {ip, mascara, gateway}} ->
        :erlang.display({:conectado, formatear_ip(ip), formatear_ip(mascara), formatear_ip(gateway)})
        {:ok, ip}

      {:error, motivo} ->
        :erlang.display({:sin_red, motivo})
        {:error, motivo}
    end
  end

  defp formatear_ip({a, b, c, d}), do: "#{a}.#{b}.#{c}.#{d}"
end

Si el detalle de la API cambia entre versiones de AtomVM, el concepto no: se configura una interfaz en modo estación, se bloquea hasta tener dirección IP y desde ahí se usan sockets. Conviene siempre contrastar con la documentación de la versión que tengas instalada antes de dar por buena una firma de función.

El ESP32 como periférico de un nodo Nerves

Hay un patrón que resuelve el 80 % de los sistemas reales sin pelear con las limitaciones de AtomVM: el ESP32 hace de radio y de front-end de sensores, y un nodo Nerves hace de cerebro. La comunicación entre ambos puede ser un cable serie, SPI, o directamente MQTT sobre WiFi.

sequenceDiagram
    participant S as Sensor (I2C)
    participant E as ESP32 (ESP-IDF)
    participant B as Broker MQTT
    participant N as Nodo Nerves (Elixir/OTP)
    participant U as Interfaz Phoenix

    Note over E: deep-sleep 15 min
    E->>E: despierta por timer RTC
    E->>S: lectura I2C de temperatura
    S-->>E: 23.4 °C
    E->>E: asociación WiFi (canal cacheado en RAM RTC)
    E->>B: PUBLISH sensores/sala/temp {"v":23.4,"bat":3.71}
    B->>N: entrega al suscriptor
    N->>N: GenServer valida, promedia y persiste
    N->>U: broadcast por PubSub
    U-->>U: el gráfico se actualiza en vivo
    E->>E: vuelve a deep-sleep

Del lado Elixir, el consumidor es un GenServer común y corriente. Este ejemplo usa Tortoise311, un cliente MQTT mantenido por la comunidad Elixir, pero la estructura sirve para cualquier transporte:

defmodule Hub.Ingesta do
  @moduledoc """
  Recibe mediciones publicadas por los nodos ESP32, valida el payload,
  descarta lecturas fuera de rango y guarda el último valor por nodo.
  """
  use GenServer
  require Logger

  @bateria_critica_v 3.4

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  def ultimo(nodo), do: GenServer.call(__MODULE__, {:ultimo, nodo})

  def registrar(nodo, json), do: GenServer.cast(__MODULE__, {:medicion, nodo, json})

  @impl true
  def init(_opts), do: {:ok, %{lecturas: %{}, descartes: 0}}

  @impl true
  def handle_cast({:medicion, nodo, json}, estado) do
    case decodificar(json) do
      {:ok, %{temp: t, bateria: v}} when t >= -40.0 and t <= 85.0 ->
        if v < @bateria_critica_v, do: Logger.warning("nodo #{nodo} con batería baja: #{v} V")
        lectura = %{temp: t, bateria: v, recibida_en: System.system_time(:second)}
        {:noreply, put_in(estado.lecturas[nodo], lectura)}

      {:ok, fuera_de_rango} ->
        Logger.warning("lectura descartada de #{nodo}: #{inspect(fuera_de_rango)}")
        {:noreply, %{estado | descartes: estado.descartes + 1}}

      {:error, motivo} ->
        Logger.error("payload inválido de #{nodo}: #{inspect(motivo)}")
        {:noreply, %{estado | descartes: estado.descartes + 1}}
    end
  end

  @impl true
  def handle_call({:ultimo, nodo}, _from, estado),
    do: {:reply, Map.fetch(estado.lecturas, nodo), estado}

  defp decodificar(json) do
    case Jason.decode(json) do
      {:ok, %{"v" => t, "bat" => v}} when is_number(t) and is_number(v) ->
        {:ok, %{temp: t / 1.0, bateria: v / 1.0}}

      {:ok, otro} -> {:error, {:campos_faltantes, Map.keys(otro)}}
      error -> error
    end
  end
end

El valor de este patrón es que el rango de fallas del microcontrolador queda contenido: si un ESP32 se cuelga o se queda sin batería, el sistema pierde un sensor, no el control. Toda la lógica que necesita evolucionar rápido vive en el nodo Elixir, donde puedes desplegar sin tocar hardware.

STM32: control determinista y consumo bajo control

Cómo leer un número de parte

La familia STM32 tiene más de mil referencias, y el nombre codifica todo lo relevante. Tomemos STM32L476RGT6:

SegmentoValorSignificado
STM32Familia de 32 bits de ST
LSerieL = ultra low power (también F, G, H, U, WB, WL, C)
4SubfamiliaNivel de rendimiento dentro de la serie
76VarianteCombinación de periféricos
REncapsulado / pinesR = 64 pines (C=48, V=100, Z=144)
GFlashG = 1 MB (8=64 KB, B=128 KB, C=256 KB, E=512 KB)
TPaqueteT = LQFP
6Temperatura6 = −40 a 85 °C (7 = −40 a 105 °C)

Saber leer esto ahorra horas: cuando un tutorial dice “usa un STM32F4” y tu placa dice STM32F401CCU6, ya sabes que tienes 256 KB de flash, 48 pines y encapsulado UFQFPN.

Las series y para qué sirve cada una

SerieNúcleoPerfilUso típico
C0Cortex-M0+Costo mínimoReemplazo de 8 bits, electrodomésticos simples
F0 / F1 / F3M0 / M3 / M4Propósito generalPlacas Blue Pill, control básico, aprendizaje
F4M4F @ 84–180 MHzRendimiento equilibradoEl caballo de batalla de la robótica hobby y semi-profesional
F7 / H7M7 @ 216–550 MHzAlto rendimientoVisión, audio, control multieje, HMI gráfica
G0 / G4M0+ / M4FGeneral y control analógicoG4 tiene comparadores, op-amps y ADC rápidos para motores
L0 / L1 / L4 / L5M0+ / M3 / M4F / M33Bajo consumoSensores con batería, medidores, wearables
U0 / U5M0+ / M33Bajo consumo de nueva generaciónIgual que la L pero con TrustZone y mejores números
WBM4F + M0+InalámbricoBLE 5, Zigbee, Thread; el M0+ corre la pila de radio
WLM4F + M0+Sub-GHzLoRa y (G)FSK integrados en el chip
H5 / N6M33 / M55Seguridad y edge AICertificación, aceleración de redes neuronales

Para robótica, dos series concentran casi todo: F4 cuando quieres potencia y no te importa el consumo (controladoras de vuelo, drivers de motores paso a paso, impresoras 3D), y G4 cuando el objetivo es control de motores BLDC con lazo de corriente, porque integra amplificadores operacionales y comparadores en el mismo silicio, eliminando componentes externos.

Los periféricos que hacen la diferencia

Lo que distingue a un STM32 de un ESP32 no es la CPU: es la calidad de los periféricos y su capacidad de operar sin la CPU.

Timers avanzados. Un TIM1 o TIM8 genera hasta tres pares de salidas PWM complementarias, con tiempo muerto (dead-time) programable entre el flanco de apagado de una y el de encendido de la otra. Eso es exactamente lo que necesita un inversor trifásico para no cortocircuitar la rama alta y baja de un puente. También tiene una entrada de break que apaga todas las salidas por hardware en un ciclo si se dispara una protección de sobrecorriente, sin que ningún software intervenga. En un ESP32 esa función existe en el MCPWM pero con menos granularidad; en un Arduino sencillamente no existe.

DMA con triggers de hardware. El controlador DMA puede ser disparado por un evento de timer para leer el ADC y depositar el resultado en un buffer circular en RAM, generando una interrupción solo cuando el buffer está a la mitad y al llenarse. El resultado: puedes muestrear a 1 Msps con la CPU casi ociosa. Este patrón —timer dispara ADC, ADC alimenta DMA, DMA notifica en half/full— es la columna vertebral de cualquier lazo de control serio.

ADC de calidad. Los ADC de la serie G4 llegan a 4 Msps con 12 bits, con modo inyectado (una conversión prioritaria que interrumpe la secuencia regular) y watchdog analógico (una interrupción automática si la señal sale de una ventana). Comparado con el ADC del ESP32, que es notoriamente no lineal y necesita calibración por punto, la diferencia es de otra categoría. CAN y CAN-FD, el bus estándar del mundo automotriz e industrial, viene nativo en muchos STM32: sigue necesitando un transceptor externo, pero el controlador de protocolo con filtros de aceptación por hardware está en el chip.

flowchart LR
    subgraph SinCPU["Lazo que corre sin intervención de la CPU"]
        T[TIM1<br/>PWM complementario<br/>+ dead-time] -->|trigger TRGO| A[ADC1<br/>conversión inyectada<br/>sincronizada al centro del PWM]
        A -->|petición DMA| D[DMA1<br/>buffer circular en SRAM]
        D -->|IRQ half / full| C[CPU Cortex-M4F<br/>ejecuta el lazo PI<br/>y actualiza CCR]
        C -->|escribe registros CCR| T
        BRK[Comparador<br/>de sobrecorriente] -->|BREAK| T
    end
    T --> M[Puente en H<br/>Motor BLDC]
    M --> SH[Resistencia shunt] --> A
    M --> SH2[Sobrecorriente] --> BRK

Ese diagrama muestra lo que significa “determinista”: el camino crítico de protección (sobrecorriente apaga las salidas) no pasa por ningún software, y el muestreo del ADC ocurre siempre en el mismo instante del ciclo PWM, no cuando el planificador de tareas se acuerda.

Consumo en las series de bajo consumo

Las series L y U son la respuesta de ST al nRF52 en la métrica de energía. Los números de referencia, siempre a confirmar en la hoja de datos del modelo exacto, son estos:

ModoSTM32L4 (orden de magnitud)Qué se retiene
Run a 80 MHz~100 µA/MHz desde flashTodo
Low-power run (2 MHz)~120 µA totalCPU lenta, periféricos limitados
Sleep~30 µA/MHzPeriféricos y RAM; la CPU se detiene
Stop 2 con RTC~1 µARAM completa, RTC, algunos periféricos despertables
Standby con RTC~300 nASolo backup registers y RTC
Shutdown~8 nAPrácticamente nada; despierta solo por pin o RTC

Comparado con el ESP32, la escala es otra: el estado de espera más profundo del ESP32 (≈5 µA) es más caro que el modo Stop del STM32L4 con toda la RAM viva. Esto no es defecto del ESP32; es la consecuencia de llevar dos radios y 520 KB de SRAM en el mismo die.

La contrapartida: para tener conectividad hay que agregar un chip. Un STM32L4 con un módulo WiFi externo consume más, cuesta más y ocupa más área que un ESP32-C3 solo. La serie WB y WL existen justamente para cerrar ese hueco con radio integrada.

Toolchain del STM32

HerramientaRol
STM32CubeMXConfigurador gráfico: eliges pines, relojes y periféricos, y genera el código de inicialización
STM32CubeIDEIDE basado en Eclipse que integra CubeMX, el compilador y el depurador
HAL / LLDos capas de biblioteca: HAL es portable y verbosa; LL es delgada y cercana a los registros
arm-none-eabi-gccEl compilador real; también existe la opción de Clang/LLVM
OpenOCD / ST-LINKPuente entre el PC y el chip vía SWD, para grabar y depurar
GDBDepuración paso a paso con breakpoints reales en hardware
libopencm3Alternativa libre a HAL, más compacta y sin generador de código
Zephyr RTOSSistema operativo con drivers portables y devicetree
embedded-hal (Rust)Ecosistema Rust con abstracciones de periféricos con verificación en tiempo de compilación
AtomVMSí, también en STM32: la VM de BEAM corre sobre placas Discovery y Nucleo con suficiente RAM

Un ciclo de compilación y grabado sin IDE se ve así:

make -j$(nproc)                    # compilar con el Makefile generado por CubeMX

# Grabar por SWD con OpenOCD (o con st-flash como alternativa)
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
        -c "program build/firmware.elf verify reset exit"
st-flash write build/firmware.bin 0x08000000

# Depurar: servidor en una terminal, cliente GDB en otra
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
arm-none-eabi-gdb build/firmware.elf -ex "target extended-remote :3333"

Los flags de compilación importan más de lo que parece. Un Cortex-M4F requiere decirle al compilador que tiene FPU de precisión simple, o generará llamadas a rutinas de software para cada operación con flotantes:

-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard

Olvidar -mfloat-abi=hard en un lazo de control con PID en punto flotante puede multiplicar por diez el tiempo de ejecución sin ningún error visible.

STM32 y Elixir

Hay tres caminos, en orden de cuán común es cada uno:

  1. STM32 como esclavo de un nodo Nerves. El STM32 corre el lazo de control duro y expone un protocolo simple por UART o SPI; el nodo Elixir manda consignas y lee telemetría. Es el patrón dominante en robots móviles: el STM32 cierra el lazo de velocidad de las ruedas a 1 kHz, y Elixir planifica la trayectoria a 20 Hz.
  2. AtomVM sobre STM32. Viable en placas con RAM suficiente (una Discovery F407 o F429 con SDRAM externa). Sirve para lógica de coordinación, no para lazos de microsegundos.
  3. Generación de código o de configuración desde Elixir. Menos glamoroso pero muy útil: usar Elixir para generar tablas de calibración, mapas de motor o firmware de configuración que después se compilan en el proyecto C.

El protocolo del punto 1 es donde se juega la robustez. Un marco simple, con delimitador, longitud y checksum, evita el 90 % de los problemas de sincronización:

defmodule Puente.Trama do
  @moduledoc """
  Codificación y decodificación de tramas hacia un STM32 por UART.

  Formato:
    0xA5 0x5A | LEN (1 byte) | CMD (1 byte) | PAYLOAD (LEN-1 bytes) | CRC8 (1 byte)

  El CRC cubre desde LEN hasta el último byte de PAYLOAD.
  """

  @sof <<0xA5, 0x5A>>
  @polinomio 0x07

  @doc "Arma una trama lista para escribir en el puerto serie."
  def codificar(cmd, payload) when is_integer(cmd) and is_binary(payload) do
    len = byte_size(payload) + 1
    cuerpo = <<len::8, cmd::8, payload::binary>>
    @sof <> cuerpo <> <<crc8(cuerpo)::8>>
  end

  @doc """
  Extrae todas las tramas completas de un buffer acumulado.
  Devuelve {tramas, resto} para que el llamador conserve el remanente.
  """
  def decodificar(buffer), do: decodificar(buffer, [])

  defp decodificar(<<0xA5, 0x5A, len::8, resto::binary>> = completo, acc) do
    if byte_size(resto) >= len + 1 do
      <<cuerpo::binary-size(len), crc::8, cola::binary>> = resto
      <<cmd::8, payload::binary>> = cuerpo
      calculado = crc8(<<len::8>> <> cuerpo)

      if calculado == crc do
        decodificar(cola, [{:ok, cmd, payload} | acc])
      else
        # CRC malo: descarta un byte y resincroniza
        <<_descartado::8, siguiente::binary>> = completo
        decodificar(siguiente, [{:error, :crc} | acc])
      end
    else
      {Enum.reverse(acc), completo}
    end
  end

  defp decodificar(<<0xA5, 0x5A>> = parcial, acc), do: {Enum.reverse(acc), parcial}
  defp decodificar(<<0xA5>> = parcial, acc), do: {Enum.reverse(acc), parcial}

  defp decodificar(<<_byte::8, resto::binary>>, acc), do: decodificar(resto, acc)
  defp decodificar(<<>>, acc), do: {Enum.reverse(acc), <<>>}

  @doc "CRC-8 con polinomio 0x07, valor inicial 0."
  def crc8(datos), do: crc8(datos, 0)

  defp crc8(<<>>, acc), do: acc

  defp crc8(<<byte::8, resto::binary>>, acc) do
    nuevo =
      Enum.reduce(0..7, Bitwise.bxor(acc, byte), fn _, c ->
        desplazado = Bitwise.band(Bitwise.bsl(c, 1), 0xFF)
        if Bitwise.band(c, 0x80) != 0, do: Bitwise.bxor(desplazado, @polinomio), else: desplazado
      end)

    crc8(resto, nuevo)
  end
end

Este módulo se puede probar en iex sin ningún hardware:

iex> trama = Puente.Trama.codificar(0x10, <<250::16>>)
<<165, 90, 3, 16, 0, 250, 112>>

iex> Puente.Trama.decodificar(trama)
{[{:ok, 16, <<0, 250>>}], ""}

iex> Puente.Trama.decodificar(<<0, 0>> <> trama <> <<165, 90>>)
{[{:ok, 16, <<0, 250>>}], <<165, 90>>}

Fíjate en el tercer caso: basura al inicio y una trama incompleta al final. El decodificador descarta el ruido, entrega lo válido y devuelve el remanente para el próximo bloque de bytes. Ese comportamiento es lo que hace que un enlace serie sobreviva a un reinicio del microcontrolador en medio de una transmisión.

El GenServer que lo usa con Circuits.UART:

defmodule Puente.Serial do
  @moduledoc "Enlace UART con el STM32, con reintento de apertura y buffer de resincronización."
  use GenServer
  require Logger

  @reintento_ms 2_000

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  def enviar(cmd, payload), do: GenServer.cast(__MODULE__, {:enviar, cmd, payload})

  @impl true
  def init(opts) do
    estado = %{
      dispositivo: Keyword.fetch!(opts, :dispositivo),
      velocidad: Keyword.get(opts, :velocidad, 115_200),
      uart: nil,
      buffer: <<>>
    }

    {:ok, estado, {:continue, :abrir}}
  end

  @impl true
  def handle_continue(:abrir, estado) do
    {:ok, uart} = Circuits.UART.start_link()
    opciones = [speed: estado.velocidad, active: true, framing: Circuits.UART.Framing.None]

    case Circuits.UART.open(uart, estado.dispositivo, opciones) do
      :ok ->
        Logger.info("UART abierto en #{estado.dispositivo}")
        {:noreply, %{estado | uart: uart}}

      {:error, motivo} ->
        Logger.warning("no se pudo abrir #{estado.dispositivo}: #{inspect(motivo)}")
        Process.send_after(self(), :reintentar, @reintento_ms)
        {:noreply, estado}
    end
  end

  @impl true
  def handle_info(:reintentar, estado), do: {:noreply, estado, {:continue, :abrir}}

  def handle_info({:circuits_uart, _puerto, datos}, estado) when is_binary(datos) do
    {tramas, resto} = Puente.Trama.decodificar(estado.buffer <> datos)
    Enum.each(tramas, &procesar/1)
    {:noreply, %{estado | buffer: resto}}
  end

  def handle_info({:circuits_uart, _puerto, {:error, motivo}}, estado) do
    Logger.error("error de UART: #{inspect(motivo)}")
    Process.send_after(self(), :reintentar, @reintento_ms)
    {:noreply, %{estado | uart: nil, buffer: <<>>}}
  end

  @impl true
  def handle_cast({:enviar, _cmd, _payload}, %{uart: nil} = estado) do
    Logger.warning("descartando envío: UART cerrado")
    {:noreply, estado}
  end

  def handle_cast({:enviar, cmd, payload}, estado) do
    :ok = Circuits.UART.write(estado.uart, Puente.Trama.codificar(cmd, payload))
    {:noreply, estado}
  end

  defp procesar({:ok, 0x20, <<izq::signed-16, der::signed-16>>}),
    do: Logger.debug("velocidades: izq=#{izq} der=#{der}")

  defp procesar({:ok, cmd, payload}),
    do: Logger.debug("comando #{cmd} sin manejador, #{byte_size(payload)} bytes")

  defp procesar({:error, :crc}), do: Logger.warning("trama con CRC inválido descartada")
end

nRF52 DK: radio de bajo consumo y la placa para aprenderla

Qué trae la placa de desarrollo

El nRF52 DK (referencia de fábrica PCA10040) es la placa oficial de Nordic para los SoC nRF52832, nRF52810 y nRF52805. Incluye:

  • El SoC principal con su antena de 2,4 GHz y el circuito de adaptación ya diseñado.
  • Un depurador SEGGER J-Link OB integrado. No necesitas comprar una sonda: el mismo cable USB graba, depura y entrega una consola serie virtual.
  • Conectores compatibles con Arduino Uno Revisión 3 para usar shields de terceros, más 4 botones y 4 LEDs para experimentar sin cablear nada.
  • Antena NFC con conector, para habilitar la función de etiqueta NFC-A (útil para el emparejamiento por toque).
  • Alimentación por USB o por pila CR2032, más pines dedicados para medir corriente insertando un amperímetro en serie. Ese detalle es más importante de lo que parece: sin un punto de medición, caracterizar el consumo real de un firmware es adivinanza.
  • Cabecera para depurar un chip externo, convirtiendo la placa en programador de tu propio diseño.

Ojo con una confusión frecuente: nRF52 DK y nRF52840 DK son placas distintas. La segunda (PCA10056) lleva el nRF52840, que tiene más memoria, USB nativo, radio 802.15.4 y BLE Long Range. Si un tutorial usa Thread o Zigbee, necesitas la 52840.

Los SoC de la familia nRF52

SoCNúcleoFlash / RAMRadioRasgo
nRF52805Cortex-M4 @ 64 MHz192 KB / 24 KBBLE 5El más pequeño y económico
nRF52810Cortex-M4 @ 64 MHz192 KB / 24 KBBLE 5Sin FPU, sin NFC
nRF52811Cortex-M4 @ 64 MHz192 KB / 24 KBBLE 5 + 802.15.4Direction finding
nRF52820Cortex-M4 @ 64 MHz256 KB / 32 KBBLE 5.2USB device
nRF52832Cortex-M4F @ 64 MHz512 KB / 64 KBBLE 5, ANT, NFC-AEl equilibrado; el que trae el DK
nRF52833Cortex-M4F @ 64 MHz512 KB / 128 KBBLE 5.1 + 802.15.4Rango extendido de temperatura (105 °C)
nRF52840Cortex-M4F @ 64 MHz1 MB / 256 KBBLE 5 Long Range + 802.15.4USB 2.0, CryptoCell-310

Todos comparten el mismo núcleo y la misma frecuencia. Lo que cambia es memoria, radios adicionales y periféricos. Esto es deliberado: el código que escribes para un 52832 migra casi sin cambios a un 52840 si el proyecto crece.

Dos periféricos exclusivos del ecosistema Nordic merecen atención porque son la razón técnica de su eficiencia:

  • PPI (Programmable Peripheral Interconnect): una matriz que conecta el evento de un periférico con la tarea de otro, en hardware. Ejemplo: el evento “comparación del timer” dispara directamente la tarea “iniciar conversión del ADC”, sin que la CPU despierte. Con PPI se pueden construir cadenas completas de adquisición que funcionan con el procesador dormido.
  • EasyDMA: casi todos los periféricos (SPI, UART, ADC, I2S) tienen acceso directo a la RAM. Combinado con PPI, permite un ciclo de “muestrear 100 valores por SPI y guardarlos en RAM” con la CPU apagada todo el tiempo.

Un ESP32 haría lo mismo con la CPU despierta a 20 mA. El nRF52 lo hace a decenas de microamperes. Ahí está la diferencia de dos órdenes de magnitud.

Cómo funciona BLE, en el nivel que necesitas

Bluetooth Low Energy no es “Bluetooth más lento”. Es un protocolo distinto que comparte la banda de 2,4 GHz. Su modelo mental tiene dos capas que hay que separar.

GAP (Generic Access Profile) define quién habla con quién: un dispositivo periférico (tu sensor) emite paquetes de advertising cada cierto intervalo; un dispositivo central (tu teléfono o tu nodo Nerves) escanea, encuentra el anuncio y decide conectarse.

GATT (Generic Attribute Profile) define qué se dice una vez conectados: los datos se organizan en servicios, y cada servicio contiene características, que son valores con un UUID, permisos y propiedades (leer, escribir, notificar).

sequenceDiagram
    participant P as nRF52 (periférico)
    participant C as Central (Nerves + BLE)

    Note over P: dormido, radio apagada (~1,9 uA)
    loop cada intervalo de advertising (p. ej. 1 s)
        P->>P: despierta ~1 ms
        P-->>C: ADV_IND en canales 37, 38, 39
        P->>P: vuelve a dormir
    end
    C->>P: CONNECT_IND (solicita conexión)
    Note over P,C: se negocia connection interval,<br/>slave latency y supervision timeout
    C->>P: descubrimiento de servicios GATT
    P-->>C: servicio 0x181A (Environmental Sensing)
    C->>P: escribe 0x0001 en el CCCD de la característica
    Note over C: activa notificaciones
    loop cada connection interval
        P->>P: despierta, escucha el anchor point
        alt hay dato nuevo
            P-->>C: NOTIFY temperatura = 23.4 C
        else sin datos
            P-->>C: paquete vacío (o se salta por slave latency)
        end
        P->>P: vuelve a dormir
    end
    C->>P: desconexión
    Note over P: vuelve a advertising

Tres parámetros gobiernan el consumo de un enlace BLE, y conviene entenderlos porque son la palanca principal de diseño:

  • Connection interval: cada cuánto se encuentran central y periférico. Va de 7,5 ms a 4 s en pasos de 1,25 ms. Más corto significa menor latencia y más consumo.
  • Slave latency: cuántos eventos de conexión puede saltarse el periférico si no tiene nada que decir. Con un intervalo de 100 ms y una latencia de 9, el sensor solo despierta cada segundo, pero si necesita enviar algo urgente puede hacerlo en el próximo evento.
  • Supervision timeout: cuánto se espera sin comunicación antes de declarar la conexión perdida.

Un sensor de temperatura bien configurado usa intervalo largo y slave latency alta: el enlace se siente instantáneo cuando hay datos y cuesta casi nada cuando no los hay.

El consumo del nRF52 con números

EstadoCorriente típica (nRF52832, 3 V, DC/DC activo)
CPU ejecutando desde flash~52 µA/MHz (≈3,3 mA a 64 MHz)
Radio transmitiendo a 0 dBm~5,3 mA
Radio transmitiendo a +4 dBm~7,5 mA
Radio recibiendo (1 Mbps)~5,4 mA
System ON, idle, con RAM retenida y RTC~1,9 µA
System OFF con retención de RAM~0,7 µA
System OFF sin retención~0,3 µA

Lo relevante no es ninguna cifra aislada sino la carga por evento. Un evento de advertising completo —despertar, encender el oscilador, transmitir en tres canales, apagar— consume del orden de decenas de microcoulombs. Multiplicado por la frecuencia de eventos, da la corriente media.

Ese cálculo se hace mejor con código que a mano. El siguiente módulo es Elixir puro y corre en cualquier iex:

defmodule Energia.Presupuesto do
  @moduledoc """
  Estima la corriente media y la vida de una batería a partir de un ciclo
  de trabajo descrito como lista de fases. Cada fase aporta carga
  (corriente x tiempo) al ciclo completo.
  """

  defstruct nombre: "", corriente_ma: 0.0, duracion_ms: 0.0

  @doc "Crea una fase del ciclo."
  def fase(nombre, corriente_ma, duracion_ms) do
    %__MODULE__{nombre: nombre, corriente_ma: corriente_ma / 1.0, duracion_ms: duracion_ms / 1.0}
  end

  @doc """
  Carga total del ciclo en microcoulombs, corriente media en microamperes
  y desglose ordenado por peso. `periodo_s` incluye el tiempo dormido.
  """
  def analizar(fases, periodo_s) when is_list(fases) and periodo_s > 0 do
    periodo_ms = periodo_s * 1000.0
    tiempo_activo = Enum.reduce(fases, 0.0, &(&1.duracion_ms + &2))
    # mA x ms = microcoulombs
    carga_uc = Enum.reduce(fases, 0.0, &(&1.corriente_ma * &1.duracion_ms + &2))

    if tiempo_activo > periodo_ms do
      {:error, {:ciclo_imposible, tiempo_activo, periodo_ms}}
    else
      desglose =
        fases
        |> Enum.map(fn f ->
          aporte = f.corriente_ma * f.duracion_ms
          %{fase: f.nombre, carga_uc: redondear(aporte, 2), porcentaje: redondear(aporte / carga_uc * 100.0, 1)}
        end)
        |> Enum.sort_by(& &1.porcentaje, :desc)

      {:ok,
       %{
         carga_por_ciclo_uc: redondear(carga_uc, 2),
         corriente_media_ua: redondear(carga_uc / periodo_ms * 1000.0, 2),
         ciclo_util_pct: redondear(tiempo_activo / periodo_ms * 100.0, 3),
         desglose: desglose
       }}
    end
  end

  @doc """
  Vida estimada en días. `factor_util` descuenta autodescarga y el hecho
  de que no se aprovecha el 100 % de la carga nominal (0,7 es conservador).
  """
  def vida_dias(corriente_media_ua, capacidad_mah, factor_util \\ 0.7)
  def vida_dias(ua, _cap, _f) when ua <= 0.0, do: {:error, :corriente_invalida}

  def vida_dias(corriente_media_ua, capacidad_mah, factor_util) do
    horas = capacidad_mah * factor_util / (corriente_media_ua / 1000.0)
    {:ok, redondear(horas / 24.0, 1)}
  end

  defp redondear(valor, decimales), do: Float.round(valor * 1.0, decimales)
end

Ahora comparemos los tres candidatos para el mismo requisito: publicar una temperatura una vez por minuto, alimentado por batería.

alias Energia.Presupuesto, as: P

# --- nRF52832 anunciando por BLE, sin conexión permanente ---
nrf = [
  P.fase("despertar + oscilador", 3.0, 0.4),
  P.fase("lectura del sensor I2C", 1.2, 5.0),
  P.fase("advertising en 3 canales", 5.3, 1.5),
  P.fase("dormido (System ON idle)", 0.0019, 59_992.0)
]

{:ok, r_nrf} = P.analizar(nrf, 60)          # corriente_media_ua ≈ 2,1
{:ok, dias_nrf} = P.vida_dias(r_nrf.corriente_media_ua, 220)
# CR2032 de 220 mAh: miles de días; el límite pasa a ser la autodescarga de la pila

# --- ESP32 despertando, conectándose a WiFi y publicando por MQTT ---
esp = [
  P.fase("arranque desde deep-sleep", 60.0, 300.0),
  P.fase("lectura del sensor I2C", 25.0, 5.0),
  P.fase("asociación WiFi + DHCP", 120.0, 1_800.0),
  P.fase("handshake TLS + PUBLISH", 160.0, 700.0),
  P.fase("deep-sleep", 0.010, 57_195.0)
]

{:ok, r_esp} = P.analizar(esp, 60)          # corriente_media_ua ≈ 5.900 (unos 5,9 mA)
{:ok, dias_esp} = P.vida_dias(r_esp.corriente_media_ua, 2000)
# 18650 de 2000 mAh: del orden de 9 a 10 días

# --- STM32L4 con radio LoRa externa ---
stm = [
  P.fase("despertar desde Stop 2", 2.0, 0.1),
  P.fase("lectura del sensor I2C", 1.5, 5.0),
  P.fase("transmisión LoRa SF7", 45.0, 60.0),
  P.fase("Stop 2 con RTC", 0.001, 59_934.9)
]

{:ok, r_stm} = P.analizar(stm, 60)          # corriente_media_ua ≈ 45

El resultado no es sutil: para el mismo requisito funcional, el consumo medio varía en un factor de casi 3000 entre el nRF52 y el ESP32. La conclusión práctica es que la elección de radio determina la vida de la batería mucho más que la elección de CPU, y que el ESP32 con WiFi y batería solo es viable si el intervalo de reporte es largo o si hay energía disponible (panel solar, USB, red). El desglose que devuelve el módulo agrega el matiz importante: en el caso del ESP32, la asociación WiFi es el rubro dominante, así que antes de cambiar de chip conviene atacar ese número —guardar el canal y el BSSID en la RAM RTC, usar IP estática, reanudar sesión TLS—, porque ahí se puede recortar más de la mitad.

Toolchain del nRF52

Nordic estandarizó todo en el nRF Connect SDK, que es Zephyr RTOS más los componentes propietarios de Nordic (pila BLE, gestión de energía, actualización por aire). La gestión del árbol de repositorios se hace con west.

# Instalar la herramienta de gestión de workspaces
pip install --user west

# Inicializar el workspace del SDK en una versión fija
west init -m https://github.com/nrfconnect/sdk-nrf --mr v2.6.0 ~/ncs
cd ~/ncs && west update
west zephyr-export

# Compilar un ejemplo de periférico BLE para la placa nRF52 DK
cd ~/ncs/nrf/samples/bluetooth/peripheral_lbs
west build -b nrf52dk_nrf52832

# Grabar por el J-Link integrado en la placa
west flash

# Consola serie del dispositivo
minicom -D /dev/ttyACM0 -b 115200

El nombre de la placa (nrf52dk_nrf52832) cambió en versiones recientes de Zephyr al formato nrf52dk/nrf52832; si el build falla diciendo que no encuentra la placa, ese suele ser el motivo. Zephyr configura el hardware mediante devicetree: un archivo declarativo que describe qué periféricos existen, en qué pines y con qué parámetros. Un overlay para agregar un sensor I2C se ve así:

/* boards/nrf52dk_nrf52832.overlay */
&i2c0 {
    status = "okay";
    clock-frequency = <I2C_BITRATE_STANDARD>;

    sht3xd: sht3xd@44 {
        compatible = "sensirion,sht3xd";
        reg = <0x44>;
    };
};

Las funcionalidades del firmware se activan aparte, con Kconfig, en el archivo prj.conf: CONFIG_BT=y y CONFIG_BT_PERIPHERAL=y habilitan la pila BLE en rol periférico, CONFIG_BT_DEVICE_NAME="SensorSala" define el nombre que se anuncia, CONFIG_SENSOR=y y CONFIG_I2C=y incorporan los drivers, y CONFIG_PM_DEVICE=y habilita la gestión de energía por dispositivo.

Esta separación —hardware en devicetree, funcionalidades en Kconfig, lógica en C— es el modelo de Zephyr y es lo que hace que un mismo código funcione en un nRF52 y en un STM32 sin cambios.

nRF52 y Elixir: el central corre en Nerves

No hay una VM de BEAM para nRF52 y probablemente no la haya: 64 KB de RAM no alcanzan. La integración con Elixir es siempre desde el otro lado del enlace: el nRF52 es el periférico BLE, y un nodo Nerves con un adaptador Bluetooth es el central.

En el ecosistema Elixir existe BlueHeron, una implementación de la pila Bluetooth Host que habla HCI directamente con un controlador (por UART o USB) sin depender de BlueZ; también es posible usar BlueZ vía D-Bus si el sistema Nerves lo incluye. La estructura de la aplicación no depende de cuál elijas. Lo importante es aislar la parte inestable —el enlace de radio— detrás de un proceso supervisado que pueda morir y renacer sin arrastrar al resto:

defmodule Sala.Supervisor do
  @moduledoc "Árbol de supervisión del nodo central."
  use Supervisor

  def start_link(opts), do: Supervisor.start_link(__MODULE__, opts, name: __MODULE__)

  @impl true
  def init(_opts) do
    hijos = [
      # Almacena el último valor de cada sensor; sobrevive a caídas del enlace.
      Sala.Registro,
      # El enlace BLE: si el adaptador desaparece, este proceso muere y se reinicia.
      {Sala.EnlaceBle, dispositivos: ["EA:1F:0C:9A:22:31"]},
      # Publica al exterior; no depende del enlace.
      Sala.ApiWeb
    ]

    # rest_for_one: si el Registro cae, se reinician también los que dependen de él.
    Supervisor.init(hijos, strategy: :rest_for_one)
  end
end

Sala.Registro puede ser un Agent simple que guarde la última medición por dirección BLE junto con su marca de tiempo; con eso, detectar sensores caídos es filtrar los que llevan más de N segundos sin reportar. Lo relevante del árbol es el orden: el registro va primero porque los demás dependen de él, y :rest_for_one garantiza que una caída del enlace de radio no borre el estado acumulado.

La decodificación del payload BLE sí es responsabilidad tuya y sí es puro Elixir verificable. Las características estándar de Bluetooth SIG tienen formatos definidos; por ejemplo, la característica Temperature (UUID 0x2A6E) del servicio Environmental Sensing es un entero con signo de 16 bits en centésimas de grado, little-endian:

defmodule Sala.Gatt do
  @moduledoc "Decodificadores de características GATT estándar de Bluetooth SIG."

  @doc """
  Característica Temperature (0x2A6E): sint16, unidad 0,01 °C, little-endian.

      iex> Sala.Gatt.temperatura(<<0x26, 0x09>>)
      {:ok, 23.42}

      iex> Sala.Gatt.temperatura(<<0xD8, 0xFC>>)
      {:ok, -8.4}
  """
  def temperatura(<<centesimas::little-signed-16>>), do: {:ok, centesimas / 100.0}
  def temperatura(otro), do: {:error, {:longitud_invalida, byte_size(otro)}}

  @doc """
  Característica Humidity (0x2A6F): uint16, unidad 0,01 %.

      iex> Sala.Gatt.humedad(<<0x10, 0x17>>)
      {:ok, 59.36}
  """
  def humedad(<<centesimas::little-unsigned-16>>), do: {:ok, centesimas / 100.0}
  def humedad(otro), do: {:error, {:longitud_invalida, byte_size(otro)}}

  @doc """
  Característica Battery Level (0x2A19): uint8, porcentaje entero de 0 a 100.

      iex> Sala.Gatt.bateria(<<87>>)
      {:ok, 87}

      iex> Sala.Gatt.bateria(<<200>>)
      {:error, {:fuera_de_rango, 200}}
  """
  def bateria(<<nivel::unsigned-8>>) when nivel <= 100, do: {:ok, nivel}
  def bateria(<<nivel::unsigned-8>>), do: {:error, {:fuera_de_rango, nivel}}
  def bateria(otro), do: {:error, {:longitud_invalida, byte_size(otro)}}
end

Estos decodificadores son la parte del sistema que más se rompe en la práctica —un firmware nuevo cambia el formato y todo deja de funcionar— y son también la más fácil de testear: son funciones puras sobre binarios, sin hardware de por medio.

Tabla comparativa maestra

DimensiónESP32 (clásico / S3)STM32 (F4 / L4)nRF52832
NúcleoXtensa LX6/LX7, 2 × 240 MHzCortex-M4F, 80–180 MHzCortex-M4F, 64 MHz
RAM interna520 KB96–320 KB según modelo64 KB
FlashExterna, típicamente 4–16 MBInterna, 64 KB – 2 MBInterna, 512 KB
WiFiSí, integradoNo (salvo módulo externo)No
BluetoothBR/EDR + BLE (BLE 5 en S3/C3)Solo serie WBBLE 5 + ANT + NFC-A
Otras radios802.15.4 en C6/H2Sub-GHz en serie WL2,4 GHz propietario
Consumo dormido~10 µA (deep sleep)~1 µA (Stop 2, L4)~1,9 µA (System ON idle)
Consumo activo típico30–260 mA3–40 mA3,3 mA + 5,3 mA de radio
Determinismo de timingMedio (FreeRTOS, radio interrumpe)Alto (timers avanzados, DMA)Alto (PPI, EasyDMA)
Calidad del ADCBaja, requiere calibraciónAlta, hasta 4 Msps en G4Media-alta, SAADC 12 bits
Control de motoresMCPWM con dead-timeEl mejor de los tres (TIM1/TIM8, break)Básico
DepuradorJTAG con sonda externaSWD, sondas baratas y ubicuasJ-Link OB en la placa
Toolchain principalESP-IDF (FreeRTOS)CubeIDE / HAL / ZephyrnRF Connect SDK (Zephyr)
Elixir en el chipSí, con AtomVMSí, con AtomVM en placas con RAMNo
Elixir como central/hostNerves + MQTTNerves + UART/SPI/CANNerves + BlueHeron/BlueZ
Costo típico del móduloBajoMedioMedio-alto
Certificación de radioMódulos precertificadosNo aplica (sin radio)Módulos precertificados
Donde brillaNodo conectado a la nubeLazo de control industrialSensor a pila que dura años

Un método para elegir, en vez de una corazonada

La elección de microcontrolador se hace mal cuando se decide por familiaridad (“siempre uso ESP32”) o por precio de la placa. Un método reproducible pesa los requisitos reales y hace explícitas las restricciones que descalifican.

flowchart TD
    A[Requisitos del nodo] --> B{¿Necesita IP nativo<br/>WiFi o Ethernet?}
    B -->|Sí| C{¿Alimentado por red<br/>o batería grande?}
    C -->|Sí| D[ESP32-S3 o C6]
    C -->|No, pila pequeña| E{¿Puede reportar<br/>cada varios minutos?}
    E -->|Sí| F[ESP32-C3 con deep-sleep<br/>y conexión optimizada]
    E -->|No| G[Replantear: gateway BLE/Thread<br/>+ nRF52 en el sensor]
    B -->|No| H{¿Timing duro<br/>menor a 100 us<br/>o control de motor?}
    H -->|Sí| I{¿Bajo consumo<br/>también crítico?}
    I -->|No| J[STM32F4 o G4]
    I -->|Sí| K[STM32L4 o U5]
    H -->|No| L{¿Enlace inalámbrico<br/>de bajo consumo?}
    L -->|BLE / Thread / Zigbee| M[nRF52832 o nRF52840]
    L -->|LoRa / sub-GHz| N[STM32WL]
    L -->|No, solo cableado| O{¿Restricción de costo<br/>por unidad?}
    O -->|Alta| P[STM32C0 o G0]
    O -->|Media| Q[STM32F4 o ESP32-C3]
    D --> Z[Verificar: certificación,<br/>disponibilidad, ciclo de vida,<br/>toolchain que el equipo domina]
    F --> Z
    G --> Z
    J --> Z
    K --> Z
    M --> Z
    N --> Z
    P --> Z
    Q --> Z

El diagrama ordena la conversación, pero los proyectos reales tienen más de un eje en tensión. Para eso sirve una puntuación ponderada, que además obliga a escribir los pesos y así discutirlos:

defmodule Seleccion.Mcu do
  @moduledoc """
  Selección de microcontrolador por puntuación ponderada con restricciones duras.

  Los criterios se puntúan de 0 a 10 por candidato. Los pesos expresan
  cuánto importa cada criterio en ESTE proyecto y deben sumar 1.0.
  Las restricciones duras descalifican candidatos sin importar su puntaje.
  """

  @candidatos %{
    esp32_s3: %{
      nombre: "ESP32-S3",
      criterios: %{conectividad_ip: 10, consumo: 3, determinismo: 5, costo: 8, ecosistema: 9, control_motor: 6},
      capacidades: [:wifi, :ble, :usb, :atomvm, :dual_core]
    },
    esp32_c6: %{
      nombre: "ESP32-C6",
      criterios: %{conectividad_ip: 10, consumo: 5, determinismo: 5, costo: 8, ecosistema: 7, control_motor: 5},
      capacidades: [:wifi, :ble, :thread, :zigbee, :atomvm]
    },
    stm32g4: %{
      nombre: "STM32G4",
      criterios: %{conectividad_ip: 1, consumo: 6, determinismo: 10, costo: 6, ecosistema: 8, control_motor: 10},
      capacidades: [:can, :timers_avanzados, :adc_rapido, :opamp]
    },
    stm32l4: %{
      nombre: "STM32L4",
      criterios: %{conectividad_ip: 1, consumo: 9, determinismo: 9, costo: 6, ecosistema: 8, control_motor: 7},
      capacidades: [:can, :timers_avanzados, :bajo_consumo_profundo]
    },
    nrf52832: %{
      nombre: "nRF52832",
      criterios: %{conectividad_ip: 2, consumo: 10, determinismo: 8, costo: 5, ecosistema: 7, control_motor: 3},
      capacidades: [:ble, :nfc, :ppi, :bajo_consumo_profundo]
    },
    nrf52840: %{
      nombre: "nRF52840",
      criterios: %{conectividad_ip: 3, consumo: 9, determinismo: 8, costo: 4, ecosistema: 7, control_motor: 3},
      capacidades: [:ble, :thread, :zigbee, :usb, :nfc, :ppi, :bajo_consumo_profundo]
    }
  }

  @doc """
  Evalúa los candidatos. Opciones: `:pesos` (mapa criterio => peso, deben
  sumar 1.0) y `:requiere` (lista de capacidades obligatorias).

      iex> {:ok, r} = Seleccion.Mcu.evaluar(
      ...>   pesos: %{conectividad_ip: 0.1, consumo: 0.45, determinismo: 0.1,
      ...>            costo: 0.15, ecosistema: 0.1, control_motor: 0.1},
      ...>   requiere: [:ble])
      iex> hd(r).nombre
      "nRF52832"
  """
  def evaluar(opts \\ []) do
    pesos = Keyword.get(opts, :pesos, pesos_por_defecto())
    requeridas = Keyword.get(opts, :requiere, [])

    with :ok <- validar_pesos(pesos) do
      resultado =
        @candidatos
        |> Enum.filter(fn {_k, c} -> Enum.all?(requeridas, &(&1 in c.capacidades)) end)
        |> Enum.map(fn {clave, c} ->
          puntaje =
            Enum.reduce(pesos, 0.0, fn {criterio, peso}, acc ->
              acc + Map.get(c.criterios, criterio, 0) * peso
            end)

          %{clave: clave, nombre: c.nombre, puntaje: Float.round(puntaje, 2)}
        end)
        |> Enum.sort_by(& &1.puntaje, :desc)

      case resultado do
        [] -> {:error, {:sin_candidatos, requeridas}}
        lista -> {:ok, lista}
      end
    end
  end

  @doc "Imprime la evaluación como ranking con barras."
  def informe(opts \\ []) do
    case evaluar(opts) do
      {:ok, lista} ->
        Enum.each(lista, fn r ->
          IO.puts(String.pad_trailing(r.nombre, 12) <> String.pad_leading("#{r.puntaje}", 6) <> "  " <> String.duplicate("#", round(r.puntaje)))
        end)

      {:error, motivo} ->
        IO.puts("sin resultados: #{inspect(motivo)}")
    end
  end

  defp pesos_por_defecto do
    %{conectividad_ip: 0.2, consumo: 0.2, determinismo: 0.2, costo: 0.15, ecosistema: 0.15, control_motor: 0.1}
  end

  defp validar_pesos(pesos) do
    suma = pesos |> Map.values() |> Enum.sum()
    if abs(suma - 1.0) < 0.001, do: :ok, else: {:error, {:pesos_no_suman_uno, suma}}
  end
end

Tres escenarios distintos con el mismo módulo:

# Escenario A: sensor de temperatura a pila que debe durar dos años
Seleccion.Mcu.informe(
  pesos: %{conectividad_ip: 0.05, consumo: 0.5, determinismo: 0.05, costo: 0.2, ecosistema: 0.1, control_motor: 0.1},
  requiere: [:bajo_consumo_profundo])

# Escenario B: controlador de un robot móvil con dos motores BLDC
Seleccion.Mcu.informe(
  pesos: %{conectividad_ip: 0.05, consumo: 0.05, determinismo: 0.35, costo: 0.15, ecosistema: 0.1, control_motor: 0.3},
  requiere: [:timers_avanzados])

# Escenario C: nodo de domótica que habla con la nube y con una malla Thread
Seleccion.Mcu.informe(
  pesos: %{conectividad_ip: 0.4, consumo: 0.15, determinismo: 0.1, costo: 0.2, ecosistema: 0.15, control_motor: 0.0},
  requiere: [:wifi, :thread])

El escenario C es interesante porque la restricción [:wifi, :thread] deja un solo candidato en la lista: el ESP32-C6. Cuando una lista de requisitos colapsa a un candidato, eso es información valiosa: significa que la decisión ya estaba tomada por los requisitos, no por preferencia. Con todo, hay criterios que ninguna tabla de puntaje captura y que deciden proyectos reales:

  • Disponibilidad y ciclo de vida. Un chip excelente que tiene 40 semanas de plazo de entrega no sirve. ST y Nordic publican compromisos de longevidad de 10 o 15 años; para un producto industrial eso es un requisito, no un lujo.
  • Certificación de radio. Diseñar tu propia antena implica certificar el equipo completo. Usar un módulo precertificado (ESP32-WROOM, un módulo Nordic de Fanstel o Insight) traslada gran parte de ese costo al proveedor.
  • Qué domina el equipo. Un STM32 mal usado por alguien que aprende Zephyr desde cero rinde peor que un ESP32 bien usado por alguien con tres años de ESP-IDF.
  • Herramientas de depuración. El J-Link OB del nRF52 DK y el ST-LINK del STM32 permiten breakpoints reales en hardware. En un ESP32 la depuración por JTAG existe pero es menos habitual: mucha gente depura con printf, lo cual es aceptable hasta que deja de serlo.

Errores comunes

ErrorCausaSolución
El ESP32 se reinicia al activar el WiFiEl pico de corriente de transmisión (hasta 500 mA instantáneos) hace caer la tensión del regulador o del USB del PCAlimentar con una fuente de al menos 1 A y poner un condensador electrolítico de 470 µF cerca del módulo
Las lecturas del ADC del ESP32 dan 0 o basura con red activaEl canal usado pertenece al ADC2, que queda inhabilitado mientras el WiFi está en usoMover todas las señales analógicas a canales del ADC1 (GPIO 32 a 39) en el esquemático
El ESP32 no entra en modo de grabaciónEl circuito de autoreset del conversor USB-serie no funciona en clones con CH340Mantener BOOT presionado, tocar EN, soltar BOOT; o soldar los condensadores de autoreset
Un GPIO del ESP32 no respondeSe usó un pin del rango 6–11 (conectado a la flash) o 34–39 (solo entrada, sin pull-up)Consultar el mapa de pines del módulo antes de asignar funciones
Un sensor de 5 V mata el ESP32 o el nRF52Los GPIO no son tolerantes a 5 VUsar un conversor de nivel bidireccional o un divisor resistivo en las entradas
El PID del STM32 corre mucho más lento de lo esperadoSe compiló sin -mfloat-abi=hard, así que la FPU no se usa y las operaciones flotantes van por softwareAgregar -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard y verificar que las bibliotecas enlazadas usen la misma ABI
El STM32 arranca a una frecuencia mucho menor que la nominalEl PLL no se configuró y el chip sigue corriendo desde el oscilador interno (HSI)Configurar el árbol de relojes en CubeMX y verificar el valor real leyendo SystemCoreClock
El DMA del STM32 no transfiere nadaEl buffer está en una región de RAM que ese controlador DMA no puede alcanzar (típico en H7 con DTCM)Ubicar el buffer en una sección de RAM accesible mediante el script del linker
El PWM de motor produce cortocircuitos en el puenteSe usó PWM simple sin tiempo muerto entre las ramas alta y bajaUsar un timer avanzado (TIM1/TIM8) con salidas complementarias y dead-time configurado
El nRF52 no aparece al escanearEl intervalo de advertising es muy largo o la potencia de transmisión está en el mínimoReducir el intervalo durante el emparejamiento y volver a subirlo después de conectar
La conexión BLE se cae de forma intermitenteEl supervision timeout es demasiado corto para el intervalo y la slave latency configuradosVerificar la relación: el timeout debe superar (1 + latency) × interval × 2 con margen
El consumo medido del nRF52 es diez veces el esperadoUn periférico quedó habilitado (UART, un pin con pull-up contra tierra, el regulador LDO en vez del DC/DC)Deshabilitar periféricos no usados, revisar el estado de cada GPIO al dormir y activar el convertidor DC/DC
west build falla porque no encuentra la placaEl identificador de placa cambió de formato entre versiones de ZephyrUsar la nomenclatura de la versión instalada (nrf52dk_nrf52832 o nrf52dk/nrf52832)
Nerves no compila para ESP32Nerves construye Linux embebido y el ESP32 no tiene MMU ni RAM suficienteUsar AtomVM en el ESP32, o el ESP32 como periférico de un nodo Nerves sobre hardware con Linux
La aplicación AtomVM se queda sin memoriaSe usaron estructuras de datos grandes o binarios que no caben en la RAM disponibleProcesar por trozos, evitar acumular listas y medir con :erlang.system_info/1
El enlace UART entre Elixir y el microcontrolador se desincronizaSe está leyendo por líneas o sin delimitadores, y un reinicio deja bytes a mediasUsar tramas con byte de inicio, longitud y checksum, y descartar bytes hasta resincronizar

Ejercicios propuestos

  1. Presupuesto energético comparado. Usando el módulo Energia.Presupuesto, modela un nodo que reporta cada 5 minutos en tres variantes: ESP32 con WiFi, nRF52 con BLE, y STM32L4 con LoRa. Calcula la vida de la batería para una CR2032 (220 mAh), una AA de litio (3000 mAh) y una 18650 (2500 mAh). Presenta el resultado como tabla y explica en qué punto cada combinación deja de tener sentido.

  2. Optimización del ciclo de conexión. Toma el caso del ESP32 del ejemplo y crea variantes que reduzcan la fase de asociación WiFi: IP estática (elimina DHCP, ≈300 ms), BSSID y canal cacheados (≈600 ms menos de escaneo), sesión TLS reanudada (≈400 ms menos). Calcula cuánto mejora la vida de la batería con cada optimización acumulada y en cuál conviene invertir primero.

  3. Extender el selector. Agrega al módulo Seleccion.Mcu los candidatos stm32wb (BLE integrado) y stm32wl (LoRa integrado), con sus criterios y capacidades. Después agrega un criterio nuevo, certificacion, y verifica cómo cambia el ranking del escenario de domótica cuando ese criterio pesa 0,25.

  4. Decodificadores GATT con propiedades. Escribe pruebas basadas en propiedades para Sala.Gatt usando StreamData: para cualquier entero de 16 bits con signo, codificarlo y decodificarlo debe devolver el valor original dividido por 100. Agrega el decodificador de la característica Pressure (0x2A6D), que es un uint32 en unidades de 0,1 pascal.

  5. Protocolo robusto a fallas. Extiende Puente.Trama con un número de secuencia de 8 bits y un mecanismo de confirmación. Escribe una prueba que simule pérdida de tramas al azar y verifique que el emisor reintenta hasta tres veces antes de reportar el error hacia arriba.

  6. Elección justificada. Toma un proyecto propio y escribe una página con: los requisitos duros, los pesos de cada criterio con su justificación, la salida de Seleccion.Mcu.informe/1, y una explicación de por qué el ganador del puntaje es o no es la elección final. Si la elección final difiere del puntaje, identifica qué criterio no estaba modelado.

  7. Topología híbrida. Diseña en un diagrama Mermaid un sistema de invernadero con: cuatro nodos nRF52 midiendo humedad de suelo a pila, un STM32 controlando bombas y válvulas con lazo cerrado, un ESP32 como gateway BLE a WiFi, y un nodo Nerves ejecutando la lógica de riego y la interfaz web. Indica el protocolo de cada enlace y qué pasa cuando cada uno de los nodos falla.

Lo que viene

Este capítulo cubrió el rango de los microcontroladores: dispositivos sin sistema operativo de propósito general, con memoria medida en kilobytes, capaces de responder en microsegundos y de dormir con corrientes de microamperes. Vimos que la elección entre ESP32, STM32 y nRF52 se reduce a qué requisito domina —conectividad, determinismo o energía— y que en un sistema real suelen convivir los tres, con un nodo Elixir coordinándolos. Pero hay problemas que ningún microcontrolador resuelve. Ejecutar una red neuronal sobre video en tiempo real, procesar una nube de puntos de un LiDAR, o implementar un lazo de control con miles de canales simultáneos requiere otra categoría de hardware. En el capítulo 9 subimos al siguiente escalón: los módulos NVIDIA Jetson con su GPU para inferencia, las plataformas AMD Kria que combinan procesador y lógica programable, las FPGA como recurso de cómputo paralelo reconfigurable, y los ASIC como destino final cuando el volumen lo justifica. Ahí también cerraremos el mapa completo de decisión, desde un microcontrolador de un dólar hasta un chip diseñado a medida, para que sepas exactamente en qué punto de esa escala vive cada uno de tus proyectos. El índice completo del curso está en /tecnologias/elixir-robotics/00-indice/.