笔记|笔记17(C语言 实用调试技巧)

目录
注:
调试是什么?调试的重要性?
调试的基本步骤
Debug和Release的介绍
Windows环境调试介绍(VS)
1.调试环境的准备
2.学会快捷键
3.调试的时候查看程序当前信息
查看临时变量的值
一些调试的实例
实例一
实例二( + 一些关于栈区的说明)
如何写出好(易于调试)的代码
示范
一点概念
实现1
解析
实现2(进阶)
P1:assert的使用
P2:const的使用
知识点 - const的作用
P3:返回值为[char*]
模拟实现strlen函数 - 即my_strlen
编程常见的错误
常见的错误分类
编译型错误
链接型错误
运行时错误

注: 本笔记参考B站up鹏哥C语言的视频


推荐一些书籍:
《明解C语言》初级 / 高级
《C和指针》
《C陷阱和缺陷》
《C语言深度解剖》- 这本书有点错误
《C primer plus》 - 太厚
《谭浩强的C语言》- 通俗易懂,代码风格差,看讲解,不建议模仿代码

什么是bug?
ps:第一次被发现的导致计算机错误的飞蛾,也是第一个计算机程序错误
调试是什么?调试的重要性? ||| 调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序和电子仪器设备中程序错误的一个过程。

调试的基本步骤
  • 发现程序错误的存在 ( 1.程序员自己解决2.测试人员测试软件3.用户反馈 - 严重)
  • 以隔离、消除等方式对错误进行定位
  • 确定错误发生的原因
  • 提出纠正错误的解决办法
  • 对程序错误予以改正,重新测试

Debug和Release的介绍 ||| Debug 通常为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序
||| Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好的使用
在VS 2022 上就是这个窗口:
笔记|笔记17(C语言 实用调试技巧)
文章图片

注:Debug版本要相对更大一点,而Release版本要相对小一点。因为Debug版本包含了调试信息,并且不作任何优化。Release版本无法调试。(ps:测试人员测试的是Release版本,即使Debug版本没问题,也不代表Release版本没问题!)

Windows环境调试介绍(VS) ||| 注:Linux开发环境调试工具是gdbgdb英文指南Bug词条(英文版)
1.调试环境的准备 在 VS 2022 上就是选择这个Debug版本。在这个版本下才可以正常调试。
笔记|笔记17(C语言 实用调试技巧)
文章图片

2.学会快捷键 笔记|笔记17(C语言 实用调试技巧)
文章图片

---
F5 - 启动调试
||| 启动调试,经常用来直接跳到下一个断点处
--- (F5 和 F9 经常一起使用,方便快速到达出推测的出现Bug的地方)
F9 - 设置 / 取消断点
||| 创建断点和取消断点 断点的重要作用,可以在程序的任意位置设置断点。这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。
概念引入 - 断点
程序通过 F5 执行到断点处,就会停下。如:
笔记|笔记17(C语言 实用调试技巧)
文章图片

此时对应的监视窗口显示:
笔记|笔记17(C语言 实用调试技巧)
文章图片

通过该窗口可以明显发现程序没有继续执行下去。
同时
可以通过右击断点等方式设置断点的条件等。
笔记|笔记17(C语言 实用调试技巧)
文章图片

方便应对各种调试要求。
---
F10 - 逐过程
||| 逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者一条语句。
---
F11 - 逐语句
||| 逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最常用的)。
---
Ctrl + F5 - 开始执行不调试
||| 开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用。
---
Fn - 辅助功能键
||| 如果按F5等按键没有反应时,可以试着使用Fn + F5,Fn + F10等,或者关闭Fn按键。
想要知道更多的快捷键,推荐MrLisky的博客
3.调试的时候查看程序当前信息 查看临时变量的值
先按F10开始调试,再打开窗口:
笔记|笔记17(C语言 实用调试技巧)
文章图片


||| 断点,可以查看所有断点,并且可以通过窗口取消断点。
笔记|笔记17(C语言 实用调试技巧)
文章图片


---
||| 监视,观察程序内的相关信息,就是上面出现过的这个窗口。(注:放入的表达式需要合理)
笔记|笔记17(C语言 实用调试技巧)
文章图片

---
||| 自动窗口,随着调试的进行,会自动放入执行的相关表达式。但是,自动窗口不可以手动删除监视对象,而是自动窗口自动调整。
---
||| 局部变量,这个窗口也是自动放入变量,并且自动调整监视变量。
---
||| 内存,该窗口显示的是地址,如(仅供参考):
笔记|笔记17(C语言 实用调试技巧)
文章图片

注意:内存数据是以十六进制展现的。而每行展现的内存数据可以通过右上角调整
笔记|笔记17(C语言 实用调试技巧)
文章图片

想要观察指定变量时,可以通过正上方调整
笔记|笔记17(C语言 实用调试技巧)
文章图片

---
||| 反汇编,可以观察代码对应的汇编代码。(ps:右击鼠标也可以找到反汇编)
---
||| 寄存器,可以观察每一个寄存器。(ps:也可以通过监视窗口观察)
---
||| 调用堆栈,反馈的是函数调用逻辑。
例:
代码
void test2() { printf("Hello\n"); } void test1() { test2(); } void test() { test1(); } int main() { test(); return 0; }

通过调试,按F11进入函数内部,打开调用堆栈窗口,当执行到这一步时:
笔记|笔记17(C语言 实用调试技巧)
文章图片

对应的窗口显示:
笔记|笔记17(C语言 实用调试技巧)
文章图片

其中,函数的出现和函数调用逻辑相对应。到这一步,窗口的显示和堆栈操作类似。
接下来继续执行代码,当代码跑到这一步时:
笔记|笔记17(C语言 实用调试技巧)
文章图片

对应的窗口显示:
笔记|笔记17(C语言 实用调试技巧)
文章图片

窗口也是从顶上往下依次减少函数调用的显示。

一些调试的实例 实例一 实现代码:求1! + 2! + 3! + …… + n!; 不考虑溢出。
错误示范
int main() { int n = 0; scanf("%d", &n); //1! + 2! + 3! //1+ 2+ 6 = 9 - 目标 int i = 0; int j = 0; int ret = 1; int sum = 0; for ( j = 1; j <= n; j++) { for (i = 1; i <= j; i++) { ret *= i; } sum += ret; } printf("%d\n", sum); return 0; }

这串代码没有语法错误,但是存在运行错误。需要调试解决!
开始调试,输入需要调试的数字(注意:数字不宜太大)
笔记|笔记17(C语言 实用调试技巧)
文章图片

当计算3!时,发现
笔记|笔记17(C语言 实用调试技巧)
文章图片

这里 ret = 12 ,明显不符合 3! = 9 。在 3!处发现问题,利用断点调试回去。
重新启动调试,设置断点条件,当 j == 3 时触发断点:
笔记|笔记17(C语言 实用调试技巧)
文章图片

按F5,执行调试
笔记|笔记17(C语言 实用调试技巧)
文章图片

打开监视窗口
笔记|笔记17(C语言 实用调试技巧)
文章图片

发现问题,ret == 2 ,即 变量ret 没有重置。
正确代码
int main() { int n = 0; scanf("%d", &n); //1! + 2! + 3! //1+ 2+ 6 = 9 int i = 0; int j = 0; int sum = 0; for ( j = 1; j <= n; j++) { int ret = 1; for (i = 1; i <= j; i++) { ret *= i; } sum += ret; } printf("%d\n", sum); return 0; }

总结:
  • 解决问题时,应该拥有预期想法,明白代码的每一步可以产生什么结果。
  • 当不符合预期时,就找到问题了。

实例二( + 一些关于栈区的说明) (推荐书籍:《C陷阱和缺陷》)
int main() { int i = 0; int arr[10] = { 1,2,3,4,5,6,7,8,9,10 }; //下标:0 - 9 for ( i = 0; i <= 12; i++) { arr[i] = 0; printf("Hello\n"); } return 0; }

问题:代码运行的结果是什么?
结果:
笔记|笔记17(C语言 实用调试技巧)
文章图片

报告了一个错误,越界访问了。此时在看窗口,会发现打印了13个“Hello”后无法继续。(这个地方似还有一种可能,即出现死循环的现象)
笔记|笔记17(C语言 实用调试技巧)
文章图片

原因
进行调试
笔记|笔记17(C语言 实用调试技巧)
文章图片

当 i = 13 时,报错:
笔记|笔记17(C语言 实用调试技巧)
文章图片

这里报告 arr类型 出现了栈溢出
注:这里还有一种可能,可能会发现当 i 准备给 arr[10]/arr[11]/arr[12] 赋值时,arr[10]/arr[11]/arr[12] 值就为12,这次循环结束后,arr[12] 和 i 同时重新赋值为 0,继续循环 - 即进入死循环,出现这种情况的原因是:i 和 arr[12]的地址是相同的。
原因:i 越界访问了不属于arr数组的地址,而这个地址恰巧是 i 自己的地址,当改变 arr[10]/arr[11]/arr[12] 时,i 也发生了改变。
1 2 3 4 5 6 7 8 9 10 11 12 13
0 1 2 3 4 5 6 7 8 9 i i i
之所以不会像另一种情况一样报错,是因为程序处在死循环中。
扩展:
1.i 和 arr 都是局部变量,局部变量是放在栈区上的。
栈区内存的使用习惯是:
先使用高地址空间,再使用低地址空间。

2.数组随着下标增长,地址是由低到高变化的。
高地址
i ↑(越界)
↑(越界)
9
8
7
6
arr 5
4
3
2
1
0
低地址
所以如果反过来先定义 arr ,就不会出现这种情况。
ps:如果是Release版本,程序执行时,不会出现死循环现象 - Release版本是优化过的。
在Release版本下:i 的地址会被存储在 arr 的地址下。

如何写出好(易于调试)的代码 优秀的代码:
  1. 代码运行正常
  2. bug很少
  3. 效率高
  4. 可读性高
  5. 可维护性高
  6. 注释清晰
  7. 文档齐全
常见的coding技巧(要预防发生错误):
  1. 使用assert(断言)- 方便程序员发现问题
  2. 尽量使用const
  3. 养成良好的代码风格
  4. 添加必要的注释
  5. 避免编码的陷阱

示范 ||| 模拟实现库函数:strcpy - 字符串拷贝
一点概念 函数定义
笔记|笔记17(C语言 实用调试技巧)
文章图片

strcpy函数的两个参数:1,目标空间的起始地址2.源空间的起始地址
库函数strcpy的应用
#include int main() { char arr1[20] = "xxxxxxxxxxxxxx"; char arr2[] = "Hello\n"; strcpy(arr1,arr2); return 0; }

启动调试
笔记|笔记17(C语言 实用调试技巧)
文章图片

此时开启监视
笔记|笔记17(C语言 实用调试技巧)
文章图片

发现,strcpy函数在执行时,会连同‘ \0 ’一起拷贝。
实现1 1.不太好的版本
void my_strcpy(char* dest, char* src) { while (*src != '\0')//这个循环没有拷贝'\0' { *dest = *src; dest++; src++; } *dest = *src; //拷贝'\0' }int main() { char arr1[20] = "xxxxxxxxxxxxxx"; char arr2[] = "Hello\n"; my_strcpy(arr1, arr2); printf("%s\n", arr1); return 0; }

2.函数改进1
void my_strcpy(char* dest, char* src) { while (*src != '\0') { *dest++ = *src++; } *dest = *src; }

3.函数改进2
void my_strcpy(char* dest, char* src) { while (*dest++ = *src++) { ; } }

---
解析
while (*dest++ = *src++) { ; }

这串代码用到了表达式,表达式的结果依次是'H' 'e' 'l' 'l' 'l' 'o' '\0',只有结果不是' 0 ',循环判断就是 真 ,循环继续。当' \0 '赋值完成时,表达式结果为 ' \0 '(' \0 '的ASCII值为0),循环判断为 假 。这样,就即拷贝了' \0 ',又使得循环停止。
ps:++ 无论是前缀还是后缀,优先度都高于 = ,但是后缀++本来就是后执行的。
---
但是这串代码还是有问题,譬如没有优化关于空指针的问题(空指针无法解引用)等。
---
实现2(进阶) P1:assert的使用
void my_strcpy(char* dest, char* src) { assert(src != NULL); //断言 assert(dest != NULL); while (*dest++ = *src++) { ; } }

  • [assert(src != NULL)] :当返回值为时,指令通过;返回值为时,报错。
  • 当检测到 src 为空指针时,assert指令可以让系统报错
笔记|笔记17(C语言 实用调试技巧)
文章图片

通过这样的方式,就可以预防空指针问题。
当我们不期望某件事情发生时,[assert]指令可以及时地对错误进行反馈。

P2:const的使用
观察库函数strcpy,发现库函数指令的描述中,出现了[const]
笔记|笔记17(C语言 实用调试技巧)
文章图片

思考:这里const的作用?
如果出现这种情况:
while (*src++ = *dest++) { ; }

把 [src]和 [dest] 写反了,这时执行程序,发现:
笔记|笔记17(C语言 实用调试技巧)
文章图片

程序执行并且报错了。但是,拷贝还是执行了,arr2内放入了arr1的内容。
原因:数组arr2太小了,放不下数组arr1的内容。
既然如此,就要保护arr2,就要使用 const 。
加入关键字const后,发现
笔记|笔记17(C语言 实用调试技巧)
文章图片

此时执行程序,出现错误:
笔记|笔记17(C语言 实用调试技巧)
文章图片

这种情况在程序输入正确的情况下是不会发生的
笔记|笔记17(C语言 实用调试技巧)
文章图片

知识点 - const的作用
关键字const:
  • 修饰变量,被修饰的变量被称为常变量,常变量不可被修改,但是本质上是变量
int main() { const int num = 10; int* p = # *p = 20; printf("%d\n", num); return 0; }

问:打印结果是多少?
打印结果:20
这里就存在一个问题,为什么被 const 修改的变量num的值可以被改变。
解析:
如果我们直接改变num的值:
笔记|笔记17(C语言 实用调试技巧)
文章图片

发现报错了。
这就是说,直接修饰变量num是不行的,但是通过指针可以绕过const的限制。
打个比方,就如果锁上门比如人进来,但是窗户没有被锁上,还是可以通过翻窗户的方式进来。
但是,这就违背了我们设置const的初衷,我们是不希望num的值可以被改变而设置了const的。
---
解决
笔记|笔记17(C语言 实用调试技巧)
文章图片

在设置指针变量*p时,加入const修饰,就会发现报错。
笔记|笔记17(C语言 实用调试技巧)
文章图片


const放在 * 左边,修饰 *p,这意味着*p指向的内容就不可以通过p来改变了。这就是锁了门也封住了窗。(ps:写成[int* const p = &num]是同样的效果)
注意1:
  • const修饰指针变量时,如果const放在 * 左边,修饰的是 *p ,表示指针指向的内容是不能通过指针来改变的(和指向对象无关)。但是指针变量本身是可以修改的。
num p 是指针变量
*p 是指针指向的内容
p
这里的const限制了*p,但是没有限制p,p是可以更改的。即p可以指向其它变量。
笔记|笔记17(C语言 实用调试技巧)
文章图片


这里p指向了变量n,并且没有报错。
那么如果const放在 * 右边又是怎么样呢?
int main() { const int num = 10; int* const p = # int n = 100; p = &n; printf("%d\n", num); return 0; }

运行代码,发现报错:
笔记|笔记17(C语言 实用调试技巧)
文章图片

观察代码:
笔记|笔记17(C语言 实用调试技巧)
文章图片

这里const离p近,修饰p,即p不可以被改变,但是*p可以被改变。
注意2:
  • const修饰指针变量时,如果const放在 * 右边,修饰的是 p - 指针变量,表示指针变量不能被改变的(和指向对象无关)。但是指针变量指向的内容是可以被改变的。
小结const 修饰指针
设:
  1. 【int* m = 10】
  2. 【int* n = 100】
如果:
  1. 【int* p = &m】
  2. 【*p = 0】不可以执行 →【int const *p = &m】,此时【p = &n】仍然可以执行
  3. 要求【p = &n】不可执行 → 【int * const p = &m】→ 此时【*p = 0】可以执行
  4. 如果【int const * const p】→ 【p = &n】、【*p = 0】不可执行

P3:返回值为[char*]
再次观察库函数写法,发现strcpy函数的返回类型是[char*]
笔记|笔记17(C语言 实用调试技巧)
文章图片

原因:
  • 库函数strcpy其实返回的是目标空间的起始地址。
笔记|笔记17(C语言 实用调试技巧)
文章图片

注意这里的返回值是目标空间的起始地址。
所以在设计[my_strcpy]时,也要返回目标空间的起始地址。如:
char* my_strcpy(char* dest, const char* src) { assert(src != NULL); //断言 assert(dest != NULL); char* ret = dest; while (*dest++ = *src++) { ; } return ret; //返回目标空间的起始地址 }

因为返回值是[char*],所以[my_strcpy]可以直接作为printf函数的参数:
int main() { char arr1[20] = "xxxxxxxxxxxxxx"; char arr2[] = "Hello\n"; printf("%s\n", my_strcpy(arr1, arr2)); return 0; }

其中的 [ printf("%s\n", my_strcpy(arr1, arr2)) ] 就涉及到了链式访问

模拟实现strlen函数 - 即my_strlen 笔记|笔记17(C语言 实用调试技巧)
文章图片

#include size_t my_strlen(const char* str) { //assert(str != NULL); assert(str); int count = 0; while (*str++) { count++; } return count; } int main() { char arr[] = "Hello\0"; int len = my_strlen(arr); printf("%d\n", len); return 0; }

ps:
  • int 既可以表示正数,也可以表示负数
  • size_t 其实是unsigned int
小知识:如果观察一些参考代码,会发现 _cdecl 的存在,_cdecl 被称为函数调用约定。函数在调用时传参的顺序等一些细节的规则都是由函数调用约定决定的。
笔记|笔记17(C语言 实用调试技巧)
文章图片



编程常见的错误 常见的错误分类 编译型错误
||| 直接看错误提示信息(双击可定位),解决问题。或者凭借经验就可以搞定。相对来说简单。
---
链接型错误
||| 看错误提示信息,主要在代码中找到错误信息的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误。(如:LNK20 无法解析的外部符号……)
---
运行时错误
||| 借助调试,逐步定位问题,最难搞。
【笔记|笔记17(C语言 实用调试技巧)】比如:在上面的my_strlen函数中,出现:[return count + 8; ]这种代码。

    推荐阅读