frankfan的胡思乱想

学海无涯,回头是岸

ARM指令集浅谈

ARM Thumb 编码 立即数 寄存器

🍺闲谈🍺

armv7支持2种指令集,ARM指令集与Thumb指令集。

宏观上我们可以将CPU指令集划分2个阵营,CISC(复杂指令集)RISC(精简指令集),其实这里的「复杂」「精简」有不同的观察维度,或许从「CPU默默承受更多」这个角度而言更容易理解。

在复杂指令集中,对程序员(编译器)而言只需一条简单的指令add exa,dword ptr [edx] 便完成了一个加法,并且参与这个加法的两个数一个位于寄存器中,而一个位于内存中,微观而言一个简单的加法参与方与执行的步骤并不简单,但体现在机器码(汇编指令)上却只需「简单」的一条,归根结底,很多事情CPU都默默承受了,CPU内部将这条指令拆分成了N条更细粒度的指令,这些指令就像那些包裹里的东西,实际上这些「细小的指令」被称为微码,显然,微码属于硬件的一部分,是CPU提供的微码让我们能够以更简单的指令完成更多的事情。

显然,对于CPU而言微码的存在使得CPU需要更多的晶体管,更复杂的设计,好处与坏处都那么显然:

  • 在那个编译器水平低下的年代,能够生成更少的指令而做更多的活
  • 复杂的设计和模块化笨重的指令(甚至微码也存在bug)

ARM指令集与Thumb指令集是精简指令集,此时CPU不再提供微码的机制,每一个步骤都需要程序员(编译器)提供准确的指令,CPU不再能自作主张的帮你执行那些你没有明确给定的指令,此时的CPU终于卸下了沉重的负担。当然,这一切能行也要得益于技术的发展,编译器能够准确而优美的生成那些汇编指令

当然,时代发展到现在,精简指令集与复杂指令集并没有谁彻底取代谁,他们都有各自的应用场景,实际上双方也从技术上汲取彼此的经验(本质而言 微码就是一种精简指令集的具体实践体现)

虽然各方面双方取长补短,但精简指令集与复杂指令集之间一个最为典型的区别在于:

精简指令集为定长指令,而复杂指令集为变长指令

这从某种程度而言可以认为是精简指令集对复杂指令集的较大优势所在。

复杂指令集是变长指令,AB两条指令长度可能不同且不确定,因此CPU在解码完成A指令前是没办法确定下一条指令(B)的地址的,这就导致在一个CPU周期内解码指令数量存在严重的上限提升,而精简指令集是定长指令,因此每条指令的地址在「那一刻」就已被确定,可以由N个解码器同时对N条指令进行解码,当然,这只是理论,现实是更多的解码器意味着更多的晶体管更高的能耗和更复杂的设计,此外需要更宽的流水线,并且指令之间可能存在数据依赖,这就需要使用其他手段(如乱序执行等)去解决这些同时解码多条指令所产生的问题,但即使如此,超宽流水线带来的性能提升依旧是定长精简指令集的杀手锏。


指令格式📚

Thumb[2]指令集有16-bits32-bits两种类型,以2字节对齐,这两种类型可以混合自由搭配使用(所以从某层面而已这似乎是一种变长指令集..额)16bit指令所占内存更小(这在很多嵌入式硬件中很关键),指令代码密度更高(没有多余的bit被浪费,每个bit都有其意义),但并不适合复杂指令,毕竟每条指令容量有限,32bit指令所占内存更大,但所能执行的指令更复杂,甚至相当于多条16bit指令;

arm指令集总是32-bits的,以4字节对齐,相较于Thumb肯定是更耗能的(当然也更强大👍)。

image.png

图1.arm指令基础格式
  • cond 部分表示条件执行,每条指令都可以带上执行条件,功能实现更加灵活(配合CPSR状态寄存器)
  • op1 和 op 用于给不同的指令进行分类,也可以看成是作为区分指令编码的一部分

op1和op联合起来,用来指示当前这条指令是「干嘛的」(什么类型)

op1    op    指令类型
00x    -     数据处理以及杂项指令
010    -     load/store word类型 或者 unsigned byte
011    0     同上
011    1     媒体接口指令
10x    -     跳转指令和块数据操作指令,块数据操作指令指 STMDA 这类,连续内存操作。
11x    -     协处理器指令和 svc 指令,包括高级的 SIMD 和浮点指令。

本质上就是想办法使用 32 bits 编码实现 一条指令的唯一识别+指令n个参数的表达+指令条件

以上就是基本的指令结构,其余24位根据具体的指令类型不同而不同。

一些常见编码

立即数

mov r0 #0xffffffff

将立即数放入r0寄存器,这个立即数4个字节,一条arm指令一共才4个字节,直接放是不行的🚫。

这就涉及到「编码」,编码本质是一种数据压缩方式,被「挤压」出去的那些信息被保存在「上下文编码规则中」,也就是说,能被压缩的前提是一部分数据能够以固定规则产生,而无需其他多余数据的参与。回到这个话题,arm使用12bit来表示立即数,但这并不意味着其所能表示的最大立即数是 $2^{12}-1$ ,因为这里使用的立即数编码规则为:

指令中的 12 位来表示立即数,前八位表示立即数的"基数",而后 4 位的值乘上 2 表示循环右移值

  • 🌰 比如 000011110010,前八位的值是 00001111(0x0f),而后四位的值为 0x2,所以这个立即数的值为 0x0f 循环右移 2*2 = 4 位,结果为 11110000(0xf00000000)

显然,并不是所有的立即数都能用这种移位的方式构造出来,那些不能被这样构造出来的立即数被称为「无效立即数

那么0xffffffff显然无论如何都不能被这样的方式构造出来,属于无效立即数,那么,实际的编译器会如何生成这条指令呢?

🎉 mvn r0, #0🎉

编译器可谓是「绞尽脑汁」完成使命,mvn表示将立即数取反,再mov.

所以,当我们说「不可能」的时候还需要考虑到编译器的骚操作👍

不过这种骚操作可遇不可求,对于无效立即数ARM的常规操作手法是什么呢❓

  • 使用特殊指令,这类指令支持16bit立即数,再配合这2个指令movw(把 16 位立即数放到寄存器的低16位,高16位清0),MOVT (把 16 位立即数放到寄存器的高16位,低 16位不影响),这样经过2条指令就能把任意一个32位的立即数放入寄存器中

  • 组合有效立即数,比如:0x12000034,可以通过将 0x12000000 和 0x34 分别放在寄存器中,然后相加,就得到这个立即数

    👁此外,还有一种低效的方式,就是直接从内存中加载这个立即数。(使用标签,在标签处放置立即数,然后直接ldr加载到寄存器即可),这种方式十分低效,已逐渐不再使用。

需要说明的是⚠️,ARM中有一个指令级别的伪指令LDR,编译器会将这个伪指令编译成对应的「优化指令」,使程序员直接使用这个指令来放置立即数,而无需操心其是否有效。LDR Rn,=exprLDR r0,=0x12345678expr可以是任意立即数,编译器会将该伪指令最终编译成有效的表达方式


寄存器

🍭arm 规定了数据只能在寄存器中处理,而不能直接操作内存,所以在 arm 指令中存在非常多的寄存器操作, arm 有 16 个寄存器,所以需要使用 4 bits 来指定一个寄存器,所以通常一条指令中可以指定多个寄存器。比如:mov r0,r1

🍭除了普通的寄存器操作,非常常见的还有移位寄存器,通过将寄存器中的值进行位移来获取一个目标值,这和使用立即数的效果是差不多的,寄存器值占 4 bits,位移参数占 3~4 bitsadd r0, r0, r1, lsl #12)表示r0 + r1<<12 ,arm的流水线已经处理了位移操作,所以这样的操作与add r0, r0, r1所需周期无异

🍭对于一些连续内存操作,通常是指定寄存器,就会需要使用到其它的指定方式,比如 STMDA 指令,这条指令的参数可以是一个寄存器列表,依次将寄存器列表中的值保存到指定的连续地址,这时候用 4 bits 去表示每一个寄存器自然是不够的,处理方法是使用 16 个 bits,每一个 bit 代表一个寄存器,哪个寄存器需要被操作到,就将对应的 bit 置 1,这样就可以只使用 16 位来表示 16 个寄存器

通常情况下,每一条汇编指令可能会对应多种指令集

  • thumb 指令集分别存在 thumb1,thumb2,thumb3 三种指令编码
  • arm 指令集存在 arm1,arm2 两种指令编码

具体选用哪种则由编译器根据「最简」原则采用合适的编码。

posted on 2021-12-27 23:52  shadow_fan  阅读(1023)  评论(0)    收藏  举报

导航