Debug实现原理
by 夏泽民 Apr 14, 2020
https://tpaschalis.github.io/delve-debugging/
https://jiajunhuang.com/articles/2020_04_23-go_gdb.md.html
一般是在IDE里代码中加断点,一步步跟踪。然后观察变量的值,观察输出等等。
这种在Debug工具,许多IDE中都有提供,像Eclipse, IDEA,NetBeans,甚至我们可以直接使用JDK自带的jdb工具进行高度。这些工具都支持本地调试和远程调试。
那在我们加断点,debug,单步调试等一系列动作背后,是如何实现的呢?
不得不提JPDA(Java Platform Debugger Architecture)。我们每次使用的debug功能,都是靠JPDA的支撑实现的。
什么是JPDA?
官方文档里这样介绍:
The Java Platform Debugger Architecture (JPDA) consists of three interfaces designed for use by debuggers in development environments for desktop systems.
我们看到,JPDA由三部分组成:
JVMTI(Java Virtual Machine Tool Interface)
JDWP(Java Debugger Wire Protocol)
JDI(Java Debug Interface)
熟悉JVM的朋友可能听说过JVMPI和JVMDI,在JDK1.5他们统一被替换为JVMTI。
JVMTI
以前的文章里我们提到过Class的hotSwap,就是通过Instrument实现class的redefine和retransform。
而本质上JVMTI是一个programming interface,主要用在开发和监控上。而且它提供了接口去观察(inspect) 应用状态和控制应用的执行。工具通过它提供的接口,可以进行如下功能的实现:
profiling
debuging
monitoring
thread analysis
coverage analysis
可以看到,我们使用到的debug,只是JVMTI提供的众从能力中的一种。
JDWP
观察过Java debug进程的同学也许有印象,以debug方式启动的JVM进程,看起来是这样的:
-agentlib:jdwp=transport=dt_socket,address=127.0.0.1:63971,server=y,suspend=n
除了进程名之外,还在启动参数里包含agentlib:jdwp这些。这个就是现在要介绍的JDWP。
什么是JDWP?
Java Debug Wire Protocol,是debugger和它要debug的JVM之间进行通讯的协议。更多具体协议的细节这里不介绍,感兴趣的同学可以到这儿查看:
Command Packet
Header
length (4 bytes)
id (4 bytes)
flags (1 byte)
command set (1 byte)
command (1 byte)
data (Variable)
Reply Packet
Header
length (4 bytes)
id (4 bytes)
flags (1 byte)
error code (2 bytes)
data (Variable)
http://docs.oracle.com/javase/6/docs/technotes/guides/jpda/jdwp-spec.html
注意,这仅仅是一个协议的格式,具体的传输实现不是由JDWP来实现的。我们的debugger执行的操作发送到JDWP的实现上,然后再转给JVMTI来具体控制。
JDI
JDI是三个模块中最高层的一个接口,通过JDI,debugger可以更方便的编写符合JDWP格式的数据,用来进行调试数据传输。JDI的引入,提高了开发debugger的效率。
所以,从整体上看,我们可以把JPDA看作一个两个互相通讯的程序,所以我们可以在任意地点很方便的调试另一个JVM上运行的程序。
我们每次在IDE里进行代码调试时,实质上是通过IDE里的debugger这个界面执行GUI操作,然后通过JDI发送数据到JDWP,再经过JVMTI最终实现程序的高度。
每次我们打开IDE调试一个Java应用的时候,或者远程attach一个Java进程的时候,别忘了这个IDE背后的身影—JPDA。
PS:Tomcat启动脚本中也直接包含了debug方式启动的功能,在命令行中输入
catalina jpda start, Tomcat就以debug方式启动了。
对于想了解源码但不想把源码以项目形式运行的同学,可以采用这种方式,然后使用远程调试的方式,把源码所在项目和这个attach起来就可以了。
在【java】里,有JVM的存在,可以省好多事,分三层
1.JVMIT【虚拟机接口】,底层
2.JDWP【虚拟机传输协议,格式】,中间传输
3.JDI【程序调试接口】,发送指令控制接口
https://www.ibm.com/developerworks/cn/java/j-lo-jpda1/
Java 虚拟机工具接口(JVMTI)
JVMTI(Java Virtual Machine Tool Interface)即指 Java 虚拟机工具接口,它是一套由虚拟机直接提供的 native 接口,它处于整个 JPDA 体系的最底层,所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。我们知道,JVMTI 的前身是 JVMDI 和 JVMPI,它们原来分别被用于提供调试 Java 程序以及 Java 程序调节性能的功能。在 J2SE 5.0 之后 JDK 取代了 JVMDI 和 JVMPI 这两套接口,JVMDI 在最新的 Java SE 6 中已经不提供支持,而 JVMPI 也计划在 Java SE 7 后被彻底取代。
Java 调试线协议(JDWP)
JDWP(Java Debug Wire Protocol)是一个为 Java 调试而设计的一个通讯交互协议,它定义了调试器和被调试程序之间传递的信息的格式。在 JPDA 体系中,作为前端(front-end)的调试者(debugger)进程和后端(back-end)的被调试程序(debuggee)进程之间的交互数据的格式就是由 JDWP 来描述的,它详细完整地定义了请求命令、回应数据和错误代码,保证了前端和后端的 JVMTI 和 JDI 的通信通畅。比如在 Sun 公司提供的实现中,它提供了一个名为 jdwp.dll(jdwp.so)的动态链接库文件,这个动态库文件实现了一个 Agent,它会负责解析前端发出的请求或者命令,并将其转化为 JVMTI 调用,然后将 JVMTI 函数的返回值封装成 JDWP 数据发还给后端。
另外,这里需要注意的是 JDWP 本身并不包括传输层的实现,传输层需要独立实现,但是 JDWP 包括了和传输层交互的严格的定义,就是说,JDWP 协议虽然不规定我们是通过 EMS 还是快递运送货物的,但是它规定了我们传送的货物的摆放的方式。在 Sun 公司提供的 JDK 中,在传输层上,它提供了 socket 方式,以及在 Windows 上的 shared memory 方式。当然,传输层本身无非就是本机内进程间通信方式和远端通信方式,用户有兴趣也可以按 JDWP 的标准自己实现。
Java 调试接口(JDI)
JDI(Java Debug Interface)是三个模块中最高层的接口,在多数的 JDK 中,它是由 Java 语言实现的。 JDI 由针对前端定义的接口组成,通过它,调试工具开发人员就能通过前端虚拟机上的调试器来远程操控后端虚拟机上被调试程序的运行,JDI 不仅能帮助开发人员格式化 JDWP 数据,而且还能为 JDWP 数据传输提供队列、缓存等优化服务。从理论上说,开发人员只需使用 JDWP 和 JVMTI 即可支持跨平台的远程调试,但是直接编写 JDWP 程序费时费力,而且效率不高。因此基于 Java 的 JDI 层的引入,简化了操作,提高了开发人员开发调试程序的效率。
对于远端的调试,GDB 也没有很好的默认实现,当然,C/C++ 在这方面也没有特别大的需求。
https://www.cnblogs.com/alantu2018/p/8997173.html
调试器工作原理(1):基础篇
Linux下的调试器实现的主要组成部分——ptrace系统调用。
然后对其进行调试,或者将自己本身关联到一个已存在的进程之上。它可以单步运行代码,设置断点然后运行程序,检查变量的值以及跟踪调用栈。许多调试器已经拥有了一些高级特性,比如执行表达式并在被调试进程的地址空间中调用函数,甚至可以直接修改进程的代码并观察修改后的程序行为。
尽管现代的调试器都是复杂的大型程序,但令人惊讶的是构建调试器的基础确是如此的简单。调试器只用到了几个由操作系统以及编译器/链接器提供的基础服务,剩下的仅仅就是简单的编程问题了。
Linux下的调试——ptrace
Linux下调试器拥有一个瑞士军刀般的工具,这就是ptrace系统调用。这是一个功能众多且相当复杂的工具,能允许一个进程控制另一个进程的运行,而且可以监视和渗入到进程内部。ptrace本身需要一本中等篇幅的书才能对其进行完整的解释,这就是为什么我只打算通过例子把重点放在它的实际用途上。让我们继续深入探寻。
遍历进程的代码
我现在要写一个在“跟踪”模式下运行的进程的例子,这里我们要单步遍历这个进程的代码——由CPU所执行的机器码(汇编指令)。我会在这里给出例子代码,解释每个部分,本文结尾处你可以通过链接下载一份完整的C程序文件,可以自行编译执行并研究。从高层设计来说,我们要写一个程序,它产生一个子进程用来执行一个用户指定的命令,而父进程跟踪这个子进程。首先,main函数是这样的:
int main(int argc, char** argv)
{
pid_t child_pid;
if (argc < 2) {
fprintf(stderr, "Expected a program name as argument\n");
return -1;
}
child_pid = fork();
if (child_pid == 0)
run_target(argv[1]);
else if (child_pid > 0)
run_debugger(child_pid);
else {
perror("fork");
return -1;
}
return 0; }
代码相当简单,我们通过fork产生一个新的子进程。随后的if语句块处理子进程(这里称为“目标进程”),而else if语句块处理父进程(这里称为“调试器”)。下面是目标进程:
void run_target(const char* programname)
{
procmsg(“target started. will run ‘%s’\n”, programname);
/* Allow tracing of this process */
if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Replace this process's image with the given program */
execl(programname, programname, 0); } 这部分最有意思的地方在ptrace调用。ptrace的原型是(在sys/ptrace.h):
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
第一个参数是request,可以是预定义的以PTRACE_打头的常量值。第二个参数指定了进程id,第三以及第四个参数是地址和指向数据的指针,用来对内存做操作。上面代码段中的ptrace调用使用了PTRACE_TRACEME请求,这表示这个子进程要求操作系统内核允许它的父进程对其跟踪。这个请求在man手册中解释的非常清楚:
“表明这个进程由它的父进程来跟踪。任何发给这个进程的信号(除了SIGKILL)将导致该进程停止运行,而它的父进程会通过wait()获得通知。另外,该进程之后所有对exec()的调用都将使操作系统产生一个SIGTRAP信号发送给它,这让父进程有机会在新程序开始执行之前获得对子进程的控制权。如果不希望由父进程来跟踪的话,那就不应该使用这个请求。(pid、addr、data被忽略)”
我已经把这个例子中我们感兴趣的地方高亮显示了。注意,run_target在ptrace调用之后紧接着做的是通过execl来调用我们指定的程序。这里就会像我们高亮显示的部分所解释的那样,操作系统内核会在子进程开始执行execl中指定的程序之前停止该进程,并发送一个信号给父进程。
因此,是时候看看父进程需要做些什么了:
void run_debugger(pid_t child_pid)
{
int wait_status;
unsigned icounter = 0;
procmsg(“debugger started\n”);
/* Wait for child to stop on its first instruction */
wait(&wait_status);
while (WIFSTOPPED(wait_status)) {
icounter++;
/* Make the child execute another instruction */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Wait for child to stop on its next instruction */
wait(&wait_status);
}
procmsg("the child executed %u instructions\n", icounter); } 通过上面的代码我们可以回顾一下,一旦子进程开始执行exec调用,它就会停止然后接收到一个SIGTRAP信号。父进程通过第一个wait调用正在等待这个事件发生。一旦子进程停止(如果子进程由于发送的信号而停止运行,WIFSTOPPED就返回true),父进程就去检查这个事件。
父进程接下来要做的是本文中最有意思的地方。父进程通过PTRACE_SINGLESTEP以及子进程的id号来调用ptrace。这么做是告诉操作系统——请重新启动子进程,但当子进程执行了下一条指令后再将其停止。然后父进程再次等待子进程的停止,整个循环继续得以执行。当从wait中得到的不是关于子进程停止的信号时,循环结束。在正常运行这个跟踪程序时,会得到子进程正常退出(WIFEXITED会返回true)的信号。
icounter会统计子进程执行的指令数量。因此我们这个简单的例子实际上还是做了点有用的事情——通过在命令行上指定一个程序名,我们的例子会执行这个指定的程序,然后统计出从开始到结束该程序执行过的CPU指令总数。让我们看看实际运行的情况。
实际测试
我编译了下面这个简单的程序,然后在我们的跟踪程序下执行:
#include
int main()
{
printf(“Hello, world!\n”);
return 0;
}
令我惊讶的是,我们的跟踪程序运行了很长的时间然后报告显示一共有超过100000条指令得到了执行。仅仅只是一个简单的printf调用,为什么会这样?答案非常有意思。默认情况下,Linux中的gcc编译器会动态链接到C运行时库。这意味着任何程序在运行时首先要做的事情是加载动态库。这需要很多代码实现——记住,我们这个简单的跟踪程序会针对每一条被执行的指令计数,不仅仅是main函数,而是整个进程。
因此,当我采用-static标志静态链接这个测试程序时(注意到可执行文件因此增加了500KB的大小,因为它静态链接了C运行时库),我们的跟踪程序报告显示只有7000条左右的指令被执行了。这还是非常多,但如果你了解到libc的初始化工作仍然先于main的执行,而清理工作会在main之后执行,那么这就完全说得通了。而且,printf也是一个复杂的函数。
我们还是不满足于此,我希望能看到一些可检测的东西,例如我可以从整体上看到每一条需要被执行的指令是什么。这一点我们可以通过汇编代码来得到。因此我把这个“Hello,world”程序汇编(gcc -S)为如下的汇编码:
section .text
; The _start symbol must be declared for the linker (ld)
global _start
_start:
; Prepare arguments for the sys_write system call:
; - eax: system call number (sys_write)
; - ebx: file descriptor (stdout)
; - ecx: pointer to string
; - edx: string length
mov edx, len
mov ecx, msg
mov ebx, 1
mov eax, 4
; Execute the sys_write system call
int 0x80
; Execute sys_exit
mov eax, 1
int 0x80
section .data
msg db ‘Hello, world!’, 0xa
len equ $ - msg
这就足够了。现在跟踪程序会报告有7条指令得到了执行,我可以很容易地从汇编代码来验证这一点。
深入指令流
汇编码程序得以让我为大家介绍ptrace的另一个强大的功能——详细检查被跟踪进程的状态。下面是run_debugger函数的另一个版本:
void run_debugger(pid_t child_pid)
{
int wait_status;
unsigned icounter = 0;
procmsg(“debugger started\n”);
/* Wait for child to stop on its first instruction */
wait(&wait_status);
while (WIFSTOPPED(wait_status)) {
icounter++;
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);
procmsg("icounter = %u. EIP = 0x%08x. instr = 0x%08x\n",
icounter, regs.eip, instr);
/* Make the child execute another instruction */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
perror("ptrace");
return;
}
/* Wait for child to stop on its next instruction */
wait(&wait_status);
}
procmsg("the child executed %u instructions\n", icounter); } 同前个版本相比,唯一的不同之处在于while循环的开始几行。这里有两个新的ptrace调用。第一个读取进程的寄存器值到一个结构体中。结构体user_regs_struct定义在sys/user.h中。这儿有个有趣的地方——如果你打开这个头文件看看,靠近文件顶端的地方有一条这样的注释:
/* 本文件的唯一目的是为GDB,且只为GDB所用。对于这个文件,不要看的太多。除了GDB以外不要用于任何其他目的,除非你知道你正在做什么。*/
现在,我不知道你是怎么想的,但我感觉我们正处于正确的跑道上。无论如何,回到我们的例子上来。一旦我们将所有的寄存器值获取到regs中,我们就可以通过PTRACE_PEEKTEXT标志以及将regs.eip(x86架构上的扩展指令指针)做参数传入ptrace来调用。我们所得到的就是指令。让我们在汇编代码上运行这个新版的跟踪程序。
$ simple_tracer traced_helloworld
[5700] debugger started
[5701] target started. will run ‘traced_helloworld’
[5700] icounter = 1. EIP = 0x08048080. instr = 0x00000eba
[5700] icounter = 2. EIP = 0x08048085. instr = 0x0490a0b9
[5700] icounter = 3. EIP = 0x0804808a. instr = 0x000001bb
[5700] icounter = 4. EIP = 0x0804808f. instr = 0x000004b8
[5700] icounter = 5. EIP = 0x08048094. instr = 0x01b880cd
Hello, world!
[5700] icounter = 6. EIP = 0x08048096. instr = 0x000001b8
[5700] icounter = 7. EIP = 0x0804809b. instr = 0x000080cd
[5700] the child executed 7 instructions
OK,所以现在除了icounter以外,我们还能看到指令指针以及每一步的指令。如何验证这是否正确呢?可以通过在可执行文件上执行objdump –d来实现:
$ objdump -d traced_helloworld
traced_helloworld: file format elf32-i386
Disassembly of section .text:
08048080 <.text>:
8048080: ba 0e 00 00 00 mov $0xe,%edx
8048085: b9 a0 90 04 08 mov $0x80490a0,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094: cd 80 int $0x80
8048096: b8 01 00 00 00 mov $0x1,%eax
804809b: cd 80 int $0x80
用这份输出对比我们的跟踪程序输出,应该很容易观察到相同的地方。
关联到运行中的进程上
你已经知道了调试器也可以关联到已经处于运行状态的进程上。看到这里,你应该不会感到惊讶,这也是通过ptrace来实现的。这需要通过PTRACE_ATTACH请求。这里我不会给出一段样例代码,因为通过我们已经看到的代码,这应该很容易实现。基于教学的目的,这里采用的方法更为便捷(因为我们可以在子进程刚启动时立刻将它停止)。
代码
本文给出的这个简单的跟踪程序的完整代码(更高级一点,可以将具体指令打印出来)可以在这里找到。程序通过-Wall –pedantic –std=c99编译选项在4.4版的gcc上编译。
结论及下一步要做的
诚然,本文并没有涵盖太多的内容——我们离一个真正可用的调试器还差的很远。但是,我希望这篇文章至少已经揭开了调试过程的神秘面纱。ptrace是一个拥有许多功能的系统调用,目前我们只展示了其中少数几种功能。
能够单步执行代码是很有用处的,但作用有限。以“Hello, world”为例,要到达main函数,需要先遍历好几千条初始化C运行时库的指令。这就不太方便了。我们所希望的理想方案是可以在main函数入口处设置一个断点,从断点处开始单步执行。下一篇文章中我将向您展示该如何实现断点机制。
http://www.linuxjournal.com/article/6100?page=0,1
https://linuxgazette.net/81/sandeep.html
http://www.alexonlinux.com/how-debugger-works
调试器工作原理(2):实现断点
本文是关于调试器工作原理探究系列的第二篇。在开始阅读本文前,请先确保你已经读过本系列的第一篇(基础篇)。
本文的主要内容
这里我将说明调试器中的断点机制是如何实现的。断点机制是调试器的两大主要支柱之一 ——另一个是在被调试进程的内存空间中查看变量的值。我们已经在第一篇文章中稍微涉及到了一些监视被调试进程的知识,但断点机制仍然还是个迷。阅读完本文之后,这将不再是什么秘密了。
软中断
要在x86体系结构上实现断点我们要用到软中断(也称为“陷阱”trap)。在我们深入细节之前,我想先大致解释一下中断和陷阱的概念。
CPU有一个单独的执行序列,会一条指令一条指令的顺序执行。要处理类似IO或者硬件时钟这样的异步事件时CPU就要用到中断。硬件中断通常是一个专门的电信号,连接到一个特殊的“响应电路”上。这个电路会感知中断的到来,然后会使CPU停止当前的执行流,保存当前的状态,然后跳转到一个预定义的地址处去执行,这个地址上会有一个中断处理例程。当中断处理例程完成它的工作后,CPU就从之前停止的地方恢复执行。
软中断的原理类似,但实际上有一点不同。CPU支持特殊的指令允许通过软件来模拟一个中断。当执行到这个指令时,CPU将其当做一个中断——停止当前正常的执行流,保存状态然后跳转到一个处理例程中执行。这种“陷阱”让许多现代的操作系统得以有效完成很多复杂任务(任务调度、虚拟内存、内存保护、调试等)。
一些编程错误(比如除0操作)也被CPU当做一个“陷阱”,通常被认为是“异常”。这里软中断同硬件中断之间的界限就变得模糊了,因为这里很难说这种异常到底是硬件中断还是软中断引起的。我有些偏离主题了,让我们回到关于断点的讨论上来。
关于int 3指令
看过前一节后,现在我可以简单地说断点就是通过CPU的特殊指令——int 3来实现的。int就是x86体系结构中的“陷阱指令”——对预定义的中断处理例程的调用。x86支持int指令带有一个8位的操作数,用来指定所发生的中断号。因此,理论上可以支持256种“陷阱”。前32个由CPU自己保留,这里第3号就是我们感兴趣的——称为“trap to debugger”。
不多说了,我这里就引用“圣经”中的原话吧(这里的圣经就是Intel’s Architecture software developer’s manual, volume2A):
“INT 3指令产生一个特殊的单字节操作码(CC),这是用来调用调试异常处理例程的。(这个单字节形式非常有价值,因为这样可以通过一个断点来替换掉任何指令的第一个字节,包括其它的单字节指令也是一样,而不会覆盖到其它的操作码)。”
上面这段话非常重要,但现在解释它还是太早,我们稍后再来看。
使用int 3指令
是的,懂得事物背后的原理是很棒的,但是这到底意味着什么?我们该如何使用int 3来实现断点机制?套用常见的编程问答中出现的对话——请用代码说话!
实际上这真的非常简单。一旦你的进程执行到int 3指令时,操作系统就将它暂停。在Linux上(本文关注的是Linux平台),这会给该进程发送一个SIGTRAP信号。
这就是全部——真的!现在回顾一下本系列文章的第一篇,跟踪(调试器)进程可以获得所有其子进程(或者被关联到的进程)所得到信号的通知,现在你知道我们该做什么了吧?
就是这样,再没有什么计算机体系结构方面的东东了,该写代码了。
手动设定断点
现在我要展示如何在程序中设定断点。用于这个示例的目标程序如下:
section .text
; The _start symbol must be declared for the linker (ld)
global _start
_start:
; Prepare arguments for the sys_write system call:
; - eax: system call number (sys_write)
; - ebx: file descriptor (stdout)
; - ecx: pointer to string
; - edx: string length
mov edx, len1
mov ecx, msg1
mov ebx, 1
mov eax, 4
; Execute the sys_write system call
int 0x80
; Now print the other message
mov edx, len2
mov ecx, msg2
mov ebx, 1
mov eax, 4
int 0x80
; Execute sys_exit
mov eax, 1
int 0x80
section .data
msg1 db ‘Hello,’, 0xa
len1 equ $ - msg1
msg2 db ‘world!’, 0xa
len2 equ $ - msg2
我现在使用的是汇编语言,这是为了避免当使用C语言时涉及到的编译和符号的问题。上面列出的程序功能就是在一行中打印“Hello,”,然后在下一行中打印“world!”。这个例子与上一篇文章中用到的例子很相似。
我希望设定的断点位置应该在第一条打印之后,但恰好在第二条打印之前。我们就让断点打在第一个int 0x80指令之后吧,也就是mov edx, len2。首先,我需要知道这条指令对应的地址是什么。运行objdump –d:
traced_printer2: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000033 08048080 08048080 00000080 24
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 0000000e 080490b4 080490b4 000000b4 2 2
CONTENTS, ALLOC, LOAD, DATA
Disassembly of section .text:
08048080 <.text>:
8048080: ba 07 00 00 00 mov $0x7,%edx
8048085: b9 b4 90 04 08 mov $0x80490b4,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094: cd 80 int $0x80
8048096: ba 07 00 00 00 mov $0x7,%edx
804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx
80480a0: bb 01 00 00 00 mov $0x1,%ebx
80480a5: b8 04 00 00 00 mov $0x4,%eax
80480aa: cd 80 int $0x80
80480ac: b8 01 00 00 00 mov $0x1,%eax
80480b1: cd 80 int $0x80
通过上面的输出,我们知道要设定的断点地址是0x8048096。等等,真正的调试器不是像这样工作的,对吧?真正的调试器可以根据代码行数或者函数名称来设定断点,而不是基于什么内存地址吧?非常正确。但是我们离那个标准还差的远——如果要像真正的调试器那样设定断点,我们还需要涵盖符号表以及调试信息方面的知识,这需要用另一篇文章来说明。至于现在,我们还必须得通过内存地址来设定断点。
看到这里我真的很想再扯一点题外话,所以你有两个选择。如果你真的对于为什么地址是0x8048096,以及这代表什么意思非常感兴趣的话,接着看下一节。如果你对此毫无兴趣,只是想看看怎么设定断点,可以略过这一部分。
题外话——进程地址空间以及入口点
坦白的说,0x8048096本身并没有太大意义,这只不过是相对可执行镜像的代码段(text section)开始处的一个偏移量。如果你仔细看看前面objdump出来的结果,你会发现代码段的起始位置是0x08048080。这告诉了操作系统要将代码段映射到进程虚拟地址空间的这个位置上。在Linux上,这些地址可以是绝对地址(比如,有的可执行镜像加载到内存中时是不可重定位的),因为在虚拟内存系统中,每个进程都有自己独立的内存空间,并把整个32位的地址空间都看做是属于自己的(称为线性地址)。
如果我们通过readelf工具来检查可执行文件的ELF头,我们将得到如下输出:
$ readelf -h traced_printer2
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2’s complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Intel 80386
Version: 0x1
Entry point address: 0x8048080
Start of program headers: 52 (bytes into file)
Start of section headers: 220 (bytes into file)
Flags: 0x0
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 2
Size of section headers: 40 (bytes)
Number of section headers: 4
Section header string table index: 3
注意,ELF头的“entry point address”同样指向的是0x8048080。因此,如果我们把ELF文件中的这个部分解释给操作系统的话,就表示:
将代码段映射到地址0x8048080处
从入口点处开始执行——地址0x8048080
但是,为什么是0x8048080呢?它的出现是由于历史原因引起的。每个进程的地址空间的前128MB被保留给栈空间了(注:这一部分原因可参考Linkers and Loaders)。128MB刚好是0x80000000,可执行镜像中的其他段可以从这里开始。0x8048080是Linux下的链接器ld所使用的默认入口点。这个入口点可以通过传递参数-Ttext给ld来进行修改。
因此,得到的结论是这个地址并没有什么特别的,我们可以自由地修改它。只要ELF可执行文件的结构正确且在ELF头中的入口点地址同程序代码段(text section)的实际起始地址相吻合就OK了。
通过int 3指令在调试器中设定断点
要在被调试进程中的某个目标地址上设定一个断点,调试器需要做下面两件事情:
保存目标地址上的数据
将目标地址上的第一个字节替换为int 3指令
然后,当调试器向操作系统请求开始运行进程时(通过前一篇文章中提到的PTRACE_CONT),进程最终一定会碰到int 3指令。此时进程停止,操作系统将发送一个信号。这时就是调试器再次出马的时候了,接收到一个其子进程(或被跟踪进程)停止的信号,然后调试器要做下面几件事:
在目标地址上用原来的指令替换掉int 3
将被跟踪进程中的指令指针向后递减1。这么做是必须的,因为现在指令指针指向的是已经执行过的int 3之后的下一条指令。
由于进程此时仍然是停止的,用户可以同被调试进程进行某种形式的交互。这里调试器可以让你查看变量的值,检查调用栈等等。
当用户希望进程继续运行时,调试器负责将断点再次加到目标地址上(由于在第一步中断点已经被移除了),除非用户希望取消断点。
让我们看看这些步骤如何转化为实际的代码。我们将沿用第一篇文章中展示过的调试器“模版”(fork一个子进程,然后对其跟踪)。无论如何,本文结尾处会给出完整源码的链接。
/* Obtain and show child’s instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg(“Child started. EIP = 0x%08x\n”, regs.eip);
/* Look at the word at the address we’re interested in /
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void )addr, 0);
procmsg(“Original data at 0x%08x: 0x%08x\n”, addr, data);
这里调试器从被跟踪进程中获取到指令指针,然后检查当前位于地址0x8048096处的字长内容。运行本文前面列出的汇编码程序,将打印出:
[13028] Child started. EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba
目前为止一切顺利,下一步:
/* Write the trap instruction ‘int 3’ into the address /
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void )addr, (void*)data_with_trap);
/* See what’s there again… /
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void )addr, 0);
procmsg(“After trap, data at 0x%08x: 0x%08x\n”, addr, readback_data);
注意看我们是如何将int 3指令插入到目标地址上的。这部分代码将打印出:
[13028] After trap, data at 0x08048096: 0x000007cc
再一次如同预计的那样——0xba被0xcc取代了。调试器现在运行子进程然后等待子进程在断点处停止住。
/* Let the child run to the breakpoint and wait for it to
** reach it
*/
ptrace(PTRACE_CONT, child_pid, 0, 0);
wait(&wait_status);
if (WIFSTOPPED(wait_status)) {
procmsg(“Child got a signal: %s\n”, strsignal(WSTOPSIG(wait_status)));
}
else {
perror(“wait”);
return;
}
/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg(“Child stopped at EIP = 0x%08x\n”, regs.eip);
这段代码打印出:
Hello,
[13028] Child got a signal: Trace/breakpoint trap
[13028] Child stopped at EIP = 0x08048097
注意,“Hello,”在断点之前打印出来了——同我们计划的一样。同时我们发现子进程已经停止运行了——就在这个单字节的陷阱指令执行之后。
/* Remove the breakpoint by restoring the previous data
** at the target address, and unwind the EIP back by 1 to
** let the CPU execute the original instruction that was
** there.
/
ptrace(PTRACE_POKETEXT, child_pid, (void )addr, (void*)data);
regs.eip -= 1;
ptrace(PTRACE_SETREGS, child_pid, 0, ®s);
/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);
这会使子进程打印出“world!”然后退出,同之前计划的一样。
注意,我们这里并没有重新加载断点。这可以在单步模式下执行,然后将陷阱指令加回去,再做PTRACE_CONT就可以了。本文稍后介绍的debug库实现了这个功能。
更多关于int 3指令
现在是回过头来说说int 3指令的好机会,以及解释一下Intel手册中对这条指令的奇怪说明。
“这个单字节形式非常有价值,因为这样可以通过一个断点来替换掉任何指令的第一个字节,包括其它的单字节指令也是一样,而不会覆盖到其它的操作码。”
x86架构上的int指令占用2个字节——0xcd加上中断号。int 3的二进制形式可以被编码为cd 03,但这里有一个特殊的单字节指令0xcc以同样的作用而被保留。为什么要这样做呢?因为这允许我们在插入一个断点时覆盖到的指令不会多于一条。这很重要,考虑下面的示例代码:
.. some code ..
jz foo
dec eax
foo:
call bar
.. some code ..
假设我们要在dec eax上设定断点。这恰好是条单字节指令(操作码是0x48)。如果替换为断点的指令长度超过1字节,我们就被迫改写了接下来的下一条指令(call),这可能会产生一些完全非法的行为。考虑一下条件分支jz foo,这时进程可能不会在dec eax处停止下来(我们在此设定的断点,改写了原来的指令),而是直接执行了后面的非法指令。
通过对int 3指令采用一个特殊的单字节编码就能解决这个问题。因为x86架构上指令最短的长度就是1字节,这样我们可以保证只有我们希望停止的那条指令被修改。
封装细节
前面几节中的示例代码展示了许多底层的细节,这些可以很容易地通过API进行封装。我已经做了一些封装,使其成为一个小型的调试库——debuglib。代码在本文末尾处可以下载。这里我只想介绍下它的用法,我们要开始调试C程序了。
跟踪C程序
目前为止为了简单起见我把重点放在对汇编程序的跟踪上了。现在升一级来看看我们该如何跟踪一个C程序。
其实事情并没有很大的不同——只是现在有点难以找到放置断点的位置。考虑如下这个简单的C程序:
#include
void do_stuff()
{
printf(“Hello, “);
}
int main()
{
for (int i = 0; i < 4; ++i)
do_stuff();
printf(“world!\n”);
return 0;
}
假设我想在do_stuff的入口处设置一个断点。我将请出我们的老朋友objdump来反汇编可执行文件,但得到的输出太多。其实,查看text段不太管用,因为这里面包含了大量的初始化C运行时库的代码,我目前对此并不感兴趣。所以,我们只需要在dump出来的结果里看do_stuff部分就好了。
080483e4 :
80483e4: 55 push %ebp
80483e5: 89 e5 mov %esp,%ebp
80483e7: 83 ec 18 sub $0x18,%esp
80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp)
80483f1: e8 22 ff ff ff call 8048318 <puts@plt>
80483f6: c9 leave
80483f7: c3 ret
好的,所以我们应该把断点设定在0x080483e4上,这是do_stuff的第一条指令。另外,由于这个函数是在循环体中调用的,我们希望在循环全部结束前保留断点,让程序可以在每一轮循环中都在断点处停下。我将使用debuglib来简化代码编写。这里是完整的调试器函数:
void run_debugger(pid_t child_pid)
{
procmsg(“debugger started\n”);
/* Wait for child to stop on its first instruction */
wait(0);
procmsg("child now at EIP = 0x%08x\n", get_child_eip(child_pid));
/* Create breakpoint and run to it*/
debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
procmsg("breakpoint created\n");
ptrace(PTRACE_CONT, child_pid, 0, 0);
wait(0);
/* Loop as long as the child didn't exit */
while (1) {
/* The child is stopped at a breakpoint here. Resume its
** execution until it either exits or hits the
** breakpoint again.
*/
procmsg("child stopped at breakpoint. EIP = 0x%08X\n", get_child_eip(child_pid));
procmsg("resuming\n");
int rc = resume_from_breakpoint(child_pid, bp);
if (rc == 0) {
procmsg("child exited\n");
break;
}
else if (rc == 1) {
continue;
}
else {
procmsg("unexpected: %d\n", rc);
break;
}
}
cleanup_breakpoint(bp); }
我们不用手动修改EIP指针以及目标进程的内存空间,我们只需要通过create_breakpoint, resume_from_breakpoint以及cleanup_breakpoint来操作就可以了。我们来看看当跟踪这个简单的C程序后的打印输出:
$ bp_use_lib traced_c_loop
[13363] debugger started
[13364] target started. will run ‘traced_c_loop’
[13363] child now at EIP = 0x00a37850
[13363] breakpoint created
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
world!
[13363] child exited
跟预计的情况一模一样!
代码
这里是完整的源码。在文件夹中你会发现:
debuglib.h以及debuglib.c——封装了调试器的一些内部工作。
bp_manual.c —— 本文一开始介绍的“手动”式设定断点。用到了debuglib库中的一些样板代码。
bp_use_lib.c—— 大部分代码用到了debuglib,这就是本文中用于说明跟踪一个C程序中的循环的示例代码。
结论及下一步要做的
我们已经涵盖了如何在调试器中实现断点机制。尽管实现细节根据操作系统的不同而有所区别,但只要你使用的是x86架构的处理器,那么一切变化都基于相同的主题——在我们希望停止的指令上将其替换为int 3。
我敢肯定,有些读者就像我一样,对于通过指定原始地址来设定断点的做法不会感到很激动。我们更希望说“在do_stuff上停住”,甚至是“在do_stuff的这一行上停住”,然后调试器就能照办。在下一篇文章中,我将向您展示这是如何做到的。
调试器工作原理(3):调试信息
本文是调试器工作原理探究系列的第三篇,在阅读前请先确保已经读过本系列的第一和第二篇。
本篇主要内容
在本文中我将向大家解释关于调试器是如何在机器码中寻找C函数以及变量的,以及调试器使用了何种数据能够在C源代码的行号和机器码中来回映射。
调试信息
现代的编译器在转换高级语言程序代码上做得十分出色,能够将源代码中漂亮的缩进、嵌套的控制结构以及任意类型的变量全都转化为一长串的比特流——这就是机器码。这么做的唯一目的就是希望程序能在目标CPU上尽可能快的运行。大多数的C代码都被转化为一些机器码指令。变量散落在各处——在栈空间里、在寄存器里,甚至完全被编译器优化掉。结构体和对象甚至在生成的目标代码中根本不存在——它们只不过是对内存缓冲区中偏移量的抽象化表示。
那么当你在某些函数的入口处设置断点时,调试器如何知道该在哪里停止目标进程的运行呢?当你希望查看一个变量的值时,调试器又是如何找到它并展示给你呢?答案就是——调试信息。
调试信息是在编译器生成机器码的时候一起产生的。它代表着可执行程序和源代码之间的关系。这个信息以预定义的格式进行编码,并同机器码一起存储。许多年以来,针对不同的平台和可执行文件,人们发明了许多这样的编码格式。由于本文的主要目的不是介绍这些格式的历史渊源,而是为您展示它们的工作原理,所以我们只介绍一种最重要的格式,这就是DWARF。作为Linux以及其他类Unix平台上的ELF可执行文件的调试信息格式,如今的DWARF可以说是无处不在。
ELF文件中的DWARF格式
根据维基百科上的词条解释,DWARF是同ELF可执行文件格式一同设计出来的,尽管在理论上DWARF也能够嵌入到其它的对象文件格式中。
DWARF是一种复杂的格式,在多种体系结构和操作系统上经过多年的探索之后,人们才在之前的格式基础上创建了DWARF。它肯定是很复杂的,因为它解决了一个非常棘手的问题——为任意类型的高级语言和调试器之间提供调试信息,支持任意一种平台和应用程序二进制接口(ABI)。要完全解释清楚这个主题,本文就显得太微不足道了。说实话,我也不理解其中的所有角落。本文我将采取更加实践的方法,只介绍足量的DWARF相关知识,能够阐明实际工作中调试信息是如何发挥其作用的就可以了。
ELF文件中的调试段
首先,让我们看看DWARF格式信息处在ELF文件中的什么位置上。ELF可以为每个目标文件定义任意多个段(section)。而Section header表中则定义了实际存在有哪些段,以及它们的名称。不同的工具以各自特殊的方式来处理这些不同的段,比如链接器只寻找它关注的段信息,而调试器则只关注其他的段。
我们通过下面的C代码构建一个名为traceprog2的可执行文件来做下实验。
#include
void do_stuff(int my_arg)
{
int my_local = my_arg + 2;
int i;
for (i = 0; i < my_local; ++i)
printf("i = %d\n", i); }
int main()
{
do_stuff(2);
return 0;
}
通过objdump –h导出ELF可执行文件中的段头信息,我们注意到其中有几个段的名字是以.debug_打头的,这些就是DWARF格式的调试段:
26 .debug_aranges 00000020 00000000 00000000 00001037
CONTENTS, READONLY, DEBUGGING
27 .debug_pubnames 00000028 00000000 00000000 00001057
CONTENTS, READONLY, DEBUGGING
28 .debug_info 000000cc 00000000 00000000 0000107f
CONTENTS, READONLY, DEBUGGING
29 .debug_abbrev 0000008a 00000000 00000000 0000114b
CONTENTS, READONLY, DEBUGGING
30 .debug_line 0000006b 00000000 00000000 000011d5
CONTENTS, READONLY, DEBUGGING
31 .debug_frame 00000044 00000000 00000000 00001240
CONTENTS, READONLY, DEBUGGING
32 .debug_str 000000ae 00000000 00000000 00001284
CONTENTS, READONLY, DEBUGGING
33 .debug_loc 00000058 00000000 00000000 00001332
CONTENTS, READONLY, DEBUGGING
每行的第一个数字表示每个段的大小,而最后一个数字表示距离ELF文件开始处的偏移量。调试器就是利用这个信息来从可执行文件中读取相关的段信息。现在,让我们通过一些实际的例子来看看如何在DWARF中找寻有用的调试信息。
定位函数
当我们在调试程序时,一个最为基本的操作就是在某些函数中设置断点,期望调试器能在函数入口处将程序断下。要完成这个功能,调试器必须具有某种能够从源代码中的函数名称到机器码中该函数的起始指令间相映射的能力。
这个信息可以通过从DWARF中的.debug_info段获取到。在我们继续之前,先说点背景知识。DWARF的基本描述实体被称为调试信息表项(Debugging Information Entry —— DIE),每个DIE有一个标签——包含它的类型,以及一组属性。各个DIE之间通过兄弟和孩子结点互相链接,属性值可以指向其他的DIE。
我们运行
objdump –dwarf=info traceprog2
得到的输出非常长,对于这个例子,我们只用关注这几行就可以了:
<1><71>: Abbrev Number: 5 (DW_TAG_subprogram)
<72> DW_AT_external : 1
<73> DW_AT_name : (…): do_stuff
<77> DW_AT_decl_file : 1
<78> DW_AT_decl_line : 4
<79> DW_AT_prototyped : 1
<7a> DW_AT_low_pc : 0x8048604
<7e> DW_AT_high_pc : 0x804863e
<82> DW_AT_frame_base : 0x0 (location list)
<86> DW_AT_sibling : <0xb3>
<1>: Abbrev Number: 9 (DW_TAG_subprogram)
DW_AT_external : 1
DW_AT_name : (...): main
DW_AT_decl_file : 1
DW_AT_decl_line : 14
DW_AT_type : <0x4b>
DW_AT_low_pc : 0x804863e
DW_AT_high_pc : 0x804865a
DW_AT_frame_base : 0x2c (location list)
这里有两个被标记为DW_TAG_subprogram的DIE,从DWARF的角度看这就是函数。注意,这里do_stuff和main都各有一个表项。这里有许多有趣的属性,但我们感兴趣的是DW_AT_low_pc。这就是函数起始处的程序计数器的值(x86下的EIP)。注意,对于do_stuff来说,这个值是0x8048604。现在让我们看看,通过objdump –d做反汇编后这个地址是什么:
08048604 :
8048604: 55 push ebp
8048605: 89 e5 mov ebp,esp
8048607: 83 ec 28 sub esp,0x28
804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
804860d: 83 c0 02 add eax,0x2
8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
8048613: c7 45 (...) mov DWORD PTR [ebp-0x10],0x0
804861a: eb 18 jmp 8048634 <do_stuff+0x30>
804861c: b8 20 (...) mov eax,0x8048720
8048621: 8b 55 f0 mov edx,DWORD PTR [ebp-0x10]
8048624: 89 54 24 04 mov DWORD PTR [esp+0x4],edx
8048628: 89 04 24 mov DWORD PTR [esp],eax
804862b: e8 04 (...) call 8048534 <printf@plt>
8048630: 83 45 f0 01 add DWORD PTR [ebp-0x10],0x1
8048634: 8b 45 f0 mov eax,DWORD PTR [ebp-0x10]
8048637: 3b 45 f4 cmp eax,DWORD PTR [ebp-0xc]
804863a: 7c e0 jl 804861c <do_stuff+0x18>
804863c: c9 leave
804863d: c3 ret
没错,从反汇编结果来看0x8048604确实就是函数do_stuff的起始地址。因此,这里调试器就同函数和它们在可执行文件中的位置确立了映射关系。
定位变量
假设我们确实在do_stuff中的断点处停了下来。我们希望调试器能够告诉我们my_local变量的值,调试器怎么知道去哪里找到相关的信息呢?这可比定位函数要难多了,因为变量可以在全局数据区,可以在栈上,甚至是在寄存器中。另外,具有相同名称的变量在不同的词法作用域中可能有不同的值。调试信息必须能够反映出所有这些变化,而DWARF确实能做到这些。
我不会涵盖所有的可能情况,作为例子,我将只展示调试器如何在do_stuff函数中定位到变量my_local。我们从.debug_info段开始,再次看看do_stuff这一项,这一次我们也看看其他的子项:
<1><71>: Abbrev Number: 5 (DW_TAG_subprogram)
<72> DW_AT_external : 1
<73> DW_AT_name : (...): do_stuff
<77> DW_AT_decl_file : 1
<78> DW_AT_decl_line : 4
<79> DW_AT_prototyped : 1
<7a> DW_AT_low_pc : 0x8048604
<7e> DW_AT_high_pc : 0x804863e
<82> DW_AT_frame_base : 0x0 (location list)
<86> DW_AT_sibling : <0xb3>
<2><8a>: Abbrev Number: 6 (DW_TAG_formal_parameter)
<8b> DW_AT_name : (...): my_arg
<8f> DW_AT_decl_file : 1
<90> DW_AT_decl_line : 4
<91> DW_AT_type : <0x4b>
<95> DW_AT_location : (...) (DW_OP_fbreg: 0)
<2><98>: Abbrev Number: 7 (DW_TAG_variable)
<99> DW_AT_name : (...): my_local
<9d> DW_AT_decl_file : 1
<9e> DW_AT_decl_line : 6
<9f> DW_AT_type : <0x4b>
DW_AT_location : (...) (DW_OP_fbreg: -20)
<2>: Abbrev Number: 8 (DW_TAG_variable)
DW_AT_name : i
DW_AT_decl_file : 1
DW_AT_decl_line : 7
DW_AT_type : <0x4b>
DW_AT_location : (...) (DW_OP_fbreg: -24)
注意每一个表项中第一个尖括号里的数字,这表示嵌套层次——在这个例子中带有<2>的表项都是表项<1>的子项。因此我们知道变量my_local(以DW_TAG_variable作为标签)是函数do_stuff的一个子项。调试器同样还对变量的类型感兴趣,这样才能正确的显示变量的值。这里my_local的类型根据DW_AT_type标签可知为<0x4b>。如果查看objdump的输出,我们会发现这是一个有符号4字节整数。
要在执行进程的内存映像中实际定位到变量,调试器需要检查DW_AT_location属性。对于my_local来说,这个属性为DW_OP_fberg: -20。这表示变量存储在从所包含它的函数的DW_AT_frame_base属性开始偏移-20处,而DW_AT_frame_base正代表了该函数的栈帧起始点。
函数do_stuff的DW_AT_frame_base属性的值是0x0(location list),这表示该值必须要在location list段去查询。我们看看objdump的输出:
$ objdump --dwarf=loc tracedprog2
tracedprog2: file format elf32-i386
Contents of the .debug_loc section:
Offset Begin End Expression
00000000 08048604 08048605 (DW_OP_breg4: 4 )
00000000 08048605 08048607 (DW_OP_breg4: 8 )
00000000 08048607 0804863e (DW_OP_breg5: 8 )
00000000
0000002c 0804863e 0804863f (DW_OP_breg4: 4 )
0000002c 0804863f 08048641 (DW_OP_breg4: 8 )
0000002c 08048641 0804865a (DW_OP_breg5: 8 )
0000002c
关于位置信息,我们这里感兴趣的就是第一个。对于调试器可能定位到的每一个地址,它都会指定当前栈帧到变量间的偏移量,而这个偏移就是通过寄存器来计算的。对于x86体系结构,bpreg4代表esp寄存器,而bpreg5代表ebp寄存器。
让我们再看看do_stuff的开头几条指令:
08048604 :
8048604: 55 push ebp
8048605: 89 e5 mov ebp,esp
8048607: 83 ec 28 sub esp,0x28
804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
804860d: 83 c0 02 add eax,0x2
8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
注意,ebp只有在第二条指令执行后才与我们建立起关联,对于前两个地址,基地址由前面列出的位置信息中的esp计算得出。一旦得到了ebp的有效值,就可以很方便的计算出与它之间的偏移量。因为之后ebp保持不变,而esp会随着数据压栈和出栈不断移动。
那么这到底为我们定位变量my_local留下了什么线索?我们感兴趣的只是在地址0x8048610上的指令执行过后my_local的值(这里my_local的值会通过eax寄存器计算,而后放入内存)。因此调试器需要用到DW_OP_breg5: 8 基址来定位。现在回顾一下my_local的DW_AT_location属性:DW_OP_fbreg: -20。做下算数:从基址开始偏移-20,那就是ebp – 20,再偏移+8,我们得到ebp – 12。现在再看看反汇编输出,注意到数据确实是从eax寄存器中得到的,而ebp – 12就是my_local存储的位置。
定位到行号
当我说到在调试信息中寻找函数时,我撒了个小小的谎。当我们调试C源代码并在函数中放置了一个断点时,我们通常并不会对第一条机器码指令感兴趣。我们真正感兴趣的是函数中的第一行C代码。
这就是为什么DWARF在可执行文件中对C源码到机器码地址做了全部映射。这部分信息包含在.debug_line段中,可以按照可读的形式进行解读:
$ objdump --dwarf=decodedline tracedprog2
tracedprog2: file format elf32-i386
Decoded dump of debug contents of section .debug_line:
CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c:
File name Line number Starting address
tracedprog2.c 5 0x8048604
tracedprog2.c 6 0x804860a
tracedprog2.c 9 0x8048613
tracedprog2.c 10 0x804861c
tracedprog2.c 9 0x8048630
tracedprog2.c 11 0x804863c
tracedprog2.c 15 0x804863e
tracedprog2.c 16 0x8048647
tracedprog2.c 17 0x8048653
tracedprog2.c 18 0x8048658
不难看出C源码同反汇编输出之间的关系。第5行源码指向函数do_stuff的入口点——地址0x8040604。接下第6行源码,当在do_stuff上设置断点时,这里就是调试器实际应该停下的地方,它指向地址0x804860a——刚过do_stuff的开场白。这个行信息能够方便的在C源码的行号同指令地址间建立双向的映射关系。
1. 当在某一行上设定断点时,调试器将利用行信息找到实际应该陷入的地址(还记得前一篇中的int 3指令吗?)
2. 当某个指令引起段错误时,调试器会利用行信息反过来找出源代码中的行号,并告诉用户。
libdwarf —— 在程序中访问DWARF
通过命令行工具来访问DWARF信息这虽然有用但还不能完全令我们满意。作为程序员,我们希望知道应该如何写出实际的代码来解析DWARF格式并从中读取我们需要的信息。
自然的,一种方法就是拿起DWARF规范开始钻研。还记得每个人都告诉你永远不要自己手动解析HTML,而应该使用函数库来做吗?没错,如果你要手动解析DWARF的话情况会更糟糕,DWARF比HTML要复杂的多。本文展示的只是冰山一角而已。更困难的是,在实际的目标文件中,这些信息大部分都以非常紧凑和压缩的方式进行编码处理。
因此我们要走另一条路,使用一个函数库来同DWARF打交道。我知道的这类函数库主要有两个:
1. BFD(libbfd),GNU binutils就是使用的它,包括本文中多次使用到的工具objdump,ld(GNU链接器),以及as(GNU汇编器)。
2. libdwarf —— 同它的老大哥libelf一样,为Solaris以及FreeBSD系统上的工具服务。
我这里选择了libdwarf,因为对我来说它看起来没那么神秘,而且license更加自由(LGPL,BFD是GPL)。
由于libdwarf自身非常复杂,需要很多代码来操作。我这里不打算把所有代码贴出来,但你可以下载,然后自己编译运行。要编译这个文件,你需要安装libelf以及libdwarf,并在编译时为链接器提供-lelf以及-ldwarf标志。
这个演示程序接收一个可执行文件,并打印出程序中的函数名称同函数入口点地址。下面是本文用以演示的C程序产生的输出:
$ dwarf_get_func_addr tracedprog2
DW_TAG_subprogram: 'do_stuff'
low pc : 0x08048604
high pc : 0x0804863e
DW_TAG_subprogram: 'main'
low pc : 0x0804863e
high pc : 0x0804865a
libdwarf的文档非常好(见本文的参考文献部分),花点时间看看,对于本文中提到的DWARF段信息你处理起来就应该没什么问题了。
结论及下一步
调试信息只是一个简单的概念,具体实现细节可能相当复杂。但最终我们知道了调试器是如何从可执行文件中找出同源代码之间的关系。有了调试信息在手,调试器为用户所能识别的源代码和数据结构同可执行文件之间架起了一座桥。
本文加上之前的两篇文章总结了调试器内部的工作原理。通过这一系列文章,再加上一点编程工作就应该可以在Linux下创建一个具有基本功能的调试器。
至于下一步,我还不确定。也许我会就此终结这一系列文章,也许我会再写一些高级主题比如backtrace,甚至Windows系统上的调试。读者们也可以为今后这一系列文章提供意见和想法。不要客气,请随意在评论栏或通过Email给我提些建议吧。
几个主要软件调试方法及调试原则
调试(Debug)
软件调试是在进行了成功的测试之后才开始的工作,它与软件测试不同,调试的任务是进一步诊断和改正程序中潜在的错误。
调试活动由两部分组成:
u 确定程序中可疑错误的确切性质和位置
u 对程序(设计,编码)进行修改,排除这个错误
调试工作是一个具有很强技巧性的工作
软件运行失效或出现问题,往往只是潜在错误的外部表现,而外部表现与内在原因之间常常没有明显的联系,如果要找出真正的原因,排除潜在的错误,不是一件易事。
可以说,调试是通过现象,找出原因的一个思维分析的过程。
调试步骤:
(1) 从错误的外部表现形式入手,确定程序中出错位置
(2) 研究有关部分的程序,找出错误的内在原因
(3) 修改设计代码,以排除这个错误
(4) 重复进行暴露了这个错误的原始测试或某些有关测试。
从技术角度来看查找错误的难度在于:
u 现象与原因所处的位置可能相距甚远
u 当其他错误得到纠正时,这一错误所表现出的现象可能会暂时消失,但并为实际排除
u 现象实际上是由一些非错误原因(例如,舍入不精确)引起的
u 现象可能是由于一些不容易发现的人为错误引起的
u 错误是由于时序问题引起的,与处理过程无关
u 现象是由于难于精确再现的输入状态(例如,实时应用中输入顺序不确定)引起
u 现象可能是周期出现的,在软,硬件结合的嵌入式系统中常常遇到
几种主要的调试方法
调试的关键在于推断程序内部的错误位置及原因,可以采用以下方法:
强行排错
这种调试方法目前使用较多,效率较低,它不需要过多的思考,比较省脑筋。例如:
u 通过内存全部打印来调试,在这大量的数据中寻找出错的位置。
u 在程序特定位置设置打印语句,把打印语句插在出错的源程序的各个关键变量改变部位,重要分支部位,子程序调用部位,跟踪程序的执行,监视重要变量的变化
u 自动调用工具,利用某些程序语言的调试功能或专门的交互式调试工具,分析程序的动态过程,而不必修改程序。
应用以上任一种方法之前,都应当对错误的征兆进行全面彻底的分析,得出对出错位置及错误性质的推测,再使用一种适当的调试方法来检验推测的正确性。
回溯法调试
这是在小程序中常用的一种有效的调试方法,一旦发现了错误,人们先分析错误的征兆,确定最先发现“症状“的位置
然后,人工沿程序的控制流程,向回追踪源程序代码,直到找到错误根源或确定错误产生的范围,
例如,程序中发现错误处是某个打印语句,通过输出值可推断程序在这一点上变量的值,再从这一点出发,回溯程序的执行过程,反复思考:“如果程序在这一点上的状态(变量的值)是这样,那么程序在上一点的状态一定是这样···“直到找到错误所在。
归纳法调试
归纳法是一种从特殊推断一般的系统化思考方法,归纳法调试的基本思想是:从一些线索(错误征兆)着手,通过分析它们之间的关系来找出错误
u 收集有关的数据,列出所有已知的测试用例和程序执行结果,看哪些输入数据的运行结果是正确的,哪些输入数据的运行经过是有错误的
u 组织数据
由于归纳法是从特殊到一般的推断过程,所以需要组织整理数据,以发现规律
常以3W1H形式组织可用的数据
“What“列出一般现象
“Where“说明发现现象的地点
“When“列出现象发生时所有已知情况
“How“说明现象的范围和量级
“Yes“描述出现错误的3W1H;
“No“作为比较,描述了没有错误的3W1H,通过分析找出矛盾来
u 提出假设
分析线索之间的关系,利用在线索结构中观察到的矛盾现象,设计一个或多个关于出错原因的假设,如果一个假设也提不出来,归纳过程就需要收集更多的数据,此时,应当再设计与执行一些测试用例,以获得更多的数据。
u 证明假设
把假设与原始线索或数据进行比较,若它能完全解释一切现象,则假设得到证明,否则,认为假设不合理,或不完全,或是存在多个错误,以致只能消除部分错误
演绎法调试
演绎法是一种从一般原理或前提出发,经过排除和精华的过程来推导出结论的思考方法,演绎法排错是测试人员首先根据已有的测试用例,设想及枚举出所有可能出错的原因作为假设,然后再用原始测试数据或新的测试,从中逐个排除不可能正确的假设,最后,再用测试数据验证余下的假设确是出错的原因。
u 列举所有可能出错原因的假设,把所有可能的错误原因列成表,通过它们,可以组织,分析现有数据
u 利用已有的测试数据,排除不正确的假设
仔细分析已有的数据,寻找矛盾,力求排除前一步列出所有原因,如果所有原因都被排除了,则需要补充一些数据(测试用例),以建立新的假设。
u 改进余下的假设
利用已知的线索,进一步改进余下的假设,使之更具体化,以便可以精确地确定出错位置
u 证明余下的假设
调试原则
n 在调试方面,许多原则本质上是心理学方面的问题,调试由两部分组成,调试原则也分成两组。
n 确定错误的性质和位置的原则
u 用头脑去分析思考与错误征兆有关的信息
u 避开死胡同
u 只把调试工具当做辅助手段来使用,利用调试工具,可以帮助思考,但不能代替思考
u 避免用试探法,最多只能把它当做最后手段
n 修改错误的原则
u 在出现错误的地方,很有可能还有别的错误
u 修改错误的一个常见失误是只修改了这个错误的征兆或这个错误的表现,而没有修改错误的本身。
u 当心修正一个错误的同时有可能会引入新的错误
u 修改错误的过程将迫使人们暂时回到程序设计阶段
u 修改源代码程序,不要改变目标代码
调试手段及原理
本文将从应用程序、编译器和调试器三个层次来讲解,在不同的层次,有不同的方法,这些方法有各自己的长处和局限。了解这些知识,一方面满足一下新手的好奇心,另一方面也可能有用得着的时候。
从应用程序的角度
最好的情况是从设计到编码都扎扎实实的,避免把错误引入到程序中来,这才是解决问题的根本之道。问题在于,理想情况并不存在,现实中存在着大量有内存错误的程序,如果内存错误很容易避免,JAVA/C#的优势将不会那么突出了。
对于内存错误,应用程序自己能做的非常有限。但由于这类内存错误非常典型,所占比例非常大,所付出的努力与所得的回报相比是非常划算的,仍然值得研究。
前面我们讲了,堆里面的内存是由内存管理器管理的。从应用程序的角度来看,我们能做到的就是打内存管理器的主意。其实原理很简单:
对付内存泄露。重载内存管理函数,在分配时,把这块内存的记录到一个链表中,在释放时,从链表中删除吧,在程序退出时,检查链表是否为空,如果不为空,则说明有内存泄露,否则说明没有泄露。当然,为了查出是哪里的泄露,在链表还要记录是谁分配的,通常记录文件名和行号就行了。
对付内存越界/野指针。对这两者,我们只能检查一些典型的情况,对其它一些情况无能为力,但效果仍然不错。其方法如下(源于《Comparing and contrasting the runtime error detection technologies》):
l 首尾在加保护边界值
Header
Leading guard(0xFC)
User data(0xEB)
Tailing guard(0xFC)
在内存分配时,内存管理器按如上结构填充分配出来的内存。其中Header是管理器自己用的,前后各有几个字节的guard数据,它们的值是固定的。当内存释放时,内存管理器检查这些guard数据是否被修改,如果被修改,说明有写越界。
它的工作机制注定了有它的局限性: 只能检查写越界,不能检查读越界,而且只能检查连续性的写越界,对于跳跃性的写越界无能为力。
l 填充空闲内存
空闲内存(0xDD)
内存被释放之后,它的内容填充成固定的值。这样,从指针指向的内存的数据,可以大致判断这个指针是否是野指针。
它同样有它的局限:程序要主动判断才行。如果野指针指向的内存立即被重新分配了,它又被填充成前面那个结构,这时也无法检查出来。
从编译器的角度
boundschecker和purify的实现都可以归于编译器一级。前者采用一种称为CTI(compile-time instrumentation)的技术。VC的编译不是要分几个阶段吗?boundschecker在预处理和编译两个阶段之间,对源文件进行修改。它对所有内存分配释放、内存读写、指针赋值和指针计算等所有内存相关的操作进行分析,并插入自己的代码。比如:
Before
if (m_hsession) gblHandles->ReleaseUserHandle( m_hsession );
if (m_dberr) delete m_dberr;
After
if (m_hsession) {
_Insight_stack_call(0);
gblHandles->ReleaseUserHandle(m_hsession);
_Insight_after_call();
}
_Insight_ptra_check(1994, (void **) &m_dberr, (void *) m_dberr);
if (m_dberr) {
_Insight_deletea(1994, (void **) &m_dberr, (void *) m_dberr, 0);
delete m_dberr;
}
Purify则采用一种称为OCI(object code insertion)的技术。不同的是,它对可执行文件的每条指令进行分析,找出所有内存分配释放、内存读写、指针赋值和指针计算等所有内存相关的操作,用自己的指令代替原始的指令。
boundschecker和purify是商业软件,它们的实现是保密的,甚至拥有专利的,无法对其研究,只能找一些皮毛性的介绍。无论是CTI还是OCI这样的名称,多少有些神秘感。其实它们的实现原理并不复杂,通过对valgrind和gcc的bounds checker扩展进行一些粗浅的研究,我们可以知道它们的大致原理。
gcc的bounds checker基本上可以与boundschecker对应起来,都是对源代码进行修改,以达到控制内存操作功能,如malloc/free等内存管理函数、memcpy/strcpy/memset等内存读取函数和指针运算等。Valgrind则与Purify类似,都是通过对目标代码进行修改,来达到同样的目的。
Valgrind对可执行文件进行修改,所以不需要重新编译程序。但它并不是在执行前对可执行文件和所有相关的共享库进行一次性修改,而是和应用程序在同一个进程中运行,动态的修改即将执行的下一段代码。
Valgrind是插件式设计的。Core部分负责对应用程序的整体控制,并把即将修改的代码,转换成一种中间格式,这种格式类似于RISC指令,然后把中间代码传给插件。插件根据要求对中间代码修改,然后把修改后的结果交给core。core接下来把修改后的中间代码转换成原始的x86指令,并执行它。
由此可见,无论是boundschecker、purify、gcc的bounds checker,还是Valgrind,修改源代码也罢,修改二进制也罢,都是代码进行修改。究竟要修改什么,修改成什么样子呢?别急,下面我们就要来介绍:
管理所有内存块。无论是堆、栈还是全局变量,只要有指针引用它,它就被记录到一个全局表中。记录的信息包括内存块的起始地址和大小等。要做到这一点并不难:对于在堆里分配的动态内存,可以通过重载内存管理函数来实现。对于全局变量等静态内存,可以从符号表中得到这些信息。
拦截所有的指针计算。对于指针进行乘除等运算通常意义不大,最常见运算是对指针加减一个偏移量,如++p、p=p+n、p=a[n]等。所有这些有意义的指针操作,都要受到检查。不再是由一条简单的汇编指令来完成,而是由一个函数来完成。
有了以上两点保证,要检查内存错误就非常容易了:比如要检查++p是否有效,首先在全局表中查找p指向的内存块,如果没有找到,说明p是野指针。如果找到了,再检查p+1是否在这块内存范围内,如果不是,那就是越界访问,否则是正常的了。怎么样,简单吧,无论是全局内存、堆还是栈,无论是读还是写,无一能够逃过出工具的法眼。
代码赏析(源于tcc):
对指针运算进行检查:
void *__bound_ptr_add(void *p, int offset)
{
unsigned long addr = (unsigned long)p;
BoundEntry *e;
#if defined(BOUND_DEBUG)
printf("add: 0x%x %d\n", (int)p, offset);
#endif
e = __bound_t1[addr >> (BOUND_T2_BITS + BOUND_T3_BITS)];
e = (BoundEntry *)((char *)e +
((addr >> (BOUND_T3_BITS - BOUND_E_BITS)) &
((BOUND_T2_SIZE - 1) << BOUND_E_BITS)));
addr -= e->start;
if (addr > e->size) {
e = __bound_find_region(e, p);
addr = (unsigned long)p - e->start;
}
addr += offset;
if (addr > e->size)
return INVALID_POINTER;
return p + offset;
}
static void __bound_check(const void *p, size_t size)
{
if (size == 0)
return;
p = __bound_ptr_add((void *)p, size);
if (p == INVALID_POINTER)
bound_error("invalid pointer");
}
重载内存管理函数:
void *__bound_malloc(size_t size, const void *caller)
{
void *ptr;
ptr = libc_malloc(size + 1);
if (!ptr)
return NULL;
__bound_new_region(ptr, size);
return ptr;
}
void __bound_free(void *ptr, const void *caller)
{
if (ptr == NULL)
return;
if (__bound_delete_region(ptr) != 0)
bound_error("freeing invalid region");
libc_free(ptr);
}
重载内存操作函数:
void *__bound_memcpy(void *dst, const void *src, size_t size)
{
__bound_check(dst, size);
__bound_check(src, size);
if (src >= dst && src < dst + size)
bound_error("overlapping regions in memcpy()");
return memcpy(dst, src, size);
}
从调试器的角度
现在有OS的支持,实现一个调试器变得非常简单,至少原理不再神秘。这里我们简要介绍一下win32和linux中的调试器实现原理。
在Win32下,实现调试器主要通过两个函数:WaitForDebugEvent和ContinueDebugEvent。下面是一个调试器的基本模型(源于: 《Debugging Applications for Microsoft .NET and Microsoft Windows》)
void main ( void )
{
CreateProcess ( ..., DEBUG_ONLY_THIS_PROCESS ,... ) ;
while ( 1 == WaitForDebugEvent ( ... ) )
{
if ( EXIT_PROCESS )
{
break ;
}
ContinueDebugEvent ( ... ) ;
}
}
由调试器起动被调试的进程,并指定DEBUG_ONLY_THIS_PROCESS标志。按Win32下事件驱动的一贯原则,由被调试的进程主动上报调试事件,调试器然后做相应的处理。
在linux下,实现调试器只要一个函数就行了:ptrace。下面是个简单示例:(源于《Playing with ptrace》)。
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include
#include <linux/user.h>
int main(int argc, char *argv[])
{ pid_t traced_process;
struct user_regs_struct regs;
long ins;
if(argc != 2) {
printf("Usage: %s \n",
argv[0], argv[1]);
exit(1);
}
traced_process = atoi(argv[1]);
ptrace(PTRACE_ATTACH, traced_process,
NULL, NULL);
wait(NULL);
ptrace(PTRACE_GETREGS, traced_process,
NULL, ®s);
ins = ptrace(PTRACE_PEEKTEXT, traced_process,
regs.eip, NULL);
printf("EIP: %lx Instruction executed: %lx\n",
regs.eip, ins);
ptrace(PTRACE_DETACH, traced_process,
NULL, NULL);
return 0;
}
由于篇幅有限,这里对于调试器的实现不作深入讨论,主要是给新手指一个方向。以后若有时间,再写个专题来介绍linux下的调试器和ptrace本身的实现方法。
一般调试器工作原理
调试器简介
严格的讲,调试器是帮助程序员跟踪,分隔和从软件中移除bug的工具。它帮助程序员更进一步理解程序。一开始,主要是开发人员使用它,后来测试人员,维护人员也开始使用它。
调试器的发展历程:
1. 静态存储
2. 交互式存储分析器
3. 二进制调试器
4. 基本的符号调试器(源码调试器)
5. 命令行符号调试器
6. 全屏文本模式调试器
7. 图形用户接口调试器
8. 集成开发环境调试器
调试器的设计和开发要遵循四个关键的原则:
1. 在开发过程中,不能改变被调试程序的行为;
2. 提供真实可靠的调试信息;
3. 提供详细的信息,是调试人员知道他们调试到代码的哪一行并且知道他们是怎么到达的;
4. 非常不幸的是,我们使用的调试总是不能满足我们的需求。
按照划分的标准不同,调试器主要分为一下几类:
1. 源码调试器与机器码调试器
2. 单独的调试器与集成开发环境的调试器
3. 第四代语言调试器与第三代语言调试器
4. 操作系统内核调试器与应用程序调试器
5. 利用处理器提供的功能的调试器与利用自行仿真处理器进行调试的调试器
调试器的架构
调试器之间的区别更多的是体现在他们展现给用户的窗口。至于底层结构都是很相近的。下图展示了调试器的总体架构:
调试器内核
调试器服务于所有的调试器视图。包括进程控制,执行引擎,表达式计算,符号表管理四部分。
操作系统接口
调试器内核为了访问被调试程序,必须使用操作系统提供的一系列例程。
硬件调试功能
调试器控制被调试程序的能力主要是依靠硬件支持和操作系统的调试机制。调试器需要最少三种的硬件功能的支持:
1. 提供设置断点的方法;
2. 通知操作系统发生中断或者陷阱的功能;
3. 当中断或者陷阱发生时,直接读写寄存器,包括程序计数器。
通用的硬件调试机制
1. 断点支持
断点功能是通过特定的指令来实现的。对于变长指令的处理器,断点指令通常是最短的指令,下图给出了四个处理器的断点指令:
2. 单步调试支持
单步调试是指执行一条指令就产生一次中断,是用户可以查找每条指令的执行状态。一般的处理器都提供一个模式位来实现单步调试功能。
3. 错误检测支持
错误检测功能是指当操作系统检测到错误发生时,他通知调试器被它调试的程序发生了错误。
4. 检测点支持
用来查看被调试程序的地址空间(数据空间)。
5. 多线程支持
6. 多处理器支持
调试器的操作系统支持功能
为了控制一个被调试程序的过程,调试器需要一种机制去通知操作系统该可执行文件希望被控制。即一旦被调试程序由于某些原因停止的时候,调试器需要获取详细的信息使得他知道被调试程序是什么原因造成他停止的。
调试器是用户级的程序,并不是操作系统的一部分,并不能运行特权级指令,因此,它只能通过调用操作系统的系统调用来实现对特权级指令的访问。
调试器运行被调试程序,并将控制权转交给被调试程序,需要进行上下文切换。在一个简单的断点功能实现,有6个主要的转换:
1. 当调试器运行到断点指令的时候,产生陷阱跳转到操作系统;
2. 通过操作系统,跳转到调试器,调试器开始运行;
3. 调试器请求被调试程序的状态信息,该请求送到操作系统进行处理;
4. 转换到被调试程序文本以获取信息,被调试程序激活;
5. 返回信息给操作系统;
6. 转换到调试器以处理信息。
一旦使用图形界面调试器,过程会更加的复杂。
对于多线程调试的支持;
l 一旦进程创建和删除,操作系统必须通知调试器;
l 能够询问和设置特定进程的进程状态;
l 能够检测到应用程序停止,或者线程停止。
例子:UNIX ptrace()
UNIX ptrace 是操作系统支持调试器的一个真实的API。
控制执行
调试器的核心是它的进程控制和运行控制。为了能够调试程序,调试器必须能够对被调试程序进行状态设置,断点设置,运行进程,终止进程。
控制执行主要包含一下几个功能:
1. 创建被调试程序
调试器做的第一件工作,就是创建被调试程序。一般通过两种手段:一种是为调试程序创建被调试进程,另一种是将调试器附到被调试进程上。
2. 附到被调试进程
当一个进程发生错误异常,并且在被刷出(内存刷新)内存的时候,允许调试器挂到出错进程以此来检查内存镜像。这个时候,用户不能再继续执行进程。
3. 设置断点
设置断点的功能是在可执行文本中插入特殊的指令来实现的。当程序执行到该特殊指令的时候,就产生陷阱,陷到操作系统。
4. 使被调试程序运行
当调试中断产生的时候,调试器属于激活进程,而被调试程序属于未激活进程。调试器产生一个系统中断请求恢复被调用函数的执行,操作系统对被调试程序进行上下文切换,恢复被调用程序的现场状态,然后执行被调用程序。
执行区间的调试事件生成类型:
l 断点,单步调试事件
l 线程创建/删除事件
l 进程创建/删除事件
l 检测点事件
l 模块加载/卸载事件
l 异常事件
l 其他事件
断点和单步调试
断点通常需要两层的表示:
l 逻辑表示:指在源代码中设置的断点,用来告诉用户的;
l 物理表示:指真实的在机器码中写入,是用来告诉物理机器的。断点必须存储写入位置的机器指令,以便能够在移除断点的时候恢复原来的指令。
断点存在条件断点。
断点存在多对一的关系,即多个用户在同一个地方设置断点(多个逻辑断点对应一个物理断点),当然也有多对多的关系。下图展示了这样的一个关系:
临时断点
临时断点是指只运行一次的断点。
内部断点
内部断点对用户是不可见的。他们是被调试器设置的。
一般主要用于:
l 单步调试:内部断点和运行到内部断点;
l 跳出函数:在函数返回地址设置内部断点;
l 进入函数
查看程序的上下文信息
一般要查找程序的上下文信息主要有以下几种方法:
源代码窗口
通过源代码查看程序执行到代码的那一部分
程序堆栈
程序堆栈是由硬件,操作系统和编译器共同支持的:
硬件: 提供堆栈指针;
操作系统:为每个进程建立堆栈空间,并管理堆栈。一旦堆栈溢出,而产生一个错误;
汇编级调试:反汇编,查看寄存器,查看内存
结合程序崩溃后的core文件分析bug
1.1 引言
1.2 core文件
1.2.1 何为core文件.
1.2.2 如何产生core文件
1.2.3 为什么需要core文件
1.2.4 core文件的名称和生成路径
1.2.5 如何阅读core文件
1.3 ulimit命令参数及用法
1.4 参考
回到目录
引言
在《I/O的效率比较》中,我们在修改图1程序的BUF_SIZE为8388608时,运行程序出现崩溃,如下图1:
图1. 段错误
一般而言,导致程序段错误的原因如下:
内存访问出错,这类问题的典型代表就是数组越界。
非法内存访问,出现这类问题主要是程序试图访问内核段内存而产生的错误。
栈溢出, Linux默认给一个进程分配的栈空间大小为8M,因此你的数组开得过大的话会出现这种问题。
首先我们先看一下系统默认分配的资源:
$ ulimit -a
core file size (blocks, -c) unlimited
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 7884
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 8192
cpu time (seconds, -t) unlimited
max user processes (-u) 7884
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
可以看到默认分配的栈大小为8M。而刚好我们的代码里的栈大小调到了8M,因此出现了段错误。
那么有没有一种更直接明了的方法来识别和分析应用程序崩溃产生的bug呢? 有,那就是通过程序崩溃后产生的core文件。
回到目录
core文件
何为core文件.
core dump又叫内核转储, 在Unix系统中,核心映像(core image)就是“进程”执行当时的内存内容,当进程发生错误或收到“信号”而终止执行时,系统会将核心映像写入一个文件,以作为调试之用,这就是所谓的核心转储(core dump)。而core文件一般产生在进程的当前工作目录下。
所以core文件中只是程序的内存映像, 如果在编译时加入调试信息的话,那么还会有调试信息。
如何产生core文件
我们运行了a.out程序出现了“段错误”,但没有产生core文件。这是因为系统默认core文件的大小为0,所以没有创建。可以用ulimit命令查看和修改core文件的大小。
$ ulimit -c 0 <--------- c选项指定修改core文件的大小
$ ulimit -c 1000 <--------指定了core文件大小为1000KB, 如果设置的大小小于core文件,则对core文件截取
$ ulimit -c unlimited <---------------对core文件的大小不做限制
如果想让修改永久生效,则需要修改配置文件,如.bash_profile、/etc/profile或/etc/security/limits.conf
我们回到上面的代码演示,把core文件的大小调成不限制,再执行a.out,就可以在当前目录看到core文件了。
另外补充一些资料,说明一些情况也不会产生core文件。
进程是设置-用户-ID,而且当前用户并非程序文件的所有者;
进程是设置-组-ID,而且当前用户并非该程序文件的组所有者;
用户没有写当前工作目录的许可权;
文件太大。core文件的许可权(假定该文件在此之前并不存在)通常是用户读/写,组读和其他读。
为什么需要core文件
关于core产生的原因很多,比如过去一些Unix的版本不支持现代Linux上这种gdb直接附着到进程上进行调试的机制,需要先向进程发送终止信号,然后用工具阅读core文件。在Linux上,我们就可以使用kill向一个指定的进程发送信号或者使用gcore命令来使其主动出core并退出。
如果从浅层次的原因上来讲,出core意味着当前进程存在BUG,需要程序员修复。
从深层次的原因上讲,是当前进程触犯了某些OS层级的保护机制,逼迫OS向当前进程发送诸如SIGSEGV(即signal 11)之类的信号, 例如访问空指针或数组越界出core,实际上是触犯了OS的内存管理,访问了非当前进程的内存空间,OS需要通过出core来进行警示,这就好像一个人身体内存在病毒,免疫系统就会通过发热来警示,并导致人体发烧是一个道理(有意思的是,并不是每次数组越界都会出Core,这和OS的内存管理中虚拟页面分配大小和边界有关,即使不出core,也很有可能读到脏数据,引起后续程序行为紊乱,这是一种很难追查的BUG)。
core文件的名称和生成路径
默认情况下core的文件名叫"core"
/proc/sys/kernel/core_uses_pid可以控制core文件的文件名中是否添加pid作为扩展
文件内容为1,表示添加pid作为扩展名,生成的core文件格式为core.PID
为0则表示生成的core文件统一命名为core.
如何修改这个文件的内容?
$ echo "0" > /proc/sys/kernel/core_uses_pid
/proc/sys/kernel/core_pattern文件用于定制core的文件名,一般使用%配合不同的字符:
%p 出core进程的PID
%u 出core进程的UID
%s 造成core的signal号
%t 出core的时间,从1970-01-0100:00:00开始的秒数
%e 出core进程对应的可执行文件名
如何阅读core文件
产生了core文件之后,就是如何查看core文件,并确定问题所在,进行修复。为此,我们不妨先来看看core文件的格式,多了解一些core文件。
$ file core.4244
core.4244: ELF 64-bit LSB core file x86-64, version 1 (SYSV), SVR4-style, from '/home/fireway/study/temp/a.out'
首先可以明确一点,core文件的格式ELF格式,通过使用readelf -h命令来查看更详细内容
$ readelf -h core.4244
ELF 头:
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: CORE (Core 文件)
Machine: Advanced Micro Devices X86-64
Version: 0x1
入口点地址: 0x0
程序头起点: 64 (bytes into file)
Start of section headers: 0 (bytes into file)
标志: 0x0
本头的大小: 64 (字节)
程序头大小: 56 (字节)
Number of program headers: 19
节头大小: 0 (字节)
节头数量: 0
字符串表索引节头: 0
了解了这些之后,我们来看看如何阅读core文件,并从中追查BUG。在Linux下,一般读取core的命令为:
$ gdb exec_file core_file
使用gdb,先从可执行文件中读取符号表信息,然后读取core文件。如果不与可执行文件搅合在一起可以吗?答案是不行,因为core文件中没有符号表信息,无法进行调试,可以使用如下命令来验证:
$ objdump -x core.4244 | tail
26 load16 00001000 00007ffff7ffe000 0000000000000000 0003f000 2**12
CONTENTS, ALLOC, LOAD
27 load17 00801000 00007fffff7fe000 0000000000000000 00040000 2**12
CONTENTS, ALLOC, LOAD
28 load18 00001000 ffffffffff600000 0000000000000000 00841000 2**12
CONTENTS, ALLOC, LOAD, READONLY, CODE
SYMBOL TABLE:
no symbols <----------------- 表明当前的ELF格式文件中没有符号表信息
结合上面知识点,我们分别编译带-g的目标可执行mycat_debug和不带-g的目标可执行mycat,会发现mycat_debug的文件大小稍微大一些。使用readelf命令得出的结果比较报告,详细见附件-readelf报告.html
各自执行产生的core文件,再使用objdump命令得出的结果比较报告,详细见附件-objdump报告.html
最后我们各自使用gdb读取core文件,得出的结果比较报告,详细见附件-gdb_core报告.html
如果我们强制使用gdb mycat, 接着是带有调试信息的core文件,gdb会有什么提示呢?
Reading symbols from mycat...(no debugging symbols found)...done.
warning: core file may not match specified executable file.
[New LWP 2037]
Core was generated by `./mycat_debug'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x0000000000400957 in main ()
接下来重点来看,为啥产生段错误?
使用gdb mycat_debug core.2037可见:
GNU gdb (Ubuntu 7.7.1-0ubuntu5~14.04.2) 7.7.1
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from mycat_debug...done.
[New LWP 2037]
Core was generated by `./mycat_debug'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 main () at io1.c:16
16 int n = 0;
可知程序段错误,代码是int n = 0;这一句,我们来看当前栈信息:
(gdb) info f
Stack level 0, frame at 0x7ffc4b59d670:
rip = 0x400957 in main (io1.c:16); saved rip = 0x7fc5c0d5aec5
source language c.
Arglist at 0x7ffc4b59d660, args:
Locals at 0x7ffc4b59d660, Previous frame's sp is 0x7ffc4b59d670
Saved registers:
rbp at 0x7ffc4b59d660, rip at 0x7ffc4b59d668
其中可见指令指针rip指向地址为0x400957, 我们用x命令来查看内存地址中的值。具体帮助查看gdb调试 - 查看内存一节
(gdb) x/5i 0x400957 或者x/5i $rip
=> 0x400957 <main+26>:movl $0x0,-0x800014(%rbp)
0x400961 <main+36>:lea -0x800010(%rbp),%rax
0x400968 <main+43>:mov $0x800000,%edx
0x40096d <main+48>:mov $0x0,%esi
0x400972 <main+53>:mov %rax,%rdi
这条movl指令要把立即数0送到-0x800014(%rbp)这个地址去,其中rbp存储的是帧指针,其地址是 0x7ffc4b59d660,而-0x800014显然是个负数,十进制是8388628,且栈空间是由高地址向低地址延伸,见图2,那么n的栈地址就是-0x800014(%rbp),也就是$rbp-8388628。当我们尝试访问此地址时
图2. 典型的存储空间安排
(gdb) x /b 0x7ffc4ad9d64c
0x7ffc4ad9d64c: Cannot access memory at address 0x7ffc4ad9d64c
可以看到无法访问此内存地址,这是因为它已经超过了OS允许的范围。
回到目录
ulimit命令参数及用法
功能说明:控制shell程序的资源。
补充说明:ulimit为shell内建指令,可用来控制shell执行程序的资源。
参 数:
-a 显示目前资源限制的设定。
-c 设定core文件的最大值,单位为KB。
-d <数据节区大小> 程序数据节区的最大值,单位为KB。
-f <文件大小> shell所能建立的最大文件,单位为区块。
-H 设定资源的硬性限制,也就是管理员所设下的限制。
-m <内存大小> 指定可使用内存的上限,单位为KB。
-n <文件数目> 指定同一时间最多可开启的文件数。
-p <缓冲区大小> 指定管道缓冲区的大小,单位512字节。
-s <堆叠大小> 指定堆叠的上限,单位为KB。
-S 设定资源的弹性限制。
-t 指定CPU使用时间的上限,单位为秒。
-u <程序数目> 用户最多可开启的程序数目。
-v <虚拟内存大小> 指定可使用的虚拟内存上限,单位为KB。
回到目录
参考
Linux下运行C++程序出现“段错误(核心已转储)”的原因
ulimit命令参数及用法
Linux下core文件的演示分析
gdb分析core文件
Linux上Core Dump文件的形成和分析
详解coredump
一,什么是coredump
我们经常听到大家说到程序core掉了,需要定位解决,这里说的大部分是指对应程序由于各种异常或者bug导致在运行过程中异常退出或者中止,并且在满足一定条件下(这里为什么说需要满足一定的条件呢?下面会分析)会产生一个叫做core的文件。
通常情况下,core文件会包含了程序运行时的内存,寄存器状态,堆栈指针,内存管理信息还有各种函数调用堆栈信息等,我们可以理解为是程序工作当前状态存储生成第一个文件,许多的程序出错的时候都会产生一个core文件,通过工具分析这个文件,我们可以定位到程序异常退出的时候对应的堆栈调用等信息,找出问题所在并进行及时解决。
二,coredump文件的存储位置
core文件默认的存储位置与对应的可执行程序在同一目录下,文件名是core,大家可以通过下面的命令看到core文件的存在位置:
cat /proc/sys/kernel/core_pattern
缺省值是core
注意:这里是指在进程当前工作目录的下创建。通常与程序在相同的路径下。但如果程序中调用了chdir函数,则有可能改变了当前工作目录。这时core文件创建在chdir指定的路径下。有好多程序崩溃了,我们却找不到core文件放在什么位置。和chdir函数就有关系。当然程序崩溃了不一定都产生 core文件。
如下程序代码:则会把生成的core文件存储在/data/coredump/wd,而不是大家认为的跟可执行文件在同一目录。
通过下面的命令可以更改coredump文件的存储位置,若你希望把core文件生成到/data/coredump/core目录下:
echo “/data/coredump/core”> /proc/sys/kernel/core_pattern
注意,这里当前用户必须具有对/proc/sys/kernel/core_pattern的写权限。
缺省情况下,内核在coredump时所产生的core文件放在与该程序相同的目录中,并且文件名固定为core。很显然,如果有多个程序产生core文件,或者同一个程序多次崩溃,就会重复覆盖同一个core文件,因此我们有必要对不同程序生成的core文件进行分别命名。
我们通过修改kernel的参数,可以指定内核所生成的coredump文件的文件名。例如,使用下面的命令使kernel生成名字为core.filename.pid格式的core dump文件:
echo “/data/coredump/core.%e.%p” >/proc/sys/kernel/core_pattern
这样配置后,产生的core文件中将带有崩溃的程序名、以及它的进程ID。上面的%e和%p会被替换成程序文件名以及进程ID。
如果在上述文件名中包含目录分隔符“/”,那么所生成的core文件将会被放到指定的目录中。 需要说明的是,在内核中还有一个与coredump相关的设置,就是/proc/sys/kernel/core_uses_pid。如果这个文件的内容被配置成1,那么即使core_pattern中没有设置%p,最后生成的core dump文件名仍会加上进程ID。
三,如何判断一个文件是coredump文件?
在类unix系统下,coredump文件本身主要的格式也是ELF格式,因此,我们可以通过readelf命令进行判断。
可以看到ELF文件头的Type字段的类型是:CORE (Core file)
可以通过简单的file命令进行快速判断:
四,产生coredum的一些条件总结
1, 产生coredump的条件,首先需要确认当前会话的ulimit –c,若为0,则不会产生对应的coredump,需要进行修改和设置。
ulimit -c unlimited (可以产生coredump且不受大小限制)
若想甚至对应的字符大小,则可以指定:
ulimit –c [size]
可以看出,这里的size的单位是blocks,一般1block=512bytes
如:
ulimit –c 4 (注意,这里的size如果太小,则可能不会产生对应的core文件,笔者设置过ulimit –c 1的时候,系统并不生成core文件,并尝试了1,2,3均无法产生core,至少需要4才生成core文件)
但当前设置的ulimit只对当前会话有效,若想系统均有效,则需要进行如下设置:
Ø 在/etc/profile中加入以下一行,这将允许生成coredump文件
ulimit-c unlimited
Ø 在rc.local中加入以下一行,这将使程序崩溃时生成的coredump文件位于/data/coredump/目录下:
echo /data/coredump/core.%e.%p> /proc/sys/kernel/core_pattern
注意rc.local在不同的环境,存储的目录可能不同,susu下可能在/etc/rc.d/rc.local
更多ulimit的命令使用,可以参考:http://baike.baidu.com/view/4832100.htm
这些需要有root权限, 在ubuntu下每次重新打开中断都需要重新输入上面的ulimit命令, 来设置core大小为无限.
2, 当前用户,即执行对应程序的用户具有对写入core目录的写权限以及有足够的空间。
3, 几种不会产生core文件的情况说明:
The core file will not be generated if
(a) the process was set-user-ID and the current user is not the owner of the program file, or
(b) the process was set-group-ID and the current user is not the group owner of the file,
(c) the user does not have permission to write in the current working directory,
(d) the file already exists and the user does not have permission to write to it, or
(e) the file is too big (recall the RLIMIT_CORE limit in Section 7.11). The permissions of the core file (assuming that the file doesn't already exist) are usually user-read and user-write, although Mac OS X sets only user-read.
五,coredump产生的几种可能情况
造成程序coredump的原因有很多,这里总结一些比较常用的经验吧:
1,内存访问越界
a) 由于使用错误的下标,导致数组访问越界。
b) 搜索字符串时,依靠字符串结束符来判断字符串是否结束,但是字符串没有正常的使用结束符。
c) 使用strcpy, strcat, sprintf, strcmp,strcasecmp等字符串操作函数,将目标字符串读/写爆。应该使用strncpy, strlcpy, strncat, strlcat, snprintf, strncmp, strncasecmp等函数防止读写越界。
2,多线程程序使用了线程不安全的函数。
应该使用下面这些可重入的函数,它们很容易被用错:
asctime_r(3c) gethostbyname_r(3n) getservbyname_r(3n)ctermid_r(3s) gethostent_r(3n) getservbyport_r(3n) ctime_r(3c) getlogin_r(3c)getservent_r(3n) fgetgrent_r(3c) getnetbyaddr_r(3n) getspent_r(3c)fgetpwent_r(3c) getnetbyname_r(3n) getspnam_r(3c) fgetspent_r(3c)getnetent_r(3n) gmtime_r(3c) gamma_r(3m) getnetgrent_r(3n) lgamma_r(3m) getauclassent_r(3)getprotobyname_r(3n) localtime_r(3c) getauclassnam_r(3) etprotobynumber_r(3n)nis_sperror_r(3n) getauevent_r(3) getprotoent_r(3n) rand_r(3c) getauevnam_r(3)getpwent_r(3c) readdir_r(3c) getauevnum_r(3) getpwnam_r(3c) strtok_r(3c) getgrent_r(3c)getpwuid_r(3c) tmpnam_r(3s) getgrgid_r(3c) getrpcbyname_r(3n) ttyname_r(3c)getgrnam_r(3c) getrpcbynumber_r(3n) gethostbyaddr_r(3n) getrpcent_r(3n)
3,多线程读写的数据未加锁保护。
对于会被多个线程同时访问的全局数据,应该注意加锁保护,否则很容易造成coredump
4,非法指针
a) 使用空指针
b) 随意使用指针转换。一个指向一段内存的指针,除非确定这段内存原先就分配为某种结构或类型,或者这种结构或类型的数组,否则不要将它转换为这种结构或类型的指针,而应该将这段内存拷贝到一个这种结构或类型中,再访问这个结构或类型。这是因为如果这段内存的开始地址不是按照这种结构或类型对齐的,那么访问它时就很容易因为bus error而core dump。
5,堆栈溢出
不要使用大的局部变量(因为局部变量都分配在栈上),这样容易造成堆栈溢出,破坏系统的栈和堆结构,导致出现莫名其妙的错误。
六,利用gdb进行coredump的定位
其实分析coredump的工具有很多,现在大部分类unix系统都提供了分析coredump文件的工具,不过,我们经常用到的工具是gdb。
这里我们以程序为例子来说明如何进行定位。
1, 段错误 – segmentfault
Ø 我们写一段代码往受到系统保护的地址写内容。
Ø 按如下方式进行编译和执行,注意这里需要-g选项编译。
可以看到,当输入12的时候,系统提示段错误并且core dumped
Ø 我们进入对应的core文件生成目录,优先确认是否core文件格式并启用gdb进行调试。
从红色方框截图可以看到,程序中止是因为信号11,且从bt(backtrace)命令(或者where)可以看到函数的调用栈,即程序执行到coremain.cpp的第5行,且里面调用scanf 函数,而该函数其实内部会调用_IO_vfscanf_internal()函数。
接下来我们继续用gdb,进行调试对应的程序。
记住几个常用的gdb命令:
l(list) ,显示源代码,并且可以看到对应的行号;
b(break)x, x是行号,表示在对应的行号位置设置断点;
p(print)x, x是变量名,表示打印变量x的值
r(run), 表示继续执行到断点的位置
n(next),表示执行下一步
c(continue),表示继续执行
q(quit),表示退出gdb
启动gdb,注意该程序编译需要-g选项进行。
注: SIGSEGV 11 Core Invalid memoryreference
七,附注:
1, gdb的查看源码
显示源代码
GDB 可以打印出所调试程序的源代码,当然,在程序编译时一定要加上-g的参数,把源程序信息编译到执行文件中。不然就看不到源程序了。当程序停下来以后,GDB会报告程序停在了那个文件的第几行上。你可以用list命令来打印程序的源代码。还是来看一看查看源代码的GDB命令吧。
list
显示程序第linenum行的周围的源程序。
list
显示函数名为function的函数的源程序。
list
显示当前行后面的源程序。
list -
显示当前行前面的源程序。
一般是打印当前行的上5行和下5行,如果显示函数是是上2行下8行,默认是10行,当然,你也可以定制显示的范围,使用下面命令可以设置一次显示源程序的行数。
setlistsize
设置一次显示源代码的行数。
showlistsize
查看当前listsize的设置。
list命令还有下面的用法:
list,
显示从first行到last行之间的源代码。
list ,
显示从当前行到last行之间的源代码。
list +
往后显示源代码。
一般来说在list后面可以跟以下这些参数:
行号。
<+offset> 当前行号的正偏移量。
<-offset> 当前行号的负偏移量。
哪个文件的哪一行。
函数名。
哪个文件中的哪个函数。
<*address> 程序运行时的语句在内存中的地址。
2, 一些常用signal的含义
SIGABRT:调用abort函数时产生此信号。进程异常终止。
SIGBUS:指示一个实现定义的硬件故障。
SIGEMT:指示一个实现定义的硬件故障。EMT这一名字来自PDP-11的emulator trap 指令。
SIGFPE:此信号表示一个算术运算异常,例如除以0,浮点溢出等。
SIGILL:此信号指示进程已执行一条非法硬件指令。4.3BSD由abort函数产生此信号。SIGABRT现在被用于此。
SIGIOT:这指示一个实现定义的硬件故障。IOT这个名字来自于PDP-11对于输入/输出TRAP(input/outputTRAP)指令的缩写。系统V的早期版本,由abort函数产生此信号。SIGABRT现在被用于此。
SIGQUIT:当用户在终端上按退出键(一般采用Ctrl-/)时,产生此信号,并送至前台进
程组中的所有进程。此信号不仅终止前台进程组(如SIGINT所做的那样),同时产生一个core文件。
SIGSEGV:指示进程进行了一次无效的存储访问。名字SEGV表示“段违例(segmentationviolation)”。
SIGSYS:指示一个无效的系统调用。由于某种未知原因,进程执行了一条系统调用指令,但其指示系统调用类型的参数却是无效的。
SIGTRAP:指示一个实现定义的硬件故障。此信号名来自于PDP-11的TRAP指令。
SIGXCPUSVR4和4.3+BSD支持资源限制的概念。如果进程超过了其软C P U时间限制,则产生此信号。
SIGXFSZ:如果进程超过了其软文件长度限制,则SVR4和4.3+BSD产生此信号。
3, Core_pattern的格式
可以在core_pattern模板中使用变量还很多,见下面的列表:
%% 单个%字符
%p 所dump进程的进程ID
%u 所dump进程的实际用户ID
%g 所dump进程的实际组ID
%s 导致本次core dump的信号
%t core dump的时间 (由1970年1月1日计起的秒数)
%h 主机名
%e 程序文件名
利用Core Dump调试程序
[描述]
这里介绍Linux环境下使用gdb结合core dump文件进行程序的调试和定位。
[简介]
当用户程序运行,可能会由于某些原因发生崩溃(crash),这个时候可以产生一个Core Dump文件,记录程序发生崩溃时候内存的运行状况。这个Core Dump文件,一般名称为core或者core.pid(pid就是应用程序运行时候的pid号),它可以帮助我们找出程序崩溃的原因。
对于一个运行出错的程序,我们可以有多种方法调试它,以便发生错误的原因:a)通过阅读代码;b)通过在代码中设置一些打印语句(插旗子);c)通过使用gdb设置断点来跟踪程序的运行。但是这些方法对于调试程序运行崩溃这样类似的错误,定位都不够迅速,如果程序代码很多的话,显然前面的方法有很多缺陷。在后面,我们来看看另外一种可以定位错误的方法:d)使用gdb结合Core Dump文件来迅速定位到这个错误。这个方法,如果程序运行崩溃,那么可以迅速找到导致程序崩溃的原因。
当然,调试程序,没有哪个方法是最好的,这里只对最后一种方法重点讲解,实际过程中,往往根据需要和其他方法结合使用。
[举例]
下面,给出一个实际的操作过程,描述我们使用gdb调试工具,结合Core Dump文件,定位程序崩溃的位置。
一、程序源代码
下面是我们的程序源代码:
1 #include
2 using std::cerr;
3 using std::endl;
4
5 void my_print(int d1, int d2);
6 int main(int argc, char *argv[])
7 {
8 int a = 1;
9 int b = 2;
10 my_print(a,b);
11 return 0;
12 }
13
14 void my_print(int d1, int d2)
15 {
16 int *p1=&d1;
17 int *p2 = NULL;
18 cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
19 }
这里,程序代码很少,我们可以直接通过代码看到这个程序第17行的p2是NULL,而18行却用*p2来进行引用,明显这样访问一个空的地址是一个错误(也许我们的初衷是使用p2来指向d2)。
我们可以有多种方法调试这个程序,以便发生上面的错误:a)通过阅读代码;b)通过在代码中设置一些打印语句(插旗子);c)通过使用gdb设置断点来跟踪程序的运行。但是这些方法对于这个程序中类似的错误,定位都不够迅速,如果程序代码很多的话,显然前面的方法有很多缺陷。在后面,我们来看看另外一种可以定位错误的方法:d)使用gdb结合Core Dump文件来迅速定位到这个错误。
二、编译程序:
编译过程如下:
[root@lv-k test]# ls
main.cpp
[root@lv-k test]# g++ -g main.cpp
[root@lv-k test]# ls
a.out main.cpp
这样,编译main.cpp生成了可执行文件a.out,一定注意,因为我们要使用gdb进行调试,所以我们使用'g++'的'-g'选项。
三、运行程序
运行过程如下:
[root@lv-k test]# ./a.out
段错误
[root@lv-k test]# ls
a.out main.cpp
这里,如我们所期望的,会打印段错误的信息,但是并没有生成Core Dump文件。
配置生成Core Dump文件的选项,并生成Core Dump:
[root@lv-k test]# ulimit -c unlimited
[root@lv-k test]# ./a.out
段错误 (core dumped)
[root@lv-k test]# ls
a.out core.30557 main.cpp
这里,我们看到,使用'ulimit'配置之后,程序崩溃的时候就会生成Core Dump文件了,这里的文件是core.30557,文件名称不同的系统生成的名称有一点不同,这里linux生成的名称是:"core"+".pid"。
四、调试程序
使用Core Dump文件,结合gdb工具进行调试,过程如下:
1)初步定位:
[root@lv-k test]# gdb a.out core.30557
GNU gdb (GDB) Red Hat Enterprise Linux (7.0.1-23.el5_5.2)
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i386-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/test/a.out...done.
Reading symbols from /usr/lib/libstdc++.so.6...(no debugging symbols found)...done.
Loaded symbols for /usr/lib/libstdc++.so.6
Reading symbols from /lib/libm.so.6...(no debugging symbols found)...done.
Loaded symbols for /lib/libm.so.6
Reading symbols from /lib/libgcc_s.so.1...(no debugging symbols found)...done.
Loaded symbols for /lib/libgcc_s.so.1
Reading symbols from /lib/libc.so.6...(no debugging symbols found)...done.
Loaded symbols for /lib/libc.so.6
Reading symbols from /lib/ld-linux.so.2...(no debugging symbols found)...done.
Loaded symbols for /lib/ld-linux.so.2
Core was generated by `./a.out'.
Program terminated with signal 11, Segmentation fault.
#0 0x0804870e in my_print (d1=1, d2=2) at main.cpp:18
18 cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
这里,我们就进入了gdb的调试交互界面,看到gdb直接定位到导致程序出错的位置了。我们还可以使用如下命令:"#gdb a.out --core=core.30557"。
通过错误,我们知道程序由于"signal 11"导致终止,如果想要大致了解"signal 11",那么我们可查看signal的man手册:
#man 7 signal
这样,在输出的信息中我们可以看见“SIGSEGV 11 Core Invalid memory reference”这样的字样,意思是说,signal(信号)11表示非法内存引用。注意这里使用"man 7 signal"而不是"man signal",因为我们要查看的不是signal函数或者signal命令,而是signal的其他信息,其他的信息在man手册的第7节,具体需要了解一些使用man的命令。
2)查看具体调用关系
(gdb) bt
#0 0x0804870e in my_print (d1=1, d2=2) at main.cpp:18
#1 0x08048799 in main (argc=, argv=) at main.cpp:10
这里,我们通过backtrace(简写为bt)命令可以看到,导致崩溃那条语句是通过什么调用途径被调用到的。
3)设置断点,并进行调试等:
(gdb) b main.cpp:10
Breakpoint 1 at 0x8048787: file main.cpp, line 10.
(gdb) r
Starting program: /root/test/a.out
Breakpoint 1, main (argc=, argv=) at main.cpp:10
10 my_print(a,b);
(gdb) s
my_print (d1=1, d2=2) at main.cpp:16
16 int *p1=&d1;
(gdb) n
17 int *p2 = NULL;
(gdb) n
18 cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
(gdb) p d1
$1 = 1
(gdb) p d2
$2 = 2
(gdb) p *p1
$1 = 1
(gdb) p *p2
Cannot access memory at address 0x0
(gdb) n
Program received signal SIGSEGV, Segmentation fault.
0x0804870e in my_print (d1=1, d2=2) at main.cpp:18
18 cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
这里,我们在开始初步的定位的基础上,通过设置断点(break),运行(run),gdb的单步跟进(step),单步跳过(next),变量的打印(print)等各种gdb命令,来了解产生崩溃时候的具体情况,确定产生崩溃的原因。
4)退出gdb:
(gdb) q
A debugging session is active.
Inferior 3 [process 30584] will be killed.
Inferior 1 [process 1] will be killed.
Quit anyway? (y or n) y
Quitting: Couldn't get registers: 没有那个进程.
[root@lv-k test]#
[root@lv-k test]# ls
a.out core.30557 core.30609 main.cpp
这里,我们看到又产生了一个core文件。因为刚才调试,导致又产生了一个core文件。实际,如果我们只使用"gdb a.out core.30557"初步定位之后,不进行调试就退出gdb的话,就不会再生成core文件。
五、修正错误
1)通过上面的过程我们最终修正错误,得到正确的源代码如下:
1 #include
2 using std::cerr;
3 using std::endl;
4
5 void my_print(int d1, int d2);
6 int main(int argc, char *argv[])
7 {
8 int a = 1;
9 int b = 2;
10 my_print(a,b);
11 return 0;
12 }
13
14 void my_print(int d1, int d2)
15 {
16 int *p1=&d1;
17 //int *p2 = NULL;//lvkai-
18 int *p2 = &d2;//lvkai+
19 cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
20 }
2)编译并运行这个程序,最终产生结果如下:
[root@lv-k test]# g++ main.cpp
[root@lv-k test]# ls
a.out main.cpp
[root@lv-k test]# ./a.out
first is:1,second is:2
这里,得到了我们预期的结果。
另外,有个小技巧,如果对Makefile有些了解的话可以充分利用make的隐含规则来编译单个源文件的程序,
过程如下:
[root@lv-k test]# ls
main.cpp
[root@lv-k test]# make main
g++ main.cpp -o main
[root@lv-k test]# ls
main main.cpp
[root@lv-k test]# ./main
first is:1,second is:2
这里注意,make的目标参数必须是源文件"main.cpp"去掉后缀之后的"main",等价于"g++ main.cpp -o main",这样编译的命令比较简单。
[其它]
其它内容有待添加。
认真地工作并且思考,是最好的老师。在工作的过程中思考自己所缺乏的技术,以及学习他人的经验,才能在工作中有所收获。这篇文章原来是工作中我的一个同事加朋友的经验,我站在这样的经验的基础上,进行了这样总结。
一,什么是coredump
我们经常听到大家说到程序core掉了,需要定位解决,这里说的大部分是指对应程序由于各种异常或者bug导致在运行过程中异常退出或者中止,并且在满足一定条件下(这里为什么说需要满足一定的条件呢?下面会分析)会产生一个叫做core的文件。
通常情况下,core文件会包含了程序运行时的内存,寄存器状态,堆栈指针,内存管理信息还有各种函数调用堆栈信息等,我们可以理解为是程序工作当前状态存储生成第一个文件,许多的程序出错的时候都会产生一个core文件,通过工具分析这个文件,我们可以定位到程序异常退出的时候对应的堆栈调用等信息,找出问题所在并进行及时解决。
二,coredump文件的存储位置
core文件默认的存储位置与对应的可执行程序在同一目录下,文件名是core,大家可以通过下面的命令看到core文件的存在位置:
cat /proc/sys/kernel/core_pattern
缺省值是core
注意:这里是指在进程当前工作目录的下创建。通常与程序在相同的路径下。但如果程序中调用了chdir函数,则有可能改变了当前工作目录。这时core文件创建在chdir指定的路径下。有好多程序崩溃了,我们却找不到core文件放在什么位置。和chdir函数就有关系。当然程序崩溃了不一定都产生 core文件。
如下程序代码:则会把生成的core文件存储在/data/coredump/wd,而不是大家认为的跟可执行文件在同一目录。
通过下面的命令可以更改coredump文件的存储位置,若你希望把core文件生成到/data/coredump/core目录下:
echo “/data/coredump/core”> /proc/sys/kernel/core_pattern
注意,这里当前用户必须具有对/proc/sys/kernel/core_pattern的写权限。
缺省情况下,内核在coredump时所产生的core文件放在与该程序相同的目录中,并且文件名固定为core。很显然,如果有多个程序产生core文件,或者同一个程序多次崩溃,就会重复覆盖同一个core文件,因此我们有必要对不同程序生成的core文件进行分别命名。
我们通过修改kernel的参数,可以指定内核所生成的coredump文件的文件名。例如,使用下面的命令使kernel生成名字为core.filename.pid格式的core dump文件:
echo “/data/coredump/core.%e.%p” >/proc/sys/kernel/core_pattern
这样配置后,产生的core文件中将带有崩溃的程序名、以及它的进程ID。上面的%e和%p会被替换成程序文件名以及进程ID。
如果在上述文件名中包含目录分隔符“/”,那么所生成的core文件将会被放到指定的目录中。 需要说明的是,在内核中还有一个与coredump相关的设置,就是/proc/sys/kernel/core_uses_pid。如果这个文件的内容被配置成1,那么即使core_pattern中没有设置%p,最后生成的core dump文件名仍会加上进程ID。
三,如何判断一个文件是coredump文件?
在类unix系统下,coredump文件本身主要的格式也是ELF格式,因此,我们可以通过readelf命令进行判断。
可以看到ELF文件头的Type字段的类型是:CORE (Core file)
可以通过简单的file命令进行快速判断:
四,产生coredum的一些条件总结
1, 产生coredump的条件,首先需要确认当前会话的ulimit –c,若为0,则不会产生对应的coredump,需要进行修改和设置。
ulimit -c unlimited (可以产生coredump且不受大小限制)
若想甚至对应的字符大小,则可以指定:
ulimit –c [size]
可以看出,这里的size的单位是blocks,一般1block=512bytes
如:
ulimit –c 4 (注意,这里的size如果太小,则可能不会产生对应的core文件,笔者设置过ulimit –c 1的时候,系统并不生成core文件,并尝试了1,2,3均无法产生core,至少需要4才生成core文件)
但当前设置的ulimit只对当前会话有效,若想系统均有效,则需要进行如下设置:
Ø 在/etc/profile中加入以下一行,这将允许生成coredump文件
ulimit-c unlimited
Ø 在rc.local中加入以下一行,这将使程序崩溃时生成的coredump文件位于/data/coredump/目录下:
echo /data/coredump/core.%e.%p> /proc/sys/kernel/core_pattern
注意rc.local在不同的环境,存储的目录可能不同,susu下可能在/etc/rc.d/rc.local
更多ulimit的命令使用,可以参考:http://baike.baidu.com/view/4832100.htm
这些需要有root权限, 在ubuntu下每次重新打开中断都需要重新输入上面的ulimit命令, 来设置core大小为无限.
2, 当前用户,即执行对应程序的用户具有对写入core目录的写权限以及有足够的空间。
3, 几种不会产生core文件的情况说明:
The core file will not be generated if
(a) the process was set-user-ID and the current user is not the owner of the program file, or
(b) the process was set-group-ID and the current user is not the group owner of the file,
(c) the user does not have permission to write in the current working directory,
(d) the file already exists and the user does not have permission to write to it, or
(e) the file is too big (recall the RLIMIT_CORE limit in Section 7.11). The permissions of the core file (assuming that the file doesn't already exist) are usually user-read and user-write, although Mac OS X sets only user-read.
五,coredump产生的几种可能情况
造成程序coredump的原因有很多,这里总结一些比较常用的经验吧:
1,内存访问越界
a) 由于使用错误的下标,导致数组访问越界。
b) 搜索字符串时,依靠字符串结束符来判断字符串是否结束,但是字符串没有正常的使用结束符。
c) 使用strcpy, strcat, sprintf, strcmp,strcasecmp等字符串操作函数,将目标字符串读/写爆。应该使用strncpy, strlcpy, strncat, strlcat, snprintf, strncmp, strncasecmp等函数防止读写越界。
2,多线程程序使用了线程不安全的函数。
应该使用下面这些可重入的函数,它们很容易被用错:
asctime_r(3c) gethostbyname_r(3n) getservbyname_r(3n)ctermid_r(3s) gethostent_r(3n) getservbyport_r(3n) ctime_r(3c) getlogin_r(3c)getservent_r(3n) fgetgrent_r(3c) getnetbyaddr_r(3n) getspent_r(3c)fgetpwent_r(3c) getnetbyname_r(3n) getspnam_r(3c) fgetspent_r(3c)getnetent_r(3n) gmtime_r(3c) gamma_r(3m) getnetgrent_r(3n) lgamma_r(3m) getauclassent_r(3)getprotobyname_r(3n) localtime_r(3c) getauclassnam_r(3) etprotobynumber_r(3n)nis_sperror_r(3n) getauevent_r(3) getprotoent_r(3n) rand_r(3c) getauevnam_r(3)getpwent_r(3c) readdir_r(3c) getauevnum_r(3) getpwnam_r(3c) strtok_r(3c) getgrent_r(3c)getpwuid_r(3c) tmpnam_r(3s) getgrgid_r(3c) getrpcbyname_r(3n) ttyname_r(3c)getgrnam_r(3c) getrpcbynumber_r(3n) gethostbyaddr_r(3n) getrpcent_r(3n)
3,多线程读写的数据未加锁保护。
对于会被多个线程同时访问的全局数据,应该注意加锁保护,否则很容易造成coredump
4,非法指针
a) 使用空指针
b) 随意使用指针转换。一个指向一段内存的指针,除非确定这段内存原先就分配为某种结构或类型,或者这种结构或类型的数组,否则不要将它转换为这种结构或类型的指针,而应该将这段内存拷贝到一个这种结构或类型中,再访问这个结构或类型。这是因为如果这段内存的开始地址不是按照这种结构或类型对齐的,那么访问它时就很容易因为bus error而core dump。
5,堆栈溢出
不要使用大的局部变量(因为局部变量都分配在栈上),这样容易造成堆栈溢出,破坏系统的栈和堆结构,导致出现莫名其妙的错误。
六,利用gdb进行coredump的定位
其实分析coredump的工具有很多,现在大部分类unix系统都提供了分析coredump文件的工具,不过,我们经常用到的工具是gdb。
这里我们以程序为例子来说明如何进行定位。
1, 段错误 – segmentfault
Ø 我们写一段代码往受到系统保护的地址写内容。
Ø 按如下方式进行编译和执行,注意这里需要-g选项编译。
可以看到,当输入12的时候,系统提示段错误并且core dumped
Ø 我们进入对应的core文件生成目录,优先确认是否core文件格式并启用gdb进行调试。
从红色方框截图可以看到,程序中止是因为信号11,且从bt(backtrace)命令(或者where)可以看到函数的调用栈,即程序执行到coremain.cpp的第5行,且里面调用scanf 函数,而该函数其实内部会调用_IO_vfscanf_internal()函数。
接下来我们继续用gdb,进行调试对应的程序。
记住几个常用的gdb命令:
l(list) ,显示源代码,并且可以看到对应的行号;
b(break)x, x是行号,表示在对应的行号位置设置断点;
p(print)x, x是变量名,表示打印变量x的值
r(run), 表示继续执行到断点的位置
n(next),表示执行下一步
c(continue),表示继续执行
q(quit),表示退出gdb
启动gdb,注意该程序编译需要-g选项进行。
注: SIGSEGV 11 Core Invalid memoryreference
七,附注:
1, gdb的查看源码
显示源代码
GDB 可以打印出所调试程序的源代码,当然,在程序编译时一定要加上-g的参数,把源程序信息编译到执行文件中。不然就看不到源程序了。当程序停下来以后,GDB会报告程序停在了那个文件的第几行上。你可以用list命令来打印程序的源代码。还是来看一看查看源代码的GDB命令吧。
list
显示程序第linenum行的周围的源程序。
list
显示函数名为function的函数的源程序。
list
显示当前行后面的源程序。
list -
显示当前行前面的源程序。
一般是打印当前行的上5行和下5行,如果显示函数是是上2行下8行,默认是10行,当然,你也可以定制显示的范围,使用下面命令可以设置一次显示源程序的行数。
setlistsize
设置一次显示源代码的行数。
showlistsize
查看当前listsize的设置。
list命令还有下面的用法:
list,
显示从first行到last行之间的源代码。
list ,
显示从当前行到last行之间的源代码。
list +
往后显示源代码。
一般来说在list后面可以跟以下这些参数:
行号。
<+offset> 当前行号的正偏移量。
<-offset> 当前行号的负偏移量。
哪个文件的哪一行。
函数名。
哪个文件中的哪个函数。
<*address> 程序运行时的语句在内存中的地址。
2, 一些常用signal的含义
SIGABRT:调用abort函数时产生此信号。进程异常终止。
SIGBUS:指示一个实现定义的硬件故障。
SIGEMT:指示一个实现定义的硬件故障。EMT这一名字来自PDP-11的emulator trap 指令。
SIGFPE:此信号表示一个算术运算异常,例如除以0,浮点溢出等。
SIGILL:此信号指示进程已执行一条非法硬件指令。4.3BSD由abort函数产生此信号。SIGABRT现在被用于此。
SIGIOT:这指示一个实现定义的硬件故障。IOT这个名字来自于PDP-11对于输入/输出TRAP(input/outputTRAP)指令的缩写。系统V的早期版本,由abort函数产生此信号。SIGABRT现在被用于此。
SIGQUIT:当用户在终端上按退出键(一般采用Ctrl-/)时,产生此信号,并送至前台进
程组中的所有进程。此信号不仅终止前台进程组(如SIGINT所做的那样),同时产生一个core文件。
SIGSEGV:指示进程进行了一次无效的存储访问。名字SEGV表示“段违例(segmentationviolation)”。
SIGSYS:指示一个无效的系统调用。由于某种未知原因,进程执行了一条系统调用指令,但其指示系统调用类型的参数却是无效的。
SIGTRAP:指示一个实现定义的硬件故障。此信号名来自于PDP-11的TRAP指令。
SIGXCPUSVR4和4.3+BSD支持资源限制的概念。如果进程超过了其软C P U时间限制,则产生此信号。
SIGXFSZ:如果进程超过了其软文件长度限制,则SVR4和4.3+BSD产生此信号。
3, Core_pattern的格式
可以在core_pattern模板中使用变量还很多,见下面的列表:
%% 单个%字符
%p 所dump进程的进程ID
%u 所dump进程的实际用户ID
%g 所dump进程的实际组ID
%s 导致本次core dump的信号
%t core dump的时间 (由1970年1月1日计起的秒数)
%h 主机名
%e 程序文件名
https://www.cnblogs.com/fireway/p/6158474.html
对于程序员来说,debug的时间往往比写程序的时间还要长。尤其对我这种专写bug为主的程序员来说,一个好的调试器意味着早点下班和休息。现在方便的调试器很多,有著名的Visual Studio(VS)等IDE,也有免费的Windbg和GDB等等。加个断点也很简单,就是按一下键而已。但你有没有想过,调试器Debugger并不能控制程序的执行顺序,为什么它可以让CPU在需要的地方停住呢?
今天我们就来揭开调试断点的神秘面纱,并通过一个实例来看看调试器实际都做了些什么。调试器能够随心所欲的停止程序的执行,主要通过软件断点和硬件断点两种方式。
软件断点
软件断点在X86系统中就是指令INT 3,它的二进制代码opcode是0xCC。当程序执行到INT 3指令时,会引发软件中断。操作系统的INT 3中断处理器会寻找注册在该进程上的调试处理程序。从而像Windbg和VS等等调试器就有了上下其手的机会。
我们先通过一个例子来看看调试器都倒了什么鬼:
#include
int main ()
{
// This loop takes some time so that we
// get a chance to examine the address of
// the breakpoint at the second printf
for (int i = 1; i < 100000000; i++)
printf("Hello World!");
for (int i = 1; i < 10000000; i++)
printf("Hello World!");
return 0;
}
这是一个比较傻的Hello World程序。我们用Windbg打开它,并设置一个断点:
这时Windbg会将自己Attach到该程序的进程,通过程序PE文件的debug节找到调试信息。在调试信息里面找到加断点行所在的机器代码,并把头一个字节用WriteProcessMemory()函数换成0xCC(INT 3)。
让我们来验证一下:
推荐点开全屏看,可能更清楚
注意左边是Windbg窗口,右边是用Process view打开的进程空间,左右的红框是对应的。在我们设置断点之前,左右的内容是完全一样的,这里要特别注意printf编译出来的第一个二进制代码0x68。接下来我们设置断点,并开始运行,那100万个printf让我们有充分的时间,看看发生了什么:
我们会发现push操作代码0x68600e2900的第一个字节被windbg换成了0xCC也就是INT 3。这样windbg就可以在执行到这里时被调度。
不一会,windbg的断点到了:
到达断点后,操作符又被还原为0x68,似乎什么都没有发生,用户被蒙在鼓里,是不是很有意思?
实际上,一般情况下,调试器维护了一大组调试断点,在并把他们都换成了INT 3。在被调度回来后,会都填回去,并通过现在的地址判断是到了那个断点。软件断点没有数目限制。
https://zhuanlan.zhihu.com/p/34003929
硬件断点
X86系统提供8个调试寄存器(DR0~DR7)和2个MSR用于硬件调试。其中前四个DR0~DR3是硬件断点寄存器,可以放入内存地址或者IO地址,还可以设置为执行、修改等条件。CPU在执行的到这里并满足条件会自动停下来。
硬件断点十分强大,但缺点是只有四个,这也是为什么所有调试器的硬件断点只能设置4个原因。我们在调试不能修改的ROM时,只能选择这个,所以要省着点用,在一般情况下还是尽量选择软件断点。
还有个INT 1是单步调试命令,这里略过。
其他
Visual Studio有个有趣的特性是debug编译后,会把0xcc(INT 3)填入代码的空隙,这样一旦程序越界就会被VS捕捉而容易发现错误。而0xCCCC在中国的GBK编码是“烫”。有中国程序员翻看内存到代码段会发现很多"烫烫烫",不明所以,以为发生了什么神奇的事情。
调试断点原理
调试断点,依赖于父进程和子进程之间的通信,打断点实际是在被调试的程序中,改变断点附近程序的代码,这个断点使得被调试的程序,暂时停止,然后发送信号给父进程(调试器进程),然后父进程能够得到子进程的变量和状态。达到调试的目的。
修改断点附近程序的指令地址为0xcc,这个地址的指令就是int 3,含义是,是当前用户态程序发生中断,告诉内核当前程序有断点,那么内核中会向当前进程发送SIGTRAP信号,使当前进程暂停。父进程调用wait函数,等待子进程的运行状态发生改变,这时子进程由于int 3中断,子进程暂停,父进程就可以开始调试子进程的程序了。
自己写的一个小例子:
被调试的程序:
#include
int main()
{
printf( "~~~~~~~~~~~~> Before breakpoint\n" );
printf( "~~~~~~~~~~~~> After breakpoint\n" );
printf( "~~~~~~~~~~~~> final\n" );
return 0;
}
我们希望在程序执行第二个printf时,打断点,对break.o执行objdump -S break.o
00000000004004f4
:
#include
int main()
{
4004f4: 55 push %rbp
4004f5: 48 89 e5 mov %rsp,%rbp
printf( "~~~~~~~~~~~~> Before breakpoint\n" );
4004f8: bf 10 06 40 00 mov $0x400610,%edi
4004fd: e8 ee fe ff ff callq 4003f0
printf( "~~~~~~~~~~~~> After breakpoint\n" );
400502: bf 30 06 40 00 mov $0x400630,%edi
400507: e8 e4 fe ff ff callq 4003f0
printf( "~~~~~~~~~~~~> final\n" );
40050c: bf 4f 06 40 00 mov $0x40064f,%edi
400511: e8 da fe ff ff callq 4003f0
return 0;
400516: b8 00 00 00 00 mov $0x0,%eax
}
通过上面dump,想在输出 After breakpoint这行代码打断点,可行的做法就是把地址为0x400502这一行代码的保存起来,然后把它修改成0xcc,如果想继续执行的时候,在把原来的代码复写到原来的地方,再通知子进程执行即可。
测试程序代码:
11 int main(void)
12 {
13 int wait_val;
14 int pid;
15 long addr;
16 long data;
17 long orginData;
18 struct user_regs_struct regs;
19 setvbuf(stdout,NULL,_IONBF,0); //printf stdout 默认行缓冲,setvbuf输出无buf,直接输出
20 switch (pid = fork()) {
21 case -1:
22 perror("fork");
23 break;
24 case 0:
25 ptrace(PTRACE_TRACEME, 0, 0, 0); //子进程设置traceme,使得父进程trace子进程
26 execl("/home/djj/tmp/break.o", NULL, NULL);
27 default:
28 wait(&wait_val); //子进程设置了traceme,在执行exec函数的时候,内核会首先产生SIGTRAP信号,先给父进程trace子进程的一个机会。
29 ptrace(PTRACE_GETREGS,pid, 0, ®s);
30 addr = 0x400502; //需要打断点的程序地址
31 data=ptrace( PTRACE_PEEKTEXT, pid, (void *)addr,NULL); //获得程序代码
32 orginData = (data & ~0xff) | 0xcc; //设置代码为int 3指令,中断指令
33 ptrace( PTRACE_POKETEXT, pid, (void *)addr, orginData ); //把代码写到内存中
34 ptrace(PTRACE_CONT, pid, NULL, NULL); //通知子进程继续执行
35 wait(&wait_val); //等待子进程程序执行到断点,产生SIGTRAP信号
36 if(WIFSTOPPED(wait_val)){
37 ptrace(PTRACE_GETREGS,pid, 0, ®s); //取出rip的值
38 regs.rip-=1; //要重新执行被替换的指令,这里rip必须减一。
39 printf("break\n");
40 ptrace(PTRACE_SETREGS,pid,0,®s);
41 ptrace(PTRACE_POKETEXT,pid,(void *)addr,data);
42 ptrace(PTRACE_CONT, pid,NULL,NULL); //子进程继续执行
43 }
44 wait(NULL);
45 }
46 return 0;
47 }
运行结果:
这里值得注意的一点就是子进程在调用了traceme后,如果执行exec函数,会产生SIGTRAP信号,首先看traceme做的一些事情:
222 int ptrace_traceme(void)
223 {
224 int ret = -EPERM;
225
226 write_lock_irq(&tasklist_lock);
227 /* Are we already being traced? */
228 if (!current->ptrace) {
229 ret = security_ptrace_traceme(current->parent);
230 /*
231 * Check PF_EXITING to ensure ->real_parent has not passed
232 * exit_ptrace(). Otherwise we don't report the error but
233 * pretend ->real_parent untraces us right after return.
234 */
235 if (!ret && !(current->real_parent->flags & PF_EXITING)) {
236 current->ptrace = PT_PTRACED; //设置进程的ptrace为 PT_PTRACED,标志子进程被父进程trace
237 __ptrace_link(current, current->real_parent);
238 }
239 }
240 write_unlock_irq(&tasklist_lock);
241
242 return ret;
243 }
子进程ptrace标志了 PT_PTRACED,在执行exec函数的时候,会去触发SIGTRAP信号
1315 bprm->recursion_depth = depth;
1316 if (retval >= 0) {
1317 if (depth == 0)
1318 tracehook_report_exec(fmt, bprm, regs); //产生SIGTRAP信号
tracehook_report_exec函数的实现:
200 static inline void tracehook_report_exec(struct linux_binfmt *fmt,
201 struct linux_binprm *bprm,
202 struct pt_regs *regs)
203 {
204 if (!ptrace_event(PT_TRACE_EXEC, PTRACE_EVENT_EXEC, 0) &&
205 unlikely(task_ptrace(current) & PT_PTRACED)) //如果标志了 PT_PTRACED
206 send_sig(SIGTRAP, current, 0); //那么就向当前进程发送SIGTRAP信号,使得当前进程暂停
207 }
exec函数没研究过,猜测逻辑应该是,按照object文件中的代码段,数据段设置内存结构,在最后将指向下一条指令的地址指向刚刚代码段的起始地址,那么在程序返回用户态之后,就会按照新加载代码段开始的地方开始执行程序。内核中在加载完内存结构之后,如果当前进程标志了ptrace字段,那么暂停当前进程,通知trace的父进程。
系统在初始化的过程中已经定义了int3的中断门
822 void __init early_trap_init(void)
823 {
824 set_intr_gate_ist(1, &debug, DEBUG_STACK);
825 /* int3 can be called from all */
826 set_system_intr_gate_ist(3, &int3, DEBUG_STACK); //定义了int3的中断门
827 set_intr_gate(14, &page_fault);
828 load_idt(&idt_descr);
829 }
中断门的int3,联系到了arch/x86/kernel/entry_32.S
1476 ENTRY(int3)
1477 RING0_INT_FRAME
1478 pushl $-1 # mark this as an int
1479 CFI_ADJUST_CFA_OFFSET 4
1480 SAVE_ALL
1481 TRACE_IRQS_OFF
1482 xorl %edx,%edx # zero error code
1483 movl %esp,%eax # pt_regs pointer
1484 call do_int3 //执行do_int3函数
1485 jmp ret_from_exception
1486 CFI_ENDPROC
1487 END(int3)
可以看到最后用户态进程产生的int3中断,会触发执行do_int3函数,其中的一部分代码:
470 preempt_conditional_sti(regs);
471 do_trap(3, SIGTRAP, "int3", regs, error_code, NULL);
472 preempt_conditional_cli(regs);
最终看到调用了do_trap函数,这个函数的作用就是给当前进程发送SIGTRAP信号,使得当前进程暂停,同时这个进程的暂停,就会唤醒wait函数。使得父进程调用ptrace函数来获得子进程的相关信息。
对于一个进程想要去调试一个正在运行的进程,那么会调用ptrace,请求PTRACE_ATTACH去attach一个pid,这个原理很简单。就是通过当前namespace根据pid,得到task_struct,这个原理请参考pid Namespace浅分析。把ptrace字段设置成为PT_PTRACED。同时这个子进程向自己发送SIGSTOP信号,个人觉得这个暂停的意义就是给父进程一个机会,去设置断点等信息。
700 child = ptrace_get_task_struct(pid); //根据pid和namespace得到task_struct
701 if (IS_ERR(child)) {
702 ret = PTR_ERR(child);
703 goto out;
704 }
705
706 if (request == PTRACE_ATTACH) { //如果是attach请求
707 ret = ptrace_attach(child); //设置ptrace字段为PT_PTRACED
708 /*
709 * Some architectures need to do book-keeping after
710 * a ptrace attach.
711 */
712 if (!ret)
713 arch_ptrace_attach(child);
714 goto out_put_task_struct;
715 }
ptrace_attach函数的具体逻辑:
200 task->ptrace = PT_PTRACED; //设置ptrace字段为PT_PTRACED
201 if (capable(CAP_SYS_PTRACE))
202 task->ptrace |= PT_PTRACE_CAP;
203
204 __ptrace_link(task, current);
205 send_sig_info(SIGSTOP, SEND_SIG_FORCED, task); //向pid那个进程发送暂停信号SIGSTOP
总结:
调试的大体原理:通过设置被调试的进程ptrace字段,标志这个进程被trace,断点附近的程序代码被替换成了int 3,中断程序,引发了do_int3函数,导致了被trace进程的暂停,这样父进程就能通过ptrace系统调用获得子进程的运行情况了。以上分析代表个人观点,个人水平有限,不正确的地方希望大家指出,积极讨论。
参考文章:
1.http://blog.csdn.net/dog250/article/details/5303228
https://www.cnblogs.com/alantu2018/p/8997173.html
调试体系JPDA
JPDA(Java Platform Debugger Architecture)是 sun 公司开发的 java平台调试体系,它主要有三个层次组成,即 Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP)以及 Java 调试接口(JDI)
JVMTI(JVMDI): jdk1.4 之前称为JVMDI,之后改为了JVMTI,它是虚拟机的本地接口,其相当于 Thread 的 sleep、yield native 方法
JDWP(Java Debug Wire Protocol):java调试网络协议,其描述了调试信息的格式,以及在被调试的进程(server)和调试器(client)之间传输的请求
JDI:java调试接口,虚拟机的高级接口,调试器(client)自己实现 JDI 接口,比如 idea、eclipse 等
下面使用两张图更直观的了解JPDA的三个模块层次
1、JPDA模块层次
2、JPDA层次比较
idea 或者 eclipse 调试原理
当我们在 idea 或者 eclipse 中以 debug 模式启动运行类,就可以直接调试了,这其中的原理令人不解,下面就给大家介绍一下
客户端(idea 、eclipse 等)之所以可以进行调试,是由于客户端 和 服务端(程序端)进行了 socket 通信,通信过程如下:
1、先建立起了 socket 连接
2、将断点位置创建了断点事件通过 JDI 接口传给了 服务端(程序端)的 VM,VM 调用 suspend 将 VM 挂起
3、VM 挂起之后将客户端需要获取的 VM 信息返回给客户端,返回之后 VM resume 恢复其运行状态
4、客户端获取到 VM 返回的信息之后可以通过不同的方式展示给客户
上述过程便是一个完整的 debug 调试过程,下面通过示例来进一步说明一下这个过程
使用 idea debug 调试一个类,过程如下图:
idea 和 程序之间建立了 socket 连接,ip 是 本机,端口是 52690,注意这个端口不是固定的,每次都会变动
cmd 中使用 netstat -ano | findstr 52690 查看该监听端口 52690 使用的进程
上图可以看出,idea 调试客户端 进程id 是 5472,程序调试服务器端 进程id 是 27600,两者之间建立了连接进行通信
服务端之所以可以和客户端建立起连接,是由于调试服务器端加了一句话,打开了调试,如下图:
cmd 中 使用 jps -v | findstr HelloWorld 查找进程信息
上图看出,HelloWorld 程序的进程id 确实是 27600,并在启动时添加了以下这句话:
-agentlib:jdwp=transport=dt_socket,address=127.0.0.1:52690,suspend=y,server=n
vm 挂起后将调试信息返回给客户端,客户端可以展示给用户,如下图:
debug调试示例demo
我们下面使用示例代码来调试运行一下某行代码的某个变量值,如下图:
1、新建调试程序代码
package com.demo.debug;
public class HelloWorld {
public static void main(String[] args) {
String str = "Hello world!";
System.out.println(str);
}
}
2、调试程序客户端代码
package com.demo.debug;
import com.sun.jdi.Bootstrap;
import com.sun.jdi.LocalVariable;
import com.sun.jdi.Location;
import com.sun.jdi.ReferenceType;
import com.sun.jdi.StackFrame;
import com.sun.jdi.StringReference;
import com.sun.jdi.ThreadReference;
import com.sun.jdi.Value;
import com.sun.jdi.VirtualMachine;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.connect.LaunchingConnector;
import com.sun.jdi.event.BreakpointEvent;
import com.sun.jdi.event.ClassPrepareEvent;
import com.sun.jdi.event.Event;
import com.sun.jdi.event.EventIterator;
import com.sun.jdi.event.EventQueue;
import com.sun.jdi.event.EventSet;
import com.sun.jdi.event.VMDisconnectEvent;
import com.sun.jdi.event.VMStartEvent;
import com.sun.jdi.request.BreakpointRequest;
import com.sun.jdi.request.ClassPrepareRequest;
import com.sun.jdi.request.EventRequest;
import com.sun.jdi.request.EventRequestManager;
import java.util.List;
import java.util.Map;
public class SimpleDebugger {
static VirtualMachine vm;
static Process process;
static EventRequestManager eventRequestManager;
static EventQueue eventQueue;
static EventSet eventSet;
static boolean vmExit = false;
public static void main(String[] args) throws Exception {
LaunchingConnector launchingConnector
= Bootstrap.virtualMachineManager().defaultConnector();
// Get arguments of the launching connector
Map<String, Connector.Argument> defaultArguments
= launchingConnector.defaultArguments();
Connector.Argument mainArg = defaultArguments.get("main");
Connector.Argument suspendArg = defaultArguments.get("suspend");
// Set class of main method
mainArg.setValue("com.demo.debug.HelloWorld");
suspendArg.setValue("true");
vm = launchingConnector.launch(defaultArguments);
process = vm.process();
// Register ClassPrepareRequest
eventRequestManager = vm.eventRequestManager();
ClassPrepareRequest classPrepareRequest
= eventRequestManager.createClassPrepareRequest();
classPrepareRequest.addClassFilter("com.demo.debug.HelloWorld");
classPrepareRequest.addCountFilter(1);
classPrepareRequest.setSuspendPolicy(EventRequest.SUSPEND_ALL);
classPrepareRequest.enable();
// Enter event loop
eventLoop();
process.destroy();
}
private static void eventLoop() throws Exception {
eventQueue = vm.eventQueue();
while (true) {
if (vmExit == true) {
break;
}
eventSet = eventQueue.remove();
EventIterator eventIterator = eventSet.eventIterator();
while (eventIterator.hasNext()) {
Event event = (Event) eventIterator.next();
execute(event);
}
}
}
private static void execute(Event event) throws Exception {
if (event instanceof VMStartEvent) {
System.out.println("VM started");
eventSet.resume();
} else if (event instanceof ClassPrepareEvent) {
ClassPrepareEvent classPrepareEvent = (ClassPrepareEvent) event;
String mainClassName = classPrepareEvent.referenceType().name();
if (mainClassName.equals("com.demo.debug.HelloWorld")) {
System.out.println("Class " + mainClassName
+ " is already prepared");
}
if (true) {
// Get location
ReferenceType referenceType = classPrepareEvent.referenceType();
List locations = referenceType.locationsOfLine(10);
Location location = (Location) locations.get(0);
// Create BreakpointEvent
BreakpointRequest breakpointRequest = eventRequestManager
.createBreakpointRequest(location);
breakpointRequest.setSuspendPolicy(EventRequest.SUSPEND_ALL);
breakpointRequest.enable();
}
eventSet.resume();
} else if (event instanceof BreakpointEvent) {
System.out.println("Reach line 10 of com.demo.debug.HelloWorld");
BreakpointEvent breakpointEvent = (BreakpointEvent) event;
ThreadReference threadReference = breakpointEvent.thread();
StackFrame stackFrame = threadReference.frame(0);
LocalVariable localVariable = stackFrame
.visibleVariableByName("str");
Value value = stackFrame.getValue(localVariable);
String str = ((StringReference) value).value();
System.out.println("The local variable str at line 10 is " + str
+ " of " + value.type().name());
eventSet.resume();
} else if (event instanceof VMDisconnectEvent) {
vmExit = true;
} else {
eventSet.resume();
}
}
}
3、下载 jdi.jar 包,然后导入到工程中
4、运行测试,步骤如下
1)、cmd 切换到项目的根目录下,如下图:
2)、编译文件
cmd 执行 javac -g -cp "D:\Program Files\Java\jdk1.8.0_181\lib\tools.jar" com\demo\debug\*.java 命令,如下图:
3)、运行文件
cmd 执行 java -cp ".;D:\Program Files\Java\jdk1.8.0_181\lib\tools.jar" com.demo.debug.SimpleDebugger 命令,运行结果如下图:
调试服务器VM调试处理机制
这里有个问题需要思考一下,当debug 时,VM 是如何处理是否有断点的呢?基本上有两种猜想:一是 VM 执行代码的时候主动检查这行代码是否有断点需要处理,二是客户端动态修改了编译文件的字节码,在需要断点的地方加上了标识
对于第一种猜想,需要看 JVM 的 C 语言源码,目前这个先搁置放一下,对于第二种猜想比较好验证,只需要动态dump出类的class文件即可
dump 出 HelloWorld 文件的 class 文件步骤:
1、下载 dumpclass 文件,放到新建的一个目录下,如图:
2、cmd 中使用 jps 命令查看应用进程id号,如图:
3、cmd 切换到此目录下执行 java -cp "$JAVA_HOME\lib\sa-jdi.jar" -jar dumpclass.jar -p 1448 *HelloWorld 命令,如下图:
反编译 dump出来的 HelloWorld.class 文件,如下图:
结论:VM 是通过主动的方式检查执行的每行代码是否有断点需要处理
如何远程(remote)调试
使用IDE调试是大家最常用的方式,比如idea、eclipse等,运行时候选择debug模式即可,那么如果使用远程调试怎么做的呢?其实很简单,就是启动项目时加上一些参数而已
一、spring web 项目
小于 tomcat9 版本
tomcat 中 bin/catalina.sh 中增加 CATALINA_OPTS='-server -Xdebug -Xnoagent -Djava.compiler=NONE -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=18006',如下图所示:
大于等于 tomcat9 版本
tomcat 中 bin/catalina.sh 中的 JPDA_ADDRESS="localhost:8000" 这一句中的localhost修改为0.0.0.0(允许所有ip连接到8000端口,而不仅是本地)8000是端口,端口号可以任意修改成没有占用的即可,如下图所示:
修改之后使用 sh catalina.sh jpda start 命令启动tomcat 即可
二、spring boot 项目远程调试
-jar 后面添加这样的参数,实例如下图:
jdk1.5 之前
-Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=8000,suspend=n
jdk1.5 之后
-agentlib:jdwp=transport=dt_socket,address=8800,server=y,suspend=n
上面参数配置好之后,使用 IDEA 进行远程调试,如下图:
1、配置好 remote 远程调试
2、启动调试后请求,如下图:
调试参数详解
-Xdebug :启用调试特性
-Xrunjdwp: 在目标 VM 中加载 JDWP 实现。它通过传输和 JDWP 协议与独立的调试器应用程序通信。下面介绍一些特定的子选项
从 Java V5 开始,您可以使用 -agentlib:jdwp 选项,而不是 -Xdebug 和 -Xrunjdwp。但如果连接到 V5 以前的 VM,只能选择 -Xdebug 和 -Xrunjdwp。下面简单描述 -Xrunjdwp 子选项。
-Djava.compiler=NONE: 禁止 JIT 编译器的加载
transport : 传输方式,有 socket 和 shared memory 两种,我们通常使用 socket(套接字)传输,但是在 Windows 平台上也可以使用shared memory(共享内存)传输。
server(y/n): VM 是否需要作为调试服务器执行
address: 调试服务器的端口号,客户端用来连接服务器的端口号
suspend(y/n):值是 y 或者 n,若为 y,启动时候自己程序的 VM 将会暂停(挂起),直到客户端进行连接,若为 n,自己程序的 VM 不会挂起
上面的参数具体可以参看 IDEA 中的,如下图:
参考文档如下:
JPDA调试体系:https://www.ibm.com/developerworks/cn/java/j-lo-jpda1/index.html
Java 调试接口(JDI):https://www.ibm.com/developerworks/cn/java/j-lo-jpda4/index.html
示例demo:http://itsallbinary.com/java-debug-interface-api-jdi-hello-world-example-programmatic-debugging-for-beginners/
dump出类的class文件:https://blog.csdn.net/hengyunabc/article/details/51106980
一、远程debug原理
Java远程调试的原理是两个JVM之间通过debug协议进行通信,然后以达到远程调试的目的。两者之间可以通过socket进行通信。
被debug的机器需要开启debug模式,debug使用的客户端可以是eclipse,总之我使用eclipse已经成功了。
二、操作步骤
1.服务端设置
由于我们经常将程序部署到linux机器上,所以通常使用的是war、tar包或者直接使用可运行的jar包,由于近来spring-boot的盛行,直接使用可执行的jar包成为了最流行的部署方式:
jdk1.7之后使用命令:
java -agentlib:jdwp=transport=dt_socket,address=8000,server=y,suspend=n -jar you-runnable-jar.jar
jdwp:是 Java Debug Wire Protocol的缩写
server=y表示是监听其他debugclient端的请求
address=8000表示服务会在端口号8000监听debug请求,客户端必须设置这个端口号才能进行dubug
suspend表示是否在调试客户端建立连接之后启动 VM。如果为y,那么当前的VM就是suspend直到有debug client连接进来才开始执行程序。如果你的程序不是服务器监听模式并且很快就执行完毕的,那么可以选择在y来阻塞它的启动。
Java远程调试的原理
JDWP(Java Debug Wire Protocol)
两个VM之间通过debug协议进行通信,然后以达到远程调试的目的。两者之间可以通过socket进行通信。
其中,调试的程序常常被称为debugger, 而被调试的程序称为 debuggee。
应用场景
当你的开发环境在Window,又在远端linux Server或者移动平台上运行Java应用程序,Java提供了一系列的接口和协议让本地Java文件于远端JVM建立联系和通信。
Java 调试器架构
从下往上读架构,大致可以解读为: 用于调试的程序使用UI,通过Protocol,调用远端JVM进程。
实质还是JVM,只要确保本地Java 源代码与目标应用程序一致,本地的Java源码就可以用socket连接到远端的JVM,进而执行调试。
在Socket Attach模式下,本地只需要有源码,Java应用程序根本不用启动。
Socket Attach方式:
实际操作
首先被debug程序的虚拟机在启动时要开启debug模式,启动debug监听程序。
在启动程序时,将以下参数选项添加到自定义的命令行中,程序就会以支持RemoteDeubg的方式启动。
wrapper.java.additional.1=-Xdebug
wrapper.java.additional.2=-Xrunjdwp:transport=dt_socket,server=y,address=7899,suspend=n
DEBUG选项参数的意思
-XDebug 启用调试;
-Xrunjdwp 加载JDWP的JPDA参考执行实例;
transport 用于在调试程序和VM使用的进程之间通讯;
dt_socket 套接字传输;
server=y/n VM是否需要作为调试服务器执行;
address=7899 调试服务器监听的端口号;
suspend=y/n 是否在调试客户端建立连接之后启动 VM 。
然后用一个debug客户端去debug远程的程序,如:用Eclipse自带的debug客户端,填写运行被debug程序的虚拟机监听的端口号和地址,选择connect方式为attach。
在程序中打好断点,打开Eclipse配置
Run-->Debug Configurations…-->Remote java Application-->右键New-->填写Host和Port(例如,Host:10.75.0.103,Port:7899)-->Debug
注意,如果 Java 源代码与目标应用程序不匹配,调试特性将不能正常工作。
选择 Allow termination of remote VM 选项 可以在应用程序调试期间终止连接
这样远程调试连接上之后,就可以像在本地调试Java程序一样来调试远端的Java应用程序。
IDE快捷键
Eclipse debug快捷键
F5 Step into
F6 Step over
F7 Step out
F8 continue to the next breakpoint
Intellij debug的快捷键
F7,Step into
F8,Step over
Shift+F8, Step out
Alt+F9,运行至光标处
F9,恢复程序
https://div.io/topic/1464
一、前言
放弃phantomJS方案,决定采用Remote debugging protocol方案来获取性能数据生成har文件。第一,phantomJS还是不稳定、不是真实浏览器环境,第二,Remote debugging protocol调用真实浏览器,更加强大,DIY自由性更高。
二、Remote debugging protocol简介
Chrome Developer Tools是用HTML,Javascript,CSS编写的chrome开发者工具,然而Remote debugging protocol就是它用来与浏览器页面(pages)交互和调试的协议通道。采用websocket来与页面建立通信通道,由发送给页面的commands和它所产生的events组成。chrome的开发者工具是这个协议主要的使用者,第三方开发者也可以调用这个协议来与页面交互调试。
三、启动chrome实例
在命令行开启一个chrome的实例,加上合适的参数,会自动打开一个chrome。
chrome.exe --remote-debugging-port=9222 --user-data-dir=
打开http://loacalhost:9222,就能看到你开启的chrome实例中所有打开的标签页面
四、操作chrome标签
获取所有开打标签的信息,返回一个json数组,type为page的为打开中的页面。 webSocketDebuggerUrl就是连接到该标签页的websocket地址。
http://loacalhost:9222/json
返回
[ {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/B7F2D344-197B-4F57-A945-939F29AE2922",
"id": "B7F2D344-197B-4F57-A945-939F29AE2922",
"title": "localhost:9222/json",
"type": "page",
"url": "http://localhost:9222/json",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/B7F2D344-197B-4F57-A945-939F29AE2922"
}, {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/575FBA99-453B-40F1-9112-139D995246D6",
"faviconUrl": "https://www.baidu.com/favicon.ico",
"id": "575FBA99-453B-40F1-9112-139D995246D6",
"title": "百度一下,你就知道",
"type": "page",
"url": "https://www.baidu.com/",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/575FBA99-453B-40F1-9112-139D995246D6"
}, {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/477810FF-323E-44C5-997C-89B7FAC7B158",
"id": "477810FF-323E-44C5-997C-89B7FAC7B158",
"title": "Worker pid:12089",
"type": "service_worker",
"url": "https://www.google.com.sg/_/chrome/newtab-serviceworker.js",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/477810FF-323E-44C5-997C-89B7FAC7B158"
} ]
新建一个标签页,空白页或者带参数默认加载URL,返回创建之后该页面信息的json对象,格式同上。
http://localhost:9222/json/new
http://localhost:9222/json/new?http://www.baidu.com
关闭一个标签页,传入该页面的id。
http://localhost:9222/json/close/477810FF-323E-44C5-997C-89B7FAC7B158
激活标签页。
http://localhost:9222/json/activate/477810FF-323E-44C5-997C-89B7FAC7B158
查看chrome和协议的版本信息。
http://localhost:9222/json/version
五、功能模块域列表
该协议分为不同的功能模块域(domains),类似与chrome开发者工具里的不同功能模块。
Console
DOM Debugger
Page
Debugger
Input
Runtime
DOM
Network
Timeline
比如Network中有一个Commands是clearBrowserCache, 清除缓存。使用方法就是,用websocket连接到该页面之后用send方法发送一个对象。暂时只支持一个websocket地址只允许一个client连接。
ws.send('{"id": 1, "method": "Network.clearBrowserCache", "params": {}}')
六、扩展API extension JavaScript API
有很多扩展应用使用了该协议来与页面做交互调试,官网上有很多Sample Extensions
七、Showcase && 小结
很多工具都使用了Chrome debugging protocol,包括phantomJS,Selenium的ChromeDriver,本质都是一样的实现,它就相当于Chrome内核提供的API让应用调用。官网列出了很多有意思的工具:链接,因为API丰富,所以才有了这么多的chrome插件。
实现了Remote debugging protocol的node的库:
chrome-debug-protocol 使用了ES6和TypeScript
chrome-remote-interface 官网推荐的
chrome-har-capturer 传入url,直接获取har format文件
虚拟内存大小>程序数目>堆叠大小>缓冲区大小>文件数目>内存大小>文件大小>数据节区大小>