zcc-编译器开发总结-part5-链接和加载流程

注意:本系列文章是 zcc 编译器 总结系列文章,本系列文章并不打算呈现所有的代码细节(主要是细节太多了,全部呈现既不现实文章也看着冗长而没有意义)。因为是总结性质的文章,所以更多是分享实现的过程中个人感兴趣的点

本文基于 zcc 编译器的 main.c

经过项目编译后,会生成一个 bin 文件 – parser,对于这个 parser 来说,需要能够编译这些 .zc 结尾的代码文件,目前来说 parser 对标 gcc 编译器的某些参数,实现基本的链接和加载功能

支持的 Features

  • -T: 表示代码编译过程中输出 ast 结构
  • -o: 表示代码编译成指定的 bin 文件
  • -c: 表示代码可以经过编译后生成目标文件,但不将它们链接生成 bin 文件
  • -S: 表示代码经过编译只生成汇编文件
  • -v: 表示代码编译过程中输出一些详细信息
  • -M: 表示代码编译过程中输出 symbol table 相关信息

大体流程

  1. 扫描命令行的输入

    主要处理类似于

    parser -o out xxx.c yyy.c

    或者

    parser -T xxx.c yyy.c

    类似的语句

  2. 循环编译 - Flag 后面接着的文件,比如上面提到的 xxx.cyyy.c

    • 如果要生成 bin 或者要保存目标文件,就对编译生成的汇编文件进行操作,将它们编译后生成的 .o 的文件路径保存在一个数组中
    • 如果汇编文件不需要保存,那么就删除掉它
  3. 如果要生成 bin ,将第 2 步拿到的数组里面的 .o 文件,对它们做 link 操作生成 bin,即

    cc -o out xxx.o yyy.o

    如果不需要保存 *.o 文件就将它们全部删除

代码为

int main(int argc, char **argv) {
char *output_filename = A_OUT;
char *assembly_file, *object_file;
char *object_file_list[MAX_OBJECT_FILE_NUMBER];
int i, j, object_file_count = 0;

...

// 1. 扫描命令行的输入
for (i = 1; i < argc; i++) {
if (*argv[i] != '-') break;
for (j = 1; (*argv[i] == '-') && argv[i][j]; j++) {
switch (argv[i][j]) {
case 'T': output_dump_ast = 1; break;
case 'o': output_filename = argv[++i]; break;
case 'c':
output_keep_object_file = 1;
output_keep_assembly_file = 0;
output_binary_file = 0;
break;
case 'S':
output_keep_object_file = 0;
output_keep_assembly_file = 1;
output_binary_file = 0;
break;
case 'v': output_verbose = 1; break;
case 'M': output_dump_symbol_table = 1; break;
default: usage_info(argv[0]);
}
}
}

if (i >= argc) usage_info(argv[0]);

// 2. 循环编译 `-` Flag 后面接着的文件,比如上面提到的 `xxx.c` 和 `yyy.c`
while (i < argc) {
assembly_file = do_compile(argv[i]);

// 如果要生成 bin 或者要保存目标文件,就对编译生成的汇编文件进行操作,将它们编译后生成的 `.o` 的文件路径保存在一个数组中
if (output_binary_file || output_keep_object_file) {
object_file = do_assemble(assembly_file);
if (object_file_count == (MAX_OBJECT_FILE_NUMBER - 2)) {
fprintf(stderr, "Too many object files for the compiler to handle\n");
exit(1);
}
object_file_list[object_file_count++] = object_file;
object_file_list[object_file_count] = NULL;
}

// 如果汇编文件不需要保存,那么就删除掉它
if (!output_keep_assembly_file) do_unlink(assembly_file);
i++;
}

// 3. 如果要生成 bin ,将第 2 步拿到的数组里面的 `.o` 文件,对它们做 `link` 操作生成 bin
if (output_binary_file) {
do_link(output_filename, object_file_list);

// 如果不需要保存 `*.o` 文件就将它们全部删除
if (!output_keep_object_file)
for(i = 0; object_file_list[i]; i++)
do_unlink(object_file_list[i]);
}

return (0);
}

用一张图来表示就是

下面会说一些实现的细节

编译器生成汇编文件

代码如下

static char *do_compile(char *filename) {
// 命令行,长度为 512
char cmd[TEXT_LENGTH];

// 编译后要输出的文件名是 xxx.s,
// .s 即汇编文件后缀
global_output_filename = modify_string_suffix(filename, 's');
if (!global_output_filename) {
fprintf(stderr, "Error: %s has no suffix, try .zc on the end\n", filename);
exit(1);
}

// 执行预处理
// 即 cpp -nostdinc -isystem INCDIR xxx.zc
// INCDIR 在 incdir.h 里面定义
// popen 执行上述命令
snprintf(cmd, TEXT_LENGTH, "%s %s %s", CPP_CMD, INCDIR, filename);
if (!(input_file = popen(cmd, "r"))) {
fprintf(stderr, "Unable to open %s: %s\n", filename, strerror(errno));
exit(1);
}
// 保存 xxx.zc 这个文件名,后面的流程会用到
global_input_filename = filename;

// 打开 xxx.s 文件
if (!(output_file = fopen(global_output_filename, "w"))) {
fprintf(stderr, "Unable to open %s: %s\n", global_output_filename, strerror(errno));
exit(1);
}

// 一些初始化操作
line = 1;
start_line = 1;
putback_buffer = '\n';
clear_all_symbol_tables();

// 输出 verbose
if (output_verbose)
printf("compiling %s\n", filename);

// 扫描文件中的字符串,并将其赋值给 token_from_file 这个全局变量
scan(&token_from_file);
look_ahead_token.token = 0;

// 解析代码并往 output_file 里面写入汇编代码
generate_preamble_code();
parse_global_declaration();
generate_postamble_code();

// 关闭文件
fclose(input_file);
fclose(output_file);

// 输出 symbol table 相关信息
if (output_dump_symbol_table) {
printf("Symbols for %s\n", filename);
dump_symbol_table();
fprintf(stdout, "\n\n");
}

// 清理所有 static 的 symbol
clear_all_static_symbol();

// 返回 xxx.s 这个汇编文件名
return (global_output_filename);
}

由上面代码注释可以看出,总体逻辑主要还是将 *.zc 代码变成 *.s 的过程,其中 popen 的作用是以 只读 方式执行 cpp -nostdinc -isystem INCDIR xxx.zc 这个命令,这个命令的意思是 编译 xxx.zc 代码时,对于头文件,不要去标准系统目录(-nostdinc)去找,而是到 INCDIR 这个目录下去寻找(-isystem INCDIR)

为什么要这么做?

在 zcc 编译器项目中,有一个 include 的文件夹,这个 include 文件夹下包含了编译器所需要的所有的头文件,这个头文件里面有事先定义好的一些宏,以及一些需要使用到的标准库函数定义,这样做可以避免直接使用标准库定义,因为有些东西是编译器自己定义的,比如 # define NULL (void *)0

汇编器生成目标文件

代码如下

char *do_assemble(char *filename) {
// 命令行,长度为 512
char cmd[TEXT_LENGTH];
int error;

// 比如汇编后要输出的文件名是 xxx.o,
// .o 即汇编文件后缀
char *output_filename = modify_string_suffix(filename, 'o');
if (!output_filename) {
fprintf(stderr, "Error: %s has no suffix, try .s on the end\n", filename);
exit(1);
}

// 这里调用 system 执行命令
// 即 as -o xxx.o xxx.s
snprintf(cmd, TEXT_LENGTH, "%s %s %s", AS_CMD, output_filename, filename);
if (output_verbose) printf("%s\n", cmd);
error = system(cmd);

// 检查汇编器执行结果
if (error) {
fprintf(stderr, "Assembly of %s failed\n", filename);
exit(1);
}

// 返回 xxx.o
return (output_filename);
}

由上面代码注释可以看出,总体逻辑主要还是将 *.s 代码变成 *.o 的过程,这里使用了一个系统调用函数 system,这个函数传的是一个命令行字符串,它内部会先调用 fork 创建一个子进程,在这个子进程中,会通过调用 exec 函数执行指定的 shell 命令

链接器链接目标文件

代码如下

void do_link(char *output_filename, char **object_file_list) {
int count, size = TEXT_LENGTH;
// 命令行,长度为 512
// p 是这个命令行的指针
char cmd[TEXT_LENGTH], *p;
int error;

p = cmd;
// 比如命令为
// cc -o out
// out 表示要生成的 bin 文件名
// count 表示这个 `cc -o out` 字符串的长度
count = snprintf(p, size, "%s %s ", LD_CMD, output_filename);
p += count;
size -= count;

while (*object_file_list) {
// 继续往 cmd 里面塞字符串,这里是 xxx.o
// 一直循环到 *object_file_list 为 NULL 为止
count = snprintf(p, size, "%s ", *object_file_list);
p += count;
size -= count;
object_file_list++;
}

// 输出 verbose
if (output_verbose) printf("%s\n", cmd);
// 执行命令
// 即 cc -o out xxx.o
error = system(cmd);

// 检查有没有执行错误
if (error) {
fprintf(stderr, "Linking failed\n");
exit(1);
}
}

由上面代码注释可以看出,总体逻辑主要还是将 *.o 代码变成 bin 文件的过程,逻辑上还是很简单的

删除文件

这个很简单,直接系统调用 unlink,代码如下

void do_unlink(char *filename) {
unlink(filename);
}

unlink 就是删除掉指定的文件

总结

整体来讲,就是源码编译成一个编译器,然后给编译器的运行时加上一些参数,使得它能够自己编译指定的代码文件成一个 bin 文件的过程