一、汇编语言:assembly language(ASM)
1.一种低级编程语言,其中程序指令与特定架构的操作相匹配( Instruction Set Architecture,ISA,指令集架构)。每个架构都会有它支持的一组不同的操作(尽管有很多相似之处)。汇编不能移植到其他体系结构(如 C)
2.将程序拆分为许多小指令,每个指令执行该过程的一个部分
3.主流指令集架构:
(1)Intel:Macbooks & PCs(x86指令集,属于复杂指令集CISC)
(2)ARM:智能手机类设备(iPhone、Android)、嵌入式系统(ARM指令集,属于精简指令集RISC)
(3)RISC-V:多功能且开源相对较新,专为云计算、嵌入式系统、学术用途而设计(RISC-V指令集属于精简指令集RISC)
4.指令集架构的发展:从复杂指令集(CISC)到精简指令集(RISC)
早期的发展趋势是添加越来越多的指令进行精细化操作,但带来的副作用就是难以学习和理解,并且在复杂的硬件上运行得更慢。
如今,相反的理念开始占据主流,更简单、更小的指令集更容易构建速度快的硬件,通过简单的操作的组合来实现复杂的操作
二、寄存器
1.汇编语言没有变量
2.汇编语言用寄存器存储数据
3.寄存器特性如下:
– 固定大小的小存储器(在我们的系统中为 32 位) – 可以读或写 – 数量有限(我们系统上有 32 个寄存器) – 访问速度非常快且功耗低
4.如果变量数量大于寄存器数量,就把最常用的变量放在寄存器,其余的放在内存。越小的器件运行得越快,寄存器的速度是内存的100-500倍。
内存的大致结构:
- 寄存器:32×32 = 128 bytes
- 随机存取存储器(Random Access Memory,RAM):4-32 GB
- SSD:100-1000 GB
5.寄存器的数量:寄存器越多,能存储的变量就越多;但过度的寄存器会导致速度变慢
RISC-V有32个寄存器(x0-x31),每个寄存器都是32位/一个字(word)
注意:一个字是一个固定大小的数据块,并作为一个单元被处理器的指令集或硬件所处理。 通常一个字被定义为CPU寄存器的大小。
以下为RISC-V的寄存器的编号、名称与描述:
寄存器可以被其编号(x0-x31)或名称引用,其中名称s开头的寄存器(save register)用来保存程序变量,t开头的寄存器(temporary register)用来保存临时变量
6.寄存器没有类型,对寄存器的操作决定寄存器如何被处理
7.zero寄存器(x0):值永远为0,并且无法改变,任何改变其值的操作都是无效的;zero在程序中使用频率很高
8.计算机的大致组成:
寄存器是datapath数据路径的一部分
三、RISC-V汇编指令
0.前言:RISC-V汇编指令分为几大类型:计算指令、存储器指令(与内存传输数据)、分支指令、汇编器指令
1.语法:op dst, src1, src2
op(operation):操作名称
dst(desination):接收结果的寄存器
src1(source1):操作的第一个寄存器
src2(source2):操作的第二个寄存器
每个指令一个操作,每行一个指令
注释用‘#’
标签:名称任意选取,标记着一段代码的地址
2.基本算数指令
add
sub
3.立即数指令:opi dst, src, imm
立即数是指在指令中直接提供的数值常量,而不是通过寄存器或内存地址间接获取的数据
opi指以i结尾的指令,把第二个源寄存器替换为一个立即数
4.数据传输:利用数据传输指令在寄存器和内存之间移动数据
(1)必要知识
store:把数据从寄存器传输并存储在内存
load:从内存加载数据并存储到寄存器
语法结构:memop reg, off(bAddr)
- memop(memory operation):数据传输指令的名称
- reg:操作的寄存器,用来存储内存的数据或为内存提供数据
- bAddr(base Address):基址,是一个值为内存指针(某地址)的寄存器
- off(offset):偏移量,是立即数,单位为字节
获取的内存位置=基址+偏移量
- 注意:寄存器存储着并没有类型的原始数据,确保使用正确指向内存地址的寄存器
最小的数据类型为char,1个字节,因此任意类型的大小都是一个字节即8bits的整数倍,内存是按字节寻址的
-
字(word)地址间隔 4 个字节
-
字的地址就是第一个字节的地址
-
Addrs 必须是 4 的倍数才能“字对齐”
-
汇编中没有为您完成指针的计算,必须自己考虑数据大小
(2)指令集
加载字:lw(load word)
存储字:sw(save word)
• 举例: (address of int array[] -> s3, value of b-> s2)
C: array[10] = array[3]+b;
Assembly:
lw t0,12(s3) # t0=A[3]
add t0,s2,t0 # t0=A[3]+b
sw t0,40(s3) # A[10]=A[3]+b
(3)利用汇编器指令(.开头)在内存存储数据
.data表示数据段
.text表示代码段
在数据段中申请空间格式:
label: .word/byte/…… value1 [, value2, ...]
label为标签名,用于数据的地址
value为1~n个数据,按照声明的顺序存储在程序的数据段中
(4)符号扩展、零扩展
对于汇编语言而言,没有类型的概念,只有地址和数据,而没有类型的概念也就不知道数据应该是多大,数据表示什么完全取决于你怎么读取信息。
而我们已经知道,RISC-V的寄存器为32位,如果我们使用lw命令从内存读取1个word的数据,该数据是恰好能装满寄存器的,而假如我们想读取1个byte或者2个byte的数据呢?此时数据是装不满寄存器的,但是寄存器一次加载的动作是要求把位填满的,那么高位我们应该填什么呢?这就引出了“扩展”的概念
- 符号扩展(sign extension):取出数据的最高有效位(msb,most significant bit)并将其填充到高位
- 零扩展(zero pad):高位全部填0
而RISC-V指令集在需要时都会采用符号扩展
(5)加载/存储字节、半字(half word)
语法规则与lw、sw相同,只是操作名称改为lb(load byte)、sb(save byte)、lh(load half word)、sh(save half word)
eg:s0 = 0x00000180,
lb s1,1(s0) # s1=0x00000001
lb s2,0(s0) # s2=0xFFFFFF80
sb s2,2(s0) # *(s0)=0x00800180
而寄存器把数据存储到内存是不需要扩展的,因为内存中的数据有相应的读取方式,不需要你去修改数据前面的前缀,而且这样可能导致错误
例如4个字节32位,里面装了4个char类型的数据,如果你在把其中的char数据存储在内存中时修改这个数据前面的位的话就会破坏前面的数据
(6)无符号加载/存储:lbu(load byte unsigned)、lhu(load half word unsigned)
没有lwu指令,因为一个word32位正好与寄存器的大小相等,不需要进行扩展
5.控制流指令
beq
bne
blt
bge
j
6.扩展指令:并非所有RISCV的CPU都支持,但大多数支持的指令
mul(multiply):乘法运算,只取低位的32位,忽略溢出32位的部分
mulh(multiply high):乘法运算,只取高位的32位,忽略低位32位
div(division):除法运算,只取整数部分忽略余数
rem(remainder):取余运算
7.按位运算指令
and
andi
or
ori
xor
xori
8.比较指令
slt(set less than):slt dst, reg1,reg2
- If value in reg1 < value in reg2, dst = 1, else 0
slti(set less than imm):slti dst, reg1,imm
- If value in reg1 < imm, dst = 1, else 0
9.ecall指令
ecall是程序与操作系统交互的一种方式
要使用环境调用,请将 ID 加载到寄存器 a0 中,并将任何参数加载到 a1 - a7 中。 任何返回值都将存储在参数寄存器中
寄存器a0中的值(ID)被赋予操作系统来执行特殊功能,如打印数据、终止程序等
以下是常见的ID:
10.立即数的位数取决于指令的类型,通常为12/20位
立即数在计算指令中会自动发生符号扩展
eg:addi s0, s0, 0x800 相当于s0=s0+0xFFFFF800
11.伪指令
是一种在编译器层面上提供的语法糖,它们不是硬件实现的指令,而是被编译器转换成一系列真实的指令。伪指令的存在是为了提高代码的可读性、简化编程、或者实现特定的功能
mv(move):move dst,reg1
- translates into:addi dst,reg1,0
li(load immediate):li dst, imm
translate to:
- lui dst, imm高20位 # 将立即数的高20位加载到目标寄存器中 (imm[12]如果为0,则imm高20位=imm[31:12],imm[12]如果为1,则imm高20位=imm[31:12]+1,这是因为addi imm低的时候会发生sign extended)
- addi dst, dst, imm低12位 # 将立即数的低12位加到目标寄存器中
la(load address):la dst, label
translate to:
- auipc dst, offset to label[31:12]
- addi dst, dst, offset to label[11:0]
这里的原理与li指令相同
nop(no operation)
- addi x0, x0, 0
四、函数调用
1.调用函数的6个步骤:
- (1)把参数放在函数可以接触到的地方
- (2)把控制转移到函数
- (3)函数获取其需要的存储空间(栈帧)
- (4)函数执行功能
- (5)把返回值放在可接触到的地方然后释放空间
- (6)控制转移回调用函数的地方
2.函数参数与返回值
- 因为寄存器比内存的速度快,所以我们尽可能把这些值放在寄存器
- a0-a7:8个用来传参的寄存器
- a0-a1:2个用来传递返回值的寄存器
- 参数的顺序/规划很重要
- 如果需要额外的空间,使用内存的栈区(用sp寄存器,sp寄存器指向内存中当前所在栈帧的栈底)
eg:store t0 to the stack:
addi sp, sp, -4
sw t0, 0(sp)
3.控制的转移
-
j(jump): j label
-
jal(jump and link): jal dst, label
dst会保存跳转前的下一条指令的地址pc+4,通常用ra(return address)作dst,以此记住调用的函数结束时要返回哪里
- jalr(jump and link to register): jalr dst, src, imm
src为保存着代码地址的寄存器,dst通常为ra,imm为偏移量
j、jal、jalr这三条指令用来调用函数
- jr(jump to register):jr src
常用作jr ra,用来从被调用函数返回调用者
ps:(1)j为伪指令,相当于jal x0, label
x0的值永远为0,任何试图修改x0的操作都是无效的,因此j指令相当于只跳转不链接。jr也是伪指令,jr src实质上是jalr x0, src, 0
(2)不带r的j、jal都只能跳转label,带r的jr、jalr只能跳转寄存器
(3)j和jal都没有imm参数jalr有imm参数!!!jr的imm为0(伪指令)
4.函数调用约定
问题:有的寄存器被调用者caller使用,在调用callee时跟随进入callee,callee不能丢失caller的这些寄存器的值;有的寄存器在进入callee前就已经改变(如函数参数、返回地址),callee可以随意使用这些寄存器。函数如何区分寄存器?
(1)寄存器约定
saved register在callee返回caller后值不能改变,因此在调用callee时我们必须把它们保存起来,在callee返回前再把保存的值返还给寄存器
saved registers有:s0-s11、sp(如果不在同一个地方,调用者将无法正确访问自己的堆栈变量)
volatile register可以被callee随意使用,但caller需要在调用callee前保存好自己的这些寄存器,callee返回后再把这些值返还
volatile registers:t0-t6、a0-a7、ra
综上,saved register需要callee去保存,而volatile register则是被caller保存
(2)寄存器的保存:栈区stack
5.函数的基本结构
6.选择寄存器
(1)尽量减少需要保存的寄存器的数量(减少stack的占用、加快运行速度) (2)叶函数(不再调用callee的函数)使用t0-t6就行了,不需要用s0-s11,因为不需要提醒下一层callee保存寄存器 (3)caller调用callee时:
- 把caller需要的变量放在s0-s11,不需要的放在t0-t6
- 记得检查函数参数ax和ra是否需要保存
五、指令格式
1.存储程序的理念
- 程序作为二进制码像数据一样存储于内存
- 重新编程只需重写内存,而不用重新连接计算机
- 这样的计算机被称为冯诺依曼计算机
2.指令的二进制表示
RISC-V的指令为1个word(32位),我们希望把这32位划分为不同的区域,每个区域有着固定的大小,这样硬件就可以以同样的方式处理不同的指令
3.RISC-V的6种指令格式
- R format:使用3个寄存器作为输入,如add, xor, mul(算术操作、逻辑操作)
- I format:立即数操作和加载操作,如addi,lw,jalr,slli
- S format:存储指令,如sw、sb
- SB format:分支指令,如beq、bge
- U format:高位立即数指令(instructions with upper immediates),如lui、auipc
- UJ format:跳转指令,如jal
(1)R format
每个字段field都被视为自己的unsigned int
- rs2、rs1为参数寄存器的编号,rd为目标寄存器的编号。寄存器编号为5位,正好可以表示2^5=32个寄存器(00000-11111表示x0-x31)
- opcode用于指定指令类型
- fuct7和fuct3用于决定执行哪个具体的R类型指令。一共最多有2^7×2^3=2^10=1024种R指令
(2)I format
与R format的区别就是把rs2和fuct7合并为一个大fieldimm[11:0],因为5位用来表示立即数太小了不够用。imm的表示范围:-2^11到2^11-1
注意:虽然imm为12位,但是任何街计算都是以word为单位进行的,因此imm在计算前必须先sign extended为32位。
ps:load类的指令(lw、lb等)也是I类型,但是opcode与addi之类的不同(即使是同一类型的指令,opcode也不一定相同),width表示加载多少位
(3)S format
- rs1为内存地址,rs2为要存入的值,func3为存储大小,imm为偏移量offset
- imm被分为两部分7位和5位,低5位取代了原来的rd的位置,这样可以让rs1、rs2的位置保持不变
(4)SB format
pc相对寻址:使用指令中的立即数字段作为相对于程序计数器(PC)的偏移量,而不是绝对地址。在这种寻址模式下,指令中的立即数表示了目标地址相对于当前指令的位置的偏移量。
汇编代码汇编为机器语言时,会计算出label相对于当前pc所指地址的偏移量imm
在RISC-V中,PC相对寻址使得分支指令可以在两个方向上改变PC值,每个方向最多能够偏移± 2^11 个word(用word是因为指令的长度为1个word,即word aligned,但这只适用于指令集为32位的状况)
为了支持扩展的16位压缩指令以及任何长度为16位整数倍的指令,riscv的branch的offset实际上是以half word即2个字节为单位的,这样在支持16位压缩指令扩展的时候就不需要重写架构,牺牲了branch的范围,提高了通用性
ps:imm[12|10:5]表示这7位分别为imm[12]、imm[10]-imm[5],以此类推
-
我们发现imm[0]被丢弃了,这是因为offset的单位为half word即2个字节,imm[0]永远为0。因此branch范围为-2^11到2^11-1 half word,即-2^12到2^12-2字节
-
而且我们发现imm[11]被拿到了imm[4:1]的后面,这是为了把这个field填满为5位
- 问题:如果我们要branch的范围超出了-2^12到2^12-2字节的范围怎么办?
- 答:改用j指令(因为其imm的范围更大)
(5)U format
U类型只有两个指令:lui、auipc
- lui(load upper immediate):将一个20位的imm写入rd的高20位,并且把低12位清零
eg:lui s1 0xfffff,则s1变成0xfffff000
如果我们想使用32位的立即数怎么办?我们的I类型指令如addi的imm只有12位,这时就可以用lui
lui x10, 0x87654 # x10 = 0x87654000
addi x10, x10, 0x321 # x10 = 0x87654321
然而,由于addi会进行sign extended,就会出错:
lui x10, 0xDEADB # x10 = 0xDEADB000addi x10, x10,0xEEF # x10 = 0xDEADAEEF
实际发生的是:0xDEADB000 + 0xFFFFFEEF = 0xDEADAEEF
因此,如果要加的立即数的第12位为1,即发生sign extedned时,我们要手动提前把lui的imm+1
lui x10, 0xDEADC # x10 = 0xDEADC000
addi x10, x10,0xEEF # x10 = 0xDEADBEEF
但这样做太麻烦了,伪指令li为我们解决了这个问题。li指令就是用上面的逻辑实现的。
- auipc(add upper immediate to pc):把pc的高20位加上一个20位的imm,结果返回给rd
(6)UJ format
UJ类型只有一个指令:jal。!!!!!
j是jal的伪指令
jalr是I类型!!!(因为有imm参数)
jalr是I类型!!!(因为有imm参数)
jalr是I类型!!!(因为有imm参数)
jr是jalr的伪指令
jal格式:
- rd保存pc+4,pc=pc+offset
- 汇编代码汇编为机器语言时label相对于pc的偏移量imm(21位)被计算出来,,offset取得是imm的1-20位,单位为half word,imm第0位恒为0,因为指令的单位为half word(16位指令扩展),因此第0位舍弃
- imm的编码方式与S、SB格式的指令同理,是为了优化硬件性能
再来看看I类型的jalr:
I类型标准格式:
jalr格式:
- rd=pc+4,pc = x[rs1] + imm
- imm取0-11位,单位为1字节,不是half word
总结:
六、CALL(compile、assemble、link、load)
1.c语言从编译到运行的全过程:
2.编译和解释的区别
- 解释器:直接解释代码并执行,没有中间文件(python解释执行python字节码、java解释器解释执行class字节码)
- 编译器:把程序源代码转换为另一种语言表示的等价程序
- 对效率要求不严格时直接解释执行高级语言
- 对效率要求高时把源文件编译为低级语言程序文件
- 如操作系统这种对性能要求高的软件,必须用编译后的二进制码
- 解释器更接近高级语言,有更好的报错支持
- 解释器运行程序比编译器编译程序更慢(约10倍),但程序更小(直接解释运行源代码)(约2倍)
- 解释器独立于指令集架构,即可以跨平台
- 编译器更适合隐藏源代码
3.预编译
(1)格式:gcc -E input_file.c -o output_file.i
4.编译
(1)格式:gcc -S input_file.i -o output_file.s(.s文件包括伪指令)
5.汇编
(1)格式:gcc -c input_file.s -o output_file.o
(2)汇编器指令assembler directives:用来给汇编器指引方向,本身不翻译为机器语言
- .text:后续的代码放入用户代码段
- .data:后续的代码放入用户数据段
- .globl sym:声明全局标签sym,可以从其他文件引用
- .asciiz str:将字符串 str 存储在数据段并以 null 结尾
- .word w1 w2 w3 …… wn:存储n个32位数据在连续的内存中
- 展开伪指令
- mv t0, t1:addi t0,t1,0
- neg t0, t1(求t1的补码):sub t0, zero, t1
- li t0, imm:addi t0, zero, imm
- not t0, t1(求t1的反码)0:xori t0, t1, -1
- beqz t0, loop(t0与0比较):beq t0, zero, loop
- la t0, str:auipc t0, str[31:12] | addi t0, t0, str[11:0]
(3)汇编器的两次遍历
对于branch和jal指令,是需要知道label相对于当前指令地址的offset的,但是如果要跳转的标签当前代码还没有经过,那汇编器就没法知道这个;label在哪
解决方案:让汇编器两次遍历代码
- pass1:
- 展开遇到的伪指令
- 记住label的位置
- 删除注释、空行等
- 错误检查
- pass2:
- 根据label的位置计算出地址偏移量offset
- 输出二进制目标文件
(4)符号表symbol table
引用外部标签时单个的.o文件无法得出offset,外部数据的地址也无法得知,这些在汇编单个文件时是无法被确定的,因此我们创建了两个表:symbol table符号表、relocation table迁移表
- symbol table符号表
- 每个.o文件都有一个符号表
- 符号表列出了其他文件可能需要的项目
- label:函数调用
- data:.data字段内的数据
- relocation table迁移表
- 列出当前文件需要的地址(当前未定义)
- 外部标签external label
- data:.data字段内的数据
- 列出当前文件需要的地址(当前未定义)
(5)目标文件格式
- <1>目标文件头:目标文件其他部分的大小和位置
- <2>代码段text segment:机器语言
- <3>数据段data segment:源文件的数据(2进制形式)
- <4>迁移表relocation table
- <5>符号表symbol table
- <6>调试信息:gcc -g选项添加的调试信息,使你可以之后调试程序
6.链接
(1)格式:gcc input_file.o -o output_file
(2)链接器的存在使得可以单独编译文件,提升效率
(3)示意图
- <1>把每个.o文件的text段拿出来放在一起
- <2>把每个.o文件的data段拿出来放在一起,并连接到text段的结尾
- <3>解决引用问题:遍历迁移表relocation table,有3种地址需要解决:
- PC相对偏移地址(beq、bne、内部jal):从不relocate(汇编时2 pass就确定了offset并且相对位置在链接时不会变化)(一般SB指令都不会引用到外部lable)
- 外部函数引用(通常是外部jal):必须relocate
- 静态数据引用(auipc):必须relocate(链接后才能确定data字段的位置)
(4)解决引用问题
- 对于RV32,链接器假定text段的第一个word地址为0x10000(虚拟内存)
- 链接器知道每个text和data段的大小和它们的顺序,于是就可以计算出每一个需要跳转的lable和每一个引用的data的绝对地址
- <1>查找每一个.o文件的symbol table
- <2>没找到就去库文件里找
- <3>绝对地址确定后,按绝对地址或pc-relative地址填入机器语言指令中
(5)链接器松弛
riscv中,有一个全局指针寄存器gp(global point),用来指向全局变量data代码段
复习一下加载数据的步骤:首先,链接器要根据绝对地址计算出data相对于pc的offset,然后用
auipc s0 offset[31:12]、addi s0 s0 offfset[11:0]两条指令来让s0获取data的地址
有了gp全局指针,我们就可以直接利用gp获取datad的数据
- gp在程序运行时会被设置为一个固定的值,然后当遇到la这种读取地址的指令时,链接器会判断data的地址是否在gp ± 2kBytes的范围内(因为imm为12位,2^11=2k),如果在的话,之前的auipc+addi这两条指令可以直接替换为1条指令
lw s0,offset_to_gp(gp),这个过程称为链接器松弛
7.加载
(1)程序开始运行时,加载器把程序从磁盘disk加载到内存中并且使其开始运行
(2)事实上,加载器loader是操作系统的一部分,加载是操作系统完成的任务
- <1>加载器读取可执行文件的header,从而决定程序的代码段、数据段的大小
- <2>为程序开辟内存空间,包括代码段、数据段、栈区
- <3>把可执行文件的text、data复制到开辟的地址空间
- <4>把程序的参数复制到栈区
- <5>初始化寄存器,绝大部分寄存器清零,除了栈指针被赋值为第一个可用的栈位置的地址
- <6>程序跳转到启动例程(start-up routine),这个例程的作用是从栈中将程序的参数复制到寄存器中,并设置程序计数器pc
- 如果主函数执行完毕并返回,那么启动例程会使用exit系统调用来终止程序
8.CALL的一个简单例子
注意由于printf函数在库里,string1、string2在data段还没确定地址,因此这里.o文件中未知的参数全部用0占位,jalr指令中printf的地址用pc寄存器占位