嵌入式系统中的代码执行

11

我在嵌入式系统领域工作。我想了解从C文件开始,代码如何从微控制器(uC不需要是主观的,一般而言)执行。另外,我想了解启动代码、目标文件等相关知识。我无法找到任何关于上述内容的在线文档。如果可能的话,请提供从头开始解释这些事情的链接。感谢您的帮助。


最好指明使用的微控制器类型。 - Matthias Wandel
我正在使用8051控制器工作。我了解一些有关汇编语言中如何获取和执行操作码的知识。但是我想知道一个包含多个C文件的项目在微控制器上如何执行。 - inquisitive
2
C文件不能直接执行! :-) 它们被编译成目标文件,然后链接到最终的可执行映像中,该映像可以加载到闪存或RAM中并从那里运行。 - Warren P
7个回答

45
作为一名微处理器架构师,我有机会在软件的非常低层次上工作。基本上,低层嵌入式与一般PC编程的不同之处仅在于硬件特定级别。
低层嵌入式软件可以分为以下几类:
  1. 复位向量 - 通常用汇编语言编写。它是在启动时运行的第一件事,并可被视为硬件特定代码。它通常执行简单的功能,如通过配置寄存器等将处理器设置为预定义的稳定状态。然后跳转到启动代码。最基本的复位向量只是直接跳转到启动代码。
  2. 启动代码 - 这是运行的第一个软件特定代码。它的工作基本上是设置软件环境,以便C代码可以在其上运行。例如,C代码假定有一个内存区域定义为堆栈和堆。这些通常是软件构造而不是硬件。因此,这段启动代码将定义堆栈指针和堆指针等。这通常归为“c-runtime”。对于C++代码,还会调用构造函数。在该例程结束时,它将执行main()编辑:需要初始化的变量以及需要清除的某些内存部分也在此处完成。基本上,所有需要将事物移动到“已知状态”的内容都在这里。
  3. 应用程序代码 - 这是您实际的C应用程序,从main()函数开始。正如您所看到的,许多事情实际上都在幕后发生,甚至在第一个主函数被调用之前就已经发生了。如果有良好的硬件抽象层可用,这些代码通常可以编写为与硬件无关。应用程序代码肯定会使用许多库函数。这些库通常在嵌入式系统中静态链接。
  4. - 这些是提供基本C函数的标准C库。还有处理器特定的库,实现诸如软件浮点支持之类的东西。也可以有硬件特定的库来访问I/O设备等,用于stdin/stdout。一些常见的C库是NewlibuClibc
  5. 中断/异常处理程序 - 这些是由于硬件或处理器状态的更改而在正常代码执行期间随机运行的例程。这些例程通常也以汇编语言编写,因为它们应该以最小的软件开销运行,以便服务于实际调用的硬件。
希望这能提供一个良好的起点。如果您有其他问题,请随意留下评论。

@Guru_newbie:“实际执行是通过从闪存中执行每个指令来完成的。”一些处理器直接从闪存中运行代码,而有些则不会。我相信8051将从闪存中运行代码。高端(32位)嵌入式处理器,如PC,将应用程序代码复制到RAM中并从RAM中执行。 - simon
@sybreon:第二步还将在RAM中设置静态变量并复制初始化数据。 - simon
我认为"inquisitive"可能意味着动态运行时内存分配,就像malloc一样。如果是这样的话,那么这些内容应该在"Libraries"部分中,并且通常被称为"标准C库",其中Newlib和uCLibc是标准C库的特定实现,适用于微控制器使用。 - Warren P
1
我喜欢你的回答,sybreon,但是(在第二点中)我不认为C语言假定会有堆。许多嵌入式C编译器将为静态变量预分配内存,并从堆栈中分配本地变量的空间。在这种情况下,只有在使用具有malloc()函数的库时,堆才会发挥作用。不确定C++构造函数是否也是如此。 - Ben Gartner
@Ben Gartner 但是malloc()是C标准库的一部分,而C又是其一部分。因此,如果该库假定存在堆,则整个C也是如此。如果您忽略该库,则谈论的是C的“子集”。 - nonsensickle
显示剩余4条评论

5
通常,嵌入式系统的工作级别比通用计算机低得多。每个CPU在上电时都会有特定的行为,例如清除所有寄存器并将程序计数器设置为0xf000(这里的一切都是非具体的,就像你的问题一样)。关键是确保您的代码位于正确的位置。编译过程通常类似于通用计算机,即将C翻译成机器码(目标文件)。然后,您需要将该代码与以下内容链接起来:您的系统启动代码(通常是汇编语言),任何运行时库(包括所需的C RTL部分)。系统启动代码通常只初始化硬件并设置环境,以便您的C代码可以工作。嵌入式系统中的运行时库通常使大而笨重的东西(如浮点支持或printf)变为可选,以保持代码膨胀。嵌入式系统中的链接器通常也要简单得多,输出固定位置的代码而不是可重定位的二进制代码。您使用它来确保启动代码位于(例如)0xf000处。在嵌入式系统中,通常希望从一开始就存在可执行代码,因此可能会将其烧录到EPROM(或EEPROM或Flash或其他维护断电时内容的设备)中。
当然,记住我上次使用的是8051和68302处理器。现在的“嵌入式”系统可能是完整的Linux盒子,具有各种精彩的硬件,如果是这样,通用和嵌入式之间就没有真正的区别了。
但我怀疑。仍然需要严重低规格的硬件,需要定制操作系统和/或应用程序代码。 SPJ嵌入式技术提供其8051开发环境的可下载评估版,看起来符合您的需求。您可以创建大小为2K的程序,但它似乎经过了整个过程(编译链接,生成HEX或BIN文件以转储到目标硬件,甚至还有一个模拟器,可以访问芯片上的东西和外部设备)。
非评估产品的成本为200欧元,但如果您只想玩一下,我会下载评估版 - 除了2K限制外,它是完整的产品。

谢谢您的快速回复,pax。如果可能的话,您能否提供任何好的链接来解释上述过程(无论实际的uC是什么)? - inquisitive

3
我感觉你最感兴趣的是sybreon所说的“第二步”,与平台相关,有很多变化。通常,这些都是由引导加载程序、板支持包、C运行时(CRT)和操作系统(如果有)的组合处理。在重置向量之后,一些引导程序通常会从闪存中执行。这个引导程序可能只是设置硬件并跳转到您的应用程序的CRT(也在闪存中)。在这种情况下,CRT可能会清除.bss,将.data复制到RAM等。在其他系统中,引导程序可以从编码文件(如ELF)中分散加载应用程序,而CRT只需设置其他运行时内容(堆等)。所有这些都发生在CRT调用应用程序的main()之前。如果您的应用程序静态链接,链接器指令将指定初始化.data/.bss和堆栈的地址。这些值要么链接到CRT中,要么编码到ELF中。在动态链接环境中,应用程序加载通常由操作系统处理,该操作系统将ELF重新定位为在操作系统指定的任何内存中运行。另外,一些目标从闪存中运行应用程序,但其他目标将可执行的.text从闪存复制到RAM。(这通常是速度/占用空间的权衡,因为在大多数目标上,RAM比闪存更快/宽。)

2
你可以参考以下链接:https://automotivetechis.wordpress.com/
下面是控制器指令执行顺序的概述:
1) 分配程序执行的主内存。
2) 将地址空间从二级内存复制到主内存。
3) 将可执行文件中的 .text 和 .data 段复制到主内存中。
4) 将程序参数(例如命令行参数)复制到堆栈上。
5) 初始化寄存器:将 esp(堆栈指针)设置为指向堆栈顶部,并清除其余部分。
6) 跳转到启动例程,该例程:从堆栈中复制 main() 的参数,并跳转到 main()。

2
好的,我来试着翻译一下...
首先是架构。冯诺伊曼(Von Neumann)与哈佛(Harvard)之间的区别。哈佛结构拥有用于代码和数据的不同内存,而冯诺伊曼则没有。哈佛结构被许多微控制器所使用,并且是我熟悉的架构。
那么从基本的哈佛体系结构开始,您会拥有程序存储器。当微控制器首次启动时,它会执行位于零号内存位置的指令。通常,这是一个跳转到地址命令,其中主代码开始。
现在,当我说指令时,我指的是操作码。操作码是编码成二进制数据的指令 - 通常为8或16位。在某些架构中,每个操作码都是硬编码的,具有特定的含义;在其他架构中,每个位可重要(即,第1位表示检查进位,第2位表示检查零标志等)。因此有操作码,然后有操作码的参数。跳转指令就是一个操作码,以及一个8、16或32位的内存地址,代码会“跳转”到该地址处。也就是说,控制权将被转移到该地址处的指令。这通过操作一个包含下一条要执行的指令地址的特殊寄存器来实现。因此,要跳转到内存位置0x0050,它将用0x0050替换该寄存器的内容。在下一个时钟周期中,处理器将读取该寄存器并定位内存地址并执行那里的指令。
执行指令会导致机器状态的改变。有一个常规状态寄存器,记录有关上一条命令操作的信息(即,如果是加法,则是否需要进位,是否有相应的位等)。还有一个“累加器”寄存器,用于放置指令的结果。指令的参数可以放在几个通用寄存器之一中,也可以放在累加器中或内存地址(数据或程序)中。不同的操作码只能对某些位置中的数据执行。例如,您可能能够将来自两个通用寄存器的数据相加,并将结果显示在累加器中,但不能将来自两个数据内存位置的数据相加,并将结果显示在另一个数据内存位置中。您必须将要使用的数据移动到通用寄存器中,进行加法运算,然后将结果移动到所需的内存位置。这就是为什么汇编语言被认为难以理解的原因。存在着与架构设计的状态寄存器一样多的状态寄存器。更复杂的架构可能会有更多的寄存器,以允许更复杂的命令。更简单的架构可能没有这些。
还有一个称为堆栈的内存区域。对于某些微控制器(如8051),它只是内存中的一个区域。在其他情况下,它可能具有特殊的保护。有一个名为堆栈指针的寄存器,记录着堆栈顶部的内存位置。当您从累加器中“推送”数据到堆栈时,“顶部”内存地址会增加,并且将来自累加器的数据放入先前的地址中。从堆栈中检索或弹出数据时,执行相反的操作:堆栈指针递减,将数据从堆栈中取出并放入累
现在我也大概讲述了指令是如何“执行”的。这时,你需要深入数字逻辑——类似于 VHDL 的东西,比如多路复用器、译码器和真值表等等。这是设计的真正细节,有点棘手。如果你想将内存位置的内容“移动”到累加器中,你需要确定寻址逻辑、清除累加器寄存器、对内存位置处的数据进行与运算等等。把所有这些放在一起看会让人望而生畏,但是如果你已经分别使用 VHDL 或任何数字逻辑方式完成了某些部分(例如寻址、半加器等),你可能知道需要哪些操作。
这与 C 有什么关系呢?编译器将接受 C 指令并将其转换为一系列操作码,以执行所需操作。所有这些基本上都是十六进制数据——一堆由 1 和 0 组成的数据,它们被放置在程序内存的某个位置。这是通过编译器/链接器指令完成的,该指令告诉哪个内存位置用于哪个代码。它被写入芯片上的闪存中,然后当芯片重新启动时,它会跳转到代码内存位置 0x0000 并跳转到程序内存中代码的起始地址,然后开始执行操作码。

重置时,处理器从重新启动向量开始执行,该向量可能位于0x0000位置或其他位置。您需要查看特定处理器的数据表以获取重新启动向量的位置。 - tkyle

1

我有AVR微控制器的经验,但我认为这对所有微控制器来说都是差不多的:

编译过程与普通C代码相同。它被编译成目标文件,这些文件被链接在一起,但输出不像ELF或PE这样的复杂格式,而是简单地放置在uC内存中的某个固定地址上,没有任何头文件。

启动代码(如果编译器生成任何代码)以与“普通”计算机的启动代码相同的方式添加--在您的main()代码之前(也许在其之后)添加了一些代码。

另一个区别是链接--由于微控制器没有处理动态链接的操作系统,因此必须静态链接所有内容。


谢谢Cube。我现在明白了,在主机PC上会创建一个可执行文件,并将其放置在uC的非易失性存储器中。我想知道从那时起实际目标中的实际执行是如何开始的。任何关于此的在线文档或案例研究都是首选。 - inquisitive
关于 ELF/PE 没有完全准确。 许多嵌入式系统的链接器都会输出 ELF,只不过其中的二进制代码是固定地址而非位置无关的。因此,可以生成一个十六进制文件(Motorola S-record 或 Intel Hex)或直接的二进制转储文件(假设您知道起始地址)来加载到 Flash 中。 - Craig McQueen

1

请注意:ARM是较为复杂的嵌入式系统之一。与较小的uC(如AVR)相比,启动代码尤其复杂。 - Craig McQueen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接