系统级程序设计笔记(unit4——堆栈、堆和动态内存分配)

本文探讨了系统级程序设计中的关键概念,如堆栈管理、动态内存分配、垃圾回收机制及C语言常见错误分析。重点讲解了内存管理技术,包括堆栈分配、堆分配、隐式空闲链表等,同时深入剖析了几种垃圾回收算法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

这个专题的所有学习笔记来自于对武汉大学计算机学院软件工程专业大三上学期的专业必修课《系统级程序设计》的学习(教材为深入理解计算机系统CSAPP),涉及的编程语言全部为C语言和C++语言。

该博客为第4单元的学习笔记,这一单元的主要内容是堆栈的再认识、动态内存分配、堆的认识、隐式空闲链表、垃圾回收、C语言中与内存有关的常见错误等,部分内容来自《深入理解计算机系统》的第三章第七节的内容和第九章第九节的内容。对应ssd6课程的lecture5。


静态声明(Conclusions to C++ static Declarations)

(1)书上的内容:当我们同时编译多个文件时,所有未加static前缀的全局变量和函数都具有全局可见性,其它的源文件也能访问。如果加了static,就会对其它源文件隐藏。利用这一特性可以在不同的文件中定义同名函数和同名变量,而不必担心命名冲突。static可以用作函数和变量的前缀,对于函数来讲,static的作用仅限于隐藏。

PPT上的内容:C程序员使用静态属性来隐藏变量和函数声明的内部模块,就像你会使用public和private在java和C++的声明。C源文件起模块作用。
任何静态属性声明的全局变量或函数都是私有的。类似地,没有静态属性声明的任何全局变量或函数都是公共的,并且可以由任何其他模块访问。
尽可能地用静态属性保护变量和函数,这是好的编程风格。

有趣的是,使用C静态属性定义的本地过程变量没有在堆栈上进行管理。

(2)静态分配的注意事项:
静态分配的数据使用内存作为程序的生命周期。
它一定是固定大小的。
不是静态分配内存,而是等待运行时分配(如果知道大小)。
对静态数据的限制取决于系统。

(3)

setjmp(jmp_buf j) //first load
longjmp(jmp_buf j, int i) //destroy buf, and back

①setjmp和logjmp是配合使用的,用它们可以实现跳转的功能,和goto语句很类似,不同的是goto只能实现在同一个函数之内的跳转,而setjmp和longjmp可以实现在不同函数间的跳转
用法:首先用setjmp设置跳转的地点,setjmp的参数buf是用来保存设置跳转点时的函数使用的重要数据,当从其他函数跳转回来,如果不用这个保存的数据恢复当前函数的一些数据的话,跳转回来是不能运行的。第一次设置的时候setjmp返回值为0
使用longjmp就可以跳转到setjmp的地方了,参数buf就是使用setjmp的时候保存的,而第二个参数会在跳转以后把这个值让setjmp返回的,也就是longjmp第二个参数,就是跳转到setjmp之后setjmp函数要返回的值
如何实现异常处理
首先设置一个跳转点(setjmp()函数可以实现这一功能),然后在其后的代码中任意地方调用longjmp()跳转回这个跳转点上,以此来实现当发生异常时,转到处理异常的程序上,在其后的介绍中将介绍如何实现。setjmp()为跳转返回保存现场并为异常提供处理程序,longjmp()则进行跳转(抛出异常),setjmp()与longjmp()可以在函数间进行跳转,这就像一个全局的goto语句,可以跨函数跳转。
②例子

main()    
{
  volatile int b;
  b =3;
  if(setjmp(buf)!=0)  {
    printf("%d ", b);  
    exit(0);
  }
  b=5;
  longjmp(buf , 1);
} 
//请问输出是?

这个代码里运行步骤:
1.先执行setjmp,因为是第一次设置跳转点,返回值是0,不执行if语句块里的语句,
2.然后执行b=5,b的值就是5了
3.再执行longjmp跳转之后, 最后再执行setjmp, 这时setjmp会返回1(也就是longjmp的第二个参数指定的值),就会执行if语句块里的语句—-打印之后终止程序,这时b的值是5,就会打印出5来

#include<stdio.h>
#include<setjmp.h>
jmp_buf buf;
int times=0;
void second(int *k){
    printf(“second times=%d\n”,++(*k));
    longjmp(buf,65536);
}
void first(int *k){
    prinf(“first times=%d\n”,++(*k));
    second(k);
}
int main(void){
    int ret=setjmp(buf);
    if(ret==0){
        printf(“1.ret is %d\n”,ret);
        first(&times);
    }else{
        printf(“2.ret is %d\n”,ret);
    }
}

运行结果:

1.ret is 0
first times=1
second times=2
2.ret is 65536

堆栈

(1)堆栈分配:
堆栈非常有效地支持递归和动态分配。
当函数被调用时:堆栈保存参数值、本地变量和调用函数的地址。
当函数返回时:堆栈空间被回收用于重用。
这里写图片描述不同函数中的变量可以具有相同的名称,但仍然表示不同的变量。
递归函数的每个实例都可以有自己的私有变量集。
递归函数可以创建任意多个函数实例。

(2)使用堆栈的函数调用:
主要概念:
Activation Record or Stack Frame(活动记录或者栈帧)
函数调用时,为该函数分配的,用于记录函数信息的存储块。(因为活动记录使用栈存储,一个活动记录又称栈帧(Stack Frame))
一次函数调用包括将数据和控制从代码的一个部分传递到另外一个部分,栈帧与某个过程调用一一映射。每个函数的每次调用,都有它自己独立的一个栈帧,这个栈帧中维持着所需要的各种信息。

TOS=Top of Stack
即栈指针(Stack Pointer),记录了栈顶位置,也就是下一个活动记录将被分配的位置。又称TOS栈顶(Top of Stack),在Pentium里面又称(ESP)

Frame=Activation Record Base
帧指针(Frame Pointer),记录了当前活动记录的结束地址,也就是函数返回时,栈指针将指向的位置。又称活动基址(Activity Record Base),在Pentium中又称作(EBP)

PC=Program Counter(程序计数器)
用于保存下一条指令地址的寄存器。

这里写图片描述

(3)堆分配(显式):
不要在堆栈上返回本地变量的地址。
堆内存:堆栈内存的另一种选择
分配内存:malloc或new
返回内存:free或delete
堆上的内存总是用指针表示和访问。

常见的内存错误:
忘记释放内存
内存泄漏
悬挂指针问题

动态内存分配

(1)动态内存分配器维护一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护着一个变量brk,指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护,每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。
分配器有两种基本风格,两种风格都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。显式分配器要求应用显式地释放任何已分配的块,比如C中的malloc和free,C++中的new和delete。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫做垃圾收集器。

(2)显式分配器:C标准库提供了一个称为malloc程序包的显示分配器,程序通过调用malloc函数来从堆中分配块
①void *malloc(size_t size);
malloc函数返回一个指针,指向大小至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。
如果malloc遇到问题(例如程序要求的内存块比可用的虚拟内存还要大),那么它就返回NULL,并设置errno。malloc不初始化它返回的内存。
②sbrk函数:void *sbrk(intptr_t incr);
sbrk函数通过将内核的brk指针增加incr来扩展和收缩堆。
如果成功,它就返回brk的旧值,否则它就返回-1,并将errno设置为ENOMEM
如果incr为零,那么sbrk就返回brk的当前值(2017-2018学年期末考试考了这里)
③free函数:void free(void *ptr);
ptr参数必须指向一个从malloc、calloc或者realloc获得的已分配块的起始位置。如果不是,那么free的行为就是未定义的。更糟糕的是,既然它什么都不返回,free就不会告诉应用出现了错误。

(3)为什么要使用动态内存分配:程序使用动态内存分配的最重要的原因是经常直到程序实际运行时,才知道某些数据结构的大小。而使用硬编码的大小来分配数组不是一种好想法(不利于维护,还可能需要重新编译)

(4)分配器的规则和目标:

  • 规则
  • 处理任意请求序列(不能假设所有的分配请求都有相匹配的释放请求)
  • 立即响应请求(不允许分配器为了提高性能重新排列或者缓冲请求)
  • 只使用堆(为了使分配器是可扩展的)
  • 对齐块
  • 不修改已分配的块(分配器只能操作或者改变空闲块,一旦块被分配就不允许修改或者移动了)
  • 目标:
  • 目标1:最大化吞吐率
  • 目标2:最大化内存利用率

(5)碎片
内部碎片:已分配块比有效载荷大的时候发生的
外部碎片:空闲内存合计起来足够满足一个分配请求,但没有一个单独的空闲块足够大可以来处理这个请求

(6)实现问题
可以想象出的最简单的分配器会把堆组织成一个大的字节数组,还有一个指针p,初始指向这个数组的第一个字节。为了分配size个字节,malloc将p的当前值保存在栈里,将p增加size,并将p的旧值返回到调用函数。free只是简单地返回到调用函数而不做任何事情。
这个简单的分配器是一种极端情况,因为每个malloc和free只执行很少的指令,吞吐率会极好。然而,因为分配器从不重复使用任何块,内存利用率将极差。一个实际的分配器要在吞吐率和利用率之间把握好平衡要考虑以下问题:(2017-2018学年期末考试考了这里)

  • 空闲块组织:如何记录空闲块?
  • 放置:如何选择一个合适的空闲块来放置一个新分配的块?
  • 分割:在将一个新分配的块放置到某个空闲块之后,如何处理这个空闲块中的剩余部分?
  • 合并:如何处理一个刚刚被释放的块?

(7)隐式空闲链表
①介绍
任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入块本身。如下图所示
这里写图片描述
在这种情况中,一个块是由一个字的头部、有效载荷、以及可能的一些额外的填充组成的。头部编码了这个块的大小以及这个块是分配的还是空闲的。如果有一个双字的对齐约束条件,块大小就总是8的倍数,块大小的最低3位总是0.假设有一个已分配的块(a=1),大小为24(0x18)字节,头部将是
0x00000018|0x1=0x00000019
类似地,一个空闲块(a=0),大小为40(0x28)字节,头部将是
0x00000028|0x=0x00000028

内存对齐的原理(原因?)是?(Alignment)
平台原因:不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址取某些特定类型的数据,否则抛出硬件异常
性能原因:数据结构应该尽可能在自然边界上对齐,为了访问未对齐的内存,处理器需要作两次内存访问,而对齐的内存仅需要一次。

可以将堆组织一个连续的已分配块和空闲块的序列,如下图所示:
这里写图片描述
用隐式空闲链表来组织堆,其中阴影部分是已分配块,没阴影部分是空闲块
头部标记为(大小(字节)/已分配位)
称这种结构为隐式空闲链表是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块从而间接地遍历整个空闲块的集合。我们需要某种特殊标记的结束块,一个设置已分配位而大小为零的终止头部。

隐式空闲链表优点:简单
缺点:任何操作的开销要求对空闲链表进行搜索,所需时间与堆中已分配块和空闲块的总数呈线性关系。
P593练习题6

②放置已分配的块
首次适配、下一次适配和最佳适配
首次适配从头开始搜索链表,选择第一个合适的空闲块。
下一次适配从上一次查询结束的地方开始搜索,选择第一个合适的空闲块。
最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

③分割空闲块
一旦分配器找到一个匹配的空闲块,就必须做出一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块,虽然简单快捷,但会造成内部碎片。如果防止策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。
然而如果匹配不太好,那么分配器通常会把这个空闲块分割为两部分,第一部分变成分配块,剩下的变成一个新的空闲块。如下图展示了分配器如何分割图中8个字的空闲块来满足一个应用的对堆内存3个字的请求。
这里写图片描述
④获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块将发生什么呢?
一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块。然而如果这样还是不能生成一个足够大的块,或者空闲块已经最大程度地合并了,那么分配器就会通过调用sbrk函数向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

⑤合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块引起一种现象叫做假碎片,就是有许多可用的空闲块被切割成为小的、无法使用的空闲块。
这里写图片描述
立即合并和推迟合并:立即合并简单明了,但对于某些请求模式会产生一种形式的抖动,块会反复合并然后马上分割,产生大量不必要的分割和合并。

⑥带边界标记的合并(书P596)+练习题7

(8)垃圾回收机制的算法有哪些?
①标记-清除算法
首先标记处所有需要回收的对象,在标记完成后统一收掉所有被标记的对象。缺点有两个:一个事效率问题。标记和清除过程的效率都不高;另一个是空间问题,标记清除之后会产生大量的不连续的内存碎片。
②复制算法
它将可用内存按容量划分成大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用的内存空间一次清理掉。内存分配时也就不用考虑碎片的问题了,只要移动堆顶指针,按顺序分配内存即可实现简单,运行高效。但代价是内存缩小为原来的一半。
③标记-整理算法
前半部分与标记-清除算法相似,但不是直接回收,而是让所有存货的对象都向一端移动,然后直接清理掉边界以外的内存。
④引用计数法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1,当引用失效时,减1,任何时候计数器为0的对象都是不可能再被使用的,可以被清除掉。它不能解决对象之间的相互循环引用问题。
⑤分代收集算法
根据对象的存活周期不同将内存划分为几块。一般是把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

C语言中的一些常见的错误

(1)未初始化的本地指针

int sum(int a[], int n)
{
    int *p;
    int sum = 0;
    for (int i = 0; i < n; i++)
        sum += *p++;
}

假设您声明了一个本地指针但是忘记初始化它。由于变量的内存在堆栈上,并且堆栈可能充满了之前的活动记录所丢弃的数据,所以指针可以有任何值:
(2)未初始化的局部变量

int i;
double d;
scanf("%d %g", i, d); // wrong!!!
// here is the correct call:
scanf("%d %g", &i, &d);

scanf()不指定所有参数的类型,而形参在预编译的时候需要告诉系统要预留出多少的内存空间,所以这里的参数不可以是未初始化的局部变量。
(3)内存溢出问题

#define array_size 100
int *a = (int *) malloc(sizeof(int *) * array_size);
for (int i = 0; i <= array_size; i++)
    a[i] = NULL;

这是一个显然而低级的错误,但却是我们经常会不小心犯,应该把for循环中的小于等于改成小于。
(4)超出所分配的内存

#define array_size 100
int *a = (int *) malloc(array_size);
a[99] = 0; // this overwrites memory beyond the block

分配太少的内存会导致之后的赋值覆盖掉之前的内存。
应该为a=(int*)malloc(array_size*sizeof(int))
(5)忘记给\0分配空间
有时,程序员忘记字符串是由\0结束的。考虑下面的函数,该函数将字符串传输到堆:

char *heapify_string(char *s){
    int len = strlen(s);

    char *new_s = (char *) malloc(len);
    strcpy(new_s, s);
    return new_s;
}

在这个例子中,程序没有为字符串分配足够的空间。malloc中的参数应该为len+1,为终止的零字节留下空间。如果malloc分配的是8字节的倍数,当len是8字节的倍数的时候heapify_string这个函数将会失败(除非内存大小不是舍入到更大的内存大小)

另外,当两个字符串连接时,结果字符串也有可能占用太多空间:
char q[] = “do not overflow”;
char r[] = ” memory”;
char s[16];
strcpy(s, q);
strcat(s, r);
需要22+1(终止0字符)个字符,但只分配了16,所以写操作会超出所分配的内存(要知道strcat函数并不会为结果分配额外的内存)
(6)在运行时堆栈上构建指针并将指针返回给调用方

int *ptr_to_zero(){
    int i = 0;
    return &i;
}

尽管这个函数返回一个指向0值整数的指针,但是这个整数是在一个活动记录中。而这个函数返回时这个活动记录就会被立刻删除,那么指针引用的内存的值可以变成任意值,这还要取决于其他函数的调用情况。
(7)运算顺序和优先级导致的问题

// decrement a if a is greater than zero:
void dec_positive(int *a){
    *a--; // decrement the integer
    if (*a < 0) *a = 0; // make sure a is positive
}

函数里的第一行代码本来想减少a的值,但是事实上减少的是指向a的指针,错误原因是–的优先级虽然和*一样高,但是执行顺序是从右向左执行的。当不确定运算优先级时需要使用括号,之前的代码改为(*a)–即可。
(8)意外地释放相同的指针两次

void my_write(x){
    ... use x ...
    free(x);
}
int *x = (int *) malloc(sizeof(int*) * N);
...
my_read(x);
...
my_write(x);
free(x); //oops, x is freed in my_write()!

(9)引用释放的内存块
一旦一个块被释放,如果块占用的内存被另一个块重用,它的数据随时可能被堆分配程序和应用程序改变。因此,使用一个被释放指针会导致不好的事情发生。您可能在块被修改的地方读取到垃圾,如果写入块,则可能破坏程序已经分配好的堆或数据。下面是一个引用已释放指针的程序的示例:

void my_write(x){
    ... use x ...
    free(x);
}
int *x = (int *) malloc(sizeof(int*) * N);
...
my_read(x);
...
my_write(x);
...
my_read(x); // oops, x was freed by my write!
...
my_write(x);

避免这种错误的一种方法是在释放指针时替换带有null的指针。然而,如果指针有多个副本,这并不能解决问题。事实上这是一个常见的错误发生方式:程序员完成了一个引用并释放了块,忘记了还有其他引用可能被使用。
(10)内存泄漏
内存泄漏形象的比喻是”操作系统可提供给所有进程的存储空间正在被某个进程榨干”,最终结果是程序运行时间越长,占用存储空间越来越多,最终用尽全部存储空间,整个系统崩溃。

void my_function(char *msg){
    // allocate space for a string
    char *full_msg = (char *) malloc(strlen(msg) + 100);
    strcpy(full_msg, "The following error was encountered: ");
    strcat(full_msg, msg);
    if (!display(full_msg)) return;
    ...
    free(full_msg);   
}

在这个例子中,被分配的内存在函数最后一行被释放,但如果在display那里发生了错误,这个函数就会提前返回而没有释放内存。异常、错误、各种形式的抛出和捕获通常都会与内存泄漏有关。
(11)忘记释放数据结构的各个部分导致内存泄漏

typedef struct  {
    char *name;
    int age;
    char *address;
    int phone;
} Person;
void my_function(){
    Person *p = (Person *) malloc(sizeof(Person));
    p->name = (char *) malloc(M);
    ...
    p->address = (char *) malloc(N);
    ...
    free(p); // what about name and address?
}

在这个例子中,一个person结构体被分配和释放,但是这个结构体的一部分,name和address被分配了但是没有被释放。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值