写点什么

嵌入式软件时序(1)— C 语言是怎么编译出来的

发布于: 刚刚

当很多汽车软件的开发人员都在忙于掌握 AUTOSAR、自动驾驶、V2X、功能安全和信息安全等热门的技术时,我们往往忽略了汽车这个控制对象所要求的最重要的一个特性——“实时性”。今天,实时性这个专题已经上升到了“时序”这一概念和话题。传统的“实时性”仅仅只是针对某个功能和任务而言的,而“时序”不仅包含单一任务实施性的范畴,更加强调的是整个系统的各个任务如何设置、协作来达到全系统的所有任务的实时性要求。有幸发现了一本这方面最新的技术宝典,能够介绍完整的时序技术理论。概述的作者 Peter Gliwa 是专门从事汽车软件时序技术工作的专家,并以自己的名字命令了他的公司,在业界非常有名的 T1 时序工具就是出自 Gliwa。在这个专题中我将把该书的精华逐一介绍分享,希望时序技术能够得到越来越多的关注。



软件开发和操作系统领域的基础知识是分析和优化嵌入式软件时序(Timing)的前提。不仅是针对那些想学习或复习基础原理的人,有经验的软件开发人员也会发现很有帮助。


软件开发的 V 模型


V 模型描述了软件开发过程的概念。它在汽车行业已经使用了几十年,即使当前比较流行的敏捷开发模式(如 Scrum),在背后仍然有 V 模型的身影。像许多技术的发展一样,它起源于军事部门,后来转入民用领域。


V 模型中的 "V"代表了理想化的开发过程,有左右两各维度。横坐标是以项目开始为起点的时间轴。纵坐标标志着抽象:从底部的"详细"到顶部的 "抽象"。见下图,一个项目应该从收集客户对产品的需求开始,具有较高的抽象性。之后在系统层面上是产品的基本设计。在项目进一步的过程中,对设计进行分解、细化。如果可能,要进一步对产品产生更详细的需求。设计阶段完成后,开始进入实现阶段。就软件项目而言,这对应的是编码、各个组件的集成,然后是各个抽象层次上的验证。在这个过程中,要对之前制定的各层级的需求进行检查。最后的检查是在顶层抽象层进行,确保满足客户的需求。



如果不符合需求,就必须消除造成偏差的原因。原因必然是在 V 上需求和验证之间的某个点。因此,所有依附的后续步骤也必须进行修正、调整或至少是重复。


显然,错误发现得越晚,付出的代价越大。这个道理尽人皆知,但令人惊讶的是,很多项目对待嵌入式软件的时序特性并不关注。很多时候,运行时(Run-time)问题都是在项目后期才调查并临时修复的,匆忙之下,成本高,风险大。

时序特性与 V 模型

几乎每个汽车领域的软件开发人员都熟悉 V 模型。在使用 V 模型时,通常会把重点放在功能方面。当时序的话题出现时,会有什么不同?原则上,没有什么变化。该模型的基本思想也可以应用于时序特性。下图显示了这一思想的具体化,并给出了 V 模型不同阶段的时序特性相关技术点的例子。



C 语言的编译:从模型到可执行文件

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


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


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

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

· C 语言预处理(Preprocessor)

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



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



代码第 12 行使用的关键字 volatile(英文 "volatile "的意思是 "易失性")使编译器每次对受影响的变量的访问都是在内存中显性地进行的,而不是例如将其暂时存储在寄存器中。例如,当受影响的内存地址被硬件外设写入时,变量能够马上反映出内存内容的变化。硬件定时器的寄存器就是一个例子。volatile 用于防止编译器将代码优化掉,即编译器认为变量 a 从未被"有意义地"使用,因此删除对它的所有访问的语句。

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



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

 

提示:大多数编译器都支持-E 命令行选项,它使编译器在预处理阶段后终止,并将预处理代码输出到 stdout。在调试与预处理有关的问题时,这一点非常有用。

 

此外,这个输出也很适合向编译器制造商报告编译器问题。如果输出被重定向到一个文件(文件扩展名为.i),这个文件可以传递给编译器进行编译,而不需要任何其他文件,比如包含的头文件。编译器厂商就可以重现问题,而不需要访问所有包含的头文件。

main.c 的预处理输出(mian.i)



· C 语言编译器(Complier)

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


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

 

编译器输出汇编代码 main.src



· 编译器代码优化

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

· 汇编器(Assembler)

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


· 链接器(Linker)


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


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

 

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

 

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

 

如果在这里搜索不到某个符号,链接器就会报告一个错误,通常是 "unresolved external <symbol name>"。

 

如果一个符号被定义在一个以上的对象中,链接器也会报告一个错误,在这种情况下是"redefinition of symbol < symbol name >"。

 

如果一个符号被定义在一个对象和一个或多个函数库中,那么链接器会忽略函数库中的符号定义,并且不会报警或报错。


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


· 定址器(Locator)


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


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

 

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

 

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


· 链接脚本

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

MCU 的 GNU ld 链接脚本示例



以上显示了一个简单的 8 位 MCU Microchip AVR ATmega32 的链接器脚本片段,该 MCU 具有 32 KByte Flash、2 KByte RAM 和 1 KByte EEPROM。


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


.text      程序代码

             例如: int GiveMe42(void){return 42;}

 

.rodata   只读数据(read-only)

              例如: const int a = 5;

 

.bss        可读写数据,初始化为 0

               例如: int a;

               根据 C 语言标准,未初始化的全局变量必须被启动代码初始化为 0。并非所有的嵌入式软件项目都是这样实现启动代码的,因此,不应理所当然地认为定义期间未赋初值的所有变量在启动时都会被清 0。

 

.data      可读写数据 (read-write),初始化为一个指定值

              例如: int a = 5;

 

.noinit    可读写数据 (read-write),不初始化

              例如: int a;

              这个例子与.bss 的例子相同。编译器开关通常可以用来控制代码中未初始化的变量是否应该用 0 初始化或根本不初始化。

 

.debug   调试段既不包含程序的代码,也不包含程序的数据,而是包含能够实现或简化软件调试的附加信息。


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

 

链接脚本与时序有什么关系?访问的位置和类型对内存的访存时间和访问代码的执行时间有很大影响。访问的位置和类型是由链接脚本决定的,因此,对其语法和功能的了解对运行时的优化至关重要。


*本文所有示意图和代码示例,以及部分内容均引用自 Peter Gliwa 的《Embedded Software Timing》一书。

<本文首发于零束开发者论坛>


​作者:SoftAuto

文章来源:上汽零束 SOA 开发者论坛

原文链接:https://bbs.z-onesoft.com/omp/community/front/api/page/mainTz?articleId=7567

用户头像

还未添加个人签名 2021.09.06 加入

还未添加个人简介

评论

发布
暂无评论
嵌入式软件时序(1)— C语言是怎么编译出来的