SmolBSD : un système UNIX de 7 mégaoctets qui démarre en moins d’une seconde

Spécialité(s)


Résumé

Que de racolage en si peu de mots. Et pourtant si, c’est bien la promesse de cet article, comment parvenir à construire un système d’exploitation fonctionnel en moins de… 10 mégabits. Quelle est cette sorcellerie ? En utilisant une fonctionnalité prévue, mais pas utilisée à cet escient par le noyau NetBSD, nous allons lui faire subir un régime drastique !


Body

1. Les histoires de papy iMil

NetBSD est un héritier direct de BSD UNIX, et par héritier, je n’insinue pas un vague lien de parenté et quelques drivers copiés, non non, NetBSD est littéralement la première émanation (libre) du travail de la CSRG [1], groupe de travail auteur de la Berkeley Software Distribution, dont l’œuvre s’achève avec la livraison de 4.4BSD-Lite 2, version totalement libérée de tout code source issu de l’UNIX original.

La conséquence de ce divin lien de parenté, c’est que NetBSD hérite entre autre du système de build de son ancêtre, très largement articulé autour de l’outil make, système dans lequel on configure les capacités du noyau dans un bête fichier texte, situé à l’époque dans le répertoire /usr/sys/conf. J’aime utiliser le code source de 2.11BSD pour faire référence à cette lignée, le développement de 2BSD ayant commencé en 1979 et ayant vu sa dernière release, 2.11 donc, en 1991. De valeureux volontaires continuent néanmoins à proposer des mises à jour et la dernière date… du 28 avril 2023 !

Dans le répertoire suscité, on trouve donc les fameux fichiers de configuration du noyau, parmi eux la configuration dite « générique », portée par le fichier GENERIC, qu’il convient de copier, par exemple cp GENERIC MYKERNEL puis de modifier le fichier MYKERNEL en ajoutant ou enlevant des lignes qui correspondent à un driver ou une fonctionnalité. Quelques exemples de lignes de configuration de ce fichier :

#########################################
# PERIPHERALS: DISK DRIVES#
#########################################
 
NBR         0     # EATON BR1537/BR1711, BR1538A, B, C, D
 
NHK         2     # RK611, RK06/07
 
NRAC        2     # NRAD controllers
NRAD        3     # RX50, RC25, RD51/52/53, RA60/80/81
 
# [...]
 
#########################################
# PSEUDO DEVICES, PROTOCOLS, NETWORKING#
#########################################
 
# [...]
 
INET        NO    # TCP/IP
CHECKSTACK NO    # Kernel & Supervisor stack pointer checking
NETHER      0     # ether pseudo-device

Une fois ajouté ou enlevé les drivers ou fonctionnalités désirés, on invoque le programme config(1) avec en paramètre le fichier précédemment créé, ce qui aura pour effet de générer un répertoire ../MYKERNEL dans lequel on se rendra pour exécuter la commande make.

Simple, n’est-ce pas ?

Comment cette méthode a-t-elle évolué en 30 ans ? Peu. Les sources et définitions se situent maintenant dans /usr/src/sys/arch/<architecture>/conf puisque NetBSD est par essence multiarchitecture, c’est donc depuis ce répertoire qu’on exécutera config.

NetBSD propose également une solution très élégante permettant de faire un cross-build du noyau, mais également tout le système d’exploitation grâce à un script, build.sh, mais son utilisation dépasse le cadre de cet article.

2. Et ces « drivers », sont-ils avec nous dans cette pièce ?

Dans le noyau de 4.1BSD est apparu un concept très pratique pour la découverte du matériel à disposition du système : l’autoconfiguration.

Basiquement, ce que réalise ce framework, c’est balayer un simple tableau qui contient l’intégralité des types de périphériques ainsi que les bus auxquels ils sont rattachés (PCI, ISA, USB…) et attacher le code associé au périphérique au matériel découvert.

Pour la suite de cet article, on considérera que nous disposons des sources du système dans /usr/src, ces dernières peuvent se télécharger de multiples façons, par exemple depuis le repository Git officiel du projet NetBSD sur https://github.com/NetBSD/src.

