Linux Embedded

Le blog des technologies libres et embarquées

Introduction à la programmation UEFI en langage Rust

L’Unified Extensible Firmware Interface (UEFI) est une spécification ouverte établie par l’UEFI Forum, une organisation regroupant les grands noms de l’industrie informatique, qui définit une interface entre le micrologiciel (firmware) d’une carte mère et un programme informatique (chargeur d'amorçage, système d’exploitation, applications, pilotes). Cette interface a remplacé progressivement le BIOS sur les PC à partir des années 2000 et a également conquis les systèmes embarqués. Contrairement au BIOS, l’UEFI propose une interface de haut niveau avec un système initialisé au démarrage pour permettre d'interagir avec les disques, le réseau, les périphériques, une interface en ligne de commande et des fonctions de dessin pour concevoir une interface graphique. 

L’UEFI étant une spécification, il existe plusieurs implémentations. La plupart sont écrites en C et open source. Pour cet article, nous allons utiliser l’implémentation TianoCore EDK II développée par Intel sous licence BSD-2-Clause-Patent. Pour compléter la littérature à ce sujet, nous allons utiliser TianoCore EDK II à travers un wrapper Rust uefi-rs, sous licence MPL-2.0,  qui ajoute une couche d'abstraction et de sécurité au programmeur. 

 

L’ensemble des sources qui accompagne l’article se trouve sur ce dépôt git.

Principales caractéristiques de l’UEFI 

 

Command line interface

A l’instar du BIOS, l’UEFI propose une interface en ligne de commande pour gérer les sorties/entrées du clavier afin d'interagir avec l’utilisateur.

 

Interface Graphique

Afin de dessiner sur l’écran, l’UEFI propose une API graphique pour manipuler des pixels.  

 

Taille de l'Adresse de Mémoire

L’UEFI gère l’adressage 32 bits et 64 bits selon les processeurs permettant l’élaboration de programmes plus sophistiqués. 

 

Langage de programmation de haut niveau

Il est possible de programmer directement en langage C/C++. L’UEFI possède également un interpréteur python. 

 

Architecture modulaire

L’architecture de l’UEFI est modulaire permettant une bonne flexibilité et extensibilité par rapport à une architecture monolithique. Les composants, comme le réseau ou le gestionnaire de fichiers, sont isolés. Cette isolation permet une meilleure organisation du code et facilite le dépannage et la maintenance. La communication entre les modules se fait à travers des protocoles. 

 

Support de Disques GPT (GUID Partition Table)

L'UEFI prend en charge les disques utilisant le schéma de partition GPT, permettant l'utilisation de disques de plus de 2 To et jusqu'à 128 partitions primaires

 

Sécurité 

L'UEFI introduit la fonctionnalité de Secure Boot, qui empêche le chargement de logiciels malveillants au démarrage de l'ordinateur en vérifiant la signature numérique des logiciels.

 

Evenements et Timers 

L'UEFI donne accès à des événements et des timers

 

Network

L’UEFI inclut les protocoles réseaux comme PXE, IP4,IP6 DHCP,UDP, TFTP,TCP,HTTP

Installation de l’environnement

Pour développer une application UEFI en Rust sous Debian, nous devons installer :

 

Le langage Rust

 

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

 

Pour mettre en place notre application Rust, nous allons utiliser Cargo, utilitaire fourni avec Rust, via les commandes suivantes

 

cargo new hello_world
cd hello_world

 

On ajoute ensuite les dépendances Rust pour l’UEFI.

 

cargo add log
cargo add uefi --features logger,panic_handler

 

Et on remplace le contenu du fichier src/main.rs, par le code suivant

 

#![no_main]
#![no_std]

use log::info;
use uefi::prelude::*;

#[entry]
fn main(_image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
    uefi::helpers::init(&mut system_table).unwrap();
    info!("Hello world!");
    system_table.boot_services().stall(10_000_000);
    Status::SUCCESS
}

 

Discutons du code que l’on vient d’ajouter :

 

#![no_main]
#![no_std]

 

Comme nous développons dans un environnement sans système d’exploitation, nous devons définir nous-même le point d’entrée de notre application avec la directive #[entry] et #![no_main]. Nous n’avons également pas accès à la library std de Rust. Celle-ci est en effet dépendante de l’OS, dans le cas de Linux, la dépendance vient de la libc. 

 

fn main(_image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status

 

La fonction d’entrée d’un programme UEFI retourne obligatoirement un Status définit par l’UEFI et prend toujours deux arguments : 

 

  • Un handle qui représente l’image de l’application en cours. Un handle représente une ressource comme un disque ou une clé USB. Il peut également être plus générique comme dans ce cas avec l’image de l’application en cours.
  • Un system table fournit l’accès à différents services comme dans le code que nous venons d’écrire avec la ligne 

 

system_table.boot_services().stall(10_000_000);

 

Celle-ci récupère le boot services qui permet de mettre en pause le programme. Il existe également un runtime services qui donne accès à un nombre de services plus restreint. Durant l’étape de démarrage de l’UEFI, il faut distinguer deux étapes : 

 

  • L’étape de boot permet l’accès aux services boot et running. Les applications et les pilotes fonctionnent en mode boot ce qui nous donne accès à tous les services
  • L’étape running est généralement lancée par le bootloader qui démarre l’OS. Linux et Windows fonctionne en mode running. Les boot services ne sont plus accessibles dans ce mode. 

     

La dernière ligne importante à commenter est la suivante : 

 

  uefi::helpers::init(&mut system_table).unwrap();

 

Cette première instruction de notre programme initialise plusieurs éléments importants dont la mémoire, les services et les logs. 

Compilation de l'application

Avant de compiler, nous avons besoin de définir un rust tool chain file à la racine de notre application. Créer le fichier rust-toolchain.toml avec le contenu suivant :

 

[toolchain]
targets = ["aarch64-unknown-uefi", "i686-unknown-uefi", "x86_64-unknown-uefi"]

 

Ensuite compiler l’application avec la commande ci-dessous : 

 

cargo build --target x86_64-unknown-uefi

 

Un exécutable uefi-app.efi pour l’architecture x86_64 se trouve à ce chemin :

 

target/x86_64-unknown-uefi/debug/

Lancer l’application

Avant d’exécuter l’application, nous devons installer Qemu, un émulateur x86_64 et un firmware UEFI  :

 

sudo apt-get install qemu ovmf

 

Nous allons ensuite copier deux fichiers du firmware UEFI dans le répertoire de notre application :

 

cp /usr/share/OVMF/OVMF_CODE.fd .
cp /usr/share/OVMF/OVMF_VARS.fd .

 

Il nous faut également mettre en place la partition UEFI qui contiendra notre exécutable et que nous fournirons en argument à Qemu :

 

mkdir -p esp/efi/boot
cp target/x86_64-unknown-uefi/debug/hello_world.efi esp/efi/boot/bootx64.efi

 

Nous pouvons finalement lancer l’application :

 

qemu-system-x86_64 -enable-kvm \
    -drive if=pflash,format=raw,readonly=on,file=OVMF_CODE.fd \
    -drive if=pflash,format=raw,readonly=on,file=OVMF_VARS.fd \
    -drive format=raw,file=fat:rw:esp

 

Après l’initialisation, l’écran suivant devrait apparaître: 

 

L’ensemble du code source de ce chapitre se trouve dans le dossier 01 hello world du dépôt git. 

Interaction utilisateur en mode texte

Dans ce chapitre, nous allons nous pencher sur l’API permettant d'interagir avec la console en mode texte. Nous partirons des sources du chapitre précédent

La sortie standard

Nous modifions le fichier main.rs comme suit : 

 

#![no_main]
#![no_std]

use log::info;
use uefi::prelude::*;

// include pour la recherche de protocols
use uefi::table::boot::SearchType;
use uefi::Identify;

#[entry]
fn main(_image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
uefi::helpers::init(&mut system_table).unwrap();
    
// Récupère un handle sur la sortie de l'écran
let output_handle = *system_table.boot_services()  .locate_handle_buffer(SearchType::ByProtocol(&uefi::proto::console::text::Output::GUID)).unwrap()
     .first()
     .expect("Output is missing");

// Ouvre le protocol de sortie de l'écran
let mut output_screen_protocol = system_table.boot_services().open_protocol_exclusive::<uefi::proto::console::text::Output>(output_handle).unwrap();

// Efface l'écran
output_screen_protocol.clear();

// Change la couleur du texte en vert avec un fond en noir
output_screen_protocol.set_color(
uefi::proto::console::text::Color::Green,
uefi::proto::console::text::Color::Black
);
    
// Affiche hello world à l'écran
output_screen_protocol.output_string(cstr16!("Hello World"));
   
// Pause durant 10 secondes
system_table.boot_services().stall(10_000_000);
    
Status::SUCCESS
}

 

Pour pouvoir afficher du texte à l’écran, nous avons besoin de récupérer un handle, qui un est un pointeur sur une ressource, en l'occurrence ici la sortie d’écran. Nous utilisons un identifiant unique (GUID) pour aller le récupérer dans la table système contenant les services boot avec cette instruction : 

 

system_table.boot_services()  .locate_handle_buffer(SearchType::ByProtocol(&uefi::proto::console::text::Output::GUID)).unwrap()
     .first()
     .expect("Output is missing");
}

 

Nous ne pouvons pas utiliser l’handle directement, il nous servira à ouvrir un protocole pour écrire sur l’écran. Nous l’ouvrirons en mode exclusif pour que le protocole soit fermé automatiquement quand il sera détruit à la fin de la fonction :

 

system_table.boot_services().open_protocol_exclusive::<uefi::proto::console::text::Output>(output_handle).unwrap();

 

Une fois notre protocole obtenu sur la sortie d’écran, nous pouvons l’utiliser pour effacer l’écran, changer la couleur du texte, et écrire notre message. Pour afficher du texte, il faut convertir notre texte en Unicode 16 grâce à la macro cstr16!.

 

On peut cependant simplifier notre fonction main parce que lors de l’initialisation faite par le helper lors de notre première instruction, le protocole de sortie d’écran a déjà été récupéré. Il nous suffit donc de l’utiliser. 

 

fn main(_image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
uefi::helpers::init(&mut system_table).unwrap();
    
// Efface l'écran
system_table.stdout().clear();
    
// Change la couleur du texte en vert avec un fond en noir
system_table.stdout().set_color(
uefi::proto::console::text::Color::Green, uefi::proto::console::text::Color::Black
);
    
// Affiche hello world sur l'écran
system_table.stdout().output_string(cstr16!("Hello World"));
    
// Pause durant 10 secondes
system_table.boot_services().stall(10_000_000);

Status::SUCCESS
}

 

L'exécution de ce code donnera l’image suivante :

L’entrée standard

Le protocole de l’entrée clavier se récupère comme la sortie écran : 

 

loop {
     // Récupère l'événement clavier
     if let Some(event) = system_table.stdin().wait_for_key_event() {
         // Attend que l'événement clavier a bien eu lieu
if system_table.boot_services().wait_for_event(&mut [event]).unwrap() == 0 {
             break;
         }
     }
     // Pause durant 10 ms
     system_table.boot_services().stall(10_000);
}

 

Nous récupérons un événement de l’entrée standard, ici le clavier, depuis le protocole Input de la console. Cet événement attend l’appuie sur une touche du clavier. Nous utilisons ensuite un autre protocole qui bloque le programme jusqu'à ce qu'un des événements passés  en argument lance un signal. 

 

On peut ensuite compléter notre code pour demander à l’utilisateur d’appuyer sur la touche espace en remplaçant le break du code ci-dessus par celui-ci : 

 

if let Ok(key_option) = system_table.stdin().read_key() {
match key_option  {
// vérifié le code ascii de la touche espace
Some(key) => {
if key == Key::Printable(Char16::try_from(0x20).unwrap()) {
                   break;
                 }
           },
           _ => ()
      }
}

 

Nous récupérons juste l’enum Key renvoyé par la fonction read_key(), puis nous vérifions qu’il s’agit d’un espace.

 

Le code complet de ce chapitre se trouve dans le dossier 02_console du dépôt.  

Système de fichiers

Pour lire ou écrire sur un disque, nous devons également utiliser un handle pour récupérer le protocole SimpleFileSystem qui nous permettra d’ouvrir un volume disque.

Avant de commencer, nous devons ajouter l’allocation à notre projet pour pouvoir utiliser des vecteurs dans Rust :

 

cargo add uefi --features global_allocator,alloc

 

Dans notre programme, nous devons ensuite initialiser l’allocation :

 

unsafe {
   uefi::allocator::init(&mut system_table);
}

Ouvrir un volume disque

Afin d'interagir avec le système fichiers, nous devons ouvrir un volume disque à travers le protocole simple file system :

 

// Récupère le protocole simple file system à partir de notre image handle
let mut sfs = system_table.boot_services()
.get_image_file_system(_image_handle).unwrap();

// Ouvre le volume du disque pour accèder au système de fichiers
let mut volume = sfs.open_volume().unwrap();

Lister les fichiers du répertoire courant

Maintenant que nous avons ouvert un volume, nous pouvons l’utiliser pour lister les fichiers et répertoires dans le dossier courant en appelant dans une boucle la méthode read_entry() comme ceci :

 

 fn list_dir(volume: &mut Directory, buffer: &mut [u8]) {
// boucle sur les répertoires
loop {
     let entry = volume.read_entry(buffer);
     match entry {
         Ok(fileInfoOption) => {
             match fileInfoOption {
                 Some(fileInfo) => {
                 // affiche le nom du fichier et sa taille
                     info!(
                         "- filename: {}  size:{}",
                         fileInfo.file_name(),
                         fileInfo.file_size()
                     );
                 },
                 _ => break,
             }

         },
         _ => break,
     }
   }
}

Écrire un fichier texte

Écrire dans un fichier se fait également aisément avec notre volume. Il suffit d’ouvrir un fichier en écriture avec la méthode open. Puis d'utiliser ce pointeur sur un fichier dans un Regular File comme le montre ce code : 

 

fn write_file(filename:&CStr16, text: &[u8],  volume: &mut Directory) {

// Ouvre le fichier
let readme_txt = volume.open(
     filename,
     FileMode::CreateReadWrite,
     FileAttribute::empty(),
).unwrap();

// Ecriture
unsafe {
     let mut f = RegularFile::new(readme_txt);
     let _ = f.write(text);
     let _ = f.flush();
     f.close();
}
    
}

Lire un fichier texte

Lire dans un fichier texte est similaire à son écriture. On ouvre un pointeur sur un fichier puis on utilise un Regular File en y ajoutant un tampon pour récupérer le texte  : 

 

fn read_file(filename:&CStr16, volume: &mut Directory, buffer: &mut [u8]) {

// Ouvre le fichier
let readme_txt = volume.open(
     filename,
     FileMode::CreateReadWrite,
     FileAttribute::empty(),
).unwrap();

// Lecture
unsafe {
     let mut f = RegularFile::new(readme_txt);
     let size = f.read(buffer).unwrap();
     if size < buffer.len() {
         let t = &buffer[0..size as usize];
         info!("{}", CStr8::from_bytes_with_nul_unchecked(t));
     }
     f.close();

 }
}

Le code complet de ce chapitre se trouve dans le dossier 03_file_system du dépôt.  

Conclusion

Dans cette introduction à la programmation UEFI en Rust, nous avons pu découvrir les bases de l’UEFI en utilisant la bibliothèque uefi-rs qui en simplifie le développement. La partie installation du présent article est inspirée du tutorial de cette bibliothèque. 

En découvrant comment interagir facilement avec la console et le système de fichiers, le lecteur aura la capacité de développer une refonte des premiers OS sur PC qui s’appuyaient grandement sur le BIOS. 

 

 

 

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.