gcc编译hello world
windows环境下编译C程序,其可执行文件后缀为.exe;
而Linux环境下也就是用gcc编译时可执行文件默认后缀名为.out。
从C源程序到成为可执行文件中间所要经历的步骤:
C源文件 - 预处理 - 编译 - 汇编 - 链接 - 可执行文件
使用gcc -v来查看当前gcc版本:
接下来我们使用gcc编译器来逐步调试看一下这个编译过程,在这个之前记得准备一个hello.c文件嗷。
预处理阶段
gcc中,我们使用gcc -E参数来完成预处理阶段:
预处理的结果很长,截图关键部分:
可以看到预处理已经完成了。
但此时该预处理结果是输出在标准输出终端显示器上的,我们可以在预处理时指定这个处理结果放到什么文件中去,但要注意gcc编译器以.i文件后缀名标识其为一个预处理结果文件嗷:
查看该内容和我们刚刚打印的是一样的。
预处理阶段的作用
源代码中所有以#号开头的部分全部都是在这个预处理阶段处理的,比如#include、#define宏定义等等。
编译阶段(狭义编译)
编译阶段要在预处理之后,使用的指令为 gcc -S xxx.i,编译完之后会生成后缀名为.s的文件,这是gcc编译器指定的编译文件的后缀名:
打开该编译文件,我们可以看到C源程序已经被编译为汇编语言了:
汇编阶段(产生目标文件)
上个编译阶段已经拿到了汇编程序了,现在我们就对汇编程序进行汇编,编译命令为 gcc -c hello.s,该命令会生成一个以 .o结尾的目标文件,这一样是gcc指定的汇编阶段产生的文件名:
链接阶段
在上个汇编阶段之后,我们可以使用命令:gcc hello.o -o 可执行程序名 就可以链接成最后一个可执行程序文件:
执行可执行程序文件
使用 ./hello 的命令来运行,该命令表示运行当前路径下的hello可执行程序:
更快捷的执行操作
上面是为了更好的分析编译程序(广义编译)时其每一步的操作的具体内容,实际上我们完全可以一句命令直接生成可执行程序 gcc hello.c,此时会默认生成一个可执行程序文件a.out:
我们也可以指定最后的可执行程序文件名 gcc hello.c -o asd:
上面的三个文件本质都是一回事情:a.out、hello和asd。
数据类型,运算符和表达式
数据类型
这里目前先只讲基本数据类型,后面的类型会再说:
上图是我从网上随便截的,但是和老师的对不上,主要是int的有符号类型和无符号类型老师的是32位,而上图是16位,这里我们当作其为32位来进行学习。
整形与浮点型剖析
进制转换
这里要注意一下十进制到其它各进制的转换(比如二进制、八进制和十六进制),因为从上图我们可以看到每个数据类型所占的位数是不同的,学会进制转换有利于我们对数据类型的各种转换的理解。
假设一个十进制数254,其转换为二进制可使用除2取余的方法:
十进制转其它进制也可以这么算嗷,转几进制就除几即可。
我们可以得到二进制的254的表示为:111 111 110
假设其为一个unsigned int无符号整类型(注意之前假设过为32位嗷,与上上图区分开),即然是无符号整形则意味着其前面还要补很多0,其在计算机当中的存储应该为:0000 0000 0000 0000 0000 0000 1111 1110 共32位噢
那么无符号整形和有符号整形的区别是什么呢?
其实是其最高位到底是代表符号位还是正常的有效数值,如果是符号位那么就是有符号类型,如果是正常数值则是无符号类型。
在二进制的基础上我们转八进制就非常好转了,上面的除8取余法固然能做,但效率很慢,所以我们在二进制基础上来转,具体方法是:将二进制的各位每三位分为一组,每一组单独按二进制的求法来求八进制的值,合起来就是八进制的254的值。
来试一下,按每三位分组的结果为:011 111 110,011的值为0+2+1=3,111的值为4+2+1=7,110的值为4+2+0=6,所以合起来就是376,那么376就是十进制254的八进制表示。
转十六进制同理,我们也在二进制的基础上来转,此时唯一有区别的地方在于我们按每四位一组来分:1111 1110
那么1111为8 + 4 + 2 + 1 = 15,1110 为 8 + 4 + 2 + 0 = 14,合起来就是1514,但要注意不能直接写15,超过了9之后从10开始都是按字母来书写的嗷(10为A,11为B,12为C,13为D,14为E,15为F,这是因为从十开始每个数字都有两个位数,这会让人把两位数的数字与一位数的数字相混淆而无法区分,所以用字母来代替两位数的值),那么1514也就是FE就是十进制254的十六进制表示了。
如果还要转三十二进制的话也是一样的,那么此时我们要以几个二进制位为一组呢?我们可以从上面看出规律,8进制的时候是3位,3位全一时为7,因为下一位就要进位为8了,十六进制时是四位一组,四位全为1则是15,因为再加一位就进位为16了,怎么样是不是发现规律了?
三十二位的话,应该取五位二进制位为一组对吧?因为五位二进制位全为一时值为:16 + 8 + 4 + 2 + 1 = 31,完全正确。
再提一下各进制的书面表示,其中十进制自然就是254,没有什么特别的,但其它进制则不同:
二进制数书写:B11111110 (开头为大写的B)
八进制数书写:0376 (开头为0)
十六进制数书写:0xFE(以0x开头)
不管有符号数还是无符号数在计算机当中都是用补码来表示的,对于无符号数因为都是正数,所以其补码就是其对应的二进制数。
如254其补码就是0000 0000 0000 0000 0000 0000 1111 1110;
那么对于有符号数来说,其值可以是负数,负数同样被以补码的形式存储在计算机中,其对应的表示有一套计算公式:
以-254为例,先求其绝对值也就是正数,-254的正数为254,然后求该绝对值对应的补码:
0000 0000 0000 0000 0000 0000 1111 1110
然后对该绝对值的补码全部取反:
1111 1111 1111 1111 1111 1111 0000 0001
然后末尾加1:
1111 1111 1111 1111 1111 1111 0000 0010
上面这一串就是负数在计算机当中的补码形式啦
对于浮点型,稍微与整数不同,以float32位为例:
我们写小数应该常见的有以下几种形式:
我们人最喜欢的是第一种对吧,非常直观,但对于计算机来说这样存储是非常不合适的,这样32位的机器字长就要被分成整数部分和小数部分分别存储,能存储的值就太小了。
所以小数的存储方式如下:
它采取的方式是上图中的第二种:0.314*10^1
怎么存呢?如图所示:
上图简单模拟了一个32位的机器字长的float类型的存储方式。
图中第一位表示符号位,剩下三十一个位被用来分割成两个表示,假设分割点为第22位,那么从0到22这一端共有23个位,这一段被称为精度部分,也就是俗话中的小数部分;而从23位到30位这八位被称为指数部分,它用来表示10的指数大小,而小数点部分则被省略了由硬件自动处理。
举个例子,我们的实际数值为:0.000789,其存在计算机中的格式应为 0.789 * 10^-3
即精度部分存储789,指数部分存储-3,然后最高位存0(0表示正数1表示负数)。
其它的比如short啊long之类的都是依葫芦画瓢了,此处不再赘述。
具体其实可以参考一下《计算机组成原理》这本书籍,其说明的更加详细。
字符型char的剖析
首先来看asc码表:
上面可以看到总共是0到127共128个字符,这是标准C规定的表,但后来又扩充了许多,拓展到了256个:
这些拓展的码用在画图上比较多。
为什么char类型也分有符号类型和无符号类型呢?网上有个回答是这样说的:
我还顺道问了一下AI:
所以总结一下,我得出的结论是,在计算机早期发展中,因为只需要用127个字符,同时为了确保以后可能会有需要用到负值的时候(也就是所谓这样设计能提供更大的灵活性),那么将char设定位有符号类型是足够满足要求的;但是又出了个无符号类型的char类型,我觉得这是为了满足现在的需要,因为上面也说了嘛虽然C标准没有列出127到255的asc码值,但是现在确实拓展到了这个地步,那么顺道就出现了无符号类型的char我觉得也是合理的,当然纯属猜测,知道大概是个怎么回事就行了,至于为什么这么设计没有必要较真,因为很有可能是历史遗留问题我觉得。
不同类型数据间的转换
隐式转换
假设有以下数据:
int i;
float f;
double d;
char ch;
此时我们任意两两匹配进行运算,如果不进行人为的干预的话,其运算结果默认是往精度更大、所占位数更多的数据类型靠拢的。
如:
ch + i --> i :char型变量 ch 加上整形 i 结果为整形
f - d -->: 同理,结果为double类型
可以看到这样的混合运算,最后结果的类型依然是这些所有类型里面精度最大的。
显示转换
显示转换其实就是我们说的强制类型转换,要借助强制转换运算符来完成。
后面说运算符的时候再来详细谈论这块内容。
特殊类型的剖析
bool型
这个其实没啥好说的,C语言中用这个bool类型需要添加bool.h的头文件,false表示0,true表示1,这是后面才添加的新的C标准。
float的特殊说明
首先一定要强调,float类型的变量并非是一个精确的确定的值,它表示的实际上是个大概的范围,这是由于精度无法满足要求导致的(因为浮点数有位数限制,无法无限精确)。
容易出现这种情况:比如用一个非常非常小的float数加上一个整形数很有可能最后得到的结果并不是正确的。
下面是非常经典的一个面试题:
这个程序能看出哪里有错误的地方吗?
其实float类型的数是没有办法界定怎么样是0的。
比如1/3*3这个题,人类看一眼得1,但对于计算机来说不是这样的,它只能得到0.999999…,在数学上也有类似的计算,对于0.999999…我们可以证明其约等于1,或者说对于1.000000000…1我们也可以认为其等于1。
所以对于float类型数来说,它本身就不是一个精确表示,所以我们是没有办法使用 == 这种符号来跟0值相对比的(或者说与任意一个数进行相等比较),我们只能用约等于的方式,即用阈值来确定是否相等,伪代码如下:
|f - 0| <= 10^-6
如果满足上面的条件,那我们就可以认为它是等于0的。
char型是否有符号是未定义行为
这个其实就是最好是在声明char类型变量的时候就显式的写上有无符号类型,因为不显式声明char类型的话实际上其默认表现为unsigned char还是signed char是由具体的编译器决定的,这就有可能引发意想不到的错误,所以最好显式的写上。
0、‘0’、“0” 和 '\0’的区别
这个在后面讲常量的章节会说。
常量与变量
所谓常量,就是在程序执行过程中值不会发生变化的量。
而所谓变量,是用来保存一些特定内容,并且在程序执行过程中值会随时发生变化的量。
常量
常量可以分为:整形常量、实型常量、字符常量、字符串常量和标识常量。
整形常量
这个大白话就是我们平常用的整形数字,比如1,7650等。
实型常量
实型常量也是,其实就是浮点型的数字,如3.14、5.26等。
字符常量
字符常量:由单引号引起来的单个的字符或者转义字符,如’a’,‘\n’,‘\t’,‘\015’,‘\x7f’ 等等,转义字符集如下:
看到上图就知道为什么 ‘\015’,‘\x7f’ 也是单个字符了吧?虽然看起来像是好几个字符,但其实它们都是单个字符哟。
一个经典的面试题:'\018’这种写法对吗?
对个锤子,首先有单引号,其次有斜杠开头,斜杠后面跟着0明显是三位八进制,八进制数里面出现了8怎么可能呢?
所以肯定是错的啊。
字符串常量
字符串常量:由双引号引起来的一个或者多个字符组成的序列。
比如:“”,这个串被称为空串,这个空串的意思是没有有效数据,只有一个尾0标识字符串的结束,这也表示其还是占内存空间的,即存储尾0的空间。除了这种,还要诸如 “a”,"abSXUJJ"等都是字符串常量,还有一种比较特殊的:“abc\n\021\018”。
这个要千万看清楚嗷,分解出来是这样的总共有八个字符:“a b c \n \021 \0 1 8”。
而字符串常量还比较特殊的一个点是,字符串常量没有对应的基本数据字符串类型,所以字符串的保存需要借助一个构造类型,即数组类型,这些内容在后面聊字符串专题时会详细进行讨论。
标识常量
标识常量其实就是#define宏定义。
其使用方式与特点我在另一篇博客中有所阐述,所以这里只简明扼要的说明几点:
1、#define可以用来定义一改全改的内容,这会方便开发(不然就要到项目使用了某字面值的各个地方挨个修改)
2、#define只是做了宏名与宏体的单纯的文本替换,并没有语法的检查,这一点可以通过gcc的预处理阶段来查看这一特征
3、带参数的宏的使用:
查看预处理阶段的效果:
可以看到宏定义也可以带参数来执行类似于函数的效果,但是执行效率会快过函数。
还可以在带参数宏定义里面定义变量,当成伪函数来编写程序:
变量
变量的定义:[存储类型] 数据类型 标识符 = 值;
对该定义进行一些说明:
标识符:由字母、数字、下划线组成且不能以数字开头的一个标识序列。
数据类型:基本数据类型+构造类型
存储类型:auto static register extern(说明型)
默认是auto的存储类型(注意与C++区分嗷):自动分配空间,自动回收空间。
register:表示寄存器存储类型(很少用),只能定义局部变量,不能定义全局变量;大小有限制,只能定义32位大小的数据类型(这取决于你的寄存器字长,一般寄存器字长与机器字长相等,即三十二位操作系统那么就只能定义三十二位大小),此时double类型就不可以定义在register寄存器存储类型中,另外寄存器没有地址,所以一个寄存器类型的变量无法打印出地址查看或使用。
static:静态型,该类型的变量会自动初始化为0值或者空值,并且该类型变量的值具有继承性,另外常用于修饰变量和函数。
extern:说明型,说明程序有一个定义但是不在这儿要去别的位置找,这就意味着不能改变被说明的变量的值或类型。
验证上面说的变量特性
1、 auto存储类型与static存储类型
可以看见i和j都是一个随机值,这是因为auto存储类型的变量在不初始化的情况下那块空间是不会被写成为0的(如果你的输出为0那么应该是编译器做了优化),那么为什么也是随机值,因为默认的存储类型就是auto呀!
这个随机值又是什么东西呢?我觉得随机值是这块空间在上一次被某进程使用过后所残留的值,因为计算机中删除都是逻辑删除,即只是修改索引,并不会真正的抹上0。
而对于static的存储类型来说,当使用该存储类型我们可以知道,其所修饰的变量的值被自动初始化为0了。
static还有一个性质,叫具有继承性,接下来来说明一下什么叫继承性:
注意上图中三次地址打印都是一样的,这个是不一定的,地址的分配方式也和编译器有关系,我们要知道的是这三次打印的x肯定不是同一个x对吧,如果是同一个x那么应该输出的是1、2、3才对。
现在我们再来用static修饰一下上面程序中的x:
从运行结果可以看出,这次这三个地址是肯定一样的,因为x依次递增了,说明三次func调用操作的是同一个地址空间中的x,这就是所谓的static的继承性。
static存储类型修饰的变量一定只用一块空间,也就是说static int x = 0这句代码作为定义来说只被定义一次,当func函数第一次被调用的时候会分配一个四个字节的地址空间给变量x(三十二位系统环境下),而第二次func函数被调用时static int x = 0这句代码就不再被当作定义,即不再生成地址空间,而是继续使用之前分配的地址空间,即在原来的变量x基础上继续+1。
static存储类型还有更高级的用法,即作用在全局变量或者是函数上,来分析分析。
我们现在来模拟一个小工程minproj,因为实际的工程项目都是模块化结构化的,所以这里我们设计了三个模块:
main.c中写main函数的调用,即程序的入口函数。proj.c是其它小函数的实现,proj.h是proj.c函数实现被声明的位置。
main.c中代码如下:
proj.c:
在proj.h头文件中去包含该文件的声明:
main函数中先是打印了自己的全局变量i,然后又调用打印另一个模块中的全局变量i的值,来看一下会发生什么事情:
可以发现此时报了变量 i 重复定义的错误,这就意味着其实在两个模块作为一个整体进行编译的时候它们的全局变量是共享的,那么既然是共享的那两个模块都定义了 i 那肯定就冲突报错了,这就是全局变量的含义。
此时我们就可以使用static来解决这种问题,static除了可以修饰局部变量也可以修饰全局变量,因为我们希望某个模块的变量 i 就应该只在某个模块下进行使用而不与其它模块产生冲突,所以我们使用static来分别修饰main.c中的全局变量 i 和 proj.c 中的全局变量 i:
main.c:
proj.c:
此时编译运行:
可以看见此时冲突就消失了。
再来看static 修饰函数的情况,我们让proj.c文件中的func函数被static修饰,此时该func函数将只能在proj.c中使用了:
此时编译直接报错:
错误是对func的静态声明出现在非静态声明之后,很容易理解,因为我们在proj.h头文件中声明的是非静态的,而proj.c中是静态定义的,这就会报错,但是我们将proj.h头文件中的func函数声明成静态的难道就可以了吗,来看一下:
编译运行:
现在报func函数使用过,但从未定义了,什么意思?
就是说proj.c当中的func函数被static修饰是因为我们想防止当前函数对外扩展,其实就相当于被封装、被私有化了一样(Java和C++中有封装的机制),即static限制了函数的作用域只能在当前文件模块下,要想在main.c中使用它的话只能通过其它非私有化的函数来调用:
proj.c:
proj.h中添加该函数的声明,并且删去原来的func函数的静态声明(因为没有用了,func函数已经被proj.c私有化了呀,除了proj.c文件能用其它谁也用不了):
main.c中也不应该再调func函数,而是调用其接口函数call_func:
现在编译运行:
正确运行了。
2、register
这个用的太少了,基本用不到,一般都是编译器优化帮忙做了,知道是什么就行了。
3、extern
这个在C++里面也会学到,总结就是一句话:某模块文件要用一个变量或者函数,虽然它不知道在哪里,但是它可以肯定其一定存在,此时就可以用extern来引入该变量或者函数来在本模块中进行使用。
需要注意的是该文件模块不可以改变引用进来的变量的值或函数的定义。
还是刚刚的例子,现在我们修改一下:
main.c:
proj.c中引用其它文件中的变量 i,因为这个项目只剩另一个main.c文件,所以这里引用的其实就是main.c中的变量 i:
可以看见打印的变量 i 的值都是10,甚至使用extern的时候可以不跟变量类型嗷。
变量的生命周期和作用范围
1、全局变量和局部变量
全局变量的生命周期是从它定义的位置开始直到当前程序运行结束,而局部变量也是差不多,从它当前被声明的位置开始直到它当前所处块的作用域结束,上图中也就是main()后面跟的俩花括号。
对于上面的程序来说,i最后输出的结果是3是因为局部变量覆盖了全局变量,也就是内部的作用范围屏蔽外部的作用范围,当我们把局部变量注释掉之后,输出就成了100.
2、局部变量和局部变量
在上面的例子上稍加改造;
可以看到 i = 3和 i = 5都算作局部变量,作用于一段代码,依然是内部作用范围屏蔽了外部作用范围,这里不再赘述。
总结一下
表达式
表达式和语句的区别:由变量和运算符组成的内容就叫表达式,加了分号就称为语句。
运算符
该部分的学习可参考如下部分:
运算符种类:
运算符的结合性与优先级:
运算符的特殊用法:
这里就注意几个点就好了,因为比较简单,我们重点是掌握位运算:
1、对于取余运算符%来说,它要求两个操作数都必须为整形才可以,否则报错。
2、自增自减运算
运算符在前,先进行计算,再取变量值使用
运算符在后,先取变量值使用,再进行计算
3、逻辑与和逻辑或的短路特性
比如假设a为假,b为真,那么执行语句:a && b 的时候,其结果返回的将是假,此时含义是如果左表达式已经为假了,那么逻辑与运算符将不会计算右表达式的值,直接返回假;
逻辑或也是同理,如果左运算符已经为真,那么逻辑或也将不再计算右运算符,直接返回真。
4、三目运算符(条件运算符)
op1 ? op2 : op3;
其含义是如果op1表达式的值为真,则取op2的值,否则取op3的值
5、sizeof的使用
sizeof其实是个运算符(并不是关键字嗷),它用来反应当前环境下指针或者是变量类型真正所占的内存字节数。
6、强制类型转换运算符
强制类型转换运算符大部分人使用上都有一个误区,比如:
int a;
float f = 3.9;
a = (int)f;
有人会觉得f被强制转换成了 int 类型,但其实这是错误的,f本身类型并没改变依然还是float类型(值也并没改变,还是3.9),第三行语句只不过是将3.9这个数值的精度直接丢掉,把这个内容作为一个赋值的对象赋值给了 a 变量,仅此而已。
位运算的重要意义
反正很重要就完事儿了,在算法题中其实时常有考察,在嵌入式C开发当中可以说是重中之重,但是如果是做软件的话我感觉了解了解就行,知道怎么用就行了。
下面这是伪代码,编译器是不识别的只是用来举例子:
int i = B1100 = 12 //B1100表示二进制,12是该二进制的十进制格式的值
i >> 1 ==> 110 = 6 //对 i 进行右移一位操作,相当于对 i 进行 除 2的操作,整体往右移动一位就抹掉一个0
110 << 1 ==> 1100 = 12 //相当于对 i 进行左移一位操作,相当于对 i 进行乘 2 的操作,整体往左边移动一位就多补一个0
~i ⇒ 0011 // 表示对 i 进行按位取反
| 表示按位或,它是一个双目运算符,表示同一位上两者都为0才为0,否则为1
再来一个 int j = B1001
i | j ⇒ 1101 :
按位与同理:
^表示按位亦或,相同为0,不相同为1:
两个比较常用的位运算技巧(注意下面的n都是从0开始计数,并且是从右往左算下标的形式):
1、将操作数中第n位置成1,其它位不变:num = num | 1 << n;
下面例子是将第2位置成1:
2、将操作数中第n位清0,其他位不变:num = num & ~(1 << n)
下面例子是将第1位清0:
3、测试第n位:if(num & 1 << n)
输入输出专题
格式化输入输出函数:scanf、printf
printf
man手册中有其原型:
…表示该函数的参数是可变参数。
format是一个字符串这个不用说,它定义了输出的打印格式。
"%"是输出标记,其后跟一个修饰符(可选择的),然后修饰符(如果有)后跟一个格式化的输出符号,符号表如下:
下面这个是更清晰的版本:
修饰符列表如下:
上面就是常用的一些format格式的内容,还有更细致的用法可以需要的时候再去查找。
所以我们的format的格式就可以定为:"% [修饰符] 格式字符 "。
测试一个比较少见的C语言字符串的处理:
值得注意的一个小点,对于L的使用,还可以用在普通整形或实行常量上,看下面几种情况:
第一种情况:
传递给long类型形参的时候,如果传的是普通整形最好指定其类型,也就是像上面这样用L来指定其类型,因为在某些比较严格的编译器上不指定实参类型编译器无法知道这个数字到底代表什么身份,所以带上单位是比较具有健壮性的。
如果是long long类型同理,写成LL即可。
还有一种情况:
当我们需要表示一个很大很大数字的时候,我们需要指定其类型为long或者long long:
不然可能会因为超出默认的数据限度(比如int)而报错。
还有一个小小的面试题,问定义一个宏来表示一年有多少秒:
这个关键在于有没有考虑数据溢出的细节,还有有没有包边括号的细节处理。
scanf
函数原型如下:
可以看见和printf如出一辙,这里的format输入格式和上面讲的输出格式是差不多的,只有两点要注意,第一点就是在输入中一般情况下我们不会加上修饰符,这会限制我们的输入格式;第二点是这里…参数列表我们要填的是取地址符+对应的格式输出。
再补充一点,scanf里面别加\n嗷,加了的话就得在输入的时候原模原样的写个\n才能输入了,注意这个输入的\n是单纯要用键盘输入的\n,别和我们回车敲击的\n弄混了,切记。
测试一下:
这个测试程序本身没什么难度,重点是强调一个最佳实践,就是在scanf中输出多个值的时候像我上面一样紧挨着写会比较好,因为这样我们在键入内容的时候就可以随便键入,比如用空格分开或者用,分开都是可以的,如果我们在scanf里面规定了格式的话那么键入内容的时候就必须按照规定的格式来了,此时写错的话将会引发未知错误。
再来测试一下平常用的较少的C风格的字符串的输入:
scanf的缺陷
但是注意,使用scanf配合%s这种输入字符串的格式有缺陷,在输入字符串时不能有任何分隔符的出现(如空格、tab键、回车键啊之类的),如果出现都会被当成是当前输入的一个结束,如下:
我们输入的hello world,但是最后打印的只有hello。
还有越界异常,因为我们的数组是有大小的,但是实际上如果我们输入的字符数量超过了该数组大小有些编译器依然不会报错:
此时我们来运行输入:
可以看见我的编译器是会报错的,我们也可以发现对于scanf输入函数本身是不会检查错误的,所以这也是一个缺陷,要千万注意,更可怕的是有些编译器甚至不会报这种错!
还有一种缺陷,是在循环当中:
编译运行:
可以看见当输入正常的时候,都是没问题的,但是当我们输入字符a的时候:
直接死循环输出上一次的内容,这很明显又是一个非常大的缺陷,所以在使用scanf的时候最好是加入判断条件,来看一下scanf的返回值(是的没想到吧,scanf还有返回值):
其返回值是输入成功的变量个数,我们可以通过这个来做一个健壮性判断:
还有一种scanf函数与其它输入函数连用时产生的特殊的情况,也是一种缺陷:
我们预想的其应该是先输入一个整形比如 1,然后再输入一个字符比如c,然后就输出:i = 1, ch = c
但编译运行:
ch还没来得及输入就打印了,其实ch是有值的,其值就为我们敲击的回车键,也就是换行符。
我们可以换成打印ch的asc码值来更清楚的看到它究竟打印的是什么:
可以看到我们测试了两次,第一次是用回车表示scanf结束,第二次是用空格表示scanf结束,两次asc码分别为10和32.
对照asc码表我们可以知道,10为换行符,32为空格符。
为了解决这种问题,我们可以使用scanf函数中format参数里的一个抑制符 * 的使用,该抑制符可以用来吃掉一个char类型的字符:
可以看见此时就正常了。
字符输入输出函数:getchar、putchar
其实上面的也能完成字符输入输出,但是这里介绍的这两个是专用的,所以也要提一下。
getchar不需要参数,因为其默认从标准输入流里面获取输入字符,以返回值的方式拿到该字符,来看一下其返回值:
再来看一下putchar:
putchar也是默认输出到标准输出的文件,即终端显示器上。
测试一下:
字符串输入输出函数:gets、puts
依然是去man手册中进行函数的查看:
可以看到手册当中也是说明了该函数使用是有缺陷的,它从标准输入中读取一行内容放到缓冲区中,直到遇到换行符或者EOF就表示函数读取结束,然后在末尾加上个\0。
puts把一行字符串往标准输出中写,遇到换行符就终止。
简单测试:
可以看见连编译器都告诉我们是有问题的了,这是因为上面对gets介绍时手册中有提到gets函数对于缓冲区的溢出是不做任何检查的,我们现在将size减小:
可以看见此时已经超出了其size范围了,但是依然没有报错,证明该函数并不检查缓冲区是否溢出,什么时候会报错呢?
除非现在这个数组越界已经踩到了一块具有写保护权限的一块地址空间了,这个时候才会报段错误,就像我们之前使用scanf时产生的错误一样!
查看man手册可以看到它推荐我们使用fgets来代替gets,既然如此为什么不把gets从标准中移除呢?
原因就是gets出现的太早了,很多库或者什么东西的实现都是依赖它的,这样删掉的话就会牵一发而动全身,所以干脆就留在这了。
流程控制
这个比较简单,就记录一个比较容易犯错的点吧:
就是else只与它最近的一个if来匹配,所以要注意语法规范!
疑惑与技巧
main函数的多种风格问题
我们之前是不是见过很多种类型的main函数的形式?像下面这些:
在这里插入图片描述
这其实是和编译环境有关系的,在早期写C语言时C标准当中因为没有void类型,所以main函数的返回值都是整形,后来引入了新类型void,某些编译器就认为main函数作为一个特殊的函数(是程序的入口函数)认为其不需要返回值,对于这类编译器它就认为应该返回值写为void,甚至如果main函数没有参数的话该类编译器还会给一个void表示无参,而如果需要传参的话就有上图中的两种形式:
main (int argc,char** argv) 或者 main (int argc,char* argv[])
而我们现在使用的环境是Linux环境,使用gcc编译器,那么标准形式应该如下:
int main(void){}
大段注释的方式:C风格,采用条件编译
除了之前学过的//、/**/等,我们还可以使用条件编译的方式来注释一段代码,如下面的代码表示在编译时不参与编译:
vim的小技巧
1、vim下光标来到库函数使用处,按下shift+k,快速跳转到man手册其函数定义处,连续按两次q返回程序中。
漏掉头文件的危害
2、在C语言中,如果一个函数没有看到原型(即没有加该函数需要的头文件),那么就默认该函数的返回值是int。
3、使用 gcc命令时加上 -Wall 可以检查到所有的警告信息。
我们可以来写一下上面2和3条的验证程序:
添加 -Wall 编译运行:
可以到很多警告,第一个就是我们的第二条所解释的内容,“隐式声明与内建函数 ‘malloc’ 不兼容 ”,其意思是malloc的返回值和它的接收方式不相匹配,因为malloc作为内建函数其返回值是void*,包含在stdlib.h头文件中,但是在C语言中如果一个函数没有看到原型(即没有包含其所在的头文件),就会默认该函数返回值是int类型,这就是为什么会报该警告的原因。
总结就是——没包该函数头文件就会报这个错!
怎么解决呢?有一种错误的“常见”用法是,强转成我要用的数据类型,对于上面的程序那么就是强转成int类型:
编译运行:
可以看到还是会报这个警告,老原因了,必须得写成void才行啊,因为这是malloc函数定义的返回值,void*赋给任何类型的指针都是天经地义(除了一种特殊情况,后面会说)。
但其实引发上面一系列问题的原因,究其根本其实还是没包头文件嘛,要是包了头文件,那么编译器就不会认为malloc返回的是int类型了,会认为其会返回void类型,那该类型赋值给int类型天经地义嘛,所以还是要包头文件:
程序:
编译运行:
所以写代码的时候最好是写到没有警告为止,除非是可控的警告,如上面程序中的i没用的问题。
比如出现段错误的时候,不知道哪里错了的话,就把警告全打出来,挨个解决,全部解决的时候没准儿段错误就消失了。