Skip to content

zephyr-cp: don't build the heap control structure in an undersized region - #11230

Merged
tannewt merged 1 commit into
adafruit:mainfrom
mikeysklar:zephyr-heap/tlsf-largest-region
Aug 26, 2026
Merged

zephyr-cp: don't build the heap control structure in an undersized region#11230
tannewt merged 1 commit into
adafruit:mainfrom
mikeysklar:zephyr-heap/tlsf-largest-region

Conversation

@mikeysklar

@mikeysklar mikeysklar commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

What

tlsf_create_with_pool() can write TLSF's control structure past the end of the
region it is given. This makes port_heap_init() skip a region that is too
small to host it instead of handing it over.

Why

tlsf_t tlsf_create_with_pool(void* mem, size_t pool_bytes, size_t max_bytes)
{
    tlsf_t tlsf = tlsf_create(mem, max_bytes ? max_bytes : pool_bytes);
    ...

tlsf_create() passes that argument to control_construct(), which dimensions
the control structure from it and performs both of its size checks against it.
So the checks test the maximum heap size, not the space actually available in
mem. When port_heap_init() calls this with circuitpy_max_ram_size and a
small region, the structure is sized for the maximum and written into the small
region.

On a SiWx917-DK2605A the port skips the large SRAM region because Zephyr's
malloc arena owns it (CONFIG_COMMON_LIBC_MALLOC with ARENA_SIZE=-1), so the
first candidate is a 1 KB DMA buffer:

region 0  0x000310ca..0x00050000  126774 B  sram0        skipped, Zephyr malloc
region 1  0x24061c00..0x24062000    1024 B  DMA buffer   <- first candidate
region 2  0x00000000..0x00000400    1024 B  NWP-reserved
region 3  0x0a000000..0x0a800000  8388608 B PSRAM

With an 8 MB maximum the control structure is 2412 bytes, so it overran that
1 KB region by 1388 bytes. Measured on the board: heap = 0x24061c00 and
tlsf_get_pool(heap) = 0x2406256c, a difference of 0x96c = 2412.

Scope of the change

The bound applies only to the region that hosts the control structure:

  • Region order is unchanged.
  • The existing size < 1024 guard is unchanged, so smaller regions are still
    eligible as additional pools.
  • A board whose first region already hosts the control structure successfully
    sees no behavioural change at all.

The only boards affected are those where the current code would overrun the
region, which it does silently today.

How to reproduce

Flash, attach, and read the heap state. No test program needed:

$ commander flash zephyr.rps --device SiWG917M111MGTBA --serialno <sn>
$ JLinkGDBServer -device SiWG917M111MGTBA -if SWD -speed 1000 -select USB=<sn> -port 2331 &
$ arm-none-eabi-gdb -q zephyr.elf -ex 'target remote localhost:2331' -ex 'monitor halt' \
    -ex 'printf "count=%d heap=0x%08x pools=[0x%x,0x%x,0x%x,0x%x]\n", \
         valid_pool_count, heap, pools[0], pools[1], pools[2], pools[3]'

Before:

count=2 heap=0x24061c00 pools=[0x0,0x2406256c,0x4,0x0]

heap is inside the 1 KB DMA buffer, and pools[2] = 0x4 is the NWP-reserved
region. After:

count=1 heap=0x0a000000 pools=[0x0,0x0,0x0,0xa00096c]

The control structure is in the 8 MB PSRAM region.

Hardware tested

Two SiWx917-DK2605A boards (Zephyr board siwx917_dk2605a, SoC
SiWG917M111MGTBA), one on macOS 15 and one on Ubuntu 24.04, each with its own
J-Link. Built on top of the board definition from #11218, now merged.

board unpatched patched
A 3/3 heap=0x24061c00 3/3 heap=0x0a000000
B 1/1 heap=0x24061c00 1/1 heap=0x0a000000

Board B's unpatched image was also caught halted in arch_system_halt with
reason=4 (K_ERR_KERNEL_PANIC), reached from port_heap_init() via
tlsf_add_pool() for the PSRAM region. That panic is not reliably reproducible;
the heap layout above is, on both boards.

Not tested on any other zephyr-cp board. I do not have the vendor blobs to build
the NXP or STM32 targets locally, which is part of why the change is scoped as
narrowly as it is.

Scope

8 KB is a round number comfortably above the 2412-byte structure measured here;
it is not computed from tlsf_size(), which needs an already-constructed
instance. Keeping the NWP-reserved and DMA regions out of the heap entirely is a
separate board-level concern and is not addressed here.

AI assistance

Written with Claude Code. I ran the before/after captures above myself on the
hardware listed.

@mikeysklar
mikeysklar force-pushed the zephyr-heap/tlsf-largest-region branch from b3fafd4 to fa10182 Compare August 23, 2026 02:03
@mikeysklar mikeysklar changed the title zephyr-cp: build the Python heap in the largest RAM region zephyr-cp: don't build the heap control structure in an undersized region Aug 23, 2026

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do this in the Python build code instead? It makes the list of ram regions.

@mikeysklar
mikeysklar force-pushed the zephyr-heap/tlsf-largest-region branch from fa10182 to 5e840ab Compare August 24, 2026 21:53
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Hardware update, and it raises the stakes on this one: current main does not boot on the SiWx917-DK2605A without this patch.

Tested today on two DK2605A boards, one on macOS and one on Ubuntu 24.04, each with its own J-Link.

Build main at 8caf4ca, flash, and the core ends up halted:

PC = 0827B36A   ->  arch_system_halt   (zephyr/kernel/fatal.c:30)
R0 = 00000004   ->  K_ERR_KERNEL_PANIC
LR = 08230BF8   ->  k_sys_fatal_error_handler (fatal.c:48)

No REPL on the VCOM and nothing on RTT, because the panic happens before either is up. Same result on both boards.

Cherry-pick this PR onto the same main and reflash the same board, and it boots:

Adafruit CircuitPython 10.3.0-alpha.4-63-g9ac13f6686 on 2026-08-25;
SiWx917 Wi-Fi 6 and Bluetooth LE SoC Dev Kit (BRD2605A) with siwg917m111mgtba
>>>

So the board definition in #11218 landed without the region guard it depends on, and CI could not catch it because CI only builds. That is my fault for splitting them, not a problem with the review.

Nothing in the patch changed for this; just posting the boot evidence, since "the board panics without it" is a stronger reason to take it than the out-of-bounds write argument alone.

@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Yes, and I think you are right that it belongs there. Here is why it got through the filter that already exists.

zephyr2cp.py already has one:

MINIMUM_RAM_SIZE = 1024
...
if size >= MINIMUM_RAM_SIZE:

and both problem regions are exactly 0x400:

node devicetree reg size
/memory@0 (nwp_reserved) 1024
/memory-dma@24061c00 (sram_dma1) 1024
/memory@400 (sram0) 318 KB
/soc/...psram@a000000 8 MB

So they pass by one byte on a >=. The runtime guard I wrote has the same off by one, if (size < 1024), which is why it did not catch them either.

The floor is also just too low. TLSF sizes its control structure from circuitpy_max_ram_size, which is 8 MB here, giving 2412 bytes. Any region under that cannot host the first pool. zephyr2cp.py computes max_size already, so it can work out the real floor rather than using a constant.

One thing I would keep in the C: ram_bounds starts are linker symbols (&__NWP_RESERVED_RAM_end and friends) and the ends are DT_REG_* macros, so the Python side sees the devicetree size, not what is left after the linker fills a region. That is the case the existing comment describes. For the two regions here the linker puts nothing in them, so the devicetree size is the real size and filtering in Python is correct and better.

I will rework this to filter in zephyr2cp.py and leave the C guard for the linker fill case only.

zephyr2cp.py already filtered RAM regions by size, but MINIMUM_RAM_SIZE was
1024 and the comparison is >=, so a region of exactly 0x400 bytes passed. The
SiWx917 has two of them, /memory@0 (reserved for the network processor) and
/memory-dma@24061c00, and both reached ram_bounds.

That matters because TLSF sizes its control structure from the maximum heap
size rather than from the region it is given: tlsf_create_with_pool() forwards
max_bytes to tlsf_create(), which performs both of its size checks against the
maximum. With an 8 MB maximum the structure is 2412 bytes, so handing it a
1 KB region overruns that region by 1388 bytes and still returns success. On a
SiWx917-DK2605A the result is a kernel panic during port_heap_init().

Raise MINIMUM_RAM_SIZE to 8192 so such regions never enter the list.

The build script only sees the devicetree size, while the usable size is
DT_REG_ADDR + DT_REG_SIZE minus a linker symbol, so it cannot predict a region
the linker has filled. port.c keeps a check for that case, now by testing
tlsf_create_with_pool() for NULL rather than assuming it succeeded.

Verified on two SiWx917-DK2605A boards: before, ram_bounds carried four
regions and the board panicked at boot on current main; after, the two 1 KB
regions are gone from the generated board.c and both boards boot to the REPL.
@mikeysklar
mikeysklar force-pushed the zephyr-heap/tlsf-largest-region branch from 5e840ab to 745461c Compare August 25, 2026 17:17
@mikeysklar

Copy link
Copy Markdown
Collaborator Author

Moved it into the build code as you suggested. MINIMUM_RAM_SIZE was 1024 and the check is >=, so the two SiWx917 regions that are exactly 0x400 passed by one byte. Raised it to 8192, since TLSF sizes its control structure from the maximum heap size and that is 2412 bytes at 8 MB.

Generated board.c drops from four regions to two. The one check left in port.c is for regions the linker fills, which the build script cannot predict because it only sees the devicetree size.

Both boards boot.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@tannewt
tannewt merged commit 222802f into adafruit:main Aug 26, 2026
43 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants