无忧启动论坛

 找回密码
 注册
搜索
一次装机 终生领工资最纯净的「微PE装机优盘」UEPON大师作品卡瑞飞系统和装机二合一超级U盘
广告位出租系统gho:最纯净好用系统下载站广告联系 QQ:184822951 微信:wuyouceo
楼主: sratlf

GRUB4DOS资源索引帖,欢迎跟帖补充

    [复制链接]
发表于 2012-1-1 13:24:12 | 显示全部楼层
<转帖> grub源码分析
其实总体上我们可以把grub看成一个mini os,他有shell,支持script,有文件系统……
我们可以把stage1 stage1-5看成一个boot loader,而stage2则是一个os,只不过这个os是专门load其他os的os,为此,stage2支持像 kernel,initrd,chainloader等等为此目的而设置的内部“命令”……

1.简略流程
(如果你不熟悉grub,建议您先看完这这一节再向后)
a,Bios执行int 0x19,加载MBR至0x7c00并跳转执行。
如果你安装grub到mbr,grub的安装程序会把stage1(512B)拷贝到mbr。
视stage2的大小,安装程序会在stage1中嵌入stage1-5或者stage2的磁盘位置信息。
b,stage1开始执行,它在进行直接加载stage1-5或者stage2并跳转执行(还有些额外的工作,这里忽略先)
stage1-5很无辜,除了加载stage2以外它没有其他任何作用。
所以,不论是哪种情况,这一步的结果都是stage2开始运行了。
c,stage2这个mini os终于开始正式运行了!它会把系统切入保护模式,设置好c运行环境(主要是bss)
他会找config文件先(就是我们的menulist),如果没有的话就执行一个shell,等待我们输入命令。
然后grub的工作就是输入命令-解析命令-执行命令的循环,当然stage2本身也很无辜,他是为加载其他os
而存在的,所以如果情况允许,在他执行boot命令以后就会把控制权转交出去!

a,调用接口

类似系统调用,在stage2/disk_io.c中定义了grub_open,grub_close,grub_read,grub_dir
全局函数用于stage2的文件操作:打开,关闭,读,切换目录。

为了简化文件系统驱动的编写,grub不支持磁盘写(对于一个loader来说也没有必要去写磁盘)


b,文件系统驱动接口

任何一个文件系统驱动必须在fsys_table(stage2/disk_io.c)数组中去放置一个
struct fsys_entry的结构体,定义如下(stage2/filesys.h):
struct fsys_entry
{
char *name;//文件系统名称
int (*mount_func) (void);//初始化函数
int (*read_func) (char *buf, int len);//文件读函数
int (*dir_func) (char *dirname);//目录/文件打开函数
void (*close_func) (void);//文件关闭函数
int (*embed_func) (int *start_sector, int needed_sectors);//?大部分驱动设置此项为NULL
};


c,情景:读入文件

假设在grub的Menulist中有:kernel (hd0,0)/boot/vmlinuz(或者我们在grub shell中执行此命令)

stage2会先调用grub_open("(hd0,0)/boot/vmlinuz")来打开文件。
在执行这个函数中,grub会先在fsys_table中循环调用fsys_entry::mount_func去发现一个返回值为真的文件系统,即为当前的文件系统。然后利用当前文件系统驱动的fsys_entry::dir_func去打开/boot/vmlinuz
然后stage2会调用grub_read(buf,0)读入全部的/boot/vmlinuz文件至内存中的buf地址
grub_read是fsys_entry::read_func的封装。
最后stage2回调用grub_close关闭文件,跟前面一样,这个调用仅仅是当前文件系统fsys_entry::close_func的简单封装。

d,文件系统驱动

你可以在stage2/fsys_*.c找到各个文件系统驱动,当然你如果 对某个文件的结构熟悉,而grub中又没有相应的驱动,你可以安装b节中方法向grub中添加此文件系统的支持,比如NTFS,你只要实现mount(判断是否是你所支持的文件系统),dir(打开文件或者目录),read(读),close(关闭文件)功能就好。

Stage2-Shell
大部分情况我们是通过一个menulist文件来控制grub,这样grub会分析此文件的内容,然后显示一个菜单让我们选择。但是我不打算介绍grub stage2时怎样显示这个菜单的,这不是grub特殊的地方,这一节将对菜单选择执行的底层机制做小小的分析。(在grub没有找到menulist的时候,他就会执行一个shell,你完全可以通过这里内置的命令来控制grub,实际上你可以把menulist当作一个脚本文件,它也完全是通过内置命令执行的)

a,stage2流程

stage1或者stage1-5加载完stage2后,会跳转至stage2执行,stage2的入口是stage2/asm.S
asm.S在设置好c运行环境之后,会调用第一个c函数init_bios_info(stage2/common.c),这个函数在执行一些底层的初始化之后,会调用stage2的main函数cmain(stage2/stage2.c),这样stage2这个 mini os正式开始运行了!

针对menu.lst和shell这两种情况,cmain将:
menu.lst: run_menu()(stage2.c)->run_script()(cmdline.c)->find_command->执行命令函数
shell: enter_cmdline()(cmdline.c)->find_command->执行命令函数

殊途同归,最后都归结为命令行的解释执行
find_command(stage2/cmdline.c)按照menu.lst中或者shell用户输入的命令字符串,在一个全局性struct builtin *builtin_table[](stage2/builtin.c)变量中去找到内置命令的函数,然后执行。

值得一提的是grub的shell类似bash的命令补全和命令历史纪录。



b,内部数据结构
struct builtin
{
/* 命令名称,重要,是搜索命令时的依据 */
char *name;
/* 命令函数,重要,是搜索匹配后调用的函数 */
int (*func) (char *, int);
/* 功能标示,一般未用到. */
int flags;
/* 简短帮助信息 */
char *short_doc;
/* 完整帮助信息 */
char *long_doc;
};
struct builtin *builtin_table[];(stage2/builtin.c)

c,Hack 提示

编写一个自己的grub命令。只需按照b节所述,填充一个struct builtin结构,按后将其指针放入builtin_table指针数组,你就可以在menu.lst或者grub shell中使用这个命令了。




<by mrbanana>

GRUB整体分析
总体上我们可以把GRUB看成一个微型的操作系统,他有Shell,支持Script,有文件系统……我们可以把Stage1和Stage1.5看成一个引导程序,而Stage2则是一个操作系统,只不过这个操作系统是专门用来引导其他操作系统的操作系统,为此,Stage2支持像 kernel,initrd,chainloader等等为此目的而设置的内部“命令”。
3.1  GRUB引导操作系统的两种方式
3.1.1 直接引导方式
GRUB同时支持 Linux, FreeBSD, NetBSD 和OpenBSD。如果想要启动其他的操作系统,你必须使用链式启动方式来启动他们[6]。

通常,GRUB直接引导操作系统的步骤如下:

(1) 通过'root'指令来设置GRUB的主设备指向操作系统映像文件所存储的地方。

(2) 通过'kernel'命令来载入该操作系统的核心映像。

(3) 如果需要模块,通过'module'命令来加载模块。

(4) 运行命令'boot'。

Linux, FreeBSD, NetBSD 和 OpenBSD使用相同的方式启动。你可以通过'kernel'命令来装载核心映像,然后运行'boot'命令。如果核心需要一些参数的话,只要在 'kernal'命令以后追加就可以了。
3.1.2 链式引导方式
如果你要启动一个不被GRUB直接支持的操作系统(例如: Windows 95),可以通过链式引导启动一个操作系统。通常来说,那个引导程序和所要启动的操作系统是安装在一个分区中的。

主要步骤如下:

(1) 通过'rootnoverify'命令设置GRUB的主设备指向一个扇区。

grub> rootnoverify (hd0,0)

(2) 通过'makeactive'命令来设置在扇区上的'active’标志位。

grub> makeactive

(3) 通过'chainloader'命令来加载引导程序。

grub> chainloader +1

'+1'表明GRUB需要从起始分区读一个扇区。

(4) 运行命令'boot'。


3.2  GRUB引导操作系统的简要流程分析
3.2.1 从计算机启动到GRUB启动操作系统
(1) BIOS执行INT 0x19,加载MBR至0x7c00并跳转执行。如果你安装GRUB到MBR,GRUB的安装程序会把Stage1(512B)拷贝到MBR。视 stage2的大小,安装程序会在Stage1中嵌入Stage1_5或者Stage2的磁盘位置信息。
(2) Stage1开始执行,它在进行直接加载Stage1_5或者Stage2并跳转执行。不论是哪种情况,这一步的结果都是Stage2开始运行了。
(3) Stage2这个小型的操作系统终于开始正式运行了!它会把系统切入保护模式,设置好C运行环境(主要是BSS)。他会先找Config文件(就是我们的 Menulist),如果没有的话就执行一个Shell,等待我们输入命令。然后Grub的工作就是输入命令-解析命令-执行命令的循环,当然 Stage2本身是为加载其他操作系统而存在的,所以如果情况允许,在他执行Boot命令以后就会把控制权转交出去。
3.2.2  GRUB的主要启动模块
GRUB 包含如下几个启动模块:两个必须的场景文件,一个叫"Stage 1.5"的可选的场景文件以及2个网络启动的映像文件。首先对他们有一个大致的了解。

Stage1
这是一个基本必须的用来启动GRUB的映像文件。通常,这个文件是被装载到MBR或者启动扇区所在的分区。由于PC的启动扇区的大小为512字节,所以这个映像文件编译以后也必须为512字节。

Stage1的全部的工作是从本地磁盘把Stage 2或者Stage 1.5装载进来。由于对stage1大小的限制,它通过分程序表的形式来编码Stage 2或者Stage 1.5的位置,所以在stage1是不能识别任何文件系统的。

Stage2
这是GRUB的核心映像。它几乎做了除启动它本身以外的所有事情。通常,它被存放为某一种文件系统下,但并非是必须的。

e2fs_stage1_5
fat_stage1_5
ffs_stage1_5
jfs_stage1_5
minix_stage1_5
reiserfs_stage1_5
vstafs_stage1_5
xfs_stage1_5
这些文件被称为stage 1.5,它存在的目的是做为stage1与stage2之间的桥梁,也就是说,stage1载入stage1.5,然后stage1.5载入 stage2。
stage1与stage1.5之间的区别是,前者是不识别任何文件系统的但后者识别文件系统(例如 'e2fs_stage1_5' 识别 ext2fs)。所以你可以安全的移动stage2的位置,即使是在GRUB安装完以后。

nbgrub
这是一个网络启动的映像文件,被类似于以太网启动装载器所使用。它很类似于stage2,但它还要建立网络,然后通过网络来载入配置文件[7]。

pxegrub
这是另一个网络启动的映像文件。
除了格式以外,它和'nbgrub'是一致的。







4 STAGE1模块分析
Stage1模块是整个引导程序的引导模块,是从开机过渡到GRUB的第一个模块。Stage1的代码文件,是源码目录下 Stage1/Stage1.S,汇编后便成了一个512字节的Img,被写在硬盘的0面0道第1扇区,作为硬盘的主引导扇区。
4.1 Stage1.h文件分析
在此文件中主要是定义了一些在Stage1.S文件中使用到的一些常量。
关于这些常量的分析如下:
/* 定义了grub的版本号,在stage1中可以识别他们.*/
#define COMPAT_VERSION_MAJOR 3
#define COMPAT_VERSION_MINOR 2
#define COMPAT_VERSION ((COMPAT_VERSION_MINOR << 8) \
| COMPAT_VERSION_MAJOR)

/* MBR最后两个字节的标志*/
#define STAGE1_SIGNATURE 0xaa55

/* BPB (BIOS 参数块BIOS Parameter Block)的结束标记的偏移,他含有对驱动器的低级参数的说明. */
#define STAGE1_BPBEND 0x3e

/* 主版本号的标记的偏移*/
#define STAGE1_VER_MAJ_OFFS 0x3e

/* Stage1启动驱动器的标记的偏移*/
#define STAGE1_BOOT_DRIVE 0x40

/* 强迫使用LBA方式的标记的偏移*/
#define STAGE1_FORCE_LBA 0x41

/* Stage2地址标记的偏移*/
#define STAGE1_STAGE2_ADDRESS 0x42

/* STAGE2扇区的标记的偏移*/
#define STAGE1_STAGE2_SECTOR 0x44

/* STAGE2_段的标记的偏移*/
#define STAGE1_STAGE2_SEGMENT 0x48

/* 使用Windows NT的魔术头标识的偏移*/
#define STAGE1_WINDOWS_NT_MAGIC 0x1b8

