一、基础知识
1.语言的标准
C语言中的字符都是采用英文字符,美国国家标准协会ASCI设计出来一套编码规则,叫做ASCII码(美国信息交换代码标准),这套编码一共收录了128个字符,也被称为标准ASCII码,所以后期为了兼容欧洲等国家的字符,又推出一套拓展ASCII码,一共收录了256个字符。
字符是组成语言的最基本的元素,国际上使用最广泛的计算机字符编码就是ASCII码,如下:
2.程序入口
C语言是一门面向过程的编程语言,面向过程其实是一种解决问题的思想,这个过程指的是用户在解决某个问题时,按照某种逻辑关系把这个问题拆分为很多小问题,把每个小问题按照顺序单独解决,最终整个问题也就得到解决,可以理解为“大事化小,小事化了”的原则。
面向过程思想采用的是自顶向下的设计原则,所以对于程序而言,过程的开始和结束就非常重要,在C语言标准中,就规定了C语言程序是顺序执行的,并且程序的开始必须是main函数,如下:
可以看到,在程序启动时会调用一个叫做main的函数,并且标准中规定了main函数的原型如下所示:
注意:C语言程序的基本单位是函数,也就是说一个程序是由一个主函数以及若干个子函数组成的,主函数的名字是固定的,叫做main函数,用户不能修改,main这个单词翻译成中文具有主要的,最重要的意思,所以main函数才会作为程序的入口,也就是说程序是按照main函数中语句的先后顺序执行的。
3.函数调用
思考:一个函数库中有很多函数,那用户如何知道这个库中都有哪些函数以及如何调用呢?
回答:用户如果调用库函数,则必须在源文件(xxx.c)中包含库的索引,就是头文件(xxx.h),header file指的是头文件,在编写的时候需要包含函数对应的头文件,目的是让编译器可以去对应的函数库中找到该函数。
思考:官方和第三方都可以发布函数库,甚至用户本人也可以开发一套函数库,请思考编译器如何区分用户使用的是第三方库的头文件还是标准库的头文件?
回答:一般官方提供的函数库都已经在系统路径中能够是存在的,所以用户需要使用<>包含头文件,就可以让编译器找到对应的函数接口。
< xxx.h > :编译器只去系统指定路径中查找该头文件,如果未找到,则编译器进行错误提示。“ xxx.h ” :先去当前路径中查找头文件,未找到再去系统路径中查找,还未找到则报错提示。
4.计算机存储结构
计算机组成结构:运算器、控制器、存储器(内存、外存)、输入设备、输出设备
人类使用计算机的目的就是为了高效的处理大量数据,但是数据的类型有很多,比如声音、图像、文字、数字等,那计算机是如何区分数据的呢?
回答:计算机不用区分,计算机只采用二进制存储。
思考:计算机把还未处理的数据以及处理好的数据保存到哪里呢?
回答:这些数据都是存储在存储器,用户通过输入设备(键盘)录入的数据都是先存储在内存中,然后控制器会把这些数据传递给运算器,运算器处理完成后传递给控制器,控制器吧数据传递给输出设备(显示器)。
- 内存概念
对于计机中的存储器而言,一般分为两种:RAM(随机存储器)和 ROM(只读存储器)
只读存储器的英文全称是Read Only Memory,简称为ROM,属于非易失性存储设备,指的是掉电不丢失数据,可以把数据长时间存储,一般分为很多种,具体的区别可以通过《数电》进行了解,对于计算机的机械硬盘或者固态硬盘而言指的就是ROM。
随机存储器的英文全称是Random Access Memory,简称为RAM,属于易失性存储设备,指的是掉电会丢失数据,但是读写速度很快。所以一般CPU都是直接和RAM打交道。
- 内存单位
内存中一个MOS管可以存储一个二进制数0或1,为了方便记忆,标准规定把一个二进制数用bit来进行表示,bit(比特)是binary digit的缩写,中文翻译为位。专业术语一般把一个二进制数称为一位二进制数。所以bit就用来表示计算机数据的最小单位。
注意:计算机处理数据的基本单位是byte,计算机处理数据的最小单位是bit,1byte = 8bit。
- 命名规则
变量和常量也被称为标识符,对于标识符的命名必须遵循规则:标识符是由字母、数字、下划线以及美元符号$组成,并且标识符的第一个字符只能是字母或者下划线。
123m(错误) m123 _m123 _123 _12m M12a_ m12a_ INT Main
注意:为了防止二义性出现,C语言标准规定不可以把系统保留字,以及程序中已经存在的函数名称、以及用户已经定义出来的标识符名称作为新的标识符名称(同一个作用域内不能)。
另外:C语言的标识符是区分大小写,并且作为用户而言,标识符的命名应该是有意义的!!!
5.C语言数据类型
注意:ASCII码中的转义字符需要使用 ‘\0’ ‘\r’ ‘\n’进行表示,代表字符具有特殊的意义。
思考:C语言中提供了char、int、short、long、long long来表示整型,但是整型数是分正负的,编译器如何来区分整数的正负呢?
回答:C语言标准中提供了两个关键字 signed && unsigned用于修饰整数,unsigned表示无符号,signed表示有符号,C语言中的signed修饰符是隐式声明,也就是用户定义整型变量的时候如果没有特别强调,则整型变量默认是有符号的。
用户如果要存储无符号的整数,则必须定义变量必须显式声明变量是无符号的(unsigned)。
注意:设计程序时,定义的变量如果是有符号的,则尽量避免写入超过变量范围的数值!!!!!!
字符型: char 1字节 -- 有符号 -- 数值范围 -128 ~ 127 -- 无符号 -- 数值范围 0 ~ 255
短整型:short 2字节 -- 有符号 -- 数值范围 -32768 ~ 32767 无符号 --数值范围 0 ~ 65535
整型:int 4字节 -- 有符号 -- 数值范围 --2^31 ~ 2^31 - 1 无符号 --数值范围 0 ~ 2^32 - 1
浮点类型:数据有整数和小数之分,一般情况下处理的数据也是具有小数的,所以C语言标准中规定使用关键字float来表示单精度浮点数,单精度浮点型占4字节,另外C语言标准中也提供了另一个关键字double用来表示双精度浮点数,double占8字节,其实C语言也提供了一种类型long double,该类型占16字节。
指数形式:指的是采用以10为底的形式表示浮点数,比如 3.14 * 102 ,但是在编写程序的时候采用英文半角输入法进行设计,这种输入法是不支持输入上标或者下标,所以C语言规定采用字符e或者E来表示以10为底的指数,比如3.14E2 。
注意:C语言标准中规定字符e/E的后面必须是整数,另外字符e/E的前面必须有数字!!!!!
字符串:是表示某种含义的一个字符序列,字符串在内存是需要一块连续的内存空间进行存储,C语言中规定字符串使用双引号””表示,并且规定字符串的结束标志是’\0’,但’\0’不需要用户手动添加,系统会自动在一个字符串的末尾添加’\0’。
思考:既然在内存中字符串需要一块连续的空间来存储,内核肯定会返回字符串的开始地址,请问内核如何判断字符串何时结束? 回答:遇到’\0’自动结束
布尔型:用户有时候需要在程序进行判断,在C89标准中,用户如果想要判断某种条件是否成立,一般是定义一个整型变量,然后利用数字0和数字1来表示条件是否成立,用户就可以把变量作为一个标志位使用。 在C99标准可以使用布尔型来表示真假两种情况,头文件stdbool.h有相关描述,在头文件中定义了三个宏,分别是bool、true以及false。
二、C语言输入输出
1.格式化输出
用户如果打算使用标准C库的函数,就必须要包含函数库对应的头文件,比如输入输出函数对应的头文件就叫做stdio.h,stdio指的是standard input output,翻译为标准输入输出。
- 标志说明
- 字段宽度
- 转换精度
#include <stdio.h>
#include <stdbool.h>
int main(int argc, char const *argv[])
{
printf("%-10d%d\n",100,200); //100_______200 10指的是字段宽度
printf("%*d%d\n",10,20,30); // * 在格式化字符串中可以理解为占位符 表示列宽 此时函数第二个参数必须是整数,提供给* ________2030
printf("%f\n",3.14); // 3.140000 如果不指定精度,则默认精度为6位
printf("%.f\n",3.14); // 3 如果选择使用精度,但是不去指定精度的位数,只有 . 则精度为0
printf("%.2f\n",3.145926); // 3.15 如果选择使用精度,但是指定精度的位数,输出的数值会进行四舍五入
return 0;
}
注意:计算机内部存储多字节的数据时会涉及到大端小端,不同的处理器架构采用的模式是不同的,一般X86架构采用小端模式,ARM架构一般采用大端模式(但是并不绝对)!!!!!!
- 转换说明
- 转义字符
在利用printf函数输出字符串时,用户可能需要输出一些转义字符 ‘\n’以及’\t’是比较常用的。
注意有的时候是需要把一些特殊字符当做普通字符输出,比较 “” ,可以把 \” 和 \” 输出。
#include <stdio.h>
#include <stdbool.h>
int main(int argc, char const *argv[])
{
float a = 3.1415926;
double b = 3.1415926;
printf("%f\n",a); //3.141593 单精度
printf("%lf\n",b); //3.141593 双精度
printf("%p\n",&a); // C语言提供了一个 & 取地址符 &变量名 --> 得到变量的地址 -->得到变量对应的存储单元的编号
printf("100%%\n"); // %%可以把%作为普通字符
printf("hello\nworld\n");
printf("hello\tworld\n"); // hello world
printf("\"hellowrold\"\n"); // ""作为普通字符输出 只需要 \" hello world \"
return 0;
}
- 返回结果
2.格式化输入
用户会使用标准输入设备(键盘)写入数据,所以标准C库提供了一个输入函数scanf(),作用是把数据流写入到内存中,scanf指的是scan format,翻译为格式化扫描,用法类似于printf函数。
格式说明符
%d
:读取一个十进制整数。%f
:读取一个浮点数(float
)。%lf
:读取一个双精度浮点数(double
)。%c
:读取一个字符。%s
:读取一个字符串(不包括空白字符)。%x
:读取一个十六进制整数。%o
:读取一个八进制整数。%u
:读取一个无符号十进制整数。
- 匹配字符
3.C语言的运算符
运算符指明要进行的运算和操作,操作数是指运算符的操作对象,根据运算符操作数的数目不同,C语言标准把运算符分为三种:单目运算符(一元运算符)、双目运算符(二元运算符)、三目运算符(三元运算符)。
- 算术运算符
C语言中提供了7种算术运算符,分别是 + - * / % ++ -- ,++和--都属于单目运算符,使用的时候比较特殊,其他五种都属于双目运算符。加法运算符+和减法运算符-一般没有特别需要注意的,作为双目运算符,需要两个操作对象,但是有时候 - 也会作为负号使用就变为单目运算符,使用规则发生变化。
*和/是作为乘法运算符和除法运算符,属于双目运算符,但是除法运算符的使用规则有些不同。
当除法运算符两端的操作数都是整数的时候,得到的结果也是整数,当两个操作数无法被整数,会舍弃小数,只保留整数。
当除法运算符两端的操作数不全是整数,比如其中一个操作数是浮点数,则得到的结果也是浮点数,如果不指定精度,则默认是6位精度。
C语言中提供%作为求余运算符,也可以称为取模运算符,C语言标准中规定%符号两端的操作数必须是整数。可以知道,C语言中的运算符是有优先级的,运算符的优先级指的是多个运算符出现在同一个表达式中,优先执行哪个运算符,可以知道算术运算符* / %的优先级是高于算术运算符+和-。
思考:既然运算符有优先级,那如果一个表达式使用的运算符的优先级都一样,那应该如何进行运算?
答:C语言中的运算符是有结合性的,运算符的结合性指的是多个优先级相同的运算符出现在同一个表达式中,优先执行哪个运算符。运算符的结合性分为两种:一种是左结合性,遵循先左后右原则,比如 a + b - c,相当于(a+b)-c,另一种是右结合性,遵循先右后左原则,比如双目运算符中的赋值运算符 = ,比如表达式a=b=c,相当于a=(b=c)。
注意:C语言中的单目运算符和三目运算符都是遵循右结合性,当然也包含双目运算符中的赋值运算符=,其他的运算符都遵循左结合性。
C语言中提供了两个增量运算符++和--,属于单目运算符,只能在变量中使用,一般可以作为变量的后缀增量或者后缀减量,++和--也被称为自加运算符和自减运算符,注意:C语言标准中规定++和--也可以作为变量的前缀增量和前缀减量。 作用是让变量的值自动加1或者减1。
后置增量 a++ :在表达式中则表示先让变量a参与表达式运算,运算之后才会让变量a的值自加
前置增量 ++a :在表达式中则表示先让变量a的值自加,自加之后再参与表达式运算
思考:前缀增量和后缀增量都属于单目运算符,如果一个表达式中同时出现两种运算符,那应该如何进行解释? 比如 表达式 ++i++ 如何解释? 答: ++(i++) 遵循右结合性
注意sizeof在C语言中是一个操作符,作用是计算数据类型的大小,结果以字节为单位,sizeof括号中的表达式是不会实现运算和处理的。
另外,虽然sizeof运算符中的表达式不会被运算,但是如果sizeof表达式中出现多个数据类型的大小计算,这个时候会涉及到C语言基本数据类型的转换,转换的意思指的是将数据(变量、数值、表达式的结果等)从一种类型转换为另一种类型。一般程序中的数据类型转换分为两种情况:自动类型转换 or 强制类型转换。两者区别如下:
自动类型转换
自动类型转换就是编译器默默地、隐式地进行的数据类型转换,这种转换不需要程序员干预,会自动发生。比如将一种类型的数据赋值给另外一种类型的变量时就会发生自动类型转换。
在赋值运算中,赋值号两边的数据类型不同时,需要把右边表达式的类型转换为左边变量的类型,这可能会导致数据失真,或者精度降低;所以说自动类型转换并不一定是安全的。对于不安全的类型转换,编译器一般会给出警告。
在不同类型的混合运算中,编译器也会自动地转换数据类型,将参与运算的所有数据先转换为同一种类型,然后再进行计算。
转换的规则:转换按数据长度增加的方向进行,以保证数值不失真,或者精度不降低。例如int 和 long 参与运算时,先把 int 类型的数据转成 long 类型后再进行运算。所有的浮点运算都是以双精度进行的,即使运算中只有 float 类型,也要先转换为 double 类型,才能进行运算。另外char 和 short 参与运算时,必须先转换成 int 类型。
强制类型转换
自动类型转换是编译器根据代码的上下文环境自行判断的结果,有时候并不是那么“智能”,不能满足所有的需求。如果需要,程序员也可以自己在代码中明确地提出要进行类型转换,这称为强制类型转换。
自动类型转换是编译器默默地、隐式地进行的一种类型转换,不需要在代码中体现出来;强制类型转换是程序员明确提出的、需要通过特定格式的代码来指明的一种类型转换。换句话说,自动类型转换不需要程序员干预,强制类型转换必须有程序员干预。
强制转换规则: (需要转换的数据类型) 变量
总结:无论是自动类型转换还是强制类型转换,都只是为了本次运算而进行的临时性转换,转换的结果也会保存到临时的内存空间(栈空间),不会改变数据本来的类型或者值。
- 位操作运算符
C语言中提供了6种位操作运算符,分别是 ~ & ^ | << >>,其中~属于单目运算符,其他五种都属于双目运算符。
~:按位取反,对于二进制数而言,0变为1,1变为0 ~ 1101_1110 = 0010_0001
&:按位与,对于二进制而言,当两个bit同时为1,则结果为1,如果bit存在0,结果为0
|:按位或,对于二进制而言,当两个bit同时为0,则结果为0,如果bit存在1,结果为1
^:按位异或,对于二进制而言,当两个bit相同,则结果为0,两个bit不同,则结果为1
<<:左移运算符,对于二进制而言原则:高位舍弃、低位补0 0111_1010 << 3 -- 1101 0000
>>:右移运算符,对于二进制而言原则:低位舍弃、高位补0 0111_1010 >> 3 -- 0000 1111
- 关系运算符
C语言中一共提供了6种关系运算符,分别是 < <= > >= == != ,关系运算符都是双目运算符,都遵循左结合性,一般用在条件判断中,如果表达式中使用关系运算符,则表达式也被称为关系表达式,关系表达式的结果只有两种,用户可以使用布尔型进行结果的表示。
关系运算符 < <= > >=的优先级相同的,并且这四种运算符是高于关系运算符 == !=,两者的优先级是相同的。
- 逻辑运算符
C语言中提供了3种逻辑运算符,分别是 && || ! ,对于逻辑与&&和逻辑或||都属于双目运算符,遵循左结合性,而逻辑非!属于单目运算符,遵循右结合性。一般表达式中如果使用了逻辑运算符,则表达式被称为逻辑表达式。
还有一个逻辑运算符是 ! (逻辑非) ,属于一元运算符,只有一个操作对象,遵循右结合性,作用是把操作对象的逻辑取反(真变为假,假变为真)。
- 条件运算符
C语言中提供了1种条件运算符,符号是 ? : ,条件运算符是唯一的一个三目运算符,需要三个操作数。
- 赋值运算符
C语言中提供了11种赋值运算符,如下图所示,都属于双目运算符,但是遵循右结合性!!!
- 逗号运算符
C语言中提供了1种逗号运算符,符号是 , 作用是把多个表达式连在一起,构成一个大的表达式,也被称为逗号表达式。注意按照从左向右的流程对每个表达式进行运算,只是逗号表达式最终结果是最后一个表达式的结果。
#include<stdio.h>
int main() {
int sum, pad, PAD;
sum = pad = 5;
PAD = ++sum, PAD++, ++pad;
printf("%d\n",PAD);// 7
/*
1、sum=5;pad=5;
2、由于++sum,则sum=6;
3、由于PAD++为后置递增,这需在表达式运行后再++
4、++pad的值为6,且为逗号表达式最后一个,所以会作为表达式结果,PAD结果为6
5、最后再PAD++,则PAD结果为7
*/
}
三、语句和块
C语言标准中一共提供6种语句,注意C语言中的语句要指明执行的操作,并且没有特殊情况,语句是按照顺序执行的。
用户一般会把实现某些功能的语句整合在一起,构成一个语法单元,C语言标准的语法单元也被称为块,也被称为块语句。
1.复合语句
复合语句可以限制语句的作用范围,但是一般情况复合语句很少单独使用,都是和其他语句一起使用使用。
2.表达式语句
C语言程序一般使用表达式来实现某种操作,表达式是由一组操作符以及操作数组成,目的是实现某种特定的操作。
3.选择语句
- 针对一种情况
C语言标准中提供了一种 if() 语句,if是C语言的关键字之一,具有“如果”的含义,可以看到if()语句需要一个控制表达式,当表达式为真时,则会执行statement,如果表达式为假,则不执行statement。
2.针对两种情况
C语言中提供了if()... else结构的语句,else是C语言关键字之一,具有“否则”,当if语句的表达式不成立时,则会执行else对应的语句。
if ( 表达式 )
{
块语句1; //当表达式为真,则执行块语句1
}
else
{
块语句2; //当表达式为假,则执行块语句2
}
3.针对多种情况
C语言标准提供了switch语句,switch也是C语言关键字之一,用于表示多分支选择,需要配合标签语句一起用:
switch( 表达式 ) //表达式结果必须是整型
{
case 常量表达式1 : 待执行的语句1
case 常量表达式2 : 待执行的语句2
case 常量表达式3 : 待执行的语句3
case 常量表达式4 : 待执行的语句4
default : 待执行的语句n ---->当所有的case语句都不满足时才会执行!!!
}
4.标签语句
C语言标准中提供了3种标签语句,其中使用频率较高是case标签和default标签,case和default都是C语言的关键字之一,case具有匹配的含义,default具有默认的含义。
注意:case标签的常量表达式的结果必须是整型常量,并且case标签必须是互斥的(不能出现重复的情况,会导致二义性)。
注意:case标签语句和default标签语句只能在switch语句中使用,对于普通的标签语句是可以在跳转语句中使用的。
注意:switch语句应该结合break语句,目的是利用break语句终止switch语句,避免多个标签都被执行。
switch( 表达式 ) //表达式结果必须是整型
{
case 常量表达式1 : { 待执行的语句1 ..... } break;
case 常量表达式2 : { 待执行的语句2 ..... } break;
case 常量表达式3 : { 待执行的语句3 ..... } break;
case 常量表达式4 : { 待执行的语句4 ..... } break;
default : 待执行的语句n ---->当所有的case语句都不满足时才会执行!!!
}
5.跳转语句
C语言标准中提供了四种跳转语句:goto语句、continue语句、break语句、return语句。
continue语句、break语句:
goto语句:
6.迭代语句
C语言标准中提供了3种迭代语句给用户完成重复性的工作,迭代也可以理解为循环,可以重复执行某个代码块。
可以看到,C语言标准中提供了while()语句、do ...while()语句、for()语句,每种语句都具有控制表达式,当控制表达式的值不等于0则会重复执行循环体,当控制表达式的值等于0时终止循环体的执行。
while()语句
do...while()语句
for()语句
循环语句内部的循环体一般都需要结合break语句、continue语句,都属于跳转语句!!!!!!
四、数组
1.数组的概念
数组就是数据的集合,简单的说数组就是由n个数据组合在一起,数组的英文是Array。数组其实就是用户向内核申请的一块空间,只不过内核提供的这块空间的内存地址是连续的,目的就是方便用户存储数据和访问数据。
思考:既然用户可以向内核申请一块连续的空间来存储数据,那用户如何访问这块内存呢?
回答:首先向内核申请内存时如果申请成功则操作系统会把内存的起始地址提供给用户,用户可以通过存储单元的地址访问,但是程序的可读性以及可维护性变差,所以C语言允许用户像访问变量一样,可以对数组进行命名,这样系统内核就会把数组名和数组的起始地址建立映射关系。
思考:既然数组在内存中是一块连续的空间,那如果用户打算存储的数据的类型不一致,计算机如何区分数据的类型?
回答:计算机不用区分,因为只需要让数组中只能存储相同类型的数据,可以避免二义性出现。
2.数组的定义
回答:用户需要说明 数据数量 * 数据宽度 ,对于数据宽度而言指的是数据类型 ,数据的类型可以是基本数据类型(字符型、整型、短整型、浮点型) or 复杂数据类型(结构体、联合体) or 指针类型。
定义数组的格式: 数据类型 数组名称 [数据个数] ; // 内存大小:数据类型 * 数据个数
3.数组的访问
思考:如果把数据存储到数组中,用户如何访问数组中某个数据? 可以参考C语言标准
思考:既然数组名表示数组首个元素的地址,那用户除了通过数组下标访问数组元素外,是否可以直接通过内存地址来访问数组中的数据?如果可以那如何直接通过地址访问数组呢?
回答:C语言标准中规定可以通过 下标方式访问数组的元素 E1[E2] 也可以通过 内存地址 方式访问数组中的元素 ( * ( (E1) + (E2) ) ) ,注意:如果E1就是数组名,并且E2就是整型常量,则可以把括号省略,变为 ( * (E1 + E2) ) 。
思考:为什么C语言中规定*( E1 + E2 )可以访问数组元素地址,是什么原理?*有什么作用?
回答:C语言标准中提供多种运算符,* 可以作为二元运算符, * 作为乘法运算符,需要。两个操作对象,并且遵循“左结合性”,但是 * 也可以作为一元运算符, * 的含义就是间接运算符。
*( E1 + E2 )的解释:E1是一个数组对象,E1也就是数组名称,C语言标准中规定数组名可以作为数组中第一个元素的地址,所以相当于E1是数组中第一个元素的地址,而E2是一个整数,所以 E1 + E2 相当于从E1这个地址向后偏移E2个单位(以元素为单位,所以需要考虑元素的类型),所以E1+E2的结果还是一个地址, *( E1 + E2) 相当于间接访问该地址,相当于得到了(E1+E2)这个地址下的值。 总结: *(E1+E2) == E1[E2]
可以知道,如果E1是数组名,E2是整型常量,则E1[E2]可以等价于 E2[E1],这两种方式都可以访问数组中的元素。
思考:如果用户定义了一个整型数组 int buf[5]; 那么 (buf+1) 指的是数组地址向后偏移一个元素对应的单元大小,也就是地址向后偏移了4字节,请问 (&buf+1) 表示什么意思,应该如何解释?
回答:(&buf + 1)表达式中存在地址运算符,&可以是一元运算符,也可以是二元运算符,使用规则如下:
C语言标准中提到数组名可以用于表示数组的第一个元素的地址,但是此时有两种例外情况。
第一种情况: 当 数组名 和 &地址运算符 一起使用时,数组名就不表示数组首元素的地址,而表示取数组 a
的地址,这个地址的类型是指向整个数组的指针,即 int (*)[n]
类型,表示指向一个包含n个整数的数组的指针。
(&buf+1) 可以知道 &取地址符和数组名一起使用时,数组名不表示数组第一个元素的地址,而表示数组本身的地址,所以+1的动作是向后偏移整个数组的大小。
第二种情况:当 数组名 和 sizeof()运算符 单独使用的时候,数组名就不表示数组首元素地址,而表示数组本身。
4.数组初始化
思考:用户为数组申请的内存空间是由内核挑选的,那内存地址中是否会存储一些意想不到的值,如果会,那用户如何对数组进行初始化?
回答:C语言标准中规定数组可以进行初始化,注意:只有定义数组的同时进行赋值才叫初始化!
思考:语句 int buf[10] = {0}; 可以把数组的每个元素都设置为0,那 int buf[10] = {1}; 是否表示把数组的每个元素都设置为1?
思考:如果用户在定义数组时还没想好要存储的数据的个数,那数组[]里面是否可以空着不写? 语法上是否符合? 比如 int buf[]; //用户没有填写数据元素的个数
思考:如果用户定义数组时并未在[]中说明数组元素个数,但是在定义数组时已经对数组进行初始化,所以系统会自动计算数组所需要占用的内存大小,请问如何计算出数组的有效长度以及如何计算数组元素个数?
回答:可以利用sizeof()运算符来计算数组的容量,计算出的数组大小是以字节为单位,然后再用数组容量 / 数组中元素的类型就可以得到数组中元素的个数。
思考:用户定义了一个数组,并且也对数据正确进行了初始化,但是用户后面准备存储新的元素到数组中,想要把之前存储的元素清空,由于定义数组已经做过初始化的,是否意味着只能把数组中的元素一个一个单独清空?
回答:不需要,可以调用库函数 bzero() 以及 memset() ,可以专门对数组进行处理,尤其是清空数组的处理。memset() 比 bzero() 更灵活。
- bzero()
memset()
思考:用户定义了一个数组,并且也对数据正确进行了初始化,但是用户不小心把超过数组大小的数据赋值给数组,请问编译器是否会报错?以及用户是否可以这么操作?
回答:编译一般是不会报错,甚至于警告都不会出现,但是在程序运行阶段可能会导致内存错误(段错误),现在的现象是数组出现越界访问的情况,如果刚好访问的内存是有访问权限的,则运行也不会报错,但是如果访问的内存是没有访问权限的,就会段错误,所以就需要用户设计程序要谨慎细心。
思考:用户定义一个数组,但是在定义数组之后并没有进行初始化,而是在定义数组之后想要对数组初始化,请问是否可以,如果可以,怎么做?
4.数组种类
- 字符型数组
一般实际开发中,使用数组一般都是为了存储字符序列,C语言中的字符串也属于字符序列,字符串需要使用双引号””进行限制,双引号””表示字符串的首字符的地址,字符串的结束以’\0’作为结束。
思考:用户定义了一个字符数组 char buf[5]; 用户想要把一个字符序列abcde这5个字符存储到字符数组中,提供两种方案: char buf[5] = “abcde”; char buf[5] ={‘a’,’b’,’c’,’d’,’e’}; 请问两种方案有什么区别?
回答:如果数组的容量刚好和字符串常量中的有效字符的数量一致时,就会导致数组越界,因为字符串常量的末尾有一个转义字符’\0’,也是需要占用1个字节的存储单元。
64位:
数组型数组
思考:既然数组中可以存储某个类型的数据,那数组本身也是一个类型,那能否在数组中存储数组呢?如果可以,应该怎么做?
回答:对于数组型数组而言,就称为多维数组,但是注意:维度是针对用户而言,内存是线性的,是不分行和列的,所以多维数组其实和一维数组的本质是一样的,都是为了申请一块连续的内存空间,并且内存空间存储的数据的类型是一致的,这个只需要把数组作为元素来看待即可。
注意:不管是几维数组,数组的定义规则: 数组名[元素数量] + 元素类型 比如 int buf[5]
二维数组定义格式 : 元素类型 数组名称[元素数量][元素数量] 比如 int buf[2][3] = {0};
思考:如果定义的是多维数组,那如何去访问多维数组中的某个元素? 应该如何设计程序?
回答:就可以通过下标的方式或者地址的方式进行访问,下标的方式: int buf[3][4]; 则如果打算访问 buf[1][1] ,就表示访问元素如下图
通过地址的方式访问: int buf[3][4]; 则如果打算访问 buf[1][1] ==> * ( ( *(buf + 1) ) + 1 )
柔性型数组
思考:用户定义一个数组,但是在定义数组的时候没有想清楚数组的元素数量,所以能否使用一个变量来代替数组元素个数呢?如果可以,那是否意味着用户可以在运行程序的时候通过键盘对变量赋值,从而实现手动控制数组元素个数?
回答:柔性数组在C89标准中是不支持的,是C99标准引入的概念,柔性数组也被称为变长数组,但是注意:当数组的内存一旦确定,则不会因为变量发生变化导致数组长度变化!
匿名型数组
C99标准中支持匿名数组,但是匿名数组一般都是在函数参数中或者在多维数组中使用,很少单独使用。
比如二维数组 int buf[3][4]; ---> buf[3] 数组的每个元素的类型是 int [4] ,就是匿名数组。
零长度数组
GNU组织在C99标准的柔性数组的基础之上拓展了一个新的概念,叫做零长数组,也就是数组的长度可以是0,但是由于数组长度是0,所以操作系统是不会提供内存单元给数组的!
注意:零长度数组是不会得到内存,但是是可以访问的,一般都是结合C语言的结构体一起使用,可以用于对结构体进行拓展,所以零长度数组也属于柔性数组的一种。
五、指针
1.C语言的指针
程序是需要载入内存中运行,内存是有范围的,对于32位系统,内存地址范围是0x0000_0000~0xFFFF_FFFF,也就是内存大小为4GB,内存地址指的是内存中单元的编号,编号是固定的。所以内存地址(存储单元的编号)本质就是一个整数,对于32位系统而言,地址所对应的编号是4字节的正整数。
思考:既然用户可以定义变量来存储数据,那能否把内存地址当成数据存储在一个变量中?
回答:是可以的,因为存储单元的地址本质就是一个整数,如果是在32bit系统下,则只需要4个字节的存储单元就可以完成存储。
思考:既然内存地址可以当做数据存储在一个变量中,那内核如何区分变量中的数据是作为普通数据还是作为内存地址呢?
回答:操作系统不需要区分,但是作为用户而言,需要区分该变量下存储的是地址还是普通整数,所以C语言表中规定:用户如果打算定义一个变量来存储一个内存地址,则需要定义变量的时候指明该变量中存储的是一个地址。
C语言中把用于存储地址的变量称为指针变量,因为通过变量中的地址可以指向某个存储单元! 指针指向的是地址,所以可以把指针理解为地址,也可以把地址当做指针使用,注意:如果打算获取某个地址下的值,必须使用 * 间接运算符 , *地址 == 地址下的值
指针变量定义格式:数据类型 *变量名; 比如 int *p; or char *p; or int * buf[5];
思考:既然指针变量可以存储一个内存地址,那请问内核是否会为指针变量分配内存空间?
回答:当然会分配,因为定义变量的目的就是为了申请内存单元,32bit系统下需要4个存储单元才能记录一个地址,而记录的地址和变量本身的地址是不一样的。变量的存储单元就相当于是一个容器,记录的地址就相当于数据而已。
思考:用户定义了一个指针变量,但是此时并没有打算让该指针变量指向某个内存地址,请问内核分配给指针变量的内存空间中是否会存储一些未知的数据?如果存在,应该如何解决?
回答:是会的,所以为了提高程序的可靠性,为了避免异常出现所以就算不存储有效地址,也应该对定义的指针变量进行初始化,注意:对指针变量进行初始化,则应该把指针变量对应的内存初始化为0,但是0只是一个整数,并不是地址,而指针变量就是应该存储地址。
用户应该把普通整数0进行强制转换,转换为一个地址 0x00000000 --> (void *)0x00000000
int *p = (void *)0x00000000; //可读性较差,所以C语言中提供了一个宏定义 NULL 空指针。
可以看到,linux系统的内存中有一部分内存是属于保留区,保留区地址范围就是0x0000_0000 ~ 0x0804_8000,属于用户没有权限访问的内存空间,一旦用户访问这块区域,就会导致段错误。
int *p = NULL; //对指针变量进行初始化,目的是防止野指针出现,为了避免段错误的!!!
思考:已经知道内存中有一块保留的空间,程序是没有权限访问的,但是用户能否定义一个指针变量指向这块空间?
回答:是可以的,但是只能用指针变量记录该地址,但是不能通过指针变量间接访问该地址,如果间接访问,则会导致内存异常,发生段错误。
思考:既然指针变量中存储的是一个内存地址,内存地址的本质就是一个整数,所以能否对整数进行算术运算呢?
回答:是可以的,只不过普通整数的算术运算和地址的算术运算的理解是不同的,一般对于普通整数可以进行算术运算,则结果也是一个整数。但是对于指针变量中存储的地址进行算术运算,一般只能进行加法运算和减法运算,对地址进行加法运算和减法运算,其实就是对地址进行偏移而已,偏移的单位一般是应该以字节为单位。
注意:对于指针变量的偏移,要考虑到变量中存储的地址的数据类型,所以 地址 + 1 不表示存储单元向后偏移1个字节,应该是向后偏移 (1 * 数据类型)个字节。
思考:既然可以用指针变量指向另一个变量的地址,请问能否用指针变量指向数组的地址?
回答:当然可以,就相当利用指针变量来对数组的地址进行备份,提高了访问数组的安全性,而利用指针变量来指向数组的地址,被称为数组指针!!!! int buf[5]; int *p = buf;
如果此时用户需要访问数组中的元素,可以通过数组下标 and 指针访问,两者的区别如下:
思考:
思考:用户打算定义一个二维数组,并且用一个指针变量来存储数组的地址,现在想用指针变量来访问二维数组中的元素,请问如何访问?
思考:既然数组可以存储同一类型的数据,请问能否在一个数组中存储指针变量的地址???
回答:是可以的,如果在一个数组中,每个元素都是一个指针,则C语言中把这种结构称为指针数组。
指针数组的定义格式: 数据类型 *数组名[元素个数]; // 比如 int *buf[5];
注意:[]后缀运算符 优先级高于 *间接运算符 ,所以 buf[5] 作为一个整体,剩余部分就是数组中元素的类型,所以 int *就是数组中元素的类型,其中int是用于修饰指针指向的地址中数据的类型。
思考:既然可以使用一个指针变量来存储另一个变量的地址,能否定义一个指针变量,然后来存储另一个指针变量的地址?如果可以,那如何可以访问到最终内存地址下的数据?
回答:是可以的,如果一个指针变量中存储的地址是另一个指针变量的地址,则把这种结构称为二级指针。
二级指针定义格式: int data; //整型变量 int *p1 = &data; //指针变量 int **p2 = &p1;
用户现在定义一个int buf[5] = {1,2,3,4,5}; 现在用户定义一个数组指针来存储数组的地址, int *p = buf; 请问 printf(“%d\n”,*p++); printf(“%d\n”,(*p)++); 请问两句话的输出结果?
六、函数
C语言程序的基本单位是函数,C语言是面向过程的一门编程语言,采用“自顶向下”的设计思想,采用的方案是把一个大问题拆解为很多个小问题,每个小问题单独进行解决,每个小问题可能需要多条语句才能解决,为了提高效率,所以就把可以解决问题的多条语句构成一个块语句,C语言中把这种块语句就称为函数。
1.函数定义
思考:既然函数可以很大程度提高开发效率,应该如何去定义一个函数呢?有没有注意事项?
//函数有参数列表,则应该在函数名称的()中写清楚每个参数的类型,以及每个参数的名称
函数类型 函数名称(参数1类型 参数1名称,参数2类型 参数2名称.........)
{
}
//函数的参数是可以可选的,如果没有参数,则需要在函数名称的()中填写void即可
函数类型 函数名称(void)
{
}
注意:void在C语言标准中是一个关键字,含义具有空的意思,所以如果在参数列表中出现,则表示函数没有参数,同样,如果void是函数类型,则表示函数没有返回值。
注意:函数的类型其实指的是函数的返回值的类型,C语言标准中规定函数类型可以是void或者是对象类型(基本数据类型 int char long float... + 复杂数据类型 struct union.... +指针)
但是函数的返回值类型不允许是数组!!!!
注意:如果函数有返回值类型,则函数内部的需要返回的数据的类型必须要和函数的返回值类型一致,则需要在函数内部调用return语句实现。
int 函数名称(void)
{
return 3.14; //不允许,因为实际返回的数据的类型和定义函数的时候声明类型不一致
}
void 函数名称(void)
{
return 10; //不允许,因为void作为函数的类型,表示函数是没有返回值的!!!!!!!!!
}
int [10] 函数名称(void)
{
int buf[10] = {1,2,3,4,5};
return buf; //不允许,因为函数的返回值类型不允许是数组,但是可以选择传递地址!
}
int * 函数名称(void)
{
int buf[10] = {1,2,3,4,5};
return buf; //允许的,因为函数的返回值类型不允许是数组,但是可以选择传递地址!
}
思考:既然C语言程序的基本单位是函数,能否在一个已经存在的函数中定义一个新的函数?
回答:不可以!C语言中函数都是独立的个体,不允许在一个函数内部定义新的函数,但是允许在一个函数内部调用其他的函数!设计函数应该做到低耦合,高内聚!
2.函数调用
思考:如果用户打算封装一个函数实现某个功能,但是此时用户还没想好函数对应的块语句怎么写,只是把函数的名称和返回值类型以及参数列表写了出来,那能否在一个函数中进行调用?
回答:是可以调用的,但是遵循一个“先定义,后使用”原则,由于C语言中程序都是以函数为单位,并且程序的入口是主函数main(),所以应该把用户自定义的函数定义在main()函数之前,然后在main()函数中进行调用。
但是,有时用户可以在程序设计时是先在main()中调用了某个自定义函数,然后在main()函数后面定义了子函数,此时编译会报错,会提示:子函数未定义,为了避免此类问题,C语言中也是支持“先声明,后定义”。
3.函数参数
思考:既然一个函数可以对数据进行处理,请问如何把要处理的数据传递给函数?应该如何操作?
回答:需要在设计函数的时候说清楚函数需要传递的参数的类型以及参数名称,都是在定义函数的时候通过函数的参数列表传递。
函数的参数列表是在后缀运算符()里面进行填写,()中的参数只是一个函数的助记符, 只是为了描述需要传递给函数的参数,所以函数的参数一般称为形式参数,简称为形参。而定义函数的时候函数参数列表中的形参是不占内存的,只是为了提醒用户参数的数量和类型!
用户在调用函数接口时,需要按照函数的参数列表来向函数提供对应的数据,数据的数量和数据的类型必须和形参一致。
注意:当一个函数被调用之后,函数的形参才会得到对应的内存,并且函数的形参的内存只会在函数内部生效,当函数调用完成后,则函数形参的内存会被系统自动释放。
注意:当用户调用一个函数时,如果函数有参数列表,则用户需要提供对应的数据给函数,而用户提供的数据的类型必须和函数参数类型一致,用户实际提供的数据被称为实际参数,简称为实参,而实参是必须存在的,实参的形式可以是表达式、常量、变量、地址........
- 单向传递
单向传递:只是把实参的值传递给函数作为参数,在函数内部对数值进行修改是不会影响外部实参的!
思考:一个函数的参数列表中的参数有对应的类型和名称,那另一个函数在调用该函数的时候,传递给函数的需要处理的数据(实参)的类型和名称是否需要和参数列表(形参)完全一致?
回答:实参的名称和函数形参的名称不需要一致,只需要确保实参的类型和函数形参的类型一致即可,如果类型不一致,则会出现数据精度异常。
思考:既然函数数据传递的过程是单向的,请问用户如何获取函数内部对数据的处理结果呢?
回答:可以通过函数的返回值获取函数的处理结果,函数中调用return语句可以把结果返回给被调用的位置。
- 双向传递
如果不打算调用return语句,则可以选择把实参的地址作为参数传递给函数内部,这样函数内部对地址中的数据进行修改,则函数外部的实参地址下的值也会变化,只不过此时函数参数类型应该是指针才可以。
- 生命周期
思考:程序中全局变量和局部变量在使用的时候是否有区分?有哪些使用细节需要注意??
回答:对于生命周期是指变量的生命周期,也就是变量从得到内存到释放内存的时间就是变量的生命周期,程序中变量如果按照存储单元的属性分类,可以分为变量和常量,也可以按照生命周期进行划分,可以分为全局变量和局部变量。
- 局部变量:在函数内部定义的变量或者在某个复合语句中定义的变量都称为局部变量!
- 全局变量:在所有的函数外部(在所有复合语句外部)定义的变量就被称为全局变量!
- 作用范围
作用范围指的是定义的变量的作用域,也就是变量的有效使用范围,对于全局变量而言,作用域是针对整个程序,所以程序中任何一个函数都有访问权限。对于局部变量而言。作用域只针对当前局部变量的复合语句有效。
注意:当全局变量的名称和局部变量名称相同时,则应该遵循“就近原则”,也就是应该优先使用同一个作用域内的变量,如果该作用域中没有该变量,则可以扩大作用域。
- 数组传递
思考:通过学习已经知道可以把变量的值或者地址当做参数传递给函数进行处理,但是有时用户需要连续处理多个相同类型的数据,但是不想定义多个形参,请问如何解决该问题???
回答:可以选择把多个类型相同的数据构造为一个数组,然后把数组作为参数传递给函数,本质就是把数组的地址传递过去,此时分为两种方案:
思考:如果把数组的地址当做参数传递给函数,那用户如何知道实参数组的长度是多少???
回答:如果打算把数组作为参数传递给函数,则应该连同数组的长度一同作为参数传递给函数,而数组长度应该使用sizeof进行计算。
思考:既然可以把一维数组的地址作为参数传递,请问能否把多维数组传递给函数处理???
回答:一维数组和多维数组其实没有区别,因为都是把数组的首地址传递过去,只不过在函数内部访问数组元素的时候有一些区别。
思考:如果用户打算在一个函数中定义一个数组用来存储已经处理好的数据,但是C语言中规定不允许返回一个数组类型,当函数调用完成后函数内部的内存会被内核释放掉,也就意味着处理好的数据都会丢失,请问应该如何处理?
回答:由于C语言不支持函数的返回值类型是一个数组,但是可以选择把数组的地址作为返回值,此时函数的返回值类型就应该是指针才对。
思考:程序中数组的类型和函数的返回值类型是一致的,为什么编译程序会报警告,运行时也出错,是什么原因导致的?应该怎么解决?
出现段错误的原因:因为子函数中的变量buf的生命周期是在函数内部的,所以当函数调用完成后,则数组buf的内存会被系统自动释放,此时数组buf的地址对应的存储单元就没有访问权限了。
虽然得到了数组buf的地址,但是由于用户没有该地址的访问权限,所以访问时会出现段错误。
- 解决方案:可以选择把函数内部的数组定义为全局变量,此时程序中任意函数都可以访问,并且数组内存是在程序终止后才会被释放。
- 解决方案:可以选择把函数内部的局部变量的生命周期延长,此时需要使用C语言中的存储类修饰符,就是C语言关键字之一的static关键字,static具有静态的含义,可以把局部变量的生命周期进行延长。
从内存角度分析:如果在函数内部定义一个局部变量,则系统会从内存分区中的栈空间中分配一块内存给局部变量,栈空间是由系统自动管理,所以当函数调用结束时系统会自动释放该局部变量的内存。
如果函数中定义的局部变量使用static关键字进行修饰,则系统会从全局数据区分配内存空间给该局部变量,全局数据区的生命周期是跟随程序的,不会因为函数结束而释放。
static除了可以修饰局部变量外,也可以用于修饰函数,如果一个函数在定义的时候使用static关键字进行修饰,则可以限制函数的作用域为文件内部有效。
4.内存分布
思考:请问什么是栈空间以及什么是全局数据区?两者之间有什么联系?如何区分变量是在栈空间还是全局数据区?
如果采用的是32bit的linux系统,则每个运行的程序都会得到4G大小的内存空间,只不过每个程序得到的4G大小的内存都是虚拟内存,而物理内存才只有4G,物理内存是真实存在的,而虚拟内存是通过映射得到的。
虚拟内存是由物理内存映射而来,所以都需要计算机中的重要部件:MMU 内存管理单元!
- 保留区
保留区也可以称为不可访问区域,用户是没有权限访问的,对于Linux系统而言,保留区的地址范围是0x0000_0000 ~ 0x0804_8000,所以保留区的大小是128M,一般用户定义的指针变量在初始化的时候就可以指向这块空间,由于这块空间任何程序都没有权限访问,所以可以确保指针不会被误用,所以可以防止野指针出现,宏定义NULL其实就是指向0x0000_0000.
- 代码段
程序由数据以及指令组成,代码段存储的是编译器对程序编译之后生成的二进制指令,代码段分为两部分,分别是.text段和.init段。
.text段用于存储用户程序生成的指令,.init段用于存储系统初始化的指令,这两部分的属性是只读的,在程序运行之后代码段中的数据就不应该再被修改。在程序运行之前代码段的内存空间就已经被内核计算完成。
- 数据段
程序由数据以及指令组成,根据数据的生命周期和数据类型的不同,一般把数据存储在两部分,一个部分是栈空间,另一个部分是数据段。
数据根据数据类型(变量or常量,全局or局部)以及根据数据是否被初始化(已初始化or未初始化)把数据存储在三个不同的位置:.rodata段 .bss段 .data段。
- .rodata段:被称为只读常量区,程序中的常量(整型常量、字符串常量)都是存储在该区域,对于该区域的属性是只读的,当程序结束后该区域的内存会被释放。
- .data段:用于存储程序中的已经被初始化的全局变量和已经被初始化的静态局部变量,另外注意初始化的值不能为0!
- .bss段:用于存储程序中未被初始化的全局变量以及未被初始化的静态局部变量以及初始化为0的全局变量和初始化为0的静态局部变量。
- 堆空间
堆空间属于用户可以随意支配的内存,用户想要支配堆空间的内存的前提是需要向内核申请,可以通过库函数malloc()、calloc()申请堆内存,注意堆空间需要用户手动申请以及手动进行释放,通过库函数free()释放堆内存。堆内存属于匿名内存,只能通过指针访问!!!
- malloc()
利用malloc申请堆内存,则申请成功的堆内存是未被初始化的,所以用户应该对申请的堆内存进行初始化,同样,malloc只需要一个参数,该参数指的是需要申请的堆内存的大小,以字节为单位。该函数的返回值是申请成功的堆内存的首地址,但是该地址的类型是void*,则用户应该对该地址进行强制转换,如果申请失败,则函数返回NULL,所以用户应该进行错误处理!
- calloc()
calloc函数可以申请堆内存,calloc有两个参数,第一个参数是要申请的内存块的数量,第二个参数是内存块的大小,所以申请的内存的总大小 = 内存块数量 * 内存块大小,相当于是数组结构。该函数的返回值是申请成功的堆内存的首地址,但是该地址的类型是void*,则用户应该对该地址进行强制转换,如果申请失败,则函数返回NULL,所以用户应该进行错误处理!
- free()
注意:由于堆空间是由用户进行支配,所以用户申请成功之后,使用完成后需要及时释放堆空间,并且必须手动释放,并且必须只能释放一次,如果不释放,则会导致内存泄漏!
另外,当把申请的堆内存释放之后,则应该同样把指向堆内存首地址的指针的地址指向NULL!
- 栈空间
栈空间主要用于存储程序的命令行参数、局部变量、函数的参数值、函数的返回地址,当函数被调用期间,内核会分配对应大小的栈空间给函数使用,当函数调用完成则栈空间就会内核释放。
栈空间的内存存储是随机值,所以用户得到栈空间之后,应该把变量进行初始化,目的是防止变量中存储的值是不确定的。
对于栈空间的地址分配是向下递增,所以栈空间使用的越多,则分配的内存地址越低,栈空间的数据遵循“先进后出”原则,一般内核都会提供两个指针,一个指针指向栈顶,一个指针指向栈底,数据进入栈空间的动作就叫做入栈/压栈(PUSH),数据从栈空间出去的动作就叫做出栈/弹栈(POP)。
注意:Linux系统中栈空间的容量是有限的,如果超过容量,则会发生栈溢出,导致程序出现段错误。
思考:既然linux内存的栈空间大小是有限制的,请问栈空间的大小是多少?能否修改大小?
回答:linux系统的栈空间的大小默认是8M,是允许修改的,可以利用linux系统的ulimit命令来查询栈空间的大小以及修改栈空间的大小。注意:栈空间的大小的修改是临时性,是针对当前终端的,不是永久有效的!
七、const和递归
1.const常量关键字
思考:可以看到C语言标准中对于main函数的第二个参数的约束是 char * argv[],但是代码编辑器在定义main函数的时候为什么是 char const *argv[] ? 为什么多了一个const,这个词表示什么意思,有什么作用?
回答:const是C语言的关键字之一,其实是英文constant的缩写,具有常量的含义,const关键字在C语言标准中是类型限定符,一般用于修饰变量的,可以用于降低变量的访问权限,相当于把变量的属性变为只读,变量的存储单元只能读,不能写。
举例: int data = 10; data = 20; //变量是可读可写的 const int data; //只读变量
思考:C语言中明明支持定义常量,为什么还需要定义一个变量,再把变量的权限变为只读?
回答:程序的中的常量都是存储在只读常量区(.rodata段),但是由于这个段的属性是只读的,并且没有办法通过名称来访问常量,所以就可以定义变量,通过变量名称来间接访问在程序运行期间不需要修改的常量的值!
定义格式: const int data; // 不行,因为变量的存储单元已经变为只读 data = 10; 会报错
注意: 如果需要利用变量来存储一个常量,则需要在定义变量的时候利用const关键字修饰,并且一定要完成初始化!
思考:既然const可以修饰普通变量,那是否可以修饰指针变量呢?如果可以修饰指针变量,那请问 int *const p; 和 const int *p; 是否有区别?有什么区别?
回答:const关键字是可以修饰指针变量,但是 int *const p; 和 const int *p;是有很大区别的!
- int *const p;
可以看到const离变量名称更近,const是修饰变量p的,而变量p是一个指针变量,变量p用于存储一个地址,但是变量p本身也可以得到存储单元,相当于降低了变量p的存储单元的属性,也就是变量p中存储的地址就不能发生变化,所以这个指针变量就称为指针常量。
- const int *p;
可以看到const关键字离指针变量p指向的地址下的数据类型更近,所以const是用于修饰指针变量p指向的地址的,所以也可以写成 int const *p;变量p的存储单元是没有受到影响的,而是变量p中存储的地址的权限降低为只读了,可以为变量p是美杜莎之眼,被称为常量指针。
注意:只有通过指针变量p来间接访问地址下的值时,才会出现错误,因为变量p中存储的地址被变量p影响了,权限降低为只读了,但是只要不通过变量p来间接访问,则就不会受到变量p的影响。
2.递归思想的应用
思考:C语言程序的基本单位是函数,每个函数都可以解决一个问题,但是如果此时一个程序中有n个相同的问题出现,则就需要调用对应的函数n次,这样会导致程序冗杂,可读性较差,请问是否有更为简单的方案来解决对应的问题呢?
回答:可以使用递归函数解决,当然注意递归函数不是万能的,一般用于解决数学问题,递归函数指的是在一个函数内部反复调用自己的函数,递归函数具有递进和回归的过程,就相当于把一个类似的大问题拆分为很多类似的小问题,再把每个小问题的结果作为上一个问题的答案,一层一层进行解决。
注意:使用递归函数的时候要谨慎,必须要提前写清楚终止条件,如果不写终止条件,就会变为死循环,相当于一直调用自己,由于每调用一次函数,内核都会提供一块栈空间,就会栈溢出,从而发生段错误,导致程序崩溃。
题目:用户打算设计程序,用户通过键盘输入一个正整数n,然后设计一个递归函数,用来求1 * 2* 3* 4 *....*n的结果,请问如何设计程序。
八、结构体
1.基本概念
思考:如果一个函数需要处理很多种不同的数据,那就需要传递很多参数,会导致用户传递参数时容易发生混乱,并且程序可读性也不好,请问有没有方案可以实现多种类型的数据传递,并且又不需要传递多个参数呢?如果有,应该如何设计程序?
回答:当然是有的,为了方便用户传递数据,所以C语言标准中提供一种数据类型叫做结构体,也被称为用户自定义数据类型或者复杂数据类型,指的是用户有权利在程序中设计一种新的类型。
C语言标准中提供了一个叫做struct的关键字,是英文structure的缩写,具有结构的含义,一般在C语言中利用该关键字设计的类型被称为结构体。
定义格式:
struct 结构体名称
{
数据类型 成员名称;
数据类型 成员名称;
数据类型 成员名称;
....
}; //注意:复合语句后的分号不可以省略
注意:构造的结构体类型是不占内存的,只有使用该类型创建的变量才会得到内存空间!!!
2.成员访问
思考:用户定义了一个新的struct结构体数据类型,并且结构体类型中包含了很多的成员,请问用户如何访问结构体中的成员呢?
- 通过.访问
可以看到,后缀运算符. 有两个操作对象,第一个操作对象是结构体或者联合体的类型,第二个操作对象是成员的名称。
- 通过->访问
思考:用户设计了一个结构体类型,并且在程序中利用该类型创建了一个结构体变量,但是用户打算在定义变量之后就对结构体进行初始化,请问如何实现?
优点:可读性好,可维护性高,更可靠 缺点:代码冗杂 ,可以忽略不计
3.空间大小
思考:用户设计了一个结构体类型,结构体类型中包含很多成员类型,那用户在定义结构体类型的变量时内核会分配对应的内存空间,请问内核会提供给结构体变量多少内存空间?如何计算结构体变量所需要的内存大小?
回答:可以通过C语言标准中提供的sizeof运算符来计算机结构体变量的大小,sizeof运算符的结果是以字节为单位。
可以看到,理论上计算出的结构体类型需要的字节宽度是166字节,但是实际上64bit系统计算出的字节宽度是168字节,所以出现偏差。
原因:计算自定义数据类型宽度的时候,要考虑CPU的运行效率,一般嵌入式系统都采用32bit系统,所以CPU的地址总线是32bit,所以为了提高CPU的工作效率,所以寻址都是4字节为主,也就是当数据宽度不够4字节,则系统默认提供4字节内存方便CPU寻址,所以这种方式被称为字节对齐。
所以计算结构体类型的大小时考虑计算机字节对齐是非常典型的以空间换时间的案例!!!!!
验证技巧:计算出的结构体的大小应该是结构体类型中字节宽度最大的成员的整数倍!!!!!
思考:内核在为结构体变量分配内存单元的时候会进行地址对齐,好处是提高了CPU的寻址效率,但是同时也可能会导致内存空间的浪费,对于某些嵌入式产品而言,内存大小是极其有限的,请问是否有办法可以让内核在分配内存单元时采用“按需分配”的原则?
回答:是可以的,只不过需要使用C语言标准的一个预处理指令 #pragma ,当然该预处理指令和编译器相关,C语言标准中的预处理指令 #pragma pack(n)可以用于进行字节对齐以及取消字节对齐。
#pragma pack(n)可以用于进行字节对齐以及取消字节对齐,n的值可以是1、2、4、8.......
4.变量定义
思考:用户定义了结构体类型之后,可以利用该结构体类型定义多个变量,但是在某些情况下用户只打算定义一个结构体类型的变量专门用来存储唯一且重要的数据,后期并不打算继续使用该结构体类型定义新的变量,请问是否有方案来满足这种需求?
5.别名定义
思考:用户在使用结构体类型创建结构体变量的时候,需要写比较繁琐的语句,可能会导致程序可读性不好,请问是否有更加简单的方案来实现相同的效果?如果有,那应该如何设计?
回答:可以使用C语言中提供了的typedef关键字,其实是type define的缩写,用于给某个数据类型起一个别名,不会创建新的类型。
typedef关键字对数据类型起别名,应该是针对整个程序而言,所以不应该在某个复合语句内进行声明。
使用格式: typedef 数据类型 别名; 举例 : typedef unsigned int uint32_t;
潜规则:如果打算把某个数据类型起一个新的别名,在使用typedef的时候,新的别名应该在命名的时候末尾添加 _t
举例 : typedef unsigned int uint32_t; //为了提高可读性,用户可以知道这个是别名!!
思考:既然用户可以通过struct关键字定义一个新的数据类型,那是否可以定义一个结构体数组呢?
回答:是可以的,数组的元素可以是结构体类型,但是必须为相同的结构体类型,也就是结构体类型的数据宽度要一致。
6.函数传参
思考:用户通过struct关键字定义一个新的数据类型,并且打算封装一个函数来处理结构体类型的数据,请问是否可以把结构体类型作为函数的形参?在函数形参中是否可以使用typedef起的别名?
思考:既然可以把结构体作为函数参数,请问是否可以把结构体指针作为函数的参数呢,如果可以,那在函数中应该如何访问结构体中的成员?
九、联合体与枚举
1.基本概念
某些情况下用户只打算使用一块空间来存储不同类型的数据,而这些数据又是互斥的,也就是不会同时出现,为了节约内存空间,所以C语言提供了另一种复杂数据类型,那就是联合体。
C语言标准中提供了一个叫做union的关键字,用于构造联合体类型,联合体也被称为共用体,指的是联合体中的各个成员是共用一块内存空间,所以联合体的内存空间就是以成员中数据宽度最大的那个成员为主。
定义格式:
union联合体名称
{
数据类型 成员名称;
数据类型 成员名称;
数据类型 成员名称;
....
}; //注意:复合语句后的分号不可以省略
注意:联合体变量中的成员由于是共用一块内存,所以每个成员的起始地址都是相同的,只要修改联合体中任何一个成员的值,都会影响其他成员的值,另外,也不应该同时对联合体中的多个成员进行赋值。
2.成员访问
- 通过.访问
可以看到,后缀运算符. 有两个操作对象,第一个操作对象是结构体或者联合体的类型,第二个操作对象是成员的名称。
- 通过->访问
思考:内核为结构体变量分配内存单元的过程中会遵循内存对齐原则,请问内核为联合体变量在分配内存单元的过程中是否同样遵循内存对齐原则呢? 回答:也是遵循字节对齐!!!
注意:联合体很少单独使用,一般都是作为结构体中一个成员而已,灵活一些,如果在笔试过程中遇到判断机器大小端的问题,则应该使用联合体解决,才能拿满分!
3.C语言的枚举
为了提高程序的可读性以及可维护性,方便后期对程序的迭代,所以C语言标准提供了一种数据类型,就是枚举类型,可以很轻松的提高程序可读性。
C语言中规定,枚举类型也属于用户自定义类型,用户通过关键字enum可以实现枚举类型的设计,enum是C语言关键字之一,是英文单词enumeration的缩写,中文具有枚举的含义。
注意:枚举就是把一些没有意义的数字(整数)起一个有意义的名称,利用该名称就相当使用该整数常量,是为了提高程序可读性!!
十、宏定义
1.基本概念
C语言中可以利用宏定义实现文本的快速替换,注意:宏定义是单纯的文本替换,不检查语法是否合法。
C语言标准中提供了很多的预处理指令,比如#include、#pragma......以#开头的都属于预处理指令。
预处理指令指的是在gcc编译套件中的cpp预处理器对程序进行编译之前所做的一些动作,比如#include预处理指令就是在程序编译之前由预处理器把包含的头文件中的代码拷贝一份到源文件对应的位置,如果包含的文件中还有其他的预处理指令,会递归执行!
C语言标准中还提供了#define预处理指令,define是C语言关键字之一,中文具有定义的含义,所以利用#define预处理指令可以对某些表达式、某些常量、某些函数进行定义,其实就是给这些内容起一个可读性较高的名称。
2.定义格式
宏替换其实就是简单的文本替换,宏名称就是一个用户命名的特定的标识符,一般实际开发中宏名称都采用大写(潜规则)。 macro 宏
宏名称后面就是用来替换宏名称的替换列表,这个替换列表可以是常量、表达式、if语句以及函数等。
定义格式: #define 宏名称(大写) 替换列表 换行(一般就是用户按下回车)
用户在源文件中某个位置使用了宏,不管使用了多少次,在程序编译之前,预处理器都会把宏用替换列表进行替换,当然要注意,宏替换就是单纯的文本替换,预处理器并不会做任何检查,比如替换之后是否符合语法,语法的检查是由编译器在编译阶段进行的。
3.使用规则
注意:宏定义的作用域是针对整个文件有效,所以应该定义在源文件的开头部分,这样才可以在其他的函数中使用宏定义,另外,宏不是语句,所以不需要在末尾添加分号,如果添加分号,则分号也会被一起替换。
4.具体分类
C语言中宏定义的方案有三种,分别是无参数宏、带参数宏、无替换列表宏,具体如下所示:
- 无参数的宏定义
注意:除了用户自定义的宏之外,系统中也存在一些已经定义好的宏,比如常用的NULL就是一个宏,当然,C99标准中还有一些常用的系统预定义的宏:
- 带参数的宏定义
C语言标准中支持定义带参数的宏,带参数的宏的使用在语法上类似于函数调用,宏的参数由括号()进行包含,括号中如果有多个参数则需要通过逗号来分隔,另外,带参数的宏在定义的时候宏名称和参数列表之间不能空格,如下所示:
可以发现,带参数的宏和函数的形式很像,但是却完全不同,带参数的宏会在程序所有出现的位置进行展开,缺点是浪费了内存空间,但是节约了函数切换的时间。
- 无替换列表的宏
C语言中也允许只定义一个宏,这个宏可以没有替换列表,一般实际开发中都是对程序进行条件编译的情况下来使用。
条件编译指的是可以选择性的编译程序中的某段代码,也就是预处理器可以根据具体的条件来保留或者删除某段源程序。
可以理解为是类似于C语言的判断语句,只不过是使用C语言中的预处理指令来判断宏的有效性,有效性指的是宏是否为真以及宏是否存在,C语言中提供了多种预处理指令来实现条件编译。
- #if 用于判断常量表达式是否成立,遵循“非0即真”原则,#if预处理指令作为条件编译
一般#if和#endif是结合一起使用的,经常用于程序中的调试,可以选择保留或注释代码块!
- #ifdef 用于判断宏是否被定义,如果宏是提前定义好的,则该预处理指令是有效的,也需要和#endif一起使用
- #if和#elif和#else和#endif 用于条件编译,可以通过常量表达式的多种状态来选择保留或者删除某些代码块
- #ifndef和#endif 用于判断宏是否未定义,如果宏定义,则该代码块会被删除,如果宏未被定义,则该代码块可以保留
5.作用范围
5.程序的编译过程
思考:宏定义一般定义在源文件的开头,所以作用域是针对整个文件,但是有的时候如果只打算让某个宏只对某个函数有效,请问应该如何实现?
回答:可以实现,可以利用C语言标准中提供的预处理指令#undef,可以提前终止某个宏的作用域。
6.程序的编译过程
思考:什么叫做预处理阶段?预处理阶段和编译阶段有什么不同?源文件转换为可执行文件一共需要经历几个阶段?
预处理:
对源码进行简单的加工,GCC编译器会调用预处理器cpp对程序进行预处理,其实就是解释源程序中所有的预处理指令,如#include(文件包含)、#define(宏定义)、#if(条件编译)等以#号开头的预处理语句。
这些预处理指令将会在预处理阶段被解释掉,如会把被包含的文件拷贝进来,覆盖掉原来的#include语句,把所有的宏定义展开,所有的条件编译语句被执行,GCC还会把所有的注释删掉,添加必要的调试信息。
预处理指令: gcc -E xxx.c -o xxx.i 会生成预处理文件 xxx.i
编译:
就是对经过预处理之后的.i文件进行进一步翻译,也就是对语法、词法的分析,最终生成对应硬件平台的汇编文件,具体生成什么平台的汇编文件取决于编译器,比如X86平台使用gcc编译器,而ARM平台使用交叉编译工具arm-linux-gcc。
编译指令 : gcc -S xxx.i -o xxx.s 会生成汇编文件 xxx.s
汇编:
GCC编译器会调用汇编器as将汇编文件翻译成可重定位文件,其实就是把.s文件的汇编代码翻译为相应的指令。
编译指令 : gcc -c xxx.s -o xxx.o 会生成目标文件 xxx.o
链接:
经过汇编步骤后生成的.o文件其实是ELF格式的可重定位文件,虽然已经生成了指令流,但是需要重定位函数地址等,所以需要链接系统提供的标准C库和其他的gcc基本库文件等,并且还要把其他的.o文件一起进行链接。-lc -lgcc 是默认的,可以省略
编译指令:gcc hello.o -o hello -lc -lgcc 会生成可执行文件 xxx // l是lib的缩写