Highlight
Xcode 12 支持把 Mac app 编译成同时包含 arm64 与 x86_64 的 Universal app,并要求开发者检查二进制依赖、低层计时代码、同步原语和插件架构匹配。
核心内容
Mac 迁移到 Apple Silicon 以后,很多团队最担心的是重写。一个已经运行多年的 Mac app,里面可能有 AppKit UI、C 代码、二进制 framework、命令行构建脚本、插件系统和性能优化代码。过去从 PowerPC 到 Intel 的经验会让人想到字节序、架构分支和大量条件编译。
这场 session 给出的第一步很直接:打开 Xcode 12,选择 My Mac,按 Run。演讲中的 Solar System 示例没有改项目设置,就编译并以 Apple CPU 架构运行。原因是 Xcode 12、SDK 和工具链已经能在任意 Mac 上交叉编译 arm64 与 x86_64。对高层 AppKit 代码来说,迁移常常先从一次普通构建开始。
麻烦出现在旧假设里。代码可能用 __x86_64__ 表示“这是 Mac”,也可能把 mach_absolute_time() 当成纳秒,或者依赖一个只有 Intel slice 的 .framework。这些代码在 Intel Mac 上跑了很多年,到了 arm64 构建、原生运行或插件加载时才会暴露。
Apple 的路线是把迁移拆成可检查的步骤:先确认 Intel 构建仍然通过,再切换到原生架构修编译错误,然后处理 link-time 的二进制依赖,最后在 Apple Silicon Mac 上跑测试、调试、性能分析和插件验证。Universal app 不是一次开关,它是一份检查清单。
详细内容
从 Xcode 12 构建 Universal app
(02:19)Xcode 12 的运行目的地可以选择原生 Apple Silicon、Rosetta,或者构建同时支持 Apple Silicon 与 Intel 的 Universal app。演讲强调,工具链能在 Intel Mac 上交叉编译,所以构建 Universal app 不要求开发机已经是 Apple Silicon Mac。
关键点:
- Mach-O 文件可以只包含一个 CPU 架构,也可以是同时包含 arm64 与 x86_64 的 Universal binary。
lipo -info是演讲中用来检查.a、.dylib、.framework、.xcframework架构信息的命令。- 命令行构建时先用
xcodebuild -showdestinations查看目的地,再用-destination指定测试运行在哪个架构。 arch=arm64对应 Apple Silicon 原生测试路径,arch=x86_64对应在 Apple Silicon Mac 上通过 Rosetta 跑 Intel 代码的测试路径。
(09:04)第一个构建坑是页面大小。演讲提到 Apple Silicon 系统的 native page size 与 Intel 机器不同,PAGE_SIZE 不再适合作为编译期常量。
关键点:
PAGE_MAX_SIZE表示编译期可用的最大页大小上界。vm_page_size在运行期返回当前进程看到的页大小。- Rosetta 会匹配 Intel 环境,translated process 仍支持 4 KB pages,因此这个问题要在原生路径测试。
用语义条件替代 CPU 条件
(10:09)演讲中的示例函数 GetDefaultTimerClass 用 __x86_64__ 表示 macOS 和 simulator。到了 Apple Silicon Mac,这个条件失效。CPU 架构描述的是指令集,平台条件描述的是系统环境,两者不能混用。
关键点:
TARGET_OS_OSX和 Swift 的#if os(macOS)表达“这段代码跑在 macOS”。TARGET_CPU_X86_64和 Swift 的#if arch(x86_64)只在代码确实依赖 Intel 指令或 ABI 时使用。- 如果只有 Intel 汇编或 SSE/AVX 实现,需要给 arm64 提供第二套实现,或改用 Accelerate、Compression 等系统框架。
修复非 Universal 二进制依赖
(12:54)演讲中的 demo app 在 arm64 链接阶段失败,错误来自一个预编译 binary framework。linker 报出 ignoring file when building for macOS-arm64,说明项目正在链接一个只含 x86_64 的文件。
关键点:
- link-time 的
Undefined symbols for architecture arm64可能是下游 binary 没有 arm64 slice。 .framework目录本身不是 Mach-O 文件,要检查 framework 里的实际可执行文件。- 迁移时要扫描
.a、.dylib、.framework和.xcframework,再用lipo -info确认每个预编译依赖是否 Universal。 - 第三方 binary 需要供应商发布 Universal build;短期排查时可以临时移除依赖,但发布前要换回 Universal 版本。
不要把 mach_absolute_time 当成纳秒
(21:19)演讲的 runtime bug 出现在进度条刷新频率上。旧代码把 mach_absolute_time() 的 tick 当成纳秒,然后除以 1_000_000_000 得到秒。这个假设在不同 CPU 架构上不成立。
// Don’t assume the time is returned in nanoseconds
func monotonicTimestampInSeconds() -> Double {
let ticks = mach_absolute_time()
let seconds = Double(ticks) / 1_000_000_000
return seconds
}
关键点:
mach_absolute_time()返回 tick units,不保证 tick 等于 1 纳秒。- 代码把 ticks 当纳秒,会让定时器在某些架构上变慢,演讲提到常见现象是大约慢 40 倍。
- 这类 bug 通常不会在编译期暴露,必须在 Apple Silicon Mac 上跑原生路径。
(21:40)如果确实需要单调时间戳,演讲建议改用返回纳秒的 clock_gettime_nsec_np(CLOCK_UPTIME_RAW),或者使用 Foundation 与 GCD 的 timer。
// Use clock_gettime_nsec_np to read timestamp in nanoseconds
func monotonicTimestampInSeconds() -> Double {
let nanoseconds = clock_gettime_nsec_np(CLOCK_UPTIME_RAW)
let seconds = Double(nanoseconds) / 1_000_000_000
return seconds
}
关键点:
clock_gettime_nsec_np(CLOCK_UPTIME_RAW)直接返回纳秒,后续除法才有明确单位。- 函数名保留 seconds 语义,调用方不用知道底层时间 API 已替换。
- 搜索代码库里的
mach_absolute_time是迁移检查中的低成本动作。
用阻塞同步替代 spinlock 和 busy-wait
(26:40)Apple Silicon Mac 有性能核心与能效核心。系统会根据性能和电量动态调度。演讲特别提醒,spinlock 和 busy-wait 可能白白占住性能核心,拖慢整体工作完成时间。
func performWorkUnderSpinlock() {
spinlock_lock()
performWork()
spinlock_unlock()
}
func retrieveNextWorkTask() -> WorkTask {
while true {
let task = queue.sync { taskQueue.pop() }
if let task = task { return task }
else { continue }
}
}
关键点:
spinlock_lock()会在拿不到锁时继续消耗 CPU。while true循环反复检查队列是否有任务,这是 busy-wait。- 在非对称 CPU 系统上,这类等待可能占用更适合执行真实任务的 P cores。
(27:03)替代方案是使用阻塞锁和条件变量。线程无法继续时应让出 CPU,等条件满足再恢复。
func performWorkUnderSpinlock() {
os_unfair_lock_lock()
performWork()
os_unfair_lock_unlock()
}
func retrieveNextWorkTask() -> WorkTask {
condition.lock()
while !taskQueue.hasAnyWork {
condition.wait()
}
let task = taskQueue.pop()
condition.unlock()
return task
}
关键点:
os_unfair_lock_lock()是阻塞型同步原语,适合替代自旋等待。condition.wait()在没有任务时阻塞线程,不反复占用 CPU。- 演讲还列出 NSLock、pthread mutex、NSCondition、pthread condition variables,并建议能用 GCD 时优先用 GCD。
插件必须匹配进程架构
(30:16)插件迁移的核心规则很简单:同一个进程里的所有代码必须使用同一种 CPU 架构。in-process 插件用 dlopen 或 Bundle.load 加载,如果 host 是原生 arm64,插件也必须有 arm64 slice;如果 host 在 Rosetta 下运行,插件必须能作为 Intel 代码加载。
void *plugin_module = dlopen("./path/to/plugin.dylib", RTLD_NOW);
if (plugin_module == NULL) {
fprintf(stderr, "loading module failed:\n");
fprintf(stderr, "%s\n", dlerror());
return 0;
}
关键点:
dlopen返回NULL时必须调用dlerror(),错误信息会指出磁盘上的文件架构不匹配。- 第一方插件应删除硬编码的
x86_64Architectures 设置,让 Xcode 使用 Standard Architectures。 - 第三方 in-process 插件如果仍是 Intel-only,用户可能只能强制 app 通过 Rosetta 打开。
- out-of-process 插件可通过 XPC 隔离成独立进程,一份 app 最多维护按架构区分的插件进程。
发布前看三类 crash log
(36:39)Xcode Organizer 会显示 archive 中包含的 CPU 架构,也会在 crash log 里显示崩溃来自哪个架构、进程是否处于 translated 状态。Universal app 发布后,开发者会看到 Intel Mac 上的 x86 crash、Apple Silicon Mac 上的 native arm64 crash,以及 Apple Silicon Mac 上 Rosetta 路径的 translated x86 crash。
关键点:
- Debug 构建通过不代表 Archive 构建也通过,因为 Archive 可能第一次真正构建 Universal 产物。
- 发布前要确认 Archive 产物包含 arm64 与 x86_64,并分别跑原生 arm64 与 Rosetta x86_64 测试。
- 插件和二进制依赖仍要用
lipo -info检查,避免发布后才发现某个路径只能在 Intel 下工作。 - Thread Sanitizer 适合排查迁移后暴露的数据竞争;演讲指出 Intel 与 Apple Silicon 的 memory ordering model 不同。
- kernel extension 和 DriverKit 相关软件要额外阅读 macOS porting 文档,因为部分内核扩展在 Apple Silicon Mac 上被弃用或不允许使用。
核心启发
1. 给现有 Mac app 做一页 Universal 迁移仪表盘
- 做什么:在团队内部做一个脚本,扫描
.a、.dylib、.framework、.xcframework、mach_absolute_time、__x86_64__、PAGE_SIZE和硬编码 Architectures。 - 为什么值得做:这场 session 的迁移问题大多能被静态扫描提前发现,尤其是二进制依赖和架构条件。
- 怎么开始:用 shell 或 SwiftPM command plugin 包装
find、lipo -info、rg,输出每个目标是否有 arm64 slice,以及需要人工确认的源代码位置。
2. 在 CI 中把 Mac 测试拆成 arm64 与 x86_64 两条路径
- 做什么:同一个 test scheme 分别用
arch=arm64与arch=x86_64跑,记录哪条路径失败。 - 为什么值得做:演讲明确要求原生和 Rosetta 都测试;只跑一种架构会漏掉非 portable code。
- 怎么开始:在 Apple Silicon runner 上为
xcodebuild test增加两个 destination,并把性能测试结果按架构上传到 dashboard。
3. 给插件型 app 增加架构诊断面板
- 做什么:当插件加载失败时,把
dlerror()、插件路径、host 当前架构和lipo -info结果展示给用户或写入诊断日志。 - 为什么值得做:in-process 插件的失败常来自架构不匹配,普通用户只看到功能失效,很难知道该更新插件还是切换 Rosetta。
- 怎么开始:在
dlopen或Bundle.load失败分支收集错误,附带“插件供应商需要提供 Universal build”的提示,并给用户打开支持文档的入口。
4. 重写低层性能代码的等待策略
- 做什么:找出自旋锁、空循环取队列、按 CPU 数切分任务的旧实现,改成 GCD、阻塞锁或条件变量。
- 为什么值得做:Apple Silicon Mac 的 P cores 和 E cores 会动态调度,busy-wait 会浪费性能核心。
- 怎么开始:先替换最热路径里的 spinlock,再用 Instruments 对比原生 arm64 下 CPU 占用和任务完成时间。
5. 给时间相关代码建立架构回归测试
- 做什么:对刷新频率、超时、节流、防抖、benchmark 计时建立单元测试或 UI 测试。
- 为什么值得做:
mach_absolute_time()tick 假设错误会表现成“慢几十倍”的运行期 bug,编译器不会提醒。 - 怎么开始:搜索
mach_absolute_time,优先改用clock_gettime_nsec_np(CLOCK_UPTIME_RAW)或 Foundation/GCD timer,再在 arm64 和 x86_64 destination 下跑同一组测试。
关联 Session
- Explore the new system architecture of Apple silicon Macs — 先理解 SoC、统一内存、Rosetta 与安全启动等平台基础,再做 app 迁移决策。
- iPad and iPhone apps on Apple silicon Macs — 对比已有 Mac app 的 Universal 迁移与 iOS app 原样来到 Mac 的分发路径。
- Meet Audio Workgroups — 音频 app 和音频插件可继续深入 Apple Silicon 上实时线程协作问题。
- Bring your Metal app to Apple silicon Macs — 图形密集型 Mac app 需要额外理解 Apple GPU 与 Metal 迁移细节。
- Modernize PCI and SCSI drivers with DriverKit — 依赖驱动或内核扩展的软件需要学习 DriverKit 路线。
评论
GitHub Issues · utterances