csapp大作业:hello的一生

csapp大作业

摘 要
本文通过对最简单的程序hello的从进程创建到进程回收的的整体过程的分析,来阐述程序的生命周期、计算机的底层实现和整个计算机系统为实现程序运行的组织结构。

关键词:hello、进程、计算机系统

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)

目 录

第1章 概述 - 4 -
1.1 HELLO简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在UBUNTU下预处理的命令 - 5 -
2.3 HELLO的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在UBUNTU下编译的命令 - 6 -
3.3 HELLO的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在UBUNTU下汇编的命令 - 7 -
4.3 可重定位目标ELF格式 - 7 -
4.4 HELLO.O的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在UBUNTU下链接的命令 - 8 -
5.3 可执行目标文件HELLO的格式 - 8 -
5.4 HELLO的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 HELLO的执行流程 - 8 -
5.7 HELLO的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 HELLO进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 10 -
6.3 HELLO的FORK进程创建过程 - 10 -
6.4 HELLO的EXECVE过程 - 10 -
6.5 HELLO的进程执行 - 10 -
6.6 HELLO的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 HELLO的存储管理 - 11 -
7.1 HELLO的存储器地址空间 - 11 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.5 三级CACHE支持下的物理内存访问 - 11 -
7.6 HELLO进程FORK时的内存映射 - 11 -
7.7 HELLO进程EXECVE时的内存映射 - 11 -
7.8 缺页故障与缺页中断处理 - 11 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 HELLO的IO管理 - 13 -
8.1 LINUX的IO设备管理方法 - 13 -
8.2 简述UNIX IO接口及其函数 - 13 -
8.3 PRINTF的实现分析 - 13 -
8.4 GETCHAR的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -

第1章 概述
1.1 Hello简介
1.1.1 P2P(From Program to progress):
用高级语言编写得到.c文件,然后经过cpp预处理得到.i文件,经过ccl的编译得到.s文件,再经过as的汇编将.s文件翻译成机器代码并打包成.o可重定位目标程序,最后再通过ld的链接得到可执行文件,在shell中键入启动命令,shell会为其fork产生子进程的进程,再调用execve加载进程
1.1.2 020(From Zero-0 to Zero-0):
操作系统调用execve后映射虚拟内存,进入程序入口后程序开始载入物理内存,再进入main函数执行代码,程序执行完成后,shell父进程负责回收hello进程,内核删除相关数据结构
1.2 环境与工具
硬件环境:2.6GHz Intel Core i7
软件环境:macOS Catalina、Parallels desktop 14、Ubuntu 18.04
开发工具:gdb、edb、Xcode
1.3 中间结果
hello.c :hello的c语言源代码
hello.i :预处理后的文本文件
hello.s :hello.i编译后的汇编文件
hello.o :hello.s汇编后的可重定位目标文件
hello.txt:hello.o的elf文件
hello_obj.txt:hello.o的反汇编结果文件
hello :helllo.o与预编译文件链接后的可执行文件
hello_e.txt:可执行文件hello的elf文件
1.4 本章小结
本章主要介绍了P2P、020过程,列出了本次实验的环境和工具,并且列出了本次试验过程的中间结果文件
(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念:
预处理又称预编译,指的是在程序编译之前,根据以字符#开头的命令修改原始的c程序(C语言的预处理主要有三个方面的内容: 1.宏定义; 2.文件包含; 3.条件编译)
2.1.2预处理的作用:
1.执行源文件包含#include 的指令,告诉cpp读取源程序所引用的系统源文件,并把源文件直接插入程序文本中。
2.执行宏替换。
3.条件编译。根据#if 、#endif后面的条件决定需要编译的代码
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
在这里插入图片描述

图2.3 Hello的预处理结果解析

hello.c已经从534字节扩展至66.1KB,并且打开hello.i可以发现main函数前面被填充了大量的头文件文件
在这里插入图片描述

图2-2 hello.i文件

2.4 本章小结
本章介绍了hello.c的预处理阶段,并对预处理后得到的.i文件进行了简要分析

(第2章0.5分)

第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
把预处理完的文件进行一系列语法分析及优化后生成相应的汇编指令
3.1.2编译的作用
将hello.i翻译成hello.s
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
在这里插入图片描述

图3-1 编译命令

3.3 Hello的编译结果解析
3.3.1文件声明
在这里插入图片描述

图3-2 hello.s 的文件声明部分

.file:源文件
.text:代码段
.globl:全局变量
.data:数据段
.align:对齐格式
.type:指定是对象类型或是函数类型
.size:数据空间大小
.long:长整型数据
.section .rodata:只读代码段
.string:字符串

3.3.2数据
.s文件中的主要数据类型:整型、字符串、指针数组
1.整型:
(1).全局变量 int sleepsecs=2.5:
已初始化的全局变量,存放在.data节,由图3-3,我们可以看到,sleepsecs被设置为对齐方式为4,类型是object,大小是4Byte
在这里插入图片描述

图3-3 全局变量sleepsecs

(2).main的参数 int argc:
argc是函数传入的第一个int型参数,存储在寄存器%edi中,用来表示终端输入参数个数

(3).main函数内部的的局部变int i:
函数内部的局部变量,通常保存在寄存器或是栈中。由图3-4,根据语句movl $0, -4(%rbp)可知i占用了4字节大小的栈空间。
在这里插入图片描述

图3-4 i的声明

2.字符串:
由图3-5,第一个字符串.LC0 包含汉字,每个汉字在 utf-8 编码中被编码为三个字节,第二个字符串的两个%s 为用户在终端输入的两个参数:argv[1]与argv[2]。
在这里插入图片描述

图3-5 字符串

3.指针数组argv[]:
作为main函数的参数的char *argv[]出现在栈帧中。

3.3.3赋值:
1.对全局变量的赋值:
见图3-6,对全局变量sleepsecs赋初值为2
在这里插入图片描述

图3-6 对全局变量sleepsecs的赋值

2.对局部变量的赋值:
由图3-7中的语句movl $0, -4(%rbp),对局部变量i赋初值0
在这里插入图片描述

图 3-7对局部变量i的赋值

3.3.4类型转换:
由于2.5是浮点数类型,但是sleepsecs是int型,所以当给它赋值为2.5时,值会自动向零舍入,如图3-6中sleepsecs隐式地被赋值为2 。

3.3.5算术操作:
hello.c中的主要算术操作为循环变量的自增 (i++)以及语句argc!=3
如图3-8,编译器将i++翻译为addl $1,-4(%rbp)
在这里插入图片描述

图3-8 算术操作

3.3.6关系操作:
hello.c中的主要关系操作为循环变量的控制(i<10)以及语句argc!=3
1.i < 10:
如图3-9,编译器将i<10翻译为cmpl $9,-4(%rbp)
在这里插入图片描述

图 3-9 i<10
2.argc != 3:
如图3-10,编译器将argc!=3翻译为cmpl $3,-20(%rbp)
在这里插入图片描述

图 3-10 argc!=3

3.3.7数组/指针/结构操作:
指针数组(char *argv[]):
在argv数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别指向两个用户从终端输入的字符串。根据图3-11,可知通过%rax+16和%rax+24中存储的地址,(语句:addq $16,%rax 以及 addq $8,%rax)分别得到argv[1]和argc[2]。
在这里插入图片描述

图 3-11 指针数组

3.3.8控制转移:
hello.c内部的控制转移主要有if语句以及for循环内部的控制转移
1.for(i=0;i<10;i++):
如图3-9for循环的控制时比较cmpl $9, -4(%rbp) ,当i大于9时跳出循环,否则进入.L4循环体内部执行
2.if(argc != 3):
如图3-10,当argc不等于3时进行跳转。cmpl语句比较 -20(%rbp)和-3,设置条件码,然后根据ZF进行判断,如果最近的操作得出的结果为0,则跳到.L2中,否则顺序执行下一条语句。

3.3.9函数操作:
hello.c内部主要有5个函数:main(),printf(),exit(),sleep(),getchar()
1.main():
参数传递:传入参数argc和argv,分别用寄存器%rdi和%rsi存储
函数调用:被系统启动函数调用,
函数返回:设置%eax为0并且返回
函数作用:作为程序运行的唯一入口
2.printf():
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址。
函数调用:在for循环中被调用
函数作用:用来打印信息
3.exit():
参数传递:传入一个布尔变量
函数调用:if判断条件满足后被调用
函数作用:如果传入的参数为1,则执行退出命令
4.sleep():
参数传递:传入参数sleepsecs
传递控制:call sleep
函数调用:for循环下被调用
函数作用:使计算机程序(进程,任务或线程)进入休眠
5.getchar():
传递控制:call getchar
函数调用:在main中被调用
函数作用:用来读取字符串

3.4 本章小结
本章主要介绍了编译的概念与作用,以及ubuntu下对应的编译命令,并且对编译结果进行了较为完备的分析
(第3章2分)

第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念:
通过汇编器,把汇编语言翻译成机器语言

4.1.2汇编的作用:
通过汇编这个过程,把汇编代码转化成了计算机完全能够理解的机器代码,这个代码也就是程序在计算机中表示二进制形式。

4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
在这里插入图片描述

图 4-1 汇编命令

4.3 可重定位目标elf格式
4.3.1 elf文件的生成:
在这里插入图片描述

图 4-2 elf文件的生成

4.3.2 elf头:
ELF头描述了生成该文件的系统的字的大小和字节顺序,并且包含帮助链接器语法分析和解释目标文件的信息。
在这里插入图片描述

图 4-3 elf头

4.3.3节头:
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等。
在这里插入图片描述

图 4-4 elf节头

4.3.4重定位节:
连接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位的信息都记录在ELF文件表里面,对于每个须要重定位的代码段和数据段,都会有一个相应的重定位表,例如 .rel.text 表对应.text段。也就是说,重定位表记录了须要被重定位的地址都在相应段的哪些地方。
在这里插入图片描述

图 4-5 重定位节

4.3.5符号表:
.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
在这里插入图片描述

图 4-6 符号表

4.4 Hello.o的结果解析
4.4.1 hello.o的结果查看:
命令:objdump -d -r hello.o > hello_obj.txt
在这里插入图片描述

图 4-7 hello.o查看

4.4.2 操作数:
如图4-8,hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。
4.4.3分支转移:
如图4-8,跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指
令之后是相对偏移的地址。
4.4.4 函数调用:
如图4-8,hello.s中,call指令之后直接是函数名称,而反汇编代码中call指令之后是函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.4.5全局变量的访问:
如图4-8,在hello.s文件中,对于.rodata和sleepsecs等全局变量的访问,是$.LC0和sleepsecs(%rip),而在反汇编代码中是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
在这里插入图片描述

图 4-8 hello.o与hello.s的对比

4.5 本章小结
本章主要介绍了汇编的概念与作用,在ubuntu下进行汇编的具体指令,并通过对汇编后产生的 hello.o 的可重定位的 ELF 格式的考察、对重定位项目的举例分析以及对反汇编文件与 hello.s 的对比,从原理层次进一步阐述了汇编这一过程
(第4章1分)

第5章 链接
5.1 链接的概念与作用
5.1.1概念:
链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载到内存并执行。

5.1.2作用:
当程序调用函数库中的一个函数printf,printf函数存在于一个名为printf.o的单独的预编译好了的目标文件中,而这个函数必须通过链接器将这个文件合并到hello.o程序中,结果得到hello文件,它是一个可执行目标文件,可以被加载到内存中,由系统执行。另外,链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。
5.2 在Ubuntu下链接的命令
ld链接命令: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-1 在ubuntu下链接

5.3 可执行目标文件hello的格式
5.3.1 生成elf:
命令:readelf -a hello > hello_e.txt
在这里插入图片描述

图 5-2 生成hello的elf

5.3.2 elf各段的分析:
如图5-3,可执行目标文件hello的格式类似于可重定位目标文件的格式,ELF头描述文件的总体格式。
在 ELF 格式文件中,Section Headers 对 hello 中所有的节信息进行了声明,其 中包括大小 Size 以及在程序中的偏移量Offset,因此根据 Section Headers 中的信 息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。
它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件中的节是类似的,除了这些节已经被重定位到它们最终的运行时的内存地址外。.init节定义了一个小函数_init,程序初始化代码会调用它。因为可执行文件时完全连接的,所以无.rel节。
在这里插入图片描述

图 5-3 hello的elf

5.4 hello的虚拟地址空间
5.4.1 用 edb 打开 hello
如图5-4,可以在 Data Dump 窗口看见 hello 加载到虚拟地址中的状况,程序的虚拟地址空间为
0x00000000004000000-0x0000000000401000
在这里插入图片描述

图 5-4 用edb打开hello

5.4.2 补充说明:
如图5-5是可执行文件的程序头部表,展示了ELF可执行文件的连续的片被映射到连续的内存段的映射关系。其中展示了目标文件中的偏移,内存地址,对其要求,目标文件中的段大小,内存中的段大小,运行时访问权限等信息。图中橙色底的部分是只读代码段,白色底的部分是读/写段
在这里插入图片描述

图 5-5 可执行文件hello的程序头

5.5 链接的重定位过程分析
5.5.1反汇编可执行文件:
反汇编命令:objdump -d -r hello
在这里插入图片描述

图5-6 hello的反汇编

5.5.2 hello与hello.o的不同:
由图5-7可以分析出以下几点:
1.链接增加新的函数:在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码,函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。而hello.o文件中对于.rodata和sleepsecs等全局变量的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
在这里插入图片描述

图 5-7 hello 与hello.o的反汇编对比

5.5.3 hello的重定位过程:
重定位过程合并输入模块,并为每个符号分配运行时地址,主要有以下两步:
1.重定位节和符号定义。在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。包括hello.o在内的所有可重定位目标文件中的.data节被全部合并成一个节,这个节成为输出的可执行目标文件hello中的.data节。然后,连接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,hello中每条指令和包括.rodata、sleepsecs等全局变量都有唯一的运行时内存地址了。
2.重定位节中的符号引用。链接器依赖于hello.o中的重定位条目,修改代码节和数据节中对每个符号的引用,使得它们指向正确运行时的地址。

5.6 hello的执行流程
加载程序 ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
加载hello hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
Hello初始化 hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
–libc-2.27.so!__sigjmp_save
调用main函数(运行) hello!main
调用打印函数 hello!puts@plt
调用退出函数 hello!exit@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
–ld-2.27.so!_dl_lookup_symbol_x
退出程序 libc-2.27.so!exit

5.7 Hello的动态链接分析
如图5-8和图5-9,在do_init前后global_offset表发生变化
在edb调试之后我们发现原先0x006008c0开始的global_offset表是全0的状态,在执行过_dl_init之后被赋上了相应的偏移量的值。这说明dl_init操作是给程序赋上当前执行的内存地址偏移量 。
在这里插入图片描述

图 5-8 do_init前
在这里插入图片描述

图 5-9 do_init 后

5.8 本章小结
本章主要阐述了链接的概念与作用,并指出了在ubuntu下的链接指令,还通过分析hello.o与hello的elf文件以及反汇编文件的异同,更好的说明了链接这一个过程,尤其是其中的重定位部分
(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念:
进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。

6.1.2进程的作用:
1.每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在新进程的上下文中运行它们自己的代码或其他应用程序。
2.进程提供给应用程序的关键抽象:一个独立的逻辑控制流,好像我们的程序独占地使用处理器;一个私有的地址空间,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1.shell的作用
实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果

2.shell的处理流程
shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。

6.3 Hello的fork进程创建过程
根据shell的处理流程,输入命令执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程Hello。Hello进程几乎但不完全与父进程相同,Hello进程得到与父进程用户级虚拟空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库、以及用户栈。Hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,Hello进程可以读写父进程中打开的任何文件。父进程和Hello进程最大的区别在于它们有不同的PID。
fork函数只被调用一次,却会返回两次。在父进程中,fork返回Hello进程的PID,在Hello进程中,fork返回0 。
6.4 Hello的execve过程
execve函数在加载并运行可执行目标文件Hello,且带列表argv和环境变量列表envp。只有当出现错误时,例如找不到Hello,execve才会返回到调用程序。在execve加载了Hello之后,它调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下的原型:
int main(int argc , char **argv , char *envp);

结合虚拟内存和内存映射过程,可以更详细地说明exceve函数实际上是如何加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
6.5.1逻辑控制流:
一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

6.5.2用户模式和内核模式:
处理器通过用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令和它可以访问的地址空间范围。当设置了模式位时,进程就运行着在内核模式,可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。

6.5.3用户模式与内核模式之间的转换方法:
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过中断,故障或陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将用户模式变为内核模式,处理程序运行在内核模式中,当它返回到应用程序代码时,处理前就把模式从内核模式改为用户模式。

6.5.4上下文信息:
内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。并通过上下文切换的机制来将控制转移到新的进程。

6.5.5进程时间片:
一个进程执行它的控制流的一部分的每一时间段叫做时间片。

6.5.6Hello的进程调度过程:
hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,计时器开始计时,内核通过上下文切换将当前进程将当前进程的控制权交给其他进程。当sleep函数时间到达时发送一个中断信号,此时进入内核状态执行中断处理,然后内核将进程控制权交还给hello进程,hello进程继续执行自己的控制逻辑流。
6.6 hello的异常与信号处理
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
hello执行过程中会出现的异常:
中断:信号SIGTSTP,默认行为是 停止直到下一个SIGCONT
终止:信号SIGINT,默认行为是 终止
下面演示程序运行时各命令情况:

1.如图6-1,hello运行时什么都不按。程序执行完后,进程被回收。再按回车键,结束。
在这里插入图片描述

图 6-1 正常运行

  1. 如图6-2,运行过程中按Ctrl+C。父进程收到SIGINT信号,终止hello进程,并且回收hello进程。
    在这里插入图片描述

图 6-2 运行时按Ctrl + C

3.如图6-3,运行时乱按。发现乱按的输入并不会影响进程的执行,当按到回车键时,getchar会读入回车符,并且后面的字符串会当作shell的命令行输入。
在这里插入图片描述

图6-3 hello运行时乱按

4.如图6-4,按下Ctrl+Z后运行ps命令。按下Ctrl+Z后,父进程收到SIGTSTP信号,将hello进程挂起,ps命令列出当前系统中的进程(包括僵死进程)
在这里插入图片描述

图 6-4 按下Ctrl+Z后运行ps

  1. 如图6-5,按下Ctrl+Z后运行jobs命令。jobs命令列出 当前shell环境中已启动的任务状态。
    在这里插入图片描述

图 6-5 按下Ctrl+Z后运行jobs

  1. 如图6-6,按下Ctrl+Z后运行pstree命令。pstree命令是以树状图显示进程间的关系。
    在这里插入图片描述

图 6-6 按下Ctrl+Z后运行pstree

  1. 如图6-7,fg命令将进程调到前台。
    在这里插入图片描述

图 6-7 fg命令

  1. 如图6-8,kill发送信号给一个进程或多个进程。通过kill -9 30468杀死pid为30468的进程
    在这里插入图片描述

图 6-8 kill指令

6.7本章小结
本章阐述了进程的概念与作用,然后简述量shell-bash的作用与处理流程,然后分析了hello执行过程中的fork过程与execve过程,还通过对hello的进程执行以及各种异常与信号处理的分析,完备的阐述了hello的进程管理
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址:
包含在机器语言中用来指定一个操作数或一条指令的地址。每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离。就是hello.o里相对偏移地址。
7.1.2线性地址:
逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。
7.1.3虚拟地址:
一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。
7.1.4物理地址:
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。地址翻译会将hello的一个虚拟地址转化为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
分段功能在实模式和保护模式下有所不同。
1.实模式:逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
2.保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。
段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址,具体步骤:
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。 还是挺简单的,对于软件来讲,原则上就需要把硬件转换所需的信息准备好,就可以让硬件来完成这个转换了。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。 另一类“页”,我们称之为物理页,或者是页框(frame)、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。 这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。
分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。
每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位) 依据以下步骤进行转换:
(1)从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
(2)根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了。
(3)根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
(4)将页的起始地址与线性地址中最后12位相加,得到最终的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换
图7-1给出了Core i7 MMU如何使用四级页表来将虚拟地址翻译成物理地址。36位的虚拟地址被分割成4个9位的片。CR3寄存器包含L1页表的物理地址。VPN1有一个到L1 PTE的偏移量,找到这个PTE以后又会包含到L2页表的基础地址;VPN2包含一个到L2PTE的偏移量,找到这个PTE以后又会包含到L3页表的基础地址;VPN3包含一个到L3PTE的偏移量,找到这个PTE以后又会包含到L4页表的基础地址;VPN4包含一个到L4PTE的偏移量,找到这个PTE以后就是相应的PPN(物理页号)。
在这里插入图片描述

图 7-1 Core i7页表翻译

7.5 三级Cache支持下的物理内存访问
要访问物理内存,需要以下几步:
1、CPU给出VA
2、MMU用VPN到TLB中找寻PTE,若命中,得到PA;若不命中,利用VPN(多级页表机制)到内存中找到对应的物理页面,得到PA。
3、PA分成PPN和PPO两部分。利用其中的PPO,将其分成CI和CO,CI作为cache组索引,CO作为块偏移,PPN作为tag。
先访问一级缓存,不命中时访问二级缓存,再不命中访问三级缓存,再不命中访问主存,如果主存缺页则访问硬盘
L1 Cache是8路64组相联。块大小为64B。因此CO和CI都是6位,CT是40位。根据物理地址(PA),首先使用CI组索引,每组8路,分别匹配标记CT。如果匹配成功且块的有效位是1,则命中,根据块偏移CO返回数据。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中,向下一级缓存中取出被请求的块,然后将新的块存储在组索引指示的组中的一个高速缓存行中。一般而言,如果映射到的组内有空闲块,则直接放置,否则必须驱逐出一个现存的块,一般采用最近最少被使用策略LRU进行替换。

在这里插入图片描述
图7-2 三级cache
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
exceve函数加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault) 。图7-8展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
缺页处理程序从磁盘上用VP3的副本取代VP4,在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常。
7.9动态存储分配管理
7.9.1动态内存分配的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(如图7-9)。分配器将堆视为一组不同大小的块的集合,来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
1.显式分配器:要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。其中malloc采用的总体策略是:先系统调用sbrk一次,会得到一段较大的并且是连续的空间。进程把系统内核分配给自己的这段空间留着慢慢用。之后调用malloc时就从这段空间中分配,free回收时就再还回来(而不是还给系统内核)。只有当这段空间全部被分配掉时还不够用时,才再次系统调用sbrk。当然,这一次调用sbrk后内核分配给进程的空间和刚才的那块空间一般不会是相邻的。
2.隐式分配器:也叫做垃圾收集器,例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 放置策略:
1.首次适配:从头搜索,遇到第一个合适的块就停止
2.下次适配:从头搜索,遇到下一个合适的块停止
3.最佳适配:全部搜索,选择合适的块停止。
7.9.3: 两种堆的数据结构组织形式

  1. 带标签的隐式空闲链表
    (1).放置块时,分配器搜索空闲链表,常见有首次适配、下一次适配和最佳适配的放置策略。首次适配从头开始搜索空闲链表,下一次适配从链表的上一次查询结束的地方开始搜索,最佳适配检查所有空闲块,选择最小满足的。下一次适配运行最快,但利用率低得多;最佳适配最慢,利用率最高。
    (2).分配器找到匹配的空闲块后,根据情况可能分割它。如果没有合适的空闲块,合并空闲块来创建更大的空闲块。如果还是不能满足需要,分配器向内核请求额外的堆存储器,转成空闲块加入到空闲链表中。
    (3).分配器可以选择立即合并或推迟合并,一般为防止抖动,会采用某种形式的推迟合并。
    (4).合并需要在常数时间内完成,对于空闲链表来说,它是单链表,可以方便地查看后面的块是否空闲块,但前面的块则不行,一个好办法是在块的脚部使用边界标记,它是头部的副本,这样就可以在常数时间查看前后块的类型了。为了避免边界标记占用空间,可以只在空闲块中加边界标记。
    在这里插入图片描述

