英特尔x86最小可运行裸机示例
具有所有必需样板的可运行裸机示例。所有主要部分均在下面介绍。
在Ubuntu 15.10 QEMU 2.3.0和Lenovo ThinkPad T400 真实硬件客户机上进行了测试。
在英特尔手册卷3系统编程指南- 325384-056US 2015年9月盖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环(用户程序)无法进行大多数操作。
因此,您需要编写自己的内核以自由使用它:userland Linux程序将无法运行。
首先,运行单个处理器,称为引导处理器(BSP)。
它必须通过称为处理器间中断(IPI)的特殊中断唤醒其他处理器(称为应用处理器(AP))。
可以通过中断命令寄存器(ICR)对高级可编程中断控制器(APIC)进行编程来完成这些中断。
ICR的格式记录在:10.6“ ISSUING INTERPROCESSOR INTERRUPTS”
一旦我们写到ICR,IPI就发生了。
ICR_LOW在8.4.4“ MP初始化示例”中定义为:
ICR_LOW EQU 0FEE00300H
神奇的值0FEE00300
是ICR的存储器地址,如表10-1“本地APIC寄存器地址映射”中所述
在示例中使用了最简单的方法:它将ICR设置为发送广播IPI,这些IPI将传递到除当前处理器之外的所有其他处理器。
但是也有可能,并且有人建议通过 BIOS设置的特殊数据结构(例如ACPI表或Intel的MP配置表)获取有关处理器的信息,然后仅逐个唤醒您需要的那些信息。
XX
in 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(在我的示例中使用)
- 高温PET
- 使用上述方法校准繁忙循环的时间,并改用它
相关:如何在屏幕上显示数字并使用DOS x86组件睡眠一秒钟?
我认为初始处理器需要处于保护模式才能正常工作,因为我们写入的地址0FEE00300H
对于16位而言太高了
为了在处理器之间进行通信,我们可以在主进程上使用自旋锁,然后从第二个内核修改锁。
我们应该确保完成内存写回操作,例如通过wbinvd
。
处理器之间的共享状态
8.7.1“逻辑处理器的状态”说:
以下功能是支持Intel超线程技术的Intel 64或IA-32处理器中逻辑处理器的体系结构状态的一部分。这些功能可以分为三组:
- 每个逻辑处理器重复
- 由物理处理器中的逻辑处理器共享
- 共享或重复,具体取决于实现
每个逻辑处理器都复制了以下功能:
- 通用寄存器(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。
逻辑处理器共享以下功能:
下列功能是共享还是重复是特定于实现的:
- IA32_MISC_ENABLE MSR(MSR地址1A0H)
- 机器检查体系结构(MCA)MSR(IA32_MCG_STATUS和IA32_MCG_CAP MSR除外)
- 性能监视控制和计数器MSR
缓存共享在以下位置讨论:
英特尔超线程比单独的内核具有更大的缓存和管道共享:https : //superuser.com/questions/133082/hyper-threading-and-dual-core-whats-the-difference/995858#995858
Linux内核4.2
主要的初始化动作似乎在arch/x86/kernel/smpboot.c
。
ARM最小可运行裸机示例
在这里,我为QEMU提供了一个最小的可运行ARMv8 aarch64示例:
.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释放自旋锁时退出。
自旋锁之后,CPU 0然后执行半主机退出调用,这会使QEMU退出。
如果仅使用一个CPU启动QEMU -smp 1
,则模拟将永远挂在自旋锁上。
通过PSCI接口唤醒了CPU 1,更多详细信息位于:ARM:启动/唤醒/启动其他CPU内核/ AP并传递执行开始地址?
在上游的版本也有一些调整,使其在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上进行了测试。
进一步简化编程的后续步骤
前面的示例唤醒辅助CPU,并使用专用指令执行基本内存同步,这是一个不错的开始。
但是,为了使多核系统易于编程,例如POSIX pthreads
,您还需要进入以下涉及更多的主题:
安装程序中断并运行一个计时器,该计时器定期确定现在将运行哪个线程。这被称为抢占式多线程。
这种系统还需要在启动和停止线程寄存器时保存和恢复它们。
也可以有非抢占式多任务处理系统,但是这些系统可能需要您修改代码,以便每个线程都能产生收益(例如,使用 pthread_yield
实现),并且很难平衡工作负载。
以下是一些简单的裸机计时器示例:
处理内存冲突。值得注意的是,每个线程都需要一个唯一的堆栈如果您要使用C或其他高级语言进行编码。
您可以将线程限制为具有固定的最大堆栈大小,但是处理此问题的更好方法是使用分页,这可以实现有效的“无限大小”堆栈。
这是一个幼稚的aarch64裸机示例,如果堆栈太深,它将崩溃
这些是使用Linux内核或其他操作系统的一些很好的理由:-)
Userland内存同步原语
尽管线程启动/停止/管理通常不在用户区范围内,但是您可以使用用户区线程中的汇编指令来同步内存访问,而无需花费更多的系统调用。
当然,您应该更喜欢使用可移植地包装这些低级原语的库。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()系统调用提供了一种等待直到特定条件变为真的方法。它通常在共享内存同步的上下文中用作阻止结构。使用futex时,大多数同步操作在用户空间中执行。用户空间程序仅在程序必须阻塞更长的时间直到条件变为真时才使用futex()系统调用。其他futex()操作可用于唤醒等待特定条件的任何进程或线程。
syscall名称本身的意思是“快速用户空间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
从中我们可以看到x86 LOCK前缀/ aarch64 LDADD
指令使加法原子成为原子:没有它,我们在许多加法上都存在竞争条件,并且最后的总数少于同步的20000。
也可以看看:
已在Ubuntu 19.04 amd64和QEMU aarch64用户模式下进行测试。