用 `make` 编译 C 程序(写给非 C 程序员)
原文由 Julia Evans 于 发布,订阅该博客
我从来不是 C 程序员,但时不时还是需要从源码编译 C/C++ 程序。这对我来说一直有点吃力:很长一段时间里,我的做法基本上就是“先装好依赖,然后运行 make,如果不行,就去找别人编译好的二进制包,找不到就放弃”。
用 Linux 的时候,“指望别人已经编译好了”这招还挺管用,但过去几年改用 Mac 之后,需要自己动手编译的情况就变多了。
那么我们来聊聊编译 C 程序可能需要做些什么吧!我会用几个自己编译过的具体程序来举例,讲讲可能会出什么问题。我们接下来要讨论的三个程序是:
步骤 1:安装 C 编译器
这一步很简单:在 Ubuntu 系统上,如果还没有 C 编译器,我会这样安装:
sudo apt-get install build-essential
这会装上 gcc、g++ 和 make。在 Mac 上情况要复杂一点,大概就是“安装 Xcode Command Line Tools”。
步骤 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 程序分为两步:
- 编译代码为目标文件(用
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)让我有种恍然大悟的感觉。
比如,来自 paperjam 的 nix 软件包的这一行写道:
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,我觉得它很重要,但还没搞懂
随机一篇博客
评论
登录后参与讨论