图 7-3 简单的堆块的格式和隐式空闲链表的组织

  1. 显式空闲链表
    对于通用的分配器,隐式空闲链表并不适合,因为它的块分配和堆块的总数呈线性关系。可以在空闲块中增加一种显式的数据结构。下面是双向空闲链表的堆块的格式。双向链表使首次适配时间从块总数的线性时间减少到了空闲块数的线性时间。
    在这里插入图片描述

图 7-4 双向空闲链表的堆块的格式

显式链表的缺点是空闲块必须足够大来包含结构,这增大了最小块的大小,也潜在提高了内部碎片的程度。

7.9.4 分离的空闲链表:
分离的空闲链表利用分离存储来减少分配时间。分配器维护一个空闲链表数组,每个空闲链表为一个大小类。大小类的定义方式有很多,如2的幂。有简单分离存储和分离适配方法。
  简单分离存储的大小类的空闲链表包含大小相等的块,块大小为大小类中最大元素的大小。分配和释放块都是常数时间,不分割,不合并,已分配块不需要头部和脚部,空闲链表只需是单向的,因此最小块为单字大小。缺点是很容易造成内部和外部碎片。
分离适配的分配器维护一个空闲链表的数组,每个链表和一个大小类相关联,包含大小不同的块。分配块时,确定请求的大小类,对适当的空闲链表做首次适配。如果找到合适的块,可以分割它,将剩余的部分插入适当的空闲链表中;如果没找到合适的块,查找更大的大小类的空闲链表。分离适配方法比较常见,如GNU malloc包。这种方法既快、利用率也高。

7.10本章小结
本章着重介绍了被许多现代操作系统所采用的虚拟内存系统,即访存时地址需要从逻辑地址翻译到虚拟地址并进一步翻译成物理地址,并详细阐述了在TLB和四级页表支持下VA到PA的转换,以及得到了PA后,三级cache下的物理内存的访问过程,最后还讨论了malloc堆区的动态内存分配
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.1.1设备的模型化:
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。
8.1.2设备管理:unix io接口
8.1.3设备管理方法:
一个linux文件就是一个m个字节的序列:B0 , B1 , … , Bk , … , Bm-1
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口:
1.打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
2.Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
3.改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
4.读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
5.关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中

8.2.2 Unix IO函数:

  1. open()函数
    功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
    函数原型:int open(const char *pathname,int flags,int perms)
    参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,
    返回值:成功:返回文件描述符;失败:返回-1

  2. close()函数
    功能描述:用于关闭一个被打开的的文件
    所需头文件: #include <unistd.h>
    函数原型:int close(int fd)
    参数:fd文件描述符
    函数返回值:0成功,-1出错

  3. read()函数
    功能描述: 从文件读取数据。
    所需头文件: #include <unistd.h>
    函数原型:ssize_t read(int fd, void *buf, size_t count);
    参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
    返回值:返回所读取的字节数;0(读到EOF);-1(出错)。

  4. write()函数
    功能描述: 向文件写入数据。
    所需头文件: #include <unistd.h>
    函数原型:ssize_t write(int fd, void *buf, size_t count);
    返回值:写入文件的字节数(成功);-1(出错)

  5. lseek()函数
    功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
    所需头文件:#include <unistd.h>,#include <sys/types.h>
    函数原型:off_t lseek(int fd, off_t offset,int whence);
    参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
    返回值:成功:返回当前位移;失败:返回-1
    8.3 printf的实现分析
    8.3.1 printf的函数体:
    如图8-1,先看printf函数的函数体。 调用printf函数的时候,先是最右边的参数入栈。fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。
    在这里插入图片描述

图 8-1 printf的函数体

printf函数主要调用了vsprintf和write函数。

8.3.2 vsprintf函数:
由图8-2,我们可以知道该函数的功能是将所有的参数内容格式化以后存入buf
然后返回格式化数组的长度
在这里插入图片描述

图 8-2 vsprintf函数

8.3.3 write函数:
在这里插入图片描述

图 8-3 write函数

一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
于是我们查看sys_call的实现:
在这里插入图片描述

图8-4 sys_call函数

于是可以直到printf函数执行过程如下:
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar有一个int型的返回值.当程序调用getchar时.程序就等着用户按键.用户输入的字符被存放在键盘缓冲区中.直到用户按回车为止(回车字符也放在缓冲区中).当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符.getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕.如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
在这里插入图片描述

图 8-5 getchar的函数内容

8.5本章小结
本章介绍了linux下IO设备的管理方法,简单阐述了Unix IO和Unix IO函数,并详细分析了printf函数和getchar函数的底层实现。

(第8章1分)

结论
1.hello的一生:
1.用高级语言编写相应的hello程序(我们分析的是c语言程序hello.c),然后以文件的形式存储在主存中
2.hello.c经过预处理器转化为hello.i文件
3.hello.i文件经过编译器的编译操作转化为hello.s文件
4.hello.s经过汇编器被处理成可重定位的目标程序hello.o
5.链接器将hello.o与外部预编译文件进行链接,得到可执行文件hello
6.在shell中键入命令以及参数,内核为hello分配好运行程序所需要的堆、用户栈以及虚拟内存等系统资源
7. 通过调用execve函数加载并运行hello程序
8. hello的VA通过TLB和页表翻译为PA,然后在三级cache 支持下的hello物理地址访问,得到需要的数据以及代码
9. hello在运行过程中会有异常和信号等,当需要从外部对hello程序进行操控的时候,只需要在键盘上给一个相应的信号,程序就会按照指令来执行。
10. printf会调用malloc通过动态内存分配器申请堆中的内存
11. shell父进程回收hello子进程,内核删除为hello创建的所有数据结构,hello的一生结束
2.深切感悟:
通过本次对hello一生的阐述,让我对整个计算机系统有了更深刻的理解,从一个程序的源代码编写完成,到一步步转变为可执行文件,然后计算机系统为其分配各种资源,让其在某个时间片内拥有自己的cpu资源进行执行,让我深刻的领悟到了计算机的工作原理的复杂以及底层硬件为了配合实现某一过程的种种神奇而伟大之处,让我惊叹于历史上对计算机发展有所贡献的先辈们的智慧。
3.创新理念:
当下出现了量子计算机等更高级体系的计算机的模型,能够在在更小体积的基础上拥有更庞大的算力,而当下最能便利我们的日常生活中的与计算机息息相关的就是各式各样的嵌入式系统,所以我认为我们应该把计算机系统向松散耦合的方向发展,每个功能部件都是一个功能主体,在主控计算机的有限控制下完成,同时在嵌入式设备上应用普通计算机的各种先进技术让嵌入式设备性能更加优越同时价格更加令人可以接受。
(结论0分,缺失 -1分,根据内容酌情加分)

附件
hello.c :hello的c语言源代码
hello.i :预处理后的文本文件
hello.s :hello.i编译后的汇编文件
hello.o :hello.s汇编后的可重定位目标文件
hello.txt:hello.o的elf文件
hello_obj.txt:hello.o的反汇编结果文件
hello :helllo.o与预编译文件链接后的可执行文件
hello_e.txt:可执行文件hello的elf文件

(附件0分,缺失 -1分)

参考文献
为完成本次大作业你翻阅的书籍与网站等
[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分)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值