嵌入式学习笔记
以下嵌入式学习的参考资料是**《嵌入式C语言自我修养:从芯片、编译器到操作系统》**,个人阅读过程中受益匪浅。
嵌入式学习笔记
核与cache
首先引入cache的原因是CPU的运行效率要远大率从内存读写数据的效率,为了提高系统的运行效率,引入了cache的概念。然后由于不断要从缓存新的数据到cache中,cache很容易满,所以还有清理cache的动作,这又会大大降低效率,所以又引入了多级cache,多级cache的读写速度离核越远效率越低。下图是通俗场景下cache和CPU的示意图。
在ARM架构中,cache还会有点特殊,L2cache会被每个簇共享,然后簇之间共享L3cache,L1cache还会分为指令cache和数据cache。如下图所示

当然也不是所有的处理器都需要cache,cache其实会增加成本和功耗,有的处理器工作频率不高,那么就对cache依赖较小,而且cache无法保证实时性,如果换成未命中,就需要从DDR(RAM)中读取数据了,可能某些实时性高的系统无法接受这一点。
cache有两个特性:
时间局部性:程序最近访问的地址,可能在不久在将来再次被访问;
空间局部性:程序访问某地址后,其相邻的地址可能被再次访问。
多利用上述两种特性,减少未命中的情况。进而引出一种情况,如果某个核写内存里,会导致另一个核在同一个CacheLine中的另外一个变量对应的Cache失效。
typedef struct {
uint64_t write;
uint64_t read;
uint64_t data[10];
}MyTest;
typedef struct {
uint64_t write;
uint64_t data[10];
uint64_t read;
}MyTest1;
//例如这两种写法,写法1就会导致伪共享的情况,当write一直在改变的时候,read的值会一直CacheMiss状态,因为他们在内存中是相邻排布的,而写法2中,由于write和read之间有data相隔,他们不处于一个CacheLine,所以不会受影响。进而写法2的访问速度大大高于写法1
能否通过软件方法让程序员自己决定某个变量处于某一级的缓存中呢? 答曰:不能,程序员无法直接决定某个变量或者内存块必须存放在L1、L2或L3缓存中。(因为ARM或者X86没有提供指令和寄存器让程序员指定数据存放的缓存层级) 缓存层级的管理完全由硬件自动完成,不过可以通过对缓存原理的理解优化代码和数据访问模式,可以间接影响硬件对缓存的使用策略,从而提高高频数据在更高级缓存(如 L1/L2)中的驻留概率。抛开上文的两个局部性特性不谈,还可以通过设置内存区域的缓存模式,间接影响数据是否进入内存,比如CACHEABLE、UNCACHEABLE、WRITE-COMBINING。
cache的地址是如何分配的呢? 答曰:首先缓存的地址并不是通过软件显式分配的一块内存地址空间,而是由硬件自动管理和维护的透明高速存储层,数据来源和地址映射完全依赖于硬件设计和工作原理。通常依赖内存地址的划分,具体可以分为直接映射、全相联映射和组相连映射三种策略。
- 直接映射中,内存地址会被直接映射到缓存的一个特定行,每个地址根据其高低位、中间位来确定对应的缓存行,地址分配的方式:块大小、索引、标签、块偏移。
- 全相联映射,任何内存地址都可以放在缓存中的任何行,没有固定的规则,缓存管理器会将每个内存块放入任意空闲的缓存行。管理方式主要通过标签。
- 组相连映射,结合了上述两个方法的特点。内存地址先被分配到特定的组中,然后组内再分为特定的内存块。管理方式主要有:组数、索引、标签、块偏移
缓存替换的策略有哪些? 答曰: LRU:替换最久没用过的数据;FIFO,类似队列先进先出;随机(逆天策略)。
缓存一致性
都说到cache了,再说说缓存一致性,多核之前绝大多数情况是需要保证多核的cache和RAM中的数据是相同的,所以簇之间甚至核之间是需要通过缓存一致性协议来保证数据一致性的。
缓存一致性协议:
一致性意味着确保系统中的所有处理器或总线master具有相同的共享内存视图。 这意味着一个核的缓存中保存数据的更改对其他核是可见的,其他核不可能看到失效或旧的数据副本。这可以通过不缓存来处理,即禁用共享内存的cache缓存,但这通常具有较高的性能成本。
软件管理一致性:通过软件(驱动程序)来清除缓存的脏数据或者使旧数据失效,这需要时间,并且在贡献率搞得情况下,会降低性能。
硬件管理一致性:通过硬件来保证一致性,要求D-cache、MMU使能,然后这个某一块地址会标记为coherent。不维护数据cache和指令cache,只要数据被标记为共享属性,硬件来保证一致性
这保持这一致性有很多种协议,ARMv8用MOESI协议
大小核架构
ARM架构的其中一大特性:大小核
大小核架构:一个处理器继承了高性能的核,也有低功耗的核,当CPU负载很重的时候,启动高性能的核工作,当CPU负载没那么高的时候,切换到低功耗的core上工作,根据不同场景分配不同的core,达到性能核功耗的平衡。
然后就可以把高性能的核放到一个簇cluster里,构成一个bigcluster,然后将低功耗的核放到一个簇里,构成一个little cluster。
如果不用大小核会导致什么问题呢?答曰:例如一个系统中全是性能很高的核,那么这样成本会很高,并且有些任务其实用不到这么高的性能,会导致核的性能浪费,比如CPU的频率是0.1s,但是任务可能0.001s就做完了,那么剩下的时间留浪费了,所以用大小核有两个主要作用:1是降成本,2是合理分配任务,达到性能平衡。
流水架构
CPU的流水线是一种空间换时间的操作,将某次任务的上下游寄存器通过类似流水线的方式串起来,这样当模块A完成当前任务之后,就能进行下一次任务的处理,提高了并行度,再加上ARM架构有一个叫做NEON的特性,能够使多指令并发,这效率嘎嘎快啊。例如:
单流水结构
uint32_t Test()
{
uint32_t sum = 0;
for(int i = 0; i < 100; i++){
sum += array[i];
}
return sum;
}
多条流水
uint32_t Test()
{
uint32_t sum = 0;
uint32_t sum1 = 0;
uint32_t sum2 = 0;
uint32_t sum3 = 0;
uint32_t sum4 = 0;
for(int i = 0; i < 25; i += 4){
sum1 += array[i];
sum2 += array[i + 1];
sum3 += array[i + 2];
sum4 += array[i + 3];
}
sum = (sum4 + sum3) + (sum1 + sum2);
return sum;
}
但是捏,流水线也不是越深越好,流水线越深,那么CPU的主频就需要越高,运行效率也要求越快,不然完成不了啊,那么多级流水呢,带来的功耗也会越大,而且万一某个if分支预测错了,后面预取的指令都浪费了,这浪费的时间也很多。
这么看其实流水线带来的风险也很多,主要有三种:
● 结构冒险:所需的硬件正在为前面的指令工作。 这个冒险主要是多条指令用的是同样的硬件,比如内存,会导致冲突
● 数据冒险:当前指令需要前面指令的运算数据才能执行。这个可以通过加空指令来规避,其实就是等前级数据算完
● 控制冒险:需根据之前指令的执行结果决定下一步的行为。 很经典的就是分支预测,也可以增加空指令来实现,但是如果流水线过深,就会加很多空指令,这个就拉长的原有的运行时间,cpu是无法接受的,所以加上分支预测。
分支预测可分为静态预测和动态预测,静态预测是在编译的时候就进行预测,这种对于循环程序有效,因为循环边界是可以判断的,动态预测就比较粗暴了,一般不跳转,所以在编写有跳转分支的代码时,应该把大概率执行的代码写在前面,这样能提高运行效率。如果把小概率运行的写在前面,发生跳转了,就会导致流水线冲刷或者停顿,因为指令都是预取的。
插一句题外话:C++中虚函数效率低,其实就和流水线冲刷一个原理,只有运行到跟前,才知道能从虚函数表中提取正确的虚函数指针,这样会导致后面预取的指令被冲刷,所以慢的发昏。
然后有个牛逼的地方,CPU去RAM中按照顺序去读取指令,这种情况如果发生数据冒险,可以增加空指令来避免预取的指令被冲刷掉。
为了解决上述情况的数据冒险问题,还可以通过乱序执行来实现,流水线冲突的根因主要是结构冒险或者数据冒险,那么我们可以通过重排指令来解决这问题,就比如四条指令分别是ADD、 SUB 、ADD、ADD,如果按顺序执行,就会造成诶我调用了一次ADD,再调用一次SUB,再调用一次ADD,这样是不是有点奇怪,如果我移动一下呢?如下图所示
我把三个ADD放在了一起,再执行SUB,再搭配上ARM的NEON特性,等于是本来要执行两次ADD的时间缩减为只需要执行一次,那我执行效率是不是大大滴提升了呢?不过用这种方法最好是依赖性比较弱的数据,不然还是会造成数据冒险的。
如果由于存在软硬件交互,比如更新cache,或者多线程交互的场景,没法让代码去乱序执行,那么需要人为加个屏障。
asm volatile("mfence" :::"memory");
通过软件编码也可以达到软并行的效果
例如:下文如果通过软件封装了LDR、ADD、STR,那么这段代码这么写其实就是个软流水,因为第一个LDR结束之后,就可以去执行第二个LDR了,剩下两个指令也是同理。
LDR RO, R1
ADD R1, R1,#0,
STR R0, R1
LDR RO, R1
ADD R1, R1,#0,
STR R0, R1
异构结构
这个比较简单,就是一个SOC里集成了不同架构的东东,CPU、DSP、GPU、NPU都塞在一起,就叫异构
多核处理器
单核处理器其实还是多任务抢占一个cpu资源串行执行,除非任务切换的够快,才能够让用户感觉是多个程序同时运行
只有多核处理器才能够真正实现多并发,当你有多核的时候,除非真的需要,不然就少用一点锁,这样才是真正的又快又好,能够达到多核并行的加速目的。
那么怎么做到多核之间的通信呢?硬件上可以用总线型,但是这样容易存在多核抢占通信资源的情况;交叉开关就像路由器一样有多个端口,多个核可以通过交叉开关的端口互连,并行通信,但是当端口多的时候,功耗会急剧上升;
进一步优化则是层次化交叉开关,这一步就有一点像是网络局点的连线了,如下图所示,可以形成一个环路,实现四核间的通信。
目前比较常见的是类似一种片上网络的结构, net on chip(NoC),也就是给每个核或者每个模块配置一个路由,实现核间、模块间的物理链路的通信,比如配置一个mac地址,然后有一个交换模块,那是不是就能实现类似网络中路由器的功能了呢?这个交换模块知道芯片上各个核或者模块的mac地址,然后如果某个核需要发消息到另一个核,根据目的地址在交换模块中查找,就能够实现转发了。
然后CPU和外设之间是通过总线连接的,总线承载各种数字信号,包括地址信号、数据信号、控制信号、中断信号等等,总线也可以根据自己的功能划分成内存总线、PCI总线之类的,这个我不大了解,就不展开说了。ARM和X86寻址方式是不同的,ARM是CPU来统一编址,X86是独立编址,感兴趣的可以自己再去找相关资料。
指令集
不同架构的处理器支持的指令类型是不同的,x86和arm的指令就不互通。那么CPU上支持的有限的指令的集合,就叫做指令集。这是翻译的说法,有点抽象,其实就是不同架构支持的指令不一样,对应架构的指令集和就叫指令集。
由于我工作用的比较多的是ARM架构,下文就只提及ARM架构的指令集。
ARM的指令集是属于RISC(简化指令集计算),目的是为了减少指令复杂度和数量来提高计算机的性能。
那么具体如何减少指令的复杂度和数量呢?
答曰:
- 移除不常用的指令
- 固定长度指令,如32位,这样有点像内存对齐,提高访问效率
- 减少内存访问质量,通过专门的load和store来完成内存访问,所有的计算操作都在寄存器中,避免复杂的内存到内存的操作
- 使用大量寄存器(不是很推荐,感觉不靠谱
- 使用流水结构
那么RISC的特点是哪些呢?
答曰:
● Load/Store架构,CPU不能直接处理内存中的数据,要先将内存中的数据Load(加载)到寄存器中才能操作,然后将处理结果Store(存储)到内存中。
● 固定的指令长度、单周期指令。
● 倾向于使用更多的寄存器来存储数据,而不是使用内存中的堆栈,效率更高。
ARM指令集虽然属于RISC,但是和原汁原味的RISC相比,还是有一些差异的,具体如下。
● ARM有桶型移位寄存器,单周期内可以完成数据的各种移位操作。
● 并不是所有的ARM指令都是单周期的。
● ARM有16位的Thumb指令集,是32位ARM指令集的压缩形式,提高了代码密度。
● 条件执行:通过指令组合,减少了分支指令数目,提高了代码密度。
● 增加了DSP、SIMD/NEON等指令
现在更先进的用的是RISC-V,比RISC更牛逼了,是一个完全开源的架构,大家可以根据自己所需,自由使用、修改和实现指令集,除了基础的指令集以外,最经典的就是浮点计算、向量操作。
ARM的话,用的是LDR\STR,这两个指令来取、存内存中的数据到寄存器,具体使用的如下:
LDR R1,[R0]; 将R0的值作为地址,将该地址上的值保存到R1
STR R1,[R0]; 将R0的值作为地址,将R1中的值存储到这个内存地址
这两句看着很简单,里面的坑可多着哩,且听我细细道来。
先介绍一下寄存器的寻址方式:
- 间接寻址, LDR、STR指令大部分通过间接寻址。
- 基址寻址,通过源地址偏移多少,然后再去访问偏移后的新地址,根据这个特性,基本上用在数组访问、查表、函数栈帧这种。
- 多寄存器寻址,例如 LDMIA STMDB,多寄存器用大括号{}括起来,寄存器之间逗号隔开,连续寄存器使用-连接,例如R0-R2代表,R0、R1、R2。LDM/STM指令一般和IA、IB、DA、DB组合使用,分别表示Increase After、Increase Before、Decrease After、Decrease Before。不过ARM是没有单独的出栈入栈指令,栈操作就是基于STM/LDM配合完成的。
- 相对寻址, 就是PC指针的偏移,但是是有长度限制的,因为函数跳转指令B是由范围限制[0,32MB],如果二进制文件大于32M,就需要考虑跳转长度问题了。
看到这里,应该就能看出问题了,LDR、STR指令,跳转不了地址啊。LDR、STR指令本质其实和MOV有点类似,都是给寄存器赋值,但是当赋值的是地址的时候,这里就有问题了。因为RISC的特点是单周期指令,指令长度通常是固定的,以32位举例,指令长度大概率就是32位的,和字节对齐的原因有点类似。那么这个指令包括操作码和操作数,也就是说,操作码和操作数共享32位的存储空间,那么操作码哪怕就占1位,剩下的31位也不够存储一个地址的长度,所以引入了LDR伪指令来将地址传到寄存器中。
伪指令其实就长这样:LDR R0, =0x30008000,比指令就多了一个=,如果操作数小于32位,那么编译器会用MOV代替。
那么LDR伪指令是怎么实现MOV的呢?原理是什么? 答曰:编译器会转换为LDR标准指令+文字池子的形式,然后编译器会在内存中分配一个4字节的存储单元存放这个地址的值,这个存储单元译为文字池,有够抽象的,然后编译器计算出该存储单元到LDR伪指令之间的偏移,然后再通过寄存器的相对寻址,这样就可以把地址赋值给寄存器了,屌不屌,很抽象吧。不过由于相对寻址是有长度限制的,一般会把存储单元就放在指令附近。
总结一下,指令和伪指令有什么区别呢? 答曰:LDR指令是ARM一条实际的指令,通常只能从内存中加载数据,但是不能给寄存器写地址值,而伪指令是写在汇编程序里的,可以用于给寄存器加载常量或者地址值。那么简单地说,区别就是一个主要用于加载数据,另一个通常用来给寄存器写地址。然后指令区别可以直接看有没有=号。
其他的伪指令可以以此类推一下哈,基本上都是为了对地址操作才诞生的,都是需要把地址放在一个存储单元中,再通过相对寻址,将值赋值给寄存器。
其他的ADD、SUB这种指令都比较简单,没啥好说的,好了可以开始写汇编语言了。
而且C语言可以和汇编语言放在一起编译,仅需要在前面加个__asm关键字(和编译器有关),然后编译文件需要用交叉编译器arm-linux-gcc,如果在汇编语言中需要跳转到C的函数,直接用BL指令即可。
编译器
在linux执行机上安装编译器比较简单,直接在联网的Linux系统下载就行了
从C程序到可执行文件经过:预处理、编译、汇编、链接,这个流程大家都很熟悉了
预处理
预处理阶段就是对一些宏做展开、处理的过程,然后删除注释,添加行号和文件名标示,通过#标示,常见的预处理命令如下:
- #include
- #define
- #if、 #else 、#endif 条件编译
- #pragma 编译控制
其中pragma可以设定编译器的状态: - #pragma pack([n]) 指定结构体和联合成员的字节对齐方式;
- #pragma message:编译信息输出窗口打印自己的文本信息;
- #pragma warning:改变编译器的warning信息;
- #pragma once:在头文件中添加这条指令,防止头文件多次编译
编译
预处理之后,程序就是打开包装的薯片了,剩下的都是C代码了,就可以进入编译阶段。分为两步:1、编译器调用解析工具分析C代码,转化为汇编文件;2、通过汇编器将汇编文件汇编成可重定位的目标文件。
汇编文件是以段为单位来组织程序的:代码段、数据段、BSS段,各个段之间相互独立,可以用AREA或者.section来定义一个段。其实汇编文件和二进制文件已经很接近了,基本上一毛一样,只需要编译器处理掉汇编中的伪指令,就是二进制目标文件了。从C源文件到汇编文件的转换,其实就是将C文件中的程序代码块、函数转换为汇编程序中的代码段,将C程序中的全局变量、静态变量、常量转换为汇编程序中的数据段、只读数据段。可以通过readelf查看文件格式,可查看的类型很多,包括符号表-s、动态段-d等,便于更好地理解二进制文件的组成,最简单的-s可以看到data段、bss段基于基地址的偏移。由于我不怎么接触这一块,理解确实不行,就不细讲了。
这里编译的符号表和coredump的符号表是不一样的,不要搞混,编译的符号表是程序调试链接的信息,coredump文件时程序奔溃的快照。
那么在一个C源文件中,引用了其他文件的函数或者全局变量,在编译过程中是否回报错呢?答曰:编译器是不会报错的,只要有声明就ok,但是在链接的时候,如果在其他文件或者库中找不到所引用,才会报错。在当前文件找不到符号定义,需要从其他库或者源文件中查找相应符号,这时候就有一个新的符号表,用于存储这些带查找的符号,即重定向符号表。
在开发过程中很容易遇到几个关键字,会对编译器的优化做出影响:
- volatile关键字,比较官方的说法是可以限制编译器的自由度,告诉编译器每次访问该变量都必须从内存中读取,不能假设该变量的值不会发生变化。那么本质上其实表示:编译器在编译C代码时,保证编译出的汇编指令对volatile变量的访问顺序和次数与C源代码一致。 其中次数一致表示: 源码对volatile变量读写了几次,那么最终指令也读写了几次; 顺序一致表示:源码对多个volatile变量的访问,最终访问顺序和源码保持一致,需要注意的是,volatile变量和非volatile变量之间依然可以乱序。 值得一提的是,可能有些地方会说volatile数据可以避免放在cache中,这是不对的。拓展: volatile值解决了编译器产生的乱序问题,实际多核并发程序还面临内存模型的乱序,这个可以通过memory_barrier来保序。 最简单的应用就是用于同步标志上了:
//g_flag作为多线程共享的全局变量
while(g_flag != 1){}; /* 等待条件满足 */
可能被编译器优化为:
temp = g_flag;
while(tmp != 1){};//产生死循环
解决方法:
volatile UINT32 g_flag;
虽然linux内核原文建议尽量不用volatile,使用一些类似barrier的手段,但是有时候没办法,该用就用。
- restrict关键字,也是一种编译器优化的关键字,官方说法是可以告诉编译器所指向元素不重叠,提高访问速度。但是好像这么理解也足够了,表示某一处内存不会被其他变量所访问。
链接
编译的时候,编译器将每个文件单独翻译成对应的目标文件,然后每个目标文件的段地址都是从目标文件的零偏移地址开始顺序依次存放,这是暂时性的。在链接过程中,这些目标文件的各个section会重新拆分组装,每个section的其实参考地址都会发生变化,需要重新修改,这就是重定位的过程。所以,链接主要分为3个过程:分段组装、符号决议和重定位。
分段组装的话,从上面的图就能简单理解链接的意义,有一种合并同类向的感觉,然后地址的话就按a.out去做偏移
符号决议的话,其实是决定同名符号,用哪一个的,但是在实际项目开发过程中,最好不要用同名的全局变量,很蠢好吧。
分段组装后,各个段的其实地址发了变化,那么各个符号的地址也发生了变化,更新各个符号的地址这一动作就是重定位。之前的重定位表就能用上了
运行的话其实涉及linux操作系统了,感觉不如直接讲操作系统来的直接,所以嵌入式就到此为止啦,后续有新的理解会再补充。
魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。
更多推荐


所有评论(0)