Linux device drivers affect the stability of the whole operating system. Running in the kernel space, device drivers handle critical resources and low-level operations. That’s why ensuring driver security and reliability is paramount.
With built-in security features, Rust helps you prevent memory safety issues in C-based Linux kernel code and create a robust solution out of the box. However, as the Linux kernel heavily depends on C for many core functions, building Linux solutions in Rust is a non-trivial task.
In this article, we explore the benefits and challenges of developing a Linux driver in Rust. You’ll also find a valuable, practical example of building a network monitoring driver for Linux, considering current Rust limitations.
This article will be useful for project and technical leaders who want to enhance system security when creating a Linux driver solution.
Why adopt Rust for Linux driver development?
Until support for Rust was added in 2022, C was the only language allowed for Linux driver development. Building a Linux driver in C involves integrating custom code into the kernel. A common way to do this is to incorporate code into the kernel source tree and recompile the kernel.
A more efficient alternative is to load modules into the kernel while it is operational. Such kernel modules can be dynamically loaded to or unloaded from the kernel as required, allowing you to extend kernel functionality without rebooting the system.
Although C is suitable for both approaches, it has significant drawbacks. They include a lack of memory safety (leading to issues such as buffer overflows, use-after-free errors, and access to uninitialized variables) and undefined behavior (which can cause unpredictable results and security flaws).
Rust is a memory-safe language by default, and its adoption is designed both to address memory-related and kernel security issues and to enhance Linux driver security and reliability. Moreover, adoption of Rust for Linux driver development is accelerating due to a range of benefits we will learn about in the next section.
Thinking of switching from C to Rust for Linux driver development?
Apriorit Rust and Linux professionals are ready to share their expertise and build a robust and secure solution for your project.
Key benefits of building a Linux driver in Rust
Let’s explore why it’s worth using Rust for your driver project. We can outline the following benefits:
- Enhanced memory safety. Built with a strong focus on memory safety, Rust prevents such issues as buffer overflows, use-after-free errors, and null pointer dereferences during compilation. It does this by enforcing ownership, borrowing, and lifetime rules that guarantee safe and efficient memory management. The Rust compiler strictly applies these rules, making it nearly impossible for developers to introduce memory-related issues. As a result, incorporating Rust into the Linux kernel has the potential to significantly decrease security vulnerabilities, contributing to a more secure operating system.
- Cost-effectiveness. Rust’s emphasis on correctness enables early detection of many errors, including improper null handling and integer overflows. This lets you save money by delivering more stable code out of the box.
- Performance comparable to C. Rust generates optimized native code without a garbage collector, delivering C-level performance in most kernel environments. For example, the Rust-for-Linux project’s trials to implement the NVMe driver in safe Rust showed its performance was on par with the C driver.
- Safe concurrency. Rust’s default immutability prevents unintended changes and side effects in code by disallowing modifications unless explicitly permitted. This helps catch unauthorized attempts to alter values, encouraging developers to write predictable and robust code that is easy to maintain. Additionally, immutable data can be safely shared across threads without the need for synchronization, resulting in safer and more reliable concurrent programs with less effort from developers.
- Increased developer productivity. Rust’s features, expressive type system, and robust tooling simplify the process of writing, reviewing, and maintaining kernel code and enhance overall developer productivity.
Main challenges of building a Linux driver in Rust
Alongside the significant benefits, adopting Rust for Linux driver development goes hand in hand with a range of challenges you need to consider:
1. Maintenance of a dual-language kernel. To enable Rust to work alongside C, developers use a Foreign Function Interface (FFI) to bridge the two languages. While FFI allows communication between Rust and C, it introduces additional complexity that can increase the risk of issues. Adding Rust requires creating and maintaining bindings between Rust and C code, often through wrappers. This leads to tight coupling, where changes in C code, such as modifying data structures or internal functions, require corresponding changes in Rust bindings. Such interdependency complicates development and increases the maintenance burden.
2. Linux kernel incompatibility. Not all kernel features are currently compatible with Rust, which limits the ability to leverage Rust.
3. Tooling and compiler instability. While Rust has been officially included in the Linux kernel since version 6.1, the Rust compiler and toolchain have not yet reached full stability or maturity to handle all kernel development tasks.
4. Unstable kernel API. Frequent updates to Linux kernel APIs and a lack of comprehensive documentation make it difficult to write and maintain safe Rust bindings, which leads to an increased maintenance workload.
Read also
Developing Reactive Applications: Asynchronous Programming in Rust vs Coroutines in C++
Learn how asynchronous programming in Rust can help you optimize server performance and enhance network application responsiveness.
How to create a Linux network monitoring driver in Rust: a practical example
To explore how you can overcome some challenges of using Rust for Linux in real life and deliver a secure and efficient solution, let’s look at an example from Apriorit experts of building a network monitoring driver.
Developing a Linux driver in Rust consists of different steps. In our practical example, it includes five steps:
1. Set up the environment
To set up the environment:
1. Download the Linux kernel source code.
2. Install the Rust compiler. Note: Each Linux version is compatible with a particular Rust version.
3. Set up .config with Rust support by enabling a corresponding flag in the config file. To do this, run the make menuconfig command to open menuconfig and search for the Rust flag:
Search Results
Symbol: RUST [=n]
Type: bool
Defined at init/Kconfig:2001
Prompt: Rust support
Depends on: HAVE_RUST [=y] && RUST_IS_AVAILABLE [=y] && (!MODVERSIONS [=n] GENDWARFKSYMS [=y]) && !GCC_PLUGIN_RANDSTRUCT [=n] && !RANDSTRUCT [=n] && (!DEBUG_INFO_BTF [=n] PAHOLE_HAS_LANG_EXCLUDE [=n] && !LTO [=n]) && (!CFI_CLANG [=n] HAVE_CFI_ICALL_NORMALIZE_INTEGERS_RUSTC [=n]) && (!CALL_PADDING [=y] RUSTC_VERSION [=0]>=108100) && !KASAN_SW_TAGS [=n] && (!MITIGATION_RETHUNK [=y] !KASAN [=n] RUSTC_VERSION [=0]>=108300)
Location:
(1) -> General setup
-> Rust support (RUST [=n])
Selects: EXTENDED_MODVERSIONS [=n] && CFI_ICALL_NORMALIZE_INTEGERS [=n]
4. Enable the appropriate flags in the list, then enable the Rust flag.
5. Compile the kernel via LLVM (Clang).
6. Install the kernel on the machine on which you will test your kernel module.
2. Organize the project’s structure and build a kernel module
After setting up the environment, you need to arrange the project’s structure. In our example, we use the same project structure as for kernel module development in C:
netmon
├ Makefile
├netmon.rs
├some_source_file.rs
Then you can proceed to building a kernel module. To build a Linux kernel module using Makefile, do the following:
1. Introduce constants to the build:
KDIR ?= /lib/modules/`uname -r`/build
MODULE_NAME := netmon
obj-m := $(MODULE_NAME).o
CC := clang
2. Set the build target:
all:
make -C $(KDIR) M=$(PWD) modules CC=$(CC)
3. Set the clean-up target:
clean:
make -C $(KDIR) M=$(PWD) clean
Once the kernel module is built, you can move to the next step.
3. Use a Foreign Function Interface
For now, there are few APIs available for the Linux kernel. If required functionality is not available, you will have to implement it using FFI, which allows a program developed in one language to call functions or use services created in another. The implementation process involves making the Linux kernel implement raw bindings for the C API via bindgen and creating safe wrappers for them.
At the time of writing, there is no API for network filters, so you can implement this functionality as follows:
- Go to the linux/rust directory.
- Create a subdirectory for the API you’ll use: for example, “netfilter”.
- Create two files in the subdirectory: netfilter_helper.h, a C header in which other C headers will be specified; and lib.rs, a Rust file that will generate the bindings for bindgen.
- Add the bindings to build them while building the kernel.
- After building the kernel with FFI bindings, you can use those bindings in the kernel module.
Now, let’s proceed to implementing the “safe” wrappers for “unsafe” bindings.
In this example, the network monitor kernel module aims to print packets. In addition, it features filtering by network protocol (TCP, UDP), IP address (v4), and port.
To get the necessary information about incoming and outgoing packets in Linux, you need to work with sk_buff. And to implement bindings for it, you first need to provide a wrapper around the kernel’s struct sk_buff, which is a metadata structure required for working with network packets:
#[repr(transparent)]
pub(crate) struct SkBuff(UnsafeCell<sk_buff>);
Where:
- UnsafeCell is the core primitive for interior mutability in Rust, used to wrap the sk_buff structure generated by the bindgen tool. All other types that allow internal mutability, such as Cell<T> and RefCell<T>, use UnsafeCell internally to wrap their data. This primitive allows for obtaining a mutable pointer &mut T from a shared reference, avoiding the undefined behavior.
- #[repr(transparent)] is an attribute used to specify that the type representation is the representation of its only field: sk_buff.
Having the type, we can implement functions for the kernel module’s business logic. One of these functions is from_ptr. This function is crucial, as we’ll get the pointer to the sk_buff buffer from the system.
impl SkBuff {
/// Creates a reference to [SkBuff] from a valid pointer.
///
/// # Safety
///
/// The caller must ensure that ptr is valid and will be valid for the lifetime of the returned [SkBuff] instance.
pub(crate) unsafe fn from_ptr<'a>(ptr: *const sk_buff) -> &'a SkBuff {
// SAFETY: Safety requirements guarantee the validity of the dereference, while the SkBuff type being transparent makes the cast ok.
unsafe { &*ptr.cast() }
}
Note: You must leave safety comments to show the constraints used to provide valid behavior. You should leave them in places where you leverage and declare “unsafe” functions. This guarantees that the user of your API will be aware of constraints you have created.
As you can see, the Rust coding style differs from C, starting from naming conventions and continuing with #defines. In C, when introducing a name to some magic numbers, you should use the #define preprocessing macro. In Rust, you must use a const or an enum if there are several related numbers. For example, when wrapping constants:
/* Responses from hook functions. */
#define NF_DROP 0
#define NF_ACCEPT 1
#define NF_STOLEN 2
#define NF_QUEUE 3
#define NF_REPEAT 4
#define NF_STOP 5
/* Deprecated, for userspace nf_queue compatibility. */
#define NF_MAX_VERDICT NF_STOP
The equivalent in Rust will look like this:
/// Responses from hook functions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookResponse {
/// Drop the packet.
Drop = netfilter::NF_DROP as _,
/// Accept the packet.
Accept = netfilter::NF_ACCEPT as _,
/// Packet has been "stolen" or consumed by the hook function.
Stolen = netfilter::NF_STOLEN as _,
/// Queue the packet to userspace for processing.
Queue = netfilter::NF_QUEUE as _,
/// Run the current hook function again.
Repeat = netfilter::NF_REPEAT as _,
#[deprecated(note = "Deprecated, for userspace nf_queue compatibility.")]
Stop = netfilter::NF_STOP as _,
}
impl HookResponse {
/// The highest possible verdict number.
#[allow(deprecated)]
pub(crate) const MAX_VERDICT: HookResponse = HookResponse::Stop;
}
Now, let’s proceed to the business logic and implement module initialization and de-initialization in the kernel.
Read also
Maintain Safety with Unsafe and C-interoperable Rust: Apriorit’s Tips
Explore how to work with unsafe Rust, including best practices from Apriorit experts.
4. Initialize and de-initialize the module
In C, initializing and de-initializing are performed via the module_init and module_exit macros, which are defined in the include/linux/module.h header. The only thing you need to consider is that initialization and cleanup functions must be defined before calling the macros to avoid compilation errors. To reference information about your module, you can use the MODULE_LICENSE, MODULE_AUTHOR, and MODULE_DESCRIPTION macros. Moreover, you don’t need to implement bindings for them, as they are already available.
To initialize the module, you need to implement the kernel::Module trait. It will have only one init function called upon module initialization. Use this method to perform whatever setup or registration your module requires.
To de-initialize the module, you must manually implement a Drop trait. This is a standard Rust trait used for cleanup.
To implement the necessary traits, do the following:
1. Create a structure:
/// Structure representing a kernel module.
struct NetMon {
/// Netfilter hook operations.
nfho: Pin<Box<NetFilterHookOps>>,
}
2. Implement the traits:
impl kernel::Module for NetMon {
fn init(_: &'static ThisModule) -> Result<Self> {
// Create a module instance and register hook operations.
...
}
}
impl Drop for NetMon {
fn drop(&mut self) {
// Unregister the hook.
...
}
}
3. Specify information about your module using the module! macro.
module! {
type: NetMon,
name: "netmon",
author: "author",
description: "Network monitoring module written in Rust",
license: "GPL",
}
You can find the rest of the code in our GitHub repository.
5. Test the code
To test the code, do the following:
1. Open diagnostic messages:
sudo dmesg –wH
2. Build and insert the module:
make
sudo insmod netmon.ko
3. Check the Linux kernel logs for the driver’s output, which includes a dump of monitored packets:
sudo dmesg
[Feb18 15:09] netmon: Rust Network Monitor (init)
[ +7.908866] netmon: Tcp: 172.64.41.4:443 -> 192.168.254.135:54964
[ +0.000112] netmon: Packet hex dump:
[ +0.000051] netmon: 000000 00 50 56 2D BB 02 00 50 56 E4 7B 03 08 00 45 00
[ +0.001117] netmon: 000010 00 28 80 13 00 00 80 06 26 48 AC 40 29 04 C0 A8
[ +0.000876] netmon: 000020 FE 87 01 BB D6 B4 0E 35
[ +0.013177] netmon: Tcp: 172.64.41.4:443 -> 192.168.254.135:54964
[ +0.000100] netmon: Packet hex dump:
[ +0.000038] netmon: 000000 00 50 56 2D BB 02 00 50 56 E4 7B 03 08 00 45 00
[ +0.000813] netmon: 000010 00 4F 80 14 00 00 80 06 26 20 AC 40 29 04 C0 A8
[ +0.000895] netmon: 000020 FE 87 01 BB D6 B4 0E 35 F0 E0 AF 42 56 4D 50 18
[ +0.000907] netmon: 000030 FA F0 BB 5B 00 00 17 03 03 00 22 2B AE 82 A2 A1
[ +0.000823] netmon: 000040 40 7F 63 E5 E3 20 BC 16 9A CE 61 F5 3D 65 33
As you can see, the driver monitors packets and records them in Linux kernel logs, which proves that the driver works as intended.
The above example shows how you can create a Linux driver in Rust using FFI. Since most real cases are more complicated than the one described, it’s important to work with experts in developing Linux drivers who also have solid knowledge of Rust.
How Apriorit can help you with building Linux and Rust-based solutions
With more than 20 years of experience developing Linux drivers and with Rust professionals on board, Apriorit can provide you with a full scope of services that include:
- Building complex Linux drivers to solve low-level development tasks
- Securing the Linux development process to enhance your product’s protection
- Creating process management solutions for Linux to monitor processes and eliminate issues with your Linux software
- Developing Rust-based cybersecurity solutions that are reliable and protect against memory-related issues
- Creating network applications in Rust using asynchronous programming to improve your app’s responsiveness
- Enhancing your in-house team with Linux and Rust developers to get niche expertise
Conclusion
Rust brings apparent advantages for Linux driver development, including bug-free code and enhanced security. However, your team can still face challenges on the way to secure and effective software. To overcome them and make the most out of Rust and Linux device drivers, make sure to enrich your team with experienced developers.
At Apriorit, we have extensive experience building kernel and driver solutions for various operating systems, including Linux, and our Rust developers will help you create software products of any complexity. With our wide range of skills, we can provide you with a secure and effective solution that meets your demands.
Need help building a Linux driver in Rust?
Outsource this task to Apriorit’s skilled specialists and receive efficient and secure software tailored to your business goals and technical requirements.