前言:
内存管理是初学者进阶到高手必须要学习的,本文最好反复专注地看,否则可能会感觉在看天书。内存管理前,我们先了解一些基础知识。代码的组成中要么是函数,要么是变量。那么
下面来解答这些问题。
作用域:起作用的区域,也就是可以工作的范围。
代码块:所谓代码块,就是用{}括起来的一段代码。
数据段:数据段存的是数,像全局变量就是存在数据段的
代码段:存的是程序代码,一般是只读的。
栈(stack):先进后出。C语言中局部变量就分配在栈中。
普通的局部变量定义的时候直接定义或者在前面加上auto
void func1(void)
{
int i = 1;
i++;
printf("i = %d.\n", i);
}
局部变量i的解析:
在连续三次调用func1中,每次调用时,在进入函数func1后都会创造一个新的变量i,
并且给它赋初值1,然后i++时加到2,
然后printf输出时输出2.然后func1本次调用结束,
结束时同时杀死本次创造的这个i。这就是局部变量i的整个生命周期。
下次再调用该函数func1时,又会重新创造一个i,经历整个程序运算,
最终在函数运行完退出时再次被杀死。
静态局部变量(static) 静态局部变量定义时前面加static关键字。
总结:
1、静态局部变量和普通局部变量不同。静态局部变量也是定义在函数内部的,静态局部变量定义时前面要加static关键字来标识,静态局部变量所在的函数在多调用多次时,只有第一次才经历变量定义和初始化,以后多次在调用时不再定义和初始化,而是维持之前上一次调用时执行后这个变量的值。本次接着来使用。
2、静态局部变量在第一次函数被调用时创造并初始化,但在函数退出时它不死亡,而是保持其值等待函数下一次被调用。下次调用时不再重新创造和初始化该变量,而是直接用上一次留下的值为基础来进行操作。
3、静态局部变量的这种特性,和全局变量非常类似。它们的相同点是都创造和初始化一次,以后调用时值保持上次的不变。不同点在于作用域不同。(静态局部变量的作用域是:从定义点到函数体(或复合语句)结束)
全局变量:定义在函数外面的变量,就叫全局变量。
普通全局变量:普通全局变量就是平时使用的,定义前不加任何修饰词。普通全局变量可以在各个文件中使 用,可以在项目内别的.c文件中被看到,所以要确保不能重名。
静态全局变量 :静态全局变量就是用来解决重名问题的。静态全局变量定义时在定义前加static关键字, 告诉编译器这个变量只在当前本文件内使用,在别的文件中绝对不会使用。这样就不用担心重名问题。所以静态的全局变量就用在我定义这个全局变量并不是为了给别的文件使用,本来就是给我这个文件自己使用的。
跨文件引用全局变量(extern) :就是说,你在一个程序的多个.c源文件中,可以在一个.c文件中定义全局变量g_a,并且可以在别的另一个.c文件中引用该变量g_a(引用前另一个.c文件中要声明)
函数和全局变量在C语言中可以跨文件引用,也就是说他们的连接范围是全局的,具有文件连接属性,总之意思就是全局变量和函数是可以跨文件看到的(直接影响就是,我在a.c和b.c中各自定义了一个函数func,名字相同但是内容不同,编译报错。)。
1、定义同时没有初始化,则局部变量的值是随机的,而全局变量的值是默认为0.
2、使用范围上:全局变量具有文件作用域,而局部变量只有代码块作用域。
3、生命周期上:全局变量是在程序开始运行之前的初始化阶段就诞生,到整个程序结束退出的时候才死亡;而局部变量在进入局部变量所在的代码块时诞生,在该代码块退出的时候死亡(静态局部变量除外)。
4、变量分配位置: 已初始化的全局变量(且不初始化为0) 分配在数据段(data段)上;** 未初始化的全局变量(自动为0)**分配在bss段上;而 **局部变量(未初始化时为随机值)**是自动分配在栈上的(但不包括static声明的变量,static意味着在data段或bass段中存放变量,跟全局变量的存放一样)。
注意: const声明的变量:编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。
参考链接: C语言局部变量、静态局部变量、全局变量与静态全局变量!
在32位x86的Linux系统中,虚拟地址空间布局如下图所示:
虚拟地址空间分布:
这样的概念,不知道最初来源于哪里的规定,但在当前的计算机程序设计中是很重要的一个基本概念。
而且在嵌入式系统的设计中也非常重要,牵涉到嵌入式系统运行时的内存大小分配,存储单元占用空间大小的问题。
在采用段式内存管理的架构中(比如intel的80x86系统),bss段通常是指用来存放程序中未初始化的全局变量的一块内存区域,
一般在初始化时bss 段部分将会清零。bss段属于静态内存分配,即程序一开始就将其清零了。
比如,在C语言之类的程序编译完成之后,已初始化的全局变量保存在.data 段中,未初始化的全局变量保存在.bss 段中。
text和data段都在可执行文件中(在嵌入式系统里一般是固化在镜像文件中),由系统从可执行文件中加载;而bss段不在可执行文件中,由系统初始化。
在初始化时 bss 段部分将会清零。bss 段属于静态内存分配,即程序一开始就将其清零了。
比如,在C语言之类的程序编译完成之后,已初始化的全局变量保存在.data 段中(也就是其值是存放在rom中),未初始化的全局变量保存在.bss 段中。
上面这些理论可能有些抽象,下面我们通过示例代码来对比两者区别。
示例代码
#include
int bss_1;// 未初始化的全局变量,bss段
int bss_2 = 0;// 初始化为0的全局变量,bss段
int data_1 = 1;// 初始化非0的全局变量,data段
int main() {
static int bss_3;// 未初始化的静态局部变量,bss段
static int bss_4 = 0;// 初始化为0静态局部变量,bss段
static int data_2 = 1;// 初始化非0静态局部变量,data段
printf("bss段地址:bss_1 = %p\n", &bss_1);
printf("bss段地址:bss_2 = %p\n", &bss_2);
printf("bss段地址:bss_3 = %p\n", &bss_3);
printf("bss段地址:bss_4 = %p\n\n", &bss_4);
printf("data段地址:data_1 = %p\n", &data_1);
printf("data段地址:data_2 = %p\n", &data_2);
return 0;
}
再来示例代码
程序1:
int array[30000];
int main() {
return 0;
}
程序2:
int array[30000] = {1, 2, 3, 4, 5, 6};
int main() {
return 0;
}
总结
未初始化的全局变量、静态局部变量,存储在.bss段中,具体体现为一个占位符;
已初始化的全局变量、静态局部变量,存储在.data段中;
此外,非静态局部变量,都在栈中分配空间。
.bss 是不占用.exe文件空间的,其内容由操作系统初始化(清零);
.data 却需要占用,其内容由程序初始化。因此造成了上述情况。
bss 段,不为数据分配空间,只是记录数据所需空间的大小;
bss 段的大小从可执行文件中得到 ,然后链接器得到这个大小的内存块,紧跟在data段后面。
data 段,则为数据分配空间,数据保存在目标文件中;
data 段包含经过初始化的全局变量以及它们的值。
参考资料:
1、 深入理解BSS段与data段的区别
2、 (深入理解计算机系统) bss段,data段、text段、堆(heap)和栈(stack)
简洁版:
什么是单片机堆栈?
在片内RAM中,常常要指定一个专门的区域来存放某些特别的数据,它遵循顺序存取和后进先出(LIFO/FILO)的原则,这个RAM区叫堆栈。
它的作用:
1. 子程序调用和中断服务时,CPU自动将当前PC值压栈保存,返回时自动将PC值弹栈。
2. 保护现场/恢复现场
3. 数据传输
单片机堆栈原理:
堆栈区由特殊功能寄存器堆栈指针SP管理。堆栈区可以安排在RAM区任意位置,但一般不安排在工作寄存器区和可按位寻址的RAM区,通常是放在RAM区靠后的位置。
看关于单片机方面的书籍的时候,总是能看到别人说的一些堆栈啊什么的操作,之前看到这个术语就直接跳过,没想到去探究单片机内部的原理。但是最近课程学习微机原理这门课,需要我们写汇编程序,汇编里面经常遇到堆栈这个东西,所以就找了个时间把堆栈给彻底的搞一下。
如果了解一点汇编编程话,就可以知道,堆栈是内存中一段连续的存储区域,用来保存一些临时数据。通常用来保存CALL指令调用子程序时的返回地址,RET指令从堆栈中获取返回地址。中断指令INT调用中断程序时,将标志寄存器值、代码段寄存器CS值、指令指针寄存器IP值保存在堆栈中。
堆栈也可以用来保存其他数据。
堆栈操作由PUSH,POP两条指令来完成;
堆栈操作的操作数均为子类型(两个字节)进行操作。
程序内存可以分为几个区,栈区(stack),堆区(Heap),全局区(static),文字常亮区,程序代码区。
程序编译之后,全局变量,静态变量已经分配好内存空间,在函数运行时,程序需要为局部变量分配栈空间,当中断来时,也需要将函数指针入栈,保护现场,以便于中断处理完之后再回到之前执行的函数。
栈是从高到低分配,堆是从低到高分配。
我们一般说的堆栈指的栈。堆栈又分硬堆栈和软堆栈,硬堆栈即SP,从片内RAM的顶部向下生长。软堆栈在硬堆栈跟全局变量区之间的空间,C51函数调用通过R0-R7和栈来实现。
为什么单片机启动时,不需要用bootloader将代码从ROM搬移到RAM,而ARM则需要。这里我们可以先看看单片机程序执行的过程,单片机执行分三个步骤,取执行—分析指令----执行指令。取指令的任务是:根据PC的值从程序存储器读出指令,送到指令寄存器。然后分析执行执行。这样单片机就从内部程序存储器去代码指令,从RAM存取相关数据。要知道RAM取数的速度是远高于ROM的,但是单片机因为本身运行频率不高,所以从ROM取指令慢并不影响。而ARM不同,cpu运行的频率高,远大于从ROM读写的速度,所以一般有操作系统,都需要将代码部分拷贝到RAM中再执行。
再来看一个网上很流行的经典例子:
main.cpp
int a = 0; 全局初始化区
char *p1; 全局未初始化区
main()
{
int b; //栈
char s[] = “abc”; //栈
char *p2; //栈
char *p3 = “123456”; //123456/0在常量区,p3在栈上。
static int c =0; //全局(静态)初始化区
p1 = (char *)malloc(10); //堆
p2 = (char *)malloc(20); //堆
}
不知道你是否有点明白了,堆和栈的第一个区别就是申请方式不同:栈(英文名称是stack)是系统自动分配空间的,例如我们定义一个 char a;系统会自动在栈上为其开辟空间。而堆(英文名称是heap)则是程序员根据需要自己申请的空间,例如malloc(10);开辟十个字节的空间。由于栈上的空间是自动分配自动回收的,所以栈上的数据的生存周期只是在函数的运行过程中,运行后就释放掉,不可以再访问。而堆上的数据只要程序员不释放空间,就一直可以访问到,不过缺点是一旦忘记释放会造成内存泄露。
网上一个很好的比喻,摘抄下来,以便理解:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。
总结:
其实堆栈就是单片机中的一些存储单元,这些存储单元被指定保存一些特殊信息,比如地址(保护断点)和数据(保护现场)。
如果非要给他加几个特点的话那就是:1、这些存储单元中的内容都是程序执行过程中被中断打断时,事故现场的一些相关参数。如果不保存这些参数,单片机执行完中断函数后就无法回到主程序继续执行了。
2、这些存储单元的地址被记在了一个叫做堆栈指针(SP)的地方。
参考资料: 单片机中堆栈的概念,单片机什么是堆栈?堆栈有何作用
说到内存管理大家会可能想到malloc和free函数。
在讲这两个函数之前,我们先来复习栈(stack)和堆(heap)的概念。
我们单片机一般有个启动文件,拿STM32F103来举例。
这个Stack_Size就是栈大小,0x00000400就是代表有1K(0x400/1024)的大小。
那这个栈到底用来干嘛的呢?
比如说我们函数的形参、以及函数里定义的局部变量就是存储在栈里,所以我们在函数的局部变量、数组这些不能超过1K(含嵌套的函数),否则程序就会崩溃进入hardfaul。
除了这些局部变量以外,还有一些实时操作系统的现场保护、返回地址都是存储在栈里面。
还有一点题外话,就是栈的增长方向是从高地址到低地址的,这个用得不多,暂时不需要去深究。
malloc()函数动态分配的内存就属于堆的空间。
同样,在单片机启动文件里也有对堆大小的定义。
0x00000200就是代表有512个字节。
这意味着如果你用malloc()函数,那么最大分配的内存不能大于512字节,否则程序会崩溃。
网上很多文章说,全局变量和静态变量是放在堆区。
但是我做了实验,把堆的空间大小设置成0,程序正常运行并无影响。
这说明我们平时定义的全局变量、静态变量是不存放在堆的,而是堆栈以外的另外一篇静态空间区域,个人理解,如果有误请指正。
Ok,那么我们简单了解了堆和栈的概念,也知道malloc()函数分配的是堆的空间。
那么下面,我们探讨一个问题,有现成的动态分配内存函数malloc(),为什么单片机却很少用,为什么还要自己去做内存管理(自己写代码实现malloc()和free()等函数)?
malloc()函数经过成千上万网友验证,很容易出问题,所以一般单片机开发没人敢用,除非是…。
而上位机很多就会用,因为lib库里有写好的内存管理的算法,并不适用于单片机。
malloc()用于单片机主要问题体现在容易产生内存碎片。
内存碎片是什么?
内存碎片就是分配了内存空间,但是未被使用的部分。
比如说你用malloc(1)分配了1个字节,但实际给你分配了8个字节的空间,剩余7个就是内存碎片。
那内存碎片是怎么产生的呢?
我们在给p1和p2分配的时候,明明只分配1个字节,实际却分配了8个字节的空间,在释放前这7个字节都不能再被分配,相当于7个字节空间就浪费了。
这是其中一个产生碎片的方式,除此以外,还有别的方式会产生。
比如说你第一次连续申请了2个空间,第一块是1个字节,第二块也是1个字节。
理论上分配的空间地址都是连续的,但是中间产生7个字节内存碎片,分配两块的话就是14个字节。
当把第一块1个字节释放以后,第二块1个字节的空间还没释放。
这样相当于第一块的空间只能用来分配1个左右字节的空间了(有可能还可以分配2-6个字节的),具体要看Malloc()函数分配算法。
但可以肯定的是,不能分配像10个字节这么大的空间,那这块空间的应用范围就会缩小了很多。
如果一个程序分配很多1个字节这种小空间,那后面整个内存块会有非常多这种碎片叠加。
最后会导致,明明有很多空闲内存,但是总是分配失败,甚至导致程序崩溃。
所以,这就是要自己写内存管理的原因,就是要解决内存碎片这种痛点。
内存管理由很多不同的子功能组成,比如说动态内存分配算法、内存释放等等。
但是内存管理做起来是比较复杂的,涉及到数据结构和一些小算法。
有些高端的单片机为了帮工程师解决繁琐的内存管理代码,就内置了MMU(内存管理单元)模块。
不过大多数单片机都没有,我自己也没用过,没有的就要自己写代码去实现内存管理。
内存管理可以说是实时操作系统和自己写程序架构的刚需,操作系统一般有自带不用自己写。
拿我们wifi报警主机这个项目为例。
1.用于任务灵活创建
我们的主机自己写了一个小系统,有涉及到任务创建和调度。
我们在创建任务的时候就非常不灵活,需要手工去调整内核头文件的任务数量。
最理想的状态是系统内核文件不用修改任何东西,实际上没动态内存分配根本做不到。
我们这个架构很多产品都能用,每个产品功能不一样,所以任务数量也不一样。
如果有动态内存分配,就可以给大家灵活地创建自己产品需要的任务,而不用手动改,甚至我都可以把架构的代码都封装成lib,直接提供函数接口给不同的工程师使用。
2.用于不确定数量的临时数据
比如说我们主机有配对功能,就是可以通过无线通讯去学习探测器。
然后我们设置菜单有个探测器列表,列表会显示所有已配对的探测器。
如果要把全部已经配对的探测器都显示出来,比如说我主机总共支持配对20个探测器。
最惨的是,还需要定义成静态的,不然下次进入这个函数,数据又丢了。
而如果我不在这个菜单的时候,实际上这块内存是浪费了的,如果有动态内存分配,那绝对是相见恨晚。
如果你还不会,赶紧学,迟早用得上!
以前我就在网上找了很多资料和例程,一边找一边骂,结果还是以失败告终。
这块是我一直想突破,一直无法突破的痛,以前也做过,但是一直无法很好地解决碎片问题。
最近运气好,经过一个高手推荐,在Github上嫖到了大神写的内存管理代码。
代码给你没用,知识嘛,学到才是自己的,哈哈。
下载下来,先深度研究,吃透以后改了个小BUG和一些细节代码,搞成Keil能编译的版本。
测试平台是我们的wifi报警项目硬件,基于STM32F103。
是不是有种头皮发麻的感觉?那就对了! 码农的脑子从来没有舒服过。
下面是测试数据(有点长,只截取了3/4):
密密麻麻的数据上一行是地址,为了方便调试和显示,我限制了最大只能分配120个字节,然后地址一个字节够用,就把高3个字节地址去掉了。
Ok,下面进入本篇文章高潮部分,算法如何实现?
1.算法原理
先定义一个很大的数组,你最大支持多大内存分配,就定义多大的数组,比如说我目前最大支持120个字节,MEM_SIZE就是120。
2.数组存储方式
我们每一次分配内存给这块内存做一张”表格”,”表格”里面记录这块内存的信息。
表格用程序来表示就是结构体,因为只有结构体能表示不同类型数据的集合。
这个“表格”一共会记录内存块3个信息:内存块数据的存储地址、内存块大小、内存块ID。
这3个信息是为后面写动态内存分配和释放内存函数作铺垫的,目的是更好地寻找到指定的内存块。
相当于每动态分配一块内存,都会在内存池(数组)里面分配两块内存空间。
一块内存是用来存储这块内存唯一的表格(结构体),根据结构体成员计算的话就是固定的8个字节。
另一块内存就是实际你需要分配的内存空间大小,最终你的数据就是存在这块内存里。
比如说,当我调用动态内存分配函数mem_malloc(10),分配了10个字节的内存空间,并且全部写入值1。
然后,我再调用动态内存分配函数mem_malloc(8),又分配了8个字节的内存空间,并且全部值写入2。
经过这两次分配内存以后,不知道你发现了没有。
内存是连续分配的,没有碎片。
内存低地址空间保存内存块信息(“表格”),高地址空间分配用户的缓存,有没有感觉跟前面堆栈的使用一样?都是往内存空间中间分配。
数据的低位存储在内存的低地址中数据的低位存储在内存的低地址中(即小端模式)
3. mem_malloc()动态内存分配函数
mem_malloc()函数就是以上面说的分配原理,然后用代码去实现,源代码如下:
这个函数相对简单,注释也详细,大家可以自己研究下。
4.mem_free()内存释放函数
真正的难点也就是在这个函数,主要是去碎片的算法。
实现原理:
比如我现在动态分配了3块内存空间,每个内存块对应信息如下。
内存块1:
内存地址-94 00 00 20(十六进制),转换成高位在前就是0x20000094
内存大小-0a 00,换算成10进制就是10个数据,代表缓存区大小是10个字节
内存ID-01 00,每个内存块ID都不同,自动递增
缓存区-0x20000094这个地址存储的数据,我程序初始化为全是1。
内存块2:
内存地址- 8c 00 00 20 (十六进制),转换成高位在前就是0x2000008c
内存大小- 08 00,换算成10进制就是8个数据,代表缓存区大小是8个字节
内存ID-02 00,每个内存块ID都不同,自动递增
缓存区-0x2000008c这个地址存储的数据,我程序初始化为全是2。
内存块3:
内存地址- 78 00 00 20 (十六进制),转换成高位在前就是0x20000078
内存大小- 14 00,换算成10进制就是20个数据,代表缓存区大小是20个字节
内存ID-03 00,每个内存块ID都不同,自动递增
缓存区-0x20000078这个地址存储的数据,我程序初始化为全是3。
Ok,这个时候我调用内存释放函数mem_free(2),把内存块2的空间释放掉。
从图中可以看出,内存块2的内存表格信息和缓存区的数据都被内存块3替代了。
所以mem_free(2)函数实现步骤大概如下:
第一步:先通过ID找到要释放的内存块表格信息,表格信息里有缓存区的地址。
第二步:通过内存块2的信息可以计算出内存块3的表格地址。
第三步:把内存块3的缓存区数据迁移并覆盖到内存块2的缓存区 (也就是8个字节)。
第四步:内存块3往内存空间高字节地址迁移8个字节(内存块2缓存区大小)后,会多8个字节数据出来,值为3,把这8个字节数据清零。
第五步:把内存块2的表格信息替换成内存块3的表格信息
第六步:更新内存块3表格信息里的缓存区地址改成最新的地址。
第六步:最后把原来存储内存块3表格信息地址的数据清掉,因为内存块3表格信息此时已在内存块2表格的位置。
这个步骤,估计大家已经看晕了。
这个不是写给你现在看的,想要理解这个代码,最好就是用串口把数据打印出来,一用看数据一边用st-link仿真分析程序。
如果没思路,再看我这个流程。
实现思路最重要,有这个思路完全可以自己写代码去实现。
至此,整个内存管理分析分享到此结束。
这个内存管理的代码还是需要进一步优化才能真正用在项目上。
比如说:
1.现在动态分配内存是返回内存块ID,实际最好返回分配出来的缓存区地址。
2.释放内存是传递内存块ID,实际最好传递缓存区地址。
3.分配和释放内存前要进入临界
这个就靠大家自己去优化了,我会把优化好的版本共享给我们的学员。
如果要这个版本的源代码,可以找无际拿。
最后,目前我发现这个内存管理代码唯一不足的地方就是每分配一块内存都要额外增加8个字节来存储内存块表格信息。
源代码是12个字节,被我干掉了4个,实际如果单次分配不超过256个字节,6个字节就够用)。
不过,这算是我目前见过最好的了,如果还有更好的,麻烦分享给我,感谢!
注:本节引用自: 一文搞懂栈(stack)、堆(heap)、单片机裸机内存管理malloc
作者:无际单片机编程