Linux 0.11内核之旅(六) :main.c之fork调用流程

本文深入剖析Linux0.11内核中的fork系统调用流程,从内核态到用户态的转换,再到进程复制及任务调度的具体实现。

紧接着,上一篇博文Linux 0.11内核之旅(五) :main.c之move_to_user_mode

继续分析main函数后半段的fork系统调用流程。

void main(void)     /* This really IS void, no error here. */
{           /* The startup routine assumes (well, ...) this */
/*
 * Interrupts are still disabled. Do necessary setups, then
 * enable them
 */
 
    ROOT_DEV = ORIG_ROOT_DEV;
    drive_info = DRIVE_INFO;
    memory_end = (1<<20) + (EXT_MEM_K<<10);
    memory_end &= 0xfffff000;
    if (memory_end > 16*1024*1024)
        memory_end = 16*1024*1024;
    if (memory_end > 12*1024*1024)
        buffer_memory_end = 4*1024*1024;
    else if (memory_end > 6*1024*1024)
        buffer_memory_end = 2*1024*1024;
    else
        buffer_memory_end = 1*1024*1024;
    main_memory_start = buffer_memory_end;
#ifdef RAMDISK
    main_memory_start += rd_init(main_memory_start, RAMDISK*1024); //将main_memory_start开始的RAMDISK×1024个字节=0
#endif
    mem_init(main_memory_start,memory_end); //内存初始化
    trap_init(); //idt中断表初始化
    blk_dev_init();//块设备初始化
    chr_dev_init();//字符设备初始化,为空
    tty_init();//tty设备初始化
    time_init();//时间初始化
    sched_init();//调度程序初始化
    buffer_init(buffer_memory_end);//缓冲区初始化,创建管理缓冲区的双向链表,此处缓冲区大小为3M
    hd_init();//硬盘以及硬盘中断初始化
    floppy_init();//软盘以及软盘中断初始化                                                                                                                                                                  
    sti();//打开中断
  
    move_to_user_mode();//指令实现从内核模式切换到用户模式(任务0)
 

    //以下代码在用户模式(任务0)中执行
/*******************************分析从这里开始*************************************/
    if (!fork()) {      /* we count on this going ok *///触发系统中断0x80,调用system_call中断函数
 /*******************************分析从这里结束*************************************/
        //fork返回值为0的进程(子进程)执行init();
        init();
    }
/*
 *   NOTE!!   For any other task 'pause()' would mean we have to get a
 * signal to awaken, but task0 is the sole exception (see 'schedule()')
 * as task 0 gets activated at every idle moment (when no other tasks
 * can run). For task0 'pause()' just means we go check if some other
 * task can run, and if not we return here.
 */
    for(;;) pause(); //任务0进入pause
}

之前描述过,在move_to_user_mode之后,CPU已经工作在用户模式。我们把这之后的代码理解成一个跑在操作系统上的应用程序,叫它task0。那么,task0做了什么呢,task0,首先就进行了fork函数的系统调用,产生出一个子进程,我们叫它task1,通过这个进程接着继续执行后面的init()。

 

这里,我们就分析一下task0执行fork()的时候,到底发生了什么。首先看看fork的定义:

static _inline _syscall0(int,fork);


#define _syscall0(type,name) \                                                                                                               
type name(void) \
{ \
    volatile long __res; \
    _asm {  /* 输入为系统中断调用号__NR_name*/\
        _asm mov eax,__NR_##name\
        _asm int 80h /* 调用系统中断0x80。*/\
        _asm mov __res,eax /* 返回值??eax(__res)*/\
    } \                         
    if (__res >= 0)         /* 如果返回值>=0,则直接返回该值。*/\
        return (type) __res; \                  
    errno = -__res;     /* 否则置出错号,并返回-1。*/\
    return -1; \
}

可以看到,fork函数实际是通过一个宏 _syscall0 来定义的,那么这里我们将宏展开:

...
#define __NR_fork 2
...
                                                                                                        
int fork(void) 
{ 
    volatile long __res; 
    _asm {  /* 输入为系统中断调用号__NR_name*/
        _asm mov eax,__NR_fork
        _asm int 80h /* 调用系统中断0x80。*/
        _asm mov __res,eax /* 返回值??eax(__res)*/
    }                         
    if (__res >= 0)         /* 如果返回值>=0,则直接返回该值。*/
        return (type) __res;                   
    errno = -__res;     /* 否则置出错号,并返回-1。*/
    return -1; 
}

可以看到fork函数是由内嵌汇编实现的,首先将eax=__NR_fork,执行int 80h,之后将返回值_res=eax,最后再判断返回值的范围,如果为正,fork函数最终返回__res,如果为负,fork函数最终返回 -1。

所以,在int 80h是整个fork函数的关键。

他到底做了什么呢,其实int是x86汇编里触发软中断的命令,当int执行的时,CPU会被通知产生了软中断,因而在保存当前上下文状态之后,自动跳转至之前初始化定义的中断描述表IDT表的第128(0x80)个中断处理函数去执行。来看看之前定义的IDT表的第128(0x80)项的中断处理函数system_call:

// 调度程序的初始化子程序。
void sched_init (void)
{
...
  // 设置系统调用0x80中断的中断处理函数system_call。
    set_system_gate (0x80, &system_call);                                                                                                                                                                   
}

进一步看看这个函数做了什么:


void _inline _set_gate(unsigned long *gate_addr, \
                       unsigned short type, \
                       unsigned short dpl, \
                       unsigned long addr) 
{// c语句和汇编语句都可以通过
    gate_addr[0] = 0x00080000 + (addr & 0xffff);
    gate_addr[1] = 0x8000 + (dpl << 13) + (type << 8) + (addr & 0xffff0000);
}


#define set_system_gate(n,addr) \
_set_gate((unsigned long*)(&(idt[n])),15,3,(unsigned long)addr)

实际上就是对idt表的第0x80项,进行了赋值,值得注意的是IDT表的一项的长度是64位。

(1)0-15位和48-64位组合起来形成32位偏移量,也就是中断处理程序所在段(由16-31位给出)的段内偏移。

(2)16-31共16位是中断处理程序所在的段选择符。

(3)40-43位共4位表示描述符的类型。(0111:中断描述符,1010:任务门描述符,1111:陷阱门描述符)

(4)45-46两位标识描述符的访问特权级(DPL,Descriptor Privilege Level)。

(5)47位标识段是否在内存中。如果为1则表示段当前不再内存中。

根据以上内容再对set_system_gate (0x80, &system_call);进行宏展开为:

((unsigned long*)(&ldt[0x80]))[0] = 0x00080000 + (addr & 0xffff);
((unsigned long*)(&idt[0x80]))[1] = 0x8000 + (3 << 13) + (15 << 8) + (addr & 0xffff0000);

可以看出

段选择子=0x0008

段内偏移=addr=&system_call

访问特权级=3

描述符类型为陷阱门

 

这里展开说一下段选择子,0x0008的含义,十六进制为0x0000000000001000,低两位00代表特权级为0即是内核级申请,第三位0代表使用GDT表,第四,五位01,代表使用gdt表格的第一项的值(从0开始数),gdt表在head.s中已经初始化完成如下。

_gdt:
    DQ 0000000000000000h    ;/* NULL descriptor */
    DQ 00c09a0000000fffh    ;/* 16Mb */  // 代码段最大长度16M。
    DQ 00c0920000000fffh    ;/* 16Mb */ // 数据段最大长度16M。
    DQ 0000000000000000h    ;/* TEMPORARY - don't use */
    DQ 252 dup(0)               ;/* space for LDT's and TSS's etc */
end 

所以,这里段选择子最终选择的值是00c09a0000000fff

具体含义如下,不再赘述。

GDT表项含义

言归正传,当task0 执行int 80的时候会跳转至内核态(DPL=00),段内地址为system_call标号的地方去执行。

