What makes embedded programming different

Firmware is the software stored in a device's own flash memory. On a PC, firmware only starts the operating system; on a microcontroller, the firmware is the whole program. Embedded programming is writing that software: programs that run directly on a microcontroller in an embedded system, close to the hardware, with tight limits on memory, energy and timing, and without a person at a keyboard to restart them.

Compared with writing a script or a web service, five things change:

  • Little memory. An ESP32 has 520 KB of RAM, a Raspberry Pi Pico 264 kB, a classic Arduino Uno 2 kB. There is no swap and usually no memory protection: running out means a crash.
  • Direct hardware access. The program sets bits in the chip's registers to switch pins, start a UART or read the ADC, often through a framework but sometimes directly.
  • No operating system, or a very small one. There is no shell, no package manager and no process isolation; at most a real-time operating system that schedules a few tasks.
  • Cross-compiling. You build on a laptop for a different processor (Xtensa, RISC-V or ARM) and copy the result to the device.
  • It must run forever on its own. A device in a meter cupboard has nobody to press Ctrl+C. It must survive bad input, lost Wi-Fi and its own bugs, and recover by itself.

Languages and frameworks

C is the main language of firmware: compilers exist for every microcontroller, it gives exact control over memory and registers, and every vendor's examples use it. C++ is common too, often as "C with classes". Around the language, a framework supplies drivers, a build system and libraries.

ESP-IDF

ESP-IDF (Espressif IoT Development Framework) is Espressif's official framework for its ESP32 chips: C libraries for Wi-Fi, Bluetooth, the peripherals, secure boot, flash encryption and over-the-air updates, a build tool, and a version of FreeRTOS extended for the ESP32's two cores. As of October 2026 the latest release is v6.1 (27 August 2026); each release is supported for 30 months, so a product team picks a release and plans its upgrade before support ends. ESP-IDF is what Espressif recommends for products; the other frameworks below often build on it.

The Arduino framework

The Arduino framework hides the hardware behind simple functions such as pinMode(), digitalWrite() and Serial.print(), and runs on many boards, including the ESP32. A program, called a sketch, has two functions: setup() runs once, loop() runs over and over. Thousands of libraries for sensors and displays make it the fastest way from idea to working prototype. Arduino IDE 2.3.10 (June 2026) is the current editor as of October 2026; many developers use the framework from other editors too.

MicroPython

MicroPython is a compact implementation of Python 3 that runs directly on microcontrollers such as the ESP32 family and the Raspberry Pi Pico. It includes a subset of the standard library and a machine module for pins, buses, timers and sleep. You type commands at an interactive prompt (the REPL) over USB and see the result at once, and you copy .py files to the board without compiling. As of October 2026 the current version is v1.29.0 (24 August 2026).

The price is speed and memory: interpreted Python is many times slower than C and needs more RAM, and time-critical work (fast bit-banging, tight timing) belongs in C. For a reader that handles one telegram per second, MicroPython is more than fast enough. Modules such as machine, network and umqtt.simple exist only on MicroPython, not in the Python on your laptop.

ESP-IDF (C) Arduino (C++) MicroPython
Best for products, full control of the ESP32 prototypes, hobby projects, many boards learning, quick experiments, simple devices
Speed and memory best good slowest, uses most RAM
Edit-test cycle compile and flash compile and flash copy a file or type at the REPL
Security features secure boot, flash encryption, OTA with rollback through the underlying vendor framework depends on the port and build

Other options exist, such as the Zephyr real-time operating system (the Arduino UNO Q runs its sketches on Zephyr) and configuration-based firmware such as ESPHome, where you describe the device in YAML instead of writing code (see Smart Home and Building Automation).

Building and flashing

A compiler for the target processor (a cross-compiler) turns the source into a binary image on your laptop. Flashing is writing that image into the microcontroller's flash memory, usually over USB or a serial connection, sometimes through a dedicated debug adapter. On an ESP32 development board the USB port carries the serial connection; the flashing tool puts the chip into download mode, writes the image and restarts it.

Typical commands (not run here; the serial port name depends on the board and the computer):

# ESP-IDF: build, flash and open the serial monitor
idf.py -p /dev/ttyUSB0 flash monitor

# MicroPython: copy a program to the board and open the REPL
mpremote connect /dev/ttyUSB0 cp main.py :
mpremote connect /dev/ttyUSB0 repl

# MicroPython: install a library from the micropython-lib index
mpremote mip install umqtt.simple

On Linux the board appears as /dev/ttyUSB0 or /dev/ttyACM0. "Permission denied" on that device usually means your user is not in the group that owns serial ports (dialout on Debian and Raspberry Pi OS). If the port does not appear at all, try another USB cable: many cheap cables carry only power, no data.

