Skip to content

xtensa/esp32s3: Run user processes in a world they cannot escape - #19798

Open
casaroli wants to merge 22 commits into
apache:masterfrom
casaroli:esp32s3-kernel-full
Open

xtensa/esp32s3: Run user processes in a world they cannot escape#19798
casaroli wants to merge 22 commits into
apache:masterfrom
casaroli:esp32s3-kernel-full

Conversation

@casaroli

@casaroli casaroli commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

This gives the ESP32-S3 a kernel build: fork(), a per-process address environment, and a privilege boundary between the kernel and a user process.

It replaces #19795, #19796 and #19797, which are the same work in three parts.

The change has four parts.

vfork() on Xtensa.

up_fork(bool vfork) is the architecture half of the primitive of #19562.
The child gets its own stack that holds a copy of the part of the parent stack that is in use, and it resumes where the caller would have returned.
The windowed ABI needs care in one place: a stack pointer has a base save area 16 bytes below it that holds the spilled a0-a3 of the caller, and the frame chain runs through it, so the chain is walked and rebased onto the copy.

BUILD_KERNEL on Xtensa.

A kernel build needs a kernel stack for a user thread, a signal trampoline that returns through a system call, an initial register state that starts a user task at the right privilege, and the system call paths between the two worlds.
mm/pgalloc gets 32 KB and 64 KB pages, because the Espressif cache MMU has a fixed 64 KB page for external flash and 32 KB for external RAM.

The address environment.

It is built on the cache MMU of the SoC and not on anything in the Xtensa core.
The core gives only coarse 512 MB regions with no paging, so the per-process mapping comes from the MMU that maps external flash and PSRAM.
This is why the code is chip code: a non-Espressif LX7 would share none of it.

The page pool is not kept mapped in the kernel address space.
It is carved out of the PSRAM that the user processes run from, and the external memory permissions are indexed by physical address, so a kernel window on the pool would be a window on the memory of every process.
The kernel reaches a page through a small scratch region that is mapped for one operation and invalidated after it.

The privilege boundary.

PMS was programmed only from esp32s3_userspace.c, which is built for a protected build alone.
A kernel build therefore never programmed the split lines, never entered World 1, and never installed the monitor interrupt, so a user process could read and write kernel memory and reach the registers that control the mapping.

The world split now lives in esp32s3_isolation.c, which is built for any build that is not flat.
esp32s3_start() programs the worlds and the permissions before nx_start().

An access that no MMU entry translates is reported as well.
The PMS grants and refuses physical addresses, so it never sees an untranslated access: the cache answered it with zeros, and the task continued with a value it never should have had.
EXTMEM_MMU_ENTRY_FAULT is enabled and the Cache Invalid Access interrupt goes to the same handler, which reads the cause before it clears the latch.

An unprivileged task that makes any of these accesses is terminated with SIGSEGV.
A privileged one still panics.

Impact

A flat build does not change.

A protected build keeps the same permissions.
The code that sets them moved to a file that both builds use.

mm/pgalloc gains two page sizes and changes no default.
MM_PGSIZE is read only when CONFIG_MM_PGALLOC is set.

The ESP32-S3 gains two kernel configurations, kernel_oct and kernel_n8r2.
fork() is available in a kernel build only, because it needs an address environment.
vfork() is available in every build mode.

Testing

Board: ESP32-S3-DevKitC with an ESP32-S3-WROOM-2 N32R8V, 32 MB octal flash and 8 MB PSRAM.

Host: macOS 15 on Apple Silicon, xtensa-esp32s3-elf-gcc 12.2.0.

Configuration: esp32s3-devkit:kernel_oct.

fork() and vfork()

vfork_test: Child 41 ran and exited before the parent resumed
fork_test: Child running independently (child)
fork_test: Parent and child had independent memory
ostest_main: Exiting with status 0

ostest runs to the end with no assertion and no panic.

The boundary

Every target of examples/sandbox carries the outcome it expects, so the test fails a build that refuses everything as well as one that permits everything.
self is the control: it touches memory the process owns and must be allowed.

sandbox: target self -- this process's own data, expecting success
sandbox: PASS - the allowed access completed

sandbox: target kernel -- kernel memory at 0x3fc98000, expecting a fault
pms_violation_isr: SIGSEGV (PMS) task /system/bin/sandbox
sandbox: PASS - the offending process was terminated

sandbox: target periph -- a peripheral register at 0x600c5000, expecting a fault
pms_violation_isr: SIGSEGV (PMS) task /system/bin/sandbox
sandbox: PASS - the offending process was terminated

sandbox: target unmapped -- an address with no mapping at 0x3d800000, expecting a fault
pms_violation_isr: SIGSEGV (MMU entry) task /system/bin/sandbox
sandbox: PASS - the offending process was terminated

sandbox: CONTAINED - 4 target(s), every check passed

0x600c5000 is DR_REG_MMU_TABLE, the registers that hold the mapping.

Resources of a killed process

The offending process allocates 64 KiB, writes to all of it, and opens a file before it makes the access.
It still holds both when it is killed.

sandbox: /proc/meminfo reads
      total       used       free    maxused    maxfree  nused  nfree name
     378616      28336     350280      28704     345576    105      4 Kmem
    4194304    1441792    2752512               2752512               Page

sandbox: memory 1441792 -> 2162688 -> 1441792
sandbox: PASS - 3 descriptor(s) open, none after

The count rises while the offender lives and returns to the same value after it is reaped.
The test reports a failure if the count never rises, because a number that does not move proves nothing.

The whole run was made three times, which is twelve process deaths, with the same result each time.

tools/checkpatch.sh -c -u -m -g reports no errors.

depends-on: apache/nuttx-apps/pull/3721

Xtensa selected neither fork primitive, so vfork() was simply absent.  This
wires it onto the two-primitive semantics.

There is no assembly entry point and none is needed.  Every exception entry
already runs SPILL_ALL_WINDOWS, so the whole context of the calling thread is
in its exception frame and copying its stack copies a complete frame chain.
A flat build reaches that frame through SYS_save_context, issued inline so
that the recorded stack pointer belongs to a frame that stays alive for the
whole operation; a build with syscalls reaches it through xcp.sregs, recorded
by xtensa_swint() for the duration of the call.

The stack copy needs more than a relocated stack pointer here.  A windowed
ABI stores each frame's caller stack pointer absolutely, in the base save
area below the frame, so a copy taken at a different address still names the
parent throughout and the child's first retw would underflow onto the
parent's stack.  xtensa_fork_rebase() walks that chain and adds the
relocation offset to each link.  The copy also starts one base save area
below the stack pointer rather than at it, because the frame the child
resumes into keeps its caller's spilled a0-a3 there.

Ported from the per-architecture work, reduced to the two primitives.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Add CONFIG_MM_PGSIZE == 32768 and 65536 to the page-size switch (and the
Kconfig help text). The 64 KB size matches the ESP32-S3 cache-MMU page
granularity, so an address-environment port there can use one mm_pgalloc()
page per cache-MMU page (naturally 64 KB-aligned by the granule allocator)
instead of coalescing several smaller pages. Inert for existing configs:
MM_PGSIZE is only used when CONFIG_MM_PGALLOC is enabled (BUILD_KERNEL).

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Add what a kernel build needs on Xtensa:  a crt0 for a user process, the
kernel stack allocation that a system call switches to, the syscall entry and
return path for an unprivileged caller, and the initial register state that
starts a user task at EL0 with its save area on the kernel stack.

On the ESP32-S3 the arch code that runs while the flash mapping is in flux
moves to IRAM, and the kernel heap is placed above the user .bss so that
up_allocate_kheap() and the user address environment do not overlap.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
…t B)

Route the precise cache-attribute permission faults -- Load/Store/InstrFetch
Prohibited (EXCCAUSE 28/29/20) -- from xtensa_user() to a new dispatcher,
esp32s3_pagefault_dispatch().  On a serviced fault the register frame is
returned so the exception vector's RFE re-executes the faulting instruction;
otherwise it declines to the existing panic path.  Gated by
CONFIG_ESP32S3_PAGEFAULT (default n, depends on BUILD_PROTECTED); the build
is unchanged when the option is off.

This is the recoverable-fault primitive the address-environment / demand-paging
work builds on.  Proven on the ESP32-S3-DevKitC WROOM-2:

- A precise LoadProhibited carries a tracking EXCVADDR (the exact faulting
  address), and RFE cleanly re-executes the faulted load on return -- verified
  with CONFIG_ESP32S3_PAGEFAULT_SELFTEST (the identical instruction restarts
  three times, then steps past, and the task resumes with the shell alive).
- ESP32-S3 PMS (World Controller) permission violations are NOT delivered as
  these precise causes; they raise the asynchronous DRAM0/IRAM0 PMS-monitor
  interrupt, so PMS is an isolation (kill) mechanism, not a restartable one.

No regression: esp32s3-devkit:knsh (WROOM-2) boots to nsh and ostest passes
with the option enabled.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
The protected kernel linker (kernel-space.ld) placed the octal (OPI)
flash bring-up helpers -- esp_rom_spiflash / esp_rom_opiflash_*,
spi_flash_oct_flash_init, mmu_hal, mspi_timing_*, bootloader_flash*,
efuse_hal/efuse_utility, esp_mmu_map and esp32s3_spi_timing -- in mapped
flash.  During configure_cpu_caches() / spi_flash_init_chip_state() in
__start these run while the flash mapping is being reconfigured, which
faults (illegal instruction) on octal-flash modules such as the
ESP32-S3-WROOM-2.  Quad-flash parts never exercise the OPI path, so the
problem was latent.

Place those functions in .iram0.text (mirroring the flat sections
script) so they are safe to execute during flash reconfiguration.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Give the ESP32-S3 the arch_addrenv_t machinery that BUILD_KERNEL needs:  a
per-process page directory built from the 64 KiB MMU pages of the chip, with
allocation, teardown, and the vaddr-to-paddr translation that the kernel uses
to reach a user buffer.

The MMU, PMS and WCL primitives are exposed as an arch API first, because the
address environment code and the protected user split both need them and
neither owns them.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
kernel_oct, with the user-program layout and the boot ROMFS a kernel build
loads its programs from.  The ROMFS placeholder is rebuilt with the image,
the generated copy is ignored, and the programs are given stack sizes and
room for a fork() child.

Folds in:
  esp32s3-devkit: user-program layout and boot ROMFS for kernel builds
  boards/esp32s3-devkit: add a kernel-build configuration
  boards/esp32s3-devkit: give kernel_oct's programs their stacks back
  esp32s3-devkit: ignore the generated boot ROMFS
  boards/esp32s3-devkit: rebuild the ROMFS placeholder with the image
  boards/esp32s3-devkit: leave kernel_oct room for a fork() child

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
The page pool is carved out of the PSRAM that user processes run from, and
the external memory permissions are indexed by physical address, so a
permanent kernel window onto the pool is a window onto every process, which
no permission setting can close.

Stop mapping the pool.  The kernel reaches a pool page through a small
scratch region instead, mapped for one operation and invalidated afterwards.
esp32s3_pgmap() takes a slot, esp32s3_pgunmap() releases it, and
ARCH_KMAP_VBASE and ARCH_KMAP_NPAGES describe the region.  Two slots are
enough, because the deepest user is up_addrenv_fork(), which holds a source
and a destination page at once.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
up_addrenv_fork() duplicates an address environment into freshly allocated
pages mapped at the same virtual addresses.  The text, data and heap regions
of the source are walked one page at a time and copied into fresh pages hung
off the child's own directory, using the two kmap slots that
CONFIG_ARCH_KMAP_NPAGES reserves for exactly this.

xtensa_fork.c already took both paths:  a child that keeps the parent's stack
addresses needs no relocation, which is what a duplicated address environment
gives it.  Only the hook and the Kconfig default were missing.

fork() is offered on a kernel build, which is the only mode with per-process
address environments.

Verified on an ESP32-S3-WROOM-2 with esp32s3-devkit:kernel_oct.  ostest
reports "Parent and child had independent memory" and exits with status 0.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
kernel_oct targets a WROOM-2 N32R8V:  octal flash, and 8 MB of PSRAM for the
page pool.  The defaults size the pool for that part, with 8 pages of 64 KiB
for each of the text, data and heap regions, so 1.5 MB per process.  fork()
duplicates the address environment, so a parent and a child need 3 MB at once
and a module with 2 MB of PSRAM cannot do it.

kernel_n8r2 sizes the same build for such a module.  Each region is 2 pages,
so a process takes 384 KiB and a fork() peaks at 768 KiB, inside a 1.5 MB pool
placed at 0x80000 to leave the start of the PSRAM alone.

The flash is quad and runs in DIO mode, so this configuration also exercises
the CONFIG_ESP32S3_FLASH_MODE_OCT guard in kernel-space.ld from the quad side,
which kernel_oct cannot.

This is tight by construction.  ostest has 115 KiB of text against a 128 KiB
text region.  A larger program needs a module with more PSRAM, not a larger
pool.

Verified on an ESP32-S3-DevKitC with an N8R2 module, 8 MB flash in DIO mode
and 2 MB of embedded quad PSRAM.  ostest reports "Parent and child had
independent memory" and exits with status 0.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Describe kernel_oct and kernel_n8r2 next to the other configurations of this
board.

The entry for kernel_oct carries what a user needs and cannot guess:  a KERNEL
build is the only mode with fork() on this chip, the page pool is reached
through a scratch mapping rather than a permanent window, the ROMFS is linked
into the kernel image so a change to an application needs the whole
export-import-mkromfsimg-relink chain, how to confirm that the ROMFS is really
in the image, and that the shell needs the full path of a program.

The entry for kernel_n8r2 states its limit.  Each region of a process is 2
pages, ostest has 115 KiB of text against a 128 KiB text region, and a larger
program needs a module with more PSRAM.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Two points from the review of apache#19772 that belong with the ESP32-S3 work.

ARCH_HAVE_FORK is now selected by the architecture rather than defaulted from
inside its own definition, so the condition sits where a reader of arch/Kconfig
will look for it.  It repeats the ARCH_ADDRENV dependency, because a select
bypasses depends on and without that an architecture could offer fork() with no
address environment to duplicate.

The page pool no longer carries chip-specific copies of settings the common
address environment already defines.  ARCH_PGPOOL_PBASE and ARCH_PGPOOL_SIZE
were only reachable under ARCH_PGPOOL_MAPPING, which does not apply here:  the
pool is deliberately left unmapped, because it is carved out of the PSRAM the
user processes run from and the external memory permissions are indexed by
physical address.  But a physical base and a size describe the pool whether or
not it is mapped -- only a virtual base needs the mapping -- so those two move
out of that block and the chip uses them.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
The common Xtensa BUILD_KERNEL support needs the chip to say what it can do
and where its memory goes.

The chip selects the address environment options it now implements, keeps the
kernel and user heaps apart, and the linker scripts separate kernel from user
text and data so the two worlds can be given different permissions.

Split out of the same change as the common code, so that arch/xtensa/src/common
can be reviewed without the chip in the way.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Separate the world split from the protected user image, give WORLD1 its own
vector table and its own PMS permissions -- including the PSRAM -- clean up
the user cache-MMU windows, and stop keeping the page pool mapped.

Folds in:
  xtensa/esp32s3: separate the world split from the protected user image
  xtensa/esp32s3: give the unprivileged world its own vector table
  xtensa/esp32s3: give the unprivileged world its permissions
  xtensa/esp32s3: clean up the user cache-MMU windows
  xtensa/esp32s3: stop keeping the page pool mapped
  xtensa/esp32s3: give the PSRAM its own PMS permissions

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
When an unprivileged (WORLD1) task takes an unrecoverable cache-attribute
fault (Load/Store/InstrFetch Prohibited), deliver a fatal SIGSEGV to just
that task instead of panicking the whole system; kernel-mode faults still
panic.  Gated by CONFIG_ESP32S3_PAGEFAULT_ABORT (default y under
ESP32S3_PAGEFAULT), which selects SIG_DEFAULT + SIG_SIGKILL_ACTION so the
signal's default action terminates the task.

esp32s3_pagefault_abort() mirrors the interrupt-dispatch handshake: it records
the exception frame as the task context, dispatches SIGSEGV (which redirects
the task to the signal trampoline via up_schedule_sigaction()), and returns
the redirected frame so the vector's RFE resumes the task in the trampoline,
whose default action _exit()s it and reschedules.  No kernel stack is required,
so this works on the existing protected configs.

Verified on the ESP32-S3-DevKitC WROOM-2:

- "pffault r 0x0" / "pffault w 0x0" (NULL read/write, EXCCAUSE 28/29) terminate
  only the pffault task; nsh stays interactive.
- Repeatable with no memory leak (free unchanged after 8 aborts) and no zombie
  tasks (ps shows none lingering).
- No regression: ostest exits with status 0 and the RFE-restart self-test still
  passes.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit b160d7ad844aed8589b1c3cb399252c44284cf6a)

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
A denied external-memory access does not trap on this chip.  TRM v1.8 p.699:
an access without permission is "responded with 0 (for internal memory) or
0xdeadbeaf (for external memory)".  So when an unprivileged task branches
into kernel text -- in flash or in PSRAM -- the fetch is refused silently,
the CPU executes the dummy word it was handed, and the refusal arrives at
xtensa_user() as EXCCAUSE_ILLEGAL at the address that was branched to.  It
never arrives as EXCCAUSE_INSTR_PROHIBITED, which is what the existing
recoverable path looks for.

The result was that a correctly refused fetch panicked the system.  Killing
just the offender is the right answer however the illegal instruction arose
-- a refused fetch or simply a corrupt user binary -- because a user task
running garbage must not take the system down with it.  It is not offered to
esp32s3_pagefault_dispatch() first: re-executing cannot help, the instruction
genuinely is not there.

The User Mode bit in the interruptee's saved PS keeps this to unprivileged
faults; an illegal instruction in the kernel still panics.

Verified on an ESP32-S3 DevKitC with a WROOM-2 module (octal flash, 8 MB
octal PSRAM), esp32s3-devkit:kernel_oct.  examples/sandbox aimed at real
kernel .text, both modes, at the same address:

  sandbox r 0x42011014 -> pms_violation_isr: SIGSEGV (PMS)
  sandbox x 0x42011014 -> esp32s3_pagefault_abort: SIGSEGV EXCCAUSE=0
  held (read,call): 2 of 2

Before this commit the execute probe produced xtensa_user_panic and a full
crash dump.  Note the probe must be given an explicit address: this board's
CONFIG_RAM_START is 0x20000000, which is not a kernel region at all.

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 08304b4f6f28a8a57579b2a054d1b3d391564b09)
Reporting a fault can itself fault.  syslog reaches memory that the very
fault being reported may have made unreachable, so esp32s3_pagefault_dispatch()
is re-entered from inside its own _alert() and never returns.  The console
fills with the same line severed part-way through EXCVADDR, forever, and
nothing legible ever reaches it.

Found by running esp32s3-devkit:kernel_oct under Espressif's QEMU, where
PSRAM never initialises and the kernel build needs it -- the pgalloc pool
that backs every user process lives there.  13 MB of half-printed lines in
60 s, no NSH, no way to see what went wrong.  Hardware does not show this:
the board's PSRAM works, so the path is never taken.

Bound it.  A fault repeating at the same address and PC is not going to be
helped by reporting it again, so try three times and then halt with
interrupts off.  The lines may still be truncated -- the print is what
faults, so it cannot be made to complete from here -- but a handful of
severed lines followed by silence is diagnosable, and an endless stream of
them is not.

The counter is cleared in esp32s3_pagefault_abort().  Reaching there means
the fault was contained and the system carried on, so only *unbroken*
recursion should stop the machine; without the reset, a probe run three
times at one address would trip the guard and halt a perfectly healthy
board.  That is the case verified on hardware below, and it is the reason
the reset exists.

Verified both ways.  Under QEMU, where the report does fault:

  before: 12,958,521 bytes in 60 s, unbounded
  after:       1,567 bytes in 45 s, four reports then halt

On an ESP32-S3 DevKitC with a WROOM-2 module, esp32s3-devkit:kernel_oct,
three identical probes in a single boot -- one "Booting NuttX", no reset:

  sandbox r 0x42011014  x3 -> CONTAINED - the sandbox held  (3 of 3)

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 222003254e4e0793e5593ef91ba3b2b2db1e14d4)
The user-exception handler recovered from three causes -- Load, Store and
InstrFetch Prohibited -- plus an illegal instruction, and panicked on
everything else.  Gating on a list of causes means every cause left off the
list is a way for an unprivileged task to stop the machine, and a task has
plenty:  a divide by zero (EXCCAUSE 6), a privileged instruction (8), a
load/store error (3), an instruction-fetch error (2), a data access to an
address the bus will not carry (9).  None of these involve a boundary at all;
they are things a correctly confined application does to itself.

Measured on an ESP32-S3-WROOM-2, kernel build, before this change:  a user
task dividing by zero takes the whole system down, silently.

So gate on the interruptee instead.  The dispatcher still gets first refusal
on 28/29/20, which are the precise, restartable causes and the only ones
re-executing can help.  Anything it does not service now goes to the abort
path if the saved PS says the fault was taken in User Mode.  That bit is also
what excludes the cases with no safe task to kill:  a kernel thread, a fault
inside a system call made on the user's behalf, and a fault while handling an
interrupt all run with PS.UM clear and still panic.  This is the same shape
riscv_fault_handler() has, where the cause is used for the message only.

