【大咖专栏】编译过程简介

随着国内第一本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汇编语言程序设计”打下基础。

注:本文力求通俗易懂,主要面向初学者,对编译过程有所了解的读者可以忽略此文。

1 本文概述

本文将介绍如何将高层的C/C++语言编写的程序转换成为处理器能够执行的二进制代码的过程,该过程即一般编译原理书籍所介绍的过程,包括四个步骤:

预处理(Preprocessing)

编译(Compilation)

汇编(Assembly)

链接(Linking)

本文限于篇幅,将不会对各个步骤的原理进行详解,将仅仅结合Linux自带的GCC工具链对其过程进行简述。感兴趣的读者可以自行查阅其他资料深入学习编译原理的相关知识。

注意:

本文为了简化描述与便于初学者理解,将在Linux操作系统平台上编译一个Hello World程序并在此Linux平台上运行作为示例。而嵌入式开发所使用的交叉编译的使用方法与本文所述的编译过程有所差异,本公众号将在后续发文《嵌入式开发的特点介绍》中对嵌入式系统编译进行更多介绍。

本文使用的是Linux自带的GCC工具链作为演示,而未涉及到如何使用RISC-V GCC工具链,本公众号将在后续发文《RISC-V GCC工具链的介绍》中对RISC-V GCC工具链进行更多介绍。

2 GCC工具链介绍

2.1 GCC工具链概述

通常所说的GCC是GUN Compiler Collection的简称,是Linux系统上常用的编译工具。GCC实质上不是一个单独的程序,而是多个程序的集合,因此通常称为GCC工具链。工具链软件包括GCC、C运行库、Binutils、GDB等。

GCC

GCC(GNU C Compiler)是编译工具。本文所要介绍的将C/C++语言编写的程序转换成为处理器能够执行的二进制代码的过程即由编译器完成。有关编译过程的更多介绍请参见后文。

GCC既支持本地编译(即在一个平台上编译该平台运行的程序),也支持交叉编译(即在一个平台上编译供另一个平台运行的程序)。

本文为了简化描述与便于初学者理解,将在Linux操作系统平台上编译一个Hello World程序并在此Linux平台上运行作为示例,即为一种本地编译的开发方式。


交叉编译多用于嵌入式系统的开发,有关交叉编译,本公众号将在后续发文《嵌入式开发的特点介绍》中对嵌入式系统交叉编译进行更多介绍。

C运行库

由于C运行库的相关背景知识较多,请参见后文对其单独进行介绍。

Binutils

由于Binutils的相关信息较多,请参见后文对其单独进行介绍。

GDB

GDB(GNU Project Debugger)是调试工具,可以用于对程序进行调试。

2.2 Binutils

一组二进制程序处理工具,包括:addr2line、ar、objcopy、objdump、as、ld、ldd、readelf、size等。这一组工具是开发和调试不可缺少的工具,分别简介如下:

addr2line:用来将程序地址转换成其所对应的程序源文件及所对应的代码行,也可以得到所对应的函数。该工具将帮助调试器在调试的过程中定位对应的源代码位置。

as:主要用于汇编,有关汇编的详细介绍请参见后文。

ld:主要用于链接,有关链接的详细介绍请参见后文。

ar:主要用于创建静态库。为了便于初学者理解,在此介绍动态库与静态库的概念:

如果要将多个.o目标文件生成一个库文件,则存在两种类型的库,一种是静态库,另一种是动态库。

在windows中静态库是以 .lib 为后缀的文件,共享库是以 .dll 为后缀的文件。在linux中静态库是以.a为后缀的文件,共享库是以.so为后缀的文件。

静态库和动态库的不同点在于代码被载入的时刻不同。静态库的代码在编译过程中已经被载入可执行程序,因此体积较大。共享库的代码是在可执行程序运行时才载入内存的,在编译过程中仅简单的引用,因此代码体积较小。在Linux系统中,可以用ldd命令查看一个可执行程序依赖的共享库。

如果一个系统中存在多个需要同时运行的程序且这些程序之间存在共享库,那么采用动态库的形式将更节省内存。但是对于嵌入式系统,大多数情况下都是整个软件就是一个可执行程序且不支持动态加载的方式,即以静态库为主。

ldd:可以用于查看一个可执行程序依赖的共享库。

objcopy:将一种对象文件翻译成另一种格式,譬如将.bin转换成.elf、或者将.elf转换成.bin等。

objdump:主要的作用是反汇编。有关反汇编的详细介绍,请参见后文。

readelf:显示有关ELF文件的信息,请参见后文了解更多信息。

size:列出可执行文件每个部分的尺寸和总尺寸,代码段、数据段、总大小等,请参见后文了解使用size的具体使用实例。

Binutils的每个工具的功能均很强大,本节限于篇幅无法详细介绍其功能,读者可以自行查阅资料了解其详情。Binutils还有其他工具,在此不一一赘述,感兴趣的读者可以自行查阅其他资料学习。

2.3 C运行库

为了解释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标准库的实现,其要点如下:

glibc本身是GNU旗下的C标准库,后来逐渐成为了Linux的标准C库。glibc 的主体分布在Linux系统的/lib与/usr/lib目录中,包括 libc 标准 C 函式库、libm数学函式库等等,都以.so做结尾。

注意:Linux系统下面的标准C库不仅有这一个,如uclibc、klibc、以及Linux libc,但是glibc使用最为广泛。而在嵌入式系统中使用较多的C运行库为Newlib,有关Newlib的详细介绍将在本公众号后续发文《嵌入式开发的特点介绍》中进行。

Linux系统通常将libc库作为操作系统的一部分,它被视为操作系统与用户程序的接口。譬如:glibc不仅实现标准C语言中的函数,还封装了操作系统提供的系统服务,即系统调用的封装。

通常情况,每个特定的系统调用对应了至少一个glibc 封装的库函数,如系统提供的打开文件系统调用sys_open对应的是glibc中的open函数;其次,glibc 一个单独的API可能调用多个系统调用,如glibc提供的 printf 函数就会调用如 sys_open、sys_mmap、sys_write、sys_close等系统调用;另外,多个 glibc API也可能对应同一个系统调用,如glibc下实现的malloc、free 等函数用来分配和释放内存,都利用了内核的sys_brk的系统调用。

对于C++语言,常用的C++标准库为libstdc++。注意:通常libstdc++与GCC捆绑在一起的,即安装gcc的时候会把libstdc++装上。而glibc并没有和GCC捆绑于一起,这是因为glibc需要与操作系统内核打交道,因此其与具体的操作系统平台紧密耦合。而libstdc++虽然提供了c++程序的标准库,但其并不与内核打交道。对于系统级别的事件,libstdc++会与glibc交互,从而和内核通信。

2.4 GCC命令行选项

GCC有着丰富的命令行选项支持各种不同的功能,本文由于篇幅有限,无法一一赘述,请读者自行查阅相关资料学习。

对于RISC-V的GCC工具链而言,还有其特有的编译选项,本公众号将在后续发文《RISC-V GCC工具链的介绍》中介绍RISC-V GCC工具链的更多详情。

3 准备工作

3.1 Linux安装

由于GCC工具链主要是在Linux环境中进行使用,因此本文也将以Linux系统作为工作环境。

对于Linux的安装,准备好自己的电脑环境。如果是个人电脑,推荐如下配置:

使用VMware虚拟机在个人电脑上安装虚拟的Linux操作系统。

由于Linux操作系统的版本众多,推荐使用Ubuntu 16.04版本的Linux操作系统。

有关如何安装VMware以及Ubuntu操作系统本文不做介绍,有关Linux的基本使用本文也不做介绍,请读者自行查阅资料学习。

3.2 准备Hello World程序

为了能够演示编译的整个过程,本节先准备一个C语言编写的简单Hello程序作为示例,其源代码如下所示:​

​4 编译过程

4.1 预处理

预处理的过程主要包括以下过程:

将所有的#define删除,并且展开所有的宏定义,并且处理所有的条件预编译指令,比如#if #ifdef #elif #else #endif等。

处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。

删除所有注释“//”和“/* */”。

添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。

保留所有的#pragma编译器指令,后续编译过程需要使用它们。
使用gcc进行预处理的命令如下:​

​hello.i文件可以作为普通文本文件打开进行查看,其代码片段如下所示:​

​4.2 编译

编译过程就是对预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码。

使用gcc进行编译的命令如下:​

​上述命令生成的汇编程序hello.s的代码片段如下所示,其全部为汇编代码。​

​4.3 汇编

汇编过程调用对汇编代码进行处理,生成处理器能识别的指令,保存在后缀为.o的目标文件中。由于每一个汇编语句几乎都对应一条处理器指令,因此,汇编相对于编译过程比较简单,通过调用Binutils中的汇编器as根据汇编指令和处理器指令的对照表一一翻译即可。

当程序由多个源代码文件构成时,每个文件都要先完成汇编工作,生成.o目标文件后,才能进入下一步的链接工作。注意:目标文件已经是最终程序的某一部分了,但是在链接之前还不能执行。

使用gcc进行汇编的命令如下:​

​注意:hello.o目标文件为ELF(Executable and Linkable Format)格式的可重定向文件,不能以普通文本形式的查看(vim文本编辑器打开看到的是乱码)。有关ELF文件的更多介绍,请参见后文。

4.4 链接

经过汇编以后的目标文件还不能直接运行,为了变成能够被加载的可执行文件,文件中必须包含固定格式的信息头,还必须与系统提供的启动代码链接起来才能正常运行,这些工作都是由链接器来完成的。

GCC可以通过调用Binutils中的链接器ld来链接程序运行需要的所有目标文件,以及所依赖的其它库文件,最后生成一个ELF格式可执行文件。

如果直接调用Binutils中的ld进行链接,命令如下,则会报出错误:​

​之所以直接用ld进行链接会报错是因为仅仅依靠一个hello.o目标文件还无法链接成为一个完整的可执行文件,需要明确的指明其需要的各种依赖库和引导程序以及链接脚本,此过程在嵌入式软件开发时是必不可少的。而在Linux系统中,可以直接使用gcc命令执行编译直至链接的过程,gcc会自动将所需的依赖库以及引导程序链接在一起成为Linux系统可以加载的ELF格式可执行文件。使用gcc进行编译直至链接的命令如下:​

​注意:hello可执行文件为ELF(Executable and Linkable Format)格式的可执行文件,不能以普通文本形式的查看(vim文本编辑器打开看到的是乱码)。

在前文介绍了动态库与静态库的差别,与之对应的,链接也分为静态链接和动态链接,其要点如下:

静态链接是指在编译阶段直接把静态库加入到可执行文件中去,这样可执行文件会比较大。链接器将函数的代码从其所在地(不同的目标文件或静态链接库中)拷贝到最终的可执行程序中。为创建可执行文件,链接器必须要完成的主要任务是:符号解析(把目标文件中符号的定义和引用联系起来)和重定位(把符号定义和内存地址对应起来然后修改所有对符号的引用)。


而动态链接则是指链接阶段仅仅只加入一些描述信息,而程序执行时再从系统中把相应动态库加载到内存中去。

在Linux系统中,gcc编译链接时的动态库搜索路径的顺序通常为:首先从gcc命令的参数-L指定的路径寻找;再从环境变量LIBRARY_PATH指定的路径寻址;再从默认路径/lib、/usr/lib、/usr/local/lib寻找。

在Linux系统中,执行二进制文件时的动态库搜索路径的顺序通常为:首先搜索编译目标代码时指定的动态库搜索路径;再从环境变量LD_LIBRARY_PATH指定的路径寻址;再从配置文件/etc/ld.so.conf中指定的动态库搜索路径;再从默认路径/lib、/usr/lib寻找。

在Linux系统中,可以用ldd命令查看一个可执行程序依赖的共享库。


由于链接动态库和静态库的路径可能有重合,所以如果在路径中有同名的静态库文件和动态库文件,比如libtest.a和libtest.so,gcc链接时默认优先选择动态库,会链接libtest.so,如果要让gcc选择链接libtest.a则可以指定gcc选项-static,该选项会强制使用静态库进行链接。以本节的Hello World为例:

如果使用命令“gcc hello.c -o hello”则会使用动态库进行链接,生成的ELF可执行文件的大小(使用Binutils的size命令查看)和链接的动态库(使用Binutils的ldd命令查看)如下所示:​

​如果使用命令“gcc -static hello.c -o hello”则会使用静态库进行链接,生成的ELF可执行文件的大小(使用Binutils的size命令查看)和链接的动态库(使用Binutils的ldd命令查看)如下所示:​

​链接器链接后生成的最终文件为ELF格式可执行文件,一个ELF可执行文件通常被链接为不同的段,常见的段譬如.text、.data、.rodata、.bss等段。有关ELF文件和常见段的更多介绍,请参见后文。

4.5 一步到位的编译

从功能上分,预处理、编译、汇编、链接是四个不同的阶段,但GCC的实际操作上,它可以把这四个步骤合并为一个步骤来执行。如下例所示:​

​注意:

一个程序无论有一个源文件还是多个源文件,所有被编译和链接的源文件中必须有且仅有一个main函数。

但如果仅仅是把源文件编译成目标文件,因为不会进行链接,所以main函数不是必需的。

5 分析ELF文件

5.1 ELF文件介绍

在介绍ELF文件之前,首先将其与另一种常见的二进制文件格式bin进行对比:

binary文件,其中只有机器码。

elf文件除了含有机器码之外还有其它信息,如:段加载地址,运行入口地址,数据段等。

ELF全称Executable and Linkable Format,可执行链接格式。ELF文件格式主要三种:

可重定向(Relocatable)文件:

文件保存着代码和适当的数据,用来和其他的目标文件一起来创建一个可执行文件或者是一个共享目标文件。

可执行(Executable)文件:

文件保存着一个用来执行的程序(例如bash,gcc等)。

共享(Shared)目标文件(Linux下后缀为.so的文件):

即所谓共享库。

5.2 ELF文件的段

ELF文件格式如图1中所示,位于ELF Header和Section Header Table之间的都是段(Section)。一个典型的ELF文件包含下面几个段:

.text:已编译程序的指令代码段。

.rodata:ro代表read only,即只读数据(譬如常数const)。

.data:已初始化的C程序全局变量和静态局部变量。

注意:C程序普通局部变量在运行时被保存在堆栈中,既不出现在.data段中,也不出现在.bss段中。此外,如果变量被初始化值为0,也可能会放到bss段。

.bss:未初始化的C程序全局变量和静态局部变量。

注意:目标文件格式区分初始化和未初始化变量是为了空间效率,在ELF文件中.bss段不占据实际的存储器空间,它仅仅是一个占位符。

.debug:调试符号表,调试器用此段的信息帮助调试。

上述仅讲解了最常见的节,ELF文件还包含很多其他类型的节,本文在此不做赘述,请感兴趣的读者自行查阅其他资料了解学习。​

​图1 ELF格式

5.3 查看ELF文件

可以使用Binutils中readelf来查看ELF文件的信息,可以通过readelf --help来查看readelf的选项:​

​以本文Hello World示例,使用readelf -S查看其各个section的信息如下:​

​5.4 反汇编

由于ELF文件无法被当做普通文本文件打开,如果希望直接查看一个ELF文件包含的指令和数据,需要使用反汇编的方法。反汇编是用于调试和定位处理器问题时最常用的手段。
可以使用Binutils中objdump来对ELF文件进行反汇编,可以通过objdump --help来查看其选项:​

​以本文Hello World示例,使用objdump -D对其进行反汇编如下:​

​使用objdump -S将其反汇编并且将其C语言源代码混合显示出来:​

​6 嵌入式系统编译的特殊性

为了易于读者理解,本文以一个Hello World程序为例讲解了在Linux环境中的编译过程以帮助初学者入门,但是了解这些基础背景知识对于嵌入式开发还远远不够。
对于嵌入式开发,嵌入式系统的编译有其特殊性,譬如:

嵌入式系统需要使用交叉编译与远程调试的方法进行开发。

需要自己定义引导程序。

需要注意减少代码尺寸。

需要移植printf从而使得嵌入式系统也能够打印输入。

使用Newlib作为C运行库。

每个特定的嵌入式系统都需要配套的板级支持包。

为了易于读者理解,本文使用的是Linux自带的GCC工具链,其并不能反映嵌入式开发的特点。本公众号将在后续发文《嵌入式开发的特点介绍》《RISC-V GCC工具链的介绍》中介绍“嵌入式开发特点”和“RISC-V GCC工具链”的更多详情。

7 总结

编译原理是一门博大精深的学科,虽然大多数的用户只是将编译器作为一门工具使用而无需关注其内部原理,但是适当的了解编译的过程对于开发大有裨益,尤其是对于嵌入式软件开发而言,更需要了解编译与链接的基本过程。

本文为了简化描述与便于初学者理解,仅仅以在Linux操作系统平台上使用其自带的GCC编译一个Hello World程序作为示例。本文虽面向的是RISC-V嵌入式开发,其使用的RISC-V工具链交叉编译使用方法与本文所述的编译过程有所差异,但是其原理和使用方法大致相同,因此也可以作为初学者的学习参考。

《手把手教你设计CPU——RISC-V处理器篇》

胡振波 著

点此链接购买纸书

本书是一本介绍通用CPU设计的入门书,以通俗的语言系统介绍了CPU和RISC-V架构,力求为读者揭开CPU设计的神秘面纱,打开计算机体系结构的大门。 

本书共分为四部分。第一部分是CPU与RISC-V的综述,帮助初学者对CPU和RISC-V快速地建立起认识。第二部分讲解如何使用Verilog设计CPU,使读者掌握处理器核的设计精髓。第三部分主要介绍蜂鸟E203配套的SoC和软件平台,使读者实现蜂鸟E203 RISC-V处理器在FPGA原型平台上的运行。第四部分是附录,介绍了RISC-V指令集架构,辅以作者加入的背景知识解读和注解,以便于读者理解。 ​


本文摘自异步社区,作者: 硅农亚历山大    作品:《【大咖专栏】编译过程简介》,未经授权,禁止转载。​

推荐阅读

2018年5月新书书单(文末福利)

2018年4月新书书单

异步图书最全Python书单

一份程序员必备的算法书单

第一本Python神经网络编程图书

​长按二维码,可以关注我们哟

每天与你分享IT好文。

在“异步图书”后台回复“关注”,即可免费获得2000门在线视频课程


点击阅读原文购买《手把手教你设计CPU——RISC-V处理器篇》

阅读原文​​​​