后台开发:核心技术与应用实践 -- 编译与调试
编译
编译与链接
编译与链接的过程可以分解为4个步骤:分别是预处理(Prepressing )、编译(Compilation )、汇编(Assembly )和链接(Linking ),一个helloworld的编译过程如下:
预处理
相当于执行g++ -E helloworld.cpp -o helloworld.i
,其中:-E
的编译选项,意味着只执行到预编译,直接输出预编译结果
预处理过程主要处理那些源代码文件,主要处理以#
开始的预编译指令。比如#include
、#define
等,主要处理规则如下所述:
- 将所有的 `#define~ 删除,并且展开所有的宏定义
还有
#undef
,则将取消对某个宏的定义,使以后该串的出现不再被替换
- 处理所有条件预编译指令,比如
#if
、#ifdef
、#elif
、#else
、#endif
- 处理
#include
预编译指令,将被包含的文件插入到该预编译指令的位置 - 过滤所有的注释
//
和/**/
中的内容 - 添加行号和文件名标识,比如
#2 "test.c" 2
,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号 - 保留所有的
#pragma
编译器指令,因为编译器需要使用它们
经过预编译后的 .i
文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i
文件中。所以当无法判断宏定义是否正确或头文件包含是否正确时,可以查看预处理后的文件来确定问题
编译
编译过程就是把预处理完的文件进行一系列的词法分析、语法分析 语义分析以及优化后产生相应的汇编代码文件,这个过程往往是整个程序构建的核心部分,也是最复杂的部分之一。编译过程相当于如下命令:
g++ -s helloworld.i -o helloworld.s
其中,-S
的编译选项,表示只执行到源代码到汇编代码的转换,输出汇编代码
在这个过程中,编译器做的就是将高级语言翻译成机器可以执行的指令和数据。编的过程一般分为6步:扫描(词法分析)、语法分析、语义分析、源代码优化、 代码生成和目标代码优化,整个过程如下:
- 词法分析
运用一种类似于有限状态机的算法将源代码的字符序列分割成一系列的记号
- 语法分析
语法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树。整个分析过程采用了上下文无关文法的分析手段。简单地讲,由语法分析器生成的语法树就是以表达式为节点的树
可以看到,整个语句被看作一个赋值表达式:赋值表达式的左边是一个数组表达式;它的右边是一个乘法表达式;数组表达式又由两个符号表达式组成,等等。符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以通常为整个语法树的叶节点。在语法分析的同时,很多运算符的优先级和含义也被确定下来
- 语义分析
语义分析是由语义分析器完成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。编译器所能分析的语义是静态语义,所谓静态语义是指在编译期间可以确定的语义,与之对应的动态语义就是只有在运行期间才能确定的语义。静态语义通常包括声明和类型的匹配及类型的转换等,动态语义一般指在运行期间出现的语义相关的问题。经过语义分析阶段后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转化,语义分析程序会在语法树中插入相应的转换节点。语义分析后的语法树:
- 中间语言的生成
现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过程。源代码优化器会在源代码级别进行优化,直接在语法树上进行这类优化比较困难,所以源代码优化器往往将整个语法树转换成中间代码,它是语法树的顺序表示,并接近目标代码。中间代码一般跟目标机器和运行时环境是无关的,比如不包含数据的尺寸、变量的地址和寄存器的名字等。
- 代码生成和目标代码优化
链接
把每个源代码模块独立地编译,然后按照要将它们“组装”起来,这个组装模块的过程就是链接。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确的衔接。从原理上讲,它的工作就是把一些指令对其他符号地址的引用加以修正。链接过程主要包括了地址和空间分配、符号决议和重定位等这些步骤。
静态链接过程如图所示,每个模块的源代码文件经过编译器编译成目标文件,目标文件和库一起链接形成最终可执行文件。
每个目标文件除了拥有自己的数据和二进制代码外,还提供了3个表:未解决符号表、导出符号表、地址重定向表,具体如下所述:
- 未解决符号表提供了所有在该编译单元里引用但是定义并不是在本编译单元的符号以及其出现的地址;
- 导出符号表提供了本编译单元具有定义,并且愿意提供给其他单元使用的符号及其地址;
- 地址重定向表提供了本编译单元所有对自身地址的引用的记录
编译器将 extern
声明的变量置入未解决符号表,而不置入导出符号表,这属于外部链接
编译器将 static
声明的全局变量不置入未解决符号表,也不置入导出符号表,因此其他单元无法使用,这属于内部链接
链接分为静态链接和动态链接,对函数库的链接是放在编译时期完成的是静态链接。有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件。程序在运行时,与函数库再无瓜葛,因为所有需要的函数已复制到相关位置,这些函数库被称为静态库,通常文件名
为 libxxx.a
的形式。无论是静态库文件还是动态库文件,都是由 .o
文件创建的
把对一些库函数的链接载入推迟到程序运行时期(runtime),这就是动态链接库(dynamic link library)技术。
静态链接库、动态链接库各自的特点:
- 动态链接库有利于进程间资源共享
当某个程序在运行中要调用某个动态链接库函数的时候,如果内存里已有此库函数的拷贝了,则让其共享那一个拷贝;只有没有时才链接载入。如果系统中多个程序都要调用某个静态链接库函数时,则每个程序都要将这个库函数拷贝到自己的代码段中
- 将一些程序升级变得简单
只要动态库提供给该程序的接口没变,只要重新用新生成的动态库替换原来就可以了,而使用静态库就需要重新进行编译
- 可以真正做到链接载入完全由程序员在程序代码中控制
程序员在编写程序的时候,可以明确的指明什么时候或者什么情况下,链接载入哪个动态链接库函数
- 由于静态库在编译的时候,就将库函数装载到程序中去了,而动态库函数必须在运行的时候才被装载,所以程序在执行的时候,用静态库速度更快些
makefile文件
一个工程中的源文件不计数,其按类型、功能、模块分别放在若干个目录中,如何更高效率地编译整个工程,需要用到 makefile/make 命令工具。makefile 中会定义一系列的规则,指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。
makefile 带来的好处就是“自动化编译”,一旦写好,只需要一个 make 命令,整个工程完全自动编译,极大地提高了软件开发的效率。make 命令是一个命令工具,是一个解释makefile 中指令的命令工具。
makefile 就像 shell 脚本 样,其中也可以执行操作系统的命令。
makefile 主要含有一系列的规则:
A: B
(tab)<command>
(tab)<command>
每个命令行前都必须有 tab
符号。
假设helloworld 依赖 filel.o file2.o 两个目标文件:
helloworld : filel.o file2.o
编译出 helloworld 可执行文件,-o
后面加你指定的目标文件名:
g++ filel.o file2.o -o helloworld
file2.o 依赖 file2.cpp 文件:
file2.o : file2.cpp
-c
表示 C++ 只把给它的文件编译成目标文件
g++ -c file2.cpp -o file2.o
编译出 filel.o 文件:
filel.o:filel.cpp filel.h
g++ -c filel.cpp -o filel.o
clean
删除文件
clean:
rm -rf *.o helloworld
makefile中的变量设定,要设定一个变量,只要在一行的前端写下这个变量的名字,后面跟个=
,后面跟要设定的这个变量的值即可,以后要引用这个变量,只写一个$
符号,后面是在括号里的变量名即可
XX = g++
$(XX) -c helloworld.cpp -o helloworld.o
在 makefile 中使用函数:
在 makefile 规则中,通配符会被自动展开,但在变量的定义和函数引用时,通配符将失效。这种情况下如果需要通配符有效,就需要使用函数
wildcard
,它的用法是:$(wildcard PATTERN ...)
在 makefile 中,它被展开为已经存在的、使用空格分开的、匹配此模式的所有文件列表。如果不存在任何符合此模式的文件,函数会忽略模式并返回空。
patsubst
函数,用于匹配替换,有3个参数。第一个是一个要匹配的式样,第二个表示用什么来替换它,第三个是一个需要被处理的由空格分隔的列表,比如:$(patsubst %.c %.o $(dir) )
该代码指示用 patsubst 把$(dir)中的变量符合后缀是
.c
的全部替换成.0
makefile的内部变量:
$@
扩展成当前规则的目的文件名$<
扩展成依靠列表中的第一个依靠文件$^
扩展成整个依靠的列表(除掉了里面所有重复的文件名)
目标文件
ELF 是一种用于二进制文件、可执行文件、目标代码、共享库和核心转储的标准文件格式。ELF 标准的目的是为软件开发人员提供二进制接口定义,这些接口可以延伸到多种操作环境中,从而减少重新编码、编译程序的需要
目标文件有3种类型,如下所述:
- 可重定位的目标文件
这是由汇编器汇编生成的
.o
文件,链接器拿一个或一些可重定位的目标文件作为输入,经链接处理后,生成一个可执行的目标文件或者一个可被共享的对象文件(.so
文件)。可以使用 ar 工具将众多的.o
文件归档(archive)成.a
静态库文件
- 可执行的目标文件
- 可被共享的目标文件
这些就是所谓的动态库文件,也即
.so
文件。动态库在发挥作用的过程中,必须经过两个步骤 1. 链接器拿它和其他可重定位的文件(.o
文件)以及其他.so
文件作为输入,经链接处理后,生成另外的可共享的目标文件(.so
文件)或者可执行的目标文件;2. 在运行时,动态链接器拿它和一个可执行的目标文件以及另外一些可共享的目标文件 (.so
) 来 起处理,在 Linux 系统里面创建一个进程映像
有两种视图可以来说明 ELF 的组成格式,即链接视图和执行视图,这是因为 ELF 格式需要使用在两种场合,1. 组成不同的可重定位文件,以参与可执行文件或者可被共享的对象文件的链接。2. 组成可执行文件或者可被共享的对象文件,以在运行时内存中进程映像的构建。构建对象文件组成如表:
ELF 文件头被固定地放在不同类对象文件的最前面 因此,我们可以用 file 命令来看文件是属于哪种 ELF 文件,如下:
结果展示了, add.o、sub.o 都是可重定位文件, libmymath.so 是可被共享文件, main是可执行文件
减少目标文件大小的工具一一 strip
它能清除执行文件中不必要的标示符及调试信息,可减小文件大小而不影响正常使用。不过文件一旦进行 strip 操作后就不能恢复原样了,所以 strip 可以认为是一个“减肥”工具而不是压缩工具。而且,被 strip 后的文件不包含调试信息。strip 命令能从 ELF 文件中有选择地除去行号信息、重定位信息、调试段、 typchk 段、注释段、文件头以及所有或部分符号表。一旦使用该命令,则很难调试文件的符号;因此,通常只在已经调试和测试过的生成模块上使用 strip 命令,来减少对象文件所需的存储量开销。
调试
调试的方法一般有两种:
- 在程序中插入打印语句,优点是能够显示程序的动态过程,比较容易检查源程序的有关信息。缺点是效率低,可能输入大量无关的数据,发现错误具有偶然性
- 借助调试工具,目前大多数程序设计语言都有专门的调试工具,比如 C++ 的调试工具有 GDB ,可以用这些工具来分析程序的动态行为
strace
应用程序是不能直接访问 Linux 内核的,它既不能访问内核所占内存空间,也不能调用内核函数。不过,应用程序可以跳转到 system_call 的内核位置,内核会检查系统调用号,这个号码会告诉内核进程正在请求哪种服务。然后,它查看系统调用表,找到所调用的内核函数入口地址,调用该函数,然后返回到进程。所有操作系统在其内核都有一些内建的函数,这些函数可以用来完成一些系统级别的功能,一般称 Linux 系统上的这些函数为 系统调用 (system call)。这些函数代表了用户空间到内核空间的一种转换。例如,在用户空间调用 open 函数,在内核空间则会调用 sys_open。
系统调用的错误码 :系统调用并不直接返回错误码,而是将错误码放入一个名为 errno的全局变量中。如果一个系统调用失败,你可以读出 errno 的值来确定问题的所在。
strace 是一个通过跟踪系统调用来让开发者知道一个程序在后台所做事情的工具。
- strace可以用来跟踪信号传递
- strace可以使用
-c
参数来统计系统调用 - strace可以使用
-T
参数将每个系统调用的时间打印出来 - 可以使用strace来调试程序,使用方法为:
starce ./可执行文件
gdb
gdb是gcc 的调试工具,主要用于 C和C++ 这两种语言编写的程序。它的功能很强大,主要体现在以下4点:
- 启动程序,可以按照用户自定义的要求随心所欲地运行程序
- 可让被调试的程序在指定的断点处停住
- 当程序被停住时,可以检查此时程序中运行的状态
- 动态地改变程序的执行环境
要调试 C和C++ 的程序,首先在编译时,必须要把调试信息加到可执行文件中。使用编译器(cc/gcc/g++) 的 -g
数可以做到这一点,如下代码:
gcc -g hello.c -o hello
g++ -g hello.cpp -o hello
如果没有-g
,你将看不见程序的函数名、变量名,所代替的全是运行时的内存地址。
启动 gdb 的方法:
- gdb program
program是可执行文件
- gdb program core
用 gdb 同时调试一个运行程序和 core 文件, core 是程序非法执行后 core dump 产生的文件
- gdb program 1234
如果程序是一个服务程序,那么可以指定这个服务程序运行时的进程 ID, gdb会自动进行 attach 操作,并调试这个程序。并且 program 应该在 PATH 环境变量中搜索得到
综上,一个简单的使用gdb来进行调试的demo为:
gcc -g hello.c -o hello
gdb hello
进入gdb调试模式后:
输入"1",表示从第一行开始列出源码
按下Enter键,表示重复上一次命令
输入"b num",表示在bum行设置断点
输入"r"表示运行程序,run 命令简写
输入"n",表示单条语句执行, next 命令简写
输入p i
p arr[i]
,分别打印变量i和变量arr[i]的值
输入"bt",查看函数堆拢
输入"finish"退出函数
可以使用 gdb 分析 coredump 文件
产生 coredump 文件的一些原因:
- 内存访问越界
- 多线程程序,使用了线程不安全的函数
- 多线程读写的数据未加锁保护
- 非法指针,包括使用空指针或随意使用指针转换
- 堆横溢出
Linux 中的 ps
(process status )命令列出的是当前在运行的进程的快照,就是执行 ps 命令的那个时刻的那些进程,如果想要动态地显示进程信息,就可以使用 top
命令
Linux 上进程有5种状态,如下所述:
- 运行(正在运行或在运行队列中等待)
- 中断(休眠中,受阻,在等待某个条件的形成或接受到信号)
- 不可中断(收到信号不唤醒和不可运行,进程必须等待直到有中断发生)
- 僵死(进程已终止,但进程描述符存在,直到父进程调用 wait4()系统调用后释放)
- 停止(进程收到 SIGSTOP, SIGSTP, SIGTIN, SIGTOU 信号后停止运行运行)
ps 具标识进程的5种状态码
l) D 不可中断 uninterruptible sleep (usually IO)
2) R 运行 runnable (on run queue)
3) S 中断 sleeping
4) T 停止 traced or stopped
5) Z 僵死 a defunct (”zombie”) process
命令格式是: ps [参数]。命令功能是用来显示当前进程的状态。eg : ps -all
ps命令的一些操作
- 显示指定用户信息
ps -u username
- 显示所有进程信息,连同命令行
ps -ef
- 将目前登人的 PID 与相关信息列示出来
ps -l
- 列出目前所有的正在内存当中的程序
ps aux
Linux 程序内存空间布局
一个典型的 Linux 的运行中的C程序的内存空间布局
一个典型的 Linux 下的 C 程序内存空间由如下几部分组成:
- 代码段(.text segment):代码段通常是指用来存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读,某些架构也允许代码段为可写,即允许修改程序。在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。程序段是程序代码在内存中的映射,一个程序可以在内存中有多个副本
- 初始化数据段(.data segment):通常是指用来存放程序中已初始化的全局变量的一块内存区域,例如,位于所有函数之外的全局变量: int val=lOO。 需要强调的是,以上内容都是位于程序的可执行文件中,内核在调用 exec 函数启动该程序时从源程序文件中读人数据段属于静态内存分配
- 未初始化数据段(.bss segment):通常是指用来存放程序中未初始化的全局变量的一块内存区域。BSS是Block Started by Symbol 的简称
- 堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态地扩张或缩减。当进程调用
malloc/free
等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张)或释放的内存从堆中被剔除(堆被缩减) - 栈(stack):又称堆栈,存放程序的局部变量(但不包括 static 声明的变量, static意味着在数据段中存放变量。除此以外,在函数被调用时,栈用来传递参数和返回值。由于栈的先进后出特点,所以栈特别方便用来保存/恢复调用现场。而动态内存分配,需要程序员手工分配,手工释放
堆和栈的区别
- 申请方式不同
- 栈:由系统自动分配。例如,声明在函数中一个局部变量
int b;
系统自动在栈中为b
开辟空间- 堆:需要程序员自己申请,并指明大小,在C中用 malloc 函数,在 C++ 中用 new 运算符
- 申请后系统的响应不同
- 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常,提示栈溢出
- 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间中大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。其次,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样代码中的 delete 语句才能正确的释放本内存空间。最后,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动地将多余的那部分重新放入空闲链表中。
- 申请大小的限制不同
- 栈:栈是向低地址扩展的数据结构,是一块连续的内存的区域,即是栈顶的地址和栈的最大容量是系统预先规定好的
- 堆:堆是向高地址扩展的数据结构,,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存
- 申请效率不同
- 栈是系统自动分配,速度较快;但程序员是无法控制的
2)堆是由 new 分配的内存,一般速度比较慢,而且容易产生内存碎片
- 堆和栈中的存储内容不同
栈:在函数调用时,第一个进栈的是主函数中后的下一条指令( 函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的,当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行
堆: 一般是在堆的头部用一个字节存放堆的大小,堆中的具体内容由程序员安排
常见的内存动态管理错误包括以下几种:
- 申请和释放不一致
申请和释放所使用的函数需匹配,如new申请的空间应使用delete释放,而malloc申请的空间应使用free释放
- 申请和释放不匹配
申请和释放的内存空间大小应该一致
- 释放后仍然读写