程序在异常终止时,会触发对应的错误信号,此时操作系统会将程序的内存态内容包括程序内存、寄存器状态、调用栈等信息写入一个core文件。异常终止原因根据对应信号主要分为如下几种:
1、段错误,触发信号 SIGSEGV
包括访问空指针、数组越界、栈溢出等;
2、非法指令,触发信号SIGILL
比如把一些随机数据当成指令执行:
void (*func)() = ptr; // 将内存空间作为函数指针
func(); // 触发 SIGILL
3、浮点异常,触发信号 SIGFPE
也就是除0操作;
4、非法内存访问,触发信号SIGMEM
如访问已释放的内存;
5、总线错误,触发信号 SIGBUS
比如收到异常的网络包等。
如何开启coredump
1、开启coredump:ulimit -c unlimited;
2、对于某些设置了suid的程序如网卡抓包程序,在需要开启coredump时,需要修改 /etc/sysctl.conf 文件来启用。
排查问题时,如果有core文件,使用gdb分析;否则使用dmesg分析内核日志。分析问题时,首先确认是否是OOM导致进程消失。
grep xxx /var/log/messages 获取到程序crash的地址,然后使用ldd查看外部依赖库地址基址,使用
objdump -d /lib64/libc-2.12.so --start-address=0x3ab9a7500 | head -n2000 | grep 75f62
查找crash的系统调用。
在排查问题时,coredump通常需要配合持久化日志综合分析。
GDB调试
gdb进入coredump堆栈后,bt可以展示栈帧,默认是当前栈帧也就是0栈帧。要查看对应栈帧的变量情况,可以使用f+栈帧号切换。list func可以查看对应函数的反编译源码,print p、print &p可以打印对应变量的值。
frame +数字可以切换函数帧,disassemble可以查看汇编代码。
使用print可以查看寄存器状态、函数的栈帧空间、形参的位置和值是否有问题。
info signals查看信号是否会引起段错误,info registers 命令查看寄存器状态、函数调用时的栈空间。
多线程场景,需要切换到具体的线程查看堆栈进行分析。查看所有线程:info threads
切换到对应线程,如线程2:
thread 2 //这里使用的是gdb的id,不是pid
查看当前状态:bt/where
info mutex:查看当前程序中的互斥锁信息。
dis可以查看地址的汇编指令,如:
dis -l c000000000255900
0xc000000000255900 <main+0>: push %rbp
0xc000000000255901 <main+1>: mov %rsp,%rbp
0xc000000000255904 <main+4>: sub $0x10,%rsp
...
rd可以查看内存内容,如:
rd 0x7fffffffe000 32
这将从0x7fffffffe000地址开始,读取 32 个字节的内存内容。不同版本可能不一样,可以使用x命令代替,这个命令用来分析函数比较方便,打印函数堆栈内容,第一个参数一般为函数返回地址,从第二个参数开始为函数的入参:
比如分析ipv4报文,查找4500开头的内容,找到对应的地址,然后使用iphdr <栈地址>可以打印报文内容,根据偏移查找udphdr、tcphdr内容。
GDB附着命令
遇到死锁之类的,可以使用非调试手段进行定位。
附加到正在运行的线程:gdb -p pid
附加到进程:gdb attach pid
使用gdb break设置条件断点,可以抓取偶现bug。
Gdb还可以用于调试程序,p打点,watch可以设置观察点,c继续执行:
#rbp是当前函数调用栈中的基指针寄存器,向下偏移8字节指向存放金丝雀值的地方
(gdb) p $rbp - 0x8
$8 = (void *) 0x7fffffff04a8
#这里对canary在栈中存放的地址打数据断点
(gdb) watch *0x7fffffff04a8
Hardware watchpoint 2: *0x7fffffffe4a8 #触发到条件断点
(gdb) c
Continuing.
经验:大型工程多个.so会依赖相同的开源头文件,如果不能保证每个.so各自依赖的头文件版本一致,就可能出现栈溢出踩内存问题。
丢日志问题
可以把日志写入到一个mmap打开的文件中(mmap不支持调整文件大小,需要预先分配好空间),如果进程崩溃了系统会自动把 mmap 后的内存写入到文件里,不会丢失。
内存延迟分配
用户调用API进行内存分配的时候,操作系统并不会直接分配给用户这么多内存,而是直到用户真的访问了申请的page时产生一个page fault,然后将这个page真的分配给用户,并重新执行产生page fault的语句。所以即使使用了new,在真正使用之前是没有被真正的分配虚拟内存。
所以为了加速,可以提前初始化,或者使用memset对每个页读取一个字节,使其在内存中cache。
使用htop -p可以查看进程内存占用等情况。
内存问题分析
静态检测:
gcc使用-fstack-usage选项,能输出每个函数栈的最大使用量,具体含义:
1、static: 堆栈使用量在编译时是已知的,不依赖于任何运行时条件。
2、dynamic: 堆栈使用量依赖于运行时条件,例如递归调用或基于输入数据的条件分支。
3、bounded: 堆栈使用量虽然依赖于运行时条件,但有一个可预知的上限。
动态检测:
1、使用pmap或查看/proc/pid/maps中的stack。
2、通过注册一个自定义的信号处理函数来拦截 SIGSEGV段错误信号,处理函数会收到一个 siginfo_t 结构体,其中包含错误的地址和寄存器状态等上下文信息,可以判断是否发生了栈溢出。
3、栈缓冲溢出一般主要是数组越界,使用gcc的-fstack-protector选项保护栈,可触发检测函数。如果canary值被修改,程序会认为发生了栈溢出攻击,通常会立即终止,例如通过调用 __stack_chk_fail() 函数。这个也叫做“金丝雀分析法”。
另外,使用STL容器可以减少大部分栈缓冲溢出问题。
address sanitizer工具
开address sanitizer可以进行内存错误检测,且支持多线程环境,对程序性能影响夜宵,避免coredump。
使用步骤
编译时添加-fsanitize=address选项,插入内存错误检测的相关代码。
-fno-omit-frame-pointer选项保留堆栈指针。
-g选项添加调试符号和源码行号。
-O1或者更高的优化级别。
打包并链接 libasan.so。示例:
gcc -fsanitize=address -fno-omit-frame-pointer -O1 -g xx.cc -o xx
两个案例
1、netfilter回调
netfilter可以自定义增加hook点,而这些钩子函数可能修改skb报文,导致数据或者程序异常。Netfilter的五个钩子点,分别为NF_INET_PRE_ROUTING、NF_INET_LOCAL_IN、NF_INET_FORWARD、NF_INET_LOCAL_OUT、NF_INET_POST_ROUTING。
比如,如果是经过NF_INET_LOCAL_IN之后数据异常了,那么查找挂载在NF_INET_LOCAL_IN上的钩子:
查找处理 sk_buff (skb) 的 NF_INET_LOCAL_IN 钩子的流程如下:
1、使用struct命令查看sk_buff结构体中的dev字段:
#表示 skb包 关联的网络设备
struct sk_buff.dev <sk_buff对象地址>
dev = 0xffff8881171a8000
2、从net_device结构体中获取net字段:
#显示网络设备关联的网络上下文 (init_net),即当前的网络命名空间。
struct net_device.nd_net <net_device结构体对象地址>
nd_net = { net = 0xffffffff8322dc80 <init_net> }
3、从net结构体中获取nf.hooks_ipv4:
nf.hooks_ipv4 = {0xfff88810ba7fe00, 0xfff888810ba7ff830, 0x0, 0xffff88810ba7fe80, 0x0}
hooks_ipv4对象表示IPv4的NF (netfilter)钩子数组,五个地址对应五个回调钩子函数地址,其中NF_INET_LOCAL_IN的位置是第一个元素 (0xfff88810ba7fe00)。
4、获取 NF_INET_LOCAL_IN 钩子的条目:
nf_hook_entries显示了钩子条目,num_hook_entries = 1 表示当前有一个钩子条目,hooks = 0xffff88810ba7ff88 表示钩子条目的地址。
5、查看具体的钩子条目:
nf_hook_entry显示了钩子条目的内容,其中 hook = 0xffffffffc0e6b260 是实际的处理函数地址,然后查看处理函数的具体逻辑。
2、包版本管理问题
程序依赖了third.lib和data.h文件,data.h包含Data结构体的实现,third.lib里也依赖data.h文件。当版本更新,如Data结构体里面增加了一个字段,而data.lib因为种种原因如包管理不规范而没有重新编译和更新时,可能会引起 core dump。
为了避免这种情况,可以使用PImpl解决。PImpl 模式用于将接口和实现分离,避免直接暴露结构体的实现,进而提高了ABI二进制接口兼容性。举例:
Data.h:
class DataImpl; // 前向声明
class Data {
public:
Data();
~Data();
private:
std::unique_ptr<DataImpl> pImpl; // 通过指针持有实现
};
data.cc:
#include "data.h"
struct DataImpl
{
int a;
double b;
std::string c;
};
...
PImpl 模式通过将实现细节封装在私有的实现类中,使内存布局变化与代码解耦,可以随意改变DataImpl的内存布局和实现细节,而不需要担心外部代码受到影响,因为外部代码只会与Data指针交互,而不是直接访问DataImpl的成员。