GDB是一个由GNU开源组织发布的、UNIX/LINUX操作系统下的、基于命令行的、功能强大的程序调试工具。 对于一名Linux下工作的c++程序员,gdb是必不可少的工具。
启动gdb
对C/C++程序的调试,需要在编译前就加上-g选项。对大型项目,一般选择Debug选项进行编译以 方便gdb调试。
$ g++ -g hello.cpp -o hello
调试可执行文件:
$gdb <program>
program也就是你的执行文件,一般在当前目录下。
gdb交互命令
启动gdb后,进入到交互模式,通过以下命令完成对程序的调试;注意高频使用的命令一般都会有缩写,熟练使用这些缩写命令能提高调试的效率;
运行
- run:简记为 r ,其作用是运行程序,当遇到断点后,程序会在断点处停止运行,等待用户输入下一步的命令。
- continue (简写c ):继续执行,到下一个断点处(或运行结束)
- next:(简写 n),单步跟踪程序,当遇到函数调用时,也不进入此函数体;此命令同 step 的主要区别是,step 遇到用户自定义的函数,将步进到函数中去运行,而 next 则直接调用函数,不会进入到函数体内。
- step (简写s):单步调试如果有函数调用,则进入函数;与命令n不同,n是不进入调用的函数的
- until:当你厌倦了在一个循环体内单步跟踪时,这个命令可以运行程序直到退出循环体。
- until+行号: 运行至某行,不仅仅用来跳出循环
- finish: 运行程序,直到当前函数完成返回,并打印函数返回时的堆栈地址和返回值及参数值等信息。
- call 函数(参数):调用程序中可见的函数,并传递“参数”,如:call gdb_test(55)
- quit:简记为 q ,退出gdb
设置断点
- break n (简写b n):在第n行处设置断点 (可以带上代码路径和代码名称: b OAGUPDATE.cpp:578)
- b fn1 if a>b:条件断点设置
- break func(break缩写为b):在函数func()的入口处设置断点,如:break cb_button
- delete 断点号n:删除第n个断点
- disable 断点号n:暂停第n个断点
- enable 断点号n:开启第n个断点
- clear 行号n:清除第n行的断点
- info b (info breakpoints) :显示当前程序的断点设置情况
- delete breakpoints:清除所有断点
查看源代码
- list :简记为 l ,其作用就是列出程序的源代码,默认每次显示10行。
- list 行号:将显示当前文件以“行号”为中心的前后10行代码,如:list 12
- list 函数名:将显示“函数名”所在函数的源代码,如:list main
- list :不带参数,将接着上一次 list 命令的,输出下边的内容。
打印表达式
- print 表达式:简记为 p ,其中“表达式”可以是任何当前正在被测试程序的有效表达式,比如当前正在调试C语言的程序,那么“表达式”可以是任何C语言的有效表达式,包括数字,变量甚至是函数调用。
- print a:将显示整数 a 的值
- print ++a:将把 a 中的值加1,并显示出来
- print name:将显示字符串 name 的值
- print gdb_test(22):将以整数22作为参数调用 gdb_test() 函数
- print gdb_test(a):将以变量 a 作为参数调用 gdb_test() 函数
- display 表达式:在单步运行时将非常有用,使用display命令设置一个表达式后,它将在每次单步进行指令后,紧接着输出被设置的表达式及值。如: display a
- watch 表达式:设置一个监视点,一旦被监视的“表达式”的值改变,gdb将强行终止正在被调试的程序。如: watch a
- whatis :查询变量或函数
- info function: 查询函数
- 扩展info locals: 显示当前堆栈页的所有变量
查询运行信息
- where/bt :当前运行的堆栈列表;
- bt backtrace 显示当前调用堆栈
- up/down 改变堆栈显示的深度
- set args 参数:指定运行时的参数
set args -l a -C abc
set args -l=a -C=abc
- show args:查看设置好的参数
- info program: 来查看程序的是否在运行,进程号,被暂停的原因。
分割窗口
- layout:用于分割窗口,可以一边查看代码,一边测试:
- layout src:显示源代码窗口
- layout asm:显示反汇编窗口
- layout regs:显示源代码/反汇编和CPU寄存器窗口
- layout split:显示源代码和反汇编窗口
- Ctrl + L:刷新窗口
总览
本文为GDB调试指南,参考GDB调试手册,但加入了很多实例,目前已有的篇目:
- 启动调试
- 断点设置
- 变量查看
- 单步调试
- 源码查看
启动调试
前言
GDB(GNU Debugger)是UNIX及UNIX-like下的强大调试工具,可以调试ada, c, c++, asm, minimal, d, fortran, objective-c, go, java,pascal等语言。本文以C程序为例,介绍GDB启动调试的多种方式。
哪类程序可被调试
对于C程序来说,需要在编译时加上-g参数,保留调试信息,否则不能使用GDB进行调试。 但如果不是自己编译的程序,并不知道是否带有-g参数,如何判断一个文件是否带有调试信息呢?
gdb 文件
例如:
$ gdb helloworld
Reading symbols from helloWorld...(no debugging symbols found)...done.
如果没有调试信息,会提示no debugging symbols found。 如果是下面的提示:
Reading symbols from helloWorld...done.
则可以进行调试。
readelf查看段信息
例如:
$ readelf -S helloWorld|grep debug
[28] .debug_aranges PROGBITS 0000000000000000 0000106d
[29] .debug_info PROGBITS 0000000000000000 0000109d
[30] .debug_abbrev PROGBITS 0000000000000000 0000115b
[31] .debug_line PROGBITS 0000000000000000 000011b9
[32] .debug_str PROGBITS 0000000000000000 000011fc
helloWorld为文件名,如果没有任何debug信息,则不能被调试。
file查看strip状况
下面的情况也是不可调试的:
file helloWorld
helloWorld: (省略前面内容) stripped
如果最后是stripped,则说明该文件的符号表信息和调试信息已被去除,不能使用gdb调试。但是not stripped的情况并不能说明能够被调试。
调试方式运行程序
程序还未启动时,可有多种方式启动调试。
调试启动无参程序
例如:
$ gdb helloWorld
(gdb)
输入run命令,即可运行程序
调试启动带参程序
假设有以下程序,启动时需要带参数:
#include<stdio.h>
int main(int argc,char *argv[])
{
if(1 >= argc)
{
printf("usage:hello name\n");
return 0;
}
printf("Hello World %s!\n",argv[1]);
return 0 ;
}
编译:
gcc -g -o hello hello.c
这种情况如何启动调试呢?需要设置参数:
$ gdb hello
(gdb)run 编程珠玑
Starting program: /home/shouwang/workspaces/c/hello 编程珠玑
Hello World 编程珠玑!
[Inferior 1 (process 20084) exited normally]
(gdb)
只需要run的时候带上参数即可。 或者使用set args,然后在用run启动:
gdb hello
(gdb) set args 编程珠玑
(gdb) run
Starting program: /home/hyb/workspaces/c/hello 编程珠玑
Hello World 编程珠玑!
[Inferior 1 (process 20201) exited normally]
(gdb)
调试core文件
当程序core dump时,可能会产生core文件,它能够很大程序帮助我们定位问题。但前提是系统没有限制core文件的产生。可以使用命令limit -c查看:
$ ulimit -c
0
如果结果是0,那么恭喜你,即便程序core dump了也不会有core文件留下。我们需要让core文件能够产生:
ulimit -c unlimied #表示不限制core文件大小
ulimit -c 10 #设置最大大小,单位为块,一块默认为512字节
上面两种方式可选其一。第一种无限制,第二种指定最大产生的大小。 调试core文件也很简单:
gdb 程序文件名 core文件名
调试已运行程序
如果程序已经运行了怎么办呢? 首先使用ps命令找到进程id:
ps -ef|grep 进程名
attach方式
假设获取到进程id为20829,则可用下面的方式调试进程:
$ gdb
(gdb) attach 20829
接下来就可以继续你的调试啦。
可能会有下面的错误提示:
Could not attach to process. If your uid matches the uid of the target
process, check the setting of /proc/sys/kernel/yama/ptrace_scope, or try
again as the root user. For more details, see /etc/sysctl.d/10-ptrace.conf
ptrace: Operation not permitted.
解决方法,切换到root用户: 将/etc/sysctl.d/10-ptrace.conf中的
kernel.yama.ptrace_scope = 1
修改为
kernel.yama.ptrace_scope = 0
直接调试相关id进程
还可以是用这样的方式gdb program pid,例如:
gdb hello 20829
或者:
gdb hello --pid 20829
已运行程序没有调试信息
为了节省磁盘空间,已经运行的程序通常没有调试信息。但如果又不能停止当前程序重新启动调试,那怎么办呢?还有办法,那就是同样的代码,再编译出一个带调试信息的版本。然后使用和前面提到的方式操作。对于attach方式,在attach之前,使用file命令即可:
$ gdb
(gdb) file hello
Reading symbols from hello...done.
(gdb)attach 20829
总结
本文主要介绍了两种类型的GDB启动调试方式,分别是调试未运行的程序和已经运行的程序。对于什么样的程序能够进行调试也进行了简单说明。
断点设置
前言
上篇我们讲到了GDB启动调试的多种方式,分别应用于多种场景。今天我们来介绍一下断点设置的多种方式。
为何要设置断点
在介绍之前,我们首先需要了解,为什么需要设置断点。我们在指定位置设置断点之后,程序运行到该位置将会“暂停”,这个时候我们就可以对程序进行更多的操作,比如查看变量内容,堆栈情况等等,以帮助我们调试程序。
查看已设置的断点
在学习断点设置之前,我们可以使用info breakpoints查看已设置断点:
info breakpoints
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000004005fc in printNum2 at test.c:17
breakpoint already hit 1 time
2 hw watchpoint keep y a
breakpoint already hit 1 time
ignore next 3 hits
它将会列出所有已设置的断点,每一个断点都有一个标号,用来代表这个断点。例如,第2个断点设置是一个观察点,并且会忽略三次。
断点设置
断点设置有多种方式,分别应用于不同的场景。借助示例程序进行一一介绍:
//test.c
#include<stdio.h>
void printNum(int a)
{
printf("printNum\n");
while(a > 0)
{
printf("%d\n",a);
a--;
}
}
void printNum2(int a,int num)
{
printf("printNum\n");
while(a > num && a>0)
{
printf("%d\n",a);
a--;
}
}
int div(int a,int b)
{
printf("a=%d,b=%d\n",a,b);
int temp = a/b;
return temp;
}
int main(int argc,char *argv[])
{
printNum2(12,5);
printNum(10);
div(10,0);
return 0;
}
编译:
gcc -g -o test test.c
注意,编译时需要带上-g参数,具体原因参见。
根据行号设置断点
b 9 #break 可简写为b
或者
b test.c:9
程序运行到第9行的时候会断住。
根据函数名设置断点
同样可以将断点设置在函数处:
b printNum
程序在调用到printNum函数的时候会断住。
根据条件设置断点
假设程序某处发生崩溃,而崩溃的原因怀疑是某个地方出现了非期望的值,那么你就可以在这里断点观察,当出现该非法值时,程序断住。这个时候我们可以借助gdb来设置条件断点,例如:
break test.c:23 if b==0
当在b等于0时,程序将会在第23行断住。 它和condition有着类似的作用,假设上面的断点号为1,那么:
condition 1 b==0
会使得b等于0时,产生断点1。而实际上可以很方便地用来改变断点产生的条件,例如,之前设置b为0时产生该断点,那么使用condition可以修改断点产生的条件。
根据规则设置断点
例如需要对所有调用printNum函数都设置断点,可以使用下面的方式:
rbreak printNum*
所有以printNum开头的函数都设置了断点。而下面是对所有函数设置断点:
#用法:rbreak file:regex
rbreak .
rbreak test.c:. #对test.c中的所有函数设置断点
rbreak test.c:^print #对以print开头的函数设置断点
设置临时断点
假设某处的断点只想生效一次,那么可以设置临时断点,这样断点后面就不复存在了:
tbreak test.c:l0 #在第10行设置临时断点
跳过多次设置断点
假如有某个地方,我们知道可能出错,但是前面30次都没有问题,虽然在该处设置了断点,但是想跳过前面30次,可以使用下面的方式:
ignore 1 30
其中,1是你要忽略的断点号,可以通过前面的方式查找到,30是需要跳过的次数。这样设置之后,会跳过前面30次。再次通过info breakpoints可以看到:
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000004005e8 in printNum2 at test.c:16
ignore next 30 hits
根据表达式值变化产生断点
有时候我们需要观察某个值或表达式,知道它什么时候发生变化了,这个时候我们可以借助watch命令。例如:
watch a
这个时候,让程序继续运行,如果a的值发生变化,则会打印相关内容,如:
Hardware watchpoint 2: a
Old value = 12
New value = 11
但是这里要特别注意的是,程序必须运行起来,否则会出现:
No symbol "a" in current context.
因为程序没有运行,当前上下文也就没有相关变量信息。
rwatch和awatch同样可以设置观察点前者是当变量值被读时断住,后者是被读或者被改写时断住。
禁用或启动断点
有些断点暂时不想使用,但又不想删除,可以暂时禁用或启用。例如:
disable #禁用所有断点
disable bnum #禁用标号为bnum的断点
enable #启用所有断点
enable bnum #启用标号为bnum的断点
enable delete bnum #启动标号为bnum的断点,并且在此之后删除该断点
断点清除
断点清除主要用到clear和delete命令。常见使用如下:
clear #删除当前行所有breakpoints
clear function #删除函数名为function处的断点
clear filename:function #删除文件filename中函数function处的断点
clear lineNum #删除行号为lineNum处的断点
clear f:lename:lineNum #删除文件filename中行号为lineNum处的断点
delete #删除所有breakpoints,watchpoints和catchpoints
delete bnum #删除断点号为bnum的断点
总结
本文介绍了常见的断点设置方法,断点设置之后,可以便于我们后期观察变量,堆栈等信息,为进一步的定位与调试做准备。
变量查看
前言
在启动调试以及设置断点之后,就到了我们非常关键的一步-查看变量。GDB调试最大的目的之一就是走查代码,查看运行结果是否符合预期。既然如此,我们就不得不了解一些查看各种类型变量的方法,以帮助我们进一步定位问题。
准备工作
在查看变量之前,需要先启动调试并设置断点。后面的内容都基于在某个位置已经断住。
本文辅助说明程序如下: testGdb.c
//testGdb.c
#include<stdio.h>
#include<stdlib.h>
#include"testGdb.h"
int main(void)
{
int a = 10; //整型
int b[] = {1,2,3,5}; //数组
char c[] = "hello,shouwang";//字符数组
/*申请内存,失败时退出*/
int *d = (int*)malloc(a*sizeof(int));
if(NULL == d)
{
printf("malloc error\n");
return -1;
}
/*赋值*/
for(int i=0; i < 10;i++)
{
d[i] = i;
}
free(d);
d = NULL;
float e = 8.5f;
return 0;
}
testGdb.h
int a = 11;
编译:
$ gcc -g -o testGdb testGdb.o
普通变量查看
打印基本类型变量,数组,字符数组
最常见的使用便是使用print(可简写为p)打印变量内容。 例如,打印基本类型,数组,字符数组等直接使用p 变量名即可:
(gdb) p a
$1 = 10
(gdb) p b
$2 = {1, 2, 3, 5}
(gdb) p c
$3 = "hello,shouwang"
(gdb)
当然有时候,多个函数或者多个文件会有同一个变量名,这个时候可以在前面加上函数名或者文件名来区分:
(gdb) p 'testGdb.h'::a
$1 = 11
(gdb) p 'main'::b
$2 = {1, 2, 3, 5}
(gdb)
这里所打印的a值是我们定义在testGdb.h文件里的,而b值是main函数中的b。
打印指针指向内容
如果还是使用上面的方式打印指针指向的内容,那么打印出来的只是指针地址而已,例如:
(gdb) p d
$1 = (int *) 0x602010
(gdb)
而如果想要打印指针指向的内容,需要解引用:
(gdb) p *d
$2 = 0
(gdb) p *d@10
$3 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
(gdb)
从上面可以看到,仅仅使用*只能打印第一个值,如果要打印多个值,后面跟上@并加上要打印的长度。 或者@后面跟上变量值:
(gdb) p *d@a
$2 = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
(gdb)
由于a的值为10,并且是作为整型指针数据长度,因此后面可以直接跟着a,也可以打印出所有内容。
另外值得一提的是,$可表示上一个变量,而假设此时有一个链表linkNode,它有next成员代表下一个节点,则可使用下面方式不断打印链表内容:
(gdb) p *linkNode
(这里显示linkNode节点内容)
(gdb) p *$.next
(这里显示linkNode节点下一个节点的内容)
如果想要查看前面数组的内容,你可以将下标一个一个累加,还可以定义一个类似UNIX环境变量,例如:
(gdb) set $index=0
(gdb) p b[$index++]
$11 = 1
(gdb) p b[$index++]
$12 = 2
(gdb) p b[$index++]
$13 = 3
这样就不需要每次修改下标去打印啦。
按照特定格式打印变量
对于简单的数据,print默认的打印方式已经足够了,它会根据变量类型的格式打印出来,但是有时候这还不够,我们需要更多的格式控制。常见格式控制字符如下:
- x 按十六进制格式显示变量。
- d 按十进制格式显示变量。
- u 按十六进制格式显示无符号整型。
- o 按八进制格式显示变量。
- t 按二进制格式显示变量。
- a 按十六进制格式显示变量。
- c 按字符格式显示变量。
- f 按浮点数格式显示变量。
还是以辅助程序来说明,正常方式打印字符数组c:
(gdb) p c
$18 = "hello,shouwang"
但是如果我们要查看它的十六进制格式打印呢?
(gdb) p/x c
$19 = {0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x73, 0x68, 0x6f, 0x75, 0x77, 0x61,
0x6e, 0x67, 0x0}
(gdb)
但是如果我们想用这种方式查看浮点数的二进制格式是怎样的是不行的,因为直接打印它首先会被转换成整型,因此最终会得到8:
(gdb) p e
$1 = 8.5
(gdb) p/t e
$2 = 1000
(gdb)
那么就需要另外一种查看方式了。
查看内存内容
examine(简写为x)可以用来查看内存地址中的值。语法如下:
x/[n][f][u] addr
其中:
- n 表示要显示的内存单元数,默认值为1
- f 表示要打印的格式,前面已经提到了格式控制字符
- u 要打印的单元长度
- addr 内存地址
单元类型常见有如下:
- b 字节
- h 半字,即双字节
- w 字,即四字节
- g 八字节
我们通过一个实例来看,假如我们要把float变量e按照二进制方式打印,并且打印单位是一字节:
(gdb) x/4tb &e
0x7fffffffdbd4: 00000000 00000000 00001000 01000001
(gdb)
可以看到,变量e的四个字节都以二进制的方式打印出来了。
自动显示变量内容
假设我们希望程序断住时,就显示某个变量的值,可以使用display命令。
(gdb) display e
1: e = 8.5
那么每次程序断住时,就会打印e的值。要查看哪些变量被设置了display,可以使用:
(gdb)info display
Auto-display expressions now in effect:
Num Enb Expression
1: y b
2: y e
如果想要清除可以使用
delete display num #num为前面变量前的编号,不带num时清除所有。
或者去使能:
disable display num #num为前面变量前的编号,不带num时去使能所有
查看寄存器内容
(gdb)info registers
rax 0x0 0
rbx 0x0 0
rcx 0x7ffff7dd1b00 140737351850752
rdx 0x0 0
rsi 0x7ffff7dd1b30 140737351850800
rdi 0xffffffff 4294967295
rbp 0x7fffffffdc10 0x7fffffffdc10
(内容过多未显示完全)
总结
通过不同方式查看变量值或者内存值能够极大的帮助我们判断程序的运行是否符合我们的预期,如果发现观察的值不是我们预期的时候,就需要检查我们的代码了。
单步调试
前言
我们已经了解了GDB基本的启动,设置断点,查看变量等,如果这些内容你还不知道,建议先回顾一下前面的内容。在启动调试设置断点观察之后,没有我们想要的信息怎么办呢?这个时候,就需要单步执行或者跳过当前断点继续执行等等。而本文所说的单步调试并非仅仅指单步执行,而是指在你的控制之下,按要求执行语句。
准备
老规矩,先准备一个示例程序如下:
/*gdbStep.c*/
#include<stdio.h>
/*计算简单乘法,这里没有考虑溢出*/
int add(int a, int b)
{
int c = a + b;
return c;
}
/*打印从0到num-1的数*/
int count(int num)
{
int i = 0;
if(0 > num)
return 0;
while(i < num)
{
printf("%d\n",i);
i++;
}
return i;
}
int main(void)
{
int a = 3;
int b = 7;
printf("it will calc a + b\n");
int c = add(a,b);
printf("%d + %d = %d\n",a,b,c);
count(c);
return 0;
}
编译:
gcc -g -o gdbStep gdbStep.c
程序的功能比较简单,这里不多做解释。
特别简单说明一条命令,list(可简写为l),它可以将源码列出来,例如:
(gdb) list
1 #include<stdio.h>
2
3 /*计算简单乘法,这里没有考虑溢出*/
4 int add(int a, int b)
5 {
6 int c = a * b;
7 return c;
8 }
9 int main(void)
10 {
(gdb) l
11 int a = 13;
12 int b = 57;
13 printf("it will calc a * b\n");
14 int c = add(a,b);
15 printf("%d*%d = %d\n",a,b,c);
16 return 0;
17 }
(gdb)
单步执行-next
next命令(可简写为n)用于在程序断住后,继续执行下一条语句,假设已经启动调试,并在第12行停住,如果要继续执行,则使用n执行下一条语句,如果后面跟上数字num,则表示执行该命令num次,就达到继续执行n行的效果了:
$ gdb gdbStep #启动调试
(gdb)b 25 #将断点设置在12行
(gdb)run #运行程序
Breakpoint 1, main () at gdbStep.c:25
25 int b = 7;
(gdb) n #单步执行
26 printf("it will calc a + b\n");
(gdb) n 2 #执行两次
it will calc a + b
28 printf("%d + %d = %d\n",a,b,c);
(gdb)
从上面的执行结果可以看到,我们在25行处断住,执行n之后,运行到26行,运行n 2之后,运行到28行,但是有没有发现一个问题,为什么不会进入到add函数内部呢?那就需要用到另外一个命令啦。
单步进入-step
对于上面的情况,如果我们想跟踪add函数内部的情况,可以使用step命令(可简写为s),它可以单步跟踪到函数内部,但前提是该函数有调试信息并且有源码信息。
$ gdb gdbStep #启动调试
(gdb) b 25 #在12行设置断点
Breakpoint 1 at 0x4005d3: file gdbStep.c, line 25.
(gdb) run #运行程序
Breakpoint 1, main () at gdbStep.c:25
25 int b = 7;
(gdb) s
26 printf("it will calc a + b\n");
(gdb) s #单步进入,但是并没有该函数的源文件信息
_IO_puts (str=0x4006b8 "it will calc a + b") at ioputs.c:33
33 ioputs.c: No such file or directory.
(gdb) finish #继续完成该函数调用
Run till exit from #0 _IO_puts (str=0x4006b8 "it will calc a + b")
at ioputs.c:33
it will calc a + b
main () at gdbStep.c:27
27 int c = add(a,b);
Value returned is $1 = 19
(gdb) s #单步进入,现在已经进入到了add函数内部
add (a=13, b=57) at gdbStep.c:6
6 int c = a + b;
从上面的过程可以看到,s命令会尝试进入函数,但是如果没有该函数源码,需要跳过该函数执行,可使用finish命令,继续后面的执行。如果没有函数调用,s的作用与n的作用并无差别,仅仅是继续执行下一行。它后面也可以跟数字,表明要执行的次数。
当然它还有一个选项,用来设置当遇到没有调试信息的函数,s命令是否跳过该函数,而执行后面的。默认情况下,它是会跳过的,即step-mode值是off:
(gdb) show step-mode
Mode of the step operation is off.
(gdb) set step-mode on
(gdb) set step-mode off
还有一个与step相关的命令是stepi(可简写为si),它与step不同的是,每次执行一条机器指令:
(gdb) si
0x0000000000400573 6 int c = a + b;
(gdb) display/i $pc
1: x/i $pc
=> 0x400573 <add+13>: mov -0x18(%rbp),%eax
(gdb)
继续执行到下一个断点-continue
我们可能打了多处断点,或者断点打在循环内,这个时候,想跳过这个断点,甚至跳过多次断点继续执行该怎么做呢?可以使用continue命令(可简写为c)或者fg,它会继续执行程序,直到再次遇到断点处:
$ gdb gdbStep
(gdb)b 18 #在count函数循环内打断点
(gdb)run
Breakpoint 1, count (num=10) at gdbStep.c:18
18 i++;
(gdb) c #继续运行,直到下一次断住
Continuing.
1
Breakpoint 1, count (num=10) at gdbStep.c:18
18 i++;
(gdb) fg #继续运行,直到下一次断住
Continuing.
2
Breakpoint 1, count (num=10) at gdbStep.c:18
18 i++;
(gdb) c 3 #跳过三次
Will ignore next 2 crossings of breakpoint 1. Continuing.
3
4
5
Breakpoint 1, count (num=10) at gdbStep.c:18
18 i++;
继续运行到指定位置-until
假如我们在25行停住了,现在想要运行到29行停住,就可以使用until命令(可简写为u):
$ gdb gdbStep
(gdb)b 25
(gdb)run
(gdb) u 29
it will calc a + b
3 + 7 = 10
main () at gdbStep.c:29
29 count(c);
(gdb)
可以看到,在执行u 29之后,它在29行停住了。它利用的是临时断点。
跳过执行—skip
skip可以在step时跳过一些不想关注的函数或者某个文件的代码:
$ gdb gdbStep
(gdb) b 27
Breakpoint 1 at 0x4005e4: file gdbStep.c, line 27.
(gdb) skip function add #step时跳过add函数
Function add will be skipped when stepping.
(gdb) info skip #查看step情况
Num Type Enb What
1 function y add
(gdb) run
Starting program: /home/hyb/workspaces/gdb/gdbStep
it will calc a + b
Breakpoint 1, main () at gdbStep.c:27
27 int c = add(a,b);
(gdb) s
28 printf("%d + %d = %d\n",a,b,c);
(gdb)
可以看到,再使用skip之后,使用step将不会进入add函数。 step也后面也可以跟文件:
(gdb)skip file gdbStep.c
这样gdbStep.c中的函数都不会进入。
其他相关命令:
- skip delete [num] 删除skip
- skip enable [num] 使能skip
- skip disable [num] 去使能skip
其中num是前面通过info skip看到的num值,上面可以带或不带该值,如果不带num,则针对所有skip,如果带上了,则只针对某一个skip。
总结
本文主要介绍了一些简单情况的单步调试方法或常见命令使用,但这些已经够用了,毕竟大部分程序的执行或停止都在我们的掌控之中了。
源码查看
前言
我们在调试过程中难免要对照源码进行查看,如果已经开始了调试,而查看源码或者编辑源码却要另外打开一个窗口,那未免显得太麻烦。文本将会介绍如何在GDB调试模式下查看源码或对源码进行编辑。
准备工作
为了说明后面的内容,我们先准备一些源码,分别是main.c:
//main.c
#include<stdio.h>
#include"test.h"
int main(void)
{
printf("it will print from 5 to 1\n");
printNum(5);
printf("print end\n");
printf("it will print 1 to 5\n");
printNum1(5);
printf("print end\n");
return 0;
}
头文件test.h:
#ifndef _TEST_H
#define _TEST_H
#include<stdio.h>
void printNum(int n);
void printNum1(int n);
#endif
以及test.c:
#include"test.h"
void printNum(int n)
{
if( n < 0)
return;
while(n > 0)
{
printf("%d\n",n);
n--;
}
}
void printNum1(int n)
{
if( n < 0)
return;
int i = 1;
while(i <= n)
{
printf("%d\n",i);
i++;
}
}
编译运行:
$ gcc -g -o main main.c test.c
$ chmod +x main
$ ./main
it will print from 5 to 1
5
4
3
2
1
print end
it will print 1 to 5
1
2
3
4
5
print end
程序功能比较简单,用来打印5到1的数以及1到5的数,这里也就不多做解释。
列出源码
首先要介绍的就是list命令(可简写为l),它用来打印源码。
直接打印源码
例如:
$ gdb main
(gdb) l
1 //main.c
2 #include<stdio.h>
3 #include"test.h"
4 int main(void)
5 {
6 printf("it will print from 5 to 1\n");
7 printNum(5);
8 printf("print end\n");
9
10 printf("it will print 1 to 5\n");
(gdb)
直接输入l可从第一行开始显示源码,继续输入l,可列出后面的源码。后面也可以跟上+或者-,分别表示要列出上一次列出源码的后面部分或者前面部分。
列出指定行附近源码
l后面可以跟行号,表明要列出附近的源码:
(gdb) l 9
4 int main(void)
5 {
6 printf("it will print from 5 to 1\n");
7 printNum(5);
8 printf("print end\n");
9
10 printf("it will print 1 to 5\n");
11 printNum1(5);
12 printf("print end\n");
13 return 0;
在这里,l后面跟上9,表明要列出第9行附近的源码。
列出指定函数附近的源码
这个很容易理解,而使用也很简单,l后面跟函数名即可,例如:
(gdb) l printNum
1 #include"test.h"
2 void printNum(int n)
3 {
4 if( n < 0)
5 return;
6 while(n > 0)
7 {
8 printf("%d\n",n);
9 n--;
10 }
在这里,l后面跟上函数名printNum,它便列出了printNum函数附近的源码。
设置源码一次列出行数
不知道你有没有发现,在列出函数源码的时候,它并没有列全,因为l每次只显示10行,那么有没有方法每次列出更多呢? 我们可以通过listsize属性来设置,例如设置每次列出20行:
(gdb) set listsize 20
(gdb) show listsize
Number of source lines gdb will list by default is 20.
这样每次就会列出20行,当然也可以设置为0或者unlimited,这样设置之后,列出就没有限制了,但源码如果较长,查看将会不便。
列出指定行之间的源码
list first,last 例如,要列出3到15行之间的源码:
(gdb) l 3,15
3 {
4 if( n < 0)
5 return;
6 while(n > 0)
7 {
8 printf("%d\n",n);
9 n--;
10 }
11 }
12
13 void printNum1(int n)
14 {
15 if( n < 0)
启始行和结束行号之间用逗号隔开。两者之一也可以省略,例如:
(gdb) list 3,
3 {
4 if( n < 0)
5 return;
6 while(n > 0)
7 {
8 printf("%d\n",n);
9 n--;
10 }
11 }
12
省略结束行的时候,它列出从开始行开始,到指定大小行结束,而省略开始行的时候,到结束行结束,列出设置的大小行,例如默认设置为10行,则到结束行为止,总共列出10行。前面我们也介绍了修改和查看默认列出源码行数的方法。
列出指定文件的源码
前面执行l命令时,默认列出main.c的源码,如果想要看指定文件的源码呢?可以
l location
其中location可以是文件名加行号或函数名,因此可以使用:
(gdb) l test.c:1
1 #include"test.h"
2 void printNum(int n)
3 {
4 if( n < 0)
5 return;
6 while(n > 0)
7 {
8 printf("%d\n",n);
9 n--;
10 }
(gdb)
来查看指定文件指定行,或者指定文件指定函数:
(gdb) l test.c:printNum1
9 n--;
10 }
11 }
12
13 void printNum1(int n)
14 {
15 if( n < 0)
16 return;
17 int i = 1;
18 while(i <= n)
(gdb)
或者指定文件指定行之间:
(gdb) l test.c:1,test.c:3
1 #include"test.h"
2 void printNum(int n)
3 {
(gdb)
指定源码路径
在查看源码之前,首先要确保我们的程序能够关联到源码,一般来说,我们在自己的机器上加上-g参数编译完之后,使用gdb都能查看到源码,但是如果出现下面的情况呢?
源码被移走
例如,我现在将main.c移动到当前的temp目录下,再执行l命令:
(gdb) l
1 main.c: No such file or directory.
(gdb)
它就会提示找不到源码文件了,那么怎么办呢? 我们可以使用dir命名指定源码路径,例如:
(gdb) dir ./temp
Source directories searched: /home/hyb/workspaces/gdb/sourceCode/./temp:$cdir:$cwd
这个时候它就能找到源码路径了。我这里使用的是相对路径,保险起见,你也可以使用绝对路径。
更换源码目录
例如,你编译好的程序文件,放到了另外一台机器上进行调试,或者你的源码文件全都移动到了另外一个目录,怎么办呢?当然你还可以使用前面的方法添加源码搜索路径,也可以使用set substitute-path from to将原来的路径替换为新的路径,那么我们如何知道原来的源码路径是什么呢?借助readelf命令可以知道:
$ readelf main -p .debug_str
[ 0] long unsigned int
[ 12] short int
[ 1c] /home/hyb/workspaces/gdb/sourceCode
[ 40] main.c
(显示部分内容)
main为你将要调试的程序名,这里我们可以看到原来的路径,那么我们现在替换掉它:
(gdb) set substitute-path /home/hyb/workspaces/gdb/sourceCode /home/hyb/workspaces/gdb/sourceCode/temp
(gdb) show substitute-path
List of all source path substitution rules:
`/home/hyb/workspaces/gdb/sourceCode' -> `/home/hyb/workspaces/gdb/sourceCode/temp'.
(gdb)
设置完成后,可以通过show substitute-path来查看设置结果。这样它也能在正确的路径查找源码啦。
需要注意的是,这里对路径做了字符串替换,那么如果你有多个路径,可以做多个替换。甚至可以对指定文件路径进行替换。
最后你也可以通过unset substitute-path [path]取消替换。
编辑源码
为了避免已经启动了调试之后,需要编辑源码,又不想退出,可以直接在gdb模式下编辑源码,它默认使用的编辑器是/bin/ex,但是你的机器上可能没有这个编辑器,或者你想使用自己熟悉的编辑器,那么可以通过下面的方式进行设置:
$ EDITOR=/usr/bin/vim
$ export EDITOR
/usr/bin/vim可以替换为你熟悉的编辑器的路径,如果你不知道你的编辑器在什么位置,可借助whereis命令或者witch命令查看:
$ whereis vim
vim: /usr/bin/vim /usr/bin/vim.tiny /usr/bin/vim.basic /usr/bin/vim.gnome /etc/vim /usr/share/vim /usr/share/man/man1/vim.1.gz
$ which vim
/usr/bin/vim
设置之后,就可以在gdb调试模式下进行编辑源码了,使用命令edit location,例如:
(gdb)edit 3 #编辑第三行
(gdb)edit printNum #编辑printNum函数
(gdb)edit test.c:5 #编辑test.c第五行
可自行尝试,这里的location和前面介绍的一样,可以跟指定文件的特定行或指定文件的指定函数。 编辑完保存后,别忘了重新编译程序:
(gdb)shell gcc -g -o main main.c test.c
这里要注意,为了在gdb调试模式下执行shell命令,需要在命令之前加上shell,表明这是一条shell命令。这样就能在不用退出GDB调试模式的情况下编译程序了。
另外一种模式
启动时,带上tui(Text User Interface)参数,会有意想不到的效果,它会将调试在多个文本窗口呈现:
gdb main -tui
但是本文不作介绍,有兴趣的可以探索一下。
C++调用C接口-extern “C”
前言
如何在C++代码中调用写好的C接口?你可能会奇怪,C++不是兼容C吗?直接调用不就可以了?这里我们先按下不表,先看看C++如何调用C代码接口。
C++如何调用C接口
为什么会有这样的情况呢?想象一下,有些接口是用C实现的,并提供了库,那么C++中该如何使用呢?我们先不做任何区别对待,看看普通情况下会发生什么意想不到的事情。 首先提供一个C接口:
//来源:公众号【编程珠玑】 博客:https://www.yanbinghu.com
//test.c
#include"test.h"
void testCfun()
{
printf("I am c fun\n");
return;
}
为了简化,我们在这里就不将它做成静态库或者动态库了,有兴趣的可以自行尝试。我们在这里编译成C目标文件:
gcc -c test.c
另外提供一个头文件test.h:
#include<stdio.h>
void testCfun();
我们的C++代码调用如下:
//来源:公众号【编程珠玑】 博客:https://www.yanbinghu.com
//main.cpp
#include"test.h"
#include<iostream>
using namespace std;
int main(void)
{
/*调用C接口*/
cout<<"start to call c function"<<endl;
testCfun();
cout<<"end to call c function"<<endl;
return 0;
}
编译:
$ g++ -o main main.cpp test.o
/tmp/ccmwVJqM.o: In function `main':
main.cpp:(.text+0x21): undefined reference to `testCfun()'
collect2: error: ld returned 1 exit status
很不幸,最后的链接报错了,说找不到testCfun,但是我们确实定义了这个函数。为什么会找不到呢?现在你还会认为C++直接就可以调用C接口了吗?
真相
我们都知道,C++中函数支持重载,而C并不支持。C++为了支持函数重载,它在“生成”函数符号信息时,不能仅仅通过函数名,因为重载函数的函数名都是一样的,所以它还要根据入参,命名空间等信息来确定唯一的函数签名。或者说C++生成函数签名的方式与C不一致,所以即便是函数名一样,对于C和C++来说,它们最终的函数签名还是不一样。当然这里又是另外一回事了,我们不细说。我们看看两个文件里的函数符号有什么区别:
$ nm test.o|grep testCfun
0000000000000000 T testCfun
$ nm main.o|grep testCfun
U _Z8testCfunv
所以它们两个能链接在一起才真是奇怪了呢!名字都不同,还怎么链接?
如何处理
那么如何处理呢?很显然,我们必须告诉链接器,这是一个C接口,而不是C++接口,所以需要加入 extern C,我们修改test.h
#include<stdio.h>
extern "C"{
void testCfun();
}
这里用extern “C”将testCfun接口包裹起来,告诉编译器,这里的是C接口哈,你要按C代码的方式处理。再次编译:
$ g++ -o main main.cpp test.o
$ ./main
start to call c function
I am c fun
end to call c function
看终端输出,完美!
优化
虽然上面的C接口可以被C++正常调用了,但是如果这个C接口要被C代码调用呢?增加main.c内容如下
//main.c
#include"test.h"
int main(void)
{
/*调用C接口*/
testCfun();
return 0;
}
编译:
$ gcc -o main main.c test.c
In file included from main.c:2:0:
test.h:2:8: error: expected identifier or '(' before string constant
extern "C"{
^
In file included from test.c:2:0:
test.h:2:8: error: expected identifier or '(' before string constant
extern "C"{
不出意外,又报错了,很显然,C语言中并没有extern “C”这样的写法,所以为了能使得test.c的代码既能被C++调用,也能被C调用,需要改写成下面这样:
#include<stdio.h>
#ifdef __cplusplus
extern "C"{
#endif
void testCfun();
#ifdef __cplusplus
}
#endif
这里通过__cplusplus宏来控制是否需要extern “C”,如果是C++编译器,那么extern “C”部分就会被预处理进去,这样test.c代码就可以既用于C++,也可以用于C啦。
赶快去你的C项目代码头文件中看看,是不是也有这样的代码段呢?
问题
为什么我们在C++代码中可以直接调用一些标准C库函数呢?即使你在main函数中调用printf等函数,它也不会出现链接错误。因为库函数已经有了类似的处理了。
如果你还是不确定,你可以先预处理:
$ g++ -E main.i main.cpp
去生成的main.i文件中找一找,是不是有extern “C”。
总结
C++支持重载,而C不支持,C++并不能直接调用C代码写好的接口,因此如果你的C代码想要能够被C调用,也想被C++调用,那么别忘了extern “C”。
制作静态库
前言
那么你有没有想过如何把自己写好的函数接口制作成静态库给别人用呢?本文教你如何制作属于自己的静态库。
编译成可重定位文件
在ubuntu18.04的系统,使用gcc7.4编译出来的可执行文件的type是DYN,这是编译器生成了一种位置无关的可执行文件(PIE),它类似于动态库,其地址在加载时确定,从而更加安全。本文不再展开介绍。
本文实例代码test1.c代码如下:
//来源:公众号【编程珠玑】 网站:https://www.yanbinghu.com
#include"test1.h"
void test1()
{
printf("I am test1\n");
}
编译成可重定位文件,即生成.o文件:
$ gcc -c test1.c
$ readelf -h test1.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
(省略部分内容)
制作成静态库
为了制作成静态库,我们需要使用ar命令。
$ ar -rcs libtest1.a test1.o #库名一般以.a为扩展名,以lib开头
$ ar -t libtest1.a #查看内容
test1.o
通常来说,静态库以.a作为后缀,且以lib开头。至此就将我们提供的test1函数做成了静态库,但是为了方便其他人使用,我们再提供一个头文件test1.h,代码如下:
#include<stdio.h>
void test1();
这个时候就可以将我们做好的静态库给其他人使用啦。
使用静态库
我们写一个main.c来调用test1():
//来源:公众号【编程珠玑】 网站:https://www.yanbinghu.com
#include"test1.h"
int main(void)
{
test1();
return 0;
}
编译运行:
$ gcc -o main main.c -L ./ -ltest1
$ ./main
I am test1
其中-L用于指定链接库的路径,由于我们要链接的库名为libtest1.a,在链接的时候,去掉开头的lib和后缀.a,前面再加l,就变成了-ltest1,其他库也是类似。例如,你如果看到程序链接使用-lm,说明它使用了名为libm.a的库。
再看静态库使用
如果这时候还有一个库libtest0.a,库中调用了test1.c的函数,而main函数调用了libtest0.a中的函数呢?即,假设有test0.c中调用test1(),且两者位于不同的库中,test0.c代码如下:
#include"test0.h"
void test0()
{
printf("I am test0,I will call test1\n");
test1();
printf("test0 call test1 end\n");
}
头文件test0.h:
#include"test1.h"
void test0();
还是以类似的方法制作静态库libtest0.a:
$ gcc -c test0.c
$ ar -rcs libtest0.a test0.o
改写main.c:
//来源:公众号【编程珠玑】 网站:https://www.yanbinghu.com
#include"test0.h"
int main(void)
{
test0();
return 0;
}
重新编译链接:
$ gcc -o main main.c -L ./ -ltest1 -ltest0
.//libtest0.a(test0.o): In function `test0':
test0.c:(.text+0x14): undefined reference to `test1'
collect2: error: ld returned 1 exit status
这里我们发现编译出错了,提示test1未定义,很显然是由于test0中调用了test1。至于解决办法也很简单,调整链接库的顺序即可。
我们调整之后再次编译链接并运行:
$ gcc -o main main.c -L ./ -ltest0 -ltest1
$ ./main
I am test0,I will call test1
I am test1
test0 call test1 end
可以看到,在调整两个库的顺序之后,编译链接正常,并且程序也按照我们预期的结果运行。
因此,我们在链接时,应该尽量把被需要的库放在后面。
ar命令详解
从前面的内容我们可以观察到,我们是通过ar命令来制作静态库(归档文件)的,它可以将多个按照一定的规则组织在一起。我们再来了解一下ar命令,ar命令常见参数如下:
- r 向归档文件中添加内容,如原先已存在,则替换
- c 创建归档文件
- s 添加索引信息
- d 从归档文件中删除
- t 查看归档文件的内容
- x 解压归档文件
- a/b 向归档文件中添加内容
- v 显示详细信息
rcs参数我们已经在前面用到了。-a(after)或者-b(before)参数可以向归档文件中添加文件,例如:
$ ar -ra test0.o libtest0.a test1.o
这里表示在libtest0.a中的test0.o之后,添加test1.o。 添加后内容如下:
$ ar -t libtest0.a
test0.o
test1.o
当然了,归档文件是可以解开的,比如:
$ ar -xv libtest0.a
x - test0.o
x - test1.o
你要删除其中的某个文件,也是没人阻止的:
$ ar -d test1.o libtest0.a
d - test1.o
-d参数后面跟着要移除的文件。
需要特别注意的是,这里ar归档的作用并不仅仅针对可重定位目标文件,而是几乎针对任何类型的普通文件。
总结
制作静态库不过是利用ar命令把一些文件可重定位文件打包在一起,其他程序在使用时需要通过链接动态将自己需要的内容“拷贝”到最终的可执行文件中。现在知道如何制作属于自己的静态库了吗?赶紧自己试试吧!
静态库和动态库区别
前言
我们在编写代码的时候经常用到已有的接口,他们是以库的形式提供给我们使用的,而常见形式有两种,一种常以.a为后缀,为静态库;另一种以.so为后缀,为动态库。那么这两种库有什么区别呢?
说明:本文主要说明Linux下的情况,windows不涉及。
目标文件
在解释静态库和动态库之前,需要简单了解一下什么是目标文件。目标文件常常按照特定格式来组织,在linux下,它是ELF格式(Executable Linkable Format,可执行可链接格式),而在windows下是PE(Portable Executable,可移植可执行)。
而通常目标文件有三种形式:
- 可执行目标文件。即我们通常所认识的,可直接运行的二进制文件。
- 可重定位目标文件。包含了二进制的代码和数据,可以与其他可重定位目标文件合并,并创建一个可执行目标文件。
- 共享目标文件。它是一种在加载或者运行时进行链接的特殊可重定位目标文件。
我们来看一个简单实例:
//main.c
#include<stdio.h>
#include<math.h>
int main(int argc,char *argv[])
{
printf("hello 编程珠玑\n");
int b = 2;
double a = exp(b);
printf("%lf\n",a);
return 0;
}
代码计算e的2次方并打印结果。由于代码中用到了exp函数,它位于数学库libm.so或者libm.a中,因此编译时需要加上-lm。
生成可重定位目标文件main.o:
$ gcc -c main.c #生成可重定位目标文件
$ readelf -h main.o #查看elf文件头部信息
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: REL (Relocatable file)
(省略其他内容)
通过上面的命令将main.c生成为可重定位目标文件。通过readelf命令也可以看出来:REL (Relocatable file)。
观察共享目标文件libm.so:
$ readelf -h /lib/x86_64-linux-gnu/libm.so.6
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: DYN (Shared object file)
(省略其他内容)
不同系统中libm.so的位置可能不一样,你可以通过locate命令来查找。从结果可以看到,libm.so是共享目标文件(Shared object file)。
查看可执行目标文件main:
$ gcc -o main main.o -lm #编译成最终的可执行文件
$ readelf -h main #查看ELF文件头
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
(省略其他内容)
这里必须要强调一点,如果使用到的函数没有在libc库中,那么你就需要指定要链接的库,本文中需要链接libm.so或libm.a。可以看到,最终生成的main类型是Executable file,即可执行目标文件。
什么是静态库
前面所提到可重定位目标文件以一种特定的方式打包成一个单独的文件,并且在链接生成可执行文件时,从这个单独的文件中“拷贝”它自己需要的内容到最终的可执行文件中。这个单独的文件,称为静态库。linux中通常以.a(archive)为后缀
还是拿前面的例子来说,我们使用静态链接构建我们的可执行文件:
$ gcc -c main.c
$ gcc -static -o main main.o -lm
在这个过程中,就会用到系统中的静态库libm.a。这个过程做了什么呢?首先第一条命令会将main.c编译成可重定位目标文件main.o,第二条命令的static参数,告诉链接器应该使用静态链接,-lm参数表明链接libm.a这个库(类似的,如果要链接libxxx.a,使用-lxxx即可)。由于main.c中使用了libm.a中的exp函数,因此链接时,会将libm.a中需要的代码“拷贝”到最终的可执行文件main中。
特别注意,必须把-lm放在后面。放在最后时它是这样的一个解析过程:
- 链接器从左往右扫描可重定位目标文件和静态库
- 扫描main.o时,发现一个未解析的符号exp,记住这个未解析的符号
- 扫描libm.a,找到了前面未解析的符号,因此提取相关代码
- 最终没有任何未解析的符号,编译链接完成
那如果将-lm放在前面,又是怎样的情况呢?
- 链接器从左往右扫描可重定位目标文件和静态库
- 扫描libm.a,由于前面没有任何未解析的符号,因此不会提取任何代码
- 扫描main.o,发现未解析的符号exp
- 扫描结束,还有一个未解析的符号,因此编译链接报错
如果把-lm放在前面,编译结果如下:
$ gcc -static -lm -o main main.o
main.o: In function `main':
main.c:(.text+0x2f): undefined reference to `exp'
collect2: error: ld returned 1 exit status
我们看看最终生成的文件大小:
$ ls -lh main
-rwxrwxr-x 1 hyb hyb 988K 6月 27 20:22 main
生成的可执行文件大小为988k。
由于最终生成的可执行文件中已经包含了exp相关的二进制代码,因此这个可执行文件在一个没有libm.a的linux系统中也能正常运行。
什么是动态库
动态库和静态库类似,但是它并不在链接时将需要的二进制代码都“拷贝”到可执行文件中,而是仅仅“拷贝”一些重定位和符号表信息,这些信息可以在程序运行时完成真正的链接过程。linux中通常以.so(shared object)作为后缀。
通常我们编译的程序默认就是实用动态链接:
$ gcc -o main main.c -lm #默认使用的是动态链接
我们来看最终生成的文件大小:
$ ls -lh main
-rwxrwxr-x 1 hyb hyb 8.5K 6月 27 20:25 main
可以看到,通过动态链接的程序只有8.5k!
另外我们还可以通过ldd命令来观察可执行文件链接了哪些动态库:
$ ldd main
linux-vdso.so.1 => (0x00007ffc7b5a2000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fe9642bf000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe963ef5000)
/lib64/ld-linux-x86-64.so.2 (0x00007fe9645c8000)
正因为我们并没有把libm.so中的二进制代码“拷贝”可执行文件中,我们的程序在其他没有上面的动态库时,将无法正常运行。
有什么区别
到这里我们大致了解了静态库和动态库的区别了,静态库被使用目标代码最终和可执行文件在一起(它只会有自己用到的),而动态库与它相反,它的目标代码在运行时或者加载时链接。正是由于这个区别,会导致下面所介绍的这些区别。
可执行文件大小不一样
从前面也可以观察到,静态链接的可执行文件要比动态链接的可执行文件要大得多,因为它将需要用到的代码从二进制文件中“拷贝”了一份,而动态库仅仅是复制了一些重定位和符号表信息。
占用磁盘大小不一样
如果有多个可执行文件,那么静态库中的同一个函数的代码就会被复制多份,而动态库只有一份,因此使用静态库占用的磁盘空间相对比动态库要大。
扩展性与兼容性不一样
如果静态库中某个函数的实现变了,那么可执行文件必须重新编译,而对于动态链接生成的可执行文件,只需要更新动态库本身即可,不需要重新编译可执行文件。正因如此,使用动态库的程序方便升级和部署。
依赖不一样
静态链接的可执行文件不需要依赖其他的内容即可运行,而动态链接的可执行文件必须依赖动态库的存在。所以如果你在安装一些软件的时候,提示某个动态库不存在的时候也就不奇怪了。
即便如此,系统中一班存在一些大量公用的库,所以使用动态库并不会有什么问题。
复杂性不一样
相对来讲,动态库的处理要比静态库要复杂,例如,如何在运行时确定地址?多个进程如何共享一个动态库?当然,作为调用者我们不需要关注。另外动态库版本的管理也是一项技术活。这也不在本文的讨论范围。
加载速度不一样
由于静态库在链接时就和可执行文件在一块了,而动态库在加载或者运行时才链接,因此,对于同样的程序,静态链接的要比动态链接加载更快。所以选择静态库还是动态库是空间和时间的考量。但是通常来说,牺牲这点性能来换取程序在空间上的节省和部署的灵活性时值得的。再加上局部性原理,牺牲的性能并不多。
总结
静态库和动态库具体是何如链接的已经超出了本文的介绍范围,本文仅简单介绍了一些静态库和动态库的区别,另外文中提到的在其他的linux系统,也指的是同样处理器架构的系统。但是了解这些基本信息,就能够帮助我们解决很多编译问题了。更多内容可自己阅读装载,链接方面的书籍。后面的文章也会介绍更多相关信息。
一个奇怪的链接问题
前言
链接是代码生成可执行文件中一个非常重要的过程。我们在使用一些库函数时,有时候需要链接库,有时候又不需要,这是为什么呢?了解一些链接的基本过程,能够帮助我们在编译时解决一些奇怪的问题。比如,下面就有一种奇怪的现象。
一个奇怪的链接问题
程序功能很简单,计算e的n次方。程序清单如下(代码一):
#include<stdio.h>
#include<math.h>
int main(int argc,char *argv[])
{
double a = exp(2);
printf("%lf\n",a);
return 0;
}
编译运行:
gcc -o expTest expTest.c
./expTest
7.389056
一切似乎顺理成章,我们再来看下面这种情况(代码二):
#include<stdio.h>
#include<math.h>
int main(int argc,char *argv[])
{
int b = 2;
double a = exp(b);
printf("%lf\n",a);
return 0;
}
编译:
gcc -o expTest expTest.c
/tmp/ccx5lXbS.o:在函数‘main’中:
expTest.c:(.text+0x20):对‘exp’未定义的引用
collect2: error: ld returned 1 exit status
我们发现,同样的编译方法编译不过了,提示对‘exp’未定义的引用,并且抛出链接出错。
我们通过man命令查看exp函数:
man 3 exp
NAME
exp, expf, expl - base-e exponential function
SYNOPSIS
#include <math.h>
double exp(double x);
float expf(float x);
long double expl(long double x);
Link with -lm.
发现它除了需要包含头文件math.h外,编译时还需要使用-lm链接。 再次编译运行:
gcc -lm -o expTest expTest.c
/tmp/ccYT3E65.o:在函数‘main’中:
expTest.c:(.text+0x20):对‘exp’未定义的引用
collect2: error: ld returned 1 exit status
为什么还是不行呢?我们已经按照帮助手册的只是加了-lm了啊?难道是位置不对?我们换个位置试试:
gcc -o expTest expTest.c -lm
./expTest
7.389056
现在终于成功编译并运行。
分析
虽然最后终于成功编译运行,但是不免产生了几个疑问:
- 两段代码同样都调用了exp函数,为什么一个需要链接,一个不需要链接呢?
- 到底什么时候需要链接呢?
- 为什么链接的时候放在前面就不行呢?
我们一一解答。 1.为什么一个需要链接,一个不需要? 我们可以观察到,代码一调用exp传入的参数是常量2,代码二调用exp传入的参数是变量b,那么对于代码一会不会在运行之前就计算好了呢? 我们来看一下它们的汇编代码。 代码一:
.LC1:
.string "%lf\n"
main:
push rbp
mov rbp, rsp
sub rsp, 32
mov DWORD PTR [rbp-20], edi
mov QWORD PTR [rbp-32], rsi
movsd xmm0, QWORD PTR .LC0[rip]
movsd QWORD PTR [rbp-8], xmm0
movsd xmm0, QWORD PTR [rbp-8]
mov edi, OFFSET FLAT:.LC1
mov eax, 1
call printf
mov eax, 0
leave
ret
.LC0:
.long 3100958126
.long 1075678820
代码二:
.LC0:
.string "%lf\n"
main:
push rbp
mov rbp, rsp
sub rsp, 32
mov DWORD PTR [rbp-20], edi
mov QWORD PTR [rbp-32], rsi
mov DWORD PTR [rbp-4], 2
cvtsi2sd xmm0, DWORD PTR [rbp-4]
call exp
movq rax, xmm0
mov QWORD PTR [rbp-16], rax
movsd xmm0, QWORD PTR [rbp-16]
mov edi, OFFSET FLAT:.LC0
mov eax, 1
call printf
mov eax, 0
leave
ret
汇编的具体细节我们无需尽知,但是我们可以很明显地看到,第二段代码调用了exp函数(call exp指令),而第一段代码没有看到调用exp的身影。 实际上,通过汇编代码可以看到,当传入参数为常量时,就已经计算好了值(emm0寄存器为浮点运算相关寄存器),最后根本不需要调用exp函数。而对于变量型的参数,其值在运行时确定,因此需要调用。我们还可以通过ldd命令来看它们链接的库有什么不同。 对于代码一:
ldd expTest
linux-vdso.so.1 => (0x00007ffec079d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd327744000)
/lib64/ld-linux-x86-64.so.2 (0x00007fd327b0e000)
对于代码二:
ldd expTest
linux-vdso.so.1 => (0x00007ffefdfc9000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f9afcccb000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9afc901000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9afcfd4000)
可以看到,第二段代码编译出来的可执行文件,多依赖了libm.so.6,也就是exp函数所在的库。
2.什么时候需要链接? 事实上,C编译器总是主动传送libc.a或libc.so给链接器,也就是说,对于使用包含在libc.a或libc.so库中的函数,是不需要在编译时手动链接的。而调用函数是否需要链接,可以使用命令“man 3 函数名“查看,如果需要链接库,最后都有说明。
3.为什么链接的时候放在前面就不行呢? 这个就涉及到链接器的工作原理了,在此只简单说明一下:链接过程中,需要进行符号解析,并且是按照顺序解析;如果库链接在前,就可能出现库中的符号不会被需要,链接器不会把它加到未解析的符号集合中,那么后面引用这个符号的目标文件就不能解析该引用,导致最后链接失败。因此链接库的一般准则是将它们放在命令行的结尾。
总结
通过前面的实例和分析,我们总结出以下几点:
- 调用包含于libc库中的函数不需要链接。
- 对于传参为常量的数学函数调用,生成可执行文件过程中可能将其优化,而无需调用该函数。
- 库链接一般放在命令行结尾。
- 通过man命令查看在调用某个函数时是否需要链接。
linux常用命令–开发调试篇
前言
Linux常用命令中有一些命令可以在开发或调试过程中起到很好的帮助作用,有些可以帮助了解或优化我们的程序,有些可以帮我们定位疑难问题。本文将简单介绍一下这些命令。
示例程序
我们用一个小程序,来帮助后面我们对这些命令的描述,程序清单cmdTest.c如下:
#include<stdio.h>
int test(int a,int b)
{
return a/b;
}
int main(int argc,char *argv[])
{
int a = 10;
int b = 0;
printf("a=%d,b=%d\n",a,b);
test(a,b);
return 0;
}
编译获得elf文件cmdTest并运行:
gcc -g -o cmdTest cmdTest.c
./cmdTest
a=10,b=0
Floating point exception (core dumped)
程序内容是在main函数中调用test,计算a/b的值,其中b的值为0,因此程序由于除0错误异常终止。
查看文件基本信息—file
file cmdTest
cmdTest: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.24, BuildID[sha1]=448e1c34b4c548120e2c04f6a2bfce4e6d2281a3, not stripped
通过file命令可以看到cmdTest的类型为elf,是64位、运行于x86-64的程序,not striped表明elf文件中还保留着符号信息以及调试信息等不影响程序运行的内容。
查看程序依赖库—ldd
ldd cmdTest
linux-vdso.so.1 => (0x00007ffc8e548000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0621931000)
/lib64/ld-linux-x86-64.so.2 (0x00007f0621cf6000)
我们可以看到cmdTest依赖了libc.so等库。
查看函数或者全局变量是否存在于elf文件中—nm
nm命令用于查看elf文件的符号信息。文件编译出来之后,我们可能不知道新增加的函数或者全局变量是否已经成功编译进去。这时候,我们可以使用nm命令来查看。 例如,查看前面所提到的elf文件有没有test函数,可以用命令:
nm cmdTest|grep test
000000000040052d T test #打印结果
按照地址顺序列出符号信息:
nm -n cmdTest
w _ITM_deregisterTMCloneTable
w _ITM_registerTMCloneTable
w _Jv_RegisterClasses
w __gmon_start__
U __libc_start_main@@GLIBC_2.2.5
U printf@@GLIBC_2.2.5
00000000004003e0 T _init
0000000000400440 T _start
0000000000400470 t deregister_tm_clones
00000000004004a0 t register_tm_clones
00000000004004e0 t __do_global_dtors_aux
0000000000400500 t frame_dummy
000000000040052d T test
0000000000400540 T main
0000000000400590 T __libc_csu_init
0000000000400600 T __libc_csu_fini
(列出部分内容)
可以看到test函数的开始地址为0x000000000040052d,结束地址为0x0000000000400540。
打印elf文件中的可打印字符串—strings
例如你在代码中存储了一个版本号信息,那么即使编译成elf文件后,仍然可以通过strings搜索其中的字符串甚至可以搜索某个.c文件是否编译在其中:
strings elfFile| grep "someString"
查看文件段大小—size
可以通过size命令查看各段大小:
size cmdTest
text data bss dec hex filename
1319 560 8 1887 75f cmdTest
text段:正文段字节数大小 data段:包含静态变量和已经初始化的全局变量的数据段字节数大小 bss段:存放程序中未初始化的全局变量的字节数大小 当我们知道各个段的大小之后,如果有减小程序大小的需求,就可以有针对性的对elf文件进行优化处理。
为elf文件”瘦身“—strip
strip用于去掉elf文件中所有的符号信息:
ls -al cmdTest
-rwxr-xr-x 1 hyb root 9792 Sep 25 20:30 cmdTest #总大小为9792字节
strip cmdTest
ls -al cmdTest
-rwxr-xr-x 1 hyb root 6248 Sep 25 20:35 cmdTest#strip之后大小为6248字节
可以看到,“瘦身”之后,大小减少将近三分之一。但是要特别注意的是,“瘦身”之后的elf文件由于没有了符号信息,许多调试命令将无法正常使用,出现core dump时,问题也较难定位,因此只建议在正式发布时对其进行“瘦身”。
查看elf文件信息—readelf
readelf用于查看elf文件信息,它可以查看各段信息,符号信息等,下面的例子是查看elf文件头信息:
readelf -h cmdTest
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 #elf文件魔数字
Class: ELF64 #64位 elf文件
Data: 2's complement, little endian#字节序为小端序
Version: 1 (current)
OS/ABI: UNIX - System V #
ABI Version: 0
Type: EXEC (Executable file)#目标文件类型
Machine: Advanced Micro Devices X86-64 #目标处理器体系
Version: 0x1
Entry point address: 0x400440 #入口地址
Start of program headers: 64 (bytes into file)
Start of section headers: 4456 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 28
Section header string table index: 27
从elf头信息中,我们可以知道该elf是64位可执行文件,运行在x86-64中,且字节序为小端序。另外,我们还注意到它的入口地址是0x400440(_start),而不是400540(main)。也就是说,我们的程序运行并非从main开始。
反汇编指定函数—objdump
objdump用于展示elf文件信息,功能较多,在此不逐一介绍。有时候我们需要反汇编来定位一些问题,可以使用命令:
objdump -d cmdTest #反汇编整个cmdTest程序
但是如果程序较大,那么反汇编时间将会变长,而且反汇编文件也会很大。如果我们已经知道了问题在某个函数,只想反汇编某一个函数,怎么处理呢? 我们可以利用前面介绍的nm命令获取到函数test的地址,然后使用下面的方式反汇编:
objdump -d cmdTest --start-address=0x40052d --stop-address=0x400540 ##反汇编指定地址区间
端口占用情况查看—netstat
我们可能常常会遇到进程第一次启动后,再次启动会出现端口绑定失败的问题,我们可以通过netstat命令查看端口占用情况:
netstat -anp|grep 端口号
进程状态查看—ps&top
top命令实时显示当前进程状态,最活跃的进程显示在最顶部。
core dump文件生成配置—ulimit -c
有时候我们的程序core dump了却没有生成core文件,很可能是我们设置的问题:
ulimit -c #查看core文件配置,如果结果为0,程序core dump时将不会生成core文件
ulimit -c unlimited #不限制core文件生成大小
ulimit -c 10 #设置最大生成大小为10kb
调试神器—gdb
gdb是一个强大的调试工具,但这里仅介绍两个简单使用示例。 有时候程序可能已经正在运行,但是又不能终止它,这时候仍然可以使用gdb调试正在运行的进程:
gdb processFile PID #processFile为进程文件,pid为进程id,可通过ps命令查找到
有时候程序可能core dump了,但是系统还留给了我们一个礼物—core文件。 在core文件生成配置完成之后,运行cmdTest程序,产生core文件。我们可以用下面的方法通过core文件定位出错位置:
gdb cmdTest core #processFile为进程文件,core为生成的core文件
Core was generated by `./cmdTest'.
Program terminated with signal SIGFPE, Arithmetic exception.
#0 0x00000000004004fb in test (a=10, b=0) at cmdTest.c:4
4 return a/b;
(gdb)bt
#0 0x00000000004004fb in test (a=10, b=0) at cmdTest.c:4
#1 0x000000000040052c in main (argc=1, argv=0x7ffca9536d38) at cmdTest.c:10
(gdb)
输入bt后,就可以看到调用栈了,出错位置在test函数,cmdTest.c的第4行。
定位crash问题—addr2line
有时候程序崩溃了但不幸没有生成core文件,是不是就完全没有办法了呢?还是cmdTest的例子。运行完cmdTest之后,我们通过dmesg命令可以获取到以下内容
[27153070.538380] traps: cmdTest[2836] trap divide error ip:40053b sp:7ffc230d9280 error:0 in cmdTest[400000+1000]
该信息记录了cmdTest运行出错的基本原因(divide error)和出错位置(40053b),我们使用addr2line命令获取出错具体行号:
addr2line -e cmdTest 40053b
/home/hyb/practice/cmdTest.c:4
可以看到addr2line命令将地址(40053b)翻译成了文件名(cmdTest.c)和行号(4),确定了出错位置。
总结
本文对以上命令仅介绍其经典使用,这些命令都还有其他一些有帮助的用法,但由于篇幅有限,不在此介绍,更多使用方法可以通过man 命令名的方式去了解。
hello程序是如何变成可执行文件的
前言
hello程序几乎是我们每个人学习C语言写的第一个程序,但是它是如何从.c文本变成可以打印出”hello world“的可执行文件的呢?本文将简单介绍其过程。
Hello World
hello world程序我们再熟悉不过:
/*include head file*/
#include<stdio.h>
/*the main function*/
int main(int argc,char *argv[])
{
printf("Hello World!\n");
return 0 ;
}
编译并运行:
gcc -o helloWorld helloWorld.c
./helloWorld
Hello World!
整个过程一气呵成,但是实际上上面的过程并非像看起来那么简单。它可以大体分为4个步骤:预处理,编译,汇编,链接。接下来我们一一简单介绍这四个步骤做了什么。
预处理
预处理主要是处理源代码中以#开头的指令(#pragma 除外),例如本文hello world程序中的#include,预处理之后会将stdio.h的内容插入到预处理指令的位置。 想要只生成预处理之后的内容,可以使用下面的方式:
gcc -E -o helloWorld.i helloWorld.c #-E参数表示只进行预处理
生成的helloWorld.i即为预处理之后的内容,有兴趣的可以打开文件查看里面的内容,会发现stdio.h的位置被其实际内容所替代。预处理之后,注释内容也会被删除,宏定义会被展开。
编译
预处理之后就需要对生成的预处理文件进行词法分析,语法分析,语义分析,最终产生汇编代码文件,说白点可以简单理解为将C代码“翻译”成汇编代码。该过程是核心同时也是较复杂的一个过程。我们可以通过命令:
gcc -S -o helloWorld.s helloWorld.c #-S参数表示只到生成汇编为止
cat helloWorld.s
.file "helloWorld.c"
.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
subq $16, %rsp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
movl $.LC0, %edi
call puts
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
上面的内容即为编译之后得到的汇编代码。
汇编
汇编是将汇编代码翻译成机器可执行的指令,生成目标文件。整个过程较为简单,几乎只是按照汇编指令和机器指令进行一一翻译。我们可以用下面的命令获得汇编后的内容:
gcc -o helloWorld.o -c helloWorld.c
od helloWorld.o #查看二进制内容
0000000 042577 043114 000402 000001 000000 000000 000000 000000
0000020 000001 000076 000001 000000 000000 000000 000000 000000
0000040 000000 000000 000000 000000 001260 000000 000000 000000
0000060 000000 000000 000100 000000 000000 000100 000015 000012
0000100 044125 162611 101510 010354 076611 044374 072611 137760
(其他内容未显示)
链接
链接是以某种方式将各个目标文件整个在一起,生成最后的可执行文件。我们的hello程序中调用了printf函数,但是并不存在于helloWorld.o中,而是存在于libc.so或libc.a中。
gcc -o helloWorld helloWorld.c
执行上面的命令之后,就得到了我们的helloWorld程序了。 我们通过ldd命令看到helloWorld程序链接了系统的库:
ldd helloWorld
linux-vdso.so.1 => (0x00007ffe9ef11000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0d9f038000)
/lib64/ld-linux-x86-64.so.2 (0x00007f0d9f402000)
有兴趣的也可以尝试一下,如果删除系统中的libc.so库(记得事先备份),发现能够编译过,却在最后链接失败。
总结
我们大致总结整个编译过程如下:
st=>start: .c源程序 e=>end op1=>operation: 预处理将.c处理为.i(处理后的源程序) op2=>operation: 编译器将.i编译为.s(汇编程序) op3=>operation: 汇编器将.s汇编成.o(可重定位目标文件) op4=>operation: 链接器将可重定位文件链接成可执行文件 st->op1->op2->op3->op4->
而正是由于整个编译过程分阶段进行,我们可以看到不同类型的问题在不同阶段出现并且有先后顺序。正因如此,链接问题在编译的最后阶段才会出现。
- gcc编译系统本身调用了很多其他相关工具,可以加上—verbose观察其详细编译过程,发现gcc命令调用了预处理器,编译器,汇编器,链接器等命令。
本文只是粗略介绍其整个过程,更多地了解编译过程能够帮助我们优化代码、处理令人困扰的链接问题或避免安全漏洞,本文不展开介绍具体的编译过程。
