Recursive RTOS Mutexes: Audit Acquisition Depth Along Reentrant Call Paths

A recursive mutex becomes available to another task only after its owner has matched every successful recursive take with a recursive give. Audit that balance along complete call paths, including callbacks and early returns. A log containing an “unlock” entry does not prove the outermost owner released the mutex.

The concrete implementation here is FreeRTOS Kernel V11.1.0 with recursive mutex support enabled. The procedure concerns repeated acquisition of the same mutex by the same task. It does not cover general priority-inversion analysis, C-library lock-hook integration or a lock-order graph for several different mutexes.

Verify the object and API family first

The V11.1.0 semaphore header requires a recursively created mutex for the recursive take/give macros and documents configUSE_RECURSIVE_MUTEXES. It also states that an existing owner can take again without waiting, regardless of the supplied block time. That second successful take still creates another release obligation.

Trace the handle from construction to every wrapper. Do not mix normal give calls with recursive take calls because the variable happens to use the shared semaphore-handle type. Record whether each call happens in task context; a recursive mutex is not an interrupt synchronization mechanism.

Give the mutex a stable diagnostic name. For each operation, capture task identity, call-site identifier, operation result and application-maintained depth. The depth ledger is test instrumentation, not a request to read private kernel fields. Keep its recording path bounded and avoid logging through a service protected by the same mutex.

Build a call-path depth ledger

Start with one public operation and expand every path that can reenter the protected component. Include a callback invoked while the outer operation still owns the mutex, error formatting, optional validation and nested helper calls. For each function, declare whether it acquires one level itself or requires its caller to provide ownership.

Step in one task Successful operation Depth afterward Available to another task?
Enter public update Recursive take 1 No
Enter reentrant query Recursive take 2 No
Return from query Recursive give 1 No
Return from update Recursive give 0 Yes, subject to scheduling

At each function exit, require the same depth it had on entry unless its documented contract explicitly transfers an obligation. For ordinary wrappers, transfer should be unnecessary. A helper that releases “until unlocked” can consume its caller’s level and expose partially updated state.

Recursive mutex depth ledger for one task: outer take raises depth to one, inner take to two, inner give returns to one, and only the outer give reaches zero and makes the lock available.
Balance belongs to each acquisition scope. An inner return must preserve the outer caller’s ownership level.

Count successful takes, not attempted takes

The pinned implementation increments recursive depth when the current owner takes again or when another task successfully obtains the mutex. A failed take does not create a level to release. The give path checks the owner and releases the underlying mutex only when recursive depth reaches zero.

A generic cleanup label that always gives is therefore unsafe if execution can reach it after acquisition failure. Conversely, an early return after successful acquisition must release that scope’s level. Review the control-flow edge, not merely whether both API names appear somewhere in the function.

Track an acquired flag for each scope, or use an appropriate language-level guard whose construction and destruction semantics are understood. Do not create a guard that releases on a failed acquisition. If the project uses exceptions or nonlocal exits, include those paths explicitly; ordinary C return-path reasoning does not automatically cover them.

Expose both missing and extra releases

  1. Normal nested call: take twice and give once. Verify that the original task is still the owner. Give the second time and verify release.
  2. Inner error: force every inner failure after its take. Require return to the outer depth, then eventual release by the outer scope.
  3. Failed outer take: let another task own the mutex and force the caller’s bounded attempt to fail. Require no give from that failed scope.
  4. Extra give: in an isolated test, attempt a give after full release. Require detection rather than assuming success.
  5. Competing task: keep a second task waiting while the owner unwinds. It must not enter after the inner give while the outer level remains.

A local harness compiled unmodified V11.1.0 sources with the official POSIX port and GCC 14.2.0. It checked two successful takes, owner retention after one give, release after the second, and rejection of an extra give. Those assertions passed. Injected wrapper failures and a gated competing-task campaign remain proposed tests.

An owner-query function can support controlled diagnostics when enabled, but a query is not a lock acquisition. Do not use a live snapshot to authorize another task to access the protected object. The application ledger should be reconciled at controlled checkpoints rather than treated as an independent synchronization mechanism.

Balanced depth does not prove safe reentrancy

A recursive lock permits the same task to enter again. It does not ensure that the protected data is valid halfway through the outer operation. A callback may read a new length with an old buffer pointer, for example, even though every take and give is perfectly balanced.

Document which invariants hold at each callback boundary. Move callbacks outside the protected mutation where possible, or expose only operations permitted during the intermediate state. Also bound recursive call depth; an accidental callback cycle can exhaust the task stack even when it never blocks on its own mutex.

Hypothetical example and review output

Imagine a hypothetical settings service whose update function owns a recursive mutex and calls a validation callback. The callback enters a query helper that takes the same lock. Its error branch returns without giving. The update’s final give reduces depth from two to one, leaving the service owned after the public operation appears finished.

The repair belongs in that helper’s exit contract, not in a supervisor that gives someone else’s mutex. Deliver the call-path ledger, acquisition-result handling, maximum intended nesting and injected-return matrix. Accept the change when every public call returns with its expected depth and waiting tasks can enter only after the true outer release. Target scheduling, stack bounds and the service’s data invariants still require product-specific evidence.

Similar Posts