程序员的自我修养:链接、装载与库.pdf
http://www.100md.com
2020年1月13日
![]() |
| 第1页 |
![]() |
| 第6页 |
![]() |
| 第12页 |
![]() |
| 第28页 |
![]() |
| 第41页 |
![]() |
| 第323页 |
参见附件(12415KB,637页)。
程序员的自我修养,这是一本关于编程相关的学习教材,全书一共分为了13个章节,每个章节详细的介绍了编程的技术原理等,用户可以在这里学到基础的编程知识。

程序员的自我修养内容提要
本书主要介绍系统软件的运行机制和原理,涉及在Windows和Linux两个系统平台上,一个应用程序在编译、链接和运行时刻所发生的各种事项,包括:代码指令是如何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,C/C++运行库的工作原理,以及操作系统提供的系统服务是如何被调用的。每个技术专题都配备了大量图、表和代码实例,力求将复杂的机制以简洁的形式表达出来。本书后还提供了一个小巧且跨平台的C/C++运行库MiniCRT,综合展示了与运行库相关的各种技术。
本书对装载、链接和库进行了深入浅出的剖析,并且辅以大量的例子和图表,可以作为计算机软件专业和其他相关专业大学本科高年级学生深入学统软件的参考书。同时,还可作为各行业从事软件开发的工程师、研究人员以及其他对系统软件实现机制和技术感兴趣者的自学教材。
程序员的自我修养特点
每个技术专题都配备大量图、表和代码实例,力求将复杂的机制以简洁的形式表达出来。
提供了一个小巧且跨平台的C/C++运行库MiniCRT,综合展示了与运行库相关的各种技术。
能将程序的编译和运行过程所涉及的各种技术全面地串连起来介绍的,至今尚未有先例。
覆盖Windows和Linux两个平台,详述应用程序在编译、链接和运行时所发生的各种事项。
学习研究他人的代码是枯燥而耗时的,先行者的经验能避免重复劳动,直指经验和关键。
学会编程“易筋经”,可以开发操作系统、编译器,甚至是开发一种新的程序设计语言。
由一个很小很简单的问题或示例入手,层层剥开、深入挖掘,探究每个机制“怎么做”。
务必理解“为什么这样做”,力求深入浅出、图文并茂,尽力把每一步细节呈现给读者。
程序员的自我修养主目录
第1章 温故而知新
第2章 编译和链接
第3章 目标文件里有什么
第4章 静态链接
第5章 WINDOWS PE/COFF
第6章 可执行文件的装载与进程
第7章 动态链接
第8章 LINUX 共享库的组织
第9章 WINDOWS 下的动态链接
第10章 内存
第11章 运行库
第12章 系统调用与API
第13章 运行库实现
程序员的自我修养:链接、装载与库截图


程序员的自我修养
链接、装载与库
作者 俞甲子 石凡 潘爱民
类别 图书 非虚构
出版社 电子工业出版社
出版日
期
2009.4
ISBN 7121085119
提供方 电子工业出版社
标签
计算机编程程序员程序设计系统结构双十一专题计算机
与互联网
这本书主要介绍系统软件的运行机制和原理,涉及在Windows和Linux
两个系统平台上,一个应用程序在编译、链接和运行时刻所发生的各种
事项,包括:代码指令是如何保存的,库文件如何与应用程序代码静态
链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,CC++运行库的工作原理,以及操作系统提供的系统服务是如何被调用
的。每个技术专题都配备了大量图、表和代码实例,力求将复杂的机制
以简洁的形式表达出来。本书最后还提供了一个小巧且跨平台的
CC++运行库MiniCRT,综合展示了与运行库相关的各种技术。
对装载、链接和库进行了深入浅出的剖析,并且辅以大量的例子和图
表,可以作为计算机软件专业和其他相关专业大学本科高年级学生深入
学习系统软件的参考书。同时,还可作为各行业从事软件开发的工程
师、研究人员以及其他对系统软件实现机制和技术感兴趣者的自学教
材。
作者:俞甲子、石凡、潘爱民
作者访谈录
针对俞甲子、石凡和潘爱民三位的新书《程序员的自我修养——链接、装载与库》的出版,博文视点对俞甲子进行了专访,现将博文的编辑与
俞甲子的访谈对话整理成文,以飨读者。
博文编辑:
甲子,你好!能否向读者介绍你是如何对操作系统的底层机制和运行原
理产生兴趣的?
俞甲子:
很大程度上是因为性格决定的吧,因为我是一个喜欢对技术问题寻根究
底的人,不满足于仅仅了解一个技术的表面,而是希望能通过层层深入
地挖掘,找出它背后最关键最核心的机理。我相信很多计算机技术都是
相通的,它们的核心思想相对是稳定不变的。经常听很多人谈起,IT技
术日新月异,其实真正核心的东西数十年都没怎么变化,变化的仅仅是
它们外在的表现,大体也是换汤不换药吧。
为了了解操作系统内核及装载、链接等这些关键的技术,我曾经自己从
头写了一个很小的内核、装载器及一个简单的运行库,它们组成了一个
可以完整运行在PC上的支持多进程、多线程的操作系统环境,并且支
持虚拟存储、简单的文件系统、网络、鼠标键盘等,前后加起来花了两
年多时间,大约有数万行代码,编译器和链接器使用的是GCC和LD。
当然,如果继续写下去,可以让它的功能变得更加完整,但是我停止了
对它的继续维护,因为我认为通过这个雏形系统,我已经了解了其背后
的机理,如果再继续写下去更多的只是重复性的工作,因为现在已经有
了很多很优秀的内核、装载和链接的相关软件和标准。
虽然我在这个系统上花费了很多时间和精力,却没有获得什么直接的收
益,也没有让我跟上最新的技术潮流,但是它带给我的间接收获却是无
法言表的,它使我在后来学习其他技术的时候能够很快地触类旁通、自
下而上地去理解整个系统,往往能够理解得更加深刻更加透彻。
博文编辑:
介绍链接、装载与库原理的资料非常少,你在自己钻研的过程中,遇到
的最大困难是什么?
俞甲子:当然相关资料很少会给我们带来很多的困难和挑战,而且相关的源代码
在经过多年的发展和锤炼后,变得非常注重性能和效率,而很少考虑可
读性,这使得通过挖掘源代码理解机制变得更为困难。这些代码很多都
是相关领域的黑客高手写的,他们对系统机制的了解已经到了很深刻的
地步,一小段代码会用尽系统的各种机制和方法,经常让人看得不知所
云。比如系统库在不同的链接和装载方式下对C++全局对象的构造和析
构,就异常复杂。整个流程来回曲折,加上有些代码已经遗弃,还会造
成误解。Glibc这种支持数十种平台的系统还要考虑到各个系统的通性
和个性,更使整个过程雪上加霜。其实理解还不是最大的困难,最大的
困难是理解了这个复杂而又晦涩的机制和过程,如何将它们尽量地简
化,从中取舍,摈弃所有不必要的内容,再将它剥离出来后组织成尽量
深入浅出层层引导的文字和图表,这才是最大的挑战。
博文编辑:
在自学的过程中,一定有许多令你得意或开心的事,可不可以分享一
二?
俞甲子:
在这个过程中,最烦恼的事莫过于一个困扰了你很久的问题,通过各种
办法,包括阅读源代码等还是无法理解或无法解释某个程序现象。忽然
有一天某个灵感突现,回头再仔细阅读代码,紧接着马上试验一下,果
真如此!大有拨云见日、豁然开朗的感觉,这应该是最开心的事吧。
博文编辑:
你现在从事的工作和系统底层结合紧密吗?在系统运行机制上的积累对
目前的工作有帮助吗?
俞甲子:
我目前从事的工作跟系统底层关系不是很大,现在最常用的都是Web前
端、MySQL数据库等这些应用层面的系统。虽然不是直接与系统底层
打交道,但是之前的积累无时无刻不在帮助我去深入理解应用开发。比
如MySQL系统的内存和文件系统的优化,如果对操作系统的虚拟存储
和文件系统机制没有深入了解,那么可能只能在配置参数上做一些“猜
测”性质的调整,不断地尝试各种参数,或者参考网络上别人提供的配置参数,但不一定适合自己的应用情况。了解虚存如何运作,进程地址
空间的分布等,将会对应用的优化甚至是构架设计上都会有更高层次的
俯视。
博文编辑:
对知识的渴求,对未知世界的好奇是人类的天性。但这种天性也需要引
导,小心保护,否则就可能会丧失。读书是一种很好的保护途径,可不
可以向读者推荐几本对你个人成长影响最大的书?
俞甲子:
如果是推荐非技术类的书籍,我应该不是很在行。在这里向大家推荐几
本我读过的,并且跟本书主题相关的书籍吧。
《Linkers and Loaders 》,John R. Levine。这本书基本上是链接和装载
方面最为完整和权威的理论著作了,但是内容有些偏旧,并且有些晦
涩。
《Intel? 64 and IA-32 Architectures Software Developer’s Manuals》,Intel
官方的x64和x86CPU的技术手册,总共分3卷,另外还有几本优化手
册,这些手册不适合通读,但强烈建议阅读其中的介绍性章节,并且手
边能够常备一份,以便需要时查阅,查阅网址
http:www.intel.comproductsprocessormanuals。
《Linux内核源代码情景分析》,毛德操,胡希明。这部书分为上下两
卷,总共近2000页,虽然出版年份较早(2001年出版),而且是基于
Linux 2.4内核的,但是它对很多细节的描述非常到位,比很多Linux内
核的书籍要详细,值得一看。
《深入理解计算机系统》(Computer Systems A Programmer’s
Perspective,Randal E. Bryant和David O’Hallaron著)。这本书对整个计
算机软硬件体系结构进行了深入浅出的介绍,是理解系统底层不可多得
的好书,强烈推荐!
《深入解析Windows操作系统,第4版——Microsoft Windows Server
2003Windows XPWindows 2000技术内幕》,Mark
E.Russinovich(著),潘爱民(译)。这本书是理解Windows内核最好的选择,至少我没有看到任何一本描述关于Windows内核的书能与它相
媲美。
《Advanced Programming in the UNIX Environment,Second Edition》,W.Richard Stevens,Stephen A.Rago。这本书被誉为UNIX程序设计
的“圣经”,也是了解NIX系统内核,运行库和执行环境的很好选择。
序言一
两年前,甲子跟我提起,他在考虑写一本讲述计算机程序基本工作原理
的书,由于代码背后的许多细节现在难以找到完整而又实用的资料,因
此,系统性地讲述这些技术要素一定非常有意义。这是我非常感兴趣的
话题,因为最近几年来,我每次给学生讲课或作技术报告时,经常会提
到程序背后的一些细节知识,而当有人请我推荐一些参考资料时,我很
难想得出有什么恰当的参考书可供学习。我自己也曾想过要写一点这方
面的书,只是一直下不了决心做这件事情。甲子的提议让我意识到,写
这样一本书的机会来了。于是,我们认真规划了书的选题。按我的建
议,这应该是三卷本的书,每卷独立,合起来成一体系。第一卷是基础
篇,介绍程序的基本运行过程,即是您现在看到的这本书。其他两卷还
需要时日和机缘。
在过去两年中,我曾经以“Inside Windows Programs”为题在多所高校作
过报告,旨在介绍Windows程序背后的一些支撑技术。对于正在学习计
算机或软件专业的学生,或者正在从事软件开发的工程师们,我认为理
解这些支撑技术是很有必要的。试想,即使一个简单的“Hello
World!”程序,也依赖于背后的输入输出库(或流库)及系统提供的模
块,这种依赖性已经成为现代软件在操作系统环境下运行的一个必要条
件。然而,有关这些支撑技术的系统性资料却少而又少,虽然Internet上
并不缺乏任何一方面的细节信息,但是,能将程序的编译和运行过程所
涉及的各种技术全面地串连起来介绍的,却尚未有先例。
甲子曾经在2006年夏天跟我实习过两个月,他帮我搭建了一个在
Windows已有体系结构下将交换空间重定向到远程机器物理内存的原型
系统。完成这一系统并非易事,而且甲子事前并无Windows内核编程经
验,但是,他凭借扎实的计算机系统软件功底,成功地打通了从页面错
误(page fault)异常例程到远程机器内存管理器之间的数据通路。在这
一段实习经历中,我不仅看到了他驾驭代码和系统的能力,也感受到他做事认真负责的态度。因此,当他提出要写一本介绍程序基础的书时,我认为他是非常合适的人选。考虑到写书的艰巨性,他推荐石凡同学加
入进来,这才有了我们三个人的组合。我原先担心写作的进度,毕竟写
这样一本书需要大量的时间投入。幸运的是,在甲子和石凡的不懈努力
下,这本书终于面市了。
本书讲解的内容,涉及在Windows和Linux两个系统平台上,一个应用
程序在编译、链接和运行时刻所发生的各种事项,包括:代码指令是如
何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载
到内存中并开始运行,动态链接如何实现,CC++运行库如何工作,以
及操作系统提供的系统服务是如何被调用的。每个技术专题都配备了大
量图示和代码实例,力求将复杂的机制以简洁的形式表达出来。本书最
后还提供了一个小巧且跨平台的CC++运行库MiniCRT,综合展示了与
运行库相关的各种技术。
关于写作这本书的功劳,我不敢掠美。在创作之初,包括拟定提纲及甄
选内容方面,我跟甲子有过认真而细致的讨论;在写作过程中,我对甲
子和石凡的初稿提出过一些建议,尤其在表述方面,同时我也协助他们
与编辑进行了沟通和交流。对于正文的内容,我并无实质性的贡献,但
基于我对甲子和石凡两位年轻人的了解,我相信他们自身的技术实践功
底,以及足够的技术阐释能力。我期待这本书能够真正地提升程序员的
自我修养,让程序员总是生活在“知其然,更知其所以然”的代码曼妙
中。
最后,我要感谢这本书的四位编辑,他们是何艳、方舟、刘铁锋和陈元
玉,谢谢他们为这本书付出的努力。还要感谢博文视点团队的负责人周
筠女士,谢谢她给予两位年轻作者的扶持和关爱。
潘爱民
2009年2月于北京微软
序言二
两年前,我在浙江大学的一著名BBS的C++板块上担任版主,而俞甲子
则是板上的资深版友(以及前版主)。那时候我对链接装载、运行库等
内容比较感兴趣,自己摸索着在博客上写了一篇关于链接的入门文章,而这就是一切的开始。
我猜想俞甲子可能对写这么一本书早有想法,看到我的文章正好找到了
同路人。他找到了我和潘爱民老师,我们一拍即合,就开始了这长达两
年的写作历程。考虑到当时俞甲子已经在链接部分有了相当的积累,因
此我不得不放弃最有兴趣的一部分转而在运行环境上做文章。我把glibc
和msvcrt的源代码翻了个底朝天,了解到了许多平时不可能接触到的内
幕和技术细节。事实上,这基本是一个现学现卖的过程,我一边学习着
新的知识,一边把新知识组织整理写成文字。读者在看某些章节的时
候,会发现这些章节的讲解过程就是一个源代码的挖掘过程,这实际上
也就是我的学习过程。学习研究他人的代码是枯燥而耗时的,我很高兴
能够做这样一个先行者,将我的经验写进书里,让读者能够避免重复劳
动,直接获得其中的经验和关键技术。
本书所讲的内容不是活跃在当今IT舞台上的高新技术,也不是雄踞计算
机某个领域的王牌霸主,而是默默服务于所有计算机应用的扫地僧。也
许阅读本书不能够直接在平时学习工作中的生产力上得到体现,但了解
计算机的台前幕后会对读者产生潜移默化的影响。当你的程序无法启动
的时候,你可能会在脑海里多设想一种可能性;当你的代码链接失败的
时候,你可能会更快地意识到问题的所在;当你的程序发生非法操作的
时候,你可能不至于面对微软的错误报告毫无头绪。有人总爱用“时效
性”评价当今的IT技术。仿佛一项技术的生存期就只有几年。我不能说
这样的想法是错误的,如今的技术的确在飞速地更替和发展。但是本书
所讲的技术,大多是成型在十年前,乃至二十年前,它们是整个计算机
行业技术的根本,也几乎是现在所有计算机应用的基础。在当今的计算
机技术发生根本性变革之前,这些技术还将继续存在并保持活力。
我很荣幸能够有机会和读者分享这些技术,但写作水平有限(我在语文
课上历来不是个好学生),最终在文字和结构上颇有缺憾,只能在这里
说一声抱歉。在这里要感谢我小学、初中和高中的语文老师,谢谢你们
当初对我的教导,尽管最终可能辜负了你们的希望。感谢潘老师、博文
视点的编辑及所有支持我们的朋友们,谢谢你们的帮助。最后要感谢我
的父母,没有你们,我永远不可能走到今天这一步。
石凡
2009年2月于杭州序言三
CPU体系结构、汇编、C语言(包括C++)和操作系统,永远都是编程
大师们的护身法宝,就如同少林寺的《易筋经》,是最为上乘的武功;
学会了《易筋经》,你将无所不能,任你创造武功;学会了编程“易筋
经”,大师们可以任意开发操作系统、编译器,甚至是开发一种新的程
序设计语言!
——佚名
念书的时候,作为标准的爱好技术的宅男,每天扫一遍各大高校BBS的
技术版面,基本好比一日三餐一样平常。我对计算机技术方面的口味很
杂,从汇编版到C++到Linux内核开发、Linux应用开发、游戏开发、网
络、编程语言、体系结构、移动开发、开源闭源我都会参上一脚。
我始终认为技术优劣取决于需求,与很多持有“编程语言血统论”的程序
开发者不同,我不认为C++或Java本身有什么直接可比性,或者OOP与
函数式编程谁优谁劣,我始终坚持认为作为开发者,MOP(MarketMoney Oriented Programming)才是唯一不变的编程范
式。于是我往往不参与那些技术、平台、语言教派之间的宗教战争,这
种论战基本上每周都会有,我很佩服论战各方见多识广、旁征博引、高
屋建瓴的论断,但我往往只是灌灌水调节一下思绪。相反,我很关注一
些与语言、平台等相对独立的基本的系统概念方面的问题,这些问题比
较具体,也比较实用,比如:
为什么程序是从main开始执行?
“malloc分配的空间是连续的吗?”
“PEELF文件里面存的是什么?”
“我想写一个不需要操作系统可以直接在硬件上跑的程序该怎么做?”
“目标文件是什么?链接又是什么?”
“为什么这段程序链接时报错?”
“句柄到底是什么东西?”这些问题看似很简单但实际上有很多值得深入挖掘的地方,比如第一个
问题围绕着main函数执行前后可以延伸出一大堆问题:程序入口、运行
库初始化、全局静态对象构造析构、静态和动态链接时程序的初始化
和装载等。我们把这些问题归结起来,发现主要是三个很大的而且连贯
的主题,那就是“链接、装载与库”。
事实上,现在市面上和网络上能找到的计算机技术方面的书籍和资料
中,什么都很齐全,唯独关于这三个主题的讨论十分稀缺,即使能找到
一些也是犹如残缺的典籍,不仅不完整而且很多已经过时了。关于现在
通用的Windows和Linux平台的链接、装载及PEELF文件的详细分析,实在很少见。这个领域中,最为完整、也最为权威的莫过于John R.
Levine的《Linkers Loaders》,这本书我也前前后后通读了好几遍,虽然它对链接和装载方面的描述较为完整,但是过于理论化,对于实际
的系统机制描述则过于简略。
我始终认为对于一个问题比较好的描述方式,是由一个很小很简单的问
题或示例入手,层层剥开深入挖掘,不仅探究每个机制“怎么做”,而且
要理解它们“为什么这样做”,力求深入浅出、图文并茂,尽力把每一步
细节都呈现给读者。这是我一贯的想法,也是我们在本书中努力试图达
到的效果。
第一次有想写这样一本书的念头是在2006年底,当时我正在念研一,想
起未来还有一年多漫长而又相对空闲的研究生生涯,觉得写一本这样的
书大概是比较好的“消遣活动”。于是我第一时间想到了在微软研究院实
习时的导师潘爱民老师,潘老师在写作技术书籍方面有很深的功底和丰
富的经验。我把想法告诉潘老师以后,他十分支持,于是我又找到了当
时刚好保送研究生、时间上也相对充裕的石凡,我们三个都对这个选题
十分感兴趣,可谓一拍即合。
当时也没多想,以为写书大概也就跟BBS发帖连载差不多吧。一旦写起
来才发现自己完全轻视了写书的工作量。书中的每一个章节、每一个小
段、每一个例子甚至每一个用词有时候都要斟酌很久,生怕用得不恰当
误导了读者。“误人子弟”这四个字罪名可不轻,大有推出午门斩首五遍
以儆效尤之过。写书的时间的确很仓促,虽然我们都是在读研时写的,按理说相对于已经工作的作者来讲,已经是有很多闲余的时间了,但还
是经常手忙脚乱。想到以前看书看到作者写的序里,经常使用“时间仓
促,水平有限”的话,推想作者不过是出于谦虚不免要客套一下。现在
轮到自己写序了,终于感觉到了这八个字的分量。即使到现在已近完稿,我们还是心里十分忐忑,因为还有不少地方的确写得不够完善。也
听到了很多第一批读者的反馈意见,很多建议都正中这本书的软肋,我
们也根据大家的意见又一次进行了修改,这已经是反反复复的第N次修
订了。
这本书前前后后花了两年多的时间一直没有完稿,由于截稿时间快到
了,我们才终于定稿,因为实在没有办法做到完美,只能向无限接近完
美努力。最后,我们在“著”和“编著”之间犹豫了很久,想到本书凝聚了
我们很多的心血,还是诚惶诚恐地写上了“著”字,权当给自己壮胆了。
我们也相信,本书虽然没做到完美,但是它一定会给你带来一些你以前
想看、想了解而又找不到的东西。或者以前在编程过程中困惑了你很
久,但始终没有找到解释的问题,当在本书中终于找到答案且大呼“原
来如此!”时,我们也就很欣慰了!
关于本书的书名笔者们也讨论了很久,征询过很多意见,最终还是决定
用“程序员的自我修养”作为书名,将“链接、装载与库”作为副标题。书
名源自于俄罗斯的演员斯坦尼斯拉夫斯基创作的《演员的自我修养》,作者为了写这本书前前后后修改了三十年之久,临终前才同意不再修
改,拿去出版。使用这个书名一方面是本书的内容的确不是介绍一门新
的编程语言或展示一些实用的编程技术,而是介绍程序运行背后的机制
和由来,可以看作是程序员的一种“修养”;另一方面是向斯坦尼斯拉夫
斯基致敬,向他对作品精益求精的精神致敬。
在本书的创作过程中,很多人对我们的支持和帮助难以言表。这里我要
感谢博文视点的编辑何艳、方舟、刘铁锋和陈元玉等,他们为本书付出
了很多心血;特别要感谢博文视点的周筠老师,这本书能够面世离不开
她的支持和努力。另外也要感谢浙江大学的张晓龙博士,他为本书提出
了很多建议,并且贡献了“DLL HELL”一节。
俞甲子
2009年2月于杭州
导读
你将学到什么本书将详细描述现在流行的Windows和Linux操作系统下各自的可执行
文件、目标文件格式;普通CC++程序代码如何被编译成目标文件及程
序在目标文件中如何存储;目标文件如何被链接器链接到一起,并且形
成可执行文件;目标文件在链接时符号处理、重定位和地址分配如何进
行;可执行文件如何被装载并且执行;可执行文件与进程的虚拟空间之
间如何映射;什么是动态链接,为什么要进行动态链接;Windows和
Linux如何进行动态链接及动态链接时的相关问题;什么是堆,什么是
栈;函数调用惯例;运行库,Glibc和MSVC CRT的实现分析;系统调
用与API;最后我们自己还实现了一个Mini CRT。
应当具备的基础知识
在本书中,我们尽量避免要求读者有很多的基础知识,但难免有些要
求。其中包括对CC++编程语言的基本了解、x86汇编语言基础、操作
系统基本概念及基本编程技巧和计算机系统结构的基本概念。
本书的组织
本书分为4大部分,分别如下。
第1部分 简介
第1章 温故而知新
介绍基本的背景知识,包括硬件、操作系统、线程等。
第2部分 静态链接
第2章 编译和链接
介绍编译和链接的基本概念和步骤。
第3章 目标文件里有什么
介绍COFF目标文件格式和源代码编译后如何在目标文件中存储。
第4章 静态链接
介绍静态链接与静态库链接的过程和步骤。第5章 Windows PECOFF
介绍Windows平台的目标文件和可执行文件格式。
第3部分 装载与动态链接
第6章 可执行文件的装载与进程
介绍进程的概念、进程地址空间的分布和可执行文件映射装载过程。
第7章 动态链接
以Linux下的.so共享库为基础详细分析了动态链接的过程。
第8章 Linux共享库的组织
介绍Linux下共享库文件的分布和组织。
第9章 Windows下的动态链接
介绍Windows系统下DLL动态链接机制。
第4部分 库与运行库
第10章 内存
主要介绍堆与栈,堆的分配算法,函数调用栈分布。
第11章 运行库
主要介绍运行库的概念、CC++运行库、Glibc和MSVC CRT、运行库如
何实现C++全局构造和析构及以fread库函数为例对运行库进行剖析。
第12章 系统调用与API
主要介绍Linux和Windows的系统调用及Windows 的API。
第13章 运行库实现
本章主要实现了一个支持堆、基本文件操作、格式化字符串、基本输入输出、C++ newdelete、C++ string、C++全局构造和析构的Mini CRT。
编译本书的程序
编译本书中所有的示例代码,在Windows平台下可使用Microsoft Visual
C++ 2005或2008,操作系统为Windows XP sp3。读者可以去微软的官方
网站Visual C++ 2008 Express版:
编译本书中所有的示例代码,在Windows平台下可使用Microsoft Visual
C++ 2005或2008,操作系统为Windows XP sp3。读者可以去微软的官方
网站Visual C++ 2008 Express版:
http:www.microsoft.comexpressvc
Linux下使用的GCC 4.1.2,ld版本为2.18,Glibc和ld-linux.so的版本为
2.6.1,操作系统为Ubuntu 7.04。
联系博文视点
您可以通过如下方式与本书的出版方取得联系。
读者信箱:reader@broadview.com.cn
投稿信箱:bvtougao@gmail.com
北京博文视点资讯有限公司(武汉分部)
湖北省 武汉市 洪山区 吴家湾 邮科院路特1号 湖北信息产业科技大厦
1402室
邮政编码:430074
电 话:027-87690813
传 真:027-87690595
若您希望参加博文视点的有奖读者调查,或对写作和翻译感兴趣,欢迎
您访问:http:bv.csdn.net关于本书的勘误、资源下载及博文视点的最新书讯,欢迎您访问博文视
点官方博客:http:blog.csdn.netbvbook
第1部分 简介
第1章 温故而知新
1.1 从Hello World说起
1.2 万变不离其宗
1.3 站得高,望得远
1.4 操作系统做什么
1.5 内存不够怎么办
1.6 众人拾柴火焰高
1.7 本章小结
1.1 从Hello World说起
毫无疑问,“Hello World”对于程序员来说肯定是如雷贯耳。就是这样一
个简单的程序,带领了无数的人进入了程序的世界。简单的事物背后往
往又蕴涵着复杂的机制,如果我们深入思考一个简单的“Hello World”程
序,就会发现很多问题看似很简单,但实际上我们并没有一个非常清晰
的思路;或者在我们脑海里有着模糊的印象,但真正到某些细节的时候
可能又模糊不清了。比如对于C语言编写的Hello World程序:
include
int main
{
printf(Hello World\n);
return 0;}
对于下面这些问题,你的脑子里能够马上反应出一个很清晰又很明确的
答案吗?
程序为什么要被编译器编译了之后才可以运行?
编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?
最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们
怎么存放的,怎么组织的?
include是什么意思?把stdio.h包含进来意味着什么?C语言
库又是什么?它怎么实现的?
不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
Hello World程序是怎么运行起来的?操作系统是怎么装载它的?它从
哪儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束
以后又发生了什么?
如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作
系统的机器上运行Hello World需要什么?应该怎么实现?
printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够
在终端上输出字符串?
Hello World程序在运行时,它在内存中是什么样子的?
对于上面的问题,如果你确信能够非常清楚地了解里面的各个细节,并
且对其中的过程和机制都了如指掌,那么很遗憾,这本书不是为你准备
的;如果你发现对其中一些问题并不是很了解,甚至从来没有想到过一
个Hello World还能引出这么多值得思考的问题,而你又想了解它们,那
么恭喜你,这本书就是为你准备的。随着各个章节的逐步展开,我们会
从最基本的编译、静态链接到操作系统如何装载程序、动态链接及运行库和标准库的实现,甚至一些操作系统的机制,力争深入浅出地将这些
问题层层剥开,最终使得这些程序运行背后的机制形成一个非常清晰而
流畅的脉络。
在开始进入庞大而又繁琐的系统软件之前,让我们先进行热身活动,那
就是一起来回顾计算机系统的一些基本而又重要的概念。整个计算机系
统回顾过程将分为两个部分,分别是硬件部分和软件部分。本书的主要
目的不是介绍计算机系统结构,第1章的回顾只是巩固和总结计算机软
硬件体系里面几个重要的概念,这些概念在我们后面的章节中将时时伴
随着我们,失去了它们的支撑,后面的章节将会显得繁琐而又晦涩。如
果你自认为这些基本概念很简单,那么你可以大概地浏览一遍几个知识
点的标题,然后直接跳到第2章;反之,如果你觉得有些概念还不是很
清楚,甚至从来没听说过这些概念,那么请你仔细阅读相关章节,相信
这个过程对你阅读本书甚至对你深入了解计算机大有裨益。
1.2 万变不离其宗
计算机是个非常广泛的概念,大到占用数层楼的用于科学计算的超级计
算机,小到手机上的嵌入式芯片都可以被称为计算机。虽然它们的外
形、结构和性能都千差万别,但至少它们都有“计算”这个概念。在本书
里面,我们将计算机的范围限定在最为流行、使用最广泛的PC机,更
具体地讲是采用兼容x86指令集的32位CPU的个人计算机。原因很简
单:因为笔者手上目前只有这种类型的计算机可供操作和实验,不过相
信90%以上的读者也是,所以在这一点上我们很快能达成共识。其实选
择具体哪种平台并不是最关键的,虽然各种平台的软硬件差别很多,但
是本质上它们的基本概念和工作原理都是一样的,只要我们能够掌握一
种平台上的技术,那么其他的平台都是大同小异的,很轻松地可以举一
反三。所以我们相信,只有你能够深刻地理解x86平台下的系统软件背
后的机理,当有一天你需要在MIPS指令集的嵌入式平台上做开发,或
者需要为64位的Windows或Linux开发应用程序的时候,你很快就能找
到它们之间的相通之处。
撇开计算机硬件中纷繁复杂的各种设备、芯片及外围接口等,站在软件
开发者的角度看,我们只须抓住硬件的几个关键部件。对于系统程序开
发者来说,计算机多如牛毛的硬件设备中,有三个部件最为关键,它们
分别是中央处理器CPU、内存和IO控制芯片,这三个部件几乎就是计
算机的核心了;对于普通应用程序开发者来说,他们似乎除了要关心CPU以外,其他的硬件细节基本不用关心,对于一些高级平台的开发者
来说(如Java、.NET或脚本语言开发者),连CPU都不需要关心,因为
这些平台为它们提供了一个通用的抽象的计算机,他们只要关心这个抽
象的计算机就可以了。
早期的计算机没有很复杂的图形功能,CPU的核心频率也不高,跟内存
的频率一样,它们都是直接连接在同一个总线(Bus)上的。由于IO设
备诸如显示设备、键盘、软盘和磁盘等速度与CPU和内存相比还是慢很
多,当时也没有复杂的图形设备,显示设备大多是只能输出字符的终
端。为了协调IO设备与总线之间的速度,也为了能够让CPU能够和IO
设备进行通信,一般每个设备都会有一个相应的IO控制器。早期的计
算机硬件结构如图1-1所示。
图1-1 早期的计算机硬件
结构 …
后来由于CPU核心频率的提升,导致内存跟不上CPU的速度,于是产生
了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线进行
通信。接着随着图形化的操作系统普及,特别是3D游戏和多媒体的发
展,使得图形芯片需要跟CPU和内存之间大量交换数据,慢速的IO总
线已经无法满足图形设备的巨大需求。为了协调CPU、内存和高速的图
形设备,人们专门设计了一个高速的北桥芯片,以便它们之间能够高速
地交换数据。
由于北桥运行的速度非常高,所有相对低速的设备如果全都直接连接在
北桥上,北桥既须处理高速设备,又须处理低速设备,设计就会十分复
杂。于是人们又设计了专门处理低速设备的南桥(Southbridge)芯片,磁盘、USB、键盘、鼠标等设备都连接在南桥上,由南桥将它们汇总后
连接到北桥上。20世纪90年代的PC机在系统总线上采用的是PCI结构,而在低速设备上采用的ISA总线,采用PCIISA及南北桥设计的硬件构架
如图1-2所示。位于中间是连接所有高速芯片的北桥(Northbridge,PCI Bridge),它
就像人的心脏,连接并驱动身体的各个部位;它的左边是CPU,负责所
有的控制和运算,就像人的大脑。北桥还连接着几个高速部件,包括左
边的内存和下面的PCI总线。
PCI的速度最高为133 MHz,它还是不能满足人们的需求,于是人们又
发明了AGP、图1-2 硬件结构框架 …
PCI Express等诸多总线结构和相应控制芯片。虽然硬件结构看似越来越
复杂,但实际上它还是没有脱离最初的CPU、内存,以及IO的基本结
构。我们从程序开发的角度看待硬件时可以简单地将它看成最初的硬件
模型。SMP与多核
人们总是希望计算机越来越快,这是毫无疑问的。在过去的50年里,CPU的频率从几十KHz到现在的4GHz,整整提高了数十万倍,基本上
每18个月频率就会翻倍。但是自2004年以来,这种规律似乎已经失效,CPU的频率自从那时开始再也没有发生质的提高。原因是人们在制造
CPU的工艺方面已经达到了物理极限,除非CPU制造工艺有本质的突
破,否则CPU的频率将会一直被目前4GHz的“天花板”所限制。
在频率上短期内已经没有提高的余地了,于是人们开始想办法从另外一
个角度来提高CPU的速度,就是增加CPU的数量。一个计算机拥有多个
CPU早就不是什么新鲜事了,很早以前就有了多CPU的计算机,其中最
常见的一种形式就是对称多处理器(SMP,Symmetrical Multi-
Processing),简单地讲就是每个CPU在系统中所处的地位和所发挥的
功能都是一样的,是相互对称的。理论上讲,增加CPU的数量就可以提
高运算速度,并且理想情况下,速度的提高与CPU的数量成正比。但实
际上并非如此,因为我们的程序并不是都能分解成若干个完全不相干的
子问题。就比如一个女人可以花10个月生出一个孩子,但是10个女人并
不能在一个月就生出一个孩子一样。
当然很多时候多处理器是非常有用的,最常见的情况就是在大型的数据
库、网络服务器上,它们要同时处理大量的请求,而这些请求之间往往
是相互独立的,所以多处理器就可以最大效能地发挥威力。
多处理器应用最多的场合也是这些商用的服务器和需要处理大量计算的
环境。而在个人电脑中,使用多处理器则是比较奢侈的行为,毕竟多处
理器的成本是很高的。于是处理器的厂商开始考虑将多个处理器“合并
在一起打包出售”,这些“被打包”的处理器之间共享比较昂贵的缓存部
件,只保留多个核心,并且以一个处理器的外包装进行出售,售价比单
核心的处理器只贵了一点,这就是多核处理器(Multi-core Processor)
的基本想法。多核处理器实际上就是SMP的简化版,当然它们在细节上
还有一些差别,但是从程序员的角度来看,它们之间区别很小,逻辑上
来看它们是完全相同的。只是多核和SMP在缓存共享等方面有细微的差
别,使得程序在优化上可以有针对性地处理。简单地讲,除非想把CPU
的每一滴油水都榨干,否则可以把多核和SMP看成同一个概念。
推荐阅读:“Free Lunch is Over”(免费午餐已经结束了)http:www.gotw.capublicationsconcurrency-ddj.htm
随着CPU频率碰到了“天花板”,多核处理器越来越普及,对程序员开发
程序的方式也将发生极大的变化,这篇文章很好地分析了将要到来的多
核时代对程序开发的挑战和机遇。
1.3 站得高,望得远
系统软件这个概念其实比较模糊,传统意义上一般将用于管理计算机本
身的软件称为系统软件,以区别普通的应用程序。系统软件可以分成两
块,一块是平台性的,比如操作系统内核、驱动程序、运行库和数以千
计的系统工具;另外一块是用于程序开发的,比如编译器、汇编器、链
接器等开发工具和开发库。本书将着重介绍系统软件的一部分,主要是
链接器和库(包括运行库和开发库)的相关内容。
计算机系统软件体系结构采用一种层的结构,有人说过一句名言:
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”
遗憾的是,这句经典的名言出处无从考证,据说是有人从图灵奖的获得者Butler Lampson的讲座
上听来的;也有人说是EDSAC的发明者David Wheeler讲的;还有人指出这是CMU计算机系创
始人Alan Perlis的名言。
“Any problem in computer science can be solved by another layer of
indirection.”
这句话几乎概括了计算机系统软件体系结构的设计要点,整个体系结构
从上到下都是按照严格的层次结构设计的。不仅是计算机系统软件整个
体系是这样的,体系里面的每个组件比如操作系统本身,很多应用程
序、软件系统甚至很多硬件结构都是按照这种层次的结构组织和设计
的。系统软件体系结构中,各种软件的位置如图1-3所示。 图1-3 计算机软件体系结构 …
每个层次之间都须要相互通信,既然须要通信就必须有一个通信的协
议,我们一般将其称为接口(Interface),接口的下面那层是接口的提
供者,由它定义接口;接口的上面那层是接口的使用者,它使用该接口
来实现所需要的功能。在层次体系中,接口是被精心设计过的,尽量保
持稳定不变,那么理论上层次之间只要遵循这个接口,任何一个层都可
以被修改或被替换。除了硬件和应用程序,其他都是所谓的中间层,每
个中间层都是对它下面的那层的包装和扩展。正是这些中间层的存在,使得应用程序和硬件之间保持相对的独立,比如硬件和操作系统都日新
月异地发展,但是最初为80386芯片和DOS系统设计的软件在最新的多
核处理器和Windows Vista下还是能够运行的,这方面归功于硬件和操作
系统本身保持了向后兼容性,另一方面不得不归功于这种层次结构的设
计方式。最近开始流行的虚拟机技术更是在硬件和操作系统之间增加了
一层虚拟层,使得一个计算机上可以同时运行多个操作系统,这也是层
次结构带来的好处,在尽可能少改变甚至不改变其他层的情况下,新增
加一个层次就可以提供前所未有的功能。
我们的软件体系中,位于最上层的是应用程序,比如我们平时用到的网
络浏览器、Email客户端、多媒体播放器、图片浏览器等。从整个层次
结构上来看,开发工具与应用程序是属于同一个层次的,因为它们都使
用一个接口,那就是操作系统应用程序编程接口(Application
Programming Interface)。应用程序接口的提供者是运行库,什么样的
运行库提供什么样的API,比如Linux下的Glibc库提供POSIX的API;
Windows的运行库提供Windows API,最常见的32位Windows提供的API
又被称为Win32。
运行库使用操作系统提供的系统调用接口(System call Interface),系
统调用接口在实现中往往以软件中断(Software Interrupt)的方式提供,比如Linux使用0x80号中断作为系统调用接口,Windows使用0x2E
号中断作为系统调用接口(从Windows XP Sp2开始,Windows开始采用
一种新的系统调用方式)。
操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的
定义者,硬件的接口定义决定了操作系统内核,具体来讲就是驱动程序
如何操作硬件,如何与硬件进行通信。这种接口往往被叫做硬件规格
(Hardware Specification),硬件的生产厂商负责提供硬件规格,操作
系统和驱动程序的开发者通过阅读硬件规格文档所规定的各种硬件编程
接口标准来编写操作系统和驱动程序。
1.4 操作系统做什么
操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件
资源。
计算机硬件的能力是有限的,比如一个CPU一秒钟能够执行的指令条数
是1亿条或是1GB的内存能够最多同时存储1GB的数据。无论你是否使
用它,资源总是那么多。当然我们不希望自己花钱买回来的硬件成为摆
设,充分挖掘硬件的能力,使得计算机运行得更有效率,在更短的时间
内处理更多的任务,才是我们的目标。这对于早期动辄数百万美元的古
董计算机来说更是如此,人们挖空心思让计算机硬件发挥所有潜能。一
个计算机中的资源主要分CPU、存储器(包括内存和磁盘)和IO设
备,我们分别从这三个方面来看看如何挖掘它们的潜力。
1.4.1 不要让CPU打盹
在计算机发展早期,CPU资源十分昂贵,如果一个CPU只能运行一个程
序,那么当程序读写磁盘(当时可能是磁带)时,CPU就空闲下来了,这在当时简直就是暴殄天物。于是人们很快编写了一个监控程序,当某
个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU资源的
程序启动,使得CPU能够充分地利用起来。这种被称为多道程序
(Multiprogramming)的方法看似很原始,但是它当时的确大大提高了
CPU的利用率。不过这种原始的多道程序技术存在最大的问题是程序之
间的调度策略太粗糙。对于多道程序来说,程序之间不分轻重缓急,如
果有些程序急需使用CPU来完成一些任务(比如用户交互的任务),那
么很有可能很长时间后才有机会分配到CPU。这对于有些响应时间要求
高的程序来说是很致命的,想象一下你在Windows上面点击鼠标10分钟以后系统才有反应,那该是多么沮丧的事。
经过稍微改进,程序运行模式变成了一种协作的模式,即每个程序运行
一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都
有机会运行一小段时间。这对于一些交互式的任务尤为重要,比如点击
一下鼠标或按下一个键盘按键后,程序所要处理的任务可能并不多,但
是它需要尽快地被处理,使得用户能够立即看到效果。这种程序协作模
式叫做分时系统(Time-Sharing System),这时候的监控程序已经比多
道程序要复杂多了,完整的操作系统雏形已经逐渐形成了。Windows的
早期版本(Windows 95和Windows NT之前),Mac OS X之前的Mac OS
版本都是采用这种分时系统的方式来调度程序的。比如在Windows 3.1
中,程序调用Yield、GetMessage或PeekMessage这几个系统调用时,Windows 3.1操作系统会判断是否有其他程序正在等待CPU,如果有,则可能暂停执行当前的程序,把CPU让出来给其他程序。如果一个程序
在进行一个很耗时的计算,一直霸占着CPU不放,那么操作系统也没办
法,其他程序都只有等着,整个系统看过去好像死机了一样。比如一个
程序进入了一个while(1)的死循环,那么整个系统都停止了。
经过稍微改进,程序运行模式变成了一种协作的模式,即每个程序运行
一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都
有机会运行一小段时间。这对于一些交互式的任务尤为重要,比如点击
一下鼠标或按下一个键盘按键后,程序所要处理的任务可能并不多,但
是它需要尽快地被处理,使得用户能够立即看到效果。这种程序协作模
式叫做分时系统(Time-Sharing System),这时候的监控程序已经比多
道程序要复杂多了,完整的操作系统雏形已经逐渐形成了。Windows的
早期版本(Windows 95和Windows NT之前),Mac OS X之前的Mac OS
版本都是采用这种分时系统的方式来调度程序的。比如在Windows 3.1
中,程序调用Yield、GetMessage或PeekMessage这几个系统调用时,Windows 3.1操作系统会判断是否有其他程序正在等待CPU,如果有,则可能暂停执行当前的程序,把CPU让出来给其他程序。如果一个程序
在进行一个很耗时的计算,一直霸占着CPU不放,那么操作系统也没办
法,其他程序都只有等着,整个系统看过去好像死机了一样。比如一个
程序进入了一个while(1)的死循环,那么整个系统都停止了。
这在现在看来是很荒唐的事,系统中的任何一个程序死循环都会导致系
统死机,这是无法令人接受的。当然当时的PC硬件处理能力本身就很
弱,PC上的应用也大多是比较低端的应用,所以这种分时方式勉强也能应付一下当时的交互式环境了。此前在高端领域,非PC的大中小型
机领域,其实已经在研究一种更为先进的操作系统模式了。这种模式就
是我们现在很熟悉的多任务(Multi-tasking)系统,操作系统接管了所
有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程
序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个
进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。
CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机
会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停
该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式
即所谓的抢占式(Preemptive),操作系统可以强制剥夺CPU资源并且
分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间
都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同
时运行的假象。目前几乎所有现代的操作系统都是采用这种方式,比如
我们熟悉的UNIX、Linux、Windows NT,以及Mac OS X等流行的操作
系统。
1.4.2 设备驱动
操作系统作为硬件层的上层,它是对硬件的管理和抽象。对于操作系统
上面的运行库和应用程序来说,它们希望看到的是一个统一的硬件访问
模式。作为应用程序的开发者,我们不希望在开发应用程序的时候直接
读写硬件端口、处理硬件中断等这些繁琐的事情。由于硬件之间千差万
别,它们的操作方式和访问方式都有区别。比如我们希望在显示器上画
一条直线,对于程序员来说,最好的方式是不管计算机使用什么显卡、什么显示器,多少大小多少分辨率,我们都只要调用一个统一的
LineTo函数,具体的实现方式由操作系统来完成。试想一下如果程序
员需要关心具体的硬件,那么结果会是这样:对于A型号的显卡来说,需要往IO端口0x1001写一个命令0x1111,然后从端口0x1002中读取一
个4字节的显存地址,然后使用DDA(一种画直线的图形算法)逐个地
在显存上画点……如果是B型号的显卡,可能完全是另外一种方式。这
简直就是灾难。不过在操作系统成熟之前,的确存在这样的情况,就是
应用程序的程序员需要直接跟硬件打交道。
当成熟的操作系统出现以后,硬件逐渐被抽象成了一系列概念。在
UNIX中,硬件设备的访问形式跟访问普通的文件形式一样;在
Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象
成了DirectX对象;磁盘被抽象成了普通文件系统,等等。程序员逐渐从硬件细节中解放出来,可以更多地关注应用程序本身的开发。这些繁
琐的硬件细节全都交给了操作系统,具体地讲是操作系统中的硬件驱动
(Device Driver)程序来完成。驱动程序可以看作是操作系统的一部
分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核
之间有一定的独立性,使得驱动程序有比较好的灵活性。因为PC的硬
件多如牛毛,操作系统开发者不可能为每个硬件开发一个驱动程序,这
些驱动程序的开发工作通常由硬件生产厂商完成。操作系统开发者为硬
件生产厂商提供了一系列接口和框架,凡是按照这个接口和框架开发的
驱动程序都可以在该操作系统上使用。让我们以一个读取文件为例子来
看看操作系统和驱动程序在这个过程中扮演了什么样的角色。
提到文件的读取,那么不得不提到文件系统这个操作系统中最为重要的
组成部分之一。文件系统管理着磁盘中文件的存储方式,比如我们在
Linux系统下有一个文件“homeusertest.dat”,长度为8 000个字节。那么
我们在创建这个文件的时候,Linux的ext3文件系统有可能将这个文件按
照这样的方式存储在磁盘中:文件的前4 096字节存储在磁盘的1000号
扇区到1007号扇区,每个扇区512字节,8个扇区刚好4 096字节;文件
的第4 097个字节到第8 000字节共3 904个字节,存储在磁盘的2000号扇
区到2007号扇区,8个扇区也是4 096字节,只不过只存储了3 904个有效
的字节,剩下的192个字节无效。如果把这个文件的存储方式看作是一
个链状的结构,它的结构如图1-4所示。
图1-4 文件在磁盘中的结
构 …
这里我们先穿插一个关于硬盘的结构介绍,关于硬盘结构可能很多读者
已经有一个大概的了解,那就是硬盘基本存储单位为扇区(Sector),每个扇区一般为512字节。一个硬盘往往有多个盘片,每个盘片分两
面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。
比如一个硬盘有2个盘片,每个盘面分65 536磁道,每个磁道分1 024个
扇区,那么硬盘的容量就是2 2 65 536 1 024 512 = 137 438 953
472字节(128GB)。但是我们可以想象,每个盘面上同心圆的周长不
一样,如果按照每个磁道都拥有相同数量的扇区,那么靠近盘面外围的磁道密度肯定比内圈更加稀疏,这样是比较浪费空间的。但是如果不同
的磁道扇区数又不同,计算起来就十分麻烦。为了屏蔽这些复杂的硬件
细节,现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的
方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。逻辑扇区号抛弃了所有复杂的磁道、盘
面之类的概念。当我们给出一个逻辑的扇区号时,硬盘的电子设备会将
其转换成实际的盘面、磁道等这些位置。
文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证
磁盘中的扇区能够有效地组织和利用。那么当我们在Linux操作系统
中,要读取这个文件的前4 096个字节时,我们会使用一个read的系统调
用来实现。文件系统收到read请求之后,判断出文件的前4 096个字节位
于磁盘的1000号逻辑扇区到1007号逻辑扇区。然后文件系统就向硬盘驱
动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,磁盘驱动程
序收到这个请求以后就向硬盘发出硬件命令。向硬件发送IO命令的方
式有很多种,其中最为常见的一种就是通过读写IO端口寄存器来实
现。在x86平台上,共有65 536个硬件端口寄存器,不同的硬件被分配
到了不同的IO端口地址。CPU提供了两条专门的指令“in”和“out”来实现
对硬件端口的读和写。
对IDE接口来说,它有两个通道,分别为IDE0和IDE1,每个通道上可以
连接两个设备,分别为Master和Slave,一个PC中最多可以有4个IDE设
备。假设我们的文件位于IDE0的Master硬盘上,这也是正常情况下硬盘
所在的位置。在PC中,IDE0通道的IO端口地址是0x1F0~0x1F7及
0x376~0x377。通过读写这些端口地址就能与IDE硬盘进行通信。这些
端口的作用和操作方式十分复杂,我们以实现读取1000号逻辑扇区开始
的8个扇区为例:
第0x1F3~0x1F6 4个字节的端口地址是用来写入LBA地址的,那么
1000号逻辑扇区的LBA地址为0x000003E8,所以我们需要往0x1F3、0x1F4写入0x00,往0x1F5写入0x03,往0x1F6写入0xE8。
0x1F2这个地址用来写入命令所需要读写的扇区数。比如读取8个扇区
即写入8。
0x1F7这个地址用来写入要执行的操作的命令码,对于读取操作来
说,命令字为0x20。所以我们要执行的指令为:
out 0x1F3, 0x00
out 0x1F4, 0x00
out 0x1F5, 0x03
out 0x1F6, 0xE8
out 0x1F2, 0x08
out 0x1F7, 0x20
在硬盘收到这个命令以后,它就会执行相应的操作,并且将数据读取到
事先设置好的内存地址中(这个内存地址也是通过类似的命令方式设置
的)。当然这里的例子中只是最简单的情况,实际情况比这个复杂得
多,驱动程序须要考虑硬件的状态(是否忙碌或读取错误)、调度和分
配各个请求以达到最高的性能等。
1.5 内存不够怎么办
上面一节中我们提到了进程的概念,进程的总体目标是希望每个进程从
逻辑上来看都可以独占计算机的资源。操作系统的多任务功能使得CPU
能够在多个进程之间很好地共享,从进程的角度看好像是它独占了CPU
而不用考虑与其他进程分享CPU的事情。操作系统的IO抽象模型也很
好地实现了IO设备的共享和抽象,那么唯一剩下的就是主存,也就是
内存的分配问题了。
在早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序
在运行时所访问的地址都是物理地址。当然,如果一个计算机同时只运
行一个程序,那么只要程序要求的内存空间不要超过物理内存的大小,就不会有问题。但事实上为了更有效地利用硬件资源,我们必须同时运
行多个程序,正如前面的多道程序、分时系统和多任务中一样,当我们
能够同时运行多个程序时,CPU的利用率将会比较高。那么很明显的一
个问题是,如何将计算机上有限的物理内存分配给多个程序使用。
假设我们的计算机有128 MB内存,程序A运行需要10 MB,程序B需要
100 MB,程序C需要20 MB。如果我们需要同时运行程序A和B,那么比
较直接的做法是将内存的前10 MB分配给程序A,10 MB~110 MB分配
给B。这样就能够实现A和B两个程序同时运行,但是这种简单的内存分配策略问题很多。
地址空间不隔离 所有程序都直接访问物理地址,程序所使用的内存空
间不是相互隔离的。恶意的程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意的、但是有臭虫的程序可能不小心修改
了其他程序的数据,就会使其他程序也崩溃,这对于需要安全稳定的计
算环境的用户来说是不能容忍的。用户希望他在使用计算机的时候,其
中一个任务失败了,至少不会影响其他任务。
内存使用效率低 由于没有有效的内存管理机制,通常需要一个程序执
行时,监控程序就将整个程序装入内存中然后开始执行。如果我们忽然
需要运行程序C,那么这时内存空间其实已经不够了,这时候我们可以
用的一个办法是将其他程序的数据暂时写到磁盘里面,等到需要用到的
时候再读回来。由于程序所需要的空间是连续的,那么这个例子里面,如果我们将程序A换出到磁盘所释放的内存空间是不够的,所以只能将
B换出到磁盘,然后将C读入到内存开始运行。可以看到整个过程中有
大量的数据在换入换出,导致效率十分低下。
程序运行的地址不确定 因为程序每次需要装入运行时,我们都需要给
它从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定
的。这给程序的编写造成了一定的麻烦,因为程序在编写时,它访问数
据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问
题,我们在第2部分和第3部分还会详细探讨重定位的问题。
解决这几个问题的思路就是使用我们前文提到过的法宝:增加中间层,即使用一种间接的地址访问方法。整个想法是这样的,我们把程序给出
的地址看作是一种虚拟地址(Virtual Address),然后通过某些映射的
方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥
善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程
序所能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址
空间隔离的效果。
1.5.1 关于隔离
让我们回到程序的运行本质上来。用户程序在运行时不希望介入到这些
复杂的存储器管理过程中,作为普通的程序,它需要的是一个简单的执
行环境,有一个单一的地址空间、有自己的CPU,好像整个程序占有整
个计算机而不用关心其他的程序(当然程序间通信的部分除外,因为这是程序主动要求跟其他程序通信和联系)。所谓的地址空间是个比较抽
象的概念,你可以把它想象成一个很大的数组,每个数组的元素是一个
字节,而这个数组大小由地址空间的地址长度决定,比如32位的地址空
间的大小为 2^32 = 4 294 967 296 字节,即4GB,地址空间有效的地址是
0~4 294 967 295,用十六进制表示就是0x00000000~0xFFFFFFFF。地
址空间分两种:虚拟地址空间(Virtual Address Space)和物理地址空间
(Physical Address Space)。物理地址空间是实实在在存在的,存在于
计算机中,而且对于每一台计算机来说只有唯一的一个,你可以把物理
空间想象成物理内存,比如你的计算机用的是Intel的Pentium 4的处理
器,那么它是32位的机器,即计算机地址线有32条(实际上是36条地址
线,不过我们暂时认为它只是32条),那么物理空间就有4GB。但是你
的计算机上只装了512MB的内存,那么其实物理地址的真正有效部分只
有0x00000000~0x1FFFFFFF,其他部分都是无效的(实际上还有一些
外部IO设备映射到物理空间的,也是有效的,但是我们暂时无视其存
在)。虚拟地址空间是指虚拟的、人们想象出来的地址空间,其实它并
不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自
己的地址空间,这样就有效地做到了进程的隔离。
1.5.2 分段(Segmentation)
最开始人们使用的是一种叫做分段(Segmentation)的方法,基本思路
是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空
间。比如程序A需要10 MB内存,那么我们假设有一个地址从
0x00000000到0x00A00000的10MB大小的一个假象的空间,也就是虚拟
空间,然后我们从实际的物理内存中分配一个相同大小的物理地址,假
设是物理地址0x00100000开始到0x00B00000结束的一块空间。然后我们
把这两块相同大小的地址空间一一映射,即虚拟空间中的每个字节相对
应于物理空间中的每个字节。这个映射过程由软件来设置,比如操作系
统来设置这个映射函数,实际的地址转换由硬件完成。比如当程序A中
访问地址0x00001000时,CPU会将这个地址转换成实际的物理地址
0x00101000。那么比如程序A和程序B在运行时,它们的虚拟空间和物
理空间映射关系可能如图1-5所示。图1-5 段映射机制 …
分段的方法基本解决了上面提到的3个问题中的第一个和第三个。首先
它做到了地址隔离,因为程序A和程序B被映射到了两块不同的物理空
间区域,它们之间没有任何重叠,如果程序A访问虚拟空间的地址超出
了0x00A00000这个范围,那么硬件就会判断这是一个非法的访问,拒
绝这个地址请求,并将这个请求报告给操作系统或监控程序,由它来决
定如何处理。再者,对于每个程序来说,无论它们被分配到物理地址的哪一个区域,对于程序来说都是透明的,它们不需要关心物理地址的变
化,它们只需要按照从地址0x00000000到0x00A00000来编写程序、放
置变量,所以程序不再需要重定位。
但是分段的这种方法还是没有解决我们的第二个问题,即内存使用效率
的问题。分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,这样势必会造成大量的磁盘访问操
作,从而严重影响速度,这种方法还是显得粗糙,粒度比较大。事实
上,根据程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在
一个时间段内都是不会被用到的。人们很自然地想到了更小粒度的内存
分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高
了内存的使用率。这种方法就是分页(Paging)。
1.5.3 分页(Paging)
分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大
小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大
小。比如Intel Pentium系列处理器支持4KB或4MB的页大小,那么操作
系统可以选择每页大小为4KB,也可以选择每页大小为4MB,但是在同
一时刻只能选择一种大小,所以对整个系统来说,页就是固定大小的。
目前几乎所有的PC上的操作系统都使用4KB大小的页。我们使用的PC
机是32位的虚拟地址空间,也就是4GB,那么按4KB每页分的话,总共
有1 048 576个页。物理空间也是同样的分法。
下面我们来看一个简单的例子,如图1-6所示,每个虚拟空间有8页,每
页大小为1KB,那么虚拟地址空间就是8KB。我们假设该计算机有13条
地址线,即拥有2^13的物理寻址能力,那么理论上物理空间可以多达
8KB。但是出于种种原因,购买内存的资金不够,只买得起6KB的内
存,所以物理空间其实真正有效的只是前6KB。
那么,当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页
装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时
候再把它从磁盘里取出来即可。以图1-6为例,我们假设有两个进程
Process1和Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如VP0、VP1和VP7映射到PP0、PP2和PP3;而有部分页面却在磁盘
中,比如VP2和VP3位于磁盘的DP0和DP1中;另外还有一些页面如
VP4、VP5和VP6可能尚未被用到或访问到,它们暂时处于未使用的状态。在这里,我们把虚拟空间的页就叫虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫
做磁盘页(DP,Disk Page)。图中的线表示映射关系,我们可以看到
虚拟空间的有些页被映射到同一个物理页,这样就可以实现内存共享。
图1-6中Process1的VP2和VP3不在内存中,但是当进程需要用到这两个
页的时候,硬件会捕获到这个消息,就是所谓的页错误(Page
Fault),然后操作系统接管进程,负责将VP2和VP3从磁盘中读出来并
且装入内存,然后将内存中的这两个页与VP2和VP3之间建立映射关
系。以页为单位来存取和交换这些数据非常方便,硬件本身就支持这种
以页为单位的操作方式。图1-6 进程虚拟空间、物理空间和磁盘之间的页映射关系 …
保护也是页映射的目的之一,简单地说就是每个页可以设置权限属性,谁可以修改,谁可以访问等,而只有操作系统有权限修改这些属性,那
么操作系统就可以做到保护自己和保护进程。对于保护,我们这里只是
简单介绍,详细的介绍和为什么要保护我们将会在本书的第2部分再介绍。
虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。
但是几乎所有的硬件都采用一个叫MMU(Memory Management Unit)
的部件来进行页映射,如图1-7所示。
图1-7 虚拟地址到物理地址的转换 …
在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的
是虚拟地址。经过MMU转换以后就变成了Physical Address。一般MMU
都集成在CPU内部了,不会以独立的部件存在。
1.6 众人拾柴火焰高
1.6.1 线程基础
现代软件系统中,除了进程之外,线程也是一个十分重要的概念。特别
是随着CPU频率增长开始出现停滞,而开始向多核方向发展。多线程,作为实现软件并发执行的一个重要的方法,也开始具有越来越重要的地
位。我们将在这一节回顾线程相关的内容,包括线程的概念、线程的调
度、线程安全、用户线程与内核线程之间的映射关系。虽然线程相关的
概念与本书的内容并不是十分相关,但是我们相信深刻地理解线程对于
更加深入地理解装载、动态链接和运行库,特别是运行库与多线程相关
部分的内容会有很大的帮助。
什么是线程
线程(Thread),有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID、当前指
令指针(PC)、寄存器集合和堆栈组成。通常意义上,一个进程由一
个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。一个经典
的线程与进程的关系如图1-8所示。图1-8 进程内的线程 …
大多数软件应用中,线程的数量都不止一个。多个线程可以互不干扰地
并发执行,并共享进程的全局变量和堆的数据。那么,多个线程与单线
程的进程相比,又有哪些优势呢?通常来说,使用多线程的原因有如下
几点。
某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继
续执行。多线程执行可以有效利用等待的时间。典型的例子是等待网络
响应,这可能要花费数秒甚至数十秒。
某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序
和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线
程负责计算。
程序逻辑本身就要求并发操作,例如一个多端下载软件(例如
Bittorrent)。
多CPU或多核计算机(基本就是未来的主流计算机),本身具备同时执
行多个线程的能力,因此单线程程序无法全面地发挥计算机的全部计算
能力。
相对于多进程应用,多线程在数据共享方面效率要高很多。
线程的访问权限
线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其
他线程的堆栈(如果它知道其他线程的堆栈地址,那么这就是很少见的
情况),但实际运用中线程也拥有自己的私有存储空间,包括以下几方
面。
栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是
私有的数据)。
线程局部存储(Thread Local Storage, TLS)。线程局部存储是某些操作
系统为线程单独提供的私有空间,但通常只具有很有限的容量。
寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程
私有。从C程序员的角度来看,数据在线程之间是否私有如表1-1所示。
表1-1 …
线程调度与优先级
不论是在多处理器的计算机上还是在单处理器的计算机上,线程总
是“并发”执行的。当线程数量小于等于处理器数量时(并且操作系统支
持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处
理器上,彼此之间互不相干。但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因为此时至少有一个处理器会运行多个线
程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系
统会让这些多线程程序轮流执行,每次仅执行一小段时间(通常是几十
到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断
在处理器上切换不同的线程的行为称之为线程调度(Thread
Schedule)。在线程调度中,线程通常拥有至少三种状态,分别是:
运行(Running):此时线程正在执行。
就绪(Ready):此时线程可以立刻运行,但CPU已经被占用。
等待(Waiting):此时线程正在等待某一事件(通常是IO或同步)发
生,无法执行。
处于运行中线程拥有一段可以执行的时间,这段时间称为时间片(Time
Slice),当时间片用尽的时候,该进程将进入就绪状态。如果在时间片
用尽之前进程就开始等待某事件,那么它将进入等待状态。每当一个线
程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪
状态。这3个状态的转移如图1-9所示。
图1-9 线程状态切换 …
线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算
法。现在主流的调度方式尽管各不相同,但都带有优先级调度(Priority
Schedule)和轮转法(Round Robin)的痕迹。所谓轮转法,即是之前提
到的让各个线程轮流执行一小段时间的方法。这决定了线程之间交错执
行的特点。而优先级调度则决定了线程按照什么顺序轮流执行。在具有
优先级调度的系统中,线程都拥有各自的线程优先级(Thread
Priority)。具有高优先级的线程会更早地执行,而低优先级的线程常常
要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行。
在Windows中,可以通过使用:
BOOL WINAPI SetThreadPriority(HANDLE hThread, int nPriority);
来设置线程的优先级,而Linux下与线程相关的操作可以通过pthread库
来实现。
在Windows和Linux中,线程的优先级不仅可以由用户手动设置,系统
还会根据不同线程的表现自动调整优先级,以使得调度更有效率。例如
通常情况下,频繁地进入等待状态(进入等待状态,会放弃之后仍然可
占用的时间份额)的线程(例如处理IO的线程)比频繁进行大量计
算、以至于每次都要把时间片全部用尽的线程要受欢迎得多。其实道理
很简单,频繁等待的线程通常只占用很少的时间,CPU也喜欢先捏软柿
子。我们一般把频繁等待的线程称之为IO密集型线程(IO Bound
Thread),而把很少等待的线程称为CPU密集型线程(CPU Bound
Thread)。IO密集型线程总是比CPU密集型线程容易得到优先级的提
升。在优先级调度下,存在一种饿死(Starvation)的现象,一个线程被饿
死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试
图执行,因此这个低优先级线程始终无法执行。当一个CPU密集型的线
程获得较高的优先级时,许多低优先级的进程就很可能饿死。而一个高
优先级的IO密集型线程由于大部分时间都处于等待状态,因此相对不容
易造成其他线程饿死。为了避免饿死现象,调度系统常常会逐步提升那
些等待了过长时间的得不到执行的线程的优先级。在这样的手段下,一
个线程只要等待足够长的时间,其优先级一定会提高到足够让它执行的
程度。
让我们总结一下,在优先级调度的环境下,线程的优先级改变一般有三
种方式。
用户指定优先级。
根据进入等待状态的频繁程度提升或降低优先级。
长时间得不到执行而被提升优先级。
可抢占线程和不可抢占线程
我们之前讨论的线程调度有一个特点,那就是线程在用尽时间片之后会
被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占
(Preemption),即之后执行的别的线程抢占了当前线程。在早期的一
些系统(例如Windows 3.1)里,线程是不可抢占的。线程必须手动发
出一个放弃执行的命令,才能让其他的线程得到执行。在这样的调度模
型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进
入。如果线程始终拒绝进入就绪状态,并且也不进行任何的等待操作,那么其他的线程将永远无法执行。在不可抢占线程中,线程主动放弃执
行无非两种情况。
当线程试图等待某事件时(IO等)。
线程主动放弃时间片。
因此,在不可抢占线程执行的时候,有一个显著的特点,那就是线程调
度的时机是确定的,线程调度只会发生在线程主动放弃执行或线程等待
某事件的时候。这样可以避免一些因为抢占式线程里调度时机不确定而产生的问题(见下一节:线程安全)。但即使如此,非抢占式线程在今
日已经十分少见。
Linux的多线程
Windows对进程和线程的实现如同教科书一般标准,Windows内核有明
确的线程和进程的概念。在Windows API中,可以使用明确的API:
CreateProcess和CreateThread来创建进程和线程,并且有一系列的API来
操纵它们。但对于Linux来说,线程并不是一个通用的概念。
Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正
意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)
都称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,Linux下不同的任务之
间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间
的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。在
Linux下,用以下方法可以创建一个新的任务,如表1-2所示。
表1-2 …
fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从
fork函数里返回。例如如下代码:
pid_t pid;
if (pid = fork)
{….
}
在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返
回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回
0。fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Copy on Write, COW)的内存空
间(见图1-10)。所谓写时复制,指的是两个任务可以同时自由地读取
内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供
给修改方单独使用,以免影响到其他的任务使用。
fork只能够产生本任务的镜像,因此须要使用exec配合才能够启动别的
新任务。exec可以用新的可执行映像替换当前的可执行映像,因此在
fork产生了一个新任务之后,新任务可以调用exec来执行新的可执行文
件。fork和exec通常用于产生新任务,而如果要产生新线程,则可以使
用clone。clone函数的原型如下:
int clone(int (fn)(void), void child_stack, int flags, void arg);
图1-10 写时复
制(Copy-On-Write) …
使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选
的)共享当前进程的内存空间和文件等。如此就可以在实际效果上产生
一个线程。
1.6.2 线程安全
多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时
都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
竞争与原子操作
多个线程同时访问一个共享数据,可能造成很恶劣的后果。下面是一个
著名的例子,假设有两个线程分别要执行如表1-3所示的C代码。
表1-3 …
在许多体系结构上,++i的实现方法会如下:
(1)读取i到某个寄存器X。
(2)X++。
(3)将X的内容存储回i。
由于线程1和线程2并发执行,因此两个线程的执行序列很可能如下(注
意,寄存器X的内容在不同的线程中是不一样的,这里用X[1]和X[2]分
别表示线程1和线程2中的X),如表1-4所示。
表1-4 …
从程序逻辑来看,两个线程都执行完毕之后,i的值应该为1,但从之前
的执行序列可以看到,i得到的值是0。实际上这两个线程如果同时执行的话,i的结果有可能是0或1或2。可见,两个程序同时读写同一个共享
数据会导致意想不到的后果。
很明显,自增(++)操作在多线程环境下会出现错误是因为这个操作被
编译为汇编代码之后不止一条指令,因此在执行的时候可能执行了一半
就被调度系统打断,去执行别的代码。我们把单指令的操作称为原子的
(Atomic),因为无论如何,单条指令的执行是不会被打断的。为了避
免出错,很多体系结构都提供了一些常用操作的原子指令,例如i386就
有一条inc指令可以直接增加一个内存单元值,可以避免出现上例中的
错误情况。在Windows里,有一套API专门进行一些原子操作(见表1-
5),这些API称为Interlocked API。
表1-5 …
使用这些函数时,Windows将保证是原子操作的,因此可以不用担心出
现问题。遗憾的是,尽管原子操作指令非常方便,但是它们仅适用于比
较简单特定的场合。在复杂的场合下,比如我们要保证一个复杂的数据
结构更改的原子性,原子操作指令就力不从心了。这里我们需要更加通
用的手段:锁。
同步与锁
为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需
要将各个线程对同一个数据的访问同步(Synchronization)。所谓同
步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个
数据进行访问。如此,对数据的访问被原子化了。
同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线
程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束
之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会
等待,直到锁重新可用。二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状
态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二
元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获
得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二
元信号量的线程将会等待,直到该锁被释放。
对于允许多个线程并发访问的资源,多元信号量简称信号量
(Semaphore),它是一个很好的选择。一个初始值为N的信号量允许N
个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操
作:
将信号量的值减1。
如果信号量的值小于0,则进入等待状态,否则继续执行。
访问完资源之后,线程释放信号量,进行如下操作:
将信号量的值加1。
如果信号量的值小于1,唤醒一个等待中的线程。
互斥量(Mutex)和二元信号量很类似,资源仅同时允许一个线程访
问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释
放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一
个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负
责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。
临界区(Critical Section)是比互斥量更加严格的同步手段。在术语
中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界
区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任
何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号
量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅
限于本进程,其他的进程无法获取该锁。除此之外,临界区具有和互斥
量相同的性质。
读写锁(Read-Write Lock)致力于一种更加特定的场合的同步。对于一
段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子
型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一
种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶尔
写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个
锁,读写锁有两种获取方式,共享的(Shared)或独占的
(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都
能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共
享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如
果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必
须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其
他线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为可以总
结如表1-6所示。
表1-6 …
条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅
栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变
量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变
量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也
就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事
件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。
可重入(Reentrant)与线程安全
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调
用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
(1)多个线程同时执行这个函数。
(2)函数自身(可能是经过多层调用之后)调用自身。
一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后
果。举个例子,如下面这个sqr函数就是可重入的:int sqr(int x)
{
return x x;
}
一个函数要成为可重入的,必须具有如下几个特点:
不使用任何(局部)静态或全局的非const变量。
不返回任何(局部)静态或全局的非const变量的指针。
仅依赖于调用方提供的参数。
不依赖任何单个资源的锁(mutex等)。
不调用任何不可重入的函数。
可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下
放心使用。
过度优化
线程安全是一个非常烫手的山芋,因为即使合理地使用了锁,也不一定
能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的
并发需求。很多看似无错的代码在优化和并发面前又产生了麻烦。最简
单的例子,让我们看看如下代码:
x = 0;
Thread1 Thread2
lock; lock;
x++; x++;
unlock; unlock;
由于有 lock 和 unlock 的保护,x++的行为不会被并发所破坏,那么x的值
似乎必然是2了。然而,如果编译器为了提高x的访问速度,把x放到了
某个寄存器里,那么我们知道不同线程的寄存器是各自独立的,因此如
果Thread1先获得锁,则程序的执行可能会呈现如下的情况:[Thread1]读取x的值到某个寄存器R[1](R[1]=0)。
[Thread1]R[1]++(由于之后可能还要访问x,因此Thread1暂时不将R[1]
写回x)。
[Thread2]读取x的值到某个寄存器R[2](R[2]=0)。
[Thread2]R[2]++(R[2]=1)。
[Thread2]将R[2]写回至x(x=1)。
[Thread1](很久以后)将R[1]写回至x(x=1)。
可见在这样的情况下即使正确地加锁,也不能保证多线程安全。下面是
另一个例子:
x = y = 0;
Thread1 Thread2
x = 1; y = 1;
r1 = y; r2 = x;
很显然,r1和r2至少有一个为1,逻辑上不可能同时为0。然而,事实上
r1=r2=0的情况确实可能发生。原因在于早在几十年前,CPU就发展出
了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。
同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两
条相邻指令(如x=1和r1=y)的执行顺序。也就是说,以上代码执行的
时候可能是这样的:
x = y = 0;
Thread1 Thread2
r1 = y; y = 1;
x = 1; r2 = x;
那么r1=r2=0就完全可能了。我们可以使用volatile关键字试图阻止过度
优化,volatile基本可以做到两件事情:
(1)阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。(2)阻止编译器调整操作volatile变量的指令顺序。
可见volatile可以完美地解决第一个问题,但是volatile是否也能解决第二
个问题呢?答案是不能。因为即使volatile能够阻止编译器调整顺序,也
无法阻止CPU动态调度换序。
另一个颇为著名的与换序有关的问题来自于Singleton模式的double-
check。一段典型的double-check的singleton代码是这样的(不熟悉
Singleton的读者可以参考《设计模式:可复用面向对象软件的基础》,但下面所介绍的内容并不真正需要了解Singleton):
volatile T pInst = 0;
T GetInstance
{
if (pInst == NULL)
{
lock;
if (pInst == NULL)
pInst = new T;
unlock;
}
return pInst;
}
抛开逻辑,这样的代码乍看是没有问题的,当函数返回时,PInst总是指
向一个有效的对象。而lock和unlock防止了多线程竞争导致的麻烦。双
重的if在这里另有妙用,可以让lock的调用开销降低到最小。读者可以
自己揣摩。
但是实际上这样的代码是有问题的。问题的来源仍然是CPU的乱序执
行。C++里的new其实包含了两个步骤:
(1)分配内存。
(2)调用构造函数。所以pInst = new T包含了三个步骤:
(1)分配内存。
(2)在内存的位置上调用构造函数。
(3)将内存的地址赋值给pInst。
在这三步中,(2)和(3)的顺序是可以颠倒的。也就是说,完全有可
能出现这样的情况:pInst的值已经不是NULL,但对象仍然没有构造完
毕。这时候如果出现另外一个对GetInstance的并发调用,此时第一个if
内的表达式pInst==NULL为false,所以这个调用会直接返回尚未构造完
全的对象的地址(pInst)以提供给用户使用。那么程序这个时候会不会
崩溃就取决于这个类的设计如何了。
从上面两个例子可以看到CPU的乱序执行能力让我们对多线程的安全保
障的努力变得异常困难。因此要保证线程安全,阻止CPU换序是必需
的。遗憾的是,现在并不存在可移植的阻止换序的方法。通常情况下是
调用CPU提供的一条指令,这条指令常常被称为barrier。一条barrier指
令会阻止CPU将该指令之前的指令交换到barrier之后,反之亦然。换句
话说,barrier指令的作用类似于一个拦水坝,阻止换序“穿透”这个大
坝。
许多体系结构的CPU都提供barrier指令,不过它们的名称各不相同,例
如POWERPC提供的其中一条指令名叫lwsync。我们可以这样来保证线
程安全:
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;
}
由于barrier的存在,对象的构造一定在barrier执行之前完成,因此当
pInst被赋值时,对象总是完好的。
1.6.3 多线程内部情况
三种线程模型
线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要
更为复杂一些:大多数操作系统,包括Windows和Linux,都在内核里
提供线程的支持,内核线程(注:这里的内核线程和Linux内核里的
kernel_thread并不是一回事)和我们之前讨论的一样,由多处理器或调
度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于
用户态的用户线程。用户态线程并不一定在操作系统内核里对应同等数
量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程
在同时执行,对内核来说很可能只有一个线程。本节我们将详细介绍用
户态多线程库的实现方式。
1. 一对一模型
对于直接支持线程的系统,一对一模型始终是最为简单的模型。对一对
一模型来说,一个用户使用的线程就唯一对应一个内核使用的线程(但
反过来不一定,一个内核里的线程在用户态不一定有对应的线程存
在),如图1-11所示。 图1-11 一对一
线程模型 …
这样用户线程就具有了和内核线程一致的优点,线程之间的并发是真正
的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此
外,一对一模型也可以让多线程程序在多处理器的系统上有更好的表
现。
一般直接使用API或系统调用创建的线程均为一对一的线程。例如在
Linux里使用clone(带有CLONE_VM参数)产生的线程就是一个一对一
线程,因为此时在内核有一个唯一的线程与之对应。下列代码演示了这
一过程:
int thread_function(void)
{ ....}
char thread_stack[4096];
void foo
{
clone(thread_function, thread_stack, CLONE_VM, 0);
}
在Windows里,使用API CreateThread即可创建一个一对一的线程。
一对一线程缺点有两个:
由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户
的线程数量受到限制。
许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
2. 多对一模型
多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由
用户态的代码来进行,因此相对于一对一模型,多对一模型的线程切换
要快速许多。多对一的模型示意图如图1-12所示。
图1-12 多对一线程模型…
多对一模型一大问题是,如果其中一个用户线程阻塞,那么所有的线程
都将无法执行,因为此时内核里的线程也随之阻塞了。另外,在多处理
器系统上,处理器的增多对多对一模型的线程性能也不会有明显的帮
助。但同时,多对一模型得到的好处是高效的上下文切换和几乎无限制
的线程数量。
3. 多对多模型
多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映
射到少数但不止一个内核线程上,如图1-13所示。
在多对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线
程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得
到一定的性能提升,不过提升的幅度不如一对一模型高。 图1-13 多对多线程
模型 …
1.7 本章小结
在这一章中,我们对整个计算机的软硬件基本结构进行了回顾,包括
CPU与外围部件的连接方式、SMP与多核、软硬件层次体系结构、如何
充分利用CPU及与系统软件十分相关的设备驱动、操作系统、虚拟空
间、物理空间、页映射和线程的基础概念。虽然这些概念都是大家所了
解的,但是我们认为还是有必要回顾一下,它们跟本书后面章节介绍的
内容息息相关。正所谓温故而知新,这就是本章的目的。
第2部分 静态链接
第2章 编译和链接
2.1 被隐藏了的过程
2.2 编译器做了什么
2.3 链接器年龄比编译器长
2.4 模块拼装——静态链接
2.5 本章小结
对于平常的应用程序开发,我们很少需要关注编译和链接过程,因为通常的开发环境都是流行的集成开发环境(IDE),比如Visual Studio、Delphi等。这样的IDE一般都将编译和链接的过程一步完成,通常将这
种编译和链接合并到一起的过程称为构建(Build)。即使使用命令行
来编译一个源代码文件,简单的一句“gcc hello.c”命令就包含了非常复
杂的过程。
IDE和编译器提供的默认配置、编译和链接参数对于大部分的应用程序
开发而言已经足够使用了。但是在这样的开发过程中,我们往往会被这
些复杂的集成工具所提供的强大功能所迷惑,很多系统软件的运行机制
与机理被掩盖,其程序的很多莫名其妙的错误让我们无所适从,面对程
序运行时种种性能瓶颈我们束手无策。我们看到的是这些问题的现象,但是却很难看清本质,所有这些问题的本质就是软件运行背后的机理及
支撑软件运行的各种平台和工具,如果能够深入了解这些机制,那么解
决这些问题就能够游刃有余,收放自如了。
2.1 被隐藏了的过程
C语言的经典,“Hello World”程序几乎是每个程序员闭着眼睛都能写出
的,编译运行通过一气呵成,基本成了程序入门和开发环境测试的默认
的标准。
include
int main
{
printf(Hello World\n);
return 0;
}
在Linux下,当我们使用GCC来编译Hello World程序时,只须使用最简
单的命令(假设源代码文件名为hello.c):
gcc hello.c
.a.out
Hello World事实上,上述过程可以分解为4个步骤,分别是预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking),如图2-1
所示。
图2-1 GCC编译过程分解 …
2.1.1 预编译
首先是源代码文件hello.c和相关的头文件,如stdio.h等被预编译器cpp预
编译成一个.i文件。对于C++程序来说,它的源代码文件的扩展名可能
是.cpp或.cxx,头文件的扩展名可能是.hpp,而预编译后的文件扩展名
是.ii。第一步预编译的过程相当于如下命令(-E表示只进行预编译):gcc –E hello.c –o hello.i
或者:
cpp hello.c > hello.i
预编译过程主要处理那些源代码文件中的以“”开始的预编译指令。比
如 “include”、“define ”等,主要处理规则如下:
将所有的 “define ”删除,并且展开所有的宏定义。
处理所有条件预编译指令,比如 “if”、“ifdef”、“elif”、“else”、“endif ”。
处理 “include ”预编译指令,将被包含的文件插入到该预编译指令的位
置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含
其他文件。
删除所有的注释“”和“ ”。
添加行号和文件名标识,比如2“hello.c”2,以便于编译时编译器产生
调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
保留所有的 pragma 编译器指令,因为编译器须要使用它们。
经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义
是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问
题。
2.1.2 编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义
分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整
个程序构建的核心部分,也是最复杂的部分之一。我们将在下一节简单
介绍编译的具体几个步骤,这涉及编译原理等一些内容,由于它不是本
书介绍的核心内容,所以也仅仅是介绍而已。上面的编译过程相当于如
下命令:
gcc –S hello.i –o hello.s现在版本的GCC把预编译和编译两个步骤合并成一个步骤,使用一个叫
做cc1的程序来完成这两个步骤。这个程序位于“usrlibgcci486-linux-
gnu4.1”,我们也可以直接调用cc1来完成它:
usrlibgcci486-linux-gnu4.1cc1 hello.c
main
Execution times (seconds)
preprocessing :0.01(100%)usr 0.01(33%)sys 0.00( 0%)wall 77 kB( 8%)ggc
lexical analysis :0.00( 0%)usr 0.00( 0%)sys 0.02(50%)wall 0 kB(0%)ggc
parser :0.00( 0%)usr 0.00( 0%)sys 0.01(25%)wall 125 kB(13%)ggc
expand :0.00( 0%)usr 0.01(33%)sys 0.00( 0%)wall 6 kB(1%)ggc
TOTAL :0.01 0.03 0.04 982 kB
或者使用如下命令:
gcc –S hello.c –o hello.s
都可以得到汇编输出文件hello.s。对于C语言的代码来说,这个预编译
和编译的程序是cc1,对于C++来说,有对应的程序叫做cc1plus;
Objective-C是cc1obj;fortran是f771;Java是jc1。所以实际上gcc这个命
令只是这些后台程序的包装,它会根据不同的参数要求去调用预编译编
译程序cc1、汇编器as、链接器ld。
2.1.3 汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎
都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简
单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据
汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来
源于此。上面的汇编过程我们可以调用汇编器as来完成:
as hello.s –o hello.o
或者:
或者使用gcc命令从C源代码文件开始,经过预编译、编译和汇编直接输
出目标文件(Object File):gcc –c hello.c –o hello.o
2.1.4 链接
链接通常是一个让人比较费解的过程,为什么汇编器不直接输出可执行
文件而是输出一个目标文件呢?链接过程到底包含了什么内容?为什么
要链接?这恐怕是很多读者心中的疑惑。正是因为这些疑惑总是挥之不
去,所以我们特意用这一章的篇幅来分析链接,具体地说分析静态链接
的章节。下面让我们来看看怎么样调用ld才可以产生一个能够正常运行
的HelloWorld程序:
ld -static usrlibcrt1.o usrlibcrti.o usrlibgcci486-linux-
gnu4.1.3crtbeginT.o -Lusrlibgcci486-linux-gnu4.1.3 -Lusrlib -
Llib hello.o --start-group -lgcc -lgcc_eh -lc --end-group usrlibgcci486-
linux-gnu4.1.3crtend.o usrlibcrtn.o
可以看到,我们需要将一大堆文件链接起来才可以得到“a.out”,即最终
的可执行文件。看了这行复杂的命令,可能很多读者的疑惑更多了,crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o这些文件是什么?它们做什
么用的 ?-lgcc –lgcc_eh –lc 这些都是什么参数?为什么要使用它们?为什
么要将它们和hello.o链接起来才可以得到可执行文件?等等。
这些问题正是本书所需要介绍的内容,它们看似简单,其实涉及了编
译、链接和库,甚至是操作系统的一些很底层的内容。我们将紧紧围绕
着这些内容,进行必要的分析。不过在分析这些内容之前,我们还是来
关注一下上面这些过程中,编译器担任了一个什么样的角色。
2.2 编译器做了什么
从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工
具。比如我们用CC++语言写的一个程序可以使用编译器将其翻译成机
器可以执行的指令及数据。我们前面也提到了,使用机器指令或汇编语
言编写程序是十分费事及乏味的事情,它们使得程序开发的效率十分低
下。并且使用机器语言或汇编语言编写的程序依赖于特定的机器,一个
为某种CPU编写的程序在另外一种CPU下完全无法运行,需要重新编
写,这几乎是令人无法接受的。所以人们期望能够采用类似于自然语言
的语言来描述一个程序,但是自然语言的形式不够精确,所以类似于数
学定义的编程语言很快就诞生了。20世纪的六七十年代诞生了很多高级
语言,有些至今仍然非常流行,如FORTRAN、C语言等(准确地讲,FORTRAN诞生于20世纪50年代的IBM)。高级语言使得程序员们能够更加关注程序逻辑的本身,而尽量少考虑计算机本身的限制,如字长、内存大小、通信方式、存储方式等。高级编程语言的出现使得程序开发
的效率大大提高,高级语言的可移植性也使得它在多种计算机平台下能
够游刃有余。据研究,高级语言的开发效率是汇编语言和机器语言的5
倍以上。
让我们继续回到编译器本身的职责上来,编译过程一般可以分为6步:
扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
整个过程如图2-2所示。
图2-2 编译过程 …
我们将结合图2-2来简单描述从源代码(Source Code)到最终目标代码
(Final Target Code)的过程。以一段很简单的C语言的代码为例子来讲
述这个过程。比如我们有一行C语言的源代码如下:
array[index] = (index + 4) (2 + 6)CompilerExpression.c
2.2.1 词法分析
首先源代码程序被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State
Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记
号(Token)。比如上面的那行程序,总共包含了28个非空字符,经过
扫描以后,产生了16个记号,如表2-1所示。
表2-1 …
词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量
(包含数字、字符串等)和特殊符号(如加号、等号)。在识别记号的
同时,扫描器也完成了其他工作。比如将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用。
有一个叫做lex的程序可以实现词法扫描,它会按照用户之前描述好的
词法规则将输入的字符串分割成一个个记号。因为这样一个程序的存
在,编译器的开发者就无须为每个编译器开发一个独立的词法扫描器,而是根据需要改变词法规则就可以了。
另外对于一些有预处理的语言,比如C语言,它的宏替换和文件包含等
工作一般不归入编译器的范围而交给一个独立的预处理器。
2.2.2 语法分析
接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语
法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文
无关语法(Context-free Grammar)的分析手段,如果你对上下文无关语
法及下推自动机很熟悉,那么应该很好理解。否则,可以参考一些计算
理论的资料,一般都会有很详细的介绍。此处不再赘述。简单地讲,由
语法分析器生成的语法树就是以表达式(Expression)为节点的树。我
们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式
的组合。上面例子中的语句就是一个由赋值表达式、加法表达式、乘法
表达式、数组表达式、括号表达式组成的复杂语句。它在经过语法分析
器以后形成如图2-3所示的语法树。
图2-3 语法树 …
从图2-3中我们可以看到,整个语句被看作是一个赋值表达式;赋值表
达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达
式又由两个符号表达式组成,等等。符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以它们通常作为整个语法树的叶节
点。在语法分析的同时,很多运算符号的优先级和含义也被确定下来
了。比如乘法表达式的优先级比加法高,而圆括号表达式的优先级比乘
法高,等等。另外有些符号具有多重含义,比如星号在C语言中可以表
示乘法表达式,也可以表示对指针取内容的表达式,所以语法分析阶段
必须对这些内容进行区分。如果出现了表达式不合法,比如各种括号不
匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。
正如前面词法分析有lex一样,语法分析也有一个现成的工具叫做
yacc(Yet Another Compiler Compiler)。它也像lex一样,可以根据用户
给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树。
对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每
个编译器编写一个语法分析器,所以它又被称为“编译器编译器
(Compiler Compiler)”。
2.2.3 语义分析
接下来进行的是语义分析,由语义分析器(Semantic Analyzer)来完
成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了
解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没
有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个
浮点数做乘法运算是否合法等。编译器所能分析的语义是静态语义
(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与
之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的
语义。
静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型
的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转
换的过程,语义分析过程中需要完成这个步骤。比如将一个浮点型赋值
给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会
报错。动态语义一般指在运行期出现的语义相关的问题,比如将0作为
除数是一个运行期语义错误。
经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有
些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节
点。上面描述的语法树在经过语义分析阶段以后成为如图2-4所示的形
式。 图2-4 标识语义后的
语法树 …
可以看到,每个表达式(包括符号和数字)都被标识了类型。我们的例
子中几乎所有的表达式都是整型的,所以无须做转换,整个分析过程很
顺利。语义分析器还对符号表里的符号类型也做了更新。
2.2.4 中间语言生成
现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过
程。我们这里所描述的源码级优化器(Source Code Optimizer)在不同
编译器中可能会有不同的定义或有一些其他的差异。源代码级优化器会
在源代码级别进行优化,在上例中,细心的读者可能已经发现,(2 +
6)这个表达式可以被优化掉,因为它的值在编译期就可以被确定。类
似的还有很多其他复杂的优化过程,我们在这里就不详细描述了。经过
优化的语法树如图2-5所示。 图2-5 优化后的语法树…
我们看到(2 + 6)这个表达式被优化成8。其实直接在语法树上作优化
比较困难,所以源代码优化器往往将整个语法树转换成中间代码
(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近
目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不
包含数据的尺寸、变量地址和寄存器的名字等。中间代码有很多种类
型,在不同的编译器中有着不同的形式,比较常见的有:三地址码
(Three-address Code)和P-代码(P-Code)。我们就拿最常见的三地址
码来作为例子,最基本的三地址码是这样的:
x = y op z
这个三地址码表示将变量y和z进行op操作以后,赋值给x。这里op操作
可以是算数运算,比如加减乘除等,也可以是其他任何可以应用到y和z
的操作。三地址码也得名于此,因为一个三地址码语句里面有三个变量
地址。我们上面的例子中的语法树可以被翻译成三地址码后是这样的:
t1 = 2 + 6
t2 = index + 4
t3 = t2 t1
array[index] = t3
我们可以看到,为了使所有的操作都符合三地址码形式,这里利用了几
个临时变量:t1、t2和t3。在三地址码的基础上进行优化时,优化程序会将2+6的结果计算出来,得到t1 = 8。然后将后面代码中的t1替换成数
字8。还可以省去一个临时变量t3,因为t2可以重复利用。经过优化以后
的代码如下:
t2 = index + 4
t2 = t2 8
array[index] = t2
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器
无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对
于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个
前端和针对不同机器平台的数个后端。
2.2.5 目标代码生成与优化
源代码级优化器产生中间代码标志着下面的过程都属于编辑器后端。编
译器后端主要包括代码生成器(Code Generator)和目标代码优化器
(Target Code Optimizer)。让我们先来看看代码生成器。代码生成器
将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为
不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型
等。对于上面例子中的中间代码,代码生成器可能会生成下面的代码序
列(我们用x86的汇编语言来表示,并且假设index的类型为int型,array
的类型为int型数组):
movl index, %ecx ; value of index to ecx
addl 4, %ecx ; ecx = ecx + 4
mull 8, %ecx ; ecx = ecx 8
movl index, %eax ; value of index to eax
movl %ecx, array(,eax,4) ; array[index] = ecx
最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址
方式、使用位移来代替乘法运算、删除多余的指令等。上面的例子中,乘法由一条相对复杂的基址比例变址寻址(Base Index Scale
Addressing)的lea指令完成,随后由一条mov指令完成最后的赋值操
作,这条mov指令的寻址方式与lea是一样的。
movl index, %edxleal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
现代的编译器有着异常复杂的结构,这是因为现代高级编程语言本身非
常地复杂,比如C++语言的定义就极为复杂,至今没有一个编译器能够
完整支持C++语言标准所规定的所有语言特性。另外现代的计算机CPU
相当地复杂,CPU本身采用了诸如流水线、多发射、超标量等诸多复杂
的特性,为了支持这些特性,编译器的机器指令优化过程也变得十分复
杂。使得编译过程更为复杂的是有些编译器支持多种硬件平台,即允许
编译器编译出多种目标CPU的代码。比如著名的GCC编译器就几乎支持
所有CPU平台,这也导致了编译器的指令生成过程更为复杂。
经过这些扫描、语法分析、语义分析、源代码优化、代码生成和目标代
码优化,编译器忙活了这么多个步骤以后,源代码终于被编译成了目标
代码。但是这个目标代码中有一个问题是:index和array的地址还没有
确定。如果我们要把目标代码使用汇编器编译成真正能够在机器上执行
的指令,那么index和array的地址应该从哪儿得到呢?如果index和array
定义在跟上面的源代码同一个编译单元里面,那么编译器可以为index
和array分配空间,确定它们的地址;那如果是定义在其他的程序模块
呢?
这个看似简单的问题引出了我们一个很大的话题:目标代码中有变量定
义在其他模块,该怎么办?事实上,定义其他模块的全局变量和函数在
最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编
译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接
器最终将这些目标文件链接起来形成可执行文件。让我们带着这个问
题,走进链接的世界。
2.3 链接器年龄比编译器长
很久很久以前,在一个非常遥远的银河系……人们编写程序时,将所有
源代码都写在同一个文件中,发展到后来一个程序源代码的文件长达数
百万行,以至于这个地方的人类已经没有能力维护这个程序了。人们开
始寻找新的办法,一场新的软件开发革命即将爆发……
为了更好地理解计算机程序的编译和链接的过程,我们简单地回顾计算
机程序开发的历史一定会非常有益。计算机的程序开发并非从一开始就有着这么复杂的自动化编译、链接过程。原始的链接概念远在高级程序
语言发明之前就已经存在了,在最开始的时候,程序员(当时程序员的
概念应该跟现在相差很大了)先把一个程序在纸上写好,当然当时没有
很高级的语言,用的都是机器语言,甚至连汇编语言都没有。当程序须
要被运行时,程序员人工将他写的程序写入到存储设备上,最原始的存
储设备之一就是纸带,即在纸带上打相应的孔。
这个过程我们可以通过图2-6来看到,假设有一种计算机,它的每条指
令是1个字节,也就是8位。我们假设有一种跳转指令,它的高4位是
0001,表示这是一条跳转指令;低4
图2-6 纸带与机器指
令 …
位存放的是跳转目的地的绝对地址。我们可以从图2-6中看到,这个程
序的第一条指令就是一条跳转指令,它的目的地址是第5条指令(注
意,第5条指令的绝对地址是4)。至于0和1怎么映射到纸带上,这个应
该很容易理解,比如我们可以规定纸带上每行有8个孔位,每个孔位代
表一位,穿孔表示0,未穿孔表示1。
现在问题来了,程序并不是一写好就永远不变化的,它可能会经常被修
改。比如我们在第1条指令之后、第5条指令之前插入了一条或多条指
令,那么第5条指令及后面的指令的位置将会相应地往后移动,原先第
一条指令的低4位的数字将需要相应地调整。在这个过程中,程序员需
要人工重新计算每个子程序或跳转的目标地址。当程序修改的时候,这
些位置都要重新计算,十分繁琐又耗时,并且很容易出错。这种重新计
算各个目标的地址过程被叫做重定位(Relocation)。
如果我们有多条纸带的程序,这些程序之间可能会有类似的跨纸带之间的跳转,这种程序经常修改导致跳转目标地址变化在程序拥有多个模块
的时候更为严重。人工绑定进行指令的修正以确保所有的跳转目标地址
都正确,在程序规模越来越大以后变得越来越复杂和繁琐。
没办法,这种黑暗的程序员生活是没有办法容忍的。先驱者发明了汇编
语言,这相比机器语言来说是个很大的进步。汇编语言使用接近人类的
各种符号和标记来帮助记忆,比如指令采用两个或三个字母的缩写,记
住“jmp”比记住0001XXXX是跳转(jump)指令容易得多了;汇编语言
还可以使用符号来标记位置,比如一个符号“divide”表示一个除法子程
序的起始地址,比记住从某个位置开始的第几条指令是除法子程序方便
得多。最重要的是,这种符号的方法使得人们从具体的指令地址中逐步
解放出来。比如前面纸带程序中,我们把刚开始第5条指令开始的子程
序命名为“foo”,那么第一条指令的汇编就是:
jmp foo
当然人们可以使用这种符号命名子程序或跳转目标以后,不管这
个“foo”之前插入或减少了多少条指令导致“foo”目标地址发生了什么变
化,汇编器在每次汇编程序的时候会重新计算“foo”这个符号的地址,然后把所有引用到“foo”的指令修正到这个正确的地址。整个过程不需
要人工参与,对于一个有成百上千个类似的符号的程序,程序员终于摆
脱了这种低级的繁琐的调整地址的工作,用一句政治口号来说叫做“极
大地解放了生产力”。符号(Symbol)这个概念随着汇编语言的普及迅
速被使用,它用来表示一个地址,这个地址可能是一段子程序(后来发
展成函数)的起始地址,也可以是一个变量的起始地址。
有了汇编语言以后,生产力大大提高了,随之而来的是软件的规模也开
始日渐庞大,这时程序的代码量也已经开始快速地膨胀,导致人们要开
始考虑将不同功能的代码以一定的方式组织起来,使得更加容易阅读和
理解,以便于日后修改和重复使用。自然而然,人们开始将代码按照功
能或性质划分,分别形成不同的功能模块,不同的模块之间按照层次结
构或其他结构来组织。这个在现代的软件源代码组织中很常见,比如在
C语言中,最小的单位是变量和函数,若干个变量和函数组成一个模
块,存放在一个“.c”的源代码文件里,然后这些源代码文件按照目录结
构来组织。在比较高级的语言中,如Java中,每个类是一个基本的模
块,若干个类模块组成一个包(Package),若干个包组合成一个程
序。在现代软件开发过程中,软件的规模往往都很大,动辄数百万行代码,如果都放在一个模块肯定无法想象。所以现代的大型软件往往拥有成千
上万个模块,这些模块之间相互依赖又相对独立。这种按照层次化及模
块化存储和组织源代码有很多好处,比如代码更容易阅读、理解、重
用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整
个程序等。
在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一
个单一的程序是须解决的问题。模块之间如何组合的问题可以归结为模
块之间如何通信的问题,最常见的属于静态语言的CC++模块之间通信
有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访
问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地
址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引
用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区
域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合
(见图2-7)。这个模块的拼接过程就是本书的一个主题:链接
(Linking)。
图2-7 模块间拼合 …
这种基于符号的模块化的一个直接结果是链接过程在整个程序开发中变
得十分重要和突出。我们在本书的后面将可以看到链接器如何将这些编
译后的模块链接到一起,最终产生一个可以执行的程序。
2.4 模块拼装——静态链接
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂
的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个
突破的目的。一个复杂的软件也如此,人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接
(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处
理好,使得各个模块之间能够正确地衔接。链接器所要做的工作其实跟
前面所描述的“程序员人工调整地址”本质上没什么两样,只不过现代的
高级语言的诸多特性和功能,使得编译器、链接器更为复杂,功能更为
强大,但从原理上来讲,它的工作无非就是把一些指令对其他符号地址
的引用加以修正。链接过程主要包括了地址和空间分配(Address and
Storage Allocation)、符号决议(Symbol Resolution)和重定位
(Relocation)等这些步骤。
符号决议有时候也被叫做符号绑定(Symbol Binding)、名称绑定
(Name Binding)、名称决议(Name Resolution),甚至还有叫做地址
绑定(Address Binding)、指令绑定(Instruction Binding)的,大体上
它们的意思都一样,但从细节角度来区分,它们之间还是存在一定区别
的,比如“决议”更倾向于静态链接,而“绑定”更倾向于动态链接,即它
们所使用的范围不一样。在静态链接,我们将统一称为符号决议。
最基本的静态链接过程如图2-8所示。每个模块的源代码文件(如.c)文
件经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库(Library)一起链接图2-8 链接过程 …
形成最终可执行文件。而最常见的库就是运行时库(Runtime
Library),它是支持程序运行的基本函数的集合。库其实是一组目标文
件的包,就是一些最常用的代码编译成目标文件后打包存放。关于库本
书的后面还会再详细分析。
我们认为对于Object文件没有一个很合适的中文名称,把它叫做中间目
标文件比较合适,简称为目标文件,所以本书后面的内容都将称Object
文件为目标文件,很多时候我们也把目标文件称为模块。
现代的编译和链接过程也并非想象中的那么复杂,它还是一个比较容易
理解的概念。比如我们在程序模块main.c中使用另外一个模块func.c中的
函数foo。我们在main.c模块中每一处调用foo的时候都必须确切知道
foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编
译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo
的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的
目标地址修正。如果没有链接器,须要我们手工把每个调用foo的指令
进行修正,则填入正确的foo函数地址。当func.c模块被重新编译,foo函
数的地址有可能改变时,那么我们在main.c中所有使用到foo的地址的指
令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。使用链接
器,你可以直接引用其他模块的函数和全局变量而无须知道它们的地
址,因为链接器在链接的时候,会根据你所引用的符号 foo,自动去相
应的func.c模块查找foo的地址,然后将main.c模块中所有引用到foo的指
令重新修正,让它们的目标地址为真正的foo函数的地址。这就是静态
链接的最基本的过程和作用。
在链接过程中,对其他定义在目标文件中的函数调用的指令须要被重新
调整,对使用其他定义在其他目标文件的变量来说,也存在同样的问
题。让我们结合具体的CPU指令来了解这个过程。假设我们有个全局变
量叫做var,它在目标文件A里面。我们在目标文件B里面要访问这个全
局变量,比如我们在目标文件B里面有这么一条指令:
movl 0x2a, var
这条指令就是给这个var变量赋值0x2a,相当于C语言里面的语句var =
42。然后我们编译目标文件B,得到这条指令机器码,如图2-9所示。图2-9 传送指令 …
由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所
以编译器在没法确定地址的情况下,将这条mov指令的目标地址置为
0,等待链接器在将目标文件A和B链接起来的时候再将其修正。我们假
设A和B链接后,变量var的地址确定下来为0x1000,那么链接器将会把
这个指令的目标地址部分修改成0x10000。这个地址修正的过程也被叫
做重定位(Relocation),每个要被修正的地方叫一个重定位入口
(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址
引用的位置“打补丁”,使它们指向正确的地址。
2.5 本章小结
在这一章中,我们首先回顾了从程序源代码到最终可执行文件的4个步
骤:预编译、编译、汇编、链接,分析了它们的作用及相互之间的联系,IDE集成开发工具和编译器默认的命令通常将这些步骤合并成一
步,使得我们通常很少关注这些步骤。
我们还详细回顾了上面这4个步骤中的主要部分,即编译步骤。介绍了
编译器将C程序源代码转变成汇编代码的若干个步骤:词法分析、语法
分析、语义分析、中间代码生成、目标代码生成与优化。最后我们介绍
了链接的历史和静态链接的一系列基本概念:重定位、符号、符号决
议、目标文件、库、运行库等概念。
第3章 目标文件里有什么
3.1 目标文件的格式
3.2 目标文件是什么样的
3.3 挖掘SimpleSection.o
3.4 ELF文件结构描述
3.5 链接的接口——符号
3.6 调试信息
3.7 本章小结
编译器编译源代码后生成的文件叫做目标文件,那么目标文件里面到底
存放的是什么呢?或者我们的源代码在经过编译以后是怎么存储的?我
们将在这一节剥开目标文件的层层外壳,去探索它最本质的内容。
目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有
经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它
本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构
上稍有不同。
可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面。了
解它的结构并深入剖析它对于认识系统、了解背后的机理大有好处。
3.1 目标文件的格式现在PC平台流行的可执行文件格式(Executable)主要是Windows下的
PE(Portable Executable)和Linux的ELF(Executable Linkable
Format),它们都是COFF(Common file format)格式的变种。目标文
件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和
Linux下的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执
行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文
件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行
文件看成是一种类型的文件,在Windows下,我们可以统称它们为PE-
COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。其他不
太常见的可执行文件格式还有IntelMicrosoft的OMF(Object Module
Format)、Unix a.out格式和MS-DOS .COM格式等。
不光是可执行文件(Windows的.exe和Linux下的ELF可执行文件)按照
可执行文件格式存储。动态链接库(DLL,Dynamic Linking Library)
(Windows的.dll和Linux的.so)及静态链接库(Static Linking Library)
(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。它们
在Windows下都按照PE-COFF格式存储,Linux下按照ELF格式存储。静
态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再
加上一些索引,你可以简单地把它理解为一个包含有很多目标文件的文
件包。ELF文件标准里面把系统中采用ELF格式的文件归为如表3-1所列
举的4类。表3-1 …
我们可以在Linux下使用file命令来查看相应的文件格式,上面几种文件
在file命令下会显示出相应的类型:
file foobar.o
foobar.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
file binbash
binbash: ELF 32-
bit LSB executable, Intel 80386, version 1 (SYSV), for GNULinux 2.6.8, dynamically linked (uses shared libs), stripped
file libld-2.6.1.so
liblibc-2.6.1.so: ELF 32-
bit LSB shared object, Intel 80386, version 1 (SYSV), for GNULinux 2.6.8, stripped
目标文件与可执行文件格式的小历史目标文件与可执行文件格式跟操作系统和编译器密切相关,所以不同的
系统平台下会有不同的格式,但这些格式又大同小异,目标文件格式与
可执行文件格式的历史几乎是操作系统的发展史。
COFF是由Unix System V Release 3首先提出并且使用的格式规范,后来
微软公司基于COFF格式,制定了PE格式标准,并将其用于当时的
Windows NT系统。System V Release 4在COFF的基础上引入了ELF格
式,目前流行的Linux系统也以ELF作为基本可执行文件格式。这也就是
为什么目前PE和ELF如此相似的主要原因,因为它们都是源于同一种可
执行文件格式COFF。
Unix最早的可执行文件格式为a.out格式,它的设计非常地简单,以至于
后来共享库这个概念出现的时候,a.out格式就变得捉襟见肘了。于是人
们设计了COFF格式来解决这些问题,这个设计非常通用,以至于COFF
的继承者到目前还在被广泛地使用。
COFF的主要贡献是在目标文件里面引入了“段”的机制,不同的目标文
件可以拥有不同数量及不同类型的“段”。另外,它还定义了调试数据格
式。
下文的剖析我们以ELF结构为主。然后会专门分析PE-COFF文件结构,并对比其与ELF的异同。
3.2 目标文件是什么样的
我们大概能猜到,目标文件中的内容至少有编译后的机器指令代码、数
据。没错,除了这些内容以外,目标文件中还包括了链接时所须要的一
些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息
按不同的属性,以“节”(Section)的形式存储,有时候也
叫“段”(Segment),在一般情况下,它们都表示一个一定长度的区
域,基本上不加以区别,唯一的区别是在ELF的链接视图和装载视图的
时候,后面会专门提到。在本书中,默认情况下统一将它们称为“段”。
程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有“.code”或“.text”;全局变量和局部静态变量数据经
常放在数据段(Data Section),数据段的一般名字都叫“.data”。让我们
来看一个简单的程序被编译成目标文件后的结构,如图3-1所示。图3-1 程序与目标文件 …
假设图3-1的可执行文件(目标文件)的格式是ELF,从图中可以看到,ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括
文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行
文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表
(Section Table),段表其实是一个描述文件中各个段的数组。段表描
述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保
存的就是程序的指令,数据段保存的就是程序的静态变量等。
对照图3-1来看,一般C语言的编译后执行语句都编译成机器代码,保存
在.text段;已初始化的全局变量和局部静态变量都保存在. data段;未初
始化的全局变量和局部静态变量一般放在一个叫.“bss”的段里。我们知
道未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以
被放在.data段的,但是因为它们都是0,所以为它们在.data段分配空间
并且存放数据0是没有必要的。程序运行的时候它们的确是要占内存空
间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变
量的大小总和,记为.bss段。所以.bss段只是为未初始化的全局变量和局
部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空
间。
BSS历史
BSS(Block Started by Symbol)这个词最初是UA-SAP汇编器(United
Aircraft Symbolic Assembly Program)中的一个伪指令,用于为符号预留
一块内存空间。该汇编器由美国联合航空公司于20世纪50年代中期为
IBM 704大型机所开发。
后来BSS这个词被作为关键字引入到了IBM 709和709094机型上的标准
汇编器FAP(Fortran Assembly Program),用于定义符号并且为该符号
预留给定数量的未初始化空间。
Unix FAQ section 1.3(http:www.faqs.orgfaqsunix-faqfaqpart1section-
3.html)里面有Unix和C语言之父Dennis Rithcie对BSS这个词由来的解
释。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数
据。代码段属于程序指令,而数据段和.bss段属于程序数据。
很多人可能会有疑问:为什么要那么麻烦,把程序的指令和数据的存放
分开?混杂地放在一个段里面不是更加简单?其实数据和指令分段的好
处有很多。主要有如下几个方面。
一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。
由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这
样可以防止程序的指令被有意或无意地改写。
另外一方面是对于现代的CPU来说,它们有着极为强大的缓存
(Cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序
必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的
局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所
以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程
序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程
序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读
数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属
于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程
私有的。不要小看这个共享指令的概念,它在现代的操作系统里面占据
了极为重要的地位,特别是在有动态链接的系统中,可以节省大量的内
存。比如我们常用的Windows Internet Explorer 7.0运行起来以后,它的
总虚存空间为112 844 KB,它的私有部分数据为15 944 KB,即有96 900
KB的空间是共享部分(数据来源见图3-2)。如果系统中运行了数百个
进程,可以想象共享的方法来节省大量空间。关于内存共享的更为深入
的内容我们将在装载这一章探讨。 图3-2 Process Explorer
下查看进程IExplorer.exe的进程信息 …
3.3 挖掘SimpleSection.o
前面对于目标文件只是作了概念上的阐述,如果不彻底深入目标文件的
具体细节,相信这样的分析也只是泛泛而谈,没有真正深入理解的效
果。就像知道TCPIP协议是基于包的结构,但是从来却没有看到过包的
结构是怎样的,包的头部有哪些内容?目标地址和源地址是怎么存放
的?如果不了解这些,那么对于TCPIP的了解是粗略的,不够细致的。
很多问题其实在表面上看似很简单,其实深入内部会发现很多鲜为人知
的秘密,或者发现以前自己认为理所当然的东西居然是错误的,或者是
有偏差的。对于系统软件也是如此,不了解ELF文件的结构细节就像学
习了TCPIP网络没有了解IP包头的结构一样。本节后面的内容就是以
ELF目标文件格式作为例子,彻底深入剖析目标文件,争取不放过任何
一个字节。真正了不起的程序员对自己的程序的每一个字节都了如指掌。
——佚名
我们就以前面提到过的SimpleSection.c编译出来的目标文件作为分析对
象,这个程序是经过精心挑选的,具有一定的代表性而又不至于过于繁
琐和复杂。在接下来所进行的一系列编译、链接和相关的实验过程中,我们将会用到第1章所提到过的工具套件,比如GCC编译器、binutils等
工具,如果你忘了这些工具怎么使用,那么在阅读过程中可以再回去参
考本书第1部分的内容。图3-1中的程序代码如清单3-1所示。
清单3-1
SimpleSection.c
Linux:
gcc -c SimpleSection.c
Windows:
cl SimpleSection.c c Za
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;
}
如不加说明,则以下所分析的都是32位Intel x86平台下的ELF文件格
式。
我们使用GCC来编译这个文件(参数 -c 表示只编译不链接):
gcc –c SimpleSection.c
我们得到了一个1 104字节(该文件大小可能会因为编译器版本以及机
器平台不同而变化)的SimpleSection.o目标文件。我们可以使用binutils
的工具objdump来查看object内部的结构,这个工具在第1部分已经介绍
过了,它可以用来查看各种目标文件的结构和内容。运行以下命令:
objdump -h SimpleSection.o
SimpleSection.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000005b 00000000 00000000 00000034 22
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 00000000 00000000 00000090 ......
链接、装载与库
作者 俞甲子 石凡 潘爱民
类别 图书 非虚构
出版社 电子工业出版社
出版日
期
2009.4
ISBN 7121085119
提供方 电子工业出版社
标签
计算机编程程序员程序设计系统结构双十一专题计算机
与互联网
这本书主要介绍系统软件的运行机制和原理,涉及在Windows和Linux
两个系统平台上,一个应用程序在编译、链接和运行时刻所发生的各种
事项,包括:代码指令是如何保存的,库文件如何与应用程序代码静态
链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,CC++运行库的工作原理,以及操作系统提供的系统服务是如何被调用
的。每个技术专题都配备了大量图、表和代码实例,力求将复杂的机制
以简洁的形式表达出来。本书最后还提供了一个小巧且跨平台的
CC++运行库MiniCRT,综合展示了与运行库相关的各种技术。
对装载、链接和库进行了深入浅出的剖析,并且辅以大量的例子和图
表,可以作为计算机软件专业和其他相关专业大学本科高年级学生深入
学习系统软件的参考书。同时,还可作为各行业从事软件开发的工程
师、研究人员以及其他对系统软件实现机制和技术感兴趣者的自学教
材。
作者:俞甲子、石凡、潘爱民
作者访谈录
针对俞甲子、石凡和潘爱民三位的新书《程序员的自我修养——链接、装载与库》的出版,博文视点对俞甲子进行了专访,现将博文的编辑与
俞甲子的访谈对话整理成文,以飨读者。
博文编辑:
甲子,你好!能否向读者介绍你是如何对操作系统的底层机制和运行原
理产生兴趣的?
俞甲子:
很大程度上是因为性格决定的吧,因为我是一个喜欢对技术问题寻根究
底的人,不满足于仅仅了解一个技术的表面,而是希望能通过层层深入
地挖掘,找出它背后最关键最核心的机理。我相信很多计算机技术都是
相通的,它们的核心思想相对是稳定不变的。经常听很多人谈起,IT技
术日新月异,其实真正核心的东西数十年都没怎么变化,变化的仅仅是
它们外在的表现,大体也是换汤不换药吧。
为了了解操作系统内核及装载、链接等这些关键的技术,我曾经自己从
头写了一个很小的内核、装载器及一个简单的运行库,它们组成了一个
可以完整运行在PC上的支持多进程、多线程的操作系统环境,并且支
持虚拟存储、简单的文件系统、网络、鼠标键盘等,前后加起来花了两
年多时间,大约有数万行代码,编译器和链接器使用的是GCC和LD。
当然,如果继续写下去,可以让它的功能变得更加完整,但是我停止了
对它的继续维护,因为我认为通过这个雏形系统,我已经了解了其背后
的机理,如果再继续写下去更多的只是重复性的工作,因为现在已经有
了很多很优秀的内核、装载和链接的相关软件和标准。
虽然我在这个系统上花费了很多时间和精力,却没有获得什么直接的收
益,也没有让我跟上最新的技术潮流,但是它带给我的间接收获却是无
法言表的,它使我在后来学习其他技术的时候能够很快地触类旁通、自
下而上地去理解整个系统,往往能够理解得更加深刻更加透彻。
博文编辑:
介绍链接、装载与库原理的资料非常少,你在自己钻研的过程中,遇到
的最大困难是什么?
俞甲子:当然相关资料很少会给我们带来很多的困难和挑战,而且相关的源代码
在经过多年的发展和锤炼后,变得非常注重性能和效率,而很少考虑可
读性,这使得通过挖掘源代码理解机制变得更为困难。这些代码很多都
是相关领域的黑客高手写的,他们对系统机制的了解已经到了很深刻的
地步,一小段代码会用尽系统的各种机制和方法,经常让人看得不知所
云。比如系统库在不同的链接和装载方式下对C++全局对象的构造和析
构,就异常复杂。整个流程来回曲折,加上有些代码已经遗弃,还会造
成误解。Glibc这种支持数十种平台的系统还要考虑到各个系统的通性
和个性,更使整个过程雪上加霜。其实理解还不是最大的困难,最大的
困难是理解了这个复杂而又晦涩的机制和过程,如何将它们尽量地简
化,从中取舍,摈弃所有不必要的内容,再将它剥离出来后组织成尽量
深入浅出层层引导的文字和图表,这才是最大的挑战。
博文编辑:
在自学的过程中,一定有许多令你得意或开心的事,可不可以分享一
二?
俞甲子:
在这个过程中,最烦恼的事莫过于一个困扰了你很久的问题,通过各种
办法,包括阅读源代码等还是无法理解或无法解释某个程序现象。忽然
有一天某个灵感突现,回头再仔细阅读代码,紧接着马上试验一下,果
真如此!大有拨云见日、豁然开朗的感觉,这应该是最开心的事吧。
博文编辑:
你现在从事的工作和系统底层结合紧密吗?在系统运行机制上的积累对
目前的工作有帮助吗?
俞甲子:
我目前从事的工作跟系统底层关系不是很大,现在最常用的都是Web前
端、MySQL数据库等这些应用层面的系统。虽然不是直接与系统底层
打交道,但是之前的积累无时无刻不在帮助我去深入理解应用开发。比
如MySQL系统的内存和文件系统的优化,如果对操作系统的虚拟存储
和文件系统机制没有深入了解,那么可能只能在配置参数上做一些“猜
测”性质的调整,不断地尝试各种参数,或者参考网络上别人提供的配置参数,但不一定适合自己的应用情况。了解虚存如何运作,进程地址
空间的分布等,将会对应用的优化甚至是构架设计上都会有更高层次的
俯视。
博文编辑:
对知识的渴求,对未知世界的好奇是人类的天性。但这种天性也需要引
导,小心保护,否则就可能会丧失。读书是一种很好的保护途径,可不
可以向读者推荐几本对你个人成长影响最大的书?
俞甲子:
如果是推荐非技术类的书籍,我应该不是很在行。在这里向大家推荐几
本我读过的,并且跟本书主题相关的书籍吧。
《Linkers and Loaders 》,John R. Levine。这本书基本上是链接和装载
方面最为完整和权威的理论著作了,但是内容有些偏旧,并且有些晦
涩。
《Intel? 64 and IA-32 Architectures Software Developer’s Manuals》,Intel
官方的x64和x86CPU的技术手册,总共分3卷,另外还有几本优化手
册,这些手册不适合通读,但强烈建议阅读其中的介绍性章节,并且手
边能够常备一份,以便需要时查阅,查阅网址
http:www.intel.comproductsprocessormanuals。
《Linux内核源代码情景分析》,毛德操,胡希明。这部书分为上下两
卷,总共近2000页,虽然出版年份较早(2001年出版),而且是基于
Linux 2.4内核的,但是它对很多细节的描述非常到位,比很多Linux内
核的书籍要详细,值得一看。
《深入理解计算机系统》(Computer Systems A Programmer’s
Perspective,Randal E. Bryant和David O’Hallaron著)。这本书对整个计
算机软硬件体系结构进行了深入浅出的介绍,是理解系统底层不可多得
的好书,强烈推荐!
《深入解析Windows操作系统,第4版——Microsoft Windows Server
2003Windows XPWindows 2000技术内幕》,Mark
E.Russinovich(著),潘爱民(译)。这本书是理解Windows内核最好的选择,至少我没有看到任何一本描述关于Windows内核的书能与它相
媲美。
《Advanced Programming in the UNIX Environment,Second Edition》,W.Richard Stevens,Stephen A.Rago。这本书被誉为UNIX程序设计
的“圣经”,也是了解NIX系统内核,运行库和执行环境的很好选择。
序言一
两年前,甲子跟我提起,他在考虑写一本讲述计算机程序基本工作原理
的书,由于代码背后的许多细节现在难以找到完整而又实用的资料,因
此,系统性地讲述这些技术要素一定非常有意义。这是我非常感兴趣的
话题,因为最近几年来,我每次给学生讲课或作技术报告时,经常会提
到程序背后的一些细节知识,而当有人请我推荐一些参考资料时,我很
难想得出有什么恰当的参考书可供学习。我自己也曾想过要写一点这方
面的书,只是一直下不了决心做这件事情。甲子的提议让我意识到,写
这样一本书的机会来了。于是,我们认真规划了书的选题。按我的建
议,这应该是三卷本的书,每卷独立,合起来成一体系。第一卷是基础
篇,介绍程序的基本运行过程,即是您现在看到的这本书。其他两卷还
需要时日和机缘。
在过去两年中,我曾经以“Inside Windows Programs”为题在多所高校作
过报告,旨在介绍Windows程序背后的一些支撑技术。对于正在学习计
算机或软件专业的学生,或者正在从事软件开发的工程师们,我认为理
解这些支撑技术是很有必要的。试想,即使一个简单的“Hello
World!”程序,也依赖于背后的输入输出库(或流库)及系统提供的模
块,这种依赖性已经成为现代软件在操作系统环境下运行的一个必要条
件。然而,有关这些支撑技术的系统性资料却少而又少,虽然Internet上
并不缺乏任何一方面的细节信息,但是,能将程序的编译和运行过程所
涉及的各种技术全面地串连起来介绍的,却尚未有先例。
甲子曾经在2006年夏天跟我实习过两个月,他帮我搭建了一个在
Windows已有体系结构下将交换空间重定向到远程机器物理内存的原型
系统。完成这一系统并非易事,而且甲子事前并无Windows内核编程经
验,但是,他凭借扎实的计算机系统软件功底,成功地打通了从页面错
误(page fault)异常例程到远程机器内存管理器之间的数据通路。在这
一段实习经历中,我不仅看到了他驾驭代码和系统的能力,也感受到他做事认真负责的态度。因此,当他提出要写一本介绍程序基础的书时,我认为他是非常合适的人选。考虑到写书的艰巨性,他推荐石凡同学加
入进来,这才有了我们三个人的组合。我原先担心写作的进度,毕竟写
这样一本书需要大量的时间投入。幸运的是,在甲子和石凡的不懈努力
下,这本书终于面市了。
本书讲解的内容,涉及在Windows和Linux两个系统平台上,一个应用
程序在编译、链接和运行时刻所发生的各种事项,包括:代码指令是如
何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载
到内存中并开始运行,动态链接如何实现,CC++运行库如何工作,以
及操作系统提供的系统服务是如何被调用的。每个技术专题都配备了大
量图示和代码实例,力求将复杂的机制以简洁的形式表达出来。本书最
后还提供了一个小巧且跨平台的CC++运行库MiniCRT,综合展示了与
运行库相关的各种技术。
关于写作这本书的功劳,我不敢掠美。在创作之初,包括拟定提纲及甄
选内容方面,我跟甲子有过认真而细致的讨论;在写作过程中,我对甲
子和石凡的初稿提出过一些建议,尤其在表述方面,同时我也协助他们
与编辑进行了沟通和交流。对于正文的内容,我并无实质性的贡献,但
基于我对甲子和石凡两位年轻人的了解,我相信他们自身的技术实践功
底,以及足够的技术阐释能力。我期待这本书能够真正地提升程序员的
自我修养,让程序员总是生活在“知其然,更知其所以然”的代码曼妙
中。
最后,我要感谢这本书的四位编辑,他们是何艳、方舟、刘铁锋和陈元
玉,谢谢他们为这本书付出的努力。还要感谢博文视点团队的负责人周
筠女士,谢谢她给予两位年轻作者的扶持和关爱。
潘爱民
2009年2月于北京微软
序言二
两年前,我在浙江大学的一著名BBS的C++板块上担任版主,而俞甲子
则是板上的资深版友(以及前版主)。那时候我对链接装载、运行库等
内容比较感兴趣,自己摸索着在博客上写了一篇关于链接的入门文章,而这就是一切的开始。
我猜想俞甲子可能对写这么一本书早有想法,看到我的文章正好找到了
同路人。他找到了我和潘爱民老师,我们一拍即合,就开始了这长达两
年的写作历程。考虑到当时俞甲子已经在链接部分有了相当的积累,因
此我不得不放弃最有兴趣的一部分转而在运行环境上做文章。我把glibc
和msvcrt的源代码翻了个底朝天,了解到了许多平时不可能接触到的内
幕和技术细节。事实上,这基本是一个现学现卖的过程,我一边学习着
新的知识,一边把新知识组织整理写成文字。读者在看某些章节的时
候,会发现这些章节的讲解过程就是一个源代码的挖掘过程,这实际上
也就是我的学习过程。学习研究他人的代码是枯燥而耗时的,我很高兴
能够做这样一个先行者,将我的经验写进书里,让读者能够避免重复劳
动,直接获得其中的经验和关键技术。
本书所讲的内容不是活跃在当今IT舞台上的高新技术,也不是雄踞计算
机某个领域的王牌霸主,而是默默服务于所有计算机应用的扫地僧。也
许阅读本书不能够直接在平时学习工作中的生产力上得到体现,但了解
计算机的台前幕后会对读者产生潜移默化的影响。当你的程序无法启动
的时候,你可能会在脑海里多设想一种可能性;当你的代码链接失败的
时候,你可能会更快地意识到问题的所在;当你的程序发生非法操作的
时候,你可能不至于面对微软的错误报告毫无头绪。有人总爱用“时效
性”评价当今的IT技术。仿佛一项技术的生存期就只有几年。我不能说
这样的想法是错误的,如今的技术的确在飞速地更替和发展。但是本书
所讲的技术,大多是成型在十年前,乃至二十年前,它们是整个计算机
行业技术的根本,也几乎是现在所有计算机应用的基础。在当今的计算
机技术发生根本性变革之前,这些技术还将继续存在并保持活力。
我很荣幸能够有机会和读者分享这些技术,但写作水平有限(我在语文
课上历来不是个好学生),最终在文字和结构上颇有缺憾,只能在这里
说一声抱歉。在这里要感谢我小学、初中和高中的语文老师,谢谢你们
当初对我的教导,尽管最终可能辜负了你们的希望。感谢潘老师、博文
视点的编辑及所有支持我们的朋友们,谢谢你们的帮助。最后要感谢我
的父母,没有你们,我永远不可能走到今天这一步。
石凡
2009年2月于杭州序言三
CPU体系结构、汇编、C语言(包括C++)和操作系统,永远都是编程
大师们的护身法宝,就如同少林寺的《易筋经》,是最为上乘的武功;
学会了《易筋经》,你将无所不能,任你创造武功;学会了编程“易筋
经”,大师们可以任意开发操作系统、编译器,甚至是开发一种新的程
序设计语言!
——佚名
念书的时候,作为标准的爱好技术的宅男,每天扫一遍各大高校BBS的
技术版面,基本好比一日三餐一样平常。我对计算机技术方面的口味很
杂,从汇编版到C++到Linux内核开发、Linux应用开发、游戏开发、网
络、编程语言、体系结构、移动开发、开源闭源我都会参上一脚。
我始终认为技术优劣取决于需求,与很多持有“编程语言血统论”的程序
开发者不同,我不认为C++或Java本身有什么直接可比性,或者OOP与
函数式编程谁优谁劣,我始终坚持认为作为开发者,MOP(MarketMoney Oriented Programming)才是唯一不变的编程范
式。于是我往往不参与那些技术、平台、语言教派之间的宗教战争,这
种论战基本上每周都会有,我很佩服论战各方见多识广、旁征博引、高
屋建瓴的论断,但我往往只是灌灌水调节一下思绪。相反,我很关注一
些与语言、平台等相对独立的基本的系统概念方面的问题,这些问题比
较具体,也比较实用,比如:
为什么程序是从main开始执行?
“malloc分配的空间是连续的吗?”
“PEELF文件里面存的是什么?”
“我想写一个不需要操作系统可以直接在硬件上跑的程序该怎么做?”
“目标文件是什么?链接又是什么?”
“为什么这段程序链接时报错?”
“句柄到底是什么东西?”这些问题看似很简单但实际上有很多值得深入挖掘的地方,比如第一个
问题围绕着main函数执行前后可以延伸出一大堆问题:程序入口、运行
库初始化、全局静态对象构造析构、静态和动态链接时程序的初始化
和装载等。我们把这些问题归结起来,发现主要是三个很大的而且连贯
的主题,那就是“链接、装载与库”。
事实上,现在市面上和网络上能找到的计算机技术方面的书籍和资料
中,什么都很齐全,唯独关于这三个主题的讨论十分稀缺,即使能找到
一些也是犹如残缺的典籍,不仅不完整而且很多已经过时了。关于现在
通用的Windows和Linux平台的链接、装载及PEELF文件的详细分析,实在很少见。这个领域中,最为完整、也最为权威的莫过于John R.
Levine的《Linkers Loaders》,这本书我也前前后后通读了好几遍,虽然它对链接和装载方面的描述较为完整,但是过于理论化,对于实际
的系统机制描述则过于简略。
我始终认为对于一个问题比较好的描述方式,是由一个很小很简单的问
题或示例入手,层层剥开深入挖掘,不仅探究每个机制“怎么做”,而且
要理解它们“为什么这样做”,力求深入浅出、图文并茂,尽力把每一步
细节都呈现给读者。这是我一贯的想法,也是我们在本书中努力试图达
到的效果。
第一次有想写这样一本书的念头是在2006年底,当时我正在念研一,想
起未来还有一年多漫长而又相对空闲的研究生生涯,觉得写一本这样的
书大概是比较好的“消遣活动”。于是我第一时间想到了在微软研究院实
习时的导师潘爱民老师,潘老师在写作技术书籍方面有很深的功底和丰
富的经验。我把想法告诉潘老师以后,他十分支持,于是我又找到了当
时刚好保送研究生、时间上也相对充裕的石凡,我们三个都对这个选题
十分感兴趣,可谓一拍即合。
当时也没多想,以为写书大概也就跟BBS发帖连载差不多吧。一旦写起
来才发现自己完全轻视了写书的工作量。书中的每一个章节、每一个小
段、每一个例子甚至每一个用词有时候都要斟酌很久,生怕用得不恰当
误导了读者。“误人子弟”这四个字罪名可不轻,大有推出午门斩首五遍
以儆效尤之过。写书的时间的确很仓促,虽然我们都是在读研时写的,按理说相对于已经工作的作者来讲,已经是有很多闲余的时间了,但还
是经常手忙脚乱。想到以前看书看到作者写的序里,经常使用“时间仓
促,水平有限”的话,推想作者不过是出于谦虚不免要客套一下。现在
轮到自己写序了,终于感觉到了这八个字的分量。即使到现在已近完稿,我们还是心里十分忐忑,因为还有不少地方的确写得不够完善。也
听到了很多第一批读者的反馈意见,很多建议都正中这本书的软肋,我
们也根据大家的意见又一次进行了修改,这已经是反反复复的第N次修
订了。
这本书前前后后花了两年多的时间一直没有完稿,由于截稿时间快到
了,我们才终于定稿,因为实在没有办法做到完美,只能向无限接近完
美努力。最后,我们在“著”和“编著”之间犹豫了很久,想到本书凝聚了
我们很多的心血,还是诚惶诚恐地写上了“著”字,权当给自己壮胆了。
我们也相信,本书虽然没做到完美,但是它一定会给你带来一些你以前
想看、想了解而又找不到的东西。或者以前在编程过程中困惑了你很
久,但始终没有找到解释的问题,当在本书中终于找到答案且大呼“原
来如此!”时,我们也就很欣慰了!
关于本书的书名笔者们也讨论了很久,征询过很多意见,最终还是决定
用“程序员的自我修养”作为书名,将“链接、装载与库”作为副标题。书
名源自于俄罗斯的演员斯坦尼斯拉夫斯基创作的《演员的自我修养》,作者为了写这本书前前后后修改了三十年之久,临终前才同意不再修
改,拿去出版。使用这个书名一方面是本书的内容的确不是介绍一门新
的编程语言或展示一些实用的编程技术,而是介绍程序运行背后的机制
和由来,可以看作是程序员的一种“修养”;另一方面是向斯坦尼斯拉夫
斯基致敬,向他对作品精益求精的精神致敬。
在本书的创作过程中,很多人对我们的支持和帮助难以言表。这里我要
感谢博文视点的编辑何艳、方舟、刘铁锋和陈元玉等,他们为本书付出
了很多心血;特别要感谢博文视点的周筠老师,这本书能够面世离不开
她的支持和努力。另外也要感谢浙江大学的张晓龙博士,他为本书提出
了很多建议,并且贡献了“DLL HELL”一节。
俞甲子
2009年2月于杭州
导读
你将学到什么本书将详细描述现在流行的Windows和Linux操作系统下各自的可执行
文件、目标文件格式;普通CC++程序代码如何被编译成目标文件及程
序在目标文件中如何存储;目标文件如何被链接器链接到一起,并且形
成可执行文件;目标文件在链接时符号处理、重定位和地址分配如何进
行;可执行文件如何被装载并且执行;可执行文件与进程的虚拟空间之
间如何映射;什么是动态链接,为什么要进行动态链接;Windows和
Linux如何进行动态链接及动态链接时的相关问题;什么是堆,什么是
栈;函数调用惯例;运行库,Glibc和MSVC CRT的实现分析;系统调
用与API;最后我们自己还实现了一个Mini CRT。
应当具备的基础知识
在本书中,我们尽量避免要求读者有很多的基础知识,但难免有些要
求。其中包括对CC++编程语言的基本了解、x86汇编语言基础、操作
系统基本概念及基本编程技巧和计算机系统结构的基本概念。
本书的组织
本书分为4大部分,分别如下。
第1部分 简介
第1章 温故而知新
介绍基本的背景知识,包括硬件、操作系统、线程等。
第2部分 静态链接
第2章 编译和链接
介绍编译和链接的基本概念和步骤。
第3章 目标文件里有什么
介绍COFF目标文件格式和源代码编译后如何在目标文件中存储。
第4章 静态链接
介绍静态链接与静态库链接的过程和步骤。第5章 Windows PECOFF
介绍Windows平台的目标文件和可执行文件格式。
第3部分 装载与动态链接
第6章 可执行文件的装载与进程
介绍进程的概念、进程地址空间的分布和可执行文件映射装载过程。
第7章 动态链接
以Linux下的.so共享库为基础详细分析了动态链接的过程。
第8章 Linux共享库的组织
介绍Linux下共享库文件的分布和组织。
第9章 Windows下的动态链接
介绍Windows系统下DLL动态链接机制。
第4部分 库与运行库
第10章 内存
主要介绍堆与栈,堆的分配算法,函数调用栈分布。
第11章 运行库
主要介绍运行库的概念、CC++运行库、Glibc和MSVC CRT、运行库如
何实现C++全局构造和析构及以fread库函数为例对运行库进行剖析。
第12章 系统调用与API
主要介绍Linux和Windows的系统调用及Windows 的API。
第13章 运行库实现
本章主要实现了一个支持堆、基本文件操作、格式化字符串、基本输入输出、C++ newdelete、C++ string、C++全局构造和析构的Mini CRT。
编译本书的程序
编译本书中所有的示例代码,在Windows平台下可使用Microsoft Visual
C++ 2005或2008,操作系统为Windows XP sp3。读者可以去微软的官方
网站Visual C++ 2008 Express版:
编译本书中所有的示例代码,在Windows平台下可使用Microsoft Visual
C++ 2005或2008,操作系统为Windows XP sp3。读者可以去微软的官方
网站Visual C++ 2008 Express版:
http:www.microsoft.comexpressvc
Linux下使用的GCC 4.1.2,ld版本为2.18,Glibc和ld-linux.so的版本为
2.6.1,操作系统为Ubuntu 7.04。
联系博文视点
您可以通过如下方式与本书的出版方取得联系。
读者信箱:reader@broadview.com.cn
投稿信箱:bvtougao@gmail.com
北京博文视点资讯有限公司(武汉分部)
湖北省 武汉市 洪山区 吴家湾 邮科院路特1号 湖北信息产业科技大厦
1402室
邮政编码:430074
电 话:027-87690813
传 真:027-87690595
若您希望参加博文视点的有奖读者调查,或对写作和翻译感兴趣,欢迎
您访问:http:bv.csdn.net关于本书的勘误、资源下载及博文视点的最新书讯,欢迎您访问博文视
点官方博客:http:blog.csdn.netbvbook
第1部分 简介
第1章 温故而知新
1.1 从Hello World说起
1.2 万变不离其宗
1.3 站得高,望得远
1.4 操作系统做什么
1.5 内存不够怎么办
1.6 众人拾柴火焰高
1.7 本章小结
1.1 从Hello World说起
毫无疑问,“Hello World”对于程序员来说肯定是如雷贯耳。就是这样一
个简单的程序,带领了无数的人进入了程序的世界。简单的事物背后往
往又蕴涵着复杂的机制,如果我们深入思考一个简单的“Hello World”程
序,就会发现很多问题看似很简单,但实际上我们并没有一个非常清晰
的思路;或者在我们脑海里有着模糊的印象,但真正到某些细节的时候
可能又模糊不清了。比如对于C语言编写的Hello World程序:
include
int main
{
printf(Hello World\n);
return 0;}
对于下面这些问题,你的脑子里能够马上反应出一个很清晰又很明确的
答案吗?
程序为什么要被编译器编译了之后才可以运行?
编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎么做的?
最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们
怎么存放的,怎么组织的?
include
库又是什么?它怎么实现的?
不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM),以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
Hello World程序是怎么运行起来的?操作系统是怎么装载它的?它从
哪儿开始执行,到哪儿结束?main函数之前发生了什么?main函数结束
以后又发生了什么?
如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作
系统的机器上运行Hello World需要什么?应该怎么实现?
printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够
在终端上输出字符串?
Hello World程序在运行时,它在内存中是什么样子的?
对于上面的问题,如果你确信能够非常清楚地了解里面的各个细节,并
且对其中的过程和机制都了如指掌,那么很遗憾,这本书不是为你准备
的;如果你发现对其中一些问题并不是很了解,甚至从来没有想到过一
个Hello World还能引出这么多值得思考的问题,而你又想了解它们,那
么恭喜你,这本书就是为你准备的。随着各个章节的逐步展开,我们会
从最基本的编译、静态链接到操作系统如何装载程序、动态链接及运行库和标准库的实现,甚至一些操作系统的机制,力争深入浅出地将这些
问题层层剥开,最终使得这些程序运行背后的机制形成一个非常清晰而
流畅的脉络。
在开始进入庞大而又繁琐的系统软件之前,让我们先进行热身活动,那
就是一起来回顾计算机系统的一些基本而又重要的概念。整个计算机系
统回顾过程将分为两个部分,分别是硬件部分和软件部分。本书的主要
目的不是介绍计算机系统结构,第1章的回顾只是巩固和总结计算机软
硬件体系里面几个重要的概念,这些概念在我们后面的章节中将时时伴
随着我们,失去了它们的支撑,后面的章节将会显得繁琐而又晦涩。如
果你自认为这些基本概念很简单,那么你可以大概地浏览一遍几个知识
点的标题,然后直接跳到第2章;反之,如果你觉得有些概念还不是很
清楚,甚至从来没听说过这些概念,那么请你仔细阅读相关章节,相信
这个过程对你阅读本书甚至对你深入了解计算机大有裨益。
1.2 万变不离其宗
计算机是个非常广泛的概念,大到占用数层楼的用于科学计算的超级计
算机,小到手机上的嵌入式芯片都可以被称为计算机。虽然它们的外
形、结构和性能都千差万别,但至少它们都有“计算”这个概念。在本书
里面,我们将计算机的范围限定在最为流行、使用最广泛的PC机,更
具体地讲是采用兼容x86指令集的32位CPU的个人计算机。原因很简
单:因为笔者手上目前只有这种类型的计算机可供操作和实验,不过相
信90%以上的读者也是,所以在这一点上我们很快能达成共识。其实选
择具体哪种平台并不是最关键的,虽然各种平台的软硬件差别很多,但
是本质上它们的基本概念和工作原理都是一样的,只要我们能够掌握一
种平台上的技术,那么其他的平台都是大同小异的,很轻松地可以举一
反三。所以我们相信,只有你能够深刻地理解x86平台下的系统软件背
后的机理,当有一天你需要在MIPS指令集的嵌入式平台上做开发,或
者需要为64位的Windows或Linux开发应用程序的时候,你很快就能找
到它们之间的相通之处。
撇开计算机硬件中纷繁复杂的各种设备、芯片及外围接口等,站在软件
开发者的角度看,我们只须抓住硬件的几个关键部件。对于系统程序开
发者来说,计算机多如牛毛的硬件设备中,有三个部件最为关键,它们
分别是中央处理器CPU、内存和IO控制芯片,这三个部件几乎就是计
算机的核心了;对于普通应用程序开发者来说,他们似乎除了要关心CPU以外,其他的硬件细节基本不用关心,对于一些高级平台的开发者
来说(如Java、.NET或脚本语言开发者),连CPU都不需要关心,因为
这些平台为它们提供了一个通用的抽象的计算机,他们只要关心这个抽
象的计算机就可以了。
早期的计算机没有很复杂的图形功能,CPU的核心频率也不高,跟内存
的频率一样,它们都是直接连接在同一个总线(Bus)上的。由于IO设
备诸如显示设备、键盘、软盘和磁盘等速度与CPU和内存相比还是慢很
多,当时也没有复杂的图形设备,显示设备大多是只能输出字符的终
端。为了协调IO设备与总线之间的速度,也为了能够让CPU能够和IO
设备进行通信,一般每个设备都会有一个相应的IO控制器。早期的计
算机硬件结构如图1-1所示。
图1-1 早期的计算机硬件
结构 …
后来由于CPU核心频率的提升,导致内存跟不上CPU的速度,于是产生
了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线进行
通信。接着随着图形化的操作系统普及,特别是3D游戏和多媒体的发
展,使得图形芯片需要跟CPU和内存之间大量交换数据,慢速的IO总
线已经无法满足图形设备的巨大需求。为了协调CPU、内存和高速的图
形设备,人们专门设计了一个高速的北桥芯片,以便它们之间能够高速
地交换数据。
由于北桥运行的速度非常高,所有相对低速的设备如果全都直接连接在
北桥上,北桥既须处理高速设备,又须处理低速设备,设计就会十分复
杂。于是人们又设计了专门处理低速设备的南桥(Southbridge)芯片,磁盘、USB、键盘、鼠标等设备都连接在南桥上,由南桥将它们汇总后
连接到北桥上。20世纪90年代的PC机在系统总线上采用的是PCI结构,而在低速设备上采用的ISA总线,采用PCIISA及南北桥设计的硬件构架
如图1-2所示。位于中间是连接所有高速芯片的北桥(Northbridge,PCI Bridge),它
就像人的心脏,连接并驱动身体的各个部位;它的左边是CPU,负责所
有的控制和运算,就像人的大脑。北桥还连接着几个高速部件,包括左
边的内存和下面的PCI总线。
PCI的速度最高为133 MHz,它还是不能满足人们的需求,于是人们又
发明了AGP、图1-2 硬件结构框架 …
PCI Express等诸多总线结构和相应控制芯片。虽然硬件结构看似越来越
复杂,但实际上它还是没有脱离最初的CPU、内存,以及IO的基本结
构。我们从程序开发的角度看待硬件时可以简单地将它看成最初的硬件
模型。SMP与多核
人们总是希望计算机越来越快,这是毫无疑问的。在过去的50年里,CPU的频率从几十KHz到现在的4GHz,整整提高了数十万倍,基本上
每18个月频率就会翻倍。但是自2004年以来,这种规律似乎已经失效,CPU的频率自从那时开始再也没有发生质的提高。原因是人们在制造
CPU的工艺方面已经达到了物理极限,除非CPU制造工艺有本质的突
破,否则CPU的频率将会一直被目前4GHz的“天花板”所限制。
在频率上短期内已经没有提高的余地了,于是人们开始想办法从另外一
个角度来提高CPU的速度,就是增加CPU的数量。一个计算机拥有多个
CPU早就不是什么新鲜事了,很早以前就有了多CPU的计算机,其中最
常见的一种形式就是对称多处理器(SMP,Symmetrical Multi-
Processing),简单地讲就是每个CPU在系统中所处的地位和所发挥的
功能都是一样的,是相互对称的。理论上讲,增加CPU的数量就可以提
高运算速度,并且理想情况下,速度的提高与CPU的数量成正比。但实
际上并非如此,因为我们的程序并不是都能分解成若干个完全不相干的
子问题。就比如一个女人可以花10个月生出一个孩子,但是10个女人并
不能在一个月就生出一个孩子一样。
当然很多时候多处理器是非常有用的,最常见的情况就是在大型的数据
库、网络服务器上,它们要同时处理大量的请求,而这些请求之间往往
是相互独立的,所以多处理器就可以最大效能地发挥威力。
多处理器应用最多的场合也是这些商用的服务器和需要处理大量计算的
环境。而在个人电脑中,使用多处理器则是比较奢侈的行为,毕竟多处
理器的成本是很高的。于是处理器的厂商开始考虑将多个处理器“合并
在一起打包出售”,这些“被打包”的处理器之间共享比较昂贵的缓存部
件,只保留多个核心,并且以一个处理器的外包装进行出售,售价比单
核心的处理器只贵了一点,这就是多核处理器(Multi-core Processor)
的基本想法。多核处理器实际上就是SMP的简化版,当然它们在细节上
还有一些差别,但是从程序员的角度来看,它们之间区别很小,逻辑上
来看它们是完全相同的。只是多核和SMP在缓存共享等方面有细微的差
别,使得程序在优化上可以有针对性地处理。简单地讲,除非想把CPU
的每一滴油水都榨干,否则可以把多核和SMP看成同一个概念。
推荐阅读:“Free Lunch is Over”(免费午餐已经结束了)http:www.gotw.capublicationsconcurrency-ddj.htm
随着CPU频率碰到了“天花板”,多核处理器越来越普及,对程序员开发
程序的方式也将发生极大的变化,这篇文章很好地分析了将要到来的多
核时代对程序开发的挑战和机遇。
1.3 站得高,望得远
系统软件这个概念其实比较模糊,传统意义上一般将用于管理计算机本
身的软件称为系统软件,以区别普通的应用程序。系统软件可以分成两
块,一块是平台性的,比如操作系统内核、驱动程序、运行库和数以千
计的系统工具;另外一块是用于程序开发的,比如编译器、汇编器、链
接器等开发工具和开发库。本书将着重介绍系统软件的一部分,主要是
链接器和库(包括运行库和开发库)的相关内容。
计算机系统软件体系结构采用一种层的结构,有人说过一句名言:
“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”
遗憾的是,这句经典的名言出处无从考证,据说是有人从图灵奖的获得者Butler Lampson的讲座
上听来的;也有人说是EDSAC的发明者David Wheeler讲的;还有人指出这是CMU计算机系创
始人Alan Perlis的名言。
“Any problem in computer science can be solved by another layer of
indirection.”
这句话几乎概括了计算机系统软件体系结构的设计要点,整个体系结构
从上到下都是按照严格的层次结构设计的。不仅是计算机系统软件整个
体系是这样的,体系里面的每个组件比如操作系统本身,很多应用程
序、软件系统甚至很多硬件结构都是按照这种层次的结构组织和设计
的。系统软件体系结构中,各种软件的位置如图1-3所示。 图1-3 计算机软件体系结构 …
每个层次之间都须要相互通信,既然须要通信就必须有一个通信的协
议,我们一般将其称为接口(Interface),接口的下面那层是接口的提
供者,由它定义接口;接口的上面那层是接口的使用者,它使用该接口
来实现所需要的功能。在层次体系中,接口是被精心设计过的,尽量保
持稳定不变,那么理论上层次之间只要遵循这个接口,任何一个层都可
以被修改或被替换。除了硬件和应用程序,其他都是所谓的中间层,每
个中间层都是对它下面的那层的包装和扩展。正是这些中间层的存在,使得应用程序和硬件之间保持相对的独立,比如硬件和操作系统都日新
月异地发展,但是最初为80386芯片和DOS系统设计的软件在最新的多
核处理器和Windows Vista下还是能够运行的,这方面归功于硬件和操作
系统本身保持了向后兼容性,另一方面不得不归功于这种层次结构的设
计方式。最近开始流行的虚拟机技术更是在硬件和操作系统之间增加了
一层虚拟层,使得一个计算机上可以同时运行多个操作系统,这也是层
次结构带来的好处,在尽可能少改变甚至不改变其他层的情况下,新增
加一个层次就可以提供前所未有的功能。
我们的软件体系中,位于最上层的是应用程序,比如我们平时用到的网
络浏览器、Email客户端、多媒体播放器、图片浏览器等。从整个层次
结构上来看,开发工具与应用程序是属于同一个层次的,因为它们都使
用一个接口,那就是操作系统应用程序编程接口(Application
Programming Interface)。应用程序接口的提供者是运行库,什么样的
运行库提供什么样的API,比如Linux下的Glibc库提供POSIX的API;
Windows的运行库提供Windows API,最常见的32位Windows提供的API
又被称为Win32。
运行库使用操作系统提供的系统调用接口(System call Interface),系
统调用接口在实现中往往以软件中断(Software Interrupt)的方式提供,比如Linux使用0x80号中断作为系统调用接口,Windows使用0x2E
号中断作为系统调用接口(从Windows XP Sp2开始,Windows开始采用
一种新的系统调用方式)。
操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的
定义者,硬件的接口定义决定了操作系统内核,具体来讲就是驱动程序
如何操作硬件,如何与硬件进行通信。这种接口往往被叫做硬件规格
(Hardware Specification),硬件的生产厂商负责提供硬件规格,操作
系统和驱动程序的开发者通过阅读硬件规格文档所规定的各种硬件编程
接口标准来编写操作系统和驱动程序。
1.4 操作系统做什么
操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件
资源。
计算机硬件的能力是有限的,比如一个CPU一秒钟能够执行的指令条数
是1亿条或是1GB的内存能够最多同时存储1GB的数据。无论你是否使
用它,资源总是那么多。当然我们不希望自己花钱买回来的硬件成为摆
设,充分挖掘硬件的能力,使得计算机运行得更有效率,在更短的时间
内处理更多的任务,才是我们的目标。这对于早期动辄数百万美元的古
董计算机来说更是如此,人们挖空心思让计算机硬件发挥所有潜能。一
个计算机中的资源主要分CPU、存储器(包括内存和磁盘)和IO设
备,我们分别从这三个方面来看看如何挖掘它们的潜力。
1.4.1 不要让CPU打盹
在计算机发展早期,CPU资源十分昂贵,如果一个CPU只能运行一个程
序,那么当程序读写磁盘(当时可能是磁带)时,CPU就空闲下来了,这在当时简直就是暴殄天物。于是人们很快编写了一个监控程序,当某
个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU资源的
程序启动,使得CPU能够充分地利用起来。这种被称为多道程序
(Multiprogramming)的方法看似很原始,但是它当时的确大大提高了
CPU的利用率。不过这种原始的多道程序技术存在最大的问题是程序之
间的调度策略太粗糙。对于多道程序来说,程序之间不分轻重缓急,如
果有些程序急需使用CPU来完成一些任务(比如用户交互的任务),那
么很有可能很长时间后才有机会分配到CPU。这对于有些响应时间要求
高的程序来说是很致命的,想象一下你在Windows上面点击鼠标10分钟以后系统才有反应,那该是多么沮丧的事。
经过稍微改进,程序运行模式变成了一种协作的模式,即每个程序运行
一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都
有机会运行一小段时间。这对于一些交互式的任务尤为重要,比如点击
一下鼠标或按下一个键盘按键后,程序所要处理的任务可能并不多,但
是它需要尽快地被处理,使得用户能够立即看到效果。这种程序协作模
式叫做分时系统(Time-Sharing System),这时候的监控程序已经比多
道程序要复杂多了,完整的操作系统雏形已经逐渐形成了。Windows的
早期版本(Windows 95和Windows NT之前),Mac OS X之前的Mac OS
版本都是采用这种分时系统的方式来调度程序的。比如在Windows 3.1
中,程序调用Yield、GetMessage或PeekMessage这几个系统调用时,Windows 3.1操作系统会判断是否有其他程序正在等待CPU,如果有,则可能暂停执行当前的程序,把CPU让出来给其他程序。如果一个程序
在进行一个很耗时的计算,一直霸占着CPU不放,那么操作系统也没办
法,其他程序都只有等着,整个系统看过去好像死机了一样。比如一个
程序进入了一个while(1)的死循环,那么整个系统都停止了。
经过稍微改进,程序运行模式变成了一种协作的模式,即每个程序运行
一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都
有机会运行一小段时间。这对于一些交互式的任务尤为重要,比如点击
一下鼠标或按下一个键盘按键后,程序所要处理的任务可能并不多,但
是它需要尽快地被处理,使得用户能够立即看到效果。这种程序协作模
式叫做分时系统(Time-Sharing System),这时候的监控程序已经比多
道程序要复杂多了,完整的操作系统雏形已经逐渐形成了。Windows的
早期版本(Windows 95和Windows NT之前),Mac OS X之前的Mac OS
版本都是采用这种分时系统的方式来调度程序的。比如在Windows 3.1
中,程序调用Yield、GetMessage或PeekMessage这几个系统调用时,Windows 3.1操作系统会判断是否有其他程序正在等待CPU,如果有,则可能暂停执行当前的程序,把CPU让出来给其他程序。如果一个程序
在进行一个很耗时的计算,一直霸占着CPU不放,那么操作系统也没办
法,其他程序都只有等着,整个系统看过去好像死机了一样。比如一个
程序进入了一个while(1)的死循环,那么整个系统都停止了。
这在现在看来是很荒唐的事,系统中的任何一个程序死循环都会导致系
统死机,这是无法令人接受的。当然当时的PC硬件处理能力本身就很
弱,PC上的应用也大多是比较低端的应用,所以这种分时方式勉强也能应付一下当时的交互式环境了。此前在高端领域,非PC的大中小型
机领域,其实已经在研究一种更为先进的操作系统模式了。这种模式就
是我们现在很熟悉的多任务(Multi-tasking)系统,操作系统接管了所
有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程
序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个
进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。
CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机
会得到CPU,但是,如果运行时间超出了一定的时间,操作系统会暂停
该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式
即所谓的抢占式(Preemptive),操作系统可以强制剥夺CPU资源并且
分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间
都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同
时运行的假象。目前几乎所有现代的操作系统都是采用这种方式,比如
我们熟悉的UNIX、Linux、Windows NT,以及Mac OS X等流行的操作
系统。
1.4.2 设备驱动
操作系统作为硬件层的上层,它是对硬件的管理和抽象。对于操作系统
上面的运行库和应用程序来说,它们希望看到的是一个统一的硬件访问
模式。作为应用程序的开发者,我们不希望在开发应用程序的时候直接
读写硬件端口、处理硬件中断等这些繁琐的事情。由于硬件之间千差万
别,它们的操作方式和访问方式都有区别。比如我们希望在显示器上画
一条直线,对于程序员来说,最好的方式是不管计算机使用什么显卡、什么显示器,多少大小多少分辨率,我们都只要调用一个统一的
LineTo函数,具体的实现方式由操作系统来完成。试想一下如果程序
员需要关心具体的硬件,那么结果会是这样:对于A型号的显卡来说,需要往IO端口0x1001写一个命令0x1111,然后从端口0x1002中读取一
个4字节的显存地址,然后使用DDA(一种画直线的图形算法)逐个地
在显存上画点……如果是B型号的显卡,可能完全是另外一种方式。这
简直就是灾难。不过在操作系统成熟之前,的确存在这样的情况,就是
应用程序的程序员需要直接跟硬件打交道。
当成熟的操作系统出现以后,硬件逐渐被抽象成了一系列概念。在
UNIX中,硬件设备的访问形式跟访问普通的文件形式一样;在
Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象
成了DirectX对象;磁盘被抽象成了普通文件系统,等等。程序员逐渐从硬件细节中解放出来,可以更多地关注应用程序本身的开发。这些繁
琐的硬件细节全都交给了操作系统,具体地讲是操作系统中的硬件驱动
(Device Driver)程序来完成。驱动程序可以看作是操作系统的一部
分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核
之间有一定的独立性,使得驱动程序有比较好的灵活性。因为PC的硬
件多如牛毛,操作系统开发者不可能为每个硬件开发一个驱动程序,这
些驱动程序的开发工作通常由硬件生产厂商完成。操作系统开发者为硬
件生产厂商提供了一系列接口和框架,凡是按照这个接口和框架开发的
驱动程序都可以在该操作系统上使用。让我们以一个读取文件为例子来
看看操作系统和驱动程序在这个过程中扮演了什么样的角色。
提到文件的读取,那么不得不提到文件系统这个操作系统中最为重要的
组成部分之一。文件系统管理着磁盘中文件的存储方式,比如我们在
Linux系统下有一个文件“homeusertest.dat”,长度为8 000个字节。那么
我们在创建这个文件的时候,Linux的ext3文件系统有可能将这个文件按
照这样的方式存储在磁盘中:文件的前4 096字节存储在磁盘的1000号
扇区到1007号扇区,每个扇区512字节,8个扇区刚好4 096字节;文件
的第4 097个字节到第8 000字节共3 904个字节,存储在磁盘的2000号扇
区到2007号扇区,8个扇区也是4 096字节,只不过只存储了3 904个有效
的字节,剩下的192个字节无效。如果把这个文件的存储方式看作是一
个链状的结构,它的结构如图1-4所示。
图1-4 文件在磁盘中的结
构 …
这里我们先穿插一个关于硬盘的结构介绍,关于硬盘结构可能很多读者
已经有一个大概的了解,那就是硬盘基本存储单位为扇区(Sector),每个扇区一般为512字节。一个硬盘往往有多个盘片,每个盘片分两
面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。
比如一个硬盘有2个盘片,每个盘面分65 536磁道,每个磁道分1 024个
扇区,那么硬盘的容量就是2 2 65 536 1 024 512 = 137 438 953
472字节(128GB)。但是我们可以想象,每个盘面上同心圆的周长不
一样,如果按照每个磁道都拥有相同数量的扇区,那么靠近盘面外围的磁道密度肯定比内圈更加稀疏,这样是比较浪费空间的。但是如果不同
的磁道扇区数又不同,计算起来就十分麻烦。为了屏蔽这些复杂的硬件
细节,现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的
方式,即整个硬盘中所有的扇区从0开始编号,一直到最后一个扇区,这个扇区编号叫做逻辑扇区号。逻辑扇区号抛弃了所有复杂的磁道、盘
面之类的概念。当我们给出一个逻辑的扇区号时,硬盘的电子设备会将
其转换成实际的盘面、磁道等这些位置。
文件系统保存了这些文件的存储结构,负责维护这些数据结构并且保证
磁盘中的扇区能够有效地组织和利用。那么当我们在Linux操作系统
中,要读取这个文件的前4 096个字节时,我们会使用一个read的系统调
用来实现。文件系统收到read请求之后,判断出文件的前4 096个字节位
于磁盘的1000号逻辑扇区到1007号逻辑扇区。然后文件系统就向硬盘驱
动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,磁盘驱动程
序收到这个请求以后就向硬盘发出硬件命令。向硬件发送IO命令的方
式有很多种,其中最为常见的一种就是通过读写IO端口寄存器来实
现。在x86平台上,共有65 536个硬件端口寄存器,不同的硬件被分配
到了不同的IO端口地址。CPU提供了两条专门的指令“in”和“out”来实现
对硬件端口的读和写。
对IDE接口来说,它有两个通道,分别为IDE0和IDE1,每个通道上可以
连接两个设备,分别为Master和Slave,一个PC中最多可以有4个IDE设
备。假设我们的文件位于IDE0的Master硬盘上,这也是正常情况下硬盘
所在的位置。在PC中,IDE0通道的IO端口地址是0x1F0~0x1F7及
0x376~0x377。通过读写这些端口地址就能与IDE硬盘进行通信。这些
端口的作用和操作方式十分复杂,我们以实现读取1000号逻辑扇区开始
的8个扇区为例:
第0x1F3~0x1F6 4个字节的端口地址是用来写入LBA地址的,那么
1000号逻辑扇区的LBA地址为0x000003E8,所以我们需要往0x1F3、0x1F4写入0x00,往0x1F5写入0x03,往0x1F6写入0xE8。
0x1F2这个地址用来写入命令所需要读写的扇区数。比如读取8个扇区
即写入8。
0x1F7这个地址用来写入要执行的操作的命令码,对于读取操作来
说,命令字为0x20。所以我们要执行的指令为:
out 0x1F3, 0x00
out 0x1F4, 0x00
out 0x1F5, 0x03
out 0x1F6, 0xE8
out 0x1F2, 0x08
out 0x1F7, 0x20
在硬盘收到这个命令以后,它就会执行相应的操作,并且将数据读取到
事先设置好的内存地址中(这个内存地址也是通过类似的命令方式设置
的)。当然这里的例子中只是最简单的情况,实际情况比这个复杂得
多,驱动程序须要考虑硬件的状态(是否忙碌或读取错误)、调度和分
配各个请求以达到最高的性能等。
1.5 内存不够怎么办
上面一节中我们提到了进程的概念,进程的总体目标是希望每个进程从
逻辑上来看都可以独占计算机的资源。操作系统的多任务功能使得CPU
能够在多个进程之间很好地共享,从进程的角度看好像是它独占了CPU
而不用考虑与其他进程分享CPU的事情。操作系统的IO抽象模型也很
好地实现了IO设备的共享和抽象,那么唯一剩下的就是主存,也就是
内存的分配问题了。
在早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序
在运行时所访问的地址都是物理地址。当然,如果一个计算机同时只运
行一个程序,那么只要程序要求的内存空间不要超过物理内存的大小,就不会有问题。但事实上为了更有效地利用硬件资源,我们必须同时运
行多个程序,正如前面的多道程序、分时系统和多任务中一样,当我们
能够同时运行多个程序时,CPU的利用率将会比较高。那么很明显的一
个问题是,如何将计算机上有限的物理内存分配给多个程序使用。
假设我们的计算机有128 MB内存,程序A运行需要10 MB,程序B需要
100 MB,程序C需要20 MB。如果我们需要同时运行程序A和B,那么比
较直接的做法是将内存的前10 MB分配给程序A,10 MB~110 MB分配
给B。这样就能够实现A和B两个程序同时运行,但是这种简单的内存分配策略问题很多。
地址空间不隔离 所有程序都直接访问物理地址,程序所使用的内存空
间不是相互隔离的。恶意的程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意的、但是有臭虫的程序可能不小心修改
了其他程序的数据,就会使其他程序也崩溃,这对于需要安全稳定的计
算环境的用户来说是不能容忍的。用户希望他在使用计算机的时候,其
中一个任务失败了,至少不会影响其他任务。
内存使用效率低 由于没有有效的内存管理机制,通常需要一个程序执
行时,监控程序就将整个程序装入内存中然后开始执行。如果我们忽然
需要运行程序C,那么这时内存空间其实已经不够了,这时候我们可以
用的一个办法是将其他程序的数据暂时写到磁盘里面,等到需要用到的
时候再读回来。由于程序所需要的空间是连续的,那么这个例子里面,如果我们将程序A换出到磁盘所释放的内存空间是不够的,所以只能将
B换出到磁盘,然后将C读入到内存开始运行。可以看到整个过程中有
大量的数据在换入换出,导致效率十分低下。
程序运行的地址不确定 因为程序每次需要装入运行时,我们都需要给
它从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定
的。这给程序的编写造成了一定的麻烦,因为程序在编写时,它访问数
据和指令跳转时的目标地址很多都是固定的,这涉及程序的重定位问
题,我们在第2部分和第3部分还会详细探讨重定位的问题。
解决这几个问题的思路就是使用我们前文提到过的法宝:增加中间层,即使用一种间接的地址访问方法。整个想法是这样的,我们把程序给出
的地址看作是一种虚拟地址(Virtual Address),然后通过某些映射的
方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥
善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程
序所能够访问的物理内存区域跟另外一个程序相互不重叠,以达到地址
空间隔离的效果。
1.5.1 关于隔离
让我们回到程序的运行本质上来。用户程序在运行时不希望介入到这些
复杂的存储器管理过程中,作为普通的程序,它需要的是一个简单的执
行环境,有一个单一的地址空间、有自己的CPU,好像整个程序占有整
个计算机而不用关心其他的程序(当然程序间通信的部分除外,因为这是程序主动要求跟其他程序通信和联系)。所谓的地址空间是个比较抽
象的概念,你可以把它想象成一个很大的数组,每个数组的元素是一个
字节,而这个数组大小由地址空间的地址长度决定,比如32位的地址空
间的大小为 2^32 = 4 294 967 296 字节,即4GB,地址空间有效的地址是
0~4 294 967 295,用十六进制表示就是0x00000000~0xFFFFFFFF。地
址空间分两种:虚拟地址空间(Virtual Address Space)和物理地址空间
(Physical Address Space)。物理地址空间是实实在在存在的,存在于
计算机中,而且对于每一台计算机来说只有唯一的一个,你可以把物理
空间想象成物理内存,比如你的计算机用的是Intel的Pentium 4的处理
器,那么它是32位的机器,即计算机地址线有32条(实际上是36条地址
线,不过我们暂时认为它只是32条),那么物理空间就有4GB。但是你
的计算机上只装了512MB的内存,那么其实物理地址的真正有效部分只
有0x00000000~0x1FFFFFFF,其他部分都是无效的(实际上还有一些
外部IO设备映射到物理空间的,也是有效的,但是我们暂时无视其存
在)。虚拟地址空间是指虚拟的、人们想象出来的地址空间,其实它并
不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自
己的地址空间,这样就有效地做到了进程的隔离。
1.5.2 分段(Segmentation)
最开始人们使用的是一种叫做分段(Segmentation)的方法,基本思路
是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空
间。比如程序A需要10 MB内存,那么我们假设有一个地址从
0x00000000到0x00A00000的10MB大小的一个假象的空间,也就是虚拟
空间,然后我们从实际的物理内存中分配一个相同大小的物理地址,假
设是物理地址0x00100000开始到0x00B00000结束的一块空间。然后我们
把这两块相同大小的地址空间一一映射,即虚拟空间中的每个字节相对
应于物理空间中的每个字节。这个映射过程由软件来设置,比如操作系
统来设置这个映射函数,实际的地址转换由硬件完成。比如当程序A中
访问地址0x00001000时,CPU会将这个地址转换成实际的物理地址
0x00101000。那么比如程序A和程序B在运行时,它们的虚拟空间和物
理空间映射关系可能如图1-5所示。图1-5 段映射机制 …
分段的方法基本解决了上面提到的3个问题中的第一个和第三个。首先
它做到了地址隔离,因为程序A和程序B被映射到了两块不同的物理空
间区域,它们之间没有任何重叠,如果程序A访问虚拟空间的地址超出
了0x00A00000这个范围,那么硬件就会判断这是一个非法的访问,拒
绝这个地址请求,并将这个请求报告给操作系统或监控程序,由它来决
定如何处理。再者,对于每个程序来说,无论它们被分配到物理地址的哪一个区域,对于程序来说都是透明的,它们不需要关心物理地址的变
化,它们只需要按照从地址0x00000000到0x00A00000来编写程序、放
置变量,所以程序不再需要重定位。
但是分段的这种方法还是没有解决我们的第二个问题,即内存使用效率
的问题。分段对内存区域的映射还是按照程序为单位,如果内存不足,被换入换出到磁盘的都是整个程序,这样势必会造成大量的磁盘访问操
作,从而严重影响速度,这种方法还是显得粗糙,粒度比较大。事实
上,根据程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁地用到了一小部分数据,也就是说,程序的很多数据其实在
一个时间段内都是不会被用到的。人们很自然地想到了更小粒度的内存
分割和映射的方法,使得程序的局部性原理得到充分的利用,大大提高
了内存的使用率。这种方法就是分页(Paging)。
1.5.3 分页(Paging)
分页的基本方法是把地址空间人为地等分成固定大小的页,每一页的大
小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大
小。比如Intel Pentium系列处理器支持4KB或4MB的页大小,那么操作
系统可以选择每页大小为4KB,也可以选择每页大小为4MB,但是在同
一时刻只能选择一种大小,所以对整个系统来说,页就是固定大小的。
目前几乎所有的PC上的操作系统都使用4KB大小的页。我们使用的PC
机是32位的虚拟地址空间,也就是4GB,那么按4KB每页分的话,总共
有1 048 576个页。物理空间也是同样的分法。
下面我们来看一个简单的例子,如图1-6所示,每个虚拟空间有8页,每
页大小为1KB,那么虚拟地址空间就是8KB。我们假设该计算机有13条
地址线,即拥有2^13的物理寻址能力,那么理论上物理空间可以多达
8KB。但是出于种种原因,购买内存的资金不够,只买得起6KB的内
存,所以物理空间其实真正有效的只是前6KB。
那么,当我们把进程的虚拟地址空间按页分割,把常用的数据和代码页
装载到内存中,把不常用的代码和数据保存在磁盘里,当需要用到的时
候再把它从磁盘里取出来即可。以图1-6为例,我们假设有两个进程
Process1和Process2,它们进程中的部分虚拟页面被映射到了物理页面,比如VP0、VP1和VP7映射到PP0、PP2和PP3;而有部分页面却在磁盘
中,比如VP2和VP3位于磁盘的DP0和DP1中;另外还有一些页面如
VP4、VP5和VP6可能尚未被用到或访问到,它们暂时处于未使用的状态。在这里,我们把虚拟空间的页就叫虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫
做磁盘页(DP,Disk Page)。图中的线表示映射关系,我们可以看到
虚拟空间的有些页被映射到同一个物理页,这样就可以实现内存共享。
图1-6中Process1的VP2和VP3不在内存中,但是当进程需要用到这两个
页的时候,硬件会捕获到这个消息,就是所谓的页错误(Page
Fault),然后操作系统接管进程,负责将VP2和VP3从磁盘中读出来并
且装入内存,然后将内存中的这两个页与VP2和VP3之间建立映射关
系。以页为单位来存取和交换这些数据非常方便,硬件本身就支持这种
以页为单位的操作方式。图1-6 进程虚拟空间、物理空间和磁盘之间的页映射关系 …
保护也是页映射的目的之一,简单地说就是每个页可以设置权限属性,谁可以修改,谁可以访问等,而只有操作系统有权限修改这些属性,那
么操作系统就可以做到保护自己和保护进程。对于保护,我们这里只是
简单介绍,详细的介绍和为什么要保护我们将会在本书的第2部分再介绍。
虚拟存储的实现需要依靠硬件的支持,对于不同的CPU来说是不同的。
但是几乎所有的硬件都采用一个叫MMU(Memory Management Unit)
的部件来进行页映射,如图1-7所示。
图1-7 虚拟地址到物理地址的转换 …
在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的
是虚拟地址。经过MMU转换以后就变成了Physical Address。一般MMU
都集成在CPU内部了,不会以独立的部件存在。
1.6 众人拾柴火焰高
1.6.1 线程基础
现代软件系统中,除了进程之外,线程也是一个十分重要的概念。特别
是随着CPU频率增长开始出现停滞,而开始向多核方向发展。多线程,作为实现软件并发执行的一个重要的方法,也开始具有越来越重要的地
位。我们将在这一节回顾线程相关的内容,包括线程的概念、线程的调
度、线程安全、用户线程与内核线程之间的映射关系。虽然线程相关的
概念与本书的内容并不是十分相关,但是我们相信深刻地理解线程对于
更加深入地理解装载、动态链接和运行库,特别是运行库与多线程相关
部分的内容会有很大的帮助。
什么是线程
线程(Thread),有时被称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。一个标准的线程由线程ID、当前指
令指针(PC)、寄存器集合和堆栈组成。通常意义上,一个进程由一
个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。一个经典
的线程与进程的关系如图1-8所示。图1-8 进程内的线程 …
大多数软件应用中,线程的数量都不止一个。多个线程可以互不干扰地
并发执行,并共享进程的全局变量和堆的数据。那么,多个线程与单线
程的进程相比,又有哪些优势呢?通常来说,使用多线程的原因有如下
几点。
某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继
续执行。多线程执行可以有效利用等待的时间。典型的例子是等待网络
响应,这可能要花费数秒甚至数十秒。
某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序
和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线
程负责计算。
程序逻辑本身就要求并发操作,例如一个多端下载软件(例如
Bittorrent)。
多CPU或多核计算机(基本就是未来的主流计算机),本身具备同时执
行多个线程的能力,因此单线程程序无法全面地发挥计算机的全部计算
能力。
相对于多进程应用,多线程在数据共享方面效率要高很多。
线程的访问权限
线程的访问非常自由,它可以访问进程内存里的所有数据,甚至包括其
他线程的堆栈(如果它知道其他线程的堆栈地址,那么这就是很少见的
情况),但实际运用中线程也拥有自己的私有存储空间,包括以下几方
面。
栈(尽管并非完全无法被其他线程访问,但一般情况下仍然可以认为是
私有的数据)。
线程局部存储(Thread Local Storage, TLS)。线程局部存储是某些操作
系统为线程单独提供的私有空间,但通常只具有很有限的容量。
寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程
私有。从C程序员的角度来看,数据在线程之间是否私有如表1-1所示。
表1-1 …
线程调度与优先级
不论是在多处理器的计算机上还是在单处理器的计算机上,线程总
是“并发”执行的。当线程数量小于等于处理器数量时(并且操作系统支
持多处理器),线程的并发是真正的并发,不同的线程运行在不同的处
理器上,彼此之间互不相干。但对于线程数量大于处理器数量的情况,线程的并发会受到一些阻碍,因为此时至少有一个处理器会运行多个线
程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系
统会让这些多线程程序轮流执行,每次仅执行一小段时间(通常是几十
到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断
在处理器上切换不同的线程的行为称之为线程调度(Thread
Schedule)。在线程调度中,线程通常拥有至少三种状态,分别是:
运行(Running):此时线程正在执行。
就绪(Ready):此时线程可以立刻运行,但CPU已经被占用。
等待(Waiting):此时线程正在等待某一事件(通常是IO或同步)发
生,无法执行。
处于运行中线程拥有一段可以执行的时间,这段时间称为时间片(Time
Slice),当时间片用尽的时候,该进程将进入就绪状态。如果在时间片
用尽之前进程就开始等待某事件,那么它将进入等待状态。每当一个线
程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。在一个处于等待状态的线程所等待的事件发生之后,该线程将进入就绪
状态。这3个状态的转移如图1-9所示。
图1-9 线程状态切换 …
线程调度自多任务操作系统问世以来就不断地被提出不同的方案和算
法。现在主流的调度方式尽管各不相同,但都带有优先级调度(Priority
Schedule)和轮转法(Round Robin)的痕迹。所谓轮转法,即是之前提
到的让各个线程轮流执行一小段时间的方法。这决定了线程之间交错执
行的特点。而优先级调度则决定了线程按照什么顺序轮流执行。在具有
优先级调度的系统中,线程都拥有各自的线程优先级(Thread
Priority)。具有高优先级的线程会更早地执行,而低优先级的线程常常
要等待到系统中已经没有高优先级的可执行的线程存在时才能够执行。
在Windows中,可以通过使用:
BOOL WINAPI SetThreadPriority(HANDLE hThread, int nPriority);
来设置线程的优先级,而Linux下与线程相关的操作可以通过pthread库
来实现。
在Windows和Linux中,线程的优先级不仅可以由用户手动设置,系统
还会根据不同线程的表现自动调整优先级,以使得调度更有效率。例如
通常情况下,频繁地进入等待状态(进入等待状态,会放弃之后仍然可
占用的时间份额)的线程(例如处理IO的线程)比频繁进行大量计
算、以至于每次都要把时间片全部用尽的线程要受欢迎得多。其实道理
很简单,频繁等待的线程通常只占用很少的时间,CPU也喜欢先捏软柿
子。我们一般把频繁等待的线程称之为IO密集型线程(IO Bound
Thread),而把很少等待的线程称为CPU密集型线程(CPU Bound
Thread)。IO密集型线程总是比CPU密集型线程容易得到优先级的提
升。在优先级调度下,存在一种饿死(Starvation)的现象,一个线程被饿
死,是说它的优先级较低,在它执行之前,总是有较高优先级的线程试
图执行,因此这个低优先级线程始终无法执行。当一个CPU密集型的线
程获得较高的优先级时,许多低优先级的进程就很可能饿死。而一个高
优先级的IO密集型线程由于大部分时间都处于等待状态,因此相对不容
易造成其他线程饿死。为了避免饿死现象,调度系统常常会逐步提升那
些等待了过长时间的得不到执行的线程的优先级。在这样的手段下,一
个线程只要等待足够长的时间,其优先级一定会提高到足够让它执行的
程度。
让我们总结一下,在优先级调度的环境下,线程的优先级改变一般有三
种方式。
用户指定优先级。
根据进入等待状态的频繁程度提升或降低优先级。
长时间得不到执行而被提升优先级。
可抢占线程和不可抢占线程
我们之前讨论的线程调度有一个特点,那就是线程在用尽时间片之后会
被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占
(Preemption),即之后执行的别的线程抢占了当前线程。在早期的一
些系统(例如Windows 3.1)里,线程是不可抢占的。线程必须手动发
出一个放弃执行的命令,才能让其他的线程得到执行。在这样的调度模
型下,线程必须主动进入就绪状态,而不是靠时间片用尽来被强制进
入。如果线程始终拒绝进入就绪状态,并且也不进行任何的等待操作,那么其他的线程将永远无法执行。在不可抢占线程中,线程主动放弃执
行无非两种情况。
当线程试图等待某事件时(IO等)。
线程主动放弃时间片。
因此,在不可抢占线程执行的时候,有一个显著的特点,那就是线程调
度的时机是确定的,线程调度只会发生在线程主动放弃执行或线程等待
某事件的时候。这样可以避免一些因为抢占式线程里调度时机不确定而产生的问题(见下一节:线程安全)。但即使如此,非抢占式线程在今
日已经十分少见。
Linux的多线程
Windows对进程和线程的实现如同教科书一般标准,Windows内核有明
确的线程和进程的概念。在Windows API中,可以使用明确的API:
CreateProcess和CreateThread来创建进程和线程,并且有一系列的API来
操纵它们。但对于Linux来说,线程并不是一个通用的概念。
Linux对多线程的支持颇为贫乏,事实上,在Linux内核中并不存在真正
意义上的线程概念。Linux将所有的执行实体(无论是线程还是进程)
都称为任务(Task),每一个任务概念上都类似于一个单线程的进程,具有内存空间、执行实体、文件资源等。不过,Linux下不同的任务之
间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间
的多个任务构成了一个进程,这些任务也就成了这个进程里的线程。在
Linux下,用以下方法可以创建一个新的任务,如表1-2所示。
表1-2 …
fork函数产生一个和当前进程完全一样的新进程,并和当前进程一样从
fork函数里返回。例如如下代码:
pid_t pid;
if (pid = fork)
{….
}
在fork函数调用之后,新的任务将启动并和本任务一起从fork函数返
回。但不同的是本任务的fork将返回新任务pid,而新任务的fork将返回
0。fork产生新任务的速度非常快,因为fork并不复制原任务的内存空间,而是和原任务一起共享一个写时复制(Copy on Write, COW)的内存空
间(见图1-10)。所谓写时复制,指的是两个任务可以同时自由地读取
内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供
给修改方单独使用,以免影响到其他的任务使用。
fork只能够产生本任务的镜像,因此须要使用exec配合才能够启动别的
新任务。exec可以用新的可执行映像替换当前的可执行映像,因此在
fork产生了一个新任务之后,新任务可以调用exec来执行新的可执行文
件。fork和exec通常用于产生新任务,而如果要产生新线程,则可以使
用clone。clone函数的原型如下:
int clone(int (fn)(void), void child_stack, int flags, void arg);
图1-10 写时复
制(Copy-On-Write) …
使用clone可以产生一个新的任务,从指定的位置开始执行,并且(可选
的)共享当前进程的内存空间和文件等。如此就可以在实际效果上产生
一个线程。
1.6.2 线程安全
多线程程序处于一个多变的环境当中,可访问的全局变量和堆数据随时
都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
竞争与原子操作
多个线程同时访问一个共享数据,可能造成很恶劣的后果。下面是一个
著名的例子,假设有两个线程分别要执行如表1-3所示的C代码。
表1-3 …
在许多体系结构上,++i的实现方法会如下:
(1)读取i到某个寄存器X。
(2)X++。
(3)将X的内容存储回i。
由于线程1和线程2并发执行,因此两个线程的执行序列很可能如下(注
意,寄存器X的内容在不同的线程中是不一样的,这里用X[1]和X[2]分
别表示线程1和线程2中的X),如表1-4所示。
表1-4 …
从程序逻辑来看,两个线程都执行完毕之后,i的值应该为1,但从之前
的执行序列可以看到,i得到的值是0。实际上这两个线程如果同时执行的话,i的结果有可能是0或1或2。可见,两个程序同时读写同一个共享
数据会导致意想不到的后果。
很明显,自增(++)操作在多线程环境下会出现错误是因为这个操作被
编译为汇编代码之后不止一条指令,因此在执行的时候可能执行了一半
就被调度系统打断,去执行别的代码。我们把单指令的操作称为原子的
(Atomic),因为无论如何,单条指令的执行是不会被打断的。为了避
免出错,很多体系结构都提供了一些常用操作的原子指令,例如i386就
有一条inc指令可以直接增加一个内存单元值,可以避免出现上例中的
错误情况。在Windows里,有一套API专门进行一些原子操作(见表1-
5),这些API称为Interlocked API。
表1-5 …
使用这些函数时,Windows将保证是原子操作的,因此可以不用担心出
现问题。遗憾的是,尽管原子操作指令非常方便,但是它们仅适用于比
较简单特定的场合。在复杂的场合下,比如我们要保证一个复杂的数据
结构更改的原子性,原子操作指令就力不从心了。这里我们需要更加通
用的手段:锁。
同步与锁
为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们需
要将各个线程对同一个数据的访问同步(Synchronization)。所谓同
步,既是指在一个线程访问数据未结束的时候,其他线程不得对同一个
数据进行访问。如此,对数据的访问被原子化了。
同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线
程在访问数据或资源之前首先试图获取(Acquire)锁,并在访问结束
之后释放(Release)锁。在锁已经被占用的时候试图获取锁时,线程会
等待,直到锁重新可用。二元信号量(Binary Semaphore)是最简单的一种锁,它只有两种状
态:占用与非占用。它适合只能被唯一一个线程独占访问的资源。当二
元信号量处于非占用状态时,第一个试图获取该二元信号量的线程会获
得该锁,并将二元信号量置为占用状态,此后其他的所有试图获取该二
元信号量的线程将会等待,直到该锁被释放。
对于允许多个线程并发访问的资源,多元信号量简称信号量
(Semaphore),它是一个很好的选择。一个初始值为N的信号量允许N
个线程并发访问。线程访问资源的时候首先获取信号量,进行如下操
作:
将信号量的值减1。
如果信号量的值小于0,则进入等待状态,否则继续执行。
访问完资源之后,线程释放信号量,进行如下操作:
将信号量的值加1。
如果信号量的值小于1,唤醒一个等待中的线程。
互斥量(Mutex)和二元信号量很类似,资源仅同时允许一个线程访
问,但和信号量不同的是,信号量在整个系统可以被任意线程获取并释
放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一
个线程释放。而互斥量则要求哪个线程获取了互斥量,哪个线程就要负
责释放这个锁,其他线程越俎代庖去释放互斥量是无效的。
临界区(Critical Section)是比互斥量更加严格的同步手段。在术语
中,把临界区的锁的获取称为进入临界区,而把锁的释放称为离开临界
区。临界区和互斥量与信号量的区别在于,互斥量和信号量在系统的任
何进程里都是可见的,也就是说,一个进程创建了一个互斥量或信号
量,另一个进程试图去获取该锁是合法的。然而,临界区的作用范围仅
限于本进程,其他的进程无法获取该锁。除此之外,临界区具有和互斥
量相同的性质。
读写锁(Read-Write Lock)致力于一种更加特定的场合的同步。对于一
段数据,多个线程同时读取总是没有问题的,但假设操作都不是原子
型,只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。如果我们使用上述信号量、互斥量或临界区中的任何一
种来进行同步,尽管可以保证程序正确,但对于读取频繁,而仅仅偶尔
写入的情况,会显得非常低效。读写锁可以避免这个问题。对于同一个
锁,读写锁有两种获取方式,共享的(Shared)或独占的
(Exclusive)。当锁处于自由的状态时,试图以任何一种方式获取锁都
能成功,并将锁置于对应的状态。如果锁处于共享状态,其他线程以共
享的方式获取锁仍然会成功,此时这个锁分配给了多个线程。然而,如
果其他线程试图以独占的方式获取已经处于共享状态的锁,那么它将必
须等待锁被所有的线程释放。相应地,处于独占状态的锁将阻止任何其
他线程获取该锁,不论它们试图以哪种方式获取。读写锁的行为可以总
结如表1-6所示。
表1-6 …
条件变量(Condition Variable)作为一种同步手段,作用类似于一个栅
栏。对于条件变量,线程可以有两种操作,首先线程可以等待条件变
量,一个条件变量可以被多个线程等待。其次,线程可以唤醒条件变
量,此时某个或所有等待此条件变量的线程都会被唤醒并继续支持。也
就是说,使用条件变量可以让许多线程一起等待某个事件的发生,当事
件发生时(条件变量被唤醒),所有的线程可以一起恢复执行。
可重入(Reentrant)与线程安全
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调
用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
(1)多个线程同时执行这个函数。
(2)函数自身(可能是经过多层调用之后)调用自身。
一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后
果。举个例子,如下面这个sqr函数就是可重入的:int sqr(int x)
{
return x x;
}
一个函数要成为可重入的,必须具有如下几个特点:
不使用任何(局部)静态或全局的非const变量。
不返回任何(局部)静态或全局的非const变量的指针。
仅依赖于调用方提供的参数。
不依赖任何单个资源的锁(mutex等)。
不调用任何不可重入的函数。
可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下
放心使用。
过度优化
线程安全是一个非常烫手的山芋,因为即使合理地使用了锁,也不一定
能保证线程安全,这是源于落后的编译器技术已经无法满足日益增长的
并发需求。很多看似无错的代码在优化和并发面前又产生了麻烦。最简
单的例子,让我们看看如下代码:
x = 0;
Thread1 Thread2
lock; lock;
x++; x++;
unlock; unlock;
由于有 lock 和 unlock 的保护,x++的行为不会被并发所破坏,那么x的值
似乎必然是2了。然而,如果编译器为了提高x的访问速度,把x放到了
某个寄存器里,那么我们知道不同线程的寄存器是各自独立的,因此如
果Thread1先获得锁,则程序的执行可能会呈现如下的情况:[Thread1]读取x的值到某个寄存器R[1](R[1]=0)。
[Thread1]R[1]++(由于之后可能还要访问x,因此Thread1暂时不将R[1]
写回x)。
[Thread2]读取x的值到某个寄存器R[2](R[2]=0)。
[Thread2]R[2]++(R[2]=1)。
[Thread2]将R[2]写回至x(x=1)。
[Thread1](很久以后)将R[1]写回至x(x=1)。
可见在这样的情况下即使正确地加锁,也不能保证多线程安全。下面是
另一个例子:
x = y = 0;
Thread1 Thread2
x = 1; y = 1;
r1 = y; r2 = x;
很显然,r1和r2至少有一个为1,逻辑上不可能同时为0。然而,事实上
r1=r2=0的情况确实可能发生。原因在于早在几十年前,CPU就发展出
了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。
同样,编译器在进行优化的时候,也可能为了效率而交换毫不相干的两
条相邻指令(如x=1和r1=y)的执行顺序。也就是说,以上代码执行的
时候可能是这样的:
x = y = 0;
Thread1 Thread2
r1 = y; y = 1;
x = 1; r2 = x;
那么r1=r2=0就完全可能了。我们可以使用volatile关键字试图阻止过度
优化,volatile基本可以做到两件事情:
(1)阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。(2)阻止编译器调整操作volatile变量的指令顺序。
可见volatile可以完美地解决第一个问题,但是volatile是否也能解决第二
个问题呢?答案是不能。因为即使volatile能够阻止编译器调整顺序,也
无法阻止CPU动态调度换序。
另一个颇为著名的与换序有关的问题来自于Singleton模式的double-
check。一段典型的double-check的singleton代码是这样的(不熟悉
Singleton的读者可以参考《设计模式:可复用面向对象软件的基础》,但下面所介绍的内容并不真正需要了解Singleton):
volatile T pInst = 0;
T GetInstance
{
if (pInst == NULL)
{
lock;
if (pInst == NULL)
pInst = new T;
unlock;
}
return pInst;
}
抛开逻辑,这样的代码乍看是没有问题的,当函数返回时,PInst总是指
向一个有效的对象。而lock和unlock防止了多线程竞争导致的麻烦。双
重的if在这里另有妙用,可以让lock的调用开销降低到最小。读者可以
自己揣摩。
但是实际上这样的代码是有问题的。问题的来源仍然是CPU的乱序执
行。C++里的new其实包含了两个步骤:
(1)分配内存。
(2)调用构造函数。所以pInst = new T包含了三个步骤:
(1)分配内存。
(2)在内存的位置上调用构造函数。
(3)将内存的地址赋值给pInst。
在这三步中,(2)和(3)的顺序是可以颠倒的。也就是说,完全有可
能出现这样的情况:pInst的值已经不是NULL,但对象仍然没有构造完
毕。这时候如果出现另外一个对GetInstance的并发调用,此时第一个if
内的表达式pInst==NULL为false,所以这个调用会直接返回尚未构造完
全的对象的地址(pInst)以提供给用户使用。那么程序这个时候会不会
崩溃就取决于这个类的设计如何了。
从上面两个例子可以看到CPU的乱序执行能力让我们对多线程的安全保
障的努力变得异常困难。因此要保证线程安全,阻止CPU换序是必需
的。遗憾的是,现在并不存在可移植的阻止换序的方法。通常情况下是
调用CPU提供的一条指令,这条指令常常被称为barrier。一条barrier指
令会阻止CPU将该指令之前的指令交换到barrier之后,反之亦然。换句
话说,barrier指令的作用类似于一个拦水坝,阻止换序“穿透”这个大
坝。
许多体系结构的CPU都提供barrier指令,不过它们的名称各不相同,例
如POWERPC提供的其中一条指令名叫lwsync。我们可以这样来保证线
程安全:
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;
}
由于barrier的存在,对象的构造一定在barrier执行之前完成,因此当
pInst被赋值时,对象总是完好的。
1.6.3 多线程内部情况
三种线程模型
线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要
更为复杂一些:大多数操作系统,包括Windows和Linux,都在内核里
提供线程的支持,内核线程(注:这里的内核线程和Linux内核里的
kernel_thread并不是一回事)和我们之前讨论的一样,由多处理器或调
度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于
用户态的用户线程。用户态线程并不一定在操作系统内核里对应同等数
量的内核线程,例如某些轻量级的线程库,对用户来说如果有三个线程
在同时执行,对内核来说很可能只有一个线程。本节我们将详细介绍用
户态多线程库的实现方式。
1. 一对一模型
对于直接支持线程的系统,一对一模型始终是最为简单的模型。对一对
一模型来说,一个用户使用的线程就唯一对应一个内核使用的线程(但
反过来不一定,一个内核里的线程在用户态不一定有对应的线程存
在),如图1-11所示。 图1-11 一对一
线程模型 …
这样用户线程就具有了和内核线程一致的优点,线程之间的并发是真正
的并发,一个线程因为某原因阻塞时,其他线程执行不会受到影响。此
外,一对一模型也可以让多线程程序在多处理器的系统上有更好的表
现。
一般直接使用API或系统调用创建的线程均为一对一的线程。例如在
Linux里使用clone(带有CLONE_VM参数)产生的线程就是一个一对一
线程,因为此时在内核有一个唯一的线程与之对应。下列代码演示了这
一过程:
int thread_function(void)
{ ....}
char thread_stack[4096];
void foo
{
clone(thread_function, thread_stack, CLONE_VM, 0);
}
在Windows里,使用API CreateThread即可创建一个一对一的线程。
一对一线程缺点有两个:
由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户
的线程数量受到限制。
许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
2. 多对一模型
多对一模型将多个用户线程映射到一个内核线程上,线程之间的切换由
用户态的代码来进行,因此相对于一对一模型,多对一模型的线程切换
要快速许多。多对一的模型示意图如图1-12所示。
图1-12 多对一线程模型…
多对一模型一大问题是,如果其中一个用户线程阻塞,那么所有的线程
都将无法执行,因为此时内核里的线程也随之阻塞了。另外,在多处理
器系统上,处理器的增多对多对一模型的线程性能也不会有明显的帮
助。但同时,多对一模型得到的好处是高效的上下文切换和几乎无限制
的线程数量。
3. 多对多模型
多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映
射到少数但不止一个内核线程上,如图1-13所示。
在多对多模型中,一个用户线程阻塞并不会使得所有的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线
程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得
到一定的性能提升,不过提升的幅度不如一对一模型高。 图1-13 多对多线程
模型 …
1.7 本章小结
在这一章中,我们对整个计算机的软硬件基本结构进行了回顾,包括
CPU与外围部件的连接方式、SMP与多核、软硬件层次体系结构、如何
充分利用CPU及与系统软件十分相关的设备驱动、操作系统、虚拟空
间、物理空间、页映射和线程的基础概念。虽然这些概念都是大家所了
解的,但是我们认为还是有必要回顾一下,它们跟本书后面章节介绍的
内容息息相关。正所谓温故而知新,这就是本章的目的。
第2部分 静态链接
第2章 编译和链接
2.1 被隐藏了的过程
2.2 编译器做了什么
2.3 链接器年龄比编译器长
2.4 模块拼装——静态链接
2.5 本章小结
对于平常的应用程序开发,我们很少需要关注编译和链接过程,因为通常的开发环境都是流行的集成开发环境(IDE),比如Visual Studio、Delphi等。这样的IDE一般都将编译和链接的过程一步完成,通常将这
种编译和链接合并到一起的过程称为构建(Build)。即使使用命令行
来编译一个源代码文件,简单的一句“gcc hello.c”命令就包含了非常复
杂的过程。
IDE和编译器提供的默认配置、编译和链接参数对于大部分的应用程序
开发而言已经足够使用了。但是在这样的开发过程中,我们往往会被这
些复杂的集成工具所提供的强大功能所迷惑,很多系统软件的运行机制
与机理被掩盖,其程序的很多莫名其妙的错误让我们无所适从,面对程
序运行时种种性能瓶颈我们束手无策。我们看到的是这些问题的现象,但是却很难看清本质,所有这些问题的本质就是软件运行背后的机理及
支撑软件运行的各种平台和工具,如果能够深入了解这些机制,那么解
决这些问题就能够游刃有余,收放自如了。
2.1 被隐藏了的过程
C语言的经典,“Hello World”程序几乎是每个程序员闭着眼睛都能写出
的,编译运行通过一气呵成,基本成了程序入门和开发环境测试的默认
的标准。
include
int main
{
printf(Hello World\n);
return 0;
}
在Linux下,当我们使用GCC来编译Hello World程序时,只须使用最简
单的命令(假设源代码文件名为hello.c):
gcc hello.c
.a.out
Hello World事实上,上述过程可以分解为4个步骤,分别是预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking),如图2-1
所示。
图2-1 GCC编译过程分解 …
2.1.1 预编译
首先是源代码文件hello.c和相关的头文件,如stdio.h等被预编译器cpp预
编译成一个.i文件。对于C++程序来说,它的源代码文件的扩展名可能
是.cpp或.cxx,头文件的扩展名可能是.hpp,而预编译后的文件扩展名
是.ii。第一步预编译的过程相当于如下命令(-E表示只进行预编译):gcc –E hello.c –o hello.i
或者:
cpp hello.c > hello.i
预编译过程主要处理那些源代码文件中的以“”开始的预编译指令。比
如 “include”、“define ”等,主要处理规则如下:
将所有的 “define ”删除,并且展开所有的宏定义。
处理所有条件预编译指令,比如 “if”、“ifdef”、“elif”、“else”、“endif ”。
处理 “include ”预编译指令,将被包含的文件插入到该预编译指令的位
置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含
其他文件。
删除所有的注释“”和“ ”。
添加行号和文件名标识,比如2“hello.c”2,以便于编译时编译器产生
调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号。
保留所有的 pragma 编译器指令,因为编译器须要使用它们。
经过预编译后的.i文件不包含任何宏定义,因为所有的宏已经被展开,并且包含的文件也已经被插入到.i文件中。所以当我们无法判断宏定义
是否正确或头文件包含是否正确时,可以查看预编译后的文件来确定问
题。
2.1.2 编译
编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义
分析及优化后生产相应的汇编代码文件,这个过程往往是我们所说的整
个程序构建的核心部分,也是最复杂的部分之一。我们将在下一节简单
介绍编译的具体几个步骤,这涉及编译原理等一些内容,由于它不是本
书介绍的核心内容,所以也仅仅是介绍而已。上面的编译过程相当于如
下命令:
gcc –S hello.i –o hello.s现在版本的GCC把预编译和编译两个步骤合并成一个步骤,使用一个叫
做cc1的程序来完成这两个步骤。这个程序位于“usrlibgcci486-linux-
gnu4.1”,我们也可以直接调用cc1来完成它:
usrlibgcci486-linux-gnu4.1cc1 hello.c
main
Execution times (seconds)
preprocessing :0.01(100%)usr 0.01(33%)sys 0.00( 0%)wall 77 kB( 8%)ggc
lexical analysis :0.00( 0%)usr 0.00( 0%)sys 0.02(50%)wall 0 kB(0%)ggc
parser :0.00( 0%)usr 0.00( 0%)sys 0.01(25%)wall 125 kB(13%)ggc
expand :0.00( 0%)usr 0.01(33%)sys 0.00( 0%)wall 6 kB(1%)ggc
TOTAL :0.01 0.03 0.04 982 kB
或者使用如下命令:
gcc –S hello.c –o hello.s
都可以得到汇编输出文件hello.s。对于C语言的代码来说,这个预编译
和编译的程序是cc1,对于C++来说,有对应的程序叫做cc1plus;
Objective-C是cc1obj;fortran是f771;Java是jc1。所以实际上gcc这个命
令只是这些后台程序的包装,它会根据不同的参数要求去调用预编译编
译程序cc1、汇编器as、链接器ld。
2.1.3 汇编
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎
都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简
单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据
汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来
源于此。上面的汇编过程我们可以调用汇编器as来完成:
as hello.s –o hello.o
或者:
或者使用gcc命令从C源代码文件开始,经过预编译、编译和汇编直接输
出目标文件(Object File):gcc –c hello.c –o hello.o
2.1.4 链接
链接通常是一个让人比较费解的过程,为什么汇编器不直接输出可执行
文件而是输出一个目标文件呢?链接过程到底包含了什么内容?为什么
要链接?这恐怕是很多读者心中的疑惑。正是因为这些疑惑总是挥之不
去,所以我们特意用这一章的篇幅来分析链接,具体地说分析静态链接
的章节。下面让我们来看看怎么样调用ld才可以产生一个能够正常运行
的HelloWorld程序:
ld -static usrlibcrt1.o usrlibcrti.o usrlibgcci486-linux-
gnu4.1.3crtbeginT.o -Lusrlibgcci486-linux-gnu4.1.3 -Lusrlib -
Llib hello.o --start-group -lgcc -lgcc_eh -lc --end-group usrlibgcci486-
linux-gnu4.1.3crtend.o usrlibcrtn.o
可以看到,我们需要将一大堆文件链接起来才可以得到“a.out”,即最终
的可执行文件。看了这行复杂的命令,可能很多读者的疑惑更多了,crt1.o、crti.o、crtbeginT.o、crtend.o、crtn.o这些文件是什么?它们做什
么用的 ?-lgcc –lgcc_eh –lc 这些都是什么参数?为什么要使用它们?为什
么要将它们和hello.o链接起来才可以得到可执行文件?等等。
这些问题正是本书所需要介绍的内容,它们看似简单,其实涉及了编
译、链接和库,甚至是操作系统的一些很底层的内容。我们将紧紧围绕
着这些内容,进行必要的分析。不过在分析这些内容之前,我们还是来
关注一下上面这些过程中,编译器担任了一个什么样的角色。
2.2 编译器做了什么
从最直观的角度来讲,编译器就是将高级语言翻译成机器语言的一个工
具。比如我们用CC++语言写的一个程序可以使用编译器将其翻译成机
器可以执行的指令及数据。我们前面也提到了,使用机器指令或汇编语
言编写程序是十分费事及乏味的事情,它们使得程序开发的效率十分低
下。并且使用机器语言或汇编语言编写的程序依赖于特定的机器,一个
为某种CPU编写的程序在另外一种CPU下完全无法运行,需要重新编
写,这几乎是令人无法接受的。所以人们期望能够采用类似于自然语言
的语言来描述一个程序,但是自然语言的形式不够精确,所以类似于数
学定义的编程语言很快就诞生了。20世纪的六七十年代诞生了很多高级
语言,有些至今仍然非常流行,如FORTRAN、C语言等(准确地讲,FORTRAN诞生于20世纪50年代的IBM)。高级语言使得程序员们能够更加关注程序逻辑的本身,而尽量少考虑计算机本身的限制,如字长、内存大小、通信方式、存储方式等。高级编程语言的出现使得程序开发
的效率大大提高,高级语言的可移植性也使得它在多种计算机平台下能
够游刃有余。据研究,高级语言的开发效率是汇编语言和机器语言的5
倍以上。
让我们继续回到编译器本身的职责上来,编译过程一般可以分为6步:
扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化。
整个过程如图2-2所示。
图2-2 编译过程 …
我们将结合图2-2来简单描述从源代码(Source Code)到最终目标代码
(Final Target Code)的过程。以一段很简单的C语言的代码为例子来讲
述这个过程。比如我们有一行C语言的源代码如下:
array[index] = (index + 4) (2 + 6)CompilerExpression.c
2.2.1 词法分析
首先源代码程序被输入到扫描器(Scanner),扫描器的任务很简单,它只是简单地进行词法分析,运用一种类似于有限状态机(Finite State
Machine)的算法可以很轻松地将源代码的字符序列分割成一系列的记
号(Token)。比如上面的那行程序,总共包含了28个非空字符,经过
扫描以后,产生了16个记号,如表2-1所示。
表2-1 …
词法分析产生的记号一般可以分为如下几类:关键字、标识符、字面量
(包含数字、字符串等)和特殊符号(如加号、等号)。在识别记号的
同时,扫描器也完成了其他工作。比如将标识符存放到符号表,将数字、字符串常量存放到文字表等,以备后面的步骤使用。
有一个叫做lex的程序可以实现词法扫描,它会按照用户之前描述好的
词法规则将输入的字符串分割成一个个记号。因为这样一个程序的存
在,编译器的开发者就无须为每个编译器开发一个独立的词法扫描器,而是根据需要改变词法规则就可以了。
另外对于一些有预处理的语言,比如C语言,它的宏替换和文件包含等
工作一般不归入编译器的范围而交给一个独立的预处理器。
2.2.2 语法分析
接下来语法分析器(Grammar Parser)将对由扫描器产生的记号进行语
法分析,从而产生语法树(Syntax Tree)。整个分析过程采用了上下文
无关语法(Context-free Grammar)的分析手段,如果你对上下文无关语
法及下推自动机很熟悉,那么应该很好理解。否则,可以参考一些计算
理论的资料,一般都会有很详细的介绍。此处不再赘述。简单地讲,由
语法分析器生成的语法树就是以表达式(Expression)为节点的树。我
们知道,C语言的一个语句是一个表达式,而复杂的语句是很多表达式
的组合。上面例子中的语句就是一个由赋值表达式、加法表达式、乘法
表达式、数组表达式、括号表达式组成的复杂语句。它在经过语法分析
器以后形成如图2-3所示的语法树。
图2-3 语法树 …
从图2-3中我们可以看到,整个语句被看作是一个赋值表达式;赋值表
达式的左边是一个数组表达式,它的右边是一个乘法表达式;数组表达
式又由两个符号表达式组成,等等。符号和数字是最小的表达式,它们不是由其他的表达式来组成的,所以它们通常作为整个语法树的叶节
点。在语法分析的同时,很多运算符号的优先级和含义也被确定下来
了。比如乘法表达式的优先级比加法高,而圆括号表达式的优先级比乘
法高,等等。另外有些符号具有多重含义,比如星号在C语言中可以表
示乘法表达式,也可以表示对指针取内容的表达式,所以语法分析阶段
必须对这些内容进行区分。如果出现了表达式不合法,比如各种括号不
匹配、表达式中缺少操作符等,编译器就会报告语法分析阶段的错误。
正如前面词法分析有lex一样,语法分析也有一个现成的工具叫做
yacc(Yet Another Compiler Compiler)。它也像lex一样,可以根据用户
给定的语法规则对输入的记号序列进行解析,从而构建出一棵语法树。
对于不同的编程语言,编译器的开发者只须改变语法规则,而无须为每
个编译器编写一个语法分析器,所以它又被称为“编译器编译器
(Compiler Compiler)”。
2.2.3 语义分析
接下来进行的是语义分析,由语义分析器(Semantic Analyzer)来完
成。语法分析仅仅是完成了对表达式的语法层面的分析,但是它并不了
解这个语句是否真正有意义。比如C语言里面两个指针做乘法运算是没
有意义的,但是这个语句在语法上是合法的;比如同样一个指针和一个
浮点数做乘法运算是否合法等。编译器所能分析的语义是静态语义
(Static Semantic),所谓静态语义是指在编译期可以确定的语义,与
之对应的动态语义(Dynamic Semantic)就是只有在运行期才能确定的
语义。
静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型
的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转
换的过程,语义分析过程中需要完成这个步骤。比如将一个浮点型赋值
给一个指针的时候,语义分析程序会发现这个类型不匹配,编译器将会
报错。动态语义一般指在运行期出现的语义相关的问题,比如将0作为
除数是一个运行期语义错误。
经过语义分析阶段以后,整个语法树的表达式都被标识了类型,如果有
些类型需要做隐式转换,语义分析程序会在语法树中插入相应的转换节
点。上面描述的语法树在经过语义分析阶段以后成为如图2-4所示的形
式。 图2-4 标识语义后的
语法树 …
可以看到,每个表达式(包括符号和数字)都被标识了类型。我们的例
子中几乎所有的表达式都是整型的,所以无须做转换,整个分析过程很
顺利。语义分析器还对符号表里的符号类型也做了更新。
2.2.4 中间语言生成
现代的编译器有着很多层次的优化,往往在源代码级别会有一个优化过
程。我们这里所描述的源码级优化器(Source Code Optimizer)在不同
编译器中可能会有不同的定义或有一些其他的差异。源代码级优化器会
在源代码级别进行优化,在上例中,细心的读者可能已经发现,(2 +
6)这个表达式可以被优化掉,因为它的值在编译期就可以被确定。类
似的还有很多其他复杂的优化过程,我们在这里就不详细描述了。经过
优化的语法树如图2-5所示。 图2-5 优化后的语法树…
我们看到(2 + 6)这个表达式被优化成8。其实直接在语法树上作优化
比较困难,所以源代码优化器往往将整个语法树转换成中间代码
(Intermediate Code),它是语法树的顺序表示,其实它已经非常接近
目标代码了。但是它一般跟目标机器和运行时环境是无关的,比如它不
包含数据的尺寸、变量地址和寄存器的名字等。中间代码有很多种类
型,在不同的编译器中有着不同的形式,比较常见的有:三地址码
(Three-address Code)和P-代码(P-Code)。我们就拿最常见的三地址
码来作为例子,最基本的三地址码是这样的:
x = y op z
这个三地址码表示将变量y和z进行op操作以后,赋值给x。这里op操作
可以是算数运算,比如加减乘除等,也可以是其他任何可以应用到y和z
的操作。三地址码也得名于此,因为一个三地址码语句里面有三个变量
地址。我们上面的例子中的语法树可以被翻译成三地址码后是这样的:
t1 = 2 + 6
t2 = index + 4
t3 = t2 t1
array[index] = t3
我们可以看到,为了使所有的操作都符合三地址码形式,这里利用了几
个临时变量:t1、t2和t3。在三地址码的基础上进行优化时,优化程序会将2+6的结果计算出来,得到t1 = 8。然后将后面代码中的t1替换成数
字8。还可以省去一个临时变量t3,因为t2可以重复利用。经过优化以后
的代码如下:
t2 = index + 4
t2 = t2 8
array[index] = t2
中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器
无关的中间代码,编译器后端将中间代码转换成目标机器代码。这样对
于一些可以跨平台的编译器而言,它们可以针对不同的平台使用同一个
前端和针对不同机器平台的数个后端。
2.2.5 目标代码生成与优化
源代码级优化器产生中间代码标志着下面的过程都属于编辑器后端。编
译器后端主要包括代码生成器(Code Generator)和目标代码优化器
(Target Code Optimizer)。让我们先来看看代码生成器。代码生成器
将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为
不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型
等。对于上面例子中的中间代码,代码生成器可能会生成下面的代码序
列(我们用x86的汇编语言来表示,并且假设index的类型为int型,array
的类型为int型数组):
movl index, %ecx ; value of index to ecx
addl 4, %ecx ; ecx = ecx + 4
mull 8, %ecx ; ecx = ecx 8
movl index, %eax ; value of index to eax
movl %ecx, array(,eax,4) ; array[index] = ecx
最后目标代码优化器对上述的目标代码进行优化,比如选择合适的寻址
方式、使用位移来代替乘法运算、删除多余的指令等。上面的例子中,乘法由一条相对复杂的基址比例变址寻址(Base Index Scale
Addressing)的lea指令完成,随后由一条mov指令完成最后的赋值操
作,这条mov指令的寻址方式与lea是一样的。
movl index, %edxleal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)
现代的编译器有着异常复杂的结构,这是因为现代高级编程语言本身非
常地复杂,比如C++语言的定义就极为复杂,至今没有一个编译器能够
完整支持C++语言标准所规定的所有语言特性。另外现代的计算机CPU
相当地复杂,CPU本身采用了诸如流水线、多发射、超标量等诸多复杂
的特性,为了支持这些特性,编译器的机器指令优化过程也变得十分复
杂。使得编译过程更为复杂的是有些编译器支持多种硬件平台,即允许
编译器编译出多种目标CPU的代码。比如著名的GCC编译器就几乎支持
所有CPU平台,这也导致了编译器的指令生成过程更为复杂。
经过这些扫描、语法分析、语义分析、源代码优化、代码生成和目标代
码优化,编译器忙活了这么多个步骤以后,源代码终于被编译成了目标
代码。但是这个目标代码中有一个问题是:index和array的地址还没有
确定。如果我们要把目标代码使用汇编器编译成真正能够在机器上执行
的指令,那么index和array的地址应该从哪儿得到呢?如果index和array
定义在跟上面的源代码同一个编译单元里面,那么编译器可以为index
和array分配空间,确定它们的地址;那如果是定义在其他的程序模块
呢?
这个看似简单的问题引出了我们一个很大的话题:目标代码中有变量定
义在其他模块,该怎么办?事实上,定义其他模块的全局变量和函数在
最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编
译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接
器最终将这些目标文件链接起来形成可执行文件。让我们带着这个问
题,走进链接的世界。
2.3 链接器年龄比编译器长
很久很久以前,在一个非常遥远的银河系……人们编写程序时,将所有
源代码都写在同一个文件中,发展到后来一个程序源代码的文件长达数
百万行,以至于这个地方的人类已经没有能力维护这个程序了。人们开
始寻找新的办法,一场新的软件开发革命即将爆发……
为了更好地理解计算机程序的编译和链接的过程,我们简单地回顾计算
机程序开发的历史一定会非常有益。计算机的程序开发并非从一开始就有着这么复杂的自动化编译、链接过程。原始的链接概念远在高级程序
语言发明之前就已经存在了,在最开始的时候,程序员(当时程序员的
概念应该跟现在相差很大了)先把一个程序在纸上写好,当然当时没有
很高级的语言,用的都是机器语言,甚至连汇编语言都没有。当程序须
要被运行时,程序员人工将他写的程序写入到存储设备上,最原始的存
储设备之一就是纸带,即在纸带上打相应的孔。
这个过程我们可以通过图2-6来看到,假设有一种计算机,它的每条指
令是1个字节,也就是8位。我们假设有一种跳转指令,它的高4位是
0001,表示这是一条跳转指令;低4
图2-6 纸带与机器指
令 …
位存放的是跳转目的地的绝对地址。我们可以从图2-6中看到,这个程
序的第一条指令就是一条跳转指令,它的目的地址是第5条指令(注
意,第5条指令的绝对地址是4)。至于0和1怎么映射到纸带上,这个应
该很容易理解,比如我们可以规定纸带上每行有8个孔位,每个孔位代
表一位,穿孔表示0,未穿孔表示1。
现在问题来了,程序并不是一写好就永远不变化的,它可能会经常被修
改。比如我们在第1条指令之后、第5条指令之前插入了一条或多条指
令,那么第5条指令及后面的指令的位置将会相应地往后移动,原先第
一条指令的低4位的数字将需要相应地调整。在这个过程中,程序员需
要人工重新计算每个子程序或跳转的目标地址。当程序修改的时候,这
些位置都要重新计算,十分繁琐又耗时,并且很容易出错。这种重新计
算各个目标的地址过程被叫做重定位(Relocation)。
如果我们有多条纸带的程序,这些程序之间可能会有类似的跨纸带之间的跳转,这种程序经常修改导致跳转目标地址变化在程序拥有多个模块
的时候更为严重。人工绑定进行指令的修正以确保所有的跳转目标地址
都正确,在程序规模越来越大以后变得越来越复杂和繁琐。
没办法,这种黑暗的程序员生活是没有办法容忍的。先驱者发明了汇编
语言,这相比机器语言来说是个很大的进步。汇编语言使用接近人类的
各种符号和标记来帮助记忆,比如指令采用两个或三个字母的缩写,记
住“jmp”比记住0001XXXX是跳转(jump)指令容易得多了;汇编语言
还可以使用符号来标记位置,比如一个符号“divide”表示一个除法子程
序的起始地址,比记住从某个位置开始的第几条指令是除法子程序方便
得多。最重要的是,这种符号的方法使得人们从具体的指令地址中逐步
解放出来。比如前面纸带程序中,我们把刚开始第5条指令开始的子程
序命名为“foo”,那么第一条指令的汇编就是:
jmp foo
当然人们可以使用这种符号命名子程序或跳转目标以后,不管这
个“foo”之前插入或减少了多少条指令导致“foo”目标地址发生了什么变
化,汇编器在每次汇编程序的时候会重新计算“foo”这个符号的地址,然后把所有引用到“foo”的指令修正到这个正确的地址。整个过程不需
要人工参与,对于一个有成百上千个类似的符号的程序,程序员终于摆
脱了这种低级的繁琐的调整地址的工作,用一句政治口号来说叫做“极
大地解放了生产力”。符号(Symbol)这个概念随着汇编语言的普及迅
速被使用,它用来表示一个地址,这个地址可能是一段子程序(后来发
展成函数)的起始地址,也可以是一个变量的起始地址。
有了汇编语言以后,生产力大大提高了,随之而来的是软件的规模也开
始日渐庞大,这时程序的代码量也已经开始快速地膨胀,导致人们要开
始考虑将不同功能的代码以一定的方式组织起来,使得更加容易阅读和
理解,以便于日后修改和重复使用。自然而然,人们开始将代码按照功
能或性质划分,分别形成不同的功能模块,不同的模块之间按照层次结
构或其他结构来组织。这个在现代的软件源代码组织中很常见,比如在
C语言中,最小的单位是变量和函数,若干个变量和函数组成一个模
块,存放在一个“.c”的源代码文件里,然后这些源代码文件按照目录结
构来组织。在比较高级的语言中,如Java中,每个类是一个基本的模
块,若干个类模块组成一个包(Package),若干个包组合成一个程
序。在现代软件开发过程中,软件的规模往往都很大,动辄数百万行代码,如果都放在一个模块肯定无法想象。所以现代的大型软件往往拥有成千
上万个模块,这些模块之间相互依赖又相对独立。这种按照层次化及模
块化存储和组织源代码有很多好处,比如代码更容易阅读、理解、重
用,每个模块可以单独开发、编译、测试,改变部分代码不需要编译整
个程序等。
在一个程序被分割成多个模块以后,这些模块之间最后如何组合形成一
个单一的程序是须解决的问题。模块之间如何组合的问题可以归结为模
块之间如何通信的问题,最常见的属于静态语言的CC++模块之间通信
有两种方式,一种是模块间的函数调用,另外一种是模块间的变量访
问。函数访问须知道目标函数的地址,变量访问也须知道目标变量的地
址,所以这两种方式都可以归结为一种方式,那就是模块间符号的引
用。模块间依靠符号来通信类似于拼图版,定义符号的模块多出一块区
域,引用该符号的模块刚好少了那一块区域,两者一拼接刚好完美组合
(见图2-7)。这个模块的拼接过程就是本书的一个主题:链接
(Linking)。
图2-7 模块间拼合 …
这种基于符号的模块化的一个直接结果是链接过程在整个程序开发中变
得十分重要和突出。我们在本书的后面将可以看到链接器如何将这些编
译后的模块链接到一起,最终产生一个可以执行的程序。
2.4 模块拼装——静态链接
程序设计的模块化是人们一直在追求的目标,因为当一个系统十分复杂
的时候,我们不得不将一个复杂的系统逐步分割成小的系统以达到各个
突破的目的。一个复杂的软件也如此,人们把每个源代码模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接
(Linking)。链接的主要内容就是把各个模块之间相互引用的部分都处
理好,使得各个模块之间能够正确地衔接。链接器所要做的工作其实跟
前面所描述的“程序员人工调整地址”本质上没什么两样,只不过现代的
高级语言的诸多特性和功能,使得编译器、链接器更为复杂,功能更为
强大,但从原理上来讲,它的工作无非就是把一些指令对其他符号地址
的引用加以修正。链接过程主要包括了地址和空间分配(Address and
Storage Allocation)、符号决议(Symbol Resolution)和重定位
(Relocation)等这些步骤。
符号决议有时候也被叫做符号绑定(Symbol Binding)、名称绑定
(Name Binding)、名称决议(Name Resolution),甚至还有叫做地址
绑定(Address Binding)、指令绑定(Instruction Binding)的,大体上
它们的意思都一样,但从细节角度来区分,它们之间还是存在一定区别
的,比如“决议”更倾向于静态链接,而“绑定”更倾向于动态链接,即它
们所使用的范围不一样。在静态链接,我们将统一称为符号决议。
最基本的静态链接过程如图2-8所示。每个模块的源代码文件(如.c)文
件经过编译器编译成目标文件(Object File,一般扩展名为.o或.obj),目标文件和库(Library)一起链接图2-8 链接过程 …
形成最终可执行文件。而最常见的库就是运行时库(Runtime
Library),它是支持程序运行的基本函数的集合。库其实是一组目标文
件的包,就是一些最常用的代码编译成目标文件后打包存放。关于库本
书的后面还会再详细分析。
我们认为对于Object文件没有一个很合适的中文名称,把它叫做中间目
标文件比较合适,简称为目标文件,所以本书后面的内容都将称Object
文件为目标文件,很多时候我们也把目标文件称为模块。
现代的编译和链接过程也并非想象中的那么复杂,它还是一个比较容易
理解的概念。比如我们在程序模块main.c中使用另外一个模块func.c中的
函数foo。我们在main.c模块中每一处调用foo的时候都必须确切知道
foo这个函数的地址,但是由于每个模块都是单独编译的,在编译器编
译main.c的时候它并不知道foo函数的地址,所以它暂时把这些调用foo
的指令的目标地址搁置,等待最后链接的时候由链接器去将这些指令的
目标地址修正。如果没有链接器,须要我们手工把每个调用foo的指令
进行修正,则填入正确的foo函数地址。当func.c模块被重新编译,foo函
数的地址有可能改变时,那么我们在main.c中所有使用到foo的地址的指
令将要全部重新调整。这些繁琐的工作将成为程序员的噩梦。使用链接
器,你可以直接引用其他模块的函数和全局变量而无须知道它们的地
址,因为链接器在链接的时候,会根据你所引用的符号 foo,自动去相
应的func.c模块查找foo的地址,然后将main.c模块中所有引用到foo的指
令重新修正,让它们的目标地址为真正的foo函数的地址。这就是静态
链接的最基本的过程和作用。
在链接过程中,对其他定义在目标文件中的函数调用的指令须要被重新
调整,对使用其他定义在其他目标文件的变量来说,也存在同样的问
题。让我们结合具体的CPU指令来了解这个过程。假设我们有个全局变
量叫做var,它在目标文件A里面。我们在目标文件B里面要访问这个全
局变量,比如我们在目标文件B里面有这么一条指令:
movl 0x2a, var
这条指令就是给这个var变量赋值0x2a,相当于C语言里面的语句var =
42。然后我们编译目标文件B,得到这条指令机器码,如图2-9所示。图2-9 传送指令 …
由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所
以编译器在没法确定地址的情况下,将这条mov指令的目标地址置为
0,等待链接器在将目标文件A和B链接起来的时候再将其修正。我们假
设A和B链接后,变量var的地址确定下来为0x1000,那么链接器将会把
这个指令的目标地址部分修改成0x10000。这个地址修正的过程也被叫
做重定位(Relocation),每个要被修正的地方叫一个重定位入口
(Relocation Entry)。重定位所做的就是给程序中每个这样的绝对地址
引用的位置“打补丁”,使它们指向正确的地址。
2.5 本章小结
在这一章中,我们首先回顾了从程序源代码到最终可执行文件的4个步
骤:预编译、编译、汇编、链接,分析了它们的作用及相互之间的联系,IDE集成开发工具和编译器默认的命令通常将这些步骤合并成一
步,使得我们通常很少关注这些步骤。
我们还详细回顾了上面这4个步骤中的主要部分,即编译步骤。介绍了
编译器将C程序源代码转变成汇编代码的若干个步骤:词法分析、语法
分析、语义分析、中间代码生成、目标代码生成与优化。最后我们介绍
了链接的历史和静态链接的一系列基本概念:重定位、符号、符号决
议、目标文件、库、运行库等概念。
第3章 目标文件里有什么
3.1 目标文件的格式
3.2 目标文件是什么样的
3.3 挖掘SimpleSection.o
3.4 ELF文件结构描述
3.5 链接的接口——符号
3.6 调试信息
3.7 本章小结
编译器编译源代码后生成的文件叫做目标文件,那么目标文件里面到底
存放的是什么呢?或者我们的源代码在经过编译以后是怎么存储的?我
们将在这一节剥开目标文件的层层外壳,去探索它最本质的内容。
目标文件从结构上讲,它是已经编译后的可执行文件格式,只是还没有
经过链接的过程,其中可能有些符号或有些地址还没有被调整。其实它
本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构
上稍有不同。
可执行文件格式涵盖了程序的编译、链接、装载和执行的各个方面。了
解它的结构并深入剖析它对于认识系统、了解背后的机理大有好处。
3.1 目标文件的格式现在PC平台流行的可执行文件格式(Executable)主要是Windows下的
PE(Portable Executable)和Linux的ELF(Executable Linkable
Format),它们都是COFF(Common file format)格式的变种。目标文
件就是源代码编译后但未进行链接的那些中间文件(Windows的.obj和
Linux下的.o),它跟可执行文件的内容与结构很相似,所以一般跟可执
行文件格式一起采用一种格式存储。从广义上看,目标文件与可执行文
件的格式其实几乎是一样的,所以我们可以广义地将目标文件与可执行
文件看成是一种类型的文件,在Windows下,我们可以统称它们为PE-
COFF文件格式。在Linux下,我们可以将它们统称为ELF文件。其他不
太常见的可执行文件格式还有IntelMicrosoft的OMF(Object Module
Format)、Unix a.out格式和MS-DOS .COM格式等。
不光是可执行文件(Windows的.exe和Linux下的ELF可执行文件)按照
可执行文件格式存储。动态链接库(DLL,Dynamic Linking Library)
(Windows的.dll和Linux的.so)及静态链接库(Static Linking Library)
(Windows的.lib和Linux的.a)文件都按照可执行文件格式存储。它们
在Windows下都按照PE-COFF格式存储,Linux下按照ELF格式存储。静
态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再
加上一些索引,你可以简单地把它理解为一个包含有很多目标文件的文
件包。ELF文件标准里面把系统中采用ELF格式的文件归为如表3-1所列
举的4类。表3-1 …
我们可以在Linux下使用file命令来查看相应的文件格式,上面几种文件
在file命令下会显示出相应的类型:
file foobar.o
foobar.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
file binbash
binbash: ELF 32-
bit LSB executable, Intel 80386, version 1 (SYSV), for GNULinux 2.6.8, dynamically linked (uses shared libs), stripped
file libld-2.6.1.so
liblibc-2.6.1.so: ELF 32-
bit LSB shared object, Intel 80386, version 1 (SYSV), for GNULinux 2.6.8, stripped
目标文件与可执行文件格式的小历史目标文件与可执行文件格式跟操作系统和编译器密切相关,所以不同的
系统平台下会有不同的格式,但这些格式又大同小异,目标文件格式与
可执行文件格式的历史几乎是操作系统的发展史。
COFF是由Unix System V Release 3首先提出并且使用的格式规范,后来
微软公司基于COFF格式,制定了PE格式标准,并将其用于当时的
Windows NT系统。System V Release 4在COFF的基础上引入了ELF格
式,目前流行的Linux系统也以ELF作为基本可执行文件格式。这也就是
为什么目前PE和ELF如此相似的主要原因,因为它们都是源于同一种可
执行文件格式COFF。
Unix最早的可执行文件格式为a.out格式,它的设计非常地简单,以至于
后来共享库这个概念出现的时候,a.out格式就变得捉襟见肘了。于是人
们设计了COFF格式来解决这些问题,这个设计非常通用,以至于COFF
的继承者到目前还在被广泛地使用。
COFF的主要贡献是在目标文件里面引入了“段”的机制,不同的目标文
件可以拥有不同数量及不同类型的“段”。另外,它还定义了调试数据格
式。
下文的剖析我们以ELF结构为主。然后会专门分析PE-COFF文件结构,并对比其与ELF的异同。
3.2 目标文件是什么样的
我们大概能猜到,目标文件中的内容至少有编译后的机器指令代码、数
据。没错,除了这些内容以外,目标文件中还包括了链接时所须要的一
些信息,比如符号表、调试信息、字符串等。一般目标文件将这些信息
按不同的属性,以“节”(Section)的形式存储,有时候也
叫“段”(Segment),在一般情况下,它们都表示一个一定长度的区
域,基本上不加以区别,唯一的区别是在ELF的链接视图和装载视图的
时候,后面会专门提到。在本书中,默认情况下统一将它们称为“段”。
程序源代码编译后的机器指令经常被放在代码段(Code Section)里,代码段常见的名字有“.code”或“.text”;全局变量和局部静态变量数据经
常放在数据段(Data Section),数据段的一般名字都叫“.data”。让我们
来看一个简单的程序被编译成目标文件后的结构,如图3-1所示。图3-1 程序与目标文件 …
假设图3-1的可执行文件(目标文件)的格式是ELF,从图中可以看到,ELF文件的开头是一个“文件头”,它描述了整个文件的文件属性,包括
文件是否可执行、是静态链接还是动态链接及入口地址(如果是可执行
文件)、目标硬件、目标操作系统等信息,文件头还包括一个段表
(Section Table),段表其实是一个描述文件中各个段的数组。段表描
述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息。文件头后面就是各个段的内容,比如代码段保
存的就是程序的指令,数据段保存的就是程序的静态变量等。
对照图3-1来看,一般C语言的编译后执行语句都编译成机器代码,保存
在.text段;已初始化的全局变量和局部静态变量都保存在. data段;未初
始化的全局变量和局部静态变量一般放在一个叫.“bss”的段里。我们知
道未初始化的全局变量和局部静态变量默认值都为0,本来它们也可以
被放在.data段的,但是因为它们都是0,所以为它们在.data段分配空间
并且存放数据0是没有必要的。程序运行的时候它们的确是要占内存空
间的,并且可执行文件必须记录所有未初始化的全局变量和局部静态变
量的大小总和,记为.bss段。所以.bss段只是为未初始化的全局变量和局
部静态变量预留位置而已,它并没有内容,所以它在文件中也不占据空
间。
BSS历史
BSS(Block Started by Symbol)这个词最初是UA-SAP汇编器(United
Aircraft Symbolic Assembly Program)中的一个伪指令,用于为符号预留
一块内存空间。该汇编器由美国联合航空公司于20世纪50年代中期为
IBM 704大型机所开发。
后来BSS这个词被作为关键字引入到了IBM 709和709094机型上的标准
汇编器FAP(Fortran Assembly Program),用于定义符号并且为该符号
预留给定数量的未初始化空间。
Unix FAQ section 1.3(http:www.faqs.orgfaqsunix-faqfaqpart1section-
3.html)里面有Unix和C语言之父Dennis Rithcie对BSS这个词由来的解
释。
总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数
据。代码段属于程序指令,而数据段和.bss段属于程序数据。
很多人可能会有疑问:为什么要那么麻烦,把程序的指令和数据的存放
分开?混杂地放在一个段里面不是更加简单?其实数据和指令分段的好
处有很多。主要有如下几个方面。
一方面是当程序被装载后,数据和指令分别被映射到两个虚存区域。
由于数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,所以这两个虚存区域的权限可以被分别设置成可读写和只读。这
样可以防止程序的指令被有意或无意地改写。
另外一方面是对于现代的CPU来说,它们有着极为强大的缓存
(Cache)体系。由于缓存在现代的计算机中地位非常重要,所以程序
必须尽量提高缓存的命中率。指令区和数据区的分离有利于提高程序的
局部性。现代CPU的缓存一般都被设计成数据缓存和指令缓存分离,所
以程序的指令和数据被分开存放对CPU的缓存命中率提高有好处。
第三个原因,其实也是最重要的原因,就是当系统中运行着多个该程
序的副本时,它们的指令都是一样的,所以内存中只须要保存一份该程
序的指令部分。对于指令这种只读的区域来说是这样,对于其他的只读
数据也一样,比如很多程序里面带有的图标、图片、文本等资源也是属
于可以共享的。当然每个副本进程的数据区域是不一样的,它们是进程
私有的。不要小看这个共享指令的概念,它在现代的操作系统里面占据
了极为重要的地位,特别是在有动态链接的系统中,可以节省大量的内
存。比如我们常用的Windows Internet Explorer 7.0运行起来以后,它的
总虚存空间为112 844 KB,它的私有部分数据为15 944 KB,即有96 900
KB的空间是共享部分(数据来源见图3-2)。如果系统中运行了数百个
进程,可以想象共享的方法来节省大量空间。关于内存共享的更为深入
的内容我们将在装载这一章探讨。 图3-2 Process Explorer
下查看进程IExplorer.exe的进程信息 …
3.3 挖掘SimpleSection.o
前面对于目标文件只是作了概念上的阐述,如果不彻底深入目标文件的
具体细节,相信这样的分析也只是泛泛而谈,没有真正深入理解的效
果。就像知道TCPIP协议是基于包的结构,但是从来却没有看到过包的
结构是怎样的,包的头部有哪些内容?目标地址和源地址是怎么存放
的?如果不了解这些,那么对于TCPIP的了解是粗略的,不够细致的。
很多问题其实在表面上看似很简单,其实深入内部会发现很多鲜为人知
的秘密,或者发现以前自己认为理所当然的东西居然是错误的,或者是
有偏差的。对于系统软件也是如此,不了解ELF文件的结构细节就像学
习了TCPIP网络没有了解IP包头的结构一样。本节后面的内容就是以
ELF目标文件格式作为例子,彻底深入剖析目标文件,争取不放过任何
一个字节。真正了不起的程序员对自己的程序的每一个字节都了如指掌。
——佚名
我们就以前面提到过的SimpleSection.c编译出来的目标文件作为分析对
象,这个程序是经过精心挑选的,具有一定的代表性而又不至于过于繁
琐和复杂。在接下来所进行的一系列编译、链接和相关的实验过程中,我们将会用到第1章所提到过的工具套件,比如GCC编译器、binutils等
工具,如果你忘了这些工具怎么使用,那么在阅读过程中可以再回去参
考本书第1部分的内容。图3-1中的程序代码如清单3-1所示。
清单3-1
SimpleSection.c
Linux:
gcc -c SimpleSection.c
Windows:
cl SimpleSection.c c Za
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;
}
如不加说明,则以下所分析的都是32位Intel x86平台下的ELF文件格
式。
我们使用GCC来编译这个文件(参数 -c 表示只编译不链接):
gcc –c SimpleSection.c
我们得到了一个1 104字节(该文件大小可能会因为编译器版本以及机
器平台不同而变化)的SimpleSection.o目标文件。我们可以使用binutils
的工具objdump来查看object内部的结构,这个工具在第1部分已经介绍
过了,它可以用来查看各种目标文件的结构和内容。运行以下命令:
objdump -h SimpleSection.o
SimpleSection.o: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000005b 00000000 00000000 00000034 22
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 00000000 00000000 00000090 ......
您现在查看是摘要介绍页, 详见PDF附件(12415KB,637页)。





