哈工大操作系统实验7 地址映射与共享(万字详解)

哈工大操作系统实验7 地址映射与共享

该篇文章是哈工大操作系统实验7——地址映射与共享的完成笔记,其中包含了详细的步骤和相关代码,并有截图说明。实验内容都成功通过了,但是因为内容较多,记录中难免会有疏忽,如有发现错误,欢迎大家留言和我联系。

不知不觉又肝了两周多,对Linux0.11内存管理的细节有了更多的理解,欢迎大家一键三连:点赞、关注加收藏,感谢大家的支持。

理论知识

实验内容推荐大家学习对应的视频课程:

  • L20 内存使用与分段
  • L21 内存分区与分页
  • L22 段页结合的实际内存管理
  • L23 请求调页内存换入
  • L24 内存换出

以及《注释》第5.3.1~5.3.4节Linux内核对内存的管理和使用:介绍了逻辑地址线性地址物理地址这些地址的概念,也详细说明了地址翻译的过程。

另外我推荐阅读:

  • 《注释》第13章内存管理:更加详细介绍了Linux中内存管理。此外还有linux/mm/memory.c、linux/mm/page.s代码的详细注释。
  • 《注释》第12.16节exec.c程序:这章介绍了shell程序是如何执行ls、pwd等命令的,其中change_ldt方法中演示了如何把物理内存映射到进程线性地址的。
  • 《UNIX环境高级编程中文第三版》第15.9共享存储:主要介绍了使用内存进行共享的相关函数shmget、shmat、shmdt、shmctl。当然网上也有很多相关教程可供参考,只是如果能系统阅读肯定是更好的。

实验内容:

image

以下根据实验内容逐步实现。

Bochs调试工具跟踪地址映射

实验内容从6.1~6.7节有说明了这个步骤,照着一步步做下来就可以了。

做完之后才发现其实最本质的目的就是要找出逻辑地址线性地址物理地址,关于这个几个地址在《注释》书籍5.3.2章节有介绍,5.3.3有一个图更加生动形象的描述了这个过程:

image

于是本文就从这个思路出发,分成三步进行实现。和实验内容基本相差无异,只是换了个维度。

  1. 查找变量i的逻辑地址;
  2. 查找变量i的线性地址;
  3. 查找变量i的物理地址。

test.c的代码:

#include <stdio.h>

int i = 0x12345678;
int main(void)
{
    printf("The logical/virtual address of i is 0x%08x", &i);
    fflush(stdout);
    while (i)
        ;
    return 0;
}

查找变量i的逻辑地址

使用Bochs汇编调试功能查找对应的汇编代码,这样就能找到变量i的逻辑地址了。

image

cmp这行汇编代码的意思将 ds:0x3004 存储的值和0进行比较,其实就是 while(i) 这行代码的含义,ds:0x3004 存储的就是i值。

实验中有提到 0x00003004 这个值在任何人的机器上都是一样的,那么为什么呢?

因为 0x00003004 表示了变量i在代码段中的偏移位置,不管在哪个机器上,只要编译器没有变化,编译出来的代码就是一样的,偏移位置自然不会变化。只要在 int i = 0x12345678; 这行代码前加一个变量声明,再试试看能明白了。

#include <stdio.h>

int a = 0;      // 新增一个变量声明
int i = 0x12345678;
int main(void)
{
    printf("The logical/virtual address of i is 0x%08x", &i);
    fflush(stdout);
    while (i)
        ;
    return 0;
}

下一步就是要找出 ds:0x3004 这个逻辑地址对应的线性地址。

查找变量i的线性地址

实验内容说明了每一步的流程,具体原理我画了个图,更加容易理解。

image

在保护模式下,ds存储的是选择子,ds选择子的值为:0x0017=0000000000010111,对应的是LDT表中2号项,因此需要先找出LDT表,然后再定位到2号项

查找LDT表:思路就是通过ldtr存储的选择子在GDT中找到对应项。

image

定位到2号项:

image

根据2号项的值就可以计算出其线性基地址为:0x10000000,加上偏移地址 0x3004 就是线性地址:0x10003004,使用calc进行验证。

image

下一步就是要找出 0x10003004 这个线性地址对应的物理地址。

查找变量i的物理地址

根据分页基址中线性地址的存储格式,可以算出线性地址中的页目录号、页表号和页内偏移,它们分别对应了 32 位线性地址的 10 位 + 10 位 + 12 位,所以 0x10003004 的页目录号是64页号3页内偏移是4。核心原理是这张图:

image

后面的思路就是查找出页目录项、页表项,最后再通过页内偏移即可得到最终物理地址。

查找出页目录项:

image

查找出页表项和计算最终物理地址:

image

最后通过直接修改这个物理内存存储的值进行验证:

image

Ubuntu上用共享内存做缓冲区

在Linux0.11上实现共享内存前,可以先在Ubuntu上写一个,这样对共享内存的使用就能有一个大概的认识,方便后面在Linux0.11实现共享内存的功能。在编写程序时如能了解内存共享相关的系统调用shmget、shmat、shmdt、shmctl函数,那么大有裨益。这块的知识可以参考《UNIX环境高级编程中文第三版》第15.9共享存储。

共享内存的原理可参考如下图解:

image

1)实现producer.c

将上一章的程序拿来进行修改,将其中文件相关的部分换成内存即可,参考如下代码。另外还有一点不同就是因为生产者和消费者进行了分离,生产者就没有必要再fork进程了。其他参考代码中的注释。

#include <unistd.h>    /* 提供对 POSIX 操作系统 API 的访问,例如 fork(), pipe(), read(), write(), close() 等 */
#include <sys/types.h> /* 提供数据类型,例如 pid_t */
#include <stdio.h>     /* 提供输入输出函数,例如 printf() */
#include <stdlib.h>    /* 提供通用工具函数,例如 exit(), malloc(), free() */
#include <fcntl.h>     /* 提供对文件控制选项的访问,例如 open(), lseek() */
#include <sys/stat.h>  /* 提供对文件状态标志和模式的访问(但此代码中未直接使用) */
#include <semaphore.h> /* 提供对 POSIX 信号量的访问,例如 sem_open(), sem_wait(), sem_post(), sem_unlink() */
#include <sys/wait.h>  /* 提供等待进程结束的函数,例如 wait(), waitpid() */
#include <sys/ipc.h>   /* 包含用于进程间通信(IPC)的接口定义 */
#include <sys/shm.h>   /* 包含共享内存(shared memory)相关的接口定义 */

#define M 530          /* 打出数字总数 */
#define N 5            /* 消费者进程数 */
#define AVG M / N      /* 每个消费者消费数字平均数 */
#define BUFSIZE 10     /* 缓冲区大小 */
#define SHM_KEY 0x1234 /* 共享内存的key,用来在生产者和消费者进程之间进行识别 */
#define SHM_SIZE 1024  /* 共享内存的大小,实际11就可以了,1024看起来更帅气一些 */
int main()
{
    sem_t *empty, *full, *mutex; /* 3个信号量 */
    int shm_id;                  /* 共享内存id */
    int *shm_addr;               /* 共享内存在当前进程虚拟地址空间的起始地址 */
    int i;                       /* 循环变量和子进程计数器 */
    int buf_in = 0;              /* 记录上次写入缓冲区位置 */

    /* 1. 创建信号量 */
    empty = sem_open("empty", O_CREAT | O_EXCL, 0644, BUFSIZE); /* 剩余空间信号量,初始化为 BUFSIZE */
    full = sem_open("full", O_CREAT | O_EXCL, 0644, 0);         /* 已占用空间信号量,初始化为 0 */
    mutex = sem_open("mutex", O_CREAT | O_EXCL, 0644, 1);       /* 互斥信号量,初始化为 1 */

    /* 2. 创建共享内存段,用于进程间通信 */
    shm_id = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666);
    if (shm_id < 0)
    {
        perror("Fail to shmget!\n");
        return -1;
    }
    shm_addr = (int *)shmat(shm_id, NULL, 0);
    if (shm_addr < 0)
    {
        perror("Fail to shmat!\n");
        return -1;
    }

    /* 3. 生产消息 */
    printf("I'm producer. pid = %d\n", getpid());
    /* 生产多少个产品就循环几次 */
    for (i = 0; i < M; i++)
    {
        sem_wait(empty); /* empty--,等待有空闲缓冲区, empty>0才能生产 */
        sem_wait(mutex); /* mutex--,进入临界区 */

        /* 从上次位置继续向共享缓冲区写入一个字符 */
        shm_addr[buf_in] = i;
        printf("%d: buf_in=%d, shm_addr[buf_in]=%d,\n", getpid(), buf_in, shm_addr[buf_in]);
        /* 更新写入缓冲区位置,保证在0-9之间,缓冲区最大为10 */
        buf_in = (buf_in + 1) % BUFSIZE;

        sem_post(mutex); /* mutex++, 离开临界区 */
        sem_post(full);  /* full++,增加已占用缓冲区计数 */
    }
    printf("Producer end.\n");
    fflush(stdout); /*确保将输出立刻输出到标准输出。*/

    /* 4. 回收 */
    /* 回收子进程资源 */
    wait(NULL); /* 等待子进程结束 */
    /* 使用完毕后,分离共享内存段 */
    if (shmdt(shm_addr) == -1)
    {
        perror("Fail to shmd!\n");
        return -1;
    }
    sleep(1); /* 确保消费者消费完成 */
    /* 清理资源(在实际应用中,这通常会在所有进程都完成共享内存的使用后进行) */
    shmctl(shm_id, IPC_RMID, NULL);
    /* 释放信号量 */
    sem_unlink("full");
    sem_unlink("empty");
    sem_unlink("mutex");

    return 0;
}

2)实现consumer.c

同生产者一样拿上章代码进行修改,代码参考如下。和上一章不一样的点:

  • 不使用文件,使用共享内存;
  • 信号量不再创建,而是直接打开生产者创建的信号量;

另外在进程最后不需要清理共享内存,由生产者进程进行清理即可。

#include <unistd.h>    /* 提供对 POSIX 操作系统 API 的访问,例如 fork(), pipe(), read(), write(), close() 等 */
#include <sys/types.h> /* 提供数据类型,例如 pid_t */
#include <stdio.h>     /* 提供输入输出函数,例如 printf() */
#include <stdlib.h>    /* 提供通用工具函数,例如 exit(), malloc(), free() */
#include <fcntl.h>     /* 提供对文件控制选项的访问,例如 open(), lseek() */
#include <sys/stat.h>  /* 提供对文件状态标志和模式的访问(但此代码中未直接使用) */
#include <semaphore.h> /* 提供对 POSIX 信号量的访问,例如 sem_open(), sem_wait(), sem_post(), sem_unlink() */
#include <sys/wait.h>  /* 提供等待进程结束的函数,例如 wait(), waitpid() */
#include <sys/ipc.h>   /* 包含用于进程间通信(IPC)的接口定义 */
#include <sys/shm.h>   /* 包含共享内存(shared memory)相关的接口定义 */

#define M 530          /* 打出数字总数 */
#define N 5            /* 消费者进程数 */
#define AVG M / N      /* 每个消费者消费数字平均数 */
#define BUFSIZE 10     /* 缓冲区大小 */
#define SHM_KEY 0x1234 /* 共享内存的key,用来在生产者和消费者进程之间进行识别 */
#define SHM_SIZE 1024  /* 共享内存的大小,实际11就可以了,1024看起来更帅气一些 */

int main()
{
    sem_t *empty, *full, *mutex; /* 3个信号量 */
    int shm_id;                  /* 共享内存id */
    int *shm_addr;               /* 共享内存在当前进程虚拟地址空间的起始地址 */
    int i, j, k, child;          /* 循环变量和子进程计数器 */
    int data;                    /* 读取的数据 */
    pid_t pid;                   /* 进程id */
    int buf_out = 0;             /* 记录上次从缓冲区读取位置 */

    /* 1. 打开已存在的信号量 */
    empty = sem_open("empty", O_RDWR); /* 打开empty信号量 */
    full = sem_open("full", O_RDWR);   /* 打开full信号量 */
    mutex = sem_open("mutex", O_RDWR); /* 打开mutex信号量 */

    /* 2. 创建共享内存段,该内存段SHM_KEY和生产者进程一样,标识同一个内存段 */
    shm_id = shmget(SHM_KEY, SHM_SIZE, IPC_CREAT | 0666);
    if (shm_id < 0)
    {
        perror("Fail to shmget!\n");
        return -1;
    }
    shm_addr = (int *)shmat(shm_id, NULL, 0);
    if (shm_addr < 0)
    {
        perror("Fail to shmat!\n");
        return -1;
    }

    /* 3. 消费者进程,一共创建N个消费者进程 */
    for (j = 0; j < N; j++)
    {
        pid = fork();
        if (pid < 0) /* 创建进程失败 */
        {
            perror("Fail to fork!\n");
            return -1;
        }
        else if (pid == 0)
        {
            /* 每个进程平均读取数字,即每个进程读取 M/N 个数字 */
            for (k = 0; k < AVG; k++)
            {
                /* full大于0,才能消费 */
                sem_wait(full);  /* full--, 等待有数据可读 */
                sem_wait(mutex); /* mutex--,进入临界区 */

                /* 从文件第11个位置获得上次读取位置 */
                buf_out = shm_addr[BUFSIZE];
                /* 从上次读取位置继续读取数据 */
                data = shm_addr[buf_out];
                /* 在文件第11个位置写入下次应读取位置 */
                buf_out = (buf_out + 1) % BUFSIZE;
                shm_addr[BUFSIZE] = buf_out;

                printf("%d:  %d\n", getpid(), data); /* 打印消费者进程 ID和消费的数据 */
                fflush(stdout);                      /* 确保数据送到终端 */

                sem_post(mutex); /* mutex++,离开临界区 */
                sem_post(empty); /* empty++,增加空闲缓冲区计数 */
            }
            printf("Child-%d: pid = %d end.\n", j, getpid());
            return 0;
        }
    }

    /* 4. 回收 */
    /* 回收子进程资源 */
    child = N;      /* 包括生产者在内的子进程总数  */
    while (child--) /* 等待所有子进程结束 */
        wait(NULL); /* 等待子进程结束 */
    /* 使用完毕后,分离共享内存段 */
    if (shmdt(shm_addr) < 0)
    {
        perror("Fail to shmd!\n");
        return -1;
    }
    /* 释放信号量 */
    sem_unlink("full");
    sem_unlink("empty");
    sem_unlink("mutex");
    return 0;
}

3)编译程序

$ gcc -o producer producer.c -lpthread
$ gcc -o consumer consumer.c -lpthread

4)运行程序

运行生产者程序:

$ ./producer > producer.txt

运行后,会发现生产者进程卡住了,因为生产者生产了10个内容,empty=0了,正在等待消费者进程进行消费。

另外起一个终端运行消费者程序:

$ ./consumer > consumer.txt

此时生产者和消费者都会运行结束,查看producer.txt和consumer.txt的内容:

生产者日志:

# 以下是producer.txt的内容

