From cd239c037213c9c8db42eec4c50bbe3facdf2388 Mon Sep 17 00:00:00 2001 From: Liam Girdwood Date: Thu, 22 Oct 2026 13:33:34 +0100 Subject: [PATCH 1/3] scripts: llext_link_helper: align the first section of each region The Zephyr LLEXT loader merges the sections of an extension into regions by type: code, read-only data and writable data, and pads the start of each region down to the largest alignment of its sections. For relocatable (ET_REL) objects it only applies that padding to the file offset of the region, not to its address. So if the first section of a region isn't at a file offset with that alignment, every address within the region is translated to a file offset, which is off by the padding. llext_export_symbols() uses that translation for the names of symbols, which pre-located extensions export. With wrong names, other modules can't link against them, e.g. eq_iir against the IIR functions of aux1_iir, which fails with -ENOENT. This happens as soon as the first read-only section isn't the most aligned one, for example an alignment 1 string section followed by the 16 byte aligned .module section. Find such regions after linking and raise the alignment of their first section, which is at a page aligned address anyway, so that objcopy places it at an aligned file offset too. Shared (ET_DYN) objects aren't affected, so only do this for relocatable ones. Signed-off-by: Liam Girdwood Co-Authored-By: Claude Opus 5.5 --- scripts/llext_link_helper.py | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/scripts/llext_link_helper.py b/scripts/llext_link_helper.py index 3b6c2d451e7c..835d3c518749 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,62 @@ def max_alignment(addr, align1, align2): upper = addr + align1 - 1 return upper - (upper % align1) +# The Zephyr LLEXT loader merges the sections of an extension into regions by +# type: code, read-only data and writable data, and pads the start of each +# region down to the largest alignment of its sections. For relocatable +# objects it only moves the file offset of the region that way, not its +# address, so if the first section of a region isn't at a file offset with +# that alignment, addresses within that region are translated to wrong file +# offsets. Among other things the names of symbols, exported by the module, +# can't be found then. Return the sections, which have to be aligned in the +# file to avoid that, and their required alignment. +def region_alignment_fixes(path, detached): + regions = {} + + with open(path, 'rb') as f: + elf = ELFFile(f) + + for section in elf.iter_sections(): + s_flags = section.header['sh_flags'] + + if (not s_flags & SH_FLAGS.SHF_ALLOC or s_flags & SH_FLAGS.SHF_TLS or + not section.header['sh_size'] or section.name in detached): + continue + + if section.name == '.exported_sym': + region = 'export' + elif section.header['sh_type'] != 'SHT_PROGBITS': + continue + elif s_flags & SH_FLAGS.SHF_EXECINSTR: + region = 'text' + elif s_flags & SH_FLAGS.SHF_WRITE: + region = 'data' + else: + region = 'rodata' + + regions.setdefault(region, []).append((section.header['sh_offset'], + section.name, + section.header['sh_addralign'], + section.header['sh_addr'])) + + fixes = [] + + for sections in regions.values(): + offset, name, align, addr = min(sections) + region_align = max(s[2] for s in sections) + + if offset % region_align == 0: + continue + + if addr % region_align: + print(f'llext_link_helper.py: WARNING: cannot align {name} to {region_align}', + file=sys.stderr) + continue + + fixes.append((name, region_align)) + + return fixes + def main(): global args @@ -221,6 +278,12 @@ def main(): if first_dram_rodata: copy_command.extend(['--set-section-alignment', f'{first_dram_rodata}=4096']) + if '-r' in args.params: + detached = [section.name for section in executable + readonly_dram] + + for s_name, s_align in region_alignment_fixes(f'{args.file}.tmp', detached): + copy_command.extend(['--set-section-alignment', f'{s_name}={s_align}']) + copy_command.extend([f'{args.file}.tmp', f'{args.output}']) subprocess.run(copy_command) From ba53e1d6b34d3fa33b2f0f01e5db5029eb0ce654 Mon Sep 17 00:00:00 2001 From: Liam Girdwood Date: Thu, 15 Oct 2026 22:22:00 +0100 Subject: [PATCH 2/3] 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 | 39 +++++++++++++++++++++++++++++++-- scripts/llext_merge_sections.ld | 21 ++++++++++++++++++ 2 files changed, 58 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 835d3c518749..b21f39c847bf 100755 --- a/scripts/llext_link_helper.py +++ b/scripts/llext_link_helper.py @@ -105,6 +105,32 @@ def region_alignment_fixes(path, detached): return fixes +# 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 @@ -141,7 +167,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, @@ -267,7 +302,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 6fdacc46b8f9deb80b05ce2fdaa752872f05199c Mon Sep 17 00:00:00 2001 From: Liam Girdwood Date: Thu, 15 Oct 2026 22:22:00 +0100 Subject: [PATCH 3/3] 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 b21f39c847bf..727234654334 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 @@ -131,6 +132,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('