Linux Embedded

Le blog des technologies libres et embarquées

Intégration de Rust dans Buildroot

( English version below )

 

Introduction

Rust est un langage récent qui a fait ses débuts en 2015. Depuis son apparition, il a été introduit dans de plus en plus de domaines du développement logiciel. L'objectif du langage est de fournir une excellente sécurité mémoire sans avoir besoin d'un ramasse-miettes (garbage collector) tout en conservant un niveau de performance comparable à celui de C et C++.

Avec Rust, il est essentiel de produire du code sécurisé en mémoire grâce à plusieurs règles vérifiées directement par le compilateur au moment de la compilation.

Cela le rend particulièrement intéressant pour les systèmes embarqués. 

Il est donc légitime de le mettre en compétition avec C et C++ et leur système notoirement fastidieux de gestion de la mémoire où la mémoire doit être allouée et libérée manuellement.

Afin d'évaluer l'usage de la solution Rust, nous devons examiner plusieurs choses :

  1. Qu'est-ce qui distingue Rust des autres langages ?
  2. Comment l'intégrer à BuildRoot ?
  3. A-il de bonnes performances ?

Pour répondre à toutes ces questions, je vais présenter les grandes lignes du langage Rust, intégrer plusieurs solutions en Rust dans Buildroot et documenter mes expériences dans cet article.

 

  1. Les concepts de Rust

Rust utilise 3 concepts principaux pour garantir la sécurité de sa mémoire. Certains de ces concepts peuvent également être présents dans d'autres langages, en particulier avec l'utilisation d'outils externes pour détecter les fuites de mémoire, mais Rust est un langage qui applique l'utilisation de bonnes pratiques et vise à fournir de bons messages d'erreur via son compilateur pour aider le développeur. Je vais d'abord décrire les 3 principaux concepts de Rust pour la sécurité de la mémoire, puis parler de Rust unsafe, qui est important dans un contexte intégré de bas niveau.

 

La possession

C'est le système de Rust pour éviter le besoin d'un ramasse-miettes (garbage collector). Les ramasse-miettes fonctionnent généralement en vérifiant dynamiquement si la mémoire allouée est toujours utilisée ou non, puis en la libérant si nécessaire. Rust évite cela grâce à l'utilisation de 3 règles simples :

  • Chaque valeur dans Rust a un propriétaire
  • Il ne peut y avoir qu'un seul propriétaire à la fois
  • Lorsque le propriétaire sort de la portée, la valeur est supprimée (libérée)

Ces règles sont particulièrement importantes lorsqu'il s'agit de types de données qui n'ont pas de taille fixe au moment de la compilation, comme les chaînes. Pour les types de données de taille fixe (comme i32, un entier 32 bits), la valeur serait copiée d'un propriétaire à un autre.

L'exemple suivant montre un programme simple qui serait rejeté par le compilateur en raison de problèmes de propriété.

 

let s1 = String::from("hello"); // s1 gets the pointer to the memory area
let s2 = s1; // ownership of the pointer gets passed to s2

println!("{}, world!", s1); // Error: s1 does not own the data anymore

 

La méthode String::from() alloue la mémoire nécessaire sur le tas et renvoie une référence à cette mémoire qui est affectée à la variable s1. s1 devient donc propriétaire de ces données. Avec la deuxième ligne, s2 devient le nouveau propriétaire des données et s1 est supprimé. Nous essayons alors de référencer la valeur de s1 qui ne fonctionne pas car ce n'est plus une valeur valide.

 

Emprunt

Il est parfois préférable de pouvoir manipuler des variables sans s'approprier les données. Cela est vrai pour les appels de fonction car la propriété de la donnée serait transmise au paramètre d'entrée qui la supprimerait à la fin d'un appel de fonction (sauf si elle est également utilisée comme valeur de retour).

Deux types d'emprunts sont possibles: l’emprunt mutable ou immuable.

À tout moment, il est possible d'avoir un nombre infini d'emprunts immuables mais un seul emprunt mutable. En plus de cela, un emprunt mutable ne peut pas se produire avant un emprunt immuable.

Tout cela garantit qu'aucune course aux données (data race) ne se produise et garantie, de fait, l'état des valeurs à tout moment.

L'exemple suivant montre la notation utilisée pour emprunter une valeur à l'intérieur d'une fonction. A la fin de l'appel à la fonction ‘change()’, la propriété est automatiquement rendue à la variable ‘s’.

 

fn main() {
let mut s = String::from("hello");

change(&mut s);    //value gets borrowed
    println!("{}", s); //ownership is automatically returned
}

fn change(some_string: &mut String) {
some_string.push(", world");
}



Durée de vie

Le concept final pour garantir la sécurité de la mémoire est la durée de vie. La durée de vie est omniprésente dans le code Rust et est déduite par le compilateur afin qu'elles ne soient pas directement visibles pour le développeur

La durée de vie d'une valeur fait référence aux parties de code dans lesquelles une valeur est valide. Cela correspond souvent à la portée d'une variable, mais pas toujours.

L'extrait de code suivant montre un programme qui échoue en raison de problèmes de durée de vie.

 

fn main() {
    let r;                // -------+-- 'a
                          //          |
    {                     //          |
        let x = 5;     // +-- 'b  |
        r = &x;        //  |       |
    }                     //       +------+
                          //          |
    println!("r: {}", r); //       |
}                         // --------+

 

Ici, ‘x’ a une durée de vie ‘b. Nous essayons ensuite de référencer ‘r’ dans sa propre durée de vie mais comme il contient une référence à la variable ‘x’ qui est hors de portée, il contient une référence invalide. Pour que ce code fonctionne, la durée de vie de 'a doit tenir entièrement à l'intérieur de 'b comme dans l'exemple suivant.

 

