Read OSS

Rust in the Kernel: Safe Abstractions Over Unsafe Foundations

Advanced

Prerequisites

  • Article 1: architecture-and-directory-map
  • Article 2: boot-sequence-and-initialization
  • Rust fundamentals (ownership, traits, no_std, unsafe)
  • Understanding of C function pointer structs from Article 4

Rust in the Kernel: Safe Abstractions Over Unsafe Foundations

Throughout this series, we've seen a recurring pattern: C structures full of function pointers — sched_class, file_operations, inode_operations, io_issue_def — acting as vtables for runtime polymorphism. These work, but they carry inherent risks: nothing prevents a filesystem from leaving a function pointer NULL, nothing stops a driver from forgetting to release a lock, and nothing catches use-after-free at compile time. The Rust-for-Linux initiative aims to change this — not by rewriting the kernel, but by wrapping these C patterns in safe Rust abstractions that prevent entire classes of bugs.

As we saw in Article 1, Rust support is conditionally compiled via obj-$(CONFIG_RUST) += rust/ in the top-level Kbuild. This article explores what that rust/ directory contains, how C bindings flow into safe Rust APIs, and how a real GPU driver uses these abstractions.

The kernel Crate Structure

The kernel crate is the central Rust library for in-kernel code. Its lib.rs reveals an architecture that mirrors the C kernel's Kconfig-driven compilation model:

rust/kernel/lib.rs#L1-L160

#![no_std]
...
#[cfg(not(CONFIG_RUST))]
compile_error!("Missing kernel configuration for conditional compilation");

extern crate self as kernel;

pub use ffi;

pub mod alloc;
#[cfg(CONFIG_AUXILIARY_BUS)]
pub mod auxiliary;
#[cfg(CONFIG_BLOCK)]
pub mod block;
pub mod clk;
#[cfg(CONFIG_CONFIGFS_FS)]
pub mod configfs;
pub mod device;
pub mod driver;
#[cfg(CONFIG_DRM = "y")]
pub mod drm;
pub mod error;
pub mod fs;
#[cfg(CONFIG_I2C = "y")]
pub mod i2c;
pub mod init;
pub mod irq;
#[cfg(CONFIG_NET)]
pub mod net;
#[cfg(CONFIG_PCI)]
pub mod pci;
pub mod platform;
pub mod prelude;
pub mod sync;
pub mod task;
...
graph TD
    subgraph "kernel crate (rust/kernel/)"
        lib["lib.rs<br/>#![no_std]"]
        lib --> prelude["prelude.rs"]
        lib --> alloc["alloc/"]
        lib --> device["device.rs"]
        lib --> driver["driver.rs"]
        lib --> drm["drm/<br/>#[cfg(CONFIG_DRM)]"]
        lib --> auxiliary["auxiliary.rs<br/>#[cfg(CONFIG_AUXILIARY_BUS)]"]
        lib --> pci["pci.rs<br/>#[cfg(CONFIG_PCI)]"]
        lib --> init["init.rs"]
        lib --> sync["sync/"]
        lib --> fs["fs.rs"]
        lib --> net["net/<br/>#[cfg(CONFIG_NET)]"]
    end

The #[cfg(CONFIG_*)] attributes are the Rust equivalent of #ifdef CONFIG_* in C. The kernel's Kconfig symbols are passed as --cfg flags to rustc, so #[cfg(CONFIG_DRM = "y")] only includes the DRM module when DRM is built-in (not as a loadable module). This tight integration means the Rust code participates in the same conditional compilation system that governs the entire 30-million-line C codebase.

Notice #![no_std] at line 14 — there is no standard library. The kernel provides its own allocator, its own string types, its own synchronization primitives. Rust in the kernel operates in a freestanding environment.

The Prelude and Common Abstractions

Every Rust kernel module begins with use kernel::prelude::*:

rust/kernel/prelude.rs

pub use crate::alloc::{flags::*, Box, KBox, KVBox, KVVec, KVec, VBox, VVec, Vec};

pub use macros::{export, fmt, kunit_tests, module, vtable};

pub use pin_init::{init, pin_data, pin_init, pinned_drop, InPlaceWrite, Init, PinInit, Zeroable};

pub use super::error::{code::*, Error, Result};

pub use super::{str::CStrExt as _, ThisModule};

pub use super::{pr_alert, pr_crit, pr_debug, pr_emerg, pr_err, pr_info, pr_notice, pr_warn};

Key abstractions:

  • KVec, KBox — Kernel-aware allocators that use GFP_KERNEL flags instead of the standard library allocator
  • Error / Result — Wraps kernel error codes (-ENOMEM, -EINVAL) in Rust's type system
  • ThisModule — Reference to the current kernel module (for ownership tracking)
  • pr_info! and friends — Wrappers around printk() with format checking
  • pin_data, pin_init — Macros for safe initialization of pinned (non-movable) types
  • vtable — The macro that bridges Rust traits to C function pointer structs

Tip: If you're writing a new Rust kernel module, start with use kernel::prelude::* and the module! macro. Together they provide everything you need for basic module lifecycle management.

