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

309

从前,在写x86汇编语言时,例如,你需要指令说明“将EDX寄存器加载为值5”,“增加EDX”寄存器等。

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

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

如果您要编写操作系统,硬件公开了哪种机制可以允许您在不同的核心上调度执行?这是一些特殊的特权指令吗?

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

x86机器代码已经做出哪些改变以支持多核功能?


2
这里有一个类似的问题(虽然不完全相同): https://dev59.com/53RB5IYBdhLWcg3wHkPi - Nathan Fellman
11个回答

195
这不是对问题的直接回答,而是对评论中提到的问题的回答。本质上,问题是硬件对多核操作的支持,即在没有软件上下文切换的情况下同时运行多个软件线程的能力(有时称为SMP系统)。
至少在x86架构中,Nicholas Flynt说得对。在多核环境中(超线程、多核或多处理器),引导核心(通常是处理器0中的核心0中的硬件线程(又称逻辑核心)0)启动并从地址0xfffffff0获取代码。所有其他核心(硬件线程)都以一种特殊的休眠状态启动,称为“Wait-for-SIPI”。作为初始化的一部分,主核心通过APIC发送一个名为SIPI(启动IPI)的特殊的处理器间中断(IPI)到每个处于WFS状态的核心。SIPI包含该核心应从其开始获取代码的地址。
这种机制允许每个核心从不同的地址执行代码。所需的只是每个硬件核心的软件支持,以设置其自己的表和消息队列。
操作系统使用这些表和队列来执行实际的多线程软件任务调度。(正常操作系统仅需要在启动时一次性启动其他内核,除非您正在热插拔CPU,例如在虚拟机中。这与启动或迁移软件线程到这些内核上是分开的。每个核心都在运行内核,它花费时间调用睡眠函数等待中断,如果没有其他事情需要做。)
就实际汇编而言,如Nicholas所写,单线程或多线程应用程序的汇编不存在区别。每个核心都有自己的寄存器集(执行上下文),因此编写:
mov edx, 0

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


2
感谢您填补了尼古拉斯回答中的空白。现在我已将您的回答标记为采纳答案....您提供了我感兴趣的具体细节...虽然如果有一个将您和尼古拉斯的信息合并在一起的单一答案会更好。 - Paul Hollingsworth
4
这并没有回答“线程”从何而来的问题。核心和处理器是硬件,但线程必须在软件中创建。主线程如何知道将SIPI发送到哪里?或者,SIPI本身会创建一个新的线程吗? - rich remer
10
看起来你混淆了硬件线程和软件线程。硬件线程始终存在,有时处于休眠状态。SIPI本身会唤醒硬件线程并允许运行软件。由操作系统和BIOS决定哪些硬件线程运行,哪些进程和软件线程在每个硬件线程上运行。 - Nathan Fellman
3
这里有很多好的简洁信息,但这是一个大课题 - 所以问题可能会持续存在。在野外有一些完整的“裸骨”内核示例,可以从USB驱动器或“软盘”启动 - 这是一个使用旧的TSS描述符以汇编语言编写的x86_32版本,实际上可以运行多线程C代码(https://github.com/duanev/oz-x86-32-asm-003),但没有标准库支持。虽然远远超出了您的要求,但它可能可以回答一些仍然悬而未决的问题。 - duanev
@richremer:这个答案之前对于逻辑核心(硬件线程)和软件线程都使用了“线程”。如果你已经了解了这一背景知识,才能够知道它是在讨论哪一个。我进行了澄清,在所有讨论硬件执行上下文的地方写上“逻辑核心”或“核心”,并在所有讨论操作系统可以调度到机器的核心上的软件线程/进程的地方写上“线程”。(在具有超线程或其他SMT的CPU上,每个物理核心都有多个执行上下文,即逻辑核心)。 - Peter Cordes

136

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    ; 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

在那个代码上:
  1. 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.

  2. 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.

  3. 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"

  4. 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.

  5. 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.

  6. 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?

  7. 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

  8. 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)
            :
            :
        );
        // 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用户模式。


你用什么汇编器来编译你的示例?GAS似乎不喜欢你的#include(将其视为注释),NASM、FASM、YASM不知道AT&T语法,所以它们也不是...那是什么呢? - Ruslan
1
@Ruslan gcc#include 来自 C 预处理器。请使用在入门部分中解释的提供的 Makefile:https://github.com/cirosantilli/x86-bare-metal-examples/blob/8ceb4b27a68d44318b58db4c48ccf8b95eca9f1b/getting-started.md 如果这样不起作用,请打开一个 GitHub 问题。 - Ciro Santilli OurBigBook.com
在x86架构上,如果一个核心意识到队列中没有更多的进程可以运行(这可能会在空闲系统上时不时发生),会发生什么?它会自旋锁定共享内存结构,直到有新任务吗?(这可能不是很好,因为它会消耗大量功率)它会调用类似HLT的东西来睡眠,直到有中断吗?(在这种情况下,谁负责唤醒该核心?) - tigrou
@tigrou 不确定,但我认为 Linux 实现极有可能将其置于电源状态,直到下一个(可能是定时器)中断,特别是在强调功耗的 ARM 上。我建议快速尝试一下,看看是否可以通过运行 Linux 的模拟器的指令跟踪来观察到具体情况,这可能是:https://github.com/cirosantilli/linux-kernel-module-cheat/tree/50ac89b779363774325c81157ec8b9a6bdb50a2f#gem5-tracing - Ciro Santilli OurBigBook.com
1
一些关于x86/Windows的信息可以在这里找到(请参见“空闲线程”)。简而言之:当CPU上不存在可运行的线程时,CPU会被分派到一个空闲线程。除了其他一些任务外,它最终将调用由CPU供应商提供的驱动程序通过注册的电源管理处理器空闲例程。这可能会将CPU转换为某些更深的C状态(例如:C0-> C3),以便降低功耗。 - tigrou
1
哇靠,Ciro居然花了他的硕士学位来给OP写个答案。真是个又长又详细的回答,谢谢! - undefined

48

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

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


34
不过,这确实引出了一个问题:操作系统可用哪些指令来执行此操作? - Paul Hollingsworth
4
有一组特权指令可以做到这一点,但这是操作系统的问题,而不是应用程序代码的问题。如果应用程序代码想要支持多线程,它必须调用操作系统函数来实现“魔法”。 - sharptooth
2
BIOS通常会识别可用的核心数,并在被询问时将此信息传递给操作系统。有一些标准,BIOS(和硬件)必须符合这些标准,以便从操作系统的角度来看,不同PC的硬件细节(处理器、核心、PCI总线、PCI卡、鼠标、键盘、图形、ISA、PCI-E/X、内存等)看起来是相同的。如果BIOS没有报告有四个核心,操作系统通常会假定只有一个核心。甚至可能有一个BIOS设置可以进行实验。 - Olof Forshell
2
这很酷,但如果你正在编写裸机程序呢? - Alexander Ryan Baggett
3
@AlexanderRyanBaggett, ? 这是什么意思?重申一下,当我们说“交给操作系统处理”,实际上是在回避问题,因为问题是:操作系统是如何处理的呢?它使用了哪些汇编指令? - Pacerier
显示剩余3条评论

44

非官方SMP常见问题解答 stack overflow logo


曾经,要编写x86汇编代码,例如,你需要指定“将EDX寄存器加载为值5”,“增加EDX寄存器”等指令。现代CPU有4个核心(甚至更多),在机器码级别上,看起来就像有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路多核CPU几乎与n个独立处理器共享同一内存相同来回答。12有一个重要的问题没有被问到:如何编写程序以在多个核心上运行以获得更好的性能?答案是:使用像Pthreads.这样的线程库编写。一些线程库使用不可见于操作系统的“绿色线程”,这些线程不会获得单独的核心,但只要线程库使用内核线程功能,那么您的线程化程序将自动成为多核心。
1.出于向后兼容性考虑,仅第一个核心在复位时启动,并需要执行一些驱动程序类型的操作才能启动其余的核心。
2.它们也自然地共享所有外围设备。


4
我认为“线程”一直是一个软件概念,这让我很难理解多核处理器。问题是,代码如何告诉一个核心:“我将创建一个在线程2中运行的线程”?是否有特殊的汇编代码可以实现这一点? - demonguy
3
没有特别的指令来实现这种功能。您可以通过设置亲和力掩码(指定“此线程可在这些逻辑核心上运行”)来请求操作系统在特定的处理器核心上运行您的线程。这完全是一个软件问题。每个CPU核心(硬件线程)都独立地运行着Linux(或Windows)。它们使用共享数据结构与其他硬件线程进行协同工作,但你永远不会“直接”在另一个CPU上启动一个线程。您只需告诉操作系统您想要一个新的线程即可,然后它会在一个数据结构中记录下来,并被其他核心上的操作系统所看到。 - Peter Cordes
2
我可以识别它,但如何将代码放到特定的核心上? - demonguy
5
每个核心共享操作系统映像,并在同一位置开始运行它。因此,对于8个核心,即在内核中运行8个“硬件进程”。每个进程调用相同的调度器函数,该函数检查进程表以获取可运行的进程或线程(即运行队列)。同时,具有线程的程序无需意识到底层SMP特性,它们只需fork(2)或其他方式,并让内核知道它们要运行。实际上,是核心找到进程,而不是进程找到核心。 - DigitalRoss
1
你实际上不需要从一个核心中断另一个核心。这样想:以前你需要用软件机制进行通信,而现在仍然可以使用相同的软件机制。因此,管道、内核调用、睡眠/唤醒等所有东西...它们仍然像以前一样工作。虽然不是每个进程都在同一个CPU上运行,但它们具有与以前相同的通信数据结构。在SMP方面的努力主要集中在使旧锁在更并行的环境中工作。 - DigitalRoss
显示剩余4条评论

11
如果你要为多核CPU编写优化的编译器/字节码虚拟机,需要了解什么是x86,以使其生成能够高效运行在所有核心上的代码?作为一名编写优化编译器/字节码虚拟机的人,我可以在这里帮助您。要让它生成能够在所有核心上高效运行的代码,您不需要了解x86的任何具体内容。但是,您可能需要了解cmpxchg等内容,以编写能够在所有核心上正确运行的代码。多核编程需要在执行线程之间使用同步和通信。您可能需要了解一些关于x86的内容,以便在x86上生成通用的高效代码。您应该学习操作系统(Linux或Windows或OSX)提供的允许您运行多个线程的功能。 您应该了解并行化API,如OpenMP和Threading Building Blocks,或OSX 10.6“Snow Leopard”的即将推出的“Grand Central”。您应该考虑您的编译器是否应该自动并行化,或者由您编译的应用程序的作者是否需要在程序中添加特殊语法或API调用来利用多个核心。

许多流行的虚拟机(如.NET和Java)存在一个问题,即它们的主要GC过程被锁定并基本上是单线程的。 - Marco van de Voort

9
每个核心都从不同的内存区域执行。您的操作系统将把一个核心指向您的程序,该核心将执行您的程序。您的程序不会意识到存在多个核心或正在哪个核心上执行。
此外,操作系统也没有额外的指令。这些核心与单核芯片相同。每个核心运行的是操作系统的一部分,它将处理通信以查找下一个要执行的内存区域所使用的公共内存区域。
这只是一个简化版,但可以让您了解基本的执行方式。Embedded.com上的有关多核和多处理器的更多信息有很多关于这个主题的信息……这个主题很快就变得非常复杂!

我认为在这里应该更加仔细地区分多核的工作原理以及操作系统对其的影响。在我看来,“每个核从不同的内存区域执行”过于误导人。首先,从原则上讲,使用多个核并不需要这样做,而且对于一个线程化的程序,你很容易想到希望两个核心同时处理相同的文本和数据段(同时每个核心也需要独立的资源,如堆栈)。 - Volker Stolz
@ShiDoiSi 这就是为什么我的回答中包含了文本“这是一个简化版”。 - Gerhard

5
汇编代码将被翻译成机器码,并在一个核心上执行。如果您希望它是多线程的,您将需要使用操作系统原语来启动此代码多次,或在不同的核心上启动不同的代码片段 - 每个核心将执行一个单独的线程。每个线程只能看到它当前正在执行的一个核心。

5
我原本想说这样的话,但操作系统如何将线程分配给核心呢?我想可能有一些特权汇编指令可以完成这个任务。如果是这样的话,我认为这就是作者要找的答案。 - A. Levy
没有这样的指令,这是操作系统调度程序的职责。在Win32中有一些操作系统函数,如SetThreadAffinityMask,代码可以调用它们,但这是操作系统的东西并且会影响调度程序,而不是处理器指令。 - sharptooth
3
操作系统必须有操作码,否则它不能执行相应的操作。 - Matthew Whited
4
并非真正的用于调度的操作码 - 它更像是每个处理器都获得一个操作系统副本,共享内存空间;每当一个核心重新进入内核(系统调用或中断),它会查看内存中相同的数据结构,以决定要运行哪个线程。 - pjc50
2
@A.Levy:当您使用只允许在不同核心上运行的亲和力启动线程时,它不会立即移动到另一个核心。它的上下文被保存到内存中,就像正常的上下文切换一样。其他硬件线程在调度程序数据结构中看到它的条目,并且其中一个线程最终将决定运行该线程。因此,从第一个核心的角度来看:您写入共享数据结构,最终在另一个核心(硬件线程)上的操作系统代码将注意到它并运行它。 - Peter Cordes

3
它完全不是通过机器指令实现的。每个核心都假装是独立的CPU,并没有任何特殊的功能来相互通信。它们之间有两种通信方式:
  • 它们共享物理地址空间。硬件处理高速缓存一致性,因此一个CPU写入一个内存地址,另一个CPU将读取该地址。
  • 它们共享可编程中断控制器(APIC)。APIC映射到物理地址空间中,并可被一个处理器用于控制其他处理器,打开或关闭它们,发送中断等。
http://www.cheesecake.org/sac/smp.html 是一个带有愚蠢URL的好参考资料。

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

2
我认为提问者可能想通过让多个核心并行工作来加快程序运行速度。这就是我想要的,但所有答案都让我一无所知。然而,我认为我明白了:您无法将不同的线程同步到指令执行时间准确度。因此,您无法让4个核心并行执行对四个不同数组元素进行乘法以加速处理4:1。相反,您必须将程序视为由按顺序执行的主要块组成,例如
  1. 在一些数据上执行FFT
  2. 将结果放入矩阵中,并找到其特征值和特征向量
  3. 按特征值对后者进行排序
  4. 使用新数据从步骤1开始重复
您可以在不同核心上运行步骤2以处理步骤1的结果,同时在不同核心上运行步骤3以处理步骤2在下一个数据上运行时的结果,而步骤1则在其之后的数据上运行。 您可以在Compaq Visual Fortran和Intel Fortran中实现这一点,方法是编写三个单独的程序/子例程来完成三个步骤,而不是一个“调用”下一个,而是调用API启动其线程。 它们可以使用COMMON共享数据,这将是所有线程的COMMON数据内存。 您必须学习手册直到头痛,并进行实验,直到成功至少一次。

有些单一问题足够大,可以进行并行化处理,例如大型矩阵乘法或大型FFT(http://www.fftw.org/parallel/parallel-fftw.html)。一些库提供了并行化实现。但是,确实,线程只适用于相对粗略的并行性,因为分配工作和收集结果需要的开销较大。 - Peter Cordes

1
单线程应用程序和多线程应用程序的主要区别在于前者有一个堆栈,而后者每个线程都有一个堆栈。代码生成略有不同,因为编译器将假定数据和堆栈段寄存器(ds和ss)不相等。这意味着通过ebp和esp寄存器进行间接引用时,默认情况下指向ss寄存器的操作不会同时指向ds(因为ds!=ss)。相反,通过默认指向ds的其他寄存器进行间接引用时,不会默认指向ss。
线程共享所有其他内容,包括数据和代码区域。它们还共享库例程,因此请确保它们是线程安全的。可以将在RAM中对区域进行排序的过程多线程化以加快速度。然后,线程将访问、比较和排序相同物理内存区域中的数据,并执行相同的代码,但使用不同的本地变量来控制它们各自的排序部分。当然,这是因为线程具有不同的堆栈,其中包含本地变量。这种类型的编程需要仔细调整代码,以减少核间数据冲突(在缓存和RAM中),从而产生比只有一个线程更快的代码。当然,未经调整的代码通常比使用两个或更多处理器更快。调试更具挑战性,因为标准的“int 3”断点将不适用,因为您想要中断特定线程而不是所有线程。调试寄存器断点也无法解决此问题,除非您可以将它们设置在执行特定线程的特定处理器上。
其他多线程代码可能涉及在程序的不同部分运行的不同线程。这种类型的编程不需要相同类型的调整,因此学习起来要容易得多。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接