Пример минимального запуска Intel x86 для неизолированного металла
Работоспособный пример из чистого металла со всеми необходимыми образцами . Все основные части описаны ниже.
Протестировано на Ubuntu 15.10 QEMU 2.3.0 и Lenovo ThinkPad T400 с реальным аппаратным гостем .
Руководство Intel по системному программированию, том 3, 325384-056RU, сентябрь 2015 г., посвящено SMP в главах 8, 9 и 10.
Таблица 8-1. «Последовательность широковещательной передачи INIT-SIPI-SIPI и выбор тайм-аутов» содержит пример, который в основном работает:
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
На этот код:
Большинство операционных систем сделает невозможным большинство этих операций из кольца 3 (пользовательские программы).
Так что вам нужно написать свое собственное ядро, чтобы свободно играть с ним: пользовательская программа Linux не будет работать.
Сначала запускается один процессор, называемый процессором начальной загрузки (BSP).
Он должен активировать другие (называемые процессорами приложений (AP)) через специальные прерывания, называемые межпроцессорными прерываниями (IPI) .
Эти прерывания могут быть сделаны путем программирования расширенного программируемого контроллера прерываний (APIC) через регистр команд прерывания (ICR)
Формат ICR задокументирован по адресу: 10.6 «ВЫПУСК МЕЖПРОЦЕССОРНЫХ ПРЕРЫВАНИЙ»
IPI происходит, как только мы пишем в ICR.
ICR_LOW определяется в 8.4.4 «Пример инициализации MP» как:
ICR_LOW EQU 0FEE00300H
Магическое значение 0FEE00300
- это адрес памяти ICR, как описано в Таблице 10-1 «Карта адресов локального регистра APIC».
В примере используется самый простой из возможных методов: он устанавливает ICR для отправки широковещательных IPI, которые доставляются всем другим процессорам, кроме текущего.
Но также возможно, и некоторые рекомендуют , получать информацию о процессорах через специальные структуры данных, настраиваемые BIOS, например таблицы ACPI или таблицу конфигурации Intel MP, и запускать только те, которые вам нужны, по очереди.
XX
в 000C46XXH
кодирует адрес первой инструкции, которую процессор будет выполнять как:
CS = XX * 0x100
IP = 0
Помните, что CS умножает адреса на0x10
, поэтому фактический адрес памяти первой инструкции:
XX * 0x1000
Так что если, например XX == 1
, процессор будет начинаться с 0x1000
.
Затем мы должны убедиться, что в этом месте памяти выполняется 16-битный код реального режима, например:
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
Использование сценария компоновщика - еще одна возможность.
Петли задержки - раздражающая часть, чтобы начать работать: не существует супер простого способа точно сделать такие сны.
Возможные методы включают в себя:
- PIT (используется в моем примере)
- HPET
- откалибруйте время занятой петли с помощью вышеприведенного и используйте его вместо
Связанный: Как отобразить число на экране и так и поспать одну секунду со сборкой DOS x86?
Я думаю, что исходный процессор должен быть в защищенном режиме, чтобы это работало, когда мы пишем по адресу, 0FEE00300H
который слишком высок для 16-бит
Для связи между процессорами мы можем использовать спин-блокировку основного процесса и изменить блокировку со второго ядра.
Мы должны убедиться, что обратная запись в память выполнена, например, через wbinvd
.
Общее состояние между процессорами
8.7.1 «Состояние логических процессоров» гласит:
Следующие функции являются частью архитектурного состояния логических процессоров в процессорах Intel 64 или IA-32, поддерживающих технологию Intel Hyper-Threading. Функции можно разделить на три группы:
- Дублируется для каждого логического процессора
- Совместно используемые логическими процессорами в физическом процессоре
- Совместно или дублируется, в зависимости от реализации
Следующие функции дублируются для каждого логического процессора:
- Регистры общего назначения (EAX, EBX, ECX, EDX, ESI, EDI, ESP и EBP)
- Сегментные регистры (CS, DS, SS, ES, FS и GS)
- EFLAGS и EIP регистры. Обратите внимание, что регистры CS и EIP / RIP для каждого логического процессора указывают на поток команд для потока, выполняемого логическим процессором.
- Регистры FPU x87 (ST0-ST7, слово состояния, слово управления, слово тега, указатель операнда данных и указатель инструкции)
- MMX регистры (от MM0 до MM7)
- Регистры XMM (от XMM0 до XMM7) и регистр MXCSR
- Регистры управления и регистры указателей системной таблицы (GDTR, LDTR, IDTR, регистр задач)
- Регистры отладки (DR0, DR1, DR2, DR3, DR6, DR7) и MSR управления отладкой
- Проверка состояния компьютера (IA32_MCG_STATUS) и возможности проверки компьютера (IA32_MCG_CAP) MSR
- Тепловая тактовая модуляция и ACPI MSR управления управлением питанием
- Счетчик меток времени MSR
- Большинство других регистров MSR, включая таблицу атрибутов страницы (PAT). Смотрите исключения ниже.
- Местные регистры APIC.
- Дополнительные регистры общего назначения (R8-R15), регистры XMM (XMM8-XMM15), регистр управления, IA32_EFER на процессорах Intel 64.
Следующие функции являются общими для логических процессоров:
- Регистры диапазонов типов памяти (MTRR)
Совместное использование или дублирование следующих функций зависит от реализации:
- IA32_MISC_ENABLE MSR (адрес MSR 1A0H)
- MSR архитектуры проверки компьютера (MCA) (за исключением MSR IA32_MCG_STATUS и IA32_MCG_CAP)
- Контроль производительности и счетчик MSR
Совместное использование кэша обсуждается по адресу:
Гиперпотоки Intel имеют больший общий объем кэша и конвейера, чем отдельные ядра: /superuser/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Ядро Linux 4.2
Основное действие инициализации, кажется, в arch/x86/kernel/smpboot.c
.
ARM минимальный работоспособный пример из неизолированного металла
Здесь я приведу минимальный исполняемый пример ARMv8 aarch64 для 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 вверх по течению .
Собрать и запустить:
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 \
;
В этом примере мы помещаем CPU 0 в цикл спин-блокировки, и он завершается только тогда, когда CPU 1 освобождает спин-блокировку.
После спин-блокировки ЦП 0 выполняет вызов выхода из полухоста, который заставляет QEMU выйти.
Если вы запускаете QEMU только с одним процессором -smp 1
, то симуляция просто навсегда зависает на спин-блокировке.
CPU 1 разбудился с помощью интерфейса PSCI, более подробную информацию можно найти по адресу: ARM: Запустите / включите / включите другие ядра / AP CPU и передайте начальный адрес выполнения?
В апстрим-версии также есть несколько настроек, чтобы заставить его работать на gem5, так что вы также можете поэкспериментировать с характеристиками производительности.
Я не тестировал его на реальном оборудовании, поэтому не уверен, насколько это портативно. Следующая библиография Raspberry Pi может представлять интерес:
Этот документ содержит некоторые рекомендации по использованию примитивов синхронизации ARM, которые затем можно использовать для забавных вещей с несколькими ядрами: http://infocenter.arm.com/help/topic/com.arm.doc.dht0008a/DHT0008A_arm_synchronization_primitives.pdf
Протестировано на Ubuntu 18.10, GCC 8.2.0, Binutils 2.31.1, QEMU 2.12.0.
Следующие шаги для более удобного программирования
Предыдущие примеры пробуждают вторичный процессор и выполняют базовую синхронизацию памяти с выделенными инструкциями, что является хорошим началом.
Но чтобы сделать многоядерные системы простыми в программировании, например, POSIX pthreads
, вам также необходимо перейти к следующим более сложным темам:
Программа установки прерывает и запускает таймер, который периодически решает, какой поток будет запущен сейчас. Это известно как вытесняющая многопоточность .
Такая система также должна сохранять и восстанавливать регистры потоков по мере их запуска и остановки.
Также возможно иметь не вытесняющие многозадачные системы, но они могут потребовать, чтобы вы изменили свой код так, чтобы каждый поток давал результат (например, с pthread_yield
реализацией), и становится сложнее балансировать рабочие нагрузки.
Вот несколько упрощенных примеров таймера с голым металлом:
иметь дело с конфликтами памяти. В частности, каждому потоку понадобится уникальный стек, если вы хотите кодировать на C или других языках высокого уровня.
Вы можете просто ограничить потоки фиксированным максимальным размером стека, но лучший способ справиться с этим - использование подкачки, которая позволяет создавать эффективные стеки «неограниченного размера».
Вот наивный простой пример aarch64, который взорвется, если стек станет слишком глубоким
Вот несколько веских причин использовать ядро Linux или другую операционную систему :-)
Примитивы синхронизации памяти пользователя
Хотя запуск / остановка / управление потоком, как правило, выходят за рамки области пользователя, вы можете использовать инструкции по сборке из потоков области пользователя для синхронизации обращений к памяти без потенциально более дорогих системных вызовов.
Конечно, вы должны предпочесть использовать библиотеки, которые переносят эти примитивы низкого уровня. Стандарт C ++ сам сделал большие успехи на тех <mutex>
и <atomic>
заголовки, и , в частности , с std::memory_order
. Я не уверен, охватывает ли он всю возможную семантику памяти, но это возможно.
Более тонкая семантика особенно актуальна в контексте структур данных без блокировки , которые в некоторых случаях могут повысить производительность. Чтобы реализовать их, вам, вероятно, придется немного узнать о различных типах барьеров памяти: https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/
Например, в Boost есть несколько реализаций контейнеров без блокировки по адресу: https://www.boost.org/doc/libs/1_63_0/doc/html/lockfree.html.
Такие пользовательские инструкции также используются для реализации futex
системного вызова Linux , который является одним из основных примитивов синхронизации в Linux. man futex
4.15 гласит:
Системный вызов futex () предоставляет метод для ожидания, пока определенное условие не станет истинным. Обычно он используется в качестве блокирующей конструкции в контексте синхронизации с общей памятью. При использовании фьютексов большинство операций синхронизации выполняются в пространстве пользователя. Программа пространства пользователя использует системный вызов futex () только тогда, когда существует вероятность, что программе придется блокировать в течение более длительного времени, пока условие не станет истинным. Другие операции futex () могут использоваться для пробуждения любых процессов или потоков, ожидающих определенного условия.
Само имя системного вызова означает «Fast Userspace XXX».
Вот минимальный бесполезный пример C ++ x86_64 / aarch64 со встроенной сборкой, который иллюстрирует базовое использование таких инструкций в основном для развлечения:
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 вверх по течению .
Возможный вывод:
my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267
Из этого мы видим, что LDADD
инструкция x86 LOCK prefix / aarch64 сделала добавление атомарным: без него у нас есть условия гонки для многих добавлений, и общее количество в конце меньше, чем синхронизированные 20000.
Смотрите также:
Протестировано в Ubuntu 19.04 amd64 и в пользовательском режиме QEMU aarch64.