NVIDIA多线程:CUDA-安装使用(配合VS2017)

安装

https://developer.nvidia.com/cuda-downloads?target_os=Windows&target_arch=x86_64,直接下载安装。装的时候安装选项全都装上就行。

兼容问题

使用的是 CUDA9.1 和 VS2017. 由于CUDA更新远远赶不上VS,所以……直接使用是不行的……

解决方案见https://blog.csdn.net/shenpibaipao/article/details/79519533,摘录于下:

  • 打开VS Installer,修改-单个组件,选上用于桌面的VC++2015.3 v140 工具集(x86, x64)
  • 项目属性里面换掉平台工具集
  • C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.1\include\crt\host_config.h,修改第133行的#if _MSC_VER < 1600 || _MSC_VER > 1911,将1911改成一个比较大的数(如1920)
  • 因为v140里面没有安装CUDA,所以需要重新装一下CUDA

第一个项目

创建项目的时候选择模板-NVIDIA-CUDA,直接给示例程序,直接运行就行。(就只是向量相加,很快的)

代码分析:

  • 首先一堆include
  • addWithCuda函数用于main函数中调用,其中进行以下操作:
    • 定义显存中的指针dev_a,dev_b,dev_c,中间的函数调用全部会返回一个状态码,设置变量cudaStatus用于保存状态码
    • SetDevice
    • cudaMalloc函数来申请指针对应数组的空间
    • cudaMemcpy函数来向那三个指针里面拷贝内存中的数据
    • 调用计算用的函数,在显存里面计算
    • cudaGetLastError来检测有无错误
    • cudaDeviceSynchronize等待所有操作结束
    • cudaMemcpy来将结果数组拷贝回来
    • cudaFree释放空间

上面的程序也没有用多线程,也没啥复杂的操作,类似一个……代码比较长的helloworld

行数多主要是因为要考虑各种异常情况并且提示……自己写就懒得加了[doge]

程序设计

GPU配置查看

要多线程,肯定先看看多少个线程合适。代码里面直接添加(来自http://www.360doc.com/content/18/0408/16/5315_743836114.shtml):

1
2
3
4
5
6
7
8
9
int dev = 0;
cudaDeviceProp devProp;
cudaGetDeviceProperties(&devProp, dev);
std::cout << "使用GPU device " << dev << ": " << devProp.name << std::endl;
std::cout << "SM的数量:" << devProp.multiProcessorCount << std::endl;
std::cout << "每个线程块的共享内存大小:" << devProp.sharedMemPerBlock / 1024.0 << " KB" << std::endl;
std::cout << "每个线程块的最大线程数:" << devProp.maxThreadsPerBlock << std::endl;
std::cout << "每个EM的最大线程数:" << devProp.maxThreadsPerMultiProcessor << std::endl;
std::cout << "每个EM的最大线程束数:" << devProp.maxThreadsPerMultiProcessor / 32 << std::endl;

结果类似:

1
2
3
4
5
6
使用GPU device 0: GeForce GTX 950M
SM的数量:5
每个线程块的共享内存大小:48 KB
每个线程块的最大线程数:1024
每个EM的最大线程数:2048
每个EM的最大线程束数:64

ubuntu-apt-get换阿里云源

来自https://www.cnblogs.com/gabin/p/6519352.html

我的系统是16.04LTS

备份

sudo mv /etc/apt/sources.list /etc/apt/source.list.bak

新建

sudo vim /etc/apt/sources.list

文件中的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
deb http://mirrors.aliyun.com/ubuntu/ xenial main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ xenial-security main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ xenial-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ xenial-backports main restricted universe multiverse
##测试版源
deb http://mirrors.aliyun.com/ubuntu/ xenial-proposed main restricted universe multiverse
# 源码
deb-src http://mirrors.aliyun.com/ubuntu/ xenial main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-backports main restricted universe multiverse
##测试版源
deb-src http://mirrors.aliyun.com/ubuntu/ xenial-proposed main restricted universe multiverse
# Canonical 合作伙伴和附加
deb http://archive.canonical.com/ubuntu/ xenial partner
deb http://extras.ubuntu.com/ubuntu/ xenial main

更新

1
2
sudo apt-get update
sudo apt-get upgrade

二分查找

二分查找这个非常基础的东西竟然写错了……而且没有意识到,在别的地方debug了半天……

可见基础还是很差,需要多注意。

二分查找:(C++为例,用于多线程归并,查找到的位置要不小于查找的元素,但是左边的元素可以等于查找的元素。查找到的位置可以是数组的右侧下一个元素。)

  • 一种方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    		int left = 最左边的那个可能的元素下标, right = 右边可能元素的下标, halflen2;
    while (left <= right) {
    halflen2 = (left + right) >> 1;
    if (A[halflen2] > target) {
    right = halflen2 - 1;
    }
    else {
    left = halflen2 + 1;
    }
    }
    halflen2 = left;

    这样可以保证每一步中,right所对应的位置不小于target,left对应的位置不大于target。
    这里面没有分开考虑小于和等于,因为现在条件里面不需要……
    但是,如果right和left越界,会怎样呢?

    • 如果left越界,也就是说,当时left已经到了右边界,右边界对应的元素小于等于所要找的;因此会指向最后一个元素的下一个,满足要求。
    • 如果right越界,也就是说,现在right已经到了左边界,所有元素都大于target。但是返回的是left,没有问题。
  • 另一种:

    1
    2
    3
    4
    5
    6
    7
    8
    low = xxx
    high = xxx
    while low < high
    mid = ⌊(low + high)/2
    if x ≤ T[mid]
    high = mid
    else low = mid + 1
    return high

    这里面因为low<high,因此最终会使得low==high,最后返回哪一个无所谓。而且不会发生越界。

windows下pip换源

来源

http://www.cnblogs.com/microman/p/6107879.html

用法

可以直接:

1
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple pyspider

使用清华镜像

或者在user目录中创建一个pip目录,如:C:\Users\xx\pip,新建文件pip.ini。内容:

1
2
3
4
[global]
index-url = http://mirrors.aliyun.com/pypi/simple/
[install]
trusted-host=mirrors.aliyun.com

公众号推荐

推荐一波自己的公众号:五道口的程序狐

里面有一个聊天机器人,抚慰你的心灵

mp

如有需要,联系contact@fhao.top

TensorFlow-安装与使用

简介

记录一下学习Tensorflow的过程,包括代码和教程。

学习资料包括http://wiki.jikexueyuan.com/project/tensorflow-zh/tutorials

https://www.tensorflow.org/get_started/

代码见https://github.com/fengh16/TensorFlow-Learning

一些公式使用$$标记,如果显示不出请使用typora并且复制进去读取。

环境

Windows 10,Ubuntu18.04 GTX 950M; (Ubuntu安装过程见https://www.jianshu.com/p/a4d72364a7ee 相关部分)

腾讯云辣鸡学生机(ubuntu 1U2G)……反正只放了一个公众号后台,跑跑tensorflow也无妨,大不了跑个几天几夜

安装

安装python3, pip等,之后ubuntu系统直接pip install tensorflow,win10下面……一定要下载64位的python 3.5/3.6(我也不知道为啥原来下载的是32位的)!之后直接pip install tensorflow。如果有报错,试试pip install --upgrade --ignore-installed tensorflow

(记得换源加速,见https://www.jianshu.com/p/a8b84ac0e609)

如果安装gpu版本:

  • 因为官方的是基于cuda 9.0版本的,所以就直接安装cuda9.0版本,之后安装cudnn并且复制文件就行。

    • 复制一堆文件
      • Copy \cuda\bin\cudnn64_7.dll to C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.0\bin.
      • Copy \cuda\ include\cudnn.h to C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.0\include.
      • Copy \cuda\lib\x64\cudnn.lib to C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.0\lib\x64.
  • 如果想用9.1版本的……那就折腾吧……(原始教程:https://blog.csdn.net/vcvycy/article/details/79298703)

    • 安装cuda 9.1+VS2017
    • 安装cudnn7.0
    • 复制一堆文件
      • Copy \cuda\bin\cudnn64_7.dll to C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.0\bin.
      • Copy \cuda\ include\cudnn.h to C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.0\include.
      • Copy \cuda\lib\x64\cudnn.lib to C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v9.0\lib\x64.
    • https://github.com/fo40225/tensorflow-windows-wheel下载Cuda9.1版本的tensorflow

检测TensorFlow安装

代码见https://github.com/fengh16/TensorFlow-Learning/blob/master/001-FirstTry-180419/test.py

1
2
3
4
5
6
7
8
9
10
11
from __future__ import absolute_import, division, print_function

import os

import tensorflow as tf
import tensorflow.contrib.eager as tfe

tf.enable_eager_execution()

print("TensorFlow version: {}".format(tf.VERSION))
print("Eager execution: {}".format(tf.executing_eagerly()))

第一个程序

代码见https://github.com/fengh16/TensorFlow-Learning/blob/master/001-FirstTry-180419/try.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 生成三维数据之后用平面拟合

import tensorflow as tf
import numpy as np

# 使用 NumPy 生成假数据(phony data), 总共 100 个点.
x_data = np.float32(np.random.rand(2, 100)) # 随机输入
y_data = np.dot([0.100, 0.200], x_data) + 0.300

# 构造一个线性模型
b = tf.Variable(tf.zeros([1]))
W = tf.Variable(tf.random_uniform([1, 2], -1.0, 1.0))
y = tf.matmul(W, x_data) + b

# 最小化方差
loss = tf.reduce_mean(tf.square(y - y_data))
optimizer = tf.train.GradientDescentOptimizer(0.5)
train = optimizer.minimize(loss)

# 初始化变量
init = tf.global_variables_initializer()

# 启动图 (graph)
sess = tf.Session()
sess.run(init)

# 拟合平面
for step in range(201):
sess.run(train)
if step % 20 == 0:
print(step, sess.run(W), sess.run(b))

运行后会输出结果。

MNIST机器学习入门

MNIST是一个入门级的计算机视觉数据集,包含各种手写图片以及标签。

下载数据集

直接下载http://yann.lecun.com/exdb/mnist/或者https://github.com/fengh16/TensorFlow-Learning/tree/master/002-MNIST-180420/MNIST_data里面的四个压缩包,然后复制到需要的路径下(如`MNIST_data`文件夹下)

或者下载运行https://github.com/fengh16/TensorFlow-Learning/tree/master/002-MNIST-180420/download.py

数据集介绍

下载下来的数据集被分成两部分:60000行的训练数据集(mnist.train)和10000行的测试数据集(mnist.test)。

每一个MNIST数据单元有两部分组成:一张包含手写数字的图片和一个对应的标签。我们把这些图片设为“xs”,把这些标签设为“ys”。训练数据集和测试数据集都包含xs和ys,比如训练数据集的图片是 mnist.train.images ,训练数据集的标签是 mnist.train.labels

每一张图片包含28X28个像素点。我们可以用一个数字数组来表示这张图片。我们把这个数组展开成一个向量,长度是 28x28 = 784。如何展开这个数组(数字间的顺序)不重要,只要保持各个图片采用相同的方式展开。从这个角度来看,MNIST数据集的图片就是在784维向量空间里面的点。因此,在MNIST训练数据集中,mnist.train.images 是一个形状为 [60000, 784] 的张量,第一个维度数字用来索引图片,第二个维度数字用来索引每张图片中的像素点。在此张量里的每一个元素,都表示某张图片里的某个像素的强度值,值介于0和1之间。相对应的MNIST数据集的标签是介于0到9的数字,用来描述给定图片里表示的数字。我们使标签数据是”one-hot vectors“。 一个one-hot向量除了某一位的数字是1以外其余各维度数字都是0。所以在此教程中,数字n将表示成一个只有在第n维度(从0开始)数字为1的10维向量。比如,标签0将表示成([1,0,0,0,0,0,0,0,0,0,0])。因此, mnist.train.labels 是一个 [60000, 10] 的数字矩阵。

Softmax回归介绍

softmax模型可以用来给不同的对象分配概率。即使在之后,我们训练更加精细的模型时,最后一步也需要用softmax来分配概率。

softmax回归softmax regression):

  • 为了得到一张给定图片属于某个特定数字类的证据(evidence),我们对图片像素值进行加权求和。如果这个像素具有很强的证据说明这张图片不属于该类,那么相应的权值为负数,相反如果这个像素拥有有利的证据支持这张图片属于这个类,那么权值是正数。
  • 下面的图片显示了一个模型学习到的图片上每个像素对于特定数字类的权值。红色代表负数权值,蓝色代表正数权值。
    权值示意
  • 我们也需要加入一个额外的偏置量(bias),因为输入往往会带有一些无关的干扰量。因此对于给定的输入图片 x 它代表的是数字 i 的证据可以表示为:$evidence_i = \sum\limits_j W_{i, j}x_j + b_j$ ,其中$W_{i,j}$表示输入图片像素位置为$j$的地方是$i$的权重,$b_i$表示数字i的偏置量,$j$代表给定图片x的像素引用于像素求和。之后用softmax函数可以把这些证据转换成概率 y:$y=softmax(evidence)$
  • 这里的softmax可以看成是一个激励(activation)函数或者链接(link)函数,把我们定义的线性函数的输出转换成我们想要的格式,也就是关于10个数字类的概率分布。因此,给定一张图片,它对于每一个数字的吻合度可以被softmax函数转换成为一个概率值。softmax函数可以使用正态分布的方法$normalize(exp(x))$
  • 这个回归模型可以用以下图解释:
    计算

python实现

为了用python实现高效的数值计算,我们通常会使用函数库,比如NumPy,会把类似矩阵乘法这样的复杂运算使用其他外部语言实现。不幸的是,从外部计算切换回Python的每一个操作,仍然是一个很大的开销。如果你用GPU来进行外部计算,这样的开销会更大。用分布式的计算方式,也会花费更多的资源用来传输数据。

TensorFlow也把复杂的计算放在python之外完成,但是为了避免前面说的那些开销,它做了进一步完善。Tensorflow不单独地运行单一的复杂计算,而是让我们可以先用图描述一系列可交互的计算操作,然后全部一起在Python之外运行。(这样类似的运行方式,可以在不少的机器学习库中看到。)

代码见 github或者注释简洁的

使用TensorFlow之前,首先导入它:

1
import tensorflow as tf

我们通过操作符号变量来描述这些可交互的操作单元,可以用下面的方式创建一个:

1
x = tf.placeholder(tf.float32, [None, 784])

x不是一个特定的值,而是一个占位符placeholder,我们在TensorFlow运行计算时输入这个值。我们希望能够输入任意数量的MNIST图像,每一张图展平成784维的向量。我们用2维的浮点数张量来表示这些图,这个张量的形状是[None,784 ]。(这里的None表示此张量的第一个维度可以是任何长度的。)

我们的模型也需要权重值和偏置量,当然我们可以把它们当做是另外的输入(使用占位符),但TensorFlow有一个更好的方法来表示它们:Variable 。 一个Variable代表一个可修改的张量,存在在TensorFlow的用于描述交互性操作的图中。它们可以用于计算输入值,也可以在计算中被修改。对于各种机器学习应用,一般都会有模型参数,可以用Variable表示。

1
2
W = tf.Variable(tf.zeros([784,10]))
b = tf.Variable(tf.zeros([10]))

我们赋予tf.Variable不同的初值来创建不同的Variable:在这里,我们都用全为零的张量来初始化Wb。因为我们要学习Wb的值,它们的初值可以随意设置。

注意,W的维度是[784,10],因为我们想要用784维的图片向量乘以它以得到一个10维的证据值向量,每一位对应不同数字类。b的形状是[10],所以我们可以直接把它加到输出上面。

现在,我们可以实现我们的模型啦。只需要一行代码!

1
y = tf.nn.softmax(tf.matmul(x,W) + b)

首先,我们用tf.matmul(X,W)表示x乘以W,对应之前等式里面的

,这里x是一个2维张量拥有多个输入。然后再加上b,把和输入到tf.nn.softmax函数里面。

至此,我们先用了几行简短的代码来设置变量,然后只用了一行代码来定义我们的模型。TensorFlow不仅仅可以使softmax回归模型计算变得特别简单,它也用这种非常灵活的方式来描述其他各种数值计算,从机器学习模型对物理学模拟仿真模型。一旦被定义好之后,我们的模型就可以在不同的设备上运行:计算机的CPU,GPU,甚至是手机!

训练模型

为了训练我们的模型,我们首先需要定义一个指标来评估这个模型是好的。其实,在机器学习,我们通常定义指标来表示一个模型是坏的,这个指标称为成本(cost)或损失(loss),然后尽量最小化这个指标。但是,这两种方式是相同的。

一个非常常见的,非常漂亮的成本函数是“交叉熵”(cross-entropy)。交叉熵产生于信息论里面的信息压缩编码技术,但是它后来演变成为从博弈论到机器学习等其他领域里的重要技术手段。它的定义如下:

y 是我们预测的概率分布, y’ 是实际的分布(我们输入的one-hot vector)。比较粗糙的理解是,交叉熵是用来衡量我们的预测用于描述真相的低效性。更详细的关于交叉熵的解释超出本教程的范畴,但是你很有必要好好理解它

为了计算交叉熵,我们首先需要添加一个新的占位符用于输入正确值:

1
y_ = tf.placeholder("float", [None,10])

然后我们可以用

计算交叉熵:

1
cross_entropy = -tf.reduce_sum(y_*tf.log(y))

首先,用 tf.log 计算 y 的每个元素的对数。接下来,我们把 y_ 的每一个元素和 tf.log(y) 的对应元素相乘。最后,用 tf.reduce_sum 计算张量的所有元素的总和。(注意,这里的交叉熵不仅仅用来衡量单一的一对预测和真实值,而是所有100幅图片的交叉熵的总和。对于100个数据点的预测表现比单一数据点的表现能更好地描述我们的模型的性能。

现在我们知道我们需要我们的模型做什么啦,用TensorFlow来训练它是非常容易的。因为TensorFlow拥有一张描述你各个计算单元的图,它可以自动地使用反向传播算法(backpropagation algorithm)来有效地确定你的变量是如何影响你想要最小化的那个成本值的。然后,TensorFlow会用你选择的优化算法来不断地修改变量以降低成本。

1
train_step = tf.train.GradientDescentOptimizer(0.01).minimize(cross_entropy)

在这里,我们要求TensorFlow用梯度下降算法(gradient descent algorithm)以0.01的学习速率最小化交叉熵。梯度下降算法(gradient descent algorithm)是一个简单的学习过程,TensorFlow只需将每个变量一点点地往使成本不断降低的方向移动。当然TensorFlow也提供了其他许多优化算法:只要简单地调整一行代码就可以使用其他的算法。

TensorFlow在这里实际上所做的是,它会在后台给描述你的计算的那张图里面增加一系列新的计算操作单元用于实现反向传播算法和梯度下降算法。然后,它返回给你的只是一个单一的操作,当运行这个操作时,它用梯度下降算法训练你的模型,微调你的变量,不断减少成本。

现在,我们已经设置好了我们的模型。在运行计算之前,我们需要添加一个操作来初始化我们创建的变量:

1
init = tf.initialize_all_variables()

现在我们可以在一个Session里面启动我们的模型,并且初始化变量:

1
2
sess = tf.Session()
sess.run(init)

然后开始训练模型,这里我们让模型循环训练1000次!

1
2
3
for i in range(1000):
batch_xs, batch_ys = mnist.train.next_batch(100)
sess.run(train_step, feed_dict={x: batch_xs, y_: batch_ys})

该循环的每个步骤中,我们都会随机抓取训练数据中的100个批处理数据点,然后我们用这些数据点作为参数替换之前的占位符来运行train_step

使用一小部分的随机数据来进行训练被称为随机训练(stochastic training)- 在这里更确切的说是随机梯度下降训练。在理想情况下,我们希望用我们所有的数据来进行每一步的训练,因为这能给我们更好的训练结果,但显然这需要很大的计算开销。所以,每一次训练我们可以使用不同的数据子集,这样做既可以减少计算开销,又可以最大化地学习到数据集的总体特性。

评估我们的模型

那么我们的模型性能如何呢?

首先让我们找出那些预测正确的标签。tf.argmax 是一个非常有用的函数,它能给出某个tensor对象在某一维上的其数据最大值所在的索引值。由于标签向量是由0,1组成,因此最大值1所在的索引位置就是类别标签,比如tf.argmax(y,1)返回的是模型对于任一输入x预测到的标签值,而 tf.argmax(y_,1) 代表正确的标签,我们可以用 tf.equal 来检测我们的预测是否真实标签匹配(索引位置一样表示匹配)。

1
correct_prediction = tf.equal(tf.argmax(y,1), tf.argmax(y_,1))

这行代码会给我们一组布尔值。为了确定正确预测项的比例,我们可以把布尔值转换成浮点数,然后取平均值。例如,[True, False, True, True] 会变成 [1,0,1,1] ,取平均值后得到 0.75.

1
accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))

最后,我们计算所学习到的模型在测试数据集上面的正确率。

1
print sess.run(accuracy, feed_dict={x: mnist.test.images, y_: mnist.test.labels})

这个最终结果值应该大约是91%。

MNIST优化

代码见Github

权重初始化:

为了创建这个模型,我们需要创建大量的权重和偏置项。这个模型中的权重在初始化时应该加入少量的噪声来打破对称性以及避免0梯度。由于我们使用的是ReLU神经元,因此比较好的做法是用一个较小的正数来初始化偏置项,以避免神经元节点输出恒为0的问题(dead neurons)。为了不在建立模型的时候反复做初始化操作,我们定义两个函数用于初始化。

1
2
3
4
5
6
7
8
def weight_variable(shape):
initial = tf.truncated_normal(shape, stddev=0.1)
return tf.Variable(initial)

def bias_variable(shape):
initial = tf.constant(0.1, shape=shape)
return tf.Variable(initial)

卷积和池化

TensorFlow在卷积和池化上有很强的灵活性。我们怎么处理边界?步长应该设多大?在这个实例里,我们会一直使用vanilla版本。我们的卷积使用1步长(stride size),0边距(padding size)的模板,保证输出和输入是同一个大小。我们的池化用简单传统的2x2大小的模板做max pooling。为了代码更简洁,我们把这部分抽象成一个函数。

1
2
3
4
5
6
7
def conv2d(x, W):
return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')

def max_pool_2x2(x):
return tf.nn.max_pool(x, ksize=[1, 2, 2, 1],
strides=[1, 2, 2, 1], padding='SAME')

第一层卷积

现在我们可以开始实现第一层了。它由一个卷积接一个max pooling完成。卷积在每个5x5的patch中算出32个特征。卷积的权重张量形状是[5, 5, 1, 32],前两个维度是patch的大小,接着是输入的通道数目,最后是输出的通道数目。 而对于每一个输出通道都有一个对应的偏置量。

1
2
3
W_conv1 = weight_variable([5, 5, 1, 32])
b_conv1 = bias_variable([32])

为了用这一层,我们把x变成一个4d向量,其第2、第3维对应图片的宽、高,最后一维代表图片的颜色通道数(因为是灰度图所以这里的通道数为1,如果是rgb彩色图,则为3)。

1
2
x_image = tf.reshape(x, [-1,28,28,1])

We then convolve x_image with the weight tensor, add the bias, apply the ReLU function, and finally max pool. 我们把x_image和权值向量进行卷积,加上偏置项,然后应用ReLU激活函数,最后进行max pooling。

1
2
3
h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1)
h_pool1 = max_pool_2x2(h_conv1)

第二层卷积

为了构建一个更深的网络,我们会把几个类似的层堆叠起来。第二层中,每个5x5的patch会得到64个特征。

1
2
3
4
5
6
W_conv2 = weight_variable([5, 5, 32, 64])
b_conv2 = bias_variable([64])

h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)
h_pool2 = max_pool_2x2(h_conv2)

密集连接层

现在,图片尺寸减小到7x7,我们加入一个有1024个神经元的全连接层,用于处理整个图片。我们把池化层输出的张量reshape成一些向量,乘上权重矩阵,加上偏置,然后对其使用ReLU。

1
2
3
4
5
6
W_fc1 = weight_variable([7 * 7 * 64, 1024])
b_fc1 = bias_variable([1024])

h_pool2_flat = tf.reshape(h_pool2, [-1, 7*7*64])
h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)

Dropout

为了减少过拟合,我们在输出层之前加入dropout。我们用一个placeholder来代表一个神经元的输出在dropout中保持不变的概率。这样我们可以在训练过程中启用dropout,在测试过程中关闭dropout。 TensorFlow的tf.nn.dropout操作除了可以屏蔽神经元的输出外,还会自动处理神经元输出值的scale。所以用dropout的时候可以不用考虑scale。

1
2
3
keep_prob = tf.placeholder("float")
h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

输出层

最后,我们添加一个softmax层,就像前面的单层softmax regression一样。

1
2
3
4
5
W_fc2 = weight_variable([1024, 10])
b_fc2 = bias_variable([10])

y_conv=tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)

训练和评估模型

这个模型的效果如何呢?

为了进行训练和评估,我们使用与之前简单的单层SoftMax神经网络模型几乎相同的一套代码,只是我们会用更加复杂的ADAM优化器来做梯度最速下降,在feed_dict中加入额外的参数keep_prob来控制dropout比例。然后每100次迭代输出一次日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cross_entropy = -tf.reduce_sum(y_*tf.log(y_conv))
train_step = tf.train.AdamOptimizer(1e-4).minimize(cross_entropy)
correct_prediction = tf.equal(tf.argmax(y_conv,1), tf.argmax(y_,1))
accuracy = tf.reduce_mean(tf.cast(correct_prediction, "float"))
sess.run(tf.initialize_all_variables())
for i in range(20000):
batch = mnist.train.next_batch(50)
if i%100 == 0:
train_accuracy = accuracy.eval(feed_dict={
x:batch[0], y_: batch[1], keep_prob: 1.0})
print "step %d, training accuracy %g"%(i, train_accuracy)
train_step.run(feed_dict={x: batch[0], y_: batch[1], keep_prob: 0.5})

print "test accuracy %g"%accuracy.eval(feed_dict={
x: mnist.test.images, y_: mnist.test.labels, keep_prob: 1.0})

以上代码,在最终测试集上的准确率大概是99.2%。

公众号推荐

推荐一波自己的公众号:五道口的程序狐

里面有一个聊天机器人,抚慰你的心灵

mp

如有需要,联系contact@fhao.top

xv6-boot部分

注:

这部分代码是使用typora写的,里面嵌套的一些内容可能在简书里面显示不出来,所以如有格式问题,建议下载typora复制粘贴查看。

另:这些内容基本都是从网上各个网站找的资料,印象比较深刻的有以下几个:

https://blog.csdn.net/vally1989/article/details/71796482
http://leenjewel.github.io/blog/2015/11/11/%5B%28xue-xi-xv6%29%5D-nei-he-gai-lan/
http://leenjewel.github.io/blog/2015/05/26/%5B%28xue-xi-xv6%29%5D-jia-zai-bing-yun-xing-nei-he/
https://blog.csdn.net/qq_25426415/article/details/54583835
https://blog.csdn.net/qq_25426415/article/details/54619488

感谢上述各个文章。

如有需要,联系contact@fhao.top

引导

BIOS需要执行系统的启动扇区上的东西,故首先需要将启动扇区复制到内存中,并且将其执行后进入操作系统的内核之中。

BIOS必须通过实模式启动,但当需要我们引入xv6自己的内核时,需要从实模式转换到保护模式(因为保护模式地址总线为32位,可访问地址位更多,可以利用4G内存)。

打开A20

