哈尔滨工业大学 计算机系统大作业

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业    计算机科学与技术    

学     号       2022113586       

班     级        2203103         

学       生       大菜狗        

指 导 教 师         史先俊         

计算机科学与技术学院

2023年4月

摘  要

    hello是绝大多数程序员所编写的第一段代码,是编程学习中最为简单最为基础的一个程序。而从计算机系统的角度看,一个hello程序从编写完成经过预处理、编译、汇编、链接等过程形成可执行文件,再将可执行文件加载进内存并运行的过程融汇了计算机系统设计的方方面面。本文从一个简单的hello.c程序开始,介绍了hello程序再Linux系统下运行的完整生命周期,包括了P2P(From program to process)和020(From zero to zero)两个过程,通过hello.c的完整周期,可以进而窥探其它程序的生命历程,进而领略计算机系统设计的巧妙。

关键词:Linux系统;计算机系统;P2P;020;程序的生命历程                           

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

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

1.3 中间结果... - 4 -

1.4 本章小结... - 4 -

第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的结果解析... - 19 -

4.5 本章小结... - 21 -

第5章 链接... - 22 -

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

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

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

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

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

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

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

5.8 本章小结... - 33 -

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

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

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

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

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

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

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

6.7本章小结... - 37 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 41 -

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

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

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

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

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

8.5本章小结... - 46 -

结论... - 46 -

附件... - 48 -

参考文献... - 49 -

第1章 概述

1.1 Hello简介

P2P过程,即From Program to Process,指的是一个程序从高级语言程序转变为进程的过程。在这个过程中,Hello始于hello.c,经历预处理、编译、汇编、链接四个阶段转变为可执行程序,随后将可执行程序hello通过shell加载到内存中,建立进程,完成P2P的过程。在预处理阶段,hello.c首先通过预处理器生成修改过后的C程序,该过程中预处理器根据头文件相关引用,读取系统头文件的内容,将其插入到文本中,得到hello.i文件;在编译阶段,编译器将hello.i翻译为汇编语言程序,形成文本文件hello.s;在汇编阶段,汇编器将hello.s翻译成为机器语言指令,并将这些指令打包,形成可重定位目标程序的形式,并将结果存在hello.o中;在链接阶段,连接器将程序中使用过的库函数所在目标文件与hello.o进行合并,得到一个可执行文件。最终shell调用fork函数和execve函数,创建进程,在进程上下文中运行这个程序,完成加载入内存并运行的任务。

020过程,即From Zero to Zero,指的是一个程序在运行过程中从无到终得过程。在这个过程中,shell首先调用fork函数为程序创建进程,随后调用execve函数在进程上下文中加载运行hello程序,从而得到了虚拟地址空间,存储了通用目的寄存器、程序计数器、用户栈、内核栈及各种内核数据结构等信息,至此实现了从无到有的过程。程序在运行过程中经历大量异常和信号,对存储器进行读写访问,与外设进行交互,程序运行结束后,父进程回收子进程,释放虚拟地址空间,删除相关内容,实现从有到终的过程。

1.2 环境与工具

硬件环境:Intel i7 10750H CPU @ 2.60GHz 2.59GHz;16G RAM;256GHD Disk

软件环境:Windows11 64位;VMware Workstation Pro 16;Ubuntu 20.04

开发和调试工具:gdb;edb;readelf;objdump;vim

1.3 中间结果

Hello.c——源程序

Hello.i——预处理后得到的变化的文本文件

Hello.s——汇编语言的汇编文件

Hello.o——可重定位目标执行文件

Hello——可执行文件

Hello1——加上了-g选项编译得到的可执行文件,便于edb反汇编得到调试信息

1.4 本章小结

       本章根据hello的自白,简要介绍了hello的P2P和020过程,并罗列出了实验所需要的软硬件开发环境和开发调试工具,并罗列了实验中所得到的中间结果文件及其作用。

第2章 预处理

2.1 预处理的概念与作用

预处理是指预处理器(cpp)依据以#开头的命令,对高级语言程序进行修改,得到修改后的源程序文本的过程。

预处理阶段包含了以下几个操作:头文件展开、去注释、宏替换、条件编译等。头文件展开过程中,预处理器读取头文件内容,并将其直接插入程序文本中;去注释过程中,预处理器将程序的注释部分全部替换成空格;宏替换过程中,预处理器将程序所使用的宏定义在源代码出现的任何位置都进行替换;而在对条件编译的处理过程,预处理器会对使用条件编译的代码块进行条件判断,若条件满足则保留,否则将代码裁剪,使其不在后续阶段被编译。[1]

2.2在Ubuntu下预处理的命令

       在Ubuntu中打开终端,使用cpp hello.c > hello.i命令对高级语言程序hello.c进行预处理,得到修改过后的程序文本hello.i

图2.2-1 Ubuntu下预处理命令及其结果

2.3 Hello的预处理结果解析

从图2.3.1中可以得到,hello.c程序在预处理阶段主要需要进行的操作有两个:一是去注释过程,去掉hello.c中一至三行的注释;二是头文件展开,hello.c中引用了三个头文件,分别是stdio.h、unisted.h和stdlib.h,预处理阶段将其直接插入到高级语言程序中。

图2.3-1 hello.c源程序

从下图2.3.2中可以得到,只有28行的源程序在插入头文件之后得达到了3096行,其中最后部分为源程序原本的内容,其中已经删去了全部的注释和头文件引用的相关内容。

图2.3-2  hello.i部分内容

2.4 本章小结

本章主要介绍了高级语言程序在翻译称为可执行目标文件的四个阶段中的第一个阶段——预处理,介绍了预处理的概念及主要作用,并在Ubuntu系统中对hello.c调用预处理命令进行了预处理操作,得到了hello.i文件,经过比对,与预先依据理论进行分析得到的结果相同。

第3章 编译

3.1 编译的概念与作用

编译阶段指编译器(ccl)将文本文件hello.i翻译称为文本文件hello.s的过程,hello.s中包含了一个汇编语言程序。

编译阶段的主要作用就是要将高级程序语言翻译为汇编语言。汇编语言的每条语句都以一种文本格式描述了一条低级机器语言指令,汇编语言比高级程序语言更加接近计算机所能够执行的机器语言。同时,汇编语言能够为不同高级程序语言的不同编译器提供通用的通用的输出语言,进而有助于后续将不同的高级程序语言翻译成同种形式的机器语言指令。

3.2 在Ubuntu下编译的命令

在Ubuntu中打开终端,使用gcc -m64 -Og -no-pie -fno-stack-protector -fno-PIC hello.i -S -o hello.s命令,对hello.i进行编译操作,从而得到汇编语言程序hello.s。   

其中-m64 -Og -no-pie -fno-stack-protector -fno-PIC是hello.c文件中要求的编译选项,而-S是实际上的编译操作需要的选项,如果不考虑hello.c文件要求的,只需要使用gcc hello.i -S -o hello.s命令即可。

图3.2-1 Ubuntu下编译命令

3.3 Hello的编译结果解析

3.3.1 伪指令部分

图3.3-1  伪指令部分

图3.3.1是汇编程序开头的伪指令,这些伪指令大多以‘.’开头,记录文件相关信息,主要用于直到后续汇编器和链接器的工作。.file表明了源文件名称,.text意为代码段,.section .rodata意为只读代码段,.align说明了对齐方式为8字节对齐,.string为程序用到的字符串,.global为全局变量,.type说明了main是一个函数。

3.3.2 对数据类型的操作

1.对局部变量的操作

hello中涉及局部变量操作的地方主要是源程序中的第10行int i;。

图3.3-2  源程序中局部变量声明

    这一句中在hello.s中对应的语句主要是如图3.3-3中红框标出的语句。在这一句中,栈顶指针rep向上移动八个字节,在栈中为局部变量i保留了空间。

图3.3-3  汇编程序中局部变量保留空间

之后对局部变量i的操作就是集中在for循环中“for(i = 0 ; i < 8; i ++)”这一句当中。由于增加了Og选项,汇编程序牺牲了一定的可读性换取了性能上的一定提升。在本汇编程序的体现就是,在将rbp压栈之后,不将rsp的值赋给rbp,而是将rbp(ebp)也作为一个寄存器正常使用。在本程序中,先将ebp赋为0,之后在循环过程中与7做小于等于的比较,如果rbp的值小于等于7,则会跳转到.L3,而在.L3部分汇编程序中,会对i++,这也就对应了“for ( i = 0 ; i < 8; i ++)”的三个操作。

图3.3-4  局部变量操作

2.对数组的操作

       在hello.c程序中,主要进行操作的数组就是命令行参数数组,具体的使用则是出现在for循环的内部的输出语句中。在hello.c程序中,命令行参数数组作为第二个main函数的参数,其首地址在传参过程中,存在于rsi寄存器中。在汇编程序中,在将rbx压栈之后,将rsi的内容赋给了rbx,则rbx得到了数组的首地址。for循环内部的输出语句是“printf(“Hello %s %s\n”,argv[1],argv[2]);”,其中每次循环输出的是数组的第一个和第二个元素,则对应的就是内存中rbx + 8 和rbx + 16所存储的内容。由于输出调用了printf函数,所以需要将这两个内容作为参数进行传递,那么就将rbx + 16(argv[2])赋给rdx,将rbx + 8 (argv[1])赋给rsi,作为参数传递给printf函数。

图3.3-5  数组相关操作

3.字符串常量

       在hello.c中设计的字符串常量主要就是printf函数总的参数内容。在汇编程序中,字符串常量是事先被存储到.rodata中的,在printf函数实际需要输出时,则对应将其先赋值给rdi(edi),然后通过传参机制将其传递给printf函数(在Og选项下,单独输出字符串的printf函数被优化成了puts函数),随后完成了字符串的输出。

图3.3-6  字符串相关操作

3.3.3 赋值操作

       赋值操作的实现在汇编语言中主要是通过mov指令实现的。如下图红框部分,对应的是将i赋值为0的语句

图3.3-7  赋值操作

3.3.4 关系指令与跳转

       hello.c程序中主要用到的关系指令有两个:!= 和 <= ,!=的条件判断使用的是跳转控制指令jne,例如下图,比较4和edi(argc)的大小关系,如果不相等,则跳转到.L6。

图3.3-8  !=的相关操作

       而<=使用的跳转控制指令是jle,例如下图,比较7和ebp(int i)的大小关系,如果ebp的值<= 7,那么跳转到.L3。

图3.3-9  <=的相关操作

       上述两个案例均用于控制转移操作中,分别用于if语句和for语句中,从中也可看出,if语句和for语句的控制转移是通过比较指令cmp和跳转控制指令实现的。

3.3.5 函数调用与参数传递

前面部分曾介绍过部分参数传递的机制,在64位编译模式下,汇编程序的参数传递先借助寄存器,前六个参数使用寄存器rsi、rdi、rdx、rcx、r8、r9,之后的参数在利用栈,将其压入栈中。前面的分析过程中包含了printf函数和main函数的传参机制。

而对于函数调用的整个过程,会依次进行以下操作,先压入返回地址(函数调用所在位置的下一条指令的地址),之后是可能压入的函数参数,之后压入rbp,然后压入可能会在函数中使用到的寄存器,从而保存现场,在调用完函数之后,则会依次将上述内容从栈中弹出。

3.4 本章小结

本章简要介绍了编译过程中,hello.i通过编译器从而被翻译为汇编语言的过程,并详细的依照C语言的各种类型和操作,对汇编语言程序进行了解析。汇编语言相较于高级程序语言,操作计算机内存寄存器等硬件部分的痕迹更为明显,同时具有统一的格式,能够较好的统一不同高级程序语言编写的不同程序形式,进而为接下来的汇编和链接阶段做好准备。

第4章 汇编

4.1 汇编的概念与作用

       汇编是指汇编器(as)将汇编语言程序hello.s翻译为机器语言指令,并将这些指令打包成可重定位目标程序的格式,将结果保存在目标文件hello.o文件中的过程。

       计算机只能识别处理机器指令程序,汇编过程将汇编语言程序翻译为了机器指令,进一步向计算机能够执行操作的形式迈进,便于计算机直接进行分析处理。

4.2 在Ubuntu下汇编的命令

如图4.2-1所示,在Ubuntu中打开终端,使用gcc -c hello.s -o hello.o命令,对hello.s进行汇编操作,从而得到目标文件hello.o

图4.2  Ubuntu下的汇编命令

4.3 可重定位目标elf格式

4.3.1 可重定位目标文件

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

.text:已编译的程序的机器代码。

.rodata:制度数据,比如printf语句中的格式串和开关语句的跳转表。

.data:已初始化的全局变量和静态C变量。

.bss:未初始化的全局变量和静态C变量,以及所有初始化为0的全局变量和静态C变量。在目标文件中这个节并不会占据实际空间,只是一个占位符。目标文件格式区分变量是否初始化主要是为了空间效率:在目标文件中,未初始化变量并不需要占据任何实际的磁盘空间,在运行时再从内存分配变量,初始值为0。

.symtab:符号表,存放了程序中定义和引用的函数和全局变量的信息。

.rel.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,调用外部函数或者是引用全局变量的指令都是需要修改的,而调用本地函数的指令则不需要。

.rel.data:被模块引用或定义的所有全局变量的重定位信息。一般而言,已初始化的全局变量的初始值如果是一个全局变量地址或者是外部定义函数的地址,就都需要修改。

.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才能够得到这张表。

.line:原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才能够得到这张表。

.strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串序列。

图4.3-1  一个典型的ELF可重定位目标文件

4.3.2 hello.o的具体分析

1.在Ubuntu中打开终端,用readelf -S hello.o指令查看hello.o的节头部表,查看每一个节的基本信息。由4.3.1的论述可以得到,节头部表描述了每一个节的位置、大小,除此之外还有访问属性和对齐方式等。

图4.3-2  hello.o的节头部表1

图4.3-3  hello.o的节头部表2

2.用readelf -h指令查看hello.o的ELF头信息

ELF头中存储了生成该文件的系统的字的大小和字节顺序、ELF头的大小、目标文件类型、机器类型节头部表的文件偏移以及节头部表条目的大小和数量。

图4.3-4  ELF头信息

3.使用readelf -s hello.o查看符号表

图4.3-4  符号表信息

符号表存放的可重定位模块m中的三种不同符号分别是m定义并能被其他模块引用的全局符号、由其他模块定义并且被m引用的全局符号和只m定义和引用的局部符号。

在符号表中,name代表的是字符串中的字节偏移,指向符号的以NULL结尾的字符串的名字;value是距定义目标的节的起始位置的偏移(对于可重定位的模块);size是目标的大小(以字节为单位);type要么是数据,要么是函数;符号表还包含各个节的条目以及对应的原始源文件路径名的条目;binding表示符号是本地的还是全局的。

4.使用readelf -r hello.o得到hello.o的重定位信息

       使用上述命令可以得到ELF的重定位条目,重定位条目用于告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目被放在.rel.text中,而初始化数据的重定位条目被放在.rel.data中。

       在hello的分析过程中,没有找到rel.data,说明hello中没有需要重定位的已初始化的数据。

       在重定位条目中,offset表示需要被修改的引用的节偏移;symbol表示被修改引用应该指向的符号;type告知链接器如何修改新的引用;addend是一个有符号的常数,一些类型的重定位使用addend对被修改的引用的值做偏移调整。

       Type有两种类型,R_X86_64_PC32是重定位一个使用32为PC相对地址的引用。这里的PC相对地址就是距程序计数器(PC)的当前运行时值的偏移量。当CPU执行一条PC相对寻址的指令时,它就将指令中编码的32为值加上PC当前运行时值,得到有效地址(如call指令的目标),PC值通常时下一条指令在内存中的地址。R_X86_64_32是重定位一个使用32位绝对地址的引用。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,而不需要进行修改。

图4.3-5  重定位条目信息

5.使用readelf -g hello.o得到节组信息

图4.3-6  节组信息

       从而可以得知,hello.o中没有节组。

4.4 Hello.o的结果解析

使用objdump -d -r hello.o命令,得到对应的反汇编程序,结果如下图所示:

图4.4-1  反汇编程序1

图4.4-2  反汇编程序2

       hello.s的文件内容如下图所示:

图4.4-3  hello.s内容1

图4.4-4  hello.s内容2

经过对比可以得到,汇编指令代码几乎是相同的,反汇编代码再汇编代码的基础之上,还有左侧的机器代码,说明机器代码和汇编指令是一一对应的。跳转指令中,反汇编代码的跳转是对应的地址的跳转,而hello.s中的跳转则是以.Lx命名的代码块为单位的跳转;对于常量的操作数,反汇编代码中主要以十六进制呈现,而hello.s中则主要是十进制的数字。除此之外,helllo.s中有大量的以“.”开头的伪指令,而反汇编代码中则没有。

机器指令主要由操作指令和操作数操作数组成,在映射关系上与汇编指令呈现一一对应的映射关系。

4.5 本章小结

本章简要的介绍了hello.s经过汇编器得到可重定位目标文件hello.o的过程,首先简要的介绍了可重定位目标文件的格式,在此基础上使用readelf命令添加不同选项得到了关于hello.o中ELF的节头表、符号表、可重定位条目等信息,最后通过反汇编程序将hello.o反汇编生成得到汇编语言程序,并于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-g

nu/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指令完成链接,得到可执行程序hello。

图5.2-1  Ubuntu下链接的命令

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

    可执行目标文件的格式类似于可重定位目标文件的格式类似于可重定位目标文件的格式。ELF头描述文件的总体格式。它还包括程序的入口点,也就是程序运行时要执行的第一条指令的地址。.text、.rodata和.data节与可重定位目标文件的节是相似的,除了这些节已经被重定位到它们最终的运行内存izhi以外。.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。因为可执行文件是完全链接的(已被重定位),所以不再需要rel节。

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

图5.3-1  可执行文件ELF结构示意图

1.使用readelf -h hello命令查看hello的ELF头

图5.3-2  hello的ELF头

2.使用readelf -S查看hello的节头部表

图5.3-3  hello的节头部表1

图5.3-4  hello的节头部表2

3.调用readelf -l hello查看hello的程序头表


       ELF可执行文件的连续的片被映射到了连续的内存段,从而很容易被加载到内存中。程序头部表描述了这种映射关系。

图5.3-6  hello的程序头表1

图5.3-7  hello的程序头表2

       Offset代表目标文件中的偏移,VirtAdrr代表虚拟内存的地址,PhysAddr代表物理地址的内存,FileSiz代表目标文件中的段大小,MemSiz代表内存中的段大小,flags代表运行时的访问权限,align代表对齐要求。

5.4 hello的虚拟地址空间                 

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

由下图可以得到,虚拟地址空间的起始位置是0x400000

图5.4-1  hello的虚拟地址空间起始位置

由上图5.3-3可以得知,.interp段的起始地址为0x4002e0,在edb中查找地址得到如下图的结果:

图5.4-1  .interp存储内容

由上图5.3-4可以得到,.text的起始地址为0x4010f0,在edb中查询地址可以得到如下图的结果:

图5.4-2  .text存储内容

由上图5.1-5可以得到,.rodata的起始地址为0x402000,在edb中查询地址可以得到如下图的结果:

图5.4-3  .rodata存储内容

5.5 链接的重定位过程分析

调用objdump -d -r hello命令,得到hello的反汇编程序,如下图结果所示

图5.5-1  hello反汇编1

图5.5-2  hello反汇编2

图5.5-3  hello反汇编3

图5.5-4 hello反汇编4

图5.5-5  hello反汇编5

图5.5-6  hello反汇编6

    

从上图5.5-1到5.5-6并结合图4.4-1到图4.4-2可以得到,hello与hello.o在以下几点由一定差异:

1.虚拟地址不同,hello.o的反汇编代码虚拟地址从0开始,而hello的反汇编代码虚拟地址从0x400000开始。这是因为hello.o在链接之前只能给出相对地址,而hello在链接之后得到的是绝对地址。

2.反汇编节数不同,hello.o只有.text节,里面只有main函数的反汇编代码。而hello在main函数之前加上了链接过程中重定位而加入的各种在hello中被调用的函数、数据,增加了.init,.plt,.plt.sec等节的反汇编代码。

3.跳转指令不同,hello.o中的跳转指令后加的主要是汇编代码块前的标号,而hello中的跳转指令后加的则是具体的地址,但相对地址没有发生变化。

       重定位过程由两步组成,首先是重定位和符号定义,在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节,之后将运行时内存地址赋给新的聚合节,赋给输入模块定义的每一个节以及赋给输入模块定义的每一个符号,这一步完成之后,程序的每条指令就都有唯一的运行时的内存了。第二步时重定位节的符号引用,在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得其指向正确的运行时地址。这一过程依赖于4.3.2中所提到的重定位条目的type,通过重定位PC相对引用和重定位绝对引用两种方式,完成每个符号的引用。

5.6 hello的执行流程

由于先前在编译时没有加上-g选项,则生成的可执行文件中不含调试信息,对分析工作产生阻碍,因此重新用下图的指令进行编译,产生新的可执行文件hello1。

图5.6-1  加上-g选项的编译命令

之后调用edb运行可执行程序hello1,右键点击analyze here可以得到hello1的函数列表信息,如下图所示

图5.6-2  hello1的函数列表信息

5.7 Hello的动态链接分析

程序调用一个有共享库定义的函数时,编译器无法预测函数在运行时的地址,因为定义这个函数的共享模块可能可以被加载到任何位置。因此,编译系统采用延迟绑定,将过程地址的绑定推迟到第一次调用该过程的时候。

延迟绑定需要用到两个数据结构GOT和过程链接表(PLT)。PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

在第一次调用某个函数时,程序不直接调用,而是调用进入函数所在的PLT条目,第一条PTL指令通过GOT进行间接跳转,每个GOT条目初始时都指向其对应的PLT条目的第二条指令,这个间接跳转只是简单将控制传送回函数所在的PLT条目的下一条指令。之后将函数的ID压入栈中之后,函数所在的PLT条目跳转到PLT[0],最后PLT[0]通过GOT[1]间接地把动态链接器的一个参数压入栈中,然后通过GOT[2]简介跳转进入动态链接器。动态链接器通过使用两个栈条目来确定函数的运行时位置,在将控制传递给函数。

后续调用时,则可以不用通过GOT[4]的跳转将控制给到函数。

由图5.3-5查看helloELF文件,得到GOT运行时的地址0x403ff0PLT运行时的地址0x404000。之后在程序调用dl_init之前,先查看0x404000位置的内容:

图5.7-1  调用dl_init前PLT的内容

dl_init调用前,对于每一条PIC函数调用,初始时每个GOT条目都指向了PLT条目的第二条指令。

图5.7-2  调用dl_init后PLT的内容

在调用dl_init后,可以看到对应内容发生了变化。

5.8 本章小结

本章节简要介绍了链接的相关过程,首先简要阐述了链接的概念和作用,给出了链接在Ubuntu系统下的指令。之后研究了可执行目标文件hello的ELF格式,并通过edb调试工具查看了虚拟地址空间和几个节的内容,之后依据重定位条目分析了重定位的过程,并借助edb调试工具,研究了程序中各个子程序的执行流程,最后则借助edb调试工具通过对虚拟内存的查取,分析研究了动态链接的过程。

6章 hello进程管理

6.1 进程的概念与作用

进程的经典定义是一个执行中程序的实例。上下文由程序正确运行所需哟啊的状态组成,这个状态包括存放在内存中的程序的代码和数据、栈、通用寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。系统中的每个程序都运行在某个进程的上下文中。

进程给程序提供了一个独立的逻辑控制流,提程序独占使用处理器的假象;进程给程序提供了一个私有的地址空间,提供了程序独占使用内存系统的假象。

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

Shell是一种交互型程序,用于代表用户运行其他程序。

Shell的处理流程如下:首先对命令行参数求值,判断命令行是否为空,如果不为空则判断第一个命令行参数是不是一个内置的命令,如果是一个内置命令则直接执行,否则检查是否是一个应用程序。之后在搜索路径里寻找这些应用程序,如果键入的命令不是一个内部命令并且路径里没有找到这个可执行文件,则会显示一条错误信息。如果能够成功找到命令,那么该内部命令或者应用程序将会被分解为系统调用并传递给linux内核。

6.3 Hello的fork进程创建过程

父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同,子进程得到父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,也就意味着父进程调用fork函数时,子进程能够读写父进程打开的任何文件。父进程和新创建的子进程之间最大的区别在于其不同的PID。

fork函数只被调用一次,但是会返回两次;依次在父进程中,返回子进程的PID,另一次是在子进程中,直接返回0,通过fork函数的返回值可以判断程序是在父进程还是子进程中执行。父进程和子进程是并发运行的独立进程。内核以任意方式交替执行其逻辑控制流的指令。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。execve函数一次调用而从不返回。

6.5 Hello的进程执行

1.逻辑控制流

       逻辑控制流是一个PC值的序列,PC值指程序计数器的值,这些值与可执行目标文件的指令或者包含在运行时动态链接到程序的共享对象中的指令一一对应。

2.时间分片

       进程是轮流使用处理器的,每个进程都执行它的流的一部分,然后被抢占(暂时挂起),然后轮到其它进程。一个逻辑流的执行在时间上与另一个流重叠被称为并发流,这两个流并发运行。

       多个流并发执行的概念被称为并发。一个进程与其他进程轮流运行的概念称为多任务。一个进程执行其控制流一部分的每一个时间段叫做时间片,多任务也就被称作是时间分片。

3.用户模式与内核模式

       为了保护操作系统内核,处理器在某一个控制寄存器中的一个模式位,设置模式位时,进程就运行在内核模式,否则运行在用户模式。

       运行程序代码初始时都是在用户模式中的,当发生中断故障或系统调用的异常时,进程从用户模式转变为内核模式。当异常发生时,控制传递到异常处理程序,处理器将模式转变为内核模式。内核处理程序运行在内核模式中,当它返回到应用程序代码时,处理器把模式从内核模式改回到用户模式。

图6.5  进程上下文切换机制的示意图

4.进程上下文切换   

前文在介绍进程概念时提出了进程上下文的概念。进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这样的决策叫做调度,由内核中的调度器的代码处理。在这个抢占过程中需要用到上下文切换,上下文切换保存当前进程的上下文,恢复先前某个被抢占的上下文,并将控制传递给新恢复的进程。

6.6 hello的异常与信号处理

1.乱打字

图6.6-1  程序运行过程中乱打字的结果

在程序运行时乱打字,不影响程序的正常运行。随著printf的调用保留在了输出结果中。

2.按下ctrl-Z

图6.6-2  程序调用过程中按下Ctrl -Z的结果

Ctrl-Z是挂起当前作业,但不会回收。

图6.6-3  调用ps命令后的结果

ps命令可以看到hello进程并没有被回收。

图6.6-4  调用jobs命令后的结果

       jobs可以查到hello的后台job id是1。

图6.6-5  调用fg命令后的结果

fg将hello调到前台重新开始运行,结合参考上图,输出次数与八次循环总数吻合

图6.6-6  调用Ctrl-C命令后的结果

Ctrl-C命令内核向前台发送SIGINT信号,终止了前台作业。

6.7本章小结

本章概述了hello进程大致的执行过程,阐述了进程、shell、fork、execve等相关概念,之后从逻辑控制流、时间分片、用户模式/内核模式、上下文切换等角度详细分析了进程的执行过程。并在运行时尝试了不同形式的命令和异常,每种信号都有不同处理机制,针对不同的shell命令,hello会产生不同响应。

7章 hello的存储管理

7.1 hello的存储器地址空间

1.逻辑地址

       程序经过编译后出现在汇编代码中的地址,逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为段标识符:段内偏移量。

2.物理地址

       在存储器中以字节为单位存储信息,每一个字节单元与一个唯一的存储器地址一一对应,称为物理地址,又叫实际地址或者是绝对地址。物理地址对应了系统中实际的内存字节。

3.虚拟地址

       CPU启动保护模式之后,程序运行在虚拟地址空间中,虚拟地址空间是所有可能地址的集合,对于一个64位的机器而言,则集合中共有2^64种可能。

4.线性地址

       逻辑地址到物理地址之间变换的中间层,在分段不见中逻辑地址就是段总的偏移地址,加上段的及地址就可以得到线性地址。

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

在 Intel 平台下,逻辑地址是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。这个过程就称作段式内存管理。

逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符),可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。

全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。

给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。

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

线性地址到物理地址之间的转换通过使用分页机制完成。分页机制是指将虚拟内存分割为虚拟页的大小固定的块。

为判断虚拟页是否缓存在DRAM的某个地方,并确定虚拟页所在的物理页位置,因此需要一个页表。页表是一个页表条目(PTE)的数组,每个PTE由一个有效位和一个n位地址字段组成,有效位的设置与否表明了虚拟页是否被DRAM缓存。在地址翻译过程中,需要用到一个页表基址寄存器,指向当前页表。一个n位虚拟地址包含两个部分,一是p位的虚拟页面偏移,二是一个n-p位的虚拟页号。在虚拟页已被缓存的情况下,页面命中,MMU利用VPN来选择合适的PTE,将页表条目中的物理页号和虚拟地址中的虚拟页面偏移结合起来就可以得到一个物理地址。而如果虚拟页没有被缓存,那么就需要处理缺页,触发缺页异常,并将控制传递到缺页异常处理程序,缺页处理程序能够确定物理内存中的牺牲页,进而判断是否因被修改过而需要调出内存,完成牺牲页的替换之后,则调入新的页面,并更新内存中的PTE,最后返回到原来进程,将引起缺页的虚拟地址重新发给MMU,这次便变成了命中的情况。

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

TLB是一个位于MMU中,关于PTE的一个缓存,被称为快表。快表是一个小的、虚拟寻址的缓存,其中每一行均保存了一个由单个PTE组成的块。TLB有高度的相联性。

四级页表是一种多级页表,多级页表的主要目的是用于压缩页表。在地址翻译过程中,虚拟的地址页号VPN被分为了k个,每一个VPNi都是一个指向第i级页表的索引。当1 <= j <= k-1时,都是指向第j+1级的某个页表。第k级页表中的每个PTE包含某个物理页面的PPN,或者时一个磁盘块的地址。为构造物理地址,MMU需要访问k个PTE,之后才能确定PPN。Intel Core i7采用的是一个四级页表层次结构,每个VPNi有9位,当未命中时,36位的VPN被分为VPN1、VPN2、VPN3、VPN4,每个VPNi被用作到一个页表的偏移量。CR3寄存器包含L1 页表的物理地址,VPN1提供到一个L1 PTE的偏移量,这个PTE包含某个L2页表的基址。VPN2提供到这个L2页表中某个PTE的偏移量,以此类推。最后得到的L4 PTE包含了需要的物理页号,和虚拟地址中的VPO连接起来就得到相应的物理地址。

TLB能够加速地址翻译,而多级页表能够对页表进行压缩,便于大量存储。

在从VA翻译得到PA的过程中,MMU首先用VPN向TLB申请请求对应的PTE,如果命中,那么直接跳过后面的步骤;之后MMU生成PTE地址,从高速主存请求得到PTE,高速缓存或主存会向MMU返回PTE。若PTE有效位为0,说明缺页,MMU触发缺页异常,缺页处理程序确定物理内存中的牺牲页(若页面修改,则换出到磁盘)。之后缺页处理程序调入新的页面,并更新PTE。之后却也处理程序返回原进程,并重新执行导致缺页的指令。

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

通过内存地址的组索引获得值,如果对应的值是data则像L1 d-cache对应组中查找,如果是指令,则向L1 i-cache对应组中查找。将L1对应组中的每一行的标记位进行对比,如果相同并且有效位为1则命中,获得偏移量,取出相应字节,否则不命中,向下一级cache寻找,直到向内存中寻找。

7.6 hello进程fork时的内存映射

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

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

7.7 hello进程execve时的内存映射

Execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效替代了当前程序。加载并运行a.out需要以下几个步骤:

  1. 删除已存在的用户区域:删除进程虚拟地址中已存在的用户区域;
  2. 映射私有区域:为新程序的代码、数据、bsss和栈区域创建新的区域结构。这些区域都是私有的、写时复制的。代码和数据区域被映射成a.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。
  3. 映射共享区域:如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器:execve做的最后一件事就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

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

缺页故障:一个虚拟页没被缓存在DRAM中,即DRAM缓存不命中被称为缺页。当CPU引用了一个页表条目中的一个字,而该页表条目并未被缓存在DRAM中,地址翻译硬件从内存中读取该页表条目,从有效位为0可以判断尚未被缓存,进而触发去也异常。

缺页中断处理:缺页异常调用缺页异常处理程序,该程序会选择一个牺牲页,如果这个牺牲页在DRAM中已被修改,那么就将他写回磁盘,之后将引用的虚拟页复制到内存中的原来牺牲页所在位置,并对页表条目进行更新,随后返回。当异常处理程序返回时,它会重新启动缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。此时需要调用的虚拟页已经缓存到主存中了,则页命中可以由地址翻译硬件正常处理。

7.9动态存储分配管理

动态内存分配主要使用了动态内存分配器,动态内存分配器维护了一个进程的虚拟内存区,称为堆。分配器将堆是为一组不同大小的块的集合来维护,每个块都是一个连续的虚拟内存片,虚拟内存片要么是已分配的,要么是空闲的。已分配的块显式地保留从而为供应应用程序使用。空闲块可以用来分配,空闲块在被应用分配之前都会保持空闲状态,而已分配的块在被释放之前都会保持已分配状态。释放要么由应用程序显式执行,要么是内存分配器自身隐式执行的。

分配器有两种基本风格:

  1. 显示分配器:要求应用显式地释放任何已分配的块
  2. 隐式分配器要求分配器监测一个已分配块何时不再被应用程序所使用。并在不被使用时释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用地已分配的块的过程叫做垃圾收集。

7.10本章小结

本章主要介绍了hello的存储管理,先介绍了四种存储器地址空间,然后介绍了intel逻辑地址到线性地址的变换机制,介绍了段式管理,之后概述了hello从线性地址到物理地址的变换机制——页式管理,并介绍了用于加速地址翻译的快表TLB和用于压缩页表的四级页表,并依照TLB和四级页表简单介绍了VA到PA的变换机制,随后简单提及了三级Cache支持下的物理内存访问机制,并在内存映射的角度上回顾了fork和execve函数。之后简单介绍了缺页故障和缺页中断处理机制,最后介绍了动态存储分配管理机制。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m个字节的序列,所有的I/O设备都被模型化为文件,所有的输入输出都被当作是文件的读和写来执行。这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,被称为是Unix I/O,这使得所有的输入和输出都能够以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

1.Unix接口:

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

(2)I/O设备:内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

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

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

5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。

2.Unix I/O函数:

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

进程通过调用open函数打开一个已存在的文件或者创建一个新文件:

Open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O_RDONLY:只读、O_WRONLY:只写和O_RDWR可读可写。mode参数指定了新文件的访问权限位。

(2)int close(fd):

       进程调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。

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

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

(4)ssize_t write(int fd, const void *buf, size_t n);

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

8.3 printf的实现分析

1.printf函数的函数体:

图8.3-1  printf函数的函数体

va_list是一个字符指针的重定义,(char*)((&fmt) + 4 )是第一个参数,这与栈的结构有关,*fmt存放在栈中,后续的字符型指针也都存在栈中,而指针大小位四个字节,所以+4得到第一个参数。之后调用了vsprintf函数,其函数体如下图所示:

图8.3-2  vsprintf函数的函数体

vsprintf主要用于格式化,接受输出格式的格式字符串fmt,用格式字符串对个数变化的参数进行格式化,产生格式化输出。

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

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

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

getchar的源代码如下:

图8.4-1  getchar函数的函数体

       getchar函数调用了read函数,通过系统调用read读取存储在键盘缓冲区的ASCII码,直到读到回车符才返回。不过read函数每次会把所有内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简单的返回缓冲区最前面的元素。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

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

8.5本章小结

本章主要介绍了hello的IO管理机制,先简述了IO设备被抽象为文件的现象,随后介绍了IO的设备管理方法——unix IO接口,随后对unixIO接口做了介绍之后,给出了Unix IO接口的相关函数,并在此基础上,对printf和getchar的视线分析做了介绍。

结论

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

  1. 编写hello.c的源程序
  2. 预处理,将c文件调用的库与原本的c文件进行合并,得到hello.i文本文件
  3. 编译:hello.i编译生成汇编语言文件hello.s
  4. 汇编:hello.s汇编得到二进制可重定位目标文件hello.o
  5. 链接:hello.o与其它调用库函数所在的可重定位目标文件和动态链接库链接生成可执行文件hello,hello至此可以被加载入内存并运行。
  6. 创建进程:终端shell调用fork函数,创建一个子进程,为程序的加载运行提供虚拟内存空间等上下文
  7. 加载程序:shell调用execve函数,启动加载器映射虚拟内存,之后开始载入物理内存,在进入main函数
  8. 访问内存:通过TLB和多级页表,实现虚拟内存和物理内存的翻译,进而访问计算机的存储结构,访问内存。
  9. IO:hello输入输出与外界进行交互,与Linux IO的抽象有关
  10. 终止:hello被父进程回收,内核收回为其创建的所有信息。

感悟:

      计算机系统的涉及与实现远比我想象中要复杂的多得多,我们日常生活中在使用计算机的每一个看似简单的功能,其背后实现所涉及的知识原理内容都相当的复杂。Hello的一生告诉我们,计算机科学的领域,没有顺理成章,没有理所当然,一切看似容易的操作都建立在前人伟大而巧妙的构思之上。计算机领域的学习需要潜心深入、止于至善。

附件

Hello.c——源程序

Hello.i——预处理后得到的变化的文本文件

Hello.s——汇编语言的汇编文件

Hello.o——可重定位目标执行文件

Hello——可执行文件

Hello1——加上了-g选项编译得到的可执行文件,便于edb反汇编得到调试信息

参考文献

[1]  忆梦初心 C语言学习之预处理. 2023-07-22 https://blog.csdn.net/m0_69909682/article/details

/128616065

[2]  月光下的麦克 readelf指令使用.2023-0201 http://t.csdnimg.cn/mpcVG

[3]  长路漫漫2021 Shell和Bash的区别和联系 http://t.csdnimg.cn/VXp25

[4]  Randal E. Bryant;David R.O’Hallaron. 深入理解计算机系统.背景:机械工业出版社,2016.7

[5]  Pianistx  printf函数的深入剖析https://www.cnblogs.com/pianist/p/3315801.html

  • 23
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值