C语言中构造类型一共有4种,它们分别是:
数组(array)、结构体(struct)、枚举类型(enum)、共用体(union)。
结构体
结构体的语法
1、结构体的基本用法
结构体(Struct)从本质上讲是一种自定义的数据类型,只不过这种数据类型比较复杂,和前面讲过的数组有点像,差别就在数组里的元素必须是同一个类型,而结构体里的成员可以是不同类型的。
在实际开发中,我们可以将一组类型不同的、但是用来描述同一件事物的变量放到一个结构体中。
例如,学生有姓名、学号、年龄、班级、成绩等属性,学了结构体后,我们可以自定义一种新数据类型就是学生,这种类型里包含这些属性信息。
结构体的定义形式为:
struct 结构体名{
结构体所包含的变量或数组或另一个结构体;
....
};
结构体是一种集合,它里面包含了多个变量或数组,它们的类型可以相同,也可以不同,每个这样的变量或数组都称为结构体的成员(Member):
student 为结构体名,它包含了 5 个成员,分别是 name、num、age、class、score。
结构体成员的定义方式与变量和数组的定义方式相同,只是不能初始化。
注意:大括号后面的分号;不能少,这是一条完整的语句。
像 int、float、char 等是由C语言本身提供的数据类型,不能再进行分拆,我们称之为基本数据类型;
结构体可以包含多个基本类型,也可以包含其他的结构体,所以我们称它为复杂数据类型或构造数据类型。
结构体变量
既然结构体是一种数据类型,那么就可以用它来定义变量:
都由 5 个成员组成。注意关键字struct不能少。
你也可以在定义结构体的同时定义结构体变量:
将变量放在结构体定义的最后即可。
如果只需要 stu1、stu2 两个变量,后面不需要再使用结构体,那么在定义时也可以不给出结构体名:
#include <stdio.h>
//推荐把结构体的定义放在程序文件的最上面,不建义放在函数内
struct student{
char * name;
int stuNo;
int age;
char class; //一个字符表示,如'a','b'班
float score;
}stu3,stu4; //可以声明多个结构体
struct student stu1; //声明了一个全局变量,类型是:学生结构体,学生1,也可以函数内部声明局部变量
int main(){
struct student stu2;
return 0;
}
成员的获取和赋值
结构体使用点号.获取单个成员。
获取结构体成员的一般格式为:
结构体变量名.成员名;
通过这种方式可以获取成员的值,也可以给成员赋值:
#include <stdio.h>
int main(){
struct{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
} stu1;
//给结构体成员赋值
stu1.name = "Tom";
stu1.num = 12;
stu1.age = 18;
stu1.class= 'A';
stu1.score = 136.5;
//读取结构体成员的值
printf("%s的学号是%d,年龄是%d,在%c组,今年的成绩是%.1f!\n",
stu1.name, stu1.num, stu1.age, stu1.class, stu1.score);
return 0;
}
除了可以对成员进行逐一赋值,也可以在定义时整体赋值,例如:
struct{
char *name; //姓名
int num; //学号
int age; //年龄
char group; //所在小组
float score; //成绩
} stu1, stu2 = { "Tom", 12, 18, 'A', 136.5 };
不过整体赋值仅限于定义结构体变量的时候,在使用过程中只能对成员逐一赋值,这和数组的赋值非常类似。
需要注意的是,结构体是一种自定义的数据类型,是创建变量的模板,不占用内存空间;
结构体变量才包含了实实在在的数据,需要内存空间来存储。
结构体数组
所谓结构体数组,是指数组中的每个元素都是一个结构体。
在实际应用中,C语言结构体数组常被用来表示一个拥有相同数据结构的群体,比如一个班的学生。
在C语言中,定义结构体数组和定义结构体变量的方式类似:
struct stu{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
}team[5];
结构体数组在定义的同时也可以初始化,例如:
struct stu{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
}team[5] = {
{"Li ping", 5, 18, 'C', 145.0},
{"Zhang ping", 4, 19, 'A', 130.5},
{"He fang", 1, 18, 'A', 148.5},
{"Cheng ling", 2, 17, 'F', 139.0},
{"Wang ming", 3, 17, 'B', 144.5}
};
当对数组中全部元素赋值时,也可不给出数组长度,例如:
struct stu{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
}team[] = {
{"Li ping", 5, 18, 'C', 145.0},
{"Zhang ping", 4, 19, 'A', 130.5},
{"He fang", 1, 18, 'A', 148.5},
{"Cheng ling", 2, 17, 'F', 139.0},
{"Wang ming", 3, 17, 'B', 144.5}
};
【示例】计算全班学生的总成绩、平均成绩和以及 140 分以下的人数。
#include <stdio.h>
struct{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
}team[] = {
{"Li ping", 5, 18, 'C', 145.0},
{"Zhang ping", 4, 19, 'A', 130.5},
{"He fang", 1, 18, 'A', 148.5},
{"Cheng ling", 2, 17, 'F', 139.0},
{"Wang ming", 3, 17, 'B', 144.5}
};
int main(){
int num_140 = 0;
float sum = 0;
for(int i=0; i<5; i++){
sum += team[i].score;
if(team[i].score < 140) num_140++;
}
printf("sum=%.2f\naverage=%.2f\nnum_140=%d\n", sum, sum/5, num_140);
return 0;
}
结构体指针
当一个指针变量指向结构体时,我们就称它为结构体指针。
C语言结构体指针的定义形式一般为:
struct 结构体名 *变量名;
下面是一个定义结构体指针的实例:
//结构体
struct stu{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
} stu1 = { "Tom", 12, 18, 'A', 136.5 };
//结构体指针
struct stu *pstu = &stu1;
也可以在定义结构体的同时定义结构体指针:
struct stu{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
} stu1 = { "Tom", 12, 18, 'A', 136.5 }, *pstu = &stu1;
注意,结构体变量名和数组名不同,数组名在表达式中会被转换为数组指针,而结构体变量名不会,无论在任何表达式中它表示的都是整个集合本身,要想取得结构体变量的地址,必须在前面加&
通过指针获取结构体成员
通过结构体指针可以获取结构体成员,一般形式为:
(*pointer).memberName
或者:
pointer->memberName
第一种写法中,.的优先级高于,(pointer)两边的括号不能少。
如果去掉括号写作pointer.memberName,那么就等效于(pointer.memberName),这样意义就完全不对了。
第二种写法中,->是一个新的运算符,习惯称它为“箭头”,我个人喜欢叫“goes to”,有了它,可以通过结构体指针直接取得结构体成员;这也是->在C语言中的唯一用途。
上面的两种写法是等效的,我们通常采用后面的写法,这样更加直观。
【示例】结构体指针的使用。
#include <stdio.h>
int main(){
struct{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
} stu1 = { "Tom", 12, 18, 'A', 136.5 }, *pstu = &stu1;
//读取结构体成员的值
printf("%s的学号是%d,年龄是%d,在%c组,今年的成绩是%.1f!\n",
(*pstu).name, (*pstu).num, (*pstu).age, (*pstu).group, (*pstu).score);
printf("%s的学号是%d,年龄是%d,在%c组,今年的成绩是%.1f!\n",
pstu->name, pstu->num, pstu->age, pstu->group, pstu->score);
return 0;
}
结构体指针作为函数参数
结构体变量名代表的是整个集合本身,作为函数参数时传递的整个集合,也就是所有成员,而不是像数组一样被编译器转换成一个指针。
如果结构体成员较多,尤其是成员为数组时,传送的时间和空间开销会很大,影响程序的运行效率。
所以最好的办法就是使用结构体指针,这时由实参传向形参的只是一个地址,非常快速。
【示例】计算全班学生的总成绩、平均成绩和以及 140 分以下的人数。
#include <stdio.h>
struct stu{
char *name; //姓名
int num; //学号
int age; //年龄
char class; //班级
float score; //成绩
}stus[] = {
{"Li ping", 5, 18, 'C', 145.0},
{"Zhang ping", 4, 19, 'A', 130.5},
{"He fang", 1, 18, 'A', 148.5},
{"Cheng ling", 2, 17, 'F', 139.0},
{"Wang ming", 3, 17, 'B', 144.5}
};
float average(struct stu *ps, int len); //这是声明一下,因为该函数写在调用函数下面了
int main(){
int len = sizeof(stus) / sizeof(struct stu);
float avg = average(stus, len);
printf("平均成绩= %f\n",avg);
return 0;
}
float average(struct stu *ps, int len){
int i, num_140 = 0;
float average, sum = 0;
for(i=0; i<len; i++){
sum += (ps + i) -> score;
}
return sum/len;
}
位域
有些数据在存储时并不需要占用一个完整的字节,只需要占用一个或几个二进制位即可。例如开关只有通电和断电两种状态,用 0 和 1 表示足以,也就是用一个二进位。正是基于这种考虑,C语言又提供了一种叫做位域的数据结构。
在结构体定义时,我们可以指定某个成员变量所占用的二进制位数(Bit),这就是位域。
struct bs{
unsigned int m;
unsigned int n: 4; //:后面的数字用来限定成员变量占用的位数。
unsigned char ch: 6;
};
#include <stdio.h>
struct bs{
unsigned int m: 22; //定义变量后冒号对应的数字为它占用多少个位
unsigned int n: 12;
unsigned int p: 4;
};
int main(){
printf("%ld\n",sizeof(struct bs)); //8,如果不指定位数,则打印12,表示12个字节
return 0;
}
C语言标准规定,位域的宽度不能超过它所依附的数据类型的长度。
C语言标准还规定,只有有限的几种数据类型可以用于位域。
在 ANSI C 中,这几种数据类型是 int、signed int 和 unsigned int(int 默认就是 signed int);
到了 C99,_Bool 也被支持了。
但编译器在具体实现时都进行了扩展,额外支持了 char、signed char、unsigned char 以及 enum 类型,所以上面的代码虽然不符合C语言标准,但它依然能够被编译器支持。
位域的存储
C语言标准并没有规定位域的具体存储方式,不同的编译器有不同的实现,但它们都尽量压缩存储空间。
位域的具体存储规则如下:
- 当相邻成员的类型相同时,如果它们的位宽之和小于类型的 sizeof 大小,
那么后面的成员紧邻前一个成员存储,直到不能容纳为止;
如果它们的位宽之和大于类型的 sizeof 大小,那么后面的成员将从新的存储单元开始,
其偏移量为类型大小的整数倍。
以下面的位域 bs 为例:
#include <stdio.h>
struct bs{
unsigned int m: 6;
unsigned int n: 12;
unsigned int p: 4;
};
int main(){
printf("%ld\n", sizeof(struct bs));
return 0;
}
m、n、p 的类型都是 unsigned int,sizeof 的结果为 4 个字节(Byte),也即 32 个位(Bit)。
m、n、p 的位宽之和为 6+12+4 = 22,小于 32,所以它们会挨着存储,中间没有缝隙。
sizeof(struct bs) 的大小之所以为 4,而不是 3,是因为要将内存对齐到 4 个字节,以便提高存取效率
如果将成员 m 的位宽改为 22,那么输出结果将会是 8,因为 22+12 = 34,大于 32,n 会从新的位置开始存储,相对 m 的偏移量是 sizeof(unsigned int),也即 4 个字节。
- 当相邻成员的类型不同时,不同的编译器有不同的实现方案,GCC 会压缩存储,而 VC/VS 不会。
#include <stdio.h>
int main(){
struct bs{
unsigned int m: 12;
unsigned char ch: 4;
unsigned int p: 4;
};
printf("%ld\n", sizeof(struct bs));
return 0;
}
在 Linux_GCC 下的运行结果为 4,三个成员挨着存储;在Windows 下的运行结果为 12,三个成员按照最长的类型存储(与不指定位宽时的存储方式相同)。
- 如果成员之间穿插着非位域成员,那么不会进行压缩。例如对于下面的 bs:
struct bs{
unsigned int m: 12;
unsigned int ch;
unsigned int p: 4;
};
在各个编译器下 sizeof 的结果都是 12。
通过上面的分析,我们发现位域成员往往不占用完整的字节,有时候也不处于字节的开头位置,因此使用&获取位域成员的地址是没有意义的,C语言也禁止这样做。地址是字节(Byte)的编号,而不是位(Bit)的编号。
无名位域
位域成员可以没有名称,只给出数据类型和位宽,如下所示:
struct bs{
int m: 12;
int : 20; //该位域成员不能使用
int n: 4;
};
无名位域一般用来作填充或者调整成员位置。因为没有名称,无名位域不能使用。
枚举类型
在实际编程中,有些数据的取值往往是有限的,只能是非常少量的整数,并且最好为每个值都取一个名字,以方便在后续代码中使用,比如一个星期只有七天,一年只有十二个月等。
以每周七天为例,我们可以使用#define命令来给每天指定一个名字:
#include <stdio.h>
#define Mon 1
#define Tues 2
#define Wed 3
#define Thurs 4
#define Fri 5
#define Sat 6
#define Sun 7
int main(){
int day;
scanf("%d", &day);
switch(day){
case Mon: puts("Monday"); break;
case Tues: puts("Tuesday"); break;
case Wed: puts("Wednesday"); break;
case Thurs: puts("Thursday"); break;
case Fri: puts("Friday"); break;
case Sat: puts("Saturday"); break;
case Sun: puts("Sunday"); break;
default: puts("Error!");
}
return 0;
}
-----#define命令虽然能解决问题,但也带来了不小的副作用,导致宏名过多,代码松散,不便于代码维护。
C语言提供了一种枚举(Enum)类型,既可以列出所有可能的取值,又可以给每个值取一个名字。``
枚举类型的定义形式为:
enum typeName{ valueName1, valueName2, valueName3, ...... };
例如,列出一个星期有几天:
enum week{ Mon, Tues, Wed, Thurs, Fri, Sat, Sun };
可以看到,我们仅仅给出了名字,却没有给出名字对应的值,
这是因为枚举值默认从 0 开始,往后逐个加 1(递增);
也就是说,week 中的 Mon、Tues ...... Sun 对应的值分别为 0、1 ...... 6。
我们也可以给每个名字都指定一个值:
enum week{ Mon = 1, Tues = 2, Wed = 3, Thurs = 4, Fri = 5, Sat = 6, Sun = 7 };
更为简单的方法是只给第一个名字指定值:
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun };
这样枚举值就从 1 开始递增,跟上面的写法是等效的。
枚举变量
枚举是一种类型,通过它可以定义枚举变量:下面的a,b,c是week枚举的变量,通过变量可另赋值。
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun },a,b,c;
enum week a = Mon,b=wed,c=Sat;
//或者
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } a = Mon,b=wed,c=Sat;
枚举变量的意义:“枚举"是指将变量所有可能的取值一一列举出来,变量的取值只限于枚举列出的常量。
【示例】判断用户输入的是星期几
#include <stdio.h>
int main(){
enum week{Mon = 1,Tues ,Wed,Thurs,Fri,Sat,Sun} day; //day是变量,week是枚举类型名
scanf("%d", &day);
switch(day){
case Mon: puts("Monday"); break;
case Tues: puts("Tuesday"); break;
case Wed: puts("Wednesday"); break;
case Thurs: puts("Thursday"); break;
case Fri: puts("Friday"); break;
case Sat: puts("Saturday"); break;
case Sun: puts("Sunday"); break;
default: puts("Error!");
}
return 0
}
需要注意的两点
枚举列表中的 Mon、Tues、Wed 这些标识符的作用范围内(这里就是 main() 函数内部),不能再定义与这些标识符名字相同的变量。
Mon、Tues、Wed 等都是常量,不能对它们赋值,只能将它们的值赋给其他的变量。
枚举和宏其实非常类似:
宏在预处理阶段将名字替换成对应的值,枚举在编译阶段将名字替换成对应的值。
我们可以将枚举理解为编译阶段的宏。
对于上面的代码,在编译的某个时刻会变成类似下面的样子:
#include <stdio.h>
int main(){
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } day;
scanf("%d", &day);
switch(day){
case 1: puts("Monday"); break;
case 2: puts("Tuesday"); break;
case 3: puts("Wednesday"); break;
case 4: puts("Thursday"); break;
case 5: puts("Friday"); break;
case 6: puts("Saturday"); break;
case 7: puts("Sunday"); break;
default: puts("Error!");
}
return 0;
}
这意味着,Mon、Tues、Wed 等都不是变量,它们不占用数据区(常量区、全局数据区、栈区和堆区)的内存区域,而是直接被编译到命令里面,放到代码区,所以不能用&取得它们的地址。这就是枚举的本质。
最后强调:枚举类型变量只能存放整数进去:
#include <stdio.h>
int main(){
enum week{ Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } day = Mon;
printf("%d, %d, %d, %d, %d\n", sizeof(enum week), sizeof(day), sizeof(Mon), sizeof(Wed), sizeof(int) );
return 0;
}
共用体
通过前面的讲解,我们知道结构体(Struct)是一种构造类型或复杂类型,它可以包含多个类型不同的成员。
在C语言中,还有另外一种和结构体非常类似的类型,叫做共用体(Union),它的定义格式为:
union 共用体名{
成员列表
};
共用体有时也被称为联合或者联合体,这也是 Union 这个单词的本意。
结构体和共用体的区别在于:
- 结构体的各个成员会占用不同的内存,互相之间没有影响;
而共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员。 - 结构体占用的内存大于等于所有成员占用的内存的总和(成员之间可能会存在缝隙),
共用体占用的内存等于最长的成员占用的内存。 - 共用体使用了内存覆盖技术,同一时刻只能保存一个成员的值,如果对新的成员赋值,就会把原来成员的值覆盖掉。
共用体也是一种自定义类型,可以通过它来创建变量,例如:
union data{
int n;
char ch;
double f;
};
union data a, b, c; //声明三个变量
上面是先定义共用体,再创建变量,也可以在定义共用体的同时创建变量:
union data{
int n;
char ch;
double f;
} a, b, c;
如果不再定义新的变量,也可以将共用体的名字省略:
union{
int n;
char ch;
double f; } a, b, c;
--------共用体 data 中,成员n 占用的内存最多,为4 个字节,所以 data 类型的变量也占用 4个字节的内存:
#include <stdio.h>
union data{
int n;
char ch;
short m;
};
int main(){
union data a;
printf("%d, %d\n", sizeof(a), sizeof(union data) );
a.n = 0x40;
printf("%#X, %c, %#hX\n", a.n, a.ch, a.m);
a.ch = '9';
printf("%#X, %c, %#hX\n", a.n, a.ch, a.m);
a.m = 0x2059;
printf("%#X, %c, %#hX\n", a.n, a.ch, a.m);
a.n = 0x3E25AD54;
printf("%#X, %c, %#hX\n", a.n, a.ch, a.m);
return 0;
}
这段代码不但验证了共用体的长度,还说明共用体成员之间会相互影响,修改一个成员的值会影响其他成员。
要想理解上面的输出结果,弄清成员之间究竟是如何相互影响的,就得了解各个成员在内存中的分布。
成员 n、ch、m 在内存中“对齐”到一头,对 ch 赋值修改的是前一个字节,对 m 赋值修改的是前两个字节,对 n 赋值修改的是全部字节。也就是说,ch、m 会影响到 n 的一部分数据,而 n 会影响到 ch、m 的全部数据。
上图是在绝大多数 PC 机上的内存分布情况,如果是 51 单片机,情况就会有所不同:
上面情况不同是因为大小端的问题:
计算机中的数据是以字节(Byte)为单位存储的,每个字节保存在内存里都有不同的地址。
现代 CPU 的位数(可以理解为CUP一次能处理的数据的位数),
PC机、服务器的 CPU 基本都是 64 位的,嵌入式系统或单片机系统仍然在使用 32 位和 16 位的 CPU,
但都超过了 8 位(一个字节),对于一次能处理多个字节的CPU,必然存在着如何安排多个字节的问题,也就是大端和小端模式。
大端模式(Big-endian)是指将数据的低位(比如 1234 中的 34 就是低位)放在内存的高地址上,而数据的高位(比如 1234 中的 12 就是高位)放在内存的低地址上。
小端模式(Little-endian)是指将数据的低位放在内存的低地址上,而数据的高位放在内存的高地址上。
数据在内存中的存储模式是由硬件决定的,准确点是由 CPU 决定, PC 机上使用的是 X86 结构的 CPU,它是小端模式;51系列单片机则是大端模式;所以上面的代码在小端设备上运行和大端设备上运行结果是有差异的。
共用体的应用
共用体在一般的编程中应用较少,在单片机中应用较多。
对于 PC 机,经常使用到的一个实例是:
现有一张关于学生信息和教师信息的表格。
学生信息包括姓名、编号、性别、职业、分数,
教师的信息包括姓名、编号、性别、职业、教学科目。
请看下面的表格:
f 和 m 分别表示女性和男性,
s 表示学生,t 表示教师。
可以看出,学生和教师所包含的数据是不同的。
现在要求把这些信息放在同一个表格中,并设计程序输入人员信息然后输出。
如果把每个人的信息都看作一个结构体变量的话,那么教师和学生的前 4 个成员变量是一样的,第 5 个成员变量可能是 score 或者 course。
当第 4 个成员变量的值是 s 的时候,第 5 个成员变量就是 score;
当第 4 个成员变量的值是 t 的时候,第 5 个成员变量就是 course。
经过上面的分析,我们可以设计一个包含共用体的结构体,请看下面的代码:
#include <stdio.h>
#include <stdlib.h>
#define TOTAL 4 //人员总数
struct{
char name[20];
int num;
char sex;
char profession;
union{
float score;
char course[20];
} sc;
} bodys[TOTAL];
int main(){
int i;
//输入人员信息
for(i=0; i<TOTAL; i++){
printf("Input info: ");
scanf("%s %d %c %c", bodys[i].name, &(bodys[i].num), &(bodys[i].sex), &(bodys[i].profession));
if(bodys[i].profession == 's'){ //如果是学生
scanf("%f", &bodys[i].sc.score);
}else{ //如果是老师
scanf("%s", bodys[i].sc.course);
}
fflush(stdin);
}
//输出人员信息
printf("\nName\t\tNum\tSex\tProfession\tScore / Course\n");
for(i=0; i<TOTAL; i++){
if(bodys[i].profession == 's'){ //如果是学生
printf("%s\t%d\t%c\t%c\t\t%f\n", bodys[i].name,
bodys[i].num, bodys[i].sex, bodys[i].profession, bodys[i].sc.score);
}else{ //如果是老师
printf("%s\t%d\t%c\t%c\t\t%s\n", bodys[i].name,
bodys[i].num, bodys[i].sex, bodys[i].profession, bodys[i].sc.course);
}
}
return 0;
}
cpu工作原理
#include <stdio.h>
#include <stdlib.h>
struct{
int a;
char b;
int c;
}t={ 10, 'C', 20 };
int main(){
printf("length: %d\n", sizeof(t));
printf("&a: %X\n&b: %X\n&c: %X\n", &t.a, &t.b, &t.c);
system("pause");
return 0
程序写好编译后保存在磁盘,然后加载到内存中运行的,一名合格的程序员必须了解内存,学习C语言更是要多了解些内存的知识点,C语言是一门偏向硬件的编程语言。
1、想理解清楚内存,先要弄清楚CPU的组成、工作原理和必要的一些相关概念
CPU总线
习惯上人们把和CPU直接相关的局部总线叫做CPU总线或内部总线,而把和各种通用扩展槽相接的局部总线叫做系统总线或外部总线。
具体地,CPU总线一般指CPU与芯片组之间的公用连接线,又叫前端总线(FSB)。不管是内部总线还是外部总线,我们可以把它们理解成城市中的主干道和一般道路。
通常,总线可分为三类
:数据总线,地址总线,控制总线,数据、地址和控制信号是分开传输的。三者配合起来实现CPU对数据和指令的读写操作。
寄存器和CPU指令
寄存器(Register)是CPU内部非常小、非常快速的存储部件,它的容量很有限,对于32位的CPU,每个寄存器一般能存储32位(4个字节)的数据,对于64位的CPU,每个寄存器一般能存储64位(8个字节)的数据。
为了完成各种复杂的功能,现代CPU都内置了几十个甚至上百个的寄存器,嵌入式系统功能单一,寄存器数较少。
我们经常听说多少位的CPU,即指的是寄存器能存储数据的位数,也是数据总线位数(总线的条数)。
现在个人电脑使用的CPU已经进入了64位时代,例如 Intel 的 Core i3、i5、i7 等。
寄存器在程序的执行过程中至关重要,不可或缺,它们可以用来完成数学运算、控制循环次数、控制程序的执行流程、标记CPU运行状态等。
寄存器有很多种,例如:
- EIP(Extern Instruction Pointer )寄存器的值是下一条指令的地址,
CPU执行完当前指令后,会根据 EIP 的值找到下一条指令,改变 EIP 的值,
就会改变程序的执行流程,CPU中EIP就是我们上面说的程序计数器PC; - CR3(Control Register )寄存器保存着当前进程页目录的物理地址,切换进程就会改变 CR3 的值;
- EBP(Extended Base Pointer)、ESP(Extended Stack Pointer) 寄存器用来指向栈的底部和顶部,
函数调用会改变 EBP 和 ESP 的值。 - EAX 是"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器。
- EBX 是"基地址"(base)寄存器, 在内存寻址时存放基地址。
- ECX 是计数器(counter), 是重复前缀指令和LOOP指令的内定计数器。
- EDX 则总是被用来放整数除法产生的余数。
- ESI/EDI分别叫做"源/目标索引寄存器"(source/destination inde因为在很多字符串操作指令中,ESI指向源串,EDI指向目标串.
CPU指令
要想让CPU工作,必须借助特定的指令,例如 add 用于加法运算,sub 用于除法运算,cmp 用于比较两个数的大小,这称为CPU的指令集(Instruction Set)。
我们的C语言代码最终也会编译成一条一条的CPU指令。
不同型号的CPU支持的指令集会有所差异,但绝大部分是相同的。
我们以C语言中的加法为例来演示CPU指令的使用。
假设有下面的C语言代码:
int a = 0X14, b = 0XAE, c;
c = a + b;
生成的CPU指令为(这是假的,不是真的指令,只是模拟CUP的处理过程):
mov ptr[a], 0X14
mov ptr[b], 0XAE
mov eax, ptr[a]
add eax, ptr[b]
mov ptr[c], eax
总起来讲:第一二条指令给变量 a、b 赋值,
第三四条指令完成加法运算,
第五条指令将运算结果赋值给变量 c。
-----我们在程序里使用的内存地址是假的,是虚拟地址
在C语言中,指针变量的值就是一个内存地址,&运算符的作用也是取变量的内存地
址,请看下面的代码:
#include <stdio.h>
int a = 1, b = 255;
int main(){
int *pa = &a;
printf("pa = %p, &b = %p\n", pa, &b);
return 0;
}
我执行了两次,打印输出同样的地址。
代码中的 a、b 是全局变量,它们的内存地址在链接时就已经决定了,以后再也不能改变,该程序无论在何时运行,结果都是一样的。
那么问题来了,如果物理内存中的这两个地址被其他程序占用了怎么办,我们的程序岂不是无法运行了?
幸运的是,这些内存地址都是假的,不是真实的物理内存地址,而是虚拟地址。
在程序运行时,需要使用真正的地址了,CPU会把虚拟地址转换成真正的内存的物理地址,而且每次程序运行时,操作系统都会重新安排虚拟地址和物理地址的对应关系,哪一段物理内存空闲就使用哪一段。
为什么要在程序与物理地址之间,加一个虚拟地址,不能让程序直接操作物理地址?
使用虚拟地址才能在编程中有一个确定地址,而物理地址不能确定
我们知道编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存当中去运行,如果直接使用物理地址的话,我们无法确定内存现在使用到哪里了,也就是说拷贝的实际内存地址每一次运行都是不确定的,比如:第一次执行a.out时候,内存当中一个进程都没有运行,所以搬移到内存地址是0x00000011,但是第二次的时候,内存已经有10个进程在运行了,那执行a.out的时候,内存地址就不一定了。使用虚拟地址可以让不同程序的地址空间相互隔离
如果所有程序都直接使用物理内存,那么程序所使用的地址空间不是相互隔离的。恶意程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意、但是有 Bug 的程序也可能会不小心修改其他程序的数据,导致其他程序崩溃。
这对于需要安全稳定的计算机环境的用户来说是不能容忍的,用户希望他在使用计算机的时候,其中一个任务失败了,至少不会影响其他任务。
使用了虚拟地址后,程序A和程序B虽然都可以访问同一个地址,但它们对应的物理地址是不同的,无论如何操作,都不会修改对方的内存。
- 提高内存使用效率
使用虚拟地址后,操作系统会更多地介入到内存管理工作中,这使得控制内存权限成为可能。由操作系统更多的管理内存,当物理内存不够用,操作系统自动将不常用的数据转存到磁盘,用的时候在读回来,哪些内存在用,哪些内存没在用,OS可以动态判断,比我们程序员直接在程序里管理内存,更好,内存的使用率更高。
虚拟地址空间以及编译模式
所谓虚拟地址空间,就是程序可以使用的虚拟地址的有效范围。
虚拟地址和物理地址的映射关系由操作系统决定,相应地,虚拟地址空间的大小不仅由操作系统决定,还会受到编译模式的影响。重点讨论一下编译模式,要了解编译模式,还得从CPU来说起:
CPU的数据处理能力
CPU是计算机的核心,决定了计算机的数据处理能力和寻址能力,也即决定了计算机的性能。
CPU一次能处理的数据的大小由寄存器的位数和数据总线的宽度(有多少根数据总线)决定,我们通常所说的多少位的CPU,除了可以理解为寄存器的位数,也可以理解数据总线的宽度,通常情况下它们是相等的。
CPU实际支持多大的物理内存
CPU支持的物理内存只是理论上的数据,实际应用中还会受到操作系统和其他条件的限制,例如,Win7 64位家庭版最大仅支持8GB或16GB的物理内存,Win7 64位专业版或企业版能够支持到192GB的物理内存。Win10 64位系统支持4G 8G 16G 32G 64G 128G 256G内存,理论上可以无限支持,但也要主板能支持才行。
编译模式
为了兼容不同的平台,现代编译器大都提供两种编译模式:32位模式和64位模式。
32位编译模式
在32位模式下,一个指针或地址占用4个字节的内存,共有32位,理论上能够访问的虚拟内存空间大小为 2^32 = 0X100000000 Bytes,即4GB,有效虚拟地址范围是 0 ~ 0XFFFFFFFF。
也就是说,对于32位的编译模式,不管实际物理内存有多大,程序能够访问的有效虚拟地址空间的范围就是0 ~ 0XFFFFFFFF,也即虚拟地址空间的大小是 4GB。换句话说,程序能够使用的最大内存为 4GB,跟物理内存没有关系。
如果程序需要的内存大于物理内存,或者内存中剩余的空间不足以容纳当前程序,那么操作系统会将内存中暂时用不到的一部分数据写入到磁盘,等需要的时候再读取回来,而我们的程序只管使用 4GB 的内存,不用关心硬件资源够不够。
如果物理内存大于 4GB,例如目前很多PC机都配备了8GB\16GB\32GB的内存,那么程序也无能为力,它只能够使用其中的 4GB。
64位编译模式
在64位编译模式下,一个指针或地址占用8个字节的内存,共有64位,理论上能够访问的虚拟内存空间大小为 2^64。这是一个很大的值,几乎是无限的,就目前的技术来讲,不但物理内存不可能达到这么大,CPU的寻址能力也没有这么大,实现64位长的虚拟地址只会增加系统的复杂度和地址转换的成本,带不来任何好处,所以 Windows 和 Linux 都对虚拟地址进行了限制,仅使用虚拟地址的低48位(6个字节),总的虚拟地址空间大小为 2^48 = 256TB。
需要注意的是:
1)32位的操作系统只能运行32位的程序,64位操作系统可以同时运行32位的程序和64位的程序。
2)64位的CPU运行64位的程序才能发挥它的最大性能,运行32位的程序会白白浪费一部分资源。
目前计算机可以说已经进入了64位的时代,之所以还要提供32位编译模式,是为了兼容一些老的硬件平台和操作系统,或者某些场合下32位的环境已经足够,使用64位环境会增大成本,例如嵌入式系统、单片机、工控等。
这里所说的32位环境是指:32位的CPU + 32位的操作系统 + 32位的程序。
另外需要说明的是,32位环境拥有非常经典的设计,易于理解,适合教学,课程里不特别说明默认都是基于32位来分析讲解相关内存的知识点。
下面代码是64位环境和32位环境下运行效果
#include <stdio.h>
int a = 1, b = 255;
int main(){
int *pa = &a;
printf("pa = %p, &b = %p\n,%d\n", pa, &b,sizeof(pa));
return 0;
}
//执行与结果,分别有64位模式我32位模式编译执行
gcc -g demo1.c -o demo1.exe
./demo1
pa = 0000000000403010, &b = 0000000000403014,8
//32位模式
gcc -m32 -g demo1.c -o demo1.exe
./demo1
pa = 00403004, &b = 00403008,4
内存对齐
计算机内存是以字节(Byte)为单位划分的,理论上CPU可以访问任意编号的字节,但实际情况并非如此。
CPU 通过地址来访问内存,通过地址在内存中定位要找的目标数据,我们叫寻址,CPU在寻址的时候它不是从0 1 2 3...挨着寻址的,有跳跃的步长的,这个步长和CPU的位数有关系!
以32位的CPU为例,实际寻址的步长为4个字节,也就是只对地址为 4 的倍数的内存寻址,
例如 0、4、8、12、1000 等,而不会对编号为 1、3、11、1001 的内存寻址。如下图所示:
这样做可以以最快的速度寻址:不遗漏一个字节,也不重复对一个字节寻址。
对于程序来说,一个变量最好位于一个寻址步长的范围内,这样CPU读变量的效率就比较高,否则效率就低些。
例如:一个 int 类型的数据,如果地址为 8,那么很好办,对编号为 8 的内存寻址一次就可以。如果编号为 10,就比较麻烦,CPU需要先对编号为 8 的内存寻址,读取4个字节,得到该数据的前半部分,然后再对编号为 12 的内存寻址,读取4个字节,得到该数据的后半部分,再将这两部分拼接起来,才能取得数据的值。
编译器会优化代码,根据变量大小,尽量将变量放在合适的位置,避免跨步长存储,这称为内存对齐。
#include <stdio.h>
#include <stdlib.h>
struct{
int a;
char b;
int c;
}t={ 10, 'C', 20 };
int main(){
printf("length: %d\n", sizeof(t));
printf("&a: %d\n&b: %d\n&c: %d\n", &t.a, &t.b, &t.c);
return 0;
}
length: 12 ,就是编译器进行了内存对齐。本来两个int型加一个字符型,是9个字节的长度。
-----编译器之所以要内存对齐,是为了更加高效的存取成员 c,而代价就是浪费了3个字节的空间。
编译器编译时对齐的规则
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数),具体的对齐规则没有统一的标准,也比较复杂,内存对齐本身对我们编程来讲,基本上也是透明的,所以不去过多深入研究了,但基本东西我们要了解。
可以通过预编译命令#pragma pack(n),n=1,2,4,8,16 来一定程度上影响这一“对齐系数”,但不绝对的。
如果上面那个例子,对齐系数改成1,那么就只占用9个byte,但这样会使得cpu负担更大。
除了结构体,变量也会进行内存对齐,请看下面的代码:
#include <stdio.h>
#include <stdlib.h>
int m;
char c;
int n;
int main(){
printf("&m: %X\n&c: %X\n&n: %X\n", &m, &c, &n);
system("pause");
return 0;
}
//执行结果
&m: 407978
&c: 407974
&n: 407970
它们的地址都是4的整数倍,并相互挨着。
内存分页机制
前面已经了解了,虚拟地址的使用是有价值,有意义的,所以程序运行的时候,操作系统会自动帮我们,把虚拟地址映射为物理地址,但这个映射转换机制的实现,有很多思路:
1)以程序为单位进行映射
如果物理内存不足,被换入换出到磁盘的是整个程序,这样势必会导致大量的磁盘读写操作,严重影响运行速度,所以这种方法还是显得粗糙,粒度比较大。
2)内存分页机制
现代计算机都使用分页(Paging)的方式对虚拟地址空间和物理地址空间进行分割和映射,以减小当内存不够时数据在内存与磁盘之间的换入换出的粒度,提高程序运行效率。
分页(Paging)的思想是指把地址空间人为地分成大小相等(并且固定)的若干份,这样的一份称为一页,就像一本书由很多页面组成,每个页面的大小相等。如此,就能够以页为单位对内存进行换入换出:
(1)当程序运行时,只需要将必要的数据从磁盘读取到内存,暂时用不到的数据先留在磁盘中,
什么时候用到什么时候读取。
(2)当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可,
不用把整个程序都写入磁盘。
关于页的大小
页的大小是固定的,由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。
比如 Intel Pentium 系列处理器支持 4KB 或 4MB 的页大小,那么操作系统可以选择每页大小为 4KB,
也可以选择每页大小为 4MB,但是在同一时刻只能选择一种大小,所以对整个系统来说,也就是固定大小的。
目前几乎所有PC上的操作系统都是用 4KB 大小的页。
假设我们使用的PC机是32位的,那么虚拟地址空间总共有 4GB,按照 4KB 每页分的话,总共有 2^32 / 2^12 = 2^20 = 1M = 1048576 个页;物理内存也是同样的分法。
根据页进行映射
如下图中,当程序1,2运行时,初始化时,要用到的程序段数据先载入的内存中,当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可,
不用把整个程序都写入磁盘。
分页机制究竟是如何实现的?
现代操作系统都使用分页机制来管理内存,这使得每个程序都拥有自己的地址空间。程序运行的时候虚拟地址必须转换为实际的物理地址,才能真正在内存条上读写数据。如下图所示:
直接使用数组转换,不靠谱
最容易想到的映射方案是使用数组:每个数组元素保存一个物理地址,而把虚拟地址作为数组下标,这样就能够很容易地完成映射,并且效率不低。如下图所示:
但是这样的数组有 2^32 个元素,每个元素大小为4个字节,总共占用16GB的内存,显现是不现实的!怎么办?
使用分页机制,为什么分页后,这个关系数据会压缩!
使用一级页表
既然内存使用分页机制,内存就是分页的,那么我们定位数据就不用定位到内存的每个字节,只需定位到数据所在的页,以及数据在页内的偏移(也就是距离页开头的字节数),就能够转换为物理地址。
例如:
一个 int 类型的值保存在下标为 12 页,页内偏移 240B,那么对应的物理地址就是 2^12 * 12 + 240 = 49392。
32位系统虚拟地址空间大小为 4GB,总共包含 2^32 / 2^12 = 2^20 = 1K * 1K = 1M = 1048576 个页面,我们可以定义一个这样的数组:它包含 2^20 = 1M 个元素,每个元素的值为物理内存的页面编号,长度为4字节,整个数组共才占用4MB的内存空间。这样的数组就称为页表(Page Table),它记录了地址空间中所有页的编号。
为了让这个4MB大小的数组,保存的下上面16GB才能保存的虚拟地址和物理地址之间的映射关系,我们做一个规定:虚拟地址和页表数组中的元素值,都分两部分:
虚拟地址:
页表数组中的元素值:
物理页属性:是否有读写权限、是否已经分配物理内存、是否被换出到硬盘等。
-----一个虚拟地址 0XA010B100,它的高20位是 0XA010B,对应页表数组的小标,后面的100是在物理页面内的数据偏移量。假设页表数组小为 0XA010B 元素的值为 0X0F70AAA0,它的高20位为 0X0F70A,对应数据位于第 0X0F70A 个物理页面编号,它的低12位是 0XAA0,对应物理页的属性 ,有了物理页面索引和页内偏移量,就可以算出物理地址了。经过计算,最终的物理地址为 0X0F70A * 2^12 + 0X100 = 0XF70A100。
可以发现,有的页被映射到物理内存,有的被映射到硬盘,不同的映射方式可以由页表数组元素的低12位来控制。
-----使用这种方案,不管程序需要多大的内存,每个程序都会对应的页表数组占用4M的内存空间(页表数组也必须放在物理内存中)。
为了进一步压缩空间,从而会使用二级、三级页表甚至多级页表,具体原理我们以二级页表为例:
二级页表
上面的页表数组共有 2^20 = 2^10 * 2^10=1M 个元素,为了压缩页表的存储空间,可以将上面的页表分拆成 2^10 = 1K = 1024 个小的页表数组,这样每个小页表数组只包含 2^10 = 1K = 1024 个元素,占用 2^10 * 4 = 4KB 的内存,也即一个页面的大小。这 1024 个小的页表数组本身可以存储在不同的物理页,它们之间可以是不连续的。
那么问题来了,既然这些小的页表分散存储,位于不同的物理页,该如何定位它们呢?
也就是如何记录它们的编号(也即在物理内存中位于第几个页面)。
1024 个页表有 1024 个索引,所以不能用一个指针指向它们,必须将这些索引再保存到一个额外的数组中。
这个额外的数组有1024个元素,每个元素记录一个页表所在物理页的编号,长度为4个字节,总共占用4KB的内存,我们将这个额外的数组称为页目录,因为它的每一个元素对应一个小页表。
如此,只需使用一个指针记下页目录的地址,等到进行地址转换时,可以根据这个指针找到页目录,再根据页目录找到页表,最后找到物理地址,前后共经过3次间接转换。
那么,如何根据虚拟地址找到页目录和小页表中相应的元素呢?
我们不妨将虚拟地址分割为三部分,高10位作为页目录中元素的下标,中间10位作为小页表中元素的下标,最后12位作为数据在物理页内偏移量,如下图所示:
前面我们说过,知道了物理页的索引和页内偏移就可以转换为物理地址了,在这种方案中,页内偏移可以从虚拟地址的低12位得到,但是物理页索引却保存在 1024 个分散的小页表中,所以就必须先根据页目录找到对应的页表,再根据页表找到物理页索引。
采用这样的两级页表的一个明显优点是,如果程序占用的内存较少,分散的小页表的个数就会远远少于1024个,对应占用存储空间越小(远远小于4M)。
在极少数的情况下,程序占用的内存非常大,布满了虚拟地址空间,这样小页表的数量可能接近甚至等于1024,再加上页目录占用的存储空间,总共是 4MB+4KB,比上面使用一级页表的方案还多出4KB的内存。
对于32位系统来说这是可以容忍的,因为很少出现如此极端的情况。
也就是说,使用两级页表后,页表占用的内存空间不固定,它和程序本身占用的内存空间成正比,从整体上来看,会比使用一级页表占用的内存少得多。
上面对应的是32位环境虚拟地址空间只有4G,对于64位环境,虚拟地址空间达到 256TB,如果使用二级页表占用的存储空间依然还是过大,从而继续使用三级页表甚至多级页表,这样就会有多个页目录,虚拟地址也会被分割成更多个部分,思路和上面是一样的,就继续分析着玩儿了!
MMU部件以及对内存权限的控制
通过页表完成虚拟地址和物理地址的映射时,要经过多次转换,还要进行计算,如果由操作系统来完成这项工作,那将会成倍降低程序的性能,得不偿失,所以这种方式还是不现实的,还得继续想办法。
MMU
在CPU内部,有一个部件叫做MMU(Memory Management Unit,内存管理单元),由它来负责将虚拟地址映射为物理地址,如下图所示:
即便是这样,MMU也要访问好几次内存,性能依然堪忧,所以在MMU内部又增加了一个缓存,专门用来存储页目录和页表。MMU内部的缓存有限,当页表过大时,也只能将部分常用页表加载到缓存,但这已经足够了,因为经过算法的巧妙设计,可以将缓存的命中率提高到 90%,剩下的10%的情况无法命中,再去物理内存中加载页表。
有了硬件的直接支持,使用虚拟地址和使用物理地址相比,损失的性能已经很小,终于在可接受的范围内了。
MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。
在程序加载到内存以及程序运行过程中,操作系统会不断更新程序对应的页表,并将页目录的物理地址保存到 CR3 寄存器。MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射。
每个程序在运行时都有自己的一套页表,切换程序时,只要改变 CR3 寄存器的值就能够切换到对应的页表。
对内存权限的控制
MMU 除了能够完成虚拟地址到物理地址的映射,还能够对内存权限进行控制。在如一级页表数组中,每个元素占用4个字节,也即32位,我们使用高20位来表示物理页编号,还剩下低12位,这12位就用来对内存进行控制,例如,是映射到物理内存还是映射到磁盘,程序有没有访问权限,当前页面有没有执行权限等。
操作系统在构建页表时将内存权限定义好,当MMU对虚拟地址进行映射时,首先检查低12位,看当前程序是否有权限使用,如果有,就完成映射,如果没有,就产生一个异常,并交给操作系统处理。操作系统在处理这种内存错误时一般比较粗暴,会直接终止程序的执行。
请看下面的代码:
#include <stdio.h>
int main() {
char *str = (char*)0XFFF00000; //使用数值表示一个明确的地址
printf("%s\n", str);
return 0;
}
这段代码不会产生编译和链接错误,但在运行程序时,为了输出字符串,printf() 需要访问虚拟地址为 0XFFFF00000 的内存,但是该虚拟地址是被操作系统占用的(后面知识点马上会讲解),程序没有权限访问,会被强制关闭。而在Linux下,会产生段错误(Segmentation fault),相信大家在编程过程中会经常见到这种经典的内存错误。
操作系统的内存分布
不同的操作系统的内存分布是不一样的,我们说说Linux,Windows是闭源操作系统,很难说清楚它内部的内存分布,Linux我们也只需要大致了解一下内存分布就可以解释我们在编程中的很多问题,了解我们在前面讲的这个图,基本就够用了:
内核空间和用户空间
对于32位环境,理论上程序可以拥有 4GB 的虚拟地址空间,我们在C语言中使用到的变量、函数、字符串等都会对应内存中的一块区域。
但是,在这 4GB 的地址空间中,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间(Kernel Space)。
Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),
而 Linux 默认情况下会将高地址的 1GB 空间分配给内核。
也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间(User Space)。
内核模式和用户模式
首先我们要解释一个概念——进程(Process):
简单来说,一个可执行程序就是一个进程,前面我们使用C语言编译生成的程序,运行后就是一个进程。进程最显著的特点就是拥有独立的地址空间。
严格来说,程序是存储在磁盘上的一个文件,是指令和数据的集合,是一个静态的概念;进程是程序加载到内存运行后一些列的活动,是一个动态的概念。
前面我们在讲解地址空间时,一直说“程序的地址空间”,这其实是不严谨的,应该说“进程的地址空间”。一个进程对应一个地址空间,而一个程序可能会创建多个进程。
内核空间存放的是操作系统内核代码和数据,是被所有程序共享的,在程序中修改内核空间中的数据不仅会影响操作系统本身的稳定性,还会影响其他程序,这是非常危险的行为,所以操作系统禁止用户程序直接访问内核空间。
要想访问内核空间,必须借助操作系统提供的 API 函数,执行内核提供的代码,让内核自己来访问,这样才能保证内核空间的数据不会被随意修改,才能保证操作系统本身和其他程序的稳定性。
用户程序调用系统 API 函数称为系统调用(System Call);
发生系统调用时会暂停用户程序,转而执行内核代码,访问内核空间,这称为内核模式(Kernel Mode)。
用户空间保存的是用户的应用程序代码和数据,是程序私有的,其他程序一般无法访问。
当执行用户自己的应用程序代码时,称为用户模式(User Mode)。
计算机会经常在内核模式和用户模式之间切换:
(1)当运行在用户模式的应用程序需要输入输出、申请内存等比较底层的操作时,
就必须调用操作系统提供的 API 函数,从而进入内核模式;
(2)操作完成后,继续执行应用程序的代码,就又回到了用户模式。
总结:用户模式就是执行应用程序代码,访问用户空间;内核模式就是执行内核代码,访问内核空间(当然也有权限访问用户空间)。
为什么要区分两种模式,安全考虑,怕用户的程序轻轻松松搞死操作系统
我们知道,内核最主要的任务是管理硬件,包括显示器、键盘、鼠标、内存、硬盘等,并且内核也提供了接口,供上层程序使用。
当程序要进行输入输出、分配内存、响应鼠标等与硬件有关的操作时,必须要使用内核提供的接口。
但是用户程序是非常不安全的,内核对用户程序也是充分不信任的,当程序调用内核接口时,内核要做各种校验,以防止出错。
从 Intel 80386 开始,出于安全性和稳定性的考虑,CPU 可以运行在 ring0 ~ ring3 四个不同的权限级别,也对数据提供相应的四个保护级别。不过 Linux 和 Windows 只利用了其中的两个运行级别:
(1)一个是内核模式,对应 ring0 级,操作系统的核心部分和设备驱动都运行在该模式下。
(2)另一个是用户模式,对应 ring3 级,操作系统的用户接口部分以及所有的用户程序都运行在该级别。
为什么内核和用户程序要共用地址空间,为了效率
既然内核也是一个应用程序,为何不让它拥有独立的4GB虚拟地址空间,而是要和用户程序共享、占用有限的内存呢?
让内核拥有完全独立的地址空间,就是让内核处于一个独立的进程中,这样每次进行系统调用都需要切换进程。
切换进程的消耗是巨大的,不仅需要寄存器进栈出栈,还会使CPU中的数据缓存失效、MMU中的页表缓存失效,这将导致内存的访问在一段时间内相当低效。
而让内核和用户程序共享地址空间,发生系统调用时进行的是模式切换,模式切换仅仅需要寄存器进栈出栈,不会导致缓存失效;与进程切换比起来,效率大大提高了。
栈(Stack)
程序的虚拟地址空间分为多个区域,栈(Stack)是其中的一个区域。
栈(Stack)可以存放函数参数、局部变量、局部数组等作用范围在函数内部的数据,它的用途就是完成函数的调用。
栈内存由系统自动分配和释放:发生函数调用时就为函数运行时用到的数据分配内存,
函数调用结束后就将之前分配的内存全部销毁。
所以局部变量、参数只在当前函数中有效,不能传递到函数外部。
栈的概念
在计算机中,栈可以理解为一个特殊的容器,用户可以将数据依次放入栈中,然后再将数据按照相反的顺序从栈中取出。
也就是说,最先放入的数据最后才能取出,而最后放入的数据必须最先取出。
这称为先进后出(First In Last Out)原则。
放入数据常称为入栈或压栈(Push),取出数据常称为出栈或弹出(Pop)。
如下图所示:
从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。
在现代计算机中,通常使用ebp寄存器指向栈底,而使用esp寄存器指向栈顶。随着数据的进栈出栈,esp 的值会不断变化,进栈时 esp 的值减小,出栈时 esp 的值增大。
ebp和esp重叠,表示栈是空的。
栈的大小以及栈溢出
对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这是编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。
注意:一个程序可以包含多个线程,每个线程都有自己的栈,严格来说,栈的内存空间大小是针对线程来说的,而不是针对程序。
栈内存的大小和编译器/OS有关,Windows平台栈内存大小有编译器决定,Linux是OS决定。默认大小一般都是几个M,win64是2m,linux上一般为8m。
当程序使用的栈内存大于默认值(或者修改后的值)时,就会发生栈溢出(Stack Overflow)错误。
#include <stdio.h>
int main(){
char str[1024*1024*8] = {0}; //局部变量都会保存栈中
printf("xiong\n");
return 0;
}
//执行结果
什么都不打印,因为数组已经用尽了栈空间,后面的代码不会执行了
更改Win默认的栈空间大小
gcc -Wl,--stack=16777216 *.c
更改Linux默认栈空间的大小
(1)查看linux默认栈空间的大小
通过命令 ulimit -s 查看linux的默认栈空间大小,默认情况下为8192 KB 即8MB。
(2)临时改变栈空间的大小
通过命令 ulimit -s 设置大小值临时改变栈空间大小。例如:ulimit -s 102400,即修改为100MB。
(3)永久修改栈空间大大小。有两种方法:
方法一:可以在/etc/rc.local 内加入 ulimit -s 102400 则可以开机就设置栈空间大小,
任何用户启动的时候都会调用。
方法二:修改配置文件/etc/security/limits.conf
一个函数在运行过程中,在栈上到底是怎样变化的?
函数的调用和栈是分不开的,没有栈就没有函数调用,那么函数在栈上是如何被调用的?
栈帧/活动记录
当发生函数调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。
活动记录一般包括以下几个方面的内容:
函数的返回地址,例如:
int a, b, c;
func(1, 2);
c = a + b;
站在C语言的角度看,func() 函数执行完成后,会继续执行c=a+b;语句,那么返回地址就是该语句在内存中的位置。本质上是应该是PC里记录的下一条指令的地址。参数和局部变量。有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中,我们暂时不考虑这种情况。
C语言动态内存分配(堆内存)
在进程的地址空间中,代码区、常量区、全局数据区的内存在程序启动时就已经分配好了,它们大小固定,不能由程序员分配和释放,只能等到程序运行结束由操作系统回收。这称为静态内存分配。
栈区和堆区的内存在程序运行期间可以根据实际需求来分配和释放,不用在程序刚启动时就备足所有内存。这称为动态内存分配。
使用静态内存的优点是速度快,省去了向操作系统申请内存的时间,缺点就是不灵活,缺乏表现力,例如不能控制数据的作用范围,不能使用较大的内存。
而使用动态内存可以让程序对内存的管理更加灵活和高效,需要内存就立即分配,而且需要多少就分配多少,从几个字节到几个GB不等;不需要时就立即回收,再分配给其他程序使用。
栈和堆的区别
1)栈
程序启动时操作系统给程序对应的每条线程分配一块大小适当的内存做栈区,容量不大,默认一般1-8M
对于一般的函数调用这已经足够了,函数进栈出栈只是 ebp、esp 寄存器指向的变换,
或者是向已有的内存中写入数据,不涉及内存的分配和释放。大部分情况下并没有真的分配栈内存,仅仅是对已有内存的操作。栈区内存由系统分配和释放,不受程序员控制;
2)堆
堆不是程序启动的时候分配的,是程序中调用函数malloc() ,这个函数会去向操作系统“批发”了一块较大的内存空间,然后“零售”给程序用,当全部“售完”或程序有大量的内存需求时,再根据实际需求向操作系统“进货”,
所以堆可以很大,也可以很小!
当然 malloc() 在向程序零售堆空间时,必须管理它批发来的堆空间,不能把同一块地址出售两次,导致地址的冲突。于是 malloc() 需要一个算法来管理堆空间,这个算法就是堆的分配算法(不去玩儿了)。
总结:堆区内存完全由程序员掌控,想分配多少就分配多少,小到几个字节,大到几个GB,都可以,想什么时候释放就什么时候释放,非常灵活。
动态内存分配函数
堆(Heap)是唯一由程序员控制的内存区域,我们常说的动态内存分配也是在这个区域。在堆上分配和释放内存需要用到C语言标准库中的几个函数:
malloc()、calloc()、realloc() 和 free()。
- malloc()
原型为:void* malloc (size_t size);
作用:malloc() 函数用来动态地分配一块堆内存里的空间,参数size 为需要分配的内存空间的大小,单位字节。返回值:成功返回分配的内存地址,失败则返回NULL。
几点注意:
(1)由于申请内存空间时可能有也可能没有,所以需要自行判断是否申请成功,再进行后续操作。
(2)申请的内存空间没有做任何初始化的,里边数据是未知的垃圾数据。
(3)在分配内存时最好不要直接用数字指定内存空间的大小,这样不利于程序的移植。
因为在不同的操作系统中,同一数据类型的长度可能不一样。
为了解决这个问题,C语言提供了一个判断数据类型长度的操作符,就是 sizeof。
char ptr = (char )malloc(10sizeof(char)); // 分配10个字符的内存空间,用来存放字符
(4)函数的返回值类型是 void ,void 并不是说没有返回值或者返回空指针,而是返回的指针类型未知。
所以在使用 malloc() 时通常需要进行强制类型转换,将 void 指针转换成我们希望的类型,例如:**
#include <stdio.h> /* printf, scanf, NULL */
#include <stdlib.h> /* malloc, free, rand, system */
int main ()
{
int i,n;
char * buffer;
printf ("输入字符串的长度:");
scanf ("%d", &i);
buffer = (char*)malloc((i+1)*sizeof(char)); // 字符串最后包含 \0
if(buffer==NULL)
exit(1); // 判断是否分配成功,异常退出,exit(0)是正常退出
// 随机生成字符串
for(n=0; n<i; n++){
buffer[n] = rand()%26+'a';
}
buffer[i]='\0';
printf ("随机生成的字符串为:%s\n",buffer);
free(buffer);
// 释放内存空间,不能忘记,有借有还再借不难,大量的借了不还,就没可用内存了,
// 这种情况我们叫内存泄露,这个我们编程中尽力避免的东西。
return 0;
}
//执行与结果
chcp 65001 //win中解决中文乱码命令
gcc -g heapmalloc.c -o heap1.exe
./heap1
输入字符串的长度:100
随机生成的字符串为:phqghumeaylnlfdxfircvscxggbwkfnqduxwfnfozvsrtkjprepggxrpnrvystmwcysyycqpevikeffmznimkkasvwsrenzkycxf
- calloc()
原型:void* calloc(size_t n, size_t size);
功能:calloc() 在内存中动态地分配 n 个长度为 size 的连续空间(n*size 个字节长度的内存空间),并将每一个字节都初始化为 0。
calloc() 函数是对 malloc() 函数的简单封装,和malloc比就是多了一个初始化。
返回值:成功返回分配的内存地址,失败则返回NULL。
#include <stdio.h>
#include <stdlib.h>
int main(){
int i=0,n;
int * pData = NULL;
printf("请输入一个数字的数目:");
scanf("%d",&i);
pData = (int *)calloc(i,sizeof(int));
if(pData == NULL) //如果地址空间为空,不玩了,异常退出,exit(0)是正常退出
exit(1);
for(n=0;n<i;n++){
printf("填进去的第几个数字: %d ",n+1);
scanf("%d",&pData[n]);
}
//填进去的数字输出
printf("填进去的数字为: ");
for(n=0;n<i;n++){
printf("%d",pData[n]);
}
printf("\n");
//用完后,释放内存,避免内存泄漏
free(pData);
return 0;
}
- realloc()
原型:void* realloc(void *ptr, size_t size);
功能:realloc() 对 ptr 指向的内存重新分配 size 大小的空间,size 可比原来的大或者小,
还可以不变(如果你无聊的话)。
当 malloc()、calloc() 分配的内存空间不够用时,就可以用 realloc() 来调整已分配的内存。
如果 ptr 为 NULL,它的效果和 malloc() 相同,即分配 size 字节的内存空间。
如果 size 的值为 0,那么 ptr 指向的内存空间就会被释放,但是由于没有开辟新的内存空间,所以会返回空指针;类似于调用 free()。
返回值:分配成功返回新的内存地址,可能与 ptr 相同,也可能不同;失败则返回 NULL。
几点注意:
(1)指针 ptr 必须是在动态内存空间分配成功的指针,如后面的指针是不可以的:int *i; int a[2];
会导致运行时错误,可以简单的这样记忆:用 malloc()、calloc()、realloc() 分配成功的指针,或者是NULL指针,才能被 realloc() 函数接受。
(2)成功分配内存后 ptr 将被系统回收,一定不可再对 ptr 指针做任何操作,包括 free();
相反的,可以对 realloc() 函数的返回值进行正常操作。
(3)如果是扩大内存操作会把 ptr 指向的内存中的数据复制到新地址(新地址也可能会和原地址相同,但依旧不能对原指针进行任何操作);如果是缩小内存操作,原始据会被复制并截取新长度。
(4)如果分配失败,ptr 指向的内存不会被释放,它的内容也不会改变,依然可以正常使用。
#include <stdio.h>
#include <stdlib.h>
int main ()
{
int input,n;
int count = 0; //循环计数器
int* numbers = NULL;
int* more_numbers = NULL;
do {
printf ("Enter an integer value (0 to end): ");
scanf ("%d", &input);
count++;
more_numbers = (int*) realloc (numbers, count * sizeof(int));
if (more_numbers!=NULL) {
numbers=more_numbers;
numbers[count-1]=input;
}else {
free (numbers);
puts ("Error (re)allocating memory");
exit (1);
}
} while(input!=0);
printf ("Numbers entered: ");
for (n=0;n<count;n++)
printf("%d ",numbers[n]);
free (numbers); //more_numbers不用自已手工释放
system("pause");
return 0;
}
- free()
原型:void free(void* ptr);
功能:释放由 malloc()、calloc()、realloc() 申请的内存空间。
几点注意:
(1) 每个内存分配函数必须有相应的 free 函数,释放后不能再次使用被释放的内存。
(2) free(ptr ) 并不能改变指针 ptr 的值,ptr 依然指向以前的内存,为了防止再次使用该内存,建议将 ptr 的值手动置为 NULL。
没有这个步骤,ptr就变成了指向释放了的内存或者指向没有权限的内存,这种指针叫“野指针”。
野指针是我们编程中要尽量避免的东西!
free(ptr);
ptr = NULL;
(3)free() 只能释放动态分配的内存空间,并不能释放任意的内存。下面的写法是错误的:
int a[10];
// ...定义好的变量,是分配栈空间的不是堆内存了
free(a);
如果 ptr 所指向的内存空间不是由上面的三个函数所分配的,或者已被释放,那么调用 free() 会有无法预知的情况发生。
(4)如果 ptr 为 NULL,那么 free() 不会有任何作用。
#include <stdlib.h>
int main ()
{
int * buffer1, * buffer2, * buffer3;
buffer1 = (int*) malloc (100*sizeof(int));
buffer2 = (int*) calloc (100,sizeof(int));
buffer3 = (int*) realloc (buffer2,500*sizeof(int));
free (buffer1);
buffer1 = NULL;
free (buffer3);
buffer3 = NULL;
return 0;
}
总结:什么时候要自己分配内存使用堆空间?
栈:先进后出的连续存储方式,容量小,一般程序中的局部变量名和指针什么的都在栈上存储!
堆:非连续的存储方式,容量大,一般程序中占用空间较大的数据,比如数组、大字符串就放在堆上。