E4 从C代码到二进制程序
在线网站Compiler Explorer很适合开发单个源文件的程序, 但如果要开发多个源文件的程序, 或者让开发的多个程序之间相互协助以完成更复杂的任务, 在线网站就不方便了. 安装Linux系统后, 我们将把开发环境迁移到Linux系统中, 同时借助Linux中一些有用的工具辅助C语言的进阶学习, 为搭建后续任务的开发环境打下基础.
Linux中的C语言进阶学习
在Linux中完成Learn C the hard way练习0~22, 练习24~25, 练习27~33, 练习42, 练习44, 你需要将示例代码拷贝到Linux中编译并运行, 结合文字RTFSC理解示例代码, 并完成相应的附加题.
注意, 上述习题将是入学答辩的一部分. 如果你被某些题目卡住, 可以通过STFW或在预学习群中询问.
虽然这份教程的C语言知识点和之前的《Linux C编程一站式学习》差不多, 但它还包含了Makefile, Valgrind等各种工具的使用介绍, 以及一些值得学习的编程理念, 这些对大家将来进行开发都是很有帮助的, 非常值得大家在Linux上进行实践. 事实上, 如果你之前的C语言基础足够牢固, 你不应该在阅读代码上花费过多的时间; 相反, 如果你在阅读代码时遇到不少困难, 你应该感谢这次机会帮助你查缺补漏.
熟悉了Linux系统中的C语言开发后, 你已经知道, 程序从C代码生成二进制的可执行文件后才能运行. 那么, 编译器是如何将C代码转变成可执行文件的呢? 为了回答这个问题, 我们借助Linux上的一些工具, 来进一步了解这个过程中都有哪些步骤.
预处理
预处理属于正式编译之前的步骤, 其本质是文本处理. 预处理主要包含以下工作:
- 头文件包含
- 宏替换
- 去掉注释
- 连接因断行符(行尾的
\
)而拆分的字符串 - 处理条件编译
#ifdef
/#else
/#endif
- 处理字符串化操作符
#
- 处理标识符连接操作符
##
例如, 有如下C代码
// a.c
#include <stdio.h>
#define MSG "Hello \
World!\n"
#define _str(x) #x
#define _concat(a, b) a##b
int main() {
printf(MSG /* "hi!\n" */);
#ifdef __riscv
printf("Hello RISC-V!\n");
#endif
_concat(pr, intf)(_str(RISC-V));
return 0;
}
可通过
gcc -E a.c
对上述C代码进行预处理.
观察预处理结果
尝试运行上述gcc
命令, 然后对比预处理结果和源文件的区别.
一个值得讨论的问题是, gcc
是如何找到头文件的? 要回答这个问题, 我们可以通过阅读工具的日志和相关手册.
如何寻找头文件
- 尝试执行
gcc -E a.c --verbose > /dev/null
, 并在输出的结果中寻找和头文件相关的内容. - 在
man gcc
中搜索-I
选项相关的说明并阅读.
了解后, 尝试创建一些stdio.h
文件, 然后通过-I
选项让gcc
包含你创建的stdio.h
, 而不是标准库中的stdio.h
. 通过-E
选项来检查预处理结果是否符合你的预期.
观察预处理结果(2)
尝试安装面向RISC-V架构的gcc
, 并用其进行预处理:
apt-get install g++-riscv64-linux-gnu
riscv64-linux-gnu-gcc -E a.c
查看此时的预处理结果, 你发现有什么新的变化?
上述C代码中的宏__riscv
很特殊, 它是由riscv64-linux-gnu-gcc
预先定义的. 因此即使在C代码中没有直接定义它, riscv64-linux-gnu-gcc
仍然按已定义的方式处理它. 可以通过以下命令查看所有预定义宏:
echo | gcc -dM -E - | sort
上述命令让gcc
对一个空文件进行预处理, 然后打印出这个过程中的所有定义的宏, 并将其排序.
对比gcc和riscv64-linux-gnu-gcc的预定义宏
尝试对比gcc
和riscv64-linux-gnu-gcc
的预定义宏, 从而了解两者在预处理时的差异. 你只需要简单了解这些差异即可, 无需深入了解每一个宏的具体含义.
Hint:
- 使用
diff
或者相关的命令能帮助你快速找到两个文件的不同之处 - 如果你想了解一些宏的含义, 可以查阅
gcc
的相关手册
编译
从广义上理解, 编译是将一种语言转换成另一种语言的过程. 而对于C语言编译器来说, 编译是将C语言转换成目标语言的过程, 这里的目标语言和ISA相关, 通常是指目标ISA的汇编语言. 例如, 对x86架构的计算机来说, gcc
会将C语言转换成x86汇编语言; 而riscv64-linux-gnu-gcc
会将C语言转换成riscv64
汇编语言.
这个过程涉及较多细节, 在此我们不深究过程中的每个步骤是如何实现的. 不过我们将借助合适的工具来了解每个步骤都在做什么, 从而对编译的过程建立简单的认识. 为此, 你需要安装clang
, 它在功能上和gcc
等价, 都是C语言的编译器.
apt-get install clang
同时, 我们通过以下程序来观察编译过程中的步骤:
// a.c
#include <stdio.h>
int main() { // compute 10 + 20
int x = 10, y = 20;
int z = x + y;
printf("z = %d\n", z);
return 0;
}
了解编译的过程
尝试查阅man clang
, 阅读其中关于编译阶段的介绍, 从而大致了解编译过程.
词法分析
词法分析的工作是识别并记录源文件中的每一个token, 包括标识符, 关键字, 常数, 字符串, 运算符, 大括号, 分号等. 若遇到非法的token(如@
), 则报告错误. 可以通过如下命令来查看词法分析的结果:
clang -fsyntax-only -Xclang -dump-tokens a.c
可以看到, 词法分析的结果还通过文件名:行号:列号
的格式记录了每一个token的位置.
事实上, C语言的源文件本质上是一个文本文件, 因此也可以将其看成一个字符串, 而词法分析工具则是可以看作一个字符串匹配程序.
词法分析和语法高亮
你应该使用过编辑器中的语法高亮功能. 事实上, 这一功能并不难实现: 根据C语言标准规范中的定义, 你可以编写一个简单的词法分析工具, 来识别C代码中的一些关键字, 并用不同的颜色重新输出它们即可. 如果要输出到终端, 可以使用ANSI转义码的颜色功能.
语法分析
语法分析的工作是按照C语言的语法将识别出的token组织成树状结构, 从而梳理出源程序的层次结构, 从文件, 函数, 到语句, 表达式, 变量等. 若遇到语法错误(如漏了分号), 则报告错误. 语法分析的结果通常通过抽象语法树(Abstrace Syntax Tree, AST)的方式呈现. 可以通过如下命令来查看语法分析的结果:
clang -fsyntax-only -Xclang -ast-dump a.c
语义分析
语法分析的工作是按照C语言的语义确定AST中每个表达式的类型. 在这个过程中, 相容的类型将根据C语言标准进行类型转换(如算术类型提升). 而对于不符合语义的情况, 则报告错误. 一些符合语法但不符合语义的情况包括未定义的引用, 运算符的操作数类型不匹配(如struct mytype a; int b = a + 1;
), 函数调用参数的类型和数量不匹配等.
对于clang
来说, 在输出AST的时候就已经给出表达式的类型了. 事实上, 大多数编译器并没有将语法分析和语义分析严格分开.
语义分析的一个重要应用是静态程序分析, 它是指在不运行程序的情况下对源代码进行分析, 本质是分析AST中的语义信息. 分析的角度包括代码风格和规范, 潜在的的软件缺陷, 安全漏洞, 性能问题等. 例如有如下程序:
// a.c
#include <stdlib.h>
int main() {
int *p = malloc(sizeof(*p) * 10);
free(p);
*p = 0;
return 0;
}
这个程序符合C语言的语法, 单独看每条语句也符合C语言的语义, 直接通过gcc a.c
编译并没有任何报错, 甚至编译后的程序也能成功运行. 但如果添加编译选项-Wall
, gcc
就会进行更多的代码检查工作, 并通过警告的方式指出代码中存在use-after-free
的问题, 即空间释放后仍然继续访问的问题. 这种通过静态程序分析报告代码中潜在问题的工具称为lint工具.
类似地, 也可以调用clang
的lint工具来分析上述程序:
clang a.c --analyze -Xanalyzer -analyzer-output=text
重视lint工具的作用
一些编程初学者会觉得, 让编译器报告更多的警告会给编程带来额外的工作量. 事实上, 使用lint工具对开发者来说几乎是零成本的, 却可以帮助开发者发现很多潜在问题. 这些问题一旦进入程序运行阶段, 开发者将要付出更大的代价来调试它们. 尤其是在大型项目的运行过程中, 类似上文的代码造成的问题非常隐蔽, 软件很可能在运行很长一段时间后突然崩溃, 要调试是非常困难的. 因此, 大型项目通常都会充分利用lint工具, 来尽可能提升项目的质量.
中间代码生成
中间代码是一种由编译器定义的, 面向编译场景的ISA, 也称中间表示(Intermediate Representation, IR)或中间语言(Intermediate Language). 可以通过如下命令来查看clang
生成的中间代码:
clang -S -emit-llvm a.c
cat a.s
回顾状态机模型, 编译的工作主要是将C程序的状态机翻译成ISA的状态机, 也即, 将C程序的变量翻译成寄存器或内存, 将C程序的语句翻译成指令序列. 既然中间代码也可以看成一种ISA, 我们也可以通过状态机视角来理解中间代码生成的工作: 将C程序的变量翻译成中间代码的变量, 包括%1
, %2
, %3
等; 将C程序的语句翻译成中间代码的指令, 包括alloca
, store
, load
, add
, call
, ret
等. 当然, 翻译的过程需要依据AST分析出的语义, 使得翻译之后得到的中间代码的行为与输入的C程序等价.
那为什么不直接翻译到目标语言, 即处理器相关的ISA呢? 一方面, 处理器相关的ISA有很多, 如果直接翻译到处理器相关的ISA, 并且要要对程序进行优化, 就要将一个优化技术分别实现到不同的ISA上, 增加了编译器的维护成本; 如果先翻译到中间代码, 再翻译到处理器相关的ISA, 就只需要将优化技术实现到中间代码上. 另一方面, 中间代码还能作为多种源语言(如C语言, Fortran, Haskell等) 和多种目标语言(如x86, ARM, RISC-V等)之间的桥梁: 假设有种源语言和种目标语言, 如果直接翻译到目标语言, 就需要实现个翻译模块; 如果引入中间代码, 以中间代码为边界, 将编译器的流程分为前端(frontend)和后端(backend), 就只实现个翻译模块, 其中个前端模块分别负责将种源语言翻译到中间代码, 个后端模块分别负责将中间代码翻译到种目标语言.
frontend backend
+----------+ +------------+
C -> | Clang | -+ +-> | llvm-x86 | -> x86
+----------+ | | +------------+
+----------+ +-> +----------+ -+ +------------+
Fortran -> | llvm-gcc | ---> | llvm-opt | ---> | llvm-arm | -> ARM
+----------+ +-> +----------+ -+ +------------+
+----------+ | | +------------+
Haskell -> | GHC | -+ +-> | llvm-riscv | -> RISC-V
+----------+ LLVM IR LLRM IR +------------+
不同的编译器可能会采用不同的中间代码, 例如clang
使用的中间代码叫LLVM IR
, gcc
使用的中间代码叫GIMPLE
. 不过, 我们并不需要理解中间代码的具体细节, 只需要从状态机模型的视角大致了解中间代码生成的工作过程即可.
编译优化
编译优化是现代软件构建过程中的重要步骤, 通过编译优化, 开发者可以将精力集中在程序业务逻辑的开发中, 而不必在开发阶段过多考虑程序的性能, 编译器通常能提供一个还不错的性能下限.
编译优化正确性的定义
我们可以从程序行为的角度来理解编译优化: 如果两个程序在某种意义上"相同", 就可以用"简单"的替代"复杂"的. 其中, 遵循C语言标准逐条语句执行的行为称为"严格执行". 以"严格执行"为基准, C语言标准对上文的"相同"作了严谨的定义, 即优化后的程序应满足"程序可观测行为"(C99标准手册5.1.2.3节第6点)的一致性, 具体包括:
- 对
volatile
关键字修饰变量的访问需要严格执行 - 程序结束时, 写入文件的数据需要与严格执行时一致
- 交互式设备的输入输出(stdio.h)需要与严格执行时一致
"可观测行为"刻画的是从外部视角看C程序对外界的影响, 例如, 第2点要求那些没有实时性的外部操作在最后"看起来一致", 第3点要求那些有实时性的外部操作在执行过程中"看起来一致". 第1点约束的是C程序内部的行为, 它其实和F阶段中提到的"内存映射I/O"有关, 不过我们不打算在此进一步说明, 我们会在D阶段中继续讨论相关内容.
因此, 只要优化后仍然满足程序可观测行为的一致性, 这种优化都是"正确"的. 在这个条件下, 如果优化后的程序变量更少, 或者语句更少, 可以预期程序的性能表现就会更优.
编译优化技术举例
真实项目普遍都使用编译优化技术, 将来你也会在自己设计的处理器上运行各种经过编译优化的程序. 因此, 了解一些常见的优化技术, 知道编译器为什么会生成相应的指令序列, 将有助于将来开展调试和体系结构优化等工作.
下面列举一些常见的编译优化技术. 注意在编译流程中, 优化技术并不是在C代码上直接开展的, 但为了方便理解, 我们用C代码来呈现优化前后的语义.
- 常量传播 - 如果一个变量的取值是常数, 可以将该取值代入到引用处, 若代入后形成常量表达式, 可直接计算出该表达式的值. 以下示例中,
a
的取值是常数, 因此表达式a + 2
也是常数, 从而使得b
的取值也是常数; 进一步地, 表达式b * 3
也是常数. 编译器可以直接计算这些常量表达式, 用计算结果代替它们, 从而无需生成相应的指令(如加法指令和乘法指令)来在程序运行时刻计算这些表达式.
// 优化前 | 优化后
int a = 1; | int a = 1;
int b = a + 2; | int b = 3;
printf("%d\n", b * 3); | printf("%d\n", 9);
- 死代码消除 - 对于不可达(unreachable)的代码或不再使用的变量, 可将其移除. 以下示例中,
DEBUG
宏的定义为0
, 因此if
代码块中的代码永远不会执行, 可将其移除; 移除if
块的代码后, 变量a
未被使用, 可进一步将其移除.
// 优化前 | 优化后
#define DEBUG 0 | #define DEBUG 0
int fun(int x) { | int fun(int x) {
int a = x + 3; | return x / 2;
if (DEBUG) { | }
printf("a = %d\n", a); |
} |
return x / 2; |
} |
- 消除冗余操作 - 对于那些没有被读出就被覆盖的赋值操作, 可将其移除. 以下示例中,
a = 3
的赋值操作将会被a = f()
覆盖, 因此可移除前者; 同理, 可移除a = f()
的赋值操作和a = 7
的赋值操作.
// 优化前 | 优化后
int a; | int a;
a = 3; | f();
a = f(); | a = 10;
a = 7; |
a = 10; |
可以进一步优化吗?
上例优化结果保留了函数调用f()
, 能否进一步移除f()
? 为什么?
- 代码强度削减 - 用简单的运算操作替代复杂的运算操作. 以下示例中,
i * 4
和i << 2
的行为在有定义时, 其语义一致. 但在大多数计算机中, 乘法指令的执行开销高于移位指令, 通过用后者替代前者, 可以提升程序的性能.
// 优化前 | 优化后
int x = a[i * 4]; | int x = a[i << 2];
- 提取公共子表达式 - 对于多次计算的子表达式, 可用中间变量保存其结果, 并在后续代码中直接引用该结果, 无需重复计算. 以下示例中,
a * b
出现了2次, 通过引入中间变量temp
来存放a * b
的结果, 可减少一次乘法运算, 从而提升程序的性能.
// 优化前 | 优化后
int x = a * b - 1; | int temp = a * b;
int y = a * b * 2; | int x = temp - 1;
| int y = temp * 2;
- 循环不变代码外提 - 对于每次循环结果都一样的代码, 可以将其提到循环之前进行一次计算. 以下示例中, 表达式
a + 2
在每次循环中的结果都相同, 因此可以将其提到循环之前进行一次计算, 从而无需在每次循环中都重复计算.
// 优化前 | 优化后
int a = f1(); | int x = f1() + 2;
for (i = 0; i < 10; i ++) { | for (i = 0; i < 10; i ++) {
int x = a + 2; | int y = f2(x);
int y = f2(x); | sum += y + i;
sum += y + i; | }
} |
可以进一步优化吗? (2)
上例优化结果的f2(x)
仍然在循环中, 能否进一步将f2(x)
提到循环之前进行计算? 为什么?
- 函数内联 - 对于较小的函数, 可以在调用处将其展开, 节省函数调用的开销. 以下示例中,
f2()
中的f1(x, 3)
可以直接展开, 直接计算x + 3
, 无需经过函数调用和函数返回的过程, 从而提升程序的性能.
// 优化前 | 优化后
int f1(int x, int y) { | int f1(int x, int y) {
return x + y; | return x + y;
} | }
int f2(int x) { | int f2(int x) {
return f1(x, 3); | return x + 3;
} | }
可以进一步优化吗? (3)
上例中, 假设f1(x, 3)
是在该源文件中对f1()
的唯一调用, 能否对优化结果中的f1()
应用死代码消除技术将其移除? 为什么?
除了上文介绍的技术, 编译优化技术还有很多, 例如归纳变量分析, 循环展开, 软流水, 自动并行化, 别名和指针分析等, 此处我们不展开讨论, 感兴趣的同学可以查阅相关资料.
启用编译优化技术
可以在clang
的命令行中给出-O1
选项来开启更多的编译优化工作:
clang -S -emit-llvm -O1 a.c
cat a.s
对比编译优化的结果
尝试对比添加-O1
前后所生成的中间代码, 你发现添加-O1
后, 生成的中间代码有何不同?
对比编译优化的结果(2)
尝试在int z = x + y;
之前添加volatile
关键字, 并重新生成-O1
的中间代码. 和之前生成的中间代码对比, 你发现此时的中间代码有何不同?
编译器通常会提供不同的优化等级, 让开发者在程序性能, 代码大小和编译时间等指标中选择. 例如, 在gcc
中, 针对程序性能, 有以下不同的优化等级: -Ofast
> -O3
> -O2
> -O1
> -Og
> -O0
(默认). 优化等级越高, 生成程序的性能也越高, 但编译时间也越长. 大部分采用gcc
或clang
编译的软件项目通常使用-O2
的优化等级, 这能使软件在较好的性能下运行; 对于-O3
, gcc
还会尝试通过生成更多的代码来换取更高的程序性能; -Ofast
则更激进, 它甚至会采取一些违反语言标准的优化策略, 来换取更高的程序性能. -Og
则会仅采取那些对调试较友好的优化策略, 和-O0
相比, 能在提升程序性能的同时, 让优化后的程序仍然能保持程序原本的层次结构(如循环, 函数调用等), 使得生成的指令序列能较好地对应到C代码中, 方便开发者进行调试.
除了程序性能, gcc
还提供了一些面向代码大小的优化等级: -Oz
> -Os
> -O1
> -O0
(默认). 对clang
来说, 上述优化等级的编译选项也同样适用.
一个优化等级通常包含很多优化技术. 对于gcc
, 可以通过-Q --help=optimizers
查看相应优化等级开启的优化技术, 例如
gcc -Q --help=optimizers -O1
可以了解-O1
启用了哪些优化技术. 而对于clang
, 可以通过-ftime-report
查看编译过程中都有哪些子步骤(pass):
clang -S -emit-llvm -O1 a.c -ftime-report
当然, 我们不要求你理解每一个优化技术的具体细节. 如果你感兴趣, 可以查阅相关手册.
目标代码生成
目标代码生成的工作是将优化后的中间代码翻译成目标代码, 也即处理器相关的ISA. 同样地, 我们可以通过状态机视角来理解目标代码生成的工作: 将中间代码的变量翻译成处理器ISA的变量, 也即将%1
, %2
, %3
等翻译成寄存器或内存地址; 将中间代码的指令翻译成处理器ISA的指令, 也即将alloca
, store
, load
, add
, call
, ret
等指令翻译成处理器ISA的指令. 可以通过如下命令来查看clang
生成的目标代码:
clang -S a.c
cat a.s
上述clang
命令会默认生成与本地环境相同ISA的汇编代码, 如x86. 也可以向clang
提供编译选项--target=riscv32-linux-gnu
来指定生成riscv32的汇编代码:
clang -S a.c --target=riscv32-linux-gnu
这种生成与本地环境不同的ISA汇编代码的编译过程, 称为"交叉编译"(cross-compilation).
理解C代码与riscv指令序列的关联
阅读clang
交叉编译得到的riscv32汇编代码, 并尝试指出哪一段汇编代码是由哪一段C代码编译得到的.
理解C代码与riscv指令序列的关联(2)
添加-O1
并重新编译得到riscv32汇编代码, 你发现生成的汇编代码有何不同? 它如何与C代码建立关联?
目标代码生成作为编译过程的最后一个步骤, 使用gcc
也可以得到相应的结果:
gcc -S a.c # 编译到本地ISA
riscv64-linux-gnu-gcc -march=rv32g -mabi=ilp32 -S a.c # 交叉编译到riscv32
其中, -march=rv32g
和-mabi=ilp32
这两个选项是为了指示 riscv64-linux-gnu-gcc
生成riscv32
的汇编代码, 若不提供这两个选项, riscv64-linux-gnu-gcc
将默认生成riscv64
的汇编代码.
在生成目标代码的过程中, 编译器还会进行与目标ISA相关的优化. 例如, 将中间代码的变量翻译成ISA的寄存器或内存时, 编译器会尝试把经常使用的变量放到寄存器, 不太常用的变量放到内存. 在现代计算机中, 处理器访问寄存器的效率要远高于访问内存, 因此将常用的变量放到寄存器中, 可以从整体上提升程序的性能. 对于指令的翻译, 编译器也会尝试生成指令数量较少的指令序列, 这同样可以提升程序的性能. 这些策略的背后有一定的理论支撑, 在这里我们就不详细展开了, 感兴趣的同学可以考虑查阅编译原理的相关资料.
二进制文件的生成和执行
汇编
编译的结果是汇编代码. 我们已经知道汇编语言是指令的符号化表示, 因此汇编代码本质上是可读的文本. 但处理器的电路无法理解文本, 因此还需要将汇编代码转变成指令的二进制编码. 这就是汇编(assemble)这一步骤的工作, 完成这一工作的工具称为汇编器.
汇编器的工作方式很直接, 总体上来说就是查阅ISA手册, 将汇编代码中的文本指令逐条翻译成相应的二进制编码, 从而得到目标文件(object file). 可以通过如下命令来让clang
生成汇编后的目标文件:
clang -c a.c
ls a.o
不过, 目标文件中包含的内容已经不是文本了, 因此用文本编辑器打开目标文件将得到不可读的内容. 为了查看目标文件中的内容, 我们需要一些面向二进制文件的解析工具, 来帮我们将这些文件中的二进制内容解析成可读的文本内容. 例如, 我们可以利用binutils(Binary Utilities)
工具包中的objdump
工具来解析目标文件:
objdump -d a.o
从目标文件中重新解析出汇编代码的过程称为"反汇编"(disassemble). 上述命令将得到x86的反汇编代码, 它和编译器生成的汇编文件是类似的.
为了得到riscv32的目标文件, 我们需要进行交叉编译:
clang -c a.c --target=riscv32-linux-gnu
riscv64-linux-gnu-objdump -d a.o
类似地, 我们也可以用gcc
来生成目标文件:
gcc -c a.c
riscv64-linux-gnu-gcc -march=rv32g -mabi=ilp32 -c a.c
或者用llvm的工具链来进行反汇编, 它可以自动识别目标文件对应的ISA架构, 用起来更方便:
llvm-objdump -d a.o # x86和riscv32的目标文件均支持
查看riscv32目标文件的反汇编结果
根据上述命令, 查看riscv32
目标文件的反汇编结果, 并将其与编译器生成的汇编文件进行对比.
链接
链接的工作是将多个目标文件合并成最终的可执行文件. 可以通过如下命令来让clang
生成链接后的可执行文件:
clang a.c
ls a.out
可执行文件同样可以通过objdump
进行反汇编.
不过你会发现, 和链接前的目标文件(a.o
)相比, 链接后的可执行文件中多出了不少内容. 为了进一步了解这些内容来自哪里, 我们可以查看clang
命令执行时的日志:
clang a.c --verbose
在日志末尾可以看到链接相关的命令, 在该命令中还包含了若干命名形如crt*.o
的目标文件. 此处的crt
是C runtime
的缩写, 表示C程序的运行时环境. 也即, 链接过程会将a.c
编译和汇编后得到的目标文件, 和已有的C程序运行时环境相关的目标文件进行合并, 最终生成可执行文件. 可以遇见, 可执行文件之所以要包含这些和运行时环境相关的目标文件, 是为了向可执行文件的执行提供必要的支持.
类似地, 我们也可以用gcc
来生成可执行文件:
gcc a.c
对于RISC-V的可执行文件, 由于系统中缺少riscv32
的运行时环境, 因此编译器无法生成riscv32
的可执行文件, 但由于存在riscv64
的运行时环境, 因此可以生成riscv64
的可执行文件:
clang a.c --target=riscv64-linux-gnu
riscv64-linux-gnu-gcc a.c
查看riscv64可执行文件的反汇编结果
尝试生成riscv64
的可执行文件, 查看其反汇编结果, 并将其与链接前的目标文件进行对比.
链接的过程还涉及较多细节, 此时不必深究, 我们会在D阶段中再展开介绍.
执行
通过以下命令来执行编译出的可执行文件:
./a.out
你已经熟悉处理器执行指令的过程了: 取指, 译码, 执行, 更新PC. 只要将程序的指令序列放置在内存, 并让PC指向第一条指令, 处理器就会自动执行这个程序.
对比编译优化前后的性能差异
我们之前介绍了各种编译优化的选项, 现在你可以来体会一下这些选项的威力了. 以之前介绍的数列求和程序为例, 你可以测量不同编译优化等级下程序的运行时间, 从而体会不同优化等级对程序性能的影响.
不过之前的数列求和程序的末项为10
, 为了方便测量出性能的区别, 你需要调整程序的末项, 以增加其执行时间. 如果程序的末项较大, 你还可以将sum
变量的类型修改为long long
. 你可以通过time
命令来测量一条命令的执行时间, 如time ls
将报告ls
命令的执行时间.
调整数列的末项后, 分别在-O0
, -O1
, -O2
下编译并测量程序的运行时间.
如果你感兴趣, 你还可以通过反汇编来查看相应的汇编代码, 并尝试根据汇编代码理解: 为什么会得到相应的性能提升? 编译器可能应用了哪些编译优化技术? 不过为了回答这些问题, 你可能需要通过RTFM或STFW来了解一些汇编指令的功能.
不过, 编译出生成的可执行文件在外存(磁盘或SSD)中, 怎么把它放置在内存呢? 回顾之前用Logisim实现的处理器, 我们是直接将指令序列以常数的形式连接到电路中, 或者通过GUI的Load Image
操作将程序的指令序列读入到ROM
组件中. 这两种方式的本质都是我们手动来完成"将程序的指令序列放置在内存"的工作, 但在上述./a.out
的命令中, 我们并没有手动完成这项工作.
在我们执行./a.out
的时候, 究竟是谁完成了"将程序的指令序列放置在内存"的工作, 使得处理器可以从内存中取出程序的指令序列来执行呢? 事实上, 现代操作系统中有一个称为"加载器"的特殊程序, 它的工作是将其他可执行文件从外存读入(加载)到内存, 并跳转到相应的程序入口处执行指令.
但处理器只能执行符合其ISA规范的指令, 例如, 大多数同学使用的是配备x86处理器的计算机, 因此只能执行x86的可执行文件, 而无法执行RISC-V的可执行文件; 如果强行让x86处理器执行RISC-V的指令序列, 在译码过程中可能会将RISC-V指令识别成其他行为的x86指令, 甚至识别成非法指令, 使得处理器无法按照RISC-V指令原本的语义去执行, 程序执行的结果将不符合预期. 因此, 加载器在加载程序的过程中会检查程序的指令序列属于哪种ISA, 如果与当前处理器的ISA不一致, 加载器将停止加载并报错.
加载程序是程序运行之前的必要步骤, 因此加载器属于运行时环境的一部分. 上文提到的crtxxx.o
这些目标文件, 其中就包含加载器的部分功能. 运行时环境还包含其他功能, 例如, 在上述./a.out
的执行过程中, 运行时环境的作用还体现在:
- 在程序执行开始前, 准备各种初始化事项. 在之前学习C语言时, 你可能会认为程序就是从
main()
开始执行的. 但如果真的是这样, 我们在命令行中输入的程序参数, 是如何传递给main()
的argc
和argv
的呢? 事实上, 这项工作也是运行时环境来完成的: 运行时环境在完成加载程序, 参数准备等一系列准备工作后, 才会调用main()
. - 在程序执行过程中, 提供
printf()
等库函数的支持. 上文的示例代码中直接调用了printf()
, 并没有编写printf()
的代码, 但执行./a.out
的时候确实成功通过printf()
输出了信息. 因此我们有理由猜测,crtxxx.o
这些目标文件以直接或者间接的方式提供了执行printf()
的方法. 事实上, 库函数确实是运行时环境的一部分. - 在程序执行结束后, 提供程序退出的功能. 在之前学习C语言时, 你可能会认为程序从
main()
返回后就直接退出了. 但如果你能理解运行时环境在main()
执行之前还做了很多准备工作, 你也很容易猜到, 从main()
返回之后应该回到运行时环境, 再由它来开展一些程序退出前的清理工作.
一般来说, 光有程序本身还不能运行, 从广义的角度来讲, 所有支撑程序运行的功能, 都属于运行时环境的范畴.
手册行为和编码规范
和ISA手册定义了指令的语义一样, C语言的语义也是通过相应的手册定义的. C语言在上世纪70年代问世后, 由于其高效, 灵活和移植性高等特点被广泛使用, 但是各种C语言在不同编译器中的变体和扩展带来了兼容性问题. 为了解决这一挑战, ANSI组织在1983年成立了一个委员会, 来开展C语言的标准化工作. C语言的标准经过多年的发展, 演进出各种版本, 包括C90, C99, C11, C17, C23. 这些版本通常以标准发布的年份来命令, 例如, C11标准是在2011年左右发布的. 在"一生一芯"中, 我们以C99为例, 来了解C语言标准中的一些定义和概念, 从而对C程序的行为建立更全面的认识.
程序执行的语义
回顾计算机系统的状态机模型, C程序执行的过程就是通过C程序的语句改变程序状态的过程. 这个理解是基于状态机模型的, 现在我们可以来看看C语言标准是如何定义"程序执行"的, 这将有助于我们进一步认识"程序执行"的细节.
C99手册的第5.1.2.3节定义了"程序执行", 我们对这些定义逐条进行说明:
1 The semantic descriptions in this International Standard describe the behavior of
an abstract machine in which issues of optimization are irrelevant.
在手册中, 程序执行的语义描述是针对抽象机(abstract machine)而言的, 其中不涉及优化的话题. 在这里, 抽象机的概念和我们之前介绍ISA时讨论的模型机很类似, 都是只讨论其具备的功能和行为, 而不讨论其具体实现.
2 Accessing a volatile object, modifying an object, modifying a file, or calling a
function that does any of those operations are all side effects, which are changes
in the state of the execution environment. Evaluation of an expression in general
includes both value computations and initiation of side effects. Value computation
for an lvalue expression includes determining the identity of the designated object.
访问volatile
对象, 修改对象, 修改文件, 或者调用一个包含上述操作的函数, 都称为"副作用", 也即执行环境状态的改变. 对表达式的求值通常包括值的计算和副作用的引入. 对左值表达式的计算还包括决定目标对象的实体.
针对"访问"(access)和"修改"(modify), 它们的概念均在手册第3章Terms, definitions, and symbols
中定义. 具体地, "访问"是指"读出"或"修改"对象的值, 其中, "修改"包含"需要存储的新值与旧值相同"的情况.
3 Sequenced before is an asymmetric, transitive, pair-wise relation between
evaluations executed by a single thread, which induces a partial order among those
evaluations. Given any two evaluations A and B, if A is sequenced before B, then the
execution of A shall precede the execution of B. (Conversely, if A is sequenced
before B, then B is sequenced after A.) If A is not sequenced before or after B,
then A and B are unsequenced. Evaluations A and B are indeterminately sequenced when
A is sequenced either before or after B, but it is unspecified which. The presence
of a sequence point between the evaluation of expressions A and B implies that every
value computation and side effect associated with A is sequenced before every value
computation and side effect associated with B. (A summary of the sequence points is
given in annex C.)
- "前序于"是针对在一个线程中执行的求值所定义的一个反对称和传递性的二元关系, 通过它可以得到这些求值之间的一个偏序.
- 给定两个任意的求值
A
和B
, 如果A
前序于B
, 那么A
的执行发生在B
的执行之前. - 相反地, 如果
A
前序于B
, 那么B
后序于A
. - 如果
A
既不前序于B
, 也不后序于B
, 那么A
和B
是未定序的. - 如果
A
前序于B
, 或者A
后序于B
, 但并未指定是何者, 则称A
和B
是不确定序的. - 如果
A
和B
之间存在一个序列点, 那么, 和A
相关的所有值的计算和副作用, 都前序于和B
相关的所有值的计算和副作用. - 附录C给出了序列点的总结.
这一条内容通过序列点的概念严格定义了不同求值操作之间的顺序关系, 从而严格定义了整个程序执行的语义. 例如, 有以下程序:
a = 1;
b = a + 2;
根据附录C中对序列点的总结, 表达式语句之间的表达式求值存在序列点. 根据序列点的定义, 这意味着, a = 1
这个表达式相关的所有值的计算和副作用, 都需要在对b = a + 2
进行求值之前发生并生效, 因此, 对b = a + 2
进行求值时, a
的值已经被修改为1
, 从而让程序此刻在变量a
中读出1
.
你可能会觉得这好像没什么用, 让我们来看下面的例子, 这个程序的输出是什么?
#include <stdio.h>
int f() { printf("in f()\n"); return 1; }
int g() { printf("in g()\n"); return 2; }
int h() { printf("in h()\n"); return 3; }
int main () {
int result = f() + g() * h();
return 0;
}
事实上, 这个程序可能输出任意的函数调用顺序, 这是因为, 在同一个表达式语句中, 多个函数调用之间是不确定序的, 因此它们可以按任意顺序调用. 如果程序的行为依赖于某种调用顺序, 程序的执行结果将可能不符合预期.
4 In the abstract machine, all expressions are evaluated as specified by the
semantics. An actual implementation need not evaluate part of an expression if it
can deduce that its value is not used and that no needed side effects are produced
(including any caused by calling a function or accessing a volatile object).
在抽象机中, 所有表达式都按照相应的语义进行求值. 而在一个具体实现中, 如果一个表达式中的其中一部分的值没有被使用, 也没有产生副作用(包括由调用函数和访问volatile
对象引起的副作用), 那么, 这部分表达式可以不进行求值.
这一条内容其实指示了表达式求值过程中的优化空间.
5 When the processing of the abstract machine is interrupted by receipt of a signal,
the values of objects that are neither lock-free atomic objects nor of type volatile
sig_atomic_t are unspecified, as is the state of the floating-point environment. The
value of any object modified by the handler that is neither a lock-free atomic
object nor of type volatile sig_atomic_t becomes indeterminate when the handler
exits, as does the state of the floating-point environment if it is modified by the
handler and not restored to its original state.
这一条内容和信号机制相关, 已经超出了当前的学习范围, 因此这里不展开说明.
6 The least requirements on a conforming implementation are:
— Accesses to volatile objects are evaluated strictly according to the rules of the
abstract machine.
— At program termination, all data written into files shall be identical to the
result that execution of the program according to the abstract semantics would
have produced.
— The input and output dynamics of interactive devices shall take place as specified
in 7.21.3. The intent of these requirements is that unbuffered or line-buffered
output appear as soon as possible, to ensure that prompting messages actually
appear prior to a program waiting for input.
This is the observable behavior of the program.
一个符合规范的实现至少需要满足3点要求, 这就上文讨论编译优化时的"程序可观测行为的一致性".
7 What constitutes an interactive device is implementation-defined.
交互式设备具体包含哪些, 是由实现来定义的.
8 More stringent correspondences between abstract and actual semantics may be
defined by each implementation.
每个实现可以进一步定义抽象语义和实际语义之间的严格对应关系.
标准规范的实现
标准规范的本质是一些定义和约定, 通常以手册作为载体呈现. 为了让标准规范得到落实, 需要将标准规范以某种形式实现出来, 这样的形式称为标准规范的实现(implementation). 例如, ISA的本质是标准规范, 而通过数字电路将这一规范实现出来的处理器, 就是ISA的一种实现. 显然, 处理器的行为需要符合ISA标准规范.
类似地, C语言标准也有相应的实现. C99手册的第3.12节对"什么是实现"进行了定义:
particular set of software, running in a particular translation environment
under particular control options, that performs translation of programs for,
and supports execution of functions in a particular execution environment
也即, C语言标准的实现是一系列特定软件, 这些软件在特定的翻译环境中运行, 用于进行程序的翻译, 并支持程序的相应功能在特定的执行环境中执行. 如果用我们熟悉的概念去理解, C语言标准的实现就是编译器(用于进行程序的翻译)和运行时环境(用于支持程序的执行). 同样地, 编译器和运行时环境需要符合C语言标准.
标准规范定义了很多细节, 包括各种"应该"或者"不应该", 这些属于明确定义的行为, 具体实现的相应行为需要遵循标准规范. 但并非所有情况下的行为, 标准规范都能明确定义. 理解具体实现会如何处理这些行为, 将有助于我们理解计算机系统如何工作, 并指导我们如何编写更规范的代码来规避这些行为.
以C99标准为例, 无法明确定义的行为有以下几类, 它们的概念均在手册第3章Terms, definitions, and symbols
中定义.
未指定行为(Unspecified Behavior)
C99手册对Unspecified Behavior的定义如下:
use of an unspecified value, or other behavior where this International
Standard provides two or more possibilities and imposes no further requirements
on which is chosen in any instance
也即, 对于这类行为的结果, C语言标准提供了多种选择, 但没有规定选择哪一种, 具体实现可以从中选择一种.
C99手册中举了如下示例:
An example of unspecified behavior is the order in which the arguments to a
function are evaluated
也即, 函数调用时参数求值顺序是未指定的. 我们可以通过以下程序来验证:
// a.c
#include <stdio.h>
void f(int x, int y) {
printf("x = %d, y = %d\n", x, y);
}
int main() {
int i = 1;
f(i ++, i ++);
return 0;
}
在yzh的系统中分别通过gcc
和clang
编译并运行程序, 结果如下:
$ gcc a.c && ./a.out
x = 2, y = 1
$ clang a.c && ./a.out
x = 1, y = 2
可以看到, 即使是同一份程序代码, 使用不同的编译器进行编译, 程序的运行结果会有所不同.
体验未指定行为
尝试在你的系统中编译运行上述程序, 观察程序的结果.
如果你尝试在自己的系统上编译并运行上述程序, 得到的结果可能和yzh不同. 如果是这样, 说明即使是同一个编译器, 使用不同的版本进行编译这个程序, 也可能会得到不同的运行结果, 但这仍然符合C语言标准. 更极端地, 某编译器通过随机方式决定函数调用时的参数求值顺序, 这仍然符合C语言标准!
if (rand() & 1) { 从左向右求值; }
else { 从右向左求值; }
既然C语言标准和C语言标准的实现都没有错, 那出问题的只能是程序. 事实上, 上述程序的行为依赖于C语言标准中的未指定行为, 使得程序在不同的编译器中有不同的表现. 如果不希望出现这种结果, 我们应该编写出不受未指定行为影响的代码. 例如, 将上述代码按如下方式改写:
int i = 1;
int x = i ++;
int y = i ++;
f(x, y);
这样, 无论f(x, y)
采用何种求值顺序, 程序的行为总是输出x = 1, y = 2
, 从而不受未指定行为的影响.
尝试从序列点的角度理解参数求值的顺序
在C99手册中查阅参数求值的相关内容, 手册是如何通过序列点的角度说明参数求值的顺序的?
实现定义行为(Implementation-defined Behavior)
C99手册对Implementation-defined Behavior的定义如下:
unspecified behavior where each implementation documents how the choice is made
也即, 这类行为是一类特殊的未指定行为, 但具体实现需要将行为的选择写进相关文档中. 和一般的未指定行为不同, 具体实现将实现定义行为将选择写进相关文档后, 就不能再修改了, 具体实现不仅需要遵循C语言标准, 还需要遵循自己的相关文档. 因此, 包含这类行为的程序, 在特定的环境下(包括编译器和运行时环境)多次编译运行, 仍然可以得到相同的结果.
一个常见的例子是整数类型的长度. 事实上, C语言标准从来没有定义过每个整数类型有多长. C99手册的5.2.4.2小节对整数类型长度的相关规定如下:
An implementation is required to document all the limits specified in this
subclause, which are specified in the headers <limits.h> and <float.h>.
Additional limits are specified in <stdint.h>.
关于整数类型的长度, C99手册的5.2.4.2.1小节的规定如下:
The values given below shall be replaced by constant expressions suitable for
use in #if preprocessing directives. Moreover, except for CHAR_BIT and
MB_LEN_MAX, the following shall be replaced by expressions that have the same
type as would an expression that is an object of the corresponding type
converted according to the integer promotions. Their implementation-defined
values shall be equal or greater in magnitude (absolute value) to those shown,
with the same sign.
在这里我们列出上文中提到的the values given below
的其中一些取值:
取值 | 说明 | |
---|---|---|
SCHAR_MIN | signed char 的最小值 | |
SCHAR_MAX | signed char 的最大值 | |
UCHAR_MAX | unsigned char 的最大值 | |
SHRT_MIN | short int 的最小值 | |
SHRT_MAX | short int 的最大值 | |
USHRT_MAX | unsigned short int 的最大值 | |
INT_MIN | int 的最小值 | |
INT_MAX | int 的最大值 | |
UINT_MAX | unsigned int 的最大值 | |
LONG_MIN | long int 的最小值 | |
LONG_MAX | long int 的最大值 | |
ULONG_MAX | unsigned long int 的最大值 | |
LLONG_MIN | long long int 的最小值 | |
LLONG_MAX | long long int 的最大值 | |
ULLONG_MAX | unsigned long long int 的最大值 |
可以看到, C语言标准只定义了每个整数类型的取值的最小范围, 具体实现可以为每个整数类型的取值定义更大的范围, 但不能比C语言标准所定义的最小范围更小.
为什么C语言标准要这样规定呢? 我们可以从C99手册的摘要部分了解到C语言标准的目标:
This International Standard specifies the form and establishes the
interpretation of programs expressed in the programming language C. Its purpose
is to promote portability, reliability, maintainability, and efficient
execution of C language programs on a variety of computing systems.
为了最大程度地兼容各种计算机系统, C语言标准的制定需要考虑:
- 要支持过去的计算机系统, 于是很多规定不能说太死. 细心的你可能已经注意到, 在C语言标准中,
signed char最小值
的最大值是, 但8位二进制补码可以表示的最小数是. 这是因为C语言标准考虑到, 过去有些计算机采用原码或反码表示, 在这些计算机中, 8位有符号数可以表示的最小数是. 如果C语言标准将signed char最小值
的最大值定义为, 就无法兼容这些计算机.
1个字节有多长?
你可能会不假思索地回答"8比特". 但事实上, C语言标准第3.6节规定, 1字节所包含的比特数量是implementation-defined的:
A byte is composed of a contiguous sequence of bits, the number of which
is implementation-defined
这显然是为了兼容过去的计算机系统: 在历史上, 1字节的长度曾经在不同的计算机中被定义成不同数量的比特, 从1比特到48比特都有. 于是, C语言标准不能直接将1字节
定义成8比特
, 而是交给具体实现来定义.
细心的你也许已经发现, 上文引用的C语言标准中的内容, 是用取值范围来间接描述整数类型的长度, 而不是用类似4字节
的直接描述, 其实也是为了避免使用字节
这个在不同的实现上存在歧义的概念.
- 要支持将来的计算机系统, 于是很多规定也要能被将来兼容. C语言标准只定义了每个整数类型的取值的最小范围, 就是在给将来留空间. 以
int
类型为例, 在一些上世纪90年代的环境中 (如Turbo C, 一些老教材使用的开发环境),int
是16位的; 在32位系统中(如windows的VC 6.0, 或Linux的GCC),int
是32位的; 在现代64位系统中,int
还是32位的.
查看Linux系统中整数类型的取值范围
Linux的运行时环境也是C语言标准的具体实现的一部分, 你可以通过查看/usr/include/limits.h
, 来了解在这个具体实现中整数类型的取值范围.
总之, 对于包含实现定义行为的程序, 在特定环境中多次编译运行, 可以得到相同的结果. 但如果要将程序移植到另一个环境, 就需要考虑实现定义行为在新环境中的表现与旧环境的差异. 例如, 如果要将一个32位的程序移植到16位的环境(虽然现在这种需求极少出现), 一个需要考虑的问题是int
类型的数据可能会发生溢出, 一种解决方案是将其改为long
类型, 因为C语言标准规定, long
类型的取值范围至少是, 能包含32位环境中int
类型的取值范围.
区域特定行为(Locale-specific Behavior)
C99手册对Locale-specific Behavior的定义如下:
behavior that depends on local conventions of nationality, culture, and
language that each implementation documents
也即, 这类行为的结果依赖于国家地区, 文化和语言的本土习惯, 具体实现需要将行为的结果写入文档中, 是一类特殊的实现定义行为.
一个例子是扩展字符集. 扩展字符集中包含哪些字符, 属于区域特定行为. 考虑如下程序:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <assert.h>
#define 主函数 main
#define 返回 return
char* 字符串拼接(char *串1, char *串2) {
char *新串 = malloc(strlen(串1) + strlen(串2) + 1);
assert(新串);
strcpy(新串, 串1);
strcat(新串, 串2);
返回 新串;
}
int 主函数() {
char *信息 = 字符串拼接("一生一芯", "很简单");
printf("%s\n", 信息);
free(信息);
返回 0;
}
上述程序使用了包含中文字符的字符串和标识符. 在支持中文字符的具体实现中, 该程序可以成功编译并运行; 但在只支持基本字符集(即ASCII字符集)的具体实现中, 上述程序将无法通过编译.
通常, 开发国际化软件(即i18n)时需要考虑区域特定行为. 除了字符集外, 区域特定行为还包括小数点字符, 货币符号, 时间和日期的格式等. 不过"一生一芯"中不涉及这类软件, 因此你无需深入了解.
未定义行为(Undefined Behavior)
C99手册对Undefined Behavior的定义如下:
behavior, upon use of a nonportable or erroneous program construct or of
erroneous data, for which this International Standard imposes no requirements
这是一类程序或数据不符合标准的错误行为, 但C语言标准对这种行为的结果不作任何约束, 也即, 无论结果如何, 都符合C语言标准. 通俗来说, 就是"一切皆有可能". C99手册列举了一些可能的结果:
Possible undefined behavior ranges from ignoring the situation completely with
unpredictable results, to behaving during translation or program execution in a
documented manner characteristic of the environment (with or without the
issuance of a diagnostic message), to terminating a translation or execution
(with the issuance of a diagnostic message).
这些结果包括:
- 编译时或执行时报错并退出
- 按照具体实现的文档要求来处理, 可能不报警告
- 完全无法预料的结果
一个未定义行为的例子是缓冲区溢出. 考虑如下程序:
// a.c
#include <stdio.h>
int main() {
int a[10] = {0};
printf("a[10] = %d\n", a[10]);
return 0;
}
体验未定义行为
尝试在你的系统中编译并多次运行上述程序, 观察程序的结果.
上述程序在调用printf()
时引用了a[10]
, 访问的元素已经越过了数组的边界. 按照C语言标准, a[10]
的值是未定义的. 因此, 无论是编译器报错, 还是程序运行过程中报错, 还是程序输出0
或垃圾值, 这些行为都符合C语言标准.
包含未定义行为的程序, 即使是在编译之后多次运行, 大概率也无法得到正确的结果. 如果你的程序在多次运行的过程中得到不同的结果, 在排除外部带来的随机因素之后, 你就可以怀疑你的程序存在未定义行为了.
为什么要RTFM
我们之所以在上文中引用C语言标准中的原文, 就是希望让大家看到C语言标准对一些概念的精确定义. 你大概率很难从其他材料中看到类似的概念及其定义. 例如, 即使你上过C语言课, 或者通过其他材料学习过C语言, 你也可能是第一次听说"未定义行为"和"序列点"这些概念.
这说明了, 你接触的C语言教材和学习资料, 并不是C语言的全部. 事实上, C语言标准精确定义了C语言的每一处细节, 我们也有理由相信, 在对C语言的认知方面, C语言标准的制定者比书籍和博客的作者要深刻.
当然, 不同的人学习C语言的目标也并不相同. 如果是为了用C语言来编写一些简单的程序, 或者是为了通过考试, 大部分教材和学习资料已经能满足这些需求. 在此基础上, 如果你想进一步深入学习C语言, 或者是想理解计算机系统工作的基本原理, 甚至是想构建一台属于自己的计算机系统, 阅读C语言标准手册将是你的不二选择.
"一生一芯"的目标是后者, 因为处理器芯片和其他芯片不同, 其设计目标就是为了运行软件程序. 因此, 我们是从计算机系统的视角来看待处理器芯片设计: 你不仅需要学会设计出一颗处理器芯片, 还需要学会在这颗处理器芯片上把程序运行起来, 从而检查程序运行得对不对, 运行得好不好.
随着学习的深入, 你会逐渐遇到一些书籍和博客都无法解释清楚的问题. 这时候你需要意识到, 你已经开始踏入专业人士的领域了. 学会阅读手册, 是成为专业人士的通行证.
不过手册中内容繁多, 我们建议你阅读手册, 并不是希望你在短时间内掌握其中的所有内容, 更重要的是培养阅读手册的意识: 当你想彻底弄清楚一个问题, 你应该想到阅读手册的相关内容; 即使能在互联网上搜到相关回答, 你也应该想着看看手册是怎么说的, 除非这些回答显式地引用了手册中的内容(这样的回答是优秀的!). 如果你能建立这样的意识并付诸实践, 你对C语言的理解就已经进入行业水平的前1%了.
上述不确定行为的参考列表
C99手册的附录J(Annex J)列出了C语言标准中上述4类行为的所有内容, 必要时可查阅.
应用程序二进制接口(Application Binary Interface)
我们已经知道, C语言标准的具体实现是一系列软件, 主要是编译器和运行时环境, 其中编译器负责生成程序的二进制可执行文件, 运行时环境负责支撑程序的运行. 一方面, 程序的二进制可执行文件和ISA相关, 程序也是在相应ISA的处理器上执行; 另一方面, 运行时环境包含库函数和操作系统的部分功能, 程序在运行的前, 中, 后都需要和它们交互.
因此, 程序, 编译器, 操作系统, 库函数, ISA这些概念, 它们作为一个计算机系统的整体, 彼此之间存在一定的关联. 这种关联一般通过约定和规范来呈现, 这就是应用程序二进制接口, 简称ABI, 也即, ABI是程序在二进制层面与上述概念之间的接口规范.
C语言标准由于要兼容各种计算机系统, 因此无法精确定义很多行为的结果. 而对于一个特定的计算机系统, 很多条件是确定的. ABI作为这个计算机系统在二进制层次的约定, 它可以看成是C语言标准的一种具体实现的文档, 因此对于C语言标准层次中的很多实现定义行为, 其选择都会写入ABI中.
例如, 对于特定的ISA, 字节和通用寄存器的位宽都是确定的. 基于这一点, ABI就可以确定C语言标准中整数类型的取值范围, 整个计算机系统对整数类型取值范围的理解都会遵循ABI, 从而基于一致的认识共同支撑程序的运行.
ABI作为一种规范, 其内容包括:
- 处理器的指令集, 寄存器结构, 栈的组织, 访存类型等
- 处理器可直接访问的基本数据类型的大小, 布局, 对齐方式
- 调用约定, 用于规定函数的参数如何传递, 返回值如何获取
- 应用程序如何向操作系统发起系统调用
- 目标文件的格式, 支持的运行库等
我们再次看到: 程序在特定计算机系统上的运行结果, 与源代码, 编译器, 运行时环境, ISA, 硬件等都有关联; 而ABI也是计算机系统软件和硬件相互协助, 共同支撑程序运行的重要体现. 目前你只需要简单认识ABI即可, 我们将会在D阶段以RISC-V为例, 再展开介绍ABI的具体内容.
RTFM
当然, 不仅是C语言标准的手册, 我们也推荐大家在茶余饭后阅读其他手册, 包括RISC-V手册, Verilog手册, RISC-V的ABI手册等, 它们能为你理解相关细节带来最全面, 最权威的帮助. 如果你发现阅读它们非常吃力, 不必担心, 当你随着学习的深入积累越来越多的基础知识后, 再回过头来阅读它们, 就会越来越顺利.
指令集模拟器 - 可以执行程序的程序
我们已经介绍了从C代码到二进制程序的步骤, 为了进一步理解程序执行的本质, 我们尝试用C语言来实现程序执行的过程. 这样一个用来执行程序的程序, 称为模拟器. 在工业界的处理器设计流程中, 模拟器起着非常重要的作用. 往后, 你也会使用模拟器帮助你完成处理器设计流程中的一些重要工作, 不过目前我们还是先关注如何实现一个简单的模拟器.
你已经尝试在Logisim中设计过CPU了, 其过程就是用数字电路的状态机实现ISA的状态机. 要用C语言来实现模拟器, 其实需要考虑的是如何用C程序的状态机实现ISA的状态机. 为此, 我们先回顾状态机视角下的C程序和ISA:
C程序 | ISA | |
---|---|---|
状态 | ||
激励事件 | 执行语句 | 执行指令 |
状态转移规则 | 语句的语义 | 指令的语义 |
要用C程序的状态机实现ISA的状态机, 我们需要开发一个包含如下功能的C程序:
- 用C程序的状态实现ISA的状态, 也即, 用C程序的变量实现ISA的PC, GPR和内存
- 用C程序的状态转移规则实现ISA的状态转移规则, 也即, 用C语言语句实现指令的语义
这种仅仅从ISA层面实现指令行为的模拟器, 称为指令集模拟器. 类似地, 还有架构模拟器和电路模拟器, 不过我们暂时不会接触它们.
sEMU的基本实现
下面以sISA为例说明如何实现指令集模拟器, 我们称相应的指令集模拟器为sEMU(simple EMUlator). 关于sISA的细节, 请查阅之前的讲义.
sEMU要用C程序的变量实现ISA的PC, GPR和内存, 这并不难:
#include <stdint.h>
uint8_t PC = 0;
uint8_t R[4];
uint8_t M[16];
其中, 虽然在ISA层面中PC位宽为4位, 但C语言中不存在4位的基础数据类型, 故此处使用uint8_t
; 此外, PC的位宽只有4位, 说明sISA的程序最多只能包含16条指令, 因此用于实现内存的数组M
的大小只需设置为16
即可.
sEMU要用C语言语句实现指令的语义, 这也不难. 从指令周期的角度考虑, 需要编写一个函数inst_cycle()
实现如下功能:
- 取指 - 直接根据
PC
索引内存M
, 即可取出一条指令 - 译码 - 通过C语言的位运算抽取出指令的
opcode
字段, 并检查属于哪一条指令; 然后根据指令格式抽取出操作数字段, 并获得相应的操作数 - 执行 - 若执行的指令不是
bner0
, 则将结果写回目的寄存器; 否则, 根据判断情况决定是否进行跳转 - 更新PC - 若不跳转, 则让
PC
加1
实现inst_cycle()
后, 只需要让sEMU不断调用它即可:
while (1) { inst_cycle(); }
上文已经考虑了sISA的所有细节, 但要让程序在sEMU中运行起来, 不仅需要实现ISA层次的功能, 还需要考虑如何给程序提供相应的运行时环境. 但毕竟sISA的功能有限, 它只能支持少数程序的运行, 因此我们以"只运行数列求和程序"为前提, 来讨论运行时环境应该需要提供哪些支持. 我们从三个方面来考虑:
- 在程序执行开始前: 一个需要考虑的问题是如何加载程序. 但因为sEMU只打算运行数列求和这一个程序, 我们可以直接对
M
进行初始化:由于目前数列求和程序的运行过程中并没有涉及参数, 故运行时环境可暂时不考虑程序参数的传递.uint8_t M[16] = { ... };
- 在程序执行过程中: 由于目前数列求和程序的运行过程中并没有涉及库函数的调用, 故运行时环境可暂时不考虑库函数的支持.
- 在程序执行结束后: 由于目前数列求和程序通过死循环来指示程序结束, 而这已经通过指令层面的
bner0
指令来实现, 故无需额外提供程序退出的功能.
实现sEMU
根据上述思路, 用C代码实现sEMU, 并运行之前的数列求和程序. 由于数列求和程序本身不会结束, 你可以修改while
语句的循环条件, 在循环一定的次数后退出循环, 然后检查数列求和的结果是否符合预期.
强化运行时环境
可以看到, 这个运行时环境比上文讨论的./a.out
涉及的运行时环境简单很多. 事实上, C语言标准定义了两种运行时环境, 一种称为"独立环境"(freestanding environment), 这个运行时环境通常没有操作系统的支持, 程序通常直接运行在裸机(bare-metal)上, 刚才讨论的sEMU的运行时环境就属于独立环境; 另一种称为"宿主环境"(hosted environment), 它指代有操作系统运行的情况, 程序通常运行在操作系统上, 由操作系统的相关组件提供运行时环境的支持, 上文讨论的./a.out
的运行时环境就属于宿主环境.
你已经能理解, 如果程序的ISA和处理器的ISA不一致, 程序就无法运行. 但即使ISA一致, 程序也不一定能成功运行, 还需要考虑运行时环境. 假设你实现了一个类似sEMU的x86指令集模拟器, 但你还是无法将上文的./a.out
, 这是因为./a.out
假设自己运行在Linux这个宿主环境上, 需要运行时环境提供一系列的功能支持; 但你实现的指令集模拟器提供的运行时环境属于独立环境, 它提供的支持无法满足./a.out
的需求. 另一个例子是, 如果你将一个Windows的程序拷贝到Linux下, 它也无法成功运行, 虽然Windows和Linux均属于宿主环境, 但它们提供的具体支持并不相同.
从另一个侧面看, 为了让程序的功能更丰富, 除了添加更多的指令, 我们还可以向程序提供功能更强大的运行时环境. 下面我们尝试向sEMU添加更多功能.
首先, 我们来向sEMU添加输出功能, 使得在其上运行的程序可以输出一个整数. 具体地, 你需要为sISA添加一条新指令out rs
, 执行该指令后, 会将R[rs]
输出到终端. 你可以自行决定这条指令的编码, 然后在sEMU中实现它.
一个需要考虑的问题是如何实现"输出到终端"的功能, 毕竟"终端"这个概念已经不属于sISA的范畴了, 确切来说, 它属于Linux这个宿主环境的范畴. 注意到, 虽然sISA的程序运行在sEMU提供的运行时环境中, 但同时sEMU也运行在Linux提供的运行时环境中, 因此我们可以借助Linux运行时环境的功能来实现"输出到终端"的效果. 而这样的功能, 自然是printf()
了, 也即, sEMU通过调用printf()
, 向其上运行的程序提供"输出到终端"的运行时环境支持, 从而实现out
指令的约定功能. 从运行时环境的角度来看, 这一功能属于程序执行过程中的支持.
实现输出功能
根据上文, 在sEMU中实现out
指令, 并修改数列求和程序, 使得在计算出结果后通过out
指令输出计算结果. 如果你的实现正确, 你应该能看到终端上显示55
.
然后我们来考虑实现程序的传参. 之前的数列求和程序只能计算1+2+...+10
, 我们希望把数列的末项作为参数, 也即, 我们希望用户从命令行输入参数n
, 让数列求和程序计算1+2+...+n
. 为了将n
传递给程序, 我们需要让运行时环境在程序开始执行之前将n
放在一个约定的位置, 这样程序就可以从约定的位置读到n
, 从而进行计算. 一种简单的方式是将这个参数放在寄存器中, 例如r0
.
实现参数化的数列求和
修改数列求和程序, 使其从r0
中获取数列的末项. 然后修改sEMU, 来将用户输入的参数放置在r0
中. 实现后, 简单测试你的实现, 例如, 键入./semu 10
, 程序应输出55
; 键入./semu 15
, 程序应输出120
. 我们假设用户的输入不会导致计算过程溢出, 因此你不必考虑如何处理结果溢出的情况.
经过上述增强后, 我们简单总结sEMU向程序提供的运行时环境: 我们从三个方面来考虑:
- 在程序执行开始前, 程序的指令序列将存放在内存中. 此外, 在
r0
寄存器中存放程序的参数. - 在程序执行过程中, 可通过
out
指令以整数方式输出指定寄存器的值. - 在程序执行结束后, 程序将陷入死循环.
minirvEMU的实现
你已经知道如何实现sEMU了, 类似地, 你也可以为之前的minirv实现一个指令集模拟器, 我们称它为minirvEMU. 关于minirv的细节, 请参考之前的讲义.
实现两条指令的minirvEMU
设计支持addi
和jalr
指令的minirvEMU, 并让其运行之前在Logisim上运行过的那个两条指令的测试程序.
要为minirvEMU添加剩余的6条指令并不难, 但要运行sum
和mem
两个程序, 则需要一些额外的考量. 这两个程序有很多指令, 不方便手动对M
进行初始化. 为了自动对M
进行初始化, 我们可以让minirvEMU通过文件方式将需要运行的程序读入到M
中. 以sum
程序为例, 我们可以通过以下命令将之前的.hex
文件转换成二进制表示的.bin
文件:
tail -n +2 sum.hex | sed -e 's/.*: //' -e 's/ /\n/g' | sed -e 's/\(..\)\(..\)\(..\)\(..\)/\4 \3 \2 \1/' | xxd -r -p > sum.bin
得到.bin
文件后, 你就可以通过C语言中的文件操作将其读入M
并执行它了.
实现完整的minirvEMU
为minirvEMU添加剩余的6条指令, 并更新加载程序的方式, 然后运行之前在Logisim上运行过的sum
和mem
两个程序. 为了判断程序是否成功结束运行, 你可以参考之前在Logisim上的判断方式.
之前我们是人工判断Logisim上运行的程序是否成功结束, 一个原因是, Logisim作为GUI程序, 不太方便添加个性化的功能. 而minirvEMU作为一个C程序, 我们可以尝试让minirvEMU自动判断程序是否成功结束.
具体地, 我们可以添加一条ebreak
指令, 并将其功能定义为程序结束. 为了实现程序结束的自动判断, 我们让minirvEMU执行ebreak
后停止仿真. 为了实现程序结束状态正确性的自动判断, 我们对运行时环境的功能进行扩充, 约定在程序结束前, 先将一个表示结束状态的整数写入a0
寄存器, 然后执行ebreak
指令. 这个整数如果为0
, 表示程序正确结束; 若不为0
, 表示程序发生错误. 根据这个约定, 一方面, 我们需要修改程序结束时的指令序列来实现这个约定; 另一方面, 在minirvEMU执行ebreak
后, 可以通过检查a0
寄存器的值, 来查看程序是否成功结束. 例如, 若a0
为0
, 则输出HIT GOOD TRAP
的信息, 表示程序成功结束; 若a0
不为0
, 则输出HIT BAD TRAP
的信息, 表示程序发生错误.
为了修改程序来遵守上述运行时环境的约定, 我们可以让minirvEMU在将程序读入M
后, 手动将ebreak
指令的编码写入一个正确的位置: 关于ebreak
指令的具体编码, 请RTFM; 而对于"正确的位置", 就是指程序中halt
函数的附近. 例如, 你可以通过如下方式写入ebreak
指令:
M[? + 0] = ?;
M[? + 1] = ?;
// ......
你需要找到正确的内存位置和ebreak
指令的编码, 然后替换上述?
.
实现程序结束的自动判断
根据上述运行时环境的约定, 在minirvEMU中添加并实现ebreak
指令, 然后修改程序的指令序列, 使其在结束时执行ebreak
指令. 如果你的实现正确, 你会看到程序自动结束并通过minirvEMU输出结束信息.
当然, 这个过程仍然涉及不少手动操作, 不过目前我们需要运行的程序并不多, 这些手动操作的开销尚可接受. 当需要运行的程序数量越来越多, 我们还是需要想办法实现全自动的判断方式. 我们将会在C阶段中继续讨论这个问题.
支持GUI输入输出的程序
你之前已经在Logisim中让minirv处理器运行一个显示图片的程序, 那么, 能否也在minirvEMU中运行这个程序呢? 但是, 你之前开发的C程序都只能通过终端进行输入输出, 那么, 我们平时常用的GUI程序是如何开发的呢? 为了给大家建立简单的认识, 我们先来让你体验一个经典游戏超级玛丽.
运行超级玛丽
阅读PA1的在开始愉快的PA之旅之前->NEMU是什么?
开头部分的讲义内容, 按照讲义指示尝试运行超级玛丽, 并完成画面, 按键和声音的检查.
你一定会对超级玛丽具体如何运行感到好奇. 事实上, 超级玛丽本身也是一个指令序列, 不过其中的指令属于一种叫6502的指令集. 这种指令集是上世纪80年代的时候问世的, 现在已经很少使用, 相应的处理器也不那么常见了. 但你刚才并没有在真正的6502处理器上运行超级玛丽, 那超级玛丽究竟是如何运行起来的呢? 刚才其实是先运行了一个6502指令集的模拟器程序fceux
, 这个程序的行为十分特殊, 它可以通过软件的行为模拟6502指令执行的过程, 从而模拟出整个超级玛丽游戏执行的过程!
体验GUI输入输出
不过我们还是先来考虑GUI程序相关的内容, 后面再回过头来讨论模拟器. 我们引入一个叫abstract machine
(简称AM)的运行时环境, 它以库函数的方式提供了一些API(Application Programming Interface), 用于实现一些在GUI中常用的输入输出功能. 刚才的fceux
正是通过调用这些API, 来实现超级玛丽的操作和显示. 但fceux
的行为对大家来说还是太复杂, 我们还是先通过一些简单的示例程序来体会fceux
用到的输入输出功能.
体验时钟功能
通过如下命令运行时钟测试程序:
cd am-kernels/tests/am-tests
make ARCH=native mainargs=t run
你会发现程序在终端上每隔1s输出一句话. 此外, 程序还会弹出一个画面全黑的新窗口, 但在当前程序中无任何功能, 目前你不必关心它.
体验上述功能后, 尝试阅读am-kernels/tests/am-test/src/tests/rtc.c
, 理解上述功能是如何实现的. 其中, 代码io_read(AM_TIMER_UPTIME).us
将获得程序运行以来经过的时间, 单位是us.
体验按键功能
通过如下命令运行按键测试程序:
cd am-kernels/tests/am-tests
make ARCH=native mainargs=k run
你会发现程序弹出一个画面全黑的新窗口, 在新窗口中按下按键, 你将会看到程序在终端输出相应的按键信息, 包括按键名, 键盘码, 以及按键状态.
体验上述功能后, 尝试阅读am-kernels/tests/am-test/src/tests/keyboard.c
, 理解上述功能是如何实现的(目前你可以忽略代码中uart
相关的功能). 其中, 代码io_read(AM_INPUT_KEYBRD)
将获得一个按键事件ev
, ev.keycode
表示按键的编码, ev.keydown
表示按键为按下还是释放. 按键的编码值可查阅abstract-machine/am/include/amdev.h
, 它们均以AM_KEY_
为前缀, 如A
键的编码为AM_KEY_A
. 特别地, AM_KEY_NONE
表示无按键事件.
体验显示功能
通过如下命令运行显示测试程序:
cd am-kernels/tests/am-tests
make ARCH=native mainargs=v run
你会发现程序弹出一个新窗口并播放动画.
体验上述功能后, 尝试阅读am-kernels/tests/am-test/src/tests/video.c
, 理解上述功能是如何实现的. 其中, 代码io_write(AM_GPU_FBDRAW, x, y, pixels, w, h, true)
表示向屏幕(x, y)
坐标处绘制w*h
的矩形图像. 图像像素按行优先方式存储在pixels
中, 每个像素用32位整数以00RRGGBB
的方式描述颜色.
设计简单的屏幕保护程序
了解如何在代码中使用GUI输入输出后, 我们接下来将实现一个简单的屏幕保护程序, 来了解如何在AM上开发一个简单的GUI程序.
首先, 我们先尝试让弹出的窗口显示某种颜色. 我们给出如下的框架代码:
#include <am.h>
#include <klib-macros.h>
void draw(uint32_t color) {
// add code here
}
int main() {
ioe_init(); // initialization for GUI
while (1) {
draw(0x000000ff);
}
return 0;
}
为了使用AM提供的功能, 我们需要编写一个符合AM规范的Makefile
文件, 例如:
NAME = screensaver
SRCS = screensaver.c
include $(AM_HOME)/Makefile
如果你保存的C代码的文件名不是screensaver.c
, 你需要按照实际的文件名修改上述Makefile
文件. 然后通过以下命令编译运行:
make ARCH=native run
你应该能看到程序弹出一个画面全黑的新窗口, 这是因为你还没有正确实现void draw(uint32_t color)
函数. 关于上述Makefile
和AM的详细信息, 我们将在D阶段进行介绍, 目前你只需要知道如何使用AM提供的功能来开发, 编译, 运行程序即可.
实现单种颜色的显示
实现上述的draw()
函数, 它将弹出的窗口填充为参数color
所指示的颜色, 窗口的分辨率为400x300
.
实现后, 重新编译运行. 如果你的实现正确, 你应该看到弹出的窗口填充了蓝色.
我们需要实现的屏幕保护程序的效果是让屏幕在多种颜色之间发生渐变. 渐变算法需要在当前颜色和目标颜色之间线性插入种颜色, 并依次显示它们, 从而实现渐变效果. 假设当前颜色是, 渐变过程经过步后变化为目标颜色, 每个颜色分量的取值都是, 则第步(应该显示的颜色为
当一轮渐变结束后, 就选择下一种目标颜色进行下一轮的渐变. 目标颜色可以在以下颜色中随机选择:
0x000000, 0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff, 0xffffff
我们只需要通过时钟相关的代码控制屏幕显示颜色的间隔, 即可实现渐变效果.
实现颜色渐变效果
按照上述介绍, 实现颜色渐变效果. 你可以自行决定每轮渐变持续多长时间, 以及一轮渐变中的步数.
最后, 让我们把按键的功能用起来. 具体地, 我们希望实现如下的按键功能:
- 如果按下
ESC
键(按键编码为AM_KEY_ESCAPE
), 则退出程序 - 如果按下其他任意键, 则加快一轮渐变的时间; 释放按键后, 渐变时间恢复
添加按键效果
按照上述介绍, 为屏幕保护程序添加按键效果.
屏幕保护程序如何保护屏幕
过去, 人们通常采用CRT显示器, 其工作原理是通过电子枪发射的高速电子束轰击屏幕的荧光层, 从而使其发光来显示图像. 但如果屏幕显示的画面不变, 电子束将长时间轰击荧光层的相同位置, 会使得该位置的显像管加速老化, 甚至击穿显像管, 损坏屏幕. 屏幕保护程序通过不断变化屏幕上显示的图像, 从而不断改变电子束轰击屏幕的位置和强度, 避免损坏屏幕.
不过, 如今人们用的更多是LCD显示器(即液晶显示器), 其工作原理与CRT显示器不同, 过去的屏幕保护程序如今已经不能起到保护作用了. 上面的必做题也仅仅是作为一个简单的练习, 并不考虑其保护功能. 事实上, 屏幕显示的画面不变通常意味着用户长时间未对计算机进行操作, 此时最好的做法是关闭显示器.
实现屏幕保护程序之后, 你应该对GUI程序的开发过程有了基本的了解. 但是, 我们并没有进一步解释io_read()
和io_write()
究竟是如何实现输入输出的: 为什么调用io_read(AM_INPUT_KEYBRD)
就可以获得按键的信息? 为什么调用io_write(AM_GPU_FBDRAW, x, y, pixels, w, h, true)
就可以往屏幕绘制像素? 在真实的计算机系统中, 这是需要多个抽象层次的模块之间相互协助才能实现的, 包括运行时环境, 指令集, 处理器, 总线, 设备控制器, I/O设备. 随着学习进度的深入, 你将会在B阶段了解到这个过程中的每一处细节.
开发自己的小游戏
如果你感兴趣, 你还可以利用上述API来开发一款属于自己的小游戏! 所谓小游戏, 其本质不过是一个死循环:
while (1) {
等待新的一帧();
处理用户按键();
更新游戏逻辑();
绘制新的屏幕();
}
你刚才开发的屏幕保护程序, 本质上也是这样一个死循环, 只不过其游戏逻辑非常简单, 只包含一个渐变算法的处理. 更多地, am-kernels
还提供了一些小游戏的示例:
am-kernels/kernels/typing-game
- 打字游戏am-kernels/kernels/snake
- 贪吃蛇
开发自己的小游戏
尝试构思并设计一个小游戏. 一方面, 将来这个小游戏可以运行在你自己设计的处理器上; 另一方面, 我们也长期征集好玩有趣的小游戏, 如果你设计的游戏足够优秀, 将有机会收录到我们维护的游戏集合中, 供其他同学进行娱乐, 甚至在真实的芯片上展示.
一些有用的资源:
- 显示字符:
am-kernels/kernels/typing-game/font.c
提供了字符的位图, 请查阅相关代码了解如何使用 - 显示图片: 可以通过命令
convert abc.jpg abc.txt
将一张图片转化成一个枚举像素的文本文件, 文件中按顺序列举了每一个像素的颜色编码, 你可以通过脚本进一步处理这个文本文件, 将其转化成一个C语言的数组
如果你计划向我们提交你设计的游戏, 可联系助教. 但为了保证你的游戏可以在其他同学的环境中运行, 请额外保证如下内容:
- 可移植性:
- 代码中不使用浮点数
- 需要进行小数相关的计算时, 可放大到整数后进行操作, 例如, 要处理一个3位小数, 可将其放大1000倍后再处理
- 需要计算
sin
等初等函数时, 可通过幂级数展开来近似计算
- 需要64位整数类型时, 使用
uint64_t
或int64_t
- 需要使用整数类型存放指针时, 使用
uintptr_t
或intptr_t
- 代码中不使用浮点数
- 可玩性: 进入游戏主体后, 如果玩家不操作, 一段时间后游戏将失败
- 可退出: 按下
ESC
键后, 代码调用halt(0)
退出
为minirvEMU添加图形显示功能
你已经设计过屏幕保护程序了, 因此要为minirvEMU添加图形显示功能并不困难. 具体地, 你需要额外定义一个数组用于存放程序将要写入的像素信息, 并在sw
指令的实现中判断其地址是否落在[0x20000000, 0x20040000)
, 若是, 则将这次写入操作写入到上述数组中. 等到程序运行结束后, 再通过AM提供的API将这些像素显示到屏幕上.
添加图形显示功能
根据上述思路, 为minirvEMU添加图形显示功能, 然后运行之前在Logisim上运行过的vga
程序.
一些提示:
- 为了使用AM提供的功能, 你可以参考上文屏幕保护程序的相关代码
- 为了防止minirvEMU在显示图像后马上退出, 可以让它进入死循环
虽然目前的minirvEMU只运行了一个显示图片的程序, 但不难想象, 如果它支持更多的指令, 并且提供时钟和按键等更完善的输入输出功能, 你就得到了一个更强大的, 类似fceux
的模拟器, 有可能运行超级玛丽, 甚至是你自己开发的游戏! 到了D阶段, 你就能实现这个目标了!
此外, AM作为一个运行时环境, 我们可以借助它编译出通过ebreak
指令来结束的程序, 而不需要手动通过minirvEMU在正确的位置写入ebreak
指令. 我们将会在D阶段再来展开介绍AM中的细节.
最后, 你应该能感受到, 同样是执行minirv指令集的程序, 用C语言开发一个模拟器, 比用Logisim电路开发一个处理器简单得多: 我们只要考虑如何用C语言实现指令的行为即可, 不必考虑其电路实现, 也不必考虑连线等细节. 事实上, 实现一个真正的处理器还要考虑更多的因素, 包括时序, 面积, 功耗等, 你会在B阶段对此有更深刻的认识. 但从学习的角度来考虑, 也正是因为模拟器的实现无需考虑这些因素, 它成为了了解指令集和程序行为的一种很好的方式. 在工业界的处理器设计流程中, 模拟器还充当着功能验证, 程序分析和调试的重要作用, 一些更细致的体系结构模拟器还是处理器设计空间探索的重要组成部分. 随着学习的深入, 你也会逐渐体会到模拟器的这些意义.