现代x86硬件可以不将单个字节存储到内存中吗

Can modern x86 hardware not store a single byte to memory?

本文关键字:存储 字节 内存 单个 硬件 x86 现代      更新时间:2023-10-16

说到C++的并发内存模型,Stroustrup的C++编程语言,4th ed.,sect。41.2.1,表示:

。。。(像大多数现代硬件一样)机器无法加载或存储任何小于一个单词的东西。

然而,我的x86处理器使用了几年,可以而且确实存储小于一个单词的对象。例如:

#include <iostream>
int main()
{
char a =  5;
char b = 25;
a = b;
std::cout << int(a) << "n";
return 0;
}

在没有优化的情况下,GCC将其编译为:

[...]
movb    $5, -1(%rbp)   # a =  5, one byte
movb    $25, -2(%rbp)  # b = 25, one byte
movzbl  -2(%rbp), %eax # load b, one byte, not extending the sign
movb    %al, -1(%rbp)  # a =  b, one byte
[...]

评论由我提出,但大会由海湾合作委员会提出。当然,它运行得很好。

显然,当Stroustrup解释硬件可以加载和存储一个单词时,我不明白他在说什么。据我所知,我的程序什么都不做,只是加载和存储小于一个单词的对象。

C++对零成本、硬件友好抽象的彻底关注使C++与其他更容易掌握的编程语言不同。因此,如果Stroustrup在总线上有一个有趣的信号心理模型,或者有其他类似的东西,那么我想了解Stroustrup的模型。

斯特劳斯特鲁普在说什么?

有上下文的较长报价

以下是斯特劳斯特鲁普在更全面的背景下引用的话:

考虑一下,如果链接器在内存中的同一个字中分配了[char类型的变量,如]cb,并且(像大多数现代硬件一样)机器无法加载或存储小于一个字的任何东西,会发生什么。。。。如果没有定义良好且合理的内存模型,线程1可能会读取包含bc的单词,更改c,并将该单词写回内存。同时,线程2可以对b执行同样的操作。然后,无论哪个线程最先读取单词,还是哪个线程最后将结果写回内存,都将决定结果。。。。

其他备注

我不相信Stroustrup在谈论缓存线。即使他是,据我所知,缓存一致性协议也会透明地处理这个问题,除非是在硬件I/O期间。

我已经检查了处理器的硬件数据表。从电学上讲,我的处理器(Intel Ivy Bridge)似乎通过某种16位多路复用方案来寻址DDR3L内存,所以我不知道这是怎么回事。不过,我还不清楚这与斯特劳斯特鲁普的观点有多大关系。

斯特劳斯特鲁普是一个聪明的人和杰出的科学家,所以我毫不怀疑他在做一些明智的事情。我很困惑。

另请参阅此问题。我的问题在几个方面类似于链接问题,链接问题的答案在这里也很有用。然而,我的问题也涉及到硬件/总线模型,它激励C++成为现在的样子,并导致Stroustrup编写他所写的东西。我不想仅仅就C++标准正式保证的问题寻求答案,我还想了解为什么C++标准会保证它。其基本思想是什么?这也是我问题的一部分。

TL:DR:在每一个拥有字节存储指令(包括x86)的现代ISA上,它们都是原子指令,不会干扰周围的字节(我不知道任何旧的ISAs中字节存储指令也可以"发明写入"到相邻字节。)

实际的实现机制(在非x86 CPU中)有时是一个内部RMW循环来修改缓存行中的整个字,但这已经完成了;无形地";在核心内部,而它对缓存线拥有独占所有权,所以这只是性能问题,而不是正确性问题。(存储缓冲区中的合并有时可以将字节存储指令转换为高效的全字提交到L1d缓存。)



关于Stroustrup的措辞

我认为这不是一个非常准确、清晰或有用的说法。更准确的说法是,现代CPU不能加载或存储比缓存线更小的东西。(尽管对于不可缓存的内存区域,例如MMIO,情况并非如此。)

最好只是举一个假设的例子来谈论内存模型,而不是暗示真正的硬件是这样的。但如果我们尝试,我们可能会找到一个没有那么明显或完全错误的解释,这可能是斯特劳斯特鲁普在写这篇文章介绍记忆模型主题时的想法。(对不起,这个答案太长了;我写了很多文章,同时猜测他的意思和相关主题…)

或者,这可能是高级语言设计师不是硬件专家的另一种情况,或者至少偶尔会做出错误的陈述。


我认为Stroustrup谈论的是CPU如何在内部实现字节存储指令。他建议,一个没有定义良好且合理的内存模型的CPU可能会在缓存行中或在没有缓存的CPU的内存中实现包含字的非原子RMW的字节存储。

对于高性能x86 CPU来说,即使是关于内部(外部不可见)行为的较弱说法也不成立。现代英特尔CPU对字节存储,甚至是不跨越缓存线边界的未对齐字或矢量存储,都没有吞吐量损失。AMD也是如此。

如果字节或未对齐的存储必须作为提交到L1D缓存的存储进行RMW循环,这将干扰存储和/或加载指令/uop吞吐量,我们可以用性能计数器来衡量这种吞吐量。(在一个精心设计的实验中,该实验避免了在提交到L1d缓存之前在存储缓冲区中合并存储的可能性,从而隐藏了成本,因为存储执行单元在当前CPU上每个时钟只能运行1个存储。)


然而,一些针对非x86 ISAs的高性能设计确实使用原子RMW循环在内部将存储提交到L1d缓存。有没有任何现代CPU的缓存字节存储实际上比字存储慢缓存行始终处于MESI Exclusive/Modified状态,因此不会带来任何正确性问题,只会对性能造成小的影响。这与从其他CPU访问存储区的操作非常不同。(下面关于没有发生的论点仍然适用,但我的更新可能遗漏了一些仍然认为原子缓存RMW不太可能的东西。)

(在许多非x86 ISAs上,根本不支持未对齐的存储,或者与x86软件相比,未对齐的存储器很少使用。弱序ISAs允许更多的存储缓冲区合并,因此没有那么多字节存储指令实际会导致单字节提交到L1d。如果没有这些花哨(耗电)缓存访问硬件的动机,用于分散字节存储的字RMW在一些设计中是可接受的折衷。)


Alpha AXP,1992年的一种高性能RISC设计,在1996年Alpha 21164A(EV56)之前,省略了字节加载/存储指令。显然,他们并不认为字RMW是实现字节存储的可行选择,因为仅实现32位和64位对齐存储的一个优点是L1D缓存的ECC更高效"传统的SECDED ECC在32位颗粒上需要7个额外的比特(22%的开销),而在8位颗粒上则需要4个额外比特(50%的开销)"(@Paul A.Clayton关于字与字节寻址的回答还有一些其他有趣的计算机体系结构内容。)如果字节存储是用字RMW实现的,那么你仍然可以用字粒度进行错误检测/校正。

由于这个原因,当前的英特尔CPU在L1D中只使用奇偶校验(而不是ECC)。(正如RWT上所讨论的,至少一些较旧的Xeon可以在ECC模式下以一半容量运行L1d,而不是正常的32KiB。目前尚不清楚是否有任何变化,例如英特尔现在对L1d使用ECC)。另请参阅本问答;A关于硬件(非)消除";无声商店":在写入之前检查缓存的旧内容,以避免在匹配的情况下将行标记为脏行,这将需要RMW而不仅仅是存储,这是一个主要障碍。

事实证明,一些高性能流水线设计确实使用原子字RMW来提交到L1d,尽管它会使内存管道停滞,但(正如我在下面所说的)它比任何对RAM执行外部可见RMW的可能性都小得多。

