From:从零Makefile落地算法大项目,完整案例教程 — 杜佬牛逼🐂🍺

为什么要学makefile:

  1. 相比cmake,Makefile更加轻量简洁,侵入性低,掌控力强,语法更少更简单;
  2. 使用Makefile你可以进行更细粒度的掌控,虽然cmake简化了这些,但是对于大型项目,细节的掌控是必须的;
  3. 对于算法落地,我们会面临各种库包,理清楚非常有利于降低问题的发生;

准备环境

  1. VSCode (Visual Studio Code) 作为IDE,(VSCODE YYDS)
  2. 安装VSCode的C++插件
  3. 准备Linux系统(Ubuntu),推荐使用VSCode的SSH插件远程连接服务器(另一个电脑)进行开发。本地电脑可以是windows/mac
  4. 熟悉C++的基本语法,我们主讲Makefile但是会有C++编码部分(不多)

    目录

    1. g++指令介绍
    2. C++的编译链接过程、编译时、运行时介绍
    3. Makefile基本语法,依赖关系定义
    4. 基于Makefile的标准工程结构
    5. 基于Makefile实现的完整功能项目
    6. 分析程序依赖项,readelf、ldd
    7. 配置C++的调试功能
    8. 头文件修改后自动编译

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.s
  • g++ main.s -o main.bin
  • g++ main.cpp -o main.bin

比较常用的是 编译—链接

  • 编译:代码编译到二进制:g++ -c main.cpp -o main.o
  • 链接:多个二进制链接成执行程序:g++ main.o -o main.bin | image.png | | —- | | image.png | | image.png | | image.png |

2. C++编译链接 / 编译时和运行时

2.1 C++编译链接流程图

makefile - 图7

2.2 C++的声明和实现

:::info 声明代表一个函数的符号或者ID,实现则代表函数的具体执行的代码 :::

  1. int add(int, int);
  2. 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

image.png
在main.cpp中有个函数声明:int add(int a, int b);,具体实现在test.cpp中。

2.3.2 main.cpp 和 test.cpp 的汇编代码

image.png
image.png
从两者的汇编代码,可以看到

  1. main.s里面没有add函数的具体实现,只有call _Z3addii操作;
  2. add的具体实现在test.s里面

image.png

2.3.3 带有命名空间时的名字编码

image.png

2.4 C++编译过程

关于编码的结论是:

  • c++中的函数、符号、变量会被编码
  • 函数的编码关心的是:函数名称、所在命名空间、参数类型
  • 函数的编码不关心的是:返回值类型

关于编译的结论是:
调用一个函数,会生成call 函数名称的代码,没有函数的具体实现。具体实现是在链接阶段。

image.png

2.5 C++链接过程

image.png

2.6 C++实际的链接过程

makefile - 图15 :::info 任何链接的符号(例如**add**),会在所有链接的对象中查找实现,例如这里在 **test.o****lib3rd.so****libpkg.a**中找

如果没找到,则**undefined reference _Z3addii**
如果找到多个,则**multiple definition of add**
任何符号的实现全局只能有一个。 :::

2.7 若add函数在动态库lib3rd.so中时

makefile - 图16 :::info 如果链接的符号在**so**中,**out.bin**会引用这个符号的**so**文件,在运行时动态加载**lib3rd.so**后,再调用**add**函数。 :::

2.8 若add函数在静态库,libpkg.a中时

makefile - 图17 :::info 如果链接的符号在**a**中,out.bin会将add的实现代码完整复制到**out.bin**中,在运行**out.bin**时,不需要**libpkg.a** :::

2.9 编译链接成一个完整程序的过程

image.png

2.10 C++链接时,查找so文件、a文件方式的方式

g++ -lpkg -l3rd main.o -o out.bin

当程序链接时,如何决定链接的是哪个**so****a**文件呢?

是按照如下依据来的:

  1. g++ -lname:表示链接一个动态库或者静态库(.so or .a),名字是libname.so/libname.a
  2. g++ -Lfolder:表示配置一个动态/静态库的查找目录
  3. g++查找so/a文件的地方有3个,按照下列优先顺序查找

    1. 第一顺序:-L配置的目录;
    2. 第二顺序:g++内置的系统目录,例如/usr/lib/, etc…
    3. 第三顺序:系统环境变量(例如LIBRARY_PATH)指定的目录

      2.11 C++运行时,查找so文件的方式

      ./out.bin,当程序运行时,此时进入运行时状态,跟编译链接完全不同。此时的so文件的查找则也不同,是按照如下进行:
  4. 第一顺序:应用程序的当前目录(当前目录不同于程序所在目录)

  5. 第二顺序:out.bin中储存的rpath(run path)。readelf -d out.bin指令可以查看文件的runpath信息。如果该选项指定了依旧失效,说明依赖的so文件还存在更多依赖,,却没有在其他目录明确(常用)
  6. 第三顺序:环境变量指定的目录(例如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 使用尖括号时:

  1. 第一顺序:以g++ -I配置的路径查找,例如g++ -I/data/folder,确认路径是:/data/folder/path/name.hpp,对所有路径都进行测试,找到为止
  2. 第二顺序:g++内置的系统路径,一般是/usr/include 等等,g++ -print-search-dirs可以打印出来
  3. 第三顺序:系统环境变量配置的路径,例如:C_INCLUDE_PATH, CPP_INCLUDE_PATH

    3. Makefile基础

    3.1 Makefile基础-解决的问题是什么

    编译代码是一个很耗时的事情尤其是代码量大、CPU差时(边缘端)。参考官方文档,查看更多定义:http://www.gnu.org/software/make/manual/make.html

  4. make指令执行时,默认会查找当前目录下的makefileMakefile文件作为代码文件,当然也可以make -f abc的方式,指定make运行的Makefile代码

  5. Makefile主要解决的问题是,描述生成依赖关系,根据生成和依赖文件修改时间新旧决定是否执行command。可手动调用g++进行编译,重点是,如果每次编译,都是全体代码参与,对于没有修改部分的代码编译,是浪费时间。项目文件越多,这个问题越严重。而Makefile帮我们解决这个问题
  6. Makefile的重点有:描述依赖关系、command(生成文件的指令)
  7. 我们只需要学习Makefile的基本操作足以应付项目需求即可,并不需要学习全部语法

    3.2 Makefile基础-代码域

    image.png

    3.3 Makefile基础-语法

  8. 数据类型,字符串和字符串数组

  9. 定义变量,var := folder,定义变量var,为string类型,值是folder
  10. 定义数组,var := hello world folder,定义变量var,为数组类型,值是["hello", "world", "folder"]
  11. 定义的方式有多种
    1. = 赋值 var = folder 基本赋值,Makefile全部执行后决定取值(不常用)
    2. := 赋值 var := folder 基本赋值,当前所在位置决定取值(常用)
    3. ?= 赋值 var ?= folder 如果没有赋值,则赋值为folder
    4. += 赋值 var += folder 添加值,在var后面添加值。可以认为数组后边增加元素appendvar := hello, var += world,结果是hello world,中间有空格
  12. $(var)${var},在这个位置解释为var的值,例如: var2 := $(var)
  13. $(func param),调用Make提供的内置函数
    1. 例如:var := $(shell echo hello),定义var的值为执行shell echo hello后的输出
    2. 例如:$(info $(var)),直接打印var变量内容
    3. 例如:var := $(subst a, x, aabbcc),替换aabbcc中的ax后赋值给var,结果xxbbcc
    4. 逻辑语法: ifeqifneqifdefifndef
      ifeq($(var), depends)
      name := hello
      endif
      
      | image.png | image.png | | —- | —- |
  • 生成项可以没有依赖项,那么如果该生成项文件不存在,command将永远执行

    3.2 依赖关系定义

    image.png

  • 第一次执行make a.o时,由于a.o不存在,执行了command

  • 第二次执行make a.o时,由于a.cpp时间没有比a.o新,打印a.o is up to date,不需要编译
  • 生成项和依赖项,从来都是当成文件来看待的

    3.3 编译和链接结合起来

    image.png

  • 定义好依赖后,make out.bin,会自动查找依赖关系,并自动按照顺序执行command

  • 这是makefile为我们解决的核心问题,剩下就是如何玩的更方便罢了。比如自动检索a.cppb.cpp,自动定义a.o依赖a.cpp。等等

    3.4 总结

  1. 变量赋值有4种方式var = 123, var := 123, var ?= 123, var += 123。其中var := 123常用,var += 123常用
  2. 取变量值有两种,$(var)or${var}。小括号大括号均可以
  3. 数据类型只有字符串和字符串数组,空格隔开表示多个元素
  4. $(function arguments)是调用make内置函数的方法,具体可以参考官方文档的函数大全。但是常用的其实只有少数两个即可
  5. 依赖关系定义中,如果代码修改时间比生成的更新/生成不存在时,command会执行。否则只会打印main.o is up to date。这是makefile解决的核心问题
  6. 依赖关系可以链式的定义,即b依赖a,c依赖b,而make会自动链式的查找并根据时间执行command
  7. commandshell指令,可以使用$(var)来将变量用到其中。前面加@表示执行执行时不打印原指令内容。否则默认打印指令后再执行指令
  8. make不写具体生成名称,则会选择依赖关系中的第一项生成

    3.5 未解决的问题

  9. 通常,我们的cpp会比较多,总不会每次都写全a.o: a.cpp吧?

  10. 通常,我们的cpp会多级目录,总不会每个目录都写Makefile吧?
  11. 我不希望生成的a.oa.cpp在一起,我想统一放到其他位置可以吗?

    4. 基于Makefile的标准工程结构

    4.1 Makefile工程结构

    image.png
  • 一个标准工程,我们做如下定义:

    • 具有src目录,存放我们的代码,可能有多级,例如main.cpp,foo/foo.cpp等
    • 具有workspace目录,存放我们编译后的可执行程序、资源、数据
    • 具有objs目录,存放由cpp编译后得到的o文件等中间文件
    • .vscode目录,存放vscodecpp配置,用于语法解析器。vscodec++插件所使用。ctrl+shift+p后搜索c++,找到JSON那一项就是
    • Makefile文件,当前工程的Makefile代码

      4.2 写代码

      image.png
  • 这里简单定义了foo.hppfoo.cpp,目的是链接为可执行程序后,可以执行

  • ifndef是防止重复包含

    4.3 解决多级目录cpp检索问题

    image.png

    4.4 替换src/为objs/,o文件放到objs中

    image.png

    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 定义依赖关系,通配

    image.png

  • objs/%.osrc/%.cpp代表了通配依赖关系,模式匹配,%相当于变量部分

    4.6 为o文件创建目录

    4.6.1 编译失败,因为目录不存在

    image.png

  • 原因是,试图创建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 $@)获取生成项目录

    image.png

    4.7 链接所有o文件生成可执行程序

    image.png

  • 我们定义workspace/pro 的生成,依赖自所有的o文件。pro是我们的可执行程序

    4.8 完善一下Makefile

    | image.png |
    - 添加make pro,简洁的编译程序
    - 添加make run,编译后顺便执行一下,注意: cd到workspace是为了让运行程序后的当前工作目录在workspace中
    - 添加make clean,清理编译后的垃圾
    - 添加.PHONY,让我们作为指令存在的东西,不要被视作为文件。即make这东西时永远执行command
    | | —- | —- |

4.9 完整版本的Makefile

image.pngimage.png image.png
修改一个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开头的链接
  • 下载libcurl:https://curl.se/download/curl-7.78.0.tar.gz

    • 这个是用于实现http/https的访问操作。如果要访问https,则依赖openssl

      5.2 下载和编译libcurl/openssl

      image.png
  • 创建build目录,用于储存下载后的文件,准备用来编译

  • 创建lean目录,用于存放编译后的结果,作为依赖项目录
  • 将下载后的.tar.gz放到 build目录下,并解压出来

    5.3 编译openssl

    image.png

    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

    image.png

    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 编译后结果

    image.png
    至此我们得到头文件库文件

    5.6 配置IntellSense和browse路径

    image.png
    变量${workspaceFolder}代表了我们的当前目录,即/media/darrenzhang/Workdirs/trt-pro/makefile_tutorial_project

    5.7 配置Makefile

    image.png
    image.png
    至此整个makefile已经非常完备了。该makefile可以通用了

    5.8 coding

    将代码放在./src/下 ```cpp

    include “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分析

image.png

6.2 使用ldd workspace/pro分析

image.png
ldd指令可以查看该程序依赖的so,查找到的具体路径。看看是否符合预期。
因为如果不符合预期,会发生莫名其妙的错误,这个千万要不得。我们一定要让他符合我们的预期。

7. 配置C++的调试功能

剩下最后一个环节了,配置调试功能。写代码怎么能没有调试

7.1 配置task.json

image.png

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

    7.2 配置launch.json

    image.png

  • 这个文件可用通过直接按下F5后自动产生,也可以手动敲哈

  • 如果有参数,可以加到args中
  • stopAtEntry表示启动后就停止到main函数里边

    7.3 进行调试

  • 好了,我们在main.cpp的29行这个文字左侧点击后后个红点,作为断点,然后按下F5键,看看会怎么样

image.png

7.4 界面介绍

image.png

7.5 恭喜

  • 到这里,恭喜你,已经掌握了如何使用
  • Makefile在linux下开发的技能了! Congratulations!!!

    8. 头文件修改后自动编译

    关于最后一个扩展知识点考虑:如果头文件修改了,那么依赖头文件的cpp会发生编译吗?如果不发生编译,会带来什么后果?

如果头文件修改,依赖头文件的cpp没有发生编译,你可能会面临莫名的错误,当然这种事情并不常发生。
解决方案:如果头文件修改,make clean后再编译即可。但是这并不是好的解决方案。
image.png

  • 我们有如下代码。头文件a.hpp中定义了Number 888

image.png
make run后打印的888

image.pngimage.png 修改成555后继续make run发现并没有编译a.cpp。因此输出依旧是888

8.2 分析原因

  • 原因:缺少a.ohpp依赖关系的定义。makefile中没有定义a.o : a.hpp,没有要求编译a.cpp需要检查a.hpp的时间
  • 解决方案:直接增加a.o : a.cpp a.hpp吗?是可以。强制要求 a.o生成时检查a.hpp

image.png

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 指定依赖项名称
image.png
通过include a.mk包含生成的文件,使其生效

  • 我们使用g++ -MM a.cpp -MF a.mk -MT a.o
  • 为了使编译后的a.mk生效,我们可以通过include a.mk包含进来

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