上一篇文章简单提到了编译过程。对于笔者而言,C++编译运行似乎只是在IDE界面按下F5按钮,然后把一切事情交给编译器,然后等待正确运行,在终端开始交互测试,或者看着出现的warning和error开始漫长的debug。。。从未想过背后编译器都做了什么事。实际上想成为一个真正的底层开发者,应该学会和编译器打交道。
很遗憾在本科专业学习中并没有《编译原理》这门课程,也没有特地研究过这门学问,只是在一次又一次的debug中获得只言片语的了解。想在短时间内掌握这门技术是很困难的,也难以在一篇文章里说清楚来龙去脉。所以笔者从一个简单的C++ 程序展开,一步一步探究程序员使用的高级语言是如何被一层一层“翻译”直到变成计算机可以读懂并执行的字节的。
笔者写了一个最简单的C++程序,内容是大家在学习无论何种计算机语言的时候通常会写的第一个demo:hello world。这里没有使用using namespace,语句能达成最基本功能即可。囿于这方面的知识有限,分析过程中还请读者指出错误。
int
C/C++编译是集成的,笔者常用gcc/g++ [source file] -o [executable file]。其中隐藏了很多步骤,拆分开来是以下四个步骤:
先来看第一个步骤预处理的结果,helloworld.i。由于遵循C语言文法,它仍然是C文件。文件有733行,故节选部分笔者认为较有价值的片段进行分析。
首先笔者对define进行了全局搜索,没有结果,可以印证预处理过程中对所有define都完成了变量替换。helloworld.i的最前面是一系列和stdio.h有引用关系的头文件路径。
# 1 "helloworld.c"
# 1 ""
# 1 "<命令行>"
# 31 "<命令行>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<命令行>" 2
# 1 "helloworld.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
# 1 "/usr/include/bits/libc-header-start.h" 1 3 4
# 33 "/usr/include/bits/libc-header-start.h" 3 4
# 1 "/usr/include/features.h" 1 3 4
# 450 "/usr/include/features.h" 3 4
# 1 "/usr/include/sys/cdefs.h" 1 3 4
# 460 "/usr/include/sys/cdefs.h" 3 4
# 1 "/usr/include/bits/wordsize.h" 1 3 4
# 461 "/usr/include/sys/cdefs.h" 2 3 4
# 1 "/usr/include/bits/long-double.h" 1 3 4
# 462 "/usr/include/sys/cdefs.h" 2 3 4
# 451 "/usr/include/features.h" 2 3 4
# 474 "/usr/include/features.h" 3 4
# 1 "/usr/include/gnu/stubs.h" 1 3 4
# 10 "/usr/include/gnu/stubs.h" 3 4
# 1 "/usr/include/gnu/stubs-64.h" 1 3 4
# 11 "/usr/include/gnu/stubs.h" 2 3 4
# 475 "/usr/include/features.h" 2 3 4
# 34 "/usr/include/bits/libc-header-start.h" 2 3 4
# 28 "/usr/include/stdio.h" 2 3 4
接下来是一系列常用数据类型的别名定义。
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;
typedef signed char __int8_t;
typedef unsigned char __uint8_t;
typedef signed short int __int16_t;
typedef unsigned short int __uint16_t;
typedef signed int __int32_t;
typedef unsigned int __uint32_t;
typedef signed long int __int64_t;
typedef unsigned long int __uint64_t
IO_FILE的文件定义。
typedef struct _IO_FILE FILE;
# 43 "/usr/include/stdio.h" 2 3 4
# 1 "/usr/include/bits/types/struct_FILE.h" 1 3 4
# 35 "/usr/include/bits/types/struct_FILE.h" 3 4
struct _IO_FILE;
struct _IO_marker;
struct _IO_codecvt;
struct _IO_wide_data;
typedef void _IO_lock_t;
struct _IO_FILE
{
int _flags;
char *_IO_read_ptr;
char *_IO_read_end;
char *_IO_read_base;
char *_IO_write_base;
char *_IO_write_ptr;
char *_IO_write_end;
char *_IO_E_base;
char *_IO_buf_end;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset;
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
__off64_t _offset;
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data;
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
size_t __pad5;
int _mode;
char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
};
三个常用标准输入输出流文件
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
之后是大量的文件处理(fseek、fread、fwrite等)、输入输出函数(printf、sprintf、fprintf等)声明,此处截取部分。补充以下此处使用extern关键字,使该函数在实现部分使用原来文件里面的定义。
extern int fgetc (FILE *__stream);
extern int getc (FILE *__stream);
extern int getchar (void);
extern int getc_unlocked (FILE *__stream);
extern int getchar_unlocked (void);
# 510 "/usr/include/stdio.h" 3 4
extern int fgetc_unlocked (FILE *__stream);
# 521 "/usr/include/stdio.h" 3 4
extern int fputc (int __c, FILE *__stream);
extern int putc (int __c, FILE *__stream);
extern int putchar (int __c);
# 537 "/usr/include/stdio.h" 3 4
extern int fputc_unlocked (int __c, FILE *__stream);
extern int putc_unlocked (int __c, FILE *__stream);
extern int putchar_unlocked (int __c);
extern int getw (FILE *__stream);
extern int putw (int __w, FILE *__stream);
最后才是笔者写的程序本体。
可见,预处理阶段,编译器将代码中的stdio.h编译进来,把#include包含进来的.h 文件插入到#include所在的位置,把源程序中使用到的用#define定义的宏用实际的字符串代替。
接着对该文件进行编译处理,这一步编译器检查是否有语法错误,准确无误则开始。结果如下(不太懂Assembly,不作注释,maybe I will add them someday... )。
.file "helloworld.c"
.text
.section .rodata
.LC0:
.string "Hello World!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 9.2.0"
.section .note.GNU-stack,"",@progbits
汇编阶段,汇编语言被翻译为机器语言,二进制文件。当笔者打算查看此helloworld.o文件的时候,无论是terminal还是VSCode都对笔者发出了“是否继续查看”的提示。果然人类无法阅读:
最后一步是连接,完成这一步即可得到一个可以运行的可执行文件了。但是稍等,有一个问题,“连接”的是什么东西?所需的外部文件在预处理阶段就已经包括进来,为什么还需要引入外部文件?所以提出一个关键概念——链接库。库有两种,一种是静态链接库,一种是动态链接库,不管是哪一种库,要使用它们,都要在程序中包含相应的include头文件。linux中,静态库文件后缀.a,动态库文件后缀.so。
什么是静态链接呢?即在链接阶段,将源文件中用到的库函数与汇编生成的目标文件.o合并生成可执行文件。该可执行文件可能会比较大。这种链接方式的好处是:方便程序移植,因为可执行程序与库函数再无关系,放在如何环境当中都可以执行。缺点是:文件太大。想尝试的话可以在编译参数中加上-static。
那么什么是动态连接呢?我们知道静态链接的话,文件会很大,往往实现很小的一个功能就需要占用很大的空间,而且每次库文件升级的话,都要重新编译源文件,很不方便。而且静态文件在内存中多份拷贝,占用很大空间。gcc默认先采用动态库。但是动态库在内存中存在一份即可。动态链接有一个缺点就是可移植性太差,如果两台电脑运行环境不同,动态库存放的位置不一样,很可能导致程序运行失败。所以应该结合实际环境考虑采用何种库。
此处引用一下yanlei的回答(thanks)
yanlei:编译器编译原理:预处理,编译,汇编,链接各步骤详解zhuanlan.zhihu.com静态库链接时搜索路径顺序:
动态链接时、执行时搜索路径顺序:
有关环境变量:
那么笔者再写一个例子,说明这两种库的使用。
写两个文件,add.h和add.c,并且目录结构如下所示:
[jaimeow@jaimeow-pc demo2]$ ls -R
.:
add.c addlib add.o test test.c
./addlib:
add.h libadd.a
#ifndef ADD_H_
#define ADD_H_
int add(int a,int b);
#endif
#include ".addlib/add.h"
#include
int add(int a,int b){
return a + b;
}
然后执行gcc -c add.c和ar -crv libadd.a add.o,生成add.o(目标文件)和libadd.a(静态库文件)。把库文件封装在addlib目录下,再写一个测试样例。
#include "./addlib/add.h"
#include
int main(){
int a = 1,b = 2;
printf("%d",add(a,b));
return 0;
}
结果可以正确运行,输出3。
那么来试一下动态库,gcc -fPIC -shared -o libadd.so add.c,gcc -o test test.c -L./addlib -ladd。如果把libadd.so放入刚才的addlib目录中提示找不到共享库,放入可执行文件所在目录即可正确运行,输出3。从这一点来看,的确静态库是运行之前包括的,动态库是运行时加载的。
写完这篇文章,对计算机语言的认识更进一步,程序员在编程的时候首先要做到了解自己所用的工具,才能更好的使用工具,让它变成你的开发之友。这也是区分程序员水平之处。
(如有转载请注明作者与出处,欢迎建议和讨论,thanks)