[C语言重点知识学习笔记]

C语言重点知识学习笔记

  • 三、字符与编码
    • 窄字符char
    • 宽字符wchar_t
  • 四、用户输入—scanf、getchar、getche、getch
    • 清空输入缓冲区
      • getchar()
      • scanf()
    • 非阻塞键盘输入
    • 输入密码遮挡
  • 六、字符串处理函数string.h
    • strcat()
    • strcpy()
  • 七、函数
  • 八、预处理指令
    • #include文件包含命令
    • #define宏定义命令
    • #的用法
    • ##的用法
    • 预定义宏
    • 条件编译
    • error宏
  • 九、指针
    • 字符串指针
    • 函数指针
    • 数组指针
    • const 和指针
    • const 和函数形参
    • const与非const转换
  • 十、结构体、枚举、共同体
    • 结构体
    • 枚举
    • 共同体
    • 大端小端
    • 位域
    • 位运算
  • 十一、文件操作
    • 字符读写函数 fgetc、fputc
    • 字符串读写函数 fgets、fputs
    • 数据块的形式读写fread、fwrite
    • 格式化读写文件
    • 随机读写文件
    • 文件复制拷贝
    • 获取文件长度
  • 十二、内存管理
    • 虚拟内存
    • 虚拟地址空间以及编译模式
      • CPU的数据处理能力
      • 实际支持的物理内存
      • 编译模式
    • 内存对齐
    • 内存分页机制
      • 地址隔离
      • 内存分页机制
        • 一级分页机制
        • 二级分页机制
    • 内存权限的控制
      • MMU部件
      • 对内存权限的控制
    • 内存布局(内存模型)
      • Linux下32位环境的用户空间内存分布
      • Windows下C语言程序的内存布局
    • 用户模式和内核模式
    • 栈(Stack)
      • 栈溢出
      • 函数调用
      • 函数调用惯例
      • 函数进栈出栈
      • 栈溢出攻击
    • 动态内存分配
      • 内存池
      • 内存泄漏(内存丢失)
    • 存储类别和生存期
      • static 变量
      • register 变量
  • 十三、多文件编写
    • extern
    • 编译
  • 十四、标准库

三、字符与编码

窄字符char

char 只能处理 ASCII 编码中的英文字符。

宽字符wchar_t

C语言规定,对于 ASCII 编码之外的单个字符,使用宽字符的编码方式。wchar_t 的长度由编译器决定:

  1. 在微软编译器下,它的长度是 2,等价于 unsigned short;
  2. 在GCC、LLVM/Clang 下,它的长度是 4,等价于unsigned int。

宽字符的编码方式,就得加上L前缀,例如L’A’、L’9’、L’中’、L’国’,加上L前缀后,所有的字符都将成为宽字符,占用 2 个字节或者 4 个字节的内存,包括 ASCII 中的英文字符。

#include 
#include 

int main(){
     
    wchar_t a = L'A';  //英文字符(基本拉丁字符)
    wchar_t b = L'9';  //英文数字(阿拉伯数字)
    wchar_t c = L'中';  //中文汉字
    wchar_t d = L'国';  //中文汉字
    wchar_t e = L'。';  //中文标点
    wchar_t f = L'ヅ';  //日文片假名
    wchar_t g = L'♥';  //特殊符号
    wchar_t h = L'༄';  //藏文
   
    //将本地环境设置为简体中文
    setlocale(LC_ALL, "zh_CN");

    //使用专门的 putwchar 输出宽字符
    putwchar(a);  putwchar(b);  putwchar(c);  putwchar(d);
    putwchar(e);  putwchar(f);  putwchar(g);  putwchar(h);
    putwchar(L'\n');  //只能使用宽字符
   
    //使用通用的 wprintf 输出宽字符
    wprintf(
        L"Wide chars: %lc %lc %lc %lc %lc %lc %lc %lc\n",  //必须使用宽字符串
        a, b, c, d, e, f, g, h
    );
   
   
    return 0;
}

四、用户输入—scanf、getchar、getche、getch

getche() 一样,getch() 也位于 conio.h 头文件中,也不是标准函数,默认只能在 Windows 下使用,不能在 Linux 和 Mac OS 下使用。
[C语言重点知识学习笔记]_第1张图片

清空输入缓冲区

getchar()

getchar()是带有缓冲区的,每次从缓冲区中读取一个字符,包括空格、制表符、换行符等空白符,只要我们让 getchar() 不停地读取,直到读完缓冲区中的所有字符,就能达到清空缓冲区的效果。

#include 
#include 

int main(){
     
	char str[12]; int a;char temp;
	scanf("%3s", str);
	while((temp=getchar()) != '\n' && temp != EOF);
	scanf("%2d", &a);
	printf("%s, %d", str, a);
	return 0; 
}

scanf()

scanf()允许把读取到的数据直接丢弃,不往变量中存放,具体方法就是在 % 后面加一个*,例如:
%d表示读取一个整数并丢弃;
%
[a-z]表示读取小写字母并丢弃;
%*[^\n]表示将换行符以外的字符全部丢弃。

%{
     *} {
     width} type
  • type表示读取什么类型的数据,例如 %d、%s、%[a-z]、% [ ^\n] 等;type 必须有。
  • width表示最大读取宽度,可有可无。
  • *表示丢弃读取到的数据,可有可无。
#include 
#include 

int main(){
     
	char str[12]; int a;char temp;
	scanf("%3s", str);
	scanf("%*[^\n]");scanf("%*c");
	scanf("%2d", &a);
	printf("%s, %d", str, a);
	return 0; 
}

非阻塞键盘输入

在 Windows 系统中,conio.h头文件中的kbhit()函数就可以用来实现非阻塞式键盘监听。 用户每按下一个键,都会将对应的字符放到输入缓冲区中,kbhit() 函数会检测缓冲区中是否有数据,如果有的话就返回非 0 值,没有的话就返回 0 值。但是 kbhit() 不会读取数据,数据仍然留在缓冲区,所以一般情况下我们还要结合输入函数将缓冲区种的数据读出。

#include 
#include 
#include 

int main(){
     
    char ch;
    int i = 0;
    //循环监听,直到按Esc键退出
    while(1){
     
        if(kbhit()){
       //检测缓冲区中是否有数据
            ch = getch();  //将缓冲区中的数据以字符的形式读出
            if(ch == 27){
     
                break;
            }
        }
        printf("Number: %d\n", ++i);
        Sleep(1000);  //暂停1秒
    }
    return 0;
}

输入密码遮挡

getch()函数不带回显,输入的字符不会在界面显示。

#include 
#include 

int main(){
     
	char yang; char psd[20];int index;
	while ((yang = getch()) != '\r'){
     
		if (yang != '\b'){
     
			printf("*");
			psd[index++] = yang;
		}
		else if (index>= 1 && yang == '\b'){
     
			psd[--index] = '\0';
			printf("\b \b");
		}
	}
	putchar('\n');
	printf("最终密码是:%s", psd);
	return 0; 
}

printf("\b \b")作用:首先让光标回退一格,然后输出"空格"字符,此时光标又会后移一位,这时在将光标后退一格。
在这里插入图片描述

