Plugin System

May 27, 2026 · View on GitHub

The stub supports runtime-loadable plugins that extend its command set with new capabilities. Plugins are general-purpose — they can add support for external flash chips, memory devices, security features, or any other hardware functionality. This document describes the plugin architecture, the build process, and how to add a new plugin.

Architecture

Function Pointer Table (FPT)

Plugins are dispatched through a Function Pointer Table (FPT): a global array of function pointers located in the base stub's .data segment. At startup all entries are initialized to s_plugin_unsupported, which sends an error response for any unknown opcode.

Key constants (defined in src/plugin_table.h):

ConstantValueMeaning
PLUGIN_FIRST_OPCODE0xD5First opcode reserved for plugins
PLUGIN_LAST_OPCODE0xEFLast opcode reserved for plugins
PLUGIN_TABLE_SIZE27Number of FPT slots
plugin_table_offset(computed)Byte offset of FPT within the .data segment, derived from the plugin_table ELF symbol at JSON generation time

Handler ABI

Every plugin handler must match the following signature:

typedef int (*plugin_cmd_handler_t)(uint8_t command,
                                    const uint8_t *data,
                                    uint32_t len,
                                    struct command_response_data *resp);
  • command — the opcode byte.
  • data — pointer to the command payload.
  • len — payload length in bytes.
  • resp — output struct (zeroed by the dispatcher before the call); the handler fills resp->value, resp->data[], and resp->data_size as needed. Streaming handlers may also set resp->post_process to a callback that runs after the primary response has been sent.

The handler returns an esp_response_code value (RESPONSE_SUCCESS == 0 on success). The base dispatcher owns primary response framing and the post-process step — handlers must not send their own response frames. Post-process callbacks receive struct cmd_ctx, including ctx->transport, and should use that transport for streaming data and polling ACK frames.

Plugin handlers must use BSS-only global state — no initialized (.data) globals and no .rodata constants. Only zero-initialized globals (.bss) and code/const data emitted into .text are permitted because only the .text and .bss sections are present in the plugin binary.

Keeping handler symbols in the plugin ELF

Handlers are only invoked at runtime after esptool patches the FPT in the base stub. The plugin link has no in-image call graph to those functions, so the linker may drop them under -ffunction-sections and --gc-sections.

Every exported handler must:

  1. Have global linkage and a stable symbol name (matching PLUGIN_HANDLER_SYMBOLS in tools/elf2json.py).
  2. Be listed with EXTERN(<symbol>) in the plugin linker script (see src/ld/nand_plugin.ld). EXTERN roots the symbol for the linker regardless of LTO or section layout.
int my_plugin_attach(uint8_t command, const uint8_t *data,
                     uint32_t len, struct command_response_data *resp)
{
    ...
}
ENTRY(my_plugin_attach)
EXTERN(my_plugin_attach);
EXTERN(my_plugin_read);
/* one EXTERN per handler in PLUGIN_HANDLER_SYMBOLS */

If a handler is missing from the linker script, the plugin ELF may link successfully but elf2json.py fails during the post-build step with “missing handler symbol(s)”.

The plugin linker script must set ENTRY(<attach_handler>) after INCLUDE common.ld so ENTRY(esp_main) from common.ld is overridden (required for Xtensa). ENTRY alone roots only the attach handler; list every other handler with EXTERN.

Dispatch

handle_command() in src/command_handler.c checks whether the incoming opcode falls in the range 0xD50xEF. If so, it indexes into the FPT and calls the corresponding handler. If the entry is still s_plugin_unsupported, an error response is returned.

Upload Order

Before uploading the stub, esptool (Python side):

  1. Patches the relevant FPT entries with the plugin handler addresses.
  2. Uploads in this order:
    1. Base stub .text
    2. Base stub .data + .bss zeros + plugin .bss zeros
    3. Plugin .text
    4. Calls mem_finish to start execution

After startup, plugin opcodes use the same command dispatcher and transport operations as base commands. Plugins do not link against base SLIP helper symbols; streaming callbacks use ctx->transport.

Xtensa IRAM Caveat

On Xtensa cores (ESP32-S2, ESP32-S3), IRAM only supports 32-bit stores. Writing non-4-byte-aligned data to IRAM causes a LoadStoreError exception (EXCCAUSE=3). elf2json.py automatically pads the plugin .text to a 4-byte boundary before embedding it in the JSON file.

Build

A plugin must be linked at a fixed address immediately after the base stub (and after any earlier plugins), so its load address depends on the base stub's .text/.data/.bss sizes. Those sizes are only known once the base stub is linked, so the address is computed at build time, after the base stub:

stub-<chip>.elf                          (base stub linked)


tools/compute_plugin_addrs.py
  Reads the base ELF .text/.data/.bss sizes (plus any preceding
  plugin ELFs passed via --after) and writes a linker fragment
  <name>_plugin_addrs.ld:  PLUGIN_TEXT_ADDR / PLUGIN_BSS_ADDR


stub-<chip>-<name>-plugin.elf
  Linked with  -T <name>_plugin_addrs.ld  -T <name>_plugin.ld.
  A LINK_DEPENDS on the fragment relinks the plugin whenever the
  base stub's size changes.


tools/elf2json.py
  Converts the base stub ELF to JSON and embeds each plugin's
  .text and .bss metadata under the "plugins" key.

The generated fragment replaces the older -Wl,--defsym approach: compute_plugin_addrs.py emits linker-script symbol assignments that the plugin linker script consumes in its MEMORY block. Multiple plugins stack — each plugin's fragment is computed from the base stub plus every preceding plugin ELF (--after).

