进行本次实验前需要先完成 实验六 信号量的实现与运用。
实验要求与实验指导见 实验楼。
实验环境为 配置本地实验环境。
这节实验的目的是用 Bochs 的调试功能获取变量的虚拟地址映射的物理地址。
在 Linux-0.11 中运行下面的死循环程序:
#include
int i = 0x12345678;
int main(void)
{
printf("The logical/virtual address of i is 0x%08x", &i);
fflush(stdout);
while (i)
;
return 0;
}
在上述程序的运行过程中,在命令行窗口按下 Ctrl+C
,进入 Bochs 的调试功能,使用 u /8
命令获取反汇编代码,如图:
可知 i 的虚拟地址为 ds:0x3004
。
使用 sreg
命令查看段寄存器的值如图:
LDTR 的值为 0x0068 = 0000000001101000。根据段选择符的结构,最后两位表示请求特权级 RPL = 00,倒数第三位为表指示标志 TI - 0,表示该选择符存放在 GDT 中,前面的 13 位为索引值 Index = 1101 (二进制) = 13 (十进制),表示该进程的 LDT 表存放在 GDT 表中的 13 号位置。
GDTR 的值为 0x00005cb8,于是目的 LDT 表的地址为 0x00005cb8+13*8:
所得结果为 0x52d0
0068 0x00
0082fd
,根据段描述符的格式:
这里将上述结果组合为 0x00fd52d0
,即为 LDT 表的物理地址。查看 LDT 表的内容如图所示:
Linux-0.11 中,LDT 表的第一项为空,第二项为代码段,第三项为数据和堆栈段 ds&ss。于是所需的 ds 的段描述符为 0x0000
3fff 0x10
c0f300
,组合为 0x10000000
,即为 ds 段的起始地址。
于是 i 即 ds:0x3004
的线性地址为 0x10003004
。
接下来通过页表将线性地址映射到物理地址,规则如下:
首先计算线性地址的页目录号为 64,页表号为 3,页内偏移为 4。
页目录表的物理地址保存在 CR3 寄存器中,使用 creg
命令获取其值:
可知页目录表的基址为 0,这是 Linux-0.11 启动时 boot/head.s
设置的。目的页目录号为 64,查看该目录项:
页表项中的 12-31 位为页帧地址(见《Linux内核完全注释》P99),故目的页帧号为 0x00fa6。接着查 3 号表项:
于是线性地址对应的页帧为 0x00fa3,加上业内偏移 0x004,得到 0x00fa3004 即为变量 i 的物理地址。验证如下:
使用命令 setpmem 0x00fa3004 4 0
直接修改内存,将变量 i 的值设为 0,再用 c 命令继续运行 Bochs,死循环程序退出。
这个程序跟上次实验的不同之处在于:使用共享内存替换文件缓冲区;将生产者和消费者分成两个不同的程序,两个都是单进程的。
Linux 中,将不同进程的虚拟地址空间通过页表映射到物理内存的同一区域即为共享内存。如图所示:
两个进程都可以访问共享内存,但是为了确保对共享内存操作的互斥,仍需要使用一个信号量在每次读写的时候进行限制,然后由另外两个信号量保来保证共享内存中每次至多有 10 个数字。
用共享内存和信号量实现的 producer.c
代码如下:
#include
#include
#include
#include
#include
#include
#define SIZE 10
#define M 510
int main()
{
int shm_id;
int count = 0;
int *p;
int curr;
sem_t *sem_empty, *sem_full, *sem_shm;
sem_empty = sem_open("empty", O_CREAT|O_EXCL, 0644, SIZE);
sem_full = sem_open("full", O_CREAT|O_EXCL, 0644, 0);
sem_shm = sem_open("shm", O_CREAT|O_EXCL, 0644, 1);
shm_id = shmget(2521, SIZE, IPC_CREAT | IPC_EXCL | 0664); // 创建共享内存
p = (int *)shmat(shm_id, NULL, 0);
while (count <= M) {
sem_wait(sem_empty);
sem_wait(sem_shm);
curr = count % SIZE;
*(p + curr) = count;
printf("Producer: %d\n", *(p + curr));
fflush(stdout);
sem_post(sem_shm);
sem_post(sem_full);
count++;
}
printf("producer end.\n");
fflush(stdout);
return 0;
}
consumer.c
的代码如下:
#include
#include
#include
#include
#include
#include
#define SIZE 10
#define M 510
int main()
{
int shm_id;
int count = 0;
struct shmid_ds buf;
int *p;
int curr;
sem_t *sem_empty, *sem_full, *sem_shm;
sem_empty = sem_open("empty", SIZE);
sem_full = sem_open("full", 0);
sem_shm = sem_open("shm", 1);
shm_id = shmget(2521, 0, 0);
p = (int *)shmat(shm_id, NULL, 0);
while(count <= M) {
sem_wait(sem_full);
sem_wait(sem_shm);
curr = count % SIZE;
printf("%d:%d\n", getpid(), *(p + curr));
fflush(stdout);
sem_post(sem_shm);
sem_post(sem_empty);
count++;
}
printf("consumer end.\n");
fflush(stdout);
sem_unlink("empty");
sem_unlink("full");
sem_unlink("shm");
shmctl(shm_id, IPC_RMID, &buf);
return 0;
}
为了让两个进程在同一个终端运行,这里使用终端的后台运行功能:
./producer &
./consumer
在 Ubuntu 下的运行结果如图所示:
函数 int shmget(key_t key, size_t size, int shmflg)
会新建或打开一页内存,然后返回该页共享内存的 shmid。忽略 shmflg 参数后,可知一页共享内存需要保存的信息有 唯一标识符 key、共享内存的大小 size,然后还需要一个参数保存共享内存页面的地址。于是共享内存信息的结构体如下:
struct shm_tables
{
int key;
int size;
unsigned long page;
};
根据要求,shmget()
函数需要获取一块空闲的内存的物理页面来创建共享内存,shmat()
函数需要将该物理页面映射到进程的虚拟内存空间,然后返回其首地址。
函数 get_free_page()
能够获取一块空闲的物理页面,并且返回该页面的起始物理地址,用于 shmget()
的实现。
函数 put_page()
能够把物理页面映射到指定线性地址空间处。为了能让两个进程操作这块共享内存,需要把物理页面分别映射到该进程自己的虚拟空间。内核为每个进程虚拟了一块地址空间,然后分配数据段、代码段和栈段,由函数 do_execve()
实现,虚拟空间的分配如下图:
其中 start_code
为代码段起始地址,brk
为代码段和数据段的总长度,start_stack
为栈的起始地址,这些值保存在进程的 task_struct
中。brk
和 start_stack
之间的空间为栈准备,栈底是闲置的,可将共享内存映射到这块空间。
shm.c
的代码如下:
#include
#include
#include
#include
#include
#define _SHM_NUM 20
struct shm_tables
{
int key;
int size;
unsigned long page;
} shm_tables[_SHM_NUM];
int sys_shmget(int key, int size)
{
int i;
unsigned long page;
for (i = 0; i < _SHM_NUM; i++) /* 查看 key 对应的共享内存是否已存在 */
if(shm_tables[i].key == key)
return i;
if (size > PAGE_SIZE) /* 内存大小超过一页 */
return -EINVAL;
page = get_free_page(); /* 获取物理内存页面 */
if(!page)
return -ENOMEM;
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;
}
}
return -1; /* 共享内存数量已满 */
}
void * sys_shmat(int shmid)
{
int i;
unsigned long data_base;
if (shmid < 0 || shmid >= _SHM_NUM || shm_tables[shmid].key == 0) // 判断 shmid 是否合法
return -EINVAL;
put_page(shm_tables[shmid].page, current->brk + current->start_code); // 把物理页面映射到进程的虚拟空间
current->brk += PAGE_SIZE; // 修改总长度
return (void*)(current->brk - PAGE_SIZE);
}
修改 mm/Makefile
,将 shm.c
一块编译进 Image:
OBJS = memory.o page.o shm.o
# add
shm.o shm.c: ../include/asm/segment.h ../include/linux/kernel.h \
../include/linux/sched.h ../include/linux/mm.h ../include/errno.h
然后对系统调用的实现做后续补充。在 include/linux/sys.h
中添加:
extern int sys_shmget();
extern int sys_shmat();
fn_ptr sys_call_table[] = { ..., sys_shmget, sys_shmat };
在 include/unistd.h
及 Linux-0.11 中的 usr/include/unistd.h
添加:
#define __NR_shmget 76
#define __NR_shmat 77
最后修改 kernel/system_call.s
中系统调用的数量:
nr_system_calls = 78
共享内存实现完毕。
;修改 consumer.c
和 producer.c
,从而能够在 Linux-0.11 运行这两个程序:
#define __LIBRARY__ /* 在第一行添加 */
/* add */
_syscall2(int,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_value,sem_t *,sem)
_syscall1(int,sem_unlink,const char *,name)
_syscall2(int,shmget,int,key,int,size)
_syscall1(int, shmat, int, shmid)
int main()
{
...
/* change */
sem_empty = sem_open("empty", SIZE);
sem_full = sem_open("full", 0);
sem_shm = sem_open("shm", 1);
shm_id = shmget(2521, SIZE);
p = (int *)shmat(shm_id);
...
return 0;
}
然后在 Linux-0.11 中编译运行,结果如下(为了便于显示,这里将 M 改为 60、将 SIZE 改为 4):
Linux-0.11 同时采用了内存的分段和分页机制从而有效地使用物理内存。于是内存的地址映射需要进行以下步骤:
在保护模式下,段寄存器存放的不再是内存的段基址,而是一个段描述符表中某一描述符项在表中的索引值。于是寻址的过程中需要使用段描述符表,包括全局描述符表 GDT、局部描述符表 LDT、中断描述符表 IDT。当 CPU 要寻址一个段时,就会使用 16 位的段寄存器中的选择符来定位一个段描述符,段寄存器中的值右移 3 位即是描述符表中一个描述符的索引值。选择符中位 2 (TI) 用来指定使用哪个表。若该位是 0 则选择符指定的是 GDT 表中的描述符,否则是 LDT 表中的描述符。
在 Linux-0.11 中,程序逻辑地址到线性地址的变换过程使用了 CPU 的全局段描述符表
GDT 和局部段描述符表 LDT 。由 GDT 映射的地址空间称为全局地址空间,由 LDT 映射的地址空间则称
为局部地址空间,而这两者构成了虚拟地址的空间。如图所示:
Intel 的 80386 CPU 可以提供 4G 的线性地址空间。Linux-0.11 中定义了最大进程数为 64 个,每个进程的逻辑地址范围是 64M,其中任务 0 和 1 将代码和数据区重叠在 640K 范围内,比较特殊。线性地址位置示意图如下:
对于单个进程而言,其代码段和数据段、堆栈段的分布如图所示:
对于地址映射实验部分,列出你认为最重要的那几步(不超过 4 步),并给出你获得的实验数据。
最重要的四步:
- 获取 i 的虚拟地址
- 获取 LDT 表的地址
- 获取线性地址
- 获取物理地址
实验数据请见上文。
test.c 退出后,如果马上再运行一次,并再进行地址跟踪,你发现有哪些异同?为什么?
段基址可能会变化,因为操作系统为每个进程分配的 64M 空间位置不同,导致段基址不同。
而数据段偏移量不变,这是编译时就设置完毕的。