一线大厂程序员的考公上岸心得

个人介绍 1、简单介绍下自己吧 大家好,我叫张安宇,我以前是一名普通的 iOS 程序员。感谢飞哥再次邀请对我进行采访,本来公务员相关的话题是比较敏感的,不过我接受本次采访的主要原因是最近有不少跟我年龄相仿的同行向我请教经验:如何刷题、如何在职复习、如何准备面试、如何报考等等。我知道最近整个互联网行业非常不景气,大家也有比较严重的年龄焦虑,我能在接近 35 岁之前考上编制我很庆幸,所以想通过访谈的形式给大家分享一些我的上岸经验。但是我需要提前告诉大家的是他人的经验仅供参考,我的经历还稍微有些「传奇」,可能不太通用,也许我能通过这种方式上岸,换个人用同样的方式不一定能行得通,所以整篇文章仅供参考哈。 2、你目前考到了哪里,在什么单位,工作日常和主要职责是什么? 我考到了四川省自贡市政数局:自贡市政务服务和数字化管理局是中国四川省自贡市的政府机构,负责提供政务服务和推进数字化管理。该局的主要职责包括政务服务平台建设、政务数据管理、数字化办公和信息化建设等。我们单位的主要职责包括:规范和推动政务服务标准化,管理和利用政务数据资源,推进电子政务建设,指导市政务服务大厅的规划和管理,简化审批流程并推进协同审批,管理政务服务热线平台,推进政务诚信体系建设,组织行政审批制度改革,管理公共资源交易活动,指导惠民帮扶工作,安全生产和职业健康、环境保护等工作,以及推进政府信息公开工作等。 我的单位是一个市属事业单位,截止今天我已经入职一个多月了吧,通过一个月下来的了解,我的日常工作目前还比较简单,处理的是一些基本的工作流程类的文件、公文等,协助软件公司完成我们系统平台的研发。 我的岗位叫做软件工程、大数据分析,所以闲暇也会自己做一些技术方面的学习与研究,比如我正在做的是对自贡市的一些数据进行挖掘分析、数据清洗、人工标注等等的工作,最耗费我精力的就是新词的挖掘和统计,大量的新词需要我一条条地手动确认并保存,这个过程是机器无法替代的。我认为这份工作挑战还是非常大,NLP 、机器学习、数据挖掘等工作是比较有技术难度的领域,没有很好的技术基础我认为做不好这件事,希望以后能够学习更多的技术更好地完成我的工作。 进入这样的部门工作,我认为最大的职责就是要把每件事办好,让每件事都切实有着落,在做任何事情之前优先考虑的是人民群众的利益,要站在最普通老百姓的角度上去思考所问题,真正做到“权为民所用、利为民所谋”,我认为是这样的。为什么选择考公 1、为什么选择了考公这条路?是为了逃离互联网吗? 逃离互联网说不上,我的经历比较曲折,虽然进过很多大公司,拿着很高的薪水,但是待的时间都不长,经常搬家,也没有什么积蓄。在北京这样的城市根本不可能扎根,与其说逃离互联网,不如说我逃离了大城市。有人说在北京没有房产就根本无法立足,我觉得是的,我不想租一辈子的房子,如果我的工作稳定了希望尽快在自贡买房定居下来,家人也不希望我继续漂泊在外地。最近在看《曾少年》这部剧,里面有句台词我觉得特别好:越大的城市越容易产生梦想,但是也更容易让梦想破灭。剧中人物的爱情故事就是这样的悲剧,也符合我的想法,我想逃离的是大城市,也许小地方平平淡淡的生活更适合我呢?考公就是这样的另外一条路,另一种可能性。 选择考公最大的动力是我相信自己在很多时候是努力的,只是以前时运不济,总是一事无成,我相信考公上岸这种路径:通过自己的真才实学和踏实的复习,日积月累最终达成自己最初定下的目标,所以我下定决心准备试试。 2、除了考公你还想过其他途径吗?你怎么看待程序员和公务员这两个身份? 有的,在考公的同时我也有一些出国工作机会,对于程序员来说润出去确实是一个很好的选择。毕竟我也习惯了高薪的工作,突然进入体制内拿很低的薪水,我要经历一个很痛苦的消费降级的过程,曾经随随便便就能购买消费的东西,现在只能远远的看着,已经买不起了。这种感觉真的很难受,考公并不是适合所有人,我也经常问自己,如果我没考上,接下来的人生会在失业中度过吗?我想即便找不到一份高薪的工作,能够找一份普普通通的工作维持生计应该也不难。 所以吧,程序员这个身份,在我眼中是高薪和容易失业的,公务员在我眼中是稳定,不会让我暴富,但是也不会让我饿死的这样一份工作。如果我考不上编制,我可能会折衷一点,选择做普通的相对稳定一些的工作,放弃高薪,这也是符合我的一贯想法的,我不喜欢卷,不希望自己人生活的太累,我希望我既能够生活下去,也能随时享受生活本身。 回望之前的程序员身份,我应该是感激多一些,虽然我上岸了,但是我在政数局这样一个单位,总体上来说跟信息化、数字化的联系程度应该高于体制内的大多数单位,曾经的程序员经历给了我一个很好的经验,我希望能够把这些经验沉淀利用到日常的工作中来,为自贡市,为国家和人民做出我能做出的贡献。 3、考公好像是大多数互联网从业者都曾幻想过的发展路线,什么样的人更适合考公?什么样的人不适合?你会鼓励大家考公吗? 我不会无条件鼓励大家考公,我考公有一个原因是我的原生家庭吧,因为我父母是公务员,考公这件事不仅仅关系到个体,还应该听取父母、家族长辈们的建议,这条路是否适合自己需要好好考虑,不应该盲目地跟风,因为考公热现象只是最近这些年才热起来的。 像我一样父母都在体制内的朋友,我认为是很适合考公的,公务员是一个身份,也是一个家庭的传承。如果家人没有在体制内,很多问题你不知道向谁请教经验,特别是职场上的人情世故,肯定不能借鉴体制外的那些经验,向外人你又不好开口问,父母无疑是最好的老师。我的父母给我提供了很多的上岸后如何做人做事的经验。 我认为不适合考公的人群,其实没有,我觉得任何人想考都能考,我们国家的公务员考试制度不同于古代科举,没有限制谁谁谁不能参加考试,这个岗位专业不符合要求,总有岗位你能报;这个岗位太卷,总有岗位不卷;这个岗位要求学历,总有公益性质的岗位它要求很低。如果特别想考公,我认为不妨一试,人生没有什么不可能,没有哪条路是一定不能走的对吧。 上岸并不是终点,它仅仅是一个起点,考上了只是代表自己进入这个体制内,能不能做好,还得持续不断地耕耘才可能有收获。 如何准备考公 在本篇开始之前就一些概念补充一些相关背景知识:类别 考试 岗位 考试时间 考试内容 其他说明公务员 国考 受中央领导:税务局、国家部委、国家统计局、外交部、银保监会、海关总署 11月份 行测、申论 应届毕业生有一定优势省考 受地方政府领导:各地教育、公安、商务 联考4月份前后,自主考试时间看各地安排 行测、申论 一般有户籍限制,灵动性比较强,部门之间转换事业编 事业编考试 各研究院、管理局、信息中心、办公室 各地自行安排,考试次数比较多,考试时间比较分散 职测(类似行测)、公共基础知识,有些地方还会有专业课,具体以招聘公告为准 要求非常灵活,以当地公示为准国考、省考、事业编考试这三者,整体的招聘岗位数量依次减少,福利待遇相差不大,依次略微降低,但都有编制,上岸难度系数依次降低。因为考公和考编需要准备的内容相似,通常是同时准备,考上哪个是哪个,下文不再额外区分,统称考公。 1、考公的整个流程会是什么样的,可以大概简述下吗? 公务员考试和事业单位考试的大致流程基本一致,都是报名、笔试、资格审查、面试、政审、公示、正式入职,流程很长我从报名到入职只经历了 5 个月。很多朋友对我说我是“肉眼可见的上岸最快速度者”,我实在觉得自己是太走运了。我复习了很久的事业单位 abcde 类《职测》与《综应》四川省并不考;自贡市市属事业单位实际上是我参加的第三场考试,之前还参加了 23 年省考和同期的四川泸州的事业单位考试,后面又参加了三场考试,一共参加了 6 场笔试,进面一次,上岸一次。5 个月的时间走完入编的所有流程,我认为这个不容易,这个流程很长,这中间每个环节都可能淘汰掉我,也许别人好几年也走不完这个流程,这个结果也远超我自己在备考期间的时间预期。 我的这份幸运并不是特例,很多考公上岸的幸运儿都有类似的经历:对手没来参加面试,报考岗位竞争非常小,因此考公之路,有前人总结的是选择大于努力,岗位选得好才是事半功倍。我觉得我的幸运也来自于我的岗位选的不错,我总结的经验是:我会提前报名我感兴趣的城市,然后关注报名交费数据,大部分城市都会统计公布,如果竞争比超过了1:1000,我是一定不会缴费参考的,如果竞争比小于 100 我认为是 ok 的,500 左右我都会考虑下 ROI 的问题(主要是巡考路费,和其他考试撞时间的机会成本损失等)。 选择了考公的道路,基本上是要与以前的自己一刀两断了,要时刻以公务员的标准来要求自己,即使是在备考阶段。入职以后更是需要谨言慎行,认真工作,我认为这是一个改变自己的过程,也是一种锻炼自己的机会,去学习适应新的身份,至少不再是一个程序员了,要以更加系统的思维去全局化地思考问题,站在国家的立场和角度去考虑普通的民生问题,我以后的一言一行要代表得了我的单位,需要对得起党和国家,这是一个很基本的要求和标准,我必须要去做到,这对我来说其实还挺难的,毕竟以前在微软这样的外企工作,我的思想非常open的,上岸以后,我对自己的性格做了很大的收敛,我也相信身边的朋友能够看出来一些,在这方面我还需要再加强。 2、考公是否存在年龄、考试次数、户籍之类的限制;考公的公平性如何? 绝大部分岗位限制年龄 35 岁(含)以下,也就是说你可以考到 36 岁生日的前一天为止,应届研究生、博士放宽到40岁。考试次数当然没有限制,户籍限制是有的,在某些地区的事业单位招聘中明确限制了当地的户籍。 如果报考条件都符合,最终结果一定不会因为这些条件导致取消录用的,无论是公务员还是事业单位的录用,都是参照《公务员法》执行的。如果要在录用阶段取消考生的录用资格,一定是要有足够充分的理由才行。大家想象中的黑幕、暗箱操作,我个人感觉是没有的,我觉得我们国家目前的公务员考试制度相当公平。以我自己为例,我考到自贡来之前,我一个自贡人都不认识,如果不是有绝对公平的考试制度,我又为啥能考进来呢?我这个单位并不差,我认为是一个很好的单位,肯定还有很多人也想进来;假如真有这样那样的黑幕,我应该被刷掉才对。 3、不同地区的考试是否是独立命题?计算机岗位这个方向难度和竞争大不大?很多时候这些信息差会导致判断失误,有没有一些靠谱的可以获取这些信息的途径? 公务员考试的话是每个省一套题,事业单位的话基本上是每个市一套题目,题目难度不一,比如我考过重庆市市属事业单位,我考的那套题最低进面分数是 180 分,而同期举办的区县里的进面分数竟然在 230-250,甚至有区县的进面分高达 280(满分300),那我觉得难度应该是不一样的。最近丰都和南川的高分考生还被查出来集体作弊被取消了成绩,也是解答了我一个疑惑,这些人怎么考到这么离谱的高分的,是我学习方法不对还是我不是那块料? 计算机岗位目前的竞争我觉得中规中矩,我们国家目前正在加快数字化,加快数字经济与实体经济的融合是非常重要的国家战略,因此各个部门也亟需各种各样的计算机专业人才,可以肯定地说计算机专业和其他专业对比在公务员考试中是更吃香的;但整个互联网行业低迷的就业环境导致找工作的人很多,所以竞争也很大,所以只能说比其他专业好很多,但是还是需要付出足够的努力才能上岸。 考公时刻关注各大机构的消息,具体什么机构我就不说了,我相信一个人真正进入了一个备考的状态之后,他会重新建立一个获取备考信息的网络:比如关注一些半月谈公考之类的公众号,亦或是下载学习强国 APP,他会每天自然而然地会关注到所有该注意到的信息和细节,否则我觉得可能这个人备考是还没有进入状态的。4、计算机岗位的考试主要考哪些内容?你是如何准备考试的?是否有必要报班? 计算机岗位的考试题目跟非计算机岗位的考试题目是一样的,只有极少数地区会在面试过程中出专业题(比如我报考的四川自贡市,这在四川都是独一无二的存在),但笔试阶段是不需要准备任何计算机专业知识的。 我报了面试班,但是笔试是全靠自己自学的,我也是这样给其他向我咨询的朋友说的,如果你有基本的自律,其实没必要报笔试班;但是如果有幸获得了面试资格,请不要为了省钱自学,最好至少报一次面试班系统地学习好结构化面试。 5、有过多少次考公经历?备考过程踩过哪些坑?考试的心境有哪些变化? 我今年 33 岁,这是我唯一一次备考公,一共参加了 6 场笔试,从开始备考到入职一共持续了 6 个月左右,其中我当前岗位的录用流程就花了 5 个月,也就是说我就额外多复习了一个月的时间,这也是我为什么觉得自己运气足够好的原因,感觉有点像第一次买彩票就中了大奖一般,哈哈。 踩过哪些坑?我觉得最坑的地方是常识模块,常识模块的知识点实在太多了,即便我刷再多题听再多课,真的找不到什么提分技巧,真的看运气,运气不好常识模块的准确率就只有 40%,非常搞人心态。 我一共刷了 3000 题,总正确率是 70%左右,其实我刷题的数量不多,相比很多比我卷的局长们,我这 3000 题仅仅是刚刚入门而已。备考阶段我的心境其实很平淡,某一两次考试失利我也不会特别难受,只是觉得可能自己还不够努力,只要继续坚持下去总能考上自己喜欢的岗位。我给自己预想的时间是考两年,实际上岸的进度条实在是走的太快了,远超了我的预期,以至于我买的大量复习资料、文具等等,还没派上什么用途就开始在家吃灰了,不知道如何处理。 6、面试环节是什么样的,需要注意哪些事情 如果有幸进入了面试环节,那基本上离上岸只有一步之遥了。如果你的岗位排名大于招聘名额,例如招 1 排 2,招 2 排 4 等,就属于入大围,反之假如招 1 排 1,招 2 排 2 就属于入小围。一般入大围上岸几率会小很多,你需要面试超对手足够多的分数才能逆袭上岸,而有些地区的面试打分分差它是很小的,比如某些地区会在 82-85 之间打分,很可能整个考场的最大分差只有 3 分,这种情况下如果你在大围,明确分差大于这个区间,可以考虑放弃面试。具体打分标准可以查询对应地区历年面试的打分情况来看。我所在的面试组打分区间很大,就我同一个组的考生分差就有 10 分以上,所以情况各不相同。往往分差越大的地方,守擂的不确定性就高,逆袭的成功率也就更高。 我的一些建议是面试阶段尽量要报面试班,我推荐我的面试老师给大家,这里就不打广告了,如果有需要,可以通过飞哥联系我咨询。面试过程中要自信、声音洪亮、衣着得体。差不多就这些。 体制内工作感受 1、你现在应该已经上了一段时间班了,当前岗位的福利待遇、工作状态、工作环境怎么样的,跟你的预期是否相符? 我的福利待遇肯定跟以前打了骨折了,工资连以前的零头都没有了,整个四川的待遇都差不太多哈。工作状态的话我觉得我是非常认真的一个工作状态,会认真地做好每一件工作相关的事情,不能出什么差池,也出不得什么岔子哈。 我们单位的工作环境还不错,当然了,以前在微软那样的大工位肯定是没有了(图 1 是刚入职微软时候的工位,那会对面坐的可是郭神哈,图 2 是后来公司统一升级的新款升降桌):但是也不算太小,我们毕竟是公家单位,跟微软比工作环境,这样的攀比心是不应该有的:我在互联网公司的体验其实还挺好的,过去的几家公司领导都待我不错,压力不算太大,虽然经常公司要求我加班,但是我大部分时候都是主动拒绝的,包括我换工作我的第一号诉求就是不接受加班。 我们单位不考勤,没有过于严厉的规章制度,所以我很庆幸每天上班的几个小时我可以相对自由地安排自己的时间,哪两个小时认真工作,哪两个小时写写代码(基本上已经是写着玩玩了),哪两个小时娱乐休闲、或者学习充电提升自己。 食堂自然是没有微软的食堂好吃,不过与之相应的是更轻松的工作氛围,更 WLB 吧我觉得。绝对的 955,没有周报月报和开不完的 scrum,没有末尾 5% 的 LITE(Low Impact Than Expect) 淘汰机制,不用写 connect,下班时间没有任何人在 Teams 上 ping 我(也没有 Teams,我们工作就用微信)。 所以,我写这些在这里只想说可能每份工作都有优缺点,我觉得在体制内工作会失去一些东西,但是也会得到很多东西,我相信这一点。 虽然微软食堂也能吃一辈子,那也能算“铁饭碗”,但是能在微软这样的公司待一辈子的人也是少数,我觉得哈,每年 5%的 LITE 淘汰,你必须确保你不是每年的那个 5%,遇上经济不好再来一个额外的 全员10%裁员计划,在人生大事上不能用幸存者偏差去看问题,毕竟默默离开的人们不说话,不能忽略他们的感受,所谓稳定就是要绝对的稳定,不容许突如其来的变故,这些 5%、10%的厄运降临到每个人头上都是你的 100%,你都很难去承受。 我在互联网公司最大的缺失自然是集体认同感和稳定性,但我相信我现在的单位以后会给到我的。 2、公务员是否是你的理想职业,它有没有让你感觉有待提升的点?这里的关系是更复杂还是更简单?这是否会是你的最后一站? 我小时候的理想职业是成为一名计算机科学家,所以我的儿时理想跟公务员没有关系。我热爱计算机,大学志愿填报我根本没有考虑过其他任何专业。 现在我的理想首先是能活下去,其次是能给国家给社会做点贡献,成为一名公务员是给了我这样的机会,我应该好好干。我认为体制内的关系比互联网的职场相对简单一点,也许我是一个简单的人吧。 我希望这份工作是我最后一站,这代表我后面不会再准备遴选、考省直或者中直的公务员等等,我很喜欢自贡这个城市,愿意一直生活在这里,自贡和我有着解不开的缘分。自贡是一座千年盐都,我的家乡巫溪县也是一座上古盐都,这是两座以盐文化为傲的城市,除此之外自贡也比我的家乡发达很多,盐、灯、恐龙、川菜都是世界闻名的,我相信这里能成为我的第二故乡。有句话说的好,别问我是哪里人,我的编制在哪里,我就是哪里人。我本就出生在直辖之前的重庆,那会我是四川人,现在我还是四川人,我的编制在四川,我会觉得哪怕从此以后一辈子在自贡也是一件很开心的事情(第一次报名的时候最担心的就是如果考上了就要去一个自己不喜欢的地方生活一辈子,内心会产生巨大的陌生感,那种真实的感受非常令人窒息)。 3、很多时候观察到的现象是,不少政务平台都比较难用,这里的底层逻辑是什么呢 这个说法不太对,我们的政务平台正在以更高的水平、更高的质量发展与建设,我们在这方面做出了很多的努力,我们的一网通办只会越来越方便快捷,特别是我所在的川渝地区,重庆的渝快办已经和我们的政务服务互联互认了,我觉得不会是难用的一个状态。 我们还在配合四川省大数据中心建设打造“无证明城市”,也许以后你到自贡来旅游,可以不带身份证就能到我们局里来办理业务,我们单位的同事们做了很多努力,这些都是可以预见的未来。 至于说为什么有些政务平台大家认为缺乏维护,我想可能存在一些历史因素以及财政紧张的原因,如果大家对我们的政务云、政务平台有任何相关的建议或意见,可以随时拨打我们的 12345 市民热线留下大家的宝贵意见与建议,在大家的监督和促进下我们一定可以做的更好的。 4、最后可以再补充一些你想说的话 最后的最后,欢迎大家到自贡来旅游哦!欢迎大家来我们自贡吃兔子!自贡的夜景非常漂亮:自贡是一座生机勃勃的小城市,自贡的灯会举世闻名,自贡的盐帮菜是川菜的八大菜系之一,自贡的恐龙博物馆是全球最大的三家恐龙博物馆之一(另外两家在加拿大和美国),自贡的井盐更是有几千年的历史。 自贡缺乏的是一个历史机遇,在当代沿海城市已经很发达的背景下,我们的中西部城市也需要快速崛起,也许《重庆四川携手推出多项新举措推进成渝地区“中部崛起”》就是这样的历史机遇。 扩展 考公三大机构:华图在线、中公教育、粉笔教育 考公论坛:https://bbs.qzzn.com/ 程序员考公指南:https://github.com/coder2gwy/coder2gwy

《程序员的自我修养(链接、装载与库)》学习笔记三(装载和动态链接)

