Week1Day2B:程序调试与技巧【2023 安全创客实践训练|笔记】

内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将系列博客设置为仅粉丝可见。

目录

WA 的一般原因与处理

TLE 的一般原因与处理

RE 的一般原因与处理

输出调试方法

C 语言输出 DEBUG 调试信息的方法

手工环境下 BUG 程序中的调试信息

用预处理指令封装调试信息

有时候输出数据过多,那该怎么办呢

日志调试

日志

日志调试

断言测试

简介

用法总结与注意事项

总结

gdb 调试

概念

功能

使用方法

启动方式

常用参数

调试示例


WA 的一般原因与处理

WA(Wrong Answer)相信大家都已经不陌生了,但是费劲千辛万苦写出来的代码,却不能得到正确答案,很容易导致心态不稳定,接下来我们来总结 WA 出现的常见原因,以及一些应对技巧。

第一种原因就是看错题目,或者忽略了一些特殊情况,尤其是在一些复杂的模拟题里面,这类题目中往往会出现很多情况,对于一些情况的 认知错误 或者 完全忽略 都会导致最终答案的错误。

例如 NOIP2016 玩具谜题,小人们可能有两种朝向(面朝圆心或者背对圆心),每个小人可能给出两种指令——向左或者向右,但是不同朝向的向左和向右是不同的,需要加以区分,如果将其混淆,最终就有可能得不到正确答案。

第二种原因是溢出问题。

有的时候,输入的值域很大,接近 int 的最大值,如果我们在求解问题时需要对两个很大的输入进行加法或者乘法操作,这个时候就很容易使得结果超出 int 的范围,造成溢出从而导致答案错误。

例如想要计算 x×y 的结果,其中 1≤x,y≤10^6。此时 x×y 就可能超出 int 范围。

应对这种问题也很简单,开 long long 或者 double 就好了。因此在写题目时,我们要时常问问自己,这题如果我不开 long long ,会造成溢出问题吗?

第三种是自己写代码的逻辑出了错误。

比如说在写代码的时候,把 == 打成了 = ,把 i 打成了 1 类似于这样的错误,会导致代码运行不出正确的结果。

在数十行,甚至上百行的代码中,寻找这种细节错误是非常低效且不值的。平时训练中,debug 一个下午甚至一天,最终发现自己代码是错在类似的错误上的选手大有人在。

因此在写代码时速度一定要慢下来,可能你慢的这两分钟,可以减少你两个小时的 debug 时间。

第四种是自己的解法本来就有问题。


TLE 的一般原因与处理

除了 WA ,TLE(Time Limit Exceed)也是选手们经常看到的状态,TLE 的原因主要有二,一种是错误地写了递归或者循环的结束条件,导致无限递归或者循环,一种是自己解决问题的时间复杂度与数据范围不符。

如果是错误地写了递归或者循环的结束条件导致了无限递归与无限循环。这个时候可以通过输出相关变量进行调试,找出自己原本期望在何处停止递归与循环以及实际情况,找出两者之间的差别再修改代码,逐渐使得程序能够很快地结束运行。

如果是解决问题的时间复杂度与数据范围不符,就需要换一种时间复杂度更低的解法,或者对自己的解法进行一些优化,减小常数或者降低复杂度。

下面附上常见时间复杂度以及能够应对的数据范围(仅供参考)。

基于评测机 11 秒能够进行 108108 次运算的标准。考虑到常数等原因,下表表示在对应数据范围内使用对应复杂度会比较稳,稍微超过一点也没有问题,但是对于小常数的要求更高。

时间复杂度数据范围
Θ(k^n)n≤15
Θ(n^4)n≤100
Θ(n^3)n≤250
Θ(n^2)n≤5000
Θ(nlogn)n≤10^5
Θ(n)n≤10^7
Θ(logn)n≤10^18

当然,数据范围很大的也可能是 Θ(1) 的结论题。参考 NOIP2017 小凯的疑惑(提高组 D1T1)。


RE 的一般原因与处理

RE(Runtime Error)的原因相对来说比较单纯,一般有四种情况:野指针、数组下标越界、递归爆栈以及除以 0。

野指针,指定义了却没有确定指向的指针,就像不知道哪里窜出来的野孩子一样。如果我们写了以下代码:

int a = 1;
int *p;
cout << *p;

这个时候就会 RE,因为 p 的指向未确定,只有我们加上一句 p = &a ,才可以正确输出 a 的值。

关于数组下标越界,就是题目要求使用一个长度为 10^5 的数组,但是你只开了长度为 10^4 的数组,这样在访问数组时就很有可能造成越界。当然如果下标为负数同样会出问题。

但是开一个长度为 10^5 的数组也不能说保险,因为在边界上对数组进行操作时,还是有很大概率超出数组的范围,应当要适当加一些 if 语句防止越界访问。如果不想写 if ,最方便的方法就是把数组再开大一点,反正空间够用,开一个长度为 10^5+50 ,这样越界访问的概率就会大大降低。

关于递归爆栈,很有可能是程序中有一个递归由于边界条件的编写出现失误,因而导致无限递归,最终由于储存空间不够用了,访问非法内存而导致 RE 。

在碰到递归爆栈的问题时,可以在递归函数中输出递归参数,判断期望输出和实际输出的差异,并相应地对递归函数进行修改。

至于除以 0 ,就是一个数学上的常见问题了:0 不能作为除数,这个时候,我们需要找到程序中哪里可能出现除以 0 的操作,并加入相应判断即可。


输出调试方法

某个步骤出错了,就可能导致后面的结果出错;某个步骤中出现了死循环,就可能导致超时;或者在某个步骤运行出错了,就可能导致运行时错误。

所以,我们可以采用输出调试这个方法去看,到底是哪一步出了问题。

具体我们可以这么操作(假设程序分 6 个版块,下面是只执行前面 3 个版块的例子):

operation1();
operation2();
operation3();
/*
operation4();
operation5();
operation6();
*/
  1. 执行第一个版块,并输出那个版块应该得到的结果;
  2. 执行前两个版块,并输出那个版块应该得到的结果;
  3. 执行前三个版块,并输出那个版块应该得到的结果;

⋯⋯

实际上可以借助二分法去更快的定位到是哪个模块出了问题,但是我们现在的代码还比较简单,并不需要使用那样的方法去定位,就老老实实地从第一个版块慢慢来吧。

如果你定位到了哪个版块在运行时候的结果不符合你的预期,你就可以停下来开始仔细看看前面的代码了,你可以把每个模块分得更细,以更精确地找到你的代码到底是哪里出了问题。

当然,在这里你需要搞清楚的一件事情是,在某个版块出现了错误的结果,并不代表你前面的版块就没有错误。第一个版块出错,可能在第四个甚至第五个才会体现出来。

所以避免出错的根本方法就是,在开始写代码之前你就要想清楚:

  1. 每个模块你到底要做什么。
  2. 每个模块会得到什么结果,以用于后面的模块。

其实还有一种情况是,如果你发现所有的版块都是对的,那就说明,你造的数据太弱了,需要再加强。或者你的算法本来就是有问题的(虽然你的代码是完全符合你的算法的),需要整体重新构思和修改。


C 语言输出 DEBUG 调试信息的方法

手工环境下 BUG 程序中的调试信息

/*  debug.c  */
#include <stdio.h>
#include <stdlib.h>

//#define DEBUG

/*  计算n的阶乘n!  */
long Fac(int n);

/* 主函数
 * 输入一个n计算n的阶乘  */
int main(void) {
    int n;
    long fac;
    while(scanf("%d", &n) != EOF) {
        printf("%d! = %ld\n", n, Fac(n));
    }
    return EXIT_SUCCESS;
}

/*  计算n的阶乘n!  */
long Fac(int n) {
    int i;
    long fac;
    for(i = 1; i <= n; i++) {
        fac *= i;
        printf("调试信息 %d! = %ld\n", i, fac);/*  调试信息  */
    }
    return fac;
}

这个程序是有 BUG 的,在程序第 2323 行,变量 fac 未初始化为 1。

插入的调试信息

printf("%d! = %ld\n", i, fac);/*  调试信息  */

在不需要时我们只能将此调试信息注释掉,这个是最原始,最人工的一种方式。

优势: 
方便简单,易于操作,简单易读 
缺点: 
非常灵活,单一的调试信息会造成错误输出过于冗余

用预处理指令封装调试信息

通过预处理指令将调试信息封闭起来,如下

#ifdef DEBUG
    printf("%d! = %ld\n", i, fac);
#endif

这样调试的信息只存在与插桩信息宏 DEBUG 的预处理指令下,如果需要打开调试信息就定义插桩信息宏 DEBUG,否则就将插桩信息宏 DEBUG 注释掉(也可以 undef 或者删掉)。

这样我们的代码就变成:

/*  debug.c  */
#include <stdio.h>
#include <stdlib.h>

/*  插桩信息宏  */
#define DEBUG   /*  如果需要调试信息请使用该宏,如果想取消调试信息,请注释掉或者*/
//#undef DEBUG   /*  取消插桩信息宏DEBUG  */

/*  计算n的阶乘n!  */
long Fac(int n);

/* 主函数
 * 输入一个n计算n的阶乘  */
int main(void) {
    int n;
    long fac;
    while(scanf("%d", &n) != EOF)
    {
        printf("%d! = %ld\n", n, Fac(n));
    }
    return EXIT_SUCCESS;
}

/*  计算n的阶乘n!  */
long Fac(int n) {
    int i;
    long fac;

    for(i = 1; i <= n; i++) {
        fac *= i;

#ifdef DEBUG
        printf("调试信息 %d! = %ld\n", i, fac);
#endif
    }

    return fac;
}

其实我们也可以不在代码中添加插桩信息宏DEBUG,gcc为我们提供了一个更简单的方法,那就是gcc -D编译选项

-DDEBUG 以字符串“1”定义 DEBUG 宏。   
-DDEBUG=DEFN 以字符串“DEFN”定义 DEBUG 宏。

因此我们可以直接

gcc -DDEBUG debug.c -o debug

优势:

  • 方便简单,易于操作,简单易读

缺点:

  1. 不灵活,单一的调试宏,对于小项目来说可以,但是对于大项目同样会造成错误输出过于冗余,在大项目中,为了增加灵活性,往往通过定义多个等级的DEBUG(如DEBUG1,DEBUG2,DEBUG3等)或者不同名称的DEBUG(如DEBUG_DATA,DEBUG_COMM,DEBUG_APP等),来为不同的模块,或者错误等级进行调试,但是也会引入其他一些更复杂的问题,如项目难以管理,难以整合等问题。
  2. 每个调试信息都会被成对的预处理指令包含,造成项目代码的过度膨胀,延长预处理时间;同时也不利于代码的阅读。

在输出调试信息时,很常见的应用情景是输出变量的值,我们可以通过如下的宏定义,输出变量的值及变量所在行的信息:

#define debug(x) printf("line %d, %s=%d\n", __LINE__, #x, x)

这样就可以在程序的任意地方输出变量的调试信息。而这样做的方便之处在于,如果我们希望把调试信息隐去,直接把这行宏定义改为

#define debug

就可以不再输出调试信息了。

我们通常在哪些情况下借助这种办法进行调试呢?

中间变量输出调试法,往往适用于程序可以正常结束运行的情况。

比如在这份程序想要计算 n 的所有因子和。

  • 直接运行后输入数据,只有一个最终结果:

)

  • 但是我们可以在程序中添加一些输出的语句,将程序中间步骤输出来:

)

  • 可以发现,这里输出的 i 都是对的,但是答案是错的,那肯定是 sum += n 这一句语句有问题,所以应该是 sum += i 才对

同样的,如果你的程序答案是正确的,你也可以通过这样的方式来观察你的程序找到的因子是否完全正确,会不会这份测试数据存在巧合导致虽然你因子找错了,但是最终的答案却是正确的。

有时候输出数据过多,那该怎么办呢

  • 可以加上一些条件,只输出 某些特定情况时的结果。
  • 多输出 一些标示性的数据,来表明内容。

比如刚才计算因子,如果有多个数字都计算了因子,那我们怎么分辨每个数的因子呢?

比如这份程序修改成,需要计算 1∼n 所有数字的因子总和,如果我们还是像刚才那样输出因子的结果就是这样,比较难以分辨:

)

 

多输出 一些标示性的数据,来分割内容

)

在测试结束以后,记得将添加用于测试的输出代码删除或者注释!

否则好不容易调试正确的代码,因为粗心没有删除测试用的语句导致错误,那真是太可惜了。


日志调试

日志

日志的打印在软件开发过程中必不可少,一般分为两个大类:

  • 操作日志,主要针对的是用户,例如在Photoshop软件中会记录自己操作的步骤,便于用户自己查看。

  • 系统日志,主要针对的是软件开发人员(包括测试、维护人员),也就是说这部分的日志用户是看不到的,也就是我们通常所说的 debug 日志。

日志调试

对于工程上,尤其是大型项目,常用的调试,检测问题方法就是打印日志, 特别是在代码经过编译器一些比较复杂的优化后,会变得“难以辨认”,使用调试器也变得有些头疼。此时,日志打印的好处就体现出来了,无论编译器如何优化,printf 的输出总是正确的(编译器的优化总是保证程序效果不变),而且相较于调试器各种高深摸测的命令,printf 的用法是程序猿的必备知识,所以利用 printf 来跟踪程序有的时候比调试器还要方便。虽然有的时候 printf 可能显得不那么安全,但你可换其它的安全的输出函数啊。

常用调试宏定义

宏名(每个宏名前后双下划线)类型意义
FILE字符串当前程序名
FUNCTION字符串当前函数名
LINE整数当前行号(在源代码中的)
DATE字符串被编译的日期
TIME字符串被编译的时间
STDC整数(布尔)如果编译器按照ANSI C来编译,为非零值;否则为0

使用这些宏来配合 printf,可以做到很好的调试

比如定义一个 debug 输出的函数模板:

#define Debug()    \
    printf("Bug in function: %s (file: %s), @line: %d. It is compiled on %s  %s, %s ANSI C standard.\n", __FUNCTION__, __FILE__, __LINE__, __TIME__, __DATE__, __STDC__? "with" : "without");

当我觉得可能是对某函数因为参数指针p是NULL而使得程序崩溃,那么我可以在该操作中加入如下一句:

if(!p) {
    Debug();
}

这样如果真的因为 p 是 NULL 造成的程序崩溃的话,程序退出前会输出这个 BUG 在源代码中的位置,方便我们追踪它.

在 C/C++ 工程开发中,我们可以用一些开源的日志框架帮助我们实现日志的记录。

常用 Logging 框架:


断言测试

简介

assert 宏的原型定义在 <assert.h> 中,其作用是如果它的条件返回错误,则终止程序执行。

原型定义:

#include <assert.h>
void assert( int expression );

assert 的作用是先计算表达式 expression,如果其值为假(即为 0),那么它先向 stderr 打印一条出错信息,然后通过调用 abort 来终止程序运行。请看下面的程序清单 badptr.c:

#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
int main( void )
{
       FILE *fp;

       fp = fopen( "test.txt", "w" );//以可写的方式打开一个文件,如果不存在就创建一个同名文件
       assert( fp );                           //所以这里不会出错
       fclose( fp );

       fp = fopen( "noexitfile.txt", "r" );//以只读的方式打开一个文件,如果不存在就打开文件失败
       assert( fp );                           //所以这里出错
       fclose( fp );                           //程序永远都执行不到这里来
       return 0;
}
[root@localhost error_process]# gcc badptr.c 
[root@localhost error_process]# ./a.out 
a.out: badptr.c:14: main: Assertion `fp' failed.

已放弃使用 assert() 的原因是,频繁的调用会极大的影响程序的性能,增加额外的开销。在调试结束后,可以通过在包含 #include <assert.h> 的语句之前插入 #define NDEBUG 来禁用 assert调用,示例代码如下:

#include <stdio.h>
#define NDEBUG
#include <assert.h>

用法总结与注意事项

1)在函数开始处检验传入参数的合法性如:

int resetBufferSize(int nNewSize) {
    //功能:改变缓冲区大小,
    //参数:nNewSize 缓冲区新长度
    //返回值:缓冲区当前长度 
    //说明:保持原信息内容不变     nNewSize<=0表示清除缓冲区
    assert(nNewSize >= 0);
    assert(nNewSize <= MAX_BUFFER_SIZE);
    ...
}

2)每个 assert 只检验一个条件,因为同时检验多个条件时,如果断言失败,无法直观的判断是哪个条件失败,如:

不好:

assert(nOffset>=0 && nOffset+nSize<=m_nInfomationSize);

好:

assert(nOffset >= 0);
assert(nOffset+nSize <= m_nInfomationSize);

3)不能使用改变环境的语句,因为 assert 只在 DEBUG 个生效,如果这么做,会使用程序在真正运行时遇到问题,如:

错误:

assert(i++ < 100);

这是因为如果出错,比如在执行之前 i=100,那么这条语句就不会执行,那么 i++ 这条命令就没有执行。

正确:

assert(i < 100);
i++;

4)assert和后面的语句应空一行,以形成逻辑和视觉上的一致感。

5)有的地方,assert不能代替条件过滤。

总结

assert是用来避免显而易见的错误的,而不是处理异常的。错误和异常是不一样的,错误是不应该出现的,异常是不可避免的。c语言异常可以通过条件判断来处理,其它语言有各自的异常处理机制。

一个非常简单的使用assert的规律就是,在方法或者函数的最开始使用,如果在方法的中间使用则需要慎重考虑是否是应该的。方法的最开始还没开始一个功能过程,在一个功能过程执行中出现的问题几乎都是异常。


gdb 调试

概念

gdb 是 GNU 开源组织发布的一个强大的 UNIX 下的程序调试工具。或许,各位比较喜欢那种图形界面方式的,像 VC、BCB 等 IDE 的调试,但如果你是在 UNIX 平台下做软件,你会发现 gdb 这个调试工具有比 VC、BCB 的图形化调试器更强大的功能。所谓“寸有所长,尺有所短”就是这个道理。

功能

  • 启动你的程序,可以按照你的自定义的要求运行程序
  • 可让被调试的程序在你所指定的调置的断点处停住。(断点可以是条件表达式)
  • 当程序被停住时,可以检查此时你的程序中所发生的事
  • 动态的改变你程序的执行环境

使用方法

一般来说 gdb 主要调试的是 C/C++ 的程序。要调试 C/C++ 的程序,首先在编译时,我们必须要把调试信息加到可执行文件中。使用编译器(cc/gcc/g++)的-g 参数可以做到这一点

注:如果没有-g,你将看不见程序的函数名、变量名,所代替的全是运行时的内存地址。

启动方式

  • gdb program 也就是你的执行文件,一般在当前目录下。

  • gdb core用 gdb 同时调试一个运行程序和 core 文件,core 是程序非法执行后 core dump 后产生的文件

  • gdb 如果你的程序是一个服务程序,那么你可以指定这个服务程序运行时的进程 ID。gdb 会自动 attach 上去,并调试他, 此时的 program 应该在 PATH 环境变量中搜索得到

常用参数

命令命令缩写命令说明
listl显示多行源代码
breakb设置断点,程序运行到断点的位置会停下来
infoi描述程序的状态
runr开始运行程序
displaydisp跟踪查看某个变量,每次停下来都显示它的值
steps执行下一条语句,如果该语句为函数调用,则进入函数执行其中的第一条语句
nextn执行下一条语句,如果该语句为函数调用,不会进入函数内部执行(即不会一步步地调试函数内部语句)
printp打印内部变量值
continuec继续程序的运行,直到遇到下一个断点
set var name=v设置变量的值
startst开始执行程序,在main函数的第一条语句前面停下来
file装入需要调试的程序
killk终止正在调试的程序
watch监视变量值的变化
backtracebt产看函数调用信息(堆栈)
framef查看栈帧
quitq退出GDB环境

调试示例

源程序 test.c :

#include 

int func(int n) {
    int sum = 0, i;
    for (i = 0; i < n; i++) {
        sum += i;
    }
    return sum;
}
int main() {
    int i;
    long result = 0;
    for (i = 1; i <= 100; i++) {
        result += i;
    }
    printf("result[1-100] = %d /n", result);
    printf("result[1-250] = %d /n", func(250));
}

编译生成执行文件

gcc -g test.c -o main

使用 gdb 调试,完整实例

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 main...done.
(gdb) l     <-------------------- l命令相当于list,从第一行开始例出原码。
1        #include 
2
3        int func(int n)
4        {
5                int sum=0,i;
6                for(i=0; i < n; i++)
7                {
8                        sum+=i;
9                }
10               return sum;
(gdb)       <-------------------- 直接回车表示,重复上一次命令
11       }
12
13
14       main()
15       {
16               int i;
17               long result = 0;
18               for(i=1; i<=100; i++)
19               {
20                       result += i;  
(gdb) break 16    <-------------------- 设置断点,在源程序第16行处。
Breakpoint 1 at 0x8048496: file tst.c, line 16.
(gdb) break func <-------------------- 设置断点,在函数func()入口处。
Breakpoint 2 at 0x8048456: file tst.c, line 5.
(gdb) info break <-------------------- 查看断点信息。
Num Type           Disp Enb Address    What
1   breakpoint     keep y   0x08048496 in main at tst.c:16
2   breakpoint     keep y   0x08048456 in func at tst.c:5
(gdb) r           <--------------------- 运行程序,run命令简写
Starting program: /home/hchen/test/tst

Breakpoint 1, main () at tst.c:17    <----------在断点处停住。
17               long result = 0;
(gdb) n          <--------------------- 单条语句执行,next命令简写。
18               for(i=1; i<=100; i++)
(gdb) n
20                       result += i;
(gdb) n
18               for(i=1; i<=100; i++)
(gdb) n
20                       result += i;
(gdb) c          <--------------------- 继续运行程序,continue命令简写。
Continuing.
result[1-100] = 5050       <----------程序输出。

Breakpoint 2, func (n=250) at tst.c:5
5                int sum=0,i;
(gdb) n
6                for(i=1; i<=n; i++)
(gdb) p i        <--------------------- 打印变量i的值,print命令简写。
$1 = 134513808
(gdb) n
8                        sum+=i;
(gdb) n
6                for(i=1; i<=n; i++)
(gdb) p sum
$2 = 1
(gdb) n
8                        sum+=i;
(gdb) p i
$3 = 2
(gdb) n
6                for(i=1; i<=n; i++)
(gdb) p sum
$4 = 3
(gdb) bt        <---------------------查看函数堆栈。
#0 func (n=250) at tst.c:5
#1 0x080484e4 in main () at tst.c:24
#2 0x400409ed in __libc_start_main () from /lib/libc.so.6
(gdb) finish    <---------------------退出函数。
Run till exit from #0 func (n=250) at tst.c:5
0x080484e4 in main () at tst.c:24
24              printf("result[1-250] = %d /n", func(250) );
Value returned is $6 = 31375
(gdb) c     <--------------------- 继续运行。
Continuing.
result[1-250] = 31375    <----------程序输出。

Program exited with code 027. <--------程序退出,调试结束。
(gdb) q     <--------------------- 退出gdb。

当编译好的 C 程序在 Linux 中运行异常退出时,会在程序所在目录内生成一个 core dump 文件。

加入可执行程序名称为 a,core dump 文件名为 core.3059,接下来我们执行

gdb ./x core.3059

就可以看到:

这样就可以快速定位程序异常退出的位置了。 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值