Skip to content

Started a ThreadX module port for the Cortex-R52, headers first - #639

Draft
fdesbiens wants to merge 33 commits into
eclipse-threadx:devfrom
fdesbiens:feature/r52-module-port-foundation
Draft

Started a ThreadX module port for the Cortex-R52, headers first#639
fdesbiens wants to merge 33 commits into
eclipse-threadx:devfrom
fdesbiens:feature/r52-module-port-foundation

Conversation

@fdesbiens

Copy link
Copy Markdown
Contributor

Draft: foundation only. The two headers a module port needs, plus the design decisions the rest will rest on. Opened early so the choices below can be argued with before sources are built on them.

No GNU module port exists for any R-profile core, so there is no close template.

Seeded from cortex_m33, not cortex_r4

This is the important choice. cortex_r4 is the other R-profile port, but it is PMSAv7: power-of-two region sizes, 541 lines of register setup mostly spent juggling them, plus an alignment-adjustment pass to match.

PMSAv8-R uses base/limit pairs with a 64-byte granule — the Armv8-M model. So the M33 port's structure transfers and that whole class of complexity never arises. The attribute encodings are bit-identical: shareability PRBAR[4:3], access permission PRBAR[2:1], execute-never PRBAR[0], attribute index PRLAR[3:1], region enable PRLAR[0].

What differs from the M33, and is handled

Region budget. MPUIR on the S32Z280 reports 20 EL1 regions where an M33 has 8 — which changes the design, not just the numbers. On an M33 the kernel and a module compete for the same eight. Here the kernel keeps its own and the manager owns a separate block from TXM_MODULE_MPU_FIRST_REGION upward, so a module switch reprograms only the module's regions and never rebuilds the kernel's.

Granule: 64 bytes, not 32. Silent when wrong — the low bits of PRBAR/PRLAR hold attributes, so an under-aligned base doesn't fault, it changes the region's shareability and permissions.

Fault reporting. Armv8-M uses SHCSR/CFSR/MMFAR/BFAR. This core splits by access type, so the fault info carries DFSR/DFAR for data aborts and IFSR/IFAR for prefetch aborts. Both are needed: a module can fault by writing outside its data region or by branching outside its code region. SPSR replaces xPSR, because the faulting mode is what distinguishes module-in-user-mode from kernel.

No secure-stack extension. That is the Armv8-M security extension; privilege here is EL1 against EL0 with no secure world to allocate a second stack in.

A bug worth flagging

tx_thread_vfp_enable is kept at the front of TX_THREAD_EXTENSION_2, ahead of the module fields. The base port holds the floating-point lazy-enable flag there. Dropping it while adding module fields — which the first draft of this did — would leave VFP threads with nowhere to record that they have used the unit, surfacing as corrupted floating-point state inside a module a long way from its cause.

Design input from #637

A region switch on this part costs 562–604 cycles against a ~1400-cycle context switch, and most of that is the dsb/isb pair rather than the register writes. So a module switch programming several regions should issue one barrier pair at the end, not one per region. The header says so where the register info structure is defined, so the manager isn't written around the wrong assumption.

Still to come

Module manager sources: its own copies of the port assembly so the region switch happens in the scheduler, the PMSAv8-R region setup, a memory fault handler, user-mode entry, the port dispatch, and a sample module with its build.

The base port under ports/cortex_r52/ is untouched by this PR.

No GNU module port exists for any R-profile core, so there is no close template
to copy. This adds the two headers a module port needs and records the design
decisions the rest of it will rest on.

Seeded from cortex_m33 rather than cortex_r4, which is the important choice
here. The R4 port is the other R-profile one, but it is PMSAv7: power-of-two
region sizes, and 541 lines of register setup mostly spent juggling them plus an
alignment adjustment pass to match. PMSAv8-R uses base and limit pairs with a
64-byte granule, which is the Armv8-M model, so the M33 port's structure
transfers and that whole class of complexity does not arise. The attribute
encodings are bit-identical: shareability in PRBAR[4:3], access permission in
PRBAR[2:1], execute-never in PRBAR[0], attribute index in PRLAR[3:1], region
enable in PRLAR[0].

What differs from the M33, and is handled:

Region budget. MPUIR on the S32Z280 reports 20 EL1 regions where an M33 has 8,
and that changes the design rather than the numbers. On an M33 the kernel and a
module compete for the same eight; here the kernel keeps its own and the manager
owns a separate block from TXM_MODULE_MPU_FIRST_REGION upward, so a module
switch reprograms only the module's regions and never rebuilds the kernel's.

Granule. 64 bytes, not 32. This one is silent when wrong: the low bits of PRBAR
and PRLAR hold attributes, so an under-aligned base does not fault, it changes
the region's shareability and permissions instead.

Fault reporting. Armv8-M reports a memory fault through SHCSR, CFSR, MMFAR and
BFAR. This core splits it by access type, so the fault info structure carries
DFSR and DFAR for data aborts and IFSR and IFAR for prefetch aborts. Both pairs
are needed: a module can fault either by writing outside its data region or by
branching outside its code region. SPSR replaces xPSR, because the mode the
faulting code was in is what says whether it was the module in user mode or the
kernel.

No secure-stack extension. Those calls are the Armv8-M security extension, which
this core does not have; privilege here is EL1 against EL0 and there is no secure
world to allocate a second stack in.

tx_thread_vfp_enable is kept at the front of TX_THREAD_EXTENSION_2, ahead of the
module fields. The base port holds the floating-point lazy-enable flag there, and
dropping it while adding the module fields would leave VFP threads with nowhere
to record that they have used the unit -- which would surface as corrupted
floating-point state inside a module, a long way from its cause.

Also recorded, from the measurement in eclipse-threadx#637: a region switch on this part costs
562 to 604 cycles against a context switch of about 1400, and most of that is
the dsb and isb rather than the register writes. A module switch programming
several regions should therefore issue one barrier pair at the end rather than
one per region, and the header says so where the register info structure is
defined.

Still to come: the module manager sources. Its own copies of the port assembly so
the region switch happens in the scheduler, the PMSAv8-R region setup, a memory
fault handler, user-mode entry, the port dispatch, and a sample module with its
build.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two of the module manager's port-specific sources, both derived from the
Armv8-M versions for the reasons given in the previous commit.

The region setup needed two substantive changes and they are the ones worth
reviewing.

The address mask is 64-byte where Armv8-M is 32-byte, and it is now a named
constant, TXM_MODULE_MPU_ADDRESS_MASK, rather than a literal repeated six times.
PRBAR and PRLAR hold attributes in the bits below the granule, so masking to the
wrong boundary does not fault: it writes address bits into the shareability and
permission fields, and the region comes up with attributes nobody asked for.
That failure is silent, which is why the constant is named and the reason is
recorded where the code uses it.

Regions are non-shareable where the M33 port marks module memory
inner-shareable. The RTU here is a single Cortex-R52 and every region in the
board support package's map is non-shareable, so matching it keeps one memory
model across the kernel and its modules. A multi-core RTU configuration would
need to revisit this, and much else besides.

The fault handler needed almost nothing, which was worth finding out. It is
architecture-neutral: it terminates the faulting thread and calls the
notification callback. The register capture happens before it, in the abort
vector, and that part is still to be written -- DFSR, DFAR, IFSR and IFAR have
to be read before anything else can fault and overwrite them, and on this core
the abort is taken in Abort mode with its own banked lr and sp, so the capture
belongs there rather than in C. The file now says so, so the next person does
not look for it here.

Both files compile clean against the module headers, which also confirms the
header work: the new mask and the DFSR, DFAR, IFSR and IFAR fault fields all
resolve.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Three more port-specific sources: alignment adjustment, the fault notification
API, and shared external memory enable. All five C files now compile clean
against the module headers.

alignment_adjust is worth a note because of how little it does. It rounds code
and data size up to the 64-byte granule and sets both alignments to it, and that
is the whole function. The Cortex-R4 port's equivalent runs to 183 lines, because
PMSAv7 regions must be a power of two in size and aligned to their own size, so a
module's code and data have to be grown and repositioned to fit the nearest legal
region. Base and limit pairs have no such constraint: any 64-byte-aligned extent
is a legal region. The comment says so, because the brevity looks like something
missing otherwise.

What remains is the assembly and an example, and that is the larger half:

  - its own copies of the port assembly -- schedule, context save and restore,
    stack build, system return -- so the region switch happens in the scheduler
    rather than being called by the thread as in eclipse-threadx#637
  - the module thread stack build and user-mode entry
  - the abort vector's register capture, which has to read DFSR, DFAR, IFSR and
    IFAR before anything else can fault over them, in Abort mode with its own
    banked lr and sp
  - the port dispatch
  - a sample module and its build

None of that is a copy of the M33 equivalents. Armv8-M scheduling is built on
exception return with PSP and MSP and a 697-line scheduler; this core switches
ARM modes between EL1 and EL0, and its base scheduler is far smaller. The
assembly starts from the working Cortex-R52 port rather than from the M33 module
port, with the region load added.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…the module block

The Cortex-R52 TRM provides direct access to PRBAR0 through PRBAR15 and PRLAR0
through PRLAR15 (3.3.85, 3.3.86), encoded CRn c6, CRm c8 + n/2, with opc2 0 and 1
for an even region and 4 and 5 for an odd one. PRBAR and PRLAR without a number
are the indirect view that PRSELR selects, and PRSELR needs an ISB before the
region registers can be written -- once per region.

This was found while sizing the module manager's region switch and it changes the
design, so it is applied in both places.

Measured on the S32Z280-594EVB, same region and same isolation result either way:

    through PRSELR      542 to 604 cycles
    direct              434 to 470 cycles

The isolation test still passes, which is what confirms the encoding reaches the
region it is supposed to: each thread reaches its own window and faults on the
other. A wrong CRm would have programmed some other region and the fault would
have gone away.

What remains of the 434 is mostly the closing dsb and isb, the table search and
two counter reads rather than the two register writes. So a block of regions
written directly with one barrier pair at the end costs far less than the
per-region figure implies, where the PRSELR route pays an ISB every time. That is
the difference between a module switch that is affordable and one that is not:
eight regions through PRSELR would have added several thousand cycles to a
context switch of about 1400.

The module port's region block therefore moves from 9 through 16 to 8 through 15,
so all eight entries sit inside the directly addressable range. The board support
package's map occupies 0 through 7, so nothing has to move to make room. A board
needing more than eight kernel regions must either shrink the module block or
accept PRSELR for the overflow, and the header says so.

thread_mpu.c uses the direct path as well, which makes the per-thread window from
eclipse-threadx#637 about 22% cheaper. Its PRSELR accessors are kept and marked unused, because
regions above 15 have no direct encoding and a later board may need them.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…at build time

The scheduler is seeded from the working Cortex-R52 port rather than from the
Armv8-M module port, because the two schedule differently: Armv8-M returns
through an exception with PSP and MSP and needs 697 lines for it, where this core
switches ARM modes and manages in 240.

The region switch goes in after the current thread pointer is set and the stack
is switched, and it is unrolled rather than looped. CRm and opc2 are encoded in
the instruction rather than held in a register, so a loop is not possible for
direct region access -- and unrolled is what is wanted anyway: sixteen writes and
one barrier pair, where PRSELR would need an ISB per region.

A thread that is not a module thread, or a module without protection configured,
takes the other path and all eight regions are closed. That is not tidiness. The
alternative is that whatever module ran last silently lends its memory to
whatever runs next, and per-thread protection quietly becomes no protection at
all. Only PRLAR is written there, since clearing the enable bit is what closes a
region.

The offsets the assembly hard-codes are now verified at compile time in
txm_module_manager_offset_check.c, which emits no code. Hard-coded offsets in
port assembly are a standing hazard -- issue eclipse-threadx#577 proposes exactly this check for
every port -- and this port is a concrete case for it: the module instance
pointer is at 0x94 here against 0x90 on Armv8-M, because the Cortex-R52 port
keeps tx_thread_vfp_enable ahead of the module fields in TX_THREAD_EXTENSION_2.
Copying the Armv8-M number would have built cleanly and misbehaved on the board.

The check was tested in both directions. It passes against the real structures,
and with the offset changed to the Armv8-M value it fails the build with the
message naming what would go wrong. An assertion that only ever passes proves
very little; one that provably rejects the mistake it exists to catch is worth
having. It also guards the region-table shape the unrolled sequence depends on --
eight entries of two words each -- and that the block stays inside the directly
addressable range, since regions above 15 have no PRBARn encoding.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Completes the fault path. The C handler was already in place and is
architecture-neutral -- it terminates the offending thread and notifies the
application -- and this is the part that has to be assembly: the fault registers
hold only the most recent fault, so anything that runs first and faults destroys
the evidence, and the abort arrives in Abort mode with its own banked sp and lr
so C cannot be entered until a stack is known good.

Two entry points, because this core reports the two kinds of violation
differently. A module writing outside its data region raises a data abort and
reports through DFSR and DFAR; a module branching outside its code region raises
a prefetch abort and reports through IFSR and IFAR. Both pairs are recorded from
either entry, so a reader can tell which one is meaningful.

Three bugs in the first version of this, all caught by reading it rather than by
its assembling cleanly, which it did throughout:

The mode switch used ORR alone to select System mode. That happens to work from
Abort mode, whose encoding is 0x17, and would silently select the wrong mode from
anywhere else. It clears the field first now.

The recovery of the pushed registers built its destination address in the same
registers it had just loaded the values into, so the first store wrote the
address instead of the module's r0 and the rest were shifted. It also loaded four
words where five had been pushed. Copied one at a time now, which is duller and
correct.

The unexpected-return path restored and returned to the faulting instruction,
which would fault again immediately and for ever. It stops instead, where a
debugger can see why. The handler is not expected to return at all -- it
terminates the thread and the scheduler runs something else.

One limitation is documented rather than left to be discovered: the captured sp
and lr are read in System mode, which shares User mode's banked copies. That is
right for a module fault, which happens in User mode, and wrong for a fault taken
in a privileged mode, where System's registers are not that mode's. SPSR records
which happened, so a reader can tell when those two fields do not apply.

The offsets this file hard-codes are checked against offsetof alongside the
scheduler's, including that r4 through r11 are four words past r0 and that r12 is
where the block store expects, since the capture writes them as blocks.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…ernel

A module runs in User mode and cannot execute kernel code or touch kernel
memory. When it calls a ThreadX service the call arrives here: SVC 1 raises
privilege, the dispatch function performs the service, SVC 2 drops back to User
mode, and the module continues. Everything the module is allowed to ask for is
decided inside _txm_module_manager_kernel_dispatch, in memory the module cannot
reach.

Derived from the Cortex-R4 module port rather than the Armv8-M one, which is the
right parent for this file specifically. Privilege on Armv8-M is a bit in CONTROL
and the transition rides an exception return; in AArch32 it is the processor mode,
and SVC is how a module asks to change it. The R4 sequence transfers directly
even though its MPU does not.

One improvement over the R4 version, and it is about isolation rather than
memory. That port aligns this function to 4 KB because PMSAv7 regions must be a
power of two in size and aligned to their own size, so the smallest region that
covers the entry without covering its neighbours is a page -- and whatever else
shares that page is then also executable by the module. A base and limit pair has
no such constraint. The function is 64-byte aligned, and the measured privileged
surface a module can execute is 24 bytes.

The kernel entry region's limit is taken from a symbol at the end of the function
rather than from its base. One granule covers 24 bytes so the base would work
today, but if the entry ever grows past 64 bytes, sizing from the base would
leave its tail outside the region and a module would fault on a call it is
entitled to make.

SVC 2 sits between the dispatch and every return path rather than on one of them.
Skipping it would return the module to its own code still privileged, which is
the entire protection gone, so it is unconditional.

The supervisor call vector that recognises SVC 1 and SVC 2 is the next piece and
does not exist yet.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
SVC 1 raises a module thread out of User mode and onto its kernel stack, SVC 2
puts it back. This is the mechanism the rest of the port exists to protect.

Two properties make it a boundary rather than a formality, and both are copied in
substance from the Cortex-R4 module port because both are load-bearing.

