Skip to content

!sched/arch/libc: Give fork() and vfork() their real, separate semantics. - #19562

Merged
acassis merged 6 commits into
apache:masterfrom
casaroli:fork-semantics-core
Aug 10, 2026
Merged

!sched/arch/libc: Give fork() and vfork() their real, separate semantics.#19562
acassis merged 6 commits into
apache:masterfrom
casaroli:fork-semantics-core

Conversation

@casaroli

@casaroli casaroli commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

NuttX gives fork() and vfork() the same behaviour. Both are libc wrappers around one up_fork() system call. vfork() only adds a waitpid() at the end. Below them, the child joins the address environment of the parent. This is the same addrenv_join() that pthread_create() uses. The child gets a private copy of the stack, but it shares .data, .bss and the heap with the parent, and it runs at the same time as the parent.

That is not fork(). It is vfork() with a private stack, under the name of fork(). The history agrees. Commit c33d1c9 renamed the old vfork() to fork() in 2023 and changed no behaviour. The result was a silent fault. A program written for POSIX fork() compiled and ran, and the writes of the child went into the variables of the parent.

This is step 1 of the plan in #19540 (ordering, go-ahead). It gives the two primitives their own semantics. It also withdraws fork() from every architecture, so that each architecture can restore it later with the correct behaviour.

What changes

There are now two primitives. The caller selects one by the function that it calls, not by the hardware.

memory parent availability
fork() the child gets its own copy at the same virtual addresses runs at the same time CONFIG_ARCH_HAVE_FORK, which now depends on ARCH_ADDRENV, and which no architecture selects
vfork() the child shares the memory of the parent suspended until the child calls _exit() or exec() CONFIG_ARCH_HAVE_VFORK

Below libc there is still one system call. up_fork() takes a bool that says which primitive the caller used, because the register snapshot is the same for both. It gives the flag to nxtask_setup_fork(), which is the one place that decides the memory semantics. The flag arrives in the first argument register and passes on unchanged, so each architecture only keeps it alive across its snapshot sequence.

The suspension of the vfork() parent moves out of libc into nxtask_start_fork(). nxsched_release_tcb() releases it. The parent therefore continues at exec(), because exec_swap() has already given the pid of the child to the loaded program when the vfork stub exits. vfork() no longer needs CONFIG_SCHED_WAITPID.

fork() uses a new addrenv_fork(). An up_addrenv_fork() hook supports it. The hook copies an address environment into new pages at the same virtual addresses.

Breaking change

fork() is withdrawn from every architecture. unistd.h no longer declares it, so code that calls it fails to build with an error that names the function. The old sharing behaviour is gone. It is not renamed. A build error is better than the silent fault that it replaces.

Select the correct replacement by the reason for the call:

why you called fork() use
to run a program vfork() and exec*(), or better posix_spawn()
a second flow of control that shares the memory of the caller pthread_create()
a truly independent copy of the process keep fork(), and wait for the patch that implements up_addrenv_fork() for your architecture

Out-of-tree code that tests CONFIG_ARCH_HAVE_FORK to find a fork-then-exec path must test CONFIG_ARCH_HAVE_VFORK instead. The migration guide is Documentation/guides/fork_vfork_migration.rst.

Which architectures have vfork()

Every architecture that had the old fork() now has vfork(), under the same condition.

architecture condition change
ARM 32-bit all builds the kernel build is new
ARM64 all builds the kernel and protected builds are new
RISC-V all builds none
SIM if !HOST_WINDOWS none
x86_64 all builds the kernel build is new
MIPS32 all builds none

ARM 32-bit, ARM64 and x86_64 gain build modes that never had a working fork family. In each of them the entry point can only snapshot itself, and in a kernel or protected build that snapshot describes the kernel-side stub, not the caller. A child built from it resumes at a kernel address. That is why the old ARCH_HAVE_FORK excluded those modes.

The last four commits record the frame of the caller instead. arm_syscall(), dispatch_syscall() on arm64, and x86_64_syscall() store the exception frame in xcp.sregs. arm_fork(), arm64_fork() and x86_64_fork() then build the child from it. This mirrors riscv_swint.c. Cortex-M is not affected, because BUILD_KERNEL needs ARCH_USE_MMU.

Companion pull request

The companion apache/nuttx-apps#3685 divides the fork test of ostest in two. It must merge after this PR. It does not block CI here. apache/nuttx-apps#3673 fixed everything in apps that called fork() without a condition, and it merged on 2026-08-03, so apps master builds against this branch. Between the two merges, ostest has no fork test. That is the cost of carrying no compatibility layer.

Testing

Host: macOS 15 (Darwin 25.5.0) on Apple Silicon. QEMU 11.0.3, xPack riscv-none-elf-gcc 14.2.0-3, Arm GNU arm-none-eabi-gcc and aarch64-none-elf-gcc 14.2.rel1, xtensa-esp32s3-elf-gcc 12.2.0, x86_64-elf-gcc 16.1.0.

tools/checkpatch.sh -c -u -m -g gives no errors. It is also clean with -b, so the breaking change label can be applied.

I built and ran every configuration with apache/nuttx-apps#3685, and started ostest from NSH. The rev column gives the revision that each row ran at.

