【嵌入式智能产品开发实战】(八)—— 政安晨:通过ARM-Linux掌握基本技能【C语言程序的编译过程】

目录

前言

编译工具安装

从源程序到二进制文件


政安晨的个人主页政安晨

欢迎 👍点赞✍评论⭐收藏

收录专栏:  嵌入式智能产品开发实战

希望政安晨的博客能够对您有所裨益,如有不足之处,欢迎在评论区提出指正!


在ARM-Linux中,C语言程序的编译链接安装运行过程如下所示

  1. 编写C语言代码首先,使用文本编辑器编写C语言程序的源代码,并保存为以.c为扩展名的文件。

  2. 编译源代码使用交叉编译器将C语言源代码编译为ARM架构下的可执行二进制文件交叉编译器是针对ARM平台的特定编译器,它能够在非ARM架构的主机上生成适用于ARM架构的可执行文件

  3. 链接可执行文件编译器将源代码编译为目标文件后,链接器会将目标文件与所需的库文件进行链接,生成可执行文件链接器的作用是将多个目标文件和库文件合并成一个完整的可执行文件。

  4. 安装可执行文件将生成的可执行文件安装到ARM-Linux系统中的适当位置。这通常涉及将可执行文件复制到目标设备的特定目录中,比如/bin、/usr/bin等。

  5. 运行程序使用ARM-Linux系统的命令行终端,通过执行可执行文件的路径来运行程序。在终端中输入可执行文件的路径,按下回车键,即可运行C语言程序。

需要注意的是,编译和链接C语言程序时,需要根据目标ARM架构选择合适的交叉编译器和库文件,以确保生成的可执行文件能够在ARM-Linux系统中正确运行。

接下来,咱们就在ARM-Linux系统中演绎这个过程。


前言

在Windows下开发一个C程序

一般都会用到集成开发环境(Integrated Development Environment,IDE),如VC++、C-Free、Visual Studio、Keil等。

IDE界面友好,使用方便,5分钟就可以快速上手:新建一个工程/源文件,编辑程序,点击界面上的Run按钮,然后我们编写的程序就可以运行了。

至于程序是如何编译和运行的,我们无须操心,因为IDE已经为我们封装好了

IDE集程序编辑器、工程管理器、编译器、汇编器、链接器、调试器、二进制工具、库、头文件于一身,留给用户的使用接口就是创建一个工程,编写代码,运行代码。

这种一站式开发方式大大简化了软件的开发,程序员只需要关注自己要实现的业务逻辑和功能代码即可,至于底层是如何编译运行的,不用关心

嵌入式开发和桌面开发不太一样

处理器平台和软件生态碎片化、多样化。为了提高性价比,不同的嵌入式系统往往采取更灵活的配置:

不同的CPU平台、不同大小的存储、不同的启动方式,导致我们在编译程序时,有时候不仅要考虑一个嵌入式平台的内存、存储器的地址空间,还要考虑将我们的程序代码“烧”写到什么地方、加载到内存什么地方、如何执行。

这就要求嵌入式工程师必须了解在程序运行的背后,它们是如何编译、链接和运行的。

有了这些理论支撑,我们才可能灵活地根据硬件平台的差异去完成软件层面的编译优化和配置。

对于未来要从事机器学习智能硬件领域开发的嵌入式工程师来说,对编译原理要掌握到什么程度,才能满足工作的需要呢?

这是一个值得研究的问题嵌入式工程师大多数拥有电子、电气、自动化专业背景,不可能像计算机专业的学生那样掌握程序编译过程中的每一个细节,如语法分析、词法分析等虽然没有这个必要,但也不能对编译原理只有一个感性的认识,忽视一些关键的知识点和细节,在实际项目中将无法给我们的工程实践带来理论上的帮助和支撑。

咱们在接下来几篇文章中,结合ARM平台,把程序的编译、链接、安装和运行的基本原理串起来再给小伙伴们梳理一遍,并对嵌入式开发中的一些关键知识点和理论(如U-boot的加载、重定位)着重分析。对于ARM裸机程序运行的环境配置、Linux内核模块的加载运行机制等以案例演绎的方式为大家呈现。

对于一些与嵌入式开发不太相关的内容(如语法分析、词法分析等),我们稍作了解就可以了,不必过于纠结细节,以防陷入其中无法自拔,打击咱们的学习的自信和热情。(呵呵)

咱们接下来几篇写作遵循的基本原则就是:适合智能硬件工程师小伙伴们阅读,不会去纠结一些太复杂烦琐的细节问题,着重讲解在实际开发演绎中需要掌握的一些关键知识点和核心理论。

咱们上篇文章的目的由此就看出来啦,是要确保您具备一台可以运行Linux操作系统的ARM主机:

【机器学习智能硬件开发全解】(七)—— 政安晨:通过ARM-Linux掌握基本技能【环境准备:树莓派】icon-default.png?t=N7T8https://blog.csdn.net/snowdenkeke/article/details/136782169(当然虚拟机也可以,如果是虚拟机的话,您要自己查阅资料安装GCC编译器。)