字RMW对于MMIO字节存储也不是一个有用的选项,所以除非你有一个不需要子字存储的IO架构,否则你需要对IO进行某种特殊处理(比如Alpha的稀疏I/O空间,其中字加载/存储映射到字节加载/存储,这样它就可以使用商品PCI卡,而不需要没有字节IO寄存器的特殊硬件)。

正如@Margaret所指出的,DDR3内存控制器可以通过设置控制信号来屏蔽突发中的其他字节来进行字节存储。将该信息获取到存储器控制器(对于未缓存的存储)的相同机制也可以将该信息与加载或存储一起传递到MMIO空间。因此,有硬件机制可以真正做到字节存储,即使在面向突发的存储系统上也是如此,现代CPU很可能会使用它而不是实现RMW,因为它可能更简单,而且对MMIO的正确性来说要好得多。

执行长字传输到CPU所需的周期数和大小显示了ColdFire微控制器如何通过外部信号线发送传输大小(字节/字/长字/16字节线)的信号,即使32位宽的存储器连接到其32位数据总线,也允许其进行字节加载/存储。像这样的东西可能是大多数内存总线设置的典型(但我不知道)。ColdFire的例子很复杂,因为它还可以配置为使用16或8位内存,为更宽的传输占用额外的周期。但别介意,重要的一点是它有外部信号来指示传输大小,告诉内存HW它实际上在写哪个字节。


Stroustrup的下一段是

"C++内存模型保证两个执行线程可以在不相互干扰的情况下更新和访问单独的内存位置。这正是我们天真地期望的。编译器的工作是保护我们免受现代硬件有时非常奇怪和微妙的行为的影响。编译器和硬件组合如何实现这一点取决于编译器">

所以很明显,他认为真正的现代硬件可能无法提供";"安全";字节加载/存储。设计硬件内存模型的人同意C/C++的观点,并意识到如果字节存储指令可以踩到相邻的字节,那么它们对程序员/编译器来说就不是很有用。

除了早期的Alpha AXP之外,所有现代(非DSP)体系结构都有字节存储和加载指令,而AFAIK在体系结构上都定义为不影响相邻字节无论它们在硬件中实现了这一点,软件都不需要关心正确性。即使是MIPS的第一个版本(1983年)也有字节和半字加载/存储,这是一个非常面向字的ISA。

然而,他实际上并没有声称大多数现代硬件需要任何特殊的编译器支持来实现C++内存模型的这一部分,只是一些可能需要。也许他在第二段中真的只是在谈论单词可寻址DSP(其中C和C++实现通常使用16或32位char,这正是Stroustrup所说的编译器变通方法。)


Most";现代的";CPU(包括所有x86)具有L1D缓存。它们将获取整个缓存行(通常为64字节),并在每个缓存行的基础上跟踪脏/不脏因此,如果两个相邻字节都在同一缓存行中,那么它们与两个相邻字几乎完全相同写入一个字节或单词将导致提取整行,并最终写回整行。请参阅Ulrich Drepper的《每个程序员都应该知道的关于内存的知识》。MESI(或类似MESIF/MOSI的衍生物)确保这不是问题,这是正确的。(但同样,这是因为硬件实现了一个健全的内存模型。)

存储只能在行处于(MESI的)Modified状态时提交到L1D缓存。因此,即使内部硬件实现的字节速度较慢,并且需要额外的时间将字节合并到缓存行中的包含字中,只要不允许行在读取和写入之间无效和重新获取,它实际上就是原子读取-修改-写入。(虽然此缓存的行处于"已修改"状态,但任何其他缓存都不能具有有效副本)。请参阅@old_timer的注释,该注释提出了同样的观点(但也适用于内存控制器中的RMW)。

这比例如来自同样需要ALU和寄存器访问的寄存器的原子xchgadd更容易,因为所有涉及的HW都在同一流水线阶段,这可以简单地停滞一两个额外的周期。这显然对性能不利,并且需要额外的硬件才能让管道阶段发出它正在停滞的信号。这并不一定与Stroustrup的第一个说法相冲突,因为他说的是一个没有内存模型的假设ISA,但这仍然是一个延伸。

