gdb 调试技术

 

本文简介

本文简介

调试断点

目前采用得最多的是调试代理(Debug Agent)加调试器的方式。调试代理运行在目标机上,而调试器则运行在宿主机上,它们之间采用某种协议通信,比如GDB调试器采用RSP(Remote Serial Protocol,远程串行协议)。

为了实现源码级调试,调试器必须知道高级语言中的每一条语句与汇编代码的对应关系。多数现代编译器都提供了一个编译选项,用以在编译时生成调试信息。这些调试信息包含了源代码行和汇编指令之间的对应关系。当在源代码的某一行上设置断点的时候,实际上是在对应的汇编指令上设置了一条断点指令或非法指令,同时把原来位于该位置处的指令保存到了某个地方。在触发断点之后,调试器把控制权交给用户,此时用户可以查看机器的各种状态。在用户把控制权还给调试器之后,调试器将此前保存的指令恢复,继续往下执行。

软件断点是最常用的断点技术,它通常是一种特殊类型的机器指令,在x86上是INT 3,断点指令将被插入到程序的代码段中。当处理器执行断点指令时,将触发特殊的处理程序,此时调试器将冻结被调程序的执行,将控制权交给用户,于是用户可以查看和修改被调程序当前的执行状态。

数据断点会在CPU企图访问(尤其是写)某个特定的数据时触发断点,就好像在所有访问此项数据的指令上设置了断点一样。

硬件断点常常被用做数据监视点。在Intel的StrongARM/XScale系列处理器及x86处理器家族中,都设计了专门的硬件断点寄存器。硬件断点的原理其实很容易理解。因为无论指令断点还是数据断点,都必然会涉及地址操作。那么,在将内存地址或I/O地址放到地址总线上之前,CPU就可以把地址单元中的地址和硬件断点寄存器中的地址进行比对。如果比对的结果相符,那么立即触发一次自陷或异常,之后的事情就交给软件去处理了。 硬件断点比较少,只有4个。常见的做法是指令断点采用软件断点,而硬件断点则用做数据监视点

调试技术历史

早先的时候,印制电路板(Print Circuit Board,PCB)的面积普遍比较大,电路板的层数也比较少。可以把CPU拆下,用外部仪器“仿真”CPU在整个系统中运行,这种调试方式被称为在线仿真(InCircuit Emulation),简称为ICE;由于外部仿真器已经接管了CPU的所有引脚,无论地址总线、数据总线还是控制总线,都在外部仿真器的监控范围之内。但是ICE仿真器非常昂贵,对于产品开发而言,这往往是很难接受的;其次,嵌入式CPU的速度越来越快,这就要求ICE仿真器的时钟频率越来越高,这加大了开发ICE仿真器的难度;

片上调试(On-Chip Debug,OCD)属于较新的调试技术,主要包含了BDM(BackgroundDebug Mode)和JTAG(Joint Test Action Group,边界扫描测试)两种。

BDM是由Motorola最先提出的调试技术。BDM模块直接内建在处理器内部,并能够访问处理器的内部总线,因此也就能够获得处理器内核的几乎所有信息。但BDM不是一项开放的技术,只能用在Motorola专用的处理器上。

IEEE于20世纪80年代中期(1985年)组织了一个工作组着手制定一套新的测试手段和标准。这个工作组就是欧洲联合测试行动工作组(Joint European TestAction Group,JETAG)。1986年,这一工作组吸收了来自欧洲和北美的成员,于是工作组更名为联合测试行动工作组(Joint Test Action Group,JTAG)。IEEE 1149.1—1990标准在1990年被正式批准。由于这一标准的制定是由联合测试行动工作组(JTAG)发起并制定的,因此也常常将这一标准称为JTAG标准

JTAG具有硬件和软件两方面的作用。硬件方面,JTAG最重要的作用是用于系统级的互联测试,可通过它诊断电路是否存在连接故障;而在软件方面,JTAG最重要的功能有两个:一个作用是用于下载程序映像,另一个则是用于程序调试

在调试中,开发人员通常都希望调试工具能够像ICE那样接管处理器芯片的所有信号,并且能够控制处理器与其他芯片的连接与断开。因此,需要实现这样3个功能:

  • 即当处理器工作在正常状态下时,芯片的引脚要能够正常接通,就好像调试模块并不存在一样;
  • 在需要时,不仅要能让芯片引脚正常接通,还要能监视芯片引脚上的信号;
  • 在必要的时候,要能够切断芯片引脚的连接,并且通过某种方式“伪造”芯片上的输入信号或输出信号。

JTAG是这样做的,即JTAG把芯片的每一个引脚都和一个一位的移位寄存器相连,每一个这样的移位寄存器称做边界扫描单元(Boundary-Scan Cell),并且将所有这些一位的移位寄存器都以顺序的方式连接起来,就像一条链一样,因此把这样的移位寄存器链称为扫描链。在扫描链的两端,分别接了两条串行线,在JTAG中分别是TDI和TDO

其中TDI用于在每个时钟脉冲向扫描链输入一位数据,而TDO则用于在输入的同时从扫描链的另一端输出一位数据。因为扫描链是一个串联起来的移位寄存器链,因此不断地从TDI向扫描链中移入数据,同时向TDO移出扫描链中原有的数据,直到扫描链中的所有位都被移出。与此同时,扫描链本身的内容也被TDI输入的数据替换了。由于扫描链是和芯片的引脚连在一起的,因此在改变了扫描链内容的同时,也就改变了芯片引脚上的信号。而芯片引脚上原先的信号则可以通过TDO读出。

由于在芯片的每一个引脚上都设置了这样一个移位寄存器,看起来就像是用很多个这样的移位寄存器把芯片包围起来了一样,而移位寄存器则成为了芯片的“边界”,因此把这样的测试技术称为边界扫描测试。

ARM7TDMI的JTAG调试

在正常运行状态下,ARM7TDMI由MCLK(Memory Clock)时钟信号驱动,处理器正常运行。在调试状态下,ARM7TDMI的正常运行被打断,并且和系统的其他部分隔离开来(如和系统存储器等隔离)。此时,可以通过插入特定的ARM/THUMB指令(ARM指令是32位指令集,THUMB指令是16位指令集)来读写ARM7TDMI的内部寄存器和内部存储器的内容。

在调试状态下,ARM7TDMI由内部的调试时钟DCLK(Debug Clock)驱动,而DCLK是由JTAG的TCK信号产生的。DCLK比MCLK慢很多,所以在调试状态下,插入的ARM/THUMB指令的运行速度比起正常运行时会慢一些。在完成需要的调试操作以后,可以用JTAG指令RESTART将处理器恢复到原来的正常运行状态

GDB 简介

GDB是GNU的开发工具系列中的一个拳头产品。GDB是一个功能强大的调试器,既支持多种硬件平台,也支持多种程序语言。

GDB用户可以通过输入help命令得到GDB的帮助信息。由于GDB的命令非常多,因此,GDB将所有命令根据功能特点进行了分类

GDB使用的技巧

  • 通过gdb命令进入GDB之后,输入help可以看到GDB的命令,可以help -> help breakpoints -> help break逐层找到自己需要的帮助
  • GDB中可以用tab补全命令,可以用ctrl+Dquit退出,ctrl+c是中断正在执行的被调试程序,比如,被调试程序进入了一个很耗时的循环,那么就可以用【Ctrl+C】组合键将其中断。
  • GDB的命令都有简写,比如:help -> h, print -> p
  • <cr>重复上一条命令
  • 要调试的可执行程序名为prog,可以gdb prog
    • 则GDB将在启动之后将prog程序的二进制映像载入到内存中
  • 载入转储文件
    • 假设prog执行出错,产生的转储文件名为prog.core
    • gdb prog prog.core
  • 指定GDB所启动的被调试程序的PID
    • gdb prog 1234
  • 指定调试程序的参数
    • gdb --args vim +10 fac.c
    • vim在打开factorial.c文件后将把光标移动到第10行
  • GDB以安静模式启动
    • gdb-silent
  • GDB启动之后将欲调试的程序载入
    • file factorial
    • file命令必须跟一个参数,这个参数指定了要调试的程序
    • GDB将从这个程序的可执行文件中读取符号,同时这个程序也是用户向GDB发出run命令时将要执行的程序
  • 在GDB的命令行上输入并执行shell命令
    (gdb) shell ls
    (gdb) shell ls -F /
    
  • GDB对make的特殊照顾
    • 不用输入shell命令前缀即可在GDB下执行make
      (gdb) make -j8
      
  • 打开GDB的命令历史功能
    (gdb) set history filename gdb.history
    (gdb) set history save on
    (gdb) set hisotry size 100
    
  • 手动设置要使用的编程语言
    • set language java
    • 不带参数则打印支持的语言
    • GDB之所以需要设置工作语言,是因为不同的语言具有不同的语法
    • 而这会影响到GDB的输入格式或是表达式的形式等
      • 工作语言是C/C++,那么要打印指针p所指对象的值就应该用*p的形式

GDB 的启动步骤

  1. 根据GDB的命令行选项建立命令解释器。
  2. 读取用户主目录下的init文件(如果有的话,通常文件名为.gdbinit)并顺序执行其中的命令
  3. 处理命令行选项和选项参数
  4. 读取当前目录下的init文件(如果有的话)并执行其中的命令(注意,当前目录下的init文件和用户主目录下的init文件是不同的)
  5. 读取并执行-x选项中指定的文件(-x选项的作用和init文件类似,都是让gdb在启动时执行里面的命令)
  6. 读取history文件中的命令历史
    • 如果没有设置history文件,则每次调试会话的命令历史都将被丢弃,也就是在一个新的调试会话中不能查看以前调试会话中使用的命令

在编译时加入调试信息

在GCC中,-g选项可在生成的可执行程序中加入调试信息。

调试信息保存在目标文件中,它描述的内容包括:

  • 变量的数据类型
  • 函数的原型声明
  • 程序符号表
  • 行号与指令之间的对应关系…..

-glevel的说明:

  • g1
    • GCC只产生最少的调试信息。这些调试信息包括函数及全局变量的描述信息,但不包括局部变量的描述信息,也不包括行号的映射关系
  • g2(默认)
    • 不仅包括level为1时要产生的所有调试信息,
    • 局部变量的调试信息
    • 源程序行号与生成的汇编指令之间的对应关系
  • g3
    • level1
    • level2
    • GDB可以支持宏定义扩展
  • 命令
    gcc -g -o func func.c
    gcc -g3 -o func func.c
    

采用GCC编译C程序时,可以同时指定-g-O选项。-O选项的作用是让GCC对C语言程序进行优化。通常而言,优化后的程序和本来的程序之间会有一定的出入。所以实际上是不推荐同时使用-g选项和-O选项的。在程序的开发阶段,要排除问题,应该尽可能地只使用-g选项,避免使用-O选项

在GDB 下运行程序

指定要调试的程序:

  • 直接在启动GDB的命令行上给出
    # 编译
    gcc -g -o factorial factorial.c
    # 调试
    gdb --args ./facotrial 5
    # 运行
    (gdb) run
    
  • 启动GDB之后用file命令或exec-file命令将程序的可执行映像载入内存中
    gdb
    (gdb) file factorial
    exec-file factorial
    
    • file命令除了会在内存中初始化程序的代码段、数据段、堆和栈外,还会将程序文件中的符号表信息也载入到GDB中
    • exec-file命令不会载入符号表,只会加载可执行的二进制映像
      (gdb) exec-file factorial
      (gdb) break main
      找不到main符号
      

      设置程序的运行环境

程序运行的参数

  1. 命令行上指定
      >>> gdb --args ./factorial 3
    
  2. run指令
    (gdb) file factorial
    (gdb) run 3
    
  3. set args指定
    (gdb) file factorial
    (gdb) set args 3
    (gdb) run
    
  4. 查看函数参数
    (gdb) show args
    

设置环境变量

GDB中的环境变量是从shell继承过来的,但是在使用GDB进行调试时,也可以随时改变环境变量的值,以控制程序的行为

(gdb) file func1
(gdb) set environment FAC_ARG=5
(gdb) run

# 查看环境变量
(gdb) show environment
# 删除
(gdb) unset environment FAC_ARG

设置程序工程目录

parent
  └── child
      ├── chdir # 代码是通过相对路径读read.txt
      ├── chdir.c
      └── read.txt

child/中执行是成功的

>>> gdb
(gdb) file chdir
(gdb) run

parent/目录下执行失败,找不到文件

>>> gdb 
(gdb) file child/chdir
(gdb) run

这个问题是由于程序的工作目录不正确引起的。在GDB中,可以通过cd命令设置被调程序的工作目录。对于上面这个程序chdir,如果不是从与read.txt相同的目录启动的话,应通过cd命令更改工作目录到test.txt文件所在的目录,然后再运行chdir才不会出错。

(gdb) pwd
(gdb) cd child
(gdb) run

尽管GDB能够设定被调程序运行时的工作目录,但是,如果被调试进程通过chdir()系统调用更改了工作目录的话,GDB就无法确定出进程更改后的工作目录了。一个比较迂回的做法是,如果操作系统启用了/proc文件系统,那么可以通过/proc获得执行进程的工作目录。

输入输出重定向

GDB中的输入输出重定向要在run命令上指定

(gdb) run > error.log 2>&1
  • >error.log表示把所有输出到标准输出上的信息都重定向到error.log文件中
  • 2>&1则表示将标准出错的文件描述符复制到标准输出的文件描述符上
  • 这样,无论程序的标准输出还是标准出错信息都将被送入error.log文件中

可以使用tty命令来指定GDB的标准输入和标准输出

(gdb) tty /dev/ttyS0

将GDB调试器的默认输入输出重定向到串口上

停止运行

想要停止被调程序的运行,那么应该使用GDB的kill命令,该命令将会杀死被调进程(特别注意:GDB下的kill命令用于杀死被调进程,而shell下的kill命令则用于向进程发送信号,勿将两者混淆)

kill还有一个特点就是,如果在使用kill命令“杀死”被调进程之后重新编译了程序,则在下一次通过run命令运行该程序时,GDB会自动重新读入目标文件中的符号表,同时,原来的断点信息等也将被保存,用户不需要再重新设置。

比如,在调试后,修改、编译代码后,再启动新的调试

(gdb) kill
(gdb) shell vim func1.c
(gdb) shell gcc -g -o f1 func1.c
(gdb) run # 重读符号表

断点、监视点与捕捉点

GDB中的断点分为3种类型:指令断点、监视点和捕捉点

  • 指令断点位于程序正文段
    • 当被调程序执行到断点位置上时,程序的执行会被暂停
  • 监视点是一类特殊的断点
    • 它是面向数据的断点。监视点监视的是表达式的值,当它发现表达式的值发生了改变时,它将迫使被调程序将控制权交给调试器
  • 捕捉点也是一类特殊的断点
    • 它是在某些事件(event)发生的情况下才会被触发。常见的事件如C++抛出的异常,或者程序加载了某个函数库
  • GDB给每一个断点赋予了一个断点号
    • 可以用连字符来指定要操作的断点号范围:5-7
  • 查看断点信息
    • info breakpoints输出分为6列
      • Num列指明了断点所对应的断点号
      • Type列指明了断点类型是指令断点还是监视点
      • Disp列指明了断点被触发之后应当如何处理,有3种处理方式
        • 如果是keep,则断点在此次被触发之后依然有效
        • 如果是del则在断点被触发一次之后就将被GDB自动删掉
        • 如果是dis则断点被触发一次之后就将被禁用
      • Enb列表明该断点是否处于使能状态
      • Address和What列指明了断点所在的源文件和相应的行号
    • 如果断点已经被触发过一次或多次,info breakpoints也将打印出断点被触发的次数

3.4.1 断点、监视点与捕捉点的设置

断点的设置

方法 举例 说明
b function break main 在main处打断点
b +offset/-offset b +5, b -5 当前执行的代码行之前或之后offset行处设置断
b 行号 b 50 第50行设置断点
b 文件:行号/文件:函数 b main.cpp:main
b test.c:5
在文件的函数或行号处打断点
b *地址 b *0x0804856c 在指定的内存地址address上设置断点
b break 在所选栈帧中将要被执行的下一条指令处设置断点
  • tbreak中的t表示的是temporary的意思。tbreak可以带的参数与break可以带的参数是一样的,可以是function,也可以是+offset、-offset、linenum或*address等。唯一的不同在于当tbreak设置的断点被触发一次之后,该断点就将被GDB自动删除
  • hbreak设置硬件断点,可带的参数与break是一样的,不同之处在于hbreak要求必须要有硬件的支持。硬件断点的好处在于它可以用来调试某些位于不可擦写存储器中的代码,如位于EPROM/ROM中的代码。之所以能够做到这一点,是因为硬件断点不需要改变断点处的指令
  • thbreak:用于设置临时硬件断点
  • rbreak regex:在所有与正则表达式regex匹配的函数上设置断点