Skip to content

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

Description

@casaroli

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:

  1. 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
  2. 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:

  1. 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.
  2. 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:

  1. 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).
  2. 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.
  3. In-tree users must be audited. Any apps/ code calling fork() needs to
    be checked for whether it means fork() or vfork().
  4. 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.
  5. 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

  1. Add CONFIG_ARCH_HAVE_VFORK; add SYS_vfork and up_vfork().
  2. 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.
  3. 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).
  4. 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).
  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).
  6. 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.
  7. 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.
  8. 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

Describe alternatives you've considered

No response

Verification

  • I have verified before submitting the report.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions