C语言高级

文件操作

参考链接

  • 文件流: 数据在文件和内存之间传递的过程
    • 输入流: 数据从文件复制到内存的过程
    • 输出流: 从内存保存到文件的过程
  • 数据流: 数据在数据源和程序(内存)之间传递

内存

程序是保存在硬盘中的,要载入内存才能运行,CPU也被设计为只能从内存中读取数据和指令。
CPU访问的是虚拟地址, OS负责把虚拟地址映射为真正的物理地址(内存条上的地址)
好处:
 1. 使不同程序的地址空间相互隔离
 2. 提高内存使用效率(控制内存权限等等)


CPU的数据处理能力
通常所说的多少位的CPU,除了可以理解为寄存器的位数,也可以理解数据总线的宽度,通常情况下它们是相等的。
数据总线决定了CPU单次的数据处理能力,主频决定了CPU单位时间内的数据处理次数,它们的乘积就是CPU单位时间内的数据处理量。
地址总线用于在内存上定位数据


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


内存映射

  1. 首先把常用的代码和数据加载到内存中
  2. 当进程需要内存外的数据时, 硬件会捕获这个消息, 就是所谓的页错误, 然后OS接管进程, 负责将硬盘中的页读取出来并放入内存中, 然后将其建立映射关系.
    在这里插入图片描述
    分页机制

假设虚拟地址空间大小为 4G, 虚拟地址长度为 32位 总共包含 232 / 212 = 220 = 1K * 1K = 1M 个页面

  • 一级页表
    页表(Page Table):
    包含 220 = 1M 个元素, 元素大小为 4B , 总大小为 4M . 其中高 20 位表示页面编号, 用于确定物理地址. 低 12 位 表示控制信息.

    虚拟地址:
    将低12位作为页内偏移, 高20为作为页表数组下标, 如图:
    在这里插入图片描述
    映射关系如下:
    在这里插入图片描述

  • 两级页表
    将一级页表拆成 1024 个小页表, 每个小页表包含 1024 个元素, 占用 210 * 4B = 4Kb 大小. 小页表可存储在不同的物理页, 它们之间可以是不连续的.
    页目录(Page Directory):
    一个额外的数组, 有 1024 个元素, 记录小页表位置, 每个元素代表一个页表;
    虚拟地址:
    高10位作为页目录中元素的下标,中间10位作为页表中元素的下标,最后12位作为页内偏在这里插入图片描述

    例如一个虚拟地址 0011000101  1010001100  111100001010,它的高10位为 0011000101,对应页目录中的第 0011000101 个元素,假设该元素的高20位为 0XF012A,也即对应的页表在物理内存中的编号为 0XF012A,这样就找到了页表。虚拟地址中间10位为 1010001100,它对应页表中的第 1010001100 个元素,假设该元素的高20位为 0X00D20,也即物理页的索引为 0X00D20。通过计算,最终的物理地址为 0X00D20 * 2^12 + 111100001010 = 0X00D20F0A。
    映射关系如下:
    在这里插入图片描述
    优点: 大大节省内存空间, 需要多少内存空间就记录多少个小页面.

  • 多级页表
    思路和二级一样!

  • NMU(Memory Management Unit, 内存管理单元
    在实际开发中, 由NMU和OS共同完成内存映射操作, OS负责管理和更新程序的页目录及页表, NMU负责根据页表进行内存映射. 每个程序在运行时都有自己的一套页表,切换程序时,只要改变 CR3寄存器 的值就能够切换到对应的页表。

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

对内存权限的控制

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

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

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

Linux下C语言程序的内存布局(内存模型)

32位环境的用户空间内存分布情况
在这里插入图片描述
对各个分区的说明:

内存分区 说明
程序代码区
(code)
存放函数体的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。
常量区
(constant)
存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程序运行期间不能改变。
全局数据区
(global data)
存放全局变量、静态变量等。这块内存有读写权限,因此它们的值在程序运行期间可以任意改变。
堆区
(heap)
一般由程序员分配和释放,若程序员不释放,程序运行结束时由操作系统回收。malloc()、calloc()、free() 等函数操作的就是这块内存。

注意:这里所说的堆区与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。
动态链接库 用于在程序运行期间加载和卸载动态链接库。
栈区
(stack)
存放函数的参数值、局部变量的值等,其操作方式类似于数据结构中的栈。

程序代码区常量区全局数据区在程序加载到内存后就分配好了,并且在程序运行期间一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。
函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入中,函数执行结束后,这些信息都将被销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。
常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。程序员唯一能控制的内存区域就是堆(Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。

常量区和全局数据区有时也被合称为静态数据区,意思是这段内存专门用来保存数据,在程序运行期间一直存在。

用户模型和内核模式

进程: 简单来说,一个可执行程序就是一个进程,前面我们使用C语言编译生成的程序,运行后就是一个进程。进程最显著的特点就是拥有独立的地址空间。

程序与进程的区别:
严格来说,程序是存储在磁盘上的一个文件,是指令和数据的集合,是一个静态的概念;进程是程序加载到内存运行后一些列的活动,是一个动态的概念。

内核模式(Kernel Mode): 当运行在用户模式的应用程序需要输入输出、申请内存等比较底层的操作时,就必须调用操作系统提供的 API 函数,用户程序调用系统 API 函数称为系统调用(System Call);发生系统调用时会暂停用户程序,转而执行内核代码(内核也是程序),访问内核空间,这称为内核模式。

用户模式(User Mode): 用户空间保存的是应用程序的代码和数据,是程序私有的,其他程序一般无法访问。

栈(Stack)

栈(Stack)可以存放函数参数、局部变量、局部数组等作用范围在函数内部的数据,它的用途就是完成函数的调用。
从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。在现代计算机中,通常使用 ebp 寄存器指向栈底,而使用 esp 寄存器指向栈顶。随着数据的进栈出栈,esp 的值会不断变化,进栈(push)时 esp 的值减小,出栈(pop)时 esp 的值增大。
在这里插入图片描述

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

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

  • 栈帧(stack frame)
    包含如下:

    1. 函数的返回地址, 即原函数下一条指令地址.
    2. 参数和局部变量, 有些参数通过寄存器传递.
    3. 编译器自动生成的临时数据。例如,当函数返回值的长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。
    4. 被调用者保存的寄存器

实例(不同编译器在不同编译模式下说产生的函数栈不完全相同):
在
当发生函数调用时:

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

数据定位
由于 esp 的值会随着数据的入栈而不断变化,要想根据 esp 找到参数、局部变量等数据是比较困难的,所以在实现上是根据 ebp 来定位栈内数据的。ebp 的值是固定的,数据相对 ebp 的偏移也是固定的,ebp 的值加上偏移量就是数据的地址。

实例分析:
在这里插入图片描述

函数进栈

步骤①到⑥是函数进栈过程:

  1. main() 是主函数,也需要进栈,如步骤①所示。
  2. 在步骤②中,执行语句func(90, 26);,先将实参 90、26 压入栈中,再将返回地址压入栈中,这些工作都由 main() 函数(调用方)完成(这一部分的栈帧属于调用者)。这个时候 ebp 的值并没有变,仅仅是改变 esp 的指向。
  3. 到了步骤③,就开始执行 func() 的函数体了。首先将原来 ebp 寄存器的值压入栈中(也即图中的 old ebp),并将 esp 的值赋给 ebp,这样 ebp 就从 main() 函数的栈底指向了 func() 函数的栈底,完成了函数栈的切换。由于此时 esp 和ebp 的值相等,所以它们也就指向了同一个位置。
  4. 为局部变量、返回值等预留足够的内存,如步骤④所示。由于栈内存在函数调用之前就已经分配好了,所以这里并不是真的分配内存,而是将 esp 的值减去一个整数,例如 esp - 0XC0,就是预留 0XC0 字节的内存。
  5. 将 ebp、esi、edi 寄存器的值依次压入栈中。
  6. 将局部变量的值放入预留好的内存中。注意,第一个变量和 old ebp 之间有4个字节的空白,变量之间也有若干字节的空白。

为什么要留出这么多的空白,岂不是浪费内存吗?
这是因为我们使用Debug模式生成程序,留出多余的内存,方便加入调试信息;以Release模式生成程序时,内存将会变得更加紧凑,空白也被消除。

至此,func() 函数的活动记录就构造完成了。可以发现,在函数的实际调用过程中,形参是不存在的,不会占用内存空间,内存中只有实参,而且是在执行函数体代码之前、由调用方压入栈中的。

函数出栈
步骤⑦到⑨是函数 func() 出栈过程:

  1. 函数 func() 执行完成后开始出栈,首先将 edi、esi、ebx 寄存器的值出栈。

  2. 将局部变量、返回值等数据出栈时,直接将 ebp 的值赋给 esp,这样 ebp 和 esp 就指向了同一个位置。

  3. 接下来将 old ebp 出栈,并赋值给现在的 ebp,此时 ebp 就指向了 func() 调用之前的位置,即 main() 活动记录的 old ebp 位置,如步骤⑨所示。

这一步很关键,保证了还原到函数调用之前的情况,这也是每次调用函数时都必须将 old ebp 压入栈中的原因。

最后根据返回地址找到下一条指令的位置,并将返回地址和实参都出栈,此时 esp 就指向了 main() 活动记录的栈顶, 这意味着 func() 完全出栈了,栈被还原到了 func() 被调用之前的情况。

遗留的错误认知
局部变量并没有被销毁, 有的只是覆盖和指向之前的数据.


栈溢出(覆盖)

#include <stdio.h>
int main(){
    char str[10] = {0};
    gets(str);
    printf("str: %s\n", str);
    return 0;
}

但输入字符串过大时, 会发生数组溢出,占用“4字节空白内存”、“old ebp”和“返回地址”所在的内存,并将原有的数据覆盖掉,这样当 main() 函数执行完成后,会取得一个错误的返回地址,该地址上的指令是不确定的,或者根本就没有指令,所以程序在返回时出错。
在这里插入图片描述
C语言不会对数组溢出做检测,这是一个典型的由于数组溢出导致覆盖了函数返回地址的例子,我们将这样的错误称为“栈溢出错误”

堆(Heap)

栈和堆的区别
栈区和堆区的管理模式有所不同:栈区内存由系统分配和释放,不受程序员控制;堆区内存完全由程序员掌控,想分配多少就分配多少,想什么时候释放就什么时候释放,非常灵活。
程序启动时会为栈区分配一块大小适当的内存,对于一般的函数调用这已经足够了,函数进栈出栈只是 ebp、esp 寄存器指向的变换,或者是向已有的内存中写入数据,不涉及内存的分配和释放。当函数中有较大的局部数组时,比如 1024*10 个元素,编译器就会在函数代码中插入针对栈的动态内存分配函数,这样函数被调用时才分配内存,不调用就不分配。

动态内存分配函数
堆(Heap)是唯一由程序员控制的内存区域,我们常说的动态内存分配也是在这个区域。在堆上分配和释放内存需要用到C语言标准库中的几个函数:malloc()calloc()realloc()free()

  1. malloc()
    原型:void* malloc (size_t size);
    作用:在堆区分配 size 字节的内存空间。
    返回值:成功返回分配的内存地址,失败则返回NULL。

    注意:分配内存在动态存储区(堆区),手动分配,手动释放,申请时空间可能有也可能没有,需要自行判断,由于返回的是void*,建议手动强制类型转换。

  2. calloc()
    原型:void* calloc(size_t n, size_t size);
    功能:在堆区分配 n*size 字节的连续空间。
    返回值:成功返回分配的内存地址,失败则返回NULL。

    注意:calloc() 函数是对 malloc() 函数的简单封装,参数不同,使用时务必小心,第一参数是第二参数的单元个数,第二参数是单位的字节数。

  3. realloc()
    原型:void* realloc(void *ptr, size_t size);
    功能:对 ptr 指向的内存重新分配 size 大小的空间,size 可比原来的大或者小,还可以不变(如果你无聊的话)。
    返回值:成功返回更改后的内存地址,失败则返回NULL。

  4. free()
    原型:void free(void* ptr);

    功能:释放由 malloc()、calloc()、realloc() 申请的内存空间。

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

C++是编译型语言,没有内存回收机制,程序员需要自己释放不需要的内存,这在给程序带来了很大灵活性的同时,也带来了不少风险,例如C/C++程序经常会发生内存泄露,程序刚开始运行时占用内存很少,随着时间的推移,内存使用不断增加,导致整个计算机运行缓慢。
内存泄露的问题往往难于调试和发现,或者只有在特定条件下才会复现,这给代码修改带来了不少障碍。为了提高程序的稳定性和健壮性,后来的 Java、Python、C#、JavaScript、PHP 等使用了虚拟机机制的非编译型语言都加入了垃圾内存自动回收机制,这样程序员就不需要管理内存了,系统会自动识别不再使用的内存并把它们释放掉,避免内存泄露。可以说,这些高级语言在底层都实现了自己的内存池,也即有自己的内存管理机制。

池化技术
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。

内存泄露

  • 野指针

如果一个指针指向的内存没有访问权限,或者指向一块已经释放掉的内存,那么就无法对该指针进行操作,这样的指针称为野指针(Wild Pointer)

free()释放的是指针指向的内存!注意!释放的是内存,不是指针!

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

存储类别和生存期

存储类别就是变量在内存中存放的区域, 有常量区, 全局数据区和栈区.

静态数据区(常量区, 全局数据区): 该区的内存在程序启动时就由OS分配好, 占用的空间固定, 程序运行期间不再改变, 运行结束后才由OS释放.
用于存放全局变量, (全局/局部)静态变量, 一般常量字符串.
栈区的内存在程序运行期间由系统根据需要来分配(使用到变量才分配内存;如果定义了变量但没有执行到该代码,也不会分配内存),占用的空间实时改变,使用完毕后立即释放,不必等到程序运行结束;它可以存放局部变量函数参数等。

  • static 变量:
    静态数据区的数据在程序启动时就会初始化,直到程序运行结束;对于代码块中的静态局部变量,即使代码块执行结束,也不会销毁。注意:静态数据区的变量只能初始化(定义)一次,以后只能改变它的值,不能再被初始化,即使有这样的语句,也无效。
    实际开发中,我们通常将不需要被其他模块调用的全局变量或函数用 static 关键字来修饰,static 能够将全局变量和函数的作用域限制在当前文件中,在其他文件中无效
    总结起来,static 变量主要有两个作用:

    1. 隐藏
      程序有多个模块时,将全局变量或函数的作用范围限制在当前模块,对其他模块隐藏。
    2. 保持变量内容的持久化
      将局部变量存储到全局数据区,使它不会随着函数调用结束而被销毁。
  • 全局变量:
    在所有函数之外定义的全局变量(Global Variable),它的作用域默认是整个程序,也就是所有的代码文件,包括 .c.h 文件。
    C语言代码是由上到下依次执行的,不管是变量还是函数,原则上都要先定义再使用,否则就会报错。但在实际开发中,经常会在函数或变量定义之前就使用它们,这个时候就需要提前声明。声明时不分配内存, 只是告诉编译器后续找得到其定义

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

    int var; -> declaration and definition
    extern int var; -> declaration

    module.c 源码

    #include <stdio.h>
    int m = 100;
    void func(){
        printf("Multiple file programming!\n");
    }
    

    main.c 源码

    #include <stdio.h>
    extern void func();
    extern int m;
    int n = 200;
    int main(){
        func();
        printf("m = %d, n = %d\n", m, n);
        return 0;
    }
    

    编译:

    $gcc main.c module.c && ./a.out
    

    结果:

    Multiple file programming!
    m = 100, n = 200
    

头文件编写

从源代码生成可执行文件可以分为四个步骤,分别是预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。下图是 GCC 生成 a.out 的过程:

在这里插入图片描述

  1. 预处理:
    预处理过程主要是处理那些源文件和头文件中以#开头的命令,比如 #include、#define、#ifdef 等。预处理的规则一般如下:

    • 将所有的#define删除,并展开所有的宏定义。
    • 处理所有条件编译命令,比如 #if、#ifdef、#elif、#else、#endif 等。
    • 处理#include命令,将被包含文件的内容插入到该命令所在的位置,这与复制粘贴的效果一样。注意,这个过程是递归进行的,也就是说被包含的文件可能还会包含其他的文件。
    • 删除所有的注释 ///* ... */
    • 添加行号和文件名标识,便于在调试和出错时给出具体的代码位置。
    • 保留所有的#pragma命令,因为编译器需要使用它们。

    gcc -E ...

  2. 编译(Compilation):
    将预处理后的文件进行如下操作, 最复杂的过程:

    • 词法分析
    • 语法分析
    • 语义分析
    • 汇编代码生成(优化)

    生成.s文件
    gcc -S

  3. 汇编(Assembly)
    把汇编代码转换为可以执行的机器指令. 汇编过程相对于编译来说比较简单,没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编语句和机器指令的对照表一一翻译就可以了。
    生成目标文件 GCC下.o, VS下 .obj
    gcc -c

  4. 链接(Link)
    目标文件已经是二进制文件,与可执行文件的组织形式类似,只是有些函数和全局变量的地址还未找到,程序不能执行。链接的作用就是找到这些目标地址,将所有的目标文件组织成一个可以执行的二进制文件。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值