将信号用作 Linux 调试工具

时间:2021-09-10 01:38:27
通过重点分析使用信号处理程序捕获到的数据,您可以加速调试过程中耗时最多的一个步骤:寻找 bug。本文介绍了 Linux®信号的背景知识,并给出了已在 PPC Linux 测试通过的示例,然后介绍如何设计自己的信号处理程序来输出信息,从而快速定位代码中有问题的部分。

信号 就是软件中断,可以向正在执行的程序(进程)发送有关异步事件发生的信息。大部分硬件 trap(非法指令、对无效地址的访问等等)都可以转换成信号。

信号可以由进程本身生成,也可以从一个进程发送到其他进程中。系统中可以产生并发送多种类型的信号,它们对于程序员来说有很多用处。(要在 Linux® 环境中查看完整的信号清单,可使用 kill -l 命令。)

尽管本文中介绍的基本原理都是通用的,不过所给出的示例程序是使用 gcc v3.3.3 版及 SUSE Linux Enterprise Server 9(PPC 版)操作系统编译的。

将信号用作调试工具

在调试程序时,大约有 90%的时间都要花在寻找问题上。您可以使用信号来缩短寻找问题的时间。信号可以提供很多有关用户空间进程的信息(或者将某些信息提供给用户空间的进程)。您可以将自己的应用程序设计成可以使用信号信息来判断操作过程,从而使应用程序在执行上下文中实现完全控制。

信号可以使用 SIG_IGN 忽略,忽略的信号不会发送给进程。清单 1 显示了如何忽略一个 SIGINT 信号。(由于这个进程忽略了 SIGINT 信号,因此您需要使用 Crtl-Z 来终止这个进程,或者使用 Crlt-/ 来退出这个进程。)


清单 1. 忽略 SIGINT 信号的示例程序


#include <stdio.h>
#include <signal.h>

main()
{
signal(SIGINT,SIG_IGN);
while(1)
printf("You can't kill me with SIGINT anymore, dude/n");
return 0;
}


当一个信号被发送给某个进程时,可能会发生两类操作:

  • 默认操作,其中内核会对信号进行处理,并根据信号的不同执行适当的操作。每个信号在内核中都有自己的信号处理程序;信号处理程序的默认行为是终止进程。
  • 执行用户定义的操作,此时这个信号由一个用户定义的信号处理程序来处理。

下面让我们来重点介绍一下用户空间的信号处理程序。


将信号用作 Linux 调试工具
将信号用作 Linux 调试工具
回页首


用户空间的信号处理程序

信号处理程序 (signal handler) 就是在接收到信号时所执行的代码。它是用户空间的程序代码的一部分,需要在用户空间的上下文中执行。信号处理程序中提供了有关在信号发生时要执行的操作的信息。信号处理程序可以编写为忽略这个信号。

用户进程不允许为所有信号安装处理程序;例如,不允许为 SIGKILLSIGSTOP 安装处理程序。如果进程失去了控制,有些地方(至少是内核)需要能够终止这个进程。如果操作系统允许进程为这两个信号注册处理程序,并且这两个处理程序设计为忽略信号,那么除了进行硬件重启之外,就没有任何办法可以终止这个进程了。

清单 2 给出了一种注册信号处理程序的方法:


清单 2. 注册信号处理程序


struct sigaction mysig_act;
mysig_act.sa_flags = SA_SIGINFO;
mysig_act.sa_sigaction = (void *)mysig_handler;
if(sigaction (<signal number>,&mysig_act,(struct sigaction *)NULL)) {
printf("Sigaction returned error = %d/n", errno);
exit(0);
}

sigaction
系统调用需要使用 3 个参数:

  • 信号编号
  • 指向新 sigaction 结构体的指针
  • 指向旧 sigaction 结构体的指针

sigaction
结构体的定义如清单 3 所示:


清单 3. sigaction 结构体


struct sigaction {
void (*sa_handler)(int); /* func pointer */
void (*sa_sigaction)(int, siginfo_t *, void *); /*func pointer */
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}

其中 sa_flags 设置为 SA_SIGINFO,信号处理函数应设置为 sa_sigactionSA_SIGINFO 使用下面 3 个参数来调用信号处理程序:

  • 信号编号
  • 信号信息
  • 硬件上下文的快照

mysig_handler
是在接收到信号时要调用的处理函数。mysig_act 是一个 sigaction 结构体,其中包含了所有的信息。

在 UNIX® 中,每个信号都有自己惟一的信号编号。如前所述,kill -l 可以列出所有信号及其对应信号编号。

第二个参数是信号信息结构体。该结构体名为 siginfo_t。这个结构体是由内核根据所生成的信号来填充的。结构体可用于获取发送者的 pid、uid、错误地址以及其他信息。其中还提供了一个错误代码和一个 si 代码。包含此结构体定义的头文件是 bits/siginfo.h。

第三个参数是 ucontext 结构体。此结构体(也就是 User Context Structure 的简写)有一些指向其他结构体 —— 例如 mcontext_tsigset_t 等 —— 的指针。mcontext_t 提供了有关在系统出问题时可以找到的所有寄存器值的数据;这些寄存器值可以作为信号发送给这个进程。内核为系统中所有的进程都维护了一个 context 结构体,以及要在不同进程之间有效进行上下文切换所需要的信息。

内核只是在 pt_regsmcontext_t 结构体中为用户程序提供了有限的信息。这些结构体几乎包含了所有寄存器的数据:通用寄存器 (GPR)、浮点寄存器 (FPR)、VMX 寄存器(如果存在)和专用寄存器 (SPR)。

但切记,pt_regs 是一个面向特定体系结构的结构体。包含这一信息的头文件是 sys/ucontext.h 和 asm/ptrace.h。


清单 4. pt_regs 结构体定义 <asm-ppc64/ptrace.h>


#define PPC_REG unsigned long
struct pt_regs {
PPC_REG gpr[32];
PPC_REG nip;
PPC_REG msr;
PPC_REG orig_gpr3; /* Used for restarting system calls */
PPC_REG ctr;
PPC_REG link;
PPC_REG xer;
PPC_REG ccr;
PPC_REG softe; /* Soft enabled/disabled */
PPC_REG trap; /* Reason for being here */
PPC_REG dar; /* Fault registers */
PPC_REG dsisr;
PPC_REG result; /* Result of a system call */
};

在调试信号时,需要查看的一些重要寄存器包括 GPR、指令指针 (NIP)、机器状态寄存器 (MSR)、Trap、数据地址寄存器 (DAR) 等等。不过并非所有的寄存器都是与所有的信号有关的。在 SIGILL 的情况中,DAR 可能不会提供任何有用的数据,因为这个寄存器在 SIGSEGV 的情况中就被用来存放故障地址。

现在您已经了解了有关信号的背景知识,接下来让我们看一下如何使用信号。下面这个示例程序使用了 SIGTERM 信号。


清单 5. 处理 SIGTERM 的程序


#include <stdio.h>
#include <signal.h>
#include <errno.h>
#include <ucontext.h>

static void myhandler (unsigned int sn , siginfo_t si , struct ucontext *sc)
{
unsigned int mnip;
int i;
printf(" signal number = %d, signal errno = %d, signal code = %d/n",
si.si_signo,si.si_errno,si.si_code);
printf(" senders' pid = %x, sender's uid = %d, /n",si.si_pid,si.si_uid);
}

main()
{
struct sigaction s;
s.sa_flags = SA_SIGINFO;
s.sa_sigaction = (void *)myhandler;
if(sigaction (SIGTERM,&s,(struct sigaction *)NULL)) {
printf("Sigaction returned error = %d/n", errno);
exit(0);
}
while(1);
return 0;
}

上面这个示例程序为 SIGTERM 注册一个信号处理程序,在处理程序的代码中,它打印了发送者进程的 pid 和 uid,并直接忽略这个信号,然后继续执行。下面是这个程序的输出结果:


清单 6. 清单 5 程序的输出结果


> ./fin &
[2] 7375
> ps -ef | grep 7375
maddy 7375 7063 90 16:51 pts/0 00:00:24 ./fin
maddy 7377 7063 0 16:52 pts/0 00:00:00 grep 7375
> kill 7375
signal number = 15, signal errno = 0, signal code = 0
senders' pid = 7063, sender's uid = 1001,
> kill -9 7375
> ps -ef | grep 7375
maddy 7379 7063 0 16:52 pts/0 00:00:00 grep 7375
[2]+ Killed ./fin

这一信号处理数据在某些情况中非常重要。使用这些数据,进程如果在运行过程中接收到一个 SIGTERM 信号,就可以在执行完关键代码(如果已经启动)之后自行终止。这可以通过在信号处理程序代码中设置一个全局标志并在完成关键部分的代码之后检查这个标志来实现。您也可以将发送者的 pid 保存下来,并将其打印到一个输出文件中,从而了解是哪些进程发送的信号。

下面让我们来看一个更重要的例子。考虑一下 SIGILL 信号。SIGILL 是为那些执行非法指令的情况而产生的。它是在特定条件下产生的。例如非法的操作码、非法操作数、特权操作码等等。

清单 7 所示程序就试图执行一个特权操作:


清单 7. 处理 SIGILL 的程序


#include <stdio.h>
#include <signal.h>
#include <errno.h>
#include <ucontext.h>

static void myhandler (unsigned int sn , siginfo_t si ,/
struct ucontext *sc)
{
unsigned int mnip;
int i,j;

printf(" Signal number = %d, Signal errno = %d/n"
,si.si_signo,si.si_errno);
switch(si.si_code)
{
case 1: printf(" SI code = %d (Illegal opcode)/n",si.si_code);
break;
case 2: printf(" SI code = %d (Illegal operand)/n",si.si_code);
break;
case 3: printf(" SI code = %d (Illegal addressing mode)/n",
si.si_code);
break;
case 4: printf(" SI code = %d (Illegal trap)/n",si.si_code);
break;
case 5: printf(" SI code = %d (Privileged opcode)/n",si.si_code);
break;
case 6: printf(" SI code = %d (Privileged register)/n",si.si_code);
break;
case 7: printf(" SI code = %d (Coprocessor error)/n",si.si_code);
break;
case 8: printf(" SI code = %d (Internal stack error)/n",si.si_code);
break;
default: printf("SI code = %d (Unknown SI Code)/n",si.si_code);
break;
}

printf(" Machine State Register = %x /n",
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->msr));
printf(" Link register pointing to location = 0x%x, /
Opcode at the location = 0x%x /n",
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->link),
*(unsigned int *) /
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->link));
for(i=20,j=5;i>0;i-=4,j--)
printf(" Op-Code [nip - %d] = 0x%x at address = 0x%x /n"
,j,*(unsigned int *)(si.si_addr - i)
,(si.si_addr - i) );
printf(" Failed Op-code = 0x%x at address = 0x%x /n",
*(unsigned int*)(si.si_addr), (si.si_addr));
printf(" Op-Code [nip + 1] = 0x%x at address = 0x%x /n",
*(unsigned int *)(si.si_addr + 4), (si.si_addr + 4));
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip) += 4;
}

my()
{
__asm__ volatile ("add 4,5,6 /n/t":);
__asm__ volatile ("add 7,8,9 /n/t":);
__asm__ volatile ("mfmsr 3 /n/t":);
__asm__ volatile ("add 4,5,6 /n/t":);
__asm__ volatile ("add 7,8,9 /n/t":);
}

main()
{
struct sigaction s;

s.sa_flags = SA_SIGINFO;
s.sa_sigaction = (void *)myhandler;
if(sigaction (SIGILL,&s,(struct sigaction *)NULL)) {
printf("Sigaction returned error = %d/n", errno);
exit(0);
}
my();
return 0;
}

有些指令不允许在用户空间中执行,例如试图访问 MSR 和 SRR0/SRR1(保存恢复寄存器)的指令。要执行这些指令,您必须切换到内核上下文。

清单 7 中的程序会试图执行一条将一个值从 MSR 移动到 GPR 的指令。读取 MSR 就是特权操作,因此就会产生一个 SIGILL 信号。输出结果如清单 8 所示:


清单 8. 清单 7 的输出结果


> ./mysigill
Signal number = 4, Signal errno = 0
SI code = 5 (Privileged opcode)
Machine State Register = 4d032
Link register pointing to location = 0x10000830, Opcode at the location = 0x38000000
Op-Code [nip - 5] = 0x9421ffe0 at address = 0x10000788
Op-Code [nip - 4] = 0x93e1001c at address = 0x1000078c
Op-Code [nip - 3] = 0x7c3f0b78 at address = 0x10000790
Op-Code [nip - 2] = 0x7c853214 at address = 0x10000794
Op-Code [nip - 1] = 0x7ce84a14 at address = 0x10000798
Failed Op-code = 0x7c6000a6 at address = 0x1000079c
Op-Code [nip + 1] = 0x7c853214 at address = 0x100007a0

正如我们期望的一样,这个程序会接收到一个 SIGILL(信号编号为 4)信号,其 si 代码为 5,这是在用户空间的程序执行特权操作时产生的。

正如清单 8 所示,这个程序输出了 6 条连续的指令,包括出错的那条指令。要查看代码中是哪条指令出错了,可以使用 objdump 命令输出可执行文件的代码,该命令会列出编译器所生成的指令。(从 objdump 的帮助页中可获得关于此工具的更多信息。)


清单 9. objdump 命令


> objdump -S mysigill >> /tmp/mdmp

/tmp/mdmp 文件中保存了可执行文件 mysigill 执行 objdump 之后的结果。首先,查找出错的操作码/指令。在本例中,出错的操作码是 7c6000a6。


清单 10. 对象 dump 文件


<Search the output for the opcode "7c6000a6">

10000788 <my>:
10000788: 94 21 ff e0 stwu r1,-32(r1)
1000078c: 93 e1 00 1c stw r31,28(r1)
10000790: 7c 3f 0b 78 mr r31,r1
10000794: 7c 85 32 14 add r4,r5,r6
10000798: 7c e8 4a 14 add r7,r8,r9
1000079c: 7c 60 00 a6 mfmsr r3 <== Bingo!!!
100007a0: 7c 85 32 14 add r4,r5,r6
100007a4: 7c e8 4a 14 add r7,r8,r9
100007a8: 7c 03 03 78 mr r3,r0
100007ac: 81 61 00 00 lwz r11,0(r1)
100007b0: 83 eb ff fc lwz r31,-4(r11)
100007b4: 7d 61 5b 78 mr r1,r11
100007b8: 4e 80 00 20 blr

如果这个程序中一条操作码出现了多次,请尝试在 dump 文件中寻找处理程序代码所打印的序列。这让您可以将程序中导致执行或生成这条指令的函数隔离开来。当使用 -g 选项来编译源代码时,dump 文件通常会包含有一行行的源代码以及对应的实现指令。

下面让我们来看一种程序员经常会遇到的、由信号引起的错误的调试方法。SIGSEGV 信号是在特定的条件下生成的,例如当进程试图在一个尚未分配的内存区域中加载或保存数据时、或程序试图对只读内存进行写操作时都会产生这个信号。清单 11 所示程序是一个段错误的典型例子。


清单 11. 处理 SIGSEGV 的程序


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <errno.h>
#include <ucontext.h>

static void seghandler (unsigned int sn , siginfo_t si , /
struct ucontext *sc)
{
unsigned int mnip;
int i;

mnip=*(unsigned int *)(((struct pt_regs *) /
((&(sc->uc_mcontext))->regs))->nip);
printf(" Signal number = %d, Signal errno = %d/n",
si.si_signo,si.si_errno);
switch(si.si_code)
{
case 1: printf(" SI code = %d (Address not mapped to object)/n",
si.si_code);
break;
case 2: printf(" SI code = %d (Invalid permissions for /
mapped object)/n",si.si_code);
break;
default: printf("SI code = %d (Unknown SI Code)/n",si.si_code);
break;
}
printf(" Intruction pointer = %x /n",mnip);
printf(" Fault addr = 0x%x /n",si.si_addr);
printf(" dar = 0x%x /n",
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->dar));
printf(" trap = 0x%x /n",
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->trap));
printf(" Op-Code [nip - 4] = 0x%x at address = 0x%x /n",
*(unsigned int *)/
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip-4),
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip-4) );
printf(" Failed Op-code = 0x%x at address = 0x%x /n",
*(unsigned int *)/
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip),
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip));
printf(" Op-Code [nip + 1] = 0x%x at address = 0x%x /n",
*(unsigned int *) /
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip+4),
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip + 4));
printf("***GPR values are the time of fault*** /n");
for (i=0;i<11;i++)
printf(" Gpr[%d] = 0x%x /n",i, /
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->gpr[i]));
(((struct pt_regs *)((&(sc->uc_mcontext))->regs))->nip)+=4;
}

main()
{
struct sigaction m;
char *p,*q, arr[]="Ma";
q=arr;

m.sa_flags = SA_SIGINFO;
m.sa_sigaction = (void *)seghandler;
sigaction (SIGSEGV,&m,(struct sigaction *)NULL);
*p++ = *q++;
return 0;
}

这个程序试图在一个尚未分配的内存中保存数据:它执行一个字符串复制操作,将 arr 中的数据复制到 p 变量中。这样做的结果是产生一个 SEGSEGV 信号,如清单 12 所示:


清单 12. 清单 11 的输出结果


> ./sigsegv
Signal number = 11, Signal errno = 0
SI code = 1 (Address not mapped to object)
Intruction pointer = 98080000
Fault addr = 0x0
dar = 0x0
trap = 0x300
Op-Code [nip - 4] = 0x88090000 at address = 0x10000760
Failed Op-code = 0x98080000 at address = 0x10000764
Op-Code [nip + 1] = 0x396b0001 at address = 0x10000768
***GPR values are the time of fault***
Gpr[0] = 0x4d
Gpr[1] = 0xffffe070
Gpr[2] = 0x4001ee20
Gpr[3] = 0x0
Gpr[4] = 0xffffdf30
Gpr[5] = 0x0
Gpr[6] = 0xffffe110
Gpr[7] = 0xffffe114
Gpr[8] = 0x0
Gpr[9] = 0xffffe120
Gpr[10] = 0x0

这个示例程序还输出了当时通用寄存器的值。调试这个问题的一种方法是对这个可执行程序执行 objdump 命令,并将其结果保存到一个文件中;然后查找出错的指令(在本例中,出错的操作码是 98080000)。


清单 13. 对象 dump 文件


<Search output for the opcode "98080000">

10000744: 48 01 07 21 bl 10010e64 <__bss_start+0x48>
*p++ = *q++;
10000748: 38 df 00 a0 addi r6,r31,160
1000074c: 81 46 00 00 lwz r10,0(r6)
10000750: 38 ff 00 a4 addi r7,r31,164
10000754: 81 67 00 00 lwz r11,0(r7)
10000758: 7d 48 53 78 mr r8,r10
1000075c: 7d 69 5b 78 mr r9,r11
10000760: 88 09 00 00 lbz r0,0(r9)
10000764: 98 08 00 00 stb r0,0(r8) <==Failed instruction
10000768: 39 6b 00 01 addi r11,r11,1
1000076c: 91 67 00 00 stw r11,0(r7)
10000770: 39 4a 00 01 addi r10,r10,1
10000774: 91 46 00 00 stw r10,0(r6)
return 0;
10000778: 38 00 00 00 li r0,0
}

由于这个程序是使用 -g 选项编译的,因此对象 dump 文件中就包含了源代码。此处出错的指令是 stb。这个进程试图将一个字节从寄存器 r0 保存到一个寄存器 r8 所指向的内存地址中,但是寄存器 r8 的值为 0x0 —— 这可以从处理程序代码所输出的 gpr 的值中看出来,这就是产生信号的根源。


将信号用作 Linux 调试工具



参考资料

学习


获得产品和技术

  • 下载 KGDB,这是 Linux 内核的一个源代码级的调试器,可以与 GDB 一起使用对内核进行调试。
  • 定购免费的 SEK for Linux,共有两张 DVD,包括最新的 IBM for Linux 的试用版软件以及 DB2®、Lotus®、Rational®、Tivoli® 和 WebSphere®。
  • 在您的下一个开发项目中使用 IBM 试用版软件,可以从 developerWorks 上直接下载。


讨论

  • 通过参与 developerWorks blogs 来参与 developerWorks 社区。