实验2前篇——X86内存管理

Posted yiye_01

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实验2前篇——X86内存管理相关的知识,希望对你有一定的参考价值。



    实验1“洋洋洒洒的写了那么多的内容,看起来很丰富;但其实没有进入主题。然而它们却是实现与理解操作系统的必要步骤与开端。纯粹的操作系统的理论总是让人无法身临其境的去理解而且实用性不是很强 ,而且我觉得操作系统的相关理论都是经验的总结,而无其他,实际中使用的往往是实践跑在前面,然后被总结为理论,最后被无休止的进行复制。这样确实一个好的软件发展模式,不断以代码的方式沉淀,从而让软件发展得更好。

    操作系统发展了很多年了,其中也包含了很多的内容,同时也可以被分割为相对对立的许多部分,如下图(来源于《操作系统精髓与设计原理(原书第6)》)所示:

    通过上图可以看出存储器管理处于所有操作系统模块的核心部位,与其他每个部分都有关系。当然对于程序来说,对内存的管理——分配与释放也是最常用的操作。所以本实验的主要内容是介绍操作系统内存的管理。

    本篇为实验2的前篇,主要是为了理解与学习实验2做好铺垫与准备。因为该实验需要自己动手写代码满足实验代码中的测试,所以需要能够完全理解实验的代码流程与相关的概念。本篇主要从两个方面介绍相关知识:1.X86系统的模式切换;2.系统内存的检测。

   一)X86系统的模式切换

    内存管理的基本策略是分页与分段,根据它们的特点,对于我们实现时的启示是:1.进程中的所有存储器访问都是逻辑地址,这些逻辑地址在运行时被动态地转换成物理地址;2.一个进程可以划分为许多块(页和段),在执行过程中,这些块不需要连续地位于主存中。

    对于如上描述的内存管理策略的启示,我们需要实现地址的动态转换与进程的分割,对于这些需求,在以处理器为核心的计算机构架中,对其的实现在硬件方面提供了必要的支持。而我们的实验环境为x8632位平台,所以我们先用如下图来描述对内存的管理的迁移与相关概念,然后进一步详细介绍其中的细节,当然根据的内容可以参考Intel软件开发手册;而且对于目前的计算机构架来说,只要有一款处理器实现了利于操作系统资源管理的方案,其他处理器必然有相类似或者更优的方案,所以我们对于它们的理解可以一通百通,从而举一反三。为此我们总结一张图来描述系统启动流程的模式切换:

    如上图所示,x86处理器一开机处于最简单的模式——8088模式,然后通过设置全局段表(GDT)与开启保护模式,进入到32位保护平坦模式;接着,为了支持操作系统的内存管理,我们需要进入32位分页模式,当然这个过程不是一蹴而就的,因为最开始操作系统还没有运行它的环境(它依赖于分页模式),所以需要有一个过渡阶段,搭建一个临时的分页模式4M,足够它运行即可,等到操作系统运行起来之后,它需要对系统内存进行管理,而且需要根据不同的进程动态的创建页表,所以最终才进入完整的32位分页模式。从如上的模式切换流程,可以看出系统运行,状态的切换是一个从简单到负责,逐步为操作系统最终运行,不断创造执行环境的过程。

    在如上的描述内存管理的过程中一个绕不开的概念就是地址转换——将程序使用的逻辑地址转换成硬件访问的物理地址。而对于X86的处理器有3个类似的概念——逻辑地址,线性地址,物理地址;而它们的关系与区别为逻辑地址为进程运行时访问的地址(程序编译时生成与链接的使用的地址),线性地址为逻辑地址通过分段机制转换之后的地址,物理地址为程序运行时实际访问的内存地址,当然也是线性地址经过分页机制转换之后的地址。

为了更具体而详细的描述如上过程,我们需要去仔细的解剖x86处理器的分页与分段模式,而获取这些信息最简单而直接的方式是参考intel开发手册的相关章节。对于分段与分页的机制详细描述如下:

   1)x86的内存管理之分段——详细参考第3卷第3

   对于分段的实现方式,x86的处理为设置一个段表用于表示每个段的长度,基地址以及访问权限,而该地址段表被段寄存器(它对程序是不可见的,)所指向,所以x86使用分段机制,其实是根据段选择符去读段寄存器去指向的数组(段表)项,从而进行地址转换,详细的见如上模式转换图中32位保护平坦模式地址访问部分。

   针对如上的管理机制,得出初始化分段机制步骤如下:

  1.设置段表与段寄存器内容——在内存中分配一段内存初始化它们

  2.加载段表到段寄存器——LGDT &seg_table_reg_content

  3.跳转到分段模式的地址中执行代码——LJMP &code_at_seg_mode

  4.设置段选择符——CSDSSSESFSGS

  A)段表描述图:

    如上图所示,段表项长度为8,段表首地址也为8Byte对齐,而段表寄存器主要为两部分——段表的长度(以字节为单位,其实为段表长度-1),段表所在地址。其中所描述的地址为绝对物理地址。

    B)段表项——段描述符

   段描述符包含了段的基地址,段长度,段的访问权限等内容。详细介绍见第3卷3.4.5节。

   C)段选择符

    段选择符包含了访问端的索引为8的倍数,访问权限。其实是8088所熟知的段寄存器为CSDSESSSFSGS等。

    2)x86的内存管理之分页——详细参考第3卷第4章:

    对于分页机制的实现,x86的处理为将线性地址进行分割,然后通过设置的页目录寄存器(CR3)所指向数组(页目录)进行转换。如下为我们使用的32位分页模式的访问方式为例进行详细描述:

   如上图所示,将32位线性地址进行3部分分割——页目录索引(10位),页表索引(10位),偏移量(12位),地址转换过程为首先查询CR3指向的页目录地址,通过页目录索引得到,页表地址,再通过页表索引得到物理基地址,然后与偏移量组合成物理地址。

   对于x86处理器来说,它所支持的分页模式如下表所示:

    如上图所示,分页模式有3种,页帧的大小支持4K/4M的,而我们实验使用的是32-bit且页帧为4KByte的模式。

    A)页表项与页目录项:

    如上图所示,页表与页目录的首地址需要4KByte对齐,其中也包含了页表与页目录的访问权限。其中会常用到的权限为:0bit表示是否在内存中,1bit表示是否只读,2bit表示是否用户空间可访问。

   二)系统内存的检测

    在理解了x86对内存管理的分页与分段机制之后,我们需要对内存进行管理,首先需要检测系统能够提供给我们的物理空间有多大?

    为了读取x86系统的信息,目前只有通过bios提供的服务来读取。而为了考虑兼容性x86的内存分为两部分——低1M内存空间,扩展内存。所以要检测系统的内存,需要读取这两部分的内存空间大小,这里的内存大小为系统可以用来作为RAM来使用的(内核与应用程序能使用的空间)

。详细的介绍可以参考:http://wiki.osdev.org/Detecting_Memory_(x86)。在实验2的内存检测函数中,它使用的是通过IO口读取CMOS所配的内存大小,这是不准的,所以我们需要修改之,使用中断(INT 0x15 EAX=0xE820)的方式读取所有的内存。

   A)1M内存检测:

   最简单方式使用中断0x12,默认值为640K,正常读取也为这个值。也可以使用CMOS读取。详情见源代码。

   B)扩展内存检测:

   内存检测协议——流程如下:

     内存检测内容——内存映射段的描述,以c语言数据结构的模式:

typedef struct SMAP_entry  	
uint32_t BaseL; // base address uint64_t	
uint32_t BaseH;	
uint32_t LengthL; // length uint64_t	
uint32_t LengthH;	
uint32_t Type; // entry Type	
uint32_t ACPI; // extended
 __attribute__((packed)) SMAP_entry_t;

     如上所示:64位开始地址,64位段长度,段类型,ACPI段(当映射长度为24才有)

     段类型如下:

 

类型

功能

1

正常RAM使用

2

保留内存——不能被使用

3

ACPI内存

4

ACPI NVS内存

5

坏的内存区

    C)代码实现

    在我实现的内存检测代码中有两种方式:1.将内存检测的intel汇编代码,经过转换成at&t汇编直接生成的代码;2.移植linux内核的内存检测代码。

    (1)将内存检测的intel汇编代码,先用nasm汇编生成elf文件,然后再用objdump进行反汇编,接着去掉反汇编产生的机器码与地址,最后再修改相关的地址与相关部分。如上过程我已经用脚本实现了,方便处理。

     实现的intel汇编如下:

segment .text
global do_e820
; use the INT 0x15, eax= 0xE820 BIOS function to get a memory map
; inputs: es:di -> destination buffer for 24 byte entries
; outputs: bp = entry count, trashes all registers except esi
do_e820:
	xor ebx, ebx		; ebx must be 0 to start
	xor bp, bp		; keep an entry count in bp
	mov edx, dword 0x0534D4150	; Place "SMAP" into edx
	mov eax, 0xe820
	mov [es:di + 20], dword 1	; force a valid ACPI 3.X entry
	mov ecx, 24		; ask for 24 bytes
	int 0x15
	jc short .failed	; carry set on first call means "unsupported function"
	mov edx, 0x0534D4150	; Some BIOSes apparently trash this register?
	cmp eax, edx		; on success, eax must have been reset to "SMAP"
	jne short .failed
	test ebx, ebx		; ebx = 0 implies list is only 1 entry long (worthless)
	je short .failed
	jmp short .jmpin
.e820lp:
	mov eax, 0xe820		; eax, ecx get trashed on every int 0x15 call
	mov [es:di + 20], dword 1	; force a valid ACPI 3.X entry
	mov ecx, 24		; ask for 24 bytes again
	int 0x15
	jc short .e820f		; carry set means "end of list already reached"
	mov edx, 0x0534D4150	; repair potentially trashed register
.jmpin:
	jcxz .skipent		; skip any 0 length entries
	cmp cl, 20		; got a 24 byte ACPI 3.X response?
	jbe short .notext
	test byte [es:di + 20], 1	; if so: is the "ignore this data" bit clear?
	je short .skipent
.notext:
	mov ecx, [es:di + 8]	; get lower uint32_t of memory region length
	or ecx, [es:di + 12]	; "or" it with upper uint32_t to test for zero
	jz .skipent		; if length uint64_t is 0, skip entry
	inc bp			; got a good entry: ++count, move to next storage spot
	add di, 24
.skipent:
	test ebx, ebx		; if ebx resets to 0, list is complete
	jne short .e820lp
.e820f:
	mov [mmap_ent], bp	; store the entry count
	clc			; there is "jc" on end of list to this point, so the carry must be cleared
	ret
.failed:
	stc			; "function unsupported" error exit
	ret
mmap_ent dd 0x8004

    转换脚本——见附件change_asm2S.sh:

if [ -z "$1" ];then
	echo "input the intel\\'s asm file!!!"
fi

#nasm 编译intel汇编为elf文件
nasm -o $1.elf -f elf $1
#反汇编elf文件
objdump -M 16 -d $1.elf > $1.elf.dump
#清除地址信息与机器码 clear_dump_addr_info.pl脚本见附件
./clear_dump_addr_info.pl $1.elf.dump
rm -fr $1.elf $1.elf.dump 
    生成之后的AT&T汇编:
.section .text
.global do_e820
.code16
do_e820:
	xorl    %ebx,%ebx
	xorw    %bp,%bp
	movl    $0x534d4150,%edx
	movl    $0xe820,%eax
	movl   $0x1,%es:0x14(%di)
	
	mov    $0x18,%ecx
	int    $0x15
	jb do_e820.failed
	movl    $0x534d4150,%edx
	cmp    %edx,%eax
	jne do_e820.failed
	test   %ebx,%ebx
	je do_e820.failed
	jmp do_e820.jmpin
do_e820.e820lp:
	mov    $0xe820,%eax
	movl   $0x1,%es:0x14(%di)
	
	mov    $0x18,%ecx
	int    $0x15
	jb do_e820.e820f
	mov    $0x534d4150,%edx
do_e820.jmpin:
	jcxz do_e820.skipent
	cmp    $0x14,%cl
	jbe do_e820.notext
	testb  $0x1,%es:0x14(%di)
	je do_e820.skipent
do_e820.notext:
	mov    %es:0x8(%di),%ecx
	or     %es:0xc(%di),%ecx
	je do_e820.skipent
	inc    %bp
	add    $0x18,%di
do_e820.skipent:
	test   %ebx,%ebx
	jne do_e820.e820lp
do_e820.e820f:
	movl	mmap_ent,%ebx
	mov    %bp,(%ebx)
	clc    
	ret    
do_e820.failed:
	stc    
	ret    
mmap_ent:
	.int 0x8004	

   (1)移植linux内核的内存检测代码:

    当将linux内存检测的相关代码移植到程序中出现了一个可以预见的问题,编译出来的bin档大小会大于512Byte,而且读取出来的信息如何给我们编译的操作系统。

    解决第一个问题的方法,将移植的linux内存检测的代码放到MBR之后的空间,然后读取,再加载的内存中运行之。

    解决第二个问题的方法,将读取的内存信息加载固定的地址(约定地址),然后由编译之后的操作系统去读取即可。

     根据如上两方面的分析,我们需要采取类似linux内核的启动机制,首先位于MBR的代码只作加载内核引导桥代码到内存中运行之,然后由内核引导桥代码进行加载内核,然后再运行之。为此,我们也需要把桥代码读取的内存信息以固定地址与格式作为约定,被操作系统访问。具体如下图所示:

    在移植linux代码过程中有如下几个技术点需要理解:

    1.以寄存器的方式进行参数传递实现,如下两种方式:

     编译时添加编译参数: -mregparm=3

    声明函数属性为:__attribute__((regparm(3))) 

    这样做的好处是提高参数传递效率,加快程序运行。为某些程序调用提供接口。

    参数对应方式,在x86平台上:

    Arg1-->EAX

    Arg2-->ECX

    Arg3-->EDX

    2.在汇编代码中嵌入机器码:

    好处是手动修改操作指令的操作码,这样可以使需要常量的指令(比如intljmp等)使用变量,从而更灵活的处理指令。

   

.code16
	.section ".inittext","ax"
	.globl	intcall
	.type	intcall, @function
intcall:
	/* Self-modify the INT instruction.  Ugly, but works.将传入的参数1作为中断号,修改3f处的操作数支持动态修改int指令的操作数 */
	cmpb	%al, 3f
	je	1f
	movb	%al, 3f
	jmp	1f		/* Synchronize pipeline */
1:
	/* Save state */
	pushfl
	pushw	%fs
	pushw	%gs
	pushal

	/* Copy input state to stack frame */
	subw	$44, %sp
	movw	%dx, %si
	movw	%sp, %di
	movw	$11, %cx
	rep; movsd

	/* Pop full state from the stack */
	popal 
	popw	%gs
	popw	%fs
	popw	%es
	popw	%ds
	popfl

	/* Actual INT 用机器码定义中断操作,然后通过代码修改操作数实现动态修改int 中断号*/
	.byte	0xcd		/* INT opcode */
3:	.byte	0

	/* Push full state to the stack */
	pushfl
	pushw	%ds
	pushw	%es
	pushw	%fs
	pushw	%gs
	pushal

	/* Re-establish C environment invariants */
	cld
	movzwl	%sp, %esp
	movw	%cs, %ax
	movw	%ax, %ds
	movw	%ax, %es

	/* Copy output state from stack frame */
	movw	68(%esp), %di	/* Original %cx == 3rd argument */
	andw	%di, %di
	jz	4f
	movw	%sp, %si
	movw	$11, %cx
	rep; movsd
4:	addw	$44, %sp

	/* Restore state and return */
	popal
	popw	%gs
	popw	%fs
	popfl
	retl
	.size	intcall, .-intcall

    3.内存管理的模式——分段与分页与ljmp的地址只能是绝对物理地址。

一叶说:操作系统的主要任务就是资源管理与任务调度,当然也可以换句话说是,为处理器与存储器写驱动,为应用软件提供软件接口;所以从这个角度来理解操作系统,需要对处理器有很好的理解才行,而处理器对缓存,内存管理,中断管理,总线管理,io管理都要有一定的规范,这就只有从处理器制造商获取第一手资料,然后进行反复查阅。而目前我们实现的操作系统,是在unix的经典模型下进行开发,可以说unix为操作系统的实现提供了设计规范,所以就需要我们能够去理解unix的实现,从而以此作为基础,理解现实中的操作系统。而在实现代码过程中,我们参考了已经存在的代码。通过学习经典的代码,能够提高我们的编程技巧与对系统的理解。



以上是关于实验2前篇——X86内存管理的主要内容,如果未能解决你的问题,请参考以下文章

实验3前篇——X86的中断管理

实验3前篇——X86的中断管理

80x86的保护模式

X86段机制

ucore实验

x86 构架的 Arduino 开发板Intel Galileo