Intel x86 最小可运行裸机示例
具有所有必需样板的可运行裸机示例。下面介绍了所有主要部分。
在 Ubuntu 15.10 QEMU 2.3.0 和 Lenovo ThinkPad T400真实硬件客户机上测试。
英特尔手册第 3 卷系统编程指南 - 325384-056US 2015 年 9 月在第 8、9 和 10 章中介绍了 SMP。
表 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) 的处理器。
它必须通过称为处理器间中断 (IPI)的特殊中断唤醒其他处理器(称为应用处理器 (AP) ) 。
这些中断可以通过中断命令寄存器 (ICR) 对高级可编程中断控制器 (APIC) 进行编程来完成
ICR 的格式记录在:10.6 “发出 INTERPROCESSOR INTERRUPTS”
IPI 在我们写入 ICR 后立即发生。
ICR_LOW 在 8.4.4“MP 初始化示例”中定义为:
ICR_LOW EQU 0FEE00300H
魔术值0FEE00300
是 ICR 的内存地址,如表 10-1“本地 APIC 寄存器地址映射”中所述
示例中使用了最简单的方法:它设置 ICR 以发送广播 IPI,这些 IPI 将被传递到除当前处理器之外的所有其他处理器。
但是也有可能并且被一些人推荐,通过 BIOS 设置的特殊数据结构(如ACPI 表或 Intel 的 MP 配置表)获取有关处理器的信息,并且只唤醒您需要的那些。
XX
in000C46XXH
将处理器将执行的第一条指令的地址编码为:
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“逻辑处理器的状态”说:
以下功能是支持英特尔超线程技术的英特尔 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
,那么模拟就永远挂在自旋锁上。
CPU 1 被 PSCI 接口唤醒,更多细节在:ARM: Start/Wakeup/Bringup the other CPU cores/APs and pass execution start address?
上游版本还进行了一些调整以使其适用于 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 内核或其他操作系统的一些很好的理由 :-)
用户态内存同步原语
尽管线程启动/停止/管理通常超出用户空间范围,但是您可以使用来自用户空间线程的汇编指令来同步内存访问,而无需潜在的更昂贵的系统调用。
您当然应该更喜欢使用可移植地包装这些低级原语的库。<mutex>
C++ 标准本身在和标头上取得了很大进步<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
此类用户态指令似乎也用于实现 Linuxfutex
系统调用,这是 Linux 中的主要同步原语之一。man futex
4.15 内容如下:
futex() 系统调用提供了一种等待某个条件成立的方法。它通常用作共享内存同步上下文中的阻塞构造。使用 futex 时,大多数同步操作都是在用户空间中执行的。用户空间程序仅在程序可能必须阻塞更长的时间直到条件变为真时才使用 futex() 系统调用。其他 futex() 操作可用于唤醒任何等待特定条件的进程或线程。
系统调用名称本身的意思是“快速用户空间 XXX”。
这是一个带有内联汇编的最小无用 C++ x86_64 / aarch64 示例,它说明了此类指令的基本用法,主要是为了好玩:
主文件
#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 前缀/aarch64LDADD
指令使加法成为原子:没有它,我们在许多加法上都有竞争条件,最后的总计数小于同步的 20000。
也可以看看:
在 Ubuntu 19.04 amd64 和 QEMU aarch64 用户模式下测试。