
以前历史课老师曾给我们推荐过《全球通史》这部著作,因为老师的教授风格很有趣,所讲述的观点和视角也比课本上的丰富得多,所以一直记得这本书。最近北京大学出版社出版了第7版新校本,在全网热推,正好看到就买了,果然是一部佳作。
本书的全名为《全球通史:从史前到21世纪》(A Global History - from Prehistory to the 21st Century),作者是L·S·斯塔夫里阿诺斯,一位在加拿大出生的希腊裔美国历史学家。这本书共分上下两册,以公元1500年划分,最早于1971年出版。
我们在学校里学习世界史的时候,虽然会提及全球各个大陆的历史,但是通常都是以中国为中心去看的。比如说1840年鸦片战争,虽然前半部分会阐述工业革命以来英国生产力的大发展,但是后续就开始以中国视角面对帝国主义列强的入侵了。反过来说,西方的史学界也经常以“西欧中心论”来看待历史。
作者写这本书时,试图以全球的视角来看待不同地区的文明直接的碰撞与交流,所以相较之下本书会更多着墨于各个地区之间的联系。当然,本作毕竟成书较早,通读下来依然有一种“西欧中心”的感觉,这主要来自于作者本身的局限性。一个人毕竟很难掌握世界上全部的语言,更罔论通读各个地区各个国家的历史资料了。所以编写全球史的人在论及自己不太熟悉的国家与地区时,通常都需要阅读第二手资料,而由此产生的谬误与浅层理解就无法避免。作者书写西欧历史时可谓神采飞扬,中东伊斯兰世界也可圈可点,但是书写中国、印度等地区时则显得较为单薄。
不过作为一个习惯了中国视角的普通读者来说,本作依然能给我带来一个新颖的角度,从中也受到了不少有趣的启发,我以为本作非常适合作入门科普阅读。
人类出现在地球上可以追溯到400万年前,但是文明的出现至今不足6000年,假如把人类历史缩放到一天,那么文明只占最后的两分钟(129秒)而已。
但是人类世界的发展曲线却在最后几千年呈现指数爆炸的变化,越靠近现代变化速度越快,所以本书对历史的断代在时间上是不均匀的。
另外,历史上的时间分界点也只是一个大概的日期,确定这样的时间点只是为了方便起见。事实上历史是过渡渐进的,所以不管把过渡时间指定为1年、10年还是100年都没有意义。
作者为上下两册选择的分界点是公元1500年。在这个过渡阶段里有一个重要的标志性事件:1492年哥伦布发现新大陆。同样,这个事件也是全球各地一系列发展的结果,而不是1492年或者1500年历史的车头突然发生90度转弯。
本书上册主要讲的是史前人类到公元1500年这段时间。
史前人类的历史有两个重要的转变:一是灵长类逐渐转变为有思维能力的真正的人类;二是人类的先祖从坐享大自然恩赐的食物采集者,转变为日益摆脱大自然束缚、掌握自己命运的食物生产者。所以简单来说史前这段数百万年的演化史最重要的是进化为人类和农业革命。
接下来是文明出现(约公元前3500年)到公元500年之前,作者从美索不达米亚的苏美尔文明一路讲到西欧因蛮族入侵导致西罗马帝国灭亡。
然后是公元500年到1500年漫长的中世纪。上册大量的篇幅在讲述欧亚大陆上各种族之间的互相影响,像是古希腊与埃及文化的交流,像是突厥人和蒙古人从东亚一路打到欧洲等等。然后作者又用两个“编”的篇幅讲述了非欧亚大陆世界和诸孤立地区的世界。
相比于我们熟悉的中国中心的历史,这种相对来说更以“西欧”为中心的历史提供了一种不同的视角。虽然作者在本书中试图以“全球史观”来看世界,但是在我读来,全球除欧洲以外各地区的篇幅和分析仍不够西欧地区来得详尽。不过我并不是史学专家,能以这样的视角看世界已经是非常不错的体验了。
如前文所述,人类世界在加速发展,1500年以后虽然只有短短数百年,但是却发生了比过去几千年加起来还多的巨变。其中对当今世界最为深刻的影响就是全球化。西方从文艺复兴时期开始兴起的技术革命、社会革命,在几百年时间里让一度落后于世界的地区,一跃成为征服全球的殖民者。
下册的第一个阶段是1500-1763年。因为伊斯兰世界的兴盛,西方与东方的传统贸易路线被切断,西欧人想要买到东方的香料只能通过威尼斯人中转。于是让威尼斯和阿拉伯商人赚得盆满钵满。现在我们不太了解“香料”这个东西为什么在当时能有这么大的诱惑力,本书也未作详尽解释,这里我们知道“香料”的利润率极高就行。麦哲伦的船队绕地球一圈,整个舰队最后只剩一艘船摇摇欲坠,但是他带回的香料依然足够支付整条舰队的全部费用。可见“香料”是只要肯冒险走一趟就能发家致富的生意。
于是1500-1763这段时间里,巨大利益的诱惑和陆上商路的阻隔促使西方人寻找新的贸易路线,此时积累的航海技术也得到一个正向循环的发展。于是有了寻找通往东方新航线的大航海时代,也即所谓的“地理大发现”时代。
要知道这一阶段西方依然不是最强的地区,不论是与世隔绝的中国还是鼎盛时期的中东伊斯兰世界,都是比西方发达的地区。直到1763-1914时期,作者作为下册的第二阶段:西方据优势地位时期,西方列强才开始征服世界。
我们上面也说过历史是渐进发展的,工业革命并不是一夜之间爆发的。长期以来主要是科学革命、工业革命和政治革命三个相互影响,相辅相成的重大转发在西方发生了,最终给了西方以不可阻挡的推动力和力量。
这段时期包括我们所熟知的英国建立日不落帝国,西方对非洲的瓜分,亚洲的殖民,跟我们息息相关的鸦片战争等等。直到1914年第一次世界大战爆发。
最后一个阶段是1914年以来,西方衰落与成功。一战二战对全球的影响无疑是巨大的,无论是战争摧毁的城市还是旧有国家制度,先破而后立,全世界在战后的废墟之上发生了天翻地覆的变化。
殖民初期盛行的白人至上论,借着达尔文主义的兴起,成为西方殖民的正当理由。但是在几百年时间里建立起来的全球霸权,却在战后短短数十年时间里土崩瓦解。诸殖民地纷纷宣告独立,一个个新兴国家建立起来,最终才有了我们现在所生活中的世界。
作者在每一编的最后都会专门写一章“历史对今天的启示”,在章节里也经常会对历史事件对今日的影响作出评述。像是黑奴在西方盛行的时期,借着达尔文的《物种起源》发展为“白人至上论”,可谓今日种族歧视之根源。其中我觉得最有意思的是技术革命与社会革命之间的脱节。
人们一般都比较容易接受技术革命,因为技术上的发展通常都能提升我们生活的幸福感。这点在互联网兴盛的当下可以说体会至深。但是在人类智力发展的同时,却又不具备掌握该智力的智慧。“环保”这个概念喊了这么多年,但是在生产力发展,经济发展的大趋势下依然没有十分有效的手段可以两者兼备。互联网技术的发展也带来了极大的效率提升,但是机器取代人类之后,人类的工作时间反而增加了。种种矛盾现象实际上无不体现出人类在社会革命上与技术革命的严重脱节。
史前时代的人类通常每周只需15到20小时采集食物,但是随着农业革命的到来,变化开始发生,直到第一次工业革命,直到二战后的高科技革命。个体的生产力在提升,但是工作时间也在剧增。1900年美国人每周平均工作60小时。以前可能听说日本有“过劳死”的案例,现在国内也时不时会有猝死的报道。虽然个人的生产力提升了,但是如果所有人都具备一样的生产力基础,那么相对生产力就没有差异。这时候竞争激烈的公司之间,只能通过堆人力堆时间来互相对抗,这个现象在国内的互联网行业尤为普遍。
在信息时代,形成技术壁垒远比过去困难得多。大航海时代葡萄牙可以通过掌握自己的航线来跟西班牙竞争,先行者们也可以通过控制重要港口来打击对手。但是在信息流通如此迅速的今天,达成技术壁垒的门槛非常高。即使是领跑世界的科技公司Google也在近来开始取消其著名的20%时间,开始削减各项公司福利。
1992年,美国劳工联合会前主席威廉姆·格林断言:“唯一的选择就是失业或休息。”结果选择就是失业。公司高管都拒绝接受缩短工时的建议,因为这样增加的劳动成本将使他们的公司受到国内外竞争对手的攻击。
所以可以说,在人类社会发展出新的正向循环的竞争机制之前,当下的社会就一定是有人会去做堆时间堆人力的竞争,而一旦有人这么做了,其他人就得跟进,最终结局就变成要嘛加班要嘛倒闭。
作者在本书中还讲述了其他意义深刻的观点与启示,比如社会的不公平,比如自我毁灭倾向。可以看到人类历史上作出的重大发展都有其二元性,既有好的方面也有坏的方面,既是破坏也是进步。所以作为生存在地球上的一个普通个体,如何去看待这个世界呢?作者的态度还是比较乐观的。
虽然我们的历史充满了矛盾,技术的进步带来了生产力的发展和“核冬天”的恐怖。但是人类之所以特别,是因为我们学会了利用自然环境来满足自身的需求——使环境来适应我们的遗传特点而不是相反。当然这种做法也引发了对自然环境的极大破坏,但是至少人类不是命运的产物,人类通过高科技已经摆脱了部分可能的灭绝威胁:绕地小行星的爆炸和早已形成的新冰川纪的袭击。
纵观历史,我们不仅要看到人类科技、社会的进步,也要看到我们在公平性,时间上的倒退。作为一个普通的个体,历史的洪流在往前奔涌,我们能做到的无非是以史为鉴,少走弯路,看清自己所处的环境。在无法改变环境之前要适应,在能够改变的时候试图做些改变。这不仅需要勇气,也需要智慧与毅力。
如前所述,编写全球史对作者有着极高的要求,他可能熟悉自己母语国家的历史,却很难精通世上所有语言。所以在讲述东亚诸国历史时,尤其是我们所熟悉的中国历史,总觉得作者的跳跃比较大,而且有些强行把中国历史切分到他所设定的断代节点中的感觉。比如说公元1500年上下,西方虽然开始了地理大发现时代,但是中国却处于闭关锁国之态,虽然1405-1433年间有过郑和下西洋事件可与地理大发现挂钩,但是总的来说并没有明显变化。所以这样的分界可能更靠近西方世界的变革多一些,如果以西方自地理大发现以来积累导致的技术革命,将对未来的全球造成统一的影响来看,那么还是合理的。只是对于与世隔绝的中国来说,硬套进这个框架里还是有点脱节。
本书自1971年出版以后,作者对其进行了多次修订,增添了不少内容,比如冷战时期的内容。作为一部时空跨度极大的史书,可能许多人的刻板印象是按照时间轴陈列事件,内容艰涩生硬。但是本书却浅显易懂,文风流畅,读起来毫不费力。而且虽然主线是从古至今,但作者时时将历史事件与今日时势结合作出点评,发人深省。
本书也需要对不同方面的内容作出取舍,所以如果想单独了解专一领域的历史就不合适了。比如基督教的诞生与发展的历史其实非常的迂回曲折,但是本书并不会过多涉及。这也带来另一个问题,假设你之前并不了解基督教,那么阅读起来在部分章节就会有点不知所云。像是基督教后来怎么分出天主教、东正教之类的,他们的教义有什么区别,教派有什么分歧之类的。这样完全没有背景的读者读起来可能会有点一头雾水。
但是总的来说,我非常欣赏这种全球史观的作品,对于作者着墨于各地区的联系这点也觉得十分有趣。再加上阅读起来非常轻松,所以是值得推荐的好书。

1973 年 Xerox PARC 第一次在 Xerox Alto 这款个人计算机上推出带有 GUI 界面的操作系统,自此让极大地降低个人计算机的使用门槛,也开启了更加丰富多彩的计算机发展。
不过作为一个码农,终端依然是平日不可或缺的生产力工具。在 macOS 上,系统自带的 Termianl.app 或者更加好用的开源的 iTerm2.app 是最受欢迎的终端应用(其他 X Windows 系统也有像 xterm 之类的优秀应用)。他们也都是一个 Cocoa App。那么一个 Cocoa App 是如何把自己变成一个能跟用户通过键盘交互,有标准输入输出的“伪终端”(Pseudoterminal)的呢?
在带有电子显示器的终端发明以前,人们真的就是在一台带键盘的打印机上,一边打字输入,一边等待计算机在纸上打印输出。所以大家写 Hello World! 的时候都是用 print("Hello World!"),因为它是真地在打印。
第一台带有显示器,支持 ANSI escape codes 的终端是 DEC 公司生产的 VT100。在这之前他们已经生产过很多种型号的电子终端,不过这台机器是最成功的。
我们知道 ls 这个命令在 Unix 系统里就是一个 binary,一般放在 /bin 或者 /usr/bin 这样的目录里,用 whereis 可以找到它在哪里。
whereis ls
ObjectiveC 在 Foundation 里提供了 NSTask 这样的高级封装,用它的接口可以非常简单地实现类似 shell command 的效果。
但是首先一个沙盒 App 的能力是有限的,其次就算是沙盒外的 App,NSTask 也不允许直接访问 /usr/bin 目录里的 binaries,直接调用要嘛无响应要嘛直接 crash。
所以我们还得迂回一下,我们不直接运行 binaries,而是利用 bash 来运行:
NSTask *task = [[NSTask alloc] init];
[task setLaunchPath:@"/bin/bash"];
[task setArguments:@[ @"-c", @"/usr/bin/killall Dock" ]];
[task launch];
但是即便如此,想要使用 NSTask 的接口来模拟终端还是非常困难的事情。所以,Termianl Apps 们是怎么实现的呢?
iTerm2 的代码是开源的,历史原因内部实现比较复杂,而且 iTerm2 支持在 Cocoa App 里直接和 python 脚本交互,相当于他提供了一套桥接的接口,可以用 python 来实现对 iTerm2 App 的自动化,类似 Hammerspoon 这类 App 的效果。所以阅读过程中我还看到一堆 client/server 的通信,有点绕。
最后我发现真正实现终端功能的地方在这里: iTermPosixTTYReplacements.c,关键函数是:
int openpty(int *amaster, int *aslave, char *name, struct termios *termp, struct winsize *winp);
这个函数的实现在 Libc 里,可以参考苹果开源页面。
openpty() 是 BSD 函数,并不在 POSIX 标准里,不过 Linux 也有把这个函数 port 过去。从应用层的角度来看,openpty() 会跟 open("/dev/ptmx") 获取一个可用的 pseudoterminal。iTerm2 的做法就是通过该函数获得一个 pseudoterminal master 和 slave 的 fd 句柄,后续用户在 UI 界面上的输入都通过这两个句柄来交互。
iTerm 在 openpty() 之后还 fork() 了一下自己,然后父进程释放所有的句柄,这样父进程处理 UI 输入,一个窗口对应一个子进程,一个子进程对应一个 pty。
为什么 Unix 要这么设计 pty 接口呢?历史原因。
早期的计算机比如 1970 年 DEC 生产的 PDP-11,他需要通过一系列的电线跟用户的终端(也就是键盘和打印机)连接到一起。这种只有键盘和打印机的终端也叫做 TTY。后来有了电子显示器之后,就得使用软件模拟一个硬件终端,也叫做"伪终端"(pseudoterminal)。
UNIX 采用的设计是加入了一个中间层,当你使用 openpty() 打开一个伪终端的时候,会给你一个 master 一个 slave 句柄。GUI 软件把键盘输入作为 master 的 input 写入,master 的 output 就会作为 slave 的 input 写入,然后再作为 output 输出。所以对于我们的 Cocoa App 应用层来说,可以简单地把 master fd 作为 writer,把 slave fd 作为 reader。
听起来好像没什么必要但是其实 slave 做了一些特殊的处理。比如 GUI 直接把键盘输入的 CTRL+C(0x03) 写入 master 句柄。这时候 slave 接收到后会把 0x03 转换成 SIGINT signal 发出。对此感兴趣的同学可以参考微软关于 ConPTY 的这篇文章。
所以 iTerm2 既是一个 Cocoa App 又是一个“终端模拟器”,你可以在这个 App 里跑任意 shell 命令。
openpty() 这种 master/slave fd 的设计还体现在 SSH 远程登录上。可以参考 macOS 的 OpenSSH 源码。客户端通过 SSH 协议连上服务端时,服务端的 sshd 进程开了一个 pty 用来跑客户端输入的命令。
另外 VSCode 也基于 Node.js 实现了一个编辑器内的 console,源码在这里。
回到我们的 Cocoa App 来,一个 NSTask 对象在被 launch() 之前我们可以当做是一个数据存储的结构体来对待。通常我们会直接调用它的 launch() 方法,然后使用 NSPipe 来读写。
这里如果要绕过上文所述的 crash 问题,我们可以改用 openpty():
NSCAssert(openpty(&masterFD, &slaveFD, NULL, NULL, NULL) == 0,
@"A pseudoterminal couldn't be opened.");
*readHandle = [[NSFileHandle alloc] initWithFileDescriptor:masterFD closeOnDealloc:YES];
*writeHandle = [[NSFileHandle alloc] initWithFileDescriptor:slaveFD closeOnDealloc:YES];
有兴趣的读者朋友不妨一试。
We love mini apps, we'd like to keep our apps to be useful while in a mini style.
In this update, we've fine-tuned details of the user interface, to make it simpler and cleaner. The weather illustrations are merged into the canvas now, it looks even better in the dark background.
There's a brand new mini mode in this version, it's a square window that only shows current weather illustration with limited information, which is a great way for keeping it on the desktop or in another monitor if you were using multiple displays.
We've added more mouse controls in this version, now you can double click the artwork to toggle between normal and mini mode. The artwork supports control-click for quick operation too. If the default conditions were not your favorite order, you can simply reorder them by drag and drop.
Unlike iOS, many users are still using macOS Majove or even El Capitan, so we've restructured the whole codebase to support that, we hope more and more people can enjoy this mini weather experience on their Mac.

大家好!这是一期怀旧节目,我和自力邀请了好朋友同时也是资深老前端,前端观察的站长——神飞,来跟我们一起漫谈前端史。
前端的发展离不开网络基础的发展,自 1990 年从万维网演变至今天的互联网,短短几十年风云变幻,波澜壮阔。我们三个都曾经是前端开发,时间或长或短,如今在客户端、交互设计以及后台的岗位上继续随着浪潮起伏。前端领域里的技术更迭瞬息万变,有吐不完的槽也有怀不完的旧。各位听友不妨戴上耳机,跟着我们,一起回到过去,重温 96169 拨号上网的时代。
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

因为最近新冠疫情的关系,很多团队已经开始尝试在家远程办公。但并非所有团队都有远程办公的经验,对我们两位主播来说也是首次尝试。
所以本期节目我们邀请到 9 位业内朋友,听听看他们这次疫情对于他们的团队的影响,以及他们远程办公的看法。
这些朋友来自国内外的创业团队,程序员,设计师,投资人,还有偏传统行业的朋友。每个人的访谈都很有意思,但是由于节目时长的关系,我们没法把所有人的录音都剪到正式节目里,所以我们把完整的采访录音也都放在 Show Notes 里面,大家可以点击链接进行收听。这里有各种不同的角度和看法,值得一听。
顺序不分先后
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

今年(2020年)的春节对全国人民来说都是非常特别的一个假期,由于"武汉肺炎"(新型冠状病毒)的爆发,全国进入警备状态,人均口罩,窝在家里不出门。同时年轻人劝自己的父母长辈戴口罩、取消家族聚餐等等举措亦成为一种新的流行。
从我个人的角度来说,不仅劝说长辈一事遇到了一些矛盾与冲突,需要学习和尝试新的沟通技巧,而且在过去的几个月时间里,我自身也遇到了不少事情需要我不断打破过去的习惯,学习新的处事方式来应对不断变化的工作与生活。
有些事情在已经掌握方法的人眼里:"这不就是件小事嘛"。但是对于没有门道或者掉进陷阱的人来说,这可能是难以逾越的高墙。如果你具备了解决问题的能力,那么能够理解高墙另一侧的人的心态就成为沟通的关键;如果你不具备解决问题的能力,那么如何提升自己的弹跳能力,或者想办法绕过高墙,就成为自我发展和成长的关键。
上一期我们跟大家分享了 Jordan Peterson 的《人生十二法则》这本书,里面讲了许多切实可行且行之有效的人生道理。但是我在文章与播客都有提及,书中并未指出如何提升自我认知的方法,有些例子也比较北美,中国读者可能比较难感同身受。所以本期节目我们给大家分享另一本关于自我发展的心理学作品,就是心理咨询师陈海贤老师的《了不起的我》。
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

今年(2020年)的春节对全国人民来说都是非常特别的一个假期,由于"武汉肺炎"(新型冠状病毒)的爆发,全国进入警备状态,人均口罩,窝在家里不出门。同时年轻人劝自己的父母长辈戴口罩、取消家族聚餐等等举措亦成为一种新的流行。
从我个人的角度来说,不仅劝说长辈一事遇到了一些矛盾与冲突,需要学习和尝试新的沟通技巧,而且在过去的几个月时间里,我自身也遇到了不少事情需要我不断打破过去的习惯,学习新的处事方式来应对不断变化的工作与生活。
有些事情在已经掌握方法的人眼里:"这不就是件小事嘛"。但是对于没有门道或者掉进陷阱的人来说,这可能是难以逾越的高墙。如果你具备了解决问题的能力,那么能够理解高墙另一侧的人的心态就成为沟通的关键;如果你不具备解决问题的能力,那么如何提升自己的弹跳能力,或者想办法绕过高墙,就成为自我发展和成长的关键。
上一期夜读我们跟大家分享了 Jordan Peterson 的《人生十二法则》这本书,里面讲了许多切实可行且行之有效的人生道理。但是我在文章与播客都有提及,书中并未指出如何提升自我认知的方法,有些例子也比较北美,中国读者可能比较难感同身受。所以本期夜读我们给大家分享另一本关于自我发展的心理学作品,就是心理咨询师陈海贤老师的《了不起的我》。
1 月 21 日,还在公司加班的我看到"丁香医生"做的"武汉肺炎"的确诊报道页面,以及许多其他关于此病的报道,深感疫情严重。当时我和朋友说起希望劝说家里的长辈戴口罩,最好是春节哪儿都不要去,但是我当时的态度是"这是不可能的"。那天我的朋友已成功劝说他的父母春节不要出去,在家过,同时告诉我他的秘诀:不停往家人群里发各种疫情的报道。
当时劝说戴口罩和春节在家的流行尚未起来,疫情报道还停留在 100 人以下确诊的时候。没有外部舆论的助力,我觉得我的劝说希望渺茫。但是那会儿我已经在阅读《了不起的我》这本书了。除了此前提到过的自我分为"情感的大象"和"理智的骑象人"以外,陈海贤老师还提到,要学会"控制的两分法",即:努力控制自己能控制的部分,不要试图去控制自己无法控制的部分。
所以我当时听朋友这么说,虽然不抱太大期望,但也开始往我的家人群里各种转发疫情相关报道,每天发一两次,同时打电话给父母,告知他们疫情还是很严重的。一开始父母的态度在意料之中:我们老家不在疫区,不用紧张,口罩什么的不要紧的。当时公司前台已开始发口罩,我每天离开办公室都要戴上口罩。于是我戴着口罩发了张自拍发到家人群里,身体力行地告诉家人要重视这件事情。
父母长辈怎么看待疫情我无法控制,他们要不要取消春节聚餐我也无法控制。我能控制的部分是多打电话,多往家人群里普及疫情的资讯。最后的结果是,今年春节的除夕、初一、初二三天的家族聚餐全部取消了。这大大出乎我的意料之外。当然这并不是我的功劳,我在其中起到的作用是微乎其微的。不过这出乎意料之外的结果却让我对本书理论的印象更深了。
在本书第一章,作者援引了心理学家罗伯特·凯根(Robert Kegan)的"心理免疫的 X 光片"方法,分析了为什么我有时候明明想要改变,却总是往相反的方向做事情。我们可以用这个 X 光片的方法来分析一下劝父母取消家族聚餐这件事情。这个方法需要你把自己的心理分为四栏分别填入:
这里当然是希望父母家人全都平安,但这是愿望不是行为,直接的行为是"取消家族聚餐"。
我们正在做哪些跟目标完全相反的行为
一开始我觉得没必要去劝说,说了也没用,所以这个行为是"没有任何劝说,或者劝说力度很小"。
这些与目标相反的行为有哪些隐含的好处或可以避免的损失
我觉得劝说没用,但是没用并没有损失,所以更深一层其实是害怕跟家人发生矛盾。怕这个劝说从"你不要过分紧张啦"之类的敷衍演变成更激烈的冲突。所以这里的好处是:避免冲突。
内心有一个重大的假设,这个假设是什么?
这里 #3 的好处是避免冲突,那么为什么会冲突呢?因为我心里有一个重大假设,这个假设就是:说了也一定不会听。那么这个假设成立吗?从今年的结果来看,这个假设完全不成立。取消初一、初二的聚餐需要我的父亲和外公去行动,他们的行动已经表明了他们的态度:他们非常理解疫情的严重性和取消聚餐的必要性。
根据三段论,大前提都是错的,那么后面不管怎么推理都是错的。所以这个例子里面我的努力仅仅是转发了微信消息,告知疫情的严重性,实际上父母长辈是否真的有了解到疫情的讯息,是否真的觉得有取消聚餐的必要这些完全不是我能控制的。但是最终结果是:家族聚餐真的取消了。
不管我的努力到底有没有作用,至少这个结果在鼓励我的情感大象,告诉我的大象这一小步走出去是有用的。只要能一小步一小步的往前走,跟自己比,总是能变成一个更加强大的自己。
2020 年我立的一个 flag 是想变成一个 Tough Guy。我以前不是,希望未来是,所以这中间就需要"改变"。改变是一件很难的事情,即使对于掌握充分理论知识的心理学家来说也很难,何况一无所知的普通人。
所以学习《了不起的我》,不能让你立刻就产生改变,但是可以帮助各位读者朋友,掌握多一点点的门道,学会多一些可以付诸实践的技巧,我觉得这已经很棒了。
"焦虑感"仿佛已经成为现代人心理健康的头号大敌,各种营销号"贩卖焦虑"收割颇丰。如果你仔细观察你身边的同事你会发现,不管是初入职场的新人还是经验丰富的老人,无论是夹心饼干的基层管理还是收入爆表的公司大佬,多数人的脑门上都常常写着"焦虑"二字。虽然多数时候工作还是要继续,生活还是要照过,但是"焦虑来袭"可以说是在繁忙都市的白领中最常见的一种消极情绪了。
我对这种情绪当然也十分熟悉。不过此前在《人生十二法则》的夜读/播客中我曾提到,遇到情绪是最好的分析自我的时刻。所以消极情绪并不可怕,是大脑给我们正常的也是必须的反馈。关键是我们遇到消极情绪时如何去应对。
"改变"是应对消极情绪最常见的做法,只是我们可能对"改变"的了解不多。我们已经知道,当现实世界不符合我们的预期时,我们就会有怨恨、愤怒、悲伤等情绪出现。有的时候我们在工作中在学习中要做一些自己并不喜欢的事情时,会觉得"我没有选择"。
但其实"每个人都有选择"。你选择了"我没有选择",这也是一种选择。
本书第一章,作者通过"每个人都有选择",打破"我没有选择"的障碍。其实选择有很多,可能性有很多,你说没有,只是因为你已经在众多可能性中选择了当下这种而已。接下来作者用"情感的大象"和"理智的骑象人"作比喻,解释了为什么我们已经下决心要改变却很难做到。
比如一个人如果想戒烟,他可能会说我从今天起不抽烟就好了。但是情感的大象是很强壮的,平时它听话的时候骑象人要它往左它就往左,但是当你要戒烟了,情感的大象平时收到抽烟的那些种种好处的反馈突然没了,它发怒了,这时候骑象人的力量是完全拉不动这头大象的。这就是为什么我们很难改变旧有经验的原因。
再加上前文所述的"心理免疫原理": 人的心理免疫系统会阻碍一切改变,无论好坏。当你想要改变的时候,骑象人想往戒烟的方向走,可大象却会努力阻止骑象人。"就像一辆车,一脚踩着刹车一脚轰油门,只能原地打转,痛苦地消耗而已。"
所以在第一章中,作者先讲述了难以改变旧有经验的原因,然后提出了"小步子原理",像文首的例子一样,不要管长辈听还是不听,先把疫情文章转了,走出一小步。然后像情感的大象感受到这一小步带来的好处,才有助于让它缓和下来,配合骑象人一起往自己心中想要的方向去慢慢修正。
但是在第一章的最后一节,作者又提出了一个打翻本章"改变"主题的观点: 改变真的有效吗?
前面的小节都在说我们为什么难以改变,我们如何能够拉动大象实现改变。但是这个前提是: 改变真的有用,我们需要改变。然而我们的真的遇到什么问题都需要改变吗?不一定,有的时候"改变"反而会成为我们逃避问题的借口。
书中举了一个例子,一个年轻人毕业三年换了五份工作,每次换工作的原因都是觉得这份工作不是自己想要的。所以看上去好像"换工作"是在积极"改变",是在寻找自己的人生意义。但是真的是这样吗?
改变有两层意义:
"有时候,改变作为应对方式本身,也需要改变,在心理学上这叫"第二序列改变"(保罗·瓦茨拉维克 Paul Watzlawick《改变:问题形成和解决的原则》)。例子中的年轻人想要改变的,是工作这个内容,但是他真正想要改变的却一直没有变的,是通过换工作来应对焦虑的这种方式。盲目寻求变化,没法安顿下来踏踏实实积累经验,这才是他真正的问题。"
其实每次有朋友提到工作上遇到了什么什么困难的时候,总会有人说那可以选择换一个公司,然后就会有人说,那换了一个公司也会有啊。接下来的话题就会变成,哪家公司有这个问题哪家公司没有了。这其实没有意义,真正需要讨论的不是哪家公司有同样的问题,而是遇到问题的这位朋友,能否通过改变自身的应对方式来解决这个问题。
如果能够解决,此时再选择什么公司,那都是自发自愿的选择,而不是为了逃避这个问题而选择。选择哪家公司和如何应对这个问题,本质上是两个平行的问题。
本书一共分为五章,每章一个话题,每个话题之间层层递进,从“开启行为的改变”到"推动思维的变化",从"发展关系中的自我"到"走出人生的瓶颈",最后"绘制人生的地图"。前面的章节可以说是为了最后能够把自己的人生故事写好而做的必要准备。
同时每一章都有多个小节,从提出问题,分析问题,到切实可行的解决方法,再到最后一小节"部分推翻"自己前面的论述。
可供具体执行的方法是自我发展类书籍一个非常重要的检验标准,缺乏具体方法的书顶多只能称为"心灵鸡汤",喝过就忘了。《人生十二法则》和《了不起的我》就都是可以付诸实践的好书。而且《了不起的我》还指出了一个非常棒的观点: 知识只是局部的真理,包括本书。
我们知道物理学的发展过程就是在不断地发现新的知识,推翻自己,重建,再推翻自己的过程。心理学和脑神经科学在不同的维度分析我们人类的大脑的运作方式,可能会得出各种看似矛盾的答案。但是我觉得,追求知识的过程就是在这样的矛盾和推翻中不断地向前螺旋滚动。2019 年我遇到也解决了许多问题,在这个过程中,有局部的真理,也有被我后来推翻的真理。从自我认知到接纳自我,自己的人生地图只有自己可以画,自己的人生故事只有自己可以写。
《了不起的我》一共 360 多页,阅读的过程中一半是学习新知识,一半是印证旧想法。但是在学习了这两本书之后,"这不就是xxx嘛"的自大想法不会再有了,毕竟"知道自己什么都不知道",还是挺难的,要学习。
自我发展需要空间,能够退后一步,给自己空间很不容易。所以不要指望看完本书就能成仙,要不然岂不是所有的和尚读完佛经就成得道高僧了。学习是一小步,改变是一小步,用一句烂俗的话讲:每天一小步,天天都有新高度。
全书的前半部分解答了我最近的一些疑惑,也提供了我可以付诸实践的方法,所以读起来感同身受,非常受用。但是到了后半部分,关于转折期与人生各个阶段的阐述,我阅读起来则比较有距离感。每个人阅读时的背景与经历不同,想必读起来自有不同的领悟。
总体来说,作为一个自我发展的心理学读物,行文平易近人,用词简单易懂,少专业术语,多实例举证,很容易为读者所接受。而书中所提出之理论,亦多辅以文献佐证,令人信服。书本末尾更带有全部引用的文献作者和书名,读者可自行查阅。
我觉得这本书更像一本工具书,适合摆在书架上,遇到问题时回头来查阅一番,寻找焦虑出口的路标,让本就焦头烂额的生活稍微平顺一点。毕竟 "shit happens, but life goes on."
2020/01/30 凌晨
于自居
想阅读"枫影夜读"栏目旧文("每周读书")的读者朋友可点此直达。
"The missing clock app in the Dock"
The Dock on the Mac desktop is a convenient place to access apps and features that you’re likely to use every day, and the dock icons look gorgeous, especially on a retina display.
Wouldn't it be great if the dock icons can show even more information?
Clock mini would be a perfect choice:
Clock mini has a simple to use a timer which will send you an alert when the timer reaches zero. You can choose different alarm sounds, or let the dock to bounce repeatedly.
All in all, it is just a simple timer, no big deal.
However, you can fire a timer by just clicking the dock icon any time without switching from different workspaces or applications, which makes Clock mini a really handy timer utility when you were busy multitasking with lots of windows on the desktop.
The timezone dashboard makes it possible for you to bring up all the clocks in different timezones around the world into one place.
You can apply different themes for different clocks in the dashboard, they look just beautiful in full screen.
We love our Mac, we love the dock, we need every icon in the dock to be beautiful, so we are super serious about the app icon, that's why we built themes library.
We are enjoying design new themes, we've even built an app helping us. However there are no scheduled plans for the updates of the themes library, good design takes time.
Please stay tuned :D

各位听众朋友大家好,时间过得真快,转眼来到 2020 年。
2019 年本播客顺利播出了 6 期节目,从 6 月份的 WWDC 开始,录制了有 7, 8 期节目,但是因为“真·技术原因(音频问题)”只播出了 6 期。希望 2020 年能有一个好的开始,今年内至少要播出 12 期节目😂。
最近我读了 Jordan Peterson 的 12 Rules for Life 这本书,中文版是 Steve 说的主播史秀雄翻译的,名为《人生十二法则》。前几天我也发了一篇博客聊这本书。
读书的时候觉得他写得特别好,于是推荐给了本节目的常驻嘉宾自力(@hzlzh)。所以本期节目就是我们俩对这本书的一个读后的讨论。秉持着本节目灌水的态度,我们以心理学门外汉的视角,非常水的讨论了一番。
如果大家想获得一个专业的分析的话可以去听译者史秀雄的播客《Steve 说》。
《人生十二法则》提供了面对残酷且艰难的人生时,我们如何积极应对的一种思路。希望 2020 年大家都能成长为足够坚强的人,在这个其实非常残酷的世界里活出自己人生的意义。
Life is Suffering.
Be a tough guy.
P.S.
我们都生活在阴沟里,但仍有人仰望星空。
We are all in the gutter, but some of us are looking at the stars.
—— 出自 奥斯卡·王尔德《温夫人的扇子》
P.P.S 12 Rules for Life
- Stand up straight with your shoulders back
- Treat yourself like someone you are responsible for helping
- Make friends with people who want the best for you
- Compare yourself to who you were yesterday, not to who someone else is today
- Do not let your children do anything that makes you dislike them
- Set your house in perfect order before you criticise the world
- Pursue what is meaningful (not what is expedient)
- Tell the truth – or, at least, don’t lie
- Assume that the person you are listening to might know something you don’t
- Be precise in your speech
- Do not bother children when they are skateboarding
- Pet a cat when you encounter one on the street
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

各位读者朋友大家好,转眼来到 2020 年,因为本人拖更成性,“每周读书”系列的“每周”早已名存实亡。所以想着 2020 年我们干脆开一个新系列好了。
其实就算按照本系列更新的巅峰时期我都很难做到真的每个礼拜输出一篇文章。我的输出流程是,一读书,二作笔记,三才是输出文章,这些都需要不少时间。2019 年我的时间分配有了比较大的变化,客观上不足以维持每周的输出。
当然阅读依然是件很棒的事情。2019 年我一共读完了 16 本书,其中也有些我觉得很有趣、很实用,想要分享给大家的书。所以既然做不到,就不再给自己设定“每周”的标题,就叫大白话“枫影读书”吧 (已更名“枫影夜读”),希望能够输出更多的分享给大家。
曾经我们在聊《后物欲时代的来临》一书时提到过人生的意义可能并不是能够被“寻找到”的,而是需要由自己来“拼凑”和“定义”。不过那本书主要讨论的还是“外部”的,而不是“自身”的。我们每个人生活在这世上,一直在探索“自我”,有的人探索得比较快,比较深,有的人可能懵懵懂懂,一辈子也不太有“自我意识”,一直被来自外部的压力、要求、规范、约束驱动着往前跑。
一个婴孩降生于世,他是空白的。成长的过程中需要不断地学习,在课堂练习加减乘除是学习,在社会被生活的车轱辘碾压过去是学习,对自我意识的探索也是学习。曾经我介绍自己的纸笔思维练习方法 FWP 时说过凡事都需要练习,不仅木匠铁匠运动员需要练习,对自我的探索,对思维方式的修正也需要练习。
我们在成长过程中有一个阶段是“大人不希望小孩子看到不该看的东西”,这不仅是现在引起社会关注的“性教育”问题,更基础的我觉得是“大人普遍没有在教授社会的混乱与黑暗”给小孩子。不仅是东方文明,西方社会也普遍希望教授给小孩一个理想的世界,(改编后的)童话故事里的结局大都是美好的,但是现实却并不如此。小孩子不在课堂里学会现实世界的真实样貌,他就得在面对社会的车轱辘时受到更大的冲击和挫折。
前阵子有一个纪录片挺火的,叫做《美国工厂》(American Factory)。我留意到镜头中那些工作在福耀玻璃美国工厂里的美国本地员工们,他们抱怨工资太低,工作时间太长的委屈与无力的表情,像极了初入社会无法接受理想与现实之间巨大落差的年轻人们。
我想这是两方面的问题造成的,一则外部世界并不如想象的那样美好,这个世界本质上是黑暗与残酷的,人生是痛苦且艰难的。在物质丰富,生活稳定的年代成长的孩子,容易产生这个世界很美好,所有的事物都应该像童话般美丽一样的错觉。比如说发达国家美国,比如说出生在摆脱了饥饿、战争等基本生存困境的今天的孩子们。
另一方面,当外部世界不如预期的时候,从我们自身的角度,我们感受到的是混乱,而我们期望的是秩序。
地球诞生至今大约有 45.4 亿年,历史上有过五次极大规模的生物大灭绝,距离我们最近的一次是白垩纪﹣古近纪灭绝事件,也就是我们熟悉的恐龙大灭绝,距今六千五百万年。而智人出现只有几十万年,人类文明有记载的也不过几千年。人类的寿命与这巨大的时间尺度比起来九牛一毛,甚至直到几十年前,全世界的人类还在因为饥饿问题而烦恼,二战距今也才 75 年。这个世界的历史几乎全部主题都是苦难,这才是世界本来的面目。我们的祖先从树上跑到草原,进化出双腿直立行走,能够活到今天是因为智人能够适应环境,能够在自然选择中占到优势生存下来。生存,才是这个世界的主要命题,既不是理想,也不是童话,更不是改变世界。
当然我这里并不是说理想与改变世界不好,这些品质依然是人类所需要的,否则人类历史就没有办法往前进步了。但是我想说的是,想要改变世界是需要付出代价的,如果一心想要用理想来应对现实,没问题,请承担你的代价,而不是一边抱怨这个世界太糟糕,一边又逃避自己应该为此而付出的代价。
说到底,这个世界的本质是痛苦,是混乱。人类通过通过改变自己,适应世界,达成某种程度的秩序。可以说没有混乱就没有秩序,没有秩序就没有历史的发展,如道家的太极,黑为混乱,白为秩序,人生的意义就是一只脚踩在混乱中,一只脚踩在秩序上,在黑白之间的曲线上弯曲前行。
而这就是我所理解的,来自多伦多大学的心理学教授 Jordan Peterson 所著的 12 Rules for Life (中文版译为《人生十二法则》)的基础,在混乱与秩序中交替前行。
人类是动物的一种,动物有情绪,人类也有情绪,我们会愤怒,会开心,会哀伤,会哭泣。在大脑的进化中,这些情绪反应是由比较古老的部分所控制的,而现代人所推崇的所谓“理性”则是由后来发展出来的部分控制。所以当我们面对突发事情的时候,我们会脸红,会愤怒,所谓眼神会出卖你。
通常大家都说要克制,要压抑自己的情绪,男儿有泪不轻弹之类云云。但其实 2019 年我学到非常有用的一句话是:
当你出现情绪的时候,就是你进行自我分析的最佳时机。
情绪并不可怕,情绪并不需要被“压抑”,情绪是因为我们遇到“不符合预期”的事情时,旧脑非常快速的反应,也是我们可以了解自己的最佳时机。只是通常我们任由自己的情绪去直接应对突发事件的时候,都得不到一个好的结果,所以从表象上看似乎我们只要压抑了自己的情绪就好了。但其实不是的,能够从容面对各种状况的人往往不是他能够克制,而是因为他拥有解决一切问题的能力,所以他能够很自信地应对这些状况。
所以每一次情绪出现的时候,我当下或者事后都会好好分析自己,看看自己是什么地方没有自信,或者什么地方没做好,没能给这个问题一个解决方案。而不是说告诉自己下一次一定要压抑住,历史的经验告诉我,这是完全没用的。
当然要做到这点,前提是必须有一定的自我意识,能够从一个剥离的角度回顾自己,这里涉及“自我意识”的训练,可以参考这本书的翻译者——"Steve说播客"的主播史秀雄——曾经介绍的“个人成长史”的练习(这个练习方法也是史秀雄在加拿大留学期间,Jordan Peterson 布置给他们的大作业)。根据我的观察,生活中还有很多人没办法从自己的驱壳中跳出来观察自己,这样他的一切情绪反应都只是本能与旧脑的应激反应,没有办法从中自省与改变,也就无法成长。所以“自我意识”是这一切的基础,我以为 Jordan Peterson 没有在这本书中提到这个前提,故在此一提。
这本书还有个副标题:An Antidote to Chaos,中文翻译为解决混乱的灵药。前面我们分析了遇到“不符合预期”的事情时我们会觉得遇到了“混乱”。这本书的副标题大概是想让读者读完之后能够掌握应对“混乱”的解决方案,从而可以自信从容地,把突发事件消于无形之中。
书里提到的十二条法则涵盖了人生的多个方面。现年 58 岁(1962 年生)的 Jordan Peterson,无论是学术上的成就还是人生阅历,完全有资格给年轻人建议。他也经常在 Quora 上面回答问题,这 12 条法则就是他给问题 "What are the most valuable things everyone should know?" 的回答。
这十二条法则如下:
单看这十二句话应该是云里雾里不知所云的,作者每一个法则都用完整的一章来解释,内容详实,极具深度,有些观点也颇为新颖,当然有些例子和观点跟美国文化相关度较高,我自己阅读的过程中有时候代入感不会很强,但是总体读下来,全书的质量还是很高的,是一本发人深省的好书。
比如第一条法则:"Stand up straight with your shoulders back",中文译为:"获胜的龙虾从不低头:笔直站立,昂首挺胸"。前半句是译者自己根据章节内容加的,后半句是直译。
这一章主要讲的是龙虾(Lobster)的故事。相比起人类的大脑,龙虾的神经系统要简单得多,所以科学家可以根据龙虾的行为和神经系统的反应,相对准确地解释二者的关系。研究这种简单的系统有助于科学家们更加复杂的系统,比如我们的大脑。
龙虾是一种生活在海床上的动物,成年的龙虾每年都会脱壳一两次,脱壳的时候就会变得很脆弱,所以它需要寻找一个合适的地方,既能有足够的食物,又能保护自己免受天敌或者其他威胁的伤害。
这样的地方当然很多龙虾都想去,那当两只龙虾遇到一起的时候,他们就有可能要互相攻击,抢夺地盘。但是如果说每一次遇到其他龙虾他们都要打一架的话,那这个伤亡的代价就太惨重了,如果实力悬殊那获胜的一方可能可以全身而退,但是如果实力相当,可能结果非死即伤,胜利的一方以后面对其他龙虾也会处于弱势。
所以龙虾群体就演化出一种能够更好地生存下去的方法,就是斗争升级机制。第一阶段,两只龙虾会互相张牙舞爪,同时用眼睛下方的喷嘴向对方喷射液体,就跟吐口水似的。如果双方大小差异很大,弱的一方可以从对方的钳子大小以及喷射出的液体的信息知道对方比自己强壮很多,然后落荒而逃。
如果这一阶段势均力敌,那就进入第二阶段:双方拼命抽打触须,钳子向下收起,一只前进,一只后退,然后轮到对方前进,另一只后退。这么来回几次,看看有没有人觉得自己不够打先逃。
如果还不逃,那就继续第三阶段:真正开打了。这一阶段有点像摔跤,双方伸出钳子要把对方掀翻,先被掀翻的一方通常会承认对手厉害,然后逃走。
但是如果都无法掀翻对方,或者说输了的那一方不服输,那么就是最后阶段,真刀真枪地,用钳子夹住对方的腿、触须、眼镜等软弱的部位,斗个你死我活。通常这种情况无论胜负,不管生死,双方都会受到很大的打击,从而在以后的战斗中很可能处于劣势,这也是龙虾们极力想要避免的局面。
在斗争当中,战败的那只龙虾,无论它之前有多厉害,接下来的时间里他都会完全没有斗志,垂头丧气,信心全无。如果说这只龙虾之前还是在某片海域里占有统治地位的大龙虾,他的大脑甚至会彻底重构,以便适应他新的“卑微”的身份,否则他无法承受从“君王”降为“草民”的打击。像是从事业巅峰被打击到谷底的人类一样,也会有类似的情感转变。
科学家就从龙虾的研究中发现了他们的神经元通信传递的化学递质——血清素和章鱼胺。血清素高、章鱼胺低的龙虾往往会变得趾高气昂,斗志满满,反之则垂头丧气,毫无战意。实际上血清素也被用于治疗人类的抑郁症。
Jordan Peterson 在 YouTube 也有一个 Channel,专门放他的 TED,公开课之类的演讲视频,龙虾故事也是他非常受欢迎的一个,所以国内也有人叫他“龙虾教授”。他讲龙虾的故事主要是想带出一个非常重要的知识点:就是统治地位并不来自于人类文化,而是根植于大脑的。
过去我们往往认为人类世界的统治阶级是文化的产物,但是通过龙虾的研究我们发现,其实在动物界这些“统治地位”的例子比比皆是,龙虾是一种,猩猩也是。说明这种等级制度基本上动物世界运行的基础,是这个世界的规则之一,只不过在人类社会,这种等级的划分往往包含了多个维度。一个学生在中国学校里,通常是以成绩论高低,当然家境、样貌、运动能力等都对一个人的综合水平有影响,但是一直以来“分数”才是中国学校里的“正统评价”。这种正统评价其实是非常单一的,所以当一个学生离开学校开始工作了之后,就会突然发现这种单一的评价标准不再管用了。
当然人们又选择了另一个大多数人都认同的单一评价标准:财富。权力、能力、职位等等往往都与财富成正比例关系,所以当财富能够正确反映一个人的综合能力水平时,倒不失为一个不错的衡量标准。可惜的是,学校里的“分数”能够大致反应一个人的智力水平,而社会上公认的“财富”却可以由很多个维度共同决定,这中间当然也包括运气。
于是你就会发现,在不同的组织里,通常会有不同的等级制度,不同的游戏规则。在规则下把游戏玩得好的人就是赢家,跟动物世界的赢家通吃一样,人类社会也是赢家通吃。金字塔顶端 1% 的人掌握的财富跟 50% 的底层人民掌握的财富一样多。
但是今天没有把规则玩好并不代表一直玩不好,而且人类世界各种大大小小的组织非常多,如果留意观察就会发现,每一个稍微有点规模的组织都在构建自己的等级制度和游戏规则。每个人都有自己擅长的事情和不擅长的事情,在一个维度上不如别人做得好并不代表自己什么都做不好。这时候如果陷入战败龙虾的状态,就应该意识到是血清素和章鱼胺在作祟。
只要你笔挺站立,昂首挺胸(Stand up straight with your shoulders back),其他龙虾看到你就会觉得,哇塞,这是一只常胜龙虾诶。从而给自己带来一个良性循环,而不是任由战败情绪作祟,反而陷入恶性循环,掉进穷人陷阱,再也出不来。
这话说起来简单,做起来很难。通常战败的人会告诉自己“我没得选择”,从而获得一种“受害者的安慰”。因为这是最简单,最不费力,最容易接受的一种战败者状态。如果你想要从战败者状态站起来,首先你得自省,得有自我意识,得知道“哦,原来我自己受到了血清素的影响”,然后你得有勇气,得有面对困难挑战极限的勇气。一只脚踩在黑色的混乱,另一只脚踏上白色的秩序,这样你的人生才能在黑白交替中拼凑出意义的地图,而不是浑浑噩噩,自己也不知道自己为什么要活在这个世界上。
这本书讲了很多内容,而且都很深刻,龙虾一章从分析龙虾的神经系统与生理基础,讲到陷入焦虑、脆弱、抑郁的原因和陷阱,再然后告诉读者朋友如何能够走出失败者模式。我觉得 Jordan Peterson 讲的东西是非常具有启发性和实践意义的,但是阅读起来需要有自我意识前提,并且有些分析相对学术,有一定门槛,当然比起学术论文还是通俗得多。
在后面的章节里 Jordan Peterson 还分析了自己如何跟自己和解,好好照顾自己,分析了如何放弃损友,分析了如何让自己不要跟别人比较,而是跟昨天的自己比较等非常具有积极人生意义的话题。
我觉得读起来获益良多。虽然在玩滑板的例子、养狗养猫的例子、以及教育小孩的例子上我没有办法感同身受,但是很多道理在生活中曾经懵懵懂懂一知半解,读书的过程就像一盏灯突然照亮了这些模糊的地方,印证了自己的想法。
我在阅读 Jordan Peterson 的这十二条法则的时候常常映照自己的生活实践来理解,往往会有额外的收获,这些收获并不一定直接来自于这本书,我对于这些法则的理解也是非常主观的个人理解。希望各位读者朋友在阅读这本书的时候,也能收获属于自己的理解。
希望所有人都能昂首挺胸,做一只获胜的龙虾。
2020.01.05/下午
于自居
我购买了本书的英文版和中文版,皆为 Kindle 版本。英文版是 Random House Canada 在 2018 年 1 月出版的,ASIN 为 B0797Y87JC。中文版为史秀雄翻译的《人生十二法则》,由浙江人民出版社于 2019 年 11 月 1 日出版。史秀雄老师在加拿大留学的时候曾经上过 Jordan Peterson 的课,我在听他的播客时曾经听他提起过这本书,所以英文版很早就买了但是一直没读。最近听史秀雄的播客才知道原来他翻译了这本书并且已经开售了,于是完整地读完了中文版,翻译质量很好,非常流畅。
英文原版并不算很难读,但是专门名词有点多,而且 Jordan Peterson 写书有点跳跃,比如第一章开头才刚讲了龙虾几句立刻就跳到鸟类关于领地之争去了。像是这句:
High serotonin/low octopamine characterizes the victor. The opposite neurochemical configuration, a high ratio of octopamine to serotonin, produces a defeated-looking, scrunched-up, inhibited, drooping, skulking sort of lobster, very likely to hang around street corners, and to vanish at the first hint of trouble.
专有名词和非常长的句子对我这种英文水平一般的读者来说确实是一种挑战。所以如果只是想了解 Jordan Peterson 的十二法则的核心,那么阅读中文版是足够的。
不过中文版对于这十二法则的翻译,基本都在前面加了半句译者对章节内容的总结,并非法则原文。除了我们上面提到的第一条法则的翻译之外,第三条是:
Make friends with people who want the best for you.
翻译为:
放弃损友:与真心希望你好的人做朋友
后面是直译,前半句则是总结。我觉得如果不太细心的读者可能会误以为原文就是整句,如果能说明一下更好。(译者曰此半句为编辑所加,我觉得很能理解😂)
另外中文版每个章节前面都会截取一小段原文和翻译,这个英文版也是没有的,比如第一条龙虾法则,中文版截取了一小段:
ATTEND CAREFULLY TO YOUR POSTURE. QUIT DROOPING AND HUNCHING AROUND. SPEAK YOUR MIND. PUT YOUR DESIRES FORWARD, AS IF YOU HAD A RIGHT TO THEM-AT LEAST THE SAME RIGHT AS OTHERS.
谨慎对待你的体态,别再低头徘徊。
说你所想,追你所求,
这是你和他人同样拥有的权利。
这是中英文的一点差异,但是总的来说中文版翻译的很好,值得推荐。
想阅读本栏目旧文("每周读书")的读者朋友可点此直达。
"A dock clock and more."
In the first public beta release of Mac OS X, there was a Clock app built right into the OS. That was the first time Apple introduced the aqua interface to the world, and the clock app was such an elegant utility app. There's no such technology like the Retina display back in 2000, but still, the clock app became my favorite one; it was so beautiful.
Years later, I started teaching myself Cocoa programming, and the Clock app was gone in upcoming updates. So I decided to make one, which was the first version of Clock mini, launched in 2014.
It was just a simple cocoa app without many features, no big deal. To my surprise, when I submitted it to Product Hunt, Clock mini got lots of upvotes, and the app store even promoted it once in "New Apps We Love Right Now".
People were loving and enjoying this little cocoa app!
The retina display was a game-changer. The high pixel density makes the Dock 4x more data-dense than before. I still remember the moment when I opened Xcode and built Clock mini on a Retina MacBook Pro. The richness of details in the clock icon just blew me away.
It was magical!
I think Mac users are enjoying using the Dock. To me, it is not just a container of shortcuts; it is alive there, telling you that the computer you are looking at is more than a tool.
So we keep working on the project, trying to make Clock mini more useful and meaningful.
Recently, we've released version 2.0, in which we've redesigned everything, from code to pixels. We've added lots of new features like a timer and time zones support, a themes library with different styles...
From a tiny little Cocoa app to a dock clock utility with a timer and time zones and a beautiful themes library, the mini clock is getting better.
We've been using Clock Mini for a while now; it has already become a part of our Macs. In the new year, we hope more and more Mac users can enjoy using Clock Mini just as we did.

前两天同事提到苹果去年发布的 A12 芯片支持 arm64e 指令集,提供了指令地址加密功能。说是虽然系统是 64 位的,但是 arm64 指令地址根本用不满,所以把高位的部分(upper bits)拿来存一个指针地址签名。
当时我就很好奇,现在 arm64 的内存指针都是 64 位的,为啥会用不满?于是我学习了一下 ARMv8.3 新增的 PAC 功能。
首先我们来看看 PAC 是啥。PAC 是 Pointer Authentication Code 的缩写,字面意思翻译就是指针验证码。在 CPU 执行指令前的时候先拿指针的高位签名跟低位的实际地址部分做一下校验,如果失败了就直接抛出异常,从而防止指令地址被篡改。
Exception Subtype: KERN_INVALID_ADDRESS at 0x0040000105394398 -> 0x0000000105394398 (possible pointer authentication failure)
为了实现这个 PAC 功能,arm64e 新增了两个指令:
PACIASP 计算 PAC 加密并加到指针地址上AUTIASP 校验加密部分,并还原指针地址并不是所有的指针都需要 PAC 保护。高通的 ARMv8.3文档给这项新技术举了个例子:
| 行为 | 没有栈保护 | 使用 PAC |
|---|---|---|
| 函数入栈(入口) | SUB sp, sp, #0x40 STP x29, x30, [sp,#0x30] ADD x29, sp, #0x30 … |
PACIASP SUB sp, sp, #0x40 STP x29, x30, [sp,#0x30] ADD x29, sp, #0x30 … |
| 函数出栈(返回) | ... LDP x29,x30,[sp,#0x30] ADD sp,sp,#0x40 RET |
... LDP x29,x30,[sp,#0x30] ADD sp,sp,#0x40 AUTIASP RET |
把函数返回地址加密,用于对抗缓冲区溢出攻击(buffer-overflow vulner-ability)。

简单介绍一下缓冲区溢出攻击,上图是一个 App 在内存时的布局(memory layout),在这个 case 中,我们只关注其中的 stack 和 heap。
heap 也就是堆,堆会往上长,stack 也就是栈,往下长。这项攻击利用的就是 stack 的缓冲区增长过程中的漏洞。

一个函数被调用的时候需要在 stack 上入栈很多东西,从内存高位开始,参数名,函数的返回地址,接下来是函数内部要执行的指令。这样当指令执行完就一个个出栈,到了函数返回地址 CPU 就知道该往哪里去了。

可以看到栈底的东西是用来控制 CPU 指令往哪里跳的,而我们代码里分配的 buffer 跟它连在一起。关键点在于 buffer 的填充方向是从低位往高位去的。如果我们先分配一小块 buffer,然后往里面写一段超出 buffer 长度的数据,我们就能直接改变栈顶的数据,比如我们的目标:return address。
雪城大学有一个教程教你怎么利用 fwrite 写一段超过 buffer 长度的数据,然后把准备好的调起 shell 的函数入口塞进去替换到原先的函数返回地址,这样 CPU 执行完写 buffer 指令后就拿到该函数地址,直接出栈打开了 /bin/bash。
我们的程序是由内核运行在用户空间的,默认没有 root 权限。但是当内核执行我们修改过的返回地址打开 /bin/bash 的时候,就是以内核权限打开的。这时候我们就获得了一个有 root 权限的 shell,接下来想干啥就可以干啥了。
有了 PAC 之后,我们编译的 App 就可以带上这个保护,遇到这种篡改过的地址就直接抛出异常。当然这个例子里的攻击很简单,操作系统早就有了多种防范手段,这里只是举一个 PAC 应用的例子。而 PAC 是在 CPU 指令层面加入的保护,理论上只是多耗了一个 CPU 周期而已,性能应该要比在软件层面的保护高得多。
PAC 介绍完了,接下来我们来看看为什么指针地址用不满,还剩一半可以直接用来存 PAC 签名?
翻了苹果的文档,高通的文档都只是轻描淡写地说利用没有用到的高位。
于是我们开脑洞想是不是一个 Mach-O 文件的 (__TEXT,__text) 段(机器码段)最大不能超过 4GB (一个 32 位指针的最大地址),又或者是整个操作系统能够跑起来的所有进程加起来不能超过 4GB 之类的。
但是其实 __text 段里的数据全都是只读的,内核随时可以换出(page out),需要的时候再换入(page in),如果忽略 vm_pressure 的话,理论上应该只要它不要超过虚拟内存大小就行(不可能有人写那么大的代码的)。最后推断其实现在的 App 根本用不了那么多的地址空间。因为用不了那么多,所以才可以利用起高位。
不过这些脑洞都没有道理,其实正确答案是: 系统虚拟内存的寻址设计根本不需要用满 64 位指针。
我们看 AARch64 Linux 的虚拟内存分级设计。一个内存页大小为 4KB,整个虚拟内存被划分为 3 级或 4 级(level),下面我们以 3 级为例。
Start End Size Use
-----------------------------------------------------------------------
0000000000000000 0000007fffffffff 512GB user
ffffff8000000000 ffffffffffffffff 512GB kernel
用户空间的地址把 63:48 位都置为 0,内核空间则都置为 1。
Translation table lookup with 4KB pages:
+--------+--------+--------+--------+--------+--------+--------+--------+ |63 56|55 48|47 40|39 32|31 24|23 16|15 8|7 0| +--------+--------+--------+--------+--------+--------+--------+--------+ | | | | | | | | | | | v | | | | | [11:0] in-page offset | | | | +-> [20:12] L3 index | | | +-----------> [29:21] L2 index | | +---------------------> [38:30] L1 index | +-------------------------------> [47:39] L0 index +-------------------------------------------------> [63] TTBR0/1
这样只需要 L1 + L2 + L3 + in-page offset 就能定位到一个虚拟内存地址。在 AARch64 Linux 的设计里,一个用户空间的内存指针其实只需要用到 0:47 一共 48 位,剩下的就都是没用到的了(是不是回想起大学时计算机课的内容了😂)。
那么 PAC 引入之后剩下的位是怎么利用的呢?参考高通的这份文档,分为两种情况:

有标记位的情况下因为高位部分可能已经被用来存储额外的指针标记了,所以只用了 48:54 一共 7 位来存储。
指针没有标记位

没有标记位的情况就往 63:56 写入 8 位,往 48:54 写入 7 位,一共用了 15 位。
Tagged pointer其实用法很多,本质上跟 PAC 的原理是一样的,都是利用了指针的剩余无效空间。比如苹果在 iOS 7 引入的 NSTaggedPointer,利用指针的剩余空间来存数据的值。比如一个 NSString 如果内容很短,就可以利用指针剩余的 bits 把内容存起来,不需要另外开辟一个内存空间。
高通的文档里如果用上了 15 位那可能剩下的空间就不够 NSTaggedPointer 发挥了,所以如果要对这类指针用 PAC 就只能用 7 位签名。当然一般这些数据应该不需要保护就是了。

因为推友问了一个问题:
@PofatTseng: 發問:要怎麼測量 symbol 在 MachO 裡佔據的大小,如果只看 __Text.__text 後 + 的偏移量準嗎?
@MapleShadow: @PofatTseng 看 Load Command 的 LC_SYMTAB 能满足你的场景吗?like
otool -l xxxx | grep -i LC_SYMTAB -B 10 -A 10
@PofatTseng: @MapleShadow 我想問的是單一個物件的相關 symbol ,比如我有一個 struct Foo {}
怎麼知道他在 MachO 裡佔去了多少空間?Foo 所有symbol 會在連續的位置上嗎
@MapleShadow: @PofatTseng 这个问题是个好问题,本来以为是一个简单的问题但是一点都不简单😂简单说通常情况下我们 App 的符号都被 strip 掉放进 dSYM 所以不占 Mach-O 空间,但是如果你是 debug 版或者动态库就会塞进去。至于长度,symbole table 的指针都是 8 字节(updated: 其实是 16 bytes),但是指向的符号 string 不是定长的,在 string table 里面取
我本以为是一个简单的问题,结果发现自己对 Mach-O 的很多细节都不太了解,于是学习了一下,以此文作为学习笔记。
如果只对上述问题的答案感兴趣的可以直接跳到末尾看结论。
P.S. 学习过程我参考了这篇文章但是因为年代有点久远,里面有些字段已经弃用了,当做字典参考就行。
我们在 macOS 系统如何启动?和 App 如何运行起来均有涉及 Mach-O 文件结构的讨论,但不全面。这里我们再详细介绍一下 Mach-O 的结构。下文使用的例子需要对比 Debug 和 Release 版,所以用我的 Mac 全屏休息提醒工具: Just Focus 为例。
首先我们来看最简单的 64 位单架构 Mach-O 文件(Fat Binary 后面再讨论),相关的数据结构定义在 XNU 源码的 EXTERNAL_HEADERS/mach-o/loader.h 里面。一个 Mach-O 文件有三个主要部分:

dyld 动态链接的符号表,标示初始函数入口,标示动态库的地址等等。segment,每个 segment 包含 0 个或多个 section。内核加载 Mach-O 时会根据 load commands 把相应的数据加载到内存里,根据 XNU 的注释,分 segment 是为了做数据对齐(segment alignment)以优化换页效率,下文分析 section 结构体时会讲到。Header 是定长的,在 64 位 Mach-O 中表现为 mach_header_64 结构体。
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
magic: 大小端兼容性之用,MH_MAGIC_64 就是编译的文件和系统是同样的 byte order,MH_CIGAM_64 则是反过来。原因是曾经兼容 PPC 和 Intel 等多种 CPU,有兴趣的同学可以阅读: macOS 内核之 OS X 系统的起源。cputype: CPU 类型定义,CPU_TYPE_POWERPC 用于 PowerPC CPU,CPU_TYPE_I386 就是 Intel 的 x86,当然还有 iPhone 的 CPU_TYPE_ARM。cpusubtype: 属于 cputype 的细分,比如 i386 全部支持 CPU_SUBTYPE_I386_ALL,或者只支持 armv7 的 CPU_SUBTYPE_ARM_V7。filetype: 文件类型,决定了这个 Mach-O 文件的布局,定义从 MH_OBJECT 0x1 到 MH_DSYM 0xa。
MH_OBJECT: 编译过程产生的中间文件,这个文件比较特殊,其他文件分了多个 segment 和 section 但是这家伙只有一个 segment,把所有的 section 都塞进去。这个中间文件可以在 DerivedData/JustFocus-xxxx/Build/Intermediates.noindex/JustFocus.build/Debug/JustFocus\ Helper.build/Objects-normal/x86_64/ 里面找到。MH_EXECUTE: 标准可执行文件MH_BUNDLE: 动态库,macOS 上跟资源文件打包为 .bundle 或 .plugin,比如 /System/Library/Audio/Plug-Ins/HAL/AirPlay.driver/Contents/MacOS/AirPlay。本质上是动态库,Unix-like 系统叫做 .so,但是在 macOS 历史上曾经有点特殊,可以参考macOS 上 bundle (.so) 和 dylib 的区别。MH_DYLIB: 动态库,比如 /System/Library/Frameworks/AppKit.framework/AppKit 就是 MH_DYLIB 类型。MH_PRELOAD: 不在内核运行的特殊文件格式,比如内核还没加载前就要执行的 Bootloader,参考 macOS 内核之系统如何启动?MH_CORE: core 文件,程序 crash 的时候保存地址空间里的数据,服务端开发的朋友应该很熟悉。不过 macOS 默认不会把 core 信息 dump 到 /cores/ 目录,而是产生 crash log 放在 /Library/Logs/DiagnosticReports。可以参考这里打开 core dump.MH_DYLINKER: 动态链接器类型,一般我们写的 App 都是用系统的 /usr/lib/dyld,这个文件就是 MH_DYLINKER 类型。MH_DSYM: 编译后的 .dSYM 包里最主要的就是用 Mach-O 文件存储的 symbol 信息,比如 Alamofire.framework.dSYM/Contents/Resources/DWARF/Alamofire 就是 MH_DSYM 类型的 Mach-O 文件。ncmds: load commands 个数sizeofcmds: load commands 总长度flags: 这里面有一堆 flags,大部分是跟编译相关的,我也没全部学明白,所以干脆不描述了,感兴趣的朋友可以看这里。reserved: 应该只用来做字节对齐了
mh64->reserved = 0; /* 8 byte alignment */
Mach-O 文件中,读完 Header 和 Load Commands 之后,就是各种 Data 数据了,这些数据是以 segment 组织的。
一个 segment 有起始和终止的 offset,该范围内的数据就是 segment 的数据。segment 的标识是 segment name,宏以 SEG_ 开头。
但是 segment 的数据没有带上起始终止之类的信息,这些信息是在 Load Commands 中定义的。比如 LC_SEGMENT_64 会定义某个 segment 从哪里开始到哪里结束,名字是什么,虚拟内存的属性(比如 read-only),有多少个 section 等等,相当于一个索引,我们要获得有意义的数据就得先解析 Load Commands 然后再去读取对应的数据。
segment 的数据会被 dyld 根据 LC 的布局信息加载到内存里,所以 segment 都是按页对齐的。在 x86 上一页是 4096 bytes 也即 4 KB。
segment 做按页对齐其实就是把它所包含的所有 section 加起来除以 4 KB,不能整除就在最后一个 section 补 0。
理论上 Mach-O 文件里的 segment 有多大,加载后就会占多少的虚拟内存。但是实际上一个 segment 有可能在加载后比它在 Mach-O 里的数据大,比如 __PGAEZERO 这个 segment。在 Mach-O 里它其实是空的,只在 Load Command 记录了一个索引信息,但是加载到内存的时候,内核会给我们的 App 的地址开始端 0x0 分配一个空的页(到 0x1000)。这个空的内存页不带内存保护(声明为 VM_PROT_NONE),不可读写不可执行,我们平时遇到的访问野指针(NULL)就会命中这个区域,然后内核就让我们的 App crash 了。
上面 header 提到过 .o 文件比较特别,他是编译过程的中间文件(intermediate object file),出于文件大小的考虑,他的所有 sections 全部放在一个 segment 里面,并且这个 segment 没有名字。

segment 用名字区分,定义了这么多种:
#define SEG_PAGEZERO "__PAGEZERO" /* the pagezero segment which has no */
/* protections and catches NULL */
/* references for MH_EXECUTE files */
#define SEG_TEXT "__TEXT" /* the tradition UNIX text segment */
#define SEG_DATA "__DATA" /* the tradition UNIX data segment */
#define SEG_OBJC "__OBJC" /* objective-C runtime segment */
#define SEG_ICON "__ICON" /* the icon segment */
#define SEG_LINKEDIT "__LINKEDIT" /* the segment containing all structs */
#define SEG_IMPORT "__IMPORT" /* the segment for the self (dyld) */
/* modifing code stubs that has read, */
/* write and execute permissions */
#define SEG_UNIXSTACK "__UNIXSTACK" /* the unix stack segment */
有些是历史遗留产物,对我们来说有用的字段是这些:
__PAGEZERO 的作用讲过了不再赘述,这个东西是由静态链接器生成的。__TEXT 包含了所有的可执行代码,内存保护设置为 VM_PROT_READ 和 VM_PROT_EXECUTE。因为这一整段都是只读的,所以内核可以在内存不够的时候把这些数据换出(page out),需要的时候再换回来(page in)。__DATA 可写的数据,比如 ObjC runtime 支持的库。像这样的系统库有可能被多个进程链接,因为这一段内存可写,所以写操作会触发 copy-on-write,以此实现逻辑上每个进程有一份 copy (不一定真的要 copy)。__LINKEDIT 动态链接器需要用到的数据,比如 symbol table, string table 之类的下面这些是历史:
__OBJC Objective-C 的 runtime 支持,历史遗留字段,现在都放进 __DATA 里面了__ICON 应该是历史遗留产物,现在图标资源已经分离出去了,我们的 App 一般打包成 .app 文件夹。__IMPORT i386(IA-32) 也就是 32 位 x86 架构才会用到的一个字段,64 位改用 __DATA,__la_symbol_ptr 了。__UNIXSTACK 应该也是历史产物,参考这里。
__TEXT 和 __DATA 一般会包含多个 sections,这些 sections 的命名和用途也会随着系统和编译器更新而变化,想要了解全部 section 及其作用的可以参考 LLVM 项目。这里我们看几个关键 section。
| Segment, Section | 作用 |
|---|---|
| __TEXT,__text | 可执行的机器码 |
| __TEXT,__cstring | 常量定义的 C strings,以 '\0' 结尾。编译器编译时会把所有 C String 合并优化,放在这个地方。 |
| __TEXT,__const | 初始化过的常量。编译器会把所有无需重定向的以 const 声明的常量放在这类。(多数编译器都把未初始化过的常量默认赋值为 0。) |
| __TEXT,__objc_ 开头的 | 以前放在 __OBJC 里 runtime 的支持,现在都放这里了。 |
| __TEXT,__stubs 和 __TEXT,__stub_helper | 动态链接需要用到的信息 |
想要理解完所有 __TEXT 里的 sections,你得学习 llvm 的源码。并且这些字段也经常随着系统和编译器的更新二更新,所以我选择放弃。真的需要的时候再回过来反查就行。在这一个 segment 里最重要的就是 __TEXT,__text,可执行的机器码放在这里。
| Segment, Section | 作用 |
|---|---|
| __DATA,__data | 初始化过的变量,比如一个可变的 C string 或者一个数组 |
| __DATA,__la_symbol_prt | Imported 函数的指针表,比如 libswiftFoundation.dylib 这样的动态库的符号的指针地址 |
| __DATA,__bss | 未初始化的静态变量 |
load command 的定义很简单:
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
cmd 就是 LC_ 开头定义的宏,非常多,我们只看关键的,全量的请参考 loader.h 里的定义。
| Command | 结构体 | 作用 |
|---|---|---|
| LC_UUID | uuid_command | 编译出来的 image/dSYM 的 UUID,用于两者互相关联 |
| LC_SEGMENT_64 | segment_command_64 | 定义 segment |
| LC_SYMTAB | symtab_command | 定义 symbol table |
| LC_DYSYMTAB | dysymtab_command | 定义动态链接库需要用到的 symbol table |
| LC_UNIXTHREAD | thread_command | 程序的入口。现在大部分 App 都用 dyld 调起了,内核的 Mach-O 和 dyld 则还是用 LC_UNIXTHREAD 声明入口 |
| LC_MAIN | entry_point_command | 程序的入口,需要配合 LC_LOAD_LINKER 使用,把该地址交给 dyld 然后由它来调起 App 的入口函数 |
| LC_LOAD_LINKER | dylinker_command | 声明用到的 dy linker, iOS/Mac 一般都是 /usr/lib/dyld |
| LC_LOAD_DYLIB | dylib_command | 该 Mach-O 需要用到的动态库 |
通过 Load Command 获取了 segment 的 offset 和 size 之后就可以读取为 segment_command_64 和 section_64 结构体了。
struct segment_command_64 { /* for 64-bit architectures */ uint32_t cmd; /* LC_SEGMENT_64 */ uint32_t cmdsize; /* includes sizeof section_64 structs */ char segname[16]; /* segment name */ uint64_t vmaddr; /* memory address of this segment */ uint64_t vmsize; /* memory size of this segment */ uint64_t fileoff; /* file offset of this segment */ uint64_t filesize; /* amount to map from the file */ vm_prot_t maxprot; /* maximum VM protection */ vm_prot_t initprot; /* initial VM protection */ uint32_t nsects; /* number of sections in segment */ uint32_t flags; /* flags */ };
struct section_64 { /* for 64-bit architectures / char sectname[16]; / name of this section / char segname[16]; / segment this section goes in / uint64_t addr; / memory address of this section / uint64_t size; / size in bytes of this section / uint32_t offset; / file offset of this section / uint32_t align; / section alignment (power of 2) / uint32_t reloff; / file offset of relocation entries / uint32_t nreloc; / number of relocation entries / uint32_t flags; / flags (section type and attributes)/ uint32_t reserved1; / reserved (for offset or index) / uint32_t reserved2; / reserved (for count or sizeof) / uint32_t reserved3; / reserved */ };
其中比较特殊的是,最后一个 segment 也就是 __LINKEDIT 存储 link edit information,里面有 symbole table, string table, dynamic symbol table, code signature 等信息。
但是他的 LC_SEGMENT_64 里面却没有包含里面的 sections 信息,你需要配合 LC_SYMTAB 来解析 symbol table 和 string table。
// LC_SYMTAB 对应的结构体
struct symtab_command {
uint32_t cmd; /* LC_SYMTAB */
uint32_t cmdsize; /* sizeof(struct symtab_command) */
uint32_t symoff; /* symbol table offset */
uint32_t nsyms; /* number of symbol table entries */
uint32_t stroff; /* string table offset */
uint32_t strsize; /* string table size in bytes */
};
没有对 Mach-O 文件的符号进行任何处理的时候,所有符号表信息都会放在 Mach-O 文件里。
我们可以用 MachOView 直接查看 Symbol Table。

这是 Just Focus Debug 版的符号表,但是 Xcode 在编译的时候默认会对 Release 版做一个优化: 把符号从 App 的 Mach-O 去掉,写进成对的 dSYM 文件。可以在你的 Xcode Project -> Build Settings -> Build Options -> Debug Information Format 看到各个 scheme 的配置。
DWARF 是 Executable and Linkable Format 配套的一个 Debug 数据格式。ELF 则是 Unix 的一个标准格式,多数 Unix 系统和 Linux 都采用这种格式定义可执行文件。macOS 虽然不支持 ELF 但是用了 DWARF 作为 debug 数据格式。
DWARF 生成 debug 信息并塞进 Mach-O 文件DWARF with dSYM File 生成 debug 信息并放到配套的 dSYM 文件,以 UUID 匹配,App 的Mach-O 里不带符号信息。
可以读取 LC_SYMTAB 然后在最后一个 segment 里找到 symbol table。LC_SYMTAB 数据是一个定长的 16 bytes 数据。
然后通过 symbol table 的 string table index 获取该 symbol 对应的 string,这个就不是定长的了,读到 \0 停止。所以符号的 string 越长占 Mach-O 的 size 就越大。
2019-11-16 updated: 上面的说法是你使用 MachOView 这样的工具时,可以肉眼 filter 已知的 string 所以可以这样查。但是系统执行文件的时候,拿到的是 (__TEXT,__text) 里的一个个指针地址,crash 发生的时候内核会保存当前进程的内存空间快照,crash 时的指令地址反查 symbol 就能得到我们人能阅读的 crash 堆栈。所以如果你想要通过 string 裸读 Mach-O 文件来反查对应指针地址的话,因为 string table 里的存储是连续的 bits,没有索引就无法读出 string,所以只能解出所有结果,然后自己去 filter。
无用 class/struct 会占用 Mach-O 空间吗?
如果是 C/C++ 的符号,编译链接时会知道这个 class/struct 没人用,直接优化删掉,等于没有。
如果是 ObjC 的符号,则还是会保留,因为有 runtime,你不知道它到底有没有被人用。
所以 ObjC 无用的 class/struct 在 release 下不会占用 Mach-O 的 Symbol Table/String Table 空间,但是会占用 Mach-O 的 (__TEXT,__text) 空间。
foo 的所有符号会连续吗?
不连续,link-editor 比如 dyld 可以通过读取 LC_SYMTAB, LC_DYSYMTAB 等 load command,从对应的 Symbol Table 和 Dynamic Symbol Table 找到符号。
比如 Just Focus 有一个 Swift enum JFAppState 在 Symbol Table 上它的符号并不连续。
什么符号可以从 Mach-O 去掉?
默认情况下所有符号都会保留在 Mach-O 里,这样调试的时候就能显示全部符号,但是如前所述发布版本并不需要这些符号,完全可以去掉以节省空间。Xcode 对 Strip Style 也提供了多个选项可供设置: Build Settings -> Deployment -> Strip Style

单独编译静态库是无法 Strip All Symbols 的,不然你引用这个静态库链接器就不知道该怎么链接了。但是打包成一个完成 App 的时候,静态库的符号可以被去掉。
理论上动态库的符号无法去掉,但是编译器可以根据你调用的方法进行优化,只保留用到的符号。但是 ObjC 有 runtime,应该无法确定哪些符号用到哪些没有。llvm 用到的链接器 ld 提供了 -strip-unneeded 的选项,不过我还不知道他是怎么实现的,大概要把编译原理从头学一下然后再学一遍 llvm 才知道了。
主流操作系统 Unix-like, Windows 和 macOS 虽然各有自己可执行文件的格式,但是设计上大同小异。
Mach-O 文件格式随着系统与编译器的升级加入和删除了很多古老的 segment 或者 section,而这些特性都需要编译器(llvm)与执行环境(xnu)的配合开发。
作为一个编译后的产物,Mach-O 里的字段有很多跟编译器的优化相关。这些字段如果要一个个理解清楚需要很多时间,并且需要熟悉编译原理以及 llvm 自家的特性(毕竟很多优化都是独有的)。所以没有必要细究每一个字段的作用,真的用到的时候再查就行了。
但是以鸟瞰的视角了解 Mach-O 文件的结构,对于理解一些古怪的问题还是很有帮助的。

今天和同事讨论到一个问题:
bundle和动态库一样吗?
同事说 bundle 只是包含了其他资源而已,其实就是动态库。
我看 Mach-O 文件类型里 MH_BUNDLE 与 MH_DYLIB 是分开的,所以觉得 .bundle 里面的 Mach-O 文件和 dylib 的 Mach-O 文件应该会有些不一样。不过我也不知道有什么不一样,所以学习了一下,以此文记之。
定义一下动态库为 dylib Mach-O 文件, bundle 指的是 .bundle 文件夹里面的 Mach-O 文件,一般类 Unix 系统叫做 .so 库,不过苹果官方建议叫做 .bundle。
P.S. 这里苹果官方不厚道,它推荐用 .bundle 作为 MH_BUNDLE 类型文件的后缀名但不强制,然后自己还把 .bundle 后缀名用作一个类似 .app 的资源与可执行文件打包。所以很容易就会混淆两个概念。实际上我看到的 MH_BUNDLE 类型的 Mach-O 基本上都没有后缀名,有 .bundle 后缀名的基本上都是资源与可执行文件的打包。
先说结论: 通常语境下 bundle 和 dylib 没有区别。要较真的话也只有在 OS X 10.5 以前才有比较大的区别,所以同事说 bundle 和动态库没有区别是对的。
P.S. ELF 系统(Executable and Linking Format,Unix-like 系统基本都是)上这两者完全相等,只有 Mac 的 dyld 对他们做了点区别对待。
Mach-O 文件的 header 里有一个 type 字段表示当前文件的类型,如果把 .bundle 文件夹解开,里面的 Mach-O 文件的类型是 MH_BUNDLE,而 dylib 则是 MH_DYLIB。
➜ otool -hv AppKit Mach header magic cputype cpusubtype caps filetype ncmds sizeofcmds flags MH_MAGIC_64 X86_64 ALL 0x00 DYLIB 60 8344 NOUNDEFS DYLDLINK TWOLEVEL APP_EXTENSION_SAFE
➜ AppKit.framework otool -hv /System/Library/Audio/Plug-Ins/HAL/AirPlay.driver/Contents/MacOS/AirPlay Mach header magic cputype cpusubtype caps filetype ncmds sizeofcmds flags MH_MAGIC_64 X86_64 ALL 0x00 BUNDLE 21 2544 NOUNDEFS DYLDLINK TWOLEVEL
在 macOS 上,动态加载通过 dyld 进行。bundle 和 dylib 两种文件都可以使用 dlopen 加载。两者的区别要在 dyld 的源码里面找。
dyld 的 dlopen() 实现主要关注是这几个地方:
dlopen()load()loadPhase0()loadPhase1()loadPhase2()loadPhase3()loadPhase4()loadPhase5()loadPhase6()checkandAddImage()
dylib 就从 sAllImages 找到一样路径的 image 先删掉dylib 和 bundle 能使用的 API 不一样,所以这里还得判断 context.mustBeBundle 和 isBundle()是否匹配// some API's restrict what they can load
if ( context.mustBeBundle && !image->isBundle() )
throw "not a bundle";
if ( context.mustBeDylib && !image->isDylib() )
throw "not a dylib";
bundle 就不会加到 global list,因为 bundle 可以只加载但不链接。所以结论是 bundle 可以只加载不链接,而 dylib 加载后就链接了。
NSObjectFileImage 只有 bundle 能用dyld 提供了 NSObjectFileImage 接口,这些接口只有 bundle 能用,只加载不链接就通过这个接口来实现。
NSObjectFileImageReturnCode NSCreateObjectFileImageFromFile(const char* pathName, NSObjectFileImage *objectFileImage)
里面会调用 load() 方法加载 bundle,这类接口的 context.mustBeBundle 为 true,底下判断的时候遇到非 bundle 就会报错。
load() 之后再使用以下方法链接:
NSModule NSLinkModule(NSObjectFileImage objectFileImage, const char* moduleName, uint32_t options)
NSObjectFileImage 相关的接口从 OS X 10.5 开始已经被废弃了。
在 Mac OS X 10.5 (2007 年) 以前,bundle 可以被 unload 但是 dylib 不可以,10.5 开始 dylib 也可以被 unload 了。dlclose() 的实现很简单,调用时减一下引用计数,为 0 就从走垃圾回收接口 garbageCollectImages() 删掉。
经过以上调查,现如今的 bundle 跟 dylib 在使用上几乎可以完全对等。要说区别那就只有编译 dylib 为 shared library 的时候需要加上版本号,而 bundle 只会给自己的 App 用就没有必要了。
libbz2.1.0.5.tbd
libbz2.1.0.tbd
libbz2.tbd
至于 Mach-O Header file type 的区别,只是给 dyld 作 NSObjectFileImage 接口判断而已,这些接口废弃了那自然就没有区别了。

前面的文章都在讲内核代码细节,实在有点费脑,这次我们来聊点轻松的历史故事吧。现在我们已经知道 macOS 的内核主要是由 BSD 和 Mach 组成,但是为什么是这样的混合设计呢?
Amit Singh 的 Mac OS X Internals 一书在开头就介绍了从 Apple OS X 诞生的历史,几年前刚买这本书的时候我还觉得为啥讲这么长的故事一直不进入“干货”部分。现在回过头来看,正是作者介绍了这段历史,后面内核中一些有点疑惑的地方才顺理成章。
本文主要来自 Amit Singh 书中所述,再加上我查阅的资料所写。年代久远,如有谬误,烦请诸位不吝雅正。
乔布斯(Steve Jobs)和史蒂夫·沃茲尼克(Steve Wozniak) 1976 创办苹果公司,关于这家公司的故事已经广为流传,OS X 的诞生也与乔布斯后来的回归息息相关。我们知道乔布斯离开苹果后创办了 NeXT 公司,也知道今天我们开发的 iOS/macOS 系统跟 NeXTSTEP 系统有千丝万缕的关系。但是乔布斯一回归 NeXTSTEP 就变成今天的 macOS 了吗?并不是,历史的道路是非常曲折的。

时间回到 1977 年,乔布斯在 West Coast Computer Faire 发布了 Apple II 这款个人电脑,这是苹果公司对外发售的第一款消费级个人电脑。这款产品大获成功,也让两位创始人成为百万富翁。
1984 年 1 月 22 日,苹果在超级碗(Super Bowl)中场休息时播放了一个堪称历史经典的广告——
,以此发布新产品 计算机。但是在苹果公司内部,与 Macintosh 研发的同一时期,乔布斯还带领了一个团队开发 Lisa 电脑(1983 年发布)。现在我们知道这是一个失败的产品,并且乔布斯也于 1985 年被董事会赶出了苹果,后来自己创办了 NeXT 公司。
一晃四年过去,1988 年苹果的团队在开会讨论下一代操作系统应该带上什么特性。他们在白板上用三种颜色的便利贴表示不同的 idea:
当时 Macintosh 上跑的系统版本是闭源的 System 6,1988 年 4 月发布,苹果自家的很多产品都使用这个系统。在这个阶段,苹果的图形界面操作系统依然还是处于比较领先的地位,市面上有 GUI 的操作系统还不算多,做得好的更没几个。但是苹果的下一代系统 System 7 的研发却出现了问题,一连好几年没法发布。
这时候隔壁家微软已经在 1990 年发布了 Windows 3.0 (1.0 和 2.0 市场反响都一般),借此一炮而红,成为当年最流行的图形界面操作系统。

1991 年苹果终于发布了 System 7 版本,但是这些“蓝色”的 idea 不过是对现有系统的改进,并没有特别大的突破。

而微软在 Windows 3.0 成功后,又继续在操作系统上发力。当时微软内部有一个代号为 Chicago 的项目,原计划在 1993 年发布。但是项目一直延期,最终在 1995 年才终于面世。这款产品就是广为人知的 Windows 95。除了家用系统,微软在 1993 年也发布了面向服务器的 Windows NT 系统,自带网络服务, NTFS 文件系统,支持 Win32 API。

反过来看苹果,却陷入了深深的危机。1998 年开始苹果一直在探索自家操作系统未来的方向。除了已经发布的“蓝色” System 7,“粉红色”的部分苹果与 IBM 合作,成立 Taligent 公司试图研发下一代操作系统,但是该项目一直没有产出,直到最后公司被 IBM 收购也没有对外发布过任何系统。
至于更加激进的“红色”项目,代号为 Raptor,则无疾而终。个中细节在网络上未有记录,只在《Mac OS X Internals》一书有所提及。可以说 1990 年代的苹果,正在一步步走向深渊。
面对微软的挑战,苹果做了很多操作系统的探索和尝试,内部开发与外部合作兼备。当时的 CPU 还不像今天基本只剩 Intel 和 AMD 两家(手机端基本都是 ARM),Macintosh 的机器使用的是摩托罗拉 68K 系列的 CPU,而 Windows 则使用的是 Intel 的 x86 系列 CPU。68K CPU 虽然能提供 Intel 486 一样的能力但是发热比 486 高,这时候如果苹果也开始迁移到 Intel 平台那可能历史就改写了。
Intel 的 CEO Andy Grove 还找过苹果,期望能让 Macintosh 支持 Intel CPU。但是当时苹果评估之后觉得 Intel 的 CISC (复杂指令集) 设计未来肯定打不过 RISC (精简指令集),所以没有投入 Intel 的怀抱。他们选择了与 IBM, 摩托罗拉合作成立 AIM 联盟,研发 RISC 的 PowerPC CPU。
1994 年苹果发布的高性能机器 Power Macintosh 首次搭载了这颗芯片,在市场上获得不错的反响,在 9 个月内卖出超过 100 万台。但是长期来看当时没有选择 Intel 是个错误的决定。
但是研发 PowerPC 的同时,苹果也没有放弃 Intel x86 架构。1992 年他们跟 Novell 公司合作,打算把 System 7 移植到 x86 架构上。苹果有操作系统经验,Novell 则有跨平台经验。但是 1993 年中,PC 价格战开始后因为业绩压力董事会把 CEO John Sculley (也就是那位著名的卖可乐的 CEO,也是他把乔布斯赶走的)辞退了,新任 CEO Michael Spindler 对 Intel 不感兴趣于是这个项目就被取消了。

Michael Spindler 在 CEO 的位子只坐了 3 年,他在职期间发布了 PowerPC 倒是挺成功的,但是后来的 Newton 和 Copland 操作系统却均是失之作。
苹果一直以来都自信自家的产品能提供远超其他产品的用户体验,但是随着 Windows 95 的发布这种差距在缩小,并且随着 PC 价格的下降苹果的性价比已然极低,于是苹果急于让自家的操作系统提供远超微软 Windows 的能力。
从 System 7.6 开始,Macintosh 的操作系统正式改名为 Mac OS 7.6。1994 年苹果宣布 Mac OS 8 将提供非常革命性的新特性,项目代号为 Copland。
Copland 的目标包括拥抱 RISC 让整个系统原生支持 PowerPC 架构,集成并改进苹果现有的技术比如 OpenDoc,ColorSync 等等。保留现有的 Mac OS 界面并提供可自定义的能力。扩展系统能力,允许跟 DOS 和 Windows 系统协作。支持多用户登录。以及一些其他革命性的特性。
一开始这个项目在公司内是非常激动人心的,1995 年还对 50 个 Mac 开发者放出了 Beta 版。但是从那以后,Copland 就再也没有更新过,也从来没有正式对外发布过。

当时的苹果公司以及负债累累,John Sculley 辞职的时候苹果公司还有 20 亿美元的现金与 2 亿美元的负债。到了 1996 年,有超过 500 名工程师投入到 Copland 项目中,光这个项目一年就要花去 2.5 亿美元的预算。那一年苹果亏损 7.4 亿美元,CEO Michael Spindler 被辞退,Gil Amelio 上任,该项目被正式取消。
Gil 后来在他的 On the Firing Line: My 500 Days at Apple 一书中是这样描述这个项目的:
just a collection of separate pieces, each being worked on by a different team… that were expected to magically come together somehow…
Copland 项目虽然失败了,但是它让苹果重新思考了自家操作系统的定位,同时感受到了强烈的生存危机,毕竟从 1991 年发布 System 7 到 1997 年之间,苹果一直没能发布一个正式的大版本。
此时的苹果急需寻找一款足够优秀的操作系统来拯救苹果。这时候收购一个操作系统公司的选项浮出水面。差不多是时候乔布斯要出场了,但是在他出现之前,还有另外一家公司成为苹果的候选。

1996 年 Gil Amelio 上任后苹果已岌岌可危。当时考虑过跟微软合作,开发基于 Windows NT 的 Apple OS。同时也考虑采用 Sun 公司的 Solaris 系统,或者收购 Be 公司的 BeOS。
Be 公司也跟 Apple 颇有渊源,甚至有点狗血。BeOS 的创始人 Jean-Louis Gassée 曾经是苹果公司欧洲运营负责人。1985 年 Gassée 得知乔布斯准备把当时人还在中国的 CEO John Sculley 赶走的时候,通知了 John Sculley,于是 Sculley 召开了董事会讨论这件事情。当时乔布斯在苹果内部可谓是众叛亲离,这是他自己盲目自信带来的后果。当时他利用自己的权威给 Macintosh 部门很多资源,员工的收入都比隔壁 Apple II 高得多,但实际上 Apple II 才是真正贡献公司利润的部门。1985 年初连创始人史蒂夫·沃茲尼克也离开了苹果,连带着很多高层也相继离开。所以最终董事会站在 Sculley 这边,反而把乔布斯赶走了。
John Sculley 成功把乔布斯赶走了之后,就让 Gassée 主管 Macintosh 产品。1988 年 Gassée 主管苹果的高级产品开发和全球市场,有传言称他要取代 Sculley 成为 CEO。不过 1990 年他就被 Sculley 和其他董事会成员要求离职了。
1991 年离开苹果之后 Gassée 创办了 Be 公司,带走了一堆苹果员工。他们开发了 BeOS,能在 PowerPC 上跑,目标很明确就是希望苹果可以收购他们,取代前面说的已经快挂掉的 Mac OS。BeOS 的特性很多,首先可以在 PowerPC 运行,然后支持内存保护,抢占式多任务,支持对称多处理等等。但是,BeOS 当时还没有完全实现,也并没有经历过市场的考验。
1996 年苹果给 Be 开价 5000 万美元(Be 公司的总投资大约 2000 万美元),但是 Gassée 非常自信地给出 5 亿美元回价。苹果又协商给 1.25 亿,Gassée 回 3 亿,苹果再开价 2 亿,但是 Gassée 仍不接受,给了个最终价 2.75 亿。
于是交易告吹。
苹果于同年底宣布以 4 亿美元收购了乔布斯的 NeXT,1997 年 2 月正式完成收购,乔帮主回归苹果,7 月份说服董事会辞退 Amelio,自己成为公司 CEO,开启了苹果的新世纪。

NeXT 的操作系统 NEXTSTEP (也写作 NeXTstep, NeXTStep) 跟 BeOS 不一样,它是经历过市场验证的。苹果当时的 CEO Amelio 还戏称这场收购是用 "plan A" 取代了 "plan Be"。
最终 NEXTSTEP 与 Mac OS 的结合诞生了如今我们使用的 Mac OS X (macOS)。不过并不是说乔布斯一回到苹果这系统就整合完了,他的回归到 OS X 诞生大约隔了 3 年。

1985 年乔布斯离开苹果的时候,带走了 5 个苹果员工创办 NeXT 公司,专做面向教育的产品。四年后,1988 年 10 月 12 日,
,跑在上面的操作系统就是 NEXTSTEP。NeXT 公司的创始团队还包括来自 CMU Mach 内核的团队的成员 Avie Tevanian。他是 Mach 内核的主要设计者和开发者之一。所以 NEXTSTEP 系统从第一天起就是基于 Mach 和 BSD 内核进行开发。当时发布的第一个版本采用的是 Mach 2.0 版本和 BSD 4.3 版本。Avie Tevanian 后来也成为苹果公司软件工程的高级 VP,2003 年当上 CTO,2006 年离职。
NEXTSTEP 系统提供了图形界面和 Unix 风格的命令行操作。可以说今天我们见到的 macOS 的很多特性都来自于 NEXTSTEP。比如说:
Application Kit1992 年,NEXTSTEP 发布了可以跑在 x86 架构上的版本。当时它们可以支持在 68K(摩托罗拉), x86(英特尔), PA-RISC(惠普), SPARC(Sun)等多种不同的芯片上运行。并且可以把多种架构的代码打包成一个 fat binary,也就是我们今天在 iOS 上常见的所谓 Universal Binary。

NeXT 公司还和 Sun 公司合作开发了 OpenStep。这是一套能跑在 SunOS, HP-UX 和 Windows NT 上的面向对象的接口。基于这个接口,一个精简版的 NEXTSTEP 就可以跑在支持这个接口的机器上。1994 年 OpenStep 发布了第一个版本。
不过没多久 NeXT 公司就转而专注在 WebObjects 技术上了。1996 年乔布斯还在微软的 Professional Developers conference 演示了这一技术: [Microsoft Professional Developers Conference 1996 Keynote Speaker: Steve Jobs](Microsoft Professional Developers conference)
简单说这个技术就是用 Java 开发网站的技术。这里有一份官方文档有兴趣的读者可以看看。
这项技术在 NeXT 被苹果收购之后也用在了部分苹果产品上,但是从 2008 年开始就不更新了,2016 年官方宣布中止开发。据称目前还用于 Apple Store 以及 iTunes Store 的一部分,不过除非内部负责该项目的开发者,不然无从考证了。
我们知道 Mach 是 NEXTSTEP 以及后来的 OS X 非常重要的组成部分。它是由 CMU (Carnegie Mellon University) 开发的微内核。它的前身是 CMU 开发的 Accent 内核,Accent 的前身则是 UR (University of Rochester) 开发的 RIG (Rochester's Intelligent Gateway) 项目的一部分。
1975 年一群来自罗切斯特大学(University of Rochester)的学者在开发一个智能网关系统,叫做 RIG (Rochester's Intelligent Gateway)。这个项目跑在 Aleph 系统上,这个系统跑在 Data General 公司的 Eclipse 小型机上。
![]()
这个内核的主要功能是提供 IPC 能力(interprocess communication),也就是我们常说的“进程间通信”。我们可以从 Aleph 的 IPC 抽象上看到 Mach IPC 的设计。系统采用 Message 在多个进程间传递信息,采用 Port 来对应信息的接收方。跟后来的 Mach 设计是一样的。但是当时这个系统有几个非常严重的基础缺陷,比如说:
关于这个系统的论文可以在这里下载,有兴趣的读者可以看看。

RIG 项目的其中一个成员——也是上述论文的作者之一——Richard Rashid 在 1979 年转到 CMU 当教授。在 CMU 工作的其中一个项目就是 Accent 内核,从 1981 年开始正式启动。这个内核面向的是网络操作系统。作为一个面向通信的系统,Accent 也采用了类似 RIG IPC 通信方式的设计,不过做了很多改进:
wire 到物理内存上(还记得我们之前分析内存接口的时候有一个 wire 类型的内存占用吗?)看起来 Accent 比 RIG 好多了,但是这个内核设计的时候是跑在 PERQ 工作站上的。虽然它拥有很多厉害的特性但是设计的时候非常依赖硬件,也不支持 Unix 软件运行。
为了支持 Unix, Richard Rashid 开始了 Mach 项目,并于 1985 年发布了第一个版本。这位厉害的学者因为 Mach 一战成名,1991 年加入微软,后来成为微软的 VP 直到 2012 年。
关于 Accent 的论文可以到这里下载。
Mach 内核的设计目标之一是要兼容 Unix 系统。在这个项目启动的时候,Unix 已经存在了 15 年之久,有大量的 feature 被集合到这个巨大的内核里。
Richard 甚至把 Unix 戏称为"所有新特性或功能的垃圾场"(dumping ground for virtually every new feature or facility)。所以 Mach 项目就是要设计一个可以为其他操作系统内核基础的一个微内核,他们的目标包括:
Mach 内核设计的时候主要 focus 在 CPU 支持与内存管理上,没有考虑支持文件系统,网络接口或者设备 I/O 接口。当初他们的设想是,真正的操作系统可以作为一个用户态的程序跑在 Mach 内核上。Mach 内核采用 C 语言开发,这意味着可以很轻易地移植到各个平台。
Mach 内核开发的时候以 4.3BSD 为基础进行开发。Richard 由于有 RIG 和 Accent 的经验,在 Mach 内核的设计上可谓驾轻就熟。1986 年正式对外发布的时候,他们在论文上称这是"为 UNIX 开发的一个新内核"。
当时选择新的 Mach 内核作为自家操作系统内核的,不止 NeXT 一家。1994 年苹果还没收购 NeXT 之前,在 Copland 项目中也用到了作为 Mach 3.0 作为系统内核。但是在对外公布的测试版中却极其不稳定。这个内核项目叫做 NuKernel,当然后来也随着 Copland 项目的结束也无疾而终。

在前面的文章中我们也提到过 Mach 内核的一些基本抽象,这里还是简单介绍一下:
task 表示一个或多个线程资源的集合,资源包括内存,ports(翻译成端口好像不太合适), CPU 核心等等。我们可以简单理解为大家熟悉的“进程”。thread (线程)是一个 task 的基本执行单元。task 负责提供线程的运行环境,多个线程共享相同的资源。这点与 Accent 不同,Process 被进一步分为 task 和多个 threads。port 跟 Accent 的 port 很像,也是一个内核维护的消息队列,用于 IPC 通信。在 Mach 里一个 port 表示为一个整数。message (消息)就是用于 IPC 的结构体,可以在不同的 task 之间通信,也可以在同一个 task 里的不同 thread 通信。memory object 可以看成是映射到一个 task 内存空间的的一个数据集合(包括文件数据)。Mach 的内存管理分为 pmap 物理内存层和 vmmap 虚拟内存层。需要 PMMU 硬件支持换入换出,现代 CPU 都集成 MMU 了,当年的 MMU 还是外置的。当年 CMU 做了一个非常重要的决定,就是 Mach 内核开源且无任何 licensing 约束。这意味着任何人都可以免费发行 Mach 内核。

1996 年 12 月苹果宣布收购 NeXT 公司, 但是在那之前,2 月份苹果就已经开始了一个特别的项目:把 Linux 移植到 PowerPC 平台,让 Macintosh 机器也能跑 Linux。
这个项目的产品叫做 MkLinux, 由 OSF (Open Software Foundation) 和苹果公司联合开发的,目标是让 Linux 内核跑在 Mach 3.0 内核上。
OSF 早期的成立是为了给 UNIX 系统提供一个开放标准。在 CMU 开发 Mach 2.5 版本的时候,OSF 宣布用于其开发的 OSF/1 系统,并将 host Mach 内核的未来版本。事实上 Mach 3.0 版本是从 CMU 开始,后来也是由 OSF 开发完成。当时 NEXTSTEP 用的是 Mach 2.x 内核。
1996 年在 WWDC 上苹果公司正式宣布将把 Linux 移植到 Power Macintosh 机器上,名为 MkLinux (Microkernel Linux)。
这个项目后来也随着 OS X 的整合而终止,交回给社区维护。但是这个项目对苹果整合 NEXTSTEP 帮助不小,在官方的 Kernel Programming Guide 有曰:
OS X is based on the Mach 3.0 microkernel, designed by Carnegie Mellon University, and later adapted to the Power Macintosh by Apple and the Open Software Foundation Research Institute (now part of Silicomp). This was known as osfmk, and was part of MkLinux (http://www.mklinux.org). Later, this and code from OSF’s commercial development efforts were incorporated into Darwin’s kernel.
这也是为什么我们看 XNU 代码里面,Mach 的部分都放在 osfmk 目录下。目前 MkLinux 社区也没什么声音了,最后一个发版本在 2002 年。
P.S. osfmk 就是 Open Software Foundation Mach Kernel 的缩写。
前面我们提到 CMU 开发 Mach 内核时嫌弃传统 UNIX 内核什么都干,过于臃肿。所以设计目标是要取代 UNIX,让 UNIX 跑在 Mach 内核的用户空间里。这个特性在 Mach 3.0 真正实现了。但是众所周知 Mach 内核并不提供文件系统和网络实现,所以依然需要和 UNIX 做大量的数据交换。这种交换的方式就是通过 Mach 的 IPC 通信。而让几乎所有进程都在两个空间之间做 IPC 通信是非常低效的。
所以 NEXTSTEP 系统修改了 Mach 内核的实现,让 Mach 和 BSD 都跑在同样的内核空间上,同时让用户空间发起的文件、网络请求等本来要通过 IPC 调用的接口都改成 system call。

1997 年 1 月份
是乔布斯回归后的第一次登台,讲了一堆苹果过去十年犯下的错误之后,宣布 Rhapsody 项目,很有救世主之风。 第一次演示了 Rhapsody 的 demo。在他登台之后,现场响起了绵延不绝的掌声。Rhapsody 基于 NeXT 的 OPENSTEP 开发,可以认为是 Mac OS X 的过渡产品。经过漫长的研发阶段,终于在 2000 年 12 月正式发布第一个 Public Beta 版。这期间大概的时间线是这样的:
其中在 1999 开始开源了系统的核心部分,名为 Darwin。其核心就来自 NEXTSTEP 的 XNU,也就是 Mach/BSD 混合内核。Mach 部分更新了 OSFMK 的 Mach 3.0 和部分来自 University of Utah 的 Mach 4 项目,BSD 部分更新了 FreeBSD 项目的代码。早期苹果甚至提供了 Darwin 安装包,可以作为一个独立系统安装到 x86 和 PowerPC 机器上。不过现在只开放源代码了。
2000 年乔布斯在 Macworld Expo 上首次介绍了 Mac OS X,演讲风格非常乔帮主,有兴趣的朋友可以看看:
严格来说现在我们接触到的 macOS 内核,官方叫做 Darwin,它的核心是 XNU,可以独立安装。严格意义上 XNU 和 Darwin 并不完全相等,较真地讲 XNU 只是 Mach/BSD 部分。在前面的文章里我基本上把 Darwin 和 XNU 当做同义词,这并不严谨。但是根据我的考证,目前 Darwin, XNU 和 macOS Kernel 基本等同于一个意思,只要读者朋友不会产生歧义即可。
使用 uname -a 可以查看自己的系统版本:
Darwin xxx.local 19.0.0 Darwin Kernel Version 19.0.0: Thu Oct 17 16:17:15 PDT 2019; root:xnu-6153.41.3~29/RELEASE_X86_64 x86_64
Mach 内核最初的设计是一个微内核,但是现在 Darwin 已经是一个什么都干的宏内核(Monolithic kernel)了。在看这段历史的时候颇有一种天下大势,分久必合,合久必分的感觉。想想从 1971 年第一个 Unix 版本到现在(2019 年)已经 48 年过去了,OS X 10.0 也过去 18 年了。2016 年,苹果在 WWDC 宣布 OS X 改名为 macOS。
风云变幻几十年,既有技术的发展也有商业的博弈,很多今天看起来完全看不懂的代码,都是当年历史遗留的未解之谜。XNU 代码里的注释,也有历史的痕迹:
/*
* Well-known UDP port, debugger side.
* FIXME: This is what the 68K guys use, but beats me how they chose it...
*/
#define KDP_REMOTE_PORT 41139 /* pick one and register it */
至少现在我终于明白,什么是 68K guys 了。XDDD

此前我们在macOS 内核之系统如何启动?提到内核作为一个巨大的 Mach-O 文件如何被加载到内存运行的,不过内核是被 BootLoader(iBoot) 加载的,入口 LC_UNIXTHREAD 也是 ASLR 应用之前的旧实现。
那么内核是如何运行起一个 App 的呢?
在开始之前我们先了解几个简单的背景知识:XNU 的 Process (进程)的组成是怎样的?
我们知道 Process 这个抽象概念是指一个 Program (程序)加上它所持有的 Resources (资源)。资源包括物理的 CPU 时间和内存,或者抽象的文件概念等等。
我们知道 XNU 内核主要由 BSD 和 Mach 两个部分组成,BSD 作为 Unix 内核提供了 Unix Process,Mach 内核则把 Process 抽象为 Task 和 Thread,所以在 macOS 上,一个进程既是 Mach Task 也是 BSD Process。不过内核中比较多的 IPC 是通过 Mach 来完成的。
Mach Task 的定义在 osfmk/kern/task.h,这个结构体非常大,持有 IPC space, memory address space, Mach threads, BSD info 等非常多进程相关信息。
我们在用户空间给自己的 App 新起线程的时候,无论是用 NSThread 还是其他上层接口,系统都用 pthread 接口实现了(POSIX Threads)。进入到内核空间,一个 pthread 对应的是一个 Mach Thread,结构体定义在 osfmk/kern/thread.h,就是 struct thread。机器相关的定义在 struct machine_thread,不同的架构各有一个实现。thread 带有 struct task *task; 信息指向对应的进程。这个 Mach Thread 里也包含了 BSD 的 uthread。
所以一个 pthread 既是 Mach thread 也是 Unix thread。所以内核在创建一个新进程的时候,就需要同时创建 Unix Process 和 Mach Task,以及他们需要的 threads, processors 等各种信息。
我们可以通过 sysctl 查看:
➜ sysctl -a | grep -i proc
kern.maxproc: 4176
内核也在 bsd/conf/param.c hardcoded 了数字 NPROC:
#if CONFIG_EMBEDDED #define NPROC 1000 /* Account for TOTAL_CORPSES_ALLOWED by making this slightly lower than we can. */ #define NPROC_PER_UID 950 #else #define NPROC (20 + 16 * 32) #define NPROC_PER_UID (NPROC/2) #endif
/* NOTE: maxproc and hard_maxproc values are subject to device specific scaling in bsd_scale_setup / #define HNPROC 2500 / based on thread_max */ int maxproc = NPROC;
fork() 与 exec()在传统的 Unix 系统中,fork() 是唯一用来创建新进程的方法,该方法将复刻一个当前进程的完整结构,包括二进制代码。所以负责启动其他 App 的进程为了能跑其他人的程序,还需要配合 exec() 方法,把 fork 出来的进程的 image 覆盖成新 App 的。
macOS 的 BSD 部分也提供了 fork() 方法,返回值是 pid_t,为 0 即表示当前跑在子进程,-1 是失败,其他就是父进程的 pid。参考 MTU 课程的一个示例代码:
#include <stdio.h> #include <sys/types.h>#define MAX_COUNT 200
void ChildProcess(void); /* child process prototype / void ParentProcess(void); / parent process prototype */
void main(void) { pid_t pid;
pid = fork(); if (pid == 0) ChildProcess(); else ParentProcess();}
void ChildProcess(void) { int i;
for (i = 1; i <= MAX_COUNT; i++) printf(" This line is from child, value = %d\n", i); printf(" *** Child process is done ***\n");}
void ParentProcess(void) { int i;
for (i = 1; i <= MAX_COUNT; i++) printf("This line is from parent, value = %d\n", i); printf("*** Parent is done ***\n");
}
BSD 提供的 exec() 方法有很多,可以参考这里:
execl, execlp, execle, exect, execv, execvp, execvP -- execute a file
但最终都会进入 execve() 系统调用,这是内核提供给用户空间用于打开其他程序的唯一接口。
fork()在进入内核实现之前,fork() 在用户空间还做了一大堆事情,这些是在 libSystem 里面实现的,源码可以在这里找到。
我们的示例代码在调用 fork() 函数之后,就会先进入 libSystem 调用 libSystem_atfork_prepare() 处理注册的 hooks,接下来如果是动态库就走 dyld 的 _dyld_fork_child() 方法,静态库就不走 dyld 了。(我找到了函数实现但是没有找到判断与调用的地方。)
在 dyld 43 版本还有对静态库的处理 _dyld_fork_parent() 但是最新的版本(655.1.1)已经只剩下 _dyld_fork_child() 了。
// Libsystem-1252.250.1
// init.c()
static const struct _libc_functions libc_funcs = {
.version = 1,
.atfork_prepare = libSystem_atfork_prepare,
.atfork_parent = libSystem_atfork_parent,
.atfork_child = libSystem_atfork_child,
};
接下来 libSystem, dyld 和 xnu 会有一系列复杂的互相调用。《Mac OS X Internals》书中介绍的版本比较旧,新的代码和书中所说的稍有不同,但是原理是差不多的。这一部分直接阅读源码比较困难,所以我选择放弃,直接阅读书里的结论就好。XD
大家可以到这里参考原文
void libSystem_atfork_child(void) { // first call hardwired fork child handlers for Libsystem components // in the order of library initalization above _dyld_fork_child(); _pthread_atfork_child(); _mach_fork_child(); _malloc_fork_child(); _libc_fork_child(); // _arc4_fork_child calls malloc dispatch_atfork_child(); #if defined(HAVE_SYSTEM_CORESERVICES) _libcoreservices_fork_child(); #endif _asl_fork_child(); _notify_fork_child(); xpc_atfork_child(); _libtrace_fork_child(); _libSC_info_fork_child();// second call client parent handlers registered with pthread_atfork() _pthread_atfork_child_handlers();
}
用户空间准备完了就开始进入内核的 fork() 函数了,实现在 bsd/kern/kern_fork.c:
int fork(proc_t parent_proc, __unused struct fork_args *uap, int32_t *retval)
返回值 0 为成功,其他就是错误码。
第一个参数 parent_proc 就是调用 fork 的那个 process,第二个参数 uap 已经弃置不用了,第三个参数就是返回的 pid。父进程会收到 hardcoded 的 0。
关键实现在 fork1() 函数:
int
fork1(proc_t parent_proc, thread_t *child_threadp, int kind, coalition_t *coalitions)
这个函数上来先取父进程的 thread 和 uthread,接着取当前用户 ID kauth_getruid(),也就是我们通过 ps 看到的当前进程由哪个用户创建的信息,我们在 shell 里经常需要 sudo 也就是切换成 root 身份来跑一个进程,这个权限就是通过 kauth 模块管理。
接下来判断当前进程数是否超限,没问题就继续。
count = chgproccnt(uid, 1);
这里把当前用户进程数 + 1,我想到内核启动的时候,也 hardcode 了一句 + 1 给 launchd 这个进程。接着会判断用户的进程数上限是否超限。
接下来是安全检查,判断当前用户是否有权限 fork 新的进程,没问题就开始 switch kind 了,一共有三种类型:
/* process creation arguments */
#define PROC_CREATE_FORK 0 /* independent child (running) */
#define PROC_CREATE_SPAWN 1 /* independent child (suspended) */
#define PROC_CREATE_VFORK 2 /* child borrows context */
其中 vfork() 是 fork() 的变种,大部分 Unix-like 系统都有这两种 fork,区别是 vfork 创建的子进程会 block 住父进程,一直等到子进程跑完 exit 然后父进程才会继续,fork 则不会,可自行编译运行我们上文的小 demo。
至于 spawn 则是给 posix_spawn() 用的,跟 fork() 类似,但是 fork 会继承(或者说复制)父进程的很多资源比如内存,而 spawn 不会。可以参考 Linxu 关于 POSIX Spawn 的文档,简单理解为是给那些性能比较低的设备(比如嵌入式设备)用的。
我们继续看 fork():
cloneproc() // 创建新的 Mach Task (task_t), Unix Process (proc_t) 以及 thread_
forkproc()
proc_t 然后把父进程的信息都塞给他pid 然后赋值给新的 proc_tinherit_memory 如果为 true,则 vm_map 也会 fork 一份,否则就是重新创建一个 vm_map 然后赋值。fork() 进来的为 true, posix_spawn() 为 false。fork_create_child() 创建新的线程 thread_tprocdup() 这个在书中有提但是新版内核已去掉thread_dup()
machine_thread_dup() 不同的架构各有实现,主要是复制了当前线程的寄存器信息,FPU 信息等硬件相关的上下文信息。task_clear_return_wait()
thread_wakeup()thread_wakeup_with_result()
```
#define thread_wakeup_with_result(x, z) \
thread_wakeup_prim((x), FALSE, (z))
```
thread_wakeup_prim()
书中曰最终会进入 thread_resume() 但是我又没找到从哪里进入的🤦♂️。
execve()实现在 bsd/kern/kern_exec.c,我们来个示例代码看看:
#include <stdio.h> #include <sys/types.h> #include <unistd.h> #include <stdlib.h> #include <errno.h> #include <sys/wait.h>int main() { pid_t pid; int status, died;
pid = fork(); if (pid == 0) { printf("%s\n", "parent"); } else { int ret = execve("/bin/date",0,0); printf("%d\n", ret); }
}
输出如下:
➜ ./a.out
parent
Wed Nov 6 18:55:45 CST 2019
可以看到子进程已经被 /bin/date 覆盖了。同样的,这个函数也有用户空间和内核空间实现,上面示例我们用的接口是 POSIX 定义的:
int execve(const char * __file, char * const * __argv, char * const * __envp);
接受文件路径参数,参数列表和环境参数。
到了内核这个函数则是:
// bsd/kern/kern_exec.c
int
execve(proc_t p, struct execve_args *uap, int32_t *retval)
p 是当前进程,uap 是用户空间传过来的参数,有三个:
uap->fname 文件名uap->argp 参数列表uap->envp 环境参数对应用户空间里我们传的三个参数。最后 retval 是给上层的返回值,函数自身返回 0 则成功。
该函数的主要实现在 __mac_execve()。
先组装一个 image_params 数据结构:
struct image_params { user_addr_t ip_user_fname; /* argument */ user_addr_t ip_user_argv; /* argument */ user_addr_t ip_user_envv; /* argument */ int ip_seg; /* segment for arguments */ struct vnode *ip_vp; /* file */ struct vnode_attr *ip_vattr; /* run file attributes */ struct vnode_attr *ip_origvattr; /* invocation file attributes */ cpu_type_t ip_origcputype; /* cputype of invocation file */ cpu_subtype_t ip_origcpusubtype; /* subtype of invocation file */ char *ip_vdata; /* file data (up to one page) */ int ip_flags; /* image flags */ int ip_argc; /* argument count */ int ip_envc; /* environment count */ int ip_applec; /* apple vector count */char *ip_startargv; /* argument vector beginning */ char *ip_endargv; /* end of argv/start of envv */ char *ip_endenvv; /* end of envv/start of applev */ char *ip_strings; /* base address for strings */ char *ip_strendp; /* current end pointer */ int ip_argspace; /* remaining space of NCARGS limit (argv+envv) */ int ip_strspace; /* remaining total string space */ user_size_t ip_arch_offset; /* subfile offset in ip_vp */ user_size_t ip_arch_size; /* subfile length in ip_vp */ char ip_interp_buffer[IMG_SHSIZE]; /* interpreter buffer space */ int ip_interp_sugid_fd; /* fd for sugid script */ /* Next two fields are for support of architecture translation... */ struct vfs_context *ip_vfs_context; /* VFS context */ struct nameidata *ip_ndp; /* current nameidata */ thread_t ip_new_thread; /* thread for spawn/vfork */ struct label *ip_execlabelp; /* label of the executable */ struct label *ip_scriptlabelp; /* label of the script */ struct vnode *ip_scriptvp; /* script */ unsigned int ip_csflags; /* code signing flags */ int ip_mac_return; /* return code from mac policy checks */ void *ip_px_sa; void *ip_px_sfa; void *ip_px_spa; void *ip_px_smpx; /* MAC-specific spawn attrs. */ void *ip_px_persona; /* persona args */ void *ip_cs_error; /* codesigning error reason */ uint64_t ip_dyld_fsid; uint64_t ip_dyld_fsobjid;
};
组装完了之后就 active 一下 image:
static int
exec_activate_image(struct image_params *imgp)
这个函数主要是分配内存,权限检查,通过 namei() 方法找到该二进制文件,使用 vn 接口(跟文件系统无关的抽象接口)读取文件头,最多读一页。
error = vn_rdwr(UIO_READ, imgp->ip_vp, imgp->ip_vdata, PAGE_SIZE, 0,
UIO_SYSSPACE, IO_NODELOCKED,
vfs_context_ucred(imgp->ip_vfs_context),
&resid, vfs_context_proc(imgp->ip_vfs_context));
读到文件头信息之后再循环走一遍,判断是否如下三种:
{ exec_mach_imgact, "Mach-o Binary" }, // 普通的单架构 Mach-o 二进制文件
{ exec_fat_imgact, "Fat Binary" }, // 多架构 Mach-o 二进制文件
{ exec_shell_imgact, "Interpreter Script" }, // 脚本
找到了就使用对应 imgact 转成函数指针然后调用它,传入 imgp 参数。
error = (*execsw[i].ex_imgact)(imgp);
我们直接看 exec_mach_imgact():
static int
exec_mach_imgact(struct image_params *imgp)
这个函数最重要的地方是:
lret = load_machfile(imgp, mach_header, thread, &map, &load_result);
load_machfile() 实现在 bsd/kern/mach_loader.c 里面。负责分配物理内存和虚拟内存,如果有 ASLR (就是内存 offset 加个随机偏移,默认开)就随机一下,然后解析 Mach-o 文件,根据 Mach-o 文件的 load commands 信息把二进制数据装进内存。
其中用到了 parse_machfile() 方法处理 Mach-o 文件里的 load commands。我们知道有了 ASLR 之后大家的入口都从 LC_UNIXTHREAD 变成了 LC_MAIN。这个方法就把这些信息都保存到 load_result_t 里面然后返回, load_result_t 里包含了 threadstate,里面就有 entry_point 信息。
load mach file 结束后 activate_exec_state()
static int
activate_exec_state(task_t task, proc_t p, thread_t thread, load_result_t *result)
这个函数会调用 thread_setentrypoint() 把之前函数入口 entry_point 地址塞进 eip 寄存器于是函数就愉快地被调用了。
thread_setentrypoint(thread, result->entry_point);
// i386 实现
#define CAST_DOWN_EXPLICIT( type, addr ) ( ((type)((uintptr_t) (addr))) )
/*
-
thread_setentrypoint:
-
-
Sets the user PC into the machine
dependent thread state info.
*/
void
thread_setentrypoint(thread_t thread, mach_vm_address_t entry)
{
pal_register_cache_state(thread, DIRTY);
if (thread_is_64bit_addr(thread)) {
x86_saved_state64_t *iss64;
iss64 = USER_REGS64(thread);
iss64->isf.rip = (uint64_t)entry;
} else {
x86_saved_state32_t *iss32;
iss32 = USER_REGS32(thread);
iss32->eip = CAST_DOWN_EXPLICIT(unsigned int, entry);
}
}
这里涉及 i386 架构的寄存器设计,以底下的 32 位为例,eip 就是 PC 寄存器(Program Counter Register)。
#define REG_PC EIP
#define REG_FP EBP
#define REG_SP UESP
#define REG_PS EFL
#define REG_R0 EAX
#define REG_R1 EDX
在 i386 或曰 x86 架构里面,这个寄存器就是下一个指令会访问到的内存地址。于是我们将它设置为函数入口,该函数就开始了。
LC_MAIN 的 entryoff有了 ASLR 之后入口地址不再是静态的偏移量而是每次都会随机一下。如果是以前的入口在 LC_UNIXTHREAD 的,这时候取 entry point 就直接赋值。
但是 LC_MAIN 入口的却会传给 LC_LOAD_DYLINKER 段里面指定使用的 dyld。由于 Release App 基本都会去掉 debugging symbol 放进 dSYM,方便起见我们直接看我的 Debug 版的 Just Focus for Mac:
Load command 11
cmd LC_MAIN
cmdsize 24
entryoff 535536
stacksize 0
entryoff 这个偏移量是基于文件初始位置的。
535536 转成 hex 就是 0x000082BF0,再加上 macOS 上的基准地址 0x100000000 就是 0x100082BF0。方便起见我们直接用 MachOView 来看看 (__TEXT,__text) 段里的数据

可以看到这里就是我们的 _main() 函数入口。当然这些数值都是静态的,当 App 被加入内存时,内核会计算偏移量所以运行时的地址还得再加上那个偏移量。
接下来 parser_machinefile() 就会去调用 load_dylinker(),初始化一些 dylddata 然后又回去调用 parse_machinefile() 一次。这一次,parse 的不是别人,而是 LC_LOAD_DYLINKER 里指定的 dyld,比如上面的 /usr/lib/dyld。
这个家伙当然不用 LC_MAIN 而是 LC_UNIXTHREAD 啦:
Load command 12
cmd LC_UNIXTHREAD
cmdsize 184
flavor x86_THREAD_STATE64
count x86_THREAD_STATE64_COUNT
rax 0x0000000000000000 rbx 0x0000000000000000 rcx 0x0000000000000000
rdx 0x0000000000000000 rdi 0x0000000000000000 rsi 0x0000000000000000
rbp 0x0000000000000000 rsp 0x0000000000000000 r8 0x0000000000000000
r9 0x0000000000000000 r10 0x0000000000000000 r11 0x0000000000000000
r12 0x0000000000000000 r13 0x0000000000000000 r14 0x0000000000000000
r15 0x0000000000000000 rip 0x0000000000001000
rflags 0x0000000000000000 cs 0x0000000000000000 fs 0x0000000000000000
于是设置好 entry point,通过 dyld 起飞!
内核的 fork() 和 exec() 任务到给 thread 设置 entry point 之后就结束了。至于为什么往寄存器里塞一个函数指针地址它就开始跑起来,那就涉及到汇编,CPU 如何执行指令了。阮一峰的科普文章《汇编语言入门教程》写得很浅显易懂可以参考一下。
接下来我们切换到 dyld 的源码。dyld 在模拟器和真机上有不同的启动入口:
// configs/dyld.xcconfig
ENTRY[sdk=simulator] = -Wl,-e,_start_sim ENTRY[sdk=iphoneos*] = -Wl,-e,__dyld_start ENTRY[sdk=macosx*] = -Wl,-e,__dyld_start
入口函数的实现是汇编,在 dyldStartup.s 文件。我们可以搜索关键词 call:
// i386 实现 .text .align 4, 0x90 .globl __dyld_start __dyld_start: popl %edx # edx = mh of app pushl $0 # push a zero for debugger end of frames marker movl %esp,%ebp # pointer to base of kernel frame andl $-16,%esp # force SSE alignment subl $32,%esp # room for locals and outgoing parameterscall L__dyld_start_picbaseL__dyld_start_picbase:
popl %ebx # set %ebx to runtime value of picbasemovl Lmh-L__dyld_start_picbase(%ebx), %ecx # ecx = prefered load address movl __dyld_start_static_picbase-L__dyld_start_picbase(%ebx), %eax subl %eax, %ebx # ebx = slide = L__dyld_start_picbase - [__dyld_start_static_picbase] addl %ebx, %ecx # ecx = actual load address # call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue) movl %edx,(%esp) # param1 = app_mh movl 4(%ebp),%eax movl %eax,4(%esp) # param2 = argc lea 8(%ebp),%eax movl %eax,8(%esp) # param3 = argv movl %ebx,12(%esp) # param4 = slide movl %ecx,16(%esp) # param5 = actual load address lea 28(%esp),%eax movl %eax,20(%esp) # param6 = &startGlue call __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm movl 28(%esp),%edx cmpl $0,%edx jne Lnew # clean up stack and jump to "start" in main executable movl %ebp,%esp # restore the unaligned stack pointer addl $4,%esp # remove debugger end frame marker movl $0,%ebp # restore ebp back to zero jmp *%eax # jump to the entry point # LC_MAIN case, set up stack for call to main()
Lnew: movl 4(%ebp),%ebx movl %ebx,(%esp) # main param1 = argc leal 8(%ebp),%ecx movl %ecx,4(%esp) # main param2 = argv leal 0x4(%ecx,%ebx,4),%ebx movl %ebx,8(%esp) # main param3 = env
所以在我们的 App 的函数入口被调用之前,dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)函数会先被调用,它的返回值是真正 App 的函数入口,比如说 main()。
uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[],
intptr_t slide, const struct macho_header* dyldsMachHeader,
uintptr_t* startGlue)
这个函数调用了 dyld::_main() 这个函数才是重点,上面不同架构的汇编都会进这里,只是参数各有不同。这个函数会 load 所有的动态库 image,初始化,最后再拿到真正的 App 入口,然后返回。最后汇编代码里就会 jmp 到 App 入口,于是 App 就愉快地启动了。
如果你在 Activity Monitor App 里选中一个进程,点左上角的感叹号,你可以看到当前进程的 Parent Process。然后你就会发现基本上所有你通过 Finder, Launchpad 之类的方式启动的 App(命令行的 open 也是),它们的 parent process 都是 launchd (当然 App 自行创建的子进程就不是,比如 Google Chrome Helper)。在 iOS 的 Crash Log 里,App 的 parent process 也是 launchd。
在 macOS 上我们可以使用系统提供的 Launch Service 来启动其他 App,最终也是由 launchd 来完成 fork() 和 execve()。
launchd 的 parent process 是 kernel_task。kernel_task 进程就是内核进程本程了,在内核启动时自行创建,实现在 bsd/kern/bsd_init.c 的 bsd_init(void) 函数。
launchd 是 Mac OS X Tiger 10.4 开始引入的特性,在 Kernel 启动时创建,然后它负责创建其他系统守护进程(Daemons),也负责创建系统登录界面。
还有另一个服务是 launchctl,可以跟 launchd 进行 IPC 通信,经常被用来做开机启动任务。LaunchControl.app 就是非常好的 launchctl/launchd 图形界面。
Unix 的 fork() 和 execve() 方法在上学的时候学校曾经教过。但是一则当时的讲解还比较偏高级抽象,二则年代久远已经记不太清了,所以回顾学习这一段的时候还是费了点力气去了解诸如汇编、寄存器之类的概念。Apple 开源的代码还是很多的,除了内核,大量的系统服务也都开源了,非常有助学习。最近学习内核代码,一边看代码一边跟着书本理解,总让我有一种“源码在手,天下我有”的错觉。XD

经过前两篇提到的尝试之后,终于来到 BPF 了。由于 nstat 在内核中定义为私有接口,所以它的数据虽然现成,用起来却一点也不简单。那么有没有更厉害一点的方法呢?
朋友听说我在学习这方面的技术,于是推荐了一个关键词: BPF。我们知道抓包界有一个大名鼎鼎的工具叫做 tcpdump,它的核心原理就是使用了 BPF 技术(基于 pcap 接口)。
我阅读了 1992 年 BPF 发表的论文,顺带发现了
的 PDF,才知道原来 TCPDump 是 Steve McCanne 1988 年在加州大学伯克利分校选修编译器课程的时候,跟其他同学一起做的,BPF 可以看做是当时他们做tcpdump 时顺手开发的。有点像我们上大学时老师要求做的大作业,只不过人家的大作业是改变世界的大作业😂。
当时 Steve 和同学组成一个四个人的 Research Group:
其中 Steve McCanne 和 Van Jacobson 负责网络抓包的部分(他们俩也是论文的作者)。他们开始用 Sun 的抓包工具但是用起来非常抓狂,于是他们决定写一个自己的工具,也就是后来的 tcpdump。其中跑在 Unix 内核的部分就是 BPF,Berkeley Packet Filter 的缩写,最后于 1992 年 12 月发表论文。
Packet Filter 这种技术是为了网络监控程序设计的,我们知道内核空间与用户空间的虚拟内存实现不同,如果要从内核传递数据到用户空间需要经过地址空间转换,还要 copy 数据,是一种比较耗时的操作。(这里 Unix 和 Linux 的虚拟内存实现还不一样,我尚未仔细学习,目前只知道操作耗时。)
为了减少 copy 操作,早期有些 Unix 系统提供了包过滤技术,比如 CMU/Stanford Packet Filter。BPF 论文发表的时候称性能比 Sun's NIT 快 100 倍,吊打所有对手。这篇论文并不长有兴趣的读者可以看一下: The BSD Packet Filter: A New Architecture for User-level Packet Capture
根据我的阅读理解,Packet Filter 技术应该都会提供 pseudo-machine (伪代码虚拟机)把 bytecode (字节码)转为机器码,也就是虚拟机,著名的虚拟机比如 Java 的 JVM,把源码转成 .class 的字节码然后每个平台各自跑个虚拟机从而实现跨平台。BPF 的操作也是通过 bytecode 编写。FreeBSD, NetBSD 都提供了 JIT 编译器给 BPF,Linux 也有不过默认是关的。
由于 BPF 设计的时候摒弃了以前 Packet Filter 基于栈设计(Stack based)的虚拟机的做法(比如 JVM 就是),改为使用基于寄存器(Register based)设计的虚拟机,充分利用了当时还算新技术的 CPU RISC (精简指令集)的优势。(题外: RISC 的发明者 David Patterson 也是加州大学伯克利分校的)
另外 BPF 还做了一个看似非常小的改进:在内核层接到 device interface 丢过来的包时就进行 filter,不需要的包直接丢弃,不会多出任何无效 copy。从而比旧时代的技术有着显著的性能优势。论文中他们还提到 BPF 的多项优化细节,这里不再赘述,有兴趣的读者可自行阅读论文。
总而言之 BPF 技术提供了一个原始接口,可以获取 Data Link Level (数据链路层)的数据包,并且支持数据包过滤,由于采用虚拟机在内核层直接执行 bytecode,所以过滤逻辑实际上跑在内核层,性能十分优越。在 OSI 模型中,Link Level 是最接近物理层的了,在这一层抓包当然是最王道的选择啦。
P.S. 系统内核是没必要走 Packet Filter 的,这个技术是给用户空间的 App 用的,内核本来就有所有数据包,所以 nstat 不会用到这些技术。
如第一节所说,bpf 在内核层实现了一个可以执行 bpf 字节码的虚拟机,所以理论上我们可以裸写 bpf 指令,跟写汇编差不多。XNU 的 BSD 部分实现了 bpf,需要引入头文件:
#import <net/bpf.h>
以下是 BPF program 示例代码(来自 Mac OS X Internals):
int installFilter(int fd, unsigned char Protocol, unsigned short Port) { struct bpf_program bpfProgram = {0};/* Dump IPv4 packets matching Protocol and (for IPv4) Port only */ /* @param: fd - Open /dev/bpfX handle. */ const int IPHeaderOffset = 6 + 6 + 2; /* 14 */ /* Assuming Ethernet (DLT_EN10MB) frames, We have: * * Ethernet header = 14 = 6 (dest) + 6 (src) + 2 (ethertype) * Ethertype is 8-bits (BFP_P) at offset 12 * IP header len is at offset 14 of frame (lower 4 bytes). * We use BPF_MSH to isolate field and multiply by 4 * IP fragment data is 16-bits (BFP_H) at offset 6 of IP header, 20 from frame * IP protocol field is 8-bts (BFP_B) at offset 9 of IP header, 23 from frame * TCP source port is right after IP header (HLEN*4 bytes from IP header) * TCP destination port is two bytes later * * Note Port offset assumes that this Protocol == IPPROTO_TCP! * If it isn't, adapting this to UDP port is left as an exercise to the reader, * as is extending this to support IPv6, as well.. */struct bpf_insn insns[] = {
/* Uncomment this line to accept all packets (skip all checks) */ // BPF_STMT(BPF_RET + BPF_K, (u_int)-1), // Return -1 (packet accepted)
BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 6+6), // Load ethertype 16-bits from 12 (6+6) BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ETHERTYPE_IP, 0, 10), // Test Ethertype or jump(10) to reject BPF_STMT(BPF_LD + BPF_B + BPF_ABS, 23), // Load protocol (= IP Header + 9 bytes) BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K , Protocol, 0, 8), // Test Protocol or jump(8) to reject BPF_STMT(BPF_LD + BPF_H + BPF_ABS, IPHeaderOffset+6),// Load fragment offset field BPF_JUMP(BPF_JMP + BPF_JSET+ BPF_K , 0x1fff, 6, 0), // Reject (jump 6) if more fragments BPF_STMT(BPF_LDX + BPF_B + BPF_MSH, IPHeaderOffset), // Load IP Header Len (x4), into BPF_IND BPF_STMT(BPF_LD + BPF_H + BPF_IND, IPHeaderOffset), // Skip hdrlen bytes, load TCP src BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K , Port, 2, 0), // Test src port, jump to "port" if true
/* If we're still here, we know it's an IPv4, unfragmented, TCP packet, but source port
- doesn't match - maybe destination port does? */
BPF_STMT(BPF_LD + BPF_H + BPF_IND, IPHeaderOffset+2), // Skip two more bytes, to load TCP dest /* port / BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K , Port, 0, 1), // If port matches, ok. Else reject / ok: / BPF_STMT(BPF_RET + BPF_K, (u_int)-1), // Return -1 (packet accepted) / reject: */ BPF_STMT(BPF_RET + BPF_K, 0) // Return 0 (packet rejected) };
先初始化一个 bpf_program 结构体:
struct bpf_program { u_int bf_len; struct bpf_insn *bf_insns; };
struct bpf_insn { u_short code; u_char jt; u_char jf; bpf_u_int32 k; };
然后编写指令 bpf_insn,看上去像写汇编一样差不多(虽然我不会)。
除了写 *pcap 的人之外,在 Unix 上,一般开发者都用 bpf 作者写的 libpacp 封装来操作 bpf。我在 macOS 10.15 Catalina (19A583) 上用 libpcap 实现了一个简单的抓包逻辑,我们可以看一下去掉错误处理的关键代码:
// 创建一个 bpf_program struct bpf_program fp;// 找一下 device interface char *dev = pcap_lookupdev(errbuf);
// 获取 IP 和 netmask bpf_u_int32 mask; bpf_u_int32 net; pcap_lookupnet(dev, &net, &mask, errbuf);
// 打开一个 pcap session pcap_t *handle = pcap_open_live(dev, BUFSIZ, 1, 1000, errbuf);
我们看下这个函数原型:
pcap_t *pcap_open_live(char *device, int snaplen, int promisc, int to_ms,
char *ebuf)
第一个参数 device 就是 pcap_lookupdev 拿到的 device 了,第二个 snaplen 是 pcap 可以捕获的最大长度,这里填 stdio.h 定义的值 BUFSIZ,也就是 1024 bytes(官网教程说的是 pcap.h 有但是我没找到,只在 stdio.h 里找到了)。
第三个参数 promisc 是 promiscuous mode 是否打开。promiscuous mode 中文翻译为混杂模式,没打开的时候我们只能获取目标地址为该 interface 的包,打开了之后经过它的包也可以被我们抓到。
第四个参数 to_ms 是设置超时时间,以 ms 为单位,填 0 就是不设置超时。
最后一个参数 ebuf 就是错误信息返回了。传入 char *errbuf[PCAP_ERRBUF_SIZE]; 就行。
上一篇我们讲过 PPP 和 Ethernet 包有所不同,如果你只想处理 Ethernet 包的话你可以通过 pcap_datalink() 接口判断 link-layer header。
if (pcap_datalink(handle) != DLT_EN10MB) {
fprintf(stderr, "Device %s doesn't provide Ethernet headers - not supported\n", dev);
return(2);
}
前面说过 bpf_program 里都是存的字节码指令,所以我们得编译一下:
char filter_exp[] = "port 23";
pcap_compile(handle, &fp, filter_exp, 0, net)
最后把 filter 设置好:
pcap_setfilter(handle, &fp)
然后我们就可以愉快地抓包了。使用 pcap_next() 可以获得一个 filter 过的包。
/* Grab a packet */
packet = pcap_next(handle, &header);
/* Print its length */
printf("Jacked a packet with length of [%d]\n", header.len);
/* And close the session */
pcap_close(handle);
完整示例可以参考 tcpdump 官网的这篇文章: Programming with pcap
一般情况下我们不会只抓一个包,我们可以用 pcap_loop() 来循环抓包:
int pcap_loop(pcap_t *p, int cnt, pcap_handler callback, u_char *user)
第一个参数就是上面创建的 handle 了,第二个参数 cnt 是说抓了多少个包之后回调给你。第三个函数 pcap_handler 就是你的回调函数,最后一个是上下文参数,透传的。
回调函数 pcap_handler 的原型如下:
typedef void (*pcap_handler)(u_char *arg, const struct pcap_pkthdr *, const u_char *packet);
第一个参数 arg 就是 pcap_loop() 注册时最后一个上下文参数,你自己传的。
第二个参数 pcap_pkthdr 是 pcap 包头,第三个参数 packet 就是网络包啦,解析这两个参数我们就能获得包信息。
struct pcap_pkthdr {
struct timeval ts; time stamp
bpf_u_int32 caplen; length of portion present
bpf_u_int32; lebgth this packet (off wire)
}
因为前面可以设置抓包阈值,所以包本身的时间放在 pcap_pkthdr 里面。
我们只关心外网 IP 包,不关心 ARP 包,另外 PPP 先不处理,所以过滤一下:
if (ntohs (eptr->ether_type) == ETHERTYPE_IP) {}
然后可以打印出来了:
int i; u_char *ptr; /* printing out hardware header info */ /* copied from Steven's UNP */ ptr = eptr->ether_dhost; i = ETHER_ADDR_LEN; printf(" Destination Address: "); do{ printf("%s%x",(i == ETHER_ADDR_LEN) ? " " : ":",*ptr++); }while(--i>0); printf("\n");
ptr = eptr->ether_shost; i = ETHER_ADDR_LEN; printf(" Source Address: "); do{ printf("%s%x",(i == ETHER_ADDR_LEN) ? " " : ":",*ptr++); }while(--i>0); printf("\n");
输出结果:
Ethernet type hex:800 dec:2048 is an IP packet
Destination Address: 0:0:c:7:ac:ec
Source Address: dc:a9:4:77:9c:41
Ethernet type hex:800 dec:2048 is an IP packet
Destination Address: 0:0:c:7:ac:ec
Source Address: dc:a9:4:77:9c:41
这样,所有的 IP packet 的 Mac 地址都被我们打印出来了。如果我想打印 IPv4 地址,以及 TCP 协议的端口呢?
TCP 是 IP 上层的协议,如果我们要抓 TCP 的包我们可以判断一下 IP packet 里的 protocol number。不过在那之前,我们要先从 packet 里面解出 IP 信息和 TCP 信息。我们参考一下整个包的内存结构:
| Variable | Location (in bytes) |
|---|---|
| Ethernet | x |
| IP | x + SIZE_ETHERNET |
| TCP | x + SIZE_ETHERNET + {IP header length} |
| payload | x + SIZE_ETHERNET + {IP header length} + {TCP header length} |
// 原型可见 bsd/netinet/ip.h
// 这里参考 https://www.tcpdump.org/pcap.html
struct sniff_ip {
#ifdef _IP_VHL
u_char ip_vhl; /* version << 4 | header length >> 2 */
#else
#if BYTE_ORDER == LITTLE_ENDIAN
u_int ip_hl:4, /* header length */
ip_v:4; /* version */
#endif
#if BYTE_ORDER == BIG_ENDIAN
u_int ip_v:4, /* version */
ip_hl:4; /* header length */
#endif
#endif /* not _IP_VHL */
u_char ip_tos; /* type of service */
u_short ip_len; /* total length */
u_short ip_id; /* identification */
u_short ip_off; /* fragment offset field */
#define IP_RF 0x8000 /* reserved fragment flag */
#define IP_DF 0x4000 /* dont fragment flag */
#define IP_MF 0x2000 /* more fragments flag */
#define IP_OFFMASK 0x1fff /* mask for fragmenting bits */
u_char ip_ttl; /* time to live */
u_char ip_p; /* protocol */
u_short ip_sum; /* checksum */
struct in_addr ip_src,ip_dst; /* source and dest address */
};
出于学习目的我们只看 Ethernet 包,Ethernet 包的包头规定是 14 byets,所以我们偏移 14 bytes 就能得到包体。
#define SIZE_ETHERNET 14
ip = (struct sniff_ip*)(packet + SIZE_ETHERNET);
IP 协议的规定比较复杂,他的 ip header 长度不是固定的,而是 4 字节长度的 word 的个数。
#define IP_HL(ip) (((ip)->ip_vhl) & 0x0f)
ip = (struct sniff_ip*)(packet + SIZE_ETHERNET); size_ip = IP_HL(ip)*4;
TCP header 也不是定长的,同样也是取 4 字节 word 长度的个数。
tcp = (struct sniff_tcp*)(packet + SIZE_ETHERNET + size_ip); size_tcp = TH_OFF(tcp)*4;
// 剩下的就是 payload 了 payload = (u_char *)(packet + SIZE_ETHERNET + size_ip + size_tcp);
fprintf(stdout,"IP: %s", inet_ntoa(ip->ip_src)); fprintf(stdout,"Port: %s", ntohs(tcp->th_sport));
fprintf(stdout,"IP: %s", inet_ntoa(ip->ip_dst)); fprintf(stdout,"Port: %s", ntohs(tcp->th_dport));
这样我们就获得所有 TCP 包的数据了。
这里使用 ntohs() 进行转换是因为网络层的 byte order 和 host (CPU 架构)的不一样,network byte order 是用大端(big-endian),host 则根据 CPU 架构来,从 Mac OS X 支持 i386 开始就是小端了(little-endian)。所以必须把内存里的数据转换一下才能得到正确的数值。
inet_ntoa() 则是把 network byte order 的结构体 in_addr 转换成一个 IPv4 的 string。
以上是如何使用 pcap() 接口抓包。由于我们在 link level 抓的包全都是 packet 数据,可以承载 TCP/UDP, IP/ARP, Ethernet/PPP 等多种非常"原始"的数据,所以处理起来非常感人。
作为学习之用我觉得挺好的,要付诸生产环境还需要不少功夫。
这些 packet 包本身是不带进程信息 pid 的,如果我们要把这些包跟进程关联到一起就还需要额外的处理。一种解决方法是根据每个 TCP 连接中系统给分配的 port,从系统调用反查该 port 对应的进程。但是有可能当我们去查询的时候这个连接已经断开了(虽然讲道理 bpf 截获数据包比真正接包的应用还早,但我们可以设置回调间隔,所以不一定),所以也不一定靠谱。我本来也研究了一下如何从系统获取所有 process 和对应分配的 port,但是很笨地跟上面那一堆 pcap 代码一起忘记 commit 了。所以我重新学习了一遍 pcap 使用,但是不想再去尝试 process 获取 port 了 XD。
网络层是我目前学习内核遇到最复杂的一部分,涉及的知识点太多,接口非常古老,缺乏文档,需要好好理解上述代码如何处理 packet 的话,我还得阅读 RFC 对 TCP/UDP/IP 等协议的规定。所以我选择了放弃,还是学点其他的知识好了。
在阅读 BPF 论文的时候,也对这些能做出厉害东西的程序员十分叹服。同时也觉得有些时候我们认为一些技术非常神秘难懂,觉得非常黑科技,但如果能有源码可读,能有论文可辅助,其实原理并不是很难。难的是发明这些技术的人,不仅能理解和掌握这么复杂的技术,而且能把这些离散的点连接起来创造出厉害的东西。

书接上回,我们讨论了如何使用 Unix 的 sysctl()接口以及 Unix Domain Socket 来获取系统 network interface 的流量信息。
我们是从 Activity Monitor.app 开始的,这个 App 不仅能显示整体网卡的流量,还能分进程显示。这回我们还是在 macOS 上实验,看看有没有方法也跟他一样实现进程流量监控。
先说结论: 以我的微末道行,暂未发现靠谱且简单实现方案。有简单的,不靠谱;有靠谱的,不简单。😂
希望知道简单靠谱方案的读者朋友可以分享一下。
使用 otool -l 我们可以看到 Activity Monitor.app 用了一个私有的系统库:
/System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics
这个库同时也用在了 macOS 的 nettop 命令上。所以如果我们直接调用这个库的 API 那就非常省时省力了。
使用 class-dump 把它的头文件 dump 出来:
class-dump /System/Library/PrivateFrameworks/NetworkStatistics.framework/Versions/A/NetworkStatistics
@interface NWStatisticsManager : NSObject
{}
- (BOOL)addAllUDP:(unsigned long long)arg1;
(BOOL)addAllTCP:(unsigned long long)arg1;
这个可疑的类和接口想必就是我们要寻找的答案了。接下来就是凭经验观察接口猜想看看这些接口怎么用了。我实验过可以非常轻松地获得进程 pid,进程名字 processName,和对应的 rxBytes, rtBytes。
首先,把 dump 出来的头文件引入自己的工程,同时把 NetworkStatistics.framework 加入 Link Binary With Libraries 列表。这一步比较简单各位可以自行 Google。
我们以 TCP 为例看看如何使用它的接口:
NWStatisticsManager *mgr = [[NWStatisticsManager alloc] init];
mgr.delegate = self;
[mgr addAllTCP:0];
加完 source 之后会通过回调告诉你所有的 TCP 连接的建立和销毁:
@protocol NWStatisticsManagerDelegate <NSObject>
@optional
- (void)statisticsManager:(NWStatisticsManager *)arg1 didReceiveDirectSystemInformation:(NSDictionary *)arg2;
- (void)statisticsManager:(NWStatisticsManager *)arg1 didRemoveSource:(NWStatisticsSource *)arg2;
(void)statisticsManager:(NWStatisticsManager *)arg1 didAddSource:(NWStatisticsSource *)arg2;
@end
我们获得 NWStatisticsSource 之后要加入它的 delegate 等待回调:
- (void)sourceDidReceiveCounts:(NWStatisticsSource *)arg1 { NWStatisticsTCPSource *tcp = (NWStatisticsTCPSource *)arg1; NWSTCPSnapshot *snapshot = [tcp currentSnapshot];NSLog(@"NWStatisticsManager rx: %llu", snapshot.rxBytes); NSLog(@"NWStatisticsManager tx: %llu", snapshot.txBytes); NSLog(@"NWStatisticsManager processName: %@", snapshot.processName); NSLog(@"NWStatisticsManager processID: %d", snapshot.processID);
}
有数据变化的时候这个回调会被 called 我们就可以愉快地获取各个进程的 tx/rx 数据了,不仅有 bytes, 还有 packets 数据。
但是正如前文所述,此法简单,却不靠谱。
NWStatisticsManager 作为一个非常上层的接口,经常变更。比如旧版本的接口就是 C 风格的:
void *NStatManagerCreate(CFAllocatorRef allocator, dispatch_queue_t queue, void (^)(void *)); void NStatManagerDestroy(void *manager);void NStatSourceSetRemovedBlock(void *source, void (^)()); void NStatSourceSetCountsBlock(void *source, void (^)(CFDictionaryRef)); void NStatSourceSetDescriptionBlock(void *source, void (^)(CFDictionaryRef));
void NStatManagerAddAllTCP(void *manager); void NStatManagerAddAllUDP(void *manager);
有兴趣的朋友可以参考这里: *OS Internals::User Space
接口变更就意味着一旦系统升级我们的代码就得跟着改,而且是从头猜一遍他的接口应该怎么用。又由于里面的实现是黑盒的,我们的猜想不一定对,所以很容易出现用错接口和 Crash。
留意到 NetworkStatistics.framework 里面用到的数据结构有 nstat_msg_hdr,据此我们猜测他用了内核的 nstat.h 里的接口。既然上层接口经常改,那么内核接口即使改应该也不会太频繁吧?直接上 nstat 可乎?
先说结论:相对比较靠谱,但是非常不简单。
我们需要的很多数据在内核代码里也被标记为 PRIVATE:
#define PRIVATE
这些私有的数据结构和 API 都不会公开到 Xcode 能引用的头文件里,比如说最重要的文件 ntstat.h 整个都是 private。所以为了让 Xcode 能编译通过,我们得把这个头文件手动 copy 过来,附带的还有 tcp.h, in_stat.h, net_api_stats.h 等多个文件。
跟上一篇讲 ppp connect 一样,我们需要创建一个 socket 跟内核进行 IPC 通信,不过这次不是用户空间的 AF_LOCAL 而是系统的 AF_SYSTEM/PF_SYSTEM。这是 Darwin XNU 专有的一种 Protocol Family,其他 Unix 系统并未实现。用于用户态的进程请求内核态进程的数据。
对于 PF_SYSTEM 类型的 socket,XNU 提供了两种协议,分别是: SYSPROTO_EVENT 和 SYSPROTO_CONTROL。详情可参考: http://newosxbook.com/bonus/vol1ch16.html
SYSPROTO_EVENT 用于监听内核提供的事件,通过 kev_request 传参,创建后 WiFi 切换、扫描事件,IP 地址更新等各种事件都会通过 socket 消息通知过来。
SYSPROTO_CONTROL 这个就是我们要找的主角了。这个 sockect 给用户空间和 XNU 内核空间的 providers 进程提供了控制通道,一般在 kernel extension 用的比较多,用户空间的 App 几乎没用到。并且,接口全部没有文档。
SYSPROTO_CONTROL 的 providers 用反域名作为 ID,一般都是 Apple 自己的代码,所以是 com.apple 开头,NetworkStatistics.framework 用到的 provider 叫做 com.apple.network.statistics。
我们需要使用 ioctl() 接口跟这个家伙通信,我们常用的 ifconfig 命令也是通过这个方法。
由于根本没有文档,所以如何创建并连接上这个东西就非常困难,对着 XNU 的 ntstat 实现代码看半天也没用,因为他是通过 ioctl 模块通信的。好在 Apple Open Source 有开源 netstat 的代码,我们可以通过它的代码学习一下,删掉错误处理之后代码如下:
struct sockaddr_ctl sc; struct ctl_info ctl; int fd; // 创建一个 PF_SYSTEM socket, protocol 为 SYSPROTO_CONTROL,用于 ioctl() 函数 fd = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL);/* Get the control ID for statistics */ bzero(&ctl, sizeof(ctl)); strlcpy(ctl.ctl_name, NET_STAT_CONTROL_NAME, sizeof(ctl.ctl_name)); // 创建完 socket 之后要先调用 ioctl 获取 ctl_info,我们需要里面的 ctl_id 才能连接 socket ioctl(fd, CTLIOCGINFO, &ctl)
/* Connect to the statistics control / bzero(&sc, sizeof(sc)); sc.sc_len = sizeof(sc); sc.sc_family = AF_SYSTEM; sc.ss_sysaddr = SYSPROTO_CONTROL; sc.sc_id = ctl.ctl_id; sc.sc_unit = 0; // 连接 socket connect(fd, (struct sockaddr)&sc, sc.sc_len)
/* Set socket to non-blocking operation */ // 使用 fcntl() 函数把 socket 读取设置为非阻塞读取 fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK)
如此就成功创建了一个跟 "com.apple.network.statistics" 通信的 socket 了。
接下来要发送 add source 请求,跟上面使用 NWStatisticsManager 的时候差不多。netstat的源码是发一个 NSTAT_PROVIDER_IFNET 类型的请求:
nstat_msg_add_src_req *addreq; nstat_msg_src_added *addedmsg; nstat_ifnet_add_param *param; char buffer[sizeof(*addreq) + sizeof(*param)]; ssize_t result; const u_int32_t addreqsize = offsetof(struct nstat_msg_add_src, param) + sizeof(*param);/* Setup the add source request */ addreq = (nstat_msg_add_src_req )buffer; param = (nstat_ifnet_add_param)addreq->param; bzero(addreq, addreqsize); addreq->hdr.context = (uintptr_t)&buffer; addreq->hdr.type = NSTAT_MSG_TYPE_ADD_SRC; // 操作是 add source addreq->provider = NSTAT_PROVIDER_IFNET; // 关注的是 ifnet,还可以关注 TCP/UDP 等多个 provider bzero(param, sizeof(*param)); param->ifindex = ifparam->ifindex; param->threshold = ifparam->threshold;
/* Send the add source request */ result = send(fd, addreq, addreqsize, 0);
发送后收到的请求如下:
addedmsg = (nstat_msg_src_added *)buffer; result = recv(fd, addedmsg, sizeof(buffer), 0);// addedmsg->hdr.type == NSTAT_MSG_TYPE_SRC_ADDED
// 这里我们收到了一个 source 指针,发送NSTAT_MSG_TYPE_GET_SRC_DESC请求时需要用到这个指针 outsrc = addedmsg->srcref;
检查 interface 状态的部分我们就不看了,也是一样发个请求收个消息,我们直接看 src descriptor 的。
nstat_msg_get_src_description *dreq; nstat_msg_src_description *drsp; char buffer[sizeof(*drsp) + sizeof(*ifdesc)]; ssize_t result; const u_int32_t descsize = offsetof(struct nstat_msg_src_description, data) + sizeof(nstat_ifnet_descriptor);dreq = (nstat_msg_get_src_description *)buffer; bzero(dreq, sizeof(*dreq)); dreq->hdr.type = NSTAT_MSG_TYPE_GET_SRC_DESC; dreq->srcref = srcref; // 这个就是刚才上一步收到的 source 指针 result = send(fd, dreq, sizeof(*dreq), 0);
// 这里接收到 nstat_msg_src_description 了 drsp = (nstat_msg_src_description *)buffer; result = recv(fd, drsp, sizeof(buffer), 0);
// link_status_type 还可以判断是 WiFi 还是 cellular // ifdesc.link_status.link_status_type == NSTAT_IFNET_DESC_LINK_STATUS_TYPE_WIFI
最后把 WiFi 信息打印一下:
en0: 17:38:02 interface state:
wifi status: link_quality_metric: 0 ul_effective_bandwidth: 6695 ul_max_bandwidth: 237641040 ul_min_latency: -1 ul_effective_latency: 0 ul_max_latency: 0 ul_retxt_level: 4(high) ul_bytes_lost: -1 ul_error_rate: 0 dl_effective_bandwidth: 2955 dl_max_bandwidth: 237641040 dl_min_latency: -1 dl_effective_latency: 0 dl_max_latency: 0 dl_error_rate: 8533 config_frequency: 2 config_multicast_rate: -1 scan_count: -1 scan_duration: -1
netstat 命令没有打印所有进程信息,但是如果我们阅读 XNU 源码,这个 provider 支持返回 nstat_tcp_descriptor 这种数据,里面可是带了 pid 的。我们可以试着获取 TCP Descriptor 看看。
这里我还是只能靠经验瞎猜,同时阅读 XNU 关于 ntstat 的实现代码,没有特别好的方法。如果读者朋友有比较聪明的方法请分享一下,非常需要😂。
我们看到 nstat_tcp_descriptor 这个数据的 copy 在 nstat_tcp_copy_descriptor() 函数,这个函数的指针被赋值给 nstat_tcp_provider.nstat_copy_descriptor。所以我们需要这个 tcp_provider 给我们这些信息。
所以我们猜测,先添加 tcp provider source,然后进行再获取他的 src description 就能获得这些数据。实验核心代码如下:
nstat_msg_add_all_srcs *addreq;char buffer[sizeof(*addreq)]; ssize_t result; const u_int32_t addreqsize = sizeof(struct nstat_msg_add_all_srcs);
/* Setup the add source request */ addreq = (nstat_msg_add_all_srcs *)buffer; bzero(addreq, addreqsize); addreq->hdr.length = sizeof(nstat_msg_add_all_srcs); addreq->hdr.context = 3; // 随便填 addreq->hdr.type = NSTAT_MSG_TYPE_ADD_ALL_SRCS; // 所有 sources addreq->provider = NSTAT_PROVIDER_TCP_KERNEL;
result = send(fd, addreq, addreqsize, 0);
一开始填 NSTAT_MSG_TYPE_SYSINFO_COUNTS 这个最大值,我一直收到 error。且确认就是在 nstat_control_begin_query() 函数里返回的 EAGAIN 错误码:
// man 2 intro | less -Ip EAGAIN
35 EAGAIN Resource temporarily unavailable. This is a temporary condi-
tion and later calls to the same routine may complete normally.
正准备放弃的时候,看到 libnstat 这个用 C++ 实现的库在这里填的参数是 2。他的头文件定义是 NSTAT_PROVIDER_TCP = 2 但我看到的 XNU 头文件却把内核空间与用户空间分开了:
enum
{
NSTAT_PROVIDER_NONE = 0
,NSTAT_PROVIDER_ROUTE = 1
,NSTAT_PROVIDER_TCP_KERNEL = 2
,NSTAT_PROVIDER_TCP_USERLAND = 3
,NSTAT_PROVIDER_UDP_KERNEL = 4
,NSTAT_PROVIDER_UDP_USERLAND = 5
,NSTAT_PROVIDER_IFNET = 6
,NSTAT_PROVIDER_SYSINFO = 7
};
换成 NSTAT_PROVIDER_TCP_KERNEL 之后能成功连接上 socket,但是 get src description 却返回错误的数据。本想继续研究但是看到 libnstat 项目里针对不同版本的内核也用了不同的头文件和 cpp 实现,说明 Apple 对这部分代码的修改也还算比较频繁的。目前我使用的系统版本是 macOS Catalina 10.15 (19A583),xnu 版本是: 6153.11.26~2。libnstat 项目准备了 5 个不同版本的 nstat.h 文件,他的项目里最新的是 xnu-4570.1.46。所以有理由猜想是内核又更新了这部分代码,不过无论如何,到这一步已经可以证明结论:
使用 nstat.h 的接口,不仅非常复杂,而且也不靠谱。
没想到 nstat 相关的内容也这么复杂,学习起来还是挺费劲的。本章我们通过 class-dump 私有库 NetworkStatistics.framework 的头文件接口,凭经验猜测和实验,用上了这个相对上层的接口,实现了网络包统计。
接着我们尝试往下一层,通过 ioctl() 接口,使用 PF_SYSTEM 这种 XNU 独有的 socket 跟内核通信,从 com.apple.network.statistics 这个 provider 那里读取网络统计信息。
但是这两种方法首先都使用到系统的私有方法,并且这两个东西历史上都有过比较大的 API 变动。framework 的接口好猜但变化频繁,nstat 的接口变化稍微少一点但是几乎没有文档,学习起来非常痛苦。
总而言之就是这两个方法都不靠谱,那么有没有其他更有意思的方法呢?下一篇我们来试试 BPF (Berkeley Packet Filter)。
P.S. 传 req 的时候我发现仅存可供参考的代码都没有传 hdr.length,同时内核代码有一段注释,说为了兼容旧版 client 的实现,拿到 hdr.length 如果为空就补刀一下。所以是内核本来为了兼容旧版的补刀逻辑让现在新实现的人都不填 length 了。😂

可以说这个世界有了网络之后,重新了计算机。网络是目前所有 PC 和手机设备不可或缺的东西。同时飞速发展的互联网行业也让这一层的技术更迭迅速,衍生出无数计算机网络技术。
由于涉及的概念和技术点太多,所以一时半会我也不知从何学起,看到 Activity Monitor.app 的 Network 一项系统能够统计的数据挺多的,不如就试试做拿跟他一样的信息看看。
讲道理我们的 App 和系统自带的 App 都是跑在用户空间的,大家用的 API 也差不多,他能做到我们也能做到对吧。
事实证明我还是太天真了😂。
有学过计算机网络的朋友应该都听说过 OSI Model(Open Systems Interconnection model),把计算机网络分为七层:
| # | Layer |
|---|---|
| 7 | Application (应用层, HTTP) |
| 6 | Presentation (表现层, HTTP) |
| 5 | Session (会话层, HTTP) |
| 4 | Transport (传输层, TCP) |
| 3 | Network (网络层, IP) |
| 2 | Data link (链路层, Frames) |
| 1 | Physical (物理层,Bits) |
这是 ISO 提出的逻辑分层标准,好处是分层隔离之后,各层的技术自行更新时不会影响到其他层的逻辑,比如最底层的 Physical Layer (物理层)发展到现在的万兆光纤,它只需要关心 Bits 怎么传输就行,上层的逻辑几乎不需要更新。
但是人们实现这个分层标准的时候也并不完全按照分层来,比如最上面的几层,应用层(Application Layer)提供面向用户的协议比如 HTTP,其中数据压缩本来是表现层(Presentation Layer)的事情但是 HTTP 支持 Compression。然后 TLS/SSL 在传输层但是它支持加解密。
实际上 TCP/IP Model (Internet protocol suite) 的四层模型比 OSI 七层简化了一些,也相对比较贴近大家的使用习惯。
| # | Layer |
|---|---|
| 4 | Application Layer (应用层, HTTP/ IMAP…) |
| 3 | Transport Layer (传输层, TCP/UDP…) |
| 2 | Internet Layer (网络层, IP/ICMP…) |
| 1 | Link Layer (链路层, MAC/PPP…) |
以 OSI 七层模型来看,XNU 内核负责的主要是第 2 到第 5 层, TCP/IP 模型则是 1 到 3 层(我们熟悉的 URLSession 是上层提供的,不在内核实现)。
第 2 层里 XNU 提供了网络相关的 interface。如果在终端运行 ifconfig 的话大家会看到一堆信息,以 en0, lo0 开头的。这些是 device interface names,对应了物理或者虚拟网卡,这些设备不在 /dev 里表现,用户空间如果要访问它们就必须通过 Unix domain socket 进行通信(有别于 IP socket,下文将有描述)。
所以如果我们要统计一台机器的网络流量,我们可以通过获取主要网卡的流量信息来解决。
开源的系统监控软件 GKrellM 项目在 macOS 上的实现就是通过 sysctl() 获取网卡数据来统计网络流量,实现入口在 src/sysdeps/bsd-common.c 里的 void gkrellm_sys_net_read_data(void) 函数。
我们在本 macOS 内核系列的第一篇有提到过利用 sysctl() 函数可以从内核获取很多有用的系统信息,同时系统也提供了 sysctl 命令可以在终端运行。sysctl 基本上是所有类 Unix 系统的标准命令之一。在 XNU 内核中,sysctl以及网络相关的接口由 BSD 内核实现。
另一个非常常见的命令是 ifconfig,运行它可以获取我们所有网卡(network interface)信息。ifconfig 的代码是开源的可以在这里找到。
系统内核会维护一份以树形 MIB (management information base)形式存储的数据,里面包含了硬件信息、网络统计信息等一大堆数据,sysctl 接口会读取 MIB 数据然后返回。我们也可以通过别的接口来获取这些数据(下文将有介绍),但是 sysctl 接口很方便也很快。
sysctl的 MIB 存储划分为多种类型,内存 vm, 网络 net, 硬件 hw 之类的。可以通过 sysctl -A 命令打出来。
sysctl 不仅可以读数据,也可以写数据。该函数原型 XNU 没有注释,我们(可以参考这里)在 Linux 上的定义:
int sysctl (int *name,
int nlen,
void *oldval,
size_t *oldlenp,
void *newval,
size_t newlen);
name: 一个整数的数组,里面是查询参数nlen: 第一个参数里有多少个整数oldval: 存储的数据通过这个指针返回,有可能为 NULLoldlenp: 存储的数据的长度newval: 用该参数写入新数据到 MIB,传 NULL 则不修改newlen: 新数据的长度在 GKrellM 里获取网卡信息的实现分为两步,第一步先取数据长度 oldlenp:
static int mib_net[] = { CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST, 0 }; static char *buf; static int alloc; size_t needed;
if (sysctl(mib_net, 6, NULL, &needed, NULL, 0) < 0) return;
第二步,取到长度之后分配一个足够长的内存然后正式读数据:
if (alloc < needed) { if (buf != NULL) free(buf); buf = malloc(needed); if (buf == NULL) return; alloc = needed; }
if (sysctl(mib_net, 6, buf, &needed, NULL, 0) < 0) return;
net 前缀在宏定义里是 CTL_NET。
PF_ROUTE 是路由表相关的操作。前缀 PF_ 是 Protocol Family 的意思,对应的还有 AF_ Address Family。在 XNU 里,PF_ 和 AF_ 的定义是完全一样的(Linux 也是)。
前面说跟 interface 打交道得通过 Unix domain socket(跟 IP socket 稍有不同),要创建 一个 Unix domain socket,第一个参数就是 Protocol Famil。我们知道 XNU 包含了 Mach 内核和 FreeBSD 内核,它本身最常用的 IPC 方式是 Mach 内核提供的 Mach Port 方式,BSD 提供的这种 socket 方式其实比较少见。
BSD 中创建 socket 使用 socket() 函数:
int socket (int family, int type, int protocol);
第一个参数是 family,指的其实是 Protocol Family,也就是 PF_ 开头的参数,但实际上我们可以用 AF_ 来代替,这是一个历史遗留产物。在的书中提到:
(This PF_INET thing is a close relative of the AF_INET that you can use when initializing the sin_family field in your struct sockaddr_in. In fact, they’re so closely related that they actually have the same value, and many programmers will call socket() and pass AF_INET as the first argument instead of PF_INET. Now, get some milk and cookies, because it’s time for a story. Once upon a time, a long time ago, it was thought that maybe an address family (what the “AF” in “AF_INET” stands for) might support several protocols that were referred to by their protocol family (what the “PF” in “PF_INET” stands for). That didn’t happen. And they all lived happily ever after, The End. So the most correct thing to do is to use AF_INET in your struct sockaddr_in and PF_INET in your call to socket().)
大意是说以前大家曾经试图在 socket 上抽象出一个 Protocol Family 的概念,允许一个 Address Family 支持多种协议。但是这件事情一直没人实现过😂,所以遗留了这么个东西。Unix 和 Linux 的定义都是直接把 PF_ 开头的宏定义为同名的 AF_ 宏。
第二个参数是 socket 类型:
/*
* Types
*/
#define SOCK_STREAM 1 /* stream socket */
#define SOCK_DGRAM 2 /* datagram socket */
#define SOCK_RAW 3 /* raw-protocol interface */
#if !defined(_POSIX_C_SOURCE) || defined(_DARWIN_C_SOURCE)
#define SOCK_RDM 4 /* reliably-delivered message */
#endif /* (!_POSIX_C_SOURCE || _DARWIN_C_SOURCE) */
#define SOCK_SEQPACKET 5 /* sequenced packet stream */
第三个是协议类型,比如 UDP, TCP:
// bsd/netinet/in.h
#define IPPROTO_UDP 17 /* user datagram protocol / #define IPPROTO_TCP 6 / tcp */
bsd/netinet/in.h 里还定义了上百个,我已放弃学习🤦♂️。
在 IPv4 网络中,第一个参数我们传 PF_INET,IP 地址会保存在 sockaddr_in 结构体中:
struct sockaddr_in {
short sin_family;
u_short sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
IPv6 则是 PF_INET6,XNU 的相关定义在 bsd/netinet/in.h。
PF_ROUTE 获取的是系统路由表相关的信息,XNU 没什么文档,但是这是一个 BSD 标准,所以我们可以参考 NetBSD 关于网络的文档。BSD 中关于路由表的实现分为三个部分,以 Radix Tree (基数树)存储的数据库 net/radix.c,提供查询和修改接口的 net/route.c,以及提供给上层的 socket 接口 net/rtsock.c。系统的 route(8) 命令有用到 PF_ROUTE,可以到 Apple Open Source 找到源码。
在用户空间,我们和路由表的交互都是通过 protocol family 为 PF_ROUTE 的 socket 来跟 network interface 通信的。
BSD 的 Network Routing 层负责转发数据包 packet 到目标网关,涉及到 ARP 解析(也就是 IP 地址与 Mac 地址的映射)。比如说一个 TCP/IP 协议的包到了路由这一层,就会根据 IP 地址寻找到目标网卡,把包发过去,比如发到 WiFi 网卡。所以我们可以通过路由这一层获得某一个网卡上所有的收发包数据,从而实现流量监控。
我们通过 sysctl() 接口获取信息的时候,这个 socket 是由内核创建的,我们只需要传参数就行。可以参考 FreeBSD 关于 sysctl(3) 的文档。
static int mib_net[] = { CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST, 0 };
留意到这里其实传了六个参数,CTL_NET 和 PF_ROUTE 已经解释过了。第三参数 0 是 hardcoded 的,以前留给 Protocol Family 的。第四个是 Address Family,这里填 0 可以表示获取所有 Family。第五个和第六个是有关联的,具体参考 FreeBSD 文档,我们只要知道传 NET_RT_IFLIST 时后面一个传 0。
最近阅读内核代码,碰到这种有历史的 C 接口感觉都非常依赖文档,如果没有文档几乎寸步难行。T_T
The NET_RT_IFLISTL is like NET_RT_IFLIST, just returning message
header structs with additional fields allowing the interface to
be extended without breaking binary compatibility.The NET_RT_IFLISTL uses 'l' versions of the message header struc-
tures: struct if_msghdrl and struct ifa_msghdrl.
根据文档,NET_RT_IFLIST 会返回 message header structs,用的是这个结构体 if_msghdr。
struct if_msghdr {
u_short ifm_msglen; /* to skip over non-understood messages */
u_char ifm_version; /* future binary compatibility */
u_char ifm_type; /* message type */
int ifm_addrs; /* like rtm_addrs */
int ifm_flags; /* value of if_flags */
u_short ifm_index; /* index for associated ifp */
struct if_data ifm_data; /* statistics and other data about if */
};
sysctl 返回的是一个数组,包含多个 if_msghdr 结构体,ifm_msglen 用于指针偏移量。我们可以通过一个循环来取每个 message header。
struct if_msghdr *ifmsg = (struct if_msghdr *)currentData;
if (ifmsg->ifm_type != RTM_IFINFO) {
currentData += ifmsg->ifm_msglen;
continue;
}
这里只关心 RTM_IFINFO 这种类型,相关定义还有十几个,在 bsd/net/route.h 的 RTM_ 开头的宏。
if (ifmsg->ifm_flags & IFF_LOOPBACK) {
currentData += ifmsg->ifm_msglen;
continue;
}
我们只关心真正和互联网通信的 interface,所以过滤本地 loopback 网络。这里我们可以简单理解包含了 localhost 的特殊网卡(可以参考这里),如果你在终端运行 ifconfig 看到 lo 开头的就是 loopback interface。
struct sockaddr_dl *sdl = (struct sockaddr_dl *)(ifmsg + 1);
if (sdl->sdl_family != AF_LINK) {
currentData += ifmsg->ifm_msglen;
continue;
}
把 ifmsg 这个 if_msghdr + 1 我们得到 Header 之后的内存地址,也就是 sockaddr_dl 数据,这个数据是 Link-Level sockaddr。我们先取 sdl_family,如果是 AF_LINK 就说明我们的结构体取对了。这里取得 sockaddr_dl 之后, sdl_data 的前 sdl_nlen 长度的数据就是他的名字,后面的是 ll address。
/*
* Structure of a Link-Level sockaddr:
*/
struct sockaddr_dl {
u_char sdl_len; /* Total length of sockaddr */
u_char sdl_family; /* AF_LINK */
u_short sdl_index; /* if != 0, system given index for interface */
u_char sdl_type; /* interface type */
u_char sdl_nlen; /* interface name length, no trailing 0 reqd. */
u_char sdl_alen; /* link level address length */
u_char sdl_slen; /* link layer selector length */
char sdl_data[12]; /* minimum work area, can be larger;
* contains both if name and ll address */
#ifndef __APPLE__
/* For TokenRing */
u_short sdl_rcf; /* source routing control */
u_short sdl_route[16]; /* source routing information */
#endif
};
我们直接读 sdl_data 里 sdl_nlen 这么长的数据,得到 interface name:
NSString *interfaceName = [[NSString alloc] initWithBytes:sdl->sdl_data length:sdl->sdl_nlen encoding:NSASCIIStringEncoding];
接下来检查这个 interface 有没有在跑:
if (ifmsg->ifm_flags & IFF_UP)
然后就可以读 ifmsg 的 if_data 数据了:
/*
* Structure describing information about an interface
* which may be of interest to management entities.
*/
struct if_data {
/* generic interface information */
u_char ifi_type; /* ethernet, tokenring, etc */
u_char ifi_typelen; /* Length of frame type id */
u_char ifi_physical; /* e.g., AUI, Thinnet, 10base-T, etc */
u_char ifi_addrlen; /* media address length */
u_char ifi_hdrlen; /* media header length */
u_char ifi_recvquota; /* polling quota for receive intrs */
u_char ifi_xmitquota; /* polling quota for xmit intrs */
u_char ifi_unused1; /* for future use */
u_int32_t ifi_mtu; /* maximum transmission unit */
u_int32_t ifi_metric; /* routing metric (external only) */
u_int32_t ifi_baudrate; /* linespeed */
/* volatile statistics */
u_int32_t ifi_ipackets; /* packets received on interface */
u_int32_t ifi_ierrors; /* input errors on interface */
u_int32_t ifi_opackets; /* packets sent on interface */
u_int32_t ifi_oerrors; /* output errors on interface */
u_int32_t ifi_collisions; /* collisions on csma interfaces */
u_int32_t ifi_ibytes; /* total number of octets received */
u_int32_t ifi_obytes; /* total number of octets sent */
u_int32_t ifi_imcasts; /* packets received via multicast */
u_int32_t ifi_omcasts; /* packets sent via multicast */
u_int32_t ifi_iqdrops; /* dropped on input, this interface */
u_int32_t ifi_noproto; /* destined for unsupported protocol */
u_int32_t ifi_recvtiming; /* usec spent receiving when timing */
u_int32_t ifi_xmittiming; /* usec spent xmitting when timing */
struct IF_DATA_TIMEVAL ifi_lastchange; /* time of last administrative change */
u_int32_t ifi_unused2; /* used to be the default_proto */
u_int32_t ifi_hwassist; /* HW offload capabilities */
u_int32_t ifi_reserved1; /* for future use */
u_int32_t ifi_reserved2; /* for future use */
};
我们只统计流量所以只关心这两个数值:
u_int32_t ifi_ibytes; /* total number of octets received */
u_int32_t ifi_obytes; /* total number of octets sent */
跟获取 CPU 信息的原理差不多,上面的数据是一个累计数值,但是我们要计算的是一个瞬时速率,所以得获取两次数据作比较。
这里 ifi_ibytes 和 ifi_obytes 使用 u_int32_t 存的,但是内核在计算这个数值的时候会一直累加,也就是说这个数据会 overflow (溢出)。计数增长的方法在 XNU 源码的 bsd/net/kip_interface.c 里面:
if (s->bytes_in != 0)
atomic_add_64(&ifp->if_data.ifi_ibytes, s->bytes_in);
所以如果我们要计算数据累加量的话,要自己处理这个 u_int32_t 的大小变化,如果发现保存的上一次的 ifi_ibytes 大于新的数值,说明新的数值已经溢出变小了。
P.S. 所有的网络监控软件都无法统计到历史数据,只能统计他开始监控那一刻起的数据。系统内核因为是第一个启动的,所以它能统计到的数据一定比我们多。
以上的处理是针对非 PPP 连接的 interface 的数据处理,PPP interface 比较麻烦,需要自建 socket 跟 interface 通信。在开始 PPP 连接处理之前,我们先岔开看看 interface naming。
留意到在 macOS 上运行 ifconfig 和在 Linux 上看到的 interface 命名规则有点不同:
# macOS lo0: … gif0: … stf0: … en0: … en1: … bridge0: … p2p0: … awdl0: … llw0: … utun0: … utun1: …Ubuntu
eth0: … lo0: …
interface 命名规则是由操作系统自己实现的,BSD 和 Linux 各有自己的规则。早期的 Linux 系统会只有 eth[0123…],根据内核启动时发现这些硬件的序号来命名。后来才加了 Consistent Network Device Naming feature。
在 Unix 系统上,这些 interface 会根据不同的类别有不同的前缀,《Mac OS X and iOS Internals》这本书的 Chap 17,Layer II: INTERFACES 对此命名规则有过介绍。大家可以参考看看。
主要分为两大类,一类是 XNU 原生支持的 interfaces,比如 bridge 和 lo。另一类是通过 Kernel Extension 支持的 interfaces,比如 en 和 ppp。
en 的支持在 IONetworkingFamily kext 里,对应的是 Ethernet (以太网)标准,在我的 MacBook 上 en0 是无线网卡,如果接上有线网卡会多出来一个 en1,前缀是类型,后缀数字区分不同硬件。
ppp 在 PPP kext 里,支持 PPP 点对点协议。平时我们最常见到这个协议的应用就是 PPPoE (Point-to-Point Protocol over Ethernet) 了,这个协议主要是在 Ethernet 协议上加了一层身份认证和传输加密,这样电信运营商才可以知道你的帐号,判断你有没有交钱。如果你的机器通过 WiFi 连接到家里的路由器,那么我们只管看 en interface 的数据就好,但是你也有可能直接通过你的 Mac PPPoE 拨号上网,那就得统计 PPP 端口了。
PPP interface 的数据处理起来比较麻烦,sysctl() 并没有直接返回数据,我们得另起一个 UNIX domain socket 跟它进行 IPC 通信(参考 MenuMeters的实现)。
UNIX domain socket 跟现在常见的 IP socket 不一样,不过接口差不多。UNIX domain socket 是 UNIX 独有的 IPC 通信方式,出现比 IP socket 还在,它可以用本地文件系统的路径作为 socket 地址(虽然不是真的文件,大部分都在 /var/run 里面),可以直接通过 socket 传文件。当然 Mach Port 也可以传 file descriptor,我们之前的文章也有介绍过。不过Mach Port 和这种特殊 socket 都不是 POSIX 标准。
// PPP local socket path #define kPPPSocketPath "/var/run/pppconfd\0"
pppconfdSocket = socket(AF_LOCAL, SOCK_STREAM, 0); struct sockaddr_un socketaddr = { 0, AF_LOCAL, kPPPSocketPath }; if (connect(pppconfdSocket, (struct sockaddr *)&socketaddr, (socklen_t)sizeof(socketaddr))) { NSLog(@"MenuMeterNetPPP unable to establish socket for pppconfd. Abort."); return nil; }
首先创建一个 UNIX domain socket,然后连接到 pppconfd:
int pppconfdSocket = socket(AF_LOCAL, SOCK_STREAM, 0);
struct sockaddr_un socketaddr = { 0, AF_LOCAL, kPPPSocketPath };
if (connect(pppconfdSocket, (struct sockaddr *)&socketaddr, (socklen_t)sizeof(socketaddr))) {
NSLog(@"MenuMeterNetPPP unable to establish socket for pppconfd. Abort.");
return nil;
}
AF_LOCAL 就是 UNIX domain socket 类型,这种类型的 socket 只支持 SOCK_STREAM + TCP 或者 SOCK_DGRAM + UDP,所以第三个参数可以不传。接下来通过 connect 函数连接两个 socket。
// Create the filehandle
pppconfdHandle = [[NSFileHandle alloc] initWithFileDescriptor:pppconfdSocket];
if (!pppconfdHandle) {
NSLog(@"MenuMeterNetPPP unable to establish file handle for pppconfd. Abort.");
return nil;
}
ObjC 的 NSFileHandle 可以来做 socket 通信,一个 writeData: 一个 readDataOfLength: 一发已收。
- (NSData *)pppconfdExecMessage:(NSData *)message {// Write the data [pppconfdHandle writeData:message]; // Read back the reply headers NSData *header = [pppconfdHandle readDataOfLength:sizeof(struct ppp_msg_hdr)]; if ([header length]) { struct ppp_msg_hdr *header_message = (struct ppp_msg_hdr *)[header bytes]; if (header_message && header_message->m_len) { NSData *reply = [pppconfdHandle readDataOfLength:header_message->m_len]; if ([reply length] && !header_message->m_result) { return reply; } } } // Get here we got nothing return nil;
} // pppconfdExecMessage
接下来先查一下 interface status,我们跟 pppconfd 发一个 PPP 消息:
struct msg { struct ppp_msg_hdr hdr; unsigned char data[MAXDATASIZE]; };/* PPP message paquets */ struct ppp_msg_hdr { u_int16_t m_flags; // special flags u_int16_t m_type; // type of the message u_int32_t m_result; // error code of notification message u_int32_t m_cookie; // user param u_int32_t m_link; // link for this message u_int32_t m_len; // len of the following data };
struct ppp_msg { u_int16_t m_flags; // special flags u_int16_t m_type; // type of the message u_int32_t m_result; // error code of notification message u_int32_t m_cookie; // user param, or error num for event u_int32_t m_link; // link for this message u_int32_t m_len; // len of the following data u_char m_data[1]; // msg data sent or received };
PPP 的实现不在 XNU 内核范围内,但也是开源的,可以到这里下载源码。可以看到不管是 struct msg 还是 struct ppp_msg 他的内存布局都是一样的,前面是 header 后面是数据。
看到我们跟 PPP 通信需要带一个 m_link 参数,因为 PPP 协议是基于 link 进行数据传输的。PPP 协议主要由三个部分组成:
其中 LCP 协议规定了 PPP 端口通过 link 传输。并且,PPP 协议支持一点对多点通信,这也是为什么我们家里的宽带有可能通过多拨实现带宽翻倍的原因。多连接协议称为 Multi-Link PPPoE (MLPPP)。
所以要跟 pppconfd 通信前我们还需要先拿到当前的 link:
// Get the link id for the interface
struct ppp_msg_hdr idMsg = { 0, PPP_GETLINKBYIFNAME, 0, 0, -1, (u_int32_t)[ifnameData length] };
NSMutableData *idMsgData = [NSMutableData dataWithBytes:&idMsg length:sizeof(idMsg)];
[idMsgData appendData:ifnameData];
NSData *idReply = [self pppconfdExecMessage:idMsgData];
uint32_t linkID = 0;
if ([idReply length] != sizeof(uint32_t)) return nil;
[idReply getBytes:&linkID];
传入 message type PPP_GETLINKBYIFNAME,带一个 ifname 表示对应的 interface。PPP 源码中对应的实现在这个函数:
static
void socket_getlinkbyifname(struct client *client, struct msg *msg, void **reply)
非常简单,遍历所有端口匹配一下然后 copy 信息返回。
这个函数里的实现用到一个 bytes 转换函数叫做 htonl(),因为 host byte order 和 network byte order 的排序不一样。上层几乎不需要管,但是在后续使用 bpf/pcap 抓包实现的时候就需要自己手动转换这些数据了。
获得 linkID 之后就可以问 PPP 要这条 link 的收发包数据了:
// Now get status of that link
struct ppp_msg_hdr statusMsg = { 0, PPP_STATUS, 0, 0, linkID, 0 };
NSData *statusReply = [self pppconfdExecMessage:[NSData dataWithBytes:&statusMsg length:sizeof(statusMsg)]];
if ([statusReply length] != sizeof(struct ppp_status)) return nil;
struct ppp_status *pppStatus = (struct ppp_status *)[statusReply bytes];
if (pppStatus->status == PPP_RUNNING) {
// pppStatus->s.run.inBytes
// pppStatus->s.run.outBytes
// pppStatus->s.run.timeElapsed
// pppStatus->s.run.timeRemaining
}
数据处理跟上面非 PPP Connection 的一样, PPP_STATUS 在 PPP 源码中对应的实现在:
static
void socket_status(struct client *client, struct msg *msg, void **reply)
本来网络抓包的学习除了通过 sysctl() 接口和 pppconfd 的 socket 通信之外,我还尝试了 NetworkStatistics.framework,NStat, BPF/pcap 等多种实现。但是没想到第一种实现就已经这么复杂,所以我们把剩下的内容分开多篇来学习。
计算机网络的出现是革命性的,互联网已经重塑了整个世界。相应的,他的蓬勃发展也带来技术的蓬勃发展。虽然历史遗留的问题很多,也有些设计上的缺陷经常被人用于恶意攻击(比如 ARP 的设计就非常不安全),但是以我微弱的能力,对于这些计算机先辈的设计只有滔滔景仰的敬意,以及,缺少文档时阅读起来的痛苦😂。

前面几篇关于 XNU 内核学习的文章里,经常会提到有些数据来自启动时外部传入的参数,比如 mem_size。因为内核本身也是一个巨大的程序,它也会被编译成二进制,然后在系统启动的时候加载到内存里,提供给上层诸如多核 CPU 运算,虚拟内存,线程,进程等一系列能力。
那么问题来了,内核是在什么时候被加载到内存里的呢?谁来负责调用内核的入口函数呢?整个计算的启动过程是怎样的呢?
我在阅读了 Amit Singh 的《Mac OS X Internals》一书中跟启动相关的章节之后,想以此文总结记录一下。希望看到详细内容的读者朋友们,我个人非常推荐 Amit 这本书,内容深入浅出,通俗易读。
我们知道系统内核也是一堆代码,XNU 内核就是 C 写的(I/O Kit 部分是 C++),最终会编译成一个二进制。在 macOS 上唯一能执行的二进制格式是 Mach-O。
全称是 Mach object file format,但是较真起来这个文件格式跟 Mach 内核没有半毛钱关系 XD。因为在 XNU 中,文件系统是由 BSD 实现的,Mach 并不识别任何文件系统。
在 macOS 操作的设计中,我们可以访问磁盘上的任何一个文件(当然有权限控制),所以我们也可以找到内核这个二进制,就是 /System/Library/Kernels/kernel。理论上你可以删掉这个文件,或者自己编译一个内核替换他,但是我不建议你这么做😂。
比 OS X 10.11 El Capitan 更早的系统直接就在 /mach_kernel
所以要让内核这个大程序跑起来,首先得有人把这个文件读取后放进内存里,找到入口,然后调用,这个过程大概是这样的:
ROM 即 Read Only Memory,在 PC 中通常是嵌在主板上的一块芯片。有自己折腾过 PC 攒机经验的小伙伴们肯定听说过 BIOS 这个东西。它的全称是 Basic Input/Output Service。CPU 从 ROM 中读取的就是 BIOS,在 Mac 上用的是 Intel 的 Extensible Firmware Interface(EFI) 接口,更老的 PowerPC CPU 则用的是 Open Firmware。
这个接口和硬件强相关,所以是由硬件厂商制定的标准。EFI 是英特尔制定的,目前已经交给 Unified EFI Forum 来维护,接口也改名为 UEFI。
因为这个东西并不是硬件 Hardware,也不是上层跑的软件 Software,所以取了个介乎中间的名字固件 Firmware。这东西是写在硬件上的,有些可以被擦写替换,有些则不可以。之前很火的利用 iOS Firmware 漏洞来越狱的工具非常强大的一点就在于此:这个固件写在硬件上,Apple 无法通过 OTA 让旧机器更新固件,也就无法修复漏洞,所以越狱对于旧机器会一直有效。
这期间你甚至可以基于这个简单的系统开发软件,除了越狱之外还有很多可以做的。《Mac OS X Internals》提到 Open Firmware 还自带了 telnet, tftp 等工具,有点意思。
在 Mac 上以前用的是 BootX,后来 Apple 的所有产品,包括 iOS 都升级为 iBoot 了。这个东西~~也被编译为 Mach-O 文件~~是一个 efi 文件,可以参考这里。这文件就放在这里 /System/Library/CoreServices/boot.efi。代码是闭源的,之前有人放出了泄漏代码在 GitHub 上:https://github.com/h1x0rz3r0/iBoot。不过现在仓库被关闭了。
BootX 的代码是开源的,可以在这里找到: https://opensource.apple.com/tarballs/BootX/
BootX 负责初始化内核运行环境和加载内核,具体的分析可以看《Mac OS X Internals》的 4.10 章节。
前面已经讲过 kernel 是一个 Mach-O 文件,这个文件的结构大概是这样的:

开始加载内核之前,系统提供了 otool 这个工具用于分析 Mach-O 文件,这个有意思我们可以介绍一下。
# file 命令查看 kernel 的文件格式 ➜ Kernels file kernel kernel: Mach-O 64-bit executable x86_64otool 命令 -h 看一下 Mach Header 信息
➜ Kernels otool -hv kernel Mach header magic cputype cpusubtype caps filetype ncmds sizeofcmds flags MH_MAGIC_64 X86_64 ALL 0x00 EXECUTE 18 3968 NOUNDEFS PIE
otool 代码是开源的,可以在这里找到。当我们运行 otool 命令时,会掉进它的 main() 函数,解析一大堆 -h 之类的 flag 之后,会调用内核的 open() 方法打开文件,位于 bsd/vfs/vsf_syscalls.c。
BSD 的 Mach-O 文件读取实现在这个函数:
int
open1(vfs_context_t ctx, struct nameidata *ndp, int uflags,
struct vnode_attr *vap, fp_allocfn_t fp_zalloc, void *cra,
int32_t *retval)
otool -h 取得的是 Mach Header 信息,结构体如下:
/* * The 64-bit mach header appears at the very beginning of object files for * 64-bit architectures. */ struct mach_header_64 { uint32_t magic; /* mach magic number identifier */ cpu_type_t cputype; /* cpu specifier */ cpu_subtype_t cpusubtype; /* machine specifier */ uint32_t filetype; /* type of file */ uint32_t ncmds; /* number of load commands */ uint32_t sizeofcmds; /* the size of all the load commands */ uint32_t flags; /* flags */ uint32_t reserved; /* reserved */ };
/* Constant for the magic field of the mach_header_64 (64-bit architectures) / #define MH_MAGIC_64 0xfeedfacf / the 64-bit mach magic number */
MH_MAGIC_64 和 MH_CIGAM_64 是不同大小端系统定义的常数,莫名有点喜感。
CPU Type 和 SubType 都在 XNU 代码里定义,位于 osfmk/mach/machine.h,一堆 hardcode 的定义。诸如 CPU Type CPU_TYPE_POWERPC64 或者 CPU_TYPE_x86_64 之类的,满满的历史痕迹。SubType 则是虽然大家都是 POWERPC 但也有可能不兼容,如果所有都兼容就是 CPU_SUBTYPE_POWERPC_ALL
filetype 定义在 EXTERNAL_HEADERS/mach-o/loader.h。kernel 打出来是 2,也即是 MH_EXECUTE,可执行文件。
ncmds 是 load commands 有多少条, sizeofcmds 是所有 load commands 加起来的 size,以字节为单位。
详细的 Header 说明这里有篇文章大家可以参考一下: aidansteele/osx-abi-macho-file-format-reference: Mirror of OS X ABI Mach-O File Format Reference。
Load command 就跟在 Mach Header 后面,应该算作 Header 的一部分,再往下就是编译好的二进制文件了。
Load Command 描述了文件的逻辑结构,以及文件在内存里的布局信息。内核执行 Mach-O 文件的实现在 bsd/kern/kern_exec.c,入口是 execve() 方法。在 parse_machfile() 方法中会遍历所有的 load commands 然后执行不同的命令,遇到 LC_MAIN 就会执行 load_main(),创建一个线程,加载函数主入口。
eip 寄存器(下一条指令)Load command 是有很多不同类型的。以前 LC_THREAD 或者 LC_UNIXTHREAD 是函数入口,不过从 10.8 开始就改成 LC_MAIN 了。
现在我们用 otool -l 看看 kernel 的 load commands。
# otool 命令 -l 查看 load commands
➜ Kernels otool -l kernel
kernel:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
0xfeedfacf 16777223 3 0x00 2 18 3968 0x00200001
Load command 0
cmd LC_SEGMENT_64
cmdsize 392
segname __TEXT
vmaddr 0xffffff8000200000
vmsize 0x0000000000a00000
fileoff 0
filesize 10485760
maxprot 0x00000005
initprot 0x00000005
nsects 4
flags 0x0
...
otool -l 的结果非常长,可以 >> 到一个文本文件再打开。内核比较特殊,入口不在 LC_MAIN 而是 LC_UNIXTHREAD。我们找到 LC_UNIXTHREAD 所在的地方:
Load command 15
cmd LC_UNIXTHREAD
cmdsize 184
flavor x86_THREAD_STATE64
count x86_THREAD_STATE64_COUNT
rax 0x0000000000000000 rbx 0x0000000000000000 rcx 0x0000000000000000
rdx 0x0000000000000000 rdi 0x0000000000000000 rsi 0x0000000000000000
rbp 0x0000000000000000 rsp 0x0000000000000000 r8 0x0000000000000000
r9 0x0000000000000000 r10 0x0000000000000000 r11 0x0000000000000000
r12 0x0000000000000000 r13 0x0000000000000000 r14 0x0000000000000000
r15 0x0000000000000000 rip 0xffffff8000197000
rflags 0x0000000000000000 cs 0x0000000000000000 fs 0x0000000000000000
gs 0x0000000000000000
其中 rip 寄存器里的地址 0xffffff8000197000 就是内核函数的入口。我们可以用 nm 工具列出内核的所有符号然后匹配一下:
➜ Kernels nm kernel | grep -i 197000
ffffff8000197000 S __start
ffffff8000197000 S _pstart
非常好,这样 XNU 内核就通过这个内存地址把 __start() 函数加载到内存里,愉快地开机了。
看到这里不知道大家有没有个疑惑,就是 BSD 读取 Mach-O 的实现我懂,但是 BSD 不是在 kernel 里面的吗,这时候 kernel 自己都还没被加载啊喂😂。
没错,上面描述的是普通 Mach-O 文件被内核加载的过程,但是内核自己是被 Bootloader 加载的,所以它的实现是在 Bootloader 里面。新的 iBoot 没有开源所以我们看看 BootX 的实现。
BootX 的整体入口在 bootx.tproj/sl.subproj/main.c 文件中:
const unsigned long StartTVector[2] = {(unsigned long)Start, 0};
StartTVector 指向 Start() 函数:
static void Start(void *unused1, void *unused2, ClientInterfacePtr ciPtr) { long newSP;// Move the Stack to a chunk of the BSS newSP = (long)gStackBaseAddr + sizeof(gStackBaseAddr) - 0x100; asm volatile("mr r1, %0" : : "r" (newSP));
Main(ciPtr); }
调用 Main(),里面调用 InitEverything(),然后通过 GetBootPaths() 拿到 kernel 文件路径,然后 DecodeKernel() 获得内核的主入口内存地址:
gKernelEntryPoint = ppcThreadState->srr0;
最后 CallKernel() 调用内核入口:
// Call the Kernel's entry point
(*(void (*)())gKernelEntryPoint)(gBootArgsAddr, kMacOSXSignature);
留意到这里内核的入口地址在 srr0 寄存器,这是老的 BootX 的代码,我们上面分析了一下 kernel 的 Mach-O 文件可以看到新的内核的入口是在 rip 寄存器上的。
nm 会输出一样地址的两个函数?留意到我们刚用 nm 工具 grep 的时候有两个 start 函数:
➜ Kernels nm kernel | grep -i 197000
ffffff8000197000 S __start
ffffff8000197000 S _pstart
这是为啥?原因是这两个函数的实现可能是完全一致的,然后被编译优化了。那么这两个函数的实现是怎样的呢?
这两个函数是用汇编实现的,位置在 osfmk/x86_64/start.s。里面包含了 32 位和 64 位的兼容代码,比较长且我自己也看不懂😂。
.code32
.text
.section __HIB, __text
.align ALIGN
.globl EXT(_start)
.globl EXT(pstart)
LEXT(_start)
LEXT(pstart)
不过可以看到上述代码声明了全局符号 _start 和 pstart 给链接器,并且 _start 和 pstart 底下的实现是一样的。所以编译优化后这两个函数的地址是一样的。
那么为什么入口是 _start 呢?因为链接器默认的入口就是 _start。Linux 链接器 ld 的默认入口就是 _start,Apple 用的 Darwin Linker (ld64) 也是。可以到这里看看 Darwin Linker 的源代码: https://opensource.apple.com/source/ld64/ld64-97.2/
如果想要自定义入口可以使用 -e 参数:
ld -e my_entry_point -o out a.o
LC_MAIN 和 entryoffMac OS X 10.8 以及 iOS 10.6 以后,ld64 就把 LC_UNIXTHREAD 改成 LC_MAIN 了,同时整个系统所有 App 都实现了 ASLR(Address space layout randomization)。
每次程序加载到内存的时候都会加上一个随机的偏移量,用于防止恶意程序的攻击。ASLR 是内核实现的,所以内核自身当然没法动态偏移。
我们用 otool -l 看看 TweetBot.app 的 Mach-O 文件。LC_MAIN 这个 cmd 不显示内存地址了,变成了 entryoff。
Load command 11
cmd LC_MAIN
cmdsize 24
entryoff 7084
stacksize 0
但是符号表还在 Mach-O 文件中,存于 __LINKEDIT。
entryoff 是入口函数相对于文件头的偏移量,16 进制为 0x1BAC。
再加上一个不同平台不一样的基准偏移量,在 Mac 上是 0x100000000,所以是 0x100001BAC。
方便起见,可以使用 MachOView 这个 App 打开 Mach-O 文件,但是 release App 一般都会去掉符号所以你也看不到这个地址对应的是不是 main 之类的函数。所以读者朋友可以自己编译一个 Debug 版来看,可参考 macOS 内核之一个 App 如何运行起来。
一个 App 如何启动可以参考这里: macOS 内核之一个 App 如何运行起来
其实 BIOS(UEFI) 启动时的硬件检查,Bootloader(BootX) 加载后做的事情,以及内核的主入口被调用之后,这一系列的操作都做了无数的事情。《Mac OS X Internals》书里对这些详细的步骤做了很好的解释,读起来对作者非常服气。
最近读内核代码总会发现各种曾经似懂非懂的概念在阻碍我继续学习,并且东看一下西看一下也不能形成很好的整体印象。所以阅读《Mac OS X Internals》这样的书是一种非常好的辅助。同时也建议读者朋友们不要只是读书,或者只是读代码。最好是两者结合动手实践一下,可以获得更深刻的理解。