在单核微控制器上,缓存字节存储的内部字RMW将更合理,因为在原子RMW缓存字更新期间,不会有来自其他核的Invalidate请求,它们将不得不延迟响应。但这对不可缓存区域的I/O没有帮助。我之所以说微控制器,是因为其他单核CPU设计通常支持某种多插槽SMP


许多RISC ISA不支持使用单个指令进行未对齐的字加载/存储,但这是一个单独的问题(困难在于处理加载跨越两个缓存行甚至页面的情况,而字节或对齐的半字不可能发生这种情况)。不过,越来越多的ISAs在最近的版本中添加了对未对齐加载/存储的保证支持。(例如,2014年的MIPS32/64 Release 6,我认为是AArch64和最近的32位ARM。还有RISC-V,但它允许用陷阱到未对准处理程序来实现)。


斯特劳斯特鲁普的书的第四版于2013年出版,当时阿尔法已经去世多年。第一版出版于1985年,当时RISC是一个新的大创意(例如,根据维基百科计算硬件的时间表,1983年的斯坦福MIPS,但当时的"现代"CPU是通过字节存储进行字节寻址的。Cyber CDC 6600是字寻址的,可能仍然存在,但不能称为现代。

即使是像MIPS和SPARC这样非常面向字的RISC机器也有字节存储和字节加载(带符号或零扩展)指令。它们不支持未对齐的字加载,从而简化了缓存(如果没有缓存,则简化了内存访问)和加载端口,但您可以用一条指令加载任何单个字节,更重要的是存储一个字节,而无需对周围字节进行任何架构可见的非原子重写。(缓存存储可以在存储缓冲区中合并,以潜在地完成4字节或8字节块的完全写入,从而避免在这种情况下对提交进行特殊处理。)

我假设Alpha上的C++11(它为该语言引入了一个线程感知内存模型)如果目标是没有字节存储的Alpha ISA版本,则需要使用32位char。或者,当它无法证明没有其他线程可以拥有允许它们写入相邻字节的指针时,它将不得不将软件原子RMW与LL/SC一起使用。


IDK慢字节加载/存储指令在任何CPU中是如何实现的,这些指令在硬件中实现,但不如字加载/存储便宜。只要使用movzx/movsx来避免部分寄存器错误依赖或合并暂停,x86上的字节加载就很便宜。在AMD pre-Reyzen上,movsx/movzx需要一个额外的ALU uop,但在其他方面,零/符号扩展可以在Intel和AMD CPU的加载端口中处理。)x86的主要缺点是,您需要一个单独的加载指令,而不是使用内存操作数作为ALU指令的源(如果您要将零扩展字节添加到32位整数),这样可以节省前端uop吞吐量带宽和代码大小。或者,如果您只是在字节寄存器中添加一个字节,那么x86基本上没有缺点。RISC加载存储ISAs总是需要单独的加载和存储指令。x86字节存储并不比32位存储更昂贵。

作为一个性能问题,对于具有慢字节存储的硬件来说,一个好的C++实现可能会将每个char放在自己的字中,并尽可能使用字加载/存储(例如,用于结构外的全局和堆栈上的局部)。IDK,如果MIPS/ARM/的任何实际实现都有缓慢的字节加载/存储,但如果是这样,也许gcc有-mtune=选项来控制它。

这对char[]没有帮助,或者当你不知道它可能指向哪里时取消引用char *。(这包括用于MMIO的volatile char*。)因此,让编译器+链接器将char变量放在单独的单词中并不是一个完整的解决方案,如果真正的字节存储很慢,这只是一个性能问题。


PS:有关Alpha的更多信息:

Alpha之所以有趣,有很多原因:它是为数不多的全新64位ISA之一,而不是对现有32位ISA的扩展。安腾是最近的一个全新的ISA,它是几年后的另一个,尝试了一些巧妙的CPU体系结构思想。