Only the two exact instructions inside _txm_module_manager_user_mode_entry may
make these calls. lr points one instruction past the call, so the handler compares
lr minus four against the known call site and stops if it does not match. Without
that check a module could execute SVC 1 anywhere in its own code and come back
privileged, which is every protection in this port gone at once.

The stacks are swapped, not shared. A module's stack lives in memory the module
can write, so the kernel must not run on it -- a module could otherwise corrupt
kernel state by scribbling on what it believes is its own stack. SVC 1 switches
to a kernel stack outside the module's regions and SVC 2 switches back. The swap
happens in System mode because System shares User's banked sp.

ThreadX's stack bounds are repointed at whichever stack is in use. Leaving them
alone would have a stack check measure the kernel's sp against the module's
bounds and report an overflow that has not happened.

Unrecognised SVC numbers stop rather than return. Returning would resume the
caller as though the call had succeeded, with the privilege state undefined. This
core's base port does not use SVC at all -- its vector treats one as a fault -- so
there is no third caller to accommodate.

The offsets are not the R4 port's. Every field after tx_thread_vfp_enable moves by
a word in this port, so the kernel stack fields sit at 0xA8, 0xAC and 0xB0 where
R4 has 0xA4, 0xA8 and 0xAC. All twelve are asserted against offsetof, with the
two that matter most spelled out in their messages: a wrong user-mode flag offset
would leave a thread believing it is privileged when it is not, and a wrong
kernel stack offset would put the kernel on memory the module can write.

The file declares .syntax unified rather than relying on the default, which
rejected the unified conditional-load form. A file that hand-writes exception
entry is the last place to leave the assembler's dialect to chance.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…starts in

A kernel thread starts in SVC mode. A module thread starts in User mode when it
asked for protection and System mode when it did not, and that choice is made
here, in the initial CPSR of its first stack frame. Everything else about the
frame is identical to a kernel thread's.

Derived from this port's own tx_thread_stack_build rather than from another
architecture's module port, because everything except the mode selection is
already correct here and the frame layout is the port's own. The kernel stack
build is copied across unchanged, since the module port needs its own copy of the
port assembly.

The user-mode flag is read from tx_thread_module_user_mode at 0xA0. The Cortex-R4
module port reads 0x9C, which in this port is
tx_thread_module_current_user_mode -- a different field, holding whether the
thread is privileged right now rather than whether it should start unprivileged.
Copying that offset would have built cleanly and started module threads in the
wrong mode, which is protection absent from the first instruction while looking
present everywhere else.

Two assertions guard it. One pins the offset. The other checks that the requested
and current user-mode fields have not collapsed onto the same offset, because if
they ever did, the stack build and the SVC handler would silently be reading each
other's field and the failure would look like intermittent privilege rather than a
layout change.

The Thumb bit is taken from the requested entry point, so a module built for Thumb
starts in Thumb state rather than executing its first instruction as ARM.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
In a module port no thread runs in SVC mode. SVC is reserved for the supervisor
call handler, which is how a module reaches the kernel, and a thread sitting in
SVC mode would be using that handler's banked stack pointer as its own. So kernel
threads start in SYS mode and module threads in User mode, and the two share a
banked sp -- which is what lets the interrupt paths reach a thread's stack without
caring whose thread it is.

Three files, and the first was a bug I had already committed.

tx_thread_stack_build was copied from the base port unchanged, which left kernel
threads starting in SVC mode. The restore path would then have looked for their
context on the SYS stack while it had been pushed onto the SVC one. One line, and
it is now SYS with the reasoning recorded next to it. Found by checking what the
Cortex-R4 module port does differently from its own base port rather than by
assuming the file needed no changes.

tx_thread_context_restore now enters SYS mode wherever it was entering SVC mode to
reach the interrupted thread's stack. Entering SVC there would push the
interrupted context onto the supervisor call handler's stack and the thread would
resume on a corrupted frame. Three switches changed; the IRQ-mode switch in the
middle is unrelated and stays.

tx_thread_context_save and tx_thread_system_return are copied unchanged, having no
mode assumptions at all. That was checked rather than assumed, and it matches what
the R4 module port does with the same two files.

One thing deliberately not copied. R4's module context restore replaces the branch
to the scheduler with an exception return that sets SPSR to SYS mode and clears
the Thumb bit. This port reaches the scheduler already in SYS mode, privileged and
on the thread's stack, so a plain branch appears sufficient and is what the base
port does. R4 may be solving something specific to its own structure. Copying a
sequence I cannot yet explain seemed worse than the version I can reason about,
and this note is here so a reviewer can disagree -- none of it has run on hardware
yet, and the interrupt paths are where a mistake will show up first.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two small pieces, both decided by finding out what this port does not need rather
than by adding something.

The port dispatch is now undefined. It exists so a port can service kernel
requests specific to it, and the only ones the Armv8-M port dispatches are its
secure-stack allocate and free, which belong to the security extension this core
does not have. The header inherited the define from that port, which would have
had the module manager call a _txm_module_manager_port_dispatch that does not
exist. The Cortex-R4 module port leaves it undefined for the same reason, and the
comment says so where the define used to be.

module_lib gets the thread shell entry and nothing else. The Cortex-R4 module
library also carries txm_module_initialize.S, and that file is not portable here:
it references __scatterload and __rt_entry, which are ARM Compiler runtime
symbols. The GNU Armv8-M module library has no initialize file at all, which is
the better guide for a GNU port -- so the shell entry is taken from there, and it
turned out architecture-neutral apart from its identity line.

That is the pattern for most of this port so far: the Armv8-M port is the right
parent for anything touching the MPU or the toolchain, and the Cortex-R4 port for
anything touching processor modes and privilege. Taking either one wholesale
would have been wrong.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The header the module manager reads before it will load a module: identity,
version, properties, and the entry points it needs, all as offsets from the start
of the preamble rather than addresses. A module is position independent because the
manager decides where it lands, so it cannot know its own addresses at link time.

Properties are 0x02000003 -- the GNU compiler field, plus user mode and memory
protection. Those last two together are the point of this port; a module built
without them would load and run privileged, which the manager permits and this
example does not want.

Two departures from the Cortex-R4 preamble this is modelled on.

Code and data sizes come from the linker script rather than being written in as
literals. R4's preamble carries the numbers 9000 and 11000, and a module that
grows past them gets less memory mapped than it uses -- a fault in a module that
did nothing wrong, whose cause is a constant in a file nobody thought to change.
The comment says so, along with the fact that the right response to such a fault
is not to inflate the numbers.

The preamble declares its own length in its fourth word and the manager believes
it, so the assembler now checks that the declaration matches the file. Tested both
ways: it passes as written, and with one extra word added it fails the build
naming the problem. A size field that agrees with reality only because someone
counted once is a latent bug.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The Armv8-M sample module creates eight threads, a queue, a semaphore, a mutex,
event flags and two pools. That demonstrates the API works. It does not
demonstrate the boundary works, and the boundary is the only thing this port
adds -- that module would pass identically with the MPU switched off.

This one does three things and is killed by the third:

  1. Writes and reads its own data, which must succeed.
  2. Calls tx_thread_sleep, which must succeed. A sleep rather than something
     cheaper because it yields: the scheduler then runs a module thread and
     reloads its regions on the way back, so the call exercises the region
     switch as well as the supervisor call boundary.
  3. Reads an address the manager gives it, which must fault and must terminate
     the thread.

Step 3 is the whole point. Steps 1 and 2 passing without step 3 faulting means
the module is running unprotected, and there is a fourth flag set only on that
path so the manager can report it as a failure rather than reading a missing flag
as success.

The forbidden address is passed in by the manager rather than hard-coded, so the
module carries no assumption about where the manager lives.

The module cannot print. The console belongs to the board support package,
outside every region a module owns, so reaching for it would fault exactly as
step 3 does. Progress is recorded in the module's own data for the manager to
read back afterwards, which is the only reporting channel a protected module has.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Loads the sample module, lets it reach outside its own memory, and reports what
the hardware did about it.

The fault is the result, not an error path. A module that starts and runs proves
the loader works; a module stopped by the memory protection unit when it reaches
outside its own memory proves the port works. So the absence of a fault is the
failure, and the reporting says so in those words rather than printing a count and
leaving the reader to judge.