The abort itself moves to esp32s3_userfault.c and stops depending on the
recoverable-fault dispatcher.  CONFIG_ESP32S3_PAGEFAULT_ABORT was a child of
CONFIG_ESP32S3_PAGEFAULT, which is default n, so "do not let a rogue task
halt the machine" was unreachable unless you also opted into "retry the
faulting instruction".  Those are separate capabilities and the safety one is
the one you always want, so it becomes CONFIG_ESP32S3_USERFAULT_ABORT,
default y wherever there is an unprivileged world.  A BUILD_PROTECTED
configuration previously got neither.

Verified on hardware with the new examples/misbehave.  A user task that
writes through NULL (29), reads a wild address (28), divides by zero (6) or
calls into a buffer of garbage (20) is terminated on its own, with an
unrelated task still counting either side of it.

Two limits worth recording rather than hiding:

* Stack overflow is not contained and does not even report -- the console
  goes silent.  On a windowed ABI the overflow faults inside the window
  overflow handler, so it arrives as a double exception with PS.UM already
  clear and no gate can see it.  Guard pages are the answer and are what
  CONFIG_ESP32S3_PAGEFAULT exists to make possible; that is separate work.
* A 32-bit load from an unaligned address does not trap on this core, so
  EXCCAUSE 9 is reachable by other means but not that one.

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3c11f3793dbae918dc6ebe3e99cbb60f70648b3e)
The PMS grants and refuses physical addresses, so it never sees an access
that no MMU entry translates.  The cache answered such an access with zeros
and raised nothing, and the task carried on with a value it never should
have had.

Enable EXTMEM_MMU_ENTRY_FAULT and route the Cache Invalid Access interrupt
to the handler that already serves the PMS monitors.  An unprivileged task
that makes the access is terminated with SIGSEGV;  a privileged one still
panics.  The latch is level triggered, so it is cleared with the others.

Read the cause before the clear, so the log tells the two apart:  a PMS
violation is a refused translation, an MMU entry fault is an access that was
never translated.

Give the kernel_oct configuration the addresses that examples/sandbox needs
to name its targets.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Two files each defined the same 16 byte constant, KSTACK_ALIGNMENT and
SIGTRAMP_STACK_ALIGN.  Use STACKFRAME_ALIGN, which arch/xtensa/include/irq.h
already gives as 16, with the STACKFRAME_ALIGN_DOWN() of nuttx/irq.h.

STACK_ALIGNMENT is not the name to use here.  It is TLS_STACK_ALIGN when
CONFIG_TLS_ALIGNED is set, which is the alignment of a thread stack and not
of a frame.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Each of these carried a name and nothing else.  Give every one a
description, its input parameters and its returned value, as asked in
review.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
The comment said "handled elsewhere" and did not say where.  Name the
handler and the function that installs it, and add the fault for an access
that no MMU entry translates.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
@casaroli
casaroli force-pushed the esp32s3-kernel-full branch from c2ff339 to 5add8b3 Compare August 11, 2026 20:06
@github-actions github-actions Bot added Area: Documentation Improvements or additions to documentation Arch: xtensa Issues related to the Xtensa architecture Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces. Board: xtensa labels Aug 11, 2026
@casaroli casaroli changed the title xtensa/esp32s3: Isolate the unprivileged world in a kernel build xtensa/esp32s3: Add a kernel build with fork() and user mode isolation Aug 11, 2026
@casaroli casaroli changed the title xtensa/esp32s3: Add a kernel build with fork() and user mode isolation xtensa/esp32s3: Add POSIX fork() and hardware enforced process isolation Aug 11, 2026
@casaroli casaroli changed the title xtensa/esp32s3: Add POSIX fork() and hardware enforced process isolation xtensa/esp32s3: Run user processes in a world they cannot escape Aug 11, 2026
@github-actions

Copy link
Copy Markdown

MemBrowse Memory Report

esp32-devkitc

@casaroli

casaroli commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@tmedicci @igrr @xiaoxiang781216 what do you think?

@github-actions

Copy link
Copy Markdown

🔗 Cross-repo PR dependencies

The read-only Build run reported the following dependent PR(s) and fetched head SHA(s):

CI run: https://github.com/apache/nuttx/actions/runs/31531219719

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Arch: xtensa Issues related to the Xtensa architecture Area: Documentation Improvements or additions to documentation Board: xtensa Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant