linux下进程

从内核的观点来看,进程的目的就是担当分配系统资源(CPU时间,内存等)的实体。

尽管父子进程共享含有程序代码的页,但是他们各自有独立的数据拷贝(堆和栈),因此子进程对一个内存单元的修改对父进程是不可见的。

多线程应用程序多个执行流的创建,处理,调度整个都是在用户态进行的(通常使用POSIX兼容的pthread库)。

两个轻量级进程基本上可以共享一些资源,诸如地址空间,打开的文件等等,只要其中一个修改共享资源,另一个就可以立即查看这种修改。

进程描述符中state字段描述了进程当前所处的状态:

1.TASK_RUNNING 2.TASK_INTERRUPTIBLE 3.TASK_UNINTERRUPTIBLE 4.TASK_STOPPED 5.TASK_TRACED

还有两个进程状态是既可以存放在进程描述符的state字段中,也可以存放在exte_state字段中

 1.EXIT_ZOMBIE 2.EXIT_DEAD(由于父进程刚发出wait4()或waitpid()系统调用,因而进程由系统删除)

内核也使用set_task_state()和set_current_state()宏,他们分别设置指定进程的状态和当前执行进程的状态。

一般来说,能被独立调度的每个执行上下文都必须拥有它自己的进程描述符(task_struct)。

内核对进程的大部分引用是通过进程描述符指针进行的。

PID被顺序编号,新创建进程的PID通常是前一个进程的PID加1.

一个线程组中的所有线程使用和该线程组的领头线程(thread group leader)相同的PID,也就是该组中第一个轻量级进程的PID,他被存入进程描述符的tgid字段中。

内存必须能够同时处理很多进程,并把进程描述符存放在动态内存中,并不是放在永久分配给内核的内存区。????

linux都把两个不同的数据结构紧凑地存放在一个单独为进程分配的存储区域内:一个是与进程描述符相关的小数据结构thread_info,叫做线程描述符。另一个是内核态的进程堆栈。这个存储区域的大小通常为8192个字节。

当使用一个页框存放内核态堆栈和thread_info结构时,内核要采用一些额外的栈防止中断和异常的深度嵌套而引起的溢出。

从用户态刚切换到内核态以后,进程的内核栈总是空的。

为了获得当前在cpu上运行进程的描述符指针,内核要调用current宏,该宏本质上等价于cuuent_thread_info()->task;

对每个链表,必须实现一组原语操作,初始化链表,插入和删除一个元素,扫描链表等。

进程链表把所有进程的描述符链接起来。每个task_struct结构都包含一个list_head类型的tasks字段。

进程链表的头是init_task描述符,它是所谓的0进程或swapper进程的进程描述符。

for_each_process()宏的功能是扫描整个进程链表。

enqueue_task()函数把进程描述符插入某个运行队列的列表。

进程0和进程1是由内核创建的。

顺序扫描进程链表并检查进程描述符的pid字段是可行但相当低效的。为了加速查找引入了几个散列表。

内核初始期间(pidhash_init)动态地分为几个散列表分配空间,并把它们的地址存入pid_hash数组。

两个不同的PID散列到相同的表索引称为冲突。linux利用链表来处理冲突的PID。

运行队列链表把处于TASK_RUNNING状态的所有进程组织在一起。

等待队列在内核中有很多用途,尤其用在中断处理,进程同步及定时。

等待队列表示一组睡眠的进程,当某一条件变为真时,由内核唤醒他们。

等待队列由双向链表实现。每个等待队列都有一个等待队列头,等待队列头是一个类型为wait_queue_head_t的数据结构,同步是通过等待队列头中的lock自旋锁达到的。

等待队列链表中的每个元素代表一个睡眠进程,该进程等待某一事件的发生。

有两种睡眠进程,互斥进程(等待队列元素的flags字段为1)由内核有选择的唤醒,而非互斥进程(flags值为0)总是由内核在事件发生时唤醒。

等待队列元素的func字段用来表示等待队列中睡眠进程应该用什么方式唤醒。


用DECLARE_WAIT_QUEUE_HEAD(name)宏定义一个新等待队列的头,它静态地声明一个叫name的等待队列的头变量并对该变量的lock和task_list字段进行初始化。函数init_waitqueue_head()可以用来初始化动态分配的等待队列的头变量。

也可以选择DEFINE_WAIT宏声明一个wait_queue_t类型的新变量,并用CPU上运行的当前进程的描述符和唤醒函数autoremove_wake_function()地址初始化这个新变量。

内核开发者可以通过init_waitqueue_func_entry()函数来自定义唤醒函数,该函数负责初始化等待队列的元素。

一旦定义了一个元素,必须把它插入等待队列。add_wait_queue()函数把一个非互斥进程插入等待链表的第一个位置。

在linux2.6中引入prepare_to_wait(),prepare_to_wait_exclusive()和finish_wait()函数提供了另外一种途径来使当前进程在一个等待队列中睡眠。

sleep_on()函数在一下条件下不能使用,那就是必须测试条件并且当条件还没有得到验证时又紧接着让进程去睡眠。

每个进程都有一组相关的资源限制,限制制定了进程能使用的系统资源数量。对当前进程的资源限制存放在current->signal->rlim字段。

尽管每个进程可以拥有属于自己的地址空间,但所有进程必须共享CPU寄存器。因此,在恢复一个进程的执行之前,内核必须确保每个寄存器装入了挂起进程时的值。

进程恢复执行前必须装入寄存器的一组数据称为硬件上下文,硬件上下文是进程可执行上下文的一个子集。

进程切换只发生在内核态,在执行进程切换之前,用户态进程使用的所有寄存器内容都已保存在内核态堆栈上。

每个进程描述符包含一个类型为thread_struct的thread字段,只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。

从本质上讲,每个进程切换两步组成:

1.切换页全局目录以安装一个新的地址空间

2. 切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息。

switch_to宏是内核中与硬件关系最密切的例程之一。

在任何进程切换中,涉及到三个进程而不是两个进程。

1. 写时复制技术允许父子进程读相同的物理页。只要两者中有一个试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理页,并把这个新的物理页分配给正在写的进程。

2.轻量级进程允许父子进程共享每进程在内核的很多数据结构如页表(也就是整个用户态地址空间),打开文件表及信号处理

3.vfork()系统调用创建的进程能共享其父进程的内存地址空间

在linux中,轻量级进程是由名为clone()的函数创建的。

实际上clone()是在C语言库中定义的一个封装函数,它负责建立新轻量级进程的堆栈并且调用对编程者隐藏的clone()系统调用。

传统的fork()调用在linux中是用clone()实现的,其中的clone()的flags参数指定为SIGCHLD信号以及所有清零的clone标志,而它的child_stack参数是父进程当前的堆栈指针。

vfork()系统调用在linux中也是用clone()实现的,其中clone()的参数flags指定为SIGCHLD信号和CLONE_VM以及CLONE_VFORK标志,clone()的参数child_stack等于父进程当前的栈指针。

do_fork()函数负责处理clone(),fork(),vfork()系统调用。利用辅助函数copy_process()来创建进程描述符以及子进程执行所需要的所有其他内核数据结构。

在linux中,内核线程在以下方面不同于普通进程:

1 内核线程只运行在内核态,而普通进程既可以运行在内核态,也可以运行在用户态

2 因为内核线程只运行在内核态,他们呢只能使用大于PAGE_OFFSET的线性地址空间。另外不管用户态还是内核态,普通进程可以用4GB的线性地址空间。

kernel_thread()函数创建一个新的内核线程。

所有进程的祖先叫做进程0,idle进程,或因为历史原因叫做swapper进程,它是linux的初始化阶段从无到有创建的一个内核线程。这个祖先进程使用静态分配的数据结构(所有其他进程的数据结构都是动态分配的).

start_kernel()函数初始化内核需要的所有数据结构,激活中断,创建另外一个进程1的内核线程(一般叫做init进程)。新创建内核线程的PID为1,并与进程0共享每进程所有的内核数据结构。

创建init进程后,进程0执行cpu_idle()函数。只有当没有其他进程处于TASK_RUNNING状态时,调度程序才选择进程0.

init内核线程变为一个普通进程,且拥有自己的每进程内核数据结构。在系统关闭之前,init进程一直存活,因为它创建和监控在操作系统外层执行的所有进程的活动。

进程终止的一般方式是调用exit()函数,该函数释放C函数库所分配的资源,执行编程所注册的那个函数,并结束从系统回收进程的那个系统调用。

所有进程的终止都是由do_exit()函数来处理的,这个函数从内核数据结构中删除对终止进程的大部分引用。

Unix允许进程查询内核以获得其父进程的PID,或者其任何子进程的执行状态。

releas_task(0函数从僵死进程的描述符中分离出最后的数据结构,对僵死进程的处理有两种可能的方式

1如果父进程不需要接受来自子进程的信号,就调用do_exit();内存的回收将由进程调度程序来完成

2如果已经给父进程发送了一个信号,就调用wait4()和waitpid()系统调用,函数还将回收进程描述符所占用的内存空间


linux调度基于分时技术,多个进程以”时间多路复用“方式运行。因为CPU的时间被分成”片“,给每个可运行进程分配一片。

交互式进程相对有较高的优先级。

在linux中,进程的优先级是动态的。

在linux中,调度算法可以明确的确认所有实时程序的身份,但没有办法区分交互式程序和批处理程序。

注意被抢占的进程没有被挂起,因为它还处于TASK_RUNNING状态,只不过不再使用CPU。

调度程序总能成功地找到要执行的进程。事实上,总是至少有一个可运行进程,即swapper进程,它的PID等于0,而且它只有在CPU不能执行其他进程时才执行。

调度算法根据进程是普通进程还是实时进程有很大的不同。

每个普通进程都有他自己的静态优先级,调度程序使用静态优先级来估计系统中这个进程与其他普通进程之间的调度的程度。内核从100(最高优先级)到139(最低优先级)的数表示普通进程的静态优先级。

新进程总是继承其父进程的静态优先级。

静态优先级本质上决定了进程的基本时间片。

普通进程除了静态优先级,还有动态优先级,其值的范围ieshi100-139.动态优先级是调度程序在选择新进程来运行的时候使用的数。

平均睡眠时间是进程在睡眠状态所消耗的平均纳秒数。进程在运行的过程中平均睡眠时间递减,最后,平均睡眠时间永远不会大于1s。

平均睡眠时间也被调度程序用来确定一个给定进程是交互式进程还是批处理进程。

一个具有缺省静态优先级(120)的进程,一旦其平均睡眠时间超过700ms,就成为交互式进程。

活动进程:还没有用完他们的时间片,因此允许他们运行

过期进程:可运行进程已经用完了他们的时间片,因此被禁止运行,直到所有活动进程都过期。

每个实时进程都与一个实时优先级相关,实时优先级是一个范围从1-99的值,调度程序总是让优先级高的进程运行,与普通进程相反,实时进程总是被当成活动进程。

如果几个可运行的实时进程具有相同的最高优先级,那么调度程序选择第一个出现在与本地CPU的运行队列相应链表中的进程。

当系统调用nice()和setpriority()用于基于时间片轮转的实时进程时,不改变实时进程的优先级而会改变其基本时间片的长度。实际上,基于时间片轮转的实时进程的的基本时间片的长度与实时进程的优先级无关,而依赖于进程的静态优先级。

数据结构rq是调度程序最重要的数据结构,系统中的CPU都有它自己的运行队列。宏this_rq()产生本地CPU运行队列的地址,宏cpu_rq(n)产生索引为n的CPU运行队列的地址。

系统中的每个可运行进程属于且只属于一个运行队列。只要可运行进程保持在同一个运行队列中,那就只可能在拥有该运行队列的CPU上执行。


一般来说,内核总是尽量推迟给用户态进程分配动态内存。

当用户态进程请求动态内存时,并没有获得请求的页框,而仅仅获得对一个新的线性地址区间的使用权,而这一线性地址区间就成为进程地址空间的一部分。

每个进程所看到的线性地址集合是不同的,一个进程所使用的地址与另外一个进程所使用的地址并没有什么关系。

确定一个进程当前所拥有的线性区(即进程的地址空间)是内核的基本任务。

内核通过提供页框来处理缺页异常,并让进程继续执行。

与进程地址空间有关的全部信息都包含一个叫做内存描述符(mm_struct)的数据结构中。

所有的内存描述符存放在一个双向链表中,每个描述符在mmlist字段存放链表相邻元素的地址。链表的第一个元素是init_mm的mmlist字段,init_mm是初始化阶段进程0使用的内存描述符。

mm_user字段存放共享mm_struct数据结构的轻量级进程的个数。

mm_alloc()函数用来获得一个新的内存描述符,由于这些描述符被保存在slab分配器高速缓存中,因此,mm_alloc()调用kmem_cache_alloc()来初始化新的内存描述符,并把mm_count和mm_user字段都置为1.
相反,mmput()函数递减内存描述符的mm-user字段,如果该字段变为0,这个函数释放局部描述符,线性区描述符及由内存描述符所引用的页表,并调用mmdrop()。后一个函数把mm_count字段减1,如果该字段变为0,就释放mm_struct数据结构。
与普通进程相反,内核线程不用线性区因此,内存描述符的很多字段对内核线程是没有意义的。
因为大于TASK_SIZE线性地址的相应页表项都应该总是相同的,因此,一个内核线程到底使用什么样的页表集根本没有什么关系。?????
进程描述符中的mm字段指向进程所拥有的内存描述符,而active_mm字段指向进程运行时所使用的内存描述符。对于普通进程而言,这两个字段存放相同的指针。但是内核线程不拥有任何内存描述符,因为他们的mm字段总是为NULL。
每当一个高端线性地址被重新映射时(一般通过vmalloc()或vfree()),内核就更新被定为在swapper_pg_dir主内核页全局目录中的常规页表集合。这个页全局目录由主内存描述符的pgd字段所指向,而主内核存描述符存放在init_mm变量中。?????
linux通过类型对vm_area_struct的对象实现线性区。
每个线性区描述符表示一个线性区间,vm_start字段包含区间的第一个线性地址,而vm_end字段包含区间之外的第一个线性地址。vm_end-vm_start表示线性区的长度。vm_mm字段指向拥有这个区间的进程的mm_struct内存描述符。
进程所拥有的线性区从来不重叠,并且内核尽力把新分配的线性区与紧邻的现有线性区进行合并。如果两个相邻的访问权限相匹配,就能把他们合并在一起。
进程所拥有的所有线性区是通过一个简单的链表链接在一起的,出现在链表中的线性区是按内存地址的升序排列的。
内核通过进程的内存描述符的mmap字段来查找线性区,其中mmap字段指向链表中的第一个线性区描述符。
内存描述符的map_count字段存放进程所拥有的线性区数目。
为了存放进程的线性区,linux既使用了链表,也使用了红黑树,这两种数据结构包含指向同一个线性区描述符的指针,当插入或删除一个线性区描述符时,内核通过红黑树搜素前后元素,并用搜索结果快速更新链表而不用扫描链表。
一般来说,红黑树用来确定含有指定地址的线性区,而链表通常在扫描整个线性区域集合时来使用。

页访问全乡表示何种类型的访问应该产生一个缺页异常。????
页表标志的初值(同一线性区所有页标志的初值必须一样)存放在vm_area_struct描述符的vm_page_prot字段中,当增加一个页时,内核根据vm_page_prot字段的值设置相应页表项中的标志。
为了做到在”写时复制“技术中适当的推迟页框的分配,只要相应的页不是由多个进程所共享,那么这种页框都是写保护的。
每个内存描述符包含一个mmap_cache字段,这个字段保存进程最后一次引用线性区的描述符地址。引入这个的附加字段是为了减少查找一个给定线性地址所在线性区而花费的时间。
通过系统调用mmap(),每个进程都可能获得两种不同形式的线性区:一种从线性地址0x40000000开始向高端地址增长,另一种正好从用户态堆栈开始向低端地址增长。
把内存描述符的字段mm->free_area_cache初始化为用户态线性地址空间的三分之一,并在以后创建新线性区时对它进行更新。
其实用户态线性地址空间的三分之一是为有定义起始线性地址的线性区(典型的是可执行文件的正文段,数据段和bss段)而保留的。
do_map()函数为当前进程创建并初始化一个新的线性区,不过分配成功之后 ,可以把这个新的线性区与进程已有的其他线性区进行合并。
内核使用do_munmap()函数从当前进程的地址空间中删除一个线性地址区间。参数为:进程内存描述符的地址,地址区间的起始地址和它的长度。
split_vma()函数的功能是把与线性地址区间交叉的线性区划分为ieliangge较小的区,一个在线性区间外部,一个在区间的内部。
unmap()函数遍历线性区链表并释放他们的页框。
linux的缺页异常处理程序必须区分一下两种情况:由编程错误引起的异常,由引用属于进程地址空间但还尚未分配物理页框的页所引起的异常。
线性区描述符可以让缺页异常处理程序非常有效的完成它的工作。
如果缺页的确发生在中断处理程序,可延迟函数,临界区或内核线程中,do_page_fault()就不会试图把这个线程地址与current的线性区做比较。内核线程从来不使用小于TASK_SIZE的地址。同样中断处理程序,可延迟函数和临界代码也不应该使用小于TASK_SIZE的地址,因为这可能导致当前进程的阻塞。
每个向低地址扩展的栈所在的线性区,他的VM_GROWSDOWN标志被设置,这样当vm_start字段的值可能被减少的时候,vm_end字段的值保持不变。
如果handle_mm_fault()函数成功的给进程分配一个页框,则返回VM_FAULT_MINOR(表示在没有阻塞当前进程的情况下处理了缺页,这种缺页叫做次缺页)或者VM_FAULT_MAJOR(表示缺页迫使当前进程睡眠,阻塞当前的缺页就叫做主缺页)。
handle_pte_fault()函数检查address地址所对应的页表项,并决定如何为进程分配一个新页框:
如果被访问的页不存在,也就是说这个页还没有被存放在任何一个页框中,那么内核分配一个新的页框并适当地初始化,这种技术称为请求调页
如果被访问的页存在但是标记为只读,也就是说,他已经被存放在一个页框中,那么,内核分配一个新的页框,并把页框的数据拷贝到新页框来初始化它的内容,这种技术成为写时复制。
进程开始运行的时候并不访问其他地址空间中的全部地址,事实上,有一部分地址也许永远不被进程使用。
在RAM总数保持不变的情况下,请求调页从总体上能使系统有更大的吞吐量。
由请求调页所引发的每个缺页异常必须由内核处理,这将浪费CPU的时钟周期。
被访问的页可能不在主存中,其原因或者是进程从没访问过该页,或者是内核已经回收了相应的页框。
pte_offset_map和pte_unmap这对宏获取和释放同一个临时内核映射。临时内核映射必须在调用alloc_page()之前释放,因为这个函数可能阻塞当前进程。
零页在内核初始化期间被静态分配,并存放在empty_zero_page变量中。
内核复制父进程的整个地址空间并把复制的那一份分配给子进程是非常耗时的,因为需要:为子进程的页表分配页框;为子进程的页分配页框;初始化子进程的页表;把父进程的页复制到子进程相应的页中。
写时复制的思想很简单:父进程和子进程共享页框而不是复制页框。然而只要页框被共享,他们就不能被修改,无论父进程还是子进程何时试图写一个共享的页框,就会产生一个异常。这时内核就把这个页复制到一个新的页框中并标记为可写,原来的页框仍然是写保护的;当其他进程试图写入时,内核检查写进程是否是这个页框的唯一属主,如果是,就把这个页框标记为对这个进程可写的。
页描述符的_count字段用于跟踪共享相应页框的进程数目,只要进程释放一个页框或者在它上面执行写时复制,它的_count字段就减少,只有当_count变为-1时,这个页框才被释放。
handle_pte_fault()函数是与体系结构无关的,它考虑任何违背页访问权限的可能。最后总是调用do_wp_fault()函数,首先获取与缺页相关的页框描述符(缺页表对应的页框)。接下来,函数确定页的复制是否真正必要,如果仅有一个进程拥有这个项,那么写时复制就不必应用,且该进程应当自由地写该页。当写时复制不进行时,就把该页框标记为可写的,一面试图写时引起进一步的缺页异常。
一旦内核初始化阶段结束,任何进程或内核线程便都不直接使用主内核页表(init_mm.pgd和它的子页表)
当创建一个新的进程时内核调用copy_mm()函数,这个函数通过建立新进程的所有页表和内存描述符来创建进程的地址空间。
copy_mm()函数必须创建一个新的地址空间(在进程请求一个地址前,即使在地址空间内没有分配内存)。这个函数分配一个新的内存描述符,把它的地址存放在新进程描述符tsk的mm字段中,并把current->mm的内容复制到tsk->mm中,然后改变新进程描述符的一些字段。
dup_mmap()函数既复制父进程的线性区,也复制父进程的页表。dup_mmap()函数把新内存描述符tak->mm插入到内存描述符的全局链表中。然后,从current->mm->map所指向的线性区开始扫描父进程的线性区链表,它复制遇到的每一个vm_area_struct线性区描述符,并把复制品插入到子进程的线性区链表和红黑树中。
当进程结束时,内核调用exit_mm()函数释放进程的地址空间。
mmput() 函数释放局部描述符表,线性区描述符和页表。不过因为exit_mm()已经递增了主使用计数器,所以并不释放内存描述符本身。当要把正在被终止的进程从本地CPU撤销时,将由finish_task_switch()函数释放内存描述符。
堆用于满足进程的动态内存请求。内存描述符的start_brk和brk字段分别限定了这个区的开始地址和结束地址。
sys_brk()函数首先验证addr参数是否位于进程代码所在的线性区。如果是,则立即返回,因为堆不能与进程代码所在的线性区重叠。
do_brk()函数实际上仅处理匿名线性区的do_mmap()的简化版,速度稍快,因为前者假定线性区不映射磁盘上的文件,从而避免了检查线性区对象的几个字段。


当装入并运行一个程序时,用户可以提供影响程序执行方式的两种信息,命令行参数和环境变量。
当进程开始执行一个新进程时,它的执行上下文发生很大的改变,这是因为在进程的前一个计算执行期间所获得的大部分资源会被抛弃。
当某个进程试图访问一个文件时,VFS总是根据文件的拥有者和进程的信任状所建立的许可权限检查访问的合法性。
命令行参数和环境串存放在用户堆栈中,正好位于返回地址之前。环境变量位于栈底附近正好一个0长整数之后。
目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的线性地址。
当main()函数终止时,C编译程序把exit_group()函数插入到目标代码中。
当程序被装入内存执行时,一个名为动态链接器的程序就专注于分析可执行文件中的库名,确定所需要库在系统目录树中的位置,并使执行进程可以使用所请求的代码。进程也可以使用dlopen()库函数在运行时装入额外的共享库。
当动态链接库程序必须把某一共享库链接到进程时,并不拷贝目标代码,而是仅仅执行一个内存映射,把库文件的相关部分映射到进程的地址空间中。
动态链接的程序启动时间通常比静态链接的程序长。
GCC编译器提供--static选项,即告诉链接程序使用静态库而不是共享库。
从逻辑上Unix程序的线性地址空间传统上被划分为几个段:正文段,已初始化数据段,未初始化数据段,堆栈段。
内核能使用RLIMIT_STACK资源来限定用户态堆栈的大小时,通常使用灵活布局,但是这个空间大小不能小于128M或大于256GB。
在linux中,通过ptrace()系统调用进行执行跟踪。设置了CAP_SYS_PTRACE权能的进程可以跟踪系统中的任何进程。两个进程不能同时跟踪同一个进程。
ptrace()系统调用修改跟踪进程描述符的parent字段以使它指向跟踪进程,因此跟踪进程变为被跟踪进程的有效父进程。
当一个监控的事件发生时,被跟踪的程序停止,并且将SIGCHILD信号发送给它的父进程。
类型为linux_binfmt对象所描述的可执行文件实质上提供一下三种方法“
load_library:通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境
load_shlib:用于动态地把一个共享库捆绑到一个已经在运行的进程
core_dump:在名为core的文件中存放当前进程的执行上下文,这个文件通常是在进程接收到一个缺省操作为”dump“的信号时被创建的。
当内核确定可执行文件是自定义格式时,它就启动相应的解释程序。解释程序运行在用户态,读入可执行文件的路径名作为参数,并进行计算。
所有的exech函数都是C库定义的封装例程,并利用了execve()系统调用,这是linux所提供的处理程序执行的唯一系统调用。


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值