动手编写操作系统(4):保护模式 - 1

本文详细介绍了保护模式的概念、特性,以及从实模式到保护模式的转变,重点讲解了全局描述符表(GDT)的作用和段描述符的结构。保护模式通过GDT实现内存段的保护,验证访问规则,提供安全的内存访问。此外,还阐述了如何开启A20线和设置CR0寄存器以进入保护模式。最后,通过代码展示了如何构建GDT和加载段选择子。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

动手编写操作系统(4):保护模式 - 1

  经过BIOS和MBR的两轮接力,现在接力棒交到了DBR与系统内核加载器手中。它们的任务是构建保护模式必要的环境——全局描述符表、中断描述符表、页表(内存分页)等;并将控制权转交给内核完成最后一棒。在真正开始构建保护模式的环境之前,我们有必要了解一下保护模式究竟是什么,以及构建时需要我们做什么。本文,我们将一起探索保护模式的奥秘。

  本文为保护模式系列第一节,包含保护模式概述、特性、全局描述符表、进入保护模式。

什么是保护模式

  “保护模式”,是一个相对于实模式的概念,它们都是随着Intel 80286芯片引入的概念。在它出现之前,8086系列CPU只有一种工作模式,地址总线20位,只能寻址1MB的空间。当80286芯片带来了一种全新的工作模式后,为了区分这种新模式与原有的模式,原有的20位地址总线模式被称为“实模式”,而新的工作模式被称为“保护模式”。

  保护模式的“保护”,主要体现在处理器对内存访问的保护。在实模式中,存在许多安全问题,这些安全问题将导致程序的一个错误扩大为整个系统的崩溃:

  1. 实模式下,操作系统和用户程序位于同一特权级。
  2. 被用户程序所使用的地址都是物理地址,物理内存被用户程序直接操作。
  3. 用户程序通过修改段基址,可以访问任意内存地址,包括操作系统的地址。

  另外,实模式还存在着许多使用上的不便与限制:

  1. 访问超过64KB的数据需要切换段基址。
  2. 只能同时运行一个程序,系统资源利用率不高。
  3. 地址总线只有20位,只能寻址1MB空间。

  保护模式的出现弥补了实模式的安全问题,突破了实模式的诸多限制,也为32位CPU奠定了基础。

保护模式特性

寄存器拓展和段描述符

  到了32位CPU时代,地址总线和数据总线都拓展为32位。CPU从实模式的16位进入32位后,相应地,各个通用寄存器的位宽也扩展到了32位。为了保持兼容性,这些拓展后的寄存器的低16位仍然可以按照原来的名称单独使用。但是高16位不能,只能通过拓展的寄存器名称与低16位一起使用。

  由于通用寄存器的长度拓展为原来的两倍,架构设计者便在它的名称前加上extended,缩写为E。例如,AX寄存器拓展为EAX,IP寄存器拓展为EIP。**段寄存器的长度没有拓展,但其内容发生了变化。**实模式中,段寄存器中存放段基址;但保护模式中,段寄存器储存段选择子(selector,是段描述符在表中的索引)。

段描述符被储存在全局描述符表(Global Descriptor Table, GDT)中,GDTR寄存器指向它。另外,为了提高段信息的访问效率,CPU使用段描述符缓冲寄存器(Descriptor Cache Registers)来缓冲获得的段描述符信息。DCR的位宽较长,储存了完整的段描述符信息。**通过扩展段描述符中的基址位宽,就可以适配地址总线的位宽,从而完成范围更宽的寻址任务。**例如,286将选择子中的基址宽度拓展到了24位,搭配其24位地址总线,总共可以寻址16MB空间。

从286到386,又是一次跨时代的变革。CPU正式迈入了32位的大门,地址总线和数据总线都扩展为32位。此时,单个32位寄存器就可以寻址4GB内存空间,在内存寻址方面,CPU完成了在当时看来是“一步到位”的跨越。CPU可以不需要借助段描述符中提供的段基址,仅通过偏移地址就能访问全部4GB内存。另外加上内存分页机制的引入,内存分段模型的时代逐渐落下了帷幕。(关于内存分段、分页、平坦模型,有兴趣的读者可以参考我的另一篇博客:操作系统原理——内存的分段、分页和平坦模型:区别与发展

寻址方式拓展

回顾实模式中的寻址方式,在基址、变址寻址中,对基址寄存器和变址寄存器(索引寄存器)有限定。即:基址寄存器只能是bxbp,变址寄存器只能是sidi。另外,立即数地址不能超过16位。

( B X B P ) + ( S I D I ) + I m m e d i a t e   N u m b e r \left(\begin{array}{ccc}BX\\BP\end{array}\right)+ \left(\begin{array}{ccc}SI\\DI\end{array}\right)+ Immediate\ Number (BXBP)+(SIDI)+Immediate Number

而在保护模式中,这类限制被取消了。“通用寄存器”拥有了更好的通用性。除了esp寄存器不能作为变址寄存器(可以是基址寄存器)以外,所有的通用寄存器都可以作为基址或变址寄存器。另外,变址寄存器还可以乘一个比例因子,这个比例因子必须属于 { 1 , 2 , 4 , 8 } \{1,2,4,8\} {1,2,4,8}

B a s e + I n d e x × ( 1 2 4 8 ) + I m m e d i a t e   N u m b e r Base + Index\times\left(\begin{array}{ccc}1\\2\\4\\8\end{array}\right)+Immediate\ Number Base+Index×1248+Immediate Number

运行模式反转

  32位CPU处于实模式下时,并不是8086时的“纯”实模式,在实模式下,仍然可以使用32位宽的寄存器。同样地,保护模式下,我们仍然可以使用但是,保护模式和实模式中,相同操作数的编码是不同的。编译器在编译汇编语句时,需要明确当前处于何种模式下。对于一般情况,例如在目标格式为16位bin下使用eax,编译器会自动处理。但更加复杂的混淆情况下(例如,mov ax, [edx]),我们需要借助伪指令bits来指明接下来的语句是何种模式。指令为bits 16, bits 32,前者表示编译为16位机器码(供实模式使用),后者表示编译为32位机器码(供保护模式使用)。

  在16位模式和32位模式下,寄存器的对应指令、操作数编码会有混淆。例如,16位模式下mov ax编码为b8,而这与32位模式下mov eax的编码相同。为了解决不同模式下模式反转造成的编码混淆,我们使用反转前缀来标识需要反转的语句这是编译器的工作,我们的工作是为编译器指明当前的编译模式,编译器明白编译模式后会视情况添加反转前缀。

  指令前缀,顾名思义,放在一条指令或一个操作数的前面,作为指令的选项。例如,指令重复前缀rep,段跨越前缀sreg:,还有下面要提到的操作数反转前缀0x66和寻址方式反转前缀0x67
  操作数反转前缀:将语句所使用的操作数模式转换成与当前运行模式不同的一种。例如,在实模式(16-bit)下,使用这个前缀将本条语句的操作数识别为32-bit下,反之亦然。
  寻址方式反转前缀:与上面类似,本前缀修改的是寻址模式。
下面这个例子很好地说明了前缀的作用:

bits 16
mov ax, [edx]  ; 67 8b 02
bits 32
mov ax, [edx]  ; 66 8b 02
  • 有趣的是,同样的一条语句,面向不同的运行模式编译,得到的结果不同:
    • 第二行:在实模式(16-bit)下,采用了保护模式(32-bit)的寻址方式,需要添加寻址方式反转前缀0x67
    • 第四行:在保护模式(32-bit)下,使用实模式下的操作数编码(ax),需要添加操作数反转前缀0x66

  注意:两个反转前缀可以同时出现,请看下面这个例子:

bits 16
mov dword [eax], 0x1234	; 66 67 c7 00 34 12
  • 本例中,在实模式情况下,使用eax寄存器寻址,需要加寻址方式反转前缀0x67,另外,向目标地址赋值DWORD,后面的操作数将被识别为32位的,也需要操作数反转前缀0x66

指令拓展

  进入保护模式,各个指令也需要拓展为支持更大操作数位宽的版本,同时保留原有的功能。同样地,遇到操作数编码混淆的问题,依然使用上面的寻址方式/操作数反转前缀来解决。

  需要注意的是无符号乘法mul指令。由于乘法指令得到结果位宽是操作数的两倍,结果可能需要额外的寄存器才能存放,具体规则如下:(以下经过实验验证,与《操作系统真象还原》中不符)

mul r/m8	; 以al为乘数,结果16b存入ax
mul r/m16	; 以ax为乘数,结果32b存入dx:ax,即dx存高16位,ax低16位
mul r/m32	; 以eax为乘数,结果64b存入edx:eax,即edx存高32位,eax低32位
  • 若出现运算结果位数高于操作数位宽,则CF位置1,表示进位。该标志位可以确定忽略高位寄存器时,运算结果的准确性。

  另外一个值得注意的指令是无符号除法指令div。与乘法指令相对应,除法指令的被除数位宽是除数的两倍。被除数由默认寄存器存放,规则与mul类似:

div r/m8	; 以ax为被除数,商存入al, 余数存入ah
div r/m16	; 以eax为被除数,商存入ax,余数存入dx
div r/m32	; 以edx:eax为被除数,商存入eax,余数存入edx
  • 注意:当出现商过大,无法放入商寄存器导致溢出的情况时,直接报SIGFPE: Arithmetic exception

  还有一个需要注意的指令push。维护栈在某些情况下需要对齐,在实模式和保护模式中,它对不同的操作数有不一样的动作。在指令编码方面,除了某些需要特殊对齐的push指令(如32-bit下push m8)使用另外的OP code,其他指令的OP code基本相同,部分语句按照上面说过的规则增加反转前缀即可。下面主要讨论执行指令时的CPU行为:

  • 当压入一个立即数时:

    • 实模式下:

      push imm8		; esp-2; 栈对齐,将8bit操作数扩展为16bit
      push imm16	; esp-2; 普通压栈
      push imm32	; esp-4; 操作数反转,占用两层普通栈
      
    • 保护模式下:

      push imm8		; esp-4; 栈对齐,8bit直接扩展为32bit,因为保护模式默认操作数为32bit
      push imm16	; esp-2; 操作数反转,仅占用半层栈,没有栈对齐
      push imm32	; esp-4; 普通压栈
      
  • 当压入一个段寄存器时,两种模式都会栈对齐,寄存器的值以默认操作数长度入栈

  • 对于寄存器和内存寻址,在两种模式下动作相同

    push r/m16	; esp-2
    push r/m32	; esp-4
    

对内存的保护

(本节内容建议阅读完下一节全局描述符表GDT再回头阅读)

保护模式通过GDT对内存段进行了一定的保护,主要验证以下规则:

加载选择子时:

  • 验证段选择子索引是否超过GDTR限制(对于CS、SS还要验证是否为0)
  • 检查段实际用途和段描述符中的用途权限是否匹配
    • 只有可执行段才能加载到CS
    • 可执行段不能加载到其他段
    • 可写属性的数据段才能加载到SS
    • 至少可读的段(数据段)才能加载到DS\ES\FS\GS
  • 检查段是否存在,否则抛出异常,进行加载

运行时:

  • 检查地址是否合法。对于代码、数据等,检查是否完全位于该段

全局描述符表 GDT

简介

  全局描述符表(Global Descriptor Table, GDT)是保护模式构成的重要基础之一,它是保护模式下各个内存段的登记表,由一些段描述符组成。在保护模式下,地址总线长度扩展到32位,可以寻址的空间为4GB。为了容纳完整的寻址空间,并加入安全性属性,CPU没有选择继续拓展段寄存器长度,而是让它指向一个内存中的数据结构——段描述符(Segment Descriptor)。段描述符提供了如下信息:

  • 段基址:一个段的起始地址,这是最基础的功能
  • 段界限:一个段中能访问的最大内存范围
  • 段类型:区分段中存储内容的类型(代码、数据等),以提供不同的行为保护
  • 特权级属性:部分段只有一定特权级的程序(操作系统等)才能访问,避免被非法修改
  • 其他属性信息

  需要注意:全局描述符表只有一个,所有的用户程序都使用同一套用户代码/数据段描述符,内核使用另一套描述符。

段描述符

段描述符的具体结构如下1

Segment Descriptor

  • 段基址被分为两部分保存,一份低24位一份高8位,共32位。这部分基址不需要左移,直接与段内偏移相加

  • 段界限也被分为两部分,共20位。表示最大偏移单位,由G位(后面会提到控制)。偏移的拓展方向由段的性质决定:栈段向下扩展,数据和代码段向上扩展

  • Type:指定段描述符的类型。根据S位(下一条介绍)有不同含义1

Type

  • 对于系统段,Type也不是对各个门和其他类型的单纯编码。在某些特定类型下,一些Type位具有标志作用(例如,TSS的第1位,是忙碌标志;第3位标志了386及以上CPU)

  • 非系统段的第0位A位为Accessed访问标记位,新段描述符为0,CPU访问该段后将其置1

  • 对于非系统(数据)段,第3位X为可执行位。按照是否可执行,后面的标志位含义也有所不同

    • 代码段:X(eXecutable)为1,其后的属性位依次为:C、R、A
      • 一致性Conforming:用于不同特权级的代码共享,如内核态的部分代码共享给用户态执行,但用户态不需要切换到内核态来执行这些代码
        • 若没有置位,只允许同级访问,不允许跨级,高到低也不行。防止内核态执行用户态数据导致内核崩溃。
        • 若置位,只允许低特权级访问,执行时,特权级不发生改变。
      • 可读性Read:即是否可读,若置0,则读取该段时抛出异常
    • 数据段:X为0,此后的属性位依次为E、W、A
      • 向下拓展Expand-down: 段在内存中的拓展方向向下,即向低地址方向拓展
      • 可写Write: 该段可以写入数据
  • S=System:系统段标志,置位(置1)为非系统段,否则为系统段。系统段:仅包含硬件系统运行需要的信息;软件信息(包括操作系统)属于数据段,也称为非系统段。(现代操作系统及CPU架构直接将其置1,不使用系统段)

  • DPL(Descriptor Privilege Level 描述符特权级): 共有四个等级0-3,用两位二进制位表示。内核态为DPL0,也称为Ring0;用户态特权级最低,为Ring3。现代操作系统不使用Ring1Ring2

  • P=Present: 如果该位置0,则该段在内存中不存在,CPU抛出"Segment not present"异常。由操作系统编写处理例程加载缺失的段。在x86系统中基本不使用。

  • AVL=Available: 此位可被操作系统使用,硬件不使用111

  • L=Long: 若置位,则该段为64位代码段。在目前x86架构下,直接置0即可。

  • B=Big/D=Default operand size: D对于代码段,B对于栈段。若置1,则代码段默认操作数为32位,栈段的控制寄存器使用32位esp。否则16位

  • G=Granularity: 粒度,与段界限配合使用。若置位,则一个段界限的偏移量单位为4KB,否则为1B。

  • *Linux系统基本不使用分段机制管理内存(仅用部分权限控制),但它是CPU的功能,必须填充。*Linux系统中,所有段的基址都为0x00000000,段界限为全部4GB空间。S、D/B、P位都为1

    • 对于内核段(包括内核代码段和内核数据段),DPL=0,最高特权级
    • 对于用户段(用户代码段、用户数据段),DPL=3,最低特权级
    • 对于代码段,Type=10,可执行,可读,无一致性
    • 对于数据段,Type=2,不可执行,可读写

GDT, LDT, Selector 段选择子

  • GDT (Global Descriptor Table, 全局描述符表),是一个由段描述符组成的数组,通过段寄存器存放的段选择子(Selector)中的下标索引。GDT存放在内存中,由GDTR寄存器存放其信息。寄存器共48位宽,高32位存放GDT起始地址,低16位存放GDT界限,以Byte为单位。每个SD占8B,GDT最多可容纳8192个SD

    • GDT只能通过lgdt(Load GDT)指令进行更新,该指令在实模式和保护模式下都可以使用。保护模式只能在ring0态执行

      lgdt m16&32		; 操作数为一个指向16b+32b数据的地址
      
  • Segment Selector 段选择子:段寄存器中存放段选择子,通过段选择子在GDT中找到对应的段描述符,并按照段选择子中的请求特权级访问段。

    • 段选择子共16位:0-1位为RPL (Request Privilege Level 请求特权级),表示使用段的当前特权级;第2位为TI (Table Indicator),置1表示在LDT中,否则位于GDT;3-15位为索引下标
    • 注意
      • 段选择子找到对应的段描述符后,基址不需要左移,直接和偏移相加即可。
      • 为了防止未初始化寄存器而导致的选择子错误,0号描述符不可用,试图访问将抛出异常。
    • LDT (Local Descriptor Table,局部描述符表),与全局描述符表类似,用于描述一个任务需要的段信息。LDT作为硬件原生支持多任务的结构,但未在操作系统中应用。
      • LDT表单独为一个系统段,type=0x0010,使用前需要在GDT中声明
      • 与GDT类似,使用LDTR寄存器存储表信息,通过lldt指令设置寄存器

打开A20线

  实模式下,我们只能使用20条地址线,当目标地址超过20位地址线能够寻址的1MB空间时,CPU会将超过空间的部分截断,这被称为回绕(wrap-around)。进入保护模式,对于x86架构,我们需要启用全部32条地址线,就要开启第21条地址线A20

  开始,IBM公司使用键盘控制器上的一个空闲引脚来控制是否启用A20,称为A20 Gate。由于键盘控制器速度很慢,后面增加了一个单独的端口A20快速门(A20 fast gate),即I/O端口0x92。我们只要向它的第1位置1即可。

in al, 0x92
or al, 0b10
out 0x92, al

CR0寄存器PE位

  进入保护模式的最后一步,是打开CR0寄存器的PE位(Protection Enable, 保护使能)。

  CRx控制寄存器,是CPU各类状态和控制开关的位置。X86架构中共有8个(CR0~CR7),其中最常用的是CR0,长度32b,除了保留位外,其余的名称和功能如下:
在这里插入图片描述

  • 其中,第0位的PE位就是我们要操作的对象。置位进入保护模式。启动代码如下:

    mov eax, cr0
    or eax, 1
    mov cr0, eax
    

操作实现

代码

  实现进入保护模式的步骤较少,但构造GDT表部分需要许多属性,内容较长,我们需要在boot.inc文件中进行预定义:

# file:	./boot.inc
# Loader attr
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x1

# GDT Attributes
DESC_G_4K	equ		0x80_0000			# Granularity h-23
DESC_D_32	equ		0x40_0000			# Default operand size h-22
DESC_L		equ		0
DESC_AVL	equ		0					# ignore this bit
DESC_LIMIT_CODE2	equ		0xF_0000	# 1111
DESC_LIMIT_DATA2	equ		0xF_0000	# 1111
DESC_LIMIT_VIDEO2	equ		0
DESC_P		equ		0x8000
DESC_DPL_0	equ		0
DESC_DPL_3	equ		0x6000
DESC_S		equ		0x1000
DESC_TYPE_CODE		equ		0x800		# X,c,r,a
DESC_TYPE_DATA		equ		0x200		# x,e,W,a

DESC_CODE_HIGH4		equ		DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + \
	DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S + DESC_TYPE_CODE
DESC_DATA_HIGH4		equ		DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + \
	DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S + DESC_TYPE_DATA
DESC_VIDEO_HIGH4	equ		DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + \
	DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S + DESC_TYPE_DATA

# Selectors
RPL0	equ		0
RPL3	equ		3
TI_GDT	equ		0
TI_LDT	equ		4
  • 主要定义了GDT中一部分段描述符的高4字节内容,DESC为描述符的前缀,具体属性值请参考上文和注释。
  • 可以将对应值换为二进制,但是位数过多容易出错,可以使用计算器的bit toggling keypad方便转换成二进制对应位。
    Bit toggling pad

接下来在loader中编写进入保护模式的代码:

# file: ./loader.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
	LOADDER_STACK_TOP equ LOADER_BASE_ADDR
	jmp loader_start

; GDT
GDT_BASE:			dd		0
					dd		0
CODE_DESC:			dd		0x0000ffff
					dd		DESC_CODE_HIGH4
DATA_STACK_DESC:	dd		0x0000ffff
					dd		DESC_DATA_HIGH4
VIDEO_DESC: 		dd		0x8000_0007		; limit = (0xbfff-0xb800)/4k = 0x7
					dd		DESC_VIDEO_HIGH4

GDT_SIZE	equ		$ - GDT_BASE
GDT_LIMIT	equ		GDT_SIZE - 1
dq	60	dup(0)		; reserve 60 descriptors

; Selectors
SELECTOR_CODE	equ		(0x0001<<3) + TI_GDT + RPL0
SELECTOR_DATA	equ		(0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO	equ		(0x0003<<3) + TI_GDT + RPL0

loader_start:
	; mov bx, 0x0100
	; mov si, message
	; mov cx, [len]
	; call my_print

	; entering protection mode

	; 1. open A20 fast gate
	in al, 0x92
	or al, 0b10
	out 0x92, al

	; 2. load gdt info
	lgdt [gdt_ptr]

	; 3. set cr0
	mov eax, cr0
	or eax, 1
	mov cr0, eax

	jmp dword SELECTOR_CODE:p_mode_start

	dd	16	dup(0)
[bits 32]
p_mode_start:
	mov bx, 0x0100
	mov si, message
	mov cx, [len]
	call my_print
	jmp $
	
my_print:					; vesion 3.0
; print string with gpu
; param: bx: (bh, bl)=(row, col) offset on the screen
; param si: string address
; param cx: length
	; mov cx, dx
	mov ax, 0xa0
	mul bh
	add al, bl
	mov bx, ax
  loc_0x37:
	mov al, byte [si]
	mov byte [gs:bx], al
	add bx, 1
	mov byte [gs:bx], 0x0a		; color
	add si, 1
	add bx, 1
	loop loc_0x37
	retn
; my_print endp

; data
	gdt_ptr		dw	GDT_LIMIT
				dd	GDT_BASE
	message db "Loader ready in protection mode."
	len dw $ - message

上述代码将段选择子加载进段寄存器后的效果:
sreg

GDT构建

我们在loader.S中构建了GDT表及三个段描述符,分别是代码段、数据段、以及显存段。

  • 代码段:基址0x0;范围全部内存,界限偏移为0xfffff,需要通过两部分组合得到。目前所有的段都要被操作系统使用,DPL为0,即ring0;Type为可执行,不可读
  • 数据段:其他类似代码段,Type为可读写,不可执行,无一致性
  • 显存段(VIDEO):范围为0xb8000~0xbffff,界限偏移(0xbffff-0xb8000+1)/4K - 1=0x7,其余与数据段相同

流水线

  与工厂中的情形相同,CPU内部也有类似的流水线来处理一系列命令。例如,执行一条指令的全过程为:取指令、译码、执行。对于不同的指令的不同步骤,CPU可以同时执行,例如,指令A的取指令和指令B的执行。流水线的层级称为深度,也称为级数。现代CPU通常有很高的级数,这可以提高指令的平均执行效率。

  X86架构采用CISC (Complex Instruction Set Computer)指令集,即复杂指令集。指令集中的命令功能强大但庞大而复杂,每个指令可能由多个粒度更小的微操作组成。与之相对的指令集为RISC(Reduced ~),即精简指令集,只具有最常用且最简单的指令,这些指令一般不能继续细分。微操作通常具有很好的乱序执行特性。

  另外,流水线还需要另一个关键部分,分支预测。 分支结构在程序中非常常见,而遇到分支结构则对流水线产生较大影响。 Intel的分支预测部件采用目标缓冲器,动态缓存与静态预测共用。

  当我们由实模式进入保护模式,代码段的指令位数发生了变化。但在进入保护模式时,流水线中已经有几条32位指令完成了译码,而这部分被译码成了16位的指令!这会引发错误的指令执行,并导致严重结果。所以,在进入保护模式时,我们需要进行清空流水线的操作:使用无条件跳转指令。这可以将流水线清空,并让CPU重新加载流水线。这也正是代码中我们所做的。


  1. 引用自:《操作系统真象还原》 郑刚 人民邮电出版社 ↩︎ ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值