# 第03讲:C语言编程基础 -- Linux环境下的编译、调试与程序结构 > **本节目标**:掌握Linux环境下C语言程序的编译链接过程、可执行程序的内存结构、gdb调试方法和Makefile编写,为后续操作系统编程打下基础 ## 前置知识 - [[02_Linux基础]] -- Linux基本命令和文件操作 - C语言基础语法 --- ## 一、Linux环境下C语言程序编译链接过程 ### 1.1 编译的四个阶段 在Linux下,一个C源文件要经过四个阶段才能变成可执行程序: ```mermaid graph LR A["hello.c 源代码"] -->|预处理 gcc -E| B["hello.i 预处理后的源代码"] B -->|编译 gcc -S| C["hello.s 汇编代码"] C -->|汇编 gcc -c| D["hello.o 目标代码(机器码)"] D -->|链接 gcc| E["hello 可执行程序"] style A fill:#e1f5fe style B fill:#fff3e0 style C fill:#f3e5f5 style D fill:#ffcdd2 style E fill:#e8f5e9 ``` | 阶段 | 输入 | 输出 | gcc选项 | 主要工作 | |------|------|------|---------|---------| | **预处理** | `.c` | `.i` | `-E` | 展开宏 `#define`、包含头文件 `#include`、条件编译 | | **编译** | `.i` | `.s` | `-S` | 将C代码翻译为汇编代码,进行语法分析和优化 | | **汇编** | `.s` | `.o` | `-c` | 将汇编代码翻译为机器码(目标文件) | | **链接** | `.o` | 可执行文件 | (默认) | 合并库函数、解析符号引用、生成最终可执行文件 | ### 1.2 实际操作演示 ```bash # 分步编译 hello.c gcc -E hello.c -o hello.i # 第1步:预处理 gcc -S hello.i -o hello.s # 第2步:编译(生成汇编) gcc -c hello.s -o hello.o # 第3步:汇编(生成目标文件) gcc hello.o -o hello # 第4步:链接(生成可执行文件) # 一步完成(等价于上面四步) gcc hello.c -o hello ``` ### 1.3 各阶段产物的特点 ```bash # 查看预处理结果(宏被展开,头文件被包含) gcc -E hello.c -o hello.i wc -l hello.c hello.i # hello.i 会比 hello.c 长很多 # 查看汇编代码 gcc -S hello.c -o hello.s cat hello.s # 可以看到汇编指令 # 查看目标文件内容 gcc -c hello.c -o hello.o file hello.o # 显示文件类型:ELF 64-bit relocatable nm hello.o # 查看符号表 ``` ### 1.4 链接的作用 链接器主要完成以下工作: 1. **符号解析**:将函数调用和变量引用与它们的定义关联起来 2. **地址重定位**:为代码和数据分配最终的内存地址 3. **库合并**:将程序使用的库函数(如 `printf`)合并进来 ```mermaid graph TB subgraph "链接前" A["main.o 调用 printf()"] B["sum.o 定义 sum()"] C["libc.a 定义 printf()"] end subgraph "链接后" D["可执行文件 所有代码和数据合并"] end A --> D B --> D C --> D style A fill:#e1f5fe style B fill:#fff3e0 style C fill:#f3e5f5 style D fill:#e8f5e9 ``` --- ## 二、Linux可执行程序结构 ### 2.1 ELF文件格式 Linux下的可执行文件采用 **ELF(Executable and Linkable Format)** 格式。一个可执行文件在内存中由多个段(section/segment)组成: ```mermaid graph TB subgraph "高地址" STACK["栈 (Stack) 局部变量、函数调用帧 向下增长 ↓"] end subgraph "" FREE["空闲区域 (向中间增长)"] end subgraph "" HEAP["堆 (Heap) malloc/free 动态分配 向上增长 ↑"] end subgraph "" BSS[".bss 段 未初始化的全局变量 (运行时初始化为0)"] end subgraph "" DATA[".data 段 已初始化的全局变量"] end subgraph "" RODATA[".rodata 段 只读数据(如字符串常量)"] end subgraph "低地址" TEXT[".text 段 程序代码(机器指令)"] end STACK --- FREE FREE --- HEAP HEAP --- BSS BSS --- DATA DATA --- RODATA RODATA --- TEXT style TEXT fill:#e1f5fe style RODATA fill:#fff3e0 style DATA fill:#e8f5e9 style BSS fill:#f3e5f5 style HEAP fill:#ffcdd2 style STACK fill:#c8e6c9 ``` ### 2.2 各段详解 | 段名 | 内容 | 特点 | |------|------|------| | **.text**(代码段) | 程序的机器指令 | 只读、可执行 | | **.rodata**(只读数据段) | 字符串常量、`const` 修饰的全局变量 | 只读 | | **.data**(数据段) | 已初始化的全局变量和静态变量 | 可读可写 | | **.bss** | 未初始化的全局变量和静态变量 | 运行时由系统初始化为0,不占磁盘空间 | | **堆(Heap)** | `malloc()` / `calloc()` 动态分配的内存 | 由程序员手动管理,向上增长 | | **栈(Stack)** | 局部变量、函数参数、返回地址 | 由编译器自动管理,向下增长 | ### 2.3 用代码验证 ```c // memory_layout.c - 验证程序内存布局 #include #include int global_init = 100; // .data 段(已初始化全局变量) int global_uninit; // .bss 段(未初始化全局变量) const int READONLY = 42; // .rodata 段(只读数据) int main() { int local_var = 10; // 栈(局部变量) int *heap_var = malloc(sizeof(int)); // 堆(动态分配) printf("代码段地址 (main): %p\n", main); printf("只读数据段地址: %p\n", &READONLY); printf("数据段地址 (global): %p\n", &global_init); printf("BSS段地址 (uninit): %p\n", &global_uninit); printf("堆地址 (malloc): %p\n", heap_var); printf("栈地址 (local): %p\n", &local_var); free(heap_var); return 0; } ``` ```bash gcc memory_layout.c -o memory_layout ./memory_layout ``` 预期输出中,地址从低到高排列为:`.text` < `.rodata` < `.data` < `.bss` < 堆 < 栈。 ### 2.4 用 readelf 和 objdump 查看 ```bash # 查看ELF文件的段信息 readelf -S hello # 反汇编 .text 段 objdump -d hello # 查看符号表 nm hello ``` --- ## 三、gdb调试 ### 3.1 编译时添加调试信息 使用 `-g` 选项编译,gdb才能将机器指令与源代码行号对应: ```bash gcc -g gdbuse.c -o gdbuse gdb ./gdbuse ``` ### 3.2 gdb常用命令 | 命令 | 缩写 | 作用 | 示例 | |------|------|------|------| | `run` | `r` | 运行程序 | `run` | | `break` | `b` | 设置断点 | `b main` 或 `b gdbuse.c:10` | | `next` | `n` | 单步执行(不进入函数) | `n` | | `step` | `s` | 单步执行(进入函数) | `s` | | `continue` | `c` | 继续运行到下一个断点 | `c` | | `print` | `p` | 打印变量值 | `p count` | | `backtrace` | `bt` | 查看调用栈 | `bt` | | `list` | `l` | 查看源代码 | `l` | | `info locals` | -- | 查看所有局部变量 | `info locals` | | `watch` | -- | 监视变量变化 | `watch count` | | `quit` | `q` | 退出gdb | `q` | ### 3.3 调试实例:gdbuse.c 中的bug 课程提供的 `gdbuse.c` 包含一个典型的bug,适合用来练习gdb调试: ```c // gdbuse.c - 包含bug的程序 #include #include int main() { char c = 't'; char s[100]; int i; int count = 0; strcpy(s, "abcdefghijklmopqrstuvstuxyz0123456789"); for (i = 0; i < strlen(s); i++) if (s[i] = c) // BUG: 应该是 == 而不是 = count++; printf("count = %d\n", count); } ``` **调试步骤**: ```bash # 1. 编译(加 -g 选项) gcc -g gdbuse.c -o gdbuse # 2. 启动gdb gdb ./gdbuse # 3. 在 main 函数设置断点 (gdb) b main # 4. 运行程序 (gdb) run # 5. 单步执行,观察变量 (gdb) n # 执行到 for 循环 (gdb) p s[i] # 打印当前字符 (gdb) p c # 打印目标字符 (gdb) p count # 打印计数器 # 6. 发现问题:s[i] = c 是赋值,不是比较 # 应该写成 s[i] == c ``` **bug分析**:`if (s[i] = c)` 中使用了赋值运算符 `=` 而非比较运算符 `==`,导致每次循环都把 `c` 赋值给 `s[i]`,且条件永远为真(`c` 的值 `'t'` 非零),最终 `count` 等于字符串长度。 --- ## 四、Makefile ### 4.1 为什么需要Makefile 当项目包含多个源文件时,手动逐个编译非常麻烦。Makefile 可以: - 自动判断哪些文件需要重新编译 - 并行编译,加快速度 - 统一管理编译选项 ### 4.2 基本规则 Makefile 的每条规则由三部分组成: ``` 目标: 依赖 命令(必须以Tab开头) ``` ```mermaid graph LR A["target 目标"] --> B["dependencies 依赖"] B --> C["recipe 命令"] style A fill:#ffcdd2 style B fill:#fff3e0 style C fill:#e8f5e9 ``` ### 4.3 Makefile实例 以课程中的 `sum.c` 多文件项目为例(`sum.c` + `main.c` + `calc.h`): ```makefile # Makefile - 多文件编译示例 CC = gcc CFLAGS = -Wall -g LDFLAGS = -L. -lwrapper # 目标文件 TARGET = myprogram OBJS = main.o sum.o # 默认目标 all: $(TARGET) # 链接规则 $(TARGET): $(OBJS) $(CC) $(OBJS) -o $(TARGET) $(LDFLAGS) # 通用编译规则 %.o: %.c calc.h $(CC) $(CFLAGS) -c $< -o $@ # 清理 clean: rm -f $(OBJS) $(TARGET) ``` **自动变量说明**: | 变量 | 含义 | |------|------| | `$@` | 当前目标文件名 | | `$<` | 第一个依赖文件名 | | `$^` | 所有依赖文件名 | ```bash # 使用Makefile make # 默认编译 make clean # 清理编译产物 make -j4 # 4个线程并行编译 ``` ### 4.4 Makefile的执行逻辑 ```mermaid graph TD A["make 命令"] --> B{检查 all 目标} B --> C{myprogram 存在且最新?} C -->|是| D["无需编译"] C -->|否| E{main.o 需要更新?} E -->|是| F["gcc -c main.c -o main.o"] E -->|否| G{sum.o 需要更新?} F --> G G -->|是| H["gcc -c sum.c -o sum.o"] G -->|否| I["gcc main.o sum.o -o myprogram"] H --> I style A fill:#ffcdd2 style D fill:#e8f5e9 style I fill:#e8f5e9 ``` --- ## 五、Wrapper库 ### 5.1 什么是Wrapper库 课程提供的 **Wrapper库**(`libwrapper.a`)封装了常用的系统调用,在原始系统调用的基础上增加了 **错误检查** 功能。如果系统调用失败,Wrapper函数会自动打印错误信息并终止程序。 ### 5.2 设计哲学 ```mermaid graph LR subgraph "不使用Wrapper" A[程序员] -->|"手动检查返回值"| B[系统调用] B -->|"可能忘记检查"| C[隐患:错误被忽略] end subgraph "使用Wrapper" D[程序员] -->|"直接调用"| E[Wrapper函数] E -->|"自动检查"| F[系统调用] F -->|"失败时"| G[打印错误并退出] end style C fill:#ffcdd2 style G fill:#e8f5e9 ``` ### 5.3 使用示例 ```c // 不使用 Wrapper(容易忘记检查) int fd = open("file.txt", O_RDONLY); if (fd < 0) { perror("open"); exit(1); } // 使用 Wrapper(自动检查,代码更简洁) int fd = Open("file.txt", O_RDONLY, 0); // 注意:大写开头 ``` ### 5.4 常用Wrapper函数对照 | 系统调用 | Wrapper函数 | 包含头文件 | |----------|-------------|-----------| | `fork()` | `Fork()` | `wrapper.h` | | `execve()` | `Execve()` | `wrapper.h` | | `wait()` | `Wait()` | `wrapper.h` | | `open()` | `Open()` | `wrapper.h` | | `read()` | `Read()` | `wrapper.h` | | `write()` | `Write()` | `wrapper.h` | | `close()` | `Close()` | `wrapper.h` | | `malloc()` | `Malloc()` | `wrapper.h` | | `free()` | `Free()` | `wrapper.h` | | `pthread_create()` | `Pthread_create()` | `wrapper.h` | ### 5.5 编译时链接Wrapper库 ```bash # 编译时链接 libwrapper.a gcc -o myprogram myprogram.c -L. -lwrapper # 或者在 Makefile 中 LDFLAGS = -L. -lwrapper ``` > **注意**:Wrapper函数名是对应系统调用的首字母大写形式。详见 [[附录A_Wrapper库参考]]。 --- ## 六、命令行参数 ### 6.1 argc 和 argv C程序的 `main` 函数可以接收命令行参数: ```c int main(int argc, char *argv[]) ``` - **argc**(argument count):参数个数,包括程序名本身 - **argv**(argument vector):参数字符串数组,`argv[0]` 是程序名 ### 6.2 实例:cmdpar.c ```c // cmdpar.c - 命令行参数处理 #include #include int main(int argc, char *argv[]) { int i; printf("参数个数(含程序名): %d\n", argc); for (i = 0; i < argc; i++) printf("argv[%d] = %s\n", i, argv[i]); return 0; } ``` ```bash gcc cmdpar.c -o cmdpar ./cmdpar hello world 123 ``` **预期输出**: ``` 参数个数(含程序名): 4 argv[0] = ./cmdpar argv[1] = hello argv[2] = world argv[3] = 123 ``` ### 6.3 参数传递原理 ```mermaid graph TB subgraph "命令行" CMD["./cmdpar hello world 123"] end subgraph "操作系统解析" ARG0["argv[0] = \"./cmdpar\""] ARG1["argv[1] = \"hello\""] ARG2["argv[2] = \"world\""] ARG3["argv[3] = \"123\""] ARGC["argc = 4"] end CMD --> ARG0 CMD --> ARG1 CMD --> ARG2 CMD --> ARG3 CMD --> ARGC style CMD fill:#fff3e0 style ARGC fill:#e1f5fe ``` --- ## 七、基本C编程 ### 7.1 hello.c ```c // hello.c - 最简单的C程序 #include void main() { printf("hello World\n"); } ``` ```bash gcc hello.c -o hello ./hello # 输出:hello World ``` ### 7.2 sum.c -- 多文件项目 **calc.h**(头文件): ```c // calc.h - 函数声明 #ifndef CALC_H #define CALC_H double aver(double, double); double sum(double, double); #endif ``` **sum.c**(实现文件): ```c // sum.c - 求和函数实现 #include "calc.h" double sum(double num1, double num2) { return (num1 + num2); } ``` **main.c**(主文件): ```c // main.c - 主函数 #include #include "calc.h" int main() { double a = 10.0, b = 20.0; printf("Sum = %.2f\n", sum(a, b)); printf("Average = %.2f\n", aver(a, b)); return 0; } ``` ```bash # 分步编译 gcc -c sum.c -o sum.o gcc -c main.c -o main.o gcc sum.o main.o -o calc ./calc ``` --- ## 八、知识关联 - 编译链接过程在 [[01_系统运行机制]] 中理解程序如何被加载到内存 - 可执行程序的内存布局(堆、栈)在 [[05_进程控制]] 中与进程地址空间对应 - gdb调试在实验中会大量使用 - Makefile在 [[09_并发网络服务器]] 的多文件项目中会用到 - Wrapper库贯穿整个课程,详见 [[附录A_Wrapper库参考]] - 文件I/O编程是 [[04_文件IO编程]] 的核心内容 --- ## 九、思考题 1. **预处理阶段做了什么?** `#include ` 在预处理后变成了什么? 2. **为什么需要链接?** 如果没有链接器,编程会变成什么样? 3. **.bss段为什么不在文件中占空间?** 系统如何保证未初始化全局变量为0? 4. **栈和堆的增长方向为什么相反?** 这种设计有什么好处? 5. **Wrapper库的价值是什么?** 为什么课程不直接使用系统调用? 6. **`if (a = 1)` 和 `if (a == 1)` 的区别?** 如何用gdb发现这类bug? --- ## 十、扩展阅读 - 《深入理解计算机系统》第7章:链接 - 《UNIX环境高级编程》第1章:UNIX基础知识 - GCC官方文档:https://gcc.gnu.org/onlinedocs/ - GDB官方教程:https://www.sourceware.org/gdb/documentation/