cmake之旅(1)
1 构建的过程
我们先写一个简单的的程序:
#include <iostream>
int main()
{
std::cout << "Hello Cmake" << std::endl;
return 0;
}
上面程序会在控制台中输出:Hello Cmake
那么如何运行上面的代码呢?你可能会说,在vs(或者其他的IDE)中创建一个项目,创建一个main.cpp,把上面的代码复制进行,然后点击运行就好了呀。是的没错,那么我们思考一下,我们点击运行按钮到控制台输出结果,vs在这个过程中,帮我们“偷偷的”的做了哪些事情?
vs大概帮助我们做了以下内容:预处理、编译、优化、汇编、链接、生成可执行文件、 加载与调试、运行程序、退出与清理
不得不感叹这些IDE的方便性,只要点击一个按钮就做了那么多步骤。但是我们现在就是要“化简为繁”手动的做这些事情。亲自感受一下这个过程,这有利于我们后面对cmake的理解。
上面的内容可以分为两个过程:构建过程、运行过程。我们只讲一下构建的过程:
- 预处理
- 宏替换:所有使用 #define 定义的宏在代码中被替换为它们对应的值或代码片段。
- 文件包含:#include 指令包含的头文件会被直接插入到代码中。这意味着编译器会将这些头文件的内容复制到使用 #include 指令的地方。
- 条件编译:处理 #ifdef、#ifndef、#if 等条件编译指令。这些指令允许根据某些条件编译或忽略代码块。
- 去注释:预处理器会删除所有的注释,因为它们在编译阶段是不需要的。
- 编译
- 词法分析:编译器将预处理后的代码转换为一个个“记号”(token)。每个记号表示代码中的基本元素,如关键字、标识符、运算符、数字和符号。
- 语法分析:编译器检查代码的语法结构,确保它符合语言的语法规则。语法分析器生成一个抽象语法树(AST),这是源代码结构的层次化表示。
- 语义分析:编译器进一步检查代码的语义正确性,例如类型检查、变量和函数的作用域解析、函数参数匹配等。
- 中间代码生成:编译器将语法树转换为中间代码,这通常是一种接近机器语言但仍然与具体机器无关的代码形式,如三地址码或简单的指令集。
-
优化
中间代码可以被优化,例如移除多余的计算、循环展开、常量折叠、消除冗余代码等。这一步可以在中间代码层进行,也可以在生成机器代码后进行。 -
汇编
- 汇编代码生成(Assembly Code Generation):编译器将中间代码转换为汇编代码,这是一种特定于目标处理器的低级别代码。汇编代码仍然是人类可读的,但每条指令直接对应于机器指令。
- 机器代码生成(Machine Code Generation):汇编器将汇编代码转换为二进制的机器代码,这些代码是计算机直接可以执行的指令。这些机器代码通常存储在目标文件(.obj 或 .o 文件)中。
- 链接
- 符号解析(Symbol Resolution):链接器处理所有的外部符号引用,将每个函数调用和变量引用与其实际定义关联起来。比如,一个文件中调用的函数,可能在另一个文件中定义,链接器负责将这些调用和定义匹配。
- 重定位(Relocation):链接器调整每个目标文件中的地址和指针,使得它们在最终的可执行文件中是正确的。这一步包括调整代码和数据的内存地址,使得程序可以正确地在运行时访问它们。
- 库链接(Library Linking):如果你的程序依赖于静态库或动态库,链接器会将这些库的代码合并到最终的可执行文件中。对于静态库,库的代码被直接包含在可执行文件中;对于动态库,只会在运行时加载库的代码。
- 生成可执行文件:最终,链接器将所有的目标文件和库文件组合在一起,生成一个完整的可执行文件(如 .exe 文件),这个文件包含了所有必要的代码和数据。
2 手动构建
2.1 环境
- 系统:Ubuntu
- 编译器:gcc/g++
2.2 开始编译
- 创建文件:
touch main.cpp
将上述代码复制到其中并保存 - 预处理:
g++ -E main.cpp -o main.i
-E 选项告诉编译器只进行预处理,不进行后续的编译、汇编和链接步骤。
-o main.i 指定了预处理后的输出文件名为 main.i。如果不使用 -o 选项,预处理结果会输出到标准输出(通常是终端)
根据前面的讲解,我们在代码中加点内容(加一个宏,和注释),来验证上面的说法
#include <iostream>
#define NUM 12
int main()
{
//注释
std::cout << "Hello Cmake" << std::endl;
std::cout << NUM << std::endl;
return 0;
}
此时,重新运行上面的命令,你可以再文件夹下面看到一个main.i文件,可以打开mian.i,下拉到文件底部,里面生成了那些东西。你会看到头文件被加载了进来,宏被替换了,注释被去掉了
- 编译:
g++ -S main.i -o main.s
-S 选项表示将源代码编译为汇编代码。生成的 main.s 文件是汇编语言表示的代码,可以查看其中的低级指令 - 汇编:
g++ -c main.s -o main.o
-c 选项表示将汇编代码编译为目标文件而不进行链接。生成的 main.o 是二进制的机器代码,包含了程序的指令,但还没有链接成可执行文件。 - 链接:
g++ main.o -o main
链接阶段将目标文件与标准库链接,生成最终的可执行文件。 - 运行:
./main
经过上面的步骤,应该就可以成功的生成可执行文件,并运行起来。你可能已经发现了,这样很麻烦,而且现在只是一个main.cpp文件,就要那么多步骤,如果文件很多呢,比如增加add.h、add.cpp、de.h,de.cpp:
文件结构如下:
├── add
│ ├── add.cpp
│ └── add.h
├── de
│ ├── de.cpp
│ └── de.h
├── main.cpp
└── Makefile
add.h:
#ifndef _HEAD_ADD_
#define _HEAD_ADD_
int add(int a,int b);
#endif
add.cpp
#include "add.h"
int add(int a,int b)
{
return a+b;
}
de.h
#ifndef _HEAD_DE_
#define _HEAD_DE_
int de(int a,int b);
#endif
de.cpp
#include "de.h"
int de(int a,int b)
{
return b-a;
}
main.cpp
#include <iostream>
#include "add.h"
#include "de.h"
#define NUM 12
int main()
{
//注释
std::cout << "Hello Cmake" << std::endl;
std::cout << NUM << std::endl;
std::cout << add(10,2) << " " << de(2,10) << std::endl;
return 0;
}
构建过程:
g++ -c add/add.cpp -o add/add.o
g++ -c de/de.cpp -o de/de.o
g++ -c main.cpp -I add -I de -o main.o
g++ main.o add/add.o de/de.o -o main
./main
随着文件增多,会越来越麻烦,而且容易出错。
3 使用 Makefile 简化构建
3.1 环境
- 系统:Ubuntu
- 编译器:gcc/g++
- 构建工具:make
3.2 编写Makefile
使用makefile构建,首先要在自己的电脑上安装make(网上有很多方法,不再赘述),然后编写Makefile文件。编写Makefile文件的过程和上面手动构建的过程类似,具体内容有详细注释:
增加Makefile文件
├── add
│ ├── add.cpp
│ └── add.h
├── de
│ ├── de.cpp
│ └── de.h
├── main.cpp
└── Makefile
Makefile文件:
# 定义编译器
CXX = g++
# 定义编译选项
CXXFLAGS = -Wall -g
# 定义目标文件名
TARGET = main
# 自动寻找当前目录及子目录下的所有.cpp文件
SRCS = $(wildcard */*.cpp) $(wildcard *.cpp)
# 自动生成对应的.o文件
OBJS = $(SRCS:.cpp=.o)
# 包含的头文件目录
INCLUDES = -Iadd -Ide
# 默认目标:生成可执行文件
$(TARGET): $(OBJS)
$(CXX) $(CXXFLAGS) $(INCLUDES) -o $(TARGET) $(OBJS)
# 生成对象文件的规则
%.o: %.cpp
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
# 清理目标
clean:
rm -f $(OBJS) $(TARGET)
直接make就可以生成对应的文件了,而且比之前更加的通用一点。
相信你通过这个一步一步走下来,肯定发现了它的不足:
- 如果项目不断的增大,每个源文件的依赖不同,使用makefile管理这些依赖,就变得很困难;
- 后期维护的困难大大的增加(每次都要理清这里面的依赖关系)
- 不能跨平台,比如此时想将上面的代码在win上运行,使用makefile很困难:
- 命令和工具差异
Makefile 中使用的 g++、rm 等命令通常在类 Unix 系统(如 Linux 或 macOS)上可用,而 Windows 上的命令行工具有所不同。
rm 是 Linux 下的命令,用于删除文件,但在 Windows 上并不存在。Windows 使用 del 或 erase 来删除文件。 - 路径分隔符
Makefile 中使用的路径分隔符是正斜杠 /,而在 Windows 上,路径分隔符通常是反斜杠 \。虽然某些工具(如 g++)在 Windows 上也支持正斜杠,但这并不是通用的标准。 - 构建工具
make 是 GNU 工具链的一部分,默认在 Windows 上不可用。虽然你可以通过安装 MinGW、Cygwin 或 MSYS2 等工具在 Windows 上使用 make,但这依然需要额外的配置 - 环境变量
Windows 上的环境变量设置和类 Unix 系统不同,可能需要调整编译器路径、库路径等。
有没有一个更好方法,既不用繁琐的写着构建过程,还可以自动处理依赖关系,自动寻找第三方的库?
4 使用cmake构建
CMake 是一个跨平台的构建系统生成器,它可以根据简单的配置文件(CMakeLists.txt)自动生成适用于多种构建系统的文件,比如 Unix 系统上的 Makefile,Windows 上的 Visual Studio 项目文件等。
优势:
- 自动处理依赖关系:CMake 自动管理源文件之间的依赖关系,只重新编译发生变化的部分。
- 跨平台支持:CMake 可以生成适用于不同操作系统的构建文件,从而实现真正的跨平台构建。
- 轻松集成第三方库:CMake 可以帮助你自动查找和集成第三方库,而不需要手动配置路径和编译选项。
- 社区资源丰富:有很丰富的社区和学习资料
4.1 环境
- Ubuntu:
安装:sudo apt-get install cmake
- win
https://cmake.org/download/
自行百度如何安装。
4.2 编写CMakeLists.txt
# 设定最低版本要求
cmake_minimum_required(VERSION 3.10)
# 定义项目名称
project(HelloCMake)
# 设置编译选项
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# 添加源文件
set(SOURCES
main.cpp
add/add.cpp
de/de.cpp
)
# 包含头文件目录
include_directories(add de)
# 生成可执行文件
add_executable(main ${SOURCES})
执行以下命令,就可以生成可执行文件了:
mkdir build
cd build
cmake ..
make
非常的简洁明了,不用我们手动管理依赖,也可以实现跨平台。通过上面的内容,应该对cmake有了基本了解。