ucore Lab1 系统软件启动过程

一、实验目的

操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS,读者可以了解到:
1.计算机原理

  • CPU的编址与寻址:基于分段机制的内存管理
  • CPU的中断机制
  • 外设:串口/并口/CGA,时钟,硬盘
    2.Bootloader软件
  • 编译运行bootloader的过程
  • 调试bootloader的方法
  • PC启动bootloader的过程
  • ELF执行文件的格式和加载
  • 外设访问:读硬盘,在CGA上显示字符串
    3.Ucore OS软件
  • 编译运行ucore OS的过程
  • ucore OS的启动过程
  • 调试ucore OS的方法
  • 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
  • 中断管理:与软件相关的中断处理
  • 外设管理:时钟

二、实验内容
lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。

三、实验练习
1.练习1:理解通过make生成执行文件的过程。(要求在报告中写出对下述问题的回答)
1.1操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
① 进入Ubuntu20.04,打开终端,通过sudo su指令,输入密码,从普通用户切换到root用户。
在这里插入图片描述
② 因为我之前已经换过源了,所以这个实验里不用再换源。输入apt install qemu-system-i386,再输入y,安装qemu
在这里插入图片描述
③ 输入sudo dj或ctrl+d或exit切换回普通用户,实验准备部分结束。
在这里插入图片描述
④ 进入到OS/ucore_os_lab-master/labcodes/lab1目录下,输入指令make
在这里插入图片描述
⑤ 如果连续两次输入make/make V=,会提示没有更改,这时可以make clean后再次make V=
在这里插入图片描述
⑥ 运行make V=,观察每一步的make指令,得到以下所有输出:
lab1是ucore的开始,部分函数未完成调用会出现warning
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
其中,除kernel部分外:
链接生成kernel:
在这里插入图片描述
bootblock部分:
在这里插入图片描述
链接生成bootblock:
在这里插入图片描述
生成ucore.img:
在这里插入图片描述
ld部分,由bootmain.o和bootasm.o来生成,再通过gcc编译。
有一个0x7c00是偏移量,如果直接对bootasm.o进行调试,地址是不对的,得加上0x7c00这个偏移量

⑦ 通过man查看,如看ld则输入man ld,回车即可
在这里插入图片描述
⑧ Makefile文件内容如下:

PROJ	:= challenge
EMPTY	:=
SPACE	:= $(EMPTY) $(EMPTY)
SLASH	:= /
##make “V=”可输出make执行的命令
V       := @
#need llvm/cang-3.5+
#USELLVM := 1
##选择交叉编译器检查GCCPREFIX
# try to infer the correct GCCPREFX
ifndef GCCPREFIX
GCCPREFIX := $(shell if i386-elf-objdump -i 2>&1 | grep '^elf32-i386$$' >/dev/null 2>&1; \
	then echo 'i386-elf-'; \
	elif objdump -i 2>&1 | grep 'elf32-i386' >/dev/null 2>&1; \
	then echo ''; \
	else echo "***" 1>&2; \
	echo "*** Error: Couldn't find an i386-elf version of GCC/binutils." 1>&2; \
	echo "*** Is the directory with i386-elf-gcc in your PATH?" 1>&2; \
	echo "*** If your i386-elf toolchain is installed with a command" 1>&2; \
	echo "*** prefix other than 'i386-elf-', set your GCCPREFIX" 1>&2; \
	echo "*** environment variable to that prefix and run 'make' again." 1>&2; \
	echo "*** To turn off this error, run 'gmake GCCPREFIX= ...'." 1>&2; \
	echo "***" 1>&2; exit 1; fi)
endif
##设置QEMU
# try to infer the correct QEMU
ifndef QEMU
QEMU := $(shell if which qemu-system-i386 > /dev/null; \
	then echo 'qemu-system-i386'; exit; \
	elif which i386-elf-qemu > /dev/null; \
	then echo 'i386-elf-qemu'; exit; \
	elif which qemu > /dev/null; \
	then echo 'qemu'; exit; \
	else \
	echo "***" 1>&2; \
	echo "*** Error: Couldn't find a working QEMU executable." 1>&2; \
	echo "*** Is the directory containing the qemu binary in your PATH" 1>&2; \
	echo "***" 1>&2; exit 1; fi)
