Files
obsidian/操作系统/03_C语言编程基础/03_C语言编程基础.md

15 KiB
Raw Blame History

第03讲C语言编程基础 -- Linux环境下的编译、调试与程序结构

本节目标掌握Linux环境下C语言程序的编译链接过程、可执行程序的内存结构、gdb调试方法和Makefile编写为后续操作系统编程打下基础

前置知识

  • 02_Linux基础 -- Linux基本命令和文件操作
  • C语言基础语法

一、Linux环境下C语言程序编译链接过程

1.1 编译的四个阶段

在Linux下一个C源文件要经过四个阶段才能变成可执行程序

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 实际操作演示

# 分步编译 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 各阶段产物的特点

# 查看预处理结果(宏被展开,头文件被包含)
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)合并进来
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下的可执行文件采用 ELFExecutable and Linkable Format 格式。一个可执行文件在内存中由多个段section/segment组成

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 用代码验证

// memory_layout.c - 验证程序内存布局
#include <stdio.h>
#include <stdlib.h>

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;
}
gcc memory_layout.c -o memory_layout
./memory_layout

预期输出中,地址从低到高排列为:.text < .rodata < .data < .bss < 堆 < 栈。

2.4 用 readelf 和 objdump 查看

# 查看ELF文件的段信息
readelf -S hello

# 反汇编 .text 段
objdump -d hello

# 查看符号表
nm hello

三、gdb调试

3.1 编译时添加调试信息

使用 -g 选项编译gdb才能将机器指令与源代码行号对应

gcc -g gdbuse.c -o gdbuse
gdb ./gdbuse

3.2 gdb常用命令

命令 缩写 作用 示例
run r 运行程序 run
break b 设置断点 b mainb 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调试

// gdbuse.c - 包含bug的程序
#include <stdio.h>
#include <string.h>

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);
}

调试步骤

# 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开头
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 - 多文件编译示例
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)

自动变量说明

变量 含义
$@ 当前目标文件名
$< 第一个依赖文件名
$^ 所有依赖文件名
# 使用Makefile
make              # 默认编译
make clean        # 清理编译产物
make -j4          # 4个线程并行编译

4.4 Makefile的执行逻辑

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 设计哲学

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 使用示例

// 不使用 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库

# 编译时链接 libwrapper.a
gcc -o myprogram myprogram.c -L. -lwrapper

# 或者在 Makefile 中
LDFLAGS = -L. -lwrapper

注意Wrapper函数名是对应系统调用的首字母大写形式。详见 附录A_Wrapper库参考


六、命令行参数

6.1 argc 和 argv

C程序的 main 函数可以接收命令行参数:

int main(int argc, char *argv[])
  • argcargument count参数个数包括程序名本身
  • argvargument vector参数字符串数组argv[0] 是程序名

6.2 实例cmdpar.c

// cmdpar.c - 命令行参数处理
#include <stdio.h>
#include <stdlib.h>

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;
}
gcc cmdpar.c -o cmdpar
./cmdpar hello world 123

预期输出

参数个数(含程序名): 4
argv[0] = ./cmdpar
argv[1] = hello
argv[2] = world
argv[3] = 123

6.3 参数传递原理

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

// hello.c - 最简单的C程序
#include <stdio.h>

void main() {
    printf("hello World\n");
}
gcc hello.c -o hello
./hello
# 输出hello World

7.2 sum.c -- 多文件项目

calc.h(头文件):

// calc.h - 函数声明
#ifndef CALC_H
#define CALC_H

double aver(double, double);
double sum(double, double);

#endif

sum.c(实现文件):

// sum.c - 求和函数实现
#include "calc.h"

double sum(double num1, double num2) {
    return (num1 + num2);
}

main.c(主文件):

// main.c - 主函数
#include <stdio.h>
#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;
}
# 分步编译
gcc -c sum.c -o sum.o
gcc -c main.c -o main.o
gcc sum.o main.o -o calc
./calc

八、知识关联


九、思考题

  1. 预处理阶段做了什么? #include <stdio.h> 在预处理后变成了什么?
  2. 为什么需要链接? 如果没有链接器,编程会变成什么样?
  3. .bss段为什么不在文件中占空间 系统如何保证未初始化全局变量为0
  4. 栈和堆的增长方向为什么相反? 这种设计有什么好处?
  5. Wrapper库的价值是什么 为什么课程不直接使用系统调用?
  6. if (a = 1)if (a == 1) 的区别? 如何用gdb发现这类bug

十、扩展阅读