mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
228
极客时间专栏/iOS开发高手课/原理篇/33 | iOS 系统内核 XNU:App 如何加载?.md
Normal file
228
极客时间专栏/iOS开发高手课/原理篇/33 | iOS 系统内核 XNU:App 如何加载?.md
Normal file
@@ -0,0 +1,228 @@
|
||||
<audio id="audio" title="33 | iOS 系统内核 XNU:App 如何加载?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/e7/3a98828ea4ed48df332bb26ca897e8e7.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
在专栏的第2篇文章[《App 启动速度怎么做优化与监控?》](https://time.geekbang.org/column/article/85331)更新完之后,我看到很多同学对启动加载 App 的底层原理表示出了浓厚兴趣。所谓工欲善其事,必先利其器,相信有着好奇心的你,一定也会对支撑着 App 运行的操作系统有着各种各样的疑问。
|
||||
|
||||
我曾在专栏的第5篇文章[《链接器:符号是怎么绑定到地址上的?》](https://time.geekbang.org/column/article/86840)中,和你分享了链接器在编译时和程序启动时会做的事情。而今天这篇文章,我会重点与你说说加载动态链接器之前,系统是怎么加载 App 的。
|
||||
|
||||
所以,今天我会先跟你说说iOS系统的架构是怎样的,各部分的作用是什么,帮助你理解iOS系统的原理,进而更全面地理解它在 App 加载时做了哪些事情?
|
||||
|
||||
接下来,我就先跟你聊聊 iOS 的系统架构是怎样的。在理解iOS系统架构之前,你最好掌握一些操作系统原理的基础知识。
|
||||
|
||||
## iOS 系统架构
|
||||
|
||||
iOS 系统是基于 ARM 架构的,大致可以分为四层:
|
||||
|
||||
- 最上层是用户体验层,主要是提供用户界面。这一层包含了 SpringBoard、Spotlight、Accessibility。
|
||||
- 第二层是应用框架层,是开发者会用到的。这一层包含了开发框架 Cocoa Touch。
|
||||
- 第三层是核心框架层,是系统核心功能的框架层。这一层包含了各种图形和媒体核心框架、Metal 等。
|
||||
- 第四层是 Darwin层,是操作系统的核心,属于操作系统的内核态。这一层包含了系统内核 XNU、驱动等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/01/6d0c4526f448d03c232cfa0149a32d01.png" alt="">
|
||||
|
||||
其中,用户体验层、应用框架层和核心框架层,属于用户态,是上层 App 的活动空间。Darwin是用户态的下层支撑,是iOS系统的核心。
|
||||
|
||||
Darwin的内核是XNU,而XNU是在UNIX的基础上做了很多改进以及创新。了解XNU的内部是怎么样的,将有助于我们解决系统层面的问题。
|
||||
|
||||
所以接下来,我们就一起看看XNU的架构,看看它的内部到底都包含了些什么。
|
||||
|
||||
## XNU
|
||||
|
||||
XNU 内部由 Mach、BSD、驱动 API IOKit 组成,这些都依赖于 libkern、libsa、Platform Expert。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/7b/0f51e4995ead8b5b4c0e8cd2a987917b.png" alt="">
|
||||
|
||||
其中,[Mach](https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/Mach/Mach.html)是作为 UNIX 内核的替代,主要解决 UNIX一切皆文件导致抽象机制不足的问题,为现代操作系统做了进一步的抽象工作。 Mach 负责操作系统最基本的工作,包括进程和线程抽象、处理器调度、进程间通信、消息机制、虚拟内存管理、内存保护等。
|
||||
|
||||
进程对应到 Mach 是 Mach Task,Mach Task 可以看做是线程执行环境的抽象,包含虚拟地址空间、IPC 空间、处理器资源、调度控制、线程容器。
|
||||
|
||||
进程在 BSD 里是由 BSD Process 处理,BSD Process 扩展了 Mach Task,增加了进程 ID、信号信息等,BSD Process 里面包含了扩展 Mach Thread 结构的 Uthread。
|
||||
|
||||
Mach 的模块包括进程和线程都是对象,对象之间不能直接调用,只能通过 Mach Msg 进行通信,也就是 mach_msg() 函数。在用户态的那三层中,也就是在用户体验层、应用框架层和核心框架层中,你可以通过 mach_msg_trap() 函数触发陷阱,从而切至 Mach,由 Mach 里的 mach_msg() 函数完成实际通信,具体实现可以参看 NSHipster 的这篇文章“[Inter-Process Communication](https://nshipster.com/inter-process-communication/)”。
|
||||
|
||||
每个 Mach Thread 表示一个线程,是 Mach 里的最小执行单位。Mach Thread 有自己的状态,包括机器状态、线程栈、调度优先级(有128个,数字越大表示优先级越高)、调度策略、内核 Port、异常 Port。
|
||||
|
||||
Mach Thread 既可以由 Mach Task 处理,也可以扩展为 Uthread,通过 BSD Process 处理。这是因为 XNU 采用的是微内核 Mach 和 宏内核 BSD 的混合内核,具备微内核和宏内核的优点。
|
||||
|
||||
- 微内核可以提高系统的模块化程度,提供内存保护的消息传递机制;
|
||||
- 宏内核也可以叫单内核,在出现高负荷状态时依然能够让系统保持高效运作。
|
||||
|
||||
Mach 是微内核,可以将操作系统的核心独立在进程上运行,不过,内核层和用户态各层之间切换上下文和进程间消息传递都会降低性能。为了提高性能,苹果深度定制了 BSD 宏内核,使其和 Mach 混合使用。
|
||||
|
||||
宏内核 BSD 是对 Mach 封装,提供进程管理、安全、网络、驱动、内存、文件系统(HFS+)、网络文件系统(NFS)、虚拟文件系统(VFS)、POSIX(Portable Operating System Interface of UNIX,可移植操作系统接口)兼容。
|
||||
|
||||
早期的 BSD 是 UNIX 衍生出的操作系统,现在 BSD 是类 UNIX 操作系统的统称。XNU 的 BSD 来源于 FreeBSD 内核,经过深度定制而成。IEEE 为了保证软件可以在各个 UNIX 系统上运行而制定了 POSIX 标准,iOS 也是通过 BSD 对 POSIX 的兼容而成为了类 UNIX 系统。
|
||||
|
||||
BSD 提供了更现代、更易用的内核接口,以及 POSIX 的兼容,比如通过扩展 Mach Task 进程结构为 BSD Process。对于 Mach 使用 mach_msg_trap() 函数触发陷阱来处理异常消息,BSD 则在异常消息机制的基础上建立了信号处理机制,用户态产生的信号会先被 Mach 转换成异常,BSD 将异常再转换成信号。对于进程和线程,BSD 会构建 UNIX 进程模型,创建 POSIX 兼容的线程模型 pthread。
|
||||
|
||||
iOS 6后,为了增强系统安全,BSD 实行了ASLR(Address Space Layout Randomization,地址空间布局随机化)。随着 iPhone 硬件升级,为了更好地利用多核,BSD 加入了工作队列,以支持多核多线程处理,这也是 GCD 能更高效工作的基础。 BSD 还从 TrustdBSD 引入了 MAC 框架以增强权限 entitlement 机制的安全。
|
||||
|
||||
除了微内核 Mach 和宏内核 BSD 外,XNU 还有 IOKit。IOKit 是硬件驱动程序的运行环境,包含电源、内存、CPU等信息。IOKit 底层 libkern 使用 C++ 子集 Embedded C++ 编写了驱动程序基类,比如 OSObject、OSArray、OSString等,新驱动可以继承这些基类来写。
|
||||
|
||||
了解了 XNU 后,接下来,我再跟你聊聊 XNU 怎么加载 App 的?
|
||||
|
||||
## XNU 怎么加载 App?
|
||||
|
||||
iOS 的可执行文件和动态库都是 Mach-O 格式,所以加载 APP 实际上就是加载 Mach-O 文件。
|
||||
|
||||
Mach-O header 信息结构代码如下:
|
||||
|
||||
```
|
||||
struct mach_header_64 {
|
||||
uint32_t magic; // 64位还是32位
|
||||
cpu_type_t cputype; // CPU 类型,比如 arm 或 X86
|
||||
cpu_subtype_t cpusubtype; // CPU 子类型,比如 armv8
|
||||
uint32_t filetype; // 文件类型
|
||||
uint32_t ncmds; // load commands 的数量
|
||||
uint32_t sizeofcmds; // load commands 大小
|
||||
uint32_t flags; // 标签
|
||||
uint32_t reserved; // 保留字段
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,包含了表示是64位还是32位的 magic、CPU 类型 cputype、CPU 子类型 cpusubtype、文件类型 filetype、描述文件在虚拟内存中逻辑结构和布局的 load commands 数量和大小等文件信息。
|
||||
|
||||
其中,文件类型 filetype 表示了当前 Mach-O 属于哪种类型。Mach-O 包括以下几种类型。
|
||||
|
||||
- OBJECT,指的是 .o 文件或者 .a 文件;
|
||||
- EXECUTE,指的是IPA 拆包后的文件;
|
||||
- DYLIB,指的是 .dylib 或 .framework 文件;
|
||||
- DYLINKER,指的是动态链接器;
|
||||
- DSYM,指的是保存有符号信息用于分析闪退信息的文件。
|
||||
|
||||
加载 Mach-O 文件,内核会 fork 进程,并对进程进行一些基本设置,比如为进程分配虚拟内存、为进程创建主线程、代码签名等。用户态 dyld 会对 Mach-O 文件做库加载和符号解析。
|
||||
|
||||
苹果公司已经将 [XNU 开源](https://opensource.apple.com/),并在 GitHub 上创建了[镜像](https://github.com/apple/darwin-xnu)。要想编译 XNU,你可以查看“[Building the XNU kernel on Mac OS X Sierra (10.12.X)](https://0xcc.re/building-xnu-kernel-macosx-sierrra-10-12-x/)”这篇文章;要想调试 XNU,可以查看“[Source Level Debugging the XNU Kernel](https://shadowfile.inode.link/blog/2018/10/source-level-debugging-the-xnu-kernel/)”这篇文章。
|
||||
|
||||
整个 fork 进程,加载解析 Mach-O文件的过程可以在 XNU 的源代码中查看,代码路径是darwin-xnu/bsd/kern/kern_exec.c,地址是[https://github.com/apple/darwin-xnu/blob/master/bsd/kern/kern_exec.c](https://github.com/apple/darwin-xnu/blob/master/bsd/kern/kern_exec.c),相关代码在 __mac_execve 函数里,代码如下:
|
||||
|
||||
```
|
||||
int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval)
|
||||
{
|
||||
// 字段设置
|
||||
...
|
||||
int is_64 = IS_64BIT_PROCESS(p);
|
||||
struct vfs_context context;
|
||||
struct uthread *uthread; // 线程
|
||||
task_t new_task = NULL; // Mach Task
|
||||
...
|
||||
|
||||
context.vc_thread = current_thread();
|
||||
context.vc_ucred = kauth_cred_proc_ref(p);
|
||||
|
||||
// 分配大块内存,不用堆栈是因为 Mach-O 结构很大。
|
||||
MALLOC(bufp, char *, (sizeof(*imgp) + sizeof(*vap) + sizeof(*origvap)), M_TEMP, M_WAITOK | M_ZERO);
|
||||
imgp = (struct image_params *) bufp;
|
||||
|
||||
// 初始化 imgp 结构里的公共数据
|
||||
...
|
||||
|
||||
uthread = get_bsdthread_info(current_thread());
|
||||
if (uthread->uu_flag & UT_VFORK) {
|
||||
imgp->ip_flags |= IMGPF_VFORK_EXEC;
|
||||
in_vfexec = TRUE;
|
||||
} else {
|
||||
// 程序如果是启动态,就需要 fork 新进程
|
||||
imgp->ip_flags |= IMGPF_EXEC;
|
||||
// fork 进程
|
||||
imgp->ip_new_thread = fork_create_child(current_task(),
|
||||
NULL, p, FALSE, p->p_flag & P_LP64, TRUE);
|
||||
// 异常处理
|
||||
...
|
||||
|
||||
new_task = get_threadtask(imgp->ip_new_thread);
|
||||
context.vc_thread = imgp->ip_new_thread;
|
||||
}
|
||||
|
||||
// 加载解析 Mach-O
|
||||
error = exec_activate_image(imgp);
|
||||
|
||||
if (imgp->ip_new_thread != NULL) {
|
||||
new_task = get_threadtask(imgp->ip_new_thread);
|
||||
}
|
||||
|
||||
if (!error && !in_vfexec) {
|
||||
p = proc_exec_switch_task(p, current_task(), new_task, imgp->ip_new_thread);
|
||||
|
||||
should_release_proc_ref = TRUE;
|
||||
}
|
||||
|
||||
kauth_cred_unref(&context.vc_ucred);
|
||||
|
||||
if (!error) {
|
||||
task_bank_init(get_threadtask(imgp->ip_new_thread));
|
||||
proc_transend(p, 0);
|
||||
|
||||
thread_affinity_exec(current_thread());
|
||||
|
||||
// 继承进程处理
|
||||
if (!in_vfexec) {
|
||||
proc_inherit_task_role(get_threadtask(imgp->ip_new_thread), current_task());
|
||||
}
|
||||
|
||||
// 设置进程的主线程
|
||||
thread_t main_thread = imgp->ip_new_thread;
|
||||
task_set_main_thread_qos(new_task, main_thread);
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看出,由于 Mach-O 文件很大, __mac_execve 函数会先为 Mach-O 分配一大块内存 imgp,接下来会初始化 imgp 里的公共数据。内存处理完,__mac_execve 函数就会通过 fork_create_child() 函数 fork 出一个新的进程。新进程 fork 后,会通过 exec_activate_image() 函数解析加载 Mach-O 文件到内存 imgp 里。最后,使用 task_set_main_thread_qos() 函数设置新 fork 出进程的主线程。
|
||||
|
||||
exec_activate_image() 函数会调用不同格式对应的加载函数,代码如下:
|
||||
|
||||
```
|
||||
struct execsw {
|
||||
int (*ex_imgact)(struct image_params *);
|
||||
const char *ex_name;
|
||||
} execsw[] = {
|
||||
{ exec_mach_imgact, "Mach-o Binary" },
|
||||
{ exec_fat_imgact, "Fat Binary" },
|
||||
{ exec_shell_imgact, "Interpreter Script" },
|
||||
{ NULL, NULL}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
可以看出,加载 Mach-O 文件的是 exec_mach_imgact() 函数。exec_mach_imgact() 会通过 load_machfile() 函数加载 Mach-O 文件,根据解析 Mach-O 后得到的 load command 信息,通过映射方式加载到内存中。还会使用 activate_exec_state() 函数处理解析加载 Mach-O 后的结构信息,设置执行 App 的入口点。
|
||||
|
||||
设置完入口点后会通过 load_dylinker() 函数来解析加载 dyld,然后将入口点地址改成 dyld 的入口地址。这一步完后,内核部分就完成了 Mach-O文件的加载。剩下的就是用户态层 dyld 加载 App 了。
|
||||
|
||||
Dyld 的入口函数是 __dyld_start,dyld 属于用户态进程,不在 XNU 里,__dyld_start 函数的实现代码在 dyld 仓库中的 [dyldStartup.s 文件](https://github.com/opensource-apple/dyld/blob/master/src/dyldStartup.s)里。__dyld_start 会加载 App 相关的动态库,处理完成后会返回 App 的入口地址,然后到 App 的 main 函数。
|
||||
|
||||
## 小结
|
||||
|
||||
今天我跟你介绍了 iOS 系统的内核 XNU,以及 XNU 是如何加载 App 的。总体来说,XNU 加载就是为 Mach-O 创建一个新进程,建立虚拟内存空间,解析Mach-O文件,最后映射到内存空间。流程可以概括为:
|
||||
|
||||
<li>
|
||||
fork 新进程;
|
||||
</li>
|
||||
<li>
|
||||
为 Mach-O 分配内存;
|
||||
</li>
|
||||
<li>
|
||||
解析 Mach-O;
|
||||
</li>
|
||||
<li>
|
||||
读取 Mach-O 头信息;
|
||||
</li>
|
||||
<li>
|
||||
遍历 load command 信息,将 Mach-O 映射到内存;
|
||||
</li>
|
||||
<li>
|
||||
启动 dyld。
|
||||
</li>
|
||||
|
||||
## 课后作业
|
||||
|
||||
在今天这篇文章中,我主要和你分享的是系统内核加载 App的流程,而关于用户态 dyld 加载过程没有展开说。如果你想了解 dyld 加载过程的话,可以看看 Mike Ash 的“[dyld: Dynamic Linking On OS X](https://www.mikeash.com/pyblog/friday-qa-2012-11-09-dyld-dynamic-linking-on-os-x.html)”这篇文章。
|
||||
|
||||
相应地,今天的课后思考题,我希望你能够和我分享一下这篇文章的读后感。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="34 | iOS 黑魔法 Runtime Method Swizzling 背后的原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/c8/d2e2721c422a3c82428d21c15a150ec8.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
提到Object-C中的Runtime,你可能一下就想到了iOS的黑魔法Method Swizzling。毕竟,这个黑魔法可以帮助我们在运行时进行方法交换,或者在原方法执行之前插入自定义方法,以保证在业务面向对象编程方式不被改变的情况下,进行切面功能的开发。但是,运行时进行方法交换同时也会带来一定的风险。所以,今天我就来和你详细聊聊Runtime Method Swizzling 的原理。
|
||||
|
||||
Runtime Method Swizzling 编程方式,也可以叫作AOP(Aspect-Oriented Programming,面向切面编程)。
|
||||
|
||||
AOP 是一种编程范式,也可以说是一种编程思想,使用 AOP 可以解决 OOP(Object Oriented Programming,面向对象编程)由于切面需求导致单一职责被破坏的问题。通过 AOP 可以不侵入 OOP 开发,非常方便地插入切面需求功能。
|
||||
|
||||
比如,我在专栏[第9篇文章](https://time.geekbang.org/column/article/87925)中介绍无侵入埋点方案时,就提到了通过 AOP 在不侵入原有功能代码的情况下插入收集埋点的功能。
|
||||
|
||||
除此之外,还有一些主业务无关的逻辑功能,也可以通过 AOP 来完成,这样主业务逻辑就能够满足 OOP 单一职责的要求。而如果没有使用 AOP,鉴于OOP的局限性,这些与主业务无关的代码就会到处都是,增大了工作量不说,还会加大维护成本。
|
||||
|
||||
但是我们也知道,iOS 在运行时进行 AOP 开发会有风险,不能简单地使用 Runtime 进行方法交换来实现 AOP 开发。因此,我今天就来跟你说下直接使用 Runtime 方法交换开发的风险有哪些,而安全的方法交换原理又是怎样的?
|
||||
|
||||
## 直接使用 Runtime 方法交换开发的风险有哪些?
|
||||
|
||||
Objective-C 是门动态语言,可以在运行时做任何它能做的事情。这其中的功劳离不开 Runtime 这个库。正因为如此,Runtime 成为了 iOS 开发中 Objective-C 和 C 的分水岭。
|
||||
|
||||
Runtime 不光能够进行方法交换,还能够在运行时处理 Objective-C 特性相关(比如类、成员函数、继承)的增删改操作。
|
||||
|
||||
苹果公司已经开源了Runtime,在 GitHub 上有[可编译的 Runtime 开源版本](https://github.com/0xxd0/objc4)。你可以通过于德志 (@halfrost)博客的三篇 Runtime 文章,即[isa和Class](https://halfrost.com/objc_runtime_isa_class/)、[消息发送与转发](https://halfrost.com/objc_runtime_objc_msgsend/),以及[如何正确使用Runtime](https://halfrost.com/how_to_use_runtime/),来一边学习一边调试。
|
||||
|
||||
直接使用 Runtime 进行方法交换非常简单,代码如下:
|
||||
|
||||
```
|
||||
#import "SMHook.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
@implementation SMHook
|
||||
|
||||
+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
|
||||
Class class = classObject;
|
||||
// 得到被交换类的实例方法
|
||||
Method fromMethod = class_getInstanceMethod(class, fromSelector);
|
||||
// 得到交换类的实例方法
|
||||
Method toMethod = class_getInstanceMethod(class, toSelector);
|
||||
|
||||
// class_addMethod() 函数返回成功表示被交换的方法没实现,然后会通过 class_addMethod() 函数先实现;返回失败则表示被交换方法已存在,可以直接进行 IMP 指针交换
|
||||
if(class_addMethod(class, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
|
||||
// 进行方法的交换
|
||||
class_replaceMethod(class, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
|
||||
} else {
|
||||
// 交换 IMP 指针
|
||||
method_exchangeImplementations(fromMethod, toMethod);
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
```
|
||||
|
||||
如代码所示:通过 class_getInstanceMethod() 函数可以得到被交换类的实例方法和交换类的实例方法。使用 class_addMethod() 函数来添加方法,返回成功表示被交换的方法没被实现,然后通过 class_addMethod() 函数实现;返回失败则表示被交换方法已存在,可以通过 method_exchangeImplementations() 函数直接进行 IMP 指针交换以实现方法交换。
|
||||
|
||||
但是,像上面这段代码一样,直接使用 Runtime 的方法进行方法交换会有很多风险,[RSSwizzle](https://github.com/rabovik/RSSwizzle/)库里指出了**四个典型的直接使用 Runtime 方法进行方法交换的风险**。我稍作整理,以方便你查看,并便于你理解后续的内容。
|
||||
|
||||
第一个风险是,需要在 +load 方法中进行方法交换。因为如果在其他时候进行方法交换,难以保证另外一个线程中不会同时调用被交换的方法,从而导致程序不能按预期执行。
|
||||
|
||||
第二个风险是,被交换的方法必须是当前类的方法,不能是父类的方法,直接把父类的实现拷贝过来不会起作用。父类的方法必须在调用的时候使用,而不是方法交换时使用。
|
||||
|
||||
第三个风险是,交换的方法如果依赖了 cmd,那么交换后,如果 cmd 发生了变化,就会出现各种奇怪问题,而且这些问题还很难排查。特别是交换了系统方法,你无法保证系统方法内部是否依赖了 cmd。
|
||||
|
||||
第四个风险是,方法交换命名冲突。如果出现冲突,可能会导致方法交换失败。
|
||||
|
||||
更多关于运行时方法交换的风险,你可以查看 Stackoverflow 上的问题讨论“[What are the Dangers of Method Swizzling in Objective C?](https://stackoverflow.com/questions/5339276/what-are-the-dangers-of-method-swizzling-in-objective-c)”。
|
||||
|
||||
可以看到,直接使用 Runtime 进行方法交换的风险非常大,那么安全的方法交换是怎样的呢?接下来,我就来跟你介绍一个更安全的运行时方法交换库 [Aspects](https://github.com/steipete/Aspects)。
|
||||
|
||||
## 更安全的方法交换库Aspects
|
||||
|
||||
Aspects 是一个通过 Runtime 消息转发机制来实现方法交换的库。它将所有的方法调用都指到 _objc_msgForward 函数调用上,按照自己的方式实现了消息转发,自己处理参数列表,处理返回值,最后通过 NSInvocation 调用来实现方法交换。同时,Aspects 还考虑了一些方法交换可能会引发的风险,并进行了处理。
|
||||
|
||||
通过学习Aspects 的源码,你能够从中学习到如何处理这些风险。 比如,热修复框架 [JSPatch](https://github.com/bang590/JSPatch)就是学习了 Aspects 的实现方式。因此,接下来我会展开Aspects的源码,带你一起看看它是如何解决这些问题的。这样,你再遇到类似问题时,或借鉴其中的解决思路,或经过实践、思考后形成自己的更优雅的解决方法。
|
||||
|
||||
虽然 Aspects 对于一些风险进行了规避,但是在使用不当的情况下依然会有风险,比如 hook 已经被 hook 过的方法,那么之前的 hook 会失效,而且新的 hook 也会出错。所以,即使是 Aspects, 在工程中也不能滥用。
|
||||
|
||||
现在,我们先一起看一段**如何使用 Aspects 的示例代码**:
|
||||
|
||||
```
|
||||
[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {
|
||||
NSLog(@"View Controller %@ will appear animated: %tu", aspectInfo.instance, animated);
|
||||
} error:NULL];
|
||||
|
||||
```
|
||||
|
||||
上面这段代码是 Aspects 通过运行时方法交换,按照 AOP 方式添加埋点的实现。代码简单,可读性高,接口使用 Block 也非常易用。按照这种方式,直接使用Aspects即可。
|
||||
|
||||
接下来,我就跟你说下 **Aspect 实现方法交换的原理**。
|
||||
|
||||
Aspects 的整体流程是,先判断是否可进行方法交换。这一步会进行安全问题的判断处理。如果没有风险的话,再针对要交换的是类对象还是实例对象分别进行处理。
|
||||
|
||||
- 对于类对象的方法交换,会先修改类的 forwardInvocation ,将类的实现转成自己的。然后,重新生成一个方法用来交换。最后,交换方法的 IMP,方法调用时就会直接对交换方法进行消息转发。
|
||||
- 对于实例对象的方法交换,会先创建一个新的类,并将当前实例对象的 isa 指针指向新创建的类,然后再修改类的方法。
|
||||
|
||||
整个流程的入口是 aspect_add() 方法,这个方法里包含了 Aspects 的两个核心方法,第一个是进行安全判断的 aspect_isSelectorAllowedAndTrack 方法,第二个是执行类对象和实例对象方法交换的 aspect_prepareClassAndHookSelector 方法。
|
||||
|
||||
aspect_isSelectorAllowedAndTrack 方法,会对一些方法比如 retain、release、autorelease、forwardInvocation 进行过滤,并对 dealloc 方法交换做了限制,要求只能使用 AspectPositionBefore 选项。同时,它还会过滤没有响应的方法,直接返回 NO。
|
||||
|
||||
安全判断执行完,就开始执行方法交换的 aspect_prepareClassAndHookSelector 方法,其实现代码如下:
|
||||
|
||||
```
|
||||
static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) {
|
||||
NSCParameterAssert(selector);
|
||||
Class klass = aspect_hookClass(self, error);
|
||||
Method targetMethod = class_getInstanceMethod(klass, selector);
|
||||
IMP targetMethodIMP = method_getImplementation(targetMethod);
|
||||
if (!aspect_isMsgForwardIMP(targetMethodIMP)) {
|
||||
// 创建方法别名
|
||||
const char *typeEncoding = method_getTypeEncoding(targetMethod);
|
||||
SEL aliasSelector = aspect_aliasForSelector(selector);
|
||||
if (![klass instancesRespondToSelector:aliasSelector]) {
|
||||
__unused BOOL addedAlias = class_addMethod(klass, aliasSelector, method_getImplementation(targetMethod), typeEncoding);
|
||||
NSCAssert(addedAlias, @"Original implementation for %@ is already copied to %@ on %@", NSStringFromSelector(selector), NSStringFromSelector(aliasSelector), klass);
|
||||
}
|
||||
|
||||
// 使用 forwardInvocation 进行方法交换.
|
||||
class_replaceMethod(klass, selector, aspect_getMsgForwardIMP(self, selector), typeEncoding);
|
||||
AspectLog(@"Aspects: Installed hook for -[%@ %@].", klass, NSStringFromSelector(selector));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
可以看到,通过 aspect_hookClass()函数可以判断出 class 的 selector 是实例方法还是类方法,如果是实例方法,会通过 class_addMethod 方法生成一个交换方法,这样在 forwordInvocation 时就能够直接执行交换方法。aspect_hookClass 还会对类对象、元类、KVO 子类化的实例对象、class 和 isa 指向不同的情况进行处理,使用 aspect_swizzleClassInPlace 混写 baseClass。
|
||||
|
||||
## 小结
|
||||
|
||||
在今天这篇文章中,我和你梳理了直接使用 Runtime进行方法交换会有哪些问题,进而为了解决这些问题,我又和你分享了一个更安全的方法交换库 Aspects。
|
||||
|
||||
在文章最后,我想和你说的是,对于运行时进行方法交换,有的开发者在碰到了几次问题之后,就敬而远之了,但其实很多问题在你了解了原因后就不那么可怕了。就比如说,了解更多运行时原理和优秀方法交换库的实现细节,能够增强你使用运行时方法交换的信心,从而这个技术能够更好地为你提供服务,去帮助你更加高效地去解决某一类问题。
|
||||
|
||||
## 课后作业
|
||||
|
||||
你是怎么使用方法交换的?用的什么库?和 Aspects 比,这些库好在哪儿?
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
330
极客时间专栏/iOS开发高手课/原理篇/35 | libffi:动态调用和定义 C 函数.md
Normal file
330
极客时间专栏/iOS开发高手课/原理篇/35 | libffi:动态调用和定义 C 函数.md
Normal file
@@ -0,0 +1,330 @@
|
||||
<audio id="audio" title="35 | libffi:动态调用和定义 C 函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/a7/3f848f8fe1389ddc85c920f00e7909a7.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。
|
||||
|
||||
在 iOS 开发中,我们可以使用 Runtime 接口动态地调用 Objective-C 方法,但是却无法动态调用 C 的函数。那么,我们怎么才能动态地调用 C 语言函数呢?
|
||||
|
||||
C 语言编译后,在可执行文件里会有原函数名信息,我们可以通过函数名字符串来找到函数的地址。现在,我们只要能够通过函数名找到函数地址,就能够实现动态地去调用C 语言函数。
|
||||
|
||||
而在动态链接器中,有一个接口 dlsym() 可以通过函数名字符串拿到函数地址,如果所有 C 函数的参数类型和数量都一样,而且返回类型也一样,那么我们使用 dlsym() 就能实现动态地调用 C 函数。
|
||||
|
||||
但是,在实际项目中,函数的参数定义不可能都一样,返回类型也不会都是 void 或者 int类型。所以, dlsym()这条路走不通。那么,还有什么办法可以实现动态地调用 C 函数呢?
|
||||
|
||||
## 如何动态地调用C函数?
|
||||
|
||||
要想动态地调用 C 函数,你需要先了解函数底层是怎么调用的。
|
||||
|
||||
高级编程语言的函数在调用时,需要约定好参数的传递顺序、传递方式,栈维护的方式,名字修饰。这种函数调用者和被调用者对函数如何调用的约定,就叫作调用惯例(Calling Convention)。高级语言编译时,会生成遵循调用惯例的代码。
|
||||
|
||||
不同 CPU 架构的调用惯例不一样,比如64位机器的寄存器多些、传递参数快些,所以参数传递会优先采用寄存器传递,当参数数量超出寄存器数量后才会使用栈传递。
|
||||
|
||||
所以,编译时需要按照调用惯例针对不同 CPU 架构编译,生成汇编代码,确定好栈和寄存器。 如果少了编译过程,直接在运行时去动态地调用函数,就需要先生成动态调用相应寄存器和栈状态的汇编指令。而要达到事先生成相应寄存器和栈的目的,就不能使用遵循调用惯例的高级编程语言,而需要使用汇编语言。
|
||||
|
||||
Objective-C的函数调用采用的是发送消息的方式,使用的是 objc_msgSend 函数。objc_msgSend函数就是使用汇编语言编写的,其结构分为序言准备(Prologue)、函数体(Body)、结束收尾(Epilogue)三部分。
|
||||
|
||||
序言准备部分的作用是会保存之前程序执行的状态,还会将输入的参数保存到寄存器和栈上。这样,objc_msgSend 就能够先将未知的参数保存到寄存器和栈上,然后在函数体执行自身指令或者跳转其他函数,最后在结束收尾部分恢复寄存器,回到调用函数之前的状态。
|
||||
|
||||
得益于序言准备部分可以事先准备好寄存器和栈,objc_msgSend 可以做到函数调用无需通过编译生成汇编代码来遵循调用惯例,进而使得 Objective-C 具备了动态调用函数的能力。
|
||||
|
||||
但是,不同的 CPU 架构,在编译时会执行不同的objc_msgSend 函数,而且 objc_msgSend 函数无法直接调用 C 函数,所以想要实现动态地调用 C 函数就需要使用另一个用汇编语言编写的库 libffi。
|
||||
|
||||
那么,libffi 是什么呢,又怎么使用 libffi 来动态地调用 C 函数?接下来,我就和你分析一下这两个问题应该如何解决。
|
||||
|
||||
## libffi 原理分析
|
||||
|
||||
[libffi](https://sourceware.org/libffi/) 中ffi的全称是 Foreign Function Interface(外部函数接口),提供最底层的接口,在不确定参数个数和类型的情况下,根据相应规则,完成所需数据的准备,生成相应汇编指令的代码来完成函数调用。
|
||||
|
||||
libffi 还提供了可移植的高级语言接口,可以不使用函数签名间接调用 C 函数。比如,脚本语言 Python 在运行时会使用 libffi 高级语言的接口去调用 C 函数。libffi的作用类似于一个动态的编译器,在运行时就能够完成编译时所做的调用惯例函数调用代码生成。
|
||||
|
||||
libffi 通过调用 ffi_call(函数调用) 来进行函数调用,ffi_call 的输入是 ffi_cif(模板)、函数指针、参数地址。其中,ffi_cif 由 ffi_type(参数类型) 和 参数个数生成,也可以是 ffi_closure(闭包)。
|
||||
|
||||
libffi 是开源的,代码在 [GitHub](https://github.com/libffi/libffi) 上。接下来,我将结合 libffi 中的关键代码,和你详细说下 ffi_call 调用函数的过程。这样,可以帮助你更好地了解 libffi 的原理。
|
||||
|
||||
首先,我们来看看ffi_type。
|
||||
|
||||
### ffi_type(参数类型)
|
||||
|
||||
ffi_type的作用是,描述 C 语言的基本类型,比如 uint32、void *、struct 等,定义如下:
|
||||
|
||||
```
|
||||
typedef struct _ffi_type
|
||||
{
|
||||
size_t size; // 所占大小
|
||||
unsigned short alignment; //对齐大小
|
||||
unsigned short type; // 标记类型的数字
|
||||
struct _ffi_type **elements; // 结构体中的元素
|
||||
} ffi_type;
|
||||
|
||||
```
|
||||
|
||||
其中,size表述该类型所占的大小,alignment表示该类型的对齐大小,type表示标记类型的数字,element表示结构体的元素。
|
||||
|
||||
当类型是 uint32 时,size的值是4,alignment也是4,type 的值是9,elements是空。
|
||||
|
||||
### ffi_cif(模板)
|
||||
|
||||
ffi_cif由参数类型(ffi_type) 和参数个数生成,定义如下:
|
||||
|
||||
```
|
||||
typedef struct {
|
||||
ffi_abi abi; // 不同 CPU 架构下的 ABI,一般设置为 FFI_DEFAULT_ABI
|
||||
unsigned nargs; // 参数个数
|
||||
ffi_type **arg_types; // 参数类型
|
||||
ffi_type *rtype; // 返回值类型
|
||||
unsigned bytes; // 参数所占空间大小,16的倍数
|
||||
unsigned flags; // 返回类型是结构体时要做的标记
|
||||
#ifdef FFI_EXTRA_CIF_FIELDS
|
||||
FFI_EXTRA_CIF_FIELDS;
|
||||
#endif
|
||||
} ffi_cif;
|
||||
|
||||
```
|
||||
|
||||
如代码所示,ffi_cif 包含了函数调用时需要的一些信息。
|
||||
|
||||
abi 表示的是不同 CPU 架构下的 ABI,一般设置为 FFI_DEFAULT_ABI:在移动设备上 CPU 架构是 ARM64时,FFI_DEFAULT_ABI 就是 FFI_SYSV;使用苹果公司笔记本CPU 架构是 X86_DARWIN 时,FFI_DEFAULT_ABI 就是 FFI_UNIX64。
|
||||
|
||||
nargs 表示输入参数的个数。arg_types 表示参数的类型,比如 ffi_type_uint32。rtype 表示返回类型,如果返回类型是结构体,字段 flags 需要设置数值作为标记,以便在 ffi_prep_cif_machdep 函数中处理,如果返回的不是结构体,flags 不做标记。
|
||||
|
||||
bytes 表示输入参数所占空间的大小,是16的倍数。
|
||||
|
||||
ffi_cif 是由ffi_prep_cif 函数生成的,而ffi_prep_cif 实际上调用的又是 ffi_prep_cif_core 函数。
|
||||
|
||||
了解 ffi_prep_cif_core 就能够知道 ffi_cif 是怎么生成的。接下来,我继续跟你说说 ffi_prep_cif_core 里是怎么生成 ffi_cif 的。ffi_prep_cif_core 函数会先初始化返回类型,然后对返回类型使用 ffi_type_test 进行完整性检查,为返回类型留出空间。
|
||||
|
||||
接着,使用 initialize_aggregate 函数初始化栈,对参数类型进行完整性检查,对栈进行填充,通过 ffi_prep_cif_machdep 函数执行 ffi_cif 平台相关处理。具体实现代码,你可以点击[这个链接](https://github.com/libffi/libffi/blob/master/src/prep_cif.c)查看,其所在文件路径是 libffi/src/prep_cif.c。
|
||||
|
||||
之所以将准备 ffi_cif 和 ffi_call 分开,是因为ffi_call 可能会调用多次参数个数、参数类型、函数指针相同,只有参数地址不同的函数。将它们分开,ffi_call 只需要处理不同参数地址,而其他工作只需要 ffi_cif 做一遍就行了。
|
||||
|
||||
接着,准备好了 ffi_cif 后,我们就可以开始函数调用了。
|
||||
|
||||
### ffi_call(函数调用)
|
||||
|
||||
ffi_call 函数的主要处理都交给了 ffi_call_SYSV 这个汇编函数。ffi_call_SYSV 的实现代码,你可以点击[这个链接](https://github.com/libffi/libffi/blob/master/src/aarch64/sysv.S),其所在文件路径是 libffi/src/aarch64/sysv.S。
|
||||
|
||||
下面,我来跟你说说 **ffi_call_SYSV 汇编函数做了什么**。
|
||||
|
||||
首先,我们一起看看 ffi_call_SYSV 函数的定义:
|
||||
|
||||
```
|
||||
extern void ffi_call_SYSV (void *stack, void *frame,
|
||||
void (*fn)(void), void *rvalue,
|
||||
int flags, void *closure);
|
||||
|
||||
```
|
||||
|
||||
可以看到,通过 ffi_call_SYSV 函数,我们可以得到 stack、frame、fn、rvalue、flags、closure 参数。
|
||||
|
||||
各参数会依次保存在参数寄存器中,参数栈 stack 在 x0 寄存器中,参数地址 frame 在x1寄存器中,函数指针 fn 在x2寄存器中,用于存放返回值的 rvalue 在 x3 里,结构体标识 flags 在x4寄存器中,闭包 closure 在 x5 寄存器中。
|
||||
|
||||
然后,我们再看看ffi_call_SYSV 处理的核心代码:
|
||||
|
||||
```
|
||||
//分配 stack 和 frame
|
||||
cfi_def_cfa(x1, 32);
|
||||
stp x29, x30, [x1]
|
||||
mov x29, x1
|
||||
mov sp, x0
|
||||
cfi_def_cfa_register(x29)
|
||||
cfi_rel_offset (x29, 0)
|
||||
cfi_rel_offset (x30, 8)
|
||||
|
||||
// 记录函数指针 fn
|
||||
mov x9, x2 /* save fn */
|
||||
|
||||
// 记录返回值 rvalue
|
||||
mov x8, x3 /* install structure return */
|
||||
#ifdef FFI_GO_CLOSURES
|
||||
// 记录闭包 closure
|
||||
mov x18, x5 /* install static chain */
|
||||
#endif
|
||||
// 保存 rvalue 和 flags
|
||||
stp x3, x4, [x29, #16] /* save rvalue and flags */
|
||||
|
||||
//先将向量参数传到寄存器
|
||||
tbz w4, #AARCH64_FLAG_ARG_V_BIT, 1f
|
||||
ldp q0, q1, [sp, #0]
|
||||
ldp q2, q3, [sp, #32]
|
||||
ldp q4, q5, [sp, #64]
|
||||
ldp q6, q7, [sp, #96]
|
||||
1:
|
||||
// 再将参数传到寄存器
|
||||
ldp x0, x1, [sp, #16*N_V_ARG_REG + 0]
|
||||
ldp x2, x3, [sp, #16*N_V_ARG_REG + 16]
|
||||
ldp x4, x5, [sp, #16*N_V_ARG_REG + 32]
|
||||
ldp x6, x7, [sp, #16*N_V_ARG_REG + 48]
|
||||
|
||||
//释放上下文,留下栈里参数
|
||||
add sp, sp, #CALL_CONTEXT_SIZE
|
||||
|
||||
// 调用函数指针 fn
|
||||
blr x9
|
||||
|
||||
// 重新读取 rvalue 和 flags
|
||||
ldp x3, x4, [x29, #16]
|
||||
|
||||
// 析构部分栈指针
|
||||
mov sp, x29
|
||||
cfi_def_cfa_register (sp)
|
||||
ldp x29, x30, [x29]
|
||||
|
||||
// 保存返回值
|
||||
adr x5, 0f
|
||||
and w4, w4, #AARCH64_RET_MASK
|
||||
add x5, x5, x4, lsl #3
|
||||
br x5
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,**ffi_call_SYSV 处理过程分为下面几步**:
|
||||
|
||||
第一步,ffi_call_SYSV 会先分配 stack 和 frame,保存记录 fn、rvalue、closure、flags。
|
||||
|
||||
第二步,将向量参数传到寄存器,按照参数放置规则,调整 sp 的位置,
|
||||
|
||||
第三步,将参数放入寄存器,存放完毕,就开始释放上下文,留下栈里的参数。
|
||||
|
||||
第四步,通过 blr 指令调用 x9 中的函数指针 fn ,以调用函数。
|
||||
|
||||
第五步,调用完函数指针,就重新读取 rvalue 和 flags,析构部分栈指针。
|
||||
|
||||
第六步,保存返回值。
|
||||
|
||||
可以看出,libffi 调用函数的原理和 objc_msgSend 的实现原理非常类似。objc_msgSend 原理,你可以参考 Mike Ash 的“[Dissecting objc_msgSend on ARM64](https://www.mikeash.com/pyblog/friday-qa-2017-06-30-dissecting-objc_msgsend-on-arm64.html)”这篇文章。
|
||||
|
||||
这里我要多说一句,在专栏[第2篇文章](https://time.geekbang.org/column/article/85331)中我和你分享App启动速度优化时,用到了些汇编代码,有很多用户反馈看不懂这部分内容。针对这个情况,我特意在[第11篇答疑文章](https://time.geekbang.org/column/article/88799)中,和你分享了些汇编语言学习的方法、参考资料。如果你对上述的汇编代码感兴趣,但又感觉读起来有些吃力的话,建议你再看一下第11篇文章中的相关内容。
|
||||
|
||||
了解了 libffi 调用函数的原理后,相信你迫不及待就想在你的 iOS 工程中集成 libffi了吧。
|
||||
|
||||
## 如何使用libffi?
|
||||
|
||||
孙源在 GitHub 上有个 [Demo](https://github.com/sunnyxx/libffi-iOS),已经集成了 iOS 可以用的 libffi 库,你可以将这个库集成到自己的工程中。接下来,我借用孙源这个Demo 中的示例代码,来分别和你说说如何使用 libffi 库来调用 C 函数和定义 C 函数。代码所在文件路径是 libffi-iOS/Demo/ViewController.m。在这里,我也特别感谢孙源的这个Demo。
|
||||
|
||||
### 调用 C 函数
|
||||
|
||||
首先,声明一个函数,实现两个整数相加:
|
||||
|
||||
```
|
||||
- (int)fooWithBar:(int)bar baz:(int)baz {
|
||||
return bar + baz;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,定义一个函数,使用 libffi 来调用 fooWithBar:baz 函数,也就是刚刚声明的实现两个整数相加的函数。
|
||||
|
||||
```
|
||||
void testFFICall() {
|
||||
// ffi_call 调用需要准备的模板 ffi_cif
|
||||
ffi_cif cif;
|
||||
// 参数类型指针数组,根据被调用的函数入参的类型来定
|
||||
ffi_type *argumentTypes[] = {&ffi_type_pointer, &ffi_type_pointer, &ffi_type_sint32, &ffi_type_sint32};
|
||||
// 通过 ffi_prep_cif 内 ffi_prep_cif_core 来设置 ffi_cif 结构体所需要的数据,包括 ABI、参数个数、参数类型等。
|
||||
ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 4, &ffi_type_pointer, argumentTypes);
|
||||
|
||||
Sark *sark = [Sark new];
|
||||
SEL selector = @selector(fooWithBar:baz:);
|
||||
|
||||
// 函数参数的设置
|
||||
int bar = 123;
|
||||
int baz = 456;
|
||||
void *arguments[] = {&sark, &selector, &bar, &baz};
|
||||
|
||||
// 函数指针 fn
|
||||
IMP imp = [sark methodForSelector:selector];
|
||||
// 返回值声明
|
||||
int retValue;
|
||||
|
||||
// ffi_call 所需的 ffi_cif、函数指针、返回值、函数参数都准备好,就可以通过 ffi_call 进行函数调用了
|
||||
ffi_call(&cif, imp, &retValue, arguments);
|
||||
NSLog(@"ffi_call: %d", retValue);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,先将 ffi_call 所需要的 ffi_cif 通过 ffi_prep_cif 函数准备好,然后设置好参数,通过 Runtime 接口获取 fooWithBar:baz 方法的函数指针 imp,最后就可以通过 ffi_call 进行函数调用了。
|
||||
|
||||
在这个例子中,函数指针是使用 Objective-C 的 Runtime 得到的。如果是 C 语言函数,你就可以通过 dlsym 函数获得。dlsym 获得函数指针示例如下:
|
||||
|
||||
```
|
||||
// 计算矩形面积
|
||||
int rectangleArea(int length, int width) {
|
||||
printf("Rectangle length is %d, and with is %d, so area is %d \n", length, width, length * width);
|
||||
return length * width;
|
||||
}
|
||||
|
||||
void run() {
|
||||
// dlsym 返回 rectangleArea 函数指针
|
||||
void *dlsymFuncPtr = dlsym(RTLD_DEFAULT, "rectangleArea");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上代码所示,dlsym 根据计算矩形面积的函数 rectangleArea 的函数名,返回 rectangleArea 函数指针给 dlsymFuncPtr。
|
||||
|
||||
无论是 Runtime 获取的函数指针还是 dlsym 获取的函数指针都可以在运行时去完成,接着使用 libffi 在运行时处理好参数。这样,就能够实现运行时动态地调用 C 函数了。
|
||||
|
||||
接下来,我再跟你说下如何使用 libffi 定义 C 函数。
|
||||
|
||||
### 定义 C 函数
|
||||
|
||||
首先,声明一个两数相乘的函数。
|
||||
|
||||
```
|
||||
void closureCalled(ffi_cif *cif, void *ret, void **args, void *userdata) {
|
||||
int bar = *((int *)args[2]);
|
||||
int baz = *((int *)args[3]);
|
||||
*((int *)ret) = bar * baz;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,再写个函数,用来定义 C 函数。
|
||||
|
||||
```
|
||||
void testFFIClosure() {
|
||||
ffi_cif cif;
|
||||
ffi_type *argumentTypes[] = {&ffi_type_pointer, &ffi_type_pointer, &ffi_type_sint32, &ffi_type_sint32};
|
||||
// 准备模板 cif
|
||||
ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 4, &ffi_type_pointer, argumentTypes);
|
||||
|
||||
// 声明一个新的函数指针
|
||||
IMP newIMP;
|
||||
|
||||
// 分配一个 closure 关联新声明的函数指针
|
||||
ffi_closure *closure = ffi_closure_alloc(sizeof(ffi_closure), (void *)&newIMP);
|
||||
|
||||
// ffi_closure 关联 cif、closure、函数实体 closureCalled
|
||||
ffi_prep_closure_loc(closure, &cif, closureCalled, NULL, NULL);
|
||||
|
||||
// 使用 Runtime 接口动态地将 fooWithBar:baz 方法绑定到 closureCalled 函数指针上
|
||||
Method method = class_getInstanceMethod([Sark class], @selector(fooWithBar:baz:));
|
||||
method_setImplementation(method, newIMP);
|
||||
|
||||
// after hook
|
||||
Sark *sark = [Sark new];
|
||||
int ret = [sark fooWithBar:123 baz:456];
|
||||
NSLog(@"ffi_closure: %d", ret);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上面代码所示,在 testFFIClosure 函数准备好 cif 后,会声明一个新的函数指针,这个新的函数指针会和分配的 ffi_closure 关联,ffi_closure 还会通过 ffi_prep_closure_loc 函数关联到 cif、closure、函数实体 closureCalled。
|
||||
|
||||
有了这种能力,你就具备了在运行时将一个函数指针和函数实体绑定的能力,也就能够很容易地实现动态地定义一个 C 函数了。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我和你分享了 libffi 的原理,以及怎么使用 libffi 调用和定义 C 函数。
|
||||
|
||||
当你理解了 libffi 的原理以后,再面对语言之间运行时动态调用的问题,也就做到了心中有数。在方案选择动态调用方式时,也就能够找出更多的方案,更加得心应手。
|
||||
|
||||
比如,使用 Aspect 进行方法替换,如果使用不当,会有较大的风险;再比如,hook已经被hook 过的方法,那么之前的 hook 会失效,新的hook 也会出错,而使用 libffi 进行 hook 不会出现这样的问题。
|
||||
|
||||
## 课后作业
|
||||
|
||||
Block 是一个 Objective-C 对象,表面看类似 C 函数,实际上却有很大不同。你可以点击[这个链接](http://clang.llvm.org/docs/Block-ABI-Apple.html)查看Block 的定义,也可以再看看 Mike Ash 的 [MABlockClosure](https://github.com/mikeash/MABlockClosure)库。然后,请你在留言区说说如何通过 libffi 调用 Block。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
99
极客时间专栏/iOS开发高手课/原理篇/36 | iOS 是怎么管理内存的?.md
Normal file
99
极客时间专栏/iOS开发高手课/原理篇/36 | iOS 是怎么管理内存的?.md
Normal file
@@ -0,0 +1,99 @@
|
||||
<audio id="audio" title="36 | iOS 是怎么管理内存的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/41/566d8dafb8d44d084aaf72925274a241.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天,我来和你聊聊 iOS 是怎么管理内存的。
|
||||
|
||||
不同的系统版本对 App 运行时占用内存的限制不同,你可以利用我在第14篇文章中提到的方法,去查看不同版本系统对App占用内存的具体限制是多少。另外,系统版本的升级也会增加占用的内存,同时App功能的增多也会要求越来越多的内存。
|
||||
|
||||
然而,移动设备的内存资源是有限的,当App运行时占用的内存大小超过了限制后,就会被强杀掉,从而导致用户体验被降低。所以,为了提升App质量,开发者要非常重视应用的内存管理问题。
|
||||
|
||||
移动端的内存管理技术,主要有 GC(Garbage Collection,垃圾回收)的标记清除算法和苹果公司使用的引用计数方法。
|
||||
|
||||
相比较于 GC 标记清除算法,引用计数法可以及时地回收引用计数为0的对象,减少查找次数。但是,引用计数会带来循环引用的问题,比如当外部的变量强引用 Block时,Block 也会强引用外部的变量,就会出现循环引用。我们需要通过弱引用,来解除循环引用的问题。
|
||||
|
||||
另外,在 ARC(自动引用计数)之前,一直都是通过 MRC(手动引用计数)这种手写大量内存管理代码的方式来管理内存,因此苹果公司开发了 ARC 技术,由编译器来完成这部分代码管理工作。但是,ARC依然需要注意循环引用的问题。
|
||||
|
||||
当 ARC 的内存管理代码交由编译器自动添加后,有些情况下会比手动管理内存效率低,所以对于一些内存要求较高的场景,我们还是要通过 MRC的方式来管理、优化内存的使用。
|
||||
|
||||
要想深入理解 iOS 管理内存的方式,我们就不仅仅要关注用户态接口层面,比如引用计数算法和循环引用监控技巧,还需要从管理内存的演进过程,去了解现代内存管理系统的前世今生,知其然知其所以然。
|
||||
|
||||
说到内存管理的演进过程,在最开始的时候,程序是直接访问物理内存,但后来有了多程序多任务同时运行,就出现了很多问题。比如,同时运行的程序占用的总内存必须要小于实际物理内存大小。再比如,程序能够直接访问和修改物理内存,也就能够直接访问和修改其他程序所使用的物理内存,程序运行时的安全就无法保障。
|
||||
|
||||
## 虚拟内存
|
||||
|
||||
由于要解决多程序多任务同时运行的这些问题,所以增加了一个中间层来间接访问物理内存,这个中间层就是虚拟内存。虚拟内存通过映射,可以将虚拟地址转化成物理地址。
|
||||
|
||||
虚拟内存会给每个程序创建一个单独的执行环境,也就是一个独立的虚拟空间,这样每个程序就只能访问自己的地址空间(Address Space),程序与程序间也就能被安全地隔离开了。
|
||||
|
||||
32位的地址空间是 2^32 = 4294967296 个字节,共 4GB,如果内存没有达到 4GB 时,虚拟内存比实际的物理内存要大,这会让程序感觉自己能够支配更多的内存。如同虚拟内存只供当前程序使用,操作起来和物理内存一样高效。
|
||||
|
||||
有了虚拟内存这样一个中间层,极大地节省了物理内存。iOS的共享库就是利用了这一点,只占用一份物理内存,却能够在不同应用的多份虚拟内存中,去使用同一份共享库的物理内存。
|
||||
|
||||
每个程序都有自己的进程,进程的内存布局主要由代码段、数据段、栈、堆组成。程序生成的汇编代码会放在代码段。如果每个进程的内存布局都是连在一起的话,每个进程分配的空间就没法灵活变更,栈和堆没用满时就会有很多没用的空间。如果虚拟地址和物理地址的翻译内存管理单元(Memory Management Unit,MMU)只是简单地通过进程开始地址加上虚拟地址,来获取物理地址,就会造成很大的内存空间浪费。
|
||||
|
||||
## 分段
|
||||
|
||||
分段就是将进程里连在一起的代码段、数据段、栈、堆分开成独立的段,每个段内空间是连续的,段之间不连续。这样,内存的空间管理 MMU 就可以更加灵活地进行内存管理。
|
||||
|
||||
那么,段和进程关系是怎么表示的呢?进程中内存地址会用前两个字节表示对应的段。比如00表示代码段,01标识堆。
|
||||
|
||||
段里的进程又是如何管理内存的呢?每个段大小增长的方向 Grows Positive 也需要记录,是否可读写也要记录,为的是能够更有效地管理段增长。每个段的大小不一样,在申请的内存被释放后,容易产生碎片,这样在申请新内存时,很可能就会出现所剩内存空间够用,但是却不连续,于是造成无法申请的情况。这时,就需要暂停运行进程,对段进行修改,然后再将内存拷贝到连续的地址空间中。但是,连续拷贝会耗费较多时间。
|
||||
|
||||
那么,怎么才能降低内存的碎片化程度,进而提高性能呢?
|
||||
|
||||
## 分页
|
||||
|
||||
App 在运行时,大多数的时间只会使用很小部分的内存,所以我们可以使用比段粒度更小的空间管理技术,也就是分页。
|
||||
|
||||
分页就是把地址空间切分成固定大小的单元,这样我们就不用去考虑堆和栈会具体申请多少空间,而只要考虑需要多少页就可以了。这,对于操作系统管理来说也会简单很多,只需要维护一份页表(Page Table)来记录虚拟页(Virtual Page)和物理页(Physical Page)的关系即可。
|
||||
|
||||
虚拟页的前两位是 VPN(Virtual Page Number),根据页表,翻译为物理地址 PFN(Physical Frame Number)。
|
||||
|
||||
虚拟页与物理页之间的映射关系,就是虚拟内存和物理内存的关系,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/af/7788f425fe749bef964a78ec5fbb1eaf.png" alt=""><br>
|
||||
如图所示,多个进程虚拟页和物理页的关系通过箭头关联起来了,而页表就可以记录下箭头指向的映射关系。
|
||||
|
||||
这里,我们需要注意的是,虚拟页和物理页的个数是不一样的。比如,在64位操作系统中使用的是48位寻址空间,之所以使用48位寻址空间,是因为推出64位系统时硬件还不能支持64位寻址空间,所以就一直延续下来了。虚拟页大小是16K,那么虚拟页最多能有 2^48 / 2^14 = 16M 个,物理内存为16G对应物理页个数是 2^64 / 2^14 = 524k 个。
|
||||
|
||||
维护虚拟页和物理页关系的页表会随着进程增多而变得越来越大,当页表大于寄存器大小时,就无法放到寄存器中,只能放到内存中。当要通过虚拟地址获取物理地址的时候,就要对页表进行访问翻译,而在内存中进行访问翻译的速度会比 CPU 的寄存器慢很多。
|
||||
|
||||
那么,**怎么加速页表翻译速度呢?**
|
||||
|
||||
我们知道,缓存可以加速访问。MMU 中有一个 TLB(Translation-Lookaside Buffer),可以作为缓存加速访问。所以,在访问页表前,首先检查 TLB 有没有缓存的虚拟地址对应的物理地址:
|
||||
|
||||
- 如果有的话,就可以直接返回,而不用再去访问页表了;
|
||||
- 如果没有的话,就需要继续访问页表。
|
||||
|
||||
每次都要访问整个列表去查找我们需要的物理地址,终归还是会影响效率,所以又引入了多级页表技术。也就是,根据一定的算法灵活分配多级页表,保证一级页表最小的内存占用。其中,一级页表对应多个二级页表,再由二级页表对应虚拟页。
|
||||
|
||||
这样内存中只需要保存一级页表就可以,不仅减少了内存占用,而且还提高了访问效率。根据多级页表分配页表层级算法,空间占用多时,页表级别增多,访问页表层级次数也会增多,所以**多级页表机制属于典型的支持时间换空间的灵活方案。**
|
||||
|
||||
iOS 的 XNU Mach 微内核中有很多分页器提供分页操作,比如 Freezer 分页器、VNode 分页器。还有一点需要注意的是,这些分页器不负责调度,调度都是由 Pageout 守护线程执行。
|
||||
|
||||
由于移动设备的内存资源限制,虚拟分页在 iOS 系统中的控制方式更严格。移动设备的磁盘空间也不够用,因此没有使用 DRAM(动态 RAM)的方式控制内存。为了减少磁盘空间占用,iOS 采用了 Jetsam 机制来控制内存的使用。
|
||||
|
||||
>
|
||||
备注:DRAM 内存控制方式,是在虚拟页不命中的情况下采用磁盘来缓存。
|
||||
|
||||
|
||||
占用内存过多的进程会被强杀,这也就对 App 占用的内存提出了更高的要求。同时,Jetsam机制也可以避免磁盘和内存交换带来的效率问题,因为磁盘的速度要比 DRAM 慢上几万倍。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我和你分享了 iOS 系统内存管理的原理。理解这些原理,能够加深你对系统管理内存方式的理解。
|
||||
|
||||
对于在iOS开发过程中如何优化内存,苹果公司在2018年的 WWDC Session 416: [iOS Memory Deep Dive](https://developer.apple.com/videos/play/wwdc2018/416/)上进行了详细讲解,其中就包含了 iOS 虚拟内存机制的变化。
|
||||
|
||||
Xcode 开发工具对内存分析方面所做的更新,比如 debugger 可以自动捕获内存占用触发系统限制的 EXC_RESOURCE RESOURCE_TYPE_MEMORY 异常,并断点在触发异常的位置。对 Xcode 中存储 App 内存信息的 memgrah 文件,我们可以使用 vmmap、leaks、heap、malloc_history 等命令行工具来分析。
|
||||
|
||||
在这个Session 中,苹果公司还推荐我们使用 UIGraphicsImageRenderer 替代 UIGraphicsBeginImageContextWithOptions,让系统自动选择最佳的图片格式,这样也能够降低占用的内存。对于图片的缩放,苹果公司推荐使用 ImageIO 直接读取图片的大小和元数据,也就避免了以前将原始图片加载到内存然后进行转换而带来的额外内存开销。
|
||||
|
||||
其实,图片资源不仅是影响App包大小的重要因素,也是内存的消耗大户。苹果公司在2018年的WWDC Session 219: [Images and Graphics Best Practices](https://developer.apple.com/videos/play/wwdc2018/219/)中,还专门介绍了关于图片的最佳实践,并针对减少内存消耗进行了详细讲解。
|
||||
|
||||
对于 App 处在低内存时如何处理,你可以看看这篇文章“[No pressure, Mon! Handling low memory conditions in iOS and Mavericks](http://newosxbook.com/articles/MemoryPressure.html)”。
|
||||
|
||||
## 课后作业
|
||||
|
||||
第三方内存检测工具有 [MLeaksFinder](https://github.com/Tencent/MLeaksFinder)、[FBRetainCycleDetector](https://github.com/facebook/FBRetainCycleDetector)、[OOMDetector](https://github.com/Tencent/OOMDetector)。你知道这些工具进行内存检测的原理吗?
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
318
极客时间专栏/iOS开发高手课/原理篇/37 | 如何编写 Clang 插件?.md
Normal file
318
极客时间专栏/iOS开发高手课/原理篇/37 | 如何编写 Clang 插件?.md
Normal file
@@ -0,0 +1,318 @@
|
||||
<audio id="audio" title="37 | 如何编写 Clang 插件?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/5d/d0ba91a58e6461f767a4a66ebc4f015d.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天,我和你分享的主题是,如何编写 Clang 插件。
|
||||
|
||||
Clang 使用的是模块化设计,可以将自身功能以库的方式来供上层应用来调用。比如,编码规范检查、IDE 中的语法高亮、语法检查等上层应用,都是使用 Clang 库的接口开发出来的。Clang 库对接上层应用有三个接口库,分别是 LibClang、Clang 插件、LibTooling。关于这三个接口库的介绍,我已经在[第8篇文章](https://time.geekbang.org/column/article/87844)中和你详细分享过。
|
||||
|
||||
其中,LibClang 为了兼容更多 Clang 版本,相比Clang少了很多功能;Clang 插件和 LibTooling 具备Clang 的全量能力。Clang 插件编写代码的方式,和 LibTooling 几乎一样,不同的是 Clang 插件还能够控制编译过程,可以加 warning,或者直接中断编译提示错误。另外,编写好的 LibTooling 还能够非常方便地转成 Clang 插件。
|
||||
|
||||
所以说,Clang 插件在功能上是最全的。今天这篇文章,我们就一起来看看怎样编写和运行 Clang 插件。
|
||||
|
||||
Clang 插件代码编写后进行编译的前置条件是编译 Clang。要想编译 Clang ,你就需要先安装 [CMake 工具](https://cmake.org/),来解决跨平台编译规范问题。
|
||||
|
||||
我们可以先通过 CMakeList.txt 文件,来定制CMake编译流程,再根据 CMakeList.txt 文件生成目标平台所需的编译文件。这个编译文件,在类UNIX平台就是 Makefile,在 Windows 平台就是 Visual Studio 工程,macOS 里还可以生成 Xcode 工程。所以,你可以使用熟悉的 Xcode 来编译 Clang。
|
||||
|
||||
接下来,我就和你说说怎么拉 Clang 的代码,以及编译 Clang 的过程是什么样的。
|
||||
|
||||
## 在 macOS 平台上编译 Clang
|
||||
|
||||
接下来的内容,我会以macOS 平台编译 Clang 为例。如果你想在其他平台编译,可以参看[官方说明](https://llvm.org/docs/CMake.html)。
|
||||
|
||||
首先,从 GitHub 上拉下 Clang 的代码,命令如下:
|
||||
|
||||
```
|
||||
git clone https://github.com/llvm/llvm-project.git
|
||||
|
||||
```
|
||||
|
||||
然后,执行以下命令,来创建构建所需要的目录:
|
||||
|
||||
```
|
||||
cd llvm-project
|
||||
mkdir build (in-tree build is not supported)
|
||||
cd build
|
||||
|
||||
```
|
||||
|
||||
目录结构如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/d0/06b2c299d4bd37d809d2e5b03a6d6ad0.png" alt=""><br>
|
||||
其中,clang 目录就是类 C 语言编译器的代码目录;llvm 目录的代码包含两部分,一部分是对源码进行平台无关优化的优化器代码,另一部分是生成平台相关汇编代码的生成器代码;lldb 目录里是调试器的代码;lld 里是链接器代码。
|
||||
|
||||
macOS 属于类UNIX平台,因此既可以生成 Makefile 文件来编译,也可以生成 Xcode 工程来编译。生成 Makefile 文件,你可以使用如下命令:
|
||||
|
||||
```
|
||||
cmake -DLLVM_ENABLE_PROJECTS=clang -G "Unix Makefiles" ../llvm
|
||||
make
|
||||
|
||||
```
|
||||
|
||||
生成 Xcode 工程,你可以使用这个命令:
|
||||
|
||||
```
|
||||
cmake -G Xcode -DLLVM_ENABLE_PROJECTS=clang ../llvm
|
||||
|
||||
```
|
||||
|
||||
执行完后,会在 build 目录下生成 Xcode 工程,路径如下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/50/79629c88bce6942278ee42356df9fa50.png" alt=""><br>
|
||||
执行 cmake 命令时,你可能会遇到下面的提示:
|
||||
|
||||
```
|
||||
-- The C compiler identification is unknown
|
||||
-- The CXX compiler identification is unknown
|
||||
CMake Error at CMakeLists.txt:39 (project):
|
||||
No CMAKE_C_COMPILER could be found.
|
||||
|
||||
CMake Error at CMakeLists.txt:39 (project):
|
||||
No CMAKE_CXX_COMPILER could be found.
|
||||
|
||||
```
|
||||
|
||||
这表示 cmake 没有找到代码编译器的命令行工具。这包括两种情况:
|
||||
|
||||
- 一是,如果你没有安装 Xcode Commandline Tools的话,可以执行如下命令安装:
|
||||
|
||||
```
|
||||
xcode-select --install
|
||||
|
||||
```
|
||||
|
||||
- 二是,如果你已经安装了Xcode Commandline Tools的话,直接reset 即可,命令如下:
|
||||
|
||||
```
|
||||
sudo xcode-select --reset
|
||||
|
||||
```
|
||||
|
||||
生成 Xcode 工程后,打开生成的 LLVM.xcodeproj文件,选择 Automatically Create Schemes。编译完后生成的库文件,就在 llvm-project/build/Debug/lib/ 目录下。
|
||||
|
||||
有了可以编写编译插件的 Xcode 工程,接下来你就可以着手编写 Clang 插件了。
|
||||
|
||||
## 准备编写 Clang 插件
|
||||
|
||||
编写之前,先在 llvm-project/clang/tools/ 目录下创建Clang 插件的目录,添加 YourPlugin.cpp 文件和 CMakeLists.txt 文件。其中,CMake 编译需要通过 CMakeLists.txt 文件来指导编译,cpp 是源文件。
|
||||
|
||||
接下来,我们可以使用如下代码编写 CMakeLists.txt 文件,来定制编译流程:
|
||||
|
||||
```
|
||||
add_llvm_library(YourPlugin MODULE YourPlugin.cpp PLUGIN_TOOL clang)
|
||||
|
||||
```
|
||||
|
||||
这段代码是指,要将Clang 插件代码集成到 LLVM 的 Xcode 工程中,并作为一个模块进行编写调试。
|
||||
|
||||
想要更多地了解 CMake 的语法和功能,你可以查看[官方文档](https://cmake.org/documentation/)。添加了 Clang 插件的目录和文件后,再次用 cmake 命令生成 Xcode 工程,里面就能够集成 YourPlugin.cpp 文件。
|
||||
|
||||
到这里,我们已经准备好了Clang 插件开发环境。接下来,我们就能够在Xcode编译器里开发Clang插件了。
|
||||
|
||||
编写 Clang 插件代码,入口就是 FrontActions。接下来,我们就一起看看FrontActions 是什么?
|
||||
|
||||
## FrontAction 是什么?
|
||||
|
||||
FrontActions 是编写 Clang 插件的入口,也是一个接口,是基于ASTFrontendAction 的抽象基类。其实,FrontActions 并没干什么实际的事情,只是为接下来基于 AST 操作的函数提供了一个入口和工作环境。
|
||||
|
||||
通过这个接口,你可以编写你要在编译过程中自定义的操作,具体方式是:通过 ASTFrontendAction 在 AST 上自定义操作,重载 CreateASTConsumer 函数返回你自己的 Consumer,以获取 AST 上的 ASTConsumer 单元。
|
||||
|
||||
代码示例如下所示:
|
||||
|
||||
```
|
||||
class FindNamedClassAction : public clang::ASTFrontendAction {
|
||||
public:
|
||||
// 实现 CreateASTConsumer 方法
|
||||
virtual std::unique_ptr<clang::ASTConsumer> CreateASTConsumer(
|
||||
clang::CompilerInstance &Compiler, llvm::StringRef InFile) {
|
||||
// 返回 ASTConsumer 单元
|
||||
return std::unique_ptr<clang::ASTConsumer>(
|
||||
new FindNamedClassConsumer);
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
ASTConsumer 可以提供很多入口,是一个可以访问 AST 的抽象基类,可以重载 HandleTopLevelDecl() 和 HandleTranslationUnit() 两个函数,以接收访问 AST 时的回调。其中,HandleTopLevelDecl() 函数是在访问到全局变量、函数定义这样最上层声明时进行回调,HandleTranslationUnit() 函数会在接收每个节点访问时的回调。
|
||||
|
||||
下面有一个示例,会重载 HandleTranslationUnit() 函数,使用 ASTContext 为单元调用,通过 RecursiveASTVisitor 来遍历 decl 单元。具体代码如下:
|
||||
|
||||
```
|
||||
class FindNamedClassConsumer : public clang::ASTConsumer {
|
||||
public:
|
||||
virtual void HandleTranslationUnit(clang::ASTContext &Context) {
|
||||
// 通过 RecursiveASTVisitor 来遍历 decl 单元。会访问所有 AST 里的节点。
|
||||
Visitor.TraverseDecl(Context.getTranslationUnitDecl());
|
||||
}
|
||||
private:
|
||||
// 一个 RecursiveASTVisitor 的实现
|
||||
FindNamedClassVisitor Visitor;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
上面代码可以看出,接收 AST 节点回调的 TranslationUnitDecl 函数通过重载已经准备就绪,为接下来 RecursiveASTVisitor 访问 AST 节点做好准备工作。
|
||||
|
||||
RecursiveASTVisitor 使用深度优先的方式访问 AST 的所有节点。RecursiveASTVisitor 使用的是访问者模式,支持前序遍历和后序遍历来访问 AST 节点。RecursiveASTVisitor 会遍历 AST 的每个节点,遍历节点的同时会回溯,回溯节点类型的基类,再调用节点对应的 Visit 函数。如果重写了节点对应的 Visit 函数,就会调用重写后的 Visit 函数。可以看出真正在干活的是 RecursiveASTVistor,它基本完成了编写 Clang 插件里最多、最重的活儿。
|
||||
|
||||
接下来,我就跟你说说怎么用 RecursiveASTVisitor 来查找指定名称的 CXXRecordDecl 类型的 AST 节点。也就是说,你需要通过 RecursiveASTVisitor 实现从 AST 里面提取所需要内容。
|
||||
|
||||
CXXRecordDecl 类型,表示 C++ struct/union/class。更多的节点类型,你可以参看[官方文档](http://clang.llvm.org/docs/LibASTMatchersReference.html)。
|
||||
|
||||
## 使用 RecursiveASTVisitor
|
||||
|
||||
RecursiveASTVisitor,可以为大多数的AST 节点提供布尔类型的 VisitNodeType(Nodetype *)。VisitNodeType 返回的布尔值可以控制 RecursiveASTVisitor 的访问,决定对 AST 节点的访问是否要继续下去。
|
||||
|
||||
下面,我们来重写一个访问所有 CXXRecordDecl 的 RecursiveASTVisitor。
|
||||
|
||||
```
|
||||
class FindNamedClassVisitor
|
||||
: public RecursiveASTVisitor<FindNamedClassVisitor> {
|
||||
public:
|
||||
bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {
|
||||
// dump 出已经访问的 AST 的声明节点。
|
||||
Declaration->dump();
|
||||
|
||||
// 返回 true 表示继续遍历 AST,false 表示停止遍历 AST。
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在 RecursiveASTVisitor 的方法里,可以使用 Clang AST 的全部功能获取想要的内容。比如,通过重写 VisitCXXRecordDecl 函数,找到指定名称的所有类声明。示例代码如下:
|
||||
|
||||
```
|
||||
bool VisitCXXRecordDecl(CXXRecordDecl *Declaration) {
|
||||
if (Declaration->getQualifiedNameAsString() == "n::m::C")
|
||||
Declaration->dump();
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
将代码保存成文件 FindClassDecls.cpp,并创建 CMakeLists.txt 文件来进行链接。CMakeLists.txt 的内容如下:
|
||||
|
||||
```
|
||||
add_clang_executable(find-class-decls FindClassDecls.cpp)
|
||||
target_link_libraries(find-class-decls clangTooling)
|
||||
|
||||
```
|
||||
|
||||
使用这个工具能够找到 n :: m :: C 的所有声明,然后输出如下信息:
|
||||
|
||||
```
|
||||
$ ./bin/find-class-decls "namespace n { namespace m { class C {}; } }"
|
||||
|
||||
```
|
||||
|
||||
## 编写 PluginASTAction 代码
|
||||
|
||||
由于 Clang 插件是没有 main 函数的,入口是 PluginASTAction 的 ParseArgs 函数。所以,编写 Clang 插件还要实现ParseArgs来处理入口参数。代码如下所示:
|
||||
|
||||
```
|
||||
bool ParseArgs(const CompilerInstance &CI,
|
||||
const std::vector<std::string>& args) {
|
||||
for (unsigned i = 0, e = args.size(); i != e; ++i) {
|
||||
if (args[i] == "-some-arg") {
|
||||
// 处理命令行参数
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 注册 Clang 插件
|
||||
|
||||
最后,还需要在 Clang 插件源码中编写注册代码。编译器会在编译过程中从动态库加载 Clang 插件。使用 FrontendPluginRegistry::Add<> 在库中注册插件。注册 Clang 插件的代码如下:
|
||||
|
||||
```
|
||||
static FrontendPluginRegistry::Add<MyPlugin> X("my-plugin-name", "my plugin description");
|
||||
|
||||
```
|
||||
|
||||
在 Clang 插件代码的最下面,定义的 my-plugin-name 字符串是命令行字符串,供以后调用时使用,my plugin description 是对 Clang 插件的描述。
|
||||
|
||||
现在,我们已经编写完了 Clang 插件,我来和你汇总下编写过程:
|
||||
|
||||
第一步,编写 FrontAction 入口。
|
||||
|
||||
第二步,通过 RecursiveASTVisitor 访问所有 AST 节点,获取想要的内容。
|
||||
|
||||
第三步,编写 PluginASTAction 代码处理入口参数。
|
||||
|
||||
第四步,注册 Clang 插件,提供外部使用。
|
||||
|
||||
接下来,我们再看看如何使用编写好的Clang插件吧。
|
||||
|
||||
## 使用 Clang 插件
|
||||
|
||||
LLVM 官方有一个完整可用的 Clang 插件示例,可以帮我们打印出最上层函数的名字,你可以点击[这个链接](https://github.com/llvm/llvm-project/blob/master/clang/examples/PrintFunctionNames/PrintFunctionNames.cpp)查看这个示例。
|
||||
|
||||
接下来,通过这个插件示例,看看如何使用 Clang 插件。
|
||||
|
||||
使用 Clang 插件可以通过 -load 命令行选项加载包含插件注册表的动态库,-load 命令行会加载已经注册了的所有 Clang 插件。使用 -plugin 选项选择要运行的 Clang 插件。Clang 插件的其他参数通过 -plugin-arg-<plugin-name> 来传递。</plugin-name>
|
||||
|
||||
cc1 进程类似一种预处理,这种预处理会发生在编译之前。cc1 和 Clang driver 是两个单独的实体,cc1 负责前端预处理,Clang driver则主要负责管理编译任务调度,每个编译任务都会接受 cc1 前端预处理的参数,然后进行调整。
|
||||
|
||||
有两个方法可以让 -load 和 -plugin 等选项到 Clang 的 cc1 进程中:
|
||||
|
||||
- 一种是,直接使用 -cc1 选项,缺点是要在命令行上指定完整的系统路径配置;
|
||||
- 另一种是,使用 -Xclang 来为 cc1 进程添加这些选项。-Xclang 参数只运行预处理器,直接将后面参数传递给 cc1 进程,而不影响 clang driver 的工作。
|
||||
|
||||
下面是一个编译 Clang 插件,然后使用 -Xclang 加载使用 Clang 插件的例子:
|
||||
|
||||
```
|
||||
$ export BD=/path/to/build/directory
|
||||
$ (cd $BD && make PrintFunctionNames )
|
||||
$ clang++ -D_GNU_SOURCE -D_DEBUG -D__STDC_CONSTANT_MACROS \
|
||||
-D__STDC_FORMAT_MACROS -D__STDC_LIMIT_MACROS -D_GNU_SOURCE \
|
||||
-I$BD/tools/clang/include -Itools/clang/include -I$BD/include -Iinclude \
|
||||
tools/clang/tools/clang-check/ClangCheck.cpp -fsyntax-only \
|
||||
-Xclang -load -Xclang $BD/lib/PrintFunctionNames.so -Xclang \
|
||||
-plugin -Xclang print-fns
|
||||
|
||||
```
|
||||
|
||||
上面命令中,先设置构建的路径,再通过 make 命令进行编译生成 PrintFunctionNames.so,最后使用 clang 命令配合 -Xclang 参数加载使用 Clang 插件。
|
||||
|
||||
你也可以直接使用 -cc1 参数,但是就需要按照下面的方式来指定完整的文件路径:
|
||||
|
||||
```
|
||||
$ clang -cc1 -load ../../Debug+Asserts/lib/libPrintFunctionNames.dylib -plugin print-fns some-input-file.c
|
||||
|
||||
```
|
||||
|
||||
## 小结
|
||||
|
||||
今天这篇文章,我主要和你解决的问题是,如何编写 Clang 插件。
|
||||
|
||||
Clang 作为编译前端,已经具有很强大的类 C 语言代码解析能力,利用 Clang 的分析能力,你可以在它对代码Clang AST 分析过程中,获取到 AST 各个节点的信息。
|
||||
|
||||
Clang AST 节点都是派生自 Type、Decl、Stmt。Clang AST 中最基本的两个节点就是语句 Stmt 和 声明 Decl,表达式 Expr 也是 Stmt。官方有份完整的 Clang AST 节点说明,你可以[点击链接](http://clang.llvm.org/docs/LibASTMatchersReference.html)查看使用。
|
||||
|
||||
获取到源码全量信息后,就可以更加精准的分析源码,然后统计出不满足编码规范的地方。同时,访问 SourceManager 和 ASTContext,还能够获取到节点所在源代码中的位置信息。这样的话,我们就可以直接通过Clang插件,在问题节点原地修改不规范的代码。
|
||||
|
||||
我们可以在 CreateASTConsumer 期间从 CompilerInstance 中获取ASTContext,进而使用其中的 SourceManager 里的 getFullLoc 方法,来获取 AST 节点所在源码的位置。
|
||||
|
||||
我们可以把获得的位置信息,分成行和列两个部分,据此就能够确定代码具体位置了。获取源码中位置方法如下面代码所示:
|
||||
|
||||
```
|
||||
// 使用 ASTContext 的 SourceManager 里的 getFullLoc 方法来获取到 AST 节点所在源码中的位置。
|
||||
FullSourceLoc FullLocation = Context->getFullLoc(Declaration->getBeginLoc());
|
||||
if (FullLocation.isValid())
|
||||
// 按行和列输出
|
||||
llvm::outs() << "Found declaration at "
|
||||
<< FullLocation.getSpellingLineNumber() << ":"
|
||||
<< FullLocation.getSpellingColumnNumber() << "\n";
|
||||
|
||||
```
|
||||
|
||||
## 课后作业
|
||||
|
||||
Clang 插件本身的编写和使用并不复杂,关键是如何更好地应用到工作中,通过 Clang 插件不光能够检查代码规范,还能够进行无用代码分析、自动埋点打桩、线下测试分析、方法名混淆等。
|
||||
|
||||
结合现在的工作,你还能够想到 Clang 插件的其他应用场景吗?
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
170
极客时间专栏/iOS开发高手课/原理篇/38 | 热点问题答疑(四).md
Normal file
170
极客时间专栏/iOS开发高手课/原理篇/38 | 热点问题答疑(四).md
Normal file
@@ -0,0 +1,170 @@
|
||||
<audio id="audio" title="38 | 热点问题答疑(四)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/25/5425e411481fb3ef31be839b15029525.mp3"></audio>
|
||||
|
||||
你好,我是戴铭。今天这篇答疑文章,我要针对近期留言中的热点问题,进行一次集中解答。
|
||||
|
||||
目前,我们专栏已经更新完了基础篇、应用开发篇和原理篇3大模块的内容。其中,原理篇的内容,因为涉及到的都是底层原理,比如系统内核XNU、AOP、内存管理和编译等,学习起来会很辛苦。但所谓良药苦口,你只有搞明白了这些最最底层的原理,才可以帮你抓住开发知识的规律,达到融会贯通的效果,进而提升自己造轮子、解决问题的能力。
|
||||
|
||||
也正因为这些底层知识比较难啃,需要细细琢磨,所以在这期答疑文章中,我并没有展开这个模块的内容。如果你对这个模块的文章有哪里不理解,或者觉得哪里有问题的话,可以在评论区留下你的观点,我会挑选合适的时机,给你答复。
|
||||
|
||||
接下来,我们就看看今天这篇文章要展开讨论的问题吧。
|
||||
|
||||
## 关于监控卡顿
|
||||
|
||||
@凡在第13篇文章[《如何利用 RunLoop 原理去监控卡顿?》](https://time.geekbang.org/column/article/89494)后问道:
|
||||
|
||||
>
|
||||
大多数的卡顿监控,都是在主线程上做的。音视频播放以及直播的卡顿,能否使用这种方式来监控呢?另外,我们公司对接的直播都是第三方的库和知识平台,我应该如何把这种监控放到客户端来做呢?
|
||||
|
||||
|
||||
针对这个同学的问题,我想说的是,只有在主线程上卡了,用户才会感知到,而监控卡顿主要就是要监控什么时候会卡。只要我们在发生卡顿的时刻,想办法去收集卡顿信息,就能够定位到问题,找出具体是由谁引起的卡顿。
|
||||
|
||||
比如,@凡同学提到的音视频播放卡顿问题,监控到发生卡顿的时刻,通过获取当时方法调用堆栈的方式,就能够确定出具体是哪个方法在调用,从而找到发生卡顿问题的原因。
|
||||
|
||||
当然,有些时候只通过各个线程中的方法调用栈来分析问题,可能信息还不太够,这时你还可以捕获各线程卡顿时的 CPU 使用率,进而发现哪个方法占用资源过高。同时,你还能够通过业务场景和环境数据埋点信息,综合分析发生卡顿时,业务场景以及数据是否出现了异常。
|
||||
|
||||
## 关于SMLogger的实现
|
||||
|
||||
@梁华建在第9篇文章[《无侵入的埋点方案如何实现?》](https://time.geekbang.org/column/article/87925)后留言,想要知道SMLogger是如何实现的。
|
||||
|
||||
SMLogger,是我对日志记录的一个封装。我在第9篇文章中使用 SMLogger 的方式,是这样的:
|
||||
|
||||
```
|
||||
[[[[SMLogger create]
|
||||
message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]
|
||||
classify:ProjectClassifyOperation]
|
||||
save];
|
||||
|
||||
```
|
||||
|
||||
可以看出,我把SMLogger 的接口设计成了链式调用的方式。这样的接口接收外部数据后,能够更加灵活地进行组合。
|
||||
|
||||
对于日志记录来说,可以设置默认的日志分类和日志级别,简单记录日志描述就只需要一个日志描述数据。
|
||||
|
||||
当使用者需要日志库记录一个对象时,就需要增加一个新的接口来支持记录对象。接下来,就会面对外部输入会进行不同组合的情况,比如日志记录对象、日志描述、日志分类、日志级别这四个数据的不同组合。为了满足这些不同的组合,你设置的接口数量也会增加很多。如果都放到一个统一接口中当作不同参数,那么参数的个数就会非常多,导致接口使用起来非常不方便。比如,你每次只需要设置日志描述这个参数,但是使用了多参数的统一接口后,需要手动去设置其他参数值。
|
||||
|
||||
使用链式调用的好处就是可以随意组合。而且,当有新的输入类型加入,要和以前接口组合时,也不需要额外工作。我定义的 SMLogger 的链式接口,如下所示:
|
||||
|
||||
```
|
||||
//初始化
|
||||
+ (SMLogger *)create;
|
||||
//可选设置
|
||||
- (SMLogger *)object:(id)obj; //object对象记录
|
||||
- (SMLogger *)message:(NSString *)msg; //描述
|
||||
- (SMLogger *)classify:(SMProjectClassify)classify; //分类
|
||||
- (SMLogger *)level:(SMLoggerLevel)level; //级别
|
||||
//场景记录
|
||||
- (SMLogger *)scene:(SceneType)scene;
|
||||
|
||||
//最后需要执行这个方法进行保存,什么都不设置也会记录文件名,函数名,行数等信息
|
||||
- (void)save;
|
||||
|
||||
```
|
||||
|
||||
可以看出,日志记录对象、日志描述、日志分类、日志级别分别为 object、message、classity、level。当需要在日志记录中增加业务场景数据时,只需要简单增加一个 scene 链式接口,就能够达到组合使用业务场景数据和其他链式接口的目的。
|
||||
|
||||
在 SMLogger 中,我还在链式基础上实现了宏的方式,来简化一些常用的日志记录接口调用方式。宏的定义如下:
|
||||
|
||||
```
|
||||
// 宏接口
|
||||
FOUNDATION_EXPORT void SMLoggerDebugFunc(NSUInteger lineNumber, const char *functionName, SMProjectClassify classify, SMLoggerLevel level, NSString *format, ...) NS_FORMAT_FUNCTION(5,6);
|
||||
// debug方式打印日志,不会上报
|
||||
#ifdef DEBUG
|
||||
#define SMLoggerDebug(frmt, ...) SMLoggerCustom(SMProjectClassifyNormal,SMLoggerLevelDebug,frmt, ##__VA_ARGS__)
|
||||
#else
|
||||
#define SMLoggerDebug(frmt, ...) do {} while (0)
|
||||
#endif
|
||||
// 简单的上报日志
|
||||
#define SMLoggerSimple(classify,frmt, ...) SMLoggerCustom(classify,SMLoggerLevelInfo,frmt, ##__VA_ARGS__)
|
||||
// 自定义classify和level的日志,可上报
|
||||
#define SMLoggerCustom(classify,level,frmt, ...) \
|
||||
do { SMLoggerDebugFunc(__LINE__,__FUNCTION__,classify,level,frmt, ##__VA_ARGS__);} while(0)
|
||||
|
||||
```
|
||||
|
||||
可以看到,宏定义最终调用的是 SMLoggerDebugFunc 函数,这个函数的实现如下所示:
|
||||
|
||||
```
|
||||
void SMLoggerDebugFunc(NSUInteger lineNumber, const char *functionName, SMProjectClassify classify, SMLoggerLevel level, NSString *format, ...) {
|
||||
va_list args;
|
||||
if (format) {
|
||||
va_start(args, format);
|
||||
// 输出方法名和行号
|
||||
NSString *msg = [[NSString alloc] initWithFormat:format arguments:args];
|
||||
msg = [NSString stringWithFormat:@"[%s:%lu]%@",functionName,(unsigned long)lineNumber,msg];
|
||||
// SMLogger 链式调用
|
||||
[[[[[SMLogger create] message:msg] classify:classify] level:level] save];
|
||||
va_end(args);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上面代码可以看到,SMLoggerDebugFunc 在处理完方法名和行号后,最终使用的就是SMLogger 链式调用。
|
||||
|
||||
通过宏的定义,日志记录接口调用起来也会简化很多,使用效果如下:
|
||||
|
||||
```
|
||||
// 宏方式使用,会记录具体调用地方的函数名和行数
|
||||
SMLoggerDebug(@"此处必改:%@ 此处也必改: %@",arr,dict); //仅调试,不上报
|
||||
SMLoggerSimple(SMProjectClassifyNormal,@"此处必改:%@ 此处也必改: %@",arr,dict); //会上报
|
||||
SMLoggerCustom(SMProjectClassifyNormal,SMLoggerLevelDebug, @"这两个需要上报%@%@",arr,dict); //level为debug不上报
|
||||
|
||||
```
|
||||
|
||||
## NSURLProtocol相关
|
||||
|
||||
@熊在第28篇文章[《怎么应对各种富文本表现需求?》](https://time.geekbang.org/column/article/95023)后留言到:
|
||||
|
||||
>
|
||||
WKWebView 对NSURLProtocol的支持不太好,我在网上找到的方法都不适用,连Ajax请求都不好去拦截。
|
||||
|
||||
|
||||
其实,WKWebView 处理资源缓存的思路和 UIWebView 类似,需要创建一个 WKURLSchemeHandler,然后使用 -[WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 方法注册到 WKWebView 配置里。
|
||||
|
||||
WKURLSchemeHandler 实例可以用来处理对应的 URLScheme 加载的资源,使用它的 webView:startURLSchemeTask 方法可以加载特定资源的数据。这样就能够起到和 NSURLProtocol 同样的效果。
|
||||
|
||||
## 关于JSON解析的问题
|
||||
|
||||
@大太阳在第26篇文章[《如何提高JSON解析的性能?》](https://time.geekbang.org/column/article/93819)中留言到:
|
||||
|
||||
>
|
||||
我现在项目是用Swift语言开发的,绝大部分的JSON解析用的是SwiftyJSON,很少一部分用到了KVC。我想问下,SwiftyJSON的效率怎么样?我怎么才能评测这个效率?市面上比较出名的第三方库,它们的效率排名是什么样的?
|
||||
|
||||
|
||||
其实,市面上的大多数第三方库,在解析 JSON 时用的都是系统自带的 JSONSerialization。因此,从本质上来看,它们的解析效率并无差别,只是在易用性、容错率、缓存效率上有些许差异。
|
||||
|
||||
比如,@大太阳提到的 SwiftyJSON 库,初始化方法如下:
|
||||
|
||||
```
|
||||
public init(data: Data, options opt: JSONSerialization.ReadingOptions = []) throws {
|
||||
let object: Any = try JSONSerialization.jsonObject(with: data, options: opt)
|
||||
self.init(jsonObject: object)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,SwiftyJSON 库在解析JSON时,使用的是 JSONSerialization。你可以点击[这个链接](https://github.com/SwiftyJSON/SwiftyJSON/blob/master/Source/SwiftyJSON/SwiftyJSON.swift),查看SwiftJSON 的完整代码。
|
||||
|
||||
既然 SwiftyJSON 也是使用JSONSerialization 来解析JSON的,那么解析效率就和其他使用JSONSerialization 解析的第三方库相比,没有本质上的差别。
|
||||
|
||||
## JSON案例相关
|
||||
|
||||
@徐秀滨在第23篇文章[《如何构造酷炫的物理效果和过场动画效果?》](https://time.geekbang.org/column/article/93090)后留言反馈,对通过JSON来控制代码逻辑的能力这块内容,感觉理解起来有些困难。接下来,针对这个问题,我再多说两句,希望能够对你有多帮助。
|
||||
|
||||
我在第26篇文章[《如何提高JSON解析的性能?》](https://time.geekbang.org/column/article/93819)中,举了个更加具体的例子,使用JSON 描述了一段 JavaScript 代码逻辑,你可以先看一下这篇文章的相关内容。
|
||||
|
||||
对于开发者来说,App 中的任何逻辑都可以通过代码来描述,而代码又能够转换成抽象语法树结构。JSON 作为一种数据结构的表示,同样可以表示代码的抽象语法树,自然也能够具有控制代码逻辑的能力。
|
||||
|
||||
## 总结
|
||||
|
||||
今天这篇答疑文章,我和你分享了监控卡顿、SMLogger、NSURLProtocol、JSON 相关的问题。
|
||||
|
||||
监控卡顿的方案实际上是通用的,和具体的场景没有关系。卡只是表现在主线程上,根本原因还是需要分析每个线程。
|
||||
|
||||
通过NSURLProtocol 对 WKWebView 支持不好的问题,我们可以看出,苹果公司为了更好地管控 WKWebView 而增加了一层,将资源的加载处理单独提供出来供开发者使用,以满足开发者自定义提速的需求。
|
||||
|
||||
最后,JSON 解析效率的提高,还是需要从根本上去解决,封装层解决的是易用性问题,所加缓存也只能解决重复解析的问题。
|
||||
|
||||
感谢你的收听,欢迎你在评论区给我留言分享你的观点,也欢迎把它分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user