luyism 发表于 2024-6-24 19:03

《嵌入式软件的时间分析》在Linux环境下的C程序编译过程详解【阅读笔记1】

本帖最后由 luyism 于 2024-6-25 17:32 编辑

## 《嵌入式软件的时间分析》在Linux环境下的C程序编译过程详解【阅读笔记1】


### 一、实时系统与编译过程理解

实时系统对于嵌入式工程师来说是一个基础而重要的概念。在汽车电子控制单元(ECU)等应用中,时间的精确控制和响应至关重要。而编译过程,则是将高级语言(如C语言)代码转换为可执行文件的关键步骤,其效率直接影响到系统的性能和响应速度。

### 二、GCC 基本使用方法

作为一名嵌入式工程师,熟练掌握 GCC 的使用对于我们的日常开发至关重要。下面,我将详细介绍 GCC 的基本用法以及编译过程的各个阶段。

GCC 的命令语法如下:

```
gcc [选项] 输入文件名
```


常用选项:

- `-o`:小写字母“o”,指定生成的可执行文件的名字。如果不指定,生成的可执行文件名默认为 `a.out`。
- `-E`:只进行预处理,不进行编译和汇编。
- `-S`:只进行编译,不进行汇编。
- `-c`:编译并汇编,但不进行链接。
- `-g`:生成带有调试信息的可执行文件,方便使用 `gdb` 进行调试。
- `-Ox`:大写字母“O”加数字,设置程序的优化等级,如 `-O0`、`-O1`、`-O2`、`-O3`,数字越大,代码的优化等级越高,编译出来的程序一般会更小、更快,但可能会导致程序行为的变化。

除了 `-g` 和 `-Ox` 选项,其它选项实际上是编译的分步骤,即只进行某些编译过程。

### 三、编译过程详解与示例

我们通过一个简单的C程序来演示在Linux环境下的完整编译过程。示例代码如下:

```c
// example.c
#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;
}

```

一些相关命令如下:

```bash
mkdir gcccode
cd gcccode/

# 使用vim直接建立源码文件,输入上述代码后保存并退出
vim example.c
```

#### 3.0直接编译成可执行文件

```bash
gcc example.c -o example
```

上述命令将 `example.c` 直接编译成可执行文件 `example`。

在当前目录下直接运行输出的 `example` 可执行文件即可看到运行效果:

```bash
./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` 选项来进行预处理。

命令行执行预处理:

```bash
gcc -E example.c -o example.i
```

执行后,我们可以查看生成的 `example.i` 文件。预处理后的文件展示了所有头文件内容展开、宏定义替换的结果,是纯C代码形式,没有经过语法检查。直接用编辑器打开生成的hello.i,部分内容如下:

```c
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会检查各个源文件的语法,即使我们调用了一个没有定义的函数,也不会报错。

命令行执行编译:

```bash
gcc -S example.i -o example.s
```

汇编代码展示了与特定架构相关的低级指令,是CPU可以直接理解的形式,但还不是机器码。生成的 `example.s` 文件包含了汇编代码。生成的hello.s文件可直接使用编辑器打开,内容如下:

```assembly
.text
.section.rodata
.LC0:
.string "Count: %d\n"
.text
.globlmain
.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
callprintf@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   
```

在这段汇编代码中,我们可以看到以下几个关键部分:

1. **文本段和只读数据段**:
   
    - `.text` 和 `.section .rodata` 指示了代码段和只读数据段的开始。
    - `.LC0` 定义了一个字符串常量 "Count: %d\n",用于 `printf` 函数。
2. **主函数 `main` 的定义**:
   
    - `.globl main` 声明 `main` 函数为全局符号,使其在链接时可见。
    - `.type main, @function` 指定 `main` 为函数类型。
3. **函数体**:
   
    - `main:` 是 `main` 函数的入口。
    - `.cfi_startproc` 和 `.cfi_endproc` 是用于调试信息的指令。
    - `pushq %rbp` 和 `movq %rsp, %rbp` 用于设置栈帧。
    - `subq $16, %rsp` 为局部变量分配空间。
    - `movl $0, -4(%rbp)` 初始化局部变量。
    - `jmp .L2` 跳转指令,用于实现循环逻辑。
4. **循环逻辑**:
   
    - 标签 `.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,则继续循环。
5. **函数结束**:
   
    - `movl $0, %eax` 设置返回值为0。
    - `leave` 和 `ret` 指令用于函数返回。
6. **元数据**:
   
    - `.ident` 包含编译器版本信息。
    - `.section .note.GNU-stack` 是GNU特定的节信息,表示该代码不需要可执行堆栈。
#### 3.3 汇编(Assembly)

汇编阶段将汇编代码转换成二进制目标代码(Object Code)。我们使用 `gcc` 的 `-c` 选项来进行汇编。

命令行执行汇编:

```bash
gcc -c example.s -o example.o
```

`example.o` 文件包含了机器代码的二进制表示。目标文件是二进制格式,包含了程序的机器码和必要的数据段,但尚未解决外部符号引用。

我们可以使用 `objdump` 工具来反汇编目标文件查看其内容。

命令行查看目标文件:

```bash
objdump -d example.o
```

输出内容如下:

```bash
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 <main+0x1d>
1d:        b8 00 00 00 00               mov    $0x0,%eax
22:        e8 00 00 00 00               callq27 <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
```


1. **函数入口和栈帧设置**:
   ```assembly
   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字节的空间。

2. **初始化和循环跳转**:
   ```assembly
      8:c7 45 fc 00 00 00 00movl   $0x0,-0x4(%rbp)
      f:eb 1a               jmp    2b <main+0x2b>
   ```
   - `movl $0x0,-0x4(%rbp)`:将局部变量初始化为0。
   - `jmp 2b <main+0x2b>`:跳转到循环的条件检查部分。

3. **循环体**:
   ```assembly
   11:8b 45 fc            mov    -0x4(%rbp),%eax
   14:89 c6               mov    %eax,%esi
   16:48 8d 3d 00 00 00 00lea    0x0(%rip),%rdi      # 1d <main+0x1d>
   1d:b8 00 00 00 00      mov    $0x0,%eax
   22:e8 00 00 00 00      callq27 <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。

4. **循环条件检查和函数结束**:
   ```assembly
   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` 直接生成可执行文件。

命令行执行链接:

```bash
gcc example.o -o example
```

此命令将 `example.o` 文件与标准库和其他必要的库文件链接成最终的可执行文件 `example`。

### 四、优化选项

通过上述步骤,我们了解了从高级C代码到最终可执行文件的转换过程。在实际工作中,优化编译过程可以通过调整编译器参数、选择合适的优化级别以及利用特定的硬件功能来提升代码执行效率和系统响应速度。

若想优化编译过程,可以在编译时加入优化等级标志,例如:举个例子,我们可以通过 `-O` 选项来进行编译器优化:

`-O2` 表示中等优化级别,它会启用大多数优化选项,以提高运行时性能而不显著增加编译时间。

```bash
gcc -o optimized_example example.c -O2
```


### 五、分析和查看Linux下的可执行文件 `example`

在经过编译、汇编和链接后,我们最终生成了可执行文件 `example`。接下来,我们将详细分析如何查看和分析这个可执行文件,并通过运行它来验证程序的功能。

我们可以使用 `objdump` 和 `readelf` 工具来查看可执行文件的详细信息。

1. **使用 `objdump` 查看可执行文件的反汇编代码**:

```bash
objdump -d example
```

该命令会反汇编可执行文件,显示所有的机器指令,具体的代码上面已经分析。

2. **使用 `readelf` 查看可执行文件的头部信息和段信息**:

在Linux下可执行文件是ELF (Executable and Linkable Format) 格式,我们可以使用 `readelf` 工具可以深入分析 ELF文件的头部信息和段信息,帮助我们理解文件的结构和加载方式。

下面是一个示例输出以及详细解释。

```bash
readelf -h example
```

输出内容如下所示:

```bash
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
```

#### 详细解释

1. **Magic**:
   
    - 标识文件类型为 ELF 文件,前四个字节 `7f 45 4c 46` 分别对应于字符 `.ELF`。
2. **类别**:
   
    - 指示文件是 64 位还是 32 位。这里是 64 位(ELF64)。
3. **数据**:
   
    - 指示数据编码格式,这里是小端序(little endian)。
4. **版本**:
   
    - 表示 ELF 文件的版本,目前为 1。
5. **OS/ABI**:
   
    - 指示操作系统和应用程序二进制接口 (ABI)。这里是 UNIX 系统 V。
6. **ABI 版本**:
   
    - 指示 ABI 的版本,这里是 0。
7. **类型**:
   
    - 指示文件类型,这里是 DYN,表示共享目标文件(例如动态链接库)。
8. **系统架构**:
   
    - 指示目标机器架构,这里是 x86-64 架构。
9. **版本**:
   
    - ELF 文件格式版本,通常为 0x1。
10. **入口点地址**:
   
    - 程序执行开始的地址,当系统加载这个可执行文件时,将控制权交给这个地址。这里是 `0x540`。
11. **程序头起点**:
   
    - 程序头表在文件中的偏移量(以字节为单位),这里是 64 字节。
12. **节头起点**:
   
    - 节头表在文件中的偏移量(以字节为单位),这里是 6448 字节。
13. **标志**:
   
    - 与文件相关的特定标志。这里是 `0x0`,表示没有特别的标志。
14. **本头的大小**:
   
    - ELF 文件头的大小,通常为 64 字节。
15. **程序头大小**:
   
    - 每个程序头表项的大小,通常为 56 字节。
16. **程序头数量**:
   
    - 程序头表的条目数,这里是 9 个。
17. **节头大小**:
   
    - 每个节头表项的大小,通常为 64 字节。
18. **节头数量**:
   
    - 节头表的条目数,这里是 29 个。
19. **字符串表索引节头**:
   
    - 字符串表索引节头表项的索引,这里是 28。



通过 `readelf -h` 命令,我们可以了解 ELF 可执行文件的基本结构,包括文件类型、机器架构、入口点地址和程序头表及节头表的位置和大小等信息。这些信息对于理解可执行文件的加载和执行过程非常重要,有助于嵌入式系统开发人员进行调试和优化。

Jacktang 发表于 2024-6-26 07:22

<p>&nbsp;掌握好readelf -h 命令,还是对帮助嵌入式系统开发人员进行调试和优化有很大帮助的。</p>
页: [1]
查看完整版本: 《嵌入式软件的时间分析》在Linux环境下的C程序编译过程详解【阅读笔记1】