FreeBSD Dynamic sysctl OIDs: Module Trees, Context Ownership, and Safe Unload
Build runtime sysctl trees in a FreeBSD KLD with sysctl contexts, per-node ownership, safe rollback, and unload checks that preserve live handlers.
A kernel module sometimes needs to publish a setting or statistic only while the module is loaded. FreeBSD’s dynamic sysctl API lets a KLD attach nodes and leaves to the running MIB tree, then remove them as one owned group. The useful abstraction is not “call a macro and get a name”; it is a lifetime relationship between the OID, its parent node, the module’s handler or backing data, and any other code that depends on the tree.
This guide follows the SYSCTL_ADD_* and context interfaces documented for FreeBSD 15.1-RELEASE. It covers a module-owned tree, not device-instance sysctls or the placement of persistent settings in loader.conf or sysctl.conf. Confirm the installed sysctl(9) and sysctl_ctx_free(9) manuals before compiling code for a different release.
A runtime OID is an owned registration
The sysctl MIB is a tree. A node groups child OIDs; a leaf exposes a value or handler. The static SYSCTL_* declarations are appropriate when the kernel or module declares entries as part of its compiled-in layout. A module that registers entries at runtime should use the SYSCTL_ADD_* family and a sysctl_ctx_list to record ownership. FreeBSD’s manual explicitly recommends the wrapper macros over direct calls to sysctl_add_oid().
A context is initialized before registration, passed to every wrapper whose OID the module owns, and released when the owner is ready to disappear. The returned struct sysctl_oid * for a node is used to reach its children with SYSCTL_CHILDREN(). OID_AUTO lets the kernel select a numeric component; operators and scripts should normally address a named OID, not persist an automatically assigned number as though it were an ABI.
The following is pseudocode, not a compilable C excerpt: it shows the registration shape but intentionally omits the registration_failed body and the surrounding MOD_LOAD handler. A real handler must implement rollback and its failure policy as described below. The value has static storage because the sysctl remains visible for the module’s runtime lifetime; a mutable value in real code still needs a synchronization policy appropriate to its readers and writers.
static struct sysctl_ctx_list demo_sysctl_ctx;
static struct sysctl_oid *demo_root;
static int demo_ready = 1;
error = sysctl_ctx_init(&demo_sysctl_ctx);
if (error != 0)
return (error);
demo_root = SYSCTL_ADD_ROOT_NODE(&demo_sysctl_ctx, OID_AUTO, "demo",
CTLFLAG_RW, 0, "Demo module status");
if (demo_root == NULL)
goto registration_failed;
if (SYSCTL_ADD_INT(&demo_sysctl_ctx, SYSCTL_CHILDREN(demo_root), OID_AUTO,
"ready", CTLFLAG_RD, &demo_ready, 0, "Module initialized") == NULL)
goto registration_failed;
The manual’s example uses this same root-node/child pattern. CTLFLAG_RD makes this sample leaf read-only through sysctl; it does not make the C variable atomic or protect it from the module’s own threads. If a value changes concurrently, guard it with the module’s normal locking or use a carefully designed SYSCTL_ADD_PROC() handler. Access flags are an interface policy, not a substitute for data-race control.
Every registration result matters. A NULL result means the OID was not created. If adding a later child fails after an earlier node succeeded, unwind the entries already attached to the context before reporting initialization failure. Do not leave an OID whose handler or arg1 points into code or storage that the loader will discard.
Contexts encode tree dependencies
Creation order is significant. Context entries are tracked so that removal proceeds from newer leaves toward the older parent nodes. A context can also track nodes attached below an existing static tree. A dynamic CTLTYPE_NODE may be shared: multiple modules can attach separate children beneath a common node, and its lifetime is reference-counted. That convenience creates dependencies. If module A adds a subtree under a node owned by module B, B may be unable to remove its node while A still depends on it.
Keep ownership aligned with actual lifetimes. A single module-wide context is suitable for entries that all disappear with that module. For OIDs that follow a shorter-lived object, such as a device instance, use that object’s lifecycle and Newbus’s device_get_sysctl_ctx() context rather than letting a module-global context outlive or underlive the object. Never share one context between independently torn-down owners without an explicit ownership design.
Avoid manually deleting a parent recursively to “make unload work.” sysctl_remove_oid() supports recursive removal, but the manual warns that deleting a subtree another code section still uses can lead to a kernel panic. Contexts are intended to track interdependencies and remove entries in orderly fashion. Diagnose who owns the child instead of erasing the tree around it.
An unload error is a lifetime decision
The safe sequence is to stop new work, quiesce module threads and callers that depend on the entries, then ask the context to remove its OIDs. Keep the backing data, locks, and handler code alive until that operation succeeds.
case MOD_UNLOAD:
error = sysctl_ctx_free(&demo_sysctl_ctx);
if (error != 0)
return (error); /* keep the module and its backing state live */
demo_root = NULL;
return (0);
This is more than a return-code check. sysctl_ctx_free() first tries to unregister all context OIDs without freeing their resources. If that phase encounters a dependency error, it re-registers entries already removed and returns an error. Only after successful unregistration does it free OID resources whose reference counts permit deletion and free the context entries. Therefore, a failed call means the tree can still be live: do not free its data or pretend the module has been cleanly unloaded. Return the error from the MOD_UNLOAD path so the module remains loaded, then identify and remove the dependent OID through its owner.
The same rule applies during partial initialization. Check cleanup’s result rather than assuming rollback succeeded. If cleanup fails, preserve valid code and backing state and resolve the dependency before rejecting a load in a way that would leave registered OIDs dangling. Avoid global variables with shorter lifetimes than the corresponding OIDs.
Test the full registration and removal path
Use a disposable VM with a matching kernel build. Load the module, inspect the named leaf with sysctl demo.ready, and verify the expected access mode. If the example exposes a writable setting, test both a valid update and an invalid value using a purpose-built handler; do not make a diagnostic leaf writable merely to simplify testing. Then unload and confirm the name is gone. Repeat after forcing each registration step to fail in a development build, and verify that no partial node or stale handler remains.
If unload returns an error, record the exact OID tree, the module that owns each shared node, and whether the failure is due to a child or a reference count. Do not switch immediately to recursive deletion. For production diagnostics, record the FreeBSD release and module build, OID path, owner, permissions, and whether the failure occurred during registration or teardown. That evidence distinguishes a namespace collision from an ownership bug.
Dynamic sysctls are useful precisely because their existence can follow a module or device. Their correctness depends on making that scope explicit. A context gives the kernel a record of what the owner registered; disciplined teardown ensures the OIDs never outlive the code and state they expose.
Related:
- FreeBSD KLD Modules: Runtime Loading, Dependencies, and Safe Unload
- How to Place FreeBSD Tunables in sysctl.conf, loader.conf, or rc.conf Correctly
Sources: