八、访存顺序(Memory Ordering)

前言

这部分的内容比较抽象,很多内容我无法理解,都是直接翻译过来的。虽然难,但是不可不看,如果遇到无法理解的都直接跳过,那后面都无法学习下去了。觉得无法理解是因为目前的知识还很欠缺,到后面具备了这些方面的认知再理解这些内容就会好很多了,这里先走马观花过一遍即可,有个大致的印象,后面用到可以回过头来重新理解并修订。觉得翻译有问题的,可以参考原文。

在比较老的ARM架构实现中,指令的执行顺序是严格按照代码编写的顺序来的,一条指令执行完成后才会执行下一条指令。比较新的ARM架构在指令执行顺序和访存操作方面进行了一些优化:为了减小内核执行指令的速度与外设访问速度差异带来的影响,在架构里面引入了缓存(Caches)和写缓冲区(Write Buffers)。它们的引入带来的一个影响是访存的顺序可能会被改变,实际对外设的访问顺序可能跟内核执行访存指令的顺序不一致,举个例子说明:
访存顺序举例
左边是内核执行指令的顺序,右边是指令实际的执行情况。第一条指令使用STR指令将R12寄存器的值写入到内存中,它实际上将写内存操作写到了write buffer里面。后面两条指令是读数据指令,这两条指令都会先于write buffer将数据写入到内存之前完成。第二条指令从内存中读取数据,未命中Cache,所以它会到内存中去读取数据。第三条指令也从内存中读取数据,但是命中Cache,它可以立即得到数据。对于CPU来说,它执行指令的顺序是1 2 3;但是对内存来说,它接收到的访存请求却是2 1。

当然有时候需要让实际的访存顺序按照我们指令的编写顺序去严格执行,比如说修改CP15寄存器,或者拷贝内存里面的代码这样的一些操作,处理器需要等待对应的操作完成。

对于那些超高性能的处理器,往往它们支持数据预测访问,指令多发射,缓存同步协议和乱序执行的特性,这些特性更可能引起访存顺序的改变。在单核的CPU上,硬件会处理好这些问题,它会考虑访问数据之间的关系,保证访存操作的结果正确。但是,假如CPU有多个核,它们通过共享内存交换数据,考虑访存顺序就变得特别重要,你可能需要特别关心多线程访问共享内存时的同步问题。

ARMv7-A架构的处理器采用的是弱顺序存储模型,该模型允许重排序访存操作,实际的访存顺序不用严格遵守加载(LDR)和存储(STR)指令的执行顺序。对Normal Memory的读写操作可以由硬件重排序,这种重新排序只受数据依赖性和显式内存屏障指令的约束。如果需要更强有力的排序规则,需要在该内存块的转换表入口里面设置相应的属性来告知处理器核。为处理器核强制设置排序规则会限制硬件优化的效果,导致性能下降,功耗增加。

ARM存储顺序模型

Cortex-A系列处理器采用用的是弱顺序存储模型,访存操作可以被重新排序。ARM架构定义了3种互斥的存储类型:严格顺序(Strongly-ordered)类型,设备(Device)类型,常规(Normal)类型,所有的存储区域都必须设置为其中一种存储类型。下表列出了两个访存操作A1 A2访问不同存储类型的存储区域时的实际访存序(A1先于A2发出):
不同存储类型区域的访存序
可以看到,对于Normal类型的存储区域,访存操作可以被重排序。如果都是访问的Device类型或Strongly-Ordered类型的存储区域,则严格按照指令顺序访存。

Strongly-Ordered和Device存储类型

对Strongly-Ordered和Device类型存储区域的访问遵循相同的存储序模型,规则如下:

  • 访问的次数和数据量大小保持原状,访问操作是原子的,不会被中途打断。
  • 读写访问都会对系统产生影响(无法理解),访问不经过缓存,不允许预测访问。
  • 不允许非对齐访问。
  • 对于Device类型的存储区域,访问到达存储区的顺序保证跟访存的指令顺序一致,这种保证仅适用于访问同一个外设或同一块存储区域(比如访问同一Device类型页的几个地址,它们的访问顺序是被保证的)。
  • 在ARMv7架构里,对Normal存储区域和Strongly-Ordered/Device存储区域的访问顺序可以调整。

Strongly-Ordered和Device存储类型的区别仅有以下两点:

  • 对于Strongly-Ordered存储区域的写操作,只有当写操作真正到达外设或存储设备后,才算完成。
  • 对于Device存储区域的写操作,可以在写操作真正达到外设或存储设备前提前完成。

系统外设绝大多数都被映射为Device类型,Device类型的存储区域可以设置Shareable属性。

Normal存储类型

存储系统的绝大部分存储区域都是Normal类型的,所有的ROM和RAM都被当作Normal存储类型的设备。Normal存储类型有如下特点:

  • CPU核可以重复读和某些写操作(无法理解)。
  • 在MMU访问权限允许的情况下,CPU核可以对额外的存储位置进行预测访问(不包括写操作),不会引起问题(无法理解)。
  • 允许非对齐访问。
  • 多个访存操作可以被内核硬件合并为更少的访存操作,但是单次访存的数据量更大。

(这段话直接翻译的,无法理解)
Normal类型的存储区域必须设置缓存属性(cacheability attributes),ARM架构为Normal内存的两个缓存级别(内侧缓存inner cache和外侧缓存outter cache)提供了缓存属性支持。内侧指的是最靠近CPU核的缓存(总是包含CPU核的一级缓存);实现可能没有外侧缓存,或者可以将二级和三级缓存当作外侧缓存,为它们指定外侧缓存属性。这些不同级缓存之间的映射以及缓存的物理实现是由实现自定义的。

Normal类型的存储区域需要设置共享属性(shareability attribute),要么是可共享的(Shareable),要么是非共享的(Non-Shareable)。共享和非共享是针对CPU核来说的,非共享指存储区域只能由一个CPU核使用;共享则可以由多个CPU核共用,但是软件需要处理存储数据的一致性问题(比如可以让某些核来负责维护缓存和操作内存屏障)。

(这段话直接翻译的,无法理解)
外部共享属性使得系统的定义包含多级一致性控制。比如说,一个内部共享域可以由一个Cortex-A15集群和一个Cortex-A7集群组成。在一个集群内部,CPU核的数据缓存对所有具有内部共享属性的数据访问是一致的。同时,这个集群和一个具有多核的图像处理器可能组成外部共享域。一个外部共享域可以由多个内部共享域组成,但是一个内部共享域只能作为某一个外部共享域的一部分。

(这段话直接翻译的,无法理解)
具有可共享属性的区域是可被系统中的其他代理访问的区域。同一可共享域内的其他处理器对该区域内存的访问是一致的。这意味着你不必考虑数据或缓存的影响。如果没有可共享属性,在核心之间不维护共享内存区域的一致性的情况下,您必须自己显式地管理一致性。

ARMv7架构允许将共享的存储区域指定为内部可共享(Inner Shareable)或外部可共享(Outer Shareable)。

存储屏障( Memory barriers)

存储屏障要求CPU核限制屏障前后的访存指令的访存顺序,可以使用屏障指令来实现。前面已经提到过,缓存,写缓冲和乱序执行等优化会让实际的访存顺序与代码里的指令顺序不同,这种访存顺序的改变对应用开发人员是不可见的,应用开发者通常不用关注内存屏障。但是,对于驱动开发人员或者需要同步多个数据观察者时,就需要考虑访存顺序改变问题。ARM架构提供了存储屏障指令,能够强制让CPU核等待访存操作完成,它们在ARM和THUMB代码里面,在用户模式和特权模式下均可使用。

先来看一下这些屏障指令在单核系统里面的作用。直接访问(explicit access)指的是由LDR和STR指令引起的数据访问操作,不包含取指令操作。

  • Data Synchronization Barrier (DSB),数据同步屏障。
    这条指令强制让CPU核等待所有挂起的直接数据访问完成之后,才能执行其它的指令阶段,但是它不影响指令预取。

  • Data Memory Barrier (DMB),数据存储屏障。
    这条指令保证代码里面出现在屏障之前的所有访存操作会先于屏障之后的任何直接访问操作在系统里面被观察到,但是它不影响其它指令执行的顺序,也不影响取指令的顺序。

  • Instruction Synchronization Barrier (ISB),指令同步屏障
    这条指令会冲刷流水线和预取缓冲,以便屏障后的所有指令都取自缓存或内存,这可以确保上下文调整操作的效果。比如说,屏障之前的CP15协处理器操作、改变ASID、TLB或者分支预测操作对屏障之后的任何指令都是可见的。这个屏障本身不会引起数据和指令缓存之间的同步,但在同步的时候却需要用到。

有几个选项可以搭配DMB和DSB指令一起使用,以提供访问类型和它所适用的可共享域,如下所示:

  • SY
    这是默认值,意味着屏障适用于整个系统,包括所有CPU核心和外设。

  • ST
    一个仅等待存储完成的屏障。

  • ISH
    仅适用于内侧共享域的屏障。

  • ISHST
    一个结合ST和ISH的屏障。也就是说,它只对内部可共享内存进行存储。

  • NSH
    一个(PoU)Point of Unification屏障。

  • NSHST
    一个仅等待存储完成的屏障,并且只能读出到PoU。

  • OSH
    外侧共享域的屏障。

  • OSHST
    仅等待存储完成的屏障,并且只能用于外侧共享域。

为了理解这些选项的含义,你必须使用在多核系统中更为通用的DMB和DSB定义。以下内容中提到的处理器(或代理)并不一定指代CPU核,也可能指DSP、DMA控制器、硬件加速器或任何其它访问共享内存的模块。

DMB指令的作用是在可共享域内强制执行内存访问顺序,保证共享域内的所有处理器能够先观察到DMB指令之前的所有显式内存访问,然后才能观察到指令之后的任何显式内存访问。

DSB指令与DMB指令具有相同的效果,但除此之外,它还使内存访问与完整的指令流同步,而不仅仅是与其它内存访问同步。这意味着当发出DSB指令时,指令执行将暂停,直到所有的显式内存访问完成。当所有的内存读取操作都完成并且写缓冲区已排空时,将恢复指令的执行。

通过考虑一个示例可能更容易理解内存屏障的作用。举一个四核Cortex-A9集群的例子,该集群形成一个单独的内部可共享域。当集群中的单个核心执行DMB指令时,该核心将确保屏障之前的所有访存操作按照指令中的顺序完成,然后才执行屏障之后的显式内存访问。这样,可以确保集群中的所有核心都将以同样的顺序看到该屏障两侧的内存访问操作。如果使用DMB ISH变体,则无法保证外部观察者(例如DMA控制器或DSP)也能做到这一点。

接下来看两个内存屏障使用的实例,先看第一个:假设有两个核心A和B,并且内核寄存器中保存了两个Normal存储区域的地址Addr1和Addr2。A和B分别执行如下两条访存指令:
A:

STR R0, [Addr1]
LDR R1, [Addr2]

B:

STR R2, [Addr2]
LDR R3, [Addr1]

这个示例没有对访存顺序做任何要求,也没有对访存事务发生的顺序做出任何声明。地址Addr1和Addr2是独立的,两个核心都没有必要按程序中的指令顺序来执行加载和存储操作,它们也互不关心另一个核心的操作。因此,这段代码有四种可能的合法结果,A的R1寄存器和B的R3寄存器中的值会有4种不同组合:A.R1获得旧数据 + B.R3获得旧数据、A.R1获得旧数据 + B.R3获得新数据、A.R1获得新数据 + B.R3获得旧数据、A.R1获得新数据 + B.R3获得新数据。

如果再多加一个核心C,也必须注意到没有要求它以其它核心相同的顺序观察任一存储器。A和B都完全可以在Addr1和Addr2中看到旧值,而C可以看到新值。

接下来的这个示例基于上一个示例进行改进:考虑B核心上的代码需要根据A核心设置的某个标志来读取内存数据的情况(比如,你正在从A向B传递消息),代码片段看起来可能会像下面这样:
A:

STR R0, [Msg] @ 写一些数据到消息邮箱
STR R1, [Flag] @ 新数据已经就绪,可以读取了

B:

Poll_loop:
	LDR R1, [Flag]
	CMP R1,#0 @ 标志是否已经设置?
	BEQ Poll_loop
	LDR R0, [Msg] @ 读取新数据.

同样,这段代码可能不会按照预期的方式工作。因为没有理由不允许B核心在读取[Flag]之前投机地先读取[Msg]。这是正常的,因为对于弱排序的内存,核心并不知道[Msg]和[Flag]之间可能存在依赖关系。你必须通过插入内存屏障来显式强制执行依赖关系。在这个例子中,您实际上需要两个内存屏障。A核心需要在两个存储操作之间插入一个DMB,以确保它们按代码里面指定的顺序发生。B核心需要在LDR R0, [Msg]之前插入一个DMB,以确保在标志设置之前不读取消息。

使用内存屏障来避免死锁

在某些情况下,不使用内存屏障可能会引起死锁。比如,有如下的代码片段:

STR R0, [Addr] @ write a command to a peripheral register
DSB
Poll_loop:
LDR R1, [Flag]
CMP R1,#0 @ wait for an acknowledge/state flag to be set
BEQ Poll_loop

这段代码假设CPU核心通过Addr地址与某个外设进行通信。外设轮询Addr的数据,它获取到数据后,会设置Flag标志。CPU核心轮询Flag标志是否设置,然后往下运行。

ARMv7架构没有多处理扩展(multiprocessing extensions),不严格要求一定要完成数据到[Addr]的存储(可能只是将写请求发送到了写缓冲区)。CPU核心和外设都可能锁死,外设在等CPU核心的数据,CPU核心在等待外设返回的标志位。通过在核心的STR之后插入一个DSB,可以让CPU核心在读取Flag之前将数据写入到Addr,避免发生死锁。

实现了多处理扩展的CPU核心必须在有限的时间内完成访问(即,它们的写缓冲区必须排空),因此不需要使用屏障指令。

WFE与WFI指令与屏障的交互

WFE(等待事件)和WFI(等待中断)指令使您能够停止执行并进入低功耗状态。为了确保在执行WFI或WFE之前所有的内存访问都已完成(并且对其它核心可见),您必须插入一个DSB指令。

另一个需要考虑的问题与在多核系统中使用WFE和SEV(发送事件)有关。这些指令使您能够减少与等待获取锁(自旋锁)相关的功耗。尝试获取互斥锁的核心可能会发现其它核心已经拥有该锁。您可以使用WFE指令让核心暂停执行并进入低功耗状态,而不是让核心反复轮询锁。

当接收到一个中断或其发生了异步异常事件,或者收到另一个核心发送的事件(使用SEV指令)时,核心会被唤醒。拥有锁的核心将在释放锁后使用SEV指令唤醒处于WFE状态的其它核心。内存屏障指令不将事件信号视为明确的内存访问,因此我们必须确保在执行SEV指令之前,其它处理器可以看到锁被释放,这需要使用DSB。DMB不能满足要求,因为它只影响内存访问顺序,而不将内存访问同步到特定的指令。而DSB将防止SEV执行,直到其它核心看到所有DSB屏障之前的内存访问。

内存屏障在Linux里面的使用

内存屏障用于强制内存操作的顺序。通常,您不需要理解或显式使用内存屏障。这是因为它们已经包含在内核的锁和调度原语中。然而,设备驱动程序编写者或寻求理解操作系统内核的人可能会希望详细了解内存屏障的用法。

编译器和CPU核心架构的优化允许指令和相关内存操作的顺序被改变。然而,有时您希望强制让内存的操作按照指定的执行顺序。例如,您可以通过内存映射的方式写外设寄存器,写寄存器可能对系统的其它部分产生影响。在我们的程序中,此操作之前或之后的内存操作可以像它们可以被重新排序一样出现,因为它们在不同的位置上操作。然而,在某些情况下,您希望确保所有操作在完成此外设寄存器写入之前完成。或者,您可能希望确保外设寄存器写入完成之前,任何其它的内存操作都不会开始。Linux提供了一些函数用于实现这些目的,它们如下:

  • 使用barrier()函数,可以告知编译器不允许对特定的内存操作进行重新排序。这仅控制编译器的代码生成和优化,对硬件重新排序没有影响。
  • 通过调用ARM处理器指令集里面的内存屏障指令,可以强制内存操作的顺序。对于Cortex-A的处理器,linux提供如下的一些屏障指令:
    1.读内存屏障rmb()函数确保在屏障之前的任何读取操作在屏障之后的任何读取操作执行之前完成。
    2.写内存屏障wmb()函数确保在屏障之前的任何写入操作在屏障之后的任何写入操作执行之前完成。
    3.内存屏障mb()函数确保在屏障之前的任何内存访问在屏障之后的任何内存访问执行之前完成。

这些屏障指令有相应的SMP版本,称为smp_mb()、smp_rmb()和smp_wmb()。这些屏障用于强制同一集群内的核心之间的Normal可缓存内存的顺序,例如Cortex-A15集群中的每个核心。它们可以与设备一起使用,甚至适用于不可缓存的Normal内存。当内核编译未配置CONFIG_SMP时,这些函数的调用展开为barrier()函数。

Linux内核提供的所有锁原语都使用了对应的屏障,具体可以参考linux内核中使用内存屏障

缓存一致性影响(Cache coherency implications)

大部分时候,缓存对应用程序员来说是不可见的。然而,当系统中的其它地方更改了内存数据或当应用程序对内存的更新必须对系统的其它部分可见时,应用就能够感知到缓存的影响。

一个包含外部DMA设备和核心的系统提供了一个可能出现缓存问题的简单示例。如果DMA从内存中读取数据,而更新的数据却存储在CPU核的缓存中,那么DMA将会读取到旧的数据。同样地,如果DMA向内存写入了数据,而CPU核缓存中存在过时的数据,那么CPU核可能会继续使用这些过期的旧数据。

因此,在DMA开始之前,必须显式清理CPU数据缓存中的脏数据。同样地,如果DMA正在传输数据到内存,则必须确保CPU数据缓存中不包含陈旧数据。缓存不会因为DMA写入内存而自动更新,这可能需要CPU在启动DMA之前清理缓存或使受影响的缓存区域无效。由于所有ARMv7-A处理器都可以进行推测性内存访问,因此在使用DMA后也需要进行缓存无效化操作。

  • 代码拷贝问题( Issues with copying code)
    引导代码、内核代码或即时编译器(JIT compilers)可以将程序从一个位置复制到另一个位置,或者修改内存中的代码,但是并没有硬件机制来维护指令缓存和数据缓存之间的一致性。您必须将受影响的区域无效化来将陈旧的代码数据从指令缓存中清除,并确保写入的代码数据实际上已经到达主内存。如果CPU打算跳转到修改后的代码,还需要使用包括屏障指令在内的特定代码序列。

  • 编译器重排序优化
    内存屏障指令仅适用于硬件对内存访问的重排序,理解这一点非常重要。代码中插入内存屏障指令可能并不会对编译器重排序内存访问产生任何直接影响,编译器仍然可能进行内存访问重排序优化。在C语言中,可以使用volatile限定符告诉编译器该变量可能会被当前正在访问它的代码之外的东西(比如DMA,中断等等)更改。在C语言中,这通常用于通过内存映射的方式访问I/O,使得这类设备可以通过指向volatile变量的指针安全地访问。
    然而,C标准没有提供关于在多核系统中使用volatile的规则。因此,尽管你可以确定volatile加载和存储将各自按照程序中指定的顺序发生,但却没有关于相对于非volatile加载和存储的访问重排序的保证,这意味着volatile不能用于实现互斥锁。
    由于volatile不提供跨多个处理器核心的同步保证,因此在多线程或多核环境中,通常需要使用更复杂的同步机制,如互斥锁、读写锁、原子操作或内存屏障指令,来确保数据的一致性和顺序性。

  • 27
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值