原文出处:https://mp.weixin.qq.com/s/-syKN0DibKGGPCllaeNqMg
随着国内第一本RISC-V中文书籍《手把手教你设计CPU——RISC-V处理器篇》 正式上市,越来越多的爱好者开始使用开源的蜂鸟E203 RISC-V处理核,很多初学者留言询问有关RISC-V工具链使用的问题,因此本公众号将开始陆续发表若干篇有关RISC-V软件工具链使用的文章,包括:
- RISC-V嵌入式开发准备篇1:编译过程简介
- RISC-V嵌入式开发准备篇2:嵌入式开发的特点介绍
- RISC-V嵌入式开发入门篇1:RISC-V GCC工具链的介绍
- RISC-V嵌入式开发入门篇2:RISC-V汇编语言程序设计
- RISC-V嵌入式开发上手篇:基于HBird-E-SDK平台的软件开发与运行
- RISC-V嵌入式开发实践篇:运行开源蜂鸟E200 MCU更多示例程序
- RISC-V嵌入式开发新奇篇:基于Windows Eclipse IDE的软件开发与运行
- RISC-V嵌入式开发升华篇:基于开源蜂鸟E200 MCU移植RTOS
本文为RISC-V嵌入式开发准备篇1:编译过程简介。本文的目的是对编译过程进行简单的科普与回顾,为后续详细介绍“RISC-V GCC工具链”和“RISC-V汇编语言程序设计”打下基础。
注:本文力求通俗易懂,主要面向初学者,对编译过程有所了解的读者可以忽略此文。
本文将介绍如何将高层的C/C++语言编写的程序转换成为处理器能够执行的二进制代码的过程,该过程即一般编译原理书籍所介绍的过程,包括四个步骤:
本文限于篇幅,将不会对各个步骤的原理进行详解,将仅仅结合Linux自带的GCC工具链对其过程进行简述。感兴趣的读者可以自行查阅其他资料深入学习编译原理的相关知识。
注意:
通常所说的GCC是GUN Compiler Collection的简称,是Linux系统上常用的编译工具。GCC实质上不是一个单独的程序,而是多个程序的集合,因此通常称为GCC工具链。工具链软件包括GCC、C运行库、Binutils、GDB等。
本文为了简化描述与便于初学者理解,将在Linux操作系统平台上编译一个Hello World程序并在此Linux平台上运行作为示例,即为一种本地编译的开发方式。
交叉编译多用于嵌入式系统的开发,有关交叉编译,本公众号将在后续发文《嵌入式开发的特点介绍》中对嵌入式系统交叉编译进行更多介绍。
一组二进制程序处理工具,包括:addr2line、ar、objcopy、objdump、as、ld、ldd、readelf、size等。这一组工具是开发和调试不可缺少的工具,分别简介如下:
为了解释C运行库,需要先回忆一下C语言标准。C语言标准主要由两部分组成:一部分描述C的语法,另一部分描述C标准库。C标准库定义了一组标准头文件,每个头文件中包含一些相关的函数、变量、类型声明和宏定义,譬如常见的printf函数便是一个C标准库函数,其原型定义在stdio头文件中。
C语言标准仅仅定义了C标准库函数原型,并没有提供实现。因此,C语言编译器通常需要一个C运行时库(C Run Time Libray,CRT)的支持。C运行时库又常简称为C运行库。与C语言类似,C++也定义了自己的标准,同时提供相关支持库,称为C++运行时库。
如上所述,要在一个平台上支持C语言,不仅要实现C编译器,还要实现C标准库,这样的实现才能完全支持C标准。glibc(GNU C Library)是Linux下面C标准库的实现,其要点如下:
GCC有着丰富的命令行选项支持各种不同的功能,本文由于篇幅有限,无法一一赘述,请读者自行查阅相关资料学习。
对于RISC-V的GCC工具链而言,还有其特有的编译选项,本公众号将在后续发文《RISC-V GCC工具链的介绍》中介绍RISC-V GCC工具链的更多详情。
由于GCC工具链主要是在Linux环境中进行使用,因此本文也将以Linux系统作为工作环境。
对于Linux的安装,准备好自己的电脑环境。如果是个人电脑,推荐如下配置:
为了能够演示编译的整个过程,本节先准备一个C语言编写的简单Hello程序作为示例,其源代码如下所示:
// C语言编写的Hello World程序源代码hello.c
#include //由于printf函数是一个标准的C语言库函数,其函数原型定义在标准的
// C语言stdio头文件中。stdio 是指 “standard input & output”
//(标准输入输出)的缩写。所以,源代码中如用到标准输入输出函数时,
// 就要包含此头文件
//此程序很简单,仅仅打印一个Hello World的字符串。
int main(void)
{
printf("Hello World! \n");
return 0;
}
预处理的过程主要包括以下过程:
$ gcc -E hello.c -o hello.i // 将源文件hello.c文件预处理生成hello.i
// GCC的选项-E使GCC在进行完预处理后即停止
hello.i文件可以作为普通文本文件打开进行查看,其代码片段如下所示:
// hello.i代码片段
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 942 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 3 "hello.c"
int
main(void)
{
printf("Hello World!" "\n");
return 0;
}
编译过程就是对预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码。
使用gcc进行编译的命令如下:
$ gcc -S hello.i -o hello.s // 将预处理生成的hello.i文件编译生成汇编程序hello.s
// GCC的选项-S使GCC在执行完编译后停止,生成汇编程序
上述命令生成的汇编程序hello.s的代码片段如下所示,其全部为汇编代码。
// hello.s代码片段
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
汇编过程调用对汇编代码进行处理,生成处理器能识别的指令,保存在后缀为.o的目标文件中。由于每一个汇编语句几乎都对应一条处理器指令,因此,汇编相对于编译过程比较简单,通过调用Binutils中的汇编器as根据汇编指令和处理器指令的对照表一一翻译即可。
当程序由多个源代码文件构成时,每个文件都要先完成汇编工作,生成.o目标文件后,才能进入下一步的链接工作。注意:目标文件已经是最终程序的某一部分了,但是在链接之前还不能执行。
使用gcc进行汇编的命令如下:
$ gcc -c hello.s -o hello.o // 将编译生成的hello.s文件汇编生成目标文件hello.o
// GCC的选项-c使GCC在执行完汇编后停止,生成目标文件
//或者直接调用as进行汇编
$ as -c hello.s -o hello.o //使用Binutils中的as将hello.s文件汇编生成目标文件
注意:hello.o目标文件为ELF(Executable and Linkable Format)格式的可重定向文件,不能以普通文本形式的查看(vim文本编辑器打开看到的是乱码)。有关ELF文件的更多介绍,请参见后文。
经过汇编以后的目标文件还不能直接运行,为了变成能够被加载的可执行文件,文件中必须包含固定格式的信息头,还必须与系统提供的启动代码链接起来才能正常运行,这些工作都是由链接器来完成的。
GCC可以通过调用Binutils中的链接器ld来链接程序运行需要的所有目标文件,以及所依赖的其它库文件,最后生成一个ELF格式可执行文件。
如果直接调用Binutils中的ld进行链接,命令如下,则会报出错误:
//直接调用ld试图将hello.o文件链接成为最终的可执行文件hello
$ ld hello.o –o hello
ld: warning: cannot find entry symbol _start; defaulting to 00000000004000b0
hello.o: In function `main':
hello.c:(.text+0xa): undefined reference to `puts'
之所以直接用ld进行链接会报错是因为仅仅依靠一个hello.o目标文件还无法链接成为一个完整的可执行文件,需要明确的指明其需要的各种依赖库和引导程序以及链接脚本,此过程在嵌入式软件开发时是必不可少的。而在Linux系统中,可以直接使用gcc命令执行编译直至链接的过程,gcc会自动将所需的依赖库以及引导程序链接在一起成为Linux系统可以加载的ELF格式可执行文件。使用gcc进行编译直至链接的命令如下:
$ gcc hello.c -o hello // 将hello.c文件编译汇编链接生成可执行文件hello
// GCC没有添加选项,则使GCC一步到位地执行到链接后停
// 止,生成最终的可执行文件
$ ./hello //成功执行该文件,在终端上会打印Hello World!字符串 Hello World!
注意:hello可执行文件为ELF(Executable and Linkable Format)格式的可执行文件,不能以普通文本形式的查看(vim文本编辑器打开看到的是乱码)。
在前文介绍了动态库与静态库的差别,与之对应的,链接也分为静态链接和动态链接,其要点如下:
静态链接是指在编译阶段直接把静态库加入到可执行文件中去,这样可执行文件会比较大。链接器将函数的代码从其所在地(不同的目标文件或静态链接库中)拷贝到最终的可执行程序中。为创建可执行文件,链接器必须要完成的主要任务是:符号解析(把目标文件中符号的定义和引用联系起来)和重定位(把符号定义和内存地址对应起来然后修改所有对符号的引用)。
而动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。
由于链接动态库和静态库的路径可能有重合,所以如果在路径中有同名的静态库文件和动态库文件,比如libtest.a和libtest.so,gcc链接时默认优先选择动态库,会链接libtest.so,如果要让gcc选择链接libtest.a则可以指定gcc选项-static,该选项会强制使用静态库进行链接。以本节的Hello World为例:
$ gcc hello.c -o hello
$ size hello //使用size查看大小
text data bss dec hex filename
1183 552 8 1743 6cf hello
$ ldd hello //可以看出该可执行文件链接了很多其他动态库,主要是Linux的glibc动态库
linux-vdso.so.1 => (0x00007fffefd7c000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fadcdd82000)
/lib64/ld-linux-x86-64.so.2 (0x00007fadce14c000)
$ gcc -static hello.c -o hello
$ size hello //使用size查看大小
text data bss dec hex filename
823726 7284 6360 837370 cc6fa hello //可以看出text的代码尺寸变得极大
$ ldd hello
not a dynamic executable //说明没有链接动态库
链接器链接后生成的最终文件为ELF格式可执行文件,一个ELF可执行文件通常被链接为不同的段,常见的段譬如.text、.data、.rodata、.bss等段。有关ELF文件和常见段的更多介绍,请参见后文。
从功能上分,预处理、编译、汇编、链接是四个不同的阶段,但GCC的实际操作上,它可以把这四个步骤合并为一个步骤来执行。如下例所示:
$ gcc –o test first.c second.c third.c
//该命令将同时编译三个源文件,即first.c、second.c和 third.c,然后将它们链接成
//一个可执行文件,名为test。
注意:
在介绍ELF文件之前,首先将其与另一种常见的二进制文件格式bin进行对比:
binary文件,其中只有机器码。
elf文件除了含有机器码之外还有其它信息,如:段加载地址,运行入口地址,数据段等。
ELF全称Executable and Linkable Format,可执行链接格式。ELF文件格式主要三种:
可重定向(Relocatable)文件:
可执行(Executable)文件:
共享(Shared)目标文件(Linux下后缀为.so的文件):
ELF文件格式如图1中所示,位于ELF Header和Section Header Table之间的都是段(Section)。一个典型的ELF文件包含下面几个段:
图1 ELF格式
可以使用Binutils中readelf来查看ELF文件的信息,可以通过readelf --help来查看readelf的选项:
$ readelf --help
Usage: readelf
以本文Hello World示例,使用readelf -S查看其各个section的信息如下:
$ readelf -S hello
There are 31 section headers, starting at offset 0x19d8:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
……
[11] .init PROGBITS 00000000004003c8 000003c8
000000000000001a 0000000000000000 AX 0 0 4
……
[14] .text PROGBITS 0000000000400430 00000430
0000000000000182 0000000000000000 AX 0 0 16
[15] .fini PROGBITS 00000000004005b4 000005b4
……
由于ELF文件无法被当做普通文本文件打开,如果希望直接查看一个ELF文件包含的指令和数据,需要使用反汇编的方法。反汇编是用于调试和定位处理器问题时最常用的手段。
可以使用Binutils中objdump来对ELF文件进行反汇编,可以通过objdump --help来查看其选项:
$ objdump --help
Usage: objdump
以本文Hello World示例,使用objdump -D对其进行反汇编如下:
$ objdump -D hello
……
0000000000400526 : // main标签的PC地址
//PC地址:指令编码 指令的汇编格式
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: e8 cc fe ff ff callq 400400
400534: b8 00 00 00 00 mov $0x0,%eax
400539: 5d pop %rbp
40053a: c3 retq
40053b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
……
使用objdump -S将其反汇编并且将其C语言源代码混合显示出来:
$ gcc -o hello -g hello.c //要加上-g选项
$ objdump -S hello
……
0000000000400526 :
#include
int
main(void)
{
400526: 55 push %rbp
400527: 48 89 e5 mov %rsp,%rbp
printf("Hello World!" "\n");
40052a: bf c4 05 40 00 mov $0x4005c4,%edi
40052f: e8 cc fe ff ff callq 400400
return 0;
400534: b8 00 00 00 00 mov $0x0,%eax
}
400539: 5d pop %rbp
40053a: c3 retq
40053b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
……
为了易于读者理解,本文以一个Hello World程序为例讲解了在Linux环境中的编译过程以帮助初学者入门,但是了解这些基础背景知识对于嵌入式开发还远远不够。
对于嵌入式开发,嵌入式系统的编译有其特殊性,譬如:
编译原理是一门博大精深的学科,虽然大多数的用户只是将编译器作为一门工具使用而无需关注其内部原理,但是适当的了解编译的过程对于开发大有裨益,尤其是对于嵌入式软件开发而言,更需要了解编译与链接的基本过程。
本文为了简化描述与便于初学者理解,仅仅以在Linux操作系统平台上使用其自带的GCC编译一个Hello World程序作为示例。本文虽面向的是RISC-V嵌入式开发,其使用的RISC-V工具链交叉编译使用方法与本文所述的编译过程有所差异,但是其原理和使用方法大致相同,因此也可以作为初学者的学习参考。
感兴趣的读者可以通过下面二维码关注公众号“硅农亚历山大”,了解Verilog、IC设计、CPU、RISC-V和人工智能AI相关的更多设计技巧和经验分享,注意:由于干货太多,请自备茶水。