fn main() {
    let x = 5;        // ----------+-- 'b
                          //           |
    let r = &x;      // +-- 'a  |
                          //   |       |
    println!("r: {}", r); //   |       |
                          // |       |
}                         // ----------+-------|

 

Dans ces cas, la durée de vie peut être directement déduite par le compilateur, mais parfois ce n'est pas possible. Lorsque cela n'est pas possible, les durées de vie doivent être annotées manuellement en utilisant des paramètres de type générique. Prenons l'exemple suivant où il y a deux chaînes et une fonction qui prend les deux comme argument et renvoie la plus longue à une nouvelle variable :

 

fn main() {
    let string1 = String::from("abcd");
    let string2 = String::from("xyz");

    let result = longest(string1.as_str(), string2.as_str());
    println!("The longest string is {}", result);
}

 

Avec la définition suivante de longest(), le code ne fonctionnerait pas car la valeur de retour doit avoir une durée de vie, mais il peut prendre la valeur de string1 ou string2 et ces chaînes pourraient potentiellement avoir 2 durées de vie différentes.

 

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

 

Pour que cela fonctionne, nous devons spécifier les durées de vie manuellement. Les noms des durées de vie commencent toujours par un guillemet simple: ‘. Le code suivant est un bon exemple :

 

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

 

En spécifiant un seul paramètre de durée de vie 'a, nous disons que la durée de vie de la valeur de retour doit être aussi longue que la plus courte des deux duree de vie des paramètres d'entrée.

Dès que l'une des durées de vie du paramètre d'entrée se termine, la durée de vie de la valeur de retour se termine également.

 

Unsafe Rust

Les mesures de sécurité de Rust sont extrêmement conservatrices. C’est-a-dire que pour garantir la sécurité de la mémoire, le compilateur rejette parfois du code qui pourrait parfaitement convenir mais ne respecte pas les règles évoquées précédemment.

Il est parfois nécessaire d'écrire du code qui ne respecte pas ces règles. Pour cette raison, Rust inclut le mot-clé unsafe. Lorsque ce mot-clé est utilisé, cela ne signifie qu’on admet que le code soit potentiellement dangereux, c'est-à-dire que le compilateur ne peut garantir le bon usage de la mémoire dans cette section. Plus concrètement, cela va rendre 5 choses possibles, qui ne l'étaient pas auparavant :

  • Déréférencer un pointeur brut
  • Appeler une fonction ou une méthode dangereuse (unsafe)
  • Accéder ou modifier une variable statique mutable
  • Mettre en œuvre un trait dangereux
  • Accéder aux champs des unions

 

Un pointeur brut est un pointeur dont il n'est pas garanti qu'il pointe vers une mémoire valide et qui peut être NULL. Il ignore également les règles d'emprunt et peut donc être emprunté de manière mutable plusieurs fois par exemple.

Une fonction ou une méthode dangereuse est une fonction ou une méthode qui peut contenir du code Rust unsafe.

Une variable statique est similaire aux constantes globales car leurs durées de vie sont toujours sur l'ensemble du programme. La différence entre eux est que les variables statiques ont une adresse fixe dans la mémoire du tas alors que les variables globales peuvent être copiées. Les variables statiques peuvent également être modifiables et l'accès à leur valeur est alors considéré comme dangereux.

Un trait est une fonctionnalité permettant de regrouper des méthodes avec une signature spécifique. Les traits peuvent ensuite être implémentés par des classes et sont requis par le compilateur pour implémenter la méthode telle que définie dans la définition du trait. Les traits peuvent également être utilisés dans les signatures de fonction pour n'accepter que les types de paramètres d'entrée qui implémentent un certain trait. Les traits dangereux peuvent donc contenir des méthodes dangereuses.

Les unions sont similaires aux unions en C en ce sens qu'elles peuvent contenir différents types de données mais un seul type à la fois. Ils sont principalement utilisés pour s'interfacer avec les unions en code C. Comme le compilateur ne peut pas garantir quel type de données est présent dans une union, il est considéré comme dangereux.

En règle générale, le code dangereux doit être aussi petit que possible car cela réduira la quantité de débogage de la mémoire qui devra être effectuée en cas d'erreur.

 

En quelques mots

Rust est un langage fortement contraint qui prend grand soin de ne pas laisser le développeur faire une erreur d’inattention en l’obligeant à mettre en place de bonnes pratiques.

C’est un avantage certain quand il faut produire du code pour un système embarqué, aux ressources restreintes et aux capacités de mise à jour complexe.

 

  1. Comment intégrer Rust à BuildRoot

Nous avons vu que Rust est un langage adapté aux contraintes des systèmes embarqués sur le plan critique de l’usage de la mémoire. Mais est-il simple de l’utiliser dans un environnement de développement complet tel que BuildRoot? 

 

Rust : corriger le build de bootstrap

À la suite de la mise à jour du compilateur Rust vers la version 1.67.0, sa construction à partir des sources a été cassée. Lorsque ce bogue a été découvert, il y avait déjà un commit dans le upstream de Rust en amont qui corrigeait ce bogue. Afin d'intégrer ce correctif dans Buildroot, nous devons suivre quelques étapes :

  • Cloner le dépôt Rust depuis github
  • Vérifiez la bonne version :git checkout 1.67.0
  • Identifiez le commit ID du commit qui nous intéresse en utilisant gitk par exemple
  • Cherry-pick le commit id :git cherry-pick 675fa0b3
  • Ajoutez une ligne Signed-off-by: au commit :git commit --amend -s
  • Créer un patch: git format-patch -1 HEAD
  • Placez le fichier de correctif généré dans le dossier du package de Rust dans Buildroot

 

 

