Fiat lux : le jour 0 du noyau

Introduction

Ce tutoriel présente et explique la mise en place de l’environnement d’exécution minimal pour du code C, qui est nécessaire pour programmer en real mode et donc pour entrer dans le main du setup code (dont la responsabilité finale est de décompresser le noyau).
Nous travaillons sur la dernière version stable du kernel (la 4.2) et, donc, dans le dossier /arch/x86/boot/.

Il s’agit donc, pour l’essentiel :

  • d’initialiser le segment de mémoire où le setup code sera exécuté.
  • de fournir un sous-ensemble des fonctions de la libc.

Initialiser la mémoire

Le linker script setup.ld, certaines opérations réalisées dans header.S, ainsi que les fonctions manipulant la pile et/ou le tas, nous renseignent sur la manière dont la mémoire sera utilisée par le C de la setup part… Et donc sur la manière dont il convient de l’initialiser. Considérons le schéma ci-dessous :

_____________  <- 0xffff
|////////////|

______________ <- <= 0xfffc, positionner %esp
|            | ^
| stack      | |
|            | |
|            | | STACK_SIZE
|            | |
|            | |
|            | v 
|____________| <- stack_end, à calculer
|            | <- heap_end, à calculer
| heap       | <- heap_end_ptr
|            |
|            |
|            |
|            |
|            |
|____________| <- _end <= 0x8000

______________ <- __bss_end
|            | |
| bss        | | à nettoyer
|            | |
|____________| <- __bss_start
| signature  | | simple constante "magique"
|____________| <- setup_sig
|            |
| data       |
|            |
|____________|
|            | <- video_cards_end
| videocards |
|            |
|____________| <- video_cards
|            |
| rodata     |
|            |
|____________|
| text       | |
|            | | le setup code lui-même
|            | |
|____________| <- aligner %cs, %es, %ss, %ds

______________ 
|////////////|

______________ <- __end_init <= 0x1000
|            | |
| inittext   | | fonctions et données
| initdata   | | disponibles
|____________| | pendant le setup code
|            | 
| entrytext  | | header.S/start_of_setup
|____________|
|            | 
| header     | | header.S/_start
|____________| <- 495
|            |
| bootloader |
| code/data  |
|____________| <- 0

Bien sûr, il n’est pas nécessaire de manipuler toutes ces zones de mémoire : un certain nombre de sections, comme videocards, servent simplement à réserver de l’espace pour des opérations ultérieures. Les seules opérations indispensables avant de sauter au main de la setup part sont les suivantes :

  • positionner les registres de segment %cs, %es et %ds
  • positionner le registre de segment %ss et le registre %esp, c’est-à-dire installer la pile.
  • vérifier la signature du segment de mémoire.
  • positionner à zéro l’ensemble des mots de la section bss.

Notons qu’à ce moment, le processeur est en real mode et ne peut manipuler des segments de mémoire adressés sur plus de 16 bits : tout le nécessaire devra donc se trouver entre les offsets 0x0 et 0xfffff, c’est à dire sur 1 MiB.

Notons également qu’on ne se préoccupe pas d’installer le tas dans cette phase (c’est une opération qui appartient au main du setup code).

Initialiser les registres de segment

Dans l’architecture x86, la mémoire est divisée en segments (programme, données, pile…), vers lesquels pointent des registres qui permettent d’y accéder… Mais sous Linux, toutes ces sections sont entassées dans le même segment. Il ne s’agira donc que d’aligner les registres de segment.
Dans le real mode du processeur, nous disposons de quatre registres de segment :

  • %cs (code segment), qui pointe vers les intructions du programme.
  • %ds (data segment) qui pointe vers les données statiques du programme.
  • %ss (stack segment), qui pointe vers la pile du programme.
  • %es (extra segment), qui est un pointeur d’usage général.

Le registre %ss sera positionné au moment d’initialiser la pile.
Pour les 3 autres, on présuppose que le bootloader a déjà positionné %ds sur l’adresse a laquelle le setup code a également été chargé.

Au moment d’exécuter start_of_setup dans le fichier header.S, on réalise donc les opérations suivantes.
D’abord, on aligne %es sur %ds :

    movw    %ds, %ax
    movw    %ax, %es
    cld

On a également besoin d’aligner %cs sur %es et %ds, car pour préparer l’exécution de header.S, le bootloader a positionné %cs sur le label _start, à l’adresse 0x1200 (offset 512). Cette opération intervient un peu plus tard dans le code, une fois que la pile est prête :

    pushw    %ds
    pushw    $6f
    lretw
6:

L’instruction lretw permet de changer %cs (positionné à %ds) tout en contrôlant le program counter (positionné sur le label 6, immédiatement consécutif).

Initialiser la pile

Mais évidemment, il faut avoir une pile. Son fonctionnement est simple : stack_end pointe sur le dernier mot allouable de la pile, %esp pointe sur le dernier élément placé sur la pile. Lorsqu’on push, %esp se rapproche de stack_end, lorsqu’on pop, ils s’éloignent. Si %esp = stack_end, c’est l’overflow.

|            |
| pushed     |
| words      |
|            | 
|------------| <- %esp // ↑ pop, ↓ push
|////////////|
|/free space/|
|////////////| <- stack_end

On a donc surtout besoin de positionner %esp sur la fin de la pile. On commence par vérifier que le bootloader a bien donné des valeurs initiales correctes à %ss et %sp (la version primitive de %esp sur laquelle on travaille pour le moment). Si c’est le cas, %ss pointe sur la même adresse que %ds (on charge %sp dans %dx et on saute au label 2), sinon, on doit d’abord calculer une valeur correcte pour %dx.

La vérification :

    movw    %ss, %dx
    cmpw    %ax, %dx    # %ds == %ss?
    movw    %sp, %dx
    je    2f # -> assume %sp is reasonably set

Le cas échéant, on calcule donc %ds. Si le pointeur du tas est disponible, la fin de pile sera positionnée à heap_end_ptr + STACK_SIZE, sinon, elle se contentera de STACK_SIZE :

    # Invalid %ss, make up a new stack
    movw    $_end, %dx
    testb    $CAN_USE_HEAP, loadflags
    jz    1f
    movw    heap_end_ptr, %dx
1:    addw    $STACK_SIZE, %dx
    jnc 2f
    xorw    %dx, %dx    # Prevent wraparound

Pour finir, on vérifie l’alignement de l’adresse contenue dans %dx (s’il est incorrect, on le positionne sur 0xfffc), on aligne %ss sur les autres registres de segment, et on caste %dx en long pour le stocker dans %esp (qu’on utilisera à la place de %sp).

2:    # Now %dx should point to the end of our stack space
    andw    $~3, %dx   # dword align (might as well...)
    jnz 3f
    movw    $0xfffc, %dx   # Make sure we're not zero
3:    movw    %ax, %ss
    movzwl  %dx, %esp   # Clear upper half of %esp
    sti

Vérifier la signature du segment de mémoire

On vérifie que la section de la signature contient bien la constante 0x5a5aaa55, de manière à s’assurer que la mémoire n’a pas été écrasée de l’extérieur. Si c’est le cas, l’erreur est définitive, on interrompt le setup code.

    # Check signature at end of setup
    cmpl    $0x5a5aaa55, setup_sig
    jne    setup_bad

Initialiser la section BSS

La section BSS sert à stocker les variables globales non-initialisées du C. On écrit tous les mots qu’elle contient à zéro en utilisant un xor itératif, partant de __bss_start et finissant à _end :

    movw    $__bss_start, %di
    movw    $_end+3, %cx
    xorl    %eax, %eax
    subw    %di, %cx
    shrw    $2, %cx
    rep; stosl

Équipés d’une pile et d’une section bss initialisée, la grande aventure peut commencer :

    calll    main

Bonus : mettre le tas en place

Le tas ne peut pas être mis en place immédiatement. En effet, il y a besoin :

  • Que le C minimal soit déjà utilisable.
  • Que le main du setup code ait déjà récupéré les paramètres qui lui sont transmis par le bootloader, et singulièrement, donc, le booléen indiquant si on est autorisé ou non à avoir un tas.

Néanmoins, cette phase n’a rien de sorcier.

static void init_heap(void)
{
    char *stack_end;

    if (boot_params.hdr.loadflags & CAN_USE_HEAP) {
        asm("leal %P1(%%esp),%0"
            : "=r" (stack_end) : "i" (-STACK_SIZE));

        heap_end = (char *)
            ((size_t)boot_params.hdr.heap_end_ptr + 0x200);
        if (heap_end > stack_end)
            heap_end = stack_end;
    } else {
        /* Boot protocol 2.00 only, no heap available */
        puts("WARNING: Ancient bootloader, some functionality "
             "may be limited!\n");
    }
}

Évidemment, si le bootloader ne supporte pas le tas, on se prive des fonctionnalités qui l’utilisent.

Mais s’il fournit effectivement un tas, une ligne d’ inline assembly positionne le pointeur stack_end à l’adresse %esp – STACK_SIZE. On suppose évidemment qu’à cette étape, la pile est vide : %esp est donc aussi grand qu’il peut l’être.

Ensuite, on se contente de positionner heap_end 512 octets plus loin que heap_end_ptr et, si le tas “déborde” sur la pile, on aligne heap_end avec stack_end. Toute la section de mémoire entre la pile et la section bss appartient donc désormais au tas.

Le tas est désormais accessible au travers des fonctions implémentées dans boot.h :

#define RESET_HEAP() ((void *)( HEAP = _end ))
static inline char *__get_heap(size_t s, size_t a, size_t n)
{
    char *tmp;

    HEAP = (char *)(((size_t)HEAP+(a-1)) & ~(a-1));
    tmp = HEAP;
    HEAP += s*n;
    return tmp;
}
#define GET_HEAP(type, n) \
    ((type *)__get_heap(sizeof(type),__alignof__(type),(n)))

static inline bool heap_free(size_t n)
{
    return (int)(heap_end-HEAP) >= (int)n;
}

Pour mieux comprendre ces opérations, représentons le tas dans l’espace :

| stack      |
|____________| <- stack_end
|////////////| <- heap_end
|////////////|
|////////////|
|////////////| 
|------------| <- HEAP // ↑ alloc, ↓ free
| allocated  |
| bytes      |
|____________| <- _end
| bss        | <- __bss_end
|            |

On voit donc immédiatement que la macro RESET_HEAP positionne HEAP de manière à disposer d’un tas libre (c’est-à-dire qu’on l’aligne avec _end). On y fait appel la première fois qu’on utilise le tas, mais également quand on veut libérer toute la mémoire qui y est allouée. Par ailleurs la fonction heap_free vérifie s’il reste n emplacements sur le tas.

La macro GET_HEAP “sucre” simplement la fonction __get_heap en économisant un sizeof. Intéressons-nous à cette dernière.
Son argument s correspond à la taille du type, son argument a à l’alignement du type, et son argument n au nombre d’éléments qu’on veut allouer. On se contente donc d’aligner correctement HEAP, de le positionner après l’espace mémoire alloué (calculé par s*n), et de retourner le pointeur sur le début de la mémoire allouée.

Librairie C élémentaire

Néanmoins, le setup code serait inutilisable sans le sous-ensemble de la libc déclaré dans boot.h. Ce header déclare en réalité des fonctions dont certaines ne seront utilisées que plus tardivement : pour le moment, nous considèrerons simplement celles qui sont utilisables dans l’état de la mémoire et de la reconnaissance matérielle où nous sommes (pas de tas, pas de clavier, etc).

memcpy et memset

Les fonctions memcpy et memset sont implémentées dans le fichier copy.S.
La signature de memcpy, pour être manipulée depuis le C, est la suivante :

void *memcpy(void *dest, const void *src, size_t n);

Ce sont les registres %ax, %dx, %cx qui passeront les trois arguments, et on commence par empiler %si et %di (pour les restaurer à la fin).

GLOBAL(memcpy)
    pushw   %si
    pushw   %di
    movw    %ax, %di
    movw    %dx, %si
    pushw   %cx
    shrw    $2, %cx
    rep; movsl
    popw    %cx
    andw    $3, %cx
    rep; movsb
    popw    %di
    popw    %si
    retl
ENDPROC(memcpy)

On s’intéresse surtout à la boucle, qui copie les mots trouvés à l’adresse pointée par %dx puis %si (const void * src) à l’adresse pointée par %ax puis %di tout en décrémentant %cx jusqu’à ce qu’il vaille zéro. On reproduit l’opération une dernière fois s’il reste des bytes une fois le dernier mot copié (un mot contenant 4 bytes).

La fonction memset fonctionne sensiblement de la même manière, à ceci près que le second argument sera passé par %dl. Voici sa signature :

void *memset(void *s, int c, size_t n);

Et son implémentation :

GLOBAL(memset)
    pushw   %di
    movw    %ax, %di
    movzbl  %dl, %eax
    imull   $0x01010101,%eax
    pushw   %cx
    shrw    $2, %cx
    rep; stosl
    popw    %cx
    andw    $3, %cx
    rep; stosb
    popw    %di
    retl
ENDPROC(memset)

Différence notable, on multiplie la valeur qu’on veut écrire par 0x01010101, car memset écrit 4 bytes à la fois. À chaque itération, on écrit donc c sur s, mais également s + 1, s + 2 et s + 3. Pour le reste, on boucle jusqu’à avoir entièrement rempli la plage mémoire.

memcmp et manipulation des chaînes de caractère

La fonction memcmp est implémentée dans le fichier string.c, en C et en inline assembly.
Son code n’est pas mystérieux :

int memcmp(const void *s1, const void *s2, size_t len)
{
    bool diff;
    asm("repe; cmpsb" CC_SET(nz)
        : CC_OUT(nz) (diff), "+D" (s1), "+S" (s2), "+c" (len));
    return diff;
}

On boucle en comparant s1 et s2 byte par byte, et en décrémentant le compteur len, jusqu’à ce qu’il vaille zéro.

Les fonctions de manipulation de chaînes de caractère (strcmp, strlen, atou, strtoull, strstr, strchr…) consistent simplement en du code C manipulant des pointeurs “nus”, éventuellement avec ces opérations sur la mémoire et les tests définis dans ctypes.h (isdigit, isxdigit…).

Par exemple, strlen boucle sur un espace mémoire jusqu’au point où elle rencontre 0 (ou a atteint maxlen) et renvoie le nombre d’itérations :

size_t strnlen(const char *s, size_t maxlen)
{
    const char *es = s;
    while (*es && maxlen) {
        es++;
        maxlen--;
    }

    return (es - s);
}

Représentation des registres du processeur

Pour pouvoir être lus et écrits depuis le C, les registres du processeur sont représentés, dans boot.h, de la manière suivante :

struct biosregs {
    union {
        struct {
            u32 edi;
            u32 esi;
            u32 ebp;
            u32 _esp;
            u32 ebx;
            u32 edx;
            u32 ecx;
            u32 eax;
            u32 _fsgs;
            u32 _dses;
            u32 eflags;
        };
        struct {
            u16 di, hdi;
            u16 si, hsi;
            u16 bp, hbp;
            u16 _sp, _hsp;
            u16 bx, hbx;
            u16 dx, hdx;
            u16 cx, hcx;
            u16 ax, hax;
            u16 gs, fs;
            u16 es, ds;
            u16 flags, hflags;
        };
        struct {
            u8 dil, dih, edi2, edi3;
            u8 sil, sih, esi2, esi3;
            u8 bpl, bph, ebp2, ebp3;
            u8 _spl, _sph, _esp2, _esp3;
            u8 bl, bh, ebx2, ebx3;
            u8 dl, dh, edx2, edx3;
            u8 cl, ch, ecx2, ecx3;
            u8 al, ah, eax2, eax3;
        };
    };
};

On note surtout que l’ensemble des registres de 32 bits, l’ensemble des registres de 16 bits et l’ensemble des registres de 8 bits sont représentés dans une union, indiquant qu’on ne les manipule jamais en même temps (on utilise un ensemble de registres en fonction du niveau d’abstraction courant).

Le fichier regs.c implémente la fonction initregs pour initialiser tous les registres à 0, à l’exception des registres de segment :

void initregs(struct biosregs *reg)
{
    memset(reg, 0, sizeof(*reg));
    reg->eflags |= X86_EFLAGS_CF;
    reg->ds = ds();
    reg->es = ds();
    reg->fs = fs();
    reg->gs = gs();
}

Manipulation des interruptions matérielles

Lorsqu’une interruption matérielle est appelée, elle est paramétrée par l’état des registres du processeur, sur lesquels elle peut également écrire en fonction de ses effets de bord.
C’est le fichier bioscall.S qui implémente la fonction intcall :

void intcall(u8 int_no, const struct biosregs *ireg, struct biosregs *oreg);

Ici, int_no représente le numéro de l’interruption, ireg l’état des registres paramétrant l’interruption, oreg les effets de bords réalisés par l’interruption.
L’appel à INTX est effectué “en dur”, par la représentation de l’instruction sous la forme de deux octets :

     # Actual INT
    .byte   0xcd        # INT opcode
3:    .byte   0

Bien sûr, nous lancerons d’autres interruptions que INT0 ; en réalité, le second octet de l’instruction est réécrit par %ax (int_no) dès le point d’entrée (ce code est auto-modifiant).

intcall:
    # Self-modify the INT instruction.  Ugly, but works.
    cmpb    %al, 3f
    je  1f
    movb    %al, 3f
jmp    1f

Il n’y a donc plus qu’à créer un stack frame de 44 bytes (ie 11 mots de 32 bits, la taille maximale de l’union de registres) sur la pile représentant les paramètres de l’interruption. Ils sont passés de %dx à %si à la pile (via movsd qui écrit sur l’adresse pointée par %di <=> %sp). Le compteur %cx est écrit avec la valeur 11 (mots).

    # Copy input state to stack frame
    subw    $44, %sp
    movw    %dx, %si
    movw    %sp, %di
    movw    $11, %cx
rep; movsd

De même, après l’interruption, on récupère le stack frame éventuellement modifié à l’adresse pointée par 68(%esp), c’est-à-dire oreg.

    # Copy output state from stack frame
    movw    68(%esp), %di   # Original %cx == 3rd argument
    andw    %di, %di
    jz  4f
    movw    %sp, %si
    movw    $11, %cx
    rep; movsd
4:    addw    $44, %sp

Bonus : les entrées-sorties

À présent que nous avons de quoi accéder aux interruptions matérielles, nous pouvons implémenter de vraies fonctions d’entrée-sorties, utiles pour fournir un feedback pendant le setup code… Mais aussi pour mettre en place la première console. Certaines de ces fonctions sont envoyées dans la section inittext de la mémoire, afin qu’elles soient accessibles dès la première instruction du main.

Il s’agit donc de communiquer directement avec le bios : l’interface matérielle dont on jouit est minimale, puisqu’aucun des drivers plus évolués fournis dans le noyau n’est encore disponible. Considérons le code de la fonction putchar :

static void __attribute__((section(".inittext"))) bios_putchar(int ch)
{
    struct biosregs ireg;

    initregs(&ireg);
    ireg.bx = 0x0007;
    ireg.cx = 0x0001;
    ireg.ah = 0x0e;
    ireg.al = ch;
    intcall(0x10, &ireg, NULL);
}

void __attribute__((section(".inittext"))) putchar(int ch)
{
    if (ch == '\n')
        putchar('\r');  /* \n -> \r\n */

    bios_putchar(ch);

    if (early_serial_base != 0)
        serial_putchar(ch);
}

Il ne s’agit fondamentalement que de paramétrer les registres du processeur et d’appeler l’interruption 0x10 dans la foulée. Évidemment, la spécification de cette fonction n’est pas exactement la même que celle du putchar de la libc : ici, on n’écrit pas sur stdout, mais on agit directement sur le mode d’affichage du bios. Nous manipulons donc un dialecte du C plus qu’un sous-ensemble. Notons que la dernière instruction n’est exécutée que si nous avons initialisé une console série (auquel cas le caractère ch y sera affiché).

La fonction getchar, par exemple, n’est guère plus mystérieuse, à ceci près qu’elle n’est pas envoyée en section inittext : elle n’est tout simplement pas utilisable tant que la console série n’existe pas (et effectivement, il n’y a pas besoin de lire les saisies de l’utilisateur avant ce moment).

int getchar(void)
{
    struct biosregs ireg, oreg;

    initregs(&ireg);
    /* ireg.ah = 0x00; */
    intcall(0x16, &ireg, &oreg);

    return oreg.al;
}

Outre la lecture depuis le clavier et l’écriture sur le moniteur de caractères et/ou de chaînes, on dispose aussi de fonctions pour accéder à la date du processeur.

Conclusion

Nous sommes maintenant parés pour affronter les rigueurs de la setup part… Mais pas encore, loin de là, pour exécuter le kernel. Il faut d’abord :

  • préparer le saut en protected mode (notamment en chargeant le matériel et en installant la mémoire virtuelle) et l’effectuer.
  • passer, pour ce qui concerne les machines récentes, en 64-bit mode.
  • décompresser le kernel.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.