Exemples exécutables
Créons et exécutons de minuscules programmes hello world bare metal qui s'exécutent sans système d'exploitation sur:
Nous les testerons également sur l'émulateur QEMU autant que possible, car cela est plus sûr et plus pratique pour le développement. Les tests QEMU ont été effectués sur un hôte Ubuntu 18.04 avec QEMU 2.11.1 préemballé.
Le code de tous les exemples x86 ci-dessous et plus est présent sur ce dépôt GitHub .
Comment exécuter les exemples sur du matériel réel x86
N'oubliez pas que l'exécution d'exemples sur du matériel réel peut être dangereuse, par exemple, vous pouvez effacer votre disque ou brique le matériel par erreur: ne faites cela que sur de vieilles machines qui ne contiennent pas de données critiques! Ou encore mieux, utilisez des devboards semi-jetables bon marché tels que le Raspberry Pi, voir l'exemple ARM ci-dessous.
Pour un ordinateur portable x86 typique, vous devez faire quelque chose comme:
Gravez l'image sur une clé USB (cela détruira vos données!):
sudo dd if=main.img of=/dev/sdX
branchez l'USB sur un ordinateur
allume ça
dites-lui de démarrer à partir de l'USB.
Cela signifie que le micrologiciel doit choisir USB avant le disque dur.
Si ce n'est pas le comportement par défaut de votre machine, continuez à appuyer sur Entrée, F12, ESC ou d'autres clés étranges après la mise sous tension jusqu'à ce que vous obteniez un menu de démarrage où vous pouvez choisir de démarrer à partir de l'USB.
Il est souvent possible de configurer l'ordre de recherche dans ces menus.
Par exemple, sur mon T430, je vois ce qui suit.
Après avoir allumé, c'est quand je dois appuyer sur Entrée pour entrer dans le menu de démarrage:
Ensuite, ici, je dois appuyer sur F12 pour sélectionner l'USB comme périphérique de démarrage:
De là, je peux sélectionner l'USB comme périphérique de démarrage comme ceci:
Alternativement, pour changer l'ordre de démarrage et choisir l'USB pour avoir une priorité plus élevée afin de ne pas avoir à le sélectionner manuellement à chaque fois, je frapperais F1 sur l'écran "Menu d'interruption de démarrage", puis naviguer vers:
Secteur de démarrage
Sur x86, la chose la plus simple et la plus basse que vous pouvez faire est de créer un secteur d'amorçage principal (MBR) , qui est un type de secteur d'amorçage , puis de l'installer sur un disque.
Ici, nous en créons un avec un seul printf
appel:
printf '\364%509s\125\252' > main.img
sudo apt-get install qemu-system-x86
qemu-system-x86_64 -hda main.img
Résultat:
Notez que même sans rien faire, quelques caractères sont déjà imprimés à l'écran. Ceux-ci sont imprimés par le micrologiciel et servent à identifier le système.
Et sur le T430, nous obtenons simplement un écran vide avec un curseur clignotant:
main.img
contient les éléments suivants:
\364
en octal == 0xf4
en hex: l'encodage d'une hlt
instruction, qui indique au CPU de cesser de fonctionner.
Par conséquent, notre programme ne fera rien: seulement démarrer et arrêter.
Nous utilisons octal car les \x
nombres hexadécimaux ne sont pas spécifiés par POSIX.
Nous pourrions obtenir cet encodage facilement avec:
echo hlt > a.S
as -o a.o a.S
objdump -S a.o
qui génère:
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <.text>:
0: f4 hlt
mais il est également documenté dans le manuel d'Intel.
%509s
produire 509 espaces. Nécessaire pour remplir le fichier jusqu'à l'octet 510.
\125\252
en octal == 0x55
suivi de 0xaa
.
Ce sont 2 octets magiques requis qui doivent être les octets 511 et 512.
Le BIOS parcourt tous nos disques à la recherche de ceux amorçables, et il ne considère que ceux amorçables qui ont ces deux octets magiques.
S'il n'est pas présent, le matériel ne le traitera pas comme un disque amorçable.
Si vous n'êtes pas un printf
maître, vous pouvez confirmer le contenu de main.img
avec:
hd main.img
qui montre les attentes:
00000000 f4 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 |. |
00000010 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 20 | |
*
000001f0 20 20 20 20 20 20 20 20 20 20 20 20 20 20 55 aa | U.|
00000200
où 20
est un espace en ASCII.
Le micrologiciel du BIOS lit ces 512 octets à partir du disque, les met en mémoire et définit le PC sur le premier octet pour commencer à les exécuter.
Secteur de démarrage Hello World
Maintenant que nous avons créé un programme minimal, passons à un monde bonjour.
La question évidente est: comment faire IO? Quelques options:
demandez au firmware, par exemple BIOS ou UEFI, de le faire pour nous
VGA: région de mémoire spéciale qui est imprimée à l'écran si elle est écrite. Peut être utilisé en mode protégé.
écrire un pilote et parler directement au matériel d'affichage. C'est la manière "correcte" de le faire: plus puissante, mais plus complexe.
port série . Il s'agit d'un protocole standardisé très simple qui envoie et reçoit des caractères d'un terminal hôte.
Sur les ordinateurs de bureau, cela ressemble à ceci:
Source .
Il n'est malheureusement pas exposé sur la plupart des ordinateurs portables modernes, mais c'est la voie à suivre pour les cartes de développement, voir les exemples ARM ci-dessous.
C'est vraiment dommage, car de telles interfaces sont vraiment utiles pour déboguer le noyau Linux par exemple .
utiliser les fonctionnalités de débogage des puces. ARM appelle leur semi-hébergeur par exemple. Sur le vrai matériel, cela nécessite un support matériel et logiciel supplémentaire, mais sur les émulateurs, cela peut être une option pratique gratuite. Exemple .
Ici, nous allons faire un exemple de BIOS car c'est plus simple sur x86. Mais notez que ce n'est pas la méthode la plus robuste.
main.S
.code16
mov $msg, %si
mov $0x0e, %ah
loop:
lodsb
or %al, %al
jz halt
int $0x10
jmp loop
halt:
hlt
msg:
.asciz "hello world"
GitHub en amont .
link.ld
SECTIONS
{
/* The BIOS loads the code from the disk to this location.
* We must tell that to the linker so that it can properly
* calculate the addresses of symbols we might jump to.
*/
. = 0x7c00;
.text :
{
__start = .;
*(.text)
/* Place the magic boot bytes at the end of the first 512 sector. */
. = 0x1FE;
SHORT(0xAA55)
}
}
Assembler et lier avec:
as -g -o main.o main.S
ld --oformat binary -o main.img -T link.ld main.o
qemu-system-x86_64 -hda main.img
Résultat:
Et sur le T430:
Testé sur: Lenovo Thinkpad T430, UEFI BIOS 1.16. Disque généré sur un hôte Ubuntu 18.04.
Outre les instructions de montage standard de l'utilisateur, nous avons:
.code16
: indique à GAS de sortir du code 16 bits
cli
: désactiver les interruptions logicielles. Ceux-ci pourraient faire redémarrer le processeur après lahlt
int $0x10
: effectue un appel BIOS. C'est ce qui imprime les caractères un par un.
Les indicateurs de lien importants sont:
--oformat binary
: sortie du code d'assemblage binaire brut, ne l'enveloppez pas dans un fichier ELF comme c'est le cas pour les exécutables standard de l'espace utilisateur.
Pour mieux comprendre la partie script de l'éditeur de liens, familiarisez-vous avec l'étape de relocalisation de la liaison: que font les éditeurs de liens?
Programmes de métal nu Cooler x86
Voici quelques configurations de métal nu plus complexes que j'ai réalisées:
Utilisez C au lieu de l'assemblage
Résumé: utilisez GRUB multiboot, qui résoudra beaucoup de problèmes ennuyeux auxquels vous n'avez jamais pensé. Voir la section ci-dessous.
La principale difficulté sur x86 est que le BIOS ne charge que 512 octets du disque vers la mémoire, et vous risquez de faire exploser ces 512 octets lorsque vous utilisez C!
Pour résoudre ce problème, nous pouvons utiliser un chargeur de démarrage en deux étapes . Cela fait d'autres appels BIOS, qui chargent plus d'octets du disque dans la mémoire. Voici un exemple d'assemblage minimal de l'étape 2 à partir de zéro en utilisant les appels BIOS int 0x13 :
Alternativement:
- si vous en avez seulement besoin pour fonctionner dans QEMU mais pas dans du vrai matériel, utilisez l'
-kernel
option, qui charge un fichier ELF entier en mémoire. Voici un exemple ARM que j'ai créé avec cette méthode .
- pour le Raspberry Pi, le firmware par défaut s'occupe du chargement de l'image pour nous à partir d'un fichier ELF nommé
kernel7.img
, tout comme le fait QEMU -kernel
.
À des fins éducatives uniquement, voici un exemple de C minimal en une étape :
principal c
void main(void) {
int i;
char s[] = {'h', 'e', 'l', 'l', 'o', ' ', 'w', 'o', 'r', 'l', 'd'};
for (i = 0; i < sizeof(s); ++i) {
__asm__ (
"int $0x10" : : "a" ((0x0e << 8) | s[i])
);
}
while (1) {
__asm__ ("hlt");
};
}
entrée.S
.code16
.text
.global mystart
mystart:
ljmp $0, $.setcs
.setcs:
xor %ax, %ax
mov %ax, %ds
mov %ax, %es
mov %ax, %ss
mov $__stack_top, %esp
cld
call main
linker.ld
ENTRY(mystart)
SECTIONS
{
. = 0x7c00;
.text : {
entry.o(.text)
*(.text)
*(.data)
*(.rodata)
__bss_start = .;
/* COMMON vs BSS: /programming/16835716/bss-vs-common-what-goes-where */
*(.bss)
*(COMMON)
__bss_end = .;
}
/* /programming/53584666/why-does-gnu-ld-include-a-section-that-does-not-appear-in-the-linker-script */
.sig : AT(ADDR(.text) + 512 - 2)
{
SHORT(0xaa55);
}
/DISCARD/ : {
*(.eh_frame)
}
__stack_bottom = .;
. = . + 0x1000;
__stack_top = .;
}
courir
set -eux
as -ggdb3 --32 -o entry.o entry.S
gcc -c -ggdb3 -m16 -ffreestanding -fno-PIE -nostartfiles -nostdlib -o main.o -std=c99 main.c
ld -m elf_i386 -o main.elf -T linker.ld entry.o main.o
objcopy -O binary main.elf main.img
qemu-system-x86_64 -drive file=main.img,format=raw
Bibliothèque standard C
Cependant, les choses deviennent plus amusantes si vous souhaitez également utiliser la bibliothèque standard C, car nous n'avons pas le noyau Linux, qui implémente une grande partie des fonctionnalités de la bibliothèque standard C via POSIX .
Quelques possibilités, sans passer par un système d'exploitation complet comme Linux, incluent:
Écrivez votre propre. C'est juste un tas d'en-têtes et de fichiers C à la fin, non? Droite??
Newlib
Exemple détaillé sur: /electronics/223929/c-standard-libraries-on-bare-metal/223931
Newlib met en œuvre toutes les choses de spécifiques de non-OS ennuyeux pour vous, par exemple memcmp
, memcpy
, etc.
Ensuite, il vous fournit quelques talons pour implémenter les appels système dont vous avez besoin vous-même.
Par exemple, nous pouvons implémenter exit()
sur ARM par semi-hébergement avec:
void _exit(int status) {
__asm__ __volatile__ ("mov r0, #0x18; ldr r1, =#0x20026; svc 0x00123456");
}
comme indiqué dans cet exemple .
Par exemple, vous pouvez rediriger printf
vers les systèmes UART ou ARM, ou implémenter exit()
avec semihosting .
systèmes d'exploitation embarqués comme FreeRTOS et Zephyr .
De tels systèmes d'exploitation vous permettent généralement de désactiver la planification préventive, vous donnant ainsi un contrôle total sur l'exécution du programme.
Ils peuvent être vus comme une sorte de Newlib pré-implémenté.
Multiboot GNU GRUB
Les secteurs de démarrage sont simples, mais ils ne sont pas très pratiques:
- vous ne pouvez avoir qu'un seul système d'exploitation par disque
- le code de chargement doit être vraiment petit et tenir en 512 octets
- vous devez faire beaucoup de démarrage vous-même, comme passer en mode protégé
C'est pour ces raisons que GNU GRUB a créé un format de fichier plus pratique appelé multiboot.
Exemple de fonctionnement minimal: https://github.com/cirosantilli/x86-bare-metal-examples/tree/d217b180be4220a0b4a453f31275d38e697a99e0/multiboot/hello-world
Je l'utilise également sur mon repo d'exemples GitHub pour pouvoir exécuter facilement tous les exemples sur du matériel réel sans graver l'USB un million de fois.
Résultat de l'UEMQ:
T430:
Si vous préparez votre système d'exploitation en tant que fichier multiboot, GRUB est alors en mesure de le trouver dans un système de fichiers standard.
C'est ce que font la plupart des distributions, en plaçant les images du système d'exploitation sous /boot
.
Les fichiers multiboot sont essentiellement un fichier ELF avec un en-tête spécial. Ils sont spécifiés par GRUB à: https://www.gnu.org/software/grub/manual/multiboot/multiboot.html
Vous pouvez transformer un fichier multiboot en un disque amorçable avec grub-mkrescue
.
Firmware
En vérité, votre secteur de démarrage n'est pas le premier logiciel qui s'exécute sur le processeur du système.
Ce qui s'exécute en premier est le soi-disant firmware , qui est un logiciel:
- fabriqué par les fabricants de matériel
- source généralement fermée mais probablement basée sur C
- stocké dans une mémoire morte, et donc plus difficile / impossible à modifier sans le consentement du vendeur.
Les firmwares bien connus incluent:
- BIOS : ancien firmware x86 omniprésent. SeaBIOS est l'implémentation open source par défaut utilisée par QEMU.
- UEFI : successeur du BIOS, mieux standardisé, mais plus performant et incroyablement gonflé.
- Coreboot : la tentative open source de Noble Cross Arch
Le firmware fait des choses comme:
boucle sur chaque disque dur, USB, réseau, etc. jusqu'à ce que vous trouviez quelque chose d'amorçable.
Quand nous courons QEMU, -hda
dit que main.img
est un disque dur connecté au matériel, et hda
est le premier à être jugé, et il est utilisé.
charger les 512 premiers octets à l'adresse de mémoire RAM 0x7c00
, y mettre le RIP du CPU et le laisser fonctionner
afficher des choses comme le menu de démarrage ou les appels d'impression du BIOS sur l'écran
Le micrologiciel offre des fonctionnalités de type OS dont dépendent la plupart des OS. Par exemple, un sous-ensemble Python a été porté pour fonctionner sur BIOS / UEFI: https://www.youtube.com/watch?v=bYQ_lq5dcvM
On peut affirmer que les firmwares sont indiscernables des OS, et que le firmware est la seule "vraie" programmation bare metal que l'on puisse faire.
Comme le dit ce développeur CoreOS :
La partie difficile
Lorsque vous allumez un PC, les puces qui composent le chipset (northbridge, southbridge et SuperIO) ne sont pas encore correctement initialisées. Même si la ROM du BIOS est aussi éloignée du CPU qu'elle pourrait l'être, elle est accessible par le CPU, car elle doit l'être, sinon le CPU n'aurait aucune instruction à exécuter. Cela ne signifie pas que la ROM du BIOS est complètement mappée, généralement pas. Mais juste assez est mappé pour démarrer le processus de démarrage. Tous les autres appareils, oubliez-le.
Lorsque vous exécutez Coreboot sous QEMU, vous pouvez expérimenter avec les couches supérieures de Coreboot et les charges utiles, mais QEMU offre peu de possibilités d'expérimenter avec le code de démarrage de bas niveau. D'une part, la RAM fonctionne juste dès le départ.
État initial du BIOS
Comme beaucoup de choses dans le matériel, la normalisation est faible, et l'une des choses sur lesquelles vous ne devriez pas vous fier est l'état initial des registres lorsque votre code commence à s'exécuter après le BIOS.
Faites-vous donc une faveur et utilisez un code d'initialisation comme celui-ci: https://stackoverflow.com/a/32509555/895245
Les registres aiment %ds
et %es
ont des effets secondaires importants, vous devez donc les mettre à zéro même si vous ne les utilisez pas explicitement.
Notez que certains émulateurs sont plus agréables que le vrai matériel et vous donnent un bel état initial. Ensuite, lorsque vous exécutez sur du vrai matériel, tout se casse.
El Torito
Format pouvant être gravé sur CD: https://en.wikipedia.org/wiki/El_Torito_%28CD-ROM_standard%29
Il est également possible de produire une image hybride qui fonctionne sur ISO ou USB. Cela peut être fait avec grub-mkrescue
( exemple ), et est également fait par le noyau Linux lors de l' make isoimage
utilisation isohybrid
.
BRAS
Dans ARM, les idées générales sont les mêmes.
Il n'y a pas de firmware préinstallé semi-standardisé largement disponible comme le BIOS à utiliser pour l'IO, donc les deux types d'IO les plus simples que nous pouvons faire sont:
- série, qui est largement disponible sur les devboards
- clignoter la LED
J'ai téléchargé:
Certaines différences par rapport à x86 incluent:
IO se fait en écrivant directement aux adresses magiques, il n'y a pas d' instructions in
et out
.
Ceci est appelé IO mappé en mémoire .
pour certains matériels réels, comme le Raspberry Pi, vous pouvez ajouter le micrologiciel (BIOS) vous-même à l'image disque.
C'est une bonne chose, car cela rend la mise à jour de ce firmware plus transparente.
Ressources