LA32R汇编编程简介
汇编编程示例
Hello, world!
首先我们先实现一个最经典的例子,即向屏幕中输出"Hello, world!"。在这个例子中,我们可以了解汇编程序的基本结构,掌握函数作为调用者和被调用者需要考虑的寄存器使用规范。
来看看它的c语言描述:
尝试对这个c语言描述进行翻译。
字符串"Hello, world!"需要存储于数据段.data,给其一个可以访问的标号,记为output。
代码部分位于.text代码段。程序的入口地址main必须设置为全局符号。有读者在写其他语言汇编时可能将程序入口地址设置为_start,实际上,系统有_start函数。而系统的_start会首先做初始化工作,之后跳转至main标号处,结束后会进行回收工作。如果我们将用户程序入口地址定义为_start标号,则会将系统的函数覆盖掉。有的链接器允许进行这样的操作。而la32r的链接器则会报告对_start的多重定义错误(multiple definition of`_start'),以及未找到main错误(undefined reference to`main')。因此我们在定义程序入口时,应当定义一个全局的main标号。
考虑到需要调用puts函数,$ra可能被毁,因此需要保存到栈帧中。如果该函数就是叶子函数,则不需要保存$ra。相应地,栈指针只需要减小一个寄存器长度,即4字节。(第6、7行)
在返回到main的调用函数前,需要恢复$ra和$sp。(第11、12行)
函数调用时,针对参数需要考虑两个问题:参数存在哪?存什么内容?puts只有一个参数,根据寄存器使用约定,$a0是第一个参数寄存器,因此存入$a0中。那么$a0中存什么呢?根据
c函数的声明,可以看到它的参数是地址,因此将需要输出的字符串地址通过la.local指令加载到$a0中。(第8、9行)读者可以思考一下,对于printf("%d\n", a);和scanf("%d", &a);参数应该分别是什么形式。
在返回到main的调用函数前,还需要设置main函数的返回值为0,存入返回值寄存器$v0(即$a0)中。(第10、13行)
| hello.S | |
|---|---|
Hello, world! syscall版本
此时我们聚焦于函数的主体功能部分,通过这个例子中,我们可以简单了解系统调用的汇编程序编写方法。
puts在更底层是通过sys_write来实现的。我们可以尝试使用sys_write实现打印功能。首先考察sys_write的参数格式,其他syscall函数我们可以查阅linux源码include/linux/syscalls.h中定义的函数:
第一个参数fd文件描述符,在POSIX语义中,0、1、2这三个值已经被赋予特殊含义,分别是标准输入(STDIN_FILENO),标准输出(STDOUT_FILENO),标准错误(STDERR_FILENO)。其他文件描述符用于描述打开的文件。在本示例中,因为是向屏幕输出,我们设置fd = 1。
第二个参数即为前述output。
第三个参数为字符串长度,这个需要手动计数一下。
参数都确定好之后,我们需要回忆一下系统调用的约定。和函数调用不同,我们需要将系统调用号存放于$a7中,用剩余的$a0 ~ $a6寄存器传递参数。在内核文件include/uapi/asm-generic/unistd.h中查询系统调用号,可知sys_write的系统调用号为64。因此能够写出以下代码,同样达到向屏幕打印的效果。
| hello_sys.S | |
|---|---|
bubble sort
这个示例希望能够增强读者对函数调用过程,寄存器使用规范,以及if/for/双重for控制流的理解。这里先给出它的c语言版本,读者可以尝试写出这段程序的汇编语言版本,并在我们的实验平台上运行以及调试,如遇困难再进行后面的阅读。
这个例子的main函数相对比较复杂。观察到main函数主要完成五部分内容:scanf,rand,printf,bubble_sort,printf。我们逐个击破。
scanf:从标准输入中读取len值
函数调用前解决参数问题:
第一个参数是字符串地址,之后的参数是变量的地址。因此在本例中,我们需要提前定义好字符串"%d"以及len变量,以便于后续得到地址。
rand:根据len值生成len个随机数,存入arr中
单重for循环控制流 我们可以使用分支跳转类指令一节提到的for循环结构,这里再介绍一种结构,可以在循环主体减去一条分支跳转类指令,如果已知循环至少执行一次,那么第3行还能再省去:
然而在实际循环中,仍有两方面需要注意:
-
如果
<do_something>中有函数的调用,那么存放循环计数的寄存器就不能直接用被调用者不保存的寄存器,如$t##、$a##。那么解决方法,可以是将$t##在<do_something>前保存至栈中,在<do_something>后取出使用,该方法会在循环中产生较多访存;也可以考虑使用$s##作为循环计数变量,而代价是使用前需要将旧值存入栈中,以及在该函数返回前将其恢复出来。直观感觉是第二个方法更有利于循环的高效执行。 -
虽然我们在c语言中常使用的循环增量为1,但如果考虑到这个循环计数值我们将用于数组的访问,我们可以根据数据宽度设置循环增量,并且循环初始值、结束条件可以稍作调整,将其进行等价转换:
如果我们用一个寄存器作为循环计数寄存器,那么在获取
arr[i]时,需要完成addr = (uint32_t)&arr[0] + i * 4的计算。我们可以转换成汇编做一个对比。
数组定义 与len类似,数组需要提前在数据段定义才能进行访问。这里我们可以采用之前提到的.rept ... .endr汇编指示来定义50个变量。
函数返回 这里rand是一个无参数但是有返回值的函数,注意返回值存于$a0中。
那么可以开始编写这部分内容。
printf:打印数组值
注意到main中两次printf的操作内容是一样的,我们可以将其提取为一个公共函数:
for循环的写法与注意事项已在前一小节提及,这里再强调一下printf的写法。
可以看到printf包含两个参数,字符串"%d "和整形变量a,字符串变量需在数据段定义。整形变量即为数组第i项的值(注意不是地址),此处涉及访存。
| bubble_sort.S: main: output_data | |
|---|---|
bubble_sort:冒泡排序
这是一个双重for循环的控制流,在编写for循环时,我们需要想清楚初始值是什么,终止条件是什么,循环增量怎么设置,循环主体要做什么。
前面我们提到,出于访问数组的便捷性考虑,我们会将循环计数变量改造为每次访问的地址,这里进行相同的改造:
注意到这里循环内部没有函数调用,所以我们可以放心大胆地使用$t##和$a##。
分析一下寄存器分配方案:
-
addr_j每次都从&array[0]开始,所以必须存&array[0],$a0作为参数天然存了这个值。 -
addr_i要与终止条件&array[len - 1]判断,因此要存&array[len - 1],$a1原本存len,经过(len - 1) << 2 + $a0的变换可以得到,直接存于$a1中即可。 -
存储
addr_i需要一个寄存器,记为$a2;存储addr_j需要一个寄存器,记为$t0。 -
addr_j的终止值不用每次都计算,可以在每次外层循环更新变量时更新内层循环终止值,初始设为$a1,每次减4即可,记为$t1。 -
需要获取
addr_j和addr_j + 4地址处的值,分别记为$t2和$t3。
据此,可以设计bubble_sort的代码了。
完整汇编程序
这里给读者提供一个能够正确实现bubble sort的汇编代码示例,仅供参考。
| bubble_sort.S | |
|---|---|
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 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | |