From:从零Makefile落地算法大项目,完整案例教程 — 杜佬牛逼🐂🍺
为什么要学makefile:
- 相比cmake,Makefile更加轻量简洁,侵入性低,掌控力强,语法更少更简单;
- 使用Makefile你可以进行更细粒度的掌控,虽然cmake简化了这些,但是对于大型项目,细节的掌控是必须的;
- 对于算法落地,我们会面临各种库包,理清楚非常有利于降低问题的发生;
准备环境
- VSCode (Visual Studio Code) 作为IDE,(VSCODE YYDS)
- 安装VSCode的C++插件
- 准备Linux系统(Ubuntu),推荐使用VSCode的SSH插件远程连接服务器(另一个电脑)进行开发。本地电脑可以是windows/mac
- 熟悉C++的基本语法,我们主讲Makefile但是会有C++编码部分(不多)
目录
1. g++指令介绍
1.1 g++/gcc是什么,有什么区别
g++和gcc都是gnu推出的cpp编译器,时代不同g++和gcc都可以进行cpp编译g++和gcc一样,都属于driver,即驱动编译,他会调用cclplus/ccl/ld/as等指令实现编译链接等工作,他们俩只是默认选项上的处理不同- 这里我们采用
g++而不是gcc g++等价于gcc -xc++ -lstdc++ -shared-libgcc参考:https://www.zhihu.com/question/20940822
1.2 g++的编译过程
4种情况,注意指令的大小写很重要
预处理:
g++ -E main.cpp -o main.i- 汇编:
g++ -S main.i -o main.s - 编译:
g++ -c main.s -o main.o - 链接:
g++ main.o -o main.bin
g++ 可以允许跨过中间步骤,其结果是等价的,例如:
g++ -S main.cpp -o main.sg++ main.s -o main.bing++ main.cpp -o main.bin
比较常用的是 编译—链接:
- 编译:代码编译到二进制:
g++ -c main.cpp -o main.o - 链接:多个二进制链接成执行程序:
g++ main.o -o main.bin|
|
| —- |
|
|
|
|
|
|
2. C++编译链接 / 编译时和运行时
2.1 C++编译链接流程图
2.2 C++的声明和实现
:::info 声明代表一个函数的符号或者ID,实现则代表函数的具体执行的代码 :::
int add(int, int);int add(int a, int b);
声明不关心参数名称是什么,也不关心返回值是什么,也就是说int add(int a, int b)和int add(int, int)是一样的。
int add(int a, int b)
{
return a + b;
}
2.3 C++的编译过程-案例
2.3.1 代码结构,main.cpp和test.cpp

在main.cpp中有个函数声明:int add(int a, int b);,具体实现在test.cpp中。
2.3.2 main.cpp 和 test.cpp 的汇编代码


从两者的汇编代码,可以看到
main.s里面没有add函数的具体实现,只有call _Z3addii操作;add的具体实现在test.s里面
2.3.3 带有命名空间时的名字编码
2.4 C++编译过程
关于编码的结论是:
- c++中的函数、符号、变量会被编码
- 函数的编码关心的是:函数名称、所在命名空间、参数类型
- 函数的编码不关心的是:返回值类型
关于编译的结论是:
调用一个函数,会生成call 函数名称的代码,没有函数的具体实现。具体实现是在链接阶段。
2.5 C++链接过程
2.6 C++实际的链接过程
:::info
任何链接的符号(例如**add**),会在所有链接的对象中查找实现,例如这里在 **test.o**、**lib3rd.so**、**libpkg.a**中找
如果没找到,则**undefined reference _Z3addii**;
如果找到多个,则**multiple definition of add**;
任何符号的实现全局只能有一个。
:::
2.7 若add函数在动态库lib3rd.so中时
:::info
如果链接的符号在**so**中,**out.bin**会引用这个符号的**so**文件,在运行时动态加载**lib3rd.so**后,再调用**add**函数。
:::
2.8 若add函数在静态库,libpkg.a中时
:::info
如果链接的符号在**a**中,out.bin会将add的实现代码完整复制到**out.bin**中,在运行**out.bin**时,不需要**libpkg.a**。
:::
2.9 编译链接成一个完整程序的过程
2.10 C++链接时,查找so文件、a文件方式的方式
g++ -lpkg -l3rd main.o -o out.bin
当程序链接时,如何决定链接的是哪个**so**、**a**文件呢?
是按照如下依据来的:
g++ -lname:表示链接一个动态库或者静态库(.soor.a),名字是libname.so/libname.ag++ -Lfolder:表示配置一个动态/静态库的查找目录g++查找so/a文件的地方有3个,按照下列优先顺序查找第一顺序:应用程序的当前目录(当前目录不同于程序所在目录)
- 第二顺序:
out.bin中储存的rpath(run path)。readelf -d out.bin指令可以查看文件的runpath信息。如果该选项指定了依旧失效,说明依赖的so文件还存在更多依赖,,却没有在其他目录明确(常用) - 第三顺序:环境变量指定的目录(例如LD_LIBRARY_PATH)
2.12 C++编译时,头文件的查找方式
g++ -Ifolder:这里是小写的L,不是大写的I,**-lfolder**
当程序编译时,头文件该如何查找呢:g++ -Ifolder:表示配置一个头文件查找目录
当**#include "path/name.hpp"**使用双引号时:
编译器会在当前文件的目录下查找path/name.hpp,例如我们在/data/a.cpp中写了#include "path/name.hpp,最终确认的路径是/data/path/name.hpp
当#include
- 第一顺序:以
g++ -I配置的路径查找,例如g++ -I/data/folder,确认路径是:/data/folder/path/name.hpp,对所有路径都进行测试,找到为止 - 第二顺序:
g++内置的系统路径,一般是/usr/include等等,g++ -print-search-dirs可以打印出来 第三顺序:系统环境变量配置的路径,例如:
C_INCLUDE_PATH, CPP_INCLUDE_PATH3. Makefile基础
3.1 Makefile基础-解决的问题是什么
编译代码是一个很耗时的事情尤其是代码量大、CPU差时(边缘端)。参考官方文档,查看更多定义:http://www.gnu.org/software/make/manual/make.html
make指令执行时,默认会查找当前目录下的makefile、Makefile文件作为代码文件,当然也可以make -f abc的方式,指定make运行的Makefile代码Makefile主要解决的问题是,描述生成依赖关系,根据生成和依赖文件修改时间新旧决定是否执行command。可手动调用g++进行编译,重点是,如果每次编译,都是全体代码参与,对于没有修改部分的代码编译,是浪费时间。项目文件越多,这个问题越严重。而Makefile帮我们解决这个问题- Makefile的重点有:描述依赖关系、command(生成文件的指令)
我们只需要学习Makefile的基本操作足以应付项目需求即可,并不需要学习全部语法
3.2 Makefile基础-代码域
3.3 Makefile基础-语法
数据类型,字符串和字符串数组
- 定义变量,
var := folder,定义变量var,为string类型,值是folder - 定义数组,
var := hello world folder,定义变量var,为数组类型,值是["hello", "world", "folder"] - 定义的方式有多种
=赋值var = folder基本赋值,Makefile全部执行后决定取值(不常用):=赋值var := folder基本赋值,当前所在位置决定取值(常用)?=赋值var ?= folder如果没有赋值,则赋值为folder+=赋值var += folder添加值,在var后面添加值。可以认为数组后边增加元素append,var := hello, var += world,结果是hello world,中间有空格
$(var),${var},在这个位置解释为var的值,例如:var2 := $(var)$(func param),调用Make提供的内置函数- 例如:
var := $(shell echo hello),定义var的值为执行shell echo hello后的输出 - 例如:
$(info $(var)),直接打印var变量内容 - 例如:
var := $(subst a, x, aabbcc),替换aabbcc中的a为x后赋值给var,结果xxbbcc - 逻辑语法:
ifeq、ifneq、ifdef、ifndef
|ifeq($(var), depends) name := hello endif
|
|
| —- | —- |
- 例如:
生成项可以没有依赖项,那么如果该生成项文件不存在,command将永远执行
3.2 依赖关系定义

第一次执行
make a.o时,由于a.o不存在,执行了command- 第二次执行
make a.o时,由于a.cpp时间没有比a.o新,打印a.o is up to date,不需要编译 -
3.3 编译和链接结合起来

定义好依赖后,
make out.bin,会自动查找依赖关系,并自动按照顺序执行command- 这是
makefile为我们解决的核心问题,剩下就是如何玩的更方便罢了。比如自动检索a.cpp、b.cpp,自动定义a.o依赖a.cpp。等等3.4 总结
- 变量赋值有4种方式
var = 123,var := 123,var ?= 123,var += 123。其中var := 123常用,var += 123常用 - 取变量值有两种,
$(var)or${var}。小括号大括号均可以 - 数据类型只有字符串和字符串数组,空格隔开表示多个元素
$(function arguments)是调用make内置函数的方法,具体可以参考官方文档的函数大全。但是常用的其实只有少数两个即可- 依赖关系定义中,如果代码修改时间比生成的更新/生成不存在时,
command会执行。否则只会打印main.o is up to date。这是makefile解决的核心问题 - 依赖关系可以链式的定义,即b依赖a,c依赖b,而
make会自动链式的查找并根据时间执行command command是shell指令,可以使用$(var)来将变量用到其中。前面加@表示执行执行时不打印原指令内容。否则默认打印指令后再执行指令-
3.5 未解决的问题
通常,我们的cpp会比较多,总不会每次都写全
a.o: a.cpp吧?- 通常,我们的cpp会多级目录,总不会每个目录都写
Makefile吧? - 我不希望生成的
a.o和a.cpp在一起,我想统一放到其他位置可以吗?4. 基于Makefile的标准工程结构
4.1 Makefile工程结构

一个标准工程,我们做如下定义:
这里简单定义了
foo.hpp和foo.cpp,目的是链接为可执行程序后,可以执行-
4.3 解决多级目录cpp检索问题
4.4 替换src/为objs/,o文件放到objs中

srcs is[src/download.cpp src/main.cpp] objs is[src/download.o src/main.o] objs is[objs/download.o objs/main.o]4.5 定义依赖关系,通配

objs/%.o和src/%.cpp代表了通配依赖关系,模式匹配,%相当于变量部分4.6 为o文件创建目录
4.6.1 编译失败,因为目录不存在

原因是,试图创建
objs/foo/foo.o文件时失败。因为objs/foo这个目录不存在造成。对于高版本g++(例如9.0)不会报错并为你创建objs/foo目录。因此我们需要创建
objs/foo目录,需要执行类似mkdir -p dir($@),通过dir($@)获取其目录后创建,这里的mkdir -p指多级目录也一并创建。4.6.2 使用
mkdir -p $(dir $@)获取生成项目录4.7 链接所有o文件生成可执行程序

我们定义
workspace/pro的生成,依赖自所有的o文件。pro是我们的可执行程序4.8 完善一下Makefile
|
|
- 添加make pro,简洁的编译程序
- 添加make run,编译后顺便执行一下,注意: cd到workspace是为了让运行程序后的当前工作目录在workspace中
- 添加make clean,清理编译后的垃圾
- 添加.PHONY,让我们作为指令存在的东西,不要被视作为文件。即make这东西时永远执行command
| | —- | —- |
4.9 完整版本的Makefile


修改一个cpp后,重新编译连接,得到不同的结果,这就是我们想要的。
5. 基于Makefile实现的完整功能项目
5.1 Makefile工程-一个复杂的例子,实现http请求
实现的目的:
- 具有两个依赖,openssl、libcurl,存在include、libs依赖
- 可以锻炼一个完整的相对完善的工程案例。还可以锻炼到代码调试
实现的效果:
- 实现一个程序,可以从任何网站上下载东西
准备:
- 下载openssl:https://www.openssl.org/source/old/1.1.1/openssl-1.1.1j.tar.gz
- 这是用于实现加密通信的加密算法库。用于访问https开头的链接
创建build目录,用于储存下载后的文件,准备用来编译
- 创建lean目录,用于存放编译后的结果,作为依赖项目录
-
5.3 编译openssl

cd openssl-1.1.1j ./config --prefix=/media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project/lean/openssl-1.1.1j make all -j16 && make install -j16 // 这里-j16是同时16个线程执行操作 ./config是配置并生成Makefile,指定install到/media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project/lean/openssl-1.1.1j目录- 请把这里的lean目录修改为你当前自己想放的位置
make all -j16 && make install -j16这里-j16是同时16个线程执行操作,编译后,执行安装5.4 编译libcurl

cd /media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project/build/curl-7.78.0 ./configure --prefix=/media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project/lean/curl7.78.0 \ --with-openssl=/media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project/lean/openssl-1.1.1j make all -j16 && make install -j16--prefix同样是为了设置安装目录,最后编译好的curl放在哪里--with-openssl指定刚才我们编译安装后的目录./configure同样是为了配置curl生成Makefile文件- 执行
make all -j16实现编译 - 执行
make install -j16实现安装5.5 编译后结果

至此我们得到头文件库文件5.6 配置IntellSense和browse路径

变量${workspaceFolder}代表了我们的当前目录,即/media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project。5.7 配置Makefile


至此整个makefile已经非常完备了。该makefile可以通用了5.8 coding
将代码放在./src/下 ```cppinclude “download.hpp”
include
include
using namespace std;
// 这个回调函数,是为了让curl获取到数据后,写入的管道。我们通过string的append函数 // 写入到string对象中。string可以储存二进制也可以储存字符串 size_t write_data(const void buffer, size_t count, size_t size, void user_data) { string stream = static_cast<string >(user_data); const char pbytes = static_cast<const char >(buffer); stream->append(pbytes, count size); return count size; }
string download(const string &url) {
CURL *curl = curl_easy_init();
string response;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, &write_data);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 1);
CURLcode code = curl_easy_perform(curl);
if (code != CURLE_OK)
{
printf("Has error: code = %d, message: %s\n", code, curl_easy_strerror(code));
}
curl_easy_cleanup(curl);
return response;
}
```cpp
#ifndef DOWNLOAD_HPP
#define DOWNLOAD_HPP
#include <string>
std::string download(const std::string &url);
#endif // DOWNLOAD_HPP
#include <stdio.h>
#include <fstream>
#include "download.hpp"
using namespace std;
int main()
{
// 下载一个图片文件
string sxai = download(
"https://link.zhihu.com/?target=http%3A//www.zifuture.com%3A1556/fs/sxai/2021/07/pro-18432c111ca44aa9bba49eab650f466c.jpg");
// 打印他的大小
printf("sxai.size = %d byte\n", sxai.size());
// 储存图片数据到sxai.jpg文件
ofstream of("sxai.jpg", ios::binary | ios::out);
of.write(sxai.data(), sxai.size());
of.close();
return 0;
}
文件下载成功,至此。整个http的访问工程就达成了。你学会如何控制,头文件、库文件路径了吗?还有o文件存放工作目录等。
6. 分析程序依赖项
6.1 使用readelf -d workspace/pro分析
6.2 使用ldd workspace/pro分析

ldd指令可以查看该程序依赖的so,查找到的具体路径。看看是否符合预期。
因为如果不符合预期,会发生莫名其妙的错误,这个千万要不得。我们一定要让他符合我们的预期。
7. 配置C++的调试功能
7.1 配置task.json

task.json是配置用来执行调试之前的编译工作。即,按下F5,编译程序,进入调试
7.2 配置launch.json

这个文件可用通过直接按下F5后自动产生,也可以手动敲哈
- 如果有参数,可以加到args中
-
7.3 进行调试
好了,我们在main.cpp的29行这个文字左侧点击后后个红点,作为断点,然后按下F5键,看看会怎么样
7.4 界面介绍
7.5 恭喜
- 到这里,恭喜你,已经掌握了如何使用
- Makefile在linux下开发的技能了! Congratulations!!!
8. 头文件修改后自动编译
关于最后一个扩展知识点考虑:如果头文件修改了,那么依赖头文件的cpp会发生编译吗?如果不发生编译,会带来什么后果?
如果头文件修改,依赖头文件的cpp没有发生编译,你可能会面临莫名的错误,当然这种事情并不常发生。
解决方案:如果头文件修改,make clean后再编译即可。但是这并不是好的解决方案。
- 我们有如下代码。头文件a.hpp中定义了Number 888

make run后打印的888
![]() ![]() |
修改成555后继续make run发现并没有编译a.cpp。因此输出依旧是888 |
|---|---|
8.2 分析原因
- 原因:缺少
a.o对hpp依赖关系的定义。makefile中没有定义a.o : a.hpp,没有要求编译a.cpp需要检查a.hpp的时间 - 解决方案:直接增加
a.o : a.cpp a.hpp吗?是可以。强制要求a.o生成时检查a.hpp
8.3 头文件依赖问题 - 解决方案
问题:如果增加a.o : a.hpp。但是a.hpp来自于a.cpp的#include语法。我们可能随时修改a.cpp依赖不同的hpp文件,如何自动完成?
使用**g++ -MM a.cpp -MF a.mk -MT prefix/a.o**生成**makefile**文件**a.mk**
该指令产生a.mk文件,里面写了a.cpp依赖的头文件。a.mk本身就是makefile语法。他解析了a.cpp的全部头文件后分析。所有依赖的头文件,并写成makefile语法
-MM分析 #include "a.hpp",引号类型的包含-M 分析全部包含,尖括号和引号-MF 指定储存路径-MT 指定依赖项名称
通过include a.mk包含生成的文件,使其生效
- 我们使用
g++ -MM a.cpp -MF a.mk -MT a.o - 为了使编译后的
a.mk生效,我们可以通过include a.mk包含进来

整合起来,注意,这里include a.mk修改为-include a.mk就不会提示报错了。
集成到HTTP项目中
把代码拆分出头文件用于检验效果






