《30天自制操作系统》第9天

第九天 内存管理

1.整理源文件

这一节只是进行了代码整理,把鼠标键盘相关的内容转移到了特定的文件里。


2.内存容量检查(1)

要做内存管理,首先得知道内存的容量,怎么知道内存的容量呢?BIOS可以告诉我们答案。但使用BIOS稍微有点麻烦,于是,作者决定自己写程序检查内存容量。

内存检查程序主要有以下几步:

  1. 检查CPU是386芯片还是486芯片。若为486芯片,则禁止cache。(386没有cache)

  2. 不断向内存写入然后读取数据。如果写入和读取的内容一样,则内存连接正常。

怎么判断CPU是386还是486呢?这里需要用到状态寄存器。状态寄存器的第18位AC位(Alignment Check,对齐检查),在386中即使将它设置为1,它也会变成0,而486中不会出现这种情况。于是,根据这个特点,我们就可以判断出CPU是386还是486的,然后再确定需不需要禁止缓存。相关的代码如下所示。

unsigned int memtest(unsigned int start, unsigned int end)
{
    char flg486 = 0;
    unsigned int eflg, cr0, i;
​
    /* 确认CPU是386还是486以上 */
    eflg = io_load_eflags();
    eflg |= EFLAGS_AC_BIT;              /* 对AC位置1 */
    io_store_eflags(eflg);
    eflg = io_load_eflags();
    if ((eflg & EFLAGS_AC_BIT) != 0)    /* 如果是386,即使设定AC=1,AC的值还会自动回到0 */
    {
        flg486 = 1;
    }
    eflg &= ~EFLAGS_AC_BIT;             /* 将AC位置0 */
    io_store_eflags(eflg);
​
    if (flg486 != 0)
    {
        cr0 = load_cr0();
        cr0 |= CR0_CACHE_DISABLE;       /* 禁止缓存 */
        store_cr0(cr0);
    }
​
    i = memtest_sub(start, end);
​
    if (flg486 != 0)
    {
        cr0 = load_cr0();
        cr0 &= ~CR0_CACHE_DISABLE;      /* 允许缓存 */
        store_cr0(cr0);
    }
​
    return i;
}

其中,加载和保存控制寄存器0(CR0)的函数定义如下。

_load_cr0:          ; int load_cr0(void);
    MOV EAX, CR0
    RET
​
_store_cr0:         ; void store_cr0(int cr0);
    MOV EAX, [ESP + 4]
    MOV CR0, EAX
    RET

接下来是第二步,向内存读写数据,检查内存是否正确。

unsigned int memtest_sub(unsigned int start, unsigned int end)
{
    unsigned int i, *p, old, pat0 = 0xaa55aa55, pat1 = 0x55aa55aa;
    for (i = start; i <= end; i += 4)
    {
        p = (unsigned int *) i;
        old = *p;               /* 先记住修改前的值 */
        *p = pat0;              /* 试写 */
        *p ^= 0xffffffff;       /* 反转 */
        if (*p != pat1)         /* 检查反转结果 */
        {
not_memory:
            *p = old;
            break;
        }
        *p ^= 0xffffffff;       /* 再次反转 */
        if (*p != pat0)         /* 检查值是否恢复 */
        {
            goto not_memory;
        }
        *p = old;               /* 恢复为修改前的值 */
    }
    return i;
}

可以看到,这段代码很简单,不断对内存地址的值进行翻转,若翻转后的值与预期的不同,则说明内存有问题。这里还要思考一个问题,上面函数的参数start和end应该赋予什么值?为什么?

上面内存检查的代码看起来不错,但是运行速度太慢了,毕竟它对每个地址都进行检查。作者让检查程序偷偷懒,让它能够执行得更快,代码如下所示。

unsigned int memtest_sub(unsigned int start, unsigned int end)
{
    unsigned int i, *p, old, pat0 = 0xaa55aa55, pat1 = 0x55aa55aa;
    for (i = start; i <= end; i += 0x1000)
    {
        p = (unsigned int *) (i + 0xffc);
        old = *p;               /* 先记住修改前的值 */
        *p = pat0;              /* 试写 */
        *p ^= 0xffffffff;       /* 反转 */
        if (*p != pat1)         /* 检查反转结果 */
        {
not_memory:
            *p = old;
            break;
        }
        *p ^= 0xffffffff;       /* 再次反转 */
        if (*p != pat0)         /* 检查值是否恢复 */
        {
            goto not_memory;
        }
        *p = old;               /* 恢复为修改前的值 */
    }
    return i;
}

修改后,检查程序每4K地址只检查4字节地址,速度自然是比之前快多了。另外,这段代码只是为了检查内存的容量,不要记错了它的功能。

接下来改一改HariMain就能运行了,将内存检查放在死循环之前。

    i = memtest(0x00400000, 0xbfffffff) / (1024 * 1024);    /* 0x400000以前的内存已被使用,无需检查 */
    sprintf(s, "memory %dMB", i);
    putfonts8_asc(binfo->vram, binfo->scrnx, 0, 32, COL8_FFFFFF, s);
 ​
    while (1)

运行的显示如下。

《30天自制操作系统》第9天_第1张图片

这里显示内存有3G内存,而系统内存实际只有32M,这明显不对,是什么地方出错了?


3.内存内容检查(2)

上一节为什么会出错呢?这全都是编译器的锅。

在从C语言转换成汇编的过程中,编译器发现内存检查过程中没有什么实际的改变,就把这段给优化掉了。比如,首先给一个地址存入0xaa55aa55,翻转后变成0x55aa55aa,然后再次反转变成0xaa55aa55,从结果而言并没有发生什么变化。

为了防止代码被优化,采用汇编代码编写内存检查的函数。

_memtest_sub:   ; unsigned int memtest_sub(unsigned int start, unsigned int end)
    PUSH    EDI                 ; (保存EBX、ESI、EDI中的数据)
    PUSH    ESI
    PUSH    EBX
    MOV ESI, 0xaa55aa55         ; pat0 = 0xaa55aa55;
    MOV EDI, 0x55aa55aa         ; pat1 = 0x55aa55aa;
    MOV EAX, [ESP + 12 + 4]     ; i = start; (由于栈中增加了3个元素,地址要多加12)
 
mts_loop:
    MOV EBX, EAX
    ADD EBX, 0xffc              ; p = (i + 0xffc);
    MOV EDX, [EBX]              ; old = *p;
    MOV [EBX], ESI              ; *p = pat0;
    XOR DWORD [EBX], 0xffffffff ; *p ^= 0xffffffff;
    CMP EDI, [EBX]              ; if (*p != pat1) goto fin;
    JNE mts_fin
    XOR DWORD [EBX], 0xffffffff ; *p ^= 0xffffffff;
    CMP ESI, [EBX]              ; if (*p != pat0) goto fin;
    JNE mts_fin
    MOV [EBX], EDX              ; *p = old;
    ADD EAX, 0x1000             ; i += 0x1000;
    CMP EAX, [ESP + 12 + 8]     ; if (i <= end) goto mts_loop;
    JBE mts_loop
    POP EBX
    POP ESI
    POP EDI
    RET
 ​
mts_fin:
    MOV [EBX], EDX              ; *p = old;
    POP EBX
    POP ESI
    POP EDI
    RET

这段汇编代码的逻辑与之前C语言代码是一样的,相信大家很容易理解。

运行结果如下。

《30天自制操作系统》第9天_第2张图片


4.挑战内存管理

相信学过操作系统的人都知道内存管理的重要性,毕竟没了内存管理,操作系统怎么知道哪块内存能用哪块内存不能用?

这个操作系统比较小,没有虚拟存储器,也没有什么页面置换算法,更没有什么分页分段,直接采用可变长度分配这种简单粗暴的方法。作者认为分页所需的管理表会占用不少空间就放弃了。

我们对每段空间的管理只记录两个信息:起始地址和长度。而为了方便管理,把这两个数据放在一个结构体中。

struct FREEINFO     /* 可用信息 */
{
    unsigned int addr, size;
};
 ​
struct MEMMAN       /* 内存管理 */
{
    int frees;
    struct FREEINFO free[1000];
};

在MEMMAN结构体中,frees是已使用的FREEINFO结构体的数量,并且还有一个大小为1000的FREEINFO结构体数组。

对于MEMMAN结构体而言,有增删改移位等操作。在了解这些操作的实现之前,我们要知道内存的分配算法是哪种,这个操作系统采用的是首次适应算法,首次适应算法的空闲分区要按地址由低到高进行排序,从空闲分区表的第一个表目起查找该表,把最先能够满足要求的空闲区分配出去。

1.改变。只是改动addr和size的值。

《30天自制操作系统》第9天_第3张图片

2.增加。内存中,从0x400000开始的长度为0x7c00000的内存块未被使用,首先把这块内存添加到MEMMAN结构体中。

struct MEMMAN memman;
memman.frees = 1;
memman.free[0].addr = 0x00400000;
memman.free[0].size = 0x07c00000;

3.增加并移位。这里的移位操作使用的是插入排序算法。如下图所示。

《30天自制操作系统》第9天_第4张图片

4.删除。申请的大小与空闲块大小一致。

《30天自制操作系统》第9天_第5张图片

5.合并。释放的内存块与前后地址的内存块合并。

《30天自制操作系统》第9天_第6张图片

对于内存空闲块的操作就这5种操作,我们只需根据这五种操作编写代码即可。

为了使得功能更完善,我们要为MEMMAN结构体添加一些东西。

#define MEMMAN_FREES    4090        /* 大约是32KB */
 
struct FREEINFO     /* 可用信息 */
{
    unsigned int addr, size;
};
 
struct MEMMAN       /* 内存管理 */
{
    int frees, maxfrees, lostsize, losts;
    struct FREEINFO free[MEMMAN_FREES];
};

使用可变长度的内存分配不可避免地会产生一些内存碎片,losts用于记录碎片地数量,lostsize用于记录碎片的总大小。

下面介绍内存相关的函数。

void memman_init(struct MEMMAN *man)
{
    man->frees = 0;         /* 可用信息数目 */
    man->maxfrees = 0;      /* 用于观察可用状况:frees的最大值 */
    man->lostsize = 0;      /* 释放失败的内存的大小总和 */
    man->losts = 0;         /* 释放失败次数 */
}
 ​
unsigned int memman_total(struct MEMMAN *man)   /* 报告剩余内存大小的合计 */
{
    unsigned int i, t = 0;
    for (i = 0; i < man->frees; i++)
    {
        t += man->free[i].size;
    }
    return t;
}

memman_init初始化MEMMAN结构体成员,memman_total计算出剩余内存总大小,这些都比较简单。

unsigned int memman_alloc(struct MEMMAN *man, unsigned int size)    /* 分配 */
{
    unsigned int i, a;
    for (i = 0; i < man->frees; i++)
    {
        if (man->free[i].size >= size)  /* 找到了足够大的内存 */
        {
            a = man->free[i].addr;
            man->free[i].addr += size;
            man->free[i].size -= size;
            if (man->free[i].size == 0) /* 如果free[i]变成了0,就减掉一条可用信息 */
            {
                for (; i < man->frees; i++)
                {
                    man->free[i] = man->free[i + 1];    /* 把之后的信息往前挪 */
                }
                man->frees--;
            }
            return a;
        }
    }
    return 0;   /* 没有可用空间 */
}

从名字就可以看出memman_alloc是内存分配函数,采用的是首次适应算法,也算简单。

int memman_free(struct MEMMAN *man, unsigned int addr, unsigned int size)   /* 释放 */
{
    int i, j;
    /* 为便于归纳内存,将free[]按照addr的顺序排列 */
    /* 所以,仙居定应该放哪儿 */
    for (i = 0; i < man->frees; i++)
    {
        if (man->free[i].addr > addr)
        {
            break;
        }
    }
    /* free[i - 1].addr < addr < free[i].addr */
    if (i > 0)  /* 若前面有可用内存 */
    {
        if (man->free[i - 1].addr + man->free[i - 1].size == addr)  /* 若与前面的可用内存相邻,则归纳在一起 */
        {
            man->free[i - 1].size += size;
            if (i < man->frees) /* 若后面也有可用内存内存 */
            {
                if (addr + size == man->free[i].addr)   /* 若与后面的可用内存相邻,则归纳在一起 */
                {
                    man->free[i - 1].size += man->free[i].size;
                    man->frees--;
                    for (; i< man->frees; i++)  /* 删除free[i] */
                    {
                        man->free[i] = man->free[i + 1];
                    }
                }
            }
            return 0;   /* 成功完成 */
        }
    }
    /* 若不能与前面的内存归纳在一起 */
    if (i < man->frees) /* 若后面也有可用内存内存 */
    {
        if (addr + size == man->free[i].addr)   /* 若与后面的可用内存相邻,则归纳在一起 */
        {
            man->free[i].addr = addr;
            man->free[i].size += size;
            return 0;   /* 成功完成 */
        }
    }
    /* 既不能与前面内存归纳在一起,也不能与后面内存归纳在一起 */
    if (man->frees < MEMMAN_FREES)
    {
        for (j = man->frees; j > i; j--)    /* 把信息往后移,腾出free[i] */
        {
            man->free[j] = man->free[j - 1];
        }
        man->frees++;
        if (man->maxfrees < man->frees)
        {
            man->maxfrees = man->frees;
        }
        man->free[i].addr = addr;
        man->free[i].size = size;
        return 0;   /* 成功完成 */
    }
    /* 信息个数已达到最大 */
    man->losts++;
    man->lostsize += size;  /* 失败 */
    return -1;
}

内存释放的算法逻辑可以看看上面的图,理解起来应该不会太困难。

最后还需修改一下HariMain,这里不做展示了,直接上结果图。

《30天自制操作系统》第9天_第7张图片

书上的内容到此结束。在此,给大家一点小建议,可以尝试自己实现一个内存管理算法,当使用的内存块很少时,书中的内存管理算法不算慢,但是一旦多起来(达到一两千的量级),所需时间飙升(毕竟时间复杂度为O(n2))。试试采用双向链表或其他的数据结构,把时间复杂度减至O(n)的水平。


其实已经不准备写关于《30天自制操作系统》的博客了,因为这本书的代码不是很系统,但是有人问我还有后续吗,而且我觉得内存管理比较有用,就写了这一篇博客,后面还有任务切换、异常处理等内容也有趣,但我应该不会再写了。现在正在看《一个64位操作系统的设计与实现》,我也很推荐看这本书,代码借鉴于Linux,对以后看Linux源码或其他操作系统的源码也有帮助。

你可能感兴趣的:(操作系统,操作系统)