C/C++编译相关的学习
前言
前文中我们介绍了 C/C++ 的基础语法并进行了简单的项目回顾,那么我们的代码怎么能运行起来并验证我们的逻辑呢?此时就需要编译我们的 C/C++ 代码并运行才能验证。
例如我的电脑是 Mac 平台,我使用 VSCode 作为我的编辑器,那么我需要安装 C/C++ 的扩展,那么在运行的时候就会让我们选择编译器。
当然这其实是快捷方式,其实就是对应的 Clang 和 gcc 的命令执行的快捷方式,我们可以在 VSCode 的 task.json 中看到对应的配置
其实参数的拼接就是对应的命令生成,我们完全可以不使用快捷方式,直接使用命令也是一样的。
那么对 C/C++ 的编译和运行有疑问的同学可能更多问号呢?为什么要编译之后才能运行呢?编译的原理和过程是什么呢?Clang 和 gcc又是什么呢?有其他的编译方式吗?如果是工程化的项目怎么构建编译组呢?有没有相关的示例详细演示呢?
那么我们就带着这些疑问往下看,本篇文章将围绕 C/C++ 编译展开,详细探讨编译过程的基本原理、常用编译工具的特点与适用场景,以及传统编译与交叉编译的差异及其实际应用。同时,我们还会介绍 Makefile 和 CMake 等构建系统,帮助开发者更高效地管理和构建 C/C++ 项目。
一、C/C++为什么要编译,编译与运行过程
1.1 为什么 C/C++ 需要编译才能运行?
底层硬件执行的本质
- 计算机的 CPU 只能直接理解机器码(由二进制指令组成),而 C/C++ 作为高级语言,需要通过编译将其转换为机器码。
- 硬件依赖:不同的 CPU 架构(如 x86、ARM)使用不同的指令集。例如,x86 的 mov eax, 1 与 ARM 的 mov r0, #1 完成相同操作但指令格式完全不同。
- 操作系统适配:系统调用(如文件读写、内存分配)在不同操作系统(Windows/Linux)中的实现方式差异巨大。例如,Windows 通过 CreateFile 创建文件,而 Linux 使用 open 系统调用。
所以这也是为什么我们的 C/C++ 代码要再不同的平台 CPU 上运行的时候需要编译对应的平台和系统的原因了。
那为什么我的 Java 只需要编译一次就可以在不同的平台使用?为什么我的 Python 代码直接都不需要编译?
问的很好,这就涉及到 编译型语言 vs 解释型语言 的问题了。
1.2 编译型语言 vs 解释型语言
这里还是用经典的 C/C++ Java Python 来举例:
c
// C++ 代码(需编译为机器码)
#include <iostream>
int main() {
std::cout << "Hello World" << std::endl;
return 0;
}
bash
# Python 代码(由解释器逐行执行)
print("Hello World")
typescript
// Java 代码(编译为 .class 字节码)
public class Main {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
Java 通过两层抽象实现跨平台:
- 字节码中间层:编译器 (javac) 生成 .class 文件,包含与平台无关的字节码。
- JVM 适配器:每个平台实现自己的 JVM,将字节码转换为本地机器码。
比如 Java 的 FileInputStream 在 Windows 和 Linux 上通过不同的 JVM 实现调用底层系统 API。
所以 C/C++ 直接面向硬件和操作系统开发,而 Java 面向 JVM 开发。
这也进一步再次解释,为什么 C/C++ 可以操作底层硬件而 Java 不太行的原因了。
而 Python 这种解释型语言又不一样了。
markdown
# 表面上的"直接运行"
python script.py
# 实际执行过程:
1. Python 解释器逐行解析源码 → 生成字节码(.pyc)
2. Python 虚拟机(PVM)执行字节码
Python 在首次运行时生成 .pyc 字节码文件(类似 Java 的 .class),后续运行直接使用缓存字节码。这种方式带来了更大的灵活性,但通常执行效率低于编译语言。所以解释执行比直接运行机器码慢 10-100 倍。
当然了,现代许多语言(如 Go、Rust)采用了一种混合模式,结合了编译和解释的优点,允许快速开发和高效执行。这里就不展开。
总结就是:
C/C++ 的编译过程是连接高级语言与底层硬件的关键桥梁,其直接生成机器码的特性带来了性能优势,但也牺牲了跨平台便利性。相比之下,Java 和 Python 通过虚拟机或解释器抽象硬件细节,以运行时性能为代价简化开发流程。理解不同语言的编译与执行机制,有助于在性能、开发效率和跨平台需求之间做出合理权衡。
说人话:Java 和 Python 把跨平台相关的脏活累活全帮你干了(JVM 和解释器),但是 C/C++ 要多平台运行这脏活累活你得自己干!
1.3 C/C++编译与运行的过程
编译过程通常分为以下几个主要阶段:
预处理:处理代码中的宏定义、条件编译和包含文件等指令。预处理器会生成一个扩展后的源代码文件,准备好进行编译。
大致处理内容:
- 展开宏定义(#define PI 3.14 → 替换所有 PI)
- 包含头文件(#include <stdio.h> → 插入头文件内容)
- 条件编译(#ifdef DEBUG → 选择性保留代码)
- 输入输出:.c → .i(预处理后的文本文件)
编译:将预处理后的源代码转换为中间代码。这个阶段主要负责语法分析和语义分析,生成一个中间表示。
核心任务就是将高级语言转换为汇编代码。
ini
// 原始代码
int sum = 0;
for (int i=0; i<100; i++) {
sum += i;
}
转为:
ini
; GCC 优化后(-O2)
mov eax, 4950 ; 直接计算结果 0+1+2+...+99 = 4950
汇编:将汇编代码转换为目标文件(机器代码)。这个目标文件通常包含二进制代码,但尚未链接到其他库或模块。
目标文件格式:
- Windows:.obj(COFF 格式)
- Linux:.o(ELF 格式)
链接:将一个或多个目标文件与所需的库文件结合,生成最终的可执行文件。链接器负责解决符号引用,确保所有依赖的函数和变量都可以被正确引用。
- 符号解析:将函数调用(如 printf)与库中实现地址绑定。
- 静态链接:将库代码直接复制到可执行文件中(文件较大,但独立运行)。
- 动态链接:运行时通过 .dll(Windows)或 .so(Linux)加载库(节省磁盘和内存)。
运行:加载可执行文件到内存。执行代码,处理输入输出等。
1.4 编译产物对比
在上一篇小章节中我们讲到链接动态库和链接静态库那么他们又有什么区别呢?
静态库是一个包含多个目标文件的集合(通常为 .a 或 .lib 文件),在编译时将其代码直接嵌入到最终生成的可执行文件中。
链接时间:链接发生在编译阶段,所有的目标文件和静态库会在编译时链接到程序中。可执行文件的大小通常较大,因为它包含了所需的所有代码。
更新与兼容性:更新静态库需要重新编译所有使用该库的程序。如果库的代码发生变化,开发者必须重新构建和分发所有依赖于该静态库的可执行文件。
内存管理:每个使用静态库的可执行文件都有它自己的库副本,增加了内存使用量,尤其是当多个程序使用同一个静态库时。
运行时性能:因为代码在编译时已经链接到可执行文件中,运行时性能通常较好,不会有额外的动态链接开销。
动态库是一个独立的文件(通常为 .dll、.so 或 .dylib),在程序运行时由操作系统动态加载。它的代码在运行时被链接,而不是在编译时嵌入到可执行文件中。
链接时间:链接发生在运行时。程序在执行时会根据需要加载动态库。这使得可执行文件相对较小,因为它不包含动态库的代码。
更新与兼容性:更新动态库时,无需重新编译依赖于该库的程序。只需替换动态库文件即可,这带来了更好的灵活性和可维护性。但要注意,版本兼容性可能导致运行时错误。
内存管理:多个程序可以共享同一个动态库的代码,减少了内存占用。这种共享机制使得动态库在系统资源的管理上更有效。
运行时性能:动态库在运行时需要加载和链接,可能会在启动时产生一定的性能开销。然而,现代操作系统的优化可以减少这种开销。
以 gcc 的编译为例:
csharp
# 静态链接
gcc main.c -static -o app_static # 生成包含所有库的可执行文件(约 1MB→20MB)
# 动态链接
gcc main.c -o app_dynamic # 默认动态链接(依赖系统 libc.so)
总结:静态库提供了更高的执行效率和简单的部署,但维护和更新不够灵活;动态库则在内存使用和更新方面具有优势,但引入了运行时的复杂性和潜在的版本兼容性问题。
在我们客户端常年需要更新的场景下当然毫无疑问的选择动态库。
二、C/C++ 的常用编译工具
在上一个章节中,我们提到 C/C++ 代码需要编译才能在不同平台上运行。为了实现这一目标,我们依赖各种编译工具。这些工具功能强大,使得编译过程变得更加高效和便捷。在这一章节中,我们将重点介绍几种常用的 C/C++ 编译工具,并提供一些基本的使用示例。
GCC(GNU Compiler Collection):
概述和特点
GCC 是一个开源的编译器套件,最早由 GNU 项目开发。它不仅支持 C 和 C++,还支持其他多种程序设计语言(如 Fortran、Ada、Go 等)。
特点:
- 跨平台支持:可以在多种操作系统(如 Linux、Windows、macOS)和硬件架构上运行。
- 优化能力:提供多种优化选项,以提高生成代码的执行效率。
- 活跃的社区:作为开源项目,有大量的开发者支持和持续更新。
- 支持的语言和平台
- 语言:C、C++、Fortran、Ada、Go、Objective-C 等。
- 平台:几乎支持所有主要的操作系统和硬件架构,如 x86、ARM、MIPS、PowerPC 等。
常用命令:
ini
# 基础编译(C程序)
gcc -o hello hello.c
# 编译多个 C 源文件
gcc -o my_program file1.c file2.c
# 启用C++17标准与最高优化
g++ -std=c++17 -O3 -o app main.cpp utils.cpp
# 生成预处理文件(调试宏展开)
gcc -E hello.c -o hello.i
# 生成汇编代码(查看编译优化效果)
gcc -S -O2 hello.c -o hello.s
# 静态链接库(避免动态库依赖)
gcc -static -o server server.c -lpthread
# 交叉编译ARM程序
arm-linux-gnueabi-gcc -march=armv8-a -o arm_app main.c
Clang
特点和优势:
Clang 是 LLVM 项目的一部分,旨在提供高效的编译器前端。
特点:
- 编译速度:Clang 通常在编译速度上优于 GCC,尤其是在增量编译时。
- 错误提示:提供更清晰、详细的编译错误和警告信息,帮助开发者快速定位问题。
- 模块化设计:Clang 的架构使其容易扩展和集成到其他工具(如 IDE)。
- 与 LLVM 的集成:Clang 与 LLVM 紧密集成,利用 LLVM 的优化和代码生成能力。
常用命令:
ini
# 编译单个 C 源文件
clang -o my_program my_program.c
# 编译多个 C 源文件
clang -o my_program file1.c file2.c
# 基础编译(带彩色错误提示)
clang -fcolor-diagnostics -o demo demo.c
# 启用C++20标准与地址消毒检测
clang++ -std=c++20 -fsanitize=address -g -o app main.cpp
# 生成LLVM中间表示(IR)
clang -S -emit-llvm -O2 hello.c -o hello.ll
# 增量编译加速(需CMake 3.16+支持)
cmake -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_CLANG_TIDY=clang-tidy ..
# 交叉编译Android ARMv8程序
clang --target=aarch64-linux-android21 -o android_app main.c
MSVC(Microsoft Visual C++)
概述:
MSVC 是微软的 C/C++ 编译器,通常与 Visual Studio 一起使用,是 Windows 平台上的主要开发工具。
提供的编译器和工具链:
- 工具链:包括编译器(cl.exe)、链接器(link.exe)、库(如 STL)和调试器(如 WinDbg)。
功能:
- 提供强大的 IDE 支持,如代码智能感知、调试和性能分析工具。
- 优化 Windows 应用程序的能力,特别是对 Windows API 和微软平台的支持。
常用命令:
ruby
# 编译单个 C 源文件
cl my_program.c
# 编译 C++ 源文件
cl /EHsc my_program.cpp
# 编译并生成可执行文件
cl /Fe:my_program.exe my_program.c
# 启用C++20与调试符号
cl /EHsc /std:c++20 /Zi /Fe:app.exe main.cpp
# 生成PDB调试文件
link /DEBUG /OUT:app.exe main.obj utils.obj
# 使用Windows SDK头文件
cl /I "C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\ucrt" app.c
其他工具:
TinyCC(TCC)
TinyCC 是一个轻量级的 C 编译器,特点是快速编译和小巧。它的主要目标是速度,而不是生成高效代码。
适用场景:适合于快速测试和脚本语言集成,但对于大型项目的优化和复杂性支持较弱。
Intel C++ Compiler
Intel C++ Compiler 是由英特尔开发的编译器,专为优化在英特尔架构上运行的应用程序设计。
特点:
- 提供针对 Intel 处理器的高级优化能力,适合需要高性能计算的应用(如数值计算、科学计算等)。
- 支持 OpenMP 和 Intel TBB(Threading Building Blocks)等多线程编程模型。
总结:
三、C/C++ 传统编译与交叉编译
- 传统编译
定义与特点
定义:在相同架构的宿主平台(Host)上编译代码,生成目标平台(Target)的可执行文件,且 Host = Target。
示例:在 x86_64 架构的 Linux PC 上编译运行于同一 PC 的程序。
优点:
- 环境一致:无需处理跨平台依赖,调试工具(如 GDB)可直接使用。
- 工具链简单:使用系统默认编译器(如 gcc、clang)即可完成构建。
- 交叉编译
定义与核心挑战
定义:在宿主平台(Host)上编译代码,生成目标平台(Target)的可执行文件,且 Host ≠ Target。
示例:在 x86 Linux PC 上编译运行于 ARM 嵌入式设备的程序。
核心挑战:
- 指令集差异:Host(如 x86)与 Target(如 ARM)的 CPU 指令集不同。
- 系统环境差异:目标平台的 OS 内核、系统库(如 glibc)、ABI(应用二进制接口)与宿主环境不兼容。
- 交叉编译器的选择
常用工具链:
这里以我们常用的 Linux 系统来编译为例:
arm-linux-gnueabi-gcc:用于编译适用于 ARM 架构的 Linux 系统。
aarch64-linux-gnu-gcc:用于 ARM 64 位架构。
mips-linux-gnu-gcc:用于 MIPS 架构。
静态链接:将库代码打包进可执行文件(-static),避免目标平台依赖缺失。
csharp
arm-linux-gnueabi-gcc -static -o app main.c # 生成无动态依赖的可执行文件
动态链接:依赖目标系统的动态库(.so 或 .dll),需确保库版本匹配。
css
arm-linux-gnueabi-gcc -o app main.c -L/path/to/target-libs -lfoo
手动指定工具链
arm-linux-gnueabi-gcc -o hello_arm hello.c
需要考虑的方面:
CPU 架构:了解目标平台的 CPU 架构,以便选择合适的交叉编译器和优化选项。
OS 版本:确认目标平台的操作系统类型及其版本,以便进行系统调用和库函数的正确使用。
ABI 规范:确保编译出来的代码符合目标平台的 ABI 规范,避免在链接和执行过程中出现不兼容的问题。
总结:
理解传统编译和交叉编译的区别,以及在交叉编译中需要注意的目标平台分析和库适配问题,对于开发跨平台应用程序至关重要。通过合理选择编译工具和链接库,可以有效地提高程序的兼容性和性能,确保应用程序能够在目标平台上正确运行。
由于篇幅的问题这里主要是先了解概念,具体的示例后面会单独出文章细说。
四、Makefile与CMake构建系统
说到构建系统我们之前遇到的都是一个文件一个文件的编译,如果是大型项目分为多个文件,甚至多个模块,我们怎么编译呢?
我们举一个简单的例子,加入项目的结构如图:
MyProject/ ├── main.c ├── utils.c ├── utils.h
main.c:
arduino
#include <stdio.h>
#include "utils.h"
int main() {
printf("Sum: %d\n", add(3, 4));
return 0;
}
utils.c:
arduino
#include "utils.h"
int add(int a, int b) {
return a + b;
}
utils.h:
arduino
#ifndef UTILS_H
#define UTILS_H
int add(int a, int b);
#endif
那么我们打开 VSCode 直接运行 Main :
因为之前我们说过,VSCode 的设置中,默认就是一个文件的配置,当然我们可以已手动的使用 GCC 编译是可行的。
css
test_build % gcc -o app main.c utils.c
我们输出位 app 的编译产物,需要两个目标文件的参与,手动的指定下就可以啦,编译的产物和运行的结果如图:
但是如果又更多的文件呢?有更多的文件夹呢?难道也要一个一个的写吗?有没有更方便的工具呢?
有,常见的有 Makefile 和 CMake 两个构建工具:
编写 Makefile
在 MyProject 目录下创建一个名为 Makefile 的文件
makefile
# 指定使用的编译器
CC = gcc
# 指定编译选项
CFLAGS = -Wall -g
# 列出所有的目标对象文件
OBJ = main.o utils.o
# 最终生成的可执行文件名
TARGET = app
# 默认目标
all: $(TARGET)
# 生成可执行文件
$(TARGET): $(OBJ)
$(CC) $(CFLAGS) -o $@ $^
# 编译源文件为目标文件
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# 清理生成的文件
clean:
rm -f $(OBJ) $(TARGET)
规则如下:
- CC: 指定使用的编译器,这里是 gcc。
- CFLAGS: 编译选项,-Wall 启用所有警告,-g 添加调试信息。
- OBJ: 列出所有的目标文件。这里是 main.o 和 utils.o。
- TARGET: 最终生成的可执行文件名称,这里是 app。
- all: 默认目标,此目标会在运行 make 时被执行。
依赖关系:
- <math xmlns="http://www.w3.org/1998/Math/MathML"> ( T A R G E T ) : (TARGET): </math>(TARGET):(OBJ) 表示 app 目标依赖于 main.o 和 utils.o。
- %.o: %.c 表示将所有 .c 文件编译为对应的 .o 文件。
- clean: 清理目标,用于删除生成的对象文件和可执行文件。
我们在 Makefile 所在的目录运行 make 即可生成对应的编译产物 app :
命令与执行效果如下:
那么如果是 CMake 如何配置呢?
在 MyProject 目录下创建一个名为 CMakeLists.txt 的文件。
scss
# 设置最低 CMake 版本
cmake_minimum_required(VERSION 3.10)
# 设置项目名称
project(test_build C)
# 添加可执行文件
add_executable(app main.c utils.c)
可以看到 CMake 的配置是不是对比直接使用 Makefile 来说简单很多了呢?
cmake_minimum_required(VERSION 3.10): 指定所需的最低 CMake 版本。
project(test_build C): 指定项目名称为 MyProject,并定义它是一个 C 项目。
add_executable(app main.c utils.c): 创建一个名为 app 的可执行文件,并指定源文件 main.c 和 utils.c。
当然其实有很多其他的命令,后期单独讲讲 CMake,因为我后期基本上都是用 CMake 来构建了。
然后我们在终端中开始输入命令
bash
# 1.导航到项目目录
cd path/to/test_build
# 2.创建一个构建目录
mkdir build
cd build
# 3.运行 CMake 以生成构建系统文件:
cmake ..
# 4.使用 Makefile 来编译项目,生成名为 app 的可执行文件
make
可以看到对应的 build 文件夹:
所以大家都明白了,当你在构建目录运行 make 命令时,实际上是使用生成的 Makefile 来执行编译过程。CMake 只是一个中间层,帮助您自动化和管理这个过程
过程如下:
虽然 CMake 默认生成 Makefile,但它的真正价值在于跨平台的支持和灵活性,允许开发者专注于代码,而不必过于关注构建过程的细节。
总结就是通过自定义 Makefile 我们快速的把复杂的项目构建出来,但是有时候 Makefile 定义比较复杂,我们可以通过 Makefile 这个工具更快速方便的生成 Makefile 配置,再去编译就更快速方便了。
总结
本篇我们简单阐述,为什么要编译,对比其他语言的编译,怎么编译,怎么跨平台设备编译,以及使用什么工具编译和相应的简单命令或示例。
C/C++ 编译是将高级逻辑转化为机器指令的精密过程,其核心价值在于
性能最大化,通过静态优化直接生成高效机器码。
**硬件控制力:**直接操作底层资源(内存、寄存器、系统调用)。
**平台适配性:**通过交叉编译实现代码的多架构部署。
编译工具的使用可以参考:
对应 Makefile 和 CMake 的对比,感觉 Makefile 如同机械钟表,精确可控但维护成本高。CMake 更像智能手表,用声明式语法隐藏复杂性。
所以一般我们在开发 Android 这种客户端 Native 功能的时候都是使用 CMake 来构建了,而通常一些开源库的作者也会直接提供 Makefile 给我们编译,我们只需要修改对应的平台参数就可以编译出对应的产物。
后面的文章我会对各种常用场景下的各中编译做出介绍,并且详细的给出各种开源库如何编译的相关示例。相关文章大致规划了十几篇文章,有兴趣的可以关注专栏。
那么今天就这样了,如果我的文章有错别字,不通顺的,或者代码、注释、有错漏的地方,同学们都可以指出修正。
如果感觉本文对你有一点的启发和帮助,还望你能点赞
支持一下!
Ok,完结撒花。