[CSAPP]ICS大作业

摘  要

本文主要阐述hello程序的整个生命周期,探讨hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程。通过对hello生命周期的追踪与探索,来进一步熟悉计算机系统中有关进程、内存的知识,加深对于计算机系统的理解。

关键词:hello world;Linux;预处理;编译;链接;内存;进程;I/O                         

目  录

第1章 概述................................................................................................................ - 4 -

1.1 Hello简介......................................................................................................... - 4 -

1.2 环境与工具........................................................................................................ - 4 -

1.3 中间结果............................................................................................................ - 4 -

1.4 本章小结............................................................................................................ - 5 -

第2章 预处理............................................................................................................ - 6 -

2.1 预处理的概念与作用........................................................................................ - 6 -

2.2在Ubuntu下预处理的命令............................................................................. - 6 -

2.3 Hello的预处理结果解析................................................................................. - 6 -

2.4 本章小结............................................................................................................ - 7 -

第3章 编译................................................................................................................ - 8 -

3.1 编译的概念与作用............................................................................................ - 8 -

3.2 在Ubuntu下编译的命令................................................................................ - 8 -

3.3 Hello的编译结果解析..................................................................................... - 8 -

3.4 本章小结.......................................................................................................... - 13 -

第4章 汇编.............................................................................................................. - 14 -

4.1 汇编的概念与作用.......................................................................................... - 14 -

4.2 在Ubuntu下汇编的命令.............................................................................. - 14 -

4.3 可重定位目标elf格式.................................................................................. - 14 -

4.4 Hello.o的结果解析........................................................................................ - 18 -

4.5 本章小结.......................................................................................................... - 19 -

第5章 链接.............................................................................................................. - 20 -

5.1 链接的概念与作用.......................................................................................... - 20 -

5.2 在Ubuntu下链接的命令.............................................................................. - 20 -

5.3 可执行目标文件hello的格式...................................................................... - 20 -

5.4 hello的虚拟地址空间................................................................................... - 24 -

5.5 链接的重定位过程分析.................................................................................. - 25 -

5.6 hello的执行流程........................................................................................... - 27 -

5.7 Hello的动态链接分析................................................................................... - 28 -

5.8 本章小结.......................................................................................................... - 30 -

第6章 hello进程管理....................................................................................... - 31 -

6.1 进程的概念与作用.......................................................................................... - 31 -

6.2 简述壳Shell-bash的作用与处理流程........................................................ - 31 -

6.3 Hello的fork进程创建过程......................................................................... - 32 -

6.4 Hello的execve过程..................................................................................... - 32 -

6.5 Hello的进程执行........................................................................................... - 32 -

6.6 hello的异常与信号处理............................................................................... - 33 -

6.7本章小结.......................................................................................................... - 36 -

第7章 hello的存储管理................................................................................... - 37 -

7.1 hello的存储器地址空间............................................................................... - 37 -

7.2 Intel逻辑地址到线性地址的变换-段式管理............................................... - 37 -

7.3 Hello的线性地址到物理地址的变换-页式管理......................................... - 39 -

7.4 TLB与四级页表支持下的VA到PA的变换................................................ - 40 -

7.5 三级Cache支持下的物理内存访问............................................................. - 40 -

7.6 hello进程fork时的内存映射..................................................................... - 41 -

7.7 hello进程execve时的内存映射................................................................. - 41 -

7.8 缺页故障与缺页中断处理.............................................................................. - 43 -

7.9动态存储分配管理.......................................................................................... - 44 -

7.10本章小结........................................................................................................ - 45 -

第8章 hello的IO管理.................................................................................... - 47 -

8.1 Linux的IO设备管理方法............................................................................. - 47 -

8.2 简述Unix IO接口及其函数.......................................................................... - 47 -

8.3 printf的实现分析........................................................................................... - 48 -

8.4 getchar的实现分析....................................................................................... - 49 -

8.5本章小结.......................................................................................................... - 49 -

结论............................................................................................................................ - 49 -

附件............................................................................................................................ - 51 - 

参考文献.................................................................................................................... - 52 -

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P:在Linux中,hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork产生一个子进程,然后hello便从程序变为了进程。

020: shell为此子进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。

1.2 环境与工具

硬件环境:

X64 CPU;2GHz;2G RAM;256GHD Disk 以上

软件环境:

Windows10 ; VMware Workstation Pro;Ubuntu 20.04.2.0

工具:

CodeBlocks20.04;vi/vim/gpedit+gcc;gdb;edb;HexEdit

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

文件名

文件的作用

hello.i

hello.c预处理之后文本文件

hello.s

hello.i编译后的汇编文件

hello.o

hello.s汇编之后的可重定位目标文件

hello

链接之后的可执行目标文件

hello_linkdisas

hello反汇编之后的可重定位文件

hello_disas

hello的反汇编后的文件

hello_elf

hello的elf头信息文件

hello_linkelf

链接之后再经反汇编后的文件

1.4 本章小结

本章大致主要简单介绍了 hello 的 P2P,O2O 过程,列出了本次实验信息:环境、中间结果,大致简介了hello程序从C程序hello.c到可执行目标文件hello的大致经过的历程,并列出来实验过程中的各个文件及其作用。

第2章 预处理

2.1 预处理的概念与作用

预处理概念:

预处理器(cpp)根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,读取头文件的内容,并将其字节插入程序文本中,结果会得到另一个C程序,通常是以.i作为文件拓展名。

预处理作用:

(1)处理头文件包含指令:将源文件中用#include形式声明的头文件内容复制到程序中。

(2)处理宏定义指令:用实际值替换#define定义的字符串。

(3)处理条件编译指令:根据条件编译指令#if、#ifdef等来决定需要编译那些代码,并删除不需要编译的代码。

(4)处理特殊符号:预编译程序可以识别一些特殊的符号,预编译程序对于在源程序中出现的这些串将用合适的值进行替换。

(5)去除注释。

2.2在Ubuntu下预处理的命令

预处理命令:gcc hello.c -E -o hello.i

图2.2-1 预处理结果截图

2.3 Hello的预处理结果解析

使用文本编辑器查看hello.i文件的内容:

图2.3-1 hello.i末尾内容截图

会发现在hello.i的最后部分,main函数依然保持hello.c文件中的形式,同时注释被去除。

而在前面的三千多行代码中,对原文件中的宏进行了宏展开,头文件中的内容被包含进该文件中。例如声明函数、定义结构体、定义变量、定义宏等内容。另外,如果代码中有#define命令还会对相应的符号进行替换。

以下格式自行编排,编辑时删除

2.4 本章小结

本章主要介绍了预处理的概念和预处理的功能,以及Ubuntu下预处理的命令,并对预处理结果hello.i进行了分析,发现预处理会进行头文件的展开、宏替换、清除注释、条件编译等操作

第3章 编译

3.1 编译的概念与作用

编译的概念:

编译就是编译器通过词法和语法分析,将代码指令转换为等价的中间代码或者汇编代码,将.i文件翻译成.s文件,它包含了一个汇编语言程序。

编译的作用:

编译后生成的.s文本文件是汇编语言程序,更容易让计算机理解,编译是将程序转换为机器可直接识别处理执行的机器指令的中间过程。

编译过程中的操作包括词法分析、语法分析、语义检查与中间代码生成、中间代码优化、目标代码生成几个部分。

许多汇编程序为程序开发、汇编控制、辅助调试提供了额外的支持机制。有的汇编语言编程工具经常会提供宏,它们也被称为宏汇编器。

3.2 在Ubuntu下编译的命令

编译命令:gcc -S hello.i -o hello.s

图3.2-1 编译结果截图

3.3 Hello的编译结果解析

使用文本编辑器查看hello.s的内容:

图3.3-1 hello.s内容截图1

图3.3-2 hello.s内容截图2

分析汇编代码的内容,

3.3.1 数据

1.字符串

在hello.c文件内出现了两个字符串,它们都出现在只读数据段内:

图3.3.1-1 字符串截图

同时在代码段内发现对这两个字符串的使用:

图3.3.1-2 字符串使用截图

2.变量

包括局部变量i和函数的参数argc

图3.3.1-3 局部变量i出现部分截图

main函数声明了一个局部变量i,将其放在堆栈中,地址为 -4(%rbp);

图3.3.1-4 argc出现部分截图

传入的参数argc则存放在寄存器%edi之中

3.3.2 赋值操作

在源程序的for循环中,对i进行了一次赋值操作,将其赋值为0,这与汇编代码一致:

