Du chargeur de démarrage au noyauSi vous lisez les
articles précédents, vous connaissez mon nouveau passe-temps pour la programmation de bas niveau. J'ai écrit plusieurs articles sur la programmation des assembleurs pour Linux
x86_64
et en même temps j'ai commencé à plonger dans le code source du noyau Linux.
Je suis très intéressé de comprendre comment les choses de bas niveau fonctionnent: comment les programmes s'exécutent sur mon ordinateur, comment ils sont situés en mémoire, comment le noyau gère les processus et la mémoire, comment la pile réseau fonctionne à un bas niveau, et bien plus encore. J'ai donc décidé d'écrire une autre série d'articles sur le noyau Linux pour l'
architecture x86_64 .
Veuillez noter que je ne suis pas un développeur de noyau professionnel et que je n'écris pas de code de noyau au travail. C'est juste un hobby. J'aime juste les choses de bas niveau et c'est intéressant de s'y plonger. Par conséquent, si vous constatez une confusion ou si des questions / commentaires apparaissent, contactez-moi
sur Twitter , par
mail ou créez simplement un
ticket . Je vous en serais reconnaissant.
Tous les articles sont publiés dans
le référentiel GitHub , et si quelque chose ne va pas avec mon anglais ou le contenu de l'article, n'hésitez pas à envoyer une pull request.
Veuillez noter qu'il ne s'agit pas d'une documentation officielle, mais simplement d'une formation et d'un partage de connaissances.Connaissances requises- Comprendre le code C
- Comprendre le code assembleur (syntaxe AT&T)
Dans tous les cas, si vous commencez tout juste à apprendre de tels outils, je vais essayer d'expliquer quelque chose dans cet article et les suivants. D'accord, avec l'introduction terminée, il est temps de plonger dans le noyau Linux et les choses de bas niveau.
J'ai commencé à écrire ce livre à l'époque du noyau Linux 3.18, et beaucoup de choses ont changé depuis lors. S'il y a des changements, je mettrai à jour les articles en conséquence.
Bouton d'alimentation magique, quelle est la prochaine étape?
Bien qu'il s'agisse d'articles sur le noyau Linux, nous ne l'avons pas encore atteint - du moins dans cette section. Dès que vous appuyez sur le bouton d'alimentation magique de votre ordinateur portable ou de bureau, il commence à fonctionner. La carte mère envoie un signal à l'
alimentation . Après avoir reçu le signal, il fournit à l'ordinateur la quantité d'électricité nécessaire. Dès que la carte mère reçoit un
signal "Power OK" , elle essaie de démarrer le CPU. Il vide toutes les données restantes dans ses registres et définit des valeurs prédéfinies pour chacun d'eux.
Les processeurs
80386 et versions ultérieures doivent avoir les valeurs suivantes dans les registres du CPU après un redémarrage:
IP 0xfff0
Sélecteur CS 0xf000
CS base 0xffff0000
Le processeur commence à fonctionner en
mode réel . Revenons un peu en arrière et essayons de comprendre
la segmentation de la mémoire dans ce mode. Le mode réel est pris en charge sur tous les processeurs compatibles x86: du
8086 aux processeurs Intel 64 bits modernes. Le processeur 8086 utilise un bus d'adresse 20 bits, c'est-à-dire qu'il peut fonctionner avec un espace d'
0-0xFFFFF
de
0-0xFFFFF
ou
1
. Mais il n'a que des registres 16 bits avec une adresse maximale de
2^16-1
ou
0xffff
(64 kilo-octets).
La segmentation de la mémoire est nécessaire pour utiliser tout l'espace d'adressage disponible. Toute la mémoire est divisée en petits segments d'une taille fixe de
65536
octets (64 Ko). Étant donné qu'avec les registres 16 bits, nous ne pouvons pas accéder à la mémoire supérieure à 64 Ko, une méthode alternative a été développée.
L'adresse se compose de deux parties: 1) un sélecteur de segment avec une adresse de base; 2) décalage par rapport à l'adresse de base. En mode réel, l'adresse de base du
* 16
segment
* 16
. Ainsi, pour obtenir l'adresse physique en mémoire, vous devez multiplier une partie du sélecteur de segment par 16 et y ajouter le décalage:
= * 16 +
Par exemple, si le registre
CS:IP
a la valeur
0x2000:0x0010
, alors l'adresse physique correspondante sera comme ceci:
>>> hex((0x2000 << 4) + 0x0010) '0x20010'
Mais si vous prenez le sélecteur du segment le plus grand et le décalage
0xffff:0xffff
, vous obtenez l'adresse:
>>> hex((0xffff << 4) + 0xffff) '0x10ffef'
c'est-à-dire
65520
octets après le premier mégaoctet. Étant donné qu'un seul mégaoctet est disponible en mode réel,
0x10ffef
devient
0x00ffef
avec
la ligne A20 désactivée.
Eh bien, nous en savons maintenant un peu plus sur le mode réel et l'adressage de la mémoire dans ce mode. Revenons à la discussion des valeurs de registre après réinitialisation.
Le registre
CS
compose de deux parties: un sélecteur de segment visible et une adresse de base cachée. Bien que l'adresse de base soit généralement formée en multipliant la valeur du sélecteur de segment par 16, lors d'une réinitialisation matérielle, le sélecteur de segment dans le registre CS est
0xf000
et l'adresse de base est
0xffff0000
. Le processeur utilise cette adresse de base spéciale jusqu'à ce que le CS change.
L'adresse de départ est formée en ajoutant l'adresse de base à la valeur dans le registre EIP:
>>> 0xffff0000 + 0xfff0 '0xfffffff0'
Nous obtenons
0xfffffff0
, soit 16 octets en dessous de 4 Go. Ce point est appelé
vecteur de réinitialisation . Il s'agit de l'emplacement en mémoire où le CPU attend que la première instruction s'exécute après une réinitialisation: une opération de saut (
jmp ), qui indique généralement le point d'entrée du BIOS. Par exemple, si vous regardez le code source de
coreboot (
src/cpu/x86/16bit/reset16.inc
), nous verrons:
.section ".reset", "ax", %progbits .code16 .globl _start _start: .byte 0xe9 .int _start16bit - ( . + 2 ) ...
Ici, nous voyons le code d'opération (
opcode )
jmp
, à savoir
0xe9
, et l'adresse de destination
_start16bit - ( . + 2)
.
Nous voyons également que la section de
reset
est de 16 octets, et elle se compile pour s'exécuter à partir de l'adresse
0xfffff0
(
src/cpu/x86/16bit/reset16.ld
):
SECTIONS { _bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report."); _ROMTOP = 0xfffffff0; . = _ROMTOP; .reset . : { *(.reset); . = 15; BYTE(0x00); } }
Le BIOS démarre maintenant; Après avoir initialisé et vérifié le matériel du BIOS, vous devez trouver le périphérique de démarrage. L'ordre de démarrage est enregistré dans la configuration du BIOS. Lorsque vous essayez de démarrer à partir du disque dur, le BIOS essaie de trouver le secteur de démarrage. Sur les disques
partitionnés MBR , le secteur de démarrage est stocké dans les 446 premiers octets du premier secteur, où chaque secteur fait 512 octets. Les deux derniers octets du premier secteur sont
0x55
et
0xaa
. Ils montrent au BIOS qu'il s'agit d'un périphérique de démarrage.
Par exemple:
; ; : Intel x86 ; [BITS 16] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa
Nous collectons et gérons:
nasm -f bin boot.nasm && qemu-system-x86_64 boot
QEMU reçoit une commande pour utiliser le binaire de
boot
que nous venons de créer en tant qu'image disque. Étant donné que le fichier binaire généré ci-dessus satisfait aux exigences du secteur de démarrage (commençant à
0x7c00
et se terminant par une séquence magique), QEMU considérera le binaire comme l'enregistrement de démarrage principal (MBR) de l'image disque.
Vous verrez:

Dans cet exemple, nous voyons que le code s'exécute en mode réel 16 bits et commence à l'adresse
0x7c00
en mémoire. Après le démarrage, il provoque une interruption
0x10 , qui imprime simplement un caractère
!
; remplit les 510 octets restants par des zéros et se termine par deux octets magiques
0xaa
et
0x55
.
Vous pouvez voir le vidage binaire avec l'utilitaire
objdump
:
nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot
Bien sûr, dans le vrai secteur de démarrage, il y a du code pour continuer le processus de démarrage et une table de partition au lieu d'un tas de zéros et d'un point d'exclamation :). À partir de ce moment, le BIOS transfère le contrôle au chargeur de démarrage.
Remarque : comme expliqué ci-dessus, le CPU est en mode réel; où le calcul de l'adresse physique en mémoire est le suivant:
= * 16 +
Nous n'avons que des registres à usage général 16 bits, et la valeur maximale du registre 16 bits est
0xffff
, donc aux valeurs les plus élevées, le résultat sera:
>>> hex((0xffff * 16) + 0xffff) '0x10ffef'
où
0x10ffef
est
1 + 64 - 16
. Le
processeur 8086 (le premier processeur en mode réel) possède une ligne d'adresse de 20 bits. Puisque
2^20 = 1048576
, la mémoire disponible réelle est de 1 Mo.
En général, l'adressage de la mémoire en mode réel est le suivant:
0x00000000 - 0x000003FF - table des vecteurs d'interruption du mode réel
0x00000400 - 0x000004FF - Zone de données du BIOS
0x00000500 - 0x00007BFF - non utilisé
0x00007C00 - 0x00007DFF - notre chargeur de démarrage
0x00007E00 - 0x0009FFFF - non utilisé
0x000A0000 - 0x000BFFFF - RAM vidéo (VRAM)
0x000B0000 - 0x000B7777 - mémoire vidéo monochrome
0x000B8000 - 0x000BFFFF - mémoire vidéo en mode couleur
0x000C0000 - 0x000C7FFF - BIOS de la ROM vidéo
0x000C8000 - 0x000EFFFF - zone d'ombre (ombre du BIOS)
0x000F0000 - 0x000FFFFF - BIOS système
Au début de l'article, il est écrit que la première instruction pour le processeur se trouve à
0xFFFFFFF0
, ce qui est bien plus que
0xFFFFF
(1 Mo). Comment le CPU peut-il accéder à cette adresse en mode réel? Réponse dans la documentation de
coreboot :
0xFFFE_0000 - 0xFFFF_FFFF: 128 ROM
Au début de l'exécution, le BIOS n'est pas en RAM, mais en ROM.
Bootloader
Le noyau Linux peut être chargé avec différents chargeurs de démarrage, tels que
GRUB 2 et
syslinux . Le noyau possède un protocole de démarrage qui définit les exigences du chargeur de démarrage pour implémenter la prise en charge Linux. Dans cet exemple, nous travaillons avec GRUB 2.
En poursuivant le processus de démarrage, le BIOS a sélectionné le périphérique de démarrage et transféré le contrôle au secteur de démarrage, l'exécution commence par
boot.img . En raison de sa taille limitée, il s'agit d'un code très simple. Il contient un pointeur pour accéder à l'image principale de GRUB 2. Il commence par
diskboot.img et est généralement stocké immédiatement après le premier secteur dans l'espace inutilisé avant la première partition. Le code ci-dessus charge en mémoire le reste de l'image qui contient le noyau GRUB 2 et les pilotes pour le traitement des systèmes de fichiers. Après cela, la fonction
grub_main est
exécutée .
La fonction
grub_main
initialise la console, renvoie l'adresse de base des modules, définit le périphérique racine, charge / analyse le fichier de configuration grub, charge les modules, etc. À la fin de l'exécution, il met grub en mode normal. La fonction
grub_normal_execute
(à partir du fichier source
grub-core/normal/main.c
) termine les dernières préparations et affiche un menu pour choisir le système d'exploitation. Lorsque nous sélectionnons l'un des éléments du menu grub, la fonction
grub_menu_execute_entry
est
grub_menu_execute_entry
, qui exécute la commande de
boot
grub et charge le système d'exploitation sélectionné.
Comme indiqué dans le protocole de démarrage du noyau, le chargeur de démarrage doit lire et remplir certains champs de l'en-tête d'installation du noyau, qui commence à l'offset
0x01f1
du code d'installation du noyau. Ce décalage est indiqué dans le
script de l'
éditeur de liens . L'archive d'en-tête du noyau
/ x86 / boot / header.S commence par:
.globl hdr hdr: setup_sects: .byte 0 root_flags: .word ROOT_RDONLY syssize: .long 0 ram_size: .word 0 vid_mode: .word SVGA_MODE root_dev: .word 0 boot_flag: .word 0xAA55
Le chargeur de démarrage doit remplir cet en-tête et d'autres en-têtes (qui sont marqués uniquement en tant que type
write
dans le protocole de démarrage Linux, comme dans cet exemple) avec des valeurs reçues de la ligne de commande ou calculées au démarrage. Maintenant, nous ne nous attarderons pas sur les descriptions et explications de tous les champs d'en-tête. Nous verrons plus loin comment le noyau les utilise. Pour une description de tous les champs, voir
le protocole de téléchargement .
Comme vous pouvez le voir dans le protocole de démarrage du noyau, la mémoire sera affichée comme suit:
| Mode noyau protégé |
100000 + ------------------------ +
| Mappage d'E / S |
0A0000 + ------------------------ +
| Réserve pour BIOS | Laissez autant que possible gratuitement
~ ~
| Ligne de commande | (peut également être inférieur à X + 10000)
X + 10000 + ------------------------ +
| Pile / tas | Pour utiliser du vrai code en mode noyau
X + 08000 + ------------------------ +
| Installation du noyau | Code en mode réel du noyau
| Secteur de démarrage du noyau | Secteur de démarrage du noyau hérité
X + ------------------------ +
| Chargeur | <- Secteur de démarrage du point d'entrée 0x7C00
001000 + ------------------------ +
| Réserve pour MBR / BIOS |
000800 + ------------------------ +
| Utiliser habituellement MBR |
000600 + ------------------------ +
| Utilisé BIOS uniquement |
000000 + ------------------------ +
Ainsi, lorsque le chargeur transfère le contrôle au noyau, il commence par l'adresse:
X + sizeof (KernelBootSector) + 1
où
X
est l'adresse du secteur d'amorçage du noyau. Dans notre cas,
X
est
0x10000
, comme le montre le vidage de la mémoire:

Le chargeur de démarrage a déplacé le noyau Linux en mémoire, rempli les champs d'en-tête, puis déplacé vers l'adresse mémoire correspondante. Maintenant, nous pouvons aller directement au code d'installation du noyau.
Début de la phase d'installation du noyau
Enfin, nous sommes au cœur! Bien que techniquement, il ne fonctionne pas encore. Tout d'abord, la partie d'installation du noyau doit configurer quelque chose, y compris un décompresseur et certaines choses avec la gestion de la mémoire. Après tout cela, elle déballera le vrai noyau et y ira. L'installation démarre dans
arch / x86 / boot / header.S avec le caractère
_start .
À première vue, cela peut sembler un peu étrange, car il y a plusieurs instructions devant lui. Mais il y a longtemps, le noyau Linux avait son propre chargeur de démarrage. Maintenant, si vous exécutez, par exemple,
qemu-system-x86_64 vmlinuz-3.18-generic
vous verrez:

En fait, le fichier
header.S
commence par le nombre magique
MZ (voir la capture d'écran du vidage ci-dessus), le texte du message d'erreur et l'en-tête
PE :
#ifdef CONFIG_EFI_STUB # "MZ", MS-DOS header .byte 0x4d .byte 0x5a #endif ... ... ... pe_header: .ascii "PE" .word 0
Il est nécessaire de charger un système d'exploitation avec le support
UEFI . Nous considérerons son appareil dans les chapitres suivants.
Point d'entrée réel pour l'installation du noyau:
Le chargeur de démarrage (grub2 et autres) connaît ce point (décalage
0x200
rapport à
MZ
) et y accède directement, bien que
header.S
démarre à partir de la section
.bstext
, où se trouve le texte du message d'erreur:
// // arch/x86/boot/setup.ld // . = 0; // current position .bstext : { *(.bstext) } // put .bstext section to position 0 .bsdata : { *(.bsdata) }
Point d'entrée de l'installation du noyau:
.globl _start _start: .byte 0xeb .byte start_of_setup-1f 1:
Ici, nous voyons le code d'opération
jmp
(
0xeb
), qui va au point
start_of_setup-1f
. Dans la notation
Nf
, par exemple,
2f
fait référence à l'étiquette locale
2:
Dans notre cas, il s'agit de l'étiquette
1
, qui est présente immédiatement après la transition, et qui contient le reste de l'en-tête de configuration. Immédiatement après l'en-tête d'installation, nous voyons la section
.entrytext
, qui commence par l'étiquette
start_of_setup
.
Il s'agit du premier code réellement exécuté (autre que les instructions de saut précédentes, bien sûr). Après qu'une partie de l'installation du noyau a reçu le contrôle du chargeur, la première instruction
jmp
est située à l'offset
0x200
depuis le début du mode réel du noyau, c'est-à-dire après les 512 premiers octets. Cela peut être vu à la fois dans le protocole de démarrage du noyau Linux et dans le code source grub2:
segment = grub_linux_real_target >> 4; state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20;
Dans notre cas, le noyau démarre à l'adresse
0x10000
. Cela signifie qu'après le démarrage de l'installation du noyau, les registres de segments auront les valeurs suivantes:
gs = fs = es = ds = ss = 0x10000
cs = 0x10200
Après être allé à
start_of_setup
noyau devrait faire ce qui suit:
- Assurez-vous que toutes les valeurs de registre de segment sont les mêmes
- Si nécessaire, configurez la pile correcte
- Configurer bss
- Accédez au code C dans arch / x86 / boot / main.c
Voyons comment cela est mis en œuvre.
Alignement de cas de segment
Tout d'abord, le noyau vérifie que les registres des segments
ds
et
es
pointent vers la même adresse. Il efface ensuite l'indicateur de direction à l'aide de l'
cld
:
movw %ds, %ax movw %ax, %es cld
Comme je l'ai écrit plus tôt, grub2 charge par défaut le code d'installation du noyau à
0x10000
et
cs
à
0x10200
, car l'exécution ne démarre pas au début du fichier, mais à partir de la transition ici:
_start: .byte 0xeb .byte start_of_setup-1f
Il s'agit d'un décalage de
512
octets par rapport à
4d 5a . Il est également nécessaire d'aligner
cs
de
0x10200
à
0x10000
, comme tous les autres registres de segment. Après cela, installez la pile:
pushw %ds pushw $6f lretw
Cette instruction pousse la valeur
ds
sur la pile, suivie de l'adresse de l'étiquette
6 et de l'instruction
lretw
, qui charge l'adresse de l'étiquette
6
dans le registre du
compteur de commandes et charge
cs
avec la valeur
ds
. Après cela,
ds
et
cs
auront les mêmes valeurs.
Configuration de la pile
Presque tout ce code fait partie du processus de préparation de l'environnement C en mode réel. L'étape suivante consiste à vérifier la valeur du registre
ss
et à créer la pile correcte si la valeur
ss
est incorrecte:
movw %ss, %dx cmpw %ax, %dx movw %sp, %dx je 2f
Cela peut déclencher trois scénarios différents:
ss
valeur valide de 0x1000
(comme avec tous les autres registres sauf cs
)ss
valeur non valide et l'indicateur CAN_USE_HEAP
défini (voir ci-dessous)ss
valeur non valide et l'indicateur CAN_USE_HEAP
pas défini (voir ci-dessous)
Considérez tous les scénarios dans l'ordre:
ss
valeur valide ( 0x1000
). Dans ce cas, nous allons au label 2:
2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp sti
Ici, nous définissons l'alignement du registre
dx
(qui contient la valeur
sp
indiquée par le chargeur de démarrage) sur
4
octets et vérifions zéro. S'il est nul, nous mettons la valeur
0xfffc
dx
(adresse alignée sur
4
octets avant la taille maximale de segment de 64 Ko). S'il n'est pas égal à zéro, nous continuons à utiliser la valeur
sp
spécifiée par le chargeur de démarrage (
0xf7f4
dans notre cas). Ensuite, nous mettons la valeur
ax
dans
ss
, ce qui enregistre l'adresse de segment correcte
0x1000
et définit la bonne
sp
. Maintenant, nous avons la bonne pile:

- Dans le deuxième scénario,
ss != ds
. D'abord, nous mettons la valeur _end (l'adresse de la fin du code d'installation) dans dx
et vérifions les loadflags
champ d'en-tête, en utilisant l'instruction testb
pour vérifier si le tas peut être utilisé. loadflags est un en-tête de masque de bits défini comme suit:
#define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7)
et comme indiqué dans le protocole de démarrage:
: loadflags
.
7 (): CAN_USE_HEAP
1, ,
heap_end_ptr . ,
.
Si le bit
CAN_USE_HEAP
est
CAN_USE_HEAP
, dans
dx
nous définissons la valeur
heap_end_ptr
(qui pointe vers
_end
) et y ajoutons
STACK_SIZE
(la taille minimale de la pile est de
1024
octets). Après cela, passez à l'étiquette
2
(comme dans le cas précédent) et faites la bonne pile.

- Si
CAN_USE_HEAP
pas défini, utilisez simplement la pile minimale de _end
à _end + STACK_SIZE
:

Configuration de BSS
Deux étapes supplémentaires sont nécessaires avant de passer au code C principal: il s'agit de configurer la
zone BSS et de vérifier la signature «magique». Vérification de signature d'abord:
cmpl $0x5a5aaa55, setup_sig jne setup_bad
L'instruction compare simplement
setup_sig avec le nombre magique 0x5a5aaa55. S'ils ne sont pas égaux, une erreur fatale est signalée.
Si le nombre magique correspond et que nous avons un ensemble de registres de segments corrects et une pile, alors tout ce qui reste à faire est de configurer la section BSS avant de passer au code C.
La section BSS est utilisée pour stocker des données non initialisées allouées statiquement. Linux vérifie soigneusement que cette zone mémoire est réinitialisée:
movw $__bss_start, %di movw $_end+3, %cx xorl %eax, %eax subw %di, %cx shrw $2, %cx rep; stosl
Tout d'abord, l'adresse de début de
__bss_start est déplacée vers
di
. Ensuite, l'adresse
_end + 3
(+3 pour l'alignement sur 4 octets) est déplacée vers
cx
. Le registre
eax
est effacé (à l'aide de l'instruction
xor
), la taille de la partition bss (
cx-di
) est calculée et elle est placée dans
cx
. Ensuite,
cx
est divisé en quatre (la taille du «mot») et l'instruction
stosl
est utilisée à
stosl
, stockant la valeur
(zéro) dans l'adresse pointant vers
di
, augmentant automatiquement
di
de quatre et répétant cela jusqu'à ce que
atteigne zéro). L'effet net de ce code est que les zéros sont écrits sur tous les mots en mémoire de
__bss_start
à
_end
:

Aller à la page principale
C'est tout: nous avons une pile et un BSS, vous pouvez donc aller à la fonction C
main()
:
calll main
La fonction
main()
se trouve dans
arch / x86 / boot / main.c. Nous parlerons d'elle dans la prochaine partie.
Conclusion
Ceci est la fin de la première partie sur le périphérique du noyau Linux.
Si vous avez des questions ou des suggestions, contactez-moi sur Twitter , par mail ou créez simplement un ticket . Dans la partie suivante , nous verrons le premier code en C, qui est effectuée lors de l'installation du noyau Linux, la mise en œuvre des sous-programmes de mémoire, tels que memset
, memcpy
, earlyprintk
, au début de la mise en œuvre et l' initialisation de la console, et plus encore.Les références