作者政安晨很喜欢树莓派,不仅因为它是ARM-Linux的百宝箱,还因为它陪伴我很多年。从第一代一直到现在,我的职业生涯基本都是与Linux呀、开源呀、智能硬件呀这些为伍,并深深受到开源智能硬件的影响。它们教会了我很多很多......

编译工具安装

在树莓派的ARM-Linux环境中要安装哪个编译工具?

我们看一下下面的比较:

arm-linux-gnueabi-gcc 和 arm-linux-gnueabihf-gcc

两个交叉编译器分别适用于 armel 和 armhf 两个不同的架构,armel 和 armhf 这两种架构在对待浮点运算采取了不同的策略(有 fpu 的 arm 才能支持这两种浮点运算策略)。

这两个交叉编译器是 gcc 的选项 -mfloat-abi 的默认值不同。

gcc 的选项 -mfloat-abi 有三种值 soft、softfp、hard(其中后两者都要求 arm 里有 fpu 浮点运算单元,soft 与后两者是兼容的,但 softfp 和 hard 两种模式互不兼容):

soft 不用fpu进行浮点计算,即使有fpu浮点运算单元也不用,而是使用软件模式。
softfp armel架构(对应的编译器为 arm-linux-gnueabi-gcc)采用的默认值,用fpu计算,但是传参数用普通寄存器传,这样中断的时候,只需要保存普通寄存器,中断负荷小,但是参数需要转换成浮点的再计算。
hard armhf架构(对应的编译器 arm-linux-gnueabihf-gcc采用的默认值,用fpu计算,传参数也用fpu中的浮点寄存器传,省去了转换,性能最好,但是中断负荷高。

树莓派就采用的是armhf架构。

现在,我们使用ssh远程登录控制台:

sudo apt install gcc-arm-linux-gnueabihf

安装成功后,咱们可以开始下一步的实验演绎。

从源程序到二进制文件

程序的编译过程,其实就是将我们编写的C源程序翻译成CPU能够识别和运行的二进制机器指令的过程。

关于C程序我们已经很熟悉了一个C程序主要由一行行C语言语句组成,不同的语句构成一个个代码块或函数,每个语句由C语言的关键字、运算符、预处理命令、用户定义的变量名、函数名等很多token构成。

一个C语言项目通常由多个文件组成。

要编写C语言首先要有一个随手可得的编辑工具,我们先装一个VIM,它是命令式的代码编辑工具,在命令行控制台端就可以使用。

sudo apt install vim

安装好代码编辑工具之后,先在用户目录下建立一个文件夹作为项目的目录:

mkdir zachen_1
cd zachen_l

在使用touch命令创建几个文件:
 

touch sub.c
touch sub.h
touch main.c

咱们分别实现这些文件:

// sub.c

int add(int nCountA, int nCountB)
{
    return nCountA + nCountB;
}

int sub(int nCountA, int nCountB)
{
    return nCountA  - nCountB;
}
// sub.h

int add(int nCountA, int nCountB);
int sub(int nCountA, int nCountB);
// main.c

#include <stdio.h>
#include "sub.h"

int global_val = 1;
int uninit_val;

int main(void)
{
        int a, b;
        static int local_val = 2;
        static int uninit_local_val;

        a = add(2, 3);
        b = sub(5, 4);

        printf("a = %d\n", a);
        printf("b = %d\n", b);

        return 0;
}

编码文件时,使用 vim 工具打开这个文件即可,比如:

vim main.c

打开后界面如上,以后有机会咱们将详细介绍vim编码工具的用法,这里先简单将几个命令:

在当前界面模式下,输入 i,进入编辑模式;

在编辑模式下,按Esc键退出该模式;

在编辑模式下,输入代码后,按Esc退出编辑模式,并输入 " : + wq",则为保存退出。 

在上面的程序中,我们创建了2个C程序源文件:main.c和sub.c

在main.c中定义了项目的入口函数main(),在main()函数中我们调用了add()和sub()函数对数据进行加、减运算。

add() 和sub()函数在sub.c文件中定义,并在sub.h头文件中声明。

在main.c中调用这两个函数之前,我们首先要把sub.h头文件包含进来,对这两个函数进行函数原型声明,编译器在编译程序时会根据这些函数声明对我们的源程序进行语法检查检查实参类型、返回结果类型和函数声明的类型是否匹配。

以上就是一个典型的C程序项目中多文件的组织原则:

可以把sub.c看作一个模块,定义了很多API函数供其他模块调用,并将这些API的声明封装在sub.h头文件中。如果其他模块想调用sub.c中的函数,则要先#include"sub.h"这个头文件,然后就可以直接使用了。如果我们想让上面的程序在ARM平台上运行,则要使用ARM交叉编译器将C源程序编译生成ARM格式的二进制可执行文件。

执行:

arm-linux-gnueabihf-gcc -o a.out main.c sub.c

因为我们在树莓派的ARM-Linux环境中,这个编译生成的文件直接就可以执行了。如果您是在PC端使用交叉编译工具编译的源码,则将执行文件(.a)拷贝到ARM-Linux的目标机上运行。

ARM交叉编译器成功地将C源程序翻译为可执行文件,这中间的过程我们先不管,我们先看看生成的可执行文件a.out到底长什么样。在Shell终端下用您尊贵的手指敲入readelf命令:

readelf -h a.out

您将会看到如下信息:

查看可执行文件a.out的section header。[  readelf -S a.out  ]

readelf -h命令主要用来获取可执行文件的头部信息,主要包括可执行文件运行的平台、软件版本、程序入口地址,以及program headers、section header等信息通过文件的头部信息,我们可以知道在a.out可执行文件里一共有多少个section headers。

下图是可执行文件的内部结构:

section headers是干什么用的呢?它主要用来描述可执行文件的section信息。

如上图所示,一个可执行文件通常由不同的段(section)构成

代码段、数据段、BSS段、只读数据段等。

每个section用一个section header来描述,包括段名、段的类型、段的起始地址、段的偏移和段的大小等。

一个可执行文件中的每一个section都有一个section header,将这些section headers集中放到一起,就是section header table,翻译成中文就是节头表

我们可以使用readelf -S 命令来查看一个可执行文件的节头表。

通过section header table信息,我们可以窥探一个可执行文件的基本构成:

一个可执行文件由一系列section组成,section header table自身也是以一个section的形式存储在可执行文件中的。

section header table里的各个section header用来描述各个section的名称、类型、起始地址、大小等信息。

除此之外,可执行文件还会有一个文件头ELF header,用来描述文件类型、要运行的处理器平台、入口地址等信息。

当程序运行时,加载器会根据此文件头来获取可执行文件的一些信息。

在一个可执行文件中,我们比较熟悉的section有.text、.data、.bss,就是我们常说的代码段、数据段、BSS段。

C程序中定义的函数、变量、未初始化的全局变量经过编译后会放置在不同的段中:

函数翻译成二进制指令放在代码段中,初始化的全局变量和静态局部变量放在数据段中。BSS段比较特殊,一般来讲,未初始化的全局变量和静态变量会放置在BSS段中,但是因为它们未初始化,默认值全部是0,其实没有必要再单独开辟空间存储,为了节省存储空间,所以在可执行文件中BSS段是不占用空间的。

但是BSS段的大小、起始地址和各个变量的地址信息会分别保存在节头表section header table和符号表.symtab里,当程序运行时,加载器会根据这些信息在内存中紧挨着数据段的后面为BSS段开辟一片存储空间,为各个变量分配存储单元。

知道了可执行文件的基本构成,我们也就知道了程序编译的大概流程,如下图所示

从C程序到可执行文件

就是将C程序中定义的函数、变量,挑挑拣拣、加以分类,分别放置在可执行文件的代码段、数据段和BSS段中。

程序中定义的一些字符串、printf函数打印的字符串常量则放置在只读数据段.rodata中。

如果程序在编译时设置为debug模式,则可执行文件中还会有一个专门的.debug section,用来保存可执行文件中每一条二进制指令对应的源码位置信息。

根据这些信息,GDB调试器就可以支持源码级的单步调试,否则你单步执行的都是二进制指令,可读性不高,不方便调试。

在最后环节,编译器还会在可执行文件中添加一些其他section,如.init section,这些代码来自C语言运行库的一些汇编代码,用来初始化C程序运行所依赖的环境,如内存堆栈的初始化等。

从C程序到可执行文件,整个编译过程并不是一气呵成、一步完成的,而是环环相扣、多步执行的。

如下图所示:

程序的整个编译流程主要分为以下几个阶段:预处理、编译、汇编、链接。

每个阶段需要调用不同的工具去完成,上一阶段的输出作为下一阶段的输入,步步推进。

在一个多文件的C项目中,编译器是以C源文件为单位进行编译的。

在编译的不同阶段,编译程序(如gcc、arm-linux-gcc)会调用不同的工具来完成不同阶段的任务。

在编译器安装路径的bin目录下,你会看到各种各样的编译工具,gcc在程序编译过程中会分别调用它们,常见的工具有预处理器、编译器、汇编器、链接器。

比如(一些编译工具):

● 预处理器:将源文件main.c经过预处理变为main.i。

● 编译器:将预处理后的main.i编译为汇编文件main.s。

● 汇编器:将汇编文件main.s编译为目标文件main.o。

● 链接器:将各个目标文件main.o、sub.o链接成可执行文件a.out。

最后生成的可执行文件a.out其实也是目标文件(object file),唯一不同的是,a.out是一种可执行的目标文件。

目标文件一般可以分为3种:

● 可重定位的目标文件(relocatable files)。

● 可执行的目标文件(executable files)。

● 可被共享的目标文件(shared object files)。

汇编器生成的目标文件是可重定位的目标文件,是不可执行的,需要链接器经过链接、重定位之后才能运行。

可被共享的目标文件一般以共享库的形式存在,在程序运行时需要动态加载到内存,跟应用程序一起运行。


看到这里相信小伙伴们已经对程序编译的基本流程有了一个大致的了解。

  • 24
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

政安晨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值