六、字符串处理函数string.h

strcat()

#include 
#include 
#include 

int main(){
     
	char name1[3] = {
     '1','2'};
	char name2[20] = {
     '3','4','3','s'};
	printf("name1的地址是:%d\n",name1); 
	printf("name1原始值:%s\n",name1);
	printf("name1的原始长度是:%d\n",strlen(name1));
	strcat(name1,name2);
	printf("name1合并后的长度是:%d\n",strlen(name1));
	printf("name1和name2合并后的值:%s\n",name1);
	printf("name1的地址是:%d\n",name1);
	return 0; 
}

[C语言重点知识学习笔记]_第2张图片

strcpy()

int main(){
     
	char name1[3] = {
     '1','2'};
	char name2[20] = {
     '3','4','3','s','9','8'};
	strcpy(name1,name2);
	puts(name1);
	return 0; 
}

[C语言重点知识学习笔记]_第3张图片

七、函数

从整体上看,C语言代码是由一个一个的函数构成的,除了定义和说明类的语句(例如变量定义、宏定义、类型定义等)可以放在函数外面,所有具有运算或逻辑处理能力的语句(例如加减乘除、if else、for、函数调用等)都要放在函数内部。在一个函数的函数体内,不能再定义另一个函数,即不能嵌套定义

错误代码

#include 
int a = 10;
int b = a + 20;
int main(){
     
    return 0;
}

标准C语言(ANSI C)共定义了15 个头文件,称为“C标准库”,所有的编译器都必须支持,如何正确并熟练的使用这些标准库,可以反映出一个程序员的水平。

  • 合格程序员:
  • 熟练程序员:
  • 优秀程序员:

八、预处理指令

#include文件包含命令

使用尖括号< >和双引号" "的区别在于头文件的搜索路径不同:

  • 使用尖括号< >,编译器会到系统路径下查找头文件;
  • 使用双引号" ",编译器首先在当前目录下查找头文件,如果没有找到,再到系统路径下查找。

#define宏定义命令

用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串。这里所说的字符串是一般意义上的字符序列,不要和C语言中的字符串等同,它不需要双引号。

  1. 宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单粗暴的替换。字符串中可以含任何字符,它可以是常数、表达式、if 语句、函数等,预处理程序对它不作任何检查,如有错误,只能在编译已被宏展开后的源程序时发现。
  2. 宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换
  3. 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用#undef命令。
  4. 代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替。
  5. 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开时由预处理程序层层代换。
  6. 习惯上宏名用大写字母表示,以便于与变量区别。但也允许用小写字母。
  7. 可用宏定义表示数据类型,使书写方便。应注意用宏定义表示数据类型和用 typedef 定义数据说明符的区别。宏定义只是简单的字符串替换,由预处理器来处理;而 typedef 是在编译阶段由编译器处理的,它并不是简单的字符串替换,而给原有的数据类型起一个新的名字,将它作为一种新的数据类型。

#的用法

#用来将宏参数转换为字符串,也就是在宏参数的开头和末尾添加引号。

#define STR(s) #s

printf("%s", STR(c.biancheng.net));
printf("%s", STR("c.biancheng.net"));

结果:

printf("%s", "c.biancheng.net");
printf("%s", "\"c.biancheng.net\"");

##的用法

连接符,用来将宏参数或其他的串连接起来

#define CON1(a, b) a##e##b
#define CON2(a, b) a##b##00

printf("%f\n", CON1(8.5, 2));
printf("%d\n", CON2(12, 34));

结果:

printf("%f\n", 8.5e2);
printf("%d\n", 123400);

预定义宏

ANSI C 规定了以下几个预定义宏,它们在各个编译器下都可以使用:

  • LINE:表示当前源代码的行号;
  • FILE:表示当前源文件的名称;
  • DATE:表示当前的编译日期;
  • TIME:表示当前的编译时间;
  • STDC:当要求程序严格遵循ANSI C标准时该标识被赋值为1;
  • __cplusplus:当编写C++程序时该标识符被定义。

条件编译

#if、#elif、#else 和 #endif 都是预处理命令,这种能够根据不同情况编译不同代码、产生不同目标文件的机制,称为条件编译。条件编译是预处理程序的功能,不是编译器的功能。
需要注意的是,#if 后面跟的是“整型常量表达式”,而 #ifdef 和 #ifndef 后面跟的只能是一个宏名,不能是其他的。

#include 
#define NUM1 10
#define NUM2 20
int main(){
     
    #if (defined NUM1 && defined NUM2)
        //代码A
        printf("NUM1: %d, NUM2: %d\n", NUM1, NUM2);
    #else
        //代码B
        printf("Error\n");
    #endif
    return 0;
}

#ifdef 可以认为是 #if defined 的缩写

error宏

#error指令用于在编译期间产生错误信息,并阻止程序的编译。

#ifdef WIN32
#error This programme cannot compile at Windows Platform
#endif

九、指针

注意:未经初始化的指针不要随便用,不要随便带入到函数里面,编译可能通过,但是运行会出问题

字符串指针

C语言有两种表示字符串的方法,一种是字符数组,另一种是字符串常量,它们在内存中的存储位置不同,使得字符数组可以读取和修改,而字符串常量只能读取不能修改。

字符串常量只能读取不能写入,能够正常编译和链接,但在运行时会出现段错误(Segment Fault)或者写入位置错误,第4行代码是正确的,可以更改指针变量本身的指向;第5行代码是错误的,不能修改字符串中的字符。

#include 
int main(){
     
    char *str = "Hello World!";
    str = "I love C!";  //正确
    str[3] = 'P';  //错误
    return 0;
}

C语言为什么不允许直接传递数组的所有元素,而必须传递数组指针呢?

参数的传递本质上是一次赋值的过程,赋值就是对内存进行拷贝。所谓内存拷贝,是指将一块内存上的数据复制到另一块内存上。

对于像 int、float、char 等基本类型的数据,它们占用的内存往往只有几个字节,对它们进行内存拷贝非常快速。而数组是一系列数据的集合,数据的数量没有限制,可能很少,也可能成千上万,对它们进行内存拷贝有可能是一个漫长的过程,会严重拖慢程序的效率,为了防止技艺不佳的程序员写出低效的代码,C语言没有从语法上支持数据集合的直接赋值。

函数指针

不要返回局部变量的地址

用指针作为函数返回值时需要注意的一点是,函数运行结束后会销毁在它内部定义的所有局部数据,包括局部变量、局部数组和形式参数,函数返回的指针请尽量不要指向这些数据,C语言没有任何机制来保证这些数据会一直有效,它们在后续使用过程中可能会引发运行时错误。

函数运行结束后会销毁所有的局部数据,这个观点并没错,大部分C语言教材也都强调了这一点。但是,这里所谓的销毁并不是将局部数据所占用的内存全部抹掉,而是程序放弃对它的使用权限,弃之不理,后面的代码可以随意使用这块内存。对于上面的两个例子,func() 运行结束后 n 的内存依然保持原样,值还是 100,如果使用及时也能够得到正确的数据,如果有其它函数被调用就会覆盖这块内存,得到的数据就失去了意义。