The C → Rust Binding Pipeline

Rust kernel code doesn't call C functions directly. Instead, there's a layered pipeline:

flowchart TD
    A["C Headers<br/>(include/linux/*.h)"] -->|bindgen| B["bindings_generated.rs<br/>(raw unsafe FFI)"]
    B --> C["rust/bindings/lib.rs<br/>(raw module, re-exported)"]
    C --> D["rust/kernel/*.rs<br/>(safe Rust abstractions)"]
    D --> E["Kernel Modules<br/>(drivers/gpu/drm/nova/, etc.)"]

    style A fill:#f9f,stroke:#333
    style B fill:#fbb,stroke:#333
    style C fill:#fbb,stroke:#333
    style D fill:#bfb,stroke:#333
    style E fill:#bfb,stroke:#333

Layer 1: Raw bindingsbindgen runs on C headers and generates bindings_generated.rs containing raw extern "C" function declarations and type definitions. These are unsafe and unergonomic.

rust/bindings/lib.rs wraps the generated code with appropriate #![allow] attributes:

//! This crate may not be directly used. If you need a kernel C API that is
//! not ported or wrapped in the `kernel` crate, then do so first instead of
//! using this crate.

#![no_std]
#![allow(clippy::all, missing_docs, non_camel_case_types, ...)]

mod bindings_raw {
    ...
    include!(concat!(env!("OBJTREE"), "/rust/bindings/bindings_generated.rs"));
}

The comment is a policy statement: drivers should never use bindings directly. The raw bindings are an intermediate representation.

Layer 2: Safe abstractions — The rust/kernel/ modules wrap raw bindings in safe Rust types. A raw struct device * becomes a Device with RAII cleanup. A raw spinlock becomes SpinLock<T> with type-safe locking. This is where Rust's ownership system adds value — the abstractions enforce lifetime and locking rules at compile time.

Layer 3: Drivers — Actual kernel drivers use the safe abstractions. They see Rust traits and types, not raw C pointers.

The rust/Makefile orchestrates this pipeline:

obj-$(CONFIG_RUST) += core.o compiler_builtins.o ffi.o
always-$(CONFIG_RUST) += bindings/bindings_generated.rs bindings/bindings_helpers_generated.rs
obj-$(CONFIG_RUST) += bindings.o pin_init.o kernel.o

#[vtable] and Pin Initialization Patterns

The #[vtable] Macro

In Article 4, we saw how C uses struct file_operations with function pointers. Rust's answer is the #[vtable] attribute macro. It marks a trait implementation and generates the corresponding C operations struct, automatically filling in NULL for unimplemented optional operations and setting has_* flags.

When you write:

#[vtable]
impl drm::Driver for NovaDriver {
    type Data = NovaData;
    type File = File;
    type Object = gem::Object<NovaObject>;
    const INFO: drm::DriverInfo = INFO;
    ...
}

The #[vtable] macro generates a C-compatible struct drm_driver with function pointers that call back into the Rust trait methods, handling the C-to-Rust FFI boundary automatically. Required methods that aren't implemented cause a compile error. Optional methods that aren't implemented result in NULL function pointers with appropriate HAS_* constants set to false.

Pin Initialization

Kernel data structures are often self-referential (e.g., a structure containing a list head that points back into itself) or must remain at a fixed memory address (e.g., structures registered with hardware). In C, you initialize these in place and never move them. In Rust, the move semantics make this difficult.

The #[pin_data] and try_pin_init! macros solve this:

#[pin_data]
pub(crate) struct NovaData {
    pub(crate) adev: ARef<auxiliary::Device>,
}

The #[pin_data] attribute marks which fields are structurally pinned (must not move). try_pin_init! provides in-place initialization that constructs the value directly at its final memory location, without ever moving it. This is essential for kernel objects that register themselves with C subsystems during construction.

classDiagram
    class RustTrait["trait drm::Driver"] {
        +type Data
        +type File
        +const INFO: DriverInfo
    }
    class VtableMacro["#[vtable] generates"] {
        +C struct drm_driver
        +fn pointers → Rust methods
        +NULL for optional ops
    }
    class PinInit["#[pin_data] + try_pin_init!"] {
        +In-place construction
        +No move after init
        +Self-referential safe
    }
    RustTrait --> VtableMacro : "attribute macro"
    PinInit --> RustTrait : "initializes pinned data"

Nova DRM Driver Walkthrough

Nova is a real Rust kernel driver for NVIDIA GPUs — not a toy example but an in-tree driver that demonstrates the full Rust driver model. It lives in drivers/gpu/drm/nova/.

The module root:

drivers/gpu/drm/nova/nova.rs

mod driver;
mod file;
mod gem;

use crate::driver::NovaDriver;

kernel::module_auxiliary_driver! {
    type: NovaDriver,
    name: "Nova",
    authors: ["Danilo Krummrich"],
    description: "Nova GPU driver",
    license: "GPL v2",
}

