Linux内核设计与实现——进程地址空间

进程地址空间

内核除了管理本身的内存外,还必须管理用户空间中进程的内存,称这个内存为进程地址空间。Linux中的进程以虚拟方式共享内存。

1. 地址空间

  1. 进程地址空间由进程可寻址的虚拟内存组成。每个进程都有一个32位或64位的平坦地址空间。“平坦”指地址空间范围是一个独立的连续区间(比如从0扩展到4294967295的32位地址空间)。通常每个进程都有唯一的平坦地址空间。一些操作系统提供了段地址空间,这种地址空间并非是独立的线性区域,而是被分段的。一个进程的地址空间与另一个进程的地址空间即使有相同的内存地址,实际上也互不相干,这样的进程称为线程。

  2. 一些虚拟内存的地址区间(如08048000-0804c000)可被进程访问,这些合法地址空间称为内存区域。通过内核,进程可以给自己的地址空间动态地添加或减少内存区域。每个内存区域具有相关权限如对相关进程有可读、可写、可执行属性。如果一个进程访问了不在有效范围中的内存区域,或以不正确的方式访问了有效地址,那么内核就会终止该进程,并返回“段错误"信息。

  3. 内存区域可以包含各种内存对象,比如:

    a. 可执行文件代码的内存映射,称为代码段。

    b. 可执行文件的已初始化全局变量的内存映射,称为数据段。

    c. 包含未初始化全局变量,也就是bss段的零页(页面中的信息全部为0值,可用于映射bss段等)的内存映射。"BSS"是block started by symbol的缩写。

    d. 用于进程用户空间栈(不是进程内核栈,进程的内核栈独立存在并由内核维护)的零页的内存映射。

    e. 每一个诸如C库或动态连接程序等共享库的代码段、数据段和bss也会被载入进程的地址空间。

    f. 任何内存映射文件。

    g. 任何共享内存段。

    h. 任何匿名的内存映射,比如由malloc()分配的内存。在最新版本的glibc中,通过mmap()brk()来实现malloc()函数。

    进程地址空间中的任何有效地址都只能位于唯一的区域,这些内存区域不能相互覆盖。在执行的进程中,每个不同的内存片段都对应一个独立的内存区域:栈、对象代码、全局变量、被映射的文件等。

2. 内存描述符

内核使用内存描述符结构体表示进程的地址空间,该结构包含了和进程地址空间有关的全部信息。内存描述符由mm_struct结构体表示, 定义在<linux/sched.h>中:

struct mm_struct{
        struct vm_area_struct 	*mmap;					/* 内存区域链表 */
        struct rb_root 			mm_rb;					/* VMA形成的红果树 */
        struct vm_area_struct 	*mmap_cache;			/* 最近使用的内存区域 */
        unsigned long 			free_area_cache;  		/* 地址空间第一个空洞 */
        pgd_t 					*pgd;					/* 页全局目录 */
        atomic_t 				mm_users;				/* 使用地址空间的用户数 */
        atomic_t 				mm_count;				/* 主使用计数器 */
        int 					map_count;				/* 内存区域的个数 */
        struct rw_semaphore 	mmap_sem;				/* 内存区城的信号量 */
        spinlock_t 				page_table_lock;		/* 页表锁 */
        struct list_head 		mmlidt;					/* 所有mm_struct形成的链表 */
        unsigned long 			start_code;				/* 代码段的开始地址 */
        unsigned long 			end_code;				/* 代码段的结束地址 */
        unsigned long 			start_data;				/* 数据的首地址 */
        unsigned long 			end_data;				/* 数据的尾地址 */
        unsigned long 			start_brk;				/* 堆的首地址 */
        unsigned long 			brk;					/* 堆的尾地址 */
        unsigned long 			start_stack;			/* 进程栈的首地址 */
        unsigned long 			arg_start;				/* 命令行参教的首地址 */
        unsigned long 			arg_end;				/* 命令行参数的尾地址 */
        unsigned long 			env_start;				/* 环境变量的首地址 */
        unsigned long 			env_end;				/* 环境变量的尾地址 */
        unsigned long 			rss;					/* 所分配的物理页 */
        unsigned long 			total_vm				/* 全部页面数目 */
        unsigned long 			locked_vm;				/* 上锁的页面数目 */
        unsigned long 			saved_anxv[AT_VECTOR_SIZE]; /* 保存的auxv */
        cpumask_t 				cpu_vm_mask;			/* 懒情(lazy)TLB交换掩码 */
        mm_context_t 			context;				/* 体系结构特殊数据 */
        unsigned long 			flags;					/* 状态标志 */
        int 					core_waiters;			/* 内核转储等待线程 */
        struct core_state 		*core_state;			/* 核心转储的支持 */
        spinlock_t 				ioctx_lock;				/* AIO I/O链表锁 */
        struct hlist_head 		ioctx_list;				/* AIO I/O链表 */
}
  1. 所有的mm_users都等于mm_count的增加量。当mm_users的值减为0时,mm_count域的值才变为0。当mm_count的值等于0时该结构体会被撤销。当内核在一个地址空间上操作,并需要使用与该地址相关联的引用计数时,内核便增加mm_count

  2. mmapmm_rb描述相同的对象:该地址空间中的全部内存区域。前者以链表形式存放而后者以红-黑树的形式存放。内核并没有复制mm_struct结构体,而仅仅被包含其中。覆盖树上的链表并用这两个结构体同时访问相同的数据集,此操作称作线索树。

  3. 所有的mm_struct结构体都通过自身的mmlist域连接在一个双向链表中,该链表的首元素是init_mm内存描述符,它代表init进程的地址空间。操作该链表的时候需要使用mmlist_lock锁,定义在kernel/fork.c中.

(1) 分配内存描述符

  1. 在进程的进程描述符(<linux/sched.h>中定义的task_struct结构体)中,mm域存放着该进程使用的内存描述符,所以current->mm指向当前进程的内存描述符。fork()函数利用copy_mm()函数复制父进程的内存描述符,也就是current->mm域给其子进程,而子进程中的mm_struct结构体是通过kernel/fork.c中的allocate_mm()宏从mm_cachep slab缓存中分配得到的。通常,每个进程都有唯一的mm_struct结构体,即唯一的进程地址空间。

  2. 如果父进程希望和其子进程共享地址空间,可以在调用clone()时,设置CLONE_VM标志,这样的进程称作线程。当CLONE_VM被指定后,内核就不需要调用allocate_mm()函数了,仅需要在调用copy_mm()函数中将mm域指向其父进程的内存描述符就可以了:

if(clone_flags & CLONE_VM) {
        /*
         * current是父进程而tak在fork()执行期间是子进程
         */
        atomic_inc(&current->mm->mm_users);
        tsk->mm=current->mm;
}

(2) 撤销内存描述符

进程退出时内核会调用定义在kernel/exit.c中的exit_mm()函数,该函数执行一些常规的撤销工作,同时更新一些统计量。其中,该函数会调用mmput()函数减少内存描述符中的mm_users用户计数,如果用户计数降到零,将调用mmdrop()函数减少mm_count使用计数。如果使用计数等于零,那么调用free_mm()宏通过kmem_cache_free()函数将mm_struct结构体归还到mm_cachep slab缓存中。

(3) **mm_struct**与内核线程

  1. 内核线程没有进程地址空间,所以内核线程对应的进程描述符中mm域为空。内核线程没有用户上下文,内核线程不需要访问用户空间的内存。内核线程在用户空间中没有页,不需要有自己的内存描述符和页表,内核线程使用前一个进程的内存描述符和页表。

  2. 当一个进程被调度时,该进程的mm域指向的地址空间被装载到内存,进程描述符中的active_mm域会被更新,指向新的地址空间。当一个内核线程被调度时,内核会保留前一个进程的地址空间,随后更新內核线程对应的进程描述符中的active_mm域,使其指向前一个进程的内存描述符。

3. 虚拟内存区域

内存区域由vm_area_struct结构体描述,定义在<linux/mm_types.h> 中。内存区域在Linux内核中也称作虚拟内存区域(virtual memoryAreas, VMAs)。vm_area_struct结构体描述了指定地址空间内连续区间上的一个独立内存范围。内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性,比如访问权限等,相应的操作也一致。每一个VMA可以代表不同类型的内存区域(比如内存映射文件或者进程用户空间栈),这种管理方式类似于使用VFS层的面向对象方法,下面给出该结构定义和各个域的描述:

struct vm_area_struct {
    struct mm_struct 		*vm_mm;			/* 相关的mm_struct結构体 */
    unsigned long 			vm_start; 		/* 区间的首地址 */
    unsigned long 			vm_end;			/* 区间的尾地址 */
    struct vm_area_struct 	*vm_next; 		/* VMA链表 */
    pgprot_t 				vm_page_prot; 	/* 访问控制权限 */
    unsigned long 			vm_flags;		/* 标志 */
    struct rb_node 			vm_rb;			/* 树上该VMA的节点 */
    union {			/* 或者是关联于addree_space->i_mmap字段,或者是关联于
    address_space->i_mmap_nonlinear字段 */
            struct {
                struct list_head 		list;
                void 					*parent;
                struct vm_area_struct 	*head;
            } vm_set;
            struct prio_tree_node prio_tree_node;
    } shared;
    struct list_head 				anon_vma_node;			/* anon_vma项 */
    struct anon_vma 				*anon_vma;				/* 匿名VMA对象 */
    struct vm_operations_struct 	*vm_ops;				/* 相关的操作表 */
    unsigned long 					vm_pgoff;				/* 文件中的偏移量 */
    struct file 					*vm_file;				/* 被映射的文件(如果存在) */
    void 							*vm_private_data;		/* 私有数据 */
};
  1. 每个内存描述符都对应于进程地址空间中的唯一区间。vm_start域指向区间的首地址(最低地址,它本身在区间内),vm_end域指向区间的尾地址(最高地址,它本身在区间外)之后的第一个字节,vm_end - vm_start便是内存区间的长度。同一个地址空间内的不同内存区间不能重叠。

  2. vm_mm域指向和VMA相关的mm_struct结构体,每个VMA对其相关的mm_struct结构体都是唯一的,即使两个独立的进程将同一个文件映射到各自的地址空间,它们分别都会有一个vm_area_struct结构体来标志自己的内存区域;如果两个线程共享一个地址空间,它们也同时共享其中的所有vm_area_struct结构体。每个和进程相关的内存区域都对应于一个vm_area_struct结构体。不同于线程,进程结构体stask_struct包含唯一的 mm_struct结构体引用。

(1) VMA标志

VMA标志是一种位标志,定义于<linux/mm.h>,包含在vm_flags域内,标志了内存区域所包含的页面的行为和信息。vm_flags包含了内存区域中每个页面的信息,或内存区域的整体信息,而不是具体的独立页面。表15-1列出了所有VMA标志的可能取值。
在这里插入图片描述

  1. VM_SHARD指明了内存区域包含的映射是否可以在多进程间共享,如果该标志被设置,则称为共享映射;如果未被设置,称为私有映射。
  2. VM_IO标志通常在设备驱动程序执行mmap()函数进行I/O空间映射时才被设置,该标志也表示该内存区域不能被包含在任何进程的存放转存中。VM_RESERVED标志也是在设备驱动程序进行映射时被设置。
  3. VM_SEQ_READ标志暗示内核应用程序对映射内容执行有序的(线性和连续的)读操作,这样,内核可以有选择地执行预读文件。VM_RAND_READ标志暗示应用程序对映射内容执行随机的读操作,因此内核可以有选择地减少或取消文件预读。这两个标志可以通过系统调用madvise()设置,设置参数分别是MADV_SEQUENTIALMADV_RANDOM。文件预读指在读数据时有意地按顺序多读取一些本次请求以外的数据。

(2) VMA操作

  1. vm_area_struct结构体中的vm_ops域指向与指定内存区域相关的操作函数表,内核使用表中的方法操作VMA。vm_area_struct作为通用对象代表了任何类型的内存区域,而操作表描述针对特定的对象实例的特定方法。操作函数表由vm_operations_struct结构体表示,定义在<linux/mm.h>中:
struct vm_operations_struct {
        void (*open) (struct vm_area struct *);
    	void (*close) (struct vm_area struct *);
        int (*fault) (struct vm_area struct *, struct vm_fault *);
        int (*page_mkwrite) (struct vm_area struct *vma, struct vm_fault *vmf) ;
        int (*access) (struct vm_area_struct *, unsigned long,
                       void *, int, int);
}
  1. 下面介绍具体方法:
*void open(struct vm_area_struct *area)

​ 当指定的内存区域被加入到一个地址空间时,该函数被调用。

*void close(struct vm_area_struct *area)

​ 当指定的内存区域从地址空间删除时,该函数被调用。

*int fault(struct vm_area_struct *area, struct vm_fault *vmf)

​ 当没有出现在物理内存中的页面被访问时,该函数被页面故障处理调用。

*int page_mkwrite(struct vm_area_struct *area, struct vm_fault *vmf)

​ 当某个页面为只读页面时,该函数被页面故障处理调用。

*int access(struct vm_area_struct *vma, unsigned long address, void
*buf, int len, int write)

​ 当get_user_pages()函数调用失败时,该函数被access_process_vm()函数调用。

(3) 实际使用中的内存区域

下面列出某进程地址空间中包含的内存区域,其中有代码段、数据段和bss段等。假设该进程与C库动态连接,那么地址空间中还分别包含libc.so和ld.so对应的上述三种内存区域。地址空间中还包含进程栈对应的内存区域。可以使用/proc文件系统和**pmap(1)**工具查看给定进程的内存空间和其中所含的内存区域,**pmap(1)**工具将进程的内存区域格式化后显示,在新版本的procps包中可以找到这个工具。/proc//maps的输出显示了该进程地址空间中的全部内存区域:

rlove@wolf:-$ cat /proc/1426/maps
00e80000-00faf000 r-xp 00000000 03:01 208530	/lib/tls/libc-2.5.1.so
00faf000-00fb2000 rw-p 0012f000 03:01 208530	/lib/tls/libc-2.5.1.so
00fb2000-00fb4000 rw-p 00000000 00:00 0
08048000-08049000 r-xp 00000000 03:03 439029	/home/rlove/src/example
08049000-0804a000 rw-p 00000000 03:03 439029	/home/rlove/src/example
40000000-40015000 r-xp 00000000 03:01 80276		/lib/ld-2.5.1.so
40015000-40016000 rw-p 00015000 03:01 80276		/lib/ld-2.5.1.so
4001e000-4001f000 rw-p 00000000 00:00 0
bfffe000-c0000000 rwxp ffff£000 00:00 0

每行数据格式如下:

开始-结束 访问权限  偏移  主设备号:次设备号 i节点  文件
pmap(1)工具将上述信息以更方便阅读的形式输出:
rlove@wolf:-$ pmap 1426
example [1426]
00e80000(1212KB)  r-xp	  (03:01 208530)		/lib/tls/libc-2.5.1.so
00faf000(12KB)	  rw-p	  (03:01 208530)		/lib/tls/libc-2.5.1.so
00fbz000(8KB)	  rw-p	  (00:00 0)
08048000(4KB) 	  r-xp	  (03:03 439029) 		/home/rlove/src/example
08049000(4KB) 	  rw-p	  (03:03 439029) 		/home/rlove/src/example
40000000(84KB) 	  r-xp	  (03:01 80276) 		/lib/ld-2.5.1.so
40015000(4KB) 	  rw-p	  (03:01 80276) 		/lib/ld-2.5.1.so
4001e000(4KB) 	  rw-p	  (00:00 0)
bfffe000(8KB) 	  rwxp	  (00:00 0) 			[stack]
mapped :1340KB	  writable/private : 40KB 		shared :0KB
  1. 前三行分别对应C库中lic.so的代码段、数据段和bss段,接下来的两个行为可执行对象的代码段和数据段,再下来三个行为动态连接程序ld.so的代码段、数据段和bss段,最后一行是进程的栈。代码段具有可读可执行权限;数据段和bss(都包含全局数据变量)具有可读可写但不可执行权限;堆栈则可读可写可执行。

  2. 如果一片内存范围是共享的或不可写的,那么内核只需要在内存中为文件保留一份映射(映射只用来读)。所以C库在物理内存中仅需要占用1212KB,不需要为每个使用C库的进程在内存中都保存一个1212KB的空间。进程访向了1340KB的数据和代码空间,仅消耗了40KB的物理内存。

  3. 没有映射文件的内存区域的设备标志为00:00,索引接点标志也为0,这个区域就是零页。如果将零页映射到可写的内存区域,那么该区域全被初始化为0。由于内存未被共享,所以只要一有进程写该处数据,那么该处数据就将被拷贝出来(写时拷贝),然后才被更新。

4. 操作内存区域

(1) find_vma()

struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr);

该函数定义在<mm/mmap.c>中。该函数在指定的地址空间中搜索第一个vm_end大于addr的内存区域,如果没有发现则返回NULL;否则返回指向匹配的内存区域的vm_area_struct结构体指针。由于返回的VMA首地址可能大于addr,所以指定的地址不一定包含在返回的VMA中。find_vma()函数返回的结果被缓存在内存描述符的mmap_cache域中。被缓存的VMA会有很好的命中率(实践中大约30%~40%),如果指定的地址不在缓存中,那么必须搜索和内存描述符相关的所有内存区域(通过红-黑树进行):

struct vm_area_struct * find_vma(struct mm_struct *mm, unsigned long addr)
{
        struct vm_area_struct *vma = NULL;
    
        if (mm) {
                vma = mm->mmap_cache;
                if (!(vma && vma->vm_end > addr && vma->vm_start <= addr)) {
                        struct rb_node *rb_node;
                    
                        rb_node = mm->mm_rb.rb_node;
                        vma = NULL;
                        while (rb_node) {
                                struct vm_area_struct * vma_tmp;
                            
                                vma_tmp = rb_entry(rb_node,
                                                   struct vm_area_struct, vm_rb);
                                if (vma_tmp->vm_end > addr) {
                                        vma = vma_tmp;
                                        if (vma_tmp->vm_start <= addr)
                                        		break;
                                        rb_node = rb_node->rb_left:
                                } else
                                        rb_node = rb_node->rb_right;
                        }
                        if (vma)
                        		mm->mmap_cache = vma;
                }
            
        }
    
        return vma;
}

(2) find_vma_prev()

find_vma_prev()函数和find_vma()工作方式相同,但是它返回第一个小于addr的VMA。该函数定义和声明分别在mm/mmap.c中和<linux/mm.h>中:

struct vm_area_struct * find_vma_prev(struct mm_struct *mm, unsigned long addr,
                                      struct vm_area_struct **pprev)

pprev参数存放指向先于addr的VMA指针。

(3) find_vma_intersection()

find_vma_interection()函数返回第一个和指定地址区间相交的VMA。该函数是内联函数,定义在<linux/mm.h>中:

static inline struct vm_area_struct *
find_vma_intersection(struct mm_struct *mm,
                      unsigned long start_addr,
                      unsigned long end_addr)
{
        struct vm_area_struct *vma;
    
        vma = find_vma(mm, start_addr);
        if (vma && end_addr <= vma->vm_start)
        		vma = NULL;
        return vma;
}

第一个参数mm是要搜索的地址空间,start_addr是区间的开始首位置,end_addr是区间的尾位置。

5. 创建地址区间

  1. 内核使用do_mmap()函数创建一个新的线性地址区间。如果创建的地址区间和一个已经存在的地址区间相邻且它们具有相同的访向权限,两个区间将合并为一个。do_mmap()函数定义在<linux/mm.h>中。
unsigned long do_mmap(struct file *file, unsigned long addr,
                      unsigned long len, unsigned long prot,
                      unsigned long flag, unsigned long offset)

​ a. 该函数映射由file指定的文件中从偏移offset处开始,长度为len字节的范围内的数据。如果file参数是NULL并且offset参 数也是0,那么代表这次映射没有和文件相关,称作匿名映射;如果指定了文件名和偏移量,称为文件映射。

​ b. addr是可选参数,它指定搜索空闲区域的起始位置。

​ c. prot参数指定内存区域中页面的访向权限。访问权限标志定义在<asm/mman.h>中,不同体系结构有不同定义的标志,但所有体 系结构都会包含表15-2中所列举的标志。
在这里插入图片描述
​ d.flag参数指定了VMA标志,这些标志指定类型并改变映射的行为,也在<asm/mman.h>中定义,参看表15-3。
在这里插入图片描述
​ 如果系统调用do_mmap()的参数中有无效参数,那么返回一个负值。如果新区域不能和邻近区域合并,内核就从vm_area_cachep 长字节(slab)缓存中分配一个vm_area_struct结构体,并且使用vma_link()函数将新分配的内存区域添加到地址空间的内存区域 链表和红-黑树中,随后更新内存描述符中的total_vm域,然后返回新分配的地址区间的初始地址。

  1. 在用户空间可以通过mmap()系统调用获取内核函数do_mmap()的功能。mmap()系统调用定义如下:
void * mmap2(void *start,
             size_t length,
             int prot,
             int flags,
             int fd,
             off_t pgoff)

​ 该系统调用是mmap()调用的第二种变种。mmap()调用中最后一个参数是字节偏移量,而mmap2()使用页面偏移作最后一个参数, 使用页面偏移量可以映射更大的文件和更大的偏移位置。mmap()调用由POSIX定义,仍在C库中作为mmap()方法使用,但内核中没 有对应的实现,对mmap()方法的调用是通过将字节偏移转化为页面偏移,从而转化为对mmap2()函数的调用来实现的。

6. 删除地址区间

do_mummap()函数从特定的进程地址空间中删除指定地址区间,定义在<linux/mm.h>中:

int do_mummap(struct mm_struct *mm, unsigned long start, size_t len)

第一个参数指定要删除区域所在的地址空间,删除从start开始,长度为len字节的地址区间。如果成功返回零,否则返回负的错误码。

系统调用munmap()给用户空间程序提供一种从自身地址空间中删除指定地址区间的方法:

int munmap(void *start, size_t length)

该系统调用定义在mm/mmap.c中,是对do_munmap()函数的一个简单封装:

asmlinkage long sys_munmap(unsigned long addr, size_t len)
{
        int ret;
        struct mm_struct *mm;
    
        mm = current->mm;
        down_write(&mm->mmap_sem);
        ret = do_munmap(mm, addr, len);
    	up_write(&mm->mmap_sem);
        return ret;
}

7. 页表

  1. 应用程序操作映射到物理内存之上的虚拟内存,处理器直接操作物理内存。当用程序访向一个虚拟地址时,首先必须将虚拟地址转化成物理地址,然后处理器才能解析地址访问请求。地址的转换工作通过查询页表完成,地址转换需要将虚拟地址分段,使每段虚拟地址都作为一个索引指向页表,页表项指向下一级页表或者指向最终的物理页面。

  2. Linux中使用三级页表完成地址转换,能够节约地址转换需占用的存放空间。Linux对所有体系结构,包括对不支持三级页表的体系结构(比如,有些体系结构只使用两级页表或者使用散列表完成地址转换)都使用三级页表管理,可以按照需要在编译时简化使用页表的三级结构,比如只使用两级。

    a. 顶级页表是页全局目录(PGD),它包含一个pgd_t类型数组,多数体系结构中pgd_t类型等同于无符号长整型类型。PGD中的表项 指向二级页目录中的表项:PMD。

    b. 二级页表是中间页目录(PMD),它是个pmd_t类型数组,其中的表项指向PTE中的表项。

    c. 最后一级页表简称页表,其中包含了pte_t类型的页表项,该页表项指向物理页面。

  3. 每个进程都有自己的页表(线程会共享页表)。内存描述符的pgd域指向的就是进程的页全局目录。操作和检索页表时必须使用page_table_lock锁,该锁在相应的进程的内存描述符中。页表对应的结构体依赖于具体的体系结构,定义在<asm/page.h>中。

  4. 多数体系结构都实现了一个翻译后缓冲器(translate lookaside buffer, TLB)。TLB作为一个将虚拟地址映射到物理地址的硬件缓存,当请求访问一个虚拟地址时,处理器先检查TLB中是否缓存了该虚拟地址到物理地址的映射,如果没有才通过页表搜索。

  5. 多数体系结构中搜索页表的工作由硬件完成。2.6版内核对页表管理的主要改进是:从高端内存分配部分页表。今后可能的改进包括通过在写时拷贝的方式共享页表,这使得在fork()操作中可由父子进程共享页表。因为只有当子进程或父进程试图修改特定页表项时,内核才创建该页表项的新拷贝,此后父子进程不再共享该页表项。而利用共享页表可以消除fork()操作中拷贝页表的消耗。

注:本文摘自《Linux内核设计与实现(第三版)》
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值