Assembleur ARM sur Android 3/3: Déboguage à l'aide de GDB
par Benoit, 2014-04-11

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 troisième et dernière partie (partie 1, partie 2), j’offre un tutoriel sur le déboguage à distance d’un exécutable binaire avec GDB, le GNU Debugger.

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

Un serveur de majuscules et de minuscules

Afin de présenter un exemple de programmation en langage d’assemblage un peu plus intéressant que Hello world, j’ai développé une petite application client-serveur. Ce n’est vraiment qu’une preuve de concept: le serveur convertit la casse des chaînes qu’on lui soumet, rien de plus. Le serveur est implanté selon le modèle de programmation sans librairie, alors que le client s’appuie sur libc.

Le protocole de communication entre le client et le serveur est très simple: il consiste en chaînes de caractères entrecoupées du caractère de fin de ligne (\n). Par défaut, une ligne est simplement convertie en lettres majuscules et retournée au client: ce comportement par défaut peut être testé simplement à l’aide de netcat, une commande disponible sur l’hôte Linux sur lequel on travaille.

En plus de ce traitement par défaut d’une ligne, le serveur accepte trois autres commandes, qui doivent commencer par un caractère spécifique:

  • Ascii 0: fermer la connexion et terminer le serveur. Le reste de la ligne n’est pas traité.
  • Ascii 1: convertir le reste de la ligne en lettres majuscules. Ceci équivaut au comportement par défaut.
  • Ascii 2: convertir le reste de la ligne en lettres minuscules.

Dans les deux derniers cas, seule la partie de la ligne sujette à la conversion est retournée au client.

Le serveur

Le serveur est démarré en spécifiant l’adresse IP et le port sur lesquels il doit attendre une connexion. On peut utiliser l’adresse 0.0.0.0 pour écouter sur toutes les interfaces du système. Ainsi, après avoir compilé le serveur, on le met en place et on en lance l’exécution ainsi:

$ adb push bin/server /data/local/tmp
43 KB/s (3524 bytes in 0.079s)
$ adb shell
root@generic:/ # /data/local/tmp/server 0.0.0.0 9887

Mon serveur attend patiemment une connexion client sur le port 9887. Testons à l’aide de Netcat, soit la commande nc. Comme mon émulateur n’a pas sa propre adresse IP, faisons en sorte de faire suivre les connexions TCP au port local 9887 vers le même port local de l’émulateur, de sorte qu’une connexion à l’hôte même corresponde à une connexion sur l’émulateur. Cela est réalisé grâce à ADB.

$ adb forward tcp:9887 tcp:9887
$ nc localhost 9887
asdf
ASDF
QwErty
QWERTY
<CTRL+D>

Les chaînes en majuscules sont les réponses du serveur; j’ai tapé les autres au clavier. La touche CTRL+D ferme le fichier en entrée de Netcat, ce qui met un terme à la connexion au serveur.

Le client

La fonctionnalité complète du serveur est accessible via le client. Ce dernier facilite l’accès au protocole en traduisant les mnémoniques #quit, #upper et #lower en les caractères ASCII appropriés du protocole. Il fait aussi en sorte de sauter les blancs entre la commande et le texte qui y est associé. Ce client doit aussi rouler sur l’émulateur. On lui passe l’adresse IP et le port où le serveur écoute:

$ adb push bin/client /data/local/tmp
60 KB/s (6396 bytes in 0.102s)
$ adb shell
root@generic:/ # /data/local/tmp/client 127.0.0.1 9887
Connected!
> 

Le > est l’invite du client, qui montre les réponses du serveur précédées d’un tiret. Envoyons quelques commandes au serveur:

> asdf
- ASDF
> #upper QwertY
- QWERTY
> #lower QwertY
- qwerty
> #quit
root@generic:/ #

On remarque que cette dernière commande a fait en sorte que le serveur termine son exécution. Pour quitter une session client sans tuer le serveur, on peut simplement taper CTRL+D, comme c’était le cas pour Netcat.

Déboguage à distance avec GDB

Soyons à vouloir déboguer un de nos programmes ARM. Lorsqu’un programme machine se trouve sur la machine où on roule le débogueur, on démarre simplement le débogueur contre le programme. Par contre, si le programme à déboguer est sur une machine distincte, il faut le démarrer sous un serveur de déboguage. En plus de s’attacher à notre programme, ce serveur écoute sur une interface réseau (i.e. adresse IP et port). On démarre ensuite le débogueur sur sa machine de développement et on déclare vouloir déboguer le programme à l’interface spécifiée par le serveur. Une fois cette séquence initiée, le déboguage s’exécute comme si on travaillait localement.