The module_auxiliary_driver! macro is the Rust equivalent of C's module_init() / module_exit(). It generates the initcall (as described in Article 2), the module metadata, and the registration/deregistration code. The type: NovaDriver tells the macro which type implements the driver.

The driver implementation:

drivers/gpu/drm/nova/driver.rs#L1-L79

pub(crate) struct NovaDriver {
    drm: ARef<drm::Device<Self>>,
}

impl auxiliary::Driver for NovaDriver {
    type IdInfo = ();
    const ID_TABLE: auxiliary::IdTable<Self::IdInfo> = &AUX_TABLE;

    fn probe(adev: &auxiliary::Device<Core>, _info: &Self::IdInfo) -> impl PinInit<Self, Error> {
        let data = try_pin_init!(NovaData { adev: adev.into() });
        let drm = drm::Device::<Self>::new(adev.as_ref(), data)?;
        drm::Registration::new_foreign_owned(&drm, adev.as_ref(), 0)?;
        Ok(Self { drm })
    }
}

#[vtable]
impl drm::Driver for NovaDriver {
    type Data = NovaData;
    type File = File;
    type Object = gem::Object<NovaObject>;
    const INFO: drm::DriverInfo = INFO;

    kernel::declare_drm_ioctls! {
        (NOVA_GETPARAM, drm_nova_getparam, ioctl::RENDER_ALLOW, File::get_param),
        (NOVA_GEM_CREATE, drm_nova_gem_create, ioctl::AUTH | ioctl::RENDER_ALLOW, File::gem_create),
        (NOVA_GEM_INFO, drm_nova_gem_info, ioctl::AUTH | ioctl::RENDER_ALLOW, File::gem_info),
    }
}
sequenceDiagram
    participant K as Kernel (C)
    participant R as Rust Abstractions
    participant N as Nova Driver

    K->>R: auxiliary bus probe callback
    R->>N: NovaDriver::probe(adev, info)
    N->>N: try_pin_init!(NovaData { ... })
    N->>R: drm::Device::new(adev, data)
    R->>K: C drm_dev_alloc() via FFI
    K-->>R: struct drm_device*
    R-->>N: ARef<drm::Device>
    N->>R: drm::Registration::new_foreign_owned()
    R->>K: C drm_dev_register() via FFI
    K-->>R: Success
    R-->>N: Ok(Self { drm })

Let's trace the patterns:

  1. impl auxiliary::Driver — The Rust trait that wraps C's struct auxiliary_driver. The probe function is called when the kernel matches this driver to a device.
  2. try_pin_init! — Initializes NovaData in place, as discussed above.
  3. drm::Device::<Self>::new() — Creates a DRM device, wrapping the C drm_dev_alloc() in a type-safe Rust wrapper with automatic cleanup.
  4. drm::Registration::new_foreign_owned() — Registers the device with the DRM subsystem (C's drm_dev_register()).
  5. #[vtable] impl drm::Driver — Declares the DRM driver operations. The declare_drm_ioctls! macro generates the ioctl dispatch table.

The beauty is what's absent: no manual error cleanup paths, no forgetting to unregister on failure, no NULL function pointer risks. The Rust type system handles resource cleanup through Drop, and the ? operator propagates errors with automatic unwinding.

Kbuild Integration and the Road Ahead

The integration point is a single line in the top-level Kbuild:

Kbuild#L103

obj-$(CONFIG_RUST)  += rust/

When CONFIG_RUST=y, the build system:

  1. Compiles core, compiler_builtins, and ffi crates
  2. Runs bindgen to generate bindings_generated.rs
  3. Compiles the kernel crate
  4. Compiles any Rust drivers enabled in the config

The current kernel (lib.rs lines 19-59) requires several unstable Rust features — arbitrary_self_types, derive_coerce_pointee, and others. Some are already stabilized in newer Rust versions, and the list shrinks with each release. The long-term goal is to require only stable Rust, making kernel Rust development accessible to anyone with a standard rustc install.

The Rust-for-Linux initiative is not trying to rewrite the kernel. It's creating a parallel path for new code — particularly drivers, which represent 60% of the codebase and are the primary source of bugs. As the abstraction coverage grows (device model, DRM, filesystems, networking), more and more new drivers can be written in Rust, gaining memory safety guarantees while interoperating seamlessly with the existing C subsystems we explored throughout this series.

Tip: To build the kernel with Rust support, you need rustc, bindgen, and rustfmt installed. Run make LLVM=1 rustavailable to check if your toolchain is compatible, then enable CONFIG_RUST=y in your .config.

Series Conclusion

Over these six articles, we've traced the Linux kernel from its 30-million-line directory structure through boot initialization, process scheduling, syscall entry, async I/O, and the Rust safety layer. The patterns repeat: linker section tricks for decentralized registration, C function pointer structs for polymorphism, cache-line-aware data layout for performance, and shared memory for eliminating overhead. These aren't isolated techniques — they're the architectural vocabulary of the world's most widely deployed operating system. Understanding them gives you a foundation for reading any part of the kernel source.