Assembleur ARM sur Android 2/3: Deux modèles de programmation
par Benoit, 2014-03-26

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.

  1. -o hello_world – Notre programme final se nommera hello_world.
  2. -L$NDKPLAT/usr/lib – Liaison entre outils et plate-forme.
  3. -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.
  4. $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.
  5. $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:

  1. 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.
  2. 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.

Commentaires