程序编译方法和 gdb 调试技巧,严格来说不应该算作 Linux 系统编程的内容,但 Linux 系统编程主要使用 C 语言进行,离不开程序编译、调试的基础,所以将这部分知识作为第一部分的入门知识。
在此仅仅对我所学到的做一个粗略简单的总结。
1 编译基本过程
C/C++ 程序编译主要有四个步骤
- 预处理:由预处理器完成,生成
.i
文件;主要做些代码文本替换工作,如展开头文件、展开宏、处理条件编译过滤不必要代码,过滤注释,添加行号和文件名标识,保留#pragma
指令,该指令由编译器处理; - 编译:由编译器完成,生成
.s
文件;进行语法、词法分析,检查是否有错,并且根据编译器的不同还会对代码进行不同的优化,在 gcc 中优化的级别可以设定,最后翻译成汇编语言; - 汇编:由汇编器完成,生成
.o
文件;将汇编语言翻译成目标机器指令,目标文件由段组成,至少包含代码段和数据段; - 链接:由链接器完成,一般生成
.out
格式,在 Linux 中无所谓;将目标文件与其他使用到的函数的源文件、库进行链接,生成二进制可执行文件;
2 gcc/g++ 编译指令
gcc 和 g++都能编译 C/C++代码,仅仅稍有区别
- 对于文件后缀为.c的,gcc 把它当成是C 程序,而 g++当作是C++程序;后缀为.cpp 的,两者都会认为是C++程序。(两者语法不完全一样)
- 编译阶段,g++会调用 gcc,对于 C++代码,两者是等价的,但是因为 gcc 命令不能自动和 C ++程序使用的库联接,所以通常用 g++来完成链接
- 对于__cplusplus 宏,实际上,这个宏只是标志着编译器将会把代码按 C 还是 C++语法来解释,如上所述,如果后缀为.c,并且采用 GCC 编译器,则该宏就是未定义的,否则,就是已定义。
上述规则看似有点绕,反正我的习惯就是 C 用gcc
,C++用g++
,肯定不会错。
1 | 预处理指令,-o选项用于命名 |
3 静态库制作和使用
所谓静态和动态,是指链接过程是静态的还是动态的。
程序在与静态库链接时, 会将汇编生成的目标文件.o 与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。
故而静态库类似于汇编生成的.o
目标文件,其特点如下
- 静态库对函数库的链接是放在编译时期完成的。
- 程序在运行时与函数库再无瓜葛,移植方便。
- 浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。
- 静态库更新,程序需要重新编译进行全量更新
3.1 静态库制作
静态库命名和格式有固定规则,命名必须以lib
开头,格式一般为.a
或.lib
以加减乘除为例,先写一个.c
文件
1 | int add(int a, int b) { |
再写一个头文件
1 |
|
然后开始制作静态库
1 | 首先生成源代码的目标文件 |
至此文件目录下就多出libmymath.lib
静态库文件了。
3.2 使用静态库
写一个 C 文件
1 |
|
然后将此文件与静态库一起编译、链接
1 | gcc testlib.c libmymath.lib -o testlib.out |
然后运行生成的可执行文件
1 | ./testlib.out |
4 动态库制作和使用
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
动态库又称为分享库,其特点如下
动态库把对一些库函数的链接载入推迟到程序运行的时期。
可以实现进程之间的资源共享。(因此动态库也称为共享库)
将一些程序升级变得简单。
甚至可以真正做到链接载入完全由程序员在程序代码中控制(显示调用)。
不同平台下制作动态库有些差异
在 Windows 系统下的执行文件格式是 PE 格式,动态库需要一个DllMain函数做出初始化的入口,通常在导出函数的声明时需要有_declspec(dllexport)关键字。
Linux 下 gcc 编译的执行文件默认是 ELF 格式,不需要初始化入口,亦不需要函数做特别的声明,编写比较方便。
4.1 动态库的制作
动态库的制作比静态库简单,无需使用ar
进行打包。
同样以加减乘除的 C 代码为例
1 | 使用两条指令 |
4.2 动态库的使用
由于刚才生成的动态库在当前目录下,不在系统默认的动态库路径中,所以使用前需要先将动态库路径的环境变量声明为当前目录,或者将制作的动态库移动到默认的动态库路径。
1 | 声明环境变量 |
5 makefile 的编写
在一个 C/C++ 项目中,通常一个程序需要多个文件来进行编译完成,使用gcc
指令来生成所有目标文件、再链接生成程序,未免过于繁琐,而且在对项目文件进行修改后,每次使用gcc
指令重新编译也非常麻烦、耗时。
而makefile
文件可以大大简化这一过程,几乎一劳永逸,书写完成后即使项目结构发生很大变化,也仅需要做很小的更改,甚至无需更改。
另外每次使用makefile
来对项目进行编译时,其仅会对更改过的文件重新生成目标文件,大大降低了程序总编译时间。
5.1 所使用的示例代码
这里同样以加减乘除的程序作为示例,但是将加减乘除分为四个 c 文件。如下
1 | int add(int a, int b) { |
1 | int sub(int a, int b) { |
1 | int mul(int a, int b) { |
1 | int div(int a, int b) { |
然后将四个函数声明放入一个头文件
1 |
|
再写一个主程序测试函数
1 |
|
如果直接使用gcc
命令来编译链接生成程序的话,我们需要以下指令
1 | gcc -c add.c sub.c mul.c div.c |
然后测试一下生成的主程序
1 | ./main.out |
5.2 makefile 的基本规则
makefile
类似于shell
脚本,可以使用shell
函数、语法以及通配符。
makefile
的规则包含两个部分,一个是依赖关系,一个是生成目标的方法即对应指令。
在makefile
中,规则的顺序是很重要的,因为,makefile
中只应该有一个最终目标,其它的目标都是被这 个目标所连带出来的,所以一定要让make
知道你的最终目标是什么。一般来说,定义在makefile
中的目标 可能会有很多,但是第一条规则中的目标将被确立为最终的目标。如果第一条规则中的目标有很多个,那么 ,第一个目标会成为最终的目标。make 所完成的也就是这个目标。
基本格式如下
1 | # ALL 指定最终生成的程序 |
根据基本格式,我们可以用示例代码写一个最基础的makefile
文件
1 | ALL:main |
由于之前已经用gcc
命令编译过一次,所以先执行make clean
将之前生成的目标文件和主程序删除,再执行make
使用makefile
文件规则进行编译
1 | make clean |
可见这个最基础的makefile
文件已经奏效。
5.3 加入 shell 函数和自动变量
显然,如果一个项目中文件很多的话,上面的makefile
文件将会变得极其冗长,光依赖文件就要写很长一串,这显然很不方便。
可以引入两个shell
函数
1 | # 将./目录下所有.c文件的文件名组成列表,然后赋值给src变量 |
再介绍 3 个自动变量
$@
:用于表示目标$^
:用于表示所有依赖条件$<
:用于表示第一个依赖条件,还可以将依赖条件从依赖列表中依次取出
在makefile
中的应用如下
1 | src = $(wildcard ./*.c) |
这个版本在命名上取得了一定简化,但依然有大量类似代码来生成各种目标文件,实际上这部分类似代码可以使用%.o:%.c
的规则来简化。优化如下
1 | src = $(wildcard ./*.c) |
这样就简单很多了,依然在 shell 中测试一下
1 | $ make clean |
5.4 源代码在子文件夹时的优化
之前所有的源文件、头文件以及makefile
文件都在同一个文件夹里,在实际中更常见的情况应该是源文件、头文件都放在不同的文件夹中,而makefile
文件则在根目录下。并且有时也需要加入或更改一些编译的参数如-Wall
和用于调试的-g
等。这里根据该种情况对makefile
文件进行优化。
假如所有.c 文件都放入src
的子文件夹,所有头文件都放入inc
的子文件夹,然后新建一个obj
文件用以存放生成的目标文件。结构如下
1 | tree |
源文件的路径变化,只需在makefile
文件中修改wildcard
和patsubst
函数的参数即可;由于头文件也更改了路径,在主程序中不做修改的话,则需在生成目标文件时指定头文件目录,所以需要一个变量来保存头文件路径;关于编译参数-Wall
和-g
,为了方便修改,也将其定义为一个变量。
优化后如下
1 | src = $(wildcard ./src/*.c) |
注意文件中关于伪目标的注释。
还有一种情况就是同一个项目有时需要使用不同的makefile
规则来进行测试或适应不同的环境,那么可以在使用make
指令时加上-f
选项,后面再接指定的makefile
文件名。如新建一个makefile
文件,将其命名为make_ubuntu
,
1 | make -f make_ubuntu |
6 GDB 调试基础
GDB, 是 The GNU Project Debugger 的缩写, 是 Linux 下功能全面的调试工具。GDB 支持断点、单步执行、打印变量、观察变量、查看寄存器、查看堆栈等调试手段。在 Linux 环境软件开发中,GDB 是主要的调试工具,用来调试 C 和 C++ 程序。
实际上,一般调试程序都会借助一些 IDEA 或者编辑器上的插件,比如我之前都是用 VScode 进行配置后来调试的。
虽然直接使用 GDB 的情况并不多,但了解一些基本指令和方法还是有一些帮助的。在此对 GDB 的一些调试指令做一个简单的总结记录。
6.1 GDB 使用方法
在使用 GDB 调试前,要保证生成的程序包含调试信息,也就是说在使用 gcc 进行编译生成程序时,必须要加-g
参数来生成调试信息。
这里使用一个简单的示例程序
1 |
|
使用 gcc 来编译程序
1 | gcc testgdb.c -o test -g |
然后进入 gdb 命令界面
1 | gdb test |
在 gdb 命令行界面,输入 run 执行待调试程序:
1 | (gdb) run |
在 gdb 命令行界面,输入 quit 退出 gdb:
1 | (gdb) quit |
在 gdb 命令行界面,使用 (gdb) help command 可以查看命令的用法。
在 gdb 命令行界面可以执行外部的 Shell 命令:
1 | (gdb) !shell 命令 |
例如查看当前目录的文件:
1 | (gdb) !ls |
6.2 GDB 常用指令
list/l 查看源代码
使用list 1
从第一行列出源代码,数字可以更改
1 | (gdb) list 1 |
继续输入list
或l
可以继续显示剩下的代码
1 | (gdb) l |
break/b 设置断点
使用break
或b
设置断点
1 | (gdb) break 38 |
所设置的断点可以使用info b
来查看。
1 | (gdb) info b |
还可以设置条件断点
1 | (gdb) b 20 if i = 5 |
run/r 运行程序
1 | (gdb) run |
next/n 运行至下一语句
1 | (gdb) next |
step/s 单步调试
单步调试遇到函数调用,将会进入函数,逐句执行
1 | (gdb) n |
print/p 查看变量的值
1 | (gdb) p i |
finish 结束当前函数调用
1 | (gdb) finish |
continue 继续执行断点后续语句
1 | (gdb) continue |
6.3 GDB 其他指令
设置 main 函数命令行参数
进行 gdb 命令行后,可以有两种方法设置main
函数的命令行参数。
先修改一下源文件
1 | -int main() { |
重新编译生成程序,再进入 gdb。
第一种,在run
之前使用set args
设置参数,再run
或start
1 | (gdb) set args aa bb cc |
第二种,在run
的时候直接加所需要的参数
1 | (gdb) run aa bb cc |
ptype 查看变量类型
1 | (gdb) ptype arr |
backtrace/bt 列出当前程序正在存活的栈帧
先next
到select_sort()
函数,然后step
进入,再使用bt
查看当前栈帧
1 | (gdb) n |
frame 切换栈帧
切换栈帧需要知道当前栈帧的编号
1 | (gdb) frame 0 |
display/undisplay 设置或取消跟踪变量
使用display
可以设置需要跟踪的变量,之后再使用run
,将会在每一步展示设置后的变量,变量前是展示的编号,可以使用display
加编号来取消。
1 | (gdb) display arr |
7 结束语
本篇文章从介绍 C/C++ 程序最基本的编译链接过程开始,接着介绍了 Linux 平台下使用gcc
编译程序的基本指令,以此为基础,介绍了静态库和动态库,以及相应的制作和使用方法,最后两个部分分别记录了 makefile
文件的书写方法和GDB
调试的常用指令。虽然本篇文章的内容并不和 Linux 系统编程直接相关,但确实是重要的基础。之后的部分将会记录 Linux 系统编程常用的函数,以及一些系统指令的实现。