linux内核中断详解

24 篇文章 1 订阅
22 篇文章 4 订阅

linux内核中断详解

1、中断的硬件触发流程

外设:如果外设有操作或者有数据可用,那么就会产生一个电信号,这个电信号发送给中断控制器。
中断控制器:中断控制器接收到外设发来的电信号以后,进行进一步的处理,判断这个中断是否使能或者禁止,判断它的优先级等,如果需要发送给CPU一个信号,那么中断控制器就会给cup发送一个电信号。
CPU:CPU接收到中断控制器发送过来的电信号以后,CPU就会无条件跳转到异常向量表的入口,后续CPU就处理对应的中断。

2、中断处理程序编写时的注意事项

1.中断处理函数不隶属于任何进程,所以也就不参与进程之间的调度。
2.中断处理函数要求执行的速度要快,如果不快,其他进程无法获取CPU的资源,影响系统的并发能力。
3.中断处理函数不能与用户空间进行数据的往来!如果要想让中断给用户进行数据的往来,要配合系统调用函数(file_operations)
4.在中断处理函数中,不能调用引起休眠的函数 copy_to_user, copy_from_user, 因为这些函数会进行内存的拷贝, 在拷贝的时候,有可能空闲页不够,就会引起拷贝过程进入休眠状态,等待空闲页出现!

3、共享中断

共享中断:在硬件上,多个外设接在一个中断线上,中断线接在中断控制器上。
每个设备都有自己的驱动程序,每个设备的驱动程序里面都会调用request_irq来注册中断 ,但是在注册时使用的中断号都是相同的。所以在注册中断时,一定要将中断标志或上IRQ_SHARED表示这个中断是共享的,同时dev必须是唯一的(如果中断不用时,释放中断时,由于中断号是相同的,通过dev来区分不同的中断)。如果没有使用这个宏,那么一个驱动使用这个中断号,其他的驱动就无法使用。
对于共享中断,如果中断信号来了以后,其对应的中断处理程序均会执行。因此如果使用共享中断,硬件必须支持能够判断中断是否是自己发出的(例如硬件中包含有中断状态寄存器,就可以在中断处理程序中通过中断状态寄存器的状态,判断中断是否是自己发出的)。

4、中断标志

标志描述
IRQF_SHARED多个设备共享一个中断线,共享的所有中断都必须指定此标志。如果使用共享中断的话, request_irq函数的 dev参数就是唯一区分他们的标志。
IRQF_ONESHOT单次中断,中断执行一次就 结束。即保证中断在底半部执行完之后再打开中断功能,开始接受中断。
IRQF_TRIGGER_NONE无触发。
IRQF_TRIGGER_RISING上升沿触发。
IRQF_TRIGGER_FALLING下降沿触发。
IRQF_TRIGGER_HIGH高电平触发。
IRQF_TRIGGER_LOW低电平触发。
IRQF_NO_SUSPEND在系统suspend的时候,不用disable这个中断,如果disable,可能会导致系统不能正常的resume。
IRQF_NO_THREAD有些low level的interrupt是不能线程化的(例如系统timer的中断),这个flag就是起这个作用的。

5、顶半部与底半部

在某些场合,中断处理函数有可能会处理相对比较耗时,比较多的事情,就会长时间的占有CPU的资源,对系统的并发能力和响应能力有很大的影响,比如网卡的数据包的读取过程。为了解决这个问题,内核将中断处理程序分为顶半部和底半部两个部分。
在顶半部里处理优先级比较高的事情,要求占用中断时间尽量的短,还要登记底半部的事情,在处理完成后,就激活底半部,由底半部处理其余任务。顶半部其实就是中断处理函数,这个过程不可中断!
底半部的处理方式主要有soft_irq, tasklet, workqueue三种,他们在使用方式和适用情况上各有不同。做相对比较耗时,不紧急的事情,这个过程可被中断。
①、如果要处理的内容不希望被其他中断打断,那么可以放到顶半部。
②、如果要处理的任务对时间敏感,可以放到顶半部。
③、如果要处理的任务与硬件有关,可以放到顶半部
④、除了上述三点以外的其他任务,优先考虑放到底半部。

6、底半部机制

6.1 软中断

soft_irq用在对底半部执行时间要求比较紧急或者非常重要的场合,主要为一些subsystem用,一般driver基本上用不上。软中断的优先级低于硬件中断,高于普通的进程。

6.2 tasklet

tasklet是基于软中断实现,它执行的上下文是软中断。它们之间的区别在于同一个tasklet同时一刻只能在一个CPU上运行,但对于软中断,同一时刻可以在多个CPU上执行,这时候在设计软中断的处理函数时,要求其函数具有可重入性(尽量避免使用全局变量,如果使用全局变量,记得要进行互斥访问的保护)。并且软中断的实现必须静态编译,不能采用模块化。相同点是它们都是工作在中断上下文中,不能做休眠的动作 。
tasklet的结构体定义在include\linux\interrupt.h中

// interrupt.h

struct tasklet_struct
{
	struct tasklet_struct *next;  //下一个tasklet
	unsigned long state;  //tasklet状态
	atomic_t count;  //计数器,记录tasklet的引用数
	void (*func)(unsigned long);  //tasklet执行的函数
	unsigned long data;  //函数func 的参数,可以存放普通的整形变量值,也可以存放指针,一般多存放指针
};

如果要使用 tasklet,必须先定义一个 tasklet,然后使用tasklet_init初始化tasklet。

void tasklet_init(struct tasklet_struct *t,void (*func)(unsigned long), unsigned long data);

@t:要初始化的 tasklet
@func: tasklet的处理函数。
@data 要传递给 func函数的参数
或者使用DECLARE_TASKLET一次性完成tasklet的定义和初始化。

DECLARE_TASKLET(name, func, data)

其中 name为要定义的 tasklet名字,这个名字就是一个 tasklet_struct类型的变量, func就是 tasklet的处理函数, data是传递给 func函数的参数。
在顶半部,也就是中断处理函数中调用 tasklet_schedule函数登记tasklet的工作,就能使 tasklet在合适的时间运行, tasklet_schedule函数原型如下:

void tasklet_schedule(struct tasklet_struct *t) 

@t:要调度的 tasklet,也就是 DECLARE_TASKLET宏里面的 name。
tasklet的使用流程为
1、分配tasklet结构体。
struct tasklet_struct my_tasklet;
2、初始化tasklet
tasklet_init(&my_tasklet, tasklet_func, data)
@或者
DECLARE_TASKLET( my_tasklet, tasklet_func, data);
3、在中断顶半部登记tasklet
tasklet_schedule(&my_tasklet)

6.3 工作队列

tasklet和work queue在普通的driver里用的相对较多,主要区别是tasklet是在中断上下文执行不能引起休眠,而work queue是在process上下文,因此可以执行可能sleep的操作。
工作结构体定义在include\linux\workqueue.h中

struct work_struct {
	atomic_long_t data;  //记录工作状态和指向工作者线程的指针
	struct list_head entry;  //工作数据链成员
	work_func_t func;  //工作处理函数
#ifdef CONFIG_LOCKDEP
	struct lockdep_map lockdep_map;
#endif
};

struct delayed_work {
	struct work_struct work;  //工作结构体
	struct timer_list timer;  //推后执行的定时器

	/* target workqueue and CPU ->timer uses to queue ->work */
	struct workqueue_struct *wq;
	int cpu;
};  //处理延时执行的工作的结构体

工作队列结构体定义在kernel\workqueue.c中

struct workqueue_struct {
	struct list_head	pwqs;		/* WR: all pwqs of this wq */
	struct list_head	list;		/* PR: list of all workqueues */

	struct mutex		mutex;		/* protects this wq */
	int			work_color;	/* WQ: current work color */
	int			flush_color;	/* WQ: current flush color */
	atomic_t		nr_pwqs_to_flush; /* flush in progress */
	struct wq_flusher	*first_flusher;	/* WQ: first flusher */
	struct list_head	flusher_queue;	/* WQ: flush waiters */
	struct list_head	flusher_overflow; /* WQ: flush overflow list */

	struct list_head	maydays;	/* MD: pwqs requesting rescue */
	struct worker		*rescuer;	/* MD: rescue worker */

	int			nr_drainers;	/* WQ: drain in progress */
	int			saved_max_active; /* WQ: saved pwq max_active */

	struct workqueue_attrs	*unbound_attrs;	/* PW: only for unbound wqs */
	struct pool_workqueue	*dfl_pwq;	/* PW: only for unbound wqs */

#ifdef CONFIG_SYSFS
	struct wq_device	*wq_dev;	/* I: for sysfs interface */
#endif
#ifdef CONFIG_LOCKDEP
	char			*lock_name;
	struct lock_class_key	key;
	struct lockdep_map	lockdep_map;
#endif
	char			name[WQ_NAME_LEN]; /* I: workqueue name */

	/*
	 * Destruction of workqueue_struct is RCU protected to allow walking
	 * the workqueues list without grabbing wq_pool_mutex.
	 * This is used to dump all workqueues from sysrq.
	 */
	struct rcu_head		rcu;

	/* hot fields used during command issue, aligned to cacheline */
	unsigned int		flags ____cacheline_aligned; /* WQ: WQ_* flags */
	struct pool_workqueue __percpu *cpu_pwqs; /* I: per-cpu pwqs */
	struct pool_workqueue __rcu *numa_pwq_tbl[]; /* PWR: unbound pwqs indexed by node */
};

Linux内核使用工作者线程 (worker thread)来处理工作队列中的各个工作, Linux内核使用worker结构体表示工作者线程。

struct worker {
	/* on idle list while idle, on busy hash table while busy */
	union {
		struct list_head	entry;	/* L: while idle */
		struct hlist_node	hentry;	/* L: while busy */
	};

	struct work_struct	*current_work;	/* L: work being processed */
	work_func_t		current_func;	/* L: current_work's fn */
	struct pool_workqueue	*current_pwq; /* L: current_work's pwq */
	struct list_head	scheduled;	/* L: scheduled works */

	/* 64 bytes boundary on 64bit, 32 on 32bit */

	struct task_struct	*task;		/* I: worker task */
	struct worker_pool	*pool;		/* A: the associated pool */
						/* L: for rescuers */
	struct list_head	node;		/* A: anchored at pool->workers */
						/* A: runs through worker->node */

	unsigned long		last_active;	/* L: last active timestamp */
	unsigned int		flags;		/* X: flags */
	int			id;		/* I: worker id */
	int			sleeping;	/* None */

	/*
	 * Opaque string set with work_set_desc().  Printed out with task
	 * dump for debugging - WARN, BUG, panic or sysrq.
	 */
	char			desc[WORKER_DESC_LEN];

	/* used only by rescuers to point to the target workqueue */
	struct workqueue_struct	*rescue_wq;	/* I: the workqueue to rescue */

	/* used by the scheduler to determine a worker's last known identity */
	work_func_t		last_func;
};

可以看出,每个 worker都有一个工作队列,工作者线程处理自己工作队列中的所有工作。在实际的驱动开发中,我们只需要定义工作 (work_struct)即可,关于工作队列和工作者线程我们基本不用去管。
如果要使用工作队列 ,首先需要定义一个work_struct结构体变量,然后使用 INIT_WORK宏来初始化工作。

#define INIT_WORK(_work, _func)						\
	__INIT_WORK((_work), (_func), 0)

#define INIT_DELAYED_WORK(_work, _func)					\
	__INIT_DELAYED_WORK(_work, _func, 0)

@_work:要初始化的工作
@_func: 工作对应的处理函数
也可以使用DECLARE_WORK宏一次性完成工作的创建和初始化。

#define DECLARE_WORK(n, f)						\
	struct work_struct n = __WORK_INITIALIZER(n, f)

#define DECLARE_DELAYED_WORK(n, f)					\
	struct delayed_work n = __DELAYED_WORK_INITIALIZER(n, f, 0)

@n:定义的工作work_struct
@f:工作对应的处理函数
和 tasklet一样,工作也是需要登记才能调度运行,工作的登记函数为 schedule_work

static inline bool schedule_work(struct work_struct *work)
{
	return queue_work(system_wq, work);
}

static inline bool schedule_delayed_work(struct delayed_work *dwork,
					 unsigned long delay)
{
	return queue_delayed_work(system_wq, dwork, delay);
}

@work:要调度的工作
@delay:延时调度的延时时间
工作的使用流程为
1、分配work_struct结构体
struct work_struct my_work;
struct delayed_work_struct my_dwork;
2、初始化工作
INIT_WORK(&my_work, my_work_func);
INIT_DELAYED_WORK(&my_dwork, my_dwork_func);
@或者
DECLARE_WORK(my_work, my_work_func);
DECLARE_DELAYED_WORK(my_dwork, my_dwork_func)
3、在顶半部中断中登记工作
schedule_work(&my_work)
schedule_delayed_work(&my_dwork, times)

在Linux kernel中,一个外设的中断处理被分成top half和bottom half,top half进行最关键,最基本的处理,而比较耗时的操作被放到bottom half(softirq、tasklet)中延迟执行。虽然bottom half被延迟执行,但始终都是先于进程执行的。为何不让这些耗时的bottom half和普通进程公平竞争呢?因此,linux kernel借鉴了RTOS的某些特性,对那些耗时的驱动interrupt handler进行线程化处理,在内核的抢占点上,让线程(无论是内核线程还是用户空间创建的线程,还是驱动的interrupt thread)在一个舞台上竞争CPU。

6.4 线程化irq

7、API函数

int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)

在 Linux内核中要想使用某个中断是需要申请的, request_irq函数用于申请中断, request_irq函数可能会导致睡眠,因此不能在中断上下文或者其他禁止睡眠的代码段中使用 request_irq函数。 request_irq函数会激活 (使能 )中断,所以不需要我们手动去使能中断。
@irq:要申请中断的中断号。
@handler:中断处理函数,当中断发生以后就会执行此中断处理函数。
@flags:中断标志,可以在文件 include/linux/interrupt.h里面查看所有的中断标志。
@name:中断名字,设置以后可以在 /proc/interrupts文件中看到对应的中断名字。
@dev 如果将 flags设置为 IRQF_SHARED的话, dev用来区分不同的中断,一般情况下将dev设置为设备结构体, dev会传递给中断处理函数 irq_handler_t的第二个参数。
@返回值: 0 中断申请成功,其他负值 中断申请失败,如果返回 -EBUSY的话表示中断已经被申请了。

void free_irq(unsigned int irq, void *dev)

使用中断的时候需要通过 request_irq函数申请,使用完成以后就要通过 free_irq函数释放掉相应的中断。如果中断不是共享的,那么 free_irq会删除中断处理函数并且禁止中断。
@irq: 要释放的中断。
@dev:如果中断设置为共享 (IRQF_SHARED)的话,此参数用来区分具体的中断。共享中断只有在释放最后中断处理函数的时候才会被禁止掉。

irqreturn_t (*irq_handler_t) (int, void *)

使用 request_irq函数申请中断的时候需要设置中断处理函数。
第一个参数是要中断处理函数要相应的中断号。第二个参数是一个指向 void的指针,也就是个通用指针,需要与 request_irq函数的 dev参数保持一致。用于区分共享中断的不同设备,dev也可以指向设备数据结构。

int request_threaded_irq(unsigned int irq, irq_handler_t handler, irq_handler_t thread_fn, unsigned long irqflags, const char *devname, void *dev_id)
输入参数描述
irq要注册handler的那个IRQ number。这里要注册的handler包括两个,一个是传统意义的中断handler,我们称之primary handler(类似于顶半部),另外一个是threaded interrupt handler(类似于底半部)
handlerprimary handler。需要注意的是primary handler和threaded interrupt handler不能同时为空,否则会出错。如果该函数结束时,返回的是IRQ_WAKE_THREAD,内核会调度对应线程执行thread_fn对应的函数。该参数可以设置为空,这种情况下内核会默认的irq_default_primary_handler()代替handler,并会使用IRQF_ONESHOT标记。
thread_fnthreaded interrupt handler。如果该参数不是NULL,那么系统会创建一个kernel thread,调用的function就是thread_fn
irqflags参见本章第4节中断标志,这里支持设置IRQF_ONESHOT标记,这样内核会自动帮助我们在中断上下文屏蔽对应的中断号,而在内核调度thread_fn执行后,重新使能该中断号。对于我们无法在顶半部清楚中断的情况,IRQF_ONESHOT特别有用。
devname请求中断的设备名称
dev_id传递给中断处理函数handler的参数,如果是使用IRQF_SHARED时,用于区分不同的中断。
int devm_request_threaded_irq(struct device *dev, unsigned int irq,
			      irq_handler_t handler, irq_handler_t thread_fn,
			      unsigned long irqflags, const char *devname,
			      void *dev_id)
{
	struct irq_devres *dr;
	int rc;

	dr = devres_alloc(devm_irq_release, sizeof(struct irq_devres),
			  GFP_KERNEL);
	if (!dr)
		return -ENOMEM;

	if (!devname)
		devname = dev_name(dev);

	rc = request_threaded_irq(irq, handler, thread_fn, irqflags, devname,
				  dev_id);
	if (rc) {
		devres_free(dr);
		return rc;
	}

	dr->irq = irq;
	dr->dev_id = dev_id;
	devres_add(dev, dr);

	return 0;
}

从函数实现可以看出,该函数的实现是通过request_threaded_irq实现的。该函数与request_threaded_irq的区别在于,该函数在驱动程序分离时,能够自动释放中断线。
如果释放该函数分配的中断线需要使用devm_free_irq

void devm_free_irq(struct device *dev, unsigned int irq, void *dev_id)
/**
 *	disable_irq - disable an irq and wait for completion
 *	@irq: Interrupt to disable
 *
 *	Disable the selected interrupt line.  Enables and Disables are
 *	nested.
 *	This function waits for any pending IRQ handlers for this interrupt
 *	to complete before returning. If you use this function while
 *	holding a resource the IRQ handler may need you will deadlock.
 *
 *	This function may be called - with care - from IRQ context.
 */
void disable_irq(unsigned int irq)
{
	if (!__disable_irq_nosync(irq))
		synchronize_irq(irq);
}
EXPORT_SYMBOL(disable_irq);

如果在n号中断的顶半部调用disable_irq(n),会引起系统的死锁,在这种情况下只能调用disable_irq_nosync(n)。

/**
 *	disable_irq_nosync - disable an irq without waiting
 *	@irq: Interrupt to disable
 *
 *	Disable the selected interrupt line.  Disables and Enables are
 *	nested.
 *	Unlike disable_irq(), this function does not ensure existing
 *	instances of the IRQ handler have completed before returning.
 *
 *	This function may be called from IRQ context.
 */
void disable_irq_nosync(unsigned int irq)
{
	__disable_irq_nosync(irq);
}
/**
 *	enable_irq - enable handling of an irq
 *	@irq: Interrupt to enable
 *
 *	Undoes the effect of one call to disable_irq().  If this
 *	matches the last disable, processing of interrupts on this
 *	IRQ line is re-enabled.
 *
 *	This function may be called from IRQ context only when
 *	desc->irq_data.chip->bus_lock and desc->chip->bus_sync_unlock are NULL !
 */
void enable_irq(unsigned int irq)
{
	unsigned long flags;
	struct irq_desc *desc = irq_get_desc_buslock(irq, &flags, IRQ_GET_DESC_CHECK_GLOBAL);

	if (!desc)
		return;
	if (WARN(!desc->irq_data.chip,
		 KERN_ERR "enable_irq before setup/request_irq: irq %u\n", irq))
		goto out;

	__enable_irq(desc);
out:
	irq_put_desc_busunlock(desc, flags);
}

8 request_threaded_irq分析

int request_threaded_irq(unsigned int irq, irq_handler_t handler,
			 irq_handler_t thread_fn, unsigned long irqflags,
			 const char *devname, void *dev_id)
{
	struct irqaction *action;
	struct irq_desc *desc;
	int retval;

	if (irq == IRQ_NOTCONNECTED)
		return -ENOTCONN;

	/*
	 * Sanity-check: shared interrupts must pass in a real dev-ID,
	 * otherwise we'll have trouble later trying to figure out
	 * which interrupt is which (messes up the interrupt freeing
	 * logic etc).
	 *
	 * Also IRQF_COND_SUSPEND only makes sense for shared interrupts and
	 * it cannot be set along with IRQF_NO_SUSPEND.
	 */
	if (((irqflags & IRQF_SHARED) && !dev_id) ||
	    (!(irqflags & IRQF_SHARED) && (irqflags & IRQF_COND_SUSPEND)) ||
	    ((irqflags & IRQF_NO_SUSPEND) && (irqflags & IRQF_COND_SUSPEND)))
		return -EINVAL;

	desc = irq_to_desc(irq);
	if (!desc)
		return -EINVAL;

	if (!irq_settings_can_request(desc) ||
	    WARN_ON(irq_settings_is_per_cpu_devid(desc)))
		return -EINVAL;

	if (!handler) {
		if (!thread_fn)
			return -EINVAL;
		handler = irq_default_primary_handler;
	}

	action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
	if (!action)
		return -ENOMEM;

	action->handler = handler;
	action->thread_fn = thread_fn;
	action->flags = irqflags;
	action->name = devname;
	action->dev_id = dev_id;

	retval = irq_chip_pm_get(&desc->irq_data);
	if (retval < 0) {
		kfree(action);
		return retval;
	}

	retval = __setup_irq(irq, desc, action);

	if (retval) {
		irq_chip_pm_put(&desc->irq_data);
		kfree(action->secondary);
		kfree(action);
	}

#ifdef CONFIG_DEBUG_SHIRQ_FIXME
	if (!retval && (irqflags & IRQF_SHARED)) {
		/*
		 * It's a shared IRQ -- the driver ought to be prepared for it
		 * to happen immediately, so let's make sure....
		 * We disable the irq to make sure that a 'real' IRQ doesn't
		 * run in parallel with our fake.
		 */
		unsigned long flags;

		disable_irq(irq);
		local_irq_save(flags);

		handler(irq, dev_id);

		local_irq_restore(flags);
		enable_irq(irq);
	}
#endif
	return retval;
}
EXPORT_SYMBOL(request_threaded_irq);

首先是判断传入参数的安全性

	if (((irqflags & IRQF_SHARED) && !dev_id) ||
	    (!(irqflags & IRQF_SHARED) && (irqflags & IRQF_COND_SUSPEND)) ||
	    ((irqflags & IRQF_NO_SUSPEND) && (irqflags & IRQF_COND_SUSPEND)))
		return -EINVAL;

如果设置了IRQF_SHARED但是没有传入dev_id时,会直接返回错误,因为这样的设置在释放中断函数的时候回出错。

	desc = irq_to_desc(irq);

通过中断号获取中断描述符。在linux kernel中,对于每一个外设的IRQ都用struct irq_desc来描述,我们称之中断描述符(struct irq_desc)。linux kernel中会有一个数据结构保存了关于所有IRQ的中断描述符信息,我们称之中断描述符DB。当发生中断后,首先获取触发中断的HW interupt ID,然后通过irq domain翻译成IRQ number,然后通过IRQ number就可以获取对应的中断描述符。调用中断描述符中的highlevel irq-events handler来进行中断处理就OK了。

	if (!irq_settings_can_request(desc) ||
	    WARN_ON(irq_settings_is_per_cpu_devid(desc)))
		return -EINVAL;

并非系统中所有的IRQ number都可以request,有些中断描述符被标记为IRQ_NOREQUEST,标识该IRQ number不能被其他的驱动request。一般而言,这些IRQ number有特殊的作用,例如用于级联的那个IRQ number是不能request。irq_settings_can_request函数就是判断一个IRQ是否可以被request。

	if (!handler) {
		if (!thread_fn)
			return -EINVAL;
		handler = irq_default_primary_handler;

这里是对传入的两个函数进行判断

primary handlerthreaded handler描述
NULLNULL函数出错,返回-EINVAL
设定设定正常流程。中断处理被合理的分配到primary handler和threaded handler中。
设定NULL中断处理都是在primary handler中完成
NULL设定这种情况下,系统会帮忙设定一个default的primary handler:irq_default_primary_handler,协助唤醒threaded handler线程
	action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
	if (!action)
		return -ENOMEM;

	action->handler = handler;
	action->thread_fn = thread_fn;
	action->flags = irqflags;
	action->name = devname;
	action->dev_id = dev_id;

	retval = irq_chip_pm_get(&desc->irq_data);
	if (retval < 0) {
		kfree(action);
		return retval;
	}

	retval = __setup_irq(irq, desc, action);

这部分的代码很简单,分配struct irqaction,赋值,调用__setup_irq进行实际的注册过程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值