_system_call:
    cmp eax,nr_system_calls-1 ;// 调用号如果超出范围的话就在eax 中置-1 并退出。
    ja bad_sys_call
    push ds ;// 保存原段寄存器值。
    push es
    push fs
    push edx ;// ebx,ecx,edx 中放着系统调用相应的C 语言函数的调用参数。
    push ecx ;// push %ebx,%ecx,%edx as parameters
    push ebx ;// to the system call
    mov edx,10h ;// set up ds,es to kernel space
    mov ds,dx ;// ds,es 指向内核数据段(全局描述符表中数据段描述符)。
    mov es,dx
    mov edx,17h ;// fs points to local data space
    mov fs,dx ;// fs 指向局部数据段(局部描述符表中数据段描述符)。
;// 下面这句操作数的含义是:调用地址 = _sys_call_table + %eax * 4。参见列表后的说明。
;// 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了一个包括72 个
;// 系统调用C 处理函数的地址数组表。
    call [_sys_call_table+eax*4]
    ...

system _call调用首先将原段寄存器的值压入堆栈,重新设置ds,es数据段,指向内核数据段,也就是GDT表的第二项值:00c0920000000fff,设置fs指向ldt的第二项。然后执行关键的一步call [_sys_call_table+eax*4],我们来看看_sys_call_table

typedef int (*fn_ptr) ();
...
// 系统调用函数指针表。用于系统调用中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
  sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
  sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
  sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
  sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
  sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
  sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
  sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
  sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
  sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
  sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
  sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
  sys_setreuid, sys_setregid                                                                                                                                                                                
};
includ

这个数组是一个存放 int (*fn_prt) () 函数指针类型的数组,其中一项指针的地址长度为4,所以这里实际调用的是第eax项函数指针,之前已经提到eax作为int 80的参数,在之前已经被赋值为__NR_fork=2,所以这里实际调用的是sys_call_table的第二项,为sys_fork,来看看sys_fork。

_sys_fork:                                                                                                                
    call _find_empty_process ;// 调用find_empty_process()(kernel/fork.c,135)。
    test eax,eax
    js l2
    push gs
    push esi
    push edi
    push ebp
    push eax
    call copy_process ;// 调用C 函数copy_process()(kernel/fork.c,68)。
    add esp,20 ;// 丢弃这里所有压栈内容。

继续追踪:

// 为新进程取得不重复的进程号last_pid,并返回在任务数组中的任务号(数组index)。
int find_empty_process (void)
{
    int i;

repeat:
    if ((++last_pid) < 0)
        last_pid = 1;
    for (i = 0; i < NR_TASKS; i++)
        if (task[i] && task[i]->pid == last_pid) //如果task[i] != NULL 而且 task[i]->pid == last_pid,则重新寻找 last_pid
            goto repeat;
    for (i = 1; i < NR_TASKS; i++)  // 任务0 排除在外。
        if (!task[i]) //返回一个task[i]==NULL的i
            return i;
    return -EAGAIN;
}  

find_empty_process函数主要是寻找一个没使用的pid号,以及task数组中的没使用的空任务。这个被选择的task的index[nr号]作为返回值放在eax中,为创建子任务做好准备。

int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
        long ebx,long ecx,long edx,
        long fs,long es,long ds,
        long eip,long cs,long eflags,long esp,long ss)
{
    struct task_struct *p;
    int i;
    struct file *f;

    p = (struct task_struct *) get_free_page();
    if (!p)
        return -EAGAIN;
    task[nr] = p;

    // NOTE!: the following statement now work with gcc 4.3.2 now, and you
    // must compile _THIS_ memcpy without no -O of gcc.#ifndef GCC4_3
    *p = *current; //current为当前进程,p为当前进程要创建的子进程,可以看到结构体内容直接赋值
    p->state = TASK_UNINTERRUPTIBLE;
    p->pid = last_pid;
    p->father = current->pid;
    p->counter = p->priority;
    p->signal = 0;
    p->alarm = 0;
    p->leader = 0;      /* process leadership doesn't inherit */
    p->utime = p->stime = 0;
    p->cutime = p->cstime = 0;
    p->start_time = jiffies;
    p->tss.back_link = 0;
    p->tss.esp0 = PAGE_SIZE + (long) p;
    p->tss.ss0 = 0x10;
    p->tss.eip = eip;
    p->tss.eflags = eflags;
    p->tss.eax = 0;
    p->tss.ecx = ecx;
    p->tss.edx = edx;
    p->tss.ebx = ebx;
    p->tss.esp = esp;
    p->tss.ebp = ebp;
    p->tss.esi = esi;
    p->tss.edi = edi;
    p->tss.es = es & 0xffff;
    p->tss.cs = cs & 0xffff;
    p->tss.ss = ss & 0xffff;
    p->tss.ds = ds & 0xffff;
    p->tss.fs = fs & 0xffff;
    p->tss.gs = gs & 0xffff;
    p->tss.ldt = _LDT(nr);
    p->tss.trace_bitmap = 0x80000000;
    if (last_task_used_math == current)
        __asm__("clts ; fnsave %0"::"m" (p->tss.i387));
    if (copy_mem(nr,p)) {//复制父进程的代码段和数据段到子进程的代码段和数据段
        task[nr] = NULL;
        free_page((long) p);
        return -EAGAIN;
    }
    for (i=0; i<NR_OPEN;i++)
        if ((f=p->filp[i]))
            f->f_count++;
    if (current->pwd)
        current->pwd->i_count++;
    if (current->root)
        current->root->i_count++;
    if (current->executable)
        current->executable->i_count++;
    set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));//在gdt中设置新的创建的子进程的tss
    set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));//在gdt中设置新的创建的子进程的ldt
    p->state = TASK_RUNNING;    /* do this last, just in case */
    return last_pid;
}

copy_process做了子进程task_struct任务结构体p创建以及赋值工作,基本上是直接照搬了父进程任务结构体current的内容,其中的数据结构tss是为了填充TR(任务寄存器)的值,用于cpu切换任务时使用,这里以后会详细解析,暂不详述。最后将tss和ldt的值放在GDT表的相应位置,这个位置由nr号决定。最后返回的是子进程的pid号, 返回值。

返回后回到sys_fork,再回到sys_call接着执行。

值得注意的是到目前这里,子进程的task_struct虽然被创建,但是,还并没有真正的被CPU调度。

_system_call:
...
call [_sys_call_table+eax*4]
push eax ;// 把系统调用号入栈。
    mov eax,_current ;// 取当前任务(进程)数据结构地址??eax。
;// 下面97-100 行查看当前任务的运行状态。如果不在就绪状态(state 不等于0)就去执行调度程序。
;// 如果该任务在就绪状态但counter[??]值等于0,则也去执行调度程序。
    cmp dword ptr [state+eax],0 ;// state
    jne reschedule
    cmp dword ptr [counter+eax],0 ;// counter
    je reschedule
;// 以下这段代码执行从系统调用C 函数返回后,对信号量进行识别处理。
ret_from_sys_call:
;// 首先判别当前任务是否是初始任务task0,如果是则不必对其进行信号量方面的处理,直接返回。
;// 103 行上的_task 对应C 程序中的task[]数组,直接引用task 相当于引用task[0]。
    mov eax,_current ;// task[0] cannot have signals
    cmp eax,_task
    je l1 ;// 向前(forward)跳转到标号l1。
;// 通过对原调用程序代码选择符的检查来判断调用程序是否是超级用户。如果是超级用户就直接
;// 退出中断,否则需进行信号量的处理。这里比较选择符是否为普通用户代码段的选择符0x000f
;// (RPL=3,局部表,第1 个段(代码段)),如果不是则跳转退出中断程序。
    cmp word ptr [R_CS+esp],0fh ;// was old code segment supervisor ?
    jne l1
;// 如果原堆栈段选择符不为0x17(也即原堆栈不在用户数据段中),则也退出。
    cmp word ptr [OLR_DSS+esp],17h ;// was stack segment = 0x17 ?
    jne l1
;// 下面这段代码(109-120)的用途是首先取当前任务结构中的信号位图(32 位,每位代表1 种信号),
;// 然后用任务结构中的信号阻塞(屏蔽)码,阻塞不允许的信号位,取得数值最小的信号值,再把
;// 原信号位图中该信号对应的位复位(置0),最后将该信号值作为参数之一调用do_signal()。
;// do_signal()在(kernel/signal.c,82)中,其参数包括13 个入栈的信息。
    mov ebx,[signal+eax] ;// 取信号位图??ebx,每1 位代表1 种信号,共32 个信号。
    mov ecx,[blocked+eax] ;// 取阻塞(屏蔽)信号位图??ecx。
    not ecx ;// 每位取反。
    and ecx,ebx ;// 获得许可的信号位图。
    bsf ecx,ecx ;// 从低位(位0)开始扫描位图,看是否有1 的位,
;// 若有,则ecx 保留该位的偏移值(即第几位0-31)。
    je l1 ;// 如果没有信号则向前跳转退出。
    btr ebx,ecx ;// 复位该信号(ebx 含有原signal 位图)。
    mov dword ptr [signal+eax],ebx ;// 重新保存signal 位图信息??current->signal。
    inc ecx ;// 将信号调整为从1 开始的数(1-32)。
    push ecx ;// 信号值入栈作为调用do_signal 的参数之一。
    call _do_signal ;// 调用C 函数信号处理程序(kernel/signal.c,82)
    pop eax ;// 弹出信号值。
l1: pop eax
    pop ebx
    pop ecx
    pop edx
    pop fs
    pop es
    pop ds
    iretd

接着判断当前task_struct进程指针current->counter是否为零,current->state是否为零(是否不在就绪态,不在的话),会跳转至reschedule处执行,

reschedule:
    push ret_from_sys_call ;// 将ret_from_sys_call 的地址入栈(101 行)。
    jmp schedule

这里先将函数返回地址ret_from_sys_call压入堆栈,然后跳转到schedule去执行核心调度函数。

void schedule(void)
{   
    int i,next,c;
    struct task_struct ** p;

/* check alarm, wake up any interruptible tasks that have got a signal */
    
    for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
        if (*p) {
// 如果任务的alarm 时间已经过期(alarm<jiffies),则在信号位图中置SIGALRM 信号,然后清alarm。
// jiffies 是系统从开机开始算起的滴答数(10ms/滴答)。定义在sched.h 第139 行。
            if ((*p)->alarm && (*p)->alarm < jiffies) {
                    (*p)->signal |= (1<<(SIGALRM-1));
                    (*p)->alarm = 0;
                }
// 如果信号位图中除被阻塞的信号外还有其它信号,并且任务处于可中断状态,则置任务为就绪状态。
// 其中'~(_BLOCKABLE & (*p)->blocked)'用于忽略被阻塞的信号,但SIGKILL 和SIGSTOP 不能被阻塞。
            if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
            (*p)->state==TASK_INTERRUPTIBLE)
                (*p)->state=TASK_RUNNING;
        }

/* this is the scheduler proper: */
    
    while (1) {
        c = -1;
        next = 0;
        i = NR_TASKS;
        p = &task[NR_TASKS];
        while (--i) {//循环task数组,获取counter最大的task,作为next
            if (!*--p)
                continue;
            if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
                c = (*p)->counter, next = i;
        }
        if (c) break;//如果task最大的counter也等于零,则重新分配counter,再次寻找最大counter的task,作为next
        for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
            if (*p)
                (*p)->counter = ((*p)->counter >> 1) +
                        (*p)->priority;
    }
    
    switch_to(next);
}

首先遍历task任务数组,看看哪些任务的alarm已经到期,将SIGALRM信号位置1.

然后查看task任务数组中的任务,如果有不可阻塞的信号,且任务为可中断的状态(TASK_INTERRUPTIBLE), 就将任务状态设置为运行态(TASK_RUNNING), 使其参与后续的调度。

接着是一个while循环,做的事情就是遍历task数组,如果任务task[i]为为运行态,而且它的counter值是最大的,就将它设置为下一个准备调度的任务(next),如果任务task数组中,如果所有的任务的counter都等于0,则重新给所有任务分配counter,公式为

                (*p)->counter = ((*p)->counter >> 1) + (*p)->priority;

所以counter的初始值与任务的优先级((*p)->priority)有关。

分配counter之后,再次寻找counter最大的那个任务作为将要切换的任务(next).

最后调用,真正的任务切换函数switch_to,切换到counter最大的那个任务去执行。

#define switch_to(n) {\
struct {long a,b;} __tmp; \ 
__asm__("cmpl %%ecx,current\n\t" \ 
    "je 1f\n\t" \
    "movw %%dx,%1\n\t" \
    "xchgl %%ecx,current\n\t" \
    "ljmp *%0\n\t" \ 
    "cmpl %%ecx,last_task_used_math\n\t" \
    "jne 1f\n\t" \      
    "clts\n" \
    "1:" \
    ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
    "d" (_TSS(n)),"c" ((long) task[n])); \
}

switch_to是一段内嵌汇编,后面我们会详细分析,这里就理解为,switch_to实现的功能就是保存当前任务上下文,切换到counter最大的任务去执行。

由于当前task数组中实际只有两个task,一个是task0,还有一个task0在copy_process中产生的子任务(子进程)task1,也许当前counter最大的是task1,就会切换至task1运行,task1中的程序段是与task0共用的,那么主要看eip的值,

_sys_fork:                                                                                                                
    call _find_empty_process ;// 调用find_empty_process()(kernel/fork.c,135)。
    test eax,eax
    js l2
    push gs
    push esi
    push edi
    push ebp
    push eax
    call copy_process ;// 调用C 函数copy_process()(kernel/fork.c,68)。
    add esp,20 ;// 丢弃这里所有压栈内容。
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
        long ebx,long ecx,long edx,
        long fs,long es,long ds,
        long eip,long cs,long eflags,long esp,long ss)
{
...
    p->tss.eip = eip;
...
}

可以看出task1的eip值是在 call copy_process的时候,自动压入堆栈的call copy_process的下一条语句,也就是 add esp,20 ;

所以task1被执行的时候是从add esp,20 开始。

那么,我们先不管task1,回到task0,假设task1已经执行了一段,task0又被调度,接着执行,task0会回到ret_from_sys_call,它是特殊的直接,会跳过做一些信号的处理这一步,最后中断返回iret。

task1则会参与信号处理,最后再中断返回。

需要记住的是task0返回时,_res=eax中的值是子进程号,而task1返回时,_res=eax的值是0,具体原因以后再补充。

/*******************************分析从这里开始*************************************/
    if (!fork()) {      /* we count on this going ok *///触发系统中断0x80,调用system_call中断函数
 /*******************************分析从这里结束*************************************/
        //fork返回值为0的进程(子进程)执行init();
        init();
    }

/*
 *   NOTE!!   For any other task 'pause()' would mean we have to get a
 * signal to awaken, but task0 is the sole exception (see 'schedule()')
 * as task 0 gets activated at every idle moment (when no other tasks
 * can run). For task0 'pause()' just means we go check if some other
 * task can run, and if not we return here.
 */
    for(;;) pause(); //任务0进入pause

因此,task1会去调用init();函数完成进一步的初始化。而task0则会去调用pause();函数。

后续会继续跟踪task1的init()调用,这篇有点长了,分几次写完,有点不太适合阅读,就当做我写给自己看的,可能也有理解不对的地方,欢迎拍砖,谢谢。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值