print · rss · source

< Écrire un noyau en C | TutoOS | Gérer les interruptions - la théorie >


Programmer un noyau en C qui recharge la GDT

Sources

Le package contenant les sources est téléchargeable ici : kernel_ReloadGDT.tgz
Pour naviguer dans l'arborescence : ReloadGDT


Pourquoi changer de GDT ?

Au boot, le programme du MBR commute le PC en mode protégé afin de pouvoir charger et exécuter un noyau 32 bits. Le problème est que la GDT initialisée par le secteur de boot ne correspond pas forcément è celle que l'on souhaite pour le noyau. Par exemple, si on démarre notre kernel à l'aide de LILO ou d'un autre boot loader, on ne sait pas a l'avance où sera la GDT ni comment elle sera constituée. Un noyau doit donc initialiser et charger sa propre GDT.

Un noyau qui recharge la GDT

Le programme principal

Il affiche un message, initialise la nouvelle GDT et la charge en mémoire. Après avoir réinitialisé la pile, le noyau passe la main à la fonction main() qui affiche un message et boucle indéfiniment :

#include "types.h"
#include "gdt.h"
#include "screen.h"

int main(void);

void _start(void)
{
        kY = 18;
        kattr = 0x5E;
        print("kernel : loading new gdt...\n");

        /* initialisation de la GDT et des segments */
        init_gdt();

        /* Initialisation du pointeur de pile %esp */
        asm("   movw $0x18, %ax \n \
                movw %ax, %ss \n \
                movl $0x20000, %esp"
);

        main();
}

int main(void)
{
        kattr = 0x4E;
        print("kernel : new gdt loaded !\n");

        while (1);
}

Initialiser la GDT

Les descripteurs de segment en C

Les structures ci-dessous, définies dans le fichier gdt.h, servent à créer les descripteurs de segment et le registre GDTR :

/* Descripteur de segment */
struct gdtdesc {
    u16 lim0_15;    
    u16 base0_15;
    u8 base16_23;
    u8 acces;
    u8 lim16_19 : 4;
    u8 other : 4;
    u8 base24_31;
} __attribute__ ((packed));

/* Registre GDTR */
struct gdtr {
    u16 limite ;
    u32 base ;
} __attribute__ ((packed));

La directive __attribute__ ((packed)) indique à gcc que la structure en question doit occuper le moins de place possible en mémoire. Sans cette directive, le compilateur insère des octets entre les champs de la structure afin de les aligner pour optimiser la vitesse d'accès. Or dans notre cas, nous voulons que la structure décrive exactement l'occupation en mémoire des données.
Pour ces mêmes raisons, il faut définir de nouveaux types de données afin de maîtriser les allocations en mémoire. Ces types sont définis dans le fichier types.h.

#ifndef _I386_TYPE_
#define _I386_TYPE_

typedef unsigned char u8;
typedef unsigned short u16;
typedef unsigned int u32;
typedef unsigned char uchar;

#endif

La fonction init_gdt()

Le fichier gdt.c contient la fonction init_gdt() qui initialise les descripteurs de segments et charge la nouvelle GDT :

#include "types.h"
#include "lib.h"

#define __GDT__
#include "gdt.h"


/*
 * 'init_desc' initialise un descripteur de segment situe en gdt ou en ldt.
 * 'desc' est l'adresse lineaire du descripteur a initialiser.
 */

void init_gdt_desc(u32 base, u32 limite, u8 acces, u8 other,
                   struct gdtdesc *desc)
{
        desc->lim0_15 = (limite & 0xffff);
        desc->base0_15 = (base & 0xffff);
        desc->base16_23 = (base & 0xff0000) >> 16;
        desc->acces = acces;
        desc->lim16_19 = (limite & 0xf0000) >> 16;
        desc->other = (other & 0xf);
        desc->base24_31 = (base & 0xff000000) >> 24;
        return;
}

/*
 * Cette fonction initialise la GDT apres que le kernel soit charge
 * en memoire. Une GDT est deja operationnelle, mais c'est celle qui
 * a ete initialisee par le secteur de boot et qui ne correspond
 * pas forcement a celle que l'on souhaite.
 */

void init_gdt(void)
{

        /* initialisation des descripteurs de segment */
        init_gdt_desc(0x0, 0x0, 0x0, 0x0, &kgdt[0]);
        init_gdt_desc(0x0, 0xFFFFF, 0x9B, 0x0D, &kgdt[1]);      /* code */
        init_gdt_desc(0x0, 0xFFFFF, 0x93, 0x0D, &kgdt[2]);      /* data */
        init_gdt_desc(0x0, 0x0, 0x97, 0x0D, &kgdt[3]);          /* stack */

        /* initialisation de la structure pour GDTR */
        kgdtr.limite = GDTSIZE * 8;
        kgdtr.base = GDTBASE;

        /* recopie de la GDT a son adresse */
        memcpy((char *) kgdtr.base, (char *) kgdt, kgdtr.limite);

        /* chargement du registre GDTR */
        asm("lgdtl (kgdtr)");

        /* initialisation des segments */
        asm("   movw $0x10, %ax \n \
            movw %ax, %ds       \n \
            movw %ax, %es       \n \
            movw %ax, %fs       \n \
            movw %ax, %gs       \n \
            ljmp $0x08, $next   \n \
            next:               \n"
);
}

Les descripteurs sont initialisés et copiés dans le tableau kgdt[] :

init_gdt_desc(0x0, 0xFFFFF, 0x9B, 0x0D, &kgdt[1]);      /* code */
init_gdt_desc(0x0, 0xFFFFF, 0x93, 0x0D, &kgdt[2]);      /* data */
init_gdt_desc(0x0, 0x0, 0x97, 0x0D, &kgdt[3]);          /* stack */
  • Les segments de code et de données ont leur base qui est à 0 et leur limite est de 0xFFFFF + 1 = 0x100000 pages de 4ko, soient 4 Go. Autrement dit, ils adressent l'ensemble de la mémoire.
  • De façon un peu étonnante, le segment de pile a une base et une limite qui sont à 0 ! Dans un segment de pile (expand down), la base n'est pas interprétée, elle est donc ici mise à 0. La limite se calcule comme pour les autres segment mais elle doit être interprétée comme la limite inférieure du segment. Dans le cas présent, le segment de pile recouvre donc toute la mémoire.

Une fois le tableau rempli, il est recopié à l'endroit en mémoire où la GDT doit résider :

/* recopie de la GDT a son adresse */
memcpy((char *) kgdtr.base, (char *) kgdt, kgdtr.limite);

Ensuite, la structure kgdtr est initialisé puis chargée dans le registre GDTR. A ce moment là, le changement de GDT est effectif :

/* chargement du registre GDTR */
asm("lgdtl (kgdtr)");

Une fois la nouvelle GDT chargée, il faut mettre à jour les selecteurs de segments de données (ds, es, fs , gs et ss). Un long jump permet de mettre à jour le selecteur du segment de code :

/* initialisation des segments */
 asm("   movw $0x10, %ax        \n \
         movw %ax, %ds  \n \
         movw %ax, %es  \n \
         movw %ax, %fs  \n \
         movw %ax, %gs  \n \
         ljmp $0x08, $next      \n \
         next:          \n"
);

Vous avez sans doute remarqué que le pointeur de pile est initialisé après l'appel à la fonction init_gdt(). Pourquoi n'est-il pas initialisé dans init_gdt() comme tous les autres ? Parceque l'instruction assembleur leave, en fin de fonction, écrase le registre esp avec la valeur de ebp. Tout serait alors à refaire ! Une solution serait de forcer la valeur de ebp de façon à ce qu'elle coïncide avec celle de esp, mais cela ne ferait que repousser le problème : n'oubliez pas qu'en changeant la pile, on perd l'adresse sauvegardée du compteur ordinal (eip) qui permet le retour.

Une fonction main() pour déjouer les pièges de gcc

/* initialisation de la GDT et des segments */
init_gdt();

/* Initialisation du pointeur de pile %esp */
asm("   movw $0x18, %ax \n \
        movw %ax, %ss \n \
        movl $0x20000, %esp"
);

main();

Après avoir initialisé la GDT, la pile est initialisée pour pointer en 0x20000. Cette adresse est arbitraire, j'aurais pu choisir autre chose... mais attention à prendre une valeur où la pile ne risque pas de corrompre le code ou des données !

Après ces initialisations, une fonction main() est appelée. La création d'une nouvelle fonction peut sembler luxueuse quand on voit ce qu'elle réalise : juste afficher un messager et boucler indéfiniment. N'aurait-on pas pu placer l'intégralité du code dans la fonction _start() ? Non, car l'appel à la fonction print est réalisé par gcc de cette façon :

movl    $.LC1, (%esp)
call    print

On remarque que le passage d'argument ne se fait pas par un push mais par un mov qui écrit l'adresse de la chaîne de caractère à afficher directement en 0x20000, ce qui est au delà du sommet de la pile ! En principe, pour passer un paramètre à une fonction, un compilateur doit utiliser push qui décrémente d'abord la valeur de esp avant d'écrire sur la pile, mais gcc procède autrement en réservant au début de la fonction suffisament de place sur la pile. Mais comme nous avons modifié entre temps la structure de la pile, et donc de la frame associée à la fonction _start, cela ne peut plus fonctionner ! La fonction main() permet de repartir sur une frame propre.

Compiler le bootloader et le noyau

$ tar xfz kernel_ReloadGDT.tgz
$ cd ReloadGDT
$ make

< Écrire un noyau en C | TutoOS | Gérer les interruptions - la théorie >

print · rss · source
Page last modified on February 02, 2011, at 02:24 PM