整理程序调试的一些踩坑,经验整理,注意事项等。
C++后端开发的踩坑整理
https://www.jianshu.com/p/b67222570785
-
assert断言
在程序中插入assert 断言可以让程序出现意料之外的结果的时候,立刻宕机并输出coredump文件。在开发的时候,这是一个非常有用的机制帮助定位bug。但是assert在使用的时候,非常容易掉坑。比如在debug和release 的模式下面,assert的行为不一致。在release的模式下,assert会被编译器去掉。实例如下:
#include <stdio.h>
#include <assert.h>
int main()
{
int val = 0;
assert(val == 1);
printf("assert after \n");
return 0;
}
# debug下编译
[linux@ test]~$g++ test_assert.cpp
[linux@ test]~$./a.out
# a.out: test_assert.cpp:7: int main(): Assertion `val == 1' failed. Aborted (core dumped)
# release下编译
[linux@ test]~$g++ test_assert.cpp -D NDEBUG
[linux@ test]~$./a.out
assert after
所以,在使用assert
的时候,千万不要写如下的代码。do_something的代码会在release的模式下面被去掉。
for (i = 0; i < 100; ++i)
assert (do_something () == 0); // release 版本就不会执行do_something()的逻辑了
另外,根据assert
的这个特点,往往都会在assert上面包一层宏。比如这样:
// 在debug下面打印backtrace, 然后asert掉,生成coredump文件
// 在release下面打印backtrace,assert这句被跳过,return预设的错误码返回值,进程继续运行
#define my_assert(sentence, ret_val) \
printf("setence %s \n", #sentence);\
printf("backtrace %s \n", stack_trace());\
assert(sentence);\
return ret_val;\
int func()
{
int val = 0;
my_assert(val == 1, -1);
}
int main()
{
func();
printf("===== assert after ====\n");
printf("dosomething...\n");
return 0;
}
$g++ test_assert.cpp // debug
$g++ test_assert.cpp -D NDEBUG // release
另外,assert在语义上被认为是正常情况下一定不可能发生的事情,如果出现了那么程序要立刻停下来。对于用户的输入,外部系统的输入或请求,不要进行assert。如果因为外部的错误输入,而引发内部程序的coredump这样的程序的设计非常不合理。
- 日志模块
加log来排查问题是最常见有效的定位bug的思路了,一个靠谱,高效,稳定的日志模块是一个系统最基础的组件了。日志库也有很多的开源的实现,比如glog等 > glog源码分析。
-
断点调试
使用gdb进行调试的一些基本操作,整理在这里使用gdb调试的一些基本操作。对于release版本,一般都会使用更高级别的优化或者使用strip掉可执行文件的符号表 降低文件大小。丢掉了这些信息导致对于release版本无法直接gdb。如果需要在release版本gdb的话,可以使用objcopy 生成一个debug信息,在gdb调试的时候,先加载符号信息file xxx.debug
,然后再进行调试。# strip 符号表降低二进制的大小 // makefile APP_EXE_SYMBOLS=release_debug APP_EXE=release_exe debug: g++ test_proc.cpp -g -o debug_exe release: g++ -o3 test_proc.cpp -D _NDEBUG -o release_exe clean: rm debug_exe release_exe strip: cp $(APP_EXE) $(APP_EXE)_bak objcopy --only-keep-debug $(APP_EXE) $(APP_EXE_SYMBOLS) strip --strip-debug --strip-unneeded $(APP_EXE) objcopy --add-gnu-debuglink=$(APP_EXE_SYMBOLS) $(APP_EXE) // https://docs.oracle.com/cd/E19205-01/821-2506/6nndsiuq2/index.html # gdb调试操作 gdb file xxx.debug # 加载带有符号表的debug文件 attach pid # attach 到目标进程调试
- 内存泄露定位
如何定位内存泄露是一个比较复杂的问题,往往需要发挥程序员的聪明才智,尝试各种思路,在众多蛛丝马迹中找到问题的根源。
一般来说:- 发现内存泄露,比如收到机器的内存告警,oom发生,或者其他监控日志触发告警。
- 从内存泄露的机器上定位到具体哪个进程发生了泄露。
a) top看看有没有内存占用异常高的进程
b) oom被kill掉的进程并不一定是内存泄露的进程,虽然大概率是。
c) 最好每个进程加上监控日志,每隔一段时间把自己的meminfo打印出来,这样就可以查日志,看到每个进程内存的变化。持续走高的进程就是问题所在了。 - valgrind 挂在可疑进程上,使用工具跑一下。(如果是一个小项目或者是简单的工具类程序,那么发现问题的概率是比较高的,如果一无所获,那就继续排查)
- 如果不能发现问题,接下来查看内存池日志,寻找各种对象的分配的数量和最大使用数量。如果运气好找到了泄露的对象,根据泄露的对象,进一步缩小范围,找到分配对象的函数。(一般来说可以在分配对象的信息里面带上申请对象的
__FILE__
和__FUNCTION__
这样就可以轻松找到对象是被从哪里被分配出来的) - 如果还没找到,那么可以
LD_PRELOAD
动态库去hook malloc函数,或者grep new/malloc等关键字,或者strace 进程
看看有没有申请内存异常系统调用等。address sanitizer
工具(如果gcc版本支持的话)也可以挂上去跑跑看,这个对性能的影响比较小。
定位内存泄露,需要平时在自己的技能工具箱里面积累各种工具和解决思路。整理一下我见过的一些好的工具,传送链接:
C++不用工具,如何检测内存泄漏?
基于LD_PRELOAD的动态库函数hook
一个进程如何获得自己占用的内存信息
另外,想补充的是前一段时候看到一个很有趣的思路,通过给对象计数的方式来间接定位内存泄露。这个要求计数的逻辑要设计的尽可能简洁,对原有的业务逻辑侵入的越少越好。
// counter.h
#ifndef __COUNTER_H__
#define __COUNTER_H__
#include <string>
class COUNTER_DATA
{
public:
COUNTER_DATA(const char * _name):name(_name){}
~COUNTER_DATA() {}
void inc();
void dec();
void show();
private:
int cur;
int max;
std::string name;
};
template<typename T>
class COUNTER
{
public:
COUNTER(){data.inc();}
~COUNTER() {data.dec();}
COUNTER(const COUNTER &) {data.inc();}
int show() const {data.show(); return 0;}
private:
static COUNTER_DATA data;
};
template<typename T> COUNTER_DATA
COUNTER<T>::data = COUNTER_DATA(typeid(T).name());
#endif
// counter.cpp
#include <iostream>
#include "counter.h"
using namespace std;
void COUNTER_DATA::show(){
cout << "type : " << name <<" cur : "
<< cur << " max : " << max << endl;
}
void COUNTER_DATA::inc(){
cur++;
if (cur > max) {max = cur;}
}
void COUNTER_DATA::dec(){cur--;}
#include <iostream>
#include <string>
#include "counter.h"
class OBJ1 {
public:
COUNTER<OBJ1> _counter; // 需要计数的对象插入计数桩
};
class OBJ2 {
public:
COUNTER<OBJ2> _counter; // 需要计数的对象插入计数桩
};
int main()
{
OBJ1 a1;
OBJ1 a2;
{
OBJ1 a3;
a3._counter.show();
}
a1._counter.show();
OBJ2 b1;
OBJ2 b2;
b2._counter.show();
return 0;
}
//
$g++ counter.cpp main.cpp
$./a.out
type : 4OBJ1 cur : 3 max : 3
type : 4OBJ1 cur : 2 max : 3
type : 4OBJ2 cur : 2 max : 2
这个利用了模板的机制,每个要统计的类实例化一个模板进行计数。对要统计的对象只需要插入一行代码,非常的简洁。
- cpu高负载定位
和内存泄露的思路差不多,一般来说:- 收到机器cpu告警,确认一下是瞬间cpu出现峰值,还是持续cpu出现峰值。
- 找到机器上跑满cpu的进程
a) 对于持续跑满的情况下,top
看看就知道了
b) 对于瞬间跑满的情况,登录到机器上面的时候,cpu可能已经正常了。每个进程可以加上周期性的打印出自己的cpu负载情况。这样看日志就可以找到问题进程。 - 如果是持续很高,很有可能是死循环,可以
pstack pid
打印出进程的bt或gdb attach上去bt几次,如果都是同一个bt的话,那就是在这个逻辑里面死循环了。 - 如果不是持续很高,那就要开始发动聪明的大脑了。
a) 找到cpu出现异常高的时间段,看看这样时间内发生了什么。比如,是否是收到了大量的外部请求,是否是定时任务大量同时到期,日志里面是否有可疑的日志。
b) 对于线上环境,可以使用预先给关键函数,入口函数,消息驱动函数等插入记录执行时间的统计日志。(比如统计每个rpc的平均请求数/平均处理时间,查询db的请求数/出处理时间,定时器等逻辑)如何关于如何统计函数的执行时间 有了这个日志,对定位问题有极大的帮助。
c) 一般来说上一步就可以定位问题了,或者可以定位问题的大概范围。可以进一步插入统计日志来精确定位。如果还不行,那可以结合业务分析分析,比如这段时间有什么特殊业务等。
d) 定位问题后,可以结合各种性能测试工具比如pert,vtune等工具,针对性的构建测试环境和用例,进一步的验证和复现问题。
- 内存越界定位
变量a内存越界导致把其他变量b中的数据写坏,当变量b被访问的时候,发生不确定的行为,导致程序异常或者coredump掉。遇到这种问题是比较难排查的,因为现场只可以看到变量b中的值是不正常的,但是为什么不正常,是由什么导致的不正常都无法确定。写一个例子,逐步分析一下。
// 一个简单的示例
// global_int_buff 越界写入,把global_ptr_buff写坏。
const int BUFF_LEN = 128;
int global_int_buff[BUFF_LEN];
char * global_ptr_buff[BUFF_LEN];
void overrun()
{
const int OVER_RUN = BUFF_LEN + 10;
for (int i = 0; i < OVER_RUN; i++) // 越界写入
{
global_int_buff[i] = 999;
}
}
void visit()
{
for (int i = 0; i < BUFF_LEN; i++)
{
printf("buff %s, idx %d", global_ptr_buff[i], i); // 访问被写坏的数据,导致程序coredump
}
}
void init()
{
for (int i = 0; i < BUFF_LEN; i++)
{
const int len = 10;
global_ptr_buff[i] = new char[len];
string src_str = "hello";
memcpy(global_ptr_buff[i], src_str.c_str(), src_str.size());
}
}
int main()
{
init();
overrun();
visit();
return 0;
}
这段程序跑起来就会coredump,现场如下:
(gdb) bt
#0 0x00007ffff7240687 in __strlen_avx2 () from /lib64/libc.so.6
#1 0x00007ffff713543f in vfprintf () from /lib64/libc.so.6
#2 0x00007ffff713c22a in printf () from /lib64/libc.so.6
#3 0x0000000000401261 in visit () at overrun.cpp:25
#4 0x0000000000401355 in main () at overrun.cpp:44
(gdb) f 3
#3 0x0000000000401261 in visit () at overrun.cpp:25
25 printf("buff %s, idx %d", global_ptr_buff[i], i);
(gdb) p i
$1 = 0
// 内存被写坏
(gdb) p global_ptr_buff
$2 = {0x3e7000003e7 <error: Cannot access memory at address 0x3e7000003e7>, 0x3e7000003e7 <error: Cannot access memory at address 0x3e7000003e7>,
0x3e7000003e7 <error: Cannot access memory at address 0x3e7000003e7>, 0x3e7000003e7 <error: Cannot access memory at address 0x3e7000003e7>,
0x3e7000003e7 <error: Cannot access memory at address 0x3e7000003e7>, 0x416f50 "hello", 0x416f70 "hello", 0x416f90 "hello", 0x416fb0 "hello", ....}
objdump 找到变量的地址,然后查看这个地址附近其他的变量是什么,哪些有可能会发生越界。
[linux@ workspace]~$objdump -t ./a.out | grep "global_ptr_buff"
00000000004042c0 g O .bss 0000000000000400 global_ptr_buff
[linux@ workspace]~$objdump -t ./a.out | grep "0000000000404"
0000000000404000 l d .got.plt 0000000000000000 .got.plt
0000000000404088 l d .data 0000000000000000 .data
00000000004040a0 l d .bss 0000000000000000 .bss
00000000004040a0 l O .bss 0000000000000001 completed.7294
00000000004046c0 l O .bss 0000000000000001 _ZStL8__ioinit
0000000000404000 l O .got.plt 0000000000000000 _GLOBAL_OFFSET_TABLE_
000000000040408c g .data 0000000000000000 _edata
0000000000404088 w .data 0000000000000000 data_start
0000000000404090 g O .data 0000000000000000 .hidden __TMC_END__
00000000004040c0 g O .bss 0000000000000200 global_int_buff
0000000000404088 g .data 0000000000000000 __data_start
00000000004046c8 g .bss 0000000000000000 _end
000000000040408c g .bss 0000000000000000 __bss_start
00000000004042c0 g O .bss 0000000000000400 global_ptr_buff
找到关键的线索,这两个变量的地址是接近的。gdb上去尝试打印被写坏的内存里面的内容是什么。因为global_ptr_buff是int类型,那我们这里就gdb 打印内存的之后,用int来解释这段内存看看。gdb前40个字节都被写成了999,反向从代码里面验证,发现里面的数值确实是被写坏了。
// 找到关键的线索
00000000004040c0 g O .bss 0000000000000200 global_int_buff
00000000004042c0 g O .bss 0000000000000400 global_ptr_buff
// gdb
(gdb) x/16dw 0x4042c0
0x4042c0 <global_ptr_buff>: 999 999 999 999
0x4042d0 <global_ptr_buff+16>: 999 999 999 999
0x4042e0 <global_ptr_buff+32>: 999 999 4288336 0
0x4042f0 <global_ptr_buff+48>: 4288368 0 4288400 0
定位内存被写坏的情况,是一个很头疼的问题。这个例子很好排查的原因是,程序的规模很小,逻辑简单,而且已经知道了问题是由于越界引发的写坏内存。在实际工作中,越界写坏内存只是内存被写坏的其中的一个原因,多线程环境下被写坏,程序逻辑异常导致的写坏等等都是有可能的,需要从各种蛛丝马迹的线索中找到可能的原因,然后不断的验证 直到找到问题。
- linux排查问题命令小结
对于log文件,结合各种查找命令找到关键字,实际上是日常开发中最常用的调试手段了。- grep命令
grep还可以和正则表达式结合在一起使用:// 从out.log文件中 查找 hello grep hello out.log // 从out.log文件中 查找hello ,忽略大小写 grep -i hello out.log // 递归查找 grep -rn hello out.log // 排除 grep hello out.log | grep -v world // 显示匹配到的周围几行 这个太方便的了 !!!! grep xxxx -C4
// 正则表达 // 日志中查hello 或 world的关键字 grep "hello\|world" out.log // cat out.log uid 10001 age 9 uid 10002 age 10 uid 10003 age 19 uid 10004 age 20 // 匹配一个区间 $ grep 'uid 1000[2-3]' out.log uid 10002 age 10 uid 10003 age 19
- find命令
// 匹配是xml的文件 (会递归查找下去) find . -name "*.xml*" // 忽略大小写 find . -iname "*uid.xml*"
在日常查log找bug的中,有这个两个命令基本就满足我的需求了。可以alias一下,让他们更加好用。
# grep alias
alias ga='grep_all() { grep -n --color -ir $* ./; }; grep_all'
// 查找xml
alias gxml='grep_xml() { find . -iname "*.xml*" | xargs grep -n -ir $* --color}; grep_xml'
// 查找c文件
alias gc='grep_c() {find . -name "*.c" | xargs grep -n -ir $* --color}; grep_c'
// 查找cpp文件和hpp文件
alias gcpp='grep_cpp() {find . -name "*.cpp" | xargs grep -n -ir $* --color}; grep_cpp'
alias ghpp='grep_hpp() {find . -name "*.h" | xargs grep -n -ir $* --color}; grep_hpp'
alias gpp='grep_pp() {find . -name "*.[hc]p*" | xargs grep -n -ir $* --color}; grep_xml'
// 到某个log目录下面 查某个关键字
alias gtask='gtask() {find ~/workspace/task/log -iname "*.log" | xargs -t grep -n -ir $* --color}; gtask'
- 使用更加严格的编译检查
开启更加严格的编译warning检查,把warning认为报错,这样就会及时处理各种错误。避免一些warning引发的错误。
# 任何的告警都会被认为错误
g++ -Wall -Werror test.cpp -o test
# 不过如果有依赖一个有warning的库或者是warning太多积重难返
# 可以针对一些重要waring认为是错误
g++ -Wall -Werror=shadow -Werror=maybe-uninitialized -Werror=return-type -Werror=unused-but-set-variable test.cpp -o test