第2章代码初识.docx
- 文档编号:24583472
- 上传时间:2023-05-29
- 格式:DOCX
- 页数:27
- 大小:128.47KB
第2章代码初识.docx
《第2章代码初识.docx》由会员分享,可在线阅读,更多相关《第2章代码初识.docx(27页珍藏版)》请在冰豆网上搜索。
第2章代码初识
第2章代码初识
本章首先从较高层次介绍Linux内核源程序的概况,这些都是大家关心的一些基本特点。
随后将简要介绍一些实际代码。
最后以如何编译内核来检验个人所进行的修改的讨论来作为本章的收尾。
Linux内核源程序的部分特点
在过去的一段时期,Linux内核同时使用C语言和汇编语言实现的。
这两种语言需要一定的平衡:
C语言编写的代码移植性较好、易于维护,而汇编语言编写的程序则速度较快。
一般只有在速度是关键因素或者一些因平台相关特性而产生的特殊要求(例如直接和内存管理硬件进行通讯)时才使用汇编语言。
正如同实际中所做的,即使内核并未使用C++的对象特性,部分内核也可以在g++(GNU的C++编译器)下进行编译。
同其它面向对象的编程语言相比较,相对而言C++的开销是较低的,但是对于内核开发人员来说,这已经足够甚至太多了。
内核开发人员不断发展编程风格,形成了Linux代码独有的特色。
本节将讨论其中的一些问题。
gcc特性的使用
Linux内核被设计为必须使用GNU的C编译器gcc来编译,而不是任何一种C编译器都可以使用。
内核代码有时要使用gcc特性,伴随着本书的进程,我们将陆续介绍其中的一部分。
一些gcc特有代码只是简单地使用gcc语言扩展,例如允许在C(不只是C++)中使用inline关键字指示内联函数。
也就是说,代码中被调用的函数在每次函数调用时都会被扩充,因而就可以节约实际函数调用的开销。
更为普遍的情况是代码的编写方式比较复杂。
因为对于某些类型的输入,gcc能够产生比其它输入效率更高的执行代码。
从理论上讲,编译器可以优化具有相同功能的两种对等的方法,并且得到相同的结果。
因此,代码的编写方式是无关紧要的。
但在实际上,用一些方法编写所产生的代码要比用其它方法编写所产生的代码的执行速度快得多。
内核开发人员清楚如何才能产生更高效的执行代码的方法,而且这种知识也不断在他们编写的代码中反映出来。
例如,考虑内核中经常使用的goto语句——为了提高速度,内核中经常大量使用这种一般要避免使用的语句。
在本书中所包含的不到40,000行代码中,一共有500多条goto语句,大约是每80行一个。
除汇编文件外,精确的统计数字是接近每72行一个goto语句。
公平的说,这是选择偏向的结果:
比例如此高的原因之一是本书中涉及的是内核源程序的核心,在这里速度比其它因素都需要优先考虑。
整个内核的比例大概是每260行一个goto语句。
然而,这仍然是我不再使用Basic进行编程以来见过的使用goto频率最高的地方。
代码必需受特定编译器限制的特性不仅与普通应用程序的开发有很大不同,而且也不同于大多数内核的开发。
大多数的开发人员使用C语言编写代码来保持较高的可移植性,即使在编写操作系统时也是如此。
这样做的优点是显而易见的,最为重要的一点是一旦出现更好的编译器,程序员们可以随时进行更换。
内核对于gcc特性的完全依赖使得内核向新的编译器上的移植工作更加困难。
最近Linus对这一问题在有关内核的邮件列表上表明了自己的观点。
“记住,编译器只是一个工具。
”这是对依赖于gcc特性的一个很好的基本思想的表述:
编译器只是为了完成工作。
如果通过遵守标准还不能达到工作要求,那么就不是工作要求有问题,而是对于标准的依赖有问题。
在大多数情况下,这种观点是不能够被人所接受的。
通常情况下,为了保证和程序语言标准的一致,开发人员可能需要牺牲某些特性、速度或者其它相关因素。
其它的选择可能会为后期开发造成很大的麻烦。
但是,在这种特定的情况下,Linus是正确的。
Linux内核是一个特例,因为其执行速度要比向其它编译器的可移植性远为重要。
如果设计目标是编写一个可移植性好而不要求快速运行的内核,或者是编写一个任何人都可以使用自己喜欢的编译器进行编译的内核,那么结论就可能会有所不同了;而这些恰好不是Linux的设计目标。
实际上,gcc几乎可以为所有能够运行Linux的CPU生成代码,因此,对于gcc的依赖并不是可移植性的严重障碍。
在第3章中我们将对内核设计目标进行详细说明。
内核代码习惯用语
内核代码中使用了一些显著的习惯用语,本节将介绍常用的几个。
当你通读源程序代码时,真正重要的问题是并不在这些习惯用语本身,而是这种类型的习惯用语的确存在,而且是不断被使用和发展的。
如果你需要编写内核代码,你应该注意到内核中所使用的习惯用语,并把这些习惯用语应用到你的代码中。
当通读本书(或者代码)时,注意你还能找到多少习惯用语。
为了讨论这些习惯用语,我们首先需要对它们进行命名。
为了便于讨论,笔者创造了这些名字。
而在实际中,大家不一定非要参考这些用语,它们只是对内核工作方式的描述而已。
一个普通的习惯用语笔者称之为“资源获取”(resourceacquisitionidiom)。
在这个用语中,一个函数必须实现一系列资源的获取,包括内存、锁等等(这些资源的类型未必相同)。
只有成功地获取当前所需要的资源之后,才能处理后面的资源请求。
最后,该函数还必须释放所有已经获取的资源,而不必对没有获取的资源进行考虑。
我采用“错误变量”这一用语(errorvariableidiom)来辅助说明资源获取用语,它使用一个临时变量来记录函数的期望返回值。
当然,相当多的函数都能实现这个功能。
但是错误变量的不同点在于它通常是用来处理由于速度的因素而变得非常复杂的流程控制中的问题。
错误变量有两个典型的值,0(表示成功)和负数(表示有错)。
这两个用语结合使用,我们就可以十分自然地得到符合模式的代码如下:
Intf(void)
{
interr;
resource*r1,*r2;
err=-ERR1/*assumefailure*/
r1=acquire_resource();
if(!
r1)/*notaquired*/
gotoout/*returns-ERR1*/
Gotresourcer1,tryforr2.*/
err=-ERR2;
r2=acquire_resource2();
if(!
r2)/*notaquired*/
gotoout1/*returns–ERR2*/
/*havebothr1andr2.*/
err=0;
/*…user1andr2…*/
out2:
release_resource(r2)
out1:
release_resource(r1)
out:
returnerr;
}
(注意变量err是使用错误变量的一个明确实例,同样,诸如out之类的标号则指明了资源获取用语的使用。
)
如果执行到标号out2,则都已经获取了r1和r2资源,而且也都需要进行释放。
如果执行到标号out1(不管是顺序执行还是使用goto语句进行跳转到),则r2资源是无效的(也可能刚被释放),但是r1资源却是有效的,而且必需在此将其释放。
同理,如果标号out能被执行,则r1和r2资源都无效,err所返回的是错误或成功标志。
在这个简单的例子中,对于err的一些赋值是没有必要的。
在实践中,实际代码必须遵守这种模式。
这样做的原因主要在于同一行中可能包含有多种测试,而这些测试应该返回相同的错误代码,因此对错误变量统一赋值要比多次赋值更为简单。
虽然在这个例子中对于这种属性的必要性并不非常迫切,但是我还是倾向于保留这种特点。
有关的实际应用可以参考sys_shmctl(第21654行),在第9章中还将详细介绍这个例子。
减少#if和#ifdef的使用
现在的Linux内核已经移植到不同的平台上,但是我们还必须解决移植过程中所出现的问题。
大部分支持各种不同平台的代码由于包含许多预处理代码现都已变得非常不规范,例如:
#ifdefined(SOLARIS)
/*…dothingsthesolarisway…*/
#elifdefined(HPUX)
/*…dothingstheHP-UXway…*/
#elifdefined(LINUX)
/*…dothingstherightway…*/
#endif
这个例子试图实现操作系统的可移植性,虽然Linux关注的焦点很明显是实现代码在各种CPU上的可移植性,但是二者的基本原理是一致的。
对于这类问题来说,预处理器是一种错误的解决方式。
这些杂乱的问题使得代码晦涩难懂。
更为糟糕的是,增加对新平台的支持有可能要求重新遍历这些杂乱分布的低质量代码段(实际上你很难能找到这类代码段的全部)。
与现有方式不同的是,Linux一般通过简单函数(或者是宏)调用来抽象出不同平台间的差异。
内核的移植可以通过实现适合于相应平台的函数(或宏)来实现。
这样不仅使代码的主体简单易懂,而且在移植的过程中还可以比较容易地自动检测出你没有注意到的内容:
如引用未声明函数时会出现链接错误。
有时用预处理器来支持不同的体系结构,但这种方式并不常用,而相对于代码风格的变化就更是微不足道了。
顺便说一下,我们可以注意到这种解决方法和使用用户对象(或者C语言中充满函数指针的struct结构)来代替离散的switch语句处理不同类型的方法十分相似。
在某些层次上,这些问题和解决方法是统一的。
可移植性的问题并不仅限于平台和CPU的移植,编译器也是一个重要的问题。
此处为了简化,假设Linux只使用gcc来编译。
由于Linux只使用同一个编译器,所以就没有必要使用#if块(或者#ifdef块)来选择不同的编译器。
内核代码主要使用#ifdef来区分需要编译或不需要编译的部分,从而对不同的结构提供支持。
例如,代码经常测试SMP宏是否定义过,从而决定是否支持SMP机。
代码样例
上一节仅仅是一些讨论,了解Linux代码风格最好的方法就是实际研究一下它的部分代码。
即使你不完全理解本节所讨论代码的细节也无关紧要,毕竟本节的主要目的不是理解代码,一些读者可以只对本节进行浏览。
本节的主要目的是让读者对Linux代码进行初步了解,对今后的工作提供必要基础。
而讨论将涉及部分广泛使用到的内核代码。
printk
printk(25836行)是内核内部消息日志记录函数。
在出现诸如内核检测到其数据结构出现不一致的事件时,内核会使用printk把相关信息打印到系统控制台上。
对于printk的调用一般分为如下几类:
●紧急事件(emergency)――例如,panic函数(25563行)多次使用了printk。
当内核检测到发生不可恢复的内部错误时就会调用panic函数,然后尽其所能的安全关闭计算机。
这个函数中调用printk以提示用户系统将要关闭。
●调试――从3816行开始的#ifdef块使用printk来打印SMP逻辑单元(box)中每一个处理器的相关配置信息,但是此过程只有在使用SMP_DEBUG标志编译代码的情况下才能够被执行。
●普通信息――例如,当机器启动时,内核必需估计系统速度以确保设备驱动程序能够忙等待(busy-waiting)一个精确的极短周期。
计算这种估计值的函数名为calibrate_delay(19654行),它既在19661行使用printk声明马上开始计算,又在19693行报告计算结果。
另外,在第4章将详细的介绍calibrate_delay函数。
如果你已经浏览过这些参照代码,你可能已经注意到printk和printf的参数十分类似:
一个格式化字符串,后跟零个或者多个参数加入字符串中。
格式化字符串可能是以一组“
数字区分了消息的日志等级(loglevel),只有当日志等级高于当前控制台定义的日志等级(console_loglevel,25650行)时,才会打印消息。
root可以通过适当减小控制台的日志等级来过滤不是很紧急的消息。
如果内核在格式化字符串中检测不到日志等级序列,那么就会一直打印消息。
(实际上,日志等级序列并不一定要在格式化字符串中出现,可以在格式化文本中查找到它的代码。
)
从14946行开始的#define块说明了这些特殊序列,这些定义可以帮助调用者正确区分对printk的调用。
简单的说,我称日志等级0到4为“紧急事件”,从等级5到等级6为“普通信息”,等级7自然就是我所说的“调试”。
(这种分类方法并不意味着其它更好的分类方法没有用处,而只是目前我们还不关心它而已。
)
在上面讨论的基础上,我们研究一下代码本身。
printk
25836:
参数fmt是printf类型的格式化字符串。
如果你对“…”部分的内容不熟悉,那就需要参阅一本好的C语言参考书(在其索引中查找“变参函数,variadicfunction”)。
另外,在安装的GNU/Linux中的stdarg帮助里也包含了一个有关变参函数的简明描述,在这儿只需要敲入“manstdarg”就可以看到。
简单的说,“…”部分提示编译器fmt后面可能紧跟着数量不定的任何类型的参数。
由于这些参数在编译的时候还没有类型和名字,内核使用由三个宏va_start,va_arg和va_end组成的特殊组以及一个特殊类型――va_list对它们进行处理。
25842:
msg_level记录了当前消息的日志等级。
它是静态的,这看起来可能会有些奇怪――为什么下一次对printk的调用需要记录日志等级呢?
问题的答案是只有打印出新行(\n)或者赋给一个新的日志等级序列以后,当前消息才会结束。
这样通过在包含消息结束的新行里调用printk,就保证了在多个短期冲突的情况下,调用者只打印唯一一个长消息。
25845:
在SMP逻辑单元中,内核可能试图从不同的CPU向控制台同时打印信息。
(有时在单处理机(UP)逻辑单元中也会发生同样问题,但由于中断还未被覆盖掉,所以问题也并不十分明显。
)如果不进行任何协同的话,结果就将处于完全无法让人了解的杂乱无章的状态,每个消息的各个部分都和其它消息的各个部分混杂交织在一起。
相反,内核使用旋转锁(spin-lock)来控制对控制台的访问。
旋转锁将在第10章对它进行深入的介绍。
如果你对flags在传送给spin_lock_irqsave之前为什么不对它初始化感到疑惑,请不要担心:
spin_lock_irqsave(对于不同的版本请分别参看12614行,12637行,12716行,和12837行)是一个宏,而不是一个函数。
该宏实际上是将值写入flags中,而不是从flags中读出值。
(在25895行中,存储在flags中的信息被spin_lock_irqsave回读,请参看12616行,12639行,12728行和12841行)
25846:
初始化变量args,该变量代表printk参数中的“…”部分。
25848:
调用内核自身的vsprintf(为节省空间而省略)实现。
该函数的功能与标准vsprintf函数非常相似,向buf中写入格式化文本(25634行)并返回写入字符串的长度(长度不包括最后一位终止字符0字节)。
很快,你将可以看到为什么这种机制会忽略buf的前三个字符。
(正如25847行的注释中所述)我们应该注意到在这里并没有采取严格的措施来保证缓冲器不会过载。
这里系统假定1024个字符长度的buf已经足够使用(参阅25634行)。
如果内核在这里能够使用vsnprintf函数的话,情况就会好许多。
然而,vsnprintf还有另外一个参数限制了它能够写入缓冲器的字符长度。
25849:
计算buf中最近使用的元素,调用va_end终止对“…”参数的处理。
25851:
开始格式化消息的循环。
其中存在一个内部循环能够处理更多内容(这一点随后就能看到),因此,每次内循环开始,都开始一个新的打印行。
由于通常情况下printk只用于打印单行,所以在每次调用中这种循环通常只执行一次。
25853:
如果预先不知道消息的日志等级,printk会检查当前行是否以日志等级序列开头。
25860:
如果不是,buf中开始未使用的三个字符就能够起作用了。
(第一次以后的每次循环,都会覆盖部分消息文本,但是这样并不会引起问题,因为这里的文本只是前面行中的一部分,它们已经被打印过,而且以后也不再需要了。
)这样,就可以将日志等级插入buf中。
25866:
此处有如下属性:
p指向日志等级序列(消息文本紧随其后),msg指向消息文本——请注意25852行和25865行中对msg的赋值。
由于已知p用来指示日志等级序列的开头――该日志等级序列可能是由函数自身所创建的――日志等级可以从p中抽出并存到msg_level中。
25868:
没有检测到新行,清空line_feed标志。
25869:
这是前面谈到过的内循环,循环将运行到本行结束(也就是检测到新行标志)或者缓冲器的末尾为止。
25870:
除了将消息打印到控制台之外,printk还能够记录最近打印的长度为LOG_BUF_LEN的字符组。
(LOG_BUF_LEN为16K,请参看25632行。
)如果在控制台打开之前,内核就已经调用printk,则显然不能在控制台上正确打印消息,但是这些消息将被尽可能的存储到log_buf中(25656行)。
当控制台打开以后,缓存在log_buf中的数据就可以转储并在控制台上打印出来,请参看25988行。
log_buf是一个循环缓冲器,log_start和log_size变量(25657行和25646行)分别记录当前缓冲器的开始位置和长度。
本行中的按位与(AND)操作实际上是快速求模(%)运算,它的正确性依赖于LOG_BUF_LEN的值是2的幂。
25872:
保存变量跟踪记录循环日志的值。
显然,日志大小会不断增长,直至达到LOG_BUF_LEN的值为止。
此后,log_size将保持不变,而插入新字符将导致log_start的增长。
25878:
请注意logged_chars(25658行)记录从机器启动之后printk写入的所有字符的长度,它在每次循环中都会被更新,而不是在循环结束后才改变一次。
基于同样的道理,log_start和log_size的处理方式也是一样。
这实际上是一种优化的时机,但是我们将在结束对函数的介绍之后再对它详细讨论。
25879:
消息被分为若干行,这当然要使用新行标志符来进行分割。
一旦内核检测到新行标志符,就写入一个完整行,从而内循环的执行也可以提前终止。
25884:
在这里我们先不考虑内部循环是否会提前退出,从msg到p的字符序列是专门提供给控制台使用的。
(这种字符序列我称之为行,但是不要忘了,这里的行可能并不意味着新行终止,因为buf也许还没有终止。
)如果该行的日志等级高于系统控制台定义的日志等级,而且当前又有控制台可供打印,那么就能够正确打印该行。
(记住,printk可能在所有控制台打开之前就已经被调用过了。
)
如果在该信息块中没有发现日志等级序列,并且在前面的printk调用中也没有对msg_level赋值,那么本行中的msg_level就是-1。
由于console_leglevel总不小于1(除非root通过sysctl接口锁定),于是总是可以打印这些行。
25886:
本行应该能够被打印。
printk通过遍历打开的控制台驱动链表告知每一个控制台驱动去打印当前行。
(因为虽然设备驱动在本书的讨论范围之外,但是控制台驱动代码则并不包含在内。
)
25888:
请注意这里消息文本的开头使用的是msg而不是p,这样就在没有日志等级序列的情况下写入消息了。
然而,日志等级序列已经被存储到log_buf缓冲器中了。
这样就可以使后来访问log_buf以获取信息日志等级的代码(请参看25998行)能够正确执行,不会再产生显示混乱信息序列的现象。
25892:
如果内层for循环发现一新行,那么buf中的剩余字符(如果有的话)将被认为是新的消息,因此msg_level会被重置。
但是无论怎样,外层循环都会持续到buf清空为止。
25895:
释放在25845行获取的控制台锁(consolelock)。
25896:
唤醒等待被写入控制台日志的所有进程。
注意即使没有文本被实际写入任何控制台,这个过程也仍然会发生。
这样处理是正确的,因为无论是否要往控制台中写入文本,等待进程实际上都是在等待从log_buf中读出信息。
在25748行,进程被转入休眠状态以等待log_buf的活动。
在休眠、唤醒和等待队列中所使用的机制将在下一节中进行讨论。
25897:
返回日志中写入的字符长度。
如果对于每个字符的处理工作都能减少一点,那么从25869行开始的for循环就能执行得更快一点。
当循环存在时,我们可以通过只在循环退出时将logged_chars更新一次来稍微提高运行速度。
然而我们还可以通过其它努力来提高速度。
由于我们可以预知消息的长度,因此log_size和log_start可以到最后再增长。
让我们来实验一下这样能否提高速度,下面是一段经过理想优化的代码:
do{
staticintwrapped=0;
constintx=wrapped
?
log_start
:
log_size;
constintlim=LOG_BUF_LEN–x;
intn=buf_end–p;
if(n>=lim)
n=lim;
memcpy(log_buf+x,p,n);
p+=n;
if(log_size log_size+=n; else{ wrapped=1; log_start+=n; ;og_start&=LOG_BUF_LEN–1; } }while(p 请注意循环通常只需要执行一次,只有在log_buf末尾写入信息需要折行时才会多次执行。 因而log_size和log_buf只需要更新一次(或者当写入需要换行时是两次)。 这时速度的确提高了,但是有两个原因使我们并不能这样做。 首先,内核可能有自己特有的memcpy函数,我们必须确保对memcpy的调用不会再次进入对printk的调用。 (有一部分内核移植版定义了自己特有的速度较快的memcpy函数版本,因此所有的移植都要在这一点上保持一致。 )如果memecpy调用printk来报告失败,那么就有可能触发无限循环。 然而在这一点上也并不是真的无药可救。 使用这种解决方案的最大问题在于该内核循环的形式中也要留意新行标志符,因此使用memcpy将整个消息拷贝到log_buf中是不正确的: 如果此处存在新行,我们将无法对其进行处理。 我们可以试验一个一箭双雕的办法。 下面这种替代的尝试虽然可能比前面那种初步解决方法速度要慢,但是它保持了内核版本的语意: /*indeclarationsection: */ intn; char*start; staticchar*log=log_buf; /*……*/ for(start=p;p *log++=*p; if(log>=(log_buf+LOG_BUF_LEN)) log=log_buf;/*warp*/ if(*p==‘/n’){ line_feed=1; break; } } /*p-startisnumberofcharscopied.*/ n=p–start; logged_chars+=n; /* *exerciseforthereader: *alsousentoupdatelogsizeandlog_start. *(it’snotassimpleasmaylook
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 第2章 代码初识 代码 初识