Beispiel für ein minimal lauffähiges Intel x86-Baremetall
Lauffähiges Bare-Metal-Beispiel mit allen erforderlichen Boilerplates . Alle wichtigen Teile werden unten behandelt.
Getestet unter Ubuntu 15.10 QEMU 2.3.0 und Lenovo ThinkPad T400 als echter Hardware-Gast .
Das Intel Manual Volume 3 System Programming Guide - 325384-056US September 2015 behandelt SMP in den Kapiteln 8, 9 und 10.
Tabelle 8-1. "Broadcast INIT-SIPI-SIPI-Sequenz und Auswahl von Timeouts" enthält ein Beispiel, das im Grunde nur funktioniert:
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
Auf diesem Code:
Die meisten Betriebssysteme machen die meisten dieser Vorgänge ab Ring 3 (Benutzerprogramme) unmöglich.
Sie müssen also Ihren eigenen Kernel schreiben, um frei damit spielen zu können: Ein Userland Linux-Programm funktioniert nicht.
Zunächst wird ein einzelner Prozessor ausgeführt, der als Bootstrap-Prozessor (BSP) bezeichnet wird.
Es muss die anderen (als Application Processors (AP) bezeichnet) durch spezielle Interrupts, sogenannte Inter Processor Interrupts (IPI), aufwecken .
Diese Interrupts können durch Programmieren des Advanced Programmable Interrupt Controller (APIC) über das Interrupt-Befehlsregister (ICR) erfolgen.
Das Format des ICR ist dokumentiert unter: 10.6 "AUSGABE VON INTERPROCESSOR-INTERRUPTS"
Das IPI erfolgt, sobald wir an das ICR schreiben.
ICR_LOW ist in 8.4.4 "MP-Initialisierungsbeispiel" definiert als:
ICR_LOW EQU 0FEE00300H
Der magische Wert 0FEE00300
ist die Speicheradresse des ICR, wie in Tabelle 10-1 "Local APIC Register Address Map" dokumentiert.
Im Beispiel wird die einfachste Methode verwendet: Sie richtet den ICR so ein, dass Broadcast-IPIs gesendet werden, die an alle anderen Prozessoren außer dem aktuellen geliefert werden.
Es ist aber auch möglich und von einigen empfohlen , Informationen über die Prozessoren über spezielle Datenstrukturen abzurufen, die vom BIOS eingerichtet wurden, wie z. B. ACPI-Tabellen oder Intels MP-Konfigurationstabelle, und nur diejenigen zu aktivieren, die Sie einzeln benötigen.
XX
in 000C46XXH
codiert die Adresse des ersten Befehls, den der Prozessor ausführen wird als:
CS = XX * 0x100
IP = 0
Denken Sie daran, dass CS Adressen mit multipliziert0x10
, sodass die tatsächliche Speicheradresse des ersten Befehls wie folgt lautet:
XX * 0x1000
Wenn zum Beispiel XX == 1
der Prozessor bei startet 0x1000
.
Wir müssen dann sicherstellen, dass an diesem Speicherort 16-Bit-Realmoduscode ausgeführt werden kann, z. B.:
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
Die Verwendung eines Linkerskripts ist eine weitere Möglichkeit.
Die Verzögerungsschleifen sind ein nerviger Teil, um an die Arbeit zu gehen: Es gibt keine supereinfache Möglichkeit, solche Schlafzeiten präzise durchzuführen.
Mögliche Methoden sind:
- PIT (in meinem Beispiel verwendet)
- HPET
- Kalibrieren Sie die Zeit einer Besetztschleife mit den oben genannten und verwenden Sie sie stattdessen
Verwandte Themen : Wie kann ich eine Nummer auf dem Bildschirm anzeigen und mit der DOS x86-Assembly eine Sekunde lang schlafen?
Ich denke, der anfängliche Prozessor muss sich im geschützten Modus befinden, damit dies funktioniert, wenn wir an eine Adresse schreiben, 0FEE00300H
die für 16-Bit zu hoch ist
Um zwischen Prozessoren zu kommunizieren, können wir einen Spinlock für den Hauptprozess verwenden und die Sperre vom zweiten Kern aus ändern.
Wir sollten sicherstellen, dass das Zurückschreiben des Speichers erfolgt, z wbinvd
. B. durch .
Geteilter Zustand zwischen Prozessoren
8.7.1 "Status der logischen Prozessoren" sagt:
Die folgenden Funktionen sind Teil des Architekturstatus logischer Prozessoren in Intel 64- oder IA-32-Prozessoren, die die Intel Hyper-Threading-Technologie unterstützen. Die Funktionen können in drei Gruppen unterteilt werden:
- Für jeden logischen Prozessor dupliziert
- Wird von logischen Prozessoren in einem physischen Prozessor gemeinsam genutzt
- Je nach Implementierung freigegeben oder dupliziert
Die folgenden Funktionen werden für jeden logischen Prozessor dupliziert:
- Allzweckregister (EAX, EBX, ECX, EDX, ESI, EDI, ESP und EBP)
- Segmentregister (CS, DS, SS, ES, FS und GS)
- EFLAGS- und EIP-Register. Beachten Sie, dass die CS- und EIP / RIP-Register für jeden logischen Prozessor auf den Befehlsstrom für den vom logischen Prozessor ausgeführten Thread verweisen.
- x87-FPU-Register (ST0 bis ST7, Statuswort, Steuerwort, Tag-Wort, Datenoperandenzeiger und Befehlszeiger)
- MMX-Register (MM0 bis MM7)
- XMM-Register (XMM0 bis XMM7) und das MXCSR-Register
- Steuerregister und Systemtabellenzeigerregister (GDTR, LDTR, IDTR, Taskregister)
- Debug-Register (DR0, DR1, DR2, DR3, DR6, DR7) und die Debug-Steuer-MSRs
- Globaler Status der Maschinenprüfung (IA32_MCG_STATUS) und Fähigkeit zur Maschinenprüfung (IA32_MCG_CAP) MSRs
- Thermische Taktmodulation und ACPI Power Management steuern MSRs
- Zeitstempelzähler MSRs
- Die meisten anderen MSR-Register, einschließlich der Seitenattributtabelle (PAT). Siehe die Ausnahmen unten.
- Lokale APIC-Register.
- Zusätzliche Allzweckregister (R8-R15), XMM-Register (XMM8-XMM15), Steuerregister, IA32_EFER auf Intel 64-Prozessoren.
Die folgenden Funktionen werden von logischen Prozessoren gemeinsam genutzt:
- Speichertyp-Bereichsregister (MTRRs)
Ob die folgenden Funktionen gemeinsam genutzt oder dupliziert werden, ist implementierungsspezifisch:
- IA32_MISC_ENABLE MSR (MSR-Adresse 1A0H)
- MCA-MSRs (Machine Check Architecture) (mit Ausnahme der MSRs IA32_MCG_STATUS und IA32_MCG_CAP)
- Leistungsüberwachungssteuerung und Zähler-MSRs
Die Cache-Freigabe wird unter folgender Adresse erläutert:
Intel-Hyperthreads haben eine größere Cache- und Pipeline-Freigabe als separate Kerne: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Linux-Kernel 4.2
Die Hauptinitialisierungsaktion scheint bei zu sein arch/x86/kernel/smpboot.c
.
ARM Minimal Runnable Baremetal Beispiel
Hier stelle ich ein minimal lauffähiges ARMv8 aarch64-Beispiel für QEMU bereit:
.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 stromaufwärts .
Zusammenbauen und ausführen:
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 diesem Beispiel setzen wir CPU 0 in eine Spinlock-Schleife und sie wird nur beendet, wenn CPU 1 den Spinlock freigibt.
Nach dem Spinlock führt CPU 0 dann einen Semihost-Exit-Aufruf durch , wodurch QEMU beendet wird.
Wenn Sie QEMU mit nur einer CPU starten -smp 1
, hängt die Simulation für immer am Spinlock.
CPU 1 wird mit der PSCI-Schnittstelle aufgeweckt, weitere Details unter: ARM: Start / Wakeup / Bringup die anderen CPU-Kerne / APs und Startadresse für die Ausführung übergeben?
Die Upstream-Version hat auch einige Verbesserungen, damit sie auf gem5 funktioniert, sodass Sie auch mit Leistungsmerkmalen experimentieren können.
Ich habe es nicht auf echter Hardware getestet und bin mir nicht sicher, wie portabel dies ist. Die folgende Raspberry Pi-Bibliographie könnte von Interesse sein:
Dieses Dokument enthält einige Anleitungen zur Verwendung von ARM-Synchronisationsprimitiven, mit denen Sie unterhaltsame Dinge mit mehreren Kernen ausführen können : http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Getestet unter Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Nächste Schritte für eine bequemere Programmierbarkeit
Die vorherigen Beispiele aktivieren die sekundäre CPU und führen eine grundlegende Speichersynchronisierung mit dedizierten Anweisungen durch. Dies ist ein guter Anfang.
Um die Programmierung von Multicore-Systemen wie POSIX zu vereinfachen pthreads
, müssten Sie sich jedoch auch mit den folgenden Themen befassen:
Setup unterbricht und führt einen Timer aus, der regelmäßig entscheidet, welcher Thread jetzt ausgeführt wird. Dies wird als präventives Multithreading bezeichnet .
Ein solches System muss auch Thread-Register speichern und wiederherstellen, wenn sie gestartet und gestoppt werden.
Es ist auch möglich, nicht präemptive Multitasking-Systeme zu haben. Möglicherweise müssen Sie jedoch Ihren Code so ändern, dass jeder Thread (z. B. bei einer pthread_yield
Implementierung) nachgibt , und es wird schwieriger, die Arbeitslast auszugleichen.
Hier sind einige vereinfachte Beispiele für Bare-Metal-Timer:
mit Gedächtniskonflikten umgehen. Insbesondere benötigt jeder Thread einen eindeutigen Stapel, wenn Sie in C oder anderen Hochsprachen codieren möchten.
Sie könnten Threads einfach auf eine feste maximale Stapelgröße beschränken, aber der schönere Weg, damit umzugehen, ist das Paging, das effiziente Stapel mit "unbegrenzter Größe" ermöglicht.
Hier ist ein naives aarch64-Baremetall-Beispiel, das explodieren würde, wenn der Stapel zu tief wächst
Das sind einige gute Gründe, den Linux-Kernel oder ein anderes Betriebssystem zu verwenden :-)
Grundelemente für die Userland-Speichersynchronisation
Obwohl das Starten / Stoppen / Verwalten von Threads im Allgemeinen außerhalb des Bereichs des Benutzerlandes liegt, können Sie Assembly-Anweisungen von Userland-Threads verwenden, um Speicherzugriffe ohne potenziell teurere Systemaufrufe zu synchronisieren.
Sie sollten natürlich lieber Bibliotheken verwenden, die diese Grundelemente auf niedriger Ebene portabel umschließen. Der C ++ - Standard selbst hat große Fortschritte bei den <mutex>
und <atomic>
-Headern und insbesondere bei gemacht std::memory_order
. Ich bin mir nicht sicher, ob es alle möglichen erreichbaren Speichersemantiken abdeckt, aber es könnte sein.
Die subtilere Semantik ist besonders relevant im Zusammenhang mit sperrenfreien Datenstrukturen , die in bestimmten Fällen Leistungsvorteile bieten können. Um diese zu implementieren, müssen Sie wahrscheinlich etwas über die verschiedenen Arten von Speicherbarrieren lernen: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
Boost bietet beispielsweise einige sperrenfreie Container-Implementierungen unter: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html
Solche Benutzerlandanweisungen scheinen auch verwendet zu werden, um den Linux- futex
Systemaufruf zu implementieren , der eines der Hauptsynchronisationsprimitive in Linux ist. man futex
4.15 lautet:
Der Systemaufruf futex () bietet eine Methode zum Warten, bis eine bestimmte Bedingung erfüllt ist. Es wird normalerweise als blockierendes Konstrukt im Kontext der Synchronisation mit gemeinsamem Speicher verwendet. Bei Verwendung von Futexen werden die meisten Synchronisationsvorgänge im Benutzerbereich ausgeführt. Ein User-Space-Programm verwendet den Systemaufruf futex () nur dann, wenn es wahrscheinlich ist, dass das Programm länger blockieren muss, bis die Bedingung erfüllt ist. Andere futex () -Operationen können verwendet werden, um Prozesse oder Threads zu aktivieren, die auf eine bestimmte Bedingung warten.
Der Syscall-Name selbst bedeutet "Fast Userspace XXX".
Hier ist ein minimal nutzloses C ++ x86_64 / aarch64-Beispiel mit Inline-Assembly, das die grundlegende Verwendung solcher Anweisungen hauptsächlich zum Spaß veranschaulicht:
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 stromaufwärts .
Mögliche Ausgabe:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
Daraus sehen wir, dass der x86 LOCK-Präfix / aarch64- LDADD
Befehl die Addition atomar gemacht hat: Ohne sie haben wir bei vielen der Adds Race-Bedingungen, und die Gesamtzahl am Ende ist geringer als die synchronisierten 20000.
Siehe auch:
Getestet in Ubuntu 19.04 amd64 und mit QEMU aarch64 Benutzermodus.