ChatGPT解决这个技术问题 Extra ChatGPT

多核汇编语言是什么样的?

曾几何时,要编写 x86 汇编程序,例如,您会收到说明“加载 EDX 寄存器的值 5”、“增加 EDX 寄存器”等指令。

对于具有 4 个内核(甚至更多)的现代 CPU,在机器代码级别是否看起来像有 4 个独立的 CPU(即只有 4 个不同的“EDX”寄存器)?如果是这样,当您说“增加 EDX 寄存器”时,是什么决定了哪个 CPU 的 EDX 寄存器增加?现在 x86 汇编器中是否有“CPU 上下文”或“线程”概念?

核心之间的通信/同步如何工作?

如果您正在编写一个操作系统,通过硬件公开什么机制允许您在不同的内核上调度执行?它是一些特殊的特权指令吗?

如果您正在为多核 CPU 编写优化编译器/字节码 VM,您需要特别了解 x86 以使其生成可在所有内核上高效运行的代码?

对 x86 机器代码进行了哪些更改以支持多核功能?

这里有一个类似(尽管不相同)的问题:stackoverflow.com/questions/714905/…

P
Peter Cordes

这不是对问题的直接回答,而是对评论中出现的问题的回答。从本质上讲,问题是硬件为多核操作提供了什么支持,能够真正同时运行多个软件线程,而无需在它们之间进行软件上下文切换。 (有时称为 SMP system)。

Nicholas Flynt had it right,至少关于 x86。在多核环境(超线程、多核或多处理器)中,Bootstrap 内核(通常是处理器 0 中内核 0 中的硬件线程(又名逻辑内核)0)启动从地址 0xfffffff0 获取代码。所有其他内核(硬件线程)在称为 Wait-for-SIPI 的特殊睡眠状态下启动。作为其初始化的一部分,主内核通过 APIC 向 WFS 中的每个内核发送一个称为 SIPI(启动 IPI)的特殊处理器间中断 (IPI)。 SIPI 包含该内核应该开始获取代码的地址。

这种机制允许每个内核执行来自不同地址的代码。所需要的只是对每个硬件核心的软件支持,以建立自己的表和消息队列。

操作系统使用这些来执行软件任务的实际多线程调度。 (一个普通的操作系统只需要在启动时启动一次其他内核,除非你在热插拔 CPU,例如在虚拟机中。这与启动或迁移软件线程到这些内核是分开的。每个内核都在运行内核,如果没有其他事情要做,它会花时间调用 sleep 函数来等待中断。)

就实际程序集而言,正如 Nicholas 所写,单线程或多线程应用程序的程序集之间没有区别。每个内核都有自己的寄存器集(执行上下文),因此编写:

mov edx, 0

只会为当前运行的线程更新 EDX。无法使用单个汇编指令在另一个处理器上修改 EDX。您需要某种系统调用来要求操作系统告诉另一个线程运行将更新其自己的 EDX 的代码。


感谢您填补尼古拉斯回答中的空白。现在已将您的答案标记为已接受的答案....给出了我感兴趣的具体细节...尽管如果有一个答案将您的信息和尼古拉斯的所有信息结合起来会更好。
这并没有回答线程来自哪里的问题。内核和处理器是硬件,但必须以某种方式在软件中创建线程。主线程如何知道将 SIPI 发送到哪里?还是 SIPI 本身会创建一个新线程?
@richremer:您似乎在混淆硬件线程和软件线程。硬件线程始终存在。有时它睡着了。 SIPI 本身唤醒 HW 线程并允许它运行 SW。由操作系统和 BIOS 决定运行哪些硬件线程,以及在每个硬件线程上运行哪些进程和软件线程。
这里有很多简洁明了的信息,但这是一个很大的话题——所以问题可能会挥之不去。在野外有一些完整的“裸机”内核示例,它们从 USB 驱动器或“软盘”磁盘引导 - 这是一个使用旧 TSS 描述符用汇编程序编写的 x86_32 版本,它实际上可以运行多线程 C 代码({1 }) 但没有标准库支持。比您要求的要多得多,但它也许可以回答一些挥之不去的问题。
@richremer:这个答案之前对逻辑核心(硬件线程)和软件线程都使用了“线程”。您必须已经了解该背景知识才能知道它在哪里谈论。我已经消除了歧义,在谈论硬件执行上下文的任何地方都写“逻辑核心”或“核心”,而在谈论操作系统可以调度到机器核心上的软件线程/进程的任何地方都写“线程”。 (在具有超线程或其他 SMT 的 CPU 上,每个物理内核都有多个执行上下文,也就是逻辑内核)。
C
Ciro Santilli Путлер Капут 六四事

