ARM

作者:linuxer

一、前言

上一节主要描述了为了打开MMU而进行的Translation table的建立,本文延续之前的话题,主要是进行CPU的初始化(注:该初始化仅仅为是为了turn on MMU)。

本文主要分析ARM64初始化过程中的__cpu_setup函数,代码位于arch/arm64/mm/proc.S中。主要的内容包括:

1、cache和TLB的处理

2、Memory attributes lookup table的构建

3、SCTLR_EL1、TCR_EL1的设定

二、cache和TLB的处理

1、oerview

根据ARM64 boot protocol,我们知道,会bootloader将内核解压并copy到RAM中,同时将CPU core(BSP)的状态设定为:关闭MMU,disable D-cache,I-cache状态可以是enable,也可以是disable的。其实在bootloader将控制权交给Kernel之前,bootloader已经走过千山万水,为了性能,很可能是打开了MMU以及各种cache,只是在进入kernel的时候,受限于ARM64 boot protocol而将CPU以及cache、MMU等硬件状态设定为指定的状态。因此,实际上这时候,instruction cache以及TLB中很可能有残留的数据,因此需要将其清除。

2、如何清除instruction cache的数据?

听起来这个问题似乎有点愚蠢,实际上不是。随着人类不断向更快的计算机系统进发,memory hierarchy也变得异常复杂起来,cache也形成了cache hierarchy(ARMv8最大支持7个level,L1~L7),不同级别的cache中都包含了部分下一级cache(或者main memory)的内容。这时候,维护数据一致性变得复杂了,例如:当要操作(例如clean或者invalidate)某个地址对应的cacheline的时候,是仅仅操作L1还是覆盖L1和L2,异或将L1~L3中对应的cacheline都设置为无效呢?PoU(Point of Unification)和PoC(Point of Coherency)这两个术语就是用来定义cache操作范围的,它们其实都是用来描述计算机系统中memory hierarchy的一个具体的“点”,操作范围是从PE到该点的所有的memory level。

我们先看PoU,PoU是以一个特定的PE(该PE执行了cache相关的指令)为视角。PE需要透过各级cache(涉及instruction cache、data cache和translation table walk)来访问main memory,这些操作在memory hierarchy的某个点上(或者说某个level上)会访问同一个copy,那么这个点就是该PE的Point of Unification。假设一个4核cpu,每个core都有自己的L1 instruction cache和L1 Data cache,所有的core共享L2 cache。在这样的一个系统中,PoU就是L2 cache,只有在该点上,特定PE的instruction cache、data cache和translation table walk硬件单元访问memory的时候看到的是同一个copy。

PoC可以认为是Point of System,它和PoU的概念类似,只不过PoC是以系统中所有的agent(bus master,又叫做observer,包括CPU、DMA engine等)为视角,这些agents在进行memory access的时候看到的是同一个copy的那个“点”。例如上一段文章中的4核cpu例子,如果系统中还有一个DMA controller和main memory(DRAM)通过bus连接起来,在这样的一个系统中,PoC就是main memory这个level,因为DMA controller不通过cache访问memory,因此看到同一个copy的位置只能是main memory了。

之所以区分PoC和PoU,根本原因是为了更好的利用cache中的数据,提高性能。OK,我们回到本节开始的问题:如何清除instruction cache的数据?我们还是用一个具体的例子来描述好了:对于一个PoU是L2 cache的系统,清除操作应该到哪一个level?根据ARM64 boot protocol规定,kernel image对应的VA会被cleaned to PoC,这时候,各级的data cache的数据都是一致性的,按理说,BSP只需要清除本cpu core上的instruction cache就OK了。不过代码使用了PoU,也就是说操作到了L2,而实际上,L2是unified cache,其数据是有效的,清除了会影响性能,这里我也想的不是很清楚,先存疑吧。

3、代码解析

ENTRY(__cpu_setup)
ic iallu --------------------------------(1)
tlbi vmalle1is------------------------------(2)
dsb ish --------------------------------(3)
mov x0, #3 << 20 ----------------------------(4)
msr cpacr_el1, x0 // Enable FP/ASIMD
msr mdscr_el1, xzr // Reset mdscr_el1

(1)ic iallu指令设置instruction cache中的所有的cache line是无效的,直到PoU。同时设置为无效状态的还包括BTB(Branch Target Buffer) cache。在处理器设计中,分支指令对性能的影响非常巨大(打破了pipeline,影响了并行处理),因此在处理器中会设定一个Branch target predictor单元用来对分支指令进行预测。Branch target predictor凭什么进行预测呢?所谓预测当然是根据过去推测现在,因此,硬件会记录分支指令指令的跳转信息,以便Branch target predictor对分支指令进行预测,这个硬件单元叫做Branch Target Buffer。程序中的分支指令辣么多,Branch Target Buffer不可能保存所有,只能cache近期使用到的分支跳转信息。

(2)tlbi这条指令通过猜测也知道是对TLB进行invalidation的操作,但是vmalle1is是什么鬼?它其实是vm-all-e1-is,vmall表示要invalidate all TLB entry,e1表示该操作适用于EL1,is表示inner sharebility。根据ARM ARM描述,这条指令的作用范围是inner shareable的所有PEs。这里有一个疑问:其实启动过程有些是只在BSP上进行,例如前面文章中的save boot parameter、校验blob、建立页表都是全局性的,只做一次就OK了。而这里的__cpu_setup函数是会在每一个cpu core上执行,因此应该尽量少的影响系统。如果这里是invalidation所有的inner shareable的PE的TLB,那么在secondary cpu core启动的时候会再执行一次,对系统影响很大,合理的操作应该是操作自己的TLB就OK了。

(3)step 1和step 2的操作和打开MMU操作有严格的时序要求,dsb这个memory barrier操作可以保证在执行打开MMU的时候,step 1和step 2都已经执行完毕。同样的,ish表示inner shareable。

(4)CPACR_EL1(Architectural Feature Access Control Register)是用来控制Trace,浮点运算单元以及SIMD单元的。FPEN,bits [21:20]是用来控制EL0和EL1状态的时候访问浮点单元和SIMD单元是否会产生exception从进入上一个exception level。这里的设定运行用户空间(EL0)和内核空间(EL1)访问浮点单元和SIMD单元。MDSCR_EL1(Monitor Debug System Control Register)主要用来控制debug系统的。

三、Memory attributes lookup table的构建

1、overview

MMU的作用有三个:地址映射,控制memory的访问权限,控制memory attribute。ARM64的启动过程之(二):创建启动阶段的页表对前面两个功能有了简单的描述,关于memory attribute将在本节描述。在Translation table中描述符中除了地址信息还有一些attribute的信息,例如attribute index域,既然叫做index则说明该域并没有保存实际的memory attribute,实际的attribute保存在MAIR_ELx中。在这个64 bit的寄存器中,每8个bit一组,形成一种类型的memory attribute。

2、memory type

我们知道,ARMv8采用了weakly-order内存模型,也就是说,通俗的讲就是处理器实际对内存访问(load and store)的执行序列和program order不一定保持严格的一致,处理器可以对内存访问进行reorder。例如:对于写操作,processor可能会合并两个写的请求。处理器这么任性当然是从性能考虑,不过这大大加大了软件的复杂度(软件工程师需要理解各种memory barrier操作,例如ISB/DSB/DMB,以便控制自己程序的内存访问的order)。

地址空间那么大,是否都任由processor胡作非为呢?当然不是,例如对于外设的IO地址,处理必须要保持其order。因此memory被分成两个基本的类型:normal memory和devicememory。除了基本的memory type,还有memory attribute(例如:cacheability,shareability)来进一步进行描述,我们在下一节描述。

标识为normal memory type的memory就是我们常说的内存而已,对其访问没有副作用(side effect),也就是说第n次和第n+1次访问没有什么差别。device memory就不会这样,对一些状态寄存器有可能会read clear,因此n和n+1的内存访问结果是不一样的。正因为如此,processor可以对这些内存操作进行reorder、repeat或者merge。我们可以把程序代码和数据所在的memory设定为normal memory type,这样可以获取更高的性能。例如,在代码执行过程中,processor可能进行分支预测,从而提前加载某些代码进入pipeline(而实际上,program不一定会fetch那些指令),如果设定了不正确的memory type,那么会阻止processor进行reorder的动作,从而阻止了分支预测,进而影响性能。

对于那些外设使用的IO memory,对其的访问是有side effect的,很简单的例子就是设备的FIFO,其地址是固定不变的,但是每次访问,内部的移位寄存器就会将下一个数据移出来,因此每次访问同一个地址实际上返回的数据是不一样的。device不存在cache的设定,总是no cache的,处理器访问device memory的时候,限制会比普通memory多,例如不能进行Speculative data accesses(所谓不能进行Speculative data accesses就是说cpu对memory的访问必须由顺序执行的执行产生,不能由于自己想加快性能而投机的,提前进行某些数据访问)。

3、 memory attribute

上一节将memory分成两个大类:normal memory和device,但是这么分似乎有些粗糙,我们可以进一步通过memory attribute将memory分成更多的区域。一个memory range对应的memory attribute是定义在页表的描述符中(由upper attribues和lower attributes组成),最重要的attributes定义在lower attributes中的AttrIndx[2:0],该域只是一个index而已,指向MAIR_ELx中具体的memory attribute。8-bit的memory attribute的具体解释可以参考ARM ARM。

对于device type,其总是non cacheable的,而且是outer shareable,因此它的attribute不多,主要有下面几种附加的特性:

(1)Gathering 或者non Gathering (G or nG)。这个特性表示对多个memory的访问是否可以合并,如果是nG,表示处理器必须严格按照代码中内存访问来进行,不能把两次访问合并成一次。例如:代码中有2次对同样的一个地址的读访问,那么处理器必须严格进行两次read transaction。

(2)Re-ordering (R or nR)。这个特性用来表示是否允许处理器对内存访问指令进行重排。nR表示必须严格执行program order。

(3)Early Write Acknowledgement (E or nE)。PE访问memory是有问有答的(更专业的术语叫做transaction),对于write而言,PE需要write ack操作以便确定完成一个write transaction。为了加快写的速度,系统的中间环节可能会设定一些write buffer。nE表示写操作的ack必须来自最终的目的地而不是中间的write buffer。

对于normal memory,可以是non-cacheable的,也可以是cacheable的,这样就需要进一步了解Cacheable和shareable atrribute,具体如下:

(1)是否cacheable

(2)write through or write back

(3)Read allocate or write allocate

(4)transient or non-transient cache

最后一点要说明的是由于cache hierararchy的存在,memory的属性可以针对inner和outer cache分别设定,具体如何区分inner和outer cache是和具体实现相关,但通俗的讲,build in在processor内的cache是inner的,而outer cache是processor通过bus访问的。

4、代码分析

ldr x5, =MAIR(0x00, MT_DEVICE_nGnRnE) | \
MAIR(0x04, MT_DEVICE_nGnRE) | \
MAIR(0x0c, MT_DEVICE_GRE) | \
MAIR(0x44, MT_NORMAL_NC) | \
MAIR(0xff, MT_NORMAL)
msr mair_el1, x5

页表中的memory attribute的信息并非直接体现在descriptor中的bit中,而是通过了一个间接的手段。描述符中的AttrIndx[2:0]是一个index,可以定位到8个条目,而这些条目就是保存在MAIR_EL1(Memory Attribute Indirection Register (EL1))中。对于ARM64处理器,linux kernel定义了下面的index:

#define MT_DEVICE_nGnRnE 0
#define MT_DEVICE_nGnRE 1
#define MT_DEVICE_GRE 2
#define MT_NORMAL_NC 3
#define MT_NORMAL 4

NC是no cache,也就是说MT_NORMAL_NC的memory是normal memory,但是对于这种类型的memory的访问不需要通过cache系统。这些index用于页表中的描述符中关于memory attribute的设定,对于初始化阶段的页表都是被设定成MT_NORMAL。

四、SCTLR_EL1、TCR_EL1的设定

1、寄存器介绍

SCTLR_EL1是一个对整个系统(包括memory system)进行控制的寄存器,我们这里描述几个重要的域。这些域有两种类型,一种是控制EL0状态时候能访问的资源。例如:UCI bit[26]控制是否允许EL0执行cache maintemance的指令(DC或者IC指令),如果不允许,那么会陷入EL1。nTWE bit[18]控制是否允许EL0执行WFE指令,如果不允许,那么会陷入EL1。bit 16类似bit 18,但是是for WFI指令的。UCT bit[15]控制是否允许EL0访问CTR_EL0(该寄存器保存了cache信息),如果不允许,那么会陷入EL1。UMA,bit [9]控制是否可以访问cpu状态寄存器的PSTATE.{D,A, I, F}比特。还有一种是实际控制memory system的域,例如:C bit[2]是用来enable或者disable EL0 & EL1 的data cache。具体包括通过stage 1 translation table访问的memory以及对stage 1 translation table自身memory的访问。I bit[12]是用来enable或者disable EL0 & EL1 的instruction cache。M bit[0]是用来enable或者disable EL0 & EL1 的MMU。

我们知道,kernel space和user space使用不同的页表,因此有两个Translation Table Base Registers,形成两套地址翻译系统,TCR_EL1寄存器主要用来控制这两套地址翻译系统。TBI1,bit[38]和TBI0,bit[37]用来控制是否忽略地址的高8位(TBI就是Top Byte ignored的意思),如果允许忽略地址的高8位,那么MMU的硬件在进行地址比对,匹配的时候忽略高八位,这样软件可以自由的使用这个byte,例如对于一个指向动态分配内存的对象指针,可以通过高8位来表示reference counter,从而可以跟踪其使用情况,reference count等于0的时候,可以释放内存。AS bit[36]用来定义ASID(address space ID)的size,A1, bit [22]用来控制是kernel space还是user space使用ASID。ASID是和TLB操作相关,一般而言,地址翻译的时候并不是直接查找页表,而是先看TLB是否命中,具体判断的标准是虚拟地址+ASID,ASID是每一个进程分配一个,标识自己的进程地址空间。这样在切换进程的时候不需要flush TLB,从而有助于performance。TG1,bits [31:30]和TG0,bits [15:14]是用来控制page size的,可以是4K,16K或者64K。当MMU进行地址翻译的时候需要访问页表,SH1, bits [29:28]和SH0, bits [13:12]是用来控制页表所在memory的Shareability attribute。ORGN1, bits [27:26]和ORGN0, bits [11:10]用来控制页表所在memory的outercachebility attribute的。IRGN1, bits [25:24]和IRGN0, bits [9:8]用来控制页表所在memory的inner cachebility attribute的。T1SZ, bits [21:16]和T0SZ, bits [5:0]定义了虚拟地址的宽度。

2、代码分析

代码位于arch/arm64/mm/proc.S中,该函数主要为打开MMU做准备,具体代码如下:

adr x5, crval ------------------------------(1)
ldp w5, w6, [x5]
mrs x0, sctlr_el1
bic x0, x0, x5 // clear bits
orr x0, x0, x6 // set bits

ldr x10, =TCR_TxSZ(VA_BITS) | TCR_CACHE_FLAGS | TCR_SMP_FLAGS | \
TCR_TG_FLAGS | TCR_ASID16 | TCR_TBI0
tcr_set_idmap_t0sz x10, x9 -----------------------(2)
mrs x9, ID_AA64MMFR0_EL1
bfi x10, x9, #32, #3
msr tcr_el1, x10 ----------------------------(3)
ret // return to head.S ---------------------(4)
ENDPROC(__cpu_setup)

(1)在调用__enable_mmu之前要准备好SCTLR_EL1的值,该值在这段代码中设定并保存在x0寄存器中,随后做为参数传递给__enable_mmu函数。具体怎么设定SCTLR_EL1的值呢?这是通过crval变量设定的,如下:

.type crval, #object
crval:
.word 0xfcffffff----------------SCTLR_EL1寄存器中需要清0的bit
.word 0x34d5d91d -------------SCTLR_EL1寄存器中需要设置成1的bit
由代码可知,EE和E0E这两个bit没有清零,因此实际上这些bit保持不变(在el2_setup中已经设定)。这里面具体各个bit的含义清参考ARM ARM文档,我们不一一说明了。

(2)这里的代码是准备TCR寄存器的值。TCR_TxSZ(VA_BITS)是根据CONFIG_ARM64_VA_BITS配置来设定内核和用户空间的size,其他的是进行page size的设定或者是page table对应的memory的attribute的设定,具体可以对照ARM ARM文档进行分析。

(3)到这里x10已经准备好了TCR寄存器的值,还缺省IPS(Intermediate Physical Address Size)的设定(IPS和2 stage地址映射相关,它和虚拟化有关,这里就不展开描述了,内容太多)。ID_AA64MMFR0_EL1, AArch64 Memory Model Feature Register 0,该寄存器保存了memory model和memory management的支持情况,该寄存器的PARange保存了物理地址的宽度信息,bfi x10, x9, #32, #3 指令就是将x9寄存器的内容左移32bit,copy 3个bit到x10寄存器中(IPS占据bits [34:32])。

(4)stext的代码如下:

ENTRY(stext)
……
ldr x27, =__mmap_switched
adr_l lr, __enable_mmu
b __cpu_setup
ENDPROC(stext)

在调用__cpu_setup之前设定了lr的内容是__enable_mmu,而调用__cpu_setup使用的是b而不是bl指令,因此lr寄存器没有修改,因此,这里的ret返回到__enable_mmu函数。

文章来源:蜗窝科技

围观 648

作者:suipingsp

硬件和软件是一颗芯片系统互相依存的两大部分,本文总结了一颗芯片的软硬件组成,作为对芯片的入门级概括吧。

(一)硬件

主控CPU:运算和控制核心。基带芯片基本构架采用微处理器+数字信号处理器(DSP)的结构,微处理器是整颗芯片的控制中心,会运行一个实时嵌入式操作系统(如Nucleus PLUS),DSP子系统负责基带处理。应用处理器则可能包括多颗微处理器,还有GPU。微处理器是ARM的不同系列的产品(也可以是x86架构),可以是64位或者32位。处理器内部通过“内部总线”将CPU所有单元相连,其位宽可以是8-64位。

总线:计算机的总线按功能可以划分为数据总线、地址总线和控制总线,分别用来传输数据、数据地址和控制信号。CPU内部部件由内部总线互联,外部总线则是CPU、内存、输入、输出设备传递信息的公用通道,主机的各个部件通过总线相连接。外部设备通过相应的接口电路再与外部总线相连接,从而形成了硬件系统。外部总线通过总线接口单元BLU与CPU内部相连。

片上总线标准高级微控制器总线结构AMBA定义了高性能嵌入式微控制器的通信标准。定义了三组总线:AHB(AMBA高性能总线)、ASB(AMBA系统总线)、和APB(AMBA外设总线)。AHB总线用于高性能、高时钟工作频率模块。AHB为高性能处理器、片上内存、片外内存提供接口,同时桥接慢速外设。DMA、DSP、主存等连在AHB上。ASB总线主要用于高性能系统模块。ASB是可用于AHB不需要的高性能特性的芯片设计上可选的系统总线。APB总线用于为慢速外设提供总线技术支持。APB是一种优化的,低功耗的,精简接口总线,可以支持多种不同慢速外设。由于APB是ARM公司最早提出的总线接口,APB可以桥接ARM体系下每一种系统总线。

外设I/O端口和扩展总线:GPIO通用端口、UART串口、I2C、SPI 、SDIO、USB等,CPU和外扩的芯片、设备以及两颗CPU之间(如基带处理器和应用处理器之间)进行通信的接口。一般来说,芯片都会支持多种接口,并设计通用的软件驱动平台驱动。

存储部件和存储管理设备:Rom、Ram、Flash及控制器。处理器系统中可能包含多种类型的存储部件,如Flash、SRAM、SDRAM、ROM以及用于提高系统性能的Cache等等,不同的芯片会采用不同的存储控制组合。参见博文”arm架构的芯片memory及智能机存储部件简述“

外设: 电源和功耗管理、复位电路和watchdog定时复位电路(前者是系统上电运行、后者是Reset或者超时出错运行)、时钟和计数器、中断控制器、DMA、 输入/输出(如键盘、显示器等)、摄像头等。

比如,一颗ARM9架构芯片主控器及外围硬件设备组成如下图所示:

(二)软件

芯片上的软件主要包括Boot代码、操作系统、应用程序以及硬件的firmware。

Boot程序引导设备的启动,是设备加电后在操作系统内核运行之前运行的一段小程序。通过这段小程序,我们可以初始化硬件设备、建立内存空间的映射图,从而将系统的软硬件环境带到一个合适的状态,以便为最终调用操作系统内核准备好正确的环境。

操作系统(英语:Operating System,简称OS)是管理和控制计算机硬件与软件资源的计算机程序,其五大管理功能是:

(1)处理器管理,主要包括进程的控制、同步、通信和调度。

(2)存储器管理,主要包括内存的分配、保护和扩充,地址映射。

(3)设备管理,主要包括设备的分配、处理等。

(4)文件管理,主要包括文件的存储空间管理,目录管理,文件的读写和保护。

(5)作业管理,主要包括任务、界面管理,人机交互,语音控制和虚拟现实等。

应用处理器上的操作系统有Android、IOS等,不必多说;基带处理器上则会运行一个RTOS(如Nucleus PLUS)管理整个基带系统上的任务和部件间的通信。

应用程序是为了完成某项或某几项特定任务而被开发运行于操作系统之上的程序。应用处理器上,结合操作系统API和库函数,用户可以开发各色应用程序;基带处理器上则一般只有少量必要的软件支持。

硬件firmware则是简化软件与硬件的交互,让硬件操纵起来更容易。

文章来源:极客头条

围观 333

作者:linuxer

一、前言

本文主要描述了ARM64启动过程中,如何建立初始化阶段页表的过程。我们知道,从bootloader到kernel的时候,MMU是off的(顺带的负作用是无法打开data cache),为了提高性能,加快初始化速度,我们必须某个阶段(越早越好)打开MMU和cache,而在此之前,我们必须要设定好页表。

在初始化阶段,我们mapping三段地址,一段是identity mapping,其实就是把物理地址mapping到物理地址上去,在打开MMU的时候需要这样的mapping(ARM ARCH强烈推荐这么做的)。第二段是kernel image mapping,内核代码欢快的执行当然需要将kernel running需要的地址(kernel txt、dernel rodata、data、bss等等)进行映射了,第三段是blob memory对应的mapping。

在本文中,我们会混用下面的概念:page table和translation table、PGD和Level 0 translation table、PUD和Level 1 translation table、PMD和Level 2 translation table、Page Table和Level 3 translation table。最后,还是说明一下,本文来自4.1.10内核(部分来自4.4.6),有兴趣的读者可以下载来对照阅读本文。

二、基础知识

为了更好的理解__create_page_tables的代码,我们需要准备一些基础知识。由于ARM64太复杂了,各种exception level、各种stage translation、各种地址宽度配置等等让虚拟地址到物理地址的映射变得非常复杂,因此,本文focus在一种配置:Non-secure EL1和EL0、stage 1 translation、VA和PA的地址宽度都是48个bit。

1、虚拟地址空间的size是多少?

在32-bit的ARM时代,这个问题问的有点白痴,大家都是耳熟能详的一句话就是每一个进程都有4G的独立的虚拟地址空间(0x0~0xffffffff)。对于ARM64而言,进程需要完全使用2^64那么多的虚拟地址空间吗?如果需要,那么CPU的MMU单元需要能接受来自处理器发出的64根地址线的输入信号,并对其进行翻译,这也就意味着MMU需要更多的晶体管来支持64根地址线的输入,而CPU也需要驱动更多的地址线,但是实际上,在短期内,没有看出有2^64那么多的虚拟地址空间的需求,因此,ARMv8实际上提供了TCR_ELx (Translation Control Register (ELx)可以对MMU的输入地址(也就是虚拟地址)进行配置。为了不把问题复杂化,我们先不考虑TCR_EL2和TCR_EL3这两个寄存器。通过TCR_EL1寄存器中的TxSZ域可以控制虚拟地址空间的size。对于ARM64(是指处于AArch64状态的处理器)而言,最大的虚拟地址的宽度是48 bit,因此虚拟地址空间的范围是0x0000_0000_0000_0000 ~ 0x0000_FFFF_FFFF_FFFF,总共256TB。 当然,具体实现的时候可以选择如下的地址线数目:

config ARM64_VA_BITS
int
default 36 if ARM64_VA_BITS_36
default 39 if ARM64_VA_BITS_39
default 42 if ARM64_VA_BITS_42
default 47 if ARM64_VA_BITS_47
default 48 if ARM64_VA_BITS_48

在代码中,有一个宏定义如下:

#define VA_BITS (CONFIG_ARM64_VA_BITS)

这个宏定义了虚拟地址空间的size。

2、物理地址空间的size是多少?

问过虚拟地址空间的size是多少这个问题之后,很自然的会考虑物理地址空间。基本概念和上一节类似,符合ARMv8的PE最大支持的物理地址宽度也是48个bit,当然,具体的实现可以自己定义(不能超过48个bit),具体的配置可以通过ID_AA64MMFR0_EL1 (AArch64 Memory Model Feature Register 0)这个RO寄存器获取。

3、和地址映射相关的宏定义

4、虚拟地址空间到物理地址空间的映射

MMU主要负责从VA(virutal address)到PA(Physical address)的翻译、memory的访问控制以及memory attribute的控制,这里我们暂时只关注地址翻译功能。不同的exception level和security state有自己独立的地址翻译过程,当然我们这里暂时只关注Non-secure EL1和EL0,在这种状态下,地址翻译可以分成两个stage,不过两个stage是为虚拟化考虑的,因此,为了简化问题,我们先只考虑一个stage。OK,做了这么多的简化之后,我们可以来看看地址翻译过程了(也就是Non-secure EL1和EL0 stage 1情况下的地址翻译过程)。

一个很有意思的改变(针对ARM32而言)是虚拟地址空间被分成了两个VA subrange:

Lower VA subrange : 从0x0000_0000_0000_0000 到 (2^(64-T0SZ) - 1)
Upper VA subrange : 从(2^64 - 2^(64-T1SZ)) 到 0xFFFF_FFFF_FFFF_FFFF

为什么呢?熟悉ARM平台的工程师都形成了固定的印象,当进程切换地址空间的时候,实际上切换了内核地址空间+用户地址空间(total 4G地址空间),而实际上,每次进程切换的时候,内核地址空间都是不变的,实际变化的只有userspace而已。如果硬件支持了VA subrange,那么我们可以这样使用:

Lower VA subrange : process-specific地址空间
Upper VA subrange : kernel地址空间

这样,当进程切换的时候,我们不必切换kernel space,只要切换userspace就OK了。

地址映射的粒度怎么配置呢?地址映射的粒度用通俗的语言讲就是page size(也可能是block size),传统的page size都是4K,ARM64的MMU支持4K、16K和64K的page size。除了地址映射的粒度还有一个地址映射的level的概念,在ARM32的时代,2 level或者3 level是比较常见的配置,对于ARM64,这和page size、物理地址和虚拟地址的宽度都是有关系的,具体请参考ARM ARM文档。

5、AArch64 Linux中虚拟地址空间的布局

把事情搞的太复杂了往往迷失了重点,我们这里再做一个简化就是固定page size是4K,并且VA宽度是48个bit,在这种情况下,虚拟地址空间的布局如下:

具体的映射过程如下:

整个地址翻译的过程是这样的:首先通过虚拟地址的高位可以知道是属于userspace还是kernel spce,从而分别选择TTBR0_EL1(Translation Table Base Register 0 (EL1))或者TTBR1_EL1(Translation Table Base Register 1 (EL1))。这个寄存器中保存了PGD的基地址,该地址指向了一个lookup table,每一个entry都是描述符,可能是Table descriptor、block descriptor或者是page descriptor。如果命中了一个block descriptor,那么地址翻译过程就结束了,当然对于4-level的地址翻译过程,PGD中当然保存的是Table descriptor,从而指向了下一节的Translation table,在kernel中称之PUD。随后的地址翻译概念类似,是一个PMD过程,最后一个level是PTE,也就是传说中的page table entry了,到了最后的地址翻译阶段。这时候PTE中都是一个个的page descriptor,完成最后的地址翻译过程。

三、代码分析

本文涉及的代码就是__create_page_tables这个函数。

1、initial translation tables的位置。

initial translation tables定义在链接脚本文件中(参考arch/arm64/kernel下的vmlinux.lds.S),如下:

. = ALIGN(PAGE_SIZE);
idmap_pg_dir = .;
. += IDMAP_DIR_SIZE;
swapper_pg_dir = .;
. += SWAPPER_DIR_SIZE;

ARM32的的时候,kernel image在RAM开始的位置让出了32KB的memory保存了bootloader到kernel传递的tag参数以及内核空间的页表。在刚开始的时候,ARM64沿用了ARM32的做法,将这些初始页表放到了PHYS_OFFSET和PHYS_OFFSET+TEXT_OFFSET之间(size是0x80000)。但是,其实这段内存是有可能被bootloader使用的,而且,这个时候,memory block模块(确定内核需要管理的memory block)没有ready,想要标记reservation memory也是不可能的。在这种情况下,假设bootloader在这段memory放了些数据,试图传递给kernel,但是kernel如果在这段memory上建立页表,那么就把有用数据给覆盖了。最后,initial translation tables被放到了kernel image的后面,位于bss段之后,从而解决了这个问题。

解决了位置问题之后,我们来看一看size,代码如下:

#if ARM64_SWAPPER_USES_SECTION_MAPS
#define SWAPPER_PGTABLE_LEVELS (CONFIG_PGTABLE_LEVELS - 1)
#define IDMAP_PGTABLE_LEVELS (ARM64_HW_PGTABLE_LEVELS(PHYS_MASK_SHIFT) - 1)
#else
#define SWAPPER_PGTABLE_LEVELS (CONFIG_PGTABLE_LEVELS)
#define IDMAP_PGTABLE_LEVELS (ARM64_HW_PGTABLE_LEVELS(PHYS_MASK_SHIFT))
#endif

#define SWAPPER_DIR_SIZE (SWAPPER_PGTABLE_LEVELS * PAGE_SIZE)
#define IDMAP_DIR_SIZE (IDMAP_PGTABLE_LEVELS * PAGE_SIZE)

ARM64_SWAPPER_USES_SECTION_MAPS这个宏定义是说明了swapper/idmap的映射是否使用section map。什么是section map呢?我们用一个实际的例子来描述。假设VA是48 bit,page size是4K,那么,在地址映射过程中,地址被分成9(level 0) + 9(level 1) + 9(level 2) + 9(level 3) + 12(page offset),对于kernel image这样的big block memory region,使用4K的page来mapping有点得不偿失,在这种情况下,可以考虑让level 2的Translation table entry指向一个2M 的memory region,而不是下一级的Translation table。所谓的section map就是指使用2M的为单位进行映射。当然,不是什么情况都是可以使用section map,对于kernel image,其起始地址是2M对齐的,因此block size是2M的情况下才OK,对于PAGE SIZE是16K,其Block descriptor指向了一个32M的内存块,PAGE SIZE是64K的时候,Block descriptor指向了一个512M的内存块,因此,只有4K page size的情况下,才可以启用section map。

OK,我们回到具体的初始阶段页表大小这个问题上。原来ARM32的时候,一个page就OK了,对于ARM64,由于虚拟地址空间变大了,因此我们需要更多的page来完成启动阶段的initial translation tables的构建。我们仍然用VA是48 bit,page size是4K为例子进行说明。根据前面的描述,我们知道,内核空间的地址大小是256T,48 bit的地址被分成9 + 9 + 9 + 9 + 12,因此PGD(Level 0)、PUD(Level 1)、PMD(Level 2)、PT(Level 3)的translation table中的entry都是512项,每个描述符是8个byte,因此这些translation table都是4KB,恰好是一个page size。根据链接脚本中的定义,idmap和swapper page tables (或者叫做translation table)分别保留了3个page的页面。3个page分别是3个level的translation table。等等,读者可能会问:上面不是说48 bit VA加上4K page size需要4阶translation table吗?这里怎么只有3个level?实际上,3级映射是PGD/PUM/PMD(每个table占据一个page),只不过PMD的内容不是下一级的table descriptor,而是基于2M block的mapping(或者说PMD中的描述符是block descriptor)。

2、创建页表前的准备

代码如下:

__create_page_tables:
adrp x25, idmap_pg_dir ------获取idmap的页表基地址(物理地址)
adrp x26, swapper_pg_dir -----获取kernel space的页表基地址(物理地址)
mov x27, lr ------保存lr

mov x0, x25 ----------准备要invalid cache的地址段的首地址
add x1, x26, #SWAPPER_DIR_SIZE -------准备要invalid cache的地址段的尾地址
bl __inval_cache_range ----将idmap和swapper页表地址段对应的cacheline设定为无效
mov x0, x25 -------这一段代码是将idmap和swapper页表内容设定为0
add x6, x26, #SWAPPER_DIR_SIZE ----x0是开始地址,x6是结束地址
1: stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
stp xzr, xzr, [x0], #16
cmp x0, x6
b.lo 1b

这段代码没有什么特别要说明的,除了adrp这条指令。adrp是计算指定的符号地址到run time PC值的相对偏移(不过,这个offset没有那么精确,是以4K为单位,或者说,低12个bit是0)。在指令编码的时候,立即数(也就是offset)占据21个bit,此外,由于偏移计算是按照4K进行的,因此最后计算出来的符号地址必须要在该指令的-4G和4G之间。由于执行该指令的时候,还没有打开MMU,因此通过adrp获取的都是物理地址,当然该物理地址的低12个bit是全零的。此外,由于在链接脚本中idmap_pg_dir和swapper_pg_dir是page size aligned,因此使用adrp指令也是OK的。

为什么要调用__inval_cache_range来invalidate idmap_pg_dir和swapper_pg_dir对应页表空间的cache呢?根据boot protocol,代码执行到此,对于cache的要求是kernel image对应的那段空间的cache line是clean到PoC的,不过idmap_pg_dir和swapper_pg_dir对应页表空间不属于kernel image的一部分,因此其对应的cacheline很可能有一些旧的,无效的数据,必须要清理掉。

顺便再提一句,将idmap和swapper页表内容设定为0是有意义的。实际上这些translation table中的大部分entry都是没有使用的,PGD和PUD都是只有一个entry是有用的,而PMD中有效的entry数目是和mapping的地址size有关。将页表内容清零也就是意味着将页表中所有的描述符设定为invalid(描述符的bit 0指示是否有效,等于0表示无效描述符)。

3、创建identity mapping

identity mapping实际上就是建立了整个内核(从KERNEL_START到KERNEL_END)的一致性mapping,就是将物理地址所在的虚拟地址段mapping到物理地址上去。为什么这么做呢?ARM ARM文档中有一段话:
If the PA of the software that enables or disables a particular stage of address translation differs from its VA, speculative instruction fetching can cause complications. ARM strongly recommends that the PA and VA of any software that enables or disables a stage of address translation are identical if that stage of translation controls translations that apply to the software currently being executed.

由于打开MMU操作的时候,内核代码欢快的执行,这时候有一个地址映射ON/OFF的切换过程,这种一致性映射可以保证在在打开MMU那一点附近的程序代码可以平滑切换。具体的操作分成两个阶段,第一个阶段是通过create_pgd_entry建立中间level(也就是PGD和PUD)的描述符,第二个阶段是创建PMD的描述符,由于PMD的描述符是block descriptor,因此,完成PMD的设定后就完成了整个identity mapping页表的设定。具体代码如下:

ldr x7, =MM_MMUFLAGS
mov x0, x25---------x0保存了idmap_pg_dir变量的物理地址
adrp x3, KERNEL_START---x3保存了内核image的物理地址
create_pgd_entry x0, x3, x5, x6
mov x5, x3 // __pa(KERNEL_START)
adr_l x6, KERNEL_END // __pa(KERNEL_END)
create_block_map x0, x7, x3, x5, x6

create_pgd_entry用来在PGD(level 0 translation table)中创建一个描述符,如果需要下一级的translation table,也需要同时建立,最终的要求是能够完成所有中间level的translation table的建立(其实每个table中都是只建立了一个描述符),对于identity mapping,这里需要PGD和PUD就OK了。该函数需要四个参数:x0是pgd的地址,具体要创建哪一个地址的描述符由x3指定,x5和x6是临时变量,create_pgd_entry具体代码如下:

.macro create_pgd_entry, tbl, virt, tmp1, tmp2
create_table_entry \tbl, \virt, PGDIR_SHIFT, PTRS_PER_PGD, \tmp1, \tmp2
create_table_entry \tbl, \virt, TABLE_SHIFT, PTRS_PER_PTE, \tmp1, \tmp2
.endm

create_table_entry这个宏定义主要是用来创建一个translation table的描述符,具体创建哪一个level的Translation table descriptor是由tbl参数指定的。怎么来创建描述符呢?如果是table descriptor,那么该描述符需要指向下一级页表基地址,当然,create_table_entry参数并没有给出,是在程序中hardcode实现:L(n)的translation table中的描述符指向的L(n+1) Translation table位于L(n)translation table所在page的下一个page(太拗口了,但是我也懒得画图了)。shift和ptrs这两个参数用来计算页表内的index,具体算法可以参考下面的代码:

.macro create_table_entry, tbl, virt, shift, ptrs, tmp1, tmp2
lsr \tmp1, \virt, #\shift----------------------------(1)
and \tmp1, \tmp1, #\ptrs - 1 -------------------------(2)
add \tmp2, \tbl, #PAGE_SIZE------------------------(3)
orr \tmp2, \tmp2, #PMD_TYPE_TABLE--------------------(4)
str \tmp2, [\tbl, \tmp1, lsl #3]------------------------(5)
add \tbl, \tbl, #PAGE_SIZE -------------------------(6)
.endm

(1)如果是PGD,那么shift等于PGDIR_SHIFT,也就是39了。根据第二章的描述,我们知道L0 index(PGD index)使用虚拟地址的bit[47:39]。如果是PUD,那么shift等于PUD_SHIFT,也就是30了(注意:L1 index(PUD index)使用虚拟地址的bit[38:30])。要想找到virt这个地址(实际传入的是物理地址,当然,我们本来就是要建立和物理地址一样的虚拟地址的mapping)在translation table中的index,当然需要右移shift个bit了。

(2)除了右移操作,我们还需要mask操作(ptrs - 1实际上就是掩码)。对于PGD,其index占据9个bit,因此mask是0x1ff。同样的,对于PUD,其index占据9个bit,因此mask是0x1ff。至此,tmp1就是virt地址在translation table中对应的index了。

(3)如果是table描述符,需要指向另外一个level的translation table,在哪里呢?答案就是next page,读者可以自行回忆链接脚本中的3个连续的idmap_pg_dir的page定义。

(4)光有下一级translation table的地址不行,还要告知该描述符是否有效(set bit 0),该描述符的类型是哪一种类型(set bit 1表示是table descriptor),至此,描述符内容准备完毕,保存在tmp2中。

(5)最关键的一步,将描述符写入页表中。之所以有“lsl #3”操作,是因为一个描述符占据8个Byte。

(6)将translation table的地址移到next level,以便进行下一步设定。

如果你足够细心,一定不会忽略这样的一个细节。获取KERNEL_START和KERNEL_END的代码是不一样的,对于KERNEL_START直接使用了adrp x3, KERNEL_START,而对于KERNEL_END使用了adr_l x6, KERNEL_END。具体使用哪一个是和该地址是否4K对齐相关的。KERNEL_START一定是4K对齐的,而KERNEL_END就不一定了,虽然在4.1.10中KERNEL_END也是4K对齐的,不过没有任何协议保证这一点,为了保险起见,代码使用了adr_l,确保获取正确的KERNEL_END的物理地址。

回到create_pgd_entry函数中,这个函数填充了内核image首地址对应的1G memory range所需要的Translation table描述符,听起来很吓人,不过就是两个描述符,一个是在PGD中,另外一个是在PUD中。虽然只有两个描述符,可以可以支持1G虚拟地址的mapping了。当然具体mapping多少(PMD中有多少entry),还是要看kernel image的size了。

OK,来到PMD部分的设定了,我们看看代码:

.macro create_block_map, tbl, flags, phys, start, end
lsr \phys, \phys, #BLOCK_SHIFT
lsr \start, \start, #BLOCK_SHIFT
and \start, \start, #PTRS_PER_PTE - 1 // table index
orr \phys, \flags, \phys, lsl #BLOCK_SHIFT // table entry
lsr \end, \end, #BLOCK_SHIFT
and \end, \end, #PTRS_PER_PTE - 1 // table end index
9999: str \phys, [\tbl, \start, lsl #3] // store the entry
add \start, \start, #1 // next entry
add \phys, \phys, #BLOCK_SIZE // next block
cmp \start, \end
b.ls 9999b
.endm

create_block_map的名字起得不错,该函数就是在tbl指定的Translation table中建立block descriptor以便完成address mapping。具体mapping的内容是将start 到 end这一段VA mapping到phys开始的PA上去。其实这里的代码逻辑和上面类似,我们这里就不详述,需要提及的是PTE已经进入了最后一个level的mapping,因此描述符中除了地址信息之外(占据bit[47:21],还需要memory attribute和memory accesse的信息。对于这个场景,PMD中是block descriptor,因此描述符中还包括了block attribute域,分成upper block attribute[63:52]和lower block attribute[11:2]。对这些域的定义如下:

在代码中,block attribute是通过flags参数传递的,MM_MMUFLAGS定义如下:

#define MM_MMUFLAGS PMD_ATTRINDX(MT_NORMAL) | PMD_FLAGS
#define PMD_FLAGS PMD_TYPE_SECT | PMD_SECT_AF | PMD_SECT_S

MT_NORMAL表示该段内存的memory type是普通memory(对应AttrIndx[2:0]),而不是device什么的。PMD_TYPE_SECT 说明该描述符是一个有效的(bit 0等于1)的block descriptor(bit 1等于0)。PMD_SECT_AF中的AF是access flag的意思,表示该memory block(或者page)是否被最近被访问过。当然,这需要软件的协助。如果该bit被设置为0,当程序第一次访问的时候会产生异常,软件需要将给bit设置为1,之后再访问该page的时候,就不会产生异常了。不过当软件认为该page已经old enough的时候,也可以clear这个bit,表示最近都没有访问该page。这个flag是硬件对page reclaim算法的支持,找到最近不常访问的那些page。当然在这个场景下,我们没有必要enable这个特性,因此将其设定为1。PMD_SECT_S对应SH[1:0],描述memory的sharebility。这些内容和memory attribute相关,我们会在后续的文档中描述,这里就不偏离主题了。

广大人民群众最关心的当然也是最熟悉的是memory access control,这是通过AP[2:1]域来控制的。这里该域被设定为00b,表示EL1状态下是RW,EL0状态不可访问。UXN和PXN是用来控制可执行权限的,这里UXN和PXN都是0,表示EL1和EL0状态下都是excutable的。

4、创建kernel space mapping

要创建kernel space的页表了,遇到的第一个问题就是:mapping多少呢?kernel space辣么大,256T,不可能全部都mapping。OK,答案就是创建两部分的页表,一个从kernel image的开始地址(包括开始的那一段TEXT_OFFSET的保留区域)到kernel image的结束地址(内核的正常运行需要这段mapping),这一段覆盖了内核的正文段、各种data段、bss段、各种奇奇怪怪段等。还以一个就是bootloader传递过来的blob memory对应的页表。我们先看第一段kernel image的mapping:

mov x0, x26-------------------------(1)
mov x5, #PAGE_OFFSET-------------------(2)
create_pgd_entry x0, x5, x3, x6-----------------(3)
ldr x6, =KERNEL_END------end address
mov x3, x24 // phys offset
create_block_map x0, x7, x3, x5, x6---------------(4)

(1)swapper_pg_dir其实就是swapper进程(pid等于0的那个,其实就是idle进程)的地址空间,这时候,x0指向了内核地址空间的PGD的基地址。

(2)PAGE_OFFSET是kernel image的首地址,对于48bit的VA而言,该地址是0xffff8000-00000000。

(3)创建PAGE_OFFSET(即kernel image首地址)对应的PGD和PUD中的描述符。

(4)创建PMD中的描述符。x24保存了__PHYS_OFFSET,实际上也就是kernel image的首地址(物理地址)。

完成了kernel image的mapping,我们来看看blob mapping的建立。由于ARM64 boot protocol要求blob必须在内核空间开始的512MB内(同时要求8字节对齐,dtb image不能越过2M section size的边界),因此实际上PGD和PUD都不需要建立了,只要建立PMD的描述符就OK了。对应的PMD描述符的建立代码如下:

mov x3, x21--------------FDT phys address
and x3, x3, #~((1 << 21) - 1) ------2MB aligned
mov x6, #PAGE_OFFSET-------kernel space start virtual address
sub x5, x3, x24------------subtract kernel space start physical address
tst x5, #~((1 << 29) - 1) --------within 512MB?
csel x21, xzr, x21, ne ---------bad blob parameter and zero the FDT pointer
b.ne 1f
add x5, x5, x6 ------------x5 equal blob virtual address
add x6, x5, #1 << 21 ---------mapping 2M size
sub x6, x6, #1
create_block_map x0, x7, x3, x5, x6---create blob block descriptor in PMD

5、收尾

mov x0, x25-------再次invalid上文中建立page table memory对应的cache
add x1, x26, #SWAPPER_DIR_SIZE
dmb sy
bl __inval_cache_range
mov lr, x27------恢复lr
ret-----------返回
ENDPROC(__create_page_tables)

由于页表中写了新的内容,而且是在没有打开cache的情况下写的,这时候,cache line的数据有可能被speculatively load,因此再次invalid是一个比较保险的做法。

文章来源:蜗窝科技

围观 514

ARM支持7种异常。问题时发生了异常后ARM是如何响应的呢?下面一起来学习一下:

所有的系统引导程序前面中会有一段类似的代码,如下:

从中我们可以看出,ARM支持7种异常。问题时发生了异常后ARM是如何响应的呢?第一个复位异常很好理解,它放在0x0的位置,一上电就执行它,而且我们的程序总是从复位异常处理程序开始执行的,因此复位异常处理程序不需要返回。那么怎么会执行到后面几个异常处理函数呢?

看看书后,明白了ARM对异常的响应过程,于是就能够回答以前的这个疑问。

当一个异常出现以后,ARM会自动执行以下几个步骤:

(1)把下一条指令的地址放到连接寄存器LR(通常是R14),这样就能够在处理异常返回时从正确的位置继续执行。

(2)将相应的CPSR(当前程序状态寄存器)复制到SPSR(备份的程序状态寄存器)中。从异常退出的时候,就可以由SPSR来恢复CPSR。

(3)根据异常类型,强制设置CPSR的运行模式位。

(4)强制PC(程序计数器)从相关异常向量地址取出下一条指令执行,从而跳转到相应的异常处理程序中。

至于这些异常类型各代表什么,我也没有深究。因为平常就关心reset了,也没有必要弄清楚。

ARM规定了异常向量的地址:

这样理解这段代码就非常简单了。碰到异常时,PC会被强制设置为对应的异常向量,从而跳转到相应的处理程序,然后再返回到主程序继续执行。

这些引导程序的中断向量,是仅供引导程序自己使用的,一旦引导程序引导Linux内核完毕后,会使用自己的中断向量。

嗬嗬,这又有问题了。比如,ARM发生中断(irq)的时候,总是会跑到0x18上执行啊。那Linux内核又怎么能使用自己的中断向量呢?原因在于Linux内核采用页式存储管理。开通MMU的页面映射以后,CPU所发出的地址就是虚拟地址而不是物理地址。就Linux内核而言,虚拟地址0x18经过映射以后的物理地址就是0xc000 0018。所以Linux把中断向量放到0xc000 0018就可以了。

另外,说一下MMU。说句实话,还不是很明白这个MMU机理。参加Intel培训的时候,李眈说了MMU的两个主要作用:

(1)安全性:规定访问权限

(2)提供地址空间:把不连续的空间转换成连续的。

第2点是不是实现页式存储的意思?

补充一下:

.globl _start ;系统复位位置

_start: b reset ;各个异常向量对应的跳转代码

ldr pc, _undefined_instruction ;未定义的指令异常

……

_undefined_instruction :

.word undefined_instruction

也许有人会有疑问,同样是跳转指令,为什么第一句用的是 b reset;

而后面的几个都是用ldr?

为了理解这个问题,我们以未定义的指令异常为例。

当发生了这个异常后,CPU总是跳转到0x4,这个地址是虚拟地址,它映射到哪个物理地址

取决于具体的映射。

ldr pc, _undefined_instruction

相对寻址,跳转到标号_undefined_instruction,然而真正的跳转地址其实是_undefined_instruction的内容——undefined_instruction。那句.word的相当于:

_undefined_instruction dw undefined_instruction (详见毕设笔记3)。

这个地址undefined_instruction到底有多远就难说了,也许和标号_undefined_instruction在同一个页面,也许在很远的地方。不过除了reset,其他的异常是MMU开始工作之后才可能发生的,因此undefined_instruction 的地址也经过了MMU的映射。

在刚加电的时候,CPU从0x0开始执行,MMU还没有开始工作,此时的虚拟地址和物理地址相同;另一方面,重启在MMU开始工作后也有可能发生,如果reset也用ldr就有问题了,因为这时候虚拟地址和物理地址完全不同。

因此,之所以reset用b,就是因为reset在MMU建立前后都有可能发生,而其他的异常只有在MMU建立之后才会发生。用b reset,reset子程序与reset向量在同一页面,这样就不会有问题(b是相对跳转的)。如果二者相距太远,那么编译器会报错的。

来源:互联网(版权归原著作者所有)

围观 527

常把单片机系统的复位分为冷启动和热启动。所谓冷启动,也就是一般所说的上电复位,冷启动后片内外RAM的内容是随机的,通常是0x00或0xFF;单片机的热启动是通过外部电路给运行中的单片机的复位端一复位电平而实现的,也就是所说的按键复位或看门狗复位。复位后,RAM的内容都没有改变。在某些场合,必须区分出设备的重启是热重启还是冷重启。常用的方法是:确定某内存单位为标志位(如0x40003FF4~0x40003FF7 RAM单元),启动时首先读该内存单元的内容,如果它等于一个特定的值(例如为0xAA55AA55),就认为是热启动,否则就是冷启动。

根据以上的设计思路思路定义一个变量:

uint32 unStartFlag;

在程序启动时判断:

if(unStartFlag==0xAA55AA55)

{

//热启动处理

}

else

{

//冷启动处理

unStartFlag=0xAA55AA55;

}

然而实际调试中发现,无论是热启动还是冷启动,开机后所有内存单元的值都被复位为0,当然也实现不了热启动的要求。通过看keil MDK自带的启动代码Startup.s,在这个启动代码中也并没有发现将整个RAM区域清零的语句。反汇编程序,发现从启动代码执行结束到跳转到main函数过程中,编译器还执行了很多库函数,其中__scatterload_zeroinit函数将所有W/R RAM都初始化为0(默认设置下)。为了判断冷、热启动,必须人为控制某些特定RAM在复位时不被编译器初始化为0。通过查找编译器手册,在为处理器的RAM中分出一块小片RAM,设置为NoInit格式(不对其初始化为0),如下图:

然后使用__at关键字将冷、热启动标志位定位到这个NoInit区域:

uint32 unStartFlag __at (0x40003FF4);

这样,当热启动时,变量unStartFlag所在的内存区域就不会被初始化为0,也实现了冷热启动的判断。

定义铁电0xFF7~0xFF8区域存储冷启动次数

0xFF9~0xFFA区域存储热启动次数

0xFFB~0xFFC区域存储总启动次数

围观 254

作者:linuxer

一、前言

kernel的整个启动过程涉及的内容很多,不可能每一个细节都描述清楚,因此我打算针对部分和ARM64相关的启动步骤进行学习、整理,并方便后续查阅。本文实际上描述在系统启动最开始的时候,bootloader和kernel的交互以及kernel如何保存bootloader传递的参数并进行校验,此外,还有一些最基础的硬件初始化的内容。
本文中的source来自4.1.10内核,这是一个long term的版本,后续一段时间的文章都会基于这个long term版本进行。

二、进入kernel之前

系统启动过程中,linux kernel不是一个人在战斗,在kernel之前bootloader会执行若干的动作,然后把控制权转移给linux kernel。需要特别说明的是:这里bootloader是一个宽泛的概念,其实就是为kernel准备好执行环境的那些软件,可能是传统意义的bootloader(例如Uboot),也可能是Hypervisor或者是secure monitor。具体bootloader需要执行的动作包括:

1、初始化系统中的RAM并将RAM的信息告知kernel

2、准备好device tree blob的信息并将dtb的首地址告知kernel

3、解压内核(可选)

4、将控制权转交给内核。当然,bootloader和kernel的交互的时候需求如下:

MMU = off, D-cache = off, I-cache = on or off
x0 = physical address to the FDT blob

这里需要对data cache和instruction cache多说几句。我们知道,具体实现中的ARMv8处理器的cache是形成若干个level,一般而言,可能L1是分成了data cache和instruction cache,而其他level的cache都是unified cache。上面定义的D-cache off并不是说仅仅disable L1的data cache,实际上是disable了各个level的data cache和unified cache。同理,对于instruction cache亦然。

此外,在on/off控制上,MMU和data cache是有一定关联的。在ARM64中,SCTLR, System Control Register用来控制MMU icache和dcache,虽然这几个控制bit是分开的,但是并不意味着MMU、data cache、instruction cache的on/off控制是彼此独立的。一般而言,这里MMU和data cache是绑定的,即如果MMU 是off的,那么data cache也必须要off。因为如果打开data cache,那么要设定memory type、sharebility attribute、cachebility attribute等,而这些信息是保存在页表(Translation table)的描述符中,因此,如果不打开MMU,如果没有页表翻译过程,那么根本不知道怎么来应用data cache。当然,是不是说HW根本不允许这样设定呢?也不是了,在MMU OFF而data cache是ON的时候,这时候,所有的memory type和attribute是固定的,即memory type都是normal Non-shareable的,对于inner cache和outer cache,其策略都是Write-Back,Read-Write Allocate的。

更详细的ARM64 boot protocol请参考Documentation/arm64/booting.txt文档。

三、参数的保存和校验

最开始的ARM64启动代码位于arch/arm64/kernel/head.S文件中,代码如下:

ENTRY(stext)
bl preserve_boot_args
bl el2_setup // Drop to EL1, w20=cpu_boot_mode
adrp x24, __PHYS_OFFSET
bl set_cpu_boot_mode_flag
bl __vet_fdt
……
ENDPROC(stext)

1、preserve_boot_args

preserve_boot_args:
mov x21, x0------将dtb的地址暂存在x21寄存器中,释放出x0以便后续做临时变量使用
adr_l x0, boot_args---x0保存了boot_args变量的地址
stp x21, x1, [x0]----保存x0和x1的值到boot_args[0]和boot_args[1]
stp x2, x3, [x0, #16] ---保存x2和x3的值到boot_args[2]和boot_args[3]
dmb sy---------full system data memory barrier
add x1, x0, #0x20----x0和x1是传递给__inval_cache_range的参数
b __inval_cache_range
ENDPROC(preserve_boot_args)

由于MMU = off, D-cache = off,因此写入boot_args变量的操作都是略过data cache的,直接写入了RAM中(前面说过了,这里的D-cache并不是特指L1的data cache,而是各个level的data cache和unified cache),为了安全起见(也许bootloader中打开了D-cache并操作了boot_args这段memory,从而在各个level的data cache和unified cache有了一些旧的,没有意义的数据),需要将boot_args变量对应的cache line进行清除并设置无效。在调用__inval_cache_range之前,x0是boot_args这段memory的首地址,x1是末尾的地址(boot_args变量长度是4x8byte=32byte,也就是0x20了)。

为何要保存x0~x3这四个寄存器呢?因为ARM64 boot protocol对启动时候的x0~x3这四个寄存器有严格的限制:x0是dtb的物理地址,x1~x3必须是0(非零值是保留将来使用)。在后续setup_arch函数执行的时候会访问boot_args并进行校验。

对于invalidate cache的操作而言,我们可以追问几个问题:如果boot_args所在区域的首地址和尾部地址没有对齐到cache line怎么办?具体invalidate cache需要操作到那些level的的cache?这些问题可以通过阅读__inval_cache_range的代码获得答案,这里就不描述了。

还有一个小细节是如何访问boot_args这个符号的,这个符号是一个虚拟地址,但是,现在没有建立好页表,也没有打开MMU,如何访问它呢?这是通过adr_l这个宏来完成的。这个宏实际上是通过adrp这个汇编指令完成,通过该指令可以将符号地址变成运行时地址(通过PC relative offset形式),因此,当运行的MMU OFF mode下,通过adrp指令可以获取符号的物理地址。不过adrp是page对齐的(adrp中的p就是page的意思),boot_args这个符号当然不会是page size对齐的,因此不能直接使用adrp,adr_l这个宏进行处理,如果读者有兴趣可以自己看source code。

最后,我们来解释一下dmb sy这一条指令。在ARM ARM文档中,有关于数据访问指令和 data cache指令之间操作顺序的约定,原文如下:

All data cache instructions, other than DC ZVA, that specify an address can execute in any order relative to loads or stores that access any address with the Device memory attribute,or with Normal memory with Inner Non-cacheable attribute unless a DMB or DSB is executed between the instructions.

因此,在Non-cacheable的情况下,必须要使用DMB来保证stp指令在dc ivac指令之前执行完成。

2、el2_setup

程序执行至此,CPU处于哪一个exception level呢?根据ARM64 boot protocol,CPU要么处于EL2(推荐)或者non-secure EL1。如果在EL1,情形有些类似过去arm处理器的感觉,处于EL2稍微复杂一些,需要对virtualisation extensions进行基本的设定,然后将cpu退回到EL1。代码太长了,我们分成两段来阅读,第一段如下:

ENTRY(el2_setup)
mrs x0, CurrentEL------------------------(1)
cmp x0, #CurrentEL_EL2------判断是否处于EL2
b.ne 1f--------------不是的话,跳到1f
mrs x0, sctlr_el2-------------------------(2)
CPU_BE( orr x0, x0, #(1 << 25) ) // Set the EE bit for EL2
CPU_LE( bic x0, x0, #(1 << 25) ) // Clear the EE bit for EL2
msr sctlr_el2, x0----写回sctlr_el2寄存器
b 2f
1: mrs x0, sctlr_el1-------------------------(3)
CPU_BE( orr x0, x0, #(3 << 24) ) // Set the EE and E0E bits for EL1
CPU_LE( bic x0, x0, #(3 << 24) ) // Clear the EE and E0E bits for EL1
msr sctlr_el1, x0
mov w20, #BOOT_CPU_MODE_EL1----w20寄存器保存了cpu启动时候的Eexception level
isb---------instruction memory barrier
ret
2: mov x0, #(1 << 31) ------------------------(4)
msr hcr_el2, x0
mrs x0, cnthctl_el2 -------------------------(5)
orr x0, x0, #3 // Enable EL1 physical timers
msr cnthctl_el2, x0
msr cntvoff_el2, xzr // Clear virtual offset
mrs x0, id_aa64pfr0_el1 -----------------------(6)
ubfx x0, x0, #24, #4 ----取出24 bit开始的4个bit的值并将该值赋给x0
cmp x0, #1
b.ne 3f -----不支持system register接口
mrs_s x0, ICC_SRE_EL2
orr x0, x0, #ICC_SRE_EL2_SRE // Set ICC_SRE_EL2.SRE==1
orr x0, x0, #ICC_SRE_EL2_ENABLE // Set ICC_SRE_EL2.Enable==1
msr_s ICC_SRE_EL2, x0
isb // Make sure SRE is now set
msr_s ICH_HCR_EL2, xzr // Reset ICC_HCR_EL2 to defaults
3: ……

(1)当前的exception level保存在PSTATE中,程序可以通过MRS或者MSR来访问PSTATE,当然需要传递一个Special-purpose register做为参数,CurrentEL就是获取PSTATE中current exception level域的特殊寄存器。

(2)sctlr_el2也是一个可以通过MRS/MSR指令访问的寄存器,当CPU处于EL2状态的时候,该寄存器可以控制整个系统的行为。当然,这里仅仅是设定EL2下的数据访问和地址翻译过程中的endianess配置,也就是EE bit[25]。根据配置,CPU_BE和CPU_LE包围的指令只会保留一行。对于little endian而言,实际上就是将sctlr_el2寄存器的EE(bit 25)设定为0。顺便说一下,这个bit不仅仅控制EL2数据访问的endianess以及EL2 stage 1的地址翻译过程中的endianess(当然,EL2只有stage 1),还可以控制EL1和EL0 stage 2地址翻译的过程的endianess(这时候有两个stage的地址翻译过程)。

(3)执行到这里说明CPU处于EL1,这种状态下没有权限访问sctlr_el2,只能是访问sctlr_el1。sctlr_el1可以通过EE和E0E来控制EL1和EL0状态下是little endian还是big endian。EE bit控制了EL1下的数据访问以及EL1和EL0 stage 1地址翻译的过程的endianess。E0E bit用来控制EL0状态下的数据访问的endianess。此外,需要注意的是:由于修改了system control register(设定endianess状态),因此需要一个isb来同步(具体包括两部分的内容,一是确认硬件已经执行完毕了isb之前的所有指令,包括修改system control寄存器的那一条指令,另外一点是确保isb之后的指令从新来过,例如取指,校验权限等)。

(4)执行到这里说明CPU处于EL2,首先设定的是hcr_el2寄存器,Hypervisor Configuration Register。该寄存器的大部分bit 值在reset状态的时候就是0值,只不过bit 31(Register Width Control)是implementation defined,因此这里set 31为1,确保Low level的EL1也是Aarch64的

(5)这一段代码是对Generic timers进行配置。要想理解这段代码,我们需要简单的了解一些ARMv8上Generic timer的运作逻辑。一个全局范围的system counter、各个PE上自己专属的local timer以及连接这些组件之间的bus或者信息传递机制组成了Generic Timer。对于PE而言,通过寄存器访问,它能看到的是physical counter(实际的system counter计数)、virtual counter(physical counter基础上的offset)、physical timer、virtual timer等。NTHCTL_EL2,Counter-timer Hypervisor Control register,用来控制系统中的physical counter和virutal counter如何产生event stream以及在EL1和EL0状态访问physical counter和timer的硬件行为的。在EL1(EL0)状态的时候访问physical counter和timer有两种配置,一种是允许其访问,另外一种就是trap to EL2。这里的设定是:不陷入EL2(对应的bit设置为1)。更详细的信息可以参考ARMv8 ARM文档。cntvoff_el2是virtual counter offset,所谓virtual counter,其值就是physical counter的值减去一个offset的值(也就是cntvoff_el2的值了),这里把offset值清零,因此virtual counter的计数和physical counter的计数是一样的。

(6)这一段代码是对GIC V3进行配置。ID_AA64PFR0_EL1,AArch64 Processor Feature Register 0,该寄存器描述了PE实现的feature。GIC bits [27:24]描述了该PE是否实现了system register来访问GIC,如果没有(GIC bits 等于0)那么就略过GIC V3的设定。ICC_SRE_EL2,Interrupt Controller System Register Enable register (EL2),该寄存器用来(在EL2状态时候)控制如何访问GIC CPU interface模块的,可以通过memory mapped方式,也可以通过system register的方式。将SRE bit设定为1确保通过system register方式进行GIC interface cpu寄存器的访问。将enable bit设定为1确保在EL1状态的时候可以通过ICC_SRE_EL1寄存器对GIC进行配置而不是陷入EL2。

下面我们进入第二段代码:

mrs x0, midr_el1 -----------------------------(1)
mrs x1, mpidr_el1
msr vpidr_el2, x0
msr vmpidr_el2, x1
mov x0, #0x0800 // Set/clear RES{1,0} bits ---------------(2)
CPU_BE( movk x0, #0x33d0, lsl #16 ) // Set EE and E0E on BE systems
CPU_LE( movk x0, #0x30d0, lsl #16 ) // Clear EE and E0E on LE systems
msr sctlr_el1, x0
mov x0, #0x33ff-------Disable Coprocessor traps to EL2
msr cptr_el2, x0 // Disable copro. traps to EL2
#ifdef CONFIG_COMPAT-----是否支持64 bit kernel上运行32bit 的application
msr hstr_el2, xzr // Disable CP15 traps to EL2
#endif
mrs x0, pmcr_el0------------------------------(3)
ubfx x0, x0, #11, #5 // to EL2 and allow access to
msr mdcr_el2, x0 // all PMU counters from EL1
msr vttbr_el2, xzr ----清除Stage-2 translation table base address register
adrp x0, __hyp_stub_vectors
add x0, x0, #:lo12:__hyp_stub_vectors
msr vbar_el2, x0 ---------------设定EL2的异常向量表的基地址
mov x0, #(PSR_F_BIT | PSR_I_BIT | PSR_A_BIT | PSR_D_BIT |\
PSR_MODE_EL1h)
msr spsr_el2, x0 ------------------------------(4)
msr elr_el2, lr
mov w20, #BOOT_CPU_MODE_EL2 // This CPU booted in EL2
eret-------------------------------------(5)
ENDPROC(el2_setup)

(1)midr_el1和mpidr_el1都属于标识该PE信息的read only寄存器。MIDR_EL1,Main ID Register主要给出了该PE的architecture信息,Implementer是谁等等信息。MPIDR_EL1,Multiprocessor Affinity Register,该寄存器保存了processor ID。vpidr_el2和vmpidr_el2是上面的两个寄存器是对应的,只不过是for virtual processor的。

(2)这段代码实际上是将0x33d00800(BE)或者0x30d00800(LE)写入sctlr_el1寄存器。BE和LE的设定和上面第一段代码中的描述是类似的,其他bit的设定请参考ARMv8 ARM文档

(3)PMCR_EL0,Performance Monitors Control Register,该寄存器的[15:11]标识了支持的Performance Monitors counter的数目,并将其设定到MDCR_EL2(Monitor Debug Configuration Register (EL2))中。MDCR_EL2中其他的bit都设定为0,其结果就是允许EL0和EL1进行debug的操作(而不是trap to EL2),允许EL1访问Performance Monitors counter(而不是trap to EL2)。

(4)当系统发生了异常并进入EL2,SPSR_EL2,Saved Program Status Register (EL2)会保存处理器状态,ELR_EL2,Exception Link Register (EL2)会保存返回发生exception的现场的返回地址。这里是设定SPSR_EL2和ELR_EL2的初始值。w20寄存器保存了cpu启动时候的Eexception level ,因此w20被设定为BOOT_CPU_MODE_EL2。

(5)eret指令是用来返回发生exception的现场。实际上,这个指令仅仅是模拟了一次异常返回而已,SPSR_EL2和ELR_EL2都已经设定OK,执行该指令会使得CPU返回EL1状态,并且将SPSR_EL2的值赋给PSTATE,ELR_ELR就是返回地址(实际上也恰好是函数的返回地址)。

完成了el2_setup这个函数分析之后,我们再回头思考这样的问题:为何是el2_setup?为了没有el3_setup?当一个SOC的实现在包括了EL3的支持,那么CPU CORE缺省应该进入EL3状态,为何这里只是判断EL2还是EL1,从而执行不同的流程,如果是EL3状态,代码不就有问题了吗?实际上,即便是由于SOC支持TrustZone而导致cpu core上电后进入EL3,这时候,接管cpu控制的一定不是linux kernel(至少目前来看linux kernel不会做Secure monitor),而是Secure Platform Firmware(也就是传说中的secure monitor),它会进行硬件平台的初始化,loading trusted OS等等,等到完成了secure world的构建之后,把控制权转交给non-secure world,这时候,CPU core多半处于EL2(如果支持虚拟化)或者EL1(不支持虚拟化)。因此,对于linux kernel而言,它感知不到secure world(linux kernel一般也不会做Trusted OS),仅仅是在non-secure world中呼风唤雨,可以是Hypervisor或者rich OS。

3、set_cpu_boot_mode_flag

在进入这个函数的时候,有一个前提条件:w20寄存器保存了cpu启动时候的Eexception level ,具体代码如下:

ENTRY(set_cpu_boot_mode_flag)
adr_l x1, __boot_cpu_mode
cmp w20, #BOOT_CPU_MODE_EL2
b.ne 1f
add x1, x1, #4
1: str w20, [x1] // This CPU has booted in EL1
dmb sy
dc ivac, x1 // Invalidate potentially stale cache line
ret
ENDPROC(set_cpu_boot_mode_flag)

由于系统启动之后仍然需要了解cpu启动时候的Eexception level(例如判断是否启用hyp mode),因此,有一个全局变量__boot_cpu_mode用来保存启动时候的CPU mode。代码很简单,大家自行体会就OK了,我这里补充几点描述:

(1)本质上我们希望系统中所有的cpu在初始化的时候处于同样的mode,要么都是EL2,要么都是EL1,有些EL2,有些EL1是不被允许的(也许只有那些精神分裂的bootloader才会这么搞)。

(2)所有的cpu core在启动的时候都处于EL2 mode表示系统支持虚拟化,只有在这种情况下,kvm模块可以顺利启动。

(3)set_cpu_boot_mode_flag和el2_setup这两个函数会在各个cpu上执行。

(4)变量__boot_cpu_mode定义如下:

ENTRY(__boot_cpu_mode)
.long BOOT_CPU_MODE_EL2--------A
.long BOOT_CPU_MODE_EL1--------B
如果cpu启动的时候是EL1 mode,会修改变量__boot_cpu_mode A域,将其修改为BOOT_CPU_MODE_EL1。如果cpu启动的时候是EL2 mode,会修改变量__boot_cpu_mode B域,将其修改为BOOT_CPU_MODE_EL2。

4、__vet_fdt

在进入具体函数之前,x21和x24都被设定成了指定的值。x21被设定为fdt在RAM中的物理地址(参考preserve_boot_args函数),x24被设定为__PHYS_OFFSET,定义为:

#define __PHYS_OFFSET (KERNEL_START - TEXT_OFFSET)
#define KERNEL_START _text

KERNEL_START是kernel开始运行的虚拟地址,更确切的说是内核正文段开始的虚拟地址。 在链接脚本文件中(参考arch/arm64/kernel下的vmlinux.lds.S),KERNEL_START被设定为:

. = PAGE_OFFSET + TEXT_OFFSET;
.head.text : {
_text = .;
HEAD_TEXT
}

因此,KERNEL_START的值和PAGE_OFFSET以及TEXT_OFFSET这两个offset的设定有关。TEXT_OFFSET标识了内核正文段的offset,其实如果该宏被定义为KERNEL_TEXT_OFFSET会更好理解。我们知道,操作系统运行在内核空间,应用程序运行在用户空间,假设内核空间的首地址是x(一般也是RAM的首地址),那么是否让kernel运行在x地址呢?对于arm,在内核空间的开始有32kB(0x00008000)的空间用于保存内核的页表(也就是进程0的PGD)以及bootload和kernel之间参数的传递,对于ARM64,在其Makefile中定义了这个offset是512KB(0x00080000)。

ifeq ($(CONFIG_ARM64_RANDOMIZE_TEXT_OFFSET), y)
TEXT_OFFSET := $(shell awk 'BEGIN {srand(); printf "0x%03x000\n", int(512 * rand())}')
else
TEXT_OFFSET := 0x00080000
endif

kernel image的开始部分包括了一个ARM64 image header的内容,这个header定义了bootloader如何来copy kernel image。ARM64 image header中有一个域(text_offset)就是告知bootloader,它应该按照多大的偏移来copy kernel image。当然了,也许有些bootloader不鸟这些,对于ARM64平台,反正大家一直都是固定为0x80000,因此,bootloader也没有什么太大的动力来修改支持这个特性。怎么破?虽然目前ARM64的kernel的TEXT_OFFSET就是固定为0x80000,但是也许将来内核会修改这个offset啊。在这种情况下,内核的开发者提供了一个CONFIG_ARM64_RANDOMIZE_TEXT_OFFSET选项,在编译内核的时候可以randomize内核的TEXT_OFFSET值,以此来测试bootloader是否能够正确的copy kernel image到正确的内存偏移位置上去。通过这样一个配置项,可以尽快的暴露问题,确保了整个系统(bootloader + kernel)稳定的运行。

搞定了TEXT_OFFSET,我们再来看看PAGE_OFFSET,在arch/arm64/include/asm/memory.h中,PAGE_OFFSET被定义为:

#define VA_BITS (CONFIG_ARM64_VA_BITS)
#define PAGE_OFFSET (UL(0xffffffffffffffff) << (VA_BITS - 1))

VA_BITS定义了用户空间虚拟地址的bit数(该值也就是定义了用户态程序能够访问的地址空间的size),假设VA_BITS被设定为39个bit,那么PAGE_OFFSET就是0xffffffc0-00000000。PAGE_OFFSET的名字也不好(个人观点,可能有误),OFFSET表明的是一个偏移,内核空间被划分成一个个的page,PAGE_OFFSET看起来应该是定义以page为单位的偏移。但是,以什么为基准的偏移呢?PAGE_OFFSET的名字中没有给出,当然实际上,这个符号是定义以整个address space的起始地址(也就是0)为基准。另外,虽然这个地址是要求page对齐,但是实际上,这个符号仍然定义的是虚拟地址的offset(而不是page的offset)。根据上面的理由,我觉得定义成KERNEL_IMG_OFFSET会更好理解一些。一句话总结:PAGE_OFFSET定义了将kernel image安放在虚拟地址空间的哪个位置上。

OK,经过漫长的说明之后,__PHYS_OFFSET实际上就是kernel image的首地址(并不是__PHYS_OFFSET的位置开始就是真实的kernel image,实际上从__PHYS_OFFSET开始,首先是TEXT_OFFSET的保留区域,然后才是真正的kernel image)。实际上,__PHYS_OFFSET定义的是一个虚拟地址而不是物理地址,这里的PHYS严重影响了该符号的含义,实际上adrp这条指令可以将一个虚拟地址转换成物理地址(在没有打开MMU的时候)。而函数__vet_fdt主要是对这个bootloader传递给kernel的fdt参数进行验证,看是否OK,主要验证的内容包括:

(1)是否是8字节对齐的

(2)是否在kernel space的前512M内

__vet_fdt:
tst x21, #0x7----是否是8字节对齐的
b.ne 1f
cmp x21, x24-----是否在小于kernel space的首地址
b.lt 1f
mov x0, #(1 << 29)
add x0, x0, x24
cmp x21, x0
b.ge 1f-------是否大于kernel space的首地址+512M
ret
1:
mov x21, #0----------传递的fdt地址有误,清零
ret
ENDPROC(__vet_fdt)

四、参考文献

1、Documentation/arm64/booting.txt
2、ARM Architecture Reference Manual

文章来源:蜗窝科技

围观 477

1. 前言

蜗蜗很早以前就知道有WFI和WFE这两个指令存在,但一直似懂非懂。最近准备研究CPU idle framework,由于WFI是让CPU进入idle状态的一种方法,就下决心把它们弄清楚。

WFI(Wait for interrupt)和WFE(Wait for event)是两个让ARM核进入low-power standby模式的指令,由ARM architecture定义,由ARM core实现。听着挺简单,但怎么会有两个指令?它们的区别是什么?使用场景是什么?深究起来,还挺有意思,例如:能想象WFE和spinlock的关系吗?

2. WFI和WFE

1)共同点

WFI和WFE的功能非常类似,以ARMv8-A为例(参考DDI0487A_d_armv8_arm.pdf的描述),主要是“将ARMv8-A PE(Processing Element, 处理单元)设置为low-power standby state”。

需要说明的是,ARM architecture并没有规定“low-power standby state”的具体形式,因而可以由ARM core自行发挥,根据ARM的建议,一般可以实现为standby(关闭clock、保持供电)、dormant、shutdown等等。但有个原则,不能造成内存一致性的问题。以Cortex-A57 ARM core为例,它把WFI和WFE实现为“put the core in a low-power state by disabling the clocks in the core while keeping the core powered up”,即我们通常所说的standby模式,保持供电,关闭clock。

2)不同点

那它们的区别体现在哪呢?主要体现进入和退出的方式上。

对WFI来说,执行WFI指令后,ARM core会立即进入low-power standby state,直到有WFI Wakeup events发生。

而WFE则稍微不同,执行WFE指令后,根据Event Register(一个单bit的寄存器,每个PE一个)的状态,有两种情况:如果Event Register为1,该指令会把它清零,然后执行完成(不会standby);如果Event Register为0,和WFI类似,进入low-power standby state,直到有WFE Wakeup events发生。

WFI wakeup event和WFE wakeup event可以分别让Core从WFI和WFE状态唤醒,这两类Event大部分相同,如任何的IRQ中断、FIQ中断等等,一些细微的差别,可以参考“DDI0487A_d_armv8_arm.pdf“的描述。而最大的不同是,WFE可以被任何PE上执行的SEV指令唤醒。

所谓的SEV指令,就是一个用来改变Event Register的指令,有两个:SEV会修改所有PE上的寄存器;SEVL,只修改本PE的寄存器值。下面让我们看看WFE这种特殊设计的使用场景。

3. 使用场景

1)WFI

WFI一般用于cpuidle。

2)WFE

WFE的一个典型使用场景,是用在spinlock中(可参考arch_spin_lock,对arm64来说,位于arm64/include/asm/spinlock.h中)。spinlock的功能,是在不同CPU core之间,保护共享资源。使用WFE的流程是:

a)资源空闲

b)Core1访问资源,acquire lock,获得资源

c)Core2访问资源,此时资源不空闲,执行WFE指令,让core进入low-power state

d)Core1释放资源,release lock,释放资源,同时执行SEV指令,唤醒Core2

e)Core2获得资源

以往的spinlock,在获得不到资源时,让Core进入busy loop,而通过插入WFE指令,可以节省功耗,也算是因祸(损失了性能)得福(降低了功耗)吧。

作者:wowo

文章来源: 蜗窝科技

围观 620

嵌入式操作系统(Embedded Operation System,EOS)是指用于嵌入式系统的操作系统。嵌入式系统分为4层,硬件层、驱动层、操作系统层和应用层。嵌入式操作系统是负责嵌入式系统的全部软、硬件资源的分配、任务调度,控制、协调并发活动。它必须体现其所在系统的特征,能够通过装卸某些模块来达到系统所要求的功能,是一种用途广泛的系统软件。

嵌入式LINUX

嵌入式Linux 是将日益流行的Linux操作系统进行裁剪修改,使之能在嵌入式计算机系统上运行的一种操作系统。Linux做嵌入式的优势,首先,Linux是开放源代码;其次,Linux的内核小、效率高,可以定制,其系统内核最小只有约134KB;第三,Linux是免费的OS,Linux还有着嵌入式操作系统所需要的很多特色,突出的就是Linux适应于多种CPU和多种硬件平台而且性能稳定,裁剪性很好,开发和使用都很容易。同时,Linux内核的结构在网络方面是非常完整的,Linux对网络中最常用的TCP/IP协议有最完备的支持。提供了包括十兆、百兆、千兆的以太网络,以及无线网络,Token Ring(令牌环网)、光纤甚至卫星的支持。

移植步骤:1.Bootloader的移植;2.嵌入式Linux操作系统内核的移植;3.嵌入式Linux操作系统根文件系统的创建;4.电路板上外设Linux驱动程序的编写。

WinCE

WinCE是微软公司嵌入式、移动计算平台的基础,它是一个开放的、可升级的32位嵌入式操作系统,是基于掌上型电脑类的电子设备操作系统,它是精简的Windows 95,Win CE的图形用户界面相当出色。WinCE是从整体上为有限资源的平台设计的多线程、完整优先权、多任务的操作系统。它的模块化设计允许它对于从掌上电脑到专用的工业控制器的用户电子设备进行定制。操作系统的基本内核需要至少200K的ROM。

一般来说,一个WinCE系统包括四层结构:应用程序、WinCE内核映像、板级支持包(BSP)、硬件平台。而基本软件平台则主要由WinCE系统内核映像(OS Image)和板卡支持包(BSP)两部分组成。因为WinCE系统是一个软硬件紧密结合的系统,因此即使CPU处理器相同,但是如果开发板上的外围硬件不相同,这个时候还是需要修改BSP来完成一个新的BSP。因此换句话说,就是WinCE的移植过程主要是改写BSP的过程。

Android

Android 是一个包括操作系统,中间件以及一些重要应用程序的专门针对移动设备的层次结构的软件集。Android 作为一个完全开源的操作系统,是由操作系统Linux、中间件以及核心应用程序组成的软件栈。通过 android SDK 提供的 API 以及相应的开发工具, 程序员可以很方便的开发android平台上的应用程序。其整个系统由应用程序,应用程序框架,应用程序库,Android运行库,Linux内核(Linux Kernel)五个部分组成。Android操作系统内置了一部分应用程序, 包括电子邮件客户端、SMS程序、日历、地图、浏览器、通讯录以及其他的程序,值得一提的是这些所有的程序都是用java编写的。

移植的主要的工作是驱动,硬件抽象层的移植。为了更好地理解和调试系统,也应该适当地了解上层对硬件抽象层的调用情况。

TinyOS

TinyOS是一个开源的嵌入式操作系统,它是由加州大学的伯利克分校开发出来的,主要应用于无线传感器网络方面。程序采用的是模块化设计,所以它的程序核心往往都很小,一般来说核心代码和数据大概在400 Bytes左右,能够突破传感器存储资源少的限制。TinyOS提供一系列可重用的组件,一个应用程序可以通过连接配置文件(A Wiring Specification)将各种组件连接起来,以完成它所需要的功能。

嵌入式实时操作系统(RTOS)

在工业控制、 军事设备、航空航天等领域对系统的响应时间有苛刻的要求,这就需要使用实时系统。当外界事件或数据产生时,能够接受并以足够快的速度予以处理,其处理的结果又能在规定的时间之内来控制生产过程或对处理系统作出快速响应,并控制所有实时任务协调一致运行的嵌入式操作系统。故对嵌入式实时操作系统的理解应该建立在对嵌入式系统的理解之上加入对响应时间的要求。

FreeRTOS

FreeRTOS是一个迷你操作系统内核的小型嵌入式系统。作为一个轻量级的操作系统,功能包括:任务管理、时间管理、信号量、消息队列、内存管理、记录功能等,可基本满足较小系统的需要。FreeRTOS任务可选择是否共享堆栈,并且没有任务数限制,多个任务可以分配相同的优先权。相同优先级任务的轮转调度,同时可设成可剥夺内核或不可剥夺内核。

FreeRTOS 的移植主要需要改写如下三个文件。1.portmacro.h 2.port.c 3. port.asm

μTenux

μTenux基于ARM微控制器平台,对uT最适用于ARM Cortex M0-M4系列的微控制器,代码开源、免费,是一个功能强大的抢占式实时多任务操作系统。μTenux除具有实时嵌入式操作系统的一般特性:可移植性,可固化,可裁剪等特性以外,它还具有如下优点:(1)微内核。无MMU, ROM/RAM占用量小,所占ROM最大60KB,最小10KB;RAM最大12KB,最小2KB;(2)开源免费;(3)支持所有32位ARM7/9和Cortex M系列的微控制器;(4)可配置多达到256个任务以及140个任务优先级;(5)有良好的商业支持, T-Engine论坛进行总的维护。

移植主要包括:芯片系统时钟移植,外设移植和通用输出/输入端口的移植以及看门狗模块移植。由于考虑到内核代码的重要性以及其在整个移植中的重要意义,且为了整个系统有更好的实时性,可选用汇编语言编写操作系统的启动代码。

VxWorks

VxWorks系统提供多处理器间和任务间高效的信号灯、消息队列、管道、网络透明的套接字。实时系统的另一关键特性是硬件中断处理。为了获得最快速可靠的中断响应,VxWorks系统的中断服务程序ISR有自己的上下文。VxWorks实时操作系统由400多个相对独立的、短小精炼的目标模块组成,用户可根据需要选择适当模块来裁剪和配置系统,这有效地保证了系统的安全性和可靠性。系统的链接器可按应用的需要自动链接一些目标模块。这样,通过目标模块之间的按需组合,可得到许多满足功能需求的应用。

移植过程可以参考网络上一些BSP代码,BSP的英文全称为board support package,即板级支持包,它的作用是针对特殊的硬件平台,为VxWorks内核提供操作的接口。

μClinux

嵌入式Linux作为一个开放源代码的操作系统,以价格低廉、功能强大又易移植的特性正在被广泛应用,μClinux是专门针对没有MMU的处理器而设计的嵌入式Linux,非常适合中低端嵌入式系统的需求。 在GNU通用公共许可证的授权下,μClinux操作系统的用户可以使用几乎所有Linux的API函数,不会因为没有内存管理单元MMU而受到影响;而且,μClinux在标准的Linux基础上进行了适当的裁剪和优化,形成了一个高度优化的、代码紧凑的嵌入式Linux,体积小了,但是仍然保留了Linux的大多数的优点,比如稳定性好、强大的网络功能、良好的可移植性、完备的文件系统支持功能、以及标准丰富的应用程序接口API等,可以支持类似ARM7TDMI等类型多的小巧玲珑的中央处理器。

eCos

eCos中文翻译为嵌入式可配置操作系统或嵌入式可配置实时操作系统。适合于深度嵌入式应用,主要应用对象包括消费电子、电信、车载设备、手持设备以及其他一些低成本和便携式应用。eCos是一种开发源代码软件,无任何版权费用。 eCos最大的特点是模块化,内核可配置。如果说嵌入式Linux太庞大了,那么eCos可能就能够满足要求。它是一个针对16位、32位和64位处理器的可移植开放源代码的嵌入式RTOS。和嵌入式Linux不同,它是由专门设计嵌入式系统的工作组设计的。eCos具有相当丰富的特性和一个配置工具,后者能够让你选取你所需要的特性。

eCos的软件分了若干的模块,移植工作主要在他的hal层进行,所谓hal(硬件抽象层)就是把和硬件相关的软件凑到一起。

μC/OS-II

μC/OS-II是一个完整的、可移植、可固化、可裁剪的占先式实时多任务内核。μC/OS-II绝大部分的代码是用ANSI的C语言编写的,包含一小部分汇编代码,使之可供不同架构的微处理器使用。其结构小巧简洁且支持抢占式的多任务调度与管理。此实时操作系统管理任务数多达64个,且提供内部程序存储器管理、系统运行时间管理、多任务实时调度与管理等功能。由于它的作者占用和保留了8个任务,所以留给用户应用程序最多可有56个任务。赋予各个任务的优先级必须是不相同的。这意味着μC/OS-II不支持时间片轮转调度法。μC/OS-II为每个任务设置独立的堆栈空间,可以快速实现任务切换。

将μC/OS-II操作系统移植到目标处理器上,需要从硬件和软件两方面来考虑。硬件方面,目标处理器需满足以下条件:

①处理器的C编译器能产生可重入代码;

②用C语言可以开/关中断;

③处理器支持中断,并且能够产生定时中断(通常在10~1000 Hz之间);

④处理器能够支持容纳一定量数据的硬件堆栈;

⑤处理器有将堆栈指针和其他寄存器读出和存储到堆栈或内存中的指令。

软件方面,主要是一些与处理器相关的代码移植,其分布在OS_CPU.H、OS_CPU_C.C和OS_CPU_A.ASM这3个不同的文件中。

来源:互联网(版权归原作者所有)

围观 458

在嵌入式系统开发中,目前使用的主要编程语言是C 和汇编,虽然C++已经有相应的编译器,但是现在使用还是比较少的。

在稍大规模的嵌入式程序设计中,大部分的代码都是用C来编写的,主要是因为C语言具有较强的结构性,便于人的理解,并且具有大量的库支持。但对于一写硬件上的操作,很多地方还是要用到汇编语言,例如硬件系统的初始化中的CPU 状态的设定,中断的使能,主频的设定,RAM控制参数等。另外在一些对性能非常敏感的代码块,基于汇编与机器码一一对应的关系,这时不能依靠C编译器的生成代码,而要手工编写汇编,从而达到优化的目的。汇编语言是和CPU的指令集紧密相连的,作为涉及底层的嵌入式系统开发,熟练对应汇编语言的使用也是必须的。

单纯的C或者汇编编程请参考相关的书籍或者手册,这里主要讨论C和汇编的混合编程,包括相互之间的函数调用。下面分四种情况来进行讨论,不涉及C++语言。

一、在C语言中内嵌汇编

在C中内嵌的汇编指令包含大部分的ARM和Thumb指令,不过使用与单纯的汇编程序使用的指令略有不同,存在一些限制,主要有下面几个方面:

a 不能直接向PC 寄存器赋值,程序跳转要使用B或者BL指令;

b 在使用物理寄存器时,不要使用过于复杂的C表达式,避免物理寄存器冲突;

c R12和R13可能被编译器用来存放中间编译结果,计算表达式值时可能把R0-R3、R12及R14用于子程序调用,因此避免直接使用这些物理寄存器;

d 一般不要直接指定物理寄存器;

e 让编译器进行分配内嵌汇编使用的标记是__asm或asm关键字,用法如下:__asm{instruction [; instruction]}或 asm("instruction [; instruction]")。

下面是一个例子来说明如何在C中内嵌汇编语言:

//C语言文件*.
#include
void my_strcpy(const char *src, char *dest){
char ch;
__asm{
loop:
ldrb ch, [src], #1
strb ch, [dest], #1
cmp ch, #0
bne loop
}
}
int main(){
char *a="forget it and move on!";
char b[64];
my_strcpy(a, b);
printf("original: %s", a);
printf("copyed: %s", b);
return 0;
}

在此例子中C语言和汇编之间的值传递是用C语言的指针来实现的,因为指针对应的是地址,所以汇编中也可以访问。

二、在汇编中使用C定义的全局变量

内嵌汇编不用单独编辑汇编语言文件,比较简洁,但是有很多的限制。当汇编的代码较多时一般放在单独的汇编文件中,这时就需要在汇编文件和C文件之间进行一些数据的传递,最简便的办法就是使用全局变量。

下面是一个C语言和汇编语言共享全局变量的例子:

//C语言文件*.

#include
int gVar=12;
extern asmDouble(void);
int main(){
printf("original value of gVar is: %d", gVar_1);
asmDouble();
printf(" modified value of gVar is: %d", gVar_1);
return 0;
}

;汇编语言文件*.

AREA asmfile, CODE, READONLY EXPORT asmDouble
IMPORT gVar
asmDouble
ldr r0, =gVar
ldr r1, [r0]
mov r2, #2
mul r3, r1, r2
str r3, [r0]
mov pc, lr
END

在此例中,汇编文件与C文件之间相互传递了全局变量gVar和函数asmDouble,留意声明的关键字extern和IMPORT

三、在C中调用汇编的函数

有一些对机器要求高的敏感函数,通过C语言编写再通过C编译器翻译有时会出现误差,因此这样的函数一般采用汇编语言来编写,然后供C语言调用。在C文件中调用汇编文件中的函数,要注意的有两点,一是要在C文件中声明所调用的汇编函数原型,并加入extern关键字作为引入函数的声明;二是在汇编文件中对对应的汇编代码段标识用EXPORT关键字作为导出函数的声明,函数通过mov pc, lr指令返回。这样,就可以在C文件中使用该函数了。从C语言的角度的角度,并不知道调用的函数的实现是用C语言还是汇编汇编语言,原因C语言的函数名起到表明函数代码起始地址的作用,而这个作用和汇编语言的代码段标识符是一致的。

下面是一个C语言调用汇编函数例子:

//C语言文件*.

#include
extern void asm_strcpy(const char *src, char *dest);
int main(){
const char *s="seasons in the sun"; char d[32];
asm_strcpy(s, d);
printf("source: %s", s);
printf(" destination: %s",d);
return 0;
}

;汇编语言文件*.

AREA asmfile, CODE, READONLY
EXPORT asm_strcpy
asm_strcpy
loop
ldrb r4, [r0], #1
cmp r4, #0
beq over
strb r4, [r1], #1
b loop
over
mov pc, lr
END

在此例中,C语言和汇编语言之间的参数传递是通过对应的用R0-R3来进行传递,即R0传递第一个参数,R1传递第二个参数,多于4个时借助栈完成,函数的返回值通过R0来传递。这个规定叫作ATPCS(ARM Thumb Procedure Call Standard),具体见ATPCS规范。

四、在汇编中调用C的函数

在汇编语言中调用C语言的函数,需要在汇编中IMPORT对应的C函数名,然后将C的代码放在一个独立的C文件中进行编译,剩下的工作由连接器来处理。

下面是一个汇编语言调用C语言函数例子:

//C语言文件*.

int cFun(int a, int b, int c){
return a+b+c;
}
;汇编语言文件*.

AREA asmfile, CODE, READONLY
EXPORT cFun
start
mov r0, #0x1
mov r1, #0x2
mov r2, #0x3
bl cFun
nop
nop
b start
END

在汇编语言中调用C语言的函数,参数的传递也是按照ATPCS规范来实现的。

在这里简单介绍一下部分ATPCS规范:

子程序间通过寄存器R0~R3来传递参数。

A.在子程序中,使用寄存器R4~R11来保存局部变量。

B.寄存器R12用于子程序间scratch寄存器(用于保存SP,在函数返回时使用该寄存器出桟),记作IP。

C.寄存器R13用于数据栈指针,记作SP。寄存器SP在进入子程序时的值和退出子程序时的值必须相等。

D.寄存器R14称为链接寄存器,记作LR。它用于保存子程序的返回地址。

E.寄存器R15是程序计数器,记作PC

F.参数不超过4个时,可以使用寄存器R0~R3来传递参数,当参数超过4个时,还可以使用数据栈来传递参数。

G.结果为一个32位整数时,可以通过寄存器R0返回

H.结果为一个64位整数时,可以通过寄存器R0和R1返回,依次类推。

以上通过几个简单的例子演示了嵌入式开发中常用的C 和汇编混合编程的一些方法和基本的思路,其实最核心的问题就是如何在C 和汇编之间传值,剩下的问题就是各自用自己的方式来进行处理。以上只是抛砖引玉,更详细和复杂的使用方法要结合实际应用并参考相关的资料。

围观 901

页面

订阅 RSS - ARM