Introduction#
J’ai toujours été attiré par le bas niveau en programmation, par exemple sur le pourquoi du comment un langage interprété est plus lent qu’un langage compilé ou ce qu’est réellement le noyau d’un système d’exploitation. N’ayant pas trop pratiqué durant mes études mis à part le C++, et ce, sans trop aller dans le détail, j’ai décidé de me pencher un peu plus sur le sujet.
Après discussions et recommandations, on m’a vivement conseillé d’apprendre les rudiments du C pour comprendre un peu mieux ces aspects de l’informatique.
Nous allons donc voir dans cette suite d’articles, en quoi le C est, à mon avis, le meilleur langage pour apprendre et comprendre ce qui fait la différence entre un langage dit de haut niveau et de bas niveau, un peu du fonctionnement basique d’un système d’exploitation, ainsi que comment un programme (et donc un développeur) interagit avec celui-ci.
Les briques élémentaires#
Nativement en C, il n’y a pas énormément d’abstractions. Les abstractions sont simplement des éléments du langage que le développeur va pouvoir utiliser et qui vont implémenter des principes de programmation. Par exemple, une abstraction peut être sous la forme de fonctions mises à disposition du développeur et qui vont encapsuler du code, permettant un développement simplifié. Ainsi, le C nous donne les outils rudimentaires, des sortes de briques élémentaires (à l’instar des Legos) avec lesquels nous allons pouvoir créer des choses plus complexes. Ce sont ces briques que nous allons explorer dans un premier temps.
Devoir et pouvoir construire des éléments qui relatent du haut niveau va nous permettre de comprendre leurs fonctionnements.
Le revers de la médaille, c’est que cela va demander au développeur de continuellement réinventer la roue. C’est pour cela que je considère le C comme parfait pour apprendre, mais un peu moins pour le reste.
Quelques définitions#
Il faut expliquer quelques définitions pour savoir où on va mettre les pieds :
Nom | Définition |
---|---|
Noyau (kernel) | Le cœur du système d’exploitation, c’est le deuxième programme chargé en mémoire (juste après le bootloader) ; il va gérer les fonctions vitales de celui-ci, par exemple la gestion de la mémoire, des processus, des périphériques, etc. |
Système d’exploitation (OS) | Couche logicielle qui va être composée du noyau ainsi que des programmes et bibliothèques nécessaires (la glibc sous GNU/Linux, par exemple). |
GNU/Linux | Système d’exploitation libre utilisant le noyau Linux, souvent appelé (à tord) Linux, cette appellation étant fausse, car Linux est seulement un noyau. |
Espace noyau (kernel-land) | Mémoire privilégiée où se trouve le noyau, ses possibles extensions (modules) et les pilotes. |
Espace utilisateur (user-land) | Par opposition à l’espace noyau, c’est la mémoire virtuelle utilisée par les processus de l’utilisateur (par exemple, l’environnement graphique). À noter que ce qu’on appelle « utilisateur » n’est pas forcément un être humain, par exemple, un processus démon de systemd s’exécute dans l’espace utilisateur. |
Instruction | Opération élémentaire que peut réaliser un processeur, par exemple les additions, soustractions, portes logiques, etc. Un processeur dispose de plusieurs instructions, regroupées sous le terme de jeu d’instructions, mais il n’en possède qu’un seul, qui est déterminé par son architecture. Par exemple, un processeur basé sur l’architecture ARMv8 (64 bits) aura moins d’instructions qu’un processeur x86-64. |
Modes de fonctionnement | Différents modes dans lesquels un CPU peut fonctionner. Ces modes découlent directement des anneaux de protection (ring) et de leur implémentation matérielle dans le processeur, mais la plupart du temps, seulement deux modes sont utilisés : le mode privilégié et le mode utilisateur. Concrètement, certaines instructions nécessitent le mode privilégié pour être exécutées (en particulier celles qui pourraient modifier l’état global de la machine), certaines zones de mémoire ne sont pas accessibles, etc. |
Interruption | Pause temporaire dans l’exécution d’un programme permettant de rendre la main au noyau. |
Descripteur de fichier | Un entier unique représentant un fichier ouvert (ou autre entrée/sortie comme une socket réseau) dans le système d’exploitation. |
Pour plus d’informations, je vous invite à explorer le blog de Hackndo, référence en cybersécurité, notamment l’introduction de l’article « Le monde du kernel ».
Première brique : les syscalls#
Un syscall ou system call ou appel système en français, est une instruction qui va provoquer une interruption quand elle est appelée par un programme, qui va permettre de demander au système d’exploitation de réaliser une tâche. C’est l’interface principale disponible pour permettre au programme de discuter avec le noyau.
Voici un petit schéma qui représente l’appel système :
Comme vu dans la définition des modes de fonctionnement (Quelques définitions), un processus se trouvant dans l’espace utilisateur ne pourra pas accéder à certaines instructions, car celles-ci nécessitent le mode privilégié. Le noyau possédant le mode privilégié, il pourra les exécuter.
Cette tâche peut varier en fonction du nombre d’implémentations intégrées dans le système d’exploitation. Pour les curieux, voici la liste de syscall disponibles sur l’architecture x64 (le site possède aussi les jeux de syscalls en x86, arm et arm64).
Petit exemple#
Utilisation de la libc#
Dans un premier temps, nous allons utiliser la glibc
, aussi appelé libc
, qui est la bibliothèque standard du C sous GNU/Linux (GNU C Library). Cette bibliothèque va implémenter les opérations les plus courantes, par exemple la gestion des chaines de caractères (string.h) ou, dans le cas qui nous intéresse, les syscalls. En effet, pour réaliser un syscall, un programme va devoir configurer des registres bien spécifiques (selon l’architecture du processeur) et ensuite réaliser l’instruction d’appel système.
Le code suivant va tout simplement écrire « Hello world » dans le terminal et faire un retour à la ligne. Pour cela, nous allons importer unistd.h
de la libc qui va nous offrir la fonction write
. Cette fonction est donc un wrapper (une enveloppe) du syscall du même nom : write. Ce syscall va permettre d’écrire depuis une mémoire tampon (ici un tableau de caractères) dans un descripteur de fichier.
#include <unistd.h> // unistd.h va fournir la fonction « write »
void main() {
char message[] = "Hello world\n"; // tableau de caractères
size_t message_len = 12; // taille du message
write(1, &message, message_len); // appel du syscall write
}
Pour les curieux, voici la signature de la fonction write :
ssize_t write(int fildes, const void *buf, size_t nbyte);
Appel système en assembleur ARMv8#
Pour aller un peu plus loin, voici le code en assembleur qui va faire exactement la même chose que le code précédent. C’est globalement ce que va faire le wrapper de la libc.
En ARMv8, pour réaliser le syscall write, il va falloir configurer les registres suivants :
Les registres utilisés pour faire un syscall en ARMv8 sont
x8
pour l’identifiant du syscall et dex0
àx5
pour les arguments du syscall.
Registre | Fonction |
---|---|
x8 | Va contenir l’identifiant du syscall, ici 64 pour write. |
x0 (arg0) | Va contenir le descripteur de fichier, ici 1 pour la sortie standard. |
x1 (arg1) | Va contenir l’adresse mémoire de notre chaine de caractères. |
x2 (arg2) | Va contenir la taille de notre chaine de caractères. |
1.global _start
2.section .text
3
4_start:
5 mov x8, #64 // x8: Numéro de l'appel système write
6 mov x0, #1 // x0: Descripteur de fichier de stdout
7 ldr x1, =str_hello // x1: Chargement de l'adresse de "str_hello"
8 mov x2, #12 // x2: Taille de "str_hello"
9 svc 0 // Appel système
10
11 // Ignorez les instructions ci-dessous
12 mov x8, #93 // x8: Numéro de l'appel système exit
13 mov x0, #0 // x0: Code d'erreur 0
14 svc 0 // Appel système
15
16.section .data
17 str_hello: .ascii "Hello world\n"
Ce qui va donner, après compilation :
# Compilation du code assembleur
nonames@archlinux:~$ as main.s -o main.o
# Linkage du code compilé précédemment
nonames@archlinux:~$ gcc main.o -o main.elf -nostdlib
# Exécution du binaire
nonames@archlinux:~$ ./main.elf
> Hello world
On comprend donc bien l’intérêt des wrappers de la libc, qui vont nous mâcher le travail de ce côté-là (et donc d’avoir des abstractions).
Conclusion#
Les syscalls vont donc nous permettre de réaliser des opérations essentielles que tout programme veut et va vouloir faire, c’est pourquoi comprendre leur fonctionnement est essentiel.
Nous allons nous arrêter ici afin que l’article de soit pas trop indigeste. L’article suivant parlera de la prochaine brique du C, les pointeurs.
Si vous trouvez la moindre coquille dans l’article, n’hésitez pas à me le faire savoir dans l’espace commentaires ci-dessous.