使用 `make` 编译 C 程序(面向非 C 程序员)
我从来都不是 C 程序员,但每隔一段时间就需要从源码编译 C/C++ 程序。这对我来说一直有点吃力:很长一段时间里,我的做法基本上就是“安装依赖、运行 make,如果不行,就试着找别人编译好的二进制文件,找不到就放弃”。
“指望别人已经编译好了”这一招在我用 Linux 的时候还挺管用,但过去几年改用 Mac 后,我越来越多地遇到必须自己编译程序的情况。
所以我们来聊聊编译 C 程序可能需要做些什么!我会用几个自己编译过的具体 C 程序作为例子,讲讲可能会出什么问题。我们要讨论编译的三个程序是:
第 1 步:安装 C 编译器
这一步很简单:在 Ubuntu 系统上,如果还没有 C 编译器,我会这样安装:
sudo apt-get install build-essential
这会安装 gcc、g++ 和 make。在 Mac 上的情况有点复杂,大概就是“安装 Xcode 命令行工具”之类的。
第 2 步:安装程序的依赖
与一些较新的编程语言不同,C 没有依赖管理器。所以如果程序有任何依赖,你得自己去找。好在正因为如此,C 程序员通常会把依赖保持得非常精简,而且这些依赖往往在你用的包管理器里就能找到。
README 里几乎总会有一节说明如何获取依赖,例如在 paperjam 的 README 里写道:
要编译 PaperJam,你需要 libqpdf 和 libpaper 库的头文件(通常以 libqpdf-dev 和 libpaper-dev 软件包的形式提供)。
你可能需要
a2x(可在 AsciiDoc 中找到)来构建手册页。
所以在基于 Debian 的系统上,你可以这样安装依赖。
sudo apt install -y libqpdf-dev libpaper-dev
如果 README 给出了某个包的名字(比如 libqpdf-dev),我基本都会默认它指的是“在基于 Debian 的 Linux 发行版中”:如果你在 Mac 上,brew install libqpdf-dev 是行不通的。我在 Mac 上开发还没有完全上手,所以在这方面也没什么太多心得。我猜在这种情况下,如果你用 Homebrew 的话应该是 brew install qpdf。
第 3 步:运行 ./configure(如果需要)
有些 C 程序自带 Makefile,而有些则带有一个叫 ./configure 的脚本。例如,如果你下载 sqlite 的源码,里面就是一个 ./configure 脚本,而不是 Makefile。
我对这个 ./configure 脚本的理解是:
- 你运行它,它会打印出一大堆有点让人看不懂的输出,然后要么生成一个
Makefile,要么因为缺少某个依赖而失败 ./configure脚本是名为 autotools 的系统的一部分,除了“运行它来生成Makefile”之外,我从未需要去了解它的其他任何东西。
我想也许可以传一些选项让 ./configure 脚本生成不同的 Makefile,但我从没这么做过。
第 4 步:运行 make
下一步是运行 make 来尝试构建程序。关于 make 的一些说明:
- 有时你可以运行
make -j8来并行构建,让编译更快 - 它在编译程序时通常会打印出一大堆编译器警告。我一向直接忽略。我又没写这个软件!编译器警告不是我的问题。
编译器报错往往是依赖问题
这是我在 Mac 上编译 paperjam 时遇到的一个报错:
/opt/homebrew/Cellar/qpdf/12.0.0/include/qpdf/InputSource.hh:85:19: error: function definition does not declare parameters
85 | qpdf_offset_t last_offset{0};
| ^
这些年来我学到,遇到这类问题最好不要想得太复杂:如果报错提到了 qpdf,很可能只是说明我在引入 qpdf 依赖的方式上出了问题。
现在我们来聊聊几种以正确方式引入 qpdf 依赖的方法。
关于编译器和链接器的极简介绍
在讲如何修复依赖问题之前:构建 C 程序分为 2 步:
- 编译代码生成目标文件(用
gcc或clang) - 链接这些目标文件生成最终的二进制文件(用
ld)
构建 C 程序时了解这一点很重要,因为有时你需要向编译器和链接器传递正确的标志,告诉它们去哪里找你要编译的程序所依赖的东西。
make 使用环境变量来配置编译器和链接器
如果我在 Mac 上运行 make 来安装 paperjam,会得到这个报错:
c++ -o paperjam paperjam.o pdf-tools.o parse.o cmds.o pdf.o -lqpdf -lpaper
ld: library 'qpdf' not found
这并不是因为我的系统上没有安装 qpdf(实际上已经装了!)。而是编译器和链接器不知道如何找到 qpdf 库。要修复这个问题,我们需要:
- 向编译器传递
"-I/opt/homebrew/include"(告诉它去哪里找头文件) - 向链接器传递
"-L/opt/homebrew/lib -liconv"(告诉它去哪里找库文件,并链接iconv)
而我们可以通过环境变量让 make 把这些额外参数传给编译器和链接器!看看这是怎么工作的:在 paperjam 的 Makefile 里你可以看到一堆环境变量,比如这里的 LDLIBS:
paperjam: $(OBJS)
$(LD) -o $@ $^ $(LDLIBS)
你放到 LDLIBS 环境变量里的所有内容都会作为命令行参数传给链接器(ld)。
隐藏的环境变量:CPPFLAGS
Makefiles 有时会定义自己的环境变量并传给编译器/链接器,但 make 也有一堆“隐式”环境变量,它会自动传给 C 编译器和链接器。这里有一份隐式环境变量的完整列表,其中之一就是 CPPFLAGS,它会自动传给 C 编译器。
(严格来说,用 CXXFLAGS 来做这件事会更规范,但这个特定的 Makefile 硬编码了 CXXFLAGS,所以设置 CPPFLAGS 是我找到的唯一一种无需修改 Makefile 就能设置编译器标志的办法)
向 make 传递环境变量的两种方式
多亏了 @zwol,我了解到实际上有两种向 make 传递环境变量的方式:
CXXFLAGS=xyz make(常见的方式)make CXXFLAGS=xyz
它们之间的区别在于,make CXXFLAGS=xyz 会覆盖 Makefile 中设置的 CXXFLAGS 值,而 CXXFLAGS=xyz make 则不会。
我不确定哪种方式更常见,但在这篇文章里我会用第一种方式。
如何使用 CPPFLAGS 和 LDLIBS 修复这个编译错误
既然我们已经讲了 CPPFLAGS 和 LDLIBS 是如何传给编译器和链接器的,下面就是我用来让程序成功构建的最终咒语!
CPPFLAGS="-I/opt/homebrew/include" LDLIBS="-L/opt/homebrew/lib -liconv" make paperjam
这会把 -I/opt/homebrew/include 传给编译器,把 -L/opt/homebrew/lib -liconv 传给链接器。
另外,我不想假装自己“神奇地”就知道该传这些正确的参数,搞清楚它们的过程涉及大量一头雾水的搜索,我在这篇文章里都略过了。我想说的是:
-I编译器标志告诉编译器去哪个目录找头文件,比如/opt/homebrew/include/qpdf/QPDF.hh-L链接器标志告诉链接器去哪个目录找库,比如/opt/homebrew/lib/libqpdf.a-l链接器标志告诉链接器要链接哪些库,比如-liconv意思是“链接iconv库”,而-lm意思是“链接math”
技巧:如何只构建某一个特定文件:make $FILENAME
昨天我发现了一个很酷的工具叫 qf,你可以用它从 ripgrep 的输出中快速打开文件。
qf 在一个包含各种工具的大目录里,但我只想编译 qf。所以我就只编译了 qf,像这样:
make qf
基本上,如果你知道(或能猜到)你要构建的文件的输出文件名,就可以通过运行 make $FILENAME 来让 make 只构建那个文件
技巧:你不需要 Makefile
我有时会写一些没有依赖的 5 行 C 程序,刚了解到如果我有一个叫 blah.c 的文件,即使不创建 Makefile,也可以这样直接编译它:
make blah
它会自动展开为 cc -o blah blah.c,能省下一点输入。我不知道自己会不会记住这个(我可能还是会继续敲 gcc -o blah blah.c),但这似乎是个有趣的小技巧。
技巧:看看其他打包系统是如何构建同一个 C 程序的
如果你在构建 C 程序时遇到困难,也许别人也遇到过同样的问题!每个 Linux 发行版都为其构建的每个软件包提供了构建文件,所以即使你无法直接安装那个发行版的软件包,也许也能从那个发行版那里获得如何构建该软件包的线索。意识到这一点(感谢我的朋友 Dave(戴夫))对我来说是一个巨大的顿悟时刻。
例如,nix 上 paperjam 软件包的这一行写道:
env.NIX_LDFLAGS = lib.optionalString stdenv.hostPlatform.isDarwin "-liconv";
这基本上是在说“在 Mac 上构建时要传递链接器标志 -liconv”,所以这就是我们可以用来构建它的一个线索。
同一个文件里还写着 env.NIX_CFLAGS_COMPILE = "-DPOINTERHOLDER_TRANSITION=1";。我不确定这是什么意思,但当我尝试构建 paperjam 软件包时,确实会遇到一个关于叫 PointerHolder 的东西的报错,所以我想这应该和“PointerHolder 迁移”有关。
第 5 步:安装二进制文件
一旦你成功编译了程序,大概就想把它安装到某个地方!有些 Makefile 有一个 install 目标,让你可以用 make install 把工具安装到系统上。我对此总是有点担心(它会把文件放到哪里?如果以后想卸载怎么办?),所以如果编译的是一个相当简单的程序,我通常会直接手动复制二进制文件来安装,像这样:
cp qf ~/bin
第 6 步:也许可以制作你自己的软件包!
弄明白这一切该怎么做之后,我意识到可以利用新学到的 make 知识为 Homebrew 贡献一个 paperjam 软件包!这样以后在其他系统上我就可以直接 brew install paperjam 了。
好处在于,即使所有不同打包系统的细节各不相同,它们本质上都是在使用 C 编译器和链接器。
即使你不是 C 程序员,了解一点 C 也会很有用
我觉得这一切是个很有趣的例子,说明了即使你这辈子都不打算写一个像样的 C 程序,了解一些 C 程序如何工作的基础知识(比如“它们有头文件”)也会很有用。
能够自己编译 C/C++ 程序感觉很好,即使我对所有的编译器和链接器标志还不是完全有把握,而且我仍然打算除了“运行 ./configure 来生成 Makefile”之外,永远不去学 autotools 是如何工作的。
这篇文章中我省略了两件事:
LD_LIBRARY_PATH / DYLD_LIBRARY_PATH(你用它在运行时告诉动态链接器去哪里找动态链接的文件),因为我记不起上次遇到LD_LIBRARY_PATH问题是什么时候了,也找不到例子。pkg-config,我觉得它很重要,但我还没弄懂
随机一篇博客