The report checks more than that a fault happened. SPSR's mode field says who
faulted, and only User mode counts: a fault taken from privileged code would mean
something else went wrong and the test has not passed. There are therefore two
distinct failure messages, one for no fault at all and one for a fault from the
wrong mode, because those have completely different causes.

The fault notification records and does not print. It runs in the fault path, and
this board's console is a polled driver that spins waiting for each byte to go
out -- which is a poor thing to do with a fault in progress and a thread mid
termination. A separate low-priority thread prints afterwards, and waits a bounded
time for the fault rather than for ever, since a fault that never arrives is the
result to report.

The fault information structure needs an extern declaration in the application.
The module manager expands it into the port's fault handler through a macro rather
than declaring it in a header, so anything that wants to read it has to say so
itself. Worth a comment, because the error a reader gets otherwise names a
similar-looking function instead.

Loading is in place, from where the linker puts the image, so nothing is copied
and no filesystem or download path is needed. That changes where the module's code
lives, not whether it is protected: the manager still maps it with its own MPU
regions, which is the part under test.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…lt for it

Six example images share this entry.S, so the wiring is behind
TXM_MODULE_MANAGER and the other five see no change at all.

SVC becomes the module boundary. It was a fault, because nothing in this port
used it; under the guard it branches to __tx_module_svc_interrupt instead.

The abort vectors are the interesting half, because they cannot simply be
redirected. The boot probes provoke deliberate faults and rely on the existing
recoverable path -- that is what X2 and X4 test -- while a module's violation has
to reach the module manager. So both handlers now test who faulted first: SPSR's
mode field being User means a module reaching outside its regions, and anything
else continues down the path that was already there.

r0 is pushed and popped around that test rather than simply used, and the reason is
worth recording. The existing handler clobbers r0 through r2 immediately without
saving them, which is tolerable for a probe that provokes its own fault and does
not care what it resumes with. The module fault capture, though, records the
faulting registers so an application can see what the module was doing, and it
would have recorded ours. LDM does not affect the flags, so the comparison still
holds after the pop.

That existing clobber is a fragility this commit does not fix but should not leave
unmentioned: any code recovering from a deliberate fault on this board resumes with
r0, r1 and r2 destroyed. The boot probes survive it because their next actions do
not use those registers, and the per-thread isolation test in eclipse-threadx#637 survives it for
the same reason rather than by design.

Verified both ways. All three existing targets build clean, entry.S assembles with
the guard on, and the boot image still passes six of six probes on the board --
including X2 and X4, which are the two that exercise the abort handlers this commit
touches.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…ht and be wrong

The module manager image now builds: kernel, manager, and a sample module placed in
a 64 KB carve-out at the top of DRAM1 that no kernel MPU region covers.

The layout is the one we chose deliberately. DRAM1 rather than DRAM2 because it
runs at full core speed, and because code and data can be contiguous -- putting
module code in the code RAM region instead looks obvious and does not work, since
that region is read-only and a module's data would have to live somewhere else.
Region 1 stops at 0x317EFFFF and the linker's DATA region shrinks to 448 KB to
match, of which the kernel uses about 34 KB.

Five bugs on the way here, and every one of them produced something that built:

The module port cannot share the base port's ThreadX library. Its tx_port.h adds
the owning module instance to four object types and the module fields to TX_THREAD.
Measured: TX_QUEUE 68 bytes against 60, TX_SEMAPHORE 40 against 32, TX_THREAD 236
against 184. A kernel compiled one way and a manager the other disagree about every
object and link without a word, because C linking does not compare layouts. The
failure would have been memory corruption at run time, and the first hardware runs
would have been spent hunting it in the port. There is now a threadx_module library
built from the same sources with the module headers.

The object extensions were missing from the module tx_port.h, because it was derived
from the base port rather than from another module port. That is what surfaced the
library problem.

Linker script object patterns must carry the source suffix and match the object
extension: CMake compiles foo.c to foo.c.obj here. Getting it wrong produces an
empty section at whatever address the location counter held -- an image that builds,
links, reports a plausible size and contains no module.

ALIGN belongs after the colon, not in the address position. In the address position
it places the section at the aligned current counter and then the region assignment
fails, which at least says so.

Section placement is first match, and the kernel's .text claims *(.text*). With the
module block after .text, the module's code was absorbed into kernel .text while
only its data landed in the carve-out. The image built, linked, and reported 320
bytes of module -- outside the memory the manager would protect. The block now sits
before .text and the comment says why.

_gcc_setup is a stub. On the Armv8-M module port it copies and rewrites the global
offset table so a position-independent module's data references reach the memory the
manager allocated. This port links modules absolutely for now, so there is nothing
to relocate, and the stub is where that work lands when modules become properly
relocatable. That is the next step and the reason this arrangement is a stepping
stone rather than the destination.

Nothing here has run yet.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The module's sources were compiled into the manager's image and gathered
into the module area by object name.  That cannot work, and the reason is
not obvious: the module-side library defines shims named after the same
ThreadX entry points the kernel defines, and in a single link the shims
win, because they arrive as objects and the kernel arrives as a static
library.  Nothing warns.  The manager's own tx_thread_create resolved to
the module's copy at 0x317F1700, inside the module area that no kernel
MPU region covers, so tx_application_define called into unmapped memory
and the core took a silent prefetch abort before the first thread ran.

The module is now a separate link unit -- link_demo_module.lds and the
s32z280_demo_module.elf target -- objcopied to a raw binary and carried
into the manager image as bytes by module_blob.S.  None of the module's
symbols enter the manager's link, so the collision cannot recur rather
than being avoided by care.

Keeping the module out of the link also retires a class of quiet failure
that gathering by object name had.  Matching input sections by object
file name fails open: CMake compiles foo.c to foo.c.obj here and foo.c.o
elsewhere, and a pattern that matches nothing yields an empty section at
whatever address the location counter held -- an image that builds,
links, reports a plausible size and contains no module.  That happened
twice, once with the suffix missing and once with .o where this build
produces .obj.

.incbin searches the assembler's include path rather than the source
tree, and demo_module.bin is generated, hence the -Wa,-I on module_blob.S
pointing at the build directory.

Also ignored build_mod/ and build_fvp/, which are the directories the
documented build commands create.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
No kernel MPU region covers the module area, and that absence is what
stops every thread from reaching a module's memory.  But the manager has
to read a module's preamble and write its data to load it at all, so
privileged code needs some way in.  Without one the load faulted on its
first read of the image, and because a privileged data abort ends in a
handler that only spins, that presented as the load hanging.

The way in is region 16, above both the kernel's boot regions 0-7 and the
eight the manager hands to a module, so neither the boot table nor the
scheduler's per-thread region load can disturb it.  MPUIR reports 20 EL1
regions on this part.  EL1 read/write with no EL0 access, so the manager
can load through it and a module cannot use it to reach anything.

It cannot simply be left enabled.  PMSAv8-R has no region priority: two
enabled regions matching one address is CONSTRAINED UNPREDICTABLE and on
this part it aborts, and the window covers exactly the memory a module's
own regions cover.  So the scheduler owns it -- enabling it for every
thread that owns no module and disabling it for every thread that does.
The two are mutually exclusive by ownership rather than by anyone
remembering to bracket a call, which is the property worth having.

The register words are computed once by mpu_module_window_init and read
from memory by the scheduler, so no register layout lives in assembly.
Field positions confirmed against the Cortex-R52 TRM Table 3-124:
BASE[31:6], RES0[5], SH[4:3], AP[2:1], XN[0], and AP 0b00 is EL1
read/write with no EL0 access.  Region 16 is past the direct PRBARn
encodings, so it is reached through PRSELR with an ISB, which is why the
window is one region and not the whole block.

The module pool moves with it, out of .bss and into the module area.  The
manager carves a module's data from that pool and hands it over as an MPU
region; in .bss it would sit inside the kernel's data region and the two
would overlap, which is the same rule again.

The region number is spelled twice, in mpu.h and in the scheduler's MCR,
because the assembler cannot include the board header.  Two static
asserts hold them together: one that the numbers agree, and one that the
window sits clear of the module block, since if they ever named the same
region the scheduler would enable one believing it was the other.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Every thread in this port runs on the User-banked stack pointer: kernel
threads in System mode, module threads in User mode, and those two modes
share SP_usr and LR_usr.  That shared bank is what lets one save and
restore path reach any thread's stack without first asking who owns it,
and it is the reason this port can dispatch a module thread and a kernel
thread through the same code.

It also means the scheduler must be in System mode before it writes sp.
It already is when reached from the context restore, the system return
and the idle path -- but it is in SVC on the very first dispatch out of
tx_kernel_enter.  Writing sp there sets SP_svc, the exception return then
switches to System mode, and the thread starts life on whatever SP_usr
happened to hold.  Nothing faults at the point of the mistake.  So the
mode is now forced rather than assumed, before any write to sp and with
nothing in between that touches the stack, since sp is undefined for the
mode until it is set.

The return needs the opposite treatment.  System mode has no SPSR, so the
old single LDMIA with the ^ suffix cannot work there: the thread's CPSR
has nowhere to sit.  The frame is now popped in System mode, where sp and
lr belong to the thread, and only the final exception return borrows SVC
to hold the SPSR and the resume address.  SVC banks its own sp and lr, so
switching back does not disturb what was just restored.

The frame position is captured into r1 before the mode change rather than
recomputed, because sp lands on the saved r0 whether or not VFP state was
in the way, and r0-r7 are common to every mode so r1 survives the switch.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The module's shell entry takes two arguments, the thread and the thread
entry information, and everything the module needs to start is behind the
second one: its own entry point, its parameter, its data and code bases
and the callback queue.  ThreadX's thread shell passes only one argument,
so the second can only arrive as a seeded register.

The base port's stack build leaves both at zero, which is right for a
kernel thread and wrong here: the shell entry dereferences the entry info
on its first instruction, so a module faulted before running a line of
its own code.

The entry info pointer is read from the control block before the frame is
built, because _txm_module_manager_thread_create parks it in the stack
pointer field for exactly this purpose and the initial stack pointer is
written over that same field further down.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
txm_module_manager_user_mode_entry is the only way a module reaches the
kernel, so a module needs an MPU region covering those instructions.  The
kernel's code region grants no EL0 access, and PMSAv8-R has no region
priority, so the module's region must not overlap it -- two enabled
regions matching one address is CONSTRAINED UNPREDICTABLE and on this part
it aborts.  With the function still inside .text the first memory access
after a module's regions were loaded took a data abort in the scheduler,
which is a confusing place to find the consequence of a linker decision.

It now lives in its own section, placed after __code_end__ and therefore
outside the region the kernel's code table describes.  Alone there on
purpose: whatever shared the section would become executable by every
module.

The two regions end up exactly adjacent rather than merely far apart, and
that holds for any build.  The kernel's region limit is masked down to a
granule boundary, PRLAR carrying LIMIT[31:6] with the low bits inclusive,
while ALIGN(64) pushes this section up to the next one -- so for
__code_end__ = 64k + r the kernel's inclusive limit is 64k+63 for every
r in 1..63 and 64k-1 for r = 0, which is one below this section's base in
both cases.  No gap to waste and no overlap to fault, without either side
knowing the other's address.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two ways the sample could not have worked, both of which look like the
port being broken rather than the sample being wrong.

Manager services must be called from a thread.  Loading a module takes the
manager's mutex, and a service that can block cannot be called before the
scheduler runs: driven from tx_application_define the sample printed its
way as far as the load and stopped there for ever.  Everything now happens
in manager_entry, and tx_application_define creates that thread and
nothing else.  The Armv8-M sample is arranged the same way for the same
reason.

txm_module_manager_object_pool_create is not optional for a User-mode
module.  The privileged half of each system call needs a kernel stack
allocated from that pool, and txm_module_manager_initialize deliberately
leaves it uncreated, so its absence surfaces as TX_NOT_AVAILABLE from deep
inside thread create -- which is not a recognisable way to be told that a
pool is missing.  It is a separate allocation from the module pool, and
unlike that one it can stay in .bss: the manager reads it, a module never
does.

The thread create status is now reported rather than discarded.  A thread
that was never created and a thread that was created but never scheduled
produce the same silence on the console, and telling those two apart is
most of the work when the port itself is what is under test.  The
load and start status codes and the M0-M3 markers are there for the same
reason: the expected end state of this sample is a fault, so the console
has to distinguish the fault that proves isolation from a fault that
means the sample never got started.

Also dropped an mpu.h include this file does not use.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
@fdesbiens
fdesbiens force-pushed the feature/r52-module-port-foundation branch from db26e62 to 4d201ae Compare August 19, 2026 12:40
Every entry point the manager derived from this preamble was wrong by the
byte offset of the word that held it, and the module was entered that far
into its shell entry -- past the prologue, so its arguments were never
saved and its frame pointer never set up.

The manager computes each entry as code_start + field + adjust, where the
adjust comes from TXM_MODULE_GNU_SHELL_ADJUST 24, START 28, STOP 32 and
CALLBACK 44.  Those four numbers are exactly the byte offsets of the four
words in the preamble, because the convention is that each field holds
symbol - <that field's own address> and the manager adds the offset back
to recover the offset from the module base.  Storing them relative to
__txm_module_preamble counts the offset twice.  cortex_m33, which this
port was seeded from, writes "symbol - . - 0"; all four GNU ports carry
the same adjust constants, so the constants were right and this file was
wrong.

Found on silicon, not by reading.  The module thread ran once and was
terminated with DFAR 0x1C, which made no sense until the faulting
instruction turned out to be "ldr r3, [r3, #0x1c]" at 0x317F020C -- 0x18
into a shell entry that starts at 0x317F01F4.  The captured registers were
the values the stack build seeds rather than anything the module had
computed (r9, r11, r12 zero, r10 the stack start), which is what a thread
looks like when it enters after its own prologue: r3 was still the seeded
zero, so the load went to 0x1C.

After this change the three real entry points resolve to 0x317F01F4,
0x317F0080 and 0x317F030C, matching the module ELF's symbol table, and the
stop field stays a literal 0 so the manager's "is there a stop function"
test still reads false.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…tion

Running this example on the board was a hand-assembled sequence of GTA,
the Windows CCS, an EDBGREQ clear and a UART capture, which is too much to
retype correctly and too easy to get subtly wrong -- a missed console
capture turns a diagnosable failure into "it printed nothing".

run_module_demo.sh starts the capture before the image is loaded, derives
module_progress's address from the module's own ELF, and prints the console
next to the register report.  run_module_demo.gdb loads, clears the debug
request that pins the core, enters at the T32 _start and breaks after the
manager has printed its verdict.  There is no bsp_done in this image to
break on the way the boot example has, so it breaks on the line the report
falls into, which is reached exactly once.  hbreak rather than break: the
MPU maps the code region read-only.

diagnose_module.gdb answers the questions that come up when the verdict is
not the expected one, and each was needed for a real failure: the thread
run counts separate "the module thread never ran" from "it ran and died",
the raw fault info shows whether an abort was captured even when the
sample's counter says none, and reading the notify hook separates a hook
that was never registered from a handler that never reached the call.

Reading module_progress at its linked address is only sound while the
module is linked absolutely and loaded in place; the script says so where
it does it, because relocation changes that.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The module was linked absolutely and loaded in place, which meant it ran
only at the address it was built for.  Worse, it could not run correctly
at all: the manager allocates a module's data area from its byte pool and
memsets it, and never copies .data anywhere, so a module's initialised
globals were outside every region the module owned.  Silicon faulted on
the first write to one of them, at an address in the gap between the
granted code and the granted data.

So the module is now built position independent -- -fpic with
-msingle-pic-base, which reserves r9 as the base every data reference is
measured from -- and four things had to arrive together for that to work.

_gcc_setup stops being a BX lr.  It copies the global offset table out of
the image into the module's own data, rebasing each entry by whether it
points into the code segment or the data segment, then copies .data and
zeroes .bss.  It may not use a global itself, being the function that
makes globals usable, so every address it needs is a literal pool load of
a nominal address that it corrects by hand.

The module's link map grows a second segment.  Its two origins are
fictions the module never runs at: they exist so the rebase can tell a
code address from a data address with one unsigned compare, which is why
they are far apart, and why neither is zero -- a zero GOT entry means one
the linker never filled in, and rebasing it would turn a recognisable
null into a plausible pointer.  .got and .data have their addresses in
the data segment, where the module runs, and their load images back in
the code segment, inside the blob, where _gcc_setup can find them.

The manager seeds r9.  It was storing a literal 0 at frame offset 44,
and -msingle-pic-base is precisely the promise that the caller sets that
register up, so every module data reference was resolving to a small
absolute address.  The value is the data base from the thread entry info,
whose declaration in txm_module.h carries the comment "Don't move this,
referenced in stack build to setup module data base register".

And the module is built -mno-long-calls, which is ours rather than the
reference port's.  The project-wide flags carry -mlong-calls because the
manager's image spans TCM at 0x30000000 and code at 0x79900000, further
than a direct BL reaches.  But -mlong-calls under -fpic routes every call
through the GOT, and one of those calls is the shell entry's call to
_gcc_setup -- the function that fills the GOT in.  The module would have
loaded, entered its shell entry, read a zero out of the not-yet-written
GOT and branched to address 0.  A module needs no long calls: it is one
contiguous blob under two kilobytes and never calls out of itself, since
kernel services go through the dispatcher pointer in its entry info.

Verified on an S32Z280-594EVB.  The module now writes and reads its own
data, reaches the kernel through SVC and returns, and is stopped by the
MPU on the access it should not have -- DFSR 0x0000020C, a read
permission fault, DFAR 0x31780000, SPSR mode 0x10, and r9 holding the
data base the manager handed out.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
A module that runs correctly at the address it was linked for proves
nothing about relocation: the GOT rebase maps every address to itself, so
a rebase that did nothing would look identical to one that worked.  The
example therefore loads the same blob twice -- once where the linker put
it, once from a staging area reserved in the module area -- and compares
the two runs.

Pass 1 is left loaded while pass 2 loads, so pass 2's data is allocated
after pass 1's and the data base moves too.  Both halves of the rebase are
then exercised, not just the code half.

The verdict is readable from the console alone, with no debugger and
without the manager knowing a single module symbol:

  * the same instruction faults in both passes -- equal offsets from each
    pass's own code base, at two different absolute addresses
  * DFAR is the forbidden address in both.  The module obtained that value
    by reading one of its own initialised globals through the rebased GOT,
    so one register proves the GOT was rewritten and .data was copied
  * r9 at the fault is the data base the manager handed out
  * SPSR says User mode, so the boundary held

The sample now waits on the captured fault info rather than on the notify
callback, and this is a correction rather than a refinement.  The handler
terminates the offending thread before calling the notify hook, and
_tx_thread_terminate does not return when the thread being terminated is
the running one -- which it always is here.  So the hook, though correctly
registered, never runs, fault_count stays 0, and the previous version
waited out its timeout and reported that no fault had occurred for a
module that had been stopped exactly as intended.  The abort vector's
fault info is filled in before any of that and is the evidence that
actually arrives; the callback counter is still reported, as a statement
about the notify path.  That defect is untouched here and belongs to the
fault-path task.

Staged code gets cache maintenance.  The copy is data writes to memory
about to be fetched as instructions, and the module area is Normal
write-back, so the bytes can sit in dirty D-cache lines while the
instruction side fetches what main memory still holds.  It ran correctly
before this was added, which is the problem: a cold I-cache over a
never-executed address happens to work until the staging area is reused.

An overlap check comes with the second load address.  A module's code and
data regions are enabled together, PMSAv8-R has no region priority, and
the staging area shares its 64 KB with the byte pool the data comes from.
Nothing in the manager checks, so the example does.

The harness breaks on manager_done instead of a line number.  It used to
break on sample_threadx_module_manager.c:259, and every edit to that file
moved the line out from under the breakpoint, after which the run stopped
somewhere arbitrary and reported whatever memory happened to hold.  It
also computes module_progress as an offset into the module's data segment
added to the data base read off the target, because a relocated module's
progress flag has no fixed address any more.

Verified on an S32Z280-594EVB: module_progress 0x7 in both passes, code
bases 0x317F0000 and 0x317F4680, data bases 0x317F0EC0 and 0x317F1780,
both faulting at offset 0x19C into their own image.  FVP suite still 5/5.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
The shared _txm_module_manager_memory_fault_handler calls the application's
fault-notify callback after terminating the faulting thread, and that call was
never reached on this port.  It is not an upstream bug: terminating the running
thread returns only when the kernel believes it is inside an exception, because
_tx_thread_terminate ends in _tx_thread_system_preempt_check, which calls
_tx_thread_system_return whenever _tx_thread_system_state and
_tx_thread_preempt_disable are both zero -- and on AArch32 that switches context
and never comes back.  cortex_m33 is safe because its _tx_thread_system_return
only pends PendSV; cortex_a7, the port shaped like this one, increments
_tx_thread_system_state in its abort vector.  This port's vector did not.

So the fix belongs in the vector, not the handler, whose code stays byte for byte
identical to cortex_m33, cortex_a7 and cortex_m7.  The capture now increments
_tx_thread_system_state before any kernel service runs, balances its own push,
and on return decrements it, clears _tx_thread_current_ptr and enters
_tx_thread_schedule through an exception return in System mode.

That also stops an Abort stack leak.  The _tx_thread_system_return that ran in
the callback's place did so from Abort mode, saving a solicited frame nothing
reclaimed on top of a capture push that was read rather than popped: about 56
bytes of a 1 KB Abort stack per module fault, for the life of the run.

The sample now exercises the prefetch abort as well as the data abort, since only
one of the two vectors had ever run.  Which violation the module commits is
chosen by the manager through the application-defined module ID, so one blob
covers both cases and the manager still knows no symbol of the module.  A third
pass takes the prefetch abort from the staging area, after the first two have
been unloaded, so it is also a load that follows two faults and two unloads.  The
notify callback is checked rather than printed: exactly one call per pass, with
the faulting thread and module instance compared against what was recorded before
the start.

The harness reads each pass's module_progress at a new pass_done() symbol while
that pass still holds its data.  Reading all three at the end of the run
attributed pass 3's progress to pass 1, because the byte pool had handed pass 3
pass 1's freed block.

Verified on S32Z280-594EVB silicon, RTU0 core 0: three passes, both abort types,
IFSR/IFAR 0x0000020C/0x31780000 for the prefetch pass and DFSR/DFAR
0x0000020C/0x31780000 for the data passes, all faults in User mode, one correct
notification each, and the relocation result unchanged at offset 0x1F8 from two
code bases.  Target verdict and gdb harness both green.  AR1 FVP regression 5/5.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
_txm_module_manager_external_memory_enable built PRLAR from
(address + length - 1) without masking off the low six bits, where PRLAR
carries RES0[5:4], AttrIndx[3:1] and EN[0] rather than address.  For a
granule-sized grant the limit ends in 0x3F, so those bits land on top of the
attributes: AttrIndx comes out 7 instead of 0, selecting an unwritten MAIR
byte and giving the shared region Device memory type, and both RES0 bits are
set as well.

Masking with TXM_MODULE_MPU_ADDRESS_MASK describes exactly the same inclusive
end address, because the hardware reads the limit back as LIMIT concatenated
with 0x3F.  It is what txm_module_manager_mm_register_setup.c already does for
the code and data regions; only this file was missing it.

Found by using the function for the first time, in the FVP module example.
The region still works well enough to pass a functional test with the wrong
memory type, which is why the reasoning is now written down beside the mask.

The S32Z280 module image is rebuilt by this change and its behaviour is not:
that example grants no shared regions, so it never calls this function.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
AR2's deliverable was an example module and manager on the model plus an
automated isolation regression.  Until now the module port was silicon only
and judged by a person reading a console through a debug probe, where AR1 has
ctest.  fvp_module.elf runs the same three passes the S32Z280 example does --
two data-abort passes from two different load addresses, then a prefetch-abort
pass after both have been unloaded -- and reports one result line the existing
run_fvp_test.py matches.  ctest from the build root is now 6/6.

The board support gains what a manager build needs and nothing else, all of it
behind TXM_MODULE_MANAGER: entry.S routes the EL1 supervisor call to the
module boundary and sends a User-mode abort of either kind to the manager's
capture, and mpu.c programs the load window over the module area as region 16.
link_module.lds ends the kernel's DRAM region where the module area begins,
because a module's regions may not overlap a kernel one and PMSAv8-R has no
priority to resolve it if they do.

The count of MPU regions is asked of MPUIR rather than assumed, and a build
that cannot supply seventeen refuses to enable protection at all.  This matters
more than it looks: the model reports 32 EL1 regions, which the Cortex-R52 TRM
does not permit for this core -- MPUIR.DREGION is 16, 20 or 24 -- so the model
is the generic AEMv8-R and is strictly more permissive than every real part.
A green run here cannot tell you the region budget fits silicon, and a legal
16-region R52 cannot run this port at all.  Both facts are printed by the image
and stated in its header rather than left to be inferred from a pass.

How the test knows what the module achieved, with no debugger in reach: the
FVP exposes an Iris server and no GDB stub, so the harness that reads the
module's progress out of its data area on silicon has no counterpart here.
The manager instead grants each pass a shared MPU region over one granule at a
fixed address and the module records its progress there.  That is what lets
this image fail on MODULE_PROGRESS_SURVIVED_STEAL and _SURVIVED_JUMP by name,
rather than only on the absence of a fault, and it is the first exercise this
port has given txm_module_manager_external_memory_enable.

Verified on FVP_BaseR_AEMv8R 11.32.19.  Six of six ctest green in 8.5 s; the
module image reports failures = 0 with all three passes faulting in User mode
at the forbidden address, one correct notification each, and equal fault
offsets from two different code bases.  Checked negatively as well: pointing
the module at an address it is allowed to touch makes it survive, and the run
then prints "the module SURVIVED its violation -- it is not protected",
reports MODULE RESULT: FAILED and exits non-zero.  AR1 alone still 5/5 with
the module option off, and the S32Z280 example builds clean, 596 targets and
no warnings.

The two module examples duplicate their sample, preamble, blob wrapper and
module link script rather than sharing them.  That is deliberate: the two board
examples are kept independent so silicon bring-up cannot perturb the FVP
regression, and the copies are structurally identical so diff is the tool for
telling whether they have drifted.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
A fourth pass on the FVP and on the S32Z280 exercises the shared-memory
half of the module port, which until now had one region granted and four
of its five entries never used at all.

The shared area becomes six 64-byte granules at the base of the module
region: five the pass is granted, one entry each, and granule 2 --
sandwiched between two granted ones -- which the manager never grants.
The module writes every granule it was given, reads all of them back,
then writes the gap, which must fault.  The gap sits between two grants
deliberately: the defect worth catching is a region covering one granule
too many, and it reaches the gap from either direction.  The readback
matters as much as the write, because an entry programmed with the wrong
base accepts the store and simply puts it somewhere else.

The same pass probes the two refusals nothing had ever called.  An
unaligned grant must return TXM_MODULE_ALIGNMENT_ERROR and one grant past
the entry count must return TX_NO_MEMORY.  The unaligned probe runs
first, because external_memory_enable checks the entry count before it
checks alignment and would otherwise answer TX_NO_MEMORY and say nothing
about alignment.  The sixth-grant probe aims at the gap, so a check that
ever failed would be reported twice: once as a status and once by name as
the module surviving its violation.

No port source changed.  external_memory_enable and alignment_adjust were
already correct once the limit mask landed in 7636b0c, so this is the
evidence rather than a fix.  What alignment_adjust produces is now checked
directly: code_start, data_start and data_end + 1 must be granule-aligned
in every pass.  Not code_end, which is the true end of the image and is
rounded to nothing.

Fixed one real defect that the negative runs found.  Both examples read
the instance's region addresses after the shared grant, so a grant
failure left them zero and the end-of-run overlap comparison then
reported "its code and data regions overlap" -- a false second failure
pointing at the wrong thing.  They are read straight after the load now,
which is what decided them.

The S32Z280 module now reports progress through a shared granule as well,
as the FVP one already did, and its manager reads that on the target.
The gdb harness still reads the module's own copy out of the pass's data
area, and the two readings are cross-checked against each other.  The two
sample modules are back to differing only by the board named in the
header and the two addresses at the bottom, so diff remains the way to
spot drift.  diagnose_module.gdb lost the hardcoded 0x317F05xx addresses
it had carried since before the module became relocatable.

FVP: 603 targets, zero warnings, ctest 6/6.  All three negative runs were
done -- an over-wide grant, an alignment probe made aligned, and the
alignment check made to require 128 bytes -- each failing loudly and by
name.

S32Z280: 596 targets, zero warnings, === PASS === with failures = 0 from
the target and PASS from the gdb harness.  Pass 4 on the part reported
DFSR 0x00000A04 with WnR set, DFAR 0x317F0084, SPSR mode 0x10, five
grants, five entries held, 0xF0 for the unaligned grant, 0x10 for the
sixth, each granted granule holding its own mark and granule 2 still
zero.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
…de names

Mirrors what AR1 did at its M6: upstream's kernel warning set from
test/tx/cmake/CMakeLists.txt -- -Werror -Wall -Wextra -pedantic -Wunused
-Wuninitialized -Wmissing-declarations -Wconversion -Wpointer-arith -Wlogical-op
-Waggregate-return -Wfloat-equal -- plus -Wmissing-prototypes, which is the flag
that found AR1's Rule 8.4 findings, applied to every C file of the module port
and of the board support both module-manager images compile.

Twenty-five translation units in scope across the two boards.  The nine module
port ones had four findings, identical in both configurations; the sixteen board
support ones were already clean, so AR1's board.h work and T4's cache.c and mpu.c
changes hold.

  Rule 7.2 -- TXM_MODULE_MPU_ALIGNMENT was a plain 64, and every use of it is a
  mask over a ULONG address or size in the form ~(ALIGNMENT - 1).  Spelled
  signed, that complement is -64 converted to 0xFFFFFFC0 on the way into the
  expression: three -Wsign-conversion reports across alignment_adjust and
  external_memory_enable.  Now 64UL, which fixes all three at the definition
  rather than at each use, and keeps the essential type of the mask unsigned for
  whatever uses it next.

  Rule 2.7 -- _txm_module_manager_alignment_adjust never reads module_preamble,
  and cannot drop it: _txm_module_manager_internal_load calls it through a
  prototype every port shares.  Referenced and discarded, with the reason and a
  pointer to the Cortex-R4 port, which does read it.

  Rule 8.4 -- demo_module_start is called only from txm_module_preamble.S, which
  names it with .extern and stores its offset in the START entry-point word, and
  bsp_main is called only from entry.S.  Assembly callers supply no prototype, so
  both were definitions with external linkage and no visible declaration.
  demo_module_start is now declared above its definition in both sample modules;
  the two sample managers now include board.h, which already declares bsp_main
  for exactly this reason and which every other example in the board support
  package includes.

Also picked one spelling for the processor-mode constants, which this port had
two of.  The base port already draws the distinction that matters: *_MODE_BITS is
the bare CPSR mode field (SYS_MODE_BITS, IRQ_MODE_BITS, FIQ_MODE_BITS) and a
plain *_MODE is a whole CPSR value with the interrupt masks already in it
(SVC_MODE 0xD3, IRQ_MODE 0xD2).  The module port used both names for the bare
field, so SYS_MODE meant 0x1F in three files and 0xDF or 0x9F in a fourth.  Every
bare-field constant is now *_MODE_BITS, which is what tx_thread_schedule.S and
txm_module_manager_fault_capture.S already used, and the values are unchanged and
still 0x10 / 0x13 / 0x1F.  Two dead SVC_MODE definitions went with it -- one in
tx_thread_stack_build.S and one in tx_thread_context_restore.S, both left behind
when this port moved kernel threads from SVC mode to System mode.

Deviations this port has already taken, commented at their use sites in the
samples and repeated here for the record: Rule 11.6, integer to pointer, for the
shared status address, which is an agreement between two separately linked images
and has no conforming spelling; and Rule 11.1, function pointer conversion, for
the prefetch-abort test, whose whole purpose is a fetch from an address holding no
function.

Verified.  All nine module port translation units and all sixteen board support
ones compile clean under the set, in both board configurations.  FVP 603 targets
and S32Z280 596 targets, zero warnings either way.  FVP ctest 6/6 in 8.63 s.  And
the pass emitted no code: fvp_module.elf, fvp_demo_module.elf, s32z280_module.elf
and s32z280_demo_module.elf are all byte-identical to their 87ee71e builds, so
T5's silicon result stands on the same instructions without a hardware re-run.

One finding is recorded rather than fixed, because fixing it is a functional
change and this is not a functional commit.  tx_port.h guards the CLZ
implementation of TX_LOWEST_SET_BIT_CALCULATE with #if __TARGET_ARCH_ARM > 4.
That is an Arm Compiler predefine; GCC defines __ARM_ARCH 8 and
__ARM_FEATURE_CLZ 1 and does not define it at all.  So under GCC the test is
0 > 4, the CLZ path is dead and tx_thread.h's portable loop is used instead, on a
core that has the instruction.  Confirmed against the compiler's own -dM output.
It is upstream's, identical in the base port and in about a dozen other AArch32
ports, and it changes the scheduler's priority search, so it belongs in an
upstream issue and a commit of its own.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
T7's advisory pass found this with -Wundef and recorded it rather than fixing
it, because it changes emitted code and T7 was a quality-only commit.  This is
that commit.

tx_port.h guarded its CLZ implementation of TX_LOWEST_SET_BIT_CALCULATE with
#if __TARGET_ARCH_ARM > 4.  That is an Arm Compiler 5 predefine.  GCC predefines
the ACLE macros -- __ARM_ARCH 8 and __ARM_FEATURE_CLZ 1 -- and does not define it
at all, so the test read 0 > 4, the block was dropped, and tx_thread.h's portable
loop ran on a core that has had the instruction since Armv5.  Confirmed two ways
on 20 Aug 2026: arm-none-eabi-gcc 14.3 -dM -E lists no __TARGET_ARCH_ARM, and the
built scheduler objects contained zero CLZ instructions.

This was not a dormant optimisation.  Half the TX_LOWEST_SET_BIT_CALCULATE call
sites in tx_thread_suspend.c and tx_thread_system_suspend.c sit OUTSIDE the
TX_MAX_PRIORITIES > 32 guards, so the portable loop was running the scheduler's
priority search in the default 32-priority configuration -- which is the one both
R52 ports use.

The guard now asks __ARM_FEATURE_CLZ, which is the ACLE answer to the question
actually being asked: the compiler defines it exactly when the architecture has
the instruction, so a core without CLZ is excluded by construction rather than by
an architecture number.  Arm Compiler 5's spelling is kept beside it, wrapped in
defined() so the test no longer leans on an undefined identifier evaluating to
zero.

The __thumb__ guard stays, and it turns out to be load-bearing rather than
inherited caution.  __ARM_FEATURE_CLZ describes the architecture, not the
instruction set: GCC defines it for -mthumb -march=armv5te, where Thumb-1 has no
CLZ and this asm would fail to assemble.  Measured, not assumed.  On this core it
is moot -- the R52 toolchain file builds -marm.

Two changes to the macro body, both deliberate:

  Rule 10.1/10.3.  Upstream isolates the lowest set bit with
  (ULONG) (-((LONG) m)) -- an unsigned map converted to signed and negated, which
  is undefined for the one input whose top bit is set.  Respelled as
  (~(m)) + 1, which is the same value in well-defined unsigned arithmetic and is
  character-for-character what tx_thread.h's portable version uses, so the two
  implementations now visibly compute the same thing rather than merely agreeing.

  Rule 20.7.  m and b are parenthesised in the expansion; upstream leaves them
  bare.  And the asm is spelled __asm__, because the asm keyword is rejected
  under -std=c99 -- verified, it is an "'asm' undeclared" error -- and AGENTS.md
  requires C99 compatibility.  Inline assembly itself is a documented Rule 1.2
  deviation; there is no conforming way to reach the instruction.

A precondition is written down that was previously only true by luck of the call
sites: m must be non-zero, and the two implementations DISAGREE when it is not.
CLZ(0) is 32, so this yields 31 - 32 while the portable loop yields 0.  All twelve
call sites in common/src were read, and every one reaches the macro only on a map
already tested against zero, so the difference is unreachable today.  demo_clz
pins both answers so that changing which is true has to be a decision.

New regression image, demo_clz.elf, because turning this on means hand-written
inline assembly now decides which thread runs next:

  - all 32 single-bit maps return their own bit position;
  - all 32 positions with a deterministic spray of HIGHER bits still return that
    position, which is what distinguishes "lowest set bit" from "only set bit";
  - ten hand-picked patterns including 0x80000000, the input upstream's signed
    negation had undefined behaviour for;
  - every map run twice, into a UINT and into a ULONG, because the kernel calls
    the macro with both and the closing 31 - b is where a width mistake hides;
  - the m == 0 divergence, pinned;
  - the map-is-consumed property, which callers rely on by never relying on it;
  - and #error guards so an image that reached the checks with the CLZ path
    disabled fails to build rather than testing the portable loop and reporting
    a pass.

Verified, including negatively -- three deliberate breaks, each caught:

  drop the isolation step   -> the 32 single-bit maps still PASS and the sprayed
                               group catches it, which is exactly why that group
                               exists;
  31 - b becomes 32 - b     -> caught by the single-bit group;
  restore the dead guard    -> caught at compile time by the #error.

FVP 613 targets, S32Z280 596 targets, zero warnings either way.  FVP ctest 7/7 in
9.75 s.  All 370 kernel translation units of both libthreadx and libthreadx_module
compile clean under T7's strict warning set with the CLZ path live.  CLZ present
in fvp_module.elf, s32z280_module.elf and s32z280_demo.elf, so the module port's
own copy of the header is covered by the module image rather than by inspection.

Measured on tx_thread_system_suspend.o, the one object that carries it in the
default configuration: .text 2188 -> 1316 bytes, 547 -> 329 instructions.  A 40%
smaller priority search on a code path the scheduler runs on every suspend and
resume.

Scope deliberately limited to the two R52 ports, which are the ones this branch
can build and run.  The same dead guard is in about two dozen other tx_port.h
files across ports/, ports_arch/, ports_module/ and ports_smp/ -- every GNU port,
and the armclang ones too, since armclang is Clang-based and predefines the ACLE
macros rather than Arm Compiler 5's.  Fixing ports with no build here would mean
shipping unvalidated changes, so those belong in an upstream issue with this
commit as the reference.

The S32Z280 images DID change this time, unlike T7: the kernel now carries two CLZ
instructions.  The FVP runs the same instructions on the same architecture and is
green across all seven images, and CLZ has no memory, MPU or cache interaction, so
the risk is low -- but a silicon re-run is outstanding and is not claimed here.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
Two readme fixes that the previous commit made due.

The image list in section 3 enumerates every example, so adding demo_clz.elf
without adding it here would have left the readme telling a reader to build five
images out of six.  The ninja line and the list both name it now, and section 6
says what the optimisation is, what it is worth (2188 to 1316 bytes of the
priority search), that it applies in the default 32-priority configuration rather
than only above 32, and why a Thumb build deliberately keeps the portable loop.

The second one is not mine and is worth calling out.  Section 7 told anyone
reusing this code that PRBAR.AP is encoded "the reverse of the widely-published
Armv8-R AArch64 macro set" and to re-calibrate before trusting it.  That is false,
and mpu.h has said so since the shift bug was found: AP[2] selects read-only and
AP[1] grants EL0 access, exactly as published, and the four-region calibration
that suggested otherwise was measuring a program_region() that shifted every
PRBAR field one bit too far left.  So the readme was contradicting the header
beside it on the encoding of a permissions register, which is the worst place to
be wrong -- a reader who "re-calibrated" against that advice would have inverted
their own access permissions.

Replaced with what mpu.h establishes, including the part that is genuinely worth
carrying: region coverage is enforced even when the permissions are not what you
asked for, so an unmapped address faults while a "read-only" region quietly
accepts writes, and only provoking a real fault tells the two apart.  That is the
transferable lesson the old text was reaching for.

No code change; FVP ctest still 7/7.

Assisted-by: Claude Code (Opus 5) <noreply@anthropic.com>
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.

1 participant