【转载】嵌入式软件时序简介—2. C语言是怎么编译出来的

发布于 2022-05-06 12:21:35

声明:本文转载自微信公众号SoftAuto文章《嵌入式软件时序(1)— C语言是怎么编译出来的》,略有改动。

接上篇,V模型的左半边和将源代码变成可执行文件的过程,即构建过程(build process)之间存在相似性。它从一个比较高的抽象层次开始,在时间的推移中越来越接近执行的硬件——处理器。

以下介绍了如何将源代码变成可执行的机器代码,以及哪些文件、工具和编译步骤是相关的。涉及的基础知识只是间接涉及到时序的话题。然而,如果不了解编译器的基本工作原理等,以减少运行时为目标的代码优化只能是困难重重。

1.基于模型的软件开发及代码生成

到现在,可以说在汽车上运行的软件中,基于模型的软件所占比例较大。这意味着源代码不是由人工编写的,而是由编码生成工具如Embedded Coder、Targetlink或ASCET生成的。在此之前,功能(通常是控制技术、数字滤波或状态机)是用MATLAB/Simulink或ASCET等图形化建模工具定义的,并以"模型"的形式保存。

2.C语言编译过程概览

以下代码显示了一个简单的程序,在本例中是手工编码的。下面将用这个程序来说明从源代码到可执行程序的过程。
image.png

包含的头文件myTypes.h的代码如下:
image.png

代码第12行使用的关键字volatile(英文 "volatile "的意思是 "易失性")使编译器每次对受影响的变量的访问都是在内存中显性地进行的,而不是将其暂时存储在寄存器中。volatile关键字用于防止编译器将代码优化掉,即编译器认为变量a从未被"有意义地"使用,因此删除对它的所有访问的语句。

下图显示了从源代码到可执行文件的过程中要经过哪些步骤,涉及哪些中间格式,涉及哪些附加文件。可选的数据流、文件和工具以淡色显示。
image.png

3.C语言预处理(Preprocessor)

第一步,编译器对代码进行预处理,解析所有的宏(" #define "),读取所有包含的头文件(" #include "),删除条件编译不满足的代码(" #ifdef (...) #endif "),并计算所有此时已经可以计算的值(" 400 * 5 / 1000 " → " 2 ")。所有以 "#"开头的语句都是预处理语句。事实上,预处理器还做了不少任务,但目前给出的例子应该足以说明其原理。

提示:大多数编译器都支持-E命令行选项,它使编译器在预处理阶段后终止,并将预处理代码输出到stdout。在调试与预处理有关的问题时,这一点非常有用。此外,这个输出也很适合向编译器制造商报告编译器问题。如果输出被重定向到一个文件(文件扩展名为.i),这个文件可以传递给编译器进行编译,而不需要任何其他文件,比如包含的头文件。编译器厂商就可以复现问题,而不需要访问所有包含的头文件。

下图展示了main.c的预处理输出(mian.i)
image.png

4.C语言编译器(Compiler)

预处理器的输出进入编译器,编译器从中生成处理器专用的机器代码,即与C代码对应的机器指令文件。此时函数、变量、跳转地址等内存地址还没有指定,而是以符号方式记录下来。

编译器的输出(在本例中是英飞凌AURIX处理器的TASKING编译器)可以部分从以下代码中看到。由于这段代码作为后续阶段汇编器的输入,所以也称为汇编代码。

下图展示了编译器输出汇编代码 main.src
image.png

在将源代码翻译成机器代码时,编译器可以进行各种优化。这些优化中的很多都降低了对内存的要求,同时也带来了更快的代码执行速度。然而,在一些优化的情况下,一个方面的改进只会以牺牲另一个方面为代价。软件的开发人员必须决定哪个更重要。优化后的实际效益往往难以预先估计。即使是专家,也往往会对优化的结果大吃一惊。

5.汇编器(Assembler)

汇编器将汇编代码的汇编机器指令翻译成二进制形式。因此,汇编器的输出已经不容易被人类读懂,这里不再介绍。汇编文件(通常文件扩展名为.obj或.o)称为“对象文件”。和之前的汇编代码一样,函数、变量、跳转地址等的内存地址在对象代码中还没有定义,但仍然可以用专门符号来表示。

6.链接器(Linker)

链接器将传递给它的所有对象组装成一个几乎已经完成的程序,只有具体的地址还没有分配。在我们的例子中,只传递了一个对象,即main.o。另外还隐含了一些对象,例如cstart.o,用于执行main函数前所需的基本初始化。这包括初始化内存,初始化堆栈指针,以及初始化变量。

此外,函数库可以传递给链接器,通常文件扩展名为.a或.lib。函数库实际上不过是对象的集合。“归档器”(Archiver)将选定的对象打包成库文件(.a或.lib),非常类似于压缩程序("ZIP")或tarball生成器。

链接器的另一个任务是解析所有引用的符号。假设例子中的main函数会调用另一个函数SomeOtherFunction,这个函数之前会通过外部声明的方式被声明。这个声明可以是这样的:

int SomeOtherFunction(int someParam);

如果这个函数在main.c中没有实现,那么链接器就会将符号SomeOtherFunction记忆为一个已经被引用但尚未定义的函数,即解析。在所有进一步传递给链接器的对象中,链接器现在搜索符号SomeOtherFunction 。如果它找到了一个定义,即函数的实现,对符号的引用就会被解析。在所有对象都被搜索引用解析后,调用链接器时传递的函数库被用来解析剩余的引用。

  • 如果在这里搜索不到某个符号,链接器就会报告一个错误,通常是“unresolved external”;
  • 如果一个符号被定义在一个以上的对象中,链接器也会报告一个错误,在这种情况下是“redefinition of symbol < symbol name >”;
  • 如果一个符号被定义在一个对象和一个或多个函数库中,那么链接器会忽略函数库中的符号定义,并且不会报警或报错。

传递给链接器的函数库的顺序决定了搜索顺序。如果一个符号被解析了,所有后续的定义都会被忽略,不会被 "链接"。

7.定址器(Locator)

绝大多工具厂家都是将链接器和定位器合二为一,然后称之为链接器(Linker)。定址器的作用源于它的名字:它能 "定位"可用内存中的所有符号。每个单独符号的内存地址就是这样确定的。

定址器的输出最终是以带或不带符号信息的格式输出的可执行文件。符号信息对于方便软件的调试是必需的。例如,当显示变量的内容时,符号信息可以方便地对应所需变量的名称,不需要手工对内存地址与变量名进行繁琐的匹配处理。

没有符号信息的可执行文件的典型输出格式是Intel HEX文件(.hex)或Motorola S-Records(.s19)。最常见的带有符号信息的可执行文件的输出格式是ELF格式(*.elf )。ELF是 "Executable and Linking Format "的缩写。

除了可执行文件外,还可以创建一个链接器map文件。其中包含了所有符号的列表以及它们的内存地址。

8·链接脚本

链接器脚本(也叫链接器控制文件)起着非常重要的作用。严格来说,应该叫 "Locator脚本 "或 "Locator控制文件",但如前所述,大多数厂家将定址器和连接器合并为"链接器"。

下图展示了MCU的GNU ld链接脚本示例,显示了一个简单的8位MCU Microchip AVR ATmega32的链接器脚本片段,该MCU具有32 KByte Flash、2 KByte RAM和1 KByte EEPROM。
image.png

链接器脚本告诉定位器如何将符号分配到MCU的不同内存地址区域中。首先,在C语言或汇编源代码中,所有的符号都被分配到特定的“段”(section),更准确地说是“输入段”(input section)。即使程序员没有显式地进行这种操作,也会隐式地进行。接下来,链接脚本中的指令将所有的输入段分配给输出段(output section),而输出段又最终被映射到可用的内存地址中。在典型的链接脚本中,可用内存地址区域的定义在开头就能找到。然后按照输出段的定义,输入段与分配的内存区域关联到一起。

下图是常见的段的名称,代表了默认段。段以“.”开头已成为惯例。
image.png

在GNU Linker手册中可以找到关于这个链接脚本的语法和基本概念的非常好的描述。大多数其他工具厂商的链接器至少采用了GNU链接器("ld")的概念。

总结

代码和变量的保存位置和类型对于CPU对内存的访存时间和代码的访问时间有很大影响。访问的位置和类型是由链接脚本决定的,因此,对其语法和功能的了解对CPU Runtime的优化至关重要,开发过程中应当在需求分析阶段就开展相关工作。

参考资料:

Peter Gliwa, 《Embedded Software Timing》

0 条评论

发布
问题