pc 端程序运行的环境建立过程有很多细节被隐藏了,而在嵌入式中一般我们都能看到完整的建立过程。这些过程能够让我们更清楚程序执行所依赖的环境,同时也可能会让我们进一步思考这些环境对程序的表达带来的扩展与限制。
我这里以 rv32m1 vega 织女星开发板为例来讲讲这一过程,其中也会讲到一些此款开发板上没有用到的一些初始化过程。
第一个问题:程序执行的入口是什么?
所谓程序执行的入口,其含义是程序执行的第一条指令的位置。更进一步的描述请参考我的这篇博文—嵌入式中指定程序执行入口。
第二个问题:程序初始化过程有哪些关键步骤?
1. 关中断——在临界区内初始化程序运行环境
为什么这时候要关中断呢?
在初始化过程中程序执行的环境还没有建立起来,如果产生了中断,系统是无法处理的。如果不关闭中断,那么由于中断的优先级比普通代码的优先级高,一旦产生将打断普通代码的执行过程,跳转到中断服务程序处执行,而这时候中断向量表的基址寄存器都还没有设定呢。这样一旦发生中断并跳转执行,普通代码中的初始化过程就无法完成了!
同时也要说明的是,我们在初始化过程中会设定中断向量表的基址寄存器,而这个寄存器也是被硬件共享的,因此如果当我们修改这个寄存器的时候同时发生了中断就很尴尬了。
常见的芯片中中断向量表一般不能直接进行设定,需要通过特殊的指令进行设定,这样带来的结果是,一条指令不能完成中断向量表的设定过程,则特殊指令执行前的那条指令还使用的是旧的向量表,这是一个问题。
2. 初始化 sp、gp
栈是程序执行过程中依赖的硬件环境,这个环境扩大了程序执行过程中中间状态的保存与修改能力。局部变量与子过程调用就依赖栈来进行。当然如果寄存器非常多的话,局部变量都可以分配到一个寄存器,这样就不用通过栈来存取了,这也是一种优化的方案!
同时注意栈中内存的分配与回收不需要额外的处理,只需要向上、向下拨动栈指针即可,这样带来的好处显而易见。那些在栈中存储的局部变量其创建与回收过程要比在堆中存储的变量的创建与回收更为简单。
不过栈一般都是比较小的,而堆却可以非常大。使用堆带来的好处在于程序可以存取的内存区域更大,而且堆具有某种全局属性,能够为不同的函数所共享,很多时候可能需要进行额外的串行化处理,以免产生数据不一致的问题。
3. 设置中断向量表基址寄存器
当 cpu 检测到中断时,cpu 会按照既定的规则跳转到对应的中断向量处,执行对应的中断处理程序。一般来说稍微高端的芯片可能会支持动态修改中断向量表,这一功能是通过重新在内存中建立新的中断向量表并设置中断向量表基址寄存器来实现的。对于那些直接使用程序的固定位置来存储中断向量表(一般是 0 地址)的芯片,就不能直接动态修改中断向量表的位置了。
现在,很多厂家使用纯 c 函数来编写中断服务程序代码,这样当中断发生时需要按照架构手册中的说明来保存并恢复必要的现场。
4. 根据需要拷贝代码段或数据段
嵌入式中链接脚本的设定非常灵活,可以通过修改链接脚本来将程序中的不同部分映射到芯片中诸如内部 ram、外部 ram、flash 等不同组件中。有时这样做是限于硬件条件,有时这样做却可能是为了提高程序执行的速度。
最终的程序一般都要存储到永久性存储器中进行【固化】,常见的方式是烧录到 flash 上。虽然程序要烧写到 flash 上进行固化,但程序中的一些段——如 data、bss 段却需要放到内存中来进行存取。对于 data 段存储的数据而言,数据的初始值也是被存储到永久性存储器上的,而 bss 段存放的是一些未初始化的变量,只需要标注大小,然后将 ram 中分配的 bss 段内存空间清零即可。
有时候为了调试方便,在 ram 大小足够的条件下,甚至可以将代码段也加载到 ram 中,这样就可以避免烧写到 flash 上时长时间的等待。
写到这里不得不提的是 vma 与 lma 这两个概念。
vma 全称为——virtual memory address,它是程序运行时段的地址。lma 全称为——load memory address,它是段被加载到的地址,是程序加载时的地址。在大多数时间内这两个地址是相同的,在一些情况下这两个地址却可能不相同。
常见的嵌入式设备中,程序一般直接从 flash 运行,代码段不需要拷贝到内存中,而数据段却是需要加载到内存中的,这样的过程一般是在初始化过程中完成的,只需要拷贝数据到 ram 即可。
5. 清空 bss 段
bss 段中记录了那些未初始化的全局变量、未初始化的静态变量的信息,它并不存在于生成的可执行文件中,它是在程序运行前构建的。只需要指定起始与结束地址,然后将这两个地址划定的内存区域清零即可。
6. 设定一些硬件状态
对于那些对程序的执行有直接影响的硬件,我们可能要进行配置。对于 rv32m1 vega 开发板而言,这样的操作有关闭看门狗,重新配置时钟等。其它的芯片可能还要使能 cache ,配置内存属性使能内存保护功能等等。
7. 初始化系统堆管理器
上文中我描述了栈的一些特点,在那里也提到了堆这个名词。堆实际上是内存中的一块区域,这块区域并不由硬件来管理,需要用户自行编写软件进行管理。
一般来说在嵌入式中对于内存特别少的设备根本就不需要堆。不过目前来看,那些跑实时操作系统的芯片几乎都要使用到堆。
这里所谓的堆管理器初始化其实是调用系统提供的软件接口来初始化对应的数据结构。不过这个数据结构依赖于堆的起始地址与大小,这两个依赖也是我们在调用堆初始化时需要传递给函数的参数。
8. 执行 c 库中的初始化过程——_init 函数
c 库中的一些初始化过程在这一步被执行。rv32m1 vega 的启动脚本中有如下语句:
call __libc_init_array
上述语句调用了 __libc_init_array 完成 c 库的初始化。__libc_init_array 中会调用一系列函数,这些函数都通过函数指针存储到固定的段中,这点可以从链接脚本中得到印证。
rv32m1 vega 的链接脚本中相关的内容如下;
.preinit_array :
{
PROVIDE_HIDDEN (__preinit_array_start = .);
KEEP (*(.preinit_array*))
PROVIDE_HIDDEN (__preinit_array_end = .);
} > m_text
.init_array :
{
PROVIDE_HIDDEN (__init_array_start = .);
KEEP (*(SORT(.init_array.*)))
KEEP (*(.init_array*))
PROVIDE_HIDDEN (__init_array_end = .);
} > m_text
.fini_array :
{
PROVIDE_HIDDEN (__fini_array_start = .);
KEEP (*(SORT(.fini_array.*)))
KEEP (*(.fini_array*))
PROVIDE_HIDDEN (__fini_array_end = .);
} > m_text
__libc_init_array 这个函数中执行的关键过程如下:
- 调用 .preinit_array 段中的预初始化函数
- 调用 .init 段中的 _init 函数
- 调用 .init_array 中的所有函数
与 __libc_init_array 对应的函数是 __libc_fini_array,这个函数是在 main 函数执行完成后执行的。它首先会调用 .fini_array 中的所有函数,然后调用 _fini 函数,在嵌入式中一般不会执行到 __libc_fini_array 函数。
这里我需要解释下,在一个段中可能会存在多个函数,根据链接脚本的写法,链接器会在链接时将段名相同的函数指针放到同一个段中,然后通过在段的前后设定锚点来依次执行相应的函数,这样的方式在一些系统的驱动初始化中也能见到!
在 pc 端也有类似的过程,在我的 debian 10 中 _init 与 _fini 函数在 crti.o 中被定义,详细的信息如下:
8: 0000000000000000 0 FUNC GLOBAL HIDDEN 4 _init
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
10: 0000000000000000 0 FUNC GLOBAL HIDDEN 6 _fini
No version information found in this file.
[longyu@debian-10:11:36:23] rv32m1_sdk_riscv $ objdump -d /usr/lib/x86_64-linux-gnu/crti.o
/usr/lib/x86_64-linux-gnu/crti.o: 文件格式 elf64-x86-64
Disassembly of section .init:
0000000000000000 <_init>:
0: 48 83 ec 08 sub $0x8,%rsp
4: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # b <_init+0xb>
b: 48 85 c0 test %rax,%rax
e: 74 02 je 12 <_init+0x12>
10: ff d0 callq *%rax
Disassembly of section .fini:
0000000000000000 <_fini>:
0: 48 83 ec 08 sub $0x8,%rsp
rv32m1 vega 的官方工程中使用了自己实现的 _init 与 _fini 函数,这两个函数的实现非常简单,只有一个 ret 返回指令。
在另一块开发板中我发现它在 _init 函数中完成了一些较多的操作。
不过注意它们都在工程的链接参数中指定了 --nostartfiles 选项。这个选项告诉链接器,在链接的时候不要使用系统的标准启动文件,这个文件一般是 crt0.0。
第三个问题:初始化完成后跳转到哪里执行?
完成了程序运行环境的初始化过程之后,直接跳转到 main 函数。在 main 函数中开始建立实时操作系统运行的环境,等到第一个任务执行,中断打开,整个环境初始化就完成了。
实时操作系统初始化过程主要初始化几大空链与一些系统数据结构,具体的内容我会在后边的文章中详细叙述,这里就先跳过了。
进一步的讨论——对计算机及程序执行的发散思考
计算机从最基础的 0 1 开始,这时它所能做到的变化只有两种,即 0 变为 1 ,1 变为 0。在此基础上布尔代数对这种最为基础的 0 1 进行了扩充,将操作扩充到一组 0 1 之上,这样就使得程序能够达成的变化被扩大,同时也对基础的硬件有了更高的要求,硬件需要能够存储一组 0 1 的数据。
ALU 是算术逻辑单元,它对整型二进制数进行算数与位运算,布尔代数是其实现的基础。寄存器是能够存储一组 0 1 数据的硬件,ALU 的输入输出都反映到寄存器的变化中。单个寄存器不足以完成复杂的功能,多个寄存器组合的寄存器组扩展了 ALU 的计算能力。
cpu 内部的各个组件之间需要交换数据,这样的过程需要媒介,这一媒介就是总线。一般来说总线可分为内部总线与外部总线。内部总线是用于 cpu 内部组件间消息的传递,外部组件一般用于 cpu 与外部组件间消息的传递。
类比人脑来说。人脑能够使用的记忆能力是有限的。可是我们可以将事情记录到纸上或者其它媒介上,这样的过程可以说是扩充了我们的记忆容量。对于记录到纸上的方式,我们需要使用笔来书写,需要用的时候可以通过阅读纸页上的文字来使用。
外部总线有着类似的功能,cpu 通过它来访问外部的存储空间。
到此,cpu 所能够改变的环境的范围被扩大,这样程序也就可以写得非常复杂。cpu 实际上是为程序执行提供了一套保存变化与执行变化的环境,程序依赖于这一环境,同时在执行的过程中也改变了环境。
只有当我能够完全清楚一个语句的执行对整个计算机环境做出的改变时,我才算真正掌握了计算机吧!可是这又谈何容易呢?慢慢描绘吧!
参考链接如下: