Pour qui désire apprendre la programmation en langage d’assemblage, la plate-forme ARM est une option particulièrement recommandable: cette architecture RISC est relativement simple, la plate-forme est facile d’accès du fait de son vif succès commercial et l’outillage nécessaire à la mise en oeuvre des programmes est mature et bien documenté. Cette série d’articles montre comment on peut utiliser divers éléments de la plate-forme Android pour s’exercer à diverses approches de programmation en langage d’assemblage ARM, facilement et sans aucun coût (sinon un peu de téléchargement), à partir d’une machine Linux.
Dans cette deuxième partie de trois (partie 1), je présente les outils de développement pour l’assemblage d’un code source ARM en un programme exécutable. J’illustre l’usage de ces outils en me basant sur deux modèles de programmation: l’un qui se base sur les librairies de l’environnement d’exécution du langage C, l’autre qui roule sans aucune dépendance, sur la base de conventions minimales de chargement.
La source de tous les programmes présentés ici peut être accédée en clonant ce
dépôt Git. Le Makefile
inclut avec ce dépôt
compile tous les programmes inclus en invoquant simplement la commande
make
Bref rappel: la compilation d’un programme C
Afin de mieux comprendre la suite de cet article, jetons d’abord un coup
d’oeil sur un programme C élémentaire, que nous allons ensuite réimplanter en
langage d’assemblage ARM. Ce petit rappel a le mérite de nous faire visiter un
peu les éléments importants du NDK. Le programme en question est un bon vieux
Hello world assorti de la représentation de quelques entiers. Plaçons dans
un fichier nommé hello_world.c
le code suivant:
#include <stdio.h>
int main(int argc, char** argv)
{
printf("Hello world! %x %x %x %x %x\n", 0x1, 0x22, 0x333, 0x4444, 0x55555);
}
Pour les lecteurs moins familiers avec C, ce programme est censé afficher le
message Hello world!
suivi des cinq entiers 1, 34, 819, 17476 et 349525 en
base hexadécimale. Pour le compiler, plaçons nous dans un terminal et entrons
d’abord:
$ export NDK=/opt/android/ndk
$ export NDKPLAT=$NDK/platforms/android-5/arch-arm
$ export NDKTOOL=$NDK/toolchains/arm-linux-androideabi-4.8/prebuilt/linux-x86_64/arm-linux-androideabi/bin
Ceci nous prépare à utiliser les outils de compilation croisée du NDK plus
aisément. Nous utiliserons dans cet article le compilateur C gcc
, ainsi que
l’assembleur ARM as
et l’éditeur de liens, ld
. Donc, compilons
hello_world.c
en code objet:
$ $NDKTOOL/gcc -c -I$NDKPLAT/usr/include -o hello_world.o hello_world.c
Il n’est pas usuel qu’on doive indiquer au compilateur C où se trouvent les fichiers standards. Cela est dû au fait que le NDK rassemble, d’une part, plusieurs versions des outils de compilation croisée et, d’autre part, plusieurs versions de la plate-forme Android de base, c’est-à-dire l’ensemble des librairies formant l’environnement d’exécution des programmes et des fichiers d’en-tête permettant d’accéder aux outils de ces librairies. Normalement, ce sont les scripts de compilation du SDK Android qui font le lien entre les outils et la plate-forme. Comme nous nous passons ici de ces scripts, il faut faire ce lien nous-mêmes.
Maintenant que nous avons compilé le programme, nous avons son code objet,
c’est-à-dire la traduction en langage binaire ARM du programme, dans le
fichier hello_world.o
. Il ne s’agit cependant pas d’un exécutable: pour
obtenir ceci, il faut inscrire le code objet dans l’environnement d’exécution
C à l’aide de code permettant le chargement des librairies dynamiques qui
procurent cet environnement, comme libc.so
(ainsi que libm.so
, libgcc.a
et ainsi de suite). De plus, le NDK ne fournit pas par défaut le code qui
donne au programme C l’accès à la console (entrée et sortie standard): il faut
pour cela encadrer notre code objet du prélude et de l’épilogue d’un
programme de console. Pour faire tout cela, on utilise l’éditeur de liens:
$ $NDKTOOL/ld -o hello_world -L$NDKPLAT/usr/lib -dynamic-linker=/system/bin/linker $NDKPLAT/usr/lib/crtbegin_dynamic.o hello_world.o $NDKPLAT/usr/lib/crtend_android.o -lc
Décortiquons un peu cette énorme commande.
-o hello_world
– Notre programme final se nommerahello_world
.-L$NDKPLAT/usr/lib
– Liaison entre outils et plate-forme.-dynamic-linker=/system/bin/linker
– Comme il s’agit d’une compilation croisée (la compilation a pour cible Android/ARM, l’hôte où elle a lieu est un système GNU-Linux/Intel), il faut préciser où le programme pourra trouver l’éditeur de liens dynamiques sur la plate-forme cible. L’éditeur de liens dynamique est le programme qui permet le chargement des librairies dynamiques de l’environnement d’exécution (libc.so
etc.) dans l’espace mémoire du programme.$NDKPLAT/usr/lib/crtbegin_dynamic.o
– Prélude d’un programme de console qui repose sur les librairies dynamiques du système cible pour composer son environnement d’exécution.$NDKPLAT/usr/lib/crtend_android.o
– Épilogue d’un programme de console sur Android.
Nous sommes enfin prêts à exécuter le programme!
$ adb push hello_world /data/local/tmp
$ adb shell /data/local/tmp/hello_world
Hello world! 1 22 33 4444 55555
Alternativement, on peut exécuter le programme de la manière suivante (après
l’avoir téléversé via adb push
):
$ adb shell
root@generic:/ # cd /data/local/tmp
root@generic:/ # ./hello_world
Hello world! 1 22 33 4444 55555
Cette brève (!) étude de la chaîne classique de compilation d’un programme C nous sera très utile pour comprendre comment adapter le processus pour une source en langage d’assemblage ARM.
La Cadillac: assemblage contre libc
L’approche la plus facile pour programmer en langage d’assemblage est
d’inscrire notre programme dans l’environnement d’exécution commun aux
programmes écrits en C. Comme nous le verrons, cela nous contraint à une
utilisation un peu plus complexe des outils, mais nous procure les riches
routines de base sur lesquels on s’appuie lorsqu’on programme en C.
En ce sens, similairement au programme C codé ci-haut, notre programme doit
comporter un symbole nommé main
qui se comportera comme une fonction.
Conventions d’appel de fonction
À la base, un processeur ARM comporte 16 registres, dont au moins trois ont un
usage spécifique (R12
est parfois utilisé comme pointeur de cadre sur la pile;
R13
, alias SP
, est le pointeur de pile; R14
, alias LR
, pointe vers
l’adresse de retour de la fonction appelante; R15
, alias PC
, pointe sur la
prochaine instruction à exécuter). On peut donc compter sur 12 registres pour
tout faire: variables locales et paramètres d’entrée et de sortie aux
fonctions. Clairement, il faut établir quelques règles pour éviter qu’une
fonction ruine les valeurs de travail utilisées par une autre qui l’appelle.
La convention typiquement mise en oeuvre sur la plate-forme ARM tient sur deux principes:
R0
àR3
sont utilisés pour passer les paramètres en entrée et en sortie d’une procédure: ils ne sont pas protégés.R4
àR14
, sont utilisés pour mettre en oeuvre les résultats intermédiaires locaux d’un calcul: ils sont protégés.
Le premier principe implique que si on veut appeler une procédure, on lui
passe ses quatre premiers paramètres en entrée via les registres R0
à R3
.
Si la procédure prend plus de quatre paramètres en entrée, on doit allouer de
la place sur la pile pour les paramètres subséquents: le 4e paramètre est
la valeur pointée par SP
, le 5e, la valeur pointée par SP
+4, et ainsi de
suite. L’exemple ci-bas illustre l’allocation de l’espace nécessaire sur la
pile pour deux paramètres par la décrémentation de SP
de 8 octets. Ce
premier principe indique par ailleurs qu’une routine invoquée n’est pas
responsable de protéger la valeur des registres R0
à R3
: au retour de la
routine, ces registres sont indéfinis. La seule exception est si la routine
renvoit un résultat: il est alors contenu dans R0
. Par conséquent, même dans
ce cas, la valeur de R0
n’a rien à voir avec celle fixée avant l’invocation.
Le deuxième principe implique qu’à l’opposé, une routine appelée doit
préserver les registres R4
à R14
, qui contiennent les variables locales
de l’appelant. Si la routine ré-écrit ces registres, elle doit d’abord en
sauvegarder la valeur sur la pile, puis restaurer cette sauvegarde lorsqu’elle
se termine. Sur la plate-forme ARM, ces opérations de sauvegarde et de
restauration sont aisément mises en oeuvre à l’aide des instructions de
stockage (PUSH
, STMFD
…) et de chargement multiples (POP
, LDMIA
…).
Par exemple, l’instruction
push {r4, r5, lr}
sauvegarde sur la pile les registres R4
et R5
, en plus de l’adresse de
retour de la fonction, après quoi SP
est décrémenté de 12 octets. La pile
ressemble alors à
SP+8 lr
SP+4 r5
SP r4
Son instruction opposée
pop {r4, r5, pc}
charge la valeur pointée par SP
dans R4
, SP+4
dans R5
et SP+8
dans
PC
, de ce fait branchant à l’adresse de retour de la fonction.
Hello world, version libc
Tenant compte de cette convention pour l’invocation d’une fonction,
ré-écrivons le programme C ci-haut en langage d’assemblage ARM. Nous lierons
ce programme dans l’environnement d’exécution C. Parcourons le fichier
src/hello_world_libc.s
issu du dépôt Git concerné
par cet article:
.text
.align 4
.arm
Cet en-tête assure que le code sera interprété comme du langage d’assemblage ARM (par opposition à sa variante Thumb) et sera dûment aligné sur une frontière de mot de 32 bits.
.global main
main:
Ah, voici la fonction main
, le point d’entrée de ce programme.
push {r4, r5, lr}
On sauvegarde d’abord les registres de résultats intermédiaires que nous
utiliserons. Dans le cas de LR
, nous l’utilisons lorsque nous invoquons une
fonction via l’instruction BL
(Branch and Link) ou BLX
(Branch, Link
and eXchange). LR
est alors utilisé pour stocker l’adresse de l’instruction
qui suit celle du branchement. Une routine qui n’invoque pas de fonction à son
tour peut omettre la sauvegarde de LR
; elle peut revenir à l’adresse pointée
par LR
par l’instruction BX LR
.
ldr r0, =template
On désire invoquer la routine printf
. Son premier paramètre est une chaîne
de caractères utilisée comme gabarit de formattage. Elle est située dans la
section .data
, dont on la ramène dans R0
via un chargement relatif à PC
,
calculé automatiquement par l’assembleur.
mov r1, #1
mov r2, #0x22
Les registres R1
à R3
contiendront les trois premiers nombres à formatter
via printf
. Dans le cas de R1
et R2
, les valeurs sont suffisamment
petites pour être mises en places par arithmétique immédiate. En d’autres
termes, la valeur mise dans le registre fait partie de l’instruction
arithmétique qui implante le mnémonique MOV
.
ldr r4, =numbers
ldr r3, [r4]
Les valeurs supérieures à 255 ne tiennent pas comme opérande arithmétique
immédiate. Il est possible de les mettre en place par une séquence
d’opérations arithmétique, mais il est encore plus simple de stocker de telles
valeur comme données en mémoire (section .data
) et de les charger.
sub sp, #8
Les deux derniers entiers à formatter via printf
doivent être passés par la
pile. Chacun de ces entiers tient sur un mot, donc on alloue 8 octets sur la
pile. Rappel: la pile descend. Elle commence à une adresse haute en espace
mémoire et on y alloue de l’espace en soustrayant cet espace au pointeur du
sommet de la pile (SP
, alias R13
). Réciproquement, on désalloue cet espace
en l’ajoutant à SP
.
ldr r5, [r4, #4]
str r5, [sp]
ldr r5, [r4, #8]
str r5, [sp, #4]
Ces opérations copient les derniers entiers à formatter de la section .data
à l’espace alloué sur la pile.
bl printf
Enfin!
add sp, #8
Ayant complété l’appel à printf
, il faut désallouer l’espace réservé sur la
pile.
mov r0, #0
pop {r4,r5,pc}
La fonction main
a terminé son travail. L’environnement d’exécution C
s’attend à ce que cette fonction renvoit un code signifiant que l’exécution du
programme soit réussie (code 0) ou qu’une erreur soit survenue (code distinct
de zéro indiquant la nature de l’erreur). Comme tout baigne, on retourne zéro
et on restaure les valeurs de R4
et R5
précédemment sauvegardées, en plus
de brancher à l’adresse de retour en fixant la valeur de PC
. Il ne manque
plus que la section de données:
.data
template:
.string "Hello world! %x %x %x %x %x\n"
numbers:
.word 0x333, 0x4444, 0x55555
Ici, nous sommes chanceux: la longueur de la chaîne de gabarit est un multiple
de 4, de sorte que les entiers qui suivent sont stockés à une adresse dûment
alignée avec une frontière de mot. Sans cela, le chargement des entiers à
l’adresse numbers
pourrait causer une faute d’alignement propre à planter le
programme.
Construire le programme
La construction du programme exécutable est presque pareille à celle du programme analogue en C, à ceci près qu’au lieu de compiler le code C, on assemble le code ARM.
$ $NDKTOOL/as -g -o hello_world_libc.o hello_world_libc.s
Le commutateur -g
exige que l’assembleur insère de l’information de
déboguage dans le code objet. Nous verrons dans le troisième article de cette
série comment tirer profit de cette information. Ensuite, on réalise l’édition
des liens
$ $NDKTOOL/ld -o hello_world_libc -L$NDKPLAT/usr/lib -dynamic-linker=/system/bin/linker $NDKPLAT/usr/lib/crtbegin_dynamic.o hello_world_libc.o $NDKPLAT/usr/lib/crtend_android.o -lc
Il ne reste qu’à téléverser et exécuter sur notre émulateur, via ADB:
$ adb push hello_world_libc /data/local/tmp
$ adb shell /data/local/tmp/hello_world_libc
Hello world! 1 22 33 4444 55555
Succès!
Le hot-rod: assemblage sans librairie
L’exemple précédent suggère que beaucoup de machinerie est nécessaire pour passer d’une source à un exécutable. En plus de l’assembleur et de l’éditeur de liens, il faut des librairies, un éditeur de liens dynamiques, un prélude, un épilogue… Est-ce nécessairement si compliqué? Non.
Encore des conventions
Pour la simplicité de cette discussion, tenons-nous en au côté
usager du système d’exploitation (par opposition au
noyau). D’une part, lorsqu’on lance l’exécution d’un programme, tout ce dont
le système a besoin, c’est de savoir à quelle adresse du code chargé en
mémoire commencer l’exécution. Dans le cas du noyau Linux sur plate-forme ARM,
la convention est de lancer le programme à l’adresse du symbole _start
. Ce
symbole était procuré par l’environnement d’exécution C dans l’exemple
précédent. Hors de cet environnement, il suffit de définir nous-mêmes ce
symbole. Lorsque l’exécution commence à _start
, on peut retrouver les
paramètres passés en ligne de commande sur la pile.
D’autre part, durant son exécution, un programme peut se servir des registres
et de la mémoire pour faire des calculs. S’il a besoin de quoi que ce soit
d’autre, en particulier d’entrée/sortie, il utilise les abstractions et les
services du noyau du système d’exploitation. L’interface vers ces services est
une instruction spéciale, SVC 0
(ou SWI 0
, c’est pareil), qui invoque un
appel système. Chaque appel système procure un certain service au programme:
ouvrir, fermer, lire ou écrire un fichier, terminer le programme, et ainsi de
suite. Les différents appels système se distinguent par un numéro, un entier
sur 16 bits, qu’on passe au noyau via le registre R7
. La liste des appels
système disponibles peut être consultée en jetant un coup d’oeil au fichier
/opt/android/ndk/platforms/android-5/arch-arm/usr/include/asm/unistd.h
La plupart des appels système sont emballés dans une routine POSIX documentée
via les man pages. On peut donc consulter
cette documentation pour bien comprendre la sémantique de chaque appel
système. En outre, chaque appel
système prend jusqu’à sept paramètres en entrée via les registres R0
à R6
;
la valeur de retour de l’appel est via le registre R0
. Les registres R4
à R6
sont protégés.
Hello world, version métal
Comme nous allons voir, laisser de côté les librairies nous force à implanter
un grand nombre de routines que nous prenions pour acquis. En particulier, il
faut implanter le formattage de la chaîne à écrire, ainsi que la conversion
d’un entier en sa représentation sur la base hexadécimale. De plus, il faut
s’assurer de terminer correctement le programme, sans quoi l’exécution passe la fin
de la routine principale (qui commence au symbole _start
) jusque dans la
première routine qui la suit, laquelle rebranche vers la routine principale…
menant à une boucle infinie. Voici donc le fichier hello_world_baremetal.s
.
.text
.align 4
.arm
.global _start
_start:
bl write_hello_world
Jusqu’ici, tout est plutôt simple. On commence par écrire le début de la ligne “Hello world!”.
ldr r4, =numbers
mov r5, #0
number_loop:
ldr r0, [r4, r5, lsl #2]
bl write_space_and_number
add r5, #1
cmp r5, #5
blt number_loop
bl write_newline
Ici, nous avons cinq nombres à écrire en base hexadécimale. L’écriture d’un
tel nombre précédé d’un espace est assurée par la routine
write_space_and_number
. On ne cherche pas à charger les plus petits nombres
par adressage immédiat, ici: nous irons les chercher tous en mémoire. Une fois
les nombres tous affichés, nous écrivons un caractère de retour.
/* Exit. */
mov r0, #0
mov r7, #1
svc 0
Pour éviter la boucle infinie évoquée ci-haut, on doit terminer le programme en invoquant l’appel système exit, dont le numéro est 1. Son paramètre est le code de succès d’exécution du programme, qu’on fixe à 0 parce que tout va bien.
write_hello_world:
push {lr}
ldr r0, =hello_world
bl write
pop {pc}
write:
push {r4, lr}
mov r4, r0
bl strlen
mov r2, r0
mov r1, r4
mov r0, #1
mov r7, #4
svc 0
pop {r4, pc}
Toutes les entrées/sorties sur Linux (et, plus généralement, POSIX) sont exécutées contre un fichier, identifié à l’aide d’un nombre entier, le descripteur de fichier (file descriptor). Le programmeur Linux/POSIX apprend rapidement que tout programme a au moins trois tels descripteurs de fichiers prêts à l’usage dès le lancement d’un programme: 0 (entrée standard), 1 (sortie standard) et 2 (erreur standard). Les autres fichiers, files et sockets ouverts par le programme prendront des identificateurs supérieurs.
Pour écrire une chaîne sur la console, on l’écrit dans le fichier
correspondant à la sortie standard à l’aide de l’appel système write (4).
Donc, la routine write
ci-haut place dans R0
le descripteur de fichier 1,
en R1
l’adresse de la chaîne à écrire et en R2
, sa longueur exacte –
c’est plus compliqué qu’utiliser printf
!
Notons que ces deux routines illustrent que j’adhère ici à la convention de passage des paramètres et de protection des registres entre routines. Je n’y suis pas obligé, mais c’est tout simplement plus sensé.
strlen:
mov r1, r0
mov r0, #0
strlen_loop:
ldrb r2, [r1, r0]
cmp r2, #0
bxeq lr
add r0, #1
b strlen_loop
Cette routine strlen
calcule la taille d’une chaîne de caractères. Comme
cette routine n’en appelle aucune autre et n’utilise aucun registre protégé,
je ne prends pas la peine de sauvegarder et de restaurer. Je reviens à
l’appelant via l’instruction BX LR
(ici BXEQ LR
, un branchement
conditionnel).
write_space_and_number:
push {r4, lr}
sub sp, #9
mov r1, sp
bl atox
mov r4, r1
ldr r0, =space
bl write
mov r0, r4
bl write
add sp, #9
pop {r4, pc}
La routine write_space_and_number
a pour mission d’inscrire un espace sur la
sortie standard, puis la représentation en base hexadécimale de l’entier passé
via R0
. J’alloue l’espace pour cette représentation sur la pile: comme il
s’agit d’un entier sur 32 bits, sa représentation hexadécimale ne peut prendre
plus de 8 caractères, plus le caractère nul de terminaison de la chaîne
(important, car on écrit à l’aide de la routine write
ci-haut, qui calcule
la longueur de la chaîne à l’aide de strlen
).
J’utilise pour la conversion
la routine atox
ci-dessous, dont la convention d’appel s’écarte du standard.
Cette routine renvoit en R1
le début de la chaîne contenant la
représentation, puisque le calcul est plus facile à faire en remplissant la
chaîne de droite à gauche.
atox:
mov r3, #0
strb r3, [r1, #8]
mov r3, #8
La routine atox
commence par inscrire le caractère nul de terminaison à la
fin de la chaîne passée via R1
. On se lance ensuite dans le traitement de
l’entier à convertir (passé via R0
), en traitant séparément chaque groupe de
4 bits (correspondant respectivement à chaque chiffre hexadécimal). R3
est
employé comme index d’écriture dans la chaîne.
atox_loop:
sub r3, #1
and r2, r0, #0xf
cmp r2, #10
bge digit_10_16
digit_0_9:
add r2, #48
b atox_store
digit_10_16:
add r2, #87
atox_store:
strb r2, [r1, r3]
À ce stade, on a en R2
le code ASCII du caractère correspondant au chiffre
hexadécimal le plus à droite du nombre, qu’on stocke dans la chaîne.
lsrs r0, #4
beq atox_end
cmp r3, #0
bgt atox_loop
On fait ici deux tests pour l’arrêt du programme, mais il est vraisemblable
que le second soit superflu. L’instruction LSRS R0, #4
décale R0
de quatre
bits à droite, fixant les bits de statut du processeur (CPSR
) selon le
résultat. Si le résultat est nul, l’instruction BEQ atox_end
qui suit
complètera l’exécution de la routine. On vérifie ensuite que l’index dans la
chaîne est supérieur à 0. Si c’est le cas, on a encore de la place à écrire
dans la chaîne et on peut poursuivre la conversion. Cependant, comme l’entier
à convertir tient sur 32 bits, il est certain que la première condition
d’arrêt sera satisfaite avant qu’on déborde de la chaîne, tant et si bien que
ce second test ne mènera jamais à l’arrêt de la boucle.
atox_end:
add r1, r3
bx lr
La valeur de retour est en R1
. J’aurais pu transférer le pointeur dans R0
et respectier la convention usuelle, mais je tenais à illustrer qu’en dehors
du cadre de libc
, on fait ce qu’on veut.
write_newline:
push {r4, lr}
ldr r0, =newline
bl write
pop {r4, pc}
.data
hello_world:
.string "Hello world!"
space:
.string " "
newline:
.string "\n"
.align 4
numbers:
.word 0x1, 0x22, 0x333, 0x4444, 0x55555
Le reste est simple à comprendre. Notons comment les constantes de chaîne de
caractères résultent en un mauvais alignement de la séquence d’entiers en
regard des frontières de mots en mémoire. Il faut restaurer cet alignement à
l’aide de la directive de l’assembleur .align 4
pour éviter les fautes
d’alignement.
Construire le programme
Oye, ce programme est beaucoup plus complexe que celui mis en oeuvre dans l’environnement d’exécution C! Pourquoi se taper un tel boulot? Parce que la construction du programme est beaucoup plus simple:
$ $NDKTOOL/as -g -o hello_world_baremetal.o hello_world_baremetal.s
$ $NDKTOOL/ld -o hello_world_baremetal hello_world_baremetal.o
L’assemblage est le même, mais l’édition des liens est réduite à la plus
simple invocation de ld
. Exécutons:
$ adb push hello_world_baremetal /data/local/tmp
$ adb shell /data/local/tmp/hello_world_baremetal
Hello world! 1 22 33 4444 55555
Cette approche de programmation est d’usage lorsqu’on veut réaliser un
programme statique, qui n’utilise aucune librairie standard du côté usager.
Il faut ré-écrire soi-même les outils les plus communs et emballer dans nos
propres routines les appels système, mais le programmeur est complètement
libre de toute convention en dehors de commencer à _start
. Ce modèle de
programmation permet d’explorer et d’expérimenter plus facilement sur des
sujets avancés comme l’injection de code: plug-ins, modules, édition des
liens dynamiques, conventions avancées de gestion des registres, compilation
juste à temps…
Une conclusion bien méritée
Cet article a permis d’explorer l’usage des outils de compilation du NDK pour la mise en oeuvre de programmes implantés selon deux modèles distincts: un programme inscrit dans l’environnement d’exécution C ou un programme implanté directement au métal, contre les services du noyau. La simplicité de programmation du premier exige une meilleure maîtrise des outils de compilation. Le second modèle est considérablement plus compliqué, mais offre une souplesse maximale pour l’exploration de concepts et d’idées plus exotiques.
Il reste un outil qui mérite d’être présenté d’une manière un peu plus poussée: le débogueur GDB. Ce sera l’objet du troisième et dernier article de cette série, où nous étudierons un exemple plus avancé d’une application client-serveur.