The bootloader

A bootloader is the small program that runs first when a device starts and decides which firmware to load and start; it can often also receive new firmware. On an ESP32 the chain has two steps: a first-stage bootloader in the chip's read-only memory, which nobody can change, loads a second-stage bootloader from flash, which reads the partition table and starts the application. With over-the-air updates there are two application slots, and the bootloader picks the active one; with secure boot it first checks the application's signature.

The boot chain of an ESP32. The first-stage bootloader in the chip's ROM loads the second-stage bootloader from flash. The second-stage bootloader reads the partition table and the OTA data partition and starts one of two application slots, ota_0 or ota_1. ROM bootloader in the chip, fixed 2nd-stage bootloader in flash reads partition table and otadata Application ota_0 (active) Application ota_1 (other slot)
The ESP32 boot chain: a fixed ROM bootloader, a second-stage bootloader in flash, and one of two application slots.

How the two slots make updates safe, including rollback when a new version fails, is explained on Provisioning and Over-the-Air Updates. Secure boot on PCs is on BIOS, UEFI and Boot; the idea is the same.

The super loop

The simplest firmware structure is the super loop: after a one-time setup, the program runs one endless loop that checks every input and handles every job in turn, forever. Arduino's setup() and loop() are exactly this. Hardware events that cannot wait, such as a pin change, are handled by interrupt service routines that set a flag for the loop.

The rule that keeps a super loop working: no step may block for long. A delay(500) to blink an LED stops everything else for half a second, including reading the UART, whose bytes then overflow. Instead, check the time and act when it is due:

const int LED_PIN = 2;             // pin number depends on the board
unsigned long lastBlink = 0;
bool ledOn = false;

void setup() {
  pinMode(LED_PIN, OUTPUT);
  Serial.begin(115200);
}

void loop() {
  readP1Bytes();                         // your function: take what has arrived, never wait

  if (millis() - lastBlink >= 500) {     // instead of delay(500)
    lastBlink = millis();
    ledOn = !ledOn;
    digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
  }
}

A super loop is easy to understand and debug, and enough for many devices. It becomes awkward when jobs have very different timing, or when a library (such as a Wi-Fi stack) needs to run in the background. That is where a real-time operating system comes in.

Real-time operating systems and FreeRTOS

A real-time operating system (RTOS) lets firmware run several tasks that each look like their own endless loop. A scheduler, driven by a regular tick interrupt, switches between them by priority, and the tasks exchange data through queues and protect shared data with mutexes and semaphores. The usual problems of concurrency apply: two tasks writing the same variable without a lock is a race condition, on a microcontroller as on a server.

FreeRTOS is the most widely used open-source real-time operating system kernel for microcontrollers: a small library that provides tasks, queues, semaphores and mutexes, software timers and a tick-based scheduler. It is MIT-licensed (since version 10.0.0) and maintained under Amazon Web Services' stewardship; as of October 2026 the current kernel is V11.3.1 (August 2026). ESP-IDF includes its own variant, based on FreeRTOS 10.5.1 with significant changes to schedule tasks on the ESP32's two cores. On an ESP32, Wi-Fi and Bluetooth already run as FreeRTOS tasks, even when your own code is a single loop.

A P1 reader split into two tasks: one reads and checks telegrams, the other sends them, and a queue sits between them so that a slow network does not hold up the UART (a sketch, not run here):

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"

struct reading { int32_t watts; };
static QueueHandle_t readings;

static void p1_task(void *arg)
{
    struct reading r;
    for (;;) {
        /* wait for a telegram on the UART, check its CRC, fill r */
        xQueueSend(readings, &r, portMAX_DELAY);
    }
}

static void publish_task(void *arg)
{
    struct reading r;
    for (;;) {
        if (xQueueReceive(readings, &r, portMAX_DELAY)) {
            /* publish r.watts over MQTT */
        }
    }
}

void app_main(void)
{
    readings = xQueueCreate(4, sizeof(struct reading));
    xTaskCreate(p1_task, "p1", 4096, NULL, 5, NULL);
    xTaskCreate(publish_task, "publish", 4096, NULL, 4, NULL);
}

Each task gets its own fixed stack (here 4096). Too small and the task overwrites memory or crashes; too large and RAM runs out. Tuning stack sizes is a normal part of RTOS work.

Working with very little memory

On a server, memory is allocated and freed all the time and the operating system cleans up after a process ends. Firmware runs for years in a few hundred kilobytes, so it follows stricter habits:

  • Fixed-size buffers, sized at compile time. You know the maximum telegram length, so reserve that much once.
  • No allocation in the main loop. Repeatedly allocating and freeing memory on the heap slowly fragments it, until a request fails after weeks of running. A memory leak that costs 100 bytes per message ends the device within a day.
  • Integers instead of floating point where the chip has no floating-point unit or where exact values matter: store 1.234 kW as 1234 W.
  • Decide what happens when a buffer is full. Dropping data and counting it is better than crashing; the counter tells you the buffer is too small.

Two building blocks appear in almost every firmware. The first is setting and clearing single bits in a hardware register with bit masks. In real firmware the register is a fixed memory address accessed through a volatile pointer (or a framework function); here an ordinary variable stands in for it:

#include <stdint.h>
#include <stdio.h>

#define LED_PIN   2u   /* bit numbers in the output register (illustrative) */
#define RELAY_PIN 5u

static void show(const char *step, uint32_t reg)
{
    printf("%-22s", step);
    for (int bit = 7; bit >= 0; bit--) {        /* print the low 8 bits */
        putchar((reg >> bit) & 1u ? '1' : '0');
    }
    putchar('\n');
}

int main(void)
{
    uint32_t out = 0;                  /* stands in for a GPIO output register */

    show("start", out);
    out |= (1u << LED_PIN);            /* set one bit, leave the rest alone */
    show("LED on", out);
    out |= (1u << RELAY_PIN);
    show("relay on", out);
    out &= ~(1u << LED_PIN);           /* clear one bit */
    show("LED off", out);
    out ^= (1u << RELAY_PIN);          /* toggle one bit */
    show("relay toggled", out);
    printf("relay is %s\n", (out & (1u << RELAY_PIN)) ? "on" : "off");
    return 0;
}
start                 00000000
LED on                00000100
relay on              00100100
LED off               00100000
relay toggled         00000000
relay is off

The second is a ring buffer: a fixed array used as a queue whose read and write positions wrap around. The UART interrupt puts received bytes in; the main loop takes them out when it has time. The buffer here has room for 8 bytes and 11 arrive before the loop reads:

#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>

#define SIZE 8u                        /* fixed at compile time: no malloc */

static uint8_t buffer[SIZE];
static unsigned head, tail, count, dropped;

static bool push(uint8_t byte)         /* called for every byte the UART receives */
{
    if (count == SIZE) {
        dropped++;                     /* full: count the loss instead of crashing */
        return false;
    }
    buffer[head] = byte;
    head = (head + 1) % SIZE;
    count++;
    return true;
}

static bool pop(uint8_t *byte)         /* called by the main loop when it has time */
{
    if (count == 0) {
        return false;
    }
    *byte = buffer[tail];
    tail = (tail + 1) % SIZE;
    count--;
    return true;
}

int main(void)
{
    const char *incoming = "/XYZ5\\2DEMO";   /* 11 bytes arrive before the loop reads */
    uint8_t byte;

    for (const char *p = incoming; *p != '\0'; p++) {
        push((uint8_t)*p);
    }
    printf("stored %u, dropped %u\n", count, dropped);

    printf("read: ");
    while (pop(&byte)) {
        putchar(byte);
    }
    putchar('\n');
    return 0;
}
stored 8, dropped 3
read: /XYZ5\2D

Three bytes are lost, and the telegram they belonged to will fail its CRC check. At 115200 baud a 631-byte telegram arrives in about 55 ms, so the receive buffer must hold a whole telegram or the program must read it out faster than it arrives. That is why P1 reader software typically configures a large receive buffer.

Watchdog timers

A watchdog timer is a hardware timer that resets the microcontroller unless the firmware restarts ("feeds" or "kicks") it before it runs out. If the program hangs in an endless loop, waits forever for a network that never answers or crashes in a way that leaves the chip running, the watchdog restarts it. For a device nobody will visit, it is the last line of defense.

The trick is where you feed it. Feeding it at the top of the main loop only proves that the loop runs. Feed it only after the device has done useful work, such as reading a valid telegram, and the watchdog also catches the case where the loop runs but nothing works. The simulation below follows that rule with a 30-second timeout; the P1 cable is pulled out after 10 seconds:

WATCHDOG_TIMEOUT_S = 30


def telegram_ok(second):
    """Simulated P1 input: valid telegrams until the cable is pulled at 10 s."""
    return second < 10