继续学习《程序员的自我修养 - 链接、装载与库》的第三个大的部分,前两篇内容参见: 《程序员的自我修养(链接、装载与库)》学习笔记一(温故而知新) 《程序员的自我修养(链接、装载与库)》学习笔记二(编译和链接) 这一篇包含本书的六、七章节,这两个章节中作者给我们讲解了可执行文件的装载以及动态链接的过程,操作系统是如何将程序装载到内存中运行,如何为程序的代码、数据、堆、 栈在进程地址空间中分配,分布。动态链接是如何有效的利用内存和磁盘资源,如何让程序代码的重用变得更加可行和有效等等。 经过上面几个章节章节的学习,我们已经知道了什么是可执行文件,以及可执行文件的静态链接过程,下面我们思考几个问题:为什么有了静态链接,还需要动态链接?静态链接和动态链接有什么区别呢? 可执行文件只有被装载到内存以后才能被 CPU 执行,装载的基本过程是什么样的呢? 共享对象根据模块位置和引用方式的不同分为:模块内跳转、模块内数据访问、模块外跳转、模块外数据访问,这四种类型的寻址方式有何不同? 装载时重定位和地址无关代码是解决绝对地址引用问题的两个方法,这两种方式的利弊都是什么?下面我们带着这些问题进行第六七章节的学习。首先看下这两个章节的知识点分布:可执行文件的装载 装载的方式全部载入内存程序执行时所需要的指令和数据必须在内存中才能够正常运行,最简单的办法就是将程序运行所需要的指令和数据全装入内存中。根据局部性原理进行载入程序运行时是有局部性原理的,所以可将程序最常用的部分驻留在内存中,不太常用的数据存放在磁盘里面,这就是动态装入的基本原理,**覆盖装入(Overlay)和页映射(Paging)**是两种很典型的动态装载方法。 覆盖装入 一个程序有主模块 main,main 分别会调用到模块 A 和模块 B,但是 A 和 B 之 间不会相互调用,这三个模块的大小分别是 1024 字节、512 字节和 256 字节。假设不考虑内存对齐、装载地址限制的情况,理论上运行这个程序需要有 1792 个字节的内存,当采用内存覆盖装入的办法,会按照下图的方式安排内存,我们可以把模块 A 和模块 B 在内存中相互覆盖,即两个模块共享块内存区域,除了覆盖管理器,整个程序运行只需要 1536 个字节,比原来的方案节省了 256 字节的空间。在多个模块的情况下,程序员需要手工将模块按照它们之间的调用依赖关系组织成树状结构。例如下图,模块 main 依赖于模块 A 和 B,模块 A 依赖于 C 和 D,模块 B 依赖于 E 和 F,则它们在内存中的覆盖方式如下图:覆盖管理器需要保证两点:这个树状结构中从任何一个模块到树的根模块都叫调用路径。当该模块被调用时,整个调用路径上的模块必须都在内存中。 禁止跨树间调用。任意一个模块不允许跨过树状结构进行调用。覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉,一旦模块没有在内存中,还需要从磁盘或其他存储器读取相应的模块,所以覆盖装入的速度肯定比较慢,不过这也是一种折中的方案,是典型的利用时间换取空间的方法,现在已经几乎被淘汰了。 页映射 页映射是将内存和所有磁盘中的数据和指令按照页**(Page)**为单位划分成若干个页,装载和操作的单位就是页。假设我们的 32 位机器有 16 KB 的内存,每个页大小为 4096 字节,共有 4 个页,假设程序所有的指令和数据总和为 32 KB,那么程序总共被分为 8 个页。我们将它们编号为 P0~P7。16 KB 的内存无法同时将 32 KB 的程序装入,于是我们将按照动态装入的原理来进行装入。如果程序刚开始执行时的入口地址在 P0,这时装载管理器发现程序的 P0 不在内存中,于是将内存 F0 分配给 P0,并且将 P0 的内容装入 F0,运行一段时间以后,程序需要用到 P5,于是装载管理器将 P5 装入F1,当程用到 P3 和 p6 的时候,它们分别被装入到了 F2 和 F3,映射关系如下图:但如果这时候需要访问第 5 个页,那么装载管理器必须做出抉择,它必须放弃目前正在使用的 4 个内存页中的其中一个来装载新的页。至于选择哪个页,我们有很多种算法可以选择:使用 FIFO 先进先出算法选择第一个被分配掉的内存页。 使用 LRU 最少使用算法选择很少被访问到的页。装载的过程 进程建立创建一个独立的虚拟地址空间。创建一个虚拟空间实际上是创建映射函数所需要的相应的数据结构。 创建虚拟地址实际只是创建页目录,甚至不设置页映射关系,这些映射关系等到后面程序发生页错误的时候再进行设置。读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系。当程序执行发生页错误时,操作系统将从物理内存中分配一个物理页,然后将该缺页从磁盘中读取到内存中,再设置缺页的虚拟页和物理页的映射关系,这样程序才得以正常运行。当操作系统捕获到缺页错误时,它应知道程序当前所需要的页在可执行文件中的哪一个位置。这种映射关系只是保存在操作系统内部的一个数据结构。Linux 中将进程虚拟空间中的一个段叫做虚拟内存区域 VMA,在 Windows 中将这个叫做虚拟段 Virtual Section。将 CPU 的指令寄存器设置成可执行文件的入口地址,启动运行。从进程的角度看这一步可以简单地认为操作系统执行了一条跳转指令,直接跳转到可执行文件的入口地址。页错误 上面的步骤执行完以后,其实可执行文件的真正指令和数据都没有被装入内存中。操作系统只是通过可执行文件头部的信息建立起可执行文件和进程虚拟之间的映射关系而已。 当 CPU 开始打算执行这个地址的指令时,发现是个空页面,于是它就认为这是一个页错误(Page Fault)。 CPU 将控制权交给操作系统,操作系统有专门的页错误处理例程来处理这种情况。操作系统将查询这个数据结构,然后找到空页面所在的 VMA,计算出相应的页面在可执行文件中的偏移,然后在物理内存中分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后把控制权再还给进程,进程从刚才页错误的位置重新开始执行。 随着进程的执行,页错误会不断的产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求,如下图所示:缺页本身是一种中断,与一般的中断一样,需要经过 4 个处理步骤:  1. 保护 CPU 现场。  1. 分析中断原因。    1. 转入缺页中断处理程序进行处理。    1. 恢复 CPU 现场,继续执行。 页面错误会降低系统性能并可能导致抖动,程序或操作系统的性能优化通常涉及减少页面错误的数量。优化的两个主要重点是减少整体内存使用量和改进内存局部性。为了减少页面错误,开发人员必须使用适当的页面替换算法来最大化页面命中率。 进程虚存空间分布 ELF 文件的装载 ELF 文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍,如果不是,那么多余部分也将占用一个页。一个 ELF 文件中往往有十几个段,那么内存空间的浪费是可想而知的。而操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行):以代码段为代表的权限为可读可执行的段。 以数据段和 BSS 段为代表的权限为可读可写的段。 以只读数据段为代表的权限为只读的段。相同权限的段可合井到一起当作一个段进行映射。 比如有两个段分別叫 .text 和 .init,它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设 .text 为 4097 字节,.init 为 512 字节,这两个段分别映射的话就要占用三个页面,但是如果将它们合并成一起映射的话只须占用两个页面,如下图所示:ELF 可执行文件引入了一个概念叫做 Segment,一个 Segment 包含一个或多个 Section,如果将 .text 段和 .init 段合并在一起看作是一个 Segment ,那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的 VMA,而不是两个,这样做的好处是可以减少页面内部碎片,节省了内存空间。 Segment 的概念实际上是从装载的角度重新划分了 ELF 的各个段。 Segment 和 Section 是从不同的角度来划分同一个 ELF 文件。这个在 ELF 中被称为不同的视图 View:从Section 的角度来看 ELF 文件就是链接视图 LinkingView 从 Segment 的角度来看就是执行视图 ExecutionView。当我们在谈到 ELF 装载时,段专门指 Segment,而在其他的情况下,段指的是 Section,ELF 可执行文件与进程虚拟空间映射关系如下图所示:ELF 可执行文件中有一个专门的数据结构叫做程序头表**(Program Header Table)**用来保存 Segment 的信息。因为 ELF 目标文件不需要被装载,所以它没有程序头表,而 ELF 的可执行文件和共享库文件都有。它的结构体以及各个成员的含义如下: typedef struct { Elf32_Word p_type; // 类型,基本上我们在这里只关注 LOAD 类型的 Segment Elf32_Off p_offset; // Segment 在文件中的偏移 Elf32_Addr p_vaddr; // Segment 第一个字节进程虚拟地址空间的起始位置 Elf32_Addr p_paddr; // Segment 的物理装载地址 Elf32_Word p_filesz;// 在 ELF 文件中所占空间的长度 Elf32_Word p_memsz; // Segment 在进程虚拟地址空间中所占用的长度 Elf32_Word p_flags; // Segment 权限属性(可读 R、可写 W、可执行 X) Elf32_Word p_align; // Segment 对齐属性(2 的 p _align 次方字节) } Elf32_Phdr堆和栈 在操作系统里面,VMA 除了被用来映射可执行文件中的各个 Segment ,还使用 VMA 来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到堆和栈等空间,事实上它们在进程的虚拟空间中的表现也是以 VMA 的形式存在的,很多情况下,一个进程中的堆和栈分别都有一个对应的 VMA。 Linux 下,我们可以通过查看 /proc 来查看进程的虚拟空间分布:cat /proc/21963/maps 08048000-080b9000 r-xp 00000000 08:01 2801887 ./SectionMapping.elf 080b9000-080bb000 rwxp 00070000 08:01 2801887 ./SectionMapping.elf 080bb000-080de000 rwxp 080bb000 00:00 0 [heap] bf7ec000-bf802000 rw-p bf7ec000 00:00 0 [stack] ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]第一列是 VMA 的地址范围。 第二列是 VMA 的权限,r 表示可读,w 表示可写,x 表示可执行,p 表示私有 (COW, Copy on Write) ,s 表示共享。 第三列是偏移, 表示 VMA 对应的 Segment 在映像文件中的偏移。 第四列表示映像文件所在设备的主设备号和次设备号。 第五列表示映像文件的节点号。 最后一列是映像文件的路径。操作系统通过给进程空间划分出一个个 VMA 来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个 VMA,一个进程基本上可以分为如下几种 VMA 区域:代码 VMA,权限只读,可执行,有映像文件。 数据 VMA,权限可读写,可执行,有映像文件。 堆 VMA,权限可读写,可执行,无映像文件,匿名,可向上扩展。 栈 VMA,权限可读写,不可执行,无映像文件,匿名,可向下扩展。常见进程的虚拟空间如下图所示:堆的最大的申请数量也就是 malloc 的最大申请数量会受到哪些因素的影响呢?具体的数值会受到操作系统版本,程序本身大小,用到的动态共享库数量、大小,程序栈数量、大小等。 有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术 (主要是出于安全考虑, 防止程序受恶意攻击) ,使得进程的堆空间变小。ASLR 在计算机科学中,地址空间配置随机加载称为 ASLR,又称地址空间配置随机化或地址空间布局随机化,是一种防范内存损坏漏洞被利用的计算机安全技术,通过随机放置进程关键数据区域的地址空间来防止攻击者跳转到内存特定位置来利用函数。 Linux 已在内核版本 2.6.12 中添加 ASLR。 Apple 在 Mac OS X Leopard 10.5 中某些库导入了随机地址偏移,但其实现并没有提供 ASLR 所定义的完整保护能力。而 Mac OS X Lion 10.7 则对所有的应用程序均提供了 ASLR 支持。 Apple 在 iOS 4.3 内导入了 ASLR。 段地址对齐 可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。 假设我们有一个 ELF 可执行文件,它有三个段需要装载,我们将它们命名为 SEG0、SEG1 和 SEG2。每个段的长度、在文件中的偏移如表所示:段 长度 (字节) 偏移 (字节) 权限SEG0 127 34 可读可执行SEG1 9899 164 可读可写SEG2 1988只读这属于大多常见的情况,就是每个段的长度都不是页长度的整数倍,一种最简单的映射办法就是每个段分开映射,对于长度不足一个页的部分则占一个页。通常 ELF 可执行文件的起始虚拟地址为 0x08048000,所以这三个段的虚拟地址和长度如表所示:段 起始虚拟地址 大小 有效字节 偏移 权限SEG0 0x08048000 0x1000 127 34 可读可执行SEG1 0x08049000 0x3000 9899 164 可读可写SEG2 0x0804C000 0x1000 1988只读 三个段的总长度只有 12014 字节,却占据了 5 个页,即 20480 字节,空间使用率只有 58. 6 %。导致文件段的内部会有很多碎片,浪费磁盘空间。为了解决这种问题,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次,如下图:这样映射的话,不仅进程中的某一段区域就是整个ELF 的映像,对于一些须访问 ELF 文件头的操作可以直接通过读写内存地址空间进行,而且内存空间得到了充分的利用,ELF 文件的映射方式如下表所示:段 起始虚拟地址 大小 偏移 权限SEG0 0x08048022 127 34 可读可执行SEG1 0x080490A4 9899 164 可读可写SEG2 0x0804C74F 1988可读可写进程栈初始化 操作系统在进程启动前将系统环境变量和进程的运行参数等提前保存到进程的虚拟空间的栈中,进程启动后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给 main 函数,并通过函数的两个参数 argc 和 argv,传递命令行参数数量和命令行参数字符串指针数组。 Linux 内核装载 ELF 过程简介 首先,用户层面,bash 进程会调用 fork 系统调用创建一个新的进程,然后新的进程调用 execve 系统调用执行指定的 ELF 文件,原先的 bash 进程继续返回等待刚才启动的新进程结束,然后继续等待用户输入命令。 execve 系统调用被定义在 unistd.h,它的原型如下: int execve(const char *filename,char *const argv[],char *const envp[]); 它的三个参数分别是被执行的程序文件名,执行参数和环境变量,相关函数执行顺序如下:在内核中execve 系统调用相应的入口是 sys_execve。 sys_execve 进行一些参数的检查复制之后,调用 do_execve。 do_execve 会首先查找被执行的文件,如果找到文件,则读取文件的前 128 个字节,判断文件的格式,每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头 4个字节,常常被称做魔数。 调用 search_binary_handle 通过判断文件头部的魔数确定文件的格式去搜索和匹配合适的可执行文件装载处理过程。 调用ELF 可执行文件的装载处理过程 load_elf_binary。 当 load_elf_binary 执行完毕,返回至 do_execve 再返回至 sys_execve 时已经把系统调用的返回地址改成了被装载的 ELF 程序的入口地址了。 load_elf_binary这个函数的代码比较长,它的主要步骤是:检查 ELF 可执行文件格式的有效性,比如魔数,程序头表中段的数量。寻找动态链接的 .interp 段,设置动态链接器路径。根据 ELF 可执行文件的程序头表的描述,对 ELF 文件进行映射,比如代码、数据、只读数据。初始化 ELF 进程环境,比如进程启动时 edx 寄存器的地址应该是 DT_FINI 的地址。将系统调用的返回地址修改成 ELF 可执行文件的入口点,这个入口点取决于程序的链接方式,对于静态链接的 ELF 可执行文件,这个程序入口就是 ELF 文件的文件头中 e_entry 所指的地址. 对于动态链接的 ELF 可执行文件,程序入口点是动态链接器。动态链接 静态链接的方式对于计算机内存和磁盘的空间浪费非常严重,作者讲了一个静态链接的例子,Program1 和Program2 分別包含 Program1.o 和 Program2.o 两个模块,并且它们还共用 Lib.o 这个模块,静态链接下,当同时运行 Program1 和Program2 时,Lib.o 在磁盘中和内存中都有两份副本,想象如果是静态链接的库,很多程序共用的情况下,那么将会有大量的内存空间被浪费。除此之外,如果是使用静态链接,假设 Lib.o 修改了一个 bug,那么 Program1 和 Program2 的厂家都需要拿到最新的 Lib.o,然后再与 Program1.o 或者 Program2.o 重新链接后,将最新的程序发布给用户,以至于每个小的改动,都会导致整个程序重新下载。动态链接的出现就是要解决空间浪费和更新困难这两个问题的。 把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。工作的原理与静态链接类似,包括符号解析、地址重定位,回到上面的例子,如果改成动态链接,Lib.o 在磁盘和内存中只存在一份,这么做不仅仅减少内存的使用,还可以减少物理页面的换入换出,也可以增加 CPU 缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享的模块上。升级变得更加容易只要简单地将旧的目标文件覆盖掉,而无须将所有的程序再重新链接一遍。 除了上述优点外,动态链接还可以被拿来做插件,为程序增加动态的功能扩展,也可以通过动态链接库给程序和操作系统之间增加了一个中间层,消除程序对不同平台之间依赖的差异性,虽然有很多优点,动态链接也是存在着一些缺点的,例如某个模块更新后,会产生新的模块与旧的模块之间接口不兼容的问题,这个问题也经常被称为 DLL Hell 。 动态链接的例子 /*Program1.c */ #include "Lib.h"int main() { foobar(1); return 0; }/*Program2.c*/ #include "Lib.h"int main() { foobar(2); return 0; }/*Lib.c*/ #include <stdio.h> void foobar(int i) { printf("Printing from Lib.so %d\n",i); }/*Lib.h*/ #ifndef LIB_H #define LIB_H void foobar(int i); #endif两个程序的主要模块 Program1.c 和 Program2.c 分别调用了 Lib.c 里面的 foobar 函数。 使用 GCC 将 Lib.c 编译成一个共享对象文件:gcc - fPIC -shared -o Lib.so Lib.c 两个程序 Program1 和 Program2,这两个程序都使用了 Lib.so 里面的 foobar 函数 。 从 Program1 的 角度看 ,整个编译和链接过程如下图所示:上图的步骤中只有一个步骤与静态链接不一致,那就是 Program1.o 被链接成可执行文件的这一步,在静态链接中,会把 Program1.o 和 Lib.o 链接到一起,并且产生输出可执行文件 Program1,但是这里,Lib.o 没有被链接进来,链接的输入目标文件只有 Program1.o。 当链接器将 Program1.o 链接成可执行文件时,这时候链接器必须确定 Programl.o 中所引用的 foobar 函数的性质。如果 foobar 是一个定义于其他静态目标模块中的函数,那么链接器将会按照静态链接的规则,将 Programl.o 中的 foobar 地址引用重定位,如果 foobar 是一个定义在某个动态共享对象中的函数,那么链接器就会将这个符号的引用标记为一个动态链接的符号,不对它进行地址重定位,把这个过程留到装载时再进行。 动态链接下,程序分为可执行文件和程序依赖的共享对象 Lib.so,Lib.so 中保存了完整的符号信息,通过将 Lib.so 作为链接的输入之一,就能够知道 foobar 的引用是一个静态符号还是一个动态符号。 地址无关代码 共享对象的最终地址在装载时确定,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象。那么装载地址是怎么获取的呢?在早期,有种做法叫静态共享库(将程序的各种模块统一交给操作系统来管理,操作系统在某个特定的地址划分出一些地址块,为那些已知的模块预留足够的空间)。 这种做法现在已经被淘汰了,之所以被淘汰,主要原因就是升级时,必须保持共享库中全局函数和变量地址的不变,如果应用程序在链接时己经绑定了这些地址,一但更改就必须重新链接应用程序。 为了能够使共享对象在任意地址装载,基本思路是在链接时,对所有绝对地址的引用不作重定位,而把这一步推迟到装载时再完成。假设函数 foobar 相对于代码段的起始地址是 0x100,当模块被装载到 0x10000000 时,我们假设代码段位于模块的最开始,即代码段的装载地址也是 0x10000000,那么我们就可以确定 foobar 的地址为 0x10000100。这时系统遍历模块中的重定位表,把所有对 foobar 的地址引用都重定位至0x10000100,这种装载时重定位义被叫做基址重置 Rebasing。 装载时重定位是解决动态模块中有绝对地址引用的方法之一,但是指令部分无法再多个进程之间共享,就失去了节省内存的优势。我们希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据放在一起,这样指令部分就可以保持不变,而数据部分可以在每一个进程中拥有一个副本。这种方案称之为地址无关代码 PIC 技术。 共享对象模块中的地址引用按照是否为跨模块分成两类:模块内部引用。模块外部引用按照不同的引用方式又可以分成两类:指令引用。数据访问。于是我们就得到了 4 中情况:第一种是模块内部的函数调用、跳转。 第二种是模块内部的数据访问,比如模块中定义的全局变量、静态变量。 第三种是模块外部的函数调用、跳转。 第四种是模块外部的数据访问,比如其他模块中定义的全局变量。示例代码如下: static int a; extern int b; extern void ext();void bar() { a = 1; b = 2; } void foo() { bar(); ext(); }类型一 模块内部调用或跳转 例如上面例子中 foo 对 bar 的调用,属于模块内部的调用,会产生如下代码: 8048344 <bar>: 8048344: 55 push %ebp .... 8048349 <foo>: 8048357: e8 e8 ff ff ff call 8048344<bar> 804835C: ...对于模块内部调用,因为被调用的函数和调用者在同一个模块,他们之间的相对位置是固定的。模块内部的跳转和函数调用都可以是相对地址调用,或者基于寄存器的相对调用,这些指令是不需要重定位的。 0xFFFFFFE8 是 -24 的补码形式 bar 的地址为 0x804835c + (-24) = 0x8048344 类型二 模块内部数据访问 例如上面例子中 bar 访问内部变量 a,属于模块内部的数据访问,会产生如下代码: 0000044c <bar>: ..... 44f: e8 40 00 00 00 call 494<__i686.get_pc_thunk.cx> 454: 81 c1 8c 11 00 00 add $0x118c,%ecx 45a: c7 81 28 00 00 00 01 movl %0x1,0x28(%ecx) 461: 00 00 00 ..... 494: <__i686.get_pc_thunk.cx>: 494: 8b 0c 24 mov (%esp),%ecx 497: c3 ret一个模块前面一般是若干个页的代码,后面紧跟着若干个页的数据,这些页之间的相对位置是固定的,也就是说,任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了,现代的体系结构中,数据的相对寻址往往没有相对与当前指令地址 PC 的寻址方式,所以 ELF 用了一个很巧妙的办法来得到当前的 PC 值。 __i686.get_pc_thunk.cx 这个函数的作用就是把返回地址的值放到 ecx 寄存器,即把 call 的下一条指令的地址放到 ecx 寄存器。 变量 a 的地址,是 add 指令地址加上两个偏移量 0x118c 和 0x28,即如果模块被装载到 0x10000000 这个地 址的话,变量 a 的实际地址将是 0x10000000 + 0x454 + 0x118c + 0x28 = 0x10001608 例外: 对于全局变量来说,无论是在模块内部还是模块外部,都只能使用 GOT 的方式来访问,因为编译器无法确定对全局变量的引用是跨模块的还是模块内部的,关于 GOT 下面会介绍。 类型三 模块间数据访问 例如上面例子中 bar 访问内部变量 b,属于模块间的数据访问,会产生如下代码: 0000044c <bar>: ..... 44f: e8 40 00 00 00 call 494 <__i686.get_pc_thunk.cx> 454: 81 c1 8c 11 00 00 add $0x118c,%ecx //%ecx=0x454 + 0x118C,GOT表地址 45a: c7 81 28 00 00 00 01 movl $0x1,0x28(%ecx) //a = 1 461: 00 00 00 464: 8b 81 f8 ff ff ff mov 0xfffffff8(%ecx),%eax 46a: c7 00 02 00 00 00 movl $0x2,(%eax) //b = 2 ..... 494: <__i686.get_pc_thunk.cx>: 494: 8b 0c 24 mov (%esp),%ecx 497: c3 retELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也被称为全局偏移表 (Global Offset Table,GOT) ,当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用,基本机制如下图:当指令中需要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的项找到变量的目标地址。每个变量都对应一个 4 个字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。 GOT 如何做到指令的地址无关性: 模块在编译时可以确定模块内部变量相对与当前指令的偏移,那么我们也可以在编译时确定 GOT 相对于当前指令的偏移。确定 GOT 的位置跟上面的访问变量 a 的方法基本一样,通过得到 PC 值然后加上一个偏移量,就可以得到 GOT 的位置,然后我们根据变量地址在 GOT 中的偏移就可以得到变量的地址。 我们的程序首先计算出变最 b 的地址在 GOT 中的位置,即 0x10000000 + 0x454 + 0x118c + (-8) = 0x100015d8 ,(0xfffffff8 为 -8 的补码表示,也就是 在 GOT 中偏移 8),然后使用寄存器间接寻址方式给变最 b 赋值 2。 这边解释下寄存器的直接寻址和间接寻址: 寄存器寻址:指令所要的操作数已存储在某寄存器中,或把目标操作数存入寄存器。 寄存器间接寻址:寄存器内存放的是操作数的地址,而不是操作数本身,即操作数是通过寄存器间接得到的。 因为上面我们在 GOT 拿到的是变量 b 在外部模块的地址,所以更改变量 b 的值的过程是通过间接寻址来做的。 类型四 模块间调用、跳转 例如上面例子中 foo 对 ext 的调用 ,属于模块间的函数调用,会产生如下代码: call 494 <__i686.get_pc_thunk.cx> add $0x118c,%ecx //%ecx=0x454 + 0x118C,GOT表地址 mov 0xfffffffc(%ecx),%eax call *(%eax) ..... 494: <__i686.get_pc_thunk.cx>: 494: 8b 0c 24 mov (%esp),%ecx 497: c3 ret模块需要调用目标函数时,可以通过 GOT 中的项进行间接跳转,调用 ext 函数的方法与上面访问变量 b 的方法基本类似,先得到当前指令地址 PC,然后加上一个偏移得到函数地址在 GOT 中的偏移,然后一个间接调用,如下图:4 种地址引用方式在理论上都实现了地址无关性,总结如下:指令跳转、调用 数据访问模块内部 (1)相对跳转和调用 (2)相对地址访问模块外部 (3)间接跳转和调用(GOT) (4)直接访问(GOT)共享模块的全局变量问题 当一个模块引用了一个定义在共享对象的全局变量的时候,比如一个共享对象定义了一个全局变量 global,下面这块代码我们将它定义在 module.c 中,当编译时它无法根据这个上下文判断 global 是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。 extern int global; int foo() { global = 1; }假设 module.c 是程序可执行文件的一部分,由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的 bss 段创建一个 global 变量的副本,然而由于 global 是定义在共享对象中的,那么这个 global 变量会同时存在于多个位置中,这显然是不行的。 解决的办法那就是所有的使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,通过 GOT 来实现变最的访问。当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中的相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变最在共享模块中被初始化,那么动态链接器还需要将该初始化值复制到程序主模块中的变量副本,如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向模块内部的该变量副本。假设 module.c 是一个共享对象的一部分,那么 GCC 编译器在 -fPIC 的情况下,就会把对 global 的调用按照跨模块模式产生代码。原因是编译器无法确定对 global 的引用是跨模块的还是模块内部的。即使是模块内部的,还是会产生跨模块代码,因为 global 可能被可执行文件引用,从而使得共享模块中对 global 的引用要执行可执行文件中的 global 副本。 数据段地址无关性 static int a; static int *p = &a;如果某个共享对象里面有这样一段代码的话,那么指针 p 的地址就是一个绝对地址,它指向变量 a,而变量 a 的地址会随着共享对象的装载地址改变而改变。对此,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位表,表中包含重定位的入口,当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。 那问题来了,为什么数据段可以采用装载时重定位,而代码段不可以呢? 原因其实很简单,因为对于数据段来说,它在每个进程都有一份独立的副本,所以并不担心被进程改变,而代码段则没有独立的副本,如果让代码段也使用这种装载时重定位的方法,而不使用地址无关代码的话,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。 如果可执行文件是动态链接的,那么 GCC 会使用 PIC 的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。 延迟绑定 PLT 首先我们需要认清一个问题,那就是动态链接比静态链接要慢,还会减慢程序的启动速度,主要原因是:动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址。对于模块间的调用也要先定位 GOT,然后再进行间接跳转。动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作(动态链接器会寻找并装载所需要的共享对象,然后进行符号查找地址重定位等工作等)。在一个程序运行过程中,可能很多函数在程序执行完时都不会被用到,如果一开始就把所有函数都链接好实际上是一种浪费。所以 ELF 采用了一种叫做延迟绑定的做法,基本的思想就是当函数第一次被用到时才进行绑定 (符号查找、重定位等),这样可以加快程序的启动速度。ELF 使用 PLT (Procedure Linkage Table) 的方法来实现。 例如 liba.so 需要调用 libc.so 中的 bar 函数,第一次调用时首先会需要调用动态链接器中的某个函数来完成地址绑定工作,这个函数的名字是 _dl_runtime_resolve具体过程如下(解析符号仅执行一次): bar@plt jmp *(bar@GOT) push moduleID jump _dl_runtime_resolve调用函数并不直接通过 GOT 跳转,而是通过一个叫作 PLT 项的结构来进行跳转,bar 两数在 PLT 中的项的地址我们称之为 bar@plt。 bar@plt 指令通过 GOT 进行间接跳转指令,bar@GOT 表示 GOT 中保存 bar 这个函数相应的项。 如果链接器初始化阶段并未将 bar 的地址填入该项,而是将 push n (n 为 bar 这个符号引用在重定位表 .rel.plt 中的下标)的地址填入到 bar@GOT 中。。 接着又是一条 push 指令将模块的 ID 压入到堆栈,然后跳转到 _dl_runtime_resolve。 _dl_runtime_resolve 函数来完成符号解析和重定位工作。 _dl_runtime_resolve 在进行一系列工作以后将 bar 的真正地址填入到 bar@GOT。 bar 这个函数被解析完,当我们再次调用 bar@plt 时,第一条 jmp指令就能够跳转到真正的 bar 函数中。ELF 将 GOT 拆分成了两个表叫做 got 和 got.plt 。其中 got 用来保存全局变量引用的地址,.got.plt 用来保存函数引用的地址,所有对于外部函数的引用全部被分离出来放到了 got.plt 中,got.plt 还有一个特殊的地方是它的前三项:第一项保存的是 .dynamic 段的地址,描述了本模块动态链接相关的信息。 第二项是本模块的 ID(在动态链接器在装载共享模块的时候初始化)。 第三项是保存的 _dl_runtime_resolve 的地址(在动态链接器在装载共享模块的时候初始化)。 .got.plt 的其余项分别对应每个外部函数的引用,整体结构如下图。动态链接相关结构 动态链接步骤:在动态链接情况下,操作系统会先加载一个动态链接器(实际上是一个共享对象)。 加载完成后就将控制权交给动态链接器的入口地址( 与可执行文件一样,共享对象也有入口地址)。 当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作。 根据当前的环境参数,开始对可执行文件进行动态链接工作。 当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。下面开始介绍一些动态链接中比较重要的段。 .interp 段 动态链接器的位置不是由系统配置指定的,也不是由环境变量决定的,而是由 ELF 可执行文件的 .interp 段指定的。里面保存的就是一个字符串,这个字符串就是可执行文件所需要的动态链接器的路径。 .dynamic 段 .dynamic 段保存了动态链接所需要的基本信息(依赖于哪些共享对象、动态键接符号表的位置、动态链接重定位表的 位置、共享对象初始化代码的地址等)。也是动态链接 ELF 中最重要的结构。.dynamic 段里面保存的信息有点像 ELF 文件头,只是我们前面看到的 ELF 文件头中保存的是静态链接时相关的内容,比如静态链接时用到的符号表、重定位表等,这里换成了动态链接下所使用的相应信息,具体结构如下: typedef struct { Elf32_Sword d_tag; union { Elf32_Word d_val; Elf32_Addr d_ptr; } d_un; } Elf32_Dyn;我们这里列举几个比较常见的类型值,如下表:d_tag 类型 d_un 的含义DT_SYMTAB 动态链接符号表的地址,d_ptr 表示 .dynsym 的地址DT_STRTAB 动态链接字符串表地址,d_ptr 表示 .dynstr 的地址DT_STRSZ 动态链接字符串表大小,d_val 表示大小DT_HASH 动态链接哈希表地址,d_ptr 表示 .hash 的地址DT_SONAME 本共享对象的 SO_NAMEDT_RPATH 动态链接共享对象搜索路径DT_INIT 初始化代码地址DT_FINIT 结束代码地址DT_NEED 依赖的共享对象文件,d_ptr 表示所依赖的共享对象文件名DT_REL 动态链接重定位表地址DT_RELA 动态链接重定位表地址DT_RELENT 动态重读位表入口数量DT_RELAENT 动态重读位表入口数量动态符号表 ELF 为了表示动态链接的模块之间的符号导入导出关系,使用了 .dynsym 段,也称为动态符号表,用来保存这些符号的信息,动态符号表也需要一些辅助的表,比如用于保存符号名的字符串表 .dynstr,为了加快符号的查找过程,往往还有辅助的符号哈希表 .hash。 动态链接重定位相关结构 对于动态链接来说,共享对象不是以 PIC 模式编译的,那么它需要在装载时被重定位的。 共享对象是以 PIC 模式编译的,也需要重定位,因为数据段还包含了绝对地址的引用。装载时的重定位和静态链接中的重定位区别 时机不同 重定位表共享对象的重定位 装载时 .rel.text 和 .rel.data静态链接的目标文件的重定位 链接时 .rel.dyn 和 .rel.plt.rel.dyn 实际上是对数据引用的修正,它所修正的位置位于 .got 以及数据段,而 .rel.plt 是对函数引用的修正,它所修正的位置位于 .got.plt .got.plt 的前三项是被系统占据的,从第四项开始才是真正存放函数地址的地方。 而第四项刚好是 0x000015c8 + 4* 3 = 0x000015d4 即 __gmon_start__,第五项是 printf,第六项是 sleep,第七项是 __cxa_finalize,结构如下图所示:当动态链接器需要进行重定位时 ,它先查找 printf 的地址,printf 位于 libc-2.6.1.so。 假设链接器在全局符号表里面找到 printf 的地址为 0x08801234,那么链接器就会将这个地址填入到 .got.plt 中的偏移为0x000015d8 的位置中去,从而实现了地址的重定位,即实现了动态链接最关键的一个步骤。 稍微麻烦点的是,共享对象的数据段是没有办法做到地址无关的,它可能会包含绝对地址的用,对于这种绝对地址的引用,我们必须在装载时将其重定位。 例如上面的这段代码 static int a; static int *p = &a;在编译时, 共享对象的地址是从 0 开始的,我们假设该静态变量 a 相对于起始地址 0 的偏移为 B,即 p 的值为 B。一旦共享对象被装载到地址 A,那么实际上该变量 a 的地址为 A+B。ELF 文件的编译方式 外部函数的重定位入口的位置PIC 方式 .rel.plt共享对象方式 .rel.dyn动态链接时进程堆栈初始化信息 操作系统通过进程的堆栈传递给动态链接器可执行文件和本进程的一些信息,堆栈里面保存了关于进程执行环境和命令行参数等信息。事实上,堆栈里面还保存了动态链接器所需要的一些辅助信息数组。 typedef struct { uint32_t a_type; union { uint_32_t a_val; } a_un; } Elf32_auxv_t;结构与前面的 .dynamic 段里面的结构如出一辙,32 位的类型值,常见的类型如下:a_type 定义 a_type 值 a_val 的含义AT_NULL 0 表示辅助信息数组结束AT_EXEFD 2 表示可执行文件的句柄AT_PHDR 3 可执行文件中程序头表AT_PHENT 4 可执行文件中程序头表中每一个入口(Entry)的大小AT_PHNUM 5 可执行文件中程序头表中入口(Entry)的数量AT_BASE 7 表示动态链接器本身的装载地址AT_ENTRY 9 可执行文件入口地址,即启动地址它们在进程堆栈位于环境变量指针的后面:动态链接的步骤和实现 动态链接的步骤基本上分为 3 步:启动动态链接器本身。 装载所有需要的共享对象。 重定位和初始化。Bootstrap 动态链接器本身不可以依赖于其他任何共享对象,其次是动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成。这种具有一定限制条件的启动代码往往被称为自举 (Bootstrap)。 动态链接器入口地址即自举代码的入口,自举代码首先会找到他自己的 GOT。而 GOT 的第一个入口是 .dynamic 段的偏移地址,通过 .dynamic 中的信息,自举代码便可以获得动态链接器本身的重定位表和符号表等,从而得到动态链接器本身的重定位入口,先将它们全部重定位。从这一步开始动态链接器代码才可以使用自己的全局变量和静态变量。动态链接器实际上使用 PIC 模式编译的共享对象,对于模块内部的函数调用也是采用跟模块外部函数调用一样的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 没有被重定位之前,自举代码不可以使用任何全局变量,也不可以调用函数。 装载共享对象 完成自举后,动态链接器将可执行文件和链接器本身的符号都合并到全局符号表,然后链接器通过 .dynamic 段找到可执行文件依赖的所有共享对象,并将这些对象放入一个装载集合中,然后把这些对象映射到进程中,如果这些共享对象还依赖其他共享对象,那么将所依赖的共T享对放到装载集合中。如此反复,直到所有依赖的共享对象都被装载进来。装载时符号的优先级是按照加入全局符号表的先后来排序的,当一个符号需要被加入全局符号表时,如果相同的符号名己经存在,则后加入的符号被忽路。 **小 Tip: **为了提高模块内部函数调用的效率,可使用 static 定义函数编译单元私有函数,就可以使用模块内部调用指令,可以加快函数的调用速度,前提是编译器要确保函数不被其他模块覆盖。 重定位和初始化 当上面的步骤完成之后,链接器开始重新遍历可执行文件和每个共享对象的重定位表, 将它们的 GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器己经拥有了进程的全局符号表。重定位完成后如果某个共享对象有 .init 段,那么动态链接器会执行 .init 段中的代码,用以实现动态共享对象特有的初始化过程,相应地,共享对象中还可能有 .finit 段, 当进程退出时会执行 .finit 段中的代码。 Linux动态链接器实现 对于静态链接的可执行文件来说,程序的入口就是 ELF 文件头里面的 e_entry 指定的入口。 对于动态链接的可执行文件来说,内核会分析它的动态链接器地址,将动态链接器映射至进程地址空间,然后把控制权交给动态链接器。 关于动态链接器有个值得思考的问题:动态链接器本身是动态链接的还是静态链接的? 动态链接器本身应该是静态链接的,它不能依赖于其他共享对象。动态链接器本身必须是 PIC 的吗? 动态链接器可以是 PIC 的也可以不是,但往往使用 PIC 会更加简单一些。原因如下:不是 PIC 的动态链接器,代码段无法共享,浪费内存。不是 PIC 的动态链接器本身初始化会更加复杂,因为自举时还需要对代码段进行重定位 。动态链接器可以被当作可执行文件运行,那么的装载地址应该是多少? 动态链接器作为一个共享库,内核在装载它时会为其选择一个合适的装载地址。显式运行时链接(运行时加载) 一般支持动态链接的系统,都支持程序的运行时加载,也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。满足运行时装载的共享对象往往被叫做动态装载库。 动态装载库的好处如下:使得程序的模块组织变得很灵活,可以用来实现一些诸如插件、驱动等功能。 不需要从一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用。 可以在运行的时候重新加载某个模块,程序本身不必重新启动就可以实现模块的增加、删除、更新等, 这对于很多需要长期运行的程序来说是很大的优势。动态库和一般的共享对象主要区别是: 共享对象是由动态链接器在程序启动之前负责装载和链接的,这一系列步骤都由动态链接器自动完成,对于程序本身是透明的。 动态库的装载则是通过一系列由动态链接器提供的 API: dlopen、dlsym、 dlerror 、dlclose 进行操作。 dlopen 这个函数用来打开一个动态库,并将其加载到进程的地址空间,完成初始化过程。函数的原型如下: void * dlopen(const char *filename, int flag);第一个参数 filename 是动态库的路径,路径可能是绝对路径也可能是相对路径,不同的路径存在不同的加载顺序。 如果将 filename 设置为 0 的话,dlopen 返回的是全局符号表的句柄,也就是说我们可以在运行时找到全局符号表里面的任何一个符号,并且可以执行它们。 第二个参数 flag 表示函数符号的解析方式,可以是 PLT 方式(也就是延迟绑定的机制),也可以是加载时即完 成所有的函数的绑定工作,两种方式必须二选其一。 函数的返回值是被加载的模块的句柄,这个向柄在 dlsym 或者 dlclose 时需要用到。 此外 dlopen 中还会执行模块中初始化部分的代码。 dlsym 这个函数是运行时装载的核心部分,我们可以通过这个函数找到所需要的符号。函数的原型如下: void * dlsym(void *handle, char *symbol);第一个参数是由 dlopen 返回的动态库的句柄 第二个参数即所要查找的符号的名字 如果 dlsym 找到了相应的符号,则返回该符号的值,没有找到相应的符号则返回 NULL。 符号的优先级 是当多个同名符号冲突时,先装入的符号优先,我们把这种优先级方式称为装载序列,由动态链接器装入和由 dlopen 装入的共享对象,动态链接器在进行符号的解析以及重定位时,都是采用装载序列,然而使用 dlsym 进行查找时,优先级却分两种类型:如果我们是在全局符号表中进行符号查找,那么由于全局符号表使用的是装载序列,所以 dlsym 使用的也是装载序列。 如果我们是对某个通过 dlopen 打开的共享对象进行符号查找的话,那么采用的是一种叫做依赖序列的优先级。它是以被 dlopen 打开的那个共享对象为根节点,对它所有依赖的共享对象进行广度优先遍历,直到找到符号为止。dlerror 监听 dlopen dlsym dlclose 是否成功执行,如果返回 NULL,则表示上一次调用成功,如果不是则返回相应的错误消息。 dlclose 函数作用与 dlopen 相反,系统对于已经加载的模块会存在一个计数,当计数为 0 时,会对模块进行卸载,之后执行模块的 .finit 段的代码,然后将相应的符号从符号表中去除,取消进程空间跟模块的映射关系,然后关闭模块文件。 dyld 关于 dyld (The dynamic link editor) 网上介绍的博客非常多,这里简单提一下,感兴趣的可以看下源码。 它是 Apple 的动态链接器, Mach-O 可执行文件会交由 dyld 负责链接 ,装载。目前发展了好几个版本:dyld 1.0 1996–2004) dyld 2.0 (2004–2007) dyld 2.x (2007–2017) dyld 3.0 (2017) dyld 4.0 (2022)下面是针对内参中介绍 dyld 各个版本的简单整理,从版本的差异中,也能看出苹果对于动态链接的过程的一个优化历程。是在大多数系统使用大型 C++ 动态库之前编写的,导致动态链接器必须做很多工作,而且速度非常慢。首先 dyld 1.0 使用了预绑定的技术预绑定是一种技术,我们试图为系统和应用程序中的每个 dylib 找到固定地址。动态加载器会尝试加载这些地址的所有内容,如果它成功了,它会编辑所有这些二进制文件,让这些预先计算的地址在里面。然后下一次当它把它们放到相同的地址时,它不需要做任何额外的工作。dyld 2.0 是对 dyld 1.0 的完全重写稍微扩展了Mach-o 格式并更新了 dyld 以便我们可以获得高效的 C++ 库支持。 它还具有完整的 dlopen 和 dlsym 实现以及正确的语义。 2.0 存在一些安全问题,因为它是为速度而设计的,所以它的健全性检查有限。 由于启动速度的提升,因此减少了预绑定的工作量。dyld 2.x 做了很多显著的改进,在程序进程内执行。添加了大量的架构和平台。增加安全性codeSigning 代码签名。 增加了 ASLR 机制。 对 Mach-o 标头中的许多内容添加了边界检查,这样就无法对格式错误的二进制文件执行某些类型的附加操作。摆脱预绑定并用称为共享缓存(share cache)的东西取而代之,合并了大部分系统动态库,并进行了优化:重新排列二进制文件以提高加载速度。 预链接动态库。 预构建 dyld 和 ObjC 使用的数据结构。dyld 3 出 3.0 版本主要是为了性能、可测试性、安全等方面考虑的。将大部分 dyld 移出进程,增加了可测试性;留在进程中的 dyld 位尽可能小,从而减少应用程序中的攻击面。移出进程的方式通过: 确定安全敏感组件。 确定它的昂贵部分,它们是可缓存的,这些是符号查找。大多数启动使用缓存,永远不必调用进程外的 Mach-o 解析器或编译器,而是简单地验证它们,增加启动速度,缓存步骤: 进程外的 Mach-o 解析器,解析所有搜索路径、所有@rpaths、所有可能影响您的启动的环境变量,解析 Mach-o 二进制文件,执行所有这些符号查找,用结果创建闭包。 进程内引擎,它验证启动闭包是正确的,然后它只是映射到 dylibs,并跳转到 main。 启动关闭缓存,系统应用程序关闭我们只是直接构建到共享缓存。dyld 4 目标是通过保持相同的 Mach-o 解析器来改进 dyld3,支持不需要预构建闭包的即时加载,也就是 Prebuilt + JustInTime 的双解析模式。新的抽象基类Loader,为进程中加载的每个 Mach-o 文件实例化一个 Loader 对象,Loader 有两个具体的子类 PrebuiltLoader 和 JustInTimeLoader。 PrebuiltLoader 只读的。它包含有关其 Mach-o 文件的预先计算的信息,包括其路径、验证信息、其依赖的 dylib 和一组预先计算的绑定目标。在启动时,dyld 会为程序寻找预构建的 PrebuiltLoader,验证完有效,则使用它。 如果没有有效的 PrebuiltLoader,那么创建并使用新的 JustInTimeLoader,JustInTimeLoader 然后通过解析 Mach-o 找到它的依赖项,进行实时的解析。总结程序可通过覆盖装入和页映射的两种模式,被操作系统装载到内存中运行,目前几乎所有的主流操作系统都是按页映射的方式装载可执行文件的,页映射的时候,段地址对齐处理不当会造成空间的浪费。 进程建立,首先创建一个独立的虚拟地址空间,然后建立起可执行文件和进程虚存之间的映射结构,设置可执行文件的入口,执行程序。随着程序的执行,会不断的产生页错误,操作系统会通过映射结构为进程分配相应的物理页面来满足进程执行的需求。 在 ELF 文件中使用 Program Header Table 保存 Segment 的信息,操作系统使用 VMA 来映射可执行文件中的各个 Segment,另外堆和栈等空间也是以 VMA 的形式存在的,除此之外还有称为 vdso 的 VMA 可与系统内核进行通信。 Linux 内核装载 ELF 时,首先会检查 ELF 可执行文件格式的有效性,再者设置动态链接器路径,对 ELF 文件进行映射,再初始化 ELF 进程环境,最后系统调用的返回地址修改成 ELF 可执行文件的入口。 由于静态链接对于计算机内存和磁盘的空间浪费非常严重,于是开始使用动态链接,把链接这个过程推迟到了运行时再进行,这就是动态链接的基本思想。 共享对象模块的访问根据模块所属内部或外部,指令调用或数据访问,一共分成了四种地址引用情况,针对这四种情况,都可实现了地址无关性,访问全局变量默认使用 GOT 的方式,数据段的绝对地址引用,装载时进行重定位,此外 ELF 还会使用 PLT 延迟绑定的方式,也就是一次被用到时才进行绑定。 动态链接中存在一些比较重要的段,.interp 段,.dynamic 段,动态符号表,以及一些动态链接重定位相关结构,除此之外操作系统通过进程的堆栈传递给动态链接器可执行文件和本进程的一些信息。 动态链接的步骤基本上分为 3 步,启动动态链接器本身,装载所有需要的共享对象,重定位和初始化。 满足运行时装载的共享对象往往被叫做动态装载库,它的装载是通过一系列由动态链接器提供的 API: dlopen、dlsym、 dlerror 、dlclose 进行操作。 dyld 是 Apple 的动态链接器, Mach-O 可执行文件会交由 dyld 负责链接 ,装载。已经从 1.0 版本发展到了 4.0 版本。

从校园到职场,他们是怎么过渡的

这次分享主题是「从校园到职场 -- 我的成长之路」,视频内容可以查看 B 站链接:从校园到实习再到秋招。上次的面试分享之后,阿卡拉提到关于刚毕业的学生也会有很多找工作的困扰,而且这个阶段能获取到的信息相对比较有限,如果做一期针对这个群体的分享也会是很有意义的。然后正好也接触到了两位毕业生 hzh 和豆豆,他俩一个是本科,一个是研究生,学习能力也非常强,也都在一线互联网公司实习过,所以就拉他们来做这次分享。 分享人介绍hzh 我是来自广东工业大学计算机学院的 hzh,是 23 届秋招的一名应届生。目前是在广州字节 TikTok 直播团队实习,做的是 iOS 开发,未来会去上海小红书发展。 豆豆 我叫豆豆,是曲阜师范大学,然后现在在读研三,我的研究方向是做推荐算法,有三段实习经历,目前是接了快手的 Offer。 在校篇 在学校期间个人的学习状态怎么样?计算机专业的同学在校期间卷不卷? hzh 我的学校是广州这边的一个双非,但整个学校的就业氛围还是比较强一点。我的学习状态主要是分成两个部分,一个是课程学习,另外一个是工作室的学习。 课程学习看每个人的自觉程度,像我这种比较懒散状态。其实一些课也没有比较认真地听,为了通过期末考试,才会临时突击一下,我很大一部分课程学习都是这样过来。 工作室那边的学习,主要是看自己的兴趣,我学的是 iOS 相关的一些知识,然后帮导师做一些项目。 关于周围的学习氛围,这个其实是要分环境的。我身边的氛围就是非常不卷,寝室每个人都是疯狂打游戏,参加社团活动打球,可以说是比较摆烂,但感觉这个也是一个比较正常的一个大学状态。 但是可能是因为我们班比较特殊,然后像隔壁班的话就会有比较卷的情况。我听说好像是有一个班里面有 20 个左右是绩点4.0,每一科平均都是 90 多分。 就我的宿舍来说,我们在春招之前都已经很长一段时间去刷 leetcode 了。这些主要是因为宿舍里面有个人,他比较厉害,就很喜欢刷那些困难题,我们有什么不会的也去问他。然后这种卷的话其实就是看自己想要达到什么程度,不是跟身边人去比,而是跟自己的预期去比。 豆豆 我在学校的学习状态基本上是自学,身边的同学在本科期间可能玩得比较多一些,我会有自己比较喜欢的一些方向,比较喜欢的一些项目,然后会自己去学习、实践。 本科大家都不是很卷,读研期间的话可能会因为导师分配的各种课程任务。大家需要更多的时间花在这种课业上。然后在秋招的话我身边的同学没有冲大厂的,70% 可能会选择这种国企、事业单位,20% 可能会去读博。这是研究生阶段,本科期间差不多 80% 大家都在考研,考虑直接就业的则比较少。 在校期间如何做职业选择,职业规划 hzh 我个人是有明确预期毕业要去做什么。我跟身边的人也经常去讨论过这个问题,大部分学生对这一块都是比较迷茫的。在校期间,大家都是在学校按部就班的学习,参加各种活动,然后突然间就到了大二,大三,发现自己可能需要去选择一个职业道路了,未来的一个发展也要去思考。有一些人其实甚至不愿意去想,比较晚才醒悟,比如说大三下,大四这样子,那他的准备时间,就不一定够了。 学校在这一块给的帮助也很少,应该是只有一门叫什么「大学生创业指南」,是一个水课。就是让大家了解一下创业的一些东西,让大家写一下简历,提前适应一下面试的时候该怎么回答。但这些都不能算是说去规划你未来人生要去走一个怎么样的职业道路。 我认为一个比较重要的了解职业信息的途径是,咨询直系的师兄师姐,他们大多是已经走在就业的道路上了,已经思考过这方面的选择。然后我们可以去就了解一下他们的一个选择或者听取一些建议,可能会给我一些帮助和启发。 QA:现在这个阶段,你那个专业,整体就业情况怎么样,定好工作的比例大概占多少? 其实如果不是今年的比较特殊情况,按往年来看我们整体就业率都是很高的,比如说我在的那个工作室,去年基本上都是能进大厂的。但是今年的话就是比较难,我们那个工作室,做前端和后端的,十几个人好像就只有一两个拿到了校招Offer。客户端的话可能会比例高一些,但是也不会特别高,反正今年就是非常困难。 豆豆 我们学校跟广东那边差别好大,在我们学校基本上就是做科研,做论文。 我在大学的时候做了一个学校的教务系统。是一个iOS版的,所以当时就决定要走开发这条路了。我其实明确毕业要做什么还是比较早的。 在职业选择上还可以通过加群,特别是那些有一线开发人员的群,可以获取直接的外部信息。因为在学校的话,这个信息还是比较闭塞的,身边做过开发的人也几乎没有。 就你的观察,现阶段哪个专业处于热门状态,计算机专业还是一个好专业吗,该如何做出最优的职业选择?hzh 我感觉目前互联网还是处一个比较热门的情况,但是相比于过去可能差了不少,只不过又提高了许多门槛。但是它的一些薪资待遇、福利以及未来发展都还是有一定优势的,至少还是可以排到前10%的。 关于职业选择,我总结下来就是三种:就业、考公、考研。我们学校的一个就业比例是比较高,考公考研相对少。 我身边也有一些朋友是在字节实习之后发现互联网的生活可能不太适合,然后就立马离职,然后最终选择 all in 考公这样子。所以说计算机也不是适合所有人,还需要考虑个人兴趣以及对压力的接受程度。 豆豆 我觉得现在热门行业的话有互联网这一块,互联网医院,互联网医疗就是做做核酸这一块可能比较比可能比较热门。然后行业最难的话就是公募私就是这种量化,我觉得这个行业其实门槛还是挺高的,我觉得这个应该是相对来说会难一些。职业选择就我身边同学来说的话,基本上都是以往这种稳定方向发展,计算机还是一个好的选择吧。这个我觉得如果没有一个很好的背景或者是家里有比较好的一个资源,我觉得相对其他专业来说的话,计算机还是一个比较好的选择。 考公是另一个热门选项,但成功的比例还是比较少的,一个班里去尝试的,可能最多 10% 的同学能够考上公务员。 实习篇 实习为什么选择了 iOS 呢?然后学习过程中遇到了哪些困难又如何克服的?为实习准备了哪些? hzh 选择 iOS 是因为学校有个工作室,他大一下的时候有过一个宣讲,当时听下来就是有各种方向,前端、后台、客户端还有各种机器学习。自己算是果粉,对移动端也感兴趣,然后就选择了iOS 。当时手边也有一个 Mac 电脑,然后直接就咔咔整了。 学习之中遇见的困难还是挺多的,主要来源于入门的初期阶段。要去学习计算机的思维,还有解决问题的整个思路和方法。工作室里也有一些考核,我之前会在一些简单问题上面卡很久,会怀疑自己的能力等等。但过了这个阶段就好很多饿了。 到字节实习准备的东西就是计算机基础,算法,iOS 知识点还有一些项目这几个方面。 计算机基础的话其实就是在校的课程,计算机网络、操作系统、数据结构。这些知识点更重要的是理解和记忆,最重要的还是要能够通过自己的话将这些内容讲出来,在学习之中也可以沉淀出自己的笔记,后面反复过。 关于算法,我是跟着一个公众号,「代码随想录」。里面会循序渐进,链表、二叉树、贪心、动态规划等,然后我跟着里面刷,大概刷了 500 多题。 因为当时做学校的项目,都比较简单,很难提炼说里面的难点内容。我就自己去网上看一些博客,偷学一些性能优化、滑动优化等这些内容。 然后再去学 iOS 的和 OC 的一些底层原理,我还去看了一些 objc 4 的一些源码。但我感觉这个应该没有太大作用,因为面试一个校招生其实是不会去问一些非常底层和源码的一些东西。 实习岗的竞争个人感觉还是蛮卷的,我面试了挺多公司的,最后也没通过几家。印象比较深刻的应该是拼多多在面完三轮技术面之后,在 HR 面挂了,这也侧面体现出了竞争大吧。 豆豆 我选 iOS 当时是因为给学校做了那个教务系统,就是大二,大家的时候学校没有这个iOS版教务系统,然后就自己去学习,把它给做了出来。然后大三大四也接触了一些 PHP 还有 Java。 真正选择 iOS 是在研一的上学期,当时是想了一些行业的问题,就是这个互联网不可能一直跟当时一样,那种如日中天肯定会达到一个瓶颈期,过后也会遇到困难。但就可能在客户端方面没那么卷,然后就选择了 iOS。 学习困难方面的话,肯定会有的,但很难去表述这个东西。一般是遇到问题然后去把它解决掉。其实更多的是把这些问题,答案,还有解决问题的方式给内化,就是形成了一个解决问题的能力。 实习准备的话刚刚跟 hzh 说的差不多,算法、计算机网络、操作系统,还有组成原理。还有这个平时写过的一些项目。我觉得实习的话可能竞争,对于客户端来说的没那么大,对后端和前端可能竞争会更大一些。 实习跟在校课设有什么不同?实习期间进行转正的比例大概是多少? hzh 实习跟在校课设最大的不同,我感觉可能就是一个规范的流程,跟我们就是自己去随便写点 demo 不一样。然后公司里面其实它整个流程其实还是比较长的,会有各种需求初评、细评,然后技术评审,然后排期开发,提测,一些比较规范的流程。 还有一些合作方面,自己做的一些东西其实是不用跟别人合作的。你在实习的时候是要经常遇到跟别人合作的情形,这沟通能力会有一些要求。然后实习期间最大的收获,我在面试实习的时候就是觉得自己的项目经历是比较弱,然后在实习的时候也去补了这一块知识,然后参与了整个我们公司内部的那个项目的开发,了解了其中一些知识,也做了很多需求。算是补齐了我之前的一个短板,这样子秋招竞争力会更强一点。 在字节,今年的转正应该是比较困难的,往年其听那些师兄说 是 80、90% 的转正率,今年保守估计应该是40、50%。 豆豆 实习跟课设不同的一点就是在实习过程中那个接触到的项目,它是有排期的,有时间规划。在学校自己做项目,可能就没有这么完整的时间观念了,就是哪天想起来要做一点,就花上时间去做一下。 实习期间最大的收获,我觉得更多的是能接触到更多的人。然后我会跟他们很多人都交流想法,我觉得这种人际交往是我收获比较大的一点。 转正率不好说,我是没转正就跑了。 秋招篇 今年秋招被称为最难就业季,计算机也被冠以最卷专业,大家的秋招是怎么准备的?秋招的结果怎么样,有哪些值得总结的经验?hzh 我是边实习边秋招的状态,因为实习的强度比较大,准备的时间也比较短。我大概准备一两个月,就直接投简历了。这一两个月边上班,然后下班的时间去刷一下题,准备的内容就跟上面实习准备的差不多。 秋招会偏向实际做的东西去准备多一点。秋招从结果上来讲的话其实还行,但是过程是非常痛苦的,边实习边秋招,整个人是一个非常累的状态。 我个人觉得值得总结的经验就是千万不要边实习边秋招。除非你的实习工作强度是比较轻松的,或者你对于转正有比较大的把握。 还有投简历的时机,我觉得越早越好,就算你还在一个准备状态,已经可以开始投了。因为有些公司在提前批的时候是不需要笔试的,对简历筛选也没有那么严格。到正式批的时候,会多一个笔试的流程,最终走下来可能会浪费更多的时间,还有一些公司在提前批就已经招完了。 豆豆 我准备秋招其实还算比较早的,就是在研一期间,包括实习阶段就开始为秋招准备了。当时是把计算机网络还有操作系统这些知识点去列成一个思维导图。在每次面试之前,我都会把他们有针对性地再复习一遍。我还针对牛客网上的一些面试题,把它们进行了一个归类整理,然后再用自己的语言去表述,也做了一个思维导图,把它给存起来。 然后就是去刷题,其实这个是断断续续的,把一些比较常考的那种热门题先刷一遍。今年秋招其实拿了六七家 Offer。经验的话,我觉得刷题还是要坚持下来,因为每次面试都是必须经历的,而且算法比重一般比较大,几乎每场面试都会有这种算法题,大概占 80%。如果这个题写不出来,可能就要被挂掉。 保持刷题的这种节奏,这样才能让自己的选择广一点。 成长篇 学校的教学环境,实习,个人学习,哪个对自己的成长帮助更大?为什么?从大学到职场的转变,是否经历过迷茫期,又是如何克服的? hzh 从我现在的角度来看是个人学习 > 实习环境 > 学校教学。学习这个事情永远都是自己的,无论你有没有学到或者是有没有去沉淀下来,都是你自己的事情。比如像我实习时遇到的一些问题,解决不了就会去直接问同事或者 mentor。问完之后可以直接解决,但是后续遇见相同或者类似的问题的话,还是要去问别人,这样自己的能力提升是很有限的。后来我意识到这一点,就会投入更多时间去自我学习。 我的迷茫期是对于一些工作内容强度和节奏比较不适应,感觉实习的过程中压力也比较大。然后我去跟我的一些朋友、导师或者同事讨论这个问题,发现大家在刚刚从学校到社会这个阶段,都是会有这个问题的。 这个自己要去想清楚这件事,你是想要一个怎么样的生活,期望生活是怎么一个节奏,是坚持往互联网这个方向,还是考虑其他更适合自己的事情。然后可以多去征询别人的意见,多去跟别人讨论自己的一些困惑。 但这些都是一些输入,是外部的建议,最终还是要靠自己去做归纳和总结。 豆豆 我也认为个人学习 > 实习 > 学校教学。因为在学校,更重要的是培养学生的科研能力,挖掘问题,然后再去解决这个问题。这对就业的帮助可能没那么大,对于就业,更需要的还是自我学习能力。 大学到职场的迷茫期,我其实当时也有过,就是在实习过程中不知道自己要干嘛,我觉得这个还是要多多交流,多请教别人,我一般会去在问一些职场上的朋友或者是比较熟悉的一些人。其实每个人的成长都会有这种处于十字路口的阶段,也不需要太焦虑。 对于现在计算机专业的学生有哪些建议?有哪些道理是步入社会才学到的? hzh 其实也不是建议,总结一些我的看法吧。 首先是学习,我大学的一些课程大部分应该都是一个混的状态,像数据结构,操作系统这些基础课,都是上课经常玩手机,最终期末的时候再去突击学习。这是一个非常不好的习惯。如果能够重新来的话,我会在上课时更专注一点,多听老师见解,去培养自己计算机思维还有逻辑能力,因为这些能力是会伴随自己整个职业生涯的。 对于一些比较水的课,可以不用在意老师的看法,只要不影响毕业可以随意一些。一些课外活动和社团,如果自己感兴趣的话,可以多参加几个。 最重要的是找到一个志同道合的伙伴,做一些有意义的事情。在这个过程拓宽我们的人脉,锻炼一些社交能力、合作能力、沟通能力。这些都是蛮重要的,无论是遇见了什么问题,都可以找人去沟通,去听听看他对这件事情的意见。 步入社会才学习到的道理:要有 Owner 意识,在职场上,你写的这段代码,你做的这个东西,你要对它负责,任何时候,任何相关的问题,都要做到可以找我解决的程度。 培养自我学习能力,不要遇到什么困难就去请求别人帮助,我们要摆脱对外界的依赖。 说话要有分寸一点,谨言慎行,少说多做。比如说公司的一些红线内容或者其他涉密内容,都谨慎讨论。豆豆 我觉得对于计算机专业的学生,还是要提前准备,无论是去就业或者是去读研或者是去读博,一定要提前决定,提前准备,不要到了临近报考的那个时间段,才去准备。 第二个就是学习跟实践相结合,学到了一些东西,自己也手动去操作一遍,去实践一遍,这样可能会对这个知识点掌握得会扎实一点样。 步入社会才学到的道理:更多的去理解业务,把技术当做一种工具。 团结好身边同事,跟大家要保持一个良好的相处模式。

2022 年小结

在 12 月 31 号晚上这天,打开朋友圈大家都在告别 2022、迎接 2023,我却想不到任何值得发的内容。没有外出体会元旦的节日氛围,也没有观看任何跨年活动,2022 年最后一秒跟全年的 3153.6 万秒没有任何区别。 甚至这篇总结都差点没有,回看了过去几年的年终总结,发现了一些有趣的东西,自己看待问题的想法在变化,关注在意的事情在变化,价值观也在变化。总别不代表有多大意义,却表示我当时回望走过的路时所看到风景,今年的景色如何,也记录一下。 生活 生活中最大的变化是身份的转换:我当爸爸了。孩子 5 月份出生,前半年准备迎接孩子的降生,后半年照顾养育孩子,整个一年基本都在围绕孩子转。 迎接孩子迎接孩子,不是有喜悦还有担心,每次产检都像过关一样:NT、唐氏筛查、大排畸、小排畸、糖耐、胎心监护等等,每次等结果都是忐忑的,好在一路绿灯,媳妇健康孩子也健康。 唯一的波折发生在离预产期还差一周的时候,当时北京正值疫情封控比较严的阶段,我们单元楼出现一个次密接,整单元需要封控,时间也是一周。从得知要封控开始不到两个小时的时间,楼道就被围上了铁门,有专人看守,只进不出。因为在这不久前看了上海防疫因为核酸过期无法接诊导致孕妇流产的事件,我们就很担心。跟居委会反复确认,如果临近生产,我们怎么去医院,会不会因为封控问题拒诊。居委会整体都比较负责,还联系了昌平妇幼,作为兜底,确保临产一定有医院接收,有了这些信息多少让我们安心了一些。 预产期很准,媳妇赶在封控的最后一天临产,凌晨 3 点叫的救护车,一路上我都心惊胆战,直到媳妇到了医院躺在病床,来医生问诊,我才安心一些,到了医院一切就可以交给医生了。 但因为我们属于封控人员,并未安排到产房,而是到了发热门诊,从妇产科调取的医护人员给我们接生。发热门诊医疗条件有限,没有无痛、没有导乐、没有护工,这意味着整个生产过程都需要自己扛过去。医生预估大概10点到12点之间才会生产,在不确定中焦急的情绪和宫缩带来的痛疼,让每一分每一秒都显得非常漫长。我也切身体会了,我们的生日就是妈妈的受难日这句话。 另一方面生的时候有多痛,听到孩子的哭啼就有多喜悦。整个一层楼只有我们在接生,标准的哭啼声响彻整个楼层,这个时间是 2022 年 5 月 15 号上午 10 点 8 分。 护士把孩子推出来,掰着她的小手小脚说,十根手指,十根脚趾不多不少啊。我点头肯定,我打量着这个小家伙,浑身呈粉红色,手因为在羊水里泡了很久,有点泛白,眼睛一直闭着,脸上还有一片一片的胎脂。这就是每个人刚出生时的样子了,这就是一个新生命了。 养育孩子关于名字想了好几版,才有了我跟媳妇都满意的结果:张清越。「清越」是汉语词汇,意为高超出众,清秀拔俗。也指声音悠扬动听。苏轼 《石钟山记》:「得双石於潭上,扣而聆之,南声函胡,北音清越。」寓意是一部分,我们的期望只有一个,希望清越可以健健康康,快快乐乐。 到今天清越已经出生 230 天了,以后除了年份的计数,我又多了一个计算时间的方式。在这不到八个月的时间,我跟媳妇不知熬了多少个夜,对于养孩子也有了很多感悟,俗话说不经一事,不涨一智,任何事情都需要经历才能获得深刻的感悟。养孩子最大的负担是时间成本,孩子需要陪伴,需要照料。需要考虑父母能否帮助带孩子,如果不能的话,是否要夫妻某一方专职带孩子。请保姆是一个过于理想的方案,一个是信任一个是孩子本身对于陌生人的接受程度如何,除非孩子出生就有固定的保姆帮助照看。我遇见了不少案例是,因为孩子,夫妻一方辞职带孩子,双方都辞掉带孩子的也有。 要提前做一些如何照料孩子的功课,这里推荐一本书《崔玉涛育儿百科》,它按照时间维度,从孩子出生到六周岁整个过程,整个过程孩子的发育变化和各类注意事项都有说明。比如孩子便便不同颜色表示什么状态、如何处理黄疸、脐带如何消毒、二月闹怎么处理、吐奶怎么办、辅食什么时候吃、该吃什么、如何处理孩子的情绪、发生意外怎么处理等等。提前了解这些内容不至于遇事慌张,而且也是对孩子的负责。 照料新生儿对父母冲击最大的是睡眠问题,孩子的睡眠是零散的,一晚上醒 2 次,醒 5 次都是有可能的。而且每次醒来也不一定哄哄就能入睡,有时还要闹个 1 小时才能睡着。她累了就直接睡了,大人可不一定就马上能入睡。因为孩子睡眠问题我们专门去儿研所看过一次,医生说可能是消化问题,药吃完,又过了一段时间睡眠才安稳些,可能是药的作用也可能是成长起来了。当时在医院还遇到一位妈妈说,她家孩子 5 岁了,一夜还醒 3-4 次,根本没法上班,就把工作辞了。 和父母的育儿观念发生冲突时,如果是无关紧要的事情就尊重父母的做法。 孩子会爬之后,最好一刻也别让他离开自己的视线。有一次妈妈上厕所的功夫,她睡醒了就一个劲爬,从床上掉了下来。 个人时间会被侵占很多,娱乐活动基本没了,你不能把她独自放在家里,去外面还要考虑换尿不湿喂奶等一系列事情;想看个综艺、看会书,孩子睡着之前就别想了。 每个人的耐心都是有限度的,即使知道孩子什么都不懂,也会让人失去理智,忍不住出手。 孩子醒着的时候嫌她闹人想让她睡觉,哄睡着的时候看着粉嘟嘟的又想把她叫醒陪她玩。 即使做出再大牺牲,受过再多的苦,看到孩子,感受到来自孩子的慰藉,一种从未感受过的慰藉,那一切都是值得的,甚至认为这是一种奖赏。这真的非常神奇,可能这就是养孩子的意义吧。工作 年初的时候来到抖音 iOS 基础技术团队,开启了一个全新的职业篇章。基础技术的不同 之前的工作经历都是在做业务开发,业务需求来自于 PM,自己是在实现 PM 的规划。基础技术是面向开发者,为开发者赋能的一个角色,这里没有明确的 PM,很多时候自己就是 PM 的角色。看到一个业务痛点 > 想到一种解决思路 > 调研公司内同类型问题解决方案 > 细化和完善这个技术方案 > 方案评审 > 协调投入资源 > 对齐 OKR > 投入研发工作 > 测试 > 数据分析 > 交付 > 后期迭代规划,然后再一轮的循环。这是大体流程,很像一个完整的业务需求开发,唯一的不同是,你不是只负责研发一个环节,而是所有环节。需要注意的是要做成一件事绝不仅仅是把手头工作做好就行了,很多事情都会依赖于多个团队的合作,跨团队之间目标不对齐,优先级不一致,遇到预期之外的困难等等,都会影响最终结果。 所以我平常的工作内容可能只有不到一半的时间是用来写代码的,另外一大部分时间都是在收集信息、拉会讨论、分析数据、方案验证、做规划等等。因为涉及工作范围比较广,基本脱离了 iOS 的范畴,接触到了非常多的开发语言和框架。当我们考虑整体目标时,会发现研发只是实现目标的一个环节,技术栈则是完成这个环节的手段,选什么技术去完成不重要的,能否完成才重要。 可以感受到基础技术其实是一个不断拓展研发边界的岗位。 字节工作感受 也聊一下在字节的工作感受吧。 优秀的人非常多 同事都非常优秀,优秀不只体现在技术实力,还有工作态度。他们很多都非常负责,责任感强,Owner 意识强。他们生活中也很丰富,有一定特长,有积极的兴趣爱好,给我感觉就是学习又好,又会玩。 字节员工整体是年轻化的,我之前在爱奇艺,基本是组内最小的,到了字节,发现大部分人都比我小。 但字节的工作内容却一点都不简单,这里有非常复杂的业务场景可以用于锻炼。我看到很多毕业没多久的同事工作能力都非常强,根本不像是刚毕业的样子,这就得益于工作环境带来的业务能力,毫不夸张的说,他工作一年可能比我之前工作两年学到的东西都要多。这应该也就是为什么字节年轻化的原因。所以有时候也必须的承认选择就是大于努力。 工作感受 分享一些感受吧,这样可以比较全的概括一些东西。工作强度略大,这是跟爱奇艺的工作相比。有一个从蚂蚁来的同事说,跟蚂蚁比,这边工作算轻松了。 福利好,饮食上特别是大钟寺开火之后,每天去食堂都像是去商场,一周下来每顿一个样都不带重复的,每周还会再调整菜单。开火后的一个月里,我胖了 4 斤。其他方面,额外的陪护假,育儿假,房补,吃饭提前,打车时间提前,字节虽然还是被说卷,但这些变动表示着公司在给所有员工营造轻松的工作氛围。 团队氛围好,不管是打配合还是平常一起玩耍,之前活动日,老大都会催着我们赶紧下班。但听说不少团队还存在 PUA,刷工时的情况,只能说合适的团队可遇不可求,进入合适自己的团队需要一定的运气。 分享氛围很强,小组分享,团队分享,外部团队分享,每周都有好几期,只要感兴趣都可以学习,这一点公司更像是大学的感觉。 年轻化,不只是一线员工,领导层也偏年轻化。 不同团队工作感受差异较大,在脉脉看到很多吐槽的声音,甚至有些人成了字节黑。但记住字节团队非常多,也因为成长的快,所以会存在一些不合理的现象。如果你对来字节工作有顾虑可以问清楚部门团队,不要以偏概全。 非常适合应届生,这里能看到的东西非常多,对于应届生可以快速成长起来,但目前来看面试难度会大很多,需要提早准备。个人感悟 影视作品 看了两部动漫作品:《鬼灭之刃》动漫。飞碟社制作,质量非常高,我没看过原漫画,但不少人都说,动漫质量是超越漫画的。2023 年 4 月会迎来第三季《锻刀村篇》,值得期待。《一拳超人》漫画。饿狼篇迎来终章,后期战力有些崩溃坏,插曲:村田因为被粉丝骂中间还改过一次剧情,做续作的漫画家真不好当。抛开剧情问题,漫画质量还是非常不错的,我每次看的时候都是耐着性子慢慢翻,生怕遗漏某处内容。剧集:《开端》:陪媳妇看的,她喜欢白敬亭,我喜欢赵今麦。《怪奇物语》:恐怖氛围营造非常好,有点不太能接受的是,剧中主角男孩都越长越残,女主越长越好看。游戏:《原神》:大概是 7 月份注册的,玩的不连贯,到现在冒烟才 43 级,刚开稻妻不久。原神整体是靠剧情和角色培养来吸引玩家的,像是剧情 PV 《神女劈观》《人间至味》,角色演示《纳西妲:无垠无忧》《雷电将军:净土裁断》,都非常精彩。听说很多游戏公司也在做开放世界,这里就可以断言,开放世界没人会超过米哈游。书籍:《软件研发效能提升之美》:讲研发效能涉及的各个方面,概念居多。《软件研发效能提升之美》书评《卓有成效的工程师》:提出杠杆率的概念,杠杆率 = 产出的影响 / 投入的时间,时间被经常提到,对应于工作效率,但另一个产出影响却经常被忽略,做高 ROI 高的事情,低 ROI 事情应该被提前规避。《卓有成效的工程师》书评《持续交付》:对于大型项目,发布过程有非常多事情值得注意,如果这个过程让你头疼,那说明有很多事情亟待优化。好的持续交付需要遵循两个原则:1、将几乎所有事情自动化。2、将构建、部署、测试和发布软件所需的东西全部纳入版本控制之中。《为什么精英都是时间控》:时间使用的效果能否翻倍,这不是不可以达到的,因为有的人就是能在同样时间内做比其他人多很多的事情。书中列举了很多方法,关于休息,关于快速进入工作状态,很有效果。之前有过某种感受,被书中一总结,感觉提供了更深层的支持。《金字塔原理》:写作和思考都需要遵循的一定规范,如何引入话题,如何跟让人理解,对我写技术文档有挺大帮助。《Apple Debugging & Reverse Engineering》:讲 Apple 调试与逆向的书,raywenderlich 出品,对 LLDB、汇编、DTrace 有完整介绍,不只是逆向,对于想探究 Apple 编程框架底层原理都有很大帮助。关于公众号 在字节工作节奏比之前紧一些,所以投入到公众号的时间比去年少了很多。现在周报的很多事项都由晨光在负责,也因为他,周报才能稳定输出,很少拖更,非常感谢他这一年的付出。对于后续周报的走向,可能会优先发布质量,其次发布频次;对于访谈内容,会更多考虑选题多样性,希望新的一年能结识更多有趣的人。 今年有几篇阅读量和反响都比较好的文章,再推荐一下。 2021 面试心得 人物访谈 | 东坡肘子:健康&开发 人物访谈 | 极简时钟作者:道哥 macOS 进化史 iOS 求职寒冬?听听他们怎么说 关于社会的观察今年围绕疫情发生了很多事情,群众的情绪,基层的处理方式,专家的建议,政府的决策等等,能说的事情非常多,但不能说的也非常多,这也迫使自己思考一些东西,以下仅代表个人目前的看法。很多事情都是个人决定不了的,是遵循规则,还是打破规则,没有定论,没有指导,任何情况都需要看处境。但更好的状况是我们不是只有一个选择,而是更多选择。不要迷信权威,也不要迷信官方,它们都是有立场的,这个立场不一定是指向自己。也不会有任何机构能考虑到每个人,自己的处境还需要自己负责。不要高估政策决策者的能力,也不要低估普通民众的智慧。任何情绪化的做法都是不可取的,保持理性,不随意传播具有煽动性的消息。新冠的管控结束了,但新冠远没有结束,这不代表回到了疫情前的生活,我们仍需小心。能争取的利益就尽量争取,多考虑个人少考虑集体,集体由个人组成的,集体利益是每个人的利益之和。少提问题,多想解决方案。心理上保持乐观,但行动上为悲观做准备。当你感觉辛苦时,有很多人比你辛苦的多的多。看到了太多为生活挣扎的人却依然热爱生活,真的没什么难关是过不去的。新年计划 立计划总是乐观激进的,我们基本考虑不到中间发生意料之外事情的情况,列计划的心境很难代表全年,所以对于 2023 的规划,决定不再列 KR,而只是列一些 O,对应的 KR 在实施过程中记录。 O1:提升家庭生活参与度 平常对于工作上的投入过多,一天下来跟孩子相处的时间都不足一个小时,孩子夜里哭闹,我去哄还会哭的更厉害。11、12 月因为北京防疫的原因在家办公了一个多月时间,慢慢的她才开始接受我。再往后会叫爸爸妈妈,会走路,希望我都能陪伴见证。 O2:提升产品力 产品力更多表现的是产品敏感度、产品规划能力、项目管理及数据分析等能力。 O3:更加健康的生活方式 健康在于两方面,一个是休息,一个是运动。今年跑步里程 140km,还不到预期的一半。希望今年睡的更香,跑的更远。 O4:增加对生活的记录 既包括学习产出,像是公众号内容,一些发到网上的零碎想法,也多一些对生活瞬间的记录。我从初三养成写日记的习惯,一直到大一,写了很多本,有一本 10 年和 11 年的日记还保存在身边。翻到 2010 年 12 月 31 号:看了 17 集《火影》,夜里两点多才睡,玩的真够可以。2011年1月1号:新的一年就这样悄无声息地跨过来了,就像什么也没发生一样。回家过元旦,见证了一些人的变化。发小朋朋要结婚了,跟他聊天,他说后悔相亲对象没有前一个好,但必须接受目前的状况。另外两个发小,亚东、鹏博一个染了黄头发,一个烫了头,让我想起来看到的一句话「脑袋里的东西越少,头上的花样越多」。跟弟弟交谈,他说明白周围人对他的偏见,自己正在努力改变。我惊讶于他成熟起来了,可能之前真的小看他了。2011年1月2号: 姑姑和姑父打工回来,让这个阳历年热闹了很多。姑姑张罗着做饭,炊烟雾气中,灵活的身影为寒冬带来了许多温暖。吃过饭之后,把我们几个所有的衣服都收集起来去洗,然后打扫房间、晒被子。我从姑姑身上感受到一种母亲般的温暖,母亲真是一个完整家庭最重要的因素了。 要上学了,姑姑给我三百块钱,我推脱不掉只能收下。坐在车上,又回忆起姑姑的形象。虽然她没有接受过太多教育,从小在农村长大,但她拥有善良、淳朴这些最美丽的品格。还有妈妈、婶婶、大姨她们,任劳任怨,且热爱生活,平凡却又显得那么伟大。真心祝福她们所有人幸福快乐。由于过于久远我完全记不起当时发生的这些事情了,又顺着读了几篇,让自己穿越回去,好几个瞬间都被触动到了。现在生活条件更好了,但比以前更幸福吗,我不确定。有一点是,当我好几次准备去写点什么记录时,总感觉没得写。可能就是现在的生活更平淡,没有之前有意思,也还有可能是忙着眼前的鸡毛蒜皮,丢失了发现美好的能力。新的一年希望能再找回这个能力,不确定能拾起多少碎片,但捡起一个是一个吧。

《程序员的自我修养(链接、装载与库)》学习笔记二(编译和链接)

继续学习《程序员的自我修养 - 链接、装载与库》的第二个大的部分,这一部分包含本书的二、三、四、五章节,作者深入浅出的给我们讲解了静态链接的相关的知识,干货满满,受益良多。 作为一名 iOS 开发人员,我们几乎每天都会用 Xcode 构建我们的程序,但是编译和链接的过程,我们却很少关注, Xcode 作为一种 IDE(集成开发环境)功能十分强大,它能够和 Mac OS 系统中其它的工具协作,例如编译器 gcc,它们提供的默认配置、编译和链接参数对于大部分的应用开发已经足够使用了,也正是由于这些集成工具的存在,我们也忽略了软件的运行机制与机理。 如果我们能深入的了解这些软件的运行机制,也许我们就能在解决问题的时候,多上一种思路,甚至是打破一些瓶颈。所以马上回到今天打算研究的部分,静态链接。大体来说,静态链接是由链接器在链接时将库的内容加入到可执行程序中的做法。具体分为四个部分来讲解:编译和链接,目标文件里有什么,静态链接,Windows PE/COFF,具体的知识点分布如下:还记得连载上一文提到的 Hello World 程序吗? #include <stdio.h>int main() { printf("Hello World\n"); return 0; }在 Linux 下执行,需要使用使用 gcc 来编译,首先通过 gcc hello.c,这会产生默认命名为 a.out 的可执行文件,然后通过 ./a.out 执行这个文件,输出 Hello World,事实上,这里要分为4个步骤:预处理,编译,汇编,链接。编译 在编译之前会有个预编译的过程,使用到的指令为:gcc -E hello.c hello.i 预编译主要做了如下的操作:处理 # 开头的指令,例如 #define 进行宏替换,#if、#ifdef,#elif,#else,#endif。 删除注释。 添加行号和文件名标识,用于编译时产生调试用的信息等。 保留 #pragma 编译器指令。 包含的头文件展开。预编译过后就是编译阶段,编译器就是将高级语言翻译成机器语言的一个工具,之所以使用高级语言,是因为它能使得程序员更加关注程序本身的逻辑,而不是计算机本身的限制(字长、内存大小、通信方式、存储方式等)。高级语言虽然提高了开发的效率,但是机器却无法识别,需要通过编译器,将其翻译成机器认识的语言,翻译的具体过程如下所示。具体的过程分为 6 步: 扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化,现代的 gcc 会将预编译和编译合并成一个步骤 cc1。 编译使用的指令:gcc -S hello.i hello.s gcc 这个命令只是一些后台程序的包装,它会根据不同的参数要求去调用预译编程序 cc1、汇编器 as、链接器 ld。 顺道我们回顾一下 Clang, Clang 是一个由 Apple 主动编写,是 LLVM 项目中的一个子项目。基于 LLVM 的轻量级编译器,之初是为了替代 GCC,提供更快的编译速度。他是负责编译 C、C++、OC 语言的编译器。 测试代码 CompilerExpression.c 如下: void test() { int index = 1; int array[3]; array[index] = (index + 4) * (2 + 6); }词法分析 CompilerExpression.c 源代码输入到扫描器,运用一种类似于有限状态机的算法将源代码的字符序列分割成一系列的记号,记号分为关键字、标识符、字面量(数字、字符串)、特殊符号(加号、等号)等。 同时会将标识符号放入符号表,将数字、字符串常量放到文字表,以备后续使用。 针对上面 CompilerExpression.c 里面的代码,我们可以使用 clang 进行词法分析:clang -fmodules -fsyntax-only -Xclang -dump-tokens CompilerExpression.c ,打印如下: void 'void' [StartOfLine] Loc=<CompilerExpression.c:1:1> identifier 'test' [LeadingSpace] Loc=<CompilerExpression.c:1:6> l_paren '(' Loc=<CompilerExpression.c:1:10> r_paren ')' Loc=<CompilerExpression.c:1:11> l_brace '{' [LeadingSpace] Loc=<CompilerExpression.c:1:13> int 'int' [StartOfLine] [LeadingSpace] Loc=<CompilerExpression.c:2:5> identifier 'index' [LeadingSpace] Loc=<CompilerExpression.c:2:9> equal '=' [LeadingSpace] Loc=<CompilerExpression.c:2:15> numeric_constant '1' [LeadingSpace] Loc=<CompilerExpression.c:2:17> semi ';' Loc=<CompilerExpression.c:2:18> int 'int' [StartOfLine] [LeadingSpace] Loc=<CompilerExpression.c:3:5> identifier 'array' [LeadingSpace] Loc=<CompilerExpression.c:3:9> l_square '[' Loc=<CompilerExpression.c:3:14> numeric_constant '3' Loc=<CompilerExpression.c:3:15> r_square ']' Loc=<CompilerExpression.c:3:16> semi ';' Loc=<CompilerExpression.c:3:17> identifier 'array' [StartOfLine] [LeadingSpace] Loc=<CompilerExpression.c:4:5> l_square '[' Loc=<CompilerExpression.c:4:10> identifier 'index' Loc=<CompilerExpression.c:4:11> r_square ']' Loc=<CompilerExpression.c:4:16> equal '=' [LeadingSpace] Loc=<CompilerExpression.c:4:18> l_paren '(' [LeadingSpace] Loc=<CompilerExpression.c:4:20> identifier 'index' Loc=<CompilerExpression.c:4:21> ...上文有说过,词法分析的结果会将源代码分解成一个个小的 Token,标明了所在的行数和列数。 语法分析 对词法分析的结果进行语法分析,生成语法树(以表达式为节点的树),复杂的语句就是很多表达式的组合,编译器的开发者仅仅需要改变语法规则,即可适配多种编程语言。 上面的代码中的语句,就是由赋值表达式、加法表达式、乘法表达式、数组表达式、括号表达式组合成的复杂语句,经过语法分析之后,就会形成下图所示的语法树,在这个阶段如果出现表达式不合法(括号不匹配、表达式缺少操作符)编译器就会报告语法分析阶段的错误:语法分析会形成抽象语法树 AST,我们继续使用 clang 命令进行语法分析 clang -fmodules -fsyntax-only -Xclang -ast-dump CompilerExpression.c,得到的结果如下: TranslationUnitDecl 0x7fccdc822808 <<invalid sloc>> <invalid sloc> |-TypedefDecl 0x7fccdc823048 <<invalid sloc>> <invalid sloc> implicit __int128_t '__int128' | `-BuiltinType 0x7fccdc822dd0 '__int128' `-FunctionDecl 0x7fccdd04d048 <CompilerExpression.c:1:1, line:5:1> line:1:6 test 'void ()' `-CompoundStmt 0x7fccdd04d568 <col:13, line:5:1> `-BinaryOperator 0x7fccdd04d548 <line:4:5, col:40> 'int' '=' |-ArraySubscriptExpr 0x7fccdd04d3f0 <col:5, col:16> 'int' lvalue | |-ImplicitCastExpr 0x7fccdd04d3c0 <col:5> 'int *' <ArrayToPointerDecay> | | `-DeclRefExpr 0x7fccdd04d320 <col:5> 'int[3]' lvalue Var 0x7fccdd04d2a0 'array' 'int[3]' | `-ImplicitCastExpr 0x7fccdd04d3d8 <col:11> 'int' <LValueToRValue> | `-DeclRefExpr 0x7fccdd04d358 <col:11> 'int' lvalue Var 0x7fccdd04d160 'index' 'int' `-BinaryOperator 0x7fccdd04d528 <col:20, col:40> 'int' '*' |-ParenExpr 0x7fccdd04d488 <col:20, col:30> 'int' | `-BinaryOperator 0x7fccdd04d468 <col:21, col:29> 'int' '+' | |-ImplicitCastExpr 0x7fccdd04d450 <col:21> 'int' <LValueToRValue> | | `-DeclRefExpr 0x7fccdd04d410 <col:21> 'int' lvalue Var 0x7fccdd04d160 'index' 'int' | `-IntegerLiteral 0x7fccdd04d430 <col:29> 'int' 4 `-ParenExpr 0x7fccdd04d508 <col:34, col:40> 'int' `-BinaryOperator 0x7fccdd04d4e8 <col:35, col:39> 'int' '+' |-IntegerLiteral 0x7fccdd04d4a8 <col:35> 'int' 2 `-IntegerLiteral 0x7fccdd04d4c8 <col:39> 'int' 6上面的示例代码比较简单,我们来分析下这个 AST 中的几个节点:对于 Clang来说,顶层结构是TranslationUnitDecl (translation unit declaration :翻译单元声明),对 AST 树的遍历,实际上是遍历整个 TranslationUnitDec。 TypedefDecl 类型描述,对应typedef。 FunctionDecl 代表 C/C++方法定义。 CompoundStmt 代表了像 { stmt stmt } 这样的statement的集合。实际上就是用 {} and {{}} 包裹的代码块。 之前说过语法分析的结果,会生成以表达式为节点的树,clang AST 中的所有表达式都由 Expr 的子类表示。 BinaryOperator 类是 Expr 类的子类,其包括两个子节点。上文也说过在这个阶段如果出现表达式不合法(括号不匹配、表达式缺少操作符)编译器就会报告语法分析阶段的错误,所以我将 CompilerExpression.c 中的 array[index] = (index + 4) * (2 + 6) 中的 ; 去掉试一下: CompilerExpression.c:4:41: error: expected ';' after expression array[index] = (index + 4) * (2 + 6) ^ ; ... 1 error generated.果然报告了一个错误,提示 array[index] = (index + 4) * (2 + 6) 后面应该加上 ; 语义分析 经过词法分析和语法分析之后,语句是否真的有意义呢,这时候就需要进行语义分析,查看语句在语法上是否合法,需要注意的是编译器只能查看静态语义,动态语义需要运行时才能进行确定。 如果过程中出现了类型不匹配,编译器就会报错,经过语义分析之后的语法树如下:可以看出,编译期可以确定的表达式类型,都已经被确定好了(如果有隐式转换,语义分析会在语法树中插入转换节点),除此之外,符号表里面的类型也做了更新。 中间语言生成 编译器存在多个层次的优化行为,源代码级别的称之为源代码优化器,上述的经过语义分析之后的整个语法树,(2+6)会被优化成了8,其实并不是直接在语法树上进行优化,而是将整个语法树转化成中间代码,来顺序的标识语法树,常见三地址码和 P-Code 法两种方式。 中间代码层作为中间层的存在,使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。 针对上面代码采用三地址码法进行优化 : 最基本的三地址码长这样: x = y op z 表示变量 x 和变量 y 经过 op 操作后赋值给 x 例如函数内部的代码 array[index] = (index + 4) * (2 + 6);,经过中间层的源代码优化器 (Optimizer)优化后的代码最终为: t2 = index + 4 t2 = t2 * 8 array[index] = t2使用 clang 命令将语法树自顶向下遍历逐步翻译成 LLVM IR:clang -S -fobjc-arc -emit-llvm test.c -o test.ll 得到 .ll 文件,IR 代码如下: ; ModuleID = 'CompilerExpression.c' source_filename = "CompilerExpression.c" target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128" target triple = "x86_64-apple-macosx12.0.0"; Function Attrs: noinline nounwind optnone ssp uwtable define void @test() #0 { %1 = alloca i32, align 4 %2 = alloca [3 x i32], align 4 store i32 1, i32* %1, align 4 %3 = load i32, i32* %1, align 4 %4 = add nsw i32 %3, 4 %5 = mul nsw i32 %4, 8 %6 = load i32, i32* %1, align 4 %7 = sext i32 %6 to i64 %8 = getelementptr inbounds [3 x i32], [3 x i32]* %2, i64 0, i64 %7 store i32 %5, i32* %8, align 4 ret void } ...BitCode 这里简单提一下我们经常听说的 BitCode,BitCode 是 iOS 9 引入的新特性,官方文档解释 BitCode 是一种中间代码,包含 BitCode 的应用程序会在 App Store 上编译和链接, BitCode 允许苹果在后期对我们的应用程序的二进制文件进行优化。其实就是 LLVM IR 的一种编码形式,如下图我们可以看到 BitCode 在编译环节所处的位置:但是在 Xcode 14 中 BitCode 被废除,iOS、tvOS 以及 watchOS 应用程序默认将不再支持 BitCode,在未来的 Xcode 版本中,BitCode 将被移除,主要原因是 Bitcode 并不是一个稳定的格式,因为在 LLVM 的设计里,它只是一个临时产生的文件,并不期望被长期存储,这导致它的兼容性很差,几乎 LLVM 每次版本更新时都会修改它,其次是对生态的要求很高,如果应用的任何一个依赖没有支持 Bitcode,那最终就无法使用。 目标代码生成与优化 编译器的后端主要包含代码生成器和目标代码优化器。代码生成器依赖于目标机器的字长、寄存器、整数数据类型和浮点数数据类型等,将中间代码转换目标机器代码。 array[index] = (index + 4) * (2 + 6); 经过源代码优化器又经过代码生成器变成如下代码: movl index, %ecx addl $4, %ecx mull $8, %ecx movl index, %eax movl %ecx, array(, eax, 4)上面代码经过目标代码优化器,会选择合适的寻址方式,使用位移来代替乘法运算,删除多余指令等,经过目标代码优化器之后代码如下(其中乘法改用相对复杂的基地址比例变址寻址的指令完成): movl index, %edx leal 32(, %edx, 8), %eax movl %eax, array(, %edx, 4)还有,我们都知道 Xcode 是使用 Clang 来编译 Objective-C 语言的,而 Xcode 供给我们 7 个等级的编译选项,在 Xcode -> Build Setting -> Apple LLVM 9.0 - Code Generation -> Optimization Level 中进行设置。None [-O0]:不优化 Fast [-O1]:大函数所需的编译时间和内存消耗都会稍微增加 Faster [-O2]:编译器执行所有不涉及时间空间交换的所有的支持的优化选项 Fastest [-O3]:在开启Fast [-O1]项支持的所有优化项的同时,开启函数内联和寄存器重命名选项 Fastest, Smallest [-Os]:在不显着增加代码大小的情况下尽量提供高性能 Fastest, Aggressive Optimizations [-Ofast]:与Fastest, Smallest [-Os]相比该级别还执行其他更激进的优化 Smallest, Aggressive Size Optimizations [-Oz]:不使用LTO的情况下减小代码大小设置不同优化选项,中间代码的大小会相应变化。 链接 代码经过标代码优化器,已经变成了最优的汇编代码结构,但是如果此时的代码里面使用到了别的目标文件定义的符号怎么办?这就引出了链接,之所以称之为链接,就是因为链接时需要将很多的文件链接链接起立,才能得到最终的可执行文件。 最开始的时候,程序员采用纸带打孔的方式输入程序的,然而指令是通过绝对地址进行寻址跳转的,指令修改过后,绝对的地址就需要进行调整,重定位的计算耗时又容易出错,于是就出现了汇编语言,采用符号的方式进行指令的跳转,每次汇编程序的时候修正符号指令到正确的地址。汇编使得程序的扩展更加方便,但是代码量开始膨胀,于是需要进行模块的划分,产生大量的模块,这些模块互相依赖又相对独立,链接之后模块间的变量访问和函数访问才有了真实的地址。模块链接的过程,本书的作者很形象生动的比作了拼图的过程,链接完成之后,才能产生一个可以真正执行的程序。链接的原理无非是对一些符号的地址加以修正的过程,将模块间的互信引用的部分都处理好。具体包括地址和空间分配、符号决议、重定位等步骤。多数情况下,目标文件和库需要一起进行链接,常用的一些基本函数大多属于运行时库(Runtime Library),链接器会根据引用的外部模块的符号,自动的去查找符号的地址,进行地址修正。 空间地址分配 到了比较重要的静态链接,上面说的 “拼图” 的过程,其实就是静态链接的过程,即将多个目标文件链接起来,形成一个可执行文件。 有这样两个文件 a.c 和 b.c,gcc -c a.c b.c 经过编译后形成 a.o 和 b.o: // a.c extern int shared; void swap(int* a, int* b); int main() { int a = 100; swap(&a, &shared); }// b.c int shared = 1; void swap(int* a, int* b) { *a ^= *b ^= *a ^= *b; }在 a.c 定义了两个外部符号 shared 和 swap ,b.c 中定义了一个 main 为全局符号,我们可以查看下通过clang进行编译,通过 MachOView 查看 a.o:可见 mov 这条指令中,shared 的部分的地址为 0x00000000,swap 的地址也为 0x00000000 (其中0xE8 为操作码)。这部分只是用来代替,真正的地址计算留给链接器。 接下来就是将两个目标文件进行链接,两个目标文件怎么合并呢? 方式一:直接将目标文件拼接起来这种拼接方式虽然简单,但是缺点很明显,段数量太多了不说,由于 x86的硬件来说,段的装载和空间对齐的单位是页,4096个字节,这就会导致即便是仅有1个字节的段,在内存中也会被分配4096个字节。 方式二:相似段合并现在的链接器大多都是采用两步链接的方法:空间与地址分配 扫描所有的目标文件 获取各个段的长度,属性和位置 收集符号表中的所有符号定义和符号引用,放入全局符号表 获得所有目标文件的长度,并且将其合并 建立合并后的文件的映射关系符号解析和重定位 获取上一步收集的段数据和重定位信息 进行符号解析 重定位 调整代码中的地址使用 ld 将 a.o 和 b.o 链接起来: ld a.o b.o -e main -o ab ,链接后使用的地址是进程中的虚拟地址。 小Tip:如果在 MacOS 系统中可直接使用 clang 命名链接目标文件 clang a.o b.o -o ab,如果直接使用 ld 进行链接可能会导致异常如下: ld: dynamic executables or dylibs must link with libSystem.dylib for architecture x86_64 即便添加了指定 libSystem:ld a.o b.o -e _main -o ab -lSystem ,也会报如下错误: ld: library not found for -lSystem 发现是因为指定库的地址,最后解决方案如下: ld a.o b.o -e _main -o ab -macosx_version_min 12.6 -L /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/lib -lSystem 我分析报错主要原因是因为,MacOS 系统在链接的时候,会默认使用 libSystem.dylib,在 Mach-O 中也能看到这个库的存在符号解析&重定位 何使用外部符号呢?比如 a.c 文件中就使用到了 shared 和 swap 两个外部符号,在 a.c 编译成目标文件的时候, shared 和 swap 两个的地址还不知道, 编译器会使用地址 0 当做 shared 的地址,函数的调用是一条叫 进址相对位移调用指令,这个我们放到最后来讲 swap 在目标文件中的地址也是一个临时的假地址 0xFFFFFFFC,在经过上一步的地址和空间分配之后,就已经可以确定所有符号的虚拟地址了。 我们看一下 a.o 和 经过链接之后的 ab,首先 objdump -d a.o 如下: a.o: file format mach-o 64-bit x86-64Disassembly of section __TEXT,__text:0000000000000000 <_main>: 0: 55 pushq %rbp 1: 48 89 e5 movq %rsp, %rbp 4: 48 83 ec 10 subq $16, %rsp 8: c7 45 fc 64 00 00 00 movl $100, -4(%rbp) f: 48 8d 7d fc leaq -4(%rbp), %rdi 13: 48 8b 35 00 00 00 00 movq (%rip), %rsi ## 0x1a <_main+0x1a> 1a: e8 00 00 00 00 callq 0x1f <_main+0x1f> 1f: 31 c0 xorl %eax, %eax 21: 48 83 c4 10 addq $16, %rsp 25: 5d popq %rbp 26: c3 retq13行 和 1a行地址是临时给到的,需要进行重定位,再使用 objdump 看下 ab ,objdump -d ab : ab: file format mach-o 64-bit x86-64Disassembly of section __TEXT,__text:0000000100003f50 <_main>: 100003f50: 55 pushq %rbp 100003f51: 48 89 e5 movq %rsp, %rbp 100003f54: 48 83 ec 10 subq $16, %rsp 100003f58: c7 45 fc 64 00 00 00 movl $100, -4(%rbp) 100003f5f: 48 8d 7d fc leaq -4(%rbp), %rdi 100003f63: 48 8d 35 96 00 00 00 leaq 150(%rip), %rsi ## 0x100004000 <_shared> 100003f6a: e8 11 00 00 00 callq 0x100003f80 <_swap> 100003f6f: 31 c0 xorl %eax, %eax 100003f71: 48 83 c4 10 addq $16, %rsp 100003f75: 5d popq %rbp 100003f76: c3 retq ...可见经过链接 swap 和 shared 符号的地址已经确定。 对比上面的 a.o 的 MachOView 结果,我们查看一下 ab:shared 的地址修正属于绝对地址修正:例如 b.o 文件中的 shared 函数的段偏移是 X 合并后的段的 b.o 的代码段的虚拟地址假设为 0x08048094 那么合并后的 shared 函数的地址为 0x08048094 + Xswap 是一条近址相对位移调用指令,它的地址是调用指令的下一条指令的偏移量,地址修正方式为:首先找到下一条指令的偏移量 0x00000011 找到下一条指令的地址,由上图可以看到 callq 指令的下一条指令地址为 0x00003F6F 所以 swap 的地址可以计算得出 0x00003F6F + 0x00000011 = 0x00003F80 至于链接器怎么就知道 shared 和 swap 是需要进行调整的指令呢?这里就涉及到了一个叫做重定位表的段,也叫做重定位段,其实上面也有说过,.rel.text 是针对代码段的重定位表,.rel.data 是针对数据段的重定位表, objdump -r a.o 结果如下: a.o: file format mach-o 64-bit x86-64RELOCATION RECORDS FOR [__text]: OFFSET TYPE VALUE 000000000000001b X86_64_RELOC_BRANCH _swap 0000000000000016 X86_64_RELOC_GOT_LOAD _shared@GOTPCRELRELOCATION RECORDS FOR [__compact_unwind]: OFFSET TYPE VALUE 0000000000000000 X86_64_RELOC_UNSIGNED __texta.o 中就存在了两个重定位入口,上图代表是代码段的重定位表,两个 offset 标识代码段中需要调整的指令的偏移地址。 C++相关问题 C++ 由于模板、外部内联函数、虚函数表等导致会产生很多重复的代码,目前的 GNU GCC 将每个模板代码放入一个段里,每个段只有一个模板的实例,当别的编译单元以相同的类型实例化模板函数的时候,也会生成和之前相同名称的段,最终在链接的时候合并到最后的代码段。C++ 还提供了一个叫做函数级别链接的编译选项,这个选项可以使所有函数都会被编译到单独的段里面,链接合并时,没有用到的函数就会被抛弃,减少了文件的长度,但是增加段的数量以及编译的时间。 C++ 的 main 之前需要初始化进程的执行环境等, main 之后需要做一些清理的工作,于是 ELF 文件还定义两个特殊的段 .init 和 .fini,一个放在main前由系统执行,一个放在main函数返回后执行。 目标文件可能是被两个不同的编译器产出的,那么两个目标文件能够进行链接的条件是:采用相同的目标文件格式 拥有同样的符号修饰标准 变量的内存分布方式相同 函数调用方式相同 ...其中 2,3,4 等是与可执行文件的二进制兼容性相关**(ABI)** ABI稳定 人们总是希望二进制和数据不加修改能够得到重用,但是实现二进制级别的重用还是很困难的,因为影响 ABI 的因素非常多,硬件、编程语言、编译器、链接器、操作系统都会影响 ABI。 C 代码层面的 ABI 稳定 从 C 语言的目标代码来说,下面几个因素会影响二进制是否兼容:内置类型的大小和在存储器中的放置方式(大端、小端、对齐方式)。 组合类型的存储方式和内存分布。 外部符号与用户定义的符号之间的命名方式和解析方式。 函数调用方式。 堆栈分布方式。 寄存器的使用约定。C ++ ABI 稳定 到了 C++ 时代,做到二进制兼容更是不易,需要考虑:继承类体系的内存分布。 指向成员函数的指针的内存分布。 如何调用虚函数,vtable 的内容和分布形式,vtable 指针在 object 中的位置。 模板如何实例化。 外部符号的修饰。 全局对象的构造和析构。 异常产生和捕获机制。 标准库的细节问题和 RTTI 如何实现。 内嵌函数访问细节等。二进制的兼容,一直都是语言发展过程中的重要事务,比如还有很多人还在致力于 C++的标准的统一。 Swift ABI稳定 从16年就接触了 Swift3.0 的开发,当时当时的 Swift 语言还是在飞速迭代的过程中,每次一个小的版本的升级,就会有大量的代码需要改动,我甚至还误以为这个是由于 Swift 的 ABI 不稳定造成的,这其实是错的,直到 Swift 5 发布,Swift 5 最重要的变化就是 ABI Stability,ABI 稳定之后,OS 发行商就可以把 Swift 标准库和运行时作为操作系统的一部分嵌入。也就是说 Apple 会把 Swift runtime 放到 iOS 和 macOS 系统里,我们的 Swift App 包里就不需要包含应用使用的标准库 和 Swift runtime 拷贝了。同时在运行的时候,只要是用 Swift 5 (或以上) 的编译器编译出来的 Binary,就可以跑在任意的 Swift 5 (或以上) 的 runtime 上。 ABI & API 此外有个与之对应的有个概念叫做 API,实际上它们都是应用程序接口,只是接口所在层面不同:API 是指源代码级别的接口 ABI 是指二进制层面的接口。大端小端就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。静态库链接 静态库可以看做是一组目标文件的集合,举例几个 C 语言的运行的库:C运行库 相关 DLL 相关 DLLlibcmt.libMultithreaded Static 多线程静态库msvert.lib msver90.dll Multithreaded Dynamic 多线程动态库libcmtd.libMultitbreaded Static Debug 多线程静态调试库msvertd.lib msvert90d.dll Multichreaded Dynamic Debug 多线程动态调试库链接使用到静态库的过程如下所示,其实目标文件中使用了printf函数:然而我们直接将 hello.o 和 printf.o 链接在一起 ld hello.o print.o,却报错了,其原因是 printf.o 中使用到了其他的库文件。 链接过程控制 特殊情况下我们需要控制链接规则,这就引出了链接过程控制。一共有三种办法做到控制:指定链接器参数,-o -e 之类。 将链接指令放在目标文件里面。 使用链接控制脚本。本身是存在默认的链接脚本的,例如在 Intel IA32下,我们使用 ld 链接生成可执行文件时,默认使用的 elf_i386.x 脚本,使用 ld 链接生成共享目标文件时,默认使用的是 elf_i386.xs 脚本。当然我们可以自己写脚本,来控制链接过程。 作者举了一个使用 ld 脚本干预链接的例子,程序代码结合的是 GCC 内嵌汇编,不借助库函数,可以打印 Hello World 程序,代码如下: char* str = "Hello world! \n"; void print () { asm("movl $13, %%edx \n\t" "movl %0, %%ecx \n\t" "movl $0, %%ebx \n\t" "movl $4, %%eax \n\t" "int $0x80 \n\t" :: "r"(str): "edx", "ecx", "ebx"); } void exit () { asm("movl $42, %ebx \n\t" "movl $1, %eax \n\t" "int $0x80 \n\t"); } void nomain () { print(); exit(); }编译:gcc -c -fno-builtin TinyHelloWorld.c 链接:ld -static -e nomain -o TinyHelloWorld TinyHelloWorld.o 这段代码的可执行代码一共会生成4个段,使用 ld 脚本可以合并部分段,并删除多余的段,脚本如下: ENTRY (nomain) SECTIONS { . = 0x08048000 + SIZEOF_HEADERS; tinytext : { *(.text) *(.data) *(.rodata) } /DISCARD/ : { *(.comment) ) }脚本一共做了几件事呢?第一行执行了程序的入口为 nomain。 SECTIONS 里面是变换规则,第一句的意思是设置 tinytext 的段的起始虚拟地址为 0x08048000 + SIZEOF_HEADERS。 第二句意思是将 .text 段、.data 段、.rodata 段合并为 tinytext 段。 第三句意思是将 .comment 段丢弃。Note:除了 tinytext 段之外,其实还会同时存在 .shstrtab 段、.symtab 段、.strtab 段(段名字符串表、符号表、字符串表) 此外,现在的 GCC 都是通过 BFD 库来处理目标文件的,BFD 库会把目标文件抽象成一个统一的模型,然后就可以操作所有支持 BFD 支持的目标文件格式。 目标文件里有什么 整体结构 目前流程的可执行文件的格式分为两种,Windows 下的 PE 和 Linux 下的 ELF,均为 COFF 的变种,目标文件是源代码经过编译后但是没有进行链接的中间文件,结构和内容和可执行文件类似,Windows 下目标文件和可执行文件统称为 PE/COFF 文件格式,Linux 系统下统称为 ELF 的文件。 此外,除了可执行性文件,动态链接库(Linux 下的 .so)、静态链接库(Linux 下的 .a)都是按照可执行文件的格式存储,在 Linux 下可以通过 file 命令查看具体类型。 ELF 目标文件的总体结构如下,之所以 Section Table 和 .rel.text 与前一个段有间隔,是因为内存对齐的原因:ELF 开头是一个头文件,描述了整个文件的文件基本属性,了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等。 头文件还包括一个段表 Section Table,是一个描述各个段的数组,描述了段名、段的长度、在文件的偏移位置、读写权限以及段的其他属性。编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性。 头文件后是各个段的内容。 执行语句编译成的机器代码在代码段(.text 段)。 已经初始化的全局变量和局部静态变量都在数据段(.data 段)。 未初始化的全局变量和局部静态变量一般放在一个叫 BSS 段中。(BSS 段只是预留位置,在文件中不会占据空间) 其他段...总体说,程序代码被编译后主要分为两种,程序指令和程序数据,为什么指令和数据分开来放呢?方便权限划分(只读和可读写等)。 划分了空间,提高 CPU 缓存的命中率。 方便指令或者数据的复用性,节省内存。比如说“共享指令”。段表 段表 Section Header Table 用于保存 ELF 文件中的所有段的基本属性的结构,是除了文件头以外中重要的结构,描述了各个段的段名、段的长度、在文件中的偏移、读写权限、以及段的其他属性。编译器、链接器、装载器都是依靠段表来定位各个段的和访问各个段的,段表在 ELF 文件的位置是由 ELF 文件头中的 e_shoff 成员决定的。段表其实是一个 EIf32_Shdr 的结构体的数组,里面包含段名 sh_name、段类型 sh_type、段的标志位 sh_flags、段的虚拟地址 sh_addr、段的偏移 sh_offset、段的长度 sh_size、段的链接信息 sh_link 和 sh_info、段的地址对齐 sh_addralign 等等。 段的名字只是在链接和编译过程中有意义,但是不能真正标识段的类型,段的属性是由段的类型 sh_type 和段的标志位 sh_flag 共同来决定的。段的类型是由 SHT_ 开头的,例如 SHT_PROGBITS 代表代码段、数据段,SHT_SYMTAB 代表符号表,SHT_STRTAB 代表字符串表。 段的标志位 sh_flag 标识了段是否可写、可执行,以 SHF_ 开头,例如SHF_WRITE 代表段在进程空间中可写, SHF_ALLOC 代表进程空问中须要分配空间 下面列举几种常见的系统保留段的段类型和段标志如下:Name sh_type sh flag.bss SHT_NOBITS SHF_ALLOC + SHF_WRITE.data SHT_PROGBITS SHF_ALLOC + SHF_WRITE.shstrtab SHT_STRTAB none.strtab SHT_STRTAB 如果该 ELF 文件中有可装載的段须要用到该字符串表,那么该字符串表也将被装载到进程空问,则有 SHF_ALLOC 标志位.symtab SHT_SYMTAB 同字符串表.text SHT_PROGBITS SHF_ALLOC + SHF_EXECINSTR下面介绍段的链接信息,如果段是与链接有关的(静态链接或者动态链接),比如说重定位表、符号表,在 sh_link 和 sh_info 字段中会包含链接相关的信息。sh_type sh_link sh_infoSHT_DYNAMIC 该段所使用的字符串表在段表中的下标 0SHT_HASH 该段所使用的符号表在段表中的下标 0SHT_REL 该段所使用的相应符号表在段表中的下标 该重定位表所作用的段在段表中的下标SHT_RELA 该段所使用的相应符号表在段表中的下标 该重定位表所作用的段在段表中的下标SHT_SYMTAB 操作系统相关的 操作系统相关的SHT_DYNSYM 操作系统相关的 操作系统相关的other SHN UNDEF 0常见段常用的段名 说明.text 程序指令.data 已经初始化的全局静态变量和局部静态变量.rodata 程序里的只读数据.bss 未初始化的全局变量和局部静态变量.comment 注释信息段.note.GNU-stack 堆栈提示段.rodata1 Read only Data,这种段里存放的是只读数据,比如字符串常量、全局 const变量。跟 “.rodata” 一祥.comment 存放的是编译器版本信息,比如字符串:"GCC: (GNU) 4.2.0”.debug 调试信息.dynamic 动态链接信息.hash 符号哈希表.line 调试时的行号表,即源代码行号与编译后指令的对应表.note 额外的编译器信息。比如程序的公司名、发布版本号等.strtab String Table 字符串表,用于存储ELF文件中用到的各种字符串.symtab Symbol Tabie 符号表.shstrtab Section String Table 段名表.plt.got 动态链接的跳转表和全局入口表.init.fini 程序初始化与终结代码段为了了解常见的段,我们先自己编译一段代码 SimpleSection.c: int printf (const char* format, ...); int global_init_var = 84; int global_uninit_var;void func1(int i) { printf( "%d\n",i); } int main(void) { static int static_var = 85; static int static_var2; int a = 1; int b; func1(static_var + static_var2 + a + b); return a; }只编译不链接该 SimpleSection.c 文件: gcc -c SimpleSection.c 得到 SimpleSection.o,SimpleSection.o 即为目标文件。 我们使用 objdump -h SimpleSection.o 查看其内部结构:除了上面我们提到过的代码段,数据段,BSS 段之外,还有三个段分别为只读数据段(.rodata 段),注释信息段(.comment 段),堆栈段(.note.GNU-stack),首先 BSS 段和堆栈段认为在文件中不存在,实际存在的段的分布情况如下:使用 size 命令可以查看各个段的长度:size SimpleSection.o text data bss dec hex filename 95 8 4 107 6b SimpleSection.o代码段 使用 objdump -s -d SimpleSection.o 可以查看所有段的十六进制内推以及所有包含指令的反汇编,我们着重看下代码段的内容:可见代码段里面包含就是两个函数 func1() 和 main() 函数的指令。 数据段和只读数据段 中存放的是已经初始化的全局静态变量和局部静态变量,上述代码中的中的 global_init_var 和 static_var 为全局静态变量和局部静态变量,加一起一共是 8 个字节,在调用 printf 函数的时候,用到了一个字符串常量,是一种只读数据,放入了 .rodata 段,刚好是 4 个字节。 BSS段 编译单元内部可见的未初始化的静态变量的确放到了BSS 段,但是未初始化的全局变量却不一定放在 BSS 段,例如上述代码中的 global_uninit_var 和 static_var2 就应该放在了BSS 段,但是我们看到该段只有四个字节的大小,其实只有 static_var2 放在了BSS 段,而 global_uninit_var 却没有放进任何段,只是一个未定义的 COMMON 符号,具体的原因我们放到下面关于符号的位置来讲解。 重定位表 链接器在处理目标文件的时候,需要对代码段和数据段中绝对地址的引用位置进行重定位,重定位的信息会记录在 ELF 文件的重定位表里,对于需要进行重定位的代码段或者数据段,都会需要一个重定位表,.rel.text 是针对代码段的重定位表,.rel.data 是针对数据段的重定位表,重定位表的 sh_type 为 SHT_REL,而 sh_link 字段记录该段所使用的相应符号表在段表中的下标,sh_info 表示该重定位表所作用的段在段表中的下标。 字符串表 ELF 文件中有许多类似于段名、变量名之类的字符串,使用字符串表,通过定义在表中的偏移来引用。字符串表在 ELF 中也是以段的形式存在..strtab 字符串表 .shstrtab 段表字符串表结合 ELF 头文件中的 e_shstrndx 即可找到段表和段表字符串表的位置,从而解析 ELF 文件。 自定义段 我们可以自己插入自定的段,做一些特定的事情,但是自定义段不能使用 . 开头,我们在全局变量或者函数加上 __attribute__((section("name"))) 就可以将相应的变量或者函数放到 name 为名的段中。 符号 链接的本质是将不同的目标文件互相 “粘” 到一起,还记得上文中的拼图吗,很形象生动,而链接中需要使用到的就是符号的名字,我们将函数和变量统称为符号 Symbol,函数名和变量名即符号名。每个目标文件中都会有一个符号表 Symbol Table,每个符号都有对应的符号值,对于变量和函数,符号值就是它们的地址。 常见的符号类型定义在本目标文件的全局符号,可以被其他目标文件引用。 外部符号,即在本目标文件中引用的全局待号,却没有定义在本目标文件。 段名,这种符号往往由编译器应生,它的值就是该段的起始地址。 局部符号,这类符号只在编译单元内部可见。 行号信点。使用 nm 可以查看目标文件的符号表: nm SimpleSection.o 打印的所有符号如下: 00000000 T funcl 00000000 D global_init_var 00000004 C global_uninit_var 0000001b T main U printf 00000004 d static_var.1286 00000000 b static_var2.1287符号表结构 符号表也是属于 ELF 文件中的一个段, 段名叫 .symtab,它是一个结构体的数组,结构体里面有几个重要的元素。 我们查看下 64 位的 Elf64_Sym 结构定义如下: typedef struct { Elf64_Word st_name; unsigned char st_info; unsigned char st_other; Elf64_Half st_shndx; Elf64_Addr st_value; Elf64_Xword st_size; } Elf64_Sym; //64位st_info 符号类型和绑定信息,低4位表示符号类型,高28位表示符号绑定信息。 st_shndx 这个值如果符号在目标文件中,这个符号就是表示符号所在的段在段表中的下标,如果符号不在目标文件中,sh_shndx 可能会有些特殊,例如 SHN_ABS 表示该符号包含一个绝对的值,SHN_COMMON 表示该符号是一个 COMMON 块类型的符号,SHN_UNDEF 表示符号未定义,可能定义在其他的目标文件中。 st_value 符号值。 如果段不是 COMMON 块(即 st_shndx 不为 SHN_COMMON),则符号对应的函数或者变量位于由 st_shndx 指定的段经过 st_value 偏移所在的位置,这种是目标文件中定义全局变量的最常见的情况。 如果是 COMMON 块(即 st_shndx 不为 SHN_COMMON), st_value 表示符号对齐属性。 如果在可执行文件中,st_value 表示符号的虚拟地址,对于动态链接器十分有用。此外还有一些特殊符号,例如 __executable_start 为程序起始地址,__etext 或 _etext 或 etext 标识代码段结束地址等等。我们使用 readelf 查看 ELF 文件的符号: readelf -s SimpleSection.o:Num 表示符号数组的下标,Value 是符号值,Size 为符号大小,st_info 为符号类型和绑定信息,Ndx 即 st_shndx 表示符号所属的段,举几个例子:func1 和 main 位置在代码段, Ndx 是1,类型为 STT_FUNC,由于是全局可见的,所以是 STB_GLOBAL, Size 表示指令所占字节数, Value表示函数相对于代码段的起始位置的偏移。 printf 这个符号,在 SimpleSection.c 中被引用,,但是没有定义,所以 Ndx 是 SHN_UNDEF。 global_init_var 是已初始化的全局变量,定义在 BSS 段,下标为 3. global_uninit_var 是未初始化的全局变量,是一个 SHN_COMMON 类型的符号,本身未存在于 BSS 段。 static_var 和 static_var2 绑定属性是 STB_LOCAL,编译内部单元可见。 STT_SECTION,表示下标为 Ndx 的段的段名,符号名没有显示,其实符号名即为段名。符号修饰和函数签名 为了防止符号的冲突,Unix系统规定 C 语音源代码的所有的全局变量和函数经过编译后,会在符号前面加上下划线。C++ 为了符号命名冲突的问题,增加了命名空间。 C++ 拥有类、继承、虚机制、重载、命名空间等特性,使得符号管理更为复杂,于是引入了函数签名,函数签名包含函数名。参数类型,所在类以及命名空间等一些列信息,用于识别不同的函数,即便函数名相同,编译器也会更具修饰后的名称,认为它们是不同的函数,修饰后的名称如表:函数签名 修饰后名称(符号名)int func(int) __Z4funcifloat func(float) __Z4funcfint C::func(int) __ZN1C4funcEiint C::C2::func(int) __ZN1C2C24funcEiint N::func(int) __ZNIN4funcEiint N::C::func(int) __ZNINICAfuncEi变量的类型没有加入到修饰后的名称中。 C++ 为了和 C 兼容, 还引入了 extern "C" 关键字: extern "C" { int func(int); int var; }在 {} 中的代码会被 C++ 编译器当做 C 的代码来处理, 当然 C++ 的名称机制也将不起作用。 如果是单独的某个函数或者变量定义为 C 语言的符号也可以使用 extern: extern "C" int func(int); extern "C" int var;强符号和弱符号 开发中我们经常会遇到符号被重复定义的错误,比如说我们在两个目标文件中都定义了相同的全局整形变量 global,并将它们同时初始化,那么链接器将两个目标文件链接的时候就会报错,对于 C/C++ 语言来说,这种已初始化的全局符号可以称之为强符号,有些符号的定义称之为弱符号,比如说未初始化的全局符号,强符号和弱符号是针对定义来说的,而不是针对符号的引用。我们也可以使用 __attribute__((weak)),来定义强符号为弱符号,下面我们看一段代码: extern int ext; // 非强符号也非弱符号int weak; // 弱符号 int strong = 1; // 强符号 __attribute__((weak)) weak2 = 2; // 弱符号int main() { // main 强符号 return 0; }上段代码的强弱符号已经进行了标注,ext 由于是一个外部变量的引用,非强符号也非弱符号。 链接器会按照下面的三个规则处理与选择多次定义的全局符号:不允许强符号被定义多次(不同的目标文件中不能有同名的强符号,否则链接报错)。 如果有一个是强符号其余的是弱符号,则选择强符号。 如果不同的目标文件中都是弱符号,则选择占用空间最大的一个。现在的链接器在处理弱符号的时候,采用的 COMMON 块一样的机制来处理,编译器将未初始化的局部静态变量定义为弱符号,还记得上面我留了一个问题,编译器将未初始化的局部静态变量 static_var2 放在 BSS 段,而 global_uninit_var 属于未初始化的全局符号,没有直接放入 BSS 段,先是标记了 COMMON,按照 COMMON 链接规则,global_uninit_var 的大小以输入文件中最大的那个为准,最终确认了符号的大小,就能放入 BSS 段了。但是有种情况,如果是同时存在强符号和弱符号,那么输出文件和强符号相同,但是如果链接过程中有弱符号大于强符号,那么 ld 就会报出警告。 对于目标文件被链接成可执行文件的阶段,如果是强符号没有被定义将会报错,如果弱符号没有被定义,链接器对于该引用不会报错,一般都会被赋予一个默认值,便于程序代码识别到。但是在运行阶段,可能会发生非法地址访问的错误。 强符号和弱符号的设计对于库来说十分有用,比如:库中定义的弱符号可以被强符号进行覆盖,从而使用自定义的库函数。 程序的某些扩展功能模块定义为弱引用,扩展模块和程序一起链接的时候就能使用模块功能,去掉模块功能也能正确链接,只是缺少部分功能。这个设计使得程序的设计更为灵活,我们可以对大的功能模块进行自由的组合和裁切。调试信息 调试信息似使得我们进行源代码级别的调试,可以设置断点,监测变量的变化,单步运行,确定目标代码的地址对应源代码中的哪一行、函数和变量的类型等等。 ELF 文件中采用一个叫做 DWARF 标准的调试信息格式,调试信息占用空间比较大,我们再发布程序的时候,往往需要使用 strip 命令来去掉 ELF 文件中的调试信息。 DWARF 一种通用的调试文件格式,支持源码级别的调试,调试信息存在于 对象文件中,一般都比较大。Xcode 调试模式下一般都是使用 DWARF 来进行符号化的。 通过 DWARF 清晰的看到函数的描述、行号、所在文件、虚拟地址等重要信息,有了这些信息,就可以实现单步调试以及查看 Crash 堆栈等能力。 说到 DWARF 可能我们还不是很熟悉,但是有一个文件,iOS 的程序员应该不陌生,那就是 dSYM 文件,日常开发时会遇到 Crash,Crash 里面有很多的堆栈信息,以及 Crash 时所执行的代码的行号,这些信息对定位问题非常重要,这个能力就是依赖 DWARF 和 dSYM 实现的。当然 DWARF 和 dSYM 是公共的标准,并不是只有苹果特有的,只不过主要是苹果在用而已。使用 Xcode 编译打包的时候会先通过可执行文件的 Debug Map 获取到所有对象文件的位置,然后使用 dsymutil 来将对象文件中的 DWARF 提取出来生成 dSYM 文件。 Strip 上文说到可以使用 Strip 命令来去掉 ELF 文件中的调试信息,在 Xcode 中其实已经给我们提供了 Strip 编译选项,之所以要在 Release 环境中去掉符号信息,主要是因为调试信息占用的空间太大了,需要进行 App 的瘦身操作。 Strip 命令就是为了去除调试信息,其中符号占据了绝大部分,而可执行文件中的符号是指程序中的所有的变量、类、函数、枚举、变量和地址映射关系,以及一些在调试的时候使用到的用于定位代码在源码中的位置的调试符号,符号和断点定位以及堆栈符号化有很重要的关系。 Xcode 编译实际的操作步骤是:生成带有 DWARF 调试信息的可执行文件 -> 提取可执行文件中的调试信息打包成 dSYM -> 去除符号化信息。去除符号是单独的步骤,使用的是 strip 命令,下面介绍两个有关于 strip 命令的 Xcode 编译选项: Strip Style Strip Style 表示的是我们需要去除的符号的类型的选项,其分为三个选择项:All Symbols Non-Global Symbols Debug Symbols去除所有符号,一般是在主工程中开启 (保留全局符号,Debug Symbols 同样会被去除),链接时会被重定向的那些符号不会被去除,此选项是静态库/动态库的建议选项。 去除调试符号,去除之后将无法断点调试。Strip Linked Product Xcode 提供给我们 Strip Linked Product 来去除不需要的符号信息(Strip Style 中选择的选项相应的符号),去除了符号信息之后我们就只能使用 dSYM 来进行符号化了,所以需要将 Debug Information Format 修改为 DWARF with dSYM file。 去除符号之后,调试阶段怎么办 去除符号化信息之后我们只能使用 dSYM 来进行符号化,那我们怎么使用 Xcode 来进行调试呢? Strip Linked Product 选项在 Deployment Postprocessing 设置为 YES 的时候才生效,而在 Archive 的时候 Xcode 总是会把 Deployment Postprocessing 设置为 YES 。所以我们可以打开 Strip Linked Product 并且把 Deployment Postprocessing 设置为 NO,而不用担心调试的时候会影响断点和符号化,同时打包的时候又会自动去除符号信息。 Mach-O 文件 经过 ELF 文件的学习,我们重温一下 iOS 里的 Mach-O 文件格式,Mach-O 是 Mach object 文件格式的缩写,是一种可执行文件、目标代码、共享程序库、动态加载代码和核心 dump,它类似于 Linux 和大部分 UNIX 的原生格式 ELF 以及 Windows 上的 PE。可见其主要包含三个部分: Header:记录了Mach-O文件的基本信息,包括CPU架构、文件类型和Load Commands等信息。 Load Commands:加载命令部分描述了需要内核加载器或动态连接器等进行的操作指令,如加载数据段、加载动态库等。 Section Data:每一个Segment的数据都保存在此,描述了段名、类型、段偏移,段大小等信息,每个 Segment 拥有一个或多个 Section ,用来存放数据和代码。 Mach-O文件中 中 Data 段之后就都是 __LINKEDIT 部分,具体如下:Dynamic Loader Info 动态加载信息Function Starts 函数起始地址表Symbol Table 符号表信息Data in Code Entries 代码入口数据Dynamic Symbol Table 动态符号表String Table 字符串表信息Code Signature 代码签名String Table 字符串表所有的变量名、函数名等,都以字符串的形式存储在字符串表中。 Symbol Table 符号表,这个是重点中的重点,符号表是将地址和符号联系起来的桥梁。符号表并不能直接存储符号,而是存储符号位于字符串表的位置。 Header struct mach_header_64 { uint32_t magic; /* 标识当前 Mach-O位32位(0xfeedface)/ 64位 (0xfeedfacf) */ cpu_type_t cputype; /* CPU 类型 */ cpu_subtype_t cpusubtype; /* CPU 子类型 */ uint32_t filetype; /* 文件类型 */ uint32_t ncmds; /* Load Commands 数量 */ uint32_t sizeofcmds; /* Load Commands 的总大小 */ uint32_t flags; /* 标识位,记录文件的详细信息 */ uint32_t reserved; /* 64位文件特有的保留字段 */ }Load Commands Load command描述了文件中数据的具体组织结构,不同的数据类型使用不同的加载命令。它的大小和数目在header中已经被提供。 struct load_command { uint32_t cmd; /* cmd 类型 */ uint32_t cmdsize; /* cmd size */ };Load Commands 的部分信息如下:LC_SEGMENT_64 将文件中的段映射到进程地址空间中LC_DYLD_INFO_ONLY 动态链接相关信息LC_SYMTAB 符号表地址LC_DYSYMTAB 动态符号地址LC_LOAD_DYLINKER 指定内核执行加载文件所需的动态连接器LC_UUID 指定图像或其对应的dSYM文件的128位UUIDLC_VERSION_MIN_MACSX 文件最低支持的操作系统版本LC_SOURCE_VERSION 源代码版本LC_MAIN 程序main函数加载地址LC_LOAD_DYLIB 依赖库路径LC_FUNCTION_STARTS 函数起始表地址LC_CODE_SIGNATURE 代码签名几种常见的命令简介如下: 使用最多的是 LC_SEGMENT_64 命令,该命令表示将相应的 segment 映射到虚拟地址空间中,一个程序一般会分为多个段,每一个段有唯一的段名,不同类型的数据放入不同的段中,LC_SEGMENT_64 中包含了五种类型:PAGEZERO:可执行文件捕获空指针的段 TEXT:代码段和只读数据 DATA_CONST:常态变量 DATA:全局变量和静态变量 LINKEDIT:包含动态链接器所需的符号、字符串表等数据动态链接相关信息:LC_DYLD_INFO_ONLY:Rebase:进行重定向的位置信息。当 Mach-O 加载到内存里,系统会随机分配一个内存偏移大小 ASLR,和 rebase 里面的 offset,对接(位置相加)获取代码在内存中的实际位置。再根据 size 开辟实际内存。 Binding:绑定的位置信息 Weak Binding:弱绑定的位置信息 Lazy Binding:懒加载绑定的位置信息 Export:对外的位置信息LC_SYMTAB 标识了 Symbol Table 和 String Table 的位置。 LC_LOAD_DYLINKER 标识了动态连接器的位置,用来加载动态库等。 Mach-O 程序入口:设置程序主线程的入口地址和栈大小 LC_MAIN,反编译后根据 LC_MAIN 标识的地址可以找到入口 main 代码,dyld 源码中 dyld::_main 可以看到 LC_MAIN 的使用,获取入口和调用。 LC_LOAD_DYLIB 是比较重要的加载动态库的指令,Name 标识了具体的动态库的路径,对一个 Mach-O 注入自定义的动态库时就是在 Load Commands 和 Data 中间添加 LC_LOAD_DYLIB 指令和信息进去。 Data Data 分为 Segment 和 Section 两个部分,存放代码、数据、字符串常量、类、方法等。 Segment 结构体定义如下: struct segment_command_64 { /* for 64-bit architectures */ uint32_t cmd; /* Load Commands 部分中提到的cmd类型 */ uint32_t cmdsize; /* cmd size */ char segname[16]; /* 段名称 */ uint64_t vmaddr; /* 段虚拟地址(未偏移),真实虚拟地址要加上 ASLR 的偏移量 */ uint64_t vmsize; /* 段的虚拟地址大小 */ uint64_t fileoff; /* 段在文件内的地址偏移 */ uint64_t filesize; /* 段在文件内的大小 */ vm_prot_t maxprot; /* maximum VM protection */ vm_prot_t initprot; /* initial VM protection */ uint32_t nsects; /* 段内 section数量 */ uint32_t flags; /* 标志位,用于描述详细信息 */ };而对于**__TEXT** 和 __DATA 这两个 Segment,则可以继续分解为 Section,从而形成 Segment -> Section 的结构。之所以要这样设计,是因为在同一个 Segment 下的 Section 可以拥有相同的控制权限,并且可以不完全按照 Page 的大小进行内存对齐,从而达到节约内存的效果。 Section 结构体定义如下: struct section_64 { /* for 64-bit architectures */ char sectname[16]; /* section名称 */ char segname[16]; /* 所属的segment名称 */ uint64_t addr; /* section在内存中的地址 */ uint64_t size; /* section大小 */ uint32_t offset; /* section在文件中的偏移*/ uint32_t align; /* 内存对齐边界 */ uint32_t reloff; /* 重定位入口在文件中的偏移 */ uint32_t nreloc; /* 重定位入口数量 */ 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 */ };常见的__TEXT Segment 的 Section 如下:__text: 可执行文件的代码区域 __objc_methname: 方法名 __objc_classname: 类名 __objc_methtype: 方法签名 __cstring: 类 C 风格的字符串常见的__DATA Segment 的 Section 如下__nl_symbol_ptr: 非懒加载指针表,dyld 加载会立即绑定 __ls_symbol_ptr: 懒加载指针表 __mod_init_func: constructor 函数 __mod_term_func: destructor 函数 __objc_classlist: 类列表 __objc_nlclslist: 实现了 load 方法的类 __objc_protolist: protocol 的列表 __objc_classrefs: 被引用的类列表 __objc _catlist: Category 列表我们可以使用系统自带查看 Mach-O 的工具:file : 查看 Mach-O 的文件类型 nm: 查看 Mach-O 文件的符号表 otool: 查看 Mach-O 特定部分和段的内容 lipo: 常用于多架构 Mach-O 文件的处理总结编译过程主要是分为 词法分析、语法分析、语义分析、生成中间代码、目标代码的生成与优化。链接的过程主要涉及到空间地址的分配、符号的解析、重定位等过程,我们可以对链接的过程通过脚本等加以控制,合并部分段,忽略个别段等。ELF 文件的主要构成,文件头、段表、各种常见段(代码段、数据段、BSS 段、只读数据段等)。关于符号大家也有了基本的认知,常见符号类型(全局符号、外部符号、段名等)。符号表提供的值得关注的信息(符号类型和绑定信息,符号所占位置、符号值),为了解决符号的冲突,C 编译后会在符号前加上下划线,C++ 编译器提供了修饰后的名称。符号分为强符号和 弱符号,对于 C/C++ 语言来说,已初始化的全局符号可以称之为强符号,未初始化的全局符号为弱符号。DWARF 一种通用的调试文件格式,支持源码级别的调试,但是所占体积较大,我们可以使用 Strip 命令来去掉 ELF 文件中的调试信息。Mach-O 是 MacOS/iOS 系统下的执行文件等的格式,有 Header、Load Command、Data 组成。

《程序员的自我修养(链接、装载与库)》学习笔记一(稳固而知新)

温故而知新 静态链接 装载与动态链接 库与运行库计算机发展 编译和链接 可执行文件的装载与进程 内存软件体系结构 目标文件里有什么 动态链接 运行库操作系统 静态链接 Linux 共享库的组织 系统调用与 API内存、线程 Windows PE/COFF Windows 下的动态链接 运行库实现在这书里有一句话,是之前认识的一个研发长者经常挂在嘴边的,今天在书中看到了感触颇多:经常听很多人谈起,IT 技术日新月异,其实真正核心的东西数十年都没怎么变化,变化的仅仅是它们外在的表现,大体也是换汤不换药吧。本书的作者介绍之所以想写这本书,其实主要也是因为不满足于技术的表面,想探索问题的根源。就像上面写的,技术发展日新月异,但是核心的东西却是相对稳定不变的,那么对于从事软件开发的工程师,研究人员,学习这些底层的知识就很有必要了,很多技术都是相通的,认识了底层才更能看清事情的表象,达到触类旁通的效果,毕竟只会写代码不是好程序员,这也是我想学习这本书的原因之一。 除此之外,这本书被大家评价为国人难得写的比较不错的一本计算机技术书籍,并且成为很多大厂人员的必读书籍,肯定是有其魅力所在的,那么是时候认真阅读一下了。 温故而知新 本书的第一章主要分为五个部分,除了第一部分主要是抛出几个问题让大家一起思考之外,剩下部分分别为计算机的发展,软件体系结构,内存和线程。具体知识点分布如图所示:Hello World引发的思考 #include <stdio.h>int main() { printf("Hello World\n"); return 0; }针对这小段代码,本书抛出了一些问题。为什么程序编译了才能运行? 编译器将 C 代码转化为机器码,做了什么? 编译出的可执行文件中有什么,存放机制是什么? ....关于上述问题,本书会从基本的编译、链接开始讲解,然后到装载程序、动态链接等。 计算机基本结构以及CPU的发展 结构 计算机基本结构:CPU、内存、I/O 控制芯片,如下图:CPU 的发展史早期 CPU 的核心频率很低,几乎等于内存频率,每个设备都会有一个 I/O 控制器,链接在一条总线(Bus)上。 随着 CPU 核心频率的提升,内存访问速度低于 CPU,于是增加了处理高速 I/O 的北桥芯片和处理低速 I/O 的南桥芯片。 CPU 速度达到极限后,又增加了多核处理器 SMP。软件体系结构 下图为计算机的软件体系结构分层:计算机软件体系结构是分层的,层与层之间通信的协议,称之为接口。 开发工具与应用程序都使用操作系统的应用程序编程接口。 运行库使用操作系统的系统调用接口。 接口需精心设计,尽量保持稳定,基于接口,具体实现层可以被任意替换。 中间层作为下面层级的包装和扩展,中间层的存在,保证了软硬件的相对独立。 操作系统提供抽象接口,管理软件、硬件资源。操作系统内核作为硬件接口的使用者,需定制硬件规格,硬件逐渐被抽象成一套接口,交给厂商,厂商写各自的驱动程序,硬件交互细节交给操作系统(驱动),程序员无需和硬件打交道。分层 分层设计的思想,其实渗透在计算机的各个领域。其中有我们最熟悉的 OSI 七层网络模型,它从低到高分别是:物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。参考计算机的软件体系结构,OSI 网络模型同样是通过制定层层的通信协议,界定各个分层的具体责任和义务。 OSI七层网络模型 TCP/IP四层概念模型 对应网络协议 应用层 应用层 HTTP、TFTP、FTP、NFS、WAIS、SMTP 表示层 Telnet、Riogin、SNMP、Gopher 会话层 SMTP、DNS 传输层 传输层 TCP、UDP 网络层 网络层 IP、ICMP、ARP、RARP、AKP、UUCP 数据链路层 数据链路层 FDDI、Ethernet、Arpanet、PDN、SLIP、PPP 物理层 IEEE 802.1A、IEEE 802.2 到 IEEE 802.11 那么为什么在架构设计的时候,采用分层设计的实现方案呢?之所以要设计分层,主要有以下几点考虑:降低复杂度,上层不需要关注下层细节。 提高灵活性,可以灵活替换某层的实现。 减小耦合度,将层次间的依赖减到最低。 有利于重用,同一层次可以有多种用途。 有利于标准化。中间层 除了分层设计,中间层的设计,也是非常巧妙的存在。 在计算机软件体系结构中,中间层作为下面层级的包装和扩展,中间层的存在,保证了软硬件的相对独立。 中间层的强大之处在 LLVM 设计的过程中也深有体现。 首先解释下 LLVM: LLVM 是构架编译器(compiler)的框架系统,以 C++ 编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。 LLVM 的大体结构设计如下图:它的设计主要可以分为编译器前端(Frontend)、优化器(Optimizer)、后端和代码生成器(Backend And CodeGenerator)。 笔者理解优化器(Optimizer)不仅仅作为编译过程中的一道工序(做各种优化并且改善代码的运行时间,减少冗余计算),优化器还作为 LLVM 设计最为精妙的地方--中间层。 为什么这么说呢? 前端语法种类繁多,后端硬件架构种类繁多,而正是中间层的存在,使得 LLVM 的架构即可以为各种语言独立编写前端,也可以为任意硬件架构编写后端,实现了开发语言和硬件架构之间相对独立,这才是其真正的强大之处。 类似软件的设计原则的体现 虽说是大的软件体系的结构设计,但是也能让笔者感触到一些软件设计原则的体现,毕竟万物皆对象,而面向对象设计原则如下:设计原则名称 简单定义开闭原则 对扩展开放,对修改关闭单一职责原则 一个类只负责一个功能领域中的相应职责里氏替换原则 所有引用基类的地方必须能透明地使用其子类的对象依赖倒置原则 依赖于抽象,不能依赖于具体实现接口隔离原则 类之间的依赖关系应该建立在最小的接口上合成/聚合复用原则 尽量使用合成/聚合,而不是通过继承达到复用的目的迪米特法则 一个软件实体应当尽可能少的与其他实体发生相互作用感受到了哪些设计原则?这列举一二,当然应该还会有更多。单一职责 分层设计,每层只负责特定的职责,拥有清晰的职责范围。 单一职责 层与层之间交互应该依赖抽象,任何满足每层协议的实体,都可以进行层的替换。 开闭原则 采用类似工厂设计原则,增加一种硬件类型,仅需要增加一种符合硬件规格厂商即可。总结:我们进行日常软件架构设计的时候,其实也可以参考计算机软件设计的一些思想,做一些合适的分层,制定层与层之间的协议,制定合适的中间层。 内存 早期内存采用扇形内存分区,磁盘中所有的扇区从0开始编号,直到最后一个扇区,编号为逻辑扇区号,设备会将逻辑扇区号,转换成真实的盘面、磁道位置。 早期程序直接运行在物理内存,存在的问题地址空间不隔离,一个程序内容容易被另一个程序修改。内存使用效率低,使用中的内存需要等到释放了,才能继续被使用。程序运行的地址不固定,程序重新装载时,内存地址变化了。虚拟地址&物理地址 为了解决地址空间不隔离的问题,引入了虚拟地址的概念,于是地址就分为了两种,虚拟地址空间和物理地址空间。 MMU是内存管理单元,有时也称作分页内存管理单元,MMU在操作系统的控制下负责将虚拟内存实际翻译成物理内存,其与CPU以及物理内存的关系如下图:物理地址空间是由地址总线条数决定的。 虚拟地址是想象出来的,每个进程都拥有独立的虚拟空间,这样做到了进程的地址隔离。 将一段程序所需要的虚拟空间,映射到某个实际的物理地址空间,映射函数由软件完成,实际转换由硬件完成。分段和分页 仅仅增加虚拟地址只能解决地址空间不隔离的问题,剩下两个问题还没解决,于是又引入了分段和分页。分段的基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间,映射关系如下图所示,通过使用分段,可以解决不隔离和不固定的问题,因为程序A和程序B被映射到了两块不同的物理空间。但是分段内存使用效率低下,内存映射以程序为单位,如果内存不足,被换出的是整个程序,其实程序内的很多数据,都不会被频繁用到,没必要被一起移除内存。分页就是将地址空间分为固定大小的页,进程的虚拟地址空间按页分隔,不常用的放入磁盘,用到时取出来即可,内存使用效率低下的问题得到了解决。内存共享的实现机制 虚拟空间页称之为虚拟页,物理内存的页为物理页,磁盘中的页为磁盘页,不同虚拟页被同时映射到同一个物理页,即可实现内存共享。 Page Fault 虚拟页不在内存中,当需要用到时,就会捕获 Page Fault。 对于 iOS 开发来说,虚拟内存也是通过分页管理的,当访问到某些数据并没有加载到内存时,操作系统就会阻塞当前线程,新加载一页到物理内存,并且将虚拟内存与之对应,这个阻塞的过程就叫做缺页中断,App 启动的时候 Page Fault次数多了会影响启动速度,而我们优化启动速的方式之一就是通过二进制重排,减少 Page Fault 的次数。当 App 的启动过程中如果需要启动符号1、启动符号2、启动符号3、启动符号4,那么 page1,page2,page3,page4 就都需要加载到内存中。而我们可以做的就是通过二进制的重排,将启动符号1、启动符号2、启动符号3、启动符号4放到了同一页,那么只需要 page1加载到内存即可。 大概的优化步骤:通过 Clang 插桩的方式找到 App 启动时,都加载了哪些符号,尽可能的将启动时用到的符号,通过自定义 Order File 放到同一个 启动时加载的 Page 当中,从而减少 Page Fault 的发生次数。线程 线程与进程的区别在于,进程是操作系统分配资源的最小单位,线程是程序执行的最小单位。 线程一些概念线程称之为轻量级进程,有线程 ID,当前指令指针 PC,寄存器,堆栈组成,线程是系统进行调度的最小单位。 各个线程共享程序的内存空间(代码段、数据段、堆),和一些进程级的资源(程序员角度:全局变量、堆、函数里的静态变量、代码)。 线程拥有私有的空间,栈、线程局部存储、寄存器(程序员角度:局部变量、函数参数)。 多处理器的线程并发才是真正并发,单个处理器的线程并发只不过是时间片轮流,调度。 线程至少三种状态:运行(时间片当中)、就绪(离开运行状态)、等待(时间片结束)。 线程调度分为优先级调度(容易出现饿死)和轮转发调度。 Linux 线程相关的操作通过 pthread 库实现。线程安全 多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时都可能被其他的线程改变,于是产生了原子操作和锁。原子操作++操作不是原子操作(编译为汇编代码之后,不止一条指令)因为需要经历 ① 读取值到寄存器,② 值+1 ③ 将值写会寄存器。 复杂场景,原子操作就不满足了,需要使用锁,实现同步。锁实现数据访问的原子化,即一个线程未访问结束,另一个线程不能访问,访问数据时获取锁,访问结束释放锁,锁已经占用的时候,获取锁线程就会进行等待,直到锁资源可以重用。二元信号量,有两个状态,占用和非占用,适合只能被为一个线程独占访问的资源。 多元信号量(Semaphore),设置一个初始值 N,可实现 N 个线程并发访问。 互斥量,类似二元信号量,二元信号量可以被其他线程获取到释放,但是互斥量要求哪个线程获取,哪个线程释放。 临界区,区别于互斥量和信号量,互斥量和信号量在其他进程是可见的,但是临界区的范围仅仅限于本进程。 读写锁,对于读取频繁但是偶尔写入的时候,使用信号量和互斥锁效率比较低,读写锁有共享状态和独占状态。下图为读写锁的几种状态:读写锁状态 已共享方式读取 以独占方式读取自由 成功 成功共享 成功 等待独占 等待 等待表中读写锁具体状态解析如下:锁自由状态时,任何方式获取锁都可以获取成功。 共享状态下,共享方式获取可成功,独占方式获取不可成功。 独占状态下,共享方式获取、独占方式获取都不可成功 。 写操作作为独占状态,读操作作为共享状态,读读并发,不用等待,读写互斥,写写互斥。多线程可放心使用的函数之-可重入函数满足如下条件的函数即为可重入函数不使用任何(局部)静态或全局的非 const 变量。 不返回任何 (局部)静态或全局的非 const 变量的指针。 仅依赖于调用方提供的参数。 不依赖任何单个资源的锁(mutex 等)。 不调用任何不可重入的函数。 可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。加了锁就安全了吗? 作者给我们举了一种情况: x = 0; // Thread1中 lock(); x++; unlock();// Thread2中 lock(); x++; unlock();上文已经介绍了 ++ 操作并非原子操作,编译器为了提高 x 的 访问速度,需要把 x 的值放入某个寄存器里面。 ++ 需要经历 ① 读取值到寄存器,② 值+1 ③ 将值写会寄存器三步。 那么就可能出现这种情况:Thread1读取 x 的值到寄存器 R1(此时 R1 = 0)。 R1 ++,此时 Thread2紧接着还要进行访问,但是 Thread1还没有将 R1值写回 x。 Thread2读取 x 的值到寄存器 R2(此时 R2 = 0)。 Thread2执行 R2 ++。 这时 Thread1将 R1 写回x(问题就来了,此时 R1 = 1,那么就出错了)。还有就是,CPU 发展出了动态调度的功能,在执行程序的时候,为了提高效率,有可能会交换指令的顺序,同样编译器在进行优化的时候,也是可能出现为了提高效率而交换毫不相干的两条相邻指令的顺序。 我们可以使用 volatile 关键字来阻止编译器为了提高速度将一个变量缓存到寄存器而不写回,也可阻止编译器调整操作 volatile 变量的指令顺序,但是 volatile 仅仅能够阻止编译器调整顺便,CPU 的动态交换顺序却没有办法阻止。 这里有个典型的例子,是关于单例模式的: volatile T* pInst = 0; T* GetInstance { if (pInst == NULL) { lock(); if (pInst == NULL) pInst = new T; unlock(); } return pInst; }这是单例模式 double-check 的案例,其中双重 if 可以令 lock 的开销降到最低,但是上面的代码其实是存在问题的,而问题就是来自于 CPU 的乱序执行。 pInst = new T 一共会分为三步: 1、分配内存 2、调用构造函数 3、将内存地址的值赋值给 pInst。 在这三步中 2、3 可能会被 CPU 颠倒顺序,那么就会出现这种情况: pInst 已经不是 NULL 了,但是还没有构造完毕,这时候另一个线程调用单例方法,发现 pInst 不为 NULL,就会将尚未构造完成的对象地址返回,这时候类就有可能产生异常。 为解决这个问题,我们可以使用 barrier 指令,来阻止 CPU 将该指令之前的指令交换到 barrier 之后, POWERPC 体系结构使用 barrier 优化后的单例方法如下: #define barrier() __asm__ volatile ("lwsync") volatile T* pInst = 0; T* GetInstance { if (!pInst) { lock(); if (!pInst) { T* temp = new T; barrier(); pInst = temp; } unlock(); } return pInst; }线程使用模型一对一模型 一个用户使用的线程就唯一对应一个内核使用的线程,这样用户线程就有了和内核线程一致的优点。这种情况下,才是真正的并发,如下图:优点:一个线程受阻时,其他的线程不会受到影响。 缺点1:内核线程数量的限制,导致用户线程受到限制。 缺点2:内核线程调度时,上下文开销较大,导致用户的执行效率降低。多对一模型 多个用户线程映射一个内核线程,如下图:优点:高效的上下文切换和几乎无限制的线程数量。 缺点1:一个线程阻塞,其他线程都将无法执行。 缺点2:多处理器不会对,对多对一模型性能没有明显帮助。多对多模型 将多个用户线程映射到不止一个的内核线程,如下图:优点1:一个线程的阻塞不会导致所有线程阻塞。 优点2:多对多模型,线程的数量没有什么限制。 优点3:多处理器系统上,多对多模型性能有提升。 缺点:实现较为困难。

iOS 求职寒冬?听听他们怎么说

这是一次线上分享的文字整理版。原版视频内容可以点这里查看:线上视频。 为啥会有这场分享最近在帮团队招人,像朋友圈、公众号也加了很多推广渠道,但能捞到的简历有限,即使是有简历,能过公司筛选且进入面试流程的都非常少。另一方面,现在找工作也挺难的,因为今年被裁员的人有很多,很多公司都已经不再招人了,导致竞争压力大,面试难度也跟着上升。像是裁员应该会导致更容易捞简历,更容易找到合适的人,事实却相反,这就很奇怪了,那这里面的 gap 在哪里呢? 最近正好有两个朋友都如愿找到了满意的工作,所以就想拉他们来一起分享下找工作的经历。 分享者介绍@阿卡拉 我是阿卡拉,毕业于郑州大学本科软件工程专业,2019 年 6 月进入腾讯。在腾讯主要负责的工作一直都是客户端基础平台建设,在工程效能方面不停的探索。 @JY 我是 JY,17 年毕业,是 iOS 摸鱼周报的联合编辑。我之前是在微盟的 App 基础技术部门工作、主要负责 APM 以及线上 Bug 排查等。因为公司裁员不得不重新找工作,目前是拿到了小红书的 Offer。 如何准备面试@阿卡拉 腾讯在 2022 年上半年就已经开始在慢慢的砍各种业务线,当时,对于我来说,感知比较弱,主要的原因是我一直在工程效能这一块,对于业务线的情况了解不是太多。仅仅是了解到外部的一些声音。 在5月30号,我们工程效能部门很多小组也开始在裁员,但是我们组的工具平台在最近的两年发展是挺不错的,这一次裁员没有涉及到组内任何同学。 或许是因为部门裁员的力度还是不够,接下来,开始在6月30号开始裁员,我们组涉及到的有16个人左右,最后仅剩下4个人做日常维护。 在腾讯的这三年,我发现自己的能力提升是比较快的,从进入公司的小职员,慢慢到一个大的项目的负责人,这也得益于我的 leader 和身边同事的配合。 所以,总的来说,并不是我要考虑换工作,而是公司的环境让自己被迫开始换一个工作环境。 本来在公司是 2 个月的缓冲期,所以缓冲期阶段除了处理手里的交接工作,同时也让自己休息一段时间,对这些接踵而来的消息做一个消化。然后就开始了我自己资料整理计划:整理 iOS 基础知识(八股文)【计划是 2 个周,实际花费 3 周】 计算机基础知识:主要是网络,操作系统等基础知识 iOS 的 dispatch:一直想把该模块的源码看完,但都是比较零散的,所以去看源码做了总结 iOS 的 dyld/objc:这个是 iOS 动态库加载的原理,所以需要去深入了解,objc 中包括很多的技术支持,如 autoreleasepool, 消息转发,AssociatedObject 机制。 Runloop 机制,KVO 与 KVC 机制,Block 管理机制,iOS 事件处理机制等等整理曾经看过的开源库:自己之所以喜欢去研究这些源码,是因为他们给我代码能力提升了很多,无论是从设计上和实现方案上都能有比较好的选择。【计划1周,实际花费2周】 AFNetworking:iOS 网络访问最出名的网络库,没有之一 YYCache,YYModule等由 ibireme 大神的开源组件合集 CocoaLumberjack:也是 iOS 出名的日志库,整个仓库的设计将设计模式很好的应用 OCMock:这个是一个单元测试组件,但是如果想验证自己的基础如何,这个仓库我觉得是最佳开源库,可以将 objc 的很多知识点应用进去。项目相关【计划 1 周,实际花费 2 周】 单元测试自动化:整理整个单元测试的执行流程和之前的实现方案。 质量组件化:熟悉自己开发的所有的 SDK ,包括语音 SDK,大文件上传 SDK,屏幕录制SDK 等等。 变异测试:我主要负责的一个平台。变异测试的整理设计方案与之后的优化方向;梳理 Objective C 热重载执行方案与设计;LLVM 对 Objective C 语言的语法树分析并做各种能力;LLVM 的 pass 化服务梳理。算法【每天早上刷算法】 leetcode上刷剑指 offer 第一版和第二版,然后刷 leetcode 热题 100(热题100要看着题目就马上写出来的那种) 回忆算法:主要是算法小抄和这个作者的网站:https://labuladong.github.io/algo/总结下来,因为本身自己也想去做一个总结,所以整体上花了大概 1 个半月的时间,所以基本都是在腾讯的缓冲期每天静下心来一步一步的梳理。其中准备算法耗时最多吧,前后刷了差不多 300 题。 @JY 我换工作的契机是因为公司裁员。在国庆节前两天,被通知 Last day,不需要交接。所以我是从国庆节前那几天才开始准备的。我主要准备的是简历,这里我的想法是需要明确自己想要去什么样的公司,然后根据公司要求和自己擅长的点,可以准备多份简历。简历里最主要的内容就是自己的项目经历,项目最好能体现出具体的优化指标。我面了很多家公司,都会被问到最后优化后的指标。 其次就是八股文,因为我之前有记一些笔记,再结合网上别人总结的一些内容,每天早晚都会大概看一遍。 算法这块,我在 Leetcode上开了一个会员,这样能看到热门公司题库,我把想要面的公司的算法都刷了一遍,有些题目可能不止一遍。 这里最耗时的就是算法这块了,有些 Hard 级别的算法题会很难写出来,但很多时候即使写不出来,有大概思路也行。所以如果算法比较弱的话,需要多刷一刷找找题感,才能更好的应对各种复杂的问题。 如何写简历 简历是面试过程中的敲门砖,只有简历被捞到,才会有后面的面试过程,所以它的优先级是很高的。很多人都有分析过简历应该怎么写,不应该怎么写,但我感觉讲再多都不如亲自去看一个优秀的简历是如何写的。下面是阿卡拉和JY两个人的简历,部分内容做了脱敏和抹除。 @阿卡拉@JY如何获取更多应聘机会@阿卡拉 招聘渠道的话,小公司直接去 Boss 上面找,大公司找内推。我对下个阶段其实考虑挺多的,结合自己的诉求,我主要有三个方面的要求: 1、大公司:字节,阿里,腾讯这一类的大公司,只要招聘的业务不是太偏就可以。业务上我希望继续在工程效能方向继续深挖。 2、有前景的公司,看中公司的进步和发展,看中公司的业务 3、加班压力小的公司 @JY 我基本也都是通过 Boss 或者内推方式,我会让朋友来面试我,培养自己的面感,这个过程也会一点点增加自信。因为现在大环境不好,很多大公司的 HC 都是很宝贵的,最好是准备很充分了再去面。 对于意向工作,我更关注的是该组是负责哪一块,是否是核心部门,公司发展前景如何。最重要的还有自身的发展方向和工作内容是否匹配。 @zhangferry 找工作前需要考虑的这几个问题,第三个是非常重要的。不管因为什么原因需要换工作,我们都应该把换工作这个节点当做整个职业规划的下一个起点。之前的工作有哪些不好,踩了哪些坑,我要避免,我了解到哪些知识,对哪个领域更感兴趣,可以往那个方向多去靠。 @东野浪子 挑选工作时要提前想好自己的诉求是什么,更高的薪资?更看好的行业?想从业务开发切换为技术开发?甚至就是想工作的轻松点,这都是没问题的。把这些诉求按照优先级进行排序,赋予不同的权重值,然后再分门别类的把你所能投的一些公司或者职位按照权重进行打分,分数最高的那个就是最适合你的岗位。 很多时候都不可能一个 Offer 覆盖你所有诉求,这时就可以利用这个方法论进行选择了。 如何进行面试@阿卡拉 我投的简历还是蛮多的,大概有十几家,最终进入面试环节的有七到八家,但是真正满足我那几个诉求的只有两到三家。我觉得寒冬的感受还是挺明显的,阿里、腾讯、大江、虾皮这些都已经不怎么招人了,因此可选的公司范围没有那么广。 技术面中基础知识,也就是我们常说的八股文还是比较重要的,也经常被问到。像是 Runloop、KVC/KVO、Objc、Block、GCD、Autorelease 这些技术点要非常清楚,每个点都需要深入。我自己在整理文档的时候基本也是从这几个方向展开的。 总共走完流程的有 4 家,有两家因为薪资不满意,还有一家其实挺想去的,但感觉他的发展前景不太好,最终就选择了字节。不说别的,就薪资这一块,字节还是棒棒的。 最终通过面试感觉还是因为自己准备比较充足,比较全面,哪些相关的知识点,我会把里面的方法论也都提炼出来。比如说 Autorelease 或者 Cache 相关内容,我会考虑如何把他们应用到自己的项目中去。只有真真实实地去考虑了这个东西,而且了解它的各方面内容,面试官问你的时候你就会感觉很轻松。 关于这些知识点的学习,我还会分有很大比重去看开源库。说实话我觉得腾讯这边,很多业务代码拿出来,大家都不想去看的,这些东西不会给人带来提升。而开源库是经过社区检验的,都是质量非常高的内容,仔细研究,更能学到东西。 字节给我面试体验就是他们的面试深度,整个面试过程就是带着你一步步往深了聊,但这随之而来的就是难度提升,这也是我为啥会把面字节的面试安排放到最后。 @JY 我投的简历不多,都是自己比较想去的才会投,除了两三家没有面试,其他都有。寒冬的感觉还是很明显的,因为我很多朋友去年找工作的时候,HC 很多,难度也不算高。今年因为有很多公司在裁员,有一些公司也锁了 HC,市面上人确实比较多,所以每次面试都应该把握好机会。 我在技术面上感觉稍微有些不同,一面遇到问八股的比较多,后面几面基本都是围绕项目在问。最经常被问到的就是你们团队为什么要做这个?为什么要采用这种方案?过程中遇到了哪些问题?你是如何去解决的?其次就是结果,带来了哪些收益,我感觉大厂的面试很像项目讨论会,双方就一个功能点不断地探讨,技术深度慢慢深化,这样整体面下来也会感觉比较舒服。 有一些没有走完的面试流程,可能是复习的时候没有准备到位,也有一些是平常工作中很少使用到的,因为答的不太好就被 pass 了。 面试通过的原因,我觉得主要还是平常的积累,平常做了什么项目,需要经常复盘总结。把遇到的问题以及收益都记录下来,组织自己的语言表达出来。 小红书整体的面试还是比价舒适的。整个面试都是从浅到深的,基本没有问什么八股,都是围绕项目开展的。 面试回顾及总结@阿卡拉 面试中踩过的坑,第一不要盲目面试,盲目面试会发现其实很多岗位都是不符合自己预期的,这些过程会有很多无效沟通,浪费了挺多时间。第二面试一定要做好总结,像是每次面试被问到的问题,如果我不知道或者感觉答的不好,下面我会再去总结和调整,短板补充是一个比较重要的提升过程。 面试对我还有一点改变是,因为在腾讯这三年都没有考虑过面试这件事,一直是比较佛系的状态,这次面试经历基本是反向的去提升自己的能力。因为面试的缘故我有充足的时间去系统的学习之前遗漏的知识点,并为下个阶段做一个规划。 如果面试不顺的话,就当做跟这家公司没有缘分吧。选择是双向的,我经常跟一些同事开玩笑说,在腾讯小马哥拥有我是他的幸福,而不是我的幸福。现在在字节也一样,他拥有我是他的幸福,但反过来对我来说,也促成了我的幸福。 关于选择这块,因为在腾讯时组内相处比较和谐,我也更倾向于氛围和谐的团队,工作和生活都要做最真实的自己。另外我觉得大家还是要选一个自己感兴趣的东西,一直坚持做下去,丰富自己的生活。 @JY 踩过的坑是,简历一定要写自己熟悉的(至少能够展开来讲的),不要写一些不是很熟悉,或者临时抱佛脚的东西,一旦被面试官问到,会很影响在面试官心中的印象。面试的时候,如果可以的话,就把面试官引到自己的舒适区,因为每个人技术侧重不同,这样的话更能体现你的优势,这样面试成功的几率也会更大。 面试过程做的特别对的事情是,我每次面试都会录音,面试完会听着录音进行复盘。有些人可能觉得自己面完感觉很不错,但有时候自己再听一遍时会发现自己有很多可以改进的地方。 做的不错的地方是调整自己心态吧,刚开始投简历的时候一个面试都没有,自己心态受到了一些影响,因为是被裁的,心情有些失落。然后我趁国庆节,前三天,出去旅游了一下,回来就放松下来了,再去调整心情,安心准备复习,这样效果会好很多。 换工作的话,主要是看公司能否满足自身发展,因为我们换一份工作一般都是期望工作一年以上的(大家肯定不希望自己简历花掉),如果这段经历跟自己不匹配,那整体工作状态可能会不开心。另外像阿卡拉说的,团队氛围,可能进入团队之前会比较难判断出团队氛围好不好,一般二面或者三面就是你的领导,这时你可以看你自己的感觉和面试官契不契合,也可以先以是否符合自身发展为主。我接触的程序员都是蛮好相处的。 如何筛选候选人-面试官角度 @东野浪子我简单自我介绍下吧,我是 15 年毕业,16 年开始做面试官,我刚看了一下面试记录,这些年来总共面试了大概有 570 多位候选人。我现在是在字节的客户端架构部门下的 APM 部门,带客户端团队。 好简历体现在哪里 先说第一点,作为面试官视角,第一眼关注的是你的硬性指标:学校、工作经历、技能匹配程度。重点大学和大厂经验会是加分项,另外还会去看你的技能匹配程度,如果你之前是做基础技术的,你应聘的也是基础技术,这就属于比较匹配。但是如果你的这些条件都不算出彩的话,就需要用项目经历去弥补了,这是第二点我比较关注的内容。 对于项目经历的整理和描述要条理清晰,可以使用 STAR 法则,我做这个东西的背景是什么,基于这个背景,你的目标是什么,你通过什么行动达成了什么样的目标,最终一定要有结果,而且最好是可以用数据量化的结果。项目中一定要有重点,一些重要的结果和数据,可以使用加粗的形式去强化。 简历也可以提现个人风格,我看过一些简历,一看就知道这个人非常的极客。因为它里面有非常多的专业术语,能明显感觉出他对技术的理解非常深刻。如果你面试的是基础技术部门,那就应该在简历里多写一些你在底层技术的探索,或者你在技术优化上做过的一些事情。这些都是简历相关的内容。 好的应聘者需要具备哪些特征 对面试阶段候选人的表现,最重要的就是基础一定要扎实,基础决定了你以后的发展潜力,甚至决定你在职场中的天花板。这里基础又包括很多方面,比如计算机基础、操作系统原理、网络等等,这些东西一点要多查漏补缺,不要有明显硬伤。这一点如果表现不好,那肯定是过不了面试的。 再就是数据结构和算法,有一点需要澄清下就是不要畏惧算法,至少在我们团队算法考察最多就是 medium 级别,不会在算法层面太难为大家。很多同学面试挂掉,如果把结果归结于算法没写好,是需要纠正的。因为算法是作为一个侧面角度考察的,他不是直接决定面试结果,如果你前面回答很出色,就算是算法答的不太好,还是会过的。 第二点是领域知识,做iOS开发对苹果开发相关的比如UI动画、多线程、内存管理、性能稳定性等都要有一定的了解。如果是做底层技术,你还需要掌握 dyld、runtime 相关的底层原理和常见的优化手段。很多人会把这说成八股文,我感觉更合适的是把它作为对你技能点的考察。这类问题通常的考察方式是从一个非常简单的问题切入,然后抽丝剥茧,逐渐加大难度,看你能够顺利的通过多少关。 第三点属于项目亮点,比如一个很常见的面试问题:你做过最满意的一个项目是什么,项目里有哪些亮点内容?亮点内容应该体现出一定的复杂度,你跟其他人的方案有什么差别,使用了哪些设计能力,涉及哪些技术选型,如何协作和执行的。同时也不能只谈难度不谈收益,它达到了哪些业务价值,你在这里承担了什么角色,是否是跨部门项目,这些方面都可以作为亮点去讲。业务价值描述时可以通过方案前后的对比来体现,之前指标是多少,使用该方案之后达到了多少,这种展现是非常直观的,更能引起面试官的共鸣,这个技巧同样也可以用到汇报工作和晋升答辩中。 软素质也会作为面试考察中比较重要的一点,因为应聘者最终会成为我的同事或者下属,我会关注自驱力,你对技术有好奇心,会主动地探索。然后是规划能力、沟通协作能力、迁移复用能力等。这些点比较多,如果无法通过一次沟通完全展示出来,那可以根据自己的需要选择一两个方面有体现也行。比如你想转技术栈或跨专业,那你就要说服面试官为什么之前积累的经验可以继续复用。 一些面试小技巧对于没有把握的问题,不要不懂装懂,面试官会很讨厌不懂装懂的情况,这会严重扣分。还有就是对于你没有把握的问题,最好不要一言不发或者直接说我不了解。如果遇到了这类问题可以尝试从自己熟悉的角度切入,或者给出一个简单版本的答案。 第二个技巧是不要在面试过程中与面试官发生冲突,有可能他老问八股文,你感觉面试官在刁难你,或者你觉得面试官很菜,都不要起冲突。因为一般面试都会有面评记录,如果有冲突,这个问题被记录,后面会影响你再面其他部门,得不偿失。 还有一个技巧就是多复盘总结,很多人会有一个面试误区,感觉面试没发挥好,就不去想他了,而产生一些抵触情绪,这其实对自己提升是无益的。一定要有复盘,也可以跟小伙伴一起讨论自己的面试情况,避免自己有认知偏差。因为有时候我们感觉自己回答的很好,但最终却没面上,有一种可能就是你回答没有达到点子上,或者回答的比较浅显,跟面试官期待有偏差。 @zhangferry 对于软素质,我感觉它体现了一个隐藏因素--兴趣,有了兴趣做为驱动力,才会在没人约束的情况下自发去探索,去做到更好。其实很多人对编程,对开发这份工作是不感兴趣的,只是当做挣钱的一种手段,这没什么不好,反过来完全出于兴趣,敲代码时快乐的不得了的人也很少。但不管怎样的状态,工作中还是应该尝试去找些让自己兴奋起来的点,比如我解决了什么技术问题,老板给我奖励;我写了一篇文章,大家都给我点赞;我在团队做了什么分享,大家都来向我请教相关问题等等。这些跟技术联通的点和个人成就感结合起来,能更好的激发创造性。上班很辛苦,但不应该只有辛苦,毕竟这份工作我们可能还要在做几年或者十几年时间,一直都感觉苦肯定是很难精进的。这一点大家也可以结合自身情况多想一想啊。 面试结果由什么决定@zhangferry:本次分享的目的是给大家提供一些值得借鉴的面试方法,其实刚才也聊了很多面试之外的事,包括如何挑选岗位,如果在工作中补足自己学历或者经历的短板。回到一个比较重要的问题上,一次面试,我有多大概率能够成功。我认为,日常工作经验和总结占 60%,面试准备占 30%,面试相关的技巧和运气占剩余的 10%。对,有时候运气也会决定面试的结果。 如果是处于工作状态,近期并没有打算换工作的同学,可以在工作期间多考虑下如果想要优化自己的简历我应该做好哪方面的事情。如果是正在准备面试的同学,工作内容已经没法改变了,但还可以把时间多放到面试准备上来,充分的准备也能提供我们通过的概率。 还有一点想要强调的是,有不少人会在准备不充分的情况下去面试,这个其实是挺不好的。因为现在面试机会真的不多,每次面试都很宝贵,另外以字节为例,不超过半年的面试,面评记录会一直留着。如果你之前面试效果不好,那后面你准备充分了,换个部门去面,就很大概率会因为面评不好直接被刷下来。我遇到过好几次这种情况,有些人会说是不知怎么简历就被 HR 捞到了,然后电话联系要约面试,自己稀里糊涂就参加了。如果遇到这种情况,一定要以自己实际准备的情况来决定是否要约面试。 QA 遇到面试就紧张,脑袋空白,面试过程不会吹怎么办? @东野浪子:紧张背后的原因大多数情况就是准备不充分,导致不自信。我们应该从这个方面去解决紧张的问题。充分的准备就是上面说的一些基础知识,领域知识,要非常熟悉;涉及项目经历这块则可能需要更长期的准备。比如日常工作中主动承担一些有挑战性的业务,对一些技术问题进行深入探索。这些点都是面试过程中可以让自己出彩的地方,所以工作的一部分也是在为自己的简历打工。 不会吹可能侧面想说的是不会表达的意思,会有些人面试很强,但是实战一般。比如一个人实力是 60 分,他能说成 80,而有的人实力是 80 分,因为不会表达,让人听起来只有60 分。这样的话,就需要培养一下自己的演讲或者表达能力了。这种能力在日常工作工作、汇报或者晋升答辩时都非常重要,这个能力本身也属于面试考察的一部分。所以平时可以刻意得去培养锻炼一下。 写了两年 UI 怎么办,就做了基本的页面开发,感觉项目经历很空洞 @东野浪子:如果一直重复一种开发模式,那确实不太好。你可能很难改变开发的需求,但是使用什么样的技术却是没人会限制你的。即使是开发UI,从 Frame、Autolayout、Masonry 再到 SwiftUI,包括 RN 和 Flutter,这一步步流程都会涉及很多 UI 内容,不同框架的出现是解决什么问题的,怎么才能更适合当前工作且提高效率,这些都是自己可以延伸思考的事情。另一方面,你做UI会不会涉及到流畅性问题、会不会涉及一些Crash,那这些问题都是可以再延伸很多内容的。所以即使复杂度不高的项目,也不会说不涉及复杂的问题,就看你有没有主动给自己增加难度,去做有调整性的那部分。没有大厂经历也类似,没人会限制你学习大厂的技术,而且现在有很多途径去获取他们的技术方案,自己可以多学习,多思考,然后实践到自己的项目中。我们是非常欢迎这种有自驱力和主动性的同学的。 如何寻找自己的第二增长曲线 @东野浪子:第二增长曲线,往大了说比如你现在是一个程序员,这是你的主页,第二曲线就可能是一个副业。比如炒股、做新媒体、公众号、拍抖音等等。往小了说,比如你现在是 iOS 开发,你只会 OC,那 Swift 以后可能会是一个趋势,你精通 Swift,那这就会成为你的第二曲线。你如你现在是做UI页面,做纯业务,那是不是可以去了解一些底层技术,像是 APM、DevOps、编译链接、端智能等,或者往跨端、全栈方面考虑,这些都是可以成为第二曲线的。第二曲线的东西一定要跟你的兴趣匹配,你要对他有热情,即使没有收益也能够坚持下去那种。 请问“横向协作能力,迁移复用能力”,这两个软素质一般会如何考察和作答? 是否是先回答自己的方法论,然后举例来说明? @东野浪子:横向协作能力,一般会在大型一些的跨团队合作项目中会有。比如我们是做客户端监控的,有些东西就会是一个双端方案。比如客户端上报流量,太高的时候后端会触发一些容灾或者限流,这个跟每个端的特性都没有关系。那如果作为这个项目的 Owner,你怎么把事情推进下去,怎么保证项目周期可控。他会涉及一些跨团队的沟通协作、目标对齐还有项目管理,这里体现的就是横向协作能力。 迁移复用能力属于比如你在一个领域 APM 做了很久,那现在需要专做音视频,相对于 APP 来说,怎么对音视频进行性能优化呢?这里有很多方法论其实是可以迁移出来再次适用的,像是线上容灾、问题响应、防裂化等处理方式都有一定的相似性。 内推信息 因为是为了信息互通,各位分享者团队有都有招聘需求,这些是内推信息:内推人(微信) 岗位描述 招聘需求zhangferry(zhangferry) [北/上/杭]抖音iOS基础技术-研发效能 有静态分析、LLVM、单元测试、自动测试框架、架构、工程效率、全栈开发等经验者优先,有业务背景但对技术有深度追求者优先。东野浪子(569087164) [北京]字节跳动 APM客户端-iOS 负责字节跳动所有移动端产品的性能优化和问题排查。北京还有 3 个 HC阿卡拉(myself439664) [深]抖音iOS基础技术-自动化测试 提升抖音等产品的研发代码质量,降低测试成本,参与自动化测试服务建设。JY(q491964334) [北/上]小红书-客户端基础架构 有过千万 DAU APP 的基础架构方面的开发经验,具有钻研 iOS 系统底层实现与优化的能力对于内推岗位,我也维护了一个公共链接,内推信息汇总,大家如果有招人需求可以到这里来填写。老司机技术周报很早也在维护一份内推信息,会更全一些,可以点击这个查看:iOS靠谱内推专题

《卓有成效的工程师》书评

  • 07 Oct, 2022

关于本书 本书英文版《The Effective Engineer》 出版时间是 2015 年,中文版出版时间是 2022 年的 7 月 1 号,是一本刚翻译过来的书。用 Effective 命名的图书很多都是经典之作,管理类的像是《The Effective Executive》、《The 7 Habits of Highly Effective People》,编程类的像《Effective C++》、《Effective Objective-C 2.0》,本书也不例外。 作者 Edmond Lau 早期在 Google 担任搜索质量软件工程师,后作为 Quora 的初创成员之一,领导工程团队致力于用户增长,并为新入职软件工程师制定入职培训和指导计划。他热衷于帮助工程团队建立强大的文化,这本书是他关于如何成为卓有成效的工程师的职业感悟和个人总结。 导读本书主题是如何成为卓有成效的工程师,即作为工程师如何提供工程效率。对于工程效率给出了一个杠杆率的概念:杠杆率 = 产出的影响 / 投入的时间高效的工作方式应该是致力于做高杠杆率的事情。全书主要分为三个部分,第一部分是讲如何聚焦杠杆率高的事情,第二部分为对于高杠杆率的事情如何优化我们执行的方法,第三部分是把时间维度拉长,去投资哪些长期价值高的事情。 聚焦杠杆率 使用杠杆率衡量工作成效 杠杆率还可以用时间的投资回报率(ROI)来类比,追求高杠杆率时,通过公式我们可以得知高杠杆率的事情应该是:提高产出降低投入的时间所以增加工作时间并不符合高杠杆率的目标。当我们感觉可以通过增加时间来完成某件事情是正确的做法时是忽略了时间是有限资源,在这里投入了更多时间意味着我们会在其他事情上可利用的时间被占用了。 除此之外还有一个方式是停止手头的内容,转向杠杆率更高的事情。这三种方式可以引申出三个问题用来评估我们正在进行的工作:如何增加该工作产生的价值如何在更短的时间内完成这项工作是否有其他工作可以在当下创造更多价值本书的核心思想就是围绕这几个点展开的 定期调整优先级 在调整优先级之前,我们应该先有一个地方能够看到我们当前在做的事情有哪些,这需要一个任务清单去管理我们所做的事情。 有了任务清单最好还有一个工具帮助记录时间的使用,它可以衡量我们在事情安排和时间使用上有哪些不足。番茄工作法一个非常好的尝试,它可以把时间有序的划分成不同的块,这种有起始和结束时间的区间更利于我们意识到时间的存在。比价高效的是一天会有 10-14 个番茄时钟用于处理重要的事情。 当任务列表被填充内容过多时我们应该警惕是否忽略了优先级问题。调整优先级不是一件容易的工作,和大多数技能一样,它需要不断实践。对于什么事更有价值的事情,有两个方面可以考虑:关注直接创造价值的工作,以及关注重要但不紧急的工作。 同时为了保持工作的高效,有几个方法可以用于借鉴:创建守护者进程。在日历上每周每天或者每周划定一个时间段只专注于工作。 限制同时进行的任务数量。人能够同时处理事情的数量是有限的,多个任务之间的切换本身也是耗费时间的,所以要控制自己同时开展任务的数量。 拖延症是阻碍我们高效的一大问题,可以采用「如果...就...」的方案给自己定一个完成节点。可行的计划类似:如果在下一项工作前有20分钟,我就去审查代码、回复邮件等。优化执行方法 优化学习方式 在经济学里有复利的概念,一旦利息被加到存款本金中,就会在未来产生复利,复利又会带来更多的利息。从以上两条对比的复利曲线可以看出,复利是一条指数增长曲线,前期看着会比较慢,但后面的增长速度是非常快的。复利开始的越早,就会越早进入高速增长区。即使利率差比较小,经过漫长的时间之后收益也会产生巨大的差异。 学习和理财一样,前面的学习会为后面的学习打基础,越早对学习方式进行优化,产生的复利效果就会越明显。与之相反,如果我们把时间花在缺乏挑战的工作上时,不是保持原地踏步那么简单,同时我们还在浪费着时间。Palantir 公司的联合创始人斯蒂芬科恩在斯坦福大学的一次客座演讲时强调,「当公司为一份轻松的、毫无挑战的、朝九晚五的工作向你支付薪水时,他们实际上是在付钱让你接受更低的智力增长率。等你认知到智力投资会产生福利效应时,已经为错失长期复利付出巨大的代价。他们没有让你获得一生中最好的机会,而是得到另一件可怕的东西:安于现状。」 投资时间到学习上是最容易产生复利效果的一件事情,我们可以采取这些方式:培养成长型思维模式。该理论来源于Carol Dweck 在《终身成长》,「固定性思维模式的人坚信,人的能力是先天注定的,后天无法改变。成长型思维模式的人相信,通过后天努力可以培养和提高自己的智力和技能」。 不断对学习方式进行优化,以提升学习速率。 寻找利于学习的工作环境。关于第三点,如何寻求一个利于学习的工作环境,有这六个因素可以考虑:快速增长。快速发展的团队和公司能提供大量产生巨大影响的机会。 培训。细致规范的入职流程表明该组织将员工的培训放在首位。Facebook 有一个为期6周的新兵训练营(Bootcamp),新入职的工程师将通过该计划了解公司所用的工具及重点领域,并完成一些初步的实际开发工作。 开放。要追求一种充满好奇心的组织文化,鼓励每个人提出问题,在结合一种开放的文化,让人们积极反馈和分享信息。 节奏。快速迭代的工作环境能提供更短的反馈周期。一个想法从构思到获得批准需要多长时间?快速行动是否体现在公司或工程价值观中。都是可以考虑的方面。 人员。与那些比自己更聪明、更有才华、更有创造力的人一起工作,意味着我们身边有很多潜在的老师和导师、就职业成长和工作幸福感而言,和谁一起工作要比实际做什么工作更为重要。在选择时可以考虑这些问题:面试官看起来比你更聪明吗?你的面试是否演进全面?你想和面试官一起工作吗? 自治。选择工作内容和工作方式的自由驱动着我们的学习能力,可以考虑大家是否拥有选择项目、开展项目的自主权?工程师是否参与产品设计的讨论并能影响产品方向。这几点因公司和团队而异,不同职业时期各个因素的重要性也会发生变化,但不论怎样,在工作中我们还可以充分利用工作中的资源以提高学习效果:学习公司里最优秀的工程师编写的核心抽象代码,特别是使用过的核心库,里面会有很多值得学习的东西。 学习也要伴随实践,把学习内容落地到项目中,编写更多代码。 研读内部可以获取的任何技术和学习资料。例如谷歌有大量资深工程师编写的 CodeLab。 掌握你所使用的编程语言,读一两本该语言的优秀著作,再至少掌握一种脚本语言,你可以将其看做快速处理工作任务的瑞士军刀。 请公司里最严格的人审查你的代码 用于学习自己不熟悉的代码这里可以补充一些我在抖音的工作感受,这里完全符合「利于学习的工作环境」。我所在团队是研发效能,这里有多个方向上还处于探索期,有很多想法可以尝试。这提供了很大的自由空间,我可以选择自己感兴趣的方向去尝试,最大限度地自主设计这些功能,并能得到多方资源的支持。 培训方面字节的机制是很完善的,像 Facebook 内部也叫 Bootcamp,时间跨度为 6 个月(整个试用期)。这里的内容有很多主题,大的有工程师文化讲解,小的有各个技术专题的内部分享。技术分享比较系统,更像是课程,从初阶到高阶,涵盖范围很广,配有完整的知识图谱进行对照。抖音内部每周还会有一个技术分享,邀请其他部门或者内部团队的优秀同学,这些内容又可以沉淀下来扩展整个学习素材库。 人才方面字节最不缺的应该就是聪明、有才华的人了。严格的 CodeReview,有趣的技术方案讨论,和优秀的人一起工作,本身就是一件享受的事。 个人权限也很高,你基本可以看部门内部任意一个代码仓库的源码,你可以向任何人请教某一个技术实现的细节。 再附一个招聘的信息:我们是负责抖音整体研发阶段质量问题的团队,有很多研究方向但苦于人力不够,所以计划在年前再招几位同学,感兴趣的同学可以点击这里了解详情:抖音 iOS 基础技术 - 研发效能方向内推投资迭代速度 Twitter 平台工程前副总裁 Raffi Krikorian 会不断提醒团队:如果某个任务必须手动做两次以上,那么第三次就去编写一个工具。工具是一个倍增器,它使我们能超越工作时间的限制。 日常工作中,像是本文编辑器、IDE 和浏览器这些高频使用的工具,代码导航、代码搜索、格式化等这些高频的编程习惯,哪怕花费一个小时或者更久去记住一个快捷键或者设计一个自动化功能去提高这方面的效率,长远来看都是非常值得的。有一些行之有效的建议可以采纳:熟练使用自己喜欢的文本编辑器或 IDE 至少学习一种高级编程语言,一种脚本语言 熟悉 Shell 命令 多用键盘少用鼠标 自动化手动工作流程关于自动化工作流程,除了脚本本身还可以利用 Alfred 帮助管理任务: 在调试一个程序时我需要频繁的检查它的产物,因为这个功能是嵌到另一个项目中的,所以它的层级目录很深,每次误关闭 Finder 都需要点击多次才能找到目的位置。对于快速定位一个目录,可以利用 Alfred 的 Default Results 功能,为这个目录配置一个替身,当我要打开该目录时直接搜索替身名即可。 类似的还有通过路径打开 Finder 对应位置,直接运行一段脚本,通过快捷键用Xcode/VSCode/PyCharm打开指定文件或文件夹等等。都可以配置为 Alfred 的 Workflow。 当原本需要三个或更多步骤才能完成的事情缩短为一个步骤时,这边便捷感会让我们的工作流程更流畅,聚焦与真正要处理的事情上。通过度量改进目标 Peter Drucker 在《The Effective Executive》中指出「如果你不能衡量它,你就无法改变它」。 以谷歌搜索为例,输入一个查询,可能对应的结果就有数十亿条,算法会计算出将近 200 个指标,然后返回前十条结果。那么如何衡量给出的这十条结果对用户来说是最优的呢?谷歌给出的衡量是用户愉悦感,让用户最舒服的结果就是最好的,衡量愉悦感是用「长点击」测量的,就是当一个人点击了一个搜索结果,没有立即范围搜索页,就表明用户对搜索结果是满意的。好的度量指标能推动我们不断优化工作结果。 有时因为系统复杂性的原因,很难通过单一指标去衡量它的运行状况,这时可以引入监控系统,从多个方面衡量起健康和稳定。 与此同时,还需要确保你的数据是可靠的,「数据都正确」比没有数据更糟糕。特别是数据指标建设早期,我们应该多方便去衡量数据指标的正确性,比如广泛记录日志以备用,尽早检查采集到的数据,通过多种方式计算同一指标,交叉验证数据准确性,尽早分析有问题的数据,以找到指标变动的原因。工作成果需要借助于度量来改进,同时我们的工作效率也可以引入度量系统。有时数据层面反应的问题,会比事情本身更有价值。 我最近发现了一个宝藏 App:Session,它同时满足了我任务记录、反思、度量这几个需求。我通过查看自己过去半个月的记录情况发现,我最高效的一天是完成 7 个番茄时钟;遇到最多的问题是任务规划不合理导致很多任务超时和中途被打断;第二周的番茄数量相比第一周有所下降。这里完整反应了我对时间的使用情况,并且可以根据这些数据做出针对性调整。建立反馈循环 在做项目,尤其是大型项目时,花费一小部分精力用来搜集数据,验证目标可行性是非常值得的投入。它们可能会增加我们 10% 的开销,但也有可能提前暴露问题,节省 90% 的精力。这是一种通过搜集尽可能多的信息来预见结果的提前反馈机制。现代项目基本都会引入 DevOps 流程,这也是一种为了加快验证代码效果以建立反馈循环的机制。 除了项目整体,作为个人在项目开发过程中也需要尝试建立与外部的反馈循环。通常我们会遇到这种场景,在实际工作中我们必须独立完成一个项目,它减少了外部沟通成本的成本但却增加了反馈阻力。如果这期间遇到某个实现被卡主,会很让人郁闷。我们可以尝试这些策略用于获取反馈:尽早并经常提交代码并进行代码审查。 请求严厉的批评者审查代码,通过严厉的审查要求,我们更容易获得反馈。 征求团队成员的反馈,把自己的想法告诉一位成员,想他解释你正在做的事情,有可能我们在解释时就会意识到一些优化点。 编写代码前先展示设计文档,通过设计文档去考虑之后要做的事情有哪些。提升项目进度把控能力 「这个功能大概多久能做完?」这是每个程序员会经常面临的问题,针对估算,Steve McConnell 甚至写了一本书《软件估算的艺术》。一个好的估算能够对项目的实际情况提供足够清晰的视角,使项目负责人能够就如何控制项目以达到目标做出正确的决定,而一个差的估算,会让自己陷入紧张的情绪和无休止的加班,甚至会引起依赖业务方的连环 Delay。 准确的估算是很难做出的,但我们可以采用一些策略让估算尽可能地贴近真实值:将项目分解为细粒度的任务。 根据任务需要进行估算,而不是自己或者别人希望花多长时间进行估算。 将估算结果视为概率分布,而不是最佳情况。例如我们可以说「我们有50%的可能在4周后交付该功能,但有 90% 的可能会在8周内交付」。 谨防锚定偏差,这是人的一种潜意识,项目经理可能会给出一个大概的数字,或者在我们不了解完整需求的前提下先给一个大致估期。虽然我们可以再做时间上的修正,但因为有了这个锚定排期,我们很容易向这个值靠近而非真实的估期。 根据历史数据验证估算结果。比如之前你的估期总是低估2 0%,但此次估期你可以在原有基础上再增加20%的时间。 可以就估算结果进行审查,并允许他人质疑估算结果,有些经验更丰富的人可以帮助我们发现估算中的错误或者确认内容。在项目开始之前还应该为意外留出一些预算(Buffer),因为除了工作我们还会有 bug 修复、面试、团队会议、回复邮件等等。一天有 8 小时用于工作,并不表示我们可以用这 8 个小时完全去做这个项目。 除此之外设定一些具体的项目目标和可度量的里程碑也非常有用。明确我们到底要解决什么,有助于我们确定哪些工作在范围之内,哪些在范围之外。很多时候我们在开发一个功能时会发现一块代码需要重构,且这块重构会不知不觉导致我们投入很多时间。这时关键目标就可以帮助我们确认,这个重构是否值得。 当我们被问及某个项目的进度时我们通常会说完成了 50%、90%。这个估算是很主观很模糊的。一种比较好的方式是为目标建立一些里程碑,作为项目进展的检查点,中间阶段应该是完成了某个功能、达到了什么效果、对指标优化了多少等等。 但项目在进行到某个阶段之后,我们可能意识到已经出现了 Delay 风险,这时大部分人考虑的是加班。对待加班一定要慎重,因为增加工作时长并不意味着一定能赶上发布日期。加班冲刺还很容易产生大量技术债,这些技术债很可能会成为我们以后得麻烦。 尽管加班有种种弊端,可能经过深思熟虑之后,我们确定可以通过一小段时间的加班赶上最终的进度。在开始之前,需要告知每个人都了解进度落后的原因,并制定更切实可行的项目计划和时间表,如果某个阶段发现还是会延误,那么就修改原定任务或者放弃冲刺吧。 投资长期价值 权衡质量与务实 软件质量可以通过严格的 CodeReview、单测、代码覆盖率、自动化检测流程保证,但这些繁琐的流程会导致项目不够敏捷,过于敏捷又不利于控制代码质量。所以软件质量是一个需要权衡的问题,回到杠杆率上,我们应该去关注长期价值,我们追求的是什么。在衡量这两者时,有这些方法可以尝试:建立可持续的代码审查流程。它可以帮助尽早发现错误,因为考虑到代码会被审查,它还可以增加代码提交者的责任心。 利用抽象控制复杂性。将一些复杂的事情进行封装,抽象一次性解决难题,且解决方案可以多次使用。Google 构建了 Protocol Buffers 以可以扩展的方式对结构化数据进行编码,构建 BigTable 用于存储和管理 PB 级结构化数据,以提高生产力。 引入自动化测试。自动化测试最大的好处就是解放人工测试的成本。编写第一个测试通常是最难的。一旦有了一些好的测试策略、测试模式和测试代码库,将来再编写测试时所需的工作量就会减少。 偿还技术债。Martin Fowler 在《重构》中指出「最常见的问题是开发者失去对技术债的控制,并将未来大部分的开发工作花在支付技术债的巨额利息上。」谷歌会举办「修复日」活动作为偿还技术债的轻量级机制,鼓励软件工程师解决特定主题的技术债问题。与其他问题一样,技术债是否必须偿还也是需要权衡的,如果可以判断出它影响重大就需要尽快处理,如果它影响很小,我们应该将这部分时间用于更重要的事情上。为团队成长投资 招聘应该是工作中非常重要的一件事情,所以设计一个有效的面试流程是一件高杠杆率的事情。为团队添加一名实力强劲的工程师所带来的额外产出,远远超过你可以做的其他许多投资的产出。 为招聘投资可以从两方面考虑,一个清楚需要招聘什么样的人才,不断迭代优化面试流程。一个是设计一套高质量的入职培训,让新员工更快速的融入公司。 招聘流程优化:定期开会讨论招聘和面试效果,找到准确评估团队重视技能和素质的方法。 设计具有多层难度分级的面试题。 控制面试节奏以保持高信噪比。 旁听其他团队成员的面试流程,并彼此反馈Quora 的入职流程:CodeLab。它是一份文档,用于解释产品中的一些核心抽象的设计理念和使用方法,并提供编程练习以帮助验证对这些核心抽象的理解。 入职培训讲座。新员工入职前三周,会有10场入职培训,培训由团队中资深软件工程师担任讲师,会介绍代码库和网站架构,并演示不同开发工具的使用,和工程实践的价值观。 导师制。每个新员工都会配一名导师,以便为他提供更加个性化的培训。 入门任务。新员工会有第一天任务,第一周任务等,用于快速适应工作环境。另有两点能够体现团队价值的事情:代码所有权的问题。当一个功能只有你知道时,不要把这当做自己的价值体现,因为你会因此失去处理其他工作的灵活性。在团队中找到可以满足这些需求的其他成员,能让你有更多自由去专注于其他高杠杆的活动。事后复盘汇聚集体智慧。我们总不可避免的犯一些错误,或者获得一些好的经验。及时复盘可以帮助我们进行事后总结,不管是好的案例还是不好的案例都能从中吸取一些教训。总结 好的工程师文化能够吸引优秀的人才,不好的工程师文化会导致优秀的工程师离开。关于什么是卓越的工程师文化,它们具有这些特点:优化迭代速度 坚持不懈地推动自动化 构建正确的软件抽象 通过代码审查来关注高质量代码 在工作中互相尊重 建立代码的共享所有权 对自动化测试投资 提供实验时间,不管是工作时间的20%,还是黑客马拉松 培养一种学习和持续改进的文化 聘用最优秀的人作为普通员工应该向这样的团队靠近,作为管理者则应该把团队往这个方向塑造。

利用 Github Action 实现博客自动发版

背景 先说下背景需求,在摸鱼周报的整理流程中,最后一步需要生成公众号的原文链接,原文链接指向的是个人博客地址。博客需要发布才能产生外部链接,发布到不费事,但是操作步骤重复,且因为涉及博客推送相关的配置都在我的个人电脑里,所有步骤必须由我来完成。来回多次之后就考虑将这个流程做成自动化了,目标是让周报协作者都可以实现博客推送,用到的实现方式是 Github Action。 实现思路 在开始之前先了解下原先的发布流程,如下图表示:整个过程涉及 3 个仓库:Moyu Repo。管理周报文章的公共仓库,协作者可以通过它拉取和推送内容。Blog Repo。管理博客内容的私有仓库,周报只是其中一部分。Blog Website。博客的网站,它部署在一台腾讯云服务器上,它也是私有的。因为涉及两个私有仓库,普通协作者都没有他们的访问权限,所以发布流程都依赖我来完成。解决方案就是消除发布流程对权限的依赖,理想流程是这样的:这样触发入口就都集中在了共有仓库,协作者也可以参与博客发布。要实现这个流程需要将需求分为两步: 1、Moyu Repo 通过 Github Action 推送 Moyu 内容到 Blog Repo。 2、Blog Repo 通过 Github Action 发布内容到网站。 这其中最关键的是访问私有仓库时如何处理权限的问题。 Github Action 这里先简单了解下 Github Action。它是 Github 提供的为仓库定义自动化流程的方案,类似 Jenkins、GitLab CI/CD。Github Action 有一套自己的流水线配置方式,所有的流程都可以通过一个 yml 文件下发。Gtihub Action 有自己的虚拟机,支持 Windows Server/Linux/Mac,使用者无需关心环境配置问题,可以直接使用。 配置入口如下图所示:点击set up a workflow yourself,即创建了一个用于编排自动化任务的 workflow,它对应一个 yml 文件,所有的配置流程都在这里进行。 自动化任务配置前我们需要先考虑这几个问题:什么时机触发?在什么设备运行?如何执行自动化任务?我们看一个简单的例子来学习下 Github Action 如何定义这些行为: name: GitHub Actions Demo on: [push] jobs: Explore-GitHub-Actions: runs-on: ubuntu-latest steps: - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." - name: Check out repository code uses: actions/checkout@v3name 表示当前流水线的名字。 什么时机触发 在什么场景触发,对应的key 是 on。上面Demo里的[push],表示仓库发生push行为时触发任务。on 还有其他几种触发途径:pull_request:提交 PR 时触发 schedule:定时触发,可以按 cron 语法配置定频 workflow_dispatch:手动触发,有用户手动激活触发行为在什么设备运行 对应的关键词是runs-on,Demo里指定值为ubuntu-latest,表示执行设备是一个 ubuntu 设备。Github Action 还支持 macOS 环境,目前有三个 macOS 版本可以支持:虚拟环境 YAML标签macOS Monterey 12 macos-12macOS Big Sur 11 macos-latest或macos-11macOS Catalina 10.15 macos-10.15需要注意:macos-latest 不是最新的 macos 版本,而是 macOS 11。iOS 开发中我们可能还会关心 Xcode 版本,Ruby 版本等。以 macOS 12 虚拟机为例,Xcode 版本:Version Build Path13.4.1 (default) 13F100 /Applications/Xcode_13.4.1.app13.4 13F17a /Applications/Xcode_13.4.app13.3.1 13E500a /Applications/Xcode_13.3.1.app13.2.1 13C100 /Applications/Xcode_13.2.1.app13.1 13A1030d /Applications/Xcode_13.1.appRuby版本:2.7.6/3.0.4/3.1.2。 其他预制环境可以参考这篇文档:macos-12-Readme。 另外 Github Action 还支持将自己的设备定义为运行机,你可以在这里了解:About self-hosted runners;支持联机调试,可以通过这个插件了解:A debugger for actions。 如何执行自动化任务 有两种执行任务的方式,一种是直接在 yml 文件里编辑脚本,关键词是run。像是 Demo 里的 echo 命令,我们可以直接输入 shell 命令进行执行。 另一种方式是插件市场,像下面这种形式: - name: Check out repository code uses: actions/checkout@v3就是使用了 actions/checkout@v3 这个插件。Github 有一个插件市场,可以搜索所需插件。像是 Code review,SSH 登录等都有封装好的插件可以直接使用。实现方案 有了这些 Github Action 知识,我们就可以开始实现我们的需求了。最终效果分成两个需求。 Moyu Repo 向 Blog Repo 推送内容 我们按照前面的三个问题来设计这个功能。 什么时机触发? 发布之前需要经过多次修改,会有多个 PR 和 Push 行为,而 Blog 发布需要等所有内容都准备完成才会执行,一般只有一次。所以考虑使用手动发布的方式,以下是配置内容: # Action name name: Weekly Article Deploy # Controls when the workflow will run on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: inputs: weekly_index: description: 'weekly index for deploy' 手动发布还补充了一个 inputs,用于接收输出参数,weekly_index为参数名,用于表示要发布第几期。执行效果如下所示:在什么设备运行? 这个需要根据执行任务来定,这里只涉及一些文本转换和仓库操作,所以任意机器都满足需求,ubuntu 资源比较多,调度会快那么一点点,所以都可的情况优先选 ubuntu。 # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on runs-on: ubuntu-latestGithub Action 里有几个名词:workflow,job,steps,这里简单捋一下。整个 Yml 文件对应为一个 workflow,它表示一次完整的自动化任务运行过程。当前仓库的整个配置都是一个 workflow。 一个 workflow 可以包含一个或多个 job,这里的 jobs 下面一级内容就是各个 job。不同 job 之间可以串行也可以并行。build为会其中一个 job,也是本 workflow 唯一的 job。 如何执行自动化任务? 这个流程需要做的事情是把 Moyu Repo 内容转成 Blog Repo 的格式,然后推送到 Blog Repo 里。前一步可以封装成一个脚本,后一步往私有仓库推送需要生成一个具有推送私有仓库权限的 token。 token 的生成需要到这里:个人头像 -> Settings -> Developer settings -> Personal access tokens,点击 Generate new token。这一步需要输入密码,然后我们可以选择所需权限去生成一个token。对于私有仓库的推送,我们选中这一个权限就可以了:为了安全考虑,这个token生成之后只会可见一次,因为后面的步骤会使用,所以我们需要做好保存。 注意这个 token 是用户级别的,它可以用于访问修改该账户名下的任意仓库。 为了让 Github Action 可以访问到这个token,需要给它做一个配置。配置路径是:在该仓库下的 Settings(注意这个是仓库下的设置而非个人下的设置) -> Secrets -> Actions 点击 New repository secret。Name 的话可以命名为 ACCESS_TOKEN,Value 为上一步生成的访问 token。这里配置的任意内容都可以通过Github Action 访问到,且是加密的,创建之后只能看到 Name 看不到 Value。下面是具体配置: env: ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}# Steps represent a sequence of tasks that will be executed as part of the job steps: - name: print inputs run: | echo "Weekly Index: ${{ github.event.inputs.weekly_index }}" - name: Git Config run: | git config --global user.email moyuweekly@github.com git config --global user.name moyuweekly # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v3 # Runs a single command using the runners shell - name: Run a one-line script run: ./Script/ci_run.sh ${{ github.event.inputs.weekly_index }} shell: bashsecrets.ACCESS_TOKEN 表示的是该仓库下面 secrets 里 name 为 ACCESS_TOKEN 的内容。${{}}为 action 语法获取变量的写法。 因为我把对私有仓库的获取和推送都放到了执行脚本里,所以这里通过环境变量的形式把这个值传给脚本。 steps 用于表述运行步骤,这里是顺序执行的。上述流程涉及到4个执行步骤: 1、打印外部传参。${{github.event.inputs.weekly_index}} 表示输入的参数。 2、配置 git user 的 email 和 name。因为执行内容涉及提交操作,这里是为了标记自动化流程的提交。 3、uses 语法对应的是插件功能,这里输入插件名即可执行对应插件配置的功能。 actions/checkout@v3 是官方插件,用于拉取本仓库代码的。 4、执行脚本。我把私有仓库的拉取,内容的格式化,私有仓库推送都放到了这个脚本里。 私有仓库的管理需要考虑 Git 链接的形式,Git 链接有两种方式,一种是给 SSH 形式,这对于本地机器比较容易,对于不固定的打包机配置起来较为麻烦。第二种是 HTTPS 形式,HTTPS 是公有链接无法处理权限问题,但 Github 支持把 token 传入链接的形式来管理权限。这里选择使用HTTPS形式,配置的 Git 地址如下: https://{github_token}@github.com/username/repo.git对仓库的操作使用这个链接就可以解决权限问题了,执行结果如下:左边是 job 描述,右边是 steps 描述,每个 steps 都可以展开查看详情。因为这里的步骤只有代码拉取推送和格式处理,所以执行很快。 Blog Repo 发布网站 这个阶段对应的是 Blog Repo 推送内容到腾讯云服务器。还是按上面的流程设计实现方式: 如何触发任务? 这个历程是上一步的承接,前一步已经定好了推送频率,这里可以接收到 push 即触发任务。 push: branches: [ "master" ]在什么机器触发? 这需要考虑到所使用的博客框架,如果是 Hexo/Jekyll 使用 Ubutu 就可以了。因为我将博客框架迁移到了 Publish,Publish 是一个由 Swift 编写的静态博客框架,所以运行机器只能是 macOS。测试时发现 Publish 引用了libswift_Concurrency.dylib 这个库,所以还需要指定版本为 macos-12。 jobs: build: # macOS Monterey 12 runs-on: macos-12如何执行自动化任务? 执行流程大概是这样的:编译 publish -> 使用 publish 把 md 源文件转成静态网站格式 -> 发布到腾讯云。 正常能获取到 publish 执行文件是无需编译的,但因为我为了让它兼容 hexo 的格式,做了一些魔改,所以我使用的 publish 是一个动态的版本,它需要随修改随时编译。 发布至腾讯云,也需要考虑权限问题,个人服务器没有 Github 那种 token 授权形式,只能借助于 SSH 了。 SSH 在开始之前再简单回顾下 SSH 登录的一点原理。SSH 支持密码和密钥两种登录方式,我们一般为省去繁琐的密码输入,会采用密钥登录的形式。密钥登录使用的是对称加密,一般的做法是登录端生成一对公私钥,把公钥放到服务端,私钥保存在本地。对称加密解决的是信息传输不会被篡改的问题,它无法防止中间人攻击,因为它没有HTTPS 那样的 CA 来验证可信性。SSH 选择的是通过手动验证的方式,关于手动验证不知你是否还还记得这段内容: The authenticity of host 'host (172.168.*.*)' can't be established. RSA key fingerprint is 98:2e:d7:e0:de:9f:ac:67:28:c2:42:2d:37:16:58:4d. Are you sure you want to continue connecting (yes/no)?它就是用来手动验证的,我们需要通过 host ip 验证该链接是来自于可信的服务器还是中间人。确定过一次之后,这个信息会被写到本地的 known_hosts 文件中,之后对于同一 ip 的服务器登录就不会再弹这个验证了。对于自动化流程来说,我们应该将私有服务器的验证信息直接填入known_hosts文件,跳过阻塞式的二次确认。 流程配置 有了以上知识,我们对于密钥的配置流程应该是如下图所示:蓝色的钥匙为 pub_key,红色钥匙为 private_key,带钥匙的文件是 know_hosts。Github Action Runner 的配置流程如果都手动实现比较麻烦,我们可以使用install-ssh-key这个插件快速实现这个功能: - name: Install SSH Key uses: shimataro/ssh-key-action@v2.3.1 with: key: ${{ secrets.SSH_PRIVATE_KEY }} # it's value doesn't matter known_hosts: 'knowen_host_value for ssh-rsa'它要求两个参数,key 为 ssh 的 private key。SSH 私钥可以使用上个章节介绍的 Secrets Actions 进行存储,将其命名为 SSH_PRIVATE_KEY。 known_hosts 是对 Server 端的信任记录,用于免去手动确认的流程。这个内容的获取,有两种方式,你可以查看本地的 known_hosts 文件找到对应的目标服务器的记录,也可以利用 ssh-keyscan 去手动查找。 $ ssh-keyscan -H <host-ip>这个结果会按多种加密算法产生多个结果,我们需要选择类型为 ssh-rsa 的内容,因为Github Action 仅支持这一种加密结果。我们把这一条内容添加 known_hosts 参数即可。当然你也可以选择使用密钥的形式存放。 最重要的步骤已经完成了,下面就可以编译 publish 并发布内容了。 - name: Create Publish run: | git clone https://github.com/zhangferry/Publish.git ./publish cd publish make cd .. - name: Blog Deploy run: | echo "begin deploy" ./publish/.build/release/publish-cli deploy最后执行结果:遗留问题 往腾讯云服务器推送内容时遇到一个腾讯云给我发了告警邮件,说是检测到异地登录,来源IP是美国,危险等级高危。这个登录应该指的是 Github Action Runner 设备的登录,这个目前还没有找到有效的解决办法,因为IP是动态的,无法通过手动加白的形式避免。 另外也可关闭异地登录的报警,在告警设置里关闭异地登录报警选项即可。但这种方式也存在一定的安全风险,因为它不是解决问题而是无视问题。 我暂时没有找到更好的解决方案,如果有人知道更好的处理方式,欢迎告知。

macOS 进化史

macOS 最为 iOS 开发的钦定操作系统,且 iOS 本身就是通过它衍生出来的,所以我们跟它之间会经常打交道。为了验证你对它的熟悉程度,看下能否回答这几个问题:我们在说 MacOS 时通常会带上「X」,将其叫做 MacOS X 或者 OS X,这是为什么呢?「X」 有什么含义? Darwin、XNU、Mach、BSD 分别代表什么,之间又有什么关系? Mac OS 与 Unix 是什么关系? Apple 开源了 Darwin,对应的开源社区为什么发展不起来?本篇文章致力于帮助解答这些问题,也会顺道讲些 Apple 相关的背景小故事。 文章内容主要参考《Mac OS X And iOS Internals》(中译本叫《深入解析Mac OS & iOS》)和 《*OS Internal Volume I -- User Mode》,作者都是 Jonathan Levin。前者完书于 2012年,后者第二版本完书于 2019 年,后者不仅是前者的完善版本,还是一个全新版本,大量图文都进行了重写。随着时间的推进,后者对最新的技术有了更多讨论。MacOS 发展背景 背景 MacOS 的早期版本叫做 Mac OS Classic,它诞生于苹果,拥有伟大的 GUI 却是一个相对粗糙且很不成熟的操作系统。 在这期间苹果创始人乔布斯离开苹果创办了 NeXT,NeXT 公司生产 NeXT 计算机和NeXTstation,它们运行在叫做 NeXTSTEP 的操作系统之上。NeXTSTEP 有这些比较前卫的特性:采用 Mach 微内核 使用 Objective-C 作为开发语言 面向对象思想贯穿整个操作系统 设备驱动开发是一个面向对象的框架,叫做 DriverKitMacOS X 诞生 后来乔布斯回归苹果,也将 NeXTSTEP 带回了苹果。于是自然而然的,Mac OS Classic 和 NeXTSTEP 两个非常小众的操作系统进行了融合。他们一个拥有伟大的 GUI 但设计糟糕,一个设计很棒但 GUI 平淡,融合之后起到了 1+1 大于 2 的效果,诞生了一个流行的多的操作系统,这就是 MacOS X。 MacOS X 此时的几个核心组件:Cocoa、Mach、IOKit、Xcode 的 Interface Builder 都来自于 NeXTSTEP。这个操作系统的内核就是 Darwin(中译为达尔文)。 Darwin 是开源的,以它为核心诞生了 iOS、tvOS、watchOS、BridgeOS(用于 Macbook Touch Bar 的 OS) 等一系列变体操作系统。有一个命令可以查看系统所使用的 Darwin 版本信息:uname。 $ uname -v Darwin Kernel Version 21.2.0: Sun Nov 28 20:28:54 PST 2021; root:xnu-8019.61.5~1/RELEASE_X86_64Darwin 和 Darwin 变体的一系列 OS 版本是同步更新的。它们之间的版本遵循这个关系:Darwinver = 10.(MacOSVer + 4) = (iOSVer + 6) = (TvOSVer + 6) = (WatchOSVer + 13)后来 MacOS 的主版本号从 10 升级为 11,上面 MacOS 的版本对应关系发生了一些变化。到了这里有必要再理一遍 MacOS 的名称变化情况,确实很少有人能准确的称呼它,因为它的命名发生过不少变化。时间段 MacOS 名称 说明创建 ~ 2001 年 MachOS Classic 古典 MacOS2001年 ~ 2011 年 Mach OS X NeXTSTEP 与 MacOS Classic 合并之后的版本2012 年 ~ 2015 年 OS X 这是最后一个以猫科动物命名的 OS 版本,此后开始以加州地标命名2016 年至今 macOS 便于与iOS、tvOS、watchOS 命名统一为了便于混乱,从这开始的下文在讲到 MacOS 统称时均以 macOS 代替。 Darwin 操作系统的演变历史。图片来自(*OS Volume 1)结合上面图片可以再讲一个 iOS 的小故事,iOS1.x 版本最初的代号是 Alpine,这是 i 系列设备的默认 root 密码。但最后发布的版本代号是 Heavenly,因为这个版本的操作系统拥有完整的调试符号、未加密还容易反汇编,很多越狱者都依赖从这个版本中提取的符号和函数调用关系寻找破解灵感,从越狱者角度来看确实如天堂般美好。Darwin 的内部组成 Darwin 是一个类 UNIX 的操作系统核心,它的组成可以近似看做:Darwin = kernel + XNU + 运行时。macOS 从Leopard(10.5) 开始已经是一个经过认证的 UNIX 实现。 XNU 是一个占据关键作用的 Darwin 核心,XNU = Mach + BSD + libkern + I/OKit。 初版的 XNU 是 NeXTSTEP 的核心,它包括 Mach 2.5 版本和 4.3 版本的 BSD。NeXTSTEP 合入苹果后,Mach被升级为 3.0,BSD 升级为 FreeBSD。 Mach 和 BSD 一个是微内核(Microkernel)一个是宏内核(Monolithic Kernel),所以 XNU 是一个混合架构(Hybrid kernel)。理解这几种内核的关键是需要注意内核模式和用户模式占据的范围。Mach Mach(微内核)由卡耐基梅隆大学开发,它的目标是取代 BSD 的 UNIX 核心。这个微内核仅能处理最基本的操作系统职责:进程和线程抽象 虚拟内存管理 任务调度 进程间通信和消息传递机制由上图可以看出微内核的功能本来就少,其他 OS 功能是作为基础服务建设在用户模式下的。因为这个特性其内部任务的调用会有更频繁的内核态/用户态上下文切换,这会额外消耗时间。同时内核与服务进程之间的消息传递也会降低运行效率,所以这种设计通常会降低性能。 但它也有优点,就是服务进程容易扩展,服务进程出问题不会危及到 kernel 。得益于这种扩展性 MachO 能支持多架构文件,以此为基础 macOS 能顺利的从 PowerPC 过渡到 Intel 再到 M1。 BSD BSD(宏内核),它是 Berkeley Software Distribution (伯克利软件包)的缩写,这是一个派生自 Unix 的操作系统。BSD 是作为完善 Mach 的一个存在,它建立在 Mach 之上,并提供了一层更可靠更现代的 API。它主要包括这些:UNIX 进程模型 POSIX 线程模型 UNIX 用户和组 网络协议栈(BSD Socket API)宏内核的特点是用户服务和内核服务都运行在同一内存空间,这还有效降低了内核态/用户态之间的频繁切换,执行效率会更高。但是宏内核也并非没有缺点,就是扩展性较差,另外如果内核有一个服务崩溃,整个操作系统就会崩溃。 Darwin 架构 既然没有完美的内核模式,于是苹果就将两者混合,它同时兼顾微内核和宏内核各自的优点,这就是 Darwin了。图片来自(Mac OS X And iOS Internals) 这里没有表示出 XNU,它的边界可以看做是 Kernel/User Transition 这里,其下包括 BSD 和 Mach 的层级就是 XNU。在 macOS 的体系里,Darwin 之上的层次基本都是不开源的,他们是 Apple 的私有财产。 XNU 中还有另外两种重要组件:libkern:这是一个内建的 C++ 库,用于支持 C++ 运行时。有了它内核的很多高级功能都可以使用 C++ 编写。 I/OKit:这是一个设备驱动框架,凭借 libkern 提供的底层支持,驱动程序可以使用 C++ 实现。借助于 C++ 的面向对象特性,外部在创建驱动程序时会节省很多成本。Darwin 的开源之路 既然 Darwin 开源了,那为什么没有出现非 Apple 系的 Darwin 发行版操作系统呢?虽说开源,但 XNU 主要依赖的 Mach 和 BSD 本来就是开源的;Apple 还把对 ARMv7/8 的支持单独闭源;原本来源的 launchd,在 Mac OS 10.10 的版本之后也变成闭源项目合入到 libxpc 项目里了。这还不算,Darwin 的开源版本并没有剥离干净,里面还包含了一些 Apple 的私有 API,导致其并不能完整编译,还需要做一些额外改造。 围绕 Darwin 有两个重要的开源版本,OpenDarwin 和 PureDarwin ,可以看下他们当前的发展状况。 OpenDarwin 它由 Apple 牵头于 2002 年 4 月成立,其目标是加强苹果开发人员与自由软件社区之间的协作且将 Darwin 发展出另一独立版本。理想情况是苹果可以将 OpenDarwin 中的改进应用到 Darwin 中,而开源设计又可以完全控制该系统,将其用于 GNU-Darwin 等自由软件的发行版中。然而仅仅过了 4 年,OpenDarwin 就宣布关闭。以下是 OpenDarwin 项目组的陈述:Over the past few years, OpenDarwin has become a mere hosting facility for Mac OS X related projects. The original notions of developing the Mac OS X and Darwin sources has not panned out. Availability of sources, interaction with Apple representatives, difficulty building and tracking sources, and a lack of interest from the community have all contributed to this. Administering a system to host other people's projects is not what the remaining OpenDarwin contributors had signed up for and have been doing this thankless task far longer than they expected. It is time for OpenDarwin to go dark.主要因素有两个:苹果的 macOS X 对 OpenDarwin 掌控过强,未推进 OpenDarwin 的独立发展 开源社区的兴趣减淡。也可以说是前者导致了后者现在 OpenDarwin 的官网 http://opendarwin.org/ 仅剩一行字:Opendarwin memorial page。 PureDarwin PureDarwin 通常被认为是 OpenDarwin 的继承者。它的代码托管在 Github上,且仍在维护。Pure 的含义是更纯净,PureDarwin 仅使用苹果为 Darwin 发布的组件而不用 macOS 的其他组件。它的目标是通过提供文档,使开放源码爱好者和开发人员能够检索、理解、修改、构建和分发 Darwin,从而使Darwin 更易于使用。 因为缺少官方的支持,它当前在 Github 上的 star 数仅有 1.7k,由此可见它的关注度和发展都不算太好。但是当有人问为什么要花费时间维护 PureDarwin 时,他们的答案是:For learning and for fun.简短却让人振奋,与此同时还有那么一丝丝凄凉。 所以回归上面的问题为什么开源的 Darwin 没有发展起来,因为它是为 macOS 创建的操作系统,它依赖于 macOS 的特性,也依赖于 Apple 的支持,脱离这两者尝试走「 Pure」Darwin 开源路线是非常困难的。 Hackintosh OpenDarwin 和 PureDarwin 的发展仍带来了一些有益的事情,其基于开源的 Darwin 制作成一个可以完整引导并且安装的 ISO 镜像。之后 OSX86(一个致力于把苹果电脑出品的 macOS 操作系统移植到非苹果电脑上的计划)项目在此基础上继续发扬光大,努力将 macOS 完整移植到 PC、笔记本等设备,该行为被称为 Hackintosh,也叫黑苹果。 通常的黑苹果方案是借助于引导程序实现的,因为它不会修改 macOS 源文件,被称为最佳的合法途径。苹果曾经开源过 Boot-132,一个用于加载 XNU 内核的引导程序。Voodoo 团队基于该程序开发出 Chameleon(变色龙)引导程序,再后来 Clover 出现,可以让不支持 EFI 的电脑进入模拟的 EFI 环境。现在又有了 OpenCore,它在配置文件时比较复杂,但因其受到较多 kexts 作者的兼容和本身的易用性而得到相当数目使用者的追捧。 关于合规的问题,虽然引导的方式没有修改 macOS 的源码,但苹果的最终用户许可证协议(EULA)里并不允许将 macOS 安装在一台没有苹果商标的硬体上。苹果曾起诉并多起黑苹果相关的商业行为并获得胜诉,对于非盈利的个人 Hackintosh 行为,苹果并没有过多理睬。 吉祥物的故事 人们热衷于为受欢迎的操作系统或者框架设置吉祥物,Linux 的吉祥物是一只企鹅(名叫 Tux,Torvalds UniX的缩写)、安卓的吉祥物是一只绿色小机器人(无正式名称,被开发人员称为 Bugdroid)。再看下跟 macOS 相关的两个操作系统的吉祥物。 BSD BSD 的吉祥物是一只小恶魔😈,叫做 Beastie,它的发音跟 BSD 很像。它通常带支三叉戟,代表行程的分岔。Darwin Darwin 的吉祥物是 Hexley,它是一个卡通的鸭嘴兽,戴着 BSD 小恶魔的帽子,也拿着三叉戟。Hexley 是由 Jon Hooper 所设计的,版权也为他所有。但 Hexley 并不附属于 苹果电脑。本来这个吉祥物的名称应该是 Huxley,源由是捍卫达尔文(Darwin)进化理论的英国生物学家 Thomas Henry Huxley,而原先提议的人误以为是达尔文的助理,并错用了 Hexley。而发现错误时,要改名已经太晚,因此沿用了 Hexley 这个名称。 这个形象并不属于 Apple,而属于开源社区,所以开源版本的 Darwin 均有该图案的展示。「*OS Internal 三部曲」的书籍封面就是用的 Hexley 形象。 未来展望 对于 macOS 未来的展望这部分内容摘自《Mac OS X And iOS Internals》(注意其完成时间是 2012 年),站在 10 年后的今天我们可以再去看下这几个预测的实现情况。 根除 Mach 内核中的 Mach API 是 NeXTSTEP 时代的产物,运行速度慢,执行效率上很难赶得上 BSD。而且 XNU 本身更趋向于宏内核架构,如果移除 Mach 将内核建设为完整的 BSD,将会有很大收益,但这确实需要巨大的工作量。 该想法并没有达成,且 Apple 根本没有这么做的打算,混合内核并没有看上去那么遭,Mach 将在很长一段时间继续存在着。 兼容 ELF 格式 macOS 无法融入 UN*X 的世界最大的一个困难就是坚持使用 Mach-O 二进制格式。当然这个想法也需要依赖上一步的根除 Mach,这样 Linux、BSD 中的程序就可以不经修改直接迁移到 macOS 上了。 这是很美好的想象,考虑 Apple 对 Hackintosh 的打压,其商业策略是独占、完全掌控而非扩大市场占有率。 使用 ZFS macOS 早期使用的文件系统是 HFS+,但该文件系统遭受过大量批评 Linus 曾这样评价 HFS+:Quite frankly, HFS+ is probably the worst filesystem ever. Christ what shit it is.HFS+ 确实有很多不完善的地方,它大小写不敏感、不支持对数据内容进行 checksum 校验、timestamp 只支持到秒级。彼时 Sun 公司开发出 ZFS,号称是「宇宙无敌最强」文件系统,有传闻 Apple 将使用这一文件系统,但后来 Sun 被 Oracle 收购,这一想法最终无法实现。 2017 年, 伴随着 Mac OS High Sierra 版本,Apple 正式发布了 Apple File System(APFS)。该文件系统是 Apple 从零开发的,耗时三年,对于完整的文件系统来说,这个效率已经非常高了。其支持更多的功能,且宣称针对 SSD 做了很多优化。但让人遗憾的是 APFS 在多个方面的性能还没有超过 HFS+。 和 iOS 合并 在 macOS 和 iOS 的发展过程中,有不少功能都是在一个平台成熟之后移植到另一个平台上的。即使在 M1 芯片出现之前,这一想法也是有可能的,Apple 在很早之前就实现过硬件架构翻译机制 -- Rosetta。 当 M1 芯片发布之后,macOS 和 iOS 都已经可以运行在 arm64 架构的芯片上了,这个想法似乎顺其自然要实现了。但现实并非如此,相对于统一,Apple 更想要的是各司其职,每个产品线 macOS、iPadOS、iOS 都有其各自适用的场景,Apple 也极力往这方面宣传,这有助于产品的销售。 总结 再次列出开头提到的几个问题便于大家回顾这篇文章的内容: 1、我们在说 MacOS 时通常会带上「X」,将其叫做 MacOS X 或者 OS X,这是为什么呢?「X」 有什么含义? 2、Darwin、XNU、Mach、BSD 分别代表什么,之间又有什么关系? 3、Mac OS 与 Unix 是什么关系? 4、Apple 开源了 Darwin,对应的开源社区为什么发展不起来?