Écrire un module noyau en Rust

Écrire un module noyau en Rust

Depuis sa version 6.1, Linux supporte, en plus du C, le langage Rust pour écrire certains composants du noyau. Même si la prise en charge était minimaliste dans les débuts, il est maintenant possible d'écrire des modules noyau et de les charger sans avoir à recompiler Linux. C'est le principe des LKM (Loadable Kernel Module).

Dans cette article, je vous montre comment écrire un module noyau simple : un pilote pour un device de type char, générant des hurlements (i.e. "aAaaaAAhHHaaHH"), délire partant d'un message sur Mastodon.

Prérequis

Puisque le support de Rust est assez récent, il est nécessaire de mettre en place quelques outils afin d'activer Rust dans le noyau. Ces prérequis viennent de la documentation officielle du projet Rust for Linux. Nous allons voir ensemble comment satisfaire ces prérequis, qui sont :

  1. avoir un noyau Linux en version 6.3+
  2. disposer de la toolchain Rust

La version 6.3 étant assez récente (24 avril 2023) et le support de Rust étant désactivé par défaut sur la plupart des noyaux dans les distributions, nous allons devoir le compiler nous-mêmes. Pour savoir si Rust est activé dans le noyau que vous utilisez, on peut utiliser la commande grep CONFIG_RUST=y /boot/config-$(uname -r) : si la ligne s'y trouve, alors Rust est disponible (et dans ce cas il n'est peut-être pas nécessaire de compiler Linux).

Pour des raisons de simplicité et aussi pour éviter de mettre le bazar sur votre machine personnelle, il est conseillé de réaliser les opérations dans une machine virtuelle.

Machine virtuelle

J'utilise pour cela Vagrant, permettant de démarrer une machine rapidement. Le Vagrantfile utilisé est celui-ci :

Vagrant.configure("2") do |config|
  config.vm.box = "debian/bookworm64"
  config.vm.provider "libvirt" do |v|
    v.memory = 2048
    v.cpus = 4
  end
end

On l'enregistre dans un fichier Vagrantfile puis on lance la commande vagrant up :

‣ vagrant up
Bringing machine 'default' up with 'libvirt' provider...
...
==> default: Machine booted and ready!

Une fois démarrée, on lance la commande vagrant ssh puis sudo su pour être connecté en root sur la machine :

‣ vagrant ssh
...
vagrant@bookworm:~$ sudo su
root@bookworm:/home/vagrant#

On va maintenant pouvoir préparer la machine pour faire tourner un noyau 6.3 avec le support de Rust.

Installer les dépendances de paquets

Installer les paquets nécessaires avec la commande suivante :

# apt update
# apt install -y flex bison clang lld build-essential llvm git libelf-dev libclang-13-dev libssl-dev tmux curl

Télécharger les sources du noyau Linux

On le télécharge directement depuis le dépôt git :

# git clone --depth=1 https://github.com/Rust-for-Linux/linux.git
# cd linux

Installer la toolchain Rust

Pour cela, lancer les commandes :

# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# source $HOME/.cargo/env
# rustup component add rust-src
# rustup override set $(scripts/min-tool-version.sh rustc)
# cargo install --locked --version $(scripts/min-tool-version.sh bindgen) bindgen

Pour vérifier si le compilateur détecte correctement la disponibilité de Rust, on utilise :

# make LLVM=1 rustavailable
Rust is available!

À partir de là, on peut compiler Linux avec le support de Rust !

Compiler Linux

Nous allons compiler Linux en activant le support de Rust. Pour cela, nous allons éditer légèrement la configuration du noyau.

D'abord, on reprend la configuration de la distribution :

# cp /boot/config-$(uname -r) .config
# make oldconfig

Puis on l'édite :

# make menuconfig

et on active le support de Rust :

General setup  ---> [*] Rust support

On quitte et on sauvegarde.

On peut lancer la compilation (ça va durer un certain temps) :

# make LLVM=1 -j4

On peut ensuite installer les modules et le noyau sur le système de la machine virtuelle

# make modules_install
# make install

On rédémarre la VM et on doit maintenant tourner sur le nouveau noyau :

# uname -a 
Linux bookworm 6.3.0+ #2 SMP PREEMPT_DYNAMIC Sat Aug 12 09:01:08 UTC 2023 x86_64 GNU/Linu

On va pouvoir passer aux choses sérieuses !

Écrire le module en Rust

Le dépôt git du projet Rust for Linux fournit quelques exemples de modules que l'on peut écrire. Ce que l'on souhaite faire ici, c'est un device de type char qui renvoit une suite de caractères choisis aléatoirement dans l'alphabet aAhH. On créé un fichier dev_scream.rs, dans un nouveau dossier :

# mkdir rust_dev_scream
# cd rust_dev_scream
# touch dev_scream.rs

puis on y copie l'exemple rust_chrdev.rs, ressemblant à ceci :

// SPDX-License-Identifier: GPL-2.0

//! Rust character device sample.

use kernel::prelude::*;
use kernel::{chrdev, file};

module! {
    type: RustChrdev,
    name: "rust_chrdev",
    author: "Rust for Linux Contributors",
    description: "Rust character device sample",
    license: "GPL",
}

struct RustFile;

#[vtable]
impl file::Operations for RustFile {
    fn open(_shared: &(), _file: &file::File) -> Result {
        Ok(())
    }
}

struct RustChrdev {
    _dev: Pin<Box<chrdev::Registration<2>>>,
}

impl kernel::Module for RustChrdev {
    fn init(name: &'static CStr, module: &'static ThisModule) -> Result<Self> {
        pr_info!("Rust character device sample (init)\n");

        let mut chrdev_reg = chrdev::Registration::new_pinned(name, 0, module)?;

        // Register the same kind of device twice, we're just demonstrating
        // that you can use multiple minors. There are two minors in this case
        // because its type is `chrdev::Registration<2>`
        chrdev_reg.as_mut().register::<RustFile>()?;
        chrdev_reg.as_mut().register::<RustFile>()?;

        Ok(RustChrdev { _dev: chrdev_reg })
    }
}

impl Drop for RustChrdev {
    fn drop(&mut self) {
        pr_info!("Rust character device sample (exit)\n");
    }
}

Nous allons commenter les parties qui nous intéressent.

On déclare les métadonnées de notre module :

module! {
    type: RustChrdev, // Le type du module (ici char device)
    name: "rust_chrdev", // le nom du module
    author: "Rust for Linux Contributors",
    description: "Rust character device sample",
    license: "GPL",
}

Ensuite vient la fonction d'initialisation, permettant d'exécuter du code quand le module est chargé :

impl kernel::Module for RustChrdev {
    fn init(name: &'static CStr, module: &'static ThisModule) -> Result<Self> {
        pr_info!("Rust character device sample (init)\n");

        let mut chrdev_reg = chrdev::Registration::new_pinned(name, 0, module)?;

        // Register the same kind of device twice, we're just demonstrating
        // that you can use multiple minors. There are two minors in this case
        // because its type is `chrdev::Registration<2>`
        chrdev_reg.as_mut().register::<RustFile>()?;
        chrdev_reg.as_mut().register::<RustFile>()?;

        Ok(RustChrdev { _dev: chrdev_reg })
    }
}

dans cette fonction sont initialisées toutes les variables utiles à l'exécution du module. Il existe la même chose quand le module est déchargé :

impl Drop for RustChrdev {
    fn drop(&mut self) {
        pr_info!("Rust character device sample (exit)\n");
    }
}

Ce code est quasiment satisfaisant, on change le nom du module en name: "rust_scream" et il ne nous manque plus qu'une seule chose, la fonction qui gère la lecture du device !

Pour cela, on ajoute la fonction read à RustFile, comme ceci :

#[vtable]
impl file::Operations for RustFile {
    fn read(
        _this: (),
        _file: &file::File,
        buf: &mut impl io_buffer::IoBufferWriter,
        _: u64,
    ) -> Result<usize> {
        let total_len = buf.len();
        let mut chunkbuf = [0; 256];

        while !buf.is_empty() {
            let chunk = &mut chunkbuf[0..1];
            kernel::random::getrandom_nonblock(chunk)?;
            let r: usize = <u8 as Into<usize>>::into(chunk[0]) % ALPHABET.len();
            let c = ALPHABET.chars().nth(r).unwrap();
            buf.write_slice(&[c as u8])?;
        }
        Ok(total_len)
    }
}

Et oui ! Nous sommes dans le noyau, ce qui signifie que nous n'avons pas accès à toute la bibliothèque standard de Rust, comme habituellement ! Ainsi, pour générer de l'aléatoire, on doit demander au noyau avec la fonction kernel::random::getrandom_nonblock(chunk) qui remplit le chunk d'aléa. Ici, nonblock est satisfaisant car la qualité de l'aléa n'est pas critique.

On n'a plus qu'à compiler et charger le module ! Toujours dans le dossier rust_dev_scream, faire :

# echo 'obj-m := dev_scream.o' > Kbuild
# make LLVM=1 -C /lib/modules/$(uname -r)/build M=$PWD modules
make: Entering directory '/vagrant/linux'
  MODPOST /vagrant/rust_dev_scream/Module.symvers
make: Leaving directory '/vagrant/linux'
# make LLVM=1 -C /lib/modules/$(uname -r)/build M=$PWD modules_install
make: Entering directory '/vagrant/linux'
  INSTALL /lib/modules/6.3.0+/updates/dev_scream.ko
  SIGN    /lib/modules/6.3.0+/updates/dev_scream.ko
  DEPMOD  /lib/modules/6.3.0+
make: Leaving directory '/vagrant/linux'

On voit que le fichier /lib/modules/6.3.0+/updates/dev_scream.ko a été créé ! Chargeons maintenant le module :

# modprobe dev_scream

si tout se passe bien, vous devriez voir un device rust_scream dans /proc/devices !

# grep rust_scream /proc/devices
248 rust_scream

Nous n'avons plus qu'à lui joindre un device pour s'en servir :

# mknod /dev/scream c 248 0

et voilà, on peut lire dedans avec n'importe quelle commande :

# head -c 100 /dev/scream
AAaAHAaHhHAHHAHhHHAHHhhHHHAHhAHHAAAhAAAHAaHHAaAHhHhHAaaAHhHHaHaaAHHhHaahHAHaaaHHaaAHhahAhhAhAhAHAAAA

L'ensemble du code source peut être trouvé sur ce dépôt git.

Sources et liens utiles