Intel x86最小可运行裸机示例
带有所有必需样板的可运行裸机示例。下面涵盖了所有主要部分。
在Ubuntu 15.10 QEMU 2.3.0和Lenovo ThinkPad T400 真实硬件客户端上测试通过。
Intel手册第3卷系统编程指南-325384-056US September 2015在第8、9和10章中涵盖了SMP。
表8-1.“广播INIT-SIPI-SIPI序列和超时选择”包含一个基本可行的示例:
MOV ESI, ICR_LOW
MOV EAX, 000C4500H
MOV [ESI], EAX
MOV EAX, 000C46XXH
MOV [ESI], EAX
MOV [ESI], EAX
在那个代码上:
Most operating systems will make most of those operations impossible from ring 3 (user programs).
So you need to write your own kernel to play freely with it: a userland Linux program will not work.
At first, a single processor runs, called the bootstrap processor (BSP).
It must wake up the other ones (called Application Processors (AP)) through special interrupts called Inter Processor Interrupts (IPI).
Those interrupts can be done by programming Advanced Programmable Interrupt Controller (APIC) through the Interrupt command register (ICR)
The format of the ICR is documented at: 10.6 "ISSUING INTERPROCESSOR INTERRUPTS"
The IPI happens as soon as we write to the ICR.
ICR_LOW is defined at 8.4.4 "MP Initialization Example" as:
ICR_LOW EQU 0FEE00300H
The magic value 0FEE00300
is the memory address of the ICR, as documented at Table 10-1 "Local APIC Register Address Map"
The simplest possible method is used in the example: it sets up the ICR to send broadcast IPIs which are delivered to all other processors except the current one.
But it is also possible, and recommended by some, to get information about the processors through special data structures setup by the BIOS like ACPI tables or Intel's MP configuration table and only wake up the ones you need one by one.
XX
in 000C46XXH
encodes the address of the first instruction that the processor will execute as:
CS = XX * 0x100
IP = 0
Remember that CS multiples addresses by 0x10
, so the actual memory address of the first instruction is:
XX * 0x1000
So if for example XX == 1
, the processor will start at 0x1000
.
We must then ensure that there is 16-bit real mode code to be run at that memory location, e.g. with:
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
Using a linker script is another possibility.
The delay loops are an annoying part to get working: there is no super simple way to do such sleeps precisely.
Possible methods include:
- PIT (used in my example)
- HPET
- calibrate the time of a busy loop with the above, and use it instead
Related: How to display a number on the screen and and sleep for one second with DOS x86 assembly?
I think the initial processor needs to be in protected mode for this to work as we write to address 0FEE00300H
which is too high for 16-bits
To communicate between processors, we can use a spinlock on the main process, and modify the lock from the second core.
We should ensure that memory write back is done, e.g. through 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寄存器指向由逻辑处理器执行的线程的指令流。
- x87 FPU寄存器(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),控制寄存器,在Intel 64处理器上是IA32_EFER。
以下功能由逻辑处理器共享:
- 内存类型范围寄存器(MTRRs)
以下功能是共享或复制的,具体取决于实现:
- IA32_MISC_ENABLE MSR(MSR地址1A0H)
- 机器检查架构(MCA)MSR(除了IA32_MCG_STATUS和IA32_MCG_CAP MSR)
- 性能监视控制和计数器MSR
缓存共享讨论在以下链接中:
Intel 超线程比独立核心具有更大的缓存和流水线共享:https://superuser.com/questions/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 upstream。
组装并运行:
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释放自旋锁时,它才会退出。
自旋锁后,CPU 0执行
semihost exit call,使QEMU退出。
如果你只用
-smp 1
启动QEMU,则模拟会永远停留在自旋锁上。
使用PSCI接口唤醒CPU 1,更多详情请参考:
ARM:启动/唤醒/启动其他CPU核/AP并传递执行起始地址?
上游版本还有一些调整,使其在gem5上工作,因此您也可以尝试性能特征。
我没有在真实硬件上测试过它,所以不确定这有多可移植。以下的 树莓派文献 可能会引起您的兴趣:
这份文档提供了一些关于使用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上进行了测试。
更方便的可编程性的下一步
以前的示例唤醒辅助CPU并使用专用指令进行基本内存同步,这是一个良好的开端。
但是为了使多核系统易于编程,例如像
POSIX pthreads
一样,您还需要涉及以下更深入的主题:
设置中断并运行定时器,周期性地决定现在将运行哪个线程。这被称为抢占式多线程。
这样的系统还需要在启动和停止线程时保存和恢复线程寄存器。
也可以有非抢占式多任务处理系统,但这些可能需要您修改代码,以便每个线程都能让出(例如使用pthread_yield
实现),并且更难平衡工作负载。
以下是一些简单的裸机计时器示例:
处理内存冲突。特别是,如果要使用C或其他高级语言编写代码,则每个线程都需要一个唯一的堆栈。
您可以仅限制线程具有固定的最大堆栈大小,但更好的处理方式是使用分页,它允许有效地实现“无限大小”堆栈。
以下是一个天真的aarch64 baremetal示例,如果堆栈增长得太深就会崩溃
这些都是使用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
此类用户空间指令似乎也被用于实现 Linux 的
futex
系统调用,它是 Linux 中的主要同步原语之一。
man futex
4.15 读取:
futex()系统调用提供了一种等待某个条件变为真的方法。它通常作为共享内存同步上下文中的阻塞构造使用。使用futexes时,大多数同步操作在用户空间中执行。只有当程序可能需要长时间阻塞直到条件成为真时,用户空间程序才会使用futex()系统调用。其他futex()操作可用于唤醒等待特定条件的任何进程或线程。
系统调用本身的名称意味着“快速用户空间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)
:
:
);
__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)
:
:
);
__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);
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
从这里我们可以看出,x86 LOCK前缀 / aarch64 LDADD
指令使加法操作具有原子性:如果没有它,许多加法操作会存在竞争条件,并且最终的总计数少于同步的20000。
另请参阅:
在Ubuntu 19.04 amd64上测试,并使用QEMU aarch64用户模式。