理解C运行环境(ARM)-从何开始

10

我是一位嵌入式开发者,主要使用ARM Cortex-M设备。最近,我转向了Linux,并决定学习更多关于构建/组装/链接流程的知识,如何编写makefile等。因为我之前使用的IDE(IAR、Keil、Eclipse等)自动化很多,用户实际上不知道在后台发生了什么。目标是更好地理解这个低级过程,如何正确使用工具,而不仅仅依赖于IDE的默认设置。

编写makefile后,我能够构建我的应用程序。但是,我决定通过直接调用链接器(而不是通过编译器)手动执行链接过程,结果出现了问题!未定义对libc、__libc_init_array中的_init函数、_exit等的引用是问题所在。在整整一天的调查后,我成功地手动包含了所有的对象文件(crt0.o、crti.o)和库(libnosys.a)。显然,编译器是自动完成这些工作的。

克服这些麻烦后,我意识到我完全不了解这些内部信息。我为什么需要一些ctr0.o、crti.o等文件?这些文件从哪里来?它与编译器/链接器或C运行时库或系统有关吗?

我想学习更多关于这些内部信息的知识,但我不确定我实际上在寻找什么。是图书、手册、读物,还是所有这些东西的组合?

编辑:

在与您的讨论后,我很可能已经想清楚了我需要什么,所以我会重新表达我的问题:

1)我收到了一个MCU(比如STM32F4xx),我应该创建一个闪烁的LED示例。所有这些都应该从头开始做,自己的启动代码,不使用外部库等。

2) 在第二步中,有人告诉我所有这些都已经被其他人完成了(GCC工具链/标准库、MCU供应商的启动文件等)。因此,我只需要理解/链接我的工作与已经完成的内容,并比较差异,了解为什么他们要那样做等。

@mbjoe回答了所有这些问题,而且我还发现了一些有趣的阅读材料: https://www.amazon.com/Embedded-Shoestring-experience-designing-software/dp/0750676094

感谢大家的帮助和指引我走向正确的方向!


2
要求我们推荐或寻找书籍、工具、软件库、教程或其他外部资源的问题在 Stack Overflow 上是不适合的,因为它们往往会吸引主观的答案和垃圾邮件。相反,请描述问题以及已经采取的解决方法。 - LPs
如果你想看到实际的链接命令,或许使用编译器时有“详细”模式。Gcc肯定可以显示它正在做的所有事情。 - Jens
1
也许我可以建议您阅读高级Linux编程书籍,我脑海中没有具体的页码,在其中清楚地说明了从头开始构建ARM Linux,编写makefile直到使用tftp引导内核。您的问题非常广泛。 - danglingpointer
1
了解事物的运作方式包括了解需要在任何微控制器C程序启动时执行的代码。在ARM的情况下,还要了解CMSIS的作用和不作用。像IAR这样的工具将为您预先编写好启动代码。否则就必须自己编写代码。这是非常有用的知识。 - Lundin
1
尽管可能会很痛苦,但有时你想要完全控制代码的行为,尤其是在嵌入式环境中。 - BillyJoe
显示剩余5条评论
5个回答

3
你所提到的模块(ctr0.o,crti.o,_init,__libc_init_array,_exit)是由IAR和/或Keil预构建的库/对象文件/函数。正如你所说,它们需要在运行main()函数之前初始化环境(全局变量初始化、中断向量表等)。在这些库/对象文件中的某个时刻,将有一个类似于以下的C或汇编语言函数:
void startup(void)
{ 
    ... init code ...

    main();

    while(1);   // or _exit()
}

你可以查看以下从头开始构建启动代码的示例:

http://www.embedded.com/design/mcus-processors-and-socs/4007119/Building-Bare-Metal-ARM-Systems-with-GNU-Part-1--Getting-Started

https://github.com/payne92/bare-metal-arm


也许这正是我在找的东西。非常感谢mbjoe,我一定会查看这些链接! - ST Renegade

3
我收到了一款MCU(比如STM32F4xx),我需要创建一个闪烁LED的例子。这应该是从零开始完成的,包括自己的启动代码,没有使用外部库等。
我有一个MCU,比如STM32F4xx,想要让PA5上的LED闪烁,不使用任何库,从零开始,没有外部内容。
blinker01.c
void PUT32 ( unsigned int, unsigned int );
unsigned int GET32 ( unsigned int );
void dummy ( unsigned int );

#define RCCBASE 0x40023800
#define RCC_AHB1ENR (RCCBASE+0x30)

#define GPIOABASE 0x40020000
#define GPIOA_MODER     (GPIOABASE+0x00)
#define GPIOA_OTYPER    (GPIOABASE+0x04)
#define GPIOA_BSRR      (GPIOABASE+0x18)

int notmain ( void )
{
    unsigned int ra;
    unsigned int rx;

    ra=GET32(RCC_AHB1ENR);
    ra|=1<<0; //enable GPIOA
    PUT32(RCC_AHB1ENR,ra);

    ra=GET32(GPIOA_MODER);
    ra&=~(3<<10); //PA5
    ra|=1<<10; //PA5
    PUT32(GPIOA_MODER,ra);
    //OTYPER
    ra=GET32(GPIOA_OTYPER);
    ra&=~(1<<5); //PA5
    PUT32(GPIOA_OTYPER,ra);

    for(rx=0;;rx++)
    {
        PUT32(GPIOA_BSRR,((1<<5)<<0));
        for(ra=0;ra<200000;ra++) dummy(ra);
        PUT32(GPIOA_BSRR,((1<<5)<<16));
        for(ra=0;ra<200000;ra++) dummy(ra);
    }
    return(0);
}

flash.s

.thumb

.thumb_func
.global _start
_start:
stacktop: .word 0x20001000
.word reset
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang
.word hang

.thumb_func
reset:
    bl notmain
    b hang
.thumb_func
hang:   b .

.align

.thumb_func
.globl PUT16
PUT16:
    strh r1,[r0]
    bx lr

.thumb_func
.globl PUT32
PUT32:
    str r1,[r0]
    bx lr

.thumb_func
.globl GET32
GET32:
    ldr r0,[r0]
    bx lr

.thumb_func
.globl dummy
dummy:
    bx lr

linker script flash.ld

MEMORY
{
    rom : ORIGIN = 0x08000000, LENGTH = 0x1000
    ram : ORIGIN = 0x20000000, LENGTH = 0x1000
}

SECTIONS
{
    .text : { *(.text*) } > rom
    .rodata : { *(.rodata*) } > rom
    .bss : { *(.bss*) } > ram
}

所有这些都是使用gcc/gnu工具完成的。

arm-none-eabi-as --warn --fatal-warnings -mcpu=cortex-m4 flash.s -o flash.o
arm-none-eabi-gcc -Wall -Werror -O2 -nostdlib -nostartfiles -ffreestanding  -mcpu=cortex-m4 -mthumb -mcpu=cortex-m4 -c blinker01.c -o blinker01.flash.o
arm-none-eabi-ld -o blinker01.flash.elf -T flash.ld flash.o blinker01.flash.o
arm-none-eabi-objdump -D blinker01.flash.elf > blinker01.flash.list
arm-none-eabi-objcopy blinker01.flash.elf blinker01.flash.bin -O binary

为确保启动正确和链接正确,请检查列表文件中的向量表。

08000000 <_start>:
 8000000:   20001000 
 8000004:   08000041 
 8000008:   08000047 
 800000c:   08000047 
 8000010:   08000047 
 8000014:   08000047 

这些应该是奇数,处理器地址加1。
08000040 <reset>:
 8000040:   f000 f80a   bl  8000058 <notmain>
 8000044:   e7ff        b.n 8000046 <hang>

08000046 <hang>:
 8000046:   e7fe        b.n 8000046 <hang>

在这些STM32部件的情况下,它们从0x08000000开始(某些供应商会将其设置为零)。在上电时,零会被镜像到0x08000000,因此向量将带您到flash中的正确位置。关于LED,将GPIO引脚设置为推挽输出并打开或关闭它即可。在这种情况下,通过使用不在blinker01.c中的函数来强制编译器执行这些计数(而不是进行易失性操作),这是一种简单的优化技巧。PUT32/GET32是个人偏好,确保使用正确的指令,编译器并不总是使用正确的指令,如果硬件需要特定大小的操作,则可能会遇到麻烦。抽象化有更多的优点而不是缺点,我个人认为。这些部件配置和使用起来相当简单。这样学习很好,也可以使用库,职业上你可能需要处理两个极端,也许你成为为他人编写库的人,并且需要同时掌握这两种方法。了解你的工具是最重要的事情,是的,大多数人在这项业务中不知道如何做到这一点,他们依赖于工具,绕过工具或库的问题,而不是理解正在发生的事情并/或修复它。这个答案的重点是1)你问了,2)展示了使用工具有多容易。如果我只使用汇编作为创建向量表的非常简单的方法,则可以使它变得更加简单。Cortex-m是这样的,您可以在C中完成除向量表之外的所有操作(虽然您可以,但它很丑),然后使用像经过良好测试和工作的汇编程序之类的东西来创建向量表。注意Cortex-m0与其他部件的区别。
 8000074:   f420 6140   bic.w   r1, r0, #3072   ; 0xc00
 8000078:   f441 6180   orr.w   r1, r1, #1024   ; 0x400

Cortex-M0和(如果你遇到的话)M1是基于ARMv6M的,而其他的都是基于ARMv7M的,后者有大约150个Thumb2扩展到Thumb指令集中(以前是未定义的指令,用于制作可变长指令)。所有的Cortex-M都运行Thumb指令集,但是Cortex-M0不支持ARMv7M特定的扩展,在构建时可以将其修改为Cortex-M0而不是M4,它将在M4上正常工作,像这样的代码(根据需要修补地址,也许GPIO对于你的特定部分是不同的,也许不是),并为M0构建,它将在M0上运行...就像需要定期检查向量表是否被正确构建一样,您可以检查反汇编以查看使用的指令的正确版本。


从您的帖子中可以看出,用户应该熟悉所有工具...我发现我的知识还有很多漏洞。您能推荐一个起点吗?不幸的是,所有的工具都相当复杂,手册也很大,所以要深入了解这个主题并跳来跳去并不容易。 :-) - ST Renegade
为什么你不能拿提供的代码开始工作呢? - old_timer
通过处理核心中的其他项目,如SysTick定时器,以使LED以准确的速率闪烁。其中一个计时器外设,UART等...您有一个基础,无论是这个代码还是您可以从mbed或其他地方获得的示例(我希望/假设您有Nucleo板或Discovery而不是放在板子上的松散芯片)... - old_timer
去ST官网获取你所拥有的芯片和板子的文档,他们可能还有处理器的程序员手册。但如果你有一个stm32f4,那么它就是一个cortex-m4,可以去arm的网站infocenter.arm.com获取cortex-m4和armv7-m的体系结构参考手册和技术参考手册。其中包含了systick定时器、指令集以及我设置的向量表的原因等内容。ST的文档显示了gpio寄存器的配置方法等信息。 - old_timer
@old_timer,你的回答真是太棒了!我从你的例子中学到了很多。谢谢! - jap jap

2
我发现这篇由两部分组成的博客非常有趣,其中涵盖了你所询问的细节:
一些主要观点包括: - `main()` 函数不是程序入口. 内核/加载器根本没有“调用”任何函数,而是设置虚拟地址空间,在堆栈上放置一些数据,然后在可执行文件指定的地址开始执行进程。
- 程序不能像函数那样返回。 这是上述要点的直接结果:栈上根本没有返回地址供程序返回。相反,进程必须进行系统调用以请求内核销毁进程。确切地说,这个系统调用是 `exit_group()` 系统调用。 这是通过创建软件中断来完成的,导致内核模式中断处理程序运行。然后,这个中断处理程序将操作内核的数据结构来销毁和处理进程,并释放它持有的资源。虽然效果与函数调用相似(永远不会返回),但CPU机制使用的方式完全不同。
请注意,您无需链接任何库即可进行系统调用,系统调用只是一些指令,用于将系统调用参数加载到CPU寄存器中,然后是中断指令。您在链接尝试中看不到的 `_exit()` 函数不是系统调用,它只是围绕它的一个包装。C 不知道系统调用是什么,libc 包装必须使用语言扩展才能能够进行系统调用。这就是为什么通常会链接到 libc 并调用其系统调用包装而不是直接进行系统调用:它将您与进行系统调用的实现定义详细信息隔离开来。
- 运行在 `main()` 调用之前和之后的 libc 代码一般负责加载动态库,初始化静态数据(如果需要),调用标有 `__attribute__((constructor))` 或 `__attribute__((destructor))` 的函数,以及调用使用 `atexit()` 注册的函数。

2

这是一个比较大的问题,但我会尝试回答并概述所有步骤,将“hello world”变成实际的ARM可执行文件。我会着重介绍命令以展示每个步骤,而不是解释每个细节。

#include <stdio.h>

int main()
{
        printf("Hello world!\r\n");
        return 0;
}

我将在Ubuntu 17.04上使用gcc进行此示例。 arm-none-eabi-gcc(15:5.4.1 + svn241155-1)5.4.1 20160919

