0%

ARM笔记之立即数

一、 立即数的由来及计算方式

在ARM中有立即数的概念,但是这个立即数和高级编程语言中的字面值常量还不是一回事,我刚学ARM汇编的时候经常被这个立即数搞蒙,问题不解决它就一直是问题,于是决定彻底搞明白它。

要理解为什么会出现立即数这种奇怪的表示方法,就要先了解一下ARM汇编指令是如何表示的,ARM汇编指令也要存储在文件中,每条ARM汇编指令占用32 bit空间,其中这32 bit 又分为几部分,每个部分有不同的作用:

image-20210724050932009

这里不详细的解释每一部分的作用,只需要知道最低的12 bit 名为shifter_operand这部分表示的是源操作数,而立即数就是放在这里表示的,是的,很悲剧,只有12 bit,但是ARM中有2^32个数要表示,12 bit 至多能够表示2^12个数,离2^32差太远。巧妇难为无米之炊,在我等菜菜眼里看来这是一个很蛋疼的问题,但是在计算机届的前辈大佬们眼中这都不是事,办法总比困难多,大佬们想了一种特殊的编码方式来尽可能的扩大这12 bit 能够表示的范围,于是用来表示立即数的这12 bit 又被划分为了两部分:

img

immed_8:这部分比较容易理解,使用8 bit 来表示一个数,能够表示的范围是[0, 255],它表示的数就是肉眼看到的数,没啥猫腻。

rotate_imm:用来表示要对前面的immed_8这个数进行多少次循环右移,但是这个是4位数,能够表示的最大的数是15,而这个循环右移是要覆盖住32 bit的空间的,所以就把rotate_imm乘以2,即对immed_8循环右移rotate_imm * 2次。

即最终表示的数 = ROR(immed_8, rotate_imm * 2)

二、 立即数计算的例子

举一个例子,这条指令:

img

源代码里的数值为0x7f000000,左边中括号里的汇编指令是0xe3a0047f,它的低12位47f就是0x7f000000的立即数表示形式。

47f又分为两个部分,低八位的7f是字面值,高四位的4表示4 * 2等于8表示对7f进行8次循环右移,打开Windows的计算器,将范围设置为DWORD,位移位设置为循环位移,然后输入7f,手动右位移8次,看下循环位移的结果,正是0x7F000000:

img

三、 穷举所有合法的立即数

为了更好的理解ARM中的立即数,我根据立即数的机制,对ARM中立即数机制能够表示的所有数值打了个表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package cc11001100;

import org.apache.commons.io.FileUtils;

import java.io.File;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
* @author CC11001100
*/
public class Test {

/**
* 对n循环右移m位
*
* @param n
* @param m
* @return
*/
private static int ror(int n, int m) {
return (n >> m) | (n << (32 - m));
}

/**
* 测试ARM中的立即数机制能够表示的所有数值
*
* @throws IOException
*/
private static void start() throws IOException {

int count = 0 ;
Set<Integer> set = new HashSet<>();
for (int i = 0; i < 256; i++) {
for (int j = 0; j < 16; j++) {
int n = ror(i, j * 2);
set.add(n);
count++;
}
}
System.out.println("count = " + count);
System.out.println("count(distinct) = " + set.size());

final List<Integer> numList = set.stream().sorted().collect(Collectors.toList());
String lines = numList.stream().map(n -> {
String oct = String.format("%11s", n);
String binary = "0B" + String.format("%32s", Integer.toBinaryString(n)).replace(" ", "0");
String hex = "0x" + String.format("%8s", Integer.toHexString(n)).toUpperCase().replace(" ", "0");
return oct + " " + hex + " " + binary;
}).collect(Collectors.joining("\n"));
FileUtils.writeStringToFile(new File("data/arm-num"), lines, "UTF-8");
}

public static void main(String[] args) throws IOException {

start();

}

}

先看下控制台打印的结果:

img

可以看到,这种机制能够表示的所有数字只有256 * 16=4096个,但是这些数是有重复的,也就是一个数字有好几种对应的立即数表示方式,去重之后能够表示的数字就只有3073个了。然后打开arm-num这个文件看下保存的数字都是啥样子的,这里就不把全量数据放上来了,只放几个区间的例子,需要全量数据可以自己跑下程序得到:

img

img

img

辅助这个表就能很容易理解立即数了,说白了其实就是稀疏分布,数值并不连续,但是也勉强算是把2^32范围覆盖了,至此我们应该能够比较深刻的理解这个立即数机制了,它就是把原来能够表示2^12的空间的值做一个稀疏分布,使其能够稀疏分布在2^32上,这样就有一种能够表示2^32的范围的错觉了,你需要2^32空间上的一个数,也许不能够给你一个正好等于它的数,但是能给你一个近似的数(这个数能够用立即数表示),然后通过这个近似的数加减运算得到你真正想要的数。

四、判断一个数是否能够用立即数的形式表示

网上有很多总结的技巧,个人感觉这些技巧其实并没有什么用还贼拉费脑子,我的方式比较简单粗暴省头发,直接去打好的表里搜一下…

五、ARM中的数值表示

上面搞清楚了立即数的来龙去脉,但是实际使用中又该如何使用呢,总的来说:

小于256的数

直接使用8位表示即可,不需要考虑立即数机制。

2^8到2^32的数

判断是否可以使用立即数表示,可以去表里搜索,但是因为过于稀疏,可能在实际问题中作用不大,在实际问题中,可以对这个范围内的数直接简单粗暴的使用LDR伪指令加载到寄存器,比如指令:LDR R0, =0x7F000001,具体实现的工作编译器会去做,但是编译器是如何做的呢,来编写一段简单的指令,用LDR伪指令加载一个无法用立即数机制表示的数:

img

然后看下编译为机器码之后的汇编指令:

img

这条汇编指令表示用LDR指令从内存地址0x00008004加载一个32位的数,后面还很贴心的有个注释“= #0x7f000001”,打开内存视图,去看下0x00008004开始的这四个字节存的是啥:

img

正是0X7F000001啊,只不过是小端存储的。

事实上编译器的底层实现非常的智能,做了很多的优化工作,它会根据数值的不同优化为不同的指令,上面看的只是其中一种情况。

更大的数

先作为数据写在其它的段或是什么地方,然后再使用的时候再去对应地址读取使用,与LDR的方式类似。

文章的最后顺带吐槽一下,这个立即数真的很不立即!