计组学习 —— CALL
翻译VS解释 Translation vs Interpretation
-
如何跑一个用原始语言写的程序?
- Interpreter:直接在源语言中执行程序
- Translator:将一个源语言中的程序转化为一个别的语言的程序
- Translate会非常快,Interperter的代码量会很小
-
C的特点:C的一个很特别的点就是,可以分别编译文件,再之后把他们组合起来形成一个可执行的程序
- 文件都可以访问什么?
- 程序
- 全局变量
- 文件都可以访问什么?
-
CALL的结构(C的编译过程)
那么接下来就一个一个解析一下这几个东西!
Compiler编译器
-
输入:高级语言代码(比如C或者Java)
-
输出:汇编语言代码(比如RISC-V架构的foo.s)
-
输出中可能包含伪指令(pseudo-instructions)
-
事实上,还有一个预处理指令,它将处理所有的宏定义指令
编译器实际上很复杂,需要讲的内容有很多,在计组里不过多阐述
CS164是完整的编译原理,可以到那里继续进修
Assembler汇编器
- 输入:汇编语言代码(比如foo.s)
- 输出:目标代码/文件(object code/file) 比如(foo.o)
汇编器都会:
- 读取并且使用指令
- 把伪指令替换掉
- 产生机器语言
Assembler Directives
-
他们向汇编语言提供方向但是不产生机器指令
.text
:在后面加上一些用户文本部分或者代码部分中的项目.data
:类似于text,除此之外把内容放在静态字段或者内存的数据段.globl sym
:声明sym
这个全局变量,并且可以被其他文件使用.asciiz str
: 把字符串str
存储在内存里,也是以null终止.word w1 w2 wn
:接受由空格分割的整数或者单词,然后存储着n个32位量在连续的内存里
替换伪指令:在之前已经举过例子了,会把一些伪指令替换成真正的指令
产生机器语言
- 简单的情况
- 算术或者逻辑指令,位运算都算比较简单的,交给计算机就好
- 因为所有的信息都在指令里
- 分支和跳跃呢?
- 分支和跳跃需要相对地址
- 当我们把伪指令转化为真正指令的时候,我们会计算每条指令,并且弄清他们的去向这意味着我们会知道地址
- 如果跳跃到一些标签呢?
- 分支控制语句可能跳转到一些以后的标签,我们还没有计算过地址的标签,甚至于汇编器都不知道这个标签的意义是什么,该怎么办呢?
- 解决方案:让汇编器扫描两次,这样就都知道了
- 分支控制语句可能跳转到一些以后的标签,我们还没有计算过地址的标签,甚至于汇编器都不知道这个标签的意义是什么,该怎么办呢?
汇编器的两次扫描
- 第一次
- 把伪指令替换成真正的指令
- 将标签的地址记住
- 去掉注释,空白行
- 检查错误
- 第二次
- 使用标签的地址区生成相对地址(相对地址用于分支跳转和标签跳转等)
- 输出目标文件(object file),目标文件只是二进制代码中指令的集合
仍然不能被创建的东西
- 如果跳转到外部标签
- 需要知道最终地址
- 不知道是不是向前或者向后,不能生成机器指令,因为不知道内存里指令的位置
- 关于数据
- la这种伪指令会被拆分程lui 和 addi
- 这回需要32位地址的数据
- 这些不能被决定的东西,我们的想法是创建两个表,这两个表可以位其他文件储存这些信息。当我们使用连接器把两个程序连接到一起是,使用这个表。
符号表(symbol table)
-
符号表是一个用来储存其他文件可能使用的”东西‘的列表
-
这些“东西”是指:
-
标签:函数调用
-
数据:所有在
.data
部分变量可被文件访问
-
- 保持对标签的追踪解决了“前向关联问题”
重定位表(relocation table)
- 重定位表是一个用来存储文件里一会可能要用到地址的“东西”
- 这些“东西”是指:
- 任何拓展标签(jal,jar)
- 内部标签 internal
- 拓展标签 external (包括库文件)
- 认可数据段
- 比如任何在
data
部分提到的
- 比如任何在
- 任何拓展标签(jal,jar)
标准的Object File格式
- 目标文件头:大小、目标文件的其他部分的位置
- text段:机器代码
- data段:source文件里的数据(二进制)
- 重定位表:指明需要被链接器“处理”代码的行
- 符号表:这个文件的可被引用的标签和可以被其他文件引用的数据的表格
- 调试信息:(比如-g的调试信息)标准格式是ELF
Linker
- 输入:目标文件(object file)
- 输出:可执行代码(Executable Code)
- 把多个目标文件组合成为一个可执行文件("linking")
- 可以进行单独的编译文件(非常有用!)
- 如果改变单个文件不需要重新编译整个项目
比如object file 1是我们的程序文件,object file 2是我们的库文件链接器可以把我们两个部分链接到一起
-
链接器将从每个.o文件中提取文本段然后把他们放在一起
-
把每个.o文件的数据段放在一起,之后把数据连接到文本段之后
-
解决所有引用
- 看重定位表并且处理其中每个条目
- 填写所有最终的绝对地址
三种地址类型
- 相对PC的地址(beq, bne, jal)
- 不需要重定位,只要代码之间的相对位置不变,就不需要改变
- 外部函数的引用(往往都是jal)
- 往往需要重定位
- 静态数据的引用(往往都是auipc和addi)
- 往往需要重定位
- RISCV经常用auipc
RISC-V中的绝对寻址
哪些指令需要重新定位编辑?
- J格式的指令: jump / jump and link
- 加载或者储存变量 到 静态区域里,和全局指针相关
- 条件分支,也是PC相关的,但是不用担心搬移,因为代码之间的相对位置没有改变
解决引用问题
-
链接器假设文本段的第一个是0x1000
- 在之后涉及到虚拟内存可以解释
-
链接器知道:
- 每一个文本段和数据段的长度
- 文本段和数据段的顺序
-
链接器计算:
- 每一个被跳转的标签(内部的或者拓展的)的绝对地址
- 被引用的所有数据
-
解决引用问题:
- 在所有"调用者"的符号表里搜索引用(数据或者标签)
- 如果没找到,那么就在库文件里搜索(比如printf)
- 一旦绝对地址被决定了,立刻填写进机器代码里
-
链接器的输出:包含文本段和数据段的可执行文件
Loader 装载程序
-
输入:可执行代码
-
输出: <程序跑起来了>
-
可执行文件储存在磁盘里
-
当一个程序要运行的时候,装载器的作用就是把他加载进入内存里然后启动它
-
事实上,装载器就是操作系统的一部分
- loading是操作系统的工作的一部分
- 读取可执行文件的头,以确定文本和数据段的大小
- 为程序创建一个空间,以容纳文本段和数据段还有栈空间和堆空间
- 把指令和数据复制进入新的地址空间
- 复制参数传给在栈里的程序
- 初始化寄存器
- 大多数寄存器都被清空,stack pointer指向栈的第一个空间
- 跳转到启动例程(start-up routine),从栈复制程序的参数,粘贴给寄存器,设置PC指针
一个CALL的例子
我们有如下的C程序
#include<stdio.h>
int main(){
printf("Hello,%s\n","world");
return 0;
}
-
第一步,把Hello.c编译为Hello.s
可以看到,有一些汇编指令告诉我们接下来的部分是在内存的
.text
部分,接下来我们把main这个标签声明为全局的,这意味着其他文件可能访问它接下来在下面可以看到有一个
.section
告诉我们下一个部分变成数据的只读部分,接下来声明了两个string,这两个部分将进入内存然后接下来被引用 -
接下来,汇编器需要讲文件变成目标文件
可以看到我们这里有许多的占位符,这些用红色标注的0都是我们目前并不知道位置,所以只能暂时用0来填充。可以看到我们为1c的jalr也进行了填充,因为我们并不知道printf在哪里
-
接下来,链接器工作
链接器接受整个目标文件并且和标准库链接在一起,这样我们就知道了printf的地址,和数据部分的地址
-
接下来,加载器加载程序到内存并且启动,计算机执行程序
总结
- 编译器 把高级语言程序(HLL)转化成简单的汇编语言
- 汇编器 移除伪指令,转化成机器语言,为链接器创建数据表(重定位表和符号表)
- 符号表是标签的地址列表,这样使得链接在一起的文件知道其他数据的地址
- 在汇编器中,两遍扫描来解决内部的”前向引用“的问题
- 链接器 合并多个目标文件,解析绝对地址。因此它将接受一个或多个目标文件或o文件然后将输出一个.out文件
- 如果一个文件改变,只需要重编译他自己,再和其他文件链接到一起