x86汇编语言:从实模式到保护模式_实战操作系统 loader 编写(上) 进入保护模式...

1. 引言

此前的文章中,我们详细介绍了从引导扇区跳转到 loader 的工作:
从启动扇区跳转到 loader

引导扇区的工作已经告一段落,接下来我们的工作就是编写我们的 loader 了。
loader 的工作只有两个:

  1. 将内核载入内存

  2. 跳入保护模式

本文我们就来详细介绍一下。

2. loader 加载内核的过程

有了通过引导扇区加载 loader 的经验,让 loader 加载内核就简单的多了。
从原理上来说,loader 加载内核也同样是从 FAT12 的软盘文件系统上找到内核入口文件,这与引导扇区做的事情并没有很大的区别,这里也不进行详细的介绍,只是分块大致讲解一下。
但是,我们的内核将编译成 ELF 文件,因为只有这样,我们才能够接下来实现用 C 语言编写内核的目的,那么,如何让 loader 将内核 ELF 文件载入内存呢?其原理上一篇文章已经介绍过:
详解 Linux 可执行文件 ELF 文件的内部结构

ELF 文件是在 unix 环境上编译生成的可执行可连接文件,他通过多个 section 来组织编译后的可执行代码,若干个    section 构成一个段,由 program header table 描述如何载入内存。
因此,通过 elf header 与 program header table 中每一个条目的指引,我们就能够将 ELF 文件载入内存了。
但是别急,本文我们先不急于去把 ELF 放在他应该在的内存位置上,因为 ELF 文件必须在保护模式下执行,所以我们先把内核放到一整块内存中,然后进入保护模式,再在保护模式中对他进行调整,根据 ELF 内部的一系列信息将他放到他应该位于的虚拟地址上,然后才能通过跳转指令,将控制权从 loader 再交给 kernel。
本文,我们就来实现将内核载入内存并启动保护模式,也许你会有些失望,本文描述的内容都是此前文章已经介绍过的,不存在新的知识点,但不是有句话说“温故而知新”嘛。

3. 从软盘读取 kernel

我们首先来看看如何让 loader 能够在软盘上找到 kernel,这里的 kernel,我们暂且先使用之前我们写好的快速排序的程序:
如何实现汇编语言与 C 语言之间的相互调用

3.1. 定义 FAT12 磁盘头及相关信息

因为 boot 扇区需要读取 FAT12 磁盘头及相关信息,而 loader 也同样需要,所以我们需要定义一个公共依赖:

; FAT12 磁盘头
BS_OEMName DB 'ForrestY' ; OEM String, 必须 8 个字节

BPB_BytsPerSec DW 512 ; 每扇区字节数
BPB_SecPerClus DB 1 ; 每簇多少扇区
BPB_RsvdSecCnt DW 1 ; Boot 记录占用多少扇区
BPB_NumFATs DB 2 ; 共有多少 FAT 表
BPB_RootEntCnt DW 224 ; 根目录文件数最大值
BPB_TotSec16 DW 2880 ; 逻辑扇区总数
BPB_Media DB 0xF0 ; 媒体描述符
BPB_FATSz16 DW 9 ; 每FAT扇区数
BPB_SecPerTrk DW 18 ; 每磁道扇区数
BPB_NumHeads DW 2 ; 磁头数(面数)
BPB_HiddSec DD 0 ; 隐藏扇区数
BPB_TotSec32 DD 0 ; 如果 wTotalSectorCount 是 0 由这个值记录扇区数
BS_DrvNum DB 0 ; 中断 13 的驱动器号
BS_Reserved1 DB 0 ; 未使用
BS_BootSig DB 29h ; 扩展引导标记 (29h)
BS_VolID DD 0 ; 卷序列号
BS_VolLab DB 'OrangeS0.02'; 卷标, 必须 11 个字节
BS_FileSysType DB 'FAT12 ' ; 文件系统类型, 必须 8个字节

; 根目录占用空间 RootDirSectors = ((BPB_RootEntCnt*32)+(BPB_BytsPerSec–1))/BPB_BytsPerSec
RootDirSectors equ 14

; Root Directory 的第一个扇区号 = BPB_RsvdSecCnt + (BPB_NumFATs * BPB_FATSz16)
SectorNoOfRootDirectory equ 19

