Écrire un module pour Linux 🐧 en Rust 🩀

Le 14 avril 2021, une sĂ©rie de patch a Ă©tĂ© soumise sur la mailing list du kernel Linux pour dĂ©marrer la discussion et proposer une premiĂšre RFC en vue d’ajouter le langage Rust comme second langage intĂ©grĂ© au projet.

Le 4 juillet, une nouvelle série de patch a été envoyée, cette fois-ci pour activer le support et le rendre accessible aux développeurs du kernel.

Dans cet article, je vais vous prĂ©senter l’intĂ©rĂȘt de cette dĂ©marche et un exemple « simple » de module Rust pour le noyau Linux.

L’intĂ©rĂȘt de Rust

L’objectif du langage Rust est de fournir un langage bas niveau, mais fournissant davantage de garanties qu’un langage comme le C.

Des invariants du langage offrent des garanties sur la manipulation de la mémoire.
Exemples :

  • Une rĂ©fĂ©rence vers une valeur ne peut pas exister au-delĂ  de l’existence de la valeur
  • Une seule rĂ©fĂ©rence mutable vers une valeur peut exister Ă  un instant donnĂ©

Ces invariants permettent d’Ă©viter des comportements indĂ©finis liĂ©s Ă  l’utilisation de pointeurs non initialisĂ©s notamment le dĂ©rĂ©fĂ©rencement de null pointers ou les Use after-free.

Le systĂšme de type de Rust nous permet Ă©galement d’obtenir des garanties sur les donnĂ©es manipulĂ©es vĂ©rifiĂ©es par le compilateur alors que dans le cadre d’une implĂ©mentation en C, c’est au dĂ©veloppeur de prendre les prĂ©cautions nĂ©cessaires.

Si vous voulez en savoir plus Ă  ce sujet, je vous invite Ă  regarder cette prĂ©sentation qui dĂ©taille ce qu’on entend par comportement indĂ©fini et comment Rust permet de les limiter.

Compilation du kernel avec support de Rust

Pour commencer, nous allons devoir compiler notre propre kernel Linux avec le support de Rust activé.
De mon cĂŽtĂ©, j’ai dĂ©cidĂ© de faire ça sur une machine virtuelle Ubuntu 21.04.

La plupart des étapes de ce tutoriel sont basées sur la documentation officielle du projet Rust for Linux.

Commencez par l’installation des outils nĂ©cessaires Ă  la compilation :

# Distro packages
sudo apt update
sudo apt install -y flex bison clang lld build-essential llvm git libelf-dev libclang-11-dev libssl-dev tmux

Ensuite, installez la toolchain Rust qui sera utilisée pour la compilation du kernel et les dépendances nécessaires.

rust-src : le code source de la standard librairie Rust est nécessaire car on va recompiler core et alloc
bindgen : sera utilisé pour générer les bindings avec le C du kernel lors du build.

# Rust dependencies
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustup component add rust-src
cargo install --locked --version 0.56.0 bindgen

Clonez les sources du kernel intégrant les patchs nécessaires pour Rust :

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

Il faut ensuite configurer le kernel pour activer le support de Rust et intégrer nos exemples.

Recopiez la configuration actuelle du kernel afin de minimiser les changements Ă  effectuer.

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

Configurez les options spécifiques :

make menuconfig

Vous pouvez vous baser sur cette liste pour savoir quoi activer/désactiver :

Il est nécessaire de désactiver le versioning des modules :

Enable loadable module support => [ ] Module versioning support

Activaction du support de Rust :

General Setup => [*] Rust support

Et activez la compilation d’un exemple de driver :

Kernel Hacking => Sample kernel code => [*] Rust samples => <M> Character device

De mon cĂŽtĂ©, j’ai Ă©galement dĂ» dĂ©sactiver certaines options pour faire passer la compilation :

Kernel Hacking => Compile-time checks and compiler options => [ ] Compile the kernel with debug info
Cryptographic API => Certificates for signature checking => () Additional X.509 keys for default system keyring 
Cryptographic API => Certificates for signature checking => () X.509 certificates to be preloaded into the system blacklist keyring

Lancez la compilation et aller se chercher 2~3 cafés

Il faudra adapter le -j5 en fonction du nombre de core disponibles sur la machine utilisĂ©e pour la compilation (en gĂ©nĂ©ral, on choisit nombre de core + 1, ce qui permet de lancer 5 tĂąches de compilation en parallĂšle, et d’occuper tous les cores, mĂȘme en prenant en compte le fait que certaines tĂąches attendant sur des IO).

make LLVM=1 -j5

Une fois la compilation terminĂ©e, installez les modules dans l’arborescence du systĂšme et installer le kernel.

sudo make modules_install
sudo make install

Test

Pour tester, vous pouvez alors redémarrer la machine et vérifier la version du kernel utilisé suite à ce redémarrage :

$ sudo reboot

# Puis

$ uname -a
Linux lima-default 5.15.0+ #4 SMP PREEMPT Sun Nov 14 13:41:03 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux

Il est alors possible de charger le module Ă©crit en Rust.

$ sudo insmod /lib/modules/$(uname -r)/kernel/samples/rust/rust_chrdev.ko
$ lsmod | grep rust
rust_chrdev            16384  0
$ sudo rmmod rust_chrdev
$ sudo dmesg | grep rust_chrdev
[27357.104859] rust_chrdev: Rust character device sample (init)
[27425.248428] rust_chrdev: Rust character device sample (exit)

Nous avons donc pu charger notre module exemple Ă©crit en Rust !

Exemple de module Ă©crit en Rust avec explications

Je vous propose maintenant de passer en revue les Ă©tapes nĂ©cessaires Ă  l’Ă©criture d’un module en Rust. Petite mise en garde nĂ©anmoins, ce n’est pas ma spĂ©cialitĂ© et il est possible que ma comprĂ©hension de certains aspects ne soit que partielle.

Le premier module que nous avions chargĂ© Ă©tait directement intĂ©grĂ© dans l’arborescence du noyau, mais ce n’est pas indispensable.
Vous pouvez retrouver cet exemple sur ce repo.

Nous allons commencer par le Makefile qui sera utilisé pour compiler notre module.

# Déclarer le module à compiler en indiquant le fichier objet résultant
obj-m += rust_chrdev.o

# Déclarer notre cible par défaut en précisant :
# - LLVM=1 : Utilisation de LLVM
# - -C /lib/modules/$(shell uname -r)/build : Utilisation du systĂšme de build du kernel
# - M=$(PWD) : Le chemin du module
# - modules : Compilation notre module
all:
    make LLVM=1 -j5 -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

# Ajout une cible pour indiquer comment faire le ménage
clean:
    make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Passons maintenant au code de notre module, vous pouvez retrouver l’exemple complet sur github.
Nous allons commencer par un exemple simple, notre module crĂ©era un character device qui transmettra la chaĂźne Â«Â đŸŠ€ Hello from rust\n » lorsqu’on lira dedans, une fois le fichier ouvert.
Nous stockerons également un état partagé qui nous permettra de comptabiliser combien de fois le fichier a été ouvert.

Commençons par dĂ©clarer les structures qui stockeront l’Ă©tat de lecture du fichier device et l’Ă©tat partagĂ© de notre module :

/// État partagĂ© dans notre module
struct Shared {
    open_count: AtomicU64,
}

/// Compteur d'octets lus sur notre fichier
struct RustFile {
    read_count: AtomicUsize,
}

Ensuite, nous dĂ©clarons la structure qui contient l’enregistrement de notre module :

/// Structure correspondant à notre module, qui porte l'état partagé de
/// l'enregistrement du device
struct Rustdev {
    _dev: Pin<Box<Registration<Ref<Shared>>>>,
}

Cette structure ne possĂšde qu’un seul membre : l’enregistrement Registration du device et qui porte l’Ă©tat partagĂ©.

Pour initialiser le module et enregistrer notre device, il faut implémenter le trait KernelModule pour notre device :

/// 
impl KernelModule for Rustdev {
    /// Cette méthode est appelé au chargement de notre module et permet
    /// d'effectuer les Ă©tapes d'initialisation
    fn init(name: &'static CStr, _module: &'static ThisModule) -> Result<Self> {
        // Cette macro permet d'afficher un message d'information dans `dmesg`
        pr_info!("Rust device sample (init)\n");

        // Initialisation de l'état partagé qui comptera le nombre d'accÚs à
        // notre device
        let shared = Ref::try_new(Shared {
            open_count: AtomicU64::new(0),
        })?;

        // Création de la structure correspondant à notre module, et création de
        // l'enregistrement qui portera notre état partagé.
        Ok(Rustdev {
            _dev: Registration::new_pinned::<RustFile>(name, None, shared)?,
        })
    }
}

Nous implémentons également le trait Drop qui sera utilisé lors de la suppression du module.

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

Il faut également utiliser une macro pour finaliser les déclarations nécessaires à la prise en compte de notre module :

module! {
    type: Rustdev,
    name: b"rust_mydev",
    author: b"Pierre-Yves Aillet",
    description: b"Rust character device sample",
    license: b"GPL v2",
}

Il nous manque encore 2 traits à implémenter :

  • FileOpener pour traiter l’ouverture du fichier de notre character device
  • FileOperations pour implĂ©menter le comportement lors de la lecture de ce fichier
/// Ce trait permet d'indiquer ce qui est réalisé lors de l'ouverture du
/// device.
/// Il est utilisé pour initialiser la structure qui correspond à l'état du
/// fichier ouvert, et peut Ă©galement ĂȘtre utilisĂ© pour y associer l'Ă©tat
/// partagé (ce qui n'est pas fait dans cet exemple).
impl FileOpener<Ref<Shared>> for RustFile {
    fn open(shared: &Ref<Shared>) -> Result<Box<Self>> {
        // Mise Ă  jour le compteur d'ouverture du fichier
        shared.open_count.fetch_add(1, Ordering::SeqCst);

        // Affichage dans le `dmesg` le nombre de fois que le device a été
        // ouvert
        pr_info!(
            "Opened the file {} times\n",
            shared.open_count.load(Ordering::SeqCst)
        );
        // Initialisation et transfert de la structure correspondant Ă  l'ouverture
        // courante de notre fichier.
        Ok(Box::try_new(Self {
            read_count: AtomicUsize::new(0),
        })?)
    }
}
/// Constante correspondant Ă  la chaĂźne que nous souhaitons renvoyer
const HELLO: &'static str = "🩀 Hello from rust\n\0";

/// Ce trait comporte l'ensemble des opérations possibles pour un fichier.
/// Voir la documentation [ici](https://rust-for-linux.github.io/docs/kernel/file_operations/trait.FileOperations.html)
impl FileOperations for RustFile {
    /// L'utilisation de cette macro permet de spécifier les opérations réellement
    /// implémentée pour notre device
    kernel::declare_file_operations!(read);

    /// Cette méthode est appelé lorsqu'une opération de lecture est réalisée
    /// sur le fichier device
    fn read(
        this: &Self,
        _file: &File,
        data: &mut impl IoBufferWriter,
        _offset: u64,
    ) -> Result<usize> {
        let hello_bytes = HELLO.as_bytes();
        // Si le fichier n'a pas déjà été lu
        if hello_bytes.len() > this.read_count.load(Ordering::SeqCst) {
            // Et si le buffer fournit est assez grand pour y Ă©crire le message
            if data.len() >= hello_bytes.len() {
                // Écriture notre message dans ce buffer
                data.write_slice(&hello_bytes)?;
                // Mise Ă  jour le compteur d'octets lu pour cette ouverture
                // de fichier
                this.read_count.store(hello_bytes.len(), Ordering::SeqCst);
                // Renvoie du nombre d'octets lus et réellement écrits
                // dans le buffer
                return Ok(hello_bytes.len());
            }
        }
        // Dans les autres cas, aucun octet n'a été lu
        Ok(0)
    }
}

Vous pouvez retrouver l’exemple complet ici.

Voici un exemple de session avec utilisation de ce module :

$ make
make LLVM=1 -j5 -C /lib/modules/5.15.0+/build M=/home/pyaillet.linux/rust-lkm modules
make[1]: Entering directory '/home/pyaillet.linux/linux'
  RUSTC [M] /home/pyaillet.linux/rust-lkm/rust_chrdev.o
  MODPOST /home/pyaillet.linux/rust-lkm/Module.symvers
  CC [M]  /home/pyaillet.linux/rust-lkm/rust_chrdev.mod.o
  LD [M]  /home/pyaillet.linux/rust-lkm/rust_chrdev.ko
make[1]: Leaving directory '/home/pyaillet.linux/linux'
$ sudo insmod rust_chrdev.ko
$ sudo dmesg | grep rust_mydev
[   55.920542] rust_mydev: Rust device sample (init)
$ sudo cat /dev/rust_mydev
🩀 Hello from rust
$ sudo cat /dev/rust_mydev
🩀 Hello from rust
$ sudo dmesg | grep rust_mydev
[   55.920542] rust_mydev: Rust device sample (init)
[   75.415790] rust_mydev: Opened the file 1 times
[   76.808057] rust_mydev: Opened the file 2 times
$ sudo cat /dev/rust_mydev
🩀 Hello from rust
$ sudo dmesg | grep rust_mydev
[   55.920542] rust_mydev: Rust device sample (init)
[   75.415790] rust_mydev: Opened the file 1 times
[   76.808057] rust_mydev: Opened the file 2 times
[   82.857408] rust_mydev: Opened the file 3 times
$ sudo rmmod rust_chrdev
$ sudo dmesg | grep rust_mydev
[   55.920542] rust_mydev: Rust device sample (init)
[   75.415790] rust_mydev: Opened the file 1 times
[   76.808057] rust_mydev: Opened the file 2 times
[   82.857408] rust_mydev: Opened the file 3 times
[   95.155032] rust_mydev: Rust device sample (exit)
$

Conclusion

Il reste encore du chemin à parcourir pour voir de nombreux drivers Linux implémenter en Rust.
Comme dĂ©crit ici, une grosse partie du travail restant consiste Ă  disposer des abstractions permettant d’interagir avec les APIs internes du kernel tout en conservant les garanties fournies par Rust.
Si le sujet vous intéresse je vous invite à regarder les présentations données en référence : « Rust for Linux » et « Rust in the Linux ecosystem« 

Références

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :