C语言进阶学习笔记七程序执行+调试技巧(实用技巧篇)

Posted 大家好我叫张同学

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C语言进阶学习笔记七程序执行+调试技巧(实用技巧篇)相关的知识,希望对你有一定的参考价值。


一、程序执行篇

程序的翻译环境和执行环境

在ANSIC的任何一种实现中,存在两个不同的环境。
第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。
第2种是执行环境,它用于实际执行代码。


翻译环境

1)组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
2)每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
3)链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

几个概念的理解:
源文件:.c为后缀的文件,比如test.c

目标文件:.obj为后缀的文件,由源文件编译后生成

链接库:库是写好的现有的,成熟的,可以复用的代码。

现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:静态库(.a、.lib)和动态库(.so、.dll)。
windows上对应的是.lib.dll linux上对应的是.a.so

静态库:是因为在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。

整个翻译环境分为两个大的部分:编译 + 链接
编译阶段所需要的编译器,我们可以找到它

链接阶段所需要的链接器,我们也可以找到它

在编译阶段又可以分为以下3个步骤:

预处理、编译、汇编

预处理阶段:

①头文件的包含
②#define定义的符号和宏的替换
③注释的删除(所以我们要大胆写注释!不会影响程序的运行和性能!)
– - 这些都是文本操作

编译阶段:

把c语言代码转换为汇编代码
语法分析、词法分析、语义分析、符号汇总

汇编阶段:

将汇编语言转换为机器语言
生成符号表

链接阶段

把多个目标文件(.obj(windows) / .o(Linux))和链接库进行链接
合并段表
符号表的合并和重定位

执行环境 / 运行环境

程序执行的过程︰
1.程序必须载入内存中。在有操作系统的环境中︰一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
⒉程序的执行便开始。接着便调用main函数。
3.开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack ),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程一直保留他们的值。
4.终止程序。正常终止main函数; 也有可能是意外终止。


①预处理详解

C语言允许在源程序中加入一些“预处理指令”(preprocessing directive), 以改进程序设计环境,提高编程效率。这些预处理指令是由C标准建议的, 但它不是C语言本身的组成部分,
不能用C编译系统直接对它们进行编译(因为编译程序不能识别它们)。必须在对程序进行正式编译(包括词法和语法分析、代码生成、优化等)之前,
先对程序中这些特殊的指令进行“预处理”(preprocess, 也称“编译预处理”或“预编译”)。把预处理指令转换成相应的程序段,
它们和程序中的其他部分组成真正的C语言程序, 对预处理指令进行的预处理工作,
是由称为C预处理器(preprocessor)的程序负责处理的。

在预处理阶段,预处理器把程序中的注释全部删除; 对预处理指令进行处理, 如把#include指令指定的头文件(如stdio.h)的内容复制到#include指令处; 对#define指令,进行指定的字符替换(如将程序中的符号常量用指定的字符串代替), 同时删去预处理指令

预定义符号

__FILE__
//进行编译的源文件 __: 两个下划线
_LINE__
//文件当前的行号
__DATE__
//文件被编译的日期
_TIME__
//文件被编译的时间
_STDC_
//如果编译器遵循ANSI C,其值为1,否则未定义

这些预定义符号都是语言内置的。

实际使用场景举例:创建log日志

#include<stdio.h>
int main()
{
	FILE* pf = fopen("log.txt", "w");
	if (pf == NULL)
	{
		perror("fopen");
		return 1;
	}
	//记录日志信息
	int i = 0;
	for (i = 0; i < 20; i++)
	{
		fprintf(pf, "%s %s %s %d %s\\n", __FILE__, __DATE__, __TIME__, __LINE__, __FUNCTION__);
	}
	//关闭文件
	fclose(pf);
	pf = NULL;
	return 0;
}

程序执行后生成log.txt文件

打开查看,如图

②宏定义

不带参数的宏定义 / #define定义标识符

不带参数的宏定义是比较简单的, 就是用一个指定的标识符(即名字)来代表一个字符串。它的一般形式为

③define标识符字符串

这就是定义符号常量, 例如:

#define PI 3.1415926

(1)宏定义只是用宏名代替一个字符串, 也就是只作简单的置换, 不作正确性检查。
(2)宏定义不是C语句, 不必在行末加分号。
(3)#define 指令出现在程序中的函数的外面, 宏名的有效范围为该指令行起到本源文件结束。通常,#define 指令写在文件开头, 函数之前, 作为文件一部分, 在整个文件范围内有效。
(4)可以用#undef指令终止宏定义的作用域。
(5)在进行宏定义时,可以引用已定义的宏名, 即可以层层置换。
(6)对程序中用双撇号括起来的字符串内的字符, 即使与宏名相同,也不进行置换。
(7)宏定义与定义变量的含义不同, 不分配存储空间。

带参数的宏定义 / #define定义宏

#define机制包括了一个规定,允许把参数替换到文本中,这种实现通常称为宏(macro)或定义宏(definemacro)。

#define name( parament-list ) stuff 

其中的parament-1ist是一个由逗号隔开的符号表,它们可能出现在stuff中。
注意∶参数列表的左括号必须与name紧邻。如果两者之间有任何空白存在,参数列表就会被解释为stuff的一部分。


另一种表述:

④define宏名(参数表)字符串

带参数的宏定义不是进行简单的字符串替换, 还要进行参数替换。
字符串中包含在括号中所指定的参数。例如 :
define S(a,b)a * b
area = S(3, 2);

主要宏替换的实质是符号的替换,并不会提前进行计算,然后进行宏替换
比如刚刚的例子:
S(3 + 1, 2) 实际上应该是 3 + 1 * 2 = 5 而不是 3 + 1 = 4 ,4 * 2 = 8,因为这里仅仅是文本符号的替换,并不会计算出结果后再替换。

#define替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤。

1.在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
⒉替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值替换。
3.最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程。

注意︰

1.宏参数和#define定义中可以出现其他#define定义的变量。但是对于宏,不能出现递归。
2.当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索。

#和##

如何把参数插入到字符串中 ?

#include<stdio.h>
#define PRINT(X) printf("the value of " #X " is %d\\n",X);
int main()
{
	int a = 10;
	int b = 20;
	int c = 30;
	PRINT(a);
	PRINT(b);
	PRINT(c);

	return 0;
}


##的作用

##可以把位于它两边的符号合成一个符号。它允许宏定义从分离的文本片段创建标识符.

#define ADD_TO_SuM(num,value)
sum##num += value;
ADD_TO_SUM(5, 10);//作用是∶给sum5增加10.

带副作用的宏参数

当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。

例如︰

X + 1;//不带副作用X++;//带有副作用

MAX宏可以证明具有副作用的参数所引起的问题。

#include<stdio.h>
#define MAX(X,Y) ((X)>(Y))?(X):(Y) 
int main()
{
	int a = 5;
	int b = 8;
	int m = MAX(a++, b++);//((a++)>(b++))?(a++):(b++)
	printf("%d\\n", m);
	return 0;
}

⑤宏和函数对比

宏通常被应用于执行简单的运算。比如在两个数中找出较大的一个。

#define MAx(a,b) ((a)>(b)?(a): (b))

那为什么不用函数来完成这个任务 ?
原因有二︰

1.用于调用函数和从函数返回的代码可能比实际执行这个小型计算工作所需要的时间更多,所以宏比函数在程序的规模和速度方面更胜一筹。
2.更为重要的是函数的参数必须声明为特定的类型。所以函数只能在类型合适的表达式上使用。反之这个宏怎可以适用于整型、长整型、浮点型等可以用于 > 来比较的类型。宏是类型无关的。

当然和宏相比函数也有劣势的地方︰

1.每次使用宏的时候,一份宏定义的代码将插入到程序中。除非宏比较短,否则可能大幅度增加程序的长度。
2.宏是没法调试的。
3.宏由于类型无关,也就不够严谨。
4.宏可能会带来运算符优先级的问题,导致程容易出现错。 宏有时候可以做函数做不到的事情。比如︰宏的参数可以出现类型,但是函数做不到。

总结:我们在处理简单、不易错的问题时,使用宏可能比使用函数更有优势,但是宏也有自身的缺点和不适用场景,需要谨慎使用。


命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者。那我们平时的一个习惯是︰

把宏名全部大写函数名不要全部大写

常用宏指令

#undef

这条指令用于移除一个宏定义。

#undef NAME
//如果现存的一个名字需要被重新定义,那么它的旧名字首先要被移除。

条件编译

在编译一个程序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的。因为我们有条件编译指令。

比如说︰
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译。

常见的条件编译指令︰

1.#if常量表达式 / / …
#endif //常量表达式由预处理器求值。
如∶
#define DEBUG 1#if DEBUG l l …
#endif

2.多个分支的条件编译#if 常量表达式

/ / …
#elif常量表达式 / / …
#else / / … .
#endif

3.判断是否被定义

#if defined(symbo1)#ifdef symbol
#if ! defined(symbo7)#ifndef symbol

4.嵌套指令

#if defined(os_UNIX)
#ifdef OPTTON1 unix_version_option1();
#endif
#ifdef OPTION2 unix_version_option2(;
#endif
#elif defined (os_MSDOS)
#ifdef OPTION2 msdos_version_option2();
#endif
#endif

文件包含
我们已经知道,#include 指令可以使另外一个文件被编译。就像它实际出现于#inc1ude 指令的地方一样。
这种替换的方式很简单∶预处理器先删除这条指令,并用包含文件的内容替换。这样一个源文件被包含10次,那就实际被编译10次。
头文件被包含的方式 :

本地文件包含
#include "fi1ename "

查找策略︰先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件。如果找不到就提示编译错误。
linux环境的标准头文件的路径 :
/ usr / include
VS环境的标准头文件的路径∶
C : \\Program Files(x86)\\Microsoft visua7 studio 9.0\\vc\\include

库文件包含
#include <filename.h>

查找头文件直接去标准路径下去查找,如果找不到就提示编译错误。
这样是不是可以说,对于库文件也可以使用"”的形式包含﹖答案是肯定的,可以。但是这样做查找的效率就低些,当然这样也不容易区分是库文件还是本地文件了。嵌套文件包含
如果出现这样的场景︰

<> 和 “” 包含头文件的本质区别是:查找策略的不同


二、调试技巧篇

①什么是bug?

bug英文原本含义是昆虫,小虫子。 第一次被发现的导致计算机错误的飞蛾(bug 昆虫),也是第一个计算机程序的错误。
现在bug常用于计算机领域 / 编程中,出现的错误或者漏洞,debug / 调试过程就是消除这些错误或漏洞的过程。

②调试是什么 ? 有多重要 ?

调试:寻找bug的过程

所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧,就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。
一名优秀的程序员是一名出色的侦探。

每—次调试都是尝试破案的过程。
拒绝 - 迷信式调试!!!
调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。

调试的基本步骤

1.发现程序错误的存在
2.以隔离、消除等方式对错误进行定位
3.确定错误产生的原因
4.提出纠正错误的解决办法
5.对程序错误予以改正,重新测试

③debug和release的介绍

Debug通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
Release称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用。

测试人员测试的是release版本
为了能够更好的理解这两者之间的区别,我们用一小段简单代码来实验一下:

#include<stdio.h>
int main()
{
	int i = 0;
	for (i = 1; i < 200; i++)
	{
		printf("%4d  ", i);
		if (i % 10 == 0)
			printf("\\n");
	}
	return 0;
}

1.我们先尝试一下Debug版本,运行一下

我们进入到这个项目工程的路径下面查看:

2.接着我们尝试用release版本,运行一下

同样,我们进入到这个项目工程的路径下面查看:


通过对比debug版本和release版本,我们发现虽然两者的运行结果是一样的,但是生成的可执行程序.exe文件大小却不相同。

原因分析:
debug版本里面包含程序的调试信息,这些信息是用来方便程序员编程中调试bug的。

比如:debug版本下,我们按F10进入调试环节,打开监视窗口,可以动态的监视程序中变量的变化过程。

release版本中往往进行了各种优化使程序在代码大小和运行速度上都是最优的,不包含调试信息,以便用户很好的使用。

比如:release版本下,我们按F10进入调试环节,打开监视窗口,会提示你说变量已被优化掉,因此不可用。(虽然我们继续按F10,后面仍会正常出现i的变化)

此外,release版本会对程序本身进行相应的优化。

调试状态下,进入反汇编

④windows环境调试介绍

1.调试环境的准备
在环境中选择debug选项,才能使代码正常调试。

2.学会快捷键

F5 启动调试,经常用来直接调到下一个断点处。
F9 创建断点和取消断点断点的重要作用,可以在程序的任意位置设置断点。这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去。(一般是跟F5配合使用)
F10 逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句。
F11 逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部(这是最长用的)。
CTRL + F5 开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用。
shift + F11 跳出函数内部,来到函数外部区域
shift + F5 停止调试(需要在调试过程中进行停止调试,如果本身未启动调试,则不能停止调试)
(注意:笔记本电脑和外接蓝牙键盘经常需要配合Fn来使用,比如原来仅需要CTRL + F5 变为:CTRL + F5 + Fn)

妙用:F9新增断点的同时可以添加相应的条件,快速跳到相应条件处。



⑥如何写出好(易于调试)的代码?

优秀的代码∶

1.代码运行正常
2.bug很少
3.效率高
4.可读性高
5.可维护性高
6.注释清晰
7.文档齐全

常见的coding技巧 :

1.使用assert
2.尽量使用const
3.养成良好的编码风格
4.添加必要的注释
5.避免编码的陷阱。

assert 断言 (报错)
头文件:assert.h
assert(表达式) 若表达式为真,则assert不报错;若表达式为假,则assert报错

const int num = 10;
const int* p = &num;
*p = 20;//error
//  1)const放在指针变量的*左边时,修饰的是*p,也就是说,不能通过p来改变*p的值
//  2)const放在指针变量的*右边时,修饰的是指针变量p本身,p不能被改变
const int num = 10;
int n = 30;
int* const p = &num;
*p = 20;
p = &n;//error

编程常见的错误常见的错误分类∶

1.编译型错误
直接看错误提示信息(双击),解决问题。或者凭借经验就可以搞定。相对来说简单。
2.链接型错误
看错误提示信息,主要在代码中找到错误信息中的标识符,然后定位问题所在。一般是标识符名不存在或者拼写错误。
3.运行时错误
借助调试,逐步定位问题。最难搞。

温馨提示 :
做一个有心人,积累排错经验。
讲解重点 : 介绍每种错误怎么产生,出现之后如何解决。

以上是关于C语言进阶学习笔记七程序执行+调试技巧(实用技巧篇)的主要内容,如果未能解决你的问题,请参考以下文章

C语言进阶学习笔记二指针的进阶(练习篇)

C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!

C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!

C语言初阶笔记程序员必须要会的实用调试技巧(含库函数strcpy和strlen的模拟实现)!!

C语言VS2017 - 实用调试技巧

C语言基础学习笔记+ C语言进阶学习笔记总结篇(坚持才有收获!)