图3.3.2-1 对i赋值为0

3.3.3 算数操作

源程序中的算术操作只有i++一处,使用addl来实现:

图3.3.3-1 i++操作

3.3.4 关系操作

源程序中有argc!=4和i<8两处关系操作,对应汇编代码如下:

图3.3.4-1 关系操作argc!=4

图3.3.4-2  关系操作i<8

通过两处cmpl指令进行条件的判断,来决定是否跳转至指定的分支代码之中。

3.3.5 控制转移

在源程序的if与for语句之中,出现了两次控制转移操作:

图3.3.5-1 控制转移-if

判断argc的值决定是否跳转至if语句内的代码:

图3.3.5-2 控制转移-for

判断i的值来决定是否继续for循环;

3.3.6 函数操作

(1).print函数

图3.3.6-1 函数操作-print

(2).exit

图3.3.6-2 函数操作-for

(3). sleep函数

图3.3.6-3 函数操作-sleep

(4).getchar函数

图3.3.6-4 函数操作-getchar

(5).atoi函数

图3.3.6-5 函数操作-atoi

(6).main函数

图3.3.6-6 函数操作-main

3.3.7 类型转换

hello.c中出现过一次类型转换:atoi,将字符串类型转换为整数类型;

(对应汇编代码见图3.3.6-5)

3.3.8 数组/指针/结构操作

    图3.3.8-1 数组汇编代码

hello.c中数组argv[]作为main函数的参数,由char *argv[],可知数组的每个元素都是一个指向字符类型的指针。数组的起始地址存放在栈中-32(%rbp)的位置

3.4 本章小结

本章主要介绍了编译的概念与作用,以及在Ubuntu下编译的指令,并对hello.c源文件和其编译文件hello.s进行了分析,通过对照源代码与汇编代码之间的联系与对应关系,以及通过对其中的各类数据和各项操作操作进行分析,加深了对于计算机执行代码的方式与行为逻辑,同时再一次熟悉了汇编代码的相关知识。

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:

汇编器(as)将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。

汇编的作用:

汇编就是将高级语言转化为机器可直接识别执行的代码文件的过程,汇编器将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位 目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它 包含程序的指令编码。

4.2 在Ubuntu下汇编的命令

汇编命令:gcc hello.s -c -o hello.o

图4.2-1 汇编结果截图

4.3 可重定位目标elf格式

使用readelf -a hello.o > hello_elf命令得到存有elf对应信息的文件hello_elf:

图4.3-1 生成elf信息文件

使用文本编辑器查看其内容:

图4.3-2 ELF头内容

ELF 头:以 16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。

图4.3-3 节头内容

节头部表,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。

图4.3-3 重定位节内容

链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,及代码段和数据段中那些对绝对地址引用的位置。这些重定位的信息都记录在ELF文件的重定位表里面,对于每个需要重定位的代码段或数据段,都会有相应的重定位表。

重定位节:一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。

包括需要被重定位的代码在段中的偏移,代码对应的符号在符号表中的索引以及重定位类型以及在重定位时用到的加数。

图4.3-2 符号表内容

符号表:存放程序中定义和引用的函数和全局变量的信息;

name是字符串表中的字节偏移。

value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。

size是目标的大小,type要么是数据要么是函数。

Bind字段表明符号是本地的还是全局的。

4.4 Hello.o的结果解析

使用objdump -d -r hello.o查看hello.o的反汇编代码,并与第3章的 hello.s进行对照分析。

图4.3-2 反汇编代码内容

说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。

相比hello.s仅有汇编代码,hello.o的反汇编还有机器级指令,且和汇编代码一一对应。机器级指令由操作码和操作数构成,分别对应汇编语句中的操作指令以及操作数。

机器指令由操作码和操作数构成,汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的语言。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系,因此可以将汇编语言转化为机器语言,通过对机器代码的分析可以看出一下不同的地方。

分支转移:在hello.s中,跳转指令的操作对象是.L2等段名称,但在hello.o的反汇编代码中,跳转指令后是相对偏移地址。

函数调用:在hello.s中,函数调用直接用call+函数名表示,但在hello.o的反汇编代码中,call后跟的是所调用的函数相对调用之的函数的起始位置的相对地址。

hello.s中的立即数均是十进制表示,而hello.o的反汇编代码中立即数均为十六进制表示。

4.5 本章小结

本章对汇编的相关概念以及汇编后的文件进行了分析,并分析了ELF头、节头部表、重定位节以及符号表。比较了hello.s和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.2-1 链接结果截图

5.3 可执行目标文件hello的格式

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

使用readelf -a hello > hello_linkelf命令得到存有elf对应信息的文件hello_linkelf:

图5.3-1 生成elf信息文件截图

使用文本编辑器打开文件,分析文件内容:

图5.3-2 ELF头截图

ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。

图5.3-3 节头截图1

图5.3-4 节头截图2

节头部表描述了各个节的相关信息。

图5.3-5 程序头截图

图5.3-6 重定位节截图

图5.3-7 符号表截图

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

   图5.4-1 edb运行截图

在Memory Regions选择View in Dump可以分别在Data Dump中看到只读内存段和读写内存段的信息。

图5.4-2 hello查看虚拟地址

图5.4-3 hello虚拟地址开始部分

图5.4-4 hello虚拟地址结束部分

    观察edb的Data Dump窗口。窗口显示虚拟地址由0x400000开始,到0x400fff结束,这之间的每一个节的每一个节头表的声明。观察edb的Sympols窗口,会发现虚拟地址从0x400000开始是和节头表一一对应的。

查看 ELF 格式文件中的程序头,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息,包含PHDR,INTERP,LOAD ,DYNAMIC,NOTE ,GNU_PROPERTY,GNU_STACK,GNU_RELRO几个部分。

图5.4-5 hello程序头部分截图

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

使用命令:objdump -d -r hello > hello_linkdisas,得到链接后的反汇编文件。

图5.5-1 hello反汇编文件生成截图

使用文本编辑器查看其内容

图5.5-2 hello反汇编文件内容截图

通过对比,发现hello与hello.o之间存在一些不同之处。hello反汇编的代码有确定的虚拟地址,也就是说已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0,未完成可重定位的过程。

hello反汇编的代码中多了很多的节以及很多函数的汇编代码,如hello.c源文件所用到的库函数(如printf、getchar、atoi、exit等)的具体实现。和.init、.text、.fini等节的内容。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

首先进行重定位节和符号定义,链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。之后再重定位节中的符号引用。连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。然后重定位条目,当编译器遇到对最终位置未知的目标引用时,会生成重定位条目。

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。

图5.6-1 hello运行调试截图

请列出其调用与跳转的各个子程序名或程序地址。

0000000000401000 <_init>

0000000000401020 <.plt>

0000000000401090

00000000004010a0

00000000004010b0

00000000004010c0

00000000004010d0

00000000004010e0

00000000004010f0 <_start>

000000000040111e<__libc_start_main@GLIBC_2.2.5>

0000000000401120<_dl_relocate_static_pie>

0000000000401125

00000000004011c0 <__libc_csu_init>

0000000000401230 <__libc_csu_fini>

0000000000401238 <_fini>

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。

动态链接,在可执行文件装载时或运行时,由操作系统的装载程序加载库。大多数操作系统将解析外部引用作为加载过程的一部分。在这些系统上,可执行文件包含一个叫做的表,该表的每一项包含一个库的名字。根据表中记录的名字,装载程序在硬盘上搜索需要的库,然后将其加载到内存中预先不确定的位置,之后根据加载库后确定的库的地址更新可执行程序。

程序调用一个由共享库定义的函数。编译器无法预测这个函数运行时的地址,因为定义它的共享模块在运行时可以加载到任意位置,故GNU使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程。这样第一次调用的过程中运行时开销很大,但其后的每次调用只会花费一条指令和一个简洁的内存引用。

延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。

GOT中,每个被目标模块引用的全局数据目标(过程或全局变量)都有一个8字节条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的GOT。

PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

会发现一些值被赋值,而其内容与被执行函数的地址一致。可以看出,在dl_init前, PIC函数调用的目标地址都实际指向PLT中的代码逻辑,初始时每个GOT条目都指向对应的PLT条目的第二条指令。在dl_init后,部分数据信息发生变动。

5.8 本章小结

本章介绍了链接的概念、作用以及链接命令等内。通过对hello.o进行链接得到可执行目标文件hello,分析了hello的ELF格式,并对动态链接的实现方式进行分析,了解了hello的虚拟地址空间、重定位过程、执行流程和动态链接等内容。

6章 hello进程管理

6.1 进程的概念与作用

进程的概念:

进程是一个执行中程序的实例。进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。

进程的作用:

进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存;处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。

6.2 简述壳Shell-bash的作用与处理流程

Shell概念:

Shell是一个交互型应用级程序,代表用户运行其他程序。它是系统的用户界面,提供了用户与内核进行交互操作的一种接口,它接收用户输入的命令并把它送入内核去执行。

功能:

Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核,在交互方式下解释从命令行输入的命令。可以制定用户环境,用于设置终端键和窗口属性,设置用来定义搜索路径、权限、提示符和终端类型的变量,设置特定应用程式所需的变量,如窗口、字处理程式和编程语言的库等。可以用作解释性的编程语言。

处理流程:

Shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序。然后shell在搜索路径里寻找这些应用程序。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。

6.3 Hello的fork进程创建过程

终端通过调用fork函数创建一个子进程,子进程得到与父进程用户级虚拟地址空间完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。其函数原型为pid_t fork(void);对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流中的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。

由于shell执行hello时,输入的字符串并不是系统内部指令,所以shell会fork一个子进程来执行命令,因为hello是可执行目标文件,所以shell可以执行该程序。子进程和父进程不同在PID,fork函数调用的时候会返回两次,对子进程fork返回0,父进程返回子进程PID。

6.4 Hello的execve过程

当创建了一个子进程之后,子进程调用execve函数在当前子进程的上下文加载并运行hello程序,加载并运行需要以下几个步骤:

(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。

(2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。如图6.4

(3)映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。

(4)设置程序计数器。execve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

进程时间片:一个进程执行他的控制流的一部分的每一个时间段叫做时间片,多任务也叫时间分片。

进程上下文切换:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被强占的进程。在内核调度了一个新的进程运行时,它就抢占当前进程,并使用一种上下文切换的机制来控制转移到新的进程。其主要过程主要有:保存当前进程的上下文、恢复某个先前被强占的进程被保存的上下文、将控制传递给这个新恢复的进程。

图6.5-1 上下文切换示意图

具体的用户态核心态转换:进程hello初始运行在用户模式中,直到它通过执行系统调用函数sleep或者exit时便陷入到内核。内核中的处理程序完成对系统函数的调用。之后,执行上下文切换,将控制返回给进程hello系统调用之后的那条语句。

6.6 hello的异常与信号处理

以下格式自行编排,编辑时删除

 hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

异常可以分为四类:中断、陷阱、故障、终止,如下:

图6.6-1 上下文切换示意图

    中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。

陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep       函数的时候会出现这个异常。

故障:在执行hello程序的时候,可能会发生缺页故障。

终止:不可恢复的错误造成的结果,通常一些硬件上的错误。会终止整个应用程序。

 程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps  jobs  pstree  fg  kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。

不停乱按:

图6.6-2

ps和jobs指令:

图6.6-3

Pstree指令:

图6.6-4

Fg指令:

图6.6-5

Kill指令:

图6.6-6

Kill对应的进程,并再次用ps命令查看当前进程,会发现对应的进程已经被杀死。

6.7本章小结

本章介绍了进程的相关概念和作用以及shell的处理流程及其作用,并且着分析了调用 fork 创建新进程,调用 execve函数执行 hello,hello的进程执行,以及hello的异常与信号处理等内容,并实际运行hello来分析异常与信号的处理。

7章 hello的存储管理

7.1 hello的存储器地址空间

结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。

逻辑地址:

程序经过编译后出现在汇编代码中的地址,即机器指令之中的地址,是由程序产生的与段相关的偏移地址。

线性地址:

线性地址是逻辑地址到物理地址变换之间的中间层。逻辑地址加上相应段的基地址就生成了一个线性地址。

虚拟地址:

逻辑地址也称为虚拟地址。因为与虚拟内存空间的概念类似,逻辑地址也是与实际物理内存容量无关的,是程序中的虚拟地址。

物理地址:

物理地址是指出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。物理地址可以用于直接访问主存中的数据。

7.2 Intel逻辑地址到线性地址的变换-段式管理

图7.2-1 Intel地址翻译示意图

一个逻辑地址由两部份组成:段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。

索引号,即数组下标,对应段描述符数组,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。

一些全局的段描述符,放在全局段描述符表中;一些局部的,例如每个进程自己的,放在局部段描述符表中。在段选择符中的T1字段,T1=0,表示用GDT,T1=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。

具体到实际的地址翻译过程,首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。然后拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样就可以得到Base,即基地址。最后将Base + offset,即得到我们要转换的线性地址了。

7.3 Hello的线性地址到物理地址的变换-页式管理

计算机利用页表,通过MMU来完成从虚拟地址到物理地址的转换。使用虚拟寻址,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址被送到内存之前首先转换为适当的物理地址。将一个虚拟地址转换为物理地址叫做地址翻译,需要CPU硬件和操作系统之间的紧密合作。内存管理单元MMU利用主存中的查询表来动态翻译虚拟地址。

虚拟地址作为到磁盘上存放字节的数组的索引,磁盘上的数组内容被缓存在主存中。同时,磁盘上的数据被分割成块,这些块作为磁盘和主存之间的传送单元。虚拟内存分割被成为虚拟页。物理内存被分割为物理页,物理页和虚拟页的大小时相同的。

任意时刻虚拟页都被分为三个不相交的子集,分别是未分配的,即VM系统还未分配的页;缓存的,当前已经缓存在物理内存的已分配页;未缓存的,当前未缓存在物理内存的已分配页。

每次将虚拟地址转换为物理地址,都会查询页表来判断一个虚拟页是否缓存在DRAM的某个地方,如果不在DRAM的某个地方,通过查询页表条目可以知道虚拟页在磁盘的位置。页表将虚拟页映射到物理页。

页表就是一个页表条目的数组,每一个页表条目是由一个有效位和一个n为地址字段组成。有效位表明虚拟页是否缓存在DRAM中,n位地址字段是物理页的起始地址或者虚拟页在次胖的起始地址。

图7.3-1 Intel地址变换页式管理示意图

虚拟地址被分为虚拟页号与虚拟页偏移量,CPU取出虚拟页号,通过页表基址寄存器定位页表条目,从页表条目中取出信息物理页号,将物理页号与虚拟页号之间建立映射,进而完成线性地址到物理地址的变换。如果虚拟页是未缓存的,就会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。

7.4 TLB与四级页表支持下的VA到PA的变换

每次CPU产生一个虚拟地址,MMU就必须查阅一个PTE,以便将虚拟地址翻译为物理地址。在最糟糕的情况下,会要求从内存多取一次数据,代价是几十到几百个周期。许多系统试图消除这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器(TLB)

TLB利用VPN进行寻址,分为组索引(TLBI)和标记(TLBT),用来区别可能映射到同一个TLB组的不同的VPN。

图7.4-1 TLB虚拟地址中用于访问TLB的组成部分

图7.4-2 TLB命中与不命中操作示意图

现在的64位计算机采用4级页表,CPU产生虚拟地址48位VA,其中12位VPO,36位VPN,如果TLB不命中需要在页表中寻址。因为是四级页表,36/4 = 9,即每9bit作为每一级页表的索引,直到最后一级页表找到符合的PPN,作为物理地址PA的物理页号,而PPO和一级页表情况一样,也是直接由VPO复制过来。

7.5 三级Cache支持下的物理内存访问

对于一个虚拟地址请求,首先将去TLB寻找,看是否已经在TLB中缓存。首先根据地址值中的组索引位,将二进制组索引转化为一个无符号整数,找到相应的组。行匹配将虚拟地址的标记位与相应的组中所有行的标记位进行比较,当两者匹配且高速缓存行的有效位是1时,则高速缓存命中。缓存命中后,直接从MMU中获取,根据块偏移位得出偏移量,找到第一个字节,把这个字节的内容取出传给CPU。若缓存不命中,则需要从存储层次结构中的下一层,即二级cache取出被请求的块,然后将新的块存储在组索引位所指示的组中的一个高速缓存行中。如果组内有空闲块,则直接放置;否则可以采用LFU策略。缓存不命中时需要从下一层中取出请求块,直到缓存命中。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,并为这个新进程创建虚拟内存。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

图7.6-1 共享对象示意图

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。

7.7 hello进程execve时的内存映射

Linux通过将虚拟内存区域与磁盘上的对象关联起来以初始化这个虚拟内存区域的内容。这个过程称为内存映射。

虚拟内存和内存映射解释了fork函数如何为每个新进程提供私有的虚拟地址空间。

当hello进程调用fork函数时,内核为新进程创建当前进程的mm_struct,vm_area_struct和页表的原始样本,将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。

当fork在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。

execve 函数在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用hello程序有效地替代了当前程序。

加载并运行hello需要以下几个步骤:

(1).删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。

(2).映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新区域都是私有的、写时复制的。代码和数据区域被映射为 hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。

(3).映射共享区域。hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。

(4).设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

图7.7-1 加载器映射用户地址空间的区域

7.8 缺页故障与缺页中断处理

缺页故障:当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面 到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。

图7.8-1 缺页中断处理流程图

当发生缺页时,处理流程如下:

(1).处理器生成虚拟地址,传送给MMU

(2).MMU翻译出PTE地址,并在高速缓存中查找PTE

(3).高速缓存将PTE返还给MMU

(4).PTE中有效位是0,引发缺页异常,调用缺页异常处理程序

(5).该程序选择一个牺牲页把它换到磁盘

(6).缺页处理程序页面调入新的页面,并更新内存中的PTE

(7).缺页处理程序返回到原来的进程,再次执行导致缺页的命令。

7.9动态存储分配管理

动态内存分配器维护者一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但是不失通用性,假设对是一个请求二进制的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。

分配器有两种基本风格。两种风格都是要求显示的释放分配块。

(1)  显式分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。

(2)  隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。

对于显式分配器的方法,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

图7.9-1 使用双向空闲链表的堆块格式

对于隐式分配器的方法,一个块是由一个字的头部、有效载荷,以及可能的一些额外的天重组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。如果我们独加一个双子的对齐约束条件,那么块大小就总是8的倍数,且块大小最低3位总是零。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。

图7.9-2 典型的堆块格式

7.10本章小结

本章主要介绍了存储器地址空间的相关概念,分析了逻辑地址、线性地址、虚拟地址和物理地址四种地址空间概念的差别,结合hello程序讨论了不同地址之间的相互转换的方法,对cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容进行了介绍与分析。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

所有的I/O设备都被模型化为文件,甚至内核也被映射为文件。

设备管理:unix io接口

这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,使得所有的输入和输出都被当做相应文件的读和写来执行。

我们可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek。

8.2 简述Unix IO接口及其函数

Unix IO接口:

(1).打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。

(2).Shell 创建的每个进程都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。

(3).改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位 置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置 k。

(4).读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文件位置k开始,然后将k增加到 k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制 n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。

(5).关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。

Unix IO 函数:

(1).int open(char* filename,int flags,mode_t mode)

进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。

(2).int close(fd)

fd是需要关闭的文件的描述符,close返回操作结果。用于关闭一个被打开的的文件。

(3).ssize_t read(int fd,void *buf,size_t n)

read 函数从描述符为 fd 的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0 表示EOF,否则返回值表示的是实际传送的字节数量。

(4).ssize_t wirte(int fd,const void *buf,size_t n)

write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。

8.3 printf的实现分析

从给出的网站中查看printf函数的函数体,如下:

int printf(const char *fmt, ...)

{

int i;

char buf[256];

    va_list arg = (va_list)((char*)(&fmt) + 4);

    i = vsprintf(buf, fmt, arg);

    write(buf, i);

  return i;

}

会发现其中调用了vsprintf和write函数。vsprintf的作用是接受确定输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出并赋值给buf,最后返回输出字符串的长度。而write函数将栈中参数放入寄存器,ecx存放字符个数,ebx存放字符串首地址,最后调用sys_call。

然后再运行字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

最后就是将对应的内容显示再屏幕上:显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

查找getchar相关内容,查看到getchar源码如下:

int getchar(void)

{

       static char buf[BUFSIZ];

       static char *bb = buf;

       static int n = 0;

       if(n == 0)

       {

        n = read(0, buf, BUFSIZ);

        bb = buf;

       }

       return(--n >= 0)?(unsigned char) *bb++ : EOF;

}

异步异常-键盘中断的处理:当用户按下按键输入信息时,键盘接口会得到一个对应于该按键的按键扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先通过键盘接口得到按键扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中,最后通知中断控制器本次中断结束并实现中断返回。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章主要介绍了Linux的IO设备管理办法,Unix IO接口及其函数,并对printf和getchar的具体实现进行了分析。

结论

用计算机系统的语言,逐条总结hello所经历的过程:

1.hello.c源文件经过预处理,得到hello.i文本文件。

2.hello.i经过编译,得到hello.s汇编文件,其中为hello程序的汇编代码实现。

3.hello.s经过汇编,得到二进制可重定位目标文件hello.o。

4.hello.o经过链接,最终生成了可执行文件hello。

5.在shell键入运行hello程序的命令,hello程序正式开始运行。

6.shell调用fork函数,为hello生成子进程。再调用execve函数,加载运行当前进程的上下文中加载并运行新程序hello。

7.hello通过MMU将虚拟地址转换为物理地址,对内存中的对应内容进行访问。

8.hello运行过程中会产生各种异常和信号,系统会针对出现的异常和收到的信号执行异常处理程序和信号处理程序。

9.最终当hello进程执行完毕,返回信号给shell,父进程回收子进程,删除该进程创建的所有数据结构,hello的一生最终结束。

感悟:

这门课一路学下来,虽然感觉内容非常多,但也确确实实地加深了我对于计算机的理解,但同时也还有许多不熟悉和不理解的地方。

一个简单的hello程序,在它在计算机中运行的过程中,就历经了各种各样的过程,通过对这些过程的跟踪与分析,也是让我再一次对整个计算机系统的体系与知识有了更加深刻的理解,相信这在今后的学习中也将会给我莫大的帮助。

附件

列出所有的中间产物的文件名,并予以说明起作用。

文件名

文件的作用

hello.i

hello.c预处理之后文本文件

hello.s

hello.i编译后的汇编文件

hello.o

hello.s汇编之后的可重定位目标文件

hello

链接之后的可执行目标文件

hello_linkdisas

hello反汇编之后的可重定位文件

hello_disas

hello的反汇编后的文件

hello_elf

hello的elf头信息文件

hello_linkelf

链接之后再经反汇编后的文件

参考文献

[1]  深入理解计算机系统原书第3版

[2]  https://www.cnblogs.com/pianist/p/3315801.html

[3]  https://blog.csdn.net/zhy557/article/details/80832268

[4]  https://segmentfault.com/a/1190000020792977

[5]  https://blog.csdn.net/weixin_42781851/article/details/89289049

[6]  https://blog.csdn.net/Apuls1/article/details/105735980/

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
哈尔滨工业大学(Harbin Institute of Technology,简称“哈工大”)是中国著名的重点大学,成立于1920年,是中国最早创办的六所工科高等学府之一。其中,哈尔滨工业大学的计算机科学与技术学院一直以来都是国内知名的学院。在其中,CSAPP是哈工大计算机科学与技术学院开设的一门经典课程,全称为《深入理解计算机系统》(Computer Systems: A Programmer's Perspective)。 这门课程涵盖了计算机系统的各个方面,从高级语言编程到机器级别的细节都有涉及,深入剖析了计算机系统的内部机制,讲解了各种计算机组件的原理,如内存、处理器、I/O设备、网络等等。此外,课程内容还包括缓存、异常、程序优化、并发编程、虚拟内存等重要主题,并且还会涉及安全问题,例如注入攻击、缓冲区溢出等等。 相较于其他计算机相关的课程而言,CSAPP的特殊之处在于,它以程序员的视角,深入而生动地解释了计算机系统的工作方式和内部机制。课程强调了实践性,通过大量的例子及编程作业,学生可以实际操作并理解到具体的计算机系统的运行方式。 此外,CSAPP的教学团队非常强大,由哈工大的多位顶尖教授组成,能够保证教学质量和深度。学生通过学习这门课程,不仅可以深入了解计算机系统的各个方面,还可以提高编程能力和工程实践水平,有助于更好地应对工作中遇到的各种问题。 总之,CSAPP是哈尔滨工业大学计算机科学与技术学院开设的一门经典课程,其全面而深入的课程内容、强调实践性、优秀的教学团队等特色让其在国内享有较高声誉,对学生深入理解计算机系统、提高编程实践能力等方面,都有非常积极的作用。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值