A20简介:(来自https://en.wikipedia.org/wiki/A20_line)

  • A20总线,是x86体系的扩充电子线路之一。A20总线是专门用来转换地址总线的第二十一位。
  • 由于16位操作系统数据线宽与寄存器是16位,这样只能支持$2^{16}$即64K的内存,为了扩展,在8086CPU上将地址线宽度增加到20位,这样能寻址到$2^{20}$即1M(16位+4位的偏移)。一个 16 位的寄存器来表示“段基址”(CS、DS、SS、ES四个寄存器),具体的做法是先将 16 位的“段基址”左移 4 位,然后加上 16 位的“偏移量”最终得到 20 位的内存地址送入地址线。
  • 但是到了80286CPU时已经有了24根总线,为了能够访问1M之后的内容并且与之前的8086/8088兼容,故使用键盘控制器上剩余的一些输出线来管理A20,被称为A20 Gate,若A20 Gate被打开,则0x100000-0x10FFEF的内存就可以正常访问。否则,就将超过1M的内存取模再次回到了1M以内。
  • 保护模式有32位,肯定不能关闭A20,无法访问4G地址空间。只能访问1M,3M,5M等。

BIOS设置CS寄存器为0x0,ip寄存器为0x7c00,开始执行boot loader程序。

bootasm.S中:

1
.code16

一句说明现在是16位状态。之后一句:

1
.globl start

.globl指示告诉汇编器,start这个符号要被链接器用到,所以要在目标文件的符号表中标记它是一个全局符号。start就像C程序的main函数一样特殊,是整个程序的入口,链接器在链接时会查找目标文件中的start符号代表的地址,把它设置为整个程序的入口地址,所以每个汇编程序都要提供一个start符号并且用.globl声明。如果一个符号没有用.globl声明,就表示这个符号不会被链接器用到。

之后使用cli命令来禁用中断。

接下来四句:

1
2
3
4
xorw    %ax,%ax             # 使用异或操作,直接将%ax设0
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

将寄存器DS, ES, SS全部设置为0。其中取值会用到 %cs,读写数据会用到 %ds,读写栈会用到 %ss

但是实模式刚运行完,很多遗留下的一些寄存器的值需要我们整理下,所以我们需要“将 %ax 置零,然后把这个零值拷贝到三个段寄存器中”。

接下来,打开A20开关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
seta20.1:
inb $0x64,%al # 等待键盘控制器不忙碌
testb $0x2,%al
jnz seta20.1

movb $0xd1,%al # 把0xd1写入0x64
outb %al,$0x64

seta20.2:
inb $0x64,%al # 等待键盘控制器不忙碌
testb $0x2,%al
jnz seta20.2

movb $0xdf,%al # 把0xdf写入0x60
outb %al,$0x60

xv6打开A20采用的方法是键盘控制器法,通过输出命令到键盘控制器的IO接口,控制A20的开启与关闭。

与键盘控制器有关的IO接口是0x60和0x64。其中0x64起到一个状态控制的功能,0x60则是数据端口。首先需要检查键盘控制器是否忙碌,例如是否正有键盘输入等等。这个状态检查是通过读取0x64实现的。通过检查该状态数据的低第二个比特位是否为高,来判断是否忙碌。等到该比特位为低,就可以向0x64写命令了。

上述代码中

1
inb     $0x64,%al

之中的inb代表读入数据,即将0x64地址上的数据放入%al$代表常量。

1
testb   $0x2,%al

之中的testb将两个操作数进行逻辑与运算,并根据运算结果设置相关的标志位。但两个操作数不会被改变。运算结果在设置过相关标记位后会被丢弃。这一句将0x2%al中的内容求与,即得到了%al中内容的第二位,如果是高,则执行下一句

1
jnz     seta20.1

也就是再次执行seta20.1,重新检测。否则,进行下面的操作

1
2
movb    $0xd1,%al               # 把0xd1写入0x64
outb %al,$0x64

使用%al作为中介,将0xd1写入0x64。该命令用于指示即将向键盘控制器的输出端口写一个字节的数据。

下面的一段含义类似,只是最后将0xdf写入0x60。该数据代表开启A20。

进入保护模式

GDT介绍:

  • 内存地址是段地址+段内偏移。在实模式下,段地址就放在段寄存器中,而到了保护模式下,段寄存器中保存的不再是段地址,而是GDT(global descriptor table,全局描述符表)中的偏移量,所以进入保护模式时首先需要创建GDT。

  • GDT每个段对应一个表项,保存段基址、段大小、访问权限等,让CPU可以定位到具体的物理地址去,并且检查内存访问是否合法,因此被叫做保护模式。GDT整体储存很多段描述符,故类似一个数组。

  • 每个“段描述符”占 8 字节,如图:

    1

  • 三段基地址组合形成32位的基地址(8+8+16)

  • P:       0 本段不在内存中

  • DPL:     访问该段内存所需权限等级 00 — 11,0为最大权限级别

  • S:       1 代表数据段、代码段或堆栈段,0 代表系统段如中断门或调用门

  • E:       1 代表代码段,可执行标记,0 代表数据段

  • ED:      0 代表忽略特权级,1 代表遵守特权级

  • RW:      如果是数据段(E=0)则1 代表可写入,0 代表只读;
             如果是代码段(E=1)则1 代表可读取,0 代表不可读取

  • A:       1 表示该段内存访问过,0 表示没有被访问过

  • G:       1 表示 20 位段界限单位是 4KB,最大长度 4GB;
             0 表示 20 位段界限单位是 1 字节,最大长度 1MB

  • DB:      1 表示地址和操作数是 32 位,0 表示地址和操作数是 16 位

  • XX:      保留位永远是 0

  • AA:      给系统提供的保留位

  • 规定GDT的第一个表项必须是空的。

CPU 单独为我们准备了一个寄存器叫做 GDTR 用来保存我们 GDT 在内存中的位置和我们 GDT 的长度。GDTR 寄存器一共 48 位,其中高 32 位用来存储我们的 GDT 在内存中的位置,其余的低 16 位用来存我们的 GDT 有多少个段描述符。 16 位最大可以表示 65536 个数,这里我们把单位换成字节,而一个段描述符是 8 字节,所以 GDT 最多可以有 8192 个段描述符。不仅 CPU 用了一个单独的寄存器 GDTR 来存储我们的 GDT,而且还专门提供了一个指令用来让我们把 GDT 的地址和长度传给 GDTR 寄存器。lgdt代表加载全局描述符。

1
2
3
4
5
# 将实模式转换为保护模式,使用一个引导GDT让虚拟地址直接映射到真实地址去,过渡期内这个GDT的内存映射不会改变。
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

其中

1
lgdt    gdtdesc

之中用到的gdtdesc的定义如下:

1
2
3
gdtdesc:
.word (gdtdesc - gdt - 1) # sizeof(gdt) - 1, 16位
.long gdt # address gdt,32位

加起来48位。

其中gdt定义如下:

1
2
3
4
5
6
# Bootstrap GDT
.p2align 2 # 强制进行4比特对齐
gdt:
SEG_NULLASM # 空
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # 代码段
SEG_ASM(STA_W, 0x0, 0xffffffff) # 数据(堆栈)段

宏定义在asm.h中,将他们再替换回来,得到如下:

1
2
3
4
5
6
7
gdt:
.word 0, 0;
.byte 0, 0, 0, 0 # 空
.word 0xffff, 0x0000;
.byte 0x00, 0x9a, 0xcf, 0x00 # 代码段
.word 0xffff, 0x0000;
.byte 0x00, 0x92, 0xcf, 0x00 # 数据段

代码段空间如下:

2

数据段空间如下:

3

首先说说这两个内存段的共同点,DB = 1,G = 1,基地址都是 0x00000000,内存分段长度都是 0xfffff,这说明他们都是用于 32 位寻址,所使用的内存是从 0 开始到 4GB 结束(全部内存)。这里是这么算出来的,段长度是 0xfffff = $2^{20}$,G = 1 表示段界限单位是 4k,所以 4k * $2^{20}$ = 4GB。

再说说他们的不同点,代码段的 E = 1 而数据段的 E = 0 这表明了他们的身份,身份不同 RW 的值虽然相同,但代表的意义也就不相同了,代码段的 RW = 1 代表可读取,数据段的 RW = 1 表示可读可写。这也和我们上面解释的保护模式所能够达到的目的相吻合。

代码段和数据段都启用了从 0 到 4GB 的全部内存寻址。其实这种内存规划方法叫做“平坦内存模型”,即便是 Linux 也是用的这样的方式规划内存的,并没有做到真正的“分段”。这是因为 x86 的分页机制是基于分段的,Linux 选用了更先进的分页机制来管理内存,所以在分段这里只是走一个必要的形式罢了。

后面的几句:

1
2
3
movl    %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

因为我们无法直接操作 cr0,所以我们首先要用一个通用寄存器来保存当前 cr0 寄存器的值,这里第一行就是用通用寄存器 eax 来保存 cr0 寄存器的值;然后 CR0_PE 这个宏的定义在 mmu.h 文件中,是个数值 0x00000001,将这个数值与 eax 中的 cr0 寄存器的值做“或”运算后,就保证将 cr0 的第 0 位设置成了 1 即 PE = 1 保证打开了保护模式的开关。而 cr0 的第 31 位 PG = 0 表示我们只使用分段式,不使用分页,这时再将新的计算后的 eax 寄存器中的值写回到 cr0 寄存器中就完成了到保护模式的切换。

之后进入32位:

1
ljmp    $(SEG_KCODE<<3), $start32

ljmp的语法:ljmp segment offset,它会同时设置段寄存器和段内偏移(EIP寄存器),而在32位模式下,段寄存器保存的是GDT里的偏移量,所以这里段寄存器的值是SEG_KCODE<<3,即数字8(SEG_KCODE=1),因为GDT的第一个表项必须为空,所以代码段占据第二个表项。

这个跳转语句的两个参数就是“基地址” + “偏移量”的方式告诉 CPU 要跳转到内存的什么位置去继续执行指令。

进入32位模式之后,设置各个段寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.code32  # 现在是32位
start32:
# 设置保护模式下的一堆寄存器的值
# 这时候已经在保护模式下了
# 数据段在 GDT 中的下标是 2,所以这里数据段的段选择子是 2 << 3 = 0000 0000 0001 0000
# 这 16 位的段选择子中的前 13 位是 GDT 段表下标,这里前 13 位的值是 2 代表选择了数据段
# 这里将 3 个数据段寄存器都赋值成数据段段选择子的值
movw $(SEG_KDATA<<3), %ax # 先借助中介设置DS,ES,SS这些作为数据项的值为(SEG_KDATA<<3)
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %ss # -> SS: Stack Segment
movw $0, %ax # 剩下的寄存器设置为0
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS

上述代码中,数据段占用GDT第三个表项,ES, SS等同于数据段,其他不用的段置零。

1
2
3
# Set up the stack pointer and call into C.
movl $start, %esp # 栈顶被放置在 0x7C00 处,即 $start
call bootmain

现在准备执行.c代码,首先要设置stack,movl $start, %esp,即给esp寄存器设置,startbootasm.S最开始的一个label,但是为何要将esp设置为start呢,这是因为start是代码的开始位置,代码是向高内存地址处放置的,而stack是向低内存地址增长的,所以这样设置之后,代码和栈向相反方向扩张,两者互不干涉。BIOS 会将引导扇区的引导程序加载到 0x7C00 处并引导 CPU 从此处开始运行,故栈顶即被设置在了和引导程序一致的内存位置上。我们知道栈是自栈顶开始向下增长的,所以这里栈会逐渐远离引导程序,所以这里这样安置栈顶的位置并无什么问题。另外,当call bootmain的时候,首先要做的就是movl %esp %ebp,即将esp的值设置给ebp,这样ebp保存的就是start的值,也就是函数栈中的第一个函数是start,这符合逻辑。

现在不应该执行接下来的这些语句,但是如果异常的话,bootmain返回并且执行下面的操作:

1
2
3
4
5
6
7
8
9
  # If bootmain returns (it shouldn't), trigger a Bochs
# breakpoint if running under Bochs, then loop.
movw $0x8a00, %ax # 0x8a00 -> port 0x8a00
movw %ax, %dx
outw %ax, %dx
movw $0x8ae0, %ax # 0x8ae0 -> port 0x8a00
outw %ax, %dx
spin:
jmp spin

进入.c的内容

由于之前曾经使用了call bootmain,所以现在会执行bootmain.c里面的bootmain()函数,该函数如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#define SECTSIZE  512 // 表示磁盘扇区大小(字节数)

void bootmain(void)
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;

elf = (struct elfhdr*)0x10000; // 表示内核开始的地址空间位置

// 读取一个页的数据(4k)到内存中elf对应的位置
readseg((uchar*)elf, 4096, 0);

// 判断是否是一个正确的elf格式可执行文件
if(elf->magic != ELF_MAGIC)
return; // 现在返回会回到上面所说的错误处理部分

// 加载每一个程序段(忽略ph标记)
// 找到内核 ELF 文件的程序头表
ph = (struct proghdr*)((uchar*)elf + elf->phoff);
// 内核 ELF 文件程序头表的结束位置
eph = ph + elf->phnum;
// 开始将内核 ELF 文件程序头表载入内存
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off);
// 如果内存大小大于文件大小,用 0 补齐内存空位
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
}

// 调用elf头所规定的起始位置
// 不会返回
entry = (void(*)(void))(elf->entry);
entry();
}

上述过程其实只是加载了内核(把内核从磁盘上搬到了内存中),然后执行内核中的程序。其中反复被用到的readseg()函数的定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 将'count'字节的位于距离内核'offset'的代码写入内存地址'pa'中
// 可能会比要求的多拷贝一部分
void
readseg(uchar* pa, uint count, uint offset)
{
// 表示最后的边界
uchar* epa;
// 从开始写入,最后写入的位置要小于epa
epa = pa + count;
// 多出来的忽略掉(因为磁盘扇区大小)
pa -= offset % SECTSIZE;
// 将内容翻译到扇区(sector)中,内核从下标为1处开始(因为引导区已经占了第一个即下标为0的位置)
offset = (offset / SECTSIZE) + 1;
// 如果比较慢就每次多读一些扇区的东西
// (因为每次都是读一个扇区)将多读一部分,但是没关系,因为我们按照升序加载
for(; pa < epa; pa += SECTSIZE, offset++)
readsect(pa, offset);
}

要知道内核二进制文件到底是如何链接的,首先需要了解:

  • ld表示GCC连接脚本,链接的过程实际上是为了解决多个文件之间符号引用的问题(symbol resolution)。编译时编译器只对单个文件进行处理,如果该文件里面需要引用到其他文件中的符号(例如全局变量或者函数),那么这时在这个文件中该符号的地址是没法确定的,只能等链接器把所有的目标文件连接到一起的时候才能确定最终的地址。

打开kernel.ld文件,可以发现,内核入口地址为标号_start地址

1
2
3
OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)

这个_start的地址其实是在内核代码文件entry.S是内核入口虚拟地址entry对应的物理地址,由于此时虚拟地址直接等于物理地址,_start将作为ELF文件头中的elf->entry的值。

内核文件中加载地址和链接地址是不一样的,链接地址是程序中所有标号、各种符号的地址,一般也就是内存中的虚拟地址,但是加载地址是为了在生成ELF文件时,指定各个段应该为加载的物理地址,这个地址作为每个段的p->paddr的值。

上面readseg函数之中的readsect函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 从offset位置读取单个扇区到dst
void
readsect(void *dst, uint offset)
{
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // 命令0x20也就是读取扇区的命令

// 读取数据
waitdisk();
insl(0x1F0, dst, SECTSIZE/4);
}

里面一堆神奇(莫名其妙)的outb函数就是一个写入一比特的数据(一个uchar)。这样设置的原因如下:

  • IDE 标准定义了 8 个寄存器来操作硬盘。PC 体系结构将第一个硬盘控制器映射到端口 1F0-1F7 处,而第二个硬盘控制器则被映射到端口 170-177 处。这几个寄存器的描述如下(以第一个控制器为例):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    1F0        - 数据寄存器。读写数据都必须通过这个寄存器
    1F1 - 错误寄存器,每一位代表一类错误。全零表示操作成功。
    1F2 - 扇区计数。这里面存放你要操作的扇区数量
    1F3 - 扇区LBA地址的0-7位
    1F4 - 扇区LBA地址的8-15位
    1F5 - 扇区LBA地址的16-23位
    1F6 (低4位) - 扇区LBA地址的24-27位
    1F6 (第4位) - 0表示选择主盘,1表示选择从盘
    1F6 (5-7位) - 必须为1
    1F7 (写) - 命令寄存器
    1F7 (读) - 状态寄存器
    bit 7 = 1 控制器忙
    bit 6 = 1 驱动器就绪
    bit 5 = 1 设备错误
    bit 4 N/A
    bit 3 = 1 扇区缓冲区错误
    bit 2 = 1 磁盘已被读校验
    bit 1 N/A
    bit 0 = 1 上一次命令执行失败

    上面代码中已经把这些内容翻译到注释里面去了。

xv6内核二进制文件是elf格式,在维基上有这个格式的具体说明。前两部分是ELF头部和程序头表,在elf.h文件里面有具体的声明,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#define ELF_MAGIC 0x464C457FU  // "\x7FELF" in little endian

// ELF 文件格式的头部
struct elfhdr {
uint magic; // 4 字节,为 0x464C457FU(大端模式)或 0x7felf(小端模式)
// 表明该文件是个 ELF 格式文件
uchar elf[12]; // 12 字节,每字节对应意义如下:
// 0 : 1 = 32 位程序;2 = 64 位程序
// 1 : 数据编码方式,0 = 无效;1 = 小端模式;2 = 大端模式
// 2 : 只是版本,固定为 0x1
// 3 : 目标操作系统架构
// 4 : 目标操作系统版本
// 5 ~ 11 : 固定为 0
ushort type; // 2 字节,表明该文件类型,意义如下:
// 0x0 : 未知目标文件格式
// 0x1 : 可重定位文件
// 0x2 : 可执行文件
// 0x3 : 共享目标文件
// 0x4 : 转储文件
// 0xff00 : 特定处理器文件
// 0xffff : 特定处理器文件
ushort machine; // 2 字节,表明运行该程序需要的计算机体系架构,
// 这里我们只需要知道 0x0 为未指定;0x3 为 x86 架构
uint version; // 4 字节,表示该文件的版本号
uint entry; // 4 字节,该文件的入口地址,没有入口(非可执行文件)则为 0
uint phoff; // 4 字节,表示该文件的“程序头部表”相对于文件的位置,单位是字节
uint shoff; // 4 字节,表示该文件的“节区头部表”相对于文件的位置,单位是字节
uint flags; // 4 字节,特定处理器标志
ushort ehsize; // 2 字节,ELF文件头部的大小,单位是字节
ushort phentsize; // 2 字节,表示程序头部表中一个入口的大小,单位是字节
ushort phnum; // 2 字节,表示程序头部表的入口个数,
// phnum * phentsize = 程序头部表大小(单位是字节)
ushort shentsize; // 2 字节,节区头部表入口大小,单位是字节
ushort shnum; // 2 字节,节区头部表入口个数,
// shnum * shentsize = 节区头部表大小(单位是字节)
ushort shstrndx; // 2 字节,表示字符表相关入口的节区头部表索引
};

// 程序头表
struct proghdr {
uint type; // 4 字节, 段类型
// 1 PT_LOAD : 可载入的段
// 2 PT_DYNAMIC : 动态链接信息
// 3 PT_INTERP : 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小
// 4 PT_NOTE : 指定辅助信息的位置和大小
// 5 PT_SHLIB : 保留类型,但具有未指定的语义
// 6 PT_PHDR : 指定程序头表在文件及程序内存映像中的位置和大小
// 7 PT_TLS : 指定线程局部存储模板
uint off; // 4 字节, 段的第一个字节在文件中的偏移
uint vaddr; // 4 字节, 段的第一个字节在内存中的虚拟地址
uint paddr; // 4 字节, 段的第一个字节在内存中的物理地址(适用于物理内存定位型的系统)
uint filesz; // 4 字节, 段在文件中的长度
uint memsz; // 4 字节, 段在内存中的长度
uint flags; // 4 字节, 段标志
// 1 : 可执行
// 2 : 可写入
// 4 : 可读取
uint align; // 4 字节, 段在文件及内存中如何对齐
};

现在打开xv6.img文件,读取第二个扇区位置(第一个扇区即第一个512字节是bootblock)的开头数据并且与上面内容相对应,得到如下结果:

字段名称 大小 数值 意义
magic 4字节 7F 45 4C 46 ELF 格式文件
elf 12字节 01 01 01 00 00 00 00 00 00 00 00 00 32 位小端模式,目标操作系统为 System V
type 2字节 02 00 可执行文件
machine 2字节 03 00 指定计算机体系架构为 x86
version 4字节 01 00 00 00 版本号为 1
entry 4字节 0C 00 10 00 该可执行文件入口地址
phoff 4字节 34 00 00 00 程序头表相对于文件的起始位置是 52 字节
shoff 4字节 00 F6 01 00 节区头表相对于文件的起始位置是 128512 字节
flags 4字节 00 00 00 00 无特定处理器标志
ehsize 2字节 34 00 ELF 头大小为 52 字节
phentsize 2字节 20 00 程序头表一个入口的大小是 32 字节
phnum 2字节 02 00 程序头表入口个数是 2 个
shentsize 2字节 28 00 节区头表入口大小是 40 字节
shnum 2字节 12 00 节区头表入口个数是 18 个
shstrndx 2字节 0F 00 字符表入口在节区头表的索引是 15

之后是程序头表,一共两个,每个32字节,如下:

  • 程序头表 1
字段名称 大小 数值 意义
type 4字节 01 00 00 00 可载入的段
off 4字节 00 10 00 00 段在文件中的偏移是 4096 字节
vaddr 4字节 00 00 10 80 段在内存中的虚拟地址
paddr 4字节 00 10 00 00 同段在文件中的偏移量
filesz 4字节 96 B5 00 00 段在文件中的大小是 46486 字节
memsz 4字节 FC 26 01 00 段在内存中的大小是 75516 字节
flags 4字节 07 00 00 00 段的权限是可写、可读、可执行
align 4字节 00 10 00 00 段的对齐方式是 4096 字节,即4kb
  • 程序头表 2
字段名称 大小 数值 意义
type 4字节 51 E5 74 64 PT_GNU_STACK 段
off 4字节 00 00 00 00 段在文件中的偏移是 0 字节
vaddr 4字节 00 00 00 00 段在内存中的虚拟地址
paddr 4字节 00 00 00 00 同段在文件中的偏移量
filesz 4字节 00 00 00 00 段在文件中的大小是 0 字节
memsz 4字节 00 00 00 00 段在内存中的大小是 0 字节
flags 4字节 07 00 00 00 段的权限是可写、可读、可执行
align 4字节 04 00 00 00 段的对齐方式是 4 字节

因此只有第一个程序头表是需要加载的。这个程序头表指出的加载位置 0x80100000 和内核程序的代码段 .text 的位置是一样的。

而要加载的段是 .text .rodata .stab .stabstr .data .bss ,这些段在内存中的大小总和是0x008111 + 0x000672 + 0x000001 + 0x000001 + 0x002596 + 0x00715c = 73335 即 0x11e77 按照对齐要求 0x1000 对齐后为 755160x000126fc(注意大小端转换,FC 26 01 00 是按照小端排列的,转换成正常的十六进制数为 0x000126fc)和 ELF 程序头表中的内存大小信息一致。

我们再算算这些段在文件中的大小,由于这些段在文件中是顺序排列的,所以用 .bss段 的文件偏移量减去 .text段 的文件偏移量 0x00c596 - 0x001000 = 46486 这也是和 ELF 程序头表中段在文件中大小的信息一致。

进入内核

kernel.ld文件中,找到:

1
2
3
4
ENTRY(_start)        # 内核的代码为段执行入口:_start
. = 0x80100000 # 内核的起始虚拟地址位置为:0x80100000
.text : AT(0x100000) # 内核代码段的内存装载地址为:0x100000
. = ALIGN(0x1000) # 内核代码段保证 4KB 对齐

因此就可以让系统正确加载内核。

上面提到的_startentry.S这个汇编文件里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 多系统加载头。运行多系统加载程序的部分。
.p2align 2
.text
.globl multiboot_header
multiboot_header:
#define magic 0x1badb002
#define flags 0
.long magic
.long flags
.long (-magic-flags)

# 按照约定,_start符号指出入口。因为我们还没有设置虚拟内存,因此我们的起始点是'entry'的物理地址
.globl _start
_start = V2P_WO(entry)

multiboot_header 这部分是为了方便通过GNU GRUB来引导 xv6 系统的。而V2P_WO 的定义在 memlayout.h 文件中:

1
2
#define V2P_WO(x) ((x) - KERNBASE)    // V2P里面将x转换成了uint,这里没有转换。
// 这个名字其实就是 Virtual to Physical WITHOUT

它的作用是将内存虚拟地址转换成物理地址。里面KERNBASE定义为:

1
#define KERNBASE 0x80000000         // First kernel virtual address

entry部分如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 进入xv6的启动程序,这时候还没有开启分页
.globl entry
entry:
# 打开4M页面大小扩展
movl %cr4, %eax # 把cr4寄存器拿出来,与CR4_PSE取或来设置这一位为1以打开4M
orl $(CR4_PSE), %eax
movl %eax, %cr4
# 设置内存页表
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
# 打开分页
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0

# 设置栈指针
movl $(stack + KSTACKSIZE), %esp

# 跳转到main()函数,并且转移到高地址执行。
# 因为汇编程序产生了一个及其相关的直接跳转的指令,所以需要进行间接的调用。
mov $main, %eax
jmp *%eax

.comm stack, KSTACKSIZE
开启 4MB 内存分页支持

这是通过设置寄存器 cr4PSE 位来完成的。cr4 寄存器是个 32 位的寄存器目前只用到低 21 位,每一位的至位都控制这一些功能的状态,所以 cr4 寄存器又叫做控制寄存器。

PSE 位是 cr4 控制寄存器的第 5 位,当该位置为 1 时表示内存页大小为 4MB,当置为 0 时表示内存页大小为 4KB。

建立内存页表

和分段机制一样,分页机制同样是管理内存的一种方式,只不过这种方式相对于分段式来说更为先进,也是目前主流的现代操作系统所使用的内存管理方式。

通过分页将虚拟地址转换为物理地址这项工作是由 MMU(内存管理单元)负责的,以 x86 32 位架构来说它支持两级分页(Pentium Pro下分页可以是三级),这也是由 MMU 决定的。同时 x86 架构支持 4KB、2MB 和 4MB 单位页面大小的分页。当然无论以多少级进行分页,分页机制的原理是相通的,我们就以两级分页来说。

以 32 位系统为例,其内存虚拟地址是 32 位的,这里我们先假设页面大小是 4KB ,此时 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。

在 4KB 页面大小情况下 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。

cr3 寄存器中保存着一级页表所在的内存物理地址,当给出一个虚拟地址后,根据 cr3 的地址首先找到一级页表在内存上的存放位置。上面我们说到虚拟地址的高 10 位为一级页表的索引,所以 2^10 = 1024 即一级页表一共有 1024 个元素,通过虚拟地址高 10 位的索引我们找到其中一个元素,而这个元素的值指向二级页表在内存中的物理地址。

同理,虚拟地址中紧挨着高 10 位后面的 10 位是二级页表索引,因此二级页表也有 1024 个元素,通过虚拟地址的二级页表索引找到其中的一个元素,该元素指向内存分页中的一个页的地址。

通过二级页表我们现在找到了内存上的一页物理页。根据现在的设定,一物理页的大小是 4KB,在虚拟地址上的低 12 位索引找到想要的数据(2^12 = 4096 = 4KB)。

而根据二级页表和每页内存的大小我们也不难算出:1024个一级页表项 x 1024个二级页表项 x 4KB页面大小 = 4GB,正好是 32 位系统的最大内存寻址。

这里我们再额外算一笔账,二级页表中每个表项占 32 位,所以一个一级页表的总体积是 4byte x 1024 = 4KB,而每个一级页表项都对应一个二级页表,所以全部二级页表的总体积是 4KB x 1024 = 4MB,即二级页表分页机制自身内存占用也要约 4MB 外加 4KB。

我们还要额外提一下页表项。上面刚说过每个页表项占 32 位,它也是分两个部分:高 20 位是基地址,低 12 位是控制标记位。所以当我们通过一级页表索引在一级页表中查找时是这样的:一级页表项地址 = cr3寄存器高20位 + ( 10位一级页表索引 << 2 );通过二级页表索引在二级页表中查找时是这样的:二级页表项地址 = 一级页表项高20位 + ( 10位二级页表索引 << 2 )。

  • 注:页表项地址 = 基地址 + ( 索引 x 页表项大小 ) = 基地址 + ( 索引 x 4字节 ) = 基地址 + ( 索引 << 2 )

最后我们再看看页表项低 12 位控制位都代表什么意义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ 11 + 10 + 9 + 8 +  7 + 6 + 5 +  4  +  3  + 2  + 1  + 0 +
| Avail | G | PS | D | A | PCD | PWT | US | RW | P |
+--------------------------------------------------------+
| 000 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
+--------------------------------------------------------+

P : 0 表示此页不在物理内存中,1 表示此页在物理内存中
RW : 0 表示只读,1 表示可读可写(要配合 US 位)
US : 0 表示特权级页面,1 表示普通权限页面
PWT : 1 表示写这个页面时直接写入内存,0 表示先写到缓存中
PCD : 1 表示该页禁用缓存机制,0 表示启用缓存
A : 当该页被初始化时为 0,一但进行过读/写则置为 1
D : 脏页标记(这里就不做具体介绍了)
PS : 0 表示页面大小为 4KB,1 表示页面大小为 4MB
G : 1 表示页面为共享页面(这里就不做具体介绍了)
Avail : 3 位保留位

然后我们再说回 xv6。

到目前为止我们知道 xv6 开启了 4MB 内存页大小,在 x86 架构下当通过 cr4 控制寄存器的 PSE 位打开了 4MB 分页后 MMU 内存管理单元的分页机制就会从二级分页简化为一级分页。

即虚拟地址的高 10 位仍然是一级页表项索引,但是后面的 22 位则全部变为页内偏移量(因为一页有 2^22 = 4MB 大小)。

我们来看看这个一级页表的结构:

1
2
3
# Set page directory
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3

通过代码我们知道页表地址是存在一个叫做 entrypgdir 的变量中了,通过文本搜索可以在 main.c 文件的最后找到这个变量,我们看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Boot page table used in entry.S and entryother.S.
// Page directories (and page tables), must start on a page boundary,
// hence the "__aligned__" attribute.
// Use PTE_PS in page directory entry to enable 4Mbyte pages.
__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};

//PAGEBREAK!
// Blank page.

将这些宏定义都转义过来我们看看这个页表的样子:

1
2
3
4
unsigned int entrypgdir[1024] = {
[0] = 0 | 0x001 | 0x002 | 0x080, // 0x083 = 0000 1000 0011
[0x80000000 >> 22] = 0 | 0x001 | 0x002 | 0x080 // 0x083
};

可见这个页表非常简单,只有两个页表项 0x000000000x80000000,而且两个页表项索引的内存物理地址都是 0 ~ 4MB,其他页表项全部未作设置。而且通过这两个页表项的值也可以清楚的看出这段基地址为 0 的 4MB 大小的内存页还是特级权限内存页(低 12 位的控制位对应关系已经附在上面解释控制位的示意图里了)。它仅仅只是一个临时页表,它只保证内核在即将打开内存分页支持后内核可以正常执行接下来的代码,而内核在紧接着执行 main 方法时会马上再次重新分配新的页表,而且最终的页表是 4KB 单位页面的精细页表。

开启内存分页机制

cr0寄存器的第 31 位置为 1 就可以启动内存分页。

这里还要在提一句,至此我们开启了内存分页机制,接下来内核的代码执行和数据读写都要经过 MMU 通过分页机制对内核指令地址和数据地址的转换,那么目前的页表是如何保证在转换后的物理地址是正确的,如何保证内核可以继续正常运行的呢?

我们来分析一下。

根据 kernel.ld 链接器脚本的设定,内核的虚拟地址起始于 0x80100000 即内核代码段的起始处,而内核的代码段被放置在内存物理地址 0x100000 处。我们刚刚看到目前的临时页表将虚拟地址 0x80000000 映射到物理内存的 0x0 处,所以我们来尝试用刚刚了解到的内存分页机制来解析一下 0x80100000 虚拟地址最后转换成物理地址是多少。

1
2
3
4
5
6
7
8
9
10
11
0x80100000 = 1000 0000 00|01 0000 0000 0000 0000 0000

0x8010000010 位 = 1000 0000 00 = 512

0x8010000022 位 = 01 0000 0000 0000 0000 0000 = 1048576

索引 512 对应 entrypgdir[ 0x80000000 >> 22 ] 即基地址为 0x0

换算的物理地址 = 0 + 1048576 = 1048576 = 0x100000

即内核代码段所在内存物理地址 0x100000

说白了就是通过页表将内核高端的虚拟地址直接映射到内核真实所在的低端物理内存位置。

这样虽然保证了在分页机制开启的情况下内核也可以正常运行,但也限制了内核最多只能使用 4MB 的内存,不过对于现在的内核来说 4MB 足够了。

设置内核栈顶位置并跳转到 main 执行

这里通过 .comm 在内核 bbs 段开辟了一段 KSTACKSIZE = 4096 = 4KB 大小的内核栈并将栈顶设置为这段数据区域的末尾处(栈是自上而下的嘛),最后通过 jmp 语句跳转到 main 方法处继续执行。

其他内核的运行

另外一个启动xv6内核的代码是entryother.S,将每一个非引导CPU从16位切换至32位,然后进入mpenter()函数中,最终开始CPU协同调度。其中很大一部分都与bootloader.S相同。

具体工作在main()函数中被调用,因此在下一部分介绍。

进入main()函数

现在还需要将CPU的第二个核启动,这是在main()函数中调用的mpinit()函数负责。

mpinit()函数在mp.c文件中。具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void
mpinit(void)
{
uchar *p, *e;
struct mp *mp;
struct mpconf *conf;
struct mpproc *proc;
struct mpioapic *ioapic;

bcpu = &cpus[0];
if((conf = mpconfig(&mp)) == 0)
return;
ismp = 1;
lapic = (uint*)conf->lapicaddr;
for(p=(uchar*)(conf+1), e=(uchar*)conf+conf->length; p<e; ){
switch(*p){
case MPPROC:
proc = (struct mpproc*)p;
if(ncpu != proc->apicid){
cprintf("mpinit: ncpu=%d apicid=%d\n", ncpu, proc->apicid);
ismp = 0;
}
if(proc->flags & MPBOOT)
bcpu = &cpus[ncpu];
cpus[ncpu].id = ncpu;
ncpu++;
p += sizeof(struct mpproc);
continue;
case MPIOAPIC:
ioapic = (struct mpioapic*)p;
ioapicid = ioapic->apicno;
p += sizeof(struct mpioapic);
continue;
case MPBUS:
case MPIOINTR:
case MPLINTR:
p += 8;
continue;
default:
cprintf("mpinit: unknown config type %x\n", *p);
ismp = 0;
}
}
if(!ismp){
// Didn't like what we found; fall back to no MP.
ncpu = 1;
lapic = 0;
ioapicid = 0;
return;
}

if(mp->imcrp){
// Bochs doesn't support IMCR, so this doesn't run on Bochs.
// But it would on real hardware.
outb(0x22, 0x70); // Select IMCR
outb(0x23, inb(0x23) | 1); // Mask external interrupts.
}
}

其中结构体cpu的定义如下(在proc.h中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 每个CPU的状态
struct cpu {
uchar id; // 本地 APIC(Advanced Programmable Interrupt Controller) ID, cpus[]数组的索引
struct context *scheduler; // 通过这里进入调用计划表
struct taskstate ts; // 寻找中断栈
struct segdesc gdt[NSEGS]; // 全局描述表
volatile uint started; // 记录启动与否
int ncli; // 终端层数
int intena; // 在pushcli之前有没有启用中断

// 本地的CPU存储变量
struct cpu *cpu;
struct proc *proc; // 正在运行的进程
};

并且定义:

1
2
3
4
5
extern struct cpu cpus[NCPU];
extern int ncpu;

extern struct cpu *cpu asm("%gs:0"); // &cpus[cpunum()]
extern struct proc *proc asm("%gs:4"); // cpus[cpunum()].proc

后面两句其实就是通过指针引用到当前的cpu,我觉得应该是相当于给这两个变量赋值?

因为每个CPU都有独立的gdt,所以在设置gdt的时候可以这样设置(见vm.c中的seginit()函数):

1
2
3
4
5
// CPU和进程的映射,对于每一个CPU都是私有的
c->gdt[SEG_KCPU] = SEG(STA_W, &c->cpu, 8, 0);

lgdt(c->gdt, sizeof(c->gdt));
loadgs(SEG_KCPU << 3);

通过提前设置gs寄存器和gdt的描述符,便能直接通过cpuproc变量访问当前CPU的结构体和运行进程。

main()函数调用的mpinit()初始化了cpus结构体数组,并确定了lapic地址和ioapicid

1
2
3
uchar ioapicid;
volatile uint *lapic; // Initialized in mp.c
volatile struct ioapic *ioapic; // ioapic在地址空间中有固定地址,这里写出来只是为了对比123

具体的初始过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
lapic = (uint*)conf->lapicaddr;
for(p=(uchar*)(conf+1), e=(uchar*)conf+conf->length; p<e; ){
switch(*p){
case MPPROC:
proc = (struct mpproc*)p;
if(ncpu < NCPU) {
cpus[ncpu].apicid = proc->apicid; // apicid may differ from ncpu
ncpu++;
}
p += sizeof(struct mpproc);
continue;
case MPIOAPIC:
ioapic = (struct mpioapic*)p;
ioapicid = ioapic->apicno;
p += sizeof(struct mpioapic);
continue;
case MPBUS:
case MPIOINTR:
case MPLINTR:
p += 8;
continue;
default:
ismp = 0;
break;
}
}1234567891011121314151617181920212223242526

通过在地址空间中找到mp的数据结构后,得到每个CPU的apicid和CPU数量。

通过mpinit()函数,有关的cpus结构体也初始完成了,接下来在main()函数中调用startothers函数启动其他CPU。

startothers让其他CPU执行名为entryother.S对应的代码.

entryother.S是作为独立的二进制文件与内核二进制文件一起组成整体的ELF文件,通过在LD链接器中-b参数来整合一个独立的二进制文件,在内核中通过_binary_entryother_start_binary_entryother_size来引用,具体的makefile如下:

1
$(LD) $(LDFLAGS) -T kernel.ld -o kernelmemfs entry.o  $(MEMFSOBJS) -b binary initcode entryother fs.img

entryothers在生成二进制文件时指定入口点为start以及加载地址和链接地址都为0x7000

1
2
3
4
5
entryother: entryother.S
$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c entryother.S
$(LD) $(LDFLAGS) -N -e start -Ttext 0x7000 -o bootblockother.o entryother.o
$(OBJCOPY) -S -O binary -j .text bootblockother.o entryother
$(OBJDUMP) -S bootblockother.o > entryother.asm

startothers中,首先将entryothers移动到物理地址0x7000处使其能正常运行,在这里需要注意的是此时entryothers相当于CPU刚上电的情形,因为这是其他CPU最初运行的内核代码,所以没有开启保护模式和分页机制,entryothers将页表设置为entrypgdir,在设置页表前,虚拟地址等于物理地址

1
2
3
4
5
// Write entry code to unused memory at 0x7000.
// The linker has placed the image of entryother.S in
// _binary_entryother_start.
code = P2V(0x7000);
memmove(code, _binary_entryother_start, (uint)_binary_entryother_size);

然后循环逐个开启每个CPU让每个CPU从entryothersstart标号开始运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;

// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc();
*(void**)(code-4) = stack + KSTACKSIZE;
*(void**)(code-8) = mpenter;
*(int**)(code-12) = (void *) V2P(entrypgdir);

lapicstartap(c->apicid, V2P(code));

// wait for cpu to finish mpmain()
while(c->started == 0)
;
}

在这里同时还设置了每个CPU特有的内核栈以及共有的页表,并设置mpenterentryothers最后跳转回内核的地址,entryothers主要进行CPU必要的初始化:从实模式切换到保护模式,开启分页机制,最后跳转到mpenter处,跳回内核。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;

// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc();
*(void**)(code-4) = stack + KSTACKSIZE;
*(void**)(code-8) = mpenter;
*(int**)(code-12) = (void *) V2P(entrypgdir);

lapicstartap(c->apicid, V2P(code));

// wait for cpu to finish mpmain()
while(c->started == 0)
;
}

mpter设置新的内核页表和进行段初始化,最后调用mpmain开始调度进程。

1
2
3
4
switchkvm();
seginit();
lapicinit();
mpmain();

至此,所有的CPU都已经正常工作。

xv6-boot部分代码阅读

注:

这部分代码是使用typora写的,里面嵌套的一些内容可能在简书里面显示不出来,所以如有格式问题,建议下载typora复制粘贴查看。

另:这些内容基本都是从网上各个网站找的资料,印象比较深刻的有以下几个:

https://blog.csdn.net/vally1989/article/details/71796482
http://leenjewel.github.io/blog/2015/11/11/%5B%28xue-xi-xv6%29%5D-nei-he-gai-lan/
http://leenjewel.github.io/blog/2015/05/26/%5B%28xue-xi-xv6%29%5D-jia-zai-bing-yun-xing-nei-he/
https://blog.csdn.net/qq_25426415/article/details/54583835
https://blog.csdn.net/qq_25426415/article/details/54619488

感谢上述各个文章。

如有需要,联系contact@fhao.top

引导

BIOS需要执行系统的启动扇区上的东西,故首先需要将启动扇区复制到内存中,并且将其执行后进入操作系统的内核之中。

BIOS必须通过实模式启动,但当需要我们引入xv6自己的内核时,需要从实模式转换到保护模式(因为保护模式地址总线为32位,可访问地址位更多,可以利用4G内存)。

打开A20

A20简介:(来自https://en.wikipedia.org/wiki/A20_line)

  • A20总线,是x86体系的扩充电子线路之一。A20总线是专门用来转换地址总线的第二十一位。
  • 由于16位操作系统数据线宽与寄存器是16位,这样只能支持$2^{16}$即64K的内存,为了扩展,在8086CPU上将地址线宽度增加到20位,这样能寻址到$2^{20}$即1M(16位+4位的偏移)。一个 16 位的寄存器来表示“段基址”(CS、DS、SS、ES四个寄存器),具体的做法是先将 16 位的“段基址”左移 4 位,然后加上 16 位的“偏移量”最终得到 20 位的内存地址送入地址线。
  • 但是到了80286CPU时已经有了24根总线,为了能够访问1M之后的内容并且与之前的8086/8088兼容,故使用键盘控制器上剩余的一些输出线来管理A20,被称为A20 Gate,若A20 Gate被打开,则0x100000-0x10FFEF的内存就可以正常访问。否则,就将超过1M的内存取模再次回到了1M以内。
  • 保护模式有32位,肯定不能关闭A20,无法访问4G地址空间。只能访问1M,3M,5M等。

BIOS设置CS寄存器为0x0,ip寄存器为0x7c00,开始执行boot loader程序。

bootasm.S中:

1
.code16

一句说明现在是16位状态。之后一句:

1
.globl start

.globl指示告诉汇编器,start这个符号要被链接器用到,所以要在目标文件的符号表中标记它是一个全局符号。start就像C程序的main函数一样特殊,是整个程序的入口,链接器在链接时会查找目标文件中的start符号代表的地址,把它设置为整个程序的入口地址,所以每个汇编程序都要提供一个start符号并且用.globl声明。如果一个符号没有用.globl声明,就表示这个符号不会被链接器用到。

之后使用cli命令来禁用中断。

接下来四句:

1
2
3
4
xorw    %ax,%ax             # 使用异或操作,直接将%ax设0
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

将寄存器DS, ES, SS全部设置为0。其中取值会用到 %cs,读写数据会用到 %ds,读写栈会用到 %ss

但是实模式刚运行完,很多遗留下的一些寄存器的值需要我们整理下,所以我们需要“将 %ax 置零,然后把这个零值拷贝到三个段寄存器中”。

接下来,打开A20开关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
seta20.1:
inb $0x64,%al # 等待键盘控制器不忙碌
testb $0x2,%al
jnz seta20.1

movb $0xd1,%al # 把0xd1写入0x64
outb %al,$0x64

seta20.2:
inb $0x64,%al # 等待键盘控制器不忙碌
testb $0x2,%al
jnz seta20.2

movb $0xdf,%al # 把0xdf写入0x60
outb %al,$0x60

xv6打开A20采用的方法是键盘控制器法,通过输出命令到键盘控制器的IO接口,控制A20的开启与关闭。

与键盘控制器有关的IO接口是0x60和0x64。其中0x64起到一个状态控制的功能,0x60则是数据端口。首先需要检查键盘控制器是否忙碌,例如是否正有键盘输入等等。这个状态检查是通过读取0x64实现的。通过检查该状态数据的低第二个比特位是否为高,来判断是否忙碌。等到该比特位为低,就可以向0x64写命令了。

上述代码中

1
inb     $0x64,%al

之中的inb代表读入数据,即将0x64地址上的数据放入%al$代表常量。

1
testb   $0x2,%al

之中的testb将两个操作数进行逻辑与运算,并根据运算结果设置相关的标志位。但两个操作数不会被改变。运算结果在设置过相关标记位后会被丢弃。这一句将0x2%al中的内容求与,即得到了%al中内容的第二位,如果是高,则执行下一句

1
jnz     seta20.1

也就是再次执行seta20.1,重新检测。否则,进行下面的操作

1
2
movb    $0xd1,%al               # 把0xd1写入0x64
outb %al,$0x64

使用%al作为中介,将0xd1写入0x64。该命令用于指示即将向键盘控制器的输出端口写一个字节的数据。

下面的一段含义类似,只是最后将0xdf写入0x60。该数据代表开启A20。

进入保护模式

GDT介绍:

  • 内存地址是段地址+段内偏移。在实模式下,段地址就放在段寄存器中,而到了保护模式下,段寄存器中保存的不再是段地址,而是GDT(global descriptor table,全局描述符表)中的偏移量,所以进入保护模式时首先需要创建GDT。

  • GDT每个段对应一个表项,保存段基址、段大小、访问权限等,让CPU可以定位到具体的物理地址去,并且检查内存访问是否合法,因此被叫做保护模式。GDT整体储存很多段描述符,故类似一个数组。

  • 每个“段描述符”占 8 字节,如图:

    1

  • 三段基地址组合形成32位的基地址(8+8+16)

  • P:       0 本段不在内存中

  • DPL:     访问该段内存所需权限等级 00 — 11,0为最大权限级别

  • S:       1 代表数据段、代码段或堆栈段,0 代表系统段如中断门或调用门

  • E:       1 代表代码段,可执行标记,0 代表数据段

  • ED:      0 代表忽略特权级,1 代表遵守特权级

  • RW:      如果是数据段(E=0)则1 代表可写入,0 代表只读;
             如果是代码段(E=1)则1 代表可读取,0 代表不可读取

  • A:       1 表示该段内存访问过,0 表示没有被访问过

  • G:       1 表示 20 位段界限单位是 4KB,最大长度 4GB;
             0 表示 20 位段界限单位是 1 字节,最大长度 1MB

  • DB:      1 表示地址和操作数是 32 位,0 表示地址和操作数是 16 位

  • XX:      保留位永远是 0

  • AA:      给系统提供的保留位

  • 规定GDT的第一个表项必须是空的。

CPU 单独为我们准备了一个寄存器叫做 GDTR 用来保存我们 GDT 在内存中的位置和我们 GDT 的长度。GDTR 寄存器一共 48 位,其中高 32 位用来存储我们的 GDT 在内存中的位置,其余的低 16 位用来存我们的 GDT 有多少个段描述符。 16 位最大可以表示 65536 个数,这里我们把单位换成字节,而一个段描述符是 8 字节,所以 GDT 最多可以有 8192 个段描述符。不仅 CPU 用了一个单独的寄存器 GDTR 来存储我们的 GDT,而且还专门提供了一个指令用来让我们把 GDT 的地址和长度传给 GDTR 寄存器。lgdt代表加载全局描述符。

1
2
3
4
5
# 将实模式转换为保护模式,使用一个引导GDT让虚拟地址直接映射到真实地址去,过渡期内这个GDT的内存映射不会改变。
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

其中

1
lgdt    gdtdesc

之中用到的gdtdesc的定义如下:

1
2
3
gdtdesc:
.word (gdtdesc - gdt - 1) # sizeof(gdt) - 1, 16位
.long gdt # address gdt,32位

加起来48位。

其中gdt定义如下:

1
2
3
4
5
6
# Bootstrap GDT
.p2align 2 # 强制进行4比特对齐
gdt:
SEG_NULLASM # 空
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # 代码段
SEG_ASM(STA_W, 0x0, 0xffffffff) # 数据(堆栈)段

宏定义在asm.h中,将他们再替换回来,得到如下:

1
2
3
4
5
6
7
gdt:
.word 0, 0;
.byte 0, 0, 0, 0 # 空
.word 0xffff, 0x0000;
.byte 0x00, 0x9a, 0xcf, 0x00 # 代码段
.word 0xffff, 0x0000;
.byte 0x00, 0x92, 0xcf, 0x00 # 数据段

代码段空间如下:

2

数据段空间如下:

3

首先说说这两个内存段的共同点,DB = 1,G = 1,基地址都是 0x00000000,内存分段长度都是 0xfffff,这说明他们都是用于 32 位寻址,所使用的内存是从 0 开始到 4GB 结束(全部内存)。这里是这么算出来的,段长度是 0xfffff = $2^{20}$,G = 1 表示段界限单位是 4k,所以 4k * $2^{20}$ = 4GB。

再说说他们的不同点,代码段的 E = 1 而数据段的 E = 0 这表明了他们的身份,身份不同 RW 的值虽然相同,但代表的意义也就不相同了,代码段的 RW = 1 代表可读取,数据段的 RW = 1 表示可读可写。这也和我们上面解释的保护模式所能够达到的目的相吻合。

代码段和数据段都启用了从 0 到 4GB 的全部内存寻址。其实这种内存规划方法叫做“平坦内存模型”,即便是 Linux 也是用的这样的方式规划内存的,并没有做到真正的“分段”。这是因为 x86 的分页机制是基于分段的,Linux 选用了更先进的分页机制来管理内存,所以在分段这里只是走一个必要的形式罢了。

后面的几句:

1
2
3
movl    %cr0, %eax
orl $CR0_PE, %eax
movl %eax, %cr0

因为我们无法直接操作 cr0,所以我们首先要用一个通用寄存器来保存当前 cr0 寄存器的值,这里第一行就是用通用寄存器 eax 来保存 cr0 寄存器的值;然后 CR0_PE 这个宏的定义在 mmu.h 文件中,是个数值 0x00000001,将这个数值与 eax 中的 cr0 寄存器的值做“或”运算后,就保证将 cr0 的第 0 位设置成了 1 即 PE = 1 保证打开了保护模式的开关。而 cr0 的第 31 位 PG = 0 表示我们只使用分段式,不使用分页,这时再将新的计算后的 eax 寄存器中的值写回到 cr0 寄存器中就完成了到保护模式的切换。

之后进入32位:

1
ljmp    $(SEG_KCODE<<3), $start32

ljmp的语法:ljmp segment offset,它会同时设置段寄存器和段内偏移(EIP寄存器),而在32位模式下,段寄存器保存的是GDT里的偏移量,所以这里段寄存器的值是SEG_KCODE<<3,即数字8(SEG_KCODE=1),因为GDT的第一个表项必须为空,所以代码段占据第二个表项。

这个跳转语句的两个参数就是“基地址” + “偏移量”的方式告诉 CPU 要跳转到内存的什么位置去继续执行指令。

进入32位模式之后,设置各个段寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.code32  # 现在是32位
start32:
# 设置保护模式下的一堆寄存器的值
# 这时候已经在保护模式下了
# 数据段在 GDT 中的下标是 2,所以这里数据段的段选择子是 2 << 3 = 0000 0000 0001 0000
# 这 16 位的段选择子中的前 13 位是 GDT 段表下标,这里前 13 位的值是 2 代表选择了数据段
# 这里将 3 个数据段寄存器都赋值成数据段段选择子的值
movw $(SEG_KDATA<<3), %ax # 先借助中介设置DS,ES,SS这些作为数据项的值为(SEG_KDATA<<3)
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %ss # -> SS: Stack Segment
movw $0, %ax # 剩下的寄存器设置为0
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS

上述代码中,数据段占用GDT第三个表项,ES, SS等同于数据段,其他不用的段置零。

1
2
3
# Set up the stack pointer and call into C.
movl $start, %esp # 栈顶被放置在 0x7C00 处,即 $start
call bootmain

现在准备执行.c代码,首先要设置stack,movl $start, %esp,即给esp寄存器设置,startbootasm.S最开始的一个label,但是为何要将esp设置为start呢,这是因为start是代码的开始位置,代码是向高内存地址处放置的,而stack是向低内存地址增长的,所以这样设置之后,代码和栈向相反方向扩张,两者互不干涉。BIOS 会将引导扇区的引导程序加载到 0x7C00 处并引导 CPU 从此处开始运行,故栈顶即被设置在了和引导程序一致的内存位置上。我们知道栈是自栈顶开始向下增长的,所以这里栈会逐渐远离引导程序,所以这里这样安置栈顶的位置并无什么问题。另外,当call bootmain的时候,首先要做的就是movl %esp %ebp,即将esp的值设置给ebp,这样ebp保存的就是start的值,也就是函数栈中的第一个函数是start,这符合逻辑。

现在不应该执行接下来的这些语句,但是如果异常的话,bootmain返回并且执行下面的操作:

1
2
3
4
5
6
7
8
9
  # If bootmain returns (it shouldn't), trigger a Bochs
# breakpoint if running under Bochs, then loop.
movw $0x8a00, %ax # 0x8a00 -> port 0x8a00
movw %ax, %dx
outw %ax, %dx
movw $0x8ae0, %ax # 0x8ae0 -> port 0x8a00
outw %ax, %dx
spin:
jmp spin

进入.c的内容

由于之前曾经使用了call bootmain,所以现在会执行bootmain.c里面的bootmain()函数,该函数如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#define SECTSIZE  512 // 表示磁盘扇区大小(字节数)

void bootmain(void)
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar* pa;

elf = (struct elfhdr*)0x10000; // 表示内核开始的地址空间位置

// 读取一个页的数据(4k)到内存中elf对应的位置
readseg((uchar*)elf, 4096, 0);

// 判断是否是一个正确的elf格式可执行文件
if(elf->magic != ELF_MAGIC)
return; // 现在返回会回到上面所说的错误处理部分

// 加载每一个程序段(忽略ph标记)
// 找到内核 ELF 文件的程序头表
ph = (struct proghdr*)((uchar*)elf + elf->phoff);
// 内核 ELF 文件程序头表的结束位置
eph = ph + elf->phnum;
// 开始将内核 ELF 文件程序头表载入内存
for(; ph < eph; ph++){
pa = (uchar*)ph->paddr;
readseg(pa, ph->filesz, ph->off);
// 如果内存大小大于文件大小,用 0 补齐内存空位
if(ph->memsz > ph->filesz)
stosb(pa + ph->filesz, 0, ph->memsz - ph->filesz);
}

// 调用elf头所规定的起始位置
// 不会返回
entry = (void(*)(void))(elf->entry);
entry();
}

上述过程其实只是加载了内核(把内核从磁盘上搬到了内存中),然后执行内核中的程序。其中反复被用到的readseg()函数的定义如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 将'count'字节的位于距离内核'offset'的代码写入内存地址'pa'中
// 可能会比要求的多拷贝一部分
void
readseg(uchar* pa, uint count, uint offset)
{
// 表示最后的边界
uchar* epa;
// 从开始写入,最后写入的位置要小于epa
epa = pa + count;
// 多出来的忽略掉(因为磁盘扇区大小)
pa -= offset % SECTSIZE;
// 将内容翻译到扇区(sector)中,内核从下标为1处开始(因为引导区已经占了第一个即下标为0的位置)
offset = (offset / SECTSIZE) + 1;
// 如果比较慢就每次多读一些扇区的东西
// (因为每次都是读一个扇区)将多读一部分,但是没关系,因为我们按照升序加载
for(; pa < epa; pa += SECTSIZE, offset++)
readsect(pa, offset);
}

要知道内核二进制文件到底是如何链接的,首先需要了解:

  • ld表示GCC连接脚本,链接的过程实际上是为了解决多个文件之间符号引用的问题(symbol resolution)。编译时编译器只对单个文件进行处理,如果该文件里面需要引用到其他文件中的符号(例如全局变量或者函数),那么这时在这个文件中该符号的地址是没法确定的,只能等链接器把所有的目标文件连接到一起的时候才能确定最终的地址。

打开kernel.ld文件,可以发现,内核入口地址为标号_start地址

1
2
3
OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)

这个_start的地址其实是在内核代码文件entry.S是内核入口虚拟地址entry对应的物理地址,由于此时虚拟地址直接等于物理地址,_start将作为ELF文件头中的elf->entry的值。

内核文件中加载地址和链接地址是不一样的,链接地址是程序中所有标号、各种符号的地址,一般也就是内存中的虚拟地址,但是加载地址是为了在生成ELF文件时,指定各个段应该为加载的物理地址,这个地址作为每个段的p->paddr的值。

上面readseg函数之中的readsect函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 从offset位置读取单个扇区到dst
void
readsect(void *dst, uint offset)
{
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // 命令0x20也就是读取扇区的命令

// 读取数据
waitdisk();
insl(0x1F0, dst, SECTSIZE/4);
}

里面一堆神奇(莫名其妙)的outb函数就是一个写入一比特的数据(一个uchar)。这样设置的原因如下:

  • IDE 标准定义了 8 个寄存器来操作硬盘。PC 体系结构将第一个硬盘控制器映射到端口 1F0-1F7 处,而第二个硬盘控制器则被映射到端口 170-177 处。这几个寄存器的描述如下(以第一个控制器为例):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    1F0        - 数据寄存器。读写数据都必须通过这个寄存器
    1F1 - 错误寄存器,每一位代表一类错误。全零表示操作成功。
    1F2 - 扇区计数。这里面存放你要操作的扇区数量
    1F3 - 扇区LBA地址的0-7位
    1F4 - 扇区LBA地址的8-15位
    1F5 - 扇区LBA地址的16-23位
    1F6 (低4位) - 扇区LBA地址的24-27位
    1F6 (第4位) - 0表示选择主盘,1表示选择从盘
    1F6 (5-7位) - 必须为1
    1F7 (写) - 命令寄存器
    1F7 (读) - 状态寄存器
    bit 7 = 1 控制器忙
    bit 6 = 1 驱动器就绪
    bit 5 = 1 设备错误
    bit 4 N/A
    bit 3 = 1 扇区缓冲区错误
    bit 2 = 1 磁盘已被读校验
    bit 1 N/A
    bit 0 = 1 上一次命令执行失败

    上面代码中已经把这些内容翻译到注释里面去了。

xv6内核二进制文件是elf格式,在维基上有这个格式的具体说明。前两部分是ELF头部和程序头表,在elf.h文件里面有具体的声明,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#define ELF_MAGIC 0x464C457FU  // "\x7FELF" in little endian

// ELF 文件格式的头部
struct elfhdr {
uint magic; // 4 字节,为 0x464C457FU(大端模式)或 0x7felf(小端模式)
// 表明该文件是个 ELF 格式文件
uchar elf[12]; // 12 字节,每字节对应意义如下:
// 0 : 1 = 32 位程序;2 = 64 位程序
// 1 : 数据编码方式,0 = 无效;1 = 小端模式;2 = 大端模式
// 2 : 只是版本,固定为 0x1
// 3 : 目标操作系统架构
// 4 : 目标操作系统版本
// 5 ~ 11 : 固定为 0
ushort type; // 2 字节,表明该文件类型,意义如下:
// 0x0 : 未知目标文件格式
// 0x1 : 可重定位文件
// 0x2 : 可执行文件
// 0x3 : 共享目标文件
// 0x4 : 转储文件
// 0xff00 : 特定处理器文件
// 0xffff : 特定处理器文件
ushort machine; // 2 字节,表明运行该程序需要的计算机体系架构,
// 这里我们只需要知道 0x0 为未指定;0x3 为 x86 架构
uint version; // 4 字节,表示该文件的版本号
uint entry; // 4 字节,该文件的入口地址,没有入口(非可执行文件)则为 0
uint phoff; // 4 字节,表示该文件的“程序头部表”相对于文件的位置,单位是字节
uint shoff; // 4 字节,表示该文件的“节区头部表”相对于文件的位置,单位是字节
uint flags; // 4 字节,特定处理器标志
ushort ehsize; // 2 字节,ELF文件头部的大小,单位是字节
ushort phentsize; // 2 字节,表示程序头部表中一个入口的大小,单位是字节
ushort phnum; // 2 字节,表示程序头部表的入口个数,
// phnum * phentsize = 程序头部表大小(单位是字节)
ushort shentsize; // 2 字节,节区头部表入口大小,单位是字节
ushort shnum; // 2 字节,节区头部表入口个数,
// shnum * shentsize = 节区头部表大小(单位是字节)
ushort shstrndx; // 2 字节,表示字符表相关入口的节区头部表索引
};

// 程序头表
struct proghdr {
uint type; // 4 字节, 段类型
// 1 PT_LOAD : 可载入的段
// 2 PT_DYNAMIC : 动态链接信息
// 3 PT_INTERP : 指定要作为解释程序调用的以空字符结尾的路径名的位置和大小
// 4 PT_NOTE : 指定辅助信息的位置和大小
// 5 PT_SHLIB : 保留类型,但具有未指定的语义
// 6 PT_PHDR : 指定程序头表在文件及程序内存映像中的位置和大小
// 7 PT_TLS : 指定线程局部存储模板
uint off; // 4 字节, 段的第一个字节在文件中的偏移
uint vaddr; // 4 字节, 段的第一个字节在内存中的虚拟地址
uint paddr; // 4 字节, 段的第一个字节在内存中的物理地址(适用于物理内存定位型的系统)
uint filesz; // 4 字节, 段在文件中的长度
uint memsz; // 4 字节, 段在内存中的长度
uint flags; // 4 字节, 段标志
// 1 : 可执行
// 2 : 可写入
// 4 : 可读取
uint align; // 4 字节, 段在文件及内存中如何对齐
};

现在打开xv6.img文件,读取第二个扇区位置(第一个扇区即第一个512字节是bootblock)的开头数据并且与上面内容相对应,得到如下结果:

字段名称 大小 数值 意义
magic 4字节 7F 45 4C 46 ELF 格式文件
elf 12字节 01 01 01 00 00 00 00 00 00 00 00 00 32 位小端模式,目标操作系统为 System V
type 2字节 02 00 可执行文件
machine 2字节 03 00 指定计算机体系架构为 x86
version 4字节 01 00 00 00 版本号为 1
entry 4字节 0C 00 10 00 该可执行文件入口地址
phoff 4字节 34 00 00 00 程序头表相对于文件的起始位置是 52 字节
shoff 4字节 00 F6 01 00 节区头表相对于文件的起始位置是 128512 字节
flags 4字节 00 00 00 00 无特定处理器标志
ehsize 2字节 34 00 ELF 头大小为 52 字节
phentsize 2字节 20 00 程序头表一个入口的大小是 32 字节
phnum 2字节 02 00 程序头表入口个数是 2 个
shentsize 2字节 28 00 节区头表入口大小是 40 字节
shnum 2字节 12 00 节区头表入口个数是 18 个
shstrndx 2字节 0F 00 字符表入口在节区头表的索引是 15

之后是程序头表,一共两个,每个32字节,如下:

  • 程序头表 1
字段名称 大小 数值 意义
type 4字节 01 00 00 00 可载入的段
off 4字节 00 10 00 00 段在文件中的偏移是 4096 字节
vaddr 4字节 00 00 10 80 段在内存中的虚拟地址
paddr 4字节 00 10 00 00 同段在文件中的偏移量
filesz 4字节 96 B5 00 00 段在文件中的大小是 46486 字节
memsz 4字节 FC 26 01 00 段在内存中的大小是 75516 字节
flags 4字节 07 00 00 00 段的权限是可写、可读、可执行
align 4字节 00 10 00 00 段的对齐方式是 4096 字节,即4kb
  • 程序头表 2
字段名称 大小 数值 意义
type 4字节 51 E5 74 64 PT_GNU_STACK 段
off 4字节 00 00 00 00 段在文件中的偏移是 0 字节
vaddr 4字节 00 00 00 00 段在内存中的虚拟地址
paddr 4字节 00 00 00 00 同段在文件中的偏移量
filesz 4字节 00 00 00 00 段在文件中的大小是 0 字节
memsz 4字节 00 00 00 00 段在内存中的大小是 0 字节
flags 4字节 07 00 00 00 段的权限是可写、可读、可执行
align 4字节 04 00 00 00 段的对齐方式是 4 字节

因此只有第一个程序头表是需要加载的。这个程序头表指出的加载位置 0x80100000 和内核程序的代码段 .text 的位置是一样的。

而要加载的段是 .text .rodata .stab .stabstr .data .bss ,这些段在内存中的大小总和是0x008111 + 0x000672 + 0x000001 + 0x000001 + 0x002596 + 0x00715c = 73335 即 0x11e77 按照对齐要求 0x1000 对齐后为 755160x000126fc(注意大小端转换,FC 26 01 00 是按照小端排列的,转换成正常的十六进制数为 0x000126fc)和 ELF 程序头表中的内存大小信息一致。

我们再算算这些段在文件中的大小,由于这些段在文件中是顺序排列的,所以用 .bss段 的文件偏移量减去 .text段 的文件偏移量 0x00c596 - 0x001000 = 46486 这也是和 ELF 程序头表中段在文件中大小的信息一致。

进入内核

kernel.ld文件中,找到:

1
2
3
4
ENTRY(_start)        # 内核的代码为段执行入口:_start
. = 0x80100000 # 内核的起始虚拟地址位置为:0x80100000
.text : AT(0x100000) # 内核代码段的内存装载地址为:0x100000
. = ALIGN(0x1000) # 内核代码段保证 4KB 对齐

因此就可以让系统正确加载内核。

上面提到的_startentry.S这个汇编文件里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 多系统加载头。运行多系统加载程序的部分。
.p2align 2
.text
.globl multiboot_header
multiboot_header:
#define magic 0x1badb002
#define flags 0
.long magic
.long flags
.long (-magic-flags)

# 按照约定,_start符号指出入口。因为我们还没有设置虚拟内存,因此我们的起始点是'entry'的物理地址
.globl _start
_start = V2P_WO(entry)

multiboot_header 这部分是为了方便通过GNU GRUB来引导 xv6 系统的。而V2P_WO 的定义在 memlayout.h 文件中:

1
2
#define V2P_WO(x) ((x) - KERNBASE)    // V2P里面将x转换成了uint,这里没有转换。
// 这个名字其实就是 Virtual to Physical WITHOUT

它的作用是将内存虚拟地址转换成物理地址。里面KERNBASE定义为:

1
#define KERNBASE 0x80000000         // First kernel virtual address

entry部分如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 进入xv6的启动程序,这时候还没有开启分页
.globl entry
entry:
# 打开4M页面大小扩展
movl %cr4, %eax # 把cr4寄存器拿出来,与CR4_PSE取或来设置这一位为1以打开4M
orl $(CR4_PSE), %eax
movl %eax, %cr4
# 设置内存页表
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3
# 打开分页
movl %cr0, %eax
orl $(CR0_PG|CR0_WP), %eax
movl %eax, %cr0

# 设置栈指针
movl $(stack + KSTACKSIZE), %esp

# 跳转到main()函数,并且转移到高地址执行。
# 因为汇编程序产生了一个及其相关的直接跳转的指令,所以需要进行间接的调用。
mov $main, %eax
jmp *%eax

.comm stack, KSTACKSIZE
开启 4MB 内存分页支持

这是通过设置寄存器 cr4PSE 位来完成的。cr4 寄存器是个 32 位的寄存器目前只用到低 21 位,每一位的至位都控制这一些功能的状态,所以 cr4 寄存器又叫做控制寄存器。

PSE 位是 cr4 控制寄存器的第 5 位,当该位置为 1 时表示内存页大小为 4MB,当置为 0 时表示内存页大小为 4KB。

建立内存页表

和分段机制一样,分页机制同样是管理内存的一种方式,只不过这种方式相对于分段式来说更为先进,也是目前主流的现代操作系统所使用的内存管理方式。

通过分页将虚拟地址转换为物理地址这项工作是由 MMU(内存管理单元)负责的,以 x86 32 位架构来说它支持两级分页(Pentium Pro下分页可以是三级),这也是由 MMU 决定的。同时 x86 架构支持 4KB、2MB 和 4MB 单位页面大小的分页。当然无论以多少级进行分页,分页机制的原理是相通的,我们就以两级分页来说。

以 32 位系统为例,其内存虚拟地址是 32 位的,这里我们先假设页面大小是 4KB ,此时 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。

在 4KB 页面大小情况下 32 位的虚拟地址被分为三个部分,从高位到低位分别是:10 位的一级页表索引,10 位的二级页表索引,12 位的页内偏移量。

cr3 寄存器中保存着一级页表所在的内存物理地址,当给出一个虚拟地址后,根据 cr3 的地址首先找到一级页表在内存上的存放位置。上面我们说到虚拟地址的高 10 位为一级页表的索引,所以 2^10 = 1024 即一级页表一共有 1024 个元素,通过虚拟地址高 10 位的索引我们找到其中一个元素,而这个元素的值指向二级页表在内存中的物理地址。

同理,虚拟地址中紧挨着高 10 位后面的 10 位是二级页表索引,因此二级页表也有 1024 个元素,通过虚拟地址的二级页表索引找到其中的一个元素,该元素指向内存分页中的一个页的地址。

通过二级页表我们现在找到了内存上的一页物理页。根据现在的设定,一物理页的大小是 4KB,在虚拟地址上的低 12 位索引找到想要的数据(2^12 = 4096 = 4KB)。

而根据二级页表和每页内存的大小我们也不难算出:1024个一级页表项 x 1024个二级页表项 x 4KB页面大小 = 4GB,正好是 32 位系统的最大内存寻址。

这里我们再额外算一笔账,二级页表中每个表项占 32 位,所以一个一级页表的总体积是 4byte x 1024 = 4KB,而每个一级页表项都对应一个二级页表,所以全部二级页表的总体积是 4KB x 1024 = 4MB,即二级页表分页机制自身内存占用也要约 4MB 外加 4KB。

我们还要额外提一下页表项。上面刚说过每个页表项占 32 位,它也是分两个部分:高 20 位是基地址,低 12 位是控制标记位。所以当我们通过一级页表索引在一级页表中查找时是这样的:一级页表项地址 = cr3寄存器高20位 + ( 10位一级页表索引 << 2 );通过二级页表索引在二级页表中查找时是这样的:二级页表项地址 = 一级页表项高20位 + ( 10位二级页表索引 << 2 )。

  • 注:页表项地址 = 基地址 + ( 索引 x 页表项大小 ) = 基地址 + ( 索引 x 4字节 ) = 基地址 + ( 索引 << 2 )

最后我们再看看页表项低 12 位控制位都代表什么意义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
+ 11 + 10 + 9 + 8 +  7 + 6 + 5 +  4  +  3  + 2  + 1  + 0 +
| Avail | G | PS | D | A | PCD | PWT | US | RW | P |
+--------------------------------------------------------+
| 000 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1 |
+--------------------------------------------------------+

P : 0 表示此页不在物理内存中,1 表示此页在物理内存中
RW : 0 表示只读,1 表示可读可写(要配合 US 位)
US : 0 表示特权级页面,1 表示普通权限页面
PWT : 1 表示写这个页面时直接写入内存,0 表示先写到缓存中
PCD : 1 表示该页禁用缓存机制,0 表示启用缓存
A : 当该页被初始化时为 0,一但进行过读/写则置为 1
D : 脏页标记(这里就不做具体介绍了)
PS : 0 表示页面大小为 4KB,1 表示页面大小为 4MB
G : 1 表示页面为共享页面(这里就不做具体介绍了)
Avail : 3 位保留位

然后我们再说回 xv6。

到目前为止我们知道 xv6 开启了 4MB 内存页大小,在 x86 架构下当通过 cr4 控制寄存器的 PSE 位打开了 4MB 分页后 MMU 内存管理单元的分页机制就会从二级分页简化为一级分页。

即虚拟地址的高 10 位仍然是一级页表项索引,但是后面的 22 位则全部变为页内偏移量(因为一页有 2^22 = 4MB 大小)。

我们来看看这个一级页表的结构:

1
2
3
# Set page directory
movl $(V2P_WO(entrypgdir)), %eax
movl %eax, %cr3

通过代码我们知道页表地址是存在一个叫做 entrypgdir 的变量中了,通过文本搜索可以在 main.c 文件的最后找到这个变量,我们看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Boot page table used in entry.S and entryother.S.
// Page directories (and page tables), must start on a page boundary,
// hence the "__aligned__" attribute.
// Use PTE_PS in page directory entry to enable 4Mbyte pages.
__attribute__((__aligned__(PGSIZE)))
pde_t entrypgdir[NPDENTRIES] = {
// Map VA's [0, 4MB) to PA's [0, 4MB)
[0] = (0) | PTE_P | PTE_W | PTE_PS,
// Map VA's [KERNBASE, KERNBASE+4MB) to PA's [0, 4MB)
[KERNBASE>>PDXSHIFT] = (0) | PTE_P | PTE_W | PTE_PS,
};

//PAGEBREAK!
// Blank page.

将这些宏定义都转义过来我们看看这个页表的样子:

1
2
3
4
unsigned int entrypgdir[1024] = {
[0] = 0 | 0x001 | 0x002 | 0x080, // 0x083 = 0000 1000 0011
[0x80000000 >> 22] = 0 | 0x001 | 0x002 | 0x080 // 0x083
};

可见这个页表非常简单,只有两个页表项 0x000000000x80000000,而且两个页表项索引的内存物理地址都是 0 ~ 4MB,其他页表项全部未作设置。而且通过这两个页表项的值也可以清楚的看出这段基地址为 0 的 4MB 大小的内存页还是特级权限内存页(低 12 位的控制位对应关系已经附在上面解释控制位的示意图里了)。它仅仅只是一个临时页表,它只保证内核在即将打开内存分页支持后内核可以正常执行接下来的代码,而内核在紧接着执行 main 方法时会马上再次重新分配新的页表,而且最终的页表是 4KB 单位页面的精细页表。

开启内存分页机制

cr0寄存器的第 31 位置为 1 就可以启动内存分页。

这里还要在提一句,至此我们开启了内存分页机制,接下来内核的代码执行和数据读写都要经过 MMU 通过分页机制对内核指令地址和数据地址的转换,那么目前的页表是如何保证在转换后的物理地址是正确的,如何保证内核可以继续正常运行的呢?

我们来分析一下。

根据 kernel.ld 链接器脚本的设定,内核的虚拟地址起始于 0x80100000 即内核代码段的起始处,而内核的代码段被放置在内存物理地址 0x100000 处。我们刚刚看到目前的临时页表将虚拟地址 0x80000000 映射到物理内存的 0x0 处,所以我们来尝试用刚刚了解到的内存分页机制来解析一下 0x80100000 虚拟地址最后转换成物理地址是多少。

1
2
3
4
5
6
7
8
9
10
11
0x80100000 = 1000 0000 00|01 0000 0000 0000 0000 0000

0x8010000010 位 = 1000 0000 00 = 512

0x8010000022 位 = 01 0000 0000 0000 0000 0000 = 1048576

索引 512 对应 entrypgdir[ 0x80000000 >> 22 ] 即基地址为 0x0

换算的物理地址 = 0 + 1048576 = 1048576 = 0x100000

即内核代码段所在内存物理地址 0x100000

说白了就是通过页表将内核高端的虚拟地址直接映射到内核真实所在的低端物理内存位置。

这样虽然保证了在分页机制开启的情况下内核也可以正常运行,但也限制了内核最多只能使用 4MB 的内存,不过对于现在的内核来说 4MB 足够了。

设置内核栈顶位置并跳转到 main 执行

这里通过 .comm 在内核 bbs 段开辟了一段 KSTACKSIZE = 4096 = 4KB 大小的内核栈并将栈顶设置为这段数据区域的末尾处(栈是自上而下的嘛),最后通过 jmp 语句跳转到 main 方法处继续执行。

其他内核的运行

另外一个启动xv6内核的代码是entryother.S,将每一个非引导CPU从16位切换至32位,然后进入mpenter()函数中,最终开始CPU协同调度。其中很大一部分都与bootloader.S相同。

具体工作在main()函数中被调用,因此在下一部分介绍。

进入main()函数

现在还需要将CPU的第二个核启动,这是在main()函数中调用的mpinit()函数负责。

mpinit()函数在mp.c文件中。具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
void
mpinit(void)
{
uchar *p, *e;
struct mp *mp;
struct mpconf *conf;
struct mpproc *proc;
struct mpioapic *ioapic;

bcpu = &cpus[0];
if((conf = mpconfig(&mp)) == 0)
return;
ismp = 1;
lapic = (uint*)conf->lapicaddr;
for(p=(uchar*)(conf+1), e=(uchar*)conf+conf->length; p<e; ){
switch(*p){
case MPPROC:
proc = (struct mpproc*)p;
if(ncpu != proc->apicid){
cprintf("mpinit: ncpu=%d apicid=%d\n", ncpu, proc->apicid);
ismp = 0;
}
if(proc->flags & MPBOOT)
bcpu = &cpus[ncpu];
cpus[ncpu].id = ncpu;
ncpu++;
p += sizeof(struct mpproc);
continue;
case MPIOAPIC:
ioapic = (struct mpioapic*)p;
ioapicid = ioapic->apicno;
p += sizeof(struct mpioapic);
continue;
case MPBUS:
case MPIOINTR:
case MPLINTR:
p += 8;
continue;
default:
cprintf("mpinit: unknown config type %x\n", *p);
ismp = 0;
}
}
if(!ismp){
// Didn't like what we found; fall back to no MP.
ncpu = 1;
lapic = 0;
ioapicid = 0;
return;
}

if(mp->imcrp){
// Bochs doesn't support IMCR, so this doesn't run on Bochs.
// But it would on real hardware.
outb(0x22, 0x70); // Select IMCR
outb(0x23, inb(0x23) | 1); // Mask external interrupts.
}
}

其中结构体cpu的定义如下(在proc.h中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 每个CPU的状态
struct cpu {
uchar id; // 本地 APIC(Advanced Programmable Interrupt Controller) ID, cpus[]数组的索引
struct context *scheduler; // 通过这里进入调用计划表
struct taskstate ts; // 寻找中断栈
struct segdesc gdt[NSEGS]; // 全局描述表
volatile uint started; // 记录启动与否
int ncli; // 终端层数
int intena; // 在pushcli之前有没有启用中断

// 本地的CPU存储变量
struct cpu *cpu;
struct proc *proc; // 正在运行的进程
};

并且定义:

1
2
3
4
5
extern struct cpu cpus[NCPU];
extern int ncpu;

extern struct cpu *cpu asm("%gs:0"); // &cpus[cpunum()]
extern struct proc *proc asm("%gs:4"); // cpus[cpunum()].proc

后面两句其实就是通过指针引用到当前的cpu,我觉得应该是相当于给这两个变量赋值?

因为每个CPU都有独立的gdt,所以在设置gdt的时候可以这样设置(见vm.c中的seginit()函数):

1
2
3
4
5
// CPU和进程的映射,对于每一个CPU都是私有的
c->gdt[SEG_KCPU] = SEG(STA_W, &c->cpu, 8, 0);

lgdt(c->gdt, sizeof(c->gdt));
loadgs(SEG_KCPU << 3);

通过提前设置gs寄存器和gdt的描述符,便能直接通过cpuproc变量访问当前CPU的结构体和运行进程。

main()函数调用的mpinit()初始化了cpus结构体数组,并确定了lapic地址和ioapicid

1
2
3
uchar ioapicid;
volatile uint *lapic; // Initialized in mp.c
volatile struct ioapic *ioapic; // ioapic在地址空间中有固定地址,这里写出来只是为了对比123

具体的初始过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
lapic = (uint*)conf->lapicaddr;
for(p=(uchar*)(conf+1), e=(uchar*)conf+conf->length; p<e; ){
switch(*p){
case MPPROC:
proc = (struct mpproc*)p;
if(ncpu < NCPU) {
cpus[ncpu].apicid = proc->apicid; // apicid may differ from ncpu
ncpu++;
}
p += sizeof(struct mpproc);
continue;
case MPIOAPIC:
ioapic = (struct mpioapic*)p;
ioapicid = ioapic->apicno;
p += sizeof(struct mpioapic);
continue;
case MPBUS:
case MPIOINTR:
case MPLINTR:
p += 8;
continue;
default:
ismp = 0;
break;
}
}1234567891011121314151617181920212223242526

通过在地址空间中找到mp的数据结构后,得到每个CPU的apicid和CPU数量。

通过mpinit()函数,有关的cpus结构体也初始完成了,接下来在main()函数中调用startothers函数启动其他CPU。

startothers让其他CPU执行名为entryother.S对应的代码.

entryother.S是作为独立的二进制文件与内核二进制文件一起组成整体的ELF文件,通过在LD链接器中-b参数来整合一个独立的二进制文件,在内核中通过_binary_entryother_start_binary_entryother_size来引用,具体的makefile如下:

1
$(LD) $(LDFLAGS) -T kernel.ld -o kernelmemfs entry.o  $(MEMFSOBJS) -b binary initcode entryother fs.img

entryothers在生成二进制文件时指定入口点为start以及加载地址和链接地址都为0x7000

1
2
3
4
5
entryother: entryother.S
$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c entryother.S
$(LD) $(LDFLAGS) -N -e start -Ttext 0x7000 -o bootblockother.o entryother.o
$(OBJCOPY) -S -O binary -j .text bootblockother.o entryother
$(OBJDUMP) -S bootblockother.o > entryother.asm

startothers中,首先将entryothers移动到物理地址0x7000处使其能正常运行,在这里需要注意的是此时entryothers相当于CPU刚上电的情形,因为这是其他CPU最初运行的内核代码,所以没有开启保护模式和分页机制,entryothers将页表设置为entrypgdir,在设置页表前,虚拟地址等于物理地址

1
2
3
4
5
// Write entry code to unused memory at 0x7000.
// The linker has placed the image of entryother.S in
// _binary_entryother_start.
code = P2V(0x7000);
memmove(code, _binary_entryother_start, (uint)_binary_entryother_size);

然后循环逐个开启每个CPU让每个CPU从entryothersstart标号开始运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;

// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc();
*(void**)(code-4) = stack + KSTACKSIZE;
*(void**)(code-8) = mpenter;
*(int**)(code-12) = (void *) V2P(entrypgdir);

lapicstartap(c->apicid, V2P(code));

// wait for cpu to finish mpmain()
while(c->started == 0)
;
}

在这里同时还设置了每个CPU特有的内核栈以及共有的页表,并设置mpenterentryothers最后跳转回内核的地址,entryothers主要进行CPU必要的初始化:从实模式切换到保护模式,开启分页机制,最后跳转到mpenter处,跳回内核。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for(c = cpus; c < cpus+ncpu; c++){
if(c == cpus+cpunum()) // We've started already.
continue;

// Tell entryother.S what stack to use, where to enter, and what
// pgdir to use. We cannot use kpgdir yet, because the AP processor
// is running in low memory, so we use entrypgdir for the APs too.
stack = kalloc();
*(void**)(code-4) = stack + KSTACKSIZE;
*(void**)(code-8) = mpenter;
*(int**)(code-12) = (void *) V2P(entrypgdir);

lapicstartap(c->apicid, V2P(code));

// wait for cpu to finish mpmain()
while(c->started == 0)
;
}

mpter设置新的内核页表和进行段初始化,最后调用mpmain开始调度进程。

1
2
3
4
switchkvm();
seginit();
lapicinit();
mpmain();

至此,所有的CPU都已经正常工作。

gcc-version

来自 https://blog.csdn.net/gatieme/article/details/52871438

方法

添加源:

1
2
sudo add-apt-repository ppa:ubuntu-toolchain-r/test
sudo apt-get update

安装:(下面的gcc- g++-后面的是版本号)

1
sudo apt-get install gcc-6 g++-6

刷新db并locate

1
2
3
sudo updatedb && sudo ldconfig
locate gcc | grep -E "/usr/bin/gcc-[0-9]"
locate g++ | grep -E "/usr/bin/g\+\+-[0-9]"

切换版本(下面的-6自行换成自己需要的版本)

1
2
3
4
5
cd /usr/bin
sudo rm gcc
sudo ln -s gcc-6 gcc
sudo rm g++
sudo ln -s g++-6 g++

公众号推荐

推荐一波自己的公众号:五道口的程序狐

里面有一个聊天机器人,抚慰你的心灵

mp

如有需要,联系contact@fhao.top