Exemple de baremetal exécutable minimal Intel x86
Exemple de métal nu exécutable avec toutes les plaques chauffantes requises . Toutes les parties principales sont couvertes ci-dessous.
Testé sur Ubuntu 15.10 QEMU 2.3.0 et sur le véritable invité matériel Lenovo ThinkPad T400 .
Le Guide de programmation du système Intel Manual Volume 3 - 325384-056F septembre 2015 couvre SMP dans les chapitres 8, 9 et 10.
Tableau 8-1. "Broadcast INIT-SIPI-SIPI Sequence and Choice of Timeouts" contient un exemple qui fonctionne simplement:
MOV ESI, ICR_LOW ; Load address of ICR low dword into ESI.
MOV EAX, 000C4500H ; Load ICR encoding for broadcast INIT IPI
; to all APs into EAX.
MOV [ESI], EAX ; Broadcast INIT IPI to all APs
; 10-millisecond delay loop.
MOV EAX, 000C46XXH ; Load ICR encoding for broadcast SIPI IP
; to all APs into EAX, where xx is the vector computed in step 10.
MOV [ESI], EAX ; Broadcast SIPI IPI to all APs
; 200-microsecond delay loop
MOV [ESI], EAX ; Broadcast second SIPI IPI to all APs
; Waits for the timer interrupt until the timer expires
Sur ce code:
La plupart des systèmes d'exploitation rendront la plupart de ces opérations impossibles depuis l'anneau 3 (programmes utilisateur).
Vous devez donc écrire votre propre noyau pour jouer librement avec lui: un programme Linux utilisateur ne fonctionnera pas.
Au début, un seul processeur s'exécute, appelé le processeur d'amorçage (BSP).
Il doit réveiller les autres (appelés Processeurs d'application (AP)) par le biais d'interruptions spéciales appelées Interruptions de processeur (IPI) .
Ces interruptions peuvent être effectuées en programmant le contrôleur d'interruption programmable avancé (APIC) via le registre de commande d'interruption (ICR)
Le format de l'ICR est documenté à: 10.6 "ÉMISSION D'INTERRUPTIONS D'INTERPROCESSEUR"
L'IPI se produit dès que nous écrivons à l'ICR.
ICR_LOW est défini à 8.4.4 "Exemple d'initialisation MP" comme:
ICR_LOW EQU 0FEE00300H
La valeur magique 0FEE00300
est l'adresse mémoire de l'ICR, comme indiqué dans le tableau 10-1 "Carte d'adresse du registre APIC local"
La méthode la plus simple possible est utilisée dans l'exemple: elle configure l'ICR pour envoyer des IPI de diffusion qui sont délivrés à tous les autres processeurs à l'exception du processeur actuel.
Mais il est également possible, et recommandé par certains , d'obtenir des informations sur les processeurs via des structures de données spéciales configurées par le BIOS comme les tables ACPI ou la table de configuration MP d'Intel et de ne réveiller que celles dont vous avez besoin une par une.
XX
en 000C46XXH
code l'adresse de la première instruction que le processeur exécutera comme:
CS = XX * 0x100
IP = 0
N'oubliez pas que CS multiplie les adresses par0x10
, donc l'adresse mémoire réelle de la première instruction est:
XX * 0x1000
Donc, si par exemple XX == 1
, le processeur démarre à 0x1000
.
Nous devons ensuite nous assurer qu'il y a du code en mode réel 16 bits à exécuter à cet emplacement mémoire, par exemple avec:
cld
mov $init_len, %ecx
mov $init, %esi
mov 0x1000, %edi
rep movsb
.code16
init:
xor %ax, %ax
mov %ax, %ds
/* Do stuff. */
hlt
.equ init_len, . - init
L'utilisation d'un script de l'éditeur de liens est une autre possibilité.
Les boucles de retard sont une partie gênante pour se mettre au travail: il n'y a pas de moyen super simple de faire de telles nuits avec précision.
Les méthodes possibles incluent:
- PIT (utilisé dans mon exemple)
- HPET
- calibrer le temps d'une boucle occupée avec ce qui précède, et l'utiliser à la place
Connexe: Comment afficher un nombre à l'écran et dormir pendant une seconde avec l'assemblage DOS x86?
Je pense que le processeur initial doit être en mode protégé pour que cela fonctionne lorsque nous écrivons à l'adresse 0FEE00300H
qui est trop élevée pour 16 bits.
Pour communiquer entre les processeurs, nous pouvons utiliser un verrou tournant sur le processus principal et modifier le verrou à partir du deuxième cœur.
Nous devons nous assurer que la réécriture de la mémoire est effectuée, par exemple via wbinvd
.
État partagé entre les processeurs
8.7.1 "État des processeurs logiques" dit:
Les fonctionnalités suivantes font partie de l'état architectural des processeurs logiques des processeurs Intel 64 ou IA-32 prenant en charge la technologie Intel Hyper-Threading. Les fonctionnalités peuvent être subdivisées en trois groupes:
- Dupliqué pour chaque processeur logique
- Partagé par des processeurs logiques dans un processeur physique
- Partagé ou dupliqué, selon l'implémentation
Les fonctionnalités suivantes sont dupliquées pour chaque processeur logique:
- Registres à usage général (EAX, EBX, ECX, EDX, ESI, EDI, ESP et EBP)
- Registres de segment (CS, DS, SS, ES, FS et GS)
- Registres EFLAGS et EIP. Notez que les registres CS et EIP / RIP pour chaque processeur logique pointent vers le flux d'instructions pour le thread exécuté par le processeur logique.
- Registres FPU x87 (ST0 à ST7, mot d'état, mot de contrôle, mot d'étiquette, pointeur d'opérande de données et pointeur d'instruction)
- Registres MMX (MM0 à MM7)
- Registres XMM (XMM0 à XMM7) et registre MXCSR
- Registres de contrôle et registres de pointeur de table système (GDTR, LDTR, IDTR, registre de tâches)
- Registres de débogage (DR0, DR1, DR2, DR3, DR6, DR7) et les MSR de contrôle de débogage
- MSR d'état global de vérification de machine (IA32_MCG_STATUS) et de capacité de vérification de machine (IA32_MCG_CAP)
- Modulation d'horloge thermique et contrôle de gestion de l'alimentation ACPI MSR
- Compteur d'horodatage MSR
- La plupart des autres registres MSR, y compris la table des attributs de page (PAT). Voir les exceptions ci-dessous.
- Registres APIC locaux.
- Registres généraux supplémentaires (R8-R15), registres XMM (XMM8-XMM15), registre de contrôle, IA32_EFER sur les processeurs Intel 64.
Les fonctionnalités suivantes sont partagées par les processeurs logiques:
- Registres de plage de types de mémoire (MTRR)
Le partage ou la duplication des fonctionnalités suivantes est spécifique à l'implémentation:
- IA32_MISC_ENABLE MSR (adresse MSR 1A0H)
- MSR d'architecture de vérification de la machine (MCA) (sauf pour les MSR IA32_MCG_STATUS et IA32_MCG_CAP)
- Contrôle de la performance et contrôle des MSR
Le partage de cache est discuté à:
Les hyperthreads Intel ont plus de cache et de partage de pipeline que les cœurs séparés: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Noyau Linux 4.2
La principale action d'initialisation semble être à arch/x86/kernel/smpboot.c
.
Exemple de baremetal exécutable minimal ARM
Ici, je fournis un exemple exécutable ARMv8 aarch64 minimal pour QEMU:
.global mystart
mystart:
/* Reset spinlock. */
mov x0, #0
ldr x1, =spinlock
str x0, [x1]
/* Read cpu id into x1.
* TODO: cores beyond 4th?
* Mnemonic: Main Processor ID Register
*/
mrs x1, mpidr_el1
ands x1, x1, 3
beq cpu0_only
cpu1_only:
/* Only CPU 1 reaches this point and sets the spinlock. */
mov x0, 1
ldr x1, =spinlock
str x0, [x1]
/* Ensure that CPU 0 sees the write right now.
* Optional, but could save some useless CPU 1 loops.
*/
dmb sy
/* Wake up CPU 0 if it is sleeping on wfe.
* Optional, but could save power on a real system.
*/
sev
cpu1_sleep_forever:
/* Hint CPU 1 to enter low power mode.
* Optional, but could save power on a real system.
*/
wfe
b cpu1_sleep_forever
cpu0_only:
/* Only CPU 0 reaches this point. */
/* Wake up CPU 1 from initial sleep!
* See:https://github.com/cirosantilli/linux-kernel-module-cheat#psci
*/
/* PCSI function identifier: CPU_ON. */
ldr w0, =0xc4000003
/* Argument 1: target_cpu */
mov x1, 1
/* Argument 2: entry_point_address */
ldr x2, =cpu1_only
/* Argument 3: context_id */
mov x3, 0
/* Unused hvc args: the Linux kernel zeroes them,
* but I don't think it is required.
*/
hvc 0
spinlock_start:
ldr x0, spinlock
/* Hint CPU 0 to enter low power mode. */
wfe
cbz x0, spinlock_start
/* Semihost exit. */
mov x1, 0x26
movk x1, 2, lsl 16
str x1, [sp, 0]
mov x0, 0
str x0, [sp, 8]
mov x1, sp
mov w0, 0x18
hlt 0xf000
spinlock:
.skip 8
GitHub en amont .
Assemblez et exécutez:
aarch64-linux-gnu-gcc \
-mcpu=cortex-a57 \
-nostdlib \
-nostartfiles \
-Wl,--section-start=.text=0x40000000 \
-Wl,-N \
-o aarch64.elf \
-T link.ld \
aarch64.S \
;
qemu-system-aarch64 \
-machine virt \
-cpu cortex-a57 \
-d in_asm \
-kernel aarch64.elf \
-nographic \
-semihosting \
-smp 2 \
;
Dans cet exemple, nous plaçons le CPU 0 dans une boucle de verrou tournant, et il ne se termine que lorsque le CPU 1 libère le verrou tournant.
Après le verrou tournant, le CPU 0 effectue ensuite un appel de sortie semi - hôte qui fait quitter QEMU.
Si vous démarrez QEMU avec un seul processeur -smp 1
, la simulation se bloque pour toujours sur le verrou tournant.
Le CPU 1 est réveillé avec l'interface PSCI, plus de détails sur: ARM: Start / Wakeup / Bringup the other CPU cores / APs and pass execution start address?
La version en amont a également quelques ajustements pour la faire fonctionner sur gem5, vous pouvez donc également expérimenter les caractéristiques de performance.
Je ne l'ai pas testé sur du vrai matériel, donc je ne sais pas à quel point c'est portable. La bibliographie Raspberry Pi suivante pourrait être intéressante:
Ce document fournit des conseils sur l'utilisation des primitives de synchronisation ARM que vous pouvez ensuite utiliser pour faire des choses amusantes avec plusieurs cœurs: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Testé sur Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Prochaines étapes pour une programmabilité plus pratique
Les exemples précédents réveillent le processeur secondaire et effectuent une synchronisation de base de la mémoire avec des instructions dédiées, ce qui est un bon début.
Mais pour rendre les systèmes multicœurs faciles à programmer, par exemple comme POSIX pthreads
, vous devrez également aborder les sujets suivants plus impliqués:
l'installation interrompt et exécute un minuteur qui décide périodiquement quel thread s'exécutera maintenant. C'est ce que l'on appelle le multithreading préemptif .
Ce système doit également enregistrer et restaurer les registres de threads au démarrage et à l'arrêt.
Il est également possible d'avoir des systèmes multitâches non préemptifs, mais ceux-ci peuvent vous obliger à modifier votre code afin que chaque thread cède (par exemple avec une pthread_yield
implémentation), et il devient plus difficile d'équilibrer les charges de travail.
Voici quelques exemples simplifiés de minuterie en métal nu:
gérer les conflits de mémoire. Notamment, chaque thread aura besoin d'une pile unique si vous souhaitez coder en C ou dans d'autres langages de haut niveau.
Vous pouvez simplement limiter les threads pour avoir une taille de pile maximale fixe, mais la meilleure façon de gérer cela est avec la pagination qui permet des piles de "taille illimitée" efficaces.
Voici un exemple de baremetal naïf aarch64 qui exploserait si la pile devenait trop profonde
Ce sont de bonnes raisons d'utiliser le noyau Linux ou un autre système d'exploitation :-)
Primitives de synchronisation de la mémoire Userland
Bien que le démarrage / arrêt / gestion des threads dépasse généralement la portée de l'espace utilisateur, vous pouvez cependant utiliser les instructions d'assemblage des threads utilisateur pour synchroniser les accès à la mémoire sans appels système potentiellement plus coûteux.
Vous devriez bien sûr préférer utiliser des bibliothèques qui enveloppent de manière portative ces primitives de bas niveau. Le standard C ++ lui-même a fait de grandes avancées sur les en <mutex>
- <atomic>
têtes et, et en particulier avec std::memory_order
. Je ne sais pas si cela couvre toutes les sémantiques de mémoire possibles, mais c'est possible.
La sémantique plus subtile est particulièrement pertinente dans le contexte des structures de données sans verrouillage , qui peuvent offrir des avantages en termes de performances dans certains cas. Pour les implémenter, vous devrez probablement en apprendre un peu plus sur les différents types de barrières mémoire: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
Boost, par exemple, propose des implémentations de conteneurs sans verrouillage sur: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html
Ces instructions utilisateur semblent également être utilisées pour implémenter l' futex
appel système Linux , qui est l'une des principales primitives de synchronisation sous Linux. man futex
4.15 se lit comme suit:
L'appel système futex () fournit une méthode pour attendre qu'une certaine condition devienne vraie. Il est généralement utilisé comme une construction de blocage dans le contexte de la synchronisation de la mémoire partagée. Lors de l'utilisation de futex, la majorité des opérations de synchronisation sont effectuées dans l'espace utilisateur. Un programme de l'espace utilisateur n'utilise l'appel système futex () que lorsqu'il est probable que le programme doive se bloquer plus longtemps jusqu'à ce que la condition devienne vraie. D'autres opérations futex () peuvent être utilisées pour réveiller tous les processus ou threads en attente d'une condition particulière.
Le nom du syscall lui-même signifie "Fast Userspace XXX".
Voici un exemple C ++ x86_64 / aarch64 minimal inutile avec un assemblage en ligne qui illustre l'utilisation de base de ces instructions principalement pour le plaisir:
main.cpp
#include <atomic>
#include <cassert>
#include <iostream>
#include <thread>
#include <vector>
std::atomic_ulong my_atomic_ulong(0);
unsigned long my_non_atomic_ulong = 0;
#if defined(__x86_64__) || defined(__aarch64__)
unsigned long my_arch_atomic_ulong = 0;
unsigned long my_arch_non_atomic_ulong = 0;
#endif
size_t niters;
void threadMain() {
for (size_t i = 0; i < niters; ++i) {
my_atomic_ulong++;
my_non_atomic_ulong++;
#if defined(__x86_64__)
__asm__ __volatile__ (
"incq %0;"
: "+m" (my_arch_non_atomic_ulong)
:
:
);
// https://github.com/cirosantilli/linux-kernel-module-cheat#x86-lock-prefix
__asm__ __volatile__ (
"lock;"
"incq %0;"
: "+m" (my_arch_atomic_ulong)
:
:
);
#elif defined(__aarch64__)
__asm__ __volatile__ (
"add %0, %0, 1;"
: "+r" (my_arch_non_atomic_ulong)
:
:
);
// https://github.com/cirosantilli/linux-kernel-module-cheat#arm-lse
__asm__ __volatile__ (
"ldadd %[inc], xzr, [%[addr]];"
: "=m" (my_arch_atomic_ulong)
: [inc] "r" (1),
[addr] "r" (&my_arch_atomic_ulong)
:
);
#endif
}
}
int main(int argc, char **argv) {
size_t nthreads;
if (argc > 1) {
nthreads = std::stoull(argv[1], NULL, 0);
} else {
nthreads = 2;
}
if (argc > 2) {
niters = std::stoull(argv[2], NULL, 0);
} else {
niters = 10000;
}
std::vector<std::thread> threads(nthreads);
for (size_t i = 0; i < nthreads; ++i)
threads[i] = std::thread(threadMain);
for (size_t i = 0; i < nthreads; ++i)
threads[i].join();
assert(my_atomic_ulong.load() == nthreads * niters);
// We can also use the atomics direclty through `operator T` conversion.
assert(my_atomic_ulong == my_atomic_ulong.load());
std::cout << "my_non_atomic_ulong " << my_non_atomic_ulong << std::endl;
#if defined(__x86_64__) || defined(__aarch64__)
assert(my_arch_atomic_ulong == nthreads * niters);
std::cout << "my_arch_non_atomic_ulong " << my_arch_non_atomic_ulong << std::endl;
#endif
}
GitHub en amont .
Sortie possible:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
De cela, nous voyons que l' LDADD
instruction de préfixe x86 LOCK / aarch64 a rendu l'addition atomique: sans elle, nous avons des conditions de concurrence sur de nombreux ajouts, et le nombre total à la fin est inférieur au 20000 synchronisé.
Voir également:
Testé sous Ubuntu 19.04 amd64 et avec le mode utilisateur QEMU aarch64.