add conclusion of dsa's chp2 & os lab1_report of former four exercises
This commit is contained in:
390
thu_dsa/chp2/chp2.md
Normal file
390
thu_dsa/chp2/chp2.md
Normal file
@@ -0,0 +1,390 @@
|
||||
Conclusion on Chapter Two: Vector
|
||||
=================================
|
||||
|
||||
## Vector是一种抽象数据类型
|
||||
|
||||
> 辨析抽象数据类型(ADT, Abstract Data Type)与数据结构之间的区别与联系。
|
||||
|
||||
从定义上来说,
|
||||
+ 抽象数据类型 = 数据模型 + 定义在该模型上的一组操作
|
||||
+ 数据结构 = 基于某种特定的语言,实现ADT的一整套算法
|
||||
|
||||
这个定义,就连我<del>邓公</del>也是看不懂的。但是可以做一些说明:
|
||||
|
||||
考虑基本的数据类型,如`int`, `float`, `char`, 这些数据类型都定义自己相应的操作,如加减乘除,如比对,比较,赋值等。我们在使用这些数据类型的时候,并不关心这些操作的底层是怎么实现的,并且对于不同的语言,`int`型都具有相同的`+`操作,因此我们可以不加区别地放心使用`+`操作来处理`int`型数据。这些定义在数据模型(`int`, `float`, etc)上的抽象的操作,就构成了抽象数据类型。
|
||||
|
||||
再考虑汽车。我们可以驾驶不同公司生产的不同型号的汽车,我们并不会在每开一辆新的汽车之前,都需要重新学习如何驾驶这辆汽车,因为所有汽车的驾驶方式都是大致相同的。汽车的驾驶方式也就是汽车对外提供的接口,这个接口是规范的,抽象的,所有的汽车生产商都要按照这个接口设计生产他们的汽车,这种接口就是抽象数据类型。而不同汽车生产商生产的具有相同接口的汽车,其内部实现显然是不同,这种具体的实现就是数据结构。
|
||||
|
||||
> 为什么说Vector是一种抽象数据类型?
|
||||
|
||||
Vector是一种最基本的线性结构,是对线性存储数据的一种抽象。而这种抽象,前人早就帮你做好了,他们已经定义了Vector对外需要提供哪些接口,并且这些接口满足怎样的接口规范。
|
||||
|
||||
我们这里要实现的是一种具体的Vector,就是要按照定义好的接口规范定制我们自己的算法。只要这套算法是满足接口规范的,用户就可以不加区分地使用我们自定义的Vector,也就是像使用基本数据类型`int`一样使用。
|
||||
|
||||
```
|
||||
int n;
|
||||
float x;
|
||||
char c;
|
||||
Vector myVector;
|
||||
```
|
||||
|
||||
> Vector定义的接口规范有哪些?
|
||||
|
||||
多还是有点多的。但是这里我们只讨论有点难度的几个:
|
||||
|
||||
+ 可扩充向量。要求Vector在数据满了以后,可以自动扩充空间。从而让用户感觉Vector的存储空间是无限的。
|
||||
+ 有序向量的
|
||||
- 唯一化操作
|
||||
- 查找
|
||||
- 排序
|
||||
|
||||
## 可扩充向量
|
||||
|
||||
实现可扩充向量的一个基本想法是,当需要插入到当前向量中时,如果检测到向量空间已经满了,就进行扩容。但是问题在于,这里有不同的扩容策略。
|
||||
|
||||
> 有哪些扩容策略?
|
||||
|
||||
最容易想到的肯定是容量递增策略。他的基本思想是每次扩容都增加一个固定的大小`INCREMENT`,代码实现如下:
|
||||
|
||||
```
|
||||
void Vector<T>::expand(){
|
||||
if(_size < _capacity) return;//判断是否需要扩容
|
||||
//else
|
||||
T* tmp = _elem;
|
||||
_elem = new T[_capacity += INCREMENT];//增加一个固定的大小
|
||||
//copy from tmp to new _elem
|
||||
for(int ix = 0; ix != _size; ++ix) _elem[ix] = tmp[ix];
|
||||
delete[]tmp;//cpp 手动释放内存
|
||||
}
|
||||
```
|
||||
|
||||
此外,还有一种策略是容量加倍策略。也就是每次扩容都将当前Vector的容量扩大一倍。代码实现如下:
|
||||
|
||||
```
|
||||
void Vector<T>::expand(){
|
||||
if(_size < _capacity) return;//判断是否需要扩容
|
||||
//else
|
||||
T* tmp = _elem;
|
||||
_elem = new T[_capacity <<= 1];//将_capacity扩大一倍
|
||||
//copy from tmp to new _elem
|
||||
for(int ix = 0; ix != _size; ++ix) _elem[ix] = tmp[ix];
|
||||
delete[]tmp;//cpp 手动释放内存
|
||||
}
|
||||
```
|
||||
|
||||
这里,我们应该可以更好地理解ADT与数据结构的联系。上面两段代码都是针对同一种ADT的算法,他们都遵守相同的规范,在给用户使用时,他们都可以完成用户期望的功能,但是这两种算法的内部实现显然是不一样的,因此他们也会具有不同的性能。
|
||||
|
||||
> 递增策略与加倍策略性能比较。
|
||||
|
||||
如果可以忽略重新分配内存的时间的话,每次扩容的主要时间开销是将数据从原来的区域,拷贝到新分配的区域。显然,对于两种算法而已,这个操作的时间复杂度是相同的,都是$O(n)$。所以,直接比较两个算法每次扩容的时间性能,是没有意义的。
|
||||
|
||||
所以这里需要引入<em>分摊复杂度</em>的概念。所谓分摊复杂度,是指对数据结构连续地实施足够多的次的操作后,所需总体成本分摊到单次操作的时间复杂度。它与平均复杂度是有本质区别的(但是平均复杂度,目前我还没有一个比较深入的理解,只有以后再比较)。下面,我们分别考察两种扩容策略的分摊复杂度。
|
||||
|
||||
对于递增策略,考虑最坏的情况下,我们连续做了n次插入操作,每次扩容都是递增一个固定的大小K。这样,每次扩容所需要的时间成本为
|
||||
$$
|
||||
0, K, 2K, ..., n
|
||||
$$
|
||||
注意到,这是一个线性级数。因此n次操作的总时间成本为
|
||||
$$
|
||||
T(n) = O(n^2)
|
||||
$$
|
||||
分摊到每次操作的时间成本为$O(n)$
|
||||
|
||||
同样,接下来再考虑加倍策略。同样是最坏情况下,连续进行了n次插入操作。假定初始的容量为,每次扩容所需要的时间成本为:
|
||||
$$
|
||||
1, 2, 4, 8, ..., n
|
||||
$$
|
||||
这是一个等比级数,因此n次操作的总时间成本为
|
||||
$$
|
||||
T(n) = O(n)
|
||||
$$
|
||||
这样,分摊到每次操作的时间成本为$O(1)$
|
||||
|
||||
## 有序向量的唯一化操作
|
||||
|
||||
> 有序向量的唯一化与无序向量相比,有什么优势?
|
||||
|
||||
首先还是考虑无序向量的唯一化。其实这就比较简单,因为你也不能做什么优化。对于当前的第k个元素,总是保证前k-1个元素是唯一化的,然后在前k-1个元素中遍历以查找当前元素。如果查找成功,则删除当前元素(后继元素全部向前移位),若查找失败,则唯一化序列长度加1,处理第k+1个元素。
|
||||
|
||||
上面的算法,显然具有$O(n^2)$的时间复杂度。当然可以借助`set`或者排序来实现$O(nlogn)$的时间复杂度,但不是我们这里讨论的范围。
|
||||
|
||||
那么,对于有序向量,其和无序向量相比有什么不同呢?
|
||||
|
||||
首要的第一个,显然是对于有序向量,相同的元素排列在一起。也就是你不需要遍历就可以判断当前元素是否存在:只需要和前面一个元素比较就可以了。这就得到了第一版的算法:
|
||||
|
||||
```
|
||||
int Vector<int>::unique(){
|
||||
int oldSize = _size;
|
||||
for(int ix = 1; ix != _size;){
|
||||
if(_elem[ix] == _elem[ix - 1]) pop(ix);
|
||||
else ix++;
|
||||
}
|
||||
return oldSize - _size;
|
||||
}
|
||||
```
|
||||
|
||||
和无序向量的唯一化相比,有序向量不需要进行遍历操作,因此会节省一些开销。那么究竟节省了多少开销呢?
|
||||
|
||||
考虑上面的算法,在最坏情况下,所有的元素都是相等的。这样,每次循环的开销分别是:
|
||||
$$
|
||||
n - 2, n - 3, ..., 1, 0
|
||||
$$
|
||||
所以,该算法的时间复杂度为$O(n^2)$。可以看到,这个时间复杂度和无序向量的唯一化是一样的,就比较糟糕。
|
||||
|
||||
> $O(n)$时间复杂度的有序向量唯一化算法
|
||||
|
||||
关于这个算法,我还思考了挺久的。很自然的想法是相同的元素凑在一起一起删,因为上面算法低效的元素就是频繁的删除操作,而实际上这些元素的前移有大量的冗余。但是这种算法在最坏情况下(总是成对存在的相同元素)仍然是$O(n^2)$的时间复杂度。
|
||||
|
||||
然后我就想啊,这样删可能还是前移操作太冗余了,不如我全部凑在一起删!这样就只需要两次遍历,一次标记各个元素需要前移的量,一次进行实质的删除工作。但是问题在于需要保存各个元素前移量的信息,需要$O(n)$的空间,感觉还是不太好,于是一筹莫展。
|
||||
|
||||
然后我邓公点醒了我,<del>所以我还是觉得就我这智商,可能不适合读书</del>给了一种双指针的算法。代码如下:
|
||||
|
||||
```
|
||||
int Vector<int>::unique(){
|
||||
int i = 0, j = 0;
|
||||
while(++j != _size) if(_elem[j] != _elem[i]) _elem[++i] = _elem[j];
|
||||
_size = ++i;
|
||||
return j - i;
|
||||
}
|
||||
```
|
||||
|
||||
所以就这么几行代码,就实现了$O(n)$的时间复杂度和$O(1)$的空间复杂度。所以说,代码不在于量的多少,而在于思考与逻辑。这个算法里面,跟我的思路比起来,根本就没有用到删除操作。而是用`i`指示当前唯一化序列的最后一个元素,用`j`指示当前全局遍历的元素。
|
||||
|
||||
+ 如果`j`指示的是一个重复出现的元素,则直接将它忽略掉。
|
||||
+ 而如果`j`指示元素是一个新的唯一化元素,就把它添加到i的后面。
|
||||
|
||||
这样,只需要$O(n)$的时间复杂度就完成有序向量的唯一化操作。可见,我的问题在于没有跳出删除操作的限制,从而看不到优化的途径。
|
||||
|
||||
## 有序向量的查找操作
|
||||
|
||||
> `search`函数的接口
|
||||
|
||||
有必要定义一下查找函数的接口是怎么样的,因为可能存在多种情况。例如如果Vector中存在若干个相同的元素,是应该返回哪个元素的位置?又比如如果没有查找到目标元素,是应该返回一个错误码,还是返回距离目标元素最近的元素,诸如此类,都需要预先在ADT中进行定义。
|
||||
|
||||
所以我们这里用到的接口如下:
|
||||
|
||||
+ 如果存在若干相同元素,则返回位置最后的一个元素(返回第一个也不是不可以,只是一种定义)
|
||||
+ 如果没有找到目标元素,则返回小于目标元素的最后一个元素
|
||||
|
||||
|
||||
上面两条可以总结为,返回不大于目标元素的最后一个元素,也就是`upper_bound`。
|
||||
|
||||
这样的接口会使其他操作变得简单。比如插入操作,考虑一种情况,如果在Vector中没有找到目标元素e,则插入目标元素。在上面的接口定义下,可以轻易地找到目标元素应该插入的位置,从而简化插入操作。
|
||||
|
||||
```
|
||||
int pos = v.search(e);//e不在v当中
|
||||
v.insert(pos + 1, e);
|
||||
```
|
||||
|
||||
> 二分查找
|
||||
|
||||
对于有序向量进行二分查找应该是基本操作吧。所以这里直接给出代码,但是目前为了简单起见,还没有完全满足上面的接口定义。
|
||||
|
||||
```
|
||||
int Vector<T>::search(T e, int lo, int hi){
|
||||
int mid;
|
||||
while(lo < hi){
|
||||
mid = (lo + hi) >> 1;
|
||||
if(e < _elem[mid]) hi = mid;
|
||||
else if(e > _elem[mid]) lo = mid + 1;
|
||||
else return mid;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
整个操作非常简单,并且是$O(logn)$的时间复杂度。
|
||||
|
||||
但是邓公说,对于这种经常要用到的算法,还需要考察它复杂度的系数。所以实际上它的时间复杂度是$O(1.50logn)$,这个证明暂时还不会。我们对这个算法进行一个细致的分析:
|
||||
|
||||
可以看到,这个算法虽然是_二分_查找,可是实际上左右两边的操作时间并不是平衡的,进入左边需要更高的时间代价。这是因为进入右边只需要一次比较,而进入左边需要两次比较,所以左边的时间代价更高。左右两边不平衡会导致这个算法的运行速度变慢,也意味着还有可以优化的办法。
|
||||
|
||||
> 斐波拉契查找
|
||||
|
||||
上面说二分查找右边的代价高于左边的代价,作为一种优化的思路,可以想到应该尽量查找进入左边,从而减少高代价操作的次数,这个思想和哈夫曼树是一样的。斐波拉契查找就是这样一种算法。
|
||||
|
||||
斐波拉契查找的基本思想是不使用平均二分来划分左右两个子问题,而是使用黄金分割比来划分左右两个子区域,其中左边的区域会大于右边的区域,从而减少右边高代价操作的次数。这里要利用到一个`Fib`类,利用它可以在$O(logn)$的时间内创建最大值小于n的斐波拉契序列。其代码如下:
|
||||
|
||||
```
|
||||
int Vector<T>::search(T e, int lo, int hi){
|
||||
Fib fib(hi - lo);
|
||||
int mid;
|
||||
while(lo < hi){
|
||||
while(hi - lo < fib.get()) fib.prev();
|
||||
mid = lo + fib.get() - 1;
|
||||
if(e < _elem[mid]) hi = mid;
|
||||
else if(e > _elem[mid]) lo = mid + 1;
|
||||
else return mid;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
可以证明(可是目前还不会),当左右划分比例恰好是黄金分割比时,即按照斐波拉契数进行分割,可以达到最优的时间复杂度,为$O(1.44logn)$
|
||||
|
||||
> 二分查找平衡版本
|
||||
|
||||
上面的斐波拉契查找的最终目的就是实现左右操作的平衡,所以它是表面不平衡的分割,却实现了平衡的左右时间代价。但为什么这么麻烦呢,有没有可能我们可以改进二分查找,让它可以只进行一次比较,即非左即右的比较?在这种情况下,我们要忽略元素相同的情况,并且把它归入到左右的一种情况。代码如下:
|
||||
|
||||
```
|
||||
int Vector<T>::search(T e, int lo, int hi){
|
||||
int mid;
|
||||
while(lo < hi - 1){//注意不是lo < hi的判断条件了
|
||||
mid = (lo + hi) >> 1;
|
||||
(e < _elem[mid])? hi = mid: lo = mid;//注意是lo = mid
|
||||
}
|
||||
if(_elem[lo] == e) return lo;
|
||||
return -1;
|
||||
}
|
||||
```
|
||||
|
||||
由于查找区间是[lo,hi),因此我把相同的情况归入到右边的情况,否则`hi = mid`会直接将相同的元素排除在外。
|
||||
|
||||
以及由于右边的情况是`lo = mid`,如果循环条件是`while(lo < hi)`的话,则有可能出现死循环,所以需要改成`while(lo < hi - 1)`。
|
||||
|
||||
这个版本的二分查找虽然在比较相同的情况下也还需要继续查找,但是整体效率更加稳定,平均情况下的时间复杂度更好。
|
||||
|
||||
> 二分查找的ADT版本
|
||||
|
||||
上面说了那么多,还没有实现一开始定义的ADT...所以现在基于二分查找的平衡版本实现一下,基本思想都是一样的,只是有些细节很值得考虑:
|
||||
|
||||
```
|
||||
int Vector<T>::search(T e, int lo, int hi){
|
||||
int mid;
|
||||
while(lo < hi){
|
||||
mid = (lo + hi) >> 1;
|
||||
(e < _elem[mid])? hi = mid: lo = mid + 1;
|
||||
}
|
||||
return --lo;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,同样实现地非常简练,但是其内部逻辑其实非常完备和复杂。
|
||||
|
||||
+ 这里左边的操作又变成了`lo = mid + 1`,也就是说直接忽略相等的情况。这样,退出循环时`lo`总是指向大于目标元素的第一个元素。因此返回值应该是lo的前面一个元素(`--lo`)
|
||||
+ 由于左边操作变成了`lo = mid + 1`,循环条件又回到了`while(lo < hi)`,因为此时每一步操作都会缩小问题的规模,不会出现死循环了。
|
||||
|
||||
> 插值查找
|
||||
|
||||
考虑我们查字典的时候,如果要查algorithm,肯定不会先二分查找到normal,而是直接就在最前面的地方进行查找。这是因为我们对于字典各个元素的位置分布已经有了一定的认识,因此可以快速定位。
|
||||
|
||||
插值查找也是基于这一思想,是首先对于Vector的元素分布具有一定的认识,如是线性分布,或者二次分布,然后就可以利用相关的函数以及首末位置元素的值,估计出目标元素的位置,并且不断迭代,直到找到或者找不到目标元素。
|
||||
|
||||
比较常用的就是假定目标元素是线性分布,然后通过线性插值,找到目标元素的位置。所以叫插值查找。代码如下:
|
||||
|
||||
```
|
||||
int Vector<T>::interpolation_search(T const &elem, int lo, int hi){
|
||||
int mid;
|
||||
while(lo < hi){
|
||||
mid = lo + (elem - _elem[lo])*(hi - lo - 1) / (_elem[hi - 1] - _elem[lo]);
|
||||
if (mid < lo || mid >= hi) break;
|
||||
|
||||
if (elem < _elem[mid]) hi = mid;
|
||||
else if (_elem[mid] < elem) lo = mid + 1;
|
||||
else return mid;
|
||||
}
|
||||
return lo - 1;
|
||||
}
|
||||
```
|
||||
|
||||
插值查找在平均情况下,每经过一次比较,规模从$n$缩减到$\sqrt(n)$,其平均时间复杂度为$O(loglog n)$(需要证明,但是目前不会)。这个结果虽然看起来很理想,但是在目前人类的数据量看来,和$O(logn)$相比并不占多大优势(考虑$2^64$的数据量)。
|
||||
|
||||
此外,插值查找在大数据量的输入下,表现会比较好。但是,一旦数据量缩小到一定范围,就容易受到局部扰动的影响,从而耽误大量的时间,例如整体的数据分布是线性分布,却又一个小局部不满足线性分布,是二次分布的。所以,理想的查找方法是
|
||||
|
||||
+ 首先利用插值查找,以缩小查找的范围
|
||||
+ 利用二分查找完成查找操作
|
||||
|
||||
## 排序
|
||||
|
||||
### 冒泡排序
|
||||
|
||||
从最简单的冒泡排序开始吧。虽然我一开始听邓公讲课的时候也觉得这有什么好讲的?然后我错了。
|
||||
|
||||
基本冒泡排序算法如下:
|
||||
|
||||
```
|
||||
void Vector<T>::bubble_sort(int lo, int hi){
|
||||
while(!bubble(lo, hi--));
|
||||
}
|
||||
|
||||
bool Vector<T>::bubble(int lo, int hi){
|
||||
bool sorted = true;
|
||||
while(++lo < hi){
|
||||
if(_elem[lo] < _elem[lo - 1]){
|
||||
swap(_elem[lo], _elem[lo - 1]);
|
||||
sorted = false;
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
```
|
||||
|
||||
这里用到一个整体有序标志`sorted`来判断Vector是否已经全局有序,以防止额外的操作。
|
||||
|
||||
> 对上面版本的冒泡排序进行优化
|
||||
|
||||
考虑一种情况,即一个规模为n的Vector中,已经是局部有序了,只有前面k个元素是无序的。使用上面的算法的话,在最坏情况下,时间复杂度为$O(n * k)$。假定$ k = \sqrt(n)$,则时间复杂度为$O(n^{3/2})$。
|
||||
|
||||
但是实际上,它根本不需要这么高的时间复杂度。因为要是可以检测到该序列的局部有序性,只给前面的k个元素排序的话,就只需要$O(k^2) = O(n)$的时间代价就可以完成。
|
||||
|
||||
因此,我们需要知道每一次`bubble`最后进行的交换操作。而这并不难做到,只需要将`sorted`标志位改成最后一次交换的位置就可以了。根据这个位置信息,就可以知道当前序列的局部有序程度,从而对上面的算法进行优化。代码如下:
|
||||
|
||||
```
|
||||
void Vector<T>::bubble_sort(int lo, int hi){
|
||||
while(lo < (hi = bubble(lo, hi--));
|
||||
}
|
||||
|
||||
int Vector<T>::bubble(int lo, int hi){
|
||||
int sorted = lo;
|
||||
while(++lo < hi){
|
||||
if(_elem[lo] < _elem[lo - 1]){
|
||||
swap(_elem[lo], _elem[lo - 1]);
|
||||
sorted = lo;
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
```
|
||||
|
||||
虽然做了这样的优化,但是冒泡排序还是很渣。在最坏的条件下,仍然是$O(n^2)$的时间复杂度。与第一个版本相比还是没有本质上的提升。
|
||||
|
||||
### 比较树
|
||||
|
||||
> 什么是比较树?
|
||||
|
||||
基于比较的算法(CBA, Comparison Based Algorithm),如排序,找三个元素中的最大值这种,都可以描述成比较树的形式。比较树给出了一个算法所有的可能情况,其树根对应了算法入口的起始状态,内部结点对应算法过程中的某一步计算,叶节点对应了计算后的终止状态。这样一来,从根节点到叶节点的一条路径,就是算法的一个可能运行过程。
|
||||
|
||||
> 所以比较树有什么用?
|
||||
|
||||
可以用于估计问题的复杂度下界(即最坏情况下的最低计算成本),从而可以判断一个算法是否已经达到了极限。一旦某一个算法的性能达到这一下界,即意味着它已经是最坏情况下的最优算法了。要想进行优化,就需要采用不同的算法思想,比如采用非基于比较的算法。
|
||||
|
||||
> 如何估计问题的复杂度下界?
|
||||
|
||||
这里是一个比较粗略的估计。假定比较树有N个叶节点,在最优的情况下,即这棵树是完全平衡的,同时它还是一棵完美二叉树,则树高不可能低于$log_2^N$。这样,问题的规模也就不可能小于$O(log N)$。
|
||||
|
||||
例如对于排序问题,对于任意输入的一组序列,其结果可能有$n!$(n的全排)个。这样,比较树的树高不会低于$log_2^{n!} = O(nlogn)$,所以基于比较的排序的最优时间复杂度(复杂度下界)即$O(nlogn)$。
|
||||
|
||||
这里有一个误区,即认为实际中排序算法的比较树总是不平衡的,例如冒泡排序在最优情况下的路径长度为$O(n)$,而最坏情况下的输入的路径长度为$O(n^2)$。但这是不一定的,也许以后(或许现在已经)会有算法,可以做到排序问题的所有情况都具有相同的路径长度,即一种完全平衡的状态。问题的复杂度下界就是这样的一种情况。
|
||||
|
||||
### 归并排序
|
||||
|
||||
关于归并排序其实想说的不多,就关于归并操作写点东西。
|
||||
|
||||
归并操作的实现并不困难,但是问题在于归并的操作中需要用到额外的空间,所以还是要尽量让空间复杂度尽量小。邓公给出的代码如下:
|
||||
|
||||
```
|
||||
void Vector<T>::merge(int lo, int mid, int hi){
|
||||
int leftLen = mid - lo;
|
||||
T* tmp = new T[leftLen];
|
||||
for (int ix = 0; ix != leftLen; ++ix) tmp[ix] = _elem[ix + lo];
|
||||
|
||||
for (int pos = lo, ix = 0, jx = mid; ix != leftLen;) {
|
||||
if (hi <= jx || tmp[ix] <= _elem[jx]) _elem[pos++] = tmp[ix++];
|
||||
if (jx < hi && _elem[jx] < tmp[ix]) _elem[pos++] = _elem[jx++];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在上面的代码中,并不是将原数组中的所有元素都拷贝出来,而是只建立了前半部分的拷贝。这是因为后半部分不会被归并的过程所覆盖,所以可以既用作数据的源,也作为数据拷贝的终点。
|
||||
|
||||
关于这个代码还有要注意的地方在于循环的退出条件:该循环在前半部分索引完毕就可以退出,而没有考虑后半部分。这是因为,倘若前半部分已经读取完毕,而后半部分还有元素的话,后半部分的元素就直接在它们的目标位置了,因此不需要做任何操作。
|
||||
612
thu_os/lab1_report.md
Normal file
612
thu_os/lab1_report.md
Normal file
@@ -0,0 +1,612 @@
|
||||
Lab 1 Report
|
||||
============
|
||||
|
||||
## 实验目的
|
||||
|
||||
操作系统是一个软件,也需要通过某种机制加载并运行它。在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。lab1提供了一个非常小的bootloader和ucore OS,整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。通过分析和实现这个bootloader和ucore OS,读者可以了解到:
|
||||
|
||||
+ 计算机原理
|
||||
|
||||
- CPU的编址与寻址: 基于分段机制的内存管理
|
||||
- CPU的中断机制
|
||||
- 外设:串口/并口/CGA,时钟,硬盘
|
||||
- Bootloader软件
|
||||
|
||||
+ 编译运行bootloader的过程
|
||||
- 调试bootloader的方法
|
||||
- PC启动bootloader的过程
|
||||
- ELF执行文件的格式和加载
|
||||
- 外设访问:读硬盘,在CGA上显示字符串
|
||||
- ucore OS软件
|
||||
|
||||
+ 编译运行ucore OS的过程
|
||||
- ucore OS的启动过程
|
||||
- 调试ucore OS的方法
|
||||
- 函数调用关系:在汇编级了解函数调用栈的结构和处理过程
|
||||
- 中断管理:与软件相关的中断处理
|
||||
- 外设管理:时钟
|
||||
|
||||
## 实验内容
|
||||
|
||||
lab1中包含一个bootloader和一个OS。这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。
|
||||
|
||||
## 练习
|
||||
|
||||
为了实现lab1的目标,lab1提供了6个基本练习和1个扩展练习,要求完成实验报告。
|
||||
|
||||
对实验报告的要求:
|
||||
|
||||
+ 基于markdown格式来完成,以文本方式为主。
|
||||
+ 填写各个基本练习中要求完成的报告内容
|
||||
+ 完成实验后,请分析ucore_lab中提供的参考答案,并请在实验报告中说明你的实现与参考答案的区别
|
||||
+ 列出你认为本实验中重要的知识点,以及与对应的OS原理中的知识点,并简要说明你对二者的含义,关系,差异等方面的理解(也可能出现实验中的知识点没有对应的原理知识点)
|
||||
+ 列出你认为OS原理中很重要,但在实验中没有对应上的知识点
|
||||
|
||||
## 练习1:理解通过make生成执行文件的过程。
|
||||
|
||||
列出本实验各练习中对应的OS原理的知识点,并说明本实验中的实现部分如何对应和体现了原理中的基本概念和关键知识点。
|
||||
|
||||
在此练习中,大家需要通过静态分析代码来了解:
|
||||
|
||||
+ 操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)
|
||||
+ 一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
|
||||
|
||||
### 分析ucore.img的生成过程
|
||||
|
||||
通过`make V=`命令,可以看到ucore.img生成过程中各个命令的执行次序。整个生成过程大致可以分为三个阶段,现简述如下:
|
||||
|
||||
+ step1 生成操作系统kernel文件
|
||||
|
||||
- 首先是对于kernel的各个依赖项分别进行编译。这里只是举了几个例子,实际上通过这种方式,生成了许多内核需要用到的`.o`文件,如`vectors.o`, `pmm.o`, `stdio.o`, `clock.o`...
|
||||
|
||||
```
|
||||
cc kern/init/init.c
|
||||
gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc \-fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o
|
||||
cc kern/driver/console.c
|
||||
gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/console.c -o obj/kern/driver/console.o
|
||||
......
|
||||
```
|
||||
|
||||
- 将编译生成的这些`.o`文件,利用链接指令`ld`,生成操作系统内核可执行文件`kernel`。
|
||||
|
||||
```
|
||||
ld bin/kernel
|
||||
ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel obj/kern/init/init.o \
|
||||
obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o \
|
||||
obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o \
|
||||
obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o \
|
||||
obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o \
|
||||
obj/kern/mm/pmm.o obj/libs/printfmt.o obj/libs/string.o
|
||||
```
|
||||
|
||||
+ step2 生成bootloader可执行文件`bootblock`
|
||||
|
||||
- 同样,也是先对几个依赖项进行编译,包括`bootasm.S`, `bootmain.c`
|
||||
|
||||
```
|
||||
cc boot/bootasm.S
|
||||
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o
|
||||
cc boot/bootmain.c
|
||||
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o
|
||||
```
|
||||
|
||||
- 将编译生成的`bootasm.o`和`bootmain.o`链接生成bootloader可执行文件。这里可以注意到,在链接指令`ld`中,指定了该文件读到内存中的地址`0x7C00`,即bootloader起始地址。并且,该bootloader文件大小为512字节,正好为一个磁盘扇区的大小,这也与上课讲的理论知识相符合。
|
||||
|
||||
```
|
||||
ld bin/bootblock
|
||||
ld -m elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o
|
||||
'obj/bootblock.out' size: 488 bytes
|
||||
build 512 bytes boot sector: 'bin/bootblock' success!
|
||||
```
|
||||
|
||||
> 但是这里有一个问题,就是`bootblock.o`是怎么生成`bootclock.out`以及`bootclock`的,并且为什么它们的大小从488B变到了512B。
|
||||
|
||||
`sign.c`在其中起了作用,将488B的的`bootclock.out`读到了512B的`bin/bootclock`(后面将继续讨论)
|
||||
|
||||
+ step 3 将上面两步生成的kernel 以及 bootblock 连接生成虚拟磁盘镜像文件ucore.img,使用`dd`命令(convert and copy a file)
|
||||
|
||||
```
|
||||
dd if=/dev/zero of=bin/ucore.img count=10000
|
||||
10000+0 records in
|
||||
10000+0 records out
|
||||
5120000 bytes (5.1 MB) copied, 0.0530962 s, 96.4 MB/s
|
||||
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
|
||||
1+0 records in
|
||||
1+0 records out
|
||||
512 bytes (512 B) copied, 0.000104995 s, 4.9 MB/s
|
||||
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
|
||||
146+1 records in
|
||||
146+1 records out
|
||||
74923 bytes (75 kB) copied, 0.000316792 s, 237 MB/s
|
||||
```
|
||||
|
||||
可以看到,最终`ucore.img`由三部分组成:
|
||||
+ 第一部分为`/dev/zero`,不清楚是什么文件,是一个`character special file`
|
||||
+ 第二部分为bootloader
|
||||
+ 第三部分为操作系统内核kernel
|
||||
|
||||
### 符合规范的硬盘主引导扇区的特征
|
||||
|
||||
关于此项,老师给的提示是查看`tools/sign.c`文件。其主要功能是读取一个文件(第一个参数)到内存中,该文件大小小于510B,然后在其尾部添加主引导记录的结束标志`0x55AA`,并将512B(文件本身有效部分可能没有512B)写回到磁盘上的目标文件中(第二个参数)。
|
||||
|
||||
由此可以总结出符合规范的硬盘主引导扇区的特征:
|
||||
+ 文件大小为512B,即一个硬盘扇区的大小。(当然也有可能其中有效文件根本没有这么大,但是BIOS还是读入一整个磁盘扇区的512B)
|
||||
+ 文件尾部为`0x55AA`。这里文件尾部是指文件的第511字节和第512字节,而不管文件实际的有效大小有多大。
|
||||
|
||||
## 练习2:使用qemu执行并调试lab1中的软件。
|
||||
|
||||
为了熟悉使用qemu和gdb进行的调试工作,我们进行如下的小练习:
|
||||
|
||||
+ 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
|
||||
+ 在初始化位置`0x7c00`设置实地址断点,测试断点正常。
|
||||
+ 从`0x7c00`开始跟踪代码运行,将单步跟踪反汇编得到的代码与`bootasm.S`和`bootblock.asm`进行比较。
|
||||
+ 自己找一个bootloader或内核中的代码位置,设置断点并进行测试。
|
||||
|
||||
### 跟踪BIOS运行
|
||||
|
||||
老师说在命令行运行`make debug`就可以跟踪BIOS的运行了,然而实际操作的时候并不是这样。
|
||||
|
||||
```
|
||||
$ make debug
|
||||
The target architecture is assumed to be i8086
|
||||
0x0000fff0 in ?? ()
|
||||
Breakpoint 1 at 0x7d00: file boot/bootmain.c, line 88.
|
||||
|
||||
Breakpoint 1, bootmain () at boot/bootmain.c:88
|
||||
```
|
||||
|
||||
可以看到,程序并没有在BIOS第一条指令中断,而是直接运行到了`bootmain.c`的`bootmain()`。为了解决这个问题,可以查看`Makefile`里面关于`debug`命令的描述
|
||||
|
||||
```
|
||||
debug: $(UCOREIMG)
|
||||
$(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &
|
||||
$(V)sleep 2
|
||||
$(V)$(TERMINAL) -e "cgdb -q -x tools/gdbinit"
|
||||
```
|
||||
|
||||
这里说,运行gdb的时候,是去读`tools/gdbinit`文件,因此去看看这个文件
|
||||
|
||||
```
|
||||
1 file obj/bootblock.o
|
||||
2 set architecture i8086
|
||||
3 target remote :1234
|
||||
4 break bootmain
|
||||
5 continue
|
||||
```
|
||||
|
||||
问题出现了,这里定义是在`bootmain`这个函数这里设置到断点,然后它居然就`continue`了,所以程序运行就直接来到了`bootmain`。解决方案也很简单,我选择使用`make lab1-mon`指令,正常运行时断点在bootloader的第一条指令(0x7c00)处,我把它的`continue`给删掉就可以了。删掉之后`tools/lab1init`长这样:
|
||||
|
||||
```
|
||||
1 set architecture i8086
|
||||
2 target remote :1234
|
||||
```
|
||||
|
||||
现在,运行`make lab1-mon`就可以跟踪BIOS运行了。
|
||||
|
||||
```
|
||||
The target architecture is assumed to be i8086
|
||||
0x0000fff0 in ?? ()
|
||||
(gdb) x /2i $pc
|
||||
=> 0xfff0: add %al,(%bx,%si)
|
||||
0xfff2: add %al,(%bx,%si)
|
||||
(gdb) x $cs
|
||||
0xf000: add %al,(%bx,%si)
|
||||
(gdb)
|
||||
```
|
||||
|
||||
在上面的指令中,我查看了当前的`ip`寄存器以及`cs`寄存器,从而可以得出当前运行的指令在`0xffff0`,这就是BIOS第一条指令存储的位置,也是CPU上电后执行的第一条指令。现在去查看这条指令
|
||||
|
||||
```
|
||||
(gdb) x /2i 0xffff0
|
||||
0xffff0: ljmp $0xf000,$0xe05b
|
||||
0xffff5: xor %dh,0x322f
|
||||
```
|
||||
|
||||
这是一个长跳转指令,会跳转到`0xfe05b`这个位置,然后继续执行。
|
||||
|
||||
### 跟踪Bootloader的执行
|
||||
|
||||
在原理里面有讲到,BIOS加载Bootloader到内存中的`0x7c00`位置,并且跳转到这个位置执行。为了跟踪Bootloader执行,可以在`0x7c00`处设置一个断点。
|
||||
|
||||
```
|
||||
(gdb) b *0x7c00
|
||||
Breakpoint 1 at 0x7c00
|
||||
```
|
||||
|
||||
要注意的是这里设置断点要使用`*`标志符,表示是对内存中某个地址设置断点。
|
||||
|
||||
之后,就可以查看Bootloader的汇编代码了:
|
||||
|
||||
```
|
||||
(gdb) c
|
||||
Continuing.
|
||||
|
||||
Breakpoint 1, 0x00007c00 in ?? ()
|
||||
(gdb) x /10i $pc
|
||||
=> 0x7c00: cli
|
||||
0x7c01: cld
|
||||
0x7c02: xor %ax,%ax
|
||||
0x7c04: mov %ax,%ds
|
||||
0x7c06: mov %ax,%es
|
||||
0x7c08: mov %ax,%ss
|
||||
0x7c0a: in $0x64,%al
|
||||
0x7c0c: test $0x2,%al
|
||||
0x7c0e: jne 0x7c0a
|
||||
0x7c10: mov $0xd1,%al
|
||||
```
|
||||
|
||||
和`bootasm.S`以及`bootblock.asm`比对的话,代码都是一样的。(当然是一样的,执行的就是它的代码...
|
||||
|
||||
## 练习3:分析bootloader进入保护模式的过程
|
||||
|
||||
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。
|
||||
|
||||
提示:需要阅读小节“保护模式和分段机制”和lab1/boot/bootasm.S源码,了解如何从实模式切换到保护模式,需要了解:
|
||||
|
||||
+ 为何开启A20,以及如何开启A20
|
||||
+ 如何初始化GDT表
|
||||
+ 如何使能和进入保护模式
|
||||
|
||||
### bootloader进入保护模式的过程
|
||||
|
||||
其实在题干上面已经说清楚了,Bootloader从实模式进入保护模式,主要需要进行三方面的工作:
|
||||
|
||||
+ 开启A20地址线。
|
||||
+ 初始化段表(GDT表)。这是因为实模式和保护模式的寻址方式不同,在保护模式中使用段表来进行寻址,因此在进入保护模式之前应该首先设置好段表(GDT)以及段表寄存器(GDTR)。
|
||||
+ 设置系统寄存器CR0,以使能保护模式位,进入保护模式。
|
||||
+ 进入保护模式后,寻址方式发生了改变。当前的CS:IP已经不能寻址到下一条本应执行的指令,因此需要一条长跳转指令,开始保护模式的运行。
|
||||
|
||||
下面将针对各条具体进行说明。
|
||||
|
||||
### A20地址线
|
||||
|
||||
> A20地址线有什么作用?
|
||||
|
||||
A20地址线的控制,简单说来,就是可以使能A20地址线。在A20地址线禁止的情况下,内存地址的第20位(对于32位地址,是从0~31位)恒为零。这样,系统就只能访问奇数兆的空间,即0--1M, 2--3M...所以在保护模式下,必须要打开A20地址线
|
||||
|
||||
> 为什么会出现A20地址线的控制?
|
||||
|
||||
这是具有一定的历史原因,是为了intel x86产品向下兼容而出现的。
|
||||
|
||||
说的是本来8086的芯片本来只有20位的地址线,只能访问1M的内存。但是实际上,在cs = 0xffff, ip = 0xffff时,cs:ip = 0x10ffef。这就超过了1M的内存访问空间。8086的芯片在这种情况下会出现“回卷”,即不会出现异常,而是会从零地址0x0重新定位。
|
||||
|
||||
但是在后来的芯片中,地址线的位数增加了,比如这时有了24位地址线。但是为了向下兼容,芯片提供了实模式,使之也可以工作在8086的条件下。但是由于地址线多了,寻址超过1M内存时,系统不再会回卷了,而是会实际去访问那部分不能访问的空间(想来应该是硬件电路设计的问题),这就造成了向下不兼容。
|
||||
|
||||
为了实现向下兼容的回卷,IBM给整个系统加上了硬件逻辑,这样就出现了A20 Gate。在A20 Gate禁止的情况下,访问超过1M内存的空间时,由于第20位地址始终为零,就又出现了回卷。
|
||||
|
||||
> 如何控制A20地址线?
|
||||
|
||||
这就完全是硬件方面的知识了。需要查看相关控制器的手册。这些控制器一般都是以外设的形式与CPU连接的,所以主要是通过与外设通信的`in`和`out`指令,来控制这些外设的功能。
|
||||
|
||||
对于A20地址线来说,控制它的外设即`8042键盘控制器`(叫是叫这个名字,但是好像和键盘没有任何联系)
|
||||
|
||||
`8042键盘控制器`的IO端口是0x60~0x6f, 实际上使用的只有0x60和0x64两个端口。其中,0x60端口是数据端口,命令的参数以及返回值都是通过这个读写端口获得, 0x64端口是命令端口,命令是通过写这个端口来控制`8042键盘控制器`。除此以外,8042还有3个内部端口:Input Port、Outport Port和Test Port。它们的一些具体操作如下:
|
||||
|
||||
+ 读Output Port:向64h发送0d0h命令,然后从60h读取Output Port的内容
|
||||
+ 写Output Port:向64h发送0d1h命令,然后向60h写入Output Port的数据
|
||||
+ 禁止键盘操作命令:向64h发送0adh
|
||||
+ 打开键盘操作命令:向64h发送0aeh
|
||||
|
||||
A20 Gate被定义在Output Port的bit 1上。所以理论上讲,我们只要操作8042芯片的输出端口(64h)的bit 1,就可以控制A20 Gate,但实际上,当你准备向8042的输入缓冲区里写数据时,可能里面还有其它数据没有处理,所以,我们要首先禁止键盘操作,同时等待数据缓冲区中没有数据以后,才能真正地去操作8042打开或者关闭A20 Gate。打开A20 Gate的具体步骤大致如下:
|
||||
|
||||
+ 等待8042 Input buffer为空
|
||||
+ 发送Write 8042 Output Port (P2)命令到8042 Input buffer
|
||||
+ 等待8042 Input buffer为空
|
||||
+ 将8042 Output Port(P2)得到字节的第2位置1,然后写入8042 Input buffer
|
||||
|
||||
> 具体操作代码
|
||||
|
||||
查看bootasm.S可以找到使能A20地址线的代码:
|
||||
|
||||
```
|
||||
seta20.1:
|
||||
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
|
||||
testb $0x2, %al
|
||||
jnz seta20.1
|
||||
|
||||
movb $0xd1, %al # 0xd1 -> port 0x64
|
||||
outb %al, $0x64 # 0xd1 means: write data to 8042's P2 port
|
||||
|
||||
seta20.2:
|
||||
inb $0x64, %al # Wait for not busy(8042 input buffer empty).
|
||||
testb $0x2, %al
|
||||
jnz seta20.2
|
||||
|
||||
movb $0xdf, %al # 0xdf -> port 0x60
|
||||
outb %al, $0x60 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1
|
||||
```
|
||||
|
||||
其中需要做一些说明:
|
||||
|
||||
+ `inb $0x64, %al`指令读0x64端口。之前说0x64端口是命令端口,但是读这个端口可以获得关于8042的一些状态信息,因此可以判断该控制器是否就绪。如果没有就绪的话,就持续读该端口,直到控制器就绪。
|
||||
+ `movb $0xdf, %al;outb %al, $0x60`直接将11011111b写到了8042控制器,对于我来说感觉有点莽撞。或许先读Output Port,然后再只修改A20位的值为1,再写回到控制器是不是会更稳妥一点?
|
||||
|
||||
### 初始化段表
|
||||
|
||||
上面提到了为什么要首先初始化段表?这是由于保护模式和实模式的寻址方式不同,下面将具体讨论。
|
||||
|
||||
> 保护模式和实模式的寻址方式有什么不同?
|
||||
|
||||
实模式就比较简单,直接cs:ip,然后cs左移4为就完事儿了,非常简单。
|
||||
|
||||
保护模式就不一样了,因为地址空间已经变成了32位,而段寄存器的长度还是16位,直接用段寄存器的值来索引内存的某一个段感觉有点寒碜啊。这时候,段寄存器的值已经不是某一个地址了,而是用来索引段表中某一项的段选择子(segment selector)
|
||||
|
||||
我们知道,段表的结构是一个线性表,里面每一项包括8字节,其中前32位指令该段在内存中的起始地址。段表中至多可以有8192(2^13)项,也就是允许同时存在8192个段。此时,CS寄存器中的值只是对段表这个线性表的一个索引,表示当前选择的是段表中的第几个段。
|
||||
|
||||
由于至多有8192个段,因此可以只用CS中的高13位,就足以指定段表中的任意一个段。还有低3位是用来表示一些控制信息:
|
||||
|
||||
+ 第2位,表指示位(TI, Table Indicator),用来指定应该访问哪一个描述符表。0表示全局描述符表(GDT), 1表示局部描述符表(LGT)。
|
||||
+ 第0--1位,请求特权级(RPL, Requested Privilege Level),制定当前段的优先级,是用于保护机制的。
|
||||
|
||||
> 如何找到段表的位置?
|
||||
|
||||
段表作为一个特殊的数据结构,本身并不属于任意一个段,而是相当于一个独立的段。通过全局描述符表寄存器(GDTR)来标志其位置。GDTR是一个48位寄存器,其中前32位标志了段表的位置,后16位为limit,指示了段表的大小(段表的最后一个字节),其值通常是$8·N-1$,其中N为段表中的段描述符的数量。8是因为段表中一个段描述符为8个字节,64bits。
|
||||
|
||||
通过上面的讨论,现在可以总结出保护模式下内存访问的步骤:
|
||||
|
||||
+ 读取CS寄存器,获得要访问的段描述符在段表中的偏移量
|
||||
+ 读取GDTR寄存器,验证访问的偏移没有越界,并得到段表在内存中的地址
|
||||
+ 找到段表,并访问目标段描述符,获得目标段的内存地址。
|
||||
+ 将段描述符中的段大小(20位)与IP寄存器中的偏移量进行比较,确保没有越界
|
||||
+ 一些访问权限的检验
|
||||
+ 将段的内存地址与IP中的偏移量相加,获得要访问的物理内存地址。
|
||||
|
||||
> 初始化段表的代码
|
||||
|
||||
在bootasm.S中可以找到初始化段表的代码:
|
||||
|
||||
```
|
||||
lgdt gdtdesc
|
||||
|
||||
gdt:
|
||||
SEG_NULLASM # null seg
|
||||
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
|
||||
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel
|
||||
|
||||
gdtdesc:
|
||||
.word 0x17 # sizeof(gdt) - 1
|
||||
.long gdt # address gdt
|
||||
|
||||
```
|
||||
|
||||
其中,`SEG_NULLASM`以及`SET_ASM`在asm.h中定义
|
||||
|
||||
```
|
||||
#define SEG_NULLASM \
|
||||
.word 0, 0; \
|
||||
.byte 0, 0, 0, 0
|
||||
|
||||
#define SEG_ASM(type,base,lim) \
|
||||
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
|
||||
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
|
||||
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
|
||||
|
||||
|
||||
/* Application segment type bits */
|
||||
#define STA_X 0x8 // Executable segment
|
||||
#define STA_E 0x4 // Expand down (non-executable segments)
|
||||
#define STA_C 0x4 // Conforming code segment (executable only)
|
||||
#define STA_W 0x2 // Writeable (non-executable segments)
|
||||
#define STA_R 0x2 // Readable (executable segments)
|
||||
#define STA_A 0x1 // Accessed
|
||||
```
|
||||
|
||||
在上面的代码中,
|
||||
+ `gdt`段定义了段表中的三个段描述符,即空描述符(第一个总是空描述符), 代码段描述符,数据段描述符。这里注意到,代码段和数据段是重合的,都是从0地址开始。然后也和当前CS寄存器的值相同,确保进入保护模式后程序可以正常跳转。
|
||||
+ `gdtdesc`段定义了GDTR寄存器中应该有的值,其高32位是段表的起始地址(gdt),低16位为段表的limit(之前我以为它错了,怎么可能是17?!然后我刚刚发现它是16进制的,所以应该是23,即3·8-1)
|
||||
+ `ldgt gdtdesc`将`gdtdesc`的值加载到GDTR中,这样,段表就初始化完毕了。
|
||||
|
||||
### 设置系统寄存器CR0
|
||||
|
||||
系统寄存器CR0的第0位PE(Protection Enable)用于使能保护模式。设置为1时就可以开启保护模式。这样,就是简单的寄存器操作了。
|
||||
|
||||
```
|
||||
.set CR0_PE_ON, 0x1 # protected mode enable flag
|
||||
|
||||
movl %cr0, %eax
|
||||
orl $CR0_PE_ON, %eax
|
||||
movl %eax, %cr0
|
||||
```
|
||||
|
||||
太简单了,就不多说了。
|
||||
|
||||
### 执行长跳转指令
|
||||
|
||||
> 为什么需要长跳转指令?
|
||||
|
||||
改变寻址方式后,使用之前的CS和IP已经不能正确访问到下一条要执行的指令了。就比如说,当前`cs = 0X0`, 使用这个段选择子只能选择到段表中的第一项,即`SEG_NULLASM`,而这时一个无效项。要正确访问到段表中的代码段描述符,应该将`cs`设置为段表中第一项的偏移量,即0x0008,这是因为段选择子的低三位为控制位,第二位为0表示访问GDT,低两位为零表示最高特权级。
|
||||
|
||||
但是存在一个问题,`cs`寄存器是不能直接设置的。它只能通过程序跳转指令,如`CALL`, `RET`, `INT`, `LJMP`指令来改变。因此,这里需要一个长跳转指令,来改变cs寄存器中的值,使之可以正确访问下一条要执行的指令。
|
||||
|
||||
```
|
||||
.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
|
||||
|
||||
.code16
|
||||
......
|
||||
|
||||
# Jump to next instruction, but in 32-bit code segment.
|
||||
# Switches processor into 32-bit mode.
|
||||
ljmp $PROT_MODE_CSEG, $protcseg
|
||||
|
||||
.code32 # Assemble for 32-bit mode
|
||||
protcseg:
|
||||
# Set up the protected-mode data segment registers
|
||||
```
|
||||
|
||||
其中,`$PROT_MODE_CSEG`就是段表中代码段的偏移,即0x8, `$protcseg`则为下一条指令的偏移地址。这样,Bootloader才算完全实现了从实模式进入到保护模式。
|
||||
|
||||
|
||||
## 练习4:分析bootloader加载ELF格式的OS的过程。
|
||||
|
||||
通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS
|
||||
|
||||
+ bootloader如何读取硬盘扇区的?
|
||||
+ bootloader是如何加载ELF格式的OS?
|
||||
|
||||
提示:可阅读“硬盘访问概述”,“ELF执行文件格式概述”这两小节。
|
||||
|
||||
### Bootloader读取硬盘扇区
|
||||
|
||||
Bootloader的主要功能就是要读取硬盘扇区上的操作系统,把它加载到内存中,然后把控制权转交给操作系统。因此这里就涉及到程序要直接读取硬盘扇区。
|
||||
|
||||
我们知道,硬盘设备是作为外设与CPU连接的,因此读取硬盘扇区本质上也是通过I/O操作,发送命令并且读取数据,以将硬盘上的数据读取到内存中,这与上面A20的开启本质上是一样的。
|
||||
|
||||
> 硬盘扇区的硬件格式
|
||||
|
||||
一般主板有2个IDE(Integrated Drive Electronics)通道,每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7来实现。
|
||||
|
||||
读写硬盘根据寻址空间大小的不同,可以分为多个模式,如
|
||||
+ chs方式 :小于8G (8064MB)
|
||||
+ LBA28方式:小于137GB
|
||||
+ LBA48方式:小于144,000,000 GB
|
||||
|
||||
其中LBA28模式表示28位的逻辑区块地址(Logical Block Address),每一个扇区为512字节,这样28位代表了256M个扇区,所以可以存储128GB的数据。我们这里就是使用LBA28的方式来读取硬盘。
|
||||
|
||||
LBA28模式具有多个寄存器:
|
||||
|
||||
+ data寄存器,位于端口号0x1F0。可以用来读取或写入数据
|
||||
+ features寄存器,位于端口号0x1F1。用于读取时的错误信息,写入时的额外参数
|
||||
+ sector count寄存器,位于0x1F2。用于指定读取或写入的扇区数
|
||||
+ LBA low寄存器,位于0x1F3。用于指定lba地址的低8位
|
||||
+ LBA mid寄存器,位于0x1F4。用于指定lba地址的中8位
|
||||
+ LBA high寄存器 ,位于0x1F5。用于指定lba地址的高8位
|
||||
+ device寄存器,位于端口号0x1F6。用于指定lba地址的前4位(占用device寄存器的低4位),主盘值为0(占用device寄存器的第5位),第6位值为1,第7位LBA模式为1,CHS模式为0,第8位值为1
|
||||
+ command寄存器,位于端口号啊0x1F7。用于读取,写入的命令,返回磁盘状态。其中0x20命令表示读取扇区,0x30命令表示写入扇区
|
||||
|
||||
> 读取硬盘扇区的方法
|
||||
|
||||
简单说来,可以分为下面几步
|
||||
|
||||
+ 等待磁盘准备好
|
||||
|
||||
读取command寄存器,判断硬盘状态是否就绪
|
||||
|
||||
```
|
||||
/* waitdisk - wait for disk ready */
|
||||
static void
|
||||
waitdisk(void) {
|
||||
while ((inb(0x1F7) & 0xC0) != 0x40)
|
||||
/* do nothing */;
|
||||
}
|
||||
```
|
||||
|
||||
+ 发出读取扇区的命令
|
||||
|
||||
将要读取的硬盘扇区的信息先写入前面那些寄存器,然后通过command寄存器发出读取的命令。
|
||||
|
||||
```
|
||||
outb(0x1F2, 1); // count = 1
|
||||
outb(0x1F3, secno & 0xFF);
|
||||
outb(0x1F4, (secno >> 8) & 0xFF);
|
||||
outb(0x1F5, (secno >> 16) & 0xFF);
|
||||
outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
|
||||
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
|
||||
}
|
||||
```
|
||||
|
||||
+ 再次等待磁盘准备好
|
||||
|
||||
再次调用`waitdist`函数。
|
||||
|
||||
+ 把磁盘扇区数据读到指定内存
|
||||
|
||||
从data寄存器读取请求的数据。
|
||||
|
||||
```
|
||||
// read a sector
|
||||
insl(0x1F0, dst, SECTSIZE / 4);
|
||||
```
|
||||
|
||||
要注意的是这里的`insl(0x1F0, dst, SECTSIZE / 4)`,为什么`SECSIZE`要除以4呢?这是因为`insl`指令是读入一个长字符(l for long)序列,读入的单位是4B,第三个参数是制定要读入的4字节的数量,所以应该除以4,以保证读入512字节,即一个扇区的大小。
|
||||
|
||||
上面给出的程序是读取一个硬盘扇区的函数,通过恰当的封装,可以实现通过从0开始的字节数来读取恰当硬盘扇区的函数(bootmain.c中正是这样做的)。但由于其基本原理还是那样,在这里不多做叙述。
|
||||
|
||||
### Bootloader加载ELF格式的OS
|
||||
|
||||
在上面的讨论中,已经可以做到将OS读入到内存当中,之后还剩下的问题是Bootloader需要将控制权转交给操作系统,为此就需要知道OS应该从哪条指令执行,然后跳转到那条指令。简单说来,就是要能解析OS的文件格式,而这里是elf格式。
|
||||
|
||||
> 什么是elf格式?
|
||||
|
||||
elf,<del>其实就是精灵。</del>即Executable and Linkable Format,为可执行和可链接格式。如它的名字所指示的,elf格式文件包括三种主要类型:
|
||||
|
||||
+ 可执行文件(executable file)。显然,我们这里的OS就是可执行文件。
|
||||
+ 可重定位文件(relocatable file)
|
||||
+ 共享目标文件(shared object file)
|
||||
|
||||
这里我们只分析可执行文件类型。
|
||||
|
||||
elf可执行文件由几个部分组成:
|
||||
|
||||
+ ELF头部。ELF header在文件开始处描述了整个文件的组织。比如文件的适用机器,文件的大小,版本,程序头的入口以及程序头的数量,还有整个可执行文件的入口地址。
|
||||
|
||||
```
|
||||
struct elfhdr {
|
||||
uint magic; // must equal ELF_MAGIC
|
||||
uchar elf[12];
|
||||
ushort type;
|
||||
ushort machine;
|
||||
uint version;
|
||||
uint entry; // 程序入口的虚拟地址
|
||||
uint phoff; // program header 表的位置偏移
|
||||
uint shoff;
|
||||
uint flags;
|
||||
ushort ehsize;
|
||||
ushort phentsize;
|
||||
ushort phnum; //program header表中的入口数目
|
||||
ushort shentsize;
|
||||
ushort shnum;
|
||||
ushort shstrndx;
|
||||
};
|
||||
```
|
||||
|
||||
其中,通过判断magic字段,可以确定这是否为一个合法的ELF文件。若是合法的ELF文件,则可以通过程序头的偏移量(`phoff`)以及程序头的数量(`phnum`),来读到程序头,从而可以获得程序运行的更加具体的信息。
|
||||
|
||||
+ 程序头表。描述与程序执行直接相关的目标文件结构信息,用来在文件中定位各个段的映像,同时包含其他一些用来为程序创建进程映像所必需的信息。
|
||||
|
||||
```
|
||||
struct proghdr {
|
||||
uint type; // 段类型
|
||||
uint offset; // 段相对文件头的偏移值
|
||||
uint va; // 段的第一个字节将被放到内存中的虚拟地址
|
||||
uint pa;
|
||||
uint filesz;
|
||||
uint memsz; // 段在内存映像中占用的字节数
|
||||
uint flags;
|
||||
uint align;
|
||||
};
|
||||
```
|
||||
可以看到,其中描述了程序段相对于文件头开始位置`offset`,以及被加载到内存当中的地址`va`。利用这些信息,可以把相应的程序加载到指定的位置,从而使得后续控制权可以正常地转交给os。
|
||||
|
||||
+ 节
|
||||
+ 节头表
|
||||
|
||||
> 加载elf格式的os并且转交控制权的过程
|
||||
|
||||
可以查看`bootmain.c`的代码。可以看到,Bootloader加载elf格式的os可以分为几个步骤:
|
||||
|
||||
+ 将os从硬盘读入到内存中,并判断是否合法
|
||||
|
||||
```
|
||||
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);
|
||||
if (ELFHDR->e_magic != ELF_MAGIC) {
|
||||
goto bad;
|
||||
}
|
||||
```
|
||||
|
||||
+ 通过elf文件头,获得该os的基本信息,如程序头的入口。并且将可执行代码从程序头标志的位置读入到内存中被程序头指定的地址。
|
||||
|
||||
```
|
||||
ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
|
||||
eph = ph + ELFHDR->e_phnum;
|
||||
for (; ph < eph; ph ++) {
|
||||
readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
|
||||
}
|
||||
```
|
||||
|
||||
+ 跳转到elf文件标志的起始地址开始执行os的指令。至此,CPU的控制权转交给了os。
|
||||
|
||||
```
|
||||
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
|
||||
```
|
||||
Reference in New Issue
Block a user