Ce tableau est composé d’une suite de structures de données appelée cfdata, dont voici la forme (issue du fichier /usr/src/sys/sys/device.h, consultable en ligne par exemple à http://cvsweb.netbsd.org/bsdweb.cgi/src/sys/sys/device.h) :

struct cfdata {
       const char *cf_name;            /* driver name */
       const char *cf_atname;          /* attachment name */
       short cf_unit;                  /* unit number */
       short cf_fstate;                /* finding state (below) */
       int    *cf_loc;                 /* locators (machine dependent) */
       int    cf_flags;                /* flags from config */
       const struct cfparent *cf_pspec;/* parent specification */
};

Rappelez-vous la structure du fichier de configuration des anciens BSD UNIX, voici à quoi elle ressemble aujourd’hui :

# IPMI support
ipmi0       at mainbus?
ipmi_acpi*  at acpi?
ipmi0       at ipmi_acpi?
 
# ACPI will be used if present. If not it will fall back to MPBIOS
acpi0       at mainbus0
options     ACPI_SCANPCI        # find PCI roots using ACPI
options     MPBIOS              # configure CPUs and APICs using MPBIOS
options     MPBIOS_SCANPCI      # MPBIOS configures PCI roots
#options    PCI_INTR_FIXUP      # fixup PCI interrupt routing via ACPI
#options    PCI_BUS_FIXUP       # fixup PCI bus numbering
#options    PCI_ADDR_FIXUP      # fixup PCI I/O addresses
#options    ACPI_ACTIVATE_DEV   # If set, activate inactive devices
options     VGA_POST            # in-kernel support for VGA POST
 
# ACPI devices
acpiacad*   at acpi?        # ACPI AC Adapter
acpibat*    at acpi?        # ACPI Battery
acpibut*    at acpi?        # ACPI Button

Tous les périphériques sont rattachés à leur bus parent, par exemple ici, acpibat* (l’étoile signifie qu’il peut en avoir plusieurs) est rattaché à acpi? (ici, le ? est également un wildcard, mais au niveau parent), qui lui-même est rattaché au bus principal, en réalité virtuel et qui sert de racine (root) à tous les autres bus : mainbus0.

Un élément crucial à la suite de cet article dépend de la possibilité d’enlever le support d’un périphérique du noyau, en effet, qui dit moins de bus et de périphériques dit moins de parcours et de tentative d’initialisation, et donc, un démarrage plus rapide.

Comme nous l’avons vu précédemment, la configuration du noyau à compiler s’effectue, encore aujourd’hui, à l’aide de l’outil config(1), aussi, après avoir par exemple copié le fichier GENERIC en MYKERNEL dans le répertoire /usr/src/sys/arch/amd64/compile si nous sommes sur une architecture de type Intel 64 bits, nous tapons :

# config MYKERNEL

Ce qui aura pour effet de nous indiquer la suite des opérations :

Build directory is ../compile/MYKERNEL
Don't forget to run "make depend"

Derrière ces laconiques instructions se cache en réalité une mécanique bien huilée et plus complexe qu’il n’y paraît. En outre, une des multiples tâches qu’a effectuée l’outil config, c’est de préparer un fichier, ioconf.c, dans lequel on retrouve notre fameux tableau cfdata, rempli de tous les périphériques que supportera ce noyau :

struct cfdata cfdata[] = {
    /* driver           attachment    unit state      loc   flags pspec */
/* 0: spkr* at pcppi? */
    { "spkr",       "spkr_pcppi",    0, STAR,    NULL,      0, &pspec41 },
/* 1: spkr* at audio? */
    { "spkr",       "spkr_audio",    0, STAR,    NULL,      0, &pspec140 },
/* 2: audio* at uaudio? */
    { "audio",      "audio",         0, STAR,    NULL,      0, &pspec104 },
/* 3: audio* at audiobus? */
    { "audio",      "audio",         0, STAR,    NULL,      0, &pspec139 },
/* 4: midi* at midibus? */
    { "midi",       "midi",          0, STAR,    NULL,      0, &pspec142 },
/* 5: midi* at pcppi? */
    { "midi",       "midi_pcppi",    0, STAR,    NULL,      0, &pspec41 },
/* 6: hdaudio* at pci? dev -1 function -1 */
    { "hdaudio",    "hdaudio_pci",   0, STAR,    loc+542,   0, &pspec23 },
/* 7: hdafg* at hdaudiobus? nid -1 */
    { "hdafg",      "hdafg",         0, STAR,    loc+926,   0, &pspec138 },
/* 8: video* at videobus? */
    { "video",      "video",         0, STAR,    NULL,      0, &pspec129 },
 
/* ... */

Et dans ce tableau de structures cfdata, il y a un champ qui nous intéresse particulièrement, c’est le champ cfdata.cf_fstate.

2.1 userconf

Lorsqu’on démarre un système NetBSD, le bootloader nous donne l’opportunité de démarrer sur un mode un peu spécial du noyau : userconf(4), lorsqu’on passe au noyau l’option -c, on bascule sur une CLI minimaliste qui permet… d’activer ou désactiver un périphérique !

Ceci s’effectue très simplement de la façon suivante :

uc> disable spkr*

Et que réalise concrètement cette commande ? C’est dans le fichier /usr/src/sys/kern/subr_userconf.c que se trouve la réponse :

static void
userconf_disable(int devno)
{
    int done = 0;
 
    if (devno <= userconf_maxdev) {
        switch (cfdata[devno].cf_fstate) {
        case FSTATE_NOTFOUND:
            cfdata[devno].cf_fstate = FSTATE_DNOTFOUND;
            break;
        case FSTATE_STAR:
            cfdata[devno].cf_fstate = FSTATE_DSTAR;
            break;
        case FSTATE_DNOTFOUND:
        case FSTATE_DSTAR:
            done = 1;
            break;
        default:
            printf("Error unknown state\n");
            break;
        }
 
        printf("[%3d] ", devno);
        userconf_pdevnam(devno);
        if (done) {
            printf(" already");
        } else {
            /* XXX add cmd 'd' <devno> eoc */
            userconf_hist_cmd('d');
            userconf_hist_int(devno);
            userconf_hist_eoc();
        }
        printf(" disabled\n");
    } else {
        printf("Unknown devno (max is %d)\n", userconf_maxdev);
    }
}

Pour éviter toute confusion, précisons que dans le fichier ioconf.c, nous avons :

#define STAR FSTATE_STAR

Ce qui nous indique donc que lorsqu’on désactive le support d’un périphérique dans userconf(4), on passe la valeur de cf_fstate de FSTATE_STAR à FSTATE_DSTAR. Intéressant, mais ceci est valable en runtime, à ce stade le noyau est déjà chargé et nous modifions des valeurs dynamiquement, pourrait-on plutôt…

3. Mieux que le régime Keto, VirtIO !

La règle est simple : moins de drivers, moins de temps perdu, et de nos jours, il est assez fréquent que le « matériel » se résume en réalité à un hyperviseur présentant des interfaces VirtIO [2], simples et standardisées, supportées par la plupart de nos systèmes libres.

Ainsi, pour fonctionner comme machine virtuelle avec le minimum de drivers chargés, la méthode, disons « officielle » consisterait à reconstruire un noyau avec le strict minimum, un port série et donc un bus isa?, un loop device, un bus PCI pour VirtIO, des interfaces virtuelles, et IP :

include "arch/i386/conf/std.i386"
 
makeoptions COPTS="-Os"
makeoptions USE_SSP="no"
 
maxusers    8           # estimated number of users
 
options     INSECURE    # disable kernel security levels - X needs this
options     MULTIBOOT
 
options     RTC_OFFSET=0    # hardware clock is this many mins. west of GMT
options     PIPE_SOCKETPAIR # smaller, but slower pipe(2)
file-system FFS             # UFS
file-system EXT2FS          # second extended file system (linux)
file-system KERNFS          # /kern
options     FFS_NO_SNAPSHOT # No FF snapshot support
options     INET            # IP + ICMP + TCP + UDP
config      netbsd root on ? type ?
 
# PCI bus support
pci*    at mainbus? bus ?
# ISA bus support
isa0    at mainbus?
# Console Devices
 
# ISA serial interfaces
com0    at isa? port 0x3f8 irq 4    # Standard PC serial ports
 
# network pseudo-devices
pseudo-device   bpfilter            # Berkeley packet filter
pseudo-device   loop                # network loopback
# miscellaneous pseudo-devices
pseudo-device   pty                 # pseudo-terminals
pseudo-device   clockctl            # user control of clock subsystem
 
pseudo-device   ksyms
 
virtio* at pci? dev ? function ?    # Virtio PCI device
ld* at virtio?                      # Virtio disk device
vioif* at virtio?                   # Virtio network device

Oui, oui, je vous vois grimacer « comment ça, i386 ?! » attendez, attendez, ne fuyez pas, j’ai une très bonne explication.

Afin d’outrepasser le bootloader et gagner de précieuses millisecondes, nous pouvons invoquer qemu-kvm avec le paramètre -kernel et passer à ce dernier le chemin vers le noyau sur lequel démarrer. Seulement voilà, cela suppose que ledit noyau supporte au moins l’une de ces deux fonctionnalités :

  • boot PVH ;
  • MULTIBOOT.

Le premier est actuellement… hum… « en cours d’étude ».

Le second est à cette heure uniquement fonctionnel avec une architecture i386.

Exécuter des machines virtuelles 32 bits est-il réellement pénalisant dans le contexte qui nous occupe ? L’idée conductrice de cette expérience étant de faire tourner des micro-VM, rapides, peu gourmandes en mémoire, la réponse est non.

Munis de ce fichier de configuration pour noyau minimal, appelons-le SMOLBSD, sauvegardé dans /usr/src/sys/arch/i386/conf/SMOLBSD (notez le i386), nous allons finalement utiliser (oui, j’ai menti) le script build.sh. Pourquoi ? Parce que build.sh permet de compiler un noyau pour une autre architecture que celle faisant actuellement fonctionner le système d’exploitation !

Cette étape étant livrée à votre appétit insatiable uniquement pour constater comme il serait fastidieux de procéder de la sorte pour produire un noyau minimal, nous irons droit au but.

Dans le répertoire /usr/src, lancez build.sh de la sorte :

# ./build.sh -m i386 kernel=SMOLBSD

Au bout de quelques minutes, un noyau minimal appelé netbsd devrait se trouver dans le répertoire /usr/src/sys/arch/i386/compile/obj/SMOLBSD.

Note : build.sh est non seulement multiplateforme, mais également multi-OS, il est par conséquent parfaitement possible de compiler son noyau NetBSD sur sa station de travail GNU/Linux.

Le noyau ainsi produit ne contiendra que le support pour les périphériques nécessaires, en particulier les interfaces VirtIO. Son exécution s’effectuera de cette façon :

# qemu-system-x86_64 -enable-kvm -m 256 -kernel netbsd-SMOL -append "-v console=com root=ld0a -v" -drive file=rescue.img,if=virtio -serial stdio -display none

L’image disque utilisée, rescue.img est une image générée pour l’occasion, nous y reviendrons largement.Le noyau donne une indication sur son temps de démarrage, sur la gauche des messages affichés par ce dernier se trouve un timestamp, une évaluation du temps consommé par chaque étape. Ce temps est néanmoins à lire avec précaution, premièrement, sous NetBSD, il démarre à 1.0 (une seconde), car on considère que c’est le temps approximatif des opérations menées au moment de l’initialisation du média de boot. D’autre part, ce timestamp ne prend pas en compte les éventuels chargements de firmwares, initialisations matérielles, etc.

En outre, le démarrage d’un noyau NetBSD GENERIC, sans aucune modification, prend, avec les paramètres de qemu cités et sur ma machine (un i5-7600K), une dizaine de secondes (3.333 selon le timestamp) :

[   4.3128445] boot device: ld0
[   4.3226111] root on ld0a dumps on ld0b
[   4.3226111] root file system type: ext2fs
[   4.3331806] kern.module.path=/stand/i386/9.3/modules

Le démarrage de notre noyau SMOL est à peu de chose près instantané (n’oubliez pas que le 1. est juste une convention) :

[   1.0000030] boot device: ld0
[   1.0501144] root on ld0a dumps on ld0b
[   1.0501144] root file system type: ext2fs
[   1.0501144] kern.module.path=/stand/i386/9.3/modules

4. Lobotomie

Vous m’accorderez que la technique précédemment exposée n’est pas exactement simple et encore moins rapide à mettre en œuvre pour obtenir un noyau light ; mon plan final est de pouvoir donner à chacun la possibilité de générer un microsystème capable de démarrer un service avec un minimum de surcharge, le passage par la récupération des sources et la compilation d’un noyau disqualifie, ne serait-ce que par une très légitime flemme, une bonne partie du lectorat.

Mais j’ai eu une idée.

Rappelez-vous, NetBSD permet, une fois le noyau chargé, à travers la fonctionnalité userconf(4) de désactiver des drivers, mais ce tableau de drivers est initialisé au sein du noyau, dans un tableau statique, dans la section .data, et si… nous modifiions directement la valeur de cf_fstate dans le binaire noyau ?

Pour la suite des opérations, l’utilisation d’un système NetBSD n’est pas nécessaire, nous allons manipuler un objet ELF, ce que sait faire l’intégralité de nos systèmes UNIX et UNIX-like libres.

Pour nous assurer de la présence de cette structure au sein du noyau, nous allons utiliser l’outil nm(1) :

$ nm netbsd|grep cfdata
c123f560 d ataraid_cfdata.14679
c12ae940 D cfdata
c08cf9e1 T cfdata_ifattr
c08cf79c T config_cfdata_attach
c08cf4c8 T config_cfdata_detach
c08d27a0 T device_cfdata
c1275b40 d swcrypto_cfdata

En effet, à l’adresse 0xc12ae940 se trouve bien une structure cfdata, le D nous indique qu’elle se situe dans la section .data, comme prévu. Que trouvons-nous à cette adresse ? Analysons le binaire à l’aide de notre fidèle gdb :

$ gdb netbsd
$ print &cfdata
$1 = (<data variable, no debug info> *) 0xc12ae940 <cfdata>

Nous retrouvons bien l’adresse indiquée par nm ; afin d’analyser le contenu du binaire à cette adresse, créons une variable qui pointera vers cette dernière qu’on déclare de type « pointeur vers un entier », pour rappel nous manipulons un noyau 32 bits, qui se trouve être la taille d’un entier sur notre architecture :

$ set $ptr=(int *)&cfdata

Rappel : le premier champ de la structure de données est un pointeur vers une chaîne de caractères en lecture seule (const char *), on peut afficher ce premier champ à l’aide de cette commande :

$ print (char *)*$ptr

Ici, on « déréférence » le pointeur (comprendre qu’on accède à son contenu), et l’on indique que le type de variable est un pointeur sur un caractère (char *) de façon que print affiche une chaîne de caractères et non la valeur d’adresse vers cette chaîne. Et nous obtenons (p est un alias pour print) :

$ p (char *)*$ptr
0xc10038fd "audio"

Le goût de la victoire lorsqu’on « tombe » sur une valeur cohérente quand on analyse un binaire avec gdb est certainement équivalent à celui de la première cerise de l’année.Reprenons notre fameuse structure cfdata pour en déterminer la taille en octets :

  • const char *cf_name, un pointeur sur une chaîne, 32 bits (4 octets) ;
  • const char *cf_atname, même valeur (+ 4 octets) ;
  • short cf_unit, un short vaut 16 bits (+ 2 octets) ;
  • short cf_fstate, même valeur (+2 octets) ;
  • int *cf_loc, un pointeur vers entier, 32 bits (+ 4 octets) ;
  • int cf_flags, un entier, 32 bits (+4 octets) ;
  • const struct cfparent *cf_pspec, un pointeur vers une structure, 32 bits (+4 octets).

Ce qui nous fait un total de 24 octets. Un petit schéma s’impose :

*cf_name

*cf_atname

cf_unit

cf_fstate

*cf_loc

cf_flags

*cf_pspec

4 octets

4 octets

2 octets

2 octets

4 octets

4 octets

4 octets

Par conséquent, le second élément de ce tableau de cfdata se trouve ?… 24 octets plus loin, et dans gdb, comme en C (le langage de la Vie, rappelons-le), lorsqu’on incrémente un pointeur, on l’incrémente de sa taille. Si l’on fait donc dans gdb :

$ set $ptr=$ptr+6

on incrémente $ptr de 24 octets (6 * 4) et l’on se retrouve à la seconde occurrence de cfdata :

$ p $ptr
(int *) 0xc12ae958 <cfdata+24>
$ p (char *)*($ptr)
0xc10038fd "audio"

Rien d’anormal, le tableau contient plusieurs drivers audio, avançons encore de 24 octets :

$ set $ptr=$ptr+6
$ p (char *)*($ptr)
0xc1003cb6 "midi"

Et nous voici au driver midi.

Reste maintenant à savoir si notre hypothèse est bonne : pouvons-nous désactiver un driver du noyau en changeant la valeur de cf_state de FSTATE_STAR (0x2) à FSTATE_DSTAR (0x3) ?

Comment faire et quoi changer ? Là encore, gdb à la rescousse, car si vous l’ignoriez, moyennant l’ajout du flag --write, gdb est en mesure d’écrire dans un binaire.

Avant tout, vérifions la valeur qui se trouve au 10e octet de notre structure, pour ce faire, nous allons réaffecter &cfdata à $ptr, mais en le castant en short int * cette fois ; pourquoi ? Parce que nous voulons visualiser uniquement les 2 octets qui composent cette valeur, et également parce que si nous incrémentons $ptr alors qu’il est casté en int *, nous nous déplacerons de 4 octets en 4 octets.

$ set $ptr=(short int *)&cfdata
$ x/1h $ptr+5
0xc12ae94a <cfdata+10>: 0x2

Mais quelle est cette syntaxe barbare ? x est un alias pour examine, cette commande gdb permet de lire ce qui est contenu à une certaine adresse. On peut donner des paramètres à cette commande, par exemple ici, nous lui imposons d’écrire uniquement 1 demi-mot, soient 2 octets. $ptr étant maintenant pour gdb un pointeur sur un short int, son incrément vaut 2 octets, donc pour visualiser le contenu de la mémoire 10 octets après l’adresse de départ de cfdata, il faut incrémenter son adresse de 5, soit 5*2 octets.Et nous voyons bien la valeur 0x2. C’est l’heure de sortir la fraiseuse.

$ gdb --write netbsd

Assurez-vous d’avoir une copie de ce noyau que nous allons… « modifier » :

$ # on fait pointer $ptr sur cfdata mais on le déclare comme un short int, soit 2 octets
$ set $ptr=(short int *)&cfdata
$ # on fait pointer la variable $state 5x2 octets plus loin, là où se trouve cf_state
$ set $state=$ptr+5
$ # on affiche le contenu de l'endroit où pointe cette variable
$ x/h $state
0xc12b496a <cfdata+10>: 0x2
$ # c'est bien 0x2, nous allons modifier cette valeur avec un 0x3
$ set *$state=0x3
$ x/h $state
0xc12b496a <cfdata+10>: 0x3

Voici la sortie du noyau non modifié lorsqu’on démarre qemu-system-i386 avec le support audio (-audiodev driver=pa,id=pa1 -device AC97,audiodev=pa1) :

[   1.5877663] auich0: measured ac97 link rate at 50307 Hz, will use 50000 Hz
[   1.5877663] audio0 at auich0: playback, capture, full duplex, independent
[   1.5877663] audio0: slinear_le:16 2ch 48000Hz, blk 1920 bytes (10ms) for playback
[   1.5877663] audio0: slinear_le:16 2ch 48000Hz, blk 1920 bytes (10ms) for recording

Voici la sortie du noyau avec le noyau modifié :

[   1.6003802] auich0: measured ac97 link rate at 51372 Hz, will use 51000 Hz
[   1.6003802] audio at auich0 not configured

C’est gagné : nous avons désactivé le driver audio dans le binaire noyau.

5. La sulfateuse

Nous savons désormais désactiver un driver à l’aide de gdb sans avoir à recompiler le noyau. OK, OK, j’en vois rigoler au fond « ah ah, mais ils n’ont pas de modules, chez NetBSD ? », bien sûr que si, nous avons des modules, mais il s’agit ici du noyau GENERIC, dans lequel les drivers les plus courants sont compilés de façon monolithique, l’exercice qui nous occupe est d’artificiellement désactiver les branches de recherche de matériels inutiles pour démarrer ce noyau en une poignée de millisecondes.

Il est évidemment exclu de désactiver manuellement les dizaines de drivers non utiles, et la première approche un peu brutale que nous allons utiliser consiste en un script gdb, car oui, gdb aussi peut se scripter.

La première chose que nous allons évidemment faire, c’est d’itérer dans chaque ligne de notre tableau de cfdata pour parcourir l’ensemble des drivers disponibles. Comme tout tableau qui se respecte, le tableau de cfdata se termine par un élément qui vaut 0x0, nous savons donc que notre boucle aura comme condition le fait que le contenu du pointeur que nous allons incrémenter est 0. Cela se traduit ainsi :

$ set $ptr=(int *)&cfdata
$ while (*$ptr != 0)
>p (char *)*$ptr
>set $ptr = $ptr + 6
>end

Cette petite boucle s’arrêtera donc lorsque *$ptr (la valeur à l’adresse $ptr) vaudra 0x0.

On affiche le nom du driver à cette ligne du tableau, puis on incrémente $ptr de 6 fois sa taille, un pointeur sur 32 bits soit 4 octets, donc un total de 24 octets, la taille de la structure cfdata. Cela nous donne quelque chose de ce genre :

$1 = 0xc10038fd "audio"
$2 = 0xc10038fd "audio"                                                                             
$3 = 0xc1003cb6 "midi"
$4 = 0xc1003cb6 "midi"
$5 = 0xc103bdc3 "hdaudio"
$6 = 0xc103bc41 "hdafg"
$7 = 0xc1005325 "video"
$8 = 0xc1005710 "dtv"
$9 = 0xc0ff6a32 "iic"
...
$705 = 0xc1011e76 "hvshutdown"
$706 = 0xc1011fb1 "hvtimesync"
$707 = 0xc0fbff30 "glxsb"

Encore plus fort, gdb peut être appelé en mode batch, avec en paramètre un fichier dans lequel on définit une fonction et la fonction à appeler, nous créons donc un fichier drvdig.gdb qui contient une variation du code précédent :

define loop_cfdata
        set $ptr = (int *)&cfdata
        while (*$ptr != 0)
                printf "%x %s\n", $ptr, (char *)*$ptr
                set $ptr = $ptr + 6
        end
end

Cette boucle affiche l’adresse de la ligne du tableau sur laquelle $ptr pointe ainsi que le nom du driver, on invoque la fonction de cette façon :

$ gdb -n netbsd --batch -x drvdig.gdb -ex "loop_cfdata"

Ici, le flag -n spécifie simplement que je ne souhaite charger aucun plugin de gdb (j’utilise habituellement PEDA [3]), on indique le binaire à analyser, notre noyau NetBSD, puis après le flag -x le fichier de fonctions à charger, puis après le flag -ex la fonction à exécuter.

Dans notre fichier, nous allons définir une nouvelle fonction qui aura pour rôle de désactiver un driver, cette dernière a cette forme :

define fstate
        set $baseaddr = $arg1
        set $fstateaddr = $baseaddr + 10
        set {char}$fstateaddr = $arg0
end

Cette fonction reçoit deux arguments, l’état qu’on veut assigner au driver, 2 pour activé, 3 pour désactivé, et l’adresse de base, soit l’adresse de la ligne sur laquelle nous agissons. Ici, nous ne « castons » pas, nous recevons une adresse et y ajoutons 10 (octets), l’endroit où se trouve le champ cf_fstate et y inscrivons la valeur contenue dans le premier argument, le {char} sert uniquement à indiquer à gdb que la valeur que nous inscrivons a une taille d’un octet (0x2 ou 0x3).

Munis de ces deux fonctions, nous pouvons nous fendre d’un script shell qui va boucler sur l’ensemble des pilotes et désactiver ceux qui ne sont pas présents dans une liste que nous avons établie :

#!/bin/sh
 
[ $# -lt 1 ] && exit 1
 
kern=$1
 
keep="mainbus cpu acpicpu ioapic pci isa pcdisplay wsdisplay com virtio ld vioif"
 
gdb -n $kern --batch -x drvdig.gdb -ex "loop_cfdata" | \
while read addr drv
do
    found=0
    for k in $keep
    do
        [ "$k" = "$drv" ] && found=1 && break
    done
 
    [ $found -eq 1 ] && echo "not removing $drv" && continue
 
    echo "removing $drv"
    gdb -n $kern --write --batch -x drvdig.gdb -ex "fstate 3 0x$addr"
 
    status=$?
    if [ $status -ne 0 ]
    then
        echo "Something terribly wrong has happened! GDB return $status"
        exit 1
    fi
done

Oui, j’ai été faible, j’ai combiné les deux boucles précédentes à travers un pipe shell, je n’étais pas assez courageux pour implémenter la logique du script dans le « langage » de scripting très spartiate de gdb.

On enregistre dans la variable keep la courte liste des drivers qui nous intéressent et qui sont nécessaires au fonctionnement d’une machine virtuelle. On pipe ensuite la sortie du script gdb loop_cfdata dans une boucle while qui lira la valeur de l’adresse dans la variable $addr et le nom du driver dans $drv.

On parcourt ensuite la liste des drivers nécessaires et l’on vérifie si $drv est parmi eux, si ce n’est pas le cas, on appelle le second script gdb qui s’occupera d’inscrire la valeur 0x3 (désactivé) à l’adresse calculée dans la fonction gdb fstate.

Ce script s’invoque de cette façon : kstrip.sh netbsd, et au bout d’environ une longue minute, ce dernier aura désactivé la quasi-totalité des pilotes de votre noyau, n’y laissant que le strict nécessaire pour démarrer en moins d’une seconde. La première partie de la mission est accomplie :

[   1.0530075] root on ld0a dumps on ld0b
[   1.0530075] root file system type: ext2fs
[   1.0530075] kern.module.path=/stand/i386/9.3/modules

6. Vibrante communauté

« UNE LONGUE MINUTE ??? ». Cela n’a pas tardé avant que je reçoive de très appréciables contributions qui accélèrent le « nettoyage » de notre kernel, la première arriva sous la forme d’un outil tierce-partie dont l’auteur est un certain « Lefinnois », aka Denis Bodor, connu des autorités locales, me semble-t-il.

confkendev [4], c’est le nom de l’outil, parcourt également le noyau et réalise finalement la même opération que nous réalisons avec gdb, mais de façon bien plus élégante. En effet, le programme charge l’objet ELF noyau et itère sur la structure cfdata, et non pas seulement sa taille, à la recherche de pilotes à désactiver, qu’on peut passer en paramètre ou lister dans un fichier. Et plutôt qu’en une minute, ce programme s’exécute en :

$ time confkerndev/confkerndevi386 -v -i netbsd-SMOL -K virtio.list -w
12 driver names loaded from virtio.list.
 
real    0m0.002s
user    0m0.000s
sys     0m0.001s

Ah, oui, c’est un peu mieux (toussote).

La seconde contribution est un pull request [5] sur la page du projet [6] qu’à l’heure où j’écris ces lignes je n’ai pas encore intégré, car il va demander quelques adaptations. Ce dernier est en effet un programme en Python qui réalise globalement la même chose que notre boucle gdb et constitue un joli morceau de code pour la manipulation d’objets ELF, j’aimerais donc l’importer pour laisser à l’utilisateur le choix des outils, mais aussi pour diffuser cet exemple de modification de binaire ELF.

L’étude de ces deux solutions, bien que passionnantes, ferait l’objet d’un article de 4 pages à elle toute seule, nous ne creuserons donc pas plus bas leur fonctionnement, mais s’il vous taraude de connaître la réponse à l’angoissante question « c’est quoi un programme ? », je vous suggère fortement la lecture du code source de ces deux merveilles… munis d’une introduction au format ELF, si cette notion vous est étrangère.

À l’aide de ces contributions, la réduction de la taille d’un noyau NetBSD ne prend finalement que quelques millisecondes, pas de recompilation, et intégration facile dans un script, ou encore mieux, un Makefile.

7. Et sinon, on boot sur quoi ?

> Par contre iMil, essaye de faire un peu plus court pour les prochains.

> Ah ah oui oui t’inquiète pas, j’ai un sujet assez « droit au but là », tu vas voir...

Je viens de compter le nombre de signes de cet article et si je n’arrête pas bientôt, il sera plus long que le dernier. Qui était plus long que celui d’avant. Mais comment voulez-vous que j’explique ce tour de passe-passe en moins de lignes !

Alors oui, il y aura un deuxième épisode, parce que c’est bien joli de booter un noyau en quelques centaines de millisecondes, mais l’intérêt dépasse le simple challenge technique : comme je le teasais dans la 2e partie de « La Grande Migration » (LP n°138), j’envisage de faire tourner un service par micro-VM, une sorte de « serverless » articulée sur le même principe que le projet Firecracker [7] d’AWS. Notez que ce projet fait d’ailleurs l’objet d’un travail colossal effectué par Colin Percival [8] visant à faire booter FreeBSD dans cet environnement. Nous n’en sommes pas encore là.

Lors du prochain épisode, nous allons réaliser un de mes sports favoris : créer un système UNIX à partir de rien : From. Scratch.

Bon, ça y est, il est plus long :(.

Références

[1] https://en.wikipedia.org/wiki/Berkeley_Software_Distribution

[2] https://wiki.osdev.org/Virtio

[3] https://github.com/longld/peda

[4] https://gitlab.com/0xDRRB/confkerndev

[5] https://gitlab.com/iMil/mksmolnb/-/merge_requests/5

[6] https://gitlab.com/iMil/mksmolnb

[7] https://firecracker-microvm.github.io

[8] https://en.wikipedia.org/wiki/Colin_Percival



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous