嵌入式开发初学者指南

发表时间: 2024-06-10 11:02

当我刚开始涉足嵌入式开发时,我一直想找到一本这样的书来回答我的一些问题。不幸的是,目前还没有这样的书,而且我认为它永远不会出版,因为我的问题太多太复杂了。

这些疑惑很难在教科书中找到答案。C 教程专注于 C 语法,而编译原理专注于语法和语义分析。每本教科书都有自己的侧重点,所以那些重叠的问题就得不到解决。市面上那些号称是“XX 宝典”和“XX 圣经”的书,总是会谈到一些连作者自己都可能不明白的问题。所以我想,我想知道的可能也是大家想知道的,所以如果我把我学到的东西写下来,大家可能会少花点时间在上面,把宝贵的大脑资源留给更有意义的事情。

语言选择,C 或其他

刚开始学习嵌入式编程的开发者,总是先看一些教程文章,然后就开始犹豫开发语言的选择,是C还是C++?还是比较流行的JAVA?不要犹豫,至少目前,C还是你的选择。嵌入式开发的本质是定制化开发,硬件平台很多,处理能力也不同,如果想保护学习精力的投入,C是最好的“蓝筹股”。C++的优点是代码重用,但是效率比C低很多,最重要的是,不是所有芯片的编译器都能支持C++。更别说JAVA了,在虚拟平台上开发的好处就是不用关心具体的硬件细节,但是这不是嵌入式开发者的风格,换句话说,这种开发不能称为嵌入式开发。

C被称为高级语言中的低级语言,低级语言中的高级语言。这是因为它有一套接近人类思维的语言体系,支持地址和位操作,能轻松处理硬件,嵌入式开发要操作IO,要操作硬件地址,没有位操作和指针怎么行?

嵌入式开发的一般流程

嵌入式开发流程和高层开发类似,都是编码-编译-链接-运行,当然中间可以有在线调试、重新编码等递归过程,但也还是有区别的。

首先,开发平台不同。由于嵌入式平台的处理能力有限,嵌入式开发一般采用交叉编译环境开发。所谓交叉编译就是在A平台上编译运行在B平台上的目标程序。在A平台上运行的B平台程序的编译器就称为交叉编译器。对于一个初学者来说,建立这样的编译环境可能需要几天的时间。

其次,调试方式不同,我们可以在Windows或者Linux上开发完程序后立即运行,查看运行结果,也可以使用IDE对运行过程进行调试,但嵌入式开发人员至少需要做一系列的工作才能实现这一点。

最流行的方法是使用JTAG连接目标系统,下载并运行编译后的代码,而高级调试器可以几乎像VC环境一样任意调试程序。另外,开发人员所理解的层次结构也不同,高级软件开发人员的工作重点是理解和实现应用需求。

嵌入式开发人员必须比高级开发人员对整个流程的细节有更深入的了解。最大的不同是操作系统支持的程序不需要你关心程序的运行地址以及程序链接后每个程序块的最终位置。需要 MMU 支持的 Windows、Linux 等操作系统,其程序都放置在虚拟地址空间中固定的内存地址处。无论程序的地址在真实 RAM 空间中的哪个位置,最终都会被 MMU 映射到虚拟地址空间中的固定地址处。

为什么程序的运行会和存储地址有关呢?学过汇编原理或者看过程序编译成机器码的同学都知道,程序中的变量、函数在机器码中最终都是体现为地址的。程序的跳转、子程序的调用、变量的调用最终都是由CPU通过直接提取它们的地址来实现的。编译时指定的TEXT_BASE是所有地址的引用值。如果你指定的地址和程序放置的地址不一致,显然就无法正常运行。

也有例外,但不寻常的用法当然需要不寻常的努力。有两种方法可以解决这个问题。

一种方法是在程序的最开始编写与地址无关的代码,然后将下面的程序移动到你实际指定的TEXT_BASE然后跳转到你要运行的代码。

还有一种方法就是指定TEXT_BASE作为你程序的存放地址,然后把程序移动到实际运行的地址处,用一个变量记录后面的地址作为参考值,以后符号表地址就会以这个值作为参考,和偏移值结合起来,形成它的实际地址。

听起来很别扭,实现起来也困难。在后面的内容里有一个比较好的解决办法——使用BootLoader的支持。另外,一个完整的程序至少要有三个段:TEXT(文本,也就是程序编译后的机器指令)、BSS(未初始化的变量)、DATA(已初始化的变量)。前面提到的TEXT_BASE只是TEXT段的基地址,对于其他的BSS和DATA段,如果整个程序放在RAM中,那么三个段可以连续放置。但是如果程序放在ROM或者FLASH这样的只读存储器中,那么你还需要指定你的其他段的地址,因为代码在运行过程中是不会改变的,但是后面两个就不一样了。所有这些工作都是在链接的时候完成的,编译器必须为你提供一些手段来完成这些工作。

再次,有操作系统支持的编程屏蔽了这些细节,你就不用考虑这些令人头疼的问题了。但是嵌入式开发者就没有那么幸运了,他们总是在一块冰冷的芯片上从头开始。CPU 总是在通电和复位时从一个固定的地址寻找程序,开始它忙碌的工作。对于我们的 PC 来说,这个地址就是我们的 BIOS 程序。对于嵌入式系统来说,一般没有 BIOS 支持,而且 RAM 在断电的情况下也无法保留你的程序,所以程序必须存放在 ROM 或者 FLASH 中,但是一般来说,这些存储器的宽度和速度是无法和 RAM 相比的。

程序运行在这些存储器上会降低运行速度。多数的解决方案是在这里存放一个BootLoader。BootLoader所完成的功能可多可少,一个基本的BootLoader只是完成一些系统初始化并将用户程序移动到某个地址,然后跳转到用户程序交出CPU控制权。功能强大的BootLoader还可以支持网络、串口下载,甚至调试功能。但不要指望有一个像PC BIOS一样万能的BootLoader给你用,至少你需要做一些移植工作,使它适合你的系统,这种移植工作也是你开发的一部分,作为嵌入式开发的初学者,移植或者编写一个BootLoader会让你受益匪浅。

没有BootLoader可以吗?当然可以。要么牺牲效率直接从ROM运行,要么自己写程序把代码搬到RAM里运行。最重要的是开发过程中需要好的调试工具支持在线调试,否则哪怕改一个变量都要重新烧录芯片验证。继续说程序入口的话题,不管流程是怎样的,程序最终执行起来都会变成机器指令,纯粹的可执行程序就是这些机器指令的集合。我们操作系统上的可执行程序并不是纯粹的可执行程序,而是经过格式化的,除了上面说的几段之外,还有程序长度,校验和,以及程序入口——也就是从哪里开始执行用户程序。

为什么有了程序地址还需要程序入口点呢?这是因为你真正想要开始执行的代码,并不一定非要放在一个文件的开头。即使放在开头,除非你控制链接,在多个文件的情况下,编译器也不一定会把你的程序放在最终程序的最顶端。像我们这种一般有操作系统支持的程序,你只需要在你的代码中有一个main作为程序入口点就行了——注意,这个main只是大多数编译器约定好的入口点。除非你用了别人的初始化库,否则程序入口点可以自己设定。显然,这种有格式的可执行文件使用起来更加灵活,但是需要BootLoader的支持。关于可执行文件格式,可以看看ELF文件格式。

编译预处理

首先我们来看一下文件包含,从我们的第一个 C 程序 Hello World! 开始,我们就用到了头文件包含,但令人惊讶的是,很多人在做了很长时间的开发之后,仍然对文件包含没有正确的认识,或者概念不清,甚至更多的人把头文件和相关的库混淆了。

为了照顾这些初学者,我在这里就不多说了。其实文件包含的本质就是把一个大文件切割成几个小文件,方便管理和读取。如果你包含那个文件,那么你就把这个文件的全部内容原封不动的复制到你包含它的那个文件中,效果是一模一样的。另一方面,如果你编译了一些中间代码,比如库文件,你可以提供头文件来告知调用者你的库中包含的函数和调用格式,但真正的代码已经以库文件的形式成为了目标代码。至于包含文件的后缀名,比如.h,它只是告诉用户这是一个头文件。编译器一般不会在意你用什么其他名字。

那些还在对头文件和库感到困惑的人应该突然意识到,头文件只能确保你的程序编译时没有语法错误,但库直到最后链接时才会真正使用。那些只复制一个头文件而想要一个库的人再也不要犯这样的错误了。如果你的项目中源程序的数量太多而你无法管理,将它们全部包含在一个文件中也是可以的。

初学者常遇到的另一个问题是重复包含造成的困惑。如果一个文件包含另一个文件两次或两次以上,很可能造成重复定义问题,但没有人会愚蠢到两次包含同一个文件。这个问题就是隐式重复包含。例如,文件 A 包含文件 B 和文件 C,文件 B 包含文件 C。这样,文件 A 实际上就包含文件 C 两次。然而,好的头文件会巧妙地利用编译器预处理来避免这种情况。在头文件中,你可能会发现一些像这样的预处理:

#如果定义 __TEST_H__

#定义 __TEST_H__

… …

#endif/* __TEST_H__ */

这三行编译预处理中的前两行一般在文件顶部,最后一行在文件末尾。意思是如果__TEST_H__没有定义,则定义__TEST_H__并编译后面的代码直到#endif,否则不编译。多么巧妙的设计,有了这三行简洁的预处理,这个文件就算被包含几万次也只能算一次。

我们再来看看宏的使用。初学者看别人的代码时,总会疑惑为什么要用那么多宏。很是困惑。确实,有时候使用宏会降低代码的可读性。但有时候宏也能提高代码的可读性。看看下面两段代码:

1)

#define SCC_GSMRH_RSYN 0x00000001 /*接收同步时序*/

#定义SCC_GSMRH_RTSM 0x00000002 /* RTS* 模式 */

#define SCC_GSMRH_SYNL 0x0000000c /* 同步长度 */

#define SCC_GSMRH_TXSY 0x00000010 /* 发射机/接收器同步*/

#define SCC_GSMRH_RFW 0x00000020 /* Rx FIFO 宽度 */

#define SCC_GSMRH_TFL 0x00000040 /* 传输 FIFO 长度 */

#定义SCC_GSMRH_CTSS 0x00000080 /* CTS*采样*/

#定义SCC_GSMRH_CDS 0x00000100 /* CD*采样*/

#定义SCC_GSMRH_CTSP 0x00000200/*CTS*脉冲*/

#定义SCC_GSMRH_CDP 0x00000400/*CD*脉冲*/

#define SCC_GSMRH_TTX 0x00000800 /*透明发射机*/

#define SCC_GSMRH_TRX 0x00001000 /*透明接收器*/

#define SCC_GSMRH_REVD 0x00002000 /* 反向数据 */

#定义SCC_GSMRH_TCRC 0x0000c000 /*透明CRC*/

#define SCC_GSMRH_GDE 0x00010000 /* 故障检测启用 */

*(int *)0xff000a04 = SCC_GSMRH_REVD|SCC_GSMRH_TRX|SCC_GSMRH_TTX|

SCC_GSMRH_CDP|SCC_GSMRH_CTSP|SCC_GSMRH_CDS|SCC_GSMRH_CTSS;

2)

*(int *)0xff000a04 = 0x00003f80;

这就是对某个寄存器的赋值过程,两者完成的工作一模一样。第一个代码有些冗长,第二个代码则非常简洁,但是如果你想要改变这个寄存器的设定,你显然会更愿意看到第一个代码,因为它现有的值已经非常明确了。要给那些位赋值,只要使用对应的宏定义就可以了,不用每次改完都拿起笔来重新计算。这一点对于嵌入式开发人员来说非常重要。有时候我们在调试设备的时候,某个关键寄存器的值可能会被我们修改很多次,而每次都要计算出每个位对应的值,是一件很头疼的事情。

另外使用宏也能提高代码的运行效率。子程序的调用需要进行栈的压栈和弹出,如果这个过程过于频繁,会消耗大量的CPU计算资源。因此,如果一些小但运行频繁的代码,用带参数的宏来实现,会提高代码的运行效率。比如我们经常用到给外部IO赋值的操作,可以写一个类似下面的函数来实现:

void outb(无符号字符 val,无符号整数 *addr)

*地址=val;

就是一个函数,一个语句而已,但是要调用函数,如果不用函数的话,重复写上面的语句就太啰嗦了,还不如用下面的宏来实现。

#定义 outb(b,addr) (*(volatile unsigned char *)(addr) = (b))

由于不需要调用子函数,宏虽然提高了运行效率,但是却浪费了程序空间,因为所有用到这个宏的地方都要用它替换的语句来替换,开发者需要根据系统需求在时间和空间之间进行权衡。