Linux 内核对内存的管理和使用-转载-20230916

5.3 Linux 内核对内存的管理和使用

本节首先说明 Linux 0.12 系统中比较直观的物理内存使用情况,然后结合 Linux 0.12 内核中的应用情况,再分别概要描述内存的分段和分页管理机制以及 CPU 多任务操作和保护方式。最后我们再综合说明 Linux 0.12 系统中内核代码和数据以及各个任务的代码和数据在虚拟地址、线性地址和物理地址之间的对应关系。

5.3.1物理内存

在 Linux 0.12 内核中,为了有效地使用机器中的物理内存,在系统初始化阶段内存被划分成几个功能区域,见图 5-5 所示。

image-20230916173832424

其中,Linux 内核程序占据在物理内存的开始部分,接下来是供硬盘或软盘等块设备使用的高速缓冲区部分(其中要扣除显示卡内存和 ROM BIOS 所占用的内存地址范围 640K–1MB)。当一个进程需要读取块设备中的数据时,系统会首先把数据读到高速缓冲区中;当有数据需要写到块设备上去时,系统也是先将数据放到高速缓冲区中,然后由块设备驱动程序写到相应的设备上。内存的最后部分是供所有程序可以随时申请和使用的主内存区。内核程序在使用主内存区时,也同样首先要向内核内存管理模块提出申请,并在申请成功后方能使用。对于含有 RAM 虚拟盘的系统,主内存区头部还要划去一部分,供虚拟盘存放数据。

由于计算机系统中所含的实际物理内存容量有限,因此 CPU 中通常都提供了内存管理机制对系统中的内存进行有效的管理。在 Intel 80386 及以后的 CPU 中提供了两种内存管理(地址变换)系统:内存分段系统(Segmentation System)和分页系统(Paging System)。其中分页管理系统是可选择的,由系统程序员通过编程来确定是否采用。为了能有效地使用物理内存,Linux 系统同时采用了内存分段和分页管理机制。

5.3.2内存地址空间概念

Linux 0.12 内核中,在进行地址映射操作时,我们需要首先分清 3 种地址以及它们之间的变换概念:a. 程序(进程)的虚拟和逻辑地址;b. CPU 的线性地址;c. 实际物理内存地址。

虚拟地址(Virtual Address)是指由程序产生的由段选择符和段内偏移地址两个部分组成的地址。因为这两部分组成的地址并没有直接用来访问物理内存,而是需要通过分段地址变换机制处理或映射后才对应到物理内存地址上,因此这种地址被称为虚拟地址。虚拟地址空间由 GDT 映射的全局地址空间和由 LDT 映射的局部地址空间组成。选择符的索引部分由 13 个比特位表示,加上区分 GDT 和 LDT 的 1个比特位,因此 Intel 80X86 CPU 共可以索引 16384 个选择符。若每个段的长度都取最大值 4G,则最大虚拟地址空间范围是 16384 * 4G = 64T。

逻辑地址(Logical Address)是指由程序产生的与段相关的偏移地址部分。在 Intel 保护模式下即是指程序执行代码段限长内的偏移地址(假定代码段、数据段完全一样)。应用程序员仅需与逻辑地址打交道,而分段和分页机制对他来说是完全透明的,仅由系统编程人员涉及。不过有些资料并不区分逻辑地址和虚拟地址的概念,而是将它们统称为逻辑地址。

线性地址(Linear Address)是虚拟地址到物理地址变换之间的中间层,是处理器可寻址的内存空间(称为线性地址空间)中的地址。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址可以再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386 的线性地址空间容量为 4G。

物理地址(Physical Address)是指出现在 CPU 外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。

虚拟存储(或虚拟内存)(Virtual Memory)是指计算机呈现出要比实际拥有的内存大得多的内存量。因此它允许程序员编制并运行比实际系统拥有的内存大得多的程序。这使得许多大型项目也能够在具有有限内存资源的系统上实现。一个很恰当的比喻是:你不需要很长的轨道就可以让一列火车从上海开到北京。你只需要足够长的铁轨(比如说 3 公里)就可以完成这个任务。采取的方法是把后面的铁轨立刻铺到火车的前面,只要你的操作足够快并能满足要求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。在 Linux 0.12 内核中,给每个程序(进程)都划分了总容量为 64MB的虚拟内存空间。因此程序的逻辑地址范围是 0x0000000 到 0x4000000。

如上所述,有时我们也把逻辑地址称为虚拟地址。因为逻辑地址与虚拟内存空间的概念类似,并且也是与实际物理内存容量无关。

5.3.3内存分段机制

在内存分段系统中,一个程序的逻辑地址通过分段机制自动地映射(变换)到中间层的 4GB(232)线性地址空间中。程序每次对内存的引用都是对内存段中内存的引用。当程序引用一个内存地址时,通过把相应的段基址加到程序员看得见的逻辑地址上就形成了一个对应的线性地址。此时若没有启用分页机制,则该线性地址就被送到 CPU 的外部地址总线上,用于直接寻址对应的物理内存。见图 4-4 所示。

image-20230916174313553

CPU 进行地址变换(映射)的主要目的是为了解决虚拟内存空间到物理内存空间的映射问题。虚拟内存空间的含义是指一种利用二级或外部存储空间,使程序能不受实际物理内存量限制而使用内存的一种方法。通常虚拟内存空间要比实际物理内存量大得多。

那么虚拟存储管理是怎样实现的呢?原理与上述列车运行的比喻类似。首先,当一个程序需要使用一块不存在的内存时(也即在内存页表项中已标出相应内存页面不在内存中),CPU 就需要一种方法来得知这个情况。这是通过 80386 的页错误异常中断来实现的。当一个进程引用一个不存在页面中的内存地址时,就会触发 CPU 产生页出错异常中断,并把引起中断的线性地址放到 CR2 控制寄存器中。因此处理该中断的过程就可以知道发生页异常的确切地址,从而可以把进程要求的页面从二级存储空间(比如硬盘上)加载到物理内存中。如果此时物理内存已经被全部占用,那么可以借助二级存储空间的一部分作为交换缓冲区(Swapper)把内存中暂时不使用的页面交换到二级缓冲区中,然后把要求的页面调入内存中。这也就是内存管理的缺页加载机制,在 Linux 0.12 内核中是在程序 mm/memory.c 中实现。

Intel CPU 使用段(Segment)的概念来对程序进行寻址。每个段定义了内存中的某个区域以及访问的优先级等信息。假定大家知晓实模式下内存寻址原理,现在我们根据 CPU 在实模式和保护模式下寻址方式的不同,用比较的方法来简单说明 32 位保护模式运行机制下内存寻址的主要特点。

在实模式下,寻址一个内存地址主要是使用段和偏移值,段值被存放在段寄存器中(例如 ds),并且段的长度被固定为 64KB。段内偏移地址存放在任意一个可用于寻址的寄存器中(例如 si)。因此,根据段寄存器和偏移寄存器中的值,就可以算出实际指向的内存地址,见图 5-7 (a)所示。

而在保护模式运行方式下,段寄存器中存放的不再是被寻址段的基地址,而是一个段描述符表(Segment Descriptor Table)中某一描述符项在表中的索引值。索引值指定的段描述符项中含有需要寻址的内存段的基地址、段的长度值和段的访问特权级别等信息。寻址的内存位置是由该段描述符项中指定的段基地址值与一个段内偏移值组合而成。段的长度可变,由描述符中的内容指定。可见,和实模式下的寻址相比,段寄存器值换成了段描述符表中相应段描述符的索引值以及段表选择位和特权级,称为段选择符(Segment Selector),但偏移值还是使用了原实模式下的概念。这样,在保护模式下寻址一个内存地址就需要比实模式下多一道手续,也即需要使用段描述符表。这是由于在保护模式下访问一个内存段需要的信息比较多,而一个 16 位的段寄存器放不下这么多内容。示意图见图 5-7 (b)所示。注意,如果你不在一个段描述符中定义一个内存线性地址空间区域,那么该地址区域就完全不能被寻址,CPU 将拒绝访问该地址区域。

image-20230916174543032

每个描述符占用 8 个字节,其中含有所描述段在线性地址空间中的起始地址(基址)、段的长度、段的类型(例如代码段和数据段)、段的特权级别和其他一些信息。一个段可以定义的最大长度是 4GB。

保存描述符项的描述符表有 3 种类型,每种用于不同目的。全局描述符表 GDT(Global Descriptor Table)是主要的基本描述符表,该表可被所有程序用于引用访问一个内存段。中断描述符表 IDT(Interrupt Descriptor Table)保存有定义中断或异常处理过程的段描述符。IDT 表直接替代了 8086 系统中的中断向量表。为了能在 80X86 保护模式下正常运行,我们必须为 CPU 定义一个 GDT 表和一个 IDT 表。最后一种类型的表是局部描述符表 LDT(Local Descriptor Table)。该表应用于多任务系统中,通常每个任务使用一个 LDT 表。作为对 GDT 表的扩充,每个 LDT 表为对应任务提供了更多的可用描述符项,因而也为每个任务提供了可寻址内存空间的范围。这些表可以保存在线性地址空间的任何地方。为了让 CPU 能定位 GDT 表、IDT 表和当前的 LDT 表,需要为 CPU 分别设置 GDTR、IDTR 和 LDTR 三个特殊寄存器。这些寄存器中将存储对应表的 32 位线性基地址和表的限长字节值。表限长值是表的长度值-1。

当 CPU 要寻址一个段时,就会使用 16 位的段寄存器中的选择符来定位一个段描述符。在 80X86 CPU中,段寄存器中的值右移 3 位即是描述符表中一个描述符的索引值。13 位的索引值最多可定位 8192(0–8191)个的描述符项。选择符中位 2(TI)用来指定使用哪个表。若该位是 0 则选择符指定的是 GDT表中的描述符,否则是 LDT 表中的描述符。

每个程序都可有若干个内存段组成。程序的逻辑地址(或称为虚拟地址)即是用于寻址这些段和段中具体地址位置。在 Linux 0.12 中,程序逻辑地址到线性地址的变换过程使用了 CPU 的全局段描述符表GDT 和局部段描述符表 LDT。由 GDT 映射的地址空间称为全局地址空间,由 LDT 映射的地址空间则称为局部地址空间,而这两者构成了虚拟地址的空间。具体的使用方式见图 5-8 所示。

image-20230916174733966

图中画出了具有两个任务时的情况。可以看出,每个任务的局部描述符表 LDT 本身也是由 GDT 中描述符定义的一个内存段,在该段中存放着对应任务的代码段和数据段描述符,因此 LDT 段很短,其段限长通常只要大于 24 字节即可。同样,每个任务的任务状态段 TSS 也是由 GDT 中描述符定义的一个内存段,其段限长也只要满足能够存放一个 TSS 数据结构就够了。

对于中断描述符表 idt,它保存在内核代码段中。由于在 Linux 0.12 内核中,内核和各任务的代码段和数据段都分别被映射到线性地址空间中相同基址处,且段限长也一样,因此内核的代码段和数据段是重叠的,各任务的代码段和数据段分别也是重叠的,参见图 5-10 或图 5-11 所示。任务状态段 TSS(Task State Segment)用于在任务切换时 CPU 自动保存或恢复相关任务的当前执行上下文(CPU 当前状态)。例如对于切换出的任务,CPU 就把其寄存器等信息保存在该任务的 TSS 段中,同时 CPU 使用新切换进任务的 TSS 段中的信息来设置各寄存器,以恢复该任务的执行环境,参见图 4-37 所示。在 Linux 0.12中,每个任务的 TSS 段内容被保存在该任务的任务数据结构中。另外,Linux 0.12 内核中没有使用到 GDT表中第 4 个描述符(图中 syscall 描述符项)。从如下所示的 include/linux/sched.h 文件中第 201 行上的原英文注释可以猜想到,Linus 当时设计内核时曾经想把系统调用的代码放在这个专门独立的段中。

image-20230916174909158

5.3.5CPU 多任务和保护方式

Intel 80X86 CPU 共分 4 个保护级,0 级具有最高优先级,而 3 级优先级最低。Linux 0.12 操作系统使用了 CPU 的 0 和 3 两个保护级。内核代码本身会由系统中的所有任务共享。而每个任务则都有自己的代码和数据区,这两个区域保存于局部地址空间,因此系统中的其他任务是看不见的(不能访问的)。而内核代码和数据是由所有任务共享的,因此它保存在全局地址空间中。图 5-13 给出了这种结构的示意图。图中同心圆代表 CPU 的保护级别(保护层),这里仅使用了 CPU 的 0 级和 3 级。而径向射线则用来区分系统中的各个任务。每条径向射线指出了各任务的边界。除了每个任务虚拟地址空间的全局地址区域,任务 1 中的地址与任务 2 中相同地址处是无关的。

当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(或简称为内核态)。此时处理器处于特权级最高的(0 级)内核代码中执行。当进程处于内核态时,执行的内核代码会使用当前进程的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。即此时处理器在特权级最低的(3 级)用户代码中运行。当正在执行用户程序而突然被中断程序中断时,此时用户程序也可以象征性地称为处于进程的内核态。因为中断处理程序将使用当前进程的内核栈。这与处于内核态的进程的状态有些类似。进程的内核态和用户态将在后面有关进程运行状态一节中作更详细的说明。

image-20230916175057707

5.3.6虚拟地址、线性地址和物理地址之间的关系

前面我们根据内存分段和分页机制详细说明了 CPU 的内存管理方式。现在我们以 Linux 0.12 系统为例,详细说明内核代码和数据以及各任务的代码和数据在虚拟地址空间、线性地址空间和物理地址空间中的对应关系。由于任务 0 和任务 1 的生成或创建过程比较特殊,我们将对它们分别进行描述。

内核代码和数据的地址

对于 Linux 0.12 内核代码和数据来说,在 head.s 程序的初始化操作中已经把内核代码段和数据段都设置成为长度为 16MB 的段。在线性地址空间中这两个段的范围重叠,都是从线性地址 0 开始到地址0xFFFFFF 共 16MB 地址范围。在该范围中含有内核所有的代码、内核段表(GDT、IDT、TSS)、页目录表和内核的二级页表、内核局部数据以及内核临时堆栈(将被用作第 1 个任务即任务 0 的用户堆栈)。其页目录表和二级页表已设置成把 0–16MB 的线性地址空间一一对应到物理地址上,占用了 4 个目录项,即 4 个二级页表。因此对于内核代码或数据的地址来说,我们可以直接把它们看作是物理内存中的地址。此时内核的虚拟地址空间、线性地址空间和物理地址空间三者之间的关系可用图 5-14 来表示。

image-20230916175352567

因此,默认情况下 Linux 0.12 内核最多可管理 16MB 的物理内存,共有 4096 个物理页面(页帧),每个页面 4KB。通过上述分析可以看出:①内核代码段和数据段区域在线性地址空间和物理地址空间中是一样的。这样设置可以大大简化内核的初始化操作。②GDT 和 IDT 在内核数据段中,因此它们的线性地址也同样等于它们的物理地址。在实模式下的 setup.s 程序初始化操作中,我们曾经设置过临时的 GDT和 IDT,这是进入保护模式之前必须设置的。由于这两个表当时处于物理内存大约 0x90200 处,而进入保护模式后内核系统模块处于物理内存 0 开始位置,并且 0x90200 处的空间将被挪作他用(用于高速缓冲),因此在进入保护模式后,在运行的第 1 个程序 head.s 中我们需要重新设置这两个表。即设置 GDTR和 IDTR 指向新的 GDT 和 IDT,描述符也需要重新加载。但由于开启分页机制时这两个表的位置没有变动,因此无须再重新建立或移动表位置。③除任务 0 以外,所有其他任务使用的物理内存页面与线性地址中的页面起码有部分不同,因此内核需要动态地在主内存区中为它们作映射操作,动态地建立页目录项和页表项。虽然任务 1 的代码和数据也在内核中,但由于他需要另行分配获得内存,因此也需要自己的映射表项。

虽然 Linux 0.12 默认可管理 16MB 物理内存,但是系统中并不是一定要有这些物理内存。机器中只要有 4MB(甚至 2MB)物理内存就完全可以运行 Linux 0.12 系统了。若机器只有 4MB 物理内存,那么此时内核 4MB–16MB 地址范围就会映射到不存在的物理内存地址上。但这并不妨碍系统的运行。因为在初始化时内核内存管理程序会知道机器中所含物理内存量的确切大小,因而不会让 CPU 分页机制把线性地址页面映射到不存在的 4MB–16MB 中去。内核中这样的默认设置主要是为了便于系统物理内存的扩展,实际并不会用到不存在的物理内存区域。如果系统有多于 16MB 的物理内存,由于在 init/main.c程序中初始化时限制了对 16MB 以上内存的使用,并且这里内核也仅映射了 0–16MB 的内存范围,因此在 16MB 之上的物理内存将不会用到。

通过在这里为内核增加一些页表,并且对 init/main.c 程序稍作修改,我们可以对此限制进行扩展。例如在系统中有 32MB 物理内存的情况下,我们就需要为内核代码和数据段建立 8 个二级页表项来把32MB 的线性地址范围映射到物理内存上。

任务 0 的地址对应关系

任务 0 是系统中一个人工启动的第一个任务。它的代码段和数据段长度被设置为 640KB。该任务的代码和数据直接包含在内核代码和数据中,是从线性地址 0 开始的 640KB 内容,因此可以它直接使用内核代码已经设置好的页目录和页表进行分页地址变换。同样,它的代码和数据段在线性地址空间中也是重叠的。对应的任务状态段 TSS0 也是手工预设置好的,并且位于任务 0 数据结构信息中,参见include/linux/sched.h 第 156 行开始的数据。TSS0 段位于内核 sched.c 程序的代码中,长度为 104 字节,具体位置可参见图 5-24 中“任务 0 结构信息”一项所示。三个地址空间中的映射对应关系见图 5-15 所示。

在这里插入图片描述

由于任务 0 直接被包含在内核代码中,因此不需要为其再另外分配内存页。它运行时所需要的内核态堆栈和用户态堆栈空间也都在内核代码区中,并且由于在内核初始化时(head.s)这些内核页面在页表项中的属性都已经被设置成了 0b111,即对应页面用户可读写并且存在,因此用户堆栈 user_stack[]空间虽然在内核空间中,但任务 0 仍然能对其进行读写操作。

任务 1 的地址对应关系

与任务 0 类似,任务 1 也是一个特殊的任务。它的代码也在内核代码区域中。与任务 0 不同的是在线性地址空间中,系统在使用 fork()创建任务 1(init 进程)时为存放任务 1 的二级页表而在主内存区申请了一页内存来存放,并复制了父进程(任务 0)的页目录和二级页表项。因此任务 1 有自己的页目录和页表表项,它把任务 1 占用的线性空间范围 64MB–128MB(实际上是 64MB–64MB+640KB)也同样映射到了物理地址 0–640KB 处。此时任务 1 的长度也是 640KB,并且其代码段和数据段相重叠,只占用一个页目录项和一个二级页表。另外,系统还会为任务 1 在主内存区域中申请一页内存用来存放它的任务数据结构和用作任务 1 的内核堆栈空间。任务数据结构(也称进程控制块 PCB)信息中包括任务 1的 TSS 段结构信息。见图 5-16 所示。

image-20230916180115387

任务 1 的用户态堆栈空间将直接共享使用处于内核代码和数据区域(线性地址 0–640KB)中任务 0的用户态堆栈空间 user_stack[](参见 kernel/sched.c,第 82–87 行),因此这个堆栈需要在任务 1 实际使用之前保持“干净”,以确保被复制用于任务 1 的堆栈不含有无用数据。在刚开始创建任务 1 时,任务 0的用户态堆栈 user_stack[]与任务 1 共享使用,但当任务 1 开始运行时,由于任务 1 映射到 user_stack[]处的页表项被设置成只读,使得任务 1 在执行堆栈操作时将会引起写页面异常,从而由内核另行分配主内存区页面作为堆栈空间使用。

其他任务的地址对应关系

对于被创建的从任务 2 开始的其他任务,它们的父进程都是 init(任务 1)进程。我们已经知道,在Linux 0.12 系统中共可以有 64 个进程同时存在。下面我们以任务 2 为例来说明其他任何任务对地址空间的使用情况。

从任务 2 开始,如果任务号以 nr 来表示,那么任务 nr 在线性地址空间中的起始位置将被设定在nr64MB 处。例如任务 2 的开始位置= nr64MB = 2 * 64MB = 128MB。任务代码段和数据段的最大长度被设置为 64MB,因此任务 2 占有的线性地址空间范围是 128MB–192MB,共占用 64MB/4MB = 16 个页目录项。虚拟空间中任务代码段和数据段都被映射到线性地址空间相同的范围,因此它们也完全重叠。图 5-17 显示出了任务 2 的代码段和数据段在三种地址空间中的对应关系。

从任务 2 开始,如果任务号以 nr 来表示,那么任务 nr 在线性地址空间中的起始位置将被设定在nr64MB 处。例如任务 2 的开始位置= nr64MB = 2 * 64MB = 128MB。任务代码段和数据段的最大长度被设置为 64MB,因此任务 2 占有的线性地址空间范围是 128MB–192MB,共占用 64MB/4MB = 16 个页目录项。虚拟空间中任务代码段和数据段都被映射到线性地址空间相同的范围,因此它们也完全重叠。图 5-17 显示出了任务 2 的代码段和数据段在三种地址空间中的对应关系。理一章中的相关描述。

image-20230916180336419

从 Linux 内核 0.99 版以后,对内存空间的使用方式发生了变化。每个进程可以单独享用整个 4G 的地址空间范围。如果我们能理解本节说描述的内存管理概念,那么对于现在所使用的 Linux 2.x 内核中所使用的内存管理原理也能立刻明白。由于篇幅所限,这里对此不再说明。

5.3.7用户申请内存的动态分配

当用户应用程序使用 C 函数库中的内存分配函数 malloc()申请内存时,这些动态申请的内存容量或大小均由高层次的 C 库函数 malloc()来进行管理,内核本身并不会插手管理。因为内核已经为每个进程(除了任务 0 和 1,它们与内核代码一起常驻内存中)在 CPU 的 4G 线性地址空间中分配了 64MB 的空间,所以只要进程执行时寻址的范围在它的 64MB 范围内,内核也同样会通过内存缺页管理机制自动为寻址对应的页面分配物理内存页面并进行映射操作。但是内核会为进程使用的代码和数据空间维护一个当前位置值 brk,这个值保存在每个进程的数据结构中。它指出了进程代码和数据(包括动态分配的数据空间)在进程地址空间中的末端位置。当 malloc()函数为程序分配内存时,它会通过系统调用 brk()把程序要求新增的空间长度通知内核,内核代码从而可以根据 malloc()所提供的信息来更新 brk 的值,但并此时并不为新申请的空间映射物理内存页面。只有当程序寻址到某个不存在对应物理页面的地址时,内核才会进行相关物理内存页面的映射操作。

若进程代码寻址的某个数据所在的页面不存在,并且该页面所处位置属于进程堆范围,即不属于其执行文件映像文件对应的内存范围中,那么 CPU 就会产生一个缺页异常,并在异常处理程序中为指定的页面分配并映射一页物理内存页面。至于用户程序此次申请内存的字节长度数量和在对应物理页面中的具体位置,则均由 C 库中内存分配函数 malloc()负责管理。内核以页面为单位分配和映射物理内存,该函数则具体记录用户程序使用了一页内存的多少字节。剩余的容量将保留给程序再申请内存时使用。

当用户使用内存释放函数 free()动态释放已申请的内存块时,C 库中的内存管理函数就会把所释放的内存块标记为空闲,以备程序再次申请内存时使用。在这个过程中内核为该进程所分配的这个物理页面并不会被释放掉。只有当进程最终结束时内核才会全面收回已分配和映射到该进程地址空间范围的所有物理内存页面。

有关库函数 malloc()和 free()的具体代码实现请参见内核库中的 lib/malloc.c 程序。

转载来源

  1. Linux内核0.12完全注释-修正版5.0-赵烔著
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值