Intel x86 最小可运行裸机示例

Runnable bare metal example with all required boilerplate。下面介绍了所有主要部分。

在 Ubuntu 15.10 QEMU 2.3.0 和联想 ThinkPad T400 real hardware guest 上测试。

Intel Manual Volume 3 System Programming Guide - 325384-056US September 2015 在第 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 “发出处理器中断” IPI 在我们写入 ICR 后立即发生。 ICR_LOW 在第 8.4.4 节“MP 初始化示例”中定义为: ICR_LOW EQU 0FEE00300H 魔术值 0FEE00300 是 ICR 的内存地址,如表 10-1“本地 APIC 寄存器地址映射”中所述 最简单的可能方法用于示例:它设置 ICR 以发送广播 IPI,这些 IPI 被传递到除当前处理器之外的所有其他处理器。但是也有可能,并且被一些人推荐,通过 BIOS 设置的特殊数据结构(如 ACPI 表或 Intel 的 MP 配置表)获取有关处理器的信息,并且只唤醒您需要的那些。 000C46XXH 中的 XX 将处理器将执行的第一条指令的地址编码为: 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 /* 做事。 */ hlt .equ init_len, . - init 使用链接描述文件是另一种可能性。延迟循环是一个令人讨厌的部分:没有超级简单的方法可以精确地进行这种睡眠。可能的方法包括:PIT(在我的示例中使用)HPET 使用上述校准繁忙循环的时间,并改用它相关:如何在屏幕上显示数字并使用 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。以下功能由逻辑处理器共享: 内存类型范围寄存器 (MTRR) 以下功能是否共享或复制取决于具体实现: IA32_MISC_ENABLE MSR(MSR 地址 1A0H) 机器检查架构 (MCA) MSR(IA32_MCG_STATUS 和 IA32_MCG_CAP 除外MSRs) 性能监控控制和计数器 MSRs

缓存共享在以下位置讨论:

多核 Intel CPU 中的高速缓存如何共享?

http://stackoverflow.com/questions/4802565/multiple-threads-and-cpu-cache

多个 CPU / 内核可以同时访问同一个 RAM 吗?

与单独的内核相比,英特尔超线程具有更大的缓存和管道共享: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 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 的 CPU 启动 QEMU,那么模拟就会永远挂在自旋锁上。

CPU 1 被 PSCI 接口唤醒,更多细节在:ARM: Start/Wakeup/Bringup the other CPU cores/APs and pass execution start address?

upstream version 还进行了一些调整以使其适用于 gem5,因此您也可以试验性能特征。

我还没有在真正的硬件上测试过它,所以我不确定它的便携性。以下 Raspberry Pi 参考书目可能会引起您的兴趣:

https://github.com/bztsrc/raspi3-tutorial/tree/a3f069b794aeebef633dbe1af3610784d55a0efa/02_multicorec

https://github.com/dwelch67/raspberrypi/tree/a09771a1d5a0b53d8e7a461948dc226c5467aeec/multi00

https://github.com/LdB-ECM/Raspberry-Pi/blob/3b628a2c113b3997ffdb408db03093b2953e4961/Multicore/SmartStart64.S

https://github.com/LdB-ECM/Raspberry-Pi/blob/3b628a2c113b3997ffdb408db03093b2953e4961/Multicore/SmartStart32.S

本文档提供了一些有关使用 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 实现),并且平衡工作负载变得更加困难。以下是一些简单的裸机计时器示例:x86 PIT

x86 坑

处理内存冲突。值得注意的是,如果您想用 C 或其他高级语言进行编码,每个线程都需要一个唯一的堆栈。您可以将线程限制为具有固定的最大堆栈大小,但处理此问题的更好方法是分页,它允许有效的“无限大小”堆栈。这是一个天真的 aarch64 裸机示例,如果堆栈增长得太深,它会爆炸

这些是使用 Linux 内核或其他操作系统的一些很好的理由 :-)

用户态内存同步原语

尽管线程启动/停止/管理通常超出用户态范围,但是您可以使用来自用户态线程的汇编指令来同步内存访问,而无需潜在的更昂贵的系统调用。

您当然应该更喜欢使用可移植地包装这些低级原语的库。 C++ 标准本身在 <mutex><atomic> 标头上取得了很大进步,尤其是 std::memory_order。我不确定它是否涵盖了所有可能实现的内存语义,但它只是可能。

更微妙的语义与 lock free data structures 的上下文特别相关,在某些情况下可以提供性能优势。要实现这些,您可能需要了解一些不同类型的内存屏障: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() 操作可用于唤醒任何等待特定条件的进程或线程。

系统调用名称本身的意思是“快速用户空间 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 upstream

可能的输出:

my_non_atomic_ulong 15264
my_arch_non_atomic_ulong 15267

从这里我们看到 x86 LOCK 前缀 / aarch64 LDADD 指令使加法成为原子:没有它,我们在许多加法上都有竞争条件,最后的总计数小于同步的 20000。

也可以看看:

x86 LOCK x86 汇编中的“lock”指令是什么意思? PAUSE x86 pause 指令如何在自旋锁中工作*并且*它可以在其他场景中使用吗?

LOCK x86 汇编中的“lock”指令是什么意思?

PAUSE x86 pause 指令如何在自旋锁中工作*并且*它可以在其他场景中使用吗?

ARM LDXR/STXR, LDAXR/STLXR: ARM64: LDXR/STXR vs LDAXR/STLXR LDADD 和其他原子 v8.1 加载修改存储指令:http://infocenter.arm.com/help/index.jsp?topic=/com .arm.doc.dui0801g/alc1476202791033.html WFE / SVE:ARM 中的 WFE 指令处理

LDXR/STXR、LDXR/STLXR:ARM64:LDXR/STXR 与 LDAXR/STLXR

LDADD 和其他原子 v8.1 加载修改存储说明:http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0801g/alc1476202791033.html

WFE / SVE:ARM 中的 WFE 指令处理

std::atomic 到底是什么?

在 Ubuntu 19.04 amd64 和 QEMU aarch64 用户模式下测试。


你用什么汇编器来编译你的例子? GAS 似乎不喜欢您的 #include(将其作为评论),NASM、FASM、YASM 不知道 AT&T 语法,所以不可能是它们……那是什么?
@Ruslan gcc, #include 来自 C 预处理器。按照入门部分中的说明使用提供的 Makefilegithub.com/cirosantilli/x86-bare-metal-examples/blob/… 如果这不起作用,请打开 GitHub 问题。
在 x86 上,如果核心意识到队列中没有准备好运行的进程会怎样? (在空闲系统上可能不时发生)。共享内存结构上的核心自旋锁,直到有一个新任务? (可能不好,它会消耗大量电力)它是否会调用 HLT 之类的东西来休眠,直到出现中断? (在那种情况下,谁负责唤醒那个核心?)
@tigrou 不确定,但我发现 Linux 实现极有可能将其置于电源状态,直到下一次(可能是定时器)中断,尤其是在电源是关键的 ARM 上。我会快速尝试通过运行 Linux 的模拟器的指令跟踪来查看是否可以具体轻松地观察到这一点,它可能是:github.com/cirosantilli/linux-kernel-module-cheat/tree/…
可以在 here 中找到一些信息(特定于 x86 / Windows)(请参阅“空闲线程”)。 TL;DR :当 CPU 上不存在可运行线程时,CPU 被分派到空闲线程。连同其他一些任务,它最终会调用已注册的电源管理处理器空闲例程(通过 CPU 供应商提供的驱动程序,例如:Intel)。这可能会将 CPU 转换到一些更深的 C 状态(例如:C0 -> C3)以降低功耗。
B
Blank

据我了解,每个“核心”都是一个完整的处理器,有自己的寄存器集。基本上,BIOS 以一个内核运行开始,然后操作系统可以通过初始化其他内核并将它们指向要运行的代码等来“启动”其他内核。

同步由操作系统完成。通常,每个处理器为操作系统运行不同的进程,因此操作系统的多线程功能负责决定哪个进程访问哪个内存,以及在内存冲突的情况下该怎么做。


但这确实引出了一个问题:操作系统可以使用哪些指令来执行此操作?
有一组特权指令,但这是操作系统的问题,而不是应用程序代码。如果应用程序代码想要多线程,它必须调用操作系统函数来实现“魔法”。
BIOS 通常会确定有多少内核可用,并在被询问时将此信息传递给操作系统。 BIOS(和硬件)必须符合一些标准,以便访问不同 PC 的硬件细节(处理器、内核、PCI 总线、PCI 卡、鼠标、键盘、图形、ISA、PCI-E/X、内存等)从操作系统的角度来看是一样的。如果 BIOS 没有报告有四个内核,操作系统通常会假设只有一个。甚至可能有一个 BIOS 设置可供试验。
这很酷,但如果你正在编写一个裸机程序怎么办?
@AlexanderRyanBaggett,?那还算什么?重申一下,当我们说“留给操作系统”时,我们是在回避这个问题,因为问题是操作系统如何做到这一点?它使用什么汇编指令?
D
DigitalRoss

