Qu'est-ce qu'un bootloader ?
Le Bootloader est le premier segment de code exécuté dans un système embarqué après la mise sous tension. Une fois l'initialisation du CPU et du matériel concerné terminée, il charge l'image du système d'exploitation ou le programme d'application embarqué dans la mémoire, puis passe à l'espace où réside le système d'exploitation, initiant ainsi le fonctionnement de ce dernier. Ce processus est fondamental pour toute personne impliquée dans la programmation de microcontrôleurs spécifiques tels que le STM32.
Semblable à un programme d'application, un bootloader est un programme autonome qui contient des composants essentiels tels que le code de démarrage, les interruptions, un programme principal (fonction Boot_main) et, en option, un système d'exploitation. Malgré sa petite taille, le bootloader englobe des fonctionnalités critiques.
Les bootloaders dépendent généralement fortement du matériel et sont particulièrement importants dans le domaine des systèmes embarqués. Par conséquent, il est pratiquement impossible de créer un bootloader universellement applicable dans le domaine des systèmes embarqués. Néanmoins, nous pouvons encore généraliser certains concepts relatifs aux bootloaders afin de guider les utilisateurs dans la conception et la mise en œuvre de bootloaders spécifiques.
Modes de fonctionnement d'un chargeur d'amorçage
La plupart des chargeurs d'amorçage comprennent deux modes de fonctionnement distincts : le mode d'amorçage et le mode de téléchargement.
Au démarrage, le chargeur d'amorçage initialise l'environnement logiciel et matériel du système et sélectionne l'un des modes de fonctionnement en fonction des conditions matérielles actuelles. Cela implique la configuration du mode de fonctionnement du processeur, l'initialisation de la mémoire, la désactivation des interruptions et la gestion de tâches telles que la désactivation du MMU/cache.
Mode de chargement du démarrage :
Ce mode, également appelé mode « autonome », consiste pour le chargeur d'amorçage à charger de manière autonome le système d'exploitation depuis un périphérique de stockage SSD sur la machine cible vers la mémoire RAM. L'ensemble de ce processus s'effectue sans aucune intervention de l'utilisateur. Ce mode correspond au fonctionnement normal du chargeur d'amorçage.
Mode de téléchargement :
Dans ce mode, le chargeur d'amorçage de la machine cible initie la communication via des moyens tels que des connexions série ou réseau avec une machine hôte afin de télécharger des fichiers. Les fichiers obtenus à partir de la machine hôte sont généralement stockés dans la mémoire RAM de la machine cible par le chargeur d'amorçage avant d'être écrits dans la mémoire Flash ou un dispositif de stockage à semi-conducteurs similaire sur la machine cible.
Comment fonctionne un chargeur d'amorçage ?
Il existe deux types de processus de démarrage pour un chargeur d'amorçage : à étape unique et à étapes multiples. En général, les chargeurs d'amorçage multi-étapes possèdent des fonctionnalités plus complexes et une portabilité améliorée. Les chargeurs d'amorçage qui démarrent à partir de périphériques de stockage à semi-conducteurs utilisent souvent un processus en deux étapes, divisé en étape 1 et étape 2 : l'étape 1 effectue l'initialisation du matériel, prépare l'espace mémoire pour l'étape 2, copie l'étape 2 dans la mémoire, configure la pile, puis passe à l'étape 2.
Étape 1 du chargeur d'amorçage
Initialisation du périphérique matériel
- Désactiver toutes les interruptions : la gestion des interruptions relève généralement de la responsabilité des pilotes de périphériques du système d'exploitation, de sorte que le chargeur d'amorçage peut ignorer les réponses aux interruptions tout au long de son exécution. Le masquage des interruptions peut être réalisé en modifiant le registre de masque d'interruption ou le registre d'état du processeur (tel que le registre CPSR d'ARM).
- Définir la vitesse du processeur et la fréquence d'horloge.
- Initialiser la RAM : cela comprend la configuration correcte des registres de fonction du contrôleur de mémoire du système et des différents registres de contrôle des banques de mémoire.
- Initialisation des LED : les LED sont souvent pilotées par GPIO pour indiquer l'état du système (OK ou erreur). En l'absence de LED, l'initialisation de l'UART pour imprimer le logo du chargeur d'amorçage ou des informations sur les caractères via une communication série peut servir à cette fin.
- Désactiver le cache interne d'instructions/de données du CPU.
Préparez l'espace RAM pour le chargement du chargeur d'amorçage étape 2
Pour une exécution plus rapide, la phase 2 est généralement chargée dans la mémoire RAM. Par conséquent, une plage de mémoire disponible doit être allouée pour le chargement de la phase 2 du chargeur d'amorçage.
Étant donné que la phase 2 contient généralement du code en langage C, l'espace requis doit tenir compte à la fois de la taille exécutable de la phase 2 et de l'espace de pile. De plus, l'espace doit de préférence être aligné sur la taille de la page mémoire (généralement 4 Ko). En général, 1 Mo d'espace RAM est suffisant. La plage d'adresses spécifique peut être choisie arbitrairement. Par exemple, une approche courante consiste à allouer l'image exécutable de la phase 2 pour qu'elle s'exécute dans un espace de 1 Mo à partir de l'adresse de base de la RAM système 0xc0200000. Cependant, il est recommandé d'allouer la phase 2 à la partie supérieure de 1 Mo de l'espace RAM total (c'est-à-dire (RamEnd-1Mo) – RamEnd).
Désignons la taille de la plage d'espace RAM allouée par « stage2_size » (en octets) et les adresses de début et de fin par « stage2_start » et « stage2_end » (les deux adresses étant alignées sur des limites de 4 octets). Ainsi :
stage2_end = stage2_start + stage2_size
De plus, il est impératif de s'assurer que la plage d'adresses allouée est bien un espace RAM inscriptible et lisible. Pour cela, il est nécessaire de tester la plage d'adresses allouée. Une méthode de test appropriée, comme celle utilisée par « blob », consiste à tester la capacité de lecture-écriture des deux premiers mots de chaque page mémoire.
Copier la phase 2 du chargeur d'amorçage dans l'espace RAM
Pour ce faire, veuillez vous assurer de deux points :
- L'emplacement de l'image exécutable de la phase 2 sur le périphérique de stockage SSD.
- L'adresse de départ de l'espace RAM.
Définir le pointeur de pile (SP)
Le réglage du pointeur de pile (sp) prépare l'exécution du code en langage C. En général, la valeur de sp peut être réglée sur (stage2_end-4), ce qui représente l'extrémité supérieure de l'espace RAM de 1 Mo alloué dans la section 3.1.2 (la pile s'étend vers le bas).
De plus, avant de définir le pointeur de pile, il est possible de désactiver la LED afin de signaler aux utilisateurs que le passage à l'étape 2 est imminent.
À la suite de ces étapes d'exécution, la disposition de la mémoire physique du système devrait ressembler au schéma ci-dessous.

Aller au point d'entrée C de stage2
Une fois que tout ce qui précède est prêt, vous pouvez passer à la phase 2 du chargeur d'amorçage pour l'exécuter. Par exemple, sur les systèmes ARM, cela peut être fait en modifiant le registre PC à l'adresse appropriée. La disposition de la mémoire système lorsque l'image exécutable de la phase 2 du chargeur d'amorçage vient d'être copiée dans l'espace RAM est illustrée dans la figure ci-dessus.
Étape 2 du chargeur d'amorçage
Initialisation du périphérique matériel
- Initialisez au moins un port série pour la communication E/S avec les utilisateurs finaux.
- Initialisez les minuteries et autres composants matériels.
Avant d'initialiser ces périphériques, il est également possible d'allumer la LED pour indiquer le début de l'exécution de la fonction main(). Après l'initialisation des périphériques, certaines informations telles que les chaînes de caractères du nom du programme et les numéros de version peuvent être affichées.
Détection de la cartographie mémoire du système
Le mappage mémoire désigne l'allocation de plages d'adresses dans l'espace d'adressage physique total de 4 Go pour adresser les unités RAM du système. Par exemple, dans le processeur SA-1100, un espace d'adressage de 512 Mo commençant à 0xC000,0000 sert d'espace d'adressage RAM du système. Dans le cas du processeur Samsung S3C44B0X, un espace d'adressage de 64 Mo compris entre 0x0c00,0000 et 0x1000,0000 est utilisé pour l'espace d'adressage RAM du système. Bien que les processeurs réservent généralement une partie importante de l'espace d'adressage pour la RAM du système, tout l'espace d'adressage RAM réservé n'est pas nécessairement utilisé lors de la construction de systèmes embarqués spécifiques. Ainsi, les systèmes embarqués ne mappent souvent qu'une partie de l'espace d'adressage RAM réservé du processeur aux unités RAM, laissant une partie de l'espace d'adressage RAM réservé inutilisée. Compte tenu de ce fait, la phase 2 du chargeur d'amorçage doit examiner l'ensemble du mappage mémoire du système avant de tenter toute action (telle que la lecture d'une image du noyau stockée dans la mémoire flash vers l'espace RAM). Il doit savoir quelles parties de l'espace d'adressage RAM réservé du processeur sont réellement mappées aux unités d'adresse RAM et lesquelles sont dans un état « inutilisé ».
Description du mappage mémoire
La structure de données suivante peut être utilisée pour décrire une plage d'adresses continue dans l'espace d'adressage RAM :
typedef struct memory_area_struct {
u32 start; /* the base address of the memory region */
u32 size; /* the byte number of the memory region */
int used;
} memory_area_t;
Ces plages d'adresses contiguës dans l'espace d'adressage RAM peuvent se trouver dans l'un des deux états suivants :
- used=1 indique que la plage d'adresses contiguës a été implémentée et est réellement mappée sur des unités de RAM.
- used=0 indique que la plage d'adresses contiguës n'est pas implémentée dans le système et reste inutilisée.
Sur la base de la structure de données memory_area_t décrite ci-dessus, l'ensemble de l'espace d'adressage RAM réservé au CPU peut être représenté par un tableau de type memory_area_t, comme indiqué ci-dessous :
memory_area_t memory_map[NUM_MEM_AREAS] = {
[0 ... (NUM_MEM_AREAS - 1)] = {
.start = 0,
.size = 0,
.used = 0
},
};
Détection de mappage mémoire
Voici un algorithme simple mais efficace pour détecter la situation de mappage mémoire dans l'ensemble de l'espace d'adressage RAM :
/* Initialize the array */
for(i = 0; i < NUM_MEM_AREAS; i++)
memory_map[i].used = 0;
/* Write 0 to all memory locations */
for(addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE)
*(u32 *)addr = 0;
for(i = 0, addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE) {
/*
* Check whether the address space starting from base address
* MEM_START + i * PAGE_SIZE, with a size of PAGE_SIZE, is a valid RAM address space.
*/
Call the algorithm test_mempage() from section 3.1.2;
if (current memory page is not a valid RAM page) {
/* no RAM here */
if (memory_map[i].used )
i++;
continue;
}
/*
* The current page is a valid address range mapped to RAM.
* However, we need to determine if it's an alias of some address page within the 4GB address space.
*/
if (*(u32 *)addr != 0) { /* alias? */
/* This memory page is an alias of an address page within the 4GB address space. */
if (memory_map[i].used )
i++;
continue;
}
/*
* The current page is a valid address range mapped to RAM,
* and it's not an alias of an address page within the 4GB address space.
*/
if (memory_map[i].used == 0) {
memory_map[i].start = addr;
memory_map[i].size = PAGE_SIZE;
memory_map[i].used = 1;
} else {
memory_map[i].size += PAGE_SIZE;
}
} /* end of for (…) */
Lors de l'exécution de l'algorithme ci-dessus pour détecter l'état du mappage mémoire du système, le chargeur d'amorçage peut également imprimer des informations détaillées sur le mappage mémoire sur le port série.
Chargement des images du noyau et du système de fichiers racine
Planification de la disposition de la mémoire
Cela implique deux aspects :
- la plage mémoire occupée par l'image du noyau ;
- la plage mémoire occupée par le système de fichiers racine. Lors de la planification de la disposition de la mémoire, tenez compte de l'adresse de base et de la taille des images.
Pour l'image du noyau, il est courant de la copier dans une plage mémoire commençant à (MEM_START + 0x8000), d'une taille d'environ 1 Mo (les noyaux Linux embarqués font généralement moins de 1 Mo). Pourquoi laisser un espace de 32 Ko entre MEM_START et MEM_START + 0x8000 ? C'est parce que le noyau Linux place certaines structures de données globales dans ce segment de mémoire, telles que les paramètres de démarrage et les tables de pages du noyau.
L'image du système de fichiers racine est généralement copiée à l'emplacement commençant à MEM_START + 0x0010,0000. Si vous utilisez un disque RAM comme image du système de fichiers racine, la taille non compressée est généralement d'environ 1 Mo.
Copier depuis Flash
Étant donné que les processeurs embarqués tels que ARM adressent généralement les mémoires Flash et autres périphériques de stockage SSD dans un espace d'adressage mémoire unifié, la lecture des données depuis une mémoire Flash s'apparente à la lecture depuis des unités RAM. Une simple boucle suffit pour copier l'image depuis le périphérique Flash :
while (count) {
*dest++ = *src++; /* they are all aligned with word boundary */
count -= 4; /* byte number */
};
Définition des paramètres de démarrage du noyau
Après avoir copié l'image du noyau et l'image du système de fichiers racine dans l'espace RAM, le démarrage du noyau Linux peut être préparé. Mais avant d'invoquer le noyau, une étape de préparation est nécessaire : la configuration des paramètres de démarrage du noyau Linux.
Les noyaux Linux à partir de la version 2.4.x attendent que les paramètres de démarrage soient transmis sous la forme d'une liste balisée. La liste balisée des paramètres de démarrage commence par la balise ATAG_CORE et se termine par la balise ATAG_NONE. Chaque balise se compose d'une structure tag_header identifiant le paramètre, suivie de structures de données contenant les valeurs des paramètres. Les structures de données tag et tag_header sont définies dans le fichier d'en-tête include/asm/setup.h du code source du noyau Linux.
Dans les systèmes Linux embarqués, les paramètres de démarrage courants qui doivent être définis par le chargeur d'amorçage comprennent ATAG_CORE, ATAG_MEM, ATAG_CMDLINE, ATAG_RAMDISK et ATAG_INITRD.
Voici, par exemple, comment définir ATAG_CORE :
params = (struct tag *)BOOT_PARAMS;
params->hdr.tag = ATAG_CORE;
params->hdr.size = tag_size(tag_core);
params->u.core.flags = 0;
params->u.core.pagesize = 0;
params->u.core.rootdev = 0;
params = tag_next(params);
Ici, BOOT_PARAMS représente l'adresse de base de démarrage des paramètres de démarrage du noyau dans la mémoire, et le pointeur params est de type struct tag. La macro tag_next() calcule l'adresse de démarrage de la balise suivante immédiatement après la balise actuelle. Il est important de noter que l'ID du périphérique pour le système de fichiers racine du noyau est défini ici.
Vous trouverez ci-dessous un exemple de code permettant de définir les informations de mappage mémoire :
for (i = 0; i < NUM_MEM_AREAS; i++) {
if (memory_map[i].used) {
params->hdr.tag = ATAG_MEM;
params->hdr.size = tag_size(tag_mem32);
params->u.mem.start = memory_map[i].start;
params->u.mem.size = memory_map[i].size;
params = tag_next(params);
}
}
Dans le tableau memory_map[], chaque segment de mémoire valide correspond à une balise de paramètre ATAG_MEM.
Le noyau Linux peut recevoir des informations sous forme de paramètres de ligne de commande lors du démarrage. Cela nous permet de fournir des informations sur les paramètres matériels que le noyau ne peut pas détecter lui-même ou de remplacer les informations que le noyau a détectées. Par exemple, nous utilisons la chaîne de paramètres de ligne de commande « console=ttyS0,115200n8 » pour indiquer au noyau d'utiliser ttyS0 comme console avec les paramètres « 115200bps, pas de parité, 8 bits de données ». Voici un exemple de code pour définir la chaîne de paramètres de ligne de commande du noyau :
char *p;
for (p = commandline; *p == ' '; p++)
;
if (*p == '\0')
return;
params->hdr.tag = ATAG_CMDLINE;
params->hdr.size = (sizeof(struct tag_header) + strlen(p) + 1 + 4) >> 2;
strcpy(params->u.cmdline.cmdline, p);
params = tag_next(params);
Notez que dans le code ci-dessus, lors du paramétrage de la taille de tag_header, celle-ci doit inclure le caractère de fin « \0 » dans la chaîne et être arrondie au multiple de 4 octets le plus proche, car le membre size de la structure tag_header représente le nombre de mots.
Vous trouverez ci-dessous un exemple de code pour définir ATAG_INITRD, indiquant l'emplacement dans la RAM où se trouve l'image initrd (au format compressé), ainsi que sa taille :
params->hdr.tag = ATAG_INITRD2;
params->hdr.size = tag_size(tag_initrd);
params->u.initrd.start = RAMDISK_RAM_BASE;
params->u.initrd.size = INITRD_LEN;
params = tag_next(params);
Enfin, définissez la balise ATAG_NONE pour conclure la liste complète des paramètres de démarrage :
static void setup_end_tag(void) {
params->hdr.tag = ATAG_NONE;
params->hdr.size = 0;
}
Appel du noyau
Le chargeur d'amorçage appelle le noyau Linux en sautant directement à la première instruction du noyau, c'est-à-dire en sautant directement à l'adresse MEM_START + 0x8000. Les conditions suivantes doivent être remplies lors du saut :
- Paramètres du registre CPU :
R0 = 0
R1 = ID du type de machine (pour le numéro de type de machine, reportez-vous à linux/arch/arm/tools/mach-types)
R2 = Adresse de base de départ de la liste des paramètres de démarrage dans la RAM - Mode CPU :
désactiver les interruptions (IRQ et FIQ)
Le CPU doit être en mode SVC - Paramètres du cache et du MMU :
le MMU doit être désactivé Le cache
d'instructions peut être activé ou désactivé Le cache
de données doit être désactivé
Si vous utilisez C, vous pouvez invoquer le noyau comme suit :
void (*theKernel)(int zero, int arch, u32 params_addr) =
(void (*)(int, int, u32))KERNEL_RAM_BASE;
theKernel(0, ARCH_NUMBER, (u32)kernel_params_start);
Notez que l'appel de fonction à theKernel() ne doit jamais renvoyer de résultat. S'il renvoie un résultat, cela signifie qu'une erreur s'est produite.
Chargeur d'amorçage pour système embarqué vs chargeur d'amorçage pour PC
Dans les architectures PC, le chargeur d'amorçage se compose du BIOS (essentiellement un micrologiciel) et du chargeur d'amorçage du système d'exploitation situé dans le MBR du disque dur (par exemple, LILO, GRUB). Une fois que le BIOS a terminé la détection du matériel et l'allocation des ressources, il charge le chargeur d'amorçage depuis le MBR du disque dur dans la mémoire RAM du système et transfère le contrôle au chargeur d'amorçage du système d'exploitation. La tâche principale du chargeur d'amorçage consiste à lire l'image du noyau depuis le disque dur dans la mémoire RAM, puis à passer au point d'entrée du noyau pour démarrer le système d'exploitation.
Dans les systèmes embarqués, il n'y a généralement pas de programme firmware tel que le BIOS (bien que certains processeurs embarqués puissent inclure un petit programme de démarrage embarqué). Par conséquent, l'ensemble des tâches de chargement et de démarrage du système est effectué par le chargeur d'amorçage. Par exemple, dans un système embarqué basé sur le cœur ARM7TDMI, le système commence généralement l'exécution à l'adresse 0x00000000 lors de la mise sous tension ou de la réinitialisation, et cette adresse contient généralement le programme du chargeur d'amorçage du système.
CPU et carte embarquée pris en charge par le chargeur d'amorçage
Chaque architecture de processeur différente dispose d'un chargeur d'amorçage différent. Certains chargeurs d'amorçage prennent également en charge des processeurs dotés de plusieurs architectures. Par exemple, U-Boot prend en charge à la fois l'architecture ARM et l'architecture MIPS. Outre l'architecture du processeur, le chargeur d'amorçage dépend également de la configuration de certains périphériques embarqués au niveau de la carte. En d'autres termes, le chargeur d'amorçage n'est pas nécessairement adapté aux deux cartes embarquées différentes, même si elles sont construites à partir du même processeur.
Support d'installation du chargeur d'amorçage
Lorsqu'un système démarre ou se réinitialise, les processeurs récupèrent généralement les instructions à partir d'une adresse prédéterminée définie par le fabricant du processeur. Par exemple, les processeurs basés sur le cœur ARM7TDMI récupèrent généralement leur première instruction à partir de l'adresse 0x00000000 après une réinitialisation. Les systèmes embarqués basés sur des architectures de processeurs mappent souvent un certain type de périphérique de stockage à semi-conducteurs (tel que ROM, EPROM ou FLASH) à cette adresse prédéterminée. Par conséquent, après la mise sous tension du système, le processeur exécute d'abord le programme du chargeur d'amorçage.



