中国科学院大学2023杨力祥老师操作系统高级教程思考题(1)

国科大操作系统高级教程思考题,参考书是《Linux内核设计的艺术-图解Linux操作系统架构设计与实现原理(第二版)》杨老师上课讲的特别棒,该文章供自己学习使用,有欠妥当的地方欢迎批评指正,参考往年学长学姐的文章,今年又有一些新题。

第一次思考题:

1.为什么开始启动计算机的时候,执行的是BIOS代码而不是操作系统自身的代码?

答:

计算机被设计为从内存中运行程序,无法直接从软盘或者硬盘中运行。最开始启动计算机的时候,计算机内存未初始化,没有任何程序。而因为CPU只能读取内存中的程序,所以必须将操作系统先加载进内存当中。需要使用BIOS。在加电后, BIOS 需要完成一些硬件检测工作,同时设置实模式下的中断向量表和服务程序,并将操作系统的引导扇区加载至 0x7C00 处,然后将跳转至 0x7C00运行操作系统自身的代码。BIOS程序存放在ROM中,ROM断电后也能保持信息,但一被烧就不能改变数据,适合存放BIOS这种不需要修改的例行工作。所以计算机启动最开始运行的是BIOS代码。

2.为什么BIOS只加载了一个扇区,后续扇区却是由bootsect代码加载?为什么BIOS没有直接把所有需要加载的扇区都加载?

答:

BIOS和操作系统的开发通常是不同的团队,按固定的规则约定,可以进行灵活的各自设计相应的部分。BIOS接到启动操作系统命令后,只从启动扇区将代码加载至0x7c00(BOOTSEG)位置,而后续扇区由bootsect代码加载,这些代码由编写系统的用户负责,与之前BIOS无关。这样构建的好处是站在整个体系的高度,统一设计和统一安排,简单而有效。BIOS和操作系统的开发都可以遵循这一约定,灵活地进行各自的设计。例如,BIOS可以不用知道内核镜像的大小以及其在软盘的分布等等信息,减轻了BIOS程序的复杂度,降低了硬件上的开销。而操作系统的开发者也可以按照自己的意愿,内存的规划,等等都更为灵活。另外,如果要使用BIOS进行加载,而且加载完成之后再执行,则需要很长的时间,此外,对于不同的操作系统,其代码长度不一样,可能导致操作系统加载不完全。因此Linux采用的是边执行边加载的方法。

3.为什么BIOS把bootsect加载到0x07c00,而不是0x00000?加载后又马上挪到0x90000处,是何道理?为什么不一次加载到位?

答:

加载0x07c00是BIOS提前约定设置的,不能加载到0x00000是因为从0x00000开始到0x003ff这1KB内存空间都是BIOS首先约定进行加载中断向量表的地方,不能进行覆盖。

(1)加载0x07c00是BIOS提前约定设置的,BIOS把bootsect加载到0x07c00而不是0x00000,是因为0x00000处存放着BIOS构建的1k大小的中断向量表和256B的BIOS数据区,这些数据还有用处,不能进行覆盖。

(2)加载后又挪到0x90000是因为,操作系统对内存的规划是在0x90000存放bootsect,然后bootsect执行结束之后,立即将系统机器数据存放在此处,这样就可以及时回收寿命结束的程序占据的内存空间。而且后续会把120K的系统模块存放到0x00000处,这会覆盖0x07c00处的代码和数据。

(3)不一次加载到位的原因是由于“两头约定”和“定位识别”,所以在开始时bootsect“被迫”加载到0X07c00位置。现在将自身移至0x90000处,说明操作系统开始根据自己的需要安排内存了。

下图为bootsect.s, setup.s, head.s代码执行过程中内存映像变化(这个图非常重要!!!!一定记住了)。

4.bootsect、setup、head程序之间是怎么衔接的?给出代码证据。

解释这个问题之前先搞明白bootsect、setup、head都干了啥(作为扩展总结一下,大致浏览即可,杨老师书上写的已经很详细了)

bootsect:

  1. 自我迁移:加电自检后,BIOS将引导扇区加载到内存0x7C00处。bootsect.s 随后将自身代码从0x7C00移动到内存的0x90000处,为操作系统加载腾出空间。

  2. 加载 setup 模块:将磁盘上的 setup 模块(由 setup.s 编译而成)加载到内存中bootsect后面的位置,即0x90200。

  3. 读取磁盘参数:利用BIOS中断0x13,读取磁盘参数表中当前启动引导盘的参数。

  4. 显示加载信息:在屏幕上显示“Loading system…”字符串,提供用户反馈,表明系统正在加载。

  5. 加载 system 模块:从磁盘上将 system 模块加载到内存的0x10000处。这是内核映像的标准加载地址。

  6. 确定根文件系统设备号

    • 如果已指定设备号(root_dev不为0),则直接使用该设备号。
    • 如果未指定,根据引导盘的每磁道扇区数确定盘的类型,并保存相应的设备号到 root_dev(位于引导块的0x508地址处)。
  7. 执行 setup 程序:最后,执行跳转到 setup 程序的开始处(0x90200),以继续操作系统的启动过程。

setup:

  1. 读取系统数据:利用ROM BIOS中断读取计算机的硬件和配置信息,并将这些信息保存到内存地址0x90000处,覆盖了原先的 bootsect 程序所在的位置。

  2. 移动 system 模块:将系统模块(内核映像)从0x10000-0x8ffff的位置整体移动到内存的0x00000处,这是为了准备将控制权交给操作系统。

  3. 加载IDTR和GDTR:设置中断描述符表寄存器(IDTR)和全局描述符表寄存器(GDTR),为进入保护模式做准备。

  4. 开启A20地址线:为了访问超过1MB的内存,启用A20地址线是必要的步骤。

  5. 重新配置8259A中断控制器:将硬件中断号设置为0x20-0x2f,以避免与x86架构的软件中断号冲突。

  6. 进入32位保护模式:设置CPU的控制寄存器CR0,切换CPU到32位保护模式。

  7. 跳转到 head.s:在准备完毕后,跳转到 head.s 程序,这是内核执行的下一个阶段,head.s 在保护模式下运行。

  8. 设置临时的IDT和GDTsetup.s 中还包括了设置中断描述符表(IDT)和全局描述符表(GDT)的代码,其中还包括了当前内核代码段的描述符和数据段的描述符,以确保在切换到32位模式后CPU能够正确解析内核的地址空间。

系统执行完setup之后内存映像:

head:

  1. 设置数据段寄存器:加载各个数据段寄存器,为后续程序的运行做准备。

  2. 初始化中断描述符表(IDT):重新设置IDT,共256项,每项都指向一个处理中断的程序。

  3. 重新设置全局描述符表(GDT):根据保护模式的要求,配置全局描述符表。

  4. 检测A20地址线:通过比较物理地址0和1MB处的内容,确保A20地址线已经开启,这是访问高于1MB内存地址的前提条件。

  5. 检测数学协处理器:检测PC机是否含有数学协处理器,并相应设置控制寄存器CR0中的标志位。

  6. 设置分页机制:配置内存分页处理机制,将页目录表放在物理地址0开始处,并设置后续的页表,使其能够寻址最多16MB的内存。

  7. 覆盖自身:由于页目录表被放置在head.s所在的内存位置,因此head.s程序会被覆盖。

  8. 跳转到main()函数:利用返回指令跳转到在/init/main.c程序中定义的main()函数,这标志着内核的高级初始化过程的开始。

系统执行完head之后内存映像:

答:

① bootsect→setup程序:jmpi 0,SETUPSEG;

bootsect首先利用int 0x13中断分别加载setup程序及system模块,待bootsect程序的任务完成之后,执行代码jmpi 0,SETUPSEG。由于 bootsect 将 setup 段加载到了 SETUPSEG:0 (0x90200)的地方,在实模式下,CS:IP指向setup程序的第一条指令,此时setup开始执行。

在bootsect程序中135-139行中有如下程序

//代码路径:boot\bootsect.s 135-139
! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:

	jmpi	0,SETUPSEG     !跳转到 0x9020:0000(setup.s 程序的开始处)!Tips:该程序到这已经完全结束了

而关于SETUPSEG的定义

//代码路径:boot\bootsect.s 34-39
SETUPLEN = 4					! nr of setup-sectors [setup 程序的扇区数(setup-sectors)值]
BOOTSEG  = 0x07c0			! original address of boot-sector[bootsect 的原始地址(是段地址,以下同)]
INITSEG  = 0x9000			! we move boot here - out of the way [将 bootsect 移到这里 -- 避开]
SETUPSEG = 0x9020			! setup starts here [setup 程序从这里开始]
SYSSEG   = 0x1000			! system loaded at 0x10000 (65536).[system 模块加载到 0x10000(64 kB)处]
ENDSEG   = SYSSEG + SYSSIZE		! where to stop loading [停止加载的段地址]

此时执行红线过程:

② setup→head程序:jmpi 0,8

执行setup后,内核被移到了0x00000处,系统进入了保护模式,执行jmpi 0,8

并加载了中断描述符表和全局描述符表lidt idt_48;1gdt gdt_48。在保护模式下,一个重要的特征就是根据GDT决定后续执行哪里的程序。该指令执行后跳转到以GDT第2项中的 base_addr 为基地址,以0为偏移量的位置,其中base_addr为0。由于head放置在内核的头部,因此程序跳转到head中执行。

5.setup程序的最后是jmpi 0,8 ,为什么这个8不能简单的当作阿拉伯数字8看待,究竟有什么内涵?

此时为32位保护模式,“0”表示段内偏移,“8”表示段选择符。这里8要转化为二进制:1000,最后两位00表示内核特权级(若是11则表示用户),第三位0表示 GDT 表(若是1则表示LDT表),第四位1表示根据GDT中的第2项来确定代码段的段基址和段限长等信息。可以得到代码是从head 的开始位置,段基址 0x00000000、偏移为 0 处开始执行的,即head的开始位置。

关于jmpi 0,8的解释

jmpi    0,8  ! jmp offset 0 of segment 8 (cs) ! 跳转至cs段8,偏移0处。

此时我们已经将 system 模块移动到 0x00000开始的地方,所以这里的偏移地址是 0。这里的段值8是32位保护模式下的段选择符,用于选择描述符表和描述符表项以及所要求的特权级。 段选择符长度为16 位(2 字节);位 0-1 表示请求的特权级0-3,linux 操作系统只用到两级:0 级(系统级)和 3 级(用户级);位 2用于选择全局描述符表(0)还是局部描述符表(1);位 3-15 是描述符表项的索引,指出选择第几项描述符。所以段选择符8(0b0000,0000,0000,1000)表示请求特权级0、使用全局描述符表中的第1项,该项指出代码的基地址是0(参见 209行),因此这里的跳转指令就会去执行 system 中的代码。

6.保护模式在“保护”什么?它的“保护”体现在哪里?特权级的目的和意义是什么?分页有“保护”作用吗?

(1) 保护模式在“保护”什么?它的“保护”体现在哪里?

保护操作系统的安全,不受到恶意攻击。保护进程地址空间。

“保护”体现在:打开保护模式后,CPU 的寻址模式发生了变化,基于 GDT 去获取代码或数据段的基址,相当于增加了一个段位寄存器。防止了对代码或数据段的覆盖以及代码段自身的访问超限,明显增强了保护作用。对描述符所描述的对象进行保护:在 GDT、 LDT 及 IDT 中,均有对应界限、特权级等,这是对描述符所描述的对象的保护;在不同特权级间访问时,系统会对 CPL、 RPL、 DPL、 IOPL 等进行检验,同时限制某些特殊指令如 lgdt, lidt,cli 等的使用;分页机制中 PDE 和 PTE 中的 R/W 和 U/S 等提供了页级保护,分页机制通过将线性地址与物理地址的映射,提供了对物理地址的保护。

(2)特权级的目的和意义是什么?

特权级机制目的是为了进行合理的管理资源,保护高特权级的段。其中操作系统的内核处于最高的特权级。

意义是进行了对系统的保护,对操作系统的“主奴机制”影响深远。Intel 从硬件上禁止低特权级代码段使用部分关键性指令,通过特权级的设置禁止用户进程使用 cli、 sti 等对掌控局面至关重要的指令。有了这些基础,操作系统可以把内核设计成最高特权级,把用户进程设计成最低特权级。这样,操作系统可以访问 GDT、 LDT、 TR,而 GDT、 LDT 是逻辑地址形成线性地址的关键,因此操作系统可以掌控线性地址。物理地址是由内核将线性地址转换而成的,所以操作系统可以访问任何物理地址。而用户进程只能使用逻辑地址。总之,特权级的引入对操作系统内核进行保护。

(3)分页有“保护”作用吗?

分页机制有保护作用,使得用户进程不能直接访问内核地址,进程间也不能相互访问。用户进程只能使用逻辑地址,而逻辑地址通过内核转化为线性地址,根据内核提供的专门为进程设计的分页方案,由MMU非直接映射转化为实际物理地址形成保护。此外,通过分页机制,每个进程都有自己的专属页表,有利于更安全、高效的使用内存,保护每个进程的地址空间。

为什么特权级是基于段的?(超纲备用)

在操作系统设计中,一个段一般实现的功能相对完整,可以把代码放在一个段,数据放在一个段,并通过段选择符(包括CS、SS、DS、ES、Fs和GS)获取段的基址和特权级等信息。通过段,系统划分了内核代码段、内核数据段、用户代码段和用户数据段等不同的数据段,有些段是系统专享的,有些是和用户程序共享的,因此就有特权级的概念。特权级基于段,这样当段选择子具有不匹配的特权级时,按照特权级规则评判是否可以访问。特权级基于段,是结合了程序的特点和硬件实现的一种考虑。

7.在setup程序里曾经设置过gdt,为什么在head程序中将其废弃,又重新设置了一个?为什么设置两次,而不是一次搞好?

原来GDT所在的位置是设计代码时在setup.s里面设置的数据,将来这个setup模块所在的内存位置会在设计缓冲区时被覆盖。如果不改变位置,将来GDT的内容肯定会被缓冲区覆盖掉,从而影响系统的运行。这样一来,将来整个内存中唯一安全的地方就是现在head.s所在的位置了。

那么有没有可能在执行setup程序时直接把GDT的内容复制到head.s所在的位置呢?肯定不能。如果先复制GDT的内容,后移动system模块,它就会被后者覆盖;如果先移动system模块,后复制GDT的内容,它又会把head.s对应的程序覆盖,而这时head.s还没有执行。所以,无论如何,都要重新建立GDT。

8.内核的线性地址空间是如何分页的?画出从0x000000开始的7个页(包括页目录表、页表所在页)的挂接关系图,就是页目录表的前四个页目录项、第一个个页表的前7个页表项指向什么位置?给出代码证据。

操作系统 虚拟内存 、分段、分页的理解

如何分页:head.ssetup_paging开始创建分页机制。将页目录表和4个页表放到物理内存的起始位置,从内存起始位置开始的5个页空间内容全部清零(每页4KB),然后设置页目录表的前4项,使之分别指向4个页表。然后开始从高地址向低地址方向填写4个页表,依次指向内存从高地址向低地址方向的各个页面。即将第4个页表的最后一项指向寻址范围的最后一个页面。即从0xFFF000开始的4kb 大小的内存空间。将第4个页表的倒数第二个页表项指向倒数第二个页面,即0xFFF000-0x1000000开始的4KB字节的内存空间,依此类推。

挂接关系图:

总体效果图

代码证据:注意,页目录表需指向所有页表;页表须要指向所有页;页目录表、页表自己也是页。

//代码路径:boot\head.s
.align 2
setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax
	xorl %edi,%edi			/* pg_dir is at 0x000 */
	cld;rep;stosl
	movl $pg0+7,_pg_dir		/* set present bit/user r/w */
	movl $pg1+7,_pg_dir+4		/*  --------- " " --------- */
	movl $pg2+7,_pg_dir+8		/*  --------- " " --------- */
	movl $pg3+7,_pg_dir+12		/*  --------- " " --------- */
	movl $pg3+4092,%edi
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b
	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		/* cr3 - page directory start */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

9.根据内核分页为线性地址恒等映射的要求,推导出四个页表的映射公式,写出页表的设置代码。

线性地址最终要转换为物理地址。Linux 0.11在怠速前打开了PG,线性地址是通过页目录表——页表——页面三级映射模式,最终落实到物理地址的。在保护模式下,如果没有打开PG,线性地址恒等映射到物理地址;如果打开了PG,则线性地址需要通过MMU进行解析,以页目录表、页表、页面的三级映射模式映射到物理地址。(注意:这里是为了实现支持多进程执行,不是内核分页的机制)

答:

内核分页采用线性地址恒等映射。内核的段基址是0,代码段和数据段的段限长都是16 MB。每个页面大小为4 KB,每个页表可以管理1024个页面,每个页目录表可以管理1024个页表。既然确定了段限长是16 MB,这样就需要4个页目录项(attention:只用了四个页目录项管理4个页表)下辖4个页表,来管理这16 MB的内存

页表设置代码:(内核分页采用恒等映射模式,调用get_free_page( )函数后,获取的线性地址值直接就可以当物理地址来用)

//代码路径:boot/head.s:
…
setup_paging:
      movl $1024*5,%ecx            /* 5 pages - pg_dir + 4 page tables */
      xorl %eax,%eax
      xorl %edi,%edi                       /* pg_dir is at 0x000 */
      cld;rep;stosl
      movl $pg0 + 7,_pg_dir                /* set present bit/user r/w */
      movl $pg1 + 7,_pg_dir + 4    /*  --------- " " --------- */
      movl $pg2 + 7,_pg_dir + 8    /*  --------- " " --------- */
      movl $pg3 + 7,_pg_dir + 12  /*  --------- " " --------- */
      movl $pg3 + 4092,%edi
      movl $0xfff007,%eax          /*  16Mb -4096 + 7 (r/w user,p) */
      std
1: stosl                          /* fill pages backwards - more efficient :-) */
      subl $0x1000,%eax
      jge 1b
…

10.为什么不用call,而是用ret“调用”main函数?画出调用路线图,给出代码证据。

call指令会将EIP的值自动压栈,保护返回现场,然后执行被调函数的程序,等到执行被调函数的ret 指令时,自动出栈给EIP并还原现场,继续执行call的下一条指令。然而对操作系统的main 函数来说,如果用call 调用main函数,那么ret时返回给谁呢?在由head程序向main函数跳转时,是不需要main函数返回的;同时由于main函数已经是最底层的函数了,没有更底层的支撑函数支持其返回。用ret 实现的调用操作当然就不需要返回了,call做的压栈和跳转动作需要手工编写代码,模仿了call的全部动作,实现了调用setup_paging函数。压栈的EIP值不是调用setup_paging函数的下一行指令的地址,而是操作系统的main函数的执行入口地址,这样当setup_paging函数执行到ret时,从栈中将操作系统的main函数的执行入口地址_main自动出栈给EIP,EIP指向main函数的入口地址,实现了用返回指令调用main函数。所以要达到既调用main又不需返回,就不采用call而是选择了ret“调用”了。

仿call示意图

//代码路径:boot\head.s
after_page_tables:
	pushl $0		# These are the parameters to main :-)
	pushl $0
	pushl $0
	pushl $L6		# return address for main, if it decides to.
	pushl $_main
	jmp setup_paging
L6:
	jmp L6			# main should never return here, but
				# just in case, we know what happens.

第二次思考题:

main函数调用关系图:

linux0.11线性地址空间使用分布图:

1、计算内核代码段、数据段的段基址、段限长、特权级。

在 Linux 0.11 中,内核代码段和数据段的段基址实际上是相同的,都是 0x00000000;代码段和数据段的段限长设置为能够覆盖整个物理内存空间;特权级为0特权级

Linux 系统中虚拟地址空间分配图:

2、计算进程0的代码段、数据段的段基址、段限长、特权级。

3、fork进程1之前,为什么先调用move_to_user_mode()?用的是什么方法?解释其中的道理。

Linux规定,除了进程0外,所有进程都要由一个已有的进程在3特权级下创建,进程0此时处于0特权级。按照规定,在创建进程1之前要将进程0转变为3特权级。方法是调用move_to_user_mode()函数,模仿中断返回动作,实现进程0的特权级从内核态转化为用户态。又因为在Linux-0.11中,转换特权级时采用中断和中断返回的方式,调用系统中断实现从3到0的特权级转换,中断返回时转换为3特权级。因此,进程0从0特权级到3特权级转换时采用的是模仿中断返回。设计者首先手工写压栈代码模拟int(中断)压栈,当执行iret指令时,CPU自动将这5个寄存器的值(SS,ESP,EFLAGS,CS,EIP)按序恢复给CPU,CPU就会翻转到3特权级去执行代码。

4、根据什么判定move_to_user_mode()中iret之后的代码为进程0的代码。

#define move_to_user_mode() \
__asm__ ("movl %%esp,%%eax\n\t" \
	"pushl $0x17\n\t" \
	"pushl %%eax\n\t" \
	"pushfl\n\t" \
	"pushl $0x0f\n\t" \
	"pushl $1f\n\t" \
	"iret\n" \
	"1:\tmovl $0x17,%%eax\n\t" \
	"movw %%ax,%%ds\n\t" \
	"movw %%ax,%%es\n\t" \
	"movw %%ax,%%fs\n\t" \
	"movw %%ax,%%gs" \
	:::"ax")

iret 指令已经改变了 CPU 的特权级(0特权级->3特权级)。

iret之后的代码目的是为了设置用户模式下的各种段寄存器(如 DS、ES、FS、GS),这样做是因为当从内核模式切换到用户模式时,必须确保所有的段选择器都正确地设置为用户模式的段选择器。在 Linux 0.11 中,段选择器 0x17 代表用户模式数据段的选择器。

5、进程0的task_struct在哪?具体内容是什么?给出代码证据。

进程0的task_struct位于内核数据区,因为在进程0未激活之前,使用的是boot阶段的user_stack,因此存储在user_stack中。
具体内容:包含了进程 0 的进程状态、进程 0 的 LDT、进程 0 的 TSS 等等。其中 ldt 设置了代码段和堆栈段的基址和限长(640KB),而 TSS 则保存了各种寄存器的值,包括各个段选择符。

代码如下:

//进程0的task_struct的值
/*
 *  INIT_TASK is used to set up the first task table, touch at
 * your own risk!. Base=0, limit=0x9ffff (=640kB)
 */
#define INIT_TASK \
/* state etc */	{ 0,15,15, \
/* signals */	0,{{},},0, \
/* ec,brk... */	0,0,0,0,0,0, \
/* pid etc.. */	0,-1,0,0,0, \
/* uid etc */	0,0,0,0,0,0, \
/* alarm */	0,0,0,0,0,0, \
/* math */	0, \
/* fs info */	-1,0022,NULL,NULL,NULL,0, \
/* filp */	{NULL,}, \
	{ \
		{0,0}, \
/* ldt */	{0x9f,0xc0fa00}, \
		{0x9f,0xc0f200}, \
	}, \
/*tss*/	{0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\
	 0,0,0,0,0,0,0,0, \
	 0,0,0x17,0x17,0x17,0x17,0x17,0x17, \
	 _LDT(0),0x80000000, \
		{} \
	}, \
}

6、在system.h里

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))

#define set_intr_gate(n,addr) \
_set_gate(&idt[n],14,0,addr)

#define set_trap_gate(n,addr) \
_set_gate(&idt[n],15,0,addr)

#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)

读懂代码。这里中断门、陷阱门、系统调用都是通过_set_gate设置的,用的是同一个嵌入汇编代码,比>>较明显的差别是dpl一个是3,另外两个是0,这是为什么?说明理由。

set_trap_gate 和set_intr_gate的dpl是3,set_system_gate的dpl是0。dpl为0表示只能在内核态下允许,dpl为3表示系统调用可以由3特权级调用。
当用户程序产生系统调用软中断后, 系统都通过system_call总入口找到具体的系统调用函数。 set_system_gate设置系统调用,须将 DPL设置为 3,允许在用户特权级(3)的进程调用,否则会引发 General Protection 异常。set_trap_gate 及 set_intr_gate 设置陷阱和中断为内核使用,需禁止用户进程调用,所以 DPL为 0。

7、分析get_free_page()函数的代码,叙述在主内存中获取一个空闲页的技术路线。

通过逆向扫描页表位图 mem_map,找到内存中(从高地址开始)第一个空闲(字节为0)页面,将其置为1。ecx左移12位加LOW_MEM获得该页的物理地址,并将页面清零。最后返回空闲页面物理内存的起始地址。代码如下:

//代码路径:mm\memory.c
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");

__asm__("std ; repne ; scasb\n\t"
	"jne 1f\n\t"
	"movb $1,1(%%edi)\n\t"
	"sall $12,%%ecx\n\t"
	"addl %2,%%ecx\n\t"
	"movl %%ecx,%%edx\n\t"
	"movl $1024,%%ecx\n\t"
	"leal 4092(%%edx),%%edi\n\t"
	"rep ; stosl\n\t"
	"movl %%edx,%%eax\n"
	"1:"
	:"=a" (__res)
	:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
	"D" (mem_map+PAGING_PAGES-1)
	:"di","cx","dx");
return __res;
}

8、copy_process函数的参数最后五项是:long eip,long cs,long eflags,long esp,long ss。查看栈结构确实有这五个参数,奇怪的是其他参数的压栈代码都能找得到,确找不到这五个参数的压栈代码,反汇编代码中也查不到,请解释原因。详细论证其他所有参数是如何传入的。

copy_process执行是因为进程调用了fork函数创建进程,会执行“int 0x80”产生一个软中断,中断使CPU硬件自动将ss,esp,eflags,cs,eip这5个寄存器的值按顺序压入进程0内核栈,又因为函数传递参数是使用栈的,所以刚好可以作为copy_process的最后五个参数。

9、详细分析Linux操作系统如何设置保护模式的中断机制。

①对支持轮询的8253定时器进行设置。这一步操作如图2-20中的第一步所示,其中LATCH最关键。LATCH是通过一个宏定义的,通过它在sched.c中的定义“#define LATCH(1193180/HZ)”,即系统每10毫秒发生一次时钟中断。

②对支持轮询的8253定时器进行设置。这一步操作如图2-20中的第一步所示,其中LATCH最关键。LATCH是通过一个宏定义的,通过它在sched.c中的定义“#define LATCH(1193180/HZ)”,即系统每10毫秒发生一次时钟中断。

③对支持轮询的8253定时器进行设置。这一步操作如图2-20中的第一步所示,其中LATCH最关键。LATCH是通过一个宏定义的,通过它在sched.c中的定义“#define LATCH(1193180/HZ)”,即系统每10毫秒发生一次时钟中断。

10、分析Linux操作系统如何剥夺用户进程访问内核及其他进程的能力。

所有程序的设计都是基于段的。

①进程跨越到内核

用户进程代码段的特权级都是3,内核的特权级是0,Intel IA-32架构禁止代码跨越特权级长跳转,3特权级长跳转到0特权级是禁止的,0特权级长跳转到3特权级同样是禁止的。所以这样的非法长跳转指令会被CPU硬件有效阻拦,进程与内核的边界得到有效的保护。

②当一个进程的代码中有非法的跨进程跳转的指令时,比如,ljmp指令执行时,该指令后面的操作数是“段内偏移段选择子”。代码段的段选择子存储在CS里面。仔细考察一下,可以看出Linux 0.11中所有进程的CS的内容都是一样的,用二进制表示的形式都是0000000000001111。CPU硬件无法识别是哪一个进程的CS,也就无法选择段描述符,只能默认使用当前LDT中提供的段描述符,所以类似ljmp这样的段间跳转指令,无论后面操作数怎么写,都无法跨越当前进程的代码段,也就无法进行段间跳转,最终只能是执行到本段。

11、

_system_call:
 cmpl $nr_system_calls-1,%eax
 ja bad_sys_call

分析后面两行代码的意义。

验证发起的系统调用编号是否在有效范围内,阻止非法的系统调用。

第三次思考题:

1、copy_process函数的参数最后五项是:long eip,long cs,long eflags,long esp,long ss。查看栈结构确实有这五个参数,奇怪的是其他参数的压栈代码都能找得到,确找不到这五个参数的压栈代码,反汇编代码中也查不到,请解释原因。

copy_process执行是因为进程调用了fork函数创建进程,会执行“int 0x80”产生一个软中断,中断使CPU硬件自动将ss,esp,eflags,cs,eip这5个寄存器的值按顺序压入进程0内核栈,又因为函数传递参数是使用栈的,所以刚好可以作为copy_process的最后五个参数。

2、分析get_free_page()函数的代码,叙述在主内存中获取一个空闲页的技术路线。

通过逆向扫描页表位图 mem_map,找到内存中(从高地址开始)第一个空闲(字节为0)页面,将其置为1。ecx左移12位加LOW_MEM获得该页的物理地址,并将页面清零。最后返回空闲页面物理内存的起始地址。代码如下:

//代码路径:mm\memory.c
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");

__asm__("std ; repne ; scasb\n\t"
	"jne 1f\n\t"
	"movb $1,1(%%edi)\n\t"
	"sall $12,%%ecx\n\t"
	"addl %2,%%ecx\n\t"
	"movl %%ecx,%%edx\n\t"
	"movl $1024,%%ecx\n\t"
	"leal 4092(%%edx),%%edi\n\t"
	"rep ; stosl\n\t"
	"movl %%edx,%%eax\n"
	"1:"
	:"=a" (__res)
	:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
	"D" (mem_map+PAGING_PAGES-1)
	:"di","cx","dx");
return __res;
}

3、分析copy_page_tables()函数的代码,叙述父进程如何为子进程复制页表。

进入copy_page_tables函数后,先为新的页表申请一个空闲页面,并把进程0中第一个页表里的前160个页表项复制到这个页面中(1个页表项控制一个页面4KB内存空间,160个页表项能够控制640KB内存空间)。进程0和进程1的页表暂时度指向了相同的页面,意味着进程1也能够操做进程0的页面。以后对进程1的页目录表进行设置。最后,用重置CR3的方法刷新页面变换高速缓存。进程1的页表和页目录表设置完毕。进程1此时是一个空架子,尚未对应的程序,它的页表又是从进程0的页表复制过来的,它们管理的页面彻底一致,也就是它暂时和进程0共享一套页面管理结构。

//代码路径:kernel/fork.c
int copy_mem(int nr,struct task_struct * p)
{
	......
	set_base(p->ldt[1],new_code_base);//设置子进程代码段基址
	set_base(p->ldt[2],new_data_base);//设置子进程数据段基址
	//为进程1创建第一个页表、复制进程0的页表,设置进程1的页目录项
	if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
			free_page_tables(new_data_base,data_limit);
			return -ENOMEM;
		}
		return 0;
}

//代码路径:mm/memory.c
......
#define invalidate()\
__asm__("movl%%eax,%%cr3""a"0))//重置CR3为0
......
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
	unsigned long * from_page_table;
	unsigned long * to_page_table;
	unsigned long this_page;
	unsigned long * from_dir, * to_dir;
	unsigned long nr;
/*0x3fffff是4 MB,是一个页表的管辖范围,二进制是22个1,||的两边必须同为0,所以,from和to后22位必须都为0,即4 MB的整数倍,意思是一个页表对应4 MB连续的线性地址空间必须是从0x000000开始的4 MB的整数倍的线性地址,不能是任意地址开始的4 MB,才符合分页的要求*/
	if ((from&0x3fffff) || (to&0x3fffff))
		panic("copy_page_tables called with wrong alignment");
/*一个页目录项的管理范围是4 MB,一项是4字节,项的地址就是项数×4,也就是项管理的线性地址起始地址的M数,比如:0项的地址是0,管理范围是0~4 MB,1项的地址是4,管理范围是4~8 MB,2项的地址是8,管理范围是8~12MB……>>20就是地址的MB数,&0xffc就是&111111111100b,就是4 MB以下部分清零的地址的MB数,也就是页目录项的地址*/
	from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
	to_dir = (unsigned long *) ((to>>20) & 0xffc);
	size = ((unsigned) (size+0x3fffff)) >> 22;
	for( ; size-->0 ; from_dir++,to_dir++) {
		if (1 & *to_dir)
			panic("copy_page_tables: already exist");
		if (!(1 & *from_dir))
			continue;
		from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
		if (!(to_page_table = (unsigned long *) get_free_page()))
			return -1;	/* Out of memory, see freeing */
		*to_dir = ((unsigned long) to_page_table) | 7;
		nr = (from==0)?0xA0:1024;
		for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
			this_page = *from_page_table;
			if (!(1 & this_page))
				continue;
			this_page &= ~2;
			*to_page_table = this_page;
			if (this_page > LOW_MEM) {
				*from_page_table = this_page;
				this_page -= LOW_MEM;
				this_page >>= 12;
				mem_map[this_page]++;
			}
		}
	}
	invalidate();
	return 0;
}

4、进程0创建进程1时,为进程1建立了task_struct及内核栈,第一个页表,分别位于物理内存两个页。请问,这两个页的位置,究竟占用的是谁的线性地址空间,内核、进程0、进程1、还是没有占用任何线性地址空间?说明理由(可以图示)并给出代码证据。

均占用内核的线性地址空间,原因如下:

通过逆向扫描页表位图,并由第一空页的下标左移 12 位加 LOW_MEM 得到该页的物理地址,位于 16M 内存末端。 代码如下

//代码路径:mm/memory.c
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax");

__asm__("std ; repne ; scasb\n\t"
	"jne 1f\n\t"
	"movb $1,1(%%edi)\n\t"
	"sall $12,%%ecx\n\t"
	"addl %2,%%ecx\n\t"
	"movl %%ecx,%%edx\n\t"
	"movl $1024,%%ecx\n\t"
	"leal 4092(%%edx),%%edi\n\t"
	"rep ; stosl\n\t"
	"movl %%edx,%%eax\n"
	"1:"
	:"=a" (__res)
	:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
	"D" (mem_map+PAGING_PAGES-1)
	:"di","cx","dx");
return __res;
}

进程 0 和进程 1 的 LDT 的 LIMIT 属性将进程 0 和进程 1 的地址空间限定0~640KB, 所以进程 0、 进程 1 均无法访问到这两个页面, 故两页面占用内核的线性地址空间。进程 0 的局部描述符如下:

//代码路径:boot\head.s
.align 2
setup_paging:
	movl $1024*5,%ecx		/* 5 pages - pg_dir+4 page tables */
	xorl %eax,%eax
	xorl %edi,%edi			/* pg_dir is at 0x000 */
	cld;rep;stosl
	movl $pg0+7,_pg_dir		/* set present bit/user r/w */
	movl $pg1+7,_pg_dir+4		/*  --------- " " --------- */
	movl $pg2+7,_pg_dir+8		/*  --------- " " --------- */
	movl $pg3+7,_pg_dir+12		/*  --------- " " --------- */
	movl $pg3+4092,%edi
	movl $0xfff007,%eax		/*  16Mb - 4096 + 7 (r/w user,p) */
	std
1:	stosl			/* fill pages backwards - more efficient :-) */
	subl $0x1000,%eax
	jge 1b
	xorl %eax,%eax		/* pg_dir is at 0x0000 */
	movl %eax,%cr3		/* cr3 - page directory start */
	movl %cr0,%eax
	orl $0x80000000,%eax
	movl %eax,%cr0		/* set paging (PG) bit */
	ret			/* this also flushes prefetch-queue */

上面的代码,指明了内核的线性地址空间为0x000000~Oxffffff(即前16M),且线性地址与物理地址呈现一一对应的关系。为进程1分配的这两个页,在16MB的顶端倒数第一页、第二页,因此占用内核的线性地址空间。
进程0的线性地址空间是内存前640KB,因为进程0的LDT中的limit 属性限制了进程0能够访问的地址空间。进程1拷贝了进程0的页表(160项),而这160个页表项即为内核第一个页表的前160项,指向的是物理内存前640KB,因此无法访问到16MB的顶端倒数的两个页。
进程0创建进程1的时候,先后通过get_free_page函数从物理地址中取出了两个页,但是并没有将这两个页的物理地址填入任何新的页表项中。此时只有内核的页表中包含了与这段物理地址对应的项,也就是说此时只有内核页表中有页表项指向这两个页的首地址,所以这两个页占用了内核线性空间。

5、假设:经过一段时间的运行,操作系统中已经有5个进程在运行,且内核为进程4、进程5分别创建了第一个页表,这两个页表在谁的线性地址空间?用图表示这两个页表在线性地址空间和物理地址空间的映射关系。

这两个页面均占用内核的线性地址空间。既然是内核线性地址空间,则与物理地址空间为一一对应关系。根据每一个进程占用16个页目录表项,则进程4占用从第65~81项的页目录表项。同理,进程5占用第81~96项的页目录表项。因为目前只分配了一个页面(用作进程的第一个页表),则分别只须要使用第一个页目录表项便可。映射关系如图:

6、

#define switch_to(n) {\ struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
 "je 1f\n\t" \
 "movw %%dx,%1\n\t" \
 "xchgl %%ecx,_current\n\t" \
 "ljmp %0\n\t" \
 "cmpl %%ecx,_last_task_used_math\n\t" \
 "jne 1f\n\t" \
 "clts\n" \
 "1:" \
 ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
 "d" (_TSS(n)),"c" ((long) task[n])); \ }

代码中的"ljmp %0\n\t" 很奇怪,按理说jmp指令跳转到得位置应该是一条指令的地址,可是这行代码却跳到了"m"
(*&__tmp.a),这明明是一个数据的地址,更奇怪的,这行代码竟然能正确执行。请论述其中的道理。

ljmp %0\n\t经过任务门机制并未实际使用任务门,将CPU的各个寄存器值保存在进程0的TSS中,将进程1的TSS数据以LDT的代码段、数据段描述符数据恢复给CPU的各个寄存器,实现从0特权级的内核代码切换到3特权级的进程1代码执行。其中tss.eip也天然恢复给了CPU,此时EIP指向的就是fork中的if(__res >= 0)语句。

其中a对应EIP,b对应CS,ljmp此时通过CPU中的电路进行硬件切换,进程由当前进程切换到进程n。CPU将当前寄存器的值保存到当前进程的TSS中,将进程n的TSS数据及LDT的代码段和数据段描述符恢复给CPU的各个寄存器,实现任务切换。

7、进程0开始创建进程1,调用fork(),跟踪代码时我们发现,fork代码执行了两次,第一次,执行fork代码后,跳过init()直接执行了for(;😉 pause(),第二次执行fork代码后,执行了init()。奇怪的是,我们在代码中并没有看到向转向fork的goto语句,也没有看到循环语句,是什么原因导致fork反复执行?请说明理由(可以图示),并给出代码证据。

fork 为 inline 函数,其中调用了 sys_call0,产生 0x80 中断,将 ss, esp, eflags, cs, eip 压栈,其中 eip 为 int 0x80 的下一句的地址。在 copy_process 中,内核将进程 0 的 tss 复制得到进程 1 的 tss,并将进程 1 的 tss.eax 设为 0,而进程 0 中的 eax 为 1。在进程调度时 tss 中的值被恢复至相应寄存器中,包括 eip, eax 等。所以中断返回后,进程 0 和进程 1 均会从 int 0x80 的下一句开始执行,即 fork 执行了两次。

由于 eax 代表返回值,所以进程 0 和进程 1 会得到不同的返回值,在fork返回到进程0后,进程0判断返回值非 0,因此执行代码for(;😉 pause();

在sys_pause函数中,内核设置了进程0的状态为 TASK_INTERRUPTIBLE,并进行进程调度。由于只有进程1处于就绪态,因此调度执行进程1的指令。由于进程1在TSS中设置了eip等寄存器的值,因此从 int 0x80 的下一条指令开始执行,且设定返回 eax 的值作为 fork 的返回值(值为 0),因此进程1执行了 init 的 函数。导致反复执行,主要是利用了两个系统调用 sys_fork 和 sys_pause 对进程状态的设置,以及利用了进程调度机制。
代码如下:

//代码路径:init/main.c
void main(void)	{
	......
	move_to_user_mode();
	if (!fork()) {//fork的返回值为1,if(!1)为假		/* we count on this going ok */
		init();//不会执行这一行
	}
//代码路径:include/unistd.h
int fork(void) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
	: "=a" (__res) \ //__res的值就是eax,是copy_process()的返回值last_pid(1)
	: "0" (__NR_##name)); \
if (__res >= 0) \ //iret后,执行这一行!__res就是eax,值是1
	return (type) __res; \ //返回1!
errno = -__res; \
return -1; \
}
//代码路径:kernel/fork.c
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
		long ebx,long ecx,long edx,
		long fs,long es,long ds,
		long eip,long cs,long eflags,long esp,long ss)
{
	struct task_struct *p;
	int i;
	struct file *f;

	p = (struct task_struct *) get_free_page();
	if (!p)
		return -EAGAIN;
	task[nr] = p;
	*p = *current;	/* NOTE! this doesn't copy the supervisor stack */
	p->state = TASK_UNINTERRUPTIBLE;
	p->pid = last_pid;
	p->father = current->pid;
	p->counter = p->priority;
	p->signal = 0;
	p->alarm = 0;
	p->leader = 0;		/* process leadership doesn't inherit */
	p->utime = p->stime = 0;
	p->cutime = p->cstime = 0;
	p->start_time = jiffies;
	p->tss.back_link = 0;
	p->tss.esp0 = PAGE_SIZE + (long) p;
	p->tss.ss0 = 0x10;
	p->tss.eip = eip;
	p->tss.eflags = eflags;
	p->tss.eax = 0;
	p->tss.ecx = ecx;
	p->tss.edx = edx;
	p->tss.ebx = ebx;
	p->tss.esp = esp;
	p->tss.ebp = ebp;
	p->tss.esi = esi;
	p->tss.edi = edi;
	p->tss.es = es & 0xffff;
	p->tss.cs = cs & 0xffff;
	p->tss.ss = ss & 0xffff;
	p->tss.ds = ds & 0xffff;
	p->tss.fs = fs & 0xffff;
	p->tss.gs = gs & 0xffff;
	p->tss.ldt = _LDT(nr);
	p->tss.trace_bitmap = 0x80000000;
	if (last_task_used_math == current)
		__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
	if (copy_mem(nr,p)) {
		task[nr] = NULL;
		free_page((long) p);
		return -EAGAIN;
	}
	for (i=0; i<NR_OPEN;i++)
		if (f=p->filp[i])
			f->f_count++;
	if (current->pwd)
		current->pwd->i_count++;
	if (current->root)
		current->root->i_count++;
	if (current->executable)
		current->executable->i_count++;
	set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
	set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
	p->state = TASK_RUNNING;	/* do this last, just in case */
	return last_pid;
}

8、详细分析进程调度的全过程。考虑所有可能(signal、alarm除外)

  1. 进程中有就绪进程,且时间片没有用完。

    正常情况下,schedule()函数首先扫描任务数组。通过比较每个就绪(TASK_RUNNING)任务的运行时间递减滴答计数counter 的值来确定当前哪个进程运行的时间最少。哪一个的值大,就表示运行时间还不长,于是就选中该进程,最后调用switch_to()执行实际的进程切换操作

  2. 进程中有就绪进程,但所有就绪进程时间片都用完(c=0)

    如果此时所有处于TASK_RUNNING 状态进程的时间片都已经用完,系统就会根据每个进程的优先权值priority,对系统中所有进程(包括正在睡眠的进程)重新计算每个任务需要运行的时间片值counter。计算的公式是:

    counter = counter + priority/2

    然后 schdeule()函数重新扫描任务数组中所有处于TASK_RUNNING 状态,重复上述过程,直到选择出一个进程为止。最后调用switch_to()执行实际的进程切换操作。

  3. 所有进程都不是就绪的c=-1

    此时代码中的c=-1,next=0,跳出循环后,执行switch_to(0),切换到进程0执行,因此所有进程都不是就绪的时候进程0执行。

9、分析panic函数的源代码,根据你学过的操作系统知识,完整、准确的判断panic函数所起的作用。假如操作系统设计为支持内核进程(始终运行在0特权级的进程),你将如何改进panic函数?

panic()函数是当系统发现无法继续运行下去的故障时将调用它,会导致程序终止,然后由系统显示错误号。如果出现错误的函数不是进程0,那么就要进行数据同步,把缓冲区中的数据尽量同步到硬盘上。遵循了Linux尽量简明的原则。
改进panic函数:将死循环for(;😉;改进为跳转到内核进程(始终运行在0特权级的进程),让内核继续执行。

//代码路径:kernel/panic.c
#include <linux/kernel.h>
#include <linux/sched.h>
void sys_sync(void);	/* it's really int */
volatile void panic(const char * s)
{
	printk("Kernel panic: %s\n\r",s);
	if (current == task[0])
		printk("In swapper task - not syncing\n\r");
	else
		sys_sync();
	**for(;;);**
}
下面是网上对杨力祥老师的评价: 有感于杨力祥老师和中科院的牛人们,进而有感于中科院和北京的学生 (2007-03-23 09:47:45) 转载▼ 还是再说一个高级Windows程序设计的杨力祥老师,周三上完课后还在回味呢,在他滔滔不绝,口若悬河的精彩讲演中深知计算机这东东的快乐是在巨大的痛若经历和深厚功底下的,当然了激情是支撑这一路下去的唯一东东。对编程的感觉就像艺术般地发挥。像那个五分钟搞汇编把软件驱修好的牛人,与IBM的角力,小编程的演示,无不透的深厚内涵 ,不禁想到自已是否可以几年以后如此这般萧洒。(总不明白为何身边有那夸夸其谈不干实事的人,不可能是物以类聚吧?呵自认是绝对勤快的人啊)。 再说中科院和北京的大学生,有他们是有那么好的机会和资源啊,不在于有多少钱有多好的硬件设施,他们可以看到的和听到的是我们所无法比的。可以请王永明,可以听INTEL的高手,见到不同的牛人,于此不会也是不可能的。呵如果这些都搬到广州就好了。为什么偏是在北京呢?如果哪天哪个领导人英明一下,大手一挥,北京只搞政治,不搞经济,别的都给我投去,呵,这下可好了,牛人们都往南方走了。 刚才上传了CSDN把我的资源删了,不知道怎么回事。。可能是后缀名的问题 重新弄了一下。下载后把后缀名改成downlist就可以用迅雷打开了。 解压密码:abab123.gfugifyr7t565
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

北纬40度~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值