target mode result rev
raspberrypi-pico-2:nsh — real RP2350, Cortex-M33 flat vfork() passes 9093ae5
esp32s3-devkit:ostest — real ESP32-S3, Xtensa LX7 flat boots, no fork code is linked, because Xtensa selects neither primitive 9093ae5
qemu-armv7a:nsh flat vfork() passes 151331f
qemu-armv7a:knsh kernel vfork() passes — new 151331f
qemu-armv8a:nsh flat vfork() passes 151331f
qemu-armv8a:citest_smp — 4 CPUs flat vfork() passes 9093ae5
qemu-armv8a:knsh kernel vfork() passes — new 151331f
fvp-armv8r:pnsh protected builds — new; QEMU has no Cortex-R82, so nobody can run it here 151331f
qemu-armv8a:pnsh protected vfork() passes — new; needs the board of the follow-up branch, see below bc2cf4c
mps2-an500:nsh flat vfork() passes 9093ae5
mps2-an500:knsh protected vfork() passes 9093ae5
mps2-an521:nsh flat vfork() passes 9093ae5
mps3-an547:nsh flat vfork() passes 9093ae5
mps3-an547:knsh protected deadlocks, and the baseline is also broken, see below 9093ae5
lm3s6965-ek:qemu-flat flat vfork() passes 9093ae5
rv-virt:nsh, rv-virt:flats flat vfork() passes 9093ae5
rv-virt:smp — 8 CPUs flat vfork() passes 9093ae5
rv-virt:pnsh protected vfork() passes 9093ae5
rv-virt:knsh_romfs kernel vfork() passes 9093ae5
rv-virt:nsh64 flat vfork() passes fcf49da
rv-virt:knsh64 kernel builds, and exercises the saved-syscall-frame path of RISC-V fcf49da
qemu-intel64:nsh and :ostest flat vfork() passes under TCG, see below fcf49da
qemu-intel64:knsh_romfs kernel vfork() passes — new bc2cf4c
sim:nsh flat vfork() passes fcf49da
stm32f4discovery:nsh flat builds with -Werror, and I checked the disassembly of up_fork 9093ae5
user_main: vfork() test
vfork_test: Started
vfork_test: Child 5 ran and exited before the parent resumed

fork_test is absent from every run. I searched all the run logs for user_main: fork() test and found none. That is the purpose of the change.

The protected and kernel builds generate the correct system call glue for the new argument:

pid_t up_fork(bool parm1) { return (pid_t)sys_call1((unsigned int)SYS_up_fork, (uintptr_t)parm1); }
uintptr_t STUB_up_fork(int nbr, uintptr_t parm1) { return (uintptr_t)up_fork((bool)parm1); }

The SMP row found a defect. nxtask_exit() increases rtcb->lockcount directly, not through sched_lock(). The nxsem_post() that wakes the vfork() parent therefore leaves it in the list where a task collects while pre-emption is off, and the matching decrease does not publish it. An earlier revision handled only !CONFIG_SMP, because I believed that SMP has no pending list. SMP has no pending list, but the task collects in g_readytorun instead, and it still needs to be published. qemu-armv8a:citest_smp stopped as soon as the vfork() test ran. The fix does what sched_unlock() already does in each case: nxsched_merge_pending(), or nxsched_deliver_task() under CONFIG_SMP.

One result that is not clean

mps3-an547:knsh, which is armv8-m protected, stops in vfork_test. It prints Started, and then every task is blocked and the CPU stays in up_idle(). That configuration is already broken at the baseline d960bc39ee, where the same test fails with Invalid PC load, caused by an invalid PC load by EXC_RETURN, PC: 00000006, at armv8-m/arm_usagefault.c:116. This branch neither causes the fault nor repairs it. It changes a hard fault into a stop. It deserves attention, because armv7-m protected (mps2-an500:knsh) passes.

Failures that exist before this change

I verified each of these against an unmodified baseline.

  • ostest does not run to the end on any target. It stops in timedmutex_timeout_regression_test() at timedmutex.c:185, which apps master added in eea8384ff. This is why the fork tests moved to the top of user_main(). Otherwise they never run.
  • lm3s6965-ek:qemu-protected cannot boot. The kernel image is larger than its 128 KiB kflash region and overlaps the user image. QEMU refuses to start. ld does not report it, because the overflow is in the AT > kflash load region of .data. The baseline is also above the limit. This branch adds 124 bytes.
  • lm3s6965-ek:qemu-kostest cannot boot. DEBUGASSERT(pid > 0) fails at nx_bringup.c:449. The baseline fails in the same way.
  • rv-virt:knsh prints nothing. The baseline prints nothing too. The rv32 kernel row uses rv-virt:knsh_romfs instead, which passes.
  • qemu-intel64 needs three configuration changes under TCG, which is the only choice on a host that is not x86. Disable ARCH_INTEL64_HAVE_PCID and ARCH_INTEL64_TSC_DEADLINE, enable ARCH_INTEL64_HPET_ALARM, and start QEMU with -machine pc,hpet=on -cpu max.
  • The console of mps3-an547 does not work as shipped. CONFIG_CMSDK_UART0_TX_IRQ=49 and RX_IRQ=50 are exchanged with respect to the QEMU model. Exchange them and the console works. That is how I obtained the two an547 rows. This has no relation to fork, but it stops all an547 tests under QEMU.

Work that follows this PR

Two items are known and are not in this patch. I keep them separate to hold the size of this one down.

A runnable ARM64 protected board. The only ARM64 protected configurations in the tree are the ARMv8-R FVPs, and QEMU has no Cortex-R82, so nobody can run them. I have a qemu-armv8a:pnsh configuration, protected over an MMU, on the branch arm64-qemu-protected. vfork() passes there. It adds a board, a user linker script and a userspace initialisation file, which is too much to put in a semantics patch. It will follow as its own PR.

The armv8-m protected stop. mps3-an547:knsh stops in vfork_test, and the baseline fails at the same place with a usage fault. The defect is older than this PR. I will send the fix separately, because it belongs to armv8-m and not to the fork family.