I'm producer. pid = 1287
1287: buf_in=0, shm_addr[buf_in]=0,
1287: buf_in=1, shm_addr[buf_in]=1,
1287: buf_in=2, shm_addr[buf_in]=2,
1287: buf_in=3, shm_addr[buf_in]=3,
1287: buf_in=4, shm_addr[buf_in]=4,
1287: buf_in=5, shm_addr[buf_in]=5,
1287: buf_in=6, shm_addr[buf_in]=6,
1287: buf_in=7, shm_addr[buf_in]=7,
1287: buf_in=8, shm_addr[buf_in]=8,
1287: buf_in=9, shm_addr[buf_in]=9,
1287: buf_in=0, shm_addr[buf_in]=10,
1287: buf_in=1, shm_addr[buf_in]=11,
1287: buf_in=2, shm_addr[buf_in]=12,
1287: buf_in=3, shm_addr[buf_in]=13,
1287: buf_in=4, shm_addr[buf_in]=14,
1287: buf_in=5, shm_addr[buf_in]=15,
1287: buf_in=6, shm_addr[buf_in]=16,
...
1287: buf_in=6, shm_addr[buf_in]=526,
1287: buf_in=7, shm_addr[buf_in]=527,
1287: buf_in=8, shm_addr[buf_in]=528,
1287: buf_in=9, shm_addr[buf_in]=529,
Producer end.

消费者日志:

# 以下是consumer.txt的内容

1289:  0
1290:  1
1290:  2
1289:  3
1289:  4
1291:  5
1293:  6
1293:  7
1289:  8
1290:  9
1291:  10
1292:  11
1293:  12
1293:  13
1289:  14
1291:  15
1292:  16
...
Child-0: pid = 1289 end.
1292:  483
...
1290:  494
1291:  495
Child-4: pid = 1293 end.
1292:  496
...
1291:  525
Child-3: pid = 1292 end.
1291:  526
Child-1: pid = 1290 end.
1291:  527
1291:  528
1291:  529
Child-2: pid = 1291 end.

实验内容的输出和上一章节大同小异,不再进行赘述。

Linux0.11增加共享内存功能

终于Ubuntu上成功将生产者和消费者改成了共享内存,这下也了解了共享内存的玩法,这下可以考虑如何在Linux0.11上实现了。根据实验的提示开始编写共享内存相关函数的系统调用。

实现shm.c

先实现关键的内核函数,这里只实现了 shmget 和 shmat 两个函数,没有实现 shmdt 和 shmctl 函数,对于实验来讲已经足够了,有兴趣可自行实现。

shmget函数:这个函数比较简单,就是在内核维护一个共享内存的结构体(shm_tables)数组。

  • 然后根据参数key判断共享内存在数组是否存在,如果存在则直接返回数组中对应的索引i;
  • 如果不存在则申请物理页(get_free_page函数),构建一个结构体加入到数组中,并返回索引。

shmat函数:这个函数代码不多,但是要比shmget难理解,主要的点是在在于逻辑(虚拟)地址和线性地址区分,我一开始也是搞得有点迷糊,后面打印一些内容输出后终于明白了。

  • put_page函数是将共享内存附加到进程的线性地址。(在运行中一般是输出0x10005000这个地址,这个是线性地址)
  • 返回的是共享内存在当前进程的逻辑(虚拟)地址。(在运行中一般是输出0x5000这个地址,这个是逻辑(虚拟)地址)

关键的原理可以参考这张图:

image

一个简单的例子:

start_code: 0x10000000,这个是进程的线性起始地址。
长度brk: 0x5000
put_page共享内存附加到进程的线性地址:start_code + brk = 0x10005000
函数返回:0x5000,这个进程的逻辑(虚拟)地址。

具体代码参考如下:

#include <asm/segment.h>  // 段操作头文件。定义了有关段寄存器操作的嵌入式汇编函数。
#include <linux/kernel.h> // 内核头文件。含有一些内核常用函数的原形定义。
#include <linux/sched.h>  // 调度程序头文件,定义了任务结构 task_struct、初始任务 0 的数据,有一些有关描述符参数设置和获取的嵌入式汇编函数宏语句。
#include <linux/mm.h>     // 内存管理头文件。含有页面大小定义和一些页面释放函数原型。
#include <errno.h>        // 错误号头文件。包含系统中各种出错号。(Linus 从 minix 中引进的)。

#define SHM_NUM 32 /* 最多32块共享内存 */

/* 共享内存结构体 */
struct shm_tables
{
    int key;            /* 共享内存标识 */
    int size;           /* 共享内存大小 */
    unsigned long page; /* 共享内存地址 */
} shm_tables[SHM_NUM];

/* 获取一块空闲的物理页面来创建共享内存。 */
/* 参数:key标识申请到的内存key,size表示申请的内存大小。 */
/* 返回:在共享内存表中的索引值。 */
int sys_shmget(int key, int size)
{
    int i;              /* 在共享内存表中的索引 */
    unsigned long page; /* 存放共享内存物理地址 */

    /* 查看 key 对应的共享内存是否已存在 */
    for (i = 0; i < SHM_NUM; i++)
        if (shm_tables[i].key == key) /* 如果存在则直接返回对应的索引值 */
            return i;

    /* 内存大小超过一页 */
    if (size > PAGE_SIZE)
        return -EINVAL; /* 返回参数无效的错误 */

    /* 获取一块空闲物理内存页面,返回起始物理地址 */
    page = get_free_page();
    if (!page)
        return -ENOMEM; /* 返回内存不足的错误 */
    printk("shmget get memory's address is 0x%08x\n", page);

    /* 记录到共享内存表中 */
    for (i = 0; i < SHM_NUM; i++)
    {
        if (shm_tables[i].key == 0)
        {
            shm_tables[i].key = key;
            shm_tables[i].size = size;
            shm_tables[i].page = page;
            return i;
        }
    }
    /* 如果前面没有找到,则共享内存表已经满了。*/
    /* 正常可以考虑在errno.h文件定义一个错误常量,然后返回。因为是实验,所以就直接返回-1。 */
    return -1;
}

/* 将指定物理页面映射到当前进程的线性地址空间。 */
/* 参数shmid:就是shmget函数返回的共享内存表中的索引值。*/
/* 返回值:返回共享内存在当前进程的是逻辑(虚拟)地址。  */
void *sys_shmat(int shmid)
{
    /* code_base进程代码段基址;data_base进程数据段基址;start_code进程基址。 */
    /* 这三个地址都是线性地址。 */
    unsigned long code_base, data_base, start_code;

    /* 判断共享内存 shmid 是否越界 及 共享内存是否存在 */
    if (shmid < 0 || shmid >= SHM_NUM || shm_tables[shmid].key == 0)
        return -EINVAL;

    /* linux0.11中code_base、data_base、start_code都是一样的。可以参考 kernal/fork.c中copy_mem方法。 */
    /* 在后面使用put_page进行附加的时候,用哪个都可以。 */
    // code_base = get_base(current->ldt[1]); // 取代码段基址。
    // data_base = get_base(current->ldt[2]); // 取数据段基址。
    // start_code = current->start_code;      // 进程基址。
    // printk("current's code_base=0x%08x, data_base=0x%08x, start_code=0x%08x\n", code_base, data_base, start_code);

    printk("current->start_code=0x%08x, current->brk=0x%08x, page=0x%08x\n", current->start_code, current->brk, shm_tables[shmid].page);
    /* 把物理页面映射到进程的线性地址空间,映射到代码段+数据段后,堆栈段前。 */
    /* 参考《注释》书籍图13-6 */
    put_page(shm_tables[shmid].page, current->start_code + current->brk);

    /* 修改总长度,brk为代码段和数据段的总长度 */
    current->brk += PAGE_SIZE;

    /* 返回共享内存在当前进程的是逻辑(虚拟)地址。 */
    /* 返回无指定类型的指针,具体是用于int、char或其他类型,则用应用程序自己转换。 */
    return (void *)(current->brk - PAGE_SIZE);
}

这里可以考虑将结构体 shm_tables 的定义放置到头文件 ./include/linux/shm.h,和信号量sem.h类似,更加规范一些,但是因为应用程序没有用到其中相关的变量,所以直接放在这个文件也行。

新增系统调用

增加了内核函数,相关的地方都需要进行调整,经过前面的实验,这个已经非常熟练了。

1)修改/include/unistd.h,添加新增的系统调用的编号:

/* 实验6 */
#define __NR_sem_open     72
#define __NR_sem_wait     73
#define __NR_sem_post     74
#define __NR_sem_unlink   75
/* 实验7 */
#define __NR_shmget     76
#define __NR_shmat      77

2)修改/kernel/system_call.s,需要修改总的系统调用数:

nr_system_calls = 78

3)修改/include/linux/sys.h,声明全局新增函数:

extern int sys_setregid();
/* 实验6 */
extern int sys_sem_open();
extern int sys_sem_wait();
extern int sys_sem_post();
extern int sys_sem_unlink();
/* 实验7 */
extern int sys_shmget();
extern int sys_shmat();

fn_ptr sys_call_table[] = {
//...sys_setreuid,sys_setregid,
    sys_sem_open, sys_sem_wait, sys_sem_post, sys_sem_unlink, // 实验6
    sys_shmget, sys_shmat // 实验7
};

4)修改 linux-0.11/kernel/Makefile,添加shm.c编译规则:

# 添加 shm.o
OBJS  = sched.o system_call.o traps.o asm.o fork.o \
	panic.o printk.o vsprintf.o sys.o exit.o \
	signal.o mktime.o who.o sem.o shm.o

# ...

# 添加 shm.o 的依赖
## Dependencies:
shm.s shm.o: shm.c ../include/linux/kernel.h ../include/unistd.h
sem.s sem.o: sem.c ../include/linux/sem.h ../include/linux/kernel.h ../include/unistd.h

至此内核修改就完成了,可以尝试编译及运行内核程序,如果没有错误,表示修改成功。下面就要在Linux0.11上运行生产者消费者程序试试看。

运行生产者消费者程序

生产者和消费者程序都需要针对Linux0.11进行修改调整。

1)实现producer.c

#define __LIBRARY__    /* 定义一个符号常量,见下行说明。unistd.h文件中会用到。 */
#include <unistd.h>    /* 提供对 POSIX 操作系统 API 的访问,例如 fork(), pipe(), read(), write(), close() 等 */
#include <stdio.h>     /* 提供输入输出函数,例如 printf() */
#include <stdlib.h>    /* 提供通用工具函数,例如 exit(), malloc(), free() */
#include <fcntl.h>     /* 提供对文件控制选项的访问,例如 open(), lseek(),O_CREAT等 */
#include <linux/sem.h> /* 提供对 POSIX 信号量的访问,例如 sem_open(), sem_wait(), sem_post(), sem_unlink() */

#define M 530          /* 打出数字总数 */
#define N 5            /* 消费者进程数 */
#define BUFSIZE 10     /* 缓冲区大小 */
#define SHM_KEY 0x1234 /* 共享内存的key,用来在生产者和消费者进程之间进行识别 */
#define SHM_SIZE 1024  /* 共享内存的大小,实际11就可以了,1024看起来更帅气一些 */

_syscall2(sem_t *, sem_open, const char *, name, unsigned int, value);
_syscall1(int, sem_wait, sem_t *, sem);
_syscall1(int, sem_post, sem_t *, sem);
_syscall1(int, sem_unlink, const char *, name);
_syscall2(int, shmget, int, key, int, size);
_syscall1(int, shmat, int, shmid);

int main()
{
    sem_t *empty, *full, *mutex; /* 3个信号量 */
    int shm_id;                  /* 共享内存id */
    int *shm_addr;               /* 共享内存在当前进程虚拟地址空间的起始地址 */
    int i;                       /* 循环变量和子进程计数器 */
    int buf_in = 0;              /* 记录上次写入缓冲区位置 */

    /* 1. 创建信号量 */
    if ((mutex = sem_open("mutex", 1)) == NULL)
    {
        perror("sem_open() error!\n");
        return -1;
    }
    if ((empty = sem_open("empty", BUFSIZE)) == NULL)
    {
        perror("sem_open() error!\n");
        return -1;
    }
    if ((full = sem_open("full", 0)) == NULL)
    {
        perror("sem_open() error!\n");
        return -1;
    }

    /* 2. 创建共享内存段,用于进程间通信 */
    shm_id = shmget(SHM_KEY, SHM_SIZE); /* 获取一块空闲的物理页面来创建共享内存,第11个位置消费者进程用来存储读取的位置。 */
    if (shm_id < 0)
    {
        perror("Fail to shmget!\n");
        return -1;
    }
    shm_addr = (int *)shmat(shm_id);  /* 将指定物理页面映射到当前进程的线性地址空间,返回当前进程的逻辑(虚拟)地址。 */
    if (shm_addr == (void *)-1)      /* 因为shm_addr转化为指针了,所以对比里-1也要转化为指针,避免编译器报警 */
    {
        perror("Fail to shmat!\n");
        return -1;
    }

    /* 3. 生产消息 */
    printf("I'm producer. pid = %d\n", getpid());
    /* 生产多少个产品就循环几次 */
    for (i = 0; i < M; i++)
    {
        sem_wait(empty); /* empty--,等待有空闲缓冲区, empty>0才能生产 */
        sem_wait(mutex); /* mutex--,进入临界区 */

        /* 从上次位置继续向共享缓冲区写入一个字符 */
        shm_addr[buf_in] = i;
        printf("%d: buf_in=%d, shm_addr[buf_in]=%d,\n", getpid(), buf_in, shm_addr[buf_in]);
        /* 更新写入缓冲区位置,保证在0-9之间,缓冲区最大为10 */
        buf_in = (buf_in + 1) % BUFSIZE;

        sem_post(mutex); /* mutex++, 离开临界区 */
        sem_post(full);  /* full++,增加已占用缓冲区计数 */
    }
    printf("Producer end.\n");
    fflush(stdout); /*确保将输出立刻输出到标准输出。*/

    sleep(3); /* 确保消费者消费完成 */

    /* 4. 回收 */
    /* 使用完毕后,分离共享内存段,本次实验未实现该函数。 */
    /* 清理资源(在实际应用中,这通常会在所有进程都完成共享内存的使用后进行),本次实验未实现该函数。 */

    /* 释放信号量 */
    sem_unlink("full");
    sem_unlink("empty");
    sem_unlink("mutex");

    return 0;
}

2)实现consumer.c

#define __LIBRARY__    /* 定义一个符号常量,见下行说明。unistd.h文件中会用到。 */
#include <unistd.h>    /* 提供对 POSIX 操作系统 API 的访问,例如 fork(), pipe(), read(), write(), close() 等 */
#include <stdio.h>     /* 提供输入输出函数,例如 printf() */
#include <stdlib.h>    /* 提供通用工具函数,例如 exit(), malloc(), free() */
#include <linux/sem.h> /* 提供对 POSIX 信号量的访问,例如 sem_open(), sem_wait(), sem_post(), sem_unlink() */

#define M 530          /* 打出数字总数 */
#define N 5            /* 消费者进程数 */
#define AVG M / N      /* 每个消费者消费数字平均数 */
#define BUFSIZE 10     /* 缓冲区大小 */
#define SHM_KEY 0x1234 /* 共享内存的key,用来在生产者和消费者进程之间进行识别 */
#define SHM_SIZE 1024  /* 共享内存的大小,实际11就可以了,1024看起来更帅气一些 */

_syscall2(sem_t *, sem_open, const char *, name, unsigned int, value);
_syscall1(int, sem_wait, sem_t *, sem);
_syscall1(int, sem_post, sem_t *, sem);
_syscall1(int, sem_unlink, const char *, name);
_syscall2(int, shmget, int, key, int, size);
_syscall1(int, shmat, int, shmid);

int main()
{
    sem_t *empty, *full, *mutex; /* 3个信号量 */
    int shm_id;                  /* 共享内存id */
    int *shm_addr;               /* 共享内存在当前进程虚拟地址空间的起始地址 */
    pid_t pid;                   /* fork的进程id */
    int j, k;                    /* 循环变量 */
    int buf_out = 0;             /* 记录上次从缓冲区读取位置 */
    int data;                    /* 子进程从共享内存读取的数据 */
    int child;                   /* 子进程总数 */

    /* 1. 打开已存在的信号量,这些信号量在生产者进程创建过了,消费者进程这里的第二个参数实际上是没有用的。 */
    if ((mutex = sem_open("mutex", 1)) == NULL)
    {
        perror("sem_open() error!\n");
        return -1;
    }
    if ((empty = sem_open("empty", BUFSIZE)) == NULL)
    {
        perror("sem_open() error!\n");
        return -1;
    }
    if ((full = sem_open("full", 0)) == NULL)
    {
        perror("sem_open() error!\n");
        return -1;
    }

    /* 2. 创建共享内存段,用于进程间通信 */
    shm_id = shmget(SHM_KEY, SHM_SIZE); /* 获取一块空闲的物理页面来创建共享内存,第11个位置消费者进程用来存储读取的位置。 */
    if (shm_id < 0)
    {
        perror("Fail to shmget!\n");
        return -1;
    }

    /* 3. 消费者进程,一共创建N个消费者进程 */
    for (j = 0; j < N; j++)
    {
        pid = fork();
        if (pid < 0) /* 创建进程失败 */
        {
            perror("Fail to fork!\n");
            return -1;
        }
        else if (pid == 0)
        {
            /* 这行代码要放到子进程里,每个子进程都有自己的虚拟内存空间和线性地址。 */
            shm_addr = (int *)shmat(shm_id); /* 将指定物理页面映射到当前进程的虚拟地址空间 */
            if (shm_addr == (void *)-1)      /* 因为shm_addr转化为指针了,所以对比里-1也要转化为指针,避免编译器报警 */
            {
                perror("Fail to shmat!\n");
                return -1;
            }

            /* 每个进程平均读取数字,即每个进程读取 M/N 个数字 */
            for (k = 0; k < AVG; k++)
            {
                /* full大于0,才能消费 */
                sem_wait(full);  /* full--, 等待有数据可读 */
                sem_wait(mutex); /* mutex--,进入临界区 */

                /* 从文件第11个位置获得上次读取位置 */
                buf_out = shm_addr[BUFSIZE];
                /* 从上次读取位置继续读取数据 */
                data = shm_addr[buf_out];
                /* 在内存第11个位置写入下次应读取位置 */
                buf_out = (buf_out + 1) % BUFSIZE;
                shm_addr[BUFSIZE] = buf_out;

                printf("%d:  %d\n", getpid(), data); /* 打印消费者进程 ID和消费的数据 */
                fflush(stdout);                      /* 确保数据送到终端 */

                sem_post(mutex); /* mutex++,离开临界区 */
                sem_post(empty); /* empty++,增加空闲缓冲区计数 */
            }
            printf("Child-%d: pid = %d end.\n", j, getpid());
            return 0;
        }
    }

    /* 4. 回收 */
    /* 回收子进程资源 */
    child = N;      /* 包括生产者在内的子进程总数  */
    while (child--) /* 等待所有子进程结束 */
        wait(NULL); /* 等待子进程结束 */

    /* 使用完毕后,分离共享内存段,本次实验未实现该函数。 */

    /* 释放信号量 */
    sem_unlink("full");
    sem_unlink("empty");
    sem_unlink("mutex");

    return 0;
}

和Ubuntu下的消费者程序不同,指定物理页面映射到当前进程的线性地址空间,即 shmat 这行代码必须要放到子进程内,不然输出的时候会错乱。

关于这个问题我也排查了一段时间,最后才发现,分析原因主要就是fork的写时复制(copy on write)机制

当fork进程时,子进程和父进程都有自己的线性地址,但对应的物理内存都是一样的,只是只能读不能写,当子进程需要写入的时候(即这行代码:shm_addr[BUFSIZE] = buf_out),内核就会申请一个新的物理页进行写入,这个写入的地方就不是共享内存的地址了。 所以需要在每个子进程内都附加上这段共享内存。

但是不知道为什么在Ubuntu上的消费者程序就不需要这么做,估计是新的内核在fork的时候对共享内存进行了处理。

3)将相关文件复制到Linux0.11文件系统

# 在 oslab 目录下 

$ sudo ./mount-hdc
$ cp ./consumer.c ./hdc/usr/root/
$ cp ./producer.c ./hdc/usr/root/
$ cp ./linux-0.11/include/unistd.h ./hdc/usr/include/ 
$ cp ./linux-0.11/include/linux/sem.h ./hdc/usr/include/linux/
$ sudo umount hdc

4)编译及运行Linux-0.11

# 在 oslab 目录下 

$ cd ./linux-0.11
$ make all
$ ../run

5)在Linux0.11中编译生产者消费者程序

$ gcc -o producer producer.c
$ gcc -o consumer consumer.c
$ sync

编译时出现这个报错:

In function sem_open:
warning: return of pointer from integer lacks a cast

这个是因为_syscall2函数中出错了会返回-1,和定义的指针类型不同,这个在实验中可以忽略,不影响实验效果。

6)在Linux0.11中运行生产者消费者程序

# 通过在命令后加一个 & ,可以使其在后台运行。进而就可以执行消费者程序。
# 在实验过程中,执行消费者程序时,也需要增加一个 & ,不然shell界面会输出错乱。
$ ./producer > producer.txt &
$ ./consumer > consumer.txt & 
$ sync

运行程序时发现先内核报了一个错误:

image

排查发现是在 ./mm/memory.c 中的 free_page 函数报出的。因为内核异常,会导致整个程序终止,写入的日志就不完整了,考虑到这个问题并不是致命,我暂时把这个 panic 注释掉了。

关于这个问题,有兴趣可以自行排查,有一些思路:在free_page函数内,打印出当前的进程信息和要释放的地址,看能否观察到具体释放的是哪一个物理内存页面。

7)将日志文件移动到Ubuntu下

# 在 oslab 目录下 

$ sudo ./mount-hdc
$ sudo cp ./hdc/usr/root/producer.txt ./
$ sudo cp ./hdc/usr/root/consumer.txt ./

8)查看日志

生产者日志:

# producer.txt

I'm producer. pid = 6
6: buf_in=0, shm_addr[buf_in]=0,
6: buf_in=1, shm_addr[buf_in]=1,
6: buf_in=2, shm_addr[buf_in]=2,
6: buf_in=3, shm_addr[buf_in]=3,
6: buf_in=4, shm_addr[buf_in]=4,
6: buf_in=5, shm_addr[buf_in]=5,
6: buf_in=6, shm_addr[buf_in]=6,
6: buf_in=7, shm_addr[buf_in]=7,
6: buf_in=8, shm_addr[buf_in]=8,
6: buf_in=9, shm_addr[buf_in]=9,
6: buf_in=0, shm_addr[buf_in]=10,
6: buf_in=1, shm_addr[buf_in]=11,
6: buf_in=2, shm_addr[buf_in]=12,
6: buf_in=3, shm_addr[buf_in]=13,
...
6: buf_in=8, shm_addr[buf_in]=528,
6: buf_in=9, shm_addr[buf_in]=529,
Producer end.

消费者日志:

# consumer.txt

0.  0
1.  1
2.  2
3.  3
4.  4
5.  5
6.  6
7.  7
8.  8
9.  9
10.  10
11.  11
12.  12
13.  13
14.  14
15.  15
16.  16
17.  17
18.  18
...
9:  294
9:  295
Child-0: pid = 9 end.
12:  296
12:  297
...
12:  330
Child-3: pid = 12 end.
13:  331
13:  332
...
11:  428
Child-2: pid = 11 end.
10:  429
...
13:  504
Child-4: pid = 13 end.
10:  505
10:  506
10:  507
...
10:  527
10:  528
10:  529
Child-1: pid = 10 end.

实验报告

完成实验后,在实验报告中回答如下问题:

1) 对于地址映射实验部分,列出你认为最重要的那几步(不超过 4 步),并给出你获得的实验数据。

这个在上面实验中已经总结了,本质上就是如下三步:

  1. 查找变量i的逻辑(虚拟)地址:通过dba-asm调试器进行查找。
  2. 查找变量i的线性地址:进程的LDT表可以通过LDTR和GDTR查找出来;而后通过逻辑地址的段寄存器ds,在LDT表中查找到对应项,即可得到基地址,加上逻辑地址中的偏移地址就是线性地址了。
  3. 查找变量i的物理地址:开启分页后,线性地址分解成3个部分,依次在页目录表、页表中查找,而后加上线性地址最后12位,便是物理地址了。

2) test.c 退出后,如果马上再运行一次,并再进行地址跟踪,你发现有哪些异同?为什么?

我发现了3个不同,尝试排查了下原因,一开始摸不着头脑,后来结合《注释》书籍和源码,大概明白了原因。

2.1) GDT表中对应的LDT段描述符不一样(0xa2d00068 0x000082f9 -> 0x52d00068 0x000082fd,LDT段基址发生了变化。

答:在 Linux 0.11 中,每个进程都有自己的局部描述符表(LDT),用于描述该进程的代码段、数据段等。全局描述符表(GDT)中有一个 LDT 段描述符,用于指向每个进程的 LDT。

当 shell 程序执行 test.c 程序时,会 fork 一个新进程,fork程序内会新申请一个物理内存页存储进程结构信息(进程结构信息中存储了 LDT),然后设置新进程的 LDT,并在 GDT 中设置相应的 LDT 段描述符。物理内存页的申请具有不确定性,所以每次运行 test.c,其 LDT 地址可能不一样。

相关代码参考如下:

  • fork进程:./kernel/fork.c 中的 copy_process
  • 新申请内存页:./kernel/fork.c 中的 77 行。
  • 设置新进程的LDT:./kernel/fork.c 中的 54、55 行。
  • 在GDT中设置相应的LDT段描述符:./kernel/fork.c 中的 131 行。

2.2) 页目录项发生了变化(0x00fa9027 -> 0x00fa6027),页目录项存储的内容是表示页表的情况(高20位是表示页表基址),因此可以得出页表的基址发生了变化。这点可以查看 fs/exec.c 中 do_execve 函数的328、329行。

2.3) 页表项也发生了变化(0x00fa7067 -> 0x00fa3067),页表项存储的内容是表示物理页的情况(高20位是表示物理页基址),因此可以得出物理页的基址发生了变化。原因同上。

参考资料

从以下资料得到了不少帮助,特此表示感谢。

完。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

晴空闲雲

感谢家人们的投喂

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值