编译链接二三事

4,212 阅读15分钟

日常开发中,有人抱怨Xcode编译慢、有人吐槽App启动慢,有人想尝试热重载....,其实,这些事情背后都离不开编译和链接。

一、概念

1、LLVM

  • 何为编译?简单解释是 把源代码翻译成可执行的代码(机器码);这样,程序在运行时,执行效率快,速度快;而解释器却是在程序运行时,将源码一行行解析成目标代码(字节码),然后再执行;效率自然就差一些;

  • iOS使用LLVM编译器;平常所说的LLVM有狭义广义之分;

    • 广义的LLVM: LLVM编译器架构,包括编译前端(Frontend)、优化器(Optimizer)和编译后端(Backend)三大部分;

    • 狭义的LLVM:LLVM架构的编译后端,主要负责,代码优化、目标代码生成等;

    LLVM项目已经成长为一个十分庞大的项目,已经从最初的Low Level Virtual Machine(低级虚拟机)发展成了The LLVM Compiler Infrastructure(编译器基础设施)。

2、ld和dyld

  • 链接分静态链接动态链接

  • 静态链接:静态链接器(ld64)将编译产生的一个或多个目标文件组合生成一个可执行文件(Mach-O);

  • 动态链接:程序运行时(或首次启动,或第一次使用到),通过动态链接器(dyld)将链接需要的动态库;

  • 使用dyld加载动态库有两个时机:

    • 少部分动态库在程序启动加载时,通过dyld链接。
    • 大部分动态库的第一次使用时,才通过dyld链接(减少启动耗时)。

​ 对应的,在Mach-O文件的__DATA.__nl_symbol_ptr非懒加载符号表),存储了non-lazily绑定的符号,这些符号在Mach-O加载时绑定;

__DATA.__la_symbol_ptr懒加载符号表)存储了lazy绑定的符号,这些方法在第一调用时,由dyld_stub_binder来绑定;每个Mach-O的non-lazily绑定符号都有dyld_stub_binder

3、JIT和AOT

  • 根据是够在运行期编译,编译方式分AOT(Ahead Of Time,运行前编译) 和 JIT(Just-in-time,即时编译);Android N引入混合编译模式(AOT和JIT);
  • 跨端UI框架Flutter同样支持AOT和JIT;在Debug 模式下,采用 JIT即时编译,Release 模式下采用的是 AOT 静态编译。其中,JIT将 Dart 代码编译成可以运行在 Dart VM 上的 Dart Kernel,而 Dart Kernel 是可以动态更新的;

二、LLVM简介

1、LLVM架构

  • LLVM架构采用经典的编译器架构(三段式)设计:

    • 前端 (Frontend) :对源码做词法分析、语法分析、语义分析、生成中间代码
    • 优化器 (Optimizer) :用于中间代码优化
    • 后端 (Backend) :根据中间代码,用于生成对应的 CPU架构的机器码

三段式设计

  • LLVM架构示意如下:

    LLVM架构

  • Objective C/C/C++语言使用的编译器前端是 ClangSwift是 Swift 语言的编译器,完整的表示是:Swift Compiler

  • 从LLVM架构示意图,也能明白这种三段式架构的优势:

    • 增加新的语言(如Swift),只需要实现新的 Frontend即可,Optimizer 和 Backend 可以重用;
    • 新增新的 CPU 架构时(如ARM),也只需要实现新的 Backend即可;

2、LLVM编译链接过程

​ 编译源码有四步:预处理 -> 编译 -> 汇编 -> 链接,细分过程:预处理 -> 词法分析 -> 语法分析 -> 生成AST -> 中间代码(LR)生成 -> IR优化 -> 生成汇编代码 -> 汇编 -> 链接 -> 生成Mach-O文件

  • 编译前端(Clang)处理

    • 预处理(preprocessor):替进行头文件引入,宏替换,注释处理,条件编译(#ifdef)等操作
    • 词法分析(lexical anaysis):词法分析器读入源文件的字符流,将他们组织称有意义的词素(lexeme)序列,对于每个词素,此法分析器产生词法单元(token)作为输出。
    • 语法分析(semantic analysis):词法分析产生的词法单元Token流会被解析成一颗抽象语法树(abstract syntax tree - AST);有了抽象语法树,Clang就可以对这个树进行分析,找出代码中的错误。比如类型不匹配,亦或Objective-C中向target发送了一个未实现的消息。
    • CodeGen(中间代码生成):遍历语法树,生成LLVM IR(中间)代码。LLVM IR是编译前端的输出,编译后端的输入。
  • IR处理

  • 编译后端处理

    • 生成汇编代码:LLVM对IR进行优化后,会针对不同CPU架构生成不同的目标代码,最后以汇编代码的格式输出;
    • 汇编:汇编器以汇编代码作为输入,将汇编代码转换为机器代码,最后输出目标文件(Object File),
  • 链接:链接静态库,.o文件,生成一个Mach-O格式的可执行文件,Mach-O文件的更多了解可见: Mach-O文件周边二三事

3、优秀的Clang

  • Clang比GCC有更好的性能(更快速度、更省内存),主要有:

    • 编译速度快,占用内存小,并且兼容 GCC。
    • Clang可以发现显示出问题所在的行和具体位置,并且可以确切地说明出现这个问题的原因,并指出错误的类型是什么
    • 模块化设计:Clang采用基于库的模块化设计,易于IDE集成及其他用途的重用
    • 诊断信息可读性强:在编译过程中,Clang创建并保留了大量详细的元数据(metadata),有利于调试和错误报告;
    • 设计清晰简单,容易理解,易于扩展增强。
  • Clang提供的能力

    • LibClang:LibClang 可以访问 Clang 的上层高级抽象的能力,比如获取所有 Token、遍历语法树、代码补全等。
    • Clang Plugin:允许在AST 上做些操作,这些操作可以被集成到编译中,成为编译的一部分(影响编译过程)。
    • LibTooling:可以完全控制 Clang AST,通过 LibTooling 能够编写独立运行的语言分析和检查工具;
  • 基于 Clang 库的静态分析工具

    • OCLint:默认支持70+检查规则,可定制、能够帮助发现问题
    • Clang Static Analyzer(Clang 静态分析器): C++ 开发的,分析 C、C++ 和 Objective-C 的开源工具
    • Infer:Facebook 开源的静态分析工具,可以检查出 C、Java 和 Objective-C 空指针访问、资源泄露以及内存泄露等问题。
  • Clang和代码混淆

    • 可以利用Clang的LibTooling来实现代码混淆,通过ATS接口,实现对类名、方法名、字符串的混淆;
    • 但是之前做代码混淆,还是通过写脚本,结合宏定义、AES加密等手段实现对类名、方法名和字符串的混淆;了解更多可见浅谈代码混淆

三、静态链接器&动态链接器

1、静态链接器ld64

  • ld64将链接目标文件和静态库等;当链接静态库时,ld64会将库里的目标文件和项目中的目标文件组合生成一个可执行文件;静态库多了,可执行文件体积就大了;

  • 一般,静态库生成的二进制中不裁剪调试符号信息,如果安装包选择裁剪符号,最终的安装包中不会包含静态库的调试符号,这一举措可以减少安装包体积;(调试符号可以合并在 dSYM 文件);

2、ld64对优化的启示

  • 优化编译速度:合并静态库,意味着链接过程中,更少的Page inPage Fault

  • 优化启动速度:修改符号在二进制文件中链接顺序,优化启动性能(二进制重排);

    • 背景:当进程访问一个虚拟内存页,而对应的物理内存却不存在时,会触发一次Page Fault缺页中断),将需要的数据 or 指令从磁盘加载到物理内存页中,建立映射关系,然后再恢复现场;
    • 二进制重排原理:利用XcodeBuild SettingLinking->Order File 选项可以指定链接符号顺序,减少程序加载过程中的缺页中断数。
    • 二进制重排难点:或静态扫描、或运行时trace、或llvm 插桩 或 静态库插桩 找到启动时候函数的执行顺序,函数含括Objective-C函数、C/C++函数等;

    ​ 说明1:App Store渠道分发的App,Page Fault在iOS 13前还会进行签名验证,但是iOS 13后签名验证取消了,相对来说,iOS 13后一次Page Fault耗时少了;

    ​ 说明2:如果指定的符号不存在,ld会忽略;如果提供了link选项-order_file_statistics,会以warning的形式把这些没找到的符号打印在日志里。

3、动态链接器dyld

  • iOS 13系统中,全面采用dyld 3,替代旧版本dyld 2;
  • dyld2从2004年发布以来,历经很多版本迭代;dyld2引入了保障安全的ASLR(地址空间布局随机化)、Code Sign和优化系统库载入性能的shared cache技术;dyld3进一步优化启动时间;
  • dyld会使用共享缓存,共享缓存在 /var/db/dyld/;当加载 Mach-O 文件时,dyld会先检查是否有共享缓存,每个进程都会在自己的地址空间映射这些共享缓存,这样做可以起到优化 App 启动速度的作用。

4、dyld加载过程及启示

  • 过程大概如下:(三步静态调整(fix-up) + 一步动态调整)

    • Load dylibs image:加载Mach-O需要的(部分)动态库镜像;

    • Rebase/Bind image:Rebase修复的是指向当前镜像内部的资源指针; bind指向的是镜像外部的资源指针;

    • Objc setup:注册Objc类 (class registration)、把Category的定义插入方法列表 (category registration)、保证每一个selector唯一 (selctor uniquing)

    • initializers:

      • Objc的+load()函数
      • C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
      • 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度。
  • 优化启示:load函数中尽量少的操作,能延迟的尽量延迟;无用的代码尽早清除掉(主要是为了减少Objective-C类个数,减少selector);

    很多人提到 减少系统库依赖帮助优化启动速度,目前实践和收益评估比较难;两方面原因:

    • 启动时候,加载的系统库只是一部分,大部分还是在第一次使用才会加载(懒加载);
    • dyld加载动态库,是会使用共享缓存的,这对启动速度提升是正向的;

四、编译链接优化

1、查看Xcode编译时间

  • 关闭Xcode
  • 打开终端,输入如下命令后,
defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES
  • 重启Xcode,Build,可以查看编译时间

2、Xcode编译过程

  • 创建app_name.app的文件夹;
  • Entitlements.plist写入到DerivedData里,处理打包的时候需要的信息(如bundle-identifier);
  • 创建一些辅助文件,比如各种.hmap,这是headermap文件;
  • 执行CocoaPods的编译前脚本:检查Manifest.lock文件;
  • 编译.m文件,生成.o文件。
  • 链接动态库,o文件,生成一个Mach-O格式的可执行文件。
  • 编译assets,编译storyboard,链接storyboard
  • 拷贝动态库Logger.framework,并且对其签名
  • 执行CocoaPods编译后脚本:拷贝CocoaPods Target生成的Framework
  • 对app_name.App签名,并验证
  • 生成app_name.app

3、编译的常规优化

  • 使用forward declaration(前向声明):即用@class class_name,而不是#import "class_name.h",这样能减少编译时间;

  • 常用头文件放到预编译文件里:Xcode的pch文件是预编译文件,这里的内容在执行Xcode Build之前就已经被预编译,并且引入到每一个.m文件里了。

  • Debug时Build Active Architecture Only设置为YES:只编译出支持当前CPU架构的安装包;

  • 提高编译线程数:Xcode默认的编译线程数,就是CPU的内核数,可适当增加编译线程数来提高编译速度

    # 获取当前内核数:  
    sysctl -n hw.ncpu  
    
    # 设置编译线程数:  
    defaults write com.apple.dt.Xcode IDEBuildOperationMaxNumberOfConcurrentCompileTasks 12 
    
    # 获取编译线程数:  
    defaults read com.apple.dt.Xcode IDEBuildOperationMaxNumberOfConcurrentCompileTasks  
    
  • Debug时, Debug Information Format改为 DWARF,不需要dSYM 文件,有一定的效果;

  • Debug时,Link-Time Optimization设置为NO:不使用LTO特性

  • Enable Index-While-Building Functionality设置为NO: 默认开启,Xcode 编译时会建立代码索引,影响编译速度;关闭后,在编译时就不会进行索引,而是在空闲时间建立代码索引(自动补全Code、查找定义)

4、Link-Time Optimization(LTO)

  • LTO主要是对链接过程的一个优化,开启LTO有三个好处:

    • 多余代码去除(Dead code elimination):LTO在link时候,发现跨多文件的无用代码;

    • 跨过程优化(Interprocedural analysis and optimization):最终不会执行的代码,在二进制结果文件中不出现;

    • 内联优化(Inlining optimization):汇编中不使用 “call func_name” 语句,直接将外部方法内的语句“复制”到调用者的代码段内。好处是不用进行调用函数前的压栈、调用函数后的出栈操作,提高运行效率与栈空间利用率。

  • LTO有MonilithicIncremental两个方式选择,Release下,建议选择IncrementalDebug禁用LTO,因为LTO对符号剥离有点影响,可能会影响断点的单步执行;

  • 选择Incremental这个优化方式,会有link cache,使二次编译的速度更快(只编译和链接少数修改过的文件),另一方面它还可能减小Code Size;而Monilithic 方式并不支持多线程和增量链接

  • 开启 Incremental 的选项后,如果出现duplicate symbols错误,可以看下代码,有可能是全局变量使用不规范,应该:在定义时,必须使用 static 关键字或者单独在 .m 文件中定义,.h 文件中只能声明变量,而不应该定义变量;

5、总结

  • 了解Xcode的编译链接过程,使用常规的优化手段,对提升编译速度有一定的效果;
  • 升级硬件设备也是办法之一;体验过:13.3英寸Mac Pro -> 15.4英寸Mac Pro带来的编译提升体验;
  • 如果要进一步优化编译链接速度,做的事情就很多了
    • 模块/组件拆分,沉库,组件集成和发版
    • Debug模式下,相关的用源码,无关的用二进制;
    • 多个静态库合并;
    • .....

五、热重载方案

1、Flutter的热重载

  • Flutter 是跨平台UI框架,使用自研的 Dart 语言配合在 App 内集成 Dart VM 的方式运行 Flutter 程序。

  • Flutter在Debug模型下,支持JIT(Just-in-time,即时编译)模式,可以实现实时编译,热重载,极大提高了调试效率;

  • Flutter实现热重载的原理:JIT将 上次编译后改动过的代码 及其关联到的代码库编译成Dart Kernel,发送到 Dart VM 里,Dart VM 会重新加载新的Dart Kernel,加载后会让 Flutter framework 触发所有的 Widgets (控件)和 Render Objects 进行重建、重布局、重绘。

    运行在 Dart VM 上的 Dart Kernel是可以动态更新的

2、Swift UI的“热重载”

  • SwiftUI是Apple在2019年WWDC大会推出的基于 Swift 语言构建的全新 UI 框架 ,完全抛弃了Storyboard 和Autolayout,采用声明式的界面语言(DSL) 和 实时预览功能,开发体验有了很大的提升;
  • 其中,实时预览分静态预览动态预览,默认情况下是静态预览,它的速度快,而且支持代码和可视化两种编写方式,不过它没有任何响应事件,无法滚动和跳转页面。而动态预览类似于Flutter的热重载,但是依然还有差距(需要编译一段时间,有一些限制)
  • SwiftUI还需要💪;此外,静态 UI 调试其实完全可以通过 StoryBoard,而真正实用的动态预览又有一定的局限性。

3、Injection for Xcode

  • John Holdsworth 开发的 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,到达热重载的效果;

  • Injection原理:

    • 监听源代码文件的变化后,重新编译对应的类,打包成动态库(.dylib 文件),然后通过Socket通知给运行的App;

    • Injection将动态库通过dlopen将动态库载入到运行的 App 里,返回handler(句柄);

    • dlsym结合handler + 符号得到动态库的符号地址后,就可以处理类的替换工作了;

    • 当替换工作完成,就重新绘制界面;整个过程无需重新编译和重启 App。

      dlopen 可以打开指定的动态库文件,并返回句柄;dlsym根据句柄和符号获得符号地址;dlclose可以卸载/关闭对应的动态库;

  • Injection for Xcode是利用注入动态库方式实现热重载,用到了dyld的API dlopendlsym(线下是OK的);但是提审的App中不能带这两个API,否则会被拒的;

    注意:Apple拒绝线上App有动态库注入的能力;

参考文章

iOS 13中dyld 3的改进和优化

Getting Started with the LLVM System

LLVM 初探

LLVM & Clang 入门

深入浅出iOS编译

不改代码,Link-Time Optimization提高iOS代码效率 + 汇编代码原理分析

有赞iOS-基于二进制的编译提效策略

今日头条iOS客户端启动速度优化