2026-05-03

RISC-V Linux Kernel Modules in Rust - From Scratch

RISC-V Linux Kernel Modules in Rust - From Scratch thumbnail

In this article, we will explore how to write Linux kernel modules in Rust for RISC-V architectures. We will start from scratch, covering the basics of setting up the development environment, writing a simple kernel module, and understanding the intricacies of Rust in the context of kernel development.

RISC-V Linux Kernel Modules in Rust - From Scratch

Introduction

In this little blog post / guide of mine, we’ll dive into how to:

  • Get started with cross-compiling the Linux kernel to RISC-V CPU architectures.
  • Get started writing kernel code in Rust.
  • Compile that kernel code to RISC-V along with the kernel itself, and finally;
  • How to run it on a QEMU setup, on an x86 machine.

Quite the todo list ahead of us, so let’s get right into it!

Table of Contents

Personal Primer / Motivation

This section will probably read a bit like those infamous intro sections on recipe blogs, where I share my entire lifestory, putting a huge wall of text between you, and the information you’re actually here looking for. If that sounds like a straight up bad time to you, feel free to click here to skip to the step-by-step instructions - no hard feelings from my side, I respect the hustle of wanting to get to typing some code.

In the almost 10 years since I started my software engineering university degree, I was always sort of spooked by hardware. I was scrambling to keep up with even learning the concepts and fundamentals of high-level programming and system design, so the added “burden” of needing to consider the hardware, was just one step too far. Not that I didn’t find it interesting, quite the contrary actually (this was during the period where IoT was really booming, and I was very curious about this very hardware-centric paradigm). I just found that I “got” system design, architecture, and high-level languages more organically, and thus decided to put my eggs in those baskets instead.

When we founded Tryp.com, during the 1st semester of my master’s degree, I stumbled into the role as our main cloud architect (with aggressive hints of DevOps and SRE), where the pre-established system architecture was leaning heavily into the ever-growing domain of serverless cloud computing. So once again, I found myself abstracting hardware away, and being glad to do so - we needed to iterate fast, and not needing to really manage hardware, was a huge part of speeding up our early stage development.

However, the first baby step towards this blog post, would happen to be born out of AWS Lambda and Fargate.

As it became time for us to start thinking about cost performance, the natural first step to take, was of course to deploy our Lambdas as ARM64 Lambdas, rather than the default x86_64. At this point, I was peripherally aware of the existence of ARM64 CPUs, mainly due to the industry-wide shock that was Apple Silicon, when they dropped the first M1 MacBook Pro. So I knew that ARM performed better for a lot of use cases, and migrating our Lambdas to ARM64 would be how I got familiar with ARM’s energy efficiency, and thus lower cost per vCPU hour. This would also be the first time I had to cross-compile a Docker image, to make sure that the Docker images built on our x86 CI/CD runners, would be able to run on our ARM64 hardware.

My journey towards not just being peripherally interested in deeper hardware knowledge, but wanting to dive into it, also contains the significant step of switching to daily driving Arch Linux, moving lots of our company’s infrastructure onto VPS-based solutions, and getting into writing more low-level programming languages. That entire journey will have to be a blog post of its own, just to keep this post from becoming a small book.

But all of these little steps would eventually lead me to, just a few weeks ago, reading an article about RISC-V adoption. I think it was something pertaining to the latest batch of Linux kernel changes, but it immediately sparked my interest, and got me hungry to both learn more, and maybe even contribute to the adoption of this open ISA standard.

So armed with my curiosity, a hunger to know more, and a motivation to write some code, I sat down to consult Claude, not yet aware that I’d be stumbling into a for me personally, new use case for LLMs.

LLM-assisted Learning

Throughout this little tinkering project of mine, I for the first time tried to, for an extended period of time, have an LLM act as a “teacher”. Needless to say, I have solicited LLMs for information, rubberducking, and brainstorming countless times already. But this is the first time where I explicitly requested a model (in this case Claude Opus 4.7) to lay out some checkpoints, and to only lightly guide me, and not provide me with direct answers.

And it actually worked out pretty well! The “lesson plan” and steps were solid, and it generally respected my request to not have everything handed to me (it got a bit over-eager at times when generating code samples, but no complaints aside from that). It also adjusted the plan on the go, as a response to the checkpoints and milestones I resonated with the most, and in some cases reshuffled priorities when given new information about e.g. the maturity of the Rust kernel APIs.

I’ll definitely try using LLMs this way again, as it actually kept me in the learning loop, which is a flow that can very easily be lost when outsourcing everything to agentic coding assistants.

Why RISC-V

Alright, Rust is quite fashionable these days, Linux is timeless, and the Rust integration into the Linux kernel has been drawing headlines for a little while - so far so good, this project makes sense. But why RISC-V?

As mentioned in the motivation section, RISC-V was actually the spark that got me on this journey. I had been dabbling with Rust a tiny bit, and I was in love with the idea of trying my hand at some kernel code, but taking a small dive into the world of RISC-V ended up being the final piece of the puzzle.

Unlike the industry-dominant x86, and the continuously emerging powerhouse ARM (the bleeding edge of it fueled by massive players like Apple and Amazon Web Services), RISC-V is a fully open standard. Anyone can go ahead and make themselves a RISC-V CPU, without being beholden to anyone. Want to make a new ARM? Then you have royalties to pay. Gotta pay the troll toll. Want to make an x86 chip? Then your company better be named AMD or Intel, otherwise that’s a non-starter.

Does that sound familiar? Because it’s a very familiar story to good ol’ Linux (or any FOSS software, for that matter). And Linux is a prime example of just how great, powerful, and ubiquitous a piece of technology can become, simply by keeping it completely open.

Now, obviously there is some degree of apples-and-oranges here. Distributing an operating system and having people tinker with it at home, thus leveraging the incredible power of the hobbyist community, is vastly different from openly distributing a CPU instruction set architecture. The average computer hobbyist simply isn’t building CPUs in their home office.

Where RISC-V still lacks behind, at least from the perspective of someone with my professional background (i.e., very cloud/VPS centric), is within data centers and personal computing. In both of these cases, the interest is very much there, and work is being put into both use cases.

But it is out there, and especially within IoT, academia, and in China’s domestic chip production, huge leaps have been made with RISC-V adoption, where especially IoT in many cases has RISC-V as the industry standard.

So it is both highly relevant as things already stand, and I expect it to only get more relevant as time passes. Give it a few more years, and running a Linux distro on a RISC-V personal machine might not just be for hobbyists and early adopters.

Alright, with that out of the way, let’s get into the meat and potatoes of it all, and write some kernel code.

Technologies and Concepts Worth Knowing

