本帖最后由 luyism 于 2024-6-25 17:32 编辑
《嵌入式软件的时间分析》在Linux环境下的C程序编译过程详解【阅读笔记1】
一、实时系统与编译过程理解
实时系统对于嵌入式工程师来说是一个基础而重要的概念。在汽车电子控制单元(ECU)等应用中,时间的精确控制和响应至关重要。而编译过程,则是将高级语言(如C语言)代码转换为可执行文件的关键步骤,其效率直接影响到系统的性能和响应速度。
二、GCC 基本使用方法
作为一名嵌入式工程师,熟练掌握 GCC 的使用对于我们的日常开发至关重要。下面,我将详细介绍 GCC 的基本用法以及编译过程的各个阶段。
GCC 的命令语法如下:
常用选项:
-o
:小写字母“o”,指定生成的可执行文件的名字。如果不指定,生成的可执行文件名默认为 a.out
。
-E
:只进行预处理,不进行编译和汇编。
-S
:只进行编译,不进行汇编。
-c
:编译并汇编,但不进行链接。
-g
:生成带有调试信息的可执行文件,方便使用 gdb
进行调试。
-Ox
:大写字母“O”加数字,设置程序的优化等级,如 -O0
、-O1
、-O2
、-O3
,数字越大,代码的优化等级越高,编译出来的程序一般会更小、更快,但可能会导致程序行为的变化。
除了 -g
和 -Ox
选项,其它选项实际上是编译的分步骤,即只进行某些编译过程。
三、编译过程详解与示例
我们通过一个简单的C程序来演示在Linux环境下的完整编译过程。示例代码如下:
#include <stdio.h>
#define MAX_NUM 10
int main() {
int i;
for (i = 0; i < MAX_NUM; i++) {
printf("Count: %d\n", i);
}
return 0;
}
一些相关命令如下:
mkdir gcccode
cd gcccode/
vim example.c
3.0直接编译成可执行文件
上述命令将 example.c
直接编译成可执行文件 example
。
在当前目录下直接运行输出的 example
可执行文件即可看到运行效果:
./example
Count: 0
Count: 1
Count: 2
Count: 3
Count: 4
Count: 5
Count: 6
Count: 7
Count: 8
Count: 9
3.1 预处理(Preprocessing)
预处理阶段主要负责处理以 #
开头的预编译指令,如宏定义、头文件包含等。我们使用 gcc
的 -E
选项来进行预处理。
命令行执行预处理:
gcc -E example.c -o example.i
执行后,我们可以查看生成的 example.i
文件。预处理后的文件展示了所有头文件内容展开、宏定义替换的结果,是纯C代码形式,没有经过语法检查。直接用编辑器打开生成的hello.i,部分内容如下:
1 # 1 "example.c"
2 # 1 "<built-in>"
3 # 1 "<command-line>"
4 # 31 "<command-line>"
5 # 1 "/usr/include/stdc-predef.h" 1 3 4
6 # 32 "<command-line>" 2
7 # 1 "example.c"
8
9 # 1 "/usr/include/stdio.h" 1 3 4
10 # 27 "/usr/include/stdio.h" 3 4
11 # 1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 1 3 4
12 # 33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 3 4
13 # 1 "/usr/include/features.h" 1 3 4
14 # 424 "/usr/include/features.h" 3 4
15 # 1 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 1 3 4
16 # 427 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 3 4
17 # 1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
18 # 428 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4
19 # 1 "/usr/include/x86_64-linux-gnu/bits/long-double.h" 1 3 4
20 # 429 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4
21 # 425 "/usr/include/features.h" 2 3 4
22 # 448 "/usr/include/features.h" 3 4
23 # 1 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 1 3 4
24 # 10 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 3 4
25 # 1 "/usr/include/x86_64-linux-gnu/gnu/stubs-64.h" 1 3 4
26 # 11 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 2 3 4
27 # 449 "/usr/include/features.h" 2 3 4
28 # 34 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 2 3 4
29 # 28 "/usr/include/stdio.h" 2 3 4
...
(中间省略)
...
781 extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__));
782 # 840 "/usr/include/stdio.h" 3 4
783 extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
784
785
786
787 extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) ;
788
789
790 extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
791 # 868 "/usr/include/stdio.h" 3 4
792
793 # 3 "example.c" 2
794
795
796
797
798 # 6 "example.c"
799 int main() {
800 int i;
801 for (i = 0; i < 10; i++) {
802 printf("Count: %d\n", i);
803 }
804 return 0;
805 }
可以看到我们的代码从12行增长到了805行,其中主要原因就是预处理阶段将 #include <stdio.h>
展开了。
3.2 编译(Compilation)
编译阶段将预处理后的代码翻译成汇编语言。我们使用 gcc
的 -S
选项来进行编译。在这个过程,GCC会检查各个源文件的语法,即使我们调用了一个没有定义的函数,也不会报错。
命令行执行编译:
gcc -S example.i -o example.s
汇编代码展示了与特定架构相关的低级指令,是CPU可以直接理解的形式,但还不是机器码。生成的 example.s
文件包含了汇编代码。生成的hello.s文件可直接使用编辑器打开,内容如下:
.text
.section .rodata
.LC0:
.string "Count: %d\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $0, -4(%rbp)
jmp .L2
.L3:
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
addl $1, -4(%rbp)
.L2:
cmpl $9, -4(%rbp)
jle .L3
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0"
.section .note.GNU-stack,"",@progbits
在这段汇编代码中,我们可以看到以下几个关键部分:
文本段和只读数据段:
.text
和 .section .rodata
指示了代码段和只读数据段的开始。
.LC0
定义了一个字符串常量 "Count: %d\n",用于 printf
函数。
主函数 main
的定义:
.globl main
声明 main
函数为全局符号,使其在链接时可见。
.type main, @function
指定 main
为函数类型。
函数体:
main:
是 main
函数的入口。
.cfi_startproc
和 .cfi_endproc
是用于调试信息的指令。
pushq %rbp
和 movq %rsp, %rbp
用于设置栈帧。
subq $16, %rsp
为局部变量分配空间。
movl $0, -4(%rbp)
初始化局部变量。
jmp .L2
跳转指令,用于实现循环逻辑。
循环逻辑:
- 标签
.L3
和 .L2
用于实现循环。
movl -4(%rbp), %eax
加载局部变量。
movl %eax, %esi
将局部变量传递给 printf
。
leaq .LC0(%rip), %rdi
加载字符串常量的地址。
call printf@PLT
调用 printf
函数。
addl $1, -4(%rbp)
增加局部变量的值。
cmpl $9, -4(%rbp)
比较局部变量与9。
jle .L3
条件跳转,若局部变量小于等于9,则继续循环。
函数结束:
movl $0, %eax
设置返回值为0。
leave
和 ret
指令用于函数返回。
元数据:
.ident
包含编译器版本信息。
.section .note.GNU-stack
是GNU特定的节信息,表示该代码不需要可执行堆栈。3.3 汇编(Assembly)
汇编阶段将汇编代码转换成二进制目标代码(Object Code)。我们使用 gcc
的 -c
选项来进行汇编。
命令行执行汇编:
gcc -c example.s -o example.o
example.o
文件包含了机器代码的二进制表示。目标文件是二进制格式,包含了程序的机器码和必要的数据段,但尚未解决外部符号引用。
我们可以使用 objdump
工具来反汇编目标文件查看其内容。
命令行查看目标文件:
输出内容如下:
example.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
f: eb 1a jmp 2b <main+0x2b>
11: 8b 45 fc mov -0x4(%rbp),%eax
14: 89 c6 mov %eax,%esi
16: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi
1d: b8 00 00 00 00 mov $0x0,%eax
22: e8 00 00 00 00 callq 27 <main+0x27>
27: 83 45 fc 01 addl $0x1,-0x4(%rbp)
2b: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
2f: 7e e0 jle 11 <main+0x11>
31: b8 00 00 00 00 mov $0x0,%eax
36: c9 leaveq
37: c3 retq
函数入口和栈帧设置:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
push %rbp
:将当前的基址指针压入栈中,保存调用者的栈帧指针。
mov %rsp,%rbp
:将栈指针的值赋给基址指针,建立新的栈帧。
sub $0x10,%rsp
:为局部变量分配16字节的空间。
初始化和循环跳转:
8: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
f: eb 1a jmp 2b <main+0x2b>
movl $0x0,-0x4(%rbp)
:将局部变量初始化为0。
jmp 2b <main+0x2b>
:跳转到循环的条件检查部分。
循环体:
11: 8b 45 fc mov -0x4(%rbp),%eax
14: 89 c6 mov %eax,%esi
16: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi
1d: b8 00 00 00 00 mov $0x0,%eax
22: e8 00 00 00 00 callq 27 <main+0x27>
27: 83 45 fc 01 addl $0x1,-0x4(%rbp)
mov -0x4(%rbp),%eax
:将局部变量的值加载到 eax
寄存器。
mov %eax,%esi
:将 eax
寄存器的值传递给 esi
寄存器(作为 printf
参数)。
lea 0x0(%rip),%rdi
:加载字符串常量地址到 rdi
寄存器(作为 printf
参数)。
mov $0x0,%eax
:将 eax
寄存器置零。
callq 27 <main+0x27>
:调用 printf
函数。
addl $0x1,-0x4(%rbp)
:将局部变量增加1。
循环条件检查和函数结束:
2b: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
2f: 7e e0 jle 11 <main+0x11>
31: b8 00 00 00 00 mov $0x0,%eax
36: c9 leaveq
37: c3 retq
cmpl $0x9,-0x4(%rbp)
:比较局部变量与9。
jle 11 <main+0x11>
:如果小于等于9,则跳转回循环体。
mov $0x0,%eax
:将 eax
寄存器置零,作为函数返回值。
leaveq
:恢复调用者的栈帧。
retq
:返回到调用者。
3.4 链接(Linking)
链接阶段将一个或多个目标文件与任何所需的库文件合并,解决符号引用,生成最终的可执行文件。我们使用 gcc
直接生成可执行文件。
命令行执行链接:
此命令将 example.o
文件与标准库和其他必要的库文件链接成最终的可执行文件 example
。
四、优化选项
通过上述步骤,我们了解了从高级C代码到最终可执行文件的转换过程。在实际工作中,优化编译过程可以通过调整编译器参数、选择合适的优化级别以及利用特定的硬件功能来提升代码执行效率和系统响应速度。
若想优化编译过程,可以在编译时加入优化等级标志,例如:举个例子,我们可以通过 -O
选项来进行编译器优化:
-O2
表示中等优化级别,它会启用大多数优化选项,以提高运行时性能而不显著增加编译时间。
gcc -o optimized_example example.c -O2
五、分析和查看Linux下的可执行文件 example
在经过编译、汇编和链接后,我们最终生成了可执行文件 example
。接下来,我们将详细分析如何查看和分析这个可执行文件,并通过运行它来验证程序的功能。
我们可以使用 objdump
和 readelf
工具来查看可执行文件的详细信息。
- 使用
objdump
查看可执行文件的反汇编代码:
该命令会反汇编可执行文件,显示所有的机器指令,具体的代码上面已经分析。
- 使用
readelf
查看可执行文件的头部信息和段信息:
在Linux下可执行文件是ELF (Executable and Linkable Format) 格式,我们可以使用 readelf
工具可以深入分析 ELF文件的头部信息和段信息,帮助我们理解文件的结构和加载方式。
下面是一个示例输出以及详细解释。
输出内容如下所示:
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: DYN (共享目标文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0x540
程序头起点: 64 (bytes into file)
Start of section headers: 6448 (bytes into file)
标志: 0x0
本头的大小: 64 (字节)
程序头大小: 56 (字节)
Number of program headers: 9
节头大小: 64 (字节)
节头数量: 29
字符串表索引节头: 28
详细解释
Magic:
- 标识文件类型为 ELF 文件,前四个字节
7f 45 4c 46
分别对应于字符 .ELF
。
类别:
- 指示文件是 64 位还是 32 位。这里是 64 位(ELF64)。
数据:
- 指示数据编码格式,这里是小端序(little endian)。
版本:
OS/ABI:
- 指示操作系统和应用程序二进制接口 (ABI)。这里是 UNIX 系统 V。
ABI 版本:
类型:
- 指示文件类型,这里是 DYN,表示共享目标文件(例如动态链接库)。
系统架构:
版本:
入口点地址:
- 程序执行开始的地址,当系统加载这个可执行文件时,将控制权交给这个地址。这里是
0x540
。
程序头起点:
- 程序头表在文件中的偏移量(以字节为单位),这里是 64 字节。
节头起点:
- 节头表在文件中的偏移量(以字节为单位),这里是 6448 字节。
标志:
- 与文件相关的特定标志。这里是
0x0
,表示没有特别的标志。
本头的大小:
程序头大小:
程序头数量:
节头大小:
节头数量:
字符串表索引节头:
通过 readelf -h
命令,我们可以了解 ELF 可执行文件的基本结构,包括文件类型、机器架构、入口点地址和程序头表及节头表的位置和大小等信息。这些信息对于理解可执行文件的加载和执行过程非常重要,有助于嵌入式系统开发人员进行调试和优化。