1. 预处理

它基本上处理以#开头的每一行。要显示预处理器的输出,请使用arm-none-eabi-gcc -Earm-none-eabi-cpp

arm-none-eabi-gcc -E main.c

输出非常长,因为当您#include <stdio.h>时发生了所有事情,它仍然包含“不可读”的行,例如# 585 "/usr/include/newlib/stdio.h" 3

如果使用参数-E -P -C,则输出变得更加清晰。

arm-none-eabi-gcc -E -P -C main.c -o main-preprocessed.c

现在,您可以看到#include只是将所有内容从stdio.h复制到您的代码中。

2. 编译

此步骤将预处理文件转换为汇编指令,这些指令仍然可读。要获取机器码,请使用-S

arm-none-eabi-gcc -S main.c

您应该得到一个名为main.s的文件,其中包含汇编指令。

3. 汇编

现在它变得不太可读了。将-c传递给gcc以查看输出。这一步也是内联汇编可能的原因。

arm-none-eabi-gcc -c main.c

您应该得到一个main.o文件,可以使用hexdumpxxd显示。我建议使用xxd,因为它会在原始十六进制数字旁边显示ascii表示形式。

xxd main.o

4. 链接

最终阶段,在此之后,您的程序已准备好由目标系统执行。链接器添加了“缺失”的代码。例如,没有printf()函数或stdio.h中的任何内容。

arm-none-eabi-gcc main.c --specs=nosys.specs -o main

有关--specs=nosys.specs的信息,请参见此处:https://dev59.com/cGIk5IYBdhLWcg3wRcUo#23922211

这只是一个简单的概述,但您应该能够在stackoverflow上找到有关每个步骤的更多信息。(链接器的示例: 链接器是做什么的?)


你好Skynet,非常感谢你写下整个过程的步骤。总体上,我对这些步骤很熟悉。然而,我不理解链接发生时的非常低层次的内容,以及链接了什么、为什么要链接以及所有这些东西是如何构建的等等。这就是我的情况,我想我应该学习更多才能更好地理解它。 不过,感谢你提供有关链接器的链接,我一定会看看的! 问题是,我应该学习更多关于链接器、编译器、标准库还是我实际上在寻找什么呢? :-) - ST Renegade
crt0.o、crti.o等是libc(C标准库)的一部分。详情请参见:https://dev.gentoo.org/~vapier/crt.txt。任何ARM代码的其他重要部分可能是CMSIS和启动代码(通常在startup.s中)。对我来说,编程ARM设备时最重要的知识是相应的参考手册和勘误表。除此之外,其他所有工作都是IDE的工作,直到IDE出现问题。 - sknt
这就是我找到的,尽管我没有找到所有函数的参考(例如_exit(),显然不是libc的一部分)。但问题是,这样的启动文件需要包含什么,或者这样的启动文件应该是什么样子的,为什么我需要链接这些库等。我想了解一下设计,以便了解链接等过程中进行的步骤。-spec=nosys.spec或-spec=nano.spec之间有什么区别?当然,它们链接不同的库,但是有什么区别,它们提供了什么,何时使用它们等。 - ST Renegade
GCC及其库是为类Unix操作系统设计的,因为您不在MCU上运行任何操作系统,所以您不需要完整的库,这就是为什么有一个较小的版本-“nano”版本。为什么需要链接libc(或更精确地说是C标准的实现)-因为否则会变得很丑陋:https://dev59.com/43E85IYBdhLWcg3w6oB0。一些实现之间的差异可以在此处查看:http://www.etalabs.net/compare_libcs.html(偏向musl,但仍然有用)。 - sknt

0

这并不是那么简单,至少不能这样简单地完成。

由于您使用C语言 - 它对启动代码有一些要求。

  1. 您需要考虑堆栈和堆,并将其正确设置。
  2. 至少具备最小的向量表
  3. C数据可以初始化(例如,您可以声明int a = 5;),因此启动代码应提供复制例程(从flash到ram段)
  4. C假定未初始化的全局数据为零-启动程序应该也具备这个功能
  5. 根据您的应用程序可能需要一些附加代码。

所以,您将至少需要创建一个启动程序和链接器脚本(为了能够无任何特殊限制使用C),在其中声明内存段、它们的大小、边界、计算初始化例程的起始和结束地址。

在我看来,从头开始做这件事是毫无意义的 - 您可以随时修改提供的脚本和启动文件。对于ARM uC,CMSIS可能是最好的选择,因为它给予您绝对自由。


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