Skip to content
FreeBSDDeep Dive Published Updated 8 min readViews unavailable

FreeBSD sysctl(3) Readers: Bound Buffers and Handle Changing Tables

Build read-only FreeBSD sysctl collectors with typed ABI checks, bounded buffers, safe ENOMEM retries, and capability-limited telemetry.

FreeBSD’s sysctl(3) interface is useful to monitoring agents, support tools, and native applications that need kernel-exported state without parsing a command’s presentation output. Its small call surface can be misleading: a value may be a scalar, string, structure, or changing table; the caller supplies the output buffer and its capacity; and many names are specific to a particular release, kernel configuration, or subsystem.

This guide focuses on read-only userland collection. It is not a guide to changing tunables with sysctl(8), creating kernel OIDs, or treating every MIB as a stable cross-version application protocol. The contracts below are checked against the FreeBSD 15.1-STABLE sysctl(3) and cap_sysctl(3) manuals. Use the installed manual pages, headers, and release documentation as the contract for the actual system you support.

Choose the lookup form deliberately

The three libc interfaces share a data-transfer contract but solve different lookup problems:

Interface Name input Useful when Important boundary
sysctlbyname() ASCII name such as kern.maxproc A small number of known values or a readable diagnostic Resolves the name for the request; still requires the correct output layout and size
sysctl() Integer MIB array The caller already has the MIB or repeatedly queries a prepared one namelen is a count of integer components, not a byte length
sysctlnametomib() ASCII name, then caller-owned integer array Repeated requests or a prefix whose final component is supplied by the caller sizep is an input capacity and, on success, an output component count

The manual defines sysctlbyname() as accepting an ASCII name and internally looking up the integer MIB. sysctlnametomib() is intended for applications repeatedly asking for the same variable and can also convert a name prefix before a caller appends a final MIB component. Prefer names for simple one-off reads; cache a translated MIB only when profiling shows repeated name lookup is relevant and the target’s documented MIB shape supports the operation. Do not copy numeric MIB values from another operating system or treat them as portable constants; include the target FreeBSD headers for identifiers such as CTL_KERN and KERN_MAXPROC.

Treat a size query as a probe, not a reservation

For a read, pass newp == NULL and newlen == 0. The oldlenp value is the output-buffer capacity on entry; on success it is the number of bytes returned. Passing oldp == NULL with a non-NULL length pointer asks how much data is currently available. For some operations whose size changes frequently, the kernel attempts to round the reported size upward so a subsequent read can succeed, but this is not a lock or reservation on the next call.

Between sizing and reading, a table can grow. If the supplied buffer is too short, the documented result is ENOMEM; the call can copy as much as fits, and oldlenp then reports the amount copied. That partial prefix is not a complete table and must not be parsed as one. Probe again, enforce an application-owned maximum allocation, and retry only a bounded number of times. A fixed maximum prevents a kernel-exported table or unexpected name from turning a collector into an unbounded allocator.

ENOMEM is not synonymous with “the table grew.” The same manual also documents locked-memory-limit failures. If the size remains unchanged and the call continues failing, stop and report the error instead of growing memory indefinitely or spinning. Unknown names can produce ENOENT; an intermediate node can produce ENOTDIR, while a terminal node used as an intermediate can produce EISDIR. Preserve the exact errno and sysctl name in diagnostics, without converting a transient sizing race into a misleading “unsupported variable” result.

The following helper demonstrates a bounded policy for a variable-length, read-only value. The one-megabyte ceiling and three attempts are application policy, not FreeBSD constants. It discards partial output after ENOMEM, re-probes on the next iteration, and returns no buffer unless a complete read succeeds:

#include <sys/types.h>
#include <sys/sysctl.h>

#include <errno.h>
#include <stdlib.h>

static int
read_sysctl_bounded(const char *name, void **out, size_t *outlen)
{
    const size_t max_bytes = 1024 * 1024;

    if (name == NULL || out == NULL || outlen == NULL) {
        errno = EINVAL;
        return (-1);
    }
    *out = NULL;
    *outlen = 0;
    for (unsigned attempt = 0; attempt < 3; attempt++) {
        size_t need = 0;
        if (sysctlbyname(name, NULL, &need, NULL, 0) == -1)
            return (-1);
        if (need > max_bytes) {
            errno = EOVERFLOW;
            return (-1);
        }

        void *buf = malloc(need == 0 ? 1 : need);
        if (buf == NULL)
            return (-1);
        size_t got = need;
        if (sysctlbyname(name, buf, &got, NULL, 0) == 0) {
            if (got > need) {
                free(buf);
                errno = EOVERFLOW;
                return (-1);
            }
            *out = buf;
            *outlen = got;
            return (0);
        }

        int saved_errno = errno;
        free(buf);                 /* Never consume a partial table. */
        if (saved_errno != ENOMEM) {
            errno = saved_errno;
            return (-1);
        }
    }
    errno = ENOMEM;                /* Preserve sysctl's documented ambiguity. */
    return (-1);
}

This code is a pattern to review against the exact target release and the specific sysctl’s contract, not a universal decoder. Callers still need to free the successful result and validate its length and content before interpretation. After the bounded retries, it preserves ENOMEM: the manual documents both an undersized-buffer case and locked-memory-limit cases, so the errno alone cannot prove that the table was changing. A production collector can record the probed size and supplied capacity for each attempt, but should not relabel the kernel error without evidence.

Decode only the documented type and layout

sysctl(3) transfers bytes; the call signature does not include a destination type descriptor. The FreeBSD 15.1 manual lists KERN_MAXPROC as an integer and shows it being read into an int. For that known scalar, an exact-size check makes the assumption visible:

#include <sys/sysctl.h>

#include <err.h>

int maxproc;
int mib[2] = { CTL_KERN, KERN_MAXPROC };
size_t len = sizeof(maxproc);

if (sysctl(mib, 2, &maxproc, &len, NULL, 0) == -1)
    err(1, "sysctl KERN_MAXPROC");
if (len != sizeof(maxproc))
    errx(1, "unexpected kern.maxproc size: %zu", len);

Use the type and structure definitions documented for the target release. Do not infer an integer width from a printed sysctl value, cast an arbitrary byte buffer to a convenient structure, or parse a table using a structure copied from Linux or an old blog post. Some sysctls return opaque or implementation-specific records; prefer the dedicated library interface when FreeBSD documents one for the task. Validate array divisibility and each record boundary before iterating, and treat empty output as a valid possibility only when the variable’s manual says that is meaningful.

The manual says that, unless specifically noted otherwise, one sysctl() call returns a consistent snapshot of the requested data. That does not make a sequence of separate calls a transaction. If a collector reads a count and then a related table, state can change between those calls. Reconcile the values, retry under a defined policy, or report that the sample spans different observations; do not claim cross-call atomicity.

Keep a monitoring process read-only and least-privileged

The same API can set some values when newp points to an input value, and some setters require privilege. A telemetry program should make the no-write path explicit: pass NULL, 0 for newp and newlen, expose no configuration that turns a name into a write request, and never run the collector as root merely because a particular sysctl was unreadable. Read-only access still exposes host information, so decide which names belong in logs or remote metrics and avoid exporting process, file, or network tables without a need and access policy.

For a process that enters Capsicum capability mode, FreeBSD’s cap_sysctl(3) service offers corresponding cap_sysctl(), cap_sysctlbyname(), and cap_sysctlnametomib() calls. Its default capability is unrestricted over the sysctl namespace, so the manual recommends narrowing it. As part of sandbox setup, build a limit containing only the required names with CAP_SYSCTL_READ and apply it before relying on the restricted collector; test both an approved read and a denied out-of-scope read. The limit is a capability-service boundary, not a substitute for checking returned types, sizes, or errors. The manual also documents synchronization requirements when multiple threads share a capability channel.

A production collection checklist

Before shipping an agent, record the FreeBSD release and architecture, each sysctl’s documented type and meaning, whether the value is read-only, and the maximum expected response size. Exercise missing names, changed table sizes, zero-length responses, ENOMEM, locked-memory pressure, and permission failures in a disposable target environment. Confirm the collector does not parse partial bytes, does not retry forever, and remains useful when a release removes or changes a node. Emit the sample timestamp and source name, and separate “read failed” from a genuine zero value.

Use a command-line sysctl query as an operator cross-check, not as proof that a C parser’s type layout is correct. The CLI formats values for people and has its own options; the native API returns the documented bytes. For a table or structure, compare against the relevant manual and target headers and test on the target FreeBSD release. Mark examples as source-reviewed if they have not actually run there.

The safe mental model is: resolve a documented FreeBSD name, allocate within an explicit bound, treat the reported length as bytes, discard incomplete output, and decode only a release-supported layout. A successful syscall is evidence that one query returned data, not that every related metric is from the same instant or that its representation will remain unchanged forever.

Related:

Sources:

Comments