要从多个 C++ 文件构建原生 Mac 可执行文件,请将所有必需的实现文件传递给 clang++,或者先将它们编译为目标文件,再将这些目标文件链接在一起。本指南将构建一个小型 Terminal 程序,并说明如何诊断缺失的定义。

准备 Mac 编译器工具

如果你想在本地 Terminal 中运行一个小型 C++ 程序,请使用此工作流程。Apple 通过 Xcode 或独立的 Command Line Tools 软件包提供编译器和 macOS SDK。如果你已经安装了 Xcode,就不需要单独安装该软件包。否则,运行以下命令并完成安装对话框。参见 Apple 的安装命令行工具。

xcode-select --install

安装后,检查编译器是否可用:

clang++ --version

即使输入已经编译为目标文件,最终链接时也要使用 clang++。Clang 文档明确建议 C++ 程序使用此驱动程序,因为语言模式会决定它自动链接哪些运行时库。参见组装完整工具链。

构建一个由三个文件组成的完整 C++ 程序

这个虚构示例用于统计三个桌面托盘上的物品并格式化结果。程序有一个入口点和两个辅助实现文件。新建一个空工作目录,然后将以下四个文件直接保存在其中:

desk-demo/
  desk.hpp
  main.cpp
  label.cpp
  totals.cpp

将共享声明保存为 desk.hpp:

#ifndef DESK_DEMO_DESK_HPP
#define DESK_DEMO_DESK_HPP

#include <string>

std::string make_label(int count);
int total_items(int trays, int per_tray);

#endif

将入口点保存为 main.cpp:

#include "desk.hpp"
#include <iostream>

int main() {
    const int count = total_items(3, 4);
    std::cout << make_label(count) << '\n';
    return 0;
}

将格式化实现保存为 label.cpp:

#include "desk.hpp"

std::string make_label(int count) {
    return "Desk items: " + std::to_string(count);
}

将计数实现保存为 totals.cpp:

#include "desk.hpp"

int total_items(int trays, int per_tray) {
    return trays * per_tray;
}

每个实现文件都包含同一个头文件,因此编译器可以根据共享声明检查函数定义。头文件保护宏可防止在一个翻译单元中重复包含。将这些普通的非内联函数体保留在各自的实现文件中;C++ 通过其单一定义规则对整个程序中的定义提出要求。

用一条命令编译并链接

在包含这四个文件的目录中打开 Terminal,然后运行:

clang++ -std=c++17 main.cpp label.cpp totals.cpp -o desk-demo && ./desk-demo

预期输出为:

Desk items: 12

该命令列出了全部三个实现文件,并通过 -o 设置输出文件名。由于没有停止某个阶段的选项,Clang 会编译输入文件并调用链接器。显式指定 -std=c++17,即可选择语言版本,而不依赖编译器默认值。这些选项记录在 Clang 编译器命令指南中。

由于命令之间用 && 连接,只有构建成功后,shell 才会运行可执行文件。这在重新构建时很重要:构建失败后不应意外运行旧的可执行文件,并误以为其输出是新结果。

何时适合使用通配符?

对于这个确切的目录,以下命令可以替代显式的源文件列表:

clang++ -std=c++17 ./*.cpp -o desk-demo && ./desk-demo

shell 会将 ./*.cpp 展开为当前目录中匹配的文件。它不会搜索嵌套文件夹,也不会包含以 .cc 或 .cxx 结尾的文件。显式指定包含空格的路径时,请为各个路径加引号。

只有在所有匹配文件都属于这个可执行文件时才使用通配符。如果之后添加了一个带有自身 main 的独立示例程序,或添加了特定平台的替代实现,请使用显式源文件列表。此处的“所有实现文件”指一个目标所需的全部文件,并且每个必需定义都要以适当方式提供。

先编译目标文件,再将它们链接起来

将编译和链接分开,更容易看出故障发生在编译某个源文件时,还是在组合程序时。使用同样的四个文件和工作目录,运行:

(
    set -e
    mkdir -p build
    clang++ -std=c++17 -c main.cpp -o build/main.o
    clang++ -std=c++17 -c label.cpp -o build/label.o
    clang++ -std=c++17 -c totals.cpp -o build/totals.o
    clang++ build/main.o build/label.o build/totals.o -o build/desk-demo
    ./build/desk-demo
)

-c 选项会生成目标文件而不进行链接。最后一次编译器调用接收三个目标文件并将它们链接为可执行文件。这符合 Clang 文档所述的编译阶段。子 shell 中的 set -e 会在某条命令失败时停止这个顺序执行的流程。

这里也要显式指定目标文件名。在重复使用的构建目录中链接所有目标文件,可能会意外包含程序早期版本留下的过时文件。如果不同文件夹中的两个源文件同名,请为它们指定不同的目标文件路径,不要将它们写入同一个输出文件名。

如果只更改 label.cpp,可以重新编译该目标文件,然后重复链接命令。如果更改了 desk.hpp,则应重新构建所有包含它的源文件。这种手动工作流程要求你跟踪这些依赖关系;项目规模扩大后,使用构建系统会更实用。

更改构建命令前先诊断故障

以下步骤根据本示例应用编译阶段和 C++ 定义规则。具体诊断措辞会因安装的工具链而异。

现象 首先检查 适当的下一步
报告找不到头文件 include 写法和头文件位置 恢复上述目录布局,或配置正确的头文件搜索目录。
链接时提示 make_label 或 total_items 未定义 其实现是否作为输入提供 将缺失的 .cpp 文件或其编译后的目标文件加入链接。
即使包含了对应文件,函数仍未定义 声明和定义的签名 比较命名空间、参数类型和限定符;在实现文件中包含共享头文件。
出现重复符号 是否重复提供了实现输入 移除重复的目标文件、被包含的 .cpp 文件或意外的替代定义。
main 未定义或重复 入口点文件 恰好包含一次预期的 main.cpp,并排除其他程序的入口点。
C++ 库符号未定义 最终链接驱动程序 使用 clang++,以便提供 C++ 运行时库。

例如,仅编译 main.cpp 时,头文件中声明了这两个函数,因此调用可以通过编译。但最终链接仍然需要它们的定义。再包含一次头文件并不能提供缺失的实现目标文件。相关要求见 C++ 单一定义规则。

不要通过在 main.cpp 中包含 label.cpp 来修复缺失符号,同时又独立编译 label.cpp。这样会通过两个翻译单元提供相同的普通函数定义。共享头文件和单独提供的实现文件已经构成了预期的结构。

若要在不执行构建的情况下查看工具命令,请使用 Clang 文档中的 -### 选项。请将它加上引号,以便 shell 原样传递:

clang++ '-###' -std=c++17 main.cpp label.cpp totals.cpp -o desk-demo

这会显示驱动程序选择的编译器和链接器调用,有助于区分你预期执行的命令和工具链实际会运行的命令。参见组装完整工具链。

确保架构、平台和 ABI 兼容

加入所有源文件也无法修复不兼容的二进制输入。对于本地构建,先使用一套已安装的 Apple 工具链,并一起重新构建程序的源文件。添加预编译库时,请先检查它们支持的平台和架构,再更改源文件列表。

若要构建可选的通用二进制文件,Apple 文档建议针对每种架构分别编译和链接,然后使用 lipo 合并生成的可执行文件。在支持这两个目标的工具链和 SDK 下,可按以下方式构建同一个示例。下方的 macOS 11 部署目标仅为示例选择:

(
    set -e
    mkdir -p build
    clang++ -std=c++17 -target arm64-apple-macos11 \
        main.cpp label.cpp totals.cpp -o build/desk-arm64
    clang++ -std=c++17 -target x86_64-apple-macos11 \
        main.cpp label.cpp totals.cpp -o build/desk-x86_64
    lipo -create build/desk-arm64 build/desk-x86_64 \
        -output build/desk-universal
    lipo -archs build/desk-universal
)

每个链接的依赖项都必须为相应架构构建提供兼容的输入。iOS 二进制文件不会仅仅因为它和 macOS 二进制文件都使用 arm64,就变成 macOS 二进制文件。Apple 在构建通用 macOS 二进制文件中介绍了架构切片、平台区别和可执行文件检查。

C++ 语言版本也不同于二进制兼容性,即 ABI。仅匹配 -std=c++17 并不能证明分别构建的库具有兼容的布局和配置。LLVM 文档介绍了会影响 ABI 的库配置标志,并指出用户不应自行设置这些供应商标志。对于此任务,采用工具链的常规库配置是更实用的选择。参见 libc++ 的 ABI 保证。

在 Xcode 或混合 Swift 项目中使用相同的源文件列表

在现有 Xcode 目标中,选择该目标并打开 Build Phases。检查 Compile Sources 中是否列出了该可执行文件所需的实现文件。通过 Link Binary with Libraries 添加自定义库依赖项。Apple 在自定义目标的构建阶段中说明了这两项不同职责。

对于此示例,源文件列表应包含 main.cpp、label.cpp 和 totals.cpp。将头文件加入项目并不能替代对实现文件的编译。

如果外围应用使用 Swift,上述纯 C++ 命令只涵盖 C++ 程序。Swift 文档所述的流程要求启用 C++ 互操作功能,以便跨语言公开 API;跨越该边界的只能是受支持的语言功能。参见混合使用 Swift 和 C++。

混合使用 Swift 和 C++ 的代码必须使用相同的 C++ 标准库;在 Apple 平台上,Swift 使用 libc++。Swift 还记录了编译器和语言版本限制,包括互操作要求 C++14 或更高版本。将现有库视为兼容依赖项之前,请先检查这些要求。参见 C++ 互操作支持的功能和限制。

对于本地 Terminal 任务,请从显式列出三个源文件的构建命令开始。如果失败,请确定失败的阶段,检查缺失或重复的定义,并确认二进制输入兼容。该命令成功输出预期结果后,就能为更大型的构建提供一份具体的源文件列表。

来源