Linux 操作系统中异常处理函数的实现

2023-05-15 10:22:28 digiproto
每一个Linux C/C++开发人员都碰到过由内存访问引发的段错误 Segmentation fault (core dumped) 。通常这种错误是由于访问了错误的内存地址、内存不足、错误的类型访问等问题引起的。开发人员要找到引发段错误的 bug,就需要使用调试工具或 Core dump 核心转储文件来定位问题。


01

使用 Core dump 核心转储调试程序



1.1 Core dump 简介


当应用程序在操作系统中加载运行时,操作系统将为这个应用程序分配相应的内存,将程序代码放入.text段,将已初始化的数据放入 .data 段,将未初始化的数据放入 .bss 段。同时操作系统会给 stack 和 heap 映射相应的内存页。我们可以通过配置在程序崩溃时将这些内存信息 dump 到一个文件中。本质来讲Core dump 文件就是在程序崩溃时产生的内存快照文件。


Core dump 信息内容列表:


  • data section 初始化数据段

  • bss 未初始化数据段

  • heap 堆空间

  • stack 栈空间

  • registers values 寄存器值

  • stack trace 栈轨迹

  • 其他信息



1.2 配置ulimit


Core dump 文件通常比较大,如果要在 Linux 操作系统中生成 Core dump 文件需要先确认 ulimit 设置状态。


运行如下命令:


ulimit -c unlimited


运行以上命令则不限制 Core dump 文件的大小。 现在当我们的应用程序崩溃后即可在指定目录中找到相应的 Core dump 文件。我们可以使用 GDB 加载应用程序和 Core dump 文件来调试应用程序,找到相应的崩溃点。


lee@digiproto:~/Project/handler$ ./a.out 
Segmentation fault (core dumped)
lee@digiproto:~/Project/handler$ ls -lh
total 264K
-rwxrwxr-x 1 lee lee 8.5K 6月  11 22:27 a.out
-rw------- 1 lee lee 244K 6月  11 22:30 core
-rw-rw-r-- 1 lee lee  118 6月  11 22:27 fault.c
-rw-rw-r-- 1 lee lee  770 6月  11 21:36 handler.md
lee@digiproto:~/Project/handler$



1.3 使用GDB加载 core dump 文件


当我们运行一个可以引发 Segmentation fault 的应用程序时,可以看到在当前目录中生成了关于此应用文件的core文件。 此时可以加载应用程序和 Core dump 文件到 GDB 中调试,查看崩溃信息。


gdb ./a.out ./core


GDB 调试信息显示程序出错位置为源程序第7行访问只读内存导致的段错误。