En plaçant ce fichier dans le dossier, il est automatiquement détecté par Buildroot lors de la construction du package et appliqué aux sources après l'étape de téléchargement :

 

 

Cela nous montre que d'une certaine manière qu’il était possible qu'une erreur soit intégrée dans le compilateur Rust et qu'il n’est pas certain qu'il soit exempt de bogues et doit donc toujours être vérifié avant d'être intégré dans d'autres systèmes.

 

Nushell

Nushell est un shell entièrement écrit en Rust et il a été utilisé pour tester l'infrastructure de Cargo dans Buildroot. Cargo est le gestionnaire de packages de Rust et est également utilisé comme système de construction d’applications Rust avec leurs dépendances. Rust dépend fortement de Cargo et il est donc important de connaître ses limitations lorsqu'il doit être intégré à d'autres systèmes. L'intégration de Nushell a été assez simple grâce à Cargo. Il fallait juste créer un fichier de configuration très basique pour pouvoir sélectionner le paquet puis un fichier .mk basique décrivant les règles pour construire le paquet ainsi qu'un fichier de hachage pour vérifier l'intégrité des sources.

 

 

Pour le fichier Config.in, nous définissons simplement le nom du package, comment il apparaît dans le menu de configuration, puis sélectionnons toutes les dépendances.

 

#####################################################################
#
# nushell
#
#####################################################################

NUSHELL_VERSION = 0.76.0
NUSHELL_SITE = $(call github,nushell,nushell,$(NUSHELL_VERSION))
NUSHELL_LICENSE = MIT
NUSHELL_LICENSE_FILES = LICENSE
NUSHELL_DEPENDENCIES = host-pkgconf openssl ncurses

$(eval $(cargo-package))



Pour le fichier nushell.mk, il nous suffit de définir la version, le site Web à partir duquel il est téléchargé (ici, nous utilisons une fonction qui étend automatiquement les arguments à un lien de téléchargement github), le type de licence et le nom du fichier de la licence et ses dépendances comme indiqué dans la documentation. Enfin, nous appelons l'infrastructure de Cargo, qui utilise ces valeurs pour construire le package.

Dans une première version, des messages d'erreur apparaissaient dans le shell et le rendaient inutilisable :

 

 

Après quelques recherches, il s'est avéré que le bogue provenait d'une sous dépendance. Nushell dépend de reedline qui dépend à son tour de crossterm. Dans les sources de crossterm, nous pouvons voir qu'il utilise un programme en ligne de commande externe tput comme on le voit sur l'image suivante :

 

 

Ce programme fait partie de la bibliothèque ncurses et peut être inclus dans une image Buildroot grâce à l'utilisation du package ncurses-target-progs, après quoi, il fonctionne parfaitement.

Du point de vue de la sécurité, il semble être une très mauvaise idée d'utiliser un outil de ligne de commande externe de cette manière. Il suffit d'imaginer qu'un attaquant puisse d'une manière ou d'une autre remplacer le binaire de la commande ou créer un lien symbolique vers un autre binaire qui est ensuite exécuté sans aucune vérification supplémentaire, pouvant ainsi potentiellement faire beaucoup de dégâts. Cela prouve que même si Rust fournit beaucoup de sécurité par défaut, une programmation négligente peut toujours entraîner de gros déficits de sécurité.

 

uutils/coreutils

Les coreutils GNU sont l'une des suites logicielles les plus utilisées dans les systèmes Linux. Leur utilisation généralisée et leur nature de bas niveau en font un candidat idéal pour être implémenté dans Rust. L'implémentation par uutils des coreutils n'est que cela : un remplacement des coreutils GNU entièrement écrits en Rust. Afin d'intégrer ces outils dans Buildroot, nous pouvons également utiliser Cargo. Un problème majeur est cependant le fait que nous devons écraser la fonction par défaut utilisée pour installer le package sur la cible. Normalement, nous devrions utiliser des commandes cargo install . Sur une cible embarquée il est cependant préférable d'économiser au maximum l'espace disque du fait des ressources limitées. Pour ce faire, nous voulons créer un multi-call binary qui implémente toutes les commandes dans un seul binaire et crée un lien symbolique pour chaque commande que nous voulons exécuter qui pointe vers ce binaire. Cargo n'est cependant pas capable de créer de tels liens symboliques. Le paquet contient déjà un Makefile qui fait exactement cela. Il crée un lien symbolique pour chaque commande et nous pouvons ensuite utiliser ce Makefile pour nos propres besoins d'installation. Ce qui suit montre comment nous réécrivons l'étape d'installation par défaut et appelons le Makefile avec notre environnement Buildroot préféré :

 

define UUTILS_COREUTILS_INSTALL_TARGET_CMDS
$(TARGET_MAKE_ENV) $(MAKE) \
$(UUTILS_COREUTILS_MAKE_OPTS) -C $(@D) \
    BUILDDIR=$(@D)/target/$(RUSTC_TARGET_NAME)/$(UUTILS_COREUTILS_PROFILE) \
PREFIX=$(TARGET_DIR) install
endef

 

Un autre problème est que l'étape d'installation du Makefile dépend de l'étape de construction du Makefile. L'étape de construction est cependant effectuée avec un environnement différent de celui utilisé par Buildroot. La commande build peut être vue comme suit :

 

build:
${CARGO} build ${CARGOFLAGS} --features "${EXES}" ${PROFILE_CMD} --no-default-features

install: build

 

Afin de supprimer l'étape de construction du processus d'installation, nous devons créer à nouveau un correctif qui supprime simplement le mot-clé build après le install dans le Makefile.