数组指针

C语言标准规定,当数组名作为数组定义的标识符(也就是定义或声明数组时)、sizeof 或 & 的操作数时,它才表示整个数组本身,在其他的表达式中,数组名会被转换为指向第 0 个元素的指针(地址)。

const 和指针

const int *p1;
int const *p2;
int * const p3;

在最后一种情况下,指针是只读的,也就是 p3 本身的值不能被修改;在前面两种情况下,指针所指向的数据是只读的,也就是 p1、p2 本身的值可以修改(指向不同的数据),但它们指向的数据不能被修改。

const int * const p4;
int const * const p5;

指针本身和它指向的数据都有可能是只读的

const 和函数形参

在C语言中,单独定义 const 变量没有明显的优势,完全可以使用#define命令代替。const 通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用 const 来限制

const与非const转换

C语言标准库中很多函数的参数都被 const 限制了,但我们在以前的编码过程中并没有注意这个问题,经常将非 const 类型的数据传递给 const 类型的形参,这样做从未引发任何副作用,原因就是上面讲到的,将非 const 类型转换为 const 类型是允许的。反之,编译器不提倡这种行为,会给出错误或警告。

十、结构体、枚举、共同体

结构体

需要注意的是,结构体是一种自定义的数据类型,是创建变量的模板,不占用内存空间;结构体变量才包含了实实在在的数据,需要内存空间来存储。

对齐原则
原则1:数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。

原则2:结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。

原则3:结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。

枚举

枚举和宏其实非常类似:宏在预处理阶段将名字替换成对应的值,枚举在编译阶段将名字替换成对应的值,可以将枚举理解为编译阶段的宏。

#include 
int main(){
     
    enum week{
      Mon = 1, Tues, Wed, Thurs, Fri, Sat, Sun } 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;
}

枚举列表中的 Mon、Tues、Wed 这些标识符的作用范围是全局的(严格来说是 main() 函数内部),不能再定义与它们名字相同的变量。

Mon、Tues、Wed 等都是常量,不能对它们赋值,只能将它们的值赋给其他的变量。

共同体

结构体和共用体的区别在于:结构体的各个成员会占用不同的内存,互相之间没有影响;而共用体的所有成员占用同一段内存,修改一个成员会影响其余所有成员。结构体占用的内存大于等于所有成员占用的内存的总和(成员之间可能会存在缝隙),共用体占用的内存等于最长的成员占用的内存。共用体使用了内存覆盖技术,同一时刻只能保存一个成员的值,如果对新的成员赋值,就会把原来成员的值覆盖掉。
[C语言重点知识学习笔记]_第4张图片
[C语言重点知识学习笔记]_第5张图片

大端小端

大端和小端是指数据在内存中的存储模式,它由 CPU 决定:

  1. 大端模式(Big-endian)是指将数据的低位(比如 1234 中的 34 就是低位)放在内存的高地址上,而数据的高位(比如 1234 中的 12 就是高位)放在内存的低地址上。这种存储模式有点儿类似于把数据当作字符串顺序处理,地址由小到大增加,而数据从高位往低位存放。

  2. 小端模式(Little-endian)是指将数据的低位放在内存的低地址上,而数据的高位放在内存的高地址上。这种存储模式将地址的高低和数据的大小结合起来,高地址存放数值较大的部分,低地址存放数值较小的部分,这和我们的思维习惯是一致,比较容易理解。

位域

C语言标准规定,只有有限的几种数据类型可以用于位域。在 ANSI C 中,这几种数据类型是 int、signed int 和 unsigned int(int 默认就是 signed int);到了 C99,_Bool 也被支持了。

位域的具体存储规则如下

  1. 当相邻成员的类型相同时,如果它们的位宽之和小于类型的 sizeof 大小,那么后面的成员紧邻前一个成员存储,直到不能容纳为止;如果它们的位宽之和大于类型的 sizeof 大小,那么后面的成员将从新的存储单元开始,其偏移量为类型大小的整数倍;
  2. 当相邻成员的类型不同时,不同的编译器有不同的实现方案;
  3. 如果成员之间穿插着非位域成员,那么不会进行压缩
    struct bs{
           
        unsigned m: 12;
        unsigned ch;
        unsigned p: 4;
    };
    

无名位域: 位域成员可以没有名称,只给出数据类型和位宽,无名位域一般用来作填充或者调整成员位置。因为没有名称,无名位域不能使用。

struct bs{
     
    int m: 12;
    int  : 20;  //该位域成员不能使用
    int n: 4;
};

位运算

[C语言重点知识学习笔记]_第6张图片
C语言中不能直接使用二进制,&两边的操作数可以是十进制、八进制、十六进制,它们在内存中最终都是以二进制形式存储,&就是对这些内存中的二进制位进行运算。是根据内存中的二进制位进行运算的,而不是数据的二进制形式;其他位运算符也一样。

十一、文件操作

打开文件出错时,fopen() 将返回一个空指针,也就是 NULL,在打开文件时一定要判断文件是否打开成功。

FILE *fp;
if( (fp=fopen("D:\\demo.txt","rb") == NULL ){
     
    printf("Fail to open file!\n");
    exit(0);  //退出程序(结束程序)
}

[C语言重点知识学习笔记]_第7张图片
[C语言重点知识学习笔记]_第8张图片
调用 fopen() 函数时必须指明读写权限,但是可以不指明读写方式(此时默认为"t"

文件一旦使用完毕,应该用 fclose() 函数把文件关闭,以释放相关资源,避免数据丢失,文件正常关闭时,fclose() 的返回值为0,如果返回非零值则表示有错误发生。

int fclose(FILE *fp)

文本方式和二进制方式并没有本质上的区别,只是对于换行符的处理不同。

在C语言中,二进制方式很简单,读取文件时,会原封不动的读出文件的全部內容,写入数据时,也是把缓冲区中的內容原封不动的写到文件中。

C语言程序将\n作为换行符,类 UNIX/Linux 系统在处理文本文件时也将\n作为换行符,所以程序中的数据会原封不动地写入文本文件中,反之亦然。

但是 Windows 系统却不同,它将\r\n作为文本文件的换行符。在 Windows 系统中,如果以文本方式打开文件,当读取文件时,程序会将文件中所有的\r\n转换成一个字符\n。也就是说,如果文本文件中有连续的两个字符是\r\n,则程序会丢弃前面的\r,只读入\n。

当写入文件时,程序会将\n转换成\r\n写入。也就是说,如果要写入的内容中有字符\n,则在写入该字符前,程序会自动先写入一个\r。

总起来说,对于 Windows 平台,为了保险起见,我们最好用"t"来打开文本文件,用"b"来打开二进制文件。对于 Linux 平台,使用"r"还是"b"都无所谓,既然默认是"r",那我们什么都不写就行了。

字符读写函数 fgetc、fputc

int fgetc (FILE *fp);

fp 为文件指针。fgetc() 读取成功时返回读取到的字符,读取到文件末尾或读取失败时返回EOF。

EOF 本来表示文件末尾,意味着读取结束,但是很多函数在读取出错时也返回 EOF,那么当返回 EOF 时,到底是文件读取完毕了还是读取出错了?我们可以借助 stdio.h 中的两个函数来判断,分别是 feof() 和 ferror()。

int fputc ( int ch, FILE *fp );

ch 为要写入的字符,fp 为文件指针。fputc() 写入成功时返回写入的字符,失败时返回 EOF,返回值类型为 int 也是为了容纳这个负数。

字符串读写函数 fgets、fputs

char *fgets ( char *str, int n, FILE *fp );

str 为字符数组,n 为要读取的字符数目,fp 为文件指针。返回值:读取成功时返回字符数组首地址,也即 str;读取失败时返回 NULL;如果开始读取时文件内部指针已经指向了文件末尾,那么将读取不到任何字符,也返回 NULL。读取到的字符串会在末尾自动添加 ‘\0’,n 个字符也包括 ‘\0’。

int fputs( char *str, FILE *fp );

str 为要写入的字符串,fp 为文件指针。写入成功返回非负数,失败返回 EOF。

数据块的形式读写fread、fwrite

这个类似于序列化和反序列化了。

size_t fread ( void *ptr, size_t size, size_t count, FILE *fp );
size_t fwrite ( void * ptr, size_t size, size_t count, FILE *fp );

对参数的说明:

  • ptr 为内存区块的指针,它可以是数组、变量、结构体等。fread() 中的 ptr 用来存放读取到的数据,fwrite() 中的 ptr 用来存放要写入的数据。
  • size:表示每个数据块的字节数,ptr指针指向数据类型的大小。
  • count:表示要读写的数据块的块数。
  • fp:表示文件指针。
    理论上,每次读写 size*count 个字节的数据。

格式化读写文件

fscanf() 和 fprintf() 函数与前面使用的 scanf() 和 printf() 功能相似,都是格式化读写函数,两者的区别在于 fscanf() 和 fprintf() 的读写对象不是键盘和显示器,而是磁盘文件。

int fscanf ( FILE *fp, char * format, ... );
int fprintf ( FILE *fp, char * format, ... );

如果将 fp 设置为 stdin,那么 fscanf() 函数将会从键盘读取数据,与 scanf 的作用相同;设置为 stdout,那么 fprintf() 函数将会向显示器输出内容,与 printf 的作用相同。例如:

#include
int main(){
     
    int a, b, sum;
    fprintf(stdout, "Input two numbers: ");
    fscanf(stdin, "%d %d", &a, &b);
    sum = a + b;
    fprintf(stdout, "sum=%d\n", sum);
    return 0;
}

随机读写文件

移动文件内部位置指针的函数主要有两个,即 rewind() 和 fseek()。fseek() 用来将位置指针移动到任意位置,它的原型为

int fseek ( FILE *fp, long offset, int origin );

参数说明:

  1. fp 为文件指针,也就是被移动的文件。

  2. offset 为偏移量,也就是要移动的字节数。之所以为 long 类型,是希望移动的范围更大,能处理的文件更大。offset 为正时,向后移动;offset 为负时,向前移动。

  3. origin 为起始位置,也就是从何处开始计算偏移量。C语言规定的起始位置有三种,分别为文件开头、当前位置和文件末尾,每个位置都用对应的常量来表示:
    [C语言重点知识学习笔记]_第9张图片

值得说明的是,fseek() 一般用于二进制文件,在文本文件中由于要进行转换,计算的位置有时会出错。

文件复制拷贝

两个关键的问题需要解决:

  1. 开辟多大的缓冲区合适?缓冲区过小会造成读写次数的增加,过大也不能明显提高效率。目前大部分磁盘的扇区都是4K对齐的,如果读写的数据不是4K的整数倍,就会跨扇区读取,降低效率,所以我们开辟4K的缓冲区。

  2. 缓冲区中的数据是没有结束标志的,如果缓冲区填充不满,如何确定写入的字节数?最好的办法就是每次读取都能返回读取到的字节数。

通过fread()函数,每次从 fileRead 文件中读取 bufferLen 个字节,放到缓冲区,再通过fwrite()函数将缓冲区的内容写入fileWrite文件。

int main() {
     
	FILE * Read = NULL;
	FILE * Write = NULL;
	size_t readcount;
	size_t writecount;
	srand((unsigned)time(NULL));
	int a = rand() % 100;
	char root[50] = "./as";
	char id[10]= {
     '\0'};
	sprintf(id,"%d",a);
	
	char *buffer = (char *)malloc(1024*4);
	if((Read = fopen("./as.exe","rb")) != NULL && (Write = fopen(strcat(strcat(root, id),".exe"),"wb"))){
     
		while((readcount = fread(buffer, 1, 1024*4, Read)) > 0){
     
			fwrite(buffer, readcount, 1, Write);
		}
	};
	free(buffer);
	fclose(Read);
	fclose(Write);

    return 0;
}

获取文件长度

fpos_t(long long)文件内部位置指针的值,fgetpos()获取文件内部指针的当前位置,fsetpos()将保存的文件指针设置为文件内部指针的当前位置。ftell()返回当前指针位置距离文件开头的字节长度。

int main() {
     
    FILE *Read;
    fpos_t *pos = (fpos_t *)malloc(1);
    long length;
    char *content = (char*)malloc(20);
    if ((Read = fopen("./sad.txt","rb")) != NULL){
     /*必须以二进制打开*/
    	fgetpos(Read, pos);/*保存当前文件指针位置*/
    	fseek(Read, 0, SEEK_END);/*将当前文件指针定位到文件末尾*/
    	length = ftell(Read);/*获取文件末尾到文件开头的字节长度*/
    	printf("文件长度是:%d\n", length);
		fsetpos(Read, pos); /*恢复文件指针的位置*/
		/*测试是否恢复了位置*/
		fseek(Read, 5, SEEK_SET);
		if (fgets(content,10,Read) != NULL){
     
			puts(content);
		}else{
     
			puts("没有字符串");
		};
	}
	free(content);
	free(pos);
	system("pause");
    return 0;
}

十二、内存管理

寄存器(Register)是CPU内部非常小、非常快速的存储部件,它的容量很有限,对于32位的CPU,每个寄存器一般能存储32位(4个字节)的数据,对于64位的CPU,每个寄存器一般能存储64位(8个字节)的数据。为了完成各种复杂的功能,现代CPU都内置了几十个甚至上百个的寄存器,嵌入式系统功能单一,寄存器数量较少。寄存器在程序的执行过程中至关重要,不可或缺,它们可以用来完成数学运算、控制循环次数、控制程序的执行流程、标记CPU运行状态等。
[C语言重点知识学习笔记]_第10张图片
那么,在CPU内部为什么又要设置缓存呢?虽然内存的读取速度已经很快了,但是和CPU比起来,还是有很大差距的,不是一个数量级的,如果每次都从内存中读取数据,会严重拖慢CPU的运行速度,CPU经常处于等待状态,无事可做。在CPU内部设置一个缓存,可以将使用频繁的数据暂时读取到缓存,需要同一地址上的数据时,就不用大老远地再去访问内存,直接从缓存中读取即可。

缓存的容量是有限的,CPU只能从缓存中读取到部分数据,对于使用不是很频繁的数据,会绕过缓存,直接到内存中读取。所以不是每次都能从缓存中得到数据,这就是缓存的命中率,能够从缓存中读取就命中,否则就没命中。关于缓存的命中率又是一门学问,哪些数据保留在缓存,哪些数据不保留,都有复杂的算法。

虚拟内存

内存地址都是假的,不是真实的物理内存地址,而是虚拟地址。虚拟地址通过CPU的转换才能对应到物理地址,而且每次程序运行时,操作系统都会重新安排虚拟地址和物理地址的对应关系,哪一段物理内存空闲就使用哪一段。
[C语言重点知识学习笔记]_第11张图片
把程序给出的地址看做是一种虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证程序每次运行时都可以使用相同的地址。

除了在编程时可以使用固定的内存地址,给程序员带来方便外,1、使用虚拟地址还能够使不同程序的地址空间相互隔离,2、提高内存使用效率。

虚拟地址空间以及编译模式

CPU的数据处理能力

数据总线位于主板之上,不在CPU中,也不由CPU决定,严格来讲,这里应该说CPU能够支持的数据总线的最大根数,也即能够支持的最大数据处理能力。

数据总线和主频都是CPU的重要指标:数据总线决定了CPU单次的数据处理能力,主频决定了CPU单位时间内的数据处理次数,它们的乘积就是CPU单位时间内的数据处理量。

要存取数据或指令就要知道数据或指令存放的位置,地址寄存器存储的就是CPU当前要存取的数据或指令的地址,该地址是由地址总线传输到地址寄存器上的。假设地址总线有n位,即共有n位二进制位来表示地址,那么最多可以表示2 ^ n个地址,另外,由于计算机以一个字节为寻址单位,所以CPU的寻址能力或者说最大寻址范围为2^n个字节。

由地址寄存器指出要存取数据或指令的位置后,接下来就是到该地址把数据或指令找到,并用数据总线传输给CPU。假设数据总线有m位,则传输的数据或指令也有m位。而字长指CPU同一时间内可以处理的二进制数的位数,所以数据总线传输的数据或指令的位数要与字长一致。否则,如果数据总线宽度大于字长则一条数据或指令要分多次传输,则分开传输的几组数据也就没有意义了;如果数据总线宽度小于字长,则CPU的利用率要降低,对资源是种浪费。

实际支持的物理内存

CPU支持的物理内存只是理论上的数据,实际应用中还会受到操作系统的限制,例如,Win7 64位家庭版最大仅支持8GB或16GB的物理内存,Win7 64位专业版或企业版能够支持到192GB的物理内存。

编译模式

为了兼容不同的平台,现代编译器大都提供两种编译模式:32位模式和64位模式,指的是虚拟内存的访问空间大小。

  1. 32位编译模式
    在32位模式下,一个指针或地址占用4个字节的内存,共有32位,理论上能够访问的虚拟内存空间大小为 2^32 = 0X100000000 Bytes,即4GB,有效虚拟地址范围是 0 ~ 0XFFFFFFFF。
    也就是说,对于32位的编译模式,不管实际物理内存有多大,程序能够访问的有效虚拟地址空间的范围就是0 ~ 0XFFFFFFFF,也即虚拟地址空间的大小是 4GB。换句话说,程序能够使用的最大内存为 4GB,跟物理内存没有关系
  2. 64位编译模式
    在64位编译模式下,一个指针或地址占用8个字节的内存,共有64位,理论上能够访问的虚拟内存空间大小为 2^ 64。这是一个很大的值,几乎是无限的,就目前的技术来讲,不但物理内存不可能达到这么大,CPU的寻址能力也没有这么大,实现64位长的虚拟地址只会增加系统的复杂度和地址转换的成本,带不来任何好处,所以 Windows 和 Linux 都对虚拟地址进行了限制,仅使用虚拟地址的低48位(6个字节),总的虚拟地址空间大小为 2^48 = 256TB。

32位的操作系统只能运行32位的程序(也即以32位模式编译的程序),64位操作系统可以同时运行32位的程序(为了向前兼容,保留已有的大量的32位应用程序)和64位的程序(也即以64位模式编译的程序)。
64位的CPU运行64位的程序才能发挥它的最大性能,运行32位的程序会白白浪费一部分资源。

内存对齐

CPU 通过地址总线来访问内存,一次能处理几个字节的数据,就命令地址总线读取几个字节的数据。32 位的 CPU 一次可以处理4个字节的数据,那么每次就从内存读取4个字节的数据

将一个数据尽量放在一个步长之内,避免跨步长存储,这称为内存对齐。在32位编译模式下,默认以4字节对齐;在64位编译模式下,默认以8字节对齐。

对于全局变量,GCC在 Debug 和 Release 模式下都会进行内存对齐,而VS只有在 Release 模式下才会进行对齐。而对于局部变量,GCC和VS都不会进行对齐,不管是Debug模式还是Release模式。

内存对齐虽然和硬件有关,但是决定对齐方式的是编译器,如果你的硬件是64位的,却以32位的方式编译,那么还是会按照4个字节对齐。

最后需要说明的是:内存对齐不是C语言的特性,它属于计算机的运行原理,C++、Java、Python等其他编程语言同样也会有内存对齐的问题。

内存分页机制

地址隔离

关于虚拟地址和物理地址的映射有很多思路,假设以程序为单位,把一段与程序运行所需要的同等大小的虚拟空间映射到某段物理空间。

程序A需要 10MB 内存,虚拟地址的范围是从 0X00000000 到 0X00A00000,假设它被映射到一段同等大小的物理内存,地址范围从 0X00100000 到 0X00B00000,即虚拟空间中的每一个字节对应于物理空间中的每一个字节。
[C语言重点知识学习笔记]_第12张图片
当程序A需要访问 0X00001000 时,系统会将这个虚拟地址转换成实际的物理地址 0X00101000,访问 0X002E0000 时,转换成 0X003E0000,以此类推。

程序A和程序B分别被映射到了两块不同的物理内存,它们之间没有任何重叠,如果程序A访问的虚拟地址超出了 0X00A00000 这个范围,系统就会判断这是一个非法的访问,拒绝这个请求,并将这个错误报告给用户,通常的做法就是强制关闭程序。

以程序为单位对虚拟内存进行映射时,如果物理内存不足,被换入换出到磁盘的是整个程序,这样势必会导致大量的磁盘读写操作,严重影响运行速度,所以这种方法还是显得粗糙,粒度比较大。

这种以整个程序为单位的方法很好地解决了不同程序地址隔离的问题,同时也能够在程序中使用固定的地址。也就是进程是CPU内存分配的最小单位,线程是CPU调度的最小单位

内存分页机制

当一个程序运行时,在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在一个时间段内都不会被用到。

以整个程序为单位进行映射,不仅会将暂时用不到的数据从磁盘中读取到内存,也会将过多的数据一次性写入磁盘,这会严重降低程序的运行效率。

现代计算机都使用分页(Paging)的方式对虚拟地址空间和物理地址空间进行分割和映射,以减小换入换出的粒度,提高程序运行效率。

分页(Paging)的思想是指把地址空间人为地分成大小相等(并且固定)的若干份,这样的一份称为一页,就像一本书由很多页面组成,每个页面的大小相等。如此,就能够以页为单位对内存进行换入换出:

  1. 当程序运行时,只需要将必要的数据从磁盘读取到内存,暂时用不到的数据先留在磁盘中,什么时候用到什么时候读取。
  2. 当物理内存不足时,只需要将原来程序的部分数据写入磁盘,腾出足够的空间即可,不用把整个程序都写入磁盘。

通过一个简单的例子来说明虚拟地址是如何根据页来映射到物理地址的
[C语言重点知识学习笔记]_第13张图片
程序1和程序2的虚拟空间都有8个页,为了方便说明问题,我们假设每页大小为 1KB,那么虚拟地址空间就是 8KB。假设计算机有13条地址线,即拥有 2^13 的物理寻址能力,那么理论上物理空间可以多达 8KB。但是出于种种原因,购买内存的资金不够,只买得起 6KB 的内存,所以物理空间真正有效的只是前 6KB。

当我们把程序的虚拟空间按页分隔后,把常用的数据和代码页加载到内存中,把不常用的暂时留在磁盘中,当需要用到的时候再从磁盘中读取。上图中,我们假设有两个程序 Program 1 和 Program 2,它们的部分虚拟页面被映射到物理页面,比如 Program 1 的 VP0、VP1 和 VP7 分别被映射到 PP0、PP2 和 PP3;而有部分却留在磁盘中,比如 VP2、VP3 分别位于磁盘的 DP0、DP1中;另外还有一些页面如 VP4、VP5、VP6 可能尚未被用到或者访问到,它们暂时处于未使用状态。

这里,我们把虚拟空间的页叫做虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)。

图中的线表示映射关系,可以看到,Program 1 和 Program 2 中的有些虚拟页被映射到同一个物理页,这样可以实现内存共享。

Program 1 的 VP2、VP3 不在内存中,但是当进程需要用到这两个页的时候,硬件会捕获到这个消息,就是所谓的页错误(Page Fault),然后操作系统接管进程,负责将 VP2 和 PV3 从磁盘中读取出来并且装入内存,然后将内存中的这两个页与 VP2、VP3 之间建立映射关系。

一级分页机制

内存地址的转换是通过一种叫做页表(Page Table)的机制来完成的。内存是分页的,只要我们能够定位到数据所在的页,以及它在页内的偏移(也就是距离页开头的字节数),就能够转换为物理地址。例如,一个 int 类型的值保存在第 12 页,页内偏移为 240,那么对应的物理地址就是 2^12 * 12 + 240 = 49392。

2^12 为一个页的大小,也就是4K。

虚拟地址空间大小为 4GB,总共包含 2^ 32 / 2^ 12 = 2^ 20 = 1K * 1K = 1M = 1048576 个页面,我们可以定义一个这样的数组:它包含 2^20 = 1M 个元素,每个元素的值为页面编号(也就是位于第几个页面),长度为4字节,整个数组共占用4MB的内存空间。这样的数组就称为页表(Page Table),它记录了地址空间中所有页的编号。

虚拟地址长度为32位,我们不妨进行一下切割,将高20位作为页表数组的下标,低12位作为页内偏移。如下图所示:
在这里插入图片描述
为什么要这样切割呢?因为页表数组共有 2 ^ 20 = 1M 个元素,使用虚拟地址的高20位作为下标,正好能够访问数组中的所有元素;并且,一个页面的大小为 2^12 = 4KB,使用虚拟地址的低12位恰好能够表示所有偏移。

注意,表示页面编号只需要 20 位,而页表数组的每个元素的长度却为 4 字节,即 32 位,多出 32 - 20 = 12 位。这 12 位也有很大的用处,可以用来表示当前页的相关属性,例如是否有读写权限、是否已经分配物理内存、是否被换出到硬盘等。

例如一个虚拟地址 0XA010BA01,它的高20位是 0XA010B,所以需要访问页表数组的第 0XA010B 个元素,才能找到数据所在的物理页面。假设页表数组第 0XA010B 个元素的值为 0X0F70AAA0,它的高20位为 0X0F70A,那么就可以确定数据位于第 0X0F70A 个物理页面。再来看虚拟地址,它的低12位是 0XA01,所以页内偏移也是 0XA01。有了页面索引和页内偏移,就可以算出物理地址了。经过计算,最终的物理地址为 0X0F70A * 2^12 + 0XA01 = 0X0F70A000 + 0XA01 = 0X0F70AA01。
[C语言重点知识学习笔记]_第14张图片
可以发现,有的页被映射到物理内存,有的被映射到硬盘,不同的映射方式可以由页表数组元素的低12位来控制。

使用这种方案,不管程序占用多大的内存,都要为页表数组分配4M的内存空间(页表数组也必须放在物理内存中),因为虚拟地址空间中的高1G或2G是被系统占用的,必须保证较大的数组下标有效。数组必须是连续的,不能间断。

现在硬件很便宜了,内存容量大了,很多电脑都配备4G或8G的内存,页表数组占用4M内存或许不觉得多,但在32位系统刚刚发布的时候,内存还是很紧缺的资源,很多电脑才配备100M甚至几十兆的内存,4M内存就显得有点大了,所以还得对上面的方案进行改进,压缩页表数组所占用的内存。

二级分页机制

上面的页表共有 2^20 = 2^10 * 2^10 个元素,为了压缩页表的存储空间,可以将上面的页表分拆成 2^10 = 1K = 1024 个小的页表,这样每个页表只包含 2^10 = 1K = 1024 个元素,占用 2^10 * 4 = 4KB 的内存,也即一个页面的大小。这 1024 个小的页表,可以存储在不同的物理页,它们之间可以是不连续的。

那么问题来了,既然这些小的页表分散存储,位于不同的物理页,该如何定位它们呢?也就是如何记录它们的编号(也即在物理内存中位于第几个页面)。

1024 个页表有 1024 个索引,所以不能用一个指针指向它们,必须将这些索引再保存到一个额外的数组中。这个额外的数组有1024个元素,每个元素记录一个页表所在物理页的编号,长度为4个字节,总共占用4KB的内存。我们将这个额外的数组称为页目录(Page Directory),因为它的每一个元素对应一个页表。

如此,只要使用一个指针来记住页目录的地址即可,等到进行地址转换时,可以根据这个指针找到页目录,再根据页目录找到页表,最后找到物理地址,前后共经过3次间接转换。

那么,如何根据虚拟地址找到页目录和页表中相应的元素呢?我们不妨将虚拟地址分割为三分部,高10位作为页目录中元素的下标,中间10位作为页表中元素的下标,最后12位作为页内偏移,如下图所示:
在这里插入图片描述
前面我们说过,知道了物理页的索引和页内偏移就可以转换为物理地址了,在这种方案中,页内偏移可以从虚拟地址的低12位得到,但是物理页索引却保存在 1024 个分散的小页表中,所以就必须先根据页目录找到对应的页表,再根据页表找到物理页索引。

例如一个虚拟地址 0011000101 1010001100 111100001010,它的高10位为 0011000101,对应页目录中的第 0011000101 个元素,假设该元素的高20位为 0XF012A,也即对应的页表在物理内存中的编号为 0XF012A,这样就找到了页表。虚拟地址中间10位为 1010001100,它对应页表中的第 1010001100 个元素,假设该元素的高20位为 0X00D20,也即物理页的索引为 0X00D20。通过计算,最终的物理地址为 0X00D20 * 2^12 + 111100001010 = 0X00D20F0A。

这种思路所形成的映射关系如下图所示:
[C语言重点知识学习笔记]_第15张图片
采用这样的两级页表的一个明显优点是,如果程序占用的内存较少,分散的小页表的个数就会远远少于1024个,只会占用很少的一部分存储空间(远远小于4M)。因为二级分页只要求页目录是连续的即可,页表可有可无,这是一种类似于“数组+链表”的结构,链表可有可无。

在极少数的情况下,程序占用的内存非常大,布满了4G的虚拟地址空间,这样小页表的数量可能接近甚至等于1024,再加上页目录占用的存储空间,总共是 4MB+4KB,比上面使用一级页表的方案仅仅多出4KB的内存。这是可以容忍的,因为很少出现如此极端的情况。

也就是说,使用两级页表后,页表占用的内存空间不固定,它和程序本身占用的内存空间成正比,从整体上来看,会比使用一级页表占用的内存少得多。

内存权限的控制

MMU部件

在CPU内部,有一个部件叫做MMU(Memory Management Unit,内存管理单元),由它来负责将虚拟地址映射为物理地址,如下图所示:
在这里插入图片描述
MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。在程序加载到内存以及程序运行过程中,操作系统会不断更新程序对应的页表,并将页目录的物理地址保存到 CR3 寄存器。MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射。

CR3 是CPU内部的一个寄存器,专门用来保存页目录的物理地址。

对内存权限的控制

MMU 除了能够完成虚拟地址到物理地址的映射,还能够对内存权限进行控制。页表数组中,每个元素占用4个字节,也即32位,我们使用高20位来表示物理页编号,还剩下低12位,这12位就用来对内存进行控制,例如,是映射到物理内存还是映射到磁盘,程序有没有访问权限,当前页面有没有执行权限等。

操作系统在构建页表时将内存权限定义好,当MMU对虚拟地址进行映射时,首先检查低12位,看当前程序是否有权限使用,如果有,就完成映射,如果没有,就产生一个异常,并交给操作系统处理。操作系统在处理这种内存错误时一般比较粗暴,会直接终止程序的执行。

#include 
int main() {
     
    char *str = (char*)0XFFF00000;  //使用数值表示一个明确的地址
    printf("%s\n", str);
    return 0;
}

这段代码不会产生编译和链接错误,但在运行程序时,为了输出字符串,printf() 需要访问虚拟地址为 0XFFFF00000 的内存,但是该虚拟地址是被操作系统占用的(下节会讲解),程序没有权限访问,会被强制关闭。

内存布局(内存模型)

程序内存在地址空间中的分布情况称为内存模型(Memory Model),内存模型由操作系统构建。

对于32位环境,在这 4GB 的地址空间中,要拿出一部分给操作系统内核使用,应用程序无法直接访问这一段内存,这一部分内存地址被称为内核空间(Kernel Space)。

Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),而 Linux 默认情况下会将高地址的 1GB 空间分配给内核。也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间(User Space)。

Linux下32位环境的用户空间内存分布

[C语言重点知识学习笔记]_第16张图片
程序代码区、常量区、全局数据区在程序加载到内存后就分配好了,并且在程序运行期间一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。

常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。程序员唯一能控制的内存区域就是堆(Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。

Windows下C语言程序的内存布局

不像 Linux,Windows 是闭源的,有版权保护,资料较少,不好深入研究每一个细节,至今仍有一些内部原理不被大家知晓。关于 Windows 地址空间的内存分布,官网上只给出了简单的说明:

  1. 对于32位程序,内核占用较高的 2GB,剩下的 2GB 分配给用户程序;
  2. 对于64位程序,内核占用最高的 248TB,用户程序占用最低的8TB。

[C语言重点知识学习笔记]_第17张图片
栈的位置则在 0x00030000 和 exe 文件后面都有分布,每个线程的栈都是独立的,所以一个进程中有多少个线程,就有多少个对应的栈,对于 Windows 来说,每个线程默认的栈大小是 1MB。

在分配完上面这些地址以后,Windows 的地址空间已经是支离破碎了。当程序向系统申请堆空间时,只好从这些剩下的还有没被占用的地址上分配。

用户模式和内核模式

内核空间存放的是操作系统内核代码和数据,是被所有程序共享的。

用户程序调用系统 API 函数称为系统调用(System Call);发生系统调用时会暂停用户程序,转而执行内核代码(内核也是程序),访问内核空间,这称为内核模式(Kernel Mode)。

用户空间保存的是应用程序的代码和数据,是程序私有的,其他程序一般无法访问。当执行应用程序自己的代码时,称为用户模式(User Mode)。

让内核拥有完全独立的地址空间,就是让内核处于一个独立的进程中,这样每次进行系统调用都需要切换进程。切换进程的消耗是巨大的,不仅需要寄存器进栈出栈,还会使CPU中的数据缓存失效、MMU中的页表缓存失效,这将导致内存的访问在一段时间内相当低效。

栈(Stack)

栈(Stack)可以存放函数参数、局部变量、局部数组等作用范围在函数内部的数据,它的用途就是完成函数的调用。栈内存由系统自动分配和释放:发生函数调用时就为函数运行时用到的数据分配内存,函数调用结束后就将之前分配的内存全部销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部。
[C语言重点知识学习笔记]_第18张图片

ebp 和 esp 都是CPU中的寄存器:ebp 是 Extend Base Pointer 的缩写,通常用来指向栈底;esp 是 Extend Stack Pointer 的缩写,通常用来指向栈顶。

栈溢出

对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这在编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。

一个程序可以包含多个线程,每个线程都有自己的栈,严格来说,栈的最大值是针对线程来说的,而不是针对程序。

函数调用

当发生函数调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record):

  1. 函数的返回地址:也就是函数执行完成后从哪里开始继续执行后面的代码,即下一条指令的地址
  2. 参数和局部变量:有些编译器,或者编译器在开启优化选项的情况下,会通过寄存器来传递参数,而不是将参数压入栈中;
  3. 编译器自动生成的临时数据:例如,当函数返回值的长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。
  4. 一些需要保存的寄存器值:例如 ebp、ebx、esi、edi 等。之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。

[C语言重点知识学习笔记]_第19张图片
当发生函数调用时:

  1. 实参、返回地址、ebp 寄存器首先入栈;
  2. 然后再分配一块内存供局部变量、返回值等使用,这块内存一般比较大,足以容纳所有数据,并且会有冗余;
  3. 最后将其他寄存器的值压入栈中

函数调用惯例

函数调用方和被调用方必须遵守同样的约定,理解要一致,这称为调用惯例(Calling Convention)
一个调用惯例一般规定以下两方面的内容:

  1. 函数参数的传递方式,是通过栈传递还是通过寄存器传递(这里我们只讲解通过栈传递的情况)。

  2. 函数参数的传递顺序,是从左到右入栈还是从右到左入栈。

  3. 参数弹出方式。函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。

  4. 函数名修饰方式。函数名在编译时会被修改,调用惯例可以决定如何修改函数名。

函数进栈出栈

[C语言重点知识学习笔记]_第20张图片
函数出栈只是在增加 esp 寄存器的值,使它指向上一个数据,并没有销毁之前的数据。前面我们讲局部变量在函数运行结束后立即被销毁其实是错误的,这只是为了让大家更容易理解,对局部变量的作用范围有一个清晰的认识。栈上的数据只有在后续函数继续入栈时才能被覆盖掉,这就意味着,只要时机合适,在函数外部依然能够取得局部变量的值。

