Démarrage du noyau Linux. Partie 1

Du chargeur de démarrage au noyau

Si 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 { /* Trigger an error if I have an unuseable start address */ _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' 

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 

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:

 // header.S line 292 .globl _start _start: 

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: // // rest of the header // 

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


Source: https://habr.com/ru/post/fr428664/


All Articles