Avant de poursuivre, je mentionne que cet article n’est pas un tutoriel de GDB en tant que tel. Je cherche à démontrer son usage pour déboguer un programme ARM à distance, mais je ne prendrai pas la peine de détailler toutes les commandes de GDB que j’exécute: on se concentre sur le déboguage.

Un bogue dans le traitement de #lower

Pour fins de cette démonstration, retrouvons la branche buggy du dépôt Git, recompilons et redéployons le serveur et le client. Depuis ma copie locale du dépôt:

$ git fetch http://benoithamelin.com/arm_client_server.git buggy:buggy
From http://benoithamelin.com/arm_client_server
 * [new branch]      buggy      -> buggy
$ git checkout buggy
Switched to branch 'buggy'
$ make
/opt/android/ndk/toolchains/arm-linux-androideabi-4.8/prebuilt/linux-x86_64/arm-linux-androideabi/bin/as -g -o bin/client.o src/client.s
/opt/android/ndk/toolchains/arm-linux-androideabi-4.8/prebuilt/linux-x86_64/arm-linux-androideabi/bin/ld -o bin/client -L/opt/android/ndk/platforms/android-5/arch-arm/usr/lib -dynamic-linker=/system/bin/linker /opt/android/ndk/platforms/android-5/arch-arm/usr/lib/crtbegin_dynamic.o bin/client.o /opt/android/ndk/platforms/android-5/arch-arm/usr/lib/crtend_android.o -lc
$ adb push bin/client /data/local/tmp
139 KB/s (6372 bytes in 0.044s)
$ adb push bin/server /data/local/tmp
72 KB/s (3524 bytes in 0.047s)

Lançons le serveur, puis demandons-lui de convertir une chaîne en lettres minuscules:

$ adb shell
root@generic:/ # cd /data/local/tmp
root@generic:/data/local/tmp # ./server 0.0.0.0 9887

Dans un autre terminal, on lance le client et on lui fait passer notre requête:

$ adb shell
root@generic:/ # cd /data/local/tmp
root@generic:/data/local/tmp # ./client 127.0.0.1 9887
Connected!
> #lower AsdFqwerTY
- AASDFQWERT
>

Ça n’est absolument pas la réponse attendue. Qu’arrive-t-il si on envoie une requête par défaut?

> zxcv
-
xcv
> qwerty
- ZXCV
y
> #quit
root@generic:/data/local/tmp #

Donc, les requêtes de conversion donnent des réponses étrangement farfelues, mais la requête de fermeture du serveur fonctionne (ce qu’on peut constater en jetant un coup d’oeil au terminal du serveur).

On débogue le serveur

Lançons le serveur à nouveau, mais sous l’égide du débogueur, afin d’étudier comment ce programme échange les données. Dans le terminal du serveur, je lance:

root@generic:/data/local/tmp # gdbserver 127.0.0.1:4444 ./server 0.0.0.0 9887
Process ./server created; pid = 1028
Listening on port 4444

J’ouvre un troisième terminal, où je vais exécuter le déboguage.

$ adb forward tcp:4444 tcp:4444
$ /opt/android/ndk/toolchains/arm-linux-androideabi-4.8/prebuilt/linux-x86_64/bin/arm-linux-androideabi-gdb
GNU gdb (GDB) 7.3.1-gg2
Copyright (C) 2011 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later
<http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "--host=x86_64-linux-gnu
--target=arm-linux-android".
For bug reporting instructions, please see:
<http://source.android.com/source/report-bugs.html>.
(gdb) 

Comme on étudie le code du serveur, il faut le charger dans le débogueur. Notez que dans ce troisième terminal où je débogue, je m’étais d’abord placé dans ma copie locale du dépôt Git.

(gdb) file bin/server
Reading symbols from /home/hamelin/Desktop/arm_client_server/bin/server...done.
(gdb) target remote localhost:4444
Remote debugging using localhost:4444
_start () at src/server.s:12
12          bl      parse_cmd_line
(gdb)

L’exécution du serveur est donc suspendue sur la première ligne du programme. L’échange des données avec le client se produit dans la routine process_conn, aussi vais-je y poser un point d’arrêt, puis lancer le programme.

(gdb) break process_conn
Breakpoint 1 at 0x8234: file src/server.s, line 175.
(gdb) c
Continuing.

Je me rends sur le terminal du client, puis je le lance et j’envoie une requête de conversion en minuscules.

root@generic:/data/local/tmp # ./client 127.0.0.1 9887
Connected!
> #lower AsdFqwerTY

Dès que le client s’est connecté, le débogueur a attrapé mon point d’arrêt et m’a donné le contrôle. Je vais tracer le traitement de la connexion de manière à comprendre ce qui se passe lors du traitement de la commande 2, mentionnant les hypothèses derrière mon programme au fur et à mesure.

Breakpoint 1, process_conn () at src/server.s:175
175         mov     r4, r0
(gdb) info registers
r0             0x5      5
r1             0x9410   37904
r2             0x9420   37920
r3             0xa      10
r4             0x4      4
r5             0x5      5
r6             0x0      0
r7             0x11d    285
r8             0x0      0
r9             0x0      0
r10            0x0      0
r11            0x0      0
r12            0x0      0
sp             0xbee0fad0       0xbee0fad0
lr             0x80b4   32948
pc             0x8234   0x8234 <process_conn+4>
cpsr           0x20000010       536870928
(gdb)

À ce moment, le registre R0 contient le description de fichier (FD) correspondant à la connexion avec le client (socket accepté). Il demeurera sauvegardé dans R4.

(gdb) si
176         mov     r6, #1
(gdb) si
177         lsl     r6, #12
(gdb) si
178         sub     sp, r6
(gdb) si
connection_loop () at src/server.s:182
182         mov     r0, r4
(gdb)

Je viens d’allouer un tampon de lecture de 4096 octets sur la pile.

182         mov     r0, r4
(gdb) si
183         mov     r1, sp
(gdb) si
184         mov     r2, #1
(gdb) si
185         mov     r7, #3
(gdb) si
186         svc     0
(gdb) si
187         cmp     r0, #1
(gdb) si
188         blt     connection_over_carry_on
(gdb) si
191         ldrb    r0, [sp]
(gdb)

J’ai utilisé l’appel système #3 pour lire un unique caractère dans mon tampon (pointé par SP). Ce caractère correspond au numéro de la commande à exécutér, ou il s’agit du premier caractère du texte à convertir selon la commande par défaut. Si je n’avais pas pu lire ce caractère, j’aurais abandonné la connexion.

191         ldrb    r0, [sp]
(gdb) si
192         cmp     r0, #32
(gdb) si
193         blt     process_command
(gdb) info registers
r0             0x41     65
r1             0xbee0ead0       3202411216
r2             0x1      1
r3             0xa      10
r4             0x5      5
r5             0x5      5
r6             0x1000   4096
r7             0x3      3
r8             0x0      0
r9             0x0      0
r10            0x0      0
r11            0x0      0
r12            0x0      0
sp             0xbee0ead0       0xbee0ead0
lr             0x80b4   32948
pc             0x8268   0x8268 <connection_loop+36>
cpsr           0x20000010       536870928
(gdb)

Je retrouve dans R0 le caractère lu. Je jette un coup d’oeil à sa valeur avant de brancher à process_command: au lieu de la valeur 2 à laquelle je m’attendais, j’ai la valeur 65. Cela correspond au code ASCII de la lettre A majuscule… Ainsi, le serveur ne traitera pas ma commande de convertir en lettres minuscules, mais exécutera plutôt le traitement par défaut (soit la conversion en majuscules). C’est donc le client qui ne respecte pas le protocole dont j’avais l’intention. Abandonnons donc le serveur et reprenons plutôt avec le déboguage du client.

(gdb) kill
Kill the program being debugged? (y or n) y
(gdb)

On voit que l’exécution du serveur (asservie à gdbserver) est terminée. Quittons similairement le client (touche Ctrl+D).

On débogue le client

Reprenons d’abord l’exécution du serveur, cette fois sans débogueur:

root@generic:/data/local/tmp # ./server 0.0.0.0 9887

Lançons alors le client sous la tutelle de gdbserver:

root@generic:/data/local/tmp # gdbserver localhost:4444 ./client 127.0.0.1 9887
Process ./client created; pid = 1039
Listening on port 4444

Depuis le (troisième) terminal où on roule gdb, il faut maintenant charger le programme du client à la place de celui du serveur et se préparer à l’exécution.

(gdb) file bin/client
Load new symbol table from
"/home/hamelin/Desktop/arm_client_server/bin/client"? (y or n) y
Reading symbols from /home/hamelin/Desktop/arm_client_server/bin/client...done.
Error in re-setting breakpoint 1: Function "process_conn" not defined.
(gdb)

Oups! On a encore des points d’arrêt qui n’ont de sens que dans le serveur… Faisons-en table rase.

(gdb) delete
Delete all breakpoints? (y or n) y
(gdb) target remote localhost:4444
Remote debugging using localhost:4444
warning: Unable to find dynamic linker breakpoint function.
GDB will retry eventurally.  Meanwhile, it is likely
that GDB is unable to debug shared library initializers
or resolve pending breakpoints after dlopen().
0xb6f0ea40 in ?? ()
(gdb)

Bon, gdb est un peu mélangé quant à où on se trouve dans le binaire, mais suggérons-lui un point d’arrêt et lançons l’exécution du client malgré tout. Le traitement des entrées après l’établissement de la connexion au serveur se fait sous l’étiquette prompt_loop.

(gdb) break prompt_loop
Cannot access memory at address 0x0
Breakpoint 2 at 0x85e0: file src/client.s, line 50.
(gdb) c
Continuing.
warning: Could not load shared library symbols for 2 libraries, e.g.  /system/bin/linker.
Use the "info sharedlibrary" command to see the complete listing.
Do you need "set solib-search-path" or "set sysroot"?

Breakpoint 2, prompt_loop () at src/client.s:50
50          bl       printf
(gdb)

GDB continue ses fanfaronnades, mais il s’est arrêté au bon endroit; un coup d’oeil au terminal du client suggère que nous sommes effectivement connecté et que la ligne 50 où on se trouve va faire afficher l’invite. Traçons donc le code à partir d’ici.

(gdb) ni
51          ldr      r0, =buffer
(gdb) ni
52          bl       gets
(gdb) ni

Exactement comme prévu. On passe au terminal du client pour entrer notre commande #lower.

Connected!
> #lower AsdFqwerTY

Cela nous rend l’invite dans le terminal où roule gdb.

53          cmp      r0, #0
(gdb) si
54          beq      done
(gdb) si
56          ldr      r5, =buffer
(gdb) si
57          ldrb     r1, [r5]
(gdb) si
58          cmp      r1, #'#'
(gdb) si
59          bne      exchange_data
(gdb) si
60          add      r5, #1
(gdb)

Le client reconnaît donc correctement qu’on a entré une commande et qu’il ne faut pas simplement passer notre texte au serveur. Nous allons maintenant analyser de quelle commande il s’agit.

(gdb) si
62          ldr      r0, =cmd_quit
(gdb) si
63          mov      r1, r5
(gdb) si
64          mov      r2, #4
(gdb) si
65          bl       strncmp
(gdb) ni
66          beq      do_quit
(gdb) info registers
r0             0x5      5
r1             0x98f1   39153
r2             0x4      4
r3             0x0      0
r4             0x4      4
r5             0x98f1   39153
r6             0x3      3
r7             0xb6ef8fd8       3069153240
r8             0x8560   34144
r9             0x0      0
r10            0x0      0
r11            0xbed2baec       3201481452
r12            0x9a3c   39484
sp             0xbed2bab0       0xbed2bab0
lr             0x8618   34328
pc             0x8618   0x8618 <prompt_loop+60>
cpsr           0x20000010       536870928
(gdb)

La valeur retournée par strncmp est 5, donc pas d’égalité entre la chaîne tapée (stockée dans un tampon pointé par R5) et “quit”. On poursuit.

(gdb) si
68          ldr      r0, =cmd_upper
(gdb) si
69          mov      r1, r5
(gdb) si
70          mov      r2, #5
(gdb) si
71          bl       strncmp
(gdb) ni
72          beq      do_upper
(gdb) si
74          ldr      r0, =cmd_lower
(gdb) si
75          mov      r1, r5
(gdb) si
76          mov      r2, #5
(gdb) si
77          bl       strncmp
(gdb) ni
78          beq      do_lower
(gdb) info registers
r0             0x0      0
r1             0x98f1   39153
r2             0x5      5
r3             0x5      5
r4             0x4      4
r5             0x98f1   39153
r6             0x3      3
r7             0xb6ef8fd8       3069153240
r8             0x8560   34144
r9             0x0      0
r10            0x0      0
r11            0xbed2baec       3201481452
r12            0x9a3c   39484
sp             0xbed2bab0       0xbed2bab0
lr             0x8640   34368
pc             0x8640   0x8640 <prompt_loop+100>
cpsr           0x60000010       1610612752
(gdb)

