sched: Run a module's SIGEV_THREAD notification with its data base.

A SIGEV_THREAD notification -- from mq_notify() or timer_create() -- runs its
callback on a shared signal-notification work queue, not in the registering
task.  That worker carries no FDPIC data base, so a module's callback reaches
it with the wrong base and cannot touch its own globals.  This is unlike every
other callback entry point, where the callback runs in a task that inherited
the module's data space and resolving the code address is enough.

The base is knowable exactly once, at registration, when the call is still in
the module's own context: capture it there with fdpic_base() into the
persisted work structure (mq's ntwork, the timer's pt_work).  At send or
expiry the descriptor is resolved to its code address -- a plain memory read
that needs no base -- and stored in work->func.  The worker, seeing a non-zero
base, installs it in the FDPIC register around the call and restores it after;
a zero base, which is every non-module callback, takes the direct path
unchanged.

fdpic_base() exposes the test fdpic_callback() already makes internally --
whether the caller is a module -- for a site that has to decide before it
stores a pointer somewhere the register will no longer be correct.

fdpic_invoke() is the install-call-restore, in the same ARM-thumb inline asm
as the rest of fdpic.h.  It saves the register on the stack and keeps the push
8-byte aligned, and pins the argument in r0, so it asks the allocator for only
two free registers -- enough on builds that also reserve a frame pointer.  It
is safe against preemption: the FDPIC register is REG_PIC in the saved
context, preserved across a context switch, and base firmware reserves it so
no interrupt handler disturbs it.  A context switch or interrupt while the
callback runs therefore keeps the module's base.

Verified with CONFIG_SIG_EVTHREAD on two ARM cores: an RP2350 (Cortex-M33,
armv8-m) on hardware and mps2-an500 (Cortex-M7, armv7e-m) under QEMU.  On each
a module's mq_notify and timer_create SIGEV_THREAD callbacks run on the worker
and write a distinctive value into a module global, proving the base was
installed.  Removing just the register install makes the same callback
HardFault the board, confirming it is load-bearing.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
This commit is contained in:
Marco Casaroli 2026-07-30 13:42:59 +02:00
parent b7faaed58f
commit ac7b5f5218
5 changed files with 154 additions and 1 deletions

View file

@ -212,8 +212,85 @@ static inline FAR void *fdpic_callback(FAR void *fn)
return fn;
}
/****************************************************************************
* Name: fdpic_base
*
* Description:
* The FDPIC data base of the calling context, taken from the FDPIC
* register. Non-zero means the caller is an FDPIC module; zero means base
* firmware. This is the same test fdpic_callback() makes, exposed for a
* caller that must decide whether a pointer it was handed is a descriptor
* before it stores it somewhere the register will no longer be correct --
* a callback that will run on a work-queue thread, for instance.
*
****************************************************************************/
static inline uintptr_t fdpic_base(void)
{
uintptr_t base;
__asm__ __volatile__ ("mov %0, r9" : "=r"(base));
return base;
}
/****************************************************************************
* Name: fdpic_invoke
*
* Description:
* Call a resolved module entry point with the module's data base installed
* in the FDPIC register, and restore the caller's base afterwards.
*
* This is for the one case the register cannot already be correct: a
* callback that a module registered but that runs on a shared thread --
* the signal-notification work queue -- which carries no module's base.
* The base is captured at registration (fdpic_base(), in the module's
* context) and installed here around the call. Everywhere else the
* callback runs in a task that inherited the module's data space and only
* the entry point has to be resolved; there fdpic_callback() is enough.
*
* A context switch or interrupt during the call is safe: the FDPIC
* register is REG_PIC in the saved register context, so it is preserved
* and restored across a switch, and base firmware is built with it
* reserved so no interrupt handler disturbs it.
*
* Input Parameters:
* entry - The code address to enter (already resolved from the
* descriptor).
* arg - The single word argument, passed in r0.
* got - The module data base to install in the FDPIC register.
*
****************************************************************************/
static inline void fdpic_invoke(uintptr_t entry, uintptr_t arg,
uintptr_t got)
{
register uintptr_t r0v __asm__ ("r0") = arg;
/* arg is pinned in r0 (the first argument and the call's scratch), so the
* asm needs registers only for entry and got -- kept deliberately few so
* the allocator has room on builds that reserve a frame pointer. r9 is
* saved on the stack rather than in a scratch register; r4 rides along
* only to keep the push 8-byte aligned and is restored untouched.
*/
__asm__ __volatile__
(
"push {r4, r9}\n" /* Save the caller's FDPIC register */
"mov r9, %[got]\n" /* Install the module's data base */
"blx %[entry]\n" /* Enter the module */
"pop {r4, r9}\n" /* Restore the caller's FDPIC register */
: "+r" (r0v)
: [entry] "r" (entry), [got] "r" (got)
: "r1", "r2", "r3", "r12", "lr", "cc", "memory"
);
}
#else
# define fdpic_callback(fn) (fn)
# define fdpic_base() (0)
# define fdpic_invoke(entry, arg, got) \
((void)(got), (((CODE void (*)(uintptr_t))(uintptr_t)(entry))(arg)))
#endif
#endif /* __INCLUDE_NUTTX_BINFMT_FDPIC_H */

