计算机系统大作业——Hello的一生

 计算机系统大作业

题     目  程序人生-Hello’s P2P 

专       业        人工智能(2+X)       

学     号      2022111582         

班     级              WL023           

学       生                  lcy         

指 导 教 师         刘宏伟           


目录

摘  要

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

结论

参考文献


摘  要

本文旨在探讨程序从编写到执行再到进程管理的完整生命周期。通过对一个简单的Hello程序进行详细的实验分析,展示了程序在不同阶段的处理方式,包括预处理、编译、汇编、链接和执行。通过在Ubuntu环境下对这些阶段进行逐步解析,并通过edb/gdb调试工具进行动态链接分析,我们对程序的各个组成部分及其相互关系有了深入理解。实验结果表明,掌握这些基础概念和工具对开发高效稳定的软件具有重要意义。

关键词:预处理;编译;汇编;链接;进程管理;动态链接          


第1章 概述

1.1 Hello简介

Hello的P2P(Program to Process)过程从源文件hello.c开始,通过预处理、编译、汇编、链接,最终生成可执行文件hello。然后在Shell中运行该可执行文件,通过fork()和execve()函数将其转变为进程。

1.编写和保存(Program):程序员使用文本编辑器编写代码,将其保存为hello.c文件。

2.预处理:使用GCC的预处理器(Preprocessor)将hello.c中的宏、头文件等进行处理,生成hello.i文件。

3.编译:GCC编译器(Compiler)将hello.i转换为汇编代码,生成hello.s文件。

4.汇编:GCC汇编器(Assembler)将hello.s文件转换为机器代码,生成可重定位目标文件hello.o。

5.链接:链接器(Linker)将hello.o与标准库(如libc)进行链接,生成可执行文件hello。

6.执行(Process

Shell:在命令行(如Bash)中输入./hello执行命令。

进程管理(Process Management):

fork():Shell通过fork()函数创建一个新的子进程。

execve():在子进程中,Shell使用execve()函数加载并执行可执行文件hello。这会替换子进程的地址空间,将hello程序加载进来。

Hello的O2O(从零开始到零结束)过程包括进程的加载、执行和终止,展示了从程序变成进程,再到进程结束的完整生命周期。

1.加载和执行

删除用户区域:execve()函数删除已存在的用户区域。

映射新的私有区域:为hello程序映射新的私有内存区域。

映射共享区域:映射需要共享的内存区域,如共享库等。

2.运行

CPU调度:操作系统的调度程序为hello进程分配CPU时间片。

内存管理:通过MMU进行虚拟地址到物理地址的转换,使用TLB、页表等加速内存访问。

I/O管理:处理输入输出操作,使得hello能够与外设交互。

3.进程结束

终止(Termination):当hello程序执行完毕后,进程会调用exit()函数。

资源回收:父进程通过wait()函数回收子进程的退出状态,操作系统回收该进程占用的所有资源。

内核清理:内核清除hello进程的所有记录,释放内存和其他资源。

1.2 环境与工具

软件环境:Window11;Oracle VM VirtualBox 7.0.14;Ubuntu 22.04 64位。
硬件环境:X64 CPU;3.2GHz;16G RAM;512G SSD
开发调试工具: Visual Studio 1.89.1;gedit+gcc;EDB;readelf;gdb;ld

1.3 中间结果


1-1中间结果文件

文件名称

来源

hello

链接后的可执行文件

hello.c

C源文件

hello.i

预处理产生的文件

hello.o

编译产生的文件

hello.o_elf.txt

查看hello.oELF格式

hello.o_objdump.txt

对hello.o的反汇编产生的文件

hello.s

汇编产生的文件

hello_elf.txt

查看可执行文件的ELF格式

hello_objdump.txt

对可执行文件的反汇编产生的文件

图1-2中间结果文件来源说明

1.4 本章小结

本章介绍了实验的基本内容——Hello的一生,包括Hello的P2P,020过程,然后介绍了实验所需要的硬件环境、软件环境和开发工具,以及生成的中间结果文件。


第2章 预处理

2.1 预处理的概念与作用

预处理(Preprocessing)是编译过程中的第一个阶段,其主要目的是处理源代码中的宏指令和其他预处理指令,为后续的编译步骤做准备。

概念

预处理是由预处理器(如GCC的cpp)完成的。预处理器读取源代码文件,并进行以下操作:

1.宏替换:处理由#define指令定义的宏,将宏名称替换为其定义的内容。

2.文件包含:处理由#include指令包含的文件,将这些文件的内容插入到当前源文件中。

3.条件编译:处理由#if、#ifdef、#ifndef、#else、#elif和#endif等指令控制的代码块,根据条件决定是否编译这些代码。

4.行号和文件名信息:通过#line指令生成调试和错误信息所需的行号和文件名信息。

5.删除注释:删除源代码中的注释,使编译器更容易处理代码。

作用

宏替换:简化代码编写,提高代码的可读性和可维护性。

文件包含:通过#include指令,能够将常用的函数声明、宏定义等集中在头文件中,便于代码的模块化和重用。

条件编译:通过条件编译指令,可以根据不同的条件编译不同的代码部分,这在跨平台编程和调试时非常有用。

提高编译效率:预处理器在编译前处理注释和宏,减少编译器的负担,使编译过程更高效。


2.2在Ubuntu下预处理的命令

图2-1预处理命令

2.3 Hello的预处理结果解析

源程序文件代码主要内容在hello.i的最后部分


2-2 预处理结果文件代码主要内容

hello.i的前面包括了main函数前引用的头文件stdio.h、unistd.h和stdlib.h

# 1 "/usr/include/stdio.h" 1 3 4 表示包含标准库文件 stdio.h,其内容被直接插入到此位置。

# 1 "/usr/include/unistd.h" 1 3 4 表示包含标准库文件 unistd.h,其内容被直接插入到此位置。

# 1 "/usr/include/stdlib.h" 1 3 4 表示包含标准库文件 stdlib.h,其内容被直接插入到此位置

图2-3 预处理文件头文件部分

2.4 本章小结

本章展示了如何将hello.c文件通过预处理生成hello.i文件。预处理阶段处理了宏定义、文件包含和注释删除等操作,为编译做好了准备。通过预处理,原始源代码被转换为一个纯净的、可编译的中间文件,体现了预处理在编译流程中的关键作用。


第3章 编译

3.1 编译的概念与作用

编译是将预处理后的源代码文件(通常以.i为扩展名)转换为汇编代码文件(通常以.s为扩展名)的过程。这个过程是由编译器完成的,其主要作用是将高级编程语言(如C)的代码转换为汇编语言,汇编语言更接近于机器语言,但仍然具有可读性。 

概念

编译是编译器的核心功能之一。编译器读取预处理后的源文件,进行语法分析、语义分析、中间代码生成和优化,最终生成汇编代码文件。这个过程包括以下几个阶段:

词法分析:将源代码分解为记号(Tokens),如关键字、标识符、操作符等。

语法分析:检查记号序列是否符合语言的语法规则,并生成语法树。

语义分析:检查语法树是否符合语言的语义规则,例如变量类型检查、作用域检查等。

中间代码生成:将语法树转换为中间代码,这些中间代码是独立于具体机器的代码表示形式。

中间代码优化:对中间代码进行优化,以提高运行效率和减少资源消耗。

目标代码生成:将优化后的中间代码转换为汇编代码。

作用

编译的主要作用是将高级语言代码转化为机器可读的形式,同时进行优化,以提高程序的执行效率。

语法和语义检查:在编译过程中,编译器会检查代码的语法和语义错误,帮助开发者发现并修正代码中的问题。

代码优化:编译器通过优化技术减少代码冗余、提高执行效率,从而生成更高效的汇编代码。

机器无关性:中间代码生成和优化阶段使得编译器能够在不同的平台上生成高效的目标代码。

生成汇编代码:最终生成汇编代码文件,使得程序能够进一步通过汇编器转化为机器代码并执行。    


3.2 在Ubuntu下编译的命令

图3-1 编译命令

3.3 Hello的编译结果解析

3.3.1常量

字符串常量 .LC0 和 .LC1 分别是:

"\347\224\250\346\263\225: Hello 2022111582 \345\210\230\345\264\207\345\236\232 15140761385 0\357\274\201"
"Hello %s %s %s\n"

图3-2 常量

这些字符串常量被存储在 .rodata 段中,表示只读数据。

3.3.2局部变量
局部变量 int i; 和命令行参数 argc 和 argv 被存储在栈上。

图3-3 局部变量

3.3.3表达式

argc != 5 被翻译为cmpl $5, -20(%rbp),比较 argc 和 5。

循环变量 i 被初始化为 0:movl $0, -4(%rbp)。

3.3.4类型

int 类型变量使用 movl 指令操作,例如 movl %edi, -20(%rbp)。

char 指针 argv 被存储为 64 位指针,使用 movq 指令操作。

3.3.5赋值

赋值操作 =:movq %rsp, %rbp 表示将栈指针保存到基址指针寄存器中。

3.3.6逗号操作符

addq $24, %rax 这类指令表示指针算术,用于数组索引。

3.3.7类型转换

call atoi@PLT 将字符串转换为整数,属于显式类型转换。

3.3.8算术操作

加法 +:addq $24, %rax

自增 ++:addl $1, -4(%rbp)

3.3.9逻辑操作

比较操作符 ==:cmpl $5, -20(%rbp) 比较 argc 和 5 是否相等。

3.3.10关系操作

<、>、<=、>= 等操作符通过 cmpl 和跳转指令实现,例如:


3-4 关系操作


3.3.11数组/指针操作

访问命令行参数数组 argv:

图3-5 数组

3.3.12 if/else

使用条件跳转指令,例如 je .L2 表示 if 条件为真时跳转。

3.3.13 for 循环
使用 jmp 和条件跳转指令实现循环控制:

图3-6 循环

3.3.14参数传递

参数传递通过寄存器完成,例如 movq %rsi, -32(%rbp) 将 argv 传递给函数。

3.3.15函数调用

函数调用使用 call 指令,例如 call printf@PLT。

3.3.16函数返回
函数返回使用 ret 指令,并清理栈帧:

图3-7 函数返回

3.4 本章小结

本章详细介绍了编译过程,尤其是从预处理后的 .i 文件到汇编语言程序 .s 文件的转换。编译过程是编译器将高级语言代码(如 C 语言)转换为汇编语言代码的重要阶段。编译器将 C 语言代码成功转换为汇编语言代码,为后续的汇编和链接阶段做好准备。本章的实验示例展示了 hello.c 文件的编译过程,深入理解了编译器在代码转换中的关键作用。


第4章 汇编

4.1 汇编的概念与作用

汇编是将汇编语言代码文件(通常以 .s 为扩展名)转换为机器语言的目标文件(通常以 .o 为扩展名)的过程。这个过程是由汇编器(assembler)完成的,其主要作用是将汇编语言代码转换为机器可以直接执行的二进制代码。

概念

汇编是编译过程中的一个关键步骤,涉及以下几个主要阶段:

汇编代码读取:汇编器读取 .s 文件中的汇编语言代码。

指令翻译:将每一条汇编指令翻译成对应的机器指令(机器码)。

符号解析:解析汇编代码中的符号,包括变量名、函数名、标签等,将这些符号映射到具体的内存地址或偏移量。

段处理:根据汇编代码中的段定义(如 .text 段、.data 段)生成对应的机器码和数据段。

目标文件生成:将翻译后的机器码和其他必要的符号信息、调试信息等生成目标文件(.o 文件)。

作用

汇编的主要作用是将汇编语言代码转换为计算机可以直接执行的机器代码,并生成包含必要符号信息和调试信息的目标文件。具体作用包括:

指令翻译:将人类可读的汇编指令翻译成机器可以执行的二进制指令。

符号解析:管理和解析代码中的符号,确保变量、函数等符号在链接时可以正确解析和定位。

段管理:将代码和数据分配到适当的段中,确保程序的正确组织和执行。

目标文件生成:生成包含机器码、符号信息和调试信息的目标文件,为后续链接阶段提供输入。

4.2 在Ubuntu下汇编的命令


4-1 汇编命令

4.3 可重定位目标elf格式

4.3.1查看 ELF 文件的头部信息

这是一个 ELF64 格式的可重定位文件,适用于 x86-64 架构。文件包含 14 个段,段头表的偏移量是 1088 字节。

图4-2 ELF头部信息

4.3.2查看段表信息
段表列出了每个段的信息,包括名称、类型、大小、标志等。特别需要关注 .rela.text 段,它包含了 .text 段的重定位信息。

图4-3 段表信息

4.3.3查看符号表信息
符号表列出了每个符号的信息,包括符号名、类型、绑定属性等。比如,main 函数在第 4 个条目,printf 和 __libc_start_main 函数是外部引用的符号。

图4.4 符号表信息

4.3.4查看重定位信息

重定位表列出了需要重定位的符号及其偏移量、重定位类型等信息。例如:

00000000001c  000300000002 R_X86_64_PC32     0000000000000000 .rodata - 4表示在偏移量 0x1c 处,将 .rodata 段的地址减去 4 进行重定位。

重定位节 .rela.eh_frame表示在偏移量 0x20 处,将 .text 段的地址加上 0 进行重定位。


图4-5 重定位信息

4.4 Hello.o的结果解析

图4-6 结果解析

4.4.1程序入口和栈操作

汇编代码:

.cfi_startproc

endbr64

pushq %rbp

.cfi_def_cfa_offset 16

.cfi_offset 6, -16

movq %rsp, %rbp

.cfi_def_cfa_register 6

subq $32, %rsp

反汇编代码:

0000000000000000 <main>:

0:    f3 0f 1e fa              endbr64

4:    55                       push   %rbp

5:    48 89 e5                 mov    %rsp,%rbp

8:    48 83 ec 20              sub    $0x20,%rsp

映射关系:

endbr64 对应 f3 0f 1e fa

pushq %rbp 对应 55

movq %rsp, %rbp 对应 48 89 e5

subq $32, %rsp 对应 48 83 ec 20

4.4.2变量存储和比较

汇编代码:

movl %edi, -20(%rbp)

movq %rsi, -32(%rbp)

cmpl $5, -20(%rbp)

je .L2

反汇编代码:

c:    89 7d ec                 mov    %edi,-0x14(%rbp)

f:    48 89 75 e0              mov    %rsi,-0x20(%rbp)

13:   83 7d ec 05              cmpl   $0x5,-0x14(%rbp)

17:   74 19                    je     32 <main+0x32>

映射关系:

movl %edi, -20(%rbp) 对应 89 7d ec

movq %rsi, -32(%rbp) 对应 48 89 75 e0

cmpl $5, -20(%rbp) 对应 83 7d ec 05

je .L2 对应 74 19

4.4.3地址加载和函数调用

汇编代码:

leaq .LC0(%rip), %rax

movq %rax, %rdi

call puts@PLT

反汇编代码:

19:   48 8d 05 00 00 00 00     lea    0x0(%rip),%rax     # 20 <main+0x20>

1c: R_X86_64_PC32    .rodata-0x4

20:   48 89 c7                 mov    %rax,%rdi

23:   e8 00 00 00 00           call   28 <main+0x28>

24: R_X86_64_PLT32    puts-0x4

映射关系:

leaq .LC0(%rip), %rax 对应 48 8d 05 00 00 00 00,.rodata-0x4 是重定位信息

movq %rax, %rdi 对应 48 89 c7

call puts@PLT 对应 e8 00 00 00 00,puts-0x4 是重定位信息

4.4.4循环操作

汇编代码:

movl $0, -4(%rbp)

jmp .L3

反汇编代码:

32:   c7 45 fc 00 00 00 00     movl   $0x0,-0x4(%rbp)

39:   eb 56                    jmp    91 <main+0x91>

映射关系:

movl $0, -4(%rbp) 对应 c7 45 fc 00 00 00 00

jmp .L3 对应 eb 56

4.4.5字符串处理

汇编代码:

movq -32(%rbp), %rax

addq $24, %rax

movq (%rax), %rcx

反汇编代码:

3b:    48 8b 45 e0              mov    -0x20(%rbp),%rax

3f:    48 83 c0 18              add    $0x18,%rax

43:    48 8b 08                 mov    (%rax),%rcx

映射关系:

movq -32(%rbp), %rax 对应 48 8b 45 e0

addq $24, %rax 对应 48 83 c0 18

movq (%rax), %rcx 对应 48 8b 08

汇编代码与机器代码的对比

通过以上分析,可以看出机器语言指令与汇编语言指令之间存在直接的映射关系。机器语言指令的操作数和地址通常通过重定位信息进行调整,确保程序在执行时能够正确地跳转和调用函数。这些重定位信息在链接阶段被解析和处理,以生成最终的可执行文件。

4.5 本章小结

本章实验通过汇编过程将 hello.s 文件转换为 hello.o 文件,并详细分析了机器语言与汇编语言的映射关系。我们使用 objdump 工具反汇编生成的 hello.o 文件,并将其与原始的 hello.s 汇编代码进行对比。通过对比,我们了解到每条机器指令如何对应到具体的汇编指令,以及重定位信息在函数调用和分支跳转中的重要作用。最终,我们理解了机器语言指令和操作数如何通过重定位信息正确链接和执行,为后续的程序执行打下了基础。


5章 链接

5.1 链接的概念与作用

链接是将编译器生成的目标文件 (.o 文件) 和所需的库文件组合在一起,生成最终的可执行文件的过程。链接器的主要任务是解析符号、合并多个目标文件、处理重定位以及生成最终的可执行文件。链接过程可以分为以下几个步骤:

1.符号解析:链接器识别并解析每个目标文件中使用的所有符号(函数和变量)。如果一个目标文件引用了另一个目标文件中定义的符号,链接器会查找该符号的定义并链接它们。

2.重定位处理:链接器调整每个目标文件中的地址,使得所有目标文件可以组合成一个统一的地址空间。重定位包括调整目标文件中的地址和处理重定位表。

3.合并段:链接器将多个目标文件的相同段(如 .text、.data、.bss 段)合并到最终的可执行文件中。这包括代码段、数据段、未初始化数据段等。

4.符号表和重定位表的处理:链接器生成最终的符号表和重定位表,并将这些信息写入可执行文件中,以便在运行时进行动态链接和加载。


5.2 在Ubuntu下链接的命令

图5-1 链接命令

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

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

图5-2 elf格式

5.4 hello的虚拟地址空间

edb的Data Dump一栏中可以看到程序的虚拟地址。


图5-3 程序虚拟地址

5.5 链接的重定位过程分析

图5-4 重定位过程

hello.o是编译阶段的输出,即目标文件,包含了源代码的编译结果和需要在链接阶段处理的重定位条目。hello是链接阶段的输出,即最终的可执行文件,包含了所有符号的实际地址和链接的库函数。

hello.o文件中包含重定位条目,这些条目指示了哪些地址在链接过程中需要修改。这些重定位条目包括:  

   1c: R_X86_64_PC32    .rodata-0x4

   24: R_X86_64_PLT32    puts-0x4

   2e: R_X86_64_PLT32    exit-0x4

   62: R_X86_64_PC32    .rodata+0x30

   6f: R_X86_64_PLT32    printf-0x4

   82: R_X86_64_PLT32    atoi-0x4

   89: R_X86_64_PLT32    sleep-0x4

   98: R_X86_64_PLT32    getchar-0x4

hello中重定位后的地址在最终的可执行文件hello中,这些重定位条目已经被转换为具体的地址。例如:

  40113e: 48 8d 05 c3 0e 00 00     lea    0xec3(%rip),%rax       

# 402008 <_IO_stdin_used+0x8>

  401148: e8 43 ff ff ff           call   401090 <puts@plt>

  401152: e8 79 ff ff ff           call   4010d0 <exit@plt>

  401184: 48 8d 05 b1 0e 00 00     lea    0xeb1(%rip),%rax       

# 40203c <_IO_stdin_used+0x3c>

  401193: e8 08 ff ff ff           call   4010a0 <printf@plt>

  4011a6: e8 15 ff ff ff           call   4010c0 <atoi@plt>

  4011ad: e8 2e ff ff ff           call   4010e0 <sleep@plt>

  4011bc: e8 ef fe ff ff           call   4010b0 <getchar@plt>

在链接过程中,链接器将目标文件hello.o中的重定位条目转换为具体地址。这包括以下步骤:

R_X86_64_PC32和R_X86_64_PLT32重定位类型:

R_X86_64_PC32:表示相对地址重定位。链接器计算符号相对于当前指令地址的偏移量,并将这个偏移量写入指令中。

R_X86_64_PLT32:表示用于PLT(过程链接表)的重定位。链接器将指令修改为跳转到PLT条目,PLT条目在运行时将跳转到实际的函数地址。

例:

hello.o 中 24: R_X86_64_PLT32 puts-0x4 在 hello 中被转换为 call 401090 <puts@plt>,实际跳转到 .plt 段的相应位置。

hello.o 中 1c: R_X86_64_PC32 .rodata-0x4 在 hello 中被转换为 lea 0xec3(%rip),%rax,指向最终地址402008。

5.6 hello的执行流程


图5-5 hello执行流程

5.7 Hello的动态链接分析


图5-6 start处设置断点

图5-7 加载的共享库

图5-8 动态链接表

 5-9 main处设置断点

图5-10 再次检查共享库和动态连接表

对比动态链接前后的内容:

动态链接前:共享库可能只加载了基础库,如 libc 等,还没有解析所有函数地址。

动态链接后:所有需要的共享库已加载,函数地址已解析,程序进入正常执行状态。

5.8 本章小结

链接是将多个目标文件(如 .o 文件)和库文件链接在一起,形成一个可执行文件的过程。在这个过程中,链接器的主要作用是解析符号引用,处理重定位信息,以及合并代码和数据段,最终生成可执行文件。


6章 hello进程管理

6.1 进程的概念与作用

概念

进程是程序在计算机中执行时的一个实例,它包括程序代码和与其关联的资源(如内存、文件句柄等)。一个程序可以有多个进程实例。进程的组成部分包括,

可执行程序代码:包含要执行的指令。

数据段:包含静态和全局变量。

堆栈段:用于管理函数调用和局部变量。

堆:用于动态分配内存。

程序计数器:记录下一条将要执行的指令。

处理器寄存器:保存当前正在使用的数据。

资源:如文件句柄、设备、网络连接等。

作用

资源管理:

内存管理:每个进程拥有独立的内存空间,防止进程间的相互干扰。

文件管理:进程可以打开、关闭和操作文件,操作系统负责管理这些资源。

设备管理:进程可以访问硬件设备,操作系统提供设备的抽象和管理。

并发执行:

多任务处理:操作系统通过进程调度机制,实现多个进程的并发执行,提高系统资源利用率和用户响应速度。

隔离性和安全性:进程间的隔离保证了一个进程的崩溃不会影响其他进程,提高系统的稳定性和安全性。

进程间通信:

共享内存:不同进程可以共享一块内存区域,进行高效的数据交换。

消息传递:进程可以通过消息队列、管道、信号等方式进行通信和同步。

进程调度:

调度算法:操作系统使用不同的调度算法(如轮转调度、优先级调度等)来管理进程的执行顺序和时间分配。

上下文切换:当操作系统在多个进程之间切换时,会保存和恢复进程的状态,这个过程称为上下文切换。

进程控制:

创建和终止:操作系统提供创建新进程和终止现有进程的机制。

同步和互斥:操作系统提供同步机制(如锁、信号量)来解决进程间的竞争和协调问题。

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

作用

命令解释器:作为用户与操作系统之间的桥梁,Shell读取用户输入的命令并将其传递给操作系统内核进行执行。提供命令行界面(CLI),用户可以输入命令、运行脚本、管理系统资源等。

脚本编程:支持编写Shell脚本,通过编写脚本可以实现自动化任务,如备份、系统监控、批处理操作等。Shell脚本支持变量、条件语句、循环、函数等编程结构,具备一定的编程能力。

系统管理:提供各种内置命令和工具,用于管理文件系统、用户权限、进程、网络配置等系统资源。能够调用外部程序和工具,增强系统管理和操作能力。

环境配置:允许用户自定义和配置环境变量,影响系统行为和程序运行。提供启动文件(如.bashrc、.bash_profile)来设置环境变量和别名,实现个性化配置。

处理流程

1.启动:当用户登录系统或打开一个终端时,Shell会被启动。Bash会读取启动文件(如/etc/profile、~/.bash_profile、~/.bashrc等)来初始化环境。

2.读取输入:Shell显示提示符(如$或#)等待用户输入命令。用户输入命令后按回车键,Shell读取整个命令行。

3.解析和解释:Shell对输入的命令行进行解析,包括分解命令、参数和选项。Shell替换变量、通配符和命令替换(如$(command)或`command`)。Shell解析命令的语法结构,处理重定向(如>、<、>>)和管道(|)。

4.执行命令:Shell根据命令类型进行处理:

内建命令:如果是Shell的内建命令,直接在Shell进程中执行。

外部命令:如果是外部程序,Shell创建一个子进程,通过fork和exec系统调用执行该程序。

Shell等待命令执行完成,如果命令在后台运行(使用&符号),则不会等待命令完成。

5.返回结果:Shell接收命令的执行结果(如退出状态、输出)并返回给用户。如果命令执行成功,退出状态为0;否则为非零值。

6.显示提示符:当一个命令执行完成后,Shell再次显示提示符,等待用户输入下一条命令。

6.3 Hello的fork进程创建过程

当执行hello程序时,操作系统会为其创建一个进程。这个进程包含了程序代码、数据段、堆、栈等内存区域。该进程将成为hello程序的主进程。程序启动后,首先检查命令行参数的数量是否为5(包括程序名)。如果不是,则输出用法信息并退出。程序进入一个循环,执行10次。每次循环都会打印传入的参数并调用sleep函数暂停一段时间。暂停的时间由命令行参数argv[4]转换为整数决定。循环结束后,程序调用getchar()等待用户输入字符,直到按下回车键或其他字符。

6.4 Hello的execve过程

execve函数用于在当前进程的上下文中加载并运行一个新程序。它会读取指定的可执行文件,并用该文件的内容替换当前进程的内容。这个函数接受一个参数列表和一个环境变量列表,将它们传递给新程序。只有在出现错误时,execve才会返回到调用它的程序。与fork函数不同,fork调用一次会返回两次(一次在父进程,一次在子进程),而execve只调用一次且不返回(除非发生错误)。execve调用后,当前进程的内容被新程序完全替换,所以不会返回到原来的程序。

6.5 Hello的进程执行

进程执行的关键点

进程启动:当用户运行hello程序时,操作系统创建一个进程,为其分配PID,并为其分配内存、文件描述符等资源。此时,进程处于用户态。

参数检查:程序首先检查命令行参数的数量是否为5个。如果不是,则打印用法信息并退出

循环打印与睡眠:程序进入一个循环,每次循环打印一次“Hello”信息,然后调用sleep函数暂停指定的时间。

等待用户输入:循环结束后,程序调用getchar()等待用户输入字符,直到按下回车键或其他字符。

进程上下文切换:进程上下文包括进程的寄存器状态、堆栈、内存映射、文件描述符等。当CPU从一个进程切换到另一个进程时,需要保存当前进程的上下文并加载下一个进程的上下文。这种切换称为上下文切换。

进程调度与时间片

操作系统使用调度算法来决定哪个进程在什么时候运行。常见的调度算法有轮转调度(Round Robin)、优先级调度等。

时间片:每个进程被分配一个固定的时间片(时间段)来运行。如果进程在时间片内没有完成,它将被挂起,CPU切换到下一个进程。

在sleep期间,进程主动放弃CPU,让出时间片,使其他进程可以运行。

用户态与核心态转换

用户态:用户态是指进程在执行用户代码时所处的状态。大部分应用程序代码在用户态下运行。

核心态:核心态是指进程在执行操作系统内核代码时所处的状态。系统调用、硬件中断处理等在核心态下执行。进程通过系统调用(如sleep)从用户态切换到核心态。系统调用执行完毕后,进程返回用户态。

调用sleep:当调用sleep时,进程从用户态切换到核心态。内核将进程放入睡眠队列,并设置一个定时器。当定时器到期时,内核将进程从睡眠队列中移除,并将其放入就绪队列。

调度与上下文切换:当进程睡眠时,操作系统调度其他进程运行。定时器到期后,内核调度器将睡眠的进程放回就绪队列,并在适当的时候进行上下文切换,使该进程继续运行。

getchar等待输入:调用getchar时,进程可能再次进入睡眠状态,等待用户输入。当用户按下键盘时,触发中断,内核处理中断并唤醒等待输入的进程。

6.6 hello的异常与信号处理

Ctrl-C(SIGINT):

用户按下Ctrl-C组合键时,会向前台进程组(通常是当前终端窗口中运行的程序)发送SIGINT信号,通常用于终止程序的运行。

程序可以通过设置SIGINT的信号处理函数来处理这种情况,典型的处理方式是在信号处理函数中进行一些清理工作后正常退出。

Ctrl-Z(SIGTSTP):

用户按下Ctrl-Z组合键时,会向前台进程组发送SIGTSTP信号,将程序置于后台运行状态。

程序可以捕获SIGTSTP信号并设置信号处理函数来处理这种情况。默认情况下,程序会暂停执行直到再次在前台运行。

其他异常:

程序在运行过程中可能会遇到其他类型的异常,如内存访问错误(SIGSEGV)、除零错误(SIGFPE)等。这些异常会导致操作系统向进程发送相应的信号。

正常运行:

图6-1 正常运行程序

乱按:进程不停止并一直继续执行,隔一段时间在我们的输入后面输出。

图6-2 程序运行时乱按

Ctrl-Z:bash向进程发送信号SIGTSTP,进程存在但已经停止。

 图6-3 程序运行时按Ctrl-Z

Ctrl-C: bash向进程发送信号SIGINT,进程终止。

图6-4 程序运行时按Ctrl-C

6.7本章小结

进程是程序的执行实例,以及异常控制流如何在系统中发挥作用,对于理解操作系统的基本工作原理和开发稳定的软件至关重要。


7章 hello的存储管理

7.1 hello的存储器地址空间

1. 逻辑地址

逻辑地址是在程序源代码中出现的地址,例如在Hello.c中使用的变量地址和指令地址。逻辑地址是编译器生成的地址,不是真实的物理内存地址。

int i;

for(i=0; i<10; i++){

    printf("Hello %s %s %s\n", argv[1], argv[2], argv[3]);

    sleep(atoi(argv[4]));          }

在编译阶段,这些变量和指令的地址都是逻辑地址。

2. 线性地址

线性地址是指逻辑地址经过段转换机制得到的地址。它是操作系统内存管理单元(MMU)管理的地址,是逻辑地址到物理地址转换中的中间步骤。

3. 虚拟地址

虚拟地址是操作系统为每个进程分配的地址空间。每个进程有自己的虚拟地址空间,不同进程的相同虚拟地址实际上可以映射到不同的物理内存地址。

在Hello.c中,argv指向的内存地址是虚拟地址:

printf("Hello %s %s %s\n", argv[1], argv[2], argv[3]);

argv数组的元素存储在进程的虚拟地址空间中。

4. 物理地址

物理地址是实际的内存硬件地址,是CPU访问内存的最终地址。通过虚拟地址到物理地址的映射,操作系统可以实现进程间的内存隔离和管理。

在Hello.c的执行过程中,操作系统将虚拟地址映射到实际的物理内存地址,从而使得程序可以在物理内存中运行。

编译和链接阶段:

Hello.c程序中的变量和函数在编译阶段被分配逻辑地址。

这些逻辑地址在编译后的可执行文件中被保存下来。

加载和执行阶段:

当Hello.c程序被加载到内存中执行时,操作系统为其分配虚拟地址空间。

程序中的逻辑地址通过段转换和页表机制被转换为虚拟地址。

虚拟地址通过内存管理单元(MMU)映射到物理地址。

运行阶段:

当程序运行到printf("Hello %s %s %s\n", argv[1], argv[2], argv[3]);时,argv指向的字符串在虚拟地址空间中。

操作系统将这些虚拟地址映射到实际的物理内存地址上,使得CPU可以访问并执行这些指令。

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

在Intel架构的CPU中,逻辑地址到线性地址的转换使用了段式管理机制。段式管理涉及段寄存器和段描述符。

段式管理是内存管理的一种方式,利用段寄存器和段描述符来管理和保护内存。每个逻辑地址由两个部分组成:段选择子和段内偏移。

段选择子:指示使用哪个段描述符,包含在段寄存器(如CS、DS、SS、ES、FS、GS)中。段选择子是一个16位的值,它包含三个部分,索引,段描述符表中的一个条目;TI位,指示使用GDT(全局描述符表)还是LDT(局部描述符表);RPL(请求特权级),请求访问段的特权级别。

段内偏移:指定段内的具体地址。

段描述符是描述段特性的数据结构,包含以下信息:

基址:段的起始地址。

段界限:段的大小。

属性:段的类型、特权级别等。

从逻辑地址到线性地址的转换步骤

1.确定段选择子: 通过段寄存器(例如CS、DS、SS等)中的段选择子确定要使用的段描述符。

2.加载段描述符: 根据段选择子中索引,从GDT或LDT中加载段描述符。

3.计算段基址: 段描述符中包含段基址。

4.计算线性地址: 线性地址 = 段基址 + 段内偏移。

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

在Intel架构的CPU中,线性地址到物理地址的转换使用了页式管理机制。页式管理通过页表将线性地址映射到物理地址。页式管理将内存分割成固定大小的块,称为页(Page)。每个进程有自己的页表,页表将线性地址空间中的页映射到物理内存中的页框(Frame)。

在32位系统中,线性地址通常分为三个部分:页目录索引(Page Directory Index, PDI)、页表索引(Page Table Index, PTI)和页内偏移(Page Offset)。页目录索引由线性地址的高10位构成,用于索引页目录表;页表索引由中间10位构成,用于索引页表;页内偏移由低12位构成,用于指定页内的具体地址。页表的层次结构包括页目录表、页表和页框。页目录表包含页目录项,每个页目录项指向一个页表。页表包含页表项,每个页表项指向一个页框。页框是物理内存中的固定大小的块,通常为4KB。

线性地址到物理地址的转换过程可以分为几个步骤。首先,线性地址被分解为页目录索引、页表索引和页内偏移。然后,根据页目录索引,从页目录表中找到对应的页目录项。接着,根据页目录项中的地址找到对应的页表,并根据页表索引找到页表项。最后,根据页表项中的地址找到对应的页框基址,并加上页内偏移,计算出物理地址。

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

在现代计算机体系结构中,虚拟地址(VA)到物理地址(PA)的转换依赖于页式管理和翻译后备缓冲区(TLB)。页式管理通过多级页表(如四级页表)来实现地址转换,而TLB用来加速这个过程。四级页表结构包含页全局目录(PGD)、页上级目录(PUD)、页中间目录(PMD)和页表(PT)。每一级目录项指向下一级目录,直到页表项指向物理页框。当CPU需要转换一个虚拟地址时,首先检查TLB,如果找到对应的映射(TLB命中),则快速完成转换;如果未找到(TLB未命中),则需通过四级页表进行转换。

在四级页表转换过程中,虚拟地址被分解为四个索引和一个页内偏移。首先,通过页全局目录索引找到页全局目录项,然后通过页全局目录项找到页上级目录,再通过页上级目录索引找到页上级目录项,并依此类推,直到通过页表索引找到页表项。最后,通过页表项中的物理页框基址加上页内偏移,计算出最终的物理地址。这一机制有效地管理和保护了内存,同时在TLB的帮助下,提高了地址转换的效率。

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

在现代计算机体系结构中,为了提高内存访问速度和整体系统性能,使用了多级缓存(Cache)机制。三级缓存(L1、L2和L3)是目前常见的缓存层次结构。每一级缓存具有不同的大小和速度,靠近处理器的缓存较小但速度更快,而较远的缓存较大但速度较慢。

一级缓存(L1)是最快的缓存,直接集成在处理器内核中,分为指令缓存和数据缓存。L1缓存的访问速度非常快,通常在一个处理器时钟周期内即可完成访问。由于其高速特性,L1缓存的容量通常较小,一般在几十KB到几百KB之间。

二级缓存(L2)相较于L1缓存稍慢一些,但容量较大,通常在几百KB到几MB之间。L2缓存也集成在处理器内核中,但不同于L1缓存的是,L2缓存通常为统一缓存,既存储指令又存储数据。L2缓存的访问速度稍慢,但仍比主内存快得多。

三级缓存(L3)是最大的缓存,容量通常在几MB到几十MB之间。L3缓存可以是共享缓存,多个处理器内核可以共享同一个L3缓存。L3缓存的访问速度比L2缓存慢,但仍远快于主内存。

当处理器需要访问数据时,首先会在L1缓存中查找。如果L1缓存中没有找到(称为L1缓存未命中),则会查找L2缓存。如果L2缓存也未命中,则会查找L3缓存。如果三级缓存都未命中,则处理器需要从主内存中获取数据。这种逐级查找的机制使得大多数情况下,处理器能够在较快的缓存中找到所需的数据,从而显著提高了内存访问速度和整体系统性能。

通过这种多级缓存机制,计算机能够有效地隐藏主内存访问的高延迟,提高数据访问的效率和程序运行的速度。每一级缓存在保证高性能的同时,也为大容量的数据存储提供了较好的平衡,使得系统能够在不同的工作负载下都表现出色。

7.6 hello进程fork时的内存映射

hello.c程序首先检查命令行参数的数量是否等于五。如果不等于五,它会打印一条用法信息并退出。否则,它会进入一个循环,循环十次,每次打印一次格式化的问候语,然后休眠指定的秒数(由命令行参数argv[4]确定)。最后,它等待用户输入一个字符后退出程序。这个程序的核心逻辑包含命令行参数的解析、循环打印和休眠操作。

当一个进程调用fork时,操作系统会创建一个新进程,这个新进程是调用fork的父进程的几乎精确副本。子进程继承了父进程的虚拟地址空间,包括代码段、数据段、堆和栈。然而,初始情况下,父子进程共享相同的物理内存页,这些页被标记为只读。这种策略被称为写时复制(Copy-On-Write, COW),当父进程或子进程尝试修改这些共享页面时,操作系统会为进行修改的进程创建一个该页面的副本。这种机制确保了父子进程在大多数情况下能够高效地共享内存,而不必立即复制所有内容。

此外,子进程还继承了父进程的文件描述符,这意味着它们可以访问相同的文件。如果文件描述符指向同一个文件,子进程和父进程将共享文件指针。这种共享有助于保持文件操作的一致性。

在调用fork之前,父进程的内存映射包含代码段、数据段、堆和栈。在调用fork之后,子进程的内存映射最初与父进程相同。随着程序的运行,如果子进程试图修改某些数据,写时复制机制将创建这些数据的副本,使得父子进程拥有独立的内存区域。这种内存管理方式不仅节省了内存资源,还提高了进程创建的效率。

fork调用通过复制父进程的虚拟地址空间和共享物理页面,实现了高效的进程创建和内存管理。写时复制机制确保了进程之间的独立性和内存使用的高效性,而文件描述符的继承则保证了文件操作的一致性和协作性。这些机制使得UNIX和Linux系统中的多进程编程变得高效且易于控制。

7.7 hello进程execve时的内存映射

当进程调用execve时,它的内存映射会被新程序的内存映射完全替换。调用execve前,进程的内存映射包括代码段、数据段、堆和栈。代码段存放着程序的可执行代码,数据段包含全局变量和静态变量,堆用于动态内存分配,而栈则存储函数调用信息和局部变量。

在调用execve之后,旧程序的地址空间会被完全清空,内存中的所有段都会被释放。execve加载新的可执行文件,将其内容映射到进程的地址空间中,这意味着新的程序将拥有自己的代码段、数据段、堆和栈。新程序的代码段将包含新程序的可执行代码,数据段将包括新程序的全局变量和静态变量,堆将重新初始化,用于新程序的动态内存分配,而栈也会重建,以便存储新程序的函数调用信息和局部变量。

在这一过程中,虽然进程的内存内容和代码被完全替换,但进程ID保持不变。新程序在同一个进程上下文中运行,继承了一些环境变量和文件描述符等资源。由于内存映射的完全替换,旧程序的所有内容都被新程序的内容所取代。

execve系统调用通过清空旧程序的内存映射并加载新程序的可执行文件,实现了当前进程地址空间的完全替换。这个过程确保了新程序可以在同一个进程上下文中运行,虽然进程ID未变,但所有旧程序的内存内容都被新的程序内容替代。这种机制保证了进程管理的一致性,同时提供了灵活的程序切换能力,使一个进程可以在不改变进程ID的情况下运行一个全新的程序。

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

在计算机系统中,缺页故障(page fault)是指当程序访问的内存页不在物理内存(RAM)中时,发生的一种中断或异常情况。缺页故障通常由内存管理单元(MMU)检测并发出信号给操作系统。内存管理单元负责将虚拟地址转换为物理地址,当它发现所需的内存页不在物理内存中时,会触发缺页故障。这一故障会导致控制权从当前程序转移到操作系统内核,以处理这一事件。

缺页故障发生时,操作系统会进行一系列步骤来处理这一中断。首先,操作系统会检查所请求的内存页是否在程序的合法地址空间内。这是通过查阅进程的内存描述信息完成的。如果请求的内存页不在合法地址空间内,操作系统会终止该进程并报告内存访问违规错误。如果请求的内存页在合法地址空间内,操作系统会尝试将缺失的内存页从磁盘调入物理内存。

操作系统会在磁盘上查找所需的内存页,通常在分页文件或交换空间中。找到所需内存页后,操作系统会在物理内存中找到一个空闲的内存页框。如果物理内存已满,操作系统会根据一定的页面替换算法(如LRU算法、FIFO算法等)选择一个页面进行替换。被替换的页面如果已被修改,会被写回磁盘,以确保数据不丢失。

在找到或腾出一个内存页框后,操作系统会将所需的内存页从磁盘读入这个内存页框中。读操作完成后,操作系统会更新页表,将虚拟地址映射到新的物理地址。页表是内存管理单元使用的一个数据结构,它记录了虚拟地址到物理地址的映射关系。

更新页表后,操作系统会恢复因缺页故障而被中断的程序的执行。程序从触发缺页故障的指令开始重新执行,这次访问将成功,因为所需的内存页已经加载到物理内存中。整个过程对程序是透明的,程序不会察觉到缺页故障的发生。

7.9动态存储分配管理

动态存储分配管理:动态存储分配是在程序运行期间,根据需要分配和释放内存的过程。这种内存管理方式使程序能够灵活地使用内存,适应不同的需求。常见的动态内存分配函数包括malloc、calloc、realloc和free,这些函数提供了在堆区分配和释放内存的能力。动态内存管理克服了静态内存分配的局限,使程序能够处理更大的内存需求和不确定的内存使用情况。

动态内存管理的基本方法:内存分配通过malloc函数实现,该函数根据请求的字节数在堆区分配内存,并返回指向这块内存的指针。内存释放使用free函数,将之前分配的内存块释放回堆区。为了调整已分配内存块的大小,可以使用realloc函数,它根据新的大小重新分配内存块,可能会移动内存块到新的位置。

动态内存管理的策略:动态内存管理的常见策略包括首次适应算法(First-Fit)、最佳适应算法(Best-Fit)和最差适应算法(Worst-Fit)。首次适应算法遍历空闲链表,找到第一个适合的空闲块进行分配。最佳适应算法遍历整个空闲链表,找到最接近请求大小的空闲块进行分配。最差适应算法选择最大的空闲块进行分配。分区分配(Buddy System)是一种使用二进制树结构的策略,它将内存划分成大小为2的幂的块,并根据请求大小找到最接近的块进行分配。

Printf与动态内存管理:printf函数在处理格式化字符串和可变参数时,可能会调用动态内存分配函数malloc。这是因为printf在某些情况下需要临时分配内存来存储中间结果或处理复杂的格式化操作。为了确保内存管理的高效和安全,printf函数的实现通常包括对内存分配和释放的严格管理,以防止内存泄漏和碎片化。

动态存储分配管理通过动态分配和释放内存,使程序能够灵活高效地使用内存。理解和应用动态内存管理的基本方法和策略,有助于提高程序的内存使用效率和性能。标准库函数如printf也会利用动态内存管理,以处理复杂的操作需求,确保程序运行的稳定和高效。

7.10本章小结

本章介绍了hello程序在执行时操作系统提供的内存管理机制,重点讨论了虚拟内存与物理内存之间的转换关系,以及支持这些转换的硬件和软件机制。我们详细阐述了hello进程在执行过程中,操作系统如何利用内存管理单元(MMU)将虚拟地址转换为物理地址。通过MMU,操作系统能够实现虚拟内存的管理,使得进程可以使用比物理内存更大的地址空间,从而提高内存使用的效率和灵活性。

本章还深入探讨了缺页异常的处理过程。当程序访问的内存页不在物理内存中时,会触发缺页异常。操作系统首先检查内存访问的合法性,然后从磁盘加载所需的内存页,并更新页表,将虚拟地址映射到新的物理地址。整个缺页处理过程对程序是透明的,确保程序能够继续运行,而不需关心底层的内存管理细节。

通过本章的学习,我们了解了操作系统如何通过内存管理机制高效地管理进程的内存使用,确保系统的稳定性和高效性。这些机制包括虚拟内存的地址转换、内存分配与释放、以及缺页异常处理等,都是现代操作系统不可或缺的重要功能。


结论

通过本次实验,我们全面了解了一个简单C程序从编写到执行的全过程。预处理阶段主要处理宏指令和文件包含;编译阶段将预处理后的代码转换为汇编代码;汇编阶段生成可重定位的目标文件;链接阶段将目标文件和库文件合并生成可执行文件。最终,通过Shell运行可执行文件并通过系统调用实现进程管理。实验中使用的调试工具edb/gdb帮助我们分析了动态链接的细节,展示了程序加载和执行的复杂过程。这些知识不仅对理解程序的工作机制至关重要,也为开发优化软件提供了实用的指导


附件

文件名称

来源

hello

链接后的可执行文件

hello.c

C源文件

hello.i

预处理产生的文件

hello.o

编译产生的文件

hello.o_elf.txt

查看hello.oELF格式

hello.o_objdump.txt

对hello.o的反汇编产生的文件

hello.s

汇编产生的文件

hello_elf.txt

查看可执行文件的ELF格式

hello_objdump.txt

对可执行文件的反汇编产生的文件


参考文献

[1]《深入理解计算机系统》(原书第三版)

[2]https://blog.csdn.net/qq_43334303/article/details/85322646?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522171828934316800184111361%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=171828934316800184111361&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-85322646-null-null.142^v100^control&utm_term=hello%E7%9A%84%E4%B8%80%E7%94%9F&spm=1018.2226.3001.4187

[3]https://blog.csdn.net/qq_43334303/article/details/85322646?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522171828934316800184111361%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=171828934316800184111361&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-85322646-null-null.142^v100^control&utm_term=hello%E7%9A%84%E4%B8%80%E7%94%9F&spm=1018.2226.3001.4187

[4]https://blog.csdn.net/qq_52099474/article/details/124871005?ops_request_misc=&request_id=&biz_id=102&utm_term=hello%E7%9A%84%E4%B8%80%E7%94%9F&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-6-124871005.142^v100^control&spm=1018.2226.3001.4187

[5] https://blog.csdn.net/edonlii/article/details/8779075

[6] https://blog.csdn.net/daocaokafei/article/details/118614187

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值