kdump+crash 解决休眠锁(mutex)死锁问题

目录

1、实验目的:使用crash 解决休眠锁(mutex)死锁问题

2、实验步骤

3、hung task 机制简述

4、mutex 死锁问题分析

4.1、确认死锁线程

4.1.1、在crash中通过 ps | grep UN 命令确定D状态线程,bt pid 查看D状态进程信息

4.1.2、hung task 机制确定死锁线程 

4.2、根据死锁线程找到对应休眠锁

4.3、分析mutex锁内容,解析出获取锁以及等待锁的线程

4.3.1、struct mutex 关键成员分析

4.3.2、解析mutex的2个关键成员

4.4、使用bt命令查看阻塞线程的栈回溯

环境:arm64,Linux version 5.10.66

1、实验目的:使用crash 解决休眠锁(mutex)死锁问题

        实验程序如下,当程序编译成ko并使用insmod加载到设备后,串口输入 echo kdump-3 > /proc/dbug/dump 命令之后执行我们的测试程序,测试程序是典型的AB-BA死锁,等待2分钟后 hung task 检测到有线程2min没有被调度,调用panic触发kdump。kdump产生vmcore文件后使用crash命令来分析下休眠锁(mutex)死锁问题。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>
#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>

#include <linux/device.h>
#include <linux/proc_fs.h>
#include <linux/version.h>

#include <linux/mman.h>
#include <linux/mm.h>
#include <linux/printk.h>
#include <linux/slab.h>
#include <linux/string.h>
#include <linux/kasan.h>
#include <linux/kernel.h>
#include <linux/spinlock.h>
#include <linux/kthread.h>
/* 
写成功要返回写入的字节数,否则 linux 还会尝试写入
 */

#define PROC_DIR_NAME		"dbug"			//文件夹名
#define PROC_FILE_NAME		"kdump"			//文件名称
#define KBUFSIZE 40
#define NAMELEN 20

typedef struct kdump_mutex {
	char cName[NAMELEN];
	int iDelay;
	int iTime;
	struct mutex lock;
	struct task_struct *thread;
	void (*printk)(void *p);
} KdumpMutex_st;

static KdumpMutex_st *gpstKdMutex1 = NULL;
static KdumpMutex_st *gpstKdMutex2 = NULL;

void kdump_printk(KdumpMutex_st *pstKdMutex)
{
	if(pstKdMutex != NULL)
		printk("Mutex lock name %s time = %d \n", pstKdMutex->cName, pstKdMutex->iTime);
	return;
}

static int mutex_thread1(void* arg)
{
	
	KdumpMutex_st *pstKdMutex = NULL;
	pstKdMutex = (KdumpMutex_st *)arg;
	
	/* 获取互斥锁1 */
	mutex_lock(&pstKdMutex->lock);
	pstKdMutex->iTime++;
	pstKdMutex->printk(pstKdMutex);
	msleep(pstKdMutex->iDelay);

	/* 获取互斥锁2 */
	mutex_lock(&gpstKdMutex2->lock);

	mutex_unlock(&pstKdMutex->lock);
	mutex_unlock(&gpstKdMutex2->lock);
	return 0;
}

static int mutex_thread2(void* arg)
{
	
	KdumpMutex_st *pstKdMutex = NULL;
	pstKdMutex = (KdumpMutex_st *)arg;
	
	/* 获取互斥锁2 */
	mutex_lock(&pstKdMutex->lock);
	pstKdMutex->iTime++;
	pstKdMutex->printk(pstKdMutex);
	msleep(pstKdMutex->iDelay);

	/* 获取互斥锁1 */
	mutex_lock(&gpstKdMutex1->lock);

	mutex_unlock(&pstKdMutex->lock);
	mutex_unlock(&gpstKdMutex1->lock);
	return 0;
}

int proc_kdump_mutex(int delay)
{
	/* 填充线程1的数据并运行线程 */
	gpstKdMutex1 = kzalloc(sizeof(KdumpMutex_st), GFP_KERNEL);
	strncpy(gpstKdMutex1->cName, "mutex thread_1", NAMELEN);
	mutex_init(&gpstKdMutex1->lock);
	gpstKdMutex1->printk = (void (*)(void *p))kdump_printk;
	gpstKdMutex1->iDelay = delay;
	gpstKdMutex1->thread = kthread_run(mutex_thread1, gpstKdMutex1, gpstKdMutex1->cName);
	
	/* 填充线程2的数据并运行线程 */
	gpstKdMutex2 = kzalloc(sizeof(KdumpMutex_st), GFP_KERNEL);
	strncpy(gpstKdMutex2->cName, "mutex thread_2", NAMELEN);
	mutex_init(&gpstKdMutex2->lock);
	gpstKdMutex2->printk = (void (*)(void *p))kdump_printk;
	gpstKdMutex2->iDelay = delay;
	gpstKdMutex2->thread = kthread_run(mutex_thread2, gpstKdMutex2, gpstKdMutex2->cName);
	
	return 0;
}


/* 创建/proc/dbug/kdump 的调试接口,此部分不必关注 */
char kbuf[KBUFSIZE] = {0};		//保存用户层传进来的数捿
struct proc_dir_entry *proc_wrbuff_dir;

static int proc_wrbuff_open(struct inode *inode,struct file *file);  
static ssize_t proc_wrbuff_read(struct file *file, char __user *ubuf, size_t count, loff_t *offset);  
static ssize_t proc_wrbuff_write(struct file *file, const char __user *ubuf, size_t count, loff_t *offset);  


static int proc_wrbuff_open(struct inode *inode,struct file *file)  {  
	printk("open embedsky board device!\n");  
	return 0;  
}

static ssize_t proc_wrbuff_read(struct file *file, char __user *ubuf, size_t count, loff_t *offset)  {  
	if (count > strlen(kbuf))
		count = strlen(kbuf);
	if (count < 0 )
		return -3;
	if (copy_to_user(ubuf, kbuf, count)) {
		printk(KERN_ERR "copy_to_user failed! \n");
		return -4;
	}
	return count;
}

static ssize_t proc_wrbuff_write(struct file *file, const char __user *ubuf, size_t count, loff_t *offset) {
	
	int num = 0;
	size_t cnt = min((int)count, KBUFSIZE - 1);
	if(copy_from_user(kbuf,ubuf,cnt)) {
		printk(KERN_ERR "copy_to_user failed! \n");
		return -EFAULT;
	}
	kbuf[cnt] = '\0';
	printk("printk kbuf %s \n",kbuf);

	if(sscanf(kbuf, "kdump-%d", &num)) {
		proc_kdump_mutex(num);
	}

	return cnt;
}

#if LINUX_VERSION_CODE > KERNEL_VERSION(5, 10, 0)
static struct proc_ops fops_proc_wrbuffer = {

	.proc_open = proc_wrbuff_open,
	.proc_read = proc_wrbuff_read,
	.proc_write = proc_wrbuff_write,
 };
#else
static struct file_operations fops_proc_wrbuffer = {
	.owner = THIS_MODULE,
	.open = proc_wrbuff_open,
	.read = proc_wrbuff_read,
	.write = proc_wrbuff_write,
	.owner = THIS_MODULE,
 };
#endif


static int __init proc_wrbuff_init(void) {
	int ret = 0;  
	struct proc_dir_entry *proc_file;

	/* 1 create parent dir in /porc/dbug */
	proc_wrbuff_dir = proc_mkdir(PROC_DIR_NAME, NULL);
	if(!proc_wrbuff_dir){
		printk("create proc dir error! \n");
		return -1;
	}
	/* 2 creata device file in /proc/parent dir*/
	proc_file = proc_create_data(PROC_FILE_NAME, 0666, proc_wrbuff_dir,&fops_proc_wrbuffer,0);
	if (!proc_file) {
		printk("create proc file error! \n");
		ret = -2;
		goto no_proc_file;
	}

	return 0;

no_proc_file:
	remove_proc_entry(PROC_FILE_NAME,proc_wrbuff_dir);
	return ret;
}

static void __exit proc_wrbuff_exit(void) {

	remove_proc_entry(PROC_FILE_NAME,proc_wrbuff_dir);
	remove_proc_entry(PROC_DIR_NAME, NULL);
}
late_initcall(proc_wrbuff_init);
module_exit(proc_wrbuff_exit);

MODULE_DESCRIPTION("debug");
MODULE_LICENSE("GPL");

2、实验步骤

前提:设备linux支持了kdump功能,支持方法参考此专题文章 

a、编译测试模块:将上述源码编译成kdump.ko驱动模块,在设备串口中使用insmod命令加载此驱动到设备中。此时会生成 /proc/dbug/kdump 调试文件

b、触发panic:执行 echo kdump-4 > /proc/dbug/kdump 命令执行测试程序,测试程序会执行 panic 触发kdump机制,启动捕获内核,并在捕获内核中生成 /proc/vmcore 文件。

c、保存vmcore文件:执行 cd /proc;tar -czf /tmp/3588/vmcore.tar.gz ./vmcore 将捕获内核下的vmcore文件压缩并拷贝到u盘或者nfs挂载的目录中。

d、使用crash分析vmcore文件:执行 crash vmlinux vmcore 命令使用crash分析vmcore文件。

e、由于vmcore文件只会保留kdump.ko的代码部分,因此需要在crash中使用 mod加载kdump.ko 驱动模块的调试以及符号信息。这样执行 crash> dis -l kdump_proc_write 命令才会正确显示汇编代码对应的行号信息

mod -s kdump /kdump/demo/stack/kdump.ko

3、hung task 机制简述

a、hung task 核心思想

        为创建一个内核监测进程(khungtaskd)循环监测处于D状态的每一个进程(任务),统计它们在两次检测之间的调度次数,如果发现有任务在两次监测之间没有发生任何的调度,则可判断该进程一直处于D状态,很有可能已经死锁,因此调用panic,输出进程的基本信息,栈回溯以及寄存器保存信息以供内核开发人员定位。循环周期通过 CONFIG_DEFAULT_HUNG_TASK_TIMEOUT 配置,单位为秒。 

b、触发 hung task 机制的可能原因

        相关任务或内核线程使用的completion, mutex, wait event等同步机制等待条件满足,等待过程中超过 CONFIG_DEFAULT_HUNG_TASK_TIMEOUT 设定的时间没被唤醒。

c、hung task 机制的proc接口

/proc/sys/kernel/hung_task_all_cpu_backtrace
/*  hung task 检测进程的最大数,包括非D态进程 */
/proc/sys/kernel/hung_task_check_count
/proc/sys/kernel/hung_task_check_interval_secs
/* hung task 是否导致内核 panic */
/proc/sys/kernel/hung_task_panic
/* D状态进程触发 hung task 机制的时间阈值,一般为120秒,设置为0则khungtaskd进程不在调度,关闭了hung task 功能 */
/proc/sys/kernel/hung_task_timeout_secs    
/* hung task 报警信息最大次数 */
/proc/sys/kernel/hung_task_warnings


D状态:线程的一种等待状态TASK_UNINTERRUPTIBLE,该种状态下进程不接收信号,只能通过wake_up唤醒。

4、mutex 死锁问题分析

解决休眠死锁问题,三个关键点:

a、找到死锁的线程

b、根据死锁线程获取锁

c、解析获取锁和等待锁的线程

d、查看死锁线程的堆栈信息

4.1、确认死锁线程

        针对 mutex 锁,线程无法获取锁时进入 D 状态进行休眠,当出现死锁问题,相关线程也都会休眠进入 D 状态。mutex死锁可以通过 hung task 线程检测,也是 hung task 触发panic,所以bt打出来的堆栈为 hung task 的信息,与死锁线程无关。这里介绍2中方法查找死锁线程。

crash> bt
PID: 59       TASK: ffffff81010b0000  CPU: 3    COMMAND: "khungtaskd"
 #0 [ffffffc0127bbb20] machine_kexec at ffffffc01001f63c
 #1 [ffffffc0127bbb70] __crash_kexec at ffffffc0100d91bc
 #2 [ffffffc0127bbd00] panic at ffffffc010e847f4
 #3 [ffffffc0127bbde0] watchdog at ffffffc0100ef918
 #4 [ffffffc0127bbe60] kthread at ffffffc010058270
4.1.1、在crash中通过 ps | grep UN 命令确定D状态线程,bt pid 查看D状态进程信息

a、ps | grep UN 命令会打印出所有处于D状态的线程,其中必然包括死锁的2个线程。

crash> ps | grep UN
      373       2   4  ffffff8102012400  UN   0.0        0        0  [OSA_372_1]
     5544       2   4  ffffff8106bf6c00  UN   0.0        0        0  [mutex thread_1]
     5545       2   5  ffffff8106bf3600  UN   0.0        0        0  [mutex thread_2]
crash>

b、bt pid 命令查看每个D状态线程的栈回溯打印,确定线程进入D状态的原因,找到因为死锁而进入D状态的线程。

crash> bt 373
PID: 373      TASK: ffffff8102012400  CPU: 4    COMMAND: "OSA_372_1"
 #0 [ffffffc012d93c20] __switch_to at ffffffc010007e88
 #1 [ffffffc012d93c50] __schedule at ffffffc010eafa54
 #2 [ffffffc012d93cd0] schedule at ffffffc010eafd20
 #3 [ffffffc012d93cf0] schedule_timeout at ffffffc010eb23bc
 #4 [ffffffc012d93d60] schedule_timeout_uninterruptible at ffffffc010eb2470
 #5 [ffffffc012d93d70] msleep at ffffffc0100b8890
 #6 [ffffffc012d93d80] OSA_msleep at ffffffc008ee9e58 [osa]
 #7 [ffffffc012d93d90] OSA_tskRun at ffffffc008eedc28 [osa]
 #8 [ffffffc012d93e10] OSA_thrRunBody at ffffffc008ee99cc [osa]
 #9 [ffffffc012d93e60] kthread at ffffffc010058270

查看 373 [OSA_372_1] 进程的栈回溯,发现此线程是调用 msleep 进入D状态,与锁无关。

crash> bt 5544
PID: 5544     TASK: ffffff8106bf6c00  CPU: 4    COMMAND: "mutex thread_1"
 #0 [ffffffc01334bcc0] __switch_to at ffffffc010007e88
 #1 [ffffffc01334bcf0] __schedule at ffffffc010eafa54
 #2 [ffffffc01334bd70] schedule at ffffffc010eafd20
 #3 [ffffffc01334bd90] schedule_preempt_disabled at ffffffc010eaff90
 #4 [ffffffc01334bda0] __mutex_lock.constprop.0 at ffffffc010eb0e08
 #5 [ffffffc01334be10] __mutex_lock_slowpath at ffffffc010eb0fe0
 #6 [ffffffc01334be20] mutex_lock at ffffffc010eb1024
 #7 [ffffffc01334be40] mutex_thread1 at ffffffc008fb8100 [kdump]
 #8 [ffffffc01334be60] kthread at ffffffc010058270
crash>

查看 5544 [mutex thread_1] 线程栈回溯,发现此函数是因为无法获取锁而进入D状态,基本可以确认是死锁线程。同理查看 5545 [mutex thread_2]  线程有类似打印。

总结:休眠锁(mutex,rw_samaphore)死锁会导致线程进入休眠的D状态,通过ps | grep UN 命令确定所有的D状态线程;通过bt pid 命令查看D状态线程休眠的原因,找到因为无法获取锁而进入休眠的线程。

4.1.2、hung task 机制确定死锁线程 

         hung task 机制会把D状态线程且超过2分钟没有调度的线程信息打印出来,hung task 打印出来的D状态线程大概率是死锁线程,可以通过 bt pid 命令查看栈回溯确定。

~ # echo kdump-3000 > /proc/dbug/kdump
[  598.895301] Mutex lock name mutex thread_1 time = 1 
[  598.895336] Mutex lock name mutex thread_2 time = 1 

~ # [  773.271035] INFO: task mutex thread_1:5544 blocked for more th[  773.271052] task:mutex thread_1  state:D stack:    0 pid: 5544 ppid:     2 flags:0x00000008
[  773.271057] Call trace:
[  773.271064]  __switch_to+0x134/0x1ac
[  773.271069]  __schedule+0x3bc/0x590
[  773.271072]  schedule+0x80/0xc8
[  773.271076]  schedule_preempt_disabled+0x1c/0x28
[  773.271080]  __mutex_lock.constprop.0+0x168/0x234
[  773.271083]  __mutex_lock_slowpath+0x1c/0x28
[  773.271087]  mutex_lock+0x38/0x48
[  773.271093]  mutex_thread1+0x60/0x88 [kdump]
[  773.271096]  kthread+0xf8/0x108
[  773.271100]  ret_from_fork+0x10/0x18

[  773.271103] INFO: task mutex thread_2:5545 blocked for more than 122 seconds.
[  773.271105]       Tainted: P           O      5.10.66 #1
[  773.271108] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.
[  773.271110] task:mutex thread_2  state:D stack:    0 pid: 5545 ppid:     2 flags:0x00000008
[  773.271049] "[  773.436617]  dump_stack+0x18/0x34
[  773.439923]  panic+0x170/0x35c
echo 0 > /proc/sys/kernel/hung_task_timeou[  773.446618]  watchdog+0x154/0x35c
[  773.449923]  kthread+0xf8/0x108
t_secs" disables this message.
[  773.455831]  ret_from_fork+0x10/0x18
[  773.459407] SMP: stopping secondary CPUs
[  773.463530] Starting crashdump kernel...
[  773.467451] Bye!

4.2、根据死锁线程找到对应休眠锁

根据死锁线程的栈回溯打印,在程序中很容易能查找到线程中使用了具体什么锁导致的死锁。

crash> bt 5544
PID: 5544     TASK: ffffff8106bf6c00  CPU: 4    COMMAND: "mutex thread_1"
 #0 [ffffffc01334bcc0] __switch_to at ffffffc010007e88
 #1 [ffffffc01334bcf0] __schedule at ffffffc010eafa54
 #2 [ffffffc01334bd70] schedule at ffffffc010eafd20
 #3 [ffffffc01334bd90] schedule_preempt_disabled at ffffffc010eaff90
 #4 [ffffffc01334bda0] __mutex_lock.constprop.0 at ffffffc010eb0e08
 #5 [ffffffc01334be10] __mutex_lock_slowpath at ffffffc010eb0fe0
 #6 [ffffffc01334be20] mutex_lock at ffffffc010eb1024
 #7 [ffffffc01334be40] mutex_thread1 at ffffffc008fb8100 [kdump]
 #8 [ffffffc01334be60] kthread at ffffffc010058270
crash>

 本例子中很容易就知道是 mutex_thread1 线程中的两把mutex锁:

gpstKdMutex1->lock 和 gpstKdMutex2->lock;

static int mutex_thread1(void* arg)
{
	KdumpMutex_st *pstKdMutex = NULL;
	pstKdMutex = (KdumpMutex_st *)arg;
	/* 获取互斥锁1 */
	mutex_lock(&pstKdMutex->lock);
    ..............................
	/* 获取互斥锁2 */
	mutex_lock(&gpstKdMutex2->lock);

	mutex_unlock(&pstKdMutex->lock);
	mutex_unlock(&gpstKdMutex2->lock);
	return 0;
}

 获取锁的具体内容

crash> rd gpstKdMutex1
ffffffc008fba480:  ffffff810791b080                    ........

crash> struct KdumpMutex_st.lock ffffff810791b080 -x
  lock = {
    owner = {
      counter = 0xffffff8106bf6c01
    },
    wait_list = {
      next = 0xffffffc013353de8,
      prev = 0xffffffc013353de8
    },
  }

4.3、分析mutex锁内容,解析出获取锁以及等待锁的线程

4.3.1、struct mutex 关键成员分析
struct mutex {
    atomic_long_t       owner;
    struct list_head    wait_list;
};

a、atomic_long_t    owner 成员

owner 成员分2部分:针对arm64架构,owner 成员是一个64bit的long型数据,bit[6 : 63] 保存获取此mutex锁的线程地址,也就是线程  struct task_struc 结构体的地址;bit[0 : 5]中去低3bit作为mutex锁的标记位。

 owner 成员bit[0 : 5]含义: arm64架构 struct task_struct 结构体地址低6bit地址为零, mutex 锁只会使用其中低3bit,具体含义定义如下:

/* @owner: contains: 'struct task_struct *' to the current lock owner,
 * NULL means not owned. 
 * Bit0 indicates a non-empty waiter list; unlock must issue a wakeup.
 * Bit1 indicates unlock needs to hand the lock to the top-waiter
 * Bit2 indicates handoff has been done and we're waiting for pickup  */
#define MUTEX_FLAG_WAITERS    0x01
#define MUTEX_FLAG_HANDOFF    0x02
#define MUTEX_FLAG_PICKUP    0x04
#define MUTEX_FLAGS        0x07

  owner 成员bit[6 : 63] 含义: 保存获取此mutex的线程指针, 即 struct task_struct 结构体的地址

 针对arm64架构, struct task_struct 结构体地址都按照 L1_CACHE_BYTES 字节对齐,根据下面定义也就是64字节对齐,也就是说 struct task_struct 地址转化为2进制最后6bit全为0;

\linux\src\arch\arm64\include\asm\cache.h
#define L1_CACHE_SHIFT        (6)
#define L1_CACHE_BYTES        (1 << L1_CACHE_SHIFT)

 此部分赋值是通过下面函数实现的,根据情况将  curr = (unsigned long)current; 赋值给 owner 成员bit[6 : 63]

static inline struct task_struct *__mutex_trylock_common(struct mutex *lock, bool handoff)
{
    unsigned long owner, curr = (unsigned long)current;
    if (atomic_long_try_cmpxchg_acquire(&lock->owner, &owner, task | flags))
}

 b、 struct list_head    wait_list 成员

 struct list_head    wait_list  是链表头,mutex属于睡眠锁, 当线程无法获取到锁又不具备乐观自旋条件时会挂入到这个等待队列,等待owner释放锁。  

struct mutex_waiter 是链表节点,无法获取到锁的线程会定义一个 struct mutex_waiter 结构体,将此结构体尾插入到链表。具体定义如下:

struct mutex_waiter {
	struct list_head	list;	
	struct task_struct	*task;	/* 阻塞线程 struct task_struct 地址,使用 current 变量 */
	struct ww_acquire_ctx	*ww_ctx;
};

链表的插入操作 

__mutex_lock
        __mutex_lock_common
                struct mutex_waiter waiter;
                __mutex_add_waiter(lock, &waiter, &lock->wait_list);

4.3.2、解析mutex的2个关键成员
crash> rd gpstKdMutex1
ffffffc008fba480:  ffffff810791b080                    ........
crash> struct KdumpMutex_st.lock ffffff810791b080 -x
  lock = {
    owner = {
      counter = 0xffffff8106bf6c01
    },
    wait_list = {
      next = 0xffffffc013353de8,
      prev = 0xffffffc013353de8
    },
  }

a、由atomic_long_t    owner 成员 解析出当前那个线程持有锁
owner = counter = 0xffffff8106bf6c01 ,根据4.3.1章节分析可知:此地址将低6bit清零后为持有锁线程  struct task_struc 结构体的地址。

    crash> struct task_struct.comm,pid 0xffffff8106bf6c00
      comm = "mutex thread_1\000",
      pid = 5544,
    crash>

b、由struct list_head    wait_list 成员解析出那些线程在等待锁

mutex链表头的内容如下,next和prev两个指针相同,说明只有一个节点在双向链表中。

 wait_list = {
      next = 0xffffffc013353de8,
      prev = 0xffffffc013353de8
    },

 使用 list mutex_waiter.list -s mutex_waiter.task -h 0xffffffc013353de8 命令,遍历链表所有节点,并打印出每个节点的 mutex_waiter.task 成员,即无法获取锁阻塞在链表中的线程指针。下面只有一个线程无法获取锁导致阻塞,线程 struct task_struct 结构地址在 0xffffff8106bf3600

    crash> list mutex_waiter.list -s mutex_waiter.task -h 0xffffffc013353de8
    ffffffc013353de8
      task = 0xffffff8106bf3600,
    ffffff810791b0c8
      task = 0x0,

 根据阻塞线程 struct task_struct 结构地址在 0xffffff8106bf3600,使用 struct 命令查看此线程的名字和pid;

    crash> struct task_struct.comm,pid 0xffffff8106bf3600
      comm = "mutex thread_2\000",
      pid = 5545,
    crash>

4.4、使用bt命令查看阻塞线程的栈回溯

根据当前持有锁线程,以及获取锁失败而阻塞的线程,分析死锁的成因,再具体解决死锁问题。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值