endif

# eliminate default suffix rules
.SUFFIXES: .c .S .h

##如果遇到error或者被中断了就删除所有目标文件
# delete target files if there is an error (or make is interrupted)
.DELETE_ON_ERROR:

##设置编译器选项
# define compiler and flags
ifndef  USELLVM
##gcc编译,-g为了gdb调试,-Wall生成警告信息,-02优化处理级别
HOSTCC		:= gcc
HOSTCFLAGS	:= -g -Wall -O2
CC		:= $(GCCPREFIX)gcc
##-fno-builtin不使用C语言的内建函数,-ggdb为GDB生成更丰富的调试信息,-m32用32位编译,-gstabs生成stabs格式调试信息但不包括GDB调试信息,-nostdinc不在系统默认头文件目录中寻找文件,$(DEFS)未定义可用来扩展信息
CFLAGS	:= -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc $(DEFS)
##$(shell)可以输出shell指令,-fno-stack-protector禁用堆栈保护,-E仅预处理不进行编译汇编链接可以提高速度,-x c指明C语言
##/dev/null指定目标文件,>/dev/null 2>&1标准错误重定向到标准输出,&&先运行前一句,若成功再运行后一句
##意为只预处理,所有出错全部作为垃圾(/dev/null类似垃圾文件)测试能否开启-fno-stack-protector,若能则CFLAGS += -fno-stack-protector
CFLAGS	+= $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
else
##若使用clang,类似处理
HOSTCC		:= clang
HOSTCFLAGS	:= -g -Wall -O2
CC		:= clang
CFLAGS	:= -fno-builtin -Wall -g -m32 -mno-sse -nostdinc $(DEFS)
CFLAGS	+= $(shell $(CC) -fno-stack-protector -E -x c /dev/null >/dev/null 2>&1 && echo -fno-stack-protector)
endif

##源文件类型为.c和.s
CTYPE	:= c S

LD      := $(GCCPREFIX)ld
##shell中命令shell中命令 ld -V可以输出支持的版本,|管道将前者的输出作为后者的输入,grep在输入中搜索elf_i386字串,找到就输出elf_i386
 ##意味如果支持elf_i386则LDFLAGS := -m elf_i386
LDFLAGS	:= -m $(shell $(LD) -V | grep elf_i386 2>/dev/null)
##-nostdlib不连接系统标准库文件
LDFLAGS	+= -nostdlib

OBJCOPY := $(GCCPREFIX)objcopy
OBJDUMP := $(GCCPREFIX)objdump

##定义一些shell命令
COPY	:= cp
MKDIR   := mkdir -p
MV		:= mv
RM		:= rm -f
AWK		:= awk
SED		:= sed
SH		:= sh
TR		:= tr
TOUCH	:= touch -c

OBJDIR	:= obj
BINDIR	:= bin

ALLOBJS	:=
ALLDEPS	:=
TARGETS	:=

##在function.mk中定义了大量辅助函数,部分说明参考了引用中的博文
include tools/function.mk

##call:call func,变量1,变量2,... 
##listf:列出某地址下某类型的文件 
##listf_cc:列出变量1下的.c与.S文件
listf_cc = $(call listf,$(1),$(CTYPE))

# for cc
##将文件打包
add_files_cc = $(call add_files,$(1),$(CC),$(CFLAGS) $(3),$(2),$(4))
##创建目标文件包
create_target_cc = $(call create_target,$(1),$(2),$(3),$(CC),$(CFLAGS))

# for hostcc
add_files_host = $(call add_files,$(1),$(HOSTCC),$(HOSTCFLAGS),$(2),$(3))
create_target_host = $(call create_target,$(1),$(2),$(3),$(HOSTCC),$(HOSTCFLAGS))

##patsubst替换通配符 
##cgtype(filenames,type1,type2)把文件名中type1的改为type2,如.c改为.o
cgtype = $(patsubst %.$(2),%.$(3),$(1))
##列出所有目标文件,并按规则改后缀名
objfile = $(call toobj,$(1))
asmfile = $(call cgtype,$(call toobj,$(1)),o,asm)
outfile = $(call cgtype,$(call toobj,$(1)),o,out)
symfile = $(call cgtype,$(call toobj,$(1)),o,sym)

# for match pattern
match = $(shell echo $(2) | $(AWK) '{for(i=1;i<=NF;i++){if(match("$(1)","^"$$(i)"$$")){exit 1;}}}'; echo $$?)

# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# include kernel/user

INCLUDE	+= libs/

CFLAGS	+= $(addprefix -I,$(INCLUDE))

LIBDIR	+= libs

$(call add_files_cc,$(call listf_cc,$(LIBDIR)),libs,)

# -------------------------------------------------------------------
# kernel

KINCLUDE	+= kern/debug/ \
			   kern/driver/ \
			   kern/trap/ \
			   kern/mm/

KSRCDIR		+= kern/init \
			   kern/libs \
			   kern/debug \
			   kern/driver \
			   kern/trap \
			   kern/mm

KCFLAGS		+= $(addprefix -I,$(KINCLUDE))

$(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS))

KOBJS	= $(call read_packet,kernel libs)

# create kernel target
##将所有文件链接生成kernel
kernel = $(call totarget,kernel)

$(kernel): tools/kernel.ld

$(kernel): $(KOBJS)
	@echo + ld $@
	$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
	@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
	@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)

$(call create_target,kernel)

# -------------------------------------------------------------------

# create bootblock
##将所有文件链接生成bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

bootblock = $(call totarget,bootblock)

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
	@echo + ld $@
	$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
	@$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
	@$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
	@$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

$(call create_target,bootblock)

# -------------------------------------------------------------------

# create 'sign' tools
##生成sign辅助工具
$(call add_files_host,tools/sign.c,sign,sign)
$(call create_target_host,sign,sign)

# -------------------------------------------------------------------

##生成ucore.img
# create ucore.img
UCOREIMG	:= $(call totarget,ucore.img)

$(UCOREIMG): $(kernel) $(bootblock)
	$(V)dd if=/dev/zero of=$@ count=10000
	$(V)dd if=$(bootblock) of=$@ conv=notrunc
	$(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc

$(call create_target,ucore.img)

# >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

$(call finish_all)

IGNORE_ALLDEPS	= clean \
				  dist-clean \
				  grade \
				  touch \
				  print-.+ \
				  handin

ifeq ($(call match,$(MAKECMDGOALS),$(IGNORE_ALLDEPS)),0)
-include $(ALLDEPS)
endif

# files for grade script

TARGETS: $(TARGETS)

.DEFAULT_GOAL := TARGETS

.PHONY: qemu qemu-nox debug debug-nox
qemu-mon: $(UCOREIMG)
	$(V)$(QEMU)  -no-reboot -monitor stdio -hda $< -serial null
qemu: $(UCOREIMG)
	$(V)$(QEMU) -no-reboot -parallel stdio -hda $< -serial null
log: $(UCOREIMG)
	$(V)$(QEMU) -no-reboot -d int,cpu_reset  -D q.log -parallel stdio -hda $< -serial null
qemu-nox: $(UCOREIMG)
	$(V)$(QEMU)   -no-reboot -serial mon:stdio -hda $< -nographic
TERMINAL        :=gnome-terminal
debug: $(UCOREIMG)
	$(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
	$(V)sleep 2
	$(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"
	
debug-nox: $(UCOREIMG)
	$(V)$(QEMU) -S -s -serial mon:stdio -hda $< -nographic &
	$(V)sleep 2
	$(V)$(TERMINAL) -e "gdb -q -x tools/gdbinit"

.PHONY: grade touch

GRADE_GDB_IN	:= .gdb.in
GRADE_QEMU_OUT	:= .qemu.out
HANDIN			:= proj$(PROJ)-handin.tar.gz

TOUCH_FILES		:= kern/trap/trap.c

MAKEOPTS		:= --quiet --no-print-directory

grade:
	$(V)$(MAKE) $(MAKEOPTS) clean
	$(V)$(SH) tools/grade.sh

touch:
	$(V)$(foreach f,$(TOUCH_FILES),$(TOUCH) $(f))

print-%:
	@echo $($(shell echo $(patsubst print-%,%,$@) | $(TR) [a-z] [A-Z]))

.PHONY: clean dist-clean handin packall tags
clean:
	$(V)$(RM) $(GRADE_GDB_IN) $(GRADE_QEMU_OUT) cscope* tags
	-$(RM) -r $(OBJDIR) $(BINDIR)

dist-clean: clean
	-$(RM) $(HANDIN)

handin: packall
	@echo Please visit http://learn.tsinghua.edu.cn and upload $(HANDIN). Thanks!

packall: clean
	@$(RM) -f $(HANDIN)
	@tar -czf $(HANDIN) `find . -type f -o -type d | grep -v '^\.*$$' | grep -vF '$(HANDIN)'`

tags:
	@echo TAGS ALL
	$(V)rm -f cscope.files cscope.in.out cscope.out cscope.po.out tags
	$(V)find . -type f -name "*.[chS]" >cscope.files
	$(V)cscope -bq 
	$(V)ctags -L cscope.files

⑨ 总结:
根据生成ucore的代码可知:
首先要生成一个有10000个块的文件,每个块默认512字节,用0填充dd if=/dev/zero of=bin/ucore.img count=10000。
接着把bootblock中的内容写到第一个块
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
从第二个块开始写kernel中的内容
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
dd的一些参数的含义:
-if表示输入文件,如果不指定,那么会默认从stdin中读取输入
-of表示输出文件,如果不指定,那么会stdout
bs表示以字节为单位的块大小
count表示被赋值的块数
/dev/zero是一个字符设备,会不断返回0值字节\0
conv = notrunc 不截短输出文件
seek=blocks 从输出文件开头跳过blocks个块后再开始复制
这样ucore.img就一步一步的生成了。

1.2一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
答:
磁盘主引导扇区只有512字节
磁盘最后两个字节为0x55AA
由不超过466字节的启动代码和不超过64字节的硬盘分区表加上两个字节的结束符组成
在tools/sign.c中有相应代码用来检测是否符合要求。执行指令less tools/sign.c,查看代码段,截图如下:
在这里插入图片描述
在这里插入图片描述
2.练习2:使用qemu执行并调试lab1中的软件
① 输入qemu-system-i386 -S -s -hda bin/ucore.img,进入qemu界面
在这里插入图片描述
在这里插入图片描述
② 输入gdb,开始调试。在这里插入图片描述
③ 再输入target remote:1234连接qemu
在这里插入图片描述
④ 输入file bin/指定要调试的文件,如file obj/bootblock.o,输入y进入对应的.o文件进行调试。输入b *0x7c00,在0x7c00处设置断点,再输入c,继续调试。再输入layout asm,进入调试框界面。
在这里插入图片描述
⑤ 通过layout split将窗口分为三部分,分别为代码、汇编代码、命令窗口。
在这里插入图片描述
⑥ 命令窗口输入si,进入下一条指令;b bootasm.S:51在文件第51行加入断点。
在这里插入图片描述
⑦ counting,再输入info registers查看寄存器值
在这里插入图片描述
⑧ 输入info registers cr0查看cr0里的值在这里插入图片描述
⑨ 从汇编代码可以看出,cr0的值先放入eax中,再由eax值加1后返回给cr0。通过调试检查是否如此
在这里插入图片描述
⑩ 其他一些调试指令:
b bootmain:在bootmain函数处设置断点
bt:查看当前函数调用情况
p:print
s:单步执行
n:进入下一行代码

3.练习3:分析bootloader进入保护模式的过程
① Bootasm.S部分源码分析
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
② 总结:
先设置寄存器ax,ds,es,ss寄存器值为0;地址线20被封锁,高于1MB的地址默认回卷到0。由于历史原因,A20地址位由键盘控制器芯片8042管理,所有激活A20要给8042发命令激活。
8042有两个I/O端口:0x60和0x64,激活流程位:发送0xd1命令到0x64端口后,发送0xdf到0x60
从实模式转换到保护模式:用到了全局描述符和段表,使得虚拟地址和物理地址匹配,保证转换时有效的内存映射不改变;lgdt汇编指令把GDTR描述符的大小和起始位置存入gdtr寄存器中;将cr0的最后一位设置为1,进入保护模式;指令跳转由代码段跳到protcseg的起始位置。

③ 问题:
为何开启A20,以及如何开区A20
答:
为了兼顾早期的PC机,第20根地址线在实模式下不能使用,超过1MB的地址会默认返回到地址0,所以需要开启A20.
通过将键盘控制器上的A20线置于高电位,全部32条地址线可用,可以访问4G的内存空间。

如何初始化GDT表
答:一个简单的GDT表和其描述符已经静态存储在引导区中,载入lgdt gdtdesc即可初始化GDT表

如何使能和进入保护模式
答:通过将cr0寄存器最低位置1,开启保护模式

4.练习4:分析bootloader加载ELF格式的OS的过程
4.1bootloader如何读取硬盘扇区的?
① 打开bootmain,在bootmain中第一句就是读取硬盘扇区,截图如下:
在这里插入图片描述
② 大致流程:等待磁盘准备好->发出读取扇区的命令->等待磁盘准备好->把磁盘扇区数据读取到指定内存
③ readsect从设备的第secno扇区读取数据到dst位置
在这里插入图片描述
I/O地址 功能
0x1f0 读数据,当0x1f7不为忙状态时,可以读。
0x1f2 要读写的扇区数,每次读写前,你需要表明你要读写几个扇区。最小是1个扇区
0x1f3 如果是LBA模式,就是LBA参数的0-7位
0x1f4 如果是LBA模式,就是LBA参数的8-15位
0x1f5 如果是LBA模式,就是LBA参数的16-23位
0x1f6 第0~3位:如果是LBA模式就是24-27位 第4位:为0主盘;为1从盘
0x1f7 状态和命令寄存器。操作时先给命令,再读取,如果不是忙状态就从0x1f0端口读数据

4.2bootloader是如何加载ELF格式的OS?
1.从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用;
2.校验e_magic字段;
3.根据偏移量分别把程序段的数据读取到内存中。
在这里插入图片描述
5.实现函数调用堆栈跟踪函数(需要编程)
① 函数堆栈的基本原理
一个函数调用动作可分解为:零到多个PUSH指令(用于参数入栈),一个CALL指令。CALL指令内部其实还暗含了一个将返回地址(即CALL指令下一条指令的地址)压栈的动作(由硬件完成)。
几乎所有本地编译器都会在每个函数体之前插入类似pushl %ebp 和movl %esp , %ebp的指令。这样在程序执行到一个函数的实际指令前,已经有以下数据顺序入栈:参数、返回地址、ebp寄存器。
基本栈结构如下图:
在这里插入图片描述
一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用4字节内存),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层ebp值。由于ebp中的地址处总是“上一层函数调用时的ebp值”,而在每一层函数调用中,都能通过当时的ebp值“向上(栈底方向)”能获取返回地址、参数值,“向下(栈顶方向)”能获取函数局部变量值。如此形成递归,直至到达栈底。这就是函数调用栈。

② 进入lab1/kern/debug目录下的kdebug.c,实现print_stackframe函数。函数实现如下图:
在这里插入图片描述
③ 在lab1下执行make qemu,在qemu模拟器中得到结果,符合预期。
在这里插入图片描述
6.完善中断初始化和处理(需要编程)
6.1中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
答:
中断描述符表中,一个表项占8字节,其中015位和4863为分别为offset的低16位和高16位。16~31位为段选择子。通过段选择子获得段基址,加上段内偏移量即可获得到中断处理代码的路口。
在这里插入图片描述
在这里插入图片描述
6.2请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
① 编程完善kern/trap/trap.c中的函数idt_init,实现如下:
在这里插入图片描述
② 利用了SETGATE宏,在mmu.h中查看这个宏
在这里插入图片描述
gate:为相应的idt数组内容,处理函数的入口地址
istrap:系统段设置为1,中断门设置为0
sel:段选择子,这里是GO-KTEXT
#define GD_KTEXT ((SEG_KTEXT)<<3) //kernel text
off:为_vectors数字内容,存在vectors.s中,支持256个中断
dpl:设置优先级,0为内核级,3为用户级

6.3请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
编程完善trap.c中的中断处理函数trap_dispatch,实现如下:
在这里插入图片描述
2)运行结果如下,实现中断。
在这里插入图片描述
在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值