/* 分区表起始地址的标记的偏移*/
#define STAGE1_PARTSTART 0x1be

/* 分区表结束地址的标记的偏移*/
#define STAGE1_PARTEND 0x1fe

/* Stage1堆栈段的起始地址*/
#define STAGE1_STACKSEG 0x2000

/* 磁盘缓冲段。磁盘缓冲必须是32K长而且不能跨越64K的边界。*/
#define STAGE1_BUFFERSEG 0x7000

/* 驱动器参数的地址*/
#define STAGE1_DRP_ADDR 0x7f00

/* 驱动器参数的大小*/
#define STAGE1_DRP_SIZE 0x42

/*在BOIS中软盘的驱动器号标志*/
#define STAGE1_BIOS_HD_FLAG 0x80

4.2 Stage1.s文件分析
首先在这个文件的开始部分定义了一些宏。

#define ABS(x) (x-_start+0x7c00)
这个宏计算了直接地址。由于MBR是被加载到0x7c00的位置,所以通过计算可以直接得到x参数的直接地址。这样就可以不依赖于Linker程序。

#define MSG(x) movw $ABS(x), %si;
这个宏用于处理对字符串的载入和响应。

然后程序从_start程序入口开始执行,此入口在内存中的位置为CS:IP 0:0x7c00。随后对一系列的变量进行了初始化。设置了起始的扇区、磁道和柱面,并设置了他们的起始位置。同时还设置了stage1的版本号。通过设置boot_drive变量,来设置从那个盘来载入stage2。如果此变量设置成0xff则从默认的启动驱动器中来载入stage2。然后指定了 stage2的起始地址是0x8000,起始段是0x800,起始的扇区号是1。也就是说stage2起始位置是被存放在0柱面,0磁道,第2扇区上的 [8]。
程序从real_start入口开始真正执行。首先设置了数据段以及堆栈段的偏移为0,然后设置stage1的堆栈的起始地址为 STAGE1_STACKSEG即0x2000。随后打开中断。然后检查是否设置了启动的磁盘。即boot_drive变量是否为0xff,如果非 0xff则保存设置的磁盘号到dl寄存器中,并压入堆栈保存。同时在屏幕上显示GRUB字样。然后检查此启动磁盘是否是软盘,如果是软盘则直接跳转到 CHS模式不用检测是否支持LBA模式。然后检测所启动的磁盘是否支持LBA模式。接着程序分成两块,一块是LBA模式,一块是CHS模式。
LBA英文全名为Logical Block Addressing,中文名称为逻辑区块寻址[9]。LBA所指的是一种磁盘设备的寻址技术,它是利用逻辑映对的方式来指定磁盘驱动器的扇区,目前个人计算机所使用的传输接口中,增强型 IDE (Enhanced IDE) 和 SCSI 均使用逻辑区块寻址方式。传统的硬盘寻址技术是采取实体寻址(physical mapping、physical addressing)的方式,以磁盘上的实际结构,直接作为资料区块地址的结构。但由于初期在设计实体寻址方式时,硬盘容量只有5、10、20 MB等等小容量机种,所以设计出来的最大的寻址能力,只能到1024个磁柱(cylinder)、16个磁头(head)、63个扇区(sector)。以每个扇区(sector)512字节(bytes)计算,实体寻址的方式最多只能使用512×63×1024×16=528482304字节(528MB)的硬盘空间。但是由于磁性储存技术不断的提升,硬盘容量大幅增加的情况之下,这样的限制让使用者必须将硬盘画分为多个区块,使用上非常的不方便。
因此硬件厂商研究出了LBA逻辑寻址方式,也就是计算机系统并没有将资料存放地点的相关记录,应对到硬盘上资料实际存放的位置。而是由IDE控制电路和 BIOS负责转换寻址(mapping)资料的记录位置表。经过转换后的记录方式,是将第1个磁柱上的第1条磁道的第1个扇区编号为0,第二个扇区编号为 1,以此类推……,假设1条磁道有2000个扇区,那么第2000个扇区的编号就是1999。第2条磁道上的第1个扇区就是2000,如此一直线性排列下去。以逻辑区块的方式来寻址的硬盘,最多可达16383磁柱,最大磁头数为16个,每轨扇区有63区,扇区大小为512字节,所支持之硬盘空间为 512×63×16383×16=8455200768字节(8.4GB)[10]。
在ATA的接口规格中,定义了使用28位来寻址,因此计算出来,它可以支持到224×512=137GB的容量。不过不幸的,BIOS并无法配合,它使用 24位来寻址(也就是LBA模式)。所以根本之道,就是改变BIOS对中断13h的支持,因此后来的BIOS就设计了加强版的中断13h。一口气使用了 64位来对硬盘做寻址,因此可以支持到264×512=9.4TB,相当于3万亿倍的8.4GB[11]。
如果是LBA模式下读取,首先对先前定义的磁盘的一些参数进行了定义,为以后调用INT 13做准备。使用INT13的0x42功能,把磁盘内容读到内存中。设置ah为0x42为功能号,设置dl寄存器来设置磁盘号,si为记录磁盘一系列信息的地址偏移量,磁盘信息中包括了要读入的柱面号、磁道号以及扇区号。程序然后调用BOIS INT 13中断将启动磁盘上的第二扇区上的内容读到内存中的STAGE1_BUFFERSEG处,在Stage1.h中定义STAGE1_BUFFERSEG为 0x7000。即将第二扇区上的内容读到内存中的0x7000处。读入成功的话跳转到COPY_BUFFER处,如果读取失败则尝试使用CHS模式读入。
与LBA模式不同的是,调用BIOS INT 0x13中断中的0x2号功能,设置ah寄存器为0x2,al为扇区数,cl的位6,位7和ch组合为磁道号,cl的0-5位为扇区号,dh为磁头号,dl为驱动器号(其中0x80为硬盘,0x0为软驱)。es:bx为数据缓冲区的地址。但所起的功能与前面提到的LBA模式是类似的,也是将第二扇区中的内容读到内存中的0x7000处,作为缓存。然后跳转到COPY_BUFFER处。
最后调用COPY_BUFFER将刚刚读入的扇区转移到stage2_address。即转移到0x8000处。
4.3 Stage1模块功能综述
由于对Stage1文件容量的限制,所以Stgae1所做的工作相对来说比较有限。它首先被BOIS装载到内存中的0x7c00处,然后通过调用BOIS INT13中断,把第启动驱动器中第二扇区上的内容读到内存中的0x7000处,然后通过调用COPY_BUFFER将其转移到了内存中0x8000的位置上。这个被读入的第二扇区上的内容,就是下面将要分析的Start.s功能模块。




5  START模块分析
从上一章节的分析中我们看到,Stage1的是完成了一个MBR所需要完成的任务,但GRUB并没有直接就通过Stage1直接载入GRUB的内核,而是通过Stage1载入了另一个模块到0x8000处。根据对源代码的分析,发现被载入的这个模块就是下面需要分析的第二个模块,即Start.S模块。

5.1 Start.s 模块功能分析
在程序的开始部分,仍然是对程序定义了一些宏。
#ifdef STAGE1_5
# define ABS(x) (x-_start+0x2000)
#else
# define ABS(x) (x-_start+0x8000)
#endif
可以发现,如果定义了STAGE1_5则程序的起始地址是0x2000,而如果没有定义STAGE1_5程序起始的地址正好是0x8000。所以我判断,在Stage1后载入内存的程序部分就是Start.s所编译以后的512字节的映象文件。关于STAGE1_5的部分暂时先不进行分析,这里暂且跳过。

宏 “#define MSG(x) movw $ABS(x), %si;”的作用是在屏幕上显示字符串。
接着就是程序的入口_start。由于是紧接着Stage1被载入内存中的,所以它的起始地址就是0x8000,并且它仍然将使用Stage1模块留下来的寄存器以及变量等信息。如果设置STAGE1_5变量则在屏幕上显示“Loading stage1.5”,如果没有设置这个变量则显示“Loading stage2”。然后读入需要读入的扇区的数目。接着进入一个bootloop的循环,如果需要读入的扇区不为0,则继续循环,直到当需要读入的扇区数目为0时,循环结束。在这个循环中,使用与Stage1中的方法相同,判断了驱动器磁盘所支持的读写模式,根据不同的磁盘所支持的不同模式,跳转到相应的部分去读取磁盘上的扇区到内存中去。如果磁盘支持的是LBA模式则跳转到lba_mode部分读取相应的扇区,如果磁盘不支持LBA模式,则跳转到 chs_mode部分,通过CHS模式来读入把磁盘中的扇区读入到内存中。首先是把读到的扇区读到内存中的0x7000处缓存起来,然后通过调用 copy_buffer子程序,把缓存中的内容复制到目标地址,即0x8200开始的地方。与Stage1中的一样,在Start.s中也有一个记录地址的数据结构,不同的是在Stage1中只有一项,而Start.S记录的是一个地址的链表,称为Blocklist,该链表的结点都记录了一个连续 sectors的集合。

lastlist:
.word 0
.word 0
. = _start + 0x200 - BOOTSEC_LISTSIZE
/*加0x200是由于Start.s编译完以后也是一个512字节的映象文件。*/

/* 初始化了第一个数据列表*/
blocklist_default_start:
.long 2 /* 记录了从第3个扇区开始*/
blocklist_default_len:
/* 这个参数记录了需要读取多少个扇区 */
#ifdef STAGE1_5
.word 0 /* 如果设置了STAGE1_5标志,则不读入*/
#else
.word (STAGE2_SIZE + 511) >> 9 /*读入Stage2所占的所有扇区*/
#endif
blocklist_default_seg:
#ifdef STAGE1_5
.word 0x220 /*如果设置STAGE1_5则从0x220开始读入*/
#else
.word 0x820 /*如果没有设置STAGE1_5则从0x820开始读入*/
#endif

firstlist:

当把所有需要读入的扇区都读入以后,程序进入bootit子程序块。然后程序进行跳转,如果设置了STAGE1_5标志,则跳转到0x2200执行,如果没有设置STAGE1_5标志,则跳转到0x8200处继续执行。


5.2 Start 模块功能综述
通过对Start.s文件的分析,我们可以看到。Start模块主要是做了一件事情,就是把Stage2或者Stage1_5模块从磁盘装载到内存中。如果是直接装载Stage2的话,是装载在内存的0x8200处,如果装载Stage1_5的话,是装载在内存的0x2200处。

6  GRUB Kernel模块分析
由于我分析的是GRUB2的源代码,从GRUB2开始,从Start模块载入的是Grub的整个kernel。从官方的说明可以看到,与Grub相比,最大的差异在于GRUB2将Stage1.5以及Stage2的功能归并为GRUB2的kernel,并提高了压缩性能;编译生成的 kernel──core.img只有24KB左右,即使对于最普遍的CHS读写模式所支持的0面0道的64个扇区(折合32K左右)而言,空间是足够放置GRUB2的kernel的,Grub的每个Stage1.5都至少在11K左右,而stage2则为110K左右。

6.1 Asm.s 文件分析
在分析了Start模块以后,发现如果没有设置Stage1_5参数,那么系统已经把Grub kernel从磁盘完全装载到了起始地址为0x8200开始的内存中。于是我便在源代码中寻找起始地址从0x8200开始执行的代码。发现Asm.s文件就是这样一个符合条件的模块。
首先在这个文件的开始,仍然定义了这样一个宏:
#ifdef STAGE1_5
# define ABS(x) ((x) - EXT_C(main) + 0x2200)
#else
# define ABS(x) ((x) - EXT_C(main) + 0x8200)
#endif
从这个宏中可以看出,从Start模块以后从磁盘转载的应该就是这个文件编译以后的模块。程序的入口是EXT_C(main)。如果没有定义 STAGE1_5那么程序的起始地址正是0x8200完全符合前面所做的分析。同时由于设置了.code16,整个程序开始仍然时工作在实模式下的。
接着分析ENTRY(main)这个函数。首先为了保证main这个函数如果是Stage2的话被装载在0x8200,如果是Stage1.5的话被装载在0x2200。然后程序执行了一个长跳转。ljmp $0, $ABS(codestart)。
在执行codestart代码之前,它对一些变量进行了初始化。设置了如版本号、install_partition、saved_entryno、 stage2_id、force_lba和config_file等。如果是Stgae1.5则config_file为“/boot/grub /stage2”,如果是Stage2则为“boot/grub/menu.lst”。
然后进入codestart代码,首先关中断,对断寄存器进行了一些初始化,然后设置了堆栈的起始地址为STACKOFF即(0x2000 - 0x10)。
接着程序调用了real_to_prot这样一个子功能模块。从实模式转换把程序转换到保护模式下。分析ENTRY(real_to_prot)子功能,主要的转换步骤如下:首先程序仍然在实模式下,关中断。接着载入了GDT表。然后通过.code32转到保护模式下,跳转到protcseg子功能下,重新装载所有的段寄存器。同时把返回的地址放到STACKOFF中,然后获得保护模式下的堆栈地址,把STACKOFF压入堆栈中,进行保护。然后返回。
然后程序继续,清空了bss段,调用了init_bios_info函数,这个函数体是整个C语言代码的入口,是Cmain函数前的初始化代码。
在asm.s文件中,主要是一些汇编代码的函数块,没有C语言的代码,于是我在share.h中找到了init_bios_info的函数定义,从而在 common.c中找到了init_bios_info的代码。
同时在这个文件中还定义了非常多的汇编代码写的函数,这些函数将来会被C文件调用。这里先对这些文件进行一下说明,如表6.1所示。

表6.1 asm.s文件中的汇编函数列表
函数名称 函数作用
stop() 调用prot_to_real子函数,从保护模式转换成实模式
hard_stop() 通过反复调用自身,形成一个死循环,起到一个暂停的作用
grub_reboot() 重新启动系统
grub_halt(int no_apm) 暂停系统,利用时钟计时,如果设置NO_APM将不使用时钟计时
track_int13(int drive) 追踪INT13来操作I/O的地址空间
set_int15_handler(void) 建立INT15的句柄
unset_int15_handler(void) 重新恢复INT15的句柄
set_int13_handler(map) 复制一块数据到驱动器并且建立INT13的句柄
chain_stage1(segment,offset,part_table_addr) 启动另一个stage1的载入程序
chain_stage2(segment, offset, second_sector) 启动另一个stage2的载入程序
real_to_prot () 实模式转换成保护模式
prot_to_rea l() 保护模式转换成实模式
int biosdisk_int13_extensions (int ah, int drive, void *dap) 调用IBM/MS 扩展INT13的功能。
int biosdisk_standard (int ah, int drive, int coff,int hoff, int soff,int nsec, int segment) 调用标准的INT13功能
int check_int13_extensions (int drive) 检查磁盘是否支持LBA模式
get_diskinfo_int13_extensions (int drive, void *drp) 从参数*drp返回磁盘驱动的具体结构
int get_diskinfo_standard (int drive, unsigned long *cylinders,unsigned long *heads, unsigned long *sectors) 返回指定磁盘的柱面,磁头以及扇区信息
int get_diskinfo_floppy (int drive, unsigned long *cylinders,unsigned long *heads, unsigned long *sectors) 返回软盘的磁盘的柱面,磁头以及扇区信息
get_code_end() 返回代码末端的地址
get_memsize(i) 返回内存大小,如果I为0返回常规内存,I为1返回扩展内存
get_eisamemsize() 返回EISA的内存分布图
get_rom_config_table() 获得Rom配置表的线性地址
int get_vbe_controller_info (struct vbe_controller *controller_ptr) 获得VBE控制器的信息
int get_vbe_mode_info (int mode_number, struct vbe_mode *mode_ptr) 获得VBE模式信息
int set_vbe_mode (int mode_number) 设置VBE模式
linux_boot() 做一些危险的设置,然后跳转到Linxu安装的入口代码
multi_boot(int start, int mb_info) 这个函数启动一个核心使用多重启动的标准方法
void console_putchar (int c) 通过这个函数在终端上显示字符
int console_getkey (void) 调用INT16从键盘上读取字符
int console_checkkey (void) 检查是否某个键被一直按下去
int console_getxy (void) 调用INT10获得光标的位置
void console_gotoxy(int x, int y) 调用INT10设置光标的位置
void console_cls (void) 调用INT10 清空屏幕
int console_setcursor (int on) 调用INT10设置光标的类型
getrtsecs() 如果第二个值能被读取,则返回这个值
currticks() 用Ticks为单位返回当前时间,一秒约为18-20个Ticks



6.2 Common.c 文件分析
在common.c文件中我找到了函数init_bios_info的实现的代码。分析发现,整个init_bios_info文件主要是对 multiboot_info这个结构进行初始化以及填充。
整个结构体分析如下:
struct multiboot_info
{
/* 多重启动信息的版本号*/
unsigned long flags;

/* 可以使用的内存 */
unsigned long mem_lower;
unsigned long mem_upper;

/* 主分区 */
unsigned long boot_device;

/*核心的命令行*/
unsigned long cmdline;

/*启动模块的列表*/
unsigned long mods_count;
unsigned long mods_addr;

union
{
struct
{
/* (a.out) 核心标识表的信息 */
unsigned long tabsize;
unsigned long strsize;
unsigned long addr;
unsigned long pad;
}
a;

struct
{
/* (ELF) 核心标识表的信息*/
unsigned long num;
unsigned long size;
unsigned long addr;
unsigned long shndx;
}
e;
}
syms;

/* 内存分布图的缓存 */
unsigned long mmap_length;
unsigned long mmap_addr;

/* 驱动器信息缓存 */
unsigned long drives_length;
unsigned long drives_addr;

/* ROM 配置表 */
unsigned long config_table;

/* 启动装载器的名称 */
unsigned long boot_loader_name;

/* APM 表 */
unsigned long apm_table;

/* 视频 */
unsigned long vbe_control_info;
unsigned long vbe_mode_info;
unsigned short vbe_mode;
unsigned short vbe_interface_seg;
unsigned short vbe_interface_off;
unsigned short vbe_interface_len;
};

通过调用asm.s中的底层功能模块,对这个结构进行初始化以后,直接调用cmain函数。



6.3 Stage2.c 文件分析
通过查找,在Stage2.c中找到了cmain函数,这个应该就是Stage2这个小型操作系统的入口了。然后程序就进入一个死循环,整个Stage2 就在这个死循环中运行。接着调用reset()函数对stage2的内部变量进行初始化。通过open_preset_menu()函数尝试打开已经设置好的菜单。如果用户没有设置好菜单,那么将返回0,如果已经设置好了菜单则不返回0。如果没有成功打开菜单,那么将通过grub_open()函数尝试打开config_file。Grub使用内部的文件格式来打开这样一个配置文件,如果仍然打开失败,则跳出整个循环。如果打开成功,则根据打开的情况,即 is_preset变量的值的情况来判断是从预设菜单读入还是从配置文件读入命令。然后通过把is_preset传入 get_line_from_config()函数,将命令读入cmline中。然后通过find_command()函数查找有没有这条命令。
在Grub中,保存命令的格式是保存在一个builtin的结构体中的。这个结构体在shared.h头文件中进行了定义。
struct builtin
{
/* 命令名称,重要,是搜索命令时的依据 */
char *name;
/* 命令函数,重要,是搜索匹配后调用的函数 */
int (*func) (char *, int);
/* 功能标识 */
int flags;
/* 简短帮助信息 */
char *short_doc;
/* 完整帮助信息 */
char *long_doc;
};

整个命令的表的定义如下
extern struct builtin *builtin_table[];

find_command()函数在cmdline.c中定义,它对整个builtin表进行遍历,然后比较名称。如果在表中发现了这个命令,则返回指向当前builtin结构的指针。如果没有发现这个命令则返回0同时返回一个errnum。如果成功的找到了一条指令,然后通过调用在cmdline.c中的skip_to ()函数,获得当前builtin指针所指向结构的命令的参数。然后通过(builtin->func) (arg, BUILTIN_MENU)直接调用此命令。最后一直循环,直到没有命令可以取为止。
如果由于前面没有成功的打开预先配置的文件而跳出循环,则通过在cmdline.c文件中定义的enter_cmdline()函数调用来启动命令行。在 enter_cmdline()函数中,进入另一个循环等待接受命令。当通过get_cmdline()函数接收到命令以后,仍然通过 find_command()函数调用来遍历builtin表,如果没有在表中找到输入的指令则返回一个 errnum=ERR_UNRECONGNIZED。如果成功找到了这条指令,同样首先调用在cmdline.c中的skip_to ()函数,获得当前builtin指针所指向结构的命令的参数。然后(builtin->func) (arg, BUILTIN_MENU)直接调用此命令。
如果成功的打开了菜单则跳转到run_menu()函数,这里是grub中整个menu用户界面的主循环。首先有一个计时器grub_timout进行计时,如果grub_timeout<0或者没有设置,那么就强行显示菜单。如果菜单没有显示,则在屏幕上显示“Press `ESC' to enter the menu...”,并进入一个死循环中,当用户按下ESC按键,则马上显示菜单。如果超时,那么就直接进入第一个,也就时默认的那个启动项目。如果显示菜单,则显示所有可以选择的入口。
不论是否显示菜单,最后程序都将跳转到boot_entry。首先程序先清空了屏幕,然后把光标定于第一行的位置。然后再次进入一个循环。然后如果没有设置入口则通过调用get_entry()函数来获取一个默认的入口。然后调用在cmdline中的run_script()函数解释这个入口。 Run_script()函数对这个入口以后的指令脚本,进行了解析。解析的方式仍然是利用find_command()函数调用。


6.4 GRUB部分指令说明
Grub中所有的预先设置的指令都是在builtins.c文件中实现的。比如启动一个FreeBSD操作系统,可以输入以下的指令:
grub> root (hd0,a)
grub> kernel /boot/loader
grub> boot

6.4.1  Root指令
调用root指令的函数是在builtins.c中的root_func (char *arg, int flags)函数。第一个参数指定了哪个磁盘驱动器,如hd0是指第一块硬盘。第二个参数是分区号。然后在root_func()中它有调用了 real_root_func (char *arg, int attempt_mount)这个函数,并把参数arg传入real_root_func中并把attempt_mount设置为1。如果传入的arg是空的,那么就直接使用默认的驱动器。然后调用set_device()函数,从字符串中提取出驱动器号和分区号。测试如果所填写的驱动器号以及分区号读写没有问题,那么就在变量saved_partition和saved_drive中保存读取的这两个数据。然后返回。
这个函数主要的作用是为GRUB指定一个根分区。

6.4.2  Kernel指令
调用kernel指令的函数是在builtins.c文件中kernel_func (char *arg, int flags)函数。在这个函数中,首先进入一个循环,对传进来的参数进行解析。如果“--type=TYPE”参数被设置了,根据传入的参数设置 suggested_type变量赋予不用的操作系统的值。当没有别的参数被设置以后,则跳出循环。然后从参数中获得内核的文件路径,赋值给 mb_cmdline变量,然后通过load_image()函数载入核心,并且返回核心的类型。如果返回的核心类型是grub不支持得类型,即 kernel_type == KERNEL_TYPE_NONE返回1,成功则返回0。
这个函数主要的作用是,载入操作系统的核心。

6.4.3  Boot指令
调用boot指令的函数是在builtins.c文件中的boot_func (char *arg, int flags)函数。如果被载入的核心类型不是未知的,那么调用unset_int15_handler()函数,清除int15 handler。接着根据grub支持的不同的操作系统调用相应的启动程序。当启动的内核为BSD时调用bsd_boot ()函数,当启动的内核为LINUX时调用的函数时linux_boot()函数,当启动方式是链式启动方式时,调用chain_stage1()函数,当启动方式是多重启动时,调用multi_boot()函数。
这个函数主要的作用是,根据不同的核心类型调用相应的启动函数。

6.5 GRUB Kernel分析总结
通过分析,这个核心模块主要的工作是完成了GRUB这个微型操作系统的从磁盘到内存的装载和运行。在asm.s这个文件中提供了从汇编代码到C代码转换的接口,也是从这里开始正式载入了GRUB这个微型操作系统,可以说是GRUB运行的一个入口。同时,在asm.s文件中,对底层的方法用汇编语言进行了封装,方便在以后的C代码中调用。然后经过对BIOS进行一些初始化以后,正式进入了GRUB的主程序,即在stage2中的cmain入口。从此这个微型的操作系统开始正式运行。然后值得注意的是buildin这个数据结构,这个结构就是GRUB所有支持命令的数据结构。结构包括了一个用来识别的名字和一个用来调用的方法。GRUB通过接收外部输入的指令的方式,来间接的启动和装载其他的操作系统。




7 总结
7.1 GRUB源代码分析总结
通过对整个源代码的分析,大致上整个GRUB启动到引导其他操作系统分为如下几个步骤。
第一步 开机后,通过BIOS装载Stage1模块
第二步 通过Stage1模块装载Start模块
第三步 通过Start模块将整个GRUB的内核载入内存
第四步 通过GRUB的一个Shell的机制,作为一个小型的操作系统,来通过指令的方式装载不同的其他操作系统。
整个过程中GRUB启动的内存映象图如图7.1所示。
总体分析下来,首先感觉到GRUB整个代码在编码方面是非常严谨的,特别是整个程序的构架体现出了它灵活容易扩展的特性。主要体现在,它有别于普通的操作系统引导程序,在BIOS启动时就直接去装载特定操作系统的模块或者内核,而是通过BIOS的功能首先装载了一个属于自己的引导程序,也可以理解为 GRUB这个操作系统的引导程序。也就是说在引导任何用户的操作系统之前GRUB首先引导的是它的本身。这样为将来的扩展性打下了非常好的基础。
由于GRUB采用了类似SHELL的方式来解释并运行用户设计好的脚本或者接受用户输入的指令,并且为用户提供了非常好的底层的方法接口,所以用户可以非常灵活的组合指令来引导不同的操作系统,同时并不要求用户对底层的物理结构有非常高的了解,只要能使用提供的指令就可以来操作配置多重启动的多个操作系统。并且,GRUB提供了非常好的人机交互界面,可以通过预先设置的指令,或者菜单来显示让用户选择操作系统,相对来说就比较易用。
同时GRUB也提供了非常好的扩展性,这个也是由于GRUB特殊的结构保证的。首先GRUB是一个开源的项目,它的源代码是向所有的用户和开发着公开的,这样无论是用户的需求发生了变化,还是硬件的标准得到了提升,都能很快的在GRUB中得到实现。其次,GRUB的指令是非常容易添加的,用户只要了解了 GRUB指令的格式,就能非常容易的在GRUB原始指令的基础上添加属于自己的指令,这样的设计相当程度上提高了程序的模块化,耦合度比较低。





< by cnlas >

标题:Grub中实现实模式和保护模式切换部分的代码分析
作者:CNLAS
说明:都是些个人愚见。。。出错勿怪。。。还请各位大牛指出。。。= =
/* */中为原文件的注释
/*= =*/中为我加的注释
源文件位于\stage2\asm.S 版本为grub-0.97
提示:对win32asm爱好者一点小提示,grub的asm部分编译是由gcc中的gas来完成的。。。gas采用的asm是延续UNIX的 AT&T格式。。。不太了解的同学可以先google学习一下。。。XD


/*
* These next two routines, "real_to_prot" and "prot_to_real" are structured
* in a very specific way. Be very careful when changing them.
*
* NOTE: Use of either one messes up %eax and %ebp.
*/
ENTRY(real_to_prot)
.code16
/*=在Grub这个保护模式过程中。。。只加载了GDT。。。没有设定IDT。。。所以这个保护模式要在关闭可屏蔽中断的情况下运行=*/
cli
/* load the GDT register */
/*=向gdtr寄存器中写入GDT=注1=*/
DATA32 ADDR32 lgdt gdtdesc
/* turn on protected mode */
/*=修改cr0中的pe位切换到保护模式,CR0_PE_ON在shared.h中定义为0x1。。。即将PE位置1=*/
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
/* jump to relocation, flush prefetch queue, and reload %cs */
DATA32 ljmp $PROT_MODE_CSEG, $protcseg
/*
* The ".code32" directive only works in GAS, the GNU assembler!
* This gets out of "16-bit" mode.
*/
.code32
protcseg:
/* reload other segment registers */
/*=保护模式和实模式中段寄存器的作用是不同的。。。所以这里加载的是段选择子(descriptor)PROT_MODE_DSEG在 shared.h中定义为0x10。。。即GDT中保护模式下数据段的选择子=*/
movw $PROT_MODE_DSEG, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
/* put the return address in a known safe location */
/*=保存返回地址。。。STACKOFF在shared.h中定义为(0x2000 - 0x10)=*/
movl (%esp), %eax
movl %eax, STACKOFF
/* get protected mode stack */
/*=建立保护模式下的栈。。。注2=*/
movl protstack, %eax
movl %eax, %esp
movl %eax, %ebp
/* get return address onto the right stack */
/*=返回地址入栈=*/
movl STACKOFF, %eax
movl %eax, (%esp)
/* zero %eax */
xorl %eax, %eax
/* return on the old (or initialized) stack! */
ret

ENTRY(prot_to_real)
/* just in case, set GDT */
lgdt gdtdesc
/* save the protected mode stack */
/*=保存保护模式下的栈以便下次进入时再次使用=*/
movl %esp, %eax
movl %eax, protstack
/* get the return address */
movl (%esp), %eax
movl %eax, STACKOFF
/* set up new stack */
movl $STACKOFF, %eax
movl %eax, %esp
movl %eax, %ebp
/* set up segment limits */
/*=装入GDT中实模式下数据段的选择子。。。PSEUDO_RM_DSEG是GDT中一个规范段描述符。。。具体意义后面细讲。。。注3=*/
movw $PSEUDO_RM_DSEG, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
/* this might be an extra step */
ljmp $PSEUDO_RM_CSEG, $tmpcseg /* jump to a 16 bit segment */
tmpcseg:
.code16
/* clear the PE bit of CR0 */
/*=修改cr0的pe位。。。返回实模式。。。CR0_PE_OFF在shared.h中定义为0xfffffffe。。。即将PE位置0=*/
movl %cr0, %eax
andl $CR0_PE_OFF, %eax
movl %eax, %cr0
/* flush prefetch queue, reload %cs */
DATA32 ljmp $0, $realcseg
realcseg:
/* we are in real mode now
* set up the real mode segment registers : DS, SS, ES
*/
/* zero %eax */
/*=这里才是真正将实模式下数据段装入段寄存器=*/
xorl %eax, %eax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss
/* restore interrupts */
/*=已经回到实模式了。。。置IF为1开启可屏蔽中断。。。中断响应交由中断向量表来控制=*/
sti
/* return on new stack! */
DATA32 ret
.code32



注1:

DATA32 ADDR32 lgdt gdtdesc

gdtdesc是在asm.S最后定义的GDT选择子。。。由lgdt命令读入。。。该命令将48位存储器操作数读入gdtr寄存器。。。高双字为段基地址。。。低字是以字节为单位的段界限

/* this is the GDT descriptor */
gdtdesc:
.word 0x27 /* limit */ /*=定义了5个表项。。。每项8位。。共40位。。。=*/
.long gdt /* addr */

gdt的定义就在gdtdesc的上方。。。而且源代码中还很友好的给出了GDT表项的结构图

/*
* This is the Global Descriptor Table
*
* An entry, a "Segment Descriptor", looks like this:
*
* 31 24 19 16 7 0
* ------------------------------------------------------------
* | | |B| |A| | | |1|0|E|W|A| |
* | BASE 31..24 |G|/|0|V| LIMIT |P|DPL| TYPE | BASE 23:16 |
* | | |D| |L| 19..16| | |1|1|C|R|A| |
* ------------------------------------------------------------
* | | |
* | BASE 15..0 | LIMIT 15..0 |
* | | |
* ------------------------------------------------------------
*
* Note the ordering of the data items is reversed from the above
* description.
*/

.p2align 2 /* force 4-byte alignment */
gdt:

/*=GDT的0项。。。必须为空=*/
.word 0, 0
.byte 0, 0, 0, 0

/*=保护模式下代码段的描述符
由表项定义它是一个可执行可读非一致的有效的32位代码段。。。
基地址是00000000H。。。以4K字节为单位的段界限值是0FFFFFH
描述符特权级DPL=0
=*/
/* code segment */
.word 0xFFFF, 0
.byte 0, 0x9A, 0xCF, 0

/*=保护模式下数据段的描述符
由表项定义它是一个可读可写有效的32位数据段。。。
基地址是00000000H。。。以4K字节为单位的段界限值是0FFFFFH
描述符特权级DPL=0
=*/
/* data segment */
.word 0xFFFF, 0
.byte 0, 0x92, 0xCF, 0

/*=实模式下代码段的描述符
由表项定义它是一个可执行可读一致有效的16位代码段。。。
基地址是00000000H。。。以字节为单位的段界限值是0FFFFH
描述符特权级DPL=0
=*/
/* 16 bit real mode CS */
.word 0xFFFF, 0
.byte 0, 0x9E, 0, 0

/*=实模式下数据段的描述符
由表项定义它是一个可读可写有效的16位数据段。。。
基地址是00000000H。。。以字节为单位的段界限值是0FFFFH
描述符特权级DPL=0
=*/
/* 16 bit real mode DS */
.word 0xFFFF, 0
.byte 0, 0x92, 0, 0


注2:

movl protstack, %eax

1)protstact是在asm.S中定义个一个长整型。。。初始化值为PROTSTACKINIT

protstack:
.long PROTSTACKINIT

2)PROTSTACKINIT在shared.h中定义为(FSYS_BUF - 0x10)

#define PROTSTACKINIT (FSYS_BUF - 0x10)

3)FSYS_BUF在shared.h中定义为

#define FSYS_BUF RAW_ADDR (0x68000)

4)RAW_ADDR在shared.h的最上面是这么定义的。。。

/* Maybe redirect memory requests through grub_scratch_mem. */
#ifdef GRUB_UTIL
extern char *grub_scratch_mem;
# define RAW_ADDR(x) ((x) + (int) grub_scratch_mem)
# define RAW_SEG(x) (RAW_ADDR ((x) << 4) >> 4)
#else
# define RAW_ADDR(x) (x)
# define RAW_SEG(x) (x)
#endif

注3:

为何要在切换会实模式之前把一个规范化描述符选择子装入那些段寄存器呢?
这涉及到80x86架构的CPU内部结构问题。。。
从80286开始每个段寄存器都配有一个高速缓冲寄存器。。。。这些寄存器对程序员是不可见的。。。。当给段寄存器装入选择子的时候,处理器自动从描述符表中将对应的描述符信息装入与段寄存器对应的高速缓冲寄存器中。。。以后访问该段时只要从这里读信息就行了。。。不必再去描述符表中查询。。。在保护模式下。。。可以通过将相应的选择子装入段寄存器来刷新这些高速缓冲器

在实模式中这些高速缓冲寄存器仍然发挥作用。。。实模式中段基址是段值*16。。。每个段的32位段界限都是固定的0FFFFH。。。而且很多属性都是固定的。。。实模式中不存在GDT没有办法设置这些高速缓冲寄存器中的属性。。。只能沿用保护模式下的值。。。所以在从保护模式返回实模式之前。。。一定要加载一个合适的描述符选择子到相关的段寄存器。。。从而使得这些高速缓冲寄存器满足实模式的要求。

评分

参与人数 1无忧币 +5 收起 理由
+ 5 很给力!

查看全部评分

回复

使用道具 举报

发表于 2012-1-1 13:31:03 | 显示全部楼层

回复 #11 zhaohj 的帖子

好贴,收藏了。...
回复

使用道具 举报

发表于 2012-1-5 09:14:14 | 显示全部楼层

回复 #11 zhaohj 的帖子

这段文字太好了。非常感谢,我想有了这段文件,会有更多的人参与到开发和测试中。
收藏了……
回复

使用道具 举报

发表于 2012-1-5 19:08:09 | 显示全部楼层

回复 #11 zhaohj 的帖子

妙!建议移到一楼去。
回复

使用道具 举报

发表于 2012-3-17 20:14:53 | 显示全部楼层
真是可以向高手学到很多知识!
回复

使用道具 举报

发表于 2012-9-3 11:06:44 | 显示全部楼层
模模糊糊,菜芽不太懂
回复

使用道具 举报

发表于 2012-11-26 15:24:39 | 显示全部楼层
建议11楼移到一楼去。建议11楼移到一楼去。
回复

使用道具 举报

发表于 2012-12-26 13:13:20 | 显示全部楼层
楼主辛苦,一定要好好学习!!
回复

使用道具 举报

发表于 2013-5-18 22:02:01 | 显示全部楼层
资源很足
回复

使用道具 举报

发表于 2013-7-23 07:54:32 | 显示全部楼层
你提供的grub4dos下载下来的没有chinese中文目录和字库目录
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

小黑屋|手机版|Archiver|捐助支持本站|无忧启动 ( 闽ICP备05002490号-1 )

闽公网安备 35020302032614号

GMT+8, 2019-5-25 13:56

Powered by Discuz! X3.3

© 2001-2017 Comsenz Inc.

快速回复 返回顶部 返回列表