; FAT1 的第一个扇区号 = BPB_RsvdSecCnt
SectorNoOfFAT1 equ 1

; DeltaSectorNo = BPB_RsvdSecCnt + (BPB_NumFATs * BPB_FATSz16) - 2
; 文件的开始Sector号 = DirEntry中的开始Sector号 + 根目录占用Sector数目 + DeltaSectorNo
DeltaSectorNo equ 17

3.2. 在软盘中寻找 kernel.bin

想了解更加详细的内容,参考此前引导扇区加载 loader 的代码:
从启动扇区跳转到 loader

主要步骤仍然是:

  1. 循环读取根目录区的一个扇区

  2. 循环读取当前扇区内的一个条目

  3. 比较文件名是否为 KERNEL.BIN,相同则表示已找到

详细代码见附录。

3.3. 将 kernel.bin 读取到内存

如果上一步骤中找到了 kernel.bin 则读取文件内容载入到内存:

  1. 根据 FAT12 头信息,计算出数据区起始扇区

  2. 根据根目录条目中的文件起始簇号,读取文件首个簇

  3. 根据 FAT 项信息定位到文件下一个簇号

  4. 循环读取直到完成整个文件的读取

同样,代码放在附录。

3.4. 运行程序

下面我们编译上一篇文章中的快速排序代码,并把结果命名为 kernel.bin 然后放在 boot.img 的根目录下。
运行我们的系统,就可以看到下图,表示 kernel.bin 已经成功被载入到内存中了:

5b7a53de6868978e301563d52dc8405e.png

4. 进入保护模式

如上文所说,loader 的另一个极为重要的工作就是跳转进入保护模式中。
此前我们对保护模式的工作原理、执行方式及相关代码已经有了非常详尽的介绍,我们可以直接复用那些已经写好的代码。
回忆一下,从实地址模式跳转到保护模式需要做哪些事呢?

  1. 创建 GDT 及对应的段选择子

  2. 在段内编写保护模式代码

  3. 将 GDT 首地址通过 lgdt 指令载入 gdtr

  4. 关闭硬件中断

  5. 打开 A20 地址总线

  6. 置位 cr0 寄存器的保护模式标志位

  7. 长跳转进入保护模式

这里需要说明的是,由于此前我们没有编写自己的 booter,而是使用 freedos 系统作为启动扇区拉起我们的系统,所以我们无法预期 freedos 会把我们的代码放在物理内存的哪个位置上,所以我们需要在跳转前动态计算保护模式代码所在的起始位置,然后去覆盖上述最后一步的长跳转指令操作数,这看起来是如此 treak。
如今,我们自己编写的 boot 可以直接指定 loader 被载入内存的起始物理地址,这样,我们在代码编写时就可以计算出进入保护模式的起始位置,因此,再也不需要之前那种 treak 的方法,直接可以在代码中编写操作数实现上述操作了。

5. 运行程序

执行我们的系统,可以看到:

fc2a465c5c67c428babf82dacf8756c8.png

6. 总结

本文详细介绍了 loader 中关键性的两个步骤:

  1. 将内核载入内存

  2. 进入保护模式

正所谓“厚积薄发”,此前我们关于保护模式原理的一系列介绍和总结所积累的大量代码终于派上用场,本文的代码也就显得非常简单易懂了。
然而,事实上,第一步中,我们只是开辟了一块连续的空间来存储“内核”,实际上并没有对 ELF 文件进行处理,所以 ELF 并没有达到可执行的状态,我们也就更没有实现内核的执行了。
敬请期待下一篇文章,让我们在保护模式下,重新放置我们已经载入到内存的内核 ELF 文件,实现通往内核的最后一跳。

7. 微信公众号

欢迎关注微信公众号,以技术为主,涉及历史、人文等多领域的学习与感悟,每周三到七篇推文,只有全部原创,只有干货没有鸡汤。

867fcc09cbd7af68fe1d9f6af8c7d50c.png

8. 附录1 — 源码

由于本文对 boot 代码、kernel 代码均没有任何修改,我们只是使用了此前已经编写、编译好的代码,所以在这部分不再贴出相应代码,Makefile 中也不再包含他们的编译指令。

