Is your feature request related to a problem? Please describe.
Proposal: give fork() and vfork() their real, separate semantics
A note before I start. My apologies if this has been raised and discussed
before — I would not be surprised if it has, and I have no wish to relitigate
a settled decision for its own sake. What brings me to it now is concrete: I
want NuttX to work well on MMU-capable systems, and while implementing a real
fork() for one I found that the current design makes it impossible to do
coherently. That is my motivation for bringing the subject back. If there is
prior discussion I have missed, I would be glad to be pointed at it.
Summary
NuttX today implements fork() and vfork() as the same function. Both are
thin wrappers around a single up_fork(), there is no SYS_vfork and no
up_vfork(), and the shared implementation gives the child a copy of the
parent's stack, sharing everything else.
That is not fork(): the child does not get its own memory — writes by the
child are visible to the parent, and vice versa. What it is, is vfork() with
a private stack: a vfork() conforming in its memory semantics (a program
that honours vfork()'s restrictions cannot tell the difference there —
though the moment the parent resumes is a separate defect, §1.5), but
published under fork()'s name — and under that name, the sharing is simply
wrong. Nor is this a coincidence: today's fork() is NuttX's old vfork(),
renamed (§1.4).
This proposal is to separate them:
|
semantics |
availability |
fork() |
child gets its own copy of the parent's memory |
only where an address environment can be duplicated |
vfork() |
child shares the parent's memory, parent suspended until _exit()/exec() |
implementable everywhere, MMU or not |
And today's exact behaviour — shared memory, private stack, both running
concurrently — is not lost: it is retained under an honest, non-POSIX name,
task_fork() (§4.4), on the architectures and configurations that support
it today.
This is a breaking change. It is proposed anyway because the current
behaviour is silently wrong for fork() users, it is not portable across NuttX
targets, and it is the thing blocking correct fork() support on MMU-capable
systems.
A compatibility option, CONFIG_FORK_IS_TASK_FORK (§4.5), aliases fork()
back to task_fork(), keeping existing applications building and running
exactly as they do today on every configuration where fork() exists
today.
All code citations below are quoted verbatim from apache/nuttx master at
216edd74d
and apache/nuttx-apps master at
6ca1272b9;
every file reference links to the code at those commits, so the line numbers
are stable.
1. What NuttX does today
1.1 fork() and vfork() are wrappers around the same call
libs/libc/unistd/lib_fork.c:153-234
— both entry points call up_fork():
pid_t fork(void)
{
...
pid = up_fork();
...
return pid;
}
pid_t vfork(void)
{
...
pid = up_fork();
...
if (pid != 0)
{
/* we are in parent task, and we need to wait the child task
* until running finished or performing exec
*/
ret = waitpid(pid, &status, WNOWAIT);
...
}
return pid;
}
vfork() differs from fork() only by the parent's waitpid(). The memory
semantics are identical, because the underlying operation is identical. (The
WNOWAIT flag leaves the child unreaped, so the application's own
waitpid() still collects its status afterwards. Note in passing that
vfork() is only compiled under CONFIG_SCHED_WAITPID
(lib_fork.c:176)
— a configuration without that option has no vfork() at all — and that
suspending the parent with waitpid() is itself subtly wrong; see §1.5.)
There is exactly one syscall
(include/sys/syscall_lookup.h:116):
SYSCALL_LOOKUP(up_fork, 0)
There is no SYS_vfork and no up_vfork() in the tree. Below libc, the two
POSIX functions are indistinguishable, so no implementation can give them
different behaviour even if it wanted to.
1.2 The child shares the parent's memory
sched/task/task_fork.c:162-174:
#if defined(CONFIG_ARCH_ADDRENV)
/* Join the parent address environment */
if (ttype != TCB_FLAG_TTYPE_KERNEL)
{
ret = addrenv_join(parent, child);
...
}
#endif
addrenv_join() is the same call pthread_create() uses
(sched/pthread/pthread_create.c:238)
to give a thread the memory of its creator. The forked child is therefore
given the memory relationship of a thread.
The child then gets a new stack
(up_create_stack(), task_fork.c:201),
into which the architecture's fork code copies the parent's stack. So the child
has a private stack and shares everything else: .data, .bss, the heap.
On builds without CONFIG_ARCH_ADDRENV (flat and protected builds) there is
only one address space to begin with, so everything except the stack is shared
there too.
1.3 NuttX's own documentation already describes vfork()
The most direct evidence that the current function is vfork() wearing
fork()'s name is NuttX's own comment on nxtask_setup_fork()
(sched/task/task_fork.c:57-62):
The fork() function has the same effect as posix fork(), except that the
behavior is undefined if the process created by fork() either modifies
any data other than a variable of type pid_t used to store the return
value from fork(), or returns from the function in which fork() was
called, or calls any other function before successfully calling _exit()
or one of the exec family of functions.
Those three restrictions are, word for word, the definition of vfork() in
POSIX.1-2004. They are not restrictions that fork() has, or may have. NuttX
has documented fork() by quoting the specification of vfork().
1.4 How it got this way: vfork() was renamed to fork()
This is not an accident of implementation. It is recorded in the history.
Commit
c33d1c9c97
— "sched/task/fork: add fork implementation", guoshichao, 2023-07-05 — states
its own intent:
- as we can use fork to implement vfork, so we rename the vfork to fork,
and use the fork method as the base to implement vfork method
- create the vfork function as a libc function based on fork function
The diff is a mass rename. Twenty-two files were renamed rather than written:
arch/arm/src/common/{arm_vfork.c => arm_fork.c}
arch/arm/src/common/gnu/{vfork.S => fork.S}
arch/arm64/src/common/{arm64_vfork.c => arm64_fork.c}
arch/ceva/src/common/{ceva_vfork.c => ceva_fork.c}
arch/mips/src/mips32/{vfork.S => fork.S}
...
The Kconfig symbol was renamed too, for every architecture:
config ARCH_ARM
bool "ARM"
- select ARCH_HAVE_VFORK
+ select ARCH_HAVE_FORK
and so was the system call:
-#if defined(CONFIG_SCHED_WAITPID) && defined(CONFIG_ARCH_HAVE_VFORK)
- SYSCALL_LOOKUP(vfork, 0)
+#ifdef CONFIG_ARCH_HAVE_FORK
+ SYSCALL_LOOKUP(fork, 0)
with libs/libc/unistd/lib_vfork.c added on top as a new libc wrapper.
So today's fork() is, quite literally, NuttX's old vfork() under a new
name. No copy semantics were added — the implementation was not changed, only
what it is called.
Follow-ups completed the picture:
3524f4b9c
("libs/libc/fork: add lib_fork implementation", 2023-07-16) renamed the
syscall again, to the up_fork seen in §1.1;
79c7962af
("apps: Replace CONFIG_ARCH_HAVE_VFORK with CONFIG_ARCH_HAVE_FORK",
2023-08-13) propagated the rename through apps; and
2f2632338
("arch/libc: Integrate vfork into fork, and vfork directly call up_fork",
2024-10-09) removed the last remaining distinction below libc, leaving
vfork() as fork() plus a waitpid() (§1.1).
The premise in the commit message, "as we can use fork to implement vfork", is
sound in the direction it is stated: if you have a real fork(), you can build
vfork() on it. What happened was the reverse — the existing vfork() was given
fork()'s name, so NuttX ended up with vfork() implementing fork(), which
does not work in that direction.
That framing is worth keeping in mind when weighing this proposal: it is not
asking to change what fork() has always meant in NuttX. It is asking to undo a
rename, restore ARCH_HAVE_VFORK and SYS_vfork to the implementation that
actually is vfork(), and add a genuine fork() where one can be implemented.
1.5 Even as vfork(), the parent is resumed at the wrong moment
POSIX resumes a vfork() parent when the child calls _exit() or one of the
exec functions. NuttX's vfork() suspends the parent in waitpid() (§1.1),
which returns only when the child terminates — nothing in the exec path wakes
it.
Today the exec case appears to work only by accident: NuttX's execve() does
not overlay the calling process. It starts the new program as a separate task
with a new pid, and the calling task then exits
(sched/task/task_execve.c:115-138
— exec() followed by _exit(0)). It is the child's exit that releases the
parent, and the parent is left with no relationship to the program it just
launched. The file
says so itself:
the most serious of these is that the exec'ed task will not have the same
task ID as the vfork'ed function. So the parent function cannot know the ID
of the exec'ed task.
So the canonical vfork() + exec() + waitpid() idiom cannot actually be
written on NuttX: the pid the parent holds names a task that died at exec time,
and the real program runs under a pid the parent cannot learn. Repairing
exec()'s pid discontinuity is beyond this proposal's scope, but the vfork()
half — resuming the parent at exec rather than at termination — falls out
naturally once the suspension lives in the kernel primitive instead of a libc
waitpid() (§4.2, §7).
2. What POSIX says
2.1 fork()
POSIX.1-2017 fork():
The fork() function shall create a new process. The new process (child
process) shall be an exact copy of the calling process (parent process)
except as detailed below […]
The separation of the two processes' memory is the defining property. It is what
makes the following legal, and it is what essentially all fork()-using code
relies on:
- the child may modify any variable, and the parent will not see it;
- the parent may modify any variable after the fork, and the child will not see
it;
- the child may return from the function that called
fork();
- the child may call arbitrary functions —
malloc(), printf(), anything —
and run indefinitely;
- both processes may run concurrently.
The first two — the memory guarantees, the defining property — do not hold in
NuttX today. (The other three do, courtesy of the private stack copy; that
they hold is exactly what separates today's behaviour from classic vfork() —
see §4.4.)
2.2 vfork()
POSIX.1-2004 (SUSv3) vfork():
The vfork() function shall be equivalent to fork(), except that the
behavior is undefined if the process created by vfork() either modifies
any data other than a variable of type pid_t used to store the return value
from vfork(), or returns from the function in which vfork() was called, or
calls any other function before successfully calling _exit() or one of the
exec family of functions.
and, on why it exists at all:
The vfork() function differs from fork() only in that the child process
can share code and data with the calling process (parent process). This
speeds cloning activity significantly at a risk to the integrity of the parent
process if vfork() is misused.
Two things follow.
First, sharing is vfork()'s entire purpose. It exists to avoid the copy.
An implementation of vfork() that copies is conforming but pointless — and on
a system without an MMU, an implementation that copies is not possible at all.
vfork() is precisely the primitive that a no-MMU system can offer.
Second, the restrictions are the price of that sharing. They are not
arbitrary; they exist because the child is running in the parent's address space
on borrowed time. Applying those restrictions to fork() — as NuttX's
documentation does — inverts the relationship: it takes vfork()'s cost and
attaches it to fork()'s name, while delivering neither fork()'s guarantee
nor vfork()'s performance benefit.
vfork() was marked obsolescent in POSIX.1-2001 and removed in POSIX.1-2008.
That is an argument for not requiring it, not an argument for redefining it:
where it is provided, it should mean what it has always meant. It remains widely
implemented (Linux, the BSDs) for exactly the reason POSIX gives — it is the
cheap clone.
2.3 On "vfork() may be implemented as fork()"
It is often said, correctly, that a conforming implementation may implement
vfork() as fork(), because a program that depends on the sharing is invoking
undefined behaviour. This is true and it is not the situation NuttX is in.
NuttX has done the opposite: it implements fork() as vfork(). That
direction is not defensible under any reading of the standard. A program that
depends on fork()'s copy semantics is not invoking undefined behaviour — it is
using the function exactly as specified.
3. Why this matters
3.1 The failure is silent
A program that uses fork() correctly — as specified, as it works on Linux, on
the BSDs, on macOS — compiles and runs on NuttX and produces wrong results.
There is no compiler diagnostic, no link error, no runtime error. The child's
writes quietly land in the parent's variables.
Silent wrongness is the worst possible failure mode. A link error would be
strictly better.
3.2 Availability varies across NuttX targets; the semantics are wrong everywhere
CONFIG_ARCH_HAVE_FORK is selected inconsistently in arch/Kconfig:
So whether fork() exists at all depends on the architecture and the build
model: present on ARM in every build model, absent from ARM64 kernel and
protected builds and from x86_64 kernel builds, present on the Linux/macOS
simulator, absent on the Windows one. An in-tree application can test
CONFIG_ARCH_HAVE_FORK, but that is a NuttX-specific config symbol, and it
reports only that fork() exists — never that its semantics are not
POSIX's. There is no standard mechanism — no feature-test macro, no
sysconf() query — by which portable code can discover any of this. It cannot
even degrade gracefully: it links on one target and fails to link on the next.
And where fork() does exist, it always means "share". Even the simulator,
which runs on a host with a perfectly good fork(2), does not use it:
arch/sim/src/sim/sim_fork.c
performs the same snapshot-the-registers, copy-the-stack emulation as the
embedded targets — its own comment calls the stack copy a
"feeble effort to preserve the stack contents".
There is no NuttX target on which fork() copies.
3.3 It blocks correct MMU support
This is what prompted the proposal. On a target with an MMU and address
environments, a real fork() is implementable: allocate the child its own pages,
copy the parent's regions into them, map them at the same virtual addresses.
There is currently no way to express that, because fork() and vfork() are one
code path reached through one syscall. Implementing real copy semantics
necessarily changes vfork() too — and breaks it, since vfork()'s whole
contract is the sharing that was just removed.
We hit exactly this. On an ESP32-S3 BUILD_KERNEL port we implemented a real
copying fork() (child gets its own physical pages at the same virtual
addresses; verified on hardware — the child's writes are invisible to the
parent). The first thing it broke was ostest's vfork test
(apps/testing/ostest/vfork.c:44-74),
which has the child set a global and the parent observe it:
static volatile bool g_vforkchild;
g_vforkchild = false;
pid = vfork();
if (pid == 0)
{
g_vforkchild = true;
exit(0);
}
...
sleep(1);
if (g_vforkchild) /* parent expects to see the child's write */
That test is correct — it is verifying the property that makes vfork()
vfork(). It broke because fork() and vfork() are the same function, so
fixing one necessarily breaks the other. That is the conflation, demonstrated.
Describe the solution you'd like
4. Proposal
4.1 fork()
- Child receives its own copy of the parent's memory, at the same virtual
addresses.
- No restrictions beyond POSIX's: the child may modify anything, call anything,
return from the calling function, and run concurrently with the parent.
- Available only where the architecture and build model can duplicate an
address environment.
- Where it cannot be provided,
fork() is not provided at all — the symbol
does not exist and applications that call it fail to link.
One cost is inherent and worth stating up front: without a paging MMU there is
no copy-on-write, so this fork() copies the parent's memory eagerly —
forking a large process on a small-RAM target is expensive and can fail with
ENOMEM. That is the nature of the primitive, not a defect of an
implementation: fork()'s value here is correctness for portable code.
Spawn-heavy code should prefer posix_spawn() or vfork(), on NuttX as
anywhere.
The link failure is a feature. It converts today's silent runtime wrongness into
a build-time error naming the exact function that cannot be honoured, which the
developer can then replace with vfork(), task_fork() (§4.4),
posix_spawn() or task_spawn().
CONFIG_ARCH_HAVE_FORK should be redefined to mean "this configuration can
provide POSIX fork() semantics": it requires CONFIG_ARCH_ADDRENV plus a
new CONFIG_ARCH_HAVE_ADDRENV_FORK, selected by architectures that can
duplicate an address environment. The existing unconditional selects become
conditional.
4.2 vfork()
- Child shares the parent's memory. No copy.
- Parent is suspended until the child calls
_exit() or one of the exec()
family — and is resumed at exec(), not merely at termination (§1.5). That
requires the suspension to live in the kernel primitive rather than in a
libc waitpid(), which also frees vfork() of its current
CONFIG_SCHED_WAITPID dependency.
- Restrictions are POSIX's: the child must not modify data other than the
pid_t, must not return from the calling function, and must not call other
functions before _exit()/exec(). Programs that violate them get undefined
behaviour, as they always have.
- Implementable everywhere, MMU or not. This is the primitive a no-MMU system
can implement, and it should not silently gain a copy on systems that have an
MMU — vfork() means "do not copy", and that is why callers choose it.
- Governed by
CONFIG_ARCH_HAVE_VFORK.
A word on the stack. Classic vfork() (BSD, Linux) has the child borrow
the parent's actual stack — total borrowing, no allocation, no copy — and
the parent's suspension is precisely what makes that safe. NuttX instead gives
the child a copy of the parent's stack at a different address. That too is a
workaround, not a design choice: the shared primitive never suspends the
parent, and two simultaneously runnable tasks cannot share one stack.
On ARM and RISC-V the relocated copy works in practice, because frame
addressing is stack-pointer-relative and nothing in the return path consumes
absolute stack addresses. On Xtensa's windowed ABI it cannot work at all:
the register-window base save areas embedded in the stack hold absolute
stack-pointer values (the spilled a1 of each frame), and the window
underflow machinery restores them as-is. A child started on a relocated copy
takes its very first window underflow on the retw that returns from libc's
vfork() — before the application ever sees pid == 0 — reloads a stack
pointer that points into the parent's stack, and from then on runs on the
parent's live frames, corrupting them. Nor can the copy be fixed up in
general: the save-area chain could be rebased, but pointers into the stack can
live in any register or variable. This is very likely why Xtensa has never
selected ARCH_HAVE_FORK.
The split resolves this on both sides. A true vfork() child borrows the
parent's stack, so there is nothing to relocate — a windowed ABI is satisfied
by construction, and vfork() gains its full economy: no second stack, no
copy. A true fork() duplicates the address environment at the same virtual
addresses, so the absolute pointers in the child's copied stack — window
save areas included — remain valid without any fixup. The one variant a
windowed ABI cannot support is exactly the hybrid NuttX has today: shared
memory with a relocated stack copy. On such architectures the borrow is
therefore required; on the others it is the natural optimization.
4.3 Separate them below libc
Add SYS_vfork / up_vfork() alongside up_fork(), so the semantics are chosen
by which function the application called and never by what the hardware
happens to be. This is the change that makes the rest expressible.
4.4 task_fork() — today's behaviour under an honest name
Everything above removes the name fork() from the sharing behaviour; it
does not remove the behaviour itself. What NuttX builds today — child shares
.data/.bss/heap, runs on a private copy of the parent's stack, and
executes concurrently with the parent — is a coherent, useful primitive.
It is not fork() and not vfork(); it is a task cloned at the call site,
with the memory relationship of a thread (§1.2). Precedent exists: it is
exactly Plan 9's rfork(RFPROC|RFMEM) — data and bss shared, stack copied —
and, more loosely, Linux's clone(CLONE_VM) without CLONE_VFORK, whose
child likewise shares memory and runs concurrently, though on a
caller-supplied stack rather than a copy.
The proposal is to keep it, exposed as a non-POSIX NuttX API named for what it
is:
task_fork() — joins the existing task_create() / task_spawn() /
task_delete() family. Returns twice, like fork(): 0 in the child, the
child's pid in the parent. No suspension, no memory copy beyond the stack.
- Governed by
CONFIG_ARCH_HAVE_TASK_FORK, which inherits exactly
today's ARCH_HAVE_FORK select lines — the machinery is unchanged, only
the name is honest. In particular it is never available on windowed-ABI
Xtensa, in any build model, for the reasons in §4.2 — which today's selects
already reflect.
- The name deliberately avoids "vfork": the defining property of
vfork() is
the suspension, which this primitive does not have. A name like
stack_vfork() would recreate the very confusion this proposal removes.
Two things this buys:
- Exact legacy compatibility.
CONFIG_FORK_IS_TASK_FORK (§4.5) aliases
fork() to task_fork() and legacy applications are bit-for-bit where
they are today — same sharing, same concurrency, no new suspension.
- A migration target that is a rename, not a rewrite. In-tree code found
by the audit (§5, item 3) to depend on shared-memory concurrency changes
one identifier and is done.
New code should prefer pthread_create() (the honest concurrent-sharing
primitive) or posix_spawn(); task_fork() exists to give the historical
behaviour a truthful name, not to recommend it.
4.5 Compatibility: CONFIG_FORK_IS_TASK_FORK
For existing code that calls fork() and expects today's behaviour:
config FORK_IS_TASK_FORK
bool "Provide fork() as an alias for task_fork() (legacy)"
default n
depends on ARCH_HAVE_TASK_FORK && !ARCH_HAVE_FORK
---help---
Provide fork() on configurations that cannot implement POSIX fork()
semantics, by aliasing it to task_fork().
This preserves the historical NuttX fork() behaviour exactly: the
child shares the parent's data and heap, runs on a private copy of
the parent's stack, and runs concurrently with the parent. It does
NOT provide POSIX fork() semantics: writes by the child are visible
to the parent, and vice versa.
Enable this only to keep legacy applications building. New code
should call task_fork() explicitly, or use pthread_create() or
posix_spawn().
Because the alias targets task_fork(), it is exact: same sharing, same
concurrency, no new suspension. And because ARCH_HAVE_TASK_FORK inherits
today's ARCH_HAVE_FORK select lines (§4.4), at the time the change lands the
option exists on precisely the configurations where fork() exists today — no
configuration that can call fork() now loses the ability to keep it, and
configurations that never had fork() lose nothing. As real fork() support
arrives per architecture that stops being true for the configurations gaining
it — deliberately; see the note on !ARCH_HAVE_FORK below.
Default n, so the honest behaviour is what you get unless you ask otherwise,
and the Kconfig help states plainly what you are opting into. Existing users
flip one switch and are exactly where they are today.
The depends on !ARCH_HAVE_FORK is deliberate: on a configuration that
provides real fork(), code that wants the sharing has honest spellings —
task_fork() and vfork() — and aliasing fork() back to sharing there
would reintroduce exactly the ambiguity this proposal removes. On such
targets, sharing-dependent callers must be edited (§5, item 2).
5. Impact — this is a breaking change
Stated plainly, because it should not be understated:
- Applications calling
fork() on non-MMU targets stop linking. This is
the intended, correct outcome, and it is a build break for anyone doing it
today. CONFIG_FORK_IS_TASK_FORK restores them exactly (§4.5).
- Applications calling
fork() on MMU targets change behaviour — from
sharing to copying. Code that (perhaps unknowingly) depended on the sharing
will change behaviour. Such code was relying on non-POSIX behaviour, but it
exists, and it will need task_fork() (§4.4) — or vfork() if it follows
the fork-then-exec pattern.
- In-tree users must be audited. Any
apps/ code calling fork() needs to
be checked for whether it means fork() or vfork().
ostest needs work. Its vfork test becomes a genuine vfork() test
(and should use _exit() rather than exit(), per the POSIX contract — a
vfork() child running atexit handlers and flushing stdio in the parent's
address space is exactly the misuse the restriction exists to prevent). A
separate fork() test should be added for configurations that provide it.
- Per-architecture
selects must be revisited, since
CONFIG_ARCH_HAVE_FORK changes meaning.
Mitigations: the compatibility option covers legacy code with a one-line change;
the migration path for the dominant fork+exec idiom is posix_spawn(), which
NuttX already provides and already recommends (and which today's
fork()+exec() cannot substitute for anyway, since the parent loses track of
the child at exec — §1.5); and the change is staged — vfork() can be split
out first, with fork() following per architecture as real support lands.
On the default: if withdrawing fork() from non-MMU targets in a single
release is judged too abrupt — it lands on ARM, the most-used architecture —
CONFIG_FORK_IS_TASK_FORK can ship as default y with a deprecation warning
for a release cycle, flipping to default n afterwards. The end state should
still be default n: the honest behaviour has to become the one you get
without asking. The schedule for reaching it is negotiable; the destination
should not be.
6. What we gain
fork() means one thing on every NuttX target. Copy semantics, or it does
not exist. No per-arch, per-build-model divergence.
- Portable applications become possible. Code written against POSIX
fork() works, or refuses to build. It never silently misbehaves.
vfork() becomes honest and useful. It means "share, cheaply", which is
exactly what a no-MMU system can offer and exactly why a caller picks it —
including on systems that do have an MMU.
- MMU-capable ports can implement real
fork(). This is already
demonstrated working on ESP32-S3 BUILD_KERNEL; it just cannot be upstreamed
coherently while the two functions are one.
- Windowed-ABI architectures get a workable story at all. Xtensa cannot
implement the current relocated-stack-copy hybrid — absolute stack pointers
in the window save areas make it unwind onto the parent's stack (§4.2) —
but it can implement both honest primitives: vfork() by borrowing,
fork() by duplicating at the same virtual addresses.
- Nothing is lost. Today's behaviour survives, exactly, as
task_fork()
(§4.4) — and via CONFIG_FORK_IS_TASK_FORK (§4.5) even the spelling
fork() survives for legacy code on every configuration where it works
today, until a configuration gains a real fork(), at which point its
sharing-dependent callers move to the task_fork() spelling (§4.5, §5
item 2).
- NuttX moves closer to POSIX, not further, and closes a documentation defect
in which fork() is specified using vfork()'s restrictions.
7. Implementation sketch
- Add
CONFIG_ARCH_HAVE_VFORK; add SYS_vfork and up_vfork().
- Move today's sharing implementation to
up_vfork(), unchanged in
behaviour: nxtask_setup_fork() keeps addrenv_join() on that path, and
the libc waitpid() and the stack copy stay exactly as they are. The
architecture's register/stack snapshot machinery is common to both
primitives — the divergence is which address-environment operation
nxtask_setup_fork() performs.
libs/libc/unistd/lib_fork.c: vfork() calls up_vfork(); fork() calls
up_fork(); fork() is compiled only under CONFIG_ARCH_HAVE_FORK (or
aliased to task_fork() under CONFIG_FORK_IS_TASK_FORK).
- Rename today's per-arch
ARCH_HAVE_FORK selects to ARCH_HAVE_TASK_FORK
and expose the existing machinery as task_fork() (§4.4); add the
compatibility alias CONFIG_FORK_IS_TASK_FORK (§4.5).
- Move the parent suspension out of libc into the kernel vfork path, so the
parent resumes at exec() as well as _exit() (§1.5) and vfork() stops
depending on CONFIG_SCHED_WAITPID. With the parent suspended, the child
can borrow the parent's stack outright instead of copying it — required on
windowed-ABI architectures such as Xtensa, an optimization elsewhere
(§4.2).
- Redefine
CONFIG_ARCH_HAVE_FORK to mean real fork(): it requires
CONFIG_ARCH_ADDRENV plus a new CONFIG_ARCH_HAVE_ADDRENV_FORK, selected
by architectures that can duplicate an address environment. Architectures
gain the select only as real support lands — until then they have no
fork(), which is accurate.
- Add the generic duplication path used by
fork(): a new
addrenv_fork() beside addrenv_join(), backed by an architecture hook
(up_addrenv_fork()) that copies the parent's regions into freshly allocated
pages mapped at the same virtual addresses.
ostest: correct the vfork test to the POSIX contract (_exit()); add a
fork test guarded by CONFIG_ARCH_HAVE_FORK; add a task_fork test
guarded by CONFIG_ARCH_HAVE_TASK_FORK (today's vfork test, renamed, is
very nearly that test already).
Steps 1–4 are mechanical and independently reviewable — no behaviour changes
for existing configurations. Step 5 is the first behaviour change, and it is
confined to vfork(). Steps 6–8 can land per architecture.
8. References
- POSIX.1-2017
fork() —
https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html
- POSIX.1-2004 (SUSv3)
vfork(), since withdrawn —
https://pubs.opengroup.org/onlinepubs/009695399/functions/vfork.html
- POSIX.1-2017
_exit() —
https://pubs.opengroup.org/onlinepubs/9699919799/functions/_exit.html
- POSIX.1-2017
posix_spawn() —
https://pubs.opengroup.org/onlinepubs/9699919799/functions/posix_spawn.html
- Linux
vfork(2) (standards notes: POSIX.1-2001 obsolescent, removed in
POSIX.1-2008) — https://man7.org/linux/man-pages/man2/vfork.2.html
- NuttX source referenced above, all at
apache/nuttx master
216edd74d:
libs/libc/unistd/lib_fork.c,
sched/task/task_fork.c,
sched/task/task_execve.c,
sched/addrenv/addrenv.c,
sched/pthread/pthread_create.c,
include/sys/syscall_lookup.h,
arch/Kconfig,
arch/sim/src/sim/sim_fork.c
apps source, at apache/nuttx-apps master
6ca1272b9:
testing/ostest/vfork.c
- History: nuttx
c33d1c9c97
"sched/task/fork: add fork implementation" (2023-07, the
vfork()→fork() rename); nuttx
3524f4b9c
"libs/libc/fork: add lib_fork implementation" (2023-07, syscall renamed to
up_fork); nuttx
2f2632338
"arch/libc: Integrate vfork into fork, and vfork directly call up_fork"
(2024-10); apps
79c7962af
"apps: Replace CONFIG_ARCH_HAVE_VFORK with CONFIG_ARCH_HAVE_FORK"
(2023-08)
Describe alternatives you've considered
No response
Verification
Is your feature request related to a problem? Please describe.
Proposal: give
fork()andvfork()their real, separate semanticsSummary
NuttX today implements
fork()andvfork()as the same function. Both arethin wrappers around a single
up_fork(), there is noSYS_vforkand noup_vfork(), and the shared implementation gives the child a copy of theparent's stack, sharing everything else.
That is not
fork(): the child does not get its own memory — writes by thechild are visible to the parent, and vice versa. What it is, is
vfork()witha private stack: a
vfork()conforming in its memory semantics (a programthat honours
vfork()'s restrictions cannot tell the difference there —though the moment the parent resumes is a separate defect, §1.5), but
published under
fork()'s name — and under that name, the sharing is simplywrong. Nor is this a coincidence: today's
fork()is NuttX's oldvfork(),renamed (§1.4).
This proposal is to separate them:
fork()vfork()_exit()/exec()And today's exact behaviour — shared memory, private stack, both running
concurrently — is not lost: it is retained under an honest, non-POSIX name,
task_fork()(§4.4), on the architectures and configurations that supportit today.
This is a breaking change. It is proposed anyway because the current
behaviour is silently wrong for
fork()users, it is not portable across NuttXtargets, and it is the thing blocking correct
fork()support on MMU-capablesystems.
A compatibility option,
CONFIG_FORK_IS_TASK_FORK(§4.5), aliasesfork()back to
task_fork(), keeping existing applications building and runningexactly as they do today on every configuration where
fork()existstoday.
All code citations below are quoted verbatim from
apache/nuttxmaster at216edd74dand
apache/nuttx-appsmaster at6ca1272b9;every file reference links to the code at those commits, so the line numbers
are stable.
1. What NuttX does today
1.1
fork()andvfork()are wrappers around the same calllibs/libc/unistd/lib_fork.c:153-234— both entry points call
up_fork():vfork()differs fromfork()only by the parent'swaitpid(). The memorysemantics are identical, because the underlying operation is identical. (The
WNOWAITflag leaves the child unreaped, so the application's ownwaitpid()still collects its status afterwards. Note in passing thatvfork()is only compiled underCONFIG_SCHED_WAITPID(
lib_fork.c:176)— a configuration without that option has no
vfork()at all — and thatsuspending the parent with
waitpid()is itself subtly wrong; see §1.5.)There is exactly one syscall
(
include/sys/syscall_lookup.h:116):There is no
SYS_vforkand noup_vfork()in the tree. Below libc, the twoPOSIX functions are indistinguishable, so no implementation can give them
different behaviour even if it wanted to.
1.2 The child shares the parent's memory
sched/task/task_fork.c:162-174:addrenv_join()is the same callpthread_create()uses(
sched/pthread/pthread_create.c:238)to give a thread the memory of its creator. The forked child is therefore
given the memory relationship of a thread.
The child then gets a new stack
(
up_create_stack(),task_fork.c:201),into which the architecture's fork code copies the parent's stack. So the child
has a private stack and shares everything else:
.data,.bss, the heap.On builds without
CONFIG_ARCH_ADDRENV(flat and protected builds) there isonly one address space to begin with, so everything except the stack is shared
there too.
1.3 NuttX's own documentation already describes
vfork()The most direct evidence that the current function is
vfork()wearingfork()'s name is NuttX's own comment onnxtask_setup_fork()(
sched/task/task_fork.c:57-62):Those three restrictions are, word for word, the definition of
vfork()inPOSIX.1-2004. They are not restrictions that
fork()has, or may have. NuttXhas documented
fork()by quoting the specification ofvfork().1.4 How it got this way:
vfork()was renamed tofork()This is not an accident of implementation. It is recorded in the history.
Commit
c33d1c9c97— "sched/task/fork: add fork implementation", guoshichao, 2023-07-05 — states
its own intent:
The diff is a mass rename. Twenty-two files were renamed rather than written:
The Kconfig symbol was renamed too, for every architecture:
and so was the system call:
with
libs/libc/unistd/lib_vfork.cadded on top as a new libc wrapper.So today's
fork()is, quite literally, NuttX's oldvfork()under a newname. No copy semantics were added — the implementation was not changed, only
what it is called.
Follow-ups completed the picture:
3524f4b9c("libs/libc/fork: add lib_fork implementation", 2023-07-16) renamed the
syscall again, to the
up_forkseen in §1.1;79c7962af("apps: Replace CONFIG_ARCH_HAVE_VFORK with CONFIG_ARCH_HAVE_FORK",
2023-08-13) propagated the rename through
apps; and2f2632338("arch/libc: Integrate vfork into fork, and vfork directly call up_fork",
2024-10-09) removed the last remaining distinction below libc, leaving
vfork()asfork()plus awaitpid()(§1.1).The premise in the commit message, "as we can use fork to implement vfork", is
sound in the direction it is stated: if you have a real
fork(), you can buildvfork()on it. What happened was the reverse — the existingvfork()was givenfork()'s name, so NuttX ended up withvfork()implementingfork(), whichdoes not work in that direction.
That framing is worth keeping in mind when weighing this proposal: it is not
asking to change what
fork()has always meant in NuttX. It is asking to undo arename, restore
ARCH_HAVE_VFORKandSYS_vforkto the implementation thatactually is
vfork(), and add a genuinefork()where one can be implemented.1.5 Even as
vfork(), the parent is resumed at the wrong momentPOSIX resumes a
vfork()parent when the child calls_exit()or one of theexec functions. NuttX's
vfork()suspends the parent inwaitpid()(§1.1),which returns only when the child terminates — nothing in the exec path wakes
it.
Today the exec case appears to work only by accident: NuttX's
execve()doesnot overlay the calling process. It starts the new program as a separate task
with a new pid, and the calling task then exits
(
sched/task/task_execve.c:115-138—
exec()followed by_exit(0)). It is the child's exit that releases theparent, and the parent is left with no relationship to the program it just
launched. The file
says so itself:
So the canonical
vfork()+exec()+waitpid()idiom cannot actually bewritten on NuttX: the pid the parent holds names a task that died at exec time,
and the real program runs under a pid the parent cannot learn. Repairing
exec()'s pid discontinuity is beyond this proposal's scope, but thevfork()half — resuming the parent at exec rather than at termination — falls out
naturally once the suspension lives in the kernel primitive instead of a libc
waitpid()(§4.2, §7).2. What POSIX says
2.1
fork()POSIX.1-2017
fork():The separation of the two processes' memory is the defining property. It is what
makes the following legal, and it is what essentially all
fork()-using coderelies on:
it;
fork();malloc(),printf(), anything —and run indefinitely;
The first two — the memory guarantees, the defining property — do not hold in
NuttX today. (The other three do, courtesy of the private stack copy; that
they hold is exactly what separates today's behaviour from classic
vfork()—see §4.4.)
2.2
vfork()POSIX.1-2004 (SUSv3)
vfork():and, on why it exists at all:
Two things follow.
First, sharing is
vfork()'s entire purpose. It exists to avoid the copy.An implementation of
vfork()that copies is conforming but pointless — and ona system without an MMU, an implementation that copies is not possible at all.
vfork()is precisely the primitive that a no-MMU system can offer.Second, the restrictions are the price of that sharing. They are not
arbitrary; they exist because the child is running in the parent's address space
on borrowed time. Applying those restrictions to
fork()— as NuttX'sdocumentation does — inverts the relationship: it takes
vfork()'s cost andattaches it to
fork()'s name, while delivering neitherfork()'s guaranteenor
vfork()'s performance benefit.vfork()was marked obsolescent in POSIX.1-2001 and removed in POSIX.1-2008.That is an argument for not requiring it, not an argument for redefining it:
where it is provided, it should mean what it has always meant. It remains widely
implemented (Linux, the BSDs) for exactly the reason POSIX gives — it is the
cheap clone.
2.3 On "
vfork()may be implemented asfork()"It is often said, correctly, that a conforming implementation may implement
vfork()asfork(), because a program that depends on the sharing is invokingundefined behaviour. This is true and it is not the situation NuttX is in.
NuttX has done the opposite: it implements
fork()asvfork(). Thatdirection is not defensible under any reading of the standard. A program that
depends on
fork()'s copy semantics is not invoking undefined behaviour — it isusing the function exactly as specified.
3. Why this matters
3.1 The failure is silent
A program that uses
fork()correctly — as specified, as it works on Linux, onthe BSDs, on macOS — compiles and runs on NuttX and produces wrong results.
There is no compiler diagnostic, no link error, no runtime error. The child's
writes quietly land in the parent's variables.
Silent wrongness is the worst possible failure mode. A link error would be
strictly better.
3.2 Availability varies across NuttX targets; the semantics are wrong everywhere
CONFIG_ARCH_HAVE_FORKis selected inconsistently inarch/Kconfig:ARCH_ARM—select ARCH_HAVE_FORKARCH_ARM64—select ARCH_HAVE_FORK if !BUILD_KERNEL && !BUILD_PROTECTEDARCH_RISCV—select ARCH_HAVE_FORKARCH_SIM—select ARCH_HAVE_FORK if !HOST_WINDOWSARCH_X86_64—select ARCH_HAVE_FORK if !BUILD_KERNELSo whether
fork()exists at all depends on the architecture and the buildmodel: present on ARM in every build model, absent from ARM64 kernel and
protected builds and from x86_64 kernel builds, present on the Linux/macOS
simulator, absent on the Windows one. An in-tree application can test
CONFIG_ARCH_HAVE_FORK, but that is a NuttX-specific config symbol, and itreports only that
fork()exists — never that its semantics are notPOSIX's. There is no standard mechanism — no feature-test macro, no
sysconf()query — by which portable code can discover any of this. It cannoteven degrade gracefully: it links on one target and fails to link on the next.
And where
fork()does exist, it always means "share". Even the simulator,which runs on a host with a perfectly good
fork(2), does not use it:arch/sim/src/sim/sim_fork.cperforms the same snapshot-the-registers, copy-the-stack emulation as the
embedded targets — its own comment calls the stack copy a
"feeble effort to preserve the stack contents".
There is no NuttX target on which
fork()copies.3.3 It blocks correct MMU support
This is what prompted the proposal. On a target with an MMU and address
environments, a real
fork()is implementable: allocate the child its own pages,copy the parent's regions into them, map them at the same virtual addresses.
There is currently no way to express that, because
fork()andvfork()are onecode path reached through one syscall. Implementing real copy semantics
necessarily changes
vfork()too — and breaks it, sincevfork()'s wholecontract is the sharing that was just removed.
We hit exactly this. On an ESP32-S3
BUILD_KERNELport we implemented a realcopying
fork()(child gets its own physical pages at the same virtualaddresses; verified on hardware — the child's writes are invisible to the
parent). The first thing it broke was
ostest'svforktest(
apps/testing/ostest/vfork.c:44-74),which has the child set a global and the parent observe it:
That test is correct — it is verifying the property that makes
vfork()vfork(). It broke becausefork()andvfork()are the same function, sofixing one necessarily breaks the other. That is the conflation, demonstrated.
Describe the solution you'd like
4. Proposal
4.1
fork()addresses.
return from the calling function, and run concurrently with the parent.
address environment.
fork()is not provided at all — the symboldoes not exist and applications that call it fail to link.
One cost is inherent and worth stating up front: without a paging MMU there is
no copy-on-write, so this
fork()copies the parent's memory eagerly —forking a large process on a small-RAM target is expensive and can fail with
ENOMEM. That is the nature of the primitive, not a defect of animplementation:
fork()'s value here is correctness for portable code.Spawn-heavy code should prefer
posix_spawn()orvfork(), on NuttX asanywhere.
The link failure is a feature. It converts today's silent runtime wrongness into
a build-time error naming the exact function that cannot be honoured, which the
developer can then replace with
vfork(),task_fork()(§4.4),posix_spawn()ortask_spawn().CONFIG_ARCH_HAVE_FORKshould be redefined to mean "this configuration canprovide POSIX
fork()semantics": it requiresCONFIG_ARCH_ADDRENVplus anew
CONFIG_ARCH_HAVE_ADDRENV_FORK, selected by architectures that canduplicate an address environment. The existing unconditional selects become
conditional.
4.2
vfork()_exit()or one of theexec()family — and is resumed at
exec(), not merely at termination (§1.5). Thatrequires the suspension to live in the kernel primitive rather than in a
libc
waitpid(), which also freesvfork()of its currentCONFIG_SCHED_WAITPIDdependency.pid_t, must not return from the calling function, and must not call otherfunctions before
_exit()/exec(). Programs that violate them get undefinedbehaviour, as they always have.
can implement, and it should not silently gain a copy on systems that have an
MMU —
vfork()means "do not copy", and that is why callers choose it.CONFIG_ARCH_HAVE_VFORK.A word on the stack. Classic
vfork()(BSD, Linux) has the child borrowthe parent's actual stack — total borrowing, no allocation, no copy — and
the parent's suspension is precisely what makes that safe. NuttX instead gives
the child a copy of the parent's stack at a different address. That too is a
workaround, not a design choice: the shared primitive never suspends the
parent, and two simultaneously runnable tasks cannot share one stack.
On ARM and RISC-V the relocated copy works in practice, because frame
addressing is stack-pointer-relative and nothing in the return path consumes
absolute stack addresses. On Xtensa's windowed ABI it cannot work at all:
the register-window base save areas embedded in the stack hold absolute
stack-pointer values (the spilled
a1of each frame), and the windowunderflow machinery restores them as-is. A child started on a relocated copy
takes its very first window underflow on the
retwthat returns from libc'svfork()— before the application ever seespid == 0— reloads a stackpointer that points into the parent's stack, and from then on runs on the
parent's live frames, corrupting them. Nor can the copy be fixed up in
general: the save-area chain could be rebased, but pointers into the stack can
live in any register or variable. This is very likely why Xtensa has never
selected
ARCH_HAVE_FORK.The split resolves this on both sides. A true
vfork()child borrows theparent's stack, so there is nothing to relocate — a windowed ABI is satisfied
by construction, and
vfork()gains its full economy: no second stack, nocopy. A true
fork()duplicates the address environment at the same virtualaddresses, so the absolute pointers in the child's copied stack — window
save areas included — remain valid without any fixup. The one variant a
windowed ABI cannot support is exactly the hybrid NuttX has today: shared
memory with a relocated stack copy. On such architectures the borrow is
therefore required; on the others it is the natural optimization.
4.3 Separate them below libc
Add
SYS_vfork/up_vfork()alongsideup_fork(), so the semantics are chosenby which function the application called and never by what the hardware
happens to be. This is the change that makes the rest expressible.
4.4
task_fork()— today's behaviour under an honest nameEverything above removes the name
fork()from the sharing behaviour; itdoes not remove the behaviour itself. What NuttX builds today — child shares
.data/.bss/heap, runs on a private copy of the parent's stack, andexecutes concurrently with the parent — is a coherent, useful primitive.
It is not
fork()and notvfork(); it is a task cloned at the call site,with the memory relationship of a thread (§1.2). Precedent exists: it is
exactly Plan 9's
rfork(RFPROC|RFMEM)— data and bss shared, stack copied —and, more loosely, Linux's
clone(CLONE_VM)withoutCLONE_VFORK, whosechild likewise shares memory and runs concurrently, though on a
caller-supplied stack rather than a copy.
The proposal is to keep it, exposed as a non-POSIX NuttX API named for what it
is:
task_fork()— joins the existingtask_create()/task_spawn()/task_delete()family. Returns twice, likefork(): 0 in the child, thechild's pid in the parent. No suspension, no memory copy beyond the stack.
CONFIG_ARCH_HAVE_TASK_FORK, which inherits exactlytoday's
ARCH_HAVE_FORKselect lines — the machinery is unchanged, onlythe name is honest. In particular it is never available on windowed-ABI
Xtensa, in any build model, for the reasons in §4.2 — which today's selects
already reflect.
vfork()isthe suspension, which this primitive does not have. A name like
stack_vfork()would recreate the very confusion this proposal removes.Two things this buys:
CONFIG_FORK_IS_TASK_FORK(§4.5) aliasesfork()totask_fork()and legacy applications are bit-for-bit wherethey are today — same sharing, same concurrency, no new suspension.
by the audit (§5, item 3) to depend on shared-memory concurrency changes
one identifier and is done.
New code should prefer
pthread_create()(the honest concurrent-sharingprimitive) or
posix_spawn();task_fork()exists to give the historicalbehaviour a truthful name, not to recommend it.
4.5 Compatibility:
CONFIG_FORK_IS_TASK_FORKFor existing code that calls
fork()and expects today's behaviour:Because the alias targets
task_fork(), it is exact: same sharing, sameconcurrency, no new suspension. And because
ARCH_HAVE_TASK_FORKinheritstoday's
ARCH_HAVE_FORKselect lines (§4.4), at the time the change lands theoption exists on precisely the configurations where
fork()exists today — noconfiguration that can call
fork()now loses the ability to keep it, andconfigurations that never had
fork()lose nothing. As realfork()supportarrives per architecture that stops being true for the configurations gaining
it — deliberately; see the note on
!ARCH_HAVE_FORKbelow.Default
n, so the honest behaviour is what you get unless you ask otherwise,and the Kconfig help states plainly what you are opting into. Existing users
flip one switch and are exactly where they are today.
The
depends on !ARCH_HAVE_FORKis deliberate: on a configuration thatprovides real
fork(), code that wants the sharing has honest spellings —task_fork()andvfork()— and aliasingfork()back to sharing therewould reintroduce exactly the ambiguity this proposal removes. On such
targets, sharing-dependent callers must be edited (§5, item 2).
5. Impact — this is a breaking change
Stated plainly, because it should not be understated:
fork()on non-MMU targets stop linking. This isthe intended, correct outcome, and it is a build break for anyone doing it
today.
CONFIG_FORK_IS_TASK_FORKrestores them exactly (§4.5).fork()on MMU targets change behaviour — fromsharing to copying. Code that (perhaps unknowingly) depended on the sharing
will change behaviour. Such code was relying on non-POSIX behaviour, but it
exists, and it will need
task_fork()(§4.4) — orvfork()if it followsthe fork-then-exec pattern.
apps/code callingfork()needs tobe checked for whether it means
fork()orvfork().ostestneeds work. Itsvforktest becomes a genuinevfork()test(and should use
_exit()rather thanexit(), per the POSIX contract — avfork()child runningatexithandlers and flushing stdio in the parent'saddress space is exactly the misuse the restriction exists to prevent). A
separate
fork()test should be added for configurations that provide it.selects must be revisited, sinceCONFIG_ARCH_HAVE_FORKchanges meaning.Mitigations: the compatibility option covers legacy code with a one-line change;
the migration path for the dominant fork+exec idiom is
posix_spawn(), whichNuttX already provides and already recommends (and which today's
fork()+exec()cannot substitute for anyway, since the parent loses track ofthe child at exec — §1.5); and the change is staged —
vfork()can be splitout first, with
fork()following per architecture as real support lands.On the default: if withdrawing
fork()from non-MMU targets in a singlerelease is judged too abrupt — it lands on ARM, the most-used architecture —
CONFIG_FORK_IS_TASK_FORKcan ship asdefault ywith a deprecation warningfor a release cycle, flipping to
default nafterwards. The end state shouldstill be
default n: the honest behaviour has to become the one you getwithout asking. The schedule for reaching it is negotiable; the destination
should not be.
6. What we gain
fork()means one thing on every NuttX target. Copy semantics, or it doesnot exist. No per-arch, per-build-model divergence.
fork()works, or refuses to build. It never silently misbehaves.vfork()becomes honest and useful. It means "share, cheaply", which isexactly what a no-MMU system can offer and exactly why a caller picks it —
including on systems that do have an MMU.
fork(). This is alreadydemonstrated working on ESP32-S3
BUILD_KERNEL; it just cannot be upstreamedcoherently while the two functions are one.
implement the current relocated-stack-copy hybrid — absolute stack pointers
in the window save areas make it unwind onto the parent's stack (§4.2) —
but it can implement both honest primitives:
vfork()by borrowing,fork()by duplicating at the same virtual addresses.task_fork()(§4.4) — and via
CONFIG_FORK_IS_TASK_FORK(§4.5) even the spellingfork()survives for legacy code on every configuration where it workstoday, until a configuration gains a real
fork(), at which point itssharing-dependent callers move to the
task_fork()spelling (§4.5, §5item 2).
in which
fork()is specified usingvfork()'s restrictions.7. Implementation sketch
CONFIG_ARCH_HAVE_VFORK; addSYS_vforkandup_vfork().up_vfork(), unchanged inbehaviour:
nxtask_setup_fork()keepsaddrenv_join()on that path, andthe libc
waitpid()and the stack copy stay exactly as they are. Thearchitecture's register/stack snapshot machinery is common to both
primitives — the divergence is which address-environment operation
nxtask_setup_fork()performs.libs/libc/unistd/lib_fork.c:vfork()callsup_vfork();fork()callsup_fork();fork()is compiled only underCONFIG_ARCH_HAVE_FORK(oraliased to
task_fork()underCONFIG_FORK_IS_TASK_FORK).ARCH_HAVE_FORKselects toARCH_HAVE_TASK_FORKand expose the existing machinery as
task_fork()(§4.4); add thecompatibility alias
CONFIG_FORK_IS_TASK_FORK(§4.5).parent resumes at
exec()as well as_exit()(§1.5) andvfork()stopsdepending on
CONFIG_SCHED_WAITPID. With the parent suspended, the childcan borrow the parent's stack outright instead of copying it — required on
windowed-ABI architectures such as Xtensa, an optimization elsewhere
(§4.2).
CONFIG_ARCH_HAVE_FORKto mean realfork(): it requiresCONFIG_ARCH_ADDRENVplus a newCONFIG_ARCH_HAVE_ADDRENV_FORK, selectedby architectures that can duplicate an address environment. Architectures
gain the select only as real support lands — until then they have no
fork(), which is accurate.fork(): a newaddrenv_fork()besideaddrenv_join(), backed by an architecture hook(
up_addrenv_fork()) that copies the parent's regions into freshly allocatedpages mapped at the same virtual addresses.
ostest: correct thevforktest to the POSIX contract (_exit()); add aforktest guarded byCONFIG_ARCH_HAVE_FORK; add atask_forktestguarded by
CONFIG_ARCH_HAVE_TASK_FORK(today'svforktest, renamed, isvery nearly that test already).
Steps 1–4 are mechanical and independently reviewable — no behaviour changes
for existing configurations. Step 5 is the first behaviour change, and it is
confined to
vfork(). Steps 6–8 can land per architecture.8. References
fork()—https://pubs.opengroup.org/onlinepubs/9699919799/functions/fork.html
vfork(), since withdrawn —https://pubs.opengroup.org/onlinepubs/009695399/functions/vfork.html
_exit()—https://pubs.opengroup.org/onlinepubs/9699919799/functions/_exit.html
posix_spawn()—https://pubs.opengroup.org/onlinepubs/9699919799/functions/posix_spawn.html
vfork(2)(standards notes: POSIX.1-2001 obsolescent, removed inPOSIX.1-2008) — https://man7.org/linux/man-pages/man2/vfork.2.html
apache/nuttxmaster216edd74d:libs/libc/unistd/lib_fork.c,sched/task/task_fork.c,sched/task/task_execve.c,sched/addrenv/addrenv.c,sched/pthread/pthread_create.c,include/sys/syscall_lookup.h,arch/Kconfig,arch/sim/src/sim/sim_fork.cappssource, atapache/nuttx-appsmaster6ca1272b9:testing/ostest/vfork.cc33d1c9c97"sched/task/fork: add fork implementation" (2023-07, the
vfork()→fork()rename); nuttx3524f4b9c"libs/libc/fork: add lib_fork implementation" (2023-07, syscall renamed to
up_fork); nuttx2f2632338"arch/libc: Integrate vfork into fork, and vfork directly call up_fork"
(2024-10); apps
79c7962af"apps: Replace CONFIG_ARCH_HAVE_VFORK with CONFIG_ARCH_HAVE_FORK"
(2023-08)
Describe alternatives you've considered
No response
Verification