Throughout the step-by-step instructions, key concepts and technologies will be introduced as they emerge. However, to maximize the output of the article, there are a few worth establishing ahead of time:

  • RISC-V: An open source instruction set architecture, used in the design of CPUs. Unlike x86 and ARM, which are both proprietary, RISC-V is fully open, and thus seeing increasing adoption across many industries and use cases, while being a household name in fields like IoT.
  • Rust: A statically typed, compiled, low-level programming language. Despite having garnered slight infamy, in part due to its very vocal fanbase, it is a language being adopted quickly across the industry, due to its memory safety features, and high performance. In more recent times, it has been accepted into the Linux kernel codebase by the maintainers, with adoption still in its early stages.
  • QEMU: An open source emulation tool, used to (among many things) emulate different underlying hardware during software development. While we’ll be using it for light hardware emulation purposes, the tool is incredibly powerful, and is a valuable addition to any SWE toolbox.
  • The Linux kernel: The most famous open source project of all time, and arguably one of the most important pieces of software ever written. Powering millions of servers and machines world-wide (my own included), the Linux kernel keeps being the prime example of just how powerful open source software can be.

My Setup

As a service info, I went through these steps on an x86_64 machine, running Arch Linux 6.19.12. My machine has a very humble 16GB of RAM, and an i7 CPU - so nothing all that crazy spec-wise, and I had no issues running the project at any point.

Generally, most of these steps will be reproducible on any Linux machine, and I don’t immediately see any reason that they shouldn’t work on MacOS or Windows machines. The only point where your mileage will inevitably vary, is package install commands and flows - please refer to the package manager of your distro/OS.

Step-by-Step

This section will take us through all the steps in the journey from zero to slightly better than zero - baby steps! Each individual step will contain code examples and CLI instructions, but if you’d rather skip the polish and distractions of this blog post, you can jump straight into my GitHub repository instead, and immediately have a look at the code.

Step 1: Setting up the tool stack

To work through this project, you’ll minimum need the following tools installed:

  • riscv64-linux-gnu-gcc: A C and C++ cross-compiler, used to compile the Linux kernel to RISC-V.
  • QEMU - both quemu-base and quemu-system-riscv.
  • GNU Make: An extensively used, lightweight build-automation tool.
  • Rust, including a few additional Rust toolchain components.
  • LLVM (an incredibly impactful compiler toolchain, that we’ll use to cross-compile our Linux kernel and Rust code - created by one of the compiler GOATs, Mr. Chris Lattner).

If you’re on Arch Linux, all of these are in the extra repository, and can thus be installed using pacman:

sudo pacman -Syu riscv64-linux-gnu-gcc qemu-base qemu-system-riscv make llvm rust rustup rust-source rust-bindgen

Note that if you’re on Arch, you’ll also need base-devel installed:

sudo pacman -Syu base-devel

For all the various installations in this guide, make sure to refer to your distro/operating system’s package manager, and its repositories, for the correct package names and install commands.

NOTE: You may encounter various missing packages or libraries when building some of the tools in the upcoming steps, that I haven’t listed. These can be really system-dependent, and there might be a fair few I’ve missed, due to my own system already having a lot of development tools set up. Please install additional libraries as needed, and practice some Google-fu in tracking down missing tools.

Step 2: Getting the Linux Kernel ready

This one was pretty exciting to me, in a really nerdy way. Even though I run Linux on my laptop, I’ve never actually cloned, configured, nor built the kernel itself - let’s get right into it!

Step 2.1: Cloning the kernel codebase

This is pretty peaceful, and just requires a good ol’ git clone against one of the torvalds/linux repositories out there - I pulled mine from GitHub, keeping the fetch-depth at 1 to not get the full Linux git history, and pulled specifically the recently relieved v7.0:

git clone --depth=1 --branch v7.0 https://github.com/torvalds/linux.git

Once that finishes downloading, simple run cd linux, and you’ll find yourself at the directory root of the Linux kernel codebase.

Step 2.2: Building the kernel

For a start, we will put the Rust aspect a bit to the side, and just get a successful kernel configuration and build done.

The flow of this actually left me pleasantly surprised, as I expected a much harder time getting a kernel compiled on my own machine - I guess I can only say “shame on me”, for doubting the tooling support for a project like Linux.

For the default make command to function, the compiler toolchain for Linux needs a .config file, specifying which customizations to apply to the kernel that’s being built. You can either painstakingly write this yourself, OR you can leverage the menuconfig setup, which offers a neat little TUI (Terminal User Interface) for configuring Linux builds.

To launch the menuconfig, and in the same one-liner pass it the only customizations we need, run the following command:

make ARCH=riscv LLVM=1 CROSS_COMPILE=riscv64-linux-gnu- menuconfig

Because you’ve already passed the configurations needed to the command, you can go ahead and exit the menuconfig, saying yes to the prompt to save the config. Feel free to have a look at the generated .config file, to see the absolutely terrifying scale of configuring a piece of software like the Linux kernel.

In the above one-liner, we tell menuconfig to configure our kernel to compile for the RISC-V CPU architecture, using the riscv64-linux-gnu-gcc compiler, and with LLVM support enabled. You can check the output .config file for these very configs, to validate that you’ve applied them correctly.

Now, once you have your .config file ready, you’re off to the races - time to build the kernel. To do so, simply leverage the Makefile once again, by running:

ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- LLVM=1 make -j$(nproc)

A little bit more to unpack here, but the configs are largely identical, aside from us telling the Makefile to enable LLVM (we’ll need that for Rust later), and telling it to parallelise the build through the -j flag. In this case, we’re just passing the entire core count of the machine through nproc, to allow the build process to use all the available compute power of the machine.

Now, you just have to wait. Depending on your computer’s firepower (mine has very little), this can take a while to do. While you wait, take a moment to appreciate the beauty of being able to build this cornerstone of modern tech, right there on your own machine. Open source truly is the best source. Might just be me being a massive nerd, but it really did get me quite giddy to build this thing on my own machine for the first time.

Once everything finishes, you should be able to see the main build artifact under linux/arch/riscv/boot/Image - that’s your kernel image.

It’s just sitting there, ready to run. However, there are a few components missing before we can run it. The most notable one being a root filesystem for the kernel to use - let’s get familiar with Busybox.

Step 3: BusyBox

BusyBox is a single binary, implementing the bulk load of most common Unix commands. This has made BusyBox a natural basis for standard Unix commands in most Unix derivatives, and Linux being a descendant of Unix (slight simplification, but we can’t get anecdotal about every little detail), is no exception. Essentially, BusyBox will let you actually do stuff in your kernel once it boots, without having to go find or make binaries for commands like cat, ls, cd, etc.

Much like the Linux kernel, we need to build BusyBox from source. To download the BusyBox source code, head on over to https://busybox.net/downloads/, and find the latest version. In my case, that was v1.37.0, meaning that I downloaded the archive busybox-1.37.0.tar.bz2.

After downloading and unpacking the archive, the flow is very similar to that of the Linux kernel - you use the make menuconfig command to create your build configuration, and subsequently run make to build the binary.

However, my journey with BusyBox was not that simple, as I stumbled into two bugs - I’ll share how to fix them as we reach the points where I encountered them.

Step 3.1: An additional dependency

This first issue was very peaceful - I needed to install the ncurses-devel package - on Arch, this is just named ncurses, and can be installed with:

sudo pacman -Syu ncurses

Step 3.2, bug fix: A quick patch to a shell script

Once I sorted out the above dependency issue, I found myself running headfirst into a bug when trying to launch the menuconfig. I applied the fix from the following StackOverflow post: https://stackoverflow.com/questions/78491346/busybox-build-fails-with-ncurses-header-not-found-in-archlinux-spoiler-i-alrea, which worked like a charm. It simply boils down to adding a return type to the main function of a shell script - small nuisance, but a manageable problem, and just a small blocker.

Step 3.3: Configuring BusyBox

Finally, I got the menuconfig running, and could get my build config applied. You only need to set three configurations for BusyBox:

  • Toggle the Build static binary option, to have BusyBox compile to one single binary
  • Fill out the Cross Compiler prefix with riscv64-linux-gnu-
  • Disable tc - it might have been a bug specific to my kernel and BusyBox version combination, but it can cause some trouble in the build step, and we don’t really need it for what we’re doing here.

Save and exit like before, and you’re ready to build BusyBox, by simply running:

make

Step 3.4: The sha1_process_block64_shaNI` undeclared bug

You might not encounter this one, and if not, you can just skip ahead to configuring the root filesystem.

I did however run into this one, and the fix involved patching up some C code. The fix I applied was the patch highlighted here: https://issues.guix.gnu.org/74004

First, open the file libbb/hash_md5_sha.c in your editor of choice. Then find line 1316, and from there the beginning of the function with the name: unsigned FAST FUNC sha1_end.

This function contains some code that won’t link properly (due to containing an x86-specific construct) when compiled to a non x86 context, causing the build to fail. Since we’re cross-compiling the RISC-V, that leads to us having an issue. The fix is simply to add a conditional, which checks if the target CPU architecture is x86, and if not, skips that block of code. The patched version of the sha1_end function looks like so:

/* Used also for sha256 */
unsigned FAST_FUNC sha1_end(sha1_ctx_t *ctx, void *resbuf)
{
    unsigned hash_size;
    /* SHA stores total in BE, need to swap on LE arches: */
    common64_end(ctx, /*swap_needed:*/ BB_LITTLE_ENDIAN);
    hash_size = 8;
    if (ctx->process_block == sha1_process_block64
#if ENABLE_SHA1_HWACCEL
# if defined(__GNUC__) && (defined(__i386__) || defined(__x86_64__))
     || ctx->process_block == sha1_process_block64_shaNI
# endif
#endif
    ) {
        hash_size = 5;
    }
    /* This way we do not impose alignment constraints on resbuf: */
    if (BB_LITTLE_ENDIAN) {
        unsigned i;
        for (i = 0; i < hash_size; ++i)
            ctx->hash[i] = SWAP_BE32(ctx->hash[i]);
    }
    hash_size *= sizeof(ctx->hash[0]);
    memcpy(resbuf, ctx->hash, hash_size);
    return hash_size;
}

While this will fix it, you could most likely also get around it by completely deactivating hwaccel in the BusyBox config. I didn’t investigate this option, as I was pretty happy to also get to write some C in this project. Bugs like these are also a part of the big RISC-V adoption picture, as building for non x86 architectures can lead to issues like these.

With all of these bugs out of the way, you can now have another go at running:

make

Hopefully, you’ll now get the output busybox binary, cross-compiled for RISC-V, and ready to join your root filesystem. Try executing it - unless you are actively working on a RISC-V machine, this should fail, with an Exec format error. This is, counterintuitively, a sign of success. Your machine doesn’t know how to deal with this binary, because the instructions it wants to run don’t match the architecture of your CPU.

Step 4: Your Root Filesystem Layout

To boot your Linux kernel through QEMU, you need a root filesystem, i.e., your initramfs.

initramfs is a cpio archive the kernel unpacks into RAM at boot, and uses as the root filesystem.

In a situation where you wanted to emulate a machine with a CPU architecture matching yours, you could have QEMU run as a “guest” on your own file system.

We don’t have that sort of luxury for this project though, as we need an initramfs populated with RISC-V binaries, which is what our cross-compiled busybox binary is here to help us do. Aside from the busybox binary, we just need three things:

  • A directory structure which follows the standard Linux directory layout.
  • Symlinks between the standard Unix commands and the busybox binary, so that our kernel will know where to look for e.g. the ls and cat commands.
  • An init script.

If you haven’t already, this would be the time to make yourself a project-specific directory, from where you can start piecing everything together. To get started, within your project directory, make yourself a directory named rootfs:

mkdir rootfs

Step 4.1: The init Script

The init script for this little experiment is quite simple, and only does 3 things: Mounts proc and sys, and launches an interactive shell. The last point is important, as the init script should never return in any way. So handing over control to a shell at the end, is the flow we want.

So our script ends up being:

#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
exec /bin/sh

Nothing too outlandish there - a shebang, two mount commands, and the final executing of the sh binary, to launch the interactive shell.

Name this script init, with no file extension, and place it in the root of your rootfs directory. Then it’ll be ready for the kernel to pick up and execute.

Step 4.2: The initramfs Structure

In a default Linux file system layout, you would expect to see the following output from running ls:

~# ls
bin  dev  etc  init  lib  proc  sbin  sys  usr

So for a start, we’ll need to create all of these directories - just leave them empty for now:

cd rootfs
mkdir -p bin dev etc init lib/modules proc sbin sys usr/sbin usr/bin

Now, copy your busybox binary into the bin directory:

cp /path/to/your/busybox /path/to/your/rootfs/bin/

As mentioned earlier, busybox provides all the standard Unix utilities needed to run the kernel. But when you e.g. type ls in the shell, the kernel will be looking for a binary matching that command in its $PATH, commonly in the /bin/ directory. And as things stand, all we have in there is a single binary, our RISC-V busybox binary.

So to make our kernel actually provide the basic functionalities we’re used to when booting up a Linux system, we need to create symbolic links, i.e., symlinks between the commands and our busybox binary.

We’re not going to go completely all out here, but just cover the basic commands we’ll need:

cd rootfs/bin
for cmd in sh ash ls cat echo mkdir mount umount ln cp mv rm ps \
           grep sed sleep kill dmesg insmod rmmod lsmod modprobe \
           uname hostname pwd touch head tail less; do
    ln -s busybox "$cmd"
done

This little bash command creates symbolic links between the list of commands in the for-loop, and busybox. Luckily, busybox needs no further information than that - it knows what to do when invoked through e.g. the lssymbolic link.

If you try running ls -la in the bindirectory, you should now be seeing output similar to:

lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 ash -> busybox
-rwxr-xr-x 1 guramu guramu 1811160 Apr 22 00:10 busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 cat -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 cp -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 dmesg -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 echo -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 grep -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 head -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 hostname -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 insmod -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 kill -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 less -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 ln -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 ls -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 lsmod -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 mkdir -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 modprobe -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 mount -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 mv -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 ps -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 pwd -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 rm -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 rmmod -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 sed -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 sh -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 sleep -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 tail -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 touch -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 umount -> busybox
lrwxrwxrwx 1 guramu guramu       7 Apr 22 00:15 uname -> busybox

This is exactly what we’re after. We have a bunch of symbolic links, all pointing to the busybox binary.

Finally, our initramfs structure is complete, and we can move onto packaging it.

Step 4.4: Packaging your initramfs

To package your initramfs, we’ll be using cpio. Simply cd into your rootfs directory in your project directory, and execute:

cd rootfs
find . -print0 | cpio --null -o --format=newc | gzip -9 > ../initramfs.cpio.gz

Note: To get the correct initramfs structure, executing this command inside the rootfs directory is important.

This should create the archive initramfs.cpio.gz in your project directory. To verify that everything is in place to use it to boot your kernel, run:

gunzip -c initramfs.cpio.gz | cpio -t | grep init

This should return either a plain init or ./init from the last grep in the pipe chain - if that’s the case, you’re in business, and ready to finally boot your RISC-V kernel.

Step 5: Booting with QEMU

You’ve come a long way, but should now have all the tools and components in place to boot your kernel through QEMU.

QEMU will be the final piece of the puzzle, tying together everything we’ve built and configured so far: Providing emulated RISC-V hardware to boot the kernel, and providing the initramfs to that kernel.

To boot your kernel, you will be running the command:

qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -m 512M \
    -smp 2 \
    -kernel linux/arch/riscv/boot/Image \
    -initrd initramfs.cpio.gz \
    -append "console=ttyS0 earlycon"

There’s a fair few configs here, that I can only encourage you to dive into the implications of if they interest you. However, this blog post is already way longer than I ever expected it to be, and we haven’t even gotten to the really fun parts - so for now, just be vigilant about using the qemu-system-riscv64 command, the path to the initramfs you just packaged, and your previously compiled kernel.

NOTE: This section is tough to predict the level of bugs and issues within, and you may encounter problems that I didn’t, or lack libraries or dependencies that I already had on my local setup. If this is the case, the error logs, the internet, or indeed your preferred LLM, is your best friend, and the path forward will be one of trial and error. This can be frustrating, but getting this step working reliably, is the basis for the fun parts that come next, so please stick with the grind on this part!

If everything went well, QEMU should greet you with a veritable wall of output, culminating in a final line containing just: ~ # and a cursor - congratulations, your kernel is booting correctly, and your shell is running! Just look at that: A RISC-V compiled Linux kernel, running on your x86/ARM machine.

Now, let’s test out some of the busybox symlinks, to verify that your kernel is fully operational.

First, try out uname -a, which should give you output looking somewhat similar to this:

~ # uname -a
Linux (none) 7.0.0-dirty #4 SMP PREEMPT Wed Apr 22 08:00:24 JST 2026 riscv64 GNU/Linux

This tells you that you’re running a bleeding edge Linux 7 kernel, that your hostname is none (we didn’t configure one, so it tracks), and in my case that this is the 4th build from my kernel tree – yours will most likely say #1.

Diving a bit deeper, let’s learn a bit about our emulated “machine.” If you try running cat /proc/cpuinfo, you should be greeted with output somewhat similar to this:

~ # cat proc/cpuinfo
processor   : 0
hart        : 0
isa     : rv64imafdch_zicbom_zicbop_zicboz_ziccrse_zicntr_zicsr_zifencei_zihintntl_zihintpause_zihpm_zaamo_zalrsc_zawrs_zfa_zca_zcd_zba_zbb_zbc_zbs_sstc_svadu_svvptc
mmu     : sv57
mvendorid   : 0x0
marchid     : 0x0
mimpid      : 0x0
hart isa    : rv64imafdch_zicbom_zicbop_zicboz_ziccrse_zicntr_zicsr_zifencei_zihintntl_zihintpause_zihpm_zaamo_zalrsc_zawrs_zfa_zca_zcd_zba_zbb_zbc_zbs_sstc_svadu_svvptc

processor   : 1
hart        : 1
isa     : rv64imafdch_zicbom_zicbop_zicboz_ziccrse_zicntr_zicsr_zifencei_zihintntl_zihintpause_zihpm_zaamo_zalrsc_zawrs_zfa_zca_zcd_zba_zbb_zbc_zbs_sstc_svadu_svvptc
mmu     : sv57
mvendorid   : 0x0
marchid     : 0x0
mimpid      : 0x0
hart isa    : rv64imafdch_zicbom_zicbop_zicboz_ziccrse_zicntr_zicsr_zifencei_zihintntl_zihintpause_zihpm_zaamo_zalrsc_zawrs_zfa_zca_zcd_zba_zbb_zbc_zbs_sstc_svadu_svvptc

There’s a lot to unpack here, and I’ll again let you choose how deeply you want to dive into it. But one thing worth noting is all the many extensions on the isa (instruction set architecture), tells us which extensions are advertised by our virtual CPU.

We can also see that our QEMU “machine” has two harts available - "hart" is RISC-V nomenclature for what x86 calls a "hardware thread" or "logical core." It stands for hardware thread.

Let’s run one more investigative command before moving on, this time taking dmesg for a spin:

~ # dmesg | head -20
[    0.000000] Booting Linux on hartid 0
[    0.000000] Linux version 7.0.0-dirty (<yourusername>@<yourhostname>) (clang version 22.1.3, LLD 22.1.3) #4 SMP PREEMPT Wed Apr 22 08:00:24 JST 2026
[    0.000000] random: crng init done
[    0.000000] Machine model: riscv-virtio,qemu
[    0.000000] SBI specification v3.0 detected
[    0.000000] SBI implementation ID=0x1 Version=0x10007
[    0.000000] SBI TIME extension detected
[    0.000000] SBI IPI extension detected
[    0.000000] SBI RFENCE extension detected
[    0.000000] SBI SRST extension detected
[    0.000000] SBI DBCN extension detected
[    0.000000] SBI FWFT extension detected
[    0.000000] efi: UEFI not found.
[    0.000000] earlycon: ns16550a0 at MMIO 0x0000000010000000 (options '')
[    0.000000] printk: legacy bootconsole [ns16550a0] enabled
[    0.000000] OF: reserved mem: 0x0000000080000000..0x000000008003ffff (256 KiB) nomap non-reusable mmode_resv1@80000000
[    0.000000] OF: reserved mem: 0x0000000080040000..0x000000008005ffff (128 KiB) nomap non-reusable mmode_resv0@80040000
[    0.000000] SBI HSM extension detected
[    0.000000] riscv: base ISA extensions acdfhim
[    0.000000] riscv: ELF capabilities acdfim

Once again, we get a confirmation of our Linux kernel version, along with a hartid for the hardware thread booting Linux.

The Machine model entry is correctly populated, communicating an emulated RISC-V machine, running on QEMU.

A somewhat interesting line to decipher, is the riscv: base ISA extensions acdfhim line. This is giving us details about the currently active RISC-V extensions, through the slightly obscure “acdfhim” string - let’s break that down a bit, and learn more about out emulated host system:

  • i: base integer instruction set (mandatory)
  • m: integer multiply/divide
  • a: atomic instructions
  • f / d: single- and double-precision floating point
  • c: compressed instructions (16-bit encoding for common ops)
  • h: hypervisor extension

Especially the hypervisor extension is interesting here, as it tells us that this emulated host system is actually ready to support virtualization - so we could be emulating a RISC-V system on our emulated RISC-V system, should the need arise. This might sound goofy on the surface-level, but especially when developing on virtualization platforms, or container orchestration platforms, this could come in quite handy.

Service info: Exiting QEMU simply requires that you first press ctrl+a, then release, and subsequently x. That should give you a QEMU terminated output, and bring you back into your own terminal. This might be obvious information, but this had me scrambling just as much as the first time I needed to close Neovim, the first time around.

Alright, so far so great. You now have your entire toolchain running, and can run a cross-compiled Linux kernel on a QEMU emulator, with your own cross-compiled busybox as the backbone of your home-made initramfs.

With all of this in place, we can now really start getting our hands dirty, and play around with some Rust kernel code. If you find yourself looking for a good point to take a break, I’d say that this right here is the prime time to do so. If you’re itching to write some kernel code in Rust though, no need to stop chugging along.

Step 6: The Rust for Linux Toolchain

Alright, there’s still a tiny bit of config to do, but it all pertains purely to the meeting between Rust and Linux.

Luckily, the many Linux maintainers have made efforts to make getting started with Rust and Linux as easy as possible, by integrating a Rust readiness check into their Makefile. Combine that with their min-tool-version script, and knowing what’s missing or not up-to-date enough becomes pretty peaceful.

Let’s start out checking the minimum version requirements for the tools we already know we’ll need, using the minimum tool version script. In your cloned linux directory from earlier, execute the script to check the minimum version requirements for rustc, llvm, and bindgen:

~ # ./scripts/min-tool-version.sh rustc
1.78.0
~ # ./scripts/min-tool-version.sh bindgen 
0.65.1
~ # ./scripts/min-tool-version.sh llvm
15.0.0

As long as your versions of those tools are matching or higher, we’re off to a strong start.

If you, like me, happen to be running an incompatible version of Rust globally in your system, e.g. a nightly version, you can use rustup to pin another Rust version in whatever directory/project you’re in. Just run:

rustup override set stable

That should sort it out, and let you keep having nightly Rust as your system default.

Following that round of checks, we’re ready to leverage the rustavailable flow from the kernel Makefile:

~ # make LLVM=1 rustavailable

Hopefully, you’ll be greeted with a Rust is available! print. If not, you should at the very least be given a reason why your environment is not ready for using Rust with Linux. Keep iterating, until you get the availability confirmation - then you’re all set to rebuild your kernel, with Rust configured to be a part of the tree.

Step 7: Configuring Linux with Rust

This next step will most likely feel familiar at this point, as we’re once again diving into the Linux kernel menuconfig.

Just like before, run the command:

make ARCH=riscv LLVM=1 CROSS_COMPILE=riscv64-linux-gnu- menuconfig

Your RISC-V compilation configs are once again set through the environment variables passed in the above command, and the LLVM enabling is actually primarily for Rust support. But we do however have a few pieces of configuration to apply within the menuconfig:

  1. In the General Setupsection, scroll to the bottom to find Rust support, and toggle that on. Go back to the top-level view.
  2. In the Kernel hacking menu section, find and toggle the Sample kernel code option.
  3. Enter the expanded menu of that same menu item, and find the Rust samples menu item - also toggle this one on.
  4. Enter the expanded view of that menu item as well (I know, we’re getting deep in the configs here), and you’ll be greeted with an overview of the Rust kernel module samples. Find the one named Minimal, and press y to include it as an in-tree kernel module. This will make the rust_minimal module be built into the kernel, which is what we want for this first example.

With all of that out of the way, exit the menuconfig, saving the config on your way out.

You’re now ready for your second build of the Linux kernel, cross-compiled to RISC-V, but this time with Rust support, and the rust_minimal sample kernel module built into the kernel.

Once again, launch the kernel build process using:

ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- LLVM=1 make -j$(nproc)

This will once again require a bit of patience, depending on your available hardware, but should be faster than the first time around, due to reuse of the previous build artifacts when possible.

Once that exits, you’ll have a brand new kernel image, ready to execute in your QEMU setup.

Step 8: Verifying Rust Code Execution in Kernel

This next one should be pretty peaceful. Once again, launch your QEMU setup from your project directory, this time referencing your new kernel image (which unless you configured it to be written elsewhere, will be exactly where your old one way, so the exact same QEMU command should apply):

qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -m 512M \
    -smp 2 \
    -kernel linux/arch/riscv/boot/Image \
    -initrd initramfs.cpio.gz \
    -append "console=ttyS0 earlycon"

You’ll once again be greeted with a wall of output, as QEMU boots your RISC-V kernel. However, with your Rust kernel module built into the kernel tree this time around, there should be a few new prints in there. We’ll filter those out with a simple dmesg and grep combo:

~ # dmesg | grep -i rust
[    0.758339] rust_minimal: Rust minimal sample (init)
[    0.758858] rust_minimal: Am I built-in? true
[    0.759365] rust_minimal: test_parameter: 1

And there we (hopefully) have it! Three pieces of console output from the rust_minimal module. Nothing crazy, but definitive proof that your Rust and Linux toolchain setup is working without funny business.

Notice that Am I built in? true print? Let’s shake that up a bit, by instead modularizing the kernel module sample.

Step 9: A Simple Rust Kernel Module

To make this a loadable module instead of a built-in one, you’ll once again have to find your way into the linux directory, where you’ll launch the menuconfig just like before:

make ARCH=riscv LLVM=1 CROSS_COMPILE=riscv64-linux-gnu- menuconfig

This time, you’ll be changing/checking the following configurations:

  1. Double-check that "Enable Loadable Module Support" is enabled in the menuconfig, since it's required for loadable modules to work - should be the default option, can be seen on the very first page of the menuconfig.
  2. Subsequently, maneuver all the way back to where you enabled the Rust Minimal sample (Top-level -> Kernel hacking -> Sample kernel code -> Rust samples). Here, you will once again modify the value of the Minimal sample, by pressing m to load it as a loadable module.

Close and save the menuconfig, and rebuild your kernel:

ARCH=riscv CROSS_COMPILE=riscv64-linux-gnu- LLVM=1 make -j$(nproc)

To validate that the module was built correctly, check the linux/samples/rust/directory, where you’re specifically looking for the file rust_minimal.ko. .ko is the file extension for kernel modules. In the same directory, you can find the source code for all the Rust samples, and the build artifacts from any module you have the kernel build flow compile for you.

For our QEMU-hosted kernel to find this loadable module, we need to copy it into our initramfs, in the lib/modules/ directory - in the linux directory, run:

cp ./samples/rust/rust_minimal.ko ~/path/to/your/project/rootfs/lib/modules/

With that done, in your project directory, rebuild your initramfs:

cd rootfs
find . -print0 | cpio --null -o --format=newc | gzip -9 > ../initramfs.cpio.gz

Now, with a fresh kernel and an updated initramfs, re-launch your QEMU setup, pointing at the new kernel image:

qemu-system-riscv64 \
    -machine virt \
    -nographic \
    -m 512M \
    -smp 2 \
    -kernel linux/arch/riscv/boot/Image \
    -initrd initramfs.cpio.gz \
    -append "console=ttyS0 earlycon"

Instead of looking for the output in the kernel launch output, you’ll instead have to manually initiate the module using insmod:

~ # insmod lib/modules/rust_minimal.ko 
[   13.463864] rust_minimal: Rust minimal sample (init)
[   13.465723] rust_minimal: Am I built-in? false
[   13.473405] rust_minimal: test_parameter: 1

Nothing much will have changed! However, you’ll notice that the Am I built-in? console log now correctly says false.

insmod lets us load a kernel module - to remove it again, we’ll use rmmod, referencing only the module name:

~ # rmmod rust_minimal
[   89.664436] rust_minimal: My numbers are [72, 108, 200]
[   89.667130] rust_minimal: Rust minimal sample (exit)

And just like that, you’ve both loaded and unloaded a kernel module in your RISC-V environment. Now how about we actually start touching some Rust code?

Step 10: Tinkering With the Rust Code

Now that we’ve validated the end-to-end flow with the Rust modules, we can start modifying the codebase a bit. For now, we’ll contain these changes within the rust_minimal.rs file in the linux samples directory, just to validate that changes are picked up correctly.

So for this step, we’re purely focusing on the contents of linux/samples/rust/rust_minimal.rs.

Before getting started, spend some reading the Rust code, to get a feel for the layout of a kernel module. Especially the module! macro, and the way both init and drop are declared and implemented, will give you a nice peek at how to structure Linux kernel modules in Rust.

For a few simple experiments, try adding a new instance of the pr_info! macro, and changing the numbers in the vector stored internally by the module (these are the numbers displayed by the unloading function). You could also go as far as adding new parameters to the module definition, or try accessing kernel data through the Rust kernel APIs - the latter will force you to read some of the Rust kernel APIs, which is also a great exercise.

You could e.g. try fetching out the number of CPU IDs in the emulated system, and output them via a pr_info call.

If you want to dive deeper into the bridging between the main C codebase, and the new Rust components, you could try fetching out the current count of jiffies(the number of timer interrupt ticks that have occurred since the system booted - the kernel's heartbeat, if you will).

Once you’ve made your intended modifications, you’ll once again run the flow from Step 9: Build the kernel, move the .ko file to your initramfs, rebuild your initramfs, and relaunch QEMU.

If everything went well, you should see your changes reflected when loading and unloading the module through insmod and rmmod.

Now, let’s get our module separated from the kernel codebase.

Step 11: An Out-of-tree Module

Time to move our sample kernel module out of its home in the linux build context, and into our own, separate workflow - making it an out-of-tree module!

In your RISC-V project directory, make yourself a directory at the top-level (i.e., not inside of your rootfs directory), named something like my-module/.

mkdir my-module

For now, just copy your modified rust_minimal.rs file into this directory, renaming it to my_module.rs in the process:

cp ~/path/to/linux/sample/rust/rust_minimal.rs ./my-module/my_module.rs

Step 11.1: Build-configurations and a Makefile

In the my-module dir, you will need two additional files: A Kbuild and a Makefile.

The Kbuild file tells the kernel build system of the same name, how to build this module. For our use case, we can settle for this setup:

obj-m += my_module.o

Note: The Kbuild file is simply a file with no extension named Kbuild.

obj-m tells kbuild to build the module as a loadable module. The .o extension tells kbuild the stem name — it'll find my_module.rs in the same directory, and build my_module.ko

For the Makefile, we’re looking to add the same level of convenience to our workflow, that the Makefiles we’ve interacted with when working with BusyBox and the Linux kernel added to their respective tools. Luckily, we aren’t building anything quite as complex, and our Makefile for our first out-of-tree module can settle for:

KDIR ?= ~/path/to/linux
PWD := $(shell pwd)

default:
    $(MAKE) -C $(KDIR) M=$(PWD) ARCH=riscv LLVM=1 modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

Note: Just like the Kbuild file, the Makefile is simply named Makefile, and has no file extension.

The default action here, uses the Makefile from the Linux kernel codebase, and executes the modules command against your module directory. The output will, just like when we built it in the kernel tree, be a .ko file.

Step 11.2: A Top-level Makefile

This is an optional quality of life improvement. I highly recommend doing it, but all it achieves is a faster feedback loop, and an easier development flow. So ultimately, you get to spend more time on the fun parts, and less time remembering launch commands and update flows.

We’ve already made a Makefile for the my-module directory. So in terms of building the module itself, we’re golden. But the entire flow of copying it into the rootfs directory, subsequently rebuilding our initramfs, and launching QEMU? Still painfully manual. Let’s sort that out, shall we?

My top-level Makefile is pretty verbose, and I think it would swallow up a bit too much of this blog post. So what I’d recommend doing, is to copy my reference code from the project GitHub repository: https://github.com/grammeaway/linux-rust-riscv-project/blob/main/Makefile

Make sure to actually check the code through - executing Makefiles from unknown sources (like e.g. me), is a very quick way of causing quite sinister breaches, or worse.

The only things to make sure to adjust, are the variables at the top of the file:

KDIR      ?= $(HOME)/path/to/linux
KERNEL    ?= $(KDIR)/arch/riscv/boot/Image
ROOTFS    ?= rootfs
INITRAMFS ?= initramfs.cpio.gz
QEMU      ?= qemu-system-riscv64

Make sure these match your local environment setup. The file will dynamically treat all directories with a Kbuild file as a target, so when we add a new kernel module later, this file can be fully unchanged.

Step 11.3: Adjusting the Module

For this example, we’ll more or less leave the functionality of the module alone. However, we do need to make sure that the module! macro has the correct module name and metadata (i.e., my_module). Make sure to adjust that, and if you’re feeling like properly cleaning up the code, it would make sense to also rename the kernel module struct, and adjust the codebase accordingly.

Step 11.4: Building and Running Your Module

Now, leveraging our brand new Makefiles, let’s get everything built and running.

In the top-level of your RISC-V project directory, simply run the default Makefile action, and wait for the builds and repackaging to complete:

make

Note: If you previously needed to have rustup pin you to a stable Rust release, you will most likely have to do this again in each module directory. Simply cd into them, and run:

rustup override set stable

Verify that your my-module directory now contains the various kernel module build artifacts, including a my_module.ko file.

If everything went through without hiccups, you can now once again leverage our Makefile to launch the QEMU setup:

make run

Once you reach the interactive shell, try loading your new module with insmod:

insmod lib/modules/my_module.ko

If everything went completely according to plan, you should now once again be greeted with the various info printed through the pr_info! macro in your init() function.

And similarly, when you need to unload the module, you’ll run:

rmmod my_module

Which should give you the output specified in the drop() function.

So far, so good. But nothing all that RISC-V specific - let’s fix that with some inline Assembly code, specific to the RISC-V ISA!

Step 12: Your Own Kernel Module (adding in-line Assembly!)

In this step, we’ll dive a bit deeper into the RISC-V theme.

So far, RISC-V has mainly been a target architecture that we compile to, and have our QEMU setup emulate the hardware of. Now that our entire kernel module development stack is in good shape, we can move on to writing code that will only work on RISC-V hardware, and that leverages RISC-V Assembly code. Luckily, we won’t need to leave our Rust toolchain for any of this, as Rust’s convenient macro asm!, allows us to write in-line Assembly code.

What we’ll be building is a module that reads values from three different RISC-V CSRs.

“CSR” stands for Control and Status Registers, and they are RISC-V's mechanism for privileged state - the registers that control and report on processor operation, distinct from the general-purpose registers our code would do arithmetics with.

There’s a lot to unpack about CSRs, and there are some of them that our setup most likely wouldn’t allow us to read from. For this example, we’ll work with three CSRs that are available to a kernel running in the default S-mode (supervisor), like ours.

We’ll be reading the following three CSRs:

  • time, located in register 0xC01 - a counter that ticks at a fixed rate.
  • cycle, located in register 0xC00 - the CPU cycle counter.
  • instret, located in register 0xC02 - the retired instruction counter.

Step 12.1: A Brand New Module

First, we’ll need to make a new directory, and a new setup for our 2nd out-of-tree module.

In the top-level of your RISC-V project directory, create a new directory called my-csr-module:

mkdir my-csr-module

In this directory, you’ll once again need a Kbuild file and a Makefile, which can pretty much match your setup from my-module. Naturally, be aware of the file naming.

You’ll of course also need the module source code itself. Make a file named my_csr_module.rs, and either copy over your basic structure from my_module.rs, OR try writing out the entire structure from scratch, using rust_minimal.rs or my_module.rs as a reference - I did the former, but I imagine that the latter will yield a better learning outcome.

Regardless of which approach you choose, you need to once again stay mindful of your metadata passed to the module! macro.

Once you have a bare-bones kernel module, with an init() and drop() function ready, we can introduce our first function reading from a RISC-V CSR.

Step 12.2: The time CSR

In our Rust kernel module codebase, we’ve already used a few macros, notably the pr_info! and module! macros. For our in-line Assembly project, we’ll be utilizing the asm! macro:

asm!("csrr {0}, time", out(reg) value);

This is our humble beginning. To briefly explain what’s going on here:

  • csrr {0}, time is the RISC-V instruction "CSR read." Here, it reads the time CSR into the register represented by {0}.
  • out(reg) value is the operand. It tells the compiler to pick any general-purpose register, put the result in it, and assign that register's value to the Rust variable value after the instruction runs.
  • {0} references the first operand. If you had {1}, it'd be the second.

You don't pick which register. You simply provide the constraint (reg = any general-purpose register), and the compiler picks. That's the whole point of the asm! macro - Rust and LLVM handle register allocation.

However, the Rust compiler won’t let you just make a call like this. Executing raw Assembly is inherently unsafe to do, as the Rust compiler won’t be able to protect you the way it can with regular Rust code - so we need to denote this macro call as unsafe, and also make sure to declare the value variable, and import the asm! macro:

use core::arch::asm;

let mut value: u64;
// SAFETY: reading a CSR is a pure read with no side effects.
unsafe {
    asm!("csrr {0}, time", out(reg) value);
}

In this block of code, we mark the code as unsafe through the aptly named unsafe block. The // SAFETY: comment is a Linux kernel coding standard for Rust, when running unsafe operations. It shows that you’ve thought this through, and know that this code is actually safe to run, despite needing to flag it as unsafe.

Alright, now we have the code laid out. Let’s wrap it in a callable function:

fn read_time_csr() -> u64 {
    let value: u64;
    // SAFETY: reading the `time` CSR has no side effects.
    unsafe {
        core::arch::asm!("csrr {0}, time", out(reg) value);
    }
    value
}

And just like that, we have a Rust function reading from the RISC-V CSR time, and returning the value - with proper safety annotations and everything.

However, as we’ve established, this function will only work on RISC-V architectures. If this Assembly code got executed on a non RISC-V CPU, things would crash at best, and potentially cause actual damage at worst. And regardless, it would just be bloat in the kernel, which we obviously want to minimize. Time to introduce a very nifty Rust feature: Conditional compilation checks.

By simply adding the following line above our function:

#[cfg(target_arch = "riscv64")]
  • we can ensure that this function is only included and compiled when the target architecture is a 64-bit RISC-V architecture. So with that, our final CSR reading function ends up being:
#[cfg(target_arch = "riscv64")]
fn read_time_csr() -> u64 {
    let value: u64;
    // SAFETY: reading the `time` CSR has no side effects.
    unsafe {
        core::arch::asm!("csrr {0}, time", out(reg) value);
    }
    value
}

And there you have it! A proper, RISC-V exclusive function, for our kernel module to use - but only when compiled for RISC-V.

Let’s call it in our init() function, and use the pr_info! macro to get the output when the module loads - once again, we need to be mindful that this should only be executed when compiled for RISC-V machines. Luckily, the cfg gate can also be dropped into a function, as long as the gated code is enclosed in curly brackets (how Rust manages scope, even within function contexts). In your init() function, add the following block of code:

#[cfg(target_arch = "riscv64")]
{
    let time = read_time_csr();
    pr_info!("RISC-V time CSR: {}\n", time);
}

So just like we’ve seen before, we have an architecture-guarded piece of code, which sets a variable equal to the output of a function, and prints it - only this time, the value being printed is read from a RISC-V registry! Pretty neat stuff.

Run your Makefile flows, and try loading and unloading the module with insmod and rmmod. Hopefully, you’ll be greeted with and output similar to:

~ # insmod lib/modules/my_csr_module.ko 
[    6.305845] my_csr_module: loading out-of-tree module taints kernel.
[    6.339765] my_csr_module: Rust RISC-V CSR sample (init)
[    6.342029] my_csr_module: Am I built-in? false
[    6.343916] my_csr_module: RISC-V time CSR: 68613839

Data pulled straight from the registries of our RISC-V “machine”, by code that only executes if compiled for RISC-V.

Step 12.3: cycle and instret

Adding the other two CSRs is just as simple as the time CSR. Add two new functions to your module:

#[cfg(target_arch = "riscv64")]
fn read_cycle_csr() -> u64 {
    let value: u64;
    // SAFETY: reading a CSR is a pure read with no side effects
    unsafe {
        asm!("csrr {0}, cycle", out(reg) value);
    }
    value
}

#[cfg(target_arch = "riscv64")]
fn read_instret_csr() -> u64 {
    let value: u64;
    // SAFETY: reading a CSR is a pure read with no side effects
    unsafe {
        asm!("csrr {0}, instret", out(reg) value);
    }
    value
}

Once again gating them both, making sure they don’t get compiled for any other target architectures than RISC-V.

Add them to your gated block of code in the init() function, and execute your Makefiles once again. Hopefully, you’ll be greeted with output similar to this:

~ # insmod lib/modules/my_csr_module.ko 
[    6.305845] my_csr_module: loading out-of-tree module taints kernel.
[    6.339765] my_csr_module: Rust RISC-V CSR sample (init)
[    6.342029] my_csr_module: Am I built-in? false
[    6.343916] my_csr_module: RISC-V time CSR: 68613839
[    6.346779] my_csr_module: RISC-V cycle CSR: 2080303679739944
[    6.347826] my_csr_module: RISC-V instret CSR: 2080303679839302

Three RISC-V CSRs read, using in-line assembly in a Rust kernel module, all of it cross-compiled for RISC-V. An entire workflow, tool stack, and emulation config set up, and two kernel modules written and executed. Obviously quite simple modules, but we’ve come very, very far from the start of the post.

Step 13 contains an optional refactor, to play a bit more with Rust, but the main RISC-V, Linux kernel, and Rust lessons have been largely wrapped up at this point.

Step 13 (optional): Iterating On Your Kernel Module

We won’t be adding anything too crazy in this step, but our 3-function setup is a prime candidate for a refactoring. Let’s take our module from having 3 separate functions reading from the RISC-V CSRs, and down to one single macro.

Initially, when I wanted to refactor the codebase, my first instinct as an inexperienced Rust developer, was to just make it a function, which would take the target register as a parameter, and then concatenate the Assembly string using the target register. Something like this:

fn read_csr(name: &str) -> u64 {
    let value: u64;
    unsafe {
        asm!(concat!("csrr {0}, ", name), out(reg) value);  // ← won't compile
    }
    value
}

However, this will not compile, and with good reason: The concat! macro requires all its arguments to be string literals, known at compile time. A function parameter is a runtime value - even if you call read_csr("time") with a literal, by the time you're inside the function body, name is just a &str reference, not a literal. concat! can't see through that.`

csrr is a RISC-V instruction where the CSR name is encoded directly into the machine code. csrr x5, time and csrr x5, cycle are two different instructions, with different bit patterns. The assembler needs to know the CSR name at assembly time (which, for inline Assembly, is compile time). So we simply can’t have one function that reads any CSR, because the CSR name isn't a parameter to the csrr instruction, but rather a part of the instruction.

So why does it work with a macro? Primarily, because a macro is expanded at compile time. The compiler literally substitutes the macro invocation with the macro's body, with the arguments interpolated into the body as syntax. By the time codegen runs, the macro has already been fully expanded. So there's no “call” to the macro at runtime. So when we pass e.g. the String “time” to a macro, when we reach compile time, this will have been expanded into a hardcoded “csrr {0}, time" operation - and that is a-okay with both the assembler and compiler.

So instead of the function, we’ll be defining a read_csr! macro:

// Not 100% necessary, since the macro is only used in an already 
// arch-guarded block, but good to be safe.
#[cfg(target_arch = "riscv64")]
macro_rules! read_csr { 
    ($csr:ident) => {{ 
        let value: u64;
        // SAFETY: reading a CSR is a pure read with no side effects
        unsafe { 
            asm!(
                concat!("csrr {0}, ", stringify!($csr)),
                out(reg) value 
            );
        }
        value
    }};
}