8.1. Makefile

LDR:=loader.asm
KERNEL_BIN:=kernel.bin
LDR_BIN:=$(subst .asm,.bin,$(LDR))

IMG:=boot.img
FLOPPY:=/mnt/floppy/

.PHONY : everything

everything : $(LDR_BIN)
sudo mount -o loop $(IMG) $(FLOPPY)
sudo cp $(LDR_BIN) $(FLOPPY) -v
sudo cp $(KERNEL_BIN) $(FLOPPY) -v
sudo umount $(FLOPPY)

clean :
rm -f $(LDR_BIN) *.o

$(LDR_BIN) : $(LDR)
nasm $< -o $@

8.2. loader.asm

org    0100h
jmp LABEL_START

; ---------------- 内存段描述符宏 -------------
; usage: Descriptor Base, Limit, Attr
; Base: dd
; Limit: dd (low 20 bits available)
; Attr: dw (lower 4 bits of higher byte are always 0)
%macro Descriptor 3
dw %2 & 0FFFFh ; 段界限1
dw %1 & 0FFFFh ; 段基址1
db (%1 >> 16) & 0FFh ; 段基址2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; 属性1 + 段界限2 + 属性2
db (%1 >> 24) & 0FFh ; 段基址3
%endmacro

BaseOfLoader equ 09000h ; LOADER.BIN 被加载到的段地址
OffsetOfLoader equ 0100h ; LOADER.BIN 被加载到的偏移地址
BaseOfLoaderPhyAddr equ BaseOfLoader*10h ; LOADER.BIN 被加载到的物理地址

BaseOfKernelFile equ 08000h ; KERNEL.BIN 被加载到的位置段地址
OffsetOfKernelFile equ 0h ; KERNEL.BIN 被加载到的位置偏移地址

BaseOfStack equ 0100h
PageDirBase equ 100000h ; 页目录开始地址: 1M
PageTblBase equ 101000h ; 页表开始地址: 1M + 4K

; -------------- GDT -------------
; 段基址 段界限, 属性
LABEL_GDT: Descriptor 0, 0, 0 ; 空描述符
LABEL_DESC_FLAT_C: Descriptor 0, 0fffffh, 0c09Ah ; 4GB 可执行代码段
LABEL_DESC_FLAT_RW: Descriptor 0, 0fffffh, 0c092h ; 4GB 可读写数据段
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, 0f2h ; 显存段

GdtLen equ $ - LABEL_GDT
GdtPtr dw GdtLen - 1 ; 段界限
dd BaseOfLoaderPhyAddr + LABEL_GDT ; 基地址

; ----------- GDT 选择子 ----------
SelectorFlatC equ LABEL_DESC_FLAT_C - LABEL_GDT
SelectorFlatRW equ LABEL_DESC_FLAT_RW - LABEL_GDT
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT + 3

; 下面是 FAT12 磁盘的头, 之所以包含它是因为下面用到了磁盘的一些信息
%include "fat12hdr.asm"

LABEL_START:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, BaseOfStack

mov dh, 0 ; "Loading "
call DispStrRealMode ; 显示字符串

; ----------- 获取内存信息 -------------
mov ebx, 0
mov di, _MemChkBuf ; es:di 存储地址范围描述符结构 ARDS
.MemChkLoop:
mov eax, 0E820h ; eax = 0000E820h
mov ecx, 20 ; ecx = 地址范围描述符结构大小
mov edx, 0534D4150h ; edx = 'SMAP'
int 15h
jc .MemChkFail
add di, 20
inc dword [_dwMCRNumber] ; dwMCRNumber = ARDS 的个数
cmp ebx, 0
jne .MemChkLoop
jmp .MemChkOK
.MemChkFail:
mov dword [_dwMCRNumber], 0
.MemChkOK:

; ----- 在 A 盘根目录寻找 KERNEL.BIN -----
mov word [wSectorNo], SectorNoOfRootDirectory

; 软盘复位
xor ah, ah
xor dl, dl
int 13h

LABEL_SEARCH_IN_ROOT_DIR_BEGIN:
; 根目录已读取完成,未找到 kernel.bin
cmp word [wRootDirSizeForLoop], 0
jz LABEL_NO_KERNELBIN

; 读取根目录区一个扇区
dec word [wRootDirSizeForLoop]
mov ax, BaseOfKernelFile
mov es, ax ; es mov bx, OffsetOfKernelFile ; bx mov ax, [wSectorNo] ; ax mov cl, 1
call ReadSector

mov si, KernelFileName ; ds:si = "KERNEL BIN"
mov di, OffsetOfKernelFile ; es:di = BaseOfKernelFile:OffsetOfKernelFile
cld ; df = 0

; 循环读取目录条目
mov dx, 10h ; 当前扇区所有目录条目循环次数
LABEL_SEARCH_FOR_KERNELBIN:
; 已读取完该扇区
cmp dx, 0 ; `.
jz LABEL_GOTO_NEXT_SECTOR_IN_ROOT_DIR
dec dx

; 比较文件名
mov cx, 11
LABEL_CMP_FILENAME:
cmp cx, 0
jz LABEL_FILENAME_FOUND ; 已找到 kernel
dec cx
lodsb
cmp al, byte [es:di]
jz LABEL_GO_ON
jmp LABEL_DIFFERENT
LABEL_GO_ON:
inc di
jmp LABEL_CMP_FILENAME

; 跳转到下一条目
LABEL_DIFFERENT:
and di, 0FFE0h ; 让 es:di 指向当前条目起始位置
add di, 20h ; 跳至下一条目
mov si, KernelFileName
jmp LABEL_SEARCH_FOR_KERNELBIN

; 跳转到下一扇区
LABEL_GOTO_NEXT_SECTOR_IN_ROOT_DIR:
add word [wSectorNo], 1
jmp LABEL_SEARCH_IN_ROOT_DIR_BEGIN

; 未找到,显示字符串,终止流程
LABEL_NO_KERNELBIN:
mov dh, 2 ; "No KERNEL."
call DispStrRealMode ; 显示字符串
jmp $

; 找到 kernel,加载
LABEL_FILENAME_FOUND:
; 保存 kernel.bin 的文件大小
mov eax, [es : di + 01Ch]
mov dword [dwKernelSize], eax

; 获取 loader.bin 对应的数据区簇号,保存在栈中
and di, 0FFF0h
add di, 01Ah
mov cx, word [es:di]
push cx

; 获取文件所在扇区号,保存在 cx 中
mov ax, RootDirSectors
add cx, ax
add cx, DeltaSectorNo

; es:bx = kernel.bin 将要被加载到的内存物理地址
mov ax, BaseOfKernelFile
mov es, ax
mov bx, OffsetOfKernelFile

; 循环读取 kernel.bin
mov ax, cx
LABEL_GOON_LOADING_FILE:
; 打点,表示准备读取一个扇区,展示 Booting....
push ax
push bx
mov ah, 0Eh
mov al, '.'
mov bl, 0Fh
int 10h
pop bx
pop ax

; 根据 FAT 项值循环读取簇
mov cl, 1
call ReadSector
pop ax
call GetFATEntry
cmp ax, 0FFFh
jz LABEL_FILE_LOADED
push ax
mov dx, RootDirSectors
add ax, dx
add ax, DeltaSectorNo
add bx, [BPB_BytsPerSec]
jmp LABEL_GOON_LOADING_FILE

; 加载完成
LABEL_FILE_LOADED:
; 关闭软驱
call KillMotor

; 显示字符串
mov dh, 1
call DispStrRealMode


; ------------ 跳转进入保护模式 -------------
; 加载 GDTR
lgdt [GdtPtr]

; 关中断
cli

; 打开地址线A20
in al, 92h
or al, 00000010b
out 92h, al

; 准备切换到保护模式
mov eax, cr0
or eax, 1
mov cr0, eax

; 真正进入保护模式
jmp dword SelectorFlatC:(BaseOfLoaderPhyAddr + LABEL_PM_START)

jmp $

; ---- 显示一个字符串, 函数开始时 dh 中存储字符串序号(0-based) ----
DispStrRealMode:
mov ax, MessageLength
mul dh
add ax, LoadMessage
mov bp, ax ; ┓
mov ax, ds ; ┣ ES:BP = 串地址
mov es, ax ; ┛
mov cx, MessageLength ; CX = 串长度
mov ax, 01301h ; AH = 13, AL = 01h
mov bx, 0007h ; 页号为0(BH = 0) 黑底白字(BL = 07h)
mov dl, 0
add dh, 3 ; 从第 3 行往下显示
int 10h
ret

; ------------- 关闭软驱 -----------
KillMotor:
push dx
mov dx, 03F2h
mov al, 0
out dx, al
pop dx
ret

; ----- 从第 ax 个 Sector 开始, 将 cl 个 Sector 读入 es:bx 中 -----
ReadSector:
push bp
mov bp, sp
sub esp, 2 ; 开辟两个字节的堆栈区域存储扇区数

mov byte [bp-2], cl
push bx
mov bl, [BPB_SecPerTrk] ; bl: 每磁道扇区数
div bl ; 商保存在 al 中,余数保存在 ah 中
inc ah ; 获取其实扇区号
mov cl, ah ; cl mov dh, al
shr al, 1 ; 获取柱面号
mov ch, al ; ch and dh, 1 ; 获取磁头号
pop bx
mov dl, [BS_DrvNum] ; 驱动器号 (0 表示 A 盘)
.GoOnReading:
mov ah, 2 ; 读
mov al, byte [bp-2] ; 读 al 个扇区
int 13h
jc .GoOnReading ; 如果读取错误 CF 会被置为 1, 这时就不停地读, 直到正确为止
add esp, 2
pop bp

ret

; ---- 读取序号为 ax 的 Sector 在 FAT 中的条目, 放在 ax 中 ----
GetFATEntry:
push es
push bx
push ax

; 在 BaseOfKernelFile 后面留出 4K 空间用于存放 FAT
mov ax, BaseOfKernelFile
sub ax, 0100h
mov es, ax

; 判断 ax 奇偶性,赋值 bOdd 变量
pop ax
mov byte [bOdd], 0 ; bOdd 变量用于存放当前是奇数次读取还是偶数次读取
mov bx, 3
mul bx ; dx:ax = ax * 3
mov bx, 2
div bx ; dx:ax / 2 ==> ax cmp dx, 0
jz LABEL_EVEN
mov byte [bOdd], 1 ; 奇数

LABEL_EVEN:
; 计算 FAT 项所在扇区号
xor dx, dx
mov bx, [BPB_BytsPerSec]
div bx ; dx:ax / BPB_BytsPerSec
; ax ; dx push dx
mov bx, 0 ; bx 0 于是, es:bx = (BaseOfKernelFile - 100):00
add ax, SectorNoOfFAT1 ; ax = FAT1 起始扇区号 + 指定读取扇区号 = FATEntry 所在的扇区号
mov cl, 2
call ReadSector ; 读取 FATEntry 所在的扇区, 一次读两个

; 赋值结果给 ax 并矫正结果
pop dx
add bx, dx
mov ax, [es:bx]
cmp byte [bOdd], 1
jnz LABEL_EVEN_2
shr ax, 4
LABEL_EVEN_2:
and ax, 0FFFh

LABEL_GET_FAT_ENRY_OK:

pop bx
pop es
ret

; --------------- 变量 ----------------
wRootDirSizeForLoop dw RootDirSectors ; Root Directory 占用的扇区数
wSectorNo dw 0 ; 要读取的扇区号
bOdd db 0 ; 奇数还是偶数
dwKernelSize dd 0 ; KERNEL.BIN 文件大小

; -------------- 字符串 ----------------
KernelFileName db "KERNEL BIN", 0 ; KERNEL.BIN 文件名
MessageLength equ 9
LoadMessage: db "Loading "
Message1 db "Ready. "
Message2 db "No KERNEL"

; ------------ 32 位代码段 -------------
[SECTION .s32]
ALIGN 32
[BITS 32]

LABEL_PM_START:
mov ax, SelectorVideo
mov gs, ax

mov ax, SelectorFlatRW
mov ds, ax
mov es, ax
mov fs, ax
mov ss, ax
mov esp, TopOfStack

add esp, 4

call DispMemInfo
call SetupPaging

push szMemChkTitle
call DispStr
jmp $


%include "lib.asm"

; ----------------- 获取内存信息 ------------------
DispMemInfo:
push esi
push edi
push ecx

; 循环获取 ARDS 4 个成员
mov esi, MemChkBuf ; 寻址缓存区
mov ecx, [dwMCRNumber] ; 获取循环次数 ARDS 个数
.loop:
mov edx, 5 ; 循环遍历 ARDS 的 4 个成员
mov edi, ARDStruct
.1:

; 将缓冲区中成员赋值给 ARDStruct
mov eax, dword [esi]
stosd

add esi, 4
dec edx
cmp edx, 0
jnz .1

; Type 是 AddressRangeMemory 赋值 dwMemSize
cmp dword [dwType], 1
jne .2
mov eax, [dwBaseAddrLow]
add eax, [dwLengthLow]
xchg bx, bx
cmp eax, [dwMemSize]
jb .2
mov [dwMemSize], eax
.2:
loop .loop

pop ecx
pop edi
pop esi
ret

; 启动分页机制 --------------------------------------------------------------
SetupPaging:
; 根据内存大小计算应初始化多少PDE以及多少页表
xor edx, edx
mov eax, [dwMemSize]
mov ebx, 400000h ; 400000h = 4M = 4096 * 1024, 一个页表对应的内存大小
div ebx
mov ecx, eax ; 此时 ecx 为页表的个数,也即 PDE 应该的个数
test edx, edx
jz .no_remainder
inc ecx ; 如果余数不为 0 就需增加一个页表
.no_remainder:
push ecx ; 暂存页表个数

; 为简化处理, 所有线性地址对应相等的物理地址. 并且不考虑内存空洞.

; 首先初始化页目录
mov ax, SelectorFlatRW
mov es, ax
mov edi, PageDirBase ; 此段首地址为 PageDirBase
xor eax, eax
mov eax, PageTblBase | 7
.1:
stosd
add eax, 4096 ; 为了简化, 所有页表在内存中是连续的
loop .1

; 再初始化所有页表
pop eax ; 页表个数
mov ebx, 1024 ; 每个页表 1024 个 PTE
mul ebx
mov ecx, eax ; PTE个数 = 页表个数 * 1024
mov edi, PageTblBase ; 此段首地址为 PageTblBase
xor eax, eax
mov eax, 7
.2:
stosd
add eax, 4096 ; 每一页指向 4K 的空间
loop .2

mov eax, PageDirBase
mov cr3, eax
mov eax, cr0
or eax, 80000000h
mov cr0, eax
jmp short .3
.3:
nop

ret

; ---------------- 32 位数据段 ------------------
[SECTION .data1]
ALIGN 32

LABEL_DATA:
; 实模式下使用这些符号
; 字符串
_szMemChkTitle: db "Welcome to loader by techlog.cn", 0Ah, 0
_szReturn: db 0Ah, 0

; 变量
_dwMCRNumber: dd 0 ; Memory Check Result
_dwMemSize: dd 0
_ARDStruct: ; Address Range Descriptor Structure
_dwBaseAddrLow: dd 0
_dwBaseAddrHigh: dd 0
_dwLengthLow: dd 0
_dwLengthHigh: dd 0
_dwType: dd 0
_MemChkBuf: times 256 db 0

; 保护模式下使用这些符号
szMemChkTitle equ BaseOfLoaderPhyAddr + _szMemChkTitle
szReturn equ BaseOfLoaderPhyAddr + _szReturn
dwMemSize equ BaseOfLoaderPhyAddr + _dwMemSize
dwMCRNumber equ BaseOfLoaderPhyAddr + _dwMCRNumber
ARDStruct equ BaseOfLoaderPhyAddr + _ARDStruct
dwBaseAddrLow equ BaseOfLoaderPhyAddr + _dwBaseAddrLow
dwBaseAddrHigh equ BaseOfLoaderPhyAddr + _dwBaseAddrHigh
dwLengthLow equ BaseOfLoaderPhyAddr + _dwLengthLow
dwLengthHigh equ BaseOfLoaderPhyAddr + _dwLengthHigh
dwType equ BaseOfLoaderPhyAddr + _dwType
MemChkBuf equ BaseOfLoaderPhyAddr + _MemChkBuf

; 堆栈空间
StackSpace: times 1024 db 0
TopOfStack equ BaseOfLoaderPhyAddr + $
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值