MIPS指令集:寄存器

时间:2024-04-07 19:06:25

   不同的计算机架构中寄存器的种类和数量也不相同。MIPSmips中用到的寄存器按照功能分为有通用寄存器、协处理器0、浮点寄存器、乘法部件寄存器。通用寄存器共32个,是没有特殊限制,一般程序员可以使用的寄存器。协处理器0寄存器也叫控制寄存器,共32个,用来控制并管理CPU。浮点寄存器和乘法部件寄存器都是专用寄存器。浮点寄存器也叫协处理器1寄存器,共32个,用来存储和浮点计算相关的数据。乘法部件寄存器共2个,用来存储和乘除法相关的数据。
   寄存器的长度是由机器字长决定的,机器字长是指计算机进行一次整数运算所能处理的二 进制数据的位数。龙芯3A/3B系列计算机的字长为64位,所以寄存器的长度也是64位。也叫64位寄存器。

一、 32个通用寄存器

   MIPS可供程序使用的通用寄存器(general-purpose register 简称GPR)共32个,编号从$0-$31。各个通用寄存器的别名和功能如表1所示。

表1 n64通用寄存器的别名和功能
MIPS指令集:寄存器
n64代表的是用于64位处理器上的MIPS ABI。在表1中列出了n64上的32个通用寄存器的别名和功能简介。


1.1 MIPS ABI

   ABI(Application Binary Interface):应用程序二进制接口。描述了应用程序与操作系统之间的底层接口,包括目标文件格式、数据类型、数据对齐方式、函数调用约定(应用程序如何使用内存地址和使用寄存器的约定)等。MIPS历史上重要的3个ABI如下:

  • o32 “o”代表old 。传统的32位处理器的MIPS ABI约定。指针和long类型为32位。
  • n64 “n”代表new。新的用于64位处理器的MIPS ABI约定。新的ABI约定了指针和long类型都为64位。同时改变了寄存器和参数传递的规则。将更多的参数用寄存器传递(a0-a8)。
  • n32 “n”代表new。用于64位处理器上兼容32位程序。指针和long类型还是使用32位。

1.2 寄存器别名

   为了便于记忆和阅读,每个寄存器都有一个别名。别名多以寄存器功能的英文首字母+数字或字母缩写表示。例如寄存器2的别名a0开头的a代表arguments。寄存器29的别名sp代表stack pointer 。在实际汇编语言编写过程中,我们也更推荐使用寄存器的别名方式,使用MIPS别名时需要包含头文件regdef.h(例如 #include <sys/regdef.h>),里面已经定义好了寄存器编号和别名的对应。下面这两句指令是完全相等的。
    daddiu $29,$29,32
    daddiu sp,sp,32

    在MIPS指令中使用任何寄存器编号时都要加上符号”$”。上述两条指令是完全相同的,都是实现$29+32,结果存入$29的功能。使用$29和sp是完全相同的。不过使用sp更让我们是在对堆栈指针的操作。


1.3通用寄存器功能介绍
  • 伪指令与寄存器zero、at

   寄存器zero($0)是常量寄存器,即不管存入什么值,永远返回0。其对我们平时的汇编语言编写用处不大,但是对一些合成指令的作用还是很大,它为我们提供了一种更简洁的编码方式。比如MIPS中常常用到的一些伪指令:

   //伪指令     ->     //汇编指令

   move t0,t1        or t0,t1,zero

   简单解释一下伪指令。伪指令就是为了方便软件编程,由编译器定义的命令,在编译时转换为CPU实际执行的机器指令 。也就是说伪指令并不是CPU最终可执行的机器指令,而是给汇编器看的指令。汇编器负责将伪指令翻译成正式的机器指令。有的地方称伪指令为合成指或宏指令。

   MIPS汇编中没有两个寄存器之间数据拷贝的指令。要实现这个功能可以通过or指令实现。上面的汇编指令or t0,t1,zero 意思是寄存器t1和寄存器zero进行或运算 t1 | zero,结果存入t0。寄存器zero里面值都为0。所以t1|zero的结果还是t1,然后存入t0。这就实现了一个寄存器t1到另一个寄存器t0的数据拷贝。但是这个写法还不是很直观,从“or”名字上很难看出这是个拷贝功能。所以汇编器对此实现了与此功能相同的伪指令move,要实现寄存器t1到t0的拷贝,只要写成move t0,t1即可。伪指令move t0,t1就是告诉汇编器拷贝寄存器t1的值到寄存器t0。汇编器帮助我们把这条指令翻译成CPU可识别和执行的机器指令or t0,t1,zero。

   通用寄存器at(assembly temporary),为汇编器所保留。基本可以认为通用寄存器at是伪指令的中间变量。之前提到过I型指令的立即数字段只有16位,所以在加载大常数(大于16位的数)时,编译器或汇编程序需要把大常数拆开,然后重新组合到寄存器里。比如加载一个32位立即数需要 lui(装入高位立即数)和addi两条指令。这个过程由汇编程序来完成。这时汇编器就需要一个临时寄存器at来重新组合大常数。

   既然寄存器at是为汇编器所保留的,那么我们在编写汇编程序时就要注意避免使用此寄存器。当然如果你确实想在自己的汇编程序中使用这个寄存器,可以通过伪指令.set noat来通知汇编器。但是这样一来汇编器中用at做中间变量的宏指令就不能再使用了,比如指令rol(循环左移)、指令rem(有符号整数除余)、指令jal(跳转)等。

  • 函数调用与寄存器v0、v1、a0-a7、ra
       所有的高级语言中都有子程序或者函数这个概念。子程序或者函数就是可以独立实现一个特定功能的程序块。在这里我更愿意使用函数。函数基本由返回值、函数名、参数、函数体组成。格式如下:

   返回值类型 函数名称(参数列表){函数体};

   在使用一个函数时需要关心的是参数、返回值和返回地址。MIPS n64约定函数调用时使用寄存器a0至寄存器a7来传递前8个参数,用寄存器v0存放子程序的返回值(整数或者指针),寄存器v1保留,用寄存器ra保存返回地址。

   比如我们编写一个有2个整型参数,一个整型返回值子程序的c语言代码如下:
      int ret = add(2,3); //c语言代码

   经过编译后对应的汇编指令如下:

   li a0,0x2
   li a1,0x3
   bal add
   nop
   sw v0,32(gp)

   第1行和第2行分别将两个整型参数2、3存入寄存器a0、a1。第3行的bal指令完成跳转到函数add。第4行的nop是空指令,意思是什么都不做,充当延迟槽作用。指令bal实现到函数add的跳转,在跳转到add之前,指令bal会负责保存返回地址(指令sw 所在位置)到寄存器ra($31)。这样在函数add返回可以通过jr ra完成。汇编器把变量ret的地址保存在gp+32的位置。那么就可以通过最后的sw指令把返回值v0写到ret。


  • 临时寄存器t0-t3、t8、t9

   通用寄存器t0至t3和t8、t9共6个寄存器在子程序中充当临时变量的作用,这里”t”表示temporaries。临时变量就是在子程序中使用的变量。子程序结束后,这几个寄存器的值就无效。所以一个程序中不必保存而*使用这6个寄存器。其中,t9($25)经常被汇编器用来保存子程序的地址,然后执行子程序跳转功能,类似于如下写法:

    jalr t9

指令jalr t9意思是跳转到寄存器t9所存储的地址。


  • 寄存器变量s0-s7

    s0-s7中的”s”代表saved。这8个寄存器是保存寄存器,如果程序中使用了这组里面的寄存器,在发生函数调用之前需要对该寄存器做保存。也称子程序寄存器变量。这里解释一下什么是寄存器变量。通常我们的程序是保存在内存或者外存上的,需要时才会加载到寄存器。如果一个变量在程序中频繁使用,例如循环变量,那么,系统就必须多次访问内存中的该单元,影响程序的执行效率。因此,C语言\C++语言还定义了一种变量,不是保存在内存上,而是直接存储在CPU中的寄存器中,这种变量称为寄存器变量。寄存器变量的定义格式为 “register 类型标识符 变量名”。例如我们使用c语言定义如下两个寄存器变量:

    register int a=2;
    register int b=3;

上面两行语句经过gcc编译后,对应的汇编指令如下:

    li s1,2
    li s0,3

    上面的2条汇编指令li是加载立即数(常数)到寄存器s1和s0。使用时就无需去内存加载。s0-s7在使用上和t0-t9恰恰相反,s0-s7在子程序的执行过程中,需要将它们存储在堆栈里,并在子程序结束前恢复。从而在调用函数看来这些寄存器的值没有变化。


  • 系统保留寄存器k0、k1

   k0和k1是为系统所保留,专门保留给系统发生中断时程序使用的寄存器。这里的”k”代表keep。中断是指计算机运行过程中,出现某些意外情况需主机干预时,机器能自动停止正在运行的程序并转入处理新情况的程序,处理完毕后又返回原被暂停的程序继续运行。

   MIPS架构的系统在这个过程中就是通过k0,k1引用一段可以保存其他寄存器的内存空间来实现正在运行程序的环境保存。关键汇编程序如下:

   mfc0 k1,CP0_CAUSE
   andi k1,k1,0x7c
   ld k0,exception_handlers(k1)
   jr k0

   上述指令中,首先通过指令mfc0把控制寄存器CP0_CAUSE值拷贝到通用寄存器k1。CP0_CAUSE保存了这次中断发生的原因。用andi指令把k1的值加上0x7c,然后用指令ld加载具体一个中断处理程序的入口地址exception_handlers(k1)。exception_handlers()记录了很多中断处理程序,根据k1的值找到当前系统需要执行哪个中断处理程序。然后使用指令jr跳转到这个中断处理程序。exception_handlers(k1)里将完成被中断的程序环境保存。

备注:这段指令引自内核代码genex.S


  • 寻址寄存器gp

   通用寄存器gp可以看做是为汇编器保留。gp代表global pointer,用于快速的存取static和extern类型的变量。
   MIPS指令集中,加载指令都是I-型指令,也就是用16位来存储地址偏移量。所以要对一个常数或者变量地址的加载最少需要2条指令才能完成。比如要加载32位的变量地址就先需要一条指令加载变量地址的高16位到临时寄存器(通常是通用寄存器at),然后是一条以该变量地址的低16位为偏移量的加载指令。例如:

   lui at,%hi(addr)
   lw v0,%lo(addr)(at)

   上面两条指令中lui at,%hi(addr)就代表了取变量addr的高16位,赋值给at。%lo(addr)就是取变量addr的低16位做偏移量,at为基址。%lo(addr)(at)相当于at+%lo(addr)。指令lw 就完成了addr地址加载到寄存器v0。

   c语言程序中包含了很多static和extern类型的变量。对这些类型变量的加载和存储需要至少2条指令来完成确实开销很大。对此编译器利用寄存器gp来做了优化,可以使用1条指令完成对这类变量的加载和存储。做法编译器会统一把static和extern类型变量放在一个64KB大小的内存区域。然后让寄存器gp指向这块内存区域的中间位置,接下来的变量寻址都以gp为基址,加上特定偏移量就可定位到某个变量所在地址并完成加载或者存储。例如:

   lw v0,offset(gp)

gp+offset就可以定位到一个static或extern类型变量的地址,然后通过指令lw加载到寄存器v0。

注意:这里的变量不包括函数内部定义的局部变量。函数内部的局部变量是通过寄存器或堆栈实现,不需要gp。


  • 函数栈和寄存器sp、fp

   在计算机领域,栈(stack)是允许在同一端进行插入和删除操作的动态存储空间。它按照先进后出的原则存储数据,先进入的数据被压在栈底,最后的数据在栈顶。函数栈就是在程序运行时动态分配,用来保存一个函数调用时需要维护的信息。这些信息包括函数的返回地址和参数,临时变量、栈位置。一个典型的函数栈如图1所示:

图1:MIPS函数栈示例
MIPS指令集:寄存器

   MIPS架构中,栈是向下增长的,也就是栈底在高地址,栈顶在低地址。在图3-2中,GPR[sp]代表通用寄存器sp($29)指向栈顶,又称sp为栈指针(stack pointer)。每次函数开始的时候sp都会向下移动n字节,sp预先指定了一块存储区域。这n个字节就是此函数的栈空间。这里要求n要求必须是16的倍数。典型的分配函数栈指令:

   daddiu sp,sp,-48

   指令daddiu是带常数的加法指令,上面指令相当于c语言表达式sp = sp-48。相当于把sp指针向下移动48字节,也就是申请一个48字节的栈。当函数返回时就通过sp指针向上移动48字节恢复栈,指令如下:

   daddiu sp,sp,48

   通常通过sp栈指针分配空间后,我们就可以根据需要操作函数信息的保存和恢复。一般都会先把通用寄存器ra和fp入栈,如果有gp的操作,那么也会把gp入栈。当此函数要调用其他函数时,那么此函数内部的局部变量、寄存器变量、参数等也要做入栈保存。

   栈空间的分配是以进程为单位的。进程是系统进行资源分配和调度的基本单位。系统会在进程启动时指定一个固定大小的栈空间,用于该进程的函数参数和局部变量的存储。sp的初始值就指向了这个固定大小栈的栈底。该进程中的每一次函数调用,都会通过sp指针的移动来为函数在此空间划分出一块空间用作函数栈,sp指向栈顶。函数栈又被称作栈帧(stack frame),用通用寄存器fp($30)指向当前函数栈的栈底。fp又被称作帧指针(frame pointer)。一个进程典型的栈空间如图3-3所示。

   每次调用一个函数,都要为该次调用的函数实例分配栈空间,用来局部变量的分配和释放传递函数参数,存储返回值信息,保存寄存器、以供恢复调用前处理机状态。mips下栈空间采用的是向下增长的方式(上面为高地址,下面为低地址)。SP(stack pointer) 就是当前函数的栈指针,它指向的是栈底的位置。
   进入一个函数时需要将当前栈指针向下移动 n 字节,这个大小为n字节的存储空间就是此函数的栈的存储区域,然后在函数返回时再将栈指针加上这个偏移量恢复栈现场。例如下面函数部分指令:
//通过objdump工具 获取到的main函数

0000000120000b38 :
120000b38: 67bdffd0 daddiu sp,sp,-48 //开辟48字节空间的栈
120000b3c: ebbe00bf gssq ra,s8,32(sp) //ra,s8寄存器值入栈
120000b40: ffbc0018 sd gp,24(sp) // gp寄存器值入栈

120000ba0: 67bd0030 daddiu sp,sp,48 //恢复栈现场
120000ba4: 03e00008 jr ra //函数返回
120000ba8: 00000000 nop

   从上面的例子来看,我们似乎只用一个sp寄存器就管理了整个函数栈,完全不需要fp(s8)寄存器。那么fp什么时候会用到呢?
   首先我们要知道每个进程都有自己的栈。所有函数都在这同一个栈上分配空间来存储和本函数相关的信息。每个函数所使用的那部分空间就叫栈针(stack frame)。sp和fp 就限定了每个函数的栈边界,如图2所示。

图2:MIPS进程栈示例
MIPS指令集:寄存器

   在图2中,所有的函数栈都是在当前进程的栈空间中按照向下增长的方式分配出来的一段空间。有了fp和sp就界定了每个函数栈的边界。每个函数栈内部的fp都指向了调用者函数的栈顶。fp的好处就在于当我们需要回溯函数调用关系或者动态堆栈管理时,通过当前函数里的sp和fp,就可以得到上一个函数的sp和fp,以此类推直到第一个函数。


二、 整数乘法寄存器HI、LO

   MIPS中和整数乘除法相关的寄存器有两个:HI寄存器和LO寄存器。在64位处理器上,对于两个32位做乘法运算后,可以产生64位结果。可以临时将结果的低32位放在LO寄存器,高32位放在HI寄存器。例如下面的指令:

   mult t0,t1
   mflo a4
   mfhi a5

   上面的指令”mult t0,t1”实现的是寄存器t0和寄存器t1的乘法操作,结果的低32位存放在寄存器LO,结果的高32位存放在寄存器HI。mult中的mul代表乘法,t代表临时存储(temply)。HI和LO是特殊的寄存器,不允许程序直接使用,里面的结果要通过指令mflo和mfhi获取。“mflo a4”就是拷贝寄存器LO的值到通用寄存器a4,“mfhi a5”就是拷贝寄存器HI的值到通用寄存器a5。
   HI和LO寄存器也用于除法运算结果的临时保存。除法运算结果商临时存放在寄存器LO,余数存放在寄存器HI。使用实例如下:

   div t0,t1
   mflo a4
   mfhi a5

   上面指令“div t0,t1”实现的是寄存器t0和t1的除法运算,运算结果的商存放在寄存器LO,余数存放在寄存器HI。类似于LO = t0/t1,HI = t0%t1。在HI和LO内的数据还是要通过指令mflo和指令mfhi拷贝到通用寄存器才可以使用。


三、 协处理器CP0

   在MIPS体系结构中,可支持多个协处理器(Co-Processor)。其中,协处理器0(简称CP0)是体系结构中必须实现的。CP0就是系统控制处理器,它起到控制CPU的作用,比如CPU配置、高速缓存控制、异常中断控制、存储单元控制、定时器、错误检测等。CP0包含32个寄存器,本节中只介绍有助于我们调试程序的几个关键寄存器。

注意:和CP0相关的寄存器和指令在用户态是没有权限使用的。用户态指的是非特权状态,在此状态下,执行的代码被硬件限定而不能进行某些操作。与此相对的是内核态(特权状态),在此状态下程序可以进行任何操作。通常我们的应用程序都是工作在用户态。


3.1 EPC寄存器

   异常程序计数器(Exception Program Counter 简称EPC)。EPC是一个 64 位可读写寄存器,其存储了异常处理完成后继续开始执行的指令的 地址。在MIPS体系架构中,中断、自陷、系统调用、程序错误等事件都称为异常。回到我们第一章程序崩溃时捕获的例子:

potentially unexpected fatal signal 8.
CPU: 1 PID: 10132 Comm: exception Not tainted 3.10.84-22.fc21.loongson.10.mips64el #1

Hardware name: /Loongson-3A5-780E-1w-V1.1-demo, BIOS Loongson-PMON-V3.3-\x9c\x9f\xffffffe4\xffffffb8
task: 980000017d3e6d00 ti: 9800000174afc000 task.ti: 9800000174afc000
$ 0 : 0000000000000000 0000000000000001 0000000000000000 000000000000000a
$ 4 : 0000000000000001 000000ffff8303e8 000000ffff8303f8 0000000000000000
$ 8 : 000000ffee7f3820 000000ffee81fbe8 000000ffff8303e0 0080000000000000
$12 : 000000ffee631140 0000000000000003 00000000f63d4e2e 000000ffee841e28
$16 : 000000ffee7f1cc8 0000000120000b90 0000000000000000 0000000000000000
$20 : 0000000126d8ad60 0000000126c96170 0000000000000000 0000000120158008
$24 : 0000000000000000 0000000120000ad0
$28 : 0000000120019010 000000ffff830260 000000ffff830260 0000000120000b78
Hi : 0000000000000000
Lo : 0000000000000000
epc : 0000000120000b04 0x120000b04
Not tainted
ra : 0000000120000b78 0x120000b78
Status: e400ccf3 KX SX UX USER EXL IE
Cause : 10000034
PrId : 0014630d (ICT Loongson-3)

   这里面“PID: 10132”表明此程序运行的进程号为10132。从$0至$28行分别记录了发生异常时32个通用寄存器的值。Hi和Lo记录了发生异常时的寄存器HI和LO的数据结果。“unexpected fatal signal 8”可知引起此次程序异常的信号值为8,对应的信号名可以通过/usr/include/asm/signal.h里查找到8即为 SIGFPE(浮点数异常)。这里EPC的值为0x120000b04,通过objdump反汇编程序后,找到0x120000b04附近的指令如下:

0000000120000ad0 <test>:
120000ad0: 67bdffd0 daddiu sp,sp,-48
120000ad4: ebbe00bf gssq ra,s8,32(sp)
120000ad8: ffbc0018 sd gp,24(sp)

120000afc: 8fc20004 lw v0,4(s8)
120000b00: 0062001a div zero,v1,v0
120000b04: 004001f4 teq v0,zero,0x7
120000b08: 00001810 mfhi v1
120000b0c: 00001012 mflo v0
   这就可以看出异常发生在test函数内。发生异常的是一条除0指令“div zero,v1,v0”。注意异常指令执行后,EPC已经指向下一条指令。此时你就可以去程序代码中找原因去了。上述异常我使用的C语言实例如下:
void test(){
int a = 10,b = 0;
int c = a/b; //非法除0操作
}


3.2 无效地址寄存器BadVaddr

   BadVAddr 寄存器是一个64位只读寄存器,这个寄存器保存引发异常的地址。如果程序中发生了非法或无效地址访问、地址没有正确对齐时,该寄存器都会被设置。比如我故意编写了一段地址错误的语句:
    int* ep = NULL;
    int c = *ep; //无效地址访问

包含这段语句的程序编译运行后会报错如下:

potentially unexpected fatal signal 11.
CPU: 0 PID: 13610 Comm: exception Not tainted 3.10.84-22.fc21.loongson.10.mips64el #1

epc : 0000000120000ac4 0x120000ac4
Not tainted
ra : 0000000120000b0c 0x120000b0c
Status: e400ccf3 KX SX UX USER EXL IE
Cause : 10000008
BadVA : 0000000000000000
PrId : 0014630d (ICT Loongson-3)

这里“unexpected fatal signal 11”指明了异常信号为SIGSEGV。“BadVA : 0000000000000000”异常地址为0。也就是NULL指针,出错位置通过epc可以看出在0x120000ac4


四、 浮点寄存器

    浮点寄存器也称协处理器1(Co-Processor 1 简称CP1)。MIPS 拥有32个浮点寄存器,记为f0f0-f31。每个浮点寄存器为64位。这32个浮点寄存器在使用约定(ABI)上和通用寄存器一样,也有自己一套说明。如表2所示:

表2:n64 浮点寄存器 ABI

浮点寄存器编号 功能简介
f0,f0,f2 用作函数返回值
f12f12-f19 用作传递参数
f24f24-f31 寄存器变量,发生函数调用时要保存
f1f1、f3-f11f11、f20-$23 用作临时变量

   在表2中,已经对n64的浮点寄存器使用约定做了简单介绍。功能的分配基本上和通用寄存器相同。比如一个函数返回值为整数或指针时,使用通用寄存器的v0,返回值为浮点类型时,就使用浮点寄存器f0。函数调用时的参数是整型就用通用寄存器a0-a7传递,如果是浮点类型就用f12f12-f19传递。对于函数返回地址、栈指针等还是使用通用寄存器的ra、sp。

   比如我们要实现两个浮点数的加法运算,用c语言实现就是:

   float c = fa+fb;

   同样的功能,对应的汇编指令如下:

   add.s f0,f0,f0,$f1

   指令add.s实现的是对两个浮点寄存器的加法操作。上面的“add.s f0,f0,f0,f1f1”意思是浮点寄存器f0和f1f1的加法运算,结果存入f0。相当于f0=f0=f0+$f1。由于浮点运算的性能和功耗会低于整数,平时使用又不是很多,所以浮点寄存器的使用不做展开介绍,阅读mips汇编代码时能认出浮点寄存器就可以了。