分享

ARM系列 -- load和store

 waston 2025-01-08 发布于上海

今天我们来简单研究一下ARM的load和store指令。文中的分析都是默认基于A64指令集。如果不明白A64是什么,或者对处理器内部常用寄存器不清楚,建议看看前面的文章。

现在的处理器指令集基本都是寄存器-寄存器型。在寄存器-寄存器型指令系统中,运算指令的操作数只能来自寄存器,不能来自存储器,所有的访存都必须显式的通过load指令和store指令来完成,所以寄存器-寄存器型又被称为load-store型。

Load,顾名思义,就是把外部存储器数据加载到处理器的寄存器中;store就是把寄存器中的数据存储到外部存储器中。需要注意,load/store指令其实是一组指令,除去基本的load/store指令还有一大堆变种指令,总共差不多有几十种。文中以小写字母load/store来统称加载/存储指令,具体的指令用大写字母来表示。

基本的加载和存储指令是LDR和STR,指令的语法格式是:

LDR <Destination>, [<address>]

STR <Destination>, [<address>]

LDR指令就是把内存地址<address>中的数据(用地址加上方括号表示,即[<address>])加载到<Destination>指定的通用寄存器中;STR反过来,把通用寄存器中的数据存储到内存地址中。

现在就涉及到了一个问题,指令中的地址如何确定,或者说指令如何寻址?

A64指令集中的load/store指令的寻址模式需要使用通用寄存器X0-X30或当前堆栈指针SP作为基址,再加上立即数或寄存器偏移量,构成最终的内存地址。A64的寻址模式有以下几种:
image.png

  • 基址寄存器模式(Base register modes)。这是最简单的寻址形式。基址寄存器是一个X寄存器,包含被访问数据的完整/虚拟地址,如下面的示意图所示,LDR指令把X1寄存器中的值当作内存地址,取出其中的数值,装入W0寄存器。
    image.png

  • 偏移寻址模式(Offset addressing modes)。此模式把寄存器中的地址加上偏移量,相加的结果作为内存地址,如下面的示意图所示,把X1中的地址加上偏移量12作为新的内存地址,然后把新内存地址中的数据加载到W0。

image.png

  • 前索引寻址模式(Pre-indexed addressing modes)。在这种模式下,先更新地址偏移,然后访问内存地址。在指令语法中,前索引通过在方括号之后添加感叹号来表示。前索引寻址与偏移寻址类似,不同之处在于基指针是根据指令更新的,如下面的示意图所示,先把X1中的内存地址加上偏移量12,存入X1,然后取出新地址中的数据存入W0。

image.png

  • 后索引寻址模式(Post-indexed addressing modes)。与前索引寻址模式不同,在这种模式下,先访问内存地址,然后更新地址偏移。使用后索引寻址,先从基指针中的地址加载值,然后更新指针,如下面的示意图所示,LDR先把X1内存地址的值加载到W0,然后把内存地址向后偏移12,新的内存地址放入X1。后索引寻址对于出栈很有用。指令从堆栈指针指向的位置加载值,然后将堆栈指针移动到堆栈中的下一个完整位置。

image.png

  • PC相对寻址模式(Literal)。在这种模式下,基址是64位程序计数器的值,再加上19位有符号字偏移量组成内存地址。这意味着内存地址是一个4字节对齐的地址,地址范围在±1MB内。PC相对寻址只能用于至少32位的加载和预取指令。相对寻址适用于子程序调用。

分析完寻址方式,接下来我们再进一步看看指令的编码格式。A64是固定长指令集,采用32-bit编码。Load/store指令的基本编码格式如下:
image.png
其中的op0-op4是操作码,具体编码如下表:

image.png

我们具体来分析一条指令,LDR(register)。如前面所说,该指令根据基址寄存器的值和偏移寄存器的值来计算内存地址,并把内存地址中的数据加载进通用寄存器。指令的具体格式如下,其中size=10表示32-bit,size=11表示64-bit。

image.png

其中<Xt>是64-bit的通用寄存器名称,放在上图的Rt字段;<Xn>是64-bit的通用寄存器名称,放在Rn字段;如果option<0>=1,<Xm>是64-bit的通用索引寄存器,放在Rm字段;<extend>是索引扩展/移位说明符,默认为LSL,编码在option字段中,见下图;<amount>是索引移位量,仅在<extend>不是LSL时可选用。

image.png

前面说的load/store指令都是对一个的寄存器操作。A64还提供了对两个(pair)寄存器操作的load/store指令,也叫成对指令,即LDP(Load Pair)和STP(Store Pair)。成对指令可以在两个寄存器和内存之间传输数据。

下面示例中,LDP指令加载[X0]的数据到W3,加载[X0+4]到W7:

LDP W3, W7, [X0]

下面示例中,STP指令存储D0到[X4],存储D1到[X4+4]:

STP D0, D1, [X4]

LDP和STP指令通常用于出栈和压栈操作。在AArch64中,堆栈指针必须是128-bit对齐的,而通用寄存器是64-bit宽度。下面的示例中,STP指令采用前索引寻址,先把SP指向的地址做偏移,然后把X0和X1的值存入SP指向的地址。LDP指令采用后索引寻址,把SP指向地址的数据加载到X0和X1,并且SP指向的地址偏移16(即128bit对齐)。

STP X0, X1, [SP, #-16]!

LDP X0, X1, [SP], #16

A64中还提供了一些特殊的加载和存储指令。为了支持内存屏障,A64提供了LDAR(Load-Acquire)和STLR(Store-Release)指令。LDAR和STLR是单向的内存屏障指令,可以用于支持释放一致性(Release Consistency)模型。

A64中提供了同步原语指令LDXR(Load Exclusive)和STXR(Store Exclusive),用以支持加载/存储的原子操作。接下来会另开几篇探讨内存屏障和同步,这里就不细说了。

对于load/store指令,有必要再深入研究一下。由于load和store指令是相对应的,我们今天重点看load。

ARM提供了一组指令,包括加载获取(Load Acquire)语义和存储释放(Store Release)语义,以支持Release Consistency sequentially consistent(RCsc)模型。另外,FEAT_LRCPC(Armv8.3中的扩展功能)提供Load AcquirePC指令。Load AcquirePC和Store Release的组合可用于支持Release Consistency processor consistent(RCpc)模型。RCpc相较RCsc,则更加弱化。

ARM采用的是弱一致性模型。释放一致性模型(Release Consistency,RC)是对弱一致性模型的改进,它把同步操作进一步分成获取操作(Acquire)和释放操作(Release)。Acquire用于获取对某些共享存储单元的独占性访问权;Release用于释放该访问权。执行的顺序为:acquire-> load/store ->release。

  • 对于获取语义:该原语之后的任何读写操作都不能重新排序到此原语之前执行

  • 对于释放语义:该源于之前的任何读写操作都不能重新排序到此原语之后执行

Acquire通常与加载指令结合;而Release通常与存储指令结合,所以在文档中经常会看到load-acquire和store-release这样的描述。

对于数据大小,有如下的约定:

  • 字节(Byte),8-bit

  • 半字(Half-word),16-bit

  • 字(Word),32-bit

  • 双字(Double-word),64-bit

铺垫完这些基本概念,回到加载指令。

1. LD64B,单拷贝原子64字节加载指令。该指令从基址寄存器中得到内存地址,从内存位置连续加载八个64-bit的双字,写入连续寄存器Xt到X(t+7),整个过程是原子操作。

LD64B指令的编码格式如下:
image.png
LD64B指令的语法格式如下:

LD64B <Xt>, [<Xn|SP> {,#0}]

2. LDADD指令,原子加法指令。该指令从内存中加载一个32-bit字或64-bit双字,与寄存器里面的值相加,把结果写回到内存中;最初从内存加载的值返回到目标寄存器中。

LDADD指令的编码格式如下:
image.png
LDADD指令的语法格式如下:

LDADD <Xs>, <Xt>, [<Xn|SP>]

另外还有三条指令,LDADDA,LDADDL和LDADDAL。这四条指令的区别是,LDADDA和LDADDAL的加载访问有获取语义;LDADDL和LDADDAL的存储访问有释放语义;LDADD既没有获取语义也没有释放语义。所以,指令中最后的A代表acquire,L代表release。针对字节和半字操作,相应的指令是LDADDB, LDADDAB, LDADDALB, LDADDLB和LDADDH, LDADDAH, LDADDALH, LDADDLH,最后的B表示byte,H表示halfword。

3.LDAR,加载获取指令(Load-Acquire)。该指令从基址寄存器得到内存地址,从内存位置加载一个32-bit字或64-bit双字,写入寄存器。

LDAR指令的编码格式如下:

image.png

LDAR指令的语法格式如下:

LDAR <Xt>, [<Xn|SP>{,#0}]

后面讲内存屏障的时候会着重解释一下LDAR指令。

LDAR的变体指令有LDARB和LDARH。

4.LDAPR,加载获取指令(Load-Acquire RCpc)。该指令从基址寄存器得到内存地址,从内存位置加载一个32-bit或64-bit,写入寄存器。

LDAPR指令的编码格式如下:
image.png
LDAPR指令的语法格式如下:

LDAPR <Xt>, [<Xn|SP> {,#0}]

与LDAR指令的不同之处在于,LDAPR指令支持Load-AcquirePC。LDAPR的变体指令有LDAPRB和LDAPRH。

5.LDAPUR,不扩展加载获取指令(Load-Acquire RCpc,unscaled)。该指令从基址寄存器得到内存地址,并加上一个立即数偏移组成新的地址,从新地址加载一个32-bit字或64-bit双字,不扩展,写入寄存器。

LDAPUR指令的编码格式如下:
image.png
LDAPUR指令的语法格式如下:

LDAPUR <Xt>, [<Xn|SP>{, #<simm>}]

LDAPUR指令中的U表示unscaled。这里的unscaled指的是偏移量不可扩展。LDR指令中的偏移量是可扩展的,也就是偏移量是8字节对齐的。以imm9为例,这是9-bit的偏移量,如果是可扩展,地址偏移量范围是0 ~ 4088B(2^9 * 8)。如果不可扩展,偏移量是字节对齐的,范围是-256 ~ 255B。

LDAPUR的变体指令有LDAPURB和LDAPURH。

6.LDAPURSW,有符号加载获取指令(Load-Acquire RCpc,Signed Word)。该指令从基址寄存器和立即偏移量计算地址,从内存加载有一个符号字,对其进行符号扩展,并将其写入寄存器。

LDAPURSW指令的编码格式如下:
image.png
LDAPURSW指令的语法格式如下:

LDAPURSW <Xt>, [<Xn|SP>{, #<simm>}]

LDAPURSW指令中的SW表示signed word。其变体指令有LDAPURSB和LDAPURH。

7.LDAXR,加载获取独占指令(Load-Acquire Exclusive)。该指令从基址寄存器得到内存地址,从内存位置加载一个32-bit字或64-bit双字,写入寄存器。内存访问是原子性的。PE将正在访问的物理地址标记为独占访问,此独占访问标记由存储独占指令检查。

LDAXR指令的编码格式如下:
image.png
LDAXR指令的语法格式如下:

LDAXR <Xt>, [<Xn|SP>{,#0}]

LDAXR指令中的X表示exclusive。其变体指令有LDAXRB和LDAXRH。

8.LDAXP,加载获取独占指令(Load-Acquire Exclusive Pair)。该指令从基址寄存器得到内存地址,从内存位置加载两个(一对)32-bit字或64-bit双字,并将其写入两个寄存器。PE将正在访问的物理地址标记为独占访问,此独占访问标记由存储独占指令检查。

LDAXP指令的编码格式如下:

image.png
LDAXP指令的语法格式如下:

LDAXP <Xt1>, <Xt2>, [<Xn|SP>{,#0}]

LDAXP指令中的P表示pair,参考下面的LDP指令。

9.LDXP,加载独占指令(Load Exclusive Pair)。该指令从基址寄存器值得到内存地址,从内存加载两个32-bit字或两个64-bit双字,并将其写入两个寄存器。PE将正在访问的物理地址标记为独占访问,此独占访问标记由存储独占指令检查。

LDXP指令的编码格式如下:

image.png
LDXP指令的语法格式如下:

LDXP <Xt1>, <Xt2>, [<Xn|SP>{,#0}]

10.LDXR,加载独占指令(Load Exclusive)。该指令从基址寄存器值得到内存地址,从内存加载32-bit字或64-bit双字,并将其写入寄存器。内存访问是原子性的。PE将正在访问的物理地址标记为独占访问,此独占访问标记由存储独占指令检查。

LDXR指令的编码格式如下:
image.png
LDXR指令的语法格式如下:

LDXR <Xt>, [<Xn|SP>{,#0}]

其变体指令有LDXRB和LDXRH。

11.LDP,加载指令(Pair)。该指令通过基址寄存器和立即数偏移计算出内存地址,从内存地址加载两个32-bit字或64-bit双字,写入到两个寄存器。

LDP指令的编码格式如下:

image.png

其变体指令LDTRB,LDTRH。

当PSTATE寄存器中的UAO字段为1,在EL1和EL2执行非特权加载指令的效果和执行特权加载指令的效果一样。

13.LDTRSW,非特权有符号数加载,该指令从内存中加载一个字,将其扩展为64位有符号数,并将结果写入寄存器。用于加载的地址由基址寄存器和立即数偏移量计算得出。

image.png
其变体指令有LDTRSB,LDTRSH.

14.LDUR,不扩展加载指令(unscaled),该指令根据基址寄存器和立即数偏移量计算地址,从内存加载32-bit字或64-bit双字,零扩展,并将其写入寄存器。

image.png

其变体指令有LDURB,LDURH。

15.LDURSW,有符号不扩展加载指令,该指令根据基址寄存器和立即数偏移量计算地址,从内存加载有符号字,对其进行符号扩展,并将其写入寄存器。

image.png

其变体指令有LDURSB和LDURSH

16.LDG,加载分配标记(Allocation Tag)指令,该指令从内存地址加载分配标记,从分配标记生成逻辑地址标记,并将其合并到目标寄存器中。用于加载的地址根据基址寄存器和立即数有符号偏移量(通过Tag粒度缩放)计算。

image.png

其变体指令有LDGM,M表示multiple。等讲内存标签(Memory Tagging)的时候在具体介绍LDG指令。

17.LDLAR,加载指令(Load LOAcquire)。该指令从内存中加载一个32-bit字或64-bit双字,并将其写入寄存器。该指令还具有内存排序语义(在手册的LoadLOAcquire, StoreLORelease章节)。

LDLAR指令的编码格式如下:

image.png

18.LDCLR,原子位清除指令。该指令以原子方式从内存加载一个32-bit字或64-bit双字,执行按位与运算,并将值的补码保存在寄存器中,然后将结果存储回内存。最初从内存加载的值返回到目标寄存器中。

image.png

其变体指令有LDCLRA,LDCLRL和LDCLRAL,A和L的含义参考前面。同样,对于8-bit数据操作,有LDCLRB,LDCLRAB,LDCLRALB,LDCLRLB;对16-bit数据操作,有LDCLRH,LDCLRAH,LDCLRALH,LDCLRLH。

19.LDEOR,原子异或指令。该指令以原子方式从内存中加载32-bit字或64-bit双字,使用其寄存器中保存的值执行异或,并将结果存储回内存。最初从内存加载的值返回到目标寄存器中。

LDEOR指令的编码格式如下:

image.png

其变体指令有LDEORA, LDEORAL, LDEORL,LDEORB, LDEORAB, LDEORALB, LDEORLB,LDEORH, LDEORAH, LDEORALH, LDEORLH。不再赘述。

20.LDSET,原子位设置指令。该指令以原子方式从内存加载一个32-bit字或64-bit双字,执行按位或运算,并将值保存在寄存器中,然后将结果存储回内存。最初从内存加载的值返回到目标寄存器中。

image.png

其变体指令有LDSETA,LDSETAL,LDSETL;LDSETB,LDSETAB,LDSETALB,LDSETLB;LDSETH,LDSETAH,LDSETALH,LDSETLH。

21.LDSMAX,原子有符号数取大值指令。该指令以原子方式从内存中加载一个32-bit字或64-bit双字,将其与寄存器中保存的值进行比较,并将较大的值存储回内存。最初从内存加载的值返回到目标寄存器中。

LDSMAX指令的编码格式如下:

image.png

其指令变体有LDSMAXA,LDSMAXAL,LDSMAXL;LDSMAXB,LDSMAXAB,LDSMAXALB,LDSMAXLB;LDSMAXH,LDSMAXAH,LDSMAXALH,LDSMAXLH。

对应的还有原子无符号数取大值指令,LDUMAX,LDUMAXA,LDUMAXAL,LDUMAXL;LDUMAXB,LDUMAXAB,LDUMAXALB,LDUMAXLB;LDUMAXH,LDUMAXAH,LDUMAXALH,LDUMAXLH。

22.LDSMIN,原子有符号数取小值指令。该指令从内存中加载一个32-bit字或64-bit双字,将其与寄存器中保存的值进行比较,并将较小的值存储回内存,将这些值视为有符号数。最初从内存加载的值返回到目标寄存器中。

LDSMIN指令的编码格式如下:
image.png

其变体指令LDSMINA,LDSMINAL,LDSMINL;LDSMINB,LDSMINAB,LDSMINALB,LDSMINLB;LDSMINH,LDSMINAH,LDSMINALH,LDSMINLH。

无符号数指令LDUMIN,LDUMINA,LDUMINAL,LDUMINL;LDUMINB,LDUMINAB,LDUMINALB,LDUMINLB;LDUMINH,LDUMINAH,LDUMINALH,LDUMINLH。

本篇主要是介绍各种特殊的加载指令,至于具体的用法,会放在后面的同步原语(Synchronization Rrimitive),内存屏障(Memory Barrier),内存标签(Memory Tagging)等文章中。

这篇文章整理起来十分繁琐,指令过多,而且指令名字差别不大,几个字母颠来倒去,很容易就看串行或者敲错了。

与加载指令对应的是存储指令,就不再整理了。

如果大家不是做处理器逻辑设计,或者底层软件开发,编译器开发等等(比如我,哈哈),就不需要太关注具体的指令编码和语法。但是从系统架构角度,需要对这些指令有所了解。


ARM系列 -- 同步原语

在具有多个执行线程的系统中,某些资源可能不能同时被访问或者修改。这些资源可以是外围设备或内存缓冲区和数据结构,例如打印机不会在一个时刻响应多个访问。这就需要一些同步机制去处理这些资源的同时控制(concurrency control)问题。需要注意的是,这里说的“同步”,不是同时的意思,而是协同的意思。某个资源使用受限的情况下,你用完了我用,大家协同工作。

可以通过“锁(lock)”机制来实现同步,对这些不能同时被访问的共享资源提供一个锁。线程在访问这些资源之前必须先获得锁权限。这时,如果其它线程也想申请锁,会发现锁被占用,只能等待锁被释放。拥有锁的线程在访问结束后,必须释放锁,以便其它的线程可以继续访问。

可以使用内存中的变量来实现简单的锁,该变量可以包含两个值(状态):LOCKED和UNLOCKED。进程如果发现这个锁是UNLOCKED状态,即可以修改为LOCKED,并拥有该资源的访问权限。

image.png

图中的整个过程分为三个步骤:

  • 读内存中的变量,并做状态比较;

  • 修改寄存器值;

  • 写内存变量。

在具有多个核或线程的系统中,此方法容易受到另一个线程的攻击,即在变量值的第一次读取和回写之间修改内存中的值。

这个问题可以用软件解决,也可以用额外的硬件功能来解决。一种解决方案可以是使“读-比较-修改-写(read-compare-modify-write)”的原子操作指令。ARM架构的早期版本使用SWP指令实现类似的功能。

ARMv8-A使用下面要介绍的,一种特殊类型的加载和存储指令来检测内存中的值自上次读取以来是否发生了变化。ARMv8-A 64位指令集提供了两条独占指令LDXR(Load Exclusive)和STXR(Store Exclusive)。

当使用LDXR指令读取地址时,会将其标记为独占访问。如果使用STXR指令向标记为独占的地址写入,则会清除独占状态。尝试使用STXR指令向未标记为独占的地址写入将会失败。地址的独占状态由称为独占监视器(Exclusive Monitor)的硬件维护。

使用独占load/store后的锁实现:

image.png

锁值的更新不能保证是原子的,但现在可以检测到初始读取和更新之间的任何更改。如果独占存储失败,软件可以再次尝试获取锁。

前面提到过,需要对标记为独占的地址进行监控。独占监视器可以是一个简单的状态机,其状态可以是开放的(open)和独占的(exclusive)。ARM架构定义了两种不同的状态机:本地独占监视器(Local Exclusive Monitor)和全局独占监视器(Global Exclusive Monitor)。

  • 根据被访问地址的可共享性属性,检查本地监视器或全局监视器的独占访问。

  • 对于Non-shareable地址的独占访问检查仅在本地独监视器。

  • 对shareable地址的独占访问检查在本地监视器或全局监视器。

每个处理器核都有一个与其关联的本地监视器。本地监视器可以构造为保存特定地址的独占状态,也可以构造为不保存该地址。本地监视器作为处理器的一部分实现。

全局监视器在多个处理器核之间共享。与本地监视器一样,它们只需要监视一个地址,如果监视器可以标记多个地址,则每个地址都有自己的状态机。

ARM体系结构要求以下内存类型能够与全局监视器一起工作:

image.png

如果程序无法获得锁,会不断尝试申请锁。但是这样会浪费处理器的资源,并且消耗不必要的功耗。有几种方式可以改善这个问题。对于在释放锁之前等待时间相对较长的情况,锁代码可以返回给操作系统调度程序,这允许在释放锁之前调度其它线程。

对于锁可能快速被释放的情况,ARM架构有一种机制,即允许处理器暂停执行,进入低功耗模式,等待锁被释放。ARM提供WFE(Wait For Event)指令,如果处理器申请锁失败,程序可以执行WFE进入等待。以前的做法是,负责释放锁的代码中会执行SEV(Send Event)指令,通过发送EVENT唤醒正在等待锁资源的处理器核。现在ARMv8-A架构中,清除全局监视器会自动向所有连接的处理器核发送EVENT唤醒。


ARM系列 -- 内存屏障

在开始学习ARM内存屏障(memory barrier)指令前,需要想了解几个相关的概念:内存模型(memory model),内存类型(memory type),内存属性(memory attribute)。

关于这几个概念,前面的文章讲过。为了保持本篇的内容完整性,今天再重复一遍。

第一个是内存模型,有的翻译成存储模型。Armv8-A采用的是弱序内存模型(weakly ordered model of memory),也就是说实际的内存访问顺序可能与程序的load/strore操作顺序不完全一致。为什么实际访问顺序与程序顺序不一致呢?或者换一种问法,能不能让二者的顺序一致呢?答案是可以,但是处理器会有性能损失。如果让两者的顺序完全一致,就是强序内存模型,也称为顺序一致性模型。在此模型下,load/store操作是顺序的访问存储器。处理器都按照程序顺序来执行程序,即便访问的是不同内存地址,也不能改变访问顺序。从全局看,每个内存写操作都需要能被系统中所有的处理器同时观测到,同一时刻只有一个处理器和内存系统相连,因此对内存的访问是原子化的,串行化的。但这里有一个问题,处理器只有在一个操作完成后才能执行下一个操作。我们知道,现代高性能处理器的主频很高,芯片的内存速度远远落后于处理器速度,如果处理器发起下一个访存操作前必须等在上一个访存操作完成,那么必然造成处理器停顿(CPU stall),处理器的性能将会被大大影响。

弱序内存模型对于访存顺序的要求不像顺序模型那么严格,只要同步访问之间的次序得到保证,load和store指令的执行就可以改变次序或相互叠加,无需原子性的执行这些指令。换句话说,处理器可以按照一定规则对访存指令进行重新排序。

目前,高性能处理器可以支持推测性内存读取(speculative memory read)、指令多发射(multiple issuing of instruction),乱序执行(out-of-order execution)等多种技术。这些技术与其它技术一起,为访存的硬件重新排序提供了进一步的可能性:

  • 指令多发射:处理器可以在每个时钟周期发出和执行多条指令。一些指令可以并行到达流水线的执行阶段,因此这些指令的执行顺序可能会以与程序中的顺序不同。

  • 乱序执行:此技术允许处理器可以乱序的执行非相关(依赖)的指令,一些指令可能因为某些原因暂时停留在执行阶段,但这些指令不会阻止其它的非相关指令完成。

  • 推测:处理器在执行条件指令(例如分支指令)时,可以根据一定的规则进行推测,尽可能早的装入指令,也就是尽量填充流水线,这样处理器就不会空闲着。

  • load/store优化:处理器为了减少访存次数,可以把多个访存操作合并成一笔操作。

  • 编译器优化:优化编译器可以对指令重新排序,以隐藏延迟或充分利用硬件功能。在单核系统中,这种重新排序的影响对程序员来说是透明的,因为单个处理器可以检查并确保指令的依赖性,避免竞争现象。但是在多核系统中,处理器核之间共享存储,共享数据,目前编译器没有办法知道处理器核之间的依赖关系。

综上,内存一致性模型用于定义系统中对内存访问需要遵守的原则,在高性能处理器设计需要慎重考虑。内存一致性问题不同于缓存一致性,虽然缓存一致性会加重内存一致性的难度。换句话说,即使关闭高性能处理器中的缓存,内存一致性问题依然存在。

第二个概念,内存类型。Armv8-A架构定义了两种互斥的内存类型,普通型(normal)和设备型(device),所有的内存区域都配置为这两种类型中的一种。

1. 普通型内存:

普通型内存主要包括RAM,ROM,Flash等。普通型内存是弱序的,允许编译器进行更多的优化,以支持高性能处理器。处理器可以推测性的访问标记为普通型的存储地址,以便可以从中读取数据或指令,而无需在程序中显式引用。为了获得最佳性能,需要将应用程序的代码和数据标记放在普通型内存中。如果需要严格的内存访问顺序,即在需要强制排序的情况下,可以通过使用显式屏障操作来实现。

处理器必须始终负责由地址依赖性引起的危险:

STR X0, [X2]

LDR X1, [X2]

这个例子中,第一条指令的意思是把X0寄存器中的值写入(store)到以X2寄存器的值为地址的存储位置;第二条指令是把X2寄存器的值为地址的数据加载(load)到X1处理器中,两条指令执行完的效果是把X0的值写到外部存储(地址保存在X2中),并且把这个值赋给X1。由于这两条指令都以X2寄存器的值为地址,因此便产生了地址依赖关系。如果把两条指令重新排序,先执行load再执行store,显然得到的结果不是预期的。

2. 设备型内存:

设备型内存主要指的是memory-mapped的外设空间。对设备型内存的访问,其要求比普通型严格得多。ARM架构中禁止对设备型内存进行任何推测性读取(speculative read)操作,也不建议在标记为设备型的存储空间执行程序,因为其结果是不可预测的。

根据访存需遵守的规则,设备型内存分为以下四类:

  • Device-nGnRnE

  • Device-nGnRE

  • Device-nGRE

  • Device-GRE

image.png

这里不再啰嗦,想具体了解的话去翻前面的两篇文章。

第三个概念,内存属性。系统的整个存储空间往往被分成分为几个区域,每个区域可以设置不同的属性,例如允许访问的特权等级属性,存储类型属性,可缓存(cacheability)属性,可共享(shareability)属性,缓存策略等等。

image.png

ARM中引入了一个“共享域(shareable domain)”的概念,主要用于指定屏障指令和缓存维护指令的作用范围,为的是减小带宽等开销。
image.png

  • Non-shareable:不需要与其它内核、处理器或设备同步的访问;该域通常不用于SMP系统。

  • Inner Shareable:由几个代理共享的域,但不一定是系统中的所有代理;一个系统可以有几个内部共享域;影响一个内部可共享域的操作不会影响系统中的其他内部可共享域。

  • Outer Shareable:可以由一个或多个内部可共享域组成;影响外部可共享域的操作也会影响其中的所有内部可共享域。

  • Full system:整个系统上的操作会影响系统中的所有观察者。

单看解释比较晦涩,以上图为例,最左侧的处理器核不需要与其它三个处理器核共享数据,因此划分到不可共享域;其它三个处理器共享数据且需要同步,构成内部可共享域;这三个处理器核还要与GPU共享数据,构成一个外部可共享域。

我们来看一下Armv8-A中关于内存页表的描述,先看最低的3-bit,AttrIndx[2:0]。
image.png
内存属性没有直接放在页表项中,而是放在了寄存器MAIR_ELx中。其中的MAIR是Memory Attribute Indirection Register的缩写,EL是Exception Level,x是异常等级编号,从0-3。MAIR_ELx.Attrn定义了内存类型。MAIR_ELx的attr共分成8段,可以通过上图中的AttrIndx[2:0]来索引。
image.png
内存页表描述中的SH[1:0]即为共享域的定义,编码如下图。
image.png
终于铺垫完了,可以开始今天的正题了。在某些时候,指令重新排序会导致程序运行与预期不符,因此需要在程序中显式的规定一些指令的执行顺序。这时就用到了屏障指令。

Armv8-A提供了几种内存屏障指令:

ISB(Instruction Synchronization Barrier)指令可以确保程序中在ISB指令后的所有指令,只有在ISB指令执行完才会从缓存或者内存中取出。ISB用于确保任何先前执行的上下文更改操作(如写入系统控制寄存器)在ISB指令完成前已完成,且对ISB指令之后的指令可见。其架构文档中给出一个需要插入ISB指令的例子:处理器执行缓存和TLB维护指令;执行ISB指令;等待前面的指令完成后更改系统寄存器。

DMB(Data Memory Barrier)指令是一种内存屏障指令,它确保了屏障之前的内存访问与之后的内存访问的相对执行顺序。DMB指令不能确保内存访问的完成顺序。文档中给出了一个例子及解释,注意其中ADD指令,因为这不是内存访问指令,因此可以重排序到DMB之前执行。

image.png

DSB(Data Synchronization Barrier)指令强制执行与DMB相同的顺序,但它也会阻止任何其他指令的执行,而不仅仅是load和store,直到同步完成。所以,DSB要比DMB严格。在下面的例子中,ADD指令就不可以重排序到DSB指令之前执行。

image.png

DMB和DSB指令需要带一个参数,该参数指定了屏障指令的访问类型和应用的共享域范围,如下表。参数的前缀字母表示DSB或DMB的作用域,比如OSH表示outer shareable,ISH表示inner shareable,后缀的两个字母表示DSB或DMB的访问方向,比如LD表示load,其含义是屏障指令后面的load和store指令必须要等前面的load指令完成;ST表示store,其含义是屏障指令后面的store指令必须要等前面的store指令执行完,但是屏障指令后面的load指令不受约束;如果没有后缀,则表示屏障指令之后的任何load/store指令必须要等屏障指令之前的load/store指令完成。

image.png
表中的Load - Load/Store表示在屏障之前完成所有load,但不需要完成store,程序顺序中出现在屏障之后的load和store都必须等待屏障完成。;Store – Store表示屏障仅影响store访问,load仍然可以绕过屏障自由重新排序;Any – Any表示load和store必须在屏障之前完成,程序顺序中出现在屏障之后的load和store都必须等待屏障完成。

除了上述的ISB,DMB和DSB,还有几个不常见的屏障指令。

SB(Speculation Barrier)指令阻止指令的推测性执行,直到屏障完成后。在SB完成之前,程序顺序中出现的任何指令的推测性执行都晚于SB指令。

CSDB(Consumption of Speculative Data Barrier)指令用于控制推测执行和数据值预测,包括:任何指令的数据值预测;任何指令的PSTATE.{N,Z,C,V}预测;SVE预测。

SSBB(Speculative Store Bypass Barrier)指令在某些情况下,可以防止推测性加载绕过早期存储到同一虚拟地址。

PSB CSYNC(Profiling Synchronization Barrier)它确保当前PE的所有现有分析数据(profiling data)都已格式化,并且分析缓冲区地址已转换,以便启动对分析缓冲区的所有写入操作。

PSSBB (Physical Speculative Store Bypass Barrier)指令在某些情况下,可以防止推测性加载绕过早期存储,到达相同的物理地址。

TSB CSYNC (Trace Synchronization Barrier)指令保留了对系统寄存器的内存访问的相对顺序。

这几条屏障指令在Armv8-A架构文档中的描述不多,感兴趣的可以再去查查其它文档,看看是否有相关的描述。

Armv8-A还提供了一组Load-Acquire(LDAR)和Store-Release(STLR)指令,可以用于支持释放一致性(Release Consistency)模型。释放一致性模型是对弱序一致性模型的改进,它把同步操作进一步分成获取(acquire)操作和释放(release)操作。

image.png

根据上述,可以看出来LDAR和STLR是单向的屏障。LDAR指令仅保证之后的任何内存访问指令在LDAR后可见,不限制LDAR之前的内存访问指令向后重排序。STLR仅保证在所有早期的内存访问在STLR之前都是可见的,不限制后面的内存访问指令向前重排序。所以LDAR和STLR的限制比DMB和DSB要宽松。这一段的英文描述比较绕,转成汉语更不好表达,有点像绕口令了,还是参考下图吧。

image.png

上图中的LDAR和STLR组成了一个临界区。灰色区域的load/store指令不能向前或向后重排序,但是LDAR上面的load/store指令可以先后重排序,STLR下面的load/store指令可以向前重排序。

总结一下,由于多种原因,高性能处理器支持指令重新排序。如果内存访问指令间有数据或者地址依赖关系,指令重排序不会打乱这种关系,如果没有依赖关系,内存访问指令就可能会被重新排序,因此内存访问顺序与程序预期顺序可能不符。对于并行程序而言,可能就会导致错误。通过内存屏障指令,可以强制内存访问指令的执行顺序,虽然会对性能造成影响,但是可以保证某些内存访问顺序符合程序预期。


ARM系列 -- 内存标签扩展

内存标签扩展(Memory Tagging Extension,MTE)是Armv8.5-A中添加的新功能。

目前对计算机系统的攻击,大部分是对内存的攻击。内存安全问题又可以分为两类:空间安全(spatial safety)和时间安全(temporal safety)。

当试图访问安全区域以外的数据,即违反了空间安全性,比如缓冲区溢出(Buffer Overflow)攻击。缓冲区溢出是指在存在缓存溢出安全漏洞的计算机中,攻击者可以用超出常规长度的字符数来填满一个域,通常是内存区地址。缓存区溢出存在于各种电脑程序中,特别是广泛存在于用C、C++等这些本身不提供内存越界检测功能的语言编写的程序中,例如Debian中就存在5亿行C/C++代码。

当试图访问已超出正常时间范围的内存资源时,即违反了时间安全性,比如释放后再使用(Use After Free)攻击。顾名思义,就是当一个内存块被释放之后再次被访问。攻击程序可以先申请一块内存,然后释放内存,但是不清空该内存指针,等待一段时间后再次通过指针对内存进行访问。如果恰好在访问操作之前这块内存被分配给了其它的程序,那么攻击程序可以通过内存对此程序发起攻击。

内存攻击不只以上提到的两种。我不是安全专家,就不在这里啰嗦了。

可以通过软件机制来检测内存访问违例,但代价是运行效率低。

MTE提供一种硬件机制来检测这两类内存违例的情况,这种机制类似于锁和钥匙的关系。在分配内存的时候加上一把锁,访问的时候需要提供一把钥匙,如果钥匙和锁不匹配,即阻止访问,并报告错误。具体来说,通过向物理内存的每个16字节添加四-bit元数据(Metadata)来做内存标记;指针和虚拟地址被修改为包含钥匙。16字节被定义为一个“标签颗粒(Tag Granule)”。为了在不需要较大指针的情况下实现钥匙,Armv8-A架构中使用“顶部字节忽略(Top Byte Ignore,TBI)”功能。启用TBI后,在做地址转换时,虚拟地址的顶部字节会被忽略。这样就可以使用顶部字节来存储元数据,实现内存标签的钥匙。当前,仅使用顶部字节的4-bit。

来看一个例子,下图上半部分,显示的是缓冲区溢出情况。通过new()函数分配一个16-byte的内存给ptr指针。当程序通过ptr指针来访问随后的地址空间,会产生内存违例,这是因为后面的内存的锁与ptr的钥匙不相符。下图下半部分,显示的是UAF情况。当内存被再次分配时,产生了一个新锁,如果攻击程序用旧的指针去访问,钥匙和锁不相符。

image.png
MTE支持标签的随机产生,或基于种子的伪随机产生。如果一个程序的执行次数足够,则至少其中一个程序检测到违规的概率趋于100%。

或许你已经注意到了一个细节,那就是4-bit的元数据最多只能标记16种不同的锁。也就是说还有1/16的可能性,错误的钥匙适配到了锁。为了避免这类错误,需要软件通过其它方式增加标签的不同可能性。

MTE增加了一种新的内存类型,普通标签内存(Normal Tagged Memory)。

地址中的标签和内存中的标签之间的不匹配可以配置为导致同步异常(synchronous exception)或异步报告(asynchronous report)。

同步异常是精确的,因为可以精确地确定哪个加载或存储指令导致了标记不匹配。相反,异步报告是不精确的,因为它只能将不匹配隔离到特定的执行线程。

MTE为Armv8-A体系结构添加了三类指令:

1.适用于堆栈(stack)和堆(heap)标记的标签操作指令

  • IRG(insert random tag),此指令在第一个源寄存器的地址中插入一个随机逻辑地址标记,并将结果写入目标寄存器。IRG在硬件层面支持为一个寄存器中的地址插入随机tag,这个tag随后可以为其它指令使用。

  • GMI(tag mask insert),将第一源寄存器中的标记插入第二源寄存器中指定的排除集,将新的排除集写入目标寄存器。此指令用于操作与IRG指令一起使用的排除标记集,适用于软件为特殊目的使用特定标记值,同时为正常分配保留随机标记行为的情况。

  • LDG(load allocation tag),此指令从内存地址加载分配标记(allocation tag),从分配标记生成逻辑地址标记,并将其合并到目标寄存器中。

  • STG(store allocation tag),此指令存储分配标记到内存

  • STZG(store allocation tag, zeroing),此指令将分配标记存储到内存,将相关数据位置归零

  • ST2G,此指令将分配标记存储到内存的两个标记颗粒

  • STZ2G,此指令将分配标记存储到内存的两个标记颗粒,将相关数据位置归零

  • STGP(store allocation tag and pair of registers),此指令从两个寄存器向内存存储一个分配标记和两个64位双字

2. 用于指针运算和堆栈标记的指令

  • ADDG(add with tag),此指令将由标记颗粒缩放的立即数加到源寄存器中的地址,使用立即值修改地址的逻辑地址标记,并将结果写入目标寄存器。

  • SUBG(subtract with tag),此指令从源寄存器中的地址减去由标记颗粒缩放的立即数,使用立即数修改地址的逻辑地址标记,并将结果写入目标寄存器。

  • SUBP(subtract pointer),此指令从第一源寄存器中保存的56位地址减去第二源寄存器中保留的56位的地址,符号扩展结果到64位,并将结果写入目标寄存器。

3. 用于系统的指令

  • LDGM(load tag multiple),此指令读取N个分配标记的自然对齐块

  • STGM(store tag multiple),此指令存储N个分配标记的自然对齐块

  • STZGM(store tag and zero multiple),此指令存储N个分配标记的自然对齐块,并将零存储到相关数据位置

为了在后续产品种加入MTE,ARM将开发新版本的CHI协议,以支持MTE的传输和一致性要求。

image.png

为了支持MTE,还需要对软件进行部署。ARM正在进行相关的工作。

MTE无需更改程序源代码。然而,MTE必然会导致开销,因为标签必须从内存系统中提取并存储到内存系统中。这种开销与内存分配的大小和生命周期以及标记和数据是一起操作还是单独操作有关。开销可以通过以下方式最小化:

  • 同时写入标签和初始化内存

  • 避免过度分配从未写入数据的地址空间

  • 避免过度的释放和重新分配

  • 避免在堆栈上分配大块固定大小内存

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多