[自制操作系统] 第18回 实现用户进程(上)
目录
一、前景回顾
二、任务切换相关
三、实现TSS
四、运行测试
在上一回我们已经实现了键盘的驱动编写和环形缓冲区的实现,现在让我们来想这么一个问题:
一直以来我们的程序都在最高特权级0下工作,这意味着任何程序都和操作系统平起平坐,可以改动任何资源。如果不改变这种现状的话,某个不听话的程序甚至可以给操作系统致命一击,取而代之,那么后果将不堪设想。所以从本回开始,我们便要开始着手实现用户进程,让我们的操作系统看起来更安全一点。
下面的是我自己的一些见解。
如果让我来设计任务切换,比较简单的一种思路便是:
首先我们常说的任务,就是一个程序而已,程序在内存中被分为代码段和数据段。所以我们表征多个任务,那么便是多个代码段和数据段而已。至于任务的切换,可能需要费点心思在软件层面上实现多任务调度机制。
然后现在问题出现了:
我们知道代码段和数据段需要在全局描述符表GDT中存储,一个任务需要两个描述符来存储,而我们知道全局描述符表GDT最多也就只有2^13=8192个段描述符,那么理论上也就只能容纳4096个任务,除此之外在软件层面上实现的多任务调度机制有点类似今天的用户态多线程,效率不高且安全性有诸多问题。
所以我们来看看硬件厂商和CPU厂商是如何解决任务切换的问题的,其中最主要的就是LDT和TSS。
首先是LDT。
LDT是局部描述符表,用来存储每个任务自己的私有实体资源,也就是代码和数据。LDT的地址被保存在一个段描述符中,那么理论上我们现在可以支持8192个任务了。对于当前运行的任务,其LDT的地址被存储在LDTR寄存器中,这样CPU就能根据这个地址从中拿到任务所需要的资源。每切换一个任务时,需要用lldt指令重新加载新任务的LDT地址到LDTR寄存器中。
随后便是TSS。
单核CPU要想实现多任务,唯一的方法便是多任务共享一个CPU,也就是让多个任务轮流使用CPU。前面说道,LDT是每个任务的私有资源,所以不用担心多任务时,程序的运行资源会混乱。但这还不够。
CPU执行任务时,需要把任务所需要的数据加载到寄存器、栈和内存中,因为CPU只能直接处理这些资源中的数据,这是CPU在设计之初时工程师们决定的。于是,问题来了,任务的数据和指令是CPU的处理对象,他们被存放在内存这个低速的容器中,对于CPU来讲,内存的速度太慢了,它最喜欢寄存器。因此内存中的数据往往被加载到高速的寄存器中后再处理,等处理完毕后再将结果写入到内存中,所以,任何时候,寄存器中的内容才是任务的最新状态。当任务被换下CPU后,任务的最新状态应该被保存在某个地方,以便下次重新将此任务调度到CPU时可以恢复此任务的最新状态,这样任务才能继续执行。
于是TSS就出现了,TSS是程序员为任务单独定义的一个结构体变量,当加载新任务时,CPU自动把当前任务(旧任务)的状态存入当前任务的TSS,然后将新任务TSS中的数据载入到对应的寄存器中。
TSS和其他段也是一样的,本质上是一片存储数据的内存区域,也需要某个描述符结构来描述它,这就是TSS描述符。
和LDT一样,CPU对TSS的处理也采用了类似的方法,提供一个名为TR的寄存器来存放当前任务的TSS位置。
总结一下,如图所示:
CPU原生支持的任务切换方式是针对每一个任务都有一个LDT和一个TSS结构,这种任务切换方式,在任务切换时效率比较低,所以现代操作系统并未采纳。现代操作系统放弃了LDT,只采用了TSS,但是也没有完全采纳。我们是效仿Linux的任务切换方式的,所以拿Linux为例。
Linux为每一个CPU创建一个TSS,在各个CPU上的所有任务共享一个TSS,各CPU的TR寄存器保存各CPU上的TSS,也就是说在用ltr指令加载TSS后,该TR寄存器永远指向同一个TSS,之后再也不会切换了。在进程切换时只需要把TSS中的SS0和esp0更新为新任务的内核栈的段地址和栈指针。
那么任务的状态信息保存在哪里呢?
对于Linux来讲,Linux只在TSS中初始化esp0和SS0以及IO位图。当CPU从低特权级进入高特权级时,也就是3特权级的用户态到0特权级的内核态时(Linux只有两个特权级)CPU会自动从TSS中获取到0特权级的栈指针,然后Linux手动执行一系列的push指令将任务的状态保存在0特权级的栈中。这个地方先留一下悬念,等后面实现的时候会再次提到。
虽然我们不完全采纳TSS,但是因为TSS是硬件所要求的,所以我们必须构造一个TSS来应付硬件。在project/userprog目录下新建tss.c和tss.h文件,除此之外还需要在global.h文件中新加部分代码。
1 #include "global.h"
2 #include "thread.h"
3 #include "print.h"
4 #include "string.h"
5 #include "tss.h"
6
7 struct tss {
8 uint32_t backlink;
9 uint32_t *esp0;
10 uint32_t ss0;
11 uint32_t *esp1;
12 uint32_t ss1;
13 uint32_t *esp2;
14 uint32_t ss2;
15 uint32_t cr3;
16 uint32_t (*eip) (void);
17 uint32_t eflags;
18 uint32_t eax;
19 uint32_t ecx;
20 uint32_t edx;
21 uint32_t ebx;
22 uint32_t esp;
23 uint32_t ebp;
24 uint32_t esi;
25 uint32_t edi;
26 uint32_t es;
27 uint32_t cs;
28 uint32_t ss;
29 uint32_t ds;
30 uint32_t fs;
31 uint32_t gs;
32 uint32_t ldt;
33 uint32_t trace;
34 uint32_t io_base;
35 };
36
37 static struct tss tss;
38
39 /*更新tss中的esp0字段的值为pthread的0级栈*/
40 void update_tss_esp(struct task_struct *pthread)
41 {
42 tss.esp0 = (uint32_t *)((uint32_t)pthread + PG_SIZE);
43 }
44
45 /*创建gdt描述符*/
46 static struct gdt_desc make_gdt_desc(uint32_t *desc_addr, uint32_t limit, uint8_t attr_low, uint8_t attr_high)
47 {
48 uint32_t desc_base = (uint32_t)desc_addr;
49 struct gdt_desc desc;
50 desc.limit_low_word = limit & 0x0000ffff;
51 desc.base_low_word = desc_base & 0x0000ffff;
52 desc.base_mid_byte = ((desc_base & 0x00ff0000) >> 16);
53 desc.base_high_byte = (desc_base >> 24);
54 desc.attr_low_byte = (uint8_t)attr_low;
55 desc.limit_high_attr_high = (((limit & 0x000f0000) >> 16) + (uint8_t)attr_high);
56 return desc;
57 }
58
59 /*在gdt中创建tss并重新加载gdt*/
60 void tss_init(void)
61 {
62 put_str("tss_init start \n");
63 uint32_t tss_size = sizeof(tss);
64 memset(&tss, 0, tss_size);
65 tss.ss0 = SELECTOR_K_STACK;
66 tss.io_base = tss_size;
67 /*gdt的基地址为0x900,把tss放到第4个地址,也就是0x900+0x20的位置*/
68 *((struct gdt_desc *)0xc0000920) = make_gdt_desc((uint32_t *)&tss, tss_size - 1, TSS_ATTR_LOW, TSS_ATTR_HIGH);
69 /*为用户进程提前作准备*/
70 /*在gdt中添加dpl为3的数据段和代码段描述符*/
71 *((struct gdt_desc *)0xc0000928) = make_gdt_desc((uint32_t *)0, 0xfffff, GDT_CODE_ATTR_LOW_DPL3, GDT_ATTR_HIGH);
72 *((struct gdt_desc *)0xc0000930) = make_gdt_desc((uint32_t *)0, 0xfffff, GDT_DATA_ATTR_LOW_DPL3, GDT_ATTR_HIGH);
73 //while(1);
74 /*gdt 16位的limit 32位的段基址*/
75 uint64_t gdt_operand = ((8 * 7 - 1) | ((uint64_t)(uint32_t)0xc0000900 << 16));
76 asm volatile ("lgdt %0" : : "m" (gdt_operand));
77 asm volatile ("ltr %w0" : : "r" (SELECTOR_TSS));
78
79 put_str("tss_init and ltr done\n");
80 }
1 #ifndef __USERPROG_TSS_H
2 #define __USERPROG_TSS_H
3 #include "stdint.h"
4
5 void tss_init(void);
6 static struct gdt_desc make_gdt_desc(uint32_t *desc_addr, uint32_t limit, uint8_t attr_low, uint8_t attr_high);
7 void update_tss_esp(struct task_struct *pthread);
8 #endif
1 ...
2
3 /******************** TSS描述符属性**********************/
4 #define TSS_DESC_D 0
5 #define TSS_ATTR_HIGH ((DESC_G_4K << 7) + (TSS_DESC_D << 6) + (DESC_L << 5) + (DESC_AVL << 4) + 0x0)
6 #define TSS_ATTR_LOW ((DESC_P << 7) + (DESC_DPL_0 << 5) + (DESC_S_SYS << 4) + DESC_TYPE_TSS)
7
8 #define SELECTOR_TSS ((4 << 3) + (TI_GDT << 2) + RPL0)
9
10 ...
注释写的比较清楚,我们挑重点来讲。注意这个函数:
1 /*更新tss中的esp0字段的值为pthread的0级栈*/ 2 void update_tss_esp(struct task_struct *pthread) 3 { 4 tss.esp0 = (uint32_t *)((uint32_t)pthread + PG_SIZE); 5 }
这个函数的作用就是用来更新TSS中的esp0。我们前面在实现线程的时候,线程的PCB上有一块名为中断栈的区域一直没有被使用,现在就被用上了。忘记的话点这里。其实它就是这里所说的0级栈,用户进程从3特权级进入0特权级时,CPU会自动从TSS中获取到0特权级的栈指针,也就是0级栈。
最后还需要修改一下mbr.S和loader.S文件,为什么呢?原来在loader.S文件中,我们在开头通过jmp loader_start跳转到后面执行loader部分, 在这行代码后面实现了GDT表的建立,而jmp loader_start这行代码是需要占据3个字节的内容,这样就导致GDT表位于内存0x903地址处,不利于后面的对齐,所以我们为了让GDT表位于0x900处,需要移除jmp loader_start这行代码,但是我们知道这行代码是mbr跳转执行到的,为了让mbr直接跳转到loader部分,我们需要修改mbr.S中的最后跳转语句,修改为jmp LOADER_BASE_ADDR + 0x206 这个0x206怎么来的呢,GDT表总共有64个描述符,再加上gdt指针占用6个字节,总共便是64*8+6=518个字节,也就是0x206。这里就不多啰嗦了,直接将修改好的mbr.S和loader.S附上。
1 %include "boot.inc"
2 section MBR vstart=0x7c00
3 mov ax, cs
4 mov ds, ax
5 mov es, ax
6 mov ss, ax
7 mov fs, ax
8 mov sp, 0x7c00
9 mov ax, 0xb800
10 mov gs, ax
11
12 ;利用int 0x10 的0x06号功能实现清屏
13 mov ax, 0x600
14 mov bx, 0x700
15 mov cx, 0
16 mov dx, 0x184f
17
18 int 0x10
19
20 mov ah, 3
21 mov bh, 0
22
23 int 0x10
24 ;输出字符串“HELLO MBR” A表示绿色背景闪烁,4表示前景色为红色
25 mov byte [gs:0x00],'H'
26 mov byte [gs:0x01],0xA4
27
28 mov byte [gs:0x02],'E'
29 mov byte [gs:0x03],0xA4
30
31 mov byte [gs:0x04],'L'
32 mov byte [gs:0x05],0xA4
33
34 mov byte [gs:0x06],'L'
35 mov byte [gs:0x07],0xA4
36
37 mov byte [gs:0x08],'O'
38 mov byte [gs:0x09],0xA4
39
40 mov byte [gs:0x0A],' '
41 mov byte [gs:0x0B],0xA4
42
43 mov byte [gs:0x0C],'M'
44 mov byte [gs:0x0D],0xA4
45
46 mov byte [gs:0x0E],'B'
47 mov byte [gs:0x0F],0xA4
48
49 mov byte [gs:0x10],'R'
50 mov byte [gs:0x11],0xA4
51
52 mov eax, LOADER_START_SECTOR ;起始扇区lba的地址
53 mov bx, LOADER_BASE_ADDR ;loader将要被写入的内存地址
54 mov cx, 4 ;待读入的扇区数
55 call rd_disk_m_16 ;调用函数,将loader写入到内存中
56
57 jmp LOADER_BASE_ADDR + 0x206
58
59 ;---------------------------------------
60 ;功能:读取硬盘n个扇区
61 rd_disk_m_16:
62 mov esi, eax ;备份eax,eax中存放了扇区号,这里为0x2
63 mov di, cx ;备份cx,cx中存放待读入的扇区数
64
65 ;读写硬盘:
66 ;第一步:设置要读取的扇区数
67 mov dx, 0x1f2
68 mov al, cl
69 out dx, al
70
71 mov eax, esi
72
73 ;第二步:将lba地址存入到0x1f3 ~ 0x1f6
74 ;lba地址7-0位写入端口0x1f3
75 mov dx, 0x1f3
76 out dx, al
77
78 ;lba地址15-8位写入端口0x1f4
79 mov cl, 8
80 shr eax, cl
81 mov dx, 0x1f4
82 out dx, al
83
84 ;lba地址23-16位写入端口0x1f5
85 shr eax, cl
86 mov dx, 0x1f5
87 out dx, al
88
89 shr eax, cl
90 and al, 0x0f
91 or al, 0xe0
92 mov dx, 0x1f6
93 out dx, al
94
95 ;第三步:向0x1f7端口写入读命令,0x20
96 mov dx, 0x1f7
97 mov al, 0x20
98 out dx, al
99
100 ;第四步:检测硬盘状态
101 .not_ready:
102 nop
103 in al, dx
104 and al, 0x88
105 cmp al, 0x08
106 jnz .not_ready
107
108 ;第五步:从0x1f0端口读数据
109 mov ax, di
110 mov dx, 256
111 mul dx
112 mov cx, ax
113 ;di为要读取的扇区数,一个扇区共有512字节,每次读入一个字,总共需要
114 ;di*512/2次,所以di*256
115 mov dx, 0x1f0
116 .go_on_read:
117 in ax, dx
118 mov [bx], ax
119 add bx,2
120 loop .go_on_read
121 ret
122 ;---------------------------------------
123
124 times 510-($-$$) db 0
125 db 0x55, 0xaa
1 %include "boot.inc"
2 section loader vstart=LOADER_BASE_ADDR
3 LOADER_STACK_TOP equ LOADER_BASE_ADDR
4 ;构建gdt及其内部描述符
5 GDT_BASE: dd 0x00000000
6 dd 0x00000000
7 CODE_DESC: dd 0x0000FFFF
8 dd DESC_CODE_HIGH4
9 DATA_STACK_DESC: dd 0x0000FFFF
10 dd DESC_DATA_HIGH4
11 VIDEO_DESC: dd 0x80000007
12 dd DESC_VIDEO_HIGH4
13
14 GDT_SIZE equ $-GDT_BASE
15 GDT_LIMIT equ GDT_SIZE-1
16 times 60 dq 0 ;此处预留60个描述符的空位
17
18 SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
19 SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
20 SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
21
22 ;以下是gdt指针,前2个字节是gdt界限,后4个字节是gdt的起始地址
23 gdt_ptr dw GDT_LIMIT
24 dd GDT_BASE
25
26 ;---------------------进入保护模式------------
27 loader_start:
28 ;一、打开A20地址线
29 in al, 0x92
30 or al, 0000_0010B
31 out 0x92, al
32
33 ;二、加载GDT
34 lgdt [gdt_ptr]
35
36 ;三、cr0第0位(pe)置1
37 mov eax, cr0
38 or eax, 0x00000001
39 mov cr0, eax
40
41 jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
42
43 [bits 32]
44 p_mode_start:
45 mov ax, SELECTOR_DATA
46 mov ds, ax
47 mov es, ax
48 mov ss, ax
49 mov esp, LOADER_STACK_TOP
50 mov ax, SELECTOR_VIDEO
51 mov gs, ax
52
53 mov byte [gs:160], 'p'
54 ;---------------------------------------
55
56 ;------------------开启分页机制-----------------
57 ;一、创建页目录表并初始化页内存位图
58 call setup_page
59
60 ;将描述符表地址及偏移量写入内存gdt_ptr,一会儿用新地址重新加载
61 sgdt [gdt_ptr]
62 ;将gdt描述符中视频段描述符中的段基址+0xc0000000
63 mov ebx, [gdt_ptr + 2]
64 or dword [ebx + 0x18 + 4], 0xc0000000
65
66 ;将gdt的基址加上0xc0000000使其成为内核所在的高地址
67 add dword [gdt_ptr + 2], 0xc0000000
68
69 add esp, 0xc0000000 ;将栈指针同样映射到内核地址
70
71 ;二、将页目录表地址赋值给cr3
72 mov eax, PAGE_DIR_TABLE_POS
73 mov cr3, eax
74
75 ;三、打开cr0的pg位
76 mov eax, cr0
77 or eax, 0x80000000
78 mov cr0, eax
79
80 ;在开启分页后,用gdt新的地址重新加载
81 lgdt [gdt_ptr]
82 mov byte [gs:160], 'H'
83 mov byte [gs:162], 'E'
84 mov byte [gs:164], 'L'
85 mov byte [gs:166], 'L'
86 mov byte [gs:168], 'O'
87 mov byte [gs:170], ' '
88 mov byte [gs:172], 'P'
89 mov byte [gs:174], 'A'
90 mov byte [gs:176], 'G'
91 mov byte [gs:178], 'E'
92
93 ;---------------------------------------------
94
95 ;--------------------拷贝内核文件并进入kernel--------------------------
96 mov eax, KERNEL_START_SECTOR ;kernel.bin所在的扇区号 0x09
97 mov ebx, KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到ebx指定的地址0x70000
98 mov ecx, 200 ;读入的扇区数
99
100 call rd_disk_m_32
101
102 ;由于一直处在32位下,原则上不需要强制刷新,但是以防万一还是加上
103 ;跳转到kernel处
104 jmp SELECTOR_CODE:enter_kernel
105
106 enter_kernel:
107 call kernel_init
108 mov esp, 0xc009f000 ;更新栈底指针
109 jmp KERNEL_ENTRY_POINT ;内核地址0xc0001500
110 ;jmp $
111 ;---------------------将kernel.bin中的segment拷贝到指定的地址
112 kernel_init:
113 xor eax, eax
114 xor ebx, ebx ;ebx记录程序头表地址
115 xor ecx, ecx ;cx记录程序头表中的program header数量
116 xor edx, edx ;dx记录program header 尺寸,即e_phentsize
117
118 ;偏移文件42字节处的属性是e_phentsize, 表示program header大小
119 mov dx, [KERNEL_BIN_BASE_ADDR + 42]
120
121 ;偏移文件28字节处的属性是e_phoff
122 mov ebx, [KERNEL_BIN_BASE_ADDR + 28]
123
124 add ebx, KERNEL_BIN_BASE_ADDR
125 mov cx, [KERNEL_BIN_BASE_ADDR + 44]
126
127 .each_segment:
128 cmp byte [ebx + 0], PT_NULL
129 je .PTNULL
130
131 ;为函数memcpy压入参数,参数是从右往左压入
132 push dword [ebx + 16]
133 mov eax, [ebx + 4]
134 add eax, KERNEL_BIN_BASE_ADDR
135 push eax
136 push dword [ebx + 8]
137 call mem_cpy
138 add esp, 12
139
140 .PTNULL:
141 add ebx, edx
142 loop .each_segment
143 ret
144
145 ;-----------逐字节拷贝mem_cpy(dst, src, size)
146 mem_cpy:
147 cld
148 push ebp
149 mov ebp, esp
150 push ecx
151 mov edi, [ebp + 8]
152 mov esi, [ebp + 12]
153 mov ecx, [ebp + 16]
154 rep movsb
155
156 pop ecx
157 pop ebp
158 ret
159 ;---------------------------------------------------
160
161
162
163
164 ;--------------函数声明------------------------
165 ;setup_page:(功能)设置分页------------
166 setup_page:
167 ;先把页目录占用的空间逐字节清0
168 mov ecx, 4096
169 mov esi, 0
170 .clear_page_dir:
171 mov byte [PAGE_DIR_TABLE_POS + esi], 0
172 inc esi
173 loop .clear_page_dir
174
175 ;开始创建页目录项
176 .create_pde:
177 mov eax, PAGE_DIR_TABLE_POS
178 add eax, 0x1000 ;此时eax为第一个页表的位置
179 mov ebx, eax
180
181 ;下面将页目录项0和0xc00都存为第一个页表的地址,每个页表表示4MB内存
182 ;页目录表的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问
183 or eax, PG_US_U | PG_RW_W | PG_P
184
185 ;在页目录表中的第1个目录项中写入第一个页表的地址(0x101000)和属性
186 mov [PAGE_DIR_TABLE_POS + 0x0], eax
187
188 mov [PAGE_DIR_TABLE_POS + 0xc00], eax
189
190 ;使最后一个目录项指向页目录表自己的地址
191 sub eax, 0x1000
192 mov [PAGE_DIR_TABLE_POS + 4092], eax
193
194 ;下面创建页表项(PTE)
195 mov ecx, 256 ;1M低端内存/每页大小4K=256
196 mov esi, 0
197 mov edx, PG_US_U | PG_RW_W | PG_P
198 .create_pte: ;创建page table entry
199 mov [ebx + esi*4], edx
200 add edx, 4096
201 inc esi
202 loop .create_pte
203
204 ;创建内核其他页表的PDE
205 mov eax, PAGE_DIR_TABLE_POS
206 add eax, 0x2000 ;此时eax为第二个页表的位置
207 or eax, PG_US_U | PG_RW_W | PG_P
208 mov ebx, PAGE_DIR_TABLE_POS
209 mov ecx, 254 ;范围为第769~1022的所有目录项数量
210 mov esi, 769
211 .create_kernel_pde:
212 mov [ebx + esi*4], eax
213 inc esi
214 add eax, 0x1000
215 loop .create_kernel_pde
216 ret
217
218
219 ;rd_disk_m_32:(功能)读取硬盘n个扇区------------
220 rd_disk_m_32:
221 mov esi,eax ;备份eax,eax中存放了扇区号
222 mov di,cx ;备份cx,cx中存放待读入的扇区数
223
224 ;读写硬盘:
225 ;第一步:设置要读取的扇区数
226 mov dx,0x1f2
227 mov al,cl
228 out dx,al
229
230 mov eax,esi
231
232 ;第二步:将lba地址存入到0x1f3 ~ 0x1f6
233 ;lba地址7-0位写入端口0x1f3
234 mov dx,0x1f3
235 out dx,al
236
237 ;lba地址15-8位写入端口0x1f4
238 mov cl,8
239 shr eax,cl
240 mov dx,0x1f4
241 out dx,al
242
243 ;lba地址23-16位写入端口0x1f5
244 shr eax,cl
245 mov dx,0x1f5
246 out dx,al
247
248 shr eax,cl
249 and al,0x0f
250 or al,0xe0
251 mov dx,0x1f6
252 out dx,al
253
254 ;第三步:向0x1f7端口写入读命令,0x20
255 mov dx,0x1f7
256 mov al,0x20
257 out dx,al
258
259 ;第四步:检测硬盘状态
260 .not_ready:
261 nop
262 in al,dx
263 and al,0x88
264 cmp al,0x08
265 jnz .not_ready
266
267 ;第五步:从0x1f0端口读数据
268 mov ax,di
269 mov dx,256
270 mul dx
271 mov cx,ax
272 ;di为要读取的扇区数,一个扇区共有512字节,每次读入一个字,总共需要
273 ;di*512/2次,所以di*256
274 mov dx,0x1f0
275 .go_on_read:
276 in ax,dx
277 mov [ebx],ax
278 add ebx,2
279 loop .go_on_read
280 ret
281 ;----------------------------------------------
运行测试后,tss成功初始化。
在bochs控制台输入info gdt可以看到GDT表的内容,可以看到现在有7个描述符,在GDT中第4个描述符是刚安装好的TSS段描述符,其显示为32-Bit TSS(Busy),说明TSS的B位被CPU置1了,TSS已经生效了。
本回到此结束,预知后事如何,请看下回分解。