Voilà! On reconnaît donc correctement la commande “#lower”. Voyons comment on fait passer cette commande au serveur.

(gdb) si
do_lower () at src/client.s:96
96          mov      r1, #2
(gdb)

On garde ici en R1 le numéro de la commande #lower, soit 2.

(gdb) si
97          mov      r2, #5
(gdb)

On place en R2 la longueur de la commande, qu’on veut rejeter de la chaîne transmise au serveur.

(gdb) si
98          b        do_upper_or_lower
(gdb) si
do_upper_or_lower () at src/client.s:101
101         add      r5, r2  /* Skip command. */
(gdb) si
102         mov      r0, r5
(gdb)

Après avoir rejeté la commande (ligne 101), on se prépare à rejeter les blancs qui séparent la commande du texte en invocant la routine skip_whitespace; son seul paramètre est la chaîne de laquelle on veut rejeter les blancs.

(gdb) si
102         mov      r0, r5
(gdb) si
103         bl       skip_whitespace
(gdb) ni
104         mov      r5, r0
(gdb) info registers
r0             0x98f7   39159
r1             0x41     65
r2             0x5      5
r3             0x5      5
r4             0x4      4
r5             0x98f6   39158
r6             0x3      3
r7             0xb6ef8fd8       3069153240
r8             0x8560   34144
r9             0x0      0
r10            0x0      0
r11            0xbed2baec       3201481452
r12            0x9a3c   39484
sp             0xbed2bab0       0xbed2bab0
lr             0x8684   34436
pc             0x8684   0x8684 <do_upper_or_lower+12>
cpsr           0x20000010       536870928
(gdb)

Holà! Il est vraisemblable que skip_whitespace ait fait un bon travail en ce qui concerne le rejet des blancs, mais cette routine a aussi ruiné la valeur de R1, qui devait contenir l’index de la commande à envoyer au serveur. Poursuivons pour bien voir la chaîne qui sera transmise au serveur.

(gdb) si
105         mov      r0, r4
(gdb) si
106         bl       send_command
(gdb) si
send_command () at src/client.s:184
184         push     {lr}
(gdb) si
185         sub      sp, #1
(gdb) si
186         strb     r1, [sp]
(gdb) si
187         mov      r1, sp
(gdb) si
188         mov      r2, #1
(gdb) si
189         mov      r3, #0
(gdb) si
190         bl       send
(gdb) info registers
r0             0x4      4
r1             0xbed2baab       3201481387
r2             0x1      1
r3             0x0      0
r4             0x4      4
r5             0x98f7   39159
r6             0x3      3
r7             0xb6ef8fd8       3069153240
r8             0x8560   34144
r9             0x0      0
r10            0x0      0
r11            0xbed2baec       3201481452
r12            0x9a3c   39484
sp             0xbed2baab       0xbed2baab
lr             0x8690   34448
pc             0x8798   0x8798 <send_command+24>
cpsr           0x20000010       536870928
(gdb) x/c 0xbed2baab
0xbed2baab:     65 'A'
(gdb)

Ah, voilà: le caractère envoyé au serveur pour dénoter la commande n’est pas ASCII 2 comme désiré, mais ASCII 65. C’est pourquoi le serveur interprétera la commande comme du texte. Par ailleurs, le client s’attendra à recevoir une réponse de 10 caractères, alors que le serveur aura traité une commande par défaut de 11 caractères, d’où la désynchronisation étrange observée entre les commandes et réponses subséquentes.

On résout simplement ce bogue en sauvegardant la valeur de R1 avant l’appel de skip_whitespace, qu’on restaure au retour. La version sans erreur sur la branche master du dépôt utilise R7 pour cette sauvegarde. Il faut alors sauvegarder et restaurer ce registre dans le push et le pop qui encadrent la routine main dont l’étiquette do_upper_or_lower fait partie.

Terminons notre séance de déboguage:

(gdb) kill
Kill the program being debugged? (y or n) y
(gdb) quit
$

On force l’interruption du serveur en lui envoyant la touche Ctrl+C.

Conclusion

Ceci complète ce tour d’horizon des outils pour s’exercer à la programmation ARM. Le système Android procure à cette fin un environnement d’exercice assez familier (Linux) pour permettre de programmer des choses intéressantes. En même temps, la distance entre l’hôte et l’émulateur démontre adéquatement le type de difficultés auxquelles font face les développeurs de systèmes embarqués.

Commentaires