嵌入式八股之c语言内存管理相关
由gcc编译的C语言程序占用的内存分为哪几个部分
映射区 | 存储动态链接以及mmap函数进行的文件映射 |
堆区 | 提供程序员动态申请的空间 |
全局(静态区) | 存放全局变量和静态变量, 已初始化 的全局变量和静态变量、const型常量在一块区域(.data段), 未初始化的、初始化为0 的全局变量和静态局部变量在相邻的另一块区域(.bss段) |
常量区 | 字符串 、 数字 等常量存放在常量区。 const修饰的全局变量 存放在常量区 |
程序代码区 | 存放函数的二进制代码和字符常量 |
- 代码段和数据段属于静态内存,堆栈属于动态内存
- 静态内存的特点:各个变量的地址在编译期间就确定了,在程序运行中不再改变
- 动态内存的特点:在程序运行期间内存不固定
- 在可执行文件内不给BSS段分配存储空间,在 程序运行内存时再分配存储空间和地址。
堆区和栈区
栈区
- 栈有两种基本操作:入栈(push)和出栈(pop)。入栈是把一个 栈元素压入栈中,而出栈则是从栈中弹出一个栈元素。入栈和出栈都 靠栈指针(Stack Pointer,SP)来维护,SP会随着入栈和出栈在栈顶上下移动
- C语言函数中的局部变量、传递的实参、 返回的结果、编译器生成的临时变量都是保存在栈中的。在很多嵌入式系统的启动代码中,系统 一上电开始运行的都是汇编代码,在跳到第一个C语言函数运行之前, 都要先初始化栈空间。
- 栈的初始化其实就是栈指针SP的初始化,在Linux环境下,栈的起始地址一般就 是进程用户空间的最高地址,紧挨着内核空间,栈指针从高地址往低 地址增长
- Linux默认给每一个用户进程栈分配8MB大小的空间
- 每一个函数都会有自己专门的栈空间来保存这些 数据,每个函数的栈空间都被称为栈帧(Frame Pointer,FP)。每一 个栈帧都使用两个寄存器FP和SP来维护,FP指向栈帧的底部,SP指向 栈帧的顶部。
- 上一级函 数栈帧的起始地址,即栈底也会保存到当前函数的栈帧中,多个栈帧 通过FP构成一个链,这个链就是某个进程的函数调用栈。很多调试器 支持回溯功能,其实就是基于这个调用链来分析函数的调用关系的。
- 当要传递的参数个数小于4时,直接使用R0~R3寄存 器传递即可;当要传递的参数个数大于4时,前4个参数使用寄存器传 递,剩余的参数则压入堆栈保存。参数传递时按照从右到左的顺序 依次压入堆栈.
堆区
堆是Linux进程空间中一片可 动态扩展或缩减的内存区域,一般位于BSS段的后面。
- 堆内存是匿名的,不能像变量那样使用名字直接访问,一般通 过指针间接访问。
- 在函数运行期间,对函数栈帧内的内存访问也不能像变量那样 通过变量名直接访问,一般通过栈指针FP或SP相对寻址访问。
- 堆内存由程序员自己申请和释放,函数退出时,如果程序员没 有主动释放,就会造成内存泄漏。
- 在裸机环境下一片连续的堆内存空间,经过多次小块内存的申请 和释放后,就会造成内存碎片化,
- malloc()/free()函数的底层实现,其实就是通过系统调用brk向 内核的内存管理系统申请内存。内核批准后,就会在BSS段的后面留出 一片内存空间,允许用户进行读写操作
- 大于128KB,一般会通过mmap系 统调用直接映射一片内存,使用结束后再通过ummap系统调用归还这块 内存
堆内存分配
- 当用户申请一块内存时,内存分配器就根据申请的内存大小 从 bins 查 找 合 适 的 内 存 块 。 当 申 请 的 内 存 块 小 于 M_MXFAST 时 , ptmalloc分配器会首先到fast bins中去看看有没有合适的内存块,如 果没有找到,则再到small bins中查找
- 如果要申请的内存块大于512 字节,则直接跳过small bins,直接到unsorted bin中查找
- 内存分配器如果在unsorted bin中没有找到合适 大小的内存块,则会将unsorted bins中物理相邻的内存块合并,根据 合并后的内存块大小再迁移到small bins或large bins中
防止栈溢出
- 尽量不要在函数内使用大数组,如果确实需要大块内存,则可 以使用malloc申请动态内存。
- 函数的嵌套层数不宜过深。
- 递归的层数不宜太深。
堆区和栈区主要区别:
• 申请方式:栈区内存由系统自动分配和释放,函数结束时释放;堆区内存由程序员自己申请,并指明大小,用户忘释放时,会造成内存泄露,不过进程结束时会由系统回收
• 申请后系统的响应:栈只要有剩余空间就能提供内存,否则报异常;堆则要先在自由链表中找到足够大的空间再分配给程序 。
堆区和栈区出现数组越界,各自的表现如下:• 栈上数组越界:可能会覆盖其他变量或者返回地址等重要信息,导致程序崩溃或者被攻击。• 堆上数组越界:可能会破坏堆管理器的数据结构,导致后续分配或者释放出错或者失败。
• 空间大小:栈区空间通常有一个限制,比如8MB;堆区空间一般比较大,受限于操作系统的有效虚拟内存。
• 碎片问题:栈使用是先进后出的顺序,不会产生碎片;堆频繁地分配和释放可能会造成碎片,使得效率降低。
在堆区分别申请两个1个字节的内存,其内存空间不一定会连续。因为堆区的内存分配是在逻辑地址上是连续的,但在物理地址上是不连续的 。堆区的内存分配还要考虑自由链表中是否有足够大的空间 。如果没有,可能会导致分配失败或者产生碎片。
• 生长方向:栈向低地址扩展;堆向高地址扩展。
当在堆区和栈区出现数组越界时,它们表现出的情况有所不同。
堆区:
- 运行时错误(Runtime Error):在堆区动态分配的数组,如果越界访问了数组元素,通常不会导致直接的崩溃或错误。这是因为在堆区分配的内存是由程序员负责管理的,操作系统不会对越界进行保护。程序可能会继续执行,但是访问到越界的内存可能导致不可预测的行为,可能会覆盖其他内存区域的内容,导致程序在后续执行中出现错误。
- 内存损坏或泄漏:当在堆区进行数组越界访问时,可能会修改或破坏堆区中其他动态分配的内存块,导致内存损坏。另外,如果越界访问导致程序丢失对已分配内存的指针,可能会导致内存泄漏,因为程序无法正确释放这部分内存。
栈区:
- 栈溢出(Stack Overflow):在栈区分配的数组,如果越界访问了数组元素,可能会导致栈溢出。栈区是由编译器自动管理的,通常有固定的大小限制。当递归调用层数过多或者在函数内部使用大型数组时,栈的空间可能会耗尽,导致栈溢出错误,进而导致程序崩溃。
- 运行时错误(Runtime Error):与堆区类似,栈区中的数组越界访问不会直接导致程序崩溃。程序可能会继续执行,但是访问到越界的内存可能导致不可预测的行为,因为栈区的越界访问也会影响到栈帧中其他的局部变量和返回地址等。
在遇到数组越界问题时,不同编程语言和环境可能表现不同,有些语言可能会提供一定的越界检查机制,而有些则不会。为了避免这类问题,程序员应该在编程时注意数组的边界,并确保进行正确的边界检查,以保证程序的稳定性和安全性。
程序a调用程序b ,栈有什么变化
堆栈溢出一般是由什么原因导致的?
- 堆栈溢出一般包括堆内存溢出和栈内存溢出,两者都属于缓冲区溢出。
- 堆内存溢出可能是堆的尺寸设置得过小/动态申请的内存没有释放。
- 栈内存溢出可能是栈的尺寸设置得过小/递归层次太深/函数调用层次过深/分配了过大的局部变量。
静态链接和动态链接
静态链接
- 如果我们在项目中引用了库函数, 则在编译时,链接器会将我们引用的函数代码或变量,链接到可执行 文件里,和可执行程序组装在一起,这种库被称为静态库
- 缺点1: 如果在一个源 文件中我们定义了100个函数,而只使用了其中的1个,那么链接器在 链接时也会把这100个函数的代码指令全部组装到可执行文件中,这会 让最终生成的可执行文件体积大大增加,解决办法:我们在封装函数库时,将每个函数都 单独使用一个源文件实现,然后将多个目标文件打包即可
- 缺点2:如C标准库里的printf()函数, 可能多个程序都调用了它,链接器在链接时就要将printf的指令添加 到多个可执行文件中。在一个多任务环境中,当多个进程并发运行 时,你会发现内存中有大量重复的printf指令代码,很浪费内存资源
动态链接
- 动态库在编译阶段不参与链接,不会和可执行文件组 装在一起,而是在程序运行时才被加载到内存参与链接,因此又叫作 动态链接库。
- 优点:节省了内存资源:加载到内存的动态链接库可 以被多个运行的程序共享,使用动态链接可以运行更大的程序、更多 的程序,升级也更加简单方便。
- 在Linux环境下,当我们运行一个程序时,操作系统首先会给程序 fork一个子进程,接着动态链接器被加载到内存,操作系统将控制权 交给动态链接器,让动态链接器完成动态库的加载和重定位操作,最 后跳转到要运行的程序。动态链接器在C标准库中实现,是glibc的一 部分,主要完成程序运行前的动态链接工作。
动态链接器本身也是一个动态库, 即/lib/ld-linux.so文件。动态链接器被加载到内存后,会首先给自 己重定位,然后才能运行。像这种自己给自己重定位然后自动运行的 行为,我们一般称为自举
- 动态链接需要考虑的一个重要问题是加载地址。动态链接库的地址要根据进程地址空间 的实际空闲情况随机分配。很容易想到的一个方法就是装载时重定位。
- 想让我们的动态库放到内存的任何位置都可以运行,都可以被多个进程共享一种比较好的方法是将我们的动态库设计成与地址无关的代码,将指令中需要修改的部分(如对绝对地址符号的引用)分离出来,剩余的部分就和地址无关了,放到哪里都可以执行。以ARM平台为例,可 以采用相对寻址来实现
- 延迟绑定:程序在 运行时,并不急着把所有的动态库都加载到内存中并进行重定位。当 动态库中的函数第一次被调用到时,才会把用到的动态库加载到内存 中并进行重定位
- 问题1:但是当动态库作为第三方模块被不 同的应用程序引用时,库中的一些绝对地址符号(如函数名)将不可避免地被多次调用,需要重定位。动态库中的这些绝对地址符号,如何能做到同时被不同的应用程序引用
- 解决办法:每个应用程序将引用的 动态库(绝对地址)符号收集起来,保存到一个表中,程序在运行过程中需要引用这些符号时, 可以通过这个表查询各个符号的地址,这个表为全局偏移表(GOT)。
根据动态 库被加载到内存中的具体地址,更新GOT表中的各个符号(函数)的地 址。等下次该符号被引用时,程序可以直接跳到GOT表查询该符号的地 址,如果找到要调用的函数在内存中的实际地址,就可以直接跳过去 执行了
动态链接和静态链接,分别用在哪些场景下
1. 静态链接(Static Linking):
在静态链接中,链接器将所有需要的目标文件和库文件的代码合并到一个单独的可执行文件中。这意味着可执行文件独立于系统上的其他库文件,它包含了所有程序运行所需的代码和数据。当程序被静态链接后,它可以在其他没有相关依赖的系统上运行,因为所有依赖项都已包含在可执行文件中。
适用场景:
- 独立可执行文件:静态链接生成的可执行文件可以在目标系统上独立运行,无需依赖其他外部库文件。这对于需要分发给其他用户或部署到不同环境的应用程序很有用。
- 性能优化:静态链接可以在编译时将所有库文件和目标文件合并,这可能会提高程序的执行性能,因为在运行时不需要再进行动态加载和链接。
- 版本控制:使用静态链接可以确保应用程序使用特定版本的库,减少由于不同版本库文件导致的不兼容性问题。
2. 动态链接(Dynamic Linking):
在动态链接中,程序在运行时仅保留对外部库函数的引用,并没有将实际代码合并到可执行文件中。相反,程序在运行时会从系统共享库(动态链接库或共享对象文件)中加载所需的函数。这意味着同一共享库可以被多个程序共享,节省内存空间,并且库的更新只需要替换共享库文件而不需要重新编译程序。
适用场景:
- 共享库的使用:动态链接适用于使用大型通用库的情况,这样多个程序可以共享同一个库,减少内存占用和二进制文件大小。
- 库的更新和维护:动态链接使得库的更新更加方便,只需替换共享库文件,而不需要重新编译所有使用该库的程序。
- 节省内存空间:多个程序使用同一个共享库时,动态链接可以节省内存空间,因为共享库的代码只需要在内存中加载一次。总体而言,静态链接适用于需要独立分发的程序或对性能优化有较高要求的场景,而动态链接适用于共享大型通用库、节省内存和方便库的更新维护的场景。
编译时出现ld的报错,问题出在哪里
- 未找到目标文件:链接器无法找到需要合并的目标文件。请确保所有源文件都已正确编译为目标文件,并在编译命令中正确指定它们的路径。
- 重复定义符号:如果多个目标文件中出现了同名的全局变量或函数,链接器会报重复定义的错误。解决方法是确保每个符号只定义一次,并使用extern关键字声明其他地方引用的全局变量或函数。
- 缺少依赖库:如果程序依赖于外部库,但链接器无法找到所需的库文件或库函数,将会报错。此时,您可以通过在编译命令中添加库文件路径或使用-l参数指定库来解决问题。
- 缺少主函数:如果程序缺少main函数作为入口点,链接器将无法生成可执行文件。请确保每个源文件中都包含一个名为main的函数作为程序的入口。
内存泄漏
没有使用free()函数及时 地 将 这 块 内 存 归 还 给 内 存 分 配 器 ptmalloc 或 内 存 管 理 子 系 统 , ptmalloc和内存管理子系统就失去了对这块内存的控制权失去管理和追踪的这块内存,一直孤零零地躺在内存的某片区域,用户、内存分配器和内存管 理子系统都不知道它的存在,它就像内存中的一块漏洞,我们称这种 现象为内存泄漏。
泄漏原因
- 使用malloc,realloc函数后,没有通过free函数将内存释放掉
内存泄漏检测:Mtrace
它通过跟踪内存的使用记录 来动态定位用户代码中内存泄漏的位置。使用MTrace很简单,在代码 中添加下面的接口函数就可以了
#include<mcheck.h> void mtrace(void); void muntrace(void); //mtrace()函数用来开启内存使用的记录跟踪功能,muntrace()函 数用来关闭内存使用的记录跟踪功能
- 通过生成的日志文件mtrace.log来定位内存泄漏在程序中的位 置
Linux内核模块运行机制
而内核模块的运行不依赖C标准库,动态链接、重定 位过程需要内核自己来完成当我们使用insmod命令加载一个内核模块时,基本流程如下
- (1)kernel/module.c/init_module.
- (2)复制到内核:copy_module_from_user。
- (3)地址空间分配:layout_and_allocate。
- (4)符号解析:simplify_symbols。
- (5)重定位:apply_relocations。
- (6)执行:complete_formation。
malloc和new的区别
- 语法不同:malloc是C语言中的函数,需要手动指定分配内存的大小,并且返回的是void*类型的指针;而new是C++中的运算符,可以根据类型自动计算分配内存的大小,并且返回的是具体类型的指针。
- 构造函数的调用:使用malloc分配的内存只是简单的分配了一块内存空间,不会调用对象的构造函数;而使用new分配的内存会调用对象的构造函数,完成对象的初始化。
- 内存分配失败处理:malloc在内存分配失败时会返回NULL,需要手动判断是否分配成功;而new在内存分配失败时会抛出std::bad_alloc异常,可以使用try-catch来捕获异常。
- 内存释放方式不同:malloc分配的内存必须使用free函数手动释放;而new分配的内存可以使用delete运算符来释放,同时会调用对象的析构函数来完成资源的释放。
边学习边总结的嵌入式各种知识,八股,面经,量大管饱,最重要:免费开放,希望大家能共同进步。