14 Day:同步锁与操作系统输入输出

文章讲述了操作系统中线程输出存在的问题,如线程安全和全局异常,并介绍了使用同步锁(如信号量)来解决线程安全问题。通过锁的粒度控制,确保输出函数的原子性。同时,文章详细讲解了键盘输入的工作原理,包括键盘编码、中断处理和键盘驱动的编写。最后,提出了环形缓冲区的概念,用于在生产者(键盘)和消费者(操作系统)之间协调输入数据。
摘要由CSDN通过智能技术生成

前言:在上一期的线程章节中,我们的线程输出貌似有大问题,今天我们便要来学习同步锁来解决这个问题,同时再次基础上拿下键盘输入,实现操作系统的输入和输出。从今天开始我们的操作系统不在是一块“看板”了!!!


一,上期线程输出的不足

其实13 Day的线程输出并不完美,有很大的问题。但是这个问题经过我多天的观察其实有两种。

1,我们会发现有的线程输出的内容,并没有输出完成,而是被很大的一片空白取代

2,触发global exception 异常

至于为什么会导致这样子,相信聪明的小伙伴已经知道了,如果有学过并发编程相关的知识肯定会对该情况十分熟悉。

首先我们知道,屏幕输出的操作由多个指令组成,且需要操作显存,那么就会出现以下问题

  • 显存作为公共空间,也就是临界区(不过多介绍),所有的线程进行输出都需要操作这块空间,就会导致线程安全问题
  • 屏幕输出操作有多个指令组成,这就说明屏幕输出并不是原子性的,也就是说线程的切换可能导致另一个线程屏幕输出只执行了一半,就被别人踹下台去了,就会出现原本要输出Hello,可是只输出了Hell

 那我们要怎么解决这种问题呢?没错就是关闭中断,这样操作就变成原子性的,线程无阻碍的往下执行。我们可以在原本main函数的两个线程方法while的上下加上一个 关中断与开中断,当大家再次进行输出就会发现输出正常了。

但这大家仔细想想整个while模块里面有些操作并不需要都是原子性的,我们只有某些关键操作是原子性,把整个线程的函数都进行关中断那不就变成以前的串行操作了吗?这就涉及到一个关键性问题,关中断与开中断之间的粒度,也就是JUC中经常讨论到的一点:锁的粒度,他锁定的范围越小,那么线程切换工作的效率就越高。在这里我们显然只要对输出函数进行加锁处理,那么如何让一个方法加锁请接着往下看

① 信号量

有学过操作系统大学课程的同学肯定知道信号量,其中P,V代表信号量的操作,这是一个荷兰语。P代表减少,V代表上升。那么对应锁的话就是如下几个操作:

  • up:

(1) 信号量的值+1

(2) 唤醒在此信号量上等待的线程

  • down:

(1)判断信号量是否>1

(2)信号量大于0,信号量-1

(3)信号量等于0,线程阻塞,在此信号量上等待

② 同步锁的实现

知道以上操作之后,我们便可以来实现一个锁了,在此之前我们还需要完善线程的两个操作:阻塞与唤醒

thread/thread.c

void thread_block(enum task_status stat) {
	ASSERT(((stat == TASK_BLOCKED) || (stat == TASK_WAITING) || (stat == TASK_HANGINH)));
	enum intr_status old_status = intr_disable();

	struct task_struct* cur_thread = running_thread();
	cur_thread->status = stat;
	schedule();
	intr_set_status(old_status);
}

void thread_unblock(struct task_struct* pthread) {
	enum intr_status old_status = intr_disable();
	ASSERT((pthread->status == TASK_BLOCKED) || (pthread->status == TASK_WAITING) || (pthread->status == TASK_HANGINH));

	if (pthread->status != TASK_READY) {
		ASSERT(!find(&thread_ready_list, &pthread->general_tag));
		if (find(&thread_ready_list, &pthread->general_tag)) {
			PANIC("thread_unblock");
		}
		push(&thread_ready_list, &pthread->general_tag);

		pthread->status = TASK_READY;
	}
	intr_set_status(old_status);
}

接下来我们来实现同步锁

/thread/sync.h

#ifndef  _THREAD_SYNC_H
#define	 _THREAD_SYNC_H
#include "list.h"
#include "stdint.h"
#include "thread.h"


struct semaphore {
	uint8_t value;        //信号量值
	struct list waiters;   //等待队列
};

struct lock {
	struct task_struct* holder;    //持有锁的线程
	struct semaphore semaphore;
	uint32_t holder_repeat_nr;    //锁重入次数
};
void lock_init(struct lock* plock);
void try_lock(struct lock* plock);
void try_release(struct lock* plock);
#endif // ! _THREAD_SYNC_H

 /thread/sync.c

#include "sync.h"
#include "stdint.h"
#include "list.h"
#include "thread.h"
#include "interrupt.h"
#include "debug.h"
void sema_init(struct semaphore* psema, uint8_t value) {
	psema->value = value;
	list_init(&psema->waiters);
}

void lock_init(struct lock* plock) {
	plock->holder = NULL;
	plock->holder_repeat_nr = 0;
	sema_init(&plock->semaphore,1);
}

void sema_down(struct semaphore* psema) {
	enum intr_status old_status = intr_disable();
	while (psema->value == 0) {
		//检测当前线程是否在等待队列中,在的话则报错
		if (find(&psema->waiters, &running_thread()->general_tag)) {
			PANIC("sema_down: thread blocked has been in waiter");
		}
		append(&psema->waiters, &running_thread()->general_tag);
		thread_block(TASK_BLOCKED);
	}
	--psema->value;
	ASSERT(psema->value == 0);
	intr_set_status(old_status);
}

void sema_up(struct semaphore* psema) {
	enum intr_status old_status = intr_disable();
	//从waiter队列中唤醒第一个线程
	if (!empty(&psema->waiters)) {
		struct task_struct* thread_blocked = elem2entry(struct task_struct, general_tag, pop(&psema->waiters));
		thread_unblock(thread_blocked);
	}
	++psema->value;
	ASSERT(psema->value == 1);
	intr_set_status(old_status);
}

void try_lock(struct lock* plock) {
	
	if (plock->holder != running_thread()) {
		sema_down(&plock->semaphore);
		
		plock->holder = running_thread();
		ASSERT(plock->holder_repeat_nr == 0);
		plock->holder_repeat_nr = 1;
		
	}
	else {
		++plock->holder_repeat_nr;
	}
}

void try_release(struct lock* plock) {
	ASSERT(plock->holder == running_thread());

	if (plock->holder_repeat_nr > 1) {
		--plock->holder_repeat_nr;
		return;
	}

	ASSERT(plock->holder_repeat_nr == 1);
    //注意这里不允许调换sema_up的位置,如果调换位置,就会导致中断开启,但是plock->hodler还没设为空就进行线程切换了
	plock->holder = NULL;
	plock->holder_repeat_nr = 0;
	sema_up(&plock->semaphore);
}

② 实现终端输出

这样子信号量我们就完成了,接下来我们用锁来实现一个终端输出,在此之前我们先来了解了解

什么是虚拟终端。

虚拟终端(tty):为了能让更多的人同时使用计算机,必须在一个显示器下实现多个用户,为每个用户虚拟出一个显示器

原理:不同的用户使用不同显存区域,让显存分快显示,就达到了虚拟终端的效果

 device/console.h

#include "stdint.h"

void console_init();
void console_acquire();
void console_release();
void console_put_str(char* str);
void console_put_char(uint8_t ch);
void console_put_int(uint32_t num);

 device/console.c

#include "console.h"
#include "stdint.h"
#include "sync.h"
#include "print.h"
static struct lock console_lock;

void console_init() {
	lock_init(&console_lock);
	put_str("\nconsole init done!\n");
}

void console_acquire() {
	try_lock(&console_lock);
}

void console_release() {
	try_release(&console_lock);
}

void console_put_str(char* str) {
	console_acquire();
	put_str(str);
	console_release();
}

void console_put_char(uint8_t ch) {
	console_acquire();
	put_char(ch);
	console_release();
}

void console_put_int(uint32_t num) {
	console_acquire();
	put_int(num);
	console_release();
}

将main函数中的普通put_str,改为console_put_str,接下来修改一下makefile文件,运行结果是密密麻麻的输出字符即代表我们锁编写成功(时代太过久远,忘记截图了)


 

 三,键盘键入的原理

1,两个芯片