非官方 SMP 常见问题解答

曾几何时,要编写 x86 汇编程序,例如,您会收到说明“将 EDX 寄存器的值加载为 5”、“增加 EDX”寄存器等指令。使用具有 4 个内核(甚至更多)的现代 CPU ,在机器代码级别,它只是看起来像有 4 个独立的 CPU(即只有 4 个不同的“EDX”寄存器)吗?

确切地。有 4 组寄存器,包括 4 个独立的指令指针。

如果是这样,当您说“增加 EDX 寄存器”时,是什么决定了哪个 CPU 的 EDX 寄存器增加?

自然是执行该指令的 CPU。可以将其视为 4 个完全不同的微处理器,它们只是共享相同的内存。

现在 x86 汇编器中是否有“CPU 上下文”或“线程”概念?

不,汇编器只是像往常一样翻译指令。那里没有变化。

核心之间的通信/同步如何工作?

由于它们共享相同的内存,这主要是程序逻辑的问题。虽然现在有一个 inter-processor interrupt 机制,但它不是必需的,并且最初不存在于第一个双 CPU x86 系统中。

如果您正在编写一个操作系统,通过硬件公开什么机制允许您在不同的内核上调度执行?

调度器实际上并没有改变,只是它对临界区和使用的锁的类型更加小心。在 SMP 之前,内核代码最终会调用调度程序,调度程序会查看运行队列并选择一个进程作为下一个线程运行。 (内核的进程看起来很像线程。)SMP 内核运行完全相同的代码,一次一个线程,只是现在关键部分锁定需要 SMP 安全,以确保两个内核不会意外选择相同的PID。

它是一些特殊的特权指令吗?

不,内核只是在相同的内存中运行,使用相同的旧指令。

如果您正在为多核 CPU 编写优化编译器/字节码 VM,您需要特别了解 x86 以使其生成可在所有内核上高效运行的代码?

您运行与以前相同的代码。需要更改的是 Unix 或 Windows 内核。

您可以将我的问题总结为“对 x86 机器代码进行了哪些更改以支持多核功能?”

没有什么是必要的。第一个 SMP 系统使用与单处理器完全相同的指令集。现在,已经有大量的 x86 架构演变和数以万计的新指令使事情变得更快,但对于 SMP 来说,这些都不是必需的。

有关详细信息,请参阅 Intel Multiprocessor Specification

更新:

n

1

n

2

如何编写一个程序以在多个内核上运行以获得更高的性能?

线程。

1. 为了向后兼容,只有第一个核心在复位时启动,并且需要做一些驱动类型的事情来启动其余的。 2. 他们自然也共享所有外围设备。


我一直认为“线程”是一个软件概念,这让我很难理解多核处理器,问题是,代码如何告诉一个核心“我要创建一个运行在核心 2 中的线程”?有什么特殊的汇编代码可以做到吗?
@demonguy:不,没有类似的特殊说明。您通过设置关联掩码(表示“该线程可以在这组逻辑核心上运行”)来要求操作系统在特定核心上运行您的线程。这完全是软件问题。每个 CPU 内核(硬件线程)都独立运行 Linux(或 Windows)。为了与其他硬件线程一起工作,它们使用共享数据结构。但是您永远不会“直接”在不同的 CPU 上启动线程。你告诉操作系统你想要一个新线程,它会在另一个内核上的操作系统看到的数据结构中做一个注释。
我可以告诉操作系统,但是操作系统如何将代码放到特定的核心上?
@demonguy ...(简化)...每个内核共享操作系统映像并开始在同一个地方运行它。因此,对于 8 个内核,内核中运行着 8 个“硬件进程”。每个都调用相同的调度程序函数来检查进程表中的可运行进程或线程。 (这就是运行队列。)同时,带有线程的程序在不了解底层 SMP 本质的情况下工作。他们只是 fork(2) 或其他东西,让内核知道他们想要运行。本质上,核心找到了进程,而不是进程找到了核心。
您实际上不需要中断一个核心与另一个核心。这样想:你之前需要沟通的一切都可以通过软件机制很好地沟通。相同的软件机制继续工作。所以,管道、内核调用、睡眠/唤醒,所有这些东西......它们仍然像以前一样工作。并非每个进程都在同一个 CPU 上运行,但它们具有与以前相同的通信数据结构。进入 SMP 的努力主要局限于使旧锁在更并行的环境中工作。
A
Alex Brown

