MIT——6.828:操作系统工程——第1章:实验一:启动计算机
本实验分为三个部分。
第一部分:熟悉 x86 汇编语言、QEMU x86 模拟器和 PC 的开机引导程序。
第二部分:检查我们的 6.828 内核的引导装载程序。
第三部分:深入研究了我们的 6.828 内核的名为JOS初始模。
1. 第一部分:PC引导程序
1.1 PC的物理地址空间
PC 的物理地址空间是硬连线的,布局如下:
+------------------+ <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\
/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM |
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display |
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000
由于第一台16位的intel 8088处理器只能寻址 1MB 的物理内存,早期 PC 的物理地址空间从 0x00000000 开始,以 0x000FFFFF结束,因此标记为“Low Memory”的 640KB 区域是早期 PC 唯一可以使用的随机存取存储器 (RAM),而事实上,最早的 PC 只能配置 16KB、32KB 或 64KB 的 RAM。
从 0x000A0000 到 0x000FFFFF 的 384KB 区域由硬件保留用于特殊用途,例如视频显示缓冲区和保存在非易失性存储器中的固件。这个区域最重要的部分是基本输入/输出系统 (BIOS),它占据了从 0x000F0000 到 0x000FFFFF 的 64KB 区域。在早期的 PC 中,BIOS 保存在只读存储器 (ROM) 中,但现在的 PC 将 BIOS 存储在可更新的闪存中。BIOS 负责执行基本的系统初始化,例如激活视频卡和检查安装的内存大小。执行此初始化后,BIOS 从某个适当的位置(如软盘、硬盘、CD-ROM 或网络)加载操作系统,并将机器的控制权交给操作系统。
当intel最终“1MB的障碍”时,生产分别支持 16MB 和 4GB 物理地址空间的 80286 和 80386 处理器,PC 架构师仍然保留了低 1MB 物理地址空间的原始布局,以确保向后兼容现有的软件。因此,现代 PC 在从 0x000A0000 到 0x00100000 的物理内存中有一个“洞”,将 RAM 分为“low”或“conventional memory”(前 640KB)和“extended memory”(其他所有内容)。此外,PC 的 32 位物理地址空间最顶部的一些空间,在所有物理 RAM 之上,现在通常由 BIOS 保留供 32 位 PCI 设备使用。
[注]:由于设计限制,JOS 无论如何只会使用 PC 物理内存的前 256MB,所以现在我们假设所有 PC 都“只有”一个 32 位物理地址空间。但是处理复杂的物理地址空间和硬件组织的其他方面已经发展了很多年是操作系统开发的重要实际挑战之一。
1.2 Bios ROM
在实验中,使用 QEMU 的调试工具来研究 IA-32 计算机的启动方式。
进入实验室目录中打开两个终端窗口和两个 shell。在其中一个,输入
make qemu-gdb(或者make qemu-nox-gdb)。
这将启动 QEMU,但 QEMU 恰好在处理器执行第一条指令之前停止并等待来自 GDB 的调试连接。在第二个终端中,从运行make的同一目录运行
make gdb
出现
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
是 GDB 对cpu要执行的第一条指令的反汇编,它的含义如下:
- PC 从物理地址 0x000ffff0 开始执行,该地址位于为 ROM BIOS 保留的 64KB 区域的最顶部
- PC 从CS = 0xf000和IP = 0xfff0开始执行
- 要执行的第一条指令是jmp指令,它跳转到分段地址 CS = 0xf000 和 IP = 0xe05b
这是 Intel 设计 8088 处理器的方式,IBM 在其原始 PC 中使用了该处理器。因为 PC 中的 BIOS 是“硬连线”到物理地址范围 0x000f0000-0x000fffff,这种设计确保 BIOS 在通电或系统重启后始终首先控制机器,因为在通电时处理器可以执行的RAM 中的任何地方都没有其他程序。QEMU 仿真器带有自己的 BIOS,它将其放置在处理器的模拟物理地址空间中的该位置。在处理器发出reset信号时,(模拟)处理器进入实模式并将 CS 设置为 0xf000,将 IP 设置为 0xfff0,以便从该 (CS:IP) 段地址开始执行。
分段地址0xf000:fff0如何转换为物理地址?
要回答这个问题,需要了解一些关于实模式寻址的知识。在实模式(PC 启动的模式)下,地址转换根据以下公式进行:
物理地址 = 16 * 段 + 偏移量
所以,当PC设置CS为0xf000,IP为0xfff0时,访问的物理地址为:
16 * 0xf000 + 0xfff0 #十六进制乘以16只需附加一个0
=0xf0000 + 0xfff0
=0xffff0
0xffff0是 BIOS 结束前的 16 个字节 ( 0x100000 ),因此,BIOS所做的第一件事就是向后跳转到BIOS的低地址处。
当 BIOS 运行时,它会建立一个中断描述符表并初始化各种设备,例如 VGA 显示器。
初始化 PCI 总线和 BIOS 知道的所有重要设备后,它会搜索可引导设备,如软盘、硬盘或 CD-ROM。最终,当它找到可引导磁盘时,BIOS 从磁盘读取引导加载程序并将控制权转移给它
练习2
使用 GDB 的si(步骤指令)命令跟踪 ROM BIOS 以获取更多指令,并尝试猜测它可能在做什么。无需了解所有细节——只需大致了解 BIOS 首先要做什么。
当 BIOS 运行时,它会建立一个中断描述符表并初始化各种设备,例如 VGA 显示器。
初始化 PCI 总线和 BIOS 知道的所有重要设备后,它会搜索可引导设备,如软盘、硬盘或 CD-ROM。最终,当它找到可引导设备时,读取该设备的第一扇区(即主引导扇区或启动扇区)到内存一个特定的地址0x7c00处,然后CPU控制权会转移到那个地址继续执行。至此BIOS的初始化工作做完了,进一步的工作交给了ucore的bootloader。
2. 引导加载程序
练习二
查看实验室工具指南,尤其是有关 GDB 命令的部分。即使您熟悉 GDB,这也包括一些对 OS 工作有用的深奥 GDB 命令。
在地址 0x7c00 处设置断点,这是加载引导扇区的位置。继续执行直到该断点。跟踪boot/boot.S中的代码,使用源代码和反汇编文件 obj/boot/boot.asm来跟踪您的位置。还使用 GDB 中的x/i命令反汇编引导加载程序中的指令序列,并将原始引导加载程序源代码与obj/boot/boot.asm 和 GDB 中的反汇编进行比较。
跟踪到boot/main.c中的bootmain(),然后跟踪到readsect()。确定与readsect()中的每个语句相对应的确切汇编指令。跟踪readsect()的其余部分 并返回到bootmain() ,并确定从磁盘读取内核剩余扇区的for循环的开始和结束。找出循环结束时将运行的代码,在那里设置断点,然后继续到该断点。然后逐步执行引导加载程序的其余部分。
回答以下问题:
- 处理器什么时候开始执行 32 位代码?究竟是什么导致从 16 位模式切换到 32 位模式?
- 引导加载程序执行 的最后一条指令是什么,它刚刚加载的内核的第一条指令是什么?
- 内核的第一条指令在哪里?
- 引导加载程序如何决定它必须读取多少个扇区才能从磁盘中获取整个内核?它在哪里找到这些信息?
答:首先对boot.s,引导程序的汇编部分进行分析
#include <inc/mmu.h>
// mmu.h头文件中包含了一些宏定义,用于定义x86内存管理单元(mmu),包括分段和分页相关的数据结构和常量
# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.(这段代码的目的是启动32位保护模式,跳转到C)
.set PROT_MODE_CSEG, 0x8 # kernel code segment selector(内核代码段选择子)
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector(内核数据段选择子)
.set CR0_PE_ON, 0x1 # protected mode enable flag(改变保护模式的位)
.globl start // 代码入口点,bios跳转到这里
start:
.code16 # Assemble for 16-bit mode //因为以下代码是在实模式下执行,所以要告诉编译器使用16位模式编译
cli # Disable interrupts
cld # String operations increment
//关中断,设置字符串操作是递增方向(ps:cld使标志寄存器DF复位,即是让 DF=0,绝对内存地址向高地址增加)
# Set up the important data segment registers (DS, ES, SS).
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
//设置段选择子ds,es,ss为0
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
//激活A20地址位。由于需要兼容早期pc,物理地址的第20位绑定为0,所以高于1MB的地址又回到了0x00000。而激活A20后,就可以访问所有4G内存了,就可以使用保护模式了。
怎么激活呢,由于A20地址位由键盘控制器芯片8042管理。所以要给8042发命令激活A20 8042有两个IO端口:0x60和0x64
激活流程位: 发送0xd1命令到0x64端口 --> 发送0xdf到0x60
seta20.1:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
//发送命令之前,要等待键盘输入缓冲区为空,这通过8042的状态寄存器的第2位来观察,而状态寄存器的值可以读0x64端口得到。上面的指令的意思就是,如果状态寄存器的第2位为1,就跳到seta20.1符号处执行,知道第2位为0,代表缓冲区为空
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
//发送0xd1命令到0x64端口
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
//同上 到此,A20激活完成。
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
// 转入保护模式,这里需要指定一个临时的GDT,来翻译逻辑地址。这里使用的GDT通过gdtdesc段定义,它翻译得到的物理地址和虚拟地址相同,所以转换过程中内存映射不会改变
lgdt gdtdesc //载入gdt
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
// 打开保护模式标志位,将cr0寄存器的第0位置1,cpu转入保护模式
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
// 由于上面的代码已经打开了保护模式了,所以这里要使用逻辑地址,不是之前实模式的地址了。
PROT_MODE_CSEG值是0x8。根据段选择子的定义,0x8表示为:
INDEX TI CPL
0000 0000 0000 1 00 0
INDEX代表GDT中的索引,TI代表使用GDTR中的GDT,CPL代表处于特权级。
PROT_MODE_CSEG选择子选择了GDT中的第1个段描述符。这里使用的gdt就是变量gdt,下面可以看到gdt的第1个段描述符的基地址是0x0000,所以经过映射后和转换前的内存映射的物理地址一样。
.code32 # Assemble for 32-bit mode
protcseg:
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
// 重新初始化各个段寄存器。
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
// 调用bootmain函数
# If bootmain returns (it shouldn't), loop.
spin:
jmp spin
// bootmain函数返回后,循环
# Bootstrap GDT 引导程序的GDT
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
接下来分析跳转到的bootmain函数
#include <inc/x86.h>
#include <inc/elf.h>
/**********************************************************************
* This a dirt simple boot loader, whose sole job is to boot
* an ELF kernel image from the first IDE hard disk.
*
* DISK LAYOUT
* * This program(boot.S and main.c) is the bootloader. It should
* be stored in the first sector of the disk.
*
* * The 2nd sector onward holds the kernel image.
*
* * The kernel image must be in ELF format.
*
* BOOT UP STEPS
* * when the CPU boots it loads the BIOS into memory and executes it
*
* * the BIOS intializes devices, sets of the interrupt routines, and
* reads the first sector of the boot device(e.g., hard-drive)
* into memory and jumps to it.
*
* * Assuming this boot loader is stored in the first sector of the
* hard-drive, this code takes over...
*
* * control starts in boot.S -- which sets up protected mode,
* and a stack so C code then run, then calls bootmain()
*
* * bootmain() in this file takes over, reads in the kernel and jumps to it.
**********************************************************************/
#define SECTSIZE 512
#define ELFHDR ((struct Elf *) 0x10000) // scratch space 定义一个指向内存中 ELF 文件头存放位置的结构体指针。定义 ELF 文件头应该存放在内存的 0x10000 处
void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);
void
bootmain(void)
{
struct Proghdr *ph, *eph;
// read 1st page off disk
// 将文件的前4KB读入内存,4KB 为一页,其中包括 ELF 文件头以及程序头表
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// is this a valid ELF?
// 检查ELF的魔数,检查是否是有效的ELF文件
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// load each program segment (ignores ph flags)
// 从ELF文件头读取ELF Header的e_phoff和e_phnum字段,分别表示Segment结构在ELF文件中的偏移和项数。其中的 (uint8_t *) 是为了让 ELFHDR 指针每 +1 增加 1 而不是 32 位指针默认的 +4。
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
// 将每一个Segment从ph->p_offset对应的扇区读到物理内存ph->p_pa处。
for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)(p_pa是该段的加载地址(物理地址))
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
// call the entry point from the ELF header
// note: does not return! (从ELF头调用内核代码入口点,不再返回)
((void (*)(void)) (ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.(将内核“偏移量”处的“count”字节读取到物理地址“pa”。)
// Might copy more than asked (可能实际复制的字节数多于请求的)
// pa 代表该段的加载地址, count 代表该段在内存中所占字节数,
// offset 代表该段在磁盘文件中的相对于文件首的偏移
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count; // 找到内存中加载地址的最末端
// round down to sector boundary
// 由于硬盘中的每一扇区加载到内存的时候都需要 512 字节对齐,
于是在这里把起始加载地址向下对齐到 512 字节的倍数的地址处
pa &= ~(SECTSIZE - 1);
// translate from bytes to sectors, and kernel starts at sector 1
// 将在硬盘中的偏移由字节数转换成扇区数,
由于内核可执行程序是从磁盘的第二个扇区开始存储的,所以需要加 1
offset = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.(如果读取很慢,我们可以一次读取多个扇区
可能写入到内存的字节数多于要求的,但没有关系,因为我们是递增加载的)
while (pa < end_pa) {
// Since we haven't enabled paging yet and we're using
// an identity segment mapping (see boot.S), we can
// use physical addresses directly. This won't be the
// case once JOS enables the MMU. (因为我们还没有
启用分页,而是使用一致的段映射(见 boot.S),所以可以直接使用物理地址
一旦 JOS 允许 MMU 将不会是这种情况)
readsect((uint8_t*) pa, offset);
pa += SECTSIZE;
offset++;
}
}
void
waitdisk(void)
{
// wait for disk reaady
// 循环直到硬盘准备好
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
void
readsect(void *dst, uint32_t offset)
{
// wait for disk to be ready
// 等待直到硬盘准备好
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
// 等待直到硬盘准备好
waitdisk();
// read a sector
// 读取一个扇区的数据
insl(0x1F0, dst, SECTSIZE/4);
}
可以看到,bootmain函数的作用是将存储在磁盘中的内核映像加载到内存中,然后跳转到内核入口点。
接下来,我们分析内核映像在内存中的分布,首先可以使用readelf命令分析一下内核的构成
可以看到,程序入口地址是0x10000c,有三个程序头,接下来看一下该文件的程序头表
可以看到,.text的起始地址是0x00100000,与程序的入口地址相差12个字节,这是因为在.text开始处定义了三个魔数。
由此,我们可以总结出当前的内存模型
3. 内核
3.1 切换页机制
由上文可知,内核的链接地址(运行地址)和加载地址是不同的,这是因为操作系统内核往往喜欢在很高的虚拟地址上链接运行,比如0xf0100000,以便将处理器虚拟地址空间的低位部分留给用户程序使用。这通过kern/entry.S中切换页机制,并且以kern/entrypgdir.c中手动定义的、静态的初始化页目录和页表来做到这一点。entry_pgdir
将 0xf0000000 到 0xf0400000 范围内的虚拟地址转换为物理地址 0x00000000 到 0x00400000,以及将虚拟地址 0x00000000 到 0x00400000 转换为物理地址 0x00000000 到 0x00400000。下面我们来分析它的代码
/* See COPYRIGHT for copyright information. */
#include <inc/mmu.h>
#include <inc/memlayout.h>
# Shift Right Logical
#define SRL(val, shamt) (((val) >> (shamt)) & ~(-1 << (32 - (shamt))))
###################################################################
# The kernel (this code) is linked at address ~(KERNBASE + 1 Meg),
# but the bootloader loads it at address ~1 Meg.
#
# RELOC(x) maps a symbol x from its link address to its actual
# location in physical memory (its load address).
###################################################################
#define RELOC(x) ((x) - KERNBASE)
#define MULTIBOOT_HEADER_MAGIC (0x1BADB002)
#define MULTIBOOT_HEADER_FLAGS (0)
#define CHECKSUM (-(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS))
###################################################################
# entry point
###################################################################
.text // 代码段开始处
# The Multiboot header // 定义3个四字节魔数(Multiboot规范要求)
.align 4
.long MULTIBOOT_HEADER_MAGIC
.long MULTIBOOT_HEADER_FLAGS
.long CHECKSUM
# '_start' specifies the ELF entry point. Since we haven't set up
# virtual memory when the bootloader enters this code, we need the
# bootloader to jump to the *physical* address of the entry point.
// “_start”指定ELF入口点。由于在引导加载程序输入此代码时尚未设置虚拟内存,因此需要引导加载程序跳转到入口点的物理地址。
.globl _start
_start = RELOC(entry)
.globl entry
entry:
movw $0x1234,0x472 # warm boot
# We haven't set up virtual memory yet, so we're running from
# the physical address the boot loader loaded the kernel at: 1MB
# (plus a few bytes). However, the C code is linked to run at
# KERNBASE+1MB. Hence, we set up a trivial page directory that
# translates virtual addresses [KERNBASE, KERNBASE+4MB) to
# physical addresses [0, 4MB). This 4MB region will be
# sufficient until we set up our real page table in mem_init
# in lab 2.
# Load the physical address of entry_pgdir into cr3. entry_pgdir
# is defined in entrypgdir.c.
movl $(RELOC(entry_pgdir)), %eax
movl %eax, %cr3 // 将页目录的物理基地址保存到cr3寄存器中
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0 // 将cr0的最高位置1,打开分页功能
# Now paging is enabled, but we're still running at a low EIP
# (why is this okay?). Jump up above KERNBASE before entering
# C code.
// 现在已启用分页,但我们仍在低EIP下运行。在输入C代码之前跳到KERNBASE上方。
mov $relocated, %eax
jmp *%eax
relocated:
# Clear the frame pointer register (EBP)
# so that once we get into debugging C code,
# stack backtraces will be terminated properly.
movl $0x0,%ebp # nuke frame pointer // 清除帧指针的值
# Set the stack pointer
movl $(bootstacktop),%esp // 设置堆栈指针
# now to C code
call i386_init // 进入C代码
# Should never get here, but in case we do, just spin.
spin: jmp spin // 如果因为某种情况回到这里,就循环
.data
###################################################################
# boot stack
###################################################################
.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:
还有一个问题,为啥么要采用RELOC(entry_pgdir)
呢?RELOC()
是一个宏定义(x) - KERNBASE
,由于在未开启分页机制之前,cpu不会进行地址转换,也就是虚拟地址等于物理地址,如果直接使用entry_pgdir
,这个符号的物理地址便是以地址0xf0000000
为基址,而上一节引导程序的分析,entry_pgdir
实际存储物理内存0x100000
之上,因此会出错。当将cr0
最高位置1开启分页模式后,就可以直接使用虚拟地址了,例如bootstacktop
。总的来说就是etnry_pgdir
结构所在的物理内存在RELOC(entry_pgdir)
处
3.2 格式化打印到控制台
通读kern/printf.c、lib/printfmt.c和kern/console.c,确保您了解它们之间的关系。回答以下问题。
- 解释printf.c和 console.c之间的接口。具体console.c导出的是什么功能 ?printf.c如何使用这个函数 ?
答:两者直接的调用关系如下图所示
console.c导出向控制台一次读写一个字符的接口void cputchar(int c)
- 从
console.c
解释以下内容:
1 if (crt_pos >= CRT_SIZE) {
2 int i;
3 memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
4 for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
5 crt_buf[i] = 0x0700 | ' ';
6 crt_pos -= CRT_COLS;
7 }
答:分析一下cga_putc
static void
cga_putc(int c)
{
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;
switch (c & 0xff) {
case '\b':
if (crt_pos > 0) {
crt_pos--;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;
case '\n': // 遇到换行符,将光标移至下一行,crt_pos加80(一行80个字符)
crt_pos += CRT_COLS;
/* fallthru */
case '\r': // 遇到的是回车符,将光标移到当前行的开头,crt_pos-(crt_pos%80)
crt_pos -= (crt_pos % CRT_COLS);
break;
case '\t': // 制表符
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;
default: // 普通字符,直接将ascii码填到显存中
crt_buf[crt_pos++] = c; /* write the character */
break;
}
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) { // 判断是否需要滚屏,文本模式下一页屏幕最多显示25*80个字符,如果超出了,就要将2-25行往上提一行
int i;
memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}
/* move that little blinky thing */
outb(addr_6845, 14);
outb(addr_6845 + 1, crt_pos >> 8);
outb(addr_6845, 15);
outb(addr_6845 + 1, crt_pos);
}
- 逐步跟踪以下代码的执行:
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
- 在对cprintf()的调用中,fmt指向什么?ap指向什么?
- 列出(按执行顺序)对cons_putc、va_arg和vcprintf的每个调用。对于cons_putc,也列出其参数。对于va_arg,列出ap在调用前后指向的内容。对于vcprintf,列出其两个参数的值。
答:首先来分析一下源代码
int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;
va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);
return cnt;
}
由宏定义可知,
- va_list表示可变参数列表类型,实际上就是一个char指针
- va_start用于获取函数参数列表中可变参数的首指针(获取函数可变参数列表)
- va_arg用于获取当前ap所指的可变参数并将ap指针移向下一可变参数
- va_end用于结束对可变参数的处理。实际上,va_end被定义为空.它只是为实现与va_start配对(实现代码对称和"代码自注释"功能)
因此,fmt指向字符串"x %d, y %x, z %d\n"
,ap指向参数x。
调用过程:
vcprintf(&("x %d, y %x, z %d\n"), &(x))// 两个参数:"x %d, y %x, z %d\n"
var_list{x,y,z} cons_putc('x')
cons_putc(' ')
va_arg() //调用前:x 调用后:y
cons_putc('1')
cons_putc(',')
cons_putc('y')
cons_putc(' ')
va_arg() //调用前:y 调用后:z
cons_putc('3')
cons_putc(',')
cons_putc('z')
cons_putc(' ')
va_arg() //调用前:z,调用后:
cons_putc('4')
cputchar('\n')
- 运行以下代码
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);
输出是什么?解释如何按照上一个练习的逐步方式得出这个输出。
输出取决于 x86 是小端字节序这一事实。如果 x86 是 big-endian 你会设置什么i
来产生相同的输出?您需要更改 57616
为不同的值吗?
答:输出为He110 World
。
case 'x':
num = getuint(&ap, lflag);
base = 16;
number:
printnum(putch, putdat, num, base, width, padc);
break;
static void
printnum(void (*putch)(int, void*), void *putdat,
unsigned long long num, unsigned base, int width, int padc)
{
// first recursively print all preceding (more significant) digits
if (num >= base) {
printnum(putch, putdat, num / base, base, width - 1, padc);
} else {
// print any needed pad characters before first digit
while (--width > 0)
putch(padc, putdat);
}
// then print this (the least significant) digit
putch("0123456789abcdef"[num % base], putdat); // 从头到尾打印十六进制数,57676的十六进制是e110
}
case 's':
if ((p = va_arg(ap, char *)) == NULL)
p = "(null)";
if (width > 0 && padc != '-')
for (width -= strnlen(p, precision); width > 0; width--)
putch(padc, putdat);
for (; (ch = *p++) != '\0' && (precision < 0 || --precision >= 0); width--)
if (altflag && (ch < ' ' || ch > '~'))
putch('?', putdat);
else
putch(ch, putdat); // 按72,6c,64的acsii码的顺序打印字符r,l,d
for (; width > 0; width--)
putch(' ', putdat);
break;
如果改为大端序,57616无需改动,只需将i = 0x726c6400
- 在下面的代码中,将在 之后打印
'y='
什么?(注意:答案不是具体值。)为什么会出现这种情况?
cprintf("x=%d y=%d", 3);
答:打印%(ebp) + 20
地址处的值
- 假设 GCC 更改了它的调用约定,以便它按声明顺序将参数压入堆栈,以便最后一个参数被压入最后。您将如何更改
cprintf
它的接口,以便仍然可以向它传递可变数量的参数?
答:将接口改为cprintf(..., int n, const char* fmt)
,n表示可变参数的个数
练习8:我们省略了一小段代码——使用“%o”形式的模式打印八进制数所必需的代码。查找并填写此代码片段。
case 'o':
// Replace this with your code.
num = getuint(&ap, lflag);
base = 8;
printnum(putch, putdat, num, base, width, padc);
break;
结果正确
3.2 堆栈
函数调用过程中栈空间保存了什么,一图以言之
- 执行call指令前,函数调用者将参数入栈,按照函数列表从右到左的顺序入栈
- call指令会自动将当前eip入栈,ret指令将自动从栈中弹出该值到eip寄存器
- 被调用函数负责:将ebp入栈,esp的值赋给ebp。所以反汇编一个函数会发现开头两个指令都是
push %ebp, mov %esp,%ebp
因此,对于栈指针(esp),指向当前正在使用的栈的底端。将值压入栈涉及减少栈指针,然后将值写入栈指针指向的位置。从栈中弹出一个值涉及读取栈指针指向的值,然后增加栈指针。在 32 位模式下,栈只能保存 32 位值,并且 esp 始终可以被 4 整除。
而对于帧指针(ebp),在进入调用函数时,函数的开始代码通常通过将前一个函数的帧指针压入栈中来保存它,然后在函数运行期间将当前的esp值复制到ebp中。如果一个程序中的所有函数都遵守这个约定,那么在程序执行期间的任何给定点,都可以通过保存的ebp链追溯栈指针并确定究竟是什么嵌套的函数调用序列到达程序中的特定点。
练习9:确定内核初始化栈的位置,以及栈在内存中的确切位置。内核如何为其栈保留空间?栈指针初始化指向这个保留区域的哪个“端”?
内核初始化栈的代码
movl $(bootstacktop),%esp
进入gdb调试,可知esp寄存器中保存的值是0x0010f000
在进入内核前,分配32KB的内存空间供堆栈使用。
.data
###################################################################
# boot stack
###################################################################
.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE // 8*4kb
.globl bootstacktop
因此,内核栈在内存中的准确位置是0x00107000 —— 0x0010efff
栈指针指向该区域的底端,即栈空间的最高处。
练习10:要熟悉x86上的C调用约定,请在obj/kern/kernel.asm中找到test_backtrace函数的地址,在那里设置一个断点,并检查每次在内核启动后调用它时会发生什么。test_backtrace的每个递归嵌套级别在堆栈上推送多少个32位字,这些字是什么?
答:递归调用自身时,test_backtrace
先将x-1
压栈,再将返回地址压栈,再将%ebp
压栈,共3个32位字。
练习11:按照上面指定的方式实现回溯函数。使用与示例中相同的格式,否则评分脚本将会混淆。当您认为它工作正常时,运行make grade以查看其输出是否符合我们的评分脚本的预期,如果不符合则修复它。
Stack backtrace:
ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...
练习12:修改堆栈回溯函数以显示每个eip的函数名、源文件名和对应于该eip的行号
答(练习11-12):
首先回答一个问题,在debuginfo_eip
中,__STAB_*
来自何处?
在文件kern/kernel.ld中查找__STAB_*:
.stab : {
PROVIDE(__STAB_BEGIN__ = .);
*(.stab);
PROVIDE(__STAB_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}
.stabstr : {
PROVIDE(__STABSTR_BEGIN__ = .);
*(.stabstr);
PROVIDE(__STABSTR_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}
可以看到,__STAB_BEGIN__
,__STAB_END__
,__STABSTR_BEGIN__
,__STABSTR_END__
符号分别代表.stab段和.stabstr段开始与结束的地址。
可以看到stabs的行记录。其中,每个字段的含义如下所示:
- Symnum是符号索引,把整个符号表看做一个数组,Symnum是当前符号的数组下标。
- n_type是符号类型,FUN指函数名(在String字段下显示函数名),SLINE值在text段中的行号。
- n_othr表示目前没被使用,固定值为0
- n_desc表示在文件中的行号。
- n_value表示地址。需要注意的是,只有FUN符号类型的地址是绝对地址,SLINE符号的地址是偏移地址,其实际地址是
函数入口地址+偏移地址
。
因此,编写如下代码:
在monitor.c
的commands
中插入:
{"backtrace", "Backtrace the call of functions", mon_backtrace},
在monitor.c
中插入:
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t * p = (uint32_t *)read_ebp();
struct Eipdebuginfo info;
while (p != 0) {
uint32_t eip = *(p + 1);
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", p, eip, *(p + 2), *(p + 3), *(p + 4), *(p + 5), *(p + 6));
debuginfo_eip((uintptr_t)eip, &info);
cprintf("%s:%d", info.eip_file, info.eip_line);
cprintf(": %.*s+%d\n", info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr);
p = (uint32_t*)*p;
}
return 0;
}
在kdebug.c
的debuginfo_eip
函数中插入
// Your code here.
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if (lline > rline)
return -1;
info->eip_line = stabs[lline].n_desc;
实验结果