1、程序运行为什么需要内存
1.1、计算机程序运行的目的
(1)程序的目的是为了去运行,程序运行是为了得到一定的结果。
(2)计算机程序 = 代码 + 数据。计算机程序运行完得到一个结果,就是说
代码 + 数据 (经过运行后) = 结果。
(3)从宏观上来理解,代码就是动作,就是加工数据的动作;数据就是数字,就是被代码所加工的东西。
(4)那么可以得出结论:程序运行的目的不外乎2个:结果、过程
- 用函数来类比:函数的形参就是待加工的数据(函数内还需要一些临时数据,就是局部变量),函数本体就是代码,函数的返回值就是结果,函数体的执行过程就是过程。
- 返回值是void类型的就是更在意过程,不那么在意结果。
1.2、计算机程序的运行过程
计算机程序的运行过程,其实就是程序中很多个函数相继运行的过程。程序是由很多个函数组成的,程序的本质就是函数,函数的本质是加工数据的动作。
1.3、冯诺依曼结构和哈佛结构
(1)概念
- 冯诺依曼结构是:数据和代码放在一起。
- 哈佛结构是:数据和代码分开存在。
- 什么是代码:函数
- 什么是数据:全局变量、局部变量
(2)举例说明
- 在S5PV210中运行的linux系统上,运行应用程序时:这时候所有的应用程序的代码和数据都在DRAM,所以这种结构就是冯诺依曼结构;
- 在单片机中,我们把程序代码烧写到Flash(NorFlash)中,然后程序在Flash中原地运行,程序中所涉及到的数据(全局变量、局部变量)不能放在Flash中,必须放在RAM(SRAM)中。这种就叫哈佛结构。
(3)哈佛结构和冯诺伊曼结构是由硬件设计决定的,而不是由操作系统决定的。
1.4、动态内存DRAM和静态内存SRAM
略
1.5、总结
(1)内存是用来存储可变数据的,数据在程序中表现为全局变量、局部变量等(在gcc中,其实常量也是存储在内存中的)(大部分单片机中,常量是存储在flash中的,也就是在代码段)。
(2)程序中需要数据,数据的存储需要内存。
1.6、如何管理内存
(1)对于计算机来说,内存容量越大则可能性越大(能干的事越多),所以大家都希望自己的电脑内存更大。我们写程序时如何管理内存就成了很大的问题。如果管理不善,可能会造成程序运行消耗过多的内存,这样迟早内存都被你这个程序吃光了,当没有内存可用时程序就会崩溃。所以内存对程序来说是一种资源,所以管理内存对程序来说是一个重要技术和话题。
(2)有操作系统和无操作系统
- 操作系统掌握所有的硬件内存,因为内存很大,所以操作系统把内存分成1个1个的页面(其实就是一块,一般是4KB),然后以页面为单位来管理。页面内用更细小的方式来以字节为单位管理。操作系统内存管理的原理非常麻烦、非常复杂、非常不人性化。那么对我们这些使用操作系统的人来说,其实不需要了解这些细节。操作系统给我们提供了内存管理的一些接口,我们只需要用API即可管理内存。
- 在没有操作系统(其实就是裸机程序)中,程序需要直接操作内存,编程者需要自己计算内存的使用和安排。如果编程者不小心把内存用错了,错误结果需要自己承担。
(3)不同的语言提供了不同的操作内存的接口
-
汇编语言:根本没有任何内存管理,内存管理全靠程序员自己,汇编中操作内存时直接使用内存地址(譬如0xd0020010),非常麻烦。
-
C语言:C语言中编译器帮我们管理直接内存地址,我们都是通过编译器提供的变量名等来访问内存的,操作系统下如果需要大块内存,可以通过API(malloc free)来访问系统内存。裸机程序中需要大块的内存需要自己来定义数组等来解决。
-
C++语言:C++语言对内存的使用进一步封装。我们可以用new来创建对象(其实就是为对象分配内存),然后使用完了用delete来删除对象(其实就是释放内存)。所以C++语言对内存的管理比C要高级一些,容易一些。但是C++中内存的管理还是靠程序员自己来做。如果程序员new了一个对象,但是用完了忘记delete就会造成这个对象占用的内存不能释放,这就是内存泄漏。
-
Java / C#等语言:这些语言不直接操作内存,而是通过虚拟机来操作内存。这样虚拟机作为我们程序员的代理,来帮我们处理内存的释放工作。如果我的程序申请了内存,使用完成后忘记释放,则虚拟机会帮我释放掉这些内存。听起来似乎C# java等语言比C/C++有优势,但是其实他这个虚拟机回收内存是需要付出一定代价的,所以说语言没有好坏,只有适应不适应。当我们程序对性能非常在乎的时候(譬如操作系统内核)就会用C/C++语言;当我们对开发程序的速度非常在乎的时候,就会用Java/C#等语言。
2、位、字节、半字、字的概念和内存位宽
2.1、什么是内存
(1)硬件角度
- 内存实际上是电脑的一个配件(一般叫内存条)。根据不同的硬件实现原理还可以把内存分成SRAM和DRAM。
- DRAM又有好多代,譬如最早的SDRAM,后来的DDR1、DDR2·····、LPDDR(low power低功耗内存,一般用于手机这样的产品)。
(2)逻辑角度
- 内存可以随机访问(随机访问的意思是只要给一个地址,就可以访问这个内存地址)
- 可以读写(当然了逻辑上也可以限制其为只读或者只写)
- 内存在编程中天然是用来存放变量的
- 从逻辑角度来讲,内存实际上是由无限多个内存单元格组成的,每个单元格有一个固定的地址叫内存地址,这个内存地址和这个内存单元格唯一对应且永久绑定。
- 逻辑上来说,内存可以有无限大(因为数学上编号永远可以增加,无尽头)。
- 现实中实际的内存大小是有限制的,譬如32位的系统(32位系统指的是32位数据线,但是一般地址线也是32位,这个地址线32位决定了内存地址只能有32位二进制,所以逻辑上的大小为2的32次方)内存限制就最大4G。
2.2、位和字节
(1)内存单元的大小单位有4个:位(1bit) 字节(8bit) 半字 字 双字
(2)在所有的计算机、所有的机器中(不管是32位系统还是64位系统),位永远都是1bit,字节永远都是8bit。
2.3、字、半字和双字
(1)字、半字和双字是数据存储和处理的基本单位。
(2)字、半字、双字这些单位具体有多少位是依赖于平台的。32位数据总线的字就是32位,64位的数据总线的字就是64位。
(3)半字永远是字的一半,双字永远是字的两倍。
2.4、内存位宽
(1)内存位宽(Memory Bus Width)是指内存与处理器之间数据传输通道的宽度,通常以位(bit)为单位。它决定了每次数据传输时能够同时传输的数据量。位宽越大,每次传输的数据量就越多,数据传输速度也就越快。
(2)内存芯片之间是可以并联的,通过并联后即使8位的内存芯片也可以做出来16位或32位的硬件内存。
3、内存编址与寻址、内存对齐
3.1、内存编址
(1)内存在逻辑上就是一个一个的格子,这些格子可以用来装变量。每个格子有一个编号,这个编号就是内存地址,内存地址和格子的空间是一 一对应且永久绑定的。这就是内存的编址方法。
(2)在程序运行时,计算机中CPU实际只认识内存地址,而不关心这个地址所代表的空间在哪里,怎么分布这些实体问题。因为硬件设计保证了按照这个地址就一定能找到这个格子,所以说内存单元的2个概念:地址和空间是内存单元的两个方面。
(3)内存编址是以字节为单位的
3.2、内存和数据类型的关系
(1)C语言中的基本数据类型有:
- char
- short(半个int)
- int
- ong(有时是一个int,有时是两个int)
- float
- double
(2)一般情况下,int 整形的位数和CPU本身的数据位宽是一样的,譬如32位的CPU,int就是32位的。
(3)数据类型和内存的关系就在于:
- 数据类型是用来定义变量的,而这些变量需要存储、运算在内存中。所以数据类型必须和内存相匹配才能获得最好的性能,否则可能不工作或者效率低下。
- 在32位的系统中,数据总线是32位的,这样的硬件配置天生适合处理32位的变量,读写效率最高。
- 在很多32位环境下,我们定义bool类型变量,实际只需要1个bit就够了,但是可能会用int来实现bool (int BoolVar) 。这么做实际上浪费了31位的内存,但是好处是效率高。
- 实际编程时要以省内存为主还是要以运行效率为主?答案是不定的,看具体情况。很多年前内存很贵机器上内存都很少,那时候写代码以省内存为主。现在随着半导体技术的发展内存变得很便宜了,现在的机器都是高配,不在乎省一点内存,而效率和用户体验变成了关键。所以现在写程序大部分都是以效率为重。
(4)注意:int 类型的大小并不完全由硬件平台的位数决定,而是由C语言标准和编译器共同决定。在PC系统中,int 通常为32位,即使在64位系统上也是如此,这是为了保持跨平台的兼容性。
3.3、内存对齐
(1)定义:内存对齐是指数据在内存中的起始地址必须是某个特定值(通常是数据类型大小的倍数)的整数倍。例如,对于一个32位的整数(int类型),它的地址通常应该是4字节对齐的,即地址值是4的倍数。这是因为在计算机硬件层面,内存是以块的形式进行访问的,当数据对齐时,CPU可以更高效地读写这些数据。
(2)对齐单位:对齐单位通常是数据类型大小或者某个特定的值。例如,在一些32位系统中,常见的对齐单位有1字节、2字节、4字节等。对于基本数据类型,像char(1字节)、short(2字节)、int(4字节)等,它们的对齐单位通常是其自身大小。而对于结构体等复杂数据类型,其对齐单位可能是其成员中最大对齐单位的倍数。
3.4、配置结构体单字节对齐
(1)C语言中,配置单字节对齐主要有两种常用方法:使用#pragma pack
指令和使用__attribute__((packed))
属性。
(2)在结构体定义之前使用#pragma pack(1)
,可以使得该结构体及其后的结构体(直到遇到新的#pragma pack
指令)都按照单字节对齐。
/** struct Example的成员将不会插入任何填充字节,紧密排列。* char成员占1字节,int成员紧跟其后占4字节,short成员再往后占2字节,整个结构体大小为7字节。*/
#pragma pack(1)
struct Example{char c;int i;
};
#pragma pack() // 恢复默认对齐方式
struct Another{double d;char ch;
};
(3)__attribute__((packed))
是GCC编译器提供的一个属性,用于指定结构体或联合体成员之间不插入填充字节,实现单字节对齐。
struct Example{char c;int i;short s;
}__attribute__((packed));
(4)配置单字节对齐可能会提高程序的内存使用效率,减少内存浪费。但是,它也可能会降低内存访问效率。因为现代CPU在访问对齐的数据时速度更快,当数据单字节对齐时,可能会跨越多个内存块,导致CPU需要进行多次访问和数据拼接,从而降低程序的运行速度。
4、C语言如何读写内存
4.1、C语言对内存地址的封装
4.1.1、变量与内存
(1)在C语言中,变量是对内存地址的一种封装。例如:
- int a; 这行代码让编译器为我们申请了一个 int 类型的内存格子。这个内存格子在32位系统中的长度是4字节,它有一个确定的地址,但这个地址编译器知道就行,我们无需关心。编译器将符号 a 与这个内存格子绑定在一起。
- a = 5; 编译器会将值 5 存入与符号 a 绑定的内存格子中。
- a += 4; 这等效于 a = a + 4; 。编译器会先读取 a 原来格子中的值,将其与 4 相加,然后将结果写回 a 对应的内存格子。
4.1.2、数据类型的本质
(1)C语言中数据类型的本质含义是:它决定了内存格子的长度和解析方法。
- 数据类型规定了内存格子的长度。例如,对于内存地址 0x30000000 ,原本它只代表1个字节的长度。但当我们给它一个类型 int 时,它就有了长度 4 字节。这意味着从 0x30000000 开始的连续4个字节( 0x30000000、0x30000001、0x30000002、0x30000003 )构成了一个 int 类型的格子。
- 数据类型还决定了内存单元格子中二进制数的解析方法。以内存地址
0x30000000
为例,若视为int
类型,其对应的4字节二进制数按int解析;若视为float
类型,则这4字节二进制数按float解析。
(2)普通变量、指针变量和数组
(1)关于数据类型,类型都只是规定了内存格子的长度和解析方法而已。
- 普通类型:int、float等。
- 指针类型: int *、float * 等
- 数组:int a[10]; floatb[10]等。
(2)举例
- int a; 长度4字节,变量a地址 &a 。
- int b[10]; 长度40字节,数组首元素的地址 b / &b[0] ,数组的地址 &b 。
- int *p; 长度4字节,变量p的地址&p 。
4.1.3、强制类型转换
强制类型转换会改变变量的类型,从而影响其存储和解析方式。
例如:int iVar;
- int *p = (int *)iVart; 这将iVar的二进制值 解析为int *类型,赋值给变量p。
- char cVar = (char)Var; 这将iVar的二进制值 解析为char类型,赋值给变量cVar。
4.1.4、函数名与内存地址
在C语言中,函数是对一段代码的封装。函数名的实质是这一段代码的首地址,因此函数名本质上也是一个内存地址。
4.2、内存管理之数据结构
(1)数据结构的意义:数据结构就是研究数据如何组织(在内存中排布),如何加工的学问。
(2)最简单的数据结构:数组
- 为什么要有数组?因为程序中有好多个类型相同、意义相关的变量需要管理,这时候如果用单独的变量来做程序看起来比较乱,用数组来管理会更好管理。
- 数组的优势:数组比较简单,访问用下标,可以随机访问。
- 数组的缺陷
- 数组中所有元素类型必须相同;
- 数组大小必须定义时给出,而且一旦确定不能再改。
(3)结构体
- 结构体发明出来就是为了解决数组的缺陷之一:数组中所有元素类型必须相同
4.3、结构体内嵌指针实现面向对象
(1)面向过程与面向对象
- 总的来说:C语言是面向过程的,但是C语言写出的linux系统是面向对象的。
- 非面向对象的语言,不一定不能实现面向对象的代码。只是说用面向对象的语言来实现面向对象要更加简单直观。
- 用C++、Java等面向对象的语言来实现面向对象简单一些,因为语言本身帮我们做了很多事情;但是用C来实现面向对象比较麻烦,看起来也不容易理解。
(2)面向对象的核心概念
- 类封装了数据和操作数据的方法,它是一种抽象的数据类型,用于定义一组具有相同属性和行为的对象的模板。是实现封装、继承和多态等面向对象特性的基础。
- 对象是类的一个实例,是类的具体表现形式。它是根据类的定义创建出来的具体实体,具有类定义的属性和方法,并且可以存储具体的值。
- 对象的属性是对象所具有的数据特征,用于描述对象的状态。属性通常是一些变量,存储了对象的具体信息。
- 方法是类中定义的函数,用于实现对象的行为。方法可以操作对象的属性,完成特定的功能。
(3)使用结构体就可以实现面向对象,面向对象中类包含属性(变量)和方法(函数)。如下所示:
- 结构体中的函数指针:类似于class中的方法。
- 结构体中的普通变量:类似于class中的属性。
struct s
{int age; // 普通变量void (*pFunc)(void); // 函数指针,指向 void func(void)这类的函数
};
(4)补充
- 函数指针定义: 指向函数的返回值类型 (*函数指针名)(参数类型1,参数类型2......)
- 指向函数的指针叫做函数指针,函数名可以赋值给函数指针,函数名就是函数在内存中的起始地址。举例:
#include "stdio.h" int add(int data1,int data2)
{int a = data1+data2; return a;
}int main(void)
{int (*pfun)(int,int) = NULL;int res = 0; pfun = add;// 通过指向函数指针的变量来调用函数res = (*pfun)(4,5);//res = add(4,5); printf("res = %d\n",res);return 0;
}
5、内存管理之栈
5.1、什么是栈
(1)栈是一种数据结构,C语言中使用栈来保存局部变量。
(2)栈是被发明出来管理内存的。
(3)栈管理内存的特点(小内存、自动化)
(4)栈和队列的对比
- 栈的特点是入口即出口,只有一个口,另一个口是堵死的,所以先进去的必须后出来。
- 队列的特点是入口和出口都有,必须从入口进去,从出口出来,所以先进去的必须先出来,否则就堵住后面的。
- 栈:先进后出 FILO first in last out
- 队列:先进先出 FIFO first in first out
5.2、栈的应用举例:局部变量。
(1)C语言中的局部变量是用栈来实现的。
(2)我们在C中定义一个局部变量时,编译器会在栈中分配一段空间给这个局部变量用。分配时栈顶指针会移动给出空间,给局部变量用的意思就是,将栈内存的内存地址和我们定义的变量关联起来,对应栈的操作是入栈。
注意:这里栈指针的移动和内存分配是自动的(栈自己完成,不用我们写代码去操作)。
(3)函数退出的时候,局部变量要死亡,对应栈的操作是出栈。出栈时也是栈顶指针移动将栈空间中与变量关联的空间释放。这个动作也是自动的,也不用人写代码干预。
(4)栈的优点:栈管理内存,好处是方便,分配和最后回收都不用程序员操心,C语言自动完成。
(5)分析:C语言中,定义局部变量时如果未初始化,则值是随机的,为什么?
定义局部变量,其实就是在栈中通过移动栈指针来给变量提供一个内存空间和这个局部变量名绑定。因为这段内存空间在栈上,而栈内存是反复使用的(脏的,上次用完没清零的),所以说使用栈来实现的局部变量定义时如果不显式初始化,值就是脏的。
5.3、栈的约束
(1)栈是有大小的,要避免栈的溢出,所以我们在C语言中定义局部变量时不能定义太多或者太大。譬如不能定义局部变量时 int a[10000]; 使用递归来解决问题时一定要注意递归收敛。
(2)相关补充
- C语言中,全局变量和静态变量都是存储在静态存储区的,他们在分配的时候都被系统默认初始化为0;而局部变量是在栈上分配内存的,如果不对它们进行初始化,那么他们可能是任意的随机值。
- 静态存储区(Static Storage Area)是程序运行时分配给静态变量和全局变量的内存区域。这些变量在程序启动时被分配内存,并在程序结束时释放内存。与栈区和堆区不同,静态存储区的内容在程序的整个生命周期内都保持存在。
6、内存管理之堆
(1)堆内存的定义和管理
- 堆内存是程序运行时用于动态分配内存的区域。与栈内存不同,堆内存的分配和释放不是由程序的执行流程自动完成的,而是由程序员通过特定的函数或方法手动控制。
- 在C语言中,使用
malloc
、free
进行内存分配和释放的过程,通常被称为堆管理。这是因为这些操作涉及到程序的堆内存,而不是栈内存。 - 堆(heap)是一种内存管理方式。堆内存是操作系统划归给堆管理器(通常是标准C库的一部分)来管理的,然后向使用者提供API(如
malloc
和free
)来使用堆内存。
(2)堆内存的使用场景
- 大型数据结构:例如动态数组、链表、树等。这些数据结构的大小通常在运行时确定,因此需要使用堆内存来动态分配空间。
- 全局数据:如果程序需要在多个函数之间共享数据,堆内存是一个很好的选择,因为它可以被多个函数访问。
(3)内存管理的复杂性
内存管理对操作系统来说是一件非常复杂的事情,因为首先内存容量很大,其次内存需求在时间和大小块上没有规律(操作系统上运行着的几十、几百、几千个进程随时都会申请或者释放内存,申请或者释放的内存块大小随意)。
- 内存碎片化:频繁的分配和释放可能导致内存碎片化,使得可用内存块变得零散,影响内存的利用率。
- 内存分配算法:操作系统和堆管理器通常会使用复杂的内存分配算法(如伙伴系统、SLAB分配器等)来优化内存分配的效率和减少碎片化。
- 多进程和多线程:在多进程和多线程环境中,内存管理需要确保线程安全和并发控制,以避免竞争条件和数据不一致。
(4)注意事项
- 堆内存的限制:虽然堆内存比栈内存灵活,但并不是无限的。如果程序过度使用堆内存,可能会导致系统资源耗尽,甚至引发内存不足的错误(如
malloc
返回NULL
)。 - 内存泄漏和错误:使用堆内存时,程序员需要特别小心管理内存的分配和释放。常见的问题包括内存泄漏(忘记释放内存)、重复释放(释放同一块内存多次)和野指针(使用已释放的内存)。
(5)堆管理内存的优缺点
- 优点
- 灵活性:可以根据需要动态调整大小。
- 缺点
- 申请及释放都需要手工进行,手工进行的含义就是需要程序员写代码明确进行申请malloc及释放free。如果程序员申请内存并使用后未释放,这段内存就丢失了(在堆管理器的记录中,这段内存仍然属于你这个进程,但是进程自己又以为这段内存已经不用了,再用的时候又会去申请新的内存块,这就叫吃内存),称为内存泄漏。在C/C++语言中,内存泄漏是最严重的程序bug,这也是别人认为Java/C#等语言比C/C++优秀的地方。
- 需要程序员去处理各种细节,所以容易出错,严重依赖于程序员的水平。
(6)C语言操作堆内存的接口
- 堆内存释放时最简单,直接调用free释放即可。
- void free(void *ptr);
- 堆内存申请时,有3个可选择的类似功能的函数:malloc, calloc, realloc。
- void *malloc(size_t size);
- void *calloc(size_t nmemb, size_t size); // nmemb个单元,每个单元size字节
- void *realloc(void *ptr, size_t size); // 改变原来申请的空间的大小
- 举例:譬如要申请10个int元素的内存
- malloc(40); malloc(10*sizeof(int));
- calloc(10, 4); calloc(10, sizeof(int));
- 数组定义时必须同时给出数组元素个数(数组大小),而且一旦定义再无法更改。在Java等高级语言中,有一些语法技巧可以更改数组大小,但其实这只是一种障眼法。它的工作原理是:先重新创建一个新的数组大小为要更改后的数组,然后将原数组的所有元素复制进新的数组,然后释放掉原数组,最后返回新的数组给用户;
- 堆内存申请时必须给定大小,然后一旦申请完成大小不变,如果要变只能通过realloc接口。realloc的实现原理类似于上面说的Java中的可变大小的数组的方式。