如果您正在为多核 CPU 编写优化编译器/字节码 VM,您需要特别了解 x86 以使其生成可在所有内核上高效运行的代码?

作为编写优化编译器/字节码虚拟机的人,我可以在这里为您提供帮助。

您无需了解任何有关 x86 的具体知识,即可生成可在所有内核上高效运行的代码。

但是,您可能需要了解 cmpxchg 和朋友,才能编写在所有内核上正确运行的代码。多核编程需要在执行线程之间使用同步和通信。

您可能需要了解一些关于 x86 的知识,以使其生成通常在 x86 上高效运行的代码。

还有其他一些对你有用的东西:

您应该了解操作系统(Linux 或 Windows 或 OSX)提供的允许您运行多个线程的设施。您应该了解并行化 API,例如 OpenMP 和 Threading Building Blocks,或 OSX 10.6 “Snow Leopard”即将推出的“Grand Central”。

您应该考虑您的编译器是否应该自动并行化,或者由您的编译器编译的应用程序的作者是否需要在他的程序中添加特殊语法或 API 调用以利用多个内核。


像 .NET 和 Java 这样的几个流行的 VM 没有一个问题,即它们的主要 GC 进程被锁覆盖并且基本上是单线程的?
G
Gerhard

每个核心从不同的内存区域执行。您的操作系统会将内核指向您的程序,然后内核将执行您的程序。您的程序不会知道有多个内核或它正在执行哪个内核。

也没有仅适用于操作系统的附加指令。这些内核与单核芯片相同。每个核心运行操作系统的一部分,它将处理与用于信息交换的公共内存区域的通信,以找到下一个要执行的内存区域。

这是一个简化,但它为您提供了如何完成的基本概念。 Embedded.com 上的 More about multicores and multiprocessors 有很多关于这个主题的信息......这个主题很快就会变得复杂!


我认为在这里应该更仔细地区分多核通常是如何工作的,以及操作系统的影响有多大。在我看来,“每个核心都从不同的内存区域执行”太具有误导性了。首先,原则上使用多核不需要这个,你可以很容易地看到,对于一个线程程序,你希望两个核在相同的文本和数据段上工作(而每个核还需要单独的资源,如堆栈) .
@ShiDoiSi 这就是为什么我的答案包含文本“这是一个简化”。
s
sharptooth

汇编代码将转换为将在一个内核上执行的机器代码。如果您希望它是多线程的,您将不得不使用操作系统原语在不同的处理器上多次启动此代码,或者在不同的内核上启动不同的代码 - 每个内核将执行一个单独的线程。每个线程只会看到它当前正在执行的一个内核。


我本来想说这样的话,但是操作系统如何将线程分配给内核呢?我想有一些特权汇编指令可以做到这一点。如果是这样,我认为这就是作者正在寻找的答案。
对此没有任何说明,这是操作系统调度程序的职责。 Win32中有像SetThreadAffinityMask这样的操作系统函数,代码可以调用它们,但它是操作系统的东西,会影响调度程序,它不是处理器指令。
必须有一个操作码,否则操作系统也无法做到这一点。
并不是真正的调度操作码——它更像是每个处理器获得一个操作系统副本,共享内存空间;每当一个内核重新进入内核(系统调用或中断)时,它都会查看内存中的相同数据结构来决定接下来运行哪个线程。
@A.Levy:当您启动一个具有亲和力的线程时,它只允许它在不同的核心上运行,它不会立即移动到另一个核心。它的上下文保存到内存中,就像普通的上下文切换一样。其他硬件线程在调度程序数据结构中看到它的条目,其中一个最终将决定它将运行该线程。所以从第一个核心的角度来看:你写入一个共享的数据结构,最终另一个核心(硬件线程)上的操作系统代码会注意到它并运行它。
p
pjc50

它根本不是在机器指令中完成的。这些内核假装是不同的 CPU,并且没有任何特殊的相互交谈的能力。他们有两种沟通方式:

它们共享物理地址空间。硬件处理缓存一致性,因此一个 CPU 写入另一个 CPU 读取的内存地址。

它们共享一个 APIC(可编程中断控制器)。这是映射到物理地址空间的内存,一个处理器可以使用它来控制其他处理器,打开或关闭它们,发送中断等。

http://www.cheesecake.org/sac/smp.html 是一个很好的参考,带有一个愚蠢的 url。


他们实际上并不共享 APIC。每个逻辑 CPU 都有自己的一个。 APIC 在它们之间进行通信,但它们是分开的。
它们以一种基本方式同步(而不是通信),即通过 LOCK 前缀(指令“xchg mem,reg”包含隐式锁定请求),该前缀运行到运行到所有总线的锁定引脚,有效地告诉他们 CPU (实际上是任何总线主控设备)想要独占访问总线。最终,一个信号将返回到 LOCKA(确认)引脚,告诉 CPU 它现在可以独占访问总线。由于外部设备比 CPU 的内部工作慢得多,因此 LOCK/LOCKA 序列可能需要数百个 CPU 周期才能完成。
O
Olof Forshell

单线程和多线程应用程序的主要区别在于前者有一个堆栈,而后者每个线程都有一个堆栈。由于编译器将假定数据和堆栈段寄存器(ds 和 ss)不相等,因此生成的代码略有不同。这意味着通过默认为 ss 寄存器的 ebp 和 esp 寄存器的间接寻址不会也默认为 ds(因为 ds!=ss)。相反,通过默认为 ds 的其他寄存器的间接寻址不会默认为 ss。

线程共享其他所有内容,包括数据和代码区域。它们还共享 lib 例程,因此请确保它们是线程安全的。对 RAM 中的区域进行排序的过程可以是多线程的以加快速度。然后线程将访问、比较和排序同一物理内存区域中的数据,并执行相同的代码,但使用不同的局部变量来控制它们各自的排序部分。这当然是因为线程有不同的堆栈,其中包含局部变量。这种类型的编程需要仔细调整代码,以减少内核间数据冲突(在高速缓存和 RAM 中),这反过来导致使用两个或更多线程的代码比仅使用一个线程更快。当然,未经调整的代码使用一个处理器通常会比使用两个或更多处理器更快。调试更具挑战性,因为标准的“int 3”断点将不适用,因为您想中断特定线程而不是所有线程。调试寄存器断点也不能解决这个问题,除非您可以在执行您要中断的特定线程的特定处理器上设置它们。

其他多线程代码可能涉及在程序的不同部分运行的不同线程。这种类型的编程不需要相同类型的调整,因此更容易学习。


P
Paul

我认为提问者可能希望通过让多个内核并行工作来使程序运行得更快。无论如何,这就是我想要的,但所有的答案都让我不明智。但是,我想我明白了:您无法将不同的线程同步到指令执行时间的准确性。因此,您无法让 4 个内核并行地对四个不同的数组元素进行乘法运算以将处理速度提高 4:1。相反,您必须将程序视为包含按顺序执行的主要块,例如

对某些数据进行 FFT 将结果放入矩阵中并找到它的特征值和特征向量 按特征值对后者进行排序,从第一步开始重复使用新数据

您可以做的是对第 1 步的结果运行第 2 步,同时在不同核心中对新数据运行第 1 步,并在第 2 步对下一个数据和步骤运行时在不同核心中对第 2 步的结果运行第 3 步1 之后在数据上运行。您可以在 Compaq Visual Fortran 和 Intel Fortran 中执行此操作,这是 CVF 的演变,通过为三个步骤编写三个单独的程序/子例程,而不是一个“调用”下一个它调用 API 来启动其线程。他们可以通过使用 COMMON 共享数据,这将是所有线程的 COMMON 数据内存。您必须研究手册直到头疼并进行试验,直到使它起作用,但我至少成功了一次。


一些单一问题大到足以并行化,例如大型 matmul 或大型 FFT (fftw.org/parallel/parallel-fftw.html)。一些库提供并行实现。但是,是的,很好的答案是线程只适用于稍微粗略的并行性,因为分发工作和收集结果需要开销。
j
jakobengblom2

与之前的单处理器变体相比,每个支持多处理的架构都添加了在内核之间同步的指令。此外,您还有处理缓存一致性、刷新缓冲区和操作系统必须处理的类似低级操作的指令。在同时多线程架构(如 IBM POWER6、IBM Cell、Sun Niagara 和英特尔“超线程”)的情况下,您还倾向于看到新指令来确定线程之间的优先级(例如设置优先级并在无事可做时显式让出处理器) .

但是基本的单线程语义是相同的,您只需添加额外的工具来处理与其他内核的同步和通信。