汇编语言概览

汇编语言是最接近机器语言的编程语言,如下图所示,汇编语言介于机器语言和高层语言之间:

内存模型:arm架构的cpu内存结构如下图所示:

  • 寄存器:针对不同类型的cpu最重要的大概就是寄存器了,寄存器是最靠近cpu的存储,其在速度上也最接近cpu,如上图我们可以看到针对该种类型的cpu一共有17个寄存器:
    • r0 ~ r6, r8 ~ r12 用于存放cpu需要操作的数据,这些数据可以是输入数据、输出数据、函数调用的时候栈中的数据
    • r7是一个特殊的寄存器,用于存放中断信号,中断信号的类型有两种:内中断、外中断,内中断是用户程序在运行过程中因为异常(调用一些需要超级权限才可以执行的指令、错误程序)或者用户程序主动的通过系统调用陷入内核态,外中断是基于时钟定时出发的,在一个时钟周期内cpu专心的处理程序,一个周期过了之后检查一下r7确认一下是否有中断产生,如果没有中断就会继续执行用户程序,如果有中断,则cpu会根据中断信号查找中断向量表,从而切换程序的执行。
    • sp叫做stack pointer,是指向堆栈的指针,堆栈在函数调用或者比较复杂的数据结构如list的时候会用到,具体见图:
    • lr叫做link register,这个通常和函数是结合在一起的,lr存放的是函数的地址,当函数执行结束之后,需要继续在lr标记的位置继续往下执行
    • pc叫做program counter,也就是程序计数器,用来指向下一条将要执行的代码
    • cpsr用来存放运算溢出的信息,比如两个数相减,有可能大于0、也有可能小于0,对于小于0 的数,其在寄存器中表征的数也有可能是一个很大的数,因此需要表征其符号,另外对于两个求和运算的整数,其可能会产生进位,这里的进位也需要一个单独的寄存器来存储,这里就是cpsr
    • spsr 用来保存cpsr的状态信息,当有异常的时候可以用来恢复cpsr,不过在实际应用的过程中用的并不多

汇编语言语法

快速开始

接下来通过一个示例来快速的演示一下汇编程序的hello world:

1
2
3
4
5
6
.global _start
_start:
mov r0, #30

mov r7, #1
swi 0

上述代码中.global相当于是将当前文件的代码给export出去了,同时也指示了当前代码开始的位置即 _start,也就是传统编程里面的main函数,对于一般的编程来说,这里的label更多的像是一个函数名称,并且对于一个函数来说比较重要的是要return(void的除外,不过也可以return),这里的return在汇编语言层面上要通过中断产生,也就是这里的swi 0(soft ware interrupt 软中断或者内中断),前面我们知道系统调用的时候会检测r7寄存器的值,由于我们在r7中设置了1,通过查找中断向量表我们知道这是要推出程序的执行,并且是正常退出。 中断向量表是一个枚举的类型,对于不同架构的cpu来说其枚举值必定不同