lee@digiproto:~/Project/handler$ gdb ./a.out ./core 
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
Copyright (C) 2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:.
Find the GDB manual and other documentation resources online at:.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ./a.out...done.  warning: exec file is newer than core file.
[New LWP 51917]
Core was generated by `./a.out'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x000000000040053a in main () at fault.c:7
7                       *c = 'H';
(gdb) l
2
3    int main()
4    {
5         char * c = "hello, world\n";
6
7         *c = 'H';
8
9         printf("c = %s\n", c);
10
11        return 0;
(gdb)


如想了解更多关于 Core dump 的详细调试手段,可查阅 GDB 相关手册:

https://sourceware.org/gdb/download/onlinedocs/gdb/index.html



1.4 Core dump 缺点


1.4.1、Core dump 文件存储传输问题


如果在程序占用了大量的内存空间时产生了 Segmentation fault ,将会生成一个超大的 Core dump 文件。如果我们的程序是运行在客户的电脑中并且我们想要将这个超大的 Core dump 文件回传给开发人员来分析问题,那么超大 Core dump 文件的传输就是一个很大的问题。另外在一些嵌入式应用场景中通常为了压缩存储空间,节约成本,增强稳定性等因素,其文件系统可能是只读的,无法保存应用程序生成的Core dump文件,或者 flash 空间要小于 RAM 空间,无法为 Core dump 文件提供足够的存储空间。


1.4.2、Core dump 存储信息局限


在检查Core dump 文件时,只能看到与CPU相关的数据。例如当我们将一个硬件映射到虚拟地址时,就不能使用 Core dump 访问这些地址。换句话说,Core dump 只记录CPU相关信息,不记录其他硬件的寄存器信息。


02

使用异常处理函数调试程序


在Linux操作系统中为了解决上述问题,我们可以编写异常处理函数来捕获这些由程序错误引起的信号量。



2.1 异常处理函数


如下这段程序设置了 SIGSEGV 的信号处理函数,当程序运行过程中产生段错误时,此信号处理函数即可捕获操作系统发给应用程序的 SIGSEGV 信号。同理我们还可以通过设置信号处理函数来捕获更多的信号量,例如SIGFPE (浮点错误,除零错误), SIGILL (非法指令) 以及 SIGBUS( 总线错误)等等。


#include #include #include #include void signal_handler(int signo, siginfo_t *info, void *extra)  {     printf("Signal %d received\n", signo);     abort();
}  
int main()
{     struct sigaction action;     action.sa_flags = SA_SIGINFO;     action.sa_sigaction = signal_handler;     char *c = "hello, world\n";      if (sigaction(SIGSEGV, &action, NULL) == -1)      {            perror("sigsegv: sigaction");         _exit(1);     }         *c = 'H';     printf("%s", c);      return 0;
}


程序运行结果如下:



lee@digiproto:~/Project/handler/signal_handler$ ./a.out 
Signal 11 received
Aborted (core dumped)
lee@digiproto:~/Project/handler/signal_handler$


在此示例程序中,只打印一条消息输出信号量编号并调用abort,但在这里可以输出一些更重要的内容,例如可以输出映射在内存中的硬件设备寄存器信息。


void signal_handler(int signo, siginfo_t *info, void *extra) 
{
    printf("Signal %d received\n", signo);
    for(int i = 0; i < hardware_map_size ; i++)
    {
        printf("hardware regs[%d] = 0x%X\n", i, hwmap[i]);
    }
    abort();
}


另外可以使用信号处理函数参数中的 siginfo_t 来获取更多的信息,例如此处可以输出 SIGSEGV 的产生内存地址。


printf("siginfo address = 0x%016lX\n",(uint64_t)info->si_addr);


简单修改程序后运行结果如下:



lee@digiproto:~/Project/handler/signal_handler$ ./a.out 
c = 0x0000000000400874
Signal 11 received
siginfo address = 0x0000000000400874
Aborted (core dumped)


此处可以看到输出变量 c 中存储的指针与异常产生后 siginfo_t 中的异常内存地址相同。



2.2 context 异常的上下文信息


void signal_handler(int signo, siginfo_t *info, void *extra)


在此异常处理函数中更重要的是第三个参数,虽然定义为void * 但其真实的类型为 ucontext_t,之所以在此处定义为void * 是因为在不同的操作系统,不同的体系结构中有不同的ucontext_t。关于 ucontext_t 的定义可以在当前运行环境中的头文件中找到。


例如在x86_64中我们可以在 context 里找出当前程序的崩溃位置:

( 注意:这部分代码需要增加GNU对于ucontext结构体的头文件。)


#define __USE_GNU
#include void signal_handler(int signo, siginfo_t *info, void *extra)  {     ucontext_t *context = (ucontext_t *) extra;     uint64_t rip;     printf("Signal %d received\n", signo);     printf("siginfo address = 0x%016lX\n",(uint64_t)info->si_addr);     rip = context->uc_mcontext.gregs[REG_RIP];     printf("address = 0x%016lX\n",rip);     abort();
}


运行结果如下:


lee@digiproto:~/Project/handler/signal_handler$ ./a.out 
c = 0x00000000004008B8
Signal 11 received
siginfo address = 0x00000000004008B8
address = 0x00000000004007B2
Aborted (core dumped)


从如上运行结果可以得出程序崩溃地址在0x4007B2。同时我们可以使用 objdump 生成反汇编文件查看引发崩溃的指令。


objdump -d a.out
...
 4007b2:       c6 00 48                movb   $0x48,(%rax)


使用gdb list * 0x4007B2 也可定位程序异常位置 signal.c 34行:


(gdb) list * 0x4007B2
0x4007b2 is in main (signal.c:34).
29{
30    perror("sigsegv: sigaction");
31    _exit(1);
32}
33
34    *c = 'H';
35    printf("%s", c);
36    return 0;
37}
38


使用上述方法可以在程序捕获到异常时输出CPU中的寄存器值,同时也可修改寄存器中的值。但是此功能由于平台差异性,在不同的平台需要有不同的处理方法。 例如在ARM平台可以通过p->uc_mcontext.arm_pc; 来访问ARM CPU 中的PC值。用此方法可以在ARM平台访问arm_r0,arm_sp 等寄存器。



2.3异常处理函数与 Core dump 共存


在异常处理函数中之所以没有使用 return,是因为当程序使用 return 时会一次又一次的引发异常,所以需要调用abort 或者 exit 来退出程序。调用 abort 函数会产生一个 Core dump 文件,但此时产生的 Core dump 文件是异常处理函数中的内存快照,并没有捕获真实异常产生的内存快照。那么我们如何让异常处理函数和 Core dump 文件共存?


这里可以通过注销自己实现的信号处理函数,恢复系统默认异常处理函数,并且return。注意此时并未消除此异常,所以程序会立即产生一个异常,这时会调用系统默认异常处理函数,并且可以生成一个程序异常的 Core dump 文件。


在异常处理函数中执行如下程序,即可注销异常处理函数,并恢复系统默认异常处理:

    struct sigaction action;
    action.sa_flags = SA_SIGINFO;
    action.sa_sigaction = SIG_DFL;
    sigaction(SIGSEGV, &action, NULL);


运行结果如下,可以看到我们生成的 Core dump 文件已经由 Aborted (core dumped) 变成了 Segmentation fault (core dumped)。这样就可以在使用异常处理函数的同时,生成 Core dump 文件了。


lee@digiproto:~/Project/handler/signal_handler$ ./a.out 
c = 0x00000000004008E8
Signal 11 received
siginfo address = 0x00000000004008E8
address = 0x00000000004007DF
Segmentation fault (core dumped)



2.4 将异常处理函数作为动态库注入程序


通过上面的实验我们可以看到异常处理函数具有较好的调试优势,这里介绍一种新的方法可以使用动态库的方式,已经实现好的错误处理函数打包到一个共享库中。每次运行程序时可以使用 LD_PRELOAD 将这个调试用的共享库直接注入到进程中用于调试。


构建并运行如下程序可以引发一个段错误:


#include #include #include #include int main()
{     char *c = "hello, world\n";     printf("c = 0x%016lX\n", (uint64_t)c);     *c = 'H';     printf("%s", c);     return 0;
}


直接运行结果:


lee@digiproto:~/Project/handler/signal_handler$ ./a.out 
c = 0x00000000004005F4
Segmentation fault (core dumped)


使用注入动态库的运行结果如下:


lee@digiproto:~/Project/handler/signal_handler$ LD_PRELOAD=./libsig_handler.so ./a.out 
c = 0x00000000004005F4
Signal 11 received
siginfo address= 0x00000000004005F4
pc address = 0x0000000000400550
Segmentation fault (core dumped))


可以看到使用动态库注入的方式运行可以非常方便的获取异常信息及 Core dump 文件,无需再次实现异常处理函数。


异常处理动态库源码及编译方法如下:


#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include #include #include #include #include #include #include #include #include #include static void __attribute__ ((constructor)) init_lib(void);  void set_signal_handler(void (*handler)(int,siginfo_t *,void *))
{     struct sigaction action;     action.sa_flags = SA_SIGINFO;     action.sa_sigaction = handler;      if (sigaction(SIGFPE, &action, NULL) == -1) {         perror("sigusr: sigaction");         _exit(1);     }     if (sigaction(SIGSEGV, &action, NULL) == -1) {         perror("sigusr: sigaction");         _exit(1);     }     if (sigaction(SIGILL, &action, NULL) == -1) {         perror("sigusr: sigaction");         _exit(1);     }     if (sigaction(SIGBUS, &action, NULL) == -1) {         perror("sigusr: sigaction");         _exit(1);     }  }  void signal_handler(int signo, siginfo_t *info, void *extra)  {     ucontext_t * context = (ucontext_t *) extra;     int rip;     printf("Signal %d received\n", signo);     printf("siginfo address= 0x%016lX\n", (uint64_t)info->si_addr);      rip = context->uc_mcontext.gregs[REG_RIP];     printf("pc address = 0x%016lX\n", (uint64_t)rip);     set_signal_handler(SIG_DFL);  }  void init_lib()
{     set_signal_handler(signal_handler);
}


动态库编译构建命令:


gcc -shared -o libsig_handler.so ./signal_lib.c -fPIC


-shared 意为共享库,-fPIC 意为生成位置无关代码,生成二进制中相对地址,这两个参数是生成动态库比较的参数。



2.5 产生异常时让程序继续运行


以上内容都是描述当程序产生异常时如何捕获异常并输出更多信息辅助程序员调试程序,在某些情况下我们也可以利用异常处理函数直接修复此异常让程序继续运行。


1. 在错误处理函数中继续运行,并生成可读性较强的错误报告存储在本地磁盘或直接发送至开发者邮箱中。

2. 使用exec等系统调用再次启动程序。

3. 改变 context 上下文信息中的PC指针指向另外一段程序。


关键代码展示:


void crash_info(void)
{
    printf("error message\n");
}
...

p->uc_mcontext.gregs[REG_RIP] = (uint64_t)crash_info;


4. 跳过错误指令,继续执行。(注意:此方法高度依赖平台,需要知道指令跳过的指令长度,否则会产生指令异常)


关键代码展示:


p->uc_mcontext.gregs[REG_RIP] += fault_instruction_len;


参考链接:

1.https://en.wikipedia.org/wiki/Core_dump

1.https://man7.org/linux/man-pages/man5/core.5.html

首页
产品
新闻
联系