My Blog

ARM Assembly

ARM 汇编基础知识

学习笔记,仅供参考

参考ARM 汇编语言 - freeCodeCamp | writing-arm-assembly


在学习 FreeRTOS 中要使用 ARM 汇编语言来编写系统的调度、切换等操作,之前这块知识空白所以补下基础,目的只为能看懂 ARM 汇编程序

一些概念

参考视频主要以实操为主,所以一些概念参考这篇博客 writing-arm-assembly

arm与x86区别:主要是指令集不同,x86 CISC - 有丰富的指令来访问内存,因此有更多的操作、取值模式,但寄存器较少; ARM RISC - 指令较少、寄存器较多,只使用 Load/Stroe 指令访问内存,即对一块内存做加法就要三步(1. 将数据从内存取到 CPU,2. CPU 做加法操作, 3. 再将运算结果存入内存)。RSIC的优点之一:由于每条指令都较为精简,所以执行快效率高。另外,arm 还分为两种模式 ARM 和 Thumb

更多区别:

  • ARM 大多指令能用作条件执行
  • Intel x86 使用小端存储(数据地位放在低地址)
  • ARM v3 前使用小端存储、之后变为大小端可切换的存储模式

除了 ARM 与 x86 之间有区别外,ARM 的不同版本也会区别

ARM_version

由于计算机只认识机器码,所以汇编程序还要先转为机器码才行,因此汇编器充当了这一角色。同时汇编语言的出现也减轻了程序员编写代码的压力,通过近似自然语言的文本字符来告诉计算机完成怎样的操作。

数据结构:汇编中数据通常是以字、半字、字节的形式存取数据,在指令后添加对应的后缀来表示不同的数据形式,-h 表示半字、-b 表示字节、无后缀默认为字。

/* 不同数据类型的 Load (Store 类型) */
ldr         // load word
ldrh        // load unsigned half word
ldrsh       // load signed half word
ldrb        // load unsigned byte
ldrsb       // load signed byte

大小端:上面说过,ARM 支持大小端存储数据,大小端的判断就是根据数据高低位谁放在低地址。而切换大小端模式的位是 CPSR 的第九位(E bit)决定

Endianness

寄存器:常见到的 16 个寄存器 r0~r15,可分为 2 组,通用寄存器和特殊寄存器。

Register

R0-R12 用于存放数值或内存地址。例如R0能作为算数运算结果或函数返回值;R7能用于 systemcall;R11能用于跟踪栈指针;而且函数调用的前四个参数存放在 r0-r3

R13(SP-Stack Pointer)栈顶指针,用于压栈出栈操作

R14(LR-Link Register)当有子程序或异常时LR会保存当前指令的下条指令的地址,可让程序从子程序跳回主程序

R15(PC-Program Counter)每执行一条指令就自增一次,ARM通常是4字节,Thumb是2字节。当执行分支指令时,PC 的值会加8字节

CPSR (Current Program Status Register)用于表示当前程序的一些状态

CPSR

下面是对一些标志位的描述

CPSR_bit


ARM 常见指令集

存取指令(Load/Store)

/* 将 list 的地址存入 r0,即 r0 = &list[0] */
ldr r0, =list   

/* 将 r0 值对应的地址的数据存入 r1,即 r1 = list[0] */
ldr r1, [r0]

/* 将 r0 值加 4 对应地址的数据存入 r2,即 r2 = list[1] 
   另外,加数必须为 4 的倍数,一个地址占 4 字节 */
ldr r2, [r0, #4]

/* 将 r0 值对应地址的数据存入 r3,然后 r0 再加 4,
   即 r3 = list[0], r0 += 4 */
ldr r3, [r0], #4

/* 多数据加载,将 r2 对应地址的值加载到 r0,然后再将地址+1的值
   加载到 r4,依次类推加载到 r6 为止 */
ldm r2, {r0, r4-r6}

/* !表示将最后的地址写回到 r2 */
ldm r2!, {r0, r4-r6}

/* Store 存数据也是类似情形 */

算术运算(+、-、*)

/* 加减乘 */
add r2, r1, r0      // r2 = r1 + r0
sub r2, r1, r0      // r2 = r1 - r0
mul r2, r1, r0      // r2 = r1 * r0

/* 注意:普通的算数运算指令不会设置 CPSR 的标志位,所以当一些算数产生进位、借位
  溢出时我们就无法得知,所以就有了下面这些拓展指令 */
adds r2, r1, r0
subs r2, r1, r0
muls r2, r1, r0

/* 带进位借位运算 */
ADC r2, r1, r0      // r2 = r1 + r0 + carry_bit
SBC r2, r1, r0      // r2 = r1 - r0 - carry_bit

位运算(与或非)

/* 与、或、异或 */
and r1, r0, #0xf    // r1 = r0 & (0xf)
orr r1, r0, #0xf    // r1 = r0 | (0xf)
eor r1, r0, #0xf    // r1 = r0 ^ (0xf) 

/* 赋值与取反 */
mov r0, #0xf        // r0 = 0xf
mvn r0, #0xf        // r0 = ~(0xf)

移位和循环移位

/* 左移 */
lsl r0, #1      // 对 r0 的值左移 1 位,立即数范围:0-31
lsl r1, r0      // 对 r1 的值左移 r0 值个位
lsl r1, r0, #1  // 将 r0 值左移移位存入 r1 中

/* 右移 */
lsr r0, #1      // r0 值右移 1 位,立即数范围:1-32

/* 循环右移 */
ror r0,#1      // r0 值向右循环移 1 位
ror r1, r0, #1

/* 注意:参与移位的寄存器必须为 r0-r7 */

条件与执行

/* 比较 - 即计算 r1 - r0 的差值,若为 +,CSPR N 位置 0,
   为 -,CPSR N 位置 1,为 0,CPSR Z 位置 1 */
cmp r1, r0

/* 通过 cmp 指令设置 CPSR 标志位,即可判断大小 */
BGT label   // 若 N=0,Z=0, 跳至 label 执行
BLT label   // 若 N=1,Z=0, 跳至 label 执行
BEQ label   // 若 N=0,Z=1, 跳至 label 执行
BGE(), BLE(), BNE(!=)
B label     // 跳转到 label

/* 条件跳转示例--遍历数组 */
.data
list:   // 定义数组,并获取数组长度
	.word 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
	.equ listend,(.-list)/4

.global _start
_start:     // 初始化寄存器
	ldr r0, =list   // r0 作数组下标
    mov r2, #0
    mov r3, #0
    
sum:        // 遍历数组求和
	ldr r3, [r0], #4    // 取出数组当前下标值,下标向后移
    add r2, r3          // r2 += r3
    cmp r3, #listend    // 判断下标是否超出数组长度
    blt sum             // 未超出跳至 sum 循环遍历
    
.end


/* 除了跳转可与条件结合使用外,算数运算也行,并组合成新指令 */
movlt r0, #1    // 若条件满足,r0=1
addgt r1, #2    // 若条件满足,r1+=2

栈与函数

/* 函数调用与返回 */
bl label       // 跳转 label 处执行,同时 LR 存入 bl 指令下条指令地址
bx lr          // 常用于子函数结尾,表示跳至 LR 所存地址处执行,即回到主函数

/* 栈--保护/恢复现场 */
push {r0, r1}  // 将当前寄存器的值压栈
mov r0, #0     // 执行一些语句,可能会改变寄存器值
pop {r0, r1}   // 出栈恢复寄存器值

/* 栈的一些用法: 
   1. 在调用函数前,将 reg 值压栈,防止子函数覆盖掉 reg 值,返回后再出栈
   2. 在子函数中压栈,在主函数中出栈,可获取子函数的返回值*/

由于视频这里只是简单描述,还是参考azeria进一步了解

栈是块内存区域,当进程创建时会在栈中分配一块内存空间。常存放函数的全局变量、局部变量、返回值等。另外上面的 PUSH、POP 只是栈操作的别名,而非正真的指令

栈的实现方式两个维度:1. 栈顶指针可向下(descending)还是向上(ascending)增长,2. 通过判断 SP 当前指向栈内数据(full)还是栈外空数据(empty)

由此可组合成四种实现方式:满递增、满递减、空递增、空递减

stack_implements

由栈的实现再结合 Load/Store 完成多数据存取,见下表

stack_implements_ls

其中,FD(full descending)、FA、ED、EA(empty ascending) 后缀用于堆栈操作(以SP作基地址),IA(increment after)、IB、DA、DB(decrement before) 后缀用于数据操作(以寄存器值作基地址)

栈帧:栈中一块含有特殊功能的内存,在函数调用时创建,栈帧指针(Frame Pointer)通常指向栈帧底部,然后分配栈缓冲区来作为栈帧。栈帧包含返回地址(LR)、前一个栈帧指针、一些需要保护的寄存器、函数参数(超过4个)、局部变量等。在函数结束时被销毁

Frame_stack

下面是 C 语言调用函数对应的汇编:

/* C - 找出两数的最大值 */
int main(void)
{
   int a = 1, b = 2, res = 0;
   res = max(a, b);

   return 0;
}

int max(int a, int b) 
{
   return (a > b) ? a : b;
}


/* arm asm */
main:
        stmfd   sp!, {fp, lr}
        add     fp, sp, #4       // 创建 fp
        sub     sp, sp, #16      // 分配栈帧内存
        mov     r3, #1           // 向栈帧中存数据
        str     r3, [fp, #-8]
        mov     r3, #2
        str     r3, [fp, #-12]
        mov     r3, #0
        str     r3, [fp, #-16]
        ldr     r0, [fp, #-8]    // 取数据
        ldr     r1, [fp, #-12]
        bl      max
        str     r0, [fp, #-16]
        mov     r3, #0
        mov     r0, r3
        sub     sp, fp, #4       // 释放栈帧
        ldmfd   sp!, {fp, pc}
max:
        str     fp, [sp, #-4]!
        add     fp, sp, #0
        sub     sp, sp, #12
        str     r0, [fp, #-8]
        str     r1, [fp, #-12]
        ldr     r2, [fp, #-12]
        ldr     r3, [fp, #-8]
        cmp     r2, r3
        movge   r3, r2
        movlt   r3, r3
        mov     r0, r3
        add     sp, fp, #0
        ldmfd   sp!, {fp}
        bx      lr

Frame_stack_process

函数:从上面汇编中,可将函数分为三部分:Prologue(前言), Body(主体), Epilogue(后记)。以 max 函数为例

/* Prologue - 保护前一程序的状态,并创建栈帧 */
str     fp, [sp, #-4]!
add     fp, sp, #0
sub     sp, sp, #12

/* Body - 完成函数功能,初始化参数、比较数值大小 */
str     r0, [fp, #-8]
str     r1, [fp, #-12]
ldr     r2, [fp, #-12]
ldr     r3, [fp, #-8]
cmp     r2, r3
movge   r3, r2
movlt   r3, r3
mov     r0, r3

/* Epilogue - 销毁栈帧,恢复现场,返回到主函数 */
add     sp, fp, #0
ldmfd   sp!, {fp}
bx      lr

值得注意的是,在 Body 中是使用寄存器 r0-r3 来存放函数参数的,若函数参数大于 4 个时,就会将参数存放到栈内存中。另外,mov r0, r3 指将最后函数的返回值放入 r0 中,由于寄存器是 32 bit,若返回值为 64 bit 那么就会用 r1 存放另一半数据。

此外,可根据函数 Body 是否调用其他函数来将函数分为 leaf function(叶子没有分支调用,如 max) 和 no-leaf function(非叶子有分支,如 main)

同时,再来比较下两类函数 Prologue 和 Epilogue 的区别

/* Prologue */
main:
   stmfd   sp!, {fp, lr}
   add     fp, sp, #4      
   sub     sp, sp, #16      
max:
   str     fp, [sp, #-4]!
   add     fp, sp, #0
   sub     sp, sp, #12
/* no-leaf 在保护现场时还将 lr 寄存器压栈 */

/* Epilogue */
main:
   sub     sp, fp, #4       // 释放栈帧
   ldmfd   sp!, {fp, pc}
max:
   add     sp, fp, #0
   ldmfd   sp!, {fp}
   bx      lr
/* leaf 在函数结尾时跳至 lr 以返回到主函数 */

基础的指令就先到这里,指令这种东西跟函数一样记是记不住的,只有遇到了在积累,看的多了就明白了


QEMU 模拟 ARM 环境

通过 QEMU 来模拟 Raspberry 的 ARM 环境,以便更真实地编写汇编。本文是以 Ubuntu 18.04 作为安装环境的系统。

  1. 首先在 2017-04-10-raspbian-jessie 中下载 2017-04-10-raspbian-jessie.zip 压缩包,并使用 unzip 解压得到 img 镜像包

  2. 然后到 dhruvvyas90/qemu-rpi-kernel clone 或下载此库,里面包含多个 kernel 文件,只使用 kernel-qemu-4.4.34-jessie

  3. 在 Ubuntu 上安装 QEMU,使用 sudo apt-get install qemu-system 命令

  4. 使用 qemu-system-arm 命令模拟运行 raspberry,具体命令:qemu-system-arm -kernel ~/..path/kernel-qemu-4.4.34-jessie -cpu arm1176 -m 256 -M versatilepb -serial stdio -append "root=/dev/sda2 rootfstype=ext4 rw" -hda ~/..path/2017-04...jessie.img -net nic -net user,hostfwd=tcp::5022-:22 -no-reboot 该命令含义指:内核和镜像选择下载的,CPU 为 arm1176,内存大小 256m,模拟的虚拟机为 versatilepb,外设串口 stdio,设置 root 及文件系统,配置网络端口 5022

  5. 在开启 raspberry 虚拟机后,打开其终端输入 sudo service ssh start 开启 ssh 服务,另外默认用户名为:pi,密码:raspberry

  6. 在 Ubuntu 中输入 ssh pi@127.0.0.1 -p 5022 进入 raspberry 虚拟机,即可在 Ubuntu 上使用虚拟机


Hello World 编写及 DGB 调试

汇编之 Hello World

前面的汇编都是处理数据,那么是如何处理字符串呢?以及如何让它显示到控制台上?下面就一一解释

/* 汇编程序-在控制台打印 Hello world */
.global _start
_start:
         mov r0, #1        // 设置 file description 为 1,即 stdout
         ldr r1, =message  // 取字符串地址到 r1
         ldr r2, =len      // 取字符串长度到 r2
         mov r7, #4        // syscall-write
         swi 0             // 软中断

         mov r7, #1        // syscall-exit
         swi 0

.data
message:    // 以 ASCII 码形式存储字符串,message 指向该字符串
         .asciz "hello world!\n"
len = .-message   // 获取字符串长度

从上面程序中,能看出 r0、r1、r2、r7 都不是随便设置的,而是有某种含义,其中 r7 从前文中了解有 system call 功能,它的值就是 syscall number,所以根据这点可以查看 system call 表格,具体链接在此arm-32_bit_EABI,找到对应 arm 32bit 对应的 system call table,部分如下图

ARM_syscall

根据表格就能理解 r0、r1、r2、r7 要如此设置了,首先 r7=4 代表调用 syscall-write,调用后 r0 的值就是 write 文件描述符,而在 Linux 中前三个 fd 分别为 stdin(0), stdout(1), stderr(2),所以是向 stdout 写数据;r1 此时就是写字符串的首地址,所以要获取 message 的地址;r2 就是写字符串的长度。swi 0 可能跟系统调用要求有关

在指向完写字符串后,r7=1 表示要调用 syscall-exit 即退出系统调用,可以理解为关闭 stdin 的写字符流,并且此时 r0 的值代表错误码

反过来想可能 C 语言中的 printf("hello world!\n"); 的底层就执行了这些指令

GDB 调试

在编写好上面的 hello world 后,以 hello.s 作为文件名,使用 as 命令对其汇编 as hello.s -g -o hello.o,使用 ld 命令链接 ld hello.o -g -o hello,其中 -g 表示使用 gdb 调试,之前没加上可能会找不到源文件

gdb test       // 使用 gdb 调试 test
gdb test -q    // -q(quite) 关闭 gdb test 的免责申明
break(b) _start       // b/break 在 _start 处打断点
run            // 运行程序到断点处

layout         // 查看布局
layout regs    // 查看所有寄存器
info register r0     // 查看单个寄存器
layout prev/next     // 布局窗口切换

stepi(s)       // 执行一条程序,若有函数调用则进入
next(n)        // 执行一条程序,不进入调用函数内部

x/10x $r1         // 从 r1 值开始以 hex 形式查看 10 个内存地址
x/10d $r1         // 从 r1 值开始以 decimal 形式查看 10 个内存地址
x/10c $r1         // 从 r1 值开始以 char 形式查看 10 个内存地址

最后,再放上 azeria 制作的一览表,以便快速回顾一些 arm 汇编基本知识

cheatsheet