影响世界历史的100部名著
[1] 《圣经》
它是了解西方文化的钥匙! 作为基督教的正式经典,其最重要的主题是人。
[2] 《古兰经》
伊斯兰教的经典。从对世俗的征服来看,它表现的威力比任何神迹都大。
[3] 牛顿《自然哲学的数学原理》
没有牛顿,就没有近代科学。牛顿第一次表明人可以像上帝那样洞察世界的奥妙,这种思想在思想史上是空前的。
[4] 达尔文《物种起源》
达尔文的伟大之处,就是在于在看来不变的事物中找到变化,在看来没有联系的事物中找到关系。
[5] 弗洛伊德《梦的解析》
梦是最一般的心理现象之一,也是各种迷信和预言的来源之一。弗洛伊德主要功绩在于理性地把梦作为一种对象来研究,并得出划时代的发现——潜意识。弗洛伊德指出,人并不是自己行为和精神的主宰,他在很大程度上受潜意识控制。
[6] 欧几里得《几何原本》
除了《圣经》以外,没有任何其他著作有这么多人阅读、学习和研究,正是《几何原本》把数学变成脱离实际的纯粹数学,并且对于近代科学有着不可替代的作用。
[7] 亚当·斯密《国富论》
用科学的方法对经济体制运行规律进行研究,斯密发现了任何时代、任何社会运行的主要矛盾——公平与效率。
[8] 马尔萨斯《人口原理》
两个世纪以来,马尔萨斯可以说被攻击得体无完肤。不过,即便他的每一句话、每个数据都是错的,其精神实质总是得到辉煌的证实。
[9] 卡逊《寂静的春天》
同《人口原理》一样,《寂静的春天》是人类对自己的生存状况提出严重警告的警世之作,在它出版四十多年之后,“环境”已经成为最流行的热门词条之一。
[10] 马克思,恩格斯《共产党宣言》
这是一部气势磅礴的作品,同时它又是具有深刻思想的理论著作,这两者的结合造成它的非凡的影响。它是“共产主义的圣经”,真正影响了千百万人的思想和行动。
[11] 《论语》
代表中国文化的首屈一指的著作。其核心思想在于人和人之间有差别、不能平等,这也是两千五百年来中国文化的核心。许多古代文化消亡了,而中国文化却在变化中存续下来,这可能是孔子和他的《论语》的功劳。
[12] 孙武《孙子兵法》
它是中国也是世界上最早以及最有影响的军事理论著作,它同儒家思想共同塑造了中国传统文化,而这种思想完全深入到现代人的思想之中。《孙子兵法》提供的“计谋”形成中华民族两千五百年来斗争哲学的主导技术,但单纯技术观点并不能带来胜利,许多情况下还遭到失败。
[13] 拉瓦锡《化学原论》
人类面对的就是多样多彩的物质世界,化学就是关于多样性的科学,而且化学不单纯是“自然科学”,它还是人工科学。不少人低估了化学革命,也没有对拉瓦锡的功绩予以充分的肯定。
[14] 麦克斯韦《电磁通论》
麦克斯韦的电磁理论不仅在理论上是物理科学的重大突破和完美综合,而且从技术上产生出惊人结果。一方面通过电工学使整个文明社会电气化,使工业自动化成为可能;另一方面,通过电磁波的预言和发现,直接把人类引导到无线电世纪,而这构成了信息与传媒社会的必不可少的物质基础。
[15] M.韦伯《新教伦理与资本主义精神》
马克斯·韦伯是20世纪最伟大的社会科学家,从某种意义上讲可以与马克思相提并论。马克思侧重经济基础,而韦伯则强调意识形态,特别是宗教的作用。在一个片面强调竞争、优胜劣汰、尔虞我诈的社会中,理性的经济伦理——诚实、信任、责任、互惠是多么难以建立起来。而这就是韦伯的新教伦理学说的重要意义。
[16] J.S.穆勒《论自由》
“自由”是一个美丽的字眼,但不少人对它只是泛泛而谈,许多人批判它更是偷换概念、言不及义。穆勒的《论自由》是迄今为止论述社会自由最重要的著作,也是第一批介绍到中国的世界名著。遗憾的是,一百年后,一些中国人对此书的内容仍然茫然无知。
[17] 孟德斯鸠《论法的精神》
法律是人类最古老、也是最普遍的建制之一。历史上的许多政治理想和社会大同的思想,最终只有通过法律才能落在实处。所以历代思想家对法律多有思考,但他们往往从某一角度出发,而带有一定的局限。本书是第一部系统的法学巨著,其核心在于法律至上和三权分立学说。这不仅在历史上有着重大作用,而且对今日仍有不可忽视的影响。
[18] 卢梭《社会契约论》
启蒙运动时期是一个思想大解放的时代,它与工业制度的结合是社会现代化的两个动力。卢梭是法国启蒙运动时期最有影响的思想家;但时至今日,人们对卢梭仍有许多误解,不是认为他的思想和其他人差不多,就是认为他十分激进。仔细研究一下他的思想,会发现他有许多独创之处。
[19] 希特勒《我的奋斗》
《我的奋斗》是一个非常好的反面教材。希特勒在书中反复明确宣传他的政治理想。概括起来就是:种族主义、大日耳曼民族主义、生存空间、第三帝国的理想国。这些思想在希特勒死后并没有消失,仍然在以各种形式延续着。
[20] 麦克卢汉《理解媒体》
我们生活在信息时代,可是很少人关注信息的传播;我们生活在媒体的包围之中,可是没什么人注意到它的影响。麦克卢汉是最早开始关心媒体对个人、对社会的改造的人,提出“媒体即消息”的论点。他开拓了一个无尽的前沿,但还有许多问题需要进一步挖掘。
[21] 柏拉图《共和国》
世界哲学归根溯源只有两种,其中之一是希腊哲学。了解希腊哲学,首先要谈柏拉图。柏拉图的伟大之处就在于他把哲学提高到一个新的境界:哲学不是那种空洞言词的游戏,也不是宗教和意识形态的教条。哲学要对一般概念和理论进行发挥和论证。
[22] 亚里士多德《工具论》
亚里士多德是许多学科的开创者。与柏拉图不同,他是学科的体系化者;后来许多哲学、科学体系的建立是以他的体系为模式来做的。《工具论》是亚里士多德的逻辑论文的汇编。逻辑和语言一样,平时感觉不到它有多重要,而只有在思想混乱一团时才感到其必不可少。逻辑是整理思想和知识的框架,没有它,理论和科学都无从产生。
[23] 薛定谔《生命是什么?》
20世纪生命科学最重要的事件是分子生物学的产生,分子生物学来自沃森和克里克的DNA双螺旋模型。而这两位科学家恰巧都受到薛定谔这本小册子的深刻影响,他们的成就,使其后五十年生物学完全改变面貌,而且还将在下一个五十年、一百年改变世界的面貌。这条路恰巧是一位连化学都不太懂的物理学家打通的。
[24] 维纳《控制论》
该书实际上预示了第二次世界大战以后一整套新学科的产生。控制论所包括的内容十分庞杂;20世纪晚期发展起来的一套理论,如非线性科学、浑沌理论、复杂性理论、人工生命、直接或间接的与控制论特别是维纳的思想有关。
[25] 威尔逊《社会生物学:新的综合》
正如威尔逊所说,社会生物学的主要目的,是建立一个普遍理论,使之能够依据群体的各种参数关系以及由物种遗传结构所产生的行为在受到制约的情况下,来预见社会组织的特征。显然,这在理论上是一个非常大胆的尝试。正如达尔文一样,只要把动物同人类联系起来,终将会受到挑战。
[26] 莎士比亚《哈姆雷特》
莎士比亚对人的本质的洞察上升到了哲学的高度。从古到今,人最感兴趣的问题还是人本身,人希望认识自己;而这恰巧是人本主义或人文主义的实质。但人是复杂的,这种认识也不是一蹴而就的,所以文艺作品为我们提供一个最佳的场所。
[27] 陀斯妥耶夫斯基《卡拉马佐夫兄弟们》
陀斯妥耶夫斯基在作家当中是最伟大的思想家。如果说,“文学是人学”,那他就是人类心灵最为深刻的探索者。或许,是人心的洞察者。俗话说“人心叵测”,这似乎有贬义,但人心的确难以用科学的方法、理性的方法来度量,而文学作品却可以弥补科学的不足。
[28] 孔德《实证哲学教程》
孔德的《实证哲学教程》可以看成是19世纪初思想状况的一个百科全书,从这里勾画出未来发展的蓝图。这本百科全书的大纲就是科学分类。学科的不断分化与专门化是一种不可遏制的趋势,只有科学分类才能告诉我们它们发展的逻辑线索和历史线索。
[29] 边沁《道德与立法原理引论》
边沁在国内不大为人所知,但不少人对他的“功利主义”应该有所耳闻。边沁的功利主义主要问题在于他过于强调个人幸福与公共幸福的一致性,而且事在人为,总可以使其一致。虽然边沁的每一种理想都遭到许多反对,但是在英国本土却渐进地得到贯彻,这也许是19世纪英国得到和平发展的原因所在。
[30] 《奥义书》
世界哲学归根溯源只有两种,其中之一是印度哲学。但印度哲学过于抽象、过于脱离现实和现世,它没能繁衍出健康的自然哲学和社会哲学,而只是停留在纯哲学的层面,更多的是塑造印度人乃至印度宗教的精神。《奥义书》是印度哲学的源泉。它首先从巫术、仪式中过渡到哲学性质的问题,并谋求其解答;它开辟了印度哲学的先河。
[31] 歌德《浮士德》
歌德用“古典的”形式创造一个崭新的,完全属于新时代的理想的人。在歌德的浮士德身上,最主要的是为求知识的无尽的探索精神,这个精神可称之为浮士德精神。正是这种精神成就了西方的科学昌明与文化鼎盛的时代。
[32] 塞万提斯《唐·吉诃德》
名著的伟大之处在于它有助于精神的提升,精神提升的关键在于人生意义的认同。唐·吉诃德和他的仆人桑丘以及书中的其他人物对每个读者来说都不陌生,一个人在他生活中,总会遇到类似的人物和情景。除了技术进步之外,你会发现现代的人性与塞万提斯描写的16世纪的人性与非人性何其相似乃尔。
[33] 凯恩斯《就业、利息和货币通论》
不管你相信不相信资本主义必将灭亡,20世纪没能灭亡恐怕得归功于凯恩斯,是他拯救了资本主义免遭灭顶之灾。战后经济学的理论框架也是他奠定的。
[34] 凡勃仑《有闲阶级论》
阶级的划分除了马克思的经典定义之外,一般使用比较随便,但划分的依据不外乎政治、经济与身份地位。但凡勃仑是一位另类的思想家,他把人按“闲暇”来分类,分成有闲阶级和无闲阶级。由于有闲阶级越来越成为现代生活中十分重要的因素,有必要对有闲阶级的地位和存在价值做一番探讨。
[35] 福柯《词与物》
很难用一句话概括这本书,实在要说,那就是“学科分类与演化的科学框架”。在学科肆意泛滥的今天,科学的分类实属当务之急。
[36] 罗尔斯《正义论》
罗尔斯的《正义论》于1971年出版。其中讨论的主题没有一样是新的,也就是说,这些都是启蒙运动时期的问题,但是,他却是在新的环境之下来考虑的。换句话说,这本书标志着道德哲学由现代到后现代的转变。
[37] 卡西尔《符号形式的哲学》
“人是什么?”——人们对哲学本来应该解决的最根本问题一直没有给出像样的答案。卡西尔解决这个问题的出发点与众不同,那就是“人是符号的动物”。这打开了一个新的通道。由于所有的文化的基础都是建立在人有形成概念的能力之上,这种能力使我们能够发明和使用人工记号和符号。卡西尔认为这些“符号形式”正是哲学所应该集中研究的对象。
[38] 乔姆斯基《句法结构》
语言是21世纪最主要的研究对象之一。人的一生都在用语言同人打交道,也通过语言进行思考;但是围绕语言有许多老大难问题至今不能理解。1957年出版的《句法结构》引起一场语言学上的乔姆斯基革命;这是一场对整个语言理论的正本清源。它不仅在语言学界激起强烈反响,而且影响到许多周边领域,涉及许多基本问题。
[39] 冯·诺伊曼,摩根斯坦《对策论与经济行为》
冯·诺伊曼的工作从方法上标志着数理经济学的新时代。他的方法证明,现代数学的公理思想,抽象的概念对于实际问题一样有着巨大的应用价值。他的哲学观点预示着未来数学家的工作——数学家可以在极其广泛的领域中选择课题进行研究,无论是对策论还是与经济学的结合已经取得而且必将取得重大进展。
[40] 微耳和《细胞病理学》
现在的西医被称为“科学的”,归根结底,有赖于把疾病的原因建立在实证的基础上。而这正好是德国医学家微耳和的主要贡献。他是细胞病理学的缔造者。1858年他发表了《细胞病理学》,其中完整地阐述细胞学说,并声称“所有细胞来自细胞”。
[41] 汤因比《历史研究》
今天的新闻就是明天的历史,有识之士总要考虑当前与过去的关联,将来的发展趋势等等;而这就需要通过学习和研究历史,建立一种历史哲学或历史观。而史论就是为这些人打造航海罗盘的。汤因比的《历史研究》在众多的史论中占有十分重要的地位,因为它包含大量的理论概括及创新。
[42] 布罗代尔《15至18世纪的物质文明、经济与资本主义》
到了20世纪,史学界出现一次大的革命,那就是“年鉴学派”的兴起。年鉴学派反对把历史局限于政治史范围之内,主张研究历史的全貌。布罗代尔就是年鉴学派第二代的代表人物,由于他的工作,年鉴学派在二战后占主流地位,其影响也从法国扩大到全球。
[43] 罗素《自由与组织》
在18世纪末,可以说所有国家都在同一起跑线上,却因为各种原因产生了如今如此惊人的差距。过去的历史哲学也许能指明历史发展的方向和动力,但无法预测历史发展的速度。而19~20世纪与以往历史的不同之处正好在于速度的变化。罗素的高明之处就在于他能指出引向这种变化的因素。
[44] 霍布斯鲍姆《极端的年代》
在整个历史长河中,20世纪究竟是继往开来走向进步走向辉煌的时代,还是一个无足轻重的一个小插曲,现在下结论还为时过早。但作为一个极端的时代,肯定会遗留下相当多的后遗症,值得我们反思。
[45] 亨廷顿《文明的冲突与世界秩序的重建》
在21世纪,各种文明的命运是我们首先要关心的问题。本书就是在历史转折关头反思的结果。亨廷顿害怕伊斯兰文明与儒教文明联手打败基督教文明,但是前两者是不可能结合的,这点上他的判断显然有误。然而文明的冲突是否能造成新一轮的“西方的没落”,却不是没有可能。
[46] 加缪《鼠疫》
从古到今,人类不断面对各种灾难,但他们很少能够对灾难有所准备。这本讲人类如何面对灾难的书创作于希特勒时代,许多人感到无望。在这关键时刻,加缪看到面对灾难的惟一正确态度,就是不要听从命运的摆布,也不要听从权威与利益集团的煽动。
[47] 劳伦斯《查泰莱夫人的情人》
这是一本“禁书”,也许是最著名的、名副其实的禁书。尽管它因“性”被禁,却并不等于说它的题材只是性。在书中,劳伦斯不仅要求一个人真正“性”的解放,更要求从工业文明、传统文化以及精神束缚中解放出来,成为“解放的人”。这些才是劳伦斯思想的深义。
[48] 尼采《查拉图斯特拉如是说》
19世纪两位大思想家对20世纪有着持续的影响:一位是马克思,一位是尼采。这本书是尼采著作的顶峰。它包括尼采过去的一切思想,这些思想用两个新的概念,即超人和永远轮回来加以贯穿。
[49] 波普尔《科学发现的逻辑》
波普尔的《科学发现的逻辑》是科学哲学的一次革命。波普尔说:“经验科学就是理论体系,我们可以把认识逻辑称做理论的理论。”“科学的理论就是普遍的命题。”按照他的学说,科学理论“不是由观察开始,而是由问题开始”。波普尔的整个科学理论的出发点是划界问题,也就是找出一个判据来区别科学与“伪科学”的界限。
[50] 托夫勒《第三次浪潮》
人们对未来有所企盼,但同时又对未来充满恐惧;在一个变化剧烈的世界中有着大量的不确定性,人们希望驾驭它。到20世纪后半,未来学应运而生。对未来学家的评价首先在于对还没有发生的事或仅仅处于萌芽状态的东西能否做出比较准确的预测。托夫勒做到了这点。他的这本书在1980年出版,而20世纪后二十年正是按照这个蓝图来实现的。
[51] 波伏瓦《第二性》
女性占人类的一半,但研究与思考女性问题的著作却少得可怜;英译本《第二性》1953年出版后立即成为畅销书。尽管时隔半个多世纪,女性主义和谈论女性的书出版无数,但是没有一本书像这本书一样,使人感受到如此浓郁的学者气息。
[52] 纪德《伪币制造者》
在大众文化如此昌盛的今天,纪德的书不会出现在休闲读者之中。但是,文化也是两极分化的,在高雅文化发展而且存在发展土壤的地方,纪德不会消失,相反,会维持着他那至高无上的地位。他的作品很观念化。在本书中,他试图成为真的人,但什么是真的?
[53] 萨义德《知识分子的代表》
尽管关于知识分子的理论著作难以产生,但我们还是选择了萨义德的这本书。因为我们也许可以从思想自由和知识分子看到文明的未来。在书中,萨义德提出的问题令人深思,1987年贾可比的《最后的知识分子》出版,以后是否还有知识分子?
[54] 莫诺《偶然与必然》
生命科学存在着许多谜!至今我们还对生物体的精巧别致莫名其妙,我们还不知人是不是一台机器,也不知生物进化是不是一个必然的过程,也不知道生死界究竟在何处?而这本书阐述就是法国生物学家莫诺对生物进化的哲学思考。
[55] 萧伯纳《人与超人》
萧伯纳是仅次于莎士比亚的英国(爱尔兰)剧作家,在世界戏剧史上大致也处于同等地位。他的大部分剧本可以称之为“思想剧”,而其中顶尖之作可以说是《人与超人》,该剧真正表现了萧伯纳自己特有的哲学——生命力哲学。
[56] 西蒙《人工物的科学》
近二百年实验科学的发展给我们带来一个人工的世界;而制造人工物就是发明、仿造、改进、组合直至本书所说的设计。尽管现在设计科学已成为一个专业领域,但未来需要的恰巧是西蒙这样的博家而不是狭窄领域的专家。只有他们才能设计出未来人工物的世界。
[57] 泰勒《原始文化》
“文化”一词在媒体上的运用真是泛滥成灾,可是没什么人对它哪怕有最粗略的界定;是泰勒给他的研究对象“文化”以一个比较确切的定义,他的《原始文化》被公认为文化人类学的奠基之作。表面上看,它是纯学理的研究;然而,自然语言与原始思维对于21世纪人工智能的探讨有着重要意义。
[58] 怀特海《科学与近代世界》
数学在近代科学产生与发展中有着不可替代的重要作用。许多人也知道,也说,但并不理解。其实,欧洲人领先也就三百年,可为什么老跟不上呢?中国怎么拿个诺贝尔这么困难?这可以说是“新李约瑟疑难”。其实,照怀特海那样研究一下这三百年的历史,就会有八九不离十的答案。
[59] 格劳秀斯《战争与和平法》
格劳秀斯的伟大著作已经问世将近四百年了。今天战争与和平仍是世界的头号问题。格劳秀斯已经考虑到对于战犯、损害和战费不加追究以换取有保障的和平。遗憾的是,人们似乎并没有从历史中学会足够的智慧,来处理战争的后果。
[60] 埃柯《玫瑰的名字》
随着技术的进步,过去靠指纹破不了的案今天可用DNA破案了;但是,心灵的事情就说不准了。符号表达是极为复杂的一件事,符号技术远没有基因鉴定那么方便。但是,符号学会给我们带来一个全新的,难以想像的世界。埃柯的小说神奇之处就在于此。
[61] 笛卡尔《方法谈》
什么是科学精神,到现在仍然是众说纷纭。但是,科学精神一定会导致科学发展,其起点必定是求知的欲望。笛卡尔在《方法谈》一书中一开始就讲述他从学校出来后的二十年的探索过程,而且在探索过程中开创出自己独特的理论与方法。这就是笛卡尔哲学体系,其核心是认识论。正是笛卡尔把哲学扭转到了正确的方向。
[62] 培根《论学术的进展》
四百年来,尽管人们早已享受近现代科学所带来的种种福利,然而人们的头脑不一定比古时更少教条、偏见、迷信以及愚昧和疯狂的古怪思想。而培根的伟大贡献正在于列举了四种使人陷于这类错误的“偶象”(idol)或幻象。
[63] 哈耶克《通往奴役之路》
哈耶克以经济学家知名,还荣获1974年诺贝尔经济学奖,但是,他的政治哲学更有创见,更有影响。哈耶克的思想总有点不合时宜,但从长期来看又显得十分深刻。他是位彻头彻尾的自由主义者。他的真知灼见不能因意识形态的关系而被我们舍弃。
[64] 奥威尔《1984年》
在20世纪反乌托邦、反集权主义的作品中,谁又能比奥威尔写得更好呢?在他那里,既有卡夫卡的个人处境,又有A.赫胥黎的科技统治,同时还有柯斯特勒的现实主义。《1984年》是一个警世之作,就像马尔萨斯和卡逊的著作一样。
[65] 卡夫卡《审判》
卡夫卡的不朽在于他提出的问题远比他解答的多得多。在《城堡》中他谈到“提问题是主要的”。实际上这就是科学与人生的真谛。思想来源于对问题的探索,探索过程也许比拾取现成答案更有意义。
[66] C.P.斯诺《两种文化》
斯诺两种文化的论点很简单:科学家应该读过莎士比亚,而文学家应该懂得热力学第二定律讲什么。不幸的是,这种情况越来越难以实现了。到了21世纪初,除了高喊“科学与人文相结合”的口号之外,什么也没有了,一维的人降为零维的人。然而沿着本书界定的思想史的轨迹走下去,是可能升维,升到三维四维,甚至成为“超人”的。
[67] 帕斯卡《思想录》
世界上的问题,特别是人的问题,不确定性或者偶然性是经常起作用的因素。实际上,帕斯卡尔以概率的方法对于决定论的世界观提出挑战,不管这种决定论是上帝的决定论还是科学的决定论。这种方法到20世纪发展成为一套随机的决策理论。
[68] 尼赫鲁《印度的发现》
印度同中国一样也是现在世界四大文化之一,但我们对印度却知之甚少。话说回来,要想了解一个民族,最好的办法是了解其历史。尽管印度的历史支离破碎,缺环甚多;但《印度的发现》就是了解印度历史的最好的入门书。
[69] 列夫·托尔斯泰《战争与和平》
托尔斯泰无疑是有史以来最伟大的作家之一。作为大文豪,他的作品足以为他争取到不朽的地位,但作为一个伟大的人,他并不满足。他生命最后三十年就是在对人生的一些基本问题的思考中度过的。我们选《战争与和平》,是因为从中可直接看到他的历史思想。历史虽已过去,但无法用刀斧砍去,它依然影响我们的现在,也影响我们的未来。
[70] 鲁迅《阿Q正传》
要在中国作家中选出一位思想家可不容易,特立独行不是中国人的精神。稍稍离经叛道,不是棒杀,默杀,就是捧杀。无疑,每一套杀法,鲁迅都经过,只是什么时候干什么,随时代的潮涨潮落决定。但是,近百年来中国首屈一指的作家还是鲁迅,而《阿Q正传》是其代表作。
[71] 本尼迪克特《菊与刀》
进入21世纪,世界上有五个经济大国,而日本是惟一入围的东方国家,也是最快达到这一步的国家。本书完成于二战刚刚结束时,作者对日本的民族性做了很好的概括。时隔五六十年,书中的观点基本上没过时。我们可以把这点称之为“民族性”的稳定性。无疑,日本过去是、将来也会是一个有影响的民族。
[72] 加西亚·马尔科斯《百年孤独》
对大多数人来说,拉丁美洲仍是个神秘的地区。马尔科斯在这本书中用马孔多来凝缩整个哥伦比亚乃至整个拉丁美洲的命运。愚昧闭塞和混乱是百年不变的内
2008年10月31日星期五
2008年10月12日星期日
Linux内核学习笔记之网卡驱动的详细分析
学习应该是一个先把问题简单化,在把问题复杂化的过程。一开始就着手处理复杂的问题,难免让 人有心惊胆颤,捉襟见肘的感觉。读Linux网卡驱动也是一样。那长长的源码夹杂着那些我们陌生的变量和符号,望而生畏便是理所当然的了。不要担心,事情 总有解决的办法,先把一些我们管不着的代码切割出去,留下必须的部分,把框架掌握了,哪其他的事情自然就水到渠成了,这是笔者的心得。
一般在使用的Linux网卡驱动代码动辄3000行左右,这个代码量以及它所表达出来的知识量无疑是庞大的,我们有没有办法缩短一下这个代码量,使我们的 学习变的简单些呢,经过笔者的不懈努力,在仍然能够使网络设备正常工作的前提下,把它缩减到了600多行,我们把暂时还用不上的功能先割出去。这样一来, 事情就简单多了,真的就剩下一个框架了。下面我们就来剖析这个可以执行的框架。
限于篇幅,以下分析用到的所有涉及到内核中的函数代码,我都不予列出,但给出在哪个具体文件中,请读者自行查阅。
首先,我们来看看设备的初始化。当我们正确编译完我们的程序后,我们就需要把生成的目标文件加载到内核中去,我们会先ifconfig eth0 down和rmmod 8139too来卸载正在使用的网卡驱动,然后insmod 8139too.o把我们的驱动加载进去(其中8139too.o是我们编译生成的目标文件)。就像C程序有主函数main()一样,模块也有第一个执行 的函数,即module_init(rtl8139_init_module);在我们的程序中,rtl8139_init_module()在 insmod之后首先执行,它的代码如下:
static int __init rtl8139_init_module (void)
{
return pci_module_init (&rtl8139_pci_driver);
}
它直接调用了pci_module_init(),这个函数代码在Linux/drivers/net/eepro100.c中,并且把 rtl8139_pci_driver(这个结构是在我们的驱动代码里定义的,它是驱动程序和PCI设备联系的纽带)的地址作为参数传给了它。 rtl8139_pci_driver定义如下:
static struct pci_driver rtl8139_pci_driver = {
name: MODNAME,
id_table: rtl8139_pci_tbl,
probe: rtl8139_init_one,
remove: rtl8139_remove_one,
};
pci_module_init()在驱动代码里没有定义,你一定想到了,它是Linux内核提供给模块是一个标准接口,那么这个接口都干了些什么,笔者 跟踪了这个函数。里面调用了pci_register_driver(),这个函数代码在Linux/drivers/pci/pci.c中, pci_register_driver做了三件事情。
①是把带过来的参数rtl8139_pci_driver在内核中进行了注册,内核中有一个PCI设备的大的链表,这里负责把这个PCI驱动挂到里面去。
②是查看总线上所有PCI设备(网卡设备属于PCI设备的一种)的配置空间如果发现标识信息与rtl8139_pci_driver中的id_table相同即rtl8139_pci_tbl,而它的定义如下:
static struct pci_device_id rtl8139_pci_tbl[] __devinitdata = {
{0x10ec, 0x8129, PCI_ANY_ID, PCI_ANY_ID, 0, 0, 1},
{PCI_ANY_ID, 0x8139, 0x10ec, 0x8139, 0, 0,0 },
{0,}
};
那么就说明这个驱动程序就是用来驱动这个设备的,于是调用rtl8139_pci_driver中的probe函数即rtl8139_init_one, 这个函数是在我们的驱动程序中定义了的,它是用来初始化整个设备和做一些准备工作。这里需要注意一下pci_device_id是内核定义的用来辨别不同 PCI设备的一个结构,例如在我们这里0x10ec代表的是Realtek公司,我们扫描PCI设备配置空间如果发现有Realtek公司制造的设备时, 两者就对上了。当然对上了公司号后还得看其他的设备号什么的,都对上了才说明这个驱动是可以为这个设备服务的。
③是把这个rtl8139_pci_driver结构挂在这个设备的数据结构(pci_dev)上,表示这个设备从此就有了自己的驱动了。而驱动也找到了它服务的对象了。
PCI是一个总线标准,PCI总线上的设备就是PCI设备,这些设备有很多类型,当然也包括网卡设备,每一个PCI设备在内核中抽象为一个数据结构 pci_dev,它描述了一个PCI设备的所有的特性,具体请查询相关文档,本文限于篇幅无法详细描述。但是有几个地方和驱动程序的关系特别大,必须予以 说明。PCI设备都遵守PCI标准,这个部分所有的PCI设备都是一样的,每个PCI设备都有一段寄存器存储着配置空间,这一部分格式是一样的,比如第一 个寄存器总是生产商号码,如Realtek就是10ec,而Intel则是另一个数字,这些都是商家像标准组织申请的,是肯定不同的。我就可以通过配置空 间来辨别其生产商,设备号,不论你什么平台,x86也好,ppc也好,他们都是同一的标准格式。当然光有这些PCI配置空间的统一格式还是不够的,比如说 人类,都有鼻子和眼睛,但并不是所有人的鼻子和眼睛都长的一样的。网卡设备是PCI设备必须遵守规则,在设备里集成了PCI配置空间,但它是一个网卡就必 须同时集成能控制网卡工作的寄存器。而寄存器的访问就成了一个问题。在Linux里面我们是把这些寄存器映射到主存虚拟空间上的,换句话说我们的CPU访 存指令就可以访问到这些处于外设中的控制寄存器。总结一下PCI设备主要包括两类空间,一个是配置空间,它是操作系统或BIOS控制外设的统一格式的空 间,CPU指令不能访问,访问这个空间要借助BIOS功能,事实上Linux的访问配置空间的函数是通过CPU指令驱使BIOS来完成读写访问的。而另一 类是普通的控制寄存器空间,这一部分映射完后CPU可以访问来控制设备工作。
现在我们回到上面pci_register_driver的第二步,如果找到相关设备和我们的pci_device_id结构数组对上号了,说明我们找到服务对象了,则调用rtl8139_init_one,它主要做了七件事:
① 建立net_device结构,让它在内核中代表这个网络设备。但是读者可能会问,pci_dev也是代表着这个设备,那么两者有什么区别呢,正如我们上 面讨论的,网卡设备既要遵循PCI规范,也要担负起其作为网卡设备的职责,于是就分了两块,pci_dev用来负责网卡的PCI规范,而这里要说的 net_device则是负责网卡的网络设备这个职责。
dev = init_etherdev (NULL, sizeof (*tp));
if (dev == NULL) {
printk ("unable to alloc new ethernet\n");
return -ENOMEM;
}
tp = dev->priv;
init_etherdev函数在Linux/drivers/net/net_init.c中,在这个函数中分配了net_device的内存并进行了 初步的初始化。这里值得注意的是net_device中的一个成员priv,它代表着不同网卡的私有数据,比如Intel的网卡和Realtek的网卡在 内核中都是以net_device来代表。但是他们是有区别的,比如Intel和Realtek实现同一功能的方法不一样,这些都是靠着priv来体现。 所以这里把拿出来同net_device相提并论。分配内存时,net_device中除了priv以外的成员都是固定的,而priv的大小是可以任意 的,所以分配时要把priv的大小传过去。
②开启这个设备(其实是开启了设备的寄存器映射到内存的功能)
rc = pci_enable_device (pdev);
if (rc)
goto err_out;
pci_enable_device 也是一个内核开发出来的接口,代码在drivers/pci/pci.c中,笔者跟踪发现这个函数主要就是把PCI配置空间的Command域的0位和1 位置成了1,从而达到了开启设备的目的,因为rtl8139的官方datasheet中,说明了这两位的作用就是开启内存映射和I/O映射,如果不开的 话,那我们以上讨论的把控制寄存器空间映射到内存空间的这一功能就被屏蔽了,这对我们是非常不利的,除此之外,pci_enable_device还做了 些中断开启工作。
③获得各项资源
mmio_start = pci_resource_start (pdev, 1);
mmio_end = pci_resource_end (pdev, 1);
mmio_flags = pci_resource_flags (pdev, 1);
mmio_len = pci_resource_len (pdev, 1);
读者也许疑问我们的寄存器被映射到内存中的什么地方是什么时候有谁决定的呢。是这样的,在硬件加电初始化时,BIOS固件同统一检查了所有的PCI设备, 并统一为他们分配了一个和其他互不冲突的地址,让他们的驱动程序可以向这些地址映射他们的寄存器,这些地址被BIOS写进了各个设备的配置空间,因为这个 活动是一个PCI的标准的活动,所以自然写到各个设备的配置空间里而不是他们风格各异的控制寄存器空间里。当然只有BIOS可以访问配置空间。当操作系统 初始化时,他为每个PCI设备分配了pci_dev结构,并且把BIOS获得的并写到了配置空间中的地址读出来写到了pci_dev中的resource 字段中。这样以后我们在读这些地址就不需要在访问配置空间了,直接跟pci_dev要就可以了,我们这里的四个函数就是直接从pci_dev读出了相关数 据,代码在include/linux/pci.h中。定义如下:
#define pci_resource_start(dev,bar) ((dev)->resource[(bar)].start)
#define pci_resource_end(dev,bar) ((dev)->resource[(bar)].end)
这里需要说明一下,每个PCI设备有0-5一共6个地址空间,我们通常只使用前两个,这里我们把参数1传给了bar就是使用内存映射的地址空间。
④把得到的地址进行映射
ioaddr = ioremap (mmio_start, mmio_len);
if (ioaddr == NULL) {
printk ("cannot remap MMIO, aborting\n");
rc = -EIO;
goto err_out_free_res;
}
ioremap是内核提供的用来映射外设寄存器到主存的函数,我们要映射的地址已经从pci_dev中读了出来(上一步),这样就水到渠成的成功映射了而 不会和其他地址有冲突。映射完了有什么效果呢,我举个例子,比如某个网卡有100 个寄存器,他们都是连在一块的,位置是固定的,加入每个寄存器占4个字节,那么一共400个字节的空间被映射到内存成功后,ioaddr就是这段地址的开 头(注意ioaddr是虚拟地址,而mmio_start是物理地址,它是BIOS得到的,肯定是物理地址,而保护模式下CPU不认物理地址,只认虚拟地 址),ioaddr+0就是第一个寄存器的地址,ioaddr+4就是第二个寄存器地址(每个寄存器占4个字节),以此类推,我们就能够在内存中访问到所 有的寄存器进而操控他们了。
⑤重启网卡设备
重启网卡设备是初始化网卡设备的一个重要部分,它的原理就是向寄存器中写入命令就可以了(注意这里写寄存器,而不是配置空间,因为跟PCI没有什么关系),代码如下:
writeb ((readb(ioaddr+ChipCmd) & ChipCmdClear) | CmdReset,ioaddr+ChipCmd);
是我们看到第二参数ioaddr+ChipCmd,ChipCmd是一个位移,使地址刚好对应的就是ChipCmd哪个寄存器,读者可以查阅官方 datasheet得到这个位移量,我们在程序中定义的这个值为:ChipCmd = 0x37;与datasheet是吻合的。我们把这个命令寄存器中相应位(RESET)置1就可以完成操作。
⑥获得MAC地址,并把它存储到net_device中。
for(i = 0; i < 6; i++) { /* Hardware Address */
dev->dev_addr = readb(ioaddr+i);
dev->broadcast = 0xff;
}
我们可以看到读的地址是ioaddr+0到ioaddr+5,读者查看官方datasheet会发现寄存器地址空间的开头6个字节正好存的是这个网卡设备的MAC地址,MAC地址是网络中标识网卡的物理地址,这个地址在今后的收发数据包时会用的上。
⑦向net_device中登记一些主要的函数
dev->open = rtl8139_open;
dev->hard_start_xmit = rtl8139_start_xmit;
dev->stop = rtl8139_close;
由于dev(net_device)代表着设备,把这些函数注册完后,rtl8139_open就是用于打开这个设备, rtl8139_start_xmit就是当应用程序要通过这个设备往外面发数据时被调用,具体的其实这个函数是在网络协议层中调用的,这就涉及到 Linux网络协议栈的内容,不再我们讨论之列,我们只是负责实现它。rtl8139_close用来关掉这个设备。
好了,到此我们把rtl8139_init_one函数介绍完了,初始化个设备完了之后呢,我们通过ifconfig eth0 up命令来把我们的设备激活。这个命令直接导致了我们刚刚注册的rtl8139_open的调用。这个函数激活了设备。这个函数主要做了三件事。
①注册这个设备的中断处理函数。当网卡发送数据完成或者接收到数据时,是用中断的形式来告知的,比如有数据从网线传来,中断也通知了我们,那么必须要有一 个处理这个中断的函数来完成数据的接收。关于Linux的中断机制不是我们详细讲解的范畴,有兴趣的可以参考《Linux内核源代码情景分析》,但是有个 非常重要的资源我们必须注意,那就是中断号的分配,和内存地址映射一样,中断号也是BIOS在初始化阶段分配并写入设备的配置空间的,然后Linux在建 立pci_dev时从配置空间读出这个中断号然后写入pci_dev的irq成员中,所以我们注册中断程序需要中断号就是直接从pci_dev里取就可以 了。
retval = request_irq (dev->irq, rtl8139_interrupt, SA_SHIRQ, dev->name, dev);
if (retval) {
return retval;
}
我们注册的中断处理函数是rtl8139_interrupt,也就是说当网卡发生中断(如数据到达)时,中断控制器8259A把中断号发给CPU, CPU根据这个中断号找到处理程序,这里就是rtl8139_interrupt,然后执行。rtl8139_interrupt也是在我们的程序中定义 好了的,这是驱动程序的一个重要的义务,也是一个基本的功能。request_irq 的代码在arch/i386/kernel/irq.c中。
②分配发送和接收的缓存空间
根据官方文档,发送一个数据包的过程是这样的:先从应用程序中把数据包拷贝到一段连续的内存中(这段内存就是我们这里要分配的缓存),然后把这段内存的地 址写进网卡的数据发送地址寄存器(TSAD)中,这个寄存器的偏移量是TxAddr0 = 0x20。在把这个数据包的长度写进另一个寄存器(TSD)中,它的偏移量是TxStatus0 = 0x10。然后就把这段内存的数据发送到网卡内部的发送缓冲中(FIFO),最后由这个发送缓冲区把数据发送到网线上。
好了现在创建这么一个发送和接收缓冲内存的目的已经很显然了。
tp->tx_bufs = pci_alloc_consistent(tp->pci_dev, TX_BUF_TOT_LEN,
&tp->tx_bufs_dma);
tp->rx_ring = pci_alloc_consistent(tp->pci_dev, RX_BUF_TOT_LEN,
&tp->rx_ring_dma);
tp 是net_device的priv的指针,tx_bufs是发送缓冲内存的首地址,rx_ring是接收缓存内存的首地址,他们都是虚拟地址,而最后一个 参数tx_bufs_dma和rx_ring_dma均是这一段内存的物理地址。为什么同一个事物,既用虚拟地址来表示它还要用物理地址呢,是这样的, CPU执行程序用到这个地址时,用虚拟地址,而网卡设备向这些内存中存取数据时用的是物理地址(因为网卡相对CPU属于头脑比较简单型的)。 pci_alloc_consistent的代码在Linux/arch/i386/kernel/pci-dma.c中。
③发送和接收缓冲区初始化和网卡开始工作的操作
RTL8139有4个发送描述符(包括4个发送缓冲区的基地址寄存器(TSAD0-TSAD3)和4个发送状态寄存器(TSD0-TSD3)。也就是说我 们分配的缓冲区要分成四个等分并把这四个空间的地址都写到相关寄存器里去,下面这段代码完成了这个操作。
for (i = 0; i < NUM_TX_DESC; i++)
((struct rtl8139_private*)dev->priv)->tx_buf =
&((struct rtl8139_private*)dev->priv)->tx_bufs[i * TX_BUF_SIZE];
上面这段代码负责把发送缓冲区虚拟空间进行了分割。
for (i = 0; i < NUM_TX_DESC; i++)
{
writel(tp->tx_bufs_dma+(tp->tx_buftp->tx_bufs),ioaddr+TxAddr0+(i*4));
readl(ioaddr+TxAddr0+(i * 4));
}
上面这段代码负责把发送缓冲区物理空间进行了分割,并把它写到了相关寄存器中,这样在网卡开始工作后就能够迅速定位和找到这些内存并存取他们的数据。
writel(tp->rx_ring_dma,ioaddr+RxBuf);
上面这行代码是把接收缓冲区的物理地址写到了相关寄存器中,这样网卡接收到数据后就能准确的把数据从网卡中搬运到这些内存空间中,等待CPU来领走他们。
writeb((readb(ioaddr+ChipCmd) & ChipCmdClear) |
CmdRxEnb | CmdTxEnb,ioaddr+ChipCmd);
重新RESET设备后,我们要激活设备的发送和接收的功能,上面这行代码就是向相关寄存器中写入相应值,激活了设备的这些功能。
writel ((TX_DMA_BURST << TxDMAShift),ioaddr+TxConfig);
上面这行代码是向网卡的TxConfig (位移是0x44)寄存器中写入TX_DMA_BURST << TxDMAShift这个值,翻译过来就是6<<8,就是把第8到第10这三位置成110,查阅管法文档发现6就是110代表着一次DMA的 数据量为1024字节。
另外在这个阶段设置了接收数据的模式,和开启中断等等,限于篇幅由读者自行研究。
下面进入数据收发阶段:
当一个网络应用程序要向网络发送数据时,它要利用Linux的网络协议栈来解决一系列问题,找到网卡设备的代表net_device,由这个结构来找到并 控制这个网卡设备来完成数据包的发送,具体是调用net_device的hard_start_xmit成员函数,这是一个函数指针,在我们的驱动程序里 它指向的是 rtl8139_start_xmit,正是由它来完成我们的发送工作的,下面我们就来剖析这个函数。它一共做了四件事。
①检查这个要发送的数据包的长度,如果它达不到以太网帧的长度,必须采取措施进行填充。
if( skb->len < ETH_ZLEN ){//if data_len < 60
if( (skb->data + ETH_ZLEN) <= skb->end ){
memset( skb->data + skb->len, 0x20, (ETH_ZLEN - skb->len) );
skb->len = (skb->len >= ETH_ZLEN) ? skb->len : ETH_ZLEN;}
else{
printk("%s:(skb->data+ETH_ZLEN) > skb->end\n",__FUNCTION__);
}
}
skb->data和skb->end就决定了这个包的内容,如果这个包本身总共的长度(skb->end- skb->data)都达不到要求,那么想填也没地方填,就出错返回了,否则的话就填上。
②把包的数据拷贝到我们已经建立好的发送缓存中。
memcpy (tp->tx_buf[entry], skb->data, skb->len);
其中skb->data就是数据包数据的地址,而tp->tx_buf[entry]就是我们的发送缓存地址,这样就完成了拷贝,忘记了这些内容的回头看看前面的介绍。
③光有了地址和数据还不行,我们要让网卡知道这个包的长度,才能保证数据不多不少精确的从缓存中截取出来搬运到网卡中去,这是靠写发送状态寄存器(TSD)来完成的。
writel(tp->tx_flag | (skb->len >= ETH_ZLEN ? skb->len : ETH_ZLEN),ioaddr+TxStatus0+(entry * 4));
我们把这个包的长度和一些控制信息一起写进了状态寄存器,使网卡的工作有了依据。
④判断发送缓存是否已经满了,如果满了在发就覆盖数据了,要停发。
if ((tp->cur_tx - NUM_TX_DESC) == tp->dirty_tx)
netif_stop_queue (dev);
谈完了发送,我们开始谈接收,当有数据从网线上过来时,网卡产生一个中断,调用的中断服务程序是rtl8139_interrupt,它主要做了三件事。
①从网卡的中断状态寄存器中读出状态值进行分析,status = readw(ioaddr+IntrStatus);
if ((status &(PCIErr | PCSTimeout | RxUnderrun | RxOverflow |
RxFIFOOver | TxErr | TxOK | RxErr | RxOK)) == 0)
goto out;
上面代码说明如果上面这9种情况均没有的表示没什么好处理的了,退出。
② if (status & (RxOK | RxUnderrun | RxOverflow | RxFIFOOver))/* Rx interrupt */
rtl8139_rx_interrupt (dev, tp, ioaddr);
如果是以上4种情况,属于接收信号,调用rtl8139_rx_interrupt进行接收处理。
③ if (status & (TxOK | TxErr)) {
spin_lock (&tp->lock);
rtl8139_tx_interrupt (dev, tp, ioaddr);
spin_unlock (&tp->lock);
}
如果是传输完成的信号,就调用rtl8139_tx_interrupt进行发送善后处理。
下面我们先来看看接收中断处理函数rtl8139_rx_interrupt,在这个函数中主要做了下面四件事
①这个函数是一个大循环,循环条件是只要接收缓存不为空就还可以继续读取数据,循环不会停止,读空了之后就跳出。
int ring_offset = cur_rx % RX_BUF_LEN;
rx_status = le32_to_cpu (*(u32 *) (rx_ring + ring_offset));
rx_size = rx_status >> 16;
上面三行代码是计算出要接收的包的长度。
②根据这个长度来分配包的数据结构
skb = dev_alloc_skb (pkt_size + 2);
③如果分配成功就把数据从接收缓存中拷贝到这个包中
eth_copy_and_sum (skb, &rx_ring[ring_offset + 4], pkt_size, 0);
这个函数在include/linux/etherdevice.h中,实质还是调用了memcpy()。
static inline void eth_copy_and_sum(struct sk_buff*dest, unsigned char *src, int len, int base)
{
memcpy(dest->data, src, len);
}
现在我们已经熟知,&rx_ring[ring_offset + 4]就是接收缓存,也是源地址,而skb->data就是包的数据地址,也是目的地址,一目了然。
④把这个包送到Linux协议栈去进行下一步处理
skb->protocol = eth_type_trans (skb, dev);
netif_rx (skb);
在netif_rx()函数执行完后,这个包的数据就脱离了网卡驱动范畴,而进入了Linux网络协议栈里面,把这些数据包的以太网帧头,IP头,TCP 头都脱下来,最后把数据送给了应用程序,不过协议栈不再本文讨论范围内。netif_rx函数在net/core/dev.c,中。
而rtl8139_remove_one则基本是rtl8139_init_one的逆过程。
一般在使用的Linux网卡驱动代码动辄3000行左右,这个代码量以及它所表达出来的知识量无疑是庞大的,我们有没有办法缩短一下这个代码量,使我们的 学习变的简单些呢,经过笔者的不懈努力,在仍然能够使网络设备正常工作的前提下,把它缩减到了600多行,我们把暂时还用不上的功能先割出去。这样一来, 事情就简单多了,真的就剩下一个框架了。下面我们就来剖析这个可以执行的框架。
限于篇幅,以下分析用到的所有涉及到内核中的函数代码,我都不予列出,但给出在哪个具体文件中,请读者自行查阅。
首先,我们来看看设备的初始化。当我们正确编译完我们的程序后,我们就需要把生成的目标文件加载到内核中去,我们会先ifconfig eth0 down和rmmod 8139too来卸载正在使用的网卡驱动,然后insmod 8139too.o把我们的驱动加载进去(其中8139too.o是我们编译生成的目标文件)。就像C程序有主函数main()一样,模块也有第一个执行 的函数,即module_init(rtl8139_init_module);在我们的程序中,rtl8139_init_module()在 insmod之后首先执行,它的代码如下:
static int __init rtl8139_init_module (void)
{
return pci_module_init (&rtl8139_pci_driver);
}
它直接调用了pci_module_init(),这个函数代码在Linux/drivers/net/eepro100.c中,并且把 rtl8139_pci_driver(这个结构是在我们的驱动代码里定义的,它是驱动程序和PCI设备联系的纽带)的地址作为参数传给了它。 rtl8139_pci_driver定义如下:
static struct pci_driver rtl8139_pci_driver = {
name: MODNAME,
id_table: rtl8139_pci_tbl,
probe: rtl8139_init_one,
remove: rtl8139_remove_one,
};
pci_module_init()在驱动代码里没有定义,你一定想到了,它是Linux内核提供给模块是一个标准接口,那么这个接口都干了些什么,笔者 跟踪了这个函数。里面调用了pci_register_driver(),这个函数代码在Linux/drivers/pci/pci.c中, pci_register_driver做了三件事情。
①是把带过来的参数rtl8139_pci_driver在内核中进行了注册,内核中有一个PCI设备的大的链表,这里负责把这个PCI驱动挂到里面去。
②是查看总线上所有PCI设备(网卡设备属于PCI设备的一种)的配置空间如果发现标识信息与rtl8139_pci_driver中的id_table相同即rtl8139_pci_tbl,而它的定义如下:
static struct pci_device_id rtl8139_pci_tbl[] __devinitdata = {
{0x10ec, 0x8129, PCI_ANY_ID, PCI_ANY_ID, 0, 0, 1},
{PCI_ANY_ID, 0x8139, 0x10ec, 0x8139, 0, 0,0 },
{0,}
};
那么就说明这个驱动程序就是用来驱动这个设备的,于是调用rtl8139_pci_driver中的probe函数即rtl8139_init_one, 这个函数是在我们的驱动程序中定义了的,它是用来初始化整个设备和做一些准备工作。这里需要注意一下pci_device_id是内核定义的用来辨别不同 PCI设备的一个结构,例如在我们这里0x10ec代表的是Realtek公司,我们扫描PCI设备配置空间如果发现有Realtek公司制造的设备时, 两者就对上了。当然对上了公司号后还得看其他的设备号什么的,都对上了才说明这个驱动是可以为这个设备服务的。
③是把这个rtl8139_pci_driver结构挂在这个设备的数据结构(pci_dev)上,表示这个设备从此就有了自己的驱动了。而驱动也找到了它服务的对象了。
PCI是一个总线标准,PCI总线上的设备就是PCI设备,这些设备有很多类型,当然也包括网卡设备,每一个PCI设备在内核中抽象为一个数据结构 pci_dev,它描述了一个PCI设备的所有的特性,具体请查询相关文档,本文限于篇幅无法详细描述。但是有几个地方和驱动程序的关系特别大,必须予以 说明。PCI设备都遵守PCI标准,这个部分所有的PCI设备都是一样的,每个PCI设备都有一段寄存器存储着配置空间,这一部分格式是一样的,比如第一 个寄存器总是生产商号码,如Realtek就是10ec,而Intel则是另一个数字,这些都是商家像标准组织申请的,是肯定不同的。我就可以通过配置空 间来辨别其生产商,设备号,不论你什么平台,x86也好,ppc也好,他们都是同一的标准格式。当然光有这些PCI配置空间的统一格式还是不够的,比如说 人类,都有鼻子和眼睛,但并不是所有人的鼻子和眼睛都长的一样的。网卡设备是PCI设备必须遵守规则,在设备里集成了PCI配置空间,但它是一个网卡就必 须同时集成能控制网卡工作的寄存器。而寄存器的访问就成了一个问题。在Linux里面我们是把这些寄存器映射到主存虚拟空间上的,换句话说我们的CPU访 存指令就可以访问到这些处于外设中的控制寄存器。总结一下PCI设备主要包括两类空间,一个是配置空间,它是操作系统或BIOS控制外设的统一格式的空 间,CPU指令不能访问,访问这个空间要借助BIOS功能,事实上Linux的访问配置空间的函数是通过CPU指令驱使BIOS来完成读写访问的。而另一 类是普通的控制寄存器空间,这一部分映射完后CPU可以访问来控制设备工作。
现在我们回到上面pci_register_driver的第二步,如果找到相关设备和我们的pci_device_id结构数组对上号了,说明我们找到服务对象了,则调用rtl8139_init_one,它主要做了七件事:
① 建立net_device结构,让它在内核中代表这个网络设备。但是读者可能会问,pci_dev也是代表着这个设备,那么两者有什么区别呢,正如我们上 面讨论的,网卡设备既要遵循PCI规范,也要担负起其作为网卡设备的职责,于是就分了两块,pci_dev用来负责网卡的PCI规范,而这里要说的 net_device则是负责网卡的网络设备这个职责。
dev = init_etherdev (NULL, sizeof (*tp));
if (dev == NULL) {
printk ("unable to alloc new ethernet\n");
return -ENOMEM;
}
tp = dev->priv;
init_etherdev函数在Linux/drivers/net/net_init.c中,在这个函数中分配了net_device的内存并进行了 初步的初始化。这里值得注意的是net_device中的一个成员priv,它代表着不同网卡的私有数据,比如Intel的网卡和Realtek的网卡在 内核中都是以net_device来代表。但是他们是有区别的,比如Intel和Realtek实现同一功能的方法不一样,这些都是靠着priv来体现。 所以这里把拿出来同net_device相提并论。分配内存时,net_device中除了priv以外的成员都是固定的,而priv的大小是可以任意 的,所以分配时要把priv的大小传过去。
②开启这个设备(其实是开启了设备的寄存器映射到内存的功能)
rc = pci_enable_device (pdev);
if (rc)
goto err_out;
pci_enable_device 也是一个内核开发出来的接口,代码在drivers/pci/pci.c中,笔者跟踪发现这个函数主要就是把PCI配置空间的Command域的0位和1 位置成了1,从而达到了开启设备的目的,因为rtl8139的官方datasheet中,说明了这两位的作用就是开启内存映射和I/O映射,如果不开的 话,那我们以上讨论的把控制寄存器空间映射到内存空间的这一功能就被屏蔽了,这对我们是非常不利的,除此之外,pci_enable_device还做了 些中断开启工作。
③获得各项资源
mmio_start = pci_resource_start (pdev, 1);
mmio_end = pci_resource_end (pdev, 1);
mmio_flags = pci_resource_flags (pdev, 1);
mmio_len = pci_resource_len (pdev, 1);
读者也许疑问我们的寄存器被映射到内存中的什么地方是什么时候有谁决定的呢。是这样的,在硬件加电初始化时,BIOS固件同统一检查了所有的PCI设备, 并统一为他们分配了一个和其他互不冲突的地址,让他们的驱动程序可以向这些地址映射他们的寄存器,这些地址被BIOS写进了各个设备的配置空间,因为这个 活动是一个PCI的标准的活动,所以自然写到各个设备的配置空间里而不是他们风格各异的控制寄存器空间里。当然只有BIOS可以访问配置空间。当操作系统 初始化时,他为每个PCI设备分配了pci_dev结构,并且把BIOS获得的并写到了配置空间中的地址读出来写到了pci_dev中的resource 字段中。这样以后我们在读这些地址就不需要在访问配置空间了,直接跟pci_dev要就可以了,我们这里的四个函数就是直接从pci_dev读出了相关数 据,代码在include/linux/pci.h中。定义如下:
#define pci_resource_start(dev,bar) ((dev)->resource[(bar)].start)
#define pci_resource_end(dev,bar) ((dev)->resource[(bar)].end)
这里需要说明一下,每个PCI设备有0-5一共6个地址空间,我们通常只使用前两个,这里我们把参数1传给了bar就是使用内存映射的地址空间。
④把得到的地址进行映射
ioaddr = ioremap (mmio_start, mmio_len);
if (ioaddr == NULL) {
printk ("cannot remap MMIO, aborting\n");
rc = -EIO;
goto err_out_free_res;
}
ioremap是内核提供的用来映射外设寄存器到主存的函数,我们要映射的地址已经从pci_dev中读了出来(上一步),这样就水到渠成的成功映射了而 不会和其他地址有冲突。映射完了有什么效果呢,我举个例子,比如某个网卡有100 个寄存器,他们都是连在一块的,位置是固定的,加入每个寄存器占4个字节,那么一共400个字节的空间被映射到内存成功后,ioaddr就是这段地址的开 头(注意ioaddr是虚拟地址,而mmio_start是物理地址,它是BIOS得到的,肯定是物理地址,而保护模式下CPU不认物理地址,只认虚拟地 址),ioaddr+0就是第一个寄存器的地址,ioaddr+4就是第二个寄存器地址(每个寄存器占4个字节),以此类推,我们就能够在内存中访问到所 有的寄存器进而操控他们了。
⑤重启网卡设备
重启网卡设备是初始化网卡设备的一个重要部分,它的原理就是向寄存器中写入命令就可以了(注意这里写寄存器,而不是配置空间,因为跟PCI没有什么关系),代码如下:
writeb ((readb(ioaddr+ChipCmd) & ChipCmdClear) | CmdReset,ioaddr+ChipCmd);
是我们看到第二参数ioaddr+ChipCmd,ChipCmd是一个位移,使地址刚好对应的就是ChipCmd哪个寄存器,读者可以查阅官方 datasheet得到这个位移量,我们在程序中定义的这个值为:ChipCmd = 0x37;与datasheet是吻合的。我们把这个命令寄存器中相应位(RESET)置1就可以完成操作。
⑥获得MAC地址,并把它存储到net_device中。
for(i = 0; i < 6; i++) { /* Hardware Address */
dev->dev_addr = readb(ioaddr+i);
dev->broadcast = 0xff;
}
我们可以看到读的地址是ioaddr+0到ioaddr+5,读者查看官方datasheet会发现寄存器地址空间的开头6个字节正好存的是这个网卡设备的MAC地址,MAC地址是网络中标识网卡的物理地址,这个地址在今后的收发数据包时会用的上。
⑦向net_device中登记一些主要的函数
dev->open = rtl8139_open;
dev->hard_start_xmit = rtl8139_start_xmit;
dev->stop = rtl8139_close;
由于dev(net_device)代表着设备,把这些函数注册完后,rtl8139_open就是用于打开这个设备, rtl8139_start_xmit就是当应用程序要通过这个设备往外面发数据时被调用,具体的其实这个函数是在网络协议层中调用的,这就涉及到 Linux网络协议栈的内容,不再我们讨论之列,我们只是负责实现它。rtl8139_close用来关掉这个设备。
好了,到此我们把rtl8139_init_one函数介绍完了,初始化个设备完了之后呢,我们通过ifconfig eth0 up命令来把我们的设备激活。这个命令直接导致了我们刚刚注册的rtl8139_open的调用。这个函数激活了设备。这个函数主要做了三件事。
①注册这个设备的中断处理函数。当网卡发送数据完成或者接收到数据时,是用中断的形式来告知的,比如有数据从网线传来,中断也通知了我们,那么必须要有一 个处理这个中断的函数来完成数据的接收。关于Linux的中断机制不是我们详细讲解的范畴,有兴趣的可以参考《Linux内核源代码情景分析》,但是有个 非常重要的资源我们必须注意,那就是中断号的分配,和内存地址映射一样,中断号也是BIOS在初始化阶段分配并写入设备的配置空间的,然后Linux在建 立pci_dev时从配置空间读出这个中断号然后写入pci_dev的irq成员中,所以我们注册中断程序需要中断号就是直接从pci_dev里取就可以 了。
retval = request_irq (dev->irq, rtl8139_interrupt, SA_SHIRQ, dev->name, dev);
if (retval) {
return retval;
}
我们注册的中断处理函数是rtl8139_interrupt,也就是说当网卡发生中断(如数据到达)时,中断控制器8259A把中断号发给CPU, CPU根据这个中断号找到处理程序,这里就是rtl8139_interrupt,然后执行。rtl8139_interrupt也是在我们的程序中定义 好了的,这是驱动程序的一个重要的义务,也是一个基本的功能。request_irq 的代码在arch/i386/kernel/irq.c中。
②分配发送和接收的缓存空间
根据官方文档,发送一个数据包的过程是这样的:先从应用程序中把数据包拷贝到一段连续的内存中(这段内存就是我们这里要分配的缓存),然后把这段内存的地 址写进网卡的数据发送地址寄存器(TSAD)中,这个寄存器的偏移量是TxAddr0 = 0x20。在把这个数据包的长度写进另一个寄存器(TSD)中,它的偏移量是TxStatus0 = 0x10。然后就把这段内存的数据发送到网卡内部的发送缓冲中(FIFO),最后由这个发送缓冲区把数据发送到网线上。
好了现在创建这么一个发送和接收缓冲内存的目的已经很显然了。
tp->tx_bufs = pci_alloc_consistent(tp->pci_dev, TX_BUF_TOT_LEN,
&tp->tx_bufs_dma);
tp->rx_ring = pci_alloc_consistent(tp->pci_dev, RX_BUF_TOT_LEN,
&tp->rx_ring_dma);
tp 是net_device的priv的指针,tx_bufs是发送缓冲内存的首地址,rx_ring是接收缓存内存的首地址,他们都是虚拟地址,而最后一个 参数tx_bufs_dma和rx_ring_dma均是这一段内存的物理地址。为什么同一个事物,既用虚拟地址来表示它还要用物理地址呢,是这样的, CPU执行程序用到这个地址时,用虚拟地址,而网卡设备向这些内存中存取数据时用的是物理地址(因为网卡相对CPU属于头脑比较简单型的)。 pci_alloc_consistent的代码在Linux/arch/i386/kernel/pci-dma.c中。
③发送和接收缓冲区初始化和网卡开始工作的操作
RTL8139有4个发送描述符(包括4个发送缓冲区的基地址寄存器(TSAD0-TSAD3)和4个发送状态寄存器(TSD0-TSD3)。也就是说我 们分配的缓冲区要分成四个等分并把这四个空间的地址都写到相关寄存器里去,下面这段代码完成了这个操作。
for (i = 0; i < NUM_TX_DESC; i++)
((struct rtl8139_private*)dev->priv)->tx_buf =
&((struct rtl8139_private*)dev->priv)->tx_bufs[i * TX_BUF_SIZE];
上面这段代码负责把发送缓冲区虚拟空间进行了分割。
for (i = 0; i < NUM_TX_DESC; i++)
{
writel(tp->tx_bufs_dma+(tp->tx_buftp->tx_bufs),ioaddr+TxAddr0+(i*4));
readl(ioaddr+TxAddr0+(i * 4));
}
上面这段代码负责把发送缓冲区物理空间进行了分割,并把它写到了相关寄存器中,这样在网卡开始工作后就能够迅速定位和找到这些内存并存取他们的数据。
writel(tp->rx_ring_dma,ioaddr+RxBuf);
上面这行代码是把接收缓冲区的物理地址写到了相关寄存器中,这样网卡接收到数据后就能准确的把数据从网卡中搬运到这些内存空间中,等待CPU来领走他们。
writeb((readb(ioaddr+ChipCmd) & ChipCmdClear) |
CmdRxEnb | CmdTxEnb,ioaddr+ChipCmd);
重新RESET设备后,我们要激活设备的发送和接收的功能,上面这行代码就是向相关寄存器中写入相应值,激活了设备的这些功能。
writel ((TX_DMA_BURST << TxDMAShift),ioaddr+TxConfig);
上面这行代码是向网卡的TxConfig (位移是0x44)寄存器中写入TX_DMA_BURST << TxDMAShift这个值,翻译过来就是6<<8,就是把第8到第10这三位置成110,查阅管法文档发现6就是110代表着一次DMA的 数据量为1024字节。
另外在这个阶段设置了接收数据的模式,和开启中断等等,限于篇幅由读者自行研究。
下面进入数据收发阶段:
当一个网络应用程序要向网络发送数据时,它要利用Linux的网络协议栈来解决一系列问题,找到网卡设备的代表net_device,由这个结构来找到并 控制这个网卡设备来完成数据包的发送,具体是调用net_device的hard_start_xmit成员函数,这是一个函数指针,在我们的驱动程序里 它指向的是 rtl8139_start_xmit,正是由它来完成我们的发送工作的,下面我们就来剖析这个函数。它一共做了四件事。
①检查这个要发送的数据包的长度,如果它达不到以太网帧的长度,必须采取措施进行填充。
if( skb->len < ETH_ZLEN ){//if data_len < 60
if( (skb->data + ETH_ZLEN) <= skb->end ){
memset( skb->data + skb->len, 0x20, (ETH_ZLEN - skb->len) );
skb->len = (skb->len >= ETH_ZLEN) ? skb->len : ETH_ZLEN;}
else{
printk("%s:(skb->data+ETH_ZLEN) > skb->end\n",__FUNCTION__);
}
}
skb->data和skb->end就决定了这个包的内容,如果这个包本身总共的长度(skb->end- skb->data)都达不到要求,那么想填也没地方填,就出错返回了,否则的话就填上。
②把包的数据拷贝到我们已经建立好的发送缓存中。
memcpy (tp->tx_buf[entry], skb->data, skb->len);
其中skb->data就是数据包数据的地址,而tp->tx_buf[entry]就是我们的发送缓存地址,这样就完成了拷贝,忘记了这些内容的回头看看前面的介绍。
③光有了地址和数据还不行,我们要让网卡知道这个包的长度,才能保证数据不多不少精确的从缓存中截取出来搬运到网卡中去,这是靠写发送状态寄存器(TSD)来完成的。
writel(tp->tx_flag | (skb->len >= ETH_ZLEN ? skb->len : ETH_ZLEN),ioaddr+TxStatus0+(entry * 4));
我们把这个包的长度和一些控制信息一起写进了状态寄存器,使网卡的工作有了依据。
④判断发送缓存是否已经满了,如果满了在发就覆盖数据了,要停发。
if ((tp->cur_tx - NUM_TX_DESC) == tp->dirty_tx)
netif_stop_queue (dev);
谈完了发送,我们开始谈接收,当有数据从网线上过来时,网卡产生一个中断,调用的中断服务程序是rtl8139_interrupt,它主要做了三件事。
①从网卡的中断状态寄存器中读出状态值进行分析,status = readw(ioaddr+IntrStatus);
if ((status &(PCIErr | PCSTimeout | RxUnderrun | RxOverflow |
RxFIFOOver | TxErr | TxOK | RxErr | RxOK)) == 0)
goto out;
上面代码说明如果上面这9种情况均没有的表示没什么好处理的了,退出。
② if (status & (RxOK | RxUnderrun | RxOverflow | RxFIFOOver))/* Rx interrupt */
rtl8139_rx_interrupt (dev, tp, ioaddr);
如果是以上4种情况,属于接收信号,调用rtl8139_rx_interrupt进行接收处理。
③ if (status & (TxOK | TxErr)) {
spin_lock (&tp->lock);
rtl8139_tx_interrupt (dev, tp, ioaddr);
spin_unlock (&tp->lock);
}
如果是传输完成的信号,就调用rtl8139_tx_interrupt进行发送善后处理。
下面我们先来看看接收中断处理函数rtl8139_rx_interrupt,在这个函数中主要做了下面四件事
①这个函数是一个大循环,循环条件是只要接收缓存不为空就还可以继续读取数据,循环不会停止,读空了之后就跳出。
int ring_offset = cur_rx % RX_BUF_LEN;
rx_status = le32_to_cpu (*(u32 *) (rx_ring + ring_offset));
rx_size = rx_status >> 16;
上面三行代码是计算出要接收的包的长度。
②根据这个长度来分配包的数据结构
skb = dev_alloc_skb (pkt_size + 2);
③如果分配成功就把数据从接收缓存中拷贝到这个包中
eth_copy_and_sum (skb, &rx_ring[ring_offset + 4], pkt_size, 0);
这个函数在include/linux/etherdevice.h中,实质还是调用了memcpy()。
static inline void eth_copy_and_sum(struct sk_buff*dest, unsigned char *src, int len, int base)
{
memcpy(dest->data, src, len);
}
现在我们已经熟知,&rx_ring[ring_offset + 4]就是接收缓存,也是源地址,而skb->data就是包的数据地址,也是目的地址,一目了然。
④把这个包送到Linux协议栈去进行下一步处理
skb->protocol = eth_type_trans (skb, dev);
netif_rx (skb);
在netif_rx()函数执行完后,这个包的数据就脱离了网卡驱动范畴,而进入了Linux网络协议栈里面,把这些数据包的以太网帧头,IP头,TCP 头都脱下来,最后把数据送给了应用程序,不过协议栈不再本文讨论范围内。netif_rx函数在net/core/dev.c,中。
而rtl8139_remove_one则基本是rtl8139_init_one的逆过程。
Linux内核源代码中的汇编语言代码
任何一个用高级语言编写的操作系统,其内核源代码中总有少部分代码是用汇编语言编写的。主要是关于中断与异常处理的底层程序,还有就是与初始化有关的程序以及一些核心代码中调用的公用子程序。
用汇编语言编写核心代码中的部分代码,大体上处于如下几个方面的考虑:
(1)操作系统内核中的底层程序直接与硬件打交道,需要用到一些专用的指令,而这些指令在C语言中并无对应的语言成分。例如,在386系统结构中,对外设的输入输出指令如inb、outb等均无对应的C语言语句。因此,这些底层的操作需要用汇编语言来编写。CPU中的一些对于寄存器的操作也是一样。例如,要设置一个段寄存器时,也只好用汇编语言来编写。
(2)CPU中的一些特殊指令也没有对应的C语言成分,如关中断、开中断等等。
(3)内核中实现某些操作的过程、程序段和函数,在运行时会频繁地被调用,因此其时间效率就显得很重要。而用汇编语言编写的程序,在算法和数据结构相同的条件下,其效率通常要比用高级语言编写的高。在此类程序或代码段中,往往每一条汇编指令的使用都需要经过推敲。系统调用的进入和返回就是一个典型的例子。系统调用的进出是非常频繁用到的过程,每秒钟可能会用到成千上万次,其时间效率可谓举足轻重。再说,系统调用的进出过程牵扯到用户空间和系统空间之间的来回切换,而用于这个目的的一些指令在C语言中本来就没有对应的语言成分,所以,系统调用的进入和返回显然必须用汇编语言来编写。
(4)在某些特殊的场合,一段程序的空间效率会显得非常重要。操作系统的引导程序就是一个例子,系统的引导程序通常一定要容纳在磁盘上的第一个扇区中。这时候,哪怕这段程序的大小多出一个字节也不行,所以只能用汇编语言编写。
在Linux内核的源代码中,以汇编语言编写的程序或程序段,有几种不同的形式。
第一种是完全的汇编代码,这样的代码采用.s作为文件名的后缀。事实上,尽管是“纯粹”的汇编代码,现代的汇编工具也吸收了C语言预处理的长处,也在汇编之前加上了一趟预处理,而预处理之前的文件则以.S为后缀。此类(.S)文件也和C程序一样,可以用#iinclude、#ifdef等等成分,而数据结构也一样可以在.h文件中加以定义。
第二种是嵌入在C程序中的汇编语言片断。虽然在ANSI的C语言标准中并没有关于汇编片断的规定,事实上各种实际使用的C编译中都作了这方面的扩充,而GNU的C编译gcc也在这方面做了很强的扩充。
1、GNU的386汇编语言
在DOS/Windows领域中,i386汇编语言都采用由Intel定义的语句格式,这也是几乎在所有的有关386汇编语言程序设计的教科书或参考书中所使用的格式。可是,在Unix领域中,采用的是由AT&T定义的格式。当初,当AT&T将Unix移植到30386处理器上时,根据Unix圈内人士的习惯和需要而定义了这样的格式。
AT&T与Intel汇编语言格式的差别:
(1)在Intel格式中大多使用大写字母,而在AT&T格式中都使用小写字母
(2)在AT&T格式中,寄存器名要加上“%”作为前缀,而在Intel中则不待前缀
(3)在AT&T的386汇编语言中,指令的源操作数与目的操作数的顺序与在Intel的386汇编语言中正好相反。在Intel格式中是目标在前,源在后;而在AT&T格式中则是源在前,目标在后。
(4)在AT&T格式中,访问指令的操作数大小(宽度)由操作码名称的最后一个字母(也就是操作码的后缀)来决定。用作操作码后缀的字母有b(表示8位),w(表示16位)和l(表示32位)。而在Intel格式中,则是在表示内存单元的操作数前面加上“BYTE PTR”,“WORD PTR”,或“DWORD PTR”来表示。例如,将FOO所指内存单元中的字节取入8位的寄存器AL,在两种格式中不同的表示如下:
MOV AL,BYTE PTR FOO (Intel格式)
Movb FOO,%al (AT&T格式)
(5)在AT&T格式中,直接操作数要加上“$”作为前缀,而在Intel格式中则不带前缀。所以,Intel格式中的“PUSH 4”,在AT&T格式中就变为“pushl $4”
(6)在AT&T格式中,绝对转移或调用指令jump/call的操作数(也即转移或调用的目标地址),要加上“*”作为前缀,而在Intel格式中则不带
(7)远程的转移指令和子程序调用指令的操作码名称,在AT&T格式中为“ljmp”和“lcall”,而在Intel格式中,则为“JMP FAR”和“CALL FAR”。当转移和调用的目标为直接操作数时,两种不同的表示如下:
CALL FAR SECTION:OFFSET (Intel格式)
JMP FAR SECTION:OFFSET (Intel格式)
lcall $section,$offset (AT&T格式)
ljmp $section,$offset (AT&T格式)
与之相应的远程返回指令,则为:
RET FAR STACK_ADJUST (Intel格式)
lret $stack_adjust (AT&T格式)
(8)间接寻址的一般格式,两者区别如下:
SECTION:[BASE+INDEX*SCALE+DISP] (Intel格式)
Section:disp(base,index,scale) (AT&T格式)
这种寻址方式常常用于在数据结构数组中访问特定元素内的一个字段,base为数组的起始地址,scale为每个数组元素的大小,index为下标。如果数组元素是数据结构,则disp为具体字段在结构中的位移。
2、嵌入C代码中的386汇编语言程序段
当需要在C语言的程序中嵌入一段汇编语言程序段时,可以使用gcc提供的“asm”语句功能。例如:
//取自include/asm-i386/atomic.h
static inline void atomic_add(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "addl %1,%0"
: "+m" (v->counter)
: "ir" (i));
}
一般而言,往C代码中插入汇编语言的代码片断要比“纯粹”的汇编语言代码复杂得多,因为这里有个怎样分配使用寄存器,怎样与C代码中的变量结合的问题。为了这个目的,必须对所使用的汇编语言作更多的扩充,增加对汇编工具的指导作用。其结果是其语法实际上变成了既不同于汇编语言,也不同于C语言的某种中间语言。
插入C代码中的一个汇编语言片断可以分成四部分,以“:”号加以分隔,其一般形式为:
指令部:输出部:输入部:损坏部
第一部分就是汇编语句本身,其格式与在汇编语言程序中使用的基本相同,但也有区别。这一部分可以称为“指令部”,是必须有的,而其他各部分则可视具体的情况而省略。所以在最简单的情况加就与常规的汇编语句基本相同
当将汇编语言代码片断嵌入到C代码中时,操作数与C代码中的变量如何结合显然是个问题。程序员在编写嵌入的汇编代码时,按照程序逻辑的要求很清楚应该选用什么指令,但是却无法确切地知道gcc在嵌入点的前后会把那一个寄存器分配用于哪一个变量,以及哪一个或哪几个寄存器是空闲着的。而且,光是被动地知道gcc对寄存器的分配情况也还是不够,还得有个手段把使用寄存器的要求告知gcc,反过来影响它对寄存器的分配。当然,如果gcc的功能非常强,那么通过分析嵌入的汇编代码也应该能够归纳出这些要求,再通过优化,最后也能达到目的。但是,即使这样,所引入的不确定性也还是个问题,更何况要做到这样还不容易,针对这个问题,gcc采取了一种折中的办法:程序员只提供具体的指令,而对寄存器的使用则一般只提供一个样板和一些约束条件,而把到底如何与变量结合的问题留给gcc和gas去处理。
在指令部中,数字加上前缀%,如%0、%1等等,表示需要使用寄存器的样板操作数。可以使用此类操作数的总数取决于具体CPU中通用寄存器的数量。这样,指令部中用到了几个不同的这样的操作数,就说明有几个变量需要与寄存器结合,由gcc和gas在编译和汇编时根据后面的约束条件自行变通处理。由于这些样板操作数也使用“%”前缀,在涉及到具体的寄存器时就要在寄存器名前面加上两个“%”符,以免混淆。
那么,怎样表达对变量结合的约束条件呢?这就是其余几个部分的作用。紧接在指令部后面的是“输出部”,用以规定对输出变量,即目标操作数如何结合的约束条件。每个这样的条件称为一个“约束”。必要时输出部中可以有多个约束,相互以逗号分隔。每个输出约束以“=”号开头,然后是一个字母表示对操作数类型的说明,然后是关于变量结合的约束。例如,在上面的例子中输出部为
:”=m”(v->counter)
这里具有一个约束,”=m”表示相应的目标操作数(指令部中的%0)是一个内存单元。凡是与输出部中说明的操作数相结合的寄存器或操作数本身,在执行嵌入的汇编代码后均不保留执行之前的内容,这就给gcc提供了调度使用这些寄存器的依据。
输出部后面是“输入部”。输入约束的格式与输出约束相似,但不带“=”号。在前面例子中的输入部有两个约束。第一个为”ir(i)”,表示指令中的%1可以是一个在寄存器中的直接操作数(i表示immediate),并且该操作数来自于C代码中的变量名(这里是调用参数)i。第二个约束为”m”(v->counter),意义与输出约束中相同。如果一个输入约束要求使用寄存器,则在预处理时gcc会为之分配一个寄存器,并自动插入必要的指令将操作数即变量的值装入该寄存器。与输入部中说明的操作数结合的寄存器或操作数本身,在执行嵌入的汇编代码以后也不保留执行之前的内容。例如,这里的1%要求使用寄存器,所以gcc会为其分配一个寄存器,并自动插入一条movl指令把参数i的数值装入该寄存器,可是这个寄存器原来的值就不复存在了。如果这个寄存器本来就是空闲的,那倒无所谓,可是如果所有的寄存器都在使用,而只好暂时借用一个,那就得保证在使用以后恢复其原有的内容。此时gcc会自动在开头处插入一条pushl指令,将该寄存器原来的内容保存在堆栈中,而在结束后插入一条popl指令,恢复寄存器的内容。
在有些操作能够中,除用于输入操作数和输出操作数的寄存器以外,还要将若干个寄存器用于计算和操作的中间结果,这样,这些寄存器原来的内容就损坏了,所以要在损坏部对操作的副作用加以说明,让gcc采取相应的措施。不过,有时候就直接把这些说明放在输出部了,那也并无不可。
操作数的编号从输出部的第一个约束(序号为0)开始,顺序数下来,每个约束计数一次。在指令部中引用这些操作数或分配用于这些操作数的寄存器时,就用序号前面加上一个“%”号。在指令部中引用一个操作数时总是把它当成一个32位的“长字”,但是对其实施的操作,则根据需要也可以是字节操作或字操作。对操作数进行的字节操作默认为对其低字节的操作,字操作也是一样。不过,在一些特殊的操作中,对操作数进行字节操作时也允许明确指出是对哪一个字节操作,此时在%与序号之间插入一个“b”表示最低字节,插入一个“h”表示次低字节。
表示约束条件的字母主要有:
“m”,”v”,”o” ——表示内存单元
“r” ——表示任何寄存器
“q” ——表示寄存器eax,ebx,ecx,edx之一
“i”和”h” ——表示直接操作数
“E”和”F” ——表示浮点数
“g” ——表示任意
“a”,”b”,”c”,”d” ——分别表示要求使用寄存器eax,ebx,ecx,edx
“s”,”d” ——分别表示要求使用寄存器esi或edi
“I” ——表示常数(0-31)
此外,如果一个操作数要求使用与前面某个约束中所要求的是同一个寄存器,那就把那个约束对应的操作数编号放在约束条件中。在损坏部常常会以”memory”为约束条件,表示操作完成后内存中的内容已有改变,如果原来某个寄存器的内容来自内存,则现在可能已经不一致。
还要注意,当输出部为空,即没有输出约束时,如果有输入约束存在,则须保留分隔标记“:”号。
回到上面的例子,这段代码的作用是将参数i的值加到v->counter上,代码中的关键字LOCK表示在执行addl指令时要把系统总线锁住,不让别的CPU打扰。将两个数相加是很简单的操作,C语言中明明有相应的语言成分,如:“v->counter+=I;”为什么要用汇编呢?原因就在于,这里要求整个操作只由一条指令完成,并且将总线锁住,以保证操作的“原子性”。相比之下,C语句在编译之后到底有几条指令是没有保证的,也无法要求在计算过程中对总线加锁。
再看一段嵌入汇编代码:
//取自include/asm-i386/bitops.h
static inline void set_bit(int nr, volatile void *addr)
{
asm volatile(
lock;
"bts %1,%0"
: "=m" (*(volatile long *) addr)
: "Ir" (nr)
: "memory");
}
这里的指令btsl将一个32位操作数中的某一位设置成1,参数nr表示该位的位置。
再来看一个复杂一点的例子:
//取自include/asm-i386/string.h
static __always_inline void * __memcpy(void * to, const void * from, size_t n)
{
int d0, d1, d2;
__asm__ __volatile__(
"rep ; movsl\n\t"
"testb $2,%b4\n\t"
"je 1f\n\t"
"movsw \n"
"1:\ttestb $1,%b4\n\t"
"je 2f\n\t"
"movsb \n"
"2:"
: "=&c" (d0), "=&D" (d1), "=&S" (d2)
: "0" (n/4), "g" (n), "1" ((long) to), "2" ((long) from)
: "memory");
return (to);
}
__memcpy是内核中对memcpy()的底层实现,用来复制一块内存空间的内容,而忽略其数据结构。这是使用非常频繁的一个函数,所以其运行效率十分重要。
先看约束条件和变量与寄存器的结合。输出部有三个约束,对应于操作数%0至%2。其中变量d0为操作数%0,必须放在寄存器ecx中,原因等下就会明白。同样,d1即%1必须放在寄存器edi中;d2即2%必须放在寄存器esi中。再看输入部,这里有四个约束,对应于操作数%3至%6。其中操作数%3与操作数%0使用同一个寄存器,所以也必须是寄存器ecx;并且要求由gcc自动插入必要的指令,实现将其设置成n/4,实际上是将复制长度从字节个数n换算成长字个数n/4。至于n本身,则要求gcc任意分配一个寄存器存放。操作数5%与6%,即参数to与from,分别与%1和%2使用相同的寄存器,所以也必须是寄存器edi和esi。
再看指令部,第一条指令是“rep”,表示下一条指令movsl要重复执行,每重复一遍就把寄存器ecx中的内容减1,直到变成0为止。所以,在这段代码中一共执行n/4次。那么movsl又干些什么呢?它从esi所指的地方复制一个长字到edi所指的地方,并使esi和edi分别加4。这样,当代码中的"rep ; movsl\n\t"执行完毕,所有的长字都已复制好,最多只剩下三个字节了,在这个过程中,实际上使用了ecx、edi以及esi三个寄存器。即%0(同时也是%3)、%2(同时也是%6)以及1%(同时也是%5)三个操作数,这些都隐含在指令中,从字面上看不出来。同时,这也说明了为什么这些操作书必须存放在指定的寄存器中。
用汇编语言编写核心代码中的部分代码,大体上处于如下几个方面的考虑:
(1)操作系统内核中的底层程序直接与硬件打交道,需要用到一些专用的指令,而这些指令在C语言中并无对应的语言成分。例如,在386系统结构中,对外设的输入输出指令如inb、outb等均无对应的C语言语句。因此,这些底层的操作需要用汇编语言来编写。CPU中的一些对于寄存器的操作也是一样。例如,要设置一个段寄存器时,也只好用汇编语言来编写。
(2)CPU中的一些特殊指令也没有对应的C语言成分,如关中断、开中断等等。
(3)内核中实现某些操作的过程、程序段和函数,在运行时会频繁地被调用,因此其时间效率就显得很重要。而用汇编语言编写的程序,在算法和数据结构相同的条件下,其效率通常要比用高级语言编写的高。在此类程序或代码段中,往往每一条汇编指令的使用都需要经过推敲。系统调用的进入和返回就是一个典型的例子。系统调用的进出是非常频繁用到的过程,每秒钟可能会用到成千上万次,其时间效率可谓举足轻重。再说,系统调用的进出过程牵扯到用户空间和系统空间之间的来回切换,而用于这个目的的一些指令在C语言中本来就没有对应的语言成分,所以,系统调用的进入和返回显然必须用汇编语言来编写。
(4)在某些特殊的场合,一段程序的空间效率会显得非常重要。操作系统的引导程序就是一个例子,系统的引导程序通常一定要容纳在磁盘上的第一个扇区中。这时候,哪怕这段程序的大小多出一个字节也不行,所以只能用汇编语言编写。
在Linux内核的源代码中,以汇编语言编写的程序或程序段,有几种不同的形式。
第一种是完全的汇编代码,这样的代码采用.s作为文件名的后缀。事实上,尽管是“纯粹”的汇编代码,现代的汇编工具也吸收了C语言预处理的长处,也在汇编之前加上了一趟预处理,而预处理之前的文件则以.S为后缀。此类(.S)文件也和C程序一样,可以用#iinclude、#ifdef等等成分,而数据结构也一样可以在.h文件中加以定义。
第二种是嵌入在C程序中的汇编语言片断。虽然在ANSI的C语言标准中并没有关于汇编片断的规定,事实上各种实际使用的C编译中都作了这方面的扩充,而GNU的C编译gcc也在这方面做了很强的扩充。
1、GNU的386汇编语言
在DOS/Windows领域中,i386汇编语言都采用由Intel定义的语句格式,这也是几乎在所有的有关386汇编语言程序设计的教科书或参考书中所使用的格式。可是,在Unix领域中,采用的是由AT&T定义的格式。当初,当AT&T将Unix移植到30386处理器上时,根据Unix圈内人士的习惯和需要而定义了这样的格式。
AT&T与Intel汇编语言格式的差别:
(1)在Intel格式中大多使用大写字母,而在AT&T格式中都使用小写字母
(2)在AT&T格式中,寄存器名要加上“%”作为前缀,而在Intel中则不待前缀
(3)在AT&T的386汇编语言中,指令的源操作数与目的操作数的顺序与在Intel的386汇编语言中正好相反。在Intel格式中是目标在前,源在后;而在AT&T格式中则是源在前,目标在后。
(4)在AT&T格式中,访问指令的操作数大小(宽度)由操作码名称的最后一个字母(也就是操作码的后缀)来决定。用作操作码后缀的字母有b(表示8位),w(表示16位)和l(表示32位)。而在Intel格式中,则是在表示内存单元的操作数前面加上“BYTE PTR”,“WORD PTR”,或“DWORD PTR”来表示。例如,将FOO所指内存单元中的字节取入8位的寄存器AL,在两种格式中不同的表示如下:
MOV AL,BYTE PTR FOO (Intel格式)
Movb FOO,%al (AT&T格式)
(5)在AT&T格式中,直接操作数要加上“$”作为前缀,而在Intel格式中则不带前缀。所以,Intel格式中的“PUSH 4”,在AT&T格式中就变为“pushl $4”
(6)在AT&T格式中,绝对转移或调用指令jump/call的操作数(也即转移或调用的目标地址),要加上“*”作为前缀,而在Intel格式中则不带
(7)远程的转移指令和子程序调用指令的操作码名称,在AT&T格式中为“ljmp”和“lcall”,而在Intel格式中,则为“JMP FAR”和“CALL FAR”。当转移和调用的目标为直接操作数时,两种不同的表示如下:
CALL FAR SECTION:OFFSET (Intel格式)
JMP FAR SECTION:OFFSET (Intel格式)
lcall $section,$offset (AT&T格式)
ljmp $section,$offset (AT&T格式)
与之相应的远程返回指令,则为:
RET FAR STACK_ADJUST (Intel格式)
lret $stack_adjust (AT&T格式)
(8)间接寻址的一般格式,两者区别如下:
SECTION:[BASE+INDEX*SCALE+DISP] (Intel格式)
Section:disp(base,index,scale) (AT&T格式)
这种寻址方式常常用于在数据结构数组中访问特定元素内的一个字段,base为数组的起始地址,scale为每个数组元素的大小,index为下标。如果数组元素是数据结构,则disp为具体字段在结构中的位移。
2、嵌入C代码中的386汇编语言程序段
当需要在C语言的程序中嵌入一段汇编语言程序段时,可以使用gcc提供的“asm”语句功能。例如:
//取自include/asm-i386/atomic.h
static inline void atomic_add(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "addl %1,%0"
: "+m" (v->counter)
: "ir" (i));
}
一般而言,往C代码中插入汇编语言的代码片断要比“纯粹”的汇编语言代码复杂得多,因为这里有个怎样分配使用寄存器,怎样与C代码中的变量结合的问题。为了这个目的,必须对所使用的汇编语言作更多的扩充,增加对汇编工具的指导作用。其结果是其语法实际上变成了既不同于汇编语言,也不同于C语言的某种中间语言。
插入C代码中的一个汇编语言片断可以分成四部分,以“:”号加以分隔,其一般形式为:
指令部:输出部:输入部:损坏部
第一部分就是汇编语句本身,其格式与在汇编语言程序中使用的基本相同,但也有区别。这一部分可以称为“指令部”,是必须有的,而其他各部分则可视具体的情况而省略。所以在最简单的情况加就与常规的汇编语句基本相同
当将汇编语言代码片断嵌入到C代码中时,操作数与C代码中的变量如何结合显然是个问题。程序员在编写嵌入的汇编代码时,按照程序逻辑的要求很清楚应该选用什么指令,但是却无法确切地知道gcc在嵌入点的前后会把那一个寄存器分配用于哪一个变量,以及哪一个或哪几个寄存器是空闲着的。而且,光是被动地知道gcc对寄存器的分配情况也还是不够,还得有个手段把使用寄存器的要求告知gcc,反过来影响它对寄存器的分配。当然,如果gcc的功能非常强,那么通过分析嵌入的汇编代码也应该能够归纳出这些要求,再通过优化,最后也能达到目的。但是,即使这样,所引入的不确定性也还是个问题,更何况要做到这样还不容易,针对这个问题,gcc采取了一种折中的办法:程序员只提供具体的指令,而对寄存器的使用则一般只提供一个样板和一些约束条件,而把到底如何与变量结合的问题留给gcc和gas去处理。
在指令部中,数字加上前缀%,如%0、%1等等,表示需要使用寄存器的样板操作数。可以使用此类操作数的总数取决于具体CPU中通用寄存器的数量。这样,指令部中用到了几个不同的这样的操作数,就说明有几个变量需要与寄存器结合,由gcc和gas在编译和汇编时根据后面的约束条件自行变通处理。由于这些样板操作数也使用“%”前缀,在涉及到具体的寄存器时就要在寄存器名前面加上两个“%”符,以免混淆。
那么,怎样表达对变量结合的约束条件呢?这就是其余几个部分的作用。紧接在指令部后面的是“输出部”,用以规定对输出变量,即目标操作数如何结合的约束条件。每个这样的条件称为一个“约束”。必要时输出部中可以有多个约束,相互以逗号分隔。每个输出约束以“=”号开头,然后是一个字母表示对操作数类型的说明,然后是关于变量结合的约束。例如,在上面的例子中输出部为
:”=m”(v->counter)
这里具有一个约束,”=m”表示相应的目标操作数(指令部中的%0)是一个内存单元。凡是与输出部中说明的操作数相结合的寄存器或操作数本身,在执行嵌入的汇编代码后均不保留执行之前的内容,这就给gcc提供了调度使用这些寄存器的依据。
输出部后面是“输入部”。输入约束的格式与输出约束相似,但不带“=”号。在前面例子中的输入部有两个约束。第一个为”ir(i)”,表示指令中的%1可以是一个在寄存器中的直接操作数(i表示immediate),并且该操作数来自于C代码中的变量名(这里是调用参数)i。第二个约束为”m”(v->counter),意义与输出约束中相同。如果一个输入约束要求使用寄存器,则在预处理时gcc会为之分配一个寄存器,并自动插入必要的指令将操作数即变量的值装入该寄存器。与输入部中说明的操作数结合的寄存器或操作数本身,在执行嵌入的汇编代码以后也不保留执行之前的内容。例如,这里的1%要求使用寄存器,所以gcc会为其分配一个寄存器,并自动插入一条movl指令把参数i的数值装入该寄存器,可是这个寄存器原来的值就不复存在了。如果这个寄存器本来就是空闲的,那倒无所谓,可是如果所有的寄存器都在使用,而只好暂时借用一个,那就得保证在使用以后恢复其原有的内容。此时gcc会自动在开头处插入一条pushl指令,将该寄存器原来的内容保存在堆栈中,而在结束后插入一条popl指令,恢复寄存器的内容。
在有些操作能够中,除用于输入操作数和输出操作数的寄存器以外,还要将若干个寄存器用于计算和操作的中间结果,这样,这些寄存器原来的内容就损坏了,所以要在损坏部对操作的副作用加以说明,让gcc采取相应的措施。不过,有时候就直接把这些说明放在输出部了,那也并无不可。
操作数的编号从输出部的第一个约束(序号为0)开始,顺序数下来,每个约束计数一次。在指令部中引用这些操作数或分配用于这些操作数的寄存器时,就用序号前面加上一个“%”号。在指令部中引用一个操作数时总是把它当成一个32位的“长字”,但是对其实施的操作,则根据需要也可以是字节操作或字操作。对操作数进行的字节操作默认为对其低字节的操作,字操作也是一样。不过,在一些特殊的操作中,对操作数进行字节操作时也允许明确指出是对哪一个字节操作,此时在%与序号之间插入一个“b”表示最低字节,插入一个“h”表示次低字节。
表示约束条件的字母主要有:
“m”,”v”,”o” ——表示内存单元
“r” ——表示任何寄存器
“q” ——表示寄存器eax,ebx,ecx,edx之一
“i”和”h” ——表示直接操作数
“E”和”F” ——表示浮点数
“g” ——表示任意
“a”,”b”,”c”,”d” ——分别表示要求使用寄存器eax,ebx,ecx,edx
“s”,”d” ——分别表示要求使用寄存器esi或edi
“I” ——表示常数(0-31)
此外,如果一个操作数要求使用与前面某个约束中所要求的是同一个寄存器,那就把那个约束对应的操作数编号放在约束条件中。在损坏部常常会以”memory”为约束条件,表示操作完成后内存中的内容已有改变,如果原来某个寄存器的内容来自内存,则现在可能已经不一致。
还要注意,当输出部为空,即没有输出约束时,如果有输入约束存在,则须保留分隔标记“:”号。
回到上面的例子,这段代码的作用是将参数i的值加到v->counter上,代码中的关键字LOCK表示在执行addl指令时要把系统总线锁住,不让别的CPU打扰。将两个数相加是很简单的操作,C语言中明明有相应的语言成分,如:“v->counter+=I;”为什么要用汇编呢?原因就在于,这里要求整个操作只由一条指令完成,并且将总线锁住,以保证操作的“原子性”。相比之下,C语句在编译之后到底有几条指令是没有保证的,也无法要求在计算过程中对总线加锁。
再看一段嵌入汇编代码:
//取自include/asm-i386/bitops.h
static inline void set_bit(int nr, volatile void *addr)
{
asm volatile(
lock;
"bts %1,%0"
: "=m" (*(volatile long *) addr)
: "Ir" (nr)
: "memory");
}
这里的指令btsl将一个32位操作数中的某一位设置成1,参数nr表示该位的位置。
再来看一个复杂一点的例子:
//取自include/asm-i386/string.h
static __always_inline void * __memcpy(void * to, const void * from, size_t n)
{
int d0, d1, d2;
__asm__ __volatile__(
"rep ; movsl\n\t"
"testb $2,%b4\n\t"
"je 1f\n\t"
"movsw \n"
"1:\ttestb $1,%b4\n\t"
"je 2f\n\t"
"movsb \n"
"2:"
: "=&c" (d0), "=&D" (d1), "=&S" (d2)
: "0" (n/4), "g" (n), "1" ((long) to), "2" ((long) from)
: "memory");
return (to);
}
__memcpy是内核中对memcpy()的底层实现,用来复制一块内存空间的内容,而忽略其数据结构。这是使用非常频繁的一个函数,所以其运行效率十分重要。
先看约束条件和变量与寄存器的结合。输出部有三个约束,对应于操作数%0至%2。其中变量d0为操作数%0,必须放在寄存器ecx中,原因等下就会明白。同样,d1即%1必须放在寄存器edi中;d2即2%必须放在寄存器esi中。再看输入部,这里有四个约束,对应于操作数%3至%6。其中操作数%3与操作数%0使用同一个寄存器,所以也必须是寄存器ecx;并且要求由gcc自动插入必要的指令,实现将其设置成n/4,实际上是将复制长度从字节个数n换算成长字个数n/4。至于n本身,则要求gcc任意分配一个寄存器存放。操作数5%与6%,即参数to与from,分别与%1和%2使用相同的寄存器,所以也必须是寄存器edi和esi。
再看指令部,第一条指令是“rep”,表示下一条指令movsl要重复执行,每重复一遍就把寄存器ecx中的内容减1,直到变成0为止。所以,在这段代码中一共执行n/4次。那么movsl又干些什么呢?它从esi所指的地方复制一个长字到edi所指的地方,并使esi和edi分别加4。这样,当代码中的"rep ; movsl\n\t"执行完毕,所有的长字都已复制好,最多只剩下三个字节了,在这个过程中,实际上使用了ecx、edi以及esi三个寄存器。即%0(同时也是%3)、%2(同时也是%6)以及1%(同时也是%5)三个操作数,这些都隐含在指令中,从字面上看不出来。同时,这也说明了为什么这些操作书必须存放在指定的寄存器中。
订阅:
博文 (Atom)