基础笔记-2 gcc编译过程


参考资料:
<<深度探索Linux操作系统:系统构建和原理解析>>
<<程序员的自我修养–链接,装载与库>>

http://www.skyfree.org/linux/references/ELF_Format.pdf
https://static.docs.arm.com/ddi0100/i/DDI%2001001.pdf

对于C程序来说,软件构建过程分为4个阶段:预处理,编译,汇编,链接。这里主要记录目标文件格式及链接过程分析,笔记基本摘自<<深度探索Linux操作系统:系统构建和原理解析>>和<<程序员的自我修养–链接,装载与库>>这两本书。
在这里插入图片描述
一般我们只需要使用gcc就可以完成整个编译过程,但不要被gcc的名字给误导,gcc并不是一个编译器,而是一个驱动程序(driver program),具体编译过程由ccl负责,汇编过程由as负责,链接过程由ld负责。通过gcc -v 可以输出编译过程信息。

1 预处理(Preprocessing)

预处理器将处理源代码中的预编译指令,C程序中的预处理指令以"#“开头,常用的预处理指令包括文件包含命令”#include",宏定义"#define",以及条件编译命令"ifdef" “ifndef” “#if” “#else” #endif"等等。

  1. 文件包含
    文件包含命令指示预处理器将一个源文件中的内容全部复制到当前源文件中。
  2. 宏定义
    宏可以提高程序的通用性和易读性,减少不一致性和输入错误。预处理器将宏名替换为具体的值。
  3. 条件编译
    去除不符合条件的代码。

使用gcc -E可以生成预处理信息.

gcc -E main.c -o main.i

main.c:

#include <stdio.h>

int main(int argc, void *argv[])
{
        printf("hi.\r\n");

        return 0;
}

2 编译(Compilation)

这里的编译和平时说的编译(build,make)不同。对于C,编译(build) = 预处理(Preprocessing) + 编译(Compilation) + 汇编(Assemble) + 链接(Linking)。这里的编译是指对经过预处理的结果进行词法分析,语法分析,语义分析,然后生成中间代码。并对其进行优化,最后生产相应的汇编代码。
使用gcc -S可以生成汇编文件。

$ gcc -S main.c
$ cat main.s
.file “main.c”
.section .rodata
.LC0:
.string “hi.\r”
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc

.cfi_endproc
.LFE0:
.size main, .-main
.ident “GCC: (Ubuntu 4.9.2-10ubuntu13) 4.9.2”
.section .note.GNU-stack,"",@progbits

虽然只打印了一句话,但是生成了很多东西。有相当部分是伪指令,伪指令并不参与CPU运行,是用来指导编译链接过程的,代码中以.cfi开头的伪指令是辅助汇编器创建栈帧(stack frame)信息的。当程序出现Segment Fault时,可能会输出回溯(backstrace)信息,或者调试时需要回溯,查看一些变量或函数调用信息。这个过程,就是所谓的栈的回卷(unwind stack)。

3 汇编(Assembly)

汇编器将汇编代码翻译为机器指令,每一条汇编语句几乎都对应一条机器指令。除了生成机器码外,汇编器还要在目标文件中创建辅助链接时需要的信息,包括符号表,重定位表等。
使用as -o main.o main.s 生成.o目标文件,或者使用gcc -c main.c生成。

3.1 目标文件

汇编过程的产物时目标文件,同前面的预编译和编译阶段产生的文本文件不同,目标文件的格式更为复杂,其中包括链接需要的信息。所以在理解汇编过程前,我们需要了解一下目标文件的格式。Linux下的二进制文件包括可执行文件,静态库和动态库等,均采用ELF(Executable and Linking Format)格式存储,目标文件也是。

ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节/段(Section)和节/段头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。有的将section称为节,segment称为段,中文翻译有点混乱。"Segment"的概念实际上是从转载的角度重新划分了ELF的各个段(section)。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一个空间。比如可读可执行行的段都放在一起,这种段的典型是代码段;可读可写的段都放在一起,这种典型的段是数据段。在ELF中把这些属性相似的、又连在一起的段叫做一个Segment,而系统正是按照Segment而不是section来映射可执行文件的。section可以使用readelf -S查看,Segment可以使用readelf -l查看。

在这里插入图片描述

下面写个程序来查看这些信息。

$ cat foo.c

int g_foo_init = 1;
int g_foo_uninit;

int foo(void)
{
	return g_foo_init;
}