last_feed = 0
for second in range(60):                 # one pass of the super loop per second
    if telegram_ok(second):
        last_feed = second               # feed only after real progress
        if second in (0, 9):
            print(f"{second:>2} s  telegram OK, watchdog fed")
    if second - last_feed >= WATCHDOG_TIMEOUT_S:
        print(f"{second:>2} s  no valid telegram for {WATCHDOG_TIMEOUT_S} s: "
              "watchdog resets the board")
        break
print("after the reset: boot, reconnect Wi-Fi, try the P1 port again")
 0 s  telegram OK, watchdog fed
 9 s  telegram OK, watchdog fed
39 s  no valid telegram for 30 s: watchdog resets the board
after the reset: boot, reconnect Wi-Fi, try the P1 port again

ESP-IDF has two built-in watchdogs: the interrupt watchdog checks that interrupt handlers do not block the processor too long, and the task watchdog checks that tasks keep giving the processor back to the scheduler. MicroPython offers machine.WDT, with a minimum timeout of one second on the ESP32. In MicroPython a watchdog cannot be stopped once it runs, so it keeps protecting the device even when the code that should feed it is stuck.

Putting it together: Lotte's P1 reader

Lotte Kortenhoeve, a Kestrel Energy customer in Utrecht, wrote the first version of her smart-meter reader in MicroPython on an ESP32 development board. It reads the inverted UART of the P1 port (see Bus Protocols), checks each telegram's CRC (the function and the format are explained on Smart Meters and the P1 Port), and publishes the current power to the MQTT broker on her Raspberry Pi (see MQTT and IoT Messaging). The watchdog resets the board when no valid telegram has arrived for 30 seconds. Wi-Fi setup is left out; pin numbers and the broker address are examples, and keyword arguments can differ between ports, so check the MicroPython documentation for your board.

from machine import UART, WDT
from umqtt.simple import MQTTClient

uart = UART(1, baudrate=115200, bits=8, parity=None, stop=1,
            rx=16, timeout=2000, invert=UART.INV_RX)
wdt = WDT(timeout=30000)                  # reset after 30 s without feed()
mqtt = MQTTClient("p1-reader", "192.168.1.20")
mqtt.connect()


def crc16_arc(data):
    crc = 0
    for byte in data:
        crc ^= byte
        for _ in range(8):
            crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
    return crc


def read_telegram():
    """Collect lines from "/" up to and including the "!CRC" line."""
    lines = []
    while True:
        line = uart.readline()            # None after the timeout
        if line is None:
            return None
        if line.startswith(b"/"):
            lines = []
        lines.append(line)
        if line.startswith(b"!"):
            return b"".join(lines)


def is_valid(telegram):
    end = telegram.index(b"!") + 1
    try:
        sent = int(telegram[end:].strip(), 16)    # also accepts an unpadded CRC
    except ValueError:                            # garbled CRC field: drop the telegram
        return False
    return crc16_arc(telegram[:end]) == sent


def import_watts(telegram):
    for line in telegram.split(b"\r\n"):
        if line.startswith(b"1-0:1.7.0("):
            return round(float(line[10:line.index(b"*")]) * 1000)
    return None


while True:
    telegram = read_telegram()
    if telegram and is_valid(telegram):
        wdt.feed()                        # fed only after a valid telegram
        watts = import_watts(telegram)
        if watts is not None:
            mqtt.publish(b"home/p1/power", str(watts).encode())

The program has the shape of every small device: read, validate, act, and let the watchdog catch whatever goes wrong. It also publishes a bare number of watts; a later version adds the unit and the measurement time to every message (see MQTT and IoT Messaging). What it does not yet handle well is a lost connection to the broker: publish() then raises an exception that stops the program, and the watchdog resets the board 30 seconds later. That works, but reconnecting with a delay would be kinder to the broker and the network; see Edge Computing and Gateways for buffering readings while the broker is away.

Debugging firmware

The serial console is the first tool: everything the firmware prints arrives on your laptop over the USB cable, together with the messages of the framework and the bootloader. Connecting to a MicroPython board shows its prompt:

MicroPython v1.29.0 on 2026-08-24; Generic ESP32 module with ESP32
Type "help()" for more information.
>>>

Things to look for when a device misbehaves:

  • The reset reason printed at start-up, or read in code (machine.reset_cause() in MicroPython): power-on, watchdog, brownout (supply voltage too low; see Power and Batteries) or a crash.
  • A reset loop: the device restarts every few seconds. Often a crash early in start-up, a watchdog that fires during a slow step, or a power supply that collapses when Wi-Fi starts.
  • Free memory over time: print it every hour. A number that only goes down is a leak.
  • Counters: telegrams read, CRC errors, bytes dropped, reconnects. They turn "it sometimes fails" into a number you can follow.