Ensuite, il est intéressant de vérifier s'il s'agit vraiment d'un remplacement 1 pour 1 des coreutils GNU. Le package inclut la possibilité de le tester en comparaison de la suite de tests GNU, mais cela nécessite trop de ressources pour le faire dans un environnement d'émulation. Pour contourner cela, j'ai créé un simple script shell qui appelle chaque commande avec son option --help et génère un diff pour toutes les options de ligne de commande. Le résultat était qu'il y avait 27 options manquantes ou renommées.

De cela, nous pouvons conclure que la dépendance à Cargo peut être très restrictive pour les packages Rust en raison de sa rigidité. Nous pouvons également voir que même si un package vise à être un remplacement 1 pour 1, ce n'est pas nécessairement le cas et même si ces options ne sont peut-être pas utilisées très couramment, cela peut toujours décourager quelqu'un de passer de l'implémentation classique à la nouvelle. même s'il s'agit peut-être d'une implémentation plus sécurisée.

 

Prise en charge de Rust dans le noyau

Depuis la version 6.3 du noyau, il est officiellement possible d'écrire des modules du noyau en Rust. Avant cela, il n'était disponible que via le fork de développement appelé rust-for-linux.

Afin de pouvoir créer des modules Rust à partir de Buildroot, il existe plusieurs obstacles:

Tout d'abord, lorsque le support de Rust a été fusionné avec le noyau, seul le support des architectures x86 a été fusionné.

Cependant, rust-for-linux supporte également les architectures arm, powerpc et riscv. Buildroot vise à pouvoir créer des systèmes linux pour un large éventail d'architectures, et pas seulement x86 qui est principalement utilisées pour les PC à usage général et non pour les systèmes embarqués.

Le support des modules Rust est encore très basique et le portage de modules complexe (pile TCP/IP) n’est pas encore possible.

De nouvelles fonctionnalités sont ajoutées régulièrement, mais la plus grande contrainte  est le fait qu'au moment d'écrire ceci, il n'y a pas de version minimum prise en charge pour les outils nécessaires à la construction d'un noyau avec le support Rust, à savoir le compilateur rustc et rust-bindgen. Ils sont utilisés pour générer des liaisons entre Rust et le code C.

Buildroot se retrouve aussi dans une boucle de dépendances entre le noyau, le compilateur rust et les outils d'intégrations de rust.

Le noyau s’appuie sur des fonctionnalités de Rust qui évoluent vite, et tombent vite en dépréciation.

Buildroot a besoin d’une version outil d'intégration sachant que celle-ci sera sûrement en retard sur le compilateur Rust.

Le noyau a besoin d’une version de compilateur qui ne match pas toujours avec les outils d'intégration nécessaire à Buildroot

Cela limite considérablement la capacité de mise à niveau de Rust dans Buildroot..

Donc tant qu'il n'y a pas de version minimum pour figer cette boucle, il n'est pas maintenable dans Buildroot. C'est pourtant le but des développeurs de rust-for-linux d'avoir un jour une version minimale supportée.

Ce problème montre un autre défaut de Rust. Il reçoit des mises à jour très fréquemment et il déprécie des fonctionnalités. Alors que pour d'autres langages comme C/C++ par exemple, les mises à jour des compilateurs et du langage lui-même sont découplées (mises à jour du langage tous les ~3 ans, mises à jour du compilateur tous les 3 à 6 mois pour les mises à jour mineures).

Le langage Rust est mis à jour à chaque mise à jour du compilateur et cela de façon beaucoup plus fréquente. 

C et C++, en revanche, ont également tendance à ajouter de nouvelles fonctionnalités au lieu de déprécier les précédentes.

 

RustiCL

Afin d'évaluer les performances d'une application écrite en Rust, nous avons examiné l'implémentation OpenCL de Mesa3D dans Rust : Rusticl.

OpenCL (Open Computing Language) est un framework utilisé pour effectuer des calculs parallèles sur un GPU. Ce test a été effectué sur une carte Khadas VIM3 qui possède un GPU Mali-G52 qui est pris en charge par Mesa3D via le pilote Gallium Panfrost qui est également pris en charge pour Rusticl. Toutes les exigences pour construire Rusticl peuvent être trouvées sur son site Web :https://docs.mesa3d.org/rusticl.html.

Pour le construire dans Buildroot, nous devons :

  • Mettre à jour LLVM de la version 11.1.0 à 15.0.0
  • Inclure les packages spirv-tools et spirv-headers
  • Inclure rust-bindgen
  • Inclure les en-têtes opencl
  • Inclure opencl-icd-loader
  • Ajouter la prise en charge de la compilation croisée rustc dans l'infrastructure meson
  • Ajouter la version cible de spirv-llvm-translator
  • Ajouter la prise en charge de la compilation de LLVM avec -DLLVM_ENABLE_DUMP=ON

 

SPIRV (Standard Portable Intermediate Representation) est une représentation intermédiaire utilisée pour le calcul parallèle et graphiques.

Rust-bindgen est requis par le noyau pour s'interfacer avec le code C.

Opencl-headers est requis pour construire le paquet opencl-icd-loader.

Opencl-icd-loader est nécessaire pour transférer les appels d'API OpenCL vers la bonne implémentation.

Ces appels d'API sont généralement effectués vers une bibliothèque libOpenCL.so. Rusticl ne fournit cependant qu'une bibliothèque libRusticlOpenCL.so. Le chargeur opencl-icd fournit ensuite un fichier libOpenCL.so qui capture ces appels d'API et les transmet à l'implémentation libRusticlOpenCL.so. De cette façon, on peut théoriquement avoir plus d'une implémentation en même temps sur le même système. Mesa3D est un package construit à l'aide de meson.

Pour l'instant, Buildroot n'a pas pris en charge l'utilisation de Rust pour créer des packages de Meson. Nous devons donc faire connaître à l'infrastructure le compilateur utilisé et créer une entrée dans le fichier de configuration de compilation croisée utilisé par meson.

Ceci peut être vu dans l'image suivante:


 

Ici, nous faisons connaître le compilateur Rust à l'infrastructure s'il est sélectionné et le définissons sur /bin/false s'il n'est pas sélectionné. Pour la compilation croisée, Meson nécessite un fichier de configuration qui spécifie la chaîne d'outils utilisée. Étant donné que Buildroot prend en charge plusieurs architectures différentes, les toolchains auront des noms différents. Par conséquent, il utilise un modèle de fichier de configuration pour la compilation croisée, puis remplace ses entrées à l'aide d'expressions régulières visibles au bas de l'image. L'image suivante montre l'entrée dans le fichier de compilation croisée pour le compilateur Rust et l'éditeur de liens utilisé avec les mots-clés qui seront remplacés par les expressions régulières ci-dessous.

 

 

Afin d'activer le pilote OpenCL pour une commande, nous devons définir la variable d'environnement RUSTICL_ENABLE au pilote utilisé, ici, panfrost. On peut alors utiliser l'outil clinfo pour vérifier si l'implémentation OpenCL fonctionne correctement :

 

 

Il est donc complexe, mais possible, d’utiliser OpenCL avec Rust.

 

  1. Performances

Afin de tester ensuite les performances de ce package, l'outil clpeak fonctionne en déterminant d'abord la quantité de travail qui peut être effectuée en parallèle, puis en exécutant une opération 30 fois, en établissant une moyenne du temps nécessaire, puis en calculant les opérations par seconde qui ont été effectuées. Ces tests sont effectués avec des vecteurs de tailles différentes avec des types de données variables : float4 -> un vecteur avec 4 valeurs flottantes. Nous avons comparé les performances de ce pilote avec le pilote GPU propriétaire ARM Mali. Les résultats sont visibles dans le tableau suivant :

rusticl_perf

 

 

À partir de ces résultats, nous pouvons voir que le pilote Rust est comparable en termes de performances en termes de bande passante mémoire, mais qu'il est considérablement plus rapide dans les calculs avec jusqu'à 76 fois les performances des flottants à simple précision. En moyenne, les performances sont multipliées par 35. Cette augmentation des performances n'est probablement pas due aux performances de Rust lui-même, mais plutôt parce qu'il s'agit simplement d'une bien meilleure implémentation du pilote. Il convient de noter cependant que Rusticl ne prend pas encore en charge l'ensemble de la spécification OpenCL et que l'utilisation des minuteries OpenCL, par exemple, n'est pas encore possible.

 

Conclusion

En conclusion, on peut dire que Rust est un langage très prometteur pour les systèmes embarqués, notamment du fait de ses mécanismes de sécurité qui sont appliqués par défaut tout en conservant de très bonnes performances.

C'est un langage récent qui gagne en popularité et les choses changent encore rapidement. C'est une bonne chose, mais avec des contreparties. De nouvelles fonctionnalités apparaissent fréquemment, mais les changements apportés aux fonctionnalités existantes ont tendance à remettre en question les bases précédemment définies.

C'est un problème pour les systèmes embarqués car ils nécessitent de la fiabilité. La jeunesse du  langage signifie également que les solutions disponibles manquent de maturité. Quant à la communauté, bien que très active, elle n'est pas encore aussi développée que peut l'être celle des autres langages.

Nous avons également vu que Rust ne résout pas tous les problèmes liés à la sécurité, surtout quand unsafe doit être utilisé. Également, sa dépendance à l'égard de Cargo peut compliquer la vie d'un développeur. Nous avons également discuté du fait que les implémentations Rust des packages pourraient ne pas être un remplacement à 100%, ce qui décourage également un changement.

Pour l'instant, il n'est pas encore viable de piloter un système embarqué principalement avec des solutions Rust mais étant donné que le paysage évolue rapidement, les choses seront probablement très différentes dans 10 ans.

 


( English version )

 

Integrating Rust into Buildroot

 

Introduction

Rust is a recent language that appeared in 2015. Since then it has been introduced in more and more software development. The goal of the language is to provide excellent memory safety without the need for a garbage collector while maintaining a level of performance comparable to C and C++.

With Rust, it is essential to produce memory safe code thanks to several rules checked directly by the compiler at compile time.

This makes it particularly interesting for embedded systems.

It is therefore legitimate to put it in competition with C and C++ and their notoriously tedious system of memory management where memory must be allocated and freed manually.

In order to evaluate the use of the Rust solution, we need to look at several things:

  1. What sets Rust apart from other languages?
  2. How to integrate it with BuildRoot?
  3. Does it perform well?

To answer all these questions, I will present the outline of the Rust language, integrate a couple of Rust solutions into Buildroot and document my experiences in this article.

 

  1. Rust Concepts

Rust uses 3 main concepts to guarantee the security of its memory. Some of these concepts may also be present in other languages, especially with the use of external tools to detect memory leaks, but Rust is a language that applies a lot of good practices and aims to provide good error messages through its compiler to help the developer. I'll first describe Rust's 3 main concepts for memory safety, then talk about Rust unsafe, which is important in a low-level embedded context.

 

Ownership

