C++编译流程

编译器(Compiler)

编译器是一种把人类写的源代码(C/C++)翻译成计算机能执行的机器码的程序。

在 C++ 中,常用的编译器有:

编译器平台说明
GCC(g++) Linux / WSL / Windows(MinGW) GNU 开源编译器,使用最广泛
Clang macOS / Linux 快速现代的 LLVM 编译器
MSVC(cl.exe) Windows 微软官方 C++ 编译器,集成在 Visual Studio 中
MinGW / MinGW-w64 Windows GCC 的 Windows 移植版

编译流程

C++ 程序从 .cpp 文件到 .exe 可执行文件,会经历 四个主要阶段
源代码 → 预处理 → 编译 → 汇编 → 链接 → 可执行程序

1 预处理(Preprocessing)

  • 处理 #include#define、条件编译等

  • 生成 .i 文件(展开后的源代码)

示例

#include <iostream>

#define DEBUG_MODE

#ifndef PI
#define PI 3.14
#endif

int main() {
#ifdef DEBUG_MODE
    std::cout << "Debug mode is ON" << std::endl;
#endif

    std::cout << "PI is " << PI << std::endl;
    return 0;
}

编译器会在这一步执行:

  • 处理 #include <iostream>:将 <iostream> 的内容插入代码中(即标准输入输出的定义);

  • 定义宏 DEBUG_MODE

  • 检查是否已经定义了宏 PI

    • 因为还没定义,#define PI 3.14 会生效;

  • 检查 #ifdef DEBUG_MODE 是否成立:

    • 成立 → 保留 std::cout << "Debug mode is ON" << std::endl;

  • 宏替换:将 PI 替换为 3.14

2 编译(Compilation)

  • 把预处理后的 .i 文件转成汇编语言(.s 文件)

  • 检查语法、类型、做优化

3 汇编(Assembly)

  • 把汇编代码 .s 翻译成目标代码 .o(或 .obj

4 链接(Linking)

  • 把多个 .o 文件和系统库连接起来

  • 解决函数调用、变量引用等链接

  • 最终生成 .exe 可执行文件

编译四个阶段命令(使用 g++

g++ -E main.cpp -o main.i      // -E:只执行预处理
g++ -S main.i -o main.s        // -S:把代码编译成汇编语言
g++ -c main.s -o main.o        //-c:把汇编代码翻译成目标文件(机器指令)
g++ main.o -o hello            // 把目标文件 main.o 和标准库链接成最终的可执行程序 main.exe

 或者一步到位

g++ -o hello main.cpp

示例 多文件项目示例

📁 项目结构

my_project/
├── main.cpp        // 主函数
├── math.h          // 头文件(声明函数)
├── math.cpp        // 实现文件(定义函数)d

📄代码

// math.h
#ifndef MATH_H
#define MATH_H

int add(int a, int b);
int subtract(int a, int b);

#endif

// math.cpp
#include "math.h"

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

// main.cpp
#include <iostream>
#include "math.h"

int main() {
    int x = 10, y = 5;
    std::cout << "x + y = " << add(x, y) << std::endl;
    std::cout << "x - y = " << subtract(x, y) << std::endl;
    return 0;
}

打开 WSL 终端,依次输入

# 创建项目目录
mkdir my_project
cd my_project

# 创建源文件(使用 nano 编辑器 【Ctrl + O】保存文件 【Enter】确认保存文件名  【Ctrl + X】退出编辑器)
nano main.cpp        # 创建主函数文件 
nano math.h          # 创建头文件,声明函数
nano math.cpp        # 创建实现文件,定义函数

# 编译多个 .cpp 文件为一个可执行程序
g++  -o app main.cpp math.cpp

# 运行程序
./app

Makefile

项目通常有多个源文件,手动写命令很繁琐,特别文件多时容易出错。Makefile可以自动、智能地管理编译流程,省时省力,减少错误。以上面的例子为例,假定最终生成的可执行文件是app,中间步骤还需要生成main.o和math.o两个文件。根据上述依赖关系,可以写出Makefile如下:

# 目标:生成可执行文件 app
app: main.o math.o
    # 使用 g++ 链接 main.o 和 math.o,生成可执行文件 app
    g++ -o app main.o math.o

# 目标:编译 main.cpp 为目标文件 main.o
main.o: main.cpp
    # 使用 g++ 编译 main.cpp,生成目标文件 main.o
    g++ -c main.cpp

# 目标:编译 math.cpp 为目标文件 math.o,加上math.h如果头文件变化就会重新编译math.o
math.o: math.cpp math.h
    # 使用 g++ 编译 math.cpp,生成目标文件 math.o
    g++ -c math.cpp

# 目标:清理编译生成的文件
clean:
    # 删除所有的目标文件和可执行文件
    rm -f *.o app

执行make,输出如下:

g++ -c main.cpp
g++ -c math.cpp
g++ -o app main.o math.o

使用隐式规则

我们把.o的规则删掉,也能正常编译

# 只保留生成app的规则:
app: main.o math.o
    g++ -o app main.o math.o
clean:
    rm -f *.o app

执行make,输出如下:

g++    -c -o main.o main.cpp
g++    -c -o math.o math.cpp
g++ -o app main.o math.o

在使用 make 时,如果没有显式地为 .o 文件(如 main.omath.o)定义规则,make 会自动应用内置的隐式规则来生成这些目标文件。这种机制使得 Makefile 更加简洁,避免了重复编写常见的编译命令。

但是由于 make 的隐式规则默认只根据目标文件和源文件的修改时间来判断是否需要重新编译,它并不会自动跟踪头文件(.h 文件)的变化。因此,即使修改了头文件,make 也可能不会重新编译依赖于该头文件的源文件(.cpp 文件)。

使用变量

当项目中有多个源文件时,使用变量可以使 Makefile 更简洁、易于维护。变量定义用变量名 = 值或者变量名 := 值,通常变量名全大写。引用变量用$(变量名),非常简单。比如上面的例子,可以写成:

OBJS = main.o math.o
TARGET = app

$(TARGET): $(OBJS)
	g++ -o $(TARGET) $(OBJS)

clean:
	rm -f *.o $(TARGET)

我们还可以用变量$(CXX)替换命令g++,在 Makefile 中,$(CXX) 是一个预定义的变量,表示 C++ 编译器的命令。默认情况下,$(CXX) 的值是 g++使用这个变量的好处是:

  • 可配置性您可以在命令行中通过 make CXX=clang++ 来指定使用 clang++ 作为编译器,而无需修改 Makefile。

  • 可移植性使用 $(CXX) 可以使您的 Makefile 更具可移植性,因为它允许用户根据需要选择不同的编译器。

OBJS = main.o math.o
TARGET = app

$(TARGET): $(OBJS)
	$(CXX) -o $(TARGET) $(OBJS)

clean:
	rm -f *.o $(TARGET)

引入自动变量(如 $@$^)进一步提高规则的通用性和可维护性。$@表示目标文件,$^表示所有依赖文件

OBJS = main.o math.o
TARGET = app

$(TARGET): $(OBJS)
	$(CXX) -o $@ $^

clean:
	rm -f *.o $(TARGET)

使用模式规则

 使用隐式规则可以让make在必要时自动创建.o文件的规则,但make的隐式规则的命令是固定的,对于xyz.o: xyz.cpp,它实际上是:

$(CXX) $(CFLAGS) -c -o $@ $<

如果你希望在编译过程中添加特定的命令(例如输出编译信息、使用特定的编译器选项等),则需要自定义模式规则:

OBJS = main.o math.o
TARGET = app

$(TARGET): $(OBJS)
	$(CXX) -o $@ $^

# 模式规则
%.o: %.cpp
	@echo "Compiling $<..."
	$(CXX) -c -o $@ $<

clean:
	rm -f *.o $(TARGET)

执行make,输出如下:

Compiling main.cpp...
g++ -c  -o main.o main.cpp
Compiling math.cpp...
g++ -c  -o math.o math.cpp
g++ -o app main.o math.o

自动生成依赖

隐式规则和模式规则,这两种规则都可以解决自动把.c文件编译成.o文件,但都无法解决.c文件依赖.h文件的问题。用编译器自动生成依赖流程:

  • 对每个.c(或.cpp)文件,生成对应的依赖文件(比如main.d),里面记录该源文件依赖了哪些头文件。

  • Makefile通过include包含这些.d文件。

  • 当头文件修改时,make发现依赖变化,会自动重新编译对应的.c文件。

# 列出所有 .cpp 文件
SRCS = $(wildcard *.cpp)

# 根据 SRCS 生成 .o 文件列表
OBJS = $(SRCS:.cpp=.o)

# 根据 SRCS 生成 .d 文件列表(依赖文件)
DEPS = $(SRCS:.cpp=.d)

# 目标可执行文件名
TARGET = app

# 默认目标,链接所有目标文件生成可执行文件
$(TARGET): $(OBJS)
	$(CXX) -o $@ $^

# 生成依赖文件 *.d(使用 g++ 的 -MM 生成依赖)
%.d: %.cpp
	rm -f $@; \
	$(CXX) -MM $< > $@.tmp; \
	sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.tmp > $@; \
	rm -f $@.tmp

# 模式规则:编译 .cpp 文件生成 .o 文件
%.o: %.cpp
	$(CXX) -c -o $@ $<

# 清理目标文件、依赖文件和可执行文件
clean:
	rm -rf *.o *.d $(TARGET)

# 引入所有自动生成的依赖文件
-include $(DEPS)

执行make,输出如下:

rm -f main.d; \
g++ -MM main.cpp > main.d.tmp; \
sed 's,\(main\)\.o[ :]*,\1.o main.d : ,g' < main.d.tmp > main.d; \
rm -f main.d.tmp
rm -f math.d; \
g++ -MM math.cpp > math.d.tmp; \
sed 's,\(math\)\.o[ :]*,\1.o math.d : ,g' < math.d.tmp > math.d; \
rm -f math.d.tmp
g++ -c -o math.o math.cpp
g++ -c -o main.o main.cpp
g++ -o app math.o main.o

通过include $(DEPS)我们引入math.dmain.d文件,但是这两个文件一开始并不存在,不过,make通过模式规则匹配到%.d: %.c,这就给了我们一个机会,在这个模式规则内部,用cc -MM命令外加sed.d文件创建出来。此时改动math.h,再次运行make,可以触发main.cpp的编译。

进一步的把源码文件(src目录)和生成的中间文件(如.o.d文件)分开放,比如把源代码放 src/,把编译生成的文件放 build/ 目录。

# 创建 src 目录
mkdir -p src

# 移动源文件到 src/
mv main.cpp src/
mv math.cpp src/
mv math.h src/

这样目录结构就会变成:

my_project/
├── Makefile
├── src/
│   ├── main.c
│   ├── math.c
│   └── math.h

然后更改Makefile

# 源文件目录
SRCDIR = ./src

# 生成文件目录
BUILDDIR = ./build

# 查找所有 src 目录下的 .cpp 文件
SRCS = $(wildcard $(SRCDIR)/*.cpp)

# 将 src/*.cpp 替换成 build/*.o
OBJS = $(patsubst $(SRCDIR)/%.cpp, $(BUILDDIR)/%.o, $(SRCS))

# 依赖文件列表 build/*.d
DEPS = $(patsubst $(SRCDIR)/%.cpp, $(BUILDDIR)/%.d, $(SRCS))

# 目标可执行文件名
TARGET = $(BUILDDIR)/app

# 默认目标:链接所有目标文件生成可执行文件
$(TARGET): $(OBJS)
	$(CXX) -o $@ $^

# 生成依赖文件 build/%.d
$(BUILDDIR)/%.d: $(SRCDIR)/%.cpp | $(BUILDDIR)
	@rm -f $@; \
	$(CXX) -MM $< > $@.tmp; \
	sed 's,\($*\)\.o[ :]*,\1.o $@ : ,g' < $@.tmp > $@; \
	rm -f $@.tmp

# 编译 .cpp 文件生成 .o 文件
$(BUILDDIR)/%.o: $(SRCDIR)/%.cpp | $(BUILDDIR)
	$(CXX) -c -o $@ $<

# 创建生成文件目录
$(BUILDDIR):
	mkdir -p $(BUILDDIR)

# 清理所有编译生成的文件和可执行文件
clean:
	rm -rf $(BUILDDIR) $(TARGET)

# 包含所有依赖文件
-include $(DEPS)

再执行make,得到输出

g++ -o build/app build/math.o build/main.o

补充:编译型语言 vs 解释型语言

C++ 属于典型的编译型语言:源代码需要经过完整的预处理、编译、汇编和链接,最终生成二进制可执行文件。Python属于解释型语言:源代码通过解释器(Interpreter逐行读取、逐行转换为机器码并执行,无需生成独立的可执行文件,执行时必须依赖解释器环境。

 C++(编译型语言)Python(解释型语言)
执行方式 先编译为可执行文件再运行 逐行解释执行
运行速度 通常较快,接近机器码执行速度 通常较慢,解释执行效率低
依赖环境 不依赖运行时解释器,仅需操作系统支持 运行时必须安装 Python 解释器
平台移植性 需针对目标平台重新编译 高,只要有解释器即可运行
内存管理 程序员手动分配和释放内存(使用 new/delete 或智能指针) 自动内存管理,内置垃圾回收机制(Garbage Collection)
类型系统 静态类型,变量类型在编译时确定,类型检查严格 动态类型,变量类型在运行时确定,更灵活但易出错
适合的应用场景 高性能要求场景,如系统开发、图形处理、游戏引擎 快速开发、脚本、数据分析、Web、人工智能等领域

 

 

参考:

1. Makefile教程

posted @ 2025-07-05 14:25  湾仔码农  阅读(136)  评论(0)    收藏  举报