0%

组件化

问题

由于我们的工程代码越来越多,文件原来越多,编译速度直线下降,工作效率直线下降,甚至会影响情绪(误 )

所以计划将所有pod进行静态库构建,这样编译的时候就不会再重编了,再加上我们组件化也在持续推进,最终可能只需要编我修改的库就行了(理想状态)

静态库构建

  1. 静态库构建最初方案与尝试
    最初指定的方案是在pod更新的时候进行打包,基于cocoapods-packager(后简称为packager)进行魔改。魔改后packger整体的方案是传入Podfile,在指定位置重新创建工程,根据Podfile与依赖进行libtool编译(动态库是使用xcodebuild,但我们不用动态库),然后将编译好的静态库拷贝出来。为什么要传入Podfile呢,在之前的packager是使用podspec进行工程生成,这会导致生成的静态库所依赖的其他pod版本号会和主工程中的Podfile对不上。所以我们需要维护一个和主工程Podfile相同版本号的Podfile(是不是有点绕)

其实packger内部也是使用Podspec生成Podfile的,我们只是做了直接传入Podfile,省去了生成的步骤。原理很简单,但实现还是需要摸索了一段时间。在扒了cocoapods源码后,发现cocoapods对Podfile对象提供了一个from_ruby的方法,这使得我们可以直接使用Podfile ruby文件就可以获取一个Podfile对象,但我们还需要对Podfile进行一些修改。我们将Podfile转化为yaml,然后将库中Podfile写的../ ./ 这种相对路径修改为工程地址(这是因为使用相对路径会导致生成的工程引用的代码文件不会被拷贝到工程下,源码调试会有问题),关闭uses_frameworks。

我们对packger还做了一些其他的修改。使用Podfile;修改生成工程的地址;对生成的工程Build Setting中的GCC_GENERATE_DEBUGGING_SYMBOLS打开(后面会介绍这个字段是干什么用的);修改了编译架构,只保留了arm64;修改了一些默认参数;增加了将生成的工程拷贝出来的参数;

这个魔改后的cocoapods plugin会镶嵌在自动更新pod脚本当中,使用的时候会询问是否要构建静态库。

但这个方案有两个问题,首先是需要维护一个和主工程版本号相同的Podfile比较麻烦,第二个是当依赖的库有变动的时候这个库的静态库也需要重新构建(包括依赖的依赖的依赖… 老套娃了)。这个其实可以通过分析Podfile.lock文件来寻找影响的所有库,但未免有些麻烦了。

  1. 最终方案
    ​我们既然不想维护一份单独的Podfile的话,那我们直接使用主工程不就好了吗?

所以最终我们选择了使用主工程编译后的产物来当做我们使用的静态库。

我们在工程内使用

1
xcodebuild CONFIGURATION_BUILD_DIR="#{library_path}" clean build -configuration Debug -scheme Pods-XXX -workspace "#{project_path}/XXX.xcworkspace" -arch arm64 -sdk iphoneos
  • 在 library_path 的位置生成所有静态库与bundle。这是我们的主体思路。
  • 在library_pods.json填入我们需要更新的库名(所有库名在工程pod update时候会在工程文件夹中生成一个current_mmpods.json当中)。
  • 分析 ~/.cocoapods/repos 中都有什么repo,获取repo的Git地址。
  • 分析Podfile.lock获取Pod都在那个repo中、版本号、是否被当成subspec引入。
  • 使用xcodebuild构建。
  • 遍历library_pods.json。
    • 从工程中拷贝源码。
    • 拷贝构建完成的静态库。
    • 从bundle中拷贝metalllib(.metal文件特有)生成对应的bundle。
    • 拷贝Podfile.lock(为了查询是什么情况下构建的静态库)。
    • repo中拷贝Podspec。
    • 如果Podspec没有被转化为json,就把它转成json。
    • 处理Podspec
      • 将静态库加入vendored_libraries。
      • 将源码路径加入Podfile(稍后讲到源码调试的时候会讲)。
      • 将使用的subspec中的一些字段比如source_files加入到最外面,这里是因为如果工程只引入了subspec的话,外层的对应字段会失效。
    • 将bundle路径加入到resources。
  • 构建源码的zip。
  • 构建完整的zip(包含源码zip)。
  • 上传完整的zip(这里踩了很多坑我们稍后再说)。
  • 提交Podspec至对应的静态库单独的repo仓库中。

好了,至此我们已经成功构建了一个使用静态库的Pod版本,版本号为为当前版本号.a.debug eg 1.0.0.a.debug。

我们每个版本之后进行统一回扫也不会存在有某些依赖的库更新上层库没更新的问题了。

源码调试

https://tech.meituan.com/2019/08/08/the-things-behind-the-ios-project-zsource-command.html

  1. 源码调试原理与过程
    切换完静态库之后直接带来了一个问题就是大家无法看到源码,没办法根据crash寻找代码,没法打断点了。

​为了解决这个问题,我们查阅了资料发现使用dwarfdump可以分析出静态库中AT_comp_dir的这个字段,AT_comp_dir代表编译地址,只要源码在这个位置,xcode就会根据它去寻找代码,当然了只是显示,编译还是使用静态库来编译的。(前文说的GCC_GENERATE_DEBUGGING_SYMBOLS就是为了生成这个字段用的,不过我们统一构建后就不需要再去用脚本设置了。)

​换句话说就是只要我们其他电脑上在当时构建静态库的源码路径上还是有源代码的,xcode就可以自动寻找到代码并显示。不过在我们尝试了软链接、硬链接和文件拷贝后,软链接和硬链接都有相应的问题(找不到源码或者无法进行断点调试)。最终我们还是选择了文件拷贝(又为大家岌岌可危的硬盘空间火上浇油了,不过没有很大啦。)

​现在问题来了我们需要找到一个大家都有的且有权限的路径下面进行静态库构建在经过大量尝试之后我们选择了 /private/var/folders/cocoapods/MOMO_iOS_Binary 这个路径来进行构建所以打包机上的工程师在这个路径下面的。

同时我们也支持的在工程中直接对源码进行断点,这个我们等下再说。

我们先来介绍一下我们为此做了什么工作:

  • 上文提到的源码zip就是为了一起打到zip包中方便我们pod update的时候获取源码的(考虑到再去下载可能会更麻烦)。
  • 需要把源码的zip包 xxx.binary 加入到podspec的Podfile字段中,否则update后工程中是没有它的。
  • 在我们进行pod update的时候使用Podfile提供的hook pre_install(在cocoapods拷贝文件完成之后)调用解压脚本将我们所有存在 xxx.binaryzip的文件全部解压到指定路径也就是编译时候的的路径。
  • 然后使用Podfile的另外一个hook post_integrate (在Integrating client project之后)(v1.10版本才提供,当前可调用工程/Binary/handle_add_source_file_to_project.sh)。
    这个路径其实可以通过dwarfdump来获取,但是执行的有点慢,不过我们都自己制定了路径了,那不如还是把路径写死吧!

dwarfdump xxx | grep “AT_comp_dir” | head -1 | cut -d \“ -f2

这一步我们分析了xcodeproj文件,可以将目标路径的整个文件夹当成一个Group加入到我们在Pod的xcodeproj文件中的SourceCode路径下。这些文件是不会被编译的,因为没有被加入到Build Phases中。

至此,我们就可以在工程目录中看到源码,可以直接打断点,当crash或者assert时也会直接跳转到对应位置啦(如果不是为了看源码,其实可以不操作第四步的)。

  1. 我有多个工程对应不同的源码版本怎么办呢?
    首先我们先重申一个概念,只要静态库中的AT_comp_dir对应的地方有源码,xcode会自动找到代码。
    最初我们的做法是不管什么版本的代码,只要是同名pod都会在默认的位置进行编译,如 Pods/AFNetworking。
    这样对于我们只有一个工程的情况确实没问题。但众所周知,大家的电脑里最少都有两个以上的工程副本。当处于不同的分支的时候有可能同一pod对应的就是不同的版本。这就会出现,我并不信任我当前调试的代码是不是和我当前的pod版本所对应。

我们为了解决这个问题,分为编译和解压两个部分。

编译
首先在打包的时候根据版本在不同的文件路径下编译。是不是有点没看懂,我先举个例子。假如我现在要给
AFNetworking,’1.0’版本进行静态库构建,它的原始代码路径为’工程/Pods/AFNetworking/xxx’。我们只需要把它改为工程/Pods/AFNetworking/1.0/xxx,这样在不同版本的情况下打包不就可以区分了吗?我们最终采用的是软链接,在’工程/Pods/AFNetworking/‘中创建一个名为1.0的软链接,链接’工程/Pods/AFNetworking/‘,这里有点绕,就是在目录下创建了一个指向自己的软链接,类似祖传套娃。

然后我们还需要修改project的group path,不改的话还是使用原来的路径编译。还是用AFNetworking来举例。原始的project group为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
9F144D000000D0 /* AFNetworking */ = {
isa = PBXGroup;
children = (
9F144D00000100 /* AFNetworking.h */,
9F144D000000E0 /* AFURLRequestSerialization.h */,
9F144D000000F0 /* AFURLResponseSerialization.h */,
9F144D00000250 /* Frameworks */,
9F144D00000110 /* NSURLSession */,
9F144D00000150 /* Reachability */,
9F144D00000170 /* Security */,
9F144D00000190 /* Serialization */,
9F144D000002D0 /* Support Files */,
9F144D000001A0 /* UIKit */,
);
name = AFNetworking;
path = AFNetworking;
sourceTree = "<group>";
};

我们需要把path修改为AFNetworking/1.0,然后就会发现编译不过了。因为有一个地方有些问题

1
2
3
4
5
6
7
8
9
10
9F144D000002D0 /* Support Files */ = {
isa = PBXGroup;
children = (
9F144D000002E0 /* AFNetworking.debug.xcconfig */,
9F144D000002F0 /* AFNetworking.release.xcconfig */,
);
name = "Support Files";
path = "../Target Support Files/AFNetworking";
sourceTree = "<group>";
};

发现问题所在了没?Support Files的path使用的是相对路径,因为我们相当于在上面多建立了一层文件结构所以这里要改为

  • path = “../../Target Support Files/AFNetworking”;
    以上修改完成之后就可以进行编译了。

解压
解压就比较简明易懂了,就是解压到和编译时相同的路径就可以了。这样我们就可以同时存在不同版本的代码了!

源码与静态库切换

先判断是否有二进制 git cat-file -e origin/master:#{first_name}/#{version + library_version_suffix}/#{first_name}.podspec.json

大文件存储

现在流程都跑通的,但我们每个版本都会构建新的静态库,那么我们的git仓库岂不是爆炸了吗?

我们最初有讨论将所有.a文件忽略掉,但这需要每次拉代码后进行一次pod update将静态库拉回来,有些麻烦。

经过调研和验证后我们选择了Git提供的Git LFS进行大文件存储,它实质上是讲所有文件传到Git LFS的仓库中而不是我们的工程仓库,在我们的工程仓库中会有一个类似指针的文件指向Git LFS仓库中的文件,所以我们仓库不会变大(理论上是会变大因为有一个指针文件)。当我们进行git pull的时候同时会进行git lfs pull将文件下载下来,这样解决了我们大文件存储的问题。

具体就是在.gitattribute中将*.a加入到Git LFS中。同时我们也加入了*.binaryzip

其实理论上可以进行工程回扫,将之前的所有commit都进行重建,这样我们的仓库就会从现在的十几个G减少到3个G左右,但需要SA配合,我们的电脑向Git服务器推因为过大,有三万多个commit,会在传输完成后被Git服务器中断连接,所以目前还没搞。

Q&A

Q:Podfile.lock是一个什么样的文件

A:Podfile.lock实际上是一个yaml文件,类似于json

Q:lottie-ios为什么没有切为静态库

A:有些库在spec中dependency这个库的时候限制了版本号 ~> 2.0,但cocoapods没有识别 2.0.a.debug 为2.x版本内

Q:ReactiveCocoa为什么和其他切换静态库的方式不一样

A:我们自动打出来的静态库,头文件会被增加RAC前缀,导致文件找不到。于是用单一构建的方式来构建了一版。

Q:为啥Jenkins打带有Git-LFS的包会报错?

A:因为Jenkins寻找的路径是xcode的目录,我们需要把Git-LFS拷贝到xcode目录中

cp $(which git-lfs) /Applications/Xcode.app/Contents/Developer/usr/libexec/git-core

启动速度优化

  • 静态库
  • 二进制重排,clang插桩

Total pre-main time: 1.2 seconds (100.0%)
dylib loading time: 567.72 milliseconds (45.5%)
rebase/binding time: 105.14 milliseconds (8.4%)
ObjC setup time: 40.01 milliseconds (3.2%)
initializer time: 532.47 milliseconds (42.7%)
slowest intializers :
libSystem.B.dylib : 4.70 milliseconds (0.3%)
libglInterpose.dylib : 295.89 milliseconds (23.7%)
AFNetworking : 48.75 milliseconds (3.9%)
Oasis : 285.94 milliseconds (22.9%)

参考

https://tech.meituan.com/2019/08/08/the-things-behind-the-ios-project-zsource-command.html

卡顿的发生通常有以下几个原因:

  • UI过于复杂,图文混排的绘制量过大;
  • 在主线程上进行同步的网络请求;
  • 在主线程上进行大量的IO操作;
  • 函数运算量过大,持续占用较高的CPU;
  • 死锁和主子线程抢锁;

dump线程

iOS 的线程技术与Mac OS X类似,也是基于 Mach 线程技术实现的,在 Mach 层中thread_basic_info 结构体封装了单个线程的基本信息:

1
2
3
4
5
6
7
8
9
10
struct thread_basic_info {
time_value_t user_time; /* user run time */
time_value_t system_time; /* system run time */
integer_t cpu_usage; /* scaled cpu usage percentage */
policy_t policy; /* scheduling policy in effect */
integer_t run_state; /* run state (see below) */
integer_t flags; /* various flags (see below) */
integer_t suspend_count; /* suspend count for thread */
integer_t sleep_time; /* number of seconds that thread has been sleeping */
}

一个Mach Task包含它的线程列表。内核提供了task_threads API 调用获取指定 task 的线程列表,然后可以通过thread_info API调用来查询指定线程的信息,在 thread_act.h 中有相关定义。

task_threads 将target_task 任务中的所有线程保存在act_list数组中,act_listCnt表示线程个数:

1
2
3
4
5
6
kern_return_t task_threads
(
task_t target_task,
thread_act_array_t *act_list,
mach_msg_type_number_t *act_listCnt
);

thread_info结构如下:

1
2
3
4
5
6
7
kern_return_t thread_info
(
thread_act_t target_act,
thread_flavor_t flavor, // 传入不同的宏定义获取不同的线程信息
thread_info_t thread_info_out, // 查询到的线程信息
mach_msg_type_number_t *thread_info_outCnt // 信息的大小
);

所以我们如下来获取CPU的占有率:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#import "LSLCpuUsage.h"
#import <mach/task.h>
#import <mach/vm_map.h>
#import <mach/mach_init.h>
#import <mach/thread_act.h>
#import <mach/thread_info.h>

@implementation LSLCpuUsage

+ (double)getCpuUsage {
kern_return_t kr;
thread_array_t threadList; // 保存当前Mach task的线程列表
mach_msg_type_number_t threadCount; // 保存当前Mach task的线程个数
thread_info_data_t threadInfo; // 保存单个线程的信息列表
mach_msg_type_number_t threadInfoCount; // 保存当前线程的信息列表大小
thread_basic_info_t threadBasicInfo; // 线程的基本信息

// 通过“task_threads”API调用获取指定 task 的线程列表
// mach_task_self_,表示获取当前的 Mach task
kr = task_threads(mach_task_self(), &threadList, &threadCount);
if (kr != KERN_SUCCESS) {
return -1;
}
double cpuUsage = 0;
for (int i = 0; i < threadCount; i++) {
threadInfoCount = THREAD_INFO_MAX;
// 通过“thread_info”API调用来查询指定线程的信息
// flavor参数传的是THREAD_BASIC_INFO,使用这个类型会返回线程的基本信息,
// 定义在 thread_basic_info_t 结构体,包含了用户和系统的运行时间、运行状态和调度优先级等
kr = thread_info(threadList[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
if (kr != KERN_SUCCESS) {
return -1;
}

threadBasicInfo = (thread_basic_info_t)threadInfo;
if (!(threadBasicInfo->flags & TH_FLAGS_IDLE)) {
cpuUsage += threadBasicInfo->cpu_usage;
}
}

// 回收内存,防止内存泄漏
vm_deallocate(mach_task_self(), (vm_offset_t)threadList, threadCount * sizeof(thread_t));

return cpuUsage / (double)TH_USAGE_SCALE * 100.0;
}
@end

博主文中提到:关于 phys_footprint 的定义可以在 XNU 源码中,找到 osfmk/kern/task.c 里对于 phys_footprint 的注释,博主认为注释里提到的公式计算的应该才是应用实际使用的物理内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* phys_footprint
* Physical footprint: This is the sum of:
* + (internal - alternate_accounting)
* + (internal_compressed - alternate_accounting_compressed)
* + iokit_mapped
* + purgeable_nonvolatile
* + purgeable_nonvolatile_compressed
* + page_table
*
* internal
* The task's anonymous memory, which on iOS is always resident.
*
* internal_compressed
* Amount of this task's internal memory which is held by the compressor.
* Such memory is no longer actually resident for the task [i.e., resident in its pmap],
* and could be either decompressed back into memory, or paged out to storage, depending
* on our implementation.
*
* iokit_mapped
* IOKit mappings: The total size of all IOKit mappings in this task, regardless of
clean/dirty or internal/external state].
*
* alternate_accounting
* The number of internal dirty pages which are part of IOKit mappings. By definition, these pages
* are counted in both internal *and* iokit_mapped, so we must subtract them from the total to avoid
* double counting.
*/

如何判断发生了OOM

解决

  • oom instsrument测试工具
  • 开发过程检测泄露
  • 大对象, 图片等资源的缓存
  • hook alloc dealloc

参考

https://engineering.fb.com/2015/08/24/ios/reducing-fooms-in-the-facebook-ios-app/

Momo 跨平台的crash-report解决方案

  • 最近负责公司的质量平台建设,参与了陌陌的crash-report方案设计和开发。
  • 经过优化封装后,已对外使用,组件地址
  • 使用方式请联系陌陌企业服务
  • iOS程序崩溃和抓取原理

Crash 背景

对于所有崩溃场景,仅25%的崩溃可通过信号量捕获,实施相应改进;另有75%的崩溃则难以识别,从而对App的用户体验,造成了巨大的潜在影响。

Facebook的工程师将App退出分为以下6个类别:
1.App内部主动调用exit()或abort()退出;
2.App升级过程中,用户进程被杀死;
3.系统升级过程中,用户进程被杀死;
4.App在后台被杀死;
5.App在前台被杀死,且可获取堆栈;
6.App在前台被杀死,且无法获取堆栈。

对于第1~4类退出,属于App的正常退出,对用户体验没有太大影响,无需进行相应处理;对于第5类退出,可通过堆栈代码级定位崩溃原因,对此业界已形成比较成熟的解决方案,;对于第6类退出,可能的原因很多,包括但不限于:系统内存不足时继续申请内存、主线程卡死20s以上、CPU使用率过高Stack Overflow等,在此我们统一称之为iOS客户端的“Abort问题”。
Abort问题无法被堆栈捕获,且发生频次远高于可被捕获的崩溃(下称“堆栈崩溃”)。从历史数据来看,手淘(电商类超级App代表)的Abort问题数量一般是堆栈崩溃数量的3倍左右;优酷Pad(视频类超级App代表)的Abort问题数量一般是堆栈崩溃数量的5倍左右。可见,Abort问题对用户的使用体验造成巨大影响。
本文将针对iOS客户端的Abort问题,进行根因定位分析,并提出系统性解决方案。

对于oom,除了facebook的排除法,还有字条抖音的 Memory Graph主动分析方式,参看iOS性能优化实践:头条抖音如何实现OOM崩溃率下降50%+
微信 https://wetest.qq.com/lab/view/367.html

task_vm_info.phys_footprint: 47.4MB
task_info.resident_size: 80MB

Abort问题的原因分类

形成Abort问题的原因主要包括以下4个。
1 内存 Jetsam
移动端设备的物理内存资源紧张,但App仍不断申请内存。因此系统signal 9杀死进程,造成异常退出。

1
{   "memoryPages" : {     "active" : 24493,     "throttled" : 0,     "fileBacked" : 24113,     "wired" : 13007,     "anonymous" : 12915,     "purgeable" : 127,     "inactive" : 10955,     "free" : 2290,     "speculative" : 1580  },  "uncompressed" : 125795,  "decompressions" : 143684  },  "largestProcess" : "Taobao4iPhone",  "processes" : [  {  ...  {     "rpages" : 2050,     "states" : [       "frontmost",       "resume"     ],     "name" : "Taobao4iPhone",     "pid" : 1518,     "reason" : "vm-thrashing",     "fds" : 50,     "uuid" : "5103a88a-917f-319e-8553-c0189dd1abac",     "purgeable" : 127,     "cpuTime" : 4.619693,     "lifetimeMax" : 3557  },  ...  }

2 主线程死锁
A/B两个线程同时等待对方完成某些操作,因而无法继续执行,形成死锁,造成异常退出。

1
Exception Type:  00000020Exception Codes: 0x000000008badf00dHighlighted Thread:  0 Application Specific Information:com.myapp.myapp failed to scene-create in time Elapsed total CPU time (seconds): 4.230 (user 4.230, system 0.000), 10% CPU Elapsed application CPU time (seconds): 1.039, 3% CPU Thread 0 name:  Dispatch queue: com.apple.main-threadThread 0:0   libsystem_kernel.dylib          0x36360540 semaphore_wait_trap + 81   libdispatch.dylib               0x36297eee _dispatch_semaphore_wait_slow + 1862   libxpc.dylib                    0x364077b8 xpc_connection_send_message_with_reply_sync + 1523   Security                        0x2b8dd310 securityd_message_with_reply_sync + 644   Security                        0x2b8dd48c securityd_send_sync_and_do + 445   Security                        0x2b8ea452 __SecItemCopyMatching_block_invoke + 1666   Security                        0x2b8e96f6 SecOSStatusWith + 147   Security                        0x2b8ea36e SecItemCopyMatching + 174

3 启动/重启超时
App由于启动/重启的时间超过系统允许的时间限制,造成异常退出。

scene-create watchdog transgression: app exhausted real (wall clock) time allowance of 19.93 seconds, Elapsed total CPU time (seconds): 21.050 (user 21.050, system 0.000)

4 CPU打爆
主线程死锁、启动/重启超时,都可能间接导致CPU打爆,造成异常退出。

1.Abort问题发生的场景:例如,哪个页面、什么操作。
2.Abort问题发生的原因:例如,内存Jetsam、主线程死锁、启动/重启超时、CPU打爆。
3.对于内存Jetsam,需进一步定位到是否发生了内存泄露以及泄露的循环引用(Retain Cycle)。
4.对于主线程死锁,需进一步定位到卡死的堆栈。
5.对于启动/重启超时,以及CPU打爆,需进一步定位到堆栈。

abort问题的系统性解决方案

  1. 现场捕获
    为实现Abort问题的系统性解决方案,需充分考虑以下问题:
    1.通过signal 9杀死进程造成的Abort问题,往往难以通过信号量捕获至堆栈。在这种情况下,应如何尽可能完整地捕获崩溃现场的关键信息?具体包含哪些信息?
    2.App崩溃时系统处于极不稳定的状态,应如何保证崩溃现数据稳定落盘?
    3.在信息采集、数据捕获的过程中,需对大量数据进行写入操作,应如何保证日志高性能写入?
    4.在数据量较大的情况下,数据的存储、上传可能对系统造成较大压力,应如何保证数据的高压缩率?

    基于以上考虑,我们提出并设计了一套基于mmap的高性能、高压缩率、高一致性、可自解释的trace文件协议,作为iOS端高可用体系的数据载体。

  1. mmap数据存储层保证数据写入的高性能和高一致性

    • 通过mmap将一个文件或者其它对象映射到进程的地址空间,对内存的操作会由内核将数据写到对应的磁盘文件上;数据写入的性能与内存操作相当(略比内存操作高)
    • 用户进程崩溃之后,这块映射区仍由内核管理,可以保证数据的一致性
  2. 二进制编码协议保证数据压缩率最高

    • 具体编码协议
    • 实测编码在压缩率能达到80%以上,或者直观一点说,使用50k的内存可以记录下用户二十分钟内详细的使用记录,包括页面访问记录、系统事件、秒级别的内存、CPU数据。
  3. 尽可能多的记录系统多维度指标及异常事件包括:

    • 性能数据,包括CPU、内存数据,用于判断应用当前是不是处理overload状态
    • 大内存申请
    • Retain Cycle,用于定位Jetsam Event
    • 卡顿,用于定位watch dog kill
    • 当前存活VC实例数量

BreakPad 跨平台的crash-report解决方案

官方地址
Github地址

介绍breakpad

大致流程

breakpad原理图

主要包括三个部分

  • dumpSyms 负责 读取 用户开发应用中的debug信息 并生成特定的符号文件参考
  • client 在崩溃系统中也就是指的崩溃上报的sdk 负责抓取当前线程和当前载入的库 生成 minidump文件
  • processor 也就是崩溃系统中的mnidump_stackwalk 读取minidump文件 找到合适的符号文件 产生一个人类可读的c/c++调用栈

MiniDump文件格式

  • minidump文件格式是由微软开发的用于崩溃上传,它包括:
    • 当dump生成时进程中一系列executable和shared libraries, 包括这些文件的文件名和版本号。
    • 进程中的线程列表,对于每个线程,minidump包含它在寄存器中的状态,线程的stack memory内容。这些数据都是未解析的字节流,Breakpad client通常没有调试信息(debugging information)能生成函数名,行号,甚至无法确定stack frame的边界。
    • 其他收集关于系统的信息,如:处理器,操作系统高版本,dump的原因等等。
  • breakpad在所有平台上(windows/linux等)都统一使用minidump文件格式,而不使用core files,原因是因为:
    • core files可能很大,而minidump比较小。
    • core files文档不全
    • 很难说服windows机器去生成core files,但可以说服其他机器来生成minidump文件。
    • breakpad只支持一种统一的格式会比较简单,而不是同时支持多种格式。

Symbols文件格式

symbols文件是基于纯文本的,每一行一条记录,每条记录中的字段以一个空格作为分隔符,每条记录的第一个字段表示这一行是什么类型的记录。

记录类型:

  • 模块记录:MODULE operatingsystem architecture id name
  • 文件记录:FILE number name
  • 函数记录:FUNC address size parameter_size name
  • 行号记录:address size line filenum
  • PUBLIC记录:PUBLIC address parameter_size name
  • STACK WIN
  • STACK CFI

不同平台的实现原理

默认情况下,当崩溃时breakpad会生成一个minidump文件,在不同平台上的实现机制不一样:

在windows平台上,使用微软提供的 SetUnhandledExceptionFilter() 方法来实现。
在OS X平台上,通过创建一个线程来监听 Mach Exception port 来实现。
在Linux平台上,通过设置一个信号处理器来监听 SIGILL SIGSEGV 等异常信号。

发送minidump文件

在真实环境中,你通常需要以某种方式来处理minidump文件,例如把它发送给服务器来进行分析,Breakpad源码提供了一些HTTP上传的代码,并提供一个minidump上传工具( 详见minidump_upload.cc)。

生成symbols文件

为了生成可读的stack trace, breakpad需要你将binaries里的调试符号(debugging symbols)转换成基于文本格式的symbol files。

首先确保你在编译代码的时候加上 -g 参数来生成带调试符号的。

然后使用 configure && make breakpad源码来生成 dump_syms 工具。

生成Stack Trace

breakpad包含一个叫做 minidump_stackwalk 的工具来将 minidump 文件,外加symbol files来生成一个人可读的stack trace。在编译breakpad后,这个工具一般在 google-breakpad/src/processor 目录下, 通过将 minidump 和 symbol files 传入给它即可:

客户端功能

每次crash都会生成三部分文件,目录结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
└── 根目录
├── basic.json //基础信息,展示在crash详情页

├── logs //业务方自定义日志,在展示在跟踪日志中
│ ├── log_1.txt
│ ├── log_2.txt
│ └── log_3.txt

└── stack.dmp(Android Native Crash / iOS Crash生成次文件)//堆栈信息,用于定位问题

└── logcat.log (控制台日志文件,2020110914:52:07添加,暂时只有android)

└── jvm.dmp (Android Java 堆栈信息) //安卓独有

接下来写一篇KSCrash读后感

kscm_handleException

参考

https://www.jianshu.com/p/295ebf42b05b
https://juejin.cn/post/6868230552571346951
https://engineering.fb.com/2015/08/24/ios/reducing-fooms-in-the-facebook-ios-app/
https://wetest.qq.com/lab/view/367.html

Autoreleasepool

  • 孙源大神的博客 Autoreleasepool
  • 网上搜的

与runloop的关系

第一个 Observer :
监视的事件是 Entry (即将进入Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。

第二个 Observer :
监视了两个事件:BeforeWaiting(准备进入休眠)
会调用_objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;

Exit(即将退出Loop)
会调用 _objc_autoreleasePoolPop( ) 来释放自动释放池。

  1. 子线程在使用autorelease对象时,如果没有autoreleasepool会在autoreleaseNoPage中懒加载一个出来。
  2. 在runloop的run:beforeDate,以及一些source的callback中,有autoreleasepool的push和pop操作,总结就是系统在很多地方都差不多autorelease的管理操作。
  3. 就算插入没有pop也没关系,在线程exit的时候会释放资源,执行AutoreleasePoolPage::tls_dealloc,在这里面会清空autoreleasepool。

注意点

  • 当产生了autorelease对象时,此对象又特别大, 例如图片、大字符串、大数据等等时,此时加上 autoreleasepool ,会更好,避免oom产生
  • 特别是在循环任务中执行代码,优先使用系统迭代器方法,因为系统迭代器帮我们写好了autoreleasepool

库文件地址

lua官方地址
luajit

执行原理

这里先卖个关子,有一次面试,面试官问我, objc_msgsend 可以使用C语言写吗? 当时没回答好,理论上是可以的。

我们都知道lua是解释执行语言,C是编译执行语言,最大的区别,就是C可以充分利用编译器,制作成汇编指令,效率最高。

lua解释性语言只能解释成字节码,模拟汇编,进行操作。

lua是C写的,所以 objc_msgsend 理论上也可以C实现,动态的执行方法(不同参数,不同返回值),但是效率就降低为解释执行语言的效率了。

待补充。。。。。。。。。

lua指令集

参看lopcodes.h文件,里面定义了很多指令,
还有一个重要函数,我就不贴代码了,一个方法400多行,在lvm.C文件中的luaV_execute,可以看下

1
2
3
4
/* main loop of interpreter */
for (;;) {
...这里会对不不同指令进行操作
}

一些零散的源码阅读

Lua虚拟机并不能执行我们的ifStateMent这种东西。Lua源码里的实现也是类似这种TokenType 和 结构化的 if Statement whileStatement等等,并且Lua没有生成完整的语法树。Lua源码的实现里面,它是解析一些语句,生成临时的语法树,然后翻译成指令集的。并不会等所有的语句都解析完了再翻译的。语义解析和翻译成指令集是并行的一个过程。贴一个源码里面关于语义解析的部分实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void body (LexState *ls, expdesc *e, int needself, int line) {
/* body -> `(' parlist `)' chunk END */
FuncState new_fs;
open_func(ls, &new_fs);
new_fs.f->linedefined = line;
checknext(ls, '(');
if (needself) {
new_localvarliteral(ls, "self", 0);
adjustlocalvars(ls, 1);
}
parlist(ls);
checknext(ls, ')');
chunk(ls);
new_fs.f->lastlinedefined = ls->linenumber;
check_match(ls, TK_END, TK_FUNCTION, line);
close_func(ls);
pushclosure(ls, &new_fs, e);
}

LuaJit

iOS的luajit

  1. LuaJIT分为JIT模式和Interpreter模式
  • JIT模式: 高效的机器码级别执行, 然而不幸的是这个模式在iOS下是无法开启的,因为iOS为了安全,从系统设计上禁止了用户进程自行申请有执行权限的内存空间,因此你没有办法在运行时编译出一段代码到内存然后执行,所以JIT模式在iOS以及其他有权限管制的平台(例如PS4,XBox)都不能使用。
  • Interpreter模式:编译成中间态的字节码(bytecode), 相比之下当然比JIT慢。但好处是这个模式不需要运行时生成可执行机器码字节码是不需要申请可执行内存空间的
  1. JIT模式一定更快?不一定!
    参考下图,这里可以看到,第一,Interpreter模式是必须的,无论平台是否允许JIT,都必须先使用Interpreter执行;第二,并非所有代码都会JIT执行,仅仅是部分代码会这样,并且是运行过程中决定的。

优化

  1. Reduce number of unbiased/unpredictable branches.减少不可预测的分支代码
  2. Use FFI data structures. 如果可以,将你的数据结构用ffi实现,而不是用lua table实现
  3. Call C functions only via the FFI.尽可能用ffi来调用c函数。
  4. Use plain ‘for i=start,stop,step do … end’ loops.实现循环时,最好使用简单的for i = start, stop, step do这样的写法,或者使用ipairs,而尽量避免使用for k,v in pairs(x) do
  5. Find the right balance for unrolling.循环展开,有利有弊,需要自己去平衡
  6. Define and call only ‘local’ (!) functions within a module.
  7. Cache often-used functions from other modules in upvalues.
  8. Avoid inventing your own dispatch mechanisms.避免使用你自己实现的分发调用机制,而尽量使用內建的例如metatable这样的机制
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    -- 编程的时候为了结构优雅,常常会引入像消息分发这样的机制,然后在消息来的时候根据我们给消息定义的枚举来调用对应的实现,过去我们也习惯写成:

    if opcode == OP_1 then

    elesif opcode == OP_2 then

    ...

    -- 但在luajit下,更建议将上面实现成table或者metatable

    local callbacks = {}

    callbacks[OP_1] = function() ... end

    callbacks[OP_2] = function() ... end

    -- 这是因为表查找和metatable查找都是可以参与jit优化的,而自行实现的消息分发机制,往往会用到分支代码或者其他更复杂的代码结构,性能上反而不如纯粹的表查找+jit优化来得快
  9. Do not try to second-guess the JIT compiler.无需过多去帮jit编译器做手工优化。
  10. Be careful with aliasing, esp. when using multiple arrays.变量的别名可能会阻止jit优化掉子表达式,尤其是在使用多个数组的时候。
  11. Reduce the number of live temporary variables.减少存活着的临时变量的数量
  12. Do not intersperse expensive or uncompiled operations.减少使用高消耗或者不支持jit的操作

Lua的API

参考api文档

#define lua_open() luaL_newstate() 开启一个lua状态机

1
2
3
4
5
6
7
8
9
void lua_settable (lua_State *L, int index);
// Does the equivalent to t[k] = v,
// where t is the value at the given valid index index,
// v is the value at the top of the stack,
// and k is the value just below the top.

// This function pops both the key and the value from the stack.
// As in Lua, this function may trigger a metamethod
// for the “newindex” event (see §2.8).
  1. lua_pop(L,num)函数从栈顶开始移除。
    当num>0时从栈顶移除指定个数 。
    当num=0时栈不受影响
    当num=-1时栈中元素全部移除

  2. lua_settop函数说明
    该函数用于指定栈的高度,栈只能从栈顶压栈,不能从栈底添加数据。所以栈底的数据会保持不变。
    当新的高度大于原来的高度时,会从栈顶压入数据,压入的数据不可用(因为是随机的)。
    当新的高度小于原来的高度时,会从栈顶移除多余的元素。
    当输入参数为负数时,表示从栈顶开始的索引(最栈顶元素为-1)。该函数会移除栈顶到该元素之间的所以元素。-1则无,-2 则移除-1 。-3则移除-1,-2。以此类推。但是负数编号不能超出栈底的负数索引,超出会抛出异常。lua_pop函数及是使用了该特性。

  3. int luaL_getmetatable (lua_State L,constchartname);
    将注册表中 tname 对应的元表(参见 luaL_newmetatable)压栈。如果没有 tname 对应的元表,则将 nil 压栈并返回假。

参考

https://blog.uwa4d.com/archives/usparkle_luajit.html

http://wiki.luajit.org/Numerical-Computing-Performance-Guide

深入浅出

入门级

  1. 用 ARC 管理内存

  2. 在正确的地方使用 reuseIdentifier

  3. 尽量把 views 设置为透明

  4. 避免过于庞大的 XIB

  5. 不要阻塞主线程

  6. 在 ImageViews 中调整图片大小。如果要在 UIImageView 中显示一个来自 bundle 的图片,你应保证图片 的大小和 UIImageView 的大小相同。在运行中缩放图片是很耗费资源的,特别是 UIImageView 嵌套在 UIScrollView 中的情况下。如果图片是从远端服务加载的你不能控制图片大小,比如在下载前调整到合适大 小的话,你可以在下载完成后,最好是用 background thread,缩放一次,然后在 UIImageView 中使用缩放后的图片。

  7. 选择正确的 Collection。

    • Arrays: 有序的一组值。使用 index 来 lookup 很快,使用 value lookup 很慢, 插入/删除很慢。
    • Dictionaries: 存储键值对。 用键来查找比较快。
    • Sets: 无序的一组值。用值来查找很快,插入/删除很快。
  8. 打开 gzip 压缩。app 可能大量依赖于服务器资源,问题是我们的目标是移动设备,因此你就不能指望网 络状况有多好。减小文档的一个方式就是在服务端和你的 app 中打开 gzip。这对于文字这种能有更高压缩 率的数据来说会有更显著的效用。
    iOS 已经在 NSURLConnection 中默认支持了 gzip 压缩,当然 AFNetworking 这些基于它的框架亦然。

中级

  1. 重用和延迟加载(lazy load) Views

    • 更多的view意味着更多的渲染,也就是更多的CPU和内存消耗,对于那种嵌套了很多view在 UIScrollView 里边的 app 更是如此。
    • 这里我们用到的技巧就是模仿UITableView和UICollectionView的操作:不要一次创建所有的subview, 而是当需要时才创建,当它们完成了使命,把他们放进一个可重用的队列中。这样的话你就只需要在 滚动发生时创建你的 views,避免了不划算的内存分配。
  2. Cache, Cache, 还是 Cache!

    • 一个极好的原则就是,缓存所需要的,也就是那些不大可能改变但是需要经常读取的东西。
    • 我们能缓存些什么呢?一些选项是,远端服务器的响应,图片,甚至计算结果,比如UITableView的行高。
    • NSCache 和 NSDictionary 类似,不同的是系统回收内存的时候它会自动删掉它的内容。
  3. 权衡渲染方法.性能能还是要 bundle 保持合适的大小。

  4. 处理内存警告.移除对缓存,图片 object 和其他一些可以重创建的 objects 的 strong references.

  5. 重用大开销对象

  6. 一些 objects 的初始化很慢,比如 NSDateFormatter 和 NSCalendar。然而,你又不可避免地需要使用它们, 比如从 JSON 或者 XML 中解析数据。想要避免使用这个对象的瓶颈你就需要重用他们,可以通过添加属性 到你的 class 里或者创建静态变量来实现。

  7. 避免反复处理数据.在服务器端和客户端使用相同的数据结构很重要。

  8. 选择正确的数据格式.解析 JSON 会比 XML 更快一些,JSON 也通常更小更便于传输。从 iOS5 起有了官方 内建的 JSON deserialization 就更加方便使用了。但是 XML 也有 XML 的好处,比如使用 SAX 来解析 XML 就 像解析本地文件一样,你不需像解析 json 一样等到整个文档下载完成才开始解析。当你处理很大的数据的 时候就会极大地减低内存消耗和增加性能。

  9. 正确设定背景图片

    • 全屏背景图,在view中添加一个UIImageView作为一个子View
    • 只是某个小的view的背景图,你就需要用UIColor的colorWithPatternImage来做了,它会更快地渲染也不会花费很多内存:
  10. 减少使用 Web 特性。想要更高的性能你就要调整下你的 HTML 了。第一件要做的事就是尽可能移除不 必要的 javascript,避免使用过大的框架。能只用原生 js 就更好了。尽可能异步加载例如用户行为统计 script 这种不影响页面表达的 javascript。注意你使用的图片,保证图片的符合你使用的大小。

  11. Shadow Path 。CoreAnimation 不得不先在后台得出你的图形并加好阴影然后才渲染,这开销是很大的。 使用 shadowPath 的话就避免了这个问题。使用 shadow path 的话 iOS 就不必每次都计算如何渲染,它使用 一个预先计算好的路径。但问题是自己计算 path 的话可能在某些 View 中比较困难,且每当 view 的 frame 变化的时候你都需要去 update shadow path.

  12. 优化 Table View

    • 正确使用reuseIdentifier来重用cells
    • 尽量使所有的viewopaque,包括cell自身
    • 避免渐变,图片缩放,后台选人
    • 缓存行高
    • 如果cell内现实的内容来自web,使用异步加载,缓存请求结果
    • 使用shadowPath来画阴影
    • 减少subviews的数量
    • 尽量不适用cellForRowAtIndexPath:,如果你需要用到它,只用-一次然后缓存结果
    • 使用正确的数据结构来存储数据
    • 使用 rowHeight, sectionFooterHeight 和 sectionHeaderHeight 来设定固定的高,不要请求 delegate
  13. 选择正确的数据存储选项

    • NSUserDefaults 的问题是什么?虽然它很 nice 也很便捷,但是它只适用于小数据,比如一些简单的布 尔型的设置选项,再大点你就要考虑其它方式了
    • XML 这种结构化档案呢?总体来说,你需要读取整个文件到内存里去解析,这样是很不经济的。使用 SAX 又是一个很麻烦的事情。
    • NSCoding?不幸的是,它也需要读写文件,所以也有以上问题。
    • 在这种应用场景下,使用 SQLite 或者 Core Data 比较好。使用这些技术你用特定的查询语句就能只加载你需要的对象。
    • 在性能层面来讲,SQLite和CoreData是很相似的。他们的不同在于具体使用方法。
    • Core Data 代表一个对象的 graph model,但 SQLite 就是一个 DBMS。
    • Apple 在一般情况下建议使用 Core Data,但是如果你有理由不使用它,那么就去使用更加底层的 SQLite吧。
    • 如果你使用SQLite,你可以用FMDB这个库来简化SQLite的操作,这样你就不用花很多经历了解SQLite的C API了。

高级

  1. 加速启动时间。快速打开 app 是很重要的,特别是用户第一次打开它时,对 app 来讲,第一印象太太太 重要了。你能做的就是使它尽可能做更多的异步任务,比如加载远端或者数据库数据,解析数据。避免过 于庞大的 XIB,因为他们是在主线程上加载的。所以尽量使用没有这个问题的 Storyboards 吧!一定要把设 备从 Xcode 断开来测试启动速度
  2. 使用 Autorelease Pool。NSAutoreleasePool`负责释放 block 中的 autoreleased objects。一般情况下它会自 动被 UIKit 调用。但是有些状况下你也需要手动去创建它。假如你创建很多临时对象,你会发现内存一直在 减少直到这些对象被 release 的时候。这是因为只有当 UIKit 用光了 autorelease pool 的时候 memory 才会被 释放。消息是你可以在你自己的@autoreleasepool 里创建临时的对象来避免这个行为。
  3. 选择是否缓存图片。常见的从 bundle 中加载图片的方式有两种,一个是用 imageNamed,二是用 imageWithContentsOfFile,第一种比较常见一点。
  4. 避免日期格式转换。如果你要用 NSDateFormatter 来处理很多日期格式,应该小心以待。就像先前提到 的,任何时候重用 NSDateFormatters 都是一个好的实践。如果你可以控制你所处理的日期格式,尽量选择 Unix 时间戳。你可以方便地从时间戳转换到 NSDate:

如何提升 tableview 的流畅度?

本质上是降低 CPU、GPU 的工作,从这两个大的方面去提升性能。

  • CPU:对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、 图像的绘制
  • GPU:纹理的渲染

卡顿优化在 CPU 层面

  • 尽量用轻量级的对象,比如用不到事件处理的地方,可以考虑使用 CALayer 取代 UIView
  • 不要频繁地调用 UIView 的相关属性,比如 frame、bounds、transform 等属性,尽量减少不必要的
    修改
  • 尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性
  • Autolayout 会比直接设置 frame 消耗更多的 CPU 资源
  • 图片的 size 最好刚好跟 UIImageView 的 size 保持一致
  • 控制一下线程的最大并发数量
  • 尽量把耗时的操作放到子线程
  • 文本处理(尺寸计算、绘制) - 图片处理(解码、绘制)

卡顿优化在 GPU 层面

  • 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示
  • GPU 能处理的最大纹理尺寸是 4096x4096,一旦超过这个尺寸,就会占用 CPU 资源进行处理,所以
    纹理尽量不要超过这个尺寸
  • 尽量减少视图数量和层次
  • 减少透明的视图(alpha<1),不透明的就设置 opaque 为 YES
  • 尽量避免出现离屏渲染

1.预排版,提前计算
在接收到服务端返回的数据后,尽量将 CoreText 排版的结果、单个控件的高度、cell 整体的高度提 前计算好,将其存储在模型的属性中。需要使用时,直接从模型中往外取,避免了计算的过程。
尽量少用 UILabel,可以使用 CALayer 。避免使用 AutoLayout 的自动布局技术,采取纯代码的方 式

2.预渲染,提前绘制
例如圆形的图标可以提前在,在接收到网络返回数据时,在后台线程进行处理,直接存储在模型数据里,
回到主线程后直接调用就可以了
避免使用 CALayer 的 Border、corner、shadow、mask 等技术,这些都会触发离屏渲染。
3.异步绘制
4.全局并发线程 5.高效的图片异步加载

如何优化 APP 的电量?

程序的耗电主要在以下四个方面:

  • CPU 处理
  • 定位
  • 网络
  • 图像

优化的途径主要体现在以下几个方面:

  • 尽可能降低 CPU、GPU 的功耗。
  • 尽量少用 定时器。
  • 优化 I/O 操作。
    o 不要频繁写入小数据,而是积攒到一定数量再写入
    o 读写大量的数据可以使用 Dispatch_io ,GCD 内部已经做了优化。 o 数据量比较大时,建议使用数据库
  • 网络方面的优化
    o 减少压缩网络数据 (XML -> JSON -> ProtoBuf),如果可能建议使用 ProtoBuf。
    o 如果请求的返回数据相同,可以使用 NSCache 进行缓存
    o 使用断点续传,避免因网络失败后要重新下载。
    o 网络不可用的时候,不尝试进行网络请求
    o 长时间的网络请求,要提供可以取消的操作
    o 采取批量传输。下载视频流的时候,尽量一大块一大块的进行下载,广告可以一次下载
    多个 - 定位层面的优化
    o 如果只是需要快速确定用户位置,最好用 CLLocationManager 的 requestLocation 方法。 定位完成后,会自动让定位硬件断电
    o 如果不是导航应用,尽量不要实时更新位置,定位完毕就关掉定位服务
    o 尽量降低定位精度,比如尽量不要使用精度最高的 kCLLocationAccuracyBest
    o 需要后台定位时,尽量设置 pausesLocationUpdatesAutomatically 为 YES,如果用户不太
    可能移动的时候系统会自动暂停位置更新
    o 尽 量 不 要 使 用 startMonitoringSignificantLocationChanges , 优 先 考 虑
    startMonitoringForRegion:
  • 硬件检测优化
    用户移动、摇晃、倾斜设备时,会产生动作(motion)事件,这些事件由加速度计、陀螺仪、 磁力计等硬件检测。在不需要检测的场合,应该及时关闭这些硬件

如何有效降低 APP 包的大小?

可执行文件

  • 编译器优化
  • Strip Linked Product、Make Strings Read-Only、Symbols Hidden by Default 设置为 YES
  • 去掉异常支持,Enable C++ Exceptions、Enable Objective-C Exceptions 设置为 NO, Other C Flags 添加 -fno-exceptions
  • 利用 AppCode 检测未使用的代码:菜单栏 -> Code -> Inspect Code
  • 编写LLVM插件检测出重复代码、未被调用的代码
    资源
    资源包括 图片、音频、视频 等
  • 优化的方式可以对资源进行无损的压缩
  • 去除没有用到的资源

什么是 离屏渲染?什么情况下会触发?该如何应对?

离屏渲染出发的场景有以下:

  • 圆角(maskToBounds并用才会触发) - 图层蒙版
  • 阴影
  • 光栅化
    为什么要避免离屏渲染?
    CPUGPU 在绘制渲染视图时做了大量的工作。离屏渲染发生在 GPU 层面上,会创建新的渲染缓冲区,会 触发 OpenGL 的多通道渲染管线,图形上下文的切换会造成额外的开销,增加 GPU 工作量。如果 CPU GPU 累计耗时 16.67 毫秒还没有完成,就会造成卡顿掉帧。
    圆角属性、蒙层遮罩 都会触发离屏渲染。指定了以上属性,标记了它在新的图形上下文中,在未愈合之前, 不可以用于显示的时候就出发了离屏渲染。
  • 在OpenGL中,GPU有2种渲染方式
  • On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作
  • Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作
  • 离屏渲染消耗性能的原因
  • 需要创建新的缓冲区
  • 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏 (Off-Screen);等到离屏渲染结束以后,将离屏缓冲区的渲染结果显示到屏幕上,又需要将上下文 环境从离屏切换到当前屏幕
  • 哪些操作会触发离屏渲染?
  • 光栅化,layer.shouldRasterize = YES
  • 遮罩,layer.mask
  • 圆角,同时设置layer.masksToBounds=YES、layer.cornerRadius大于0
  • 考虑通过 CoreGraphics 绘制裁剪圆角,或者叫美工提供圆角图片
  • 阴影,layer.shadowXXX,如果设置了 layer.shadowPath 就不会产生离屏渲染

在 Obj-C 中,如何检测内存泄漏?你知道哪些方式?

  • Memory Leaks
  • Alloctions
  • Analyse
  • Debug Memory Graph
  • MLeaksFinder

泄露的内存主要有以下两种:

  • Laek Memory 这种是忘记 Release 操作所泄露的内存。
  • Abandon Memory 这种是循环引用,无法释放掉的内存。

什么是 悬垂指针?什么是 野指针?

  • 悬垂指针
    指针指向的内存已经被释放了,但是指针还存在,这就是一个 悬垂指针 或者说 迷途指针
  • 野指针
    没有进行初始化的指针,其实都是 野指针

retain,copy,assign,weak,_Unsafe_Unretain 关键字的理解

深拷贝 和 浅拷贝 的概念,集合类深拷贝如何实现

自动引用计数应遵循的原则

Dealloc

  1. Dealloc调用流程

    1. 首先调用 _objc_rootDealloc()
    2. 接下来调用 rootDealloc()
    3. 这时候会判断是否可以被释放,判断的依据主要有 5 个,判断是否有以上五种情况
      • NONPointer_ISA
      • weakly_reference
      • has_assoc
      • has_cxx_dtor
      • has_sidetable_rc
    4. 如果有以上五中任意一种,将会调用 object_dispose()方法,做下一步的处理。
    5. 如果没有之前五种情况的任意一种,则可以执行释放操作,C 函数的 free()。 5.执行完毕。
  2. object_dispose() 调用流程。

    1. 直接调用 objc_destructInstance()。
    2. 之后调用 C 函数的 free()。
  3. objc_destructInstance() 调用流程

    1. 先判断 hasCxxDtor,如果有 C++ 的相关内容,要调用 object_cxxDestruct() ,销毁 C++ 相关的内容。
    2. 再判断hasAssocitatedObjects,如果有的话,要调用object_remove_associations(), 销毁关联对象的一系列操作。
    3. 然后调用 clearDeallocating()。
    4. 执行完毕。
  4. clearDeallocating() 调用流程。

    1. 先执行 sideTable_clearDellocating()。
    2. 再执行 weak_clear_no_lock,在这一步骤中,会将指向该对象的弱引用指针置为 nil。
    3. 接下来执行 table.refcnts.eraser(),从引用计数表中擦除该对象的引用计数。
    4. 至此为止,Dealloc 的执行流程结束。

内存管理方案

  • taggedPointer :存储小对象如 NSNumber。深入理解 Tagged Pointer
  • NONPOINTER_ISA(非指针型的 isa):在 64 位架构下,isa 指针是占 64 比特位的,实际上只有 30 多位就
    已经够用了,为了提高利用率,剩余的比特位存储了内存管理的相关数据内容。
  • 散列表:复杂的数据结构,包括了引用计数表和弱引用表
    通过 SideTables()结构来实现的,SideTables()结构下,有很多 SideTable 的数据结构。
    而 sideTable 当中包含了自旋锁,引用计数表,弱引用表。 SideTables()实际上是一个哈希表,通过对象的地址来计算该对象的引用计数在哪个 sideTable 中。

@autoreleasePool 的数据结构?

简单说是双向链表,每张链表头尾相接,有 parent、child 指针 每创建一个池子,会在首部创建一个 哨兵 对象,作为标记
最外层池子的顶端会有一个 next 指针。当链表容量满了,就会在链表的顶端,并指向下一张表。

BAD_ACCESS 在什么情况下出现?

访问了已经被销毁的内存空间,就会报出这个错误。
根本原因是有 悬垂指针 没有被释放。

autoReleasePool 什么时候释放?

App 启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是
_wrapRunLoopWithAutoreleasePoolHandler()。

  • 第一个 Observer 监视的事件是 Entry(即将进入 Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是 -2147483647,优先级最高,保证创 建释放池发生在其他所有回调之前。

  • 第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时调用 _objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;Exit(即 将退出 Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

  • 线程结束 Tls_dealloc 释放

  • runloop进入任务,结束任务时 释放

先看我画的一张图

野指针探测实现1

1、通过fishhook替换C函数的free方法为自定义的safe_free,类似于Method Swizzling
2、在safe_free方法中对已经释放变量的内存,填充0x55,使已经释放变量不能访问,从而使某些野指针的crash从不必现安变成必现。

  • 为了防止填充0x55的内存被新的数据内容填充,使野指针crash变成不必现,在这里采用的策略是,safe_free不释放这片内存,而是自己保留着,即safe_free方法中不会真的调用free。
  • 同时为了防止系统内存过快消耗(因为要保留内存),需要在保留的内存大于一定值时释放一部分,防止被系统杀死,同时,在收到系统内存警告时,也需要释放一部分内存

3、发生crash时,得到的崩溃信息有限,不利于问题排查,所以这里采用代理类(即继承自NSProxy的子类),重写消息转发的三个方法,以及NSObject的实例方法,来获取异常信息。但是这的话,还有一个问题,就是NSProxy只能做OC对象的代理,所以需要在safe_free中增加对象类型的判断

Zombie Objects

僵尸对象

可以用来检测内存错误(EXC_BAD_ACCESS),它可以捕获任何阐释访问坏内存的调用
给僵尸对象发送消息的话,它仍然是可以响应的,然后会发生崩溃,并输出错误日志来显示野指针对象调用的类名和方法

苹果的僵尸对象检测原理
从dealloc的源码中,我们可以看到“Replaced by NSZombie”,即对象释放时, NSZombie 将在 dealloc 里做替换,如下所示
所以僵尸对象的生成过程伪代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//1、获取到即将deallocted对象所属类(Class)
Class cls = object_getClass(self);

//2、获取类名
const char *clsName = class_getName(cls)

//3、生成僵尸对象类名
const char *zombieClsName = "_NSZombie_" + clsName;

//4、查看是否存在相同的僵尸对象类名,不存在则创建
Class zombieCls = objc_lookUpClass(zombieClsName);
if (!zombieCls) {
//5、获取僵尸对象类 _NSZombie_
Class baseZombieCls = objc_lookUpClass(“_NSZombie_");

//6、创建 zombieClsName 类
zombieCls = objc_duplicateClass(baseZombieCls, zombieClsName, 0);
}
//7、在对象内存未被释放的情况下销毁对象的成员变量及关联引用。
objc_destructInstance(self);

//8、修改对象的 isa 指针,令其指向特殊的僵尸类
objc_setClass(self, zombieCls);

野指针探测实现2

野指针检测流程
1、开启野指针检测
2、设置监控到野指针时的回调block,在block中打印信息,或者存储堆栈
3、检测到野指针是否crash
4、最大内存占用空间
5、是否记录dealloc调用栈
6、监控策略
1)只监控自定义对象
2)白名单策略
3)黑名单策略
4)监控所有对象
7、交换NSObject的dealloc方法

触发野指针
1、开始处理对象
2、是否达到替换条件
1)根据监控策略,是否属于要检测的类
2)空间是否足够
3、如果符合条件,则获取对象,并解除引用,如果不符合则正常释放,即调用原来的dealloc方法
4、向对象内填充数据
5、赋值僵尸对象的类指针替换isa
6、对象+dealloc调用栈,保存在僵尸对象中
7、根据情况是否清理内存和对象

通过僵尸对象检测的实现思路
1、通过OC中Mehod Swizzling,交换根类NSObject和NSProxy的dealloc方法为自定义的dealloc方法
2、为了避免内存空间释放后被重写造成野指针的问题,通过字典存储被释放的对象,同时设置在30s后调用dealloc方法将字典中存储的对象释放,避免内存增大
3、为了获取更多的崩溃信息,这里同样需要创建NSProxy的子类

具体实现

1、创建NSProxy的子类
2、hook dealloc函数的具体实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
@interface MIZombieSniffer : NSObject

/*!
* @method installSniffer
* 启动zombie检测
*/
+ (void)installSniffer;

/*!
* @method uninstallSnifier
* 停止zombie检测
*/
+ (void)uninstallSnifier;

/*!
* @method appendIgnoreClass
* 添加白名单类
*/
+ (void)appendIgnoreClass: (Class)cls;

@end

<!--2、MIZombieSniffer.m-->
#import "MIZombieSniffer.h"
#import "MIZombieProxy.h"
#import <objc/runtime.h>

//
typedef void (*MIDeallocPointer) (id objc);
//野指针探测器是否开启
static BOOL _enabled = NO;
//根类
static NSArray *_rootClasses = nil;
//用于存储被释放的对象
static NSDictionary<id, NSValue*> *_rootClassDeallocImps = nil;

//白名单
static inline NSMutableSet *__mi_sniffer_white_lists(){
//创建白名单集合
static NSMutableSet *mi_sniffer_white_lists;
//单例初始化白名单集合
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
mi_sniffer_white_lists = [[NSMutableSet alloc] init];
});
return mi_sniffer_white_lists;
}


static inline void __mi_dealloc(__unsafe_unretained id obj){
//获取对象的类
Class currentCls = [obj class];
Class rootCls = currentCls;

//获取非NSObject和NSProxy的类
while (rootCls != [NSObject class] && rootCls != [NSProxy class]) {
//获取rootCls的父类,并赋值
rootCls = class_getSuperclass(rootCls);
}
//获取类名
NSString *clsName = NSStringFromClass(rootCls);
//根据类名获取dealloc的imp指针
MIDeallocPointer deallocImp = NULL;
[[_rootClassDeallocImps objectForKey:clsName] getValue:&deallocImp];

if (deallocImp != NULL) {
deallocImp(obj);
}
}

//hook交换dealloc
static inline IMP __mi_swizzleMethodWithBlock(Method method, void *block){
/*
imp_implementationWithBlock :接收一个block参数,将其拷贝到堆中,返回一个trampoline
可以让block当做任何一个类的方法的实现,即当做类的方法的IMP来使用
*/
IMP blockImp = imp_implementationWithBlock((__bridge id _Nonnull)(block));
//method_setImplementation 替换掉method的IMP
return method_setImplementation(method, blockImp);
}

@implementation MIZombieSniffer

//初始化根类
+ (void)initialize
{
_rootClasses = [@[[NSObject class], [NSProxy class]] retain];
}

#pragma mark - public
+ (void)installSniffer{
@synchronized (self) {
if (!_enabled) {
//hook根类的dealloc方法
[self _swizzleDealloc];
_enabled = YES;
}
}
}

+ (void)uninstallSnifier{
@synchronized (self) {
if (_enabled) {
//还原dealloc方法
[self _unswizzleDealloc];
_enabled = NO;
}
}
}

//添加百名单
+ (void)appendIgnoreClass:(Class)cls{
@synchronized (self) {
NSMutableSet *whiteList = __mi_sniffer_white_lists();
NSString *clsName = NSStringFromClass(cls);
[clsName retain];
[whiteList addObject:clsName];
}
}

#pragma mark - private
+ (void)_swizzleDealloc{
static void *swizzledDeallocBlock = NULL;

//定义block,作为方法的IMP
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
swizzledDeallocBlock = (__bridge void *)[^void(id obj) {
//获取对象的类
Class currentClass = [obj class];
//获取类名
NSString *clsName = NSStringFromClass(currentClass);
//判断该类是否在白名单类
if ([__mi_sniffer_white_lists() containsObject: clsName]) {
//如果在白名单内,则直接释放对象
__mi_dealloc(obj);
} else {
//修改对象的isa指针,指向MIZombieProxy
/*
valueWithBytes:objCType 创建并返回一个包含给定值的NSValue对象,该值会被解释为一个给定的NSObject类型
- 参数1:NSValue对象的值
- 参数2:给定值的对应的OC类型,需要使用编译器指令@encode来创建
*/
NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))];
//为obj设置指定的类
object_setClass(obj, [MIZombieProxy class]);
//保留对象原本的类
((MIZombieProxy *)obj).originClass = currentClass;

//设置在30s后调用dealloc将存储的对象释放,避免内存空间的增大
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__unsafe_unretained id deallocObj = nil;
//获取需要dealloc的对象
[objVal getValue: &deallocObj];
//设置对象的类为原本的类
object_setClass(deallocObj, currentClass);
//释放
__mi_dealloc(deallocObj);
});
}
} copy];
});

//交换了根类NSObject和NSProxy的dealloc方法为originalDeallocImp
NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary];
//遍历根类
for (Class rootClass in _rootClasses) {
//获取指定类中dealloc方法
Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc"));
//hook - 交换dealloc方法的IMP实现
IMP originalDeallocImp = __mi_swizzleMethodWithBlock(oriMethod, swizzledDeallocBlock);
//设置IMP的具体实现
[deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)];
}
//_rootClassDeallocImps字典存储交换后的IMP实现
_rootClassDeallocImps = [deallocImps copy];
}

+ (void)_unswizzleDealloc{
//还原dealloc交换的IMP
[_rootClasses enumerateObjectsUsingBlock:^(Class rootClass, NSUInteger idx, BOOL * _Nonnull stop) {
IMP originDeallocImp = NULL;
//获取根类类名
NSString *clsName = NSStringFromClass(rootClass);
//获取hook后的dealloc实现
[[_rootClassDeallocImps objectForKey:clsName] getValue:&originDeallocImp];

NSParameterAssert(originDeallocImp);
//获取原本的dealloc实现
Method oriMethod = class_getInstanceMethod([rootClass class], NSSelectorFromString(@"dealloc"));
//还原dealloc的实现
method_setImplementation(oriMethod, originDeallocImp);
}];
//释放
[_rootClassDeallocImps release];
_rootClassDeallocImps = nil;
}

@end

整体上还算不错,对内核,Mach和BSD的介绍,包括一些函数和调用等,以及对异常处理的描述,对于理解PLC这个崩溃日志生成的库还是很有帮助的。同时一些内核线程、用户线程的概念也算是填补部分知识空白。引导过程虽然讲的很详细,不过兴趣不大。

名词解释

ASLR:Address Space Layout Randomization 地址空间布局随机化

XNU:X is Not UNIX

POSIX:Portable Operating System Interface 可移植操作系统接口

XPC:高级IPC框架,实现进程间特权的分离

ELF:Executable and Library Format ,Unix下的可执行文件格式

__LINKEDIT:由dyld使用,这个区包含了字符串表、符号表以及其他数据

pthread:就是BSD层实现的POSIX规范的线程,Mach层则是mach_thread

DFU:设备固件更新模式,这个模式用于更新iOS镜像

HFS:Hierarchical File System 层次文件系统

FAT:File Allocation Table 文件分配表

NFS:Network File System网络文件系统

VFS:Virtual File System虚拟文件系统

LR寄存器:保存了当前函数的返回地址

PC寄存器:指令指针,程序计数器,PC寄存器中的内容,是下一条要取来执行的指令的地址

ABI:Application Binary Interface 应用程序二进制接口

第一部分 第一到三章 高级用户指南

1.iOS系统的GUI是springboard,这是大家熟知的触屏应用加载器。

2.Darwin是操作系统的类UNIX核心,本身由内核,XNU和运行时组成。

3.XNU实际上是由两种技术混合在一起的:Mach和BSD,此外还添加了一些其他的组件,主要是IOKit。

4.在Mac OS中,“bundle”这个词实际上描述的是两种不同的术语:第一种是本节中讨论的目录结构;第二种是共享库目标的一种文件目标格式,共享库由进程显示的加载(普通的库是隐式加载的)。这个词有时候也表示一个插件。

5.OS X是在Mach内核的基础上构建的,而Mach是NeXTSTEP的遗产。BSD层是对Mach内核的包装,但是Mach系统调用仍然可以在用户态访问。

6.内核XNU是Darwin的核心,也是整个OS X的核心。XNU本身由以下几个组件构成:Mach微内核,BSD层,libKern,IOKit。

7.Mach微内核仅能处理操作系统最基本的职责:进程和线程抽象,虚拟内存管理,任务调度,进程间通信和消息传递机制。

8.BSD层建立在Mach层之上,BSD层提供了更高层次的抽象,其中包括:UNIX进程模型,POSIX线程模型(Pthread)及其相关的同步原语,UNIX用户和组,网络协议栈(BSD Socket API),文件系统访问,设备访问(通过/dev目录访问)。

9.OS X提供了一个系统级的通知机制。这是分布式IPC的一种形式,进程可以通过这种机制广播或监听事件。通知机制的核心部分在于notifyd(8) 守护程序,这个守护程序是在系统引导时启动的,这是Darwin的通知服务器。还有一个守护程序 distnoetd(8) 的作用是分布式通知服务器。notify(8) 默认使用Mach消息并注册Mach端口 com.apple.system.notification_center,能够处理大部分通知。notify(8)还有一个有意思的特点,这个API允许通过Mach消息传递文件描述符。

10.苹果通过代码签名,沙盒以及entitlement授权文件来保证iOS和OS X的安全。

11.沙盒机制也有一个专用的守护程序usr/libexec/sandboxd,这个程序运行在用户态,提供了跟踪功能以及内核扩展所需要的辅助服务,这个守护程序是根据需要启动的。

第四章 进程线程和Mach-O

1.尽管同一个可执行程序可以并发的启动多个实例,但是每一个实例都有一个不同的PID。

2.子进程返回的整数由其父进程收集。进程将要返回的值传递给exit(2) 系统调用(或者从main()函数返回)。

3.一个进程内的所有线程都共享虚拟内存空间,文件描述符和各种句柄。

4.进程的生命周期开始于SIDL状态,在这个状态中,进程仍被定义为”正在初始化“,不会响应任何信号,也不会进行任何操作。

5.睡眠的进程也会被信号唤醒。通过一个特殊的信号(TSTOP或TOSTOP)可以使一个进程停止执行。这相当于”冻结“了进程(即同时挂起这个进程的所有线程),将这个进程置于”深度睡眠“的状态。恢复这个进程的唯一方法就是发送另一个信号(CONT),这个信号可以将进程切换回可运行状态,使得进程中的每一个线程都可以被重新调度了。

6.终止一个进程会同时终止其所有线程。但是在进程完成终止之前,会短暂的处于僵尸(zombie)状态。每一个进程在安息之前都会有很短暂的时间处于这个状态。僵尸进程都是绝对死亡的进程,僵尸进程只是进程的空壳,所有的资源都被释放了,但是仍然占用着PID。

7.pid_suspend”冰冻“一个进程,pid_resume”解冻“一个进程。冰冻背后的挂起操作是在更为底层的Mach任务的层次实现的,而不是进程的层次实现的,所以这个睡眠更深。iOS的springboard大量使用了这些调用,比如用户按下Home键时。

8.iOS还添加了一个私有的系统调用 pid_shutdown_sockets,这在OS X是没有的。这个系统调用可以在进程之外关闭这个进程所有的套接字。只有springboard使用了这个调用,多半是挂起进程时使用的。

9.信号指发送给程序的异步通知,其中不包括数据(或只包括非常少量的数据)。信号是操作系统发送给进程的,用于表示发生了某种条件,而这种条件通常是因为某类硬件错误或程序异常而产生的。除了SIGKILL之外,进程可以通过一些系统调用屏蔽或处理以下的所有错误。

10.通用二进制(胖二进制)格式只不过是其支持的各种架构的二进制文件的打包文件。也就是说,这种格式的文件包含一个非常简单的文件头,文件头后面依次拷贝了每一种支持架构的二进制文件。lipo这个工具可以提取、删除或替换通用二进制文件中制定架构的二进制代码,因此可以用于对通用二进制文件进行”瘦身“。这个工具还可以显示胖二进制文件头的详细信息。

11.调用一个二进制文件时,Mach加载器会首先解析胖二进制文件头,确定其中可用的架构,然后只加载最适合的架构的代码,因此不相关架构的代码不会占用任何内存。

12.Mach-O格式具有一个固定的文件头,文件头一开始是一个魔数值,加载器可以通过这个魔数值快速判断这个二进制文件用于32位还是64位,在魔数值之后跟着的是CPU类型及子类型字段。

13.在iOS中,没有暴露sysct接口,堆和栈都默认不可执行。

14.Mach-O文件头的主要功能在于加载命令load command。otool工具可用于分析Mach-O文件。

15.加载过程在内核的部分负责新进程的基本设置——分配虚拟内存,创建主线程,以及处理任何可能的代码签名/加密的工作。然而对于动态链接的可执行文件(大部分可执行文件都是动态链接的)来说,真正的库加载和符号解析的工作都是通过LC_LOAD_DYLINKER命令指定的动态链接器在用户态完成的。控制权会转交给链接器。

16._PAGEZERO段(空指针陷阱)、_TEXT段(程序代码)、_DATA段(程序数据)和_LINKEDIT(链接器使用的符号和其他表)段提供了LC_SEGMENT命令。段有时候也可以进一步分解为区(section)。

17.当所有的库都完成加载之后,dyld的工作也完成了,之后由LC_UNIXTHREAD命令负责启动二进制程序的主线程(因此主线程总是在可执行文件中,而不会在其他二进制文件中例如库文件)。

18.LC_THREAD用于核心转储文件,Mach-O核心转储文件实际上是一组LC_SEGMENT命令的集合,这些命令负责建立起进程的内存镜像。

19.LC_MAIN命令的作用是设置程序主线程的入口点地址和栈大小。

20.Mach-O二进制文件有一个重要特性就是可以进行数字签名。LC_CODE_SIGNATURE包含了Mach-O二进制文件的代码签名,如果这个签名和代码本身不匹配(或者在iOS上这条命令不存在),那么内核会立即给进程发送一个SIGKILL信号,将进程杀死,没有商量的余地。

21.dyld是一个用户态的进程,dyld不属于内核的一部分,而是作为一个开源的项目由苹果单独维护的。

22.可执行文件很少是独立的,除了极少数的一些静态链接的可执行文件,大部分可执行文件都是动态链接的。

23.内核加载器执行的设置工作包括根据段的描述初始化进程地址空间以及执行其他命令。然而,仅有非常少量的进程只需要内核加载器就可以完成加载,而OS X上几乎所有的程序都是动态链接的。也就是说,Mach-O镜像中有很多”空洞“——即对外部的库和符号的引用——这些空洞要在程序启动时填补。这项工作就需要由动态链接器来完成。这个过程有时候也称为符号绑定(binding)。

24.动态链接器是内核执行LC_DYLINKER 加载命令时启动的。通常情况下,使用的是 /usr/lib/dyld 作为动态链接器,不过这条加载命令可以指定任何程序作为参数。链接器接管刚创建的进程的控制权,因为内核将进程的入口点设置为链接器的入口点。链接器索要完成的工作,就是查找进程中所有的符号和库的依赖关系,然后解决这些关系。这个过程必须递归的完成,因为通常情况下库还会依赖于其他的库。

25.通过otool -L命令可以显示库的依赖关系。

26.LC_LOAD_DYLIB命令告诉链接器在哪里可以找到这些符号。链接器要加载每一个指定的库,并且搜寻匹配的符号。被链接的库有一个符号表,符号表将符号名称和地址关联起来。符号表在Mach-O目标文件中的地址可以通过LC_SYMTAB加载命令指定的symoff找到。

27.libSystem库是系统上所有二进制代码的绝对先决条件,不论是C、C++还是Objective-C的程序。这是因为这个库是对底层系统调用和内核服务的接口,如果没有这些接口就什么事也干不了。

28.共享库缓存指的是一些库经过预先链接,然后保存在磁盘上的一个文件中。iOS中大部分常用的库都被缓存了。这个概念有点类似安卓的prelink-map,在prelink-map中的库被提前链接到地址空间中的固定偏移处。

29.为了节省加载的时间,iOS的dyld采用了一个共享库链接缓存,苹果从iOS3.0开始将所有的基础库都移到了这个缓存中。

30.在OS X中,dyld共享库缓存保存在/private/var/db/dyld目录下。在iOS中,共享库缓存可以在/System/Library/Caches/com.apple.dyld 中找到。这个缓存是一个单独的文件,即dyld_shared_cache_armv7。共享库缓存都会增长到非常庞大,OS X包含整整200多个文件,而iOS包含500多个文件,大小约为200M。

问题:共享库缓存到底是一个文件还是多个文件的组合?如果APP使用的第三方的动态库,是APP启动的时候操作系统才去加载,还是手机开机后自动加载这个共享库缓存目录下的文件?

31.OS X的dyld支持两级名称空间。这个特性是10.1引入的,指的是符号名称还包含其所在库的信息。这种方法更具有优势,因为允许两个不同的库导出相同的符号——而这在其他UNIX中会产生链接错误。有时候又需要禁用这种行为,通过将DYLD_FORCE_FLAT_NAMESPACE 环境变量设置为非零的值即可禁用。

32.函数拦截是传统ld没有而dyld有的特性。DYLD_INTERPOSE宏定义允许一个库将其库函数实现替换为另一个函数的实现。——这也是追踪函数调用和修改函数实现的原理

33.用户态的一个优点在于虚拟内存的隔离。进程独享一个私有的地址空间。

34.和所有标准的C语言程序一样,OS X中的可执行文件也有一个标准的入口点,默认名称为”main“。不过除了三个标准的参数——argc、argv和envp——之外,Mach-O程序还接受第四个参数:名为”apple“的char**。在Snow Leopard系统之前,”apple“参数只包含一个字符串——程序的完整路径,即启动这个程序所用的execve()系统调用传入的第一个参数。dyld在进程加载的过程中使用了这个参数。从Lion系统开始,”apple“参数被扩展为一个完整的向量,其中包括两个新加入的参数。

35.Cocoa应用程序的入口也是标准的C main(),不过常见做法是将main实现为NSApplicationMain()的包装,然后通过其进入Objective-C的编程模型。

36.重写内存最常用的方法是采用缓冲区溢出(即利用未经保护的内存复制操作越过栈上数组的边界),将函数的返回地址重写为自己的指针。不仅如此,黑客还有更具创意的技术,例如破坏pringtf()格式化字符串以及基于堆的缓冲区溢出等。

37.ASLR:进程每一次启动时,地址空间都会被简单的随机化——只是偏移,而不是搅乱。实现方法是通过内核将Mach-O的段平移某个随机系数。

38.在64位模式下,由于内存空间巨大,所以也就可以遵循其他操作系统采用的模型了,即将内核的地址空间映射到每一个进程的地址空间中。这是64位地址空间和传统的OS X模型的不同之处,传统的OS X内存模型中内核有自己的地址空间,但是新的地址空间允许更快速的用户态/内核态切换(共享CR3寄存器,CR3寄存器是包含了页表指针的控制寄存器)。

39.在内核层面,既没有用户堆也没有栈存在,所有的内存相关操作都要归约为页面。

40.尽管栈在传统上一直是用来保存自动变量的,但是在某些情况下,程序员也可以选择使用栈来动态分配内存,方法是使用鲜为人知的alloca()。如果发生了栈溢出,alloca()会返回NULL,进程会收到SIGSEGV信号。

41.iOS中VM压力释放机制依赖于Jetsam,Jetsam是一种类似于Linux的Out-Of-Memory killer的机制。

42.OS X有一个来自于Mach的独特之处在于,交换空间不是直接在内核层次管理的,而是由一个专用的用户进程dynamic_pager()处理所有的交换请求。这个进程在引导时由launchd通过一个属性列表文件cpm.apple.dynamic_pager.plist启动。

43.线程,作为最大化利用进程时间片的方法应运而生:通过使用多个线程,程序的执行可以分割为表面看上去并发执行的子任务。

44.进程中线程的抢占开销比多任务系统对进程抢占的开销要小。因此从这个角度看,大部分操作系统开始将调度策略从进程转换到线程是有意义的。

45.多处理器更是特别适合线程,因为多个处理器核心共享同样的cache和RAM——这为多线程之间的共享虚拟内存提供了基础。

46.GCD自己维护了一个底层的线程库实现,以支持并发和异步的执行模型,减轻开发者处理并发问题的负担,以及减少类似于死锁之类的潜在错误。GCD的另一个优势是能够自动的随着逻辑处理器的个数而扩展。 ——YYDispatchQueuePool可以根据设备的物理核心数量来创建对应的线程数量。

第五章 进程跟踪和调试

1.OS X中的调试工具首先要介绍的就是 DTrace。DTrace是一个重要的调试平台,移植自Sun的Solaris。DTrace中的D指的是D语言,这是一门完整的跟踪语言,通过这个语言可以创建专用的跟踪器,或称为探测器。D语言脚本被编译后由内核执行。

2.iOS中根本就没有提供DTrace。Linux上的ptrace提供了完整的进程跟踪和调试能力,因此称为了Linux下strace,gdb的基础。

3.DTrace神器的调试功能来源于能在内核中执行探测器的能力。DTrace的用户态部分由/usr/lib/dtrace.dylib 负责,Instruments和脚本解释器/usr/sbin/dtrace都使用了这个库。这是编译D脚本的运行时系统。然而,对于大部分有用的脚本来说,实际的执行都在内核态。DTrace通过一个特殊的字符设备(/dev/device)和内核组件进行通信。

4.sysctl机制在之前的章节中已经讨论过了,sysctl提供 一些显示进程统计数据的变量。sysctl获得进程ID列表的机制非常重要(事实上,ps和top指令都是通过这个机制获得进程列表的)。

5.除了DTrace和Instruments之外,在OS X中海油一些工具可以获得系统或进程状态的快照:system_profiler,sysdiagnose,allmemory,stackshot,stack_snapshot系统调用。

6.allmemory工具的作用是捕获用户进程的所有内存使用情况。运行时,这个工具遍历系统中的每一个进程,然后将其内存映射导出至/tmp/allmemoryfiles(可以通过-o指定其他文件)。获得了所有进程内存快照之后,allmemory会显示每一个进程的汇总统计数据,还会显示框架的内存使用。

7.stack_snapshot这个系统调用可以捕获指定进程中所有线程的状态。

8.sc_usage工具显示每一个进程的系统调用信息。fs_usage可以显示系统调用,但是显示的是与文件、套接字和目录相关的应用,这个工具可以显示系统范围内的跟踪(除非调用时提供了PID或命令参数)。

9.latency工具显示中断和调度的延迟值。这个工具展示落在阈值内的上下文切换和中断处理程序计数,这两个阈值分别可以通过-st和-it参数设置。

10.XNU包含一个称谓kdebug的内建内核跟踪设施。kdebug利用内核缓冲器来记录日志,而内核缓冲器的空间极为有限。

11.在UNIX中,崩溃和一个信号有关。崩溃的真正原因来自于内核,内核发现进程无法继续执行时,生成这个信号作为最后的补救办法。

12.当一个进程崩溃时,可以选择是否生成核心存储文件。iOS和OS X都没有选择创建巨大的核心转存文件,而是包含了一个CrashReporter,当进程异常终止时自动触发Crash Reporter,生成详细的崩溃日志。这个机制在进程消亡之前进行快速简单的分析,并且在崩溃日志中记录重要的内容。

13.有没有可能在一个应用程序崩溃时自动运行另一个应用程序?在iOS和OS X中,将异常端口绑定至BSD进程底层的Mach任务的机制则能实现这一点。

14.spindump和sample命令的采样方法都是类似的——挂起进程,记录栈跟踪(spindump使用之前描述的stack_snapshot系统调用),然后恢复进程。采样间隔通常大约为10毫秒,整个采样过程通常持续10秒,这两个值是可以配置的。

15.应用程序崩溃的主要原因就是缓冲区溢出(既包含栈也包含堆)和堆内存的破坏。

16.libgmalloc.dylib这个库可以截获并调试内存分配,这个强大的库的工作原理是截获LibSystem中的分配函数。

17.任何读/写操作如果越过了缓冲区的尾部就会导致读写操作越过页边界,从而导致一个未处理的页错误,使得进程收到总线错误的信号(SIGBUS)而崩溃。

18.标准的UNIX命令ps可以显示进程列表。UNIX的top命令是一个获得当前系统运行状况的关键工具,在OS X和iOS上都可用,而且相比标准版本有所修改。这些修改都和底层的Mach架构有关,使得top既能显示UNIX的信息(来自XNU的BSD层),也能显示Mach的信息。

19.有时候需要查看某个进程在使用哪些文件,或某个文件正在被哪些进程使用。lsof()和fuser()这两个工具就分别能够实现以上两个功能。lsof()是对之前描述的fs_usage的补充,因为后者只能看到新打开文件的操作,而看不到已经打开的文件。lsof()能显示一个进程所有文件描述符(包括套接字)的映射。换句话说,fs_usage可以持续运行,lsof产生的是一个快照。

20.fuser()提供的是反向的映射——从文件到拥有这个文件的进程。这个工具的主要作用是诊断文件锁定或者“文件被占用”的问题。

21.尽管XNU在用户态提供了完整的POSIX API,展示了UNIX兼容的人格,但是底层的实现却主要依靠Mach的基本原语。

22.随着苹果转向LLVM-gcc转换,还引入了LLDB作为GDB的替代品。LLDB的语法基本上和GDB类似,但是调试功能大有增强。

第六章 引导过程 第七章 贯穿始终——launchd

1.固件(firmware)可以看作是一种软件,这种软件因为被写入了芯片,所以是“固化”的。固件代码本身可以保存在只读存储器(ROM)中,也可以保存在电可擦除只读存储器EEPROM中

2.BIOS是一个固定的程序,而且通常都是封闭的。EFI是一套接口。EFI更像是一个运行时环境,规范了一组应用程序编程接口,基于EFI的程序可以利用这些接口实现功能。

3.苹果的EFI实现的另一个重要特性是Boot Camp。Boot Camp是苹果双重引导的解决方案,这个解决方案再Mac硬件上运行非苹果的操作系统(主要是Windows)。

4.一旦苹果关闭了某个iOS版本的时间窗口,将安全服务器配置为拒绝这个版本的签名时,就不可能降级固件了。

5.OS X和iOS中,用户环境始于launchd。launchd作为系统中的第一个用户态进程,负责直接或间接的启动系统中的其他进程。launchd仍然属于Darwin的范畴。

6.launchd是由内核直接启动的。负责加载BSD子系统的主内核线程创建一个线程来执行bdsinit_task。这个线程获得PID 1,并且临时命令为“init”,这是一项来源于BSD的遗产。bsdinit_task然后调用load_init_program(),这个函数调用execve()系统调用(在内核空间中执行)执行守护程序。

7.系统范围的launchd(PID 1)是不可能终止的。事实上,这个launchd是系统上唯一不朽的进程。当系统关闭的时候,launchd也是最后一个退出的进程。

8.launchd的核心职责是根据预定的安排或实际的需要加载其他应用程序或作业。launchd区分两种类型的后台作业:

①守护程序(daemon)和传统的UNIX概念一样,是后台服务,通常和用户没有交互。守护程序由系统自动启动,不考虑是否有用户登录进系统。

②代码程序(agent)是一类特殊的守护程序,只有在用户登录的时候才启动。和守护程序的不同之处在于,代理程序可以和用户交互,有的代理程序还有GUI。

③iOS不支持用户登录的概念,因此只有LaunchDaemon。

④守护程序和代理程序都是通过自己的属性列表文件(.plist)声明的。

9.launchd是用户态出现的第一个进程。当系统还在启动初期的时候,launchd是系统上唯一的进程(尽管这个状态很短暂)。这意味着系统启动和功能的方方面面都和launchd直接或间接相关。

10.launchd的第一个,也是主要的职责就是init守护程序的职责。init的职责是派生出各种各样的后台守护程序,设置好系统,然后转入后台,确保这些守护程序都活着。如果有程序死亡了,launchd要负责重新派生出新的守护程序。

11.传统意义上,init是用户态的第一个进程,从init fork出其他进程(转而可能fork出更多进程),init设置的资源限制会被所有后代继承。而launchd(新时代意义上的用户态第一个进程)还会设置Mach异常端口,内核内部通过异常端口处理异常情况并生成信号。

12.UNIX传统上包含两个守护程序——atd和crond——来定时作业,即在指定时间运行指定的命令。第一个守护程序atd负责一次运行的作业,第二个守护程序crond提供了重复执行作业的支持。

13.inetd/xinetd的用途是启动网络服务。这个守护程序的职责是绑定一些端口(UDP端口或TCP端口),当有连接请求到达的时候,根据需要启动相应的服务程序,并且将服务程序的输入输出描述符(stdin,stderr,stdout)连接到对应的套接字。

14.Mach的IPC服务依赖于“端口”的概念(有点类似TCP和UDP端口的概念),端口是通信的端点。

15.launchd还整合了iOS的Jetsam机制,Jetsam机制可以强制施行虚拟内存使用率的限制,这项特性在没有交换空间的iOS中特别重要。

16.ReportCrash守护程序是默认的崩溃处理程序,截获所有应用程序的崩溃。通过设置作业的Mach异常端口,在发生崩溃的时候自动运行。

17.iOS中amfid守护程序,可以阻止一切无签名无entitlement的代码在iOS中运行。

18.apsd(ApplePushService.framework)守护程序则是苹果推送服务的守护程序,以mobile用户运行。

19.atc守护程序则用于空中流量限制。crash_mover守护程序用于将崩溃日志移动到 /var/Mobile/Library/Logs 目录。

20.mobile_obliterator守护程序则用于远程擦除设备。

21.lockdownd就像是用户态的狱警,是所有越狱者的头号敌人。lockdownd由launchd启动,负责处理设备激活、备份、崩溃报告、设备同步以及其他服务。

22.OS X下的图形shell环境是Finder,iOS使用的择时SpringBoard。

23.和Finder不太一样,SpringBoard几乎全部靠自己完成所有的工作,在CoreServices目录下也只有少量几个可加载的bundle。

lockbundle提供了锁屏时的功能。NowPlayingArtLockScreen.lockbundle负责提供当音乐播放器正在运行且屏幕锁定的时候的锁屏画面,PictureFramePlugin负责显示用户照片库中的图片。iPhone还有一个名为VoiceMemosLockScreen的bundle,负责显示语音信息和未接电话指示器。

24.SpringBoard是一个多线程的应用程序,线程数远多于Finder。如果SpringBoard超过几分钟没有响应,那么看门狗会重启系统。

25.SpringBoard注册的端口中,最重要的是PurpleSystemEventPort,这个端口通过GSEvent消息的方式处理UI事件。SpringBoard中的主线程调用GSEventRun(),GSEventRun()是一个处理UI消息的CFRunloop。其他线程都类似的运行循环,处理SpringBoard中的其他Mach端口。

26.XPC是Lion和iOS5新引入的轻量级进程间通信原语。libxpc.dylib提供了各种各样的C语言层次的XPC原语。默认情况下XPC的消息是异步发送的,应答也是异步的,通过reply_sync函数可以阻塞知道收到应答消息。XPC是通过Mach消息机制实现的。

第二部分 内核

  • 第八章 内核架构

1.内核既是一个操作系统,也是一个调度器,还是一个仲裁器,内核同时也提供安全服务。

2.内核从架构上分为巨内核,微内核和混合内核。

3.巨内核采取的方式是将所有的内核功能——不论是基础功能还是高级功能——全部放在一个地址空间中。在这种架构的内核中,线程调度和内存管理,以及文件系统、安全管理、甚至设备驱动全都在一起。

所有的内核功能都实现在同一个地址空间中吗。为了进一步优化,巨内核不进将所有的工呢过都组织在同一个地址空间中们还将这个地址空间映射到每一个进程的内存中。

在巨内核架构中,从用户态到内核态的切换非常搞笑,基本上就是一次线程切换的开销。这是因为内核的你内存页面映射在所有进程的地址空间中,也就是说,除了硬件强制的内核态和用户态之间的隔离外,两者之间其实没有任何分别。所有的进程,不论所有者或功能,都包含一份内核内存的拷贝,就好像包含共享库的拷贝一样。此外,这些拷贝(同样类似于共享库)都映射到了同一组物理页面,而且是常驻内存的物理页面。

4.XNU的核心组件Mach是一个微内核系统。

一个微内核值包含最核心的内核功能,代码量也最精简。内黑只负责完成最最关键的部分——通常包括任务调度和内存管理,其他的功能都交给外部服务程序(通常是用户态)完成。

微内核有几个巨内核没有的有点:正确性,稳定性和健壮性,灵活性(移植)。

尽管微内核架构有着种种优势,但是却有一个致命的缺点——性能。微内核的消息传递在底层需要通过内存复制操作以及数次上下文的切换来实现,而这些操作对计算速度的影响都不小。

5.混合内核试图结合两种内核的好处。内核最核心部分支持底层服务,包括调度、进程间通信和虚拟内存,是自包含的,这一部分就像微内核一样。所有其他的服务都实现在这个核心之外,但是也在内核态中,而且和这个核心在同一个内存空间中。

这种内核不强制要求消息传递。其他组件可以调用这个“内部核心”的服务,但是这个“内部核心”不能调用外部的组件。

6.从技术上说,XNU是一个混合内核。Windows内核也被认为是一个混合型的内核,但是两者差别巨大。Windows更接近巨内核,所以死亡蓝屏概率高,而XNU更接近于微内核。

7.XNU的Mach最早是一个真正的微内核,现在Mach的原语仍然是围绕着消息传递的基础构建的。然而,消息通常是以指针的形式传递的,因此没有昂贵的复制操作。这是因为大部分服务现在都在同一个地址空间中执行(因为也会被归为巨内核)。类似的,建立在Mach之上的BSD层一直都是一个巨内核,而且这个子系统也在同一个地址空间中。

8.32位的OS X应用程序可以享用完整的没有内核预留的地址空间——内核有自己的地址空间。然而在64位的OS X中,苹果却顺从了,就像其他巨内核的系统一样,内核空间和用户空间是共享的。在iOS中也是如此。

9.内核是一个受信任的系统组件。内核的功能和应用程序的功能之间需要有一种严格的分离,否则应用程序的崩溃会使整个系统本科鬼。这种分离需要由硬件强制支持,因为基于软件的强制实施不但会产生很大的开销,也不可靠。区分内核态和用户态非常重要,因此这个功能是由硬件提供的。

10.用户态和内核态的切换有两种类型:

①自愿转换,比如系统调用;

②非自愿转换,当发生异常、中断或处理器陷阱的时候,代码的执行会被挂起,并且保留发生错误时候的完整状态。控制权被转交给预定义的内核态错误处理程序或中断服务程序。

11.一共有三种类型的异常:

①错误(fault):指令遇到一个可以纠正的异常,并且处理器可以重新启动这条出现异常的指令。

②陷阱(trap):类似于错误,但是错误处理完成后返回发生陷阱指令之后的那条指令。

③中止(abort):不可重启指令。

12.BSD系统调用,可以通过current_task获得当前BSD进程的数据结构。

第九章 内核引导和内核崩溃

1.苹果只开源了针对OS X编译的XNU版本,iOS的版本则是闭源的。

2.和Linux内核类似,Linux可以针对特定架构编译,Mach也能。

3.如果要在一堆源码文件中查找某个特定的函数名、变量名或其他符号,grep是一个不错的工具,grep可以接受任何正则表达式,并且在.h和.c文件中寻找匹配。

4.vstart是i386/x64架构下的“官方”的内核初始化函数,标志着从汇编代码到C语言代码的转换。

5.除了虚拟内存之外,kernel_bootstrap还初始化Mach的一些关键抽象:

IPC——进程间通信是Mach构建的根基,IPC要求一些重要的资源,例如内存、同步对象和Mach接口生成器;

时钟clock——通过时钟抽象实现闹铃;

账本——账本是Mach系统的记账工具;

任务task——任务是Mach的容器,类似BSD的进程;

线程thread——线程是实际的执行单元。任务只不过是一个资源容器,真正被调度和执行的是线程。

6.关于异常处理

第十章 Mach原语:一切以消息为媒介

1.XNU的核心是苹果从NeXTSTEP带来的Mach微内核。尽管Mach核心被BSD层包装起来了,而且主要的内核接口是标准的POSIX系统调用,但是这个Mach核心具有一组独特的API和原语。

消息传递原语:讨论消息和端口,这是Mach IPC的基础。

同步原语:锁和信号量是两种内核对象,这些对象用于确保并发执行的安全。

2.Mach采用的是极简主义的概念。Mach和其他操作系统不同,其他操作系统提供了用户态进程实现所基于的完整模型,而Mach只提供了一个极简的模型,操作系统本身可以在这个模型的基础上实现。

在Mach中,所有的东西都是通过自己的对象实现的。进程(在Mach中称为任务)、线程和虚拟内存都是对象,所有对象都有自己的属性。

Mach的独特之处在于选择了通过消息传递的方式实现对象和对象之间的通信。Mach对象不能直接调用另一个对象,而是必须传递消息。源对象发送一条消息,然后这条消息被加入到目标对象的队列中等待处理。类似的,消息处理中可能会产生一个应答,这个应答通过另一条消息被发送回源对象。消息是以FIFO的方式可靠传输的(如果消息被发送出去,那么一定能被收到)。

3.Mach的首要设计目标也是最重要的目标就是要将所有功能移出内核,并且放在用户态中,将内核保持在极简的状态。

4.Mach的设计有一个非常强大的优点——在设计中考虑了多处理。从理论上说,Mach可以轻松扩展成计算机集群使用的操作系统。

5.Mach中最基本的概念是消息,消息在两个端点或端口之间传递。任何两个端口之间都可以传递消息——不论是同一台机器上的端口还是远程主机的端口。Mach消息的设计考虑了参数串行化、对齐、填充和字节顺序的问题,这些问题都被消息实现隐藏了。

6.Mach消息的发送和接收都是通过同一个API函数mach_msg()进行的,这个函数在用户态和内核态都有实现。

7.Mach消息原本是为真正的微内核架构而设计的,也就是说,mach_msg()函数必须在发送者和接受者之间复制消息所在的内存。但是XNU通过单一内核的方式来“作弊”:所有的内核组件都共享同一个地址空间,因此消息传递时只需要传递消息的指针就可以了,从而省去了昂贵的内存复制操作。

8.为了实现消息的发送和接收,mach_msg()函数调用了一个Mach陷阱(trap)。Mach陷阱就是Mach中跟系统调用等同的概念,在用户态调用mach_msg_trap()会引发陷阱机制,切换到内核态,在内核态中,内核实现的mach_msg()会完成实际的工作。

9.消息在端口之间传递。消息从某个端口发送到另一个端口。每个端口都可以接收来自任意发送者的消息,但是只能有一个指定接收者。向一个端口发送消息时实际上是将消息放在一个队列中,直到消息能被接收者处理。

10.所有的Mach原生对象都是通过对对应的端口访问的。

11.端口和权限也可以从一个实体传递到另一个实体。实际上,通过复杂消息将端口从一个任务传递到另一个任务并不罕见。这是IPC设计中的一个非常强大的特性,有一点类似于主流UNIX的domain socket,允许在进程之间传递文件描述符。

12.Mach的消息传递模型是远程过程调用(RPC)的一种实现。

13.IPC所需要的基本原语:消息、发送和接收消息的端口,以及确保安全并发的信号量和锁。

14.每一个Mach任务(Mach任务是一个对应于进程的高层次抽象)包含一个指针指向自己的IPC名称空间,在名称空间中保存了自己的端口。此外,任务也可以获得系统范围内的端口。

15.同步机制的根本是排他访问的能力:当别人在使用一个资源时,排除其他人对这个资源的访问。因此最基本的同步原语是互斥对象,也称为互斥体。互斥体只不过是内核内存中的普通变量,通常是机器字大小的证书,但是有一个特殊要求——硬件必须对这些变量进行原子操作。“原子”的意思就是说,对互斥体的操作决不允许被打断——即使是硬件中断也不能打断。在SMP系统上,对物理互斥还有一个要求,就是要求硬件实现某种内存屏障。

16.Mach的锁依赖两个层次组合而成:硬件相关层——依赖于硬件的特殊性质,并且通过特定的汇编指令实现原子性和互斥性;硬件无关层——通过统一的API包装硬件特定的调用,这些API使得Mach之上的层完全不用关心实现的细节。

17.互斥体有一个最大的缺点,就是一次只能有一个线程持有锁。读写锁就是这个问题的解决方案,读写锁能够区分读访问和写访问,多个读者可以同时持有锁,而一次只能有一个写者。当一个写者持有锁时,所有其他线程都被阻塞。 ——pthread_rwlock_t就是POSIX层的API在iOS下的读写锁,区分pthread_rwlock_rdlock读和pthread_rwlock_wrlock写的加锁,解锁统一用pthread_rwlock_unlock。

18.阻塞一个线程意味着放弃线程的时间片,把处理器让给调度器认为下一个要执行的线程。当锁可用时,调度器会得到通知,然后根据自己的判断将线程从等待队列中取出并重新调度。

19.Mach提供了信号量,信号量是泛化的互斥体。互斥体的值只能是0和1,而信号量的取值可以达到某个正数,即允许并发持有信号量的持有者的个数。信号量可以在用户态使用,而互斥体只能在内核态使用。

20.在XNU上,POSIX信号量的底层实现是通过Mach信号量实现的。

21.信号量可以转换为端口,也可以由端口转换而来。

22.锁集就是锁的数组,通过给定的锁ID可以访问锁。锁也可以传递给其他线程。交出一个锁会阻塞交出锁的线程,并唤醒接受锁的线程。

23.锁集的有趣之处在于允许锁的传递。锁的传递指的是将锁从一个任务传递给另一个任务的过程。Mach在调度中也使用了传递的概念,允许一个线程放弃处理器但是指定哪一个线程接替运行。

24.Mach提供了一组异常丰富的API调用用于查询机器信息,所有这些调用都要求获得主机端口才能工作。

主机API最重要的一个方面就是能提供其他方式几乎无法获得的信息。Mach API是获得内核模块信息,内存映射表信息以及其他POSIX(BSD层)无法获得的信息的最直接方法。

25.所有的用户都可以通过mach_host_self()获得主机端口,但是只有特权用户才能通过调用host_get_host_priv_port()获得特权端口。

26.host_set_exception_ports()获得/设置或交换主机层次的异常处理程序。

27.一个或多个processor_t对象可以分组为处理器集,或称为pset。pset中的处理器通过两个队列进行维护:一个是active_queue,保存当前正在执行线程的处理器;另一个是idle_queue,用于保存当前空闲的处理器。

第十一章 Mach调度

1.和所有现代的操作系统一样,内核调度的对象是线程,而不是进程。Mach使用了比进程更轻量级的概念:任务task。

2.线程定义了Mach中最小的执行单元。一个或多个线程包含在一个任务中。

3.Mach将任务定义为线程的容器,因此资源是在任务这个层次处理的。线程只能(通过端口)访问包含这个线程的任务中分配的资源和内存。

4.任务task是一种容器对象,虚拟内存空间和其他资源都是通过这个容器对象管理的。这些资源包括设备和其他句柄。资源进一步被抽象为端口。因此资源的共享实际上相当于允许对对应端口进行访问。

5.严格的说,Mach的任务并不是其他操作系统中所谓的进程,因为Mach作为一个微内核的操作系统,并没有提供“进程”的逻辑,而只提供了最基本的实现。不过在BSD的模型中,这两个概念有1:1的简单映射,每一个BSD进程(也就是OS X进程)都在底层关联了一个Mach任务对象。实现这种映射的防范是指定一个透明的指针bsd_info,Mach对bsd_info完全无知。

6.任务是没有生命的,任务存在的目的就是要成为一个或多个线程的容器。大部分针对任务的操作实际上就是遍历给定任务中的所有线程,并对这些线程进行对应的线程操作。

7.在任何时刻,内核都必须能够蝴蝶当前任务和当前线程的句柄。内核分别通过current_task()和current_thread()函数完成这两个任务。

8.thread_suspend/thread_resume表示挂起/恢复线程,会递增/递减挂起计数器。线程只有在其suspend计数器和所在的任务的suspend计数器都为0时才能执行。

9.当调用pthread_create()时,底层会转而调用Mach的API调用thread_create(),并且使用mach_task_self()作为第一个参数。

10.由于每一个处理器核心在同一时刻只能运行一个线程,所以内核必须具有抢占一个线程的执行,将处理器让给另一个线程的能力,从而实现上下文切换。

11.由于Mach具有处理器集的抽象,所以从某种角度说,Mach比Linux和Windows更擅长管理多核处理器:Mach可以将同一个CPU的多个核心放在同一个pset中管理,并且通过不同的pset管理不同的CPU。

12.上下文切换是暂停某个线程的执行,并且将其寄存器状态记录在某个预定义的内存位置中。当一个线程被抢占时,CPU寄存器中会加载另一个线程保存的线程状态,从而恢复那个线程的执行。

13.一个线程在CPU上可以执行任意长时间。执行指的是这样一个事实:CPU寄存器中填满了线程的状态,因此CPU执行该线程函数的代码。这个执行过程一直持续,直到①线程终止;②线程自愿放弃CPU;③外部中断打断了线程执行(时间片用完或更高优先级的线程被唤醒)。

14.每一个操作系统都提供了一个优先级的范围:Windows有32个优先级,Linux有140个优先级,而Mach有128个优先级。

内核线程的最低优先级为80,比用户态线程的优先级要高,可以保证内核以及系统维护管理的线程能够抢占用户态的线程。

通过 ps -l 命令可以查看优先级

15.Mach会针对每一个线程的CPU利用率和整体系统负载动态吊证每一个线程的优先级。因此线程会在自己的优先级范围中“漂移”,如果耗CPU太多则降低优先级,如果不能得到足够的CPU资源则提升优先级。

16.在使用多核、SMP或超线程的现代架构中,还可以设置某个线程和一个或多个指定CPU的亲缘性。这种亲缘性对于线程和系统来说都是有好处的,因为当线程回到同一个CPU上执行时,线程的数据可能还留在CPU的缓存中,从而提升性能。用Mach的说法,线程对CPU的亲缘性的意思就是绑定。

17.Mach含有的特殊特性:

①控制权转交(handoff)允许一个线程主动放弃CPU,但不是将CPU放弃给任何其他线程,而是降CPU转交给自己选择的某个特定的线程。

②使用续体可以使线程不用管理自己的栈,线程可以丢弃自己的栈,系统恢复线程执行时不需要恢复线程的栈。

③异步软件陷阱AST是软件对底层硬件陷阱机制的补充完善。通过使用AST,内核可以响应需要得到关注的带外(out-of-band)事件,例如调度事件。

18.Mach对yield做了改进,允许选择将CPU转交给谁。控制权转交并不是对调度器的强制要求,调度器还可以选择将控制权转交给其他线程(例如,如果指定的线程处于不可运行的状态)。作为控制权转交的结果,当前线程剩下的时间片也会被转交给新调度的线程。

19.如果线程要进行控制权转交而不是简单的yield操作,那么需要调用thread_switch(),Mach将thread_switch()导出为一个陷阱,因此也可以从用户态调用这个函数。

20.上下文切换采用的是每一个线程都有自己独有栈的模型,当线程自愿请求一次上下文切换时可以选择指定一个续体。如果指定了续体,那么当线程恢复执行时,系统会以续体作为入口点重新加载线程,并创建新的栈,之前的状态都不会得到保留。这样可以明显加快上下文切换的速度,因为不需要保存和加载寄存器(此外还能显著地节省内核栈的空间,内核栈本身很小,只有4个页面,即16kb)。续体中的线程只需要4-5kb的空间来保存线程状态,将16kb中的其他空间节省下来用作其他用途。使用续体不需要保存完整的寄存器状态和线程栈,只需要保存续体以及参数。

21.续体是缓解上下文切换开销的简单有效的机制,主要由Mach的内核线程使用。内核线程特别喜欢使用续体,Mach的内核线程是通过续体启动的。

22.Mach支持两种不同模式的抢占——显示抢占和隐式抢占,续体模式只能用于显示抢占。

23.系统中的线程可能会被两种方式抢占:一种是显示的抢占,即线程放弃CPU的控制权或进入阻塞的操作;另一种是隐式的抢占,这种抢占是由中断引起的。显示抢占有时候也被认为是同步的,因为这种抢占是事先可以预知的。而由于中断不可预测的本质,所以隐式的抢占是异步的。

24.发生显示抢占的原因可能是等待某个资源,等待某个IO,或睡眠一定的时间。当用户态的线程调用阻塞的系统调用(例如read(),select()和sleep())时会发生显示抢占。

25.thread_invoke()函数负责执行上下文切换并负责处理续体。

26.显示抢占本身是有局限性的,将放弃CPU的选择权交给运行的线程是极为不可靠的。线程会陷入费时的处理操作中,甚至会进入死循环。

27.Mac OS X是一个抢占式的多任务系统。简单的说,Mach具有随时抢占一个线程的权利,不论这个线程是否准备好了抢占。和显示的抢占不同,这种隐式的抢占对于线程来说是不可见的。线程可以对这种抢占完全不知情,线程的状态会被透明的保存并恢复。大部分线程不会受到太大的影响,因为大部分线程都是IO密集型的。但是对于CPU密集型的线程来说,这种抢占可能会造成一些问题,特别是要求时间关键的性能时(例如视频和音频解码都是这种类型的任务)。

28.Mach是一个分时系统,而不是一个实时系统。

29.THREAD_AFFINITY_POLICY策略定义了线程的L2缓存亲缘性,这些线程共享同一块缓存。这意味着这些线程很可能运行在同一个CPU上,不论这个CPU有多少核心(毕竟同一个CPU上所有核心都共享同一块L2缓存)。

30.异步软件陷阱AST就是为了支持隐士抢占的。AST是人工引发的非硬件触发的陷阱。AST是内核操作的关键部分,而且是调度时间的底层机制,也是BSD信号的实现基础。

31.ast_taken函数(内核陷阱中和内核线程终止时也可以调用)负责处理除了内核idle线程之外的所有线程的AST。否则,这个函数会检查AST_BSD,这原本是对Mach的一个临时修改,使其能够处理BSD事件(例如信号),但是被永久的保留了。如果设置了AST_BSD,则调用bsd_ast处理信号。

32.Mach的线程调度算法高度可扩展,而且允许更换用于线程调度的算法。不过通常情况下,只启用了一个调度器,即传统调度器。调度器大量使用了AST机制。

33.对于要提供抢占式多任务的系统来说,必须有某种机制允许调度器能够首先得到CPU的控制权,从而抢占当前正在执行的线程,然后才能执行调度算法,并且通过调度算法决定当前的线程可以继续恢复执行还是要抢夺其CPU给更重要的线程使用。

34.为了能够从当前运行的线程抢夺CPU,现在的操作系统都利用了现有的硬件中断机制。由于中断的特点是强迫CPU在发生中断时“放下手中所有的任务”,并longjmp跳转到中断处理程序(也称为中断服务例程ISR)执行,因此可以通过中断机制在发生中断时运行调度器。但是问题是,中断是异步的。

35.内核可以配置时钟使其在给定数目的周期之后产生一个中断。这个中断源通常称为定时器中断。

36.解决方案是采用另一种不同的模型:无tick内核。在这种模型中,每一次定时中断发生时,定时器都会被重新设置为调度器认为需要下一次中断的时刻。这意味着在每一次定时器中断时,中断处理程序都要(非常快)扫描还没超期的截止时间线的列表。相比大量不必要的中断,每一次定时器中断中多做的这些处理工作还是值得的,而且通过只跟踪那些最紧急的截止时间线可以将这些处理工作的开销降到最低。

37.在添加非严格的定时器事件是会加上一个所谓的“宽限slop”值,通过宽限值可以合并一些定时器事件,从而增加这些定时器事件同时超时的概率(从而减少了定时器中断的总数)。

38.Mach只提供了一个异常处理机制用于处理所有类型的异常——包括用户定义的异常、平台无关的异常、以及平台特定的异常。

39.在Mach中,异常是通过内核中的基础设施——消息传递机制——处理的。异常由出错的线程或任务(通过msg_send())抛出,然后由一个处理程序(通过msg_recv())捕捉。处理程序可以处理异常,也可以清除异常,可以决定终止线程。

40.Mach的异常处理程序在不同的上下文中运行异常处理程序,出错的线程向预先指定好的异常端口发送消息,然后等待应答。每一个任务都可以注册一个异常端口,这个异常端口会对同一个任务中的所有线程起效。单个的线程还可以通过 thread_set_exception_prots 注册自己的异常端口。通常情况下,任务和线程的异常端口都是NULL,也就是说异常不会被处理。

41.发生异常时,首先尝试将异常抛给线程的异常端口,然后尝试抛给任务的异常端口,最后再抛给主机的异常端口(即主机注册的默认端口)。如果没有一个端口返回 KERN_SUCCESS,那么整个任务被终止。Mach不提供异常处理逻辑——只提供传递异常通知的框架。

42.exception_triage()负责主要的异常处理逻辑,这个逻辑在两种架构上的Mach消息层面都是一样的。这个函数尝试根据前文描述的方式——线程、任务、最终到达主机——利用exception_deliver()投递异常。

43.每一个线程或任务对象,以及主机本身,都有一个异常端口数组,这个数组中的端口通常初始化为IP_NULL。通过xxx_set_exception_ports()调用可以设置这些异常端口,其中的xxx为thread、task或host。

44.[mach]_exception_raise 用于EXCEPTION_DEFAULT,[mach]_exception_state_raise 用于EXCEPTION_STATE。[PLCrashReporter里有相关的使用] 这些函数最终通过调用ux_exception将异常转换为响应的UNIX信号,并且通过threadsignal将信号投递到出错的线程。

45.OS X的最重要的特性之一崩溃报告器(crash reporter)就是利用异常端口的机制实现的。launchd注册了异常端口,然后将所有子进程都应用同样的异常端口,因为异常端口是随着进程fork集成的。launchd将ReportCrash设置为MachExceptionHandler。通过这种方式,当一个launchd作业发生异常时,崩溃报告器会自动根据需要启动。调试器也可以利用异常端口捕捉异常并且在发生错误时中断。

46.Mach异常处理会先于UNIX异常处理发生。

47.使用mach_msg在异常端口上创建一个活动监视者。异常处理可以由同一个程序中的另一个线程来完成,不过更有意思的做法是在另一个程序中实现异常处理的部分。

48.Mach是XNU的微内核核心。XNU暴露给用户的主要接口:BSD层。BSD层使用了Mach作为底层的原语和抽象,向应用程序暴露出流行的POSIX API,使得OS X能够和很多其他的UNIX实现兼容。

常见的架构无关的Mach异常

EXC_BAD_ACCESS 内存访问异常,代码包含发生内存访问异常的地址。

EXC_BAD_INSTRUCTION 指令异常,非法或未定义的指令或操作数。

EXC_BREAKPOINT 和跟踪、断点相关的异常

EXC_SYSCALL 系统调用

EXC_CRASH 异常的进程退出

第十二章 虚拟内存

1.Mach和所有内核一样,代码中有很大一部分都在负责高效的管理虚拟内存。

2.vm_map:表示任务地址空间内的一个或多个虚拟内存区域。每一个区域都由一个独立的条目vm_map_entry表示,这些条目由一个双向链表vm_map_links维护。

vm_map_entry: 每一个vm_map_entry 都表示了虚拟内存中一块连续的区域。每一个这样的区域都可以通过指定的访问保护权限进行保护(和虚拟内存页面采用同样的权限,即r/w/x权限)。vm_map_entry 通常指向一个vm_object,但是也可以指向一个嵌套的vm_map,即子映射。

vm_object: 用于将vm_map_entry 和实际支撑的内存关联起来。这个数据结构包含一个 vm_page 的链表。

vm_page:vm_page真正表示了vm_object或部分vm_object。vm_page可以有多种状态:驻留内存、交换出、加密、干净和脏等。

3.每一个Mach任务都有一个自己的虚拟内存空间,任务的struct task 中的map字段保存的就是这个虚拟内存空间。

4.purgeable的对象在内存低的情况下可能会丢失,即直接释放,而不是提交到后备存储。

5.Mach的API比POSIX提供的等同API要强大得多,主要是因为Mach API允许一个任务入侵到另一个任务的地址空间。为了访问其他任务的地址空间,要求有相应的权限(具体来说就是任务的端口)。除了这点要求之外,这些调用几乎是无所不能的。事实上,在OS X中很多进程入侵和线程注入技术都依赖这些Mach调用,而不是依赖BSD提供的调用。

6.vmmap()的例子可以很容易扩展得更具有入侵性,比如可以将进程内存映射导出到磁盘,甚至可以写入内存映射。

7.pmap可以嵌套(即包含其他pmap)。这是一个非常常见的技术,共享内存严重依赖这项技术——包括隐式的共享内存(共享库)和显式的共享内存(mmap())。

8.Mach Zone的概念相当于Linux的内存缓存和Windows的Pool。Zone是一种内存区域,用于快速分配和释放频繁使用的固定大小的对象。Zone的API是内核内部使用的,在用户态不可使用。内核中的Zone和malloc的zone完全不同,后者是C运行时库的一部分,在用户态使用,而且具有很好的文档。

BSD内核zone 直接构建与Mach的zone之上。

9.如果系统内存不足,zone可能会进行垃圾回收。垃圾回收是一个两趟的过程,系统首先扫描所有的zone(跳过标记为不可回收的zone),检查这些zone的空闲列表,判断哪些对象是可以回收的。在第二趟中,将这些对象转换为页面:和非空闲对象共享了一个页面的对象不能被释放,只有页面全部空闲的对象才能被释放。最后,当判定好了可以释放的页面之后,通过kmem_free()释放。

10.所有的内核分配(除了连续物理内存的分配)的路径最终都会到达一个函数,那就是kernel_memory_allocate()。

11.进程的内存需求早晚会超过可用的RAM,系统必须有一种方法能将不活动的页面备份起来并且从RAM中删除,腾出更多的RAM给活动的页面使用,至少暂时能够满足活动页面的需求。在其他的操作系统中,这个工作是由专门的内核线程完成的。例如,Linux中的pdflush和wswapd内核线程。在Mach中,这些专用的任务成为分页器,分页器可以是内核线程,甚至可以是外部的用户态服务程序。

这里提到的分页器及基金实现了各自负责的内存对象的分页操作。这些分页器不会控制系统的分页侧路。分页策略是由vm_pageour守护线程负责的,而vm_pageout守护线程是kernel_bootstrap_thread()完成所有任务之后最后变成的。

12.Mach通过Universal Page List(统一页列表)这个数据结构来维护页的信息,这个列表和分页器的具体实现无关。UPL是连接虚拟地址和实际的物理页面的纽带,有一点类似于Windows的Memory Descriptor List和IOKit的IOMemoryDescriptor。

13.Vnode分页器负责支持文件的内存映射。当内存映射了文件,这些文件的内容需要从文件系统中读取。当内存映射的文件在内存中“脏”了,那么这些脏的页面需要写回文件系统。解密的页面永远不会被标记为脏,因此永远都不会被换出到磁盘上(如果可以从交换文件中提取到明文,那么整个加密就没有意义了)。

14.通过DYLD_INSERT_LIBRARIES强制注入一个库,然后直接从任务中读取内存。这也是为什么尽管App Store的二进制文件被加密,但是iOS应用程序的破解依然很繁荣的原因。

15.pageout守护程序其实不是一个真正的守护程序,而是一个线程。vm_pageout永不返回。初始化之后会派生出两个线程:外部的iothread,和一个垃圾回收线程(其实还有第三个线程,内部的iothread,是默认分页器注册时创建的)。设置完成后,vm_pageout()最终调用vm_pageout_continue(),这个函数周期性的唤醒并执行vm_pageout_scan()。

16.BSD层的Jetsam机制类似于Linux的Low Memory Killer。

17.在iOS中,物理内存非常紧缺而且没有交换空间,这个宏调用了vm_check_memorystatus(),而后者负责唤醒kernel_memorystatus线程,这属于Jetsam机制的一部分。

18.vm_fault()函数调用vm_page_fault()处理实际发生错误(缺页中断)的页面,并且从后备存储中将这个页面取回。实现方法是:查找vm_page对应的vm_object,然后从中获得分页器的端口,分页器的data_request函数负责从后备存储中读入要换入的页面。如果需要的话,换入操作还会对页面进行解密(如果页面在加密的交换文件中),并且验证代码签名。

19.非法访问:访问一个没有映射到进程地址空间(即任务的vm_map)的地址。解引用一个野指针时通常会发生这种错误。发生这种错误时进程会收到SIGSEGV信号。

20.页面保护错误:访问一个映射的地址,但是页面的保护掩码拒绝请求的访问。通常引发这个错误的原因包括跳转到数据段或试图写入(或读取)一个不允许写入(或不允许读取)的页面。发生这种错误时进程会收到SIGBUS信号。

21.dynamic_pager()是一个用户态的守护程序,负责维护系统交换文件,默认情况下交换文件在/private/var/vm/swapfile目录下。内核的default_pager分页器需要在用户态的干预下调整或修改交换条件时,从内核态调用这个守护程序。

第十三章 BSD层

1.Mach只是一个微内核。尽管Mach的部分应用程序编程接口(API)也暴露给了用户态,但是开发者主要使用的还是更为流行的POSIX API,而这一套API是通过Mach之上的BSD层实现的。

2.ptrace()属于进程控制相关的调用。

3.在Mach提供的这些原语之上还需要建立一个层次提供像文件、设备、用户和组等重要从抽象。Mach最早选择的这个层次就是BSD,而且延续在XNU中了。

4.BSD采用了两个原语,并且组织成立UNIX世界上著名的进程和线程的概念。

5.BSD的进程可以唯一的映射到Mach任务,但是包含的信息比Mach任务提供的基本调度和统计信息要丰富。其中最值得注意的是,BSD进程包含了文件描述符和信号处理程序的数据。进程还支持复杂的谱系,将进程和其父进程、兄弟进程和子进程连接起来。

6.进程就是容器,二进制代码的实际执行单元是线程。

7.用户态的线程始于对pthread_create的调用。这个函数做的工作并不多,因为主要工作是由bsdthread_create()系统调用完成的,bsdthread_create()只不过是对Mach线程创建的复杂包装。真正的线程创建是由底层的Mach层完成的。bsdthread_create()负责的工作是设置线程栈(如果指定了自定义栈),设置(机器相关的)线程状态,以及设置自定义调度参数(如果提供了的话)等。

8.在UNIX中,进程不能被创建出来,只能通过fork()系统调用复制出来。如果fork()操作失败,fork()只会在调用的进程中返回-1。

9.子进程是父进程的完整复制,除了一下几个重要的例外:

①文件描述符,尽管数值和指向的文件都是一样,但只是原始描述符的副本。这意味着后续对这些描述符修改的调用(例如lseek()和close())只会影响创建这些描述符的进程。

②资源限制,子进程会继承资源限制,但是资源利用率都设置为0。

③子进程的内存映像看上去是子进程私有的,但是事实上子进程和父进程使用的是内存中相通的物理页面。虚拟内存的私有性是通过设置页面的写时复制标志位来保证的,因此不论是哪个进程试图写入页面时都会引发页错误,从而创建页面的副本,并且重新建立映射。

10.vfork()的进程没有对应的Mach任务和线程。只有在接下来调用了execve()之后才会创建任务和线程。事实上,除了有一个execve()跟在后面之外,vfork()没有存在的意义,因为这个系统调用最初的设计就是为了这个目的。子进程的 task_t 和 thread_t (可以分别通过mach_task_self()和mach_thread_self()获得)完全就是父进程的 task_t 和 thread_t ,vm_map也是如此,只有以后调用 execve()载入一个Mach-O镜像才能最终真正创建一个Mach任务和进程。

11.如果将一个进程比作是一个人体,那么在进程中执行的二进制程序就是这个人体的大脑。只是通过fork()新创建出来的进程也没有多大作用,除非执行镜像通过exec()替换为另一个可执行程序。因此,进程创建的核心在于二进制文件的加载和执行。

12.execsw镜像加载器。进程执行镜像的流程太长了,这里不做记录,还是直接看书吧,

13.所有的镜像加载的路径要么终止在一个错误上,要么最终完成加载Mach镜像。

14.XNU中的Mach-O加载逻辑基本上和NeXT在1988年发明这个格式时差不多。经过这么多年苹果对这个过程做了一些修改,其中主要针对代码解密部分进行修改,但是Mach-O文件格式基础基本没什么变化。

苹果将这个修改封装在 exec_mach_imgact()中了,这是Mach二进制文件注册的处理程序。这个函数首先读取Mach文件头,然后解析其架构(32位或64位)和标志位。这个函数拒绝接受Dylib和Bundle文件——这些文件是由用户态的dyld动态链接器负责的。然后再应用posix_spawn()中的参数(如果有的话)。之后,对二进制进行评估以确保满足当前架构的需求。

处理Mach-O加载的主要函数是load_machfile()。load_machfile()函数负责设置内存映射,这个映射最终会加载各种 LC_SEGMENT 命令加载的数据。

Load_machfile()的核心在于parse_machfile。这个函数负责实际解析加载命令的繁杂工作。

其中,load_code_signature 也是load_machfile()函数里众多流程中的一个步骤,也就是验证代码签名。

经过三趟扫描后,在dlp变量中有一个保存的动态链接器命令,将动态链接器加载到新的映射中,可能要根据ASLR偏移进行调整。load_dylinker()函数会递归的调用parse_machfile()。

如果load_machfile()成功返回了,exec_mach_imgact会继续完成后续的工作。具体操作如下:

①通过调用vm_map_set_user_write_limit设置ulimit-m;

②设置代码签名的标志:

​ CS_HARD:拒绝加载无效页;

​ CS_KILL:如果有任何无效页则杀掉进程;

​ CS_EXEC_*:和上面两个标志位一样,只不过来自execve()。

③设置新的进程名称等等。

需要注意的是,这里并没有强制任何事情:真正的代码签名实施是在Mach的VM页错误处理程序中,通过调用 cs_invalid_page 来强制实施策略。

15.Mach提供了丰富的跟踪机制,其中最重要的就是DTrace。另一个机制ptrace(),这个机制在OS X和iOS(故意的)上只有部分功能有效。

16.BSD和其他UNIX系统提供了一个名为ptrace()的一站式系统调用,这个调用支持进程跟踪和调试。这个系统调用对于调试和逆向工程来说非常有用,例如在Linux中,gdb,系统调用跟踪(strace)和库函数调用跟踪(ltrace)就是用了这个系统调用。

17.在Linux中,ptrace的真正实力在于能够读写其他进程的内存,而XNU的ptrace实现则忽略了这些选项。不过Mach的API也能实现类似的功能。

18.挂起一个进程相当于停止一个进程的执行无限长时间,知道这个进程被恢复。冷冻和解冻的决定权通常都在iOS的加载器SpringBoard手中。

19.Mach已经通过异常机制提供了底层的陷阱处理,而BSD则在异常机制之上构建了信号处理机制。硬件产生的信号被Mach层捕捉,然后转换为对应的UNIX信号。为了维护一个统一的机制,操作系统和用户产生的信号首先被转换为Mach异常,然后再转换为信号。

20.当一个BSD进程(也是用户态进程)被bsdinit_task()函数启动时,这个函数还调用了 ux_handler_init()函数,这个函数设置了一个名为 ux_handler 的Mach内核线程。只有在 ux_handler_init()函数返回之后,bsdinit_task() 才能够注册使用 ux_exception_port。

通过调用 host_set_exception_ports()函数,bsdinit_task() 将所有的Mach异常消息都重定向到 ux_exception_port,这个端口由 ux_handler() 线程持有。由于所有后创建的用户态进程都是PID 1的后台,所以这些进程都会自动继承这个异常端口,相当于 ux_handler() 线程要负责处理系统上 UNIX 进程产生的每一个Mach异常。

ux_handler()函数非常简单,这个函数在进入时首先设置好 ux_handler_port,然后进入一个无限的Mach消息循环。消息循环接收Mach异常消息,然后调用mach_exc_server()处理异常。

mach_exc_server会调用mach_exception_raise(),然后会被mach_catch_exception_raise()捕获,信号处理逻辑就在这里。

21.硬件产生的信号始于处理器陷阱。处理器陷阱是平台相关的。ux_exception负责将陷阱转换为信号。

如果信号不是由硬件产生的,那么这个信号来源于两个API调用:kill()或pthread_kill()。这两个函数分别向进程和线程发送信号。

第十四章 BSD的高级功能

1.虚拟内存管理是在Mach层进行的,Mach控制了分页器,并且向用户态导出了各种vm_和mach_vm_消息接口。而用户态的开发者大部分都只知道标准的POSIX调用,因此需要对这些Mach调用进行封装。类似的,BSD层也使用了自己的内存管理函数。

2.OS X和iOS实现了一个低内存情形的处理机制,成为Jetsam,或者称为Memorystatus。这个机制有点类似于Linux的“out-of-memory”杀手,最初的用途就是杀掉消耗太多内存的进程。Jetsam的名字来源于杀掉消耗内存最多的进程并且抛弃这些进程占用的内存页面的过程。

3.Memorystaus维护了两个列表:一个是快照列表,这个列表保存了系统中所有进程的状态以及消耗的内存页面数;还有一个优先级列表,保存了要杀掉的备选进程。

4.用户态也可以通过pid_suspend()和pid_resume()控制进程的休眠。

5.ASLR:Address Space Layout Randomization 内核地址空间布局随机化。

ASLR对内核代码的影响非常小:代码不再使用固定地址,而是转变为使用相对地址,相对地址是针对程序的当前位置确定的。

6.工作队列是OS X中开发的一项机制,作用是为应用程序提供多线程并扩展到多处理器支持。工作队列是GCD的基础机制。

7.overcommit位表示这个队列可以创建新的线程。通常情况下不建议使用这个策略,因为线程数多于CPU数会降低程序的运行速度。

GCD仅通过dispatch_get_global_queue调用可以接受的一个标志(DISPATCH_QUEUE_OVERCOMMIT)来支持overcommit,但是苹果的文档掩盖了这个事实,宣称这个标志位必须为0。

GCD和libdispatch在工作队列不存在或被禁用时也能工作,在这种情况下,GCD和libdispatch会退而使用线程池模型。

补充:

_dispatch_get_root_queue 会获取一个全局队列,它有两个参数,分别表示优先级和是否支持 overcommit。一共有四个优先级,LOW、DEFAULT、HIGH 和 BACKGROUND,因此共有 8 个全局队列。带有 overcommit 的队列表示每当有任务提交时,系统都会新开一个线程处理,这样就不会造成某个线程过载(overcommit)。——参考自https://bestswifter.com/deep-gcd/

阅读过 GCD 源码的同学会知道,所有默认创建的 GCD queue 都有一个优先级,但其实每个优先级对应两个 queue,比如一个是 default-priority, 那么另一个就是 default-priority-overcommit。dispatch_async 的时候,会首先将任务丢进 default-priority 队列,如果队列满了,就转而丢进 default-priority-overcommit。

在 Mac 系统里,GCD 允许 overcommit,意味着每次 dispatch_async 都会创建一个新线程,即使 over commit 了,这些过量的线程会根据优先级来竞争 CPU 资源。

而在 iOS 系统里,GCD 会控制 overcommit,如果某个优先级队列 over commit 里,那么排在后面的任务就会处于等待状态。移动设备 CPU 资源比较紧张,这种设计合乎常理。

所以如果在 iOS 里创建过多的 serial queue,那么后面提交的任务可能就会一直处于等待状态。这也是为什么我们需要严格控制 queue 的数量和层级关系,最好是 App 当中每个子系统只能分配固定数量和优先级的 queue,从而避免 thread explosion 导致的代码无法及时执行问题。

——参考自https://zhuanlan.zhihu.com/p/37463055

8.MAC:Mandatory Access Control 强制访问控制。

9.iOS的安全机制比OS X的安全机制严格得多。OS X中代码签名是可选的,而iOS会通过kill-9杀掉任何代码签名不正确的进程。

iOS中“坏警察”是由AMFL扮演的,AMFL在用户态也有一个守护程序:/usr/libexec/amfid。这个守护程序是由launchd启动的。也注册了一个主机特殊端口。

第十五/十六章 文件系统

1.ACL:Access Control List 访问控制列表。OS X允许通过chmod()设置和修改ACL。通过 ls -e 可以显示访问控制列表。 ——比如著名的chomd 777就是把文件改为可读可写可执行的状态

2.Unix要求维护3个时间戳:创建事件、修改时间和访问时间。

3.FIFO是UNIX对“具名管道”的实现。通过pipe()系统调用可以创建匿名管道,但是匿名管道不能在无关的进程间共享。

4.底层的文件系统可以是基于表的(例如FAT),也可以基于B树的(例如NTFS和HFS+)。

5.每一个操作系统都有一个自己的原生文件系统。DOS的原生文件系统是FAT,Windows的原生文件系统是NTFS,HFS+是OS X的原生文件系统。

6.内核不能执行压缩操作,而且也没有提供对外部压缩的支持:在内核层只支持解压缩。

7.HFS+使用的是UTF_16编码——双字节Unicode。HFS+也是大小写不敏感的文件系统。OS X 默认使用HFS+,iOS使用启动了大小写敏感的HFSX。

8.日志是磁盘中一块特殊的区域,用户看不见这个区域,文件系统在向磁盘提交事务之前会将事务记录在这个区域中。如果修改事务被成功提交,那么这些事务就会从日志中删除。但是如果发生了崩溃,文件系统可以快速恢复到一致的状态——要么重放日志(即提交所有记录的事务),要么回滚日志(如果包含未完成的事务)。

日志并不是解决数据丢失的灵丹妙药。然而,日志可以显著的减少系统崩溃导致文件系统无法使用的情况。

9.HFS+有一项很有意思很特别的特性是能够动态适应频繁访问的文件。HFS+为每一个文件维护一个热度值。HFS+能够在工作时进行碎片整理工作。

10.B树是一些文件系统构建的基础,例如NTFS(Windows)、Ext4(Linux)和苹果的HFS及HFS+。

11.任何文件系统最基本的概念就是用于保存和取得文件的机制。需要满足的需求包括:搜索、插入、更新和随机访问。

大部分文件系统都采用了基于树的方案。根据树形结构的设计,上述的要求都能满足,而且还很自然的提供了层次结构,这是平坦的表示结构无法提供的。

12.B数可以看成是二叉树的扩展,相似的地方在于都采用了树形结构,而不同的地方在于B树的节点可以有任意数据的子节点——定义为m——而不只是两个子节点。这种结构可以帮助限制树的深度,从log2(N)(典型的二叉树搜索时间复杂度),到最优情况的logm(N)以及最坏情况的logm/2(N)。

13.和所有的树一样,B树由节点组成,但是和其他树不一样的地方在于,B树的节点可以有具体的子类型,或称为kind。不同的节点类型可以保存不同的数据,但是所有类型的节点都来源于一个基本类型(可以看成是一个基类)。

为了遍历所有的记录,在节点尾部向头部方向依次保存了指向每一条记录的指针,其中也包含节点中包含的任何空闲空间使用的空记录的指针。

14.HFS+的B树总是有一个固定的深度。也就是说,所有的叶子节点都在同一层上。

15.当HFS+挂载时启动了日志功能,那么还会启用一个日志文件。

其他部分 网络协议栈/内核扩展模块/IOKit

1.一般的Cocoa开发者并不需要了解套接字相关的知识。因为CoreFoundation通过CFNetwork提供了封装了套接字的CFSocket和CFStream,此外还提供了一些协议的封装,例如CFFTP、CFHTTP等。尽管如此,BSD套接字是XNU中所有网络组件的核心(实际上也是所有现代操作系统的核心)。

2.模块化设计是可扩展性之母。

3.代码签名,如今已经被大多数系统采纳为标准。Windows是一个典型实例,只允许加载具有合法数字签名的驱动程序。在控制权转交给模块入口点之前,内核会验证代码签名,代码签名保存在附加的证书中。证书必须通过私钥签名,内核已知公钥,内核也可以通过一个信任链获得这样一个秘钥。

早在iBoot阶段,未经苹果签名的代码就不能被加载。

4.预链接是苹果在OS X和iOS中使用的方法。引导加载器不按照先加载内核,再以一定顺序加载kext的方式进行加载,而是加载一个kernelcache文件。——好处是加载的速度快得多,并且kernelcache可以添加签名,甚至还可以加密,一旦加载了kernelcache,就可以禁止所有kext加载,这样可以阻断代码进入iOS内核的合法通道。

5.IOKit有一个顶层的抽象基类是OSObject。

6.IOKit提供了一个工作循环(work loop)模型,有一点类似于Objective-C的runloop(或Mach的消息循环)。简而言之,工作循环是一个不断处理事件的消息处理循环。通过使用工作循环可以极大的简化并发问题,而且通常可以避免对锁的使用,锁是会影响性能的。

7.IOKit采用了NeXT的runloop模型,用户态开发者应该会想到CFRunLoop。IOKit版本的runloop成为IOWorkloop,基本思想是一样的:提供一个单线程且线程安全的机制处理所有类型的事件,如果不采用这种机制则是异步的。工作循环的访问被一个互斥体保护,因此不需要考虑可重入的问题以及线程安全的问题。不过要注意的是,不能保证工作循环确实是一个线程。也就是说,工作循环的迭代可能会运行在系统中另一个线程的上下文中。

8.上下文切换:是另一种类型的控制权转移,上下文切换指的是将当前正在执行的线程的上下文切换为另一个线程的上下文。

9.ARM和Intel处理器都在处理器层次提供了线程的支持。事实上,这也是为什么现代操作系统都不调度进程,而是调度线程的原因。

10.在现代操作系统中,实现并发的先决条件是具有能够提供安全锁定机制的能力,通过这种方式能够同步共享资源的访问。同步机制通常都依赖于硬件的支持,因此在ARM和Intel架构上的实现是不同的。Mach底层的hw_lock_lock()函数的实现时候一个很好的示例。从内核的角度看,这个函数提供的方法总是一直的:快速的自旋锁。

11.原子操作是和锁非常接近的功能。院子操作是一类保证了原子性(atomicity,几不可中断性)的操作。原子操作常作为锁操作的底层机制(因为必须以原子的方式访问锁),而且经常可以替代锁的使用(当要保护的对象为机器字大小时)。

原子不一定意味着单周期,原子的意思只是说CPU保证在访问的过程中不会被打断。

12.为了能够最优化的利用内部组件(例如ALU,FPU和加载/存储组件),现代的CPU会采用乱序的方式执行指令。然而在某些情况下,乱序执行可能会在程序中引入bug。在这种情况下,可以使用屏障(barrier)指令来确保程序执行到某个点时所有访问操作都完成了。——比如iOS的内存屏障