From 0f0915d69f2f6161ea7e494aa86155d9ac846df7 Mon Sep 17 00:00:00 2001 From: Liam Girdwood Date: Thu, 15 Oct 2026 22:22:00 +0100 Subject: [PATCH 1/2] scripts: llext_link_helper: merge sections of relocatable modules With CONFIG_LLEXT_TYPE_ELF_RELOCATABLE modules are partially linked (-r). A partial link keeps every C++ COMDAT group (inline functions, template instances, vtables) as a separate section, so C++ modules end up with thousands of sections: a wake-word module built with TensorFlow Lite Micro has 5721 sections and 1273 relocation sections. That breaks loading in several ways: - all executable sections except .text are linked at address 0 to run from DRAM, so 1639 of its 1640 code sections, including the hot TFLM convolution kernels, would execute from IMR - the section addresses are calculated from the module object only, so sections pulled in from libraries at the final link aren't assigned an address at all and are left at 0, also as "DRAM" sections, among them C++ vtables and read-only data - the LLEXT section map alone needs 45KiB, so llext_load() fails with -ENOMEM with the default 8KiB LLEXT heap, and with a bigger heap the module can't be mapped because .bss isn't next to .data any more First partially link all objects and libraries of a relocatable module into one object, with --force-group-allocation to dissolve the section groups and a linker script to merge all .text.*, .rodata.*, .data.* and .bss.* sections into one each, the same as a final link does. Then calculate addresses for that object. Specially named sections like .cold and .coldrodata aren't affected. If the linker doesn't support this, fall back to the previous behaviour with a warning. That module goes down to 25 sections, with only the .cold section in DRAM, and its file size from 1028KiB to 657KiB. Together with further fixes in the Zephyr LLEXT loader and in the module build, it then loads and runs on PTL, without any inference slowdown compared to building it into the base firmware. C modules mostly have a handful of sections anyway. Signed-off-by: Liam Girdwood Co-Authored-By: Claude Opus 5.5 --- scripts/llext_link_helper.py | 40 +++++++++++++++++++++++++++++++-- scripts/llext_merge_sections.ld | 21 +++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 scripts/llext_merge_sections.ld diff --git a/scripts/llext_link_helper.py b/scripts/llext_link_helper.py index 3b6c2d451e7c..fb9069bb3385 100755 --- a/scripts/llext_link_helper.py +++ b/scripts/llext_link_helper.py @@ -8,6 +8,7 @@ # line parameters to this script and call the linker directly. import os +import sys import argparse import subprocess from elftools.elf.elffile import ELFFile @@ -48,6 +49,32 @@ def max_alignment(addr, align1, align2): upper = addr + align1 - 1 return upper - (upper % align1) +# A partial (-r) link keeps every per-function section and every C++ COMDAT +# group as a separate section. Below, all executable sections except .text +# are linked to run from DRAM, and sections only pulled in from libraries +# wouldn't be seen in the input object at all and would stay at address 0. +# So for relocatable modules first partially link all objects and libraries +# into a single object, dissolving section groups and merging sections of +# the same type, and then calculate addresses for that object. +# Returns the merged object and the linker parameters to use with it, or +# None if the linker doesn't support this. +def prelink_relocatable(): + merged = f'{args.file}.merged' + script = pathlib.Path(__file__).parent / 'llext_merge_sections.ld' + + command = [args.command] + args.params + ['-Wl,--force-group-allocation', + f'-Wl,-T,{script}', '-o', merged] + if subprocess.run(command).returncode != 0: + print('llext_link_helper.py: WARNING: pre-linking failed, ' + 'linking without merging sections', file=sys.stderr) + return None + + # All objects and libraries are in the merged object now, keep options + options = [p for p in args.params if p.startswith('-') and + not p.startswith(('-l', '-L'))] + + return merged, options + [merged] + def main(): global args @@ -84,7 +111,16 @@ def main(): text_found = False - elf = ELFFile(open(args.file, 'rb')) + params = args.params + elf_file = args.file + + if '-r' in params: + merged = prelink_relocatable() + if merged: + params = merged[1] + elf_file = merged[0] + + elf = ELFFile(open(elf_file, 'rb')) # Create an object file with sections grouped by their properties, # similar to how program segments are created: all executable sections, @@ -210,7 +246,7 @@ def main(): start_addr += section.header['sh_size'] command.extend(['-o', f'{args.file}.tmp']) - command.extend(args.params) + command.extend(params) subprocess.run(command) diff --git a/scripts/llext_merge_sections.ld b/scripts/llext_merge_sections.ld new file mode 100644 index 000000000000..4f4cf758da89 --- /dev/null +++ b/scripts/llext_merge_sections.ld @@ -0,0 +1,21 @@ +/* SPDX-License-Identifier: BSD-3-Clause */ +/* + * Linker script for the partial (-r) pre-link of relocatable LLEXT modules, + * used by llext_link_helper.py together with --force-group-allocation. + * + * A partial link keeps every per-function section and every C++ COMDAT + * group (inline functions, template instances, vtables) as a separate + * output section, so a C++ module can end up with thousands of sections. + * Fold them into one section per type, so the module gets one .text, one + * .rodata, one .data and one .bss, the same as after a final link. + * + * Specially named sections (.cold, .coldrodata, .module, ...) don't match + * these patterns and are kept as they are. + */ +SECTIONS +{ + .text 0 : { *(.literal .text .literal.* .text.*) } + .rodata 0 : { *(.rodata .rodata.*) } + .data 0 : { *(.data .data.* .data.rel.ro .data.rel.ro.*) } + .bss 0 : { *(.bss .bss.* COMMON) } +} From 3b188233a1c24f16f177e213c541fe215b7056fd Mon Sep 17 00:00:00 2001 From: Liam Girdwood Date: Thu, 15 Oct 2026 22:22:00 +0100 Subject: [PATCH 2/2] scripts: llext_link_helper: drop relocations needing no run-time action A partially linked Xtensa object keeps relocation entries which the LLEXT loader processes but doesn't act on: R_XTENSA_NONE and R_XTENSA_ASM_EXPAND ones, and R_XTENSA_SLOT0_OP ones for PC-relative L32R and CALLn references within a section, which the linker has already resolved, so the loader leaves the instruction unchanged. These are the vast majority: 12600 of 14764 relocations in a C++ module built with TensorFlow Lite Micro. Still for each of them the loader reads the relocation entry, the symbol and the instruction from the module image in IMR, where each cache miss costs about 1.7us on PTL. That took 73ms of the 86ms llext_load() time of that module. Remove them from the relocation sections after linking. For R_XTENSA_SLOT0_OP this uses the same check as the loader, so the result is identical. With this, the relocation pass of that module takes 21ms. Signed-off-by: Liam Girdwood Co-Authored-By: Claude Opus 5.5 --- scripts/llext_link_helper.py | 75 ++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/scripts/llext_link_helper.py b/scripts/llext_link_helper.py index fb9069bb3385..d8e1f64cbdb5 100755 --- a/scripts/llext_link_helper.py +++ b/scripts/llext_link_helper.py @@ -10,6 +10,7 @@ import os import sys import argparse +import struct import subprocess from elftools.elf.elffile import ELFFile from elftools.elf.constants import SH_FLAGS @@ -75,6 +76,77 @@ def prelink_relocatable(): return merged, options + [merged] +R_XTENSA_NONE = 0 +R_XTENSA_ASM_EXPAND = 11 +R_XTENSA_SLOT0_OP = 20 + +# Check if the Zephyr LLEXT loader would leave this instruction unchanged, +# see r_xtensa_slot0_op_relocate(): it only patches L32R and CALLn +# instructions with an all-zero operand field +def xtensa_slot0_resolved(insn): + b0, b1, b2 = insn + if b1 or b2: + return True + if (b0 & 0xf) == 1: + return False + return not ((b0 & 0xf) == 5 and not (b0 & 0xc0)) + +# Most relocations in a partially linked Xtensa object need no action at +# run time: R_XTENSA_NONE and R_XTENSA_ASM_EXPAND ones, and R_XTENSA_SLOT0_OP +# ones for PC-relative references within a section, which the linker has +# already resolved. Still the loader has to read each of them, together with +# its symbol and the instruction in the module image, which is in slow DRAM. +# Remove them from the relocation sections. +def prune_xtensa_relocs(path): + RELA_SIZE = 12 + + with open(path, 'rb') as f: + elf = ELFFile(f) + if elf['e_machine'] != 'EM_XTENSA' or elf['e_type'] != 'ET_REL': + return + + shoff = elf['e_shoff'] + shentsize = elf['e_shentsize'] + updates = [] + dropped = 0 + total = 0 + + for idx, section in enumerate(elf.iter_sections()): + if section['sh_type'] != 'SHT_RELA' or section['sh_entsize'] != RELA_SIZE: + continue + + target = elf.get_section(section['sh_info']) + data = target.data() if target['sh_type'] != 'SHT_NOBITS' else b'' + raw = section.data() + kept = bytearray() + + for i in range(0, len(raw), RELA_SIZE): + r_offset, r_info = struct.unpack_from('