引言
以下是一个C语言函数,有三行代码,实现将数字转为对应字符的功能。当然,真正的函数实现应该增加判断a的取值是否在0到9之间,这里简单的逻辑实现仅仅是为了阐述嵌入式的知识点。我们的故事就从我们写下这行代码开始,止于这行代码变成指令在CPU中运行。写这篇文章是为了讲清楚作为一名嵌入式软件开发工程师应该要具备的技能,即其应该要掌握的知识点。
char str2num(char a){
char b = a + 0x30;
printf("%c", b);
return b;
}
一、集成开发环境
我们写代码、编译、链接、调试等都是在集成开发环境中进行,作为一个嵌入式工程师应该非常精通一个集成开发环境,就像至少要精通一种芯片的体系结构一样,如Keil之于51。其相关点包括:
1. 工程的属性设置,这非常重要,包括库路径、头文件路径、代码优化级别、调试选项、宏定义等等。不仅仅是读懂,而是应该去改动这些设置,看看有什么变化和影响,真正地掌握。
2. 每个文件的属性设置也要充分了解,如产生汇编中间文件等;
3. 中间文件和结果文件的路径以及文件的格式作用;
4. 工程管理;
5. 设置字体和code style(颜色和高亮显示)是让自己的眼睛更舒服。
6. 知晓集成开发环境和工具链的对应关系。集成开发环境的编辑链接调试按钮对应什么应该要知道吧。有些芯片平台可能并没有很好的集成开环境(如MIPS),那只能熟悉命令行操作了。
7. 一般的基础开发环境都支持插件开发,理解一下就好,除非自己的工作就是做这个的。
当然,看代码一般不在集成环境里面进行,我喜欢source insight,高亮显示和智能关联,极大地提高效率。在开发环境和source insight里面写代码都较常见。
二、工具链
引言的函数是高级语言,而CPU能理解的是机器语言,也就是0101这些二进制数据。所以要想让CPU执行这个函数,得依靠工具链的支持。一般我们是在PC上进行开发,即所谓的宿主,但执行这段代码的是嵌入式处理器,也就是所谓的目标系统。所以这种工具链也成为交叉工具链,对应的开发环境称为交叉编译环境。我个人认为很多事物和思想都是相通的,举一反三,我们需要做的就是精通,至少很熟悉一种工具链,如gcc工具链。能够运用工具链并不代表你很熟悉,真正的熟悉应该是理解工具链的作用以及其在实现这个作用的过程中的大致做法,也就是实现的原理。我们需要弄懂的包括:
1. 编译器。
1)理解编译原理。编译是将每个C文件转换为接近机器语言而又能够被开发工程师理解的汇编语言,而且其将每个文件的代码和变量数据进行重新组织,分成几个部分,如这个文件里面的函数都会集中放到一个代码段.CODE(除非你自己把某个函数规定要某个指定的段),常量数据段.CONST(类似代码段只读),静态变量和全局变量所在的.DATA(有些平台两个段的名字也不一样,按作用实际一样的),未初始化的全局变量.BSS;另外一个很重要的任务是记录可重定位的符号(函数、变量)信息,如引言函数调用printf这个函数,而printf函数的地址暂时是未知的,那文件中需要为这个调用做好将来重定位的准备。这时看到的代码段的起始地址往往是0。
2)理解编译选项。其实集成开发环境的工程属性和文件的属性设置对应的就是这个编译选项。
2. 汇编器。
汇编器是将汇编语言转为机器语言,输出的也是可重定位文件。一般编译器都包含了汇编这个步骤,直接输出可重定位.O文件。而汇编器一般是处理以汇编语言编写的文件。
3. 链接器。链接就是将所有可重定位文件的代码段、数据段等进行统一组织和空间分配,其依据就是工程的链接文件,另外最重要的是实现重定位的过程。链接文件是有其固有的语法格式的,要熟悉其语法,会自己写链接文件,不能仅仅是拷贝和局部改动,而是真正能利用链接来实现自己普通的和特有的想法。最后链接器会生成一定格式的可执行文件,如exe和elf格式等。另外,掌握芯片平台的存储资源范围也是写好链接文件的前提条件。
4. makefile。makefile是gcc工具链非常重要的部分,其作用就用命令行的方式来替代集成开发环境,实现编译和链接的自动化执行过程。makefile是脚本执行,同样有固定的语法,需要很好地领会。
5. binutils。即二进制工具集,掌握和利用好这些工具能够提高开发的效率。例如在GCC工具链中我们想快速定位到某个变量的地址,并不需要去查看一些结果文件,而是敲命令mn就可以了。
至此,我们假定已经将引言的C函数编译成.O文件了,加入到工程并调用,写好makefile和link,就可以输出为可执行文件了。
三、理解各种类型的文件格式
嵌入式开发需要和硬件打交道,硬件资源有限,交叉调试环境往往没有PC软件那么友好。因此要懂得利用一切游泳资源来提高开发调试效率。我现在的机子没有交叉编译环境,我叫一个嵌入式开发工程师帮我取得引言中代码的反汇编代码,他竟然回答说要调试的时候用汇编模式才能看到反汇编代码。这明显就是一般PC工程师的认识。实际我们可以在很多地方看到反汇编的信息的。
1. 可执行文件格式,如elf。应该很好地理解这个文件格式。我们常听到一句话是“存在即是道理”,里面所有的数据结构都有其对应的作用。我们可能会看到在链接文件里面会有一个ENTRY的标识,而在elf的文件头格式中有一个entry的域,其是一一对应的,它代表着这个可执行文件的第一条执行指令的地址。有很多人可能会认为这个地址应该是main的地址吧,其实并不是,而是运行时库的入口。当然,我们并不需要对着elf的格式说明来查看这个二进制文件。不是有binutils吗?它就是用来查看我们需要的信息的。
2. map文件。map文件是以文本文件的形式来记录elf格式文件中的各种代码和数据段的地址信息和长度,以及各个函数和变量对应的地址等等。结合link文件来理解map文件会很有趣。map文件反映的函数和变量的地址信息对于调试会非常有用,尤其是一个团队开发一个项目时往往有多个工程,多个模块,而工程A在调试时,集成开发环境的watch是监看不到工程B的某个变量的。这时就需要直接输入该变量地址来查看内容值。
3. lst文件。其是反汇编文件,掌握汇编语言是嵌入式工程师的必备技能,平时可能较少用汇编开发程序,但调试的时候需要经常看反汇编是很正常的。
4. 理解代码和数据的组织方式。.O和.ELF都有描述。我们在学习C语言的时候,老师往往不会讲这些内容。这里指的是我们要知道编译器如何按它的方式去理解我们的想法(函数和变量),并以其自己的方式去管理这些函数和变量。也就是一个C文件通过编译输出.O文件时,其产生的.CODE、.DATA、.BSS、.SYM等等段。
四、加载
嵌入式软件工程师和PC工程师最大的区别是嵌入式软件工程师需要对自己所写的每一行代码负责,负责的意义代表着这行代码的作用、它在可执行文件中的位置、它真正要加载到哪里(即虚拟运行地址)、它什么时候会被加载、它什么时候会被执行等等。
加载一般指的是将目标代码复制到内存对应位置的过程(记得,RAM是掉电不保存内容的)。一般有以下几种方式:
1.对于存储资源丰富型系统,一般是使用高端嵌入式处理器的系统,在系统启动时ELF文件一般是放在外存储设备中。这种系统都部署了嵌入式操作系统,能够解释ELF格式文件,将ELF里面真正的代码和数据段加载到内存对应的位置。其第一个加载的自然是ENTRY对应的起始地址所在的代码段。
2.对应存储资源紧缺型系统,一般是使用低端嵌入式控制器的系统,其部署的操作系统往往是精简高效型系统,它并不直接解释ELF格式,而是解释ELF再处理后的格式文件。这种系统往往是定制型的操作系统,而ELF再处理也跟操作系统相关。但再处理后的格式依然要保持必要的信息,如entry,.CODE,.DATA等等。在系统启动阶段,再处理后大幅减少容量的文件一般也是放在外存储设备中,在必要时被操作系统读取并加载到内存中。
3. 有时一些代码会被固化到ROM中,如系统启动执行的代码都是固化到ROM中,如PC上电执行的第一条指令就是固化的,而且固化的地址就是运行地址,无需加载。对于嵌入式控制器,这个固化也可以理解为将代码烧写到flash rom中。这应该是常见的利用开发板进行开发所进行的步骤。
4. 有时一个系统里面又有RAM又有ROM,那有部分代码会固化到ROM中,但其运行地址并不是固化的地址,这时也需要将代码加载到RAM中对应的地址才能运行。
五、内存管理
上节所说的加载一定是将代码加载到实际的物理内存,但我们的可执行文件中的运行地址是虚拟运行地址,两者之间是什么映射的呢?这就是内存管理单元(MMU)的作用域。高端处理器一般都有MMU,但低端控制器一般是没有MMU的,但低端控制器也会按一定的方式去做好映射,最简单的就是虚拟和物理一一对应嘛。有MMU管理的系统也不是硬件就能解决所有问题,其也需要借助于页表来记录映射关系,MMU里面有一个叫做TLB的东西,是页表的CACHE,在每次访问内存时会被将虚拟地址的前N位转为对应的物理内存块的前N位,依据就是页表(可以理解为转换表)。
六、执行
执行肯定指的是CPU的取指执行了,我们需要懂得什么?我们要理解的是芯片的体系结构,理解其精简指令集。
1. 寄存器,包括通用寄存器和专用寄存器。通用寄存器一般用来暂存数据和做计算用。专用寄存器是实现专有的功能,最重要的是PC和SP。PC即程序寄存器,记录的是当前指令执行的地址,SP是栈指针,记录的是当前的栈顶地址。栈的使用绝对是一个非常绝妙的发明,栈的递归恰好对应的是函数的调用和返回,函数的调用就是将必要的信息入栈,这里最重要的是包括返回地址;而程序返回就是出栈,我们可以看到像RET这样的指令对应的伪指令都是将当前SP的值赋给PC,然后SP增加或者减少(根据体系结构而定)。
2. 理解指令集。
3. 理解流水线相关的问题、cache的问题。调试的时候特别要注意指令预取所带来的问题。
七、运行时库
我们刚开始学C语言的时候应该都有一种迷惑,我们在main中调用printf这个函数就能打印了,但我们又没看到它具体的实现是在哪里。另外,我们大部分人往往都有一个误解,以为main入口是程序执行的第一个指令,实际是不对的。
1. pirntf的功能是打印信息到屏幕上,我们可以设想在main执行之前一定要初始化屏幕的输出驱动吧。
2. 操作系统支持多线程,而执行的程序是一个进程,main就是一个主线程,在我们的程序里面没看到什么进程线程之类的东西,就是在main之前做好的。
3. 我们一般在C++中看到某个类有构造函数和虚构函数,它们分别是在main之前和退出之后要做的动作,怎么支持这个功能呢?
这些大致的功能就是运行时库所要支持的功能,不同的操作系统有不同的要求,但从现在开始,我们不能再认为main就是程序第一个要执行的指令。
八、C库
printf这个函数实际上是标准C库的一个函数。每种语言都会有很多的支持库,所以我们要懂得如何去利用现有的库来实现我们的功能,而不是什么都要自力更生。当然从节约存储资源和提高执行效率的角度,可以参考C库某函数的实现,然后进行改进。C库包括常见的字符串转换(如引言的str2num)、浮点计算等模块。
九、API
引言函数调用printf这个函数,而这个函数一般是操作系统提供的函数,其不会把代码实现拿到引言函数所在的应用工程去build,拿到的仅仅是API库。也就是说应用先通过API层进入到操作系统层再调用实际的打印驱动代码。API怎么实现?一般使用陷入指令或者软件中断,当然API的设计也是一门艺术,在资源紧缺型的系统中更是如此,因为陷入的时候我们除了要提供打印的参数,还要提供打印函数对应的索引,这个索引实现的方式并不单一,这就是具体设计时要考虑的。
十、ABI
ABI即二进制程序接口,主要涉及的是栈帧的实现和函数调用时的传参约定。这是在C语言和汇编语言交叉编程时特别要注意的知识点。
1. 栈帧记录的是非常重要的上下文信息,在多任务操作系统移植时,它记录的信息一定是要进行重点关注的,往往是汇编语言进行编写。因此理解栈帧是移植操作系统的必要步骤。当然理解好栈帧能够很好地帮助调试。
2. 传参约定,即在传递参数时用寄存器存还是栈来存,或者两者都有(如MIPS工具链),存放的顺序,在参数类型不同或者个数不同时又是怎么约定的。
3. 寄存器的使用约定,通用寄存器的执行速度肯定优于内存,所以寄存器的使用非常频繁。在函数调用的过程中,自然会出现某个函数执行到一半的时候就发生调用了,这时某些寄存器就要先保存起来,以免被下一个要调用的程序破坏;而某些寄存器在使用之前按规定是先要保存再利用,返回前将其恢复就可以了。
十一、编码格式
将数字转为字符串就是加上0X30,这是跟进ASCII码表来实现的。做嵌入式开发很多时候需要理解很多编码格式,如UNICODE,字库等等码表。
十二、软件层次
嵌入式开发工程师不应仅仅停留在某个应用或者某个模块上,而是应尽量全面地学会或者把控整个系统,虽然不能每个模块的代码都去理解一遍,但基本的流程要懂。基本的软件层次从低到高有启动、驱动、操作系统、API、中间件、UI、应用等等。
十三、硬件体系结构
现在的芯片都可以成为SOC,不仅CPU,很多模块都已经集成到芯片上,如ADC、SRAM等等。要懂得各个模块的使用接口,还要理解CPU和各种接口对应的总线的访问方式,他们是怎么竞争访问的。
十四、编程思想
要真正地学好一门语言,不是指懂得它的语法,而是要有解决问题的思想。对于嵌入式开发,应该学会面向对象的编程思想。其实C++的类CLASS和C语言的数据结构STRUCT不是一回事吗?
以上是我个人的认识,都是知识点,并没有展开具体的描述,以后有时间会逐一地加以描述并分享!有本书可以推荐大家阅读《深入理解计算机系统》!
写文章挺累的,但能够通过分享对你有帮助也是快乐的!