kk Blog —— 通用基础

date [-d @int|str] [+%s|"+%F %T"]

调试器工作原理之二——实现断点

调试器工作原理之一——基础篇
调试器工作原理之二——实现断点
调试器工作原理之三——调试信息

本文的主要内容

这里我将说明调试器中的断点机制是如何实现的。断点机制是调试器的两大主要支柱之一 ——另一个是在被调试进程的内存空间中查看变量的值。我们已经在第一篇文章中稍微涉及到了一些监视被调试进程的知识,但断点机制仍然还是个迷。阅读完本文之后,这将不再是什么秘密了。

软中断

要在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信号。

这就是全部——真的!现在回顾一下本系列文章的第一篇,跟踪(调试器)进程可以获得所有其子进程(或者被关联到的进程)所得到信号的通知,现在你知道我们该做什么了吧? 就是这样,再没有什么计算机体系结构方面的东东了,该写代码了。

手动设定断点

现在我要展示如何在程序中设定断点。用于这个示例的目标程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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 0×80指令之后吧,也就是mov edx, len2。首先,我需要知道这条指令对应的地址是什么。运行objdump –d:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
traced_printer2: file format elf32-i386
 
Sections:
Idx   Name    Size        VMA         LMA         File off    Algn
  0   .text   00000033    08048080    08048080    00000080    2**4
          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

通过上面的输出,我们知道要设定的断点地址是0×8048096。等等,真正的调试器不是像这样工作的,对吧?真正的调试器可以根据代码行数或者函 数名称来设定断点,而不是基于什么内存地址吧?非常正确。但是我们离那个标准还差的远——如果要像真正的调试器那样设定断点,我们还需要涵盖符号表以及调 试信息方面的知识,这需要用另一篇文章来说明。至于现在,我们还必须得通过内存地址来设定断点。

看到这里我真的很想再扯一点题外话,所以你有两个选择。如果你真的对于为什么地址是0×8048096,以及这代表什么意思非常感兴趣的话,接着看下一节。如果你对此毫无兴趣,只是想看看怎么设定断点,可以略过这一部分。

题外话——进程地址空间以及入口点

坦白的说,0×8048096本身并没有太大意义,这只不过是相对可执行镜像的代码段(text section)开始处的一个偏移量。如果你仔细看看前面objdump出来的结果,你会发现代码段的起始位置是0×08048080。这告诉了操作系统 要将代码段映射到进程虚拟地址空间的这个位置上。在Linux上,这些地址可以是绝对地址(比如,有的可执行镜像加载到内存中时是不可重定位的),因为在 虚拟内存系统中,每个进程都有自己独立的内存空间,并把整个32位的地址空间都看做是属于自己的(称为线性地址)。

如果我们通过readelf工具来检查可执行文件的ELF头,我们将得到如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ 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”同样指向的是0×8048080。因此,如果我们把ELF文件中的这个部分解释给操作系统的话,就表示:
1. 将代码段映射到地址0×8048080处
2. 从入口点处开始执行——地址0×8048080
但是,为什么是0×8048080呢?它的出现是由于历史原因引起的。每个进程的地址空间的前128MB被保留给栈空间了(注:这一部分原因可参考 Linkers and Loaders)。128MB刚好是0×80000000,可执行镜像中的其他段可以从这里开始。0×8048080是Linux下的链接器ld所使用的 默认入口点。这个入口点可以通过传递参数-Ttext给ld来进行修改。

因此,得到的结论是这个地址并没有什么特别的,我们可以自由地修改它。只要ELF可执行文件的结构正确且在ELF头中的入口点地址同程序代码段(text section)的实际起始地址相吻合就OK了。

通过int 3指令在调试器中设定断点

要在被调试进程中的某个目标地址上设定一个断点,调试器需要做下面两件事情:
1. 保存目标地址上的数据
2. 将目标地址上的第一个字节替换为int 3指令
然后,当调试器向操作系统请求开始运行进程时(通过前一篇文章中提到的PTRACE_CONT),进程最终一定会碰到int 3指令。此时进程停止,操作系统将发送一个信号。这时就是调试器再次出马的时候了,接收到一个其子进程(或被跟踪进程)停止的信号,然后调试器要做下面几 件事:
1. 在目标地址上用原来的指令替换掉int 3
2. 将被跟踪进程中的指令指针向后递减1。这么做是必须的,因为现在指令指针指向的是已经执行过的int 3之后的下一条指令。
3. 由于进程此时仍然是停止的,用户可以同被调试进程进行某种形式的交互。这里调试器可以让你查看变量的值,检查调用栈等等。
4. 当用户希望进程继续运行时,调试器负责将断点再次加到目标地址上(由于在第一步中断点已经被移除了),除非用户希望取消断点。
让我们看看这些步骤如何转化为实际的代码。我们将沿用第一篇文章中展示过的调试器“模版”(fork一个子进程,然后对其跟踪)。无论如何,本文结尾处会给出完整源码的链接。

1
2
3
4
5
6
7
8
/* 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);

这里调试器从被跟踪进程中获取到指令指针,然后检查当前位于地址0×8048096处的字长内容。运行本文前面列出的汇编码程序,将打印出:

1
2
[13028] Child started. EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba

目前为止一切顺利,下一步:

1
2
3
4
5
6
7
/* 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指令插入到目标地址上的。这部分代码将打印出:

1
[13028] After trap, data at 0x08048096: 0x000007cc

再一次如同预计的那样——0xba被0xcc取代了。调试器现在运行子进程然后等待子进程在断点处停止住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 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);

这段代码打印出:

1
2
3
Hello,
[13028] Child got a signal: Trace/breakpoint trap
[13028] Child stopped at EIP = 0x08048097

注意,“Hello,”在断点之前打印出来了——同我们计划的一样。同时我们发现子进程已经停止运行了——就在这个单字节的陷阱指令执行之后。

1
2
3
4
5
6
7
8
9
10
11
/* 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以同样的作用而被保留。为什么要这样做呢?因为这允许我们在插入一个断点时覆盖到的指令不会多于一条。这很重 要,考虑下面的示例代码:

1
2
3
4
5
6
.. some code ..
  jz  foo
  dec eax
foo:
  call    bar
  .. some code ..

假设我们要在dec eax上设定断点。这恰好是条单字节指令(操作码是0×48)。如果替换为断点的指令长度超过1字节,我们就被迫改写了接下来的下一条指令(call), 这可能会产生一些完全非法的行为。考虑一下条件分支jz foo,这时进程可能不会在dec eax处停止下来(我们在此设定的断点,改写了原来的指令),而是直接执行了后面的非法指令。

通过对int 3指令采用一个特殊的单字节编码就能解决这个问题。因为x86架构上指令最短的长度就是1字节,这样我们可以保证只有我们希望停止的那条指令被修改。

封装细节

前面几节中的示例代码展示了许多底层的细节,这些可以很容易地通过API进行封装。我已经做了一些封装,使其成为一个小型的调试库——debuglib。代码在本文末尾处可以下载。这里我只想介绍下它的用法,我们要开始调试C程序了。

跟踪C程序

目前为止为了简单起见我把重点放在对汇编程序的跟踪上了。现在升一级来看看我们该如何跟踪一个C程序。
其实事情并没有很大的不同——只是现在有点难以找到放置断点的位置。考虑如下这个简单的C程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
 
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部分就好 了。

1
2
3
4
5
6
7
8
080483e4 <do_stuff>:
 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来简化代码编写。这里是完整的调试器函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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程序后的打印输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ 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的这一行上停住”,然后调试器就能照办。在下一篇文章中,我将向您展示这是如何做到的。

调试器工作原理之一——基础篇

英文原文:Eli Bendersky 编译:陈舸
调试器工作原理之一——基础篇
调试器工作原理之二——实现断点
调试器工作原理之三——调试信息

本文是一系列探究调试器工作原理的文章的第一篇。我还不确定这个系列需要包括多少篇文章以及它们所涵盖的主题,但我打算从基础知识开始说起。

关于本文

我打算在这篇文章中介绍关于Linux下的调试器实现的主要组成部分——ptrace系统调用。本文中出现的代码都在32位的Ubuntu系统上开发。请注意,这里出现的代码是同平台紧密相关的,但移植到别的平台上应该不会太难。

动机

要想理解我们究竟要做什么,试着想象一下调试器是如何工作的。调试器可以启动某些进程,然后对其进行调试,或者 将自己本身关联到一个已存在的进程之上。它可以单步运行代码,设置断点然后运行程序,检查变量的值以及跟踪调用栈。许多调试器已经拥有了一些高级特性,比 如执行表达式并在被调试进程的地址空间中调用函数,甚至可以直接修改进程的代码并观察修改后的程序行为。

尽管现代的调试器都是复杂的大型程序,但令人惊讶的是构建调试器的基础确是如此的简单。调试器只用到了几个由操作系统以及编译器/链接器提供的基础服务,剩下的仅仅就是简单的编程问题了。(可查阅维基百科中关于这个词条的解释,作者是在反讽)

Linux下的调试——ptrace

Linux下调试器拥有一个瑞士军刀般的工具,这就是ptrace系统调用。这是一个功能众多且相当复杂的工 具,能允许一个进程控制另一个进程的运行,而且可以监视和渗入到进程内部。ptrace本身需要一本中等篇幅的书才能对其进行完整的解释,这就是为什么我 只打算通过例子把重点放在它的实际用途上。让我们继续深入探寻。

遍历进程的代码

我现在要写一个在“跟踪”模式下运行的进程的例子,这里我们要单步遍历这个进程的代码——由CPU所执行的机器 码(汇编指令)。我会在这里给出例子代码,解释每个部分,本文结尾处你可以通过链接下载一份完整的C程序文件,可以自行编译执行并研究。从高层设计来说, 我们要写一个程序,它产生一个子进程用来执行一个用户指定的命令,而父进程跟踪这个子进程。首先,main函数是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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语句块处理父进程(这里称为“调试器”)。下面是目标进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
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):

1
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中指定的程序之前停止该进程,并发送一个信号给父进程。 因此,是时候看看父进程需要做些什么了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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指令总数。让我们看看实际运行的情况。

实际测试

我编译了下面这个简单的程序,然后在我们的跟踪程序下执行:

1
2
3
4
5
6
#include <stdio.h>
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)为如下的汇编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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函数的另一个版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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中。这儿有个有趣的地方——如果你打开这个头文件看看,靠近文件顶端的地方有一条这样的注 释:
1
/ 本文件的唯一目的是为GDB,且只为GDB所用。对于这个文件,不要看的太多。除了GDB以外不要用于任何其他目的,除非你知道你正在做什么。/
现在,我不知道你是怎么想的,但我感觉我们正处于正确的跑道上。无论如何,回到我们的例子上来。一旦我们将所有的寄存器值获取到regs中,我们就 可以通过PTRACE_PEEKTEXT标志以及将regs.eip(x86架构上的扩展指令指针)做参数传入ptrace来调用。我们所得到的就是指 令。让我们在汇编代码上运行这个新版的跟踪程序。

1
2
3
4
5
6
7
8
9
10
11
12
$ 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来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ 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函数入口处设 置一个断点,从断点处开始单步执行。下一篇文章中我将向您展示该如何实现断点机制。

参考文献

写作本文时我发现下面这些文章很有帮助:
Playing with ptrace, Part I
Process tracing using ptrace
How debugger works

freeBSD9.2 安装 && 允许root用户用SSH登录

http://forums.freebsd.org/showthread.php?t=36579

Issue:
Here is the error message that I’ve got:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Code:

cd0 at umass-sim0 bus 0 scbus3 target 0 lun 0
cd0: <ASUS SDRW-08D2S-U B302> Removable CD-ROM SCSI-0 device
cd0: 40.000MB/s transfers
cd0: cd present [1166275 x 2048 byte records]Mounting from cd9660:/dev/iso9660/FREEBSD_INSTALL failed with error 19.

Loader variables:
	vfs.root.mountfrom=cd9660:/dev/iso9660/FREEBSD_INSTALL
	vfs.root.mountfrom.option=ro

Manual root filesystem specification:
	<fstype>:<device> [option]
	    Mount <device> using filesystem <fstype>
	    and with the specified (optional) option list.

	eg. ufs:/dev/da0s1a
	    zf:tank

The system seems to recognize the DVD drive but is unable to mount the media correctly.

You need to get a boot prompt and disable ACPI support before booting:
这两句好像没效果

1
2
# set debug.acpi.disabled ="hostres"
# boot

The solution is actually in the FreeBSD 9 errata (section 3)

1
mountroot> cd9660:/dev/cd0  用这句通过

这句不只道可不可以:

1
mountroot> cd9660:/iso9660/FREEBSD_INSTALL

二 FreeBSD 允许root用户用SSH登录

修改freebsd可以用sshd权限用户登录ssh
在/etc/ssh/sshd_config最后中加入

1
2
3
PermitRootLogin yes #允许root登录
PermitEmptyPasswords no #不允许空密码登录
PasswordAuthentication yes # 设置是否使用口令验证。

FreeBSD SSH配置详解 首先vi编辑/etc/inetd.conf,去掉ssh前的#,保存退出 (开启监听ssh服务)
编辑/etc/rc.conf
最后加入:sshd_enable=“yes"即可

激活sshd服务:

1
#/etc/rc.d/sshd start

用下面命令检查服务是否启动,在22端口应该有监听。

1
#netstat -an ## check port number 22

最后

vi /etc/ssh/sshd_config

1
2
3
4
5
6
7
8
9
10
...
Subsystem sftp /usr/libexec/sftp-server
IgnoreRhosts yes
IgnoreUserKnownHosts yes
PrintMotd yes
StrictModes no
RSAAuthentication yes
PermitRootLogin yes #允许root登录
PermitEmptyPasswords no #不允许空密码登录
PasswordAuthentication yes # 设置是否使用口令验证。

记得修改完配置文件后,重新启动sshd服务器(/etc/rc.d/sshd restart)即可。
几点补充说明

1
2
3
4
5
6
7
8
9
10
11
1,如果重启后还是不行请重新载入sshd_config 文件
/etc/rc.d/sshd reload
2,如果出现using keyboard-interactive authentication
password:
请确认PasswordAuthentication是否已经改成yes
另外如果客户端是putty那么请确认"尝试'智能键盘'认证(SSH-2)"的勾是否有去掉
3,如果是使用root帐号登陆
请确认密码是否为空
空密码无法登陆
4请确认是否有安装SSH
sysinstall>>>configure>>>networking>>>sshd是否的勾是否有打上

__builtin_return_address获得程序运行栈

gcc的编译特性使用__builtin_return_address(level)打印出一个函数的堆栈地址。其中 level代表是堆栈中第几层调用地址,__builtin_return_address(0)表示第一层调用地址,即当前函数,__builtin_return_address(1)表示第二层。如代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void f()
{
	printf("%p,%p" , __builtin_return_address(0), __builtin_return_address(1));
	//printk("Caller is %pS\n", __builtin_return_address(0));
}

void g()
{
	f();
}
int main()
{
	g();
}

分别打印出函数f()和g() 的函数地址,我们通过objdump 出来的文件去查找打印出来的函数地址,这样就能看到调用的函数名了。

编译期间求值

编译期求阶乘

c++ 中的模板可以用来计算一些值,在编译的时候就是实现计算,而不是运行的时候。

求阶乘 n!,一般 me 们会写个这样的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
long Factorial(long n)
{
	return n == 0 ? 1 : n*Factorial(n-1);
}

int main()
{
	long fac=1, n=20;
	for(int i=1; i<=n; ++i)fac *= i;
	std::cout << "20! = " << fac << " " << Factorial(20) << std::endl;
	return 0;
}

现在使用模板技术,类似于递归的方法求 20 !。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

template<int N>
class Factorial{
public:
	static const long value = N*Factorial<N-1>::value;
};

template<>
class Factorial<0>{
public:
	static const long value = 1;
};

int main()
{
	std::cout << "20! = " << Factorial<20>::value << std::endl;
	return 0;
}

说明:
template 通常用来参数化类型,通常 class T 或是 typename T(T 用来代替一个类型的名字),不过也可以带一个整型参数 N (貌似规定只能是整型)。
template <> 是用来特殊指定一些情形,比如上面给的 Factorial<0> 指定 N = 0 时的情形,这有点像递归中的 if(n==0) return 1;
class 类中可以带有 static const 变量,这种变量可以在类内初始化(只能是整型);当然既是 const 变量,又是 static 变量;
Factorila<20> 实际是一个类,而 ::value 是其 static 变量;在生成Factorila<20> 的时候同时生成了众多的Factorila ( N >0 && N < 20)类;

更多例子
模板类,或是模版函数,或是模板成员函数,都是编译器根据程序的实际情况而生成的,需要什么就生成什么,不需要就不生成。上面的例子中, 程序中使用 Factorial<20> 这个类,就生成这个类,因为 Factorial<20> 依赖 Factorial<19> 所以又生成 Factorial<19> ,这样一直依赖下去,直到 Factorial<0>( me 们已经指定了)。因为是编译期生成,也是编译器求值,所以实际程序中只能使用 static const 类似的 —— 常量,而不能使用普通的 int n。所以,模板元编程中,么发使用循环,只能类似递归的技术。
通常 me 们会将递归程序转换为循环程序,实际上循环程序基本也都可以递归解决。(是不是一定呢?O__O"…)
求斐波那契数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

template <long N>
struct Fibonacci{
	static const long value = Fibonacci<N-1>::value + Fibonacci<N-2>::value;
};

template<>
struct Fibonacci<0>{
	static const long value = 0;
};

template<>
struct Fibonacci<1>{
	static const long value = 1;
};

int main()
{
	std::cout << Fibonacci<12>::value << std::endl;
	return 0;
}

第 12 个斐波那契数是 144,这是唯一一个 Fib(n) = n*n 的数。 求 1+2+3+…+n

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

template <long N>
struct Sum{
	static const long value = N+Sum<N-1>::value;
};

template<>
struct Sum<1>{
	static const long value = 1;
};

int main()
{
	std::cout << Sum<100>::value << std::endl;
	return 0;
}

这个和 n! 的用法基本一样。

constexpr编译期求值

模板只是在编译的时候完成工作的一种方法,实际上上面的模板元编程也只是在编译期求了一些常量而已;为了简化使用模板进行元编程的难度,c++11 引入了 constexpr 关键字 —— 声明常量或是函数,实现在编译期求值。上面的三个程序都可以大大简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

constexpr long factorial(long n)
{
	return n<=1 ? 1 : n*factorial(n-1);
}
constexpr long fibonacci(long n)
{
	return n<=1 ? n : fibonacci(n-1)+fibonacci(n-2);
}
constexpr long sum(long n)
{
	return n<=1 ? n : n+sum(n-1);
}

int main()
{
	std::cout << "10! F(12) 1+2+...+100 => " << factorial(10) << " " << fibonacci(12) << " " << sum(100) << std::endl;
	return 0;
}

不用多数,看应该看得懂神马意思,要提的就是 constexpr 都是编译的时候求值。