编译过程并不神奇
工具
过程的简单描述
这篇文章尽可能地说清楚从编译程序到把代码烧到板子里,然后开始上电后运行程序。很多细节是根据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 //把当前的地址压到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开始执行新的代码了!