MIT 6.828 Lab2

Exercise 1

In the file kern/pmap.c, you must implement code for the following functions (probably in the order given).

boot_alloc()
mem_init() (only up to the call to check_page_free_list(1))
page_init()
page_alloc()
page_free()

check_page_free_list() and check_page_alloc() test your physical page allocator. You should boot JOS and see whether check_page_alloc() reports success. Fix your code so that it passes. You may find it helpful to add your own assert()s to verify that your assumptions are correct.

总的来说,就是需要实现

boot_alloc()
mem_init() // 只需要实现到check_page_free_list(1)这里。
page_init()
page_alloc()
page_free()

bootloader读入内核代码之后的分布

内存分布

这里主要是引用一下这个图:

可以发现,在刚读取完成内核代码到内存之后。形成的结构如上图所示。

开启分页

分页的机制在《x86汇编语言-从实模式到保护模式》里面介绍得比较清楚。这里就不多说,只引用一张图

UVPT

UVPT: 需要看 https://pdos.csail.mit.edu/6.828/2014/lec/l-josmem.html

UVPT = 0x3BD << 22

Case 1

如果一个虚拟地址等于0x3BD << 22 | 0x3BD << 12 | 0
使用kern_pgdir的时候,这个虚拟地址MCU处理之后就是kern_pgdir

所以 *(0x3BD << 22 | 0x3BD << 12 | 0) == kern_pgdir
那么,假设有如下代码:

int *p = 0x3BD << 22 | 0x3BD << 12 | 0;
for (int i = 0; i < 1024; i++) {
p[i]; // 这里实际上就是在遍历kern_pgdir[i];
}

所以0x3BD << 22 | 0x3BD << 12 | 0~4096byte地址,就是映射到了char kern_pgdir[4096]
这个数组。

Case 2

那么假设虚拟地址是0x3BD << 22 | 0~1024 | 0这个时候情况又是如何?比如用户程序访问0x3BD << 22这个地址。

1. CR3 = kern_pgdir
2. 高10位值为0x3BD, 页目录项为kern_pgdir[0x3BD]
kern_pgdir[PDX(UVPT)] = PADDR(kern_pgdir) | PTE_U | PTE_P;
不用说,又回到了kern_pgdir
3. 中间10位为0,那么页表为kern_pgdir[0x3BD]指向的物理地址的第0项。
由于kern_pgdir[0x3BD]指向的是kern_pgdir,所以这里页表为kern_pgdir[0]。
kern_pgdir[0]用户程序是可以访问的。在Case 1里面已经验证过了。
4. 虚拟地址就是kern_pgdir[0]指向的物理地址的第0项。不过这个物理地址,用户程序不一定可以访问。

所以总结一下就是0x3BD << 22 | 0 ~ 1024 | xxxx。这个时候,前面20位的地址一解释。指向的地址就是一个
页表地址kern_pgdir[i]。如果再加上offset = xxxx。实际上这个地址,虚拟地址不一定可以访问。

总结

0x3BD << 22 | 0x3BD << 12 | 0~4096byte地址,就是映射到了char kern_pgdir[4096]

int *p = 0x3BD << 22 | 0x3BD << 12 | 0;
for (int i = 0; i < 1024; i++) {
p[i]; // 这里实际上就是在遍历uint32_t kern_pgdir[i];
}

UVPT ~ UVPT + 4MB这个虚拟地址应该会有至少一个页目录项。一个页目录项刚好点4MB。结合Case 2。可以发现,
用户程序通过kern_pgdir这个数组里面的内容,就知道这4MB空间里面的页表的内容。比如是否有物理地址映射?
是否已经分配内存。比如:

int *p = 0x3BD << 22 | 0x3BD << 12 | 0;
for (i = 0; i < 1024; i++) {
if (*(p+i) & 0x01) {
// 二级页表存在
} else {
// 二级页表不存在
}
}

boot_alloc

这个函数首先来,end变量是定义在kernel.ld文件里面的。指向了内核地址的尾巴。
也就是在向内核要虚拟地址的时候,可以从这里开始要。

注意这里要到的地址是虚拟地址。不是物理地址。

// This simple physical memory allocator is used only while JOS is setting
// up its virtual memory system. page_alloc() is the real allocator.
//
// If n>0, allocates enough pages of contiguous physical memory to hold 'n'
// bytes. Doesn't initialize the memory. Returns a kernel virtual address.
//
// If n==0, returns the address of the next free page without allocating
// anything.
//
// If we're out of memory, boot_alloc should panic.
// This function may ONLY be used during initialization,
// before the page_free_list list has been set up.
static void *
boot_alloc(uint32_t n)
{
static char *nextfree; // virtual address of next byte of free memory
char *result;
// Initialize nextfree if this is the first time.
// 'end' is a magic symbol automatically generated by the linker,
// which points to the end of the kernel's bss segment:
// the first virtual address that the linker did *not* assign
// to any kernel code or global variables.
if (!nextfree) {
extern char end[];
nextfree = ROUNDUP((char *) end, PGSIZE);
}
// Allocate a chunk large enough to hold 'n' bytes, then update
// nextfree. Make sure nextfree is kept aligned
// to a multiple of PGSIZE.
//
// LAB 2: Your code here.
if (0 == n) {
return nextfree;
}
n = ROUNDUP(n, PGSIZE);
result = nextfree;
nextfree += n;
return result;
}

mem_init

这个函数里面分为页管理链表分配空间。

// Your code goes here:
n = sizeof(struct PageInfo) * npages;
pages = (struct PageInfo*)boot_alloc(n);
memset(pagees, 0, n);

注意看注释,要求全部初始化为0的。

page_init

这里要做的事情很简单,就是把空闲的内存通过双向链表串起来。

void
page_init(void)
{
// The example code here marks all physical pages as free.
// However this is not truly the case. What memory is free?
// 1) Mark physical page 0 as in use.
// This way we preserve the real-mode IDT and BIOS structures
// in case we ever need them. (Currently we don't, but...)
// 2) The rest of base memory, [PGSIZE, npages_basemem * PGSIZE)
// is free.
// 3) Then comes the IO hole [IOPHYSMEM, EXTPHYSMEM), which must
// never be allocated.
// 4) Then extended memory [EXTPHYSMEM, ...).
// Some of it is in use, some is free. Where is the kernel
// in physical memory? Which pages are already in use for
// page tables and other data structures?
//
// Change the code to reflect this.
// NB: DO NOT actually touch the physical memory corresponding to
// free pages!
size_t i;
// 这里采用的思路是:凡是不能被分配的内存页,都不加到链表里面。
// 只处理可以被使用的内存页。
assert(!page_free_list);
// 1. page 0是要被用来做实模式的IDT BIOS数据结构,尽管从来不会用,以后也不会用
// 这不是浪费么。不管了。
// 2. 接下来的[PGSIZE, npages_basemem * PGSIZE)是可用的。
for (i = 1; i < npages_basemem; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
// 3. IO空洞,绝对不能使用。
// 链表直接跳过。不管。
// 4. 直接找到kernel内存的尾巴
// 注意这里取了PADDR之后要除PGSIZE.
for (i = PADDR(boot_alloc(0))/PGSIZE; i < npages; i++) {
pages[i].pp_ref = 0;
pages[i].pp_link = page_free_list;
page_free_list = &pages[i];
}
}

page_alloc

page_alloc的功能就是从链表中分配一页。这里需要完全照着注释来实现。比如pp_link要设置为空。pp_ref不要去修改。

struct PageInfo *
page_alloc(int alloc_flags)
{
// Fill this function in
struct PageInfo *ret = page_free_list;
if (!page_free_list) {
return NULL;
}
page_free_list = ret->pp_link;
ret->pp_link = NULL;
if (alloc_flags & ALLOC_ZERO) {
memset(page2kva(ret), 0, PGSIZE);
}
return ret;
}

page_free

这里会把一个pp_ref为0的页表放回到链表中。

void
page_free(struct PageInfo *pp)
{
// Fill this function in
// Hint: You may want to panic if pp->pp_ref is nonzero or
// pp->pp_link is not NULL.
assert(!pp->pp_ref);
assert(!pp->pp_link);
pp->pp_link = page_free_list;
page_free_list = pp;
}

Excersize 3

  xp/Nx paddr -- 查看paddr物理地址处开始的,N个字的16进制的表示结果。
  info registers -- 展示所有内部寄存器的状态。
  info mem -- 展示所有已经被页表映射的虚拟地址空间,以及它们的访问优先级。
  info pg -- 展示当前页表的结构。

Excersize 4

pgdir_walk

pgdir_walk只是在给定的页表中查一下虚拟地址的页目录项。并不需要页目录项与虚拟地址绑定。如果存在页目录项,那么只需要直接返回相应的页目录项。
就是在写if/else的时候要考虑各种情况。

pte_t *
pgdir_walk(pde_t *pgdir, const void *va, int create)
{
// Fill this function in
assert(pgdir);
pde_t *pde = &pgdir[PDX(va)];
if (!(*pde & PTE_P)) {
if (!create) return NULL;
struct PageInfo *page = page_alloc(ALLOC_ZERO);
if (!page) return NULL;
page->pp_ref++;
assert(page->pp_ref == 1);
assert(page->pp_link == NULL);
*pde = page2pa(page) | PTE_P | PTE_U | PTE_W;
}
// 获取页表项
return (pte_t*)(KADDR(PTE_ADDR(*pde))) + PTX(va);
}

boot_map_region

把一个虚拟内存映射一个物理内存。

static void
boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
// Fill this function in
for (uint32_t i = 0; i < size; i += PGSIZE) {
pte_t *pte = pgdir_walk(pgdir, (const void *)va, true);
*pte = pa | perm | PTE_P;
va += PGSIZE;
pa += PGSIZE;
}
}

注意这里的设置。

page_insert

写这个函数的时候,要特别仔细地把注释读一下。boot_map_region在映射的时候。
并没有考虑到页表的占用释放回收什么的(直接把这个空间里面的物理内存映射到了kern_pgdir里面),这是因为boot_map_region这个函数操作的都是已经在kernel里面申请好的内存。并且页表的管理是从boot_alloc(0)之后才开始管理的。所以内核里面的页在添加到的kernel_pgdir的时候并不会用PageInfo来进行管理。

int
page_insert(pde_t *pgdir, struct PageInfo *pp, void *va, int perm)
{
// Fill this function in
pte_t *pte = pgdir_walk(pgdir, va, true);
if (!pte)
return -E_NO_MEM;
pp->pp_ref++;
if (*pte & PTE_P)
page_remove(pgdir, va);
*pte = page2pa(pp) | perm | PTE_P;
tlb_invalidate(pgdir, va);
return 0;
}

page_lookup

这个函数的功能就是给定一个虚拟地址。然后根据这个虚拟地址来找到相应的物理地址。

struct PageInfo *
page_lookup(pde_t *pgdir, void *va, pte_t **pte_store)
{
// Fill this function in
pte_t *pte = pgdir_walk(pgdir, va, false);
if (!pte || !(*pte & PTE_P)) return NULL;
if (pte_store) *pte_store = pte;
return pa2page(PTE_ADDR(*pte));
}

page_remove

page_remove这个函数的功能主要是取消虚拟地址与物理地址的关联。
这里需要注意的是。释放了虚拟内存与物理内存的映射之后。并没有直接把相应的物理内存直接放到链表里面。这主要是因为,可能存在多个虚拟内存映射到同一个物理内页面的情况。虽然这个虚拟内存不在与这个物理内存发生联系了。但是其他的虚拟地址还是有可能继续与这个物理内存关联并且还在使用的。

void
page_remove(pde_t *pgdir, void *va)
{
// Fill this function in
pte_t *pte = NULL;
struct PageInfo *pp = page_lookup(pgdir, va, &pte);
if (!pp) return;
*pte = 0;
page_decref(pp);
tlb_invalidate(pgdir, va);
}

Excersize 5

mem_init()

这里主要是要把内核里面一些区域设置到页目录中去。

// Your code goes here:
// 注意这里用的是PTSIZE
// 一种保守的作法是把pages align到页大小之后再进行映射。
boot_map_region(kern_pgdir, UPAGES, PTSIZE, PADDR(pages), PTE_U);
//////////////////////////////////////////////////////////////////////
// Use the physical memory that 'bootstack' refers to as the kernel
// stack. The kernel stack grows down from virtual address KSTACKTOP.
// We consider the entire range from [KSTACKTOP-PTSIZE, KSTACKTOP)
// to be the kernel stack, but break this into two pieces:
// * [KSTACKTOP-KSTKSIZE, KSTACKTOP) -- backed by physical memory
// * [KSTACKTOP-PTSIZE, KSTACKTOP-KSTKSIZE) -- not backed; so if
// the kernel overflows its stack, it will fault rather than
// overwrite memory. Known as a "guard page".
// Permissions: kernel RW, user NONE
// Your code goes here:
boot_map_region(kern_pgdir, KSTACKTOP-KSTKSIZE, KSTKSIZE, PADDR(bootstack), PTE_W);
//////////////////////////////////////////////////////////////////////
// Map all of physical memory at KERNBASE.
// Ie. the VA range [KERNBASE, 2^32) should map to
// the PA range [0, 2^32 - KERNBASE)
// We might not have 2^32 - KERNBASE bytes of physical memory, but
// we just set up the mapping anyway.
// Permissions: kernel RW, user NONE
// Your code goes here:
// KERNBASE 0x F0000000
// 2^32 = 0x 1000 0000
// 256MB
boot_map_region(kern_pgdir, KERNBASE, 0x10000000, 0, PTE_W);

接下来还有一系列小问题。比如

kern_pgdir里面的内容是什么?

这个问题其实只需要看一下mem_init里面的boot_map_region就可以了。

What entries (rows) in the page directory have been filled in at this point? What addresses do they map and where do they point? In other words, fill out this table as much as possible:
Entry Base Virtual Address Points to (logically):
1023 ? Page table for top 4MB of phys memory
1022 ? ?
. ? ?
. ? ?
. ? ?
2 0x00800000 ?
1 0x00400000 ?
0 0x00000000 [see next question]

内存保护

We have placed the kernel and user environment in the same address space. Why will user programs not be able to read or write the kernel’s memory? What specific mechanisms protect the kernel memory?

这个问题是因为页表里面有各种保护机制。

最大能支持的内存是多少?

* ULIM, MMIOBASE --> +------------------------------+ 0xef800000
* | Cur. Page Table (User R-) | R-/R- PTSIZE
* UVPT ----> +------------------------------+ 0xef400000
* | RO PAGES | R-/R- PTSIZE
* UPAGES ----> +------------------------------+ 0xef000000

这里UPAGES对应的就是pages这个链表。程序空间在利用虚拟地址访问pages的时候。一旦大于4MB,比如越界到了UVPT这个空间。由于这部分虚拟地址是放到了kern_pgdir里面。所以这个时候超出的部分就不能访问了。也就意味着:物理空间上,pages占用多大空间都没有问题。但是虚拟地址空间在访问UPAGES的时候就是不能访问全。因此,能支持的内存大小就变成了2GB。

How much space overhead is there for managing memory, if we actually had the maximum amount of physical memory? How is this overhead broken down?

这里是说现在管理内存的开销是多少?其实直接看虚拟地址就可以明白了。一个页目录表占用4MB。而UPAGES占用了4MB。所以合在一起就是8MB。如果要减小开销。

内存访问的问题

Q1. Revisit the page table setup in kern/entry.S and kern/entrypgdir.c. Immediately after we turn on paging, EIP is still a low number (a little over 1MB). At what point do we transition to running at an EIP above KERNBASE?
Q2. What makes it possible for us to continue executing at a low EIP between when we enable paging and when we begin running at an EIP above KERNBASE? Why is this transition necessary?

Q1. 当还在利用kern/entry.Skern/entrypgdir.c的时候。一打开分页的时候,EIP还在一个低端的物理地址上。是通过什么方式让EIP跑到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

Q2. 问的是说,实际上当打开分页的时候,EIP还是在低地址运行。然后再通过跳转跑到高端地址。打开分页的时候,EIP指向下一条指令。即`move $relocated, %eax`的内存地址。为什么访问这个内存地址不会失败?

kern/entrypgdir.c 中将 0 ~ 4MB 和 KERNBASE ~ KERNBASE + 4 MB 的虚拟地址都映射到了 0 ~ 4MB 的物理地址上,因此无论 EIP 在高位和低位都能执行。必需这么做是因为如果只映射高位地址,那么在开启分页机制的下一条语句就会crash。
`

1.2 Chrono Duration

首先看一下标准库的使用以及概念。首先是一个概念ratioratio表示的是一个概念,就是单位。

Durations表示的是一段时间。比如10秒钟,2个小时。

Ratio

template <intmax_t N, intmax_t D = 1> class ratio;

先看DD表示一秒可以被切分成多少份。N就是表示有多少份。

ratio<1, 1> seconds

这里就是表示1秒。

ratio<1, 1000> microseconds

这个就是表示毫秒。

另外一种简单的观点,就是N/D秒就是ratio的值。

比如

ratio<3600, 1> hours 3600/1 = 3600
ratio<60, 1> minutes 60/1 = 60
ratio<1, 1> seconds
ratio<1, 1000> microseconds
ratio<1, 1000000> microseconds
ratio<1, 1000000000> nanosecons

注意我们自己可以定义Period,比如ratio<1, -2>表示单位时间是-0.5秒。

Durations

这个用来表示一段时间,比如一天,2个小时,10秒钟。

template <class Rep, class Period = ratio<1> > class duration;

Rep表示一种数值类型,用来表示Period的数量,比如int float double
Period是ratio类型,用来表示时间度量单位。比如经过了2纳秒。这里Period就是用ratio<1, 1000000000> nanosecons来表示。

转换

template <class ToDuration, class Rep, class Period>
constexpr ToDuration duration_cast (const duration<Rep,Period>& dtn);

这个函数的作用是把一种duration转换成另外一种durantion的表达。

常用类型

当然,对于很多常用的时间类型,标准库里面也进行了定义。不过需要注意的是,这些不是一个常量,而是一个类型。

/// nanoseconds
typedef duration<int64_t, nano> nanoseconds;
/// microseconds
typedef duration<int64_t, micro> microseconds;
/// milliseconds
typedef duration<int64_t, milli> milliseconds;
/// seconds
typedef duration<int64_t> seconds
/// minutes
typedef duration<int64_t, ratio< 60>> minutes
/// hours
typedef duration<int64_t, ratio<3600>> hours;

所以在使用的时候,可以直接使用这些时间单位。比如休眠一段时间。

std::this_thread::sleep_for(std::chrono::seconds(3)); //休眠三秒
std::this_thread::sleep_for(std::chrono::milliseconds(100)); //休眠100毫秒

Count

有时候,也可以计算有多个少X为单位的时间。这个X可以是毫秒,也可能是纳秒等等。

#include <chrono>
#include <iostream>
int main() {
// 这里只有3毫秒,所以count结果为3.
std::chrono::milliseconds ms{3}; // 3 毫秒
// 6000 microseconds constructed from 3 milliseconds
// 这里单位变成了micro second
// 2*ms则是6ms,也就是6000微秒
std::chrono::microseconds us = 2*ms; //6000微秒
// 30Hz clock using fractional ticks
std::chrono::duration<double, std::ratio<1, 30>> hz30(3.5);
// 这里的输出是3.5
std::cout << "hz30 has" << hz30.count() << " ticks\n";
std::cout << "3 ms duration has " << ms.count() << " ticks\n"<< "6000 us duration has " << us.count() << " ticks\n";
return 0;
}

程序的输出

hz30 has3.5 ticks
3 ms duration has 3 ticks
6000 us duration has 6000 ticks

时间的计算

#include <chrono>
#include <iostream>
int main() {
std::chrono::minutes t1( 10 );
std::chrono::seconds t2( 60 );
std::chrono::seconds t3 = t1 - t2;
std::cout << t3.count() << " second" << std::endl;
return 0;
}

程序的输出

540 second

时间的转换

#include <chrono>
#include <iostream>
int main() {
std::chrono::minutes t1( 10 );
std::chrono::seconds t2( 60 );
std::chrono::seconds t3 = t1 - t2;
std::cout << t3.count() << " second" << std::endl;
std::cout <<
std::chrono::duration_cast<std::chrono::minutes>(t3).count()
<< "minutes" << std::endl;
return 0;
}

1.2 Chrono time_point

时钟的说明:

  • system_clock:从系统获取的时钟;
  • steady_clock:不能被修改的时钟;
  • high_resolution_clock:高精度时钟,实际上是system_clock或者steady_clock的别名。

steady_clock可以获取稳定可靠的时间间隔,后一次调用now()的值和前一次的差值是不因为修改了系统时间而改变,它保证了稳定的时间间隔。

除了表示时间片段,有时候我们可能更想知道一个具体的时间点,这里就要提一下time_pointtime_point表示的是:

  • 用来获取1970.1.1以来的秒数和当前的时间
  • 可以做一些时间的比较和算术运算
  • 可以和ctime库结合起来显示时间

看一下类的声明:

class system_clock
{
public:
typedef microseconds duration;
typedef duration::rep rep;
typedef duration::period period;
typedef chrono::time_point<system_clock> time_point;
static const bool is_steady = false;
static time_point now() _NOEXCEPT;
static time_t to_time_t (const time_point& __t) _NOEXCEPT;
static time_point from_time_t(time_t __t) _NOEXCEPT;
};
#ifndef _LIBCPP_HAS_NO_MONOTONIC_CLOCK
class steady_clock
{
public:
typedef nanoseconds duration;
typedef duration::rep rep;
typedef duration::period period;
typedef chrono::time_point<steady_clock, duration> time_point;
static const bool is_steady = true;
static time_point now() _NOEXCEPT;
};
typedef steady_clock high_resolution_clock;
#else
typedef system_clock high_resolution_clock;
#endif

这里本来应该是有三种clock的,也就是

  • system_clock
  • steady_clock
  • high_resolution_clock

但是从这里的定义来看,难道有的平台没有steady_clock?并且high_resolution_clock的定义是随着_LIBCPP_HAS_NO_MONOTONIC_CLOCK。这里的意思是说,

  • 如果没有单调的时钟,那么就需要自己定义一个steady_clock
  • 如果有,就不用定义了,直接用system_clock

获取一段代码的运行时间

// steady_clock example
#include <iostream>
#include <ctime>
#include <ratio>
#include <chrono>
int main ()
{
using namespace std::chrono;
steady_clock::time_point t1 = steady_clock::now();
std::cout << "printing out 1000 stars...\n";
for (int i=0; i<1000; ++i) std::cout << "*";
std::cout << std::endl;
steady_clock::time_point t2 = steady_clock::now();
// 这里转换成为时间段,只不过是用double
// 来表示,所以可以表示比如0.5秒,0.01秒这种case.
duration<double> time_span = duration_cast<duration<double>>(t2 - t1);
std::cout << "It took me " << time_span.count() << " seconds.";
std::cout << std::endl;
return 0;
}

system_clock

// system_clock example
#include <iostream>
#include <ctime>
#include <ratio>
#include <chrono>
int main ()
{
using std::chrono::system_clock;
// 这里声明一天的类型
// 其实用std::chrono::hours day(24);
std::chrono::duration<int, std::ratio<60*60*24> > one_day(1);
// 取得当前的时间点
system_clock::time_point today = system_clock::now();
// 当前的时间点,加上一段时间
system_clock::time_point tomorrow = today + one_day;
std::time_t tt;
tt = system_clock::to_time_t (today);
// 注意ctime是把time_t转换成为字符串
std::cout << "today is: " << ctime(&tt);
tt = system_clock::to_time_t(tomorrow);
std::cout << "tomorrow will be: " << ctime(&tt);
return 0;
}

时间的加减

#include <iostream>
#include <iomanip>
#include <ctime>
#include <chrono>
int main()
{
using namespace std::chrono;
system_clock::time_point now = system_clock::now();
std::time_t last = system_clock::to_time_t(now - std::chrono::hours(24));
  std::time_t next= system_clock::to_time_t(now - std::chrono::hours(24));
std::cout << "One day ago, the time was "<< std::put_time(std::localtime(&last), "%F %T") << '\n';
  std::cout << "Next day, the time was "<< std::put_time(std::localtime(&next), "%F %T") << '\n';
}

定时器

void fun() {
cout<<"hello word"<<endl;
}
int main() {
timer t; //开始计时
fun()
cout<<t.elapsed()<<endl; //打印fun函数耗时多少毫秒
}

自定义定时器

#include<chrono>
using namespace std;
using namespace std::chrono;
class Timer {
public:
Timer() : m_begin(high_resolution_clock::now()) {}
void reset() { m_begin = high_resolution_clock::now(); }
//默认输出秒
  double elapsed() const {
    return duration_cast<duration<double>>(high_resolution_clock::now() - m_begin).count();
  }
//默认输出毫秒
//int64_t elapsed() const
//{
//return duration_cast<chrono::milliseconds>(high_resolution_clock::now() - m_begin).count();
//}
//微秒
int64_t elapsed_micro() const
{
return duration_cast<chrono::microseconds>(high_resolution_clock::now() - m_begin).count();
}
//纳秒
int64_t elapsed_nano() const
{
return duration_cast<chrono::nanoseconds>(high_resolution_clock::now() - m_begin).count();
}
//秒
int64_t elapsed_seconds() const
{
return duration_cast<chrono::seconds>(high_resolution_clock::now() - m_begin).count();
}
//分
int64_t elapsed_minutes() const
{
return duration_cast<chrono::minutes>(high_resolution_clock::now() - m_begin).count();
}
//时
int64_t elapsed_hours() const
{
return duration_cast<chrono::hours>(high_resolution_clock::now() - m_begin).count();
}
private:
time_point<high_resolution_clock> m_begin;
};

MIT 6.828 Lab1

原地址:

lab1
开坑。无视我的渣渣翻译。

简介

实验主要是分为三个部分。

  • 第一部分主要是需要熟悉x86的汇编语言,QEMU x86模拟器,以及PC上电之后的启动流程。
  • 第二部分是验证6.828内核的boot loader,这里部分需要看的代码主要是位于boot目录。
  • 第三部分主要是开始实施JOS操作系统。也就是MIT 6.828的内核部分。

软件的设置

首先我的环境是沿用了Linux-2.6.26内核调试环境搭建 里面的环境。
这里原来的网页讲了很多MIT学校里面上机环境。这部分没有必要去搞。

mkdir ~/6.828
cd ~/6.828
git clone https://pdos.csail.mit.edu/6.828/2017/jos.git lab
cd lab

第一部分: PC Bootstrap

这个练习的作用完全是为了让你熟悉一下x86的汇编语言。以及PC启动的流程。并且可以熟练地使用QEMU/QEMU和GDB来调试了。在这里的实验里面你并不需要写任何的代码。但是需要有足够的理解来回答相应的问题。

Getting Started with x86 assembly
如果你对x86平台的汇编语言如果不是特别熟悉的话,可以使用这本书快速熟悉起来。 PC Assembly Language Book 这本书是一个不错的开始。因为里面包含了很多经典的和新的材料。

注意:这本书用的汇编语法是采用的NASM。但是在后面做实验的过程却是需要使用GAS语法。也就是AT & T的语法。 Brennan’s Guide to Inline Assembly。

介绍了一些关于内联汇编需要注意的地方。关于内联汇编,可以看下面两本书

  • Linux内核完全剖析 赵炯
  • Linux内核情景分析

这两本书里面都讲到了C语言里面会用到的内联汇编。实际上,除此之外,也可以看一下xv6的代码。然后注意一下里面inline assemble的写法。

Exercise 1.

这里主要是为了熟悉硬件&汇编的。所以并不做过多的介绍。只是把资源记在这里面。

  • the 6.828 reference page 提供了各种关于汇编的资料。
  • 推荐阅读 in Brennan’s Guide to Inline Assembly. 内联汇编语法。
  • 短一点的关于80386硬件特性 80386 Programmer’s Reference Manual
  • X86详细的架构信息 IA-32 Intel Architecture Software Developer’s Manuals
    太长了。后面有时间再去翻这些本来是用来查的工具书。

Simulating the x86

在作业里面并没有使用真实的PC,而是使用的是QEMU的模拟器。通过这个模拟器,可以很方便地与GDB一起合作,打断点什么的。Linux-2.6.26内核调试环境搭建 这里面已经上手玩过一次了。

在6.828里面,主要还是继续使用QEMU。一个现代的模拟器。

# cd lab
# make
+ as kern/entry.S
+ cc kern/entrypgdir.c
+ cc kern/init.c
+ cc kern/console.c
+ cc kern/monitor.c
+ cc kern/printf.c
+ cc kern/kdebug.c
+ cc lib/printfmt.c
+ cc lib/readline.c
+ cc lib/string.c
+ ld obj/kern/kernel
+ as boot/boot.S
+ cc -Os boot/main.c
+ ld boot/boot
boot block is 380 bytes (max 510)
+ mk obj/kern/kernel.img

注意 (由于我的实验环境采用的是32位的系统,不会存在这个问题。)

(If you get errors like "undefined reference to `__udivdi3'", you probably don't have the 32-bit gcc multilib. If you're running Debian or Ubuntu, try installing the gcc-multilib package.)

当make执行完成之后,运行结果就会生成obj/kern/kernel.img磁盘文件。利用这个虚拟的磁盘来启动一个PC。
仅管这里提供两种方式。

make qemu

or

make qemu-nox

尽量还是使用make qemu-nox,因为这种方式,在没有桌面环境的情况下也是可以正常工作的。

# make qemu-nox
Booting from Hard Disk...
6828 decimal is XXX octal!
entering test_backtrace 5
entering test_backtrace 4
entering test_backtrace 3
entering test_backtrace 2
entering test_backtrace 1
entering test_backtrace 0
leaving test_backtrace 0
leaving test_backtrace 1
leaving test_backtrace 2
leaving test_backtrace 3
leaving test_backtrace 4
leaving test_backtrace 5
Welcome to the JOS kernel monitor!
Type 'help' for a list of commands.
K>

如果想要退出Qemu,那么只需要执行: Ctrl+a x.

There are only two commands you can give to the kernel monitor, help and kerninfo.
K> help
help - display this list of commands
kerninfo - display information about the kernel
K> kerninfo
Special kernel symbols:
entry f010000c (virt) 0010000c (phys)
etext f0101a75 (virt) 00101a75 (phys)
edata f0112300 (virt) 00112300 (phys)
end f0112960 (virt) 00112960 (phys)
Kernel executable memory footprint: 75KB
K>

仅管看起来很简单。但是实际上这里面生成的obj/kern/kernel.img文件是可以放到真实的物理硬件上来执行的。

批注:上面的内容其实动手的部分不多。大部分还是要求去熟悉汇编。怎么讲呢?最好的还是用到的时候再学吧。

The PC’s Physical Address Space

接下来会介绍PC的启动。一个PC的物理地址空间可以分成以下组成。

We will now dive into a bit more detail about how a PC starts up. A PC's physical address space is hard-wired to have the following general layout:
+------------------+ <- 0xFFFFFFFF (4GB)
| 32-bit |
| memory mapped |
| devices |
| |
/\/\/\/\/\/\/\/\/\/\
/\/\/\/\/\/\/\/\/\/\
| |
| Unused |
| |
+------------------+ <- depends on amount of RAM
| |
| |
| Extended Memory |
| |
| |
+------------------+ <- 0x00100000 (1MB)
| BIOS ROM | 64KB
+------------------+ <- 0x000F0000 (960KB)
| 16-bit devices, |
| expansion ROMs |
+------------------+ <- 0x000C0000 (768KB)
| VGA Display | 128KB
+------------------+ <- 0x000A0000 (640KB)
| |
| Low Memory |
| |
+------------------+ <- 0x00000000

为了兼容性的考虑。PC在一开始是16位的。但是地址线却有20位。也就是能够寻址1MB的地址空间。其中640KB为低端内存。
这段内容非常重要,所以需要好好注意。Lab2内存分配的时候会用到这个。
除去低端的640KB。那么1MB还留下 1024KB - 640KB = 384KB。这384KB的范围就是
0x000A0000 ~ 0x000FFFFF

其中BIOS占掉了顶端的64KB的内存。尽管后来内存从1MB前进到了16MB,后来又进展到了4GB。但是PC的内存布局还是没有改变。主要是为了兼容性考虑。因此,32位的CPU在这里还是会有个洞。0x000A0000 〜 0x00100000

原本低端内存可以连续的1MB,变成了两段

  • 0~640KB,
  • 然后1MB〜更高的内存。

  • “conventional memory” (the first 640KB)
  • “extended memory” 1MB以上

最新的x86架构可以支持4GB以上的物理内存了。所以RAM也可以扩展到0xFFFFFFFF以上的地址。在这种情况下BIOS需要设置第二个洞。也就是在32位地址的顶端。但是JOS目前来说,只是支持256MB的物理内存。所以这里设计时只考虑到了具有32位地址的地址空间的情况。

The ROM BIOS

接下来的操作里面,你会用到QEMU的debug功能来深入了解IA-32计算机的启动流程。
需要做以下事情:
打开两个termainal

一个窗口运行make qemu-nox-gdb
另外一个窗口运行make gdb

# make gdb
GNU gdb (GDB) 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
+ target remote localhost:26000
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb)

这里能够通过gdb一下子就连接上来,这是因为提供了一个.gdbinit文件,能够自动地attach到想要调试的程序上来。当然前提是已经把这个debug的程序运行起来的情况。
第一条要执行的指令就是ljmp

[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b

从这个要执行的指令可以看出来。

IBM PC开始执行的物理位置是0x000ffff0。这个是位于1MB里面的很高的地址。也就是ROM BIOS最顶上64KB的顶部。

[f000:fff0]可以看出来,此时CS = 0xf000 and IP = 0xfff0.

如果执行完这条指令之后 CS = 0xf000 and IP = 0xe05b.
为什么QEMU一开始执行的时候是这样的?这是因为8088的芯片就是这样的。
在IBM最原始的PC就是这么使用的。总之一句话,当PC上电之后。CS:IP两个寄存器就强制被设置为这个值。CS = 0xf000 and IP = 0xfff0

注意16位的寻址模式

: physical address = 16 * segment + offset.
16 * 0xf000 + 0xfff0 # in hex multiplication by 16 is
= 0xf0000 + 0xfff0 # easy--just append a 0.
= 0xffff0

1MB尾巴上的地址就是0xffff0 + 16bytes。除了放个ljmp之外,你也不要指望16bytes能做啥了。

Exercise 2.

用gdb的si指令来单步执行。看一下在启动的时候都做了些什么。可能需要查看的资料是Phil Storrs I/O Ports Description in 6.828 reference materials page.

不需要非常详细,只需要大概了解一下就可以了。

  • 当BIOS在运行的时候。会在内存的超始地址处建立一个各种设备的16位的中断向量表,并且初始化各种设备。利用这个中断向量表,就可以成功输出”Start SeaBIOS”这种信息。
  • 当各种初始化的硬件设备工作做完之后。BIOS就开始找启动设备,最终会在硬盘的起始sector里面找0x55, 0xaa这标志位的扇区。如果有,就加载到0x7c00处开始运行。也就是(31KB)的位置。

Part 2: The Boot Loader

软盘和磁盘一般都是被切分为512 byte区域,也就是扇区。一个扇区是一个块设备的最小传输单位。每次读写都是必须是扇区的整数倍。
如果这个软盘或者磁盘是可启动的。那么第一个扇区被叫做可启动扇区。这里也存放的就是可启动的代码。当BIOS找到这个扇区的时候,就把这512 byte读到0x7c00至0x7cff。然后用一个跳转指令

ljmp 0x07c0:0000

对于CD-ROM的支持,需要看 “El Torito” Bootable CD-ROM Format Specification.
对于6.828来说,由于完全是使用硬盘来启动的。所以在硬盘的开始必须是boot loader,并且这个boot loader必须是512 bytes大小。
这个boot loader主要是由两个文件构成

boot/boot.S
boot/main.c

这里需要好好地读一下这个文件。然后知道这两个文件做了些啥。
boot loader把模式切到了32位保护模式。因为只有在这种模式下软件才可以访问1MB+以上的内存空间。保护模式在1.2.7 and 1.2.8 of PC Assembly Language 进行了介绍。 Intel architecture manuals也对这个有详细介绍。

在16位模式下只需要考虑段地址。
其次,需要注意的是boot loader读了kernel。从硬盘到内存。在操作的时候走的是PC的寄存器操作。如果想要了解更多,可以读一下"IDE hard drive controller" in the 6.828 reference page.的这一部分。

当你理解了boot loader的源码之后。接下来可以看一下obj/boot/boot.asm。b *0x7c00就可以把断点设置在0x7c00`。

b *0x7c00 # 设置断点在0x7c00
si 表示单步执行
si 2 表示接着执行两条指令
c 表示不再单步执行。直接开始运行了

查看内存中的指令,有时候可能需要查看内存操作的结果。这个时候需要用

x/i 调试命令
x/Ni 基中N是指令的数目; 会把指定内存里面的指令翻译成汇编。

Exercise 3.

特别需要注意看一下lab tools guide。里面介绍了很多GDB命令的section。这些命令对于开发操作系统特别有帮助。
0x7c00这里设置一个断点。这个位置是启动扇区被加载到的内存位置。持续执行,一直跑到那个断点那里(按一下c就可以一直跑到断点那里了 )。大概看一下boot/boot.S里面的代码。并且注意看一下obj/boot/boot.asm来跟踪当前所处的位置。

也可以用x/i命令来查看内存里面的汇编指令。并且与原本的boot loader 里面的代码进行比较。

接下来就可以通过bootmain函数进入到boot/main.c里面。然后开始执行readsect函数。注意readsect里面的汇编指令。一路跟踪readsect函数的执行,然后回到bootmain函数

一直到读完余下的磁盘上的内核扇区。找出当循环结之后,会跳到哪里去执行?(肯定是跳到内核的第一条指令那里开始执行了)需要在那里设置一个断点。

接下来就需要回答如下问题:
At what point does the processor start executing 32-bit code? What exactly causes the switch from 16- to 32-bit mode?

Q: 在哪里CPU开始进入到32位模式。
哪条指令是带来了16位到32位的切换。
A:
movl %eax, %cr0

这条指令把cr0寄存器的最后一位,即PE位打开。也就是开启了保护模式。
CPU也就从这里开始进入到了32位模式。

只不过必须要清空一下流水线。这是规定。
所以接下来需要进入到32位的时候。
就需要用一个长跳转来进入到32位的代码那里。

# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg

需要注意的是,在长跳转的时候,
这个段描述符已经指向了代码段$PROT_MODE_CSEG

也就是lgdt已经是设置好的。

What is the last instruction of the boot loader executed, and what is the first instruction of the kernel it just loaded?

Q: boot loader执行的最后一条指令是什么?kernel执行的第一条指令是什么?
A:
这个最简单的办法是打开obj/boot/boot.asm。找到bootmain函数的最后一条指令就可以了。
// call the entry point from the ELF header
// note: does not return!
((void (*)(void)) (ELFHDR->e_entry))();
7d61: ff 15 18 00 01 00 call *0x10018

就是main.c中这个代码

((void (*)(void)) (ELFHDR->e_entry))();

Where is the first instruction of the kernel?
内核的第一条指令位于kern/entry.S里面的第一条指令

movw $0x1234,0x472 # warm boot

也可以通过命令查看:

$ objdump -f kernel
kernel: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

How does the boot loader decide how many sectors it must read in order to fetch the entire kernel from disk? Where does it find this information?

Q: boot loader在加载kernel的时候,是如何决定有多少个扇区需要读取的?
这部分信息是如何确定?
A: 详细的信息会涉及到较多的ELF头文件格式。
void bootmain(void)
{
struct Proghdr *ph, *eph;
// 这里把ELF文件的头读到内存里面。
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// is this a valid ELF?
// 检查一下ELF头文件的magic是不是与指定的ELF的格式相等。
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// load each program segment (ignores ph flags)
// ELF header里面指明了第一个program section header的位置。
// 也指明了最后一个位置在哪里
// [ph, end_of_program_header)
// 这里面表明了每个程序段的大小以及位置。
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++)
// p_pa是需要被加载的地址。
// p_memsz指的是需要的物理内存的大小
// p_offset指的是在逻辑上相对于整个文件头的偏移量。
// 虽然这里p_memsz表示的时候需要占用的内存的大小。
// 实际上也是磁盘上需要读取的数据量的大小。
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
// 当把内存加载到内存里面之后。开始从这里开始执行第一条指令。
((void (*)(void)) (ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
// pa表示的是内存地址开始放磁盘内容的地方。
// count表示需要读的内容的长度。
// offset表示相对程序文件头的偏移量。这个传进来的参数还没有考虑到
// kernel在磁盘便移量。
void readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
// pa是开始放内容的内存起始地址
// end_pa是指末尾地址
end_pa = pa + count;
// 操作的时候,起始地址取一个512byte对齐的边界
// 比如,如果起始地址addr =256, 那么内存开始放的地址就是addr = 0
pa &= ~(SECTSIZE - 1);
// 注意,这里把offset修正过了。由于第一个扇区放的是
// boot loader。所以内存实际上是从第2个扇区开始放的。
// 如果代码要更加友好一点。应该改成。
// kernel_start_sector = 1;
// offset = (offset / SECTSIZE) + kernel_start_sector;
// 这样代码就比较容易理解了。
offset = (offset / SECTSIZE) + 1;
// 本来这里可以用不着一个扇区一个扇区读的。
// 可以读得快一点的。linux kernel里面就是利用较
// 复杂的代码来加速了这个读取过程。
while (pa < end_pa) {
// 这里把磁盘扇区,注意,这个时候offset表示的是哪个扇区
// 把这个扇区的内容读到内存地址pa处。
readsect((uint8_t*) pa, offset);
// 读完之后,移动内存地址以及扇区。
pa += SECTSIZE;
offset++;
}
}

虽然作业里面有个问题没有被问到。但是实际上是值得自己思考的。那就是。如果自己在写代码的时候,写的代码如下:

int a[10];
int *p = a;

编译链接之后,a和p都会有个地址。这个地址应该就是程序员看到的虚拟地址空间的地址。平时自己写C程序可以不用关心这个虚拟地址与物理地址的对应。因为操作系统会利用分页技术把这个虚拟地址与真实的物理地址做好相应的对应。
但是现在在写的是操作系统。什么都是需要自己来进行操作的。所以考虑的一点是。
如果在编译的时候,这个地址被设定为

p = 0x00ffffa;

但是整个kernel加载的虚拟地址是0xF0100000

这个时候就需要认真想一想了。是不是加载到虚拟地址0xF0200000也可以?物理地址的设定是什么?

思路如下:

kernel在编译的时候,采用的虚拟地址是0xF0100000。
这是是在链接定义文件
kern/kernel.ld指定的链接的起始地址。
root@debug:~/6.828/lab# cat kern/kernel.ld
/* Link the kernel at this address: "." means the current address */
. = 0xF0100000;
/* AT(...) gives the load address of this section, which tells
the boot loader where to load the kernel in physical memory */
.text : AT(0x100000) {
*(.text .stub .text.* .gnu.linkonce.t.*)
}

kernel在加载到的物理地址是

#define ELFHDR ((struct Elf *) 0x10000)

这两者之间的映射关系与用户的用应程序没有什么实质上的差异。也是通过页表来实现的。

kern/entrypgdir.c

完成了内核页表的设置。

__attribute__((__aligned__(PGSIZE)))
pde_t entry_pgdir[NPDENTRIES] = {
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT]
= ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W
};

这里把物理起始地址4MB以及0xF0000000这里的4MB都映射到物理地址的[0, 4MB)

Loading the Kernel

接下来就可以仔细地看一下C语言里面的内容。这里主要是看boot loader里面的C语言。即boot/main.c

Exercise 4.

这里需要对C语言的指针非常熟悉。最好的C语言的引用书籍就是《The C Programming Language》
除非你对C语言已经非常熟悉。那么不要错过这些内容。
在开始读boot/main.c的时候。
需要对ELF二进制格式有一定的了解。这是因为经过编译链接之后,生成的obj/kern/kernel内核映像是一个ELF格式的。 "Executable and Linkable Format"

关于ELF有如下完整的信息。 the ELF specification on our reference page, 但是实际上你是没有必要仔细从头到尾地看这个文档。里面有很多复杂的部分是关于动态链接库的支持的。

对于6.828这门课来说,只需要解的是ELF执行格式里面有个头,里面包含了各种每个程序段的加载信息。通过这些加载信息可以把整个程序正确地加载到内存里面。boot loader并不会去修正里面指针的指向。只是把磁盘上的kernel映像加开到内存里面,然后开始执行之。

ELF程序文是由一个固定长度的ELF头开始的。紧接着的是一个动态可变的程序头list。每个程序头,指明了每个程序段需要被加载的位置,长度,相对于整个程序的偏移量。

注意:相对的是整个程序头的偏移量,而不是相对于磁盘头的偏移量

由于在编译完成之后,才会把kernel写入磁盘的某个扇区。在编译的时候是无法知道会被写入到哪个扇区的。所以编译的时候只能说把相对的位置写入到ELF里面。
这些各种程序头比较常见的有以下几个:

.text: The program's executable instructions.
.rodata: Read-only data, such as ASCII string constants produced by the C compiler. (We will not bother setting up the hardware to prohibit writing, however.)
.data: The data section holds the program's initialized data, such as global variables declared with initializers like int x = 5;.

当链接器在计算内存部局的时候,也会保留足够的空间给各种未初始化的全局变量(初始化为0)。一般而言这个空间被称之为.bss段。
由于全部都是被设置为0.所以也就没有必要记录这些内容在ELF里面。所以ELF文件里面只需要记住.bss在内存里面的起始位置,以及大小。然后加载方需要确保这些.bss在内存里面正确的设置。以及初始化为0。
可以通过如下命令来查看obj/kern/kernel里面的names, sizes, 以及各种链接地址。

$ objdump -h obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00001917 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 00000714 f0101920 00101920 00002920 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 00003889 f0102034 00102034 00003034 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 000018af f01058bd 001058bd 000068bd 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 0000a300 f0108000 00108000 00009000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .bss 00000644 f0112300 00112300 00013300 2**5
ALLOC
6 .comment 0000002b 00000000 00000000 00013300 2**0
CONTENTS, READONLY

真正要了解这个文件,需要查看链接设定文件:

kern/kernel.ld

通过这个文件可以知道程序被加载的虚拟地址(VMA),物理地址(LMA)分别是如何指定的。也可以通过File off查看相对文件的偏移量。这个File off偏移量是如何指定的?这个非常有意思。刚好在boot/main.c里面就是一开始就读了了8个扇区,也就是0x1000 bytes

ELF文件格式简图

kernel在编译和链接的时候,其虚拟地址与物理地址是不一样的。在加载的时候,也就相应地需要设置好页表。

但是考虑一下boot loader。在加载的时候,肯定是没有什么页表等着给你用的。BIOS可不会给你设置页表。所以

$ objdump -h obj/boot/boot.out
Sections:
Idx Name Size VMA LMA File off Algn
# 注意这里的VMA与LMA是完全一致的。这是由于被加载进BIOS的时候,没有页表与段表可用。
# size = 380 bytes.
# 这里面就包含了boot loader所需要的所有信息。
0 .text 0000017c 00007c00 00007c00 00000074 2**2
CONTENTS, ALLOC, LOAD, CODE
# size = 176这个段实际上是没有什么用的?
1 .eh_frame 000000b0 00007d7c 00007d7c 000001f0 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
# 接下来的三个段,里面都是用于DEBUG的。所以真实用的时候,不会被用到。
2 .stab 000007b0 00000000 00000000 000002a0 2**2
CONTENTS, READONLY, DEBUGGING
3 .stabstr 00000846 00000000 00000000 00000a50 2**0
CONTENTS, READONLY, DEBUGGING
4 .comment 0000002b 00000000 00000000 00001296 2**0
CONTENTS, READONLY
$ objdump -x obj/boot/boot.out
obj/boot/boot.out: file format elf32-i386
obj/boot/boot.out
architecture: i386, flags 0x00000012:
EXEC_P, HAS_SYMS
start address 0x00007c00
Program Header:
LOAD off 0x00000074 vaddr 0x00007c00 paddr 0x00007c00 align 2**2
filesz 0x0000022c memsz 0x0000022c flags rwx
STACK off 0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
filesz 0x00000000 memsz 0x00000000 flags rwx
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000017c 00007c00 00007c00 00000074 2**2
CONTENTS, ALLOC, LOAD, CODE
1 .eh_frame 000000b0 00007d7c 00007d7c 000001f0 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 000007b0 00000000 00000000 000002a0 2**2
CONTENTS, READONLY, DEBUGGING
3 .stabstr 00000846 00000000 00000000 00000a50 2**0
CONTENTS, READONLY, DEBUGGING
4 .comment 0000002b 00000000 00000000 00001296 2**0
CONTENTS, READONLY
SYMBOL TABLE:
00007c00 l d .text 00000000 .text
00007d7c l d .eh_frame 00000000 .eh_frame
00000000 l d .stab 00000000 .stab
00000000 l d .stabstr 00000000 .stabstr
00000000 l d .comment 00000000 .comment
00000000 l df *ABS* 00000000 obj/boot/boot.o
00000008 l *ABS* 00000000 PROT_MODE_CSEG
00000010 l *ABS* 00000000 PROT_MODE_DSEG
00000001 l *ABS* 00000000 CR0_PE_ON
00007c0a l .text 00000000 seta20.1
00007c14 l .text 00000000 seta20.2
00007c64 l .text 00000000 gdtdesc
00007c32 l .text 00000000 protcseg
00007c4a l .text 00000000 spin
00007c4c l .text 00000000 gdt
00000000 l df *ABS* 00000000 main.c
00000000 l df *ABS* 00000000
00007c6a g F .text 00000012 waitdisk
00007d0a g F .text 00000072 bootmain
00007cd1 g F .text 00000039 readseg
00007e2c g .eh_frame 00000000 __bss_start
00007c7c g F .text 00000055 readsect
00007e2c g .eh_frame 00000000 _edata
00007e2c g .eh_frame 00000000 _end
00007c00 g .text 00000000 start

为什么说只有text段是被使用到的呢?

boot/Makefrag
$(OBJDIR)/boot/boot: $(BOOT_OBJS)
@echo + ld boot/boot
$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o $@.out $^
$(V)$(OBJDUMP) -S $@.out >$@.asm
$(V)$(OBJCOPY) -S -O binary -j .text $@.out $@ # 380 bytes
$(V)perl boot/sign.pl $(OBJDIR)/boot/boot

boot loader自己是没有利用ELF格式的。需要用boot sector固定的格式。不过加载的时候,却是采用了ELF格式来加载内核。
如果要详细看一下kernel各个段的加载情况,可以通过如下命令:
需要注意program header与sections的区别。program header是给加载程序方用的。
section是给写程序的人以及与编译器看的。

$ objdump -x obj/kern/kernel
obj/kern/kernel: file format elf32-i386
obj/kern/kernel
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c
Program Header:
LOAD off 0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
filesz 0x0000716c memsz 0x0000716c flags r-x
LOAD off 0x00009000 vaddr 0xf0108000 paddr 0x00108000 align 2**12
filesz 0x0000a300 memsz 0x0000a944 flags rw-
STACK off 0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
filesz 0x00000000 memsz 0x00000000 flags rwx
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00001917 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 00000714 f0101920 00101920 00002920 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 00003889 f0102034 00102034 00003034 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 000018af f01058bd 001058bd 000068bd 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 0000a300 f0108000 00108000 00009000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .bss 00000644 f0112300 00112300 00013300 2**5
ALLOC
6 .comment 0000002b 00000000 00000000 00013300 2**0
CONTENTS, READONLY
SYMBOL TABLE:
f0100000 l d .text 00000000 .text
f0101920 l d .rodata 00000000 .rodata
f0102034 l d .stab 00000000 .stab
f01058bd l d .stabstr 00000000 .stabstr
f0108000 l d .data 00000000 .data
f0112300 l d .bss 00000000 .bss
00000000 l d .comment 00000000 .comment
00000000 l df *ABS* 00000000 obj/kern/entry.o
f010002f l .text 00000000 relocated
f010003e l .text 00000000 spin
00000000 l df *ABS* 00000000 entrypgdir.c
00000000 l df *ABS* 00000000 init.c
00000000 l df *ABS* 00000000 console.c
f01001a0 l F .text 0000001c serial_proc_data
f01001bc l F .text 00000044 cons_intr
f0112320 l O .bss 00000208 cons
f0100200 l F .text 00000117 kbd_proc_data
f0112300 l O .bss 00000004 shift.1330
f0101b00 l O .rodata 00000100 shiftcode
f0101a00 l O .rodata 00000100 togglecode
f01019e0 l O .rodata 00000010 charcode
f0100317 l F .text 000001e0 cons_putc
f0112528 l O .bss 00000002 crt_pos
f011252c l O .bss 00000004 crt_buf
f0112530 l O .bss 00000004 addr_6845
f0112534 l O .bss 00000001 serial_exists
f0112200 l O .data 00000100 normalmap
f0112100 l O .data 00000100 shiftmap
f0112000 l O .data 00000100 ctlmap
00000000 l df *ABS* 00000000 monitor.c
f0101de4 l O .rodata 00000018 commands
00000000 l df *ABS* 00000000 printf.c
f01008eb l F .text 00000013 putch
00000000 l df *ABS* 00000000 kdebug.c
f010094b l F .text 000000dd stab_binsearch
00000000 l df *ABS* 00000000 printfmt.c
f0100c10 l F .text 000000ef printnum
f0100cff l F .text 0000001d sprintputch
f0102008 l O .rodata 0000001c error_string
00000000 l df *ABS* 00000000 readline.c
f0112540 l O .bss 00000400 buf
00000000 l df *ABS* 00000000 string.c
00000000 l df *ABS* 00000000
f010000c g .text 00000000 entry
f0101337 g F .text 00000020 strcpy
f0100513 g F .text 00000012 kbd_intr
f010079f g F .text 0000000a mon_backtrace
f01000f8 g F .text 0000005f _panic
f010009d g F .text 0000005b i386_init
f01014d4 g F .text 00000068 memmove
f0101208 g F .text 00000028 snprintf
f0100d44 g F .text 0000046c vprintfmt
f0100525 g F .text 0000004a cons_getc
f0100931 g F .text 0000001a cprintf
f010153c g F .text 00000021 memcpy
f0101230 g F .text 000000ca readline
f0111000 g O .data 00001000 entry_pgtable
f0100040 g F .text 0000005d test_backtrace
f01011b0 g F .text 00000058 vsnprintf
f0112300 g .data 00000000 edata
f010056f g F .text 000000f2 cons_init
f01058bc g .stab 00000000 __STAB_END__
f01058bd g .stabstr 00000000 __STABSTR_BEGIN__
f01017c0 g F .text 00000157 .hidden __umoddi3
f01004f7 g F .text 0000001c serial_intr
f0101690 g F .text 00000124 .hidden __udivdi3
f0100682 g F .text 0000000a iscons
f01015b3 g F .text 000000d3 strtol
f0101318 g F .text 0000001f strnlen
f0101357 g F .text 0000002b strcat
f0112940 g O .bss 00000004 panicstr
f0112944 g .bss 00000000 end
f0100157 g F .text 00000045 _warn
f010146b g F .text 0000001c strfind
f0101917 g .text 00000000 etext
0010000c g .text 00000000 _start
f01013af g F .text 0000003d strlcpy
f0101412 g F .text 00000038 strncmp
f0101382 g F .text 0000002d strncpy
f010155d g F .text 00000039 memcmp
f0100661 g F .text 00000010 cputchar
f0101487 g F .text 0000004d memset
f0100671 g F .text 00000011 getchar
f0100d1c g F .text 00000028 printfmt
f010716b g .stabstr 00000000 __STABSTR_END__
f01013ec g F .text 00000026 strcmp
f0100a28 g F .text 000001d9 debuginfo_eip
f01008fe g F .text 00000033 vcprintf
f0110000 g .data 00000000 bootstacktop
f0110000 g O .data 00001000 entry_pgdir
f0108000 g .data 00000000 bootstack
f0102034 g .stab 00000000 __STAB_BEGIN__
f0101300 g F .text 00000018 strlen
f010144a g F .text 00000021 strchr
f01006d5 g F .text 000000ca mon_kerninfo
f01007a9 g F .text 00000142 monitor
f0101596 g F .text 0000001d memfind
f0100690 g F .text 00000045 mon_help

因此,这里可以看出来。program headers 就是写在ELF头里面给加载程序用的。但是这里主要关注:

  • vaddr 加载后程序内部引用的各种虚拟地址基地址
  • paddr 加载到的物理基地址
  • memsz 这个程序段的大小
  • filesz 这个程序段所在位置起始位置。注意,这里是相对于文件头而言。 !!

这么四个变量。使用-x 输出太长了。虽然里面有些信息lab2会用到。不过对于lab1而言。只需要如下命令就可以理解了。

$ objdump -p obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Program Header:
LOAD off 0x00001000 vaddr 0xf0100000 paddr 0x00100000 align 2**12
filesz 0x0000716c memsz 0x0000716c flags r-x
LOAD off 0x00009000 vaddr 0xf0108000 paddr 0x00108000 align 2**12
filesz 0x0000a300 memsz 0x0000a944 flags rw-
STACK off 0x00000000 vaddr 0x00000000 paddr 0x00000000 align 2**4
filesz 0x00000000 memsz 0x00000000 flags rwx

Exercise 5.

这里比较有意的是试一下修改boot/Makefrag文件中的-Ttext里面的起始地址。如果修改了起始地址。
如此一来。后面程序加载的地址就发生了变化。但是需要注意的是:如果指令里面都是

mov ax, bx
mov cx, ax
mov dx, ax

这样的操作是不会出错的。随便改了加载地址,也可以随意进行加载。都可以work。
但是这样的操作就会出问题了。

jmp jump_addr
addr_tag:
.db varname 0
jump_addr:

任何引用到addr_tagjump_addr这两个地址的地方都会出错。所以一旦不对应之后。运行必然出错。
需要注意的是kernel的地址与boot loader则是不一样的。

Exercise 6.

接下来需要利用GDB的x命令查看内存中的指令。 GDB manual 有各种详细的细节。
但是现在而言,只需要知道x/Nx addr命令就是为了查看从ADDR开始的N字的指令。需要注意的是:字长并不是完全统一的。在GNU汇编里面。一个字长是两个byte。比如xorw指令里面的w表示产的是两个byte。
重置机器。当刚进入boot loader的时候,检查在内存0x00100000开头的8个字。然后再当需要跳转到内核开始执行的时候,看一下里面的指令。为什么会不一样呢?
这个应该很简单了。在boot loader刚进来的时候, kernel还没有被加载到内存里面。
当需要跳转到内核的时候,kernel image已经被加载到了0x00100000地址处。

What is there at the second breakpoint? (You do not really need to use QEMU to answer this question. Just think.)

# The Multiboot header
.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.
.globl _start
_start = RELOC(entry)
.globl entry
entry:
movw $0x1234,0x472 # warm boot
kernel的开头就是entry.S。
(gdb) x/8x 0x100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe 0x7205c766
0x100010: 0x34000004 0x0000b812 0x220f0011 0xc0200fd8

开头的三个值刚好是

#define MULTIBOOT_HEADER_MAGIC (0x1BADB002)
#define MULTIBOOT_HEADER_FLAGS (0)
#define CHECKSUM (-(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS))

Exercise 7

1. Use QEMU and GDB to trace into the JOS kernel and stop at the movl %eax, %cr0. Examine memory at 0x00100000 and at 0xf0100000. Now, single step over that instruction using the stepi GDB command. Again, examine memory at 0x00100000 and at 0xf0100000. Make sure you understand what just happened.

Q:用Qemu和GDB跳到JOS的内核里面。并且暂停在movl %eax, %cr0这条指令这里。验证内存两个地址:0x00100000 and at 0xf0100000。接下来用s指令一条一条地执行。然后再验证一下这个两个内存地址的内容。确保你理解整个发生的过程。

首先需要明白:程序地址与寻址地址

1. 程序代码地址
2. 支持的寻址地址

如果支持的寻址地址不支持汇编里面的地址(比如页表没有建立起来)。比如:

mov %eax, *$0xf0100000

这个时候必须要知道0xf0100000真正的物理地址是什么。程序代码里直接成相应的物理地址。

#define RELOC(x) ((x) - KERNBASE)

示例1: kernel的入口地址

# '_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.
.globl _start
_start = RELOC(entry)
.globl entry
entry:

kernel在编译的时候是并不知道会被加载到哪里的。通过链接的时候kern/kernel.ld链接脚本可以指令被加载到的物理地址。但是程序的入口地址仍然需要告知ELF。
ELF执行的格式是_start是入口。如果不加任何处理,那么_start就是一个虚拟地址。这个值会反应在ELF header->e_entry值上面。(看boot/main.c)里面的跳转到内核的代码:

// call the entry point from the ELF header
// note: does not return!
((void (*)(void)) (ELFHDR->e_entry))();

这里面e_entry就指向_start值。由于从boot loader跳转到的内核的时候,还在物理地址与虚拟地址完全重合的情况。并且也没有开启分页。所以这个时候必须在kern/entry.S里面

.globl _start
_start = RELOC(entry)

_start地址改造成物理地址。这会儿,

root@debug:~/6.828/lab# objdump -f obj/kern/kernel
obj/kern/kernel: file format elf32-i386
architecture: i386, flags 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED
start address 0x0010000c

这个时候start address就是一个物理地址。

原问题的正解

首先对entry.S代码加以注释。

# Load the physical address of entry_pgdir into cr3. entry_pgdir
# is defined in entrypgdir.c.
这里把页表加载到cr3寄存器。注意加载的时候,这里是直接用的物理地址。
页表还没有打开,当然是不能用到虚拟地址了。
movl $(RELOC(entry_pgdir)), %eax
f0100015: b8 00 00 11 00 mov $0x110000,%eax
movl %eax, %cr3
f010001a: 0f 22 d8 mov %eax,%cr3
# 开启分页模式
# Turn on paging.
movl %cr0, %eax
f010001d: 0f 20 c0 mov %cr0,%eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
f0100020: 0d 01 00 01 80 or $0x80010001,%eax
movl %eax, %cr0
f0100025: 0f 22 c0 mov %eax,%cr0

所以原问题中在开启分页前去看0xf0100000地址时。肯定为0。因为在当前地址空间里面,这部分虚拟地址是没有内容的。页表也还没有。只能是假装去访问物理地址。
分页后去查看地址时。0xf01000000x00100000内容就完全一样了。这是因为把[0, 4MB)映射到了[0xf0000000, 0xf0000000 + 4MB)的地方了。
开启分页后的跳转

# 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.
mov $relocated, %eax
jmp *%eax
relocated:

当开启分页之后,立马会进行相应的跳转。这里主要是因为后面会开始执行C语言的函数了。必须设置好相应的CS:IP, esp, ebp, ss等寄存器。如果还是在物理地址空间运行。但是C语言是以为自己在虚拟地址空间运行的。

    1. CPU跑在物理地址空间上,而不是虚拟地址空间上。(尽管CS:IP会被翻译到真正的地址。)
    1. C语言认为是自己是跑在虚拟地址空间。

通过jmp,可以使得两者正常化。CPU在取指,寻址的时候,就会在有页映射的地址空间里面了。环境设置好,就可以开始跳转到C语言里面了。

2. What is the first instruction after the new mapping is established that would fail to work properly if the mapping weren’t in place? Comment out the movl %eax, %cr0 in kern/entry.S, trace into it, and see if you were right.

这个问题其实也比较扯的。主要意思是说,如果把movl %eax, %cr0删除掉会发生什么样的情况。
删除掉之后,只要后面有涉及到寻址的地方,就会立马出错。假设把这一行注释掉。

# movl %eax, %cr0
# 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.
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

那么在movl $(bootstacktop), %esp这里就立马出错了。

因为把$bootstacktop当成物理地址了。但是实际上,哪有那么大的物理地址空间。所以肯定会报错了。(万一真给了qemu那么大的物理地址空间,那边物理地址也没有内容,跳到C语言之后就会出错。)

Exercise 8

We have omitted a small fragment of code - the code necessary to print octal numbers using patterns of the form “%o”. Find and fill in this code fragment.

这里的意思是说,去修改程序,使得程序能够正确地输出%o。这里还是比较简单的,只需要参考一下%d的输出代码。

root@debug:~/6.828/lab# git diff
diff --git a/lib/printfmt.c b/lib/printfmt.c
index 28e01c9..1d3a5fa 100644
--- a/lib/printfmt.c
+++ b/lib/printfmt.c
@@ -206,10 +206,13 @@ vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
// (unsigned) octal
case 'o':
// Replace this with your code.
- putch('X', putdat);
- putch('X', putdat);
- putch('X', putdat);
- break;
+ num = getint(&ap, lflag);
+ if ((long long) num < 0) {
+ putch('-', putdat);
+ num = -(long long) num;
+ }
+ base = 8;
+ goto number;
// pointer
case 'p':

然后运行

$ make
$ make qemu-nox
6828 decimal is 15254 octal!

就会看到相应的输出了。

Explain the interface between printf.c and console.c. Specifically, what function does console.c export? How is this function used by printf.c?

console.c与printf.c里面有什么样的接口?
console.c里面暴露了哪个端口给printf.c使用?
答:
这里分析过程如下:
printf.c里面的调用链如下:

cprintf -> vcprintf -> vprintfmt -> putch -> cputchar

然后cputchar的声明是在

./inc/stdio.h:11:void cputchar(int c);

这个函数的定义是在console.c

void cputchar(int c)
{
cons_putc(c);
}
// output a character to the console
static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c);
cga_putc(c);
}

接下主要就是看cga_putc。也就是显示到屏幕上的函数。首先看一下cga_init。这个函数的功能就是选定特定的屏幕。比如vga, cga等。

static void cga_init(void)
{
volatile uint16_t *cp;
uint16_t was;
unsigned pos;
cp = (uint16_t*) (KERNBASE + CGA_BUF);
was = *cp;
*cp = (uint16_t) 0xA55A;
if (*cp != 0xA55A) {
cp = (uint16_t*) (KERNBASE + MONO_BUF);
addr_6845 = MONO_BASE;
} else {
*cp = was;
addr_6845 = CGA_BASE;
}
/* Extract cursor location */
outb(addr_6845, 14);
pos = inb(addr_6845 + 1) << 8;
outb(addr_6845, 15);
pos |= inb(addr_6845 + 1);
crt_buf = (uint16_t*) cp;
crt_pos = pos;
}

一般而言,显示操作的时候,启动的时候,都是使用提CGA。也就是

./kern/console.h:14:#define CGA_BUF 0xB8000

初始化的时候,需要设定光标的位置。设置完成之后。就可以利用cga_putc来CGA屏幕上显示字符了。这里可以看出来,除了各个字符的设定之外。还随时移动的光标。

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 += CRT_COLS;
/* fallthru */
case '\r':
crt_pos -= (crt_pos % CRT_COLS);
break;
case '\t':
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;
default:
crt_buf[crt_pos++] = c; /* write the character */
break;
}
// What is the purpose of this?
if (crt_pos >= CRT_SIZE) {
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);
}

Explain the following from console.c:

这段代码的作用就是在当写满一个屏幕的时候,把整个字符串往上滚动一行。

// 一页写满,滚动一行。
if (crt_pos >= CRT_SIZE) {
int i;
// 把从第1~n行的内容复制到0~(n-1)行,第n行未变化
// 通过这一行代码完成了整个屏幕向上移动一行的操作。
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_pos -= CRT_COLS;
}

For the following questions you might wish to consult the notes for Lecture 2. These notes cover GCC’s calling convention on the x86.

单步执行以下代码。然后回答以下问题:

int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);
  • In the call to cprintf(), to what does fmt point? To what does ap point?
  • List (in order of execution) each call to cons_putc, va_arg, and vcprintf. For cons_putc, list its argument as well. For va_arg, list what ap points to before and after the call. For vcprintf list the values of its two arguments.

单步执行其实没有太多的必要。仔细读一下cprintf函数代码实现就可以了。

static void
putch(int ch, int *cnt)
{
cputchar(ch);
*cnt++;
}
int
vcprintf(const char *fmt, va_list ap)
{
int cnt = 0;
vprintfmt((void*)putch, &cnt, fmt, ap);
return cnt;
}
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的宏:

typedef __builtin_va_list va_list;
#define va_start(ap, last) __builtin_va_start(ap, last)
#define va_arg(ap, type) __builtin_va_arg(ap, type)
#define va_end(ap) __builtin_va_end(ap)

结果发现这几个宏都是由GCC来提供了。嗯。这样实际上是不太容易能够分析清楚的。
最好还是看mit 6.828 2007版本的代码。

在读下面的代码的时候,一定要注意栈的方向是从高地址往低地址前进。并且压栈的时候,
如果是一个char,也是压入一个long。所以需要注意字长。也就是所以单位是以long对齐的。
比如压入了5个char。也是需要用到2个long。在32位机器上。

// 指针定义为char *可以指向任意一个内存地址。
typedef char *va_list;
// 类型大小,注意这里是与CPU位数对齐 = sizeof(long)的作用。
#define __va_size(type) \
(((sizeof(type) + sizeof(long) - 1) / sizeof(long)) * sizeof(long))
// 这里个宏并不是取得参数的起始地址。而是说参数将从什么地址开始放。
#define va_start(ap, last) \
((ap) = (va_list)&(last) + __va_size(last))
// va_arg就是用来取参数的起始地址的。然后返回type类型。
// 从整个表达式的意义来说没有什么好用的。
// 其实等价于(*(type*)ap)
// 但是实际上使ap指针移动一个参数大小。
#define va_arg(ap, type) \
(*(type *)((ap) += __va_size(type), (ap) - __va_size(type)))
// 空指令,没有什么用
#define va_end(ap) ((void)0)

所以这里回到原来的代码:

int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

fmt就是指向那个const char *的字符串。当调用的时候,栈中的结构是如下:

+-----------------+
| |
| Z |
| |
| |
+-----------------+
| |
| Y |
| |
| |
+-----------------+
| |
| X |
| |
| |
+-----------------+
| |
| fmt |
| |
| |
+-----------------+ <-----------+&fmt
va_start(fmt, ap) 作用如下
#define va_start(ap, last) \
((ap) = (va_list)&(last) + __va_size(last))
展开就是
ap = (char *)(&fmt) + align_long(fmt);
+-----------------+
| |
| Z |
| |
| |
+-----------------+
| |
| Y |
| |
| |
+-----------------+
| |
| X |
| |
| |
+-----------------+ <--------------+ap
| |
| fmt |
| |
| |
+-----------------+

接下来简单一点,看一下调用到%c输出的时候,代码是怎么走的

void
vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
{
while (1) {
// 如果只是一般的字符串,直接输出。
while ((ch = *(unsigned char *) fmt++) != '%') {
if (ch == '\0')
return;
putch(ch, putdat);
}
// 如果发现是%c
reswitch:
// 先把%号跳掉,取出'c'
switch (ch = *(unsigned char *) fmt++) {
// ..
case 'c':
putch(va_arg(ap, int), putdat);
break;
}
}
}

这个时候通过%c就知道应该从栈中取出一个参数char类型。

va_arg(ap, int) 展开后就是
#define va_arg(ap, type) \
(*(type *)((ap) += __va_size(type), (ap) - __va_size(type)))
// putat用来统计输出的字符的个数。在这里可以不用去管
char temp = *(char*)ap;
putch(temp, putdat); // 输出到console上。
ap += align_long(char);
执行完成之后。
+-----------------+
| |
| Z |
| |
| |
+-----------------+
| |
| Y |
| |
| |
+-----------------+ <------+ap
| |
| X | 这个x会被%d提出来进行输出。
| |
| |
+-----------------+
| |
| fmt |
| |
| |
+-----------------+

从这里也可以总结出来。ap的作用实际上就是利用fmt里面的%依次把后面的类型提出来。
然后去栈中找到参数。一个一个输出。

问题4运行如下代码会输出什么?

unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);

输出是什么?解释一下?

首先%x是输出16进制。

57616 = 0xE110
i = 0x00646c72
那么如果把i占用的4byte转换成为char[4]数组。结果就是:
char str[4] = {0x72, 0x6c, 0x64, 0x00}; // = {'r', 'l', 'd', 0}
所以输出就是
Hell0 World

问题5

y后面会输出啥?这种栈越界操作,其实是无法判定的。因为不知道越界这个内存里面以前放的内容是什么。

cprintf("x=%d y=%d", 3);

问题

GCC里面输出的时候,入栈的顺序是从函数调用方的右往左入栈。所以,最后一个参数最先入栈。第一个栈入最后入栈。
如果入栈顺序反过来会怎么样?

其实也还是可以拿到参数的。只不过需要把宏的加减法改一下就可以了。把这里的加法改成减法,减法改成加法。

// 指针定义为char *可以指向任意一个内存地址。
typedef char *va_list;
// 类型大小,注意这里是与CPU位数对齐 = sizeof(long)的作用。
#define __va_size(type) \
(((sizeof(type) + sizeof(long) - 1) / sizeof(long)) * sizeof(long))
// 这里个宏并不是取得参数的起始地址。而是说参数将从什么地址开始放。
#define va_start(ap, last) \
((ap) = (va_list)&(last) + __va_size(last))
// va_arg就是用来取参数的起始地址的。然后返回type类型。
// 从整个表达式的意义来说没有什么好用的。
// 其实等价于(*(type*)ap)
// 但是实际上使ap指针移动一个参数大小。
#define va_arg(ap, type) \
(*(type *)((ap) += __va_size(type), (ap) - __va_size(type)))
// 空指令,没有什么用
#define va_end(ap) ((void)0)

挑战输出各种颜色。这种事情好玩,但是与操作系统核心偏得有点远。这里不去关注。

Exercise 9.

Determine where the kernel initializes its stack, and exactly where in memory its stack is located. How does the kernel reserve space for its stack? And at which “end” of this reserved area is the stack pointer initialized to point to?

  1. 内核在哪里初始化了内核用到的栈?
// 首先boot.S里面初始化了ss段描述符。
# 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
// 然后再进入到kern/entry.S。
// 有意思的是,这里是把栈顶设置为0。后面在递归回溯的时候,遇到放ebp = 0的时候,应该停止了。
// 在后面的作业里会用到这个。
//
# 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
...
###################################################################
# boot stack
###################################################################
.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:

这里可以看一下内核的内存分配图。

/*
* Virtual memory map: Permissions
* kernel/user
*
* 4 Gig --------> +------------------------------+
* | | RW/--
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* : . :
* : . :
* : . :
* |~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~| RW/--
* | | RW/--
* | Remapped Physical Memory | RW/--
* | | RW/--
* KERNBASE, ----> +------------------------------+ 0xf0000000 --+
* KSTACKTOP | CPU0's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| |
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* | CPU1's Kernel Stack | RW/-- KSTKSIZE |
* | - - - - - - - - - - - - - - -| PTSIZE
* | Invalid Memory (*) | --/-- KSTKGAP |
* +------------------------------+ |
* : . : |
* : . : |
* MMIOLIM ------> +------------------------------+ 0xefc00000 --+
./inc/memlayout.h:97:#define KSTKSIZE (8*PGSIZE) // size of a kernel stack

可以看出来。kernel就是把内核栈放到了kernel代码的后面。大小就是一个KSTKSIZE

Exercise 10.

为了熟悉C函数调用的转换。可以在obj/kern/kernel.asm中找到test_backtrace函数。在那里设置一个断点。
然后验证每次调用的时候发生了什么。

// Test the stack backtrace function (lab 1 only)
void
test_backtrace(int x)
{
cprintf("entering test_backtrace %d\n", x);
if (x > 0)
test_backtrace(x-1);
else
mon_backtrace(0, 0, 0);
cprintf("leaving test_backtrace %d\n", x);
}

调用方进行的操作如下:

将参数由右向左压入栈
将返回地址 (eip中的内容) 入栈,在 call 指令执行
函数内部执行:
f0100040: 55 push %ebp
f0100041: 89 e5 mov %esp,%ebp
f0100043: 53 push %ebx
f0100044: 83 ec 14 sub $0x14,%esp
将上一个函数的 ebp 入栈
将 ebx 入栈,保护寄存器状态
在栈上开辟一个空间存储局部变量。这里会有5个int的空间。

整理过程如下:

./kern/init.c:39: test_backtrace(5);
主函数的调用 x = 5
1. move $0x5, (%esp) ;执行的是movl $0x05, %(esp); 执行完之后。此时esp = 0xf010ffe0. ebp = 0xf010fff8
2. push f01000ea(retaddr) ;这是压入test_backtrace(5);返回之后的地址。执行完之后 esp = 0xf010ffe0 - 4;
3. push %ebp ;压入0xf010fff8. 执行完之后。esp = 0xf010ffe0 - 8;
movl %esp, %ebp ;执行完之后ebp = esp = 0xf010ffe0 - 8 = 0xf010ffd8
4. push %ebx ;不管这里%ebx是多少。总之esp = 0xf010ffd8 - 4 = 0xf010ffd4
5. subl $0x14, %esp ;esp = 0xf010ffd4 - 0x14 = 0xf010ffc0
(gdb) x/64x $esp
0xf010ff20: 0xf01019a0 0x00000000 0xf010ff58 0x00000000
0xf010ff30: 0xf01008eb 0x00000001 0xf010ff58 0xf0100069
0xf010ff40: 0x00000000 0x00000001 0xf010ff78 0x00000000
0xf010ff50: 0xf01008eb 0x00000002 0xf010ff78 0xf0100069
0xf010ff60: 0x00000001 0x00000002 0xf010ff98 0x00000000
0xf010ff70: 0xf01008eb 0x00000003 0xf010ff98 0xf0100069
0xf010ff80: 0x00000002 0x00000003 0xf010ffb8 0x00000000
0xf010ff90: 0xf01008eb 0x00000004 0xf010ffb8 0xf0100069
0xf010ffa0: 0x00000003 0x00000004 0x00000000 0x00000000
0xf010ffb0: 0x00000000 0x00000005 0xf010ffd8 0xf0100069
0xf010ffc0: 5.esp_0x00000004 0x00000005 0x00000000 0x00010094
0xf010ffd0: 0x00010094 4.0x00010094 3.0xf010fff8 2.0xf01000ea (返回地址)
0xf010ffe0: 1.0x00000005 0x00001aac 0x00000644 0x00000000
0xf010fff0: 0x00000000 0x00000000 0x00000000 0xf010003e

接着开始第二轮,也就是真正的递归。

接着开始第二轮 x == 4
在函数递归调用的时候,是这么调用的。
f010005e: 8d 43 ff lea -0x1(%ebx),%eax
f0100061: 89 04 24 mov %eax,(%esp)
f0100064: e8 d7 ff ff ff call f0100040 <test_backtrace>
1. mov %eax,(%esp) ; 执行完成之后. esp = 0xf010ffc0,这里eax也就是等于x-1。也就是4
2. push f0100069 (retaddr) ; 执行完成之后,esp = 0xf010ffbc
3. push 0xf010ffd8 (%ebp) ; 执行完成之后,esp = 0xf010ffb8
movl %esp, %ebp ; 执行完成之后, esp = ebp = 0xf010ffb8
4. push %ebx ; 执行完成之后, esp = 0xf010ffb4
5. subl $0x14, %esp ; 执行完成之后, esp = 0xf010ffa0
(gdb) x/64x $esp
0xf010ff20: 0xf01019a0 0x00000000 0xf010ff58 0x00000000
0xf010ff30: 0xf01008eb 0x00000001 0xf010ff58 0xf0100069
0xf010ff40: 0x00000000 0x00000001 0xf010ff78 0x00000000
0xf010ff50: 0xf01008eb 0x00000002 0xf010ff78 0xf0100069
0xf010ff60: 0x00000001 0x00000002 0xf010ff98 0x00000000
0xf010ff70: 0xf01008eb 0x00000003 0xf010ff98 0xf0100069
0xf010ff80: 0x00000002 0x00000003 0xf010ffb8 0x00000000
0xf010ff90: 0xf01008eb 0x00000004 0xf010ffb8 0xf0100069
0xf010ffa0: 5.0x00000003 0x00000004 0x00000000 0x00000000
0xf010ffb0: 0x00000000 4.0x00000005 3.0xf010ffd8 2.0xf0100069
0xf010ffc0: 1.esp_0x00000004 0x00000005 0x00000000 0x00010094
0xf010ffd0: 0x00010094 4.0x00010094 3.0xf010fff8 2.0xf01000ea (返回地址)
0xf010ffe0: 1.0x00000005 0x00001aac 0x00000644 0x00000000
0xf010fff0: 0x00000000 0x00000000 0x00000000 0xf010003e

然后开始第三轮,也就是把x=3开始调用。

接着开始第二轮 x == 3
在函数递归调用的时候,是这么调用的。
f010005e: 8d 43 ff lea -0x1(%ebx),%eax
f0100061: 89 04 24 mov %eax,(%esp)
f0100064: e8 d7 ff ff ff call f0100040 <test_backtrace>
1. mov %eax,(%esp) ; 执行完成之后. esp = 0xf010ffa0,这里eax也就是等于x-1。也就是3
2. push f0100069 (retaddr) ; 执行完成之后,esp = 0xf010ff9c
3. push 0xf010ffd8 (%ebp) ; 执行完成之后,esp = 0xf010ff98
movl %esp, %ebp ; 执行完成之后, esp = ebp = 0xf010ff98
4. push %ebx ; 执行完成之后, esp = 0xf010ff94
5. subl $0x14, %esp ; 执行完成之后, esp = 0xf010ff80
(gdb) x/64x $esp
0xf010ff20: 0xf01019a0 0x00000000 0xf010ff58 0x00000000
0xf010ff30: 0xf01008eb 0x00000001 0xf010ff58 0xf0100069
0xf010ff40: 0x00000000 0x00000001 0xf010ff78 0x00000000
0xf010ff50: 0xf01008eb 0x00000002 0xf010ff78 0xf0100069
0xf010ff60: 0x00000001 0x00000002 0xf010ff98 0x00000000
0xf010ff70: 0xf01008eb 0x00000003 0xf010ff98 0xf0100069
0xf010ff80: 5.0x00000002 0x00000003 0xf010ffb8 0x00000000
0xf010ff90: 0xf01008eb 0x00000004(ebx) 3. 0xf010ffb8 2.0xf0100069
0xf010ffa0: 1.0x00000003 0x00000004 0x00000000 0x00000000
0xf010ffb0: 0x00000000 4.0x00000005 3.0xf010ffd8 2.0xf0100069
0xf010ffc0: 1.esp_0x00000004 0x00000005 0x00000000 0x00010094
0xf010ffd0: 0x00010094 4.0x00010094 3.0xf010fff8 2.0xf01000ea (返回地址)
0xf010ffe0: 1.0x00000005 0x00001aac 0x00000644 0x00000000
0xf010fff0: 0x00000000 0x00000000 0x00000000 0xf010003e

后面就不用再依次展开,可以把这个过程分为以下几个部分。

(gdb) x/64x $esp
// x == 0的时候
0xf010ff20: 0xf01019a0 0x00000000 0xf010ff58 0x00000000
0xf010ff30: 0xf01008eb 0x00000001 0xf010ff58 0xf0100069
// x == 1
0xf010ff40: 0x00000000 0x00000001 0xf010ff78 0x00000000
0xf010ff50: 0xf01008eb 0x00000002 0xf010ff78 0xf0100069
// x == 2
0xf010ff60: 0x00000001 0x00000002 0xf010ff98 0x00000000
0xf010ff70: 0xf01008eb 0x00000003 0xf010ff98 0xf0100069
// x == 3
0xf010ff80: 0x00000002 0x00000003 0xf010ffb8 0x00000000
0xf010ff90: 0xf01008eb 0x00000004 0xf010ffb8 0xf0100069
// x == 4
0xf010ffa0: 0x00000003 0x00000004 0x00000000 0x00000000
0xf010ffb0: 0x00000000 0x00000005 0xf010ffd8 0xf0100069
// x = 5
0xf010ffc0: 0x00000004 0x00000005 0x00000000 0x00010094
0xf010ffd0: 0x00010094 0x00010094 0xf010fff8 0xf01000ea
0xf010ffe0: 0x00000005 0x00001aac 0x00000644 0x00000000
0xf010fff0: 0x00000000 0x00000000 0x00000000 0xf010003e

注意,看每一个部分的时候,一定要注意:

进入函数之后
3. push 0xf010ffd8 (%ebp) ; 执行完成之后,esp = 0xf010ffb8
movl %esp, %ebp ; 执行完成之后, esp = ebp = 0xf010ffb8
4. push %ebx ; 执行完成之后, esp = 0xf010ffb4
5. subl $0x14, %esp ; 执行完成之后, esp = 0xf010ffa0
然后递归调用的时候
f010005e: 8d 43 ff lea -0x1(%ebx),%eax
f0100061: 89 04 24 mov %eax,(%esp)
f0100064: e8 d7 ff ff ff call f0100040 <test_backtrace>

所以。

// x = 5
0xf010ffc0: 0x00000004 0x00000005 0x00000000 0x00010094
0xf010ffd0: 0x00010094 0x00010094 0xf010fff8 0xf01000ea
0xf010ffe0: 0x00000005

这里0xf010ffc0: 0x00000004这个4.实际上是在x = 5的时候,开始往函数里面填写参数的时候生成的。

此外,也需要记住每次%ebp指针的变化图。

Exercise 11.

通过以上的代码操作,给足了信息,已经实现一个栈回溯的程序了mon_backtrace()。这个函数的原型已经等着你去填相应的代码了。
kern/monitor.c。但是在操作的时候,必须全部使用C语言。在inc/x86.h里面的read_ebp()函数会是非常有用的。
此外也需要在内核中添加一个命令,用来支持monitor命令的输出。

通过命令可以得到如下的输出。

Stack backtrace:
ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...

每一行都包含了ebp, eip, args。这段小代码还是比较好处理的。

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t ebp = read_ebp();
#define TO_INT(x) *((uint32_t*)(x))
while (ebp) {
// ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n",
TO_INT(ebp), /*ebp*/
TO_INT((ebp+4)), /*eip*/
TO_INT((ebp+8)), /*arg1*/
TO_INT((ebp+12)), /*arg2*/
TO_INT((ebp+16)), /*arg3*/
TO_INT((ebp+20)), /*arg4*/
TO_INT((ebp+24))); /*arg5*/
ebp = TO_INT(ebp);
}
return 0;
}

代码还是比较简单的。

Exercise 12

修改stack backtrace函数来显示每个eip, fun_name, source_file_name, line_number等与eip相关联系的信息。
debuginfo_eip里面,__STAB_*这些信息是从哪里来的呢?这里可以通过以下方式来了解:

look in the file kern/kernel.ld for __STAB_*
run objdump -h obj/kern/kernel
run objdump -G obj/kern/kernel
run gcc -pipe -nostdinc -O2 -fno-builtin -I. -MD -Wall -Wno-format -DJOS_KERNEL -gstabs -c -S kern/init.c, and look at init.s.

链接脚本

.stabstr : {
PROVIDE(__STABSTR_BEGIN__ = .);
*(.stabstr);
PROVIDE(__STABSTR_END__ = .);
BYTE(0) /* Force the linker to allocate space
for this section */
}

比如通过objdump -h obj/kern/kernel

root@debug:~/6.828/lab# objdump -h obj/kern/kernel
obj/kern/kernel: file format elf32-i386
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 000019d7 f0100000 00100000 00001000 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .rodata 0000079c f01019e0 001019e0 000029e0 2**5
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stab 00003955 f010217c 0010217c 0000317c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .stabstr 000018ba f0105ad1 00105ad1 00006ad1 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .data 0000a300 f0108000 00108000 00009000 2**12
CONTENTS, ALLOC, LOAD, DATA
5 .bss 00000644 f0112300 00112300 00013300 2**5
ALLOC
6 .comment 0000002b 00000000 00000000 00013300 2**0
CONTENTS, READONLY

这里就可以看到.stabstr段。

objdump -G obj/kern/kernel可以把所有的详细信息都打印出来。
在链接脚本里面,明确指定了所有的字符都需要加载到固定的段里面。

需要在kern/monitor.c里面添加代码,完成功能如下:

K> backtrace
Stack backtrace:
ebp f010ff78 eip f01008ae args 00000001 f010ff8c 00000000 f0110580 00000000
kern/monitor.c:143: monitor+106
ebp f010ffd8 eip f0100193 args 00000000 00001aac 00000660 00000000 00000000
kern/init.c:49: i386_init+59
ebp f010fff8 eip f010003d args 00000000 00000000 0000ffff 10cf9a00 0000ffff
kern/entry.S:70: <unknown>+0
K>

每一行都会输出文件名以及行(根据eip得到)。但是需要注意的是这个monitor+106并不是说在monitor的106行。而是说离monitor的开始有106个bytes。

// 一定要把文件名,函数名,等相应的信息另起一行进行输出。
Be sure to print the file and function names on a separate line, to avoid confusing the grading script.

printf("%.*s", length, string) 可以输出指定长度的字符串。

由于优化的原因,一些inline的函数会被编译到调用方的代码里面。所以看到的结果会少掉某些函数。这主要是-O2这个代码优化带来的。

求解

其实主要看上面格式的说明就行了。其他扯的太多,对求解作业不一定特别有用。

文件名

909 SO 0 2 f0101340 5591 lib/readline.c

所以在根据eip来查找的时候,首先找文件。以下代码来自于函数
int debuginfo_eip(uintptr_t addr, struct Eipdebuginfo *info)

// Search the entire set of stabs for the source file (type N_SO).
lfile = 0;
rfile = (stab_end - stabs) - 1;
stab_binsearch(stabs, &lfile, &rfile, N_SO, addr);
if (lfile == 0)
return -1;

函数

原函数

char *
strcpy(char *dst, const char *src)
{
char *ret;
ret = dst;
while ((*dst++ = *src++) != '\0')
/* do nothing */;
return ret;
}

解析之后得到

1131 FUN 0 0 f0101a69 6099 strcpy:F(0,20)=*(0,2)
1132 PSYM 0 0 00000008 6121 dst:p(0,20) <--- 栈中的偏移
1133 PSYM 0 0 0000000c 6133 src:p(0,19) <--- 栈中的偏移
1134 SLINE 0 33 00000000 0 // 00000000 ---> 33, 这里33就是表示函数定义的行号。
1135 SLINE 0 36 00000006 0 // 第一行代码相对文件的行号。
1136 SLINE 0 37 0000000c 0
1137 SLINE 0 37 0000000d 0
1138 SLINE 0 39 0000002b 0
1139 SLINE 0 40 0000002e 0
1140 LSYM 0 0 fffffffc 6145 ret:(0,20)
1141 LBRAC 0 0 00000000 0
1142 RBRAC 0 0 00000030 0

第一行是函数名。第二行是符号名,第三行SLINE开始是函数体里面的代码。LYSM表示函数局部变量符号。
LBRAC表示函数左边的花括号。RBRAC表示函数右边的花括号。

所以当给定eip之后。搜索到file之后。就开始找函数名。

// Search within that file's stabs for the function definition
// (N_FUN).
lfun = lfile;
rfun = rfile;
stab_binsearch(stabs, &lfun, &rfun, N_FUN, addr);
if (lfun <= rfun) {
// stabs[lfun] points to the function name
// in the string table, but check bounds just in case.
if (stabs[lfun].n_strx < stabstr_end - stabstr)
info->eip_fn_name = stabstr + stabs[lfun].n_strx;
info->eip_fn_addr = stabs[lfun].n_value;
addr -= info->eip_fn_addr;
// Search within the function definition for the line number.
lline = lfun;
rline = rfun;
} else {
// Couldn't find function stab! Maybe we're in an assembly
// file. Search the whole file for the line number.
info->eip_fn_addr = addr;
lline = lfile;
rline = rfile;
}
// Ignore stuff after the colon.
info->eip_fn_namelen = strfind(info->eip_fn_name, ':') - info->eip_fn_name;

stab_binsearch这个函数有意思的地方是:输入参数lxxx, rxxx,进去的时候,即是限制范围。也是最后的返回值。
此外,如果是跳到汇编代码里面的话,是无法找到函数体的定义的。在这种情况下就改变策略,只能把范围设置为整个文件。

stabs

// Entries in the STABS table are formatted as follows.
struct Stab {
uint32_t n_strx; // index into string table of name
uint8_t n_type; // type of symbol
uint8_t n_other; // misc info (usually empty)
uint16_t n_desc; // description field
uintptr_t n_value; // value of symbol --> 这里就是指地址。
};

求解

root@debug:~/6.828/lab# git diff
diff --git a/kern/kdebug.c b/kern/kdebug.c
index 9547143..f85b570 100644
--- a/kern/kdebug.c
+++ b/kern/kdebug.c
@@ -180,6 +180,25 @@ debuginfo_eip(uintptr_t addr, struct Eipdebuginfo *info)
// which one.
// Your code here.
+ int olline = lline, orline = rline;
+ stab_binsearch(stabs, &olline, &orline, N_SOL, (!(lline == lfile && rline == rfile))*addr + info->eip_fn_addr);
+ // 如果没有找到N_SOL
+ if (olline > orline) {
+ stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
+ // 如果在N_SLINE也没有找到
+ if (lline > rline) {
+ return -1;
+ }
+ }
+ // 记录找到的行号
+ info->eip_line = stabs[lline].n_desc;
+
// Search backwards from the line number for the relevant filename
// stab.
diff --git a/kern/monitor.c b/kern/monitor.c
index e137e92..a0f188d 100644
--- a/kern/monitor.c
+++ b/kern/monitor.c
@@ -24,6 +24,7 @@ struct Command {
static struct Command commands[] = {
{ "help", "Display this list of commands", mon_help },
{ "kerninfo", "Display information about the kernel", mon_kerninfo },
+ { "backtrace", "Display backtrace info", mon_backtrace },
};
/***** Implementations of basic kernel monitor commands *****/
@@ -58,6 +59,30 @@ int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
+ uint32_t ebp = read_ebp();
+ uint32_t eip = 0;
+ struct Eipdebuginfo info;
+ #define TO_INT(x) *((uint32_t*)(x))
+ while (ebp) {
+ eip = TO_INT((ebp+4));
+ cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n",
+ ebp, /*ebp*/
+ eip, /*eip*/
+ TO_INT((ebp+8)), /*arg1*/
+ TO_INT((ebp+12)), /*arg2*/
+ TO_INT((ebp+16)), /*arg3*/
+ TO_INT((ebp+20)), /*arg4*/
+ TO_INT((ebp+24))); /*arg5*/
+
+ if(!debuginfo_eip(eip, &info)) {
+ cprintf("%s:%d: %.*s+%d\n",
+ info.eip_file,
+ info.eip_line,
+ info.eip_fn_namelen, info.eip_fn_name,
+ eip - info.eip_fn_addr);
+ }
+ ebp = TO_INT(ebp);
+ }
return 0;
}
diff --git a/lib/printfmt.c b/lib/printfmt.c
index 28e01c9..1d3a5fa 100644
--- a/lib/printfmt.c
+++ b/lib/printfmt.c
@@ -206,10 +206,13 @@ vprintfmt(void (*putch)(int, void*), void *putdat, const char *fmt, va_list ap)
// (unsigned) octal
case 'o':
// Replace this with your code.
- putch('X', putdat);
- putch('X', putdat);
- putch('X', putdat);
- break;
+ num = getint(&ap, lflag);
+ if ((long long) num < 0) {
+ putch('-', putdat);
+ num = -(long long) num;
+ }
+ base = 8;
+ goto number;
// pointer
case 'p':

出错记录

  • 输出ebp的时候,输出成了TO_INT(ebp)
  • N_SOL表示内联函数引进来的代码行。在处理的时候需要处理掉。
  • 当没有找到函数的时候。一定要注意:
// 注意前面的处理:如果是能够找到相应的函数定义,那么
// addr -= info->eip_fn_addr;
/*
468 FUN 0 0 f0100690 3954 mon_backtrace:F(0,1)
472 SLINE 0 60 00000000 0
473 SLINE 0 80 00000007 0
474 SOL 0 0 f01006a3 2929 ./inc/x86.h
475 SLINE 0 214 00000013 0 <-- 这一行是在x86.h里面的read_ebp()
476 SOL 0 0 f01006a5 3661 kern/monitor.c
477 SLINE 0 82 00000015 0
478 SLINE 0 84 00000017 0
479 SLINE 0 85 00000051 0
480 SLINE 0 82 00000053 0
481 SLINE 0 88 00000057 0
483 LBRAC 0 0 00000000 0 // 左花括号
484 RBRAC 0 0 00000062 0 // 左花括号
*/
// 是不是应该先找一下SOL?
/*
if (lline == lfile && rline == rfile) {
// 如果没有找到函数,那么addr就没有被减过
stab_binsearch(stabs, &lline, &rline, N_SOL, info->eip_fn_addr);
} else {
// 如果找到过函数,那么addr就被减过了
stab_binsearch(stabs, &lline, &rline, N_SOL, addr += info->eip_fn_addr);
}这段代码可以简化成如下代码

1.1 各种Object

1. object_t

这个object_t实际上就是只有一个name在起作用。

struct object_t {
string name;
object_t() {}
// cppcheck-suppress noExplicitConstructor
object_t(const char *s) : name(s) {}
// cppcheck-suppress noExplicitConstructor
object_t(const string& s) : name(s) {}
void swap(object_t& o) {
name.swap(o.name);
}
void clear() {
name.clear();
}
void encode(bufferlist &bl) const {
using ceph::encode;
encode(name, bl);
}
void decode(bufferlist::const_iterator &bl) {
using ceph::decode;
decode(name, bl);
}
};
WRITE_CLASS_ENCODER(object_t)

尾部的这个宏,实际上没有什么特别的。这里可以把这个宏展开。

WRITE_CLASS_ENCODER

#define WRITE_CLASS_ENCODER(cl) \
inline void encode(const cl &c, ::ceph::bufferlist &bl, uint64_t features=0) { \
ENCODE_DUMP_PRE(); c.encode(bl); ENCODE_DUMP_POST(cl); } \
inline void decode(cl &c, ::ceph::bufferlist::const_iterator &p) { c.decode(p); }

展开之后的代码就是

inline void encode(const cl &c, ::ceph::bufferlist &bl, uint64_t features=0) {
ENCODE_DUMP_PRE(); // -> 这两个是没有什么用的
c.encode(bl);
ENCODE_DUMP_POST(cl); // 这个宏的作用就是说想把dump出来的object打印到/tmp目录。
}
inline void decode(cl &c, ::ceph::bufferlist::const_iterator &p) {
c.decode(p);
}

所以这两个宏的作用就是直接调用类的encode和decode函数。

问题

object_t只提供了name并没有提供其他任何存储空间,那么一个object的内容在哪里呢?

重载运算符函数

针对于object_tCeph重载了很多运算符,看起来是比较繁锁的,就是不知道有没有更加简便的方法。

inline bool operator==(const object_t& l, const object_t& r) {
return l.name == r.name;
}
inline bool operator!=(const object_t& l, const object_t& r) {
return l.name != r.name;
}
inline bool operator>(const object_t& l, const object_t& r) {
return l.name > r.name;
}
inline bool operator<(const object_t& l, const object_t& r) {
return l.name < r.name;
}
inline bool operator>=(const object_t& l, const object_t& r) {
return l.name >= r.name;
}
inline bool operator<=(const object_t& l, const object_t& r) {
return l.name <= r.name;
}
inline ostream& operator<<(ostream& out, const object_t& o) {
return out << o.name;
}

如是只是为了支持hash函数,那么只需要==被重载就可以了。

如何自定义hash支持

namespace std {
template<> struct hash<object_t> {
size_t operator()(const object_t& r) const {
//static hash<string> H;
//return H(r.name);
return ceph_str_hash_linux(r.name.c_str(), r.name.length());
}
};
} // namespace std