C语言内存布局

动态内存分配

动态内存分配的意义

  • C 语言中的一切操作都是基于内存
  • 变量数组都是内存的别名
    • 内存分配由编译器在编译期间决定
    • 定义数组的时候必须指定数组长度
    • 数组长度是在编译器就必须确定

在程序运行的过程中,可能需要使用一些额外的内存空间,这时候就需要使用到动态内存分配

内存分配

malloc 和 free 用于执行动态内存分配和释放

Cneicunfenpei

  • malloc 所分配的是一块连续的内存
  • malloc 以字节为单位,并且不带任何的类型信息
  • malloc 不会初始化申请的内存空间
  • free 用于将动态内存归还系统
1
2
void* malloc(size_t size);
void free(void* pointer);

注意

  • malloc 和 free 都是库函数,而不是系统调用(不是操作系统提供的函数)
  • malloc 实际分配的内存可能会比请求的多(malloc 是库函数,他的实现需要操作系统的支持,不同的操作系统对于内存的管理不同,以 Linux 为例,空闲的空间总是 4 自己的整数倍,这时我们申请三个字节,就有可能分配给我们的是 4 个字节)
  • 不能依赖于不同平台下 malloc 行为(不同操作系统对于相同大小空间的申请可能存在差异)
  • 当请求的动态内存无法满足时 malloc 返回 NULL
  • 当 free 的参数为 NULL 时,函数直接返回

思考 malloc(0); 将返回什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>

int main() {
// 在内存中动态的开辟一段空间,空间的大小为 0,空间的首地址为 p
int* p = (int*)malloc(0);
// 这段空间的首地址为 0x55f7db0e12a0
printf("p = %p\n", p); // p = 0x55f7db0e12a0

free(p);

return 0;
}

如何我不停的向内存申请 malloc(0); 会造成内存溢出么

会,这与操作系统对于内存的管理机制有关,在某些系统中,可能我们申请的空间大小为 0,但因为空间管理机制而导致我们申请到达空间是非 0 的;

所以这里要注意,即使我们 malloc(0); 最后也必须要 free(p);

一个内存泄漏检测模块的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include "mleak.h"

void f() {
// 申请了内存空间,却没有指针指向它,必然会导致无法释放该空间,也就是内存泄漏
MALLOC(100);
}

int main() {
int* p = (int*)MALLOC(3 * sizeof(int));

f();

p[0] = 1;
p[1] = 2;
p[2] = 3;

free(p);

// 打印内存泄露的信息
// Address: 0x8377018, size:100, Location: test.c: 6
PRINT_LEAK_INF0();
}

calloc 和 realloc

  • void* calloc(size_t num, size_t size);
    • calloc 的参数代表所返回内存的类型信息
    • calloc 会将返回的内存初始化为 0
  • void* realloc(void* pointer, size_t new_size);
    • void* realloc 用于修改一个原先已经分配的内存块大小
    • 在使用 realloc 之后应该使用其返回值
    • 当 pointer 的第一个参数为 NULL 时,等价于 malloc
1
2
3
4
5
6
7
// 要申请三个 int 类型的空间可以这么写
// 3 表示类型数量,4 表示类型的大小
int* p1 = (int *)calloc(3, 4)

// 修改已经分配的内存块大小
// 将 p1 的大小修改为 8 个字节大小
p1 = (int*)realloc(p1, 8);

malloc: 只负责进行申请空间申请,不会进行内容初始化,所以内容是随机的

calloc: 申请出来的内存空间会初始化为 0

realloc:新扩容出来的的空间不会进行初始胡

小结

  • 动态内存分配是 C 语言中的强大功能
  • 程序能够在需要的时候有机会使用更多的内存
  • malloc 单纯的从系统中申请固定字节大小的内存
  • calloc 能以类型大小为单位申请内存并初始化为 0
  • realloc 用于重置内存空间大小

程序中的三分天下

程序中的栈

  • 栈是现代计算机程序里最为重要的概念之一

  • 栈在程序中用于维护函数上下文

  • 函数中的参数和局部变量存储在栈上

  • 栈是一种后进先出的行为

Czhongdezhan

  • 栈保存了一个函数调用所需的维护信息

Czhanhuodongjilu

函数调用过程

每次函数调用都对应着一个栈上的活动记录

  • 调用函数的活动记录位于栈的中部
  • 被调函数的活动记录位于栈的顶部
  • esp 栈指针,用于指向函数调用后返回的地址(栈顶)跟踪栈的增长和缩减;
  • ebp 基址指针,用于指向当前函数帧的基址,即当前函数栈帧的起始地址

Chanshudiaoyongguocheng

函数调用时栈的变化

  • 从 main() 开始运行

Cmainkaishizhixing

每次调用一个函数时,系统会为这个函数创建一个新的栈帧(开辟一段新的空间),

EBP:本身并不存储在当前函数栈帧的起始位置。它指向的是当前栈帧的基址(通常是栈帧的起始位置),但它自身的值不会被存储在这个起始位置上。

ESP: 是栈顶指针,它指向当前栈的栈顶,随着数据的压栈和弹栈,ESP 不断变化。

返回地址:函数调用返回后,主程序应该继续执行的代码位置(通常是调用函数的调用条指令的下一条指令的地址);

Old EBP:调用的基址;

  • 当 main() 调用 f()

Cmaindiaoyonghanshu

  • 当从 f() 调用中返回 main()

Chanshufanhuimain.png

当 f() 调用结束返回时,

  • 通过 mov esp, ebp 将 ESP 设置为 EBP 的值。这一步是为了将 ESP 恢复到当前栈帧的顶端位置,也就是栈帧的基址,这样方便释放当前函数的栈帧空间。

  • 通过 pop ebp 指令,将栈上的旧 EBP 恢复到寄存器中。这一步恢复了调用者的 EBP,即调用者的栈帧基址。

  • ret: 使用栈上的返回地址,跳转到调用者函数继续执行。

函数调用栈上的数据

  • 函数调用时,对应的栈空间在函数返回前是专用的
  • 函数调用结束后,栈空间将被释放,数据不再有效

Chanshudiaoyongzhanshangshuju

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//示例
#include <stdio.h>

int* g()
{
int a[10] = {1, 1, 1, 2, 2, 2, 3, 3, 3, 4};

return a;
}

void f() {
int i = 0;
int b[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
int* pointer = g(); // 野指针

for(i = 0; i < 10; i++) {
b[i] = pointer[i];
}

for(i = 0; i < 10; i++) {
printf("%d\n", b[i]);
}
/*
for(i = 0; i < 10; i++) {
printf("%d\n", pointer[i]);
}
*/
}

int main() {
f();

return 0;
}

在低版本的编译器中 b[10] 中的值会被 a[10] 中的值覆盖掉,因为当你在 C 语言中调用函数时,局部变量的内存分配在栈上。函数返回后,局部变量所占用的内存不会被清空或初始化为特定的值,而是变得不可用(或无效)。这段内存可能仍然保持原有的数据,直到被后续的函数调用或其他栈操作覆盖。

而现代标本会将 g() 函数所在的空间标记位不可用,此时第一个 for 就会报错 Segmentation fault (core dumped),因为你访问了一个不可达的内区区域;

当我们将前面两个 for 循环删除并打开第三个 for 循环时,会发现输出的内容为无意义的随机值,因为此时 g() 函数所在的空间被 print() 函数所占用了,此时 pointer[i] 指向的位置就变成了未知的内容;现代编译器会报段错误 Segmentation fault (core dumped);

  • 堆是程序中一块预留的内存空间,可由程序自由使用
  • 堆中被程序申请使用的内存在被主动释放前一直有效

为什么有了栈还需要堆

  • 栈上的数据在函数返回后就会被释放掉
  • 无法传递到函数外部,如:局部变量

程序中的堆

  • C 语言程序中通过库函数的调用获得堆空间
    • 头文件:malloc.h
    • malloc – 以字节的方式动态申请空间
    • free – 将堆空间归还给系统

系统对堆空间的管理方式

  • 空闲链表、位图法、对象池法等等

Ckonglianbiaoguanlitu

在不同的系统里面对于堆空间的管理是不同的,

C 语言是以高效而闻名的,那么在 malloc 调用的时候,也就是说向堆里面申请内存的时候,也必须是高效的;那么操作系统是如何做到高效的呢?以空闲链表为例,系统会将堆中的空间组织为一条链表,每个节点上所代表的数字就是这个节点下,每个单元内存的大小,当我们调用 malloc 时,系统就会取遍历这个空闲链表,去比较我们所需要的空间和哪一个节点下的空间大小最为接近,找到之后就会在其下的空间中找到一段可以使用的内存并返回给 p;这也就是 malloc 中所申请的空间可能会比实际空间大一点点的原因所在;

静态存储区

  • 静态存储区随着程序的运行而分配空间(在编译期确定大小)
  • 静态存储区的生命周期直到程序运行结束
  • 在程序的编译期静态存储区的大小就已经确定
  • 静态存储区主要用于保存全局变量静态局部变量
  • 静态存储区的信息最终会保存到可执行程序中
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 示例
#include <stdio.h>

int g_v = 1;

static int g_vs = 2; // static 修饰表示旨在当前文件可用

void f() {
static int g_vl = 3; // 静态局部变量

printf("%p\n", &g_vl);
}

int main() {
printf("%p\n", &g_v);

printf("%p\n", &g_vs);

f();

return 0;
}

全局变量、静态全局变量、静态局部变量,都是放在静态存储区中的

栈、堆和静态存储区是程序中的三个基本数据区

  • 栈区主要用于函数调用的使用
  • 堆区主要适用于内存的动态申请
  • 静态存储区用于保存全局变量和静态变量

程序的内存布局

程序文件的一般布局

不同代码在可执行程序中的对应关系

在编译和链接过程中,C 程序的内存布局分为不同的段,不同的代码会被分配在不同的段中;

Ccxuwenjianyibanbuju

File Header:包含了与操作系统和加载器相关的信息,描述了可执行文件的基本结构和布局。文件头通常位于可执行文件的起始位置,为操作系统和加载器提供必要的元数据以正确地加载、执行和管理该程序。

.bss 段:存储 未初始化 的全局变量和静态变量。

  • 未初始化的全局变量和静态变量会存储在这里。
  • 这些变量会在程序启动时被系统自动初始化为 0(或对应的 NULL 指针等)。
  • .bss 段在可执行文件中并不会占据空间,因为未初始化的变量在加载时由操作系统分配,并且值被默认初始化为 0。

.data 段:存储 已初始化 的全局变量和静态变量。

  • 已初始化的全局变量和静态变量会存储在这里,数据在程序加载时被初始化为编译时指定的值。
  • .data 段是可写的,因此在程序运行过程中这些变量可以被修改。
  • 存储在 .data 段中的数据会直接写入可执行文件中,并在加载时映射到内存中。

.text 段:存储程序的 代码指令(即可执行的机器指令)。

  • 这个段通常是只读的,不能被修改。现代操作系统通过内存保护机制防止程序在运行时修改 .text 段的内容,这也是防止代码被恶意修改或破坏的安全措施。
  • 这个段中的内容是可执行的。CPU 会从这里读取指令并执行。
  • 在某些情况下(如动态链接库),不同进程可以共享 .text 段以节省内存。

.rodata:专门用于存储程序中的 只读数据,即程序中那些在运行时不能被修改的常量和字符串字面量等。

  • 这个段的数据是只读的,程序在运行时不能修改这个段中的内容。如果尝试修改,将会导致程序崩溃(通常会抛出内存访问违规错误)。
  • 这个段中的内容不是程序的指令,而是只读的数据,因此它不可执行。
  • 包含了程序中的常量值(如 const 关键字定义的变量)和字符串字面量等数据。

程序与进程

  • 程序是静态的概念,表现形式为一个可执行文件
  • 进程是动态的概念,程序由操作系统加载运行后得到进程
  • 每个程序可以对应多个进程
  • 每个进程只能对应一个程序

思考

包含脚本代码的文本文件是一种类型的可执行程序吗?如果是,对应什么样的进程呢?

我们的脚本代码可以说是一种可执行程序,但是它不是一种可以直接运行的可执行程序;他的加载流程如下:

Cjiaobenwenjianyunxinguocheng

文件布局在内存中的映射

Cwenjianbujuyinsheneicun

堆和栈是程序运行时用于存储动态内存局部变量的区域。它们的内存是在运行时动态分配的,并不是编译时确定的。

.text、.data 和 .bss 段:这些段是程序可执行文件中的一部分,在程序加载时由操作系统分配到内存中,负责存储程序的指令和静态数据。

静态存储区:通常指程序中的 .bss 和 .data 段

只读存储区:通常指程序中的 .rodata 段

局部变量所占空间为栈上的空间

动态空间为堆中的空间

程序可执行代码存放于 .text 中(也就是我们常说的代码段)

思考

同样是全局变量和静态变量,为什么初始化和未初始化的保存在不同的段中?

一个全局变量或者静态变量,没在有初始化的话通常情况下值为 0,初始化过的值就是固定的;而 C 语言是以高效所闻名的,那么对于未初始化的全局变量和静态变量,在加载时候如果这些未初始化的内存都是统一放在一起的那么我只需要挨个初始化即可;而已经初始化完成的全局变量和静态变量他们的初始值已经给出来了,我们需要将变量与其初值在内存中进行一一对应处理;这二者的复杂程序不同,如果都放在同一个区域中,那么对于效率会产生影响;会将简单的事情也变得复杂;

小结

  • 程序源码在编译后对应可执行区域中的不同存储区
  • 程序和进程不同,程序是静态概念进程是动态概念
  • 堆栈段是程序运行的基础,只存在于进程空间中
  • 程序可执行代码存放于 .text 段,是只读的
  • .bss 和 .data 段用于保护全局变量静态变量

内存操作的经典问题分析

野指针

  • 指针变量中的值是非法的内存地址,进而形成野指针
  • 野指针不是 NULL 指针,是指向不可用内存地址的指针
  • NULL 指针并无危害,很好判断,也很好调试
  • C 语言中无法判断一个指针所保存的指针是否合法

在开发中一定要尽量杜绝野指针的出现;

野指针的由来

  • 局部指针变量没有被初始化
  • 指针所指向的变量在指针之前被销毁
  • 使用已经释放过的指针
  • 进行了错误的指针运算
  • 进行了错误的强制类型转换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 野指针示例
#include <stdio.h>
#include <malloc.h>

int main() {
int* p1 = (int*)malloc(40);
int* p2 = (int*)1234567; // 错误的强制类型转换
int i = 0;

for(i=0; i<40; i++) {
// P1 + i,实际上移动了 4 个字节
*(p1 + i) = 40 - i; // 进行了错误的指针运算,指针越界了
}

free(p1); // 释放了 p1 的空间,但不会重置 p1,后面在使用 p1,p1 就是野指针
// p1 = NULL 每次释放后将指针赋值为 NULL 是避免出现野指针的一种有效方式

for(i=0; i<40; i++) {
p1[i] = p2[i]; // 野指针操作的示例 Segmentation fault (core dumped)
}

return 0;
}

避免野指针的一些基本原则

  • 决不返回局部变量局部数组的地址
  • 任何变量在定义后必须进行初始化
  • 字符串必须确定 \0 操作符后才能认为他是一个字符串
  • 任何使用与内存操作相关的函数都必须指定长度信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// 示例二
#include <stdio.h>
#include <string.h>
#include <malloc.h>

struct Student {
char* name;
int number;
};

char* func() {
char p[] = "D.T.Software";

return p;
}

void del(char* p) {
printf("%s\n", p);

free(p);
}

int main() {
struct Student s; // 没有初始化,产生野指针
char* p = func(); // p 就是野指针
// s.name 是个野指针,因为没有进行初始化,导致他指向的地址是未知的
strcpy(s.name, p); // Segmentation fault (core dumped)

s.number = 99;

p = (char*)malloc(5);
// p 的长度只有 5 却赋值超过了它本身长度的内容
// 这会产生内存越界
strcpy(p, "D.T.Software");

del(p);

return 0;
}

常见的内存错误

  • 结构体成员指针未初始化
  • 结构体成员指针未分配足够的内存
  • 内存分配成功,单并未初始化
  • 内存操作越界
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 示例
#include <stdio.h>
#include <malloc.h>

void test(int* p, int size) {
int i = 0;

for(i=0; i<size; i++)
{
printf("%d\n", p[i]);
}

free(p);
}

void func(unsigned int size) {
int* p = (int*)malloc(size * sizeof(int));
int i = 0;

if( size % 2 != 0 ) {
return;
}
// 如果长度为偶数,将不会执行下面的代码进行初始化
// 同样也不会进行指针空间的释放
for(i=0; i<size; i++) {
p[i] = i;
printf("%d\n", p[i]);
}

free(p);
}

int main() {
int* p = (int*)malloc(5 * sizeof(int));

test(p, 5);
// 双重释放
free(p);

func(9);
func(10);

/*
0
0
0
0
0
free(): double free detected in tcache 2 // 双重释放
Aborted (core dumped)
*/

return 0;
}

第一处错误:双重释放

在编程时,我们要了解动态内存也就是堆里面的空间属于谁那个函数,p 是在 main() 中声明的那么其他函数就没有资格释放 p 这个指针;那么这时候看 tset() 中的函数就有问题了,他不应该释放 p;

再者,假设我们有 int a[2] 这样一个数组,此时我们调用 test(a, 2) 此时就会报错,因为 free() 释放的是堆上的空间,栈上空间是只读的,free() 释放栈上的空间,这时候就会发生 段错误;

除非特别特殊的性能需求,不然尽量要做到谁申请,谁释放;

二次释放错误的原因

当你第一次调用 free() 时,C 语言的运行时库会将这块内存标记为 “空闲”。操作系统会把它添加到一个空闲列表中,以备后续的内存分配操作使用。此时,这块内存仍然存在于堆中,在被其他的内容覆盖前,内容依然是存在的,但程序不应该再使用它,因为它可能会在后续内存分配时被覆盖。

如果你再次对同一个指针调用 free(),程序会尝试再次释放已经释放的内存。这时,C 运行时库无法正确判断如何处理这块已经释放的内存。由于 C 语言的内存管理系统不期望同一块内存被释放两次,系统将检测到这种非法操作,通常会抛出一个错误,可能是段错误(Segmentation Fault)或者 double free 错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 示例二
#include <stdio.h>
#include <malloc.h>

struct Demo {
char* p;
};

int main() {
struct Demo d1;
struct Demo d2;

char i = 0;

for(i='a'; i<'z'; i++) {
// p 这个指针指向的内存是未知的
d1.p[i] = 0; // 段错误
}
// calloc 初始化会默认初值为 0
d2.p = (char*)calloc(5, sizeof(char));
// 所以这里是没问题的
printf("%s\n", d2.p);
// 内存越界
for(i='a'; i<'z'; i++) {
d2.p[i] = i;
}

free(d2.p);

return 0;
}

一些内存操作的规则

  • 动态申请内存后,应该立即检查指针值是否为 NULL,防止使用 NULl 指针;
1
2
3
4
5
6
int* p = (int*)malloc(56);

if(p != null) {
//
}
free(p);
  • free 指针后必须立即赋值为 NULL
1
2
3
4
5
6
7
8
9
int* p = (int*)malloc(56);

free(p);
p = NULL;
...

if(p != null) {
//
}
  • 任何与内存操作相关的函数都必须带长度信息
1
2
3
4
5
6
7
8
9
10
void print(int* p, int size) {
int i = 0;
char buf[128] = {0};

snprintf(buf, sizeof(buf), "%s", "cn.kay.wang");

for(i = 0; i < size; i++) {
printf("%d\n", p[i]);
}
}
  • malloc 操作和 free 操作必须匹配,防止内存泄漏和多次释放。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void func() {
int* p = (int*)malloc(20);

free(p);
}

int maiin() {

int* p = (int*)malloc(40);

func();

free(p);

return 0;
}

小结

  • 内存错误的本质源于指针保存的地址为非法值
    • 指针变量未初始化,保存随机值
    • 指针运算导致内存越界
  • 内存泄露源于 malloc 和 free 不匹配
    • 当 malloc 次数多于 free 时,产生内存泄漏
    • 当 malloc 次数少于 free 时,程序可能崩溃