Key Tool Files

FilePurpose
tools/compute_plugin_addrs.pyReads base (+ preceding plugin) ELF sizes and emits a <name>_plugin_addrs.ld fragment defining PLUGIN_TEXT_ADDR/PLUGIN_BSS_ADDR
tools/elf2json.pyConverts ELF to JSON; embeds plugin .text and .bss size
src/plugin_table.hFPT ABI constants and plugin_cmd_handler_t typedef
src/command_handler.cOpcode dispatch to FPT
src/nand_plugin.cReference plugin (9 NAND handlers)
src/ld/nand_plugin.ldReference plugin linker script

Chip Support

Plugins are not supported on ESP8266 or ESP32. All other chips (ESP32-S2, ESP32-S3, ESP32-C3, etc.) have the FPT built in to the base stub. Currently, ESP32-S3 is the only chip that ships with a plugin (the NAND plugin). Support for additional chips is planned.

Adding a New Plugin

The following steps use a hypothetical "SPI RAM" plugin as a concrete example.

Step 1 — Choose Opcodes

Pick unused opcodes from the 0xDE0xEF range (0xD50xDD are already reserved by NAND). Document the new opcodes in src/commands.h:

#define ESP_SPI_RAM_ATTACH       0xDE
#define ESP_SPI_RAM_READ         0xDF
#define ESP_SPI_RAM_WRITE        0xE0

Step 2 — Create src/spi_ram_plugin.c

Implement functions matching plugin_cmd_handler_t. Use only BSS globals (no .data initializers and no .rodata constants). Include plugin_table.h for the ABI typedef. Every handler that appears in PLUGIN_HANDLER_SYMBOLS must be a global function with a stable symbol name:

#include "plugin_table.h"

static uint32_t s_ram_size;   /* BSS — zero-initialized */

int spi_ram_plugin_attach(uint8_t command, const uint8_t *data,
                          uint32_t len, struct command_response_data *resp)
{
    (void)command;
    /* init hardware, fill resp if needed, return status code */
    return RESPONSE_SUCCESS;
}

int spi_ram_plugin_read(uint8_t command, const uint8_t *data,
                        uint32_t len, struct command_response_data *resp)
{
    (void)command;
    /* read, fill resp->data / resp->data_size, return status code */
    return RESPONSE_SUCCESS;
}

Step 3 — Create src/ld/spi_ram_plugin.ld

Follow the pattern of src/ld/nand_plugin.ld. PLUGIN_TEXT_ADDR and PLUGIN_BSS_ADDR come from the generated <name>_plugin_addrs.ld fragment (pulled in via -T). Reuse common.ld for section layout; override the entry point and EXTERN every handler symbol:

MEMORY {
    iram : org = PLUGIN_TEXT_ADDR, len = 0x10000
    dram : org = PLUGIN_BSS_ADDR,  len = 0x10000
}

INCLUDE common.ld

ENTRY(spi_ram_plugin_attach)
EXTERN(spi_ram_plugin_attach);
EXTERN(spi_ram_plugin_read);

ASSERT(SIZEOF(.data) == 0, "Plugin must not have initialized .data — use BSS instead")

Step 4 — Add Target HAL in esp-stub-lib

Implement target-specific functions (e.g., src/target/esp32s3/src/spi_ram.c) following the same pattern as nand.c and spi_nand.c.

Step 5 — Update src/CMakeLists.txt

Add detection of the new HAL file (analogous to _NAND_C), then call stub_add_plugin() with the plugin sources and linker script. The macro derives the target name, generates the <name>_plugin_addrs.ld fragment, wires the build-time dependencies, and stacks the plugin after any earlier ones — no addresses to specify by hand:

set(_SPI_RAM_C ${ESP_STUB_LIB_DIR}/src/target/${ESP_TARGET}/src/spi_ram.c)

if(EXISTS "${_SPI_RAM_C}")
    stub_add_plugin(
        NAME          spi_ram
        SOURCES
            spi_ram_plugin.c
            ${_SPI_RAM_C}
        LINKER_SCRIPT ${LINKER_SCRIPTS_DIR}/spi_ram_plugin.ld
    )
endif()

Plugins load in the order stub_add_plugin() is called: the first is placed right after the base stub and each subsequent plugin stacks after the previous one.

Step 6 — Update tools/elf2json.py

Register the plugin handler symbols in PLUGIN_HANDLER_SYMBOLS. elf2json.py already accepts repeated --plugin <name> <elf> arguments, reads each plugin ELF's .text (padded to 4 bytes for Xtensa), resolves handler symbol addresses, and records the plugin in the output JSON under the plugins key:

"plugins": {
    "spi_ram": {
        "text": "<base64-encoded padded .text>",
        "text_start": 1077944160,
        "bss_size": 4096,
        "handlers": {
            "0xDE": 0,
            "0xDF": 128,
            "0xE0": 256
        }
    }
}

The handlers map contains opcode → offset-from-plugin-text-start pairs. Register the handler symbols in the PLUGIN_HANDLER_SYMBOLS dict at the top of elf2json.py. Each symbol named there must be implemented in the plugin C source and listed with EXTERN() in the plugin linker script.

Step 7 — Update esptool (Python Side)

In StubFlasher (esptool loader.py):

  1. Detect the "plugins" key in the JSON stub file.
  2. Patch the FPT entries for the new opcodes with the plugin handler addresses (plugin text start + handler offset).
  3. Upload plugin .bss zeros appended to the base data segment, then upload plugin .text after the base text.