来自Linux Alpha HOWTO。

当Alpha体系结构被引入时,它在RISC体系结构中是独一无二的,可以避免8位和16位的加载和存储。它支持32位和64位加载和存储(在Digital的命名法中,长字和四字)。联合建筑师(Dick Sites,Rich Witek)通过引用以下优点来证明这一决定的合理性:

  1. 缓存和内存子系统中的字节支持往往会降低32位和64位数量的访问速度
  2. 字节支持使得在高速缓存/存储器子系统中构建高速纠错电路变得困难

Alpha通过提供强大的指令来操作64位寄存器中的字节和字节组来进行补偿。字符串操作的标准基准测试(例如,一些Byte基准测试)表明Alpha在字节操作方面表现非常好。

x86 CPU不仅能够读取和写入单个字节,所有现代通用CPU都能够读取和写单个字节。更重要的是,大多数现代CPU(包括x86、ARM、MIPS、PowerPC和SPARC)都能够原子级地读取和写入单字节。

我不知道Stroustrup指的是什么。过去有一些单词可寻址的机器不能进行8位字节寻址,比如Cray,正如Peter Cordes提到的,早期的Alpha CPU不支持字节加载和存储,但今天唯一不能进行字节加载和存储的CPU是利基应用中使用的某些DSP。即使我们假设他的意思是大多数现代CPU都没有原子字节负载和存储,但大多数CPU都不是这样。

然而,简单的原子加载和存储在多线程编程中没有多大用处。您通常还需要排序保证和使读-修改-写操作原子化的方法。另一个考虑因素是,虽然CPUa可能有字节加载和存储指令,但编译器不需要使用它们。例如,编译器仍然可以生成Stroustrup描述的代码,使用单个单词加载指令加载bc作为优化。

因此,尽管你确实需要一个定义良好的内存模型,如果只是为了让编译器生成你想要的代码,问题不在于现代CPU无法加载或存储任何小于一个单词的东西。

作者似乎担心线程1和线程2会陷入读-修改-写入(不是在软件中,软件执行两个字节大小的独立指令,在某个地方,逻辑必须执行读-修改写)而不是理想的读-修改写入读-修改—写入的情况,变为读取-读取-修改-修改-写入或一些其他定时,使得读取预修改版本和最后写入的版本都获胜。读-读-修改-修改-写,或者读-修改读-修改写或读-修改读取-写入-修改-写入。

问题是从0x1122开始,一个线程希望使其成为0x33XX,另一个线程则希望使其变成0xXX44,但例如读-读-修改-写-写,最终会得到0x1144或0x3232,而不是0x3344

一个合理的(系统/逻辑)设计就没有这个问题——当然不是像这样的通用处理器,我曾设计过这样的时序问题,但这不是我们在这里谈论的,完全不同的系统设计用于不同的目的。在合理的设计中,读取-修改-写入的距离不够长,x86是合理的设计。

读-修改-写将发生在所涉及的第一个SRAM附近(理想情况下,当以典型方式运行x86,操作系统能够运行C++编译的多线程程序时为L1),并且当ram处于理想情况下的总线速度时,在几个时钟周期内发生。正如Peter所指出的,这被认为是在缓存中经历这种情况的整个缓存线,而不是处理器核心和缓存之间的读-修改-写。

即使是多核系统,"同时"的概念也不一定是同时的,最终你会被串行化,因为性能不是基于它们从头到尾并行,而是基于保持总线负载。

引号是指内存中分配给同一个单词的变量,所以这是同一个程序。两个独立的程序不会共享这样的地址空间。所以

欢迎您尝试,制作一个多线程程序,其中一个写入地址0xnnn00000,另一个写入到地址0xnnnn00001,每个写入一次,然后读取一次,或者更好的是多次写入相同值,而不是一次读取,检查读取是他们写入的字节,然后用不同的值重复。让它运行一段时间,小时/天/周/月。看看你是否绊倒了系统。。。对实际的写入指令使用汇编,以确保它正在执行您要求的操作(而不是C++或任何执行或声称不会将这些项放在同一个单词中的编译器)。可以添加延迟以允许更多的缓存驱逐,但这会降低"同时"发生冲突的几率。

在您的示例中,只要您确保您不位于边界(缓存或其他)的两侧(如0xNNNNFFFFF和0xNNNN00000),隔离对0xNNNN000000和0xNNNN00001等地址的两字节写入,就可以背靠背地执行指令,并查看您是否获得读-读-修改-写-写。围绕它进行测试,每个循环的两个值都不同,你可以根据需要在稍后的任何延迟读取整个单词,并检查这两个值。重复几天/几周/几个月/几年,看看是否失败。阅读处理器的执行和微码功能,了解它对该指令序列的作用,并根据需要创建一个不同的指令序列,试图在处理器核心的少数时钟周期内启动事务。

编辑

引号的问题在于,这一切都与语言和使用有关。"就像大多数现代硬件一样"将整个主题/文本置于一个敏感的位置,它太模糊了,一方可以争论,我所要做的就是找到一个真实的案例,使其余的都真实,同样,如果我找到一个案例,一方也可以争论,其余的都不真实。用"like"这个词作为一张可能的出狱免费卡有点混乱。

事实是,我们很大一部分数据存储在DRAM中的8位宽存储器中,只是我们不会像8位宽那样访问它们——通常我们一次访问8个,64位宽。在几周/几个月/几年/几十年后,这种说法将是不正确的。

较大的引用说"同时",然后说阅读。。。首先,写。。。最后,好吧,第一和最后同时在一起没有意义,是并行的还是串行的?作为一个整体,上下文关注的是上面的读-读-修改-写-写变体,其中你最后写了一次,取决于一次读的时间决定了两次修改是否发生。在"像大多数现代硬件一样"没有意义的同时,如果它们针对存储器中的同一触发器/晶体管,那么在单独的核心/模块中实际上并行的东西最终会被串行化,其中一个最终必须等待另一个先去。基于物理学,我认为这在未来几周/几个月/几年内不会是错误的。

这是正确的。x86_64 CPU与原始的x86 CPU一样,无法从rsp读取或写入任何小于(在本例中为64位)字的内容。存储。它通常不会读写少于一整条缓存线,尽管有一些方法可以绕过缓存,尤其是在写入时(见下文)。

然而,在本文中,Stroustrup指的是潜在的数据竞赛(在可观察的水平上缺乏原子性)。这个正确性问题与x86_64无关,因为您提到了缓存一致性协议。换言之,是的,CPU仅限于全字传输,这是透明处理的,作为程序员,您通常不必担心。事实上,C++语言从C++11开始,<em]保证>在不同内存位置上的并发操作具有定义明确的行为,即您所期望的行为。即使硬件不能保证这一点,实现也必须通过生成可能更复杂的代码来找到方法。

也就是说,将整个单词甚至缓存行总是涉及机器级别这一事实放在脑后仍然是一个好主意,原因有两个。

  • 首先,这只与编写设备驱动程序或设计设备的人有关,内存映射I/O可能对访问方式敏感。举个例子,想象一下在物理地址空间中公开64位只写命令寄存器的设备。然后可能需要:
    • 禁用缓存。读取缓存行、更改单个单词和写回缓存行是无效的。此外,即使它是有效的,仍然存在命令丢失的巨大风险,因为CPU缓存没有很快写回。至少,页面需要配置为"直写",这意味着写入会立即生效。因此,x86_64页表条目包含控制该页的CPU缓存行为的标志
    • 确保始终在汇编级别编写完整的单词。例如,考虑将值1写入寄存器,然后再写入2的情况。编译器,尤其是在优化空间时,可能会决定只覆盖最低有效字节,因为其他字节已经被认为是零(即,对于普通RAM),或者它可能会删除第一次写入,因为这个值似乎无论如何都会立即被覆盖。然而,两者都不应该在这里发生。在C/C++中,volatile关键字对于防止这种不合适的优化至关重要
  • 其次,缓存一致性协议在巧妙地避免灾难的同时,如果被"滥用",可能会带来巨大的性能成本,这对几乎所有编写多线程程序的开发人员来说都是相关的

这是一个非常糟糕的数据结构的示例,有些做作。假设您有16个线程解析文件中的一些文本。每个线程都有一个从0到15的CCD_ 24。

// shared state
char c[16];
FILE *file[16];
void threadFunc(int id)
{
while ((c[id] = getc(file[id])) != EOF)
{
// ...
}
}

这是安全的,因为每个线程都在不同的内存位置上操作。然而,这些内存位置通常位于同一个缓存线上,或者最多拆分为两个缓存线。然后使用高速缓存一致性协议来适当地同步对CCD_ 25的访问。问题就在这里,因为这迫使其他线程在对c[id]执行任何操作之前,都要等待缓存线以独占方式可用,除非它已经在"拥有"缓存线的核心上运行。假设有几个核,例如16个核,高速缓存一致性通常会一直将高速缓存线从一个核转移到另一个核。由于显而易见的原因,这种效应被称为"缓存线乒乓"。它造成了一个可怕的性能瓶颈。这是错误共享的一个非常糟糕的情况的结果,即线程共享物理缓存线而没有实际访问相同的逻辑内存位置。

与此相反,尤其是如果采取额外步骤确保file阵列位于其自己的缓存线上,则从性能角度来看,使用它(在x86_64上)将是完全无害的,因为指针在大多数情况下只从中读取。在这种情况下,多个内核可以以只读方式"共享"缓存行。只有当任何内核试图写入缓存线时,它必须告诉其他内核它将"占用"缓存线进行独占访问。

(这被大大简化了,因为有不同级别的CPU缓存,并且几个内核可能共享同一个L2或L3缓存,但它应该会让你对问题有一个基本的了解。)

不确定Stroustrup所说的"WORD"是什么意思。也许这是机器内存存储的最小大小?

无论如何,并不是所有的机器都是用8位(BYTE)分辨率创建的。事实上,我推荐埃里克·S·雷蒙德的这篇很棒的文章,描述一些计算机的历史:http://www.catb.org/esr/faqs/things-every-hacker-once-knew/

"……过去也普遍知道36位体系结构解释了C语言的一些不幸特性。原件Unix机器PDP-7具有18位字,对应于大型36位计算机上的半个字。这些更自然表示为六个八进制(3位)数字。">

Stroustrup不是说没有机器可以执行小于其原生单词大小的加载和存储,他是说机器不能

虽然一开始这似乎令人惊讶,但这并不深奥
对于初学者,我们将忽略缓存层次结构,稍后我们将对此进行考虑
假设CPU和内存之间没有缓存。

内存的大问题是密度,试图将更多可能的位放入最小的区域
为了实现这一点,从电气设计的角度来看,尽可能宽地暴露总线是方便的(这有利于重复使用一些电信号,但我没有查看具体细节)
因此,在需要大内存(如x86)或简单的低成本设计(例如涉及RISC机器)的体系结构中,内存总线比最小的可寻址单元(通常是字节)大。

根据项目的预算和遗留问题,内存可以单独或与一些边带信号一起暴露更宽的总线,以选择特定的单元。
这实际上意味着什么
如果查看DDR3 DIMM的数据表,您会发现有64个DQ0–DQ63引脚用于读取/写入数据
这是数据总线,64位宽,每次8个字节
这个8字节的东西在x86体系结构中有很好的基础,英特尔在其优化手册的WC部分提到了它,它说数据是从64字节填充缓冲区传输的(记住:我们现在忽略缓存,但这类似于缓存线如何被写回),以8字节的突发(希望是连续的)。

