zl程序教程

您现在的位置是:首页 >  后端

当前栏目

【维生素C语言】第八章 - 实用调试技巧

C语言调试 技巧 实用 第八章
2023-09-14 09:15:59 时间

前言:

一名优秀的程序员是一名出色的侦探,每一次调试都是尝试破案的过程……本章将详细带你学习实用调试技巧!正式开启DEBUG生活。


一、调试(Debug)

0x00 何为调试

一名优秀的程序员是一名出色的侦探,每一次调试都是尝试破案的过程……

📚 定义:调试,又称除错,是发现和减少计算机程序电子仪器设备中程序错误的一个过程;

0x01 调试的基本步骤

📚 基本步骤:

     ① 发现程序错误的存在;

           ✅ 能够发现错误的人:

                ⑴  程序员,自己发现;

                ⑵  软件测试人员,测试软件;

                ⑶  用户,代价严重;

           📜 箴言:要善于承认自己的错误,不能掩盖错误;

     ② 以隔离、消除等方式对错误进行定位;

            ✅ 能知道大概在什么位置,再确定错误产生的原因是什么;

     ③ 提出纠正错误的解决方案;

     ④ 对程序错误订正,重新调试;

二、Debug和Release的介绍

0x00 对比

📚 Debug 通常称为调试版本,它包含调试信息,并且不做任何优化,便于程序员调试程序;

📚 Release 称为发布版本,他往往是进行了各种优化,使得程序在代码大小和运行速度上是最优的,以便用户更好的使用;

📌 注意事项:Release 版本是不能调试的

💬 用 DebugRelease 分别运行:

int main()
{
    char* p = "hello,world!";
    printf("%s\n", p);

    return 0;
}

🚩 Debug 环境下运行结果如下:

🚩  Release 环境下运行结果如下:

💡 我们可以发现:Release进行了优化,使得程序在运行速度和代码大小上是最优的;

💬 DebugRelease反汇编展示对比:

0x01 Release的优化

❓ 使用Release版本调试时,编辑器进行了那些优化呢?

💬 请看下列代码:

int main()
{
    int arr[10] = {0};
    int i = 0;
    for(i=0; i<=12; i++) {
        arr[i] = 0;
        printf("hehe\n");
    }

    return 0;
}

🚩 如果是 debug 模式去编译,程序结果是 死循环

🚩 如果是 release 模式去编译,程序没有死循环:

💡 因为 release 的优化,避免了死循环的发生;

三、Windows环境调试介绍

0x00 调试环境准备

📚 在环境中选择 debug 选项,才能使代码正常调试;

📌 注意事项:本章使用 VS2019 演示;

0x01 开始调试(F5)

✅ 快捷键:F5

📚 作用:启动调试,经常用来直接调到下一个断点处;

📌 注意事项:

     ① 如果直接按 F5 ,如果没有阻挡的话程序一口气就干完了;

     ② 使用 F5 之前要先使用 F9 ,设置断点;

💬 按 F5 开始调试下列代码:

int main()
{
	int arr[10] = { 0 };
	int sz = sizeof(arr) / sizeof(arr[0]);

	int i = 0;
	for (i = 0; i < sz; i++) {
		arr[i] = i + 1;
	}
	for (i = 0; i < sz; i++) {
		printf("%d\n", arr[i]);
	}

	return 0;
}

🚩 运行后结果如下:

0x02 断点(F9)

✅ 快捷键:F9

📚 作用:创建断点和取消断点,断电的重要作用可以在程序的任意位置设置断点;这样就可以使得程序在想要的位置随意停止执行,继而可以一步步执行下去;

💬 按 F9 设置断点

🚩 这时按下 F5 就会直接跳到断点部分:

0x03 逐过程(F10)

✅ 快捷键:F10

📚 作用:通常用来处理一个过程,一个过程可以是一次函数的调用,或者是一条语句;

💬 逐过程:

💡 按一次 F10 代码就往下走一步;

0x04 逐语句(F11)

✅ 快捷键:F11(这是最常用的)

📚 作用:每次都执行一条语句,观察的细腻度比 F10 还要高,可以进入到函数内部

📌 注意事项:F10F11 大部分情况是一样的,区别在于 F11 遇到函数时可以进到函数内部去,函数的内部也可以一步步观察,而 F10 遇到函数调用完之后就跳出去了;

💬 观察函数内部:

💡 如果想观察函数内部,就要使用 F11 (逐语句);

0x05 开始执行不调试(Ctrl + F5)

✅ 快捷键: Ctrl + F5

📚 作用:开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用;

0x06 总结

F5 - 启动调试

F9 - 设置/取消断点

F10 - 逐过程

F11 - 逐语句 - 更加细腻

Ctrl + F5 - 运行

📌 注意事项:如果你按上面的快捷键不起作用时,可能是因为辅助功能键(Fn)导致的,此时按下 Fn 再按上面的快捷键即可;

❓ 想知道更多快捷键?

    VS中常用的快捷键  👈 戳我!

0x07 调试时查看程序当前信息

💬 查看方法:调试(D) → 窗口(W) → 选择相应的选项;

📌 注意事项:只有调试之后才会显示调试窗口里的选项

0x08 查看断点

📚 作用:调试多个文件时,可以很好地管理多个文件的断点;

0x09 监视

📚 作用:在调试开始之后,便于观察变量的值;

📌 注意事项:要填入合法的表达式;

💬 监视操作(手动添加):

0x0A 自动窗口

📚 作用:编辑器自行监视,随着代码自动给出值;

📌 注意事项:

      ① 自动窗口和监视是一样的效果,但是自动窗口里的表达式会自动发生变化;

      ② 自由度低,自动窗口是编辑器自己监视,你管不了;

0x0B 查看局部变量

📚 作用:查看程序进行到当前位置时上下文的局部变量,编辑器自主放到窗口中进行相应的解释,只有局部变量和数组;

💬 查看局部变量:

0x0C 查看内存信息

📚 作用:在调试开始之后,用于观察内存信息;

💬 查看内存信息:

0x0D 查看调用堆栈

📚 作用:通过调用堆栈,可以清晰地反应函数的调用关系和所处的位置;

💬 查看调用堆栈:

0x0E 查看汇编信息

📚 在调试开始后,有两种方式转到汇编:

      ① 第一种方式:右击鼠标,选择 " 转到反汇编 "

      ② 第二种方式:调试 → 窗口 → 反汇编

💬 查看反汇编:

0x0F 查看寄存器信息

📚 作用:可以查看当前运行环境的寄存器的实用信息;

💬 查看寄存器:

0x10 条件断点

❓ 假设某个循环要循环1000次,我怀疑第500次循环时程序会出问题,那么我要打上断点然后再按500次 F10 吗?这样一来手指头不得按断了?

💡 方法:使用条件断点;

💬 在断点设置好之后右键鼠标,选中条件:

🐞 按下 F5 后,i 会直接变为 5 :

0x11 调试的感悟

📜 箴言:

      ① 多多动手,尝试调试,才能有进步;

      ② 一定要熟练掌握调试的技巧;

      ③ 初学者可能80%的时间在写代码,20%的时间在调试。

           但是一个程序员可能20%的时间在写程序,但是80%的时间在调试;

      ④ 我们所讲的都是一些简单的调试。

           以后可能会出现很复杂的调试场景:多线程程序的调试等;

      ⑤ 多多使用快捷键,提升效率;

四、一些调试的实例

0x00 实例一

💬 实现代码:求 1!+ 2! + 3! ··· + n!(不考虑溢出)

int main()
{
	int n = 0;
	scanf("%d", &n); // 3
	// 1!+ 2!+ 3!
	// 1    2    6  =  9
	int i = 0;
	int ret = 1;
	int sum = 0;
	int j = 0;
	for (j = 1; j <= n; j++) {
		for (i = 1; i <= j; i++) {
			ret *= i;
		}
		sum += ret;
	}
	printf("%d\n", sum);

	return 0;
}

🚩 运行结果如下:

❓ 结果应该是9才对,但是输出结果为15,代码出错了;代码又没有语法错误,代码能够运行,属于运行时错误,而调试解决的就是运行时错误;

🐞 此时我们试着调试:

💡 此时我们发现了问题:每一次求阶乘时,应该从1开始乘,所以每一次进入时 ret 要置为1;

int main()
{
	int n = 0;
	scanf_s("%d", &n); // 3
	// 1!+ 2!+ 3!
	// 1    2    6  =  9
	int i = 0;
	int ret = 1;
	int sum = 0;
	int j = 0;
	for (j = 1; j <= n; j++) {
		ret = 1; // 每次进入,置为1,重新开始乘
		for (i = 1; i <= j; i++) {
			ret *= i;
		}
		sum += ret;
	}
	printf("%d\n", sum);

	return 0;
}

🚩 运行结果如下:

🔺 解决问题:

      ① 要知道程序应该是什么结果:预期

      ② 调试的时候发现不符合预期,就找到问题了;

0x01 实例二

💬 下列代码运行的结果是什么?

int main()
{
	int i = 0;
	int arr[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
//                       👇 越界访问了
	for (i = 0; i <= 12; i++) {
		printf("hehe\n");
		arr[i] = 0;
	}
 
	return 0;
}

🚩 运行结果如下:

❓ 研究导致死循环的原因:

💡 解析:

🔺 本题正确答案:死循环,因为 i arr 是里昂个局部变量,先创建 i,再创建 arr,又因为局部变量是放在栈区上的,栈区的使用习惯是先使用高地址再使用低地址,所以内存的布局是这样子的(如图),又因为数组随着下标的增长地址是由低到高变化的,所以数组用下标访问时只要适当的越界,就有可能覆盖到 i,而 i 如果被覆盖的话,就会导致程序的死循环;

五、如何写出易于调试的代码(模拟实现strcpy)

0x00 优秀的代码

0x01 常见的coding技巧

0x02 strcpy函数介绍

/* strcpy: 字符串拷贝 */
#include <stdio.h>
#include <string.h>

int main()
{
	char arr1[20] = "xxxxxxxxxx";
	char arr2[] = "hello";
	strcpy(arr1, arr2);   // 字符串拷贝(目标字符串,源字符串)
	printf("%s\n", arr1); // hello

	return 0;
}

0x03 模拟实现strcpy

💬 示例 - 模拟实现 strcpy

#include <stdio.h>

char* my_strcpy (
	char* dest, // 目标字符串
	char* src   // 源字符串
	)
{
	while (*src != '\0') {
		*dest = *src;
		dest++;
		src++;
	}
	*dest = *src; // 拷贝'\0'
}

int main()
{
	char arr1[20] = "xxxxxxxxxx";
	char arr2[] = "hello";
	my_strcpy(arr1, arr2);
	printf("%s\n", arr1); // hello

	return 0;
}

0x04 优化 - 提高代码简洁性

💬 函数部分的代码,++ 部分其实可以整合到一起:

#include <stdio.h>

char* my_strcpy (char* dest, char* src)
{
	while (*src != '\0') {
		*dest++ = *src++;
	}
	*dest = *src;
}

0x05 优化 - 修改while

2. 甚至可以把这些代码都放到 while 内:

( 利用 while 最后的判断 ,*dest++ = *src++ 正好拷走斜杠0 )

char* my_strcpy (char* dest, char* src)
{
	while (*dest++ = *src++) // 既拷贝了斜杠0,又使得循环停止
		;
}

0x06 优化 - 防止传入空指针

❓ 如果传入空指针NULL,会产生BUG

💡 解决方案:使用断言

断言是语言中常用的防御式编程方式,减少编程错误;
如果计算表达式expression值为假(0),那么向stderr打印一条错误信息,然后通过调用abort来终止程序运行;
断言被定义为宏的形式(assert(expression)),而不是函数;

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

char* my_strcpy(char* dest, char* src)
{
	assert(dest != NULL); // 断言   "dest不能等于NULL"
	assert(src != NULL);  // 断言   "src 不能等于NULL"

	while (*dest++ = *src++)
		;
}

int main()
{
	char arr1[20] = "xxxxxxxxxx";
	char arr2[] = "hello";
	my_strcpy(arr1, NULL); // 👈 实验:传入一个NULL
	printf("%s\n", arr1);

	return 0;
}

🚩 运行结果如下:

0x07 关于const的使用

💬 将 num的值修改为20:

int main()
{
	int num = 10;
	int* p = &num;
	*p = 20;
	printf("%d\n", num);

	return 0;
}

🚩  20

💬 此时在 int num 前放上 const

const 修饰变量,这个变量就被称为常变量,不能被修改,但本质上还是变量;

但是!但是呢!!

int main()
{
    const int num = 10;
    int* p = &num;
    *p = 20;

    printf("%d\n", num);

    return 0;
}

🚩 运行结果如下

❗ 我们希望 num 不被修改,结果还是改了,这不出乱子了吗?!

num 居然把自己的地址交给了 p,然后 *p = 20,通过 p 来修改 num 的值,不讲武德!

💡 解决方案:只需要在 int* p 前面加上一个 const,此时 *p 就没用了

int main()
{
    const int num = 10;

    const int* p = &num; 
// 如果放在 * 左边,修饰的是 *p,表示指针指向的内容,是不能通过指针来改变的
    *p = 20; // ❌ 不可修改

    printf("%d\n", num);

    return 0;
}

🚩 运行结果如下:

🔑 解释:const 修饰指针变量的时候,const 如果放在 * 左边,修饰的是 *p,表示指针指向的内容是不能通过指针来改变的;

❓ 如果我们再加一个变量 n = 100, 我们不&num,我们&n,可不可以?

int main()
{
    const int num = 10;
    int n = 100;

    const int* p = &num;
    *p = 20 // ❌ 不能修改
    p = &n; // ✅ 但是指针变量的本身是可以修改的

    printf("%d\n", num);

    return 0;
}

🔑 可以,p 虽然不能改变 num,但是 p 可以改变指向,修改 p 变量的值

❓ 那把 const 放在 * 右边呢?

int main()
{
    const int num = 10;

    int n = 100;
    int* const  p = &num;
// 如果放在 * 右边,修饰的是指针变量p,表示的指针变量不能被改变
// 但是指针指向的内容,可以被改变
    
    p = 20; // ✅ 可以修改
    p = &n; // ❌ 不能修改

    printf("%d\n", num);

    return 0;
}

🔑 此时指针指向的内容可以修改,但是指针变量

❓ 如果两边都放 const

int main()
{
    const int num = 10;
    const int* const p = &num;
    int n = 100;

    *p = 20; // ❌ 不能修改
    p = &n;  // ❌ 不能修改
    
    printf("%d\n", num);

    return 0;
}

 0x08 优化 - 提高代码健壮性(加入const)

💡 为了防止两个变量前后顺序写反,我们可以利用 const 常量,给自己“设定规矩”,这样一来,当我们写反的时候, 因为是常量的原因,不可以被解引用修改,从而报错,容易发现问题之所在!

char* my_strcpy (char* dest, const char* src)
{
	assert(dest != NULL);
	assert(src != NULL);

	// while(*src++ = *dest) 👈 防止写反,加一个const
	while (*dest++ = *src++)
		;
}

可以无形的防止你写出 while(*src++ = *dest) ,即使你写错了,编译器也会报错(语法错误);

📌 注意事项:按照逻辑加 const,不要随便加const(比如在dest前也加个const);

0x09 最终优化 - 使其支持链式访问

💬 实现返回目标空间的起始位置

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

char* my_strcpy (char* dest,const char* src)
{
	char* ret = dest; // 在刚开始的时候记录一下dest
	assert(dest != NULL);
	assert(src != NULL);

	while (*dest++ = *src++)
		;

	return ret; // 最后返回dest
}

int main()
{
	char arr1[20] = "xxxxxxxxxx";
	char arr2[] = "hello";
	
	printf("%s\n", my_strcpy(arr1, arr2)); // 链式访问

	return 0;
}

0x0A 库函数写法

/***
*char *strcpy(dst, src) - copy one string over another
*
*Purpose:
*       Copies the string src into the spot specified by
*       dest; assumes enough room.
*
*Entry:
*       char * dst - string over which "src" is to be copied
*       const char * src - string to be copied over "dst"
*
*Exit:
*       The address of "dst"
*
*Exceptions:
*******************************************************************************/

char * strcpy(char * dst, const char * src)
{
        char * cp = dst;
        assert(dst && src);

        while( *cp++ = *src++ )
               ;     /* Copy src over dst */
        return( dst );
}

六、模拟实现strlen函数

0x00 计数器实现

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

int my_strlen(const char* str)
{
	assert(str);
	int count = 0;
	while (*str) {
		count++;
		str++;
	}
	return count;
}

int main()
{
	char arr[] = "abcdef";
        int len = my_strlen(arr);
	printf("%d\n", len);

	return 0;
}

0x01 指针减指针实现

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

size_t my_strlen(const char* str)
{
	assert(str);
	const char* eos = str;
	while (*eos++);
	return(eos - str - 1);
}

int main()
{
	char arr[] = "abcdef";
	printf("%d\n", my_strlen(arr));

	return 0;
}

0x02 库函数写法

/***
*strlen.c - contains strlen() routine
*
*       Copyright (c) Microsoft Corporation. All rights reserved.
*
*Purpose:
*       strlen returns the length of a null-terminated string,
*       not including the null byte itself.
*
*******************************************************************************/

#include <cruntime.h>
#include <string.h>

#pragma function(strlen)

/***
*strlen - return the length of a null-terminated string
*
*Purpose:
*       Finds the length in bytes of the given string, not including
*       the final null character.
*
*Entry:
*       const char * str - string whose length is to be computed
*
*Exit:
*       length of the string "str", exclusive of the final null byte
*
*Exceptions:
*
*******************************************************************************/

size_t __cdecl strlen (
        const char * str
        )
{
        const char *eos = str;

        while( *eos++ ) ;

        return( eos - str - 1 );
}

size_t :无符号整型(unsigned int)

__cdecl :函数调用约定

七、编程常见的错误

0x00 编译型错误

📚 直接看错误提示信息(双击),解决问题;

或者凭借经验就可以搞定,相对来说简单;

0x01 链接型错误

📚 看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。

一般是 标识符名不存在 或者 拼写错误

0x02 运行时错误

📚 代码明明跑起来了,但是结果是错的;

🔑 借助调试,逐步定位问题,利用本章说的实用调试技巧解决;

0x03 建议

📜 做一个有心人,每一次遇到错误都进行自我总结,积累错误经验!


参考资料:

Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. .

比特科技. C语言基础[EB/OL]. 2021[2021.8.31]. .

📌 本文作者: Foxny

📃 更新记录: 2021.5.27

勘误记录:

📜 本文声明: 由于作者水平有限,本文有错误和不准确之处在所难免,本人也很想知道这些错误,恳望读者批评指正!

本章完。