什么是 CPU 寄存器以及它们如何使用,特别是 WRT 多线程?

2023-11-26

这个问题和我下面的回答主要是针对另一个问题中的一个困惑的地方。

在答案的最后,有一些我并不完全有信心的 WRT“易失性”和线程同步问题 - 我欢迎评论和替代答案。然而,问题的重点主要涉及 CPU 寄存器及其使用方式。


寄存器是 CPU 中的“工作存储器”。它们速度非常快,但资源非常有限。通常,CPU 具有一小组固定的命名寄存器,这些名称是该 CPU 机器代码的汇编语言约定的一部分。例如,32 位 Intel x86 CPU 有四个名为 eax、ebx、ecx 和 edx 的主要数据寄存器,以及许多索引和其他更专用的寄存器。

严格来说,现在情况并不完全正确——例如,寄存器重命名很常见。有些处理器有足够的寄存器,可以对它们进行编号而不是命名等。但是,它仍然是一个很好的基本模型。例如,寄存器重命名用于在乱序执行的情况下保留此基本模型的假象。

在手动编写的汇编程序中使用寄存器往往具有简单的寄存器使用模式。在子例程或其某些重要部分的持续时间内,一些变量将纯粹保留在寄存器中。其他寄存器以读取-修改-写入模式使用。例如...

mov eax, [var1]
add eax, [var2]
mov [var1], eax

IIRC,这是有效的(尽管可能效率低下)x86 汇编代码。在 Motorola 68000 上,我可能会写...

move.l [var1], d0
add.l  [var2], d0
move.l d0, [var1]

这次,源通常是左侧参数,目标在右侧。 68000有8个数据寄存器(d0..d7)和8个地址寄存器(a0..a7),a7 IIRC也用作堆栈指针。

在 6510(回到旧的 Commodore 64)上,我可能会写...

lda    var1
adc    var2
sta    var1

这里的寄存器大多隐含在指令中——上面都使用A(累加器)寄存器。

请原谅这些示例中的任何愚蠢错误 - 至少 15 年以来我没有编写任何大量的“真实”(而不是虚拟)汇编程序。不过,原则才是重点。

寄存器的使用特定于特定的代码片段。寄存器所保存的内容基本上就是其中最后一条指令的内容。程序员有责任跟踪代码中每个点的每个寄存器中的内容。

调用子例程时,调用者或被调用者必须负责确保不存在冲突,这通常意味着寄存器在调用开始时保存到堆栈中,然后在结束时读回。中断也会出现类似的问题。诸如谁负责保存寄存器(调用者或被调用者)之类的事情通常是每个子例程文档的一部分。

编译器通常会比人类程序员以更复杂的方式决定如何使用寄存器,但其运行原理相同。从寄存器到特定变量的映射是动态的,并且根据您正在查看的代码片段而显着变化。保存和恢复寄存器主要是根据标准约定来处理的,尽管编译器在某些情况下可能会即兴发挥“自定义调用约定”。

通常,函数中的局部变量被认为存在于堆栈中。这是 C 中“auto”变量的一般规则。由于“auto”是默认值,因此这些是普通的局部变量。例如...

void myfunc ()
{
  int i;  //  normal (auto) local variable
  //...
  nested_call ();
  //...
}

在上面的代码中,“i”很可能主要保存在寄存器中。随着函数的进行,它甚至可以从一个寄存器移至另一个寄存器,然后再移回。但是,当调用“nested_call”时,该寄存器中的值几乎肯定会在堆栈上 - 要么因为该变量是堆栈变量(而不是寄存器),要么因为保存寄存器内容以允许nested_call拥有自己的工作存储。

在多线程应用程序中,普通局部变量是特定线程的局部变量。每个线程都有自己的堆栈,并且在运行时独占使用 CPU 寄存器。在上下文切换中,这些寄存器被保存。无论是在寄存器中还是在堆栈中,局部变量都不会在线程之间共享。

即使两个或多个线程可能同时处于活动状态,这种基本情况也保留在多核应用程序中。每个核心都有自己的堆栈和寄存器。

存储在共享内存中的数据需要更加小心。这包括全局变量、类和函数中的静态变量以及堆分配的对象。例如...

void myfunc ()
{
  static int i;  //  static variable
  //...
  nested_call ();
  //...
}

在这种情况下,“i”的值在函数调用之间被保留。保留主存储器的静态区域来存储该值(因此称为“静态”)。原则上,在调用“nested_call”期间不需要任何特殊操作来保留“i”,乍一看,可以从任何内核(甚至单独的 CPU)上运行的任何线程访问该变量。

然而,编译器仍在努力优化代码的速度和大小。对主存储器的重复读写比寄存器访问慢得多。编译器几乎肯定会选择not遵循上述简单的读取-修改-写入模式,但会将值保留在寄存器中相对较长的时间,避免重复读取和写入同一内​​存。

这意味着在一个线程中所做的修改可能在一段时间内不会被另一个线程看到。两个线程最终可能会对上面“i”的值有非常不同的想法。

对此没有神奇的硬件解决方案。例如,没有用于在线程之间同步寄存器的机制。对于CPU来说,变量和寄存器是完全独立的实体——它不知道它们需要同步。不同线程中或在不同内核上运行的寄存器之间当然不存在同步 - 没有理由相信另一个线程在任何特定时间出于相同目的使用相同的寄存器。

部分解决方案是将变量标记为“易失性”......

void myfunc ()
{
  volatile static int i;
  //...
  nested_call ();
  //...
}

这告诉编译器不要优化对变量的读取和写入。处理器没有波动性的概念。该关键字告诉编译器生成不同的代码,按照赋值指定立即读取和写入内存,而不是使用寄存器来避免这些访问。

This is not然而,多线程同步解决方案——至少其本身不是。一种合适的多线程解决方案是使用某种锁来管理对此“共享资源”的访问。例如...

void myfunc ()
{
  static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

这里发生的事情比显而易见的还要多。原则上,可以将“i”的值保存在堆栈上,而不是将“i”的值写回到为“release_lock_on_i”调用做好准备的变量中。就编译器而言,这并非没有道理。无论如何,它都会进行堆栈访问(例如保存返回地址),因此将寄存器保存在堆栈上可能比将其写回“i”更有效 - 比访问完全独立的内存块更适合缓存。

但不幸的是,释放锁函数不知道该变量尚未写回内存,因此无法修复它。毕竟,该函数只是一个库调用(真正的锁释放可能隐藏在更深层的嵌套调用中),并且该库可能在​​您的应用程序之前几年就已编译 - 它不知道how它的调用者使用寄存器或堆栈。这是我们使用堆栈的一个重要原因,也是为什么调用约定必须标准化(例如,谁保存寄存器)。释放锁函数不能强制调用者“同步”寄存器。

同样,您可以将旧应用程序与新库重新链接 - 调用者不知道“release_lock_on_i”做什么或如何做,它只是一个函数调用。它不知道需要首先将寄存器保存回内存。

为了解决这个问题,我们可以带回“易失性”。

void myfunc ()
{
  volatile static int i;
  //...
  acquire_lock_on_i ();
  //  do stuff with i
  release_lock_on_i ();
  //...
}

当锁处于活动状态时,我们可以暂时使用普通的局部变量,以便编译器有机会在这段短暂的时间内使用寄存器。但原则上,锁应该尽快释放,所以里面不应该有那么多代码。但是,如果我们这样做,我们会在释放锁之前将临时变量写回到“i”,并且“i”的易失性确保它被写回主内存。

原则上,这还不够。写入主内存并不意味着您已经写入了主内存 - 中间有几层缓存需要遍历,并且您的数据可能会在这些层中的任何一层中停留一段时间。这里存在一个“内存屏障”问题,我对此了解不多 - 但幸运的是,这个问题是线程同步调用(例如上面的锁获取和释放调用)的责任。

然而,这个内存障碍问题并没有消除对“易失性”关键字的需要。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

什么是 CPU 寄存器以及它们如何使用,特别是 WRT 多线程? 的相关文章

随机推荐