Avant main() : PLongée dans le C runtime.
Avant main() : Plongée dans le C Runtime
La fonction main() est communément présentée comme le point d’entrée des programmes C. Pourtant, lors de l’exécution de ./prog, le système d’exploitation ne transfère pas directement le contrôle à main(). Une infrastructure fournie par le compilateur et le runtime C s’exécute en amont.
Cet article examine la chaîne d’appels réelle menant à main(), explique l’origine de cette convention de nommage, et démontre comment définir un point d’entrée personnalisé.
Le rôle du compilateur
Prenons ce code source très simple :
1
2
3
4
5
6
7
#include <stdio.h>
int main()
{
printf("Hello, World!\n");
return 0;
}
Lorsque nous compilons ce programme via gcc main.c -o prog, Le compilateur traduit notre code source C en code machine. En plus de cela, il ajoute d’autres chose que nous allons décortiquer par la suite.
Une fois compilé et exécuté, on a bien ceci :
1
2
./prog
Hello, World!
Maintenant imaginons que nous changions int main() par int new_entry()
1
2
3
4
gcc main.c -o prog
/usr/bin/ld: /usr/lib/gcc/x86_64-pc-linux-gnu/15.2.1/../../../../lib/crt1.o: in function `_start':
(.text+0x1b): undefined reference to `main'
collect2: error: ld returned 1 exit status
On apprend donc qu’à la compilation, une fonction _start est lié à notre programme et cherche la fonction main().
Du coup, Pourquoi il cherche main()? C’est quoi cette fonction _start? Est-il possible de changer cela ?
Enquête dans le debugger
Regardons le resultat de la compilation par GDB.
Voici les différents symlboles :
1
2
3
4
5
6
7
8
pwndbg> info functions
Non-debugging symbols:
0x0000000000401000 _init
0x0000000000401030 puts@plt
0x0000000000401040 _start
0x0000000000401126 main
0x0000000000401140 _fini
Nous pouvons voir qu’il n’y a pas que main() point de vu machine. Ce qui nous intéresse se trouve dans _start.
Voici les partie importantes de _start:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pwndbg> disass _start
Dump of assembler code for function _start:
•••
# Placement des arguments de commande en paramètre d'un prochain appel de fonction
0x0000000000401046 <+6>: mov r9,rdx
0x0000000000401049 <+9>: pop rsi
0x000000000040104a <+10>: mov rdx,rsp
•••
•••
# Placement de l'addresse de main en paramètre du prochain apel de fonction
0x0000000000401058 <+24>: mov rdi,0x401126
# Appel de la fonction à rip+0x2f63
0x000000000040105f <+31>: call QWORD PTR [rip+0x2f63] # 0x403fc8
•••
Si nous placons un breakpoint à 0x000000000040105f puis que nous lançons le programme, nous allons voir ce qui est appelé.
1
2
3
4
5
0x40105f <_start+31> call qword ptr [rip + 0x2f63] <__libc_start_main>
rdi: 0x401126 (main) ◂— push rbp
rsi: 1
rdx: 0x7fffffffdbb8 —▸ 0x7fffffffdf55 ◂— '/tmp/test/prog'
•••
_start() appel donc __libc_start_main avec pour argument :
L’adresse de
main()Le nombre d’argument (ici 1, le nom du programme)
le pointeur vers la liste des arguments (argv[])
S’il y avait deux arguments, ce serait comme ceci :
appel ./prog test
1
2
rsi: 2
rdx: 0x7fffffffdba8 —▸ 0x7fffffffdf50 ◂— '/tmp/test/prog'
l’argument 1 est donc le nom du programme. Pour être sure que le second soit bien “test” nous pouvons l’afficher comme ceci :
1
2
3
pwndbg> x/2s 0x7fffffffdf50
0x7fffffffdf50: "/tmp/test/prog"
0x7fffffffdf5f: "test"
Ensuite, qu’est-ce qu’il se passe dans __libc_start_main ?
Nous pouvons voir que c’est ce programme qui va appeler _init & _fini :
1
call_init(argc, argv, __environ);
Ce sont des fonctions qui permettent d’exécuter les constructeur et deconstructeur respectivement avant et après l’exécution de main().
Après avoir fait _init(), il appele donc main():
1
__libc_start_call_main(main, argc, argv MAIN_AUXVEC_PARAM);
Donc pour résumé : _start -> __libc_start_call_main -> main
CRT0
Aussi, au moment du lien entre notre programme et _start, l’erreur nous à montré ce fichier : /usr/lib/gcc/x86_64-pc-linux-gnu/15.2.1/../../../../lib/crt1.o.
Il s’agit en fait du programme _start. Voici une version simplifié que l’on peut faire :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SECTION .text
global _start
_start:
; Obtenir argc, argv, envp de la pile
mov rdi, [rsp]
lea rsi, [rsp + 8]
lea rdx, [rsi + rdi * 8 + 8]
; Appeler main(argc, argv, envp)
extern main
call main
; Appel systeme pour quitter le programme
mov rdi, rax
mov rax, 60
syscall
Nous compilons notre CRT1.o et le lions à notre programme.
1
2
3
4
5
6
nasm -f elf64 crt1.s -o crt1.o
gcc -nostartfiles crt1.o main.c -o prog
./prog
Hello, World!
Maintenant vérifions que c’est bien notre _start.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pwndbg> info functions
All defined functions:
Non-debugging symbols:
0x0000000000001010 puts@plt
0x0000000000001020 _start
0x000000000000103d main
pwndbg> disass _start
Dump of assembler code for function _start:
0x0000000000001020 <+0>: mov rdi,QWORD PTR [rsp]
0x0000000000001024 <+4>: lea rsi,[rsp+0x8]
0x0000000000001029 <+9>: lea rdx,[rsi+rdi*8+0x8]
0x000000000000102e <+14>: call 0x103d <main>
0x0000000000001033 <+19>: mov rdi,rax
0x0000000000001036 <+22>: mov eax,0x3c
0x000000000000103b <+27>: syscall
C’est bien notre _start. Il nous reste plus qu’a vérifier que c’est bien ce fichier qui est responsable de l’appel strict à main().
1
2
3
; Appler main(argc, argv, envp)
extern new_entry
call new_entry
On remplace dans notre CRT1.o main par new_entry.
1
2
3
4
5
6
nasm -f elf64 crt1.s -o crt1.o
gcc -nostartfiles crt1.o main.c -o prog
./prog
Hello, World!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> info functions
Non-debugging symbols:
0x0000000000001010 puts@plt
0x0000000000001020 _start
0x000000000000103d new_entry
pwndbg> disass _start
Dump of assembler code for function _start:
0x0000000000001020 <+0>: mov rdi,QWORD PTR [rsp]
0x0000000000001024 <+4>: lea rsi,[rsp+0x8]
0x0000000000001029 <+9>: lea rdx,[rsi+rdi*8+0x8]
0x000000000000102e <+14>: call 0x103d <new_entry>
0x0000000000001033 <+19>: mov rdi,rax
0x0000000000001036 <+22>: mov eax,0x3c
0x000000000000103b <+27>: syscall
Nous avons donc compris pourquoi main() est le point d’entré d’un code C et comment faire en sorte que ce soit autre chose !
Conclusion
_start(), fournie par le fichier crt1.o et automatiquement liée par le compilateur, récupère les arguments du programme depuis la pile (argc, argv, envp) et les transmet à __libc_start_main(). Cette dernière initialise l’environnement d’exécution, appelle les constructeurs globaux via _init(), puis invoque enfin notre main(). Au retour, elle appelle _fini() pour nettoyer avant de terminer le programme.
Le nom main() n’est donc pas magique : c’est une simple convention du runtime C. En créant notre propre _start(), nous pouvons utiliser n’importe quel nom pour notre point d’entrée !