Defining a macro, coincidentally requires using another macro: The macro_rules! macro. The definition ends up looking a bit different in terms of parameters, but ultimately we end up with pretty familiar-looking Rust code, using the concat! macro like we originally intended to do.

Due to the macro being expanded at compile-time, and our intended calls to the macro being inside our already defined, architecture-gated code block in the init() function, we could technically omit the architecture gating from this definition. If not compiled for RISC-V, that block is never reached, the macro is never touched by the execution path, and this won’t be included in the compiled binary. But I pretty firmly believe in a “better safe than sorry” approach when writing kernel code, so I’ve added the gating to the macro for good measure - belt and suspenders!

Now, with our new macro ready to go, we can delete the CSR functions, and replace the calls to them in our init() function:

       #[cfg(target_arch = "riscv64")]
        {
            // Initial simple reads
            let time = read_csr!(time);
            pr_info!("RISC-V time CSR: {}\n", time);
            let cycle = read_csr!(cycle);
            pr_info!("RISC-V cycle CSR: {}\n", cycle);
            let instret = read_csr!(instret);
            pr_info!("RISC-V instret CSR: {}\n", instret);

Notice that now that we’re dealing with a macro, we need to remember the ! syntax to “call” it.

And that wraps up this final, optional step. Our code is now nice and clean, and can easily be extended to read from other registries.

Some Takeaways and Conclusions

The main thing that surprised me positively during this project, which you can largely chalk up to my own ignorance, is just how mature the toolchains surrounding RISC-V are. Obviously, the architecture has been around for over a decade, but I expected to have to make significantly more time for accommodating it than I needed to, with the only real “issue” being adding the additional guarding to the BusyBox C code.

This has made me think that RISC-V adoption isn’t being held back by the toolchains per se, but perhaps rather from a lack of people leveraging those toolchains, to make their tools and libraries available on RISC-V as well. Which tracks, since the demand itself from especially the personal computer crowd isn’t quite there yet. It’s a bit of a chicken and egg situation, where owning a RISC-V machine as your daily driver doesn’t make sense due to lack of tool support, but the tool support gap isn’t being addressed due to a lack of demand.

Since the toolchains are as mature as they are, I decided to go ahead and quickly change the release flows on all my FOSS projects, to include a RISC-V compatible version in the release package. So now, all of lazyhetzner, awsbreeze, rssbreeze, funke, and claudectx are available for RISC-V machines. If you’ve made it this far, and have some FOSS projects of your own, I’d highly encourage you to look into doing the same - the more tools available on RISC-V platforms, the higher the chance of mainstream adoption.

Another thing where my own ignorance led to me being surprised, was how early stage Rust integration into the Linux kernel still is. For example, while playing around with the rust_minimal kernel module, I at one point wanted to pass a String as a module parameter, just as a “Hello World” sort of example. But nope - no String support yet from the Rust kernel code’s module macro. Little things like these, however pointless it probably is in the grand scheme of things, gave the impression that Rust is still very early in its kernel adoption journey (which it by all means is, I was just surprised that it was this early).

What’s Next

From this point on, and in a potential part 2, I plan on diving into making kernel modules that actually do stuff. Just go a bit deeper, learn a bit more about the internals of kernel development, and hopefully poke a bit more at writing assembly code for RISC-V architectures. I’d also be super keen on applying some of my professional experience, and work with RISC-V in virtualization and container orchestration contexts. And I can’t deny that I’m slightly itching to buy a small RISC-V compute board, to play around with real, proper hardware, with all the joys and sorrows that entails.

I hope this guide might have been of some help in squashing a bug, or at least piqued your curiosity regarding Linux kernel development and RISC-V CPUs - if absolutely nothing else, it very much scratched that itch for myself, and has left me eager to dive even deeper into pretty much all the covered subjects. As mentioned earlier, all my source code, Makefiles, configs, and very raw notes, can be found on my GitHub repository: https://github.com/grammeaway/linux-rust-riscv-project. So if you’re stuck, or made it down here as a Tl;dr move, the repository will have all the answers, with none of the rambling.