栈溢出攻击

这里所说的“栈溢出”是指栈上的某个数据过大,覆盖了其他的数据,不是指超出了整个栈的大小。

#include 
#include 

int main(){
     
    char *str1 = "这里是C语言中文网";
    char str2[6] = {
     0};
    strcpy(str2, str1);
    printf("str: %s\n", str2);
    return 0;
}

局部数组在栈上分配内存,并且不对数组溢出做检测,这是导致栈溢出的根源。除了上面讲到的 gets() 函数,strcpy()、scanf() 等能够向数组写入数据的函数都有导致栈溢出的风险。
[C语言重点知识学习笔记]_第21张图片
栈底为高地址,栈顶为低地址。对于数组元素,当溢出的时候往高地址增加数据,则导致odd ebp和返回地址被占用。(4字节空白内存是不是就起这个缓冲作用?)

动态内存分配

栈区和堆区的管理模式:栈区内存由系统分配和释放,不受程序员控制;堆区内存完全由程序员掌控,分配和释放。malloc()、calloc()、realloc() 和 free()

  1. 在分配内存时最好不要直接用数字指定内存空间的大小,这样不利于程序的移植。因为在不同的操作系统中,同一数据类型的长度可能不一样。为了解决这个问题,C语言提供了一个判断数据类型长度的操作符,就是 sizeof。
  2. 每个内存分配函数必须有相应的 free 函数,释放后不能再次使用被释放的内存。free(p) 并不能改变指针 p 的值,p 依然指向以前的内存,为了防止再次使用该内存,建议将 p 的值手动置为 NULL

内存池

不管具体的分配算法是怎样的,为了减少系统调用,减少物理内存碎片,malloc() 的整体思想是先向操作系统申请一块大小适当的内存,然后自己管理,这就是内存池(Memory Pool),内存池的研究重点不是向操作系统申请内存,而内存管理。

内存泄漏(内存丢失)

动态分配的内存,如果没有指针指向它,就无法进行任何操作,这段内存会一直被程序占用,直到程序运行结束由操作系统回收。

#include 
#include 
int main(){
     
    char *p = (char*)malloc(100 * sizeof(char));
    p = (char*)malloc(50 * sizeof(char));
    free(p);
    p = NULL;
    return 0;
}

存储类别和生存期

存储类别就是变量在内存中的存放区域。在进程的地址空间中,常量区、全局数据区和栈区可以用来存放变量的值。C语言共有 4 个关键字用来指明变量的存储类别:auto(自动的)、static(静态的)、register(寄存器的)、extern(外部的)。一个变量只能声明为一种存储类别

static 变量

static 声明的变量称为静态变量,不管它是全局的还是局部的,都存储在静态数据区(全局变量本来就存储在静态数据区,即使不加 static),静态数据区的变量只能初始化(定义)一次,以后只能改变它的值。静态局部变量虽然存储在静态数据区,但是它的作用域仅限于定义它的代码块。

register 变量

寄存器的长度一般和机器的字长一致,只有较短的类型如 int、char、short 等才适合定义为寄存器变量,诸如 double 等较大的类型,不推荐将其定义为寄存器类型。

十三、多文件编写

extern

所谓声明(Declaration),就是告诉编译器我要使用这个变量或函数,现在没有找到它的定义不要紧,不要报错,稍后把定义补上。

编译器很容易区分函数的定义和声明(有无函数体),所以对于函数声明来说,有没有 extern 都是一样的。对于变量,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义。

编译

从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)
[C语言重点知识学习笔记]_第22张图片

十四、标准库

频繁的调用assert会影响程序的性能,增加额外的开销。在调试结束后,可以通过在包含 #include 的语句之前插入 #define NDEBUG 来禁用 assert 调用:

#define NDEBUG/*禁用调试模式*/ 
#include 

用法总结与注意事项:

  1. 不能使用改变环境的语句,因为assert只在DEBUG模式生效,如果这么做,会使程序在真正运行时遇到问题;如:assert(i++ < 100)
  2. 使用断言捕捉不应该发生的非法情况。
  3. 使用断言对函数的参数进行确认。

接受 int 作为参数,它的值必须是 EOF 或表示为一个无符号字符判断类型,满足描述的条件,返回非零,不满足返回零。

1	int isalnum(int c)
该函数检查所传的字符是否是字母和数字。
2	int isalpha(int c)
该函数检查所传的字符是否是字母。
3	int iscntrl(int c)
该函数检查所传的字符是否是控制字符。
4	int isdigit(int c)
该函数检查所传的字符是否是十进制数字。
5	int isgraph(int c)
该函数检查所传的字符是否有图形表示法。
6	int islower(int c)
该函数检查所传的字符是否是小写字母。
7	int isprint(int c)
该函数检查所传的字符是否是可打印的。
8	int ispunct(int c)
该函数检查所传的字符是否是标点符号字符。
9	int isspace(int c)
该函数检查所传的字符是否是空白字符。
10	int isupper(int c)
该函数检查所传的字符是否是大写字母。
11	int isxdigit(int c)
该函数检查所传的字符是否是十六进制数字

转换函数

1	int tolower(int c)
该函数把大写字母转换为小写字母。
2	int toupper(int c)
该函数把小写字母转换为大写字母。

字符类型

1	数字
完整的数字集合 {
      0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }
2	十六进制数字
集合 {
      0 1 2 3 4 5 6 7 8 9 A B C D E F a b c d e f }
3	小写字母
集合 {
      a b c d e f g h i j k l m n o p q r s t u v w x y z }
4	大写字母
集合 {
     A B C D E F G H I J K L M N O P Q R S T U V W X Y Z }
5	字母
小写字母和大写字母的集合
6	字母数字字符
数字、小写字母和大写字母的集合
7	标点符号字符
集合 ! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` {
      | } ~
8	图形字符
字母数字字符和标点符号字符的集合
9	空格字符
制表符、换行符、垂直制表符、换页符、回车符、空格符的集合。
10	可打印字符
字母数字字符、标点符号字符和空格字符的集合。
11	控制字符
在 ASCII 编码中,这些字符的八进制代码是从 000037,以及 177(DEL)。
12	空白字符
包括空格符和制表符。
13	字母字符
小写字母和大写字母的集合。

你可能感兴趣的:(c语言)