- Linto Thomas
- October 9, 2025
Exploring Dynamic Loading in Zephyr RTOS for Modular Embedded Systems
1. DYNAMIC LOADING IN ZEPHYR

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. It helps in loading only position independent extensions.
1.2 LLEXT Implementation and Features
LLEXT (Loadable Extension) is a Zephyr subsystem that allows you to dynamically 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.
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
The llext heap needs execute permission to execute the .text segment of the loaded elf, if MPU or MMU is not giving that permission to the dynamically allocated memory then we would have to disable the MPU, with CONFIG_ARM_MPU=n in prj.cong
A solution other than disabling the MPU is to use the configuration CONFIG_USERSPACE=y in the prj.conf. The condition is that the program works only in user mode. https://github.com/zephyrproject-rtos/zephyr/issues/72957
Limitations of using user mode.
- User mode threads are untrusted by Zephyr and are therefore isolated from other user mode threads and from the kernel.
- Cannot call kernel APIs like k_mutex_lock, interrupt related APIs directly.
- Kernel global cannot be accessed.
- Using syscalls for accessing drivers.
- Stack size cannot be dynamically increased, so overflow might occur if enough size is not allocated initially.
- User mode threads have low priority.
Refer: https://docs.zephyrproject.org/latest/kernel/usermode/overview.html
1.2.3 Points to note in LLEXT
2 types of loading mechanism LLEXT_BUF_LOADER and LLEXT_FS_LOADER
- In case of BUF_LOADER the llext need you to give it the elf binary start address ,it 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 create a heap using k_heap_alloc and this heap is the one which hold 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 provide the data and functions to perform the reallocations and llext_load load 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:
- 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 in llext
- we could do 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:
CONFIG_LLEXT_HEAP_SIZE determines the heap size of llext , this is where the extension is kept while loading, the size of heap can be increased as needed depending on the available ram size.
Memory Layout:-

Flash operations
Since the extensions will be stored in the flash and will be loaded from the flash on requirement, we need to use the flash apis for reading and writing extension.
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 has to 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
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
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
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
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
- 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.
Can also add encryption for the the dynamic loaded executable with the main firmware holding the key - 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. - 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. - 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.