[翻译]二进制漏洞利用(二)ARM32位汇编下的TCP Bind shell:https://bbs.pediy.com/thread-253511.htm
ARM汇编语言入门
From:ARM汇编语言入门(一):https://zhuanlan.zhihu.com/p/109057983
原文地址:https://azeria-labs.com/writing-arm-assembly-part-1/
1. ARM 汇编介绍
处理器 ARM VS Intel
ARM 与 Intel 有诸多不同,最主要的区别是指令集。Intel 是复杂指令集(CISC:Complex Instruction Set Computing)处理器,拥有功能更多更丰富的指令,允许对内存进行更复杂的操作。因此也拥有更多的指令操作,寻址模式,然而寄存器数量却比 ARM少。CISC 处理器主要应用在个人电脑,工作站,服务器当中。
ARM 是精简指令集(RISC:Reduced Instruction set Computing)处理器,拥有更简单的指令集(少于100个)和更多的通用寄存器。与 Intel 不同,ARM 指令只操作寄存器,且只能使用 Load/Stroe (取/存) 命令来 读取和写入内存。也就是说,如果增加某个地址处的32位数据的值,你起码需要三个指令(取,加,存):首先将该地址处的数据加载到寄存器(取),然后增加寄存器里的值(加),最后再将寄存器里的值存储到原来的地址处(存)。
精简指令集有优点也有缺点。优点之一是单条指令执行更快,相应地也获得了更高的处理速度(精简指令集系统通过减少单条指令的时钟周期来减少执行时间)。不利的一面是更少的指令意味着更加要求更加注重软件书写效率。
还要注意的是 ARM 有两种工作状态:
- ARM 模式。
- Thumb 模式。Thumb模式指令可以是2个字节或者4个字节(详见Part 3:ARM指令集)。
ARM 与 x86 其他区别:
- ARM 中大部分指令都可以用作条件执行。
- x86 和 x86-64 系列处理器使用 小端(little-endian)地址格式。
- ARM 架构在第三版以前是小端模式。之后变为 大-小 端(BI-endian)格式,允许大端或小端两种模式进行切换。
不仅 ARM 与 Intel 有不同,而且 ARM 各版本之间也有不同。本教程尽量保留它们之间最通用的部分以便你能理解 ARM 是怎么工作的。一旦你理解了最基本的部分,当你选择不同的 ARM 版本时也可以融会贯通。本教程所有的例子是在 32-bit ARMv6 平台(Raspberry Pi 1)创建,所有的说明都是基于此版本。
不同 ARM 版本命名:
ARM 汇编
在开始 ARM 开发之前我们需要先了解基本的汇编编程。使用一般的编程语言或者脚本语言来开发不行吗,为什么还需要 ARM 汇编?确实不行,如果我们要做逆向工程或者想了解 ARM 二进制程序流,创建自己的 ARM 壳程序( shellcode:利用程序漏洞而执行的代码 ),手工制作 ROP( Return-Oriented Programming 一种利用特殊返回指令不断返回多个前一段指令而最终拼成一段有效逻辑代码,以达到特殊攻击目的的编程技术 )工具链以及调试 ARM 程序就要了解 ARM 汇编。
你不需要了解逆向工程或应用开发方面所有的汇编语言细节,你只需要了解一个大概。基础的知识都会在本教程中讲到,如果你想要了解更多可以参考文末的附加链接。
那么究竟什么是汇编语言?汇编语言你可以看成是包裹在机器码上的的一层薄薄的语法糖指令,这些指令代表着只有机器(计算机)才能读懂的二进制码。那么为什么不直接写机器码呢?好吧,如果那样做的话你绝对会很蛋疼。所以你最好还是写汇编,人能够容易读懂的 ARM 汇编。计算机不能运行汇编代码,它只能读懂机器码。我们要使用工具来将汇编代码转换为机器码。GNU汇编器 as
为我们提供了这样的功能,可以识别 *.s 类型的源代码文件。
当你编写完扩展名 *.s 的汇编源文件后,要用 as
编译然后用 ld
链接:
$ as program.s -o program.o
$ ld program.o -o program
图示:
探秘汇编语言
现在我们从最底层的工作做起。在最底层是电路板上的电信号,电信号是切换两个不同的电平产生的,0V(off) 或者 5V(on)。因为很容易地看到电路的电平变化,所以我们可以通过可视化数字 0 和 1 的表示来匹配电压的开关模式,不仅是因为 0/1 可以代表电信号的缺失和出现,还因为 0/1 是二进制系统里数字。然后用一系列 0/1 组成机器码指令在计算机处理器中运行。
下面就是一个机器语码指令。
1110 0001 1010 0000 0010 0000 0000 0001
很好,但是我们难以记得这些 0/1 组合的代表什么意思。因此我们使用叫做助记符的东西来帮助我们记忆这些二进制组合,每个二进制机器码给定一个名字。这些助记符通常包含三段字符,但不全是。这种程序被叫做汇编语言程序,它使用一系列助记符代表计算机机器码。指令中的操作数放在助记符之后。
例如:
MOV R2, R1
现在我们知道了汇编程序是由叫做助记符的文本信息组成的,我们需要把它转换为机器码。前面提到的,GNU Binutils 项目为我们提供了叫做 as
的汇编工具。使用 as
把 ARM 汇编语言转换为 ARM 机器码的过程就叫做汇编。
综上,计算机能够理解(回应)电信号的缺失和出现,并且我们可以将这一系列电信号表示成一组 0/1 序列(bits)。我们就可以用机器码(一系列电信号)让计算机根据一种定义好的行为做出反应。因为我们难以记忆这一串 0/1 组成的指令的意义,所以提供了一种助记来代表这些指令。这组助记符是计算机的汇编语言,我们使用名为 "汇编器" 的程序将代码从助记符表示形式转换为计算机可读的计算机代码,就像编译器对高级语言代码做的一样。
扩展阅读
- Whirlwind Tour of ARM Assembly. https://www.coranac.com/tonc/text/asm.htm
- ARM assembler in Raspberry Pi. http://thinkingeek.com/arm-assembler-raspberry-pi/
- Practical Reverse Engineering: x86, x64, ARM, Windows Kernel, Reversing Tools, and Obfuscation by Bruce Dang, Alexandre Gazet, Elias Bachaalany and Sebastien Josse.
- ARM Reference Manual. http://infocenter.arm.com/help/topic/com.arm.doc.dui0068b/index.html
- Assembler User Guide. http://www.keil.com/support/man/docs/armasm/default.htm
2. ARM 的 数据类型 和 寄存器
From:ARM汇编语言入门(二):https://zhuanlan.zhihu.com/p/109066320
与高级编程语言类似,ARM 也支持操作不同的数据类型。
我们载入(load)或存储(store)的数据类型可以是有符号或无符号的字、半字或字节。
这些数据类型的扩展符是:
- -h 或 -sh 代表 半字,
- -b 和 -sb 代表 字节,
- 其中 字 没有扩展符号。
有符号和无符号的区别:
- 有符号数据类型可以存储正数和负数,因此表示的值范围更小。
- 无符号数据类型可以存储大的正数(包含0),不能存储符数因此可以表示更大的数。
载入 和 存储 指令使用数据类型:
ldr = Load Word
ldrh = Load unsigned Half Word
ldrsh = Load signed Half Word
ldrb = Load unsigned Byte
ldrsb = Load signed Bytes
str = Store Word
strh = Store unsigned Half Word
strsh = Store signed Half Word
strb = Store unsigned Byte
strsb = Store signed Byte
字节序列
查看内存中的字节有两种基本方式:小端模式(Little-Endian)和 大端模式(Big-Endian)。它们的不同之处是对象存储在内存中时每个字节的排列顺序 --- 字节顺序。在x86这种小端模式的机器上低位字节存储在低地址(更靠近零地址),而在大端模式的机器上高位字节存储在低地址。在第三版本之前ARM架构是小端模式,之后是两种模式都允许,可以进行设置来切换字节序列。例如,在 ARMv6 上,指令是固定的小端,数据访问可以是小端或大端,由程序状态寄存器 (CPSR) 的位 9(E 位)控制。
ARM寄存器
寄存器 的 数量 取决于 ARM 的版本。根据 ARM 参考手册,除了基于 ARMv6-M 和 ARMv7-M 的处理器外,共有 30 个 32 位通用寄存器。前 16 个寄存器可在用户级模式下访问,其他寄存器在特权软件执行中可用(除了 ARMv6-M 和 ARMv7-M )。在本教程中,我们将使用非特权模式下可访问的寄存器:r0-15。这 16 个寄存器可以分为两组:通用寄存器 和 特殊用途寄存器。
下表只是简要了解 ARM 寄存器与 英特尔处理器中的寄存器 的关系。
说明:
- R0-R12:可用于常见操作期间存储临时值、指针(内存位置)等等。例如R0,在算术运算期间可以称为累加器,或用于存储调用的函数时返回的结果。R7在进行系统调用时非常有用,因为它存储了系统号,R11可帮助我们跟踪作为帧指针的堆栈上的边界(稍后将介绍)。此外,ARM上的函数调用约定函数的前四个参数存储在寄存器r0-r3中。
- R14:LR(链接寄存器)。进行函数调用时,链接寄存器将更新为当前函数调用指令的下一个指令的地址,也就是函数调用返回后需要继续执行的指令。这么做是允许子函数调用完成后,在子函数中利用该寄存器保存的指令地址再返回到父函数中。
- R15:PC(程序计数器)。程序计数器自动按执行的指令大小递增。此指令大小在ARM模式下始终为4个字节,在THUMB模式下为2个字节。执行分支指令时,PC保存目标地址。在执行过程中,在ARM模式下PC将当前指令的地址加上8(两个ARM指令),在Thumb(v1)状态下则指令加上4(两个Thumb指令)。这与x86 中PC始终指向要执行的下一个指令不同。
我们看一下在调试状态下 PC 的值。我们使用以下程序将 PC 地址存储到 r0 中,并包含两个随机指令。看看会发生什么。
.section .text
.global _start
_start:mov r0, pcmov r1, #2add r2, r1, r1bkpt
使用 GDB 在 _start
处设置断点并运行:
gef> br _start
Breakpoint 1 at 0x8054
gef> run
输出:
$r0 0x00000000 $r1 0x00000000 $r2 0x00000000 $r3 0x00000000
$r4 0x00000000 $r5 0x00000000 $r6 0x00000000 $r7 0x00000000
$r8 0x00000000 $r9 0x00000000 $r10 0x00000000 $r11 0x00000000
$r12 0x00000000 $sp 0xbefff7e0 $lr 0x00000000 $pc 0x00008054
$cpsr 0x00000010
0x8054 <_start> mov r0, pc <- $pc
0x8058 <_start+4> mov r0, #2
0x805c <_start+8> add r1, r0, r0
0x8060 <_start+12> bkpt 0x0000
0x8064 andeq r1, r0, r1, asr #10
0x8068 cmnvs r5, r0, lsl #2
0x806c tsteq r0, r2, ror #18
0x8070 andeq r0, r0, r11
0x8074 tsteq r8, r6, lsl #6
我们可以看到 PC 持有将要执行的下一个指令(mov r0, pc
) 的地址(0x8054)。现在,让我们执行这条指令,之后 R0 应该持有 PC(0x8054) 的地址,对吗?
$r0 0x0000805c $r1 0x00000000 $r2 0x00000000 $r3 0x00000000
$r4 0x00000000 $r5 0x00000000 $r6 0x00000000 $r7 0x00000000
$r8 0x00000000 $r9 0x00000000 $r10 0x00000000 $r11 0x00000000
$r12 0x00000000 $sp 0xbefff7e0 $lr 0x00000000 $pc 0x00008058
$cpsr 0x00000010
0x8058 <_start+4> mov r0, #2 <- $pc
0x805c <_start+8> add r1, r0, r0
0x8060 <_start+12> bkpt 0x0000
0x8064 andeq r1, r0, r1, asr #10
0x8068 cmnvs r5, r0, lsl #2
0x806c tsteq r0, r2, ror #18
0x8070 andeq r0, r0, r11
0x8074 tsteq r8, r6, lsl #6
0x8078 adfcssp f0, f0, #4.0
对吗?错!看一下 R0 中的地址。虽然我们期望R0包含以前读取的PC值(0x8054),但它保留的值比我们之前读取的 PC 早两个指令(0x805c)。从这个示例中可以看到,当我们直接读取PC时,它遵循PC指向下一个指令的定义;但在调试时,PC 会指向当前 PC 值之后的两个指令(0x8054 + 8 = 0x805C)。这是因为较旧的 ARM 处理器始终取当前执行的指令之后的两个指令。ARM 保留此定义的原因是为了确保与早期处理器兼容。
状态寄存器
当你用 gdb 调试 ARM 程序时,你会看到一些状态标志:
寄存器 $cpsr
显示当前程序状态寄存器的值,在它下面你可以看到工作状态标志,用户模式,中断标志,溢出标志,进位标志,零标志位,符号标志。这些标志代表了CPSR寄存器中特定的位,并根据CPSR的值进行设置,如果标志位有效则会进行加粗。N、Z、C 和 V 位与x86上的EFLAG寄存器中的SF、ZF、CF和OF位相同。这些位用于支持条件分支中的条件执行,并在汇编层面支持循环语句。我们将在第6部分:条件执行和分支中进行介绍。
上图显示了 32 位寄存器(CPSR)的结构,左侧是高字节位,右侧是低字节位。每个单元(GE和M部分以及空白单元除外)的大小均为一个 bit 位。这些位定义了程序当前状态的各种属性。
假设我们可以使用CMP
指令比较 1 和 2,返回结果应该为负数(1 - 2 = -1
)。当比较两个相等的数则会设置 Z(zero)标志位(例如比较 2 和 2, 2 - 2 = 0
)。记住,CMP 指令中使用的寄存器不会被修改,只有 CPSR 会根据这些寄存器相互比较的结果进行修改。
这是 GDB(安装了GEF)中的模样:在此示例中,我们比较寄存器 r1 和 r0,其中 r1 = 4 和 r0 = 2。这是执行 cmp r1,r0 操作后标志的外观:
之所以设置 Carry 标志,是因为我们使用 cmp r1, r0
将 4 与 2(4 - 2)进行比较。相反,如果我们使用 cmp r0 r1、r1 将较小的数字(2)与较大的数字(4)进行比较,则设置负标志(N)。
CPSR 包含以下状态标志:
- N – 当计算结果为负时被设置.
- Z – 当计算结果为零时被设置.
- C – 当计算结果有进位时被设置.
- V – 当计算结果有溢出时被设置.
C:其设置分一下几种情况:
- 加法运算(包括比较指令cmn):当运算结果产生了进位时(无符号数溢出),C=1,否则C=0.
- 减法运算(包括比较指令cmp):当运算时发生了借位(无符号数下益出),C=0,否则C=1.
- 对于包含移位操作的非加/减运算指令:C为移位操作中最后移出位的值.
- 对于其他非加减运算指令:C的值通常保持不变.
V:如果加、减或比较的结果大于或等于2^31 或小于-2^31,则会发生溢出。
3. ARM 指令集
From:ARM 汇编语言入门(三):https://zhuanlan.zhihu.com/p/109537645
ARM模式 和 Thumb模式
ARM 处理器主要有两种工作模式(先不算 Jazelle)
- ARM 状态 模式
- Thumb 状态 模式
这些状态模式与权限级别无关,它们主要区别是指令集,
- 在 ARM 模式下指令集始终是 32-bit,
- 但是在 Thumb模式 下可以是 16-bit 或者 32-bit。
学会怎么使用 Thumb模式 对于 ARM 开发很重要。编写 ARM 壳代码时,我们需要避免 NULL字节,使用16位Thumb指令而不是32位ARM指令可以降低这种风险。ARM各版本的调用规范容易让人混淆,不是所有的ARM版本都支持相同的Thumb指令集。后来,ARM 引入了增强的 Thumb 指令集(伪名称:Thumbv2),它允许 32 位 Thumb 指令甚至允许条件执行,而这在之前的版本中就不行。为了在 Thumb 中支持条件执行,引入了“it
”指令。但是,此指令随后在更高版本中被删除,并与更简单的东西进行了替换。我不知道所有不同 ARM/Thumb 指令集的所有不同变体,实话说,我不关心。你也最好也别关心。您只需要知道的是你的目标设备的 ARM 版本及其特定的 Thumb 支持,然后再调整代码。ARM 信息中可以帮助您确定ARM 版本的细节(http://infocenter.arm.com/help/index.jsp)。
- Thumb-1(16 位指令):在ARMv6和更早的体系结构中使用。
- Thumb-2(16 位和 32 位指令):在Thumb-1基础上添加更多指令并允许它们为 16 位或 32 位宽(ARMv6T2、ARMv7)。
- ThumbEE:更改和添加了一些支持动态生成代码的功能(在执行之前或执行期间在设备上编译代码)。
ARM模式 和 Thumb模式的态区别:
- 条件执行:在ARM模式下所有的指令都支持条件执行。一些版本的ARM处理器可以通过
it
指令在Thumb工作模式下支持条件执行。 - ARM和Thumb模式下的32-bit指令:在Thumb模式下的32-bit指令有
.w
后缀。 - 桶型位移器(barrel shifter)是ARM模式下的另一个特点。它可以将多条指令缩减为一条。例如,你可以通过向左位移1位的指令后缀将乘法运算直接包含在一条
MOV
指令中(将一个寄存器的值乘以2,再将结果MOV
到另一个寄存器):MOV R1, R0, LSL#1 ;R1 = R0 * 2
,而不需要使用专门的乘法指令来运算。
要切换处理器在其中执行的状态,必须满足以下两个条件之一:- 我们可以使用分支指令 BX(分支和切换状态)或 BLX(分支、链接和切换状态),并将目标寄存器的最小有效位设置为 1。可以通过偏移量加1来实现,例如0x5530+1。您可能会认为这将导致对齐问题,因为指令是 2 或 4 字节对齐的。这不是问题,因为处理器将忽略最低有效位。详见Part 6:条件执行和分支。
- 如果当前程序状态寄存器的T位被置位,就说明工作在Thumb模式下。
ARM 指令简介
本节简单介绍 ARM 指令集以及基本用法。了解汇编语言中的最小部分如何操作,它们之间如何衔接,它们之间能组合成什么样的功能。
ARM 指令后面通常跟着两个操作数,像下面这样的形式:
MNEMONIC{S}{condition} {Rd}, Operand1, Operand2
由于 ARM 指令集的灵活性,并不是所有的指令都用到这些字段。这些字段的解释如下:
MNEMONIC - 操作指令(机器码对应的助记符)。
{S} - 可选后缀. 如果指定了该后缀,那么条件标志将根据操作结果进行更新。
{condition} - 执行指令所需满足的条件。
{Rd} - 目标寄存器,存储操作结果。
Operand1 - 第一操作数(寄存器或者立即数)
Operand2 - 第二操作数. 立即数或者带有位移操作后缀(可选)的寄存器。
MNEMONIC
, S,
Rd
和 Operand1
字段比较明了,condition
和 Operand2
字段需要再解释一下。condition
字段与 CPSR
寄存器的值有关,准确的说是和 CPSR
某些位有关。Operand2
也叫可变操作数,因为它可以有多种形式 --- 立即数、寄存器、带有位移操作的寄存器。例如 Operand2
可以有以下多种形式:
#123 - 立即数。
Rx - 寄存器x (如 R1, R2, R3 ...)。
Rx, ASR n - 寄存器x,算术右移n位 (1 = n = 32)。
Rx, LSL n - 寄存器x,逻辑左移n位 (0 = n = 31)。
Rx, LSR n - 寄存器x,逻辑右移n位 (1 = n = 32)。
Rx, ROR n - 寄存器x,循环右移n位 (1 = n = 31)。
Rx, RRX - 寄存器x,扩展的循环位移,右移1位。
让我们以一个简单的例子看一下这些指令的不同:
ADD R0, R1, R2 - 将寄存器R1内的值与寄存器R2内的值相加,结果存储到R0。
ADD R0, R1, #2 - 将寄存器R1内的值加上立即数2,结果存储到R0。
MOVLE R0, #5 - 仅当满足条件LE(小于或等于)时,才将立即数5移动到R0(编译器会把它看作MOVLE R0, R0, #5)。
MOV R0, R1, LSL #1 - 将寄存器R1的内容向左移动一位然后移动到R0(Rd)。因此,如果R1值是2,它将向左移动一位,并变为4。然后将4移动到R0。
来快速总结一下,看一下后续示例中将涉及的一些常用指令:
4. 内存指令:加载 和 存储
From:ARM汇编语言入门(四):https://zhuanlan.zhihu.com/p/109540164
ARM 使用 加载(Load)/ 存储(Stroe)指令来读写内存,这意味着你只能使用 LDR 和 STR 指令访问内存。在 ARM 上数据必须从内存中加载到寄存器之后才能进行其他操作,而在 x86 上大部分指令都可以直接访问内存中的数据。如前所述,在 ARM 上增加内存里的一个 32-bit 数据值,需要三个指令( load,increment,store )。为了解释 ARM 上的 Load 和 Store 操作的基本原理,我们从一个基本示例开始,然后再使用三个基本偏移形式,每个偏移形式具有三种不同的寻址模式。为了简单化,每个示例,我们将在同一段汇编代码中使用不同 LDR/STR 偏移形式的。遵循这本段教程的最佳方法是在你的测试环境中用调试器(GDB)运行代码示例。
偏移形式:立即数作为偏移量
- 寻址模式:立即寻址
- 寻址模式:前变址寻址
- 寻址模式:后变址寻址
偏移形式:寄存器作为偏移量
- 寻址模式:立即寻址
- 寻址模式:前变址寻址
- 寻址模式:后变址寻址
偏移形式:缩放寄存器作为偏移量
- 寻址模式:立即寻址
- 寻址模式:前变址寻址
- 寻址模式:后变址寻址
第一个例子:
LDR 用于将内存中的值加载到寄存器中,STR 用于将寄存器内的值存储到内存地址。
解释:
LDR R2, [R0] @ [R0] - R0中保存的值是源地址。
STR R2, [R1] @ [R1] - R1中保存的值是目标地址。
LDR : 把 R0 内保存的值作为地址值,将该地址处的值加载到寄存器 R2 中。
STR : 把 R1 内保存的值作为地址值,将寄存器 R2 中的值存储到该地址处。
下面是汇编程序的样子:
.data /*.data段是动态创建的,无法预测 */
var1: .word 3 /* 内存中的变量var1=3*/
var2: .word 4 /* 内存中的变量var2=4*/
.text /* 代码段开始位置 */
.global _start
_start:ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。str r2, [r1] @ 将R2内的值(0x03)存储到R1中的地址处。 bkpt
adr_var1: .word var1 /* 变量var1的地址位置 */
adr_var2: .word var2 /* 变量var2的地址位置 */
在程序底部有我们的 文本池(在代码段用来存储常量、字符串或其他可以引用的位置无关的偏移量),使用 adr_var1
和 adr_va2
两个标签来存储 var1
和 var2
的内存地址。第一个 LDR
将 var1
的地址加载到 R0
,然后第二个 LDR
将 var2
的地址加载到·。之后将 R0
中的地址指向的值(0x03
)加载到 R2
,最后将 R2
中的值(0x03
)存储到 R1
中的地址处。
当加载数据到寄存器中时,使用 []
符号意思时:取寄存器中的值作为地址值,然后再从该地址处加载数据到目标寄存器中,如果不加 []
那就是将寄存器中保存的值直接加载到目标寄存器。
同样 STR 命令中也是一个意思。
这听起来比实际要复杂的多,没关系,下面是一个更直观的演示图:
下面我们看一下调试器中的这段代码:
gef> disassemble _start
Dump of assembler code for function _start:0x00008074 <+0>: ldr r0, [pc, #12] ; 0x8088 <adr_var1>0x00008078 <+4>: ldr r1, [pc, #12] ; 0x808c <adr_var2>0x0000807c <+8>: ldr r2, [r0]0x00008080 <+12>: str r2, [r1]0x00008084 <+16>: bx lr
End of assembler dump.
开头的两个 LDR
操作中的第二操作数被替换成了 [pc, #12
]。这被叫做 PC 相对寻址。因为我们使用了标签,所以编译器可以计算出文本池中标签的地址相对位置(pc+12
)。您可以使用这种精确的方法自行计算位置,也可以像前面一样使用标签。唯一的区别是,相较于使用标签,你需要计算值在文本池中的确切位置。在这种情况下,它距离有效的 PC 位置有3个跳转(4+4+4=12)。本章稍后将介绍有关PC相对寻址的介绍。
如果你忘了为什么有效PC指向当前指位置后两个指令,在第二部介绍了[...在执行过程中,在ARM模式下,PC将当前指令的地址加上8(两个ARM指令)作为最终值存储起来,在Thumb模式下,将当前指令加上 4(两个Thumb指令)作为最终值存储起来。而x86中PC始终指向要执行的下一个指令...]
1. 偏移模式:立即数作为偏移量
STR Ra, [Rb, imm]
LDR Ra, [Rc, imm]
这里,我们使用立即(整数)作为偏移量。从基寄存器(以下示例中的 R1)中增加或减去此值,在编译时可以用已知的偏移量访问数据。
.data
var1: .word 3
var2: .word 4
.text
.global _start
_start:ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。 str r2, [r1, #2] @ 以R1中的值为基准加上立即数2作为最终地址,将R2中的值(0x03)存储到该地址处,其中R1中的值不会被修改。 str r2, [r1, #4]! @ 前变址寻址:以R1中的值为基准加上立即数4作为最终地址,将R2中的值(0x03)存储到该地址处,其中R1中的值被修改为:R1+4。 ldr r3, [r1], #4 @ 后变址寻址:将R1中的值作为最终地址,获取该地址处的数据加载到R3,其中R1中的值被修改为:R1+4。bkpt
adr_var1: .word var1
adr_var2: .word var2
假设以上程序文件为ldr.s
,编译并用GDB允许,看看会发生什么。
$ as ldr.s -o ldr.o
$ ld ldr.o -o ldr
$ gdb ldr
GDB
(包含gef
)中,在_start
处设置断点,运行程序。
gef> break _start
gef> run
...
gef> nexti 3 /* 运行后3条指令 */
系统上的寄存器现在填充了以下值(注意,这些地址在你的系统上可能有所不同):
$r0 : 0x00010098 -> 0x00000003
$r1 : 0x0001x009c -> 0x00000004
$r2 : 0x00000003
$r3 : 0x00000000
$r4 : 0x00000000
$r5 : 0x00000000
$r6 : 0x00000000
$r7 : 0x00000000
$r8 : 0x00000000
$r9 : 0x00000000
$r10 : 0x00000000
$r11 : 0x00000000
$r12 : 0x00000000
$sp : 0xbefff7e0 -> 0x00000001
$lr : 0x00000000
$pc : 0x00010080 -> <_start+12> str r2, [r1]
$cpsr : 0x00000010
下一条指令将在偏移地址模式下执行STR
指令。它将把R2
中的值(0x00000003
)存储在:R1
(0x0001x009c
)+偏移(#2
)= 0x1009e
地址处,运行完该条指令后用x/w命令查看0x0001x009c
处的值为0x3
,完全正确。
gef> nexti
gef> x/w 0x1009e
0x1009e <var2+2>: 0x3
再下一条~指令是前变址寻址。可以根据“!
”来识别该模式。唯一区别是,基准寄存器会被更新为最终访问地址。这意味着,我们将R2
(0x3
) 中的值存储到 地址:R1
(0x1009c
)+ 偏移量(#4
) = 0x100A0
,并使用此地址更新 R1
。运行完命令查看0x100A0
地址处的值,然后使用命令info register r1
查看R1
的值。
gef> nexti
gef> x/w 0x100A0
0x100a0: 0x3
gef> info register r1
r1 0x100a0 65696
最后一条LDR
指令是后变址寻址。意思是R1
中的值作为最终访问地址,获取最终访问地址处的值加载到R3
。然后将R1
(0x100A0
)更新为R1(0x100A0)+ 偏移(#4)= 0x100a4
。运行完该命令看看寄存器R1
和R3
的值。
gef> info register r1
r1 0x100a4 65700
gef> info register r3
r3 0x3 3
下图是实际发生的事情:
2. 偏移模式:寄存器作为偏移量(寄存器基址变址寻址)
STR Ra, [Rb, Rc]
LDR Ra, [Rb, Rc]
这种偏移是使用寄存器作为偏移量。下面的示例是,代码在运行时计算要访问的数组索引。
.data
var1: .word 3
var2: .word 4
.text
.global _start
_start:ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。 str r2, [r1, r2] @ 以R1中的值为基准地址,R2中的值(0x03)为偏移量,获得最终访问地址,将R2中的值(0x03)存储到该地址处,基准寄存器R1中的值保存不变。str r2, [r1, r2]! @ 前变址寻址:以R1中的值为基准地址,R2中的值(0x03)为偏移量,获得最终访问 地址,将R2中的值(0x03)存储到该地址处,基准寄存器R1中的值更新为R1+R2。 ldr r3, [r1], r2 @ 后变址寻址:以R1中的值为最终访问地址,获取该地址处的数据并加载到R3,基准寄存器R1中的值更新为R1+R2。bx lr
adr_var1: .word var1
adr_var2: .word var2
当执行第一条STR
指令时,R2
中的值(0x00000003
)被存储到地址:0x0001009c + 0x00000003 = 0x0001009F
。
gef> x/w 0x0001009F0x1009f <var2+3>: 0x00000003
第二条STR
指令操作是前变址寻址,做了同样的操作,不同的一点是R1
的值会被更新:R1=R1+R2
。
gef> info register r1r1 0x1009f 65695
最后一条LDR
指令操作是后变址寻址。以R1
中的值为访问地址,获取该地址处的数据并加载到R3
,然后更新R1
的值:R1 = R1 + R2 = 0x1009f + 0x3 = 0x100a2
。
gef> info register r1r1 0x100a2 65698
gef> info register r3r3 0x3 3
图示:
3. 偏移模式:缩放寄存器作为偏移量(寄存器基址变址寻址)
LDR Ra, [Rb, Rc, <shifter>]
STR Ra, [Rb, Rc, <shifter>]
第三中偏移形式是缩放寄存器作为偏移量。这种情况下,Rb是基地址寄存器,Rc
是一个被左移或右移(<shifter>
位移操作)缩放过的立即数(Rc
中保存的值)。意思是桶型位移操作用来缩放偏移量。下面是一个在数组上循环遍历的例子,可以在GDB
中运行看一下:
.data
var1: .word 3
var2: .word 4
.text
.global _start
_start:ldr r0, adr_var1 @ 通过标签adr_var1获得变量var1的地址,并加载到R0。ldr r1, adr_var2 @ 通过标签adr_var2获得变量var2的地址,并加载到R1。ldr r2, [r0] @ 通过R0内的地址获取到该地址处的值(0x03),加载到R2。 str r2, [r1, r2, LSL#2] @ 以R2中的值左移2位(相当于乘以2)为偏移量,加上R1中的基准地址获得最终访问地址,将R2中的值(0x03)存储到该地址,基准寄存器R1中的值不变。str r2, [r1, r2, LSL#2]! @ 以R2中的值左移2位(相当于乘以2)为偏移量,加上R1中的基准地址获得最终结果地址,将R2中的值(0x03)存储到该地址,基准寄存器R1中的值被修改: R1 = R1 + R2<<2ldr r3, [r1], r2, LSL#2 @ 以R1中的值为访问地址,加载该地址处的数据到R3,基准寄存器R1中的值被修改: R1 = R1 + R2<<2bkpt
adr_var1: .word var1
adr_var2: .word var2
下面是程序运行时的样子:
第一条不多赘述,第二条STR
指令操作使用了前变址寻址,也就是:R1
的值0x1009c+R2
中的值左移2
位(0x03<<2=0xc
)= 0x100a8
,并更新R1
的值为0x100a8
:R1 = R1 + 0x03<<2 = 0x100a8 + 0xc = 0x100b4
。
gef> info register r1
r1 0x100a8 65704
最后一条LDR
指令操作使用了后变址寻址。意思是,加载R1
中的值0x100a8
地址处的数据到寄存器R3
,然后将R2
中的值左移两位(0x03<<2=0xc
)得到值0xC
,再加上R1
中的值0x100a8
得到0x100b4
,最后R1
的值更新为0x100a8
:R1 = R1 + 0x03<<2 = 0x100a8 + 0xc = 0x100b4
。
gef> info register r1
r1 0x100b4 65716
总结
记住 LDR
和 STR
中有三种偏移形式:
- 立即数作为偏移量:
ldr r3, [r1, #4]
- 寄存器作为偏移量:
ldr r3, [r1, r2]
- 带有位移操作的寄存器作为偏移量:
ldr r3, [r1, r2, LSL#2]
如何记住 LDR
和 STR
这些寻址模式:
- 如果带有
!
,就是前变址寻址ldr r3, [r1, #4]!
ldr r3, [r1, r2]!
ldr r3, [r1, r2, LSL#2]!
- 如果基地值寄存器(
R1
)带中括号,就是后变址寻址ldr r3, [r1], #4
ldr r3, [r1], r2
ldr r3, [r1], r2, LSL#2
- 其他的都是带偏移量的寄存器间接寻址
ldr r3, [r1, #4]
ldr r3, [r1, r2]
ldr r3, [r1, r2, LSL#2]
LDR 中的 PC 相对寻址
LDR
是唯一用来加载数据到寄存器中的指令。语法如下:
.section .text
.global _start
_start:ldr r0, =jump /* 加载函数标签jump的地址到R0 */ldr r1, =0x68DB00AD /* 加载值0x68DB00AD到R1 */
jump:ldr r2, =511 /* 加载值511到R2 */ bkpt
这些指令被称为伪指令,我们可以使用此语法来引用文本池中的数据。在上面的示例中,我们使用这些伪指令引用一个函数的偏移量,在指令中将一个32位常量加载到寄存器中。我需要使用此语法在一个指令中将 32 位常量移动到寄存器中的原因是,ARM 只能一次加载 8 位值。什么?要了解原因,您需要了解 ARM 上如何处理立即数的。
ARM 中的 立即数
在ARM上加载一个立即数到寄存器中并不像x86上那么简单,ARM对于立即数有很多限制。这些限制是什么以及如何处理它们并不是ARM汇编所关心的,但请相信我,这只是为了有助于你理解,并且有一些技巧可以绕过这些限制(提示:LDR
)。
我们知道ARM指令长度是32位,并且所有指令都是可条件执行指令。其中有16种条件码,就要占用4位(2^4=16),然后还要2位代指目标寄存器,2位代指操作寄存器,1位作为状态标志,加起其他一些操作码占用的位。到这里分配完指令类型,寄存器以及其他位段,最后只剩下12位用来操作立即数,最多只能表示4096个数。
这意味着ARM中MOV
指令只能操作一定范围内的立即数,如果不能直接被调用,就必须被分割成多个部分,用众多小数字拼起来。
还没完,这12位还不全是用来表示一个整数,其中8位用来表示0-255范围的数n
,4位表示旋转循环右移(其实ARM中只有一种位移,就是旋转循环右移,左移也是通过旋转循环右移得到)的次数r
(范围0-30)。所以一个立即数的表示形式是:v = n ror 2*r
。也就是说,只能以偶数进行旋转循环右移,一次移动两位,n组成的有效位图必须能放到一个字节(8位)中。
下面是一些有效和无效的立即数:
Valid values:
#256 // 1 ror 24 --> 256 循环右移12次,每次两位(注意数据是32位长度)。
#384 // 6 ror 26 --> 384 循环右移13次,每次两位。
#484 // 121 ror 30 --> 484
#16384 // 1 ror 18 --> 16384
#2030043136 // 121 ror 8 --> 2030043136
#0x06000000 // 6 ror 8 --> 100663296 (0x06000000 in hex)
Invalid values:
#370 // 185 ror 31 --> 循环右移31位,但超出了(0 – 30)范围,因此不是有效立即数。
#511 // 1 1111 1111 --> 有效位图无法放到一个字节(8位)中。
#0x06010000 // 110 0000 0001.. --> 有效位图无法放到一个字节(8位)中。
译注:1.以上立即数都是32位长度。2.旋转循环右移:每位都向右移动,末位不断放到最前位,类似首尾相连。3.有效位图要能放到一个字节中:例子中#511
的二进制为0000 0000 0000 0000 0000 0001 1111 1111
,有效位图为1 1111 1111
,超过一个字节。#0x06010000
的二进制位0110 0000 0001 0000 0000 0000 0000
,有效位图110 0000 0001
超过一个字节。
其结果是无法一次加载完整的 32 位地址。我们可以通过使用以下两个选项之一来绕过此限制:
- 用较小的值构造较大的值
- 不要使用
MOV r0, #511
- 分成两部分:
MOV r0, #256
和ADD r0, #255
- 不要使用
- 使用加载方式“
ldr r1, =value
”,编译器会很乐意将其转换位MOV
指令,或者是PC
相对寻址来加载。LDR r1, = 511
如果你加载了一个无效的立即数,那么编译器会报错:“Error: invalid constant
”。如果遇到这种问题你应该知道怎么做。
.section .text
.global _start
_start:mov r0, #511bkpt
如果尝试编译,编译器会输出类似以下错误:
azeria@labs:~$ as test.s -o test.o
test.s: Assembler messages:
test.s:5: Error: invalid constant (1ff) after fixup
你应该把511
拆成几个小数值,或者用前面介绍的LDR
方式。
.section .text
.global _start
_start:mov r0, #256 /* 1 ror 24 = 256, so it's valid */add r0, #255 /* 255 ror 0 = 255, valid. r0 = 256 + 255 = 511 */ldr r1, =511 /* load 511 from the literal pool using LDR */bkpt
如果你想判断一个立即数是否是有效的立即数,你可以用我写的python脚本rotator.py :
azeria@labs:~$ python rotator.py
Enter the value you want to check: 511
Sorry, 511 cannot be used as an immediate number and has to be split.
azeria@labs:~$ python rotator.py
Enter the value you want to check: 256
The number 256 can be used as a valid immediate number.
1 ror 24 --> 256
5. 加载 和 存储 多个值
From:ARM 汇编语言入门(五):https://zhuanlan.zhihu.com/p/109543429
有时你想要更有效率,一次加载(或存储)多个值。为此我们可以使用 LDM(load multiple)和 STM(stroe multiple)指令。这些指令有各种变体,基本上只因访问初始地址的方式而异。这是我们本节将要使用的代码,将一步步地认识这些指令。
.data
array_buff:.word 0x00000000 /* array_buff[0] */.word 0x00000000 /* array_buff[1] */.word 0x00000000 /* array_buff[2]. 此处是一个相对地址,等于array_buff+8 */.word 0x00000000 /* array_buff[3] */.word 0x00000000 /* array_buff[4] */
.text
.global _start
_start:adr r0, words+12 /* address of words[3] -> r0 */ldr r1, array_buff_bridge /* address of array_buff[0] -> r1 */ldr r2, array_buff_bridge+4 /* address of array_buff[2] -> r2 */ldm r0, {r4,r5} /* words[3] -> r4 = 0x03; words[4] -> r5 = 0x04 */stm r1, {r4,r5} /* r4 -> array_buff[0] = 0x03; r5 -> array_buff[1] = 0x04 */ldmia r0, {r4-r6} /* words[3] -> r4 = 0x03, words[4] -> r5 = 0x04; words[5] -> r6 = 0x05; */stmia r1, {r4-r6} /* r4 -> array_buff[0] = 0x03; r5 -> array_buff[1] = 0x04; r6 -> array_buff[2] = 0x05 */ldmib r0, {r4-r6} /* words[4] -> r4 = 0x04; words[5] -> r5 = 0x05; words[6] -> r6 = 0x06 */stmib r1, {r4-r6} /* r4 -> array_buff[1] = 0x04; r5 -> array_buff[2] = 0x05; r6 -> array_buff[3] = 0x06 */ldmda r0, {r4-r6} /* words[3] -> r6 = 0x03; words[2] -> r5 = 0x02; words[1] -> r4 = 0x01 */ldmdb r0, {r4-r6} /* words[2] -> r6 = 0x02; words[1] -> r5 = 0x01; words[0] -> r4 = 0x00 */stmda r2, {r4-r6} /* r6 -> array_buff[2] = 0x02; r5 -> array_buff[1] = 0x01; r4 -> array_buff[0] = 0x00 */stmdb r2, {r4-r5} /* r5 -> array_buff[1] = 0x01; r4 -> array_buff[0] = 0x00; */bx lr
words:.word 0x00000000 /* words[0] */.word 0x00000001 /* words[1] */.word 0x00000002 /* words[2] */.word 0x00000003 /* words[3] */.word 0x00000004 /* words[4] */.word 0x00000005 /* words[5] */.word 0x00000006 /* words[6] */
array_buff_bridge:.word array_buff /* array_buff的地址, 或者说是array_buff[0]的地址 */.word array_buff+8 /* array_buff[2]的地址 */
开始之前,你一定要记住.word是指内存中的数据是32位,也就是4字节。这对理解地址偏移量很重要。程序中的.data段分配了一个空白的数组,有5个元素。我们将它作为可写内存来进行数据存储。.text段包含我们的代码,以及包含两个标签的只读数据段。一个标签是包含7个元素的数组,第二个标签用来桥接.text段和.date段,以便我们可以访问保存在.data中的array_buff。
adr r0, words+12 /* address of words[3] -> r0 */
使用ADR
指令(惰性方法)获取words
的第四个元素(words[3]
)的地址,存储到R0
。定位到words
数组的中间,以便接下来向前和向后操作。
gef> break _start
gef> run
gef> nexti
现在R0
存有wards[3]的地址0x80B8
,算一下words[0]地址,也就是数组words开始的地址:0x80AC ( 0x80B8 – 0xC)
。看一下内存值。
gef> x/7w 0x00080AC
0x80ac <words>: 0x00000000 0x00000001 0x00000002 0x00000003
0x80bc <words+16>: 0x00000004 0x00000005 0x00000006
在R1
和R2
中分别保存array_buff数组的第一(array_buff[0])和第三(array_buff[2])个元素的地址。
ldr r1, array_buff_bridge /* address of array_buff[0] -> r1 */
ldr r2, array_buff_bridge+4 /* address of array_buff[2] -> r2 */
执行完上面两条指令,看一下R1
和R2
中的值,分别是array_buff[0]
和array_buff[2]
的地址。
gef> info register r1 r2
r1 0x100d0 65744
r2 0x100d8 65752
下一条指令LDM
从R0
指向的words[3]
位置加载两个值到R4
和R5
,其中words[3]
给R4
,words[4]
给R5
。
ldm r0, {r4,r5} /* words[3]() -> r4 = 0x03; words[4] -> r5 = 0x04 */
我们一条指令就加载了两个数据,让R4=0x00000003
,R5 = 0x00000004
。
gef> info registers r4 r5
r4 0x3 3
r5 0x4 4
很好,现在再用STM
指令一次存储多条数据值。代码中STM
从R4
和R5
分别获取值0x03
和0x04
,然后依次存储到R1
指定的地址处。前面的指令让R1
通过array_buff_bridge
指向了数组array_buff
的开始位置,最终运行结果:array_buff[0] = 0x00000003 and array_buff[1] = 0x00000004
。如果没有特殊说明,LDM
和STM
操作的数据都是32位。
stm r1, {r4,r5} /* r4 -> array_buff[0] = 0x03; r5 -> array_buff[1] = 0x04 */
现在0x03
和0x04
应该分别被保存到了0x100D0
and 0x100D4
。下面的指令是产看地址0x000100D0
处的两个字长度的值。
gef> x/2w 0x000100D0
0x100d0 <array_buff>: 0x3 0x4
前面提到,LDM
和STM
有很多变种。其中一种指令后缀。如-IA(increase after)
、-IB(increase before)
、-DA(decrease after)
、-DB(decrease before)
。这些变种依据第一个操作数(保存源地址或目标地址的寄存器)指定的不同的内存访问方式而不同。在实践中,LDM
与LDMIA
相同,意思是第一个操作数(寄存器)内的地址随着元素的加载而不断增加。通过这种方式我们根据第一个操作数(保存了源地址的寄存器)获取一连串(正向)的数据。
ldmia r0, {r4-r6} /* words[3] -> r4 = 0x03, words[4] -> r5 = 0x04; words[5] -> r6 = 0x05; */
stmia r1, {r4-r6} /* r4 -> array_buff[0] = 0x03; r5 -> array_buff[1] = 0x04; r6 -> array_buff[2] = 0x05 */
执行完上面的指令后,寄存器R4-R6
以及地址0x000100D0
, 0x000100D4
和0x000100D8
的值应该是0x3
, 0x4
和0x5
。
gef> info registers r4 r5 r6
r4 0x3 3
r5 0x4 4
r6 0x5 5
gef> x/3w 0x000100D0
0x100d0 <array_buff>: 0x00000003 0x00000004 0x00000005
LDMIB
指令先将源地址加4个字节(一个字)然后再执行加载。这种方式下我们仍然会得到一串加载的数据,但是第一个元素是从源地址偏移4个字节开始的。这就是为什么例子中LDMIB
指令操作后R4
中的值是0x00000004
(words[4]
)而不是R0
所指的0x00000003
(words[3]
)的原因。
ldmib r0, {r4-r6} /* words[4] -> r4 = 0x04; words[5] -> r5 = 0x05; words[6] -> r6 = 0x06 */
stmib r1, {r4-r6} /* r4 -> array_buff[1] = 0x04; r5 -> array_buff[2] = 0x05; r6 -> array_buff[3] = 0x06 */
上面两条指令执行后,寄存器R4-R6
以及地址0x100D4
, 0x100D8
和0x100DC
的值应该是0x4
, 0x5
和0x6
。
gef> x/3w 0x100D4
0x100d4 <array_buff+4>: 0x00000004 0x00000005 0x00000006
gef> info register r4 r5 r6
r4 0x4 4
r5 0x5 5
r6 0x6 6
当使用LDMDA
指令所有的操作都是反向的。R0
当前指向words[3],当执行指令时反方向加载words[3]
,words[2]
,words[1]
到寄存器R6
,R5
,R4
。是的,寄存器也是按照反向顺序。执行完指令后R6 = 0x00000003,R5 = 0x00000002,R4 = 0x00000001
。这里的逻辑是,每次加载后都将源地址递减一次。加载时寄存器按照反方向是因为:每次加载时地址在减小,寄存器也跟着反方向,逻辑上保证了高地址上对应的是高寄存器中的值。再看一下LDMIA
(或LDM
)的例子,我们首先加载低寄存器是因为源地也是低地址,然后加载高寄存器是因为源地址也增加了。
加载多条值,后递减:
ldmda r0, {r4-r6} /* words[3] -> r6 = 0x03; words[2] -> r5 = 0x02; words[1] -> r4 = 0x01 */
执行后R4、R5和R6的值:
gef> info register r4 r5 r6
r4 0x1 1
r5 0x2 2
r6 0x3 3
加载多条值,前递减:
ldmdb r0, {r4-r6} /* words[2] -> r6 = 0x02; words[1] -> r5 = 0x01; words[0] -> r4 = 0x00 */
执行后R4、R5和R6的值:
gef> info register r4 r5 r6
r4 0x0 0
r5 0x1 1
r6 0x2 2
存储多条值,后递减:
stmda r2, {r4-r6} /* r6 -> array_buff[2] = 0x02; r5 -> array_buff[1] = 0x01; r4 -> array_buff[0] = 0x00 */
执行后array_buff[2],array_buff[1]和array_buff[0]地址处的值:
gef> x/3w 0x100D0
0x100d0 <array_buff>: 0x00000000 0x00000001 0x00000002
存储多条值,前递减:
stmda r2, {r4-r6} /* r6 -> array_buff[2] = 0x02; r5 -> array_buff[1] = 0x01; r4 -> array_buff[0] = 0x00 */
执行后array_buff[2],array_buff[1]和array_buff[0]地址处的值:
gef> x/2w 0x100D0
0x100d0 <array_buff>: 0x00000000 0x00000001
入栈 和 出栈
进程中有一个叫做栈的内存位置。栈指针(SP)寄存器总是指向栈内存中的地址。程序应用中通常使用栈来存储临时数据。前面讲的ARM中只能使用加载和存储来访问内存,就是只能使用LDR
/STR
指令或者他们的衍生指令(LDM
、STM
、LDMIA
、LDMDA
、STMDA
等等)进行内存操作。在x86
中使用PUSH和POP从栈内取或存,ARM中我们也可以使用这条指令。
当我们将数据 PUSH 入向下生长的栈(详见Part 7:堆栈与函数)时,会发生以下事情:
- 首先,SP中的地址减少4(译注:4字节=32位)。
- 然后,数据存储到SP的新地址值处。
当数据从栈中 POP 出时,发生以下事情:
- 当前SP中地址处的数据加载到指定寄存器中。
- SP中的地址值加4。
下面的例子中使用PUSH/POP
以及LDMIA/STMDB
:
.text
.global _start
_start:mov r0, #3mov r1, #4push {r0, r1}pop {r2, r3}stmdb sp!, {r0, r1}ldmia sp!, {r4, r5}bkpt
反编译一下代码:
azeria@labs:~$ as pushpop.s -o pushpop.o
azeria@labs:~$ ld pushpop.o -o pushpop
azeria@labs:~$ objdump -D pushpop
pushpop: file format elf32-littlearm
Disassembly of section .text:
00008054 <_start>:8054: e3a00003 mov r0, #38058: e3a01004 mov r1, #4805c: e92d0003 push {r0, r1}8060: e8bd000c pop {r2, r3}8064: e92d0003 push {r0, r1}8068: e8bd0030 pop {r4, r5}806c: e1200070 bkpt 0x0000
可以看到LDMIA和STMDB被替换成了PUSH和POP。那是因为PUSH是STMDB的同语义指令,POP是LDMIA的同语义指令。
再GDB中调试运行一下:
gef> break _start
gef> run
gef> nexti 2
[...]
gef> x/w $sp
0xbefff7e0: 0x00000001
运行完头两条指令后先查看一下SP指向的地址以及地址处的数值。下一条PUSH指令会将SP减去8,并且将R1
和R0
中的值按顺序压入栈中。
gef> nexti
[...] ----- Stack -----
0xbefff7d8|+0x00: 0x3 <- $sp
0xbefff7dc|+0x04: 0x4
0xbefff7e0|+0x08: 0x1
[...]
gef> x/w $sp
0xbefff7d8: 0x00000003
接下来栈中的值0x03和0x04弹出到寄存器中。
gef> nexti
gef> info register r2 r3
r2 0x3 3
r3 0x4 4
gef> x/w $sp
0xbefff7e0: 0x00000001
6. 条件状态 和 分支
From:ARM 汇编语言入门(六):https://zhuanlan.zhihu.com/p/109543670
在探讨 CPSR 时我们已经接触了条件状态。我们通过跳转(分支)或者一些只有满足特定条件才执行的指令来控制程序在运行时的执行流。通过CPSR寄存器中的特定bit位来表示条件状态。这些位根据指令每次执行的结果而不断变化。例如,比较运算时如果两个数相等,那么就置CPSR中的Zero位(Z=1),实际上是因为:a - b = 0,这种情况下就是相等状态。如果第一个数大,那么就是大于状态。如果第二个数大,就是小于状态。除此之外,还有小于等于、大于等于等等。
下面的表格列出了可用的条件状态码,描述和标志位:
在下面代码片段中看一下执行条件加法时的实际用法L:
.global main
main:mov r0, #2 /* 初始化变量 */cmp r0, #3 /* 将R0中的值与3比较,负数位置1 */addlt r0, r0, #1 /* 如果上一条比较结果是小于(查看CPSR),则将R0加1 */cmp r0, #3 /* 将R0中的值再与3比较, 零位置1,同时负数位重置为0 */addlt r0, r0, #1 /* 如果上一条比较结果是小于(查看CPSR),则将R0加1 */bx lr
第一条cmp
指令结果导致CPSR
中的负数位置1(2- 3 = -1
)意思是R0
小于R3
。因为满足小于条件(CPSR
中的溢出位不等于负数位V != N
)所以接下来的ADDLT
指令执行。在执行下一条cmp
指令时,R0 = 3
。所以清除负数位(3 - 3 = 0
,负数位清零),零位置位(Z = 1
)。现在溢出位是0,负数位是0,不满足小于条件。所以最后一条ADDLT
指令不执行,R0
值保持3不变。
Thumb 模式下的 条件执行
我们在介绍指令集的章节讨论了Thumb状态下的不同。具体而言是Thumb-2版本支持条件执行。某些 ARM 处理器版本支持"IT"指令,允许在 Thumb 状态下支持多达4个条件执行指令。参考:http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0552a/BABIJDIC.html。
语法:IT{x{y{z}}} cond
- cond 指定 IT 块的第一个指令的条件。
- x 指定 IT 块中第二个指令的条件开关。
- y 指定 IT 块中第三个指令的条件开关。
- z 指定 IT 块中第四个指令的条件开关。
其实IT指令的结构就是“IF-Then-(Else)”,语法都是由字母“T”和“E”构成:
- IT:If-Then(下一条指令是条件的);
- ITT:If-Then-Then(后两条指令是条件的);
- ITE:If-Then-Else(后两条指令是条件的);
- ITTE:If-Then-Then-Else(后三条指令是条件的);
- ITTEE:If-Then-Then-Else-Else(后四条指令是条件的);
IT块中的每条指令必须指定相同或逻辑相反的条件后缀。意思是,如果使用ITE,那么前两个指令必须有相同的后缀,而第三个必须是逻辑相反的后缀。下面是 ARM 参考手册中的一些示例,说明了这些逻辑:
ITTE NE ; 接下来的3条指令都是有条件的。
ANDNE R0, R0, R1 ; ANDNE不更新条件标志。
ADDSNE R2, R2, #1 ; ADDSNE更新条件标志。
MOVEQ R2, R3 ; 有条件的移动
ITE GT ; 接下来的2条指令都是有条件的。
ADDGT R1, R0, #55 ; 条件满足大于时进行相加。
ADDLE R1, R0, #48 ; 条件不满足大于时进行相加。
ITTEE EQ ; 接下来的4条指令都是有条件的。
MOVEQ R0, R1 ; 有条件的MOV
ADDEQ R2, R2, #10 ; 有条件的ADD
ANDNE R3, R3, #1 ; 有条件的AND
BNE.W dloop ; 分支指令只能在IT块的最后一个指令中使用。
错误示例:
IT NE ; 下一条指令是条件的。
ADD R0, R0, R1 ; 语法错误,不是有条件的指令。
下面是条件代码和相反代码:
现在使用以下代码来测试:
.syntax unified @ 非常重要!
.text
.global _start
_start:.code 32add r3, pc, #1 @ PC的值加1并存储到R3。bx r3 @ 跳转到R3中的地址处,并切换运行模式 ->切换到Thumb模式,因为R3最低有效位(LSB) = 1。
.code 16 @ Thumb模式cmp r0, #10 ite eq @ 如果R0等于10...addeq r1, #2 @ ... 那么 R1 = R1 + 2addne r1, #3 @ ... 否则 R1 = R1 + 3bkpt
.code 32
示例中的代码开始在ARM模式下,第一条指令将PC中的地址值加1并存储到R3
,然后bx指令跳转到R3
中的地址位置,并且模式切换成Thumb
模式,因为R3
中的值最低有效位为1(0不切换)。为此使用bx
(分支+交换)非常重要。
.code 16
在Thumb
模式下,首先比较R0
和10
,结果将负数位N置位(0 - 10 = -10
)。之后使用If-Then-Else
块,因为零位Z(Zero)没有被置位所以ADDEQ
指令被跳过,然后因为结果不相等所以执行ADDNE
指令。
在 GDB
中单步执行此代码会干扰结果,因为你要在 ITE
块中执行这两个指令。 但是,在 GDB
中运行代码而不设置断点并单步执行每个指令将生成正确的结果设置 R1
= 3。
分支
分支(跳转)允许我们跳转到另一个代码段。当你需要跳过(或者重复)某块代码或者跳转到指定的函数的时候,分支很有用。此类情形中最佳的示例是IF和循环。先来看看IF案例。
.global main
main:mov r1, #2 /* 设置初始变量a */mov r2, #3 /* 设置初始变量b */cmp r1, r2 /* 比较两个变量值看哪个更大 */blt r1_lower /* 因为R2更大(N==1),跳转到r1_lower */mov r0, r1 /* 如果没有跳转, 例如R1的值更大(或者相等),则将R1的值存储到R0 */b end /* 结束 */
r1_lower:mov r0, r2 /* R1小于R2时跳转到此处, 将R2的值存储到R0 */b end /* 结束 */
end:bx lr /* THE END */
上面代码是比较两个初始值并返回最大值,C语言伪代码:
int main() {int max = 0;int a = 2;int b = 3;if(a < b) {max = b;}else {max = a;}return max;
}
现在再看一下怎么使用条件分支实现循环:
.global main
main:mov r0, #0 /* 设置初始变量a */
loop:cmp r0, #4 /* 比较a==4 */beq end /* 如果a==4,结束 */add r0, r0, #1 /* 否则将R0中的值递增1 */b loop /* 跳转到loop开始位置 */
end:bx lr /* THE END */
C语言伪代码:
int main() {int a = 0;while(a < 4) {a= a+1;}return a;
}
B、BX、BLX 指令
有三种类型的分支指令:
- 普通分支(B)
- 简单的跳转到一个函数。
- 带链接的跳转(BL)
- 将PC+4的值保存到LR寄存器,然后跳转。
- 带状态切换的跳转(BX)和带状态切换及链接的跳转(BLX)
- 与 B 和 BL 一致,只是添加了工作状态的切换( ARM模式 - Thumb模式 )。
- 需要寄存器作为第一个操作数。
BX、BLX 用来切换 ARM 模式到 Thumb 模式。
.text
.global _start
_start:.code 32 @ ARM modeadd r2, pc, #1 @ put PC+1 into R2bx r2 @ branch + exchange to R2
.code 16 @ Thumb modemov r0, #1
这里的技巧是获得当前PC的值,加1然后保存到一个寄存器,然后跳转(并且切换状态模式)到这个寄存器内的地址。可以看到加指令(add r2, pc, #1
)获取到有效的PC地址值(当前PC内的值+8=0x805C
)然后加1(0x805C + 1 = 0x805D
)。接下来,我们跳转的地址( 0x805D = 10000000 01011101
)最低有效位为1,那么意味着地址不是4字节(32bit
)对齐的。跳转到这样的地址不会导致非对齐问题。在GDB
中运行的样子(含GEF
):
注意上面的 gif
图片是在低版本的 GEF
下创建的,所以你的显示界面可能不一样,但是逻辑是一样的。
条件分支
分支也可以有条件地执行,用于在满足特定条件时跳转到函数。我们看一个使用BEQ
应用条件分支的例子,这是一段没太有用的汇编代码,只不过是在寄存器等于特定值时将一个值移动到寄存器并跳转到另一个函数的过程。
示例代码:
.text
.global _start
_start:mov r0, #2mov r1, #2add r0, r0, r1cmp r0, #4beq func1add r1, #5b func2
func1:mov r1, r0bx lr
func2:mov r0, r1bx lr
7. 栈 和 函数
ARM汇编语言入门(七):https://zhuanlan.zhihu.com/p/109544390
在这一部分我们来看一下进程中叫做栈的内存区域。本章涵盖了栈的用途和相关操作。此外我们将介绍 ARM 中函数的实现、类型和差异。
栈
一般而言,栈就是进程中的一段内存。这段内存是在进程创建时分配的。我们使用栈来保存一些临时数据,如函数中的局部变量,函数之间转换的环境变量等。使用PUSH和POP指令与栈进行交互。在Part 4:内存指令:加载与存储中我们讲到PUSH和POP是一些其他内存操作指令的别名,这里为简单起见我们使用PUSH和POP指令。
在看实例之前,我们先要明白栈有多种实现方式。首先,当我们说栈增长了,意思是一个数据(32位)被放入了栈中。栈可以向上增长(当栈是按照降序方式实现)或者向下增长(当栈是按照升序方式实现)。下一条信息将被放置的实际位置是由栈指针定义的。准确的说是保存在寄存器SP中的地址指定的。地址可以是栈中的当前(最后入栈)项或者下一个可用的内存位置。如果SP指向的是栈中的最后一个项(完整栈实现方式),那么是先增加(向上增加栈)或减小(向下增长栈)SP再放入数据;如果SP指向的是栈内下一个有效的空位置,那么是数据先入栈后再增加SP(向上增加栈)或减少SP(向下增长栈)。
总结了栈的不同实现,我们可以用以下表格列出了不同情况下使用不同的多数据存储或多数据加载指令。
我们的例子中使用了完整降序栈(Full descending)。下面是一个简单例子,看一下这种栈是如何处理栈指针的。
/* azeria@labs:~$ as stack.s -o stack.o && gcc stack.o -o stack && gdb stack */
.global main
main:mov r0, #2 /* 设置R0的初始值*/push {r0} /* 将R0的值保存到栈*/mov r0, #3 /* 覆盖R0的值 */pop {r0} /* 恢复R0的初始值 */bx lr /* 结束程序 */
在一开始,栈指针指向地址0xbefff6f8 (你的环境中可能不同)代表栈中的最后一项值。这时我们看一下这个地址处的值(同样,你的环境中可能不同):
gef> x/1x $sp
0xbefff6f8: 0xb6fc7000
当执行完第一条MOV指令后,栈内数据没有变化。当执行PUSH指令时,将发生以下事情:首先SP的值减4(4 bytes = 32 bits);然后R0中的值保存到SP指定的地址处。现在再看一下SP中指定的地址处的值:
gef> x/x $sp
0xbefff6f4: 0x00000002
例子中的指令mov r0, #3用来模拟R0中的数据被覆盖的情形。然后使用POP再将之前的数据恢复。所以,当执行POP指令时,实际发生了以下事情:首先从当前SP指向的内存地址(0xbefff6f4)处读取一个32位的数据(前面PUSH时保存的2),然后SP寄存器的值减4(变成0xbefff6f8 ),最后将从栈中读取的数值2保存到R0。
gef> info registers r0
r0 0x2 2
(注意,下面的gif展示的栈的低地址在上面,高地址在下面。不是前面展示不同堆栈实现时的图片的那种方式,这样是为了让栈看起来跟GDB中展示一样):
我们看一下函数如何利用Stack来保存本地变量、保留寄存器状态。为了让一切变得井然有序,函数使用栈帧(专门用于函数中使用的局部内存区域)。栈帧是在函数开始调用时创建的(下一节将详细介绍)。栈帧指针(FP)被置为栈帧的底部,然后分配栈帧的缓冲区。栈帧中通常(从底部)保存了返回地址(前面的LR寄存器值)、栈帧指针、其他一些需要保存的寄存器、函数参数(如果超过4个参数)、局部变量等等。虽然栈帧的实际内容可能有所不同,但基本就这些。最后栈帧在函数结束时被销毁。
下面是栈中栈帧的示意图:
为了直观点,再看一段代码:
/* azeria@labs:~$ gcc func.c -o func && gdb func */
int main()
{int res = 0;int a = 1;int b = 2;res = max(a, b);return res;
}
int max(int a,int b)
{do_nothing();if(a<b){return b;}else{return a;}
}
int do_nothing()
{return 0;
}
下面的GDB截图中我们可以看一下栈帧的样子:
从上图中我们可以看到,当前我们即将离开函数max(反汇编代码底部的箭头)时,这时,FP
(R11
寄存器)指向栈帧最底部的0xbefff254
。看栈中的绿色地址保存了返回地址0x00010418
(前面的LR寄存器)。再往上4字节的地址处(0xbefff250
)保存值0xbefff26c
,这是前一个栈帧指针(FP
)。地址0xbefff24c
和0xbefff248
处的0x1
和0x2
是函数max运行时的局部变量。所以刚才分析的这个栈帧只包含了LR
,FP
和两个局部变量。
函数
要理解 ARM 中的函数,首先要熟悉函数体的结构:开始、执行体和收尾。
开始时需要保存程序前面的状态(LR和R11分别入栈)然后为函数的局部变量设置堆栈。虽然开始部分的实现可能因编译器而异,但通常是用PUSH/ADD/SUB指令来完成的。大体看起来是下面这样:
push {r11, lr} /* 将lr和r11入栈 */
add r11, sp, #0 /* 设置栈帧的底部位置 */
sub sp, sp, #16 /* 栈指针减去16为局部变量分配缓存区 */
函数体部分就是你程序的实际逻辑区,包含了你代码逻辑的各种指令:
mov r0, #1 /* 设置局部变量(a=1). 同时也为函数max的第一个参数 */
mov r1, #2 /* 设置局部变量(b=2). 同时也为函数max的第二个参数 */
bl max /* 调用函数max */
上面的代码展示了为函数设置局部变量并跳转到另一个函数的过程。同时还展示了通过寄存器为另一个函数(max)传递参数的过程。在某些情况下,当要传递的参数超过4个时,我们需要另外使用栈来存储剩余的参数。还要说明一下,函数通过寄存器R0返回结果。所以不论max函数结果是什么,最后都要在函数结束返回后从R0中取返回值。在某些情况下,结果可能是 64 位的长度(超过 32 位寄存器的大小),这时候就需要结合R0和R1来存储返回值。
函数的最后部分用于将程序的状态还原到它初始的状态(函数调用前),这样就可以从函数被调用的地方继续执行。所以我们需要重新调整栈指针(SP)。这是通过加减帧指针寄存器(R11)来实现的。重新调整栈指针后,将之前(函数开始处)保存的寄存器值从堆栈弹出到相应的寄存器来还原这些寄存器值。根据函数类型,一般POP指令是函数最后结束的指令。但是,在还原寄存器值后,我们需要使用 BX 指令来离开函数。示例如下:
sub sp, r11, #0 /* 重新调整栈指针 */
pop {r11, pc} /* 恢复栈帧指针, 通过加载之前保存的LR到PC,程序跳转到之前LR保存位置。函数的栈帧被销毁 */
所以我们现在知道:
- 函数在开始时设置相应的环境。
- 函数体中执行相关逻辑,然后通过R0保存返回值。
- 函数收尾时恢复所有的状态,以便程序可以在函数调用前的位置继续执行。
另一个重要的知识点时函数类型:叶子函数和非叶子函数。叶子函数在函数内不会调用/跳转到另一个函数。非叶子函数则会在自己的函数逻辑中调用另一个函数。这两种函数的实现方式类似。不过,也有一些不同。我们用下面的代码分析一下:
/* azeria@labs:~$ as func.s -o func.o && gcc func.o -o func && gdb func */
.global main
main:push {r11, lr} /* 开始,栈帧指针和LR分别入栈 */add r11, sp, #0 /* 设置栈帧的底部(译注:其实是将sp的值给R11,栈指针指向初始的栈帧指针位置(栈帧底部)) */sub sp, sp, #16 /* 在栈上分配一些内存作为接下来局部变量要用的缓存区(译注:栈指针减16,相当于将栈帧指针往下移动了16字节)) */mov r0, #1 /* 设置局部变量 (a=1). 同时也为函数max准备参数a */mov r1, #2 /* 设置局部变量 (b=2). 同时也为函数max准备参数b */bl max /* 跳转到函数max */sub sp, r11, #0 /* 重新调整栈指针 */pop {r11, pc} /* 恢复栈帧指针, 通过加载之前保存的LR到PC,程序跳转到之前LR保存位置 */
max:push {r11} /* 开始,栈帧指针入栈 */add r11, sp, #0 /* 设置栈帧底部 */sub sp, sp, #12 /* 栈指针减12,分配栈内存 */cmp r0, r1 /* 比较R0和R1(a和b) */movlt r0, r1 /* 如果R0<R1, 将R1存储到R0 */add sp, r11, #0 /* 收尾,调整栈指针 */pop {r11} /* 恢复栈帧指针 */bx lr /* 通过寄存器LR跳转到main函数 */
上面的例子包含两个函数:main函数是一个非叶子函数,max函数是叶子函数。之前说了非叶子函数有跳转到其他函数的逻辑(bl , max),而max中没有(最后一条是跳转到LR指定的地址,不是函数分支)这类代码,所以是叶子函数。
另一个不同点是函数的开始与收尾的实现有差异。来看一段代码,这是叶子函数与非叶子函数在开始部分的差异:
/* 非叶子函数 */
push {r11, lr} /* 分别保存栈帧指针和LR */
add r11, sp, #0 /* 设置栈帧底部 */
sub sp, sp, #16 /* 在栈上分配缓存区*/
/* 叶子函数 */
push {r11} /* 保存栈帧指针 */
add r11, sp, #0 /* 设置栈帧底部 */
sub sp, sp, #12 /* 在栈上分配缓存区 */
不同之处是非叶子函数保存了更多的寄存器。原因也很自然,因为非叶子函数中执行时LR会被修改,因此要先保存LR以便最后恢复。当然如果有必要也可以在函数开始时保存更多的寄存器。
下面这段代码可以看到,叶函数与非叶函数在收尾时的差异主要是在于,叶子函数在结尾直接通过LR中的值跳转回去,而非叶子函数需要先通过POP恢复LR寄存器,再进行分支跳转。
/* A prologue of a non-leaf function */
push {r11, lr} /* Start of the prologue. Saving Frame Pointer and LR onto the stack */
add r11, sp, #0 /* Setting up the bottom of the stack frame */
sub sp, sp, #16 /* End of the prologue. Allocating some buffer on the stack */
/* A prologue of a leaf function */
push {r11} /* Start of the prologue. Saving Frame Pointer onto the stack */
add r11, sp, #0 /* Setting up the bottom of the stack frame */
sub sp, sp, #12 /* End of the prologue. Allocating some buffer on the stack */
最后,我们要再次强调一下在函数中BL和BX指令的使用。在我们的示例中,通过使用BL指令跳转到叶子函数中。在汇编代码中我们使用了标签,在编译过程中,标签被转换为相对应的内存地址。在跳转到对应位置之前,BL会将下一条指令的地址存储到LR寄存器中这样我们就能在函数max结束的时候返回了。
BX指令在被用在我们离开一个叶函数时,使用LR作为寄存器参数。刚刚说了LR存放着函数调用返回后下一条指令的地址。由于叶函数不会在执行时修改LR寄存器,所以就可以通过LR寄存器跳转返回到main函数了。同样可以使用BX指令帮助我们切换ARM模式和Thumb模式。可以通过LR寄存器的最低比特位来完成,0代表ARM模式,1代表Thumb模式。
换一种方式看一下函数及其内部,下面的动画说明了非叶子函数和叶子函数的内部工作过程。
Assembly Basics Cheatsheet
From:https://azeria-labs.com/assembly-basics-cheatsheet/