C Programming Language

Practice #02: Summary of Basic Debugging Skills

# Basic Tutorial,  # Programming Language,  # C Programming Language

Last Updated: 2020-01-01


前置知识

在阅读本文档前,建议你先了解 C 语言的初学者常见错误,详见 Practice #01: Summary of Common Mistakes for Beginners


前言

调试 (DEBUG) 能力对于编程来说是非常重要的,即便你写程序写得再好再快再熟练,只要你还是个人,就无法保证自己不会犯错,因此能够快速查错也是一项非常重要的基本功。不如说,写代码能力和 DEBUG 能力本来就是编程能力的两个关系紧密的子集。

作为初学者,调试技巧是有必要掌握的,你不能每次都因为一些特别智障的错误去请教他人,次数多了会令他人感到反感,而且也无益于你自身能力的提升。即便你还功力尚浅,也至少应该学会去独立排查并解决一些简单的问题。毕竟,迟早你将不再是一名初学者,如果届时你未能掌握与写代码能力相匹配的 DEBUG 能力,你恐怕会遇到不小的麻烦。

学习调试技巧之前,你需要养成遇到 BUG 先自己独立尝试解决的习惯。如果你总是依赖于他人帮你 DEBUG 的话,你永远也学不会自己 DEBUG 。就像提高代码能力需要自己多写代码一样,学习调试也需要自己多去独立尝试。当然,如果你花费了很长时间也无法解决,或是经过认真思考也一头雾水毫无头绪,向他人求助才是合适的,再自己死磕就没有意义了。

本文介绍了一些最基本的调试技巧,并给出若干示例帮助读者掌握这些技巧。此外还通过列举大量实例来帮助读者掌握最基本的阅读编译错误信息的能力。


概述

基本的调试方法说白了也就是两大类:

  • 利用 IDE 的调试工具进行调试,观测和追踪程序运行的过程;

  • 利用输出流获取中间结果,并据此思考找到程序中的错误。

现在 IDE 的调试工具的功能越来越强大,只需要花费一点时间掌握工具的使用方法(一般都不难,而且各种调试工具其实都很相像),就能非常方便地进行单步调试和定点调试等等。

然而,如果你只是写一个玩具级的单文件程序,想要使用调试工具,通常都必须用 IDE 建立一个工程项目,这实在是有些大材小用,为了调试反而把事情变得麻烦了。

在这种情况下,我们如果掌握了利用输出流查看中间结果的简单技巧,就能灵活地应对玩具级程序的 DEBUG 任务。这种方法简单粗暴,但是其麻烦程度会随着程序规模的增长而快速增长,在大型软件系统中这种方法几乎是不可行的。但是当程序规模很小的时候,这种方法可能比操作调试工具要高效不少。

我个人认为,对于规模稍大的项目,利用调试工具进行调试是合适的,而对于玩具级代码和小型项目则显得不那么必要,仅利用输出流查看中间结果就足以达到 DEBUG 的目的。当然,也可以使用调试工具,这没有优劣之分,看个人喜好即可。


利用输出流进行调试

输出中间结果

其实这个方法是非常简单的,只是会与不会的问题,你学会了之后都不需要再复习第二遍。

想象你写好了一段程序,并且编译通过了(关于阅读编译错误的问题详见后文),但是运行后出现了问题——可能是运行结果不符合预期,也可能是程序在中途就崩溃了,或是陷入了死循环。

以下面这段简单的角度转弧度的代码为例:

#include <stdio.h>
#include <math.h>
 
int main(void) {
    double pi = acos(-1.0);
    double angle;
    double rad;
    scanf("%1f", &angle);
    rad = angle * pi / 180;
    printf("%.3f\n", rad);
    return 0;
}

我们运行代码并输入如下内容:

45

程序的输出结果如下,显然是错误的:

0.000

假设你经过肉眼检查后无果,现在你要设法 DEBUG 把程序调通。

所谓的“利用输出流进行调试”,说白了就是在程序的各个地方利用 printf/puts 等等来输出中间结果,并观察结果是否符合预期。

首先,我们要确定程序的输入是否正常。我们在原始程序的8~9行之间插入如下代码:

printf("%.3f\n", angle);

输入与之前相同的内容,程序的输出结果如下:

0.000
0.000

这可以证明:要么是 scanf 写得不对,输入的值没有正确地赋给变量 angleangle ;要么是 printf 写得不对,变量 angleangle 的值没有正确地输出。这就已经基本确定了问题的所在。

现在我们先排查 printf 是否有问题。我们在原始程序的7~8行之间插入如下代码:

printf("%.3f\n", 1.5);

在输入之前,程序的输出结果如下:

1.500

显然 printf 没有问题,那么问题一定出在 scanf 上。

事实上,是因为 scanf 内我们不小心把 %lf 写成了 %1f ,小写字母 ll 错写成了数字 11 ,因为字体的原因没能发现。

当然,这个问题可能十分隐蔽,因此或许你仍需去求助他人,但是即便如此,别人帮你解决此问题的难度也已经大大降低了。

接下来我们再举一个复杂的例子,下例中包含更多的错误。

以下面这段简单的选择排序的代码为例:

#include <stdio.h>
 
#define N 100
 
int main(void) {
    // 变量声明
    int a[N];
    int n;
    int i, j;
    int m, t;
    // 输入
    scanf("%d", &n);
    for (i = 0; i < n; i ++) {
        scanf("%d", a[i]);
    }
    // 排序
    for (i = 0; i < n; i ++) {
        m = i;
        for (j = 0; j < n; j ++) {
            if (a[m] > a[j]) {
                m = j;
            }
        }
        t = a[m];
        a[m] = a[j];
        a[j] = t;
    }
    // 输出
    for (i = 0; i < n; i ++) {
        printf("%d", a[i]);
        printf(i == n - 1 ? "\n" : " ");
    }
    // 返回
    return 0;
}

我们运行代码并输入如下内容:

7
3 1 4 7 2 5 6

然后程序就崩溃了!真是个悲伤的故事。

假设你经过肉眼检查后无果,现在你要设法 DEBUG 把程序调通,当然,错误可能不止一处。

所谓的“利用输出流进行调试”,说白了就是在程序的各个地方利用 printf/puts 等等来输出中间结果,并观察结果是否符合预期。

首先,我们要确定程序的输入是否正常。我们在原始程序的15~16行之间插入如下代码:

printf("%d\n", n);
for (i = 0; i < n; i ++) {
    printf("%d", a[i]);
    printf(i == n - 1 ? "\n" : " ");
}

如果输入正常,显然屏幕上会输出一份完全相同的内容。

然而很遗憾,运行后程序仍然是直接崩溃,没有输出任何内容,这就说明输入的代码中很可能存在错误,并导致了程序的崩溃。

现在我们要确认这一点,我们将原始程序的12行改为:

printf("before input n\n");
scanf("%d", &n);
printf("%d\n", n);

现在,程序一运行就立刻输出了 before input n ,说明在输入之前程序没有崩溃;输入一行 7 之后程序立刻输出了 7 ,说明输入 nn 的代码是正确的;接下来输入一行 3 1 4 7 2 5 6 之后程序崩溃,此时错误范围已经被锁定到三行以内。

如果现在你还看不出来 scanf 输入 a[i]a[i] 时忘记加取地址符,那我也没什么办法了。

改正这个错误之后,运行程序不再崩溃,前面的这些 DEBUG 输出的内容一切正常,然而排序的结果是错误的:

3 10944512 4 7 2 5 6

由于之前已经确定了输入是没问题的,我们只需在后半部分进行排查即可。当然,如果程序本来没有 scanf 的那个错误,就在此时用同样的方法排查输入即可。

注意到输出中出现了 10944512 这种莫名其妙的数字,根据经验,很大概率是发生了非法内存访问,但是现在我们假设你经验不足看不出来,并试图定位错误所在。

选择排序有两层循环,如果在最内层输出中间结果,满屏幕的输出会让人非常头大,我们不妨先试图在外层循环中输出中间结果,如果实在无法定位错误再说。

我们在原始程序的23~24行之间插入如下代码:

printf("i = %d, find m = %d, a[m] = %d\n", i, m, a[m]);

这样可以判断寻找最小元素的那部分代码是否正确。运行程序得到输出结果如下:

i = 0, find m = 1, a[m] = 1
i = 1, find m = 4, a[m] = 2
i = 2, find m = 4, a[m] = 1
i = 3, find m = 4, a[m] = 2
i = 4, find m = 4, a[m] = 1
i = 5, find m = 4, a[m] = 2
i = 6, find m = 4, a[m] = 1
3 7274496 4 7 2 5 6

可以看到,当 i=1i = 1 时程序找到的最小值变成了 22 而不是 11 ,说明可能是在 i=0i = 0i=1i = 1 的部分代码中发生了错误,导致数组中原本的 11 丢失了。

接下来我们需要进一步确定出错的位置。我们多声明一个临时变量 kk 以保证我们不会因为任何粗心大意而发生循环变量的冲突使用,并在原始程序的26~27行之间插入如下代码:

for (k = 0; k < n; k ++) {
    printf("%d", a[k]);
    printf(k == n - 1 ? "\n" : " ");
}

运行程序得到输出结果如下:

i = 0, find m = 1, a[m] = 1
3 13303808 4 7 2 5 6
i = 1, find m = 4, a[m] = 2
3 13303808 4 7 1 5 6
i = 2, find m = 4, a[m] = 1
3 13303808 4 7 2 5 6
i = 3, find m = 4, a[m] = 2
3 13303808 4 7 1 5 6
i = 4, find m = 4, a[m] = 1
3 13303808 4 7 2 5 6
i = 5, find m = 4, a[m] = 2
3 13303808 4 7 1 5 6
i = 6, find m = 4, a[m] = 1
3 13303808 4 7 2 5 6
3 13303808 4 7 2 5 6

问题已经很明显了,在 i=0i = 0 交换元素时就导致了错误。如果现在你还看不出来交换元素的代码中把 a[i]a[i] 误写成了 a[j]a[j] ,那我也没什么办法了。

改正这个错误之后,排序的结果中不再有迷之数字,但是仍然是错误的:

3 4 7 2 5 6 1

我们仍然用之前的方法来定位错误,输出结果如下:

i = 0, find m = 1, a[m] = 1
1 3 4 7 2 5 6
i = 1, find m = 0, a[m] = 1
3 1 4 7 2 5 6
i = 2, find m = 1, a[m] = 1
3 4 1 7 2 5 6
i = 3, find m = 2, a[m] = 1
3 4 7 1 2 5 6
i = 4, find m = 3, a[m] = 1
3 4 7 2 1 5 6
i = 5, find m = 4, a[m] = 1
3 4 7 2 5 1 6
i = 6, find m = 5, a[m] = 1
3 4 7 2 5 6 1
3 4 7 2 5 6 1

可以发现,在每一轮循环中,程序找到的最小值都是 11 ,是整个数组的最小值。问题已经很明显了,第一轮循环我们将 11 找到后,下一个应该找到的是 22 ,因为经过 xx 次循环后 a[0, x1]a[0,\ x-1] 已经是有序的,我们不应当在这里面寻找最小值。而实际上我们在第二轮循环仍然找到了 11 ,说明我们找最小值的代码把前面排好序的部分也算进去了。

如果现在你还看不出来内层循环的代码中把 for (j = i + 1; j < n; j ++) 误写成了 for (j = 0; j < n; j ++) ,那我也没什么办法了。

改正错误,再次运行,大功告成!

1 2 3 4 5 6 7

此处我们仅举了一个简单的例子,其中的错误基本上都是可以肉眼查到的,但有时我们的眼睛并不好使,仍然需要利用这样的方法来定位错误。


利用调试工具进行调试(待补)


学会阅读编译错误

不要恐慌

要想学会阅读编译错误,首先你不能一看到编译错误就陷入恐慌,而是要自己去阅读并尝试理解它。事实上,相当一部分的编译错误的字面意思就足以帮你定位到错误所在。

可能对于初学者而言,许多编译错误是看不懂的,没关系,在你向他人请教后,要去试着弄懂这些报错的含义,否则下次遇到同样的错误你还得请教别人。

下面我们将列举一些初学者常见的简单的编译错误,一方面是知识的普及,另一方面是借此教会读者去自己阅读并理解这些编译错误。读者可以尝试自己将这些编译错误翻译成中文,会发现多数情况下字面内容就已经把问题说的很清楚了,你根本没必要去请教别人。

为便于理解,每个错误都会附带一份对应的代码示例,但考虑到篇幅问题,使用的示例都是极简的。

编译错误的文本内容在不同的编译器中是不同的,本文将以使用严格 C89 标准的 gcc 编译器为例。

常见简单编译错误汇总(未完成)

变量使用前未声明

代码示例:

int main(void) {
    a = 1;
    return 0;
}

编译错误:

|2| error: 'a' undeclared (first use in this function)

中文释义:

【第 2 行】错误: a 未声明(此函数中的首次使用)

语句末尾缺少分号

代码示例1:

int main(void) {
    int a = 1;
    a ++
    return 0;
}

编译错误1:

|4| error: expected ';' before 'return'

中文释义1:

【第 4 行】错误:预期为 ;return 之前

代码示例2:

int main(void) {
    int a
    return 0;
}

编译错误2:

|3| error: expected '=', ',', ';', 'asm' or '__attribute__' before 'return'

中文释义2:

【第 3 行】错误:预期为 = , , , ; , ... 之一在 return 之前

这个释义可能看起来有些难懂,因为无初始化的声明语句缺少分号时,也可能是缺少其他符号,但是结合实际情况应该就能明白是缺少分号。

备注:

一般会在缺少分号的语句的下一句的开头报错,其原因需要一些编译理论知识才能理解。

待续