02 - Kernel Module
The first exercise is to write an empty module. The module will print a message when it loads and a message when it is unloaded.
Tree Structure
Kernel modules are split into two categories:
- in tree - modules that reside within the kernel's source code tree
- out of tree - modules that are built outside the kernel
We will build out of tree modules. The folder structure of a kernel module is different from
the one of a program. The kernel uses its own tools to build the module, while applications
use cargo. The kernel uses make and Kbuild.
Kbuild File
The Kbuild file defines the object files that the module provides.
In our case, the object file will be called empty.o.
# SPDX-License-Identifier: GPL-2.0
obj-m := empty.o
Makefile
We need to use a special makefile that connects to the kernel's source build infrastructure.
# SPDX-License-Identifier: GPL-2.0
KDIR ?= /lib/modules/`uname -r`/build
default:
echo $$RUSTFLAGS
$(MAKE) -C $(KDIR) LLVM=1 M=$$PWD MO=$$PWD/build
clean:
$(MAKE) -C $(KDIR) M=$$PWD MO=$$PWD/build clean
rust-analyzer:
$(MAKE) -C $(KDIR) M=$$PWD rust-analyzer
This makefile defines three important targets:
default- that build the moduleclean- that cleans the modulerust-analyzer- that build therust-project.jsonfile used by rust-analyzer.
The makefile assumes that we will set the $KDIR variable to point to the kernel's soutrce code. In our case, this
variable will be similar to ../linux-6.18-rc5/.
Please make sure you export this variable before running any make targets.
export KDIR="../linux-6.18-rc5"
You can allways define the variable in the make command line: make KDIR=../linux-6.18-rc5 ....
The KRUSTFLAGS
Source Code
The main source code file of our module is empty.rs. It has to have the same name ast the object file defined in
KBuild.
Path Remap
We need to ask the rust compiler to rewrite the file names that start with ../ to ./
when dispaying errors, warnings and notes.
This is needed as we use the ./build folder for compiling and the compiler
considers the source files to be in ../
Usually this is done by adding a rust compilation flag using the KRUSTFLAGS environment variable.
export KRUSTFLAGS := --remap-path-prefix=../=
Starting with new kernel versions, the --remap-path-prefix=../= is already set in the kernel's Makefile.
The issue is that the flag is applied only to macros. We have to patch the kernel's main Makefile and change
this.
In the kernel's main Makefile, we have to make sure that these lines exists:
ifeq ($(call rustc-option-yn, --remap-path-scope=macro,diagnosgtics),y)
KBUILD_RUSTFLAGS += --remap-path-prefix=$(srcroot)/= --remap-path-scope=macro,diagnostics
endif
Enabling Rust Analyzer
To help us with code completion, we want to activate rust-analyzer. As this is not a standard rust application,
we have to run make rust-analyzer to obtgain the rust-project.json file which rust-analyzer can use
instead of Cargo.toml.
The rust-analyzer target creates the rust-project.json in the build folder instead of the
module's source folder. This prvents rust-analyzer from finding it.
Move the rust-project.json outside the build folder, next to the makefile.
The rust-project.json wrongly defines the path of the module's main source file (in this
case empty.rs). Please modify the root-module of emoty in rust-project.json from
"root_module": "../empty.rs"
to
"root_module": "empty.rs"
The Module
Printing to the kernel console is done using the pr_* macros such as
pr_info!,
pr_error!,
pr_warn!,
pr_debug! and
pr_alert,
A module is declared using the module! macro. It defines
the name, authors, description and the license of the module and the data type that implements the Module and Drop
trais. In this exmple, this is the Empty type.
The Module::init function may return an Error code if the module cannot be loaded. The kernel will try several times
and print the error if it still fails.
// SPDX-License-Identifier: GPL-2.0
//! Rust Empty Module
use kernel::prelude::*;
module! {
type: Empty,
name: "empty",
authors: ["Rust Workshop"],
description: "Rust empty sample",
license: "GPL",
}
struct Empty;
impl kernel::Module for Empty {
fn init(_module: &'static ThisModule) -> Result<Self> {
pr_info!("Empty Module (init)\n");
Ok(Empty)
}
}
impl Drop for Empty {
fn drop(&mut self) {
pr_info!("Empty Module (exit)\n");
}
}
Build the module
To build the module we use the make command. This will build all the Rust code and all the necessary C glue code
and output the kernel object file build/empty.ko. This is actually a static relocatable ELF file.
$ file build/empty.ko
build/empty.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=b451eeb137ea43d0abda65ee315a5dd545d46e50, with debug_info, not stripped
Loading the module
Loading
To load the module into the kernel we have to perform the following steps:
- copy the
empty.koin to$INIT_RAM_FS - rebuild the RAM disk so that it includes the module
- Boot the kernel
The module will not be automatically loaded by the kernel, we have to load it manually using the insmod command.
$ insmod empty.ko
empty: loading out-of-tree module taints kernel.
empty: Empty Module (init)
If everything works, we should see the module's init message.
We can see the loaded module using lsmod to list all the kernel modules.
$ lsmod
empty 12288 0 - Live 0xffffffffa0000000 (O)
We can see here the address at which the module is loaded.
Unloading the module
Unloading a module is done by using the rmmod command. It receives one single parameter that is the name
of the module (without the .ko extension).
$ rmmod empty
empty: Empty Module (exit)
We should see the drop message.
Module Parameters
Modules can receive parameters from the command line when loaded.
Parameters are defined in the module! macro using the params filed.
module! {
// ...
params: {
first_param: u8 {
default: 1,
description: "This parameter has a default of 1",
},
},
}
To read the value of a parameter, use
module_parameters::first_param.value()
where first_param is the name of the parameter.
Parameter values are assigned values when the module is loaded with insmod. The synatx is:
$ insmod module.ko parameter_1=value parameter_2=value ...
Run Script
Every time we change the module, we have to perform the following steps:
- Build the module
- Copy the driver to
$INIT_RAM_FS - Rebuild the RAM disk
- Run QEMU with the new RAM disk
- Load the module
We can use a run.sh script like the following placed in the module's folder to automate this:
- x86_64
- ARM64
#!/bin/sh
MODULE=empty.ko
BUILD_DIR="$(pwd)/build"
set -e
if [ -z $KDIR ]; then
echo "Kernel folder not set, use export KDIR=..."
exit 1
fi
if [ -z $INIT_RAM_FS ]; then
echo "initramfs folder not set, use export INIT_RAM_FS=..."
ecit 1
fi
echo "Building module"
make
echo "Kernel folder $KDIR"
echo "initramfs folder $INIT_RAM_FS"
KVERSION=$(cd "$KDIR" && make kernelversion)
echo "Kernel version $KVERSION"
echo "Copying driver"
MODULES_DIR="$INIT_RAM_FS/lib/modules/$KVERSION"
mkdir -p "$MODULES_DIR"
cp build/empty.ko "$MODULES_DIR"
echo "Compressing initramfs"
(cd "$INIT_RAM_FS" && find . -print0 | cpio --null -ov --format=newc | gzip -9 > "$BUILD_DIR/initramfs.cpio.gz")
echo "Running QEMU"
qemu-system-x86_64 -nographic \
-kernel "$KDIR/arch/x86_64/boot/bzImage" \
-initrd build/initramfs.cpio.gz \
-append "console=ttyS0" \
-s
#!/bin/sh
MODULE=empty.ko
BUILD_DIR="$(pwd)/build"
set -e
if [ -z $KDIR ]; then
echo "Kernel folder not set, use export KDIR=..."
exit 1
fi
if [ -z $INIT_RAM_FS ]; then
echo "initramfs folder not set, use export INIT_RAM_FS=..."
ecit 1
fi
echo "Building module"
make
echo "Kernel folder $KDIR"
echo "initramfs folder $INIT_RAM_FS"
KVERSION=$(cd "$KDIR" && make kernelversion)
echo "Kernel version $KVERSION"
echo "Copying driver"
MODULES_DIR="$INIT_RAM_FS/lib/modules/$KVERSION"
mkdir -p "$MODULES_DIR"
cp build/empty.ko "$MODULES_DIR"
echo "Compressing initramfs"
(cd "$INIT_RAM_FS" && find . -print0 | cpio --null -ov --format=newc | gzip -9 > "$BUILD_DIR/initramfs.cpio.gz")
echo "Running QEMU"
qemu-system-aarch64 -machine virt \
-cpu cortex-a53 \
-smp 1 \
-m 512M \
-nographic \
-kernel "$KDIR/arch/arm64/boot/Image" \
-initrd build/initramfs.cpio.gz \
-append "earlyprintk=serial,ttyAMA0 console=ttyAMA0 debug" \
-s
Make sure to export both $KDIR and $INIT_RAM_FS variables before running the script.
The script will place the module in /lib//lib/modules/<kernel_version>/ folder so that we
can use modprobe module_name to load the module.
Debug
Writing kernel modules is difficult as they run within the kernel and an error in the module might result in a kernel OOPS or panic. The kernel exposes several tools for debugging. The mechanisms are very similar to hardware debuggers.
Kernel Configuration
To use debuggers, we have to enable kernel debugging components.
These components should not be enabled in production builds, as they expose kernel objects that can be used to exploit it.
We have to run make menuconfig and select the following components:
- Kernel hacking
- Kernel Debugging
- Debug information -> Rely on the toolchain's implicit default DWARF version
- Compile-time checks and compiler options
- Provide GDB scripts for kernel debugging
- Compile the kernel with frame pointers
- Reduce debugging information (DISABLE)
Run QEMU with debug server
We use the -s parameter to enable QEMU's gdb server. This allows gdb to connect to it and debug the running kernel.
- x86_64
- ARM64
$ qemu-system-x86_64 -nographic \
-kernel "$KDIR/arch/x86_64/boot/bzImage" \
-initrd build/initramfs.cpio.gz \
-append "earlyprintk=serial,ttyS0 console=ttyS0" \
-s
$ qemu-system-aarch64 -machine virt \
-cpu cortex-a53 \
-smp 1 \
-m 512M \
-nographic \
-kernel arch/arm64/boot/Image \
-initrd build/initramfs.cpio.gz \
-append "earlyprintk=serial,ttyAMA0 console=ttyAMA0 debug"
-s
QEMU will start the gdb server on 127.0.0.1:1234.
Enable GDB Scrips
The kernel provides gdb scripts that add several commands to retrieve symbols. These need to be loaded by
gdb at startup. The scripts are located in the kernel's folder in scripts/gdb.
For security reasons, gdb will prfevent loading of arbitrary scriptys unless they are specifically named
in ~/.config/gdb/gdbinit. Please add the following line for everfy kernel folder (current and next)
that you use.
add-auto-load-safe-path $KDIR/scripts/gdb/vmlinux-gdb.py
Replace $KDIR with the path to the kernel directory
Debugger Configuration
We have to instruct the Rust compiler to include debug information and not to optimzie code. This is done
by setting adding to the $KRUSTFLAGS in the Makefile the -g -C opt-level=0.
export KRUSTFLAGS := -g -C opt-level=0
Failure to add these settings will make debugging unreliable.
- GDB
- Visual Studio Code
- Zed
The simples way to debug the kernel is using gdb directly. Make sure you have booted the kernel in a
QEMU session that has the -s argument.
In your module's folder run:
$ gdb -ex "target remote :1234" -ex "lx-symbols" $KDIR/vmlinux -tui
This will start gdb and:
- connect to the
127.0.0.1:1234gdbserver - run
lx-symbolsto load the kernel symbols - start the TUI and display the kernel's code
If gdb errors saying that lx-symbols is not available, please make sure you have added the gdb kernel
scripts to ~/.config/gdb/gdbinit.
Edit the .vscode/launch.json file. The program field points to the vmlinux file in the kernel folder.
Please make sure to replace KDIR with the actual path of the kernel. Visual Studio Code's launch.json
does not recognize the $KDIR environment variable.
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Linux Kernel with GDB",
"type": "cppdbg",
"request": "launch",
"program": "$KDIR/vmlinux",
"cwd": "${workspaceFolder}",
"miDebuggerPath": "/usr/bin/gdb",
"MIMode": "gdb",
"externalConsole": true,
"miDebuggerServerAddress": "127.0.0.1:1234"
}
]
}
After the debugger starts, run -exec lx-symbols in the console to ask gdb to read the symbols.
Edit the .zed/debug.json file. The program filed points to the vmlinux file in the kernel folder.
Please make sure to replace KDIR with the actual path of the kernel. Zed's debug.json
does not recognize the $KDIR environment variable.
[
{
"label": "Attach GDB",
"adapter": "GDB",
"request": "attach",
"program": "$KDIR/linux-7.1.5/vmlinux",
"cwd": "$ZED_WORKTREE_ROOT",
"target": "127.0.0.1:1234"
}
]
After the debugger starts, run lx-symbols in the console to ask gdb to read the symbols.
Exercises
- Modify the
Module::initfunction to return anError. Try loading the module with different errors and see what the kernel prints. - Modify the module to print several types of messages using different
pr_*and see what the kernel prints. - Print the current process PID, current CPU ID and current user ID in the
Module::initfunction. (Hint: use thecurrent!macro and theTaskstructure. - Add two
u8parameters to the module and print their sum in the init message. - Place breakpoints on the
Module::initfunction and step through it. - Store the current process in a variable and inspect it with the debugger.
- Place a breakpoint on the
Drop::dropfunction and step through it. - Write a
poweroffdriver that stops the virtual machine. Make sure you read the documentation forx86_64orarm64depeding on your developmenrt platform.
Bonus
Add two u8 parameters to the module and print their sum in the init message. Make sure you:
- boot the
nextversion of the kernel - set the correct
$KDIRpath to thenextversion of the kernel - run
make rust-analyzerwith the correct$KDIRpath pointing to the next version of the kernel