数据寻址(address data)

  • 立即寻址:立即寻址这种方式是向寄存器中移入一个常量值如:mov r0, #222
  • 直接寻址:直接寻址常见的有寄存器之间的数据传递,或者从stack中移入数据到寄存器,如:mov r0, r1、ldr r0,=list,这里的=list是获取list的地址
  • 间接寻址:在直接寻址中我们在r0中获取到了list的地址,接下来如果我们要获取list地址对应的值就需要ldr r1, [r0]这种方式来讲list的头元素加载到r1中。
    • 前加:当我们需要移动list指针的位置的时候,可以在获取下一个数据之前移动指针,这样获取到的值就是指针指向的下一个元素:ldr r2, [r0, #4]
    • 后加:后加的意思是我们会先获得数据,然后再进行指针的移位操作,这样会导致获取不到最后一个元素:ldr r2, [r0, #4]!、ldr r2, [r0],#4

下面通过一段代码来进行演示一下:

1
2
3
4
5
6
7
.global _start
_start:
ldr r0, =list

.data
list:
.word 1,2,3,4,5

上面我们定义了.data段,这代表的是汇编语言程序的数据段,主要用来存放大量的数据,.data在汇编中是关键词,就像.global一样,可以很明显的看到其和label最大的区别就是后面没有“:”,接下来在data段中我们定一个变量list,变量的名称叫做list,然后我们定义了list的类型.word,值得注意的是汇编语言中的数据类型,.word表征的可以是整数也可以是浮点数,同样的数据类型也还有halfword、doubleworld、byte、bit等。 上面的代码如下,我们可以明显的看到global和data的分界线: 在程序的执行过程中我们可以看到寄存器的状态如下: 我们可以看到寄存器中的值,并根据寄存器中的值查看stack对应的内存区域的数据,如下: 上面我们就见识到了寄存器、栈内存的状态,此时如果我们想要获得对应的值就需要使用间接寻址获得

数学运算

数学运算中包含+ - * /,除法稍微有点复杂,暂不研究,对于 + - *,都需要一个目标寄存器,两个源寄存器,在运算的过程中可能会产生溢出、负值等状态,这种状态就需要一个额外的寄存器CPSR进行存储,不过CPSR状态的操作需要特殊的指令,之所以使用特殊的指令是因为接近于机器的编程对性能的影响很大,能少操作一个寄存器就少操作一个,避免额外的开销。另外CPSR的状态有NZCV,分别代表了N(negtive:负数)、Z(zero 0标记位)、C(carry 进位标记位),V(overflow 溢出)

1
2
3
4
5
6
7
8
9
10
11
12
13
.global _start
_start:
mov r0, #5
mov r1, #7
mov r2, #0xffffffff
// 不会设置cpsr寄存器
sub r3, r0, r1
// 设置cpsr寄存器,s可以理解为set即设置
subs r3, r0, r1
// 不会设置cpsr寄存器
add r4, r0, r2
// ,s可以理解为set即设置
adds r4, r0,r2

上述寄存器状态图如下:

上面我们看到了数学运算的基本操作,如果我们要对cpsr寄存器进行操作的话,是需要使用特殊的指令的,比如我们想要对进位操作进行计算:

1
2
3
4
5
6
7
.global _start
_start:
mov r0, #5
mov r1, #7
mov r2, #0xffffffff
adds r3, r0,r2
adc r4, r0,r1

上面最终计算结果如下: 可以很直观的看到7+5的结果应该是12,对应的16进制就是c,但最终结果r4中显示的是d即13,说明adc讲cpsr中的结果计入了最终运算的结果。

逻辑运算

常用的逻辑运算包含了AND、OR、异或等,比较简单,就不再详细说明了

移位操作

计算机是二进制的,常见的移位操作相较于十进制来说会比较好理解,对于十进制来说左移操作会在原数的基础上乘以10,右移会在原有数据的基础上除以10。同理对于二进制来说右移是除以2,左移是乘以2。对于其他进制来说亦是如此:

1
2
3
4
5
6
7
8
9
10
.global _start
_start:
mov r0, #15
// 在原来寄存器的基础上进行移位操作,改变的是原有寄存器的值
lsl r0, #1
lsr r0, #1
mov r1, #15
// 首先将r1移动到r2中,然后在r2的基础上进行移位操作
mov r2, r1, lsl #1
ror r1, #1

对应的寄存器状态如下: 对于上面的ror的操作是循环右移,其最低位在经过右移之后会出现在最高位上,通常这种操作用在对数据取hash的操作上,而且,并没有rol的操作,因为对于32位的系统来说,ror n次相当于rol 32-n次

条件分支

常见的条件判断指令是cmp,其具体的操作是接受两个参数,然后相减,并将操作的状态信息保存在cpsr中,通过获取cpsr的状态就可以知道最终的结果信息,并且在cmp操作之后通常跟着分支操作,常见的分支有bgt、blt、beq、bne等,其中的b代表的是branch,还有一个特殊的指令bal,代表了无论结果如何都会执行的指令,其中

1
2
3
4
5
6
7
8
9
10
11
12
13
.global _start
_start:
mov r0, #1
mov r1, #2
cmp r0, r1
bgt greater
// bal always
mov r3, #1

greater:
mov r2, #3
always:
mov r2, #4

需要牢记的是,cmp操作之后,会将对应结果存放到cpsr中,因此,接下来的各种跳转操作都会给予最近一次的cpsr中的结果进行判断,如果满足bxx则最终就会陷入分支中去执行,并且最后并不会跳出分支,如上述代码,如果r0大于r1就会跳转到greater分支,并且后续的mov r3的操作也不会再执行了(没有一个返回操作,调用greater的label的操作不是函数调用,不会在函数执行完成之后返回到函数的调用处)。如果最终比较的结果没有任何一个分支满足的话,就会顺序的往下执行,并且如果在后续的执行过程中没有遇到终止的条件的话,会一直执行下面所有的分支,此时并不再是label的调用,就上述代码而言,因为cmp的操作并不满足bgt的结果,因此其首先会跳过bgt的代码,然后顺序执行mov r3、greater、always的操作。注意bal分支只要存在永远是放到代码执行完之后执行。

除了上述BLT等之外,我们还可以使用MOVLT、ADDLT等指令,这些指令是在LT发生的时候执行特定的操作,类似的还有GT等和原有指令进行组合。这是一种简化代码的方式。

循环

在高等计算机编程语言中,我们通常使用的循环类型有for、do -while、while-do类型。下面以do-while类型的循环为例进行相应的求和操作,这里我们预先初始化了list的终止条件,也就是endlist,不过在真正的高等语言中,通常是使用特殊的符号作为终止的标记(毕竟我们无法预知内存中的终止数据究竟是什么),对于下面的循环相较于高等语言来说也是类似的,初始化,进入循环,判断是否终止。这里需要注意的是对于汇编语言中的常量通常是使用类似于宏的定义预先定义好,在家在常量到寄存器的时候要使用ldr的指令,不可以使用mov指令,并且访问数据的时候要用到= ,不可以直接将如数据移入寄存器,具体代码如下:

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
// 循环求list的和
.global _start
// 初始化list的结束条件
.equ endlist, 0xaaaaaaaa
_start:
// 初始化变量,类似于高级编程
ldr r0, =list
// arm处理器加载常量必须要使用这种方式,这种方式并不是加载其地址,就是把他的值加载到制定的寄存器
ldr r3, =endlist
ldr r1, [r0]
add r2, r2, r1
// 这种方式是do - while的循环方式,因为在这之前并没有cmp的操作
loop:
ldr r1, [r0,#4]!
// 这里是比较寄存器中的值是否相等,并不是地址比较
cmp r1, r3
beq exit
add r2, r2, r1
bal loop
exit:
mov r3, r2

.data
list:
.word 1,2,3,4,5,6,7,8,9,10

上述代码最终的执行结果如下: 其在内存中的状态图如下:

函数

函数区别于label,label执行完之后不会返回到调用label的地方继续向下执行,函数则会。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.global _start
_start:

mov r0, #1
mov r1, #2
// 使用这种方式会让程序跳转到label处,这种方式并不是函数,也就是在调用了label的代码后如果后续还有代码的话并不会再执行
//bal addr2
// branch link 这个指令将会存储label调用的下一个地址到lr寄存器中
bl addr2
mov r3, #3

addr2:
add r2, #1
// branch exit 也就是程序退出的时候将pc指针指向lr寄存器的地址,继续执行函数调用完后的代码
bx lr

上述代码在执行的过程中最重要的变化就是lr寄存器的变化: 在label调用执行到bx lr之后,程序会将lr的地址传递给pc进行函数调用之后的执行。

上述函数调用是比较简单的,我们在真正的使用过程中可能还涉及到函数调用前一些状态信息的保存,这里状态主要是指寄存器,因为cpu的寄存器数量有限,我们在函数调用的过程中重复使用外层函数的寄存器,但是这样带来的结果就是寄存器被覆写了,因此我们需要在调用前将寄存器中的数据保留,然后调用函数,在执行完毕之后再恢复状态,这里用到了栈,涉及到对应的寄存器就是sp,也即是stack pointer,演示代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.global _start
_start:
mov r0, #1
mov r1, #2
push {r0, r1}
bl get_value
pop {r0, r1}
b exit

get_value:
mov r0, #2
mov r1, #3
add r2, r0, r1
bx lr

exit:

上述代码对应的寄存器及stack状态示意图如下: 并且如果我们希望得到函数返回值的话,可以进一步的操作是将最终的计算结果push到stack中,并在函数返回的时候pop即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.global _start
_start:
mov r0, #1
mov r1, #2
push {r0, r1}
bl get_value
pop {r2,r0, r1}
b exit

get_value:
mov r0, #2
mov r1, #3
add r2, r0, r1
push {r2}
bx lr

exit:

硬件交互

在线调试环境的右边我们可以看到和arm交互的硬件,对于交互的硬件只有两种类型,输入、输出,接下来我们将选择switch作为输入,led作为输出(可以看到在线仿真的平台有很多可以交互的外设,不过最简单的就是开关和led了,因此我们也以此为例,其他类型的设备需要依赖于具体设备的参考文档了):

1
2
3
4
5
6
7
8
9
10
11
12
13
.equ swth, 0xff200040
.equ led, 0xff200000

.global _start
_start:
// 加载开关位置信息
ldr r0, =swth
// 将开关对应内存信息加载到r1
ldr r1, [r0]
// 加载led的位置信息到r0中
ldr r0, =led
// 将r1中的数据写入到r0所指向的地址内存
str r1, [r0]

实践

环境搭建

下载内核引导文件https://github.com/dhruvvyas90/qemu-rpi-kernel/blob/master/kernel-qemu-4.4.34-jessie,以及操作系统镜像https://downloads.raspberrypi.org/raspbian/images/raspbian-2017-04-10/,这里为方便使用arm编程,因此使用树莓派的操作系统: 运行以下指令启动虚拟操作系统,并指定对应的运行设备:

1
qemu-system-arm -kernel ~/qemu_vms/kernel-qemu-4.4.34-jessie -cpu arm1176 -m 256 -M versatilepb -serial stdio -append "root=/dev/sda2 rootfstype=ext4 rw" -hda ~/qemu_vms/2017-04-10-raspbian-jessie.img -nic user,hostfwd=tcp::5022-:22 -no-reboot

启动树莓派操作系统之后,启动ssh 服务(sudo service ssh start),通过远程连接ssh pi@127.0.0.1 -p 5022连接到机器。

最后通过汇编代码进行测试:

小结

cpu 的核心就是一堆寄存器 加上MCU(计算控制单元,说的直白点就是逻辑门电路,可以针对寄存器进行计算),计算机的核心就是cpu、内存、输入、输出,其中内存用来缓存cpu要用到的数据。

参考网站:

youtube

在线code网址