这是否意味着x86只能写QWORDS(64位)
否,相同的数据表显示,每个DIMM都有DM0–DM7、DQ0–DQ7DQS0–DQS7信号,用于屏蔽、引导和选通64位数据总线中的8个字节。

因此x86可以以本机方式和原子方式读取和写入字节
然而,现在很容易看出,并非每个体系结构都是这样
例如,VGA视频存储器是DWORD(32位)可寻址的,使其适合8086的字节可寻址世界导致了混乱的位平面。

在通用的专用体系结构中,如DSP,在硬件级别上不可能有字节可寻址存储器。

有一个转折点:我们刚刚谈到了内存数据总线,这是可能的最低层
有些CPU可以有指令在字可寻址内存之上构建字节可寻址内存
这是什么意思
加载单词的较小部分很容易:只需丢弃其余的字节
不幸的是,我记不起体系结构的名称(如果它真的存在的话!)处理器通过读取包含未对齐字节的对齐字并在将结果保存到寄存器之前旋转结果来模拟未对齐字节负载。

对于存储,问题更为复杂:如果我们不能简单地写出刚刚更新的单词的部分,我们也需要写出未更改的剩余部分
CPU或程序员必须读取旧内容,更新并写回
这是一个读-修改-写操作,是讨论原子性时的核心概念。

考虑:

/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};
/* Thread 0                         Thread 1                 */
foo[0] = 1;                        foo[1] = 2;

是否存在数据竞赛
这在x86上是安全的,因为它们可以写入字节,但如果体系结构不能呢
两个线程都必须读取整个foo数组,对其进行修改并将其写回
伪C中,这将是

/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};
/* Thread 0                        Thread 1                 */
/* What a CPU would do (IS)        What a CPU would do (IS) */
int tmp0 = *((int*)foo)            int tmp1 = *((int*)foo)
/* Assume little endian            Assume little endian     */
tmp0 = (tmp0 & ~0xff) | 1;         tmp1 = (tmp1 & ~0xff00) | 0x200;
/* Store it back                   Store it back            */
*((int*)foo) = tmp0;               *((int*)foo) = tmp1;

我们现在可以看到Stroustrup所说的:两个存储*((int*)foo) = tmpX相互阻碍,为了看到这一点,考虑这个可能的执行序列:

int tmp0 = *((int*)foo)                   /* T0  */ 
tmp0 = (tmp0 & ~0xff) | 1;                /* T1  */        
int tmp1 = *((int*)foo)                   /* T1  */
tmp1 = (tmp1 & ~0xff00) | 0x200;          /* T1  */
*((int*)foo) = tmp1;                      /* T0  */
*((int*)foo) = tmp0;                      /* T0, Whooopsy  */

如果C++没有内存模型,这些麻烦将是特定于实现的细节,使C++在多线程环境中成为无用的编程语言。

考虑到玩具示例中描述的情况有多普遍,Stroustrup强调了定义良好的记忆模型的重要性
将内存模型形式化是一项艰巨的工作,这是一个令人疲惫、容易出错和抽象的过程,所以我也从Stroustrup的话中看到了一点的自豪感

我还没有复习过C++内存模型,但更新不同的数组元素是可以的
这是一个非常有力的保证。

我们省略了缓存,但这并没有真正改变任何事情,至少对于x86来说是这样
x86通过缓存写入内存,缓存以64字节的行逐出
在内部,每个核心都可以原子地更新任何位置的行,除非加载/存储越过行边界(例如,在其末尾附近写入)
这可以通过自然对齐数据来避免(你能证明吗?)。

在多代码/套接字环境中,缓存一致性协议确保一次只允许一个CPU自由写入缓存的内存行(处于独占或修改状态的CPU)
基本上,MESI系列协议使用的概念类似于DBMS中的锁定
出于编写目的,这具有将不同的内存区域"分配"给不同CPU的效果
所以这并没有真正影响上面的讨论。