Skip to content

Building and Injecting

A module is a standard relocatable ELF object compiled from plain C. C3PO automates the whole pipeline from the Modules view; this page documents what happens under the hood and how to install the toolchains.

The pipeline

flowchart LR
    SRC["module .c"] --> CC["compile<br/>(arch toolchain)"]
    CC --> ELF["relocatable ELF (.o)"]
    ELF --> SIGN["sign<br/>HKDF + HMAC-SHA256"]
    SIGN --> BLOB["[32B HMAC][ELF]"]
    BLOB --> XFER["mod_chunk x N + mod_load"]
    XFER --> DEV["verify -> relocate -> run"]

1. Compile

C3PO selects the toolchain from the target chip and compiles the entry .c to a relocatable object:

Chip Toolchain
esp32 xtensa-esp32-elf-gcc
esp32s2 xtensa-esp32s2-elf-gcc
esp32s3 xtensa-esp32s3-elf-gcc
esp32c3 / esp32c6 / esp32h2 riscv32-esp-elf-gcc

The compiler flags differ by architecture and are mandatory:

# Xtensa (ESP32, S2, S3)
xtensa-esp32-elf-gcc -c -Os -fno-common \
  -mlongcalls -mtext-section-literals \
  -I /path/to/ESPM/include \
  module.c -o module.o

# RISC-V (C3, C6, H2)
riscv32-esp-elf-gcc -c -Os -fno-common \
  -march=rv32imc -mabi=ilp32 \
  -I /path/to/ESPM/include \
  module.c -o module.o

Xtensa: -mlongcalls is required

Without -mlongcalls -mtext-section-literals, the compiler emits R_XTENSA_SLOT0_OP relocations the loader cannot apply, and the module will fail to load.

C3PO also passes -DCONFIG_DEVICE_ID="..." so a module can know its device id, and adds the ESPM include/ directory (the standalone espilon-test/ESPM/include, or the firmware copy as a fallback). Toolchains are found on PATH or under ~/.espressif/tools.

2. Sign

The ELF is signed with a key derived from the device's 32-byte master_key:

sign_key = HKDF-SHA256(master_key, len=32, salt=None, info="espm-sign")
blob     = HMAC-SHA256(sign_key, elf) || elf      # 32-byte tag prepended

Because the key is the device key, each module is signed per device. The firmware verifies the tag before relocation; a blob signed for one device will not load on another.

3. Inject

The signed blob is transferred to the device with the firmware built-in commands:

  • mod_chunk - append a chunk to the staging buffer (sent N times for a large module).
  • mod_load - finalize: verify the HMAC, validate and relocate the ELF, load it into IRAM (or PSRAM on S3), and start the task.

Other built-ins manage loaded modules: mod_list, mod_stop, mod_purge, and mod_unpersist.

Relocation types applied at load

The loader patches the object for the target architecture:

  • Xtensa: R_XTENSA_32, R_XTENSA_ASM_EXPAND (no-op), R_XTENSA_SLOT0_OP (avoided via -mlongcalls).
  • RISC-V: R_RISCV_32, R_RISCV_HI20, R_RISCV_LO12_I/LO12_S, R_RISCV_CALL/CALL_PLT, R_RISCV_BRANCH, R_RISCV_RVC_BRANCH/RVC_JUMP, R_RISCV_RELAX (hint).

Installing the cross-compilers

The ESP-IDF toolchains provide the gcc cross-compilers. For RISC-V targets you can also install the Espressif package directly:

sudo apt install gcc-riscv32-esp-elf

Xtensa targets use the xtensa-esp32[-s2|-s3]-elf-gcc compilers from an ESP-IDF install.

Size limits

A module must fit within ESPM_MAX_CODE_SIZE (64 KB .text) and ESPM_MAX_DATA_SIZE (32 KB .data + .bss). The loader rejects anything larger.

See also