$ cat main.c

extern int g_foo_uninit;

extern int foo(void);

int func(void);

int main(void)
{
	g_foo_uninit = foo();
	func();

	return 0;
}

int func(void)
{
	return 0;
}

使用gcc -c即可生成.o目标文件。

$ arm-himix200-linux-gcc -c *.c

3.1.1 EFL头部信息(ELF header)

使用readelf -h main.o查看文件头部信息,如下:
在这里插入图片描述

ELF文件头部信息主要记录了文件的格式版本信息、及程序入口地址(目标文件没有入口地址,所以为0)和节头的起始地址、大小和个数等。

3.1.2 Section header table

使用readelf -S main.o可以查看Section header table详细信息。

在这里插入图片描述

  • Nr: 在section header table中的下标。
  • Type: section的类型。
    NULL:无效
    PROGBITS:程序section
    NOBITS:表示该sectin在文件中没有内容。如.bss
    REL:重定位信息
    SYMTAB:该section内容为符号表
    STRTAB:该section内容为字符串表
  • Addr: 该section分配的地址,目标文件为0
  • Off: 在文件中的偏移地址
  • Size: 该section的大小(byte)
    在这里插入图片描述
    .text:存储代码部分
    .data:存储已经初始化了的全局变量
    .rodata:只读数据,如常量字符串.
    .bss:存储未初始化的全局数据,默认的当程序运行时,会被初始化为0。
    .symtab:记录函数和全局变量的符号表.因为符号的名字字串长度可变,所以目标文件将符号的名字字符串剥离出来,记录在.strtab中,符号表使用符号名字的索引在.strtab中的偏移来确定符号的名字.
    .rel*: 需要重定位的符号.
    .eh_frame:调试和异常处理时用到的信息.
    .comment:注释信息
    .strtab:字符串表,存储符号名字
    .shstrtab:记录的是section header的名字.

main.o的文件结构大概如下:
在这里插入图片描述
使用ls -l命令可以看到main.o的大小为1060个字节,符合我们上面画的结构图。

我们通常说的代码段、数据段是指可执行文件的各个segment。目标代码中的section会被链接器组织到可执行文件的各个segment中。使用size命令可查看可执行文件各个段的大小。
gcc为了节省空间,默认会将初始化为0的全局变量放在.bss,使用-fno-zero-initialized-in-bss或者-fzero-initialized-in-bss控制。

3.1.3 符号表(Symbol Table)

在链接中,我们将函数和变量统称为符号(Symbol),函数或变量名就是符号名(Symbol Name)。每一个目标文件都会有一个相应的符号表(Symbol Table),这个表里记录了目标文件中所用到的所有符号。每个定义的符号有一个对应的值叫做符号值(Symbol Value),对于变量和函数来说,符号值就是它们的地址。在本目标文件中定义(define)的全局符号可以被其他目标文件引用(reference),引用其他目标文件的全局符号,一般称为外部符号(external symbol)。

使用readelf -s查看符号表(.symtab)
在这里插入图片描述
在这里插入图片描述

符号表结构:

/* Symbol Table Entry */
typedef struct elf32_sym {
        Elf32_Word      st_name;        /* name - index into string table */
        Elf32_Addr      st_value;       /* symbol value */	//跟符号有关,可能是绝对值,或者地址.
        Elf32_Word      st_size;        /* symbol size */ //符号大小,对于包含数据的符号,值为数据类型的大小,如double型的符号占8个字节,为0表示0或者未知.
        unsigned char   st_info;        /* type and binding */
        unsigned char   st_other;       /* 0 - no defined meaning */
        Elf32_Half      st_shndx;       /* section header index */
} Elf32_Sym;

符号类型和绑定信息(st_info),低4位表示符号的类型,高28位表示绑定信息.

/* Symbol type - ELF32_ST_TYPE - st_info / //符号类型
#define STT_NOTYPE 0 /
not specified /
#define STT_OBJECT 1 /
data object / //数据对象,如变量
#define STT_FUNC 2 /
function / //函数,可执行代码
#define STT_SECTION 3 /
section /
#define STT_FILE 4 /
file */ //该符号表示文件名

/* Symbol Binding - ELF32_ST_BIND - st_info / //绑定信息
#define STB_LOCAL 0 /
Local symbol / //局部变量,对外不可见
#define STB_GLOBAL 1 /
Global symbol / //全局变量
#define STB_WEAK 2 /
like global - lower precedence */ //弱引用

/* Special Section Indexes - st_shndx*/
#define SHN_UNDEF 0 /* undefined */

Ndx表示该符号是哪一个section的name index,如g_foo_init 3属于.data,foo1属于.text(见readelf -S)。对于UND(undefined)属于外部符号。Vis在C,C++中未使用,忽略。历史兼容原因导致gcc默认将g_foo_uninit放在COM而不是.bss,可以使用-fno-common关闭。 a , a, a,d不知道先忽略。

3.1.3 重定位表

在进行汇编时,一个文件如果引用了其他文件或库中的变量或函数的话,汇编器并不会解析引用的外部符号,因为在汇编时是单独编译的,所以对外部的符号一无所知。汇编时没有为符号分配运行时的地址(虚拟地址),这些都是临时的,上面的readelf -S时可以看到Address项都是0,只有在进行链接的时侯才会分配运行时的地址。
在链接时,在符号地址确定后,链接器再来修正这些位置,这个修正过程被称为重定位。

重定位表中的表项有如下两种格式:

/* Relocation entry with implicit addend */
typedef struct
{
        Elf32_Addr      r_offset;       /* offset of relocation */
        Elf32_Word      r_info;         /* symbol table index and type */
} Elf32_Rel;

/* Relocation entry with explicit addend */
typedef struct
{
        Elf32_Addr      r_offset;       /* offset of relocation */
        Elf32_Word      r_info;         /* symbol table index and type */
        Elf32_Sword     r_addend;
} Elf32_Rela;

Elf32_Rela 与 Elf32_Rel 在结构上只有一处不同,就是前者有 r_addend。Elf32_Rela 中是用r_addend 显式地指出加数;而对 Elf32_Rel来说,加数是隐含在被修改的位置里的。Elf32_Rel中加数的形式这里并不定义,它可以依处理器架构的不同而自行决定。

r_offset为需要重定位的符号在目标文件中的偏移。对于目标文件,r_offset是相对于section的,对于可执行文件或者动态库,r_offset是虚拟地址。

r_info中包含重定位类型和此处引用的外部符号在符号表中的索引。根据符号在符号表中的索引,链接器就可以从符号表中解析出符号的地址。低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表中的下标。因为指令中包含多种不同的寻址方式,并且还要针对不同的情况,所以有多种不同的重定位类型。

具体如何重定位在链接中再介绍。

使用readelf -r查看重定位表信息。
在这里插入图片描述

4 链接

链接是编译过程的最后一个阶段,链接器将一个或者多个目标文件和库,链接为一个单独的文件(可执行文件或者库)。

链接器的工作分为两个阶段:

  • 第一阶段是将多个文件合并为一个单独的文件。对于可执行文件,还需要为指令及符号分配运行时的地址。
  • 第二阶段进行符号重定位。

4.1 合并目标文件

4.1.1 按序叠加

简单的直接将各个目标文件按照次序叠加起来。由于各个section需要一定的地址和空间对齐,目标文件过多,会造成内存空间大量的内存碎片。

4.1.2 相似段合并

在这里插入图片描述
.bss section在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各个section的同时,也将.bss合并,并且分配虚拟空间。

现在的链接器基本都是第二种,使用这种方法的链接器一般都采用两步链接(two-pass linking)的方法,也就是整个过程分为两步。

4.1.2.1 空间与地址的分配

扫描所有的输入目标文件,并且获得它们各个section的长度,属性和位置,并且将输入目标文件中的符号表中所有的符号定义和引用记录起来,统一放到一个全局符号表。这一步中,链接器将能够获取所有输入目标文件的section长度,并将它们合并,计算出输出文件中各个section的合并后的长度与位置,并建立映射关系。

4.1.2.2 符号解析与重定位
4.1.2.2.1 符号解析

