文章链接:https://www.cnblogs.com/cyx-b/p/11809742.html
作者:chuyaoxin
一、实验内容
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。
提示:需要阅读小节“保护模式和分段机制”和lab1/boot/bootasm.S源码,了解如何从实模式切换到保护模式,需要了解:
- 为何开启A20,以及如何开启A20
- 如何初始化GDT表
- 如何使能和进入保护模式
二、实验相关
(1)汇编
没有学过汇编的我刚看到源码时,有点懵逼,于是,我首先查了不少关于汇编的小资料。
Ucore中用到的是AT&T格式的汇编
在 AT&T 汇编格式中
%
寄存器名要加上 ‘%‘ 作为前缀;
$
用 ‘$‘ 前缀表示一个立即操作数;
.set symbol, expression
将symbol的值设为expression
cli
屏蔽系统中断
.code16
由于代码段在实模式下运行,所以要告诉编译器使用16位的模式编译
标号:
在x86汇编代码中,标号有唯一的名字加冒号组成。它可以出现在汇编程序的任何地方,并与紧跟其后的哪行代码具有相同的地址。
概括的说 ,当程序中要跳转到另一位置时,需要有一个标识来指示新的位置,这就是标号,通过在目标地址的前面放上一个标号,可以在指令中使用标号来代替直接使用地址。
其他
目标操作数在源操作数的右边;
操作数的字长由操作符的最后一个字母决定,后缀‘b‘、‘w‘、‘l‘分别表示操作数为字节(byte,8 比特)、字(word,16 比特)和长字(long,32比特);
(2)bootloader的作用
1、关闭中断
2、A20 使能
3、全局描述符表初始化
4、保护模式启动
5、设置段寄存器(长跳转更新CS,根据设置好的段选择子更新其他段寄存器)
6、设置堆栈,esp 0x700 ebp 0
7、进入bootmain后读取内核映像到内存,检查是否合法,并启动操作系统,控制权交给它
(3) 实模式
CPU复位(reset)或加电(power on)的时候以实模式启动,处理器以实模式工作。在实模式下,内存寻址方式和8086相同,由16位段寄存器的内容乘以16(10H)当做段基地址,加上16位偏移地址形成20位的物理地址,最大寻址空间1MB,最大分段64KB。可以使用32位指令。32位的x86 CPU用做高速的8086。在实模式下,所有的段都是可以读、写和可执行的。
实模式将整个物理内存看成分段的区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址。这样,用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的。通过修改A20地址线可以完成从实模式到保护模式的转换。
(4)保护模式
实模式下,程序地址为真实的物理地址,可以访问任意地址空间,这样不同进程可能访问到其它进程程序,造成严重错误。而保护模式下,程序地址为虚拟地址,然后由OS系统管理内存访问权限,这样每个进程只能访问分配给自己的物理
内存空间,保证了程序的安全性。例如Linux系统地址访问采用分页机制,在加载程序时,由OS分配的进程可以访问的物理页空间,并设置了页目录项和页表项,才能保证程序正常运行。这样程序运行时地址间接地由OS进行管理,防止进程之间互相影响,全部由OS稳定性保证。
(5)CR0
CR0是控制寄存器,其中包含了6个预定义标志,0位是保护允许位PE(Protedted Enable),用于启动保护模式。如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
关于CR0及其他控制寄存器的详细内容可以参考以下链接:https://blog.csdn.net/wyt4455/article/details/8691500
三、实验步骤
(一)代码分析
1、bootasm.S的代码
#include <asm.h> # Start the CPU: switch to 32-bit protected mode, jump into C. # The BIOS loads this code from the first sector of the hard disk into # memory at physical address 0x7c00 and starts executing in real mode # with %cs=0 %ip=7c00. .set PROT_MODE_CSEG, 0x8 # kernel code segment selector .set PROT_MODE_DSEG, 0x10 # kernel data segment selector .set CR0_PE_ON, 0x1 # protected mode enable flag # start address should be 0:7c00, in real mode, the beginning address of the running bootloader .globl start start: .code16 # Assemble for 16-bit mode cli # Disable interrupts cld # String operations increment # Set up the important data segment registers (DS, ES, SS). xorw %ax, %ax # Segment number zero movw %ax, %ds # -> Data Segment movw %ax, %es # -> Extra Segment movw %ax, %ss # -> Stack Segment # Enable A20: # For backwards compatibility with the earliest PCs, physical # address line 20 is tied low, so that addresses higher than # 1MB wrap around to zero by default. This code undoes this. seta20.1: inb $0x64, %al # Wait for not busy(8042 input buffer empty). testb $0x2, %al jnz seta20.1 movb $0xd1, %al # 0xd1 -> port 0x64 outb %al, $0x64 # 0xd1 means: write data to 8042‘s P2 port seta20.2: inb $0x64, %al # Wait for not busy(8042 input buffer empty). testb $0x2, %al jnz seta20.2 movb $0xdf, %al # 0xdf -> port 0x60 outb %al, $0x60 # 0xdf = 11011111, means set P2‘s A20 bit(the 1 bit) to 1 # Switch from real to protected mode, using a bootstrap GDT # and segment translation that makes virtual addresses # identical to physical addresses, so that the # effective memory map does not change during the switch. lgdt gdtdesc movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0 # Jump to next instruction, but in 32-bit code segment. # Switches processor into 32-bit mode. ljmp $PROT_MODE_CSEG, $protcseg .code32 # Assemble for 32-bit mode protcseg: # Set up the protected-mode data segment registers movw $PROT_MODE_DSEG, %ax # Our data segment selector movw %ax, %ds # -> DS: Data Segment movw %ax, %es # -> ES: Extra Segment movw %ax, %fs # -> FS movw %ax, %gs # -> GS movw %ax, %ss # -> SS: Stack Segment # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00) movl $0x0, %ebp movl $start, %esp call bootmain # If bootmain returns (it shouldn‘t), loop. spin: jmp spin # Bootstrap GDT .p2align 2 # force 4 byte alignment gdt: SEG_NULLASM # null seg SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel gdtdesc: .word 0x17 # sizeof(gdt) - 1 .long gdt # address gdt
#注释:#include <asm.h> asm.h头文件中包含了一些宏定义,用于定义gdt,gdt是保护模式使用的全局段描述符表,其中存储着段描述符。 3 # Start the CPU: switch to 32-bit protected mode, jump into C. # The BIOS loads this code from the first sector of the hard disk into # memory at physical address 0x7c00 and starts executing in real mode # with %cs=0 %ip=7c00. 此段注释说明了要完成的目的:启动保护模式,转入C函数。 这里正好说了一下bootasm.S文件的作用。计算机加电后,由BIOS将bootasm.S生成的可执行代码从硬盘的第一个扇区复制到内存中的物理地址0x7c00处,并开始执行。 此时系统处于实模式。可用内存不多于1M。 .set PROT_MODE_CSEG, 0x8 # kernel code segment selector .set PROT_MODE_DSEG, 0x10 # kernel data segment selector 这两个段选择子的作用其实是提供了gdt中代码段和数据段的索引 .set CR0_PE_ON, 0x1 # protected mode enable flag 这个变量是开启A20地址线的标志,为1是开启保护模式 # start address should be 0:7c00, in real mode, the beginning address of the running bootloader .globl start start: 这两行代码相当于定义了C语言中的main函数,start就相当于main,BIOS调用程序时,从这里开始执行 .code16 # Assemble for 16-bit mode 因为以下代码是在实模式下执行,所以要告诉编译器使用16位模式编译。 cli # Disable interrupts cld # String operations increment 关中断,设置字符串操作是递增方向。cld的作用是将direct flag标志位清零,这意味着自动增加源索引和目标索引的指令(如MOVS)将同时增加它们。 # Set up the important data segment registers (DS, ES, SS). xorw %ax, %ax # Segment number zero ax寄存器就是eax寄存器的低十六位,使用xorw清零ax,效果相当于movw $0, %ax。 但是好像xorw性能好一些,google了一下没有得到好答案 movw %ax, %ds # -> Data Segment movw %ax, %es # -> Extra Segment movw %ax, %ss # -> Stack Segment 将段选择子清零 # Enable A20: # For backwards compatibility with the earliest PCs, physical # address line 20 is tied low, so that addresses higher than # 1MB wrap around to zero by default. This code undoes this. 准备工作就绪,下面开始动真格的了,激活A20地址位。先翻译注释:由于需要兼容早期pc,物理地址的第20位绑定为0,所以高于1MB的地址又回到了0x00000. 好了,激活A20后,就可以访问所有4G内存了,就可以使用保护模式了。 怎么激活呢,由于历史原因A20地址位由键盘控制器芯片8042管理。所以要给8042发命令激活A20 8042有两个IO端口:0x60和0x64, 激活流程位: 发送0xd1命令到0x64端口 --> 发送0xdf到0x60,done! seta20.1: inb $0x64, %al # Wait for not busy(8042 input buffer empty). testb $0x2, %al jnz seta20.1 #发送命令之前,要等待键盘输入缓冲区为空,这通过8042的状态寄存器的第2bit来观察,而状态寄存器的值可以读0x64端口得到。 #上面的指令的意思就是,如果状态寄存器的第2位为1,就跳到seta20.1符号处执行,知道第2位为0,代表缓冲区为空 movb $0xd1, %al # 0xd1 -> port 0x64 outb %al, $0x64 # 0xd1 means: write data to 8042‘s P2 port 发送0xd1到0x64端口 seta20.2: inb $0x64, %al # Wait for not busy(8042 input buffer empty). testb $0x2, %al jnz seta20.2 movb $0xdf, %al # 0xdf -> port 0x60 outb %al, $0x60 # 0xdf = 11011111, means set P2‘s A20 bit(the 1 bit) to 1 到此,A20激活完成。 # Switch from real to protected mode, using a bootstrap GDT # and segment translation that makes virtual addresses # identical to physical addresses, so that the # effective memory map does not change during the switch. 转入保护模式,这里需要指定一个临时的GDT,来翻译逻辑地址。这里使用的GDT通过gdtdesc段定义。它翻译得到的物理地址和虚拟地址相同,所以转换过程中内存映射不会改变 lgdt gdtdesc 载入gdt movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0 打开保护模式标志位,相当于按下了保护模式的开关。cr0寄存器的第0位就是这个开关,通过CR0_PE_ON或cr0寄存器,将第0位置1 # Jump to next instruction, but in 32-bit code segment. # Switches processor into 32-bit mode. ljmp $PROT_MODE_CSEG, $protcseg 由于上面的代码已经打开了保护模式了,所以这里要使用逻辑地址,而不是之前实模式的地址了。 这里用到了PROT_MODE_CSEG, 他的值是0x8。根据段选择子的格式定义,0x8就翻译成: INDEX TI CPL 0000 0000 1 00 0 INDEX代表GDT中的索引,TI代表使用GDTR中的GDT, CPL代表处于特权级。 PROT_MODE_CSEG选择子选择了GDT中的第1个段描述符。这里使用的gdt就是变量gdt。下面可以看到gdt的第1个段描述符的基地址是0x0000,所以经过映射后和转换前的内存映射的物理地址一样。 .code32 # Assemble for 32-bit mode protcseg: # Set up the protected-mode data segment registers movw $PROT_MODE_DSEG, %ax # Our data segment selector movw %ax, %ds # -> DS: Data Segment movw %ax, %es # -> ES: Extra Segment movw %ax, %fs # -> FS movw %ax, %gs # -> GS movw %ax, %ss # -> SS: Stack Segment 重新初始化各个段寄存器。 # Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00) movl $0x0, %ebp movl $start, %esp call bootmain 栈顶设定在start处,也就是地址0x7c00处,call函数将返回地址入栈,将控制权交给bootmain # If bootmain returns (it shouldn‘t), loop. spin: jmp spin # Bootstrap GDT .p2align 2 # force 4 byte alignment gdt: SEG_NULLASM # null seg SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel gdtdesc: .word 0x17 # sizeof(gdt) - 1 .long gdt # address gdt
2、<asm.h>的内容
// 用来定义段描述符的宏 #ifndef __BOOT_ASM_H__ #define __BOOT_ASM_H__ // assembler macros to create x86 segments // 定义了一个空段描述符 #define SEG_NULLASM .word 0, 0; .byte 0, 0, 0, 0 // 以type,base,lim为参数定义一个段描述符, 其中的0xC0=(1100)2, 其 // 中的第一个1对应于段描述符中的G位,置1表示段界限以4KB为单位 // 第二个1对应于段描述符的D位,置1表示这是一个保护模式下的段描述符 // 具体的关于段描述符的格式定义在mmu.h中 // The 0xC0 means the limit is in 4096-byte units // and (for executable segments) 32-bit mode. #define SEG_ASM(type,base,lim) .word (((lim) >> 12) & 0xffff), ((base) & 0xffff); .byte (((base) >> 16) & 0xff), (0x90 | (type)), (0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff) // 可执行段 #define STA_X 0x8 // Executable segment // 非可执行段 #define STA_E 0x4 // Expand down (non-executable segments) // 只能执行的段 #define STA_C 0x4 // Conforming code segment (executable only) // 可写段但是不能执行的段 #define STA_W 0x2 // Writeable (non-executable segments) // 可读可执行的段 #define STA_R 0x2 // Readable (executable segments) // 表明描述符是否已被访问;把选择字装入段寄存器时,该位被标记为1 #define STA_A 0x1 // Accessed
(二)问题解答
1.1为何开启A20,以及如何开启A20
首先说明一点,这是一个历史遗留问题。
1981年8月,IBM公司最初推出的个人计算机IBM PC使用的CPU是Inter 8088.在该微机中地址线只有20根。在当时内存RAM只有几百KB或不到1MB时,20根地址线已经足够用来寻址这些 内存。其所能寻址的最高地址是0xffff,
也就是0x10ffef。对于超出0x100000(1MB)的寻址地址将默认地环绕到0xffef。当IBM公司与1985年引入AT机时,使用的是Inter 80286 CPU,具有24根地址线,最高可寻址16MB,并且有一个与8088那样实现地址寻址的环绕。
但是当时已经有一些程序是利用这种环绕机制进行工作的。为了实现完全的兼容性,IBM公司发明了使用一个开关来开启或禁止0x100000地址比特位。由于当时的8042键盘控制器上恰好有空闲的端口引脚(输出端口P2,引脚P21),
于是便使用了该引脚来作为与门控制这个地址比特位。该信号即被称为A20。如果它为零,则比特20及以上地址都被清除。从而实现了兼容性。
当A20地址线控制禁止时,程序就像运行在8086上,1MB以上的地址是不可访问的,只能访问奇数MB的不连续的地址。为了使能所有地址位的寻址能力,必须向键盘控制器8082发送一个命令,键盘控制器8042会将A20线置于高电位,使全部32条地址线可用,实现访问4GB内存。
1.2打开A20 Gate的具体步骤(参考bootasm.S)
控制 A20 gate 的方法有 3 种:
1.804x 键盘控制器法
2.Fast A20 法
3.BIOS 中断法
ucore实验中用了第一种 804x 键盘控制器法,这也是最古老且效率最慢的一种。
由于在机器启动时,默认条件下,A20地址线是禁止的,所以操作系统必须使用适当的方法来开启它。
- 等待8042 Input buffer为空;
- 发送Write 8042 Output Port (P2)命令到8042 Input buffer;
- 等待8042 Input buffer为空;
- 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer
打开A20 Gate的代码为:
seta20.1: inb $0x64, %al # Wait for not busy(8042 input buffer empty). #从0x64端口读入一个字节的数据到al中 testb $0x2, %al #如果上面的测试中发现al的第2位为0,就不执行该指令 jnz seta20.1 #循环检查 movb $0xd1, %al # 0xd1 -> port 0x64 outb %al, $0x64 # 0xd1 means: write data to 8042‘s P2 port #将al中的数据写入到端口0x64中 seta20.2: inb $0x64, %al # Wait for not busy(8042 input buffer empty). testb $0x2, %al jnz seta20.2 movb $0xdf, %al # 0xdf -> port 0x60 outb %al, $0x60 # 0xdf = 11011111, means set P2‘s A20 bit(the 1 bit) to 1
第一步是向 804x 键盘控制器的 0x64 端口发送命令。这里传送的命令是 0xd1,这个命令的意思是要向键盘控制器的 P2 写入数据。这就是 seta20.1 代码段所做的工作。
第二步就是向键盘控制器的 P2 端口写数据了。写数据的方法是把数据通过键盘控制器的 0x60 端口写进去。写入的数据是 0xdf,因为 A20 gate 就包含在键盘控制器的 P2 端口中,随着 0xdf 的写入,A20 gate 就被打开了。
接下来要做的就是进入“保护模式”了。
2.1什么是GDT表
它的GDT全称是Global Descriptor Table,中文名称叫“全局描述符表”,想要在“保护模式”下对内存进行寻址就先要有 GDT。GDT 表里的每一项叫做“段描述符”,用来记录每个内存分段的一些属性信息,每个“段描述符”占 8 字节。
在保护模式下,我们通过设置GDT将内存空间被分割为了一个又一个的段(这些段是可以重叠的),这样我们就能实现不同的程序访问不同的内存空间。这和实模式下的寻址方式是不同的, 在实模式下我们只能使用address = segment << 4 | offset的方式进行寻址(虽然也是segment + offset的,但在实模式下我们并不会真正的进行分段)。在这种情况下,任何程序都能访问整个1MB的空间。而在保护模式下,通过分段的方式,程序并不能访问整个内存空间
2.2初始化GDT表
为了使分段存储管理机制正常运行,需要建立好段描述符和段描述符表,全局描述符表是一个保存多个段描述符的“数组”,其起始地址保存在全局描述符表寄存器GDTR中。GDTR长48位,其中高32位为基地址,低16位为段界限。这里只需要载入已经静态存储在引导区的GDT表和其描述符到GDTR寄存器:
lgdt gdtdesc#CPU 单独为我们准备了一个寄存器叫做 GDTR 用来保存我们 GDT 在内存中的位置和我们 GDT 的长度。#GDTR 寄存器一共 48 位,其中高 32 位用来存储我们的 GDT 在内存中的位置,其余的低 16 位用来存我们的 GDT 有多少个段描述符。 #16 位最大可以表示 65536 个数,这里我们把单位换成字节,而一个段描述符是 8 字节,所以 GDT 最多可以有 8192 个段描述符。#CPU 不仅用了一个单独的寄存器 GDTR 来存储我们的 GDT,而且还专门提供了一个指令用来让我们把 GDT 的地址和长度传给 GDTR 寄存器:lgdt gdtdesc
gdtdesc 和 gdt 一起放在了 bootasm.S 文件的最底部
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel
gdtdesc:
.word 0x17 # sizeof(gdt) - 1 (# 16 位的 gdt 大小sizeof(gdt) - 1
)
.long gdt # address gdt(# 32 位的 gdt 所在物理地址
)
48 位传给了 GDTR 寄存器,到此 GDT 就准备好了
3.1如何使能和进入保护模式
3.1.1修改CR0寄存器的PE值
如同 A20 gate 这个开关负责打开 1MB 以上内存寻址一样,想要进入“保护模式”我们也需要打开一个开关,这个开关叫“控制寄存器”,x86 的控制寄存器一共有 4 个分别是 CR0、CR1、CR2、CR3(这四个寄存器都是 32 位的),而控制进入“保护模式”的开关在 CR0 上。
CR0中包含了6个预定义标志,0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
CR0 上和保护模式有关的位,如图所示:
打开保护模式的代码为:
movl %cr0, %eax orl $CR0_PE_ON, %eax movl %eax, %cr0
因为我们无法直接操作 CR0,所以我们首先要用一个通用寄存器来保存当前 CR0 寄存器的值,这里第一行就是用通用寄存器 eax 来保存 cr0 寄存器的值;
然后 CR0_PE 这个宏的定义在 mmu.h 文件中,是个数值 0x00000001,将这个数值与 eax 中的 cr0 寄存器的值做“或”运算后,就保证将 cr0 的第 0 位设置成了 1 即 PE = 1 保证打开了保护模式的开关。
而 cr0 的第 31 位 PG = 0 表示我们只使用分段式,不使用分页,这时再将新的计算后的 eax 寄存器中的值写回到 cr0 寄存器中就完成了到保护模式的切换。
3.1.2通过长跳转,更新CS寄存器的基地址
ljmp $PROT_MODE_CSEG, $protcseg
其中protcseg是一个标号(标号的用途在本文中的实验相关部分已说明)
由于已经使能了保护模式,所以这里要使用逻辑地址,而不是之前实模式的地址了
这里还要注意PROT_MODE_CSEG和PROT_MODE_DSEG,这两者分别定义为0x8和0x10,表示代码段和数据段的选择子。
根据段选择子的格式定义,0x8就翻译成:
INDEX TI CPL
0000 0000 1000
INDEX代表GDT中的索引,TI代表使用GDTR中的GDT, CPL代表处于特权级。
3.1.3设置段寄存器,并建立堆栈
注意这里建立堆栈,ebp寄存器按理来说是栈帧的,但是这里并不需要把它设置为0x7c00,因为这里0x7c00是栈的最高地址,它上面没有有效内容,而之后因为调用,ebp会被设置为被调用的那个函数的栈的起始地址,这里就不用管它了。
四、总结
Bootload的启动过程可以概括如下:
首先,BIOS将第一块扇区(存着bootloader)读到内存中物理地址为0x7c00的位置,同时段寄存器CS值为0x0000,IP值为0x7c00,之后开始执行bootloader程序。CLI屏蔽中断(屏蔽所有的中断:为中断提供服务通常是操作系统设备驱动程序的责任,因此在bootloader的执行全过程中可以不必响应任何中断,中断屏蔽是通过写CPU提供的中断屏蔽寄存器来完成的);CLD使DF复位,即DF=0,通过执行cld指令可以控制方向标志DF,决定内存地址是增大(DF=0,向高地址增加)还是减小(DF=1,向地地址减小)。设置寄存器 ax,ds,es,ss寄存器值为0;A20门被关闭,高于1MB的地址都默认回卷到0,所以要激活A20,给8042发命令激活A20,8042有两个IO端口:0x60和0x64, 激活流程: 发送0xd1命令到0x64端口 --> 发送0xdf到0x60,打开A20门。从实模式转换到保护模式(实模式将整个物理内存看成一块区域,程序代码和数据位于不同区域,操作系统和用户程序并没有区别对待,而且每一个指针都是指向实际的物理地址,地址就是IP值。这样,用户程序的一个指针如果指向了操作系统区域或其他用户程序区域,并修改了内容,那么其后果就很可能是灾难性的),所以就初始化全局描述符表使得虚拟地址和物理地址匹配可以相互转换;lgdt汇编指令把通过gdt处理后的(asm.h头文件中处理函数)描述符表的起始位置和大小存入gdtr寄存器中;将CR0的第0号位设置为1,进入保护模式;指令跳转由代码段跳到protcseg的起始位置。设置保护模式下数据段寄存器;设置堆栈寄存器并调用bootmain函数;
五、参考链接
汇编基本语法简介
清华大学教学内核ucore学习系列(1) bootloader
ucore-lab1-练习3report
学习xv6从实模式到保护模式
ucore练习三
原文地址:https://www.cnblogs.com/jwcz/p/11830856.html