键盘嘛,相信大家都见过的哈,没见过的同学我相信你也肯定看不到我的文章捏。在计算机这个系统中,键盘作为一个外部设备,他并不是完完全全独立于操作系统存在的,他的功能实现涉及到了两个功能独立的芯片

  •  Intel 8048(或兼容芯片):位于键盘内部,称作键盘编码器负责监听键盘按键事件并向键盘控制器报告哪个按键按下,哪个按键弹起
  • Intel 8042(或兼容芯片):位于计算机主板,称作键盘控制器,负责接受键盘编码起的按键消息,并将其解码保存,然后向中断代码发送中断,之后处理器读取8042处理过并保存的数据

 当然为了知道键盘到底安乐哪个键,8042与8048之间必须保持一个协议,那就是所有按键与对应竖直组成一个类似于hash映射的键盘扫描码


2,键的编码

键的编码

一个键通常对应两个码,按下的通码与松开的断码当你一直按着键不松开,会产生练习相同的码,当你松开的时候才会终止这个就叫做断码

 键盘扫描码

键盘的扫描码有三套:scan code set 1,scan code set 2,scan code set 3,下图分别是三套扫描码的附图

 

 在此我们使用第一套键盘扫描码

 

  • 当今键盘内部芯片默认使用第二套编码,但是我们使用第一套编码,所以说8048将编码信息发送给8042的时候会进行一次转换
  • 8042每次接受到一个字节的扫描码后就会向中断代理发送信号,一个按键操作至少触发两次中断(通码和断码各一次)
  • 一般中断处理程序不会处理断码信息
  • 当按下a键时,8048向8042发出a键的第二套扫描码0x1c,8042将其转换为第一套编码0x1e并保存到自己的缓冲区,当保存完毕后向中断代理发送中断,中断处理程序开始执行并从8042缓冲区读取0x1e,当松开后重复上述操作。

3,8042简介

8048是键盘的控制者(键盘监控,键盘设置,灯光),8042是键盘的IO接口,因此8042是8048的代理,8048通过PS/2,USB接口 与8042通信,处理器通过端口与8042通信。

8042有4个8位寄存器

  •  8042作为8048的中转站,8048通过out 0x60,将自己的数据写入8042中,让自己的数据通过8042被操作系统读入
  • 8042作为8048的输入缓冲区,将8048的信息暂存在8042缓冲区,操作系统通过 in 0x60读入数据

 要注意一点的是,当8042缓冲区已经有数据时,就不会再次读入数据,要等到数据被中断程序读取后才会再次读入8048的数据,他是根据一个状态寄存器的第0位来判断的。

8042有3个寄存器,状态寄存器,输入缓冲区寄存器,控制寄存器,由于我们只需要用到部分功能我也就贴出图大家稍微参考一下即可

 


三,编写键盘驱动

#include "interrupt.h"
#include "stdint.h"
#include "keyboard.h"
#include "io.h"
#include "print.h"


#define KB_BUF_PORT 0x60

#define esc '\033'
#define backspace '\b'
#define tab '\t'
#define enter '\r'
#define delete '\177'

#define char_invisible 0
#define ctrl_l_char char_invisible
#define ctrl_r_char char_invisible
#define shift_l_char char_invisible
#define shift_r_char char_invisible
#define alt_l_char char_invisible
#define alt_r_char char_invisible
#define caps_lock_char char_invisible

#define shift_l_make 0x2a
#define shift_r_make 0x36
#define alt_l_make 0x38
#define alt_r_make 0xe038
#define alt_r_break 0xe0b8
#define ctrl_l_make 0x1d
#define ctrl_r_make 0xe01d
#define ctrl_r_break 0xe09d
#define caps_lock_make 0x3a

static bool ctrl_status, alt_status, caps_status, shift_status, ext_scancode;

char keymap[][2] = {
	/* 0x00 */	{0,	0},
	/* 0x01 */	{esc,	esc},
	/* 0x02 */	{'1',	'!'},
	/* 0x03 */	{'2',	'@'},
	/* 0x04 */	{'3',	'#'},
	/* 0x05 */	{'4',	'$'},
	/* 0x06 */	{'5',	'%'},
	/* 0x07 */	{'6',	'^'},
	/* 0x08 */	{'7',	'&'},
	/* 0x09 */	{'8',	'*'},
	/* 0x0A */	{'9',	'('},
	/* 0x0B */	{'0',	')'},
	/* 0x0C */	{'-',	'_'},
	/* 0x0D */	{'=',	'+'},
	/* 0x0E */	{backspace, backspace},
	/* 0x0F */	{tab,	tab},
	/* 0x10 */	{'q',	'Q'},
	/* 0x11 */	{'w',	'W'},
	/* 0x12 */	{'e',	'E'},
	/* 0x13 */	{'r',	'R'},
	/* 0x14 */	{'t',	'T'},
	/* 0x15 */	{'y',	'Y'},
	/* 0x16 */	{'u',	'U'},
	/* 0x17 */	{'i',	'I'},
	/* 0x18 */	{'o',	'O'},
	/* 0x19 */	{'p',	'P'},
	/* 0x1A */	{'[',	'{'},
	/* 0x1B */	{']',	'}'},
	/* 0x1C */	{enter,  enter},
	/* 0x1D */	{ctrl_l_char, ctrl_l_char},
	/* 0x1E */	{'a',	'A'},
	/* 0x1F */	{'s',	'S'},
	/* 0x20 */	{'d',	'D'},
	/* 0x21 */	{'f',	'F'},
	/* 0x22 */	{'g',	'G'},
	/* 0x23 */	{'h',	'H'},
	/* 0x24 */	{'j',	'J'},
	/* 0x25 */	{'k',	'K'},
	/* 0x26 */	{'l',	'L'},
	/* 0x27 */	{';',	':'},
	/* 0x28 */	{'\'',	'"'},
	/* 0x29 */	{'`',	'~'},
	/* 0x2A */	{shift_l_char, shift_l_char},
	/* 0x2B */	{'\\',	'|'},
	/* 0x2C */	{'z',	'Z'},
	/* 0x2D */	{'x',	'X'},
	/* 0x2E */	{'c',	'C'},
	/* 0x2F */	{'v',	'V'},
	/* 0x30 */	{'b',	'B'},
	/* 0x31 */	{'n',	'N'},
	/* 0x32 */	{'m',	'M'},
	/* 0x33 */	{',',	'<'},
	/* 0x34 */	{'.',	'>'},
	/* 0x35 */	{'/',	'?'},
	/* 0x36	*/	{shift_r_char, shift_r_char},
	/* 0x37 */	{'*',	'*'},
	/* 0x38 */	{alt_l_char, alt_l_char},
	/* 0x39 */	{' ',	' '},
	/* 0x3A */	{caps_lock_char, caps_lock_char}
};

static void intr_keyboard_handler(void) {
	bool ctrl_down_last = ctrl_status;
	bool shift_down_last = shift_status;
	bool caps_lock_last = casps_lock_status;

	bool break_code;
	uint16_t scancode = inb(KB_BUF_PORT);

	//判断是不是为多余字符,是的话马上退出处理下一个
	if (scancode == 0xe0) {
		ext_scancode = true;
		return;
	}

	//如果上次以0xee0开头,则合并通码,并去除ext_scancode标志
	if (ext_scancode) {
		scancode |= 0xe000;
		ext_scancode = false;
	}

	//获取break code
	break_code = ((scancode & 0x0080) != 0);

	//如果是断码,对ctrl和shift和alt进行状态判断
	if (break_code) {
		uint16_t make_code = (scancode &= 0xff7f);
		if (make_code == ctrl_l_make || make_code == ctrl_r_make) {
			ctrl_status = false;
		}
		else if (make_code == shift_l_make || make_code == shift_r_make) {
			shift_status = false;
		}
		else if (make_code == alt_l_char || make code == alt_r_make) {
			alt_status = false;
		}
		return;

	}
	//如果为通码
	else if ((scancode > 0x00 && scancode < 0x3b) ||
		scancode == alt_r_make || scancode == ctrl_r_make) {
		bool shift = false;

		if ((scancode < 0x0e)  || (scancode == 0x29) || 
			(scancode == 0x1a) || (scancode == 0x1b)|| 
			(scancode == 0x2b) || (scancode == 0x27)|| 
			(scancode == 0x28) || (scancode == 0x33)|| 
			(scancode == 0x34) || (scancode == 0x35)) {
			if (shift_down_last) {
				shift = true;
			}
		}else {
			if (shift_down_last && caps_lock_last) {
				shift = false;
			}
			else if (shift_down_last || caps_lock_last) {
				shift = true;
			}
			else {
				shift = false;
			}
		}
		uint8_t index = (scancode &= 0x00ff);

		char cur_char = keymap[index][shift];

		if (cur_char) {
			put_char(cur_char);
			return;
		}

		if (scancode == ctrl_l_make || scancode == ctrl_r_make) {
			ctrl_status = true;
		}
		else if (scancode == shift_l_make || scancode == shift_r_make) {
			shift_status = true;
		}
		else if (scancode == alt_l_char || scancode == alt_r_make) {
			alt_status = true;
		}
		else if (scancode == caps_lock_make) {
			caps_lock_status = !caps_lock_status;
		}
	} else {
		put_str("unknown key\n");
	}

}

void keyboard_init() {
	put_str("keyboard_init start\n");
	register_intr(0x21, intr_keyboard_handler, "keyboard");
	put_str("keyboard_init done\n");
}

四,环形输入缓冲区

我们知道,操作键盘通常是为了和系统交互,而和系统交互一般都是写入某些shell指令,而这些shell指令需要用一个缓冲区存入起来,当形成一个完整的命令时,再一并由其他模块处理。


① 生产者消费者模型

对于缓冲区我们要有以下几个认识:

  • 缓存数据
  • 公共区域,多个线程同时使用该空间,需要对存取操作进行上锁
  • 数据可能存在存满和取空两种状态 

线程之间要相互合作,存在资源共享问题。对此我们便使用由Dijkstra提出的生产者消费者模型

 生产者消费者模型是什么,我不太想用学术的语言来介绍,我在此来举个例子:

 以前去吃过KFC的同学应该都了解,吃KFC的时候是需要排队的,那么此时我们把服务员当作生产者(就当作他又做饭又服务吧),备餐口就是缓冲区,而顾客则是消费者


 ① 当客流量比较少时,供大于求,服务员做好一大堆汉堡放在备餐区,当餐做满时便停下来休息并告诉顾客:”新鲜的汉堡做好了,来取餐嘞“(唤醒消费者),休息的顾客听到了便来到备餐区取餐

当客流量中等时,供等于求,只要服务员看到备餐口有空位,马上开始忙活起来。

当客流量很大时,供不应求,餐取完的时候,顾客便会在等待队列中等待,并催促服务员:“搞快点咯,肚子饿了“(唤醒生产者),摸鱼的服务员便会快马加鞭的做汉堡。


总结:对于有限大小的公共缓冲区,同步生产者和消费者的运行,对共享缓冲区互斥访问,并不会过度消费和过度生产,这便是生产者消费者模型,以及疯狂星期四KFCV50😋

② 环形缓冲区实现

其实线形的缓冲区也是OK的,怎么设计因人而异,我这里用队列来实现一个环形缓冲区,环形缓冲区的本质就是没有起始地址,没有终止地址,很简单我们直接开始吧。

device/ioqueue.h

#ifndef  _DEVICE_IOQUEUE_H
#define  _DEVICE_IOQUEUE_H
#include "stdint.h"
#include "thread.h"
#include "sync.h"

#define bufsize 64 //一个缓冲区的大小

struct ioqueue {
	struct lock lock;
	struct task_struct* producer;
	struct task_struct* consumer;
	char buf[bufsize];
	int32_t head;	//生产是头指针移动
	int32_t tail;	//消费时尾指针移动
};

void ioqueue_init(struct ioqueue* ioq);
int32_t next_pos(int32_t pos);
bool ioq_full(struct ioqueue* ioq);
bool ioq_empty(struct ioqueue* ioq);
void ioq_wait(struct task_struct** waiter);
void ioq_wakeup(struct task_struct** waiter);
char ioq_getchar(struct ioqueue* ioq);
void ioq_setchar(struct ioqueue* ioq, char byte);
#endif // ! _DEVICE_IOQUEUE_H

device/ioqueue.c

#include "debug.h"
#include "ioqueue.h"
#include "interrupt.h"

void ioqueue_init(struct ioqueue* ioq) {
	lock_init(&ioq->lock);
	ioq->producer = ioq->consumer = NULL;
	ioq->head = ioq->tail = 0;
}

int32_t next_pos(int32_t pos) {
	return (pos + 1) % bufsize;
}

bool ioq_full(struct ioqueue* ioq) {
	ASSERT(intr_get_status() == INTR_OFF);
	return (ioq->tail - ioq->head) == 1;
}

bool ioq_empty(struct ioqueue* ioq) {
	ASSERT(intr_get_status() == INTR_OFF);
	return ioq->head == ioq->tail;
}

//使当前的生产者或消费之在缓冲区上等待
void ioq_wait(struct task_struct** waiter) {
	ASSERT(*waiter == NULL && waiter != NULL);
	*waiter == running_thread();
	thread_block(TASK_BLOCKED);
}

//唤醒生产者或消费者
void ioq_wakeup(struct task_struct** waiter) {
	ASSERT(*waiter != NULL);
	thread_unblock(*waiter);
	*waiter = NULL;
}

char ioq_getchar(struct ioqueue* ioq) {
	//先判断是否关闭中断
	ASSERT(intr_get_status() == INTR_OFF);

	//判断是否为空,如果为空消费者需要休眠等待
	while (ioq_empty(ioq)) {
		try_lock(&ioq->lock);
		ioq_wait(&ioq->consumer);
		try_release(&ioq->lock);
	}

	//获取buf中的数据
	char ch = ioq->buf[ioq->tail];
	ioq->tail = next_pos(ioq->tail);

	if (ioq->producer != NULL) {
		ioq_wakeup(&ioq->producer);
	}

	return ch;
}

void ioq_setchar(struct ioqueue* ioq, char byte) {
	//先判断是否关闭中断
	ASSERT(intr_get_status() == INTR_OFF);

	//判断是否满缓冲区,如果满了生产需要休眠等待
	while (ioq_full(ioq)) {
		try_lock(&ioq->lock);
		ioq_wait(&ioq->producer);
		try_release(&ioq->lock);
	}

	//获取buf中的数据
	ioq->buf[ioq->head]=byte;
	ioq->head = next_pos(ioq->head);

	if (ioq->consumer != NULL) {
		ioq_wakeup(&ioq->consumer);
	}

	return byte;
}


然后我们完善一下键盘操作

device/keyboard.c

struct ioqueue ioqueue;

void keyboard_init()
{
	put_str("keyboard init start\n");
	register_handler(0x21, intr_keyboard_handler);
	init_ioqueue(&ioqueue);
	put_str("keyboard init done\n");
}
void intr_keyboard_handler(void)
{
//......
        if (cur_char)
		{
			if (!ioq_full(&ioqueue))
				ioq_putchar(&ioqueue, cur_char);
			return;
		}
//......
}

接下来我们让两个内核线程变成消费者

kernel/main.c

#include "print.h"
#include "init.h"
#include "debug.h"
#include "thread.h"
#include "console.h"
#include "keyboard.h"
#include "ioqueue.h"
#include "interrupt.h"

void g_thread(void* arg);
void g_thread2(void* arg);
void main(void) {
	
	
	put_str("Hello GeniusOS\n");
	put_int(2023);
	put_str("\n");
	init_all();
	thread_start("genius", 5, g_thread, "A_");
	thread_start("genius2", 31, g_thread2, "B_");
	
	intr_enable();
	while (1) {
		//console_put_str("Main ");
	}
}

void g_thread(void* arg) {
	while (1) {
		enum intr_status old_status = intr_disable();
		if (!ioq_empty(&kbd_buf)) {
			console_put_str(arg);
			char byte = ioq_getchar(&kbd_buf);
			console_put_char(byte);
			console_put_char(' ');
		}
		intr_set_status(old_status);
	}
}

void g_thread2(void* arg) {
	while (1) {
		enum intr_status old_status = intr_disable();
		if (!ioq_empty(&kbd_buf)) {
			console_put_str(arg);
			char byte = ioq_getchar(&kbd_buf);
			console_put_char(byte);
			console_put_char(' ');
		}
		intr_set_status(old_status);
	}
}

改一下makefile,运行一下,当你敲下键盘,且字符在屏幕上显示,这就代表你成功了,恭喜你!!!完成本章任务!!!

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值