
本文翻译自ARM 中内存序的介绍文章中加入了一些自己的理解读者如若觉得不合适欢迎指出~1、关于内存序1.1 顺序执行模型在计算机的黄金年代程序运行时的行为与开发者阅读源代码时的直觉预期高度一致程序的执行严格遵循源码设定的逻辑路径操作顺序与代码书写顺序完全吻合循环迭代次数精确对应编程时的指定次数不多不少所有操作都以严格的串行方式执行这种令人怀念的理想状态在现代计算机体系结构中被称为顺序执行模型。即便在最先进的处理器架构中为了保持既有程序的兼容性系统仍会竭力维持这种顺序执行的假象——但请注意这种假象仅对程序内部可见。在处理器外部我们依然能清晰观察到底层那些无法隐藏的魔法正在悄然发生。从程序内部的角度从程序员或软件的角度看程序似乎是一条条指令按顺序执行的。比如在 C/C/Java 中写的代码如果你写a1;ba2;你预期 b 赋值发生在 a 1 之后且是基于 a 1 的结果进行计算的。现代处理器会尽一切努力让你“看到”的执行顺序符合这种逻辑。为了维持这种“错觉”处理器会采取指令重排序但确保逻辑依赖不被破坏。使用内存屏障memory barriers或其他机制保持可观察的顺序。保证每个单线程上下文看到的行为是一致的。从处理器外部的角度例如另一个核、另一个观察者指令实际上可能是乱序执行的out-of-order execution多个指令流水线、分支预测、缓存系统等都在运作。多个核共享缓存时缓存一致性协议如 MESI引发的延迟和状态变化可能暴露出非顺序执行的行为。外部观察者比如另一个线程或另一个核通过共享内存观察会看到一些“并不顺序”的状态举个例子一个线程写入变量 x 和 yx1;y1;另一个线程读取 y 后读取 x可能会看到 y 1 但 x 0这在顺序一致模型下是不允许的但在弱内存模型中是可能的。这也是为什么多线程编程中需要加锁、使用原子操作、内存屏障等技术去显式控制跨线程可见的顺序。1.2 现实然而现实情况是为了提高性能(包括速度和功率)系统的许多不同层面都在进行大量优化。2、编译器优化Optimizing Compilers编译器优化可以深度重构程序员编写的软件代码以隐藏流水线延迟或利用微架构优化。它可以决定将内存访问提前以便在需要值之前有更多时间完成或者它可以将内存访问推迟以平衡程序中的访问次数。在高度流水线化的处理器中编译器实际上可能会重新排列所有类型的指令以便在需要之前指令的结果时能够获取其结果。以下是一个常用来解释该问题的经典示例intflagBUSY;intdata0;intsomefunc(void){while(flag!DONE);returndata;}voidotherfunc(void){data42;flagDONE;}假设上述代码在两个独立的线程中运行线程 A 调用 otherfunc() 来更新值并指示操作已完成线程 B 调用 somefunc() 等待完成信号到达后再返回变量 data 的值。C 语言规范中没有任何内容保证 somefunc() 不会在开始轮询 flag 值之前读取 data 的代码。这意味着 somefunc() 返回 0 或 42 是完全合法的。虽然有一些方法可以解决这个问题但这仍然无法阻止硬件进行重新排序见下文。3、多发射Multi-issuing在现代处理器架构中例如 ARM 处理器多发射multiple-issue技术允许每个时钟周期同时发出并执行多条指令。这意味着即使我们在汇编代码中将一条指令明确写在另一条之后处理器也可能在硬件层面将它们并行调度和执行。ARM 架构中的超标量设计例如 ARM Cortex-A 系列使得多个执行单元可以同时处理来自不同流水线阶段的指令从而大幅提升指令吞吐量和整体性能。注意多发射 和 流水线 是两个概念。流水线 (Pipeline)像是工厂的装配线。把做一件事拆成很多小步骤让不同的人同时做不同步骤。核心时间上的并行不同指令的不同阶段重叠。多发射 (Superscalar)像是工厂有多条并行的装配线。同一个时刻可以启动多个任务让它们同时进入流水线。核心空间上的并行同一周期内发射多条指令换句话说指令的执行顺序并不完全由程序中代码的书写顺序决定而是由处理器的调度器根据数据依赖、资源可用性以及指令类型等因素动态安排。这种机制使得即便是看似“顺序”的代码也可能被并行执行从而充分利用硬件资源。想象一下下面的 ARM 汇编指令序列对于一个双发射dual-issuing的 CPU 来说指令执行的顺序可能是这样在这个例子中在 cycle2 时钟周期、Issue1 的 pipeline 中因为接下来的指令执行需要 Issue0 中的 sub 指令的结果。接下来的指令str事实上在 cycle3 时钟周期发出和子程序返回bx并行。多发射架构通常依赖多个执行流水线Execution Units每个 CPU 核独享。可以简单理解为multi-issue多发射 ≈ 多执行通道pipeline / execution units3.1 关于处理器的 Multi-issuing注Partial dual issue仅特定组合可以双发射2 (Superscalar)更广泛的指令组合可并发执行相关处理器的信息ARM 官网都能查询到。4、乱序执行Out-of-order execution第一个支持乱序执行的 Arm 处理器是 Arm1136J(F)-S它允许非依赖性的加载和存储操作以乱序方式完成。具体表现为若某次数据访问缓存未命中Cache Miss其他缓存命中Hit或未命中的访问可超越其执行前提是两者无数据依赖性同时支持加载-存储指令与数据处理指令的乱序完成例如当某次加载操作为后续加载/存储提供地址时若不存在数据依赖即可乱序执行。几年后出现的的 Cortex-A9该处理器在许多情况下支持大多数非依赖指令的乱序执行。当某条指令因等待前序指令结果而停顿时Stall核心可继续执行后续不依赖该结果的指令典型场景假设以下代码片段中的 mul乘法和 ldr加载指令均需 2 个周期才能产生结果在乱序执行支持下后续无关指令可提前执行以提升效率。addr0, r0,#4mul r2, r2, r3 str r2,[r0]ldr r4,[r1]sub r1, r4, r2 bx lr如果我们在顺序处理器上执行此代码则执行将类似于以下内容但如果我们在乱序处理器上执行它我们可能会看到类似这样的结果通过允许 ldr 执行——当我们等待 mul 完成以便 str 可以继续进行。当然我们也为 ldr 提供了更多的时间来完成直到需要它的值 sub r1, r4, r2。4.1 关于处理器是否支持乱序Out-of-Order Execution 执行需要的硬件非常复杂功耗也会增加。这几年比较火的 big.LITTLE 架构本质就是性能核 能效核的组合A7/A53/A55 等小核是 In-order因为能效优先A9/A15/A75 等大核是 Out-of-order因为性能优先4.2 注意事项关注上面的红色字体并不是所有场景下 CPU 都会去乱序执行指令的。只有指令之间不相互依赖才有可能乱序执行。例如地址依赖、控制依赖。ARM® Architecture Reference Manual ARMv7-A and ARMv7-R edition数据依赖 (Data Dependency) add r0, r1, r2之后mul r3, r0, r4。第二条要等 r0 写完才能读控制依赖 (Control Dependency) 分支未决——beq label下一条语句要看分支结果。处理器只能猜路径若分支预测失败要回滚5、推测性访问Speculation简单来说Speculative 可以分为三大类Speculative ReadSpeculative WriteSpeculative Execution推测执行Speculative Execution 可以简单理解为处理器在尚未确定某条指令是否真正需要执行的情况下就提前开始执行这条指令。这样做的好处是一旦判断条件确认无误处理器就已经提前准备好了执行结果从而加快整体运行速度。这类情况常见于使用 Arm 或 Thumb 指令集中的条件执行conditional execution或者遇到条件跳转指令conditional branch时。在这些场景中处理器可能会提前执行这条条件指令甚至继续执行条件跳转后的若干指令。如果最终发现推测错误处理器必须彻底清除任何“曾经执行过”的痕迹以确保程序状态不受影响。在涉及加载指令load时推测行为可能更加激进Speculative Read。对于带 cache 的内存区域处理器可以提前发起加载load操作这可能导致该内存区域的数据被拉入 cache从而替换掉evict现有的 cache line。许多现代处理器甚至会更进一步监测内存访问模式在判断出访问趋势后提前将后续可能访问的数据加载进 cache —— 即使对应的指令尚未进入处理器流水线。当然不带 cache 的内存区域也有可能出现提前发起加载load的操作。有些场景下例如一些外设寄存器如 UART、SPI、NVIC例如读寄存器某一位就可以清除状态写入特定寄存器启动某些硬件行为如果 CPU 提前进行了访问即使程序最后并没真正需要访问会触发副作用可能导致系统异常或者外设状态错乱。所以在 ARM 学习笔记三 一文中我们讲解了几乎所有的外设寄存器都会被设置成 Device 类型的内存去禁止对这段内存的任何推测性访问。5.1 PrefetchingARM 手册中Prefetch 这个名词也经常出现。PrefetchingPrefetching refers to speculatively fetching instructions or data from the memory system. In particular, instructionprefetching is the process of fetching instructions from memory before the instructions that precede them, in simplesequential execution of the program, have finished executing. Prefetching an instruction does not mean that theinstruction has to be executed.——————《ARM Architecture Reference Manual ARMv7-A and ARMv7-R edition》预取是指从内存系统中对指令或数据进行推测性获取的操作。具体而言指令预取是指在前序指令尚未完成执行时即程序按简单顺序执行流尚未到达该指令前提前从内存中预加载后续指令的过程。需注意的是预取某条指令并不保证该指令最终一定会被执行。简单来说prefetch 可以理解为 Speculative 一种。因为 Speculative 不仅仅包括Speculative Read、Speculative Write还包括Speculative Execution。5.2 拓展上面只是笼统的介绍真正的 Speculation对应每个微架构的具体实现都有可能不同。例如Speculative writes are never made.——————《Arm Cortex-M7 Processor Technical Reference Manual r1p2》在 Cortex-M7 微架构中Speculative writes 是被禁止的。The only stores by an observer that can be observed by another observer are those stores that have been Architecturally executed. Speculative writes by an observer cannot be observed by another observer——————《ARM Architecture Reference Manual ARMv7-A and ARMv7-R edition》而对于 ARMv7 架构而言Speculative writes 是被允许的。但是一个观察者observer发出的写操作只有在该写操作已经架构性地执行即真实地对内存产生影响之后才能被另一个观察者观察到。任何仍处于推测状态的写操作不能被其他观察者看到。这条规则是为了避免因 speculative writes 导致的不可预测行为、同步问题和设备错误。关于 Architecturally Executed 架构上完成的 一词的理解一条 store 指令已经通过所有架构定义的执行阶段产生了对系统状态特别是对内存的真实、可观察改变。所以如果涉及到非常底层的问题最终还是要阅读、研究相应 CPU 微架构的手册。我们这里只是做一个简单的讲解。6、Load-Store 优化在一些高性能的系统中外部内存访问延迟通常很大几十个 bus cycles。为降低此类延迟的影响处理器会尽可能优化内存访问其核心目标是通过减少事务transaction 数量来提升效率例如单次事务写入更多数据采用突发传输burst模式以单次事务的延迟完成长数据流的传输。写操作合并针对缓冲内存buffered memory的多次写入可合并为单一事务。特性Burst突发传输写合并Write Combining优化目标提高总线带宽利用率减少总线事务数量触发条件连续地址访问硬件自动识别对同一地址/对齐块的多次写需缓冲区支持硬件依赖内存控制器/总线协议如AXI Burst存储缓冲区Store Buffer程序员可见性通常透明可能导致非原子性写入需屏障干预6.1 Store Buffer以 Cortex-A7 微架构为例我们可以看到在 CPU 中有一个 Store bufferSTB。关于 STB 的作用ARM Cortex-A7 手册中也给了明确说明The Store Buffer (STB) holds store operations when they have left the load/store pipeline andhave been committed by the DPU.STB 用于暂存已离开 load/store 流水线且被数据处理单元DPU提交的存储操作store operations。The STB can merge:Several store transactions into a single transaction if they are to the same 64-bit aligned address. The STB is also used to queue up CP15 maintenance operations before they are broadcast to other processors in the multiprocessor device.Multiple writes into an AXI write burstSTB存储事务缓冲区可执行以下合并操作若多个存储事务针对同一 64 位对齐地址则可将其合并为单一事务。该缓冲区还用于暂存 CP15 维护操作待积累后再向多处理器设备中的其他处理器进行广播所有写操作针对同一个 64 位对齐的地址块即地址的高位相同低3位为0b000。示例可合并的 32 位写操作第一个写地址 0x100064位对齐写入 32 位数据占用 0x1000~0x1003第二个写地址 0x1004非对齐但属于同一 64 位块 0x1000~0x1007写入 32 位数据占用 0x1004~0x1007合并结果一个 64 位写事务覆盖 0x1000~0x1007不可合并的情况若两个 32 位写分别指向 0x1000 和 0x1008属于不同的64位块则无法合并将多次写操作合并为AXI写突发传输L1 存储系统配备了一个存储缓冲区store buffer该缓冲区提供 4 个 64 位槽位slot并支持数据合并功能。7、多核之间的 Cache 一致性在使用多核处理器时基于硬件的缓存一致性管理例如 MESI 协议可以使缓存行在核心之间透明地迁移。这可能会导致不同核心以不同的顺序查看缓存内存位置的更新。举一个具体的例子上面的 somefunc() 和 otherfunc() 示例在多核 SMP 系统中执行时还存在另一个潜在影响。如果两个线程在不同的 CPU 核上执行那么硬件缓存一致性管理、推测和乱序执行的结合意味着不同内核可能会看到不同的内存访问顺序。简而言之硬件缓存一致性管理意味着缓存行可以在不同内核之间移动以便在任何访问位置都可用。由于具有乱序执行能力的处理器可以在等待另一个加载或存储结果完成的同时从缓存中加载一个内存位置因此执行 somefunc() 的核心完全可以在 flag 的值实际更改为 DONE 之前推测性地加载数据的值 - 即使这不是指令在编译应用程序中的顺序。8、总结通读全文我们应明确两点多数底层优化机制如乱序执行、推测执行、多发射等对软件开发者而言是透明的无需显式关注然而部分优化会对程序行为产生可观察影响。为保障正确性体系结构也提供了如内存屏障等机制使开发者可以显式控制这些影响。当然这里也包括 ARM 学习笔记三 中所讲的内存类型。阅读到这里建议再看一下 ARM 架构下 cache 一致性问题整理 一文可能会有不一样的体会。