程序调试的一些心得和经验

整理程序调试的一些踩坑,经验整理,注意事项等。

C++后端开发的踩坑整理
https://www.jianshu.com/p/b67222570785

  1. 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这样的程序的设计非常不合理。


  1. 日志模块
    加log来排查问题是最常见有效的定位bug的思路了,一个靠谱,高效,稳定的日志模块是一个系统最基础的组件了。日志库也有很多的开源的实现,比如glog等 > glog源码分析

  1. 断点调试
    使用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 到目标进程调试
    

  1. 内存泄露定位
    如何定位内存泄露是一个比较复杂的问题,往往需要发挥程序员的聪明才智,尝试各种思路,在众多蛛丝马迹中找到问题的根源。
    一般来说:
    1. 发现内存泄露,比如收到机器的内存告警,oom发生,或者其他监控日志触发告警。
    2. 从内存泄露的机器上定位到具体哪个进程发生了泄露。
      a) top看看有没有内存占用异常高的进程
      b) oom被kill掉的进程并不一定是内存泄露的进程,虽然大概率是。
      c) 最好每个进程加上监控日志,每隔一段时间把自己的meminfo打印出来,这样就可以查日志,看到每个进程内存的变化。持续走高的进程就是问题所在了。
    3. valgrind 挂在可疑进程上,使用工具跑一下。(如果是一个小项目或者是简单的工具类程序,那么发现问题的概率是比较高的,如果一无所获,那就继续排查)
    4. 如果不能发现问题,接下来查看内存池日志,寻找各种对象的分配的数量和最大使用数量。如果运气好找到了泄露的对象,根据泄露的对象,进一步缩小范围,找到分配对象的函数。(一般来说可以在分配对象的信息里面带上申请对象的__FILE____FUNCTION__ 这样就可以轻松找到对象是被从哪里被分配出来的)
    5. 如果还没找到,那么可以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

这个利用了模板的机制,每个要统计的类实例化一个模板进行计数。对要统计的对象只需要插入一行代码,非常的简洁。


  1. cpu高负载定位
    和内存泄露的思路差不多,一般来说:
    1. 收到机器cpu告警,确认一下是瞬间cpu出现峰值,还是持续cpu出现峰值。
    2. 找到机器上跑满cpu的进程
      a) 对于持续跑满的情况下,top看看就知道了
      b) 对于瞬间跑满的情况,登录到机器上面的时候,cpu可能已经正常了。每个进程可以加上周期性的打印出自己的cpu负载情况。这样看日志就可以找到问题进程。
    3. 如果是持续很高,很有可能是死循环,可以pstack pid打印出进程的bt或gdb attach上去bt几次,如果都是同一个bt的话,那就是在这个逻辑里面死循环了。
    4. 如果不是持续很高,那就要开始发动聪明的大脑了。
      a) 找到cpu出现异常高的时间段,看看这样时间内发生了什么。比如,是否是收到了大量的外部请求,是否是定时任务大量同时到期,日志里面是否有可疑的日志。
      b) 对于线上环境,可以使用预先给关键函数,入口函数,消息驱动函数等插入记录执行时间的统计日志。(比如统计每个rpc的平均请求数/平均处理时间,查询db的请求数/出处理时间,定时器等逻辑)如何关于如何统计函数的执行时间 有了这个日志,对定位问题有极大的帮助。
      c) 一般来说上一步就可以定位问题了,或者可以定位问题的大概范围。可以进一步插入统计日志来精确定位。如果还不行,那可以结合业务分析分析,比如这段时间有什么特殊业务等。
      d) 定位问题后,可以结合各种性能测试工具比如pert,vtune等工具,针对性的构建测试环境和用例,进一步的验证和复现问题。

  1. 内存越界定位
    变量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

定位内存被写坏的情况,是一个很头疼的问题。这个例子很好排查的原因是,程序的规模很小,逻辑简单,而且已经知道了问题是由于越界引发的写坏内存。在实际工作中,越界写坏内存只是内存被写坏的其中的一个原因,多线程环境下被写坏,程序逻辑异常导致的写坏等等都是有可能的,需要从各种蛛丝马迹的线索中找到可能的原因,然后不断的验证 直到找到问题。


  1. linux排查问题命令小结
    对于log文件,结合各种查找命令找到关键字,实际上是日常开发中最常用的调试手段了。
    • 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
    
    grep还可以和正则表达式结合在一起使用:
    // 正则表达
    // 日志中查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'

  1. 使用更加严格的编译检查
    开启更加严格的编译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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,869评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,716评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,223评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,047评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,089评论 6 395
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,839评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,516评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,410评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,920评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,052评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,179评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,868评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,522评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,070评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,186评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,487评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,162评论 2 356

推荐阅读更多精彩内容