FreeRTOS-ARM架构与程序的本质
本文将结合ARM架构全面阐述程序的本质(什么是栈/堆,全局变量如何存储),以及深入理解函数调用过程,理解局部变量为啥具有临时性,函数如何传参,如何返回值等等,这些知识是理解FreeRTOS内核本质必要条件,才能深入理解FreeRTOS的任务切换所涉及的内存操作的实质,其重要性也不言而喻了堆和栈说白了都只是内存(单片机上的SRAM)中的一块空闲的空间,只是他们的用法和位置有所区分。栈栈区是从高地址向
✅作者简介:嵌入式入坑者,与大家一起加油,希望文章能够帮助各位!!!!
📃个人主页:@rivencode的个人主页
🔥系列专栏:玩转FreeRTOS
💬保持学习、保持热爱、认真分享、一起进步!!!
目录
前言
本文将结合ARM架构全面阐述程序的本质(什么是栈/堆,全局变量如何存储),以及深入理解函数调用过程,理解局部变量为啥具有临时性,函数如何传参,如何返回值等等,这些知识是理解FreeRTOS内核本质的必要条件,才能深入理解FreeRTOS的任务切换所涉及的内存操作的实质,其重要性也不言而喻了
一.程序的编译链接
先看看在window系统下的程序编译链接过程
一个c语言的代码要转变成一个可执行程序要经过 编译——>链接——>执行三个步骤
1. 程序的翻译环境和执行环境
- 翻译环境:在这个环境中源代码(
文本代码
)被转换为可执行的机器指令(二进制代码
)。 - 执行环境:在这个环境下执行代码。
1.预编译处理
- 头文件的包含(将test.c所有引入的头文件里面的
内容
拷贝到test.c) - 删除test.c中的注释(使用空格替换)
- #define宏定义的替换
- 条件编译
2.编译
将C语言代码转化为汇编代码
- 语法分析
- 词法分析
- 语义分析
- 符号汇总
符号汇总包括全局变量和函数名
3.汇编
- 将汇编代码转化成二进制代码(指令)
- 形成符号表
4.链接
- 合并段表
- 符号表的合并和重定位
详情如图
在keil编译器的编译链接过程与上面类似
(1) 编译,MDK 软件使用的编译器是 armcc 和 armasm,它们根据每个 c/c++和汇编源文件编译成对应的以“.o”为后缀名的对象文件(Object Code,也称目标文件),其内容主要是从源文件编译得到的机器码(二进制代码),包含了代码、数据以及调试使用的信息;
(2) 链接,链接器 armlink 把各个.o 文件及库文件链接成一个映像文件“.axf”或“.elf”;
(3) 格式转换,一般来说 Windows 或 Linux 系统使用链接器直接生成可执行映像文件 elf后,内核根据该文件的信息加载后,就可以运行程序了,但在单片机平台上,需要把该文件的内容加载到芯片上,所以还需要对链接器生成的 elf 映像文件利用格式转换器fromelf 转换成“.bin”或“.hex”文件,交给下载器下载到芯片的 FLASH 或 ROM
中。
总结:
对于keil编译和链接所需要的工具以及生成的文件等会专门出一篇文章详细阐述,我们今天的重点就是了解:我们自己写的C语言代码或者汇编代码都只是文本代码我们看的懂但是计算机或者CPU却看不懂,所以需要编译器和链接器来编译链接这些文本文件,将它们合并成一个二进制(机器码)文件,因为CPU只认识二进制(01…),在window系统中就叫做.exe文件,而在keil编译转化成“.bin”文件或者“.hex”文件(他们之间的区别以后再讲),反正他们都是二进制文件包含CPU能够识别的二进制指令,还有一个需要了解在链接的时候编译器会统一给函数和全局变量分配地址。
二.ARM架构与汇编
“.bin”文件或者“.hex”文件的本质是将数据(二进制指令)烧录到开发板的flash(掉电数据不丢失相当于电脑中的硬盘),然后让CPU再去lash中读取二进制指令,CPU执行指令(就比如让CPU处理数据:1.将数据从内存中取出加载到CPU的寄存器中(暂存在cpu中的寄存器或高速缓存中),经过CPU的计算好的数据又重新加回内存。)
一个简单的代码就将flash与内存CPU的关系有大概的了解,后面就自然引申出CPU中的寄存器组以及常见的汇编指令。
1.Cortex-M3的寄存器组
CM3 拥有通用寄存器 R0‐R15 以及一些特殊功能寄存器。R0‐R12 是最“通
用目的”的,特殊功能寄存器有预定义的功能,而且必须通过专用的指
令来访问。
R0 - R3 : 子程序传递参数(函数传参保存参数)
R4 - R11:子程序保存局部变量
R12:子程序间的scratch寄存器,记为IP 暂存寄存器
R13:堆栈指针(别名SP):栈指针寄存器。在进入子程序时,和退出子程序时,值必须相等。(意思是调用函数申请栈帧(供函数使用的一段内存)退出函数释放栈帧)
R14:连接寄存器(别名LR):用于保存子程序的返回值,不像大多数其它处理器,ARM 为了减少访问内存的次数,把返回地址直接存储在寄存器中。这样足以使很多只有 1 级子程序调用的代码无需访问内存(堆栈内存),从而提高了子程序调用的效率。如果多于 1 级,则需要把前一级的 R14 值压到堆栈里。
通俗点讲:LR寄存器是用来保存函数的返回地址,在调用一个函数(上面所说的子程序)前保存该下一条指令的地址,执行完函数,要回过来继续执行下一条指令,如果只调用一次函数不需要将LR保存的返回地址入栈,如果调用的函数又去调用函数所以LR保存的最初的返回地址将会被覆盖,所以得将LR的值入栈,去保存新的返回地址。(大概有个概念就好后面会结合实际代码讲解保证你理解返回地址的作用,以及为什么当有多层调用关系的时候为什么要保存LR的值到内存(栈)中)
R15:程序计数寄存器(别名PC):指向当前的程序地址。如果修改它的值,就能改变程序的执行流,指向当前的正在运行的指令地址。(通俗点讲:程序运行到哪就指向哪个位置,改变它就能改变程序执行的位置)
2.ARM架构的AAPCS标准
规定了一些子程序间调用的基本规则,这些规则包括子程序调用过程中寄存器的使用规则,数据栈的使用规则,参数的传递规则。有了这些规则之后,单独编译的C语言程序就可以和汇编程序互相调用。
1.寄存器使用规则
-
1).子程序间通过寄存器R0~R3来传递参数。这时,寄存器R0~R3可记作a0~a3。被调用的子程序在返回前无需恢复寄存器R0~R3的内容。
-
2)在子程序中,使用寄存器R4~R11来保存局部变量。,则子程序进入时必须保存这些寄存器的值,在返回前必须恢复这些寄存器的值。在Thumb程序中,通常只能使用寄存器R4~R7来保存局部变量。
-
3)寄存器R12用作过程调用中间临时寄存器,记作IP。在子程序之间的连接代码段中常常有这种使用规则。
-
4)寄存器R13用作堆栈指针,记作SP。在子程序中寄存器R13不能用作其他用途。寄存器SP在进入子程序时的值和退出子程序时的值必须相等
-
寄存器R14称为连接寄存器,记作LR。它用于保存子程序的返回地址。==如果在子程序中保存了返回地址,寄存器R14则可以用作其他用途。
-
寄存器R15是程序计数器,记作PC。它不能用作其它用途
上面几条ARM寄存器使用规则十分重要,现在不理解不要紧后面会在代码中一一体现出来。
2.堆栈的使用
- sp指向最后一个压入的值,数据栈由高地址向低地址生长)类型,即满递减堆栈,并且对堆栈的操作是8字节对齐。所以经常使用的指令就有STMFD和LDMFD。
压栈PUSH:先让SP指针自减(空出内存),然后再存入数据
出栈POP:先读出SP指向的数据,然后再自减
3.参数传递规则
- 在函数传递参数时,将所有参数看作是存放在连续的内存字单元的字数据。然后,依次将各字数据传递到寄存器R0,R1,R2和R3中。如果参数多于4个,则将剩余的字数据传递到堆栈中。入栈的顺序与参数传递顺序相反,即最后一个字数据先入栈。
即函数传参时通过CPU的R0,R1,R2和R3来传递参数的,如果有更多参数即将参数压入栈中。
4.函数返回值
- 结果为一个32位整数时,可以通过寄存器R0返回;
- 结果为一个64位整数时,可以通过寄存器R0和R1返回;
- 结果为一个浮点数时,可以通过浮点运算部件的寄存器f0、d0或s0来返回;
- 结果为复合型浮点数(如复数)时,可以通过寄存器f0~fn或d0~dn来返回;
- 对于位数更多的结果,需要通过内存来传递。
3.常见汇编指令
1.读内存:LDR R0 [Addr]
- 2.写内存: STR R0 [SP,#4]
- 3.加/减指令: ADD R0,R1,R2
- 4.比较:CMP R0,R1
- 5.跳转指令:B/BL
- 6.Push/Pop
=堆的基本操作
堆操作就是对内存的读写操作,但是其地址由 SP(栈顶指针)给出。寄存器的数据通过 PUSH 操作存入堆栈,以后用 POP 操作从堆栈中取回。在 PUSH 与 POP 的操作中,SP 的值会按堆栈的使用法则自动调整,以保证后续的 PUSH 不会破坏先前 PUSH 进去的内容.
堆栈的功能就是把寄存器的数据放入内存,正常情况下,PUSH 与 POP 必须成对使用,而且参与的寄存器,不论是身份还是先后顺序都必须完全一致(高地址对应高标号的寄存器,低地址对应低标号的寄存器)。当 PUSH/POP 指令执行时,SP 指针的值也根着自减/自增。
注意:不管在寄存器列表中,寄存器的序号是以什么顺序给出的,汇编器都将把它们升序排序。然后 PUSH 指令按照从大到小的顺序依次入栈,POP 则按从小到大的顺序依次出栈。如果不按升序写寄存器,有些汇编器可能会给出一个语法错误(高地址对应高标号的寄存器,低地址对应低标号的寄存器
当参与的寄存器过多时,可以一次压栈几个寄存器的值,也可以一次出栈内存数据到寄存器中
三.程序地址空间
1.什么是堆\栈
堆和栈说白了都只是内存(单片机上的SRAM)中的一块空闲的空间,只是他们的用法和位置有所区分。
- 栈
栈区是从高地址向低地址方向生长:高地址是栈底,低地址是栈顶,也使用高地址空间在使用低地址空间。
但由起始地址(地址最低的地址),存放变量的字节地址是顺序且递增的
这就是为什么数组的元素是地址是递增的,虽然栈是从高地址向低地址方向生长,但是数组是整体在栈上开辟空间,数组的其他元素的地址是依次递增
栈:调用函数创建栈帧(供局部变量开辟空间…),退出函数释放栈帧即整块空间被系统回收,局部变量也随之销毁,(压入栈中的其他数据也随之销毁),则就是为什么局部变量具有临时性的原因
- 堆
堆是从地址向高地址方向增长
其实也只是一块空闲的空间,只不过与栈不同,malloc出来的空间称作为动态内存就是分配的堆上的空间,与栈的最大的区别就是动态内存由我们自己回收(C语言上的Free函数)。
至于为什么要有动态内存有什么好处?
以及malloc,free函数的用法?
请看——>《动态内存分配》
堆和栈由谁来分配?
我们可以来看看
上面只是截取了启动文件关于堆栈初始化的代码,详细启动文件分析后面会专门出一篇文章讲解,现在我们只要知道其实堆和栈的分配是由我们自己来决定的,所以你的程序能用多大栈和堆完成由自己掌控。
1.如何确定你需要多大的栈空间
1.主要根据你的程序中使用了多少局部变量(只要找到调用关系最深的调用链(函数调用时也要保存返回地址))来估计栈的大小。
2.根据SRAM的大小,栈的大小肯定不能超过内存(SRAM)的大小,因为SRAM还要包括堆的大小,全局变量,静态变量(static定义).
堆的话就看你要malloc多少空间
2.程序的组成
在工程的编译提示输出信息中有一个语句“Program Size:Code=xx RO-data=xx RW-data=xx ZI-data=xx”,它说明了程序各个域的大小,编译后,应用程序中所有具有同一性质的数据(包括代码)被归到一个域,程序在存储或运行的时候,不同的域会呈现不同的状态.
- Code:代码域
它指的是编译器生成的机器指令,这些内容被存储到 ROM 区(一般存储在flash中,在程序运行时CPU来取指)。
- RO-data:Read Only data,即只读数据域
它指程序中用到的只读数据,这些数据、被存储在 ROM 区(一般也是存储在flash之中),因而程序不能修改其内容。例如 C 语言中 const 关键字定义的变量就是典型的 RO-data。
有不清楚const、volatile、static等关键字的请看–>《static,const,volatile,extern,register关键字深入解析》
- RW-data:Read Write data,即可读写数据域
指初始化为“非 0 值”的可读写数据,程序刚运行时(在未调用main函数之前),这些数据具有非 0 的初始值,且运行的时候它们会常驻在RAM 区,因而应用程序可以修改其内容。例如 C 语言中使用定义的初始值非零的全局变量,以及C语言中用static关键字修饰的局部变量
- ZI-data:Zero Initialie data,即 0 初始化数据
指初始化为“0 值”的可读写数据域,它与 RW-data 的区别是程序刚运行时这些数据初始值全都为 0,而后续运行过程与 RW-data 的性质一样,它们也常驻在 RAM 区,因而应用程序可以更改其内容。例如 C 语言中使用定义的初始值为零或者为初始化的全局变量;
栈空间和堆空间也都是属于 ZI-data 区域的,这些空间都会被初始值化为 0 值。编译器给出的 ZI-data 占用的空间值中包含了堆栈的大小(经实际测试,若程序中完全没有使用 malloc动态申请堆空间,编译器会优化,不把堆空间计算在内)。
总结:
3.程序的存储与运行
- 为什么变量非得在SRAM内存中存储,为啥不直接存储在flash上
主要原因就是SRAM读写速度比ROM(flash)要快的多,而且写flash 的时候还要进行擦除,有些单片机甚至CPU取二进制指令是在内存中(先让生成的二进制指令存储在flash中,当程序要运行的时候将二进制指令全部拷贝到SRAM内存中共CPU取指,为的就是提高速度),而STM32f103系列CPU还是在flash中取指,可能是因为SRMA太小了才64kb大小。
- RW-data 和 ZI-data 它们仅仅是初始值不一样而已,为什么编译器非要把它们区分开
每当程序还未执行到main时,初始化不为0的全局变量应该已经有了初始值,但是全局变量也保存在内存(SRAM)当中,当开发板掉电就会丢失数据,所以为了实现程序刚运行时全局变量就有初始值,全局变量的初始值肯定不能保存在SRAM,一个很好的解决办法就是,先将初始化不为0的全局变量的初始值先保存在flash中(掉电不丢失数据),然后等程序刚开始运行时将全局变量的初始值一并拷贝到对应的全局变量所对应的SRAM区,则就实现了在调用main函数之前初始化不为0的全局变量已经有了初始值。
与初始化非零的全局变量不同,ZI-data(初始化为零或者未初始化的全局变量)并不会傻傻的在flash存储这些零,而是在程序在刚开始执行时,随便调用一个memset将这些全局变量全部初始化为零。
总结:
1.当程序未运行时RW-data(初始化不为零数据(全局变量,局部的静态变量))被存储在非易失存储器(flash)中,因而系统掉电后也能正常保存。但是当程序在运行状态的时候,由于运行速度的要求,这些数要被拷贝进内存中(RAM),但是掉电后这些数据会丢失。
2.ZI-data(初始化为零或者未初始化的全局变量在程序运行前在内存中全部初始化为零
所以说:程序在静止与运行的时候它在存储器中的表现是不一样的
程序在存储状态时,RO 节(RO section)及 RW 节都被保存在 ROM 区。当程序开始运行时,内核CPU直接从 ROM 中读取指令(在code-data中),并且在执行主体代码(执行main之前)前,会做两节事:
1.会先执行一段加载代码,它把 RW-data数据从 ROM 复制到 RAM,
2. 并且在 RAM 加入 ZI-data并将数据都被初始化为0。
4.启动文件_main函数执行过程
看看在进入main函数之前到底做了事情
在启动文件中调用_main函数。
_main函数调用__scatterload函数该函数会循环执行三次
- 第一次循环:给给定初值的全局变量赋初值,也就是将存储在flash的RW-data数据拷贝到SRAM中给有初始值的全局变量赋初始值。
- 第二次循环:清栈空间,将栈空间全部清零。
- 第三次循环:给未赋初值或者初始值为零的全局变量清零,
退出后返回到__scatterload中,由于达到了退出条件,则退出__scatterload函数到__main_after_scatterload中调用main函数最终去到C
语言的世界,这就解释了一执行到main中全局变量已经就有了初始值,是在这之前已经将全局变量进行初始化了
除此之外_main函数还会调用初始化堆栈的函数进行堆栈的初始化
_main执行过程我只是简单介绍_main函数做了那些事情,并没有分析它具体的汇编代码,详情请参考–>《MDK __main过程分析》
我基本也是参考这篇博文来写的_main执行过程,我觉得写的非常详细,非常好。
最后就是用C语言来写代码了
5.存储器区域功能划分
详情参考:《STM32新手入门-什么是寄存器》
在这 4GB 的地址空间中,ARM 已经粗线条的平均分成了 8 个块,每块 512MB,每个块也都规定了用途每个块的大小都有512MB,显然这是非常大的,
在这 8 个 Block 里面,有 3 个块非常重要,也是我们最关心的三个块。Block0 用来设计成内部 FLASH,Block1 用来设计成内部 RAM,Block2 用来设计成片上的外设,下面我们简单的介绍下这三个 Block 里面的具体区域的功能划分。
- 存储器 Block0 内部区域功能划分
- 储存器 Block1 内部区域功能划分
- 储存器 Block2 内部区域功能划分
Block2 用于设计片内的外设,根据外设的总线速度不同,Block 被分成了 APB 和 AHB两部分,其中 APB 又被分为 APB1 和 APB2
解释一下预留地址,这些地址没有给他分配存储单元,理论是4GB但实际并没有这么多,只是给了你指标而并没有全用
总结:
SRAM :64kb大小:起始地址0x2000 0000 - 2001 0000结束地址
FLASH :64kb大小:起始地址0x0800 0000 - 0808 0000结束地址
6.全局变量初始化
链接器决定全局变量的地址,并将全局变量的地址保存在flash中
看看全局变量的值如何赋给局部变量
汇编代码解析:
四.函数栈帧
什么是函数栈帧?
在函数入口处被压入栈空间的数据块称为栈帧
1.函数栈帧的形成与释放
现在用一个最简单的加法函数,来整体分析,函数调用是如何形成函数的栈帧,局部变量如何在栈上开辟空间,以及函数如何传参、如何将返回值带回,最后如何释放形成的栈帧。
可以先参考在window系统下中,其实过程基本上差不多《函数栈帧的形成与释放》
首先要明白一点,main函数也只是一个普通的函数,也是被别人调用,反正最开始是由硬件来调用,main函数既然也是被调用当然也会创建main函数的栈帧。。
如何调用main函数,以及为什么要保存返回地址到LR寄存器中
我是想讲详细点,可能画的有点乱了。
正式开始执行main 函数。
首先看一下初始内存是怎样的
接下来就是逐一分析汇编代码
1.将R1,R2,R3,LR寄存器入栈,形成mian函数的栈帧
2.局部变量x、y、z分配空间,并初始化
3.让r0,r1寄存器来传递参数
4.调用Add函数,并让LR保存返回地址
5.跳转到Add函数中执行,创建Add函数栈帧,并初始化局部变量c
6.进行加法运算,得出结果写回内存中,然后结果通过R0进行返回。
7.释放Add函数的栈帧,将存储在栈上的返回地址赋给PC寄存器则程序跳转到返回地址处继续执行程序
8.将Add函数的返回值赋给z变量,释放main函数的栈帧,将存储在栈上的返回地址赋给PC寄存器则程序跳转到返回地址处继续执行程序
10.最后跳转到调用main函数指令的下一条指令继续执行代码
重点总结:
1.临时空间的开辟,是在对应函数栈帧内部开辟的,函数调用完毕,栈帧结构被释放掉,因此函数中的变量的空间也随之释放,所以临时变量具有临时性。
2.调用函数是有成本的,成本体现在时间和空间上,本质是形成和释放栈帧有成本
3.子程序间通过寄存器R0~R3来传递参数,如果参数多于4个,则将剩余的字数据传递到堆栈中。入栈的顺序与参数传递顺序相反,即最后一个字数据先入栈。(待验证)
4.如果函数返回值为32位整数,由R0寄存器将返回值带回
其他返回值类型(我就不测试了)
- 结果为一个64位整数时,可以通过寄存器R0和R1返回;
- 结果为一个浮点数时,可以通过浮点运算部件的寄存器f0、d0或s0来返回;
- 结果为复合型浮点数(如复数)时,可以通过寄存器f0~fn或d0~dn来返回;
- 对于位数更多的结果,需要通过内存来传递。
2.ARM架构的AAPCS标准验证
还是用一个简单的函数,来看看到底是如何传参的
1.当有四个参数传参
2.当有五个参数传参
重要结论:
- 1).子程序间通过寄存器R0~R3来传递参数。这时,寄存器R0~R3可记作a0~a3。被调用的子程序在返回前无需恢复寄存器R0~R3的内容,也就是说函数中内部可以随意使用。
- 2)在子程序中,
使用寄存器R4~R11来保存局部变量
。则子程序(函数)进入时必须保存这些寄存器的值,在返回前必须恢复这些寄存器的值。在Thumb程序中,通常只能使用寄存器R4~R7来保存局部变量。 - 3)参数传递规则
在函数传递参数时,将所有参数看作是存放在连续的内存字单元的字数据。然后,依次将各字数据传递到寄存器R0,R1,R2和R3中。如果参数多于4个,则将剩余的字数据传递到堆栈中
。入栈的顺序与参数传递顺序相反,即最后一个字数据先入栈。
即函数传参时通过CPU的R0,R1,R2和R3来传递参数的,如果有更多参数即将参数压入栈(利用栈来传参)中。
RO~R3,R12、LR 以及 PSR
被称作“调用者保存寄存器”,若在函数调用后还需要使用这些寄存器的数值,在进行调用前,调用子程序的程序代码需要将这些寄存器的内容保存到内存中(如栈)。函数调用后不需要使用的寄存器数值则不用保存。
R4~R11
为“被调用者保存寄存器”,被调用的子程序或函数需要确保这些寄存器在函数结束时不会发生变化(与进入函数时的数值一样)。这些寄存器的数值可能会在函数执行过程中变化,不过需要在函数退出前将它们恢复为初始值。
总结
嵌入式就是得深入底层,越底层越好,这样才不会显得空中阁楼般,实现看代码像看内存一样的境界,很多问题一下子就顿悟了,哈哈哈哈。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)