Esempio baremetal eseguibile minimo Intel x86
Esempio di metallo nudo eseguibile con tutta la piastra della caldaia richiesta . Tutte le parti principali sono descritte di seguito.
Testato su Ubuntu 15.10 QEMU 2.3.0 e Lenovo ThinkPad T400 guest hardware reale .
La Guida alla programmazione del sistema Manuale del volume 3 di Intel - 325384-056US settembre 2015 copre SMP nei capitoli 8, 9 e 10.
Tabella 8-1 "Broadcast INIT-SIPI-SIPI Sequence and Choice of Timeouts" contiene un esempio che sostanzialmente funziona:
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
Su quel codice:
La maggior parte dei sistemi operativi renderà impossibile la maggior parte di tali operazioni dall'anello 3 (programmi utente).
Quindi è necessario scrivere il proprio kernel per giocare liberamente con esso: un programma Linux userland non funzionerà.
Inizialmente, viene eseguito un singolo processore, chiamato bootstrap processor (BSP).
Deve svegliare gli altri (chiamati Application Processors (AP)) attraverso speciali interrupt chiamati Inter Processor Interrupts (IPI) .
Tali interruzioni possono essere eseguite programmando Advanced Programmable Interrupt Controller (APIC) tramite il registro dei comandi di interruzione (ICR)
Il formato dell'ICR è documentato in: 10.6 "EMISSIONE DI INTERRUZIONI INTERPROCESSORI"
L'IPI si verifica non appena scriviamo all'ICR.
ICR_LOW è definito in 8.4.4 "Esempio di inizializzazione MP" come:
ICR_LOW EQU 0FEE00300H
Il valore magico 0FEE00300
è l'indirizzo di memoria dell'ICR, come documentato nella Tabella 10-1 "Mappa dell'indirizzo del registro APIC locale"
Nell'esempio viene utilizzato il metodo più semplice possibile: imposta l'ICR per inviare IPI broadcast che vengono consegnati a tutti gli altri processori tranne quello corrente.
Ma è anche possibile, e consigliato da alcuni , ottenere informazioni sui processori attraverso speciali strutture di dati impostate dal BIOS come le tabelle ACPI o la tabella di configurazione MP di Intel e solo svegliare quelle necessarie una ad una.
XX
in 000C46XXH
codifica l'indirizzo della prima istruzione che il processore eseguirà come:
CS = XX * 0x100
IP = 0
Ricorda che CS moltiplica gli indirizzi per0x10
, quindi l'indirizzo di memoria effettivo della prima istruzione è:
XX * 0x1000
Quindi, se per esempio XX == 1
, il processore inizierà alle 0x1000
.
Dobbiamo quindi assicurarci che ci sia un codice in modalità reale a 16 bit da eseguire in quella posizione di memoria, ad esempio con:
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'uso di uno script di linker è un'altra possibilità.
I loop di ritardo sono una parte fastidiosa per iniziare a lavorare: non esiste un modo super semplice per fare esattamente tali dormienti.
I metodi possibili includono:
- PIT (usato nel mio esempio)
- HPET
- calibrare il tempo di un circuito occupato con quanto sopra e usarlo invece
Correlati: Come visualizzare un numero sullo schermo e dormire per un secondo con l'assembly x86 DOS?
Penso che il processore iniziale debba essere in modalità protetta perché questo funzioni mentre scriviamo per indirizzare 0FEE00300H
che è troppo alto per 16 bit
Per comunicare tra processori, possiamo utilizzare uno spinlock sul processo principale e modificare il blocco dal secondo core.
Dovremmo assicurarci che la scrittura della memoria sia terminata, ad es wbinvd
.
Stato condiviso tra processori
8.7.1 "Stato dei processori logici" dice:
Le seguenti funzionalità fanno parte dello stato architettonico dei processori logici all'interno dei processori Intel 64 o IA-32 che supportano la tecnologia Intel Hyper-Threading. Le funzioni possono essere suddivise in tre gruppi:
- Duplicato per ciascun processore logico
- Condiviso da processori logici in un processore fisico
- Condiviso o duplicato, a seconda dell'implementazione
Le seguenti funzionalità sono duplicate per ciascun processore logico:
- Registri di uso generale (EAX, EBX, ECX, EDX, ESI, EDI, ESP ed EBP)
- Registri di segmento (CS, DS, SS, ES, FS e GS)
- Registri EFLAGS e EIP. Si noti che i registri CS ed EIP / RIP per ciascun processore logico puntano al flusso di istruzioni per il thread eseguito dal processore logico.
- Registri FPU x87 (da ST0 a ST7, parola di stato, parola di controllo, parola tag, puntatore operando dati e puntatore istruzione)
- Registri MMX (da MM0 a MM7)
- Registri XMM (da XMM0 a XMM7) e registro MXCSR
- Registri di controllo e registri dei puntatori della tabella di sistema (GDTR, LDTR, IDTR, registro attività)
- Registri di debug (DR0, DR1, DR2, DR3, DR6, DR7) e MSR di controllo del debug
- Stato globale controllo macchina (IA32_MCG_STATUS) e capacità di controllo macchina (IA32_MCG_CAP) MSR
- MSR di controllo della gestione dell'alimentazione ACPI e modulazione termica
- Contatori di timestamp MSR
- La maggior parte degli altri registri MSR, inclusa la tabella degli attributi di pagina (PAT). Vedi le eccezioni di seguito.
- Registri APIC locali.
- Registri di uso generale aggiuntivi (R8-R15), registri XMM (XMM8-XMM15), registro di controllo, IA32_EFER su processori Intel 64.
Le seguenti funzioni sono condivise dai processori logici:
- Registri di intervallo del tipo di memoria (MTRR)
Se le seguenti funzionalità sono condivise o duplicate è specifico dell'implementazione:
- IA32_MISC_ENABLE MSR (indirizzo MSR 1A0H)
- MSR di architettura di controllo macchina (MCA) (ad eccezione dei MSR IA32_MCG_STATUS e IA32_MCG_CAP)
- Controllo del monitoraggio delle prestazioni e contatore MSR
La condivisione della cache è discussa su:
Gli hyperthread Intel hanno una maggiore condivisione della cache e della pipeline rispetto ai core separati: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Kernel Linux 4.2
L'azione di inizializzazione principale sembra essere a arch/x86/kernel/smpboot.c
.
Esempio baremetal minimo eseguibile ARM
Qui fornisco un esempio minimale di ARMv8 aarch64 eseguibile per 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 a monte .
Montare ed eseguire:
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 \
;
In questo esempio, mettiamo la CPU 0 in un loop di spinlock e si esce solo con CPU 1 che rilascia lo spinlock.
Dopo lo spinlock, CPU 0 esegue quindi una chiamata di uscita semihost che fa chiudere QEMU.
Se avvii QEMU con una sola CPU -smp 1
, la simulazione si blocca per sempre sullo spinlock.
La CPU 1 viene riattivata con l'interfaccia PSCI, maggiori dettagli su: ARM: Start / Wakeup / Recupera gli altri core / AP della CPU e passa l'indirizzo iniziale dell'esecuzione?
La versione upstream ha anche alcune modifiche per farlo funzionare su gem5, in modo da poter sperimentare anche le caratteristiche delle prestazioni.
Non l'ho testato su hardware reale, quindi non sono sicuro di quanto sia portatile. Potrebbe essere interessante la seguente bibliografia su Raspberry Pi:
Questo documento fornisce alcune indicazioni sull'uso delle primitive di sincronizzazione ARM che è possibile utilizzare per fare cose divertenti con più core: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Testato su Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Prossimi passi per una più comoda programmabilità
Gli esempi precedenti riattivano la CPU secondaria ed eseguono la sincronizzazione di base della memoria con istruzioni dedicate, il che è un buon inizio.
Ma per semplificare la programmazione dei sistemi multicore, ad esempio POSIX pthreads
, è necessario approfondire i seguenti argomenti più coinvolti:
l'installazione si interrompe ed esegue un timer che decide periodicamente quale thread verrà eseguito ora. Questo è noto come multithreading preventivo .
Tale sistema deve anche salvare e ripristinare i registri thread mentre vengono avviati e arrestati.
È anche possibile disporre di sistemi multitasking non preventivi, ma questi potrebbero richiedere di modificare il codice in modo che ogni thread produca (ad esempio con pthread_yield
un'implementazione), e diventa più difficile bilanciare i carichi di lavoro.
Ecco alcuni esempi di timer bare metal semplicistici:
affrontare i conflitti di memoria. In particolare, ogni thread avrà bisogno di uno stack univoco se si desidera codificare in C o altre lingue di alto livello.
Potresti semplicemente limitare i thread ad avere una dimensione massima dello stack fissa, ma il modo migliore per gestirlo è con il paging che consente stack efficienti di "dimensioni illimitate".
Ecco un ingenuo esempio barearch aarch64 che esploderebbe se lo stack cresce troppo in profondità
Questi sono alcuni buoni motivi per usare il kernel Linux o qualche altro sistema operativo :-)
Primitive di sincronizzazione della memoria di Userland
Sebbene l'avvio / arresto / gestione dei thread sia generalmente al di fuori dell'ambito dell'utente, è comunque possibile utilizzare le istruzioni di assemblaggio dei thread dell'utente per sincronizzare gli accessi alla memoria senza chiamate di sistema potenzialmente più costose.
Ovviamente dovresti preferire l'uso di librerie che avvolgono in modo portabile queste primitive di basso livello. Lo standard C ++ si è fatto grandi progressi sui <mutex>
e <atomic>
intestazioni, e in particolare con std::memory_order
. Non sono sicuro se copre tutte le possibili semantiche di memoria ottenibili, ma potrebbe solo.
La semantica più sottile è particolarmente rilevante nel contesto di strutture dati senza blocco , che possono offrire vantaggi in termini di prestazioni in alcuni casi. Per implementarli, dovrai probabilmente imparare un po 'sui diversi tipi di barriere di memoria: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
Boost, ad esempio, ha alcune implementazioni di container senza lock su: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html
Tali istruzioni per l'utente sembrano anche essere utilizzate per implementare la futex
chiamata di sistema Linux , che è una delle principali primitive di sincronizzazione in Linux. man futex
4.15 dice:
La chiamata di sistema futex () fornisce un metodo per attendere fino a quando una determinata condizione diventa vera. In genere viene utilizzato come costrutto di blocco nel contesto della sincronizzazione della memoria condivisa. Quando si usano i futex, la maggior parte delle operazioni di sincronizzazione viene eseguita nello spazio utente. Un programma spazio utente utilizza la chiamata di sistema futex () solo quando è probabile che il programma debba bloccarsi per un periodo più lungo fino a quando la condizione diventa vera. Altre operazioni futex () possono essere utilizzate per riattivare qualsiasi processo o thread in attesa di una particolare condizione.
Il nome syscall stesso significa "Fast Userspace XXX".
Ecco un esempio minimo inutile di C ++ x86_64 / aarch64 con assembly inline che illustra l'utilizzo di base di tali istruzioni principalmente per divertimento:
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 a monte .
Uscita possibile:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
Da ciò vediamo che l'istruzione x86 LOCK prefix / aarch64 ha LDADD
reso atomica l'aggiunta: senza di essa abbiamo condizioni di gara su molti degli add, e il conteggio totale alla fine è inferiore al 20000 sincronizzato.
Guarda anche:
Testato in Ubuntu 19.04 amd64 e con modalità utente QEMU aarch64.