2022计算机系统大作业——程序人生-Hello’s P2P
Posted czdczdczdczd
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了2022计算机系统大作业——程序人生-Hello’s P2P相关的知识,希望对你有一定的参考价值。
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 120L021716
班 级 2003005
学 生 蔡泽栋
指 导 教 师 吴锐
计算机科学与技术学院
- 2021年5月
本文介绍了hello.c文件编写完成后在Linux下运行的完整生命历程,对预处理、编译、汇编、链接、进程管理、存储管理、I/O管理这些hello程序的生命历程进行详细、清楚地解释。通过运用一些工具清晰地观察hello程序完整的周期,直观地表现了程序从开始到结束的生命历程。
关键词:预处理;编译;汇编;链接;进程;存储;I/O
-
目录
第1章 概述
1.1 Hello简介
P2P: From program to process,即从程序到进程。在Linux中,hello.c经过cpp预处理、ccl编译、as汇编、ld链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork产生一个子进程,然后hello便从程序变为了进程。
020: shell为此子进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
1.2.1 硬件环境
i7-X64 CPU;2.30GHz;2G RAM;256GHD Disk
1.2.2 软件环境
Windows 10 64 位 VitualBox 15.1.0 Ubuntu 1 8 .04 LTS
1.2.3 开发工具
Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果
文件名称 | 说明 |
hello.c | hello源文件 |
hello.i | 预处理后文件 |
hello.s | 编译得到的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后可执行文件 |
hello.elf | hello的elf文件 |
-
1.4 本章小结
本章主要介绍了hello的P2P,020过程,给出了本次实验的环境。也列出了为编写本论文生成的中间结果文件并解释了其作用。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理是计算机对一个程序处理的第一步,对.c文件初步处理成一个.i文件。预处理器根据以字符#开头的命令,修改原始的C程序:是指在进行编译的第一遍扫描之前所做的工作。当对一个c源文件进行预处理时,系统自动引用预处理程序以解析以字符#开头的预处理命令,比如#include<stdio.h> 等命令来修改原始的C程序,待预处理进行完毕之后自动进入对源程序的编译。
预处理的作用:
删除宏定义“#define”展开并解析所定义的宏,处理所有条件预编译指令。插入include后面的文件到“#include”处。删除所有的注释。最后将处理过后的新的文本保存在hello.i中。
2.2 在Ubuntu下预处理的命令
命令 gcc -E hello.c -o hello.i
结果如下图所示:
2.3 Hello的预处理结果解析
hello.i文件的内容增加到3000多行,预处理器对源文件中的宏进行了宏展开,对#define相应的符号进行了替换,同时也将系统头文件中的内容直接插入到了程序文本中。
2.4 本章小结
预处理是计算机对程序进行操作的第一个步骤,预处理器会对hello.c文件进行初步的解释,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,将注释这个对于执行没有用的部分删除。
第3章 编译
3.1 编译的概念与作用
概念:编译器将文本文件hello.i翻译成hello.s,它包含一个汇编语言程序。它是以高级程序设计语言书写的源程序作为输入,以汇编语言或者机器语言表示的目标程序作为输出。
作用:进行词法分析、语法分析和目标代码的生成,检查无误后生成汇编语言。
3.2 在Ubuntu下编译的命令
命令 gcc -S hello.i -o hello.s
结果如下图所示:
3.3 Hello的编译结果解析
3.3.1 汇编指令
.file:声明源文件
.text:代码节
.rodata:只读代码段
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
3.3.2 数据
1. 字符串
第一个字符串“用法: Hello 学号 姓名 秒数!\\n”存放在只读数据段.rodata中,被编码成utf-8格式,其中一个汉字占三个字节。
第二个字符串"Hello %s %s\\n",输出传入的格式化参数,存放在只读数据段.rodata中。
2. main函数的参数argc
用户传递给main函数的参数argc被放到了堆栈。19行将栈地址保存在%rbp中,第22行%edi保存函数传入的第一个参数即argc,存放在-20(%rbp)的位置。
3. main函数的参数argv数组
argv每个元素char*大小为8字节,指针指向已分配好存放字符指针的连续空间,起始地址为argv。第23行%rsi保存函数传入的第二个参数即argv数组的首地址,存放在-32(%rbp)的位置。
访问数组元素argv[1],argv[2]和argv[3]时,按照起始地址argv大小8位计算数据地址取数据。
4. 临时变量int i
main函数内声明的局部变量i编译的时会放在堆栈中,即栈上-4(%rbp)的位置。
3.3.3 操作
1. 算术操作
for循环中临时变量i++,通过add指令实现。
2.赋值操作
for循环中对i赋初值的操作通过mov指令来进行实现。
3.关系操作
if语句判断argc!=4,设置条件码,为之后je跳转做准备。
for循环的条件判断i<8,比较i是否小于等于7,被编译为cmpl $7,-4(%rbp)。
4.数组操作
访问数组元素argv[1],argv[2]和argv[3]时,按照起始地址argv大小8位计算数据地址取数据。argv[]先被存在用户栈中,再用基址加偏移量寻址访问argv[1],argv[2]和argv[3]在第34、37和44行使用三次%rax取出其值。
5.控制转移操作
控制转移在本程序中包括if条件分支引起的跳转以及for循环分支引起的跳转。通过关系操作cmpl进行比较设置条件码,之后根据条件码进行跳转。
6.函数操作
main函数:
传递数据:外部调用过程向main函数传递参数argc和argv,分别使用%edi和%rsi存储,将%eax设置0返回。
printf函数:
传递数据:第一次printf将%rdi设为“用法: Hello 学号 姓名 秒数!\\n”字符串的首地址。第二次printf将 %rdi设为“Hello %s %s\\n”的首地址,设置%rdx为argv[1],%rsi为argv[2]。
控制传递:第一次printf有一个字符串参数, call puts@PLT,第二次printf使用call printf@PLT。
exit函数:
传递数据:将%edi设置为1。
控制传递:call exit@PLT。
sleep函数:
控制传递:call sleep@PLT。
atoi函数:
控制传递:call atoi@PLT。
getchar函数:
控制传递:call gethcar@PLT
3.4 本章小结
本章介绍了linux环境下对C语言程序进行预处理之后的文件进行编译的命令,用hello程序实际演示对编译结果hello.s的简单分析,通过源程序与汇编语言程序的对比,说明了编译器是怎么处理C语言的各个数据类型以及各类操作的,分数据,赋值,算数操作,关系操作,数组,控制转移,函数操作等方面按照类型和操作进行了分析。
第4章 汇编
4.1 汇编的概念与作用
汇编指的是汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标文件,并将结果保存在目标文件hello.o中。
hello.o是一个二进制文件,包含hello程序执行的机器指令。汇编的作用是将汇编语言翻译成机器可以直接读取分析的机器指令。
4.2 在Ubuntu下汇编的命令
命令 gcc -c hello.s -o hello.o
结果如下图所示:
4.3 可重定位目标elf格式
1. 查看ELF头
命令:readelf -h hello.o
以16字节的序列Magic即魔数开始,描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
2. 查看节头部表
命令:readelf -S hello.o
以16字节的序列Magic即魔数开始,描述了生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括 ELF 头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
2. 查看节头部表
命令:readelf -S hello.o
ELF文件中共有13个节,包含了文件中出现的节的类型、位置和大小等信息。每个节都从0开始,用于重定位。在文件头中得到节头部表的信息,再使用字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小等。
部分节的名称及内容如下:
节名称 | 包含内容 |
.text | 已编译程序的机器代码 |
.rela.text | 一个.text节中位置的列表,链接器链接其他文件时,需修改这些内容 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量和所有被初始化为0的全局或静态变量 |
.rodata | 只读数据段 |
.comment | 包含版本控制信息 |
.symtab | 符号表,存放程序中定义和引用的函数和全局变量信息 |
3. 查看符号表
命令:readelf -s hello.o
符号表用来存放程序中定义和引用的函数和全局变量的信息,重定位需要引用的符号在其中声明。
4. 查看重定位条目
命令:readelf -r hello.o
描述了需要进行重定位的各种信息,包括需要进行重定位符号的位置、重定位的方式、名字。在hello.o中,对printf,exit等函数的未定义的引用替换为该进程的虚拟地址空间中机器代码所在的地址。
4.4 Hello.o的结果解析
objdump -d -r hello.o分析hello.o的反汇编。
机器语言是二进制机器指令的集合,而机器指令由操作码和操作数构成。
机器语言与汇编语言的映射关系:每一条汇编语言操作码都可以用机器二进制数据来表示,所有的汇编语言和二进制机器语言是一一映射关系。
分支转移:
反汇编代码跳转指令的操作数使用的不是段名称如.L3,段名称只是在汇编语言中便于编写的助记符。而在机器语言反汇编程序中,分支转移命令是直接跳转入目的地址。
函数调用:
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标是相对地址。因为.c文件中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,反汇编的代码已经知道了相对位置。在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0。
4.5 本章小结
本章介绍了hello从hello.s 到hello.o 的汇编过程,通过查看hello.o的ELF格式和使用objdump得到反汇编代码与hello.s进行比较的方式,了解到从汇编语言映射到机器语言汇编器需要实现的转换。在这个过程中,生成了重定位条目,为之后的链接中的重定位过程奠定了基础。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。
作用:通过链接可以实现将头文件中引用的函数并入到程序中,解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。完成程序中各目标文件的地址空间的组织。
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/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
结果如下图所示:
5.3 可执行目标文件hello的格式
读取hello的ELF, 可以看到保存了可执行文件hello中的各个节的信息。如图5-2所示。hello文件中的节的数目比hello.o中多了很多,说明在链接过后有新文件添加进来。
hello的ELF头和hello.o的ELF头大体一致,但是类型从REL (可重定位文件)变为了EXEC (可执行文件),增加程序头起点,节头和字符串表索引节头的数量变多。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息。
可以看出.interp段在虚拟地址0x4002e0处,.dynstr段在0x400398处,.init段在0x401000处等。
同时在Data Dump中查看hello的虚拟地址空间开始处为0x401000。
5.5 链接的重定位过程分析
objdump -d -r hello得到hello的反汇编文件:
hello.o的重定位项目:
从这段汇编代码中可以看到:
hello.o中的je,call,jmp后面跟的操作数是全0,而hello中是已经计算出来的相应段或函数的地址。根据这个不同可以分析出hello.o链接成为hello的过程中需要对重定位条目进行重定位,对相应的条目进行计算得到地址。并且hello的反汇编代码还有很多其他的函数,例如puts,printf,getchar等,从这个不同可以分析出链接会将共享库中函数的汇编代码加入hello.o中。
hello重定位的过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rela.txt
重定位算法:
foreach section s
foreach relocation entry r
refptr = s + r.offset;/*ptr to reference to be relocated*/
if(r.type == R_X86_64_PC32)//PC相对寻址的引用
refaddr = ADDR(s) + r.offset;/*ref's run-time address*/
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - refaddr);
/*Relocate an absolute reference*/
if( r.type == R_X86_64_32)//使用32位绝对地址
*refptr = (unsigned)(ADDR(r.symbol) + r.addend);
重定位地址计算公式为:
refaddr = ADDR(s) + r.offset
*refptr = (unsigned) (ADDR(r.symbol) + r.addend - reffaddr)
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。
程序名称 | 程序地址 |
ld-2.27.so!_dl_start | 0x7ffff7a03b00 |
ld-2.27.so!_dl_init | 0x7ffff7de37d0 |
libc-2.27.so!__libc_start_main | 0x7ffffc827ab0 |
-libc-2.27.so!__cxa_atexit | 0x7ffffc849430 |
libc-2.27.so!_setjmp | 0x7ffffc844c10 |
libc-2.27.so!exit | 0x7ffffc849128 |
5.7 Hello的动态链接分析
动态链接就是要将程序拆成几个独立的部分,在运行的时候将它们连接起来,与静态链接把所有模块都链接成一个可执行文件不同。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。
PLT是一个数组,其中每个条目是16字节代码。每个库函数都有自己的PLT条目,PLT[0]是一个特殊的条目,跳转到动态链接器中。从PLT[2]开始的条目调用用户代码调用的函数。
GOT也是一个数组,每个条目是8字节的地址,和PLT联合使用时,GOT[2]是动态链接在ld-linux.so模块的入口点,其余条目对应于被调用的函数,在运行时被解析。每个条目都有匹配的PLT条目。
在elf文件中找到.got的地址0x403ff0
5.8 本章小结
本章介绍了链接的概念和作用,详细介绍了hello.o如何成为可执行的目标文件,详细介绍了hello.o的ELF形式和各节的意义,分析了hello的虚拟地址空间、重置进程、运行进程和动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用:
1.每次用户向shell输入一个可执行目标文件的名字运行时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
2.给应用程序两种关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:
Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell是一个交互型应用级程序,代表用户运行其它程序。
处理流程:
Shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,或者是自由软件)。然后Shell在可执行程序的目录列表里寻找这些应用程序。如果键入的命令不是一个内部命令并且在列表里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
6.3 Hello的fork进程创建过程
Shell通过fork创建一个新的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程最大的区别就是有不同的pid。
fork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0。父进程与子进程是并发运行的独立进程。
6.4 Hello的execve过程
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所以,与fork一次调用返回两次不同,execve调用一次并从不返回。
加载并运行hello需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分存在的区域结构。
2. 映射私有区域。新建应用程序的代码、数据、bss和堆放区域的新区域结构。所有这些区域结构都是私人的,写的时候都是复印的。虚拟地址空间的代码和数据区域将被映射到Hello文件的.txt和.data区域。bss区域请求二进制0。 地图匿名文件。那个大小包含在Hello文件里。堆栈和堆栈区域也请求初始长度为0的二进制数。
3. 映射共享区域。如果hello程序与共享对象相连,例如标准C库libc.so所有对象都将被动态地连接到该程序中,并反映到用户的虚拟中。地址空间的共享区域。
4.设置程序计数器。exceve最后做的就是将当前进程上下文中的程序柜台设置为指向代码区域的入口。调用此进程从这个入口开始实行。Linux可根据需要更换代码和数据页面。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
上下文信息:
上下文是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
进程时间片:
一个进程执行它的控制流的一部分的每一时间段叫做时间片。
上下文切换:
当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1.保存以前进程的上下文。
2.恢复新恢复进程被保存的上下文。
3.将控制传递给这个新恢复的进程 ,来完成上下文切换。
用户模式和内核模式:
处理器通常是用某个控制寄存器中的一个模式位提供两种模式的区分功能,该寄存器描述了进程当前享有的特权。当设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
hello程序的运行:
进程调用execve函数,为hello分配好了虚拟地址空间,将代码段和数据段映射为可执行文件hello中的相应内容。假设hello进程在sleep之前一直在顺序执行。在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。一段时间后发生中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。
6.6 hello的异常与信号处理
hello执行过程中会出现的异常:
中断:信号SIGTSTP,默认行为是 停止直到下一个SIGCONT
终止:信号SIGINT,默认行为是 终止
程序正常运行的结果如下图所示,程序执行完后进程被回收,再按回车键退出程序。
运行时乱按时的结果如下图所示,乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
运行时按Ctrl+Z后结果如下图所示。按下Ctrl+Z后父进程收到SIGTSTP信号,将hello进程挂起,ps命令列出当前系统中的进程。运行jobs命令列出当前shell环境中已启动的任务状态。
运行pstree命令,以树状图显示进程间的关系:
运行时按Ctrl+C后结果如下图所示。父进程收到SIGINT信号,终止hello进程,并且回收hello进程。
6.7本章小结
本章介绍了进程的概念及作用,shell的作用及其处理流程,并分析了hello的fork进程创建过程、execve过程和进程执行过程,最后根据不同情况分析了hello运行过程中的异常和信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址:
逻辑地址是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。
2.线性地址:
线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
3.虚拟地址:
虚拟地址是程序保护模式下,程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。就是hello里面的虚拟内存地址。
4.物理地址:
CPU通过地址总线的寻址,找到真实的物理内存对应地址。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在保护模式下,段描述符占8个字节,无法直接存放在段寄存器中(段寄存器只有2字节)。X86的设计是段描述符存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值。一个逻辑地址由两部份组成,段标识符: 段内偏移量。
逻辑地址到线性地址的变换方法:
1. 给定一个完整的逻辑地址[段选择符:段内偏移地址],首先根据T1的值,确定当前要转换是GDT中的段,还是LDT中的段,再依据对应寄存器,得到其地址和大小。
2. 根据段选择符中前13位,在数组中查找到相应的段描述符,获得基地址。
3. 将基地址加上偏移量得到要转换的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(即虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
Linux系统有自己的虚拟内存系统。Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct的链表,一个链表条目对应一个段,所以链表相连指出了hello进程虚拟内存中的所有段。
系统将每个段分割为被称为虚拟页(VP)的大小固定的块来作为进行数据传输的单元,在linux下每个虚拟页大小为4KB,类似地,物理内存也被分割为物理页(PP/页帧)。虚拟内存系统中MMU负责地址翻译,MMU使用存放在物理内存中的被称为页表的数据结构将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
不考虑TLB与多级页表,虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO,根据位数限制分析可以确定VPN和VPO分别占多少位是多少。通过页表基址寄存器PTBR+VPN在页表中获得条目PTE,一条PTE中包含有效位、权限信息、物理页号,如果有效位是0+NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0+非NULL,则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,与虚拟页偏移量共同构成物理地址PA。
7.4 TLB与四级页表支持下的VA到PA的变换
Intel i7的地址翻译用到了TLB与四级页表的技术,其中虚拟地址48位,物理地址52位,页表大小为4KB,页表为4级。
由于页表大小是4KB,所以VPO为12位,VPN就是36位。每一个PTE条目是8字节,所以每一个页表就有512个条目,那么VPN中每一级页表对应的索引就有9位。TLB有16组,所以TLBI就是4位,TLBT就是32位。
CPU产生虚拟地址VA,传给MMU,MMU使用前36位作为VPN,在TLB中搜索,如果命中,就得到其中的40位物理页号PPN,与12位VPO合并成52位的物理地址PA。
如果TLB没有命中,MMU就向内存中的页表请求PTE。CR3是一级页表的起始地址,VPN1是第一级页表中的偏移量,用来确定二级页表的起始地址,VPN2又作为第二级页表的偏移量,以此类推,最后在第四级页表中找到对应的PPN,与VPO组合成物理地址PA。
7.5 三级Cache支持下的物理内存访问
L1 Cache是8路64组相连高速缓存。块大小64B。因为有64组,所以需要6 位CI进行组寻址,共有8路,块大小为64B,所以需要6位CO表示数据偏移位置,VA共52位,所以CT共40位。
在上一步中已经获得了物理地址VA,使用CI进行组索引,每组8路,对8路的块分别匹配CT(前40位)。如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是0,则不命中,向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。
7.6 hello进程fork时的内存映射
fork函数为新进程创建各种数据结构,并给它分配一个唯一的PID。为了给新的hello进程创建虚拟内存,它创建了当前进程的的mm_struct, vm_area_struct和页表的原样副本,两个进程中的每个页面都标记为只读,两个进程中的每个区域结构都标记为私有的写时复
哈工大计算机系统大作业 程序人生-Hello‘s P2P 020
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021112808
班 级 2103103
学 生 陶丽娜
指 导 教 师 刘宏伟
摘 要
本文详细介绍了hello程序的一生,在Linux环境下借助gcc,cpp,cll,as,ld,vim,objdump,edb等工具,分析 hello程序如何从一个文本文件源程序hello.c经过预处理、编译、汇编、链接成为可执行目标文件,还分析了进程管理、异常与信号处理。通过分析hello的一生,复习了本课程的所有内容。
关键词:编译;预处理;汇编;链接;进程;异常与信号;
计算机科学与技术学院
2022年10月
目录
第1章 概述
1.1Hello简介
P2P过程:(From Program to Process)通过编译器的处理,hello.c文件经历预处理、编译、汇编、链接,四个步骤,从源程序文件变为可执行目标文件,然后由shell为其创建一个新的进程并运行它,此时hello就从program变成了process。
020过程:(From 0 to 0)刚开始程序还不在内存空间中,所以是0,shell通过execve和fork加载并执行hello,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后执行第一条指令,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构,此时又变成了0。
1.2环境与工具
硬件环境:AMD Ryzen 7 5800H; 16G RAM;512G SSD
软件环境:Windows 10 64位;VirtualBox6.1;Ubuntu 20.04 LTS 64;
开发工具: gcc, objdump, vim
1.3中间结果
名称 | 作用 |
---|---|
hello.c | hello程序c语言源文件 |
hello.i | hello.c预处理生成的文本文件 |
hello.s | 由hello.i编译得到的汇编文件 |
hello.o | hello.s汇编得到的可重定位目标文件 |
elf.txt | readelf生成的hello.o的elf文件 |
hello | hello.o和其他文件链接得到的可执行目标文件 |
hello1.elf | readelf生成的hello的elf文件 |
注反汇编结果只是命令执行后查看过,并未保存文件。
1.4本章小结
本章介绍了hello的P2P和020过程,分析hello一生需要的软硬件环境和开发工具,还罗列出了中间结果文件的名字及其作用。
第2章 预处理
2.1预处理的概念与作用
预处理是指程序在编译一个c文件之前,预处理器cpp根据以字符#开头的命令(头文件、宏等),修改原始的c程序。比如hello.c中的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中,结果就得到了另一个C程序,通常是以.i作为文件扩展名。
2.2在Ubuntu下预处理的命令
gcc -m64 -no-pie -fno-PIC -E hello.c -o hello.i
2.3Hello的预处理结果解析
如图展示了部分结果,预处理器cpp处理以#开头的语句,因为<stdio.h >,<unistd.h>,<stdlib.h>都是标准文件库,所以它会在linux系统中的环境变量下寻找这三个库,它们在/usr/include下,然后cpp将这三个库直接插入代码中。因为这三个库中还有#define #ifdef等,所以cpp还需要将这些展开,所以hello.i最后没有#define
2.4本章小结
本章讲述了预处理的概念和作用,并且分析了hello.c经过预处理后生成hello.i的过程,对hello.c来说是cpp读取了这三个系统头文件的内容,并把它插入此程序文本中,然后得到了另一个C程序hello.i。
第3章 编译
3.1编译的概念与作用
编译过程是指编译器ccl做一些语法语义分析,如果没有错误,它就会把文本文件hello.i翻译成ASCII汇编语言文件hello.s,它包含一个汇编语言程序,翻译后的汇编语言程序中,每一条语句都以一种文本格式描述一条低级机器语言指令,为不同高级语言的不同编译器提供了通用的输出语言。
3.2在Ubuntu下编译的命令
gcc -m64 -no-pie -fno-PIC -S hello.i -o hello.s
3.3Hello的编译结果解析
3.3.1数据
1.立即数
汇编语言中立即数是以$开头的。hello.c中的整型常量是以数字出现的,对应了hello.s中的立即数,如图红框中的数字就是hello.c中的整型常量。分别是4与argc比较,exit(1)参数为1,给i赋初值1,i与9比较,i加1,输出argv[1],argv[2]时的下标1,2,以及argv[3]中的下标3。
它们与hello.s中的对应关系如图所示:
2.字符串:
字符串有红框中的两个,即”用法: Hello 学号 姓名 秒数!\\n”和格式串”Hello %s %s\\n”它们都作为为源程序中printf函数中直接输出的部分,存放在了.rodata中。
3.int
a.int i是局部变量存储在寄存器或者栈中,hello.c程序中i是int型4字节局部变量,在程序中声明,存放在-4(%rbp)中。
b. int argc是main中的第一个参数,根据参数传递规则,不需要声明,直接调用就可以。如图,将edi的值传给了-20(%rbp)
4.数组
char *argv[]是指向char型变量的指针, 没有单独声明,在函数执行时在命令行进行输入。argc指向已经分配好连续的连续空间,起始地址为argv,它保存在%rsi中,如图,将rsi的值传给了-32(%rbp)。
3.3.2赋值
对局部变量i赋值即i=0;因为i是双字,所以用的是movl
3.3.3类型转换
atoi(argv[3])用到了强制转换,它把字符串转换成整型数。
3.3.4算术操作
1. 加法:ADD S, D (D <- D + S)
2. 减法:SUB S, D (D <- D - S)
3.3.5关系操作
CMP S1, S2
1. 判断argc是否等于4,使用cmp S1,S2根据S2-S1的值设置条件码,等于0则ZF=1,否则ZF=0,为接下来的跳转做准备。
2.判断i是否小于等于8,将条件码设置为i - 8,为下一句跳转指令做准备
3.3.6数组指针操作
printf中用了argv[1]、argv[2],atoi中用了argv[3]对应在汇编中的操作是寻址后把argv[1]传给%rsi,argv[2]传给%rdx完成printf的参数传递,寻址后把argv[3]传给%rdi,将参数传给atoi。
3.3.7控制转移
1.第一处是判断argc是否等于4,若不等于,则继续执行,若等于,则跳转至L2处继续执行。
2.第二处是无条件跳转,以跳到L4,即循环部分代码。
3.第三处是判断是否达到循环终止条件(i<9),hello.s中是比较i和8,若小于等于则跳回L4重复循环,否则顺序执行。
3.3.8函数调用
1.第一处是调用printf(),输出一个字符串常量,参数存在%rdi中。
2.第二处是调用printf()输出字符串常量和两个char指针argv[1],argv[2]指向的字符串,字符串常量作为参数1在%rdi中,两个指针作为参数2、3分别存在%rsi和%rdx中。
3.第三处是调用atoi(),将%rdi设置为argv[3],call调用atoi函数进行字符串到整型数的转换。
4.第四处是调用sleep(),首先是将atoi转换后的值保存到%edi中,然后调用sleep。
5.第五处是调用exit,参数是1,将立即数1保存到%edi,然后调用exit
6.第六处是调用getchar(),没有参数。
3.4本章小结
本章先分析了从hello.i到hello.s的过程,然后分析了hello.s文件的程序代码,包括C语言中的各种数据类型以及各种操作指令。它比原来更加接近于底层,更加接近于机器。
第4章 汇编
4.1汇编的概念与作用
概念:汇编器as将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中。其中hello.o是一个二进制文件。
作用:产生机器语言指令,使得机器能够识别。
4.2在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -c hello.s -o hello.o
4.3可重定位目标ELF格式
4.3.1.ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中ELF头的大小,目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如X86-64)、字节头部表的文件偏移,以及节头部表中条目的大小和数量。
4.3.2节头部表
节头部表中描述其他节的位置和大小,还包括包括节的名称、类型、地址、偏移量、对齐等。
4.3.3.rel.text和.rel.data
.rel.text(重定位节) 一个.text节中位置的列表,包含了.text节中需要进行重定位的信息。.rel.data是被模块引用或定义的所有全局变量的重定位信息。链接时,需要重定位函数位置(exit, printf, atoi, sleep, getchar)和.rodata中LC0和LC1两个字符串中的信息。
4.3.4符号表
.symtab一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。Name是字符串中的字节偏移,指向符号的以null结尾的字符串名字,value是据定义目标的节的起始位置偏移,size是目标的大小(以字节为单位)。Type是符号的种类,有函数、数据、文件等,Binding简写为bing,表示符号是本地的还是全局的,符号表还可以包含各个节的条目,以及对应原始源文件的路径名的条目。
4.4Hello.o的解析结果
反汇编结果:
hello.s:
反汇编和hello.s在代码段很相像,但是反汇编左侧增加了汇编语言对应的机器语言指令。机器语言是由0/1所构成的序列,在终端显示为16进制表示的。
1.分支转移:
hello.s分支转移目标位都是使用.L*表示,hello.o反汇编之后,目标位置变成具体的地址。段名称在hello.s只是助记符,在hello.o机器语言中不存在。
2.函数调用:
hello.s中函数调用是call+函数名,在反汇编文件中目标地址变成了当前的PC值,因为都调用外部函数,所以需要在链接阶段重定位。
3.操作数:
hello.s中立即数是十进制的,反汇编文件中都是二进制的,在终端显示的时候转换成了十六进制。
4.5本章小结
本章分析了从hello.s到hello.c的过程,分析了hello.s的ELF文件信息,对比hello.s和hello.o反汇编代码,分析了汇编指令和机器指令区别和相同点。下一步,hello.o就会通过链接,成为可执行文件。
第5章 链接
5.1链接的概念与作用
概念:链接是将各种代码和数据片段收集并组成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译、加载、运行时。
作用:使得分离编译成为可能,可以将大型应用程序分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需要简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
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/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
5.3可执行目标文件目标ELF格式
1.ELF头
hello的 ELF头和hello.o的包含的信息种类相同,但是它还包括程序的入口点,也就是当程序执行时要执行的第一条地址,程序头和节数量也有了增加。
2. 节头表
与之前的相比,因为邻接,所有的节都有了实际地址。它描述各个节的大小、偏移量和其他属性。链接器链接时,将文件的相同段合并成一个段,根据这个段的大小和偏移量重定位符号的地址。
3.重定位节
与之前的重定位节完全不同,这是链接重定位的结果。
4.符号表
链接进行符号解析后,不在main中定义的符号也有了类型(TYPE),符号表不需要加载到内存。
5.程序头
程序头部表描述了可执行文件的连续的片与连续内存段的映射,我们可以看到根据可执行目标文件的内容初始化两个内存段,代码段和数据段。
5.4Hello的虚拟地址空间
使用edb加载hello,Data Dump 窗口可以查看本进程的虚拟地址空间各段信息,程序从0x00400000处开始存放。再看程序头,它告诉链接器运行时需要加载的内容,它还提供动态链接的信息。每一个表项提供各段在虚拟地址空间和物理地址空间的各方面的信息。
其中phdr显示程序头表;interp必须调用的解释器。load表示需要从二进制文件映射到虚拟地址空间的段,保存常量数据、程序的目标代码等。dynamic 保存动态链接器使用的信息;note辅助信息。gnu_stack是权限标志,标志栈是否可执行。gnu_relro:重定位后内存中只读区域的位置。
5.5链接的重定位过程分析
命令:objdump -d -r hello
分析hello与hello.o的不同:
hello是符号解析和重定位后的结果,链接器会修改hello中数据节和代码节中对每一个符号的引用,使得他们指向正确的运行地址。
1.新的函数:
hello.o与其他库链接,hello.c中使用的库中的函数就被加载进来了,如exit、printf、sleep、getchar、atoi等函数。
2.条件控制和函数调用地址都有改变
3.hello中多了.init和.plt节。
链接的过程:链接就是链接器将各个目标文件组装在一起,文件中的各个函数段按照一定规则累积在一起。
hello重定位:有重定位PC相对引用和绝对引用,对于PC相对引用,将地址改为PC值-跳转目标位置地址。绝对引用则将地址改成该符号的第一个字节所在的地址。
5.6Hello的执行流程
通过edb的调试,逐步记录call命令调用的函数。
地址 | 名称 |
---|---|
0x401000 | <_init> |
0x401020 | <.plt > |
0x401090 | puts@plt |
0x4010a0 | printf@plt |
0x4010b0 | getchar@plt |
0x4010c0 | atoi@plt |
0x4010d0 | exit@plt |
0x4010e0 | sleep@plt |
0x4010f0 | <_start> |
0x401120 | <_dl_relocate_static_pie> |
0x401125 | |
0x4011c0 | <__libc_csu_init> |
0x401230 | <__libc_csu_fini> |
0x401238 | <_fini> |
5.7Hello的动态链接分析
从hello的elf文件可知,.got表的地址为0x0000000000403ff0,在edb中的Data Dump窗口跳转,定位到GOT表处。
调用_init后
初始地址0x00600ff0全为0。程序调用一个由共享库定义的函数,编译器没有办法预测这个函数的运行时地址,因为它定义它的共享模块在运行时可以加载到任意位置。链接器采用延迟绑定的策略解决。动态链接器使用过程链接表PLT + 全局变量偏移表GOT实现函数的动态链接,GOT中存放目标函数的地址,PLT使用该地址跳转到目标位置,其中GOT[1]指向重定位表,GOT[2]指向动态链接器ld-linux.so运行地址。
5.8本章小结
本章介绍了链接的定义和作用,分析了程序链接的过程,查看ELF的信息分析链接生成可执行文件过程中程序发生的变化。使用edb分析了动态链接。
第6章 HELLO进程管理
6.1进程的概念与作用
概念:进程时一个执行中程序的实例。
作用:系统中的每个程序都运行在某个进程的上下文中,进程提供一个假象,就好像我们的程序是系统中当前运行的唯一的程序的一样,我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接着一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2简述壳SHELL-BASH的作用与处理流程
作用:Shell能解释用户输入的命令,将它传递给内核,还可以:调用其他程序,给其他程序传递数据或参数,并获取程序的处理结果。在多个程序之间传递数据,把一个程序的输出作为另一个程序的输入。Shell本身也可以被其他程序调用。
处理流程:
- 输出一个提示符,等待输入命令,读取从终端输入的用户命令。
- 分析命令行参数,构造传递给execve的argv向量。
- 检查第一个命令行参数是否是一个内置的shell命令,如果是立即执行。
- 否则用fork为其分配子进程并运行。
- 子进程中,进行步骤2获得参数,调用exceve()执行程序。
- 命令行末尾没有&,代表前台作业,shell用waitpid等待作业终止后返回。
- 命令行末尾有&,代表后台作业,shell返回。
6.3HELLO的FORK进程创建过程
输入命令执行hello后,父进程判断不是内部指令后,会通过fork创建子进程。子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟空间相同但独立的副本,包括代码和数据段、堆、共享库以及用户栈。子进程可以读写父进程中打开的任何文件,二者最大的区别在于它们有不同的PID。
fork函数只会被调用一次,但会返回两次,在父进程中,fork返回子进程的PID,在子进程中,fork返回0。
6.4HELLO的EXECVE过程
execve函数在加载并运行可执行目标文件hello,且带列表argv和环境变量列表envp。当出现错误时,例如找不到hello时,execve会返回到调用程序,与一次调用两次返回的fork不同。在execve加载了hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:int main(intargc , char **argv , char *envp);结合虚拟空间和内存映射过程,有删除已存在的用户区域,映射私有区,映射共享区,设置PC这四个过程,进程地址空间如图所示。
6.5HELLO的进程执行
1.逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流。这个序列每个PC值唯一对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令,一个进程有一个逻辑控制流,进程交错执行。
2.上下文:内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
3.用户模式和内核模式:处理器使用某个控制寄存器一个控制位提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
4.进程时间片:一个进程和执行它的控制流的一部分的每一时间段。
5.上下文切换:比如sleep函数,初始时,控制在hello中,处于用户模式,调用系统函数sleep后,转到内核模式,调用进程被挂起,经过设定秒数后,发送中断信号,转回用户模式,继续执行指令。如图是一个进程上下文切换的剖析。
调度过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
6.6HELLO的异常与信号处理
1.异常,如图所示,异常有以下四种,分别是中断、陷阱、故障、终止。它的种类和处理方式如图所示。
2.信号,信号有很多种,图中展示的是Linux上支持的30种不同类型的信号。
3.hello正常运行状态
4.Ctrl+Z
进程收到 SIGSTP 信号, hello 进程被挂起。ps查看它的进程PID,可知 hello的PID是3333; jobs查看hello的后台 job号是1,用调用 fg 1把它调回前台。
5. Ctrl+C:进程收到 SIGINT 信号,终止 hello。在ps中没有它的PID,在job中也没有,可以看出hello已经被永远地停止了。
6.运行中不停乱按,会将屏幕的输入缓存到缓冲区,乱码被认为是命令。
7.kill,杀死进程,hello被终止。
6.7本章小结
本章介绍了hello进程的执行过程。主要是hello的创建、加载和终止,通过键盘输入。在hello运行过程中,内核有选择地对其进行管理,决定何时进行上下文切换。在hello运行过程中,接受到不同的异常信号时,异常处理程序将对异常信号做出回应,执行对应指令,每种信号有不同的处理机制,对不同的异常信号,hello有不同的处理结果。
结论
hello经历的过程
1.源文件编写:用文本编辑器写出hello的源程序文件。
2.预处理:预处理器对hello.c进行预处理,生成hello.i文本文件,将源程序中使用到的外部库插入到文件中。
3.编译:编译器对hello.i进行语法分析、优化等操作生成hello.s汇编文件。
4.汇编:as将hello.s翻译机器更易读懂的机器代码hello.o,它是一个二进制文件。
5.链接:链接器ld将hello.o和其他用到的文件进行合并链接,生成可执行文件hello。
6.运行程序:在终端中输入运行命令,shell进程调用fork为hello创建子程序,然后调用execve启动加载器,加映射虚拟内存。
7.执行指令:CPU为程序分配时间片,在一个时间片中hello使用CPU资源顺序执行控制逻辑流。
8.异常处理:hello执行的过程中可能收到来自键盘输入的信号,调用信号处理程序进行处理。
9. 进程结束:shell作为父进程回收子进程,内核删除为这个进程创建的所有数据结构。
感悟:通过分析hello的一生,把本课程的知识联系到了一起,不再是以往零碎的知识点了,有了更加完整的知识体系。
附件
名称 | 作用 |
---|---|
hello.c | hello程序c语言源文件 |
hello.i | hello.c预处理生成的文本文件 |
hello.s | 由hello.i编译得到的汇编文件 |
hello.o | hello.s汇编得到的可重定位目标文件 |
elf.txt | readelf生成的hello.o的elf文件 |
hello | hello.o和其他文件链接得到的可执行目标文件 |
hello1.elf | readelf生成的hello的elf文件 |
参考文献
[1] 龚奕利. 深入理解计算机系统.北京:机械工业出版社,2016.
以上是关于2022计算机系统大作业——程序人生-Hello’s P2P的主要内容,如果未能解决你的问题,请参考以下文章