- Linto Thomas
- October 9, 2025
Exploring Dynamic Loading in Zephyr RTOS for Modular Embedded Systems
1.1 Introduction
Zephyr RTOS Overview
Zephyr RTOS is an open-source, highly modular, real-time operating system designed for resource-constrained embedded devices, such as sensors, wearables, and IoT gateways. It supports a wide range of processor architectures (ARM, RISC-V, x86, Xtensa, etc.) and enables developers to configure systems via Kconfig and Device Tree so that only the required kernel services, drivers, and protocol stacks are included — resulting in footprints as small as a few kilobytes of RAM and flash. Zephyr’s design emphasizes deterministic performance, thread isolation, and memory protection (supported by hardware), while still providing robust connectivity, driver support, and ecosystem tooling. Its lightweight core and optional subsystems make it ideal for applications needing both minimal resource use and flexible extendibility. One such framework provided by Zephyr for dynamic loading mechanisms is LLEXT.
Dynamic Loading vs. Static Linking
Static linking combines all code and libraries into a single firmware image at build time. While this approach is simple and fast at runtime, it results in a monolithic, inflexible system — any update or addition of functionality requires a full rebuild and firmware reflash.
Dynamic loading, on the other hand, loads program modules (functions, libraries, or extensions) into memory at runtime. This enables on-demand execution, reduces memory footprint by only loading required features, and allows modular system design. Dynamic loading also supports easier updates, runtime experimentation with new algorithms, and the ability to extend functionality without rebooting or reflashing the device.
In resource-constrained embedded systems, dynamic loading improves scalability, maintainability, and flexibility, making it especially valuable for modular RTOS environments like Zephyr.
Position-Independent Code and LLEXT
There are 2 types of code position independent code and position dependent code. The difference is in the way they are linked. Position independent code is compiled in a way that allows it to run from any memory address. All memory accesses use relative addressing and not absolute addressing. Position dependent code is compiled to run at a specific memory address. All its memory references are absolute, meaning it must be loaded at the exact address it was built for. Loading at the wrong address will cause an error.
LLEXT is the zephyr tool which helps in dynamic loading of extensions at runtime. Currently it helps in loading only position independent extensions. Since dynamically loaded modules are reallocated at arbitrary memory addresses in the llext heap, LLEXT currently only supports position-independent code (PIC). For position dependent code it requires the executable to be loaded at a fixed address, this is not currently supported by LLExt framework.
1.2 LLEXT Implementation and Features
LLEXT (Loadable Extension) is a Zephyr subsystem that allows you to dynamically and load execute pre-compiled binary modules during runtime — without requiring a system reboot or firmware reflash. Currently llext supports only position independent code.
1.2.1 Building extension
The extension (the dynamically loaded application) can be built along with the main firmware as a separate elf using llext cmake function or it can be built separately using the EDK.
Add the required project configs in the prj.conf file,
CONFIG_LLEXT=y
CONFIG_LLEXT_LOG_LEVEL_DBG=y
CONFIG_LLEXT_HEAP_SIZE=64
CONFIG_LLEXT_TYPE_ELF_RELOCATABLE=y
CONFIG_MAIN_STACK_SIZE=2048
When using cmake function, you can build using the llext cmake function add_llext_target() with output and input path as arguments.
When using EDK,
Run “west build –t llext-edk” to create the llext-edk tar file.
Extract it to a folder, provide the folder path to LLEXT_EDK_INSTALL_DIR, also update ZEPHYR_SDK_INSTALL_DIR with the path to zephyr-sdk.
Then for creating the .llext file,
cmake – build –GNinja
ninja –C build
“generate_inc_file_for_target” is used to create a C loadable bin file of the elf.
1.2.2 MPU and LLEXT
When loading an extension, its .text segment (which contains executable code) must be placed in memory regions with execute permissions. If the MPU or MMU does not grant execute rights to dynamically allocated memory, the extension will fail to run.
Option 1 – Disable MPU (Simpler but less secure):
Add the following in prj.conf:
CONFIG_ARM_MPU=n
Option 2 – Enable User Mode (Safer but limited):
Enable userspace support with:
CONFIG_USERSPACE=y
This allows user-mode threads to execute extensions, but with these limitations:
– Cannot directly call kernel APIs such as k_mutex_lock() or interrupt handlers.
– Cannot access kernel global variables.
– Must use syscalls for driver access.
– Stack size is fixed and cannot grow dynamically.
– User-mode threads generally run at lower priority.
Refer:
https://docs.zephyrproject.org/latest/kernel/usermode/overview.html
https://github.com/zephyrproject-rtos/zephyr/issues/72957
1.2.3 Points to note in LLEXT
Two types of loading mechanism LLEXT_BUF_LOADER and LLEXT_FS_LOADER
- In the case of BUF_LOADER the llext needs you to give it the elf binary start does not take in the elf file itself.
- The bin can be stored in flash and the memory mapped address in flash can also be passed to llext for dynamic loading.
- The llext creates a heap using k_heap_alloc and this heap is the one which holds the extension and related data.
- Ideal situation would be like program the bin to flash and load it to ram on call.
- The sample program can be found at zephyr\samples\subsys\llext\modules
- Include header file #include <zephyr/llext/buf_loader.h>
- The input given to LLEXT_FS_LOADER is the path to the .elf file in filesystem.
- The llext_fs_loader provides the data and functions to perform the reallocations and llext_load loads the extension.
- The input is only taken in elf format not in bin or hex.
- Include header file #include <zephyr/llext/fs_loader.h>
- Add configs CONFIG_FILE_SYSTEM, CONFIG_FILE_SYSTEM_LITTLEFS (if littlefs is the one used)
Symbol Table:
Once an extension is loaded, interaction with its functions happens using the symbol table.
- llext_find_sym() helps in finding the functions address of a function in extension from the symbol table.
- The symbol table will contain the functions in extension added to EXPORT_SYMBOL.
- The exact name of the function should be given for getting the function addr.
Example: void (*hello_world_fn)() = llext_find_sym(&ext->exp_tab, “hello_world”);
Security and encryption:
- There is no particular security check for loading extension to ram in llext framework.
- Some suggestions do is something like add a security function in extension and the expected function would be called from it, only if security check passes, only the security function must be added to EXPORT_SYMBOL.
- Also, you can add encryption to the loaded elf (like aes encryption). This can be done while building the elf itself. And a decryption function will be there in main to decrypt the extension elf bin or file and validate it (using the ‘E’ ‘L’ ‘F’ hex symbol in elf).
LLEXT heap:
LLExt heap is the heap allocated from the RAM using k_heap_alloc for loading an extension module. CONFIG_LLEXT_HEAP_SIZE determines the heap size in llext , this is where the extension is kept when loaded, the size of heap can be increased as needed depending on the available ram size.
Flash operations
The main use to this dynamic loading mechanism is to reduce the memory footprint in ram, so we need to store the loadable modules or algorithms in the flash. When the main firmware requests a specific module then it will be loaded from the flash. For LLEXT_FS_LOADER the executable can be taken from flash directly if the flash has a filesystem. If LLEXT_BUF_LOADER is used, then the executable must be copied to a ram location, and this address would be the one passed to the llext_laod() for loading to the llext heap. We can use the flash apis flash_read(), flash_write(), flash_erase() for reading and writing extension in flash.
1.2.4 Additional features tried on extension
Call function in main firmware from extension
-To make a function in main firmware available to call in extension we just must add it in EXPORT_SYMBOL in the main firmware.
EXPORT_SYMBOL(my_main_function)
-And in extension we should add it with extern so that no compile error comes for extension.
extern void my_main_function(void);
Shared memory:
-A shared section was created using a dts overlay file to be used as a shared memory. For overlay files the name of the overlay file should be same as the corresponding dts file with the .overlay extension.
-Now for the shared data access a structure shared_info was created in a common header file of main and extension
-In main, the structure member must be declared in the created shared section.
-In the extension declare it as, extern struct shared_info s_info; Now you can directly access the value updated in main in extension, s_info.b and s_info.a also can update the values and it will be reflected in the main.
Loading multiple extensions:
-Multiple extensions can be loaded and unloaded one after the other or can also load multiple extensions without unloading. The heap and stack size should be given enough based on the number of extensions.
-If loading multiple ones use different struct llext variable and different loader variable.
-To load the same elf to different regions, call the llext_load 2 times so it will be loaded at 2 different places in heap.
Enable function sharing between 2 extensions:
Function sharing between 2 extensions was done by creating a function which returns the address of functions in extensions, so one extension can call function in other extension.
We would get the extension function address using llext_find_sym with the correct llext struct pointer and function name. So we call a function in main with these required arguments from an extension to get the address of a function in another extension.
Buffer pool mechanism:
To create a memory pool in the extension, we should call k_heap_init in the extension after creating a buffer of required heap size and pass it. It uses the llext heap memory and creates a memory pool inside it.
static uint8_t ext_heap_buf[1024];
static struct k_heap ext_heap;
k_heap_init(&ext_heap, ext_heap_buf, 1024);
int *ptr2 = k_heap_alloc(&ext_heap, 128, K_NO_WAIT);
k_heap_free(&ext_heap, ptr2);
The macro K_HEAP_DEFINE cannot be used here for 2 reasons because even if we call it, the k_heap_init must be still called since this is in an extension, if it was in main the init would have been called during boot up. The K_HEAP_DEFINE creates a section of memory that is under SHT_NOBITS and llext does not support relocation and loading of multiple SHT_NOBITS sections. The bss is already a SHT_NOBIT section so K_HEAP_DEFINE is creating another one it would create issues when creating uninitialized global variables in extension.
After k_heap_init is called with a particular size then k_heap_alloc and k_heap_free can be called on need to alloc heap memory and to free the allocated heap.
1.2.5 Debugging Extension
Since extensions are not part of the zephyr.elf the debug symbols for extension will not be part of this. So, we loaded the extension and then loaded the debug symbols of the extension to do the debugging using gdb in the extension.
Logging verbosity level of the LLEXT subsystem: –
CONFIG_LLEXT_LOG_LEVEL_NONE : No logs
CONFIG_LLEXT_LOG_LEVEL_ERR : Only error logs
CONFIG_LLEXT_LOG_LEVEL_WRN : Warnings + errors
CONFIG_LLEXT_LOG_LEVEL_INF : Info + warnings + errors
CONFIG_LLEXT_LOG_LEVEL_DBG : Debug + info + warnings + error
Step 1: Build the llext program with CONFIG_LLEXT_LOG_LEVEL_DBG and CONFIG_LOG configurations. A <ext name>_debug.elf file will be generated in build folder.
Step 2: Run the west debug command to start the debug section based on core.
Step 3: Put a break point at the llext_load.
Step 4: Type “finish” to finish the extension loading.
Step 5: From the log in serial output you can find the addr of diff sections like .text .data of the extension.
Step 6: Enter “symbol-file” to remove the debug symbols of main elf since it might cause an issue if the buffer in main contains the extension.
Step 7: Then enter add-symbol-file <path to \\build\\llext\\hello_world_ext_debug.elf> -s .text 0x20001c40 -s .rodata 0x20001dc0 the addr of .text, .rodata etc will get from the serial terminal log. Step 7: Now put breakpoint at an extension function and step through.
1.2.6 Practical use cases
RAM optimized features
18. Load heavy-but-infrequent functionality only when needed, reducing resident RAM usage. These could be stored in flash or some external memory and load only on requirement. Llext would be able to efficiently load these, and the symbol table mechanism provide easy method for calling functions.
Vendor-specific codec
19. Ship a common runtime in firmware and load vendor/region-specific codecs or small on-device ML inference modules as extensions. Can replace and load some vendor specific codes on requirement.
Switching algorithms
20. Load alternate algorithms for different functionality, like for a TWS system we use one algorithm for the phone calls and another for the hearing music. So detecting the function we could load the appropriate algorithms.
Field feature updates / hot plugins
21. Add bugfixes, new telemetry formats, or small algorithmic improvements to devices already deployed, we could do these testing and improvement works without reflashing bootloader or full firmware.
1.2.7 Limitations and Suggested Improvements
22. No built-in image authentication: LLEXT loads arbitrary ELF binaries by default.
We could add a security function which validates the dynamicaly loaded executable and algorithms.
23. The LLEXT heap must be executable for .text section
Some platforms require disabling MPU regions or running extensions in user mode, both have trade-offs security vs usability.
24. Memory architecture challenges (Harvard vs. Von Neumann)
On Harvard-architecture cores such as Xtensa, instruction and data memories are physically separate — typically IRAM for executable code and DRAM for data.
The LLEXT subsystem currently allocates all extension sections (.text, .data, .bss) into a single heap region. Since that heap usually resides in DRAM (non-executable), the .text section cannot be executed directly.
The Harvard architecture support is currently being implemented in Zephyr.
25. Does not support communication between 2 dynamically loaded executables
A workaround for calling functions between two dynamically loaded extensions is mentioned in section 1.2.4.
Conclusion
Dynamic loading in Zephyr using the LLEXT subsystem opens a new dimension of flexibility for embedded systems.
Instead of building every possible feature into a single monolithic firmware, you can now load and unload modules at runtime, keep your base image lean, and experiment with new algorithms or features on demand — without needing a reboot or full reflash.
While LLEXT has limitations — like needing execute permissions in RAM, lack of built-in security checks, and some complexity around MPU configuration — it shows how even resource-constrained systems can benefit from ideas traditionally found in desktop and server environments, like plugins and runtime extensions.
In the end, LLEXT isn’t just a technical tool; it’s a step toward making embedded development more modular, agile, and future-proof — where adding new features can be as easy as loading a new ELF file.

