View file

@ -68,6 +68,11 @@ struct sigwork_s
struct work_s work; /* Work queue structure */
union sigval value; /* Data passed with notification */
sigev_notify_function_t func; /* Notification function */
#ifdef CONFIG_FDPIC
uintptr_t got; /* FDPIC data base of a module callback, or
* zero. Captured at registration, installed
* around the call on the worker thread. */
#endif
};
#ifdef __cplusplus

View file

@ -34,6 +34,10 @@
#include <nuttx/irq.h>
#include <nuttx/sched.h>
#if defined(CONFIG_FDPIC) && defined(CONFIG_SIG_EVTHREAD)
# include <nuttx/binfmt/fdpic.h>
#endif
#include "sched/sched.h"
#include "mqueue/mqueue.h"
@ -156,6 +160,19 @@ int mq_notify(mqd_t mqdes, FAR const struct sigevent *notification)
sizeof(struct sigevent));
msgq->ntpid = rtcb->pid;
#if defined(CONFIG_FDPIC) && defined(CONFIG_SIG_EVTHREAD)
/* If a module registered a SIGEV_THREAD callback, capture its data
* base now, while this runs in the module's context. The callback
* fires later on a work-queue worker that has no base of its own;
* nxsig_notification() reads this to install it around the call.
*/
msgq->ntwork.got =
(fdpic_base() != 0 &&
(notification->sigev_notify & SIGEV_THREAD) != 0) ?
fdpic_base() : 0;
#endif
}
}

View file

@ -34,6 +34,10 @@
#include <nuttx/signal.h>
#ifdef CONFIG_FDPIC
# include <nuttx/binfmt/fdpic.h>
#endif
#include "sched/sched.h"
#include "signal/signal.h"
@ -70,7 +74,24 @@ static void nxsig_notification_worker(FAR void *arg)
/* Perform the callback */
work->func(work->value);
#ifdef CONFIG_FDPIC
/* A module's callback runs here on a shared worker thread, which does not
* carry the module's data base. Install the base captured at
* registration around the call so the callback can reach its own globals;
* work->func has already been resolved to the code address. A non-module
* callback has a zero base and is called directly.
*/
if (work->got != 0)
{
fdpic_invoke((uintptr_t)work->func, (uintptr_t)work->value.sival_ptr,
work->got);
}
else
#endif
{
work->func(work->value);
}
}
#endif /* CONFIG_SIG_EVTHREAD */
@ -157,6 +178,22 @@ int nxsig_notification(pid_t pid, FAR struct sigevent *event,
work->value = event->sigev_value;
work->func = event->sigev_notify_function;
#ifdef CONFIG_FDPIC
/* When the callback is a module's, work->got was set at registration
* to the module's data base (this runs at send or expiry time, whose
* context is not the module's, so it cannot be read here). The
* callback is a descriptor: resolve it to the code address now --
* reading the descriptor is just a memory access and needs no base --
* and the worker installs the base around the call.
*/
if (work->got != 0)
{
work->func = (sigev_notify_function_t)
((FAR struct fdpic_desc_s *)event->sigev_notify_function)->entry;
}
#endif
/* Then queue the work */
return work_queue(SIG_EVTHREAD_WORK, &work->work,

View file

@ -37,6 +37,10 @@
#include <nuttx/kmalloc.h>
#include <nuttx/spinlock.h>
#if defined(CONFIG_FDPIC) && defined(CONFIG_SIG_EVTHREAD)
# include <nuttx/binfmt/fdpic.h>
#endif
#include "sched/sched.h"
#include "timer/timer.h"
@ -196,6 +200,19 @@ int timer_create(clockid_t clockid, FAR struct sigevent *evp,
/* Yes, copy the entire struct sigevent content */
memcpy(&ret->pt_event, evp, sizeof(struct sigevent));
#if defined(CONFIG_FDPIC) && defined(CONFIG_SIG_EVTHREAD)
/* If a module registered a SIGEV_THREAD callback, capture its
* data base now, while this runs in the module's context. The
* callback fires later on a work-queue worker with no base of
* its own; nxsig_notification() installs this around the call.
*/
ret->pt_work.got =
(fdpic_base() != 0 &&
(evp->sigev_notify & SIGEV_THREAD) != 0) ?
fdpic_base() : 0;
#endif
}
else
{