This is Rust's system for avoiding the need for a garbage collector. Garbage collectors generally work by dynamically checking whether allocated memory is still in use or not, and then freeing it if necessary. Rust avoids this through the use of 3 simple rules:

  • Every value in Rust has a owner
  • There can only be one owner at a time
  • When the owner goes out of scope, the value is removed (freed)

 

These rules are especially important when dealing with data types that do not have a fixed size at compile time, such as strings. For fixed-size data types (like i32, a 32-bit integer), the value would be copied from one owner to another.

The following example shows a simple program that would be rejected by the compiler due to property issues.

 

let s1 = String::from("hello"); // s1 gets the pointer to the memory area
let s2 = s1;// ownership of the pointer gets passed to s2

println!("{}, world!", s1);// Error: s1 does not own the data anymore

 

The String::from() method allocates the necessary memory on the heap and returns a reference to this memory which is assigned to the variable s1. s1 therefore becomes the owner of this data. With the second line, s2 becomes the new owner of the data and s1 is deleted. We then try to reference the value of s1 which does not work because it is no longer a valid value.

 

Borrowing

Sometimes it's better to be able to manipulate variables without taking ownership of the data. This is true for function calls because ownership of the data would be passed to the input parameter which would remove it at the end of a function call (unless it is also used as a return value).

Two borrowing kinds are possible: borrowing mutable or immutable.

At any time, it is possible to have an infinite number of immutable borrowing but only one mutable loan. On top of that, mutable borrowing cannot occur Before an immutable borrowing.

All this guarantees that no data race occurs and guarantees, in the end, the state of the values ​​at any time.

The following example shows the notation used to borrow a value inside a function. At the end of the call to the 'change()' function, the property is automatically returned to the variable 's'.

 

fn main() {
let but s = String::from("hello");

change(&but s);    //value gets borrowed
    println!("{}", s); //ownership is automatically returned
}

fn change(some_string: &but String) {
some_string.push(", world");
}

 

Lifetime

The final concept for guaranteeing memory safety is the lifetime. Lifetime is ubiquitous in Rust code and is inferred by the compiler so it is not directly visible to the developer.

The lifetime of a value refers to the parts of code in which a value is valid. This often matches the scope of a variable, but not always.

The following code snippet shows a program that fails due to lifetime issues.

 

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // +-- 'b  |
        r = &x;           //  |       |
    }                     // +-------|
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

 

Here, 'x' has a lifetime of 'b. We then try to reference 'r' in its own lifetime but since it contains a reference to the variable 'x' which is out of scope, it contains an invalid reference. For this code to work, the lifetime of 'a must fit entirely inside 'b as in the following example.

 

fn main() {
    let x = 5;            // ----------+-- 'b
                          //           |
    let r = &x;           // +-- 'a  |
                          //   |       |
    println!("r: {}", r); //   |       |
                          // +-------|
}                         // ---------+

 

In these cases, the lifetime can be directly inferred by the compiler, but sometimes this is not possible. When this is not possible, lifetimes should be manually annotated using generic type parameters. Consider the following example where there are two strings and a function that takes both as arguments and returns the longer one to a new variable:

 

fn main() {
    let string1 = String::from("abcd");
    let string2 = String::from("xyz");

    let result = longest(string1.as_str(), string2.as_str());
    println!("The longest string is {}", result);
}

 

With the following definition of longest() the code would not work because the return value must have a lifetime, but it can take the value of either string1 or string2 and those strings could potentially have 2 different lifetimes.

 

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

 

For this to work, we need to specify the lifetimes manually. Lifetime names always start with a single quote: ‘. The following code is a good example:

 

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

 

By specifying a single lifetime parameter 'a, we are saying that the lifetime of the return value should be as long as the shorter of the two input parameter lifetimes.

As soon as one of the lifetimes of the input parameter ends, the lifetime of the return value also ends.

 

Unsafe Rust

Rust's security rules are extremely conservative. That is to say, to guarantee memory safety, the compiler sometimes rejects code that could be perfectly fine but does not respect the rules mentioned above.

Sometimes it is necessary to write code that violates these rules. For this reason, Rust includes the keyword unsafe. When this keyword is used, it means that we admit that the code is potentially dangerous. By this, we understand that the compiler cannot guarantee proper memory usage in this section. More concretely, this will make 5 things possible, which were not possible before:

  • Dereference a raw pointer
  • Calling a dangerous function or method (unsafe)
  • Access or modify a mutable static variable
  • Implement a Dangerous Trait
  • Access union fields

 

A raw pointer is a pointer that is not guaranteed to point to valid memory and may be NULL. It also ignores borrowing rules so it can be mutable borrowed multiple times for example.

A dangerous function or method is a function or method that may contain unsafe Rust code.

A static variable is similar to global constants because their lifetimes are always across the entire program. The difference between them is that static variables have a fixed address in heap memory whereas global variables can be copied. Static variables can also be modifiable and access to their value is then considered dangerous.

A trait is a feature for grouping methods with a specific signature. Traits can then be implemented by classes and are required by the compiler to implement the method as defined in the trait definition. Traits can also be used in function signatures to only accept input parameter types that implement a certain trait. Dangerous traits can therefore contain dangerous methods.

Unions are similar to unions in C in that they can hold different types of data but only one type at a time. They are mainly used to interface with unions in C code. Since the compiler cannot guarantee what type of data is present in a union, it is considered dangerous.

As a general rule, dangerous code should be as small as possible as this will reduce the amount of memory debugging that will need to be done in case of an error.

 

In a few words

Rust is a heavily constrained language that takes great care not to let the developer make a careless mistake by forcing him to implement good practices.

This is an advantage when you have to produce code for an embedded system, with limited resources and complex update capabilities.

 

  1. How to integrate Rust with BuildRoot

