Linux进程管理 进程状态和时钟滴答[哈工大操作系统实验]

进程管理和创建记录,是哈工大操作系统实验的内容。

涉及的技术内容包括“进程状态转换点定位”和“内核状态下写文件”。

 

本文主要讲解linux 0.11 系统[进程的调度转换知识],并给出哈工大实验自带的[内核文件读写代码]。

先讲简单的,内核文件读写:

一,搞定文件读写。

内核状态下的文件读写主要靠filp_open() ; filp_clos() ; vfs_read() ; vfs_write(),但是linux0.11的时候,这些东西还没出生呢,内核下只有个printk(String format , vars ...)来打印到屏幕,用法和printf()一样;

还好,工大给出了在内核态打印到文件的代码。这是一个添加到kernel/printk.c的外加系统调用,其实就是一个printk()函数的加强版本,你只需要用之前学过的添加系统调用的手法,在printk.c作如下的改动:

/*
 *  linux/kernel/printk.c
 *
 *  (C) 1991  Linus Torvalds
 */

/*
 * When in kernel-mode, we cannot use printf, as fs is liable to
 * point to 'interesting' things. Make a printf with fs-saving, and
 * all is well.
 */
#include <stdarg.h>
#include <stddef.h>

#include <linux/kernel.h>
#include <linux/sched.h>
#include <sys/stat.h>
static char buf[1024];

extern int vsprintf(char * buf, const char * fmt, va_list args);

int printk(const char *fmt, ...)
{
	va_list args;
	int i;

	va_start(args, fmt);
	i=vsprintf(buf,fmt,args);
	va_end(args);
	__asm__("push %%fs\n\t"
		"push %%ds\n\t"
		"pop %%fs\n\t"
		"pushl %0\n\t"
		"pushl $buf\n\t"
		"pushl $0\n\t"
		"call tty_write\n\t"
		"addl $8,%%esp\n\t"
		"popl %0\n\t"
		"pop %%fs"
		::"r" (i):"ax","cx","dx");
	return i;
}
static char logbuf[1024];
int fprintk(int fd, const char *fmt, ...)
{
	va_list args;
	int count;
	struct file * file;
	struct m_inode * inode;

	va_start(args, fmt);
	count=vsprintf(logbuf, fmt, args);
	va_end(args);

	if (fd < 3)	/* 如果输出到stdout或stderr,直接调用sys_write即可 */
	{
		__asm__("push %%fs\n\t"
			"push %%ds\n\t"
			"pop %%fs\n\t"
			"pushl %0\n\t"
			"pushl $logbuf\n\t" /* 注意对于Windows环境来说,是_logbuf,下同 */
			"pushl %1\n\t"
			"call sys_write\n\t" /* 注意对于Windows环境来说,是_sys_write,下同 */
			"addl $8,%%esp\n\t"
			"popl %0\n\t"
			"pop %%fs"
			::"r" (count),"r" (fd):"ax","cx","dx");
	}
	else	/* 假定>=3的描述符都与文件关联。事实上,还存在很多其它情况,这里并没有考虑。*/
	{
		if (!(file=task[0]->filp[fd]))	/* 从进程0的文件描述符表中得到文件句柄 */
			return 0;
		inode=file->f_inode;

		__asm__("push %%fs\n\t"
			"push %%ds\n\t"
			"pop %%fs\n\t"
			"pushl %0\n\t"
			"pushl $logbuf\n\t"
			"pushl %1\n\t"
			"pushl %2\n\t"
			"call file_write\n\t"
			"addl $12,%%esp\n\t"
			"popl %0\n\t"
			"pop %%fs"
			::"r" (count),"r" (file),"r" (inode):"ax","cx","dx");
	}
	return count;
}

然后相应修改unistd.h , system_call.s  sys.h 就搞定了这个函数,向文件中写东西的语句如下:

fprintk(3,"%d",a);
二,理清思路

我们要做的事情总的来说就是两步:

1,找到进程小朋友们在哪里转换状态。

2,在转换状态的地方记录这次变化到文件。(直接调用fprintk就能自动生成process.log并按格式写入);

问题2其实已经解决了,就剩下1了,这里有一些知识需要了解:

一个进程的状态有如下几种宏:

TASK_RUNNING(准备好随时运行或正在运行)

TASK_INTERRUPTIBLE(进入睡眠,可以打断其 “进入睡眠” 直接改状态)

TASK_UNINTERRUPTIBLE(进入不可中断睡眠,完全进入睡眠后方可正式唤醒)

TASK_ZOMBIE(僵死状态,进程同学一路走好)

不同时间进入这三种状态,会产生一些教授们定义出来的状态:New /Ready /Running /Wait /Exit

三,开始找状态点:

1,fork.c  包含状态:new,ready

/*
 *  linux/kernel/fork.c
 *
 *  (C) 1991  Linus Torvalds
 */

/*
 *  'fork.c' contains the help-routines for the 'fork' system call
 * (see also system_call.s), and some misc functions ('verify_area').
 * Fork is rather simple, once you get the hang of it, but the memory
 * management can be a bitch. See 'mm/mm.c': 'copy_page_tables()'
 */
#include <errno.h>

#include <linux/sched.h>
#include <linux/kernel.h>
#include <asm/segment.h>
#include <asm/system.h>
#include <linux/fs.h>
#ifndef timep
#define timep jiffies
#endif
extern void write_verify(unsigned long address);

long last_pid=0;

void verify_area(void * addr,int size)
{
	unsigned long start;

	start = (unsigned long) addr;
	size += start & 0xfff;
	start &= 0xfffff000;
	start += get_base(current->ldt[2]);
	while (size>0) {
		size -= 4096;
		write_verify(start);
		start += 4096;
	}
}

int copy_mem(int nr,struct task_struct * p)
{
	unsigned long old_data_base,new_data_base,data_limit;
	unsigned long old_code_base,new_code_base,code_limit;

	code_limit=get_limit(0x0f);
	data_limit=get_limit(0x17);
	old_code_base = get_base(current->ldt[1]);
	old_data_base = get_base(current->ldt[2]);
	if (old_data_base != old_code_base)
		panic("We don't support separate I&D");
	if (data_limit < code_limit)
		panic("Bad data_limit");
	new_data_base = new_code_base = nr * 0x4000000;
	p->start_code = new_code_base;
	set_base(p->ldt[1],new_code_base);
	set_base(p->ldt[2],new_data_base);
	if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
		printk("free_page_tables: from copy_mem\n");
		free_page_tables(new_data_base,data_limit);
		return -ENOMEM;
	}
	return 0;
}

/*
 *  Ok, this is the main fork-routine. It copies the system process
 * information (task[nr]) and sets up the necessary registers. It
 * also copies the data segment in it's entirety.
 */
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;
	*p = *current;	/* NOTE! this doesn't copy the supervisor stack */
	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));
	set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
	p->state = TASK_RUNNING;	/* do this last, just in case */
//这里有修改
         fprintk(3,"%ld\tN\t%ld\n",last_pid,timep);//进程新建好了	,这里的timep宏就是 jiffies
	fprintk(3,"%ld\tJ\t%ld\n",last_pid,timep);//进程肯定准备好了
	//printk("%d J %d\n",last_pid,jiffies);
	return last_pid;
}

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) goto repeat;
	for(i=1 ; i<NR_TASKS ; i++)
		if (!task[i])
			return i;
	return -EAGAIN;
}

 

2.sched.c 包含状态:ready, sleep ,run

/*
 *  linux/kernel/sched.c
 *
 *  (C) 1991  Linus Torvalds
 */

/*
 * 'sched.c' is the main kernel file. It contains scheduling primitives
 * (sleep_on, wakeup, schedule etc) as well as a number of simple system
 * call functions (type getpid(), which just extracts a field from
 * current-task
 */
#include <linux/sched.h>
#include <linux/kernel.h>
#include <linux/sys.h>
#include <linux/fdreg.h>
#include <asm/system.h>
#include <asm/io.h>
#include <asm/segment.h>

#include <signal.h>

#define _S(nr) (1<<((nr)-1))
#define _BLOCKABLE (~(_S(SIGKILL) | _S(SIGSTOP)))

void show_task(int nr,struct task_struct * p)
{
	int i,j = 4096-sizeof(struct task_struct);

	printk("%d: pid=%d, state=%d, ",nr,p->pid,p->state);
	i=0;
	while (i<j && !((char *)(p+1))[i])
		i++;
	printk("%d (of %d) chars free in kernel stack\n\r",i,j);
}

void show_stat(void)
{
	int i;

	for (i=0;i<NR_TASKS;i++)
		if (task[i])
			show_task(i,task[i]);
}

#define LATCH (1193180/HZ)

extern void mem_use(void);

extern int timer_interrupt(void);
extern int system_call(void);

union task_union {
	struct task_struct task;
	char stack[PAGE_SIZE];
};

static union task_union init_task = {INIT_TASK,};

long volatile jiffies=0;
long startup_time=0;
struct task_struct *current = &(init_task.task);
struct task_struct *last_task_used_math = NULL;

struct task_struct * task[NR_TASKS] = {&(init_task.task), };

long user_stack [ PAGE_SIZE>>2 ] ;

struct {
	long * a;
	short b;
	} stack_start = { & user_stack [PAGE_SIZE>>2] , 0x10 };
/*
 *  'math_state_restore()' saves the current math information in the
 * old math state array, and gets the new ones from the current task
 */
void math_state_restore()
{
	if (last_task_used_math == current)
		return;
	__asm__("fwait");
	if (last_task_used_math) {
		__asm__("fnsave %0"::"m" (last_task_used_math->tss.i387));
	}
	last_task_used_math=current;
	if (current->used_math) {
		__asm__("frstor %0"::"m" (current->tss.i387));
	} else {
		__asm__("fninit"::);
		current->used_math=1;
	}
}

/*
 *  'schedule()' is the scheduler function. This is GOOD CODE! There
 * probably won't be any reason to change this, as it should work well
 * in all circumstances (ie gives IO-bound processes good response etc).
 * The one thing you might take a look at is the signal-handler code here.
 *
 *   NOTE!!  Task 0 is the 'idle' task, which gets called when no other
 * tasks can run. It can not be killed, and it cannot sleep. The 'state'
 * information in task[0] is never used.
 */
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) {
			if ((*p)->alarm && (*p)->alarm < jiffies) {
					(*p)->signal |= (1<<(SIGALRM-1));
					(*p)->alarm = 0;
				}
			if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) && (*p)->state==TASK_INTERRUPTIBLE)
			{
				(*p)->state=TASK_RUNNING;
				//there is a ready
				fprintk(3,"%ld\tJ\t%ld\n",(*p)->pid,jiffies);//这里转换,进入就绪
			}
		}

/* this is the scheduler proper: */

	while (1) {
		c = -1;
		next = 0;
		i = NR_TASKS;
		p = &task[NR_TASKS];
		while (--i) {
			if (!*--p)
				continue;
			if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
				c = (*p)->counter, next = i;
		}
		if (c) break;
		for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
			if (*p)
				(*p)->counter = ((*p)->counter >> 1) +
						(*p)->priority;
	}
	if(current != p) 
	{if(current->state == 0)//这里转换:如果下一个要 RUN 的不是正在 RUN 的,给你补上一条ready,因为你将停下来进入就绪状态,换下一个跑。current指针指向当前正在运行的进程。
		fprintk(3,"%ld\tJ\t%ld\n",current->pid,jiffies);
				  //下面转换:如果下一个要 RUN 的就是当前正在 RUN 的,那就不用打印了,否则打印RUN。
	//there is a running
	fprintk(3,"%ld\tR\t%ld\n",p->pid,jiffies);
	}
	switch_to(next);//切到 p 去运行
}

int sys_pause(void)
{
	if(current->state!=TASK_INTERRUPTIBLE){
	current->state = TASK_INTERRUPTIBLE;
	//there is one
	if(current->pid!=0) //这里有一个,可中断的睡眠,就是sleep(wait),守护进程0总在等待,就不用显示它了,闹心。
	fprintk(3,"%ld\tW\t%ld\n",current->pid,jiffies);
	}
	schedule();
	return 0;
}

void sleep_on(struct task_struct **p)
{
	struct task_struct *tmp;

	if (!p)
		return;
	if (current == &(init_task.task))
		panic("task[0] trying to sleep");
	tmp = *p;
	*p = current;
	current->state = TASK_UNINTERRUPTIBLE;
		//there is one
		if(current->pid!=0) //这里有一个,不可中断的睡眠,就是sleep(wait),守护进程0总在等待,就不用显示它了,闹心。
		fprintk(3,"%ld\tW\t%ld\n",current->pid,jiffies);
	schedule();
	*p=tmp;
	if (tmp ){//&& tmp->state!=0){
		tmp->state=TASK_RUNNING;
		//there is one
		//if(tmp->pid!=current->pid)//这里,temp被唤醒就绪,判断防止重复打印
		fprintk(3,"%ld\tJ\t%ld\n",tmp->pid,jiffies);
	}
}

void interruptible_sleep_on(struct task_struct **p)
{
	struct task_struct *tmp;

	if (!p)
		return;
	if (current == &(init_task.task))
		panic("task[0] trying to sleep");
	tmp=*p;
	*p=current;
repeat:	current->state = TASK_INTERRUPTIBLE;
	//there is one wait
	if(current->pid!=0)//这里和上面同理
	fprintk(3,"%ld\tW\t%ld\n",current->pid,jiffies);
	schedule();
	if (*p && *p != current) {
		
		//这里,从调度返回,意味着p被唤醒
		fprintk(3,"%ld\tJ\t%ld\n",(*p)->pid,jiffies);
			(**p).state=0;//这里就唤醒了p
			goto repeat;
	}
	*p=tmp;
	if (tmp){
		//if(tmp->state!=0){
			tmp->state=0;
		//	if(tmp->pid!=current->pid)//此处与上同理
			fprintk(3,"%ld\tJ\t%ld\n",tmp->pid,jiffies);
		//	}
		}
}

void wake_up(struct task_struct **p)
{
	if (p && *p) {
		if((*p)->state!=TASK_RUNNING){
		fprintk(3,"%ld\tJ\t%ld\n",(*p)->pid,jiffies);//这里,同理。
		(**p).state=TASK_RUNNING;
		}
	}
}

//下边是软盘驱动什么的,不改。

3,exit.c 包含状态:exit,

/*
 *  linux/kernel/exit.c
 *
 *  (C) 1991  Linus Torvalds
 */

#include <errno.h>
#include <signal.h>
#include <sys/wait.h>

#include <linux/sched.h>
#include <linux/kernel.h>
#include <linux/tty.h>
#include <asm/segment.h>
#define VARCHEN 0
#define timep jiffies
int sys_pause(void);
int sys_close(int fd);

void release(struct task_struct * p)
{
	int i;

	if (!p)
		return;
	for (i=1 ; i<NR_TASKS ; i++)
		if (task[i]==p) {
			task[i]=NULL;
			free_page((long)p);
			schedule();
			return;
		}
	panic("trying to release non-existent task");
}

static inline int send_sig(long sig,struct task_struct * p,int priv)
{
	if (!p || sig<1 || sig>32)
		return -EINVAL;
	if (priv || (current->euid==p->euid) || suser())
		p->signal |= (1<<(sig-1));
	else
		return -EPERM;
	return 0;
}

static void kill_session(void)
{
	struct task_struct **p = NR_TASKS + task;
	
	while (--p > &FIRST_TASK) {
		if (*p && (*p)->session == current->session)
			(*p)->signal |= 1<<(SIGHUP-1);
	}
}

/*
 * XXX need to check permissions needed to send signals to process
 * groups, etc. etc.  kill() permissions semantics are tricky!
 */
int sys_kill(int pid,int sig)
{
	struct task_struct **p = NR_TASKS + task;
	int err, retval = 0;

	if (!pid) while (--p > &FIRST_TASK) {
		if (*p && (*p)->pgrp == current->pid) 
			if ((err=send_sig(sig,*p,1)))
				retval = err;
	} else if (pid>0) while (--p > &FIRST_TASK) {
		if (*p && (*p)->pid == pid) 
			if ((err=send_sig(sig,*p,0)))
				retval = err;
	} else if (pid == -1) while (--p > &FIRST_TASK) {
		if ((err = send_sig(sig,*p,0)))
			retval = err;
	} else while (--p > &FIRST_TASK)
		if (*p && (*p)->pgrp == -pid)
			if ((err = send_sig(sig,*p,0)))
				retval = err;
	return retval;
}

static void tell_father(int pid)
{
	int i;

	if (pid)
		for (i=0;i<NR_TASKS;i++) {
			if (!task[i])
				continue;
			if (task[i]->pid != pid)
				continue;
			task[i]->signal |= (1<<(SIGCHLD-1));
			return;
		}
/* if we don't find any fathers, we just release ourselves */
/* This is not really OK. Must change it to make father 1 */
	printk("BAD BAD - no father found\n\r");
	release(current);
}

int do_exit(long code)
{
	int i;
	free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
	free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
	for (i=0 ; i<NR_TASKS ; i++)
		if (task[i] && task[i]->father == current->pid) {
			task[i]->father = 1;
			if (task[i]->state == TASK_ZOMBIE){
				/* assumption task[1] is always init */
				(void) send_sig(SIGCHLD, task[1], 1);    //发出子进程退出的信号
				//printk("\nPID:%d Zombie %d\n",task[i]->pid,jiffies);
				fprintk(3,"%ld\tE\t%ld\n",task[i]->pid,timep);    //恭喜这位进程获得将死称号
			}
		}
	for (i=0 ; i<NR_OPEN ; i++)
		if (current->filp[i])
			sys_close(i);
	iput(current->pwd);
	current->pwd=NULL;
	iput(current->root);
	current->root=NULL;
	iput(current->executable);
	current->executable=NULL;
	if (current->leader && current->tty >= 0)
		tty_table[current->tty].pgrp = 0;
	if (last_task_used_math == current)
		last_task_used_math = NULL;
	if (current->leader)
		kill_session();
	current->state = TASK_ZOMBIE;   //这里是状态转换,但是因为EXIT这种状态转换很分散,“赋值为将死”可能出现在任何涉及关闭操作的系统调用中,不好找。然而对zombie的检查却都在上方。所以exit的打印就都截在了循环判断中。
	//如解除下面注释,就会让一些程序“死两遍”...
	//fprintk(3,"%d E %d\n",current->pid,jiffies);
	current->exit_code = code;
	tell_father(current->father);
	schedule();
	return (-1);	/* just to suppress warnings */
}

int sys_exit(int error_code)
{
	return do_exit((error_code&0xff)<<8);
}

int sys_waitpid(pid_t pid,unsigned long * stat_addr, int options)
{
	int flag, code;
	struct task_struct ** p;

	verify_area(stat_addr,4);
repeat:
	flag=0;
	for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) {
		if (!*p || *p == current)
			continue;
		if ((*p)->father != current->pid)
			continue;
		if (pid>0) {
			if ((*p)->pid != pid)
				continue;
		} else if (!pid) {
			if ((*p)->pgrp != current->pgrp)
				continue;
		} else if (pid != -1) {
			if ((*p)->pgrp != -pid)
				continue;
		}
		switch ((*p)->state) {
			case TASK_STOPPED:
				if (!(options & WUNTRACED))
					continue;
				put_fs_long(0x7f,stat_addr);
				return (*p)->pid;
			case TASK_ZOMBIE:    fprintk(3,"%ld\tE\t%ld\n",(*p)->pid,timep);     //这里有一个状态转换,state=zombie
				current->cutime += (*p)->utime;
				current->cstime += (*p)->stime;
				flag = (*p)->pid;
				code = (*p)->exit_code;
				release(*p);
				put_fs_long(code,stat_addr);
				return flag;
			default:
				flag=1;
				continue;
		}
	}
	if (flag) {
		if (options & WNOHANG)
			return 0;
		if(current->state!=TASK_INTERRUPTIBLE){
			current->state=TASK_INTERRUPTIBLE;    //这里变成了等待状态
			if(current->pid!=VARCHEN)              //还是防止0进程捣乱。
			fprintk(3,"%ld\tW\t%ld\n",current->pid,timep);
		}
		schedule();
		if (!(current->signal &= ~(1<<(SIGCHLD-1))))
			goto repeat;
		else
			return -EINTR;
	}
	return -ECHILD;
}

 

这样,状态的寻找就结束了,目标是尽快完成实验的同学可以不用往下看了。

下面简单说说进程调度时间片的事情。

1,什么是时间片?

我们知道,机器的最小时间单位叫做“时钟周期”,也就是晶振11.88MHZ,是执行一条原子指令的时间。几个时钟周期凑合在一起,就是“机器周期”,用于完成一小套动作。

jiffie滴答类似时钟周期,时间片类似机器周期。

时间片标志着CPU对进程的关注程度,高权限的进程得到的时间片大,包含很多滴答,低权限的就小,滴答数相应就少。

2,修改了又怎样?

进程得到的平均时间片大,则进程间切换就不频繁,当时间片无限制时,就是FIFO先来先服务,你完事儿了下一个。反之就来回折腾。

3,怎么修改啊?

[1]让每个滴答的时间变大,这样时间片的绝对时间就变长了。可以在sched.h头文件修改滴答的定义:#define ....  200  . 200HZ的滴答就是每个滴答5ms。

你把它调成100,就是10ms了。

[2]让时间片包含的滴答数目普遍增多,在schedule()里面有一个级数优先级算法 count = count/2 + priority,你给count再加一个常数,就让每个count变大了。

 

 

 

 

 

 

 

Aurora 极光城

講技術,說人話。 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值