$ arm-himix200-linux-ld main.o
arm-himix200-linux-ld: warning: cannot find entry symbol _start; defaulting to 00010074
main.o: In function main': main.c:(.text+0x8): undefined reference tofoo’
main.c:(.text+0x28): undefined reference to `g_foo_uninit’

由于main中引用了foo和g_foo_uninit,但是链接时没有加进来导致报符号未定义,这个应该每个人都会遇到这个问题吧。

为什么缺少符号的定义会导致链接错误?其实重定位过程也伴随着符号的解析,每个目标文件都可能定义一些符号,也可能引用其他目标文件的符号。重定位的过程中,每个重定位的入口都是对应一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址,这时链接器就会去查找全局符号表,找到相应的符号后进行重定位。

readelf -s查看main.o中的符号表
12: 00000000 0 NOTYPE GLOBAL DEFAULT UND foo
14: 00000000 0 NOTYPE GLOBAL DEFAULT UND g_foo_uninit

foo和g_foo_uninit是UND的,因为目标文件中有它们的重定位项,所以在链接器扫描完所有输入文件后,这些文件应该都能在全局符号表中找到,否则链接器就报符号未定义错误。

$ arm-himix200-linux-ld *.o
arm-himix200-linux-ld: warning: cannot find entry symbol _start; defaulting to 00010094

将所有.o加入还是报错,这是因为链接器默认的程序入口为_start。平时之所以没报这个是因为ld默认链接时,会添加crti.o crtn.o这些目标文件里面实现了_start和调用main函数。不过我们可以使用-e可以指定程序入口。

$ arm-himix200-linux-ld -e main foo.o main.o

生成可执行文件后我们再readelf -S查看,可以看到已经分配好地址了。
在这里插入图片描述

4.1.2.2.2 符号地址的确定

由于各个符号合并时在section内的位置是固定了,所以main其实也就确定了,只不过链接器需要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址上。有时可执行文件的.text比所有的目标文件中的.text section size加起来还要大,是因为.text需要对齐,可能调整一下输入目标文件的顺序可执行文件的.text size就会不同。

4.1.2.2.3 重定位

目标文件合并完成,并且为符号分配了运行地址,链接器接下来就要进行符号重定位了。

这里需要了解一下BL指令。

在这里插入图片描述

在这里插入图片描述
BL相对跳转指令,低24bit表示要跳转的偏移量。

一般的ARM体系结构采用了3级流水线技术(arm_v7 32bit),即取指、译码、执行。PC寄存器里的值是预取指令的地址,PC总是指向当前执行指令的下两条指令的地址,即PC的值为当前指令的地址值加8个字节。符号拓展,如果为负数则高位全部置1,否则置0。左移2bit,即4字节对齐,所以BL相对跳转范围为±32MB。

这里以main.o为例,先查看其重定位表信息。

$ readelf -r main.o
Relocation section ‘.rel.text’ at offset 0x1f8 contains 3 entries:
Offset Info Type Sym.Value Sym. Name
00000008 00000c1c R_ARM_CALL 00000000 foo
00000018 00000d1c R_ARM_CALL 0000002c func
00000028 00000e02 R_ARM_ABS32 00000000 g_foo_uninit

可以看到有3处需要重定位,分两种类型:

  • R_ARM_CALL: 相对地址跳转,修正方法 S+A。
  • R_ARM_ABS32:绝对地址跳转,修正方法 S+A-P。

对于PC上的类型可能如下:
在这里插入图片描述
使用readelf -s查看分配的符号地址。
在这里插入图片描述
g_foo_uninit_S = 0x20104;
func_S = 0x100e4;
foo_S = 0x10094;

在还没重定位时,默认的A为0,(0 - 8) >> 2 = 0xffffe;
g_foo_uninit_A = 0;
func_A = 0;
foo_A = 0;
在这里插入图片描述

反汇编查看要重定位的位置的运行地址,或者使用偏移offset加也行。
在这里插入图片描述
foo_P = 0x100c0;
func_P = 0x100d0;

foo和func为R_ARM_CALL相对重定位类型,修正方式: S + A - P
foo:
foo_S + foo_A - foo_P = -44。
在转换为BL指令带符号的24bit立即数。
signed_immed_24 = (-44 - 8)>>2 = 0xFFFFFFCC >> 2 = 0xfffff3;

func:
func_S + foo_A - func_P = 100e4 + 0 - 100d0 = 14h = 20
signed_immed_24 = (20 - 8)>>2 = 0xc >> 2 = 0x03;

g_foo_uninit为R_ARM_ABS32绝对地址类型。修正方式:S+A;
signed_immed_24 = 0x20104;

PC上的重定位:

$ gcc -c *.c
$ ld -e main foo.o main.o
$ readelf -r main.o

Relocation section ‘.rela.text’ at offset 0x290 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000000005 000900000002 R_X86_64_PC32 0000000000000000 foo - 4
00000000000b 000a00000002 R_X86_64_PC32 0000000000000000 g_foo_uninit - 4
000000000010 000b00000002 R_X86_64_PC32 000000000000001b func - 4

$ objdump -d main.o
main.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 :
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: e8 00 00 00 00 callq 9 <main+0x9>
9: 89 05 00 00 00 00 mov %eax,0x0(%rip) # f <main+0xf>
f: e8 00 00 00 00 callq 14 <main+0x14>
14: b8 00 00 00 00 mov $0x0,%eax
19: 5d pop %rbp
1a: c3 retq

000000000000001b :

$ objdump -d a.out

a.out: file format elf64-x86-64

Disassembly of section .text:

00000000004000e8 :

00000000004000f4 :
4000f4: 55 push %rbp
4000f5: 48 89 e5 mov %rsp,%rbp
4000f8: e8 eb ff ff ff callq 4000e8
4000fd: 89 05 99 00 20 00 mov %eax,0x200099(%rip) # 60019c <_edata>
400103: e8 07 00 00 00 callq 40010f
400108: b8 00 00 00 00 mov $0x0,%eax
40010d: 5d pop %rbp
40010e: c3 retq

000000000040010f :

foo 为R_X86_64_PC32重定位类型,即相对重定位. 修订公式 : S + A - P
S = 4000e8 ;
A = -4;
P = 即eb ff ff ff的地址,4000f9;
修订值 = 0x4000e8 + (-4) - (0x4000f8 + 1) = -21 = 0xFFFFEB
可以看到已经被修订为0xFFFFEB了.
4000f8: e8 eb ff ff ff callq 4000e8

func 为R_X86_64_PC32重定位类型,即相对重定位. 修订公式 : S + A - P
S = 40010f ;
A = - 4;
P = 400104;
修订值 = 40010f + (-4) - 400104 = 7
可以看到已经被修订为7了.
400103: e8 07 00 00 00 callq 40010f

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
1. 下载SDK 首先需要从OpenWrt官网下载SDK。选择与路由器硬件平台对应的SDK,例如:如果你的路由器是MT7620A芯片,则需要下载MT7620A SDK。 2. 解压SDK 将下载的SDK解压到任意目录下,例如:/opt/mt7620a_sdk。 3. 进入SDK目录 打开终端,进入SDK目录,例如:cd /opt/mt7620a_sdk。 4. 配置SDK 执行make menuconfig命令,进入SDK配置界面,进行以下配置: - Target System: 选择路由器的芯片类型,例如:MediaTek Ralink MIPS - Target Profile: 选择路由器的型号,例如:MT7620A based boards - Target Images: 选择编译软件包的目标平台,例如:ramips/mt7620a 5. 添加软件包源 执行以下命令,添加软件包源: echo "src/gz openwrt_custom http://openwrt.inkworm.com/chaos_calmer/15.05/mt7620a/packages/custom" >> /etc/opkg/customfeeds.conf opkg update 6. 安装编译工具 执行以下命令,安装编译工具: opkg install gcc make libpthread libstdcpp 7. 编写Makefile文件 在任意目录下创建一个文件夹,例如:/opt/my_package,并在该文件夹下创建一个名为Makefile的文件。在Makefile文件中编写软件包的编译规则。 以下是一个简单的Makefile文件示例: ``` include $(TOPDIR)/rules.mk PKG_NAME:=hello-world PKG_VERSION:=1.0 PKG_RELEASE:=1 include $(INCLUDE_DIR)/package.mk define Package/hello-world SECTION:=utils CATEGORY:=Utilities TITLE:=Hello World DEPENDS:=@TARGET_ramips_mt7620a endef define Package/hello-world/description This is a Hello World package. endef define Build/Compile $(MAKE) -C $(PKG_BUILD_DIR) $(TARGET_CONFIGURE_OPTS) endef define Package/hello-world/install $(INSTALL_DIR) $(1)/bin $(INSTALL_BIN) $(PKG_BUILD_DIR)/hello-world $(1)/bin/ endef $(eval $(call BuildPackage,hello-world)) ``` 8. 编译软件包 执行以下命令,编译软件包: make package/hello-world/compile V=s 编译完成后,在SDK目录下的bin目录中可以找到编译好的软件包。 9. 安装软件包 将编译好的软件包拷贝到路由器上,并执行以下命令安装: opkg install hello-world_1.0-1_ramips_24kec.ipk 安装完成后,在路由器上执行hello-world命令即可看到输出结果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值