We have seen that Rust is a language adapted to the constraints of embedded systems on the critical subject of memory usage. But is it simple to use in a development environment like BuildRoot?

 

Rust: fix bootstrap build

As a result of updating the Rust compiler to version 1.67.0, building it from source is broken. When this bug was discovered, there was already a commit in the Rust upstream that fixed this bug. In order to integrate this fix into Buildroot, we need to follow a few steps:

  • Clone Rust repository from github
  • Check the correct version:git checkout 1.67.0
  • Identify the commit ID of the commit that interests us using gitk for example
  • Cherry-pick le commit id :git cherry-pick 675fa0b3
  • Add a Signed-off-by: line to the commit:git commit --amend -s
  • Create a patch:git format-patch -1 HEAD
  • Place the generated patch file in Rust's package folder in Buildroot

 

 

By placing this file in the folder, it is automatically detected by Buildroot when building the package and applied to the sources after the download step:

 

 

This shows us that somehow it was possible that an error was embedded in the Rust compiler. It is not certain that it is bug free and therefore, it should always be checked before being integrated into other systems.

 

Nushell

Nushell is a shell written entirely in Rust and it was used to test Cargo's infrastructure in Buildroot. Cargo is Rust's package manager and is also used as a build system for Rust applications with their dependencies. Rust relies heavily on Cargo, so it's important to be aware of its limitations when integrating with other systems. The integration of Nushell was quite simple thanks to Cargo. It was just necessary to create a very basic configuration file to be able to select the package then a basic .mk file describing the rules to build the package as well as a hash file to check the integrity of the sources.

 

 

For the Config.in file, we simply set the package name, how it appears in the configuration menu, and then select all dependencies.

 

#####################################################################
#
# nushell
#
#####################################################################

NUSHELL_VERSION = 0.76.0
NUSHELL_SITE = $(call github,nushell,nushell,$(NUSHELL_VERSION))
NUSHELL_LICENSE = WITH
NUSHELL_LICENSE_FILES = LICENSE
NUSHELL_DEPENDENCIES = host-pkgconf openssl ncurses

$(eval $(cargo-package))



 

For the nushell.mk file, we just need to set the version, the website it's downloaded from (here we're using a function that automatically expands the arguments to a github download link), the license type, and the license file name and its dependencies as stated in the documentation. Finally, we call the Cargo framework, which uses these values ​​to build the package.

In an early version, error messages appeared in the shell and made it unusable:

 

 

After some research, it turned out that the bug was caused by a sub dependency. Nushell depends on reedline which itself depends on crossterm. In the sources of crossterm we can see that it uses an external command line program tput as seen in the following image:

 

 

This program is part of the library ncurses and can be included in a Buildroot image through the use of the package ncurses-target-progs, after which it works perfectly.

From a security point of view, it seems like a very bad idea to use an external command line tool in this way. Just imagine that an attacker could somehow override the command binary or create a symlink to another binary which is then executed without any additional checks, thus potentially doing a lot of damage . This proves that even though Rust provides a lot of security by default, careless programming can still lead to big security deficits.

 

uutils/coreutils

GNU coreutils are one of the most widely used software suites in Linux systems. Their widespread use and low-level nature make them an ideal candidate for implementation in Rust. The implementation by uutils coreutils is just that: a replacement for GNU coreutils written entirely in Rust. In order to integrate these tools into Buildroot, we can also use Cargo. A major problem however is the fact that we have to override the default function used to install the package on the target. Normally we should use ‘Cargo’ install command. On an embedded target, however, it is preferable to save disk space as much as possible due to the limited resources. To do this, we want to create a multi-call binary which implements all the commands in a single binary and creates a symbolic link for each command we want to run that points to that binary. Cargo is however not able to create such symbolic links. The package already contains a Makefile that does exactly that. It creates a symbolic link for each command and we can then use that Makefile for our own installation needs. The following shows how we rewrite the default install step and call the Makefile with our preferred Buildroot environment:

 

define UUTILS_COREUTILS_INSTALL_TARGET_CMDS
$(TARGET_MAKE_ENV) $(MAKE) \
$(UUTILS_COREUTILS_MAKE_OPTS) -C $(@D) \
    BUILDDIR=$(@D)/target/$(RUSTC_TARGET_NAME)/$(UUTILS_COREUTILS_PROFILE) \
PREFIX=$(TARGET_DIR) install
let go

 

Another problem is that the Makefile installation step depends on the Makefile build step. The build step is however performed with a different environment than the one used by Buildroot. The build command can be seen as follows:

 

build:
${CARGO} build ${CARGOFLAGS} --features "${EXES}" ${PROFILE_CMD} --no-default-features

install: build

 

In order to remove the build step from the installation process, we need to re-create a patch that simply removes the keyword build after the install in the Makefile.

 

Then it's worth checking if it really is a 1-to-1 replacement for GNU coreutils. The package includes the ability to test it against the GNU test suite, but that requires too many resources to do so in an emulation environment. To work around this, I created a simple shell script that calls each command with its --help option and generates a  diff for all command line options. The result was that there were 27 missing or renamed options.

 

From this we can conclude that the dependency on Cargo can be very restrictive for Rust packages due to its rigidity. We can also see that even if a package aims to be a 1 for 1 replacement, that is not necessarily the case and while these options may not be used very commonly, it can still discourage someone from switching from the classic implementation to the new one. although it may be a more secure implementation.

 

Kernel Rust support

Since kernel version 6.3, it is officially possible to write kernel modules in Rust. Before that, it was only available through the development fork called rust-for-linux.

In order to be able to create Rust modules from Buildroot, there are several obstacles:

First, when support for Rust was merged into the kernel, only support for x86 architectures was merged.

However, rust-for-linux also supports arm, powerpc and riscv architectures. Buildroot aims to be able to build linux systems for a wide range of architectures, not just x86 which is mostly used for general purpose PCs and not embedded systems.

Support for Rust modules is still very basic and porting complex modules (TCP/IP stack) is not yet possible.

New features are added regularly, but the biggest constraint is the fact that at the time of writing this there is no version minimum support for the tools needed to build a kernel with Rust support, namely the rustc compiler and rust-bindgen. They are used to generate bindings between Rust and C code.

Buildroot also finds itself in a dependency loop between the kernel, the rust compiler, and the rust integration tools.

The kernel relies on features of Rust that evolve quickly, and quickly fall into deprecation.

Buildroot needs an integration tool version knowing that it will surely lag behind the Rust compiler.

The kernel needs a compiler version that doesn't always match the integration tools needed by Buildroot

This severely limits Rust's upgradeability in Buildroot.

So as long as there is no version minimum to freeze this loop, it is not maintainable in Buildroot. It is however the goal of the developers of rust-for-linux to one day have a minimum supported version.

This issue shows another problem with Rust. It receives updates very frequently and it deprecates features. While for other languages ​​like C/C++ for example, the updates of the compilers and the language itself are decoupled (language updates every ~3 years, compiler updates every 3 to 6 months for minor updates).

The Rust language is updated with each compiler update and much more frequently.

C and C++, on the other hand, also tend to add new features instead of deprecating previous ones.

 

RustiCL

In order to evaluate the performance of an application written in Rust, we examined the OpenCL implementation of Mesa3D in Rust: Rusticl.

OpenCL (Open Computing Language) is a framework used to perform parallel computations on a GPU. This test was performed on a Khadas VIM3 board which has a Mali-G52 GPU which is supported by Mesa3D via the Gallium Panfrost driver which is also supported for Rusticl. All the requirements to build Rusticl can be found on its website:https://docs.mesa3d.org/rusticl.html.

To build it in Buildroot we need to:

  • Update LLVM from version 11.1.0 to 15.0.0
  • Include the spirv-tools and spirv-headers packages
  • Include rust-bindgen
  • Include opencl headers
  • Include opencl-icd-loader
  • Add rustc cross-compilation support in the meson framework
  • Add target version of spirv-llvm-translator
  • Add support for compiling LLVM with -DLLVM_ENABLE_DUMP=ON

SPIRV (Standard Portable Intermediate Representation) is an intermediate representation used for parallel computing and graphics.

Rest-bind is required by the kernel to interface with C code.

Opencl-headers is required to build the opencl-icd-loader package.

Opencl-icd-loader is needed to forward OpenCL API calls to the correct implementation.

These API calls are usually made to a libOpenCL.so library. Rusticl however only provides a libRusticlOpenCL.so library. The opencl-icd loader then provides a libOpenCL.so file that captures these API calls and passes them to the libRusticlOpenCL.so implementation. This way, one can theoretically have more than one implementation at the same time on the same system. Mesa3D is a package built using Meson.

At this time, Buildroot has not supported using Rust to create packages of Meson. So we need to let the framework know which compiler is used and create an entry in the cross-compilation configuration file used by meson.

This can be seen in the following image:


 

Here we inform the framework that the Rust compiler is selected. For cross-compilation, Meson requires a configuration file that specifies the toolchain used. Since Buildroot supports several different architectures, the toolchains will have different names. Therefore, it uses a configuration file template for cross-compiling and then replaces its inputs using regular expressions seen at the bottom of the image. The following image shows the entry in the cross-compilation file for the Rust compiler and linker used with the keywords that will be replaced with the regular expressions below.

 

 

In order to enable the OpenCL driver for a command, we need to set the environment variable RUSTICL_ENABLE to the driver used, here, panfrost. You can then use the tool clinfo to check if the OpenCL implementation is working correctly:

 

 

It is therefore complex, but possible, to use OpenCL with Rust.

 

  1. Performances

Finally, to test the performance of this package, the tool clpeak works by first determining the amount of work that can be done in parallel, then running an operation 30 times, averaging the time it takes, and calculating the operations per second that have been done. These tests are performed with vectors of different sizes with variable data types: float4 -> a vector with 4 float values. We compared the performance of this driver with the proprietary ARM Mali GPU driver. The results are visible in the following table:

 

rusticl_perf

 

From these results we can see that the Rust driver is comparable in performance in terms of memory bandwidth, but is considerably faster in computations with up to 76 times the performance of single precision floats. On average, the performance is increased by 35 times. This performance increase is probably not due to the performance of Rust itself, but rather because it is simply a much better implementation of the driver. It should be noted however that Rusticl does not yet support the entire OpenCL specification and the use of OpenCL timers, for example, is not yet possible.


Conclusion

In conclusion, we can say that Rust is a very promising language for embedded systems, in particular because of its security mechanisms which are applied by default while maintaining very good performance.

It is a recent language that is gaining popularity and things are still changing rapidly. It's a good thing, but with counterparts. New features pop up frequently, but changes to existing features tend to challenge previously set paradigms.

This is a problem for embedded systems because they require reliability. The youth of the language also means that the available solutions lack maturity. As for the community, although very active, it is not yet as developed as that of other languages.

We've also seen that Rust doesn't solve all security-related issues, especially when unsafe must be used. Also, its dependence on Cargo can complicate a developer's life. We also discussed that the Rust implementations of the packages might not be a 100% replacement, which also discourages a change.

It's not yet viable to drive an embedded system with a full Rust solution, but given the rapidly changing landscape, things will likely be very different in 10 years.

 

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.