一、Linux编译器 - gcc/g++ 使用
1、程序运行的四个阶段
在C语言 程序员内功心法之程序环境和预处理 博文中,我们就学习到 – 一个程序要被运行起来需要经历四个阶段:预处理 (预编译)、编译、汇编、链接,下面我们来简单回顾一下这四个阶段会进行的操作。
1.1 预处理
预处理也叫预编译,程序在预处理阶段会完成如下操作:
- 注释的删除。
- #define 定义的符号、宏的替换以及删除。
- 条件编译的执行。
- 头文件的包含:将头文件中的代码拷贝到当前代码中来。
在Linux下我们可以通过如下命令来得到预处理之后的代码:
gcc -E test.c -o test.i # gcc:表示用 gcc 编译器来编译此代码 # -E:表示让代码在完成预处理后停下来,不再继续往后编译 # test.c:我们要编译的代码 # test.i 预处理产生的文件一般以.i为后缀 # -o test.i:用于指明临时文件的名称(test.i),它会将预处理之后的代码保存到指明的临时文件中,而不是直接打印到终端上
//测试案例 -- test.c #include <stdio.h> #define M 1024 int main() { //测试注释 //printf("hello 1\n"); //printf("hello 2\n"); //printf("hello 3\n"); printf("hello 4\n"); printf("hello 5\n"); printf("hello 6\n"); printf("hello 7\n"); //测试宏 printf("%d\n", M); //测试条件编译 #ifdef SHOW printf("hello SHOW\n"); #else printf("hello DEFAULT\n"); #endif return 0; }
可以看到,预处理后 stdio.h 里面的内容会被拷贝到 test.i 里面,所以 test.i 一共有800多行;同时,预处理阶段也完成了我们上述提到的其他工作。
1.2 编译
程序在编译阶段会完成如下操作:
- 语法分析。
- 词法分析。
- 语义分析。
- 符号汇总。
在Linux下我们可以通过如下命令来得到编译之后的代码:
gcc -S test.i -o test.s # -S:表示让代码在完成编译后停下来,不再继续往后编译 # 编译产生的文件一般以.s为后缀
可以看到,编译阶段会将高级语言转换为汇编语言。
1.3 汇编
汇编阶段是把编译阶段生成汇编代码转成计算机可以识别的二进制目标代码,其中生成的 .o 文件被称为可重定向二进制目标文件。
在Linux下我们可以通过如下命令来得到编译之后的代码:
gcc -c test.s -o test.o # -c:表示让代码在完成编译后停下来,不再继续往后编译 # 汇编产生的文件一般以.o为后缀
如上,汇编得到的二进制目标文件使用一般的文本编辑器打开时是一堆我们看不懂的符号 (与符号的编码有关 – utf-8),我们可以使用 od 指令以指定格式来打开它 (默认是以八进制打开)。
1.4 链接
程序在链接阶段会完成如下操作:
合并段表:编译器会把在汇编阶段生成的多个目标文件中相同格式的数据合并在一起,最终形成一个 .exe 文件。
符号表的合并和重定位:符号表的合并是指编译器会把在汇编阶段生成的多个符号表合并为一个符号表;重定位则是指当同一个符号出现在两个符号表中时,编译器会选取其中和有效地址相关的那一个,舍弃另外一个。
在Linux中,链接我们直接使用 gcc 即可,没有额外选项,因为链接是程序的最后一个阶段;同时,链接的结果默认存放在 a.out 中。
gcc test.o -o test.out
链接得到的文件被称为可执行程序,它里面存放的也是计算机能够识别的二进制指令。
注:gcc 预处理编译链接三个阶段对应的选项和文件后缀有一个记忆技巧 – ESc 与 iso,其中 ESc 分别代表 -E -S -c,iso 分别代表 .i,.s,.o;ESc 可以对比电脑上的 [Esc] 键,将其中的 s 改为大写即可;iso 可以对比苹果 ios,将 o 和 s 的顺序调换即可。
同时,我们此处将 gcc 编译代码分为预处理、编译、汇编、链接四个阶段是为了让大家更深层次的理解一个程序的运行过程;日常编译代码的时候直接使用 “gcc test.c -o test.out” 或 “gcc test.c” 编译源文件得到可执行程序即可。
2、链接方式与函数库
2.1 动态链接与静态链接
我们在编写代码的时候,除了自己实现函数之外,我们还会去调用函数库中的代码,比如 scanf/printf/malloc/fopen;但是我们要明白,库中的代码是别人给我们写好供我们直接使用的,即我们只有该函数的调用,而没有函数的实现;
同时,程序在预处理、编译和汇编阶段处理的都是我们自己编写的代码,只有在链接的时候,库函数的实现才会和我们的代码关联起来 (符号表的重定位);所以,链接的本质是我们在调用库函数时如何与标准库相关联的问题。
程序一共有两种链接方式:动态链接与静态链接;动态链接是指执行代码时,如果遇到库函数调用就跳转到动态库中对应函数的定义处,然后执行该函数,执行完毕后再跳转回原程序并继续往下执行;它的优点是形成的可执行程序小,缺点是受到动态库变动 (删除、升级等) 的影响。
静态链接则是直接将本程序内部要使用的库函数从对应的静态库中拷贝一份过来;它的优点是不与静态库产生关联,即不受静态库变动 (删除、升级等) 的影响;缺点是形成的可执行程序非常大。
2.2 动态库与静态库
函数库是一些事先写好的,用于给别人复用的函数的集合,函数库一般分为静态库和动态库两种:
静态库是指编译链接时,把需要的库文件代码全部拷贝到可执行文件中,因此生成的文件非常大,但在运行时也就不再需要库文件了,在Linux下其后缀名为 “.a”,在Windows下其后缀名为 “.lib”;
动态库也被称为共享库,它与静态库相反,在编译链接时并没有把相应的库文件代码加入到可执行文件中,而是在程序执行时由运行时链接文件来加载库,这样可以节省系统的开销,在Linux下其后缀名为 “.so”,在Windows下其后缀名为 “.dll”;
注:动态链接必须使用动态库,静态链接必须使用静态库;即进行动态链接时只能跳转到动态库中对应函数的实现处,进行静态链接时只能拷贝静态库中的函数。
Linux中默认使用动态库进行动态链接,原因如下:
程序形成的可执行程序大不仅仅是占用的磁盘空间大,其被运行时加载到内存所占用的内存空间也会非常大,而目前我们使用的机器的内存基本上都是8/16GB的,所以运行不了过大的可执行程序;虽然动态链接受函数库变动的影响,但是函数库一般很少会变动,即使变动也必须兼容以前的版本,所以影响不大;
在Linux下,我们可以使用 “file” 指令来辨识文件类型,使用 “ldd” 来打印或者查看程序运行所需的共享库。
Linux 一般都会自动安装C语言动态库,因为Linux下的大多数指令以及我们默认使用 gcc 编译得到的可执行程序都是进行动态链接,依赖C动态库的;但是C静态库、C++静态库可能就需要我们自己安装了。
我们可以使用如下命令来安装C和C++静态库:
sudo yum install -y glibc-static sudo yum install -y libstdc++-static
我们也可以使用 “-static” 选项来指定程序使用静态方式来进行链接:
可以看到,以静态链接方式形成的可执行程序比动态链接形成的要大100~200倍,即一个动态链接只有100M的文件,静态链接就会变成十几个G,二者之间相差非常大。
3、gcc/g++ 的使用
其实上面在学习预处理、编译、汇编、链接时我们就已经在使用 gcc/g++ 了,只是比较零散,下面我们来系统的学习一下 gcc/g++。
gcc/g++ 的安装
在Linux下,我们可以使用如下指令来安装 gcc 与 g++:
sudo yum install -y gcc yum install -y gcc-c++ libstdc++-devel
gcc/g++ 的使用
gcc 和 g++ 的使用方法非常类似,因为他们的选项基本都是一样的:
-E 在完成预处理后停下来,不再继续往后编译,生成的文件以 .i 为后缀;
-S 在完成编译后停下来,不再继续往后编译,生成的文件以 .s 为后缀;
-c 在完成汇编后停下来,不再继续往后编译,生成的文件以 .o 为后缀;
-o 将输出到终端的内容保存到指定文件中;
-static 对生成的文件采用静态链接;
-g 以debug方式发布软件,即生成调试信息,GNU 调试器可利用该信息;
-shared 尽量使用动态库,需要系统存在动态库;
-O0 -O1 -O2 -O3 编译器优化选项的4个级别:
-O0 不做任何优化,这是默认的编译选项;
-O1 优化会消耗少多的编译时间,它主要对代码的分支,常量以及表达式等进行优化;
-O2 会尝试更多的寄存器级的优化以及指令级的优化,它会在编译期间占用更多的内存和编译时间;
-O3 在O2的基础上进行更多的优化;
-w 不生成任何警告信息;
-Wall 生成所有警告信息;
二、Linux调试器 - gdb 使用
1、debug 和 release
在Windows中使用VS的时候我们知道:程序的发布方式一共有两种 – debug 模式和 release 模式;其中 debug 模式是给程序员用的,其中包含调试信息,程序员可以根据这些调试信息对程序进行修改与完善;而 release 模式则是给用户用的,它不包含调试信息,因为用户不负责也不关心如何对程序进行调试。
Linux 中使用 gcc/g++ 编译链接得到的程序默认是 release 模式的,如果我们要使用 gdb 进行调试,必须在源代码生成二进制程序的时候添加 -g 选项;
//测试代码 #include <stdio.h> int Add(int begin, int end) { int ret = 0; int i = 0; for(i=begin; i<=end; ++i) { ret += i; } return ret; } int Fac(int x) { int ret = 1; int i = 0; for(i=1; i<=x; ++i) { ret *= i; } return ret; } int main() { int a = 0; int b = 100; int add = Add(a, b); printf("ret = %d\n", add); printf("ret = %d\n", add); printf("ret = %d\n", add); int fac = Fac(10); printf("fac = %d\n", fac); printf("fac = %d\n", fac); printf("fac = %d\n", fac); return 0; }
可以看到,以 debug 和 release 模式发布的程序无论是在程序大小、程序内部包含的有关调试的二进制信息,还是 gdb 模式下是否具有调试样例都是有明显区别的。
2、gdb 的使用
当我们指定 -g 得到以 debug 模式发布的可执行程序后,我们就可以使用 gdb 对其进行调试了;
gdb 的安装
在Linux下,我们可以使用如下命令来安装 gdb:
sudo yum install -y gdb
gdb 调试的常见选项如下:
l 行号:从指定的行号开始往下显示源代码,每次显示10行 (l – list);(注:gdb 有自动记忆命令的功能,即当我们第一次使用 l 显示源代码后,我们下一次再使用 l 或者下一次按下 enter 键时,它会接着上次的位置往下显示)
l 函数:列出某个函数的源代码 (l – list);
r:运行程序 (r – run);
b 行号:在某一行打一个断点,相当于VS中的F9 (b – breakpoint);
info b:查看断点;
d 断点编号:删除断点 (d – delete);(注:每个断点都有自己的编号,我们删除断点时需要指明对应的断点编号)
r:调试运行,如果程序中有断点,则在断点处停下来,如果没有,则直接将程序跑完,相当于VS中的F5 (r – run);
n:逐过程调试,相当于VS中的F10 (n – next);
s:逐语句调试,相当于VS中的F11 (s – step);
c:运行至下一个断点处停下 (c – continue);(注:如果断点所在行不是一条语句,比如 “{” “}” 或者 空行,那么它会继续往下到有效行处停下 )
bt:查看调用堆栈 (breaktrace);
p 变量:查看变量值 (p – print);
display/undisplay 变量:跟踪查看一个变量,每次停下来都显示它的值,undisplay 取消对先前设置的那些变量的跟踪;
finish:把当前函数运行完;
disable breakpoints:禁用断点;
enable breakpoints:启用断点;
quit:退出 gdb;
指令演示
l 行号显示源代码:
l 函数显示该函数的源代码:
r 运行程序:
b 行号打断点,info b 查看断点,d 断点编号 删除断点:
r 调试运行:
n 逐过程调试,s 逐语句调试:
c 运行至下一个断点处停下:
bt 查看调用堆栈:
p 变量 查看变量值,display 跟踪查看变量,undisplay 取消跟踪:
finish 把当前函数运行完,q 退出 gdb: