# cpp+whl 编译 **Repository Path**: damon_SJTU/cpp-compilation ## Basic Information - **Project Name**: cpp+whl 编译 - **Description**: 介绍一下对于cpp编译以及python打包生成whell包的认识 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2023-09-21 - **Last Updated**: 2025-04-09 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 第一部分:g++ 与 编译 一个英文的介绍:[GCC and Make Compiling, Linking and Building C/C++ Applications](https://www3.ntu.edu.sg/home/ehchua/programming/cpp/gcc_make.html) 1. gcc, g++ 安装:一般在 unix 系统中自带。 2. 编译流程:预处理(preprocessing)-> 编译(compilation)-> 汇编(Assembly)-> 链接(linking) ![Alt text](imgs/compile_process.png) - **预处理** 预处理器是一些指令,指示编译器在实际编译之前所需完成的预处理。预处理过程实际上是一个字符替换过程。 所有的预处理器指令都是以井号(#)开头,只有空格字符可以出现在预处理指令之前。预处理指令不是 C++ 语句,所以它们不会以分号(;)结尾。 C++ 还支持很多预处理指令,比如 #include、#define、#if、#else、#line 等 在 `files/` 下,执行预处理命令, ``` g++ -E Cat.cpp -o Cat.i ``` 查看 Cat.i: ![Alt text](imgs/cati.png) 可以看出,Cat.h和iostream.h2个头文件已经被展开,而只有Cat.h展开,在Cat.i中才能有Cat类的定义,这也是展开头文件的意义所在,并且Cat.h定义的 #ifndef UNTITLED_CAT_H #define UNTITLED_CAT_H 语句已经不见. - **编译** 经过预编译得到的输出文件中,只有常量;如数字、字符串、变量的定义,以及C语言的关键字,如main, if , else , for , while , { , } , + , - , * , \ 等等。 编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。 优化处理是编译系统中一项比较艰深的技术。它涉及到的问题不仅同编译技术本身有关,而且同机器的硬件环境也有很大的关系。优化一部分是对中间代码的优化。这种优化不依赖于具体的计算机。另一种优化则主要针对目标代码的生成而进行的。 对于前一种**硬件无关的优化**,主要的工作是删除公共表达式、循环优化(代码外提、强度削弱、变换循环控制条件、已知量的合并等)、复写传播,以及无用赋值的删除,等等。 后一种**硬件相关的优化**,和机器的硬件结构密切相关,最主要的是考虑是如何充分利用机器的各个硬件寄存器存放有关变量的值,以减少对于内存的访问次数。另外,如何根据机器硬件执行指令的特点(如流水线、RISC、CISC、VLIW等)而对指令进行一些调整使目标代码比较短,执行的效率比较高,也是一个重要的研究课题。 经过优化得到的汇编代码必须经过汇编程序的汇编转换成相应的机器指令,方可能被机器执行。 在 `files/` 下,执行编译命令, ``` g++ -S Cat.i -o Cat.s ``` Cat.s 里的汇编代码如下: ![Alt text](imgs/cats.png) - 汇编 汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。目标文件由段组成。通常一个目标文件中至少有两个段: 1) 代码段:该段中所包含的主要是程序的指令。该段一般是可读和可执行的,但一般却不可写。 2) 数据段:主要存放程序中要用到的各种全局变量或静态的数据。一般数据段都是可读,可写,可执行的。 常见的我们需要了解的段包括如下: 1) ELF Header文件头:描述了整个目标文件的属性,包括是否可执行、是动态链接还是静态链接、入口地址是什么、目标硬件、目标操作系统、段表偏移等信息。 2) .text代码段:存放编译后的机器指令,也即各个函数的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。 3) .data:数据段,存放全局变量和静态变量。(对应漫谈C语言内存管理中说的全局数据区) 4) .rodata :只读数据段,存放一般的常量、字符串常量等对应漫谈C语言内存管理中说的常量区。 5) .rel.text.、rel.data:重定位段,包含了目标文件中需要重定位的全局符号以及重定位入口。 6) .symtab 符号表,保存了全局变量名、局部变量名、函数名等在字符串表中的偏移。 UNIX环境下主要有三种类型的目标文件: 1) 可重定位文件 其中包含有适合于其它目标文件链接来创建一个可执行的或者共享的目标文件的代码和数据。 2) 共享的目标文件 这种文件存放了适合于在两种上下文里链接的代码和数据。 第一种是链接程序可把它与其它可重定位文件及共享的目标文件一起处理来创建另一个目标文件; 第二种是动态链接程序将它与另一个可执行文件及其它的共享目标文件结合到一起,创建一个进程映象。 3) 可执行文件 它包含了一个可以被操作系统创建一个进程来执行之的文件。 汇编程序生成的实际上是第一种类型的目标文件。对于后两种还需要其他的一些处理方能得到,这个就是链接程序的工作了。目标文件与可执行文件的组织形式非常类似,只是有些变量和函数的地址还未确定,程序不能执行。所以下一步链接的一个重要作用就是找到这些变量和函数的地址。 在 `files/` 下,执行汇编命令, ``` g++ -c Cat.s -o Cat.o ``` 汇编文件生成目标文件的过程,除了将汇编指令转化为二进制指令以外,还有一件很重要的事情,就是输出符号表。 什么是符号呢?变量(函数名)就是地址的助记符,是为了方便人处理而存在的,它们也被称为“符号”,它们的起始地址就成为“符号定义”,当它们被调用的时候也称为符号引用。 再说下符号表是什么,这对于理解后面的链接本质极为关键。符号表本质上是一种数据库,用来存储代码中的变量,函数调用等相关信息。该表以key-value 的方式存储数据。变量和函数的名字就用来对应表中的key部分,value部分包含一系列信息,例如变量的类型,所占据的字节长度,或是函数的返回值。 在链接进行之前,部分符号是 undefined 的,也就是编译器此时还不知道这部分符号对应的代码的具体位置。 - 链接 由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。所有的这些问题,都需要经链接程序的处理方能得以解决。 链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体。根据开发人员指定的同库函数的链接方式的不同,链接处理可分为两种: 1) 静态链接 在这种链接方式下,函数的代码将从其所在的静态链接库中被拷贝到最终的可执行程序中。这样该程序在被执行时这些代码将被装入到该进程的虚拟地址空间中。静态链接库实际上是一个目标文件的集合,其中的每个文件含有库中的一个或者一组相关函数的代码。 2) 动态链接 在此种方式下,函数的代码被放到称作是动态链接库或共享对象的某个目标文件中。链接程序此时所作的只是在最终的可执行程序中记录下共享对象的名字以及其它少量的登记信息。在此可执行文件被执行时,动态链接库的全部内容将被映射到运行时相应进程的虚地址空间。动态链接程序将根据可执行程序中记录的信息找到相应的函数代码。 对于可执行文件中的函数调用,可分别采用动态链接或静态链接的方法。使用动态链接能够使最终的可执行文件比较短小,并且当共享对象被多个进程使用时能节约一些内存,因为在内存中只需要保存一份此共享对象的代码。但并不是使用动态链接就一定比使用静态链接要优越。在某些情况下动态链接可能带来一些性能上损害。 总的来说,链接分为2步: 1. 目标文件段的合并,符号表合并完毕之后,再进行符号解析。 2. 符号重定向。 目标文件段的合并就是将相同的文件段合并在一起,符号解析就是链接器将每个符号的引用和符号定义建立关联。 而重定位就是计算每个定义的符号在虚拟地址空间的绝对地址,然后将可执行文件符号引用处的地址修改为重定位后的地址信息。 此时,可以通过 readelf 命令来查看生成的可执行文件的 Elf 文件头,此时入口地址有了,符号的位置应该也都被填充了。 补充: 制作静态链接库: ``` g++ -c main.cpp Dog.cpp Cat.cpp # 生成对应的目标文件 ar rcs Animal.a Cat.o Dog.o # 生成静态链接库,实际上相当于一个打包过程 g++ -static main.0 Animal.a -o Animal_static # 生成可执行文件 ./Animal_static # 执行 ``` 制作动态链接库: ``` g++ Cat.cpp Dog.cpp -c -fpic # 生成与位置无关代码的目标文件 g++ -shared Cat.o Dog.o -o Animal.so # 将目标文件生成动态链接库 g++ main.cpp Animal.so -o Animal_shared # 用动态库和main.cpp生成可执行文件 export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/qtang/my_git_projects/cpp-compilation/files # 添加 PATH 或 sudo ln Animal.so /usr/lib/libAnimal.so # 在系统目录 /lib/, /usr/lib下创建Animal.so的硬链接 ./Animal_shared # 运行可执行文件 ``` ## 第二部分,make 与 MakeFile make 官网的描述:[Overview of make](https://www.gnu.org/software/make/manual/make.html#Rule-Introduction) The make utility automatically determines which pieces of a large program need to be recompiled, and issues commands to recompile them. ……Our examples show C programs, since they are most common, but you can use make with any programming language whose compiler can be run with a shell command. Indeed, make is not limited to programs. You can use it to describe any task where some files must be updated automatically from others whenever the others change. To prepare to use make, you must write a file called the makefile that describes the relationships among files in your program and provides commands for updating each file. In a program, typically, the executable file is updated from object files, which are in turn made by compiling source files. Once a suitable makefile exists, each time you change some source files, this simple shell command: make suffices to perform all necessary recompilations. The make program uses the makefile data base and the last-modification times of the files to decide which of the files need to be updated. For each of those files, it issues the recipes recorded in the data base. You can provide command line arguments to make to control which files should be recompiled, or how. 简单来说,make就是一个构建工具,通过make的shell命令,去跑Makefile脚本,而Makefile脚本就指定了项目中哪些文件需要编译,文件的依赖关系以及最终的编译目标产物。所以通过make命令加上Makefile脚本就可以实现一键编译整个项目的梦想. ## 第三部分: CMake CMake 官网的叙述: [Overview of CMake](https://cmake.org/) 中文博客: [CMake 语言 15 分钟入门教程](https://leehao.me/cmake-%E8%AF%AD%E8%A8%80-15-%E5%88%86%E9%92%9F%E5%85%A5%E9%97%A8%E6%95%99%E7%A8%8B/) [cmake使用教程系列](https://juejin.cn/post/6844903557183832078),翻译自[官方教程](https://cmake.org/cmake-tutorial/) CMake is an open-source, cross-platform family of tools designed to build, test and package software. CMake is used to control the software compilation process using simple platform and compiler independent configuration files, and generate native makefiles and workspaces that can be used in the compiler environment of your choice. The suite of CMake tools were created by Kitware in response to the need for a powerful, cross-platform build environment for open-source projects such as ITK and VTK. 上面有一段文字,一句话概括就是CMake是一段跨平台的构建脚本,可以根据具体平台上生成对应的makefile,所以CMake的本质还是生成makefile,然后还是通过makefile来构建项目,CMake本身不构建项目。 ## 第四部分: Python wheel 安装包的制作与安装 wheel 是 python 新的发行标准。本质上,wheel 是一个 zip 文件。在制作 wheel 的过程,实际上是对 python 脚本等进行打包。而 pip 在安装 wheel 文件时,其过程也正是在对它进行解压,然后复制到对应的 site-packages 目录下。实际的安装过程可能还会更加复杂一些,比如安装依赖。 Python 库打包分发的关键在于编写 setup.py 文件。setup.py 文件编写的规则是从 setuptools 或者 distuils 模块导入 setup 函数,并传入各类参数进行调用。 ``` # coding:utf-8 from setuptools import setup # or # from distutils.core import setup setup( name='demo', # 包名字 version='1.0', # 包版本 description='This is a test of the setup', # 简单描述 author='huoty', # 作者 author_email='sudohuoty@163.com', # 作者邮箱 url='https://www.konghy.com', # 包的主页 packages=['demo'], # 包 ) ``` setup 函数常用的参数如下: - name:包名称(这里对应的是 wheel 文件的名称,并不是 from xxx import 里的 xxx 名称,后者是有用于建构的 python project 的目录架构组织确定的) - version:包版本 - author:包作者 - author_email: 包作者邮箱 - packages:需要处理的包目录,也就是将被打包进 wheel 文件里的包目录(通常为包含 __init__.py 的文件夹) - my_modules:需要打包的 python 单文件列表 - package_dir:指定哪些目录下的文件被映射到哪个源码包(默认是 setup.py 同级目录下的包) - cmdclass:添加自定义命令 - ext_modules:指定扩展模块 - requires:指定依赖的其他包 - install_requires:安装时需要安装的依赖包 - entry_points:入口地址(比如用于 shell 的可执行命令) 需要打包的内容由 packages 命令指示: 对于简单工程来说,手动增加 packages 参数是容易。而对于复杂的工程来说,可能添加很多的包,这是手动添加就变得麻烦。Setuptools 模块提供了一个 find_packages 函数,它默认在与 setup.py 文件同一目录下搜索各个含有 init.py 的目录做为要添加的包。 ``` find_packages(where='.', exclude=(), include=('*',)) ``` find_packages 函数的第一个参数用于指定在哪个目录下搜索包,参数 exclude 用于指定排除哪些包,参数 include 指出要包含的包。 默认默认情况下 setup.py 文件只在其所在的目录下搜索包。如果不用 find_packages,想要找到其他目录下的包,也可以设置 package_dir 参数,其指定哪些目录下的文件被映射到哪个源码包,如: package_dir={'': 'src'} 表示 “root package” 中的模块都在 src 目录中。 需要注意的是:name 参数决定了 wheel 包的名称,也是使用者直接使用 pip install xxx 的时候,能否通过 xxx 寻找到你的包。然后在使用的时候,from xxx import,或者import xxx,这里的 xxx 对应的是 packages 参数里的(通常也就是 find_packages 的返回值)。这也很好理解,打包生成 wheel 然后在 pip install 的过程,其实相当于把你写的 python 源文件搬到了使用者的 site-packages 目录下,所以肯定要 import 源文件的名称(或者说是,带有 __init__.py 文件的包的名称)才能找到导入物。 ## 第五部分:c/c++ 扩展 setup.py 打包 示例在 `whl_with_c/` 目录下,setup.py 的内容如下: ``` from setuptools import setup from setuptools import Extension mymath = Extension(name="mymath", sources=["mymath/mymath.c"], include_dirs=["/usr/include/python3"], # 编译时需要的 include dirs library_dirs=["/usr/lib/x86_64-linux-gnu/"] # 编译链接时寻找链接库的目录 ) setup(name="demo", # 我们的 package 的名字 version="1.0", # 版本 description="This is a demo package", packages=["src"], # 打包时需要带上的本地 python 包 ext_modules=[mymath] # 打包时需要编译的 c 模块 # libraries=['mylib'], # 链接的库文件 # define_macros=[('DEBUG', None)], # 定义宏 # extra_compile_args=['-O2'], # 额外的编译选项 # extra_link_args=['-L/path/to/libs'], # 额外的链接选项 ) ``` 当你在 Python 的 setup.py 文件中使用 ext_modules 参数时,它用于指定要构建的扩展模块(C/C++ 扩展)。该参数接受一个可迭代对象,每个元素表示一个扩展模块的描述。每个扩展模块描述通常是通过 setuptools.Extension 类创建的对象,其中包含了扩展模块的相关信息,如名称、源文件、编译选项等。 下面是 setuptools.Extension 的常用参数: - name(字符串):扩展模块的名称。这个名称将用于在 Python 中导入该模块。 - sources(字符串列表):扩展模块的源文件列表。可以指定一个或多个源文件。 - include_dirs(字符串列表):指定要包含的头文件目录列表。这些目录中的头文件将在编译扩展模块时使用。 - library_dirs(字符串列表):指定要链接的库文件目录列表。这些目录中的库文件将在链接扩展模块时使用。 - libraries(字符串列表):指定要链接的库文件列表。 - define_macros(元组列表):定义编译时的宏定义。每个元组包含两个元素,第一个元素是宏的名称,第二个元素是宏的值(可选)。 - extra_compile_args(字符串列表):指定额外的编译选项。 - extra_link_args(字符串列表):指定额外的链接选项。 通过 Extension 类,可以定义一个扩展模块。。使用 Extension 类创建了扩展模块对象,并通过参数设置了相关信息,如源文件、头文件目录、库文件目录、链接的库文件等。最后,我们将扩展模块对象的列表传递给 ext_modules 参数,以便在安装包时同时编译和构建这些扩展模块。 我理解的,通过这个 Extension 类,相当于告诉编译器如何去编译这个 c/c++ 文件,结果应该是获得一个 .so 文件(需要注意的是,这个.so的名称一般要和 c/c++ 文件里 Export 的 name 一致,这样 import 的时候才不会出错)。然后为了打包到 wheel 中,我们一般会在呆打包下面的 __init__.py 里, import 这个 .so。这样 c++ 编译得到的 .so 就可以被打包到 wheel 安装包里了。 ## 第六部分:setup.py 使用 CMake 来编译复杂的项目 示例在 `whl_with_cmake/` 目录下,setup.py 主要的改动有两点,即加入了 CMakeExtension 类和 BuildExt 类,用于在 setup.py 文件中调用 CMake 和 Make 命令来编译 C++ 扩展模块。 CMakeExtension 类继承自 setuptools.Extension,它重写了 __init__ 方法,并忽略了原来的 sources 参数,将其设置为空列表。这是因为 CMake 会自动处理源文件的编译。 BuildExt 类继承自 setuptools.command.build_ext,它重写了 run 方法,以便在构建扩展模块时调用 CMake 编译。对于每个扩展模块,它会检查是否是 CMakeExtension 类的实例,如果是,则调用 build_cmake 方法进行编译。 build_cmake 方法的实现如下: 获取当前工作目录(cwd)和构建临时目录(build_temp)的绝对路径。 创建构建临时目录和扩展模块目录(extdir)。 根据是否启用调试模式,设置 CMake 的配置参数(config)。 构建 CMake 命令的参数:设置输出目录和构建类型。 在构建临时目录下运行 CMake 命令进行配置。 如果不是 dry_run 模式(非干运行),则运行 CMake 编译命令进行构建。 最后,BuildExt 类会调用父类的 run 方法,确保执行其他默认的构建操作。 在我们的 setup.py 文件中,将 CMakeExtension 作为扩展模块的类型,并将 BuildExt 作为 cmdclass 参数传递给 setup 函数,以启用自定义的构建过程。 真正做到对 c++ 源码通过 cmake 进行编译的是这两句: ``` self.spawn(["cmake", f"{str(cwd)}/{ext.name}"] + cmake_args) self.spawn(["cmake", "--build", "."] + build_args) ``` self.spawn 方法是 setuptools 提供的一个实用方法,用于在子进程中执行命令。它会创建一个新的子进程,并在其中执行指定的命令。 这两句代码实际上都会启动一个新的进程来执行 cmake 命令。 前一个 cmake 命令和平时一样,得到 MakeFile 等文件。 后一个 cmake --build 其实类似于 make, 根据 MakeFile 和 CMakeCache 等内容,对 c++ code 进行编译,得到可执行文件 .so。 然后在 setup.py 里对如下两个参数进行替换: ``` ext_modules=[mymath], # mymath 现在是 CMakeExtension 类的实例了 cmdclass={"build_ext": BuildExt} # 使用自定义的 build_ext 类 ``` ## 补充七:相关的概念 ### configure 和 cmake configure 和 CMake 用生成 Makefile,负责将源代码与当前系统进行配置和适配。 而 make 则根据 Makefile 中的规则进行实际的编译过程,生成可执行文件或库。 最后,make install 负责将最终编译好的文件复制到指定的安装目录中,以供系统中的其他程序使用。 (1) ./configure是一种叫autoconf的构建工具自动生成的构建文件,它以shell script的形式存储,在cmake之前是c/c++的主流构建工具。近年来很多项目有从autoconf转向cmake的趋势。configure 是一个由 GNU Autoconf 提供的脚本,用于自动生成 Makefile。Autoconf 是一个用于创建可移植的源代码包的工具,它可以检测系统的特性和能力,并生成适合当前系统的配置文件。在编译开源软件时,通常会在源代码根目录下找到一个名为 configure 的文件。 在使用 configure 进行编译时,首先运行 configure 脚本,它会根据当前系统的特性和用户指定的选项生成一个或多个 Makefile。这些 Makefile 包含了编译该软件所需的详细规则和指令。 执行 configure 文件,一般会做两件事:1. 让用户选定编译特性;2. 检查编译环境。执行结果是生成 MakeFile 文件。在执行 configure 文件时,可以通过命令行传入一些参数,来定制化一些编译选项。configure 是一个 shell 脚本,它可以自动设定源程序以符合各种不同平台上 Unix 系统的特性,并且根据系统叁数及环境产生合适的 Makefile 文件或是 C 的头文件 (header file),让源程序可以很方便地在这些不同的平台上被编译连接。 (2) CMake 是一个跨平台的构建工具,它可以生成与构建系统无关的 Makefile 或 IDE 项目文件。与 configure 不同,CMake 的配置过程是跨平台的,因此可以在不同的操作系统上运行,例如 Linux、Windows 和 macOS。 CMake 的配置过程包括创建一个名为 CMakeLists.txt 的脚本文件,在该文件中定义项目的配置选项、依赖项和编译规则。然后,通过运行 CMake 工具,它会根据 CMakeLists.txt 文件生成适用于目标平台的 Makefile 或其他构建系统的文件。 autoconf和cmake的共同点是会生成makefile,然后从makefile执行真正的编译构建过程。使用哪一个取决于文件夹下面的INSTALL文件,此文件会说明你应该采用哪一个构建工具。 (3) make 是一个标准的 Unix 构建工具,用于自动化编译过程。它读取 Makefile 中的规则和依赖项,并根据这些规则来构建源代码。make 会检查源代码文件的时间戳,以确定哪些文件需要重新编译。它会自动解决依赖关系并按正确的顺序编译源文件。 通过在终端中运行 make 命令,make 将根据 Makefile 中的指令逐步构建代码,生成最终的可执行程序或库文件。