本文通过hello程序的生命历程,展示了hello从.c文件如何通过预处理、编译、汇编、链接阶段转变为可执行文件的全过程。同时介绍了hello进程在shell执行的,存储管理,I/O处理过程中的的运行机制,进一步深入理解计算机系统。
关键词:预处理;编译;汇编;链接;进程管理;存储管理;IO管理;
目 录
第1章 概述
1.1 Hello简介
P2P指Hello.c从源程序到进程的过程。Hello程序的生命周期是从一个源程序(或者说源文件)开始的,即程序员通过编辑器创建并保存的文本文件,文件名是Hello.c。Hello.c经过预处理器的编译预处理,得到预编译文件Hello.i;Hello.i又经过编译转换为汇编程序Hello.s;Hello.s又经过汇编器翻译为可重定位目标程序的格式目标文件Hello.o;最后经过链接器链接得到可执行文件Hello;Hello经过运行产生进程。
020指可执行文件Hello产生的进程从进入内存到从内存被回收的过程。子进程由父进程fork()产生成为父进程的副本,随着execve函数的执行,可执行目标文件Hello被加载并运行,新程序由此开始;随着进程的进行,进程由于某种原因终止而变为僵死进程,最后又被父进程或养父()init进程回收,到此进程被从系统中删除所有痕迹。
1.2 环境与工具
1.2.1 硬件环境
x64 CPU;2GHz;2G RAM;256GHD Disk
1.2.2 软件环境
Windows10 64位
1.2.3 开发与测试工具
Visual Studio 2022 64位;CodeBlocks 64位;gedit+gcc+gdb
1.3 中间结果
hello.c 可执行程序hello的源文件
hello.i hello.c源文件经过预处理后的文件
hello.s hello.i经过编译器编译得到的汇编程序文本
hello.o hello.s经过汇编器得到的机器语言二进制文件-可重定位目标文件
hello hello.o经过链接器与其它.o文件链接后得到的可执行程序
readelf_hello.o.txt hello.o的ELF头文件
1.4 本章小结
本章简要介绍了hello生命周期,也就是hello的P2P,020的过程,列出了过程中的环境与工具,还有中间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理是C语言的一个重要功能,它由预处理程序负责完成。当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理部分作处理,处理完毕自动进入对源程序的编译。C语言提供多种预处理功能,主要处理#开始的预编译指令,如宏定义(#define)、文件包含(#include)、条件编译(#ifdef)等。合理使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。
预编译的主要作用如下:
1、将源文件中以”include”格式包含的文件复制到编译的源文件中。
2、用实际值替换用“#define”定义的字符串。
3、根据“#if”后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令
使用命令:gcc -E hello.c -o hello.i
图2.1 预处理命令
图2.2 预处理结果-hello.i文件
2.3 Hello的预处理结果解析
查看预处理得到的hello.i文件内容,我们发现仍为C语言代码形式。文件末尾是我们hello.c的代码,可知预处理过后我们的文本添加了许多内容。这些内容其实就是我们之前在.c文件中引用的头文件stdio.h和unistd.h和stdlib.h。预处理器将他们替换为系统文件内容。这也为我们减少了大量工作,即使没有写相应代码,也能让其在编译之后为我们所用。
2.4 本章小结
本章我们介绍了预处理的概念和作用,并通过实际例子了解了预处理命令,以及查看预处理得到的文件内容,对其内容分析进一步认识到了预处理的作用。
第3章 编译
3.1 编译的概念与作用
编译指在程序源代码被翻译为目标代码的过程中,程序源文件被预处理器修改成为的预处理文件被转换为汇编程序的过程。编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
对于机器来说,.s文件比.i文件进一步的能够更好的理解,也是人能理解的更接近机器语言的格式,是翻译成.o文件重要的一步。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
使用命令gcc -S hello.c -o hello.s
图3.1 编译命令
图3.2 编译结果-hello.s文件
3.3 Hello的编译结果解析
图3.3 hello.c文件
3.3.1 数据
(1)常量:
由.c文件可知,程序中的printf函数中的字符串就是常量,对应如下:
图3.4 常量
(2)变量:
在.c文件可知,变量有argc,argc[],i。
局部变量i:储存在栈中,当所在函数返回,局部变量在栈中的空间会被释放。
正在上传…重新上传取消
图3.5 局部变量i对应的汇编语言
函数参数:(同样也是局部变量)
只在调用的函数中起作用。在进入main时存储在寄存器%rdi和%rsi中,后存储在栈中,函数返回时,空间被释放。
正在上传…重新上传取消
图3.6 函数参数对应汇编语言
- 表达式:
c语言的表达式分为很多中,其中包括:变量常量表达式,算数表达式,赋值表达式,逗号表达式,关系表达式,逻辑表达式,复合表达式。其中变量常量表达式我们在上面已经讲述完文件中的变量和常量部分,由于复合表达式是其他表达式的复合情况,我们不需要讲述。所以我们只讲述其他5个存在的表达式。
- 算数表达式
形式为a=b+c或a++。
正在上传…重新上传取消
图3.7 .c文件中算术表达式
其中i++是.c文件中的算术表达式。
正在上传…重新上传取消
图3.8 算术表达式对应汇编语言
- 赋值表达式
形式为a=1。
例子详见图3.7中赋值语句i=0。
对应汇编语言详见图3.5中movl $0, -4(%rbp)。
- 关系表达式
形式为a<=b,a!=0等。
.c文件中为argc!=4,i<8。
正在上传…重新上传取消
图3.8 .c文件中赋值表达式
正在上传…重新上传取消
图3.9 关系表达式对应汇编语言
3.3.2 赋值
对于常量,全局变量和静态变量,程序开始就已经被赋好初值;对于局部变量初次使用时赋值,详见3.3.1赋值表达式。
3.3.3 算术操作
(同3.3.1算术表达式)
3.3.4 关系操作
(详见3.3.1关系表达式)
3.3.5 数组/指针/结构操作
argv为字符串指针数组,存储着指向字符串的指针,即内存地址。我们寻找数组内元素的地址,由于argv被存储在-32(%rbp),而64位编译下指针大小为8个字节,于是每个参数字符串地址相差8个字节。编译器以数组起始-32(%rbp)为基地址,以偏移量为索引,寻找各个字符串指针的地址。即-32(%rbp)+8,-32(%rbp)+16,-32(%rbp)+24。
正在上传…重新上传取消
图3.10 汇编文件中的数组操作
3.3.6 控制转移
程序使用jmp指令跳转,一般是条件语句。
图3.11 汇编文件中对应的控制转移
3.3.7 函数操作
参数传递:在调用函数前,函数所需的参数不超过6个时被依次存放在%rdi,%rsi,%rdx,%rcx,%r8,%r9中,如果超过则多余的被压在栈中存放。在汇编文件中将参数转为整形时,使用相对寻址得到参数字符串地址分别传递给rdi,rsi,rdx,然后调用函数atoi进行转化。
函数调用:程序使用汇编指令call+函数地址来进行调用函数,在hello.o中使用函数名作为助记符代替由于没有重定位而无法得知的函数地址。call将返回地址压入栈中,为局部变量和函数参数建立栈帧,然后转移到调用函数地址
正在上传…重新上传取消
图3.12 函数调用
函数返回:在函数调用前,程序已经将返回地址压入栈中,当在调用函数中执行ret指令后,还原栈帧,返回栈中保存的返回地址,pc去执行返回地址的指令。
正在上传…重新上传取消
图3.13 函数返回
3.4 本章小结
在编译过程中,编译器将预处理文件hello.i翻译为人能够理解的,更贴近机器语言的汇编代码,针对不同的数据(常量、变量、表达式等)和操作(赋值、控制转移、函数调用等)翻译成不同的汇编指令,更好的去理解汇编语言。
第4章 汇编
4.1 汇编的概念与作用
汇编器将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件,包含着程序的指令编码,如果用文本编辑器查看,将看到一堆乱码。
汇编器将汇编语言格式的.s文件翻译成机器阅读的机器语言,得到二进制的可重定位的目标文件。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -c m64 -no-pie -fno-PIC hello.s -o hello.o
正在上传…重新上传取消
图4.1 汇编命令
4.3 可重定位目标elf格式
目标文件(Object file)有三种形式(可重定位目标文件,可执行目标文件,共享目标文件),而hello.o是一个可重定位目标文件(Relocatable file),但仍然不能运行。汇编器在翻译汇编语言的过程中,会按照特定的ELF格式,将程序中的模块组织起来。ELF格式如下图所示:
正在上传…重新上传取消
图4.2 ELF格式
4.3.1 ELF Header
使用readelf -h hello.o查看文件头:
正在上传…重新上传取消
图4.3 ELF Header
行数 信息
- ELF头开始
- Magic指明该文件类型是ELF文件,7F是固定数,45 4c 46是ELF的 ASCii值。
- 文件类别,64位
- 数据是以小端序存放
- 版本号
- OS/ABI是操作系统类别,这里是unix系统
7 ABI版本号是0
8 ELF文件类型中的REl可重定位文件,其他类型还有共享库文件,可 执行文件
9 指明系统架构
- 程序开始的虚拟地址位置
- 程序头起点位置
- 节头开始的位置
- 未知
- ELF文件字节大小
剩余不做解释
4.3.2 Section Headers
命令:readelf -S hello.o
正在上传…重新上传取消
图4.4 Section Headers
对象文件中的重定位条目,会构成一个个单独的节。一个ELF可重定位目标文件包含的节有.text, .rodata, .data, .bss, ,symtab, .rel.text, .debug, .line, .strtab等,其中一些介绍参考下图:
正在上传…重新上传取消
图4.5 节信息
4.3.3 .symtab
使用readelf -s hello.o 查看符号表节信息
正在上传…重新上传取消
图4.6 符号表
符号表存放在程序中定义和引用的函数和全局变量的信息,不包含局部变量的条目。符号表表示了重定位的所有符号。可以看到一共提供了18个符号。
4.3.4.rela.text节和.rela.eh_frame节
使用readelf -r hello.o 查看重定位信息。
正在上传…重新上传取消
图4.7 .rela.text节和.rela.eh_frame节信息
图中信息介绍了两条重定位节的一些信息,这里同时涉及到了重定位的绝对引用与相对引用,还需要了解绝对引用、相对引用的重定位算法。
正在上传…重新上传取消
图4.8 重定位算法
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
使用命令objdump -d -r hello.o对hello.o进行反汇编,得到机器代码对应的汇编代码。
正在上传…重新上传取消
图4.9 反汇编代码
对照.s文件和.o文件反汇编文件,区别有:
(1)伪指令:汇编代码中的前后有着指导汇编器和连接器的伪指令(以.开头的如.file等),而反汇编代码只有开头的文件格式说明。
(2)分支转移:反汇编代码没有使用助记符(如.L1)进行转移,而是使用如je 2f的直接地址跳转。只有汇编代码能够使用助记符(如MOV,.L1等)帮助我们理解指令内容,但机器必须将其翻译为二进制机器指令才能识别。这也说明了.s能帮助人更好的理解机器语言。
(3)函数调用:反汇编代码进行函数调用时与汇编代码一样使用callq(e8后放偏移地址),但在反汇编代码中地址显示的是下一行指令的地址,在下一行提示了我们在重定位信息中看到过的符号+加数信息,而汇编代码直接使用函数名称作为助记符。这是因为汇编代码没有进行重定位,函数调用时并不知道函数的位置,于是使用00 00 00 00代替,而e8后接的四个字节表示偏移地址,于是该条指令显示为跳转到下一条指令。
(4)全局变量:反汇编代码使用0x0(%rip)寻找全局变量的地址,而汇编代码直接引用助记符(如.LC0)。在反汇编的指令下面我们可以看到重定位信息,我们自然可以猜到这与重定位有关。我们的两个字符串提示符放在静态存储区,属于全局变量,需要进行重定位,而反汇编代码不知道位置于是暂时使用偏移0x0代题,%rip+0x0即下一条指令的开头。
4.5 本章小结
本章具体了解了汇编过程中汇编器将汇编代码翻译为机器指令,生成可重定位的目标文件hello.o,我们通过各种指令了解了hello.o的内部信息,熟悉了可重定位目标文件的内部格式,以及其内部的一些节信息等等,又将.o文件使用objdump指令进行反汇编,了解了其与.s文件汇编代码的差异。
第5章 链接
5.1 链接的概念与作用
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。链接执行符号解析、重定位过程。
链接使分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而可以把他分解更小、更好管理的模块,可以独立修改和编译这些模块。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
使用命令:
ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/
x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/ctri.o /usr/lib
/x86_64-linux-gnu/crti.o /usr/lib/x86_64-linux-gnu/crtn.o /usr/lib
/x86_64-linux-gnu/libc.so hello.o
正在上传…重新上传取消
图5.1 链接结果
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
5.3 可执行目标文件hello的格式
正在上传…重新上传取消
图5.2 典型的ELF可执行目标文件
5.3.1 ELF Header
正在上传…重新上传取消
图5.3 hello的ELF头
与hello.o的ELF头对比可知,类型由REL变为了EXEC文件,并且在hello的ELF头中大部分在hello.o的ELF头中暂时被填为0的条目被重新填充了,且于是各起始地点也相应向后移动了。由此可知,hello加载到主存执行,有了真正的“地址”。
例如:
第11行,程序的虚拟地址入口点,被改为0x4010f0。
第12行,程序头起点,被改为64。
第13行,节头开始处,被改为14208。
第15行,ELF 文件头的字节数被改为64。
第16行,程序头大小被改为56。
第17行,程序头数量被改为12。
第18行,节头的大小,这里每个节头大小为64个字节。
第19行,节头增加了13个。
第20行,节头字符串表索引号,同样增加了13个。
5.3.2 Program Header
readelf -l hello 查看程序头表
正在上传…重新上传取消
图5.4 程序头表
上图的程序头描述了目标文件中的偏移(Offset),内存地址(VirAddr/PhyAddr),目标文件中的段大小(filesz),内存中的段大小(memsz),访问权限(Flags)(R:可读/W:可写/E:可执行),对齐要求(Align)。我们可以得到各个段的信息,比如:索引号为02的段LOAD(代码段),有读/执行访问权限,开始于内存地址0x400000处,总共的内存大小为0x5c0字节,并且被初始化为可执行目标文件的头0x5c0个字节,进行2^(12)=4096字节对齐。(其他各个段类似可以得到相应信息)
5.3.3 .symtab
readelf -s hello 查看符号表
正在上传…重新上传取消
图5.5 符号表
由上图和hello.o的符号表对比可知,多了一个.dynsym,是动态符号表,是由于链接时连接了共享库。
5.3.4 重定位信息
readelf -r hello查看重定位信息
正在上传…重新上传取消
图5.6 重定位信息
5.3.5 Section Headers
使用readelf -S hello查看节头部表
正在上传…重新上传取消
图5.7 Section Headers
5.4 hello的虚拟地址空间
使用edb加载hello
正在上传…重新上传取消
正在上传…重新上传取消
图5.8 edb加载hello
从图中可以看出,程序起始虚拟地址位于0x400000,接着我们分析各段各节的对应位置。
(1)从下图中可以发现,下面的虚拟地址从0x400000开始到0x4005a0结束。与代码段起始位置和大小相对应。
正在上传…重新上传取消
图5.9 Data Dump_1
- 从图5.8可知第二部分是从0x401000开始,0x402000结束,有效大小0x245,如图:
正在上传…重新上传取消
图5.10 Data Dump
(注:这里的段的结尾要对齐)
再去根据分析hello的ELF的信息,见图5.7去查看各个节的信息。
.interp段地址从0x4002e0,偏移量为0x2e0,大小为0x1c,对齐要求为1。
正在上传…重新上传取消
正在上传…重新上传取消
图5.11 .interp段
.text段地址从0x4010f0,偏移量为0x10f0,大小为0x147,对齐要求为16。
正在上传…重新上传取消
图5.12 .text段
.rodata段地址从0x402000开始,偏移量为0x2000,大小为0x3b,对齐要求为8。
正在上传…重新上传取消
正在上传…重新上传取消
图5.13 .rodata段
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
正在上传…重新上传取消
图5.14 hello反汇编内容
通过与hello.o的反汇编结果对比我们发现,在hello的反汇编代码中的特点有所改变,主要有4点:
- 函数调用:hello反汇编代码的函数调用直接使用虚拟内存地址,而hello.o反汇编代码由于没有重定位用0代替。
- 全局变量:hello.o反汇编代码使用0x0(%rip)寻找全局变量的地址,hello反汇编代码由于重定位找到了全局变量位置(%rip)前面是有意义的偏移。
- 虚拟内存地址:hello反汇编代码每行命令左边有对应的虚拟内存地址位置,而hello.o使用数字作为相对位置。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 子程序地址 |
ld-2.23.so!_dl_start | 0x7fdc27aebc10 |
ld-2.27.so! dl_init | 0x401145 |
libc-2.27.so! cxa_atexit | 0x7fdc27aef550 |
libc-2.27.so!_setjmp | 0x7fdc27ae60d0 |
libc-2.27.so!_sigsetjmp | 0x7fdc27ae4460 |
libc-2.27.so!__sigjmp_save | 0x7fdc27adb396 |
hello!puts@plt | 0x401090 |
hello!exit@plt | 0x4010d0 |
hello!printf@plt | 0x4010a0 |
hello!sleep@plt | 0x4010e0 |
hello!getchar@plt | 0x4010b0 |
ld-2.23.so!_dl_runtime_resolve_avx | 0x7fdc27aec980 |
libc-2.27.so!exit | 0x7fdc27adb2f0 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
对于动态共享链接库中PIC函数,编译器没办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表(PLT)+全局偏移量表(GOT)实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
正在上传…重新上传取消
图5.15 节头中.got的信息
下面使用edb查看相关位置:
正在上传…重新上传取消
图5.16.got
正在上传…重新上传取消
图5.17.got.plt
在之后的调用函数时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。
5.8 本章小结
本章介绍了链接的概念与作用,并通过各种命令查看了hello可执行程序的内部信息,了解了hello的重定位过程,执行流程,对链接有了更深的认识。
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。其包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
正在上传…重新上传取消
图6.1 进程的作用
6.2 简述壳Shell-bash的作用与处理流程
在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。类似于DOS下的COMMAND.COM和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。shell 是一个交互型应用级程序,代表用户运行其他程序。
其基本功能是解释并执行用户键入的各种命令,实现用户与linux核心的接口。系统启动后,内核为每个终端用户建立一个进程去执行shell解释程序。
处理流程:
- 解析用户输入的命令行内容,得到新的“命令行”
- 判断命令是否是系统内置命令,是则直接执行,不是则当作可执行文件,利用fork创建子进程,并调用execve运行。
- 判断可执行文件命令行参数,是前台运行还是后台运行。
- Shell随时接受键盘输入的信号,并根据信号作出处理。
6.3 Hello的fork进程创建过程
当我们在shell中输入可执行文件的名字./hello时,shell解析命令行,发现不是内置命令,则将hello当作可执行文件去执行。父进程fork一个子进程,新创建的子进程几乎但不完全与父进程相同,子进程会获得与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆共享库以及用户栈。
在父进程创建新的子进程时,子进程还会获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork函数时,子进程可以读写父进程中打开的任何文件。父进程和子进程之间的最大区别在于它们有不同的PID。
正在上传…重新上传取消
图6.2 shell中输入./hello的处理流程
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。
正在上传…重新上传取消
正在上传…重新上传取消
图6.3 execve函数说明
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp.只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以,与fork一次调用返回两次,execve调用一次并从不返回。
综合6.3的说明,父进程在fork创建子进程之后,在子进程中,我们调用execve'函数,参数传递命令行参数,和hello的名字以及环境变量。execve函数就将控制传递给新程序的主函数,该主函数有如下形式的原型:
int main (int argc, char **argv, char **envp)
6.5 Hello的进程执行
参考书上的相关知识。
逻辑控制流:
即使在系统中通常有许多其他程序在运行, 进程也可以向每个程序提供一种假象,好像它在独占地使用处理器。如果想用调试器单步执行程序, 我们会看到一系列的程序计数器(PC) 的值, 这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC 值的序列叫做逻辑控制流,或者简称逻辑流。
正在上传…重新上传取消
图6.5 逻辑控制流
私有地址空间:
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间,在一台n位地址机器上,地址空间是2n个可能地址的集合,0,1,···,2n-1。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其它进程读或者写的,从这个意义上,这个地址空间是私有的。
正在上传…重新上传取消
图6.6 进程地址空间
用户模式和内核模式:
为了使操作系统内核提供一个无懈可击的进程抽象, 处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I / O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
Linux 提供了一种聪明的机制,叫做/ proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/ proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/ proc 文件系统找出一般的系统属性,比如CPU 类型(/proc/cpuinfo) ,或者某个特殊的进程使用的内存段(/ proc / < process-id > /maps)。2.6版本的Linux 内核引人/sys 文件系统,它输出关于系统总线和设备的额外的低层信息。
上下文切换:
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务。
内核为每个进程维持一个上下文。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling) ,是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2) 恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换, 运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是系统调用,它显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
如图6.7展示了一对进程A 和B 之间上下文切换的示例。在这个例子中, 进程A 初始运行在用户模式中,直到它通过执行系统调用read 陷入到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA 传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。
正在上传…重新上传取消
图6.7 上下文切换示例
磁盘取数据要用一段相对较长的时间(数量级为几十毫秒),所以内核执行从进程A 到进程B 的上下文切换,而不是在这个间歇时间内等待,什么都不做。注意在切换之前,内核正代表进程A 在用户模式下执行指令(即没有单独的内核进程)。在切换的第一部分中,内核代表进程A 在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B 在用户模式下执行指令。
随后,进程B 在用户模式下运行一会儿,直到磁盘发出一个中断信号, 表示数据已经从磁盘传送到了内存。内核判定进程B 已经运行了足够长的时间, 就执行一个从进程B 到进程A 的上下文切换, 将控制返回给进程A 中紧随在系统调用read 之后的那条指令。进程A 继续运行, 直到下一次异常发生, 依此类推。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
hello执行过程会出现四种异常:中断,陷阱,故障和终止
正在上传…重新上传取消
图6.8 异常的类别
参考书上材料:见下图:
正在上传…重新上传取消
正在上传…重新上传取消
正在上传…重新上传取消
图6.9 不同异常类型说明
异常与信号处理:
- 随便乱按
我们的一般输入不会影响程序的运行,而是打印在屏幕上,当我们输入很多换行符,并且又输入一些信息时,当程序运行结束后,这些换行和输入会重新起作用,但是由于无效,shell对其显示没有该命令。
正在上传…重新上传取消
图6.10 程序运行中随便乱按
- ctrl c
进程在运行过程中突然终止运行了。可知,我们输入ctrl c,进程就会收到sigint信号,程序就终止了。
正在上传…重新上传取消
图6.11 程序运行中输入ctrl c
- ctrl z
进程停止了。我们输入ctrl z,进程就会收到sigtstp信号,进程停止。从使用jobs命令可以看到,hello程序停止运行。
正在上传…重新上传取消
图6.12 程序运行中输入ctrl z
使用pstree查看进程树状图:
正在上传…重新上传取消
图6.13 停止时输入pstree
使用ps查看:
正在上传…重新上传取消
图6.14 停止时输入ps
使用fg %1再次运行起hello:
正在上传…重新上传取消
图6.15 停止时输入fg %1
使用kill命令杀死hello进程,即发送sigkill信号给hello进程:
正在上传…重新上传取消
图6.16 使用kill命令杀死进程
6.7本章小结
异常是允许操作系统内核提供进程概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。我们了解了不同异常的概念区别和处理流程,同时理解了进程的创建和回收,进程在实际机器中的作用,我们通过对实际运行hello可执行文件的分析,熟悉了linux下的一些常用命令,也掌握了hello程序执行前后进程的具体体现。信号是一种更高层次的软件形式的异常,允许进程和内核中断其它进程。我们从实际hello程序中,输入不同的输入,来理解了信号的机制与信号的处理方法。
7.1 hello的存储器地址空间
逻辑地址:在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。下面以hello.asm(hello的反汇编文件)为例,说明逻辑地址:
正在上传…重新上传取消
图7.1 hello.asm中的偏移地址
如图7.1所示,为程序对全局变量sleepsec的访问,程序中显式表示的0x601050即为偏移地址。
线性地址(虚拟地址):CPU在保护模式下,“段基址+段内偏移地址”叫做线性地址,注意,保护模式下段基址寄存器中存储的不是真正的段基值(和实模式的含义不一样),而是被称为“段选择子”的东西,通过段选择子在GDT(全局描述表)中找到真正的段基值。另外,如果CPU在保护模式下没有开启分页功能,则线性地址就被当做最终的物理地址来用,若开启了分页功能,则线性地址就叫虚拟地址(在没开启分页功能的情况下线性地址和虚拟地址就是一回事)。但是,如果开启分页功能,虚拟地址(或线性地址)还要通过页部件电路转换成最终的物理地址。在hello中即为虚拟内存映像中的虚拟地址。
正在上传…重新上传取消
图7.2 虚拟寻址
物理地址:物理地址就是内存单元的绝对地址。即我们使用CPU外部地址总线上的寻址物理内存的地址信号。
正在上传…重新上传取消
图7.3 物理寻址
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由两部分组成:段标识符,段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,剩下3位包含硬件信息。其中索引号可以直接理解成数组下标,它对应的“数组”就是段描述符表,段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
给定一个完整的逻辑地址[段选择符:段内偏移地址],
看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,基地址就知道了。把基地址 + 偏移量,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存到物理内存映射:
概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM 系统通过将虚拟内存分割为称为虚拟页(Virtual Page ,VP)的大小固定的块来处理这个问题。每个虚拟页的大小为P =2^p字节。类似地,物理内存被分割为物理页(PhysicaI Page,PP) ,大小也为P 字节(物理页也被称为页帧(page frame))。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
1.未分配的:VM 系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
2.缓存的:当前已缓存在物理内存中的已分配页。
3.未缓存的:未缓存在物理内存中的已分配页。
正在上传…重新上传取消
图7.4 一个VM系统如何使用主存作为缓存
如图7.5展示了一个页表的基本组织结构。页表就是一个页表条目(Page Table Entry,PTE) 的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。为了我们的目的,我们将假设每个PTE 是由一个有效位(valid bit)和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM 中。如果设置了有效位,那么地址字段就表示DRAM 中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位, 那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
正在上传…重新上传取消
图7.5 页表
虚拟地址到物理地址翻译:
如图7.6展示了MMU如何利用页表来实现虚拟地址空间(VAS)到物理地址空间(PAS)的映射。CPU 中的一个控制寄存器,页表基址寄存器(Page Table Base Reglster,PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offset,VPO) 和一个(n-p)位的虚拟页号(Virtual Page Number,VPN)。MMU利用VPN来选择适当的PTE。
正在上传…重新上传取消图7.6 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
二级页表中的每个PTE 都负责映射一个4KB 的虚拟内存页面,就像我们查看只有一级的页表一样。注意, 使用字节的PTE , 每个一级和二级页表都是4KB 字节,这刚好和一个页面的大小是一样的。
图7.7描述了使用级页表层次结构的地址翻译。虚拟地址被划分成为k个VPN 和1 个VPO 。每个VPN i都是一个到第i 级页表的索引,其中1≤i≤k。第j级页表中的每个PTE, 1≤j≤k,都指向第j + 1 级的某个页表的基址。第k级页表中的每个PTE 包含某个物理页面的PPN ,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU 必须访问差个PTE 。对于只有一级的页表结构, PPO和VPO是相同的。
正在上传…重新上传取消
图7.7 使用k级页表的地址翻译
7.5 三级Cache支持下的物理内存访问
收到虚拟地址之后,先在TLB中寻找,若不在TLB中,结合多级页表得到它的物理地址,然后到Cache里面寻找。若在TLB中,则直接被MMU得到。 在Cache中,命中就返回,不命中就依次在L1、L2、L3中不断寻找。
正在上传…重新上传取消
图7.8 TLB与Cache结合访问
7.6 hello进程fork时的内存映射
当fork 函数被shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
正在上传…重新上传取消
图7.9 一个私有的写时复制对象
7.7 hello进程execve时的内存映射
execve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。而加载程序的步骤如下:
(1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构
(2)映射私有区域,为新程序的代码、数据、bss、和栈区域创建新的区域,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data、.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
(3)映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器(PC),execve做的最后一件事就是设置当前进程上下文的PC,使其指向代码区的入口。
下一次调度这个进程时它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
正在上传…重新上传取消
图7.10 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
缺页:虚拟内存中的字不在物理内存中(BRAM缓存不命中),通俗地讲就是内存中没有这个页,所以导致MMU找不到对应的物理地址。缺页故障是一种故障,当指令引用一个虚拟地址,MMU会查找页表,当找不到对应的物理地址时,就会触发缺页中断。
正在上传…重新上传取消
图7.11 缺页
正在上传…重新上传取消
图7.12 触发缺页
正在上传…重新上传取消
图7.13 缺页处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“break”),它指向堆的顶部。
分配器将堆视为一组不同大小的块(clock)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个己分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
正在上传…重新上传取消
图7.14 堆
基本方法与策略:
(1)带边界标签的隐式空闲链表分配器管理
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的。
正在上传…重新上传取消
图7.15 一个简单的堆块格式
在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块。
正在上传…重新上传取消
图7.16 隐式空闲链表的组织堆
(2)显式空间链表管理
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。
正在上传…重新上传取消
图7.17显式空间链表管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.10本章小结
本章我们了解了内存管理的相关知识,比如不同的寻址方法、地址翻译、内存映射等。同时我们掌握了缺页故障和缺页中断的管理机制,还有动态内存分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
- 设备的模型化:文件
一个Linux 文件就是一个个m字节的序列:B0,B1,B2,… ,Bk,… ,Bm-1 所有的I /O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。 每个linux文件都有一个类型来表明它在系统中的角色: (1)普通文件:包含任意数据。 (2)目录:是包含一组链接的文件 (3)套接字:是用来与另一个进程进行跨网络通信的文件。
- 设备管理:unix io接口
这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口, 称为Unix I / O ,这使得所有的输人和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1打开和关闭文件
(1)int open(char *filename, int flags, mode_t mode);
open函数将filename转换位一个文件描述符,并且返回描述符数字。flgas参数指明了进程打算如何访问这个文件;modec参数指定了新文件的访问权限位。
(2)int close(int fd);
进程通过调用close关闭一个打开的文件,关闭一个已关闭的描述符会出错。
8.2.2读和写文件
正在上传…重新上传取消
图8.1 读和写
8.3 printf的实现分析
正在上传…重新上传取消
图8.2 printf函数体
va_list的定义:typedef char *va_list,说明它是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是...中的第一个参数。
然后我们再来查看vsprintf代码。
正在上传…重新上传取消
图8.3 vsprintf函数
先思考printf函数的功能:接受一个格式化命令,并按指定的匹配的参数格式化输出。
故I = vsprintf(buf, fmt, arg)应该是得到打印出来的字符串长度,以及后面的write(buf, i)应该是将buf中的i个元素写到终端。
所以vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
下面看write函数:
正在上传…重新上传取消图8.4 write函数
在 write 函数中,将栈中参数放入寄存器,ecx 是要打印的元素个数,ebx 存放buf中的第一个元素地址,int INT_VECTOR_SYS_CALLA 代表通过系统调用 syscall,查看 syscall 的实现:
正在上传…重新上传取消
图8.5 sys_call的实现
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar功能:程序调用getchar时,程序等待用户从键盘输入信息。在用户输入有效信息时,输入的字符被放入字符缓冲区,getchar不进行处理;当用户输入回车键时,getchar以字符为单位读取字符缓冲区,但不会读取回车键和文件结束符。
异步异常-键盘中断:getchar的从键盘输入的实现是异常中断后键盘中断的处理程序的结果。
当我们进行键盘输入时,我们从当前进程跳转到键盘中断处理子程序,接受按键扫描码。当键盘上的一个按键按下时,键盘会发送一个中断信号给CPU,与此同时,键盘会在指定端口(0x60) 输出一个数值,这个数值对应按键的扫描码(make code)叫通码,当按键弹起时,键盘又给端口输出一个数值,这个数值叫断码(break code),这样计算机知道我们何时按下何时按下、松开,是否一直按着按键。
中断处理子程序把键盘中断通码和断码数值转换为按键编码(对于字母键、数字键为ASCII码),缓存到键盘缓冲区,然后把控制器交换给原来任务(getchar),若没有遇到回车键,继续等待用户输入,重复上述过程,遇到回车键后getchar按字节读取键盘缓冲区内的内容,处理完毕后getchar返回,getchar进程结束。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章学习了linux的I/O设备管理机制,了解了打开、关闭、读、写、转移文件的接口及相关函数,简单分析了printf和getchar函数的实现方法以及操作过程。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello的一生从程序员的C语言开始,到进程的回收结束。Hello运行的短短几秒钟里,却是hello经历了复杂的过程。
hello.c从我们手中诞生,开始踏上了生命历程。第一步预处理,hello展开了自己的头文件,引入了新的内容,丰富了自我,变成了.i文件。接着,.i文件进入编译器,改头换面,穿上了汇编语言的新衣,变为了.s文件。继续行程,又进入了汇编器,再次摇身一变,变得让我们难以理解,也变得没那么“人性化”,显得“机器化”,他不再是丰富的字符文件,而变成一堆0,1组成的二进制文件.o文件。然后,我们的.o文件遇见了其它的家庭成员.o文件,链接器告诉他们说你们必须彼此相互依靠才能存活,就这样,在链接器的作用下,他们一起成为了可执行程序hello。
历程到这还远远没有结束,它在shell中运行起来,fork创建子进程,execve帮他加载,hello进入其中,开始大展身手。在这过程中,它需要得到我们的指令才能成功执行,而且它可能收到许多信号的中断,甚至承担着被杀死的风险。
不仅如此,它的运行还包括着虚拟地址,内存引用,cache等等很多知识。
最后,hello终其一生,完成了光辉的使命,我们不会让他这样灰溜溜的僵死于此,我们要光荣的“回收”hello,它的一生远不止仅仅在屏幕上留下一串串文字···
计算机系统设计的非常巧妙,这项工作真的很需要天赋型人才,其中设计模块层层相扣,紧紧联系,十分佩服。
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c 可执行程序hello的源文件
hello.i hello.c源文件经过预处理后的文件
hello.s hello.i经过编译器编译得到的汇编程序文本
hello.o hello.s经过汇编器得到的机器语言二进制文件-可重定位目标文件
hello hello.o经过链接器与其它.o文件链接后得到的可执行程序
readelf_hello.o.txt ELF格式的hello.o
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)