精华内容
下载资源
问答
  • 软件调试技术解析

    千次阅读 2015-01-26 17:07:51
    一、反调试技术 1.断点 2.计算校验和 3.检测调试器 4.探测单步执行 5.在运行时中检测速度衰减 6.指令预取 7.自修改代码 8.覆盖调试程序信息 9.解除调试器线程 10.解密 二、逆转录病毒 三...

    目录

    一、反调试技术

    1.断点 2.计算校验和

    3.检测调试器 4.探测单步执行

    5.在运行时中检测速度衰减 6.指令预取

    7.自修改代码 8.覆盖调试程序信息

    9.解除调试器线程 10.解密

    二、逆转录病毒

    三、混合技术

    四、linux反调试技术简单示例

    1. int3指令 2. 文件描述符 3. 利用getppid 4. 利用环境变量 5. 利用ptrace

    五、小结

    本文中,我们将向读者介绍恶意软件用以阻碍对其进行逆向工程的各种反调试技术,以帮助读者很好的理解这些技术,从而能够更有效地对恶意软件进行动态检测和分析。

    一、反调试技术

    反调试技术是一种常见的反检测技术,因为恶意软件总是企图监视自己的代码以检测是否自己正在被调试。为做到这一点,恶意软件可以检查自己代码是否被设置了断点,或者直接通过系统调用来检测调试器。

    1.断点

    为了检测其代码是否被设置断点,恶意软件可以查找指令操作码0xcc(调试器会使用该指令在断点处取得恶意软件的控制权),它会引起一个SIGTRAP。如果恶意软件代码本身建立了一个单独的处理程序的话,恶意软件也可以设置伪断点。用这种方法恶意软件可以在被设置断点的情况下继续执行其指令。

    恶意软件也可以设法覆盖断点,例如有的病毒采用了反向解密循环来覆盖病毒中的断点。相反,还有的病毒则使用汉明码自我纠正自身的代码。汉明码使得程序可以检测并修改错误,但是在这里却使病毒能够检测并清除在它的代码中的断点。

    2.计算校验和

    恶意软件也可以计算自身的校验和,如果校验和发生变化,那么病毒会假定它正在被调试,并且其代码内部已被放置断点。VAMPiRE是一款抗反调试工具,可用来逃避断点的检测。VaMPiRE通过在内存中维护一张断点表来达到目的,该表记录已被设置的所有断点。该程序由一个页故障处理程序(PFH),一个通用保护故障处理程序(GPFH),一个单步处理程序和一个框架API组成。当一个断点被触发的时候,控制权要么传给PFH(处理设置在代码、数据或者内存映射I/O中的断点),要么传给GPFH(处理遗留的I/O断点)。单步处理程序用于存放断点,使断点可以多次使用。

    3.检测调试器

    在Linux系统上检测调试器有一个简单的方法,只要调用Ptrace即可,因为对于一个特定的进程而言无法连续地调用Ptrace两次以上。在Windows中,如果程序目前处于被调试状态的话,系统调用isDebuggerPresent将返回1,否则返回0。这个系统调用简单检查一个标志位,当调试器正在运行时该标志位被置1。直接通过进程环境块的第二个字节就可以完成这项检查,以下代码为大家展示的就是这种技术:

    mov eax, fs:[30h]
    move eax, byte [eax+2]
    test eax, eax    
    jne @DdebuggerDetected
    

    在上面的代码中,eax被设置为PEB(进程环境块),然后访问PEB的第二个字节,并将该字节的内容移入eax。通过查看eax是否为零,即可完成这项检测。如果为零,则不存在调试器;否则,说明存在一个调试器。

    如果某个进程为提前运行的调试器所创建的,那么系统就会给ntdll.dll中的堆操作例程设置某些标志,这些标志分别是FLG_HEAP_ENABLE_TAIL_CHECK、FLG_HEAP_ENABLE_FREE_CHECK和FLG_HEAP_VALIDATE_PARAMETERS。我们可以通过下列代码来检查这些标志:

    mov eax, fs:[30h]
    mov eax, [eax+68h]
    and eax, 0x70
    test eax, eax
    jne @DebuggerDetected
    

    在上面的代码中,我们还是访问PEB,然后通过将PEB的地址加上偏移量68h到达堆操作例程所使用的这些标志的起始位置,通过检查这些标志就能知道是否存在调试器。

    检查堆头部内诸如ForceFlags之类的标志也能检测是否有调试器在运行,如下所示:

    mov eax, fs:[30h]
    mov eax, [eax+18h] ;process heap
    mov eax, [eax+10h] ;heap flags
    test eax, eax
    jne @DebuggerDetected
    

    上面的代码向我们展示了如何通过PEB的偏移量来访问进程的堆及堆标志,通过检查这些内容,我们就能知道Force标志是否已经被当前运行的调试器提前设置为1了。

    另一种检测调试器的方法是,使用NtQueryInformationProcess这个系统调用。我们可以将ProcessInformationClass设为7来调用该函数,这样会引用ProcessDebugPort,如果该进程正在被调试的话,该函数将返回-1。示例代码如下所示。

    push 0
    push 4
    push offset isdebugged
    push 7 ;ProcessDebugPort
    push -1
    call NtQueryInformationProcess
    test eax, eax
    jne @ExitError
    cmp isdebugged, 0
    jne @DebuggerDetected

    在本例中,首先把NtQueryInformationProcess的参数压入堆栈。这些参数介绍如下:第一个是句柄(在本例中是0),第二个是进程信息的长度(在本例中为4字节),接下来是进程信息类别(在本例中是7,表示ProcessDebugPort),下一个是一个变量,用于返回是否存在调试器的信息。如果该值为非零值,那么说明该进程正运行在一个调试器下;否则,说明一切正常。最后一个参数是返回长度。使用这些参数调用NtQueryInformationProcess后的返回值位于isdebugged中。随后测试该返回值是否为0即可。

    另外,还有其他一些检测调试器的方法,如检查设备列表是否含有调试器的名称,检查是否存在用于调试器的注册表键,以及通过扫描内存以检查其中是否含有调试器的代码等。

    另一种非常类似于EPO的方法是,通知PE加载器通过PE头部中的线程局部存储器(TLS)表项来引用程序的入口点。这会导致首先执行TLS中的代码,而不是先去读取程序的入口点。因此,TLS在程序启动就可以完成反调试所需检测。从TLS启动时,使得病毒得以能够在调试器启动之前就开始运行,因为一些调试器是在程序的主入口点处切入的。

    4.探测单步执行

    恶意软件还能够通过检查单步执行来检测调试器。要想检测单步执行的话,我们可以把一个值放进堆栈指针,然后看看这个值是否还在那里。如果该值在那里,这意味着,代码正在被单步执行。当调试器单步执行一个进程时,当其取得控制时需要把某些指令压入栈,并在执行下一个指令之前将其出栈。所以,如果该值仍然在那里,就意味着其它正在运行的进程已经在使用堆栈。下面的示例代码展示了恶意软件是如何通过堆栈状态来检测单步执行的:

    Mov bp,sp;选择堆栈指针
    Push ax ;将ax压入堆栈
    Pop ax ;从堆栈中选择该值
    Cmp word ptr [bp -2],ax ;跟堆栈中的值进行比较
    Jne debug ;如果不同,说明发现了调试器。  
    

    如上面的注释所述,一个值被压入堆栈然后又被弹出。如果存在调试器,那么堆栈指针–2位置上的值就会跟刚才弹出堆栈的值有所不同,这时就可以采取适当的行动。

    5.在运行时中检测速度衰减

    通过观察程序在运行时是否减速,恶意代码也可以检测出调试器。如果程序在运行时速度显著放缓,那就很可能意味着代码正在单步执行。因此如果两次调用的时间戳相差甚远,那么恶意软件就需要采取相应的行动了。Linux跟踪工具包LTTng/LTTV通过观察减速问题来跟踪病毒。当LTTng/LTTV追踪程序时,它不需要在程序运行时添加断点或者从事任何分析。此外,它还是用了一种无锁的重入机制,这意味着它不会锁定任何Linux内核代码,即使这些内核代码是被跟踪的程序需要使用的部分也是如此,所以它不会导致被跟踪的程序的减速和等待。

    6.指令预取

    如果恶意代码篡改了指令序列中的下一条指令并且该新指令被执行了的话,那么说明一个调试器正在运行。这是指令预取所致:如果该新指令被预取,就意味着进程的执行过程中有其他程序的切入。否则,被预取和执行的应该是原来的指令。

    7.自修改代码

    恶意软件也可以让其他代码自行修改(自行修改其他代码),这样的一个例子是HDSpoof。这个恶意软件首先启动了一些异常处理例程,然后在运行过程中将其消除。这样一来,如果发生任何故障的话,运行中的进程会抛出一个异常,这时病毒将终止运行。此外,它在运行期间有时还会通过清除或者添加异常处理例程来篡改异常处理例程。在下面是HDSpoof清除全部异常处理例程(默认异常处理例程除外)的代码。

    exception handlers before:

    0x77f79bb8 ntdll.dll:executehandler2@20 + 0x003a
    0x0041adc9 hdspoof.exe+0x0001adc9
    0x77e94809 __except_handler3

    exception handlers after:

    0x77e94809 __except_handler3

    0x41b770: 8b44240c       mov      eax,dword ptr [esp+0xc]
    0x41b774: 33c9           xor      ecx,ecx              
    0x41b776: 334804         xor      ecx,dword ptr [eax+0x4]
    0x41b779: 334808         xor      ecx,dword ptr [eax+0x8]
    0x41b77c: 33480c         xor      ecx,dword ptr [eax+0xc]
    0x41b77f: 334810         xor      ecx,dword ptr [eax+0x10]
    0x41b782: 8b642408       mov      esp,dword ptr [esp+0x8]
    0x41b786: 648f0500000000 pop      dword ptr fs:[0x0]   

    下面是HDSpoof创建一个新的异常处理程序的代码。

    0x41f52b: add      dword ptr [esp],0x9ca
    0x41f532: push     dword ptr [dword ptr fs:[0x0]
    0x41f539: mov      dword ptr fs:[0x0],esp
    

    8.覆盖调试程序信息

    一些恶意软件使用各种技术来覆盖调试信息,这会导致调试器或者病毒本身的功能失常。通过钩住中断INT 1和INT 3(INT 3是调试器使用的操作码0xCC),恶意软件还可能致使调试器丢失其上下文。这对正常运行中的病毒来说毫无妨碍。另一种选择是钩住各种中断,并调用另外的中断来间接运行病毒代码。

    下面是Tequila 病毒用来钩住INT 1的代码:

    new_interrupt_one:
    
       push bp
       mov bp,sp
       cs cmp b[0a],1      ;masm mod. needed
       je 0506             ;masm mod. needed
       cmp w[bp+4],09b4
       ja 050b             ;masm mod. needed
       push ax
       push es
       les ax,[bp+2]
       cs mov w[09a0],ax   ;masm mod. needed
       cs mov w[09a2],es   ;masm mod. needed
       cs mov b[0a],1
       pop es
       pop ax
       and w[bp+6],0feff
       pop bp
       iret
    
    

    一般情况下,当没有安装调试器的时候,钩子例程被设置为IRET。V2Px使用钩子来解密带有INT 1和INT 3的病毒体。在代码运行期间,会不断地用到INT 1和INT 3向量,有关计算是通过中断向量表来完成的。

    一些病毒还会清空调试寄存器(DRn的内容。有两种方法达此目的,一是使用系统调用NtGetContextThread和NtSetContextThread。而是引起一个异常,修改线程上下文,然后用新的上下文恢复正常运行,如下所示:

    push offset handler
    push dword ptr fs:[0]
    mov fs:[0],esp
    xor eax, eax
    div eax ;generate exception
    pop fs:[0]
    add esp, 4
    ;continue execution
    ;...
    handler:
    mov ecx, [esp+0Ch] ;skip div
    add dword ptr [ecx+0B8h], 2 ;skip div
    mov dword ptr [ecx+04h], 0 ;clean dr0
    mov dword ptr [ecx+08h], 0 ;clean dr1
    mov dword ptr [ecx+0Ch], 0 ;clean dr2
    mov dword ptr [ecx+10h], 0 ;clean dr3
    mov dword ptr [ecx+14h], 0 ;clean dr6
    mov dword ptr [ecx+18h], 0 ;clean dr7
    xor eax, eax
    ret
    

    上面的第一行代码将处理程序的偏移量压入堆栈,以确保当异常被抛出时它自己的处理程序能取得控制权。之后进行相应设置,包括用自己异或自己的方式将eax设为0,以将控制权传送给该处理程序。div eax 指令会引起异常,因为eax为0,所以AX将被除以零。该处理程序然后跳过除法指令,清空dr0-dr7,同样也把eax置0,表示异常将被处理,然后恢复运行。

    9.解除调试器线程

    我们可以通过系统调用NtSetInformationThread从调试器拆卸线程。为此,将ThreadInformationClass设为0x11(ThreadHideFromDebugger)来调用NtSetInformationThread,如果存在调试器的话,这会将程序的线程从调试器拆下来。以下代码就是一个例子:

    push 0
    push 0
    push 11h ;ThreadHideFromDebugger
    push -2
    call NtSetInformationThread
    

    在本例中,首先将NtSetInformationThread的参数压入堆栈,然后调用该函数来把程序的线程从调试器中去掉。这是因为这里的0用于线程的信息长度和线程信息,传递的-2用于线程句柄,传递的11h用于线程信息类别,这里的值表示ThreadHideFromDebugger。

    10.解密

    解密可以通过各种防止调试的方式来进行。有的解密依赖于特定的执行路径。如果这个执行路径没被沿用,比如由于在程序中的某个地方启动了一个调试器,那么解密算法使用的值就会出错,因此程序就无法正确进行自身的解密。HDSpoof使用的就是这种技术。

    一些病毒使用堆栈来解密它们的代码,如果在这种病毒上使用调试器,就会引起解密失败,因为在调试的时候堆栈为INT 1所用。使用这种技术的一个例子是W95/SK病毒,它在堆栈中解密和构建其代码;另一个例子是Cascade病毒,它将堆栈指针寄存器作为一个解密密钥使用。代码如下所示:

    lea   si, Start   ; position to decrypt
    mov   sp, 0682  ; length of encrypted body
    
    Decrypt:
    
    xor   [si], si    ; decryption key/counter 1
    xor   [si], sp  ; decryption key/counter 2
    inc   si    ; increment one counter
    dec   sp    ; decrement the other
    jnz   Decrypt   ; loop until all bytes are decrypted
    Start:            ; Virus body
    
    

    对于Cascade病毒如何使用堆栈指针来解密病毒体,上面代码中的注释已经做了很好的说明。相反,Cryptor病毒将其密钥存储在键盘缓冲区中,这些密钥会被调试器破坏。Tequila使用解密器的代码作为解密钥,因此如果解密器被调试器修改后,那么该病毒就无法解密了。下面是Tequila用于解密的代码:

    perform_encryption_decryption:
    
       mov bx,0
       mov si,0960
       mov cx,0960
      mov dl,b[si]
       xor b[bx],dl
       inc si
       inc bx
       cmp si,09a0
       jb 0a61             ;masm mod. needed
       mov si,0960
       loop 0a52           ;masm mod. needed
       ret
    
    the_file_decrypting_routine:
    
       push cs
       pop ds
       mov bx,4
       mov si,0964
       mov cx,0960
       mov dl,b[si]
       add b[bx],dl
       inc si
       inc bx
       cmp si,09a4
       jb 0a7e             ;masm mod. needed
       mov si,0964
       loop 0a6f           ;masm mod. needed
       jmp 0390            ;masm mod. needed
    
    
    

    人们正在研究可用于将来的新型反调试技术,其中一个项目的课题是关于多处器计算机的,因为当进行调试时,多处理器中的一个会处于闲置状态。这种新技术使用并行处理技术来解密代码。

    二、逆转录病毒

    逆转录病毒会设法禁用反病毒软件,比如可以通过携带一列进程名,并杀死正在运行的与表中同名的那些进程。许多逆转录病毒还把进程从启动列表中踢出去,这样该进程就无法在系统引导期间启动了。这种类型的恶意软件还会设法挤占反病毒软件的CPU时间,或者阻止反病毒软件连接到反病毒软件公司的服务器以使其无法更新病毒库。

    三、混合技术

    W32.Gobi病毒是一个多态逆转录病毒,它结合了EPO和其他一些反调试技术。该病毒还会在TCP端口666上打开一个后门。

    Simile(又名Metaphor)是一个非常有名的复合型病毒,它含有大约14,000行汇编代码。这个病毒通过寻找API调用ExitProcess()来使用EPO,它还是一个多态病毒,因为它使用多态解密技术。它的90%代码都是用于多态解密,该病毒的主体和多态解密器在每次感染新文件时,都会放到一个半随机的地方。Simile的第一个有效载荷只在3月、6月、9月或12月份才会激活。在这些月份的17日变体A和B显示它们的消息。变体C在这些月份的第18日显示它的消息。变体A和B中的第二个有效载荷只有在五月14日激活,而变体C中的第二个有效载荷只在7月14日激活。

    Ganda是一个使用EPO的逆转录病毒。它检查启动进程列表,并用一个return指令替换每个启动进程的第一个指令。这会使所有防病毒程序变得毫无用处。

    四、linux反调试技术简单示例

    1. int3指令

    Intel Software Developer’s Manual Volume 2A中提到:

    The INT 3 instruction generates a special one byte opcode (CC) that is intended for
    calling the debug exception handler. (This one byte form is valuable because it can be
    used to replace the first byte of any instruction with a breakpoint, including other one
    byte instructions, without over-writing other code).

    int3是一个特殊的中断指令(从名字上也看得出来),专门用来给调试器使用。这时,我们应该很容易想到,要反调试,只要插入int3来迷惑调试器即可。不过,这会不会影响正常的程序?会!因为int3会在用户空间产生SIGTRAP。没关系,我们只要忽略这个信号就可以了。

    1. #include <stdio.h>
    2. #include <signal.h>
    3.  
    4. void handler(int signo)
    5. {}
    6.  
    7. int main(void)
    8. {
    9.     signal(SIGTRAPhandler);
    10.     __asm__("nop\n\t"
    11.         "int3\n\t");
    12.     printf("Hello from main!\n");
    13.     return 0;
    14. }

    2. 文件描述符

    这是一个很巧妙的办法,不过它只对gdb之类的调试器有效。方法如下:

    1. #include <stdio.h>
    2. #include <stdlib.h>
    3. #include <unistd.h>
    4.  
    5. int main(void)
    6. {
    7.     if(close(3) == -1) {
    8.         printf("OK\n");
    9.     } else {
    10.         printf("traced!\n");
    11.         exit(-1);
    12.     }
    13.     return 0;
    14. }

    gdb要调试这个程序时会打开一个额外的文件描述符来读这个可执行文件,而这个程序正是利用了这个“弱点”。当然,你应该能猜到,这个技巧对strace是无效的。

    3. 利用getppid

    和上面一个手法类似,不过这个更高明,它利用getppid来进行探测。我们知道,在Linux上要跟踪一个程序,必须是它的父进程才能做到,因此,如果一个程序的父进程不是意料之中的bash等(而是gdb,strace之类的),那就说明它被跟踪了。程序代码如下:

    1. #include <stdio.h>
    2. #include <stdlib.h>
    3. #include <string.h>
    4. #include <unistd.h>
    5. #include <sys/types.h>
    6. #include <sys/stat.h>
    7. #include <fcntl.h>
    8.  
    9. int get_name_by_pid(pid_t pidcharname)
    10. {
    11.     int fd;
    12.     char buf[1024] = {0};
    13.     snprintf(buf1024"/proc/%d/cmdline"pid);
    14.     if ((fd = open(bufO_RDONLY)) == -1)
    15.         return -1;
    16.     read(fdbuf1024);
    17.     strncpy(namebuf1023);
    18.     return 0;
    19. }
    20.  
    21. int main(void)
    22. {
    23.     char name[1024];
    24.     pid_t ppid = getppid();
    25.     printf("getppid: %d\n"ppid);
    26.  
    27.         if (get_name_by_pid(ppidname))
    28.         return -1;
    29.     if (strcmp(name"bash") == 0 ||
    30.         strcmp(name"init") == 0)
    31.             printf("OK!\n");
    32.     else if (strcmp(name"gdb") == 0 ||
    33.         strcmp(name"strace") == 0 ||
    34.         strcmp(name"ltrace") == 0)
    35.         printf("Traced!\n");
    36.     else
    37.         printf("Unknown! Maybe traced!\n");
    38.  
    39.     return 0;
    40. }

    同样的手法,一个更简单的方式是利用session id。我们知道,不论被跟踪与否,session id是不变的,而ppid会变!下面的程序就利用了这一点。

    1. #include <stdio.h>
    2. #include <stdlib.h>
    3. #include <unistd.h>
    4.  
    5. int main(void)
    6. {
    7.     printf("getsid: %d\n"getsid(getpid()));
    8.     printf("getppid: %d\n"getppid());
    9.  
    10.         if (getsid(getpid()) != getppid()) {
    11.         printf("traced!\n");
    12.         exit(EXIT_FAILURE);
    13.     }
    14.         printf("OK\n");
    15.  
    16.     return 0;
    17. }

    4. 利用环境变量

    bash有一个环境变量叫$_,它保存的是上一个执行的命令的最后一个参数。如果在被跟踪的状态下,这个变量的值是会发生变化的(为什么?)。下面列出了几种情况:

                    argv[0]                    getenv("_")
    shell           ./test                     ./test
    strace          ./test                     /usr/bin/strace
    ltrace          ./test                     /usr/bin/ltrace
    gdb              /home/user/test           (NULL)
    

    所以我们也可以据此来判断。

    1. #include <stdio.h>
    2. #include <stdlib.h>
    3. #include <string.h>
    4.  
    5. int main( int argcchar *argv[])
    6. {
    7.     printf("getenv(_): %s\n"getenv("_"));
    8.     printf("argv[0]: %s\n"argv[0]);
    9.  
    10.     if(strcmp(argv[0](char *)getenv("_"))) {
    11.         printf("traced!\n");
    12.         exit(-1);
    13.     }
    14.  
    15.     printf("OK\n");
    16.         return 0;
    17. }

    5. 利用ptrace

    很简单,如果被跟踪了还再调用ptrace(PTRACE_TRACEME…)自然会不成功。

    1. #include <stdio.h>
    2. #include <sys/ptrace.h>
    3.  
    4. int main(void)
    5. {
    6.      if ( ptrace(PTRACE_TRACEME010) < 0 ) {
    7.         printf("traced!\n");
    8.         return 1;
    9.     }
    10.     printf("OK\n");
    11.     return 0;
    12. }

    四、小结

    本文中,我们介绍了恶意软件用以阻碍对其进行逆向工程的若干反调试技术,同时介绍了逆转录病毒和各种反检测技术的组合。我们应该很好的理解这些技术,只有这样才能够更有效地对恶意软件进行动态检测和分析。


    参考与转载:http://netsecurity.51cto.com/art/200810/92668_all.htm

        http://wangcong.org/blog/archives/310

    展开全文
  • 嵌入式软件调试技术 读书笔记

    千次阅读 2010-04-27 19:24:00
    第一章 软件调试概述 第二章 边界扫描测试技术 (JTAG) 第三章 学习使用GDB调试器 第四章 GDB远程调试技术 第五章 网络应用程序调试 第六章 多进程与多线程调试 第七章 静态库与动态库的调试 第八章 MPEG-4视频播放器...

    第一章 软件调试概述

    第二章 边界扫描测试技术 (JTAG)

    第三章 学习使用GDB调试器

    第四章 GDB远程调试技术

    第五章 网络应用程序调试

    第六章 多进程与多线程调试

    第七章 静态库与动态库的调试

    第八章 MPEG-4视频播放器的设计与调试

    第九章 基于GPS的移动定位终端

    参考文献

    边界扫描测试技术

    TIPS:

    1. TRST为什么是可选信号呢?

    因为通过TMS也可以复位测试逻辑

    2. 为什么在TMS信号上加上拉电阻?

    IEEE 1149.1规定,在TMS上没有输入信号驱动的情况下,测试逻辑工作应该和在TMS上送入高电平时是一样的,这样TAP控制器将会被强制进入Test-Logic-Reset状态。

    3. TDI TMS在上升沿采样,TDO在下降沿采样?

    为了避免在进行测试的时候存在竞争条件。

    4. JTAG调试接口由TAP控制器、指令寄存器、数据寄存器组3部分构成,而TAP控制器就是控制测试逻辑对指令(数据)寄存器进行操作的。

    5. 不管TAP控制器的原始状态是什么,只要TMS保持高电平至少5个TCK上升沿的时间以上,就将使得TAP控制器进入Test-Logic-Reset状态。只要TMS为高电平,就将使控制器保持在这一状态。

    6. 每个支持JTAG调试的芯片必须至少包含一个指令寄存器。对于特定的某款芯片而言,芯片生产厂商一般都会在IEEE1149.1标准的基础山扩充一些私有的指令寄存器和数据寄存器,以方便在开发过程中进行功能测试和诊断调试。

    7. JTAG标准允许不同的指令共享相同的二进制编码,通过TAP控制器的当前状态来区分指令的行为。他们是OPCODE相同的不同指令,如SAMPLE和PRELOAD

    8. JTAG公共指令(public)以及私有指令(private)

    通过自检对元件进行测试的能力

    能依靠边界扫描寄存器对板级互联进行测试的能力

    9. 必须包含的共有指令:

    BYPASS

    SAMPLE

    PRELOAD

    EXTEST

    IDCODE(有设备ID寄存器的情况)

    10. BYPASS:二进制编码所有位都为1

    其指令的目的是将BYPASS寄存器链入该元件自身的TDI和TDO之间,这样就可以使测试数据能够快速地从扫描链中通过。

    11. SAMPLE:

    将边界扫描寄存器串行地链入TDI和TDO之间。

    12. PRELOAD:和SAMPLE类似,但是数据流的方向是反的。S是将元件核心逻辑或外部引脚上的信号状态加载到边界扫描链中,而PRELOAD指令是将边界扫描链中的数据值送入核心逻辑或外部引脚。

    13. EXTEST:用于板级互联性测试。

    14. 设备ID寄存器包括3部分

    厂商ID,11bits压缩码

    零件编号16bits编码

    版本号4bits

    其核心就是Test Access Port Controller(测试访问端口控制器)的内部这个有限状态机见下图:

    clip_image002

    多进程与多线程调试

    TIPS:

    1. UNIX类系统中,进程的创建几乎都是通过fork()系统调用来完成

    2. fork出的子进程会继承父进程的大部分内容,如程序段、数据段、堆栈段、用户ID、组ID、控制终端和环境变量等。

    3. fork函数在被调用一次后会返回两次,一次在父进程中,返回值为子进程的ID,一次在子进程中返回值是0

    4. fork完成后,父进程还是子进程先被调度,这个顺序不是确定的

    5. Linux下,fork采用了copy-on-write的技术,fork出子进程后并不立即对父进程的资源进行复制,而是当子进程试图向这些空间中写入数据时,才由内核完成真正的复制工作。写到这里我想到实际遇到的一个问题(2.4.25的kernel),64Mbytes的系统内存,除去系统占用的10MB,主进程起来用了30MB的内存,当调用System的时候就会失败,erro no是内存申请失败。其原因是fork虽然采用了copy-on-write的技术,但是在创建进程的时候还是会检测剩下的内存是否够主进程的尺寸。因为他考虑到你可能会去修改,而实际上你并没有这样做。最后解决问题的办法是通过proc接口配置了内核的一个参数,在做内存申请判断的时候,当内存不足的情况下也返回成功。

    6. GDB并没有为多进程程序提供太多的支持,通常情况下,fork出来的子进程将脱离GDB的控制。

    7. follow-fork-mode [mode]

    mode可以是parent和child。为P,fork后GDB调试父进程,为C则调试子进程

    对应的命令:set follow-fork-mode [mode]; show follow-fork-mode;

    8. detach-on-fork [mode] mode: on / off

    为on(默认情况)不被GDB控制的进程(参考follow-fork-mode的配置)将脱离GDB而独立运行,为off,这不被GDB控制的进程将被挂起。

    对应的命令:set detach-on-fork [mode]; show detach-on-fork

    9. info forks,该命令将打印出出于GDB控制下的多有fork出的进程列表。

    10. fork [fork-id],该命令将fork-id对应的进程设成当前进程

    11. detach-fork [fork-id],使fork-id对应的进程脱离GDB的控制

    12. delete fork [fork-id],将杀死fork-id对应的进程通过在info forks列表中将其删除

    13. 线程可以看做轻量级的进程(以前课本上叫轻权进程),线程的优势在于所占用的资源少,执行效率高,上下文切换快,但缺点是难以对资源进行包含和管理。

    14. Linux下的两套线程的API:LinuxThread,Pthreads(POSIX),前者我记得到了2.6才开始有了支持,2.4的环境下没有使用过。

    15. GDB对多线程调试的支持:自动通知新线程的产生;在多个线程之间切换;查询当前线程的信息;将命令运用到一组线程上;特定于线程的断点

    16. info threads 用来获得当前进程中所有线程的概要信息。

    17. thread [threadno] 切换当前的线程为threadno所对应的线程。

    18. thread apply [threadno | all] command,将在threadno所代表的线程上应用command命令。

    19. break linespec thread threadno

    break linespec thread threadno if cond

    这两个命令可以设置特定于线程的断点

    20. set scheduler-locking mode

    该命令能够选择是否锁住内核线程调度。Mode可以是off、on和step

    Off不会锁住内核线程调度,因此该线程能够在任何时刻被调度

    On完全锁住内核线程调度,只有当前线程能够运行

    Step单步指令不会引起其他线程运行,别的指令如C可以使其他线程运行。

    静态库与动态库的调试

    1. 目标文件的归档工具ar,和文件的归档工具tar相比,他会为被归档的目标文件中的符号表建立索引,还可以对归档文件执行追加、修改和删除等操作。

    2. 通常我们使用的归档命令是:ar rcs libxxx.a obj1.o … objx.o

    r: 将成员(objx.o)插入到archive中,如果archive有同名的成员,则替换已有的成员。默认情况下插入到archive中的成员会被添加到archive的末尾

    c: 创建archive

    s: 将目标文件的索引写入archive中

    注: ar s 命令等同于归档文件使用的ranlib命令

    v: 该参数显示详细信息

    t: 显示archive中的成员

    3. ar生成的文件是否经过压缩的呢?还是像tar不带j和z参数一样是不压缩的?

    我觉得是不会压缩的(经过试验发现是不压缩的

    4. 用交叉编译器上的ar和主机的ar工具对同样的目标文件归档,其输出的结果有什么不同?

    我用ar和mips-linux-ar进行了对比,发现生成的文件除了时间戳不一样,剩下部分是一样的。

    关于AR的文件头参考:http://en.wikipedia.org/wiki/Ar_(Unix)

    Global header

    The global header is a single field containing the magic ASCII string "!" followed by a single LF control character

    File header

    The common format is as follows.

    Field Offset from

    Field Offset to

    Field Name

    Field Format

    0

    15

    File name

    ASCII

    16

    27

    File modification timestamp

    Decimal

    28

    33

    Owner ID

    Decimal

    34

    39

    Group ID

    Decimal

    40

    47

    File mode

    Octal

    48

    57

    File size in bytes

    Decimal

    58

    59

    File magic

    0x60 0x0A

    Due to the limitations of file name length and format, both the GNU and BSD variants devised different methods of storing long filenames.

    5. nm是一个很有用的工具,通过他可以获得archive中obj文件里的符号信息

    6. 对于静态库的GDB调试,只要保证生成库文件的目标文件是带调试信息的,即生成该文件时是含有-g参数的

    7. 对于静态链接生成可执行文件时,连接器会将静态链接库复制一份到最终得到的可执行代码中去。那么对于库里没有使用到的函数是否也会复制一份呢?

    通过实验证明即使不用到也是会复制的(gcc环境)

    8. 动态库的优缺点

    优点

    a> 节省内存,如果多个应用程序使用到同一个动态链接库,内存是只会存在一个动态共享库的副本。

    b> 节省硬盘,同样如果多个应用程序使用到同一个动态库,那么在存储介质上只会有一个副本。

    c> 便于软件的修复和升级,因为动态库和应用程序是独立的文件,所以可以做到分开维护,这样可以减少编译和软件升级的时间。

    d> 提高性能,当一个用到该动态库的应用程序已经在运行,那么启动另外个的时候不会再加载动态库,这可以节省些时间。

    缺点

    e> 复杂性。首先动态库的API接口不能轻易变动,其次动态库函数必须是可重入的,再次动态库复杂的依赖关系对于软件移植是不利的。

    f> 兼容性,因为动态库和应用程序是分开发表的,就要保证好其版本的依赖性。

    g> 调试困难,在嵌入式环境下调试动态库是不容易的(其实我觉得也没复杂多少)

    9. 动态库的命令(soname = share object name),例如系统里可以找到一个libc.so.6的文件,实际上是指向libc-2.3.2.so的软连接,是谁来建立这个软连接呢?是ldconfig,其工作1是建立软连接2是更新共享库的cache /etc/ld.so.cache 那么他是如何工作的呢?首先在libc-2.3.2.so中包含了soname的信息,通过命令readelf –d libc-2.3.2.so可以看到:

    0x0000000e (SONAME) Library soname : [libc.so.6]

    在/etc/ld.so.conf文件中定义的所有动态库的搜索路径,这样ldconfig就可以通过搜索这些目录找到所有的动态库,生成符号链接,更新cache

    10. 在连接可执行程序指定链接库的时候像 libc.so.6只要写成 –lc就可以了。

    11. 除非在编译应用程序时明确地使用GCC的-static选项,否则GCC将使用动态共享库与应用程序链接。问题是:如果目录下同时存在共享库和动态库,那是优先共享库么?

    通过实验证明,是优先共享库的如何指定使用静态库还不清楚

    -static选项的确可以生成使用静态库的image,不过值得注意的是所有的库都会使用静态库,包括libc,所以生成的image会比较庞大。

    通过,对于动态库也是将完整的库镜像加载到应用程序的虚拟内存上的,而不仅仅是那些应用程序用到的部分,这点是比较好理解的,毕竟动态库可能很多人用到,不可能一一处理。

    12. 共享库是不需要ar来打包的,直接通过gcc就可以了。下面是要使用到的几个gcc的参数

    -shared 告诉gcc生成共享目标文件,此选项必须和-fPIC等选项联合使用。(实际中我记得没有使用这个-fPIC的参数,好像也没什么问题)

    -fPIC 生成位置无关代码(Position-Independent Code, PIC)

    -Wl,-soname,xxx gcc会把-soname,xxx作为一个命令行选项传递给链接器ld,来生成类似0x0000000e (SONAME) Library soname : [libc.so.6]的信息,例如生成libc-2.3.2.so时的参数 –Wl,-soname,libc.so.6

    -fomit-frame-pointer 编译后的程序中不使用指令来保存和恢复函数的栈帧寄存器,这样可以提高程序的执行效率,副作用是某些平台上调试器将不正常工作(例如bt可能不正常,个人理解)

    13. 生成动态库的命令:gcc –shared –Wl,soname,xxx –o libname filelist liblist

    14. 对于动态库进行GDB调试的时候一个主要的问题是动态库的不会像应用程序一样自动的加载,对于远程调试还有一个主要问题是要区别好local和target上的动态库。

    15. 远程动态库,一般调试过程:

    a> 启动gdbserver,client连接上后在动态库加载完成的地方设置断点(通常在main上)

    b> 找到动态库在应用程序虚拟内存上的加载地址,例如:

    cat /proc/758/maps

    4001d000 – 4001e000 r-xp 00000000 1f:03 2921 /usr/local/lib/libfoobar.so.0.0

    这里只要这个0x4001d000地址

    c> 找到动态库文件中,text段在文件中的偏移地址,例如:

    arm-linux-objdump –h libfoobar.so.0.0

    idx Name Size VMA LMA File off Algn

    9. text 000001bc 0000073c 0000073c 0000073c 2**2

    这里需要的是0000073c这个地址

    d> 在叠加出的地址上通过命令进行gdb的调试符号加载,例如:

    (gdb) add-symbol-file libfoobar.so.0.0 0x4001d000+0x0000073c

    16. add-symbol-file file addr [-s -s …]

    从file中加载符号

    17. set auto-solib-add mode

    mode = on, 所有动态库中的符号都将自动被加载,自动加载的时刻:

    a> 当被调试程序开始运行时

    b> 当gdb连接到被调试程序是(attach)

    c> 当操作系统的动态链接器通知gdb有新的动态库被加载时

    默认下为off

    18. show auto-solib-add

    19. sharedlibrary regex

    从正则表示式regex指定的动态库中加载符号

    20. nosharedlibrary

    将丢弃所有从动态库中加载的符号信息

    21. info sharedlibrary

    打印当前所有加载的动态库的文件名

    22. set solib-absolute-prefix path

    show solib-absolute-prefix

    path将用作动态共享库觉得路径的前缀。这点是针对远程调试的,例如:

    /opt/arm-linux/usr/lib

    23. set solib-search-path path

    path是一个以冒号分隔的目录列表,gdb将在这些目录列表里搜索动态共享库。该搜索操作会在solib-absolute-prefix搜索后进行

    后面为AVI部分的介绍,主要是RIFF和相关的一些规范,后面是C的一个协议实现libavi,大量篇幅在数据结构的定义和代码实现上,真正和调试相关的只占了5页,这部分目前用不上,略读,跳过。

    参考文献

    1. Andrew S. Tanenbaum, Albert S. Woodhull. 操作系统:设计与实现. 第二版。 尤晋元,等译。 北京:电子工业出版社,2003

    2. Bill Blunden。虚拟机的设计与实现------C/C++。杨涛,等译。北京:机械工业出版社,2003

    3. 毛德操,胡希明。Liux内核源代码情景分析。上册。浙江:浙江大学出版社,2001.

    4. 赵民栋。嵌入式软件集成开发环境中调试器的设计与实现。西安:西北工业大学硕士学位论文,2004

    5. Norman Matloff,P.J. Salzmann. The Art of Debugging with GDB and DDD. No Starch Press, 2006

    6. Richard Stallman, 等。Debugging with GDB. Free Software Foundation, 2006

    7. Jonathan B. Rosenberg. How Debugger Work: Algorithms, Data Structures, and Architecture. John Wiley & Sons. Inc, 1996.

    8. IA-32 Intel Architecture Software Developer’s Manual Volume 3B: System Programming Guide Part 2. Intel Co., 2006.

    9. 毛德操,胡希明。嵌入式系统 --- 采用公开源代码和StrongARM/Xscale 处理器。浙江: 浙江大学出版社,2003.

    10. 黄红燕。嵌入式系统调试技术的分析与设计。浙江:浙江大学硕士学位论文,2006.

    11. Test Technology Standards Committee of the IEEE Computer Society. IEEE Standard Test Access Port and Boundary-Scan Architecture. 2001

    12. John F. Wakerly. 数字设计----原理与实践。影印版。第三版。北京:高等教育出版社,2003

    13. twentyone。 ARM JTAG调试原理。 http://twentyone,blogchina.com/index.html.

    14. ARM7TDMI Technical Reference Manual. ARM Limited. 2001

    15. W. Richard Stevens. UNIX 环境高级编程. 尤晋元,等译。 北京:机械工业出版社,2005

    16. Jeffrey E.F.Friedl. 精通正则表达式。英文影印版。第二版。南京:东南大学出版社,2005

    17. 严蔚敏,吴伟民。数据结构。C语言版。北京:清华大学出版社,2002

    18. 雷航,王茜。现代微处理器及总线技术。北京:国防工业出版社,2006

    19. Anany Levitin. 算法设计与分析基础。影印版。北京:清华大学出版社,2003.

    20. Samuel P. Harbison, 等。 C语言参考手册。英文版。第五版。北京:人民邮电出版社,2003

    21. Kurt Wall, 等。GNU/Linux 编程指南。张辉,译。北京:清华大学出版社,2005.

    22. Geert Uytterhoeven. The Frame Buffer Device. 2001

    23. Alex Buell. Framebuffer Howto. 2000

    24. Linux 2.2 Framebuffer Device Programming Tutorial. www.linuxsir.org

    25. [美]冈萨雷斯,等。数字图像处理。第二版。阮秋琦,等译。北京:电子工业出版社,2005

    26. Thomas G. Lane. USING THE IJG JPGE LIBRARY. 1994-1998

    27. Sitang-PXA255 Evaluation Platform User Guild. Intel Inc, 2003

    28. Sitang-PXA255 Evaluation Platform Linux User Guild. Intel Inc, 2003

    29. John Shapley Gray. UNIX进程同学。第二版。张宇,译。北京:电子工业出版社,2001

    30. 赵炯。Linux Developing History. www.plinux.org

    31. 林锐。高质量C++/C编程指南。上海:上海贝尔公司,2001

    32. 汤凯。OSS---跨平台的音频接口简介。http://www-128ibm.com/developerworks/cn/linux/l-ossaip/

    33. 肖文鹏。Linux音频编程指南。http://www-128ibm.com/developerworks/cn/linux/l-audio/

    34. Open Sound System Programmer’s Guild. www.opensound.com

    35. W. Richard Stevens. TCP/IP详解。范建华,等译。北京:机械工业出版社,2000

    36. W. Richard Stevens. UNIX网络编程。第二版,施振川,等译。北京:清华大学出版社,2001

    37. Marshall Kirk McKusick,等。4.4BSD操作系统设计与实现。英文影音版。北京:人民邮电出版社,2001

    38. Michael R. Sweet. Serial Programming Guide for POSIX Operating System. 5th Edition. http://digilander.libero.it/robang/serial.htm#CONTENTS. 1994-1999

    39. CDMA AT Commands Interface Specification v1.78. 2003

    40. http://www.unicode.org

    41. W. Richard Stevens. UNIX网络编程。第二版。杨继张,译。北京:北京科海电子出版社,2000

    42. Bil Lewis, Daniel J. Berg. PThreads Primers: A Guide to Multithreaded Programming. Prentice Hall PTR, 1995

    43. David R.Butenhof. POSIX 多线程程序设计。于磊,曾刚,译。北京:中国电力出版社,2003

    44. Portable Applications Standards Committee of the IEEE Computer Society and The Open Group. Standard for Information Technology ---- Portable Operating System Interface (POSIX ) System Intefaces. 2004

    45. 嵌入式Linux调试:用gdbserver调试共享库。http://blog.csdn.net/absurd/archive/2006/06/18/804810.aspx. 2006

    46. AVI RIFF File Reference. http://msdn2.microsoft.com/en-us/library/ms779636.aspx

    47. Matrox Electronic System Ltd.. OpenDML AVI File Format Extensions(version 1.02). 1997

    48. Iain E. G. Richardson. H.264 and MPEG-4 Video Compression ---- Video Coding for Next-genertaion Multimedia. John Wiley & Sons Inc. , 2003

    49. http://www.xvid.org

    50. SDL Library Documentation. http://www.libsdl.org

    51. http://www.nmea.org

    52. GARMIN Inc.. GPS 15 Technical Specifications. 2002

    53. 北京飞漫软件技术有限公司。MiniGUI编程指南 for MiniGUI Ver 1.3.x. 2003

    54. 北京飞漫软件技术有限公司。MiniGUI用户手册for MiniGUI Ver 1.3.x. 2003

    55. 北京飞漫软件技术有限公司。MiniGUI API Reference Documentation for MiniGUI Ver 1.3.x. 2003

    56. 谭浩强。 C程序设计。第二版。北京:清华大学出版社,2005

    57. http://www.tcpdump.org

    58. 李天文。GPS原理及应用。北京:科学出版社,2004.

    展开全文
  • 本文中,我们将向读者介绍恶意软件用以阻碍对其进行逆向工程的各种反调试技术,以帮助读者很好的理解这些技术,从而能够更有效地对恶意软件进行动态检测和分析。 一、反调试技术调试技术是一种常见的反检测...

    本文中,我们将向读者介绍恶意软件用以阻碍对其进行逆向工程的各种反调试技术,以帮助读者很好的理解这些技术,从而能够更有效地对恶意软件进行动态检测和分析。

    一、反调试技术

    反调试技术是一种常见的反检测技术,因为恶意软件总是企图监视自己的代码以检测是否自己正在被调试。为做到这一点,恶意软件可以检查自己代码是否被设置了断点,或者直接通过系统调用来检测调试器。

    1.断点

    为了检测其代码是否被设置断点,恶意软件可以查找指令操作码0xcc(调试器会使用该指令在断点处取得恶意软件的控制权),它会引起一个SIGTRAP。如果恶意软件代码本身建立了一个单独的处理程序的话,恶意软件也可以设置伪断点。用这种方法恶意软件可以在被设置断点的情况下继续执行其指令。

    恶意软件也可以设法覆盖断点,例如有的病毒采用了反向解密循环来覆盖病毒中的断点。相反,还有的病毒则使用汉明码自我纠正自身的代码。汉明码使得程序可以检测并修改错误,但是在这里却使病毒能够检测并清除在它的代码中的断点。

    2.计算校验和

    恶意软件也可以计算自身的校验和,如果校验和发生变化,那么病毒会假定它正在被调试,并且其代码内部已被放置断点。VAMPiRE是一款抗反调试工具,可用来逃避断点的检测。VaMPiRE通过在内存中维护一张断点表来达到目的,该表记录已被设置的所有断点。该程序由一个页故障处理程序(PFH),一个通用保护故障处理程序(GPFH),一个单步处理程序和一个框架API组成。当一个断点被触发的时候,控制权要么传给PFH(处理设置在代码、数据或者内存映射I/O中的断点),要么传给GPFH(处理遗留的I/O断点)。单步处理程序用于存放断点,使断点可以多次使用。

    3.检测调试器

    在Linux系统上检测调试器有一个简单的方法,只要调用Ptrace即可,因为对于一个特定的进程而言无法连续地调用Ptrace两次以上。在Windows中,如果程序目前处于被调试状态的话,系统调用isDebuggerPresent将返回1,否则返回0。这个系统调用简单检查一个标志位,当调试器正在运行时该标志位被置1。直接通过进程环境块的第二个字节就可以完成这项检查,以下代码为大家展示的就是这种技术:

    mov eax, fs:[30h]
    move eax, byte [eax+2]
    test eax, eax    
    jne @DdebuggerDetected
    

    在上面的代码中,eax被设置为PEB(进程环境块),然后访问PEB的第二个字节,并将该字节的内容移入eax。通过查看eax是否为零,即可完成这项检测。如果为零,则不存在调试器;否则,说明存在一个调试器。

    如果某个进程为提前运行的调试器所创建的,那么系统就会给ntdll.dll中的堆操作例程设置某些标志,这些标志分别是FLG_HEAP_ENABLE_TAIL_CHECK、FLG_HEAP_ENABLE_FREE_CHECK和FLG_HEAP_VALIDATE_PARAMETERS。我们可以通过下列代码来检查这些标志:

    mov eax, fs:[30h]
    mov eax, [eax+68h]
    and eax, 0x70
    test eax, eax
    jne @DebuggerDetected
    

    在上面的代码中,我们还是访问PEB,然后通过将PEB的地址加上偏移量68h到达堆操作例程所使用的这些标志的起始位置,通过检查这些标志就能知道是否存在调试器。

    检查堆头部内诸如ForceFlags之类的标志也能检测是否有调试器在运行,如下所示:

    mov eax, fs:[30h]
    mov eax, [eax+18h] ;process heap
    mov eax, [eax+10h] ;heap flags
    test eax, eax
    jne @DebuggerDetected
    

    上面的代码向我们展示了如何通过PEB的偏移量来访问进程的堆及堆标志,通过检查这些内容,我们就能知道Force标志是否已经被当前运行的调试器提前设置为1了。

    另一种检测调试器的方法是,使用NtQueryInformationProcess这个系统调用。我们可以将ProcessInformationClass设为7来调用该函数,这样会引用ProcessDebugPort,如果该进程正在被调试的话,该函数将返回-1。示例代码如下所示。

    push 0
    push 4
    push offset isdebugged
    push 7 ;ProcessDebugPort
    push -1
    call NtQueryInformationProcess
    test eax, eax
    jne @ExitError
    cmp isdebugged, 0
    jne @DebuggerDetected

    在本例中,首先把NtQueryInformationProcess的参数压入堆栈。这些参数介绍如下:第一个是句柄(在本例中是0),第二个是进程信息的长度(在本例中为4字节),接下来是进程信息类别(在本例中是7,表示ProcessDebugPort),下一个是一个变量,用于返回是否存在调试器的信息。如果该值为非零值,那么说明该进程正运行在一个调试器下;否则,说明一切正常。最后一个参数是返回长度。使用这些参数调用NtQueryInformationProcess后的返回值位于isdebugged中。随后测试该返回值是否为0即可。

    另外,还有其他一些检测调试器的方法,如检查设备列表是否含有调试器的名称,检查是否存在用于调试器的注册表键,以及通过扫描内存以检查其中是否含有调试器的代码等。

    另一种非常类似于EPO的方法是,通知PE加载器通过PE头部中的线程局部存储器(TLS)表项来引用程序的入口点。这会导致首先执行TLS中的代码,而不是先去读取程序的入口点。因此,TLS在程序启动就可以完成反调试所需检测。从TLS启动时,使得病毒得以能够在调试器启动之前就开始运行,因为一些调试器是在程序的主入口点处切入的。

    4.探测单步执行

    恶意软件还能够通过检查单步执行来检测调试器。要想检测单步执行的话,我们可以把一个值放进堆栈指针,然后看看这个值是否还在那里。如果该值在那里,这意味着,代码正在被单步执行。当调试器单步执行一个进程时,当其取得控制时需要把某些指令压入栈,并在执行下一个指令之前将其出栈。所以,如果该值仍然在那里,就意味着其它正在运行的进程已经在使用堆栈。下面的示例代码展示了恶意软件是如何通过堆栈状态来检测单步执行的:

    Mov bp,sp;选择堆栈指针
    Push ax ;将ax压入堆栈
    Pop ax ;从堆栈中选择该值
    Cmp word ptr [bp -2],ax ;跟堆栈中的值进行比较
    Jne debug ;如果不同,说明发现了调试器。  
    

    如上面的注释所述,一个值被压入堆栈然后又被弹出。如果存在调试器,那么堆栈指针–2位置上的值就会跟刚才弹出堆栈的值有所不同,这时就可以采取适当的行动。

    5.在运行时中检测速度衰减

    通过观察程序在运行时是否减速,恶意代码也可以检测出调试器。如果程序在运行时速度显著放缓,那就很可能意味着代码正在单步执行。因此如果两次调用的时间戳相差甚远,那么恶意软件就需要采取相应的行动了。Linux跟踪工具包LTTng/LTTV通过观察减速问题来跟踪病毒。当LTTng/LTTV追踪程序时,它不需要在程序运行时添加断点或者从事任何分析。此外,它还是用了一种无锁的重入机制,这意味着它不会锁定任何Linux内核代码,即使这些内核代码是被跟踪的程序需要使用的部分也是如此,所以它不会导致被跟踪的程序的减速和等待。

    6.指令预取

    如果恶意代码篡改了指令序列中的下一条指令并且该新指令被执行了的话,那么说明一个调试器正在运行。这是指令预取所致:如果该新指令被预取,就意味着进程的执行过程中有其他程序的切入。否则,被预取和执行的应该是原来的指令。

    7.自修改代码

    恶意软件也可以让其他代码自行修改(自行修改其他代码),这样的一个例子是HDSpoof。这个恶意软件首先启动了一些异常处理例程,然后在运行过程中将其消除。这样一来,如果发生任何故障的话,运行中的进程会抛出一个异常,这时病毒将终止运行。此外,它在运行期间有时还会通过清除或者添加异常处理例程来篡改异常处理例程。在下面是HDSpoof清除全部异常处理例程(默认异常处理例程除外)的代码。

    exception handlers before:

    0x77f79bb8 ntdll.dll:executehandler2@20 + 0x003a
    0x0041adc9 hdspoof.exe+0x0001adc9
    0x77e94809 __except_handler3

    exception handlers after:

    0x77e94809 __except_handler3

    0x41b770: 8b44240c       mov      eax,dword ptr [esp+0xc]
    0x41b774: 33c9           xor      ecx,ecx              
    0x41b776: 334804         xor      ecx,dword ptr [eax+0x4]
    0x41b779: 334808         xor      ecx,dword ptr [eax+0x8]
    0x41b77c: 33480c         xor      ecx,dword ptr [eax+0xc]
    0x41b77f: 334810         xor      ecx,dword ptr [eax+0x10]
    0x41b782: 8b642408       mov      esp,dword ptr [esp+0x8]
    0x41b786: 648f0500000000 pop      dword ptr fs:[0x0]   

    下面是HDSpoof创建一个新的异常处理程序的代码。

    0x41f52b: add      dword ptr [esp],0x9ca
    0x41f532: push     dword ptr [dword ptr fs:[0x0]
    0x41f539: mov      dword ptr fs:[0x0],esp
    

    8.覆盖调试程序信息

    一些恶意软件使用各种技术来覆盖调试信息,这会导致调试器或者病毒本身的功能失常。通过钩住中断INT 1和INT 3(INT 3是调试器使用的操作码0xCC),恶意软件还可能致使调试器丢失其上下文。这对正常运行中的病毒来说毫无妨碍。另一种选择是钩住各种中断,并调用另外的中断来间接运行病毒代码。

    下面是Tequila 病毒用来钩住INT 1的代码:

    new_interrupt_one:
    
       push bp
       mov bp,sp
       cs cmp b[0a],1      ;masm mod. needed
       je 0506             ;masm mod. needed
       cmp w[bp+4],09b4
       ja 050b             ;masm mod. needed
       push ax
       push es
       les ax,[bp+2]
       cs mov w[09a0],ax   ;masm mod. needed
       cs mov w[09a2],es   ;masm mod. needed
       cs mov b[0a],1
       pop es
       pop ax
       and w[bp+6],0feff
       pop bp
       iret
    
    

    一般情况下,当没有安装调试器的时候,钩子例程被设置为IRET。V2Px使用钩子来解密带有INT 1和INT 3的病毒体。在代码运行期间,会不断地用到INT 1和INT 3向量,有关计算是通过中断向量表来完成的。

    一些病毒还会清空调试寄存器(DRn的内容。有两种方法达此目的,一是使用系统调用NtGetContextThread和NtSetContextThread。而是引起一个异常,修改线程上下文,然后用新的上下文恢复正常运行,如下所示:

    push offset handler
    push dword ptr fs:[0]
    mov fs:[0],esp
    xor eax, eax
    div eax ;generate exception
    pop fs:[0]
    add esp, 4
    ;continue execution
    ;...
    handler:
    mov ecx, [esp+0Ch] ;skip div
    add dword ptr [ecx+0B8h], 2 ;skip div
    mov dword ptr [ecx+04h], 0 ;clean dr0
    mov dword ptr [ecx+08h], 0 ;clean dr1
    mov dword ptr [ecx+0Ch], 0 ;clean dr2
    mov dword ptr [ecx+10h], 0 ;clean dr3
    mov dword ptr [ecx+14h], 0 ;clean dr6
    mov dword ptr [ecx+18h], 0 ;clean dr7
    xor eax, eax
    ret
    

    上面的第一行代码将处理程序的偏移量压入堆栈,以确保当异常被抛出时它自己的处理程序能取得控制权。之后进行相应设置,包括用自己异或自己的方式将eax设为0,以将控制权传送给该处理程序。div eax 指令会引起异常,因为eax为0,所以AX将被除以零。该处理程序然后跳过除法指令,清空dr0-dr7,同样也把eax置0,表示异常将被处理,然后恢复运行。

    9.解除调试器线程

    我们可以通过系统调用NtSetInformationThread从调试器拆卸线程。为此,将ThreadInformationClass设为0x11(ThreadHideFromDebugger)来调用NtSetInformationThread,如果存在调试器的话,这会将程序的线程从调试器拆下来。以下代码就是一个例子:

    push 0
    push 0
    push 11h ;ThreadHideFromDebugger
    push -2
    call NtSetInformationThread
    

    在本例中,首先将NtSetInformationThread的参数压入堆栈,然后调用该函数来把程序的线程从调试器中去掉。这是因为这里的0用于线程的信息长度和线程信息,传递的-2用于线程句柄,传递的11h用于线程信息类别,这里的值表示ThreadHideFromDebugger。

    10.解密

    解密可以通过各种防止调试的方式来进行。有的解密依赖于特定的执行路径。如果这个执行路径没被沿用,比如由于在程序中的某个地方启动了一个调试器,那么解密算法使用的值就会出错,因此程序就无法正确进行自身的解密。HDSpoof使用的就是这种技术。

    一些病毒使用堆栈来解密它们的代码,如果在这种病毒上使用调试器,就会引起解密失败,因为在调试的时候堆栈为INT 1所用。使用这种技术的一个例子是W95/SK病毒,它在堆栈中解密和构建其代码;另一个例子是Cascade病毒,它将堆栈指针寄存器作为一个解密密钥使用。代码如下所示:

    lea   si, Start   ; position to decrypt
    mov   sp, 0682  ; length of encrypted body
    
    Decrypt:
    
    xor   [si], si    ; decryption key/counter 1
    xor   [si], sp  ; decryption key/counter 2
    inc   si    ; increment one counter
    dec   sp    ; decrement the other
    jnz   Decrypt   ; loop until all bytes are decrypted
    Start:            ; Virus body
    
    

    对于Cascade病毒如何使用堆栈指针来解密病毒体,上面代码中的注释已经做了很好的说明。相反,Cryptor病毒将其密钥存储在键盘缓冲区中,这些密钥会被调试器破坏。Tequila使用解密器的代码作为解密钥,因此如果解密器被调试器修改后,那么该病毒就无法解密了。下面是Tequila用于解密的代码:

    perform_encryption_decryption:
    
       mov bx,0
       mov si,0960
       mov cx,0960
      mov dl,b[si]
       xor b[bx],dl
       inc si
       inc bx
       cmp si,09a0
       jb 0a61             ;masm mod. needed
       mov si,0960
       loop 0a52           ;masm mod. needed
       ret
    
    the_file_decrypting_routine:
    
       push cs
       pop ds
       mov bx,4
       mov si,0964
       mov cx,0960
       mov dl,b[si]
       add b[bx],dl
       inc si
       inc bx
       cmp si,09a4
       jb 0a7e             ;masm mod. needed
       mov si,0964
       loop 0a6f           ;masm mod. needed
       jmp 0390            ;masm mod. needed
    
    
    

    人们正在研究可用于将来的新型反调试技术,其中一个项目的课题是关于多处器计算机的,因为当进行调试时,多处理器中的一个会处于闲置状态。这种新技术使用并行处理技术来解密代码。

    二、逆转录病毒

    逆转录病毒会设法禁用反病毒软件,比如可以通过携带一列进程名,并杀死正在运行的与表中同名的那些进程。许多逆转录病毒还把进程从启动列表中踢出去,这样该进程就无法在系统引导期间启动了。这种类型的恶意软件还会设法挤占反病毒软件的CPU时间,或者阻止反病毒软件连接到反病毒软件公司的服务器以使其无法更新病毒库。

    三、混合技术

    W32.Gobi病毒是一个多态逆转录病毒,它结合了EPO和其他一些反调试技术。该病毒还会在TCP端口666上打开一个后门。

    Simile(又名Metaphor)是一个非常有名的复合型病毒,它含有大约14,000行汇编代码。这个病毒通过寻找API调用ExitProcess()来使用EPO,它还是一个多态病毒,因为它使用多态解密技术。它的90%代码都是用于多态解密,该病毒的主体和多态解密器在每次感染新文件时,都会放到一个半随机的地方。Simile的第一个有效载荷只在3月、6月、9月或12月份才会激活。在这些月份的17日变体A和B显示它们的消息。变体C在这些月份的第18日显示它的消息。变体A和B中的第二个有效载荷只有在五月14日激活,而变体C中的第二个有效载荷只在7月14日激活。

    Ganda是一个使用EPO的逆转录病毒。它检查启动进程列表,并用一个return指令替换每个启动进程的第一个指令。这会使所有防病毒程序变得毫无用处。

    四、小结

    本文中,我们介绍了恶意软件用以阻碍对其进行逆向工程的若干反调试技术,同时介绍了逆转录病毒和各种反检测技术的组合。我们应该很好的理解这些技术,只有这样才能够更有效地对恶意软件进行动态检测和分析。


    展开全文
  • Linux内核、驱动开发中的printk打印技巧、日志系统、函数调用栈、动态调试、strace命令、内核转储、使用proc文件系统查看内核信息等查看Linux内核日志及打印信息的各种工具和方法。
  • 简介: 您可以用各种方法来监控运行着的用户空间程序:可以为其运行调试器并单步调试该程序,添加打印语句,或者添加工具来分析程序。本文描述了几种可以用来调试在 Linux 上运行的程序的方法。我们将回顾四种调试...

    简介: 您可以用各种方法来监控运行着的用户空间程序:可以为其运行调试器并单步调试该程序,添加打印语句,或者添加工具来分析程序。本文描述了几种可以用来调试在 Linux 上运行的程序的方法。我们将回顾四种调试问题的情况,这些问题包括段错误,内存溢出和泄漏,还有挂起。


    本文讨论了四种调试 Linux 程序的情况。在第 1 种情况中,我们使用了两个有内存分配问题的样本程序,使用 MEMWATCH 和 Yet Another Malloc Debugger(YAMD)工具来调试它们。在第 2 种情况中,我们使用了 Linux 中的 strace 实用程序,它能够跟踪系统调用和信号,从而找出程序发生错误的地方。在第 3 种情况中,我们使用 Linux 内核的 Oops 功能来解决程序的段错误,并向您展示如何设置内核源代码级调试器(kernel source level debugger,kgdb),以使用 GNU 调试器(GNU debugger,gdb)来解决相同的问题;kgdb 程序是使用串行连接的 Linux 内核远程 gdb。在第 4 种情况中,我们使用 Linux 上提供的魔术键控顺序(magic key sequence)来显示引发挂起问题的组件的信息。

    常见调试方法

    当您的程序中包含错误时,很可能在代码中某处有一个条件,您认为它为真(true),但实际上是假(false)。找出错误的过程也就是在找出错误后推翻以前一直确信为真的某个条件过程。

    以下几个示例是您可能确信成立的条件的一些类型:

    • 在源代码中的某处,某变量有特定的值。
    • 在给定的地方,某个结构已被正确设置。
    • 对于给定的 if-then-else 语句, if 部分就是被执行的路径。
    • 当子例程被调用时,该例程正确地接收到了它的参数。

    找出错误也就是要确定上述所有情况是否存在。如果您确信在子例程被调用时某变量应该有特定的值,那么就检查一下情况是否如此。如果您相信 if 结构会被执行,那么也检查一下情况是否如此。通常,您的假设都会是正确的,但最终您会找到与假设不符的情况。结果,您就会找出发生错误的地方。

    调试是您无法逃避的任务。进行调试有很多种方法,比如将消息打印到屏幕上、使用调试器,或只是考虑程序执行的情况并仔细地揣摩问题所在。

    在修正问题之前,您必须找出它的源头。举例来说,对于段错误,您需要了解段错误发生在代码的哪一行。一旦您发现了代码中出错的行,请确定该方法中变量的值、方法被调用的方式以及关于错误如何发生的详细情况。使用调试器将使找出所有这些信息变得很简单。如果没有调试器可用,您还可以使用其它的工具。(请注意,产品环境中可能并不提供调试器,而且 Linux 内核没有内建的调试器。)

    实用的内存和内核工具

    您可以使用 Linux 上的调试工具,通过各种方式跟踪用户空间和内核问题。请使用下面的工具和技术来构建和调试您的源代码:
    用户空间工具

    • 内存工具:MEMWATCH 和 YAMD
    • strace
    • GNU 调试器(gdb)
    • 魔术键控顺序

    内核工具

    • 内核源代码级调试器(kgdb)
    • 内建内核调试器(kdb)
    • Oops

    本文将讨论一类通过人工检查代码不容易找到的问题,而且此类问题只在很少见的情况下存在。内存错误通常在多种情况同时存在时出现,而且您有时只能在部署程序之后才能发现内存错误。

    第 1 种情况:内存调试工具

    C 语言作为 Linux 系统上标准的编程语言给予了我们对动态内存分配很大的控制权。然而,这种自由可能会导致严重的内存管理问题,而这些问题可能导致程序崩溃或随时间的推移导致性能降级。

    内存泄漏(即 malloc() 内存在对应的 free() 调用执行后永不被释放)和缓冲区溢出(例如对以前分配到某数组的内存进行写操作)是一些常见的问题,它们可能很难检测到。这一部分将讨论几个调试工具,它们极大地简化了检测和找出内存问题的过程。

    MEMWATCH

    MEMWATCH 由 Johan Lindh 编写,是一个开放源代码 C 语言内存错误检测工具,您可以自己下载它(请参阅本文后面部分的 参考资料)。只要在代码中添加一个头文件并在 gcc 语句中定义了 MEMWATCH 之后,您就可以跟踪程序中的内存泄漏和错误了。MEMWATCH 支持 ANSI C,它提供结果日志纪录,能检测双重释放(double-free)、错误释放(erroneous free)、没有释放的内存(unfreed memory)、溢出和下溢等等。

    清单 1. 内存样本(test1.c)

    #include <stdlib.h>
    #include <stdio.h>
    #include "memwatch.h"
    int main(void)
    {
      char *ptr1;
      char *ptr2;
      ptr1 = malloc(512);
      ptr2 = malloc(512);
      ptr2 = ptr1;
      free(ptr2);
      free(ptr1);
    }
    

    清单 1 中的代码将分配两个 512 字节的内存块,然后指向第一个内存块的指针被设定为指向第二个内存块。结果,第二个内存块的地址丢失,从而产生了内存泄漏。

    现在我们编译清单 1 的 memwatch.c。下面是一个 makefile 示例:

    test1

    gcc -DMEMWATCH -DMW_STDIO test1.c memwatch
    c -o test1
    

    当您运行 test1 程序后,它会生成一个关于泄漏的内存的报告。清单 2 展示了示例 memwatch.log 输出文件。

    清单 2. test1 memwatch.log 文件

      MEMWATCH 2.67 Copyright (C) 1992-1999 Johan Lindh
    ...
    double-free: <4> test1.c(15), 0x80517b4 was freed from test1.c(14)
    ...
    unfreed: <2> test1.c(11), 512 bytes at 0x80519e4
    {FE FE FE FE FE FE FE FE FE FE FE FE ..............}
    Memory usage statistics (global):
      N)umber of allocations made: 	2
      L)argest memory usage : 	1024
      T)otal of all alloc() calls: 	1024
      U)nfreed bytes totals : 	512
      

    MEMWATCH 为您显示真正导致问题的行。如果您释放一个已经释放过的指针,它会告诉您。对于没有释放的内存也一样。日志结尾部分显示统计信息,包括泄漏了多少内存,使用了多少内存,以及总共分配了多少内存。

    YAMD

    YAMD 软件包由 Nate Eldredge 编写,可以查找 C 和 C++ 中动态的、与内存分配有关的问题。在撰写本文时,YAMD 的最新版本为 0.32。请下载 yamd-0.32.tar.gz(请参阅参考资料)。执行make 命令来构建程序;然后执行 make install 命令安装程序并设置工具。

    一旦您下载了 YAMD 之后,请在 test1.c 上使用它。请删除 #include memwatch.h 并对 makefile 进行如下小小的修改:

    使用 YAMD 的 test1

    gcc -g test1.c -o test1
    

    清单 3 展示了来自 test1 上的 YAMD 的输出。

    清单 3. 使用 YAMD 的 test1 输出

    YAMD version 0.32
    Executable: /usr/src/test/yamd-0.32/test1
    ...
    INFO: Normal allocation of this block
    Address 0x40025e00, size 512
    ...
    INFO: Normal allocation of this block
    Address 0x40028e00, size 512
    ...
    INFO: Normal deallocation of this block
    Address 0x40025e00, size 512
    ...
    ERROR: Multiple freeing At
    free of pointer already freed
    Address 0x40025e00, size 512
    ...
    WARNING: Memory leak
    Address 0x40028e00, size 512
    WARNING: Total memory leaks:
    1 unfreed allocations totaling 512 bytes
    *** Finished at Tue ... 10:07:15 2002
    Allocated a grand total of 1024 bytes 2 allocations
    Average of 512 bytes per allocation
    Max bytes allocated at one time: 1024
    24 K alloced internally / 12 K mapped now / 8 K max
    Virtual program size is 1416 K
    End.
    

    YAMD 显示我们已经释放了内存,而且存在内存泄漏。让我们在清单 4 中另一个样本程序上试试 YAMD。

    清单 4. 内存代码(test2.c)

    #include <stdlib.h>
    #include <stdio.h>
    int main(void)
    {
      char *ptr1;
      char *ptr2;
      char *chptr;
      int i = 1;
      ptr1 = malloc(512);
      ptr2 = malloc(512);
      chptr = (char *)malloc(512);
      for (i; i <= 512; i++) {
        chptr[i] = 'S';
      }	
      ptr2 = ptr1;
      free(ptr2);
      free(ptr1);
      free(chptr);
    }
    

    您可以使用下面的命令来启动 YAMD:

    ./run-yamd /usr/src/test/test2/test2

    清单 5 显示了在样本程序 test2 上使用 YAMD 得到的输出。YAMD 告诉我们在 for 循环中有“越界(out-of-bounds)”的情况。

    清单 5. 使用 YAMD 的 test2 输出

    Running /usr/src/test/test2/test2
    Temp output to /tmp/yamd-out.1243
    *********
    ./run-yamd: line 101: 1248 Segmentation fault (core dumped)
    YAMD version 0.32
    Starting run: /usr/src/test/test2/test2
    Executable: /usr/src/test/test2/test2
    Virtual program size is 1380 K
    ...
    INFO: Normal allocation of this block
    Address 0x40025e00, size 512
    ...
    INFO: Normal allocation of this block
    Address 0x40028e00, size 512
    ...
    INFO: Normal allocation of this block
    Address 0x4002be00, size 512
    ERROR: Crash
    ...
    Tried to write address 0x4002c000
    Seems to be part of this block:
    Address 0x4002be00, size 512
    ...
    Address in question is at offset 512 (out of bounds)
    Will dump core after checking heap.
    Done.
    

    MEMWATCH 和 YAMD 都是很有用的调试工具,它们的使用方法有所不同。对于 MEMWATCH,您需要添加包含文件 memwatch.h 并打开两个编译时间标记。对于链接(link)语句,YAMD 只需要-g 选项。

    Electric Fence

    多数 Linux 分发版包含一个 Electric Fence 包,不过您也可以选择下载它。Electric Fence 是一个由 Bruce Perens 编写的malloc() 调试库。它就在您分配内存后分配受保护的内存。如果存在 fencepost 错误(超过数组末尾运行),程序就会产生保护错误,并立即结束。通过结合 Electric Fence 和 gdb,您可以精确地跟踪到哪一行试图访问受保护内存。Electric Fence 的另一个功能就是能够检测内存泄漏。

    第 2 种情况:使用 strace

    strace 命令是一种强大的工具,它能够显示所有由用户空间程序发出的系统调用。strace 显示这些调用的参数并返回符号形式的值。strace 从内核接收信息,而且不需要以任何特殊的方式来构建内核。将跟踪信息发送到应用程序及内核开发者都很有用。在清单 6 中,分区的一种格式有错误,清单显示了 strace 的开头部分,内容是关于调出创建文件系统操作(mkfs )的。strace 确定哪个调用导致问题出现。

    清单 6. mkfs 上 strace 的开头部分

      execve("/sbin/mkfs.jfs", ["mkfs.jfs", "-f", "/dev/test1"], &
     ...
     open("/dev/test1", O_RDWR|O_LARGEFILE) = 4
     stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0
     ioctl(4, 0x40041271, 0xbfffe128) = -1 EINVAL (Invalid argument)
     write(2, "mkfs.jfs: warning - cannot setb" ..., 98mkfs.jfs: warning -
     cannot set blocksize on block device /dev/test1: Invalid argument )
      = 98
     stat64("/dev/test1", {st_mode=&, st_rdev=makedev(63, 255), ...}) = 0
     open("/dev/test1", O_RDONLY|O_LARGEFILE) = 5
     ioctl(5, 0x80041272, 0xbfffe124) = -1 EINVAL (Invalid argument)
     write(2, "mkfs.jfs: can\'t determine device"..., ..._exit(1)
      = ?
      

    清单 6 显示 ioctl 调用导致用来格式化分区的 mkfs 程序失败。 ioctl BLKGETSIZE64 失败。(BLKGET-SIZE64 在调用ioctl 的源代码中定义。) BLKGETSIZE64 ioctl 将被添加到 Linux 中所有的设备,而在这里,逻辑卷管理器还不支持它。因此,如果BLKGETSIZE64 ioctl 调用失败,mkfs 代码将改为调用较早的ioctl 调用;这使得 mkfs 适用于逻辑卷管理器。

    第 3 种情况:使用 gdb 和 Oops

    您可以从命令行使用 gdb 程序(Free Software Foundation 的调试器)来找出错误,也可以从诸如 Data Display Debugger(DDD)这样的几个图形工具之一使用 gdb 程序来找出错误。您可以使用 gdb 来调试用户空间程序或 Linux 内核。这一部分只讨论从命令行运行 gdb 的情况。

    使用 gdb program name 命令启动 gdb。gdb 将载入可执行程序符号并显示输入提示符,让您可以开始使用调试器。您可以通过三种方式用 gdb 查看进程:

    • 使用 attach 命令开始查看一个已经运行的进程;attach 将停止进程。

    • 使用 run 命令执行程序并从头开始调试程序。

    • 查看已有的核心文件来确定进程终止时的状态。要查看核心文件,请用下面的命令启动 gdb。 gdb programname corefilename

      要用核心文件进行调试,您不仅需要程序的可执行文件和源文件,还需要核心文件本身。要用核心文件启动 gdb,请使用 -c 选项: gdb -c core programname

      gdb 显示哪行代码导致程序发生核心转储。

    在运行程序或连接到已经运行的程序之前,请列出您觉得有错误的源代码,设置断点,然后开始调试程序。您可以使用 help 命令查看全面的 gdb 在线帮助和详细的教程。

    kgdb

    kgdb 程序(使用 gdb 的远程主机 Linux 内核调试器)提供了一种使用 gdb 调试 Linux 内核的机制。kgdb 程序是内核的扩展,它让您能够在远程主机上运行 gdb 时连接到运行用 kgdb 扩展的内核机器。您可以接着深入到内核中、设置断点、检查数据并进行其它操作(类似于您在应用程序上使用 gdb 的方式)。这个补丁的主要特点之一就是运行 gdb 的主机在引导过程中连接到目标机器(运行要被调试的内核)。这让您能够尽早开始调试。请注意,补丁为 Linux 内核添加了功能,所以 gdb 可以用来调试 Linux 内核。

    使用 kgdb 需要两台机器:一台是开发机器,另一台是测试机器。一条串行线(空调制解调器电缆)将通过机器的串口连接它们。您希望调试的内核在测试机器上运行;gdb 在开发机器上运行。gdb 使用串行线与您要调试的内核通信。

    请遵循下面的步骤来设置 kgdb 调试环境:

    1. 下载您的 Linux 内核版本适用的补丁。

    2. 将组件构建到内核,因为这是使用 kgdb 最简单的方法。(请注意,有两种方法可以构建多数内核组件,比如作为模块或直接构建到内核中。举例来说,日志纪录文件系统(Journaled File System,JFS)可以作为模块构建,或直接构建到内核中。通过使用 gdb 补丁,我们就可以将 JFS 直接构建到内核中。)

    3. 应用内核补丁并重新构建内核。

    4. 创建一个名为 .gdbinit 的文件,并将其保存在内核源文件子目录中(换句话说就是 /usr/src/linux)。文件 .gdbinit 中有下面四行代码:
      • set remotebaud 115200
      • symbol-file vmlinux
      • target remote /dev/ttyS0
      • set output-radix 16

    5. 将 append=gdb 这一行添加到 lilo,lilo 是用来在引导内核时选择使用哪个内核的引导载入程序。
      • image=/boot/bzImage-2.4.17
      • label=gdb2417
      • read-only
      • root=/dev/sda8
      • append="gdb gdbttyS=1 gdb-baud=115200 nmi_watchdog=0"

    清单 7 是一个脚本示例,它将您在开发机器上构建的内核和模块引入测试机器。您需要修改下面几项:

    • best@sfb :用户标识和机器名。
    • /usr/src/linux-2.4.17 :内核源代码树的目录。
    • bzImage-2.4.17 :测试机器上将引导的内核名。
    • rcprsync :必须允许它在构建内核的机器上运行。

    清单 7. 引入测试机器的内核和模块的脚本

    set -x
    rcp best@sfb: /usr/src/linux-2.4.17/arch/i386/boot/bzImage /boot/bzImage-2.4.17
    rcp best@sfb:/usr/src/linux-2.4.17/System.map /boot/System.map-2.4.17
    rm -rf /lib/modules/2.4.17
    rsync -a best@sfb:/lib/modules/2.4.17 /lib/modules
    chown -R root /lib/modules/2.4.17
    lilo
    

    现在我们可以通过改为使用内核源代码树开始的目录来启动开发机器上的 gdb 程序了。在本示例中,内核源代码树位于 /usr/src/linux-2.4.17。输入gdb 启动程序。

    如果一切正常,测试机器将在启动过程中停止。输入 gdb 命令 cont 以继续启动过程。一个常见的问题是,空调制解调器电缆可能会被连接到错误的串口。如果 gdb 不启动,将端口改为第二个串口,这会使 gdb 启动。

    使用 kgdb 调试内核问题

    清单 8 列出了 jfs_mount.c 文件的源代码中被修改过的代码,我们在代码中创建了一个空指针异常,从而使代码在第 109 行产生段错误。

    清单 8. 修改过后的 jfs_mount.c 代码

    int jfs_mount(struct super_block *sb)
    {
    ...
    int ptr; 			/* line 1 added */
    jFYI(1, ("\nMount JFS\n"));
    / *
    * read/validate superblock
    * (initialize mount inode from the superblock)
    * /
    if ((rc = chkSuper(sb))) {
    		goto errout20;
    	}
    108 	ptr=0; 			/* line 2 added */
    109 	printk("%d\n",*ptr); 	/* line 3 added */
    

    清单 9 在向文件系统发出 mount 命令之后显示一个 gdb 异常。kgdb 提供了几条命令,如显示数据结构和变量值以及显示系统中的所有任务处于什么状态、它们驻留在何处、它们在哪些地方使用了 CPU 等等。清单 9 将显示回溯跟踪为该问题提供的信息;where 命令用来执行反跟踪,它将告诉被执行的调用在代码中的什么地方停止。

    清单 9. gdb 异常和反跟踪

    mount -t jfs /dev/sdb /jfs
    Program received signal SIGSEGV, Segmentation fault.
    jfs_mount (sb=0xf78a3800) at jfs_mount.c:109
    109 		printk("%d\n",*ptr);
    (gdb)where
    #0 jfs_mount (sb=0xf78a3800) at jfs_mount.c:109
    #1 0xc01a0dbb in jfs_read_super ... at super.c:280
    #2 0xc0149ff5 in get_sb_bdev ... at super.c:620
    #3 0xc014a89f in do_kern_mount ... at super.c:849
    #4 0xc0160e66 in do_add_mount ... at namespace.c:569
    #5 0xc01610f4 in do_mount ... at namespace.c:683
    #6 0xc01611ea in sys_mount ... at namespace.c:716
    #7 0xc01074a7 in system_call () at af_packet.c:1891
    #8 0x0 in -- ()
    (gdb)
    

    下一部分还将讨论这个相同的 JFS 段错误问题,但不设置调试器,如果您在非 kgdb 内核环境中执行清单 8 中的代码,那么它使用内核可能生成的 Oops 消息。

    Oops 分析

    Oops(也称 panic,慌张)消息包含系统错误的细节,如 CPU 寄存器的内容。在 Linux 中,调试系统崩溃的传统方法是分析在发生崩溃时发送到系统控制台的 Oops 消息。一旦您掌握了细节,就可以将消息发送到 ksymoops 实用程序,它将试图将代码转换为指令并将堆栈值映射到内核符号。在很多情况下,这些信息就足够您确定错误的可能原因是什么了。请注意,Oops 消息并不包括核心文件。

    让我们假设系统刚刚创建了一条 Oops 消息。作为编写代码的人,您希望解决问题并确定什么导致了 Oops 消息的产生,或者您希望向显示了 Oops 消息的代码的开发者提供有关您的问题的大部分信息,从而及时地解决问题。Oops 消息是等式的一部分,但如果不通过 ksymoops 程序运行它也于事无补。下面的图显示了格式化 Oops 消息的过程。


    格式化 Oops 消息
    格式化 Oops 消息

    ksymoops 需要几项内容:Oops 消息输出、来自正在运行的内核的 System.map 文件,还有 /proc/ksyms、vmlinux 和 /proc/modules。关于如何使用 ksymoops,内核源代码 /usr/src/linux/Documentation/oops-tracing.txt 中或 ksymoops 手册页上有完整的说明可以参考。Ksymoops 反汇编代码部分,指出发生错误的指令,并显示一个跟踪部分表明代码如何被调用。

    首先,将 Oops 消息保存在一个文件中以便通过 ksymoops 实用程序运行它。清单 10 显示了由安装 JFS 文件系统的 mount 命令创建的 Oops 消息,问题是由清单 8 中添加到 JFS 安装代码的那三行代码产生的。

    清单 10. ksymoops 处理后的 Oops 消息

       ksymoops 2.4.0 on i686 2.4.17. Options used
    ... 15:59:37 sfb1 kernel: Unable to handle kernel NULL pointer dereference at
    virtual address 0000000
    ... 15:59:37 sfb1 kernel: c01588fc
    ... 15:59:37 sfb1 kernel: *pde = 0000000
    ... 15:59:37 sfb1 kernel: Oops: 0000
    ... 15:59:37 sfb1 kernel: CPU:    0
    ... 15:59:37 sfb1 kernel: EIP:    0010:[jfs_mount+60/704]
    ... 15:59:37 sfb1 kernel: Call Trace: [jfs_read_super+287/688] 
    [get_sb_bdev+563/736] [do_kern_mount+189/336] [do_add_mount+35/208]
    [do_page_fault+0/1264]
    ... 15:59:37 sfb1 kernel: Call Trace: [<c0155d4f>]...
    ... 15:59:37 sfb1 kernel: [<c0106e04 ...
    ... 15:59:37 sfb1 kernel: Code: 8b 2d 00 00 00 00 55 ...
    >>EIP; c01588fc <jfs_mount+3c/2c0> <=====
    ...
    Trace; c0106cf3 <system_call+33/40>
    Code; c01588fc <jfs_mount+3c/2c0>
    00000000 <_EIP>:
    Code; c01588fc <jfs_mount+3c/2c0>  <=====
       0: 8b 2d 00 00 00 00 	mov 	0x0,%ebp    <=====
    Code; c0158902 <jfs_mount+42/2c0>
       6:  55 			push 	%ebp
       

    接下来,您要确定 jfs_mount 中的哪一行代码引起了这个问题。Oops 消息告诉我们问题是由位于偏移地址 3c 的指令引起的。做这件事的办法之一是对 jfs_mount.o 文件使用 objdump 实用程序,然后查看偏移地址 3c。Objdump 用来反汇编模块函数,看看您的 C 源代码会产生什么汇编指令。清单 11 显示了使用 objdump 后您将看到的内容,接着,我们查看 jfs_mount 的 C 代码,可以看到空值是第 109 行引起的。偏移地址 3c 之所以很重要,是因为 Oops 消息将该处标识为引起问题的位置。

    清单 11. jfs_mount 的汇编程序清单

      109	printk("%d\n",*ptr);
    objdump jfs_mount.o
    jfs_mount.o: 	file format elf32-i386
    Disassembly of section .text:
    00000000 <jfs_mount>:
       0:55 			push %ebp
      ...
      2c:	e8 cf 03 00 00	   call	   400 <chkSuper>
      31:	89 c3 	  	    	mov     %eax,%ebx
      33:	58		    	pop     %eax
      34:	85 db 	  	    	test 	%ebx,%ebx
      36:	0f 85 55 02 00 00 jne 	291 <jfs_mount+0x291>
      3c:	8b 2d 00 00 00 00 mov 	0x0,%ebp << problem line above
      42:	55			push 	%ebp
      

    kdb

    Linux 内核调试器(Linux kernel debugger,kdb)是 Linux 内核的补丁,它提供了一种在系统能运行时对内核内存和数据结构进行检查的办法。请注意,kdb 不需要两台机器,不过它也不允许您像 kgdb 那样进行源代码级别上的调试。您可以添加额外的命令,给出该数据结构的标识或地址,这些命令便可以格式化和显示基本的系统数据结构。目前的命令集允许您控制包括以下操作在内的内核操作:

    • 处理器单步执行
    • 执行到某条特定指令时停止
    • 当存取(或修改)某个特定的虚拟内存位置时停止
    • 当存取输入/输出地址空间中的寄存器时停止
    • 对当前活动的任务和所有其它任务进行堆栈回溯跟踪(通过进程 ID)
    • 对指令进行反汇编

    追击内存溢出

    您肯定不想陷入类似在几千次调用之后发生分配溢出这样的情形。

    我们的小组花了许许多多时间来跟踪稀奇古怪的内存错误问题。应用程序在我们的开发工作站上能运行,但在新的产品工作站上,这个应用程序在调用 malloc() 两百万次之后就不能运行了。真正的问题是在大约一百万次调用之后发生了溢出。新系统之所有存在这个问题,是因为被保留的malloc() 区域的布局有所不同,从而这些零散内存被放置在了不同的地方,在发生溢出时破坏了一些不同的内容。

    我们用多种不同技术来解决这个问题,其中一种是使用调试器,另一种是在源代码中添加跟踪功能。在我职业生涯的大概也是这个时候,我便开始关注内存调试工具,希望能更快更有效地解决这些类型的问题。在开始一个新项目时,我最先做的事情之一就是运行 MEMWATCH 和 YAMD,看看它们是不是会指出内存管理方面的问题。

    内存泄漏是应用程序中常见的问题,不过您可以使用本文所讲述的工具来解决这些问题。

    第 4 种情况:使用魔术键控顺序进行回溯跟踪

    如果在 Linux 挂起时您的键盘仍然能用,那请您使用以下方法来帮助解决挂起问题的根源。遵循这些步骤,您便可以显示当前运行的进程和所有使用魔术键控顺序的进程的回溯跟踪。

    1. 您正在运行的内核必须是在启用 CONFIG_MAGIC_SYS-REQ 的情况下构建的。您还必须处在文本模式。CLTR+ALT+F1 会使您进入文本模式,CLTR+ALT+F7 会使您回到 X Windows。
    2. 当在文本模式时,请按 <ALT+ScrollLock>,然后按 <Ctrl+ScrollLock>。上述魔术的击键会分别给出当前运行的进程和所有进程的堆栈跟踪。
    3. 请查找 /var/log/messages。如果一切设置正确,则系统应该已经为您转换了内核的符号地址。回溯跟踪将被写到 /var/log/messages 文件中。

    结束语

    帮助调试 Linux 上的程序有许多不同的工具可供使用。本文讲述的工具可以帮助您解决许多编码问题。能显示内存泄漏、溢出等等的位置的工具可以解决内存管理问题,我发现 MEMWATCH 和 YAMD 很有帮助。

    使用 Linux 内核补丁会使 gdb 能在 Linux 内核上工作,这对解决我工作中使用的 Linux 的文件系统方面的问题很有帮助。此外,跟踪实用程序能帮助确定在系统调用期间文件系统实用程序什么地方出了故障。下次当您要摆平 Linux 中的错误时,请试试这些工具中的某一个。


    参考资料

    关于作者

    Steve Best 目前在做 Linux 项目的日志纪录文件系统(Journaled File System,JFS)的工作。Steve 在操作系统方面有丰富的从业经验,他的着重的领域是文件系统、国际化和安全性。




    简介: Linux 的大部分特色源自于 shell 的 GNU 调试器,也称作 gdb。gdb 可以让您查看程序的内部结构、打印变量值、设置断点,以及单步调试源代码。它是功能极其强大的工具,适用于修复程序代码中的问题。在本文中,David Seager 将尝试说明 gdb 有多棒,多实用。


    编译

    开始调试之前,必须用程序中的调试信息编译要调试的程序。这样,gdb 才能够调试所使用的变量、代码行和函数。如果要进行编译,请在 gcc(或 g++)下使用额外的 '-g' 选项来编译程序:

    gcc -g eg.c -o eg

    运行 gdb

    在 shell 中,可以使用 'gdb' 命令并指定程序名作为参数来运行 gdb,例如 'gdb eg';或者在 gdb 中,可以使用 file 命令来装入要调试的程序,例如 'file eg'。这两种方式都假设您是在包含程序的目录中执行命令。装入程序之后,可以用 gdb 命令 'run' 来启动程序。

    调试会话示例

    如果一切正常,程序将执行到结束,此时 gdb 将重新获得控制。但如果有错误将会怎么样?这种情况下,gdb 会获得控制并中断程序,从而可以让您检查所有事物的状态,如果运气好的话,可以找出原因。为了引发这种情况,我们将使用一个示例程序:


    代码示例 eg1.c
    #include 
    int wib(int no1, int no2)
    {
      int result, diff;
      diff = no1 - no2;
      result = no1 / diff;
      return result;
    }
    int main(int argc, char *argv[])
    {
      int value, div, result, i, total;
      value = 10;
      div = 6;
      total = 0;
      for(i = 0; i < 10; i++)
      {
        result = wib(value, div);
        total += result;
        div++;
        value--;
      }
      printf("%d wibed by %d equals %d\n", value, div, total);
      return 0;
    }
    

    这个程序将运行 10 次 for 循环,使用 'wib()" 函数计算出累积值,最后打印出结果。

    在您喜欢的文本编辑器中输入这个程序(要保持相同的行距),保存为 'eg1.c',使用 'gcc -g eg1.c -o eg1' 进行编译,并用 'gdb eg1' 启动 gdb。使用 'run' 运行程序可能会产生以下消息:

    Program received signal SIGFPE, Arithmetic exception.
    0x80483ea in wib (no1=8, no2=8) at eg1.c:7
    7         result = no1 / diff;
    (gdb)
    

    gdb 指出在程序第 7 行发生一个算术异常,通常它会打印这一行以及 wib() 函数的自变量值。要查看第 7 行前后的源代码,请使用 'list' 命令,它通常会打印 10 行。再次输入 'list'(或者按回车重复上一条命令)将列出程序的下 10 行。从 gdb 消息中可以看出,第 7 行中的除法运算出了错,程序在这一行中将变量 "no1" 除以 "diff"。

    要查看变量的值,使用 gdb 'print' 命令并指定变量名。输入 'print no1' 和 'print diff',可以相应看到 "no1" 和 "diff" 的值,结果如下:

    (gdb) print no1
    $5 = 8
    (gdb) print diff
    $2 = 0
    

    gdb 指出 "no1" 等于 8,"diff" 等于 0。根据这些值和第 7 行中的语句,我们可以推断出算术异常是由除数为 0 的除法运算造成的。清单显示了第 6 行计算的变量 "diff",我们可以打印 "diff" 表达式(使用 'print no1 - no2' 命令),来重新估计这个变量。gdb 告诉我们 wib 函数的这两个自变量都等于 8,于是我们要检查调用 wib() 函数的 main() 函数,以查看这是在什么时候发生的。在允许程序自然终止的同时,我们使用 'continue' 命令告诉 gdb 继续执行。

    (gdb) continue
    Continuing.
    Program terminated with signal SIGFPE, Arithmetic exception.
    The program no longer exists.
    

    使用断点

    为了查看在 main() 中发生了什么情况,可以在程序代码中的某一特定行或函数中设置断点,这样 gdb 会在遇到断点时中断执行。可以使用命令 'break main' 在进入 main() 函数时设置断点,或者可以指定其它任何感兴趣的函数名来设置断点。然而,我们只希望在调用 wib() 函数之前中断执行。输入 'list main' 将打印从 main() 函数开始的源码清单,再次按回车将显示第 21 行上的 wib() 函数调用。要在那一行上设置断点,只需输入 'break 21'。gdb 将发出以下响应:

    (gdb) break 21
    Breakpoint 1 at 0x8048428: file eg1.c, line 21.
    

    以显示它已在我们请求的行上设置了 1 号断点。'run' 命令将从头重新运行程序,直到 gdb 中断为止。发生这种情况时,gdb 会生成一条消息,指出它在哪个断点上中断,以及程序运行到何处:

    Breakpoint 1, main (argc=1, argv=0xbffff954) at eg1.c:21
    21          result = wib(value, div);
    

    发出 'print value' 和 'print div' 将会显示在第一次调用 wib() 时,变量分别等于 10 和 6,而 'print i' 将会显示 0。幸好,gdb 将显示所有局部变量的值,并使用 'info locals' 命令保存大量输入信息。

    从以上的调查中可以看出,当 "value" 和 "div" 相等时就会出现问题,因此输入 'continue' 继续执行,直到下一次遇到 1 号断点。对于这次迭代,'info locals' 显示了 value=9 和 div=7。

    与其再次继续,还不如使用 'next' 命令单步调试程序,以查看 "value" 和 "div" 是如何改变的。gdb 将响应:

    (gdb) next
    22          total += result;
    

    再按两次回车将显示加法和减法表达式:

    (gdb)
    23          div++;
    (gdb)
    24          value--;
    

    再按两次回车将显示第 21 行,wib() 调用。'info locals' 将显示目前 "div" 等于 "value",这就意味着将发生问题。如果有兴趣,可以使用 'step' 命令(与 'next' 形成对比,'next' 将跳过函数调用)来继续执行 wib() 函数,以再次查看除法错误,然后使用 'next' 来计算 "result"。

    现在已完成了调试,可以使用 'quit' 命令退出 gdb。由于程序仍在运行,这个操作会终止它,gdb 将提示您确认。

    更多断点和观察点

    由于我们想要知道在调用 wib() 函数之前 "value" 什么时候等于 "div",因此在上一示例中我们在第 21 行中设置断点。我们必须继续执行两次程序才会发生这种情况,但是只要在断点上设置一个条件就可以使 gdb 只在 "value" 与 "div" 真正相等时暂停。要设置条件,可以在定义断点时指定 "break <line number> if <conditional expression>"。将 eg1 再次装入 gdb,并输入:

    (gdb) break 21 if value==div
    Breakpoint 1 at 0x8048428: file eg1.c, line 21.
    

    如果已经在第 21 行中设置了断点,如 1 号断点,则可以使用 'condition' 命令来代替在断点上设置条件:

    (gdb) condition 1 value==div
    

    使用 'run' 运行 eg1.c 时,如果 "value" 等于 "div",gdb 将中断,从而避免了在它们相等之前必须手工执行 'continue'。调试 C 程序时,断点条件可以是任何有效的 C 表达式,一定要是程序所使用语言的任意有效表达式。条件中指定的变量必须在设置了断点的行中,否则表达式就没有什么意义!

    使用 'condition' 命令时,如果指定断点编号但又不指定表达式,可以将断点设置成无条件断点,例如,'condition 1' 就将 1 号断点设置成无条件断点。

    要查看当前定义了什么断点及其条件,请发出命令 'info break':

    (gdb) info break
    Num Type           Disp Enb Address    What
    1   breakpoint     keep y   0x08048428 in main at eg1.c:21
            stop only if value == div
            breakpoint already hit 1 time
    

    除了所有条件和已经遇到断点多少次之外,断点信息还在 'Enb' 列中指定了是否启用该断点。可以使用命令 'disable <breakpoint number>'、'enable <breakpoint number>' 或 'delete <breakpoint number>' 来禁用、启用和彻底删除断点,例如 'disable 1' 将阻止在 1 号断点处中断。

    如果我们对 "value" 什么时候变得与 "div" 相等更感兴趣,那么可以使用另一种断点,称作监视。当指定表达式的值改变时,监视点将中断程序执行,但必须在表达式中所使用的变量在作用域中时设置监视点。要获取作用域中的 "value" 和 "div",可以在 main 函数上设置断点,然后运行程序,当遇到 main() 断点时设置监视点。重新启动 gdb,并装入 eg1,然后输入:

    (gdb) break main
    Breakpoint 1 at 0x8048402: file eg1.c, line 15.
    (gdb) run
    ...
    Breakpoint 1, main (argc=1, argv=0xbffff954) at eg1.c:15
    15        value = 10;
    

    要了解 "div" 何时更改,可以使用 'watch div',但由于要在 "div" 等于 "value" 时中断,那么应输入:

    (gdb) watch div==value
    Hardware watchpoint 2: div == value
    

    如果继续执行,那么当表达式 "div==value" 的值从 0(假)变成 1(真)时,gdb 将中断:

    (gdb) continue
    Continuing.
    Hardware watchpoint 2: div == value
    Old value = 0
    New value = 1
    main (argc=1, argv=0xbffff954) at eg1.c:19
    19        for(i = 0; i < 10; i++)
    

    'info locals' 命令将验证 "value" 是否确实等于 "div"(再次声明,是 8)。

    'info watch' 命令将列出已定义的监视点和断点(此命令等价于 'info break'),而且可以使用与断点相同的语法来启用、禁用和删除监视点。

    core 文件

    在 gdb 下运行程序可以使俘获错误变得更容易,但在调试器外运行的程序通常会中止而只留下一个 core 文件。gdb 可以装入 core 文件,并让您检查程序中止之前的状态。

    在 gdb 外运行示例程序 eg1 将会导致核心信息转储:

    $ ./eg1
    Floating point exception (core dumped)

    要使用 core 文件启动 gdb,在 shell 中发出命令 'gdb eg1 core' 或 'gdb eg1 -c core'。gdb 将装入 core 文件,eg1 的程序清单,显示程序是如何终止的,并显示非常类似于我们刚才在 gdb 下运行程序时看到的消息:

    ...
    Core was generated by `./eg1'.
    Program terminated with signal 8, Floating point exception.
    ...
    #0  0x80483ea in wib (no1=8, no2=8) at eg1.c:7
    7         result = no1 / diff;
    

    此时,可以发出 'info locals'、'print'、'info args' 和 'list' 命令来查看引起除数为零的值。'info variables' 命令将打印出所有程序变量的值,但这要进行很长时间,因为 gdb 将打印 C 库和程序代码中的变量。为了更容易地查明在调用 wib() 的函数中发生了什么情况,可以使用 gdb 的堆栈命令。

    堆栈跟踪

    程序“调用堆栈”是当前函数之前的所有已调用函数的列表(包括当前函数)。每个函数及其变量都被分配了一个“帧”,最近调用的函数在 0 号帧中(“底部”帧)。要打印堆栈,发出命令 'bt'('backtrace' [回溯] 的缩写):

    (gdb) bt
    #0  0x80483ea in wib (no1=8, no2=8) at eg1.c:7
    #1  0x8048435 in main (argc=1, argv=0xbffff9c4) at eg1.c:21
    

    此结果显示了在 main() 的第 21 行中调用了函数 wib()(只要使用 'list 21' 就能证实这一点),而且 wib() 在 0 号帧中,main() 在 1 号帧中。由于 wib() 在 0 号帧中,那么它就是执行程序时发生算术错误的函数。

    实际上,发出 'info locals' 命令时,gdb 会打印出当前帧中的局部变量,缺省情况下,这个帧中的函数就是被中断的函数(0 号帧)。可以使用命令 'frame' 打印当前帧。要查看 main 函数(在 1 号帧中)中的变量,可以发出 'frame 1' 切换到 1 号帧,然后发出 'info locals' 命令:

    (gdb) frame 1
    #1  0x8048435 in main (argc=1, argv=0xbffff9c4) at eg1.c:21
    21          result = wib(value, div);
    (gdb) info locals
    value = 8
    div = 8
    result = 4
    i = 2
    total = 6
    

    此信息显示了在第三次执行 "for" 循环时(i 等于 2)发生了错误,此时 "value" 等于 "div"。

    可以通过如上所示在 'frame' 命令中明确指定号码,或者使用 'up' 命令在堆栈中上移以及 'down' 命令在堆栈中下移来切换帧。要获取有关帧的进一步信息,如它的地址和程序语言,可以使用命令 'info frame'。

    gdb 堆栈命令可以在程序执行期间使用,也可以在 core 文件中使用,因此对于复杂的程序,可以在程序运行时跟踪它是如何转到函数的。

    连接到其它进程

    除了调试 core 文件或程序之外,gdb 还可以连接到已经运行的进程(它的程序已经过编译,并加入了调试信息),并中断该进程。只需用希望 gdb 连接的进程标识替换 core 文件名就可以执行此操作。以下是一个执行循环并睡眠的示例程序


    eg2 示例代码
    #include 
    int main(int argc, char *argv[])
    {
      int i;
      for(i = 0; i < 60; i++)
      {
        sleep(1);
      }
      return 0;
    }
    

    使用 'gcc -g eg2.c -o eg2' 编译该程序并使用 './eg2 &' 运行该程序。请留意在启动该程序时在背景上打印的进程标识,在本例中是 1283:

    ./eg2 &
    [3] 1283
    

    启动 gdb 并指定进程标识,在我举的这个例子中是 'gdb eg2 1283'。gdb 会查找一个叫作 "1283" 的 core 文件。如果没有找到,那么只要进程 1283 正在运行(在本例中可能在 sleep() 中),gdb 就会连接并中断该进程:

    ...
    /home/seager/gdb/1283: No such file or directory.
    Attaching to program: /home/seager/gdb/eg2, Pid 1283
    ...
    0x400a87f1 in __libc_nanosleep () from /lib/libc.so.6
    (gdb)
    

    此时,可以发出所有常用 gdb 命令。可以使用 'backtrace' 来查看当前位置与 main() 的相对关系,以及 mian() 的帧号是什么,然后切换到 main() 所在的帧,查看已经在 "for" 循环中运行了多少次:

    (gdb) backtrace
    #0  0x400a87f1 in __libc_nanosleep () from /lib/libc.so.6
    #1  0x400a877d in __sleep (seconds=1) at ../sysdeps/unix/sysv/linux/sleep.c:78
    #2  0x80483ef in main (argc=1, argv=0xbffff9c4) at eg2.c:7
    (gdb) frame 2
    #2  0x80483ef in main (argc=1, argv=0xbffff9c4) at eg2.c:7
    7           sleep(1);
    (gdb) print i
    $1 = 50
    

    如果已经完成了对程序的修改,可以 'detach' 命令继续执行程序,或者 'kill' 命令杀死进程。还可以首先使用 'file eg2' 装入文件,然后发出 'attach 1283' 命令连接到进程标识 1283 下的 eg2。

    其它小技巧

    gdb 可以让您通过使用 shell 命令在不退出调试环境的情况下运行 shell 命令,调用形式是 'shell [commandline]',这有助于在调试时更改源代码。

    最后,在程序运行时,可以使用 'set ' 命令修改变量的值。在 gdb 下再次运行 eg1,使用命令 'break 7 if diff==0' 在第 7 行(将在此处计算结果)设置条件断点,然后运行程序。当 gdb 中断执行时,可以将 "diff" 设置成非零值,使程序继续运行直至结束:

    Breakpoint 1, wib (no1=8, no2=8) at eg1.c:7
    7         result = no1 / diff;
    (gdb) print diff
    $1 = 0
    (gdb) set diff=1
    (gdb) continue
    Continuing.
    0 wibed by 16 equals 10
    Program exited normally.
    

    结束语

    GNU 调试器是所有程序员工具库中的一个功能非常强大的工具。在本文中,我只介绍了 gdb 的一小部分功能。要了解更多知识,建议您阅读 GNU 调试器手册。


    参考资料

    关于作者

    David Seager 是 IBM 的软件开发人员,他从事 Linux 和基于 Web 的应用工作已有两年时间了。


    gdb (GNU 调试器): 基础

    简介: 这是由两部分组成的关于调试 zSeries* 上的 Linux 应用程序的系列文章中的第 2 部分。请参阅 第 1 部分

    最后,set args 为程序设置命令行参数。您也可以在执行 run 时指定命令行参数,但是 set args 将使参数在 run 的多次执行中都有效。

    gdb Post Mortem

    当程序意外地终止时,内核会尝试产生一个核心文件,以图判断发生了什么错误。然而,核心文件通常不是在默认设置值下产生的。这可以使用 ulimit 命令来改变。ulimit -c unlimited 帮助确保您获得应用程序的完整核心文件。

    虽然核心文件当前仅提供多线程应用程序中的有限的值,不过 2.5 版的开发内核已开始处理这个问题。预计 2.6 版的内核中会提供一些理想的线程改进。

    图 2突出显示了一系列便利的 post mortem 命令。图 3简要显示了一个核心程序的完整运行过程。同样,我们使用了 simple 程序。 但不是手动加载程序和核心文件,而是从命令行调入:

    gdb simple core
    

    在加载符号之后,gdb 将指出程序在何处终止。注意当前帧 #0 包含前一节中计算的地址。gdb 将在 31 位系统上截去高位,仅显示指令地址。 还要注意帧 #1 包含 gpr14 中的返回地址。

    接着往下看,i f 提供了关于当前堆栈帧的信息。在堆栈帧中往上移到 main,这就是我们离开该帧的地方(即调用 memcpy 的地方)。简单的 i locals 提供了传递给 memcpy 的变量的值,其中一个变量 boink.boik 的值为 0x0。使用 ptype 来检查变量类型,这样将确认它是一个整型指针,并且如果目的是为了拷贝内容到其中,它就不应该是 0x0。最后一个选项是使用 print,通过一个星号(*)来解除指针引用,以便接收值。

    处理优化过的代码

    先前,我曾提到当您在源代码级调试优化过的代码时,gdb 可能变得有点棘手。编译器优化一些代码的执行顺序以最大化性能。 图4显示了这样一个例子。您可以看到行号如何从 32 切换到 30 然后又切换回 32。

    如何处理这种情况呢?使用 si 和 ni(next instruction;它类似 si,但是会跳过子例程调用)将非常有帮助。 在这个层次上,很好理解 zArchitecture 是有所帮助的。

    图 5显示了为调试而对程序进行的设置。首先在 main()的地址处设置一个断点,然后设置一个 display。display 是一个表达式,它在每次代码停止执行时打印有关信息。在此例中,display 被设置为显示当前指令地址处的指令。/i 是打印为反汇编代码的格式,而当前指令指针在值/寄存器(value/register)$pswa 中。

    单步调试代码,可以明显看出每条机器指令都与一行 c 代码相关联。 前四行与第 27 行(即函数 main 的开头)相关联。 前四行是典型的函数引入操作,它们保存寄存器、堆栈指针并调整堆栈。当关联的行号变为32 时,我们就设置好了对 do_one_thing() 的函数调用。

    当 display 在工作时,它显示 x /i 作为实际数据显示之前的命令。x 是检查内存的命令。/i 是以指令格式来格式化;/x 将以 16 进制格式来格式化;而 /a 将以 16 进制来格式化。然而,您应该在尽可能的地方把该值看作是地址,并解析符号名称。

    当在指令级工作时,设置一些显示可能是有所帮助的。您可以将所有 display 命令放在一个文件中,并在命令行上使用 -x 选项来指定它。 图 6包含了工作在汇编程序级时通常使用的 display 命令。

    这个命令打印全部 PSW 值、所有通用寄存器和从当前指令地址开始的下 10 行机器代码。 图 7显示了当我们在 main() 处中断时的结果。可以看到,在其中一些寄存器所指向的地方,/a 格式解析是如何使得理解正在发生的事情更加容易的。

    结束语

    对于一些可用于 Linux 应用程序调试的基本工具以及调试过程本身,本文中的信息应该为您提供了有用的入门信息。


    关于作者

    Mike Grundy:MikeGrundy 在 IBM 负责 S/390 Linux 应用程序开发工具,您可以通过电子邮件grundym@us.ibm.com联系他。

    调试 make

    让 make 为我们工作而不是为我们制造麻烦

    简介: make 工具如 GNU make、System V make 和 Berkeley make 是用来组织应用程序编译过程的基本工具,但是每个 make 工具之间又有所不同。本文将介绍 makefile 的结构,避免如何在创建 makefile 时出现一些共同的错误,并探索如何修复或解决可移植性问题,还为解决突发的问题提供了一些技巧。

    大部分 UNIX® 和 Linux® 程序都是通过运行 make 来编译的。make 工具会读取一个包含指令的文件(这个文件的名字通常都是 makefile 或 Makefile,不过后文中我们统一称之为 “makefile”),并执行各种操作来编译程序。在很多编译过程中,makefile 自己完全是由其他软件生成的;例如,autoconf/automake 程序就用来开发编译程序。其他程序可能会要求我们直接编辑 makefile,当然,新的开发还可能需要我们自己编写 makefile。

    “make 工具”这个短语可能有些容易引起误解。经常使用的 make 工具至少有 3 个变种:GNU make、System V make 和 Berkeley make。它们都是从早期 UNIX 的一个核心规范发展而来的,每个变种都增加了一些新特性。这就导致出现了一种复杂的情况:很常用的一些特性,例如在 makefile 中通过引用来包含其他文件,都不能很好地移植!简单编写程序来创建 makefile 就是一种解决方案。由于 GNU make 是免费的,并且可以广泛地发布,因此有些开发人员就简单地为它来编写代码;类似地,有很多起源于 BSD 的项目都要求我们使用 Berkeley make(这也是免费的)。

    稍微逊色一点但依然相关的 make 工具是 Jörg Schilling 的 smake 和 make 家族中的第五位(已不再使用) —— 早先的 make,后者定义了与其他 make 工具共享的一些公共特性的子集。尽管 smake 在任何系统上都不是默认的 make 工具,但是它也是一个很好的 make 实现,有些程序(尤其是 Schilling 的程序)都喜欢使用它。

    下面先来回顾一下在使用 makefile 时所遇到的最常见的一些问题。

    理解 makefile

    要调试 make,需要读取 makefile。正如所了解的那样,makefile 的目标就是为编译程序提供一些指令。make 的主要特性之一就是 依赖性管理:只有在程序源码发生更新必须要重新编译程序时,make 才会真正重新编译程序。通常,这是通过一系列依赖性规则来表示的。其中一种依赖性规则如下所示:


    清单 1. 依赖性规则的格式
    target: dependencies
    	instructions
    

    人们在编写自己的第一个 makefile 时所碰到的主要问题在这个结构中可能看得出来,也可能看不出来:缩进使用的是制表符,而不是多少个空格。由于在这种格式中使用空格所产生的 Berkeley make 错误消息对人们也没什么帮助:


    清单 2. Berkeley make 错误消息
    make: "Makefile" line 2: Need an operator
    make: Fatal errors encountered -- cannot continue
    

    GNU make,尽管不能对这个文件进行处理,但却会给出一个更有用的建议:


    清单 3. GNU make 错误消息
    Makefile::2: *** missing separator (did you mean TAB instead of 8 spaces?).  Stop.
    

    请注意依赖性和指令都是可选的;只有目标和冒号才是必须的。那么既然语法是这样,语义又该如何呢?其语义是:如果 make 希望编译 target ,那它就会首先查看依赖关系。实际上,它会递归地尝试编译目标;如果所依赖的内容碰巧又依赖其他内容,那么在这条规则继续之前,必须对所依赖的内容进行处理。如果target 存在,并且至少比 dependencies 中所列出的所有内容都要新,那么就不会执行任何操作。如果target 不存在,或者有一个或多个依赖内容更新,那么 make 就会执行 instructions 操作。依赖性是按照指定的顺序进行处理的。如果没有指定依赖性,那就总会执行 instructions。所依赖的内容也称为源(source)

    如果在命令行中给出了一个目标(例如 make foo),那么 make 就会试图编译这个目标。否则,它就试图编译文件中列出的第一个目标。一些开发人员采用的约定是让第一个目标看起来如下所示:


    清单 4. 通常使用的第一个目标约定
    default: all
    

    有些人会假设之所以使用这条规则是因为它是 “默认的”。但实际上并非如此;它之所以这样使用是因为这是该文件中的第一条规则。可以按照自己希望的方式对其进行命名,不过名字 “default” 是一个很好的选择,因为这对于读者来说意义是显而易见的。记住 makefile 是会由人来阅读的,而不是只由 make 程序来使用的。

    伪目标

    通常我们可以说,目标的功能是从其他文件中创建一个文件。实际上并非总是如此。大部分 makefile 都至少有两条规则,它们从来都不会创建目标。请考虑下面的示例规则:


    清单 5. 示例伪目标
    all: hello goodbye fibonacci
    

    这条规则会告诉 make —— 如果希望编译目标 all —— 首先要确保 hello、goodbye 和 fibonacci 都是最新的。然后,就什么也不做了。下面并没有提供指令。在这条规则完成之后,并不会创建名为 all 的文件。这个目标是一种假目标。在某些 make 变种中使用的技术术语称之为 “伪目标”。

    伪目标是为了组织结构的目的而设计的,这在编写一个清晰的 makefile 时是种非常不错的技术。举例来说,我们可能会经常看到下面的规则:


    清单 6. 伪目标的灵活用法
    build: clean all install
    

    这指定了编译过程执行的操作顺序。

    特殊的目标和源

    系统还定义了几个特殊的目标,它们对 make 可以产生一些特别的影响,提供一种可配置的机制。具体的目标集对于每个实现来说都是不同的;其中最通用的一个是 .SUFFIXES 目标,它使用的源是一系列模式,添加在可识别的文件后缀列表中。这些特殊目标并不会用作通用规则来把编译作为 makefile 中默认的第一条目标。

    有些版本的 make 允许将特殊源与给定目标的依赖性一起指定,例如 .IGNORE,它说明从编译这个目标所使用的命令中生成的错误都应该忽略,仿佛它们前面都有一个短线一样。这些标记的可移植性并不好,但是对于理解 makefile 来说却是必须的。

    通用规则

    在 make 中有一些隐式规则用来根据文件名后缀执行通用转换。举例来说,如果现在没有 makefile,可以创建一个名为 “hello.c” 的文件,并运行make hello 命令:


    清单 7. C 文件的隐式规则的例子
    $ make hello
    cc -O2   -o hello hello.c
    

    大型程序使用的 makefile 可能会简单地指定自己需要的对象模块清单(hello.o、world.o 等),然后为如何将 .c 文件转换成 .o 文件提供一条规则:


    清单 8. 将 .c 文件转换成 .o 文件的规则
    .c.o:
    	cc $(CFLAGS) -c $<
    

    实际上,大部分 make 工具都有一个早已内嵌到系统中的与此类似的规则;如果请求 make 来编译 file.o,而且现在已经有 file.c 文件了,那么它就可以正确地完成编译过程。术语 "$<" 是一个特殊的预定义的 make 变量,代表某条规则的 “源”。这使我们可以使用一些 make 变量。

    通用规则取决于 “后缀” 的声明,它然后会被识别为文件扩展名,而不是文件名的一部分。

    变量

    make 程序使用了一些变量来简化通用值的重用。最常见的值可能是 CFLAGS。有关 make 变量有一些东西应该澄清一下。它们不一定必须是环境变量。如果所给出的名字没有对应的 make 变量,那么 make 就会去检查环境变量;然而,这并意味着 make 变量会被导出为环境变量。优先规则非常神秘;通常,它们的顺序从高到低依次为:

    1. 命令行变量设置
    2. 父 make 进程的 makefile 中的变量设置
    3. 本 make 进程的 makefile 中的变量设置
    4. 环境变量

    因此,一个变量只有在没有在任何 makefile 或命令行中指定时,才会使用环境变量的设置(注意:父进程 makefile 变量有时候会传递下来,但不总会这样。正如可能已经猜测到的一样,这些规则在各个 make 工具中会有所不同)。

    人们在使用 make 时常常碰到的一个问题是变量被变量名的一部分替换掉了:举例来说,$CFLAGS 就被替换成了 “FLAGS”。因此要引用一个 make 变量,就请将它的名字放到括号中:$(CFLAGS)。否则,所得到的将是$C,后面加上一个 FLAGS

    很多变量都有一些特殊的意义,这是正在使用它们的规则的一种功能。最常见的用法有:

    • $< —— 用来构建目标所使用的源文件
    • $* —— 目标名中基本的部分(不包含扩展名或目录)
    • $@ —— 目标的完整名

    虽然 Berkeley make 没有使用这些变量,但是它们(到现在)都是可移植的。至少,是部分可移植的;其确切定义在不同的 make 实现中可能会有所不同。使用这些变量编写的任何复杂规则都可能到某个特定的实现就不能用了。

    Shell 脚本

    有时候可能还需要执行一些 make 中没法移植的内容。由于 make 是通过 shell 来运行所有操作的,因此常见的解决方案是编写一个内嵌的 shell 脚本来实现。下面是如何实现的过程。

    首先,要知道 shell 脚本传统上来讲是在多行中编写的,它们可以使用分号来分割语句,从而将整个脚本压缩成一行。其次,要注意这样做可读性不好。解决方案是一种折衷:使用常见的缩进格式来编写脚本,但是在每行后面都加上一个 “;\” 符号。这在语法上使用分号结束了一个 shell 命令,但却会把一个 make 命令的文本部分一次传递给 shell。举例来说,下面的代码就可能会在某个最上层的 makefile 中出现:


    清单 9. shell 脚本中的换行
    all:
    	for i in $(ALLDIRS) ; \
    	do      ( cd $$i ; $(MAKE) all ) ; \
    	done
    

    其中给出了需要注意的 3 件事情。首先是分号和反斜线的用法。其次是 make 变量的用法 $(VARIABLE)。再次是使用 $$ 向 shell 传递一个 $ 符号。就是这样,这实际上都非常简单。

    前缀

    默认情况下,make 会打印出它所运行的每个命令,如果有任何命令失败,make 就会停止执行。在某些情况中,可能会出现某个命令看起来失败了,但是我们却希望整个编译过程继续进行。如果一个命令的第一个字符是连字符(-),那么该行中剩余的命令都会执行,不过其退出状态会被忽略。

    如果并不希望回显命令,可以在前面加上 @ 符号作为前缀。这是显示消息最常用的方法:


    清单 10. 禁止回显
    all:
    	@echo "Beginning build at:"
    	@date
    	@echo "--------"
    

    如果没有 @ 符号,这就会产生下面的输出:


    清单 11. 没有 @ 的命令
    echo "Beginning build at:"
    Beginning build at:
    date
    Sun Jun 18 01:13:21 CDT 2006
    echo "--------"
    --------
    

    尽管 @ 符号不会真正改变 make 所做的事情,但是这却是一种非常受欢迎的特性。

    不可移植的功能

    有些人们非常希望实现的事情却不可移植。但是这些问题也有一些解决办法。

    包含文件

    历史上最难解决的一个兼容性问题是在 makefile 中对包含的处理。早先的 make 实现通常都没有提供方法来实现这种功能,但是现代的一些 make 变种似乎看起来都对这个问题进行了妥善处理。GNU make 语法非常简单,即include file。传统的 Berkeley 语法是 .include "file"。至少有一种 Berkeley make 现在也可以支持 GNU 的符号了,但是目前还尚未全部支持。autoconfImake 所提供的可移植解决方案只是将所希望使用的每个变量的赋值都包含进来。

    有些程序可能会简单地要求使用 GNU make,有些则可能要求使用 Berkeley make,还有些可能要求使用 smake。如果需要包含的文件非常多,可以尝试简单指定一个 make 工具,用这个工具编译一个树(在这 3 种以源代码形式发布的可移植 make 工具中,我最喜欢的是 Berkeley make)。

    使用变量进行嵌套编译

    实际上并没有什么好方法来做这件事情。如果使用了一个包含文件,就可能会遇到此文件是否被干净地包含这样的移植性问题。如果在每个文件中都设置了变量,那么就很难全部重载这些变量。如果只在一个顶层文件中设置这些变量,那么子目录中一些独立的编译就会失败,因为还没有设置变量!

    根据所使用的 make 版本的不同,一个理想的解决方案是在每个文件中都有条件地设置变量:只有在还没有设置这些变量时才需要进行设置;然后顶层文件中的变化在完全编译时就会影响到所有的子目录。当然,此时如果单独进入一个子目录并运行 make 会产生不同的并且不兼容的结果。

    如果所包含的文件不存在,这样做的负面影响就会被放大,那些曾经在 Imake 数千行 makefile 中挣扎过的人都可以证明这点。

    有些人提倡另外一种简单的解决方案:根本就不要递归使用 make。对于大部分项目来说,这是绝对可行的,可以急剧简化(并加速)整个编译过程。 Peter Miller 撰写的文章 “Recursive Make Considered Harmful”(请参阅参考资料)就是一个非常规范的例子。

    当出现问题时应该怎样做

    首先,不要恐慌。开发人员在编写出一个完整的版本之前,可能需要解决很多怪异的 make 问题。隐式规则、没想到的变量替换以及嵌入式 shell 脚本中的语法错误,都可能会引发这种痛苦的享受。

    此时需要仔细阅读错误消息。这是 make 自己产生的消息么?还是 make 所调用的东西产生的消息?如果有一个嵌套的编译,可能会需要通过对一组错误消息来仔细进行分析,才能找到确切的错误。

    如果一个程序没有找到,首先要检查它是否已经安装了。如果已经安装了,那么就要检查路径设置是否正确;有些开发人员的习惯是在 makefile 中使用绝对路径,这在其他系统上可能会失败。如果将某些东西安装到 /opt 中,而 makefile 引用的却是 /usr/local/bin,那么编译就会失败。此时就需要修改路径的设置。

    检查系统时钟;更重要的是,要检查编译树中文件的日期、系统中其他文件的日期以及系统的时钟。在面临输入数据的时间顺序不一致的情况时,make 的行为可能是无害的,也可能是不现实的。如果碰到了时钟问题(例如有些 “新” 文件被标记成 1970 年的),那么就需要修整这个问题了。 “touch” 工具是一个很好的帮手。在时钟问题中产生的错误消息通常都不太明显。

    如果看到的错误消息显示有一些语法错误,或者有很多变量没有设置,或设置得不正确,那么可以尝试试验一下其他版本的 make;举例来说,有些程序在使用 gmake 编译时会产生一些非常含糊的错误,而使用 smake 时就能很好地进行编译。有些非常怪异的错误会说明正在使用 GNU make 来运行一个 Berkeley 的 makefile,反之亦然。Linux 特有的程序通常会假设使用 GNU make,使用其他 make 工具可能会碰到莫名其妙的错误,有些甚至在文档中都没有任何提示。

    调试标记可能会非常有用。对于 GNU make ,-d 标记会提供大量的信息,其中有些是非常有用的。对于 Berkeley make ,-d 标记有一组标记;-d A 表示完整的集合,或者可以使用其中的一些子集;举例来说,-d vx 会给出有关变量赋值(v)的调试信息,这会导致通过sh -x 来运行所有的命令,这样 shell 就会精确地回显自己接收到的命令。-n 调试标记会导致 make 打印它认为需要做的事情的一个列表;这并不总是正确的,不过通常可以为思考哪些地方出现了问题而提供一些思路。

    在调试 makefile 时,目标是找到 make 正在试图编译什么东西,以及它认为哪些命令可以用来编译。如果 make 使用了正确的命令,但命令却出现了故障,那么这可能意味着完成了 make 调试 —— 但也许并不完全是。举例来说,如果试图编译程序时由于存在无法解析的符号而失败了,那么就可能是编译过程前面某个步骤出现了问题!如果不能定位命令中哪儿出现了问题,并且它看起来应该正常工作,那么很可能是 make 前面创建的某个文件没有被正确创建。

    文档

    通常情况下, GNU make 的主要文档都没有以 man 格式提供,这一点非常不幸;我们只好使用 info 系统,而且不能运行 man make 来查找有关的信息。不过这些文档还是非常齐全的。

    要找到有关所有实现都能支持的特性的一个 “安全子集” 的文档非常难。Berkeley 和 GNU make 文档在描述扩展时都试图提及这个问题,不过多做些测试总是个好事,这样就不会全靠猜测去定义每个 make 工具的确切界限。

    经过一段时间的发展,BSD 系列之间的微小偏移已经在 make 实现之间产生了一些差异。在三者之中,NetBSD 是 make 在其他系统上支持最为广泛的;NetBSD pkgsrc 系统现在还在其他平台上使用,它就严重依赖于 NetBSD 的 make 实现。


    参考资料

    学习

    获得产品和技术

    • 订购免费的 SEK for Linux,这有两张 DVD,包括最新的 IBM for Linux 的试用版软件,包括 DB2®、Lotus®、Rational®、Tivoli® 和 WebSphere®。

    • 使用 IBM 试用软件 构建您的下一个 Linux 开发项目,这些软件可以从 developerWorks 上直接下载。

    讨论

    关于作者

    作者照片

    Peter Seebach 有多年使用计算机的经验,已经逐渐变成了计算机高手。但是他仍然不知道为什么需要如此频繁地清理鼠标。

    Linux 系统内核的调试

    树雷 李 (lisl03@mails.tsinghua.edu.cn), 清华大学计算机系硕士研究生

    简介: 本文将首先介绍 Linux 内核上的一些内核代码监视和错误跟踪技术,这些调试和跟踪方法因所要求的使用环境和使用方法而各有不同,然后重点介绍三种 Linux 内核的源代码级的调试方法。

    调试是软件开发过程中一个必不可少的环节,在 Linux 内核开发的过程中也不可避免地会面对如何调试内核的问题。但是,Linux 系统的开发者出于保证内核代码正确性的考虑,不愿意在 Linux 内核源代码树中加入一个调试器。他们认为内核中的调试器会误导开发者,从而引入不良的修正[1]。所以对 Linux 内核进行调试一直是个令内核程序员感到棘手的问题,调试工作的艰苦性是内核级的开发区别于用户级开发的一个显著特点。

    尽管缺乏一种内置的调试内核的有效方法,但是 Linux 系统在内核发展的过程中也逐渐形成了一些监视内核代码和错误跟踪的技术。同时,许多的补丁程序应运而生,它们为标准内核附加了内核调试的支持。尽管这些补丁有些并不被 Linux 官方组织认可,但他们确实功能完善,十分强大。调试内核问题时,利用这些工具与方法跟踪内核执行情况,并查看其内存和数据结构将是非常有用的。

    本文将首先介绍 Linux 内核上的一些内核代码监视和错误跟踪技术,这些调试和跟踪方法因所要求的使用环境和使用方法而各有不同,然后重点介绍三种 Linux 内核的源代码级的调试方法。

    1. Linux 系统内核级软件的调试技术

    printk() 是调试内核代码时最常用的一种技术。在内核代码中的特定位置加入printk() 调试调用,可以直接把所关心的信息打打印到屏幕上,从而可以观察程序的执行路径和所关心的变量、指针等信息。 Linux 内核调试器(Linux kernel debugger,kdb)是 Linux 内核的补丁,它提供了一种在系统能运行时对内核内存和数据结构进行检查的办法。Oops、KDB在文章掌握Linux 调试技术有详细介绍,大家可以参考。 Kprobes 提供了一个强行进入任何内核例程,并从中断处理器无干扰地收集信息的接口。使用 Kprobes 可以轻松地收集处理器寄存器和全局数据结构等调试信息,而无需对Linux内核频繁编译和启动,具体使用方法,请参考使用 Kprobes 调试内核

    以上介绍了进行Linux内核调试和跟踪时的常用技术和方法。当然,内核调试与跟踪的方法还不止以上提到的这些。这些调试技术的一个共同的特点在于,他们都不能提供源代码级的有效的内核调试手段,有些只能称之为错误跟踪技术,因此这些方法都只能提供有限的调试能力。下面将介绍三种实用的源代码级的内核调试方法。

    2. 使用KGDB构建Linux内核调试环境

    kgdb提供了一种使用 gdb调试 Linux 内核的机制。使用KGDB可以象调试普通的应用程序那样,在内核中进行设置断点、检查变量值、单步跟踪程序运行等操作。使用KGDB调试时需要两台机器,一台作为开发机(Development Machine),另一台作为目标机(Target Machine),两台机器之间通过串口或者以太网口相连。串口连接线是一根RS-232接口的电缆,在其内部两端的第2脚(TXD)与第3脚(RXD)交叉相连,第7脚(接地脚)直接相连。调试过程中,被调试的内核运行在目标机上,gdb调试器运行在开发机上。

    目前,kgdb发布支持i386、x86_64、32-bit PPC、SPARC等几种体系结构的调试器。有关kgdb补丁的下载地址见参考资料[4]。

    2.1 kgdb的调试原理

    安装kgdb调试环境需要为Linux内核应用kgdb补丁,补丁实现的gdb远程调试所需要的功能包括命令处理、陷阱处理及串口通讯3个主要的部分。kgdb补丁的主要作用是在Linux内核中添加了一个调试Stub。调试Stub是Linux内核中的一小段代码,提供了运行gdb的开发机和所调试内核之间的一个媒介。gdb和调试stub之间通过gdb串行协议进行通讯。gdb串行协议是一种基于消息的ASCII码协议,包含了各种调试命令。当设置断点时,kgdb负责在设置断点的指令前增加一条trap指令,当执行到断点时控制权就转移到调试stub中去。此时,调试stub的任务就是使用远程串行通信协议将当前环境传送给gdb,然后从gdb处接受命令。gdb命令告诉stub下一步该做什么,当stub收到继续执行的命令时,将恢复程序的运行环境,把对CPU的控制权重新交还给内核。



    2.2 Kgdb的安装与设置

    下面我们将以Linux 2.6.7内核为例详细介绍kgdb调试环境的建立过程。

    2.2.1软硬件准备

    以下软硬件配置取自笔者进行试验的系统配置情况:



    kgdb补丁的版本遵循如下命名模式:Linux-A-kgdb-B,其中A表示Linux的内核版本号,B为kgdb的版本号。以试验使用的kgdb补丁为例,linux内核的版本为linux-2.6.7,补丁版本为kgdb-2.2。

    物理连接好串口线后,使用以下命令来测试两台机器之间串口连接情况,stty命令可以对串口参数进行设置:

    在development机上执行:


    stty ispeed 115200 ospeed 115200 -F /dev/ttyS0
    

    在target机上执行:


    stty ispeed 115200 ospeed 115200 -F /dev/ttyS0
    

    在developement机上执行:


    echo hello > /dev/ttyS0
    

    在target机上执行:


    cat /dev/ttyS0
    

    如果串口连接没问题的话在将在target机的屏幕上显示"hello"。

    2.2.2 安装与配置

    下面我们需要应用kgdb补丁到Linux内核,设置内核选项并编译内核。这方面的资料相对较少,笔者这里给出详细的介绍。下面的工作在开发机(developement)上进行,以上面介绍的试验环境为例,某些具体步骤在实际的环境中可能要做适当的改动:

    I、内核的配置与编译


    [root@lisl tmp]# tar -jxvf linux-2.6.7.tar.bz2
    [root@lisl tmp]#tar -jxvf linux-2.6.7-kgdb-2.2.tar.tar
    [root@lisl tmp]#cd inux-2.6.7
    

    请参照目录补丁包中文件README给出的说明,执行对应体系结构的补丁程序。由于试验在i386体系结构上完成,所以只需要安装一下补丁:core-lite.patch、i386-lite.patch、8250.patch、eth.patch、core.patch、i386.patch。应用补丁文件时,请遵循kgdb软件包内series文件所指定的顺序,否则可能会带来预想不到的问题。eth.patch文件是选择以太网口作为调试的连接端口时需要运用的补丁

    应用补丁的命令如下所示:


    [root@lisl tmp]#patch -p1 <../linux-2.6.7-kgdb-2.2/core-lite.patch 
    

    如果内核正确,那么应用补丁时应该不会出现任何问题(不会产生*.rej文件)。为Linux内核添加了补丁之后,需要进行内核的配置。内核的配置可以按照你的习惯选择配置Linux内核的任意一种方式。


    [root@lisl tmp]#make menuconfig
    

    在内核配置菜单的Kernel hacking选项中选择kgdb调试项,例如:


      [*] KGDB: kernel debugging with remote gdb
           Method for KGDB communication (KGDB: On generic serial port (8250))  --->  
      [*] KGDB: Thread analysis 
      [*] KGDB: Console messages through gdb
    [root@lisl tmp]#make
      

    编译内核之前请注意Linux目录下Makefile中的优化选项,默认的Linux内核的编译都以-O2的优化级别进行。在这个优化级别之下,编译器要对内核中的某些代码的执行顺序进行改动,所以在调试时会出现程序运行与代码顺序不一致的情况。可以把Makefile中的-O2选项改为-O,但不可去掉-O,否则编译会出问题。为了使编译后的内核带有调试信息,注意在编译内核的时候需要加上-g选项。

    不过,当选择"Kernel debugging->Compile the kernel with debug info"选项后配置系统将自动打开调试选项。另外,选择"kernel debugging with remote gdb"后,配置系统将自动打开"Compile the kernel with debug info"选项。

    内核编译完成后,使用scp命令进行将相关文件拷贝到target机上(当然也可以使用其它的网络工具,如rcp)。


    [root@lisl tmp]#scp arch/i386/boot/bzImage root@192.168.6.13:/boot/vmlinuz-2.6.7-kgdb
    [root@lisl tmp]#scp System.map root@192.168.6.13:/boot/System.map-2.6.7-kgdb
    

    如果系统启动使所需要的某些设备驱动没有编译进内核的情况下,那么还需要执行如下操作:


    [root@lisl tmp]#mkinitrd /boot/initrd-2.6.7-kgdb 2.6.7
    [root@lisl tmp]#scp initrd-2.6.7-kgdb root@192.168.6.13:/boot/ initrd-2.6.7-kgdb
    

    II、kgdb的启动

    在将编译出的内核拷贝的到target机器之后,需要配置系统引导程序,加入内核的启动选项。以下是kgdb内核引导参数的说明:



    如表中所述,在kgdb 2.0版本之后内核的引导参数已经与以前的版本有所不同。使用grub引导程序时,直接将kgdb参数作为内核vmlinuz的引导参数。下面给出引导器的配置示例。


    title 2.6.7 kgdb
    root (hd0,0)
    kernel /boot/vmlinuz-2.6.7-kgdb ro root=/dev/hda1 kgdbwait kgdb8250=1,115200
    

    在使用lilo作为引导程序时,需要把kgdb参放在由append修饰的语句中。下面给出使用lilo作为引导器时的配置示例。


    image=/boot/vmlinuz-2.6.7-kgdb
    label=kgdb
        read-only
        root=/dev/hda3
    append="gdb gdbttyS=1 gdbbaud=115200"
    

    保存好以上配置后重新启动计算机,选择启动带调试信息的内核,内核将在短暂的运行后在创建init内核线程之前停下来,打印出以下信息,并等待开发机的连接。

    Waiting for connection from remote gdb...

    在开发机上执行:


    gdb
    file vmlinux
    set remotebaud 115200
    target remote /dev/ttyS0
    

    其中vmlinux是指向源代码目录下编译出来的Linux内核文件的链接,它是没有经过压缩的内核文件,gdb程序从该文件中得到各种符号地址信息。

    这样,就与目标机上的kgdb调试接口建立了联系。一旦建立联接之后,对Linux内的调试工作与对普通的运用程序的调试就没有什么区别了。任何时候都可以通过键入ctrl+c打断目标机的执行,进行具体的调试工作。

    在kgdb 2.0之前的版本中,编译内核后在arch/i386/kernel目录下还会生成可执行文件gdbstart。将该文件拷贝到target机器的/boot目录下,此时无需更改内核的启动配置文件,直接使用命令:


    [root@lisl boot]#gdbstart -s 115200 -t /dev/ttyS0
    

    可以在KGDB内核引导启动完成后建立开发机与目标机之间的调试联系。

    2.2.3 通过网络接口进行调试

    kgdb也支持使用以太网接口作为调试器的连接端口。在对Linux内核应用补丁包时,需应用eth.patch补丁文件。配置内核时在Kernel hacking中选择kgdb调试项,配置kgdb调试端口为以太网接口,例如:


    [*]KGDB: kernel debugging with remote gdb
    Method for KGDB communication (KGDB: On ethernet)  ---> 
    ( ) KGDB: On generic serial port (8250)
    (X) KGDB: On ethernet
    

    另外使用eth0网口作为调试端口时,grub.list的配置如下:


    title 2.6.7 kgdb
    root (hd0,0)
    kernel /boot/vmlinuz-2.6.7-kgdb ro root=/dev/hda1 kgdbwait kgdboe=@192.168.
    5.13/,@192.168. 6.13/ 
    

    其他的过程与使用串口作为连接端口时的设置过程相同。

    注意:尽管可以使用以太网口作为kgdb的调试端口,使用串口作为连接端口更加简单易行,kgdb项目组推荐使用串口作为调试端口。

    2.2.4 模块的调试方法

    内核可加载模块的调试具有其特殊性。由于内核模块中各段的地址是在模块加载进内核的时候才最终确定的,所以develop机的gdb无法得到各种符号地址信息。所以,使用kgdb调试模块所需要解决的一个问题是,需要通过某种方法获得可加载模块的最终加载地址信息,并把这些信息加入到gdb环境中。

    I、在Linux 2.4内核中的内核模块调试方法

    在Linux2.4.x内核中,可以使用insmod -m命令输出模块的加载信息,例如:


    [root@lisl tmp]# insmod -m hello.ko >modaddr
    

    查看模块加载信息文件modaddr如下:


    .this           00000060  c88d8000  2**2
    .text           00000035  c88d8060  2**2
    .rodata         00000069  c88d80a0  2**5
    ……
    .data           00000000  c88d833c  2**2
    .bss            00000000  c88d833c  2**2
    ……
    

    在这些信息中,我们关心的只有4个段的地址:.text、.rodata、.data、.bss。在development机上将以上地址信息加入到gdb中,这样就可以进行模块功能的测试了。


    (gdb) Add-symbol-file hello.o 0xc88d8060 -s .data 0xc88d80a0 -s 
    .rodata 0xc88d80a0 -s .bss 0x c88d833c
    

    这种方法也存在一定的不足,它不能调试模块初始化的代码,因为此时模块初始化代码已经执行过了。而如果不执行模块的加载又无法获得模块插入地址,更不可能在模块初始化之前设置断点了。对于这种调试要求可以采用以下替代方法。

    在target机上用上述方法得到模块加载的地址信息,然后再用rmmod卸载模块。在development机上将得到的模块地址信息导入到gdb环境中,在内核代码的调用初始化代码之前设置断点。这样,在target机上再次插入模块时,代码将在执行模块初始化之前停下来,这样就可以使用gdb命令调试模块初始化代码了。

    另外一种调试模块初始化函数的方法是:当插入内核模块时,内核模块机制将调用函数sys_init_module(kernel/modle.c)执行对内核模块的初始化,该函数将调用所插入模块的初始化函数。程序代码片断如下:


    ……	……
    	if (mod->init != NULL)
    		ret = mod->init();
    ……	……
    

    在该语句上设置断点,也能在执行模块初始化之前停下来。

    II、在Linux 2.6.x内核中的内核模块调试方法

    Linux 2.6之后的内核中,由于module-init-tools工具的更改,insmod命令不再支持-m参数,只有采取其他的方法来获取模块加载到内核的地址。通过分析ELF文件格式,我们知道程序中各段的意义如下:

    .text(代码段):用来存放可执行文件的操作指令,也就是说是它是可执行程序在内存种的镜像。

    .data(数据段):数据段用来存放可执行文件中已初始化全局变量,也就是存放程序静态分配的变量和全局变量。

    .bss(BSS段):BSS段包含了程序中未初始化全局变量,在内存中 bss段全部置零。

    .rodata(只读段):该段保存着只读数据,在进程映象中构造不可写的段。

    通过在模块初始化函数中放置一下代码,我们可以很容易地获得模块加载到内存中的地址。


    ……
    int bss_var;
    static int hello_init(void)
    {
    printk(KERN_ALERT "Text location .text(Code Segment):%p\n",hello_init);
    static int data_var=0;
    printk(KERN_ALERT "Data Location .data(Data Segment):%p\n",&data_var);
    printk(KERN_ALERT "BSS Location: .bss(BSS Segment):%p\n",&bss_var);
    ……
    }
    Module_init(hello_init);
    

    这里,通过在模块的初始化函数中添加一段简单的程序,使模块在加载时打印出在内核中的加载地址。.rodata段的地址可以通过执行命令readelf -e hello.ko,取得.rodata在文件中的偏移量并加上段的align值得出。

    为了使读者能够更好地进行模块的调试,kgdb项目还发布了一些脚本程序能够自动探测模块的插入并自动更新gdb中模块的符号信息。这些脚本程序的工作原理与前面解释的工作过程相似,更多的信息请阅读参考资料[4]。

    2.2.5 硬件断点

    kgdb提供对硬件调试寄存器的支持。在kgdb中可以设置三种硬件断点:执行断点(Execution Breakpoint)、写断点(Write Breakpoint)、访问断点(Access Breakpoint)但不支持I/O访问的断点。目前,kgdb对硬件断点的支持是通过宏来实现的,最多可以设置4个硬件断点,这些宏的用法如下:



    在有些情况下,硬件断点的使用对于内核的调试是非常方便的。有关硬件断点的定义和具体的使用说明见参考资料[4]

    2.3.在VMware中搭建调试环境

    kgdb调试环境需要使用两台微机分别充当development机和target机,使用VMware后我们只使用一台计算机就可以顺利完成kgdb调试环境的搭建。以windows下的环境为例,创建两台虚拟机,一台作为开发机,一台作为目标机。

    2.3.1虚拟机之间的串口连接

    虚拟机中的串口连接可以采用两种方法。一种是指定虚拟机的串口连接到实际的COM上,例如开发机连接到COM1,目标机连接到COM2,然后把两个串口通过串口线相连接。另一种更为简便的方法是:在较高一些版本的VMware中都支持把串口映射到命名管道,把两个虚拟机的串口映射到同一个命名管道。例如,在两个虚拟机中都选定同一个命名管道\\.\pipe\com_1,指定target机的COM口为server端,并选择"The other end is a virtual machine"属性;指定development机的COM口端为client端,同样指定COM口的"The other end is a virtual machine"属性。对于IO mode属性,在target上选中"Yield CPU on poll"复选择框,development机不选。这样,可以无需附加任何硬件,利用虚拟机就可以搭建kgdb调试环境。即降低了使用kgdb进行调试的硬件要求,也简化了建立调试环境的过程。



    2.3.2 VMware的使用技巧

    VMware虚拟机是比较占用资源的,尤其是象上面那样在Windows中使用两台虚拟机。因此,最好为系统配备512M以上的内存,每台虚拟机至少分配128M的内存。这样的硬件要求,对目前主流配置的PC而言并不是过高的要求。出于系统性能的考虑,在VMware中尽量使用字符界面进行调试工作。同时,Linux系统默认情况下开启了sshd服务,建议使用SecureCRT登陆到Linux进行操作,这样可以有较好的用户使用界面。

    2.3.3 在Linux下的虚拟机中使用kgdb

    对于在Linux下面使用VMware虚拟机的情况,笔者没有做过实际的探索。从原理上而言,只需要在Linux下只要创建一台虚拟机作为target机,开发机的工作可以在实际的Linux环境中进行,搭建调试环境的过程与上面所述的过程类似。由于只需要创建一台虚拟机,所以使用Linux下的虚拟机搭建kgdb调试环境对系统性能的要求较低。(vmware已经推出了Linux下的版本)还可以在development机上配合使用一些其他的调试工具,例如功能更强大的cgdb、图形界面的DDD调试器等,以方便内核的调试工作。



    2.4 kgdb的一些特点和不足

    使用kgdb作为内核调试环境最大的不足在于对kgdb硬件环境的要求较高,必须使用两台计算机分别作为target和development机。尽管使用虚拟机的方法可以只用一台PC即能搭建调试环境,但是对系统其他方面的性能也提出了一定的要求,同时也增加了搭建调试环境时复杂程度。另外,kgdb内核的编译、配置也比较复杂,需要一定的技巧,笔者当时做的时候也是费了很多周折。当调试过程结束后时,还需要重新制作所要发布的内核。使用kgdb并不能进行全程调试,也就是说kgdb并不能用于调试系统一开始的初始化引导过程。

    不过,kgdb是一个不错的内核调试工具,使用它可以进行对内核的全面调试,甚至可以调试内核的中断处理程序。如果在一些图形化的开发工具的帮助下,对内核的调试将更方便。

    3. 使用SkyEye构建Linux内核调试环境

    SkyEye是一个开源软件项目(OPenSource Software),SkyEye项目的目标是在通用的Linux和Windows平台上模拟常见的嵌入式计算机系统。SkyEye实现了一个指令级的硬件模拟平台,可以模拟多种嵌入式开发板,支持多种CPU指令集。SkyEye 的核心是 GNU 的 gdb 项目,它把gdb和 ARM Simulator很好地结合在了一起。加入ARMulator 的功能之后,它就可以来仿真嵌入式开发板,在它上面不仅可以调试硬件驱动,还可以调试操作系统。Skyeye项目目前已经在嵌入式系统开发领域得到了很大的推广。

    3.1 SkyEye的安装和μcLinux内核编译

    3.1.1 SkyEye的安装

    SkyEye的安装不是本文要介绍的重点,目前已经有大量的资料对此进行了介绍。有关SkyEye的安装与使用的内容请查阅参考资料[11]。由于skyeye面目主要用于嵌入式系统领域,所以在skyeye上经常使用的是μcLinux系统,当然使用Linux作为skyeye上运行的系统也是可以的。由于介绍μcLinux 2.6在skyeye上编译的相关资料并不多,所以下面进行详细介绍。

    3.1.2 μcLinux 2.6.x的编译

    要在SkyEye中调试操作系统内核,首先必须使被调试内核能在SkyEye所模拟的开发板上正确运行。因此,正确编译待调试操作系统内核并配置SkyEye是进行内核调试的第一步。下面我们以SkyEye模拟基于Atmel AT91X40的开发板,并运行μcLinux 2.6为例介绍SkyEye的具体调试方法。

    I、安装交叉编译环境

    先安装交叉编译器。尽管在一些资料中说明使用工具链arm-elf-tools-20040427.sh ,但是由于arm-elf-xxx与arm-linux-xxx对宏及链接处理的不同,经验证明使用arm-elf-xxx工具链在链接vmlinux的最后阶段将会出错。所以这里我们使用的交叉编译工具链是:arm-uclinux-tools-base-gcc3.4.0-20040713.sh,关于该交叉编译工具链的下载地址请参见[6]。注意以下步骤最好用root用户来执行。


    [root@lisl tmp]#chmod +x  arm-uclinux-tools-base-gcc3.4.0-20040713.sh
    [root@lisl tmp]#./arm-uclinux-tools-base-gcc3.4.0-20040713.sh
    

    安装交叉编译工具链之后,请确保工具链安装路径存在于系统PATH变量中。

    II、制作μcLinux内核

    得到μcLinux发布包的一个最容易的方法是直接访问uClinux.org站点[7]。该站点发布的内核版本可能不是最新的,但你能找到一个最新的μcLinux补丁以及找一个对应的Linux内核版本来制作一个最新的μcLinux内核。这里,将使用这种方法来制作最新的μcLinux内核。目前(笔者记录编写此文章时),所能得到的发布包的最新版本是uClinux-dist.20041215.tar.gz。

    下载uClinux-dist.20041215.tar.gz,文件的下载地址请参见[7]。

    下载linux-2.6.9-hsc0.patch.gz,文件的下载地址请参见[8]。

    下载linux-2.6.9.tar.bz2,文件的下载地址请参见[9]。

    现在我们得到了整个的linux-2.6.9源代码,以及所需的内核补丁。请准备一个有2GB空间的目录里来完成以下制作μcLinux内核的过程。


    [root@lisl tmp]# tar -jxvf uClinux-dist-20041215.tar.bz2
    [root@lisl uClinux-dist]# tar -jxvf  linux-2.6.9.tar.bz2
    [root@lisl uClinux-dist]# gzip -dc linux-2.6.9-hsc0.patch.gz | patch -p0 
    

    或者使用:


    [root@lisl uClinux-dist]# gunzip linux-2.6.9-hsc0.patch.gz 
    [root@lisl uClinux-dist]patch -p0 < linux-2.6.9-hsc0.patch
    

    执行以上过程后,将在linux-2.6.9/arch目录下生成一个补丁目录-armnommu。删除原来μcLinux目录里的linux-2.6.x(即那个linux-2.6.9-uc0),并将我们打好补丁的Linux内核目录更名为linux-2.6.x。


    [root@lisl uClinux-dist]# rm -rf linux-2.6.x/
    [root@lisl uClinux-dist]# mv linux-2.6.9 linux-2.6.x
    

    III、配置和编译μcLinux内核

    因为只是出于调试μcLinux内核的目的,这里没有生成uClibc库文件及romfs.img文件。在发布μcLinux时,已经预置了某些常用嵌入式开发板的配置文件,因此这里直接使用这些配置文件,过程如下:


    [root@lisl uClinux-dist]# cd linux-2.6.x
    [root@lisl linux-2.6.x]#make ARCH=armnommu CROSS_COMPILE=arm-uclinux- atmel_
    deconfig
    

    atmel_deconfig文件是μcLinux发布时提供的一个配置文件,存放于目录linux-2.6.x /arch/armnommu/configs/中。


    [root@lisl linux-2.6.x]#make ARCH=armnommu CROSS_COMPILE=arm-uclinux-
    oldconfig
    

    下面编译配置好的内核:


    [root@lisl linux-2.6.x]# make ARCH=armnommu CROSS_COMPILE=arm-uclinux- v=1
    

    一般情况下,编译将顺利结束并在Linux-2.6.x/目录下生成未经压缩的μcLinux内核文件vmlinux。需要注意的是为了调试μcLinux内核,需要打开内核编译的调试选项-g,使编译后的内核带有调试信息。打开编译选项的方法可以选择:

    "Kernel debugging->Compile the kernel with debug info"后将自动打开调试选项。也可以直接修改linux-2.6.x目录下的Makefile文件,为其打开调试开关。方法如下:。


    CFLAGS  += -g 
    

    最容易出现的问题是找不到arm-uclinux-gcc命令的错误,主要原因是PATH变量中没有包含arm-uclinux-gcc命令所在目录。在arm-linux-gcc的缺省安装情况下,它的安装目录是/root/bin/arm-linux-tool/,使用以下命令将路径加到PATH环境变量中。


    Export PATH=$PATH:/root/bin/arm-linux-tool/bin
    

    IV、根文件系统的制作

    Linux内核在启动的时的最后操作之一是加载根文件系统。根文件系统中存放了嵌入式系统使用的所有应用程序、库文件及其他一些需要用到的服务。出于文章篇幅的考虑,这里不打算介绍根文件系统的制作方法,读者可以查阅一些其他的相关资料。值得注意的是,由配置文件skyeye.conf指定了装载到内核中的根文件系统。

    3.2 使用SkyEye调试

    编译完μcLinux内核后,就可以在SkyEye中调试该ELF执行文件格式的内核了。前面已经说过利用SkyEye调试内核与使用gdb调试运用程序的方法相同。

    需要提醒读者的是,SkyEye的配置文件-skyeye.conf记录了模拟的硬件配置和模拟执行行为。该配置文件是SkyEye系统中一个及其重要的文件,很多错误和异常情况的发生都和该文件有关。在安装配置SkyEye出错时,请首先检查该配置文件然后再进行其他的工作。此时,所有的准备工作已经完成,就可以进行内核的调试工作了。

    3.3使用SkyEye调试内核的特点和不足

    在SkyEye中可以进行对Linux系统内核的全程调试。由于SkyEye目前主要支持基于ARM内核的CPU,因此一般而言需要使用交叉编译工具编译待调试的Linux系统内核。另外,制作SkyEye中使用的内核编译、配置过程比较复杂、繁琐。不过,当调试过程结束后无需重新制作所要发布的内核。

    SkyEye只是对系统硬件进行了一定程度上的模拟,所以在SkyEye与真实硬件环境相比较而言还是有一定的差距,这对一些与硬件紧密相关的调试可能会有一定的影响,例如驱动程序的调试。不过对于大部分软件的调试,SkyEye已经提供了精度足够的模拟了。

    SkyEye的下一个目标是和eclipse结合,有了图形界面,能为调试和查看源码提供一些方便。

    4. 使用UML调试Linux内核

    User-mode Linux(UML)简单说来就是在Linux内运行的Linux。该项目是使Linux内核成为一个运行在 Linux 系统之上单独的、用户空间的进程。UML并不是运行在某种新的硬件体系结构之上,而是运行在基于 Linux 系统调用接口所实现的虚拟机。正是由于UML是一个将Linux作为用户空间进程运行的特性,可以使用UML来进行操作系统内核的调试。有关UML的介绍请查阅参考资料[10]、[12]。

    4.1 UML的安装与调试

    UML的安装需要一台运行Linux 2.2.15以上,或者2.3.22以上的I386机器。对于2.6.8及其以前版本的UML,采用两种形式发布:一种是以RPM包的形式发布,一种是以源代码的形式提供UML的安装。按照UML的说明,以RPM形式提供的安装包比较陈旧且会有许多问题。以二进制形式发布的UML包并不包含所需要的调试信息,这些代码在发布时已经做了程度不同的优化。所以,要想利用UML调试Linux系统内核,需要使用最新的UML patch代码和对应版本的Linux内核编译、安装UML。完成UML的补丁之后,会在arch目录下产生一个um目录,主要的UML代码都放在该目录下。

    从2.6.9版本之后(包含2.6.9版本的Linux),User-Mode Linux已经随Linux内核源代码树一起发布,它存放于arch/um目录下。

    编译好UML的内核之后,直接使用gdb运行已经编译好的内核即可进行调试。

    4.2使用UML调试系统内核的特点和不足

    目前,用户模式 Linux 虚拟机也存在一定的局限性。由于UML虚拟机是基于Linux系统调用接口的方式实现的虚拟机,所以用户模式内核不能访问主机系统上的硬件设备。因此,UML并不适合于调试那些处理实际硬件的驱动程序。不过,如果所编写的内核程序不是硬件驱动,例如Linux文件系统、协议栈等情况,使用UML作为调试工具还是一个不错的选择。

    5. 内核调试配置选项

    为了方便调试和测试代码,内核提供了许多与内核调试相关的配置选项。这些选项大部分都在内核配置编辑器的内核开发(kernel hacking)菜单项中。在内核配置目录树菜单的其他地方也还有一些可配置的调试选项,下面将对他们作一定的介绍。

    Page alloc debugging :CONFIG_DEBUG_PAGEALLOC:

    不使用该选项时,释放的内存页将从内核地址空间中移出。使用该选项后,内核推迟移出内存页的过程,因此能够发现内存泄漏的错误。

    Debug memory allocations :CONFIG_DEBUG_SLAB:

    该打开该选项时,在内核执行内存分配之前将执行多种类型检查,通过这些类型检查可以发现诸如内核过量分配或者未初始化等错误。内核将会在每次分配内存前后时设置一些警戒值,如果这些值发生了变化那么内核就会知道内存已经被操作过并给出明确的提示,从而使各种隐晦的错误变得容易被跟踪。

    Spinlock debugging :CONFIG_DEBUG_SPINLOCK:

    打开此选项时,内核将能够发现spinlock未初始化及各种其他的错误,能用于排除一些死锁引起的错误。

    Sleep-inside-spinlock checking:CONFIG_DEBUG_SPINLOCK_SLEEP:

    打开该选项时,当spinlock的持有者要睡眠时会执行相应的检查。实际上即使调用者目前没有睡眠,而只是存在睡眠的可能性时也会给出提示。

    Compile the kernel with debug info :CONFIG_DEBUG_INFO:

    打开该选项时,编译出的内核将会包含全部的调试信息,使用gdb时需要这些调试信息。

    Stack utilization instrumentation :CONFIG_DEBUG_STACK_USAGE:

    该选项用于跟踪内核栈的溢出错误,一个内核栈溢出错误的明显的现象是产生oops错误却没有列出系统的调用栈信息。该选项将使内核进行栈溢出检查,并使内核进行栈使用的统计。

    Driver Core verbose debug messages:CONFIG_DEBUG_DRIVER:

    该选项位于"Device drivers-> Generic Driver Options"下,打开该选项使得内核驱动核心产生大量的调试信息,并将他们记录到系统日志中。

    Verbose SCSI error reporting (kernel size +=12K) :CONFIG_SCSI_CONSTANTS:

    该选项位于"Device drivers/SCSI device support"下。当SCSI设备出错时内核将给出详细的出错信息。

    Event debugging:CONFIG_INPUT_EVBUG:

    打开该选项时,会将输入子系统的错误及所有事件都输出到系统日志中。该选项在产生了详细的输入报告的同时,也会导致一定的安全问题。

    以上内核编译选项需要读者根据自己所进行的内核编程的实际情况,灵活选取。在使用以上介绍的三种源代码级的内核调试工具时,一般需要选取CONFIG_DEBUG_INFO选项,以使编译的内核包含调试信息。

    6. 总结

    上面介绍了一些调试Linux内核的方法,特别是详细介绍了三种源代码级的内核调试工具,以及搭建这些内核调试环境的方法,读者可以根据自己的情况从中作出选择。

    调试工具(例如gdb)的运行都需要操作系统的支持,而此时内核由于一些错误的代码而不能正确执行对系统的管理功能,所以对内核的调试必须采取一些特殊的方法进行。以上介绍的三种源代码级的调试方法,可以归纳为以下两种策略:

    I、为内核增加调试Stub,利用调试Stub进行远程调试,这种调试策略需要target及development机器才能完成调试任务。

    II、将虚拟机技术与调试工具相结合,使Linux内核在虚拟机中运行从而利用调试器对内核进行调试。这种策略需要制作适合在虚拟机中运行的系统内核。

    由不同的调试策略决定了进行调试时不同的工作原理,同时也形成了各种调试方法不同的软硬件需求和各自的特点。

    另外,需要说明的是内核调试能力的掌握很大程度上取决于经验和对整个操作系统的深入理解。对系统内核的全面深入的理解,将能在很大程度上加快对Linux系统内核的开发和调试。

    对系统内核的调试技术和方法绝不止上面介绍所涉及的内容,这里只是介绍了一些经常看到和听到方法。在Linux内核向前发展的同时,内核的调试技术也在不断的进步。希望以上介绍的一些方法能对读者开发和学习Linux有所帮助。

    参考资料

    [1] http://oss.sgi.com/projects/kdb/

    [2] http://www.ibm.com/developerworks/cn/linux/sdk/l-debug/index.html

    [3] http://www.ibm.com/developerworks/cn/linux/l-kdbug/

    [4] http://www.ibm.com/developerworks/cn/linux/l-kprobes.html

    [5] http://kgdb.linsyssoft.com/downloads.htm

    [6] ftp://166.111.68.183

    [8] http://www.uclinux.org/pub/uClinux/dist/

    [9] http://opensrc.sec.samsung.com/download/linux-2.6.9-hsc0.patch.gz

    [10] http:// www.kernel.org

    [11] http://user-mode-linux.sourceforge.net/

    [12] http://www.ibm.com/developerworks/cn/linux/l-skyeye/part1/

    [13] http://www.ibm.com/developerworks/cn/views/linux/tutorials.jsp?cv_doc_id=84978

    参考文献

    [1]Robert Love Linux kernel development机械工业出版社

    [2]陈渝 源代码开发的嵌入式系统软件分析与实践 北京航空航天大学出版社

    [3]Alessandro Rubini Linux device driver 2se Edition O'Reilly

    [4]Jonathan Corbet Linux device driver 3rd Edition O'Reilly

    [5]李善平 Linux内核源代码分析大全 机械工业出版社


    作者简介

    李树雷,清华大学计算机系硕士研究生,主要从事操作系统与中间件的研究。通过lisl03@mails.tsinghua.edu.cn 可以跟他联系

    陈渝, 清华大学,通过 yuchen@tsinghua.edu.cn 可以和他联系。


    Shell脚本调试技术

    曹 羽中 (caoyuz@cn.ibm.com), 软件工程师, IBM中国开发中心

    简介: 本文全面系统地介绍了shell脚本调试技术,包括使用echo, tee, trap等命令输出关键信息,跟踪变量的值,在脚本中植入调试钩子,使用“-n”选项进行shell脚本的语法检查, 使用“-x”选项实现shell脚本逐条语句的跟踪,巧妙地利用shell的内置变量增强“-x”选项的输出信息等。

    一. 前言

    shell编程在unix/linux世界中使用得非常广泛,熟练掌握shell编程也是成为一名优秀的unix/linux开发者和系统管理员的必经之路。脚本调试的主要工作就是发现引发脚本错误的原因以及在脚本源代码中定位发生错误的行,常用的手段包括分析输出的错误信息,通过在脚本中加入调试语句,输出调试信息来辅助诊断错误,利用调试工具等。但与其它高级语言相比,shell解释器缺乏相应的调试机制和调试工具的支持,其输出的错误信息又往往很不明确,初学者在调试脚本时,除了知道用echo语句输出一些信息外,别无它法,而仅仅依赖于大量的加入echo语句来诊断错误,确实令人不胜其繁,故常见初学者抱怨shell脚本太难调试了。本文将系统地介绍一些重要的shell脚本调试技术,希望能对shell的初学者有所裨益。

    本文的目标读者是unix/linux环境下的开发人员,测试人员和系统管理员,要求读者具有基本的shell编程知识。本文所使用范例在Bash3.1+Redhat Enterprise Server 4.0下测试通过,但所述调试技巧应也同样适用于其它shell。

    二. 在shell脚本中输出调试信息

    通过在程序中加入调试语句把一些关键地方或出错的地方的相关信息显示出来是最常见的调试手段。Shell程序员通常使用echo(ksh程序员常使用print)语句输出信息,但仅仅依赖echo语句的输出跟踪信息很麻烦,调试阶段在脚本中加入的大量的echo语句在产品交付时还得再费力一一删除。针对这个问题,本节主要介绍一些如何方便有效的输出调试信息的方法。

    1. 使用trap命令

    trap命令用于捕获指定的信号并执行预定义的命令。
    其基本的语法是:
    trap 'command' signal
    其中signal是要捕获的信号,command是捕获到指定的信号之后,所要执行的命令。可以用kill –l命令看到系统中全部可用的信号名,捕获信号后所执行的命令可以是任何一条或多条合法的shell语句,也可以是一个函数名。
    shell脚本在执行时,会产生三个所谓的“伪信号”,(之所以称之为“伪信号”是因为这三个信号是由shell产生的,而其它的信号是由操作系统产生的),通过使用trap命令捕获这三个“伪信号”并输出相关信息对调试非常有帮助。


    表 1. shell伪信号
    信号名 何时产生
    EXIT 从一个函数中退出或整个脚本执行完毕
    ERR 当一条命令返回非零状态时(代表命令执行不成功)
    DEBUG 脚本中每一条命令执行之前

    通过捕获EXIT信号,我们可以在shell脚本中止执行或从函数中退出时,输出某些想要跟踪的变量的值,并由此来判断脚本的执行状态以及出错原因,其使用方法是:
    trap 'command' EXIT 或 trap 'command' 0

    通过捕获ERR信号,我们可以方便的追踪执行不成功的命令或函数,并输出相关的调试信息,以下是一个捕获ERR信号的示例程序,其中的$LINENO是一个shell的内置变量,代表shell脚本的当前行号。

    $ cat -n exp1.sh
         1  ERRTRAP()
         2  {
         3    echo "[LINE:$1] Error: Command or function exited with status $?"
         4  }
         5  foo()
         6  {
         7    return 1;
         8  }
         9  trap 'ERRTRAP $LINENO' ERR
        10  abc
        11  foo
          

    其输出结果如下:

    $ sh exp1.sh
    exp1.sh: line 10: abc: command not found
    [LINE:10] Error: Command or function exited with status 127
    [LINE:11] Error: Command or function exited with status 1
          

    在调试过程中,为了跟踪某些变量的值,我们常常需要在shell脚本的许多地方插入相同的echo语句来打印相关变量的值,这种做法显得烦琐而笨拙。而通过捕获DEBUG信号,我们只需要一条trap语句就可以完成对相关变量的全程跟踪。

    以下是一个通过捕获DEBUG信号来跟踪变量的示例程序:

    $ cat –n exp2.sh
         1  #!/bin/bash
         2  trap 'echo “before execute line:$LINENO, a=$a,b=$b,c=$c”' DEBUG
         3  a=1
         4  if [ "$a" -eq 1 ]
         5  then
         6     b=2
         7  else
         8     b=1
         9  fi
        10  c=3
        11  echo "end"
    

    其输出结果如下:

    $ sh exp2.sh
    before execute line:3, a=,b=,c=
    before execute line:4, a=1,b=,c=
    before execute line:6, a=1,b=,c=
    before execute line:10, a=1,b=2,c=
    before execute line:11, a=1,b=2,c=3
    end
    

    从运行结果中可以清晰的看到每执行一条命令之后,相关变量的值的变化。同时,从运行结果中打印出来的行号来分析,可以看到整个脚本的执行轨迹,能够判断出哪些条件分支执行了,哪些条件分支没有执行。

    2. 使用tee命令

    在shell脚本中管道以及输入输出重定向使用得非常多,在管道的作用下,一些命令的执行结果直接成为了下一条命令的输入。如果我们发现由管道连接起来的一批命令的执行结果并非如预期的那样,就需要逐步检查各条命令的执行结果来判断问题出在哪儿,但因为使用了管道,这些中间结果并不会显示在屏幕上,给调试带来了困难,此时我们就可以借助于tee命令了。

    tee命令会从标准输入读取数据,将其内容输出到标准输出设备,同时又可将内容保存成文件。例如有如下的脚本片段,其作用是获取本机的ip地址:

    ipaddr=`/sbin/ifconfig | grep 'inet addr:' | grep -v '127.0.0.1'
    | cut -d : -f3 | awk '{print $1}'` 
    #注意=号后面的整句是用反引号(数字1键的左边那个键)括起来的。
    echo $ipaddr
    

    运行这个脚本,实际输出的却不是本机的ip地址,而是广播地址,这时我们可以借助tee命令,输出某些中间结果,将上述脚本片段修改为:

    ipaddr=`/sbin/ifconfig | grep 'inet addr:' | grep -v '127.0.0.1'
    | tee temp.txt | cut -d : -f3 | awk '{print $1}'`
    echo $ipaddr
    

    之后,将这段脚本再执行一遍,然后查看temp.txt文件的内容:

    $ cat temp.txt
    inet addr:192.168.0.1  Bcast:192.168.0.255  Mask:255.255.255.0
    

    我们可以发现中间结果的第二列(列之间以:号分隔)才包含了IP地址,而在上面的脚本中使用cut命令截取了第三列,故我们只需将脚本中的cut -d : -f3改为cut -d : -f2即可得到正确的结果。

    具体到上述的script例子,我们也许并不需要tee命令的帮助,比如我们可以分段执行由管道连接起来的各条命令并查看各命令的输出结果来诊断错误,但在一些复杂的shell脚本中,这些由管道连接起来的命令可能又依赖于脚本中定义的一些其它变量,这时我们想要在提示符下来分段运行各条命令就会非常麻烦了,简单地在管道之间插入一条tee命令来查看中间结果会更方便一些。

    3. 使用"调试钩子"

    在C语言程序中,我们经常使用DEBUG宏来控制是否要输出调试信息,在shell脚本中我们同样可以使用这样的机制,如下列代码所示:

    if [ “$DEBUG” = “true” ]; then
    echo “debugging”  #此处可以输出调试信息
    fi
    

    这样的代码块通常称之为“调试钩子”或“调试块”。在调试钩子内部可以输出任何您想输出的调试信息,使用调试钩子的好处是它是可以通过DEBUG变量来控制的,在脚本的开发调试阶段,可以先执行export DEBUG=true命令打开调试钩子,使其输出调试信息,而在把脚本交付使用时,也无需再费事把脚本中的调试语句一一删除。

    如果在每一处需要输出调试信息的地方均使用if语句来判断DEBUG变量的值,还是显得比较繁琐,通过定义一个DEBUG函数可以使植入调试钩子的过程更简洁方便,如下面代码所示:

    $ cat –n exp3.sh
         1  DEBUG()
         2  {
         3  if [ "$DEBUG" = "true" ]; then
         4      $@  
         5  fi
         6  }
         7  a=1
         8  DEBUG echo "a=$a"
         9  if [ "$a" -eq 1 ]
        10  then
        11       b=2
        12  else
        13       b=1
        14  fi
        15  DEBUG echo "b=$b"
        16  c=3
        17  DEBUG echo "c=$c"
    

    在上面所示的DEBUG函数中,会执行任何传给它的命令,并且这个执行过程是可以通过DEBUG变量的值来控制的,我们可以把所有跟调试有关的命令都作为DEBUG函数的参数来调用,非常的方便。

    三. 使用shell的执行选项

    上一节所述的调试手段是通过修改shell脚本的源代码,令其输出相关的调试信息来定位错误的,那有没有不修改源代码来调试shell脚本的方法呢?答案就是使用shell的执行选项,本节将介绍一些常用选项的用法:

    -n 只读取shell脚本,但不实际执行
    -x 进入跟踪方式,显示所执行的每一条命令
    -c "string" 从strings中读取命令

    “-n”可用于测试shell脚本是否存在语法错误,但不会实际执行命令。在shell脚本编写完成之后,实际执行之前,首先使用“-n”选项来测试脚本是否存在语法错误是一个很好的习惯。因为某些shell脚本在执行时会对系统环境产生影响,比如生成或移动文件等,如果在实际执行才发现语法错误,您不得不手工做一些系统环境的恢复工作才能继续测试这个脚本。

    “-c”选项使shell解释器从一个字符串中而不是从一个文件中读取并执行shell命令。当需要临时测试一小段脚本的执行结果时,可以使用这个选项,如下所示:
    sh -c 'a=1;b=2;let c=$a+$b;echo "c=$c"'

    "-x"选项可用来跟踪脚本的执行,是调试shell脚本的强有力工具。“-x”选项使shell在执行脚本的过程中把它实际执行的每一个命令行显示出来,并且在行首显示一个"+"号。"+"号后面显示的是经过了变量替换之后的命令行的内容,有助于分析实际执行的是什么命令。 “-x”选项使用起来简单方便,可以轻松对付大多数的shell调试任务,应把其当作首选的调试手段。

    如果把本文前面所述的trap ‘command’ DEBUG机制与“-x”选项结合起来,我们就可以既输出实际执行的每一条命令,又逐行跟踪相关变量的值,对调试相当有帮助。

    仍以前面所述的exp2.sh为例,现在加上“-x”选项来执行它:

    $ sh –x exp2.sh
    + trap 'echo "before execute line:$LINENO, a=$a,b=$b,c=$c"' DEBUG
    ++ echo 'before execute line:3, a=,b=,c='
    before execute line:3, a=,b=,c=
    + a=1
    ++ echo 'before execute line:4, a=1,b=,c='
    before execute line:4, a=1,b=,c=
    + '[' 1 -eq 1 ']'
    ++ echo 'before execute line:6, a=1,b=,c='
    before execute line:6, a=1,b=,c=
    + b=2
    ++ echo 'before execute line:10, a=1,b=2,c='
    before execute line:10, a=1,b=2,c=
    + c=3
    ++ echo 'before execute line:11, a=1,b=2,c=3'
    before execute line:11, a=1,b=2,c=3
    + echo end
    end
    

    在上面的结果中,前面有“+”号的行是shell脚本实际执行的命令,前面有“++”号的行是执行trap机制中指定的命令,其它的行则是输出信息。

    shell的执行选项除了可以在启动shell时指定外,亦可在脚本中用set命令来指定。"set -参数"表示启用某选项,"set +参数"表示关闭某选项。有时候我们并不需要在启动时用"-x"选项来跟踪所有的命令行,这时我们可以在脚本中使用set命令,如以下脚本片段所示:

    set -x    #启动"-x"选项 
    要跟踪的程序段 
    set +x     #关闭"-x"选项
    

    set命令同样可以使用上一节中介绍的调试钩子—DEBUG函数来调用,这样可以避免脚本交付使用时删除这些调试语句的麻烦,如以下脚本片段所示:

    DEBUG set -x    #启动"-x"选项 
    要跟踪的程序段 
    DEBUG set +x    #关闭"-x"选项
    

    四. 对"-x"选项的增强

    "-x"执行选项是目前最常用的跟踪和调试shell脚本的手段,但其输出的调试信息仅限于进行变量替换之后的每一条实际执行的命令以及行首的一个"+"号提示符,居然连行号这样的重要信息都没有,对于复杂的shell脚本的调试来说,还是非常的不方便。幸运的是,我们可以巧妙地利用shell内置的一些环境变量来增强"-x"选项的输出信息,下面先介绍几个shell内置的环境变量:

    $LINENO
    代表shell脚本的当前行号,类似于C语言中的内置宏__LINE__

    $FUNCNAME
    函数的名字,类似于C语言中的内置宏__func__,但宏__func__只能代表当前所在的函数名,而$FUNCNAME的功能更强大,它是一个数组变量,其中包含了整个调用链上所有的函数的名字,故变量${FUNCNAME[0]}代表shell脚本当前正在执行的函数的名字,而变量${FUNCNAME[1]}则代表调用函数${FUNCNAME[0]}的函数的名字,余者可以依此类推。

    $PS4
    主提示符变量$PS1和第二级提示符变量$PS2比较常见,但很少有人注意到第四级提示符变量$PS4的作用。我们知道使用“-x”执行选项将会显示shell脚本中每一条实际执行过的命令,而$PS4的值将被显示在“-x”选项输出的每一条命令的前面。在Bash Shell中,缺省的$PS4的值是"+"号。(现在知道为什么使用"-x"选项时,输出的命令前面有一个"+"号了吧?)。

    利用$PS4这一特性,通过使用一些内置变量来重定义$PS4的值,我们就可以增强"-x"选项的输出信息。例如先执行export PS4='+{$LINENO:${FUNCNAME[0]}} ', 然后再使用“-x”选项来执行脚本,就能在每一条实际执行的命令前面显示其行号以及所属的函数名。

    以下是一个存在bug的shell脚本的示例,本文将用此脚本来示范如何用“-n”以及增强的“-x”执行选项来调试shell脚本。这个脚本中定义了一个函数isRoot(),用于判断当前用户是不是root用户,如果不是,则中止脚本的执行

    $ cat –n exp4.sh
         1  #!/bin/bash
         2  isRoot()
         3  {
         4          if [ "$UID" -ne 0 ]
         5                  return 1
         6          else
         7                  return 0
         8          fi
         9  }
        10  isRoot
        11  if ["$?" -ne 0 ]
        12  then
        13          echo "Must be root to run this script"
        14          exit 1
        15  else
        16          echo "welcome root user"
        17          #do something
        18  fi
    

    首先执行sh –n exp4.sh来进行语法检查,输出如下:

    $ sh –n exp4.sh
    exp4.sh: line 6: syntax error near unexpected token `else'
    exp4.sh: line 6: `      else'
    

    发现了一个语法错误,通过仔细检查第6行前后的命令,我们发现是第4行的if语句缺少then关键字引起的(写惯了C程序的人很容易犯这个错误)。我们可以把第4行修改为if [ "$UID" -ne 0 ]; then来修正这个错误。再次运行sh –n exp4.sh来进行语法检查,没有再报告错误。接下来就可以实际执行这个脚本了,执行结果如下:

    $ sh exp4.sh
    exp2.sh: line 11: [1: command not found
    welcome root user
    

    尽管脚本没有语法错误了,在执行时却又报告了错误。错误信息还非常奇怪“[1: command not found”。现在我们可以试试定制$PS4的值,并使用“-x”选项来跟踪:

    $ export PS4='+{$LINENO:${FUNCNAME[0]}} '
    $ sh –x exp4.sh
    +{10:} isRoot
    +{4:isRoot} '[' 503 -ne 0 ']'
    +{5:isRoot} return 1
    +{11:} '[1' -ne 0 ']'
    exp4.sh: line 11: [1: command not found
    +{16:} echo 'welcome root user'
    welcome root user
    

    从输出结果中,我们可以看到脚本实际被执行的语句,该语句的行号以及所属的函数名也被打印出来,从中可以清楚的分析出脚本的执行轨迹以及所调用的函数的内部执行情况。由于执行时是第11行报错,这是一个if语句,我们对比分析一下同为if语句的第4行的跟踪结果:

    +{4:isRoot} '[' 503 -ne 0 ']'
    +{11:} '[1' -ne 0 ']'
    

    可知由于第11行的[号后面缺少了一个空格,导致[号与紧挨它的变量$?的值1被shell解释器看作了一个整体,并试着把这个整体视为一个命令来执行,故有“[1: command not found”这样的错误提示。只需在[号后面插入一个空格就一切正常了。

    shell中还有其它一些对调试有帮助的内置变量,比如在Bash Shell中还有BASH_SOURCE, BASH_SUBSHELL等一批对调试有帮助的内置变量,您可以通过man sh或man bash来查看,然后根据您的调试目的,使用这些内置变量来定制$PS4,从而达到增强“-x”选项的输出信息的目的。

    五. 总结

    现在让我们来总结一下调试shell脚本的过程:
    首先使用“-n”选项检查语法错误,然后使用“-x”选项跟踪脚本的执行,使用“-x”选项之前,别忘了先定制PS4变量的值来增强“-x”选项的输出信息,至少应该令其输出行号信息(先执行export PS4='+[$LINENO]',更一劳永逸的办法是将这条语句加到您用户主目录的.bash_profile文件中去),这将使你的调试之旅更轻松。也可以利用trap,调试钩子等手段输出关键调试信息,快速缩小排查错误的范围,并在脚本中使用“set -x”及“set +x”对某些代码块进行重点跟踪。这样多种手段齐下,相信您已经可以比较轻松地抓出您的shell脚本中的臭虫了。如果您的脚本足够复杂,还需要更强的调试能力,可以使用shell调试器bashdb,这是一个类似于GDB的调试工具,可以完成对shell脚本的断点设置,单步执行,变量观察等许多功能,使用bashdb对阅读和理解复杂的shell脚本也会大有裨益。关于bashdb的安装和使用,不属于本文范围,您可参阅http://bashdb.sourceforge.net/上的文档并下载试用。


    参考资料

    关于作者

    曹羽中,在北京航空航天大学获得计算机软件与理论专业的硕士学位,具有数年的 unix 环境下的 C 语言,Java,数据库以及电信计费软件的开发经验,他的技术兴趣还包括 OSGi 和搜索技术。他目前在IBM中国系统与科技实验室从事系统管理软件的开发工作,可以通过caoyuz@cn.ibm.com与他联系。


    使用 GDB 调试多进程程序

    田 强 (tianq@cn.ibm.com), 软件工程师, IBM中国软件开发中心

    简介: GDB 是 linux 系统上常用的调试工具,本文介绍了使用 GDB 调试多进程程序的几种方法,并对各种方法进行比较。

    GDB 是 linux 系统上常用的 c/c++ 调试工具,功能十分强大。对于较为复杂的系统,比如多进程系统,如何使用 GDB 调试呢?考虑下面这个三进程系统:


    进程
    进程

    Proc2 是 Proc1 的子进程,Proc3 又是 Proc2 的子进程。如何使用 GDB 调试 proc2 或者 proc3 呢?

    实际上,GDB 没有对多进程程序调试提供直接支持。例如,使用GDB调试某个进程,如果该进程fork了子进程,GDB会继续调试该进程,子进程会不受干扰地运行下去。如果你事先在子进程代码里设定了断点,子进程会收到SIGTRAP信号并终止。那么该如何调试子进程呢?其实我们可以利用GDB的特点或者其他一些辅助手段来达到目的。此外,GDB 也在较新内核上加入一些多进程调试支持。

    接下来我们详细介绍几种方法,分别是 follow-fork-mode 方法,attach 子进程方法和 GDB wrapper 方法。

    follow-fork-mode

    在2.5.60版Linux内核及以后,GDB对使用fork/vfork创建子进程的程序提供了follow-fork-mode选项来支持多进程调试。

    follow-fork-mode的用法为:

    set follow-fork-mode [parent|child]

    • parent: fork之后继续调试父进程,子进程不受影响。
    • child: fork之后调试子进程,父进程不受影响。

    因此如果需要调试子进程,在启动gdb后:

    (gdb) set follow-fork-mode child

    并在子进程代码设置断点。

    此外还有detach-on-fork参数,指示GDB在fork之后是否断开(detach)某个进程的调试,或者都交由GDB控制:

    set detach-on-fork [on|off]

    • on: 断开调试follow-fork-mode指定的进程。
    • off: gdb将控制父进程和子进程。follow-fork-mode指定的进程将被调试,另一个进程置于暂停(suspended)状态。

    注意,最好使用GDB 6.6或以上版本,如果你使用的是GDB6.4,就只有follow-fork-mode模式。

    follow-fork-mode/detach-on-fork的使用还是比较简单的,但由于其系统内核/gdb版本限制,我们只能在符合要求的系统上才能使用。而且,由于follow-fork-mode的调试必然是从父进程开始的,对于fork多次,以至于出现孙进程或曾孙进程的系统,例如上图3进程系统,调试起来并不方便。

    Attach子进程

    众所周知,GDB有附着(attach)到正在运行的进程的功能,即attach <pid>命令。因此我们可以利用该命令attach到子进程然后进行调试。

    例如我们要调试某个进程RIM_Oracle_Agent.9i,首先得到该进程的pid

    [root@tivf09 tianq]# ps -ef|grep RIM_Oracle_Agent.9i
    nobody    6722  6721  0 05:57 ?        00:00:00 RIM_Oracle_Agent.9i
    root      7541 27816  0 06:10 pts/3    00:00:00 grep -i rim_oracle_agent.9i

    通过pstree可以看到,这是一个三进程系统,oserv是RIM_Oracle_prog的父进程,RIM_Oracle_prog又是RIM_Oracle_Agent.9i的父进程。

    [root@tivf09 root]# pstree -H 6722


    通过 pstree 察看进程
    通过 pstree 察看进程

    启动GDB,attach到该进程


    用 GDB 连接进程
    用 GDB 连接进程

    现在就可以调试了。一个新的问题是,子进程一直在运行,attach上去后都不知道运行到哪里了。有没有办法解决呢?

    一个办法是,在要调试的子进程初始代码中,比如main函数开始处,加入一段特殊代码,使子进程在某个条件成立时便循环睡眠等待,attach到进程后在该代码段后设上断点,再把成立的条件取消,使代码可以继续执行下去。

    至于这段代码所采用的条件,看你的偏好了。比如我们可以检查一个指定的环境变量的值,或者检查一个特定的文件存不存在。以文件为例,其形式可以如下:

    void debug_wait(char *tag_file)
    {
        while(1)
        {
            if (tag_file存在)
                睡眠一段时间;
            else
                break;
        }
    }

    当attach到进程后,在该段代码之后设上断点,再把该文件删除就OK了。当然你也可以采用其他的条件或形式,只要这个条件可以设置/检测即可。

    Attach进程方法还是很方便的,它能够应付各种各样复杂的进程系统,比如孙子/曾孙进程,比如守护进程(daemon process),唯一需要的就是加入一小段代码。

    GDB wrapper

    很多时候,父进程 fork 出子进程,子进程会紧接着调用 exec族函数来执行新的代码。对于这种情况,我们也可以使用gdb wrapper 方法。它的优点是不用添加额外代码。

    其基本原理是以gdb调用待执行代码作为一个新的整体来被exec函数执行,使得待执行代码始终处于gdb的控制中,这样我们自然能够调试该子进程代码。

    还是上面那个例子,RIM_Oracle_prog fork出子进程后将紧接着执行RIM_Oracle_Agent.9i的二进制代码文件。我们将该文件重命名为RIM_Oracle_Agent.9i.binary,并新建一个名为RIM_Oracle_Agent.9i的shell脚本文件,其内容如下:

    [root@tivf09 bin]# mv RIM_Oracle_Agent.9i RIM_Oracle_Agent.9i.binary
    [root@tivf09 bin]# cat RIM_Oracle_Agent.9i
    #!/bin/sh
    gdb RIM_Oracle_Agent.binary

    当fork的子进程执行名为RIM_Oracle_Agent.9i的文件时,gdb会被首先启动,使得要调试的代码处于gdb控制之下。

    新的问题来了。子进程是在gdb的控制下了,但还是不能调试:如何与gdb交互呢?我们必须以某种方式启动gdb,以便能在某个窗口/终端与gdb交互。具体来说,可以使用xterm生成这个窗口。

    xterm是X window系统下的模拟终端程序。比如我们在Linux桌面环境GNOME中敲入xterm命令:


    xterm
    xterm

    就会跳出一个终端窗口:


    终端
    终端

    如果你是在一台远程linux服务器上调试,那么可以使用VNC(Virtual Network Computing) viewer从本地机器连接到服务器上使用xterm。在此之前,需要在你的本地机器上安装VNC viewer,在服务器上安装并启动VNC server。大多数linux发行版都预装了vnc-server软件包,所以我们可以直接运行vncserver命令。注意,第一次运行vncserver时会提示输入密码,用作VNC viewer从客户端连接时的密码。可以在VNC server机器上使用vncpasswd命令修改密码。

    [root@tivf09 root]# vncserver 
    
    New 'tivf09:1 (root)' desktop is tivf09:1
    
    Starting applications specified in /root/.vnc/xstartup
    Log file is /root/.vnc/tivf09:1.log
    
    [root@tivf09 root]#
    [root@tivf09 root]# ps -ef|grep -i vnc
    root     19609     1  0 Jun05 ?        00:08:46 Xvnc :1 -desktop tivf09:1 (root) 
      -httpd /usr/share/vnc/classes -auth /root/.Xauthority -geometry 1024x768 
      -depth 16 -rfbwait 30000 -rfbauth /root/.vnc/passwd -rfbport 5901 -pn
    root     19627     1  0 Jun05 ?        00:00:00 vncconfig -iconic
    root     12714 10599  0 01:23 pts/0    00:00:00 grep -i vnc
    [root@tivf09 root]#

    Vncserver是一个Perl脚本,用来启动Xvnc(X VNC server)。X client应用,比如xterm,VNC viewer都是和它通信的。如上所示,我们可以使用的DISPLAY值为tivf09:1。现在就可以从本地机器使用VNC viewer连接过去:


    VNC viewer:输入服务器
    VNC viewer:输入服务器

    输入密码:


    VNC viewer:输入密码
    VNC viewer:输入密码

    登录成功,界面和服务器本地桌面上一样:


    VNC viewer
    VNC viewer

    下面我们来修改RIM_Oracle_Agent.9i脚本,使它看起来像下面这样:

    #!/bin/sh
    export DISPLAY=tivf09:1.0; xterm -e gdb RIM_Oracle_Agent.binary

    如果你的程序在exec的时候还传入了参数,可以改成:

    #!/bin/sh
    export DISPLAY=tivf09:1.0; xterm -e gdb --args RIM_Oracle_Agent.binary $@ 

    最后加上执行权限

    [root@tivf09 bin]# chmod 755 RIM_Oracle_Agent.9i

    现在就可以调试了。运行启动子进程的程序:

    [root@tivf09 root]# wrimtest -l 9i_linux
    Resource Type  : RIM
    Resource Label : 9i_linux
    Host Name      : tivf09
    User Name      : mdstatus
    Vendor         : Oracle
    Database       : rim
    Database Home  : /data/oracle9i/920
    Server ID      : rim
    Instance Home  : 
    Instance Name  : 
    Opening Regular Session...

    程序停住了。从VNC viewer中可以看到,一个新的gdb xterm窗口在服务器端打开了


    gdb xterm 窗口
    gdb xterm窗口
    [root@tivf09 root]# ps -ef|grep gdb
    nobody   24312 24311  0 04:30 ?        00:00:00 xterm -e gdb RIM_Oracle_Agent.binary
    nobody   24314 24312  0 04:30 pts/2    00:00:00 gdb RIM_Oracle_Agent.binary
    root     24326 10599  0 04:30 pts/0    00:00:00 grep gdb

    运行的正是要调试的程序。设置好断点,开始调试吧!

    注意,下面的错误一般是权限的问题,使用 xhost 命令来修改权限:


    xterm 错误
    xterm 错误
    [root@tivf09 bin]# export DISPLAY=tivf09:1.0
    [root@tivf09 bin]# xhost +
    access control disabled, clients can connect from any host

    xhost + 禁止了访问控制,从任何机器都可以连接过来。考虑到安全问题,你也可以使用xhost + <你的机器名>。

    小结

    上述三种方法各有特点和优劣,因此适应于不同的场合和环境:

    • follow-fork-mode方法:方便易用,对系统内核和GDB版本有限制,适合于较为简单的多进程系统
    • attach子进程方法:灵活强大,但需要添加额外代码,适合于各种复杂情况,特别是守护进程
    • GDB wrapper方法:专用于fork+exec模式,不用添加额外代码,但需要X环境支持(xterm/VNC)。

    参考资料

    关于作者

    田强,中国软件开发中心 Tivoli 部门软件工程师,负责 IBM 产品TMF(Tivoli Management Framework)的维护和客户支持工作,热爱 Linux。


    展开全文
  • 软件调试方法及调试原则

    万次阅读 2018-11-16 09:08:37
    软件调试是在进行了成功的测试之后才开始的工作,它与软件测试不同,调试的任务是进一步诊断和改正程序中潜在的错误。   注: 以问题为中心 以错误为导向   调试活动由两部分组成: u 确定程序中可疑错误...
  • 软件调试分析技术》是好友Monster的处女作品。作为一直以的好伙伴,他是我看着长大的,(*^__^*) 嘻嘻……之所以有今天这样的成绩,是与他的努力和天赋脱不了关系的。他大方地给了我PDF版的,我也大方的给了我们...
  • gdb+gdbserver远程调试技术(一)——调试环境搭建

    万次阅读 多人点赞 2017-09-04 16:30:35
    gdb gdbserver 调试技术
  • 调试技术(一)--静态反调试

    千次阅读 2017-03-13 18:42:56
    静态反调试技术以及反调试破解技术
  • 详解反调试技术

    万次阅读 多人点赞 2016-10-14 18:40:38
    调试技术,恶意代码用它识别是否被调试,或者让调试器失效。恶意代码编写者意识到分析人员经常使用调试器来观察恶意代码的操作,因此他们使用反调试技术尽可能地延长恶意代码的分析时间。为了阻止调试器的分析,当...
  • 1,软件测试是找出软件已经存在的错误,而调试是定位错误,修改程序以修正错误. 2,软件测试从一个已知的条件开始,有预知的结局 而调试从未知的条件开始,其结局不可预知 3,软件测试可以计划,可以预先制定测试用例和过程,...
  • 软件测试和软件调试的区别

    千次阅读 2016-10-31 10:11:55
    最近替客户写论文,整理提纲的时候发现他们把软件的测试和调试的部分分开写,虽然知道两者有区别但是当时根本搞不清楚应该怎么写,网上找了些资料看了以后才有些概念,现在贴出来,以后可那能用的到. 1,软件测试是找出...
  • 格蠹汇编——软件调试案例集锦 ,完整扫描版

    千次下载 热门讨论 2013-11-04 18:18:03
    《格蠹汇编——软件调试案例集锦》以案例形式讨论了使用调试技术解决复杂软件问题的工具和方法。全书共36章,分为四篇。前两篇每章讲述一个有代表性的真实案例,包括从堆里抢救丢失的博客,修复因误杀而瘫痪的系统,...
  • 内核调试技术

    千次阅读 2012-11-07 15:10:28
    1.调试技术 内核编程带来了它自己的,独特的调试挑战。内核代码不能简单地在调试器中执行,也不能被简单地跟踪,因为它是一组不与特定进程相关的功能。内核代码的错误非常难重现并且可能导致整个系统崩溃,因此破坏...
  • Windows调试技术基础

    千次阅读 2012-02-21 13:14:08
    软件调试技术的意义: 1. 在调试上花费时间很多  2. 调试可以解决很多问题,是强大的工具  3. 调试是有稳定的生命周期  4. 调试也是学习技术的好工具  调试窗口: BreakPoints. Watch, Local, Call...
  • Windows程序员进阶系列:《软件调试》之一:调试基础 一位著名的计算机科学家曾说过:软件调试要比编写代码困难一倍。因此在阅读《软件调试》这本书之前,我已经做好了攻坚克难的准备。希望广大读者也要心里有个谱...
  • 嵌入式软件调试方法

    千次阅读 2017-04-02 20:17:51
    SdustLiYang的专栏 厚积薄发 目录视图摘要视图订阅 CSDN日报20170401 ——《如果你还是“程序员”,我劝你别创业!...嵌入式软件调试方法 标签: 嵌入式工具测试测试
  • 1955年,一家名为Computer Usage Corporation(CUC)的公司诞生了,它是世界上第一家专门从事软件开发和服务的公司。CUC公司的创始人是Elmer Kubie和John W. Sheldon,他们都在IBM工作过。他们从当时计算机硬件的...
  • 层次1:BringUP 在单板BringUp阶段使用JTAG调式。大多数芯片厂家IDE都支持tcl脚本语言,tcl语言可以控制jtag读取基本的Register,memory,...主要使用printf和LED进行调试,其次还有一些工具软件如i2c-tool,spite...
  • 几个主要软件调试方法及调试原则

    万次阅读 2014-07-31 17:40:11
    软件调试是在进行了成功的测试之后才开始的工作,它与软件测试不同,调试的任务是进一步诊断和改正程序中潜在的错误。   调试活动由两部分组成: u 确定程序中可疑错误的确切性质和位置 u 对程序(设计,编码...
  • 软件调试总结及分享

    千次阅读 2018-05-23 11:46:33
    1、 软件调试对软件开发的意义。通过软件调试,我们可以更深刻的理解语言深处的实现原理。如利用Windbg深入理解变量的存储模型:我们可以通过windbg验证查看C++书上关于各种类型变量存储区域的说明,简单来说就是...
  • 系统软件调试的培训大纲

    千次阅读 2013-09-07 14:01:29
    培训大纲,关于如何在Linux下用gdb进行应用程序和系统软件调试
  • 软件测试和调试的区别

    千次阅读 多人点赞 2017-01-15 13:48:40
    不知道为什么,我现在正处在功能测试往性能测试方向转,突然就有这种疑问,软件测试和软件调试到底有什么区别;在这里记录一下这个区别,以免忘记 1、目的不同 软件测试的目的是发现错误,至于找出错误的原因和...
  • 调试技术(7) B. 调试器检测 书上讲了很多SoftICE的检测方法,但是那玩意儿连XP都不支持,时代的眼泪了…… OD的检测方法 查找特征码 遍历进程,将特定地址处的值与OD的特征码进行比对,相同则确定是OD...
  • ARM Cortex-M底层技术(十四)KEIL MDK 调试技术-1 【调试技术简介】  最近懒癌上身,N久都没有写东西了,我对不起祖国,对不起党,刚好赶在十一,祖国都69(注意是岁数不是姿势……)了,我实在不好意思再偷懒了...
  • 【课程介绍】 工作过可能经常遇到以下场景 程序崩溃、内存泄漏、线程死锁,测试过程中你的缺陷总比别人多,虽然也做...今天技术“大牛”带你从0开始玩转windbg软件调试。跟着视频轻轻松带你学会软件调试,从此调试问题
  • bootloader调试技术

    千次阅读 2010-01-07 16:34:00
    对于系统平台搭建工程师最初的一步通常是移植Bootloader ,当然移植有几个级别,通常最常见的是参考的EVM 的硬件有了修改(如更改了FLASH ,更改了SDRAM 、DDR SDRAM 等),并且是初次调试硬件,更大的困难是公司为...
  • 一种基于TLS的高级反调试技术

    千次阅读 2014-06-08 18:57:16
    在反盗版技术中,起最大作用的当属反调试技术。然而传统的反调试技术都存在一个弱点:他们都在程序真正开始执行之后才采取反调试手段。实际上在反调试代码被执行前,调试器有大量的时间来影响程序的执行,甚至可以在...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 215,885
精华内容 86,354
关键字:

属于软件调试技术的是