Please test vfork() on your board

CI builds this branch. It does not run vfork() on your hardware. This patch changes the lowest level of the system: address environments, stack setup and the register context of each architecture. I would rather leave this open and collect evidence than merge it on my test matrix alone.

vfork() is now available on ARM 32-bit, ARM64, RISC-V and x86_64 in every build mode, on SIM except under Windows, and on MIPS32.

Two rows need attention most. The ARM 32-bit, ARM64 and x86_64 kernel builds are new, and I ran them only under QEMU, so a report from real silicon is the most useful thing you can send. The armv8-m protected build stops, and it also stops at the baseline.

To test, take this branch together with apache/nuttx-apps#3685, build your configuration and run ostest. The vfork() test is the first output. Please report the target, the build mode and the three lines above.

A report that the test fails is as useful as a report that it passes. Please also say if your architecture has vfork() in a build mode that my table does not cover.

@github-actions github-actions Bot added Area: Documentation Improvements or additions to documentation Arch: arm Issues related to ARM (32-bit) architecture Arch: arm64 Issues related to ARM64 (64-bit) architecture Arch: ceva Issues related to CEVA architecture Arch: mips Issues related to the MIPS architecture Arch: risc-v Issues related to the RISC-V (32-bit or 64-bit) architecture Arch: simulator Issues related to the SIMulator Arch: x86_64 Issues related to the x86_64 architecture Size: XL The size of the change in this PR is very large. Consider breaking down the PR into smaller pieces. labels Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

MemBrowse Memory Report

arduino-mega2560

  • flash: .text +6 B (+0.0%, 67,622 B / 262,144 B, total: 26% used)

esp32-devkitc

  • ROM: .flash.text +8 B (+0.0%, 124,396 B / 4,194,272 B, total: 3% used)
  • irom0_0_seg: .flash.text +8 B (+0.0%, 88,600 B / 3,342,304 B, total: 3% used)

hifive1-revb

  • flash: .text +16 B (+0.0%, 83,372 B / 4,194,304 B, total: 2% used)
  • sram: .bss +32 B (+0.8%, 3,964 B / 16,384 B, total: 24% used)

mirtoo

  • kseg0_progmem: .text +52 B (+0.1%, 67,476 B / 131,072 B, total: 51% used)

qemu-armv8a

  • Code: .rodata -306 B, .text.nxsched_release_tcb +8 B, .text.nxtask_exit +12 B, .text.pthread_start -4 B, .text.up_initial_state -4 B, .text.user_main -16 B (-0.6%, 316,716 B)
  • Data: .bss.g_idletcb +8 B (+0.0%, 76,977 B)

qemu-intel64

  • Code: .text -1,562 B (-0.0%, 8,657,184 B)
  • Data: .bss +64 B, .rodata -278 B (-0.2%, 120,593 B)

s698pm-dkit

  • Code: .text +16 B (+0.0%, 363,952 B)

stm32-nucleo-f103rb

  • flash: .text +32 B (+0.1%, 34,016 B / 131,072 B, total: 26% used)
    No memory changes detected for:
  • rx65n-rsk2mb

@acassis acassis linked an issue Jul 27, 2026 that may be closed by this pull request
1 task
@casaroli

casaroli commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

I was planning on having one follow up PR for each relevant architecture, however I now think maybe we should keep one of the architectures that supports fork() (an MMU one) in this PR as a sample and that anyone can replicate the results. I am happy to follow any direction..

these are MMU capable, in order of complexity (low to high):

  • armv8-a
  • armv7-a
  • riscv
  • xtensa LX7 (esp32s3)
  • sim (could be simpler but I did not investigate enough
  • x86_64
  • x86

@casaroli
casaroli force-pushed the fork-semantics-core branch 5 times, most recently from f8d329e to e5675dc Compare July 28, 2026 07:11

@jerpelea jerpelea left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

please replace
Co-Authored-By: Claude Opus 5 (1M context) [email protected]
with
Assisted-by: Claude Opus 5 (1M context) [email protected]

@casaroli
casaroli force-pushed the fork-semantics-core branch 4 times, most recently from 0132031 to 9b787d4 Compare July 30, 2026 09:47
@casaroli
casaroli marked this pull request as ready for review July 30, 2026 11:31
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 2, 2026
NuttX implements fork() and vfork() as the same function and is gaining
three separate primitives -- see apache/nuttx#19562: task_fork() (shares
memory, private stack copy, both running), vfork() (shares memory, parent
suspended until _exit()/exec()) and POSIX fork() (child gets its own copy).
This gives each one a test of its own.

ostest's "vfork" test was never testing vfork().  It has the child write a
global and the parent observe the write -- the defining property of
*sharing*, not of vfork(), whose defining property is that the parent is
suspended and whose contract forbids the child to write anything at all.  It
is renamed to task_fork.c, unchanged, because that is the primitive it has
always described.

vfork.c is rewritten to test what vfork() promises.  The child does only what
POSIX permits: it calls _exit(42) and nothing else, not even exit(), which
would run atexit handlers and flush stdio in the parent's address space.
Since the child may not write memory and the parent cannot run while the
child lives, the observable is the child's exit status -- had the parent not
been suspended it would have reached waitpid() while the child was still
alive.  Where child status is not retained, because ostest_main() sets
SA_NOCLDWAIT for the whole run, ECHILD is accepted as equally good evidence.

fork.c is new and tests POSIX fork(): the child's writes to .data, .bss and
the heap are invisible to the parent and vice versa, a pointer to a stack
local taken before the fork names the same object in both, and the child does
everything a vfork() child may not -- calls malloc() and printf(), and
returns from the function that called fork().

