【C语言】预处理&&编译链接&&调试技巧详解
主页:醋溜马桶圈-CSDN博客
目录
1.预处理
1.1 预定义符号
__FILE__ //进行编译的源文件
__LINE__ //文件当前的行号
__DATE__ //文件被编译的日期
__TIME__ //文件被编译的时间
__STDC__ //如果编译器遵循ANSI C,其值为1,否则未定义
这些预定义符号都是语言内置的
举个例子:
1.2 #define
#define是一种预处理指令,他有两种用法:
- #define 定义常量(标识符)
- #define 定义宏
1.2.1 #define 定义标识符
语法:
#define name stuff
举个例子:
#define 是完全替换,比如
所以在定义的时候,为了强调他是一个整体,需要自己带上括号:
注意:由于是完全替换,在define定义标识符的时候,不要在最后加 ; 否则替换的时候会将 ; 也替换过去,会导致语法错误
1.2.2 #define 定义宏
#define 机制包括了一个规定,允许把参数替换到文本中,这种实现通常会被解释为宏(macro)或定义宏(define macro)
下面是宏的声明方式:
#define name( parament-list ) stuff
其中的parament-list是一个由逗号隔开的符号表,他们可能出现在stuff中
注意:
参数列表的左括号必须与name紧邻
如果两者之间有任何空白存在,参数列表就会被释解释为stuff的一部分
如:
#define定义宏也是完全替换,比如:
为了防止出现失误,我们在声明的时候需要加上括号:
我们在写宏的时候,如果逻辑需要,我们可以加上足够多的括号来使宏变得完整
1.2.3 #define 替换规则
在程序中扩展#define定义符号和宏时,需要涉及几个步骤
- 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,他们首先被替换
- 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换
- 最后,再次对结果文件进行扫描,看看它是否包含任何由#define定义的符号。如果是,就重复上述处理过程
注意:
- 宏参数和#define定义中可以出现其他#define定义的符号。但是对于宏,不能出现递归
- 当预处理器搜索#define定义的符号的时候,字符串常量的内容并不被搜索
1.2.4 #和##
1.2.4.1 # 的作用
如何把参数插入到字符串中?、
我们发现字符串是有自动连接的特点的
假设有这样的代码:
我们如何用宏来实现printf的功能呢,这里我们使用#
他的替换是周怎么完成的呢
这里只有当字符串作为宏参数的时候才可以把字符串放在字符串中
使用#,把一个宏参数变成对应的字符串
比如:代码中的#N会被预处理器处理为:“N”
所以“#N”即被处理为““N””
1.2.4.2 ## 的作用
##可以把位于他两边的符号合成一个符号
他允许宏定义从分离的文本片段创建标识符
注意:这样的连接必须产生一个合法的标识符,否则其结果就是未定义的
1.2.5 带有副作用的宏参数
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么在使用这个宏的时候就可能出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的有永久性效果
x+1;//不带副作用
x++;//带副作用
MAX宏可以证明具有副作用的参数所引起的问题
这段代码输出的结果是什么?
这里我们得知道预处理之后的结果是什么:
这段代码是证明执行的呢?
1.2.6 宏和函数的对比
宏通常被应用于执行简单的运算
比如在两个数中找出较大的一个
#define MAX(a, b) ((a)>(b)?(a):(b))
那为什么不用函数来完成这个任务?
原因有二:
- 用于调用函数和从函数返回的代码可能实际执行这个小型计算工作所需要的时间更多
所以宏比函数在程序的规模和速度方面更胜一筹 - 更为重要的是函数的参数必须声明为特定的类型
所以函数只能在类型合适的表达式上使用
宏是类型无关的
宏的缺点:当然和函数相比,宏也有劣势的地方:
- 每次使用宏的时候,一份宏定义的代码将插入到程序中,除非宏比较短,否则可能大幅度增加程序的长度
- 宏是没法调试的
- 宏由于类型无关,也就不够严谨
- 宏可能会带来运算符优先级的问题,导致过程容易出现错误
宏有时候可以做函数做不到的事情,比如:宏的参数可以出现类型,但是函数做不到
对比
建议:
如果逻辑比较简单,可以使用宏来实现
如果计算逻辑比较负责,那么就使用函数实现
1.2.7 内联函数
C99之后,C++引入了内联函数的概念 inline关键字
内联函数具有函数和宏的双重优点:
- 内联函数是函数
- 内联函数又像宏一样,在调用的地方展开
1.2.8 命名约定
一般来讲函数的宏的使用语法很相似。所以语言本身没法帮我们区分二者
那我们平时的一个习惯是:
- 把宏名全部大写 //MAX
- 函数名不要全部大写 //Max
1.3 #undef
这条指令用于移除一个宏定义
1.4 命令行定义
许多C的编译器提供了一种能力,允许在命令行中定义符号,用于启动编译过程
例如:当我们根据同一个源文件要编译出一个程序的不同版本的时候,这个特性有点用处
(假定某个程序中声明了一个某个长度的数组,如果机器内存有限,我们需要一个很小的数组,但是另外一 个机器内存大写,我们需要一个数组能够大写。)
1.5 条件编译
在编译一个程序序的时候我们如果要将一条语句(一组语句)编译或者放弃是很方便的,因为我们有条件编译指令
条件编译就是:满足条件就编译,不满足条件就不编译
比如说:
调试性的代码,删除可惜,保留又碍事,所以我们可以选择性的编译
1.5.1 常见的条件编译指令
1.
#if 常量表达式
//...
#endif
//常量表达式由预处理器求值
如:
#define __DEBUG__ 1
#if __DEBUG__
//..
#endif
表达式为真则编译,为假则不编译
2.多个分支的条件编译
#if 常量表达式
//...
#elif 常量表达式
//...
#else
//...
#endif
只会选择以一个#if或者#elif执行
3.判断是否被定义
#if defined(symbol)
#ifdef symbol
#if !defined(symbol)
#ifndef symbol
判断(symbol)是否被定义过,如果被定义过则执行代码
4.嵌套指令
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#ifdef OPTION2
unix_version_option2();
#endif
#elif defined(OS_MSDOS)
#ifdef OPTION2
msdos_version_option2();
#endif
#endif
注意:#if 与 #endif 是配套使用的,同时出现,同时消失
1.6 头文件包含
我们已经知道,#include 指令可以使另外一个文件被编译,就像它实际出现于 #include 指令的地方一样
这种替换的方式很简单:
预处理器先删除这条指令,并用包含文件的内容替换
这样一个源文件被包含10次,那就实际被编译10次
1.6.1 头文件被包含的方式
头文件的包含一般有两种方式:
1.包含本地文件(自己的.h文件)
#include "xxx.h"(用双引号)2.包含标准库中的文件
#include <xxx.h> (用尖括号)
查找策略:
先在源文件所在目录下查找,如果该头文件未找到,编译器就像查找库函数头文件一样在标准位置查找头文件
#include包括""和<>这两种情况
""是在用户工作目录下寻找(用户的工作目录是通过编译器指定的)
<>是找系统标准库函数,通过系统环境变量指定系统库目录
如果找不到就提示编译错误
1.6.2 嵌套文件的包含
如果出现这样的场景
comm.h和comm.c是公共模块
test1.h和test1.c使用了公共模块
test2.h和test2.c使用了公共模块
test.h和test.c使用了test1模块和test2模块。
这样最终程序中就会出现两份comm.h的内容
这样就造成了文件内容的重复
我们可以用条件编译解决这个问题
每个头文件的开头写:
#ifndef __TEST_H__
#define __TEST_H__
//头文件的内容
#endif //__TEST_H__
或者
#pragma once
就可以避免头文件的重复引入
2.程序环境 编译和链接
2.1 翻译环境和执行环境
在ANSI C的任何一种实现环境中,存在两个不同的环境
- 第一种是翻译环境,在这个环境中源代码被转换为可执行的机器指令
- 第二种是执行环境,它用于实际执行代码
2.2 编译和链接
2.3 翻译
- 组成一个程序的每个源文件通过编译过程分别抓换成目标代码(object code)
- 每个目标文件文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序
- 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也 链接到程序中
2.3.1 翻译的几个阶段
2.3.1.1 预编译
预编译的过程:
- 注释的替换(删除) 注释被替换成一个空格
- 头文件的包含 #include < >
- #define 符号的替换
所有的预处理指令都是在预编译阶段处理的 (文本操作)
2.3.1.2 编译
词法分析
假如有下面一段代码
array[index] = (index+4)*(2+6)
将源代码程序输入扫描器,扫描器的任务就是简单的进行词法分析,把代码中的字符分割成一系列的记号(关键字、标识符、字面量、特殊字符等)
上面程序进行词法分析后得到了16个记号:
生成一棵语法树
符号汇总
一个工程中可以包含多个.c文件,如何在一个.c文件中调用另一个.c文件中的函数呢
这里我们了解一个概念叫做符号汇总
假设有这样的代码
进行符号汇总
注意:符号汇总只能汇总全局变量
2.3.1.3 汇编
把汇编代码翻译成了二进制的指令,生成了.o文件(目标文件)
生成符号表
假设给汇总的符号给上地址,生成一个符号表
2.4 链接
2.4.1 合并段表
2.4.2 合并符号表和重定位
2.5 运行
程序执行的过程:
- 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成
- 程序的执行便开始。接着便调用main函数
- 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过程中一直保留他们的值
- 终止程序。正常终止main函数;也有可能是意外终止
3.实用调试技巧
3.1 什么是bug?
作为程序员,每天被bug支配着,当然应该了解下对手了。
bug原意本来为昆虫的意思,1947年9月9日,葛丽丝·霍普(Grace Hopper)发现了第一个电脑上的bug。当在Mark II计算机上工作时,整个团队都搞不清楚为什么电脑不能正常运作了。经过大家的深度挖掘,发现原来是一只飞蛾意外飞入了一台电脑内部而引起的故障(如图所示)。这个团队把错误解除了,并在日记本中记录下了这一事件。也因此,人们逐渐开始用“bug”来称呼计算机中的隐错。
3.2 调试是什么?有多重要?
所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了;如果问心有愧,就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径
顺着这条途径顺流而下就是犯罪,逆流而上就是真相
一名优秀的程序员是一名出色的侦探
每一次尝试都是尝试破案的过程
调试(英语:Debugging / Debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序
错误的一个过程。
3.3 调试的基本步骤
- 发现程序错误的存在
- 以隔离、消除等方式对错误进行定位
- 确定错误产生的原因
- 提出纠正错误的解决办法
- 对程序错误予以改正,重新测试
发现程序错误:程序员自己、测试人员、用户
3.4 debug和release的介绍
- Debug 通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序
- Release 称为发布版本,它往往是进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户很好地使用
测试人员站在用户的角度,测试的是发布版本
代码:
#include <stdio.h>
int main()
{
char *p = "hello world";
printf("%s\n", p);
return 0;
}
上述代码在Debug环境的结果展示
上述代码在Release环境的结果展示
Debug和Release反汇编展示对比
所以我们说调试就是在Debug版本的环境中,找代码中潜伏的问题的一个过程
那编译器进行了哪些优化呢? 请看如下代码:
#include <stdio.h>
int main()
{
int i = 0;
int arr[10] = {0};
for(i=0; i<=12; i++)
{
arr[i] = 0;
printf("hehe\n");
}
return 0;
}
如果是 debug 模式去编译,程序的结果是死循环。
如果是 release 模式去编译,程序没有死循环。
那他们之间有什么区别呢?
就是因为优化导致的
3.5 windows环境调试介绍
3.5.1 调试环境的准备
在环境中选择 debug 选项,才能使代码正常调试
3.5.2 学会快捷键
最常使用的几个快捷键:
F5
- 启动调试,经常用来直接跳到下一个断点处
F9
- 创建断点和取消断点
- 断点的重要作用,可以在程序的任意位置设置断点
- 这样就可以使得程序在想要的位置随意停止执行,继而一步步执行下去
F10
- 逐过程,通常用来处理一个过程,一个过程可以是一次函数调用,或者是一条语句
F11
- 逐语句,就是每次都执行一条语句,但是这个快捷键可以使我们的执行逻辑进入函数内部
CTRL + F5
- 开始执行不调试,如果你想让程序直接运行起来而不调试就可以直接使用
在笔记本电脑上可以配合使用FN键
3.5.3 调试的时候查看程序当前信息
3.5.3.1 查看临时变量的值
在调试开始之后,用于观察变量的值
3.5.3.2 查看内存信息
在调试开始之后,用于观察内存信息
3.5.3.3 查看调用堆栈
通过调用堆栈,可以清晰的反映函数的调用关系以及当前调用所处的位置
3.5.3.4 查看汇编信息
3.5.3.5 查看寄存器信息
可以查看当前运行环境的寄存器的使用信息
多多动手,尝试调试,才能进步
- 一定要熟练掌握调试技巧
- 初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试。
- 我们所讲的都是一些简单的调试。
- 以后可能会出现很复杂调试场景:多线程程序的调试等
- 多多使用快捷键,提升效率
3.6 如何写出好(易于调试)的代码
- 代码运行正常
- bug很少
- 效率高
- 可读性高
- 可维护性高
- 注释清晰
- 文档齐全
3.6.1 常见的coding技巧
- 使用assert(断言)
- 尽量使用const
- 养成良好的编码风格
- 添加必要的注释
- 避免编码的陷阱
3.6.1.1 assert
assert函数是C语言标准库<assert.h>中的一个函数,函数原型为:
void assert(int expression)
该函数输入参数只有一个int类型参数,返回值为void类型
assert宏的原型定义在<assert.h>中,其作用是如果它的条件返回错误,则终止程序执行
用法总结与注意事项
- 在函数开始处检验传入参数的合法性
- 每个assert只检验一个条件,因为同时检验多个条件时,如果断言失败,无法直观的判断是哪个条件失败
- 不能使用改变环境的语句,因为assert只在Debug中生效,如果这么做,会使用程序在真正运行时遇到问题
- assert和后面的语句应空一行,以形成逻辑和视觉上的一致感
- 有的地方,assert不能代替条件过滤
3.6.1.2 const
常量指针
常量指针是指针指向的内容是常量,可以有以下两种定义方式
const int* n;
int const* n;
常量指针说的是不能通过这个指针改变变量的值,但是可以通过其他的引用来改变变量的值
int a=5;
const int* n=&a;
a=6;
常量指针指向的值不能改变,但是这并不意味着指针本身不能改变,常量指针可以指向其他的地址
int a=5;
int b=6;
const int* n=&a;
n=&b;
指针常量
指针常量是指指针本身是个常量,不能再指向其他的地址,写法如下
int*const n;
需要注意的是,指针常量指向的地址不能改变,但是地址中保存的数值是可以改变的,可以通过其他指向改地址的指针来修改
int a=5;
int*p=&a;
int* const n=&a;
*p=8;
区分常量指针和指针常量
区分常量指针和指针常量的关键就在于星号(*)的位置,我们以星号为分界线
- 如果const在星号的左边,则为常量指针
- 如果const在星号的右边,则为指针常量
如果我们将星号读作"指针",将const读作‘常量'的话,内容正好符合。
- int const * n;是常量指针
- int * const n;是指针常量
3.7 编程常见的错误
3.7.1 编译型错误
直接看错误提示信息(双击),解决问题,或者凭借经验就可以搞定
3.7.2 链接型错误
看错误提示信息,主要再代码中找到错误信息中的标识符,然后定位问题所在
一般是标识符名不存在或者拼写错误
3.7.3 运行时错误
借助调试,逐步定位问题
3.8 编程思维
作为初学编程的各位小伙伴们,肯定已经或多或少地听说过编程思维这个词了,那么到底什么是编程思维呢
编程语言
表面含义,编程就是以各式的编程语言来编译代码,类似于英语,也是一门语言。那么作为语言,英语有诸多的语法,那么编程语言同样有一定的语法。毕竟写出来的代码是需要让机器看懂的。
编程思维
那么思维是什么呢,简单说就是做一件事情,脑海里要能够搭建起一个简单的框架,然后再填填补补。构建这个框架的思维就是编程思维,这要求咱们在编程前必须阅读并理解需求,不能只停留在代码的层面,要全局思考,结果会使得代码简洁又高效。
举例
For example,这是一段利用C语言写出最简单的逆序输出,显然,很通俗易懂,但是当输入n个内容,这段代码显然不适用了。
#include <stdio.h>
int main() {
int a, b, c, d, e, f, g, h, i, j;
scanf("%d %d %d %d %d %d %d %d %d %d",&a,&b,&c,&d,&e,&f,&g,&h,&i,&j);
printf("%d %d %d %d %d %d %d %d %d %d", j, i, h, g, f, e, d, c, b, a);
}
那么就可以尝试使用下面这段码
#include<stdio.h>
int main()
{
int arr[10] = {0};
for(int i = 9;i>=0;i--)
{
scanf("%d",&arr[i]);
}
for(int i = 0;i<10;i++)
{
printf("%d ",arr[i]);
}
return 0;
}
显然,这段代码就有了一定的框架结构,这就是编程思维的外在体现。