• 注册 / 登录
  • 切换到窄版
  • 查看: 1481|回复: 0

    浅析C语言内存管理

    [复制链接]

    665

    主题

    679

    帖子

    6476

    积分

    版主

    Rank: 7Rank: 7Rank: 7

    积分
    6476
    发表于 2023-8-7 13:46:39 | 显示全部楼层 |阅读模式

    路线栈欢迎您!

    您需要 登录 才可以下载或查看,没有帐号?立即注册

    x
    一、简述

    C语言是一种强大的编程语言,但是它需要程序员手动管理内存,这也是其相对其他高级语言的一个挑战。在C语言中,程序员必须自己分配和释放内存,因此深入理解C语言的内存管理对于编写高效且安全的程序至关重要。

    以下是一些关键概念和技术,有助于理解C语言内存管理:

    1.栈:存放程序的局部变量和函数调用的参数和返回地址。栈是一个向下增长的数据类型,具有后进先出(LIFO)的特性。栈上的内存由编译器自动分配和释放,不需要程序员干预,栈上的内存空间通常有限,因此不适合存放大量或复制的数据。

    2.堆:存放程序动态分配的内存,由程序员控制其生命周期。堆是一个向上增长的数据结构,可以根据需要扩展或收缩。使用new和delete操作符分配和释放堆上的内存时,需要注意内存泄漏或野指针等问题。delete之后要将地址设置为nullptr。

    3.数据区:存放程序的全局变量和静态变量,分为初始化和未初始化两部分。初始化部分包含了程序赋予初始化的变量,未初始化部分包含了程序没有赋予初始化的变量。

    4.代码区:存放程序的可执行指令,通常是只读的,可以被多个进程共享。

    • 堆空间大小可以扩展或收缩,栈空间大小有限。
    • 堆空间的访问速度比栈空间慢,堆空间也容易产生内存碎片和内存泄漏。
    • 堆空间的地址增长是向上的,沿着内存地址增加的方向,而栈空间是向下的,也就是沿着内存地址减小的方向增长。

    0.png
    C语言内存管理

    二、内存分区

    1.代码区

    作用:存放程序的编译后的可执行二进制代码,CPU执行的机器指令,并且是只读的。
    特点:
    • 只读
    • 共享(每次打开exe文件,都会指向一个地址空间)

    2.文字常量区

    作用:存放数值常量、字符常量、字符串常量、符号常量,只读的,程序结束后由系统释放。

    字符串常量是可以共享的。
    1. void test01()
    2. {
    3.         char * p1= "hello world";
    4.         char * p2 = "hello world";
    5.         char * p3 = "hello world";
    6.         printf("%d\n",&"hello world");
    7.         printf("%d\n", p1);
    8.         printf("%d\n", p2);
    9.         printf("%d\n", p3);
    10. }
    复制代码

    这四个打印出来的地址可以是一样的。

    注意:
    char   *p  =  "hello world";   //字符串常量
    char   p[]  = "hello world";   //字符串变量,可以进行修改,是把常量区拷贝到栈区了
    ①ANSI C中规定:修改字符串常量,结果是未定义的
    ②有些编译器把多个相同的字符串常量看成一个(节省空间),有些则不进行此优化
    ③有些编译器可修改字符串常量,有些编译器则不可修改字符串常量
    ④一般的,尽量不要去修改字符串常量。

    3.栈区

    作用: 由编译器自动分配释放,存放函数的参数值,局部变量的值 。
    特点:
    ①栈是一种先进后出的内存结构,由编译器自动分配释放数据。
    ②主要存放函数的形式参数值、局部变量等。
    ③函数运行结束,相应栈变量会被自动释放
    ④栈空间较小,不适合将大量数据存放在栈中

    注意:不要返回局部变量的地址

    //栈区上开辟的数据由系统进行管理,不需要程序员管理开辟和释放
    1. //栈区上开辟的数据由系统进行管理,不需要程序员管理开辟和释放
    2. int * func()
    3. {
    4.         int a = 10;
    5.         return &a;
    6. }
    7. //不管结果是否正确,这个值已经被释放了,不可以操作一块非法的内存空间
    8. void test01()
    9. {
    10.         int * p = func();

    11.         printf("a = %d\n", *p);
    12.         printf("a = %d\n", *p);

    13. }
    复制代码

    在调用func后,局部变量a已经被释放了,a的地址被销毁,在对指针p进行访问,属于非法访问内存。

    1. char * getString()
    2. {
    3.         char str[] = "hello world";
    4.         return str;
    5. }

    6. void test02()
    7. {
    8.         char * p = NULL;
    9.         p = getString();

    10.         printf("p = %s\n", p);
    11. }
    复制代码

    这里也是一样的,str指向字符串的首地址,调用函数后,地址被销毁,访问p属于非法访问内存。

    4.堆区

    由编程人员手动申请,手动释放,若不手动释放,程序结束后由系统回收,生命周期是整个程序运行期间.使用malloc进行堆的申请,堆的总大小为机器的虚拟内存的大小。
    特点:
    ①堆区由开发人员手动申请和释放,在释放之前,该块堆空间可一直使用。
    ②由程序员分配和释放,若程序员不释放,程序结束时由系统回收内存。
    ③堆空间一般没有软限制,只受限于硬件。会比栈空间更大,适宜存放较大数据。

    申请/释放堆区空间

    1. int * p = malloc(sizeof(int)* 5); //4 byte  * 5
    2. free(p);
    复制代码

    注意:主调函数中没有给指针分配内存,被调函数需要利用高级指针进行分配。

    1. void allocateSpace(char * pp)
    2. {
    3.         char * temp = malloc(100);
    4.         memset(temp, 0, 100);
    5.         strcpy(temp, "hello world");
    6.         pp = temp;
    7. }

    8. void test02()
    9. {
    10.         char * p = NULL;
    11.         allocateSpace(p);
    12.         printf("%s\n", p);
    13. }
    复制代码

    内存管理

    1.png

    函数中局部变量存放在栈中,malloc分配的空间存放在堆中,temp指针指向这点内存空间。主调函数test02中指针没有分配内存,被调函数allocateSpace中用同级指针接收是修饰不了实参p,所以这里p还是为null。 这个和传入变量,是否修改实参的值是一个意思。

    1. //1.利用高级指针
    2. void allocateSpac2(char ** pp)
    3. {
    4.         char * temp = malloc(100);
    5.         memset(temp, 0, 100);
    6.         strcpy(temp, "hello world");
    7.         *pp = temp;
    8.         printf("aaa%s\n", *pp);
    9. }

    10. void test03()
    11. {
    12.         char * p = NULL;
    13.         allocateSpac2(&p);
    14.         printf("%s\n", p);
    15. }

    16. //2.利用返回值
    17. char* allocateSpace3()
    18. {
    19.         char *temp = malloc(100);
    20.         memset(temp, 0, 100);

    21.         strcpy(temp, "hello world");
    22.         return temp;
    23. }
    24. void test04()
    25. {
    26.         char *p = NULL;
    27.         p = allocateSpace3();
    28.         printf("%s\n", p);
    29. }
    复制代码

    主调函数中没有分配内存的空指针,被调函数中要利用高级指针分配内存。

    内存申请可使用三个函数来完成:malloc、calloc、realloc,内存释放只需要使用 free 函数。

    ①malloc 函数
    原型: void *malloc(unsigned int num_bytes)
    用法:分配长度为 num_bytes 字节的内存块。
    说明:如果分配成功则返回指向被分配内存的指针,否则返回 NULL

    ②calloc 函数
    原型: void *calloc(int num_elems, int elem_size)
    用法:为具有 num_elems 个长度为 elem_size 元素的数组分配内存。
    说明:如果分配成功则返回指向被分配内存的指针,否则返回 NULL

    ③realloc 函数
    原型: void *realloc(void *mem_address, unsigned int newsize)
    作用:改变 mem_address 所指内存区域的大小为 newsize 长度。
    说明:如果重新分配成功则返回指向被分配内存的指针,否则返回 NULL

    ④free 函数
    原型: void free(void *p);
    作用:释放指针 p 所指向的的内存空间。
    说明:p所指向的内存空间必须是用 calloc,malloc,realloc 所分配的内存。如果 p 为 NULL则不做任何操作
    1. //1. calloc
    2. void test01()
    3. {
    4.         int *p =  calloc(10,sizeof(int));  //10 * 4 字节
    5.         // int *p = malloc(sizeof(int) * 10);
    6.         for (int i = 0; i < 10; ++i)
    7.         {
    8.                 p[i] = i + 1;
    9.         }
    10.         for (int i = 0; i < 10; ++i)
    11.         {
    12.                 printf("%d\n", p[i]);
    13.         }
    14.         if (p != NULL)
    15.         {
    16.                 free(p);
    17.                 p = NULL;
    18.         }
    19. }


    20. //2.realloc
    21. void test02()
    22. {
    23.         int *p = malloc(sizeof(int)* 10);
    24.         for (int i = 0; i < 10; ++i)
    25.         {
    26.                 p[i] = i + 1;
    27.         }
    28.         for (int i = 0; i < 10; ++i)
    29.         {
    30.                 printf("%d ", p[i]);
    31.         }
    32.         printf("%d\n", p);
    33.    
    34.         p = realloc(p, sizeof(int)* 200);  //重新分配内存大小
    35.         printf("%d\n",p);

    36.         for (int i = 0; i < 15; ++i)
    37.         {
    38.                 printf("%d ", p[i]);
    39.         }
    40. }
    复制代码

    利用realloc重新分配内存,如果比原有的内存空间大,若后面的空闲足够大,直接在后面继续扩展空间,指针指向的内存空间首地址不变。

    如果后面的空闲空间不够大,系统会重新找一个足够大的内存空间将原来的空间下的内存拷贝到新空间,将新空间的首地址交付给用户。

    5.全局/静态区

    全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域,程序结束后由系统释放 。

    特点:
    ①全局/静态区存储全局变量、静态变量、常量,该区变量在程序运行期间一直存在
    ②程序结束由系统回收。
    ③已初始化的数据放在data段,未初始化的数据放到bss段
    ④该区变量当未初始化时,会有有默认值初始化。

    5.1全局变量

    在其他文件调用全局变量

    1. //其他文件中声明第二行代码
    2. extern int g_a = 10; //c语言中 默认全局变量前 加了关键字 extern

    3. void test01()
    4. {
    5.         extern int g_a;  //说明引用全局变量,在其他文件中去寻找
    6.         printf("g_a = %d\n", g_a);
    7. }
    复制代码

    c语言, 默认全局变量前加了关键字 extern。若在其他文件中 extern 变量,可以调用该变量。全局变量未初始化时,默认初始化为0。

    全局变量特点:

    ① 作用域:全局可见。

    全局变量(外部变量)是在函数外部定义的,它的作用域为从变量的定义处开始,到本程序文件的末尾。
    注:通常把超出一个函数的作用域称为全局作用域,其他几种(如块作用域)不超出一个函数的作用域称为局部作用域。

    ② 存储空间:静态存储区

    系统会在编译时将全局变量分配在静态存储区,在程序执行期间,对应的存储空间不会释放,一直到程序结束才会释放。
    注:一个程序在内存中占用的存储空间可以分为3个部分:程序区(存放可执行程序的代码)、静态存储区(存放静态变量)、动态存储区(存放动态变量)。

    ③优先度:全局变量优先度低于局部变量。当全局变量和局部变量重名时,会屏蔽全局变量,局部优先。

    优点 :使用全局变量程序运行时速度会快一点,因为内存不需要再分配。
    缺点 :使用全局变量会占用更多的内存,因为其生命期长。

    全局变量作用域的扩展和限制

    ① 扩展:使用extern关键字可以对全局变量的作用域进行扩展

            全局变量的作用域为从变量的定义处开始,到本程序文件的末尾。若想在本文件全局变量定义之前引用该全局变量,可以在引用之前用extern关键字对该变量进行说明,有了此说明,就可以从说明之处起,合法地引用该变量。
            若想在一个文件(设为a.cpp)中引用另一个文件(设为b.cpp)中已定义的全局变量,可以在a.cpp中extern关键字对该全局变量进行说明,在编译和连接时,系统就会知道该全局变量已经在其他文件(b.cpp)中定义过了。
           注:在编译时遇到extern,系统会现在本文件中查找全局变量的定义,如果找到,就在本文件中扩展作用域;如果找不到,就在连接时在其他文件中查找全局变量的定义,如果找到,就将作用域扩展到本文件;如果还找不到,按出错处理。


    ② 限制:使用static关键字可以限制全局变量的作用域
           全局变量默认是有外部链接性的,作用域是整个工程,在一个文件内定义的全局变量,在另一个文件中,通过extern对全局变量进行声明,就可以使用全局变量。
           如果希望全局变量仅限本文件引用,而不能被其他文件引用,可以在定义全局变量时在前面加一个static关键字。


    注:即 static(限制) 和 extern(扩展) 不可同时出现

    5.2静态变量

    静态局部变量具有局部作用域。它只被初始化一次,生命周期是全局,静态局部变量只对定义自己的函数体始终可见。

    静态全局变量也具有全局作用域,作用于定义它的文件里,不能作用到其他文件里。

    1. void func()
    2. {
    3.         static int s_a = 10; //静态变量只初始化一次
    4.         s_a++;
    5.         printf("%d\n", s_a);
    6. }

    7. void test03()
    8. {
    9.         func(); //11
    10.         func(); //12
    11.         func(); //13
    12. }
    复制代码

    静态变量只会初始化一次。静态变量的存放地址,在整个程序运行期间,都是固定不变的。非静态变量(一定是局部变量)地址每次函数调用时都可能不同,只在函数的一次执行期间不变。如果静态局部未初始化,默认为0。

    全局变量和全局静态变量的区别:

    不管全局变量加不加static,全局变量都是存储在静态存储区的,都是在编译时分配存储空间的,两者只是作用域不同,全局变量默认具有外部链接性,作用域是整个工程,全局静态变量的作用域仅限本文件,不能在其他文件中引用。

    const修饰的变量

    1. //全局常量
    2. const int a = 10; //全局常量存放到常量区,收到常量区的保护

    3. void test01()
    4. {
    5.     //a = 20; //直接修改失败
    6.         int * p = &a;
    7.         *p = 30;  //间接修改 语法通过,运行失败
    8.         printf("a = %d ", a);


    9.         //局部常量
    10.         const int b = 10; //b分配到了栈上,可以通过间接方式对其进行修改
    11.         //b = 30; //直接修改失败
    12.         int * p2 = &b;
    13.         *p2 = 30;
    14.         printf("b = %d\n", b); //间接修改成功,C语言下const修饰的局部常量为伪常量
    15. }
    复制代码

    const修饰全局常量存放到常量区,收到常量区的保护,不可以修改。const修饰局部变量,变量会被分配到了栈上,可以通过间接方式对其进行修。

    小结:
    ①堆区(heap) :允许程序在运行时动态地申请某个大小的内存空间, 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。
    ②栈区(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。
    ③全局区(静态区):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域,程序结束后由系统释放。
    ④文字常量区:常量字符串就是放在这里的,只读的。程序结束后由系统释放。
    ⑤程序代码区:存放程序的编译后的可执行二进制代码,CPU执行的机器指令,并且是只读的。

    2.png

    三、内存分区运行前后的区别

    1.运行前

    在没有运行程序前(程序没有加载到内存前),分别为代码区(text)、数据区(data)和未初始化数据区(bss)3 个部分(把 data 和 bss 合起来叫做静态区或全局区)。

    ①代码区
    存放 CPU 执行的机器指令。通常代码区是可“共享”的,即另外的执行程序可以调用它,使其可共享的目的是对于频繁被执行的程序,只需要在内存中有一份代码即可(节约内存)。
    代码区是只读的,使其只读的原因是防止程序意外的修改了它的指令。另外,代码区还规划了局部变量的相关信息。
    ②全局初始化数据区/静态数据区(data段)
    该区包含了在程序中(1)被初始化的全局变量(2)已经初始化的静态变量,包括全局静态变量(3)常量数据(如字符串常量)。
    ③未初始化数据区(bss 区)
    存入的是全局未初始化静态变量。未初始化数据区的数据在程序开始执行之前被内核初始化为 0 或者空(NULL)。
    程序源代码被编译之后主要分成两种段:程序指令(代码区)和程序数据(数据区)。代码段属于程序指令,而数据域段和 bss 段属于程序数据。

    程序的指令和程序数据分开原因:

    程序被加载到内存中之后,可以将数据和代码分别映射到两个内存区域。由于数据区域对进程来说是可读可写的,而指令区域对程序来讲是只读的,所以分区之后呢,可以将程序指令区域和数据区域分别设置成只读或可读可写。这样可以防止程序的指令有意或者无意被修改。

    当系统中运行着多个同样的程序的时候,这些程序执行的指令都是一样的,所以只需要内存中保存一份程序的指令就可以了,只是每一个程序运行中数据不一样而已,这样可以节省大量的内存。

    2.运行后

    程序在加载到内存前,代码区和全局区(data+ bss)的大小就是固定的,程序运行期间不能改变。运行可执行程序,操作系统把物理硬盘程序加载到内存,除了根据可执行程序的信息分出代码区(text)、数据区(data)和未初始化数据区(bss)之外,还额外增加了栈区、堆区。

    ①代码区(text segment)
    加载的是可执行文件代码段,所有的可执行代码都加载到代码区,这块内存是不可以在运行期间修改的。
    ②未初始化数据区(BSS)
    加载的是可执行文件 BSS 段,位置可以分开也可以紧靠数据段,存储于数据段的数据(全局未初始化,静态未初始化数据)的生存周期是整个程序运行过程。
    ③全局初始化数据区/静态数据区(data segment)
    加载的是可执行文件数据段,存储于数据段(全局初始化,静态初始化数据,文字常量(只读))的数据的生存周期是整个程序运行过程
    ④栈区(stack)
    栈是由编译器自动分配释放,存放函数的参数值、返回值、局部变量等。在程序运行过程中实时加载和释放,局部变量的生存周期为申请到释放该段栈空间。
    ⑤堆区(heap)
    堆是一个大容器,它的容量要远远大于栈,但没有栈那样先进后出的顺序。用于动态内存分配。堆在内存中位于 BSS 区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时可能会由操作系统回收。

    关键字修饰

    类型
    作用
    生命周期
    存储位置
    auto变量
    一对{}内
    当前函数
    栈区
    static局部变量
    一对{}内
    整个程序运行期
    初始化在data段,未初始化在BSS段
    extern变量
    整个程序
    整个程序运行期
    初始化在data段,未初始化在BSS段
    static全局变量
    当前文件
    整个程序运行期
    初始化在data段,未初始化在BSS段
    extern函数
    整个程序
    整个程序运行期
    代码区
    static函数
    当前文件
    整个程序运行期
    代码区
    register变量
    一对{}内
    当前函数
    运行时存储在CPU寄存器
    字符串常量
    当前文件
    整个程序运行期
    data段

    内存分配图:

    3.png

    四、函数调用中内存分配

    1.宏函数

    宏函数:

    ①宏函数和宏常量都是利用#define定义出来的内容
    ②在项目中,经常把一些短小而又频繁使用的函数写成宏函数
    ③这是由于宏函数没有普通函数参数压栈、跳转、返回等时间上的开销,可以调高程序的效率

    注意:宏函数通常需要加括号,保证运算的完整

    宏函数将频繁短小的函数可以封装为宏函数,以空间换时间。和内联函数的作用相同。

    1. #define MYADD(x,y)  ((x) + (y)) //不是函数 ,宏函数

    2. //普通函数下的a、b都要进行入栈,函数执行后出栈
    3. int  myAdd(int a ,int b)
    4. {
    5.         return a + b;
    6. }
    7. //宏函数  在一定的场景下  要比普通的函数效率高,把频繁使用并且短小的函数 可以写成宏函数
    8. //宏函数在编译阶段就替换源码
    9. //而没有普通函数入栈出栈的开销,以空间换时间
    10. void test01()
    11. {
    12.         int a = 10;
    13.         int b = 20;
    14.         printf("a + b = %d\n", MYADD(a, b)); //  ((a) + (b))
    15. }
    复制代码

    注意:这里只是宏展开替换,是整体替换。
    #define MYADD(x,y)  x+y
    MYADD(a,b)*20 :宏展开----> a+b*20

    2.函数调用流程

    如下,在mian函数中调用func函数

    1. int func(int a,int b){
    2.         int t_a = a;
    3.         int t_b = b;
    4.         return t_a + t_b;
    5. }

    6. int main(){
    7.         int ret = 0;
    8.         ret = func(10, 20);
    9.         return EXIT_SUCCESS;
    10. }
    复制代码

    函数调用会进行入栈和出栈,那么被调函数func中的a、b入栈的顺序是从左到右,还是从右到左?当被调函数执行完毕后,a、b这两个参数是由主调函数mian去管理释放还是被调函数func管理释放?

    调用惯例

    函数的调用方(主调函数)和被调用方()对于函数是如何调用的必须有一个明确的约定,只有双方都遵循同样的约定,函数才能够被正确的调用,这样的约定被称为调用惯例。

    C/C++语言中存在多个调用惯例,默认使用的调用惯例为 cdecl。

    调用惯例出栈方参数传递名字修饰
    cdecl函数调用方从右至左参数入栈下划线+函数名
    stdcall函数本身从右至左参数入栈下划线+函数名+@+参数字节数
    fastcall函数本身前两个参数由寄存器传递,后面从右到左@+函数名+@+参数的字节数
    pascal函数本身从左至右参数入栈较为复杂,参见相关文档

    注: cdecl不是标准的关键字,在不同的编译器里可能有不同的写法,例如gcc里就不存在_cdecl这样的关键字。

    所以,上面的两个问题就解决了。

    3.栈的生长方向和内存存储方式

    栈的生长方向:自上而下 ,栈底高地址 ,栈顶低地址。

    5.png

    验证方式

    1. void test01()
    2. {
    3.         int a = 10;
    4.         int b = 20;
    5.         int c = 30;
    6.         int d = 40;

    7.         printf("%d\n", &a);
    8.         printf("%d\n", &b);
    9.         printf("%d\n", &c);
    10.         printf("%d\n", &d);
    11. }
    复制代码

    6.png

    内存中的多字节数据相对于内存地址有大端和小端之分。

    小端模式:高位字节数据保存在内存的高地址中,低位字节数据保存在内存的低地址中
    大端模式:高位字节数据保存在内存的低地址中,低位字节数据保存在内存的高地址中

    7.png

    验证方式:

    1. int a = 0x11223344;
    2.         char * p = &a; //char * 改变指针步长,一次跳一个字节

    3.         printf("%x\n", *p);      // 44  低地址  -- 低位字节
    4.         printf("%x\n", *(p+1));
    5.         printf("%x\n", *(p+2));
    6.         printf("%x\n", *(p+3));  // 11  高地址  --  高位字节
    复制代码

    注意:无论是小端模式还是大端模式。每个字节内部都是按顺序排列。

    五、结语

    了解C语言的内存管理是编写高效且安全的程序的关键。程序员必须了解堆和栈、动态内存分配、指针、内存安全等概念,并学会正确地分配和释放内存,以避免内存泄漏和其他内存问题。

    常用的X86结构是小端模式,而KEIL C51则为大端模式。很多的ARM,DSP都为小端模式。

    回复

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    小黑屋|路丝栈 ( 粤ICP备2021053448号 )

    GMT+8, 2024-9-17 03:40 , Processed in 0.050577 second(s), 22 queries .

    Powered by Discuz! X3.4

    Copyright © 2001-2021, Tencent Cloud.

    快速回复 返回顶部 返回列表