All three run at the top of user_main().  They exercise the lowest-level
machinery in the suite -- address environments, stack setup, the
architecture's register context -- so a fault in one takes the process down
instead of reporting a failure.  Learning that in seconds rather than after
everything else has passed matters when a port is being brought up.

Each test gates on the one primitive it tests and nothing stands in for
anything.  task_fork_test() keys on CONFIG_TASK_FORK rather than the
capability symbol: ARCH_HAVE_TASK_FORK says the architecture can clone a
task, TASK_FORK says this build asked for it, and task_fork() is declared
only under the latter.  vfork_test() and fork_test() have no such split and
key on ARCH_HAVE_VFORK and ARCH_HAVE_FORK directly.

The other in-tree callers are audited for which primitive they meant:
python's _posixsubprocess and libwebsockets' LWS_HAVE_WORKING_VFORK want the
fork-then-exec path, so they follow ARCH_HAVE_VFORK; python's os.fork() and
libwebsockets' LWS_HAVE_FORK mean real fork() and stay on ARCH_HAVE_FORK, so
they become absent rather than silently wrong; fdsantest's vfork case follows
vfork().

Depends on apache/nuttx#19562 and must not merge before it.

Assisted-by: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Marco Casaroli <[email protected]>
acassis pushed a commit to apache/nuttx-apps that referenced this pull request Aug 3, 2026
Two places call fork() from code that is compiled unconditionally, which is
fine only for as long as every architecture provides it.  NuttX is splitting
fork() into three primitives -- see apache/nuttx#19562 -- after which
ARCH_HAVE_FORK announces POSIX fork() specifically, and is off until an
architecture implements it.  Both then fail to link.  Each is dropped only
where ARCH_HAVE_FORK is unset, so builds that have fork() are unaffected.

system/libuv: test-fork.c and test-pipe-close-stdout-read-stdin.c are
filtered out of the test-*.c glob.  Nothing is lost even where they are
dropped: every test they define is already excluded from the task list on
NuttX by 0001-libuv-port-for-nuttx.patch, which extends the _WIN32 guards
around them to __NuttX__ -- all nine fork_* entries and
pipe_close_stdout_read_stdin.  They are compiled today but never run.

testing/ltp: the open_posix_testsuite is filtered through LTP's existing
BLACKWORDS mechanism, which already drops tests for absent features and is
already conditioned on configuration symbols.  The pattern spares vfork()
and task_fork().  Where fork() is absent this drops 278 of 1943 test files;
those tests exercise fork() and cannot link without it, and they return per
architecture as fork() lands.

Against today's master this is a no-op: ARCH_HAVE_FORK is set everywhere, so
neither filter drops anything.  It is part of what lets the NuttX side build
against apps master.

Assisted-by: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Marco Casaroli <[email protected]>
acassis pushed a commit to apache/nuttx-apps that referenced this pull request Aug 3, 2026
…ground.

Neither of these wants fork() semantics.  Both reach for fork() only to put
work in the background, and each has a NuttX-native way to do that, so
neither needs a fork primitive at all -- which matters once apache/nuttx#19562
makes ARCH_HAVE_FORK conditional on the architecture implementing POSIX
fork().

netutils/dropbear: the port already routes every fork-then-exec through
vfork(), because sysoptions.h selects DROPBEAR_VFORK when HAVE_FORK is
undefined and the port leaves it undefined.  spawn_command() in dbutil.c and
both call sites in scp.c follow that switch.  The one exception is the
daemon() fallback that compat.c compiles under #ifndef HAVE_DAEMON, which
calls fork() directly and bypasses it.  NuttX provides daemon() in
libs/libc/unistd/lib_daemon.c and declares it in unistd.h, so the fallback is
redundant; define HAVE_DAEMON alongside the HAVE_STRLCAT and HAVE_STRLCPY
entries that are there for exactly the same reason.  The code was unreachable
in any case -- the port hands svr_getopts() an argv containing -F, so
svr_opts.forkbg is always zero and dropbear never calls daemon() at all.

testing/drivers/nand_sim: forked so that the parent could return to the shell
while the child registered the MTD device and slept forever.  Nothing from
before the fork is used after it, so the child is a self-contained entry
point, and task_create() expresses that directly.  The emulator body moves
into nand_sim_daemon() unchanged.  TESTING_NAND_SIM therefore needs no fork
dependency, and the two sim configurations that enable it keep working
whatever ARCH_HAVE_FORK is set to.

Assisted-by: Claude Opus 5 (1M context) <[email protected]>
Signed-off-by: Marco Casaroli <[email protected]>
@casaroli
casaroli force-pushed the fork-semantics-core branch from 82f8e8b to 9132156 Compare August 8, 2026 07:48
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 8, 2026
ostest's "vfork" test was never testing vfork().  It has the child write a
global and the parent observe the write -- the defining property of *sharing*,
not of vfork(), whose defining property is that the parent is suspended and
whose contract forbids the child to write anything at all.  It passed because
NuttX implemented fork() and vfork() as the same sharing primitive, which
apache/nuttx#19562 separates.

vfork.c is rewritten to test what vfork() promises.  The child does only what
POSIX permits -- it calls _exit(42) and nothing else, not even exit(), which
would run atexit handlers and flush stdio in the parent's address space.  Since
the child may not write memory and the parent cannot run while the child lives,
the observable is the child's exit status:  had the parent not been suspended,
it would have reached waitpid() while the child was still alive.  Where child
status is not retained -- ostest_main() sets SA_NOCLDWAIT for the whole run,
deliberately -- ECHILD is accepted as equally good evidence, since it says the
child was already gone when the parent asked.

fork.c is new and tests POSIX fork():  the child's writes to .data, .bss and
the heap are invisible to the parent and vice versa, a pointer to a stack local
taken before the fork names the same object in both, and the child does
everything a vfork() child may not -- calls malloc() and printf(), and returns
from the function that called fork().

Both run at the top of user_main().  They exercise the lowest-level machinery
in the suite -- address environments, stack setup, the architecture's register
context -- so a fault in one takes the process down instead of reporting a
failure.  Learning that in seconds rather than after everything else has passed
matters when a port is being brought up.

Each test gates on the one primitive it tests, ARCH_HAVE_VFORK and
ARCH_HAVE_FORK respectively.  There is no compatibility layer and no mapping
between symbols.  vfork.c no longer requires SCHED_WAITPID:  the suspension is
in the kernel primitive now, so the test's core assertion holds without it and
only the status check is conditional.

The other in-tree callers are audited for which primitive they actually meant:

* interpreters/python's _posixsubprocess and netutils/libwebsockets'
  LWS_HAVE_WORKING_VFORK want the fork-then-exec path -- ARCH_HAVE_VFORK.
* python's os.fork() and libwebsockets' LWS_HAVE_FORK mean real fork() and stay
  on ARCH_HAVE_FORK, so they become *absent* rather than silently wrong.
* testing/fs/fdsantest's vfork case follows ARCH_HAVE_VFORK.

interpreters/bas is deliberately left alone.  Its SHELL and EDIT statements
reach for vfork() under an ARCH_HAVE_FORK guard and want the same treatment,
but checkpatch.sh checks the whole of any file a patch touches and
bas_statement.c produces 1681 pre-existing findings against master, so a
one-line change there fails CI on its own.  The consequence is small:
EXAMPLES_BAS_SHELL is EXPERIMENTAL and already depends on ARCH_HAVE_FORK, so it
becomes unselectable rather than misbehaving.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
@casaroli casaroli changed the title sched/arch/libc: give fork(), vfork() and task_fork() their real, separate semantics !sched/arch/libc: Give fork() and vfork() their real, separate semantics. Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

❌ Cross-repo dependency could not be applied

The Build report says the declared dependency PR(s) could not be applied, so CI did not run against the combined code:

Reason: cherry-pick failed (if your PR has merge commits, rebase instead)

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

@casaroli
casaroli marked this pull request as draft August 8, 2026 07:51
@casaroli
casaroli force-pushed the fork-semantics-core branch from 9132156 to df3ccde Compare August 8, 2026 10:10
Comment thread arch/arm/src/common/gnu/fork.S Outdated
Comment thread libs/libc/libc.csv
Comment thread arch/Kconfig Outdated
Comment thread arch/Kconfig Outdated
Comment thread arch/Kconfig Outdated
Comment thread arch/x86_64/src/common/fork.S Outdated
Comment thread arch/risc-v/src/common/fork.S Outdated
Comment thread include/nuttx/sched.h Outdated
Comment thread sched/task/task_fork.c
@casaroli
casaroli force-pushed the fork-semantics-core branch from df3ccde to 9093ae5 Compare August 9, 2026 12:22
Comment thread arch/arm/src/common/gnu/fork.S
Comment thread sched/sched/sched.h Outdated
Comment thread arch/sim/src/sim/sim_fork_arm.S
Comment thread arch/sim/src/sim/sim_fork_arm.S Outdated
Comment thread arch/arm64/src/common/arm64_fork.c Outdated
Comment thread arch/sim/src/sim/sim_fork_x86.S Outdated
…ics.

NuttX implemented fork() and vfork() as the same function.  Both were libc
wrappers around a single up_fork() syscall; vfork() differed only by a
trailing waitpid().  Underneath, the child joined the parent's address
environment -- the same addrenv_join() that pthread_create() uses -- and got
a private copy of the stack.  So the child shared .data, .bss and the heap
with its parent and ran concurrently with it.

That is not fork().  It is vfork()-with-a-private-stack under fork()'s name,
and the history says so: today's fork() is NuttX's old vfork(), renamed in
c33d1c9 (2023) without any change of behaviour.  The failure was silent --
a program written against POSIX fork() compiled, ran, and had its child's
writes land in the parent's variables.

Separate them into two primitives, chosen by which function the caller
called rather than by what the hardware happens to be:

  fork()   child gets its own copy of the parent's memory at the same
           virtual addresses; runs concurrently.  Only where an address
           environment can be duplicated -- elsewhere it is not declared at
           all, so calling it is a build error naming the function.
  vfork()  child shares the parent's memory; parent suspended until the
           child _exit()s or exec()s.  Implementable everywhere.

Below libc there is still one syscall.  up_fork() gains a bool saying which
primitive the caller used, since the per-architecture register snapshot is
the same for both, and passes it to nxtask_setup_fork(), which is the single
place the memory semantics are decided.  The argument arrives in the first
argument register and is never touched:  each architecture's snapshot takes
some other call-clobbered register for its scratch, so the flag is simply
still there when the C worker is called.

The vfork() parent suspension moves out of libc into nxtask_start_fork(),
released from nxsched_release_tcb() by nxtask_resume_vfork().  Two things
follow: the parent is resumed at exec(), since exec_swap() has already handed
the child's pid to the loaded program by the time the vfork stub exits, and
vfork() no longer depends on CONFIG_SCHED_WAITPID.

Releasing there requires one fix in nxtask_exit().  It raises rtcb->lockcount
directly rather than through sched_lock() while it tears the TCB down, so the
nxsem_post() that wakes the vfork() parent leaves it queued where a blocked
task collects while pre-emption is off -- g_pendingtasks, or g_readytorun on
SMP -- and the matching raw lockcount-- does not publish it the way
sched_unlock() would, leaving the parent stranded with nothing to move it on.
The fix mirrors sched_unlock() for each case:  nxsched_merge_pending(), or
nxsched_deliver_task() under CONFIG_SMP.  Both are no-ops while pre-emption is
still disabled, and up_exit() re-reads this_task() afterwards, so a change of
the ready-to-run head is honoured.  Without it vfork() deadlocks wherever no
other task happens to call sched_unlock() afterwards -- rv-virt:nsh64 and
rv-virt:pnsh64, where NSH is blocked in waitpid() holding the lock, and
qemu-armv8a:citest_smp, which hangs the moment the vfork() test runs.

fork() is built on a new addrenv_fork(), backed by an up_addrenv_fork() hook
that duplicates an address environment into freshly allocated pages mapped at
the same virtual addresses -- unlike up_addrenv_clone(), which copies only
the representation and leaves both pointing at the same page tables.  The
child then adopts the parent's stack geometry rather than being given a
relocated copy: a pointer to a stack local taken before fork() must name the
same object in the child that it named in the parent, and the parent's stack
is already in the duplicate, with its contents, at the parent's address.

No architecture implements up_addrenv_fork() yet, so this commit leaves
fork() unavailable everywhere.  That is the intended state.  It withdraws
fork() from ARCH_ARM, flat ARCH_ARM64, ARCH_RISCV, ARCH_SIM and ARCH_X86_64,
where until now it named the sharing primitive; per-architecture patches
restore it, with POSIX semantics, as up_addrenv_fork() lands.  In the
meantime the sharing primitive is still there under the name that describes
it: vfork() for a child that runs a program, pthread_create() for a second
flow of control that shares memory, posix_spawn() for both at once.

Kconfig: ARCH_HAVE_VFORK inherits ARCH_HAVE_FORK's select lines, conditions
included, so no configuration gains machinery; ARCH_HAVE_FORK is redefined to
mean "can provide POSIX fork() semantics" and now depends on ARCH_ADDRENV.

There is one deliberate departure from "verbatim".  ARCH_ARM selected the
fork family unconditionally, BUILD_KERNEL included, and that has never
worked:  on a kernel build the architecture's fork entry point sees the
kernel's return address and stack pointer rather than the caller's, so the
child resumes at a kernel address.  On qemu-armv7a:knsh master faults in
ostest's fork case with "Child did not run" and then a data abort; without
the condition this change faults the same way through vfork().  ARCH_ARM64
and ARCH_X86_64 already carried "if !BUILD_KERNEL" for exactly this reason --
ARM was the outlier.  Conditioning it turns a runtime fault into an honest
absence, which is the whole point of the change; arch/arm takes the condition
off again in the patch that adds its saved-syscall-frame path.  Only the
MMU-capable ARM ports are affected, since Cortex-M cannot build BUILD_KERNEL
at all.

Also fixes two latent syntax errors found on the way: a missing comma in
riscv_fork.c and mips_fork.c, both in *_FRAMEPOINTER && !SAVE_GP branches
that are never compiled today.

BREAKING CHANGE: fork() is withdrawn from every architecture.  It is no
longer declared in unistd.h, so code that calls it fails to build with an error
naming the function, and the sharing behaviour it used to have is gone rather
than renamed.  CONFIG_ARCH_HAVE_FORK no longer means "fork() exists"; it means
"this configuration can provide POSIX fork() semantics", and no architecture
selects it yet.

Quick fix, chosen by why the call was made:

  to run a program                vfork() + exec*(), or better posix_spawn()
  a second flow of control that   pthread_create()
  shares the caller's memory
  a genuinely independent copy    keep fork(), and wait for the per-arch patch
  of the process                  that implements up_addrenv_fork() and selects
                                  CONFIG_ARCH_HAVE_FORK

Out-of-tree code that tests CONFIG_ARCH_HAVE_FORK to decide whether a
fork-then-exec path is available wants CONFIG_ARCH_HAVE_VFORK instead, which is
selected in exactly the places CONFIG_ARCH_HAVE_FORK used to be.  The full
migration guide is Documentation/guides/fork_vfork_migration.rst.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
Documentation/guides/fork_vfork_migration.rst is new.  It says what changed
and why, gives the two primitives as a table, states plainly what breaks, and
answers "which replacement do I want?" from the reader's own reason for having
called fork() -- posix_spawn() or vfork() to run a program, pthread_create()
for a second flow of control that shares memory, fork() itself for an
independent copy.  It also documents the two configuration symbols, what an
architecture has to implement to gain real fork(), and the one visible
consequence of moving the vfork() suspension into the kernel: a waitpid()
after a child that _exit()s can only report status where
CONFIG_SCHED_CHILD_STATUS is enabled.

reference/user/01_task_control.rst gains an entry for fork() and rewrites the
one for vfork(), which described NuttX's limitations rather than the
interface's contract.  standards/posix.rst moves fork() from "No" to "Cond."
and vfork() from "Yes" to "Cond.", both being conditional on the configuration
now.  implementation/memory_configurations.rst no longer lists fork() as
unimplementable in the presence of address environments, which was the whole
point of that section's wish list.  Three long-standing typos in that file are
corrected while touching it, since codespell checks the whole of any file a
patch modifies.

BREAKING CHANGE: this commit carries no code; it is the migration guide for
the fork() withdrawal in the commit before it, and is marked so that every
commit in the series carries the marker CONTRIBUTING.md 1.13 requires.  The
quick fixes are in Documentation/guides/fork_vfork_migration.rst.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
@casaroli
casaroli force-pushed the fork-semantics-core branch from 9093ae5 to fcf49da Compare August 10, 2026 07:16
@casaroli

Copy link
Copy Markdown
Contributor Author

@xiaoxiang781216 i addressed all the comments. can you please check again? When you are ok with the changes, I will send an email to the list asking for more people to test vfork(). I will have a follow up pr enabling vfork() on xtensa, fork() will come per supported architecture on follow up PRs

…mv7-a.

In a kernel build the cloning primitives are reached through a system call,
and armv7-a dispatches one by re-pointing the caller's own exception frame at
dispatch_syscall() and switching to the task's kernel stack.  The snapshot the
entry point in fork.S takes for itself therefore describes the kernel-side
stub, and the frames below it are on a stack the child gets no copy of:  a
child built from that snapshot resumes at a kernel address with a stack
pointer into its own user stack.  It faulted with a prefetch abort at PC 0 on
qemu-armv7a:knsh, which is why the fork family had never been run there.

Record what the caller was actually doing instead.  arm_syscall() stores the
exception frame of the outermost system call in xcp.sregs, mirroring
riscv_swint.c, and arm_fork() chooses where the caller's registers live:

  arm_fork_syscall()  when a user stack pointer is saved, rebuilding the child
                      from xcp.sregs so that it returns from the very same SVC
                      as the parent, in the same mode, on its own stack and
                      with no inherited system call nesting;
  arm_fork_direct()   otherwise -- the flat build, a kernel thread in any
                      build, and a build without a kernel stack, where the
                      call is dispatched on the caller's own stack so the
                      caller's frames are copied along with the kernel-side
                      ones.

Note that the discriminator is xcp.ustkptr rather than TCB_FLAG_SYSCALL.  On
armv7-a the caller is the task that runs the kernel side of its own system
call, so being in a system call is not by itself a reason to distrust the
snapshot; the switch to the kernel stack is.  Because arm_syscall() has
already re-pointed the frame by the time arm_fork() runs, the caller's PC,
CPSR and SP come from where arm_syscall() put them -- syscall[0].sysreturn,
syscall[0].cpsr and ustkptr -- and the rest from the frame itself.

Nothing selects the primitives on an ARM kernel build yet, so this commit
changes no configuration; it is what the next one needs to be correct.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
ARCH_ARM has selected ARCH_HAVE_VFORK only "if !BUILD_KERNEL" since the
fork()/vfork() split.  That condition was deliberate but temporary:  it was
added because the fork family had never worked on a 32-bit ARM kernel build --
the entry point in fork.S snapshots the kernel-side stub rather than the
caller, so a child resumes at a kernel address -- and said in as many words
that "arch/arm takes the condition off again in the patch that adds its
saved-syscall-frame path".

That patch is the one before this.  arm_syscall() records the caller's
exception frame in xcp.sregs and arm_fork() builds the child from it, so the
condition has nothing left to protect against.

Cortex-M is unaffected either way -- BUILD_KERNEL depends on ARCH_USE_MMU,
which it does not have -- so the only configurations this changes are the
MMU-capable ARM ports, which are exactly the ones the previous commit fixed.

Verified on qemu-armv7a:knsh under qemu-system-arm:  ostest's vfork_test
passes, where before the change vfork() was absent.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
In a kernel or protected build vfork() is reached through a system call, so
the return address and stack pointer that the entry point in
arm64_fork_func.S can snapshot for itself belong to the kernel-side stub, not
to the caller.  A child built from that snapshot resumes at a kernel address
on a kernel stack.  This is why arm64 selected the fork family only for the
flat build.

Record what the caller was actually doing instead.  arm64_sync_exc passes the
exception frame to dispatch_syscall() in x7 -- x0-x6 carry the call number and
its six parameters, so x7 is free -- and dispatch_syscall() stores it in
xcp.sregs, mirroring what riscv_swint.c does.

arm64_fork() then chooses where the caller's registers live:

  arm64_fork_syscall()  when TCB_FLAG_SYSCALL is set, rebuilding the child
                        from xcp.sregs so that it returns from the very same
                        SVC as the parent;
  arm64_fork_direct()   otherwise, which is the flat build and any kernel
                        thread that calls the entry point as a plain function.

The stack copy and the relocation of pointers into it are shared by both
paths in arm64_fork_stack() and arm64_fork_reloc().

With that in place ARCH_ARM64 can select ARCH_HAVE_VFORK unconditionally.

Verified on qemu-armv8a:knsh (BUILD_KERNEL), qemu-armv8a:nsh (BUILD_FLAT) and
qemu-armv8a:citest_smp under qemu-system-aarch64:  ostest's vfork_test passes
on all three, and it was absent from knsh before the change.  The protected
configurations are build-verified only (fvp-armv8r:pnsh), there being no
emulator for them here.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
In a kernel build vfork() is reached through a system call, so the return
address and stack pointer the architecture's entry point can see for itself
are the kernel's, not the caller's.  A child built from those resumes at a
kernel address, which is why x86_64 selected the fork family only for the
flat build.

x86_64_syscall() now publishes the caller's frame in xcp.sregs for the
duration of the stub call, and x86_64_fork() builds the child from it:

  x86_64_fork_syscall()  when xcp.sregs is non-NULL, so that the child
                         returns from the very same `syscall' instruction as
                         the parent, in user mode, on its own stack;
  x86_64_fork_direct()   otherwise, which is the flat build and any kernel
                         thread that calls the entry point as a plain
                         function.

The discriminator is xcp.sregs rather than TCB_FLAG_SYSCALL, which arm64 and
RISC-V use:  that flag also defers signal actions, x86_64 has never raised it,
and its kernel-build signal path does not survive being made to -- a
pre-existing problem that does not belong to this work.

Two properties of SYSCALL/SYSRET shape the child's frame.  The instruction
leaves the caller's RIP and RFLAGS in RCX and R11 rather than on a stack, so
they are moved into the RIP and RFLAGS slots of the interrupt frame the child
is resumed from; and the hardware never records the caller's CS and SS at all,
SYSRETQ reconstructing them from IA32_STAR, so the child's are filled in with
the user code and data selectors at RPL 3.  The frame is therefore not copied
wholesale:  the extended state and the general registers are inherited, while
the segment registers and the thread pointer stay as up_initial_state() left
them, the child's stack being a fresh allocation the parent's FS base does not
describe.

x86_64_fork_relocfp() is new and is not optional here.  A function returns
with `leave', which feeds the frame pointer into the stack pointer, so
relocating only the RBP the child resumes with gets it exactly one frame:
the next return loads a saved RBP still pointing into the parent's stack.

With that in place ARCH_X86_64 can select ARCH_HAVE_VFORK unconditionally.

Build-verified on qemu-intel64:knsh_romfs and qemu-intel64:ostest.  NuttX on
qemu-intel64 requires tsc-deadline and pcid, which TCG does not implement, so
it cannot be run on this host.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
@xiaoxiang781216

Copy link
Copy Markdown
Contributor

@xiaoxiang781216 i addressed all the comments. can you please check again? When you are ok with the changes, I will send an email to the list asking for more people to test vfork(). I will have a follow up pr enabling vfork() on xtensa, fork() will come per supported architecture on follow up PRs

if the patch is ready, it's better to remove the draft mark from pr.

@xiaoxiang781216
xiaoxiang781216 marked this pull request as ready for review August 10, 2026 11:30
casaroli added a commit to casaroli/nuttx-apps that referenced this pull request Aug 10, 2026
ostest's "vfork" test was never testing vfork().  It has the child write a
global and the parent observe the write -- the defining property of *sharing*,
not of vfork(), whose defining property is that the parent is suspended and
whose contract forbids the child to write anything at all.  It passed because
NuttX implemented fork() and vfork() as the same sharing primitive, which
apache/nuttx#19562 separates.

vfork.c is rewritten to test what vfork() promises.  The child does only what
POSIX permits -- it calls _exit(42) and nothing else, not even exit(), which
would run atexit handlers and flush stdio in the parent's address space.  Since
the child may not write memory and the parent cannot run while the child lives,
the observable is the child's exit status:  had the parent not been suspended,
it would have reached waitpid() while the child was still alive.  Where child
status is not retained -- ostest_main() sets SA_NOCLDWAIT for the whole run,
deliberately -- ECHILD is accepted as equally good evidence, since it says the
child was already gone when the parent asked.

fork.c is new and tests POSIX fork():  the child's writes to .data, .bss and
the heap are invisible to the parent and vice versa, a pointer to a stack local
taken before the fork names the same object in both, and the child does
everything a vfork() child may not -- calls malloc() and printf(), and returns
from the function that called fork().

Both run at the top of user_main().  They exercise the lowest-level machinery
in the suite -- address environments, stack setup, the architecture's register
context -- so a fault in one takes the process down instead of reporting a
failure.  Learning that in seconds rather than after everything else has passed
matters when a port is being brought up.

Each test gates on the one primitive it tests, ARCH_HAVE_VFORK and
ARCH_HAVE_FORK respectively.  There is no compatibility layer and no mapping
between symbols.  vfork.c no longer requires SCHED_WAITPID:  the suspension is
in the kernel primitive now, so the test's core assertion holds without it and
only the status check is conditional.

The other in-tree callers are audited for which primitive they actually meant:

* interpreters/python's _posixsubprocess and netutils/libwebsockets'
  LWS_HAVE_WORKING_VFORK want the fork-then-exec path -- ARCH_HAVE_VFORK.
* python's os.fork() and libwebsockets' LWS_HAVE_FORK mean real fork() and stay
  on ARCH_HAVE_FORK, so they become *absent* rather than silently wrong.
* testing/fs/fdsantest's vfork case follows ARCH_HAVE_VFORK.

interpreters/bas is deliberately left alone.  Its SHELL and EDIT statements
reach for vfork() under an ARCH_HAVE_FORK guard and want the same treatment,
but checkpatch.sh checks the whole of any file a patch touches and
bas_statement.c produces 1681 pre-existing findings against master, so a
one-line change there fails CI on its own.  The consequence is small:
EXAMPLES_BAS_SHELL is EXPERIMENTAL and already depends on ARCH_HAVE_FORK, so it
becomes unselectable rather than misbehaving.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <[email protected]>
@acassis
acassis merged commit 067e30e into apache:master Aug 10, 2026
38 of 54 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Arch: arm Issues related to ARM (32-bit) architecture Arch: arm64 Issues related to ARM64 (64-bit) architecture Arch: ceva Issues related to CEVA architecture Arch: mips Issues related to the MIPS architecture Arch: risc-v Issues related to the RISC-V (32-bit or 64-bit) architecture Arch: simulator Issues related to the SIMulator Arch: x86_64 Issues related to the x86_64 architecture Area: Documentation Improvements or additions to documentation 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.

[FEATURE] give fork() and vfork() their real, separate semantics

4 participants