编译过程并不神奇

c/c++

浏览数:237

2019-3-29

AD:资源代下载服务

编译过程并不神奇

工具

过程的简单描述

这篇文章尽可能地说清楚从编译程序到把代码烧到板子里,然后开始上电后运行程序。很多细节是根据Arduino来写的,因为Arduino没有外接的serial flash或者外接的SDRAM,所以相对来说简单一些。但总体来说这个过程对于嵌入式设备都是十分相似的。

当按下编译按钮后,编译器compiler将每个.c文件编译成汇编语言,汇编语言是机器可以识别的语言。拿项目举例来说,LED.c经过编译器生成LED.o。然后连接器linker把每个汇编文件(*.o)连接在一起,生成最终的编译文件并通过avrdude或者programmer下载到arduino的Flash中开始运行。

编译过程

c 语言翻译成汇编语言

  • Atmel 官方的汇编指令, 对于每个嵌入式板子来说,每块cpu都有它们自己不同的汇编语言指令。
  • 举例来说,下面的c语言翻译成avr
//c language
static void Task4(void)
{
  USART_Transmit("Task4\r\n", strlen("Task4\r\n"));
}

翻译成汇编语言是

ldi    r22, 0x07    ; 7    //"Task4\r\n" 字符串长度7的low byte 存入寄存器22
ldi    r23, 0x00    ; 0    //"Task4\r\n" 字符串长度7的high byte 存入寄存器23
ldi    r24, 0x1E    ; 30   //"Task4\r\n" 的内存位置0x011E的low byte存入寄存器24,这里强调一下因为计算机架构的关系,这个位置在map文件中被翻译成0080011E,后面会在详细说明。
ldi    r25, 0x01    ; 1    //"Task4\r\n" 的内存位置0x011E的high byte存入寄存器24 
call    0x1f4    ; 0x1f4 <USART_Transmit>  //把当前的地址压到stack里,然后去地址0x1f4调用函数USART_Transmit,这个函数会利用r22,r23,r24和r25
ret                        //从stack里面弹出返回地址,去这个返回地址开始运行接下来的命令

大家可以参考Atmel官方对ret的解释,可以清楚的看到,ret命令把stack的东西弹出然后放入PC(Program Counter:里面记录着当前运行的代码的位置)里面。

内存空间分成几个section

经过编译器的编译后,c文件中的代码,和变量等会被存放到不同的区域section,参考GCC Sections
这里面比较重要的是text section, data section和bss section:

  • text section存放代码
  • data section存放初始化过的变量和常量。初始化的时候,会从Flash里把初始化值拷贝到Ram里。请参考后文,有详细的初始化汇编语言。
  • bss section存放未经过初始化的变量。初始化的时候,整个区域都会被初始化为0。请参考后文,有详细的初始化汇编语言。

举个例子来说明,对于下面的c代码:

uint16_t Data = 0x1234;
uint32_t Result;

uint32_t Power(void)
{
  Result = Data * Data;
  USART_Transmit("done!", 5);
  return Result;
}

经过编译后我们可以得到:

  • symbol table里面记录了text 和data section的起始位置,以及每个函数和变量相对于对应section的偏移。

    • 里面的函数USART_Transmit 因为并没有在这个文件中定义,所以编译器并不知道它在哪里,后面连接器linker会到其他文件中找到这个函数的定义然后把它补上。
  • text section存着函数Power()的汇编语言,里面会去地址0x00F0取得Data的值,还会把运算的结果保存到地址0x00F8的Result里面。
  • data section存着Data的数值0x1234,还有ASCII字符串”done!”。在c语言里字符串都是用0x0结尾的,0x0也会占用一个byte的空间,这里字符串的名字(String_1)是编译器自己命名的,也可能是其他的名字。
  • bss section存着Result的数值,bss区域的数据都会初始化0。

Atmel328P的Flash和Ram

Arduino所使用的MCU是Atmel328P,根据数据手册,Atmel328P有2KB的SRAM和32KB的Flash,以及1KB的EEPROM。细节请参考Atmel328P的section 12.2,我截取了数据手册中的一幅图:

  • 比如我们在Arduino经常使用的一些GPIO的寄存器,PORTB, DDRB都是存在IO registers中的。我们可以找到详细的register summary在数据手册section 35。
  • 根据之前对编译器section的讲解,最后text section和data section都会下载到Arduino的Flash中,因为text section中的代码在运行的时候并不会被改变,data section中的初始值在板子初始化的时候会从Flash中拷贝到SRAM中,bss section会在板子初始化的时候被初始化为0。
  • 细看2KB的Internal SRAM:

    • 首先放的是Data section然后是BSS section,剩下的部分都是给Heap(堆)和Stack(栈)。简单地说当我们使用malloc拿到的内存都是从heap里面取得的,而函数的参数,返回值以及在函数内声明的local variable都是向栈里面push进去和pop出来的。
    • 在嵌入式开发中,因为内存资源有限,经常地malloc/free堆的内存空间,会减低内存的利用率,所以一般情况我们不经常使用malloc去拿堆里面的空间。

连接过程

当编译器把所有的.c文件都编译好后,连接器linker就会过来把所有的.c文件集合起来生成一个总的文件。在Atmel中,最后生成的是.elf文件。连接器的作用是把所有的undefined variable都找到,把它们的地址都补全,然后把所用的相对位置都计算出绝对位置,比如前面例子中,symbol table里的函数USART_Transmit是未知位置的,这是linker就会去其他编译文件中找这个函数的定义,并得到地址。最后生成的总的文件类似于第一张图的样子,也是开始是symbol table然后是text section,data section和bss section。

Atmel328P的编译文件的连接

  • 连接器linker需要它的配置文件,被称为linker file。对于Atmel328P来说,这个文件叫avr51.x,它在Atmel Studio的安装文件夹里,我的路径是(C:\Program Files (x86)\Atmel\Studio\7.0\toolchain\avr8\avr8-gnu-toolchain\avr\lib\ldscripts\avr51.x)。打开avr51.x。

    • 首先是定义了每个section的位置,里面的data ORIGIN前两个数字0x80,eeprom ORIGIN前两个数字0x81是和总线相关,并不意味着SRAM的地址真的从0x800100开始。
    • 然后也定义了在main函数之前,Flash中应该放哪些代码,包括中断向量,初始化stack pointer,heap的地址,初始化data section和bss section等等。
MEMORY
{
  text   (rx)   : ORIGIN = 0, LENGTH = __TEXT_REGION_LENGTH__
  data   (rw!x) : ORIGIN = 0x800100, LENGTH = __DATA_REGION_LENGTH__
  eeprom (rw!x) : ORIGIN = 0x810000, LENGTH = __EEPROM_REGION_LENGTH__
  fuse      (rw!x) : ORIGIN = 0x820000, LENGTH = __FUSE_REGION_LENGTH__
  lock      (rw!x) : ORIGIN = 0x830000, LENGTH = __LOCK_REGION_LENGTH__
  signature (rw!x) : ORIGIN = 0x840000, LENGTH = __SIGNATURE_REGION_LENGTH__
  user_signatures (rw!x) : ORIGIN = 0x850000, LENGTH = __USER_SIGNATURE_REGION_LENGTH__  
}
.text:
{
  *(.vectors)
    KEEP(*(.vectors))
    *(.init0)  /* Start here after reset.  */
    KEEP (*(.init0))
    *(.init1)
    KEEP (*(.init1))
    *(.init2)  /* Clear __zero_reg__, set up stack pointer.  */
    KEEP (*(.init2))
    *(.init3)
    KEEP (*(.init3))
    *(.init4)  /* Initialize data and BSS.  */
    KEEP (*(.init4))
    *(.init5)
    KEEP (*(.init5))
    ...
}

Atmel328P的Map和Lss文件

当Atmel328P编译完成后,除了会生成最后的编译文件.elf外,还会生成.map和.lss文件,很多时候这两个文件可以帮我们很好的debug程序,理解程序。

  • .lss文件包括了整个项目的汇编代码,text section的每条代码。
  • .map就是一个symbol table,里面包含了所有symbol的位置。

下面我们来简单分析一下两个文件:

IoT_Ethernet.lss

在IoT_Ethernet.lss,我挑了几段我认为比较有意思的地方和大家分享下:

1. data, text, bss section的地址:

Idx Name          Size      VMA       LMA       File off  Algn
  0 .data         00000064  00800100  00000676  0000070a  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  1 .text         00000676  00000000  00000000  00000094  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  2 .bss          00000096  00800164  00800164  0000076e  2**0
                  ALLOC
  • VMA vs LMA:上表中记录了每个section的大小和地址,值得注意的是这里有两个地址,一个是VMA(Virtual Memory Address),另外一个是LMA(Load Memory Address)。根据这个解释,VMA是当板子经历过初始化阶段(startup)后,该section的地址;LMA是这个section的数据从哪里加载。所以:

    • data section的大小是100bytes (十六进制0x64,十进制100),从地址0x800100开始,当初始化阶段,会从地址0x676把data section拷贝到地址0x800100(这个是RAM地址)。这里0x676是data section初始化数据在Flash中的地址,也正好是从text section结束的地方。
    • text section的大小是676bytes,从地址0x0开始,它的Load Memory Address 也是0x0。意味着不需要在初始化时候进行拷贝。
    • bss section的大小是96bytes,从地址0x800164开始,它的Load Memory Address 也是0x0。

2. 初始化data 和bss section的汇编代码:

这段程序是在初始化data section。在这段asssembly code中,程序从Flash的地址0x676中复制0x64 bytes(十六进制0x64,十进制100)的数据到地址0x00800100。这里注意两条汇编,一个是LPM,一个是ST,从Atmel Assembly的官方网站,LPM: Load Program Memory,是从program memory拿出数据,这里的program memory指的是Flash。另外一个是ST: Store Indirect From Register to Data space using Index X,这里的data section指的是SDRAM,SDRAM从0x00800000开始,所以仔细分析这些汇编会发现,这里的st X+, r0,X寄存器(r26和r27合在一起)的范围是从0x100开始,但实际是把数据存到了0x00800100。

00000074 <__do_copy_data>:
  74:    11 e0           ldi    r17, 0x01    ; 1
  76:    a0 e0           ldi    r26, 0x00    ; 0
  78:    b1 e0           ldi    r27, 0x01    ; 1
  7a:    e6 e7           ldi    r30, 0x76    ; 118
  7c:    f6 e0           ldi    r31, 0x06    ; 6
  7e:    02 c0           rjmp    .+4          ; 0x84 <__do_copy_data+0x10>
  80:    05 90           lpm    r0, Z+
  82:    0d 92           st    X+, r0
  84:    a4 36           cpi    r26, 0x64    ; 100
  86:    b1 07           cpc    r27, r17
  88:    d9 f7           brne    .-10         ; 0x80 <__do_copy_data+0xc>

3. 中断向量:

当中断发生时,程序会跟据具体是哪个中断向量(比如定时器中断,外部中断等)来这个中断向量表中找到中断ISR(Interrupt Service Routine)的地址。比如我目前的这个中断向量表中,当Timer0的compare match interrupt发生的时候,程序会到0x59C执行ISR;当UART接收到数据的时候,程序会到0x222执行ISR。如下面的代码是中断向量表的一部分:

00000000 <__vectors>:
   0:    0c 94 34 00     jmp    0x68    ; 0x68 <__ctors_end>
   4:    0c 94 51 00     jmp    0xa2    ; 0xa2 <__bad_interrupt>
   8:    0c 94 51 00     jmp    0xa2    ; 0xa2 <__bad_interrupt>
...
  38:    0c 94 ce 02     jmp    0x59c    ; 0x59c <__vector_14>
...
  48:    0c 94 11 01     jmp    0x222    ; 0x222 <__vector_18>
...

IoT_Ethernet.map文件

这个文件可以理解为是一个symbol table,里面包括了项目所有symbol的信息。比如

 .text.LED_GetStatus
                0x00000428       0x20 LowLevel/LED.o
                0x00000428                LED_GetStatus
  • 函数LED_GetStatus是在Flash地址0x428,assembly code一共占32 Byte。
  • 同样在data和bss section也包含了很多信息。

Motorola Hex Format(.hex)

因为我们使用AvrDude通过USB下载代码到板子上,而AvrDude只接收Intel Hex Format,生成能被Arduino Bootloader识别的数据。使用WinAVR可以将.elf文件传变HEX文件(Atmel Studio已经帮我们做这一步了)。简单地说Intel Hex Format每行在说往某个特定的地址写特定的数据。Intel Hex Format参考链接
提取出.hex文件中比较直观的一行来说:

:10066800FFFF4765744C6564537461747573005378
  • 这里是说把0xFF, 0xFF, “GetLedStatus”, 0x53, 0x78拷贝到地址0x668。
  • AvrDude根据hex文件生成Arduino bootloader可以识别的数据通过USB接口发送给Arduino。后面bootloader的部分会讲发送的数据。

Arduino Bootloader

Bootloader是在Flash里面的一段代码,用来把新的代码(新的代码从AvrDude发到Arduino的16U2芯片,然后16U2芯片通过uart发送到Atmel328P)通过USB写到Flash的0x00地址。如果没有BootLoader,我们只能通过programmer来烧代码到Arduino,下图是Avr ISP MKII,把它插到Arduino的ICSP header上,就可以在没有bootloader的情况下给Arduino下载代码了。如果是新购买的Arduino,它里面已经有Bootloader了,它使用的是optiboot,它是open source的,这里是它的github repository。下面我们更详细的说下bootloader。

FUSE 设置

在之前的linker file里面有Fuse的地址:fuse (rw!x) : ORIGIN = 0x820000, LENGTH = __FUSE_REGION_LENGTH__。Fuse里面的信息是配置芯片的关键信息,查看和修改它的信息需要用ISP,一般USB接口没办法访问Fuse。

  • Atmel328P data sheet的第30章,Boot Loader Support详细说明了Fuse的信息。这里我附里面的两张图:可以看到,在Flash的底端是bootloader区域,我们正常的代码每次是下载到从0x0000开始的区域。配置Fuse寄存器,我们可以告诉芯片目前芯片里是否存了bootloader然后bootloader的大小是多少,bootloader的起始地址是什么。当芯片开始上电的时候,芯片实际上是从bootloader的起始位置开始运行代码,它会在bootloader里面停很短的时间,看是否有新的代码要下载到0x0000地址,如果没有就去0x0000开始执行那里的代码,如果有就下载新的代码到0x0000地址。

Optiboot

当AvrDude拿到Intel Hex Format(.hex)的代码,它会转变成Atmel STK500格式的代码,因为optiboot可以识别Atmel STK500 格式。当AvrDude要发送新的代码给Atmel328P,这是Atmel328P会先reset,然后AvrDude会发送STK500格式的数据给optiboot,optiboot会处理这些数据然后把相应的代码从Flash的0x0000地址开始写入。
当代码下载完成,就重新reset芯片,并且等待程序跳出optiboot,就可以从0x0000开始执行新的代码了!