调试技巧
调试技巧
1.前言
前面我们在【C语言】实用调试技巧中了解调试的意义以及如何进行调试。但一位优秀的程序员不仅要有一定的调试技巧,还要写出好的(易于调试的)代码。所以今天主要分享几道有趣的题目,还有分析const、assert等函数的作用,还有编译出现的错误。
2.开胃菜
int main()
{
int i = 0;
int arr[]={1,2,3,4,5,6,7,8,9,10};
for(i=0;i<=12;i++)
{
arr[i]=0;
printf("hello\n");
}
return 0 ;
}
2.1疑惑
在上面例题中,数组只有10个元素,要对下标从0开始,到12结束的每个元素都进行赋值并打印出来,这是越界访问。我们都知道数组越界访问是不可取的,但如果我们比较倔,偏要试试。那打印出来的结果是什么?是13个hello?
答案是死循环。为什么?在我们预期中,应该是打印13个hello。
肉眼很难看出来,就借助调试。
2.2调试
- 我们打开监视,输入我们观察的变量和数组。
- 紧接着,数组开始越界访问,输入访问的元素。
arr[10]和arr[11]都是随机值,而arr[12]却和i的值相等。这是为什么?等下揭晓。 - 继续调试,我们 发现当i=12时,我们对arr[12]进行赋0,结果i变成0了,循环重新开始了。
- 因此我们可以大胆地猜测,i和arr[12]共用一块内存空间,不信我们看看i和arr[12]的地址。
因为他们公用一块内存空间,所以当arr[12]被赋为0,i被赋为0,i永远不可能达到13(循环停止的条件),所以陷入死循环。
2.3画图分析
画出变量i和数组arr在内存中的存储或许可以更直观地表达这个例子。
我们在函数栈帧的创建和销毁中了解到栈区内函数和局部变量的创建是从高地址开始的,如图是这个例子中数组和变量在内存中的存储。
变量i在数组之前创建,所以放在高地址处,数组中随着下标的增长,地址由低到高。
2.4问题
在i和arr之间恰好是两个整形的空间吗?不一定,在不同的环境下结果是不同的。在vs2019x86环境下,两者之间是两个整形的空间。在VC6.0中,两者之间没有多余的空间。在gcc中,i和arr之间有一个整形的空间。
在Release版本下运行
在Release版本下运行,不会死循环,只循环了13次。为什么?被编译器优化了。我们打印出i和arr的地址后发现,i的地址竟然被调到低地址处,这样就算数组再怎么越界也不能访问到i。
2.5“解决”方法?
有朋友就会把数组定义在变量之前,变量就在低地址处,数组越界就访问不到变量。结果确实是可以打印出13个hello,但会报错,为什么?因为非法访问。那为什么在例题中编译器就不会报错?因为此时编译器忙着打印hello,没空理你,一旦打印停止就会报错,可惜不会停下来,所以才给你非法访问没什么大不了的错觉。
正确的做法就是不要非法访问数组范围外的元素,这样什么事情都不会发生。
推荐书籍
本题的原题来自《C陷阱与缺陷》,推荐大家有时间就啃啃。
这道开胃菜不知道会不会把你喂撑,坚持住,后面还有一些重点。
3.如何写出易于调试的代码
我们知道一名优秀的程序员不仅要会调试,还要会写出易于调试的代码,防患于未然。
3.1什么是优秀的代码
- 代码正常运行
- bug很少
- 效率高
- 可读性高
- 可维护性高
- 注释清晰
- 文档齐全
3.2常见的coding技巧
- 使用assert
- 尽量使用const
- 养成良好的编码风格
- 添加必要的注释
- 避免编码的陷阱
代码风格和注释我们已经讲过了,今天主要讲const和assert。
我们通过几个例子来了解assert和const的作用。
3.3模拟实现strcpy(const和assert)
strcpy是什么
是把一个数组的字符串拷贝到另一个数组的字符串。如图
模拟实现
void my_strlen(char* dest, char* sour)
{
//待填
}
int main()
{
char arr1[20] = { 0 };//拷贝到这个数组
char arr2[20] = "abcdef";//被拷贝的数组
my_strlen(arr1, arr2);
printf("%s",arr1);
return 0 ;
}
注意
我们要保证目标数组的大小一定要大于或等于被拷贝数组的大小。
while(*sour!='\0')
{
*dest = *sour;//将对应得到字符从被拷贝数组拷贝到目标数组
dest++;
sour++;
}
*dest = *sour;//把\0拷贝过去
3.3.1优化版本1
while(*dest++=*sour++)
{
;
}
++操作符的优先级高于*操作符,但因为是后置++,所以先对++的操作数进行解引用,再对操作数+1。当sour=\0时,将其赋给dest,由于赋值表达式的值是左边操作数的值,所以循环的判断表达式为0,结束循环,同时将\0拷贝到目标数组上。
风险
但如果程序员不小心将空指针传给数组,结果会报错。
3.3.2优化版本2(assert)
#include<assert.h>
void my_strlen(char* dest, char* sour)
{
assert(sour!=NULL);
assert(dest != NULL);
while (*dest++ = *sour++)
{
;
}
}
assert的作用
结果
assert会指出为什么错误以及错误在哪里,
有朋友觉得可以用if语句来判断是否是空指针,这当然是可以的,但我觉得用if语句对比assert有个不足的地方就是assert在Release版本中优化了,因为问题已经被解决,而if语句不管在Release和Debug都要进行判断。
3.33优化版本3(const)
问题
如果一个程序员不小心把被拷贝数组和目标数组顺序弄反,然后传给函数,或者在函数内将目标数组拷贝到被拷贝的数组上,而我们并不想被拷贝的数组被改变,怎么办?
const的作用
我们先来了解const的作用。
在const的修饰下,变量的值不能被改变,使变量具有常量的属性。
当我们发现,如果用指针取出变量的地址,通过指针改变变量的值,这种做法是能达到目的的。
但我们并不想让变量的值被改变,我们想,const可以限制住变量,应该也可以限制住指针,使得它所指向的内容不能被修改。
我们又发现一个问题,就是p的值可以发生改变。
我们的初衷是p所指向的内容不能发生变化,但p的值发生变化了,它所指向的内容也随之变化,所以应该也有办法限制住p的值,我们尝试将const写到*右边、p的左边,发现结果限制住了p的值,但它所指向的内容又可以被更改。
由此,我们来总结下const修饰指针变量时,
- const放在*的左边,修饰的是指针指向的内容,不能通过指针 来改变,但指针变量本身可以改变。
- const放在*的右边,修饰的是指针变量本身,表示 指针变量的内容不能被修改,但是指针指向的内容可以通过指针来改变。
解决问题
学会const后,我们就用const修饰被拷贝数组,这样它指向的内容就不能被修改了,并出现前面提及的问题,编译器也会报错,将写代码写迷糊的我们惊醒。
3.3.4最终版本
char*my_strcpy(char * dest,const char*sour)
{
char *res = dest;//因为dest在接下来的循环中会改变,所以将其指向的地址保留下来
assert(dest);//dest和sour如果是空指针的话,断言失败,报错
assert(sour);
while(*dest++=*sour++)
{
;
}
return res;
}
int main()
{
char arr1[20] = {0};
char arr2[20] ="abcdef";
printf("%s",my_strcpy(arr1,arr2));//链式访问:以一个函数的返回值作为另一个函数的参数。
return 0;
}
3.4练习(模拟实现strlen)
这里就不一一讲解了,答案如下
int my_strlen(const char*str )
{
int count = 0;
assert(str);
while(*str++)
{
count++;
}
}
int main()
{
char arr[] = "abcdef";
printf("%d",my_strlen(arr));
return 0 ;
}
4.编译常见的错误
-
编译型错误
直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。(就是简单的语法错误)
-
链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误。
链接型错误不会显示多少行,可以在编译器按CTRL+F搜索你想要的内容。
-
运行时错误
借助调试,逐步定位问题。最难搞。(我们上面例题中出现的错误就是运行时错误)
5.补充
int main()
{
int i =0;
int n = (++i)+(++i)+(++i);
printf("%d",n);
}
在不同的环境下结果不同。在vs2019中,结果为12。
有朋友不明白为什么结果是12,既然我们学了调试,那就用调试的技巧(反汇编)来解决。
-
先按调试,然后转到反汇编
-
主要分析int n = (++i)+(++i)+(++i)这句语句
int n = (++i) + (++i) + (++i);
00AA186C mov eax,dword ptr [ebp-8] //将ebp-8这个地址的值(也就是i)赋值给eax寄存器
00AA186F add eax,1 //把1加到寄存器当中
00AA1872 mov dword ptr [ebp-8],eax //将寄存器的值返回给ebp-8这个地址的值
00AA1875 mov ecx,dword ptr [ebp-8] //将ebp-8内的值赋值给ecx
00AA1878 add ecx,1 //把1加到ecx寄存器中
00AA187B mov dword ptr [ebp-8],ecx //把ecx中的值放回ebp-8这个地址的值
00AA187E mov edx,dword ptr [ebp-8] //将ebp-8的值赋值给edx
00AA1881 add edx,1 //把1加到edx中
00AA1884 mov dword ptr [ebp-8],edx//将edx的值返回ebp-8这个地址的值
00AA1887 mov eax,dword ptr [ebp-8] //把ebp-8这个地址的值赋值给eax
00AA188A add eax,dword ptr [ebp-8] //把ebp-8这个地址的值赋值给eax
00AA188D add eax,dword ptr [ebp-8] //把ebp-8这个地址的值赋值给eax
00AA1890 mov dword ptr [ebp-14h],eax //把eax的值放到ebp-14h这个地址的值(也就是n)
从上面可以知道,编译器是先对i进行++操作,最后再全部进行+操作。
4+4+4=12。
3. 同理,在gcc下这条语句的值是10。原因是先对前两个++i进行++操作,再把前两个++i相加,然后再对i进行++操作,最后加上最后一个i。
3+3+4=10。
6.总结
感谢阅读!