Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

Python的PIL库

Image读出来的是PIL的类型,而skimage.io读出来的数据是numpy格式的

1
2
3
4
5
6
7
# Image和skimage读图片
import Image as img
import os
from matplotlib import pyplot as plot
from skimage import io,transform
img_file1 = img.open('./CXR_png/MCUCXR_0042_0.png')
img_file2 = io.imread('./CXR_png/MCUCXR_0042_0.png')

输出可以看出Img读图片的大小是图片的(width, height);而skimage的是(height,width, channel),这也是为什么caffe在单独测试时要要在代码中设置:transformer.set_transpose(‘data’,(2,0,1)),因为caffe可以处理的图片的数据格式是(channel,height,width),所以要转换数据。

1
2
3
#读图片后数据的大小:
print "the picture's size: ", img_file1.size
print "the picture's shape: ", img_file2.shape

1
2
the picture's size:  (4892, 4020)
the picture's shape: (4020, 4892)
1
2
3
4
#得到像素:
print(img_file1.getpixel((500,1000)), img_file2[500][1000])
print(img_file1.getpixel((500,1000)), img_file2[1000][500])
print(img_file1.getpixel((1000,500)), img_file2[500][1000])
1
2
3
(0, 139)
(0, 0)
(139, 139)

Img读出来的图片获得某点像素用getpixel((w,h))可以直接返回这个点三个通道的像素值
skimage读出来的图片可以直接img_file2[0][0]获得,但是一定记住它的格式,并不是你想的(channel,height,width)

在图片上面加文字

1
2
3
4
5
6
7
8
9
10
11
#新建绘图对象
draw = ImageDraw.Draw(image),
#获取图像的宽和高
width, height = image.size;
#** ImageFont模块**
#选择文字字体和大小
setFont = ImageFont.truetype('C:/windows/fonts/Dengl.ttf', 20),
#设置文字颜色
fillColor = "#ff0000"
#写入文字
draw.text((40, height - 100), u'广告', font=setFont, fill=fillColor)

图片信息

如果我们想知道一些skimage图片信息

1
2
3
4
5
6
7
8
9
10
11
12
13
from skimage import io, data
img = data.chelsea()
io.imshow(img)
print(type(img)) #显示类型
print(img.shape) #显示尺寸
print(img.shape[0]) #图片高度
print(img.shape[1]) #图片宽度
print(img.shape[2]) #图片通道数
print(img.size) #显示总像素个数
print(img.max()) #最大像素值
print(img.min()) #最小像素值
print(img.mean()) #像素平均值
print(img[0][0])#图像的像素值

PIL image 查看图片信息,可用如下的方法
1
2
3
4
5
6
print type(img)
print img.size #图片的尺寸
print img.mode #图片的模式
print img.format #图片的格式
print(img.getpixel((0,0)))#得到像素:
#img读出来的图片获得某点像素用getpixel((w,h))可以直接返回这个点三个通道的像素值

1
2
3
4
5
6
7
8
9
10
11
12
# 获取图像的灰度值范围
width = img.size[0]
height = img.size[1]

# 输出图片的像素值
count = 0
for i in range(0, width):
for j in range(0, height):
if img.getpixel((i, j))>=0 and img.getpixel((i, j))<=255:
count +=1
print count
print(height*width)

使用python进行数字图片处理,还得安装Pillow包。虽然python里面自带一个PIL(python images library), 但这个库现在已经停止更新了,所以使用Pillow, 它是由PIL发展而来的。

pil能处理的图片类型

pil可以处理光栅图片(像素数据组成的的块)。

通道

一个图片可以包含一到多个数据通道,如果这些通道具有相同的维数和深度,Pil允许将这些通道进行叠加

1
2
3
4
5
6
7
8
9
10
模式
1 1位像素,黑和白,存成8位的像素
L 8位像素,黑白
P 8位像素,使用调色板映射到任何其他模式
RGB 3×8位像素,真彩
RGBA 4×8位像素,真彩+透明通道
CMYK 4×8位像素,颜色隔离
YCbCr 3×8位像素,彩色视频格式
I 32位整型像素
F 32位浮点型像素

坐标

Pil采取左上角为(0,0)的坐标系统

图片的打开与显示

1
2
3
from PIL import Image
img=Image.open('d:/dog.png')
img.show()

虽然使用的是Pillow,但它是由PIL fork而来,因此还是要从PIL中进行import. 使用open()函数来打开图片,使用show()函数来显示图片。
这种图片显示方式是调用操作系统自带的图片浏览器来打开图片,有些时候这种方式不太方便,因此我们也可以使用另上一种方式,让程序来绘制图片。

1
2
3
4
5
6
7
8
9
from PIL import Image
import matplotlib.pyplot as plt
img=Image.open('d:/dog.png')
plt.figure("dog")
plt.figure(num=1, figsize=(8,5),)
plt.title('The image title')
plt.axis('off') # 不显示坐标轴
plt.imshow(img)
plt.show()

这种方法虽然复杂了些,但推荐使用这种方法,它使用一个matplotlib的库来绘制图片进行显示。matplotlib是一个专业绘图的库,相当于matlab中的plot,可以设置多个figure,设置figure的标题,甚至可以使用subplot在一个figure中显示多张图片。matplotlib 可以直接安装.
figure默认是带axis的,如果没有需要,我们可以关掉
1
plt.axis('off')

图像加标题
1
plt.title('The image title')

matplotlib标准模式

1
2
3
4
5
6
plt.figure(num=5, figsize=(8,5),)
#plt.figure(num='newimage', figsize=(8,5),)
plt.title('The image title', color='#0000FF')
plt.imshow(lena) # 显示图片
plt.axis('off') # 不显示坐标轴
plt.show()

PIL image 查看图片信息,可用如下的方法

1
2
3
4
print type(img)
print img.size #图片的尺寸
print img.mode #图片的模式
print img.format #图片的格式

图片的保存

1
img.save('d:/dog.jpg')

就一行代码,非常简单。这行代码不仅能保存图片,还是转换格式,如本例中,就由原来的png图片保存为了jpg图片。

图像通道\几何变换\裁剪

PIL可以对图像的颜色进行转换,并支持诸如24位彩色、8位灰度图和二值图等模式,简单的转换可以通过Image.convert(mode)函数完 成,其中mode表示输出的颜色模式,例如’’L’’表示灰度,’’1’’表示二值图模式等。但是利用convert函数将灰度图转换为二值图时,是采用 固定的阈 值127来实现的,即灰度高于127的像素值为1,而灰度低于127的像素值为0。

彩色图像转灰度图

1
2
3
4
5
6
7
8
9
from PIL import Image
import matplotlib.pyplot as plt
img=Image.open('d:/ex.jpg')
gray=img.convert('L')
plt.figure("beauty")
plt.imshow(gray,cmap='gray')
plt.axis('off')
plt.title('The color image to gray image')
plt.show()

使用函数convert()来进行转换,它是图像实例对象的一个方法,接受一个 mode 参数,用以指定一种色彩模式,mode 的取值可以是如下几种:

  • 1 (1-bit pixels, black and white, stored with one pixel per byte)
  • L (8-bit pixels, black and white)
  • P (8-bit pixels, mapped to any other mode using a colour palette)
  • RGB (3x8-bit pixels, true colour)
  • RGBA (4x8-bit pixels, true colour with transparency mask)
  • CMYK (4x8-bit pixels, colour separation)
  • YCbCr (3x8-bit pixels, colour video format)
  • I (32-bit signed integer pixels)
  • F (32-bit floating point pixels)

通道分离与合并

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from PIL import Image
import matplotlib.pyplot as plt
img=Image.open('d:/ex.jpg') #打开图像
gray=img.convert('L') #转换成灰度
r,g,b=img.split() #分离三通道
pic=Image.merge('RGB',(r,g,b)) #合并三通道
plt.figure("beauty")
plt.subplot(2,3,1), plt.title('origin')
plt.imshow(img),plt.axis('off')
plt.subplot(2,3,2), plt.title('gray')
plt.imshow(gray,cmap='gray'),plt.axis('off')
plt.subplot(2,3,3), plt.title('merge')
plt.imshow(pic),plt.axis('off')
plt.subplot(2,3,4), plt.title('r')
plt.imshow(r,cmap='gray'),plt.axis('off')
plt.subplot(2,3,5), plt.title('g')
plt.imshow(g,cmap='gray'),plt.axis('off')
plt.subplot(2,3,6), plt.title('b')
plt.imshow(b,cmap='gray'),plt.axis('off')
plt.show()

水平拼接图片

给老板整理材料,顺手写了两个脚本,拼接图片用的

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
import os
from PIL import Image
import sys

file_num = len(sys.argv) - 2;
quali = int(sys.argv[1])
file_list = sys.argv[2:]
print(file_list)
min_height=999999
sum_width = 0
img_list=[]
for file_name in file_list:
img = Image.open(file_name)
img_list.append(img)
if(img.size[1]<min_height):
min_height = img.size[1]
sum_width = sum_width + img.size[0]
print("asdf")
out_list=[]
for file_name in file_list:
img = Image.open(file_name)
out = img.resize((img.size[0],min_height),Image.ANTIALIAS) #resize image with high-quality
out.save(file_name)

target = Image.new('RGB',(sum_width,min_height))
left = 0
right = 0
for file_name in file_list:
image = Image.open(file_name)
right += image.size[0]
target.paste(image,(left,0,right,min_height))
print("aaa")
left += image.size[0]
#right += image.size[1]

target.save('result.jpg',quality=quali)

竖直拼接图片

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
import os
from PIL import Image
import sys

file_num = len(sys.argv) - 2
quali = int(sys.argv[1])
file_list = sys.argv[2:]
print(file_list)
min_width=999999
sum_height = 0
img_list=[]
for file_name in file_list:
img = Image.open(file_name)
img_list.append(img)
if(img.size[0]<min_width):
min_width = img.size[0]
sum_height = sum_height + img.size[1]
print("asdf")
out_list=[]
for file_name in file_list:
img = Image.open(file_name)
out = img.resize((min_width,img.size[1]),Image.ANTIALIAS) #resize image with high-quality
out.save(file_name)

target = Image.new('RGB',(min_width,sum_height))
left = 0
right = 0
for file_name in file_list:
image = Image.open(file_name)
right += image.size[1]
target.paste(image,(0,left,min_width,right))
print("aaa")
left += image.size[1]
#right += image.size[1]

target.save('result.jpg',quality=quali)

裁剪图片

从原图片中裁剪感兴趣区域(roi),裁剪区域由4-tuple决定,该tuple中信息为(left, upper, right, lower)。 Pillow左边系统的原点(0,0)为图片的左上角。坐标中的数字单位为像素点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PIL import Image
import matplotlib.pyplot as plt
img=Image.open('d:/ex.jpg') #打开图像
plt.figure("beauty")
plt.subplot(1,2,1), plt.title('origin')
plt.imshow(img),plt.axis('off')
#box变量是一个四元组(左,上,右,下)。
box=(80,100,260,300)
roi=img.crop(box)
plt.subplot(1,2,2)
plt.title('roi')
plt.imshow(roi)
plt.axis('off')
plt.show()

用plot绘制显示出图片后,将鼠标移动到图片上,会在右下角出现当前点的坐标,以及像素值。

几何变换

Image类有resize()、rotate()和transpose()方法进行几何变换。
图像的缩放和旋转

1
2
dst = img.resize((128, 128))
dst = img.rotate(45) # 顺时针角度表示

转换图像

1
2
3
4
5
dst = im.transpose(Image.FLIP_LEFT_RIGHT) #左右互换
dst = im.transpose(Image.FLIP_TOP_BOTTOM) #上下互换
dst = im.transpose(Image.ROTATE_90) #顺时针旋转
dst = im.transpose(Image.ROTATE_180)
dst = im.transpose(Image.ROTATE_270)

transpose()和rotate()没有性能差别。

python图像处理库Image模块

创建一个新的图片

1
2
Image.new(mode, size)  
Image.new(mode, size, color)

层叠图片

层叠两个图片,img2和img2,alpha是一个介于[0,1]的浮点数,如果为0,效果为img1,如果为1.0,效果为img2。当然img1和img2的尺寸和模式必须相同。这个函数可以做出很漂亮的效果来,而图形的算术加减后边会说到。

1
Image.blend(img1, img2, alpha)  

composite可以使用另外一个图片作为蒙板(mask),所有的这三张图片必须具备相同的尺寸,mask图片的模式可以为“1”,“L”,“RGBA”
1
Image.composite(img1, img2, mask) 

添加水印

添加文字水印

1
2
3
4
5
6
7
8
from PIL import Image, ImageDraw,ImageFont
im = Image.open("d:/pic/lena.jpg").convert('RGBA')
txt=Image.new('RGBA', im.size, (0,0,0,0))
fnt=ImageFont.truetype("c:/Windows/fonts/Tahoma.ttf", 20)
d=ImageDraw.Draw(txt)
d.text((txt.size[0]-80,txt.size[1]-30), "cnBlogs",font=fnt, fill=(255,255,255,255))
out=Image.alpha_composite(im, txt)
out.show()

添加小图片水印

1
2
3
4
5
6
7
from PIL import Image
im = Image.open("d:/pic/lena.jpg")
mark=Image.open("d:/logo_small.gif")
layer=Image.new('RGBA', im.size, (0,0,0,0))
layer.paste(mark, (im.size[0]-150,im.size[1]-60))
out=Image.composite(layer,im,layer)
out.show()

PIL Image 图像互转 numpy 数组

将 PIL Image 图片转换为 numpy 数组

1
2
im_array = np.array(im)
# 也可以用 np.asarray(im) 区别是 np.array() 是深拷贝,np.asarray() 是浅拷贝

numpy image 查看图片信息,可用如下的方法

1
2
print img.shape  
print img.dtype

将 numpy 数组转换为 PIL 图片

这里采用 matplotlib.image 读入图片数组,注意这里读入的数组是 float32 型的,范围是 0-1,而 PIL.Image 数据是 uinit8 型的,范围是0-255,所以要进行转换:

1
2
3
4
5
import matplotlib.image as mpimg
from PIL import Image
lena = mpimg.imread('lena.png') # 这里读入的数据是 float32 型的,范围是0-1
im = Image.fromarray(np.uinit8(lena*255))
im.show()

PIL image 查看图片信息,可用如下的方法

1
2
3
4
5
6
print type(img)
print img.size #图片的尺寸
print img.mode #图片的模式
print img.format #图片的格式
print(img.getpixel((0,0))[0])#得到像素:
#img读出来的图片获得某点像素用getpixel((w,h))可以直接返回这个点三个通道的像素值

图像中的像素访问

前面的一些例子中,我们都是利用Image.open()来打开一幅图像,然后直接对这个PIL对象进行操作。如果只是简单的操作还可以,但是如果操作稍微复杂一些,就比较吃力了。因此,通常我们加载完图片后,都是把图片转换成矩阵来进行更加复杂的操作。
打开图像并转化为矩阵,并显示

1
2
3
4
5
6
7
8
9
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
img=np.array(Image.open('d:/lena.jpg')) #打开图像并转化为数字矩阵
plt.figure("dog")
plt.imshow(img)
plt.axis('off')
plt.title('The image title')
plt.show()

调用numpy中的array()函数就可以将PIL对象转换为数组对象。

查看图片信息,可用如下的方法
PIL image 查看图片信息,可用如下的方法

1
2
3
4
5
6
print type(img)
print img.size #图片的尺寸
print img.mode #图片的模式
print img.format #图片的格式
print(img.getpixel((0,0))[0])#得到像素:
#img读出来的图片获得某点像素用getpixel((w,h))可以直接返回这个点三个通道的像素值

如果是RGB图片,那么转换为array之后,就变成了一个rowscolschannels的三维矩阵,因此,我们可以使用
img[i,j,k]来访问像素值。

例1:打开图片,并随机添加一些椒盐噪声

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
img=np.array(Image.open('d:/ex.jpg'))

#随机生成5000个椒盐
rows,cols,dims=img.shape
for i in range(5000):
x=np.random.randint(0,rows)
y=np.random.randint(0,cols)
img[x,y,:]=255

plt.figure("beauty")
plt.imshow(img)
plt.axis('off')
plt.show()

例2:将lena图像二值化,像素值大于128的变为1,否则变为0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
img=np.array(Image.open('d:/pic/lena.jpg').convert('L'))

rows,cols=img.shape
for i in range(rows):
for j in range(cols):
if (img[i,j]<=128):
img[i,j]=0
else:
img[i,j]=1

plt.figure("lena")
plt.imshow(img,cmap='gray')
plt.axis('off')
plt.show()

如果要对多个像素点进行操作,可以使用数组切片方式访问。切片方式返回的是以指定间隔下标访问 该数组的像素值。下面是有关灰度图像的一些例子:

1
2
3
4
5
6
7
img[i,:] = im[j,:] # 将第 j 行的数值赋值给第 i 行
img[:,i] = 100 # 将第 i 列的所有数值设为 100
img[:100,:50].sum() # 计算前 100 行、前 50 列所有数值的和
img[50:100,50:100] # 50~100 行,50~100 列(不包括第 100 行和第 100 列)
img[i].mean() # 第 i 行所有数值的平均值
img[:,-1] # 最后一列
img[-2,:] (or im[-2]) # 倒数第二行

直接操作像素点

不但可以对每个像素点进行操作,而且,每一个通道都可以独立的进行操作。比如,将每个像素点的亮度(不知道有没有更专业的词)增大20%

1
2
3
4
5
6
out = img.point(lambda i : i * 1.2)
#注意这里用到一个匿名函数(那个可以把i的1.2倍返回的函数)

argument * scale + offset
e.g
out = img.point(lambda i: i*1.2 + 10)

图像直方图

我们先来看两个函数reshape和flatten:
假设我们先生成一个一维数组:

1
2
vec=np.arange(15)
print vec

如果我们要把这个一维数组,变成一个3*5二维矩阵,我们可以使用reshape来实现

1
2
mat= vec.reshape(3,5)
print mat

现在如果我们返过来,知道一个二维矩阵,要变成一个一维数组,就不能用reshape了,只能用flatten. 我们来看两者的区别
1
2
3
4
a1=mat.reshape(1,-1)  #-1表示为任意,让系统自动计算
print a1
a2=mat.flatten()
print a2

可以看出,用reshape进行变换,实际上变换后还是二维数组,两个方括号,因此只能用flatten.
我们要对图像求直方图,就需要先把图像矩阵进行flatten操作,使之变为一维数组,然后再进行统计

画灰度图直方图

绘图都可以调用matplotlib.pyplot库来进行,其中的hist函数可以直接绘制直方图。
调用方式:

1
n, bins, patches = plt.hist(arr, bins=50, normed=1, facecolor='green', alpha=0.75)

hist的参数非常多,但常用的就这五个,只有第一个是必须的,后面四个可选
1
2
3
4
5
arr: 需要计算直方图的一维数组
bins: 直方图的柱数,可选项,默认为10
normed: 是否将得到的直方图向量归一化。默认为0
facecolor: 直方图颜色
alpha: 透明度

返回值 :
1
2
3
n: 直方图向量,是否归一化由参数设定
bins: 返回各个bin的区间范围
patches: 返回每个bin里面包含的数据,是一个list

1
2
3
4
5
6
7
8
9
10
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
img=np.array(Image.open('d:/pic/lena.jpg').convert('L'))

plt.figure("lena")
arr=img.flatten()
n, bins, patches = plt.hist(arr, bins=256, normed=1, facecolor='green', alpha=0.75)
plt.title('The image title')
plt.show()

彩色图片直方图

实际上是和灰度直方图一样的,只是分别画出三通道的直方图,然后叠加在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
src=Image.open('d:/ex.jpg')
r,g,b=src.split()
plt.figure("lena")
ar=np.array(r).flatten()
plt.hist(ar, bins=256, normed=1,facecolor='r',edgecolor='r',hold=1)
ag=np.array(g).flatten()
plt.hist(ag, bins=256, normed=1, facecolor='g',edgecolor='g',hold=1)
ab=np.array(b).flatten()
plt.hist(ab, bins=256, normed=1, facecolor='b',edgecolor='b')
plt.title('The image title')
plt.show()

Python如何读取指定文件夹下的所有图像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
'''
Load the image files form the folder
input:
imgDir: the direction of the folder
imgName:the name of the folder
output:
data:the data of the dataset
label:the label of the datset
'''
def load_Img(imgDir,imgFoldName):
imgs = os.listdir(imgDir+imgFoldName)
imgNum = len(imgs)
data = np.empty((imgNum,1,12,12),dtype="float32")
label = np.empty((imgNum,),dtype="uint8")
for i in range (imgNum):
img = Image.open(imgDir+imgFoldName+"/"+imgs[i])
arr = np.asarray(img,dtype="float32")
data[i,:,:,:] = arr
label[i] = int(imgs[i].split('.')[0])
return data,label

调用方式

1
2
3
craterDir = "./data/CraterImg/Adjust/"
foldName = "East_CraterAdjust12"
data, label = load_Img(craterDir,foldName)

Python图形图像处理库ImageEnhance模块图像增强

可以使用ImageEnhance模块,其中包含了大量的预定义的图片加强方式
加强器包括,色彩平衡,亮度平衡,对比度,锐化度等。通过使用这些加强器,可以很轻松的做到图片的色彩调整,亮度调整,锐化等操作,google picasa中提供的一些基本的图片加强功能都可以实现。

颜色加强color用于调整图片的色彩平衡,相当于彩色电视机的色彩调整。这个类实现了上边提到的接口的enhance方法。

1
ImageEnhance.Color(img)#获得色彩加强器实例  

然后即可使用enhance(factor)方法进行调整。

亮度加强brightness用于调整图片的明暗平衡。

1
ImageEnhance.Brightness(img)#获得亮度加强器实例  

factor=1返回一个黑色的图片对象,0返回原始图片对象

对比度加强contrast用于调整图片的对比度,相当于彩色电视机的对比度调整。

1
ImageEnhance.Contrast(image) #获得对比度加强器实例  

1
2
3
import ImageEnhance  
enh = ImageEnhance.Contrast(im)
enh.ehhance(1.5).show("50% more contrast")

锐化度加强sharpness用于锐化/钝化图片。

1
ImageEnhance.Sharpness(image) #返回锐化加强器实例  

应该注意的是锐化操作的factor是一个0-2的浮点数,当factor=0时,返回一个完全模糊的图片对象,当factor=1时,返回一个完全锐化的图片对象,factor=1时,返回原始图片对象

Python图像处理库ImageChops模块

这个模块主要包括对图片的算术运算,叫做通道运算(channel operations)。这个模块可以用于多种途径,包括一些特效制作,图片整合,算数绘图等等方面。

Invert:

1
ImageChops.invert(image) 

图片反色,类似于集合操作中的求补集,最大值为Max,每个像素做减法,取出反色.
公式
out = MAX - image

lighter:

1
ImageChops.lighter(image1, image2)  

darker:

1
ImageChops.darker(image1, image2)  

difference

1
ImageChops.difference(image1, image2)

求出两张图片的绝对值,逐像素的做减法

multiply

1
ImageChops.multiply(image1, image2)

将两张图片互相叠加,如果用纯黑色与某图片进行叠加操作,会得到一个纯黑色的图片。如果用纯白色与图片作叠加,图片不受影响。
计算的公式如下公式
out = img1 * img2 / MAX

screen:

1
ImageChops.screen(image1, image2)  

先反色,后叠加。
公式
out = MAX - ((MAX - image1) * (MAX - image2) / MAX)

add:

1
ImageChops.add(img1, img2, scale, offset)  

对两张图片进行算术加法,按照一下公式进行计算
公式
out = (img1+img2) / scale + offset

如果尺度和偏移被忽略的化,scale=1.0, offset=0.0即
out = img1 + img2

subtract:

1
ImageChops.subtract(img1, img2, scale, offset)  

对两张图片进行算术减法:
公式
out = (img1-img2) / scale + offset

Python图形图像处理库ImageFilter模块图像滤镜

ImageFilter是PIL的滤镜模块,通过这些预定义的滤镜,可以方便的对图片进行一些过滤操作,从而去掉图片中的噪音(部分的消除),这样可以降低将来处理的复杂度(如模式识别等)。

滤镜名称 含义
ImageFilter.BLUR 模糊滤镜
ImageFilter.CONTOUR 轮廓
ImageFilter.EDGE_ENHANCE 边界加强
ImageFilter.EDGE_ENHANCE_MORE 边界加强(阀值更大)
ImageFilter.EMBOSS 浮雕滤镜
ImageFilter.FIND_EDGES 边界滤镜
ImageFilter.SMOOTH 平滑滤镜
ImageFilter.SMOOTH_MORE 平滑滤镜(阀值更大)
ImageFilter.SHARPEN 锐化滤镜

要使用PIL的滤镜功能,需要引入ImageFilter模块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Image, ImageFilter  

def inHalf(img):
w,h = img.size
return img.resize((w/2, h/2))

def filterDemo():
img = Image.open("sandstone_half.jpg")
#img = inHalf(img)
imgfilted = img.filter(ImageFilter.SHARPEN)
#imgfilted.show()
imgfilted.save("sandstone_sharpen.jpg")

if __name__ == "__main__":
filterDemo()

Python netcdf4包的使用

netCDF4包的文档:http://unidata.github.io/netcdf4-python/netCDF4/index.html

netCDF files come in five flavors.

  • NETCDF3_CLASSIC was the original netcdf binary format, and was limited to file sizes less than 2 Gb.
  • NETCDF3_64BIT_OFFSET was introduced in version 3.6.0 of the library, and extended the original binary format to allow for file sizes greater than 2 Gb.
  • NETCDF3_64BIT_DATA is a new format that requires version 4.4.0 of the C library - it extends the NETCDF3_64BIT_OFFSET binary format to allow for unsigned/64 bit integer data types and 64-bit dimension sizes.
  • NETCDF3_64BIT is an alias for NETCDF3_64BIT_OFFSET.
  • NETCDF4_CLASSIC files use the version 4 disk format (HDF5), but omits features not found in the version 3 API. They can be read by netCDF 3 clients only if they have been relinked against the netCDF 4 library. They can also be read by HDF5 clients. NETCDF4 files use the version 4 disk format (HDF5) and use the new features of the version 4 API. The netCDF4 module can read and write files in any of these formats. When creating a new file, the format may be specified using the format keyword in the Dataset constructor. The default format is NETCDF4. To see how a given file is formatted, you can examine the data_model attribute.

Closing the netCDF file is accomplished via the Dataset.close method of the Dataset instance.

因为要使用netCDF4格式的文件,所以学了一下如何把一个nc文件复制成另一个。在创建新文件时,format只能设置成“NETCDF3_CLASSIC”,否则在public2机器上无法读取,应该是HDF5的问题。下边的程序就比较齐全了,无论是维度的设置、变量及其属性的设置、全局属性的设置等都有了。复制出来的两个nc文件是一样的。

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
from netCDF4 import Dataset

nc = Dataset("wind2018100700.nc")
newnc = Dataset("new_wind.nc", "w", format='NETCDF3_CLASSIC')

ncdimensions = nc.dimensions
for dim in nc.dimensions.values():
newncdim_sample = newnc.createDimension(dim.name, dim.size)

for var in nc.variables.values():
print(var)
print(var.datatype)
print(var.ncattrs())
print(var.dimensions)
new_var = newnc.createVariable(var.name, var.datatype, var.dimensions, shuffle=False)
for attr in var.ncattrs():
new_var.setncattr(attr, var.getncattr(attr))
newnc[var.name][:] = nc[var.name][:]

for attr in nc.ncattrs():
newnc.setncattr(attr,nc.getncattr(attr))


nc.close()
newnc.close()

Python 用matplotlib画三角形

老是得画三角形,所以用Python写了个简单的脚本备忘。

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
import matplotlib.pyplot as plt
import re

lists = ["(-29.548464, -48.168283)(101.860675, -115.334736)(-95.193356, 86.781746)",
"(-95.193356, 86.781746)(101.860675, -115.334736)(101.860675, 86.781746)"
]
pattern = re.compile(r'[-+]?[0-9]*\.?[0-9]+')

for l in lists:
datas = pattern.findall(l)
length = len(datas)
if (length % 2 != 0):
print("error")
exit(0)
lons = []
lats = []
for i in range(int(length/2)):
lons.append(float(datas[i*2]))
lats.append(float(datas[i*2+1]))
plt.scatter(lons, lats, c='b')
for i in range(len(lons)):
plt.text(lons[i]*1.01, lats[i]*1.01, str(lons[i])+"\n"+str(lats[i]))
for j in range(3):
plt.plot([lons[j], lons[(j+1)%3]], [lats[j], lats[(j+1)%3]], color='b')
plt.show()

Python使用thinter写界面

找了一个样例,以后以此为模板。注意前边的import,在python3下可以正常运行,python3自带了Tkinter。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import _tkinter
from tkinter import *
import hashlib
import time

LOG_LINE_NUM = 0

class MY_GUI():
def __init__(self,init_window_name):
self.init_window_name = init_window_name


#设置窗口
def set_init_window(self):
self.init_window_name.title("文本处理工具_v1.2")
self.init_window_name.geometry('1068x681+10+10')
self.init_window_name["bg"] = "white" #窗口背景色,其他背景色见:blog.csdn.net/chl0000/article/details/7657887
self.init_window_name.attributes("-alpha",0.9) #虚化,值越小虚化程度越高
#标签
self.init_data_label = Label(self.init_window_name, text="待处理数据")
self.init_data_label.grid(row=0, column=0)
self.result_data_label = Label(self.init_window_name, text="输出结果")
self.result_data_label.grid(row=0, column=12)
self.log_label = Label(self.init_window_name, text="日志")
self.log_label.grid(row=12, column=0)
#文本框
self.init_data_Text = Text(self.init_window_name, width=67, height=35) #原始数据录入框
self.init_data_Text.grid(row=1, column=0, rowspan=10, columnspan=10)
self.result_data_Text = Text(self.init_window_name, width=70, height=49) #处理结果展示
self.result_data_Text.grid(row=1, column=12, rowspan=15, columnspan=10)
self.log_data_Text = Text(self.init_window_name, width=66, height=9) # 日志框
self.log_data_Text.grid(row=13, column=0, columnspan=10)
#按钮
self.str_trans_to_md5_button = Button(self.init_window_name, text="字符串转MD5", bg="lightblue", width=10,command=self.str_trans_to_md5)
self.str_trans_to_md5_button.grid(row=1, column=11)

#功能函数
def str_trans_to_md5(self):
src = self.init_data_Text.get(1.0,END).strip().replace("\n","").encode()
#print("src =",src)
if src:
try:
myMd5 = hashlib.md5()
myMd5.update(src)
myMd5_Digest = myMd5.hexdigest()
#print(myMd5_Digest)
#输出到界面
self.result_data_Text.delete(1.0,END)
self.result_data_Text.insert(1.0,myMd5_Digest)
self.write_log_to_Text("INFO:str_trans_to_md5 success")
except:
self.result_data_Text.delete(1.0,END)
self.result_data_Text.insert(1.0,"字符串转MD5失败")
else:
self.write_log_to_Text("ERROR:str_trans_to_md5 failed")


#获取当前时间
def get_current_time(self):
current_time = time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(time.time()))
return current_time


#日志动态打印
def write_log_to_Text(self,logmsg):
global LOG_LINE_NUM
current_time = self.get_current_time()
logmsg_in = str(current_time) +" " + str(logmsg) + "\n" #换行
if LOG_LINE_NUM <= 7:
self.log_data_Text.insert(END, logmsg_in)
LOG_LINE_NUM = LOG_LINE_NUM + 1
else:
self.log_data_Text.delete(1.0,2.0)
self.log_data_Text.insert(END, logmsg_in)


def gui_start():
init_window = Tk() #实例化出一个父窗口
ZMJ_PORTAL = MY_GUI(init_window)
# 设置根窗口默认属性
ZMJ_PORTAL.set_init_window()

init_window.mainloop() #父窗口进入事件循环,可以理解为保持窗口运行,否则界面不展示

gui_start()

原文:https://blog.csdn.net/shuaihj/article/details/14163713

事务

定义:所谓事务,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的工作单位。

准备工作:为了说明事务的ACID原理,我们使用银行账户及资金管理的案例进行分析。

1
2
3
4
5
6
7
8
9
10
// 创建数据库
create table account(
idint primary key not null,
namevarchar(40),
moneydouble
);

// 有两个人开户并存钱
insert into account values(1,'A',1000);
insert into account values(2,'B',1000);

ACID

ACID,是指在可靠数据库管理系统(DBMS)中,事务(transaction)所应该具有的四个特性:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability).这是可靠数据库所应具备的几个特性.下面针对这几个特性进行逐个讲解.

原子性

原子性是指事务是一个不可再分割的工作单位,事务中的操作要么都发生,要么都不发生。

案例

A给B转帐100元钱

1
2
3
4
5
6
7
begin transaction
update account set money= money - 100where name='A';
update account set money= money +100where name='B';
if Error then
rollback
else
commit

分析

在事务中的扣款和加款两条语句,要么都执行,要么就都不执行。否则如果只执行了扣款语句,就提交了,此时如果突然断电,A账号已经发生了扣款,B账号却没收到加款,在生活中就会引起纠纷。

解决方法

在数据库管理系统(DBMS)中,默认情况下一条SQL就是一个单独事务,事务是自动提交的。只有显式的使用start transaction开启一个事务,才能将一个代码块放在事务中执行。保障事务的原子性是数据库管理系统的责任,为此许多数据源采用日志机制。例如,SQL Server使用一个预写事务日志,在将数据提交到实际数据页面前,先写在事务日志上。

一致性

一致性是指在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。这是说数据库事务不能破坏关系数据的完整性以及业务逻辑上的一致性。

案例

对银行转帐事务,不管事务成功还是失败,应该保证事务结束后ACCOUNT表中aaa和bbb的存款总额为2000元。

解决方法

保障事务的一致性,可以从以下两个层面入手

数据库机制层面

数据库层面的一致性是,在一个事务执行之前和之后,数据会符合你设置的约束(唯一约束,外键约束,Check约束等)和触发器设置。这一点是由SQL SERVER进行保证的。比如转账,则可以使用CHECK约束两个账户之和等于2000来达到一致性目的

业务层面

对于业务层面来说,一致性是保持业务的一致性。这个业务一致性需要由开发人员进行保证。当然,很多业务方面的一致性,也可以通过转移到数据库机制层面进行保证。

隔离性

多个事务并发访问时,事务之间是隔离的,一个事务不应该影响其它事务运行效果。

这指的是在并发环境中,当不同的事务同时操纵相同的数据时,每个事务都有各自的完整数据空间。由并发事务所做的修改必须与任何其他并发事务所做的修改隔离。事务查看数据更新时,数据所处的状态要么是另一事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看到中间状态的数据。

在Windows中,如果多个进程对同一个文件进行修改是不允许的,Windows通过这种方式来保证不同进程的隔离性。

企业开发中,事务最复杂问题都是由事务隔离性引起的。当多个事务并发时,SQL Server利用加锁和阻塞来保证事务之间不同等级的隔离性。一般情况下,完全的隔离性是不现实的,完全的隔离性要求数据库同一时间只执行一条事务,这样会严重影响性能。想要理解SQL Server中对于隔离性的保障,首先要了解并发事务之间是如何干扰的.

事务之间的相互影响

事务之间的相互影响分为几种,分别为:脏读,不可重复读,幻读,丢失更新

脏读

脏读意味着一个事务读取了另一个事务未提交的数据,而这个数据是有可能回滚的;如下案例,此时如果事务1回滚,则B账户必将有损失。

不可重复读

不可重复读意味着,在数据库访问中,一个事务范围内两个相同的查询却返回了不同数据。这是由于查询时系统中其他事务修改的提交而引起的。如下案例,事务1必然会变得糊涂,不知道发生了什么。

幻读(虚读)

幻读,是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉一样.

丢失更新

两个事务同时读取同一条记录,A先修改记录,B也修改记录(B是不知道A修改过),B提交数据后B的修改结果覆盖了A的修改结果。

理解SQL SERVER中的隔离级别

数据库的事务隔离级别(TRANSACTION ISOLATION LEVEL)是一个数据库上很基本的一个概念。为什么会有事务隔离级别,SQL Server上实现了哪些事务隔离级别?事务隔离级别的前提是一个多用户、多进程、多线程的并发系统,在这个系统中为了保证数据的一致性和完整性,我们引入了事务隔离级别这个概念,对一个单用户、单线程的应用来说则不存在这个问题。

为了避免上述几种事务之间的影响,SQL Server通过设置不同的隔离级别来进行不同程度的避免。因为高的隔离等级意味着更多的锁,从而牺牲性能。所以这个选项开放给了用户根据具体的需求进行设置。不过默认的隔离级别Read Commited符合了多数的实际需求.

SQL Server隔离事务之间的影响是通过锁来实现的,通过阻塞来阻止上述影响。不同的隔离级别是通过加不同的锁,造成阻塞来实现的,所以会以付出性能作为代价;安全级别越高,处理效率越低;安全级别越低,效率高。

使用方法:SET TRANSACTIONISOLATION LEVEL REPEATABLE READ

未提交读: 在读数据时不会检查或使用任何锁。因此,在这种隔离级别中可能读取到没有提交的数据。

已提交读:只读取提交的数据并等待其他事务释放排他锁。读数据的共享锁在读操作完成后立即释放。已提交读是SQL Server的默认隔离级别。

可重复读: 像已提交读级别那样读数据,但会保持共享锁直到事务结束。

可串行读:工作方式类似于可重复读。但它不仅会锁定受影响的数据,还会锁定这个范围。这就阻止了新数据插入查询所涉及的范围。

持久性

持久性,意味着在事务完成以后,该事务所对数据库所作的更改便持久的保存在数据库之中,并不会被回滚。

即使出现了任何事故比如断电等,事务一旦提交,则持久化保存在数据库中。

SQL SERVER通过write-ahead transaction log来保证持久性。write-ahead transaction log的意思是,事务中对数据库的改变在写入到数据库之前,首先写入到事务日志中。而事务日志是按照顺序排号的(LSN)。当数据库崩溃或者服务器断点时,重启动SQL SERVER,SQLSERVER首先会检查日志顺序号,将本应对数据库做更改而未做的部分持久化到数据库,从而保证了持久性。

总结

事务的(ACID)特性是由关系数据库管理系统(RDBMS,数据库系统)来实现的。数据库管理系统采用日志来保证事务的原子性、一致性和持久性。日志记录了事务对数据库所做的更新,如果某个事务在执行过程中发生错误,就可以根据日志,撤销事务对数据库已做的更新,使数据库退回到执行事务前的初始状态。

数据库管理系统采用锁机制来实现事务的隔离性。当多个事务同时更新数据库中相同的数据时,只允许持有锁的事务能更新该数据,其他事务必须等待,直到前一个事务释放了锁,其他事务才有机会更新该数据。

数据库查询优化

使用索引

应尽量避免全表扫描,首先应考虑在 where 及 order by ,group by 涉及的列上建立索引

优化 SQL 语句

  1. 通过 explain(查询优化神器)用来查看 SQL 语句的执行效果

    • 可以帮助选择更好的索引和优化查询语句, 写出更好的优化语句。 通常我们可以对比较复杂的尤其是涉及到多表的 SELECT 语句, 把关键字 EXPLAIN 加到前面, 查看执行计划。例如: explain select * from news;
  2. 任何地方都不要使用 select * from t。用具体的字段列表代替* ,不要返回用不到的任何字段。

    • 不需要的字段会增加数据传输的时间,即使mysql服务器和客户端是在同一台机器上,使用的协议还是tcp,通信也是需要额外的时间。
    • 要取的字段、索引的类型,和这两个也是有关系的。举个例子,对于user表,有name和phone的联合索引,select name from user where phone=12345678912 和 select * from user where phone=12345678912,前者要比后者的速度快,因为name可以在索引上直接拿到,不再需要读取这条记录了。
    • 大字段,例如很长的varchar,blob,text。准确来说,长度超过728字节的时候,会把超出的数据放到另外一个地方,因此读取这条记录会增加一次io操作。
  3. 索引列不能参与计算,保持列“干净”

    • 比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本太大。所以语句应该写成create_time = unix_timestamp(’2014-05-29’);
  4. 查询尽可能使用 limit 减少返回的行数, 减少数据传输时间和带宽浪费。

优化数据库对象

  1. 优化表的数据类型

    • 使用 procedure analyse()函数对表进行分析, 该函数可以对表中列的数据类型提出优化建议。 能小就用小。 表数据类型第一个原则是: 使用能正确的表示和存储数据的最短类型。 这样可以减少对磁盘空间、 内存、 cpu 缓存的使用。
    • 使用方法: select * from 表名 procedure analyse();
  2. 对表进行拆分

    • 通过拆分表可以提高表的访问效率。 有 2 种拆分方法
    • 垂直拆分。把主键和一些列放在一个表中, 然后把主键和另外的列放在另一个表中。 如果一个表中某些列常用, 而另外一些不常用, 则可以采用垂直拆分。
    • 水平拆分。根据一列或者多列数据的值把数据行放到二个独立的表中。
  3. 使用中间表来提高查询速度

    • 创建中间表, 表结构和源表结构完全相同, 转移要统计的数据到中间表, 然后在中间表上进行统计, 得出想要的结果。

硬件优化

  1. CPU 的优化
    • 选择多核和主频高的 CPU。
  2. 内存的优化
    • 使用更大的内存。 将尽量多的内存分配给 MYSQL 做缓存。
  3. 磁盘 I/O 的优化
    • 使用磁盘阵列。RAID 0 没有数据冗余, 没有数据校验的磁盘陈列。 实现 RAID 0至少需要两块以上的硬盘,它将两块以上的硬盘合并成一块, 数据连续地分割在每块盘上。
    • RAID1 是将一个两块硬盘所构成 RAID 磁盘阵列, 其容量仅等于一块硬盘的容量, 因为另一块只是当作数据“镜像”。使用 RAID-0+1 磁盘阵列。 RAID 0+1 是 RAID 0 和 RAID 1 的组合形式。 它在提供与 RAID 1 一样的数据安全保障的同时, 也提供了与 RAID 0 近似的存储性能。
  4. 调整磁盘调度算法
    • 选择合适的磁盘调度算法, 可以减少磁盘的寻道时间

MySQL 自身的优化

  1. 对 MySQL 自身的优化主要是对其配置文件 my.cnf 中的各项参数进行优化调整。 如指定 MySQL 查询缓冲区的大小, 指定 MySQL 允许的最大连接进程数等。

应用优化

  1. 使用数据库连接池
  2. 使用查询缓存
    • 它的作用是存储 select 查询的文本及其相应结果。 如果随后收到一个相同的查询, 服务器会从查询缓存中直接得到查询结果。 查询缓存适用的对象是更新不频繁的表, 当表中数据更改后, 查询缓存中的相关条目就会被清空。

附录

什么是存储过程?有哪些优缺点?

存储过程是一些预编译的SQL语句。

更加直白的理解:存储过程可以说是一个记录集,它是由一些T-SQL语句组成的代码块,这些T-SQL语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。

存储过程是一个预编译的代码块,执行效率比较高
一个存储过程替代大量T_SQL语句 ,可以降低网络通信量,提高通信速率
可以一定程度上确保数据安全

索引是什么?有什么作用以及优缺点?

索引是对数据库表中一或多个列的值进行排序的结构,是帮助MySQL高效获取数据的数据结构

你也可以这样理解:索引就是加快检索表中数据的方法。数据库的索引类似于书籍的索引。在书籍中,索引允许用户不必翻阅完整个书就能迅速地找到所需要的信息。在数据库中,索引也允许数据库程序迅速地找到表中的数据,而不必扫描整个数据库。

MySQL数据库几个基本的索引类型:普通索引、唯一索引、主键索引、全文索引

索引加快数据库的检索速度
索引降低了插入、删除、修改等维护任务的速度
唯一索引可以确保每一行数据的唯一性
通过使用索引,可以在查询的过程中使用优化隐藏器,提高系统的性能
索引需要占物理和数据空间

什么是事务?

事务(Transaction)是并发控制的基本单位。所谓的事务,它是一个操作序列,这些操作要么都执行,要么都不执行,它是一个不可分割的工作单位。事务是数据库维护数据一致性的单位,在每个事务结束时,都能保持数据一致性。

数据库的乐观锁和悲观锁是什么?

数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。

乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。

悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作
乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。

触发器的作用?

触发器是一中特殊的存储过程,主要是通过事件来触发而被执行的。它可以强化约束,来维护数据的完整性和一致性,可以跟踪数据库内的操作从而不允许未经许可的更新和变化。可以联级运算。如,某表上的触发器上包含对另一个表的数据操作,而该操作又会导致该表触发器被触发。

索引的作用?和它的优点缺点是什么?

数据库索引,是数据库管理系统中一个排序的数据结构,以协助快速查询、更新数据库表中数据。索引的实现通常使用B树及其变种B+树。

在数据之外,数据库系统还维护着满足特定查找算法的数据结构,这些数据结构以某种方式引用(指向)数据,这样就可以在这些数据结构上实现高级查找算法。这种数据结构,就是索引。

为表设置索引要付出代价的:一是增加了数据库的存储空间,二是在插入和修改数据时要花费较多的时间(因为索引也要随之变动)。

创建索引可以大大提高系统的性能(优点):

  • 第一,通过创建唯一性索引,可以保证数据库表中每一行数据的唯一性。
  • 第二,可以大大加快数据的检索速度,这也是创建索引的最主要的原因。
  • 第三,可以加速表和表之间的连接,特别是在实现数据的参考完整性方面特别有意义。
  • 第四,在使用分组和排序子句进行数据检索时,同样可以显著减少查询中分组和排序的时间。
  • 第五,通过使用索引,可以在查询的过程中,使用优化隐藏器,提高系统的性能。

也许会有人要问:增加索引有如此多的优点,为什么不对表中的每一个列创建一个索引呢?因为,增加索引也有许多不利的方面:

  • 第一,创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增加。
  • 第二,索引需要占物理空间,除了数据表占数据空间之外,每一个索引还要占一定的物理空间,如果要建立聚簇索引,那么需要的空间就会更大。
  • 第三,当对表中的数据进行增加、删除和修改的时候,索引也要动态的维护,这样就降低了数据的维护速度。

索引是建立在数据库表中的某些列的上面。在创建索引的时候,应该考虑在哪些列上可以创建索引,在哪些列上不能创建索引。

一般来说,应该在这些列上创建索引:

  • 在经常需要搜索的列上,可以加快搜索的速度;
  • 在作为主键的列上,强制该列的唯一性和组织表中数据的排列结构;
  • 在经常用在连接的列上,这些列主要是一些外键,可以加快连接的速度;
  • 在经常需要根据范围进行搜索的列上创建索引,因为索引已经排序,其指定的范围是连续的;
  • 在经常需要排序的列上创建索引,因为索引已经排序,这样查询可以利用索引的排序,加快排序查询时间;
  • 在经常使用在WHERE子句中的列上面创建索引,加快条件的判断速度。

同样,对于有些列不应该创建索引:

  • 第一,对于那些在查询中很少使用或者参考的列不应该创建索引。这是因为,既然这些列很少使用到,因此有索引或者无索引,并能提高查询速度。相反,由于增加了索引,反而降低了系统的维护速度和增大了空间需求。
  • 第二,对于那些只有很少数据值的列也不应该增加索引。这是因为,由于这些列的取值很少,例如人事表的性别列,在查询的结果中,结果集的数据行占了表中数据行的很大比例,即需要在表中搜索的数据行的比例很大。增加索引,并不能明显加快检索速度。
  • 第三,对于那些定义为text, image和bit数据类型的列不应该增加索引。这是因为,这些列的数据量要么相当大,要么取值很少。
  • 第四,当修改性能远远大于检索性能时,不应该创建索引。这是因为,修改性能和检索性能是互相矛盾的。当增加索引时,会提高检索性能,但是会降低修改性能。当减少索引时,会提高修改性能,降低检索性能。因此,当修改性能远远大于检索性能时,不应该创建索引。

使用索引查询一定能提高查询的性能吗?为什么

通常,通过索引查询数据比全表扫描要快.但是我们也必须注意到它的代价.

索引需要空间来存储,也需要定期维护, 每当有记录在表中增减或索引列被修改时,索引本身也会被修改. 这意味着每条记录的INSERT,DELETE,UPDATE将为此多付出4,5 次的磁盘I/O. 因为索引需要额外的存储空间和处理,那些不必要的索引反而会使查询反应时间变慢.使用索引查询不一定能提高查询性能,索引范围查询(INDEX RANGE SCAN)适用于两种情况:

基于一个范围的检索,一般查询返回结果集小于表中记录数的30%
基于非唯一性索引的检索

简单说一说drop、delete与truncate的区别

SQL中的drop、delete、truncate都表示删除,但是三者有一些差别

delete和truncate只删除表的数据不删除表的结构
速度,一般来说: drop> truncate >delete
delete语句是dml,这个操作会放到rollback segement中,事务提交之后才生效;
如果有相应的trigger,执行的时候将被触发. truncate,drop是ddl, 操作立即生效,原数据不放到rollback segment中,不能回滚. 操作不触发trigger.

drop、delete与truncate分别在什么场景之下使用?

不再需要一张表的时候,用drop
想删除部分数据行时候,用delete,并且带上where子句
保留表而删除所有数据的时候用truncate

超键、候选键、主键、外键分别是什么?

超键:在关系中能唯一标识元组的属性集称为关系模式的超键。一个属性可以为作为一个超键,多个属性组合在一起也可以作为一个超键。超键包含候选键和主键。

候选键:是最小超键,即没有冗余元素的超键。

主键:数据库表中对储存数据对象予以唯一和完整标识的数据列或属性的组合。一个数据列只能有一个主键,且主键的取值不能缺失,即不能为空值(Null)。

外键:在一个表中存在的另一个表的主键称此表的外键。

什么是视图?以及视图的使用场景有哪些?

视图是一种虚拟的表,具有和物理表相同的功能。可以对视图进行增,改,查,操作,试图通常是有一个表或者多个表的行或列的子集。对视图的修改不影响基本表。它使得我们获取数据更容易,相比多表查询。

只暴露部分字段给访问者,所以就建一个虚表,就是视图。
查询的数据来源于不同的表,而查询者希望以统一的方式查询,这样也可以建立一个视图,把多个表查询结果联合起来,查询者只需要直接从视图中获取数据,不必考虑数据来源于不同表所带来的差异

说一说三个范式。

第一范式(1NF,确保每列保持原子性):数据库表中的字段都是单一属性的,不可再分。这个单一属性由基本类型构成,包括整型、实数、字符型、逻辑型、日期型等。

第一范式的合理遵循需要根据系统的实际需求来定。比如某些数据库系统中需要用到“地址”这个属性,本来直接将“地址”属性设计成一个数据库表的字段就行。但是如果系统经常会访问“地址”属性中的“城市”部分,那么就非要将“地址”这个属性重新拆分为省份、城市、详细地址等多个部分进行存储,这样在对地址中某一部分操作的时候将非常方便。这样设计才算满足了数据库的第一范式,如下表所示。

上表所示的用户信息遵循了第一范式的要求,这样在对用户使用城市进行分类的时候就非常方便,也提高了数据库的性能。

第二范式(2NF,确保表中的每列都和主键相关):数据库表中不存在非关键字段对任一候选关键字段的部分函数依赖(部分函数依赖指的是存在组合关键字中的某些字段决定非关键字段的情况),也即所有非关键字段都完全依赖于任意一组候选关键字。

第二范式在第一范式的基础之上更进一层。第二范式需要确保数据库表中的每一列都和主键相关,而不能只与主键的某一部分相关(主要针对联合主键而言)。也就是说在一个数据库表中,一个表中只能保存一种数据,不可以把多种数据保存在同一张数据库表中。

比如要设计一个订单信息表,因为订单中可能会有多种商品,所以要将订单编号和商品编号作为数据库表的联合主键,如下表所示。

这样就产生一个问题:这个表中是以订单编号和商品编号作为联合主键。这样在该表中商品名称、单位、商品价格等信息不与该表的主键相关,而仅仅是与商品编号相关。所以在这里违反了第二范式的设计原则。

而如果把这个订单信息表进行拆分,把商品信息分离到另一个表中,把订单项目表也分离到另一个表中,就非常完美了。如下所示。

这样设计,在很大程度上减小了数据库的冗余。如果要获取订单的商品信息,使用商品编号到商品信息表中查询即可。

第三范式(3NF,确保每列都和主键列直接相关,而不是间接相关):在第二范式的基础上,数据表中如果不存在非关键字段对任一候选关键字段的传递函数依赖则符合第三范式。所谓传递函数依赖,指的是如 果存在”A → B → C”的决定关系,则C传递函数依赖于A。因此,满足第三范式的数据库表应该不存在如下依赖关系: 关键字段 → 非关键字段 x → 非关键字段y

比如在设计一个订单数据表的时候,可以将客户编号作为一个外键和订单表建立相应的关系。而不可以在订单表中添加关于客户其它信息(比如姓名、所属公司等)的字段。如下面这两个表所示的设计就是一个满足第三范式的数据库表。

这样在查询订单信息的时候,就可以使用客户编号来引用客户信息表中的记录,也不必在订单信息表中多次输入客户信息的内容,减小了数据冗余。

第五讲 物理内存管理

5.1 计算机体系结构和内存层次

一个进程使用内存时要满足其要求,在不用时应及时回收。
寄存器是非常小的;内存的最小访问是8bit,一次读写32位的话也要注意对齐问题。
高速缓存如果不命中,则到内存中查找,在内存中找不到,就读取到内存中再读取,需要操作系统的介入。
内存中每一个字节有一个物理地址,硬盘中扇区512字节最小单位,我们希望将线性的物理内存空间转换成逻辑内存空间;很好的把保护(独立地址空间)和共享(访问相同内存)结合,虚拟化(实现更大的逻辑空间)。
操作系统中采用的内存管理:重定位(段地址+offset)、分段(希望他能够不连续,将程序分成三个相对独立的空间,代码数据加堆栈)、分页(把内存分成最基本的单位)。
MMU(内存管理单元)

5.2 地址空间和地址生成

物理地址空间是硬件支持的地址空间,多少位就是有多少条地址线;逻辑地址是CPU运行时进程看到的地址,对应可执行文件中的区域,进程的逻辑地址空间需要转换成物理地址空间,最后在总线上访问相应的物理单元。
逻辑地址生成:将程序转成汇编码,添加逻辑地址,再进行链接,把多个模块和函数库排成线性的序列,在程序加载要进行重定位,把链接时生成的地址进行平移。
在编译时,如果已知运行时起始地址,则可以直接生成地址,如果起始地址改变则要重新编译;在加载时也可生成绝对地址,编译器生成可重定位的代码;执行时地址生成出现在使用虚拟存储的情况下,在执行指令时进行地址转换,最灵活,可以移动指令实现虚拟内存。

CPU:ALU需要逻辑地址的内存内容,MMU进行逻辑地址和内存地址的转换,CPU控制逻辑给总线发送物理地址请求,内存发送物理地址的内容给CPU,操作系统建立逻辑地址和物理地址的映射。
CPU在执行指令时,如果访问数据段的数据,如果数据段基址+offset超过了数据段,则内存访问异常,执行失败,调用中断处理程序;如果正确那在段基址寄存器配合下得到相应的地址。

5.3 连续内存分配

为了提高效率,采用动态分配算法。
连续内存分配指给进程分配一块不小于指定大小的连续物理内存区域,会产生一些碎片,一种是两块分配单元之间的未被使用的内存,内部碎片是分配单元内部的未被使用的内存,取决于分配单元大小是否要考虑取整和对齐。
动态分区分配是指程序加载时分配一个进程指定大小可变的分区,分配得到的地址是连续的。操作系统维护两个数据结构,一个是所有进程已分配的分区,另一个是空闲分区。动态分区分配策略有很多:
最先匹配(从空闲分区列表里找第一个符合的,释放时检查是不是可以和邻近的空闲分区合并,在高地址有大块的空闲分区,但有很多外部碎片,分配大块时较慢);
最佳匹配(全找一遍,找最合适的,空闲分区按照从小往大排序,释放时跟邻近地址的合并,并且重排序,大部分分配的尺寸较小时比较好,避免大的空闲分区被拆分,减小外部碎片,但是增加了无用的小碎片);
最差匹配(找相差最大的,空闲分区从大到小拍,分配时找最大的,释放时检查可否与邻近的空闲分区合并,进行合并并重排序,如果中等大小的分配较多,则最好,避免出现太多小碎片,但是释放分区比较慢,容易破坏大的空闲分区)。

5.4碎片整理

调整已分配的进程占用的分区位置来减少或避免分区碎片,通过移动分配给进程的内存分区,以合并外部碎片。保证所有程序可动态重定位!
分区对换:通过抢占并回收处于等待状态进程的分区,以增大可用内存空间。采用对换使多个进程同时运行。

5.5 伙伴系统

连续内存分配实例。
整个可分配的分区约定为2^U,需要的分区大小为2^(U-1) < s < 2^(U),把整个块分配给这个进程。如s<2^(i-1)-1,将大小为2^i的当前分区划分成2个大小为2^(i-1)的空闲分区,重复划分过程,直到2^(i-1)-1<\s<2^(i),把一个空闲分区分配给该进程。
数据结构:空闲块按照大小和起始地址组织成二维数组,初始时只有一个大小为2^U的块,由小到大在空闲数组找最小的,如果空闲块过大,则进行二等分,直到得到需要的大小是空闲块的1/2还大些。总之,找比它大的最小的空闲块,看是不是比它的二倍大,如果是,就切块,不是的话就分配给它。合并:大小相同且地址相邻,起始地址较小的块的起始地址必须是2^(i+1)的倍数。两个块具有相同大小,且它们物理地址连续。

为了便于页面的维护,将多个页面组成内存块,每个内存块都有 2 的方幂个页,方幂的指数被称为阶 order。order相同的内存块被组织到一个空闲链表中。伙伴系统基于2的方幂来申请释放内存页。
当申请内存页时,伙伴系统首先检查与申请大小相同的内存块链表中,检看是否有空闲页,如果有就将其分配出去,并将其从链表中删除,否则就检查上一级,即大小为申请大小的2倍的内存块空闲链表,如果该链表有空闲内存,就将其分配出去,同时将剩余的一部分(即未分配出去的一半)加入到下一级空闲链表中;如果这一级仍没有空闲内存;就检查它的上一级,依次类推,直到分配成功或者彻底失败,在成功时还要按照伙伴系统的要求,将未分配的内存块进行划分并加入到相应的空闲内存块链表
在释放内存页时,会检查其伙伴是否也是空闲的,如果是就将它和它的伙伴合并为更大的空闲内存块,该检查会递归进行,直到发现伙伴正在被使用或者已经合并成了最大的内存块。

第六讲 物理内存管理: 非连续内存分配

6.1 非连续内存分配的需求背景

一种是段,一种是页,还有段页式。
非连续分配的目的是提高内存利用效率和管理灵活性:

  1. 允许一个程序使用非连续的物理地址空间;
  2. 允许共享代码与数据;
  3. 支持动态加载和动态链接。
    如何实现虚拟地址和物理地址的转换?软/硬件。

6.2 段式存储管理

段的地址空间是如何组织的,内存访问如何进行。
进程的地址空间看成若干个段,主代码段、子模块代码段、公用库代码段、堆栈段、初始化数据段、符号表等。段式管理更精细。把逻辑地址空间转换成一个不连续的物理地址空间集。
每一个段是访问方式和存储数据等属性一致的一段地址空间;对应一个连续的内存块,若干个段组成了逻辑地址空间,把逻辑地址分成一个二元组(段号,段内偏移地址),再转换成原来的地址。
程序访问物理单元时,首先用段号查段表,找到段的起始地址和长度,硬件的存储管理单元(MMU)检查越界,在MMU里利用段地址和偏移找到实际地址。

6.3 页式存储管理

物理内存空间分成“帧”,大小是2的n次幂,让这个转换变得方便,逻辑地址空间里也划分成相同大小的基本分配单位“页”,页面到页帧的转换涉及了“页表”、MMU/TLB。
物理地址组织成二元组(帧号,帧内偏移量)。逻辑地址空间也是二元组(p,o),逻辑地址中页号是连续的,物理地址的帧号是不连续的,逻辑地址中页号是p,物理地址的帧号是f,用p到页表中找对应的f,页表中保存了每个页的页表基址,用p就可以找到。每个帧的大小是2的n次方,把f左移s位再把页内偏移加上,就可以找到物理地址。

6.4 页表概述

从逻辑页号到物理页号的转换,每一个逻辑页号对应一个物理帧号,且随着程序运行变化,动态调整分配给程序的内存大小。这个表存在页表基址寄存器,告诉你这个页表放在哪。页表项中有帧号f,有几个标志位:

存在位:如果有对应的物理帧则为1;
修改位:是否修改对应页面的内容;
引用位:在过去一段时间里是否有过引用。

内存访问性能:访问一个内存单元需要2次内存访问,先获取页表项,再访问数据。
页表大小问题:页表可能非常大。
处理缓存或者间接访问(一个很长的表,多级页表等)

6.5 快表和多级页表

快表:缓存近期访问的页表项,在TLB使用关联存储实现,查找对应的key,并行查找表项,具备快速访问性能。如果没有命中只能再次查找内存中的页表并把它加到快表中。
多级页表:通过间接引用将页号分为k级。整个访问次数是k+1。建立页表树。先查第一段逻辑地址作为第一级页表的偏移,找到第二级页表的起始,第二段地址作为第二级页表项的偏移,找到第三级页表项的起始。就是说第一段地址是这个页在第一级页表中的偏移,第二段是这个页在第二级页表中的偏移地址。利用多级页表减少了整个页表的长度。

6.6 反置页表

对于大地址空间系统,多级页表变得繁琐,让页表项和物理地址空间的大小对应,不让页表项和逻辑地址空间的大小对应。这样进程数目的增加和虚拟地址空间的增大对页表占用空间没影响。
页寄存器:每个帧和一个页寄存器关联,寄存器里有:使用位表示此帧是否被使用;占用页号表明对应的页号p,保护位表明使用方式是读或者写。
页寄存器中的地址转换:CPU生成的逻辑地址如何找对应的物理地址?对逻辑地址做Hash映射,并解决Hash冲突,利用快表缓存页表项,如果出现冲突,遍历所有的对应页表项,查找失败时产生异常。

6.7 段页式存储管理

在段式管理的基础上,给每个段加一级页表,得到段的页表,再得到页的地址。

第七讲 实验二 物理内存管理

7.1 x86保护模式的特权级

x86的特权级有0,1,2,3,一般只需要0(Kernel)和3(user),有些指令只能在ring 0中执行,CPU在某些情况下也会检查特权级。
段选择子位于段寄存器中,程序在代码段中执行,指令执行会访问代码段和数据段。它的DPL位于段描述符中,来进行特权控制。中断门和陷入门中也有对应的DPL。产生中断和内存访问都有对应的CPL和RPL,进行检查确保当前的操作合法。 RPL处于数据段(DS或ES中最低两位),CPL处于指令代码段中(CS最低两位)。
数字越低特权级越高,数字越高特权级越低。
DPL是要被访问的目标的特权级。访问门时代码段的CPL要小于门的DPL,门的特权级要比较低,执行代码段的特权级比较高,这样才允许通过门(中断陷入什么的)一般特权级的应用程序可以访问处于内核态的操作系统提供的服务;访问段的时候CPL和RPL中的最大值小于DPL,即发出请求的特权级要高于对应目标,DPL的特权级要比较小。

7.2 了解特权级切换过程

通过中断切换特权级。有一个中断门,通过中断描述符表进行切换,如果产生了中断,内核态ring 0中的栈会压入一系列东西(当前执行的程序的堆栈信息SS,ESP,EFLAGS,保存了回去的地址CS,EIP等)以便恢复现场。如何回到ring3?如果是从ring0跳到ring3的,在栈中会存SS(RPL=3)和ESP,用户的ss和内核态的ss不是同一个数据段,这是特权级的转换,内核栈把数据弹出来了。通过构造一个能返回ring3的栈,再通过iret指令把相关信息弹出栈,这时候运行环境已经变成用户态。
从ring3到ring0的转换,建立中断门,一旦产生中断需要保存一些信息。通过对堆栈修改,使其执行完iret后留在ring0执行,修改CS使其指向内核态的代码段。
TSS是特殊段,任务状态段,在内存中,保存了不同特权级的堆栈信息。在全局描述符表中有一个专门指向这个TSS。硬件有一个专门的寄存器缓存TSS中的内容,建立TSS是在pmm.c中。

7.3 了解段/页表

x86内存管理单元MMU
有一系列寄存器和段描述符,寄存器里的信息最高端的十几位作为索引来找全局描述符表(GDT)里的一项,找对应的项,一项就是一个段描述符,描述了地址和基址,base address+EIP这个offset找到最终的线性地址。 如果没有页机制的话,线性地址就是物理地址。
MMU放在内存中,每次访问要先查找GDT(段表),靠硬件实现把建立在GDT里的段描述符的相关信息放在一些寄存器中的隐藏部分,缓存了基址和段大小等隐藏信息,放在CPU内部的。
在entry.S中建立了映射机制,lab1建立的是对等映射,而lab2中base_address是 -0xC0000000,虚地址比线性地址大0xC0000000.只是这个用到的映射关系(放在GDT中的信息)不同。

7.4 了解UCORE建立段/页表

一个虚拟地址它分了三块,一个典型的二级页表是32位的地址,第一个是Offset,占了12位,中间的二级页表对应的页表项占了10位,高的页目录项也占了10位。那么高的这10位是用来作为index查找这个页目录表里面的对应的项,这叫PDE,是页目录的entry,PDE记录的是二级页表里面的起始地址。所以说根据PDE里面的信息可以找到Page Table的起始地址。同时根据第二级Table这里面的10位作为index来查这个Page Table对应的项。称之为PTE。这个PTE就是Page Table Entry。存的是这个线性地址它所对应的一个页的起始地址。这一个页大小多其实由它的Offset可以算出来,12位意味着一个页的大小是4k。base_address加上offset得到了地址。
进入保护模式后段机制一定存在,为了保护。
根据地址的前10位找到Page Table的物理地址,中间12位找到PDE,计算物理页的基址。利用PDE和PTE加上offset算出地址。
CR3寄存器保存了页目录地址。 CR0的31位如果置1的话就打开了页机制。
页的基址、页表的基址都是20位,剩下12位存下了一些信息(只读?用户态或内核态)
分配一个4k的页作为页目录的Table,清理这个Page做初始化,建立页表,在页目录表和页表中填好对应信息。0xC0000000到0xF8000000这块空间会映射到物理地址的0x00000000到0x38000000这么一个地址,它的偏移值是0xC0000000,链接时用到的起始地址就是0xC0000000,把0x00000000到0x00100000映射到0x00100000的对等映射,且把CR0的31位置1,即enable了页机制,需要UPDATE GDT,使段机制的不对等映射变成对等映射,又做了取消0x00000000到0x00100000映射的操作。

第八讲 虚拟存储概念

8.1 虚拟存储的需求背景

对存储容量的需求,需要容量更大、速度更快、价格更便宜的非易失性存储器。

8.2 覆盖和交换

覆盖:在较小的内存中运行较大的程序,依据程序逻辑结构,将程序划分为若干功能独立的模块,不会同时执行的模块共享同一块内存。必要部分通常是常用功能,常驻内存,可选部分不常用只需要在用到时装入内存。不存在调用关系的部分共享一部分内存。将程序分成多组,每组按照这一组里最大的内存进行分配。开发难度增加,由程序员进行模块划分,确定模块间的覆盖关系;也增加了执行时间,从外存装入覆盖模块。
交换:增加正在运行或需要运行的程序的内存,将暂时不运行的程序放到外存。这是以进程为单位的交换技术。只有当内存空间不够或有不够的可能时才换出。交换区是用来存放所有用户进程的所有内存映像的拷贝。程序换入时采用动态地址映射的方法,重定位。

8.3 局部性原理

把内存中的信息放到外存中来需要准备工作。只把部分程序放到内存中,从而运行比物理内存大的程序,操作系统自动加载而不需要程序猿干预。实现进程在内外存之间的交换,从而获得更多的空闲内存空间。
局部性原理:所谓局部性原理呢是指程序在执行的过程当中在一个较短的时间里,它所执行的指令和指令操作数的地址分别局限于在一定区域里,因为通常情况下我们指令是存在代码段里的,指令所访问的操作数呢通常是存在数据段里的,这两个各是一个地方,那这两个的地方分别局限在一定区域里头。

  1. 第一个叫时间局部性,也就是说我一条指令的连续两次执行和一个数据的连续两次访问通常情况下都集中在一段较短的时间里;
  2. 空间局部性,我相邻的几条指令访问的相邻的几个数据通常情况下是局限在一个较小的区域里头;
  3. 叫分支局部性,一条跳转指令的两次执行很多时候是会跳转到同一个地址的。
    如果能判断他们局部的地区在哪,就可以充分利用这种局部性,虚拟存储也具有可行性。

8.4 虚拟存储概念

将不常用的内存块暂存到外存。
装载程序时只需将当前指令所需要的页面加载到内存,指令执行中需要的指令或数据不在内存时处理器通知操作系统将相应的页面调入内存。
基本特征:

  1. 不连续性:物理内存分配非连续,虚拟地址空间使用非连续;
  2. 大用户空间:提供给用户的虚拟内存可以大于实际的物理内存;
  3. 部分交换:只对部分虚拟地址空间进行调入和调出。

硬件支持:页式或短时存储的地址转换机制。
操作系统:管理内存和外存页面或段的换入换出。

8.5 虚拟页式存储

在页式存储管理的基础上增加请求调页和页面置换。当用户程序要装载到内存中时只装入部分页面就启动程序运行,进程在发现运行中需要的代码或数据不在内存中时,发送缺页异常请求,操作系统在处理缺页异常时将外村中相应的页面调入内存,使进程能继续运行。需要一个缺页异常的处理例程。
造成的修改:原来以逻辑页号为序号就可以找到物理帧号,有了这个物理页帧号之后,就能转换出相应的物理地址。现在增加一些标志位:

  1. 驻留位:它是表示该页面是否在内存当中,如果是1表示在内存当中,此时一定可以找到它的页帧号,可以转换成物理内存单元的地址;如果它是0,表示这一页在外存中这时候就会导致缺页。
  2. 修改位:表示这一页在内存当中是否被修改,这必须是驻留位有效的情况下。这一页如果被修改过,若想把这一页淘汰,必须把内存当中修改的内容写回到外存当中。
  3. 访问位:表示是否被访问过,用于页面置换算法;
  4. 保护位:可读可写可执行等。

在32位x86系统中,有12位的页内偏移,两个10位的二级页表项,物理地址也是32位,其中20位是物理页帧号。这时使用二级页表。页表项的起始地址是CR3,一个页表项四字节,4k为一页,一页里有1024页表项,刚好是10位。
地址转换:先是一级页表项里头的页号到以及页表中,作为它的偏移找到相应的页表项。这个页表项里有一个第二级页表项的物理页号,这时再加上第二级的页号,第二级页表项里 以它页号作为偏移找到相应的页表项,这时就是要访问的物理页面的物理帧号,帧号和偏移加在一起得到你的物理地址。
变化的是页表项内部的东西:前20位的物理页帧号无变化,后边的标志位有变化。用户态标志U表示是否可以在用户态访问;保留位AVL;WT位写出到缓存还是直接写出到内存,CD缓存是否有效。

8.6 缺页异常

在CPU要访问一条指令,load M,去找M对应的表项,如果M无效,抛出异常调用缺页异常服务例程。首先找到对应的一页在外存中的位置,找到了且有空闲页则读进来并修改对应的页表项。
如果空闲页没找到,则根据页面替换算法找到被替换的物理页帧,再判断这个物理页帧是否修改过,如果修改过,就写回。修改各种驻留位。重新执行产生缺页的指令。
外存管理:在何处保存未被映射的页?外存中有对换区。
虚拟页式存储中的外存选择:代码段直接指向可执行文件;动态加载的共享库指向动态库文件;其他段就可以放到对换区中。
有效存储访问时间:访存时间*(1-p) + 缺页异常处理时间*缺页率p

第九讲 页面置换算法

9.1 页面置换算法的概念

出现缺页异常时,调入新页面且内存已满时置换页面。尽可能减少页面调入调出次数。把近期不再访问的页面调出。有些页面必须常驻内存,或是操作系统的关键部分,或是要求响应速度的页面,加上一个锁定位。

局部页面置换:置换页面的选择仅限于当前进程占用的物理页面;最优算法、先进先出、最近最久未使用
全局置换算法:选择所有可换出的物理页面

9.2 最优算法、先进先出算法和最近最久未使用算法

  1. 最优算法:缺页时计算内存中每个页面的下一次访问时间,选择未来最长时间不被访问的页面。缺页次数最少,但无法实现,无法预知每个页面在下次访问的间隔时间。可以作为置换算法的评测依据。
  2. 先进先出算法:选择在内存中驻留时间最长的页面进行置换。维护一个记录所有位于内存中的逻辑页面链表,链表元素按照驻留内存时间排序,链首时间最长。出现缺页时把链首页面进行置换,新加的页面加到链尾。性能差,调出的页面可能是经常访问的,可能出现belady现象。
  3. 最近最久未使用算法:选择最长时间没有被引用的页面进行替换,如果某些页面长时间未访问,那在未来可能也不访问。缺页时计算每个逻辑页面上次访问时间。

LRU可能的实现:

  1. 页面链表。系统维护一个按最近一次访问时间排序的页面链表,链表首节点是最近刚刚使用过的页面,尾节点是最久未使用的页面。访问内存时,找到相应页面并将其移动到链表之首,缺页时替换尾节点的页面。
  2. 活动页面栈,访问时将页号压入栈顶,并将栈内相同页号抽出,缺页时置换栈底页面。开销大!

9.3 时钟置换算法和最不常用算法

  1. 时钟置换算法:对页面访问进行大致统计,过去一段时间访问过就不管它,如果没访问过就按照时间踢出去。先对数据结构做了一些改动,页表项里增加了一个访问位,用来描述在过去一段时间里这个页是否被访问过,把这些页面组织成一个环形链表,定义指针在环形链表上进行周期性的循环,这也是我们这个时钟这个词的。指针指向最先调入的页面。访问页面时在页表项中记录页面访问,缺页时从指针处开始顺序查找未被访问的页面进行置换。

    装入页面时访问位初始化为0,访问时页面置为1,缺页时,从指针当前顺序检查环形链表,访问位为0则置换,访问位为1,则访问位置为0,指针移动到下一个页面,直到找到可替换的页面。

  2. 改进的Clock算法:减少修改页的缺页处理开销。在页表项中加入修改位,并在访问时进行修改,缺页时,修改页面标志位,跳过有修改的页面。如果访问位和修改位都是0,那就直接替换。访问1修改0的改成访问0修改0访问1修改1的改成访问0修改1,改修改标志的时候并不写出,由系统执行写出。主要修改时考虑了修改的页面,推迟了被修改页面的替换。

  3. 最不常用算法(LFU):每个页面设置一个访问计数,访问页面时访问次数加一,缺页时置换计数最小的页面。可能有开始常用但是之后不常用的,这时需要定期对计数器进行衰减。LRU关注多久未访问,LFU关注访问次数。

9.4 BELADY现象和局部置换算法比较

belady现象是指采用FIFO等算法时,可能出现随着分配的页面增加,缺页次数反而升高的现象。原因是FIFO算法的置换特征与进程访问内存的动态特征矛盾,被他置换出去的页面并不一定是进程近期不会访问的。LRU是没有belady现象的。类似于栈的算法(LRU)一般不会有belady现象。
比较:

LRU依据页面的最近访问时间排序,动态调整;
FIFO依据页面进入内存时间排序,页面进入时间固定不变;
CLOCK是折中,页面访问时不动态调整页面在链表中的顺序,缺页时再把它移动到链表末尾。

9.5 工作集置换算法

全局置换算法之一:工作集置换算法
为进程非配可变数目的物理页面。进程的内存需求时有变化,分配给进程的内存也要在不同阶段变化,全局置换算法需要确定分配给进程的物理页面数量。
CPU利用率和并发进程的关系:

随着并发进程增加CPU利用率增加;
但是之后随着内存吃紧,利用率下降;
进程数少时提高并发进程数,可以提高CPU利用率;
并发进程导致了内存访问增加;
并发进程的内存访问会降低访存的局部性特征,导致了缺页率上升。

工作集是进程当前使用的逻辑页面集合,表示为二元函数(t, delta),t是当前执行时刻,delta是工作集窗口,代表定长页面访问时间窗口。W(t, delta)是当前时刻t前的delta时间窗口的所有访问页面组成的集合。
工作集变化:

进程开始执行时,随着访问新页面逐步建立稳定的工作集;
当内存访问的局部性区域位置大致稳定时,工作及大小也逐步稳定;
局部性区域改变位置时,工作集快速扩张和收缩过渡到下一个稳定值。

令全局置换算法与工作集变化曲线相拟合。
常驻集是进程实际驻留内存的页面集合工作集是进程在运行中的固有属性,而常驻集是取决于系统分配给进程的物理页面数目和页面置换算法
常驻集如果包含了工作集,缺页率比较小;工作集发生剧烈变动时,缺页较多;进程常驻集达到一定大小之后,缺页率也不会明显下降。

工作集置换算法
换出不在工作集中的页面。维护一个访存页面链表,访存时换出不在工作集的页面,更新访存链表,缺页时换入页面,更新访存链表。

  • 工作集的大小是变化的。
  • 相对比较稳定的阶段和快速变化的阶段交替出现。
  • 根据局部性原理,进程会在一段时间内相对稳定在某些页面构成的工作集上。
  • 当局部性区域的位置改变时,工作集大小快速变化。
  • 当工作集窗口滑过这些页面后,工作集又稳定在一个局部性阶段。
  • 工作集精确度与窗口尺寸 ∆ 的选择有关。如果 ∆ 太小,那么它不能表示进程的局部特征;如果 ∆ 为无穷大,那么工作集合是进程执行需要的所有页面的集合。
  • 如果页面正在使用,它就落在工作集中;如果不再使用,它将不出现在相应的工作集中。
  • 工作集是局部性原理的近似表示。
  • 如果能找出一个作业的各个工作集,并求出其页面数最大者,就可估计出该进程所需的物理块数。
  • 利用工作集模型可以进行页面置换。工作集页面置换法的基本思想:找出一个不在工作集中的页面,把它淘汰。

9.6 缺页率置换算法

缺页率:缺页次数与内存访问次数的比值,或缺页平均时间间隔的倒数,受到页面置换算法、分配给进程的物理页面数目、页面大小和程序本身的影响。缺页率随着物理页面的增加而降低。
通过调节常驻集的大小,使每个进程的缺页率保持在合理范围内,若进程缺页率过高,则增加常驻集以分配更多物理页面,若进程缺页率过低,则减少常驻集以给其他进程分配更多物理页面。
方法:访存时设置引用位标志,出现缺页时计算从上次缺页时间到现在时间的时间间隔,如果隔的时间比较长,则置换这段时间被没有被引用的页,认为这段时间的缺页率比较低;如果这段时间大于特定的值,则认为这段时间的缺页率较高,则增加常驻集。
进程驻留在内存中的页面是有变化的。与前边的工作集算法的区别主要在于缺页率置换把置换放到缺页中断中完成

9.7 抖动和负载控制

抖动是指进程物理页面较少,不能包含工作集,造成大量缺页,频繁置换,使进程运行速度变慢。主要原因是随着驻留内存进程数目不断增加,分配给每个进程的物理页面数量不断减少,缺页率不断上升。因此,操作系统需要在并发数目和缺页率之间达到一个平衡,选择适当的进程数目和进程需要的物理页面数。
通过调节并发进程数来进行系统负载均衡。
平均缺页间隔时间(MTBF) 是否等于 缺页异常处理时间(PFST)。间隔大于处理时间则处理是可以完成的,比较好。

第十讲 实验三 虚拟内存管理

10.1 实验目标:虚存管理

有关虚拟内存管理。提供给比实际物理内存空间更大的虚拟内存空间。完成Page Fault异常和FIFO页替换算法。

10.2 回顾历史和了解当下

Lab1 完成了保护模式和段机制的建立,完成了中断机制,可以输出字符串。
中断描述符表寄存器存了中断门,记录了当产生一个中断时用哪个例程处理这个中断。一旦产生中断,根据它的编号找到IDT,记录了一个offset和一个选择子,这个选择子作为一个索引来查找另外一个表GDT全局描述符表(段表),找到基址,这个基址加上offset形成了中断服务例程的入口地址。
Lab2完成物理内存管理,查找物理内存,建立基于连续物理内存空间的动态内存分配与释放算法,完成了页机制的建立。
页表的起始地址放在CR3寄存器中,页目录表中每一项是一个页目录项,其中的address指向对应页表的起始地址,对页表项,存放着物理页页帧的起始地址,加上页内偏移形成最终地址。
初始化函数在kern_init中,vmm_init。关键数据结构:vma_struct和mm_struct。swap.c和swap.h中有相应说明。

10.3 处理流程、关键数据结构和功能

swap_init:如何建立交换分区并完成以页为单位的硬盘读写。
vmm_init:分配一定物理页,如何建立模拟访问机制访问特定虚拟页。

10.4 页访问异常

产生页访问异常时,调用_alltrap的trap进行处理,调用pgfault_handler,进一步调用do_pgfault,建立一个使用者的虚拟环境,根据缺页异常的地址查找,看是不是硬盘中的一个页,把这一页读到内存中,建立映射关系,这样可以正确访问内存了。重新执行产生缺页异常的指令。

10.5 页换入换出机制

应该换出哪个页?在kern/mm/swap.c中有具体说明。建立虚拟页和磁盘扇区的对应关系:用到了swap_entry_t,其中有24bit代表磁盘扇区的编号,虚拟页编号在页表的index中,磁盘扇区的index可以写到页表项(PTE)中,虚拟页和磁盘扇区的对应也可以放到页表项中。 页表项多了一个功能,是虚拟页和磁盘扇区的对应关系,如果present位是0,代表没有映射关系,不存在物理页和虚拟页帧的对应关系,这样就可以代表虚拟页和硬盘扇区的关系。
页替换算法:FIFO、Clock等。
何时进行页换入换出:主动、被动。

第十一讲 进程和线程

11.1 进程的概念

进程是一个具有一定功能的程序在一个数据集合中的一次动态执行过程。源代码到可执行文件再到加载到进程地址内存空间(堆、栈、代码段)。进程浩瀚了正在运行的一个程序的所有状态的信息,进程是由:

  • 代码
  • 数据
  • 状态寄存器:CPU状态CR0、指令指针IP等
  • 通用寄存器:AX、BX、CX…
  • 进程占用系统资源:打开文件、已分配内存

特点:

  • 动态性:动态创建
  • 并发性:独立调度并占用处理机运行
  • 独立性:不同进程相互工作不影响
  • 制约性:因访问共享数据和资源或进程间同步产生制约

进程是处于运行状态程序的抽象,程序是一个静态的可执行文件,进程是执行中的程序,是程序+执行状态;同一个程序的多次执行过程对应不同进程;进程执行需要内存和CPU。
进程是动态的,程序是动态的,程序是有序代码的集合,进程是程序的俄执行,进程有核心态和用户态;进程是暂时的,程序是永久的,进程的组成包括程序数据进程控制块

11.2 进程控制块(PCB)

是操作系统控制进程运行的信息集合。操作系统用PCB来描述进程的基本情况和运行变化的过程。PCB是进程存在的唯一标志

  • 进程创建:生成该进程的PCB;
  • 进程终止:回收PCB;
  • 进程的组织管理:通过对PCB的组织管理实现。

进程控制块内容:

  • 进程标识信息
  • 处理机现场保存:从进程地址空间抽取PC、SP、其他寄存器保存
  • 进程控制信息:调度和状态信息(调度进程和处理机使用情况)、进程间通信信息(通信相关的标识)、存储管理信息(指向进程映像存储空间数据结构)、进程所用资源(进程使用的系统资源,文件等)、有关数据结构链接信息(与PCB有关的进程队列)

进程控制块的组织:

  • 链表:同一状态的进程其PCB组织成一个链表,多个状态对应不同链表;
  • 索引表:同一状态的进程归入一个索引表,由索引指向PCB,多个状态对应多个不同的索引表。

11.3 进程状态

操作系统为了维护进程执行中的变化来维护进程的状态。进程的生命周期分为:

  • 进程创建:创建PCB、拷贝数据。引起进程创建主要有:系统初始化、用户请求创建进程、正在执行的进程执行了创建进程的调用;
  • 进程就绪:放入等待队列中等待运行;
  • 进程执行:内核选择一个就绪进程,占用处理机并执行;
  • 进程等待:进程执行的某项条件不满足,比如请求并等待系统服务、启动某种操作无法马上完成,只有进程自身知道何时需要等待某种事件的发生
  • 进程抢占:高优先级的进程就绪或进程执行时间片用完;
  • 进程唤醒:被阻塞的进程需要的资源可满足,进程只能被别的进程或操作系统唤醒;
  • 进程结束:把进程执行占用的资源释放,有几种可能:正常、错误退出、致命错误、强制退出。

N个进程交替运行,假定进程1执行sleep(),内核里调用计时器,进程1把当前进程占用寄存器的状态保存,切换进程2,如果计时器到点了,计时器产生中断,保存进程2的状态,恢复进程1的状态。

11.4 三状态进程模型

核心是:

  • 就绪:进程获得了除了处理机之外的所有资源,得到处理机即可运行;
  • 运行:进程正在处理机上执行;
  • 等待:进程在等待某一事件在等待。

辅助状态两种:

  • 创建:一个进程正在被创建,还未被转到就绪状态之前的状态;
  • 结束:进程正在从系统中消失的状态,这是因为进程结束或其他原因所导致。

状态转换:

  • 创建 -> 就绪:进程被创建并完成初始化,变成就绪状态;
  • 就绪 -> 运行:处于就绪状态的进程被调度程序选中,分配到处理机上运行;
  • 运行 -> 结束:进程表示它已经完成或因为出错,当前运行今晨会由操作系统作结束处理;
  • 运行 -> 就绪:处于运行状态的进程在其运行过程中,由于分配给它的处理机时间片用完而让出处理机;
  • 运行 -> 等待:当进程请求某资源且必须等待时;
  • 等待 -> 就绪:进程等待的某事件到来时,它从阻塞状态变到就绪状态;

11.5 挂起进程模型

处于挂起状态的进程映像在磁盘上,目的是减少进程占用内存。

  • 等待挂起:进程在外存并等待某事件的发生;
  • 就绪挂起:进程在外存,但是只要进入内存即可运行;
  • 挂起:把进程从内存转到外存

增加了内存的转换:

  • 等待 -> 等待挂起:没有进程处于就绪状态或就绪状态要求更多内存资源;
  • 就绪到就绪挂起:有高优先级等待进程(系统认为很快就绪)和低优先级就绪进程;
  • 运行 -> 就绪挂起:对抢先式分时系统,当有高优先级等待挂起进程因事件出现而进入就绪挂起;

从外存转到内存的转换:激活

  • 就绪挂起 -> 就绪:没有就绪进程或挂起就绪进程优先级高于就绪进程;
  • 等待挂起 -> 等待:进程释放了足够内存,并有高优先级的等待挂起进程;

状态队列:有操作系统维护一组队列,表示系统所有进程的当前状态。
根据进程状态不同,进程PCB加入不同队列,进程状态切换时,加入不同队列。

11.6 线程的概念

  • 为什么要引入线程
    在进程内部增加一类实体,满足实体之间可以并发执行且实体之间可以共享相同的地址空间。线程是进程的一部分,描述指令流执行状态,它是进程中指令执行流的最小单元,是CPU调度的单位。这种剥离为并发提供了可能,描述了在进程资源环境中的指令流执行状态;进程扮演了资源分配的角色。
    原来只有一个指令指针,现在有多个堆栈和指令指针。线程=进程-共享资源。
    但是如果一个线程有异常,会导致其所属进程的所有线程都崩。
  • 比较
  • 进程是资源分配单位,线程是CPU调度单位;
  • 进程有一个完整的资源平台,线程只独享指令流执行的必要资源,如寄存器和栈;
  • 线程具有就绪、等待和运行三种基本状态和其转移关系;
  • 线程能减少并发执行的时间和空间开销:
  1. 线程创建时间短;
  2. 线程的终止时间比进程短;
  3. 同一进程的线程切换时间比进程短;
  4. 由于同一进程的各个线程共享内存和文件资源,可不通过内核进行直接通信。

11.7 用户线程

三种实现方式:

  • 用户线程:在用户空间实现,通过用户级的线程库函数完成线程的管理。在操作系统内核中仍然只有进程控制块来描述处理机的调度的情况,操作系统并不感知应用态有多线程的支持,多线程的支持是用户的函数库支持的。在应用程序内部通过构造相应的线程控制块
    来控制一个进程内部多个线程的交替执行和同步。
    这种方法不依赖操作系统内核,用于不支持线程的多线程的操作系统。每个进程有私有的线程控制块(TCB),TCB由线程库函数维护;同一进程的用户线程切换速度快,无需用户态/核心态的切换,且允许每个进程有自己的线程调度算法。
    缺点就是不支持基于线程的处理机抢占, 除非当前运行的线程主动放弃CPU,他所在进程的其他线程无法抢占CPU。
    POSIX Pthreads、Math C-threads、Solaris threads
  • 内核线程:在内核中实现,Windows、Solaris、Linux
  • 轻量级进程:在内核中实现,支持用户进程。

11.8 内核线程

内核通过系统调用完成的线程机制。由内核维护PCB和TCB,线程执行系统调用而阻塞不影响其他线程,以线程为单位的进程调度会更合理。
轻权进程:内核支持的用户线程,一个进程有多个轻量级进程,每个轻权进程由一个单独的内核线程来支持。在内核支持线程,轻权进程来绑定用户线程。
用户线程与内核线程的对应关系:一对一、多对一、多对多。

第十二讲 进程控制

12.1 进程切换

上下文切换,暂停当前运行的进程,从当前运行状态转变成其他状态,调度另一个进程从就绪状态变成运行状态,在此过程中实现进程上下文的保存和快速切换。维护进程生命周期的信息(寄存器等)。

进程控制块PCB:内核为每个进程维护了对应的PCB,将相同状态的进程的PCB放置在同一个队列中。

ucore中的进程控制块结构proc_struct:

  • 进程ID、父进程ID,组ID;
  • 进程状态信息、地址空间起始、页表起始、是否允许调度;
  • 进程所占用的资源struct mm_struct* mm;
  • 现场保护的上下文切换的context;
  • 用于描述当前进程在哪个状态队列中的指针,等。

ucore的切换流程:开始调度 -> 清除调度标志 -> 查找就绪进程 -> 修改进程状态 -> 进程切换switch_to()。
switch_to用汇编写成。。。

12.2 进程创建

Windows进程创建API:CreateProcess
Unix进程创建系统调用:fork/exec,fork()把一个进程复制成两个进程,exec()用新程序重写当前进程。

fork()的地址空间复制:fork调用对子进程就是在调用时间对父进程地址空间的一次复制。执行到fork时,复制一份,只有PID不同。系统调用exec()加载新程序取代当前运行的程序。加载进来后把代码换掉。

ucore中的do_fork:分配进程控制块数据结构、创建堆栈、复制内存数据结构、设置进程标识等。操作系统没有新的任务执行,则创建空闲进程,在proc_init中分配idleproc需要的资源,初始化idleproc的PCB。

fork的开销昂贵,在fork中内存复制是没用的,子进程将可能关闭打开的文件和连接,所以可以将fork和exec联系起来。产生了vfork,创建进程时不再创建一个同样的内存映像,用时再加载,一些时候称为轻量级fork。这时子进程应立即执行exec。现在使用写时复制技术。

12.3 进程加载

应用程序通过exec加载可执行文件,允许进程加载一个完全不同的程序,并从main开始执行。不同系统加载可执行文件的格式不同,并且允许进程加载时指定启动参数(argc,argv),exec调用成功时,它与运行exec之前是相同的进程,但是运行了不同的程序,且代码段和堆栈重写。主要是可执行文件格式的识别,有sys_exec、do_execv、load_icode函数。

ucore中第一个用户态进程是由proc_init创建的,执行了init_main创建内核线程,创建了shell程序。

12.4 进程等待与退出

父子进程的交互,完成子进程的资源回收。

子进程通过exit()向父进程返回一个值,父进程通过wait()接受并处理这个返回值。wait()父进程先等待,还是子进程先做exit(),这两种情况会导致它下面的处理有一些区别。

如果有子进程存活,也就是说父进程创建的子进程还有子进程,那这时候父进程进入等待状态,等待子进程的返回结果,父进程先执行wait,等到子进程执行的时候它执行exit(),这是exit ()是在wait之后执行的。这时候,子进程的exit()退出,唤醒父进程,父进程由等待状态回到就绪状态,父进程就处理子进程的返回的这个返回值,这是wait在前exit()在后的情况。

如果不是这样那就有一种情况,就是有僵尸子进程等待,就是子进程先执行exit(),这时它返回一个值,等待父进程的处理,exit()在前,如果子进程还一直处在这个等待的状态,在这里等待父进程的处理,父进程的wait就直接返回,如果有多个的话就从其中一个返回它的值。

进程的有序终止exit(),完成资源回收。

  • 调用参数作为进程的结果;
  • 关闭所有打开的文件等占用资源;
  • 释放内存,释放进程相关的内核数据结构;
  • 检查父进程是否存活,如存活则保留结果的值直到父进程需要他。
  • 清理所有等待的僵尸进程。

第十三讲 实验四 内核线程管理

13.1 总体介绍

了解内核线程创建执行的管理过程。了解内核线程的切换和基本调度过程,对TCB充分了解。

13.2 关键数据结构

struct proc_struct:TCB

  • pid和name代表了标识符。
  • state、runs、need_reshed代表了状态和是否需要调度
  • cr3不太需要,因为共用进程的页表
  • kstack代表了堆栈
  • mm_struct不太需要,在ucore的统一管理下
  • context是通常说的上下文,基本都是寄存器,代表了当前线程的状态
  • trap_frame代表中断产生时的信息(硬件保存)、段寄存器的信息(软件保存)
  • 一些list,父进程的信息和线程控制块的链表
  • 基于hash的list,查找对应的线程比较快

13.3 执行流程

kern_init最开始初始化,proc_init完成一系列的创建内核线程并执行。

创建第0号内核线程idleproc:

  • alloc_proc创建TCB的内存块
    -init idle_proc,设置pid、stat、kstack等

创建第1个内核线程:

  • initproc:
  • keep trapframe调用了do_fork,copy_thread等,如何跳到入口正确执行?是用户态还是内核态?
  • init_proc
  • init kernel stack,可以放到两个list中执行了
  • 开始调度执行
  • 找到线程队列中哪个是处于就绪的,切换switch kstack、页表、上下文,根据trapframe跳到内核线程的入口地址,开始执行函数。

13.4 实际操作

关注proc_init创建第0、1号线程。switch_to完成两个内核线程的切换。

第十四讲 实验五 用户进程管理

14.1 总体介绍

第一个用户进程如何创建、进程管理的实现机制、系统调用的框架实现。
构造出第一个用户进程:建立用户代码/数据段 —-> 创建内核线程 —-> 创建用户进程“壳” —-> 填写用户进程 —-> 执行用户进程(特权级转换) —-> 完成系统调用 —-> 结束用户进程(资源回收)

14.2 进程的内存布局

内核虚拟内存布局有一部分是对实际物理空间的映射,0xC0000000到0xF8000000,映射为物理空间。一个Page Tabel,0xFAC00000到0xB0000000,一开始只是管理内核空间的映射关系,有了用户进程后,页表需要扩展。

进程虚拟内存空间:
Ivalid Memory
User Stack——————0xB0000000
………..
User Program & Heap—-0x00800000
Invalid Memory
User STAB Data(optional,调试信息)
Invalid Memory

Invalid Memory一旦访问为非法,确保访问到这些是产生page fault,使之不能随意访问。

14.3 执行ELF格式的二进制代码-do_execve的实现

do_execve建好一个壳并把程序加载进来。本实验用到一个PCB(process control block),其实是跟上一个实验的TCB一样的。

首先,把之前的内存空间清空,只留下PCB,换成自己的程序。把cr3这个页表基址指向boot_cr3内核页表;把进程内存管理区域清空,对应页表清空,导致内存没有了;load_icode加载执行程序。

14.4 执行ELF格式的二进制代码-load_icode的实现

前边已经把内存管理清空了,先创建一个新内存管理空间mm_create和新页表setup_pgdir;填上我执行代码的内容,找到要加载的程序的代码段和数据段,根据代码段和数据段的虚拟地址通过mm_map完成对合法空间的建立;从程序的内存区域拷贝过来,建立物理地址和虚拟地址的映射关系;准备all_zero的内存;设置相应堆栈空间(用户态空间),使用mm_map建立;把页表的起始地址换成新建好的页表的起始地址。

完成trapframe的设置。trapframe保存了打断的中断状态保存,完成特权级转变,从kernel转换到user。

x86特权级:从ring 0 —-> ring 3,一个ring 0栈空间,构造一个信息使得执行iret时能回到用户态,重新设置ss和cs,从ring0到ring3。

用户进程有两个栈,用户栈和内核栈,通过系统调用转化。

14.5 进程复制

父进程如何构造子进程?
一个函数叫do_fork,是一个内核函数,完成用户空间的拷贝。首先,父进程创建进程控制块,初始化kernel stack,分配页空间和内核里的虚地址。copy_mm为新进程建立新虚存空间。copy_range拷贝父进程的内存到新进程。拷贝父进程的trapframe到新进程。添加新的proc_struct到proc_list并唤醒新进程。执行完do_fork后父进程得到子进程的pid,子进程得到0。

14.6 内存管理的copy-on-write机制

进程A通过do_fork创建进程B,二者重用一段空间,使得空间占用量大大减少,如果是只读的话没问题。一旦某进程做了写操作,因为页表设置成只读,则产生page_fault,触发copy-on-write机制,真正为子进程复制页表。进程创建的开销大大减小,且有效减少空间。

一个物理页可能被多个虚拟页引用,这个个数很重要,因为在进程运行时可能会出现换入换出,如何进行有效换入换出,有可能那个页既在内存中也在虚存中。

dup_mmap完成内存管理的复制。

处理机调度

处理机调度概念

进程切换是CPU资源的当前占用者的切换,保存当前进程在PCB中的执行上下文(CPU状态),恢复下一个进程的执行上下文。

处理机调度是从就绪队列中找一个占用CPU的进程,从多个可用CPU中挑选就绪进程可使用的CPU资源。

调度准则

调度时机

操作系统维护进程的状态序列。进程从运行状态切换到等待状态,这样CPU就空闲了,或者进程被终结了,CPU又空闲了。这两种情况对应着非抢占系统,当前进程主动放弃CPU。对可抢占系统,中断请求被服务例程响应完成,或当前进程因为时间片用完时会被抢占,进程从等待切换到就绪,这时更急迫的想占用CPU,也会发生抢占。

调度策略

进程在CPU计算和IO操作间交替,在时间片机制下,进程可能在结束当前CPU计算之前就被迫放弃CPU。

CPU使用率:CPU处于忙状态的时间百分比。

吞吐率:单位时间内完成的进程数量

周转时间:进程从初始化到结束(包括等待)的时间

等待时间:进程在就绪队列中的时间

响应时间:从提交请求到产生相应所花费的时间

调度算法希望“更快”的服务。

响应时间目标:

  • 减少相应时间,及时处理输入请求
  • 减少平均响应时间的波动,提高可预测性
  • 低延迟调度改善了交互体验

吞吐量目标:

  • 增加吞吐量,减少开销(操作系统开销,上下文切换)
  • 系统资源的高效利用(CPU、IO)
  • 减少等待时间,提高响应性能和吞吐量性能
  • 吞吐量是系统的计算带宽

公平性目标:

  • 保证每个进程占用相同的CPU时间
  • 公平通常会增加响应时间

先来先服务、短进程优先和最高响应比优先调度算法

先来先服务

按照就绪队列的先后顺序排列,进程进入等待或结束状态时,就绪队列中的下一个进程占用CPU。

周转时间:每个进程的平均总时间(等待+执行)

优点:简单,排队依据容易获得。

缺点: 平均等待时间波动大,排队位置对算法影响大,IO和CPU资源利用效率低。

短进程优先

考虑进程的特征,选择就绪队列中执行时间最短进程占用CPU进入运行状态。它具有最好的平均周转时间。

但可能导致饥饿,连续的短进程会使长进程无法获得CPU资源。且需要预知未来,可以用历史执行时间预估未来的执行时间。

最高响应比优先

考虑进程在就绪队列中的等待时间。选择就绪队列中响应比R最高的进程。R = (w + s) / s,w是等待时间,s是执行时间。这种算法基于短进程优先算法,不可抢占,关注了进程等待时间,以防止无限等待。

时间片轮转、多级反馈队列、公平共享调度算法和ucore调度框架

时间片轮转

时间片是分配处理机资源的基本时间单元,各个进程占用一个时间片,仍按照先来先服务策略,时间片结束时按照先来先服务切换到下一个就绪进程,每隔(n-1)个时间片进程执行一个时间片。

时间片太大的话,等待时间过长,退化成先来先服务;若太短,产生了大量上下文切换,影响系统吞吐量。

这时需要选择一个合适的时间片长度。

多级反馈队列

就绪队列排成多个子队列,不同队列可以有不同算法,进程可以在队列之间转换。队列间的调度可以采用时间片方法。

多级反馈队列:进程在不同队列间移动的多级队列算法。时间片大小随优先级级别增加而增加,如进程在当前的时间片没有完成,则降到下一个优先级。CPU密集型的进程优先级下降很快,这样时间片会增大,IO密集型的则优先级上升。

公平共享调度算法

注重资源访问的公平,一些用户比另一些用户重要,保证不重要的组无法垄断资源。未使用的资源按照比例分配,没有达到资源使用率目标的组获得更高的优先级。

uCore的调度队列run_queue

1
2
3
4
5
6
struct run_queue{
list_entry_t run_list;
unsigned int proc_num;
int max_time_slice;
list_entry_t rq_link;
}

实时调度和多处理器调度

实时调度对时间有要求,实时操作系统的正确性以来其时间和功能两方面,其性能指标是时间约束的及时性。

周期实时任务:一系列相似任务,任务有规律的重复,周期p=任务请求时间间隔,执行时间e=最大执行时间,使用率U=e/p。

硬实时是指错过任务时限会导致灾难性或非常严重的后果,必须验证,在最坏情况下能满足时限。软实时是指尽量满足任务时限。

可调度性:一个实时操作系统能满足任务时限要求。需要确定实时任务的执行顺序。静态/动态优先级调度。

速率单调调度算法(静态):通过周期安排优先级,周期越短优先级越高,执行周期最短的任务;

最早截止时间优先算法(动态):截止时间越早优先级越高,执行截止时间最早的任务。

多处理器调度

针对多个处理机,一条系统总线连接多个物理CPU,一个CPU可能有几个逻辑CPU,处理机之间可以负载共享。

对阵多处理机(SMP)调度:每个处理器运行自己的调度程序,调度程序对共享资源的访问需要同步。

静态进程分配:进程开始执行到结束都被分配到一个固定的处理机上,每个处理机都有自己的就绪队列,调度开销小,但各个处理机可能忙闲不均。

动态进程分配:进程在执行中可以分配到任意空闲处理机执行,所有处理机共享一个公共的就绪队列,调度开销大,各个处理机的负载是均衡的。

优先级反置

操作系统中出现高优先级进程长时间等待低优先级进程所占用的资源,而导致高优先级进程长时间等待的现象。

优先级继承:占用资源的低优先级进程继承申请资源的高优先级进程的优先级。只有占有资源的低优先级进程被阻塞时才能提高占有资源进程的优先级。

优先级天花板协议:占有资源进程的优先级和所有可能申请该资源的进程的最高优先级相同,不管是否发生等待,都提升占有资源进程的优先级。优先级高于系统中所有被锁定的资源的优先级上限,任务执行临界区就不会被阻塞。

实验六 调度器

16.1 总体介绍和调度过程

在lab5中,完成了用户进程的管理。lab6中完成了调度的初始化和调度过程。
实现一个调度类,绑定调度类(类似于多态或重载),设定调度点,触发调度时间,调整调度参数和调用调度算法,实现选择新进程和完成进程切换。

把当前进程放到就绪队列中,在就绪队列中选取一个适合的进程,出队然后完成切换。

16.2 调度算法支撑框架

调度点:出发做调度相关的工作

位置 原因
proc.c:do_exit 用户线程执行结束,主动放弃CPU
proc.c:do_wait 用户线程等待着子进程结束,主动放弃CPU
proc.c:init_main Init_porc内核线程等待所有用户进程结束;所有用户进程结束后回收系统资源
proc.c:cpu_idle idleproc内核线程等待处于就绪态的进程或线程,如果有选择一个并切换
sync.h:lock 进程无法得到锁,则主动放弃CPU
trap.c:trap 修改当前进程时间片,若时间片用完,则设置need_resched为1,让当前进程放弃CPU

进入/离开就绪队列的机制:

  • 抽象数据结构,可以不是队列;
  • 可根据调度算法的需求采用多种数据结构

schedule是一个总控函数,如果当前进程是 RUNNABLE会调用sched_class_enqueue,放到就绪队列中。

16.3 时间片轮转调度算法(RR调度算法)

前边介绍完成一个sched_class,

RR_init{
list_init;
run_queue->proc_num = 0;
}

在产生时钟中断时调用
RR_proc_tick{
if(proc->time_slice > 0)
proc->time_slice —;
if(proc->time_slice == 0)
proc->need_resched = 1;
}
一旦标志位为1,则说明需要调度了

当有一个进程需要进队列,则调用list_add_before,如果要选择一个进程,则选择一个尾list_next

16.4 Stride调度算法

如果有三个进程,每个进程有2个属性,stride表示现在执行到什么地方,数字大小表示执行进度;pass表示一次前进的步数。

选择当前步长最小的一个进程,执行目标是当前步长加path。

它是基于优先级的且每一步的调度策略是特定的。

可以使用priority_queue实现,又可以用Skew heap(斜堆)的优先队列实现。

stride在不停累加下如何正确判断最大最小?uint32_t!

第十七讲 同步互斥

背景

独立进程:不和其他进程共享资源或状态,具有确定性(输入决定结果);可重现(能够重现起始条件);调度顺序不重要。

并发进程:多个进程之间有资源共享;不确定性;不可重现。某些情况下调度的不一致会造成结果的不一致,也可能出现不可重现性。程序错误也可能是间歇性发生的。

进程需要与计算机中的其他进程和设备合作。有几个好处:

  1. 共享资源。多个用户使用同一个计算机;
  2. 提高速度。IO和计算可以重叠;程序可划分为多个模块放在多个处理器上并行执行;
  3. 模块化。将大程序分解成小程序。

并发创建新进程时的标识分配:程序调用fork()创建进程,操作系统需要分配一个新的且唯一的进程ID,在内核中,这个系统调用会执行new_pid = next_pid++

原子操作是一次不存在任何中断或失败的操作。要么成功要么不执行,不会出现部分执行的情况。操作系统需要利用同步机制在并发执行的同时,保证一些操作是原子操作。

现实生活中的同步问题

利用原子操作实现一个锁。

  • Lock.Acquire()
    • 在锁被释放前一直等待,然后获得锁;
    • 如果两个线程都在等待同一个锁,那如果锁被释放了,只有一个进程能得到锁
  • Lock.Release()
    • 解锁并唤醒任何等待中的进程。
  • 过程:
    • 进入临界区
    • 操作
    • 退出临界区

进程之间的交互关系:相互感知程度。

  • 相互不感知(完全不了解其他进程):独立
  • 间接感知(双方与第三方交互):通过共享合作
  • 直接感知(直接交互,如通信):通过通信合作

可能会出现如下几种:

  • 互斥:一个进程占用,则其他进程不能使用
  • 死锁:多个进程各自占用部分资源,形成循环等待
  • 饥饿:其他进程轮流占用资源,一个进程一直得不到资源

临界区和禁用硬件中断同步方法

临界区是互斥执行的代码,进入区检查进程进入临界区的条件是否成立,进入之前设置相应“正在访问临界区”的标志;退出区清除“正在访问临界区”标志。

临界区访问规则:

  • 空闲则入:没有进程在临界区时任何进程可以进入;
  • 忙则等待:有进程在临界区,则其他进程均不能进入临界区;
  • 有限等待:等待进入临界区的进程不能无线等待;
  • 让权等待:不能进入临界区的进程,需要及时释放CPU;

实现方法:

  • 禁用硬件中断:没有中断和上下文切换,因此没有并发,硬件将中断处理延迟到中断被启用之后,现在计算机体系结构都提供指令来实现禁用中断,进入临界区时禁止所有中断,退出临界区时使能所有中断。这种办法有局限性,关中断之后进程无法停止,也可能导致其他进程处于饥饿状态;临界区可能很长,无法确定相应中断所需的时间。

基于软件的同步方法

  • 软件方法:两个线程,T0和T1,线程可以通过共享一些共有变量来同步行为。
    • 采用共享变量,设置一个共享变量表示允许进入临界区的线程;
    • 设置一个共享变量数组,描述每个变量是否在临界区中,先判断另一个线程的flag是否是1,如果可以进入了,设置自己的flag;可能会同时等待或同时进入;
    • Peterson算法:turn表示该哪个进程进入临界区,flag[]表示进程是否准备好进入临界区。在进入区进程i要设置flag[i]=true,且turn=j,判断(flag[i] && turn==j),如果j没有申请进入,则i直接进去没问题。如果j也申请了,看谁先向trun里写数据,谁先写谁进入,由总线仲裁决定先后顺序!
    • N线程时,采用Eisenberg和McGuire算法,采用一个处理循环。
    • 基于软件的方法很复杂,是一个忙等待

高级抽象的同步方法

  • 借用操作系统的支持采用更高级的抽象方法,例如,锁、信号量等,用硬件原语来实现
  • 锁:一个二进制变量(锁定,解锁),Acquire和Release,使用锁控制临界区访问。
  • 原子操作指令:CPU体系结构中一类特殊的指令,把若干操作合成一个原子操作,不会出现部分执行的情况
    • 测试和置位(TS),从内存中读取,测试值是否为1并返回T/F,内存单元置为1。
    • 交换指令:交换内存中的两个值。

使用TS指令实现自旋锁:

1
2
3
4
5
6
7
8
9
10
class Lock {
int value = 0;
}
Lock::Acquire() {
while(test_and_set(value))
; // spin
}
Lock::Release() {
value = 0;
}

用TS指令把value读出来,向里边写入1。

  • 如果锁被释放,那么TS指令读取0并将值设置为1
    • 锁被设置为忙并且需要等待完成
  • 如果锁处于忙状态,那么TS指令读取1并将指令设置为1
    • 不改变锁的状态并且需要循环

无忙等待锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Lock {
int value = 0;
WaitQueue q;
}
Lock::Acquire() {
while(test_and_set(value)){
add this TCP to wait queue
schedule();
}
}
Lock::Release() {
value = 0;
remove one thread t from q
wakeup(t)
}

原子操作指令锁的特征:

  • 优点:
    • 适用于单处理器或共享内存的多处理器中任意数量的进程
    • 支持多临界区
  • 缺点:
    • 忙等待的话占用了CPU时间
    • 可能导致饥饿,进程离开临界区时有多个等待进程的话?
    • 可能死锁,低优先级的进程占用了临界区,但是请求访问临界区的高优先级进程获得了处理器并等待临界区。

第十八讲 信号量与管程

信号量

多线程的引入导致了资源的竞争,同步是协调多线程对共享数据的访问,在任何时候只能有一个线程执行临界区代码。

信号量是操作系统提供的协调共享资源访问的方法,软件同步是平等线程间的一种同步协商机制。信号量是由OS负责管理的,OS作为管理者,地位高于进程。用信号量表示一类资源,信号量的大小表示资源的可用量。

信号量是一种抽象数据类型,由一个整型变量(共享资源数目)和两个原子操作组成。

  • P()(荷兰语尝试减少)
    • sem减一
    • 如sem<0,进入等待,否则继续
  • V()(荷兰语增加)
    • sem加一
    • 如sem<=0,唤醒一个等待进程

信号量是被保护的整型变量,初始化完成后只能通过PV操作修改,是由操作系统保证PV操作是原子操作的。

P操作可能阻塞,V操作不会阻塞。P操作中sem可以等于0,但是如果小于0的话,说明我没有资源了,把这个进程放入等待队列,并且阻塞。退出时执行V操作,如果sem++后还小于0,则说明还有等着的,就把一个进程唤醒开始执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Semaphore{
int sem;
WaitQueue q;
}
Semaphore::P(){
sem --;
if(sem<0){
Add this thread t to q;
block(p)
}
}
Semaphore::V(){
sem++;
if(sem<=0){
remove a thread t from q;
wakeup(t)
}
}

它的原子性是操作系统保证的,执行不会被打断。

信号量使用

两种:二进制信号量,资源数目是0或1;资源信号量,资源数目为任意非负值。

一种是临界区的互斥访问。每类资源设置一个信号量,对应一个临界区,信号量初值为1,

1
2
3
4
5
mutex = new Semaphore(1)

mutex->P();
Critical Section
mutex->V()

第一个进程进来之后,mutex是0了,第二个进程再执行到P操作时,mutex变成-1,则会等待。第一个进程执行结束后,执行V操作,-1变成0,这时候唤醒第二个进程。

必须成对使用P()和V()操作。P()保证互斥访问,V()操作保证使用后及时释放。

一种是条件同步,初值设置为0。事件出现时设置为1。这个事件就相当于是一种资源。

1
condition = new Semaphore(0)

生产者-消费者:一个或多个生产者在生成数据后放在缓冲区总,单个消费者从缓冲区中取出数据,任何时刻只能有一个生产者或消费者可访问缓冲区(互斥关系),也就是缓冲区是一个临界区。缓冲区空时必须等待生产者(条件同步),缓冲区满时生产者必须等待消费者(条件同步)。

三个信号量:二进制信号量mutex描述互斥关系;资源信号量fullBuffer和emptyBuffer代表了条件同步关系。

刚开始时缓冲区都是空的,所以fullBuffers为0,emptyBuffers为n

1
2
3
4
5
class BounderBuffer{
mutex = new Semaphore(1);
fullBuffers = new Semphore(0);
emptyBuffers = new Semphore(n);
}

mutex实现了对缓冲区的互斥访问,但是只是这样是不够的,先检查是否有空缓冲区,有的话则检查是否有另外的消费者占用缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BounderBuffer::Deposit(c){
emptyBuffers->P();
mutex->P();
Add c to the buffer
mutex->V();
fullBuffers->V();//生产者写了之后就释放一个资源
}
BounderBuffer::Remove(c){
fullBuffers->P();
mutex->P();
Remove c from buffer
mutex->V();
emptyBuffers->V();//消费者用了一个之后释放一个
}


管程

在管程内部使用了条件变量,管程是一种用于多线程互斥访问共享资源的程序结构,采用了面向对象的方法,简化了线程间的同步控制,在任意时刻最多只有一个线程执行管程代码。正在管程中的线程可临时放弃管程的互斥访问,等待事件出现时恢复。

收集现在要同步的进程之间共享的数据,放到一起处理。在入口加一个互斥访问的锁,任何一个线程到临界区后排队,挨个进入。管理共享数据的并发访问。需要共享资源时对应相应的条件变量,使用管程中的程序。

条件变量是管程内的等待机制,进入管程的线程因资源占用而进入等待,每个条件变量表示一种等待原因,对应一个等待队列。两个操作:

  • Wait():将自己阻塞到等待队列中,唤醒一个等待者或释放管程的互斥访问。
  • Signal():将等待队列中的一个线程唤醒;如果等待队列为空,则相当于空操作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    Class Condition{
    int numWaiting = 0;
    WaitQueue q;
    }
    Condition::Wait(lock){
    numWaiting ++;
    Add this thread t to q;
    release();
    schedule();
    require(lock);
    }
    Condition::Signal(){
    if(numWaiting > 0){
    Remove a thread t from q;
    wakeup(t);
    numWaiting --;
    }
    }

    numWaiting为正表示有线程处于等待状态;把它自己放到等待队列中,释放管程使用权,开始调度。在Signal中,把一个进程从等待队列中拿出来,开始执行,numWaiting减一,等待的线程数目减少。

用信号量解决生产者-消费者问题的话,生产者消费者各对应一个函数,其他地方要使用的话直接调用这两个函数即可。首先放到一个管程里,这是由管程进入的申请和释放,如果没有空的,就在条件变量上等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class BoundedBuffer{
...
Lock lock;
int count = 0;
Condition notFull, notEmpty;
}
BoundedBuffer::Deposit(c){
lock->Acquire();
while(count == n)
notFull.Wait(&lock);
Add c to the buffer;
count ++;
notEmpty.Signal();
lock->Release();
}
BoundedBuffer::Remove(c){
lock->Acquire();
while(count == 0)
notEmpty.Wait(&lock);
Remove c from buffer;
count --;
notFull.Signal();
lock->Release();
}

管程可以把PV操作集中在一个函数里。

哲学家就餐问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define N 5
semphore fork[N];
void philosopher(int i){
while(TRUE){
think();
if(i%2 == 0){
P(fork[i]);
P(fork[(i+1)%N]);
} else{
P(fork[(i+1)%N]);
P(fork[i]);
}
eat();
V(fork[i]);
V(fork[(i+1)%N]);
}
}

读者-写者问题

共享数据的两种使用者:读者只读取数据,不修改;写者读取和修改数据。

有三种情况:

  • 读读允许:同一时刻允许多个读者同时读
  • 读写互斥:没有读者时写者才能写,没有写者时读者才能读
  • 写写互斥:没有其他写者时写者才能写

用信号量描述每个约束。信号量WriteMutex是控制读写操作的互斥,初始化为1.读者计数Rcount是对正在读操作的读者数目,初始化为0。信号量CountMutex控制对读者计数的互斥修改,初始化为1。
Writer:

1
2
3
P(WriteMutex);
write();
V(WriteMutex);

Reader:
1
2
3
4
5
6
7
8
9
10
11
12
P(CountMutex);
if(Rcount == 0)
P(WriteMutex);
++Rcount;
V(CountMutex);
read();
P(CountMutex);
--Rcount;
if(Rcount == 0)
V(WriteMutex);
++Rcount;
V(CountMutex);

管程实现读者-写者问题:

1
2
3
4
5
6
7
Database::Read(){
StartRead();
//Wait until no writers;
read database;
DoneRead();
//checkout - wakeup waiting writers;
}

1
2
3
4
5
Database::Write(){
Wait until no reader/writer;
write database;
checkout - wakeup waiting reader/writer
}

状态变量。正在读和正在写只有一个大于等于0
1
2
3
4
5
6
AR = 0;  # of active reader
AW = 0; # of active writer
WR = 0; # of waiting reader
WW = 0; # of waiting writer
Lock lock;
Condition okToRead, okToWrite

1
2
3
4
5
6
7
8
9
10
Private Database::StartRead(){
lock.Acquire();
while(AW + WW > 0){//写者优先
WR++;
okToRead.wait(&lock);
WR--;
}
AR++;
lock.Release()
}

1
2
3
4
5
6
7
Private Database::DoneRead(){
lock.Acquire();
AR --;
if(AR==0 && WW>0) //没有读者,写者在等
okToWrite.Signal();
lock.Release();
}

1
2
3
4
5
6
7
8
9
10
Private Database::StartWrite(){
lock.Acquire();
while(AW + AR > 0){//有正在写的写者或正在读的读者
WW++;
okToWrite.wait(&lock);
WW--;
}
AW++;
lock.Release()
}

1
2
3
4
5
6
7
8
9
Private Database::DoneWrite(){
lock.Acquire();
AW --;
if(WW>0) //写者优先
okToWrite.Signal();
else if(WR > 0)
okToRead.broadcase();
lock.Release();
}

第十九讲 实验七 同步互斥

总体介绍

底层支撑

定时器:进程睡眠,进入等待状态(do_sleep)。可以添加一个timer。

时钟中断时会遍历timer链表,看哪个进程的定时器到期了。

1
2
3
4
5
typedef struct{
unsigned int expires;
struct proc_struct* proc;
list_entry_t timer_link;
} timer_t;

屏蔽中断完成了互斥的保护,使得这个进程不会被调度或打断。有一个Eflag寄存器,有一个bit叫做Interrupt Enable Flag,这个flag如果置成1,当前允许中断,置成0表示不允许中断。两个指令CLI和STI分别屏蔽中断和使能中断。uCore中使用local_intr_savelocal_intr_restore封装。

等待项和等待队列:

1
2
3
4
5
6
7
8
9
typedef struct {
struct proc_struct* proc;
uint32_t wakeup_flags;//等待的原因
wait_queue_t* wait_queue;//等待项在哪个队列中
list_entry_t wait_link;
} wait_t
typedef struct {
list_entry_t wait_head;
} wait_queue_t;

信号量设计实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Semaphore{
int sem;
WaitQueue q;
}
Semaphore::P(){
sem --;
if(sem<0){
Add this thread t to q;
block(t);
}
}
Semaphore::V(){
sem++;
if(sem<=0){
Remove a thread t from q;
wakeup(t);
}
}

管程和条件变量设计实现

1
2
3
4
5
6
typedef struct monitor{
semaphore_t mutex;
semaphore_t next;
int next_count;
condvar_t *cv;
}

哲学家就餐问题

第十九讲 实验七 同步互斥

第二十讲 死锁和进程通信

死锁概念

由于竞争资源或通信关系,两个或更多线程在执行中弧线,永远相互等待只能由其他进程引发的事件。

进程访问资源的流程:资源类型有R1、R2、R3等,每类资源Ri有Wi个实例,进程访问资源时先申请空闲的资源,再占用,最后释放资源。

可重用资源是不能被删除且在任何时刻都只能有一个进程使用,一个进程释放之后其他进程就可以使用了,比如CPU,文件、数据库等,可以被多个进程交替使用。可能出现死锁。

消耗资源:一个进程创建,并有其他进程使用,比如消息等,可能出现死锁。

资源分配图描述了资源和进程之间的分配和占用关系,是一个有向图。一类顶点是系统中的进程,另一类顶点是资源;一类有向边是资源请求边,另一类有向边是资源分配边。如果有循环等待的话,就会出现死锁。但是有循环也可能不会出现死锁。

出现死锁的条件:

  • 互斥:任何时刻只能由一个进程使用一个资源实例,如果资源是共享的不会互斥的则不会死锁;
  • 持有并等待:进程保持至少一个资源并正在等待获取其他进程持有的资源;
  • 非抢占:资源只在进程使用后自愿放弃,不可以强行剥夺;
  • 循环等待:存在等待进程集合,0等1,1等2,。。。n-1等n,n等0,类似这样。

死锁处理方法

  • 死锁预防:确保系统永远不会进入死锁状态,四个必要条件的任何一个去掉都可以避免死锁,但是这样的话资源利用率低;
  • 死锁避免:在使用前进行判断,只允许不会出现死锁的进程请求资源;
  • 死锁检测和恢复:在检测到死锁后,进行恢复;
  • 通常由应用进程来处理死锁,操作系统忽略死锁的存在。

死锁预防:采用某种机制,限制并发进程对资源的请求,使系统不满足死锁的必要条件。

  • 比如可以把互斥的共享资源封装成可以同时访问的,比如打印机,加上缓冲区,在打印机内部协调先后;
  • 持有并等待,进程请求资源时,不能占用其他任何资源,想申请资源时,必须把全部资源都申请到,也可以在进程开始执行时一次请求所有需要的资源,资源利用效率低;
  • 非抢占:如进程请求不能立即分配的资源,则立即释放自己已占有的资源,只有能同时获取到所有需要资源时,才执行分配操作;
  • 循环等待:对资源排序,进程需要按照顺序请求资源,可能先申请的资源后续才用到;

死锁避免:利用额外的先验信息,在分配资源时判断是否会出现死锁,如果可能会出现死锁,则不分配。要求进程声明资源需求的最大数目,限定提供与分配的资源数目,确保满足进程的最大需求,且动态检查资源分配状态,确保不会出现死锁。

进程请求资源时,系统判断是否处于安全状态。

  • 针对所有已占用进程,存在安全序列;
  • 序列是安全的,则Pi要求的资源<=当前可用资源+所有Pj持有资源(j<\i),如果Pi的资源不能立即分配,则要等待。

银行家算法

判断并保证系统处于安全状态。

  • n=线程数量,m=资源类型数量;
  • Max(总需求量):n*m矩阵,线程Ti最多请求类型Rj的资源Max[i,j]个实例
  • Available(剩余空闲量):长度为m的向量,当前有Available[i]个类型Ri的资源实例可用
  • Allocation(已分配量):n*m矩阵,线程Ti当前分配了Allocation[i,j]个Rj的实例
  • Need(未来需求量):n*m矩阵,线程Ti未来需要Need[i,j]个Rj资源实例;
  • Need[i,j]=Max[i,j]-Allcation[i,j]

安全状态判断:

  1. Work 和 Finish 分别是长度为 m 和 n 的向量初始化: Work = Available,Finish = false for i = 1,2,…,n
  2. 寻找线程 Ti ,Finish[i] = false,Need[i] <= Work,找到 Need 比 Work 小的线程 i ,如果没有找到符合条件的 Ti ,转4
  3. Work = Work + Allocation[i] ,Finish[i] = true,线程i的资源需求量小于当前系统剩余空闲资源,所以配置给他再回收。转2
  4. 如果所有线程Ti满足Finish[i]=true,则系统处于安全状态。
  5. 这种迭代循环到最后,则是安全的

初始化:Requesti:线程Ti的资源请求向量,Requesti[j]:线程Ti请求资源Rj的实例

循环:

  1. 如果Requesti < Need[i],转到2,否则拒绝资源申请,因为县城已经超过了其最大要求;
  2. 如果Requesti <= Available,转到3,否则Ti必须等待,因为资源部可用;
  3. 通过安全状态判断是否分配资源给Ti,生成一个需要判断状态是否安全的资源分配环境:
    • Available=Available-Requesti
    • Allocation[i] = Allocation[i]+Requesti
    • Need[i] = Need[i]-Requesti

死锁检测

允许系统进入死锁状态,并维护一个资源分配图,周期性调用死锁检测算法,如果有死锁,就调用死锁处理。

  • Available:长度为m的向量,表示每种类型可用资源的数量;
  • Allocation:一个n*m矩阵,表示当前分配给各个进程每种类型资源的数量,当前Pi拥有资源Rj的Allocation[i,j]个实例。

死锁监测算法:

  1. Work是系统中的空闲资源量,Finish时线程是否结束。Work = Available,Allocation[i] > 0时,Finish[i] = false;否哦则Finish[i] = true;
  2. 寻找线程Ti满足Finish[i] = false且Requesti <= Work,线程没结束且能满足线程资源请求量。
  3. Work = Work + Allocation[i],Finish[i] = true,转到2。
  4. 如果某个Finish[i] = false,则系统会死锁。

死锁检测的使用:

  • 多长时间检测一次
  • 多少进程需要回滚
  • 难以分辨造成死锁的关键进程

死锁恢复:

  • 终止所有的死锁进程
  • 一次终止一个进程,看还会不会死锁
  • 终止进程的顺序应该是
    • 进程优先级
    • 进程已运行的时间和还需运行的时间
    • 进程已占用资源
    • 进程完成所需要的资源
    • 进程终止数目
    • 进程是交互还是批处理
      方法
    • 选择被抢占的资源
    • 进程回退

进程通信(IPC)概念

IPC提供两个基本操作:

  • 发送:send(message)
  • 接收:recv(message)

流程:

  • 建立通信链路
  • 通过send/recv交换

通信方式:

  • 间接通信:在通信进程和内核之间建立联系,一个进程把信息发送到内核的消息队列中,另一个进程读取,接受发送的时间可以不一样。通过操作系统维护的消息队列通信,每个消息队列有一个唯一的标识,只有共享了相同消息队列的进程,才能够通信。

    • 链接可以单向,也可以双向
    • 每对进程可以共享多个消息队列
    • 创建消息队列、通过消息队列收发消息、撤销消息队列
    • send(A, message)、recv(A, message),A是消息队列
    • 阻塞发送是发送方发送后进入等待,直到成功发送
    • 阻塞接受是接收后进入等待,直到成功接受
    • 非阻塞发送是发送方发送后返回
    • 非阻塞接受是没有消息发送时,接收者在请求接受消息后,接受不到消息。
  • 直接通信:两个进程同时存在,发方向共享信道里发送,收方读取。进程必须正确的命名接收方。

    • 一般自动建立链路
    • 一条链路对应一对通信进程
    • 每对进程之间只有一个链路存在
    • 链路可能单向,也可以双向

进程发送的消息在链路上可能有三种缓冲方式:

  • 0容量:发送方必须等待接收方
  • 有限容量:通信链路缓冲队列满了,发送方必须等待
  • 无限容量:发送方不需等待

信号和管道

信号是进程间软件中断通知和处理机制,如果执行过程中有意外需要处理,则需要信号,Ctrl-C可以使进程停止,这个处理是通过信号实现。如SIGKILL,SIGSTOP等。

信号的接收处理:

  • 捕获:执行进程指定的信号处理函数被调用
  • 忽略:执行操作系统的缺省处理,例如进程终止和挂起等
  • 屏蔽:禁止进程接受和处理信号,可能是暂时的。

传送的信息量小,只有一个信号类型,只能做快速的响应知己。

  1. 首先进程启动时注册相应的信号处理例程到操作系统;
  2. 其他程序发出信号时,操作系统分发信号到进程的信号处理函数;
  3. 进程执行信号处理函数。

管道:进程间基于内存文件的通信机制,内存中建立一个临时文件,子进程从父进程继承文件描述符,缺省文件描述符:0 1 2

进程不知道另一端,可能时从键盘、文件等。

系统调用:

  • 读管道read(fd,buffer,nbytes)
  • 写管道write(fd,buffer,nbytes)
  • 创建管道pipe(rgfd),rgfd时两个文件描述符组成的数组,rgfd[0]是读文件描述符,rgfd[1]是写文件描述符。利用继承的关系在两个进城之间继承文件描述符。

消息队列和共享内存

消息队列是操作系统维护的字节序列为基本单位的间接通信机制,若干个进程可以发送到消息队列中,每个消息是一个字节序列,相同标识的消息组成先进先出顺序的队列。
系统调用如下:

  • msgget(key,flags):获取消息队列标识
  • msgsnd(QID,buf,size,flags):发送消息
  • msgrcv(QID,buf,size,flags):接收消息

消息队列独立于进程,进程结束了之后消息队列可以继续存在,实现两个不同生命周期的进程之间的通信。

共享内存是把同一个物理内存区域同时映射到多个进程的内存地址空间的通信机制。每个进程都有私有内存地址空间,需要明确设置共享内存段。同一进程的线程总是共享相同的内存地址空间。

实验一

操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果)

make “V=”看到了所有的编译命令

第178行 create ucore.img,可以看到call函数,

totarget = $(addprefix $(BINDIR)$(SLASH),$(1))

这样就调用了addprefix,把$(BINDIR)$(SLASH)变成$(1)的前缀,在makefile里再把$(1)调用call变成要生成的文件,这里需要bootblock和kernel。

bootblock需要一些.o文件,makefile里的foreach有如下格式:$(foreach < var >,< list >,< text >)

这个函数的意思是,把参数< list >;中的单词逐一取出放到参数< var >所指定的变量中,然后再执行< text>;所包含的表达式。每一次< text >会返回一个字符串,循环过程中,< text >的所返回的每个字符串会以空格分隔,最后当整个循环结束时,< text >所返回的每个字符串所组成的整个字符串(以空格分隔)将会是foreach函数的返回值。

  • 通过看makefile生成的编译命令,生成bootasm.o需要bootasm.S
1
gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o

参考:

  • -ggdb 生成可供gdb使用的调试信息。这样才能用qemu+gdb来调试bootloader or ucore。
  • -m32 生成适用于32位环境的代码。我们用的模拟硬件是32bit的80386,所以ucore也要是32位。
  • -gstabs 生成stabs格式的调试信息。这样要ucore的monitor可以显示出便于开发者阅读的函数调用
  • -nostdinc 不使用标准库。标准库是给应用程序用的,我们是编译ucore内核,OS内核是提供服务的,所以所有的服务要自给自足。
  • -fno-stack-protector 不生成用于检测缓冲区溢出的代码。这是for 应用程序的,我们是编译内核,ucore内核好像还用不到此功能。
  • -Os 为减小代码大小而进行优化。根据硬件spec,主引导扇区只有512字节,我们写的简单bootloader的最终大小不能大于510字节。
  • -I< dir > 添加搜索头文件的路径
1
ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o

参考:

  • -m 模拟为i386上的连接器
  • -nostdlib 不使用标准库
  • -N 设置代码段和数据段均可读写
  • -e 指定入口
  • -Ttext 制定代码段开始位置
1
2
3
4
5
6
7
8
9
10
11
kernel = $(call totarget,kernel)

$(kernel): tools/kernel.ld

$(kernel): $(KOBJS)
@echo + ld $@
$(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
@$(OBJDUMP) -S $@ > $(call asmfile,kernel)
@$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)

$(call create_target,kernel)

编译命令:

1
gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trapentry.S -o obj/kern/(o文件)

链接器:
1
ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o obj/kern/libs/stdio.o obj/kern/libs/readline.o obj/kern/debug/panic.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/picirq.o obj/kern/driver/intr.o obj/kern/trap/trap.o obj/kern/trap/vectors.o obj/kern/trap/trapentry.o obj/kern/mm/pmm.o  obj/libs/string.o obj/libs/printfmt.o

dd:用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换。

注意:指定数字的地方若以下列字符结尾,则乘以相应的数字:b=512;c=1;k=1024;w=2
参数注释:

  • if=文件名:输入文件名,缺省为标准输入。即指定源文件。< if=input file >
  • of=文件名:输出文件名,缺省为标准输出。即指定目的文件。< of=output file >
  • ibs=bytes:一次读入bytes个字节,即指定一个块大小为bytes个字节。
  • obs=bytes:一次输出bytes个字节,即指定一个块大小为bytes个字节。
  • bs=bytes:同时设置读入/输出的块大小为bytes个字节。
  • cbs=bytes:一次转换bytes个字节,即指定转换缓冲区大小。
  • skip=blocks:从输入文件开头跳过blocks个块后再开始复制。
  • seek=blocks:从输出文件开头跳过blocks个块后再开始复制。
    • 注意:通常只用当输出文件是磁盘或磁带时才有效,即备份到磁盘或磁带时才有效。
  • count=blocks:仅拷贝blocks个块,块大小等于ibs指定的字节数。
  • conv=conversion:用指定的参数转换文件。
    • ascii:转换ebcdic为ascii
    • ebcdic:转换ascii为ebcdic
    • ibm:转换ascii为alternate ebcdic
    • block:把每一行转换为长度为cbs,不足部分用空格填充
    • unblock:使每一行的长度都为cbs,不足部分用空格填充
    • lcase:把大写字符转换为小写字符
    • ucase:把小写字符转换为大写字符
    • swab:交换输入的每对字节
    • noerror:出错时不停止
    • notrunc:不截短输出文件
    • sync:将每个输入块填充到ibs个字节,不足部分用空(NUL)字符补齐。

生成一个有10000个块的文件,用0填充(答案中说,每个块默认512字节,但是可能要有bs参数指定或者bs默认就是512?)

1
dd if=/dev/zero of=bin/ucore.img count=10000

把bootblock中的内容写到第一个块

1
dd if=bin/bootblock of=bin/ucore.img conv=notrunc

从第二个块开始写kernel中的内容

1
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc

一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?

上课讲过,合法的主引导扇区最后两个字节有特定值
0x55、0xAA

1
2
3
buf一共512个字节
buf[510] = 0x55;
buf[511] = 0xAA;

练习2:

1
2
3
4
5
file bin/kernel
set architecture i8086
target remote :1234
b *0x7c00
continue

在gdb中输入命令,输出2条instruction

1
x /2i $pc

跟bootasm.S里的汇编代码一致!amazing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
(gdb) x /2i $pc
=> 0x7c00: cli
0x7c01: cld
(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

在Makefile的debug选项中加入-d in_asm -D q.log,可以生成一个q.log里边是执行的汇编命令(部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
----------------
IN:
0xfffffff0: ljmp $0xf000,$0xe05b

----------------
IN:
0x000fe05b: cmpl $0x0,%cs:0x6c48
0x000fe062: jne 0xfd2e1

----------------
IN:
0x000fe066: xor %dx,%dx
0x000fe068: mov %dx,%ss

----------------
IN:
0x000fe06a: mov $0x7000,%esp

----------------
IN:
0x000fe070: mov $0xf3691,%edx
0x000fe076: jmp 0xfd165

练习3

分析bootloader进入保护模式的过程。(要求在报告中写出分析)
BIOS将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行bootloader。请分析bootloader是如何完成从实模式进入保护模式的。

1
lab1/boot/bootasm.S

类似之前,从0x7c00进入,首先
1
2
3
4
5
6
7
8
9
10
.globl start
start:
.code16
cli ;禁止中断发生
cld ;CLD与STD是用来操作方向标志位DF。CLD使DF复位,即D
;F=0,STD使DF置位,即DF=1.用于串操作指令中。
xorw %ax, %ax ;ax置0
movw %ax, %ds ;其他寄存器也清空
movw %ax, %es
movw %ax, %ss

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

开启A20:到了80286,系统的地址总线有原来的20根发展为24根,这样能够访问的内存可以达到2^24=16M。Intel在设计80286时提出的目标是向下兼容。所以,在实模式下,系统所表现的行为应该和8086/8088所表现的完全一样,也就是说,在实模式下,80286以及后续系列,应该和8086/8088完全兼容。但最终,80286芯片却存在一个BUG:因为有了80286有A20线,如果程序员访问100000H-10FFEFH之间的内存,系统将实际访问这块内存,而不是象8086/8088一样从0开始。为了解决上述兼容性问题,IBM使用键盘控制器上剩余的一些输出线来管理第21根地址线(从0开始数是第20根) 的有效性,被称为A20 Gate:

如果A20 Gate被打开,则当程序员给出100000H-10FFEFH之间的地址的时候,系统将真正访问这块内存区域;

如果A20 Gate被禁止,则当程序员给出100000H-10FFEFH之间的地址的时候,系统仍然使用8086/8088的方式即取模方式(8086仿真)。绝大多数IBM PC兼容机默认的A20 Gate是被禁止的。现在许多新型PC上存在直接通过BIOS功能调用来控制A20 Gate的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
seta20.1:               
inb $0x64, %al ;0x64里的数据放到al中,即从I/O端口读取一个字节(BYTE,;HALF-WORD)
testb $0x2, %al ;检测
jnz seta20.1 ;等到这个端口不忙,没有东西传进来

movb $0xd1, %al ; 0xd1 写到 0x64
outb %al, $0x64 ;写8042输出端口

seta20.2:
inb $0x64, %al
testb $0x2, %al
jnz seta20.2 ;等不忙

movb $0xdf, %al ;打开A20 0xdf -> port 0x60
outb %al, $0x60 ;0xdf = 11011111

初始化GDT表并打开保护模式
1
2
3
4
5
lgdt gdtdesc		   ;让CPU读取gdtr_addr所指向内存内容保存到GDT内存当中
movl %cr0, %eax ;cr0寄存器PE位or置1
orl $CR0_PE_ON, %eax
movl %eax, %cr0
ljmp $PROT_MODE_CSEG, $protcseg ;长跳改cs,基于段机制的寻址

最后初始化堆栈、寄存器,调用bootmain
1
2
3
4
5
6
7
8
9
10
11
12
13
protcseg:
# 初始化寄存器
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment

# Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)
movl $0x0, %ebp
movl $start, %esp
call bootmain

练习四

对于bootmain.c,它唯一的工作就是从硬盘的第一个扇区启动格式为ELF的内核镜像;控制从boot.S文件开始—这个文件设置了保护模式和一个栈,这样C代码就可以运行了,然后再调用bootmain()。

对x86.h头文件有:http://www.codeforge.cn/read/234474/x86.h__html

1
2
3
4
5
6
7
8
9
10
11
static inline uchar
inb(ushort port)
{

uchar data;

asm volatile("in %1,%0" : "=a" (data) : "d" (port));
//对应 in port,data
return data;

}

0x1F7:读 用来存放读操作后的状态

readsect(void *dst, uint32_t secno)从secno扇区读取数据到dst

  • 用汇编的方式实现读取1000号逻辑扇区开始的8个扇区
  • IDE通道的通讯地址是0x1F0 - 0x1F7
  • 其中0x1F3 - 0x1F6 4个字节的端口是用来写入LBA地址的
  • LBA就是 logical Block Address
  • 1000的16进制就是0x3E8
  • 向0x1F3 - 0x1F6写入 0x3E8
  • 向0x1F2这个地址写入扇区数量,也就是8
  • 向0X1F7写入要执行的操作命令码,对读操作的命令码是 0x20
1
2
3
4
5
6
out 0x1F3 0x00
out 0x1F4 0x00
out 0x1F5 0x03
out 0x1F6 0xE8
out 0x1F2 0x08
out 0x1F7 0x20

outb的定义在x86.h中,封装out命令,将data输出到port端口

1
2
3
4
5
6
7
static inline void
outb(ushort port, uchar data)
{

asm volatile("out %0,%1" : : "a" (data), "d" (port));

}

业界共同推出了 LBA48,采用 48 个比特来表示逻辑扇区号。如此一来,就可以管理131072 TB 的硬盘容量了。在这里我们采用将采用 LBA28 来访问硬盘。
第1步:设置要读取的扇区数量。这个数值要写入0x1f2端口。这是个8位端口,因此每次只能读写255个扇区:
1
2
3
mov dx,0x1f2
mov al,0x01 ;1 个扇区
out dx,al

注意:如果写入的值为 0,则表示要读取 256 个扇区。每读一个扇区,这个数值就减一。因此,如果在读写过程中发生错误,该端口包含着尚未读取的扇区数。

第2步:设置起始LBA扇区号。扇区的读写是连续的,因此只需要给出第一个扇区的编号就可以了。28 位的扇区号太长,需要将其分成 4 段,分别写入端口 0x1f3、0x1f4、0x1f5 和 0x1f6 号端口。其中,0x1f3 号端口存放的是 0~7 位;0x1f4 号端口存放的是 8~15 位;0x1f5 号端口存放的是 16~23 位,最后 4 位在 0x1f6 号端口。

第3步:
向端口 0x1f7 写入 0x20,请求硬盘读。

第4步:等待读写操作完成。端口0x1f7既是命令端口,又是状态端口。在通过这个端口发送读写命令之后,硬盘就忙乎开了。在它内部操作期间,它将 0x1f7 端口的第7位置“1”,表明自己很忙。一旦硬盘系统准备就绪,它再将此位清零,说明自己已经忙完了,同时将第3位置“1”,意思是准备好了,请求主机发送或者接收数据。

第5步:连续取出数据。0x1f0 是硬盘接口的数据端口,而且还是一个16位端口。一旦硬盘控制器空闲,且准备就绪,就可以连续从这个端口写入或者读取数据。

1
2
3
4
5
6
7
8
outb(0x1F2, 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

insl(0x1F0, dst, SECTSIZE / 4) // 第五步

readseg函数简单包装了readsect,可以从设备读取任意长度的内容。

1
2
3
4
5
6
7
8
9
10
11
static void readseg(uintptr_t va, uint32_t count, uint32_t offset) {
uintptr_t end_va = va + count;
va -= offset % SECTSIZE;

uint32_t secno = (offset / SECTSIZE) + 1;
// 看是第几块,加1因为0扇区被引导占用,ELF文件从1扇区开始

for (; va < end_va; va += SECTSIZE, secno ++) {
readsect((void *)va, secno);//调用之前的封装函数对每一块进行处理
}
}

对不同的文件,执行file命令如下:
1
2
3
4
5
6
7
8
9
10
11
file link.o 
link.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

file libfoo.so
libfoo.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=871ecaf438d2ccdcd2e54cd8158b9d09a9f971a7, not stripped

file p1
p1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=37f75ef01273a9c77f4b4739bcb7b63a4545d729, not stripped

file libfoo.so
libfoo.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=871ecaf438d2ccdcd2e54cd8158b9d09a9f971a7, stripped

以下是主函数。
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
bootmain(void) {
// read the 1st page off disk
readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

// 看是不是标准的elf
if (ELFHDR->e_magic != ELF_MAGIC) {
goto bad;
}

struct proghdr *ph, *eph;

// elf头中有elf文件应该加载到什么位置,将表头地址存在ph中
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);
}

// 找到内核的入口,这个函数不返回
((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);

/* do nothing */
while (1);
}

一般的 ELF 文件包括三个索引表:ELF header,Program header table,Section header table。

  • ELF header:在文件的开始,保存了路线图,描述了该文件的组织情况。
  • Program header table:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
  • Section header table:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf64_Half e_type; /* Object file type */
Elf64_Half e_machine; /* Architecture */
Elf64_Word e_version; /* Object file version */
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags; /* Processor-specific flags */
Elf64_Half e_ehsize; /* ELF header size in bytes */
Elf64_Half e_phentsize; /* Program header table entry size */
Elf64_Half e_phnum; /* Program header table entry count */
Elf64_Half e_shentsize; /* Section header table entry size */
Elf64_Half e_shnum; /* Section header table entry count */
Elf64_Half e_shstrndx; /* Section header string table index */
} Elf64_Ehdr;

ELF文件中有很多段,段表(Section Header Table)就是保存这些段的基本信息的结构,包括了段名、段长度、段在文件中的偏移位置、读写权限和其他段属性。
objdump工具可以查看ELF文件基本的段结构

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct
{
Elf64_Word sh_name; /* Section name (string tbl index) */
Elf64_Word sh_type; /* Section type */
Elf64_Xword sh_flags; /* Section flags */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Section size in bytes */
Elf64_Word sh_link; /* Link to another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;

练习五

一个比较简单但很绕的逻辑,找到每个函数调用压栈时的指针,找到这个指针也就找到了上一个函数的部分,再找它之前的函数调用压栈的内容。主要问题是忘记了ebp!=0这个条件,忽视了要用16进制。

  • eip是寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从eip寄存器中读取下一条指令的内存地址,然后继续执行;
  • esp是寄存器存放当前线程的栈顶指针;
  • ebp存放一个指针,该指针指向系统栈最上面一个栈帧的底部。即EBP寄存器存储的是栈底地址,而这个地址是由ESP在函数调用前传递给EBP的。等到调用结束,EBP会把其地址再次传回给ESP。所以ESP又一次指向了函数调用结束后,栈顶的地址。
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
void print_stackframe(void) {
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (uint32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
uint32_t my_ebp = read_ebp();
uint32_t my_eip = read_eip();//读取当前的ebp和eip
int i,j;
for(i = 0; my_ebp!=0 && i< STACKFRAME_DEPTH; i++){
cprintf("%0x %0x\n",my_ebp,my_eip);
for(j=0;j<4;j++){
cprintf("%0x\t",((uint32_t*)my_ebp+2)[j]);
}
cprintf("\n");
print_debuginfo(my_eip-1);
my_ebp = ((uint32_t*)my_ebp)[0];
my_eip = ((uint32_t*)my_ebp)[1];
}
}

ebp(基指针)寄存器主要通过软件约定与堆栈相关联。 在进入C函数时,函数的初始代码通常将先前函数的基本指针推入堆栈来保存,然后在函数持续时间内将当前esp值复制到ebp中。 如果程序中的所有函数都遵循这个约定,那么在程序执行期间的任何给定点,都可以通过跟踪保存的ebp指针链并确切地确定嵌套的函数调用序列引起这个特定的情况来追溯堆栈。 指向要达到的函数。 例如,当某个特定函数导致断言失败时,因为错误的参数传递给它,但您不确定是谁传递了错误的参数。 堆栈回溯可找到有问题的函数。

最后一行对应的是第一个使用堆栈的函数,所以在栈的最深一层,就是bootmain.c中的bootmain。 bootloader起始的堆栈从0x7c00开始,使用”call bootmain”转入bootmain函数。 call指令压栈,所以bootmain中ebp为0x7bf8。

练习六

一个表项的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*lab1/kern/mm/mmu.h*/
/* Gate descriptors for interrupts and traps */
struct gatedesc {
unsigned gd_off_15_0 : 16; // low 16 bits of offset in segment
unsigned gd_ss : 16; // segment selector
unsigned gd_args : 5; // # args, 0 for interrupt/trap gates
unsigned gd_rsv1 : 3; // reserved(should be zero I guess)
unsigned gd_type : 4; // type(STS_{TG,IG32,TG32})
unsigned gd_s : 1; // must be 0 (system)
unsigned gd_dpl : 2; // descriptor(meaning new) privilege level
unsigned gd_p : 1; // Present
unsigned gd_off_31_16 : 16; // high bits of offset in segment
};

一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成位移, 两者联合便是中断处理程序的入口地址。(copy from answer)

pic_init:中断控制器的初始化;idt_init:建立中断描述符表,并使能中断,intr_enable()

中断向量表可以认为是一个大数组,产生中断时生成一个中断号,来查这个idt表,找到中断服务例程的地址(段选择子加offset)。

主要是调用SETGATE这个宏对interrupt descriptor table进行初始化,是之前看到的对每个字节进行操作。然后调用lidt进行load idt(sti:使能中断)

建立一个中断描述符

  • istrap: 1 是一个trap, 0 代表中断
  • sel: 中断处理代码段
  • off: 中断处理代码段偏移
  • dpl: 描述符的优先级
1
#define SETGATE(gate, istrap, sel, off, dpl)

除了系统调用中断(T_SYSCALL)使用陷阱门描述符且权限为用户态权限以外,其它中断均使用特权级(DPL)为0的中断门描述符,权限为内核态权限;

  1. 中断描述符表(Interrupt Descriptor Table)中断描述符表把每个中断或异常编号和一个指向中断服务例程的描述符联系起来。同GDT一样,IDT是一个8字节的描述符数组,但IDT的第一项可以包含一个描述符。CPU把中断(异常)号乘以8做为IDT的索引。IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址。指令LIDT和SIDT用来操作IDTR。两条指令都有一个显示的操作数:一个6字节表示的内存地址。在保护模式下,最多会存在256个Interrupt/Exception Vectors。
1
2
3
4
5
6
7
8
9
        extern uintptr_t __vectors[];
int i;
//for(i=0;i<256;i++)
for(i=0;i< sizeof(idt) / sizeof(struct gatedesc); i++){
SETGATE(idt[i],0,GD_KTEXT,__vectors[i],DPL_KERNEL);
}
// SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
SETGATE(idt[T_SWITCH_TOK], 1, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
lidt(&idt_pd);

对idt中的每一项,调用SETGATE进行设置,第二个是0表明是一个中断,如果是1表明是一个陷阱;GD_KTEXT是SEG_KTEXT(1,全局段编号)乘8,是处理中断的代码段编号,__vectors[i]是作为在代码段中的偏移量,vectors[i]在kern/trap/vectors.S中定义,定义了255个中断服务例程的地址,这里才是入口,且都跳转到__alltraps。在trap中调用了trap_dispatch,这样就根据传进来的进行switch处理。

用户态设置在特权级3,内核态设置在特权级0。

练习七

这个实验实现用户态和内核态的转换,通过看代码基本明白。在init.c中的lab1_switch_to_user函数时一段汇编代码, 触发中断的话,有‘int %0’,就把第二个冒号(输入的数,T_SWITCH_TOK)替换%0, 这样中断号就是T_SWITCH_TOK。

SETGATE设置中断向量表将每个中断处理例程的入口设成vector[i]的值,然后在有中断时,找到中断向量表中这个中断的处理例程,都是跳到alltraps,__alltraps把寄存器(ds es fs gs)压栈,把esp压栈,这样假装构造一个trapframe然后调用trap,trap调用了trap_dispatch

在trap_dispatch中,对从堆栈弹出的段寄存器进行修改,转成User时和转成Kernel时不一样,分别赋值,同时需要修改之前的trapframe,实现中断的恢复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 //LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.
case T_SWITCH_TOU:
if(tf->tf_cs != USER_CS){
tf->tf_cs = USER_CS;
tf->tf_ds = USER_DS;
tf->tf_es = USER_DS;
tf->tf_ss = USER_DS;
tf->tf_eflags |= FL_IOPL_MASK;
*((uint32_t*)tf - 1) = (uint32_t)tf;
}
break;
case T_SWITCH_TOK:
if(tf->tf_cs != KERNEL_CS) {
tf->tf_cs = KERNEL_CS;
tf->tf_ds = KERNEL_DS;
tf->tf_es = KERNEL_DS;
tf->tf_eflags &= ~FL_IOPL_MASK;
struct trapframe *switchu2k = (struct trapframe *)(tf->tf_esp - (sizeof(struct trapframe) - 8));
memmove(switchu2k,tf,sizeof(struct trapframe)-8);
*((uint32_t *)tf-1)=(uint32_t)switchu2k;
}
break;

实验二

读代码

在bootloader进入保护模式前进行探测物理内存分布和大小,基本方式是通过BIOS中断调用,在实模式下完成,在boot/bootasm.S中从probe_memory处到finish_probe处的代码部分完成。以下应该是检测到的物理内存信息:

1
2
3
4
5
6
7
8
memory management: default_pmm_manager
e820map:
memory: 0009fc00, [00000000, 0009fbff], type = 1.
memory: 00000400, [0009fc00, 0009ffff], type = 2.
memory: 00010000, [000f0000, 000fffff], type = 2.
memory: 07ee0000, [00100000, 07fdffff], type = 1.
memory: 00020000, [07fe0000, 07ffffff], type = 2.
memory: 00040000, [fffc0000, ffffffff], type = 2.

参考:type是物理内存空间的类型,1是可以使用的,2是暂时不能够使用的。

之前是开启A20的16位地址线,实现20位地址访问。通过写键盘控制器8042的64h端口与60h端口。先转成实模式!
获取的物理内存信息是用这种结构存的(内存映射地址描述符),一共20字节:

1
2
3
4
5
6
7
8
struct e820map {
int nr_map;
struct {
uint64_t addr; //8字节,unsigned long long,基地址?
uint64_t size; //8字节,unsigned long long,大小
uint32_t type; //4字节,unsigned long,内存类型
} __attribute__((packed)) map[E820MAX];
};

每探测到一块内存空间,对应的内存映射描述符被写入指定表,以下是通过向INT 15h中断传入e820h参数来探测物理内存空间的信息。”$”美元符号修饰立即数,”%”修饰寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
probe_memory:
movl $0, 0x8000 #把0这个立即数写入0x8000地址,
xorl %ebx, %ebx #相当于我们设置在0x8000处存放struct e820map, 并清除e820map中的nr_map置0
movw $0x8004, %di #0x8004正好就是第一个内存映射地址描述符的地址,因为nr_map是四个字节
start_probe:
movl $0xE820, %eax #传入0xE820作为参数,
movl $20, %ecx #内存映射地址描述符的大小是20个字节
movl $SMAP, %edx #SMAP之前定义是0x534d4150,不知道何用
int $0x15 #调用INT 15H中断
jnc cont #CF=0,则跳转到cont
movw $12345, 0x8000
jmp finish_probe
cont:
addw $20, %di #设置下一个内存映射地址描述符的地址
incl 0x8000 #E820map中的nr_map加一
cmpl $0, %ebx #如果INT0x15返回的ebx为零,表示探测结束,如果还有就继续找
jnz start_probe
finish_probe:

调用中断int 15h 之前,需要填充如下寄存器:

  • eax int 15h 可以完成许多工作,主要有ax的值决定,我们想要获取内存信息,需要将ax赋值为0E820H。
  • ebx 放置着“后续值(continuation value)”,第一次调用时ebx必须为0.
  • es:di 指向一个地址范围描述结构 ARDS(Address Range Descriptor Structure), BIOS将会填充此结构。
  • ecx es:di所指向的地址范围描述结构的大小,以字节为单位。无论es:di所指向的结构如何设置,BIOS最多将会填充ecx字节。不过,通常情况下无论ecx为多大,BIOS只填充20字节,有些BIOS忽略ecx的值,总是填充20字节。
  • edx 0534D4150h(‘SMAP’)——BIOS将会使用此标志,对调用者将要请求的系统映像信息进行校验,这些信息被BIOS放置到es:di所指向的结构中。

中断调用之后,结果存放于下列寄存器之中。

  • CF CF=0表示没有错误,否则存在错误。
  • eax 0534D4150h(‘SMAP’)
  • es:di 返回的地址范围描述符结构指针,和输入值相同。
  • ecx BIOS填充在地址范围描述符中的字节数量,被BIOS所返回的最小值是20字节。
  • ebx 这里放置着为等到下一个地址描述符所需要的后续值,这个值得实际形势依赖于具体的BIOS的实现,调用者不必关心它的具体形式,自需在下一次迭代时将其原封不动地放置到ebx中,就可以通过它获取下一个地址范围描述符。如果它的值为0,并且CF没有进位,表示它是最后一个地址范围描述符。

由于一个物理页需要占用一个Page结构的空间,Page结构在设计时须尽可能小,以减少对内存的占用。

1
2
3
4
5
6
7
struct Page {                       // 描述了一个Page
int ref; // 这一页被页表的引用计数,一个页表项设置了一个虚拟页的映射
uint32_t flags; // 描述这个Page的状态,可能每个位表示不同的意思
unsigned int property; // property表示这个块中空闲页的数量,用到此成员变量的这个Page比较特殊,
// 是这个连续内存空闲块地址最小的一页(即头一页, Head Page)。
list_entry_t page_link; // 链接比它地址小和大的其他连续内存空闲块。
};

flag用到了两个bit

1
2
#define PG_reserved       0       // 表明了是否被保留,如果被保留,则bit 0会设置位1,且不能放到空闲列表里
#define PG_property 1 // bit 1表示此页是否是free的,如果设置为1,表示这页是free的,可以被分配;如果设置为0,表示这页已经被分配出去了,不能被再二次分配。

总结来说:一个页,里边有各种属性和双向链表的指针段

  • ref表示这个页被页表的引用记数,是映射此物理页的虚拟页个数。一旦某页表中有一个页表项设置了虚拟页到这个Page管理的物理页的映射关系,就会把Page的ref加一。反之,若是解除,那就减一。
  • flags表示此物理页的状态标记,有两个标志位,第一个表示是否被保留,如果被保留了则设为1(比如内核代码占用的空间)。第二个表示此页是否是free的。如果设置为1,表示这页是free的,可以被分配;如果设置为0,表示这页已经被分配出去了,不能被再二次分配。
  • property用来记录某连续内存空闲块的大小,这里需要注意的是用到此成员变量的这个Page一定是连续内存块的开始地址(第一页的地址)。
  • page_link是便于把多个连续内存空闲块链接在一起的双向链表指针,连续内存空闲块利用第一个页的成员变量page_link来链接比它地址小和大的其他连续内存空闲块,用到这个成员变量的是这个块的地址最小的一页。

下面简单看看mm/pmm.c中的pmm_init()

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
/* pmm_init - initialize the physical memory management */
static void
page_init(void) {
struct e820map *memmap = (struct e820map *)(0x8000 + KERNBASE);
uint64_t maxpa = 0;

cprintf("e820map:\n");
int i;
for (i = 0; i < memmap->nr_map; i ++) {
uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
cprintf(" memory: %08llx, [%08llx, %08llx], type = %d.\n",
memmap->map[i].size, begin, end - 1, memmap->map[i].type);
if (memmap->map[i].type == E820_ARM) {
if (maxpa < end && begin < KMEMSIZE) {
maxpa = end;
}
}
}
if (maxpa > KMEMSIZE) {
maxpa = KMEMSIZE;
}

extern char end[];

npage = maxpa / PGSIZE;
//起始物理内存地址位0,所以需要管理的页个数为npage,需要管理的所有页的大小位sizeof(struct Page)*npage
pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);
// pages的地址,最末尾地址按照页大小取整。
for (i = 0; i < npage; i ++) {
SetPageReserved(pages + i);
}
//当前的这些页设置为已占用的

uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);
// 之前设置了占用的页,那空闲的页就是从(pages+sizeof(struct Page)*npage)以上开始的

for (i = 0; i < memmap->nr_map; i ++) {
uint64_t begin = memmap->map[i].addr, end = begin + memmap->map[i].size;
if (memmap->map[i].type == E820_ARM) {
if (begin < freemem) {
begin = freemem;
}
if (end > KMEMSIZE) {
end = KMEMSIZE;
}
if (begin < end) {
begin = ROUNDUP(begin, PGSIZE);
end = ROUNDDOWN(end, PGSIZE);
if (begin < end) {
init_memmap(pa2page(begin), (end - begin) / PGSIZE);
// 通过调用本函数进行空闲的标记
}
}
}
}
}

SetPageReserved表示把物理地址对应的Page结构中的flags标志设置为PG_reserved ,表示这些页已经被使用了,将来不能被用于分配。而init_memmap函数把空闲物理页对应的Page结构中的flags和引用计数ref清零,并加到free_area.free_list指向的双向列表中。
1
2
3
4
5
6
7
8
9
struct pmm_manager {
const char *name; //物理内存页管理器的名字
void (*init)(void); //初始化内存管理器
void (*init_memmap)(struct Page *base, size_t n); //初始化管理空闲内存页的数据结构
struct Page *(*alloc_pages)(size_t n); //分配n个物理内存页
void (*free_pages)(struct Page *base, size_t n); //释放n个物理内存页
size_t (*nr_free_pages)(void); //返回当前剩余的空闲页数
void (*check)(void); //用于检测分配/释放实现是否正确
};

1
2
3
4
5
free_area_t - 维护一个双向链表记录没有用到的Page。
typedef struct {
list_entry_t free_list; // 整个双向链表的头节点
unsigned int nr_free; // 表示空闲页的数量
} free_area_t;
1
2
3
4
5
typedef struct list_entry list_entry_t;
struct list_entry {
struct list_entry *prev, *next;
};
类似Linux里的双向链表,这只是指针部分,数据部分在其他定义里

练习1 实现first-fit连续物理内存分配算法

重写函数: default_init, default_init_memmap,default_alloc_pages, default_free_pages。
在实现first_fit的回收函数时,注意连续地址空间之间的合并操作。在遍历空闲页块链表时,需要按照空闲块起始地址来排序,形成一个有序的的链表。

首次适应算法(First Fit):该算法从空闲分区链首开始查找,直至找到一个能满足其大小要求的空闲分区为止。然后再按照需求的大小,从该分区中划出一块内存分配给请求者,余下的空闲分区仍留在空闲分区链中。多使用内存中低地址部分的空闲区,在高地址部分的空闲区很少被利用,从而保留了高地址部分的空闲区。显然为以后到达的大作业分配大的内存空间创造了条件。但是低地址部分不断被划分,留下许多难以利用、很小的空闲区,每次查找又都从低地址部分开始,会增加查找的开销。

在First Fit算法中,分配器维护一个空闲块列表(free表)。一旦收到内存分配请求,
它遍历列表找到第一个满足的块。如果所选块明显大于请求的块,则分开,其余的空间将被添加到列表中下一个free块中。

  • 准备:实现First Fit我们需要使用链表管理空闲块,free_area_t被用来管理free块,首先,找到list.h中的”struct list”。结构”list”是一个简单的双向链表实现。使用”list_init”,”list_add”(”list_add_after”和”list_add_before”),”list_del”,
    “list_next”,”list_prev”。有一个棘手的方法是将一般的”list”结构转换为一个特殊结构(如struct”page”),使用以下宏:”le2page”(在memlayout.h中)。
  • “default_init”:重用例子中的”default_init”函数来初始化”free_list”并将”nr_free”设置为0。”free_list”用于记录空闲内存块,”nr_free”是可用内存块的总数。
  • “default_init_memmap”:调用栈为”kern_init” -> “pmm_init” -> “page_init” -> “init_memmap” -> “pmm_manager” -> “init_memmap”。此函数用于初始化空闲块(使用参数”addr_base”,”page_mumber”)。为了初始化一个空闲块,首先,应该在这个空闲块中初始化每个页面(在memlayout.h中定义)。这个程序包括:
    • 设置”p -> flags”的’PG_property’位,表示该页面为有效。在函数”pmm_init”(在pmm.c中),”p-> flags”的位’PG_reserved”已经设置好了。
    • 如果此页面是free的且不是free区块的第一页,”p-> property”应该设置为0。
    • 如果此页面是free的且是free区块的第一页,”p-> property”应该设置为本空闲块的总页数。
  • “default_alloc_pages”:在空闲列表中搜索第一个空闲块(块大小>=n),返回该块的地址作为所需的地址.

空闲页管理链表的初始化:把free_list的双向链表中的指针都指向自己,且计数器为0

1
2
3
4
5
6
7
static void default_init(void) {
list_init(&free_list);
nr_free = 0;
}
static inline void list_init(list_entry_t *elm) {
elm->prev = elm->next = elm;
}

初始化空闲页链表,初始化每一个空闲页,然后计算空闲页的总数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void default_init_memmap(struct Page *base, size_t n) {   
assert(n > 0);
struct Page *p = base;
for (; p != base + n; p ++) {
assert(PageReserved(p));
//这个页是否为保留页,PageReserved(p)返回true才会继续,如果返回true了,说明是保留页
//设置标志位
p->flags = 0
SetPageProperty(p);
p->property = 0; //应该只有第一个页的这个参数有用
set_page_ref(p, 0);//清空引用,现在是没有虚拟内存引用它的
list_add_before(&free_list, &(p->page_link));//插入空闲页的链表里面
}
nr_free += n; //连续有n个空闲块,空闲链表的个数加n
base->property=n; //连续内存空闲块的大小为n,属于物理页管理链表
//所有的页都在这个双向链表里且只有第0个页有这个块的信息
}

default_alloc_pages从空闲页链表中查找n个空闲页,如果成功,返回第一个页表的地址。遍历空闲链表,一旦发现有大于等于n的连续空闲页块,便将这n个页从空闲页链表中取出,同时使用SetPageReserved和ClearPageProperty表示该页为使用状态,同时如果该连续页的数目大于n,则从第n+1开始截断,之后为截断的块,重新计算相应的property的值。在贴代码之前先说说几个宏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 将这个le转换成一个Page */
#define le2page(le, member) \
to_struct((le), struct Page, member)

/* *
* to_struct - get the struct from a ptr
* @ptr: a struct pointer of member
* @type: the type of the struct this is embedded in
* @member: the name of the member within the struct
* 一般用的时候传进来的type是Page类型的,ptr是这个(Page+双向链表的两个指针)块的双向链表指针的开始地址。offsetof算出了page_link在Page中的偏移值,ptr减去双向链表第一个指针的偏移量得到了这个Page的地址
*/
#define to_struct(ptr, type, member) \
((type *)((char *)(ptr) - offsetof(type, member)))

/* Return the offset of 'member' relative to the beginning of a struct type */
0不代表具体地址,这个offsetof代表这个member在这个type中的偏移值
#define offsetof(type, member) \
((size_t)(&((type *)0)->member))

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
static struct Page * default_alloc_pages(size_t n) {
assert(n > 0);
if (n > nr_free) {
return NULL;
}
// n 一定要大于0,且n要小于当前可用的空闲块数
list_entry_t *le, *len;
le = &free_list;
struct Page *p=NULL;
while((le=list_next(le)) != &free_list) {
p = le2page(le, page_link);
if(p->property>=n)
break;
}
//在free_list里遍历每一页,用le2page转换成Page
//如果找到了一个property大于n的就说明找到了这个符合要求的块
if(p != NULL){
int i;
for(i=0;i<n;i++){
len = list_next(le);
struct Page *pp = le2page(le, page_link);
SetPageReserved(pp);
ClearPageProperty(pp);
list_del(le);
le = len;
}
// 如果我现在找到的块是大于n的,那就拆开
if(p->property>n){
(le2page(le,page_link))->property = p->property - n;
}
ClearPageProperty(p);
SetPageReserved(p);
nr_free -= n;
return p;
}
return NULL;
}

default_free_pages将base为起始地址的n个页面放回到free_list中
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
static void default_free_pages(struct Page *base, size_t n) {
assert(n > 0);
list_entry_t *le = &free_list;
struct Page *p = base;
//找到比base大的页面地址
while((le=list_next(le)) != &free_list){
p = le2page(le,page_link);
if(p > base)
break;
}
//在找到的p之前逐个插入
for(p = base; p < base + n; p ++){
list_add_before(le,&(p->page_link));
}
base->flags=0;
set_page_ref(base,0);
ClearPageProperty(base);
SetPageProperty(base);
base->property = n;
// 清空flag的信息,清空引用的信息,清空property信息,设置这个Page又是可以被引用的了
// 当前的base又是n个空闲块的头
p = le2page(le,page_link);
if(base+n==p){
base->property+=p->property;
p->property=0;
}
//看是不是可以跟后边的块恰好连在一起,如果连在一起的话就可以合并了
le=list_prev(&(base->page_link));
p = le2page(le, page_link);
//看是不是可以跟前边的连在一起,如果可以的话这个base就可以把property设成0了
if(le!=&free_list && p==base-1){
while(le!=&free_list){
if(p->property){
p->property+=base->property;
base->property=0;
break;
}
le = list_prev(le);
p=le2page(le,page_link);
}
}
nr_free +=n;
cprintf("release %d page,last %d.\n",n,nr_free);
}

运行中出现提示,表明本题成功:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
release 1 page,last 1.
release 1 page,last 2.
release 1 page,last 3.
release 1 page,last 1.
release 1 page,last 32291.
release 1 page,last 32292.
release 1 page,last 32293.
release 3 page,last 3.
release 1 page,last 1.
release 3 page,last 4.
release 1 page,last 4.
release 2 page,last 4.
release 1 page,last 5.
release 5 page,last 32293.
check_alloc_page() succeeded!

first_fit有一种改进,next_fit,第一次找到之后不暂停,第二次找到之后才真正给分配空间。修改比较简单,第一次找到之后记一个flag,下次再找到就可以分配了。

练习二

系统执行中的地址映射。

mooc中讲到了在段页式管理机制下运行这整个过程中,虚拟地址到物理地址的映射产生了多次变化,实现了最终的段页式映射关系:

1
virt addr = linear addr = phy addr + 0xC0000000  

第一个阶段(开启保护模式,创建启动段表)是bootloader阶段,即从bootloader的start函数(在boot/bootasm.S中)到执行ucore kernel的kern_entry函数之前,其虚拟地址、线性地址以及物理地址之间的映射关系与lab1的一样,即:

1
virt addr = linear addr = phy addr  

第二个阶段(创建初始页目录表,开启分页模式)从kern_entry函数开始,到pmm_init函数被执行之前。通过几条汇编指令(在kern/init/entry.S中)使能分页机制,主要做了两件事:

  • 通过movl %eax, %cr3指令把页目录表的起始地址存入CR3寄存器中;
  • 通过movl %eax, %cr0指令把cr0中的CR0_PG标志位设置上。

在此之后,进入了分页机制,地址映射关系如下:

1
2
virt addr = linear addr = phy addr # 线性地址在0~4MB之内三者的映射关系
virt addr = linear addr = phy addr + 0xC0000000 # 线性地址在0xC0000000~0xC0000000+4MB之内三者的映射关系

仅仅比第一个阶段增加了下面一行的0xC0000000偏移的映射,并且作用范围缩小到了0~4M。在下一个节点,会将作用范围继续扩充到0~KMEMSIZE。
此时的内核(EIP)还在0~4M的低虚拟地址区域运行,而在之后,这个区域的虚拟内存是要给用户程序使用的。为此,需要使用一个绝对跳转来使内核跳转到高虚拟地址(代码在kern/init/entry.S中):
1
2
3
4
5
6
    # update eip
# now, eip = 0x1.....
leal next, %eax
# set eip = KERNBASE + 0x1.....
jmp *%eax
next:

跳转完毕后,通过把boot_pgdir[0]对应的第一个页目录表项(0~4MB)清零来取消了临时的页映射关系:
1
2
3
# unmap va 0 ~ 4M, it's temporary mapping
xorl %eax, %eax
movl %eax, __boot_pgdir

最终的地址映射关系如下:
1
lab2 stage 2: virt addr = linear addr = phy addr + 0xC0000000 # 线性地址在0~4MB之内三者的映射关系

第三个阶段(完善段表和页表)从pmm_init函数被调用开始。pmm_init函数将页目录表项补充完成(从0~4M扩充到0~KMEMSIZE)。然后,更新了段映射机制,使用了一个新的段表。这个新段表除了包括内核态的代码段和数据段描述符,还包括用户态的代码段和数据段描述符以及TSS(段)的描述符。理论上可以在第一个阶段,即bootloader阶段就将段表设置完全,然后在此阶段继续使用,但这会导致内核的代码和bootloader的代码产生过多的耦合,于是就有了目前的设计。
这时形成了我们期望的虚拟地址、线性地址以及物理地址之间的映射关系:
1
lab2 stage 3: virt addr = linear addr = phy addr + 0xC0000000

请描述页目录项(Pag Director Entry)和页表(Page Table Entry)中每个组成部分的含义和以及对ucore而言的潜在用处。

页目录项(Pag Director Entry)每一位的含义:

  • 前20位表示4K对齐的该PDE对应的页表起始位置(物理地址,该物理地址的高20位即PDE中的高20位,低12位为0);
  • 第9-11位未被CPU使用,可保留给OS使用;
  • 接下来的第8位可忽略;
  • 第7位用于设置Page大小,0表示4KB;
  • 第6位恒为0;
  • 第5位用于表示该页是否被使用过;
  • 第4位设置为1则表示不对该页进行缓存;
  • 第3位设置是否使用write through缓存写策略;
  • 第2位表示该页的访问需要的特权级;
  • 第1位表示是否允许读写;
  • 第0位为该PDE的存在位;

页表项(PTE)中的每项的含义:

  • 高20位与PDE相似的,用于表示该PTE指向的物理页的物理地址;
  • 9-11位保留给OS使用;
  • 7-8位恒为0;
  • 第6位表示该页是否为dirty,即是否需要在swap out的时候写回外存;
  • 第5位表示是否被访问;
  • 3-4位恒为0;
  • 0-2位分别表示存在位、是否允许读写、访问该页需要的特权级;

PTE和PDE都有一些保留位供操作系统使用,ucore利用保留位来完成一些其他的内存管理相关的算法。

当ucore执行过程中出现了页访问异常,硬件需要完成的事情分别如下:

  • 将发生错误的线性地址保存在cr2寄存器中;
  • 在中断栈中依次压入EFLAGS,CS, EIP,以及页访问异常码error code,如果pgfault是发生在用户态,则还需要先压入ss和esp,并且切换到内核栈;
  • 根据中断描述符表查询到对应page fault的处理例程地址如后,跳转到对应处执行。

建立虚拟页和物理页帧的地址映射关系

整个页目录表和页表所占空间大小取决与二级页表要管理和映射的物理页数。
假定当前物理内存0~16MB,每物理页(也称Page Frame)大小为4KB,则有4096个物理页,也就意味这有4个页目录项和4096个页表项需要设置。一个页目录项(Page Directory Entry,PDE)和一个页表项(Page Table Entry,PTE)占4B。即使是4个页目录项也需要一个完整的页目录表(占4KB)。而4096个页表项需要16KB(即4096*4B)的空间,也就是4个物理页,16KB的空间。所以对16MB物理页建立一一映射的16MB虚拟页,需要4+1=5个物理页,即20KB的空间来形成二级页表。

把0~KERNSIZE(明确ucore设定实际物理内存不能超过KERNSIZE值,即0x38000000字节,896MB,3670016个物理页)的物理地址一一映射到页目录项和页表项的内容,其大致流程如下:

  1. 指向页目录表的指针已存储在boot_pgdir变量中。
  2. 映射0~4MB的首个页表已经填充好。
  3. 调用boot_map_segment函数进一步建立一一映射关系,具体处理过程以页为单位进行设置,即:
1
linear addr = phy addr + 0xC0000000

设一个32bit线性地址la有一个对应的32bit物理地址pa,如果在以la的高10位为索引值的页目录项中的存在位(PTE_P)为0,表示缺少对应的页表空间,则可通过alloc_page获得一个空闲物理页给页表,页表起始物理地址是按4096字节对齐的,这样填写页目录项的内容为:

页目录项内容 = (页表起始物理地址 & ~0x0FFF) | PTE_U | PTE_W | PTE_P

进一步对于页表中以线性地址la的中10位为索引值对应页表项的内容为:

页表项内容 = (pa & ~0x0FFF) | PTE_P | PTE_W

其中:

PTE_U:位3,表示用户态的软件可以读取对应地址的物理内存页内容
PTE_W:位2,表示物理内存页内容可写
PTE_P:位1,表示物理内存页存在

ucore的内存管理经常需要查找页表:
给定一个虚拟地址,找出这个虚拟地址在二级页表中对应的项。通过更改此项的值可以方便地将虚拟地址映射到另外的页上。可完成此功能的这个函数是get_pte函数。它的原型为

1
pte_t *get_pte(pde_t *pgdir, uintptr_t la, bool create)

这里涉及到三个类型pte_tpde_tuintptr_t。这三个都是unsigned int类型。

  • pde_t:page directory entry,一级页表的表项。
  • pte_t:page table entry,表示二级页表的表项。
  • uintptr_t:表示为线性地址,由于段式管理只做直接映射,所以它也是逻辑地址。
  • pgdir:给出页表起始地址。通过查找这个页表,我们需要给出二级页表中对应项的地址。

可以在需要时再添加对应的二级页表。如果在查找二级页表项时,发现对应的二级页表不存在,则需要根据create参数的值来处理是否创建新的二级页表。如果create参数为0,则get_pte返回NULL;如果create参数不为0,则get_pte需要申请一个新的物理页(通过alloc_page来实现,可在mm/pmm.h中找到它的定义),再在一级页表中添加页目录项指向表示二级页表的新物理页。

注意,新申请的页必须全部设定为零,因为这个页所代表的虚拟地址都没有被映射。

当建立从一级页表到二级页表的映射时,需要注意设置控制位。这里应该设置同时设置上PTE_U、PTE_W和PTE_P(定义可在mm/mmu.h)。如果原来就有二级页表,或者新建立了页表,则只需返回对应项的地址即可。

虚拟地址只有映射上了物理页才可以正常的读写。在完成映射物理页的过程中,除了要在页表的对应表项上填上相应的物理地址外,还要设置正确的控制位。

只有当一级二级页表的项都设置了用户写权限后,用户才能对对应的物理地址进行读写。由于一个物理页可能被映射到不同的虚拟地址上去(譬如一块内存在不同进程间共享),当这个页需要在一个地址上解除映射时,操作系统不能直接把这个页回收,而是要先看看它还有没有映射到别的虚拟地址上。这是通过查找管理该物理页的Page数据结构的成员变量ref(用来表示虚拟页到物理页的映射关系的个数)来实现的,如果ref为0了,表示没有虚拟页到物理页的映射关系了,就可以把这个物理页给回收了,从而这个物理页是free的了,可以再被分配。

page_insert函数将物理页映射在了页表上。可参看page_insert函数的实现来了解ucore内核是如何维护这个变量的。当不需要再访问这块虚拟地址时,可以把这块物理页回收并在将来用在其他地方。取消映射由page_remove来做,这其实是page_insert的逆操作。
建立好一一映射的二级页表结构后,由于分页机制在前一节所述的前两个阶段已经开启,分页机制到此初始化完毕。当执行完毕gdt_init函数后,新的段页式映射已经建立好了。

预备知识copy完了,上练习二和练习三

练习二代码

预备知识不够用了
上mmu.h的代码读读

1
2
3
4
5
6
7
8
9
10
11
12

A linear address 'la' has a three-part structure as follows:
+--------10------+-------10-------+---------12----------+
| Page Directory | Page Table | Offset within Page |
| Index | Index | |
+----------------+----------------+---------------------+
\--- PDX(la) --/ \--- PTX(la) --/ \---- PGOFF(la) ----/
\----------- PPN(la) -----------/
The PDX, PTX, PGOFF, and PPN macros decompose linear addresses as shown.
To construct a linear address la from PDX(la), PTX(la), and PGOFF(la),
use PGADDR(PDX(la), PTX(la), PGOFF(la)).

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
//get_pte - get Page Table Entry and return the kernel virtual address of this Page Table Entry for la
// - if the PT contians this Page Table Entry didn't exist, alloc a page for PT
// parameter:
// pgdir: the kernel virtual base address of PDT (页目录表的入口)
// la: the linear address need to map (线性地址)
// create: a logical value to decide if alloc a page for PT
// return vaule: the kernel virtual address of this pte (返回这个页表项的虚拟地址)
pte_t *
get_pte(pde_t *pgdir, uintptr_t la, bool create) {
/* * 使用KADDR()获得物理地址
* PDX(la) = 虚拟地址la在page directory entry 的 index
* KADDR(pa) : takes a physical address and returns the corresponding kernel virtual address.
* set_page_ref(page,1) : means the page be referenced by one time,这一页被引用了
* page2pa(page): get the physical address of memory which this (struct Page *) page manages
* 得到这个页管理的内存的物理地址
* struct Page * alloc_page() : allocation a page
* memset(void *s, char c, size_t n) : sets the first n bytes of the memory area pointed by s
* to the specified value c.
* DEFINEs:
* PTE_P 0x001 // page table/directory entry flags bit : Present
* PTE_W 0x002 // page table/directory entry flags bit : Writeable
* PTE_U 0x004 // page table/directory entry flags bit : User can access
*/

pde_t *pdep = &pgdir[PDX(la)]; // (1) find page directory entry
struct Page *page;
if (!(*pdep & PTE_P) ) { // (2) check if entry is not present
if (!create || (page = alloc_page()) == NULL) {
return NULL;
} // (3) check if creating is needed, then alloc page for page table
// CAUTION: this page is used for page table, not for common data page
set_page_ref(page, 1); // (4) set page reference
uintptr_t pa = page2pa(page); // (5) get linear address of page
memset(KADDR(pa),0,PGSIZE); // (6) clear page content using memset
*pdep = pa | PTE_U | PTE_W | PTE_P;// (7) set page directory entry's permission
}
return &((pte_t *)KADDR(PDE_ADDR(*pdep)))[PTX(la)]; // (8) return page table 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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//page_remove_pte - free an Page sturct which is related linear address la
// - and clean(invalidate) pte which is related linear address la
//note: PT is changed, so the TLB need to be invalidate
static inline void
page_remove_pte(pde_t *pgdir, uintptr_t la, pte_t *ptep) {
/* LAB2 EXERCISE 3: YOUR CODE
*
* Please check if ptep is valid, and tlb must be manually updated if mapping is updated
*
* Maybe you want help comment, BELOW comments can help you finish the code
*
* Some Useful MACROs and DEFINEs, you can use them in below implementation.
* MACROs or Functions:
* struct Page *page pte2page(*ptep): get the according page from the value of a ptep
* free_page : free a page
* page_ref_dec(page) : decrease page->ref. NOTICE: ff page->ref == 0 , then this page should be free.
* tlb_invalidate(pde_t *pgdir, uintptr_t la) : Invalidate a TLB entry, but only if the page tables being
* edited are the ones currently in use by the processor.
* DEFINEs:
* PTE_P 0x001 // page table/directory entry flags bit : Present
*/
#if 0
if (0) { //(1) check if this page table entry is present
struct Page *page = NULL; //(2) find corresponding page to pte
//(3) decrease page reference
//(4) and free this page when page reference reachs 0
//(5) clear second page table entry
//(6) flush tlb
}
#endif
if (*ptep & PTE_P) { // 确保传进来的二级页表时可用的
struct Page *page = pte2page(*ptep);// 获取页表项对应的物理页的Page结构
if (page_ref_dec(page) == 0) { // page_ref_dec被用于page->ref自减1,
// 如果返回值是0,那么就说明不存在任何虚拟页指向该物理页,释放该物理页
free_page(page);
}
*ptep = 0; // 将PTE的映射关系清空
tlb_invalidate(pgdir, la); // 刷新TLB,确保TLB的缓存中不会有错误的映射关系
}
}

问题:

1
2
3
4
5
6
7
8
数据结构Page的全局变量(其实是一个数组)的每一项与页表中的页目录项和页表项有无对应关系?如果有,其对应关系是啥?

存在对应关系:由于页表项中存放着对应的物理页的物理地址,因此可以通过这个物理地址来获取到对应到的Page数组的对应项,具体做法为将物理地址除以一个页的大小,然后乘上一个Page结构的大小获得偏移量,使用偏移量加上Page数组的基地址皆可以或得到对应Page项的地址;

如果希望虚拟地址与物理地址相等,则需要如何修改lab2,完成此事? 鼓励通过编程来具体完成这个问题。

由于在完全启动了ucore之后,虚拟地址和线性地址相等,都等于物理地址加上0xc0000000,如果需要虚拟地址和物理地址相等,可以考虑更新gdt,更新段映射,使得virtual address = linear address - 0xc0000000,这样的话就可以实现virtual address = physical address;
reference:https://www.jianshu.com/p/abbe81dfe016

实验三

实验内容

在实验二的基础上,借助页表机制和实验一中涉及的中断异常处理机制,完成Pgfault异常处理和FIFO页替换算法的实现,结合磁盘提供的缓存空间,从而能够支持虚存管理,提供一个比实际物理内存空间“更大”的虚拟内存空间给系统使用。
这个实验与实际操作系统中的实现比较起来要简单,不过需要了解实验一和实验二的具体实现。实际操作系统系统中的虚拟内存管理设计与实现是相当复杂的,涉及到与进程管理系统、文件系统等的交叉访问。

简单原理

copy from gitbook
通过内存地址虚拟化,可以使得软件在没有访问某虚拟内存地址时不分配具体的物理内存,而只有在实际访问某虚拟内存地址时,操作系统再动态地分配物理内存,建立虚拟内存到物理内存的页映射关系,这种技术称为按需分页(demand paging)。

把不经常访问的数据所占的内存空间临时写到硬盘上,这样可以腾出更多的空闲内存空间给经常访问的数据;当CPU访问到不经常访问的数据时,再把这些数据从硬盘读入到内存中,这种技术称为页换入换出(page swap in/out)。这种内存管理技术给了程序员更大的内存“空间”,从而可以让更多的程序在内存中并发运行。

参考ucore总控函数kern_init的代码,在调用完成虚拟内存初始化的vmm_init函数之前,需要首先调用pmm_init函数完成物理内存的管理,调用pic_init函数完成中断控制器的初始化,调用idt_init函数完成中断描述符表的初始化。

在调用完idt_init函数之后,将进一步调用新函数vmm_init、ide_init、swap_init

do_pgfault函数会申请一个空闲物理页,并建立好虚实映射关系,从而使得这样的“合法”虚拟页有实际的物理页帧对应。

ide_init就是完成对用于页换入换出的硬盘(简称swap硬盘)的初始化工作。完成ide_init函数后,ucore就可以对这个swap硬盘进行读写操作了。

vmm设计包括两部分:mm_struct(mm)和vma_struct(vma)。mm是具有相同PDT的连续虚拟内存区域集的内存管理器。 vma是一个连续的虚拟内存区域。 vma中存在线性链接列表,mm的vma的redblack链接列表。(redblack是啥?)

建立mm_struct和vma_struct数据结构。当访问内存产生pagefault异常时,可获得访问的内存的方式(读或写)以及具体的虚拟内存地址,这样ucore就可以查询此地址,看是否属于vma_struct数据结构中描述的合法地址范围中,如果在,则可根据具体情况进行请求调页/页换入换出处理;如果不在,则报错。

两种数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct mm_struct {
// 链接所有属于同一页目录表的虚拟内存空间
list_entry_t mmap_list;
// 指向当前正在使用的虚拟内存空间,直接使用这个指针就能找到下一次要用到的虚拟空间
struct vma_struct *mmap_cache;
pde_t *pgdir; // 第一级页表的起始地址,即页目录表项PDT。通过访问pgdir可以查找某虚拟地址对应的页表项是否存在以及页表项的属性等
int map_count; // 记录了链接了的vma_struct个数,共享了几次
void *sm_priv; // 指向记录页访问情况的链表头。
};

struct vma_struct {
// 描述应用程序对虚拟内存“需求”
struct mm_struct *vm_mm; // 指向更高抽象层次的数据结构
// the set of vma using the same PDT
uintptr_t vm_start; // 连续地址虚拟内存空间的起始位置
uintptr_t vm_end; // 连续地址虚拟内存空间的结束位置
uint32_t vm_flags; // 标志属性(读/写/执行)
//link将一系列虚拟内存空间连接起来
list_entry_t list_link;
};
vm_flags:
#define VM_READ 0x00000001 //只读
#define VM_WRITE 0x00000002 //可读写
#define VM_EXEC 0x00000004 //可执行

具体函数:

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
// mm_create -  alloc a mm_struct & initialize it.
struct mm_struct * mm_create(void) {
struct mm_struct *mm = kmalloc(sizeof(struct mm_struct));
if (mm != NULL) {
list_init(&(mm->mmap_list));
mm->mmap_cache = NULL;
mm->pgdir = NULL;
mm->map_count = 0;

if (swap_init_ok) swap_init_mm(mm);
else mm->sm_priv = NULL;
}
return mm;
}

// mm_destroy - free mm and mm internal fields
void mm_destroy(struct mm_struct *mm) {
list_entry_t *list = &(mm->mmap_list), *le;
while ((le = list_next(list)) != list) {
list_del(le);
kfree(le2vma(le, list_link),sizeof(struct vma_struct)); //kfree vma
}
kfree(mm, sizeof(struct mm_struct)); //kfree mm
mm=NULL;
}

设备驱动程序或者内核模块中动态开辟内存,不是用malloc,而是kmalloc ,vmalloc,
释放内存用的是kfree,vfree,kmalloc函数返回的是虚拟地址(线性地址)。

kmalloc特殊之处在于它分配的内存是物理上连续的,这对于要进行DMA的设备十分重要。
而用vmalloc分配的内存只是线性地址连续,物理地址不一定连续,不能直接用于DMA。vmalloc函数的工作方式类似于kmalloc,只不过前者分配的内存虚拟地址是连续的,而物理地址则无需连续。

通过vmalloc获得的页必须一个一个地进行映射,效率不高, 因此,只在不得已(一般是为了获得大块内存)时使用。vmalloc函数返回一个指针,指向逻辑上连续的一块内存区,其大小至少为size。在发生错误 时,函数返回NULL。

1
2
3
4
5
6
7
8
9
10
11
// vma_create - 新建一个vma_struct并且初始化(地址范围: vm_start~vm_end)
struct vma_struct * vma_create(uintptr_t vm_start, uintptr_t vm_end, uint32_t vm_flags) {
struct vma_struct *vma = kmalloc(sizeof(struct vma_struct));

if (vma != NULL) {
vma->vm_start = vm_start;
vma->vm_end = vm_end;
vma->vm_flags = vm_flags;
}
return vma;
}

Page Fault异常处理

处理该异常主要用do_pgfault函数,当启动分页机制以后,如果一条指令或数据的虚拟地址所对应的物理页框不在内存中或者访问的类型有错误(比如写一个只读页或用户态程序访问内核态的数据等),就会发生页访问异常。产生页访问异常的原因主要有:

目标页帧不存在(页表项全为0,即该线性地址与物理地址尚未建立映射或者已经撤销);
相应的物理页帧不在内存中(页表项非空,但Present标志位=0,比如在swap分区或磁盘文件上);
不满足访问权限(此时页表项P标志=1,但低权限的程序试图访问高权限的地址空间,或者有程序试图写只读页面)。

当出现上面情况之一,那么就会产生页面page fault(#PF)异常。CPU会把产生异常的线性地址存储在CR2中,并且把表示页访问异常类型的值(简称页访问异常错误码,errorCode)保存在中断栈中。CR2是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址。CR2用于发生页异常时报告出错信息。产生页访问异常后,CPU把引起页访问异常的线性地址装到寄存器CR2中,并给出了出错码errorCode,说明了页访问异常的类型。操作系统中对应的中断服务例程可以检查CR2的内容,从而查出线性地址空间中的哪个页引起本次异常。

CPU在当前内核栈保存当前被打断的程序现场,即依次压入当前被打断程序使用的EFLAGS,CS,EIP,errorCode;由于页访问异常的中断号是0xE,CPU把异常中断号0xE对应的中断服务例程的地址(vectors.S中的标号vector14处)加载到CS和EIP寄存器中,开始执行中断服务例程。

这时ucore开始处理异常中断,首先需要保存硬件没有保存的寄存器。在vectors.S中的标号vector14处先把中断号压入内核栈,然后再在trapentry.S中的标号__alltraps处把DS、ES和其他通用寄存器都压栈。自此,被打断的程序执行现场(context)被保存在内核栈中。接下来,在trap.c的trap函数开始了中断服务例程的处理流程,大致调用关系为:

trap —> trap_dispatch —> pgfault_handler —> do_pgfault

ucore中do_pgfault函数是完成页访问异常处理的主要函数,它根据从CPU的控制寄存器CR2中获取的页访问异常的物理地址以及根据errorCode的错误类型来查找此地址是否在某个VMA的地址范围内以及是否满足正确的读写权限,如果在此范围内并且权限也正确,这认为这是一次合法访问,但没有建立虚实对应关系。所以需要分配一个空闲的内存页,并修改页表完成虚地址到物理地址的映射,刷新TLB,然后调用iret产生软中断,返回到产生页访问异常的指令处重新执行此指令。如果该虚地址不在某VMA范围内,则认为是一次非法访问。

页面置换机制的实现

当缺页中断发生时,操作系统把应用程序当前需要的数据或代码放到内存中来,然后重新执行应用程序产生异常的访存指令。如果在把硬盘中对应的数据或代码调入内存前,操作系统发现物理内存已经没有空闲空间了,这时操作系统必须把它认为“不常用”的页换出到磁盘上去,以腾出内存空闲空间给应用程序所需的数据或代码。

  • 先进先出:选择在内存中驻留时间最久的页予以淘汰。将调入内存的页按照调入的先后顺序链接成一个队列,队列头指向内存中驻留时间最久的页,队列尾指向最近被调入内存的页。因为那些常被访问的页,往往在内存中也停留得最久,结果它们因变“老”而不得不被置换出去。FIFO算法的另一个缺点是,它有一种异常现象(Belady现象),即在增加放置页的页帧的情况下,反而使页访问异常次数增多。

  • 时钟替换算法:是LRU算法的一种近似实现。时钟页替换算法把各个页面组织成环形链表的形式,类似于一个钟的表面。然后把一个指针(简称当前指针)指向最老的那个页面,即最先进来的那个页面。另外,时钟算法需要在页表项(PTE)中设置了一位访问位来表示此页表项对应的页当前是否被访问过。当该页被访问时,CPU中的MMU硬件将把访问位置“1”。当操作系统需要淘汰页时,对当前指针指向的页所对应的页表项进行查询,如果访问位为“0”,则淘汰该页,如果该页被写过,则还要把它换出到硬盘上;如果访问位为“1”,则将该页表项的此位置“0”,继续访问下一个页。该算法近似地体现了LRU的思想,且易于实现,开销少,需要硬件支持来设置访问位。时钟页替换算法在本质上与FIFO算法是类似的,不同之处是在时钟页替换算法中跳过了访问位为1的页。

  • 改进时钟页替换算法:在时钟置换算法中,淘汰一个页面时只考虑了页面是否被访问过,但在实际情况中,还应考虑被淘汰的页面是否被修改过。因为淘汰修改过的页面还需要写回硬盘,使得其置换代价大于未修改过的页面,所以优先淘汰没有修改的页,减少磁盘操作次数。改进的时钟置换算法除了考虑页面的访问情况,还需考虑页面的修改情况。即该算法不但希望淘汰的页面是最近未使用的页,而且还希望被淘汰的页是在主存驻留期间其页面内容未被修改过的。这需要为每一页的对应页表项内容中增加一位引用位和一位修改位。当该页被访问时,CPU中的MMU硬件将把访问位置“1”。当该页被“写”时,CPU中的MMU硬件将把修改位置“1”。这样这两位就存在四种可能的组合情况:(0,0)表示最近未被引用也未被修改,首先选择此页淘汰;(0,1)最近未被使用,但被修改,其次选择;(1,0)最近使用而未修改,再次选择;(1,1)最近使用且修改,最后选择。该算法与时钟算法相比,可进一步减少磁盘的I/O操作次数。

页面置换机制

可以被换出的页

只有映射到用户空间且被用户程序直接访问的页面才能被交换,被内核直接使用的内核空间的页面不能被换出!!!操作系统是执行的关键代码,需要保证运行的高效性和实时性,如果在操作系统执行过程中,发生了缺页现象,则操作系统不得不等很长时间(硬盘的访问速度比内存的访问速度慢2到3个数量级),这将导致整个系统运行低效。

当一个Page Table Entry用来描述一般意义上的物理页时,它维护各种权限和映射关系,以及应该有PTE_P标记;但当它用来描述一个被置换出去的物理页时,它被用来维护该物理页与swap磁盘上扇区的映射关系,并且该PTE不应该由MMU将它解释成物理页映射(即没有 PTE_P 标记)

与此同时对应的权限则交由mm_struct来维护,当对位于该页的内存地址进行访问的时候,必然导致 page fault,然后ucore能够根据 PTE 描述的swap项将相应的物理页重新建立起来,并根据虚存所描述的权限重新设置好 PTE 使得内存访问能够继续正常进行。

虚存中的页与硬盘上的扇区之间的映射关系

一个页被换出到硬盘,则PTE最低位present位应该是0,表示虚实地址映射关系不存在,接下来7位为保留位,表示页帧号的24位地址用来表示在硬盘上的地址。

1
2
3
4
\-----------------------------  
| offset | reserved | 0 |
\-----------------------------
24 bits &nbsp;&nbsp; 7 bits &nbsp;&nbsp; 1 bit

执行换入换出的时机

当ucore或应用程序访问地址所在的页不在内存时,就会产生page fault异常,引起调用do_pgfault函数,此函数会判断产生访问异常的地址属于check_mm_struct某个vma表示的合法虚拟地址空间,且保存在硬盘swap文件中。

ucore目前大致有两种策略来实现换出操作,即积极换出策略消极换出策略。积极换出策略是指操作系统周期性地(或在系统不忙的时候)主动把某些认为“不常用”的页换出到硬盘上,从而确保系统中总有一定数量的空闲页存在,这样当需要空闲页时,基本上能够及时满足需求;消极换出策略是指,只是当试图得到空闲页时,发现当前没有空闲的物理页可供分配,这时才开始查找“不常用”页面,并把一个或多个这样的页换出到硬盘上。

页替换算法的数据结构设计

1
2
3
4
5
struct Page {  
……
list_entry_t pra_page_link;
uintptr_t pra_vaddr;
};

pra_page_link构造了按页的第一次访问时间进行排序的一个链表,这个链表的开始表示第一次访问时间最近的页,链表结尾表示第一次访问时间最远的页。当然链表头可以就可设置为pra_list_head(定义在swap_fifo.c中),构造的时机是在page fault发生后,进行do_pgfault函数时。pra_vaddr可以用来记录此物理页对应的虚拟页起始地址。

当一个物理页(struct Page)需要被swap出去的时候,首先需要确保它已经分配了一个位于磁盘上的swap page(由连续的8个扇区组成)。这里为了简化设计,在swap_check函数中建立了每个虚拟页唯一对应的swap page,其对应关系设定为:虚拟页对应的PTE的索引值 = swap page的扇区起始位置*8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct swap_manager  
{
const char *name;
/* swap manager 全局初始化 */
int (*init) (void);
/* 对mm_struct中的数据进行初始化 */
int (*init_mm) (struct mm_struct *mm);
/* 时钟中断处理 */
int (*tick_event) (struct mm_struct *mm);
/* Called when map a swappable page into the mm_struct */
int (*map_swappable) (struct mm_struct *mm, uintptr_t addr, struct Page *page, int swap_in);
/* When a page is marked as shared, this routine is called to delete the addr entry from the swap manager */
int (*set_unswappable) (struct mm_struct *mm, uintptr_t addr);
/* Try to swap out a page, return then victim */
int (*swap_out_victim) (struct mm_struct *mm, struct Page *ptr_page, int in_tick);
/* check the page relpacement algorithm */
int (*check_swap)(void);
};

map_swappable函数用于记录页访问情况相关属性,swap_out_vistim函数用于挑选需要换出的页。显然第二个函数依赖于第一个函数记录的页访问情况。tick_event函数指针也很重要,结合定时产生的中断,可以实现一种积极的换页策略。

  1. 准备:为了实现FIFO置换算法,我们应该管理所有可交换的页面,因此我们可以根据时间顺序将这些页面链接到pra_list_head。 使用list.h中的struct list。 struct list是一个简单的双向链表实现,具体函数包括:list_init,list_add(list_add_after),list_add_before,list_del,list_next,list_prev。 将通用列表结构转换为特殊结构(例如结构页面)。可以找到一些宏:le2page(在memlayout.h中),le2vma(在vmm.h中),le2proc(在proc.h中)等;
  2. _fifo_init_mm:初始化pra_list_head并让mm -> sm_priv指向pra_list_head的addr。 现在,从内存控制struct mm_struct,我们可以调用FIFO算法;
  3. _fifo_map_swappable:将最近访问的页放到 pra_list_head 队列最后;
  4. _fifo_swap_out_victim:最早访问的页面从pra_list_head队列中剔除,然后*ptr_page赋值为这一页。

读代码

1
2
3
4
5
6
7
/*
与虚拟地址范围[VPT,VPT + PTSIZE]对应的页面目录条目(page directory entry,PDE)指向页面目录本身。 因此,页面目录被视为页面表和页面目录。
将页面目录视为页表的一个结果是可以通过虚拟地址VPT处的“虚拟页表(virtual page table,VPT)”访问所有PTE。 数字n的PTE存储在vpt[n]中。
第二个结果是当前页面目录的内容将始终在虚拟地址PGADDR(PDX(VPT),PDX(VPT),0)处可用,vpd设置如下。
*/
pte_t * const vpt = (pte_t *)VPT;
pde_t * const vpd = (pde_t *)PGADDR(PDX(VPT), PDX(VPT), 0);

练习1:给未被映射的地址映射上物理页

完成do_pgfault(mm/vmm.c)函数,给未被映射的地址映射上物理页。设置访问权限的时候 需要参考页面所在VMA的权限,同时需要注意映射物理页时需要操作内存控制结构所指定的页表,而不是内核的页表。

引入虚拟内存后,可能会出现某一些虚拟内存空间是合法的(在vma中),但是还没有为其分配具体的内存页,这样的话,在访问这些虚拟页的时候就会产生pagefault异常,从而使得OS可以在异常处理时完成对这些虚拟页的物理页分配,在中端返回之后就可以正常进行内存的访问了。将出现了异常的线性地址保存在cr2寄存器中;再到trap_dispatch函数,在该函数中会根据中断号,将page fault的处理交给pgfault_handler函数,进一步交给do_pgfault函数进行处理。产生页面异常的原因主要有:

  • 目标页面不存在(页表项全为0,即该线性地址与物理地址尚未建立映射或者已经撤销);
  • 相应的物理页面不在内存中(页表项非空,但Present标志位=0,比如在swap分区或磁盘文件上);
  • 访问权限不符合(此时页表项P标志=1,比如企图写只读页面)。
1
2
3
4
do_pgfault - 处理缺页中断的中断处理例程 interrupt handler to process the page fault execption
@mm : the control struct for a set of vma using the same PDT
@error_code : the error code recorded in trapframe->tf_err which is setted by x86 hardware
@addr : the addr which causes a memory access exception, (the contents of the CR2 register)

调用栈: trap—> trap_dispatch—>pgfault_handler—>do_pgfault

处理器为ucore的do_pgfault函数提供了两项信息,以帮助诊断异常并从中恢复。

(1) CR2寄存器的内容。 处理器使用产生异常的32位线性地址加载CR2寄存器。 do_pgfault可以使用此地址来查找相应的页面目录和页表条目。

(2) 在内核栈中的错误码。缺页错误码与其他异常的错误码不同,错误码可以通知中断处理例程以下信息:

  • P flag(bit 0) 表明异常是否是因为一个不存在的页(0)或违反访问权限或使用保留位(1);
  • W/R flag(bit 1) 表明引起异常的访存操作是读(0)还是写(1);
  • U/S flag (bit 2) 表明引起异常时处理器是在用户态(1)还是内核态(0)

do_pgfault(struct mm_struct *mm, uint32_t error_code, uintptr_t addr)

第一个是一个mm_struct变量,其中保存了所使用的PDT,合法的虚拟地址空间(使用链表组织),以及与后文的swap机制相关的数据;而第二个参数是产生pagefault的时候硬件产生的error code,可以用于帮助判断发生page fault的原因,而最后一个参数则是出现page fault的线性地址(保存在cr2寄存器中的线性地址)。

  1. 查询mm_struct中的虚拟地址链表(线性地址对等映射,因此线性地址等于虚拟地址),确定出现page_fault的线性地址是否合法;
  2. 使用error code(包含了这次内存访问为读/写,对应物理页是否存在)判断是否出现权限问题,如果出现问题则直接返回;
  3. 根据合法虚拟地址(mm_struct中保存的合法虚拟地址链表中)生成对应产生的物理页的权限;
  4. 使用get_pte获取出错的线性地址所对应的虚拟页起始地址对应到的页表项,同时使用页表项保存物理地址(P为1)和被换出的物理页在swap中的位置(P为0),并规定swap中第0个页空出来不用于交换。
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
int do_pgfault(struct mm_struct *mm, uint32_t error_code, uintptr_t addr) {
int ret = -E_INVAL;

//根据传进的mm和地址addr,找一个vma,这个vma是在mm的mmap_cache中的,find_vma主要是先找mm中的mmap_cache,如果还不存在,就在mm的mmap_list中找,这个vma用le2vma宏进行转换,直到找到一个地址空间合适的vma,把这个vma赋值给mmap_cache。
struct vma_struct *vma = find_vma(mm, addr);

pgfault_num++;
//检查找到的vma是否为空或符合地址范围
if (vma == NULL || vma->vm_start > addr) {
cprintf("not valid addr %x, and can not find it in vma\n", addr);
goto failed;
}
//如果present位是0,代表没有映射关系,不存在物理页和虚拟页帧的对应关系
//error_code在cr2寄存器中的后几位,对这个errorcode进行判断,确定读写权限和p位是否为1
switch (error_code & 3) {
default:
/* error code flag : default is 3 ( W/R=1, P=1): write, present */
case 2: /* error code flag : (W/R=1, P=0): write, not present */
if (!(vma->vm_flags & VM_WRITE)) {
cprintf("do_pgfault failed: error code flag = write AND not present, but the addr's vma cannot write
\n");
goto failed;
}
break;
case 1: /* error code flag : (W/R=0, P=1): read, present */
cprintf("do_pgfault failed: error code flag = read AND present\n");
goto failed;
case 0: /* error code flag : (W/R=0, P=0): read, not present */
if (!(vma->vm_flags & (VM_READ | VM_EXEC))) {
cprintf("do_pgfault failed: error code flag = read AND not present, but the addr's vma cannot read or exec\n");
goto failed;
}
}

/* IF (write an existed addr ) OR
* (write an non_existed addr && addr is writable) OR
* (read an non_existed addr && addr is readable)
* THEN
* continue process
* 写一个存在的地址、写一个不存在的地址但是地址是可写的、读一个不存在的地址但是地址是可读的
*/
uint32_t perm = PTE_U;
if (vma->vm_flags & VM_WRITE) {
perm |= PTE_W;
}
// 生成一个权限控制
addr = ROUNDDOWN(addr, PGSIZE);
// 向下舍入到n的最接近的倍数

ret = -E_NO_MEM;

pte_t *ptep=NULL;
/*LAB3 EXERCISE 1: YOUR CODE
* 本次实验用到的宏和定义:
* get_pte : 获得pte,返回pte的线性地址、虚拟地址
* if the PT contians this pte didn't exist, alloc a page for PT (notice the 3th parameter '1')
* pgdir_alloc_page : 调用alloc_page 和 page_insert 分配一个页大小的内存空间,设置物理地址和线性地址的映射关系
* DEFINES:
* VM_WRITE : If vma->vm_flags & VM_WRITE == 1/0, then the vma is writable/non writable
* PTE_W 0x002 // page table/directory entry flags bit : Writeable
* PTE_U 0x004 // page table/directory entry flags bit : User can access
* VARIABLES:
* mm->pgdir : the PDT of these vma
*
*/

/*LAB3 EXERCISE 1: YOUR CODE*/
ptep = get_pte(mm->pgdir, addr, 1);
// 第三个参数create代表是否在查找page_directory的过程中没找到的话要不要创建,在这里要创建
if (ptep == NULL) {
cprintf("get_pte return a NULL.\n");
goto failed;
}
//(1) 找到一个pte,如果需要的物理页是没有分配而不是被换出到外存中
//如果物理地址不存在,则分配一个页面并使用逻辑地址映射物理地址,pgdir_alloc_page一个函数就能分配页和设置映射关系
if (*ptep == 0) {
struct Page* page = pgdir_alloc_page(mm->pgdir, addr, perm);
if(page == NULL) {
cprintf("pgdir_alloc_page return a NULL.\n");
goto failed;
}
}
else {
/*LAB3 EXERCISE 2: YOUR CODE
* 现在我们认为这个pte是一个swap的,我们应该将数据从disk加载到带有物理地址的页面,并将物理地址映射到逻辑地址,触发交换管理器来记录该页面的访问情况。
*
* MACROs or Functions:
* swap_in(mm, addr, &page) : 分配一个内存页,根据PTE中的swap地址找到磁盘页的地址,读进内存页中
* page_insert : 创建页的物理地址和线性地址的映射关系
* swap_map_swappable : 设置这一个页是可交换的
*/
if(swap_init_ok) { // 判断是否当前交换机制正确被初始化
struct Page *page=NULL;
ret = swap_in(mm, addr, &page); // 将物理页换入到内存中
if (ret != 0) {
cprintf("swap_in failed.\n");
goto failed;
}
page_insert(mm->pgdir, page, addr, perm);
//(2) According to the mm, addr AND page, setup the map of phy addr <---> logical addr
// 将物理页与虚拟页建立映射关系
swap_map_swappable(mm, addr, page, 1);
//(3) make the page swappable。设置当前的物理页为可交换的
page->pra_vaddr = addr;
//同时在物理页中维护其对应到的虚拟页的信息;
//网上有人说这个语句最好应当放置在page_insert函数中,
//在该建立映射关系的函数外对物理page对应的虚拟地址进行维护显得有些不太合适(感觉好有道理)
}
else {
cprintf("no swap_init_ok but ptep is %x, failed\n",*ptep);
goto failed;
}
}

ret = 0;
failed:
return ret;
}

问题:

  • 请描述页目录项(Page Director Entry)和页表(Page Table Entry)中组成部分对ucore实现页替换算法的潜在用处。

首先不妨先分析PDE以及PTE中各个组成部分以及其含义;

接下来先描述页目录项的每个组成部分,PDE(页目录项)的具体组成如下图所示;描述每一个组成部分的含义如下:

  • 前20位表示4K对齐的该PDE对应的页表起始位置(物理地址,该物理地址的高20位即PDE中的高20位,低12位为0);
  • 第9-11位未被CPU使用,可保留给OS使用;
  • 接下来的第8位可忽略;
  • 第7位用于设置Page大小,0表示4KB;
  • 第6位恒为0;
  • 第5位用于表示该页是否被使用过;
  • 第4位设置为1则表示不对该页进行缓存;
  • 第3位设置是否使用write through缓存写策略;
  • 第2位表示该页的访问需要的特权级;
  • 第1位表示是否允许读写;
  • 第0位为该PDE的存在位;

接下来描述页表项(PTE)中的每个组成部分的含义,具体组成如下图所示:

  • 高20位与PDE相似的,用于表示该PTE指向的物理页的物理地址;
  • 9-11位保留给OS使用;
  • 7-8位恒为0;
  • 第6位表示该页是否为dirty,即是否需要在swap out的时候写回外存;
  • 第5位表示是否被访问;
  • 3-4位恒为0;
  • 0-2位分别表示存在位、是否允许读写、访问该页需要的特权级;

可以发现无论是PTE还是TDE,都具有着一些保留的位供操作系统使用,也就是说ucore可以利用这些位来完成一些其他的内存管理相关的算法,比如可以在这些位里保存最近一段时间内该页的被访问的次数(仅能表示0-7次),用于辅助近似地实现虚拟内存管理中的换出策略的LRU之类的算法;也就是说这些保留位有利于OS进行功能的拓展;

作者:AmadeusChan
链接:https://www.jianshu.com/p/8d6ce61ac678
来源:简书

如果ucore的缺页服务例程在执行过程中访问内存,出现了页访问异常,请问硬件要做哪些事情?

考虑到ucore的缺页服务例程如果在访问内容中出现了缺页异常,则会有可能导致ucore最终无法完成缺页的处理,因此一般不应该将缺页的ISR以及OS中的其他一些关键代码或者数据换出到外存中,以确保操作系统的正常运行;如果缺页ISR在执行过程中遇到页访问异常,则最终硬件需要完成的处理与正常出现页访问异常的处理相一致,均为:

  • 将发生错误的线性地址保存在cr2寄存器中;
  • 在中断栈中依次压入EFLAGS,CS, EIP,以及页访问异常码errorcode,由于ISR一定是运行在内核态下的,因此不需要压入ss和esp以及进行栈的切换;
  • 根据中断描述符表查询到对应页访问异常的ISR,跳转到对应的ISR处执行,接下来将由软件进行处理;

练习2:补充完成基于FIFO的页面替换算法

维基百科:最简单的页面替换算法(Page Replace Algorithm)是FIFO算法。先进先出页面替换算法是一种低开销算法。这个想法从名称中可以明显看出 - 操作系统跟踪队列中内存中的所有页面,最近到达的放在后面,最早到达的放在前面。当需要更换页面时,会选择队列最前面的页面(最旧的页面)。虽然FIFO开销小且直观,但在实际应用中表现不佳。因此,它很少以未修改的形式使用。该算法存在Belady异常。

FIFO的详细信息

  1. 准备:为了实现FIFO,我们应该管理所有可交换的页面,这样我们就可以按照时间顺序将这些页面链接到pra_list_head。将通用list换为特殊结构(例如Page);
  2. _fifo_init_mm:初始化pra_list_head并让mm-> sm_priv指向pra_list_head的addr。 现在,从内存控制struct mm_struct,我们可以访问FIFO;
  3. _fifo_map_swappable: 最近到达的页需要放到pra_list_head队列的最末尾;
  4. _fifo_swap_out_victim: 最早到达的页面在pra_list_head队列最前边,我们应该将它踢出去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
将当前的物理页面插入到FIFO算法中维护的可被交换出去的物理页面链表中的末尾,从而保证该链表中越接近链表头的物理页面在内存中的驻留时间越长;
static int _fifo_map_swappable(struct mm_struct *mm, uintptr_t addr, struct Page *page, int swap_in)
{
list_entry_t *head=(list_entry_t*) mm->sm_priv;
// 找到链表入口
list_entry_t *entry=&(page->pra_page_link);
// 找到当前物理页用于组织成链表的list_entry_t

assert(entry != NULL && head != NULL);
/*LAB3 EXERCISE 2: YOUR CODE*/
// link the most recent arrival page at the back of the pra_list_head qeueue
// 将当前指定的物理页插入到链表的末尾
list_add(head, entry);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static int
_fifo_swap_out_victim(struct mm_struct *mm, struct Page ** ptr_page, int in_tick)
{
list_entry_t *head=(list_entry_t*) mm->sm_priv;
// 找到链表的入口
assert(head != NULL);
assert(in_tick==0);
/* Select the victim */
/*LAB3 EXERCISE 2: YOUR CODE*/
// unlink the earliest arrival page in front of pra_list_head qeueue
//list_entry_t *le = head->prev; the given answer
list_entry_t *le = list_next(head);
// 取出链表头,即最早进入的物理页面
assert(le != NULL);
// 确保链表非空
struct Page *p = le2page(le,pra_page_link);
// 找到对应的物理页面的Page结构
list_del(le);
// 从链表上删除取出的即将被换出的物理页面
assert(p != NULL);
*ptr_page = p;
// assign the value of *ptr_page to the addr of this page
return 0;
}

如果在_fifo_map_swappable函数中使用的是list_add_before的话,在_fifo_swap_out_victim中应该使用list_next(head)取得要被删除的页;如果在_fifo_map_swappable函数中使用的是list_add的话,在_fifo_swap_out_victim中应该使用head->prev取得要被删除的页;这个链表是双向循环链表!

如果要在ucore上实现”extended clock页替换算法”请给你的设计方案,现有的swap_manager框架是否足以支持在ucore中实现此算法?如果是,请给你的设计方案。如果不是,请给出你的新的扩展和基此扩展的设计方案。并需要回答如下问题

在现有框架基础上可以支持Extended clock算法。

根据上文中提及到的PTE的组成部分可知,PTE中包含了dirty位和访问位,因此可以确定某一个虚拟页是否被访问过以及写过,但是,考虑到在替换算法的时候是将物理页面进行换出,而可能存在着多个虚拟页面映射到同一个物理页面这种情况,也就是说某一个物理页面是否dirty和是否被访问过是有这些所有的虚拟页面共同决定的,而在原先的实验框架中,物理页的描述信息Page结构中默认只包括了一个对应的虚拟页的地址,应当采用链表的方式,在Page中扩充一个成员,把物理页对应的所有虚拟页都给保存下来;而物理页的dirty位和访问位均为只需要某一个对应的虚拟页对应位被置成1即可置成1;

完成了上述对物理页描述信息的拓展之后,考虑对FIFO算法的框架进行修改得到拓展时钟算法的框架,由于这两种算法都是将所有可以换出的物理页面均按照进入内存的顺序连成一个环形链表,因此初始化,将某个页面置为可以/不可以换出这些函数均不需要进行大的修改(小的修改包括在初始化当前指针等),唯一需要进行重写的函数是选择换出物理页的函数swap_out_victim,对该函数的修改如下:

从当前指针开始,对环形链表进行扫描,根据指针指向的物理页的状态(表示为(access, dirty))来确定应当进行何种修改:如果状态是(0, 0),则将该物理页面从链表上去下,该物理页面记为换出页面,但是由于这个时候这个页面不是dirty的,因此事实上不需要将其写入swap分区;

如果状态是(0,1),则将该物理页对应的虚拟页的PTE中的dirty位都改成0,并且将该物理页写入到外存中,然后指针跳转到下一个物理页;如果状态是(1, 0), 将该物理页对应的虚拟页的PTE中的访问位都置成0,然后指针跳转到下一个物理页面;如果状态是(1, 1),则该物理页的所有对应虚拟页的PTE中的访问为置成0,然后指针跳转到下一个物理页面;

需要被换出的页的特征是什么?

该物理页在当前指针上一次扫过之前没有被访问过;
该物理页的内容与其在外存中保存的数据是一致的, 即没有被修改过;

在ucore中如何判断具有这样特征的页?

在ucore中判断具有这种特征的页的方式已经在上文设计方案中提及过了,具体为:

假如某物理页对应的所有虚拟页中存在一个dirty的页,则认为这个物理页为dirty,否则不这么认为;
假如某物理页对应的所有虚拟页中存在一个被访问过的页,则认为这个物理页为被访问过的,否则不这么认为;

何时进行换入和换出操作?

在产生page fault的时候进行换入操作;
换出操作源于在算法中将物理页的dirty从1修改成0的时候,因此这个时候如果不进行写出到外存,就会造成数据的不一致,具体写出内存的时机是比较细节的问题, 可以在修改dirty的时候写入外存,或者是在这个物理页面上打一个需要写出的标记,到了最终删除这个物理页面的时候,如果发现了这个写出的标记,则在这个时候再写入外存;后者使用一个写延迟标记,有利于多个写操作的合并,从而降低缺页的代价;

实验四

实验目的

了解内核线程创建/执行的管理过程
了解内核线程的切换和基本调度过程

实验内容

当一个程序加载到内存中运行时,首先通过ucore OS的内存管理子系统分配合适的空间,然后就需要考虑如何分时使用CPU来“并发”执行多个程序,让每个运行的程序(这里用线程或进程表示)“感到”它们各自拥有“自己”的CPU。

内核线程是一种特殊的进程,内核线程与用户进程的区别有两个:

  • 内核线程只运行在内核态
  • 用户进程会在在用户态和内核态交替运行
  • 所有内核线程共用ucore内核内存空间,不需为每个内核线程维护单独的内存空间
  • 用户进程需要维护各自的用户内存空间

预备知识

内核线程管理

本实验实现了让ucore实现分时共享CPU,实现多条控制流能够并发执行。内核线程是一种特殊的进程,内核线程与用户进程的区别有两个:

  • 内核线程只运行在内核态而用户进程会在在用户态和内核态交替运行
  • 所有内核线程直接使用共同的ucore内核内存空间,不需为每个内核线程维护单独的内存空间而用户进程需要维护各自的用户内存空间。

设计管理线程的数据结构,即进程控制块(PCB)。创建内核线程对应的进程控制块,把这些进程控制块通过链表连在一起,便于随时进行插入,删除和查找操作。通过调度器(scheduler)来让不同的内核线程在不同的时间段占用CPU执行,实现对CPU的分时共享。

kern/init/init.c中的kern_init函数中,当完成虚拟内存的初始化工作vmm_init()后,就调用了proc_init函数。

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
void
proc_init(void) {
int i;

list_init(&proc_list);
// initialize the process double linked list
for (i = 0; i < HASH_LIST_SIZE; i ++) {
list_init(hash_list + i);
}

if ((idleproc = alloc_proc()) == NULL) {
panic("cannot alloc idleproc.\n");
}

idleproc->pid = 0;
idleproc->state = PROC_RUNNABLE;
idleproc->kstack = (uintptr_t)bootstack;
idleproc->need_resched = 1;
// 完成了idleproc内核线程创建
set_proc_name(idleproc, "idle");
nr_process ++;

current = idleproc;

int pid = kernel_thread(init_main, "Hello world!!", 0);
if (pid <= 0) {
panic("create init_main failed.\n");
}

initproc = find_proc(pid);
// initproc内核线程的创建
set_proc_name(initproc, "init");

assert(idleproc != NULL && idleproc->pid == 0);
assert(initproc != NULL && initproc->pid == 1);
}

idleproc内核线程的工作就是不停地查询,看是否有其他内核线程可以执行了,如果有,马上让调度器选择那个内核线程执行(请参考cpu_idle函数的实现)。所以idleproc内核线程是在ucore操作系统没有其他内核线程可执行的情况下才会被调用

接着就是调用kernel_thread函数来创建initproc内核线程。initproc内核线程的工作就是显示“Hello World”,表明自己存在且能正常工作了。
调度器会在特定的调度点上执行调度,完成进程切换。

在lab4中,这个调度点就一处,即在cpu_idle函数中,此函数如果发现当前进程(也就是idleproc)的need_resched置为1(在初始化idleproc的进程控制块时就置为1了),则调用schedule函数,完成进程调度和进程切换。进程调度的过程其实比较简单,就是在进程控制块链表中查找到一个“合适”的内核线程,所谓“合适”就是指内核线程处于“PROC_RUNNABLE”状态。

在接下来的switch_to函数(在后续有详细分析,有一定难度,需深入了解一下)完成具体的进程切换过程。一旦切换成功,那么initproc内核线程就可以通过显示字符串来表明本次实验成功。

进程管理信息用struct proc_struct表示,在kern/process/proc.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct proc_struct {
enum proc_state state; // Process state
int pid; // Process ID
int runs; // the running times of Proces
uintptr_t kstack; // Process kernel stack
volatile bool need_resched; // need to be rescheduled to release CPU?
struct proc_struct *parent; // the parent process
struct mm_struct *mm; // Process's memory management field
struct context context; // Switch here to run process
struct trapframe *tf; // Trap frame for current interrupt
uintptr_t cr3; // the base addr of Page Directroy Table(PDT)
uint32_t flags; // Process flag
char name[PROC_NAME_LEN + 1]; // Process name
list_entry_t list_link; // Process link list
list_entry_t hash_link; // Process hash list
};

  • mm:内存管理的信息。在lab3中有涉及,主要包括内存映射列表、页表指针等。在实际OS中,内核线程常驻内存,不需要考虑swap page问题,在用户进程中考虑进程用户内存空间的swap_page问题时mm才会发挥作用。所以在lab4中mm对于内核线程就没有用了,这样内核线程的proc_struct的成员变量mm=0是合理的。mm里有个很重要的项pgdir,记录的是该进程使用的一级页表的物理地址。由于mm=NULL,所以在proc_struct数据结构中需要有一个代替pgdir项来记录页表起始地址,这就是proc_struct数据结构中的cr3成员变量。
  • state:进程所处的状态。
1
2
3
4
5
6
enum proc_state {
PROC_UNINIT = 0, // uninitialized
PROC_SLEEPING, // sleeping
PROC_RUNNABLE, // runnable(maybe running)
PROC_ZOMBIE, // almost dead, and wait parent proc to reclaim his resource
};
  • parent:用户进程的父进程(创建它的进程)。在所有进程中,只有一个进程没有父进程,就是内核创建的第一个内核线程idleproc。内核根据这个父子关系建立一个树形结构,用于维护一些特殊的操作,例如确定某个进程是否可以对另外一个进程进行某种操作等等。
  • context:进程的上下文,用于进程切换(参见switch.S)。在uCore中,所有的进程在内核中也是相对独立的(例如独立的内核堆栈以及上下文等等)。使用context保存寄存器的目的就在于在内核态中能够进行上下文之间的切换。实际利用context进行上下文切换的函数是在kern/process/switch.S中定义switch_to。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 在上下文切换时保存寄存器信息,其中有些寄存器貌似不被保存,为了省事
    // The 这个结构体的布局要跟switch.S中的switch_to操作对应。
    struct context {
    uint32_t eip;
    uint32_t esp;
    uint32_t ebx;
    uint32_t ecx;
    uint32_t edx;
    uint32_t esi;
    uint32_t edi;
    uint32_t ebp;
    };
  • tf:中断帧的指针,总是指向内核栈的某个位置:当进程从用户空间跳到内核空间时,中断帧记录了进程在被中断前的状态。当内核需要跳回用户空间时,需要调整中断帧以恢复让进程继续执行的各寄存器值。除此之外,uCore内核允许嵌套中断。因此为了保证嵌套中断发生时tf总是能够指向当前的trapframe,uCore在内核栈上维护了tf的链。
  • cr3: cr3 保存页表的物理地址,目的就是进程切换的时候方便直接使用lcr3实现页表切换,避免每次都根据 mm 来计算 cr3。mm数据结构是用来实现用户空间的虚存管理的,但是内核线程没有用户空间,它执行的只是内核中的一小段代码(通常是一小段函数),所以它没有mm结构,也就是NULL。当某个进程是一个普通用户态进程的时候,PCB中的cr3就是mm中页表(pgdir)的物理地址;而当它是内核线程的时候,cr3等于boot_cr3。而boot_cr3指向了uCore启动时建立好的内核虚拟空间的页目录表首地址。
  • kstack: 每个线程都有一个内核栈,并且位于内核地址空间的不同位置。对于内核线程,该栈就是运行时的程序使用的栈;而对于普通进程,该栈是发生特权级改变的时候使保存被打断的硬件信息用的栈。uCore在创建进程时分配了 2 个连续的物理页(参见memlayout.h中KSTACKSIZE的定义)作为内核栈的空间。这个栈很小,所以内核中的代码应该尽可能的紧凑,并且避免在栈上分配大的数据结构,以免栈溢出,导致系统崩溃。kstack记录了分配给该进程/线程的内核栈的位置。主要作用有以下几点。

首先,当内核准备从一个进程切换到另一个的时候,需要根据kstack 的值正确的设置好tss,以便在进程切换以后再发生中断时能够使用正确的栈。

其次,内核栈位于内核地址空间,并且是不共享的(每个线程都拥有自己的内核栈),因此不受到 mm 的管理,当进程退出的时候,内核能够根据 kstack 的值快速定位栈的位置并进行回收。uCore 的这种内核栈的设计借鉴的是 linux 的方法(但由于内存管理实现的差异,它实现的远不如 linux 的灵活),它使得每个线程的内核栈在不同的位置,这样从某种程度上方便调试,但同时也使得内核对栈溢出变得十分不敏感,因为一旦发生溢出,它极可能污染内核中其它的数据使得内核崩溃。如果能够通过页表,将所有进程的内核栈映射到固定的地址上去,能够避免这种问题,但又会使得进程切换过程中对栈的修改变得相当繁琐。

为了管理系统中所有的进程控制块,uCore维护了如下全局变量(位于kern/process/proc.c):

  • static struct proc *current:当前占用CPU且处于“运行”状态进程控制块指针。通常这个变量是只读的,只有在进程切换的时候才进行修改,并且整个切换和修改过程需要保证操作的原子性,目前至少需要屏蔽中断。可以参考 switch_to 的实现。
  • static struct proc *initproc:本实验中,指向一个内核线程。本实验以后,此指针将指向第一个用户态进程。
  • static list_entry_t hash_list[HASH_LIST_SIZE]:所有进程控制块的哈希表,proc_struct中的成员变量hash_link将基于pid链接入这个哈希表中。
  • list_entry_t proc_list:所有进程控制块的双向线性列表,proc_struct中的成员变量list_link将链接入这个链表中。

创建并执行内核线程

ucore实现了一个简单的进程/线程机制,进程包含独立的地址空间,至少一个线程、内核数据、进程状态、文件等。ucore需要高效地管理所有细节。在ucore,一个线程看成一个特殊的进程(process)。

进程状态 意义 原因
PROC_UNINIT uninitialized alloc_proc
PROC_SLEEPING sleeping try_free_pages, do_wait, do_sleep
PROC_RUNNABLE runnable(maybe running) proc_init, wakeup_proc,
PROC_ZOMBIE almost dead do_exit

进程之间的关系:

  • parent: proc->parent (proc is children)
  • children: proc->cptr (proc is parent)
  • older sibling: proc->optr (proc is younger sibling)
  • younger sibling: proc->yptr (proc is older sibling)

建立进程控制块(proc.c中的alloc_proc函数)。首先,考虑最简单的内核线程,它通常只是内核中的一小段代码或者函数,没有自己的“专属”空间。这是由于在uCore OS启动后,已经对整个内核内存空间进行了管理,通过设置页表建立了内核虚拟空间(即boot_cr3指向的二级页表描述的空间)。所以uCore OS内核中的所有线程都不需要再建立各自的页表,只需共享这个内核虚拟空间就可以访问整个物理内存了。从这个角度看,内核线程被uCore OS内核这个大“内核进程”所管理。

创建第 0 个内核线程 idleproc

在init.c中的kern_init函数调用了proc.c中的proc_init函数。proc_init函数启动了创建内核线程的步骤。

首先当前的执行上下文(从kern_init启动至今)就可以看成是uCore内核(也可看做是内核进程)中的一个内核线程的上下文。为此,uCore通过给当前执行的上下文分配一个进程控制块以及对它进行相应初始化,将其打造成第0个内核线程——idleproc。具体步骤如下:

  • 首先调用alloc_proc函数来通过kmalloc函数获得proc_struct结构的一块内存块,作为第0个进程控制块,并把proc进行初步初始化(即把proc_struct中的各个成员变量清零)。但有些成员变量设置了特殊的值,比如:
1
2
3
4
proc->state = PROC_UNINIT;  设置进程为“初始”态
proc->pid = -1; 设置进程pid的未初始化值
proc->cr3 = boot_cr3; 由于该内核线程在内核中运行,故采用为uCore内核已经建立的页表,
即设置为在uCore内核页表的起始地址boot_cr3,使用内核页目录表的基址

内核线程共用一个映射内核空间的页表,这表示内核空间对所有内核线程都是“可见”的,所以更精确地说,这些内核线程都应该是从属于同一个唯一的“大内核进程”—uCore内核。

  • proc_init函数对idleproc内核线程进行进一步初始化:
1
2
3
4
5
idleproc->pid = 0;
idleproc->state = PROC_RUNNABLE;
idleproc->kstack = (uintptr_t)bootstack;
idleproc->need_resched = 1;
set_proc_name(idleproc, "idle");

第一条将pid赋值为0,表明idleproc是第0个内核线程。

第二条语句改变了idleproc的状态,使其变为“准备工作”,现在只要uCore调度便可执行。

第三条语句设置了idleproc所使用的内核栈的起始地址。需要注意以后的其他线程的内核栈都需要通过分配获得,因为uCore启动时设置的内核栈就直接分配给idleproc使用了所以这里不用分配

第四条把idleproc->need_resched设置为“1”,在cpu_idle函数中指明如果进程的need_resched为1那么就可以调度执行其他的了,如果当前idleproc在执行,则只要此标志为1,马上就调用schedule函数要求调度器切换其他进程执行。

创建第 1 个内核线程 initproc

第0个内核线程主要工作是完成内核中各个子系统的初始化。uCore接下来还需创建其他进程来完成各种工作,通过调用kernel_thread函数创建了一个内核线程init_main。

1
2
3
4
5
6
7
8
// init_main - the second kernel thread used to create user_main kernel threads
static int
init_main(void *arg) {
cprintf("this initproc, pid = %d, name = \"%s\"\n", current->pid, get_proc_name(current));
cprintf("To U: \"%s\".\n", (const char *)arg);
cprintf("To U: \"en.., Bye, Bye. :)\"\n");
return 0;
}

下面我们来分析一下创建内核线程的函数kernel_thread。kernel_thread函数采用了局部变量tf来放置保存内核线程的临时中断帧,并把中断帧的指针传递给do_fork函数,而do_fork函数会调用copy_thread函数来在新创建的进程内核栈上专门给进程的中断帧分配一块空间。给中断帧分配完空间后,就需要构造新进程的中断帧,具体过程是:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kernel_thread(int (*fn)(void *), void *arg, uint32_t clone_flags)
{
struct trapframe tf;
memset(&tf, 0, sizeof(struct trapframe));
// 给tf进行清零初始化
tf.tf_cs = KERNEL_CS;
tf.tf_ds = tf_struct.tf_es = tf_struct.tf_ss = KERNEL_DS;
// 设置中断帧的代码段(tf.tf_cs)和数据段(tf.tf_ds/tf_es/tf_ss)为内核空间的段(KERNEL_CS/KERNEL_DS)
tf.tf_regs.reg_ebx = (uint32_t)fn;
// fn是函数主体
tf.tf_regs.reg_edx = (uint32_t)arg;
// arg是fn函数的参数
tf.tf_eip = (uint32_t)kernel_thread_entry;
// tf.tf_eip的指出了initproc内核线程从kernel_thread_entry开始执行
return do_fork(clone_flags | CLONE_VM, 0, &tf);
}

kernel_thread_entry是entry.S中实现的汇编函数,它做的事情很简单:
1
2
3
4
5
kernel_thread_entry: # void kernel_thread(void)
pushl %edx # push arg
call *%ebx # call fn
pushl %eax # save the return value of fn(arg)
call do_exit # call do_exit to terminate current thread

从上可以看出,kernel_thread_entry函数主要为内核线程的主体fn函数做了一个准备开始和结束运行的“壳”:

  • 把函数fn的参数arg(保存在edx寄存器中)压栈;
  • 调用fn函数
  • 把函数返回值eax寄存器内容压栈
  • 调用do_exit函数退出线程执行。

do_fork是创建线程的主要函数。kernel_thread函数通过调用do_fork函数最终完成了内核线程的创建工作。do_fork函数主要做了以下6件事情:

  • 分配并初始化进程控制块(alloc_proc函数);
  • 分配并初始化内核栈(setup_stack函数);
  • 根据clone_flag标志复制或共享进程内存管理结构(copy_mm函数);
  • 设置进程在内核(将来也包括用户态)正常运行和调度所需的中断帧和执行上下文(copy_thread函数);
  • 把设置好的进程控制块放入hash_list和proc_list两个全局进程链表中;
  • 进程已经准备好执行了,把进程状态设置为“就绪”态;设置返回码为子进程的id号。

copy_thread函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void
copy_thread(struct proc_struct *proc, uintptr_t esp, struct trapframe *tf) {
proc->tf = (struct trapframe *)(proc->kstack + KSTACKSIZE) - 1;
// 在内核堆栈的顶部设置中断帧大小的一块栈空间
*(proc->tf) = *tf;
// 拷贝在kernel_thread函数建立的临时中断帧的初始值
proc->tf->tf_regs.reg_eax = 0;
// 设置子进程/线程执行完do_fork后的返回值
proc->tf->tf_esp = esp;
// 设置中断帧中的栈指针esp
proc->tf->tf_eflags |= FL_IF;
// 使能中断
// 以上两句设置中断帧中的栈指针esp和标志寄存器eflags,特别是eflags设置了FL_IF标志,
// 这表示此内核线程在执行过程中,能响应中断,打断当前的执行。
proc->context.eip = (uintptr_t)forkret;
proc->context.esp = (uintptr_t)(proc->tf);
}

对于initproc而言,它的中断帧如下所示:
1
2
3
4
5
6
7
8
9
10
11
//所在地址位置
initproc->tf= (proc->kstack+KSTACKSIZE) – sizeof (struct trapframe);
//具体内容
initproc->tf.tf_cs = KERNEL_CS;
initproc->tf.tf_ds = initproc->tf.tf_es = initproc->tf.tf_ss = KERNEL_DS;
initproc->tf.tf_regs.reg_ebx = (uint32_t)init_main;
initproc->tf.tf_regs.reg_edx = (uint32_t) ADDRESS of "Helloworld!!";
initproc->tf.tf_eip = (uint32_t)kernel_thread_entry;
initproc->tf.tf_regs.reg_eax = 0;
initproc->tf.tf_esp = esp;
initproc->tf.tf_eflags |= FL_IF;

设置好中断帧后,最后就是设置initproc的进程上下文。uCore调度器选择了initproc执行,需要根据initproc->context中保存的执行现场来恢复initproc的执行。这里设置了initproc的执行现场中主要的两个信息:

  • 上次停止执行时的下一条指令地址context.eip
  • 上次停止执行时的堆栈地址context.esp。

可以看出,由于initproc的中断帧占用了实际给initproc分配的栈空间的顶部,所以initproc就只能把栈顶指针context.esp设置在initproc的中断帧的起始位置。根据context.eip的赋值,可以知道initproc实际开始执行的地方在forkret函数(主要完成do_fork函数返回的处理工作)处。至此,initproc内核线程已经做好准备执行了。

调度并执行内核线程 initproc

在uCore执行完proc_init函数后,就创建好了两个内核线程:idleprocinitproc,这时uCore当前的执行现场就是idleproc,等到执行到init函数的最后一个函数cpu_idle之前,uCore的所有初始化工作就结束了,idleproc将通过执行cpu_idle函数让出CPU,给其它内核线程执行,具体过程如下:

1
2
3
4
5
6
7
8
void
cpu_idle(void) {
while (1) {
if (current->need_resched) {
schedule();
}
}
}

首先,判断当前内核线程idleproc的need_resched是否不为0,idleproc中的need_resched本就置为1,所以会马上调用schedule函数找其他处于“就绪”态的进程执行。uCore的调度器为FIFO调度器,其核心就是schedule函数。它的执行逻辑很简单:

  • 设置当前内核线程current->need_resched为0;
  • 在proc_list队列中查找下一个处于“就绪”态的线程或进程;
  • 找到这样的进程后,就调用proc_run函数,保存当前进程current的上下文,恢复新进程的执行现场,完成进程切换。

uCore通过proc_run和进一步的switch_to函数完成两个执行现场的切换,具体流程如下:

  • 让current指向next内核线程initproc;
  • 设置任务状态段ts中特权态0下的栈顶指针esp0为next内核线程initproc的内核栈的栈顶,即next->kstack + KSTACKSIZE;
  • 设置CR3寄存器的值为next内核线程initproc的页目录表起始地址next->cr3,这实际上是完成进程间的页表切换;
  • 由switch_to函数完成具体的两个线程的执行现场切换,即切换各个寄存器,当switch_to函数执行完“ret”指令后,就切换到initproc执行了。

注意,在第二步设置任务状态段ts中特权态0下的栈顶指针esp0的目的是建立好内核线程将来用户线程在执行特权态切换(从特权态0<—>特权态3,或从特权态3<—>特权态0)时能够正确定位处于特权态0时进程的内核栈的栈顶,而这个栈顶其实放了一个trapframe结构的内存空间。如果是在特权态3发生了中断/异常/系统调用,则CPU会从特权态3—>特权态0,且CPU从此栈顶(当前被打断进程的内核栈顶)开始压栈来保存被中断/异常/系统调用打断的用户态执行现场;如果是在特权态0发生了中断/异常/系统调用,则CPU会从从当前内核栈指针esp所指的位置开始压栈保存被中断/异常/系统调用打断的内核态执行现场。反之,当执行完对中断/异常/系统调用打断的处理后,最后会执行一个“iret”指令。在执行此指令之前,CPU的当前栈指针esp一定指向上次产生中断/异常/系统调用时CPU保存的被打断的指令地址CS和EIP,“iret”指令会根据ESP所指的保存的址CS和EIP恢复到上次被打断的地方继续执行。

第四步proc_run函数调用switch_to函数,参数是前一个进程和后一个进程的执行现场。

switch.S中的switch_to函数的执行流程:

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
.globl switch_to
switch_to: # switch_to(from, to)
### save from's registers ###
movl 4(%esp), %eax # eax points to from
popl 0(%eax)
# esp--> return address, so save return addr in FROM’s context
保存前一个进程的执行现场,前两条汇编指令保存了进程在返回switch_to函数后的指令地址到context.eip中

movl %esp, 4(%eax)
……
movl %ebp, 28(%eax)
7条汇编指令完成了保存前一个进程的其他7个寄存器到context中的相应成员变量中

### restore to's registers ###
恢复下一个进程的执行现场,这其实就是上述保存过程的逆执行过程
movl 4(%esp), %eax # not 8(%esp): popped return address already
# eax now points to to

movl 28(%eax), %ebp
……
movl 4(%eax), %esp
从context的高地址的成员变量ebp开始,逐一把相关成员变量的值赋值给对应的寄存器

pushl 0(%eax)
# push TO’s context’s eip, so return addr = TO’s eip
把context中保存的下一个进程要执行的指令地址context.eip放到了堆栈顶

ret
after ret, eip= TO’s eip
把栈顶的内容赋值给EIP寄存器,这样就切换到下一个进程执行了,即当前进程已经是下一个进程了

uCore会执行进程切换,让initproc执行。在对initproc进行初始化时,设置了initproc->context.eip = (uintptr_t)forkret,这样,当执行switch_to函数并返回后,initproc将执行其实际上的执行入口地址forkret。而forkret会调用位于kern/trap/trapentry.S中的forkrets函数执行,具体代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.globl __trapret
__trapret:
# restore registers from stack
popal
# restore %ds and %es
popl %es
popl %ds
# get rid of the trap number and error code
addl $0x8, %esp
iret

.globl forkrets
forkrets:
# set stack to this new process trapframe
movl 4(%esp), %esp
把esp指向当前进程的中断帧,esp指向了current->tf.tf_eip

jmp __trapret

如果此时执行的是initproc,则current->tf.tf_eip=kernel_thread_entry,initproc->tf.tf_cs = KERNEL_CS,所以当执行完iret后,就开始在内核中执行kernel_thread_entry函数了。

而initproc->tf.tf_regs.reg_ebx = init_main,所以在kernl_thread_entry中执行“call %ebx”后,就开始执行initproc的主体了。Initprocde的主体函数很简单就是输出一段字符串,然后就返回到kernel_tread_entry函数,并进一步调用do_exit执行退出操作了。

练习1:分配并初始化一个进程控制块

alloc_proc函数(位于kern/process/proc.c中)负责分配并返回一个新的struct proc_struct结 构,用于存储新建立的内核线程的管理信息。比较简单,state、pid和cr3需要考虑,其他的无脑赋0和memset一波带走就行

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
static struct proc_struct *
alloc_proc(void) {
struct proc_struct *proc = kmalloc(sizeof(struct proc_struct));
if (proc != NULL) {
//LAB4:EXERCISE1 YOUR CODE
/*
* below fields in proc_struct need to be initialized
* enum proc_state state; // Process state
* int pid; // Process ID
* int runs; // the running times of Proces
* uintptr_t kstack; // Process kernel stack
* volatile bool need_resched; // bool value: need to be rescheduled to release CPU?
* struct proc_struct *parent; // the parent process
* struct mm_struct *mm; // Process's memory management field
* struct context context; // Switch here to run process
* struct trapframe *tf; // Trap frame for current interrupt
* uintptr_t cr3; // CR3 register: the base addr of Page Directroy Table(PDT)
* uint32_t flags; // Process flag
* char name[PROC_NAME_LEN + 1]; // Process name
*/
proc->state = PROC_UNINIT;
proc->pid = -1;
proc->cr3 = boot_cr3;

proc->runs = 0;
proc->kstack = 0;
proc->need_resched = 0;
proc->parent = NULL;
proc->mm = NULL;
memset(&proc->context, 0, sizeof(struct context));
proc->tf = NULL;
proc->flags = 0;
memset(proc->name, 0, PROC_NAME_LEN);
}
return proc;
}

请说明proc_struct中struct context context和struct trapframe *tf成员变量含义和在本实验中的作用是啥?

结构体中存储了除eax之外的所有通用寄存器以及eip的数值,保存了线程运行的上下文信息;

1
2
3
4
5
6
7
8
9
10
struct context {
uint32_t eip;
uint32_t esp;
uint32_t ebx;
uint32_t ecx;
uint32_t edx;
uint32_t esi;
uint32_t edi;
uint32_t ebp;
};

context用于内核线程之间切换时,保存原先线程运行的上下文

struct trapframe *tf的作用:

  • 在copy_thread函数中对tf进行了设置。在这个函数中,把context变量的esp设置成tf变量的地址,把eip设置成forkret函数指针。
  • forkret函数调用了__trapret进行中断返回,tf变量用于构造出新线程时,正确地将控制权转交给新的线程。

练习2:为新创建的内核线程分配资源

创建一个内核线程需要分配和设置好很多资源。kernel_thread函数通过调用do_fork函数完成具体内核线程的创建工作。do_fork函数会调用alloc_proc函数来分配并初始化一个进程控制块,但alloc_proc只是找到了一小块内存用以记录进程的必要信息,并没有实际分配这些资源。

ucore一般通过do_fork实际创建新的内核线程。do_fork的作用是:

创建当前内核线程的一个副本,它们的执行上下文、代码、数据都一样,但是存储位置不同。在这个过程中,需要给新内核线程分配资源,并且复制原进程的状态。为内核线程创建新的线程控制块,并且对控制块中的每个成员变量进行正确的设置,使得之后可以正确切换到对应的线程中执行。练习2完成了在kern/process/proc.c中的do_fork函数中的处理过程。它的大致执行步骤包括:

  • 调用alloc_proc,首先获得一块用户信息块。
  • 为进程分配一个内核栈。
  • 复制原进程的内存管理信息到新进程(但内核线程不必做此事)
  • 复制原进程上下文到新进程
  • 将新进程添加到进程列表
  • 唤醒新进程
  • 返回新进程号
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
60
61
62
63
64
65
66
67
68
69
70
71
72
/* do_fork -     parent process for a new child process
* @clone_flags: used to guide how to clone the child process
* @stack: the parent's user stack pointer. if stack==0, It means to fork a kernel thread.
* @tf: the trapframe info, which will be copied to child process's proc->tf
*/
int
do_fork(uint32_t clone_flags, uintptr_t stack, struct trapframe *tf) {
int ret = -E_NO_FREE_PROC;
struct proc_struct *proc;
if (nr_process >= MAX_PROCESS) {
goto fork_out;
}
ret = -E_NO_MEM;
//LAB4:EXERCISE2 YOUR CODE
/*
* Some Useful MACROs, Functions and DEFINEs, you can use them in below implementation.
* MACROs or Functions:
* alloc_proc: create a proc struct and init fields (lab4:exercise1)
* 创建进程并初始化
* setup_kstack: alloc pages with size KSTACKPAGE as process kernel stack
* 创建页,大小为KSTACKPAGE,并作为进程的内核栈
* copy_mm: process "proc" duplicate OR share process "current"'s mm according clone_flags
* if clone_flags & CLONE_VM, then "share" ; else "duplicate"
* 进程复制memory manager,根据clone_flag不同决定不同操作
* copy_thread: setup the trapframe on the process's kernel stack top and
* setup the kernel entry point and stack of process
* 在进程内核栈顶建立trapframe
* hash_proc: add proc into proc hash_list
* 添加进程到hash_list中
* get_pid: alloc a unique pid for process
* 为进程分配一个独特的pid
* wakeup_proc: set proc->state = PROC_RUNNABLE
* VARIABLES:
* proc_list: the process set's list
* nr_process: the number of process set
*/

// 1. call alloc_proc to allocate a proc_struct
// 为要创建的新的线程分配线程控制块的空间
proc = alloc_proc();
if(proc == NULL)
goto fork_out;
// 判断是否分配到内存空间
// 2. call setup_kstack to allocate a kernel stack for child process
// 为新的线程设置栈,在本实验中,每个线程的栈的大小初始均为2个Page, 即8KB
int status = setup_kstack(proc);
if(status != 0)
goto fork_out;
// 3. call copy_mm to dup OR share mm according clone_flag
// 对虚拟内存空间进行拷贝,由于在本实验中,内核线程之间共享一个虚拟内存空间,因此实际上该函数不需要进行任何操作
status = copy_mm(clone_flags, proc);
if(status != 0)
goto fork_out;
// 4. call copy_thread to setup tf & context in proc_struct
// 在新创建的内核线程的栈上面设置伪造好的中端帧,便于后文中利用iret命令将控制权转移给新的线程
copy_thread(proc, stack, tf);
// 5. insert proc_struct into hash_list && proc_list
// 为新的线程创建pid
proc->pid = get_pid();
hash_proc(proc);
// 将线程放入使用hash组织的链表中,便于加速以后对某个指定的线程的查找
nr_process ++;
// 将全局线程的数目加1
list_add(&proc_list, &proc->list_link);
// 将线程加入到所有线程的链表中,便于进行调度
// 6. call wakeup_proc to make the new child process RUNNABLE
// 唤醒该线程,即将该线程的状态设置为可以运行
wakeup_proc(proc);
// 7. set ret vaule using child proc's pid
// 返回新线程的pid
ret = proc->pid;
fork_out:

请说明ucore是否做到给每个新fork的线程一个唯一的id?请说明你的分析和理由。

可以。ucore中为fork的线程分配pid的函数为get_pid:

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
// get_pid - alloc a unique pid for process
static int get_pid(void) {
static_assert(MAX_PID > MAX_PROCESS);
struct proc_struct *proc;
list_entry_t *list = &proc_list, *le;
static int next_safe = MAX_PID, last_pid = MAX_PID;
if (++ last_pid >= MAX_PID) {
last_pid = 1;
goto inside;
}
if (last_pid >= next_safe) {
inside:
next_safe = MAX_PID;
repeat:
le = list;
while ((le = list_next(le)) != list) {
proc = le2proc(le, list_link);
if (proc->pid == last_pid) {
if (++ last_pid >= next_safe) {
if (last_pid >= MAX_PID) {
last_pid = 1;
}
next_safe = MAX_PID;
goto repeat;
}
}
else if (proc->pid > last_pid && next_safe > proc->pid) {
next_safe = proc->pid;
}
}
}
return last_pid;
}

如果有严格的next_safe > last_pid + 1,那么可以直接取last_pid + 1作为新的pid(需要last_pid没有超出MAX_PID从而变成1),

如果在进入函数的时候,这两个变量之后没有合法的取值,也就是说next_safe > last_pid + 1不成立,那么进入循环,在循环之中首先通过if(proc->pid == last_pid)这一分支确保了不存在任何进程的pid与last_pid重合,然后再通过if (proc->pid > last_pid && next_safe > proc->pid)这一判断语句保证了不存在任何已经存在的pid满足:last_pid< pid < next_safe,这样就确保了最后能够找到这么一个满足条件的区间,获得合法的pid;

练习3:阅读代码,理解 proc_run 函数和它调用的函数如何完成进程切换的。

唯一调用到这个函数是在线程调度器的schedule函数中,proc_run将proc加载到CPU

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// proc_run - make process "proc" running on cpu
// NOTE: before call switch_to, should load base addr of "proc"'s new PDT
void proc_run(struct proc_struct *proc) {
// 判断需要运行的线程是否是正在运行的线程
if (proc != current) {
bool intr_flag;
struct proc_struct *prev = current, *next = proc;
//如果不是的话,获取到切换前后的两个线程
local_intr_save(intr_flag);
// 关闭中断
{
current = proc;
load_esp0(next->kstack + KSTACKSIZE);
lcr3(next->cr3);
// 设置了TSS和cr3,相当于是切换了页表和栈
switch_to(&(prev->context), &(next->context));
// switch_to恢复要运行的线程的上下文,然后由于恢复的上下文中已经将返回地址(copy_thread函数中完成)修改成了forkret函数的地址(如果这个线程是第一运行的话,否则就是切换到这个线程被切换出来的地址),也就是会跳转到这个函数,最后进一步跳转到了__trapsret函数,调用iret最终将控制权切换到新的线程;
}
local_intr_restore(intr_flag);
// 使能中断
}
}

forkret函数:
1
2
3
4
5
6
7
// forkret -- the first kernel entry point of a new thread/process
// NOTE: the addr of forkret is setted in copy_thread function
// after switch_to, the current proc will execute here.
static void
forkret(void) {
forkrets(current->tf);
}

在本实验的执行过程中,创建且运行了几个内核线程?

总共创建了两个内核线程,分别为:

  • idleproc: 最初的内核线程,在完成新的内核线程的创建以及各种初始化工作之后,进入死循环,用于调度其他线程;
  • initproc: 被创建用于打印”Hello World”的线程;

语句 local_intr_save(intr_flag);….local_intr_restore(intr_flag);说明理由在这里有何作用? 请说明理由。

  • 关闭中断,使得在这个语句块内的内容不会被中断打断,是一个原子操作;
  • 在proc_run函数中,将current指向了要切换到的线程,但是此时还没有真正将控制权转移过去,如果在这个时候出现中断打断这些操作,就会出现current中保存的并不是正在运行的线程的中断控制块,从而出现错误。

实验五

实验目的

了解第一个用户进程创建过程
了解系统调用框架的实现机制
了解ucore如何实现系统调用sys_fork/sys_exec/sys_exit/sys_wait来进行进程管理

实验内容

实验4的线程运行都在内核态。实验5创建了用户进程,让用户进程在用户态执行,且在需要ucore支持时,可通过系统调用来让ucore提供服务。为此需要构造出第一个用户进程,并通过系统调用sys_fork/sys_exec/sys_exit/sys_wait来支持运行不同的应用程序,完成对用户进程的执行过程的基本管理。

预备知识

实验执行流程概述

提供各种操作系统功能的内核线程只能在CPU核心态运行是操作系统自身的要求,操作系统就要呆在核心态,才能管理整个计算机系统。ucore提供了用户态进程的创建和执行机制,给应用程序执行提供一个用户态运行环境。显然,由于进程的执行空间扩展到了用户态空间,且出现了创建子进程执行应用程序等与lab4有较大不同的地方,所以具体实现的不同主要集中在进程管理内存管理部分

首先,我们从ucore的初始化部分来看,kern_init中调用的物理内存初始化,进程管理初始化等都有一定的变化。在内存管理部分,与lab4最大的区别就是增加用户态虚拟内存的管理

  • 首先为了管理用户态的虚拟内存,需要对页表的内容进行扩展,能够把部分物理内存映射为用户态虚拟内存。如果某进程执行过程中,CPU在用户态下执行(在CS段寄存器最低两位包含有一个2位的优先级域,如果为0,表示CPU运行在特权态;如果为3,表示CPU运行在用户态。),则可以访问本进程页表描述的用户态虚拟内存,但由于权限不够,不能访问内核态虚拟内存。
  • 另一方面,在用户态内存空间和内核态内核空间之间需要拷贝数据,让CPU处在内核态才能完成对用户空间的读或写,为此需要设计专门的拷贝函数(copy_from_user和copy_to_user)完成。但反之则会导致违反CPU的权限管理,导致内存访问异常。
  • 在进程管理方面,主要涉及到的是进程控制块中与内存管理相关的部分,包括建立进程的页表和维护进程可访问空间(可能还没有建立虚实映射关系)的信息;
  • 加载一个ELF格式的程序到进程控制块管理的内存中的方法;
  • 在进程复制(fork)过程中,把父进程的内存空间拷贝到子进程内存空间的技术;
  • 另外一部分与用户态进程生命周期管理相关,包括让进程放弃CPU而睡眠等待某事件、让父进程等待子进程结束、一个进程杀死另一个进程、给进程发消息、建立进程的血缘关系链表。

在用户进程管理中,首先,构造出第一个进程idle_proc,作为所有后续进程的祖先;然后,在proc_init函数中,对idle_proc进行进一步初始化,通过alloc把当前ucore的执行环境转变成idle内核线程的执行现场;然后调用kernl_thread来创建第二个内核线程init_main,而init_main内核线程有创建了user_main内核线程。到此,内核线程创建完毕。

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
// proc_init - set up the first kernel thread idleproc "idle" by itself and
// - create the second kernel thread init_main
void
proc_init(void) {
int i;

list_init(&proc_list);
for (i = 0; i < HASH_LIST_SIZE; i ++) {
list_init(hash_list + i);
}

if ((idleproc = alloc_proc()) == NULL) {
panic("cannot alloc idleproc.\n");
}

idleproc->pid = 0;
idleproc->state = PROC_RUNNABLE;
idleproc->kstack = (uintptr_t)bootstack;
idleproc->need_resched = 1;
set_proc_name(idleproc, "idle");
nr_process ++;

current = idleproc;

int pid = kernel_thread(init_main, NULL, 0);
if (pid <= 0) {
panic("create init_main failed.\n");
}

initproc = find_proc(pid);
set_proc_name(initproc, "init");

assert(idleproc != NULL && idleproc->pid == 0);
assert(initproc != NULL && initproc->pid == 1);
}

接下来是用户进程的创建过程。第一步实际上是通过user_main函数调用kernel_tread创建子进程,通过kernel_execve调用来把某一具体程序的执行内容放入内存。

具体的放置方式是根据ld在此文件上的地址分配为基本原则,把程序的不同部分放到某进程的用户空间中,从而通过此进程来完成程序描述的任务。一旦执行了这一程序对应的进程,就会从内核态切换到用户态继续执行。

以此类推:

CPU在用户空间执行的用户进程,其地址空间不会被其他用户的进程影响,但由于系统调用(用户进程直接获得操作系统服务的唯一通道)、外设中断和异常中断的会随时产生,从而间接推动了用户进程实现用户态到到内核态的切换工作。当进程执行结束后,需回收进程占用和没消耗完毕的设备整个过程,且为新的创建进程请求提供服务。

创建用户进程

应用程序的组成和编译

lab5中新增了一个文件夹user,其中是用于本实验的用户程序。如hello.c

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <ulib.h>

int main(void) {
cprintf("Hello world!!.\n");
cprintf("I am process %d.\n", getpid());
cprintf("hello pass.\n");
return 0;
}

按照手册,注释掉Makefile的第六行,编译,(部分)输出如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
gcc -Iuser/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  
-fno-stack-protector -Ilibs/ -Iuser/include/ -Iuser/libs/ -c user/pgdir.c -o obj/user/pgdir.o

ld -m elf_i386 -nostdlib -T tools/user.ld -o obj/__user_pgdir.out
obj/user/libs/panic.o obj/user/libs/syscall.o obj/user/libs/ulib.o
obj/user/libs/initcode.o obj/user/libs/stdio.o obj/user/libs/umain.o
obj/libs/string.o obj/libs/printfmt.o obj/libs/hash.o obj/libs/rand.o obj/user/pgdir.o

+ ld bin/kernel
ld -m elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel
obj/kern/init/entry.o obj/kern/init/init.o obj/kern/libs/stdio.o
obj/kern/libs/readline.o obj/kern/debug/panic.o obj/kern/debug/kdebug.o
obj/kern/debug/kmonitor.o obj/kern/driver/ide.o obj/kern/driver/clock.o
obj/kern/driver/console.o obj/kern/driver/picirq.o obj/kern/driver/intr.o
obj/kern/trap/trap.o obj/kern/trap/vectors.o obj/kern/trap/trapentry.o
obj/kern/mm/pmm.o obj/kern/mm/swap_fifo.o obj/kern/mm/vmm.o obj/kern/mm/kmalloc.o
obj/kern/mm/swap.o obj/kern/mm/default_pmm.o obj/kern/fs/swapfs.o obj/kern/process/entry.o
obj/kern/process/switch.o obj/kern/process/proc.o obj/kern/schedule/sched.o
obj/kern/syscall/syscall.o obj/libs/string.o obj/libs/printfmt.o obj/libs/hash.o obj/libs/rand.o
-b binary obj/__user_badarg.out obj/__user_forktree.out obj/__user_faultread.out obj/__user_divzero.out
obj/__user_exit.out obj/__user_hello.out obj/__user_waitkill.out obj/__user_softint.out obj/__user_spin.out
obj/__user_yield.out obj/__user_badsegment.out obj/__user_testbss.out obj/__user_faultreadkernel.out
obj/__user_forktest.out obj/__user_pgdir.out

从中可以看出,hello应用程序不仅仅是hello.c,还包含了支持hello应用程序的用户态库:

  • user/libs/initcode.S:所有应用程序的起始用户态执行地址“_start”,调整了EBP和ESP后,调用umain函数。
  • user/libs/umain.c:实现了umain函数,这是所有应用程序执行的第一个C函数,它将调用应用程序的main函数,并在main函数结束后调用exit函数,而exit函数最终将调用sys_exit系统调用,让操作系统回收进程资源。
  • user/libs/ulib.[ch]:实现了最小的C函数库,除了一些与系统调用无关的函数,其他函数是对访问系统调用的包装。
  • user/libs/syscall.[ch]:用户层发出系统调用的具体实现。
  • user/libs/stdio.c:实现cprintf函数,通过系统调用sys_putc来完成字符输出。
  • user/libs/panic.c:实现__panic/__warn函数,通过系统调用sys_exit完成用户进程退出。

在make的最后一步执行了一个ld命令,把hello应用程序的执行码obj/__user_hello.out连接在了ucore kernel的末尾。且ld命令会在kernel中会把__user_hello.out的位置和大小记录在全局变量_binary_obj___user_hello_out_start_binary_obj___user_hello_out_size中,这样这个hello用户程序就能够和ucore内核一起被 bootloader加载到内存里中,并且通过这两个全局变量定位hello用户程序执行码的起始位置和大小。

用户进程的虚拟地址空间

在tools/user.ld描述了用户程序的用户虚拟空间的执行入口虚拟地址:

1
2
3
SECTIONS {
/* Load programs at this address: "." means the current address */
. = 0x800020;

在tools/kernel.ld描述了操作系统的内核虚拟空间的起始入口虚拟地址:

1
2
3
SECTIONS {
/* Load the kernel at this address: "." means the current address */
. = 0xC0100000;

这样ucore把用户进程的虚拟地址空间分了两块:

  • 一块与内核线程一样,是所有用户进程都共享的内核虚拟地址空间,映射到同样的物理内存空间中,这样在物理内存中只需放置一份内核代码,使得用户进程从用户态进入核心态时,内核代码可以统一应对不同的内核程序;
  • 另外一块是用户虚拟地址空间,虽然虚拟地址范围一样,但映射到不同且没有交集的物理内存空间中。这样当ucore把用户进程的执行代码(即应用程序的执行代码)和数据(即应用程序的全局变量等)放到用户虚拟地址空间中时,确保了各个进程不会“非法”访问到其他进程的物理内存空间。

这样ucore给一个用户进程具体设定的虚拟内存空间(kern/mm/memlayout.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
 Virtual memory map:                                          Permissions
kernel/user

4G ------------------> +---------------------------------+
| |
| Empty Memory (*) |
| |
+---------------------------------+ 0xFB000000
| Cur. Page Table (Kern, RW) | RW/-- PTSIZE
VPT -----------------> +---------------------------------+ 0xFAC00000
| Invalid Memory (*) | --/--
KERNTOP -------------> +---------------------------------+ 0xF8000000
| |
| Remapped Physical Memory | RW/-- KMEMSIZE
| |
KERNBASE ------------> +---------------------------------+ 0xC0000000
| Invalid Memory (*) | --/--
USERTOP -------------> +---------------------------------+ 0xB0000000
| User stack |
+---------------------------------+
| |
: :
| ~~~~~~~~~~~~~~~~ |
| ~~~~~~~~~~~~~~~~ |
: :
| |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| User Program & Heap |
UTEXT ---------------> +---------------------------------+ 0x00800000
| Invalid Memory (*) | --/--
| - - - - - - - - - - - - - - - |
| User STAB Data (optional) |
USERBASE, USTAB------> +---------------------------------+ 0x00200000
| Invalid Memory (*) | --/--
0 -------------------> +---------------------------------+ 0x00000000
(*) Note: The kernel ensures that "Invalid Memory" is *never* mapped.
"Empty Memory" is normally unmapped, but user programs may map pages
there if desired.

*/

创建并执行用户进程

在确定了用户进程的执行代码和数据,以及用户进程的虚拟空间布局后,我们可以来创建用户进程了。在本实验中第一个用户进程是由第二个内核线程initproc通过把hello应用程序执行码覆盖到initproc的用户虚拟内存空间来创建的,相关代码如下所示:

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
 // kernel_execve - do SYS_exec syscall to exec a user program called by user_main kernel_thread
static int
kernel_execve(const char *name, unsigned char *binary, size_t size) {
int ret, len = strlen(name);
asm volatile (
"int %1;"
: "=a" (ret)
: "i" (T_SYSCALL), "0" (SYS_exec), "d" (name), "c" (len), "b" (binary), "D" (size)
: "memory");
return ret;
}

#define __KERNEL_EXECVE(name, binary, size) ({ \
cprintf("kernel_execve: pid = %d, name = \"%s\".\n", \
current->pid, name); \
kernel_execve(name, binary, (size_t)(size)); \
})

#define KERNEL_EXECVE(x) ({ \
extern unsigned char _binary_obj___user_##x##_out_start[], \
_binary_obj___user_##x##_out_size[]; \
__KERNEL_EXECVE(#x, _binary_obj___user_##x##_out_start, \
_binary_obj___user_##x##_out_size); \
})
……
// init_main - the second kernel thread used to create kswapd_main & user_main kernel threads
static int init_main(void *arg) {
#ifdef TEST
KERNEL_EXECVE2(TEST, TESTSTART, TESTSIZE);
#else
KERNEL_EXECVE(hello);
#endif
panic("kernel_execve failed.\n");
return 0;
}

##的作用是参数的连接,把“exit”这个字符串连接到这个宏中的x对应位置
#的作用是使一个东西字符串化

Initproc的执行主体是init_main函数,这个函数在缺省情况下是执行宏KERNEL_EXECVE(hello),而这个宏最终是调用kernel_execve函数来调用SYS_exec系统调用,由于ld在链接hello应用程序执行码时定义了两全局变量:

1
2
_binary_obj___user_hello_out_start:hello执行码的起始位置
_binary_obj___user_hello_out_size中:hello执行码的大小

kernel_execve把这两个变量作为SYS_exec系统调用的参数,让ucore来创建此用户进程。当ucore收到此系统调用后,将依次调用如下函数:

1
2
vector128(vectors.S) -->
__alltraps(trapentry.S) --> trap(trap.c) --> trap_dispatch(trap.c) --> syscall(syscall.c) --> sys_exec(syscall.c)--> do_execve(proc.c)

最终通过do_execve函数来完成用户进程的创建工作。此函数的主要工作流程如下:

  • 为加载新的执行码做好用户态内存空间清空准备。如果mm不为NULL,则设置页表为内核空间页表,且进一步判断mm的引用计数减1后是否为0,如果为0,则表明没有进程再需要此进程所占用的内存空间,为此将根据mm中的记录,释放进程所占用户空间内存和进程页表本身所占空间。最后把当前进程的mm内存管理指针为空。由于此处的initproc是内核线程,所以mm为NULL,整个处理都不会做。
  • 加载应用程序执行码到当前进程的新创建的用户态虚拟空间中。这里涉及到读ELF格式的文件,申请内存空间,建立用户态虚存空间,加载应用程序执行码等。load_icode函数完成了整个复杂的工作。
  • load_icode函数的主要工作就是给用户进程建立一个能够让用户进程正常运行的用户环境。此函数有一百多行,完成了如下重要工作:
  1. 调用mm_create函数来申请进程的内存管理数据结构mm所需内存空间,并对mm进行初始化;
  2. 调用setup_pgdir来申请一个页目录表所需的一个页大小的内存空间,并把描述ucore内核虚空间映射的内核页表(boot_pgdir所指)的内容拷贝到此新目录表中,最后让mm->pgdir指向此页目录表,这就是进程新的页目录表了,且能够正确映射内核虚空间;
  3. 根据应用程序执行码的起始位置来解析此ELF格式的执行程序,并调用mm_map函数根据ELF格式的执行程序说明的各个段(代码段、数据段、BSS段等)的起始位置和大小建立对应的vma结构,并把vma插入到mm结构中,从而表明了用户进程的合法用户态虚拟地址空间;
  4. 调用根据执行程序各个段的大小分配物理内存空间,并根据执行程序各个段的起始位置确定虚拟地址,并在页表中建立好物理地址和虚拟地址的映射关系,然后把执行程序各个段的内容拷贝到相应的内核虚拟地址中,至此应用程序执行码和数据已经根据编译时设定地址放置到虚拟内存中了;
  • 需要给用户进程设置用户栈,为此调用mm_mmap函数建立用户栈的vma结构,明确用户栈的位置在用户虚空间的顶端,大小为256个页,即1MB,并分配一定数量的物理内存且建立好栈的虚地址<—>物理地址映射关系;
  • 至此,进程内的内存管理vma和mm数据结构已经建立完成,于是把mm->pgdir赋值到cr3寄存器中,即更新了用户进程的虚拟内存空间,此时的initproc已经被hello的代码和数据覆盖,成为了第一个用户进程,但此时这个用户进程的执行现场还没建立好;
  • 先清空进程的中断帧,再重新设置进程的中断帧,使得在执行中断返回指令“iret”后,能够让CPU转到用户态特权级,并回到用户态内存空间,使用用户态的代码段、数据段和堆栈,且能够跳转到用户进程的第一条指令执行,并确保在用户态能够响应中断;
  • 至此,用户进程的用户环境已经搭建完毕。此时initproc将按产生系统调用的函数调用路径原路返回,执行中断返回指令“iret”(位于trapentry.S的最后一句)后,将切换到用户进程hello的第一条语句位置_start处(位于user/libs/initcode.S的第三句)开始执行。

进程退出和等待进程

ucore分了两步来完成进程退出工作,首先,进程本身完成大部分资源的占用内存回收工作,然后父进程完成剩余资源占用内存的回收工作。为何不让进程本身完成所有的资源回收工作呢?这是因为进程要执行回收操作,就表明此进程还存在,还在执行指令,这就需要内核栈的空间不能释放,且表示进程存在的进程控制块不能释放。所以需要父进程来帮忙释放子进程无法完成的这两个资源回收工作。

为此在用户态的函数库中提供了exit函数,此函数最终访问sys_exit系统调用接口让操作系统来帮助当前进程执行退出过程中的部分资源回收。

首先,exit函数会把一个退出码error_code传递给ucore,ucore通过执行内核函数do_exit来完成对当前进程的退出处理,主要工作是回收当前进程所占的大部分内存资源,并通知父进程完成最后的回收工作,具体流程如下:

  1. 如果current->mm != NULL,表示是用户进程,则开始回收此用户进程所占用的用户态虚拟内存空间;
  • 首先执行“lcr3(boot_cr3)”,切换到内核态的页表上,这样当前用户进程目前只能在内核虚拟地址空间执行了,这是为了确保后续释放用户态内存和进程页表的工作能够正常执行;
  • 如果当前进程控制块的成员变量mm的成员变量mm_count减1后为0(表明这个mm没有再被其他进程共享,可以彻底释放进程所占的用户虚拟空间了。),则开始回收用户进程所占的内存资源:
  • 调用exit_mmap函数释放current->mm->vma链表中每个vma描述的进程合法空间中实际分配的内存,然后把对应的页表项内容清空,最后还把页表所占用的空间释放并把对应的页目录表项清空;
  • 调用put_pgdir函数释放当前进程的页目录所占的内存;
  • 调用mm_destroy函数释放mm中的vma所占内存,最后释放mm所占内存;
  • 此时设置current->mm为NULL,表示与当前进程相关的用户虚拟内存空间和对应的内存管理成员变量所占的内核虚拟内存空间已经回收完毕;
  1. 这时,设置当前进程的执行状态current->state=PROC_ZOMBIE,当前进程的退出码current->exit_code=error_code。此时当前进程已经不能被调度了,需要此进程的父进程来做最后的回收工作(即回收描述此进程的内核栈和进程控制块);
  2. 如果当前进程的父进程current->parent处于等待子进程状态:
    current->parent->wait_state==WT_CHILD
    则唤醒父进程(即执行“wakup_proc(current->parent)”),让父进程帮助自己完成最后的资源回收;
  3. 如果当前进程还有子进程,则需要把这些子进程的父进程指针设置为内核线程initproc,且各个子进程指针需要插入到initproc的子进程链表中。如果某个子进程的执行状态是PROC_ZOMBIE,则需要唤醒initproc来完成对此子进程的最后回收工作。
  4. 执行schedule()函数,选择新的进程执行。

那么父进程如何完成对子进程的最后回收工作呢?这要求父进程要执行wait用户函数或wait_pid用户函数,这两个函数的区别是,wait函数等待任意子进程的结束通知,而wait_pid函数等待进程id号为pid的子进程结束通知。这两个函数最终访问sys_wait系统调用接口让ucore来完成对子进程的最后回收工作,即回收子进程的内核栈和进程控制块所占内存空间,具体流程如下:

  1. 如果pid!=0,表示只找一个进程id号为pid的退出状态的子进程,否则找任意一个处于退出状态的子进程;
  2. 如果此子进程的执行状态不为PROC_ZOMBIE,表明此子进程还没有退出,则当前进程只好设置自己的执行状态为PROC_SLEEPING,睡眠原因为WT_CHILD(即等待子进程退出),调用schedule()函数选择新的进程执行,自己睡眠等待,如果被唤醒,则重复跳回步骤1处执行;
  3. 如果此子进程的执行状态为PROC_ZOMBIE,表明此子进程处于退出状态,需要当前进程(即子进程的父进程)完成对子进程的最终回收工作,即首先把子进程控制块从两个进程队列proc_list和hash_list中删除,并释放子进程的内核堆栈和进程控制块。自此,子进程才彻底地结束了它的执行过程,消除了它所占用的所有资源。

系统调用实现

用户进程只能在操作系统给它圈定好的“用户环境”中执行,但“用户环境”限制了用户进程能够执行的指令,即用户进程只能执行一般的指令,无法执行特权指令。如果用户进程想执行一些需要特权指令的任务,比如通过网卡发网络包等,只能让操作系统来代劳了。于是就需要一种机制来确保用户进程不能执行特权指令,但能够请操作系统“帮忙”完成需要特权指令的任务,这种机制就是系统调用。

采用系统调用机制为用户进程提供一个获得操作系统服务的统一接口层:

  • 一来可简化用户进程的实现,把一些共性的、繁琐的、与硬件相关、与特权指令相关的任务放到操作系统层来实现,但提供一个简洁的接口给用户进程调用;
  • 二来这层接口事先可规定好,且严格检查用户进程传递进来的参数和操作系统要返回的数据,使得让操作系统给用户进程服务的同时,保护操作系统不会被用户进程破坏。

从硬件层面上看,需要硬件能够支持在用户态的用户进程通过某种机制切换到内核态。

初始化系统调用对应的中断描述符

在ucore初始化函数kern_init中调用了idt_init函数来初始化中断描述符表,并设置一个特定中断号的中断门,专门用于用户进程访问系统调用。此事由ide_init函数完成:

1
2
3
4
5
6
7
8
9
10
void
idt_init(void) {
extern uintptr_t __vectors[];
int i;
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
lidt(&idt_pd);
}

在上述代码中,可以看到在执行加载中断描述符表lidt指令前,专门设置了一个特殊的中断描述符idt[T_SYSCALL],它的特权级设置为DPL_USER,中断向量处理地址在vectors[T_SYSCALL]处。这样建立好这个中断描述符后,一旦用户进程执行“INT T_SYSCALL”后,由于此中断允许用户态进程产生(注意它的特权级设置为DPL_USER),所以CPU就会从用户态切换到内核态,保存相关寄存器,并跳转到vectors[T_SYSCALL]处开始执行,形成如下执行路径:

1
2
vector128(vectors.S) --> 
__alltraps(trapentry.S) --> trap(trap.c) --> trap_dispatch(trap.c) --> syscall(syscall.c)

建立系统调用的用户库准备

在操作系统中初始化好系统调用相关的中断描述符、中断处理起始地址等后,还需在用户态的应用程序中初始化好相关工作,简化应用程序访问系统调用的复杂性。为此在用户态建立了一个中间层,即简化的libc实现,在user/libs/ulib.[ch]和user/libs/syscall.[ch]中完成了对访问系统调用的封装。用户态最终的访问系统调用函数是syscall,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static inline int
syscall(int num, ...) {
va_list ap;
va_start(ap, num);
uint32_t a[MAX_ARGS];
int i, ret;
for (i = 0; i < MAX_ARGS; i ++) {
a[i] = va_arg(ap, uint32_t);
}
va_end(ap);

asm volatile (
"int %1;"
: "=a" (ret)
: "i" (T_SYSCALL),
"a" (num),
"d" (a[0]),
"c" (a[1]),
"b" (a[2]),
"D" (a[3]),
"S" (a[4])
: "cc", "memory");
return ret;
}

从中可以看出,应用程序调用的exit/fork/wait/getpid等库函数最终都会调用syscall函数,只是调用的参数不同而已,如果看最终的汇编代码会更清楚:

1
2
3
4
5
6
7
8
9
10
……
34: 8b 55 d4 mov -0x2c(%ebp),%edx
37: 8b 4d d8 mov -0x28(%ebp),%ecx
3a: 8b 5d dc mov -0x24(%ebp),%ebx
3d: 8b 7d e0 mov -0x20(%ebp),%edi
40: 8b 75 e4 mov -0x1c(%ebp),%esi
43: 8b 45 08 mov 0x8(%ebp),%eax
46: cd 80 int $0x80
48: 89 45 f0 mov %eax,-0x10(%ebp)
……

可以看到其实是把系统调用号放到EAX,其他5个参数a[0]~a[4]分别保存到EDX/ECX/EBX/EDI/ESI五个寄存器中,及最多用6个寄存器来传递系统调用的参数,且系统调用的返回结果是EAX。比如对于getpid库函数而言,系统调用号(SYS_getpid=18)是保存在EAX中,返回值(调用此库函数的的当前进程号pid)也在EAX中。

与用户进程相关的系统调用

在本实验中,与进程相关的各个系统调用属性如下所示:
|系统调用名 | 含义 | 具体完成服务的函数 |
|——|——|——|
|SYS_exit | process exit | do_exit |
|SYS_fork | create child process, dup mm | do_fork—>wakeup_proc |
|SYS_wait | wait child process | do_wait |
|SYS_exec | after fork, process execute a new program | load a program and refresh the mm |
|SYS_yield | process flag itself need resecheduling | proc->need_sched=1, then scheduler will rescheule this process |
|SYS_kill | kill process | do_kill—>proc->flags |= PF_EXITING, —>wakeup_proc—>do_wait—>do_exit |
|SYS_getpid | get the process’s pid | |

s##### 系统调用的执行过程
与用户态的函数库调用执行过程相比,系统调用执行过程的有四点主要的不同:

  • 不是通过“CALL”指令而是通过“INT”指令发起调用;
  • 不是通过“RET”指令,而是通过“IRET”指令完成调用返回;
  • 当到达内核态后,操作系统需要严格检查系统调用传递的参数,确保不破坏整个系统的安全性;
  • 执行系统调用可导致进程等待某事件发生,从而可引起进程切换;

下面我们以getpid系统调用的执行过程大致看看操作系统是如何完成整个执行过程的。当用户进程调用getpid函数,最终执行到INT T_SYSCALL指令后,CPU根据操作系统建立的系统调用中断描述符,转入内核态,并跳转到vector128处(kern/trap/vectors.S),开始了操作系统的系统调用执行过程,函数调用和返回操作的关系如下所示:

1
2
vector128(vectors.S) --> 
__alltraps(trapentry.S) --> trap(trap.c) --> trap_dispatch(trap.c) --> syscall(syscall.c) --> sys_getpid(syscall.c) --> …… --> __trapret(trapentry.S)

在执行trap函数前,软件还需进一步保存执行系统调用前的执行现场,即把与用户进程继续执行所需的相关寄存器等当前内容保存到当前进程的中断帧trapframe中(注意,在创建进程是,把进程的trapframe放在给进程的内核栈分配的空间的顶部)。软件做的工作在vector128和__alltraps的起始部分:
1
2
3
4
5
6
7
8
9
vectors.S::vector128起始处:
pushl $0
pushl $128
......
trapentry.S::__alltraps起始处:
pushl %ds
pushl %es
pushal
……

自此,用于保存用户态的用户进程执行现场的trapframe的内容填写完毕,操作系统可开始完成具体的系统调用服务。在sys_getpid函数中,简单地把当前进程的pid成员变量做为函数返回值就是一个具体的系统调用服务。完成服务后,操作系统按调用关系的路径原路返回到__alltraps中。然后操作系统开始根据当前进程的中断帧内容做恢复执行现场操作。其实就是把trapframe的一部分内容保存到寄存器内容。恢复寄存器内容结束后,调整内核堆栈指针到中断帧的tf_eip处,这是内核栈的结构如下:

1
2
3
4
5
6
7
8
9
/* below here defined by x86 hardware */
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;

这时执行IRET指令后,CPU根据内核栈的情况回复到用户态,并把EIP指向tf_eip的值,即INT T_SYSCALL后的那条指令。这样整个系统调用就执行完毕了。

读load_icode有感

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/* load_icode - load the content of binary program(ELF format) as the new content of current process
* @binary: the memory addr of the content of binary program
* @size: the size of the content of binary program
* 读取一个二进制elf文件并为其设置执行场景,并执行
*/
static int
load_icode(unsigned char *binary, size_t size) {
if (current->mm != NULL) {
panic("load_icode: current->mm must be empty.\n");
}

int ret = -E_NO_MEM;
struct mm_struct *mm;
//(1) create a new mm for current process
if ((mm = mm_create()) == NULL) {
goto bad_mm;
}
//(2) create a new PDT, and mm->pgdir= kernel virtual addr of PDT
if (setup_pgdir(mm) != 0) {
goto bad_pgdir_cleanup_mm;
}
//(3) copy TEXT/DATA section, build BSS parts in binary to memory space of process
struct Page *page;
//(3.1) get the file header of the bianry program (ELF format)
// 将二进制串转成描述elf的结构体
struct elfhdr *elf = (struct elfhdr *)binary;
//(3.2) get the entry of the program section headers of the bianry program (ELF format)
// 获取elf头的起始地址
struct proghdr *ph = (struct proghdr *)(binary + elf->e_phoff);
// 代码段的头
//(3.3) This program is valid?
// 第一个实验中说了elf的这个域是ELF_MAGIC
if (elf->e_magic != ELF_MAGIC) {
ret = -E_INVAL_ELF;
goto bad_elf_cleanup_pgdir;
}

uint32_t vm_flags, perm;
struct proghdr *ph_end = ph + elf->e_phnum;
for (; ph < ph_end; ph ++) {
//(3.4) find every program section headers
// 每一个程序段
if (ph->p_type != ELF_PT_LOAD) {
//程序段头里的这个程序段的类型,如可加载的代码、数据、动态链接信息等
continue ;
}
if (ph->p_filesz > ph->p_memsz) {
ret = -E_INVAL_ELF;
goto bad_cleanup_mmap;
}
if (ph->p_filesz == 0) {
// 这个段的大小
continue ;
}
//(3.5) call mm_map fun to setup the new vma ( ph->p_va, ph->p_memsz)
vm_flags = 0, perm = PTE_U;
if (ph->p_flags & ELF_PF_X) vm_flags |= VM_EXEC;
if (ph->p_flags & ELF_PF_W) vm_flags |= VM_WRITE;
if (ph->p_flags & ELF_PF_R) vm_flags |= VM_READ;
// 可读、可写、可执行?

if (vm_flags & VM_WRITE) perm |= PTE_W;
if ((ret = mm_map(mm, ph->p_va, ph->p_memsz, vm_flags, NULL)) != 0) {
goto bad_cleanup_mmap;
// 创建一个vma,并把这个vma加入到mm的list中
}
unsigned char *from = binary + ph->p_offset;
size_t off, size;
uintptr_t start = ph->p_va, end, la = ROUNDDOWN(start, PGSIZE);
//
ret = -E_NO_MEM;

//(3.6) alloc memory, and copy the contents of every program section (from, from+end) to process's memory
(la, la+end)
end = ph->p_va + ph->p_filesz;
//(3.6.1) copy TEXT/DATA section of bianry program
// 分配页
while (start < end) {
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la) {
size -= la - end;
}
memcpy(page2kva(page) + off, from, size);
start += size, from += size;
}

//(3.6.2) build BSS section of binary program
end = ph->p_va + ph->p_memsz;
if (start < la) {
/* ph->p_memsz == ph->p_filesz */
if (start == end) {
continue ;
}
off = start + PGSIZE - la, size = PGSIZE - off;
if (end < la) {
size -= la - end;
}
memset(page2kva(page) + off, 0, size);
start += size;
assert((end < la && start == end) || (end >= la && start == la));
}
while (start < end) {
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la) {
size -= la - end;
}
memset(page2kva(page) + off, 0, size);
start += size;
}
}
//(4) build user stack memory
vm_flags = VM_READ | VM_WRITE | VM_STACK;
if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0) {
goto bad_cleanup_mmap;
}
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-PGSIZE , PTE_USER) != NULL);
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-2*PGSIZE , PTE_USER) != NULL);
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-3*PGSIZE , PTE_USER) != NULL);
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-4*PGSIZE , PTE_USER) != NULL);

//(5) set current process's mm, sr3, and set CR3 reg = physical addr of Page Directory
mm_count_inc(mm); // mm的count加1,计算有多少进程同时使用这个mm
current->mm = mm; // 当前进程的mm是这个mm
current->cr3 = PADDR(mm->pgdir); // 虚拟地址转换成物理地址
lcr3(PADDR(mm->pgdir));

//(6) setup trapframe for user environment
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
/* LAB5:EXERCISE1 YOUR CODE
* should set tf_cs,tf_ds,tf_es,tf_ss,tf_esp,tf_eip,tf_eflags
* NOTICE: If we set trapframe correctly, then the user level process can return to USER MODE from kernel. S
o
* tf_cs should be USER_CS segment (see memlayout.h)
* tf_ds=tf_es=tf_ss should be USER_DS segment
* tf_esp should be the top addr of user stack (USTACKTOP)
* tf_eip should be the entry point of this binary program (elf->e_entry)
* tf_eflags should be set to enable computer to produce Interrupt
*/
tf->tf_cs = USER_CS;
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
tf->tf_esp = USTACKTOP;
tf->tf_eip = elf->e_entry;
tf->tf_eflags = 0x00000002 | FL_IF; // to enable interrupt
//网上这里有的是这么写的,不知道为啥,我觉得应该只要FL_IF就够了,可能是我考虑不周
/*
#define FL_IF 0x00000200 // Interrupt Flag
tf->tf_eflags = FL_IF;
*/
ret = 0;
out:
return ret;
bad_cleanup_mmap:
exit_mmap(mm);
bad_elf_cleanup_pgdir:
put_pgdir(mm);
bad_pgdir_cleanup_mm:
mm_destroy(mm);
bad_mm:
goto out;
}

练习1:加载应用程序并执行

do_execv函数调用了load_icode函数(位于kern/process/proc.c中)来加载并解析一个处于内存中的ELF执行文件格式的应用程序,并建立了相应的用户内存空间来存放应用程序的代码段、数据段 等,且要设置好proc_struct结构中的成员变量trapframe中的内容,确保在执行此进程后,能够从应用程序设定的起始执行地址开始执行。

load_icode函数是由do_execve函数调用的,而该函数是exec系统调用的最终处理的函数,功能为将某一个指定的ELF可执行二进制文件加载到当前内存中来,然后当前进程执行这个可执行文件(先前执行的内容全部清空),而load_icode函数的功能则在于为执行新的程序初始化好内存空间,在调用该函数之前,do_execve中已经退出了当前进程的内存空间,改使用了内核的内存空间,这样使得对原先用户态的内存空间的操作成为可能;

由于最终是在用户态下运行的,所以需要将段寄存器初始化为用户态的代码段、数据段、堆栈段;
esp应当指向先前的步骤中创建的用户栈的栈顶;
eip应当指向ELF可执行文件加载到内存之后的入口处;
eflags中应当初始化为中断使能,注意eflags的第1位是恒为1的;
设置ret为0,表示正常返回;
见上边的函数代码。

首先在初始化IDT的时候,设置系统调用对应的中断描述符,使其能够在用户态下被调用,并且设置为trap类型。设置系统调用中断是用户态的。

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
    extern uintptr_t __vectors[];
int i;
for (i = 0; i < sizeof(idt) / sizeof(struct gatedesc); i ++) {
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
SETGATE(idt[T_SYSCALL], 1, GD_KTEXT, __vectors[T_SYSCALL], DPL_USER);
lidt(&idt_pd);

/* *
* Set up a normal interrupt/trap gate descriptor
* - istrap: 1 for a trap (= exception) gate, 0 for an interrupt gate
* - sel: Code segment selector for interrupt/trap handler
* - off: Offset in code segment for interrupt/trap handler
* - dpl: Descriptor Privilege Level - the privilege level required
* for software to invoke this interrupt/trap gate explicitly
* using an int instruction.
* */
#define SETGATE(gate, istrap, sel, off, dpl) { \
(gate).gd_off_15_0 = (uint32_t)(off) & 0xffff; \
(gate).gd_ss = (sel); \
(gate).gd_args = 0; \
(gate).gd_rsv1 = 0; \
(gate).gd_type = (istrap) ? STS_TG32 : STS_IG32; \
(gate).gd_s = 0; \
(gate).gd_dpl = (dpl); \
(gate).gd_p = 1; \
(gate).gd_off_31_16 = (uint32_t)(off) >> 16; \
}

同样是在trap.c里,设置当计时器到点之后,也就是100个时钟周期之后,这个进程就是可以被重新调度的了,实现多线程的并发执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
    case IRQ_OFFSET + IRQ_TIMER:
ticks++;
if(ticks>=TICK_NUM){
assert(current != NULL);
current->need_resched = 1;
//print_ticks();
ticks=0;
}
/* LAB5 YOUR CODE */
/* you should upate you lab1 code (just add ONE or TWO lines of code):
* Every TICK_NUM cycle, you should set current process's current->need_resched = 1
*/
-

在proc_alloc函数中,额外对进程控制块中新增加的wait_state, cptr, yptr, optr成员变量进行初始化;
在alloc_proc(void)函数中,对新增的几个变量初始化
1
2
3
4
5
6
7
8
 //LAB5 YOUR CODE : (update LAB4 steps)
/*
* below fields(add in LAB5) in proc_struct need to be initialized
* uint32_t wait_state; // waiting state
* struct proc_struct *cptr, *yptr, *optr; // relations between processes
*/
proc->wait_state = 0;
proc->cptr = proc->optr = proc->yptr = NULL;

在do_fork函数中,使用set_links函数来完成将fork的线程添加到线程链表中的过程,值得注意的是,该函数中就包括了将其加入list和对进程总数加1这一操作,因此需要将原先的这个操作给删除掉;
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
// set_links - set the relation links of process
static void
set_links(struct proc_struct *proc) {
list_add(&proc_list, &(proc->list_link));
proc->yptr = NULL;
if ((proc->optr = proc->parent->cptr) != NULL) {
proc->optr->yptr = proc;
}
proc->parent->cptr = proc;
nr_process ++;
}

//LAB5 YOUR CODE : (update LAB4 steps)
/* Some Functions
* set_links: set the relation links of process. ALSO SEE: remove_links: lean the relation links of process
* -------------------
* update step 1: set child proc's parent to current process, make sure current process's wait_state is 0
* update step 5: insert proc_struct into hash_list && proc_list, set the relation links of process
*/
// 1. call alloc_proc to allocate a proc_struct
proc = alloc_proc();
if(proc == NULL)
goto fork_out;
// 2. call setup_kstack to allocate a kernel stack for child process
proc->parent = current;
assert(current->wait_state == 0);

int status = setup_kstack(proc);
if(status != 0)
goto bad_fork_cleanup_kstack;
// 3. call copy_mm to dup OR share mm according clone_flag
status = copy_mm(clone_flags, proc);
if(status != 0)
goto bad_fork_cleanup_proc;
// 4. call copy_thread to setup tf & context in proc_struct
copy_thread(proc, stack, tf);
// 5. insert proc_struct into hash_list && proc_list
proc->pid = get_pid();
hash_proc(proc);
set_links(proc);

// delete thses two lines !!!
//nr_process ++;
//list_add(&proc_list, &proc->list_link);
// delete thses two lines !!!

// 6. call wakeup_proc to make the new child process RUNNABLE
wakeup_proc(proc);
// 7. set ret vaule using child proc's pid
ret = proc->pid;

请在实验报告中描述当创建一个用户态进程并加载了应用程序后,CPU是如何让这个应用程序最终在用户态执行起来的。即这个用户态进程被ucore选择占用CPU执行(RUNNING态) 到具体执行应用程序第一条指令的整个经过。

  • 在经过调度器占用了CPU的资源之后,用户态进程调用了exec系统调用,从而转入到了系统调用的处理例程;
  • 调用中断处理例程之后,最终控制权转移到了syscall.c中的syscall函数,然后根据系统调用号转移给了sys_exec函数,在该函数中调用了上文中提及的do_execve函数来完成指定应用程序的加载;
  • 在do_execve中进行了若干设置,包括退出当前进程的页表,换用kernel的PDT之后,使用load_icode函数,完成了对整个用户线程内存空间的初始化,包括堆栈的设置以及将ELF可执行文件的加载,之后通过current->tf指针修改了当前系统调用的trapframe,使得最终中断返回的时候能够切换到用户态,并且同时可以正确地将控制权转移到应用程序的入口处;
  • 在完成了do_exec函数之后,进行正常的中断返回的流程,由于中断处理例程的栈上面的eip已经被修改成了应用程序的入口处,而cs上的CPL是用户态,因此iret进行中断返回的时候会将堆栈切换到用户的栈,并且完成特权级的切换,并且跳转到要求的应用程序的入口处;
  • 接下来开始具体执行应用程序的第一条指令;

本问题参考:https://www.jianshu.com/p/8c852af5b403

练习2:父进程复制自己的内存空间给子进程

创建子进程的函数do_fork在执行中将拷贝当前进程(即父进程)的用户内存地址空间中的合法内容到新进程中(子进程),完成内存资源的复制。具体是通过copy_range函数(位于 kern/mm/pmm.c中)实现的,请补充copy_range的实现,确保能够正确执行。

  • 父进程调用fork(),进入中断处理机制,最终交由syscall函数进行处理;
  • 在syscall,根据系统调用号,交由sys_fork函数处理;
  • 进一步调用do_fork函数,这个函数创建了子进程、并且将父进程的内存空间复制给子进程;
  • 在do_fork函数中,调用copy_mm进行内存空间的复制,在该函数中,进一步调用了dup_mmap。dup_mmap中遍历父进程的所有合法虚拟内存空间,并且将这些空间的内容复制到子进程的内存空间中去;
  • 在copy_range函数中,对需要复制的内存空间按照页为单位从父进程的内存空间复制到子进程的内存空间中去;

遍历父进程指定的某一段内存空间中的每一个虚拟页,如果这个虚拟页存在,为子进程对应的同一个地址(但是页目录表是不一样的,因此不是一个内存空间)也申请分配一个物理页,然后将前者中的所有内容复制到后者中去,然后为子进程的这个物理页和对应的虚拟地址(事实上是线性地址)建立映射关系;而在本练习中需要完成的内容就是内存的复制和映射的建立,具体流程如下:

  • 找到父进程指定的某一物理页对应的内核虚拟地址;
  • 找到需要拷贝过去的子进程的对应物理页对应的内核虚拟地址;
  • 将前者的内容拷贝到后者中去;
  • 为子进程当前分配这一物理页映射上对应的在子进程虚拟地址空间里的一个虚拟页;
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
60
61
/* copy_range - copy content of memory (start, end) of one process A to another process B
* @to: the addr of process B's Page Directory
* @from: the addr of process A's Page Directory
* @share: flags to indicate to dup OR share. We just use dup method, so it didn't be used.
*
* CALL GRAPH: copy_mm-->dup_mmap-->copy_range
*/
int
copy_range(pde_t *to, pde_t *from, uintptr_t start, uintptr_t end, bool share) {
assert(start % PGSIZE == 0 && end % PGSIZE == 0);
assert(USER_ACCESS(start, end));
// copy content by page unit.
do {
//call get_pte to find process A's pte according to the addr start
pte_t *ptep = get_pte(from, start, 0), *nptep;
if (ptep == NULL) {
start = ROUNDDOWN(start + PTSIZE, PTSIZE);
continue ;
}
//call get_pte to find process B's pte according to the addr start. If pte is NULL, just alloc a PT
if (*ptep & PTE_P) {
if ((nptep = get_pte(to, start, 1)) == NULL) {
return -E_NO_MEM;
}
uint32_t perm = (*ptep & PTE_USER);
//get page from ptep
struct Page *page = pte2page(*ptep);
// alloc a page for process B
struct Page *npage=alloc_page();
assert(page!=NULL);
assert(npage!=NULL);
int ret=0;
/* LAB5:EXERCISE2 YOUR CODE
* replicate content of page to npage, build the map of phy addr of nage with the linear addr start
*
* Some Useful MACROs and DEFINEs, you can use them in below implementation.
* MACROs or Functions:
* page2kva(struct Page *page): return the kernel vritual addr of memory which page managed (SEE pmm.
h)
* page_insert: build the map of phy addr of an Page with the linear addr la
* memcpy: typical memory copy function
*
* (1) find src_kvaddr: the kernel virtual address of page
* (2) find dst_kvaddr: the kernel virtual address of npage
* (3) memory copy from src_kvaddr to dst_kvaddr, size is PGSIZE
* (4) build the map of phy addr of nage with the linear addr start
*/
char *src_kvaddr = page2kva(page);
//找到父进程需要复制的物理页在内核地址空间中的虚拟地址,这是由于这个函数执行的时候使用的时内核的地址空间
char *dst_kvaddr = page2kva(npage);
// 找到子进程需要被填充的物理页的内核虚拟地址
memcpy(dst_kvaddr, src_kvaddr, PGSIZE);
// 将父进程的物理页的内容复制到子进程中去
page_insert(to, npage, start, perm);
// 建立子进程的物理页与虚拟页的映射关系
assert(ret == 0);
}
start += PGSIZE;
} while (start != 0 && start < end);
return 0;
}

练习3:阅读分析源代码,理解进程执行 fork/exec/wait/exit 的实现,以及系统调用的实现(不需要编码)

  1. fork:在执行了fork系统调用之后,会执行正常的中断处理流程,到中断向量表里查系统调用入口,最终将控制权转移给syscall,之后根据系统调用号执行sys_fork函数,进一步执行了上文中的do_fork函数,新进程的进程控制块进行初始化、设置、以及调用copy_mm将父进程内存中的内容到子进程的内存的复制工作,然后调用wakeup_proc将新创建的进程放入可执行队列(runnable),之后由调度器对子进程进行调度。

  2. exec:在执行了exec系统调用之后,会执行正常的中断处理流程,到中断向量表里查系统调用入口,最终将控制权转移给syscall,之后根据系统调用号执行sys_exec函数,进一步执行了上文中的do_execve函数。在该函数中,会对内存空间进行清空,然后调用load_icode将将要执行的程序加载到内存中,然后调用lcr3(boot_cr4)设置好中断帧,使得最终中断返回之后可以跳转到指定的应用程序的入口处,就可以正确执行了。

  3. wait:在执行了wait系统调用之后,会执行正常的中断处理流程,到中断向量表里查系统调用入口,最终将控制权转移给syscall,之后根据系统调用号执行sys_wait函数,进一步执行了的do_wait函数,在这个函数中,找一个当前进程的处于ZOMBIE状态的子进程,如果有的话直接将其占用的资源释放掉即可;如果找不到,则将我这个进程的状态改成SLEEPING态,并且标记为等待ZOMBIE态的子进程,然后调用schedule函数将其当前线程从CPU占用中切换出去,直到有对应的子进程结束来唤醒这个进程为止。

  4. exit:在执行了exit系统调用之后,会执行正常的中断处理流程,到中断向量表里查系统调用入口,最终将控制权转移给syscall,之后根据系统调用号执行sys_exit函数,进一步执行了的do_exit函数,首先将释放当前进程的大多数资源,然后将其标记为ZOMBIE态,然后调用wakeup_proc函数将其父进程唤醒(如果父进程执行了wait进入SLEEPING态的话),然后调用schedule函数,让出CPU资源,等待父进程进一步完成其所有资源的回收;

问题回答

请分析fork/exec/wait/exit在实现中是如何影响进程的执行状态的?

fork不会影响当前进程的执行状态,但是会将子进程的状态标记为RUNNALB,使得可以在后续的调度中运行起来;
exec不会影响当前进程的执行状态,但是会修改当前进程中执行的程序;
wait系统调用取决于是否存在可以释放资源(ZOMBIE)的子进程,如果有的话不会发生状态的改变,如果没有的话会将当前进程置为SLEEPING态,等待执行了exit的子进程将其唤醒;
exit会将当前进程的状态修改为ZOMBIE态,并且会将父进程唤醒(修改为RUNNABLE),然后主动让出CPU使用权;

实验六

实验目的

  • 理解操作系统的调度管理机制
  • 熟悉 ucore 的系统调度器框架,以及缺省的Round-Robin 调度算法
  • 基于调度器框架实现一个(Stride Scheduling)调度算法来替换缺省的调度算法

实验内容

  • 实验五完成了用户进程的管理,可在用户态运行多个进程。
  • 之前采用的调度策略是很简单的FIFO调度策略。
  • 本次实验,主要是熟悉ucore的系统调度器框架,以及基于此框架的Round-Robin(RR) 调度算法。
  • 然后参考RR调度算法的实现,完成Stride Scheduling调度算法。

调度框架和调度算法设计与实现

实验六中的kern/schedule/sched.c只实现了调度器框架,而不再涉及具体的调度算法实现,调度算法在单独的文件(default_sched.[ch])中实现。

在init.c中的kern_init函数中的proc_init之前增加了对sched_init函数的调用。sched_init函数主要完成了对实现特定调度算法的调度类(sched_class,这里是default_sched_class)的绑定,使得ucore在后续的执行中,能够通过调度框架找到实现特定调度算法的调度类并完成进程调度相关工作。

进程状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct proc_struct {
enum proc_state state; // Process state
int pid; // Process ID
int runs; // the running times of Proces
uintptr_t kstack; // Process kernel stack
volatile bool need_resched; // bool value: need to be rescheduled to release CPU?
struct proc_struct *parent; // the parent process
struct mm_struct *mm; // Process's memory management field
struct context context; // Switch here to run process
struct trapframe *tf; // Trap frame for current interrupt
uintptr_t cr3; // CR3 register: the base addr of Page Directroy Table(PDT)
uint32_t flags; // Process flag
char name[PROC_NAME_LEN + 1]; // Process name
list_entry_t list_link; // Process link list
list_entry_t hash_link; // Process hash list
};

ucore定义的进程控制块struct proc_struct包含了成员变量state,用于描述进程的运行状态,而running和runnable共享同一个状态(state)值(PROC_RUNNABLE。不同之处在于处于running态的进程不会放在运行队列中。进程的正常生命周期如下:

  • 进程首先在 cpu 初始化或者 sys_fork 的时候被创建,当为该进程分配了一个进程控制块之后,该进程进入 uninit态(在proc.c 中 alloc_proc)。
  • 当进程完全完成初始化之后,该进程转为runnable态。
  • 当到达调度点时,由调度器sched_class根据运行队列run_queue的内容来判断一个进程是否应该被运行,即把处于runnable态的进程转换成running状态,从而占用CPU执行。
  • running态的进程通过wait等系统调用被阻塞,进入sleeping态。
  • sleeping态的进程被wakeup变成runnable态的进程。
  • running态的进程主动 exit 变成zombie态,然后由其父进程完成对其资源的最后释放,子进程的进程控制块成为unused。
  • 所有从runnable态变成其他状态的进程都要出运行队列,反之,被放入某个运行队列中。

进程调度实现

内核抢占点

对于用户进程而言,由于有中断的产生,可以随时打断用户进程的执行,转到操作系统内部,从而给了操作系统以调度控制权,让操作系统可以根据具体情况(比如用户进程时间片已经用完了)选择其他用户进程执行。这体现了用户进程的可抢占性。

ucore内核执行是不可抢占的(non-preemptive),即在执行“任意”内核代码时,CPU控制权不可被强制剥夺。这里需要注意,不是在所有情况下ucore内核执行都是不可抢占的,有以下几种“固定”情况是例外:

  1. 进行同步互斥操作,比如争抢一个信号量、锁(lab7中会详细分析);
  2. 进行磁盘读写等耗时的异步操作,由于等待完成的耗时太长,ucore会调用shcedule让其他就绪进程执行。

以上两种是因为某个资源(也可称为事件)无法得到满足,无法继续执行下去,从而不得不主动放弃对CPU的控制权。在lab5中有几种情况是调用了schedule函数的。

编号 位置 原因
1 proc.c:do_exit 用户线程执行结束,主动放弃CPU
2 proc.c:do_wait 用户线程等待着子进程结束,主动放弃CPU
3 proc.c:init_main Init_porc内核线程等待所有用户进程结束;所有用户进程结束后回收系统资源
4 proc.c:cpu_idle idleproc内核线程等待处于就绪态的进程或线程,如果有选择一个并切换
5 sync.h:lock 进程无法得到锁,则主动放弃CPU
6 trap.c:trap 修改当前进程时间片,若时间片用完,则设置need_resched为1,让当前进程放弃CPU

第1、2、5处的执行位置体现了由于获取某种资源一时等不到满足、进程要退出、进程要睡眠等原因而不得不主动放弃CPU。第3、4处的执行位置比较特殊,initproc内核线程等待用户进程结束而执行schedule函数;idle内核线程在没有进程处于就绪态时才执行,一旦有了就绪态的进程,它将执行schedule函数完成进程调度。这里只有第6处的位置比较特殊:

1
2
3
4
5
6
7
if (!in_kernel) {
……

if (current->need_resched) {
schedule();
}
}

只有当进程在用户态执行到“任意”某处用户代码位置时发生了中断,且当前进程控制块成员变量need_resched为1(表示需要调度了)时,才会执行shedule函数。这实际上体现了对用户进程的可抢占性。如果没有第一行的if语句,那么就可以体现对内核代码的可抢占性。但如果要把这一行if语句去掉,我们就不得不实现对ucore中的所有全局变量的互斥访问操作,以防止所谓的race-condition现象,这样ucore的实现复杂度会增加不少。

Race condition旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出。 举例来说,如果计算机中的两个进程同时试图修改一个共享内存的内容,在没有并发控制的情况下,最后的结果依赖于两个进程的执行顺序与时机。而且如果发生了并发访问冲突,则最后的结果是不正确的。从维基百科的定义来看,race condition不仅仅是出现在程序中。以下讨论的race conditon全是计算机中多个进程同时访问一个共享内存,共享变量的例子。

要阻止出现race condition情况的关键就是不能让多个进程同时访问那块共享内存。访问共享内存的那段代码就是critical section。所有的解决方法都是围绕这个critical section来设计的。想要成功的解决race condition问题,并且程序还可以正确运行,从理论上应该满足以下四个条件:

  1. 不会有两个及以上进程同时出现在他们的critical section。
  2. 不要做任何关于CPU速度和数量的假设。
  3. 任何进程在运行到critical section之外时都不能阻塞其他进程。
  4. 不会有进程永远等在critical section之前。

进程切换过程

进程调度函数schedule选择了下一个将占用CPU执行的进程后,将调用进程切换,从而让新的进程得以执行。

两个用户进程,在二者进行进程切换的过程中,具体的步骤如下:

  1. 首先在执行某进程A的用户代码时,出现了一个trap,这个时候就会从进程A的用户态切换到内核态(过程(1)),并且保存好进程A的trapframe;当内核态处理中断时发现需要进行进程切换时,ucore要通过schedule函数选择下一个将占用CPU执行的进程(即进程B),然后会调用proc_run函数,proc_run函数进一步调用switch_to函数,切换到进程B的内核态(过程(2)),继续进程B上一次在内核态的操作,并通过iret指令,最终将执行权转交给进程B的用户空间(过程(3))。
  2. 当进程B由于某种原因发生中断之后(过程(4)),会从进程B的用户态切换到内核态,并且保存好进程B的trapframe;当内核态处理中断时发现需要进行进程切换时,即需要切换到进程A,ucore再次切换到进程A(过程(5)),会执行进程A上一次在内核调用schedule函数返回后的下一行代码,这行代码当然还是在进程A的上一次中断处理流程中。最后当进程A的中断处理完毕的时候,执行权又会反交给进程A的用户代码(过程(6))。这就是在只有两个进程的情况下,进程切换间的大体流程。

调度框架和调度算法

设计思路

在操作方面,如果需要选择一个就绪进程,就可以从基于某种组织方式的就绪进程集合中选择出一个进程执行。选择是在集合中挑选一个“合适”的进程,意味着离开就绪进程集合。

另外考虑到一个处于运行态的进程还会由于某种原因(比如时间片用完了)回到就绪态而不能继续占用CPU执行,这就会重新进入到就绪进程集合中。这两种情况就形成了调度器相关的三个基本操作:在就绪进程集合中选择进入就绪进程集合离开就绪进程集合。这三个操作属于调度器的基本操作。

在进程的执行过程中,就绪进程的等待时间执行进程的执行时间是影响调度选择的重要因素。这些进程状态变化的情况需要及时让进程调度器知道,便于选择更合适的进程执行。所以这种进程变化的情况就形成了调度器相关的一个变化感知操作:timer时间事件感知操作。这样在进程运行或等待的过程中,调度器可以调整进程控制块中与进程调度相关的属性值(比如消耗的时间片、进程优先级等),并可能导致对进程组织形式的调整(比如以时间片大小的顺序来重排双向链表等),并最终可能导致调选择新的进程占用CPU运行。这个操作属于调度器的进程调度属性调整操作。

数据结构

  • 在 ucore 中,调度器引入 run-queue(简称rq,即运行队列)的概念,通过链表结构管理进程。
  • 由于目前 ucore 设计运行在单CPU上,其内部只有一个全局的运行队列,用来管理系统内全部的进程。
  • 运行队列通过链表的形式进行组织。链表的每一个节点是一个list_entry_t,每个list_entry_t 又对应到了struct proc_struct *,这其间的转换是通过宏le2proc来完成。
  • 具体来说,我们知道在struct proc_struct中有一个叫run_linklist_entry_t,因此可以通过偏移量逆向找到对因某个run_liststruct proc_struct。即进程结构指针proc = le2proc(链表节点指针, run_link)
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
// The introduction of scheduling classes is borrrowed from Linux, and makes the
// core scheduler quite extensible. These classes (the scheduler modules) encapsulate
// the scheduling policies.
struct sched_class {
// the name of sched_class
const char *name;
// 初始化运行队列
void (*init)(struct run_queue *rq);

// put the proc into runqueue, and this function must be called with rq_lock
// 进程放入运行队列
void (*enqueue)(struct run_queue *rq, struct proc_struct *proc);

// get the proc out runqueue, and this function must be called with rq_lock
// 从队列中取出
void (*dequeue)(struct run_queue *rq, struct proc_struct *proc);

// choose the next runnable task
// 选择下一个可运行的任务
struct proc_struct *(*pick_next)(struct run_queue *rq);

// dealer of the time-tick
// 处理tick中断
void (*proc_tick)(struct run_queue *rq, struct proc_struct *proc);

/* for SMP support in the future
* load_balance
* void (*load_balance)(struct rq* rq);
* get some proc from this rq, used in load_balance,
* return value is the num of gotten proc
* int (*get_proc)(struct rq* rq, struct proc* procs_moved[]);
*/
};

proc.h 中的 struct proc_struct 中也记录了一些调度相关的信息:

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
struct proc_struct {
enum proc_state state; // Process state
int pid; // Process ID
int runs; // the running times of Proces
uintptr_t kstack; // Process kernel stack
volatile bool need_resched; // bool value: need to be rescheduled to release CPU?
struct proc_struct *parent; // the parent process
struct mm_struct *mm; // Process's memory management field
struct context context; // Switch here to run process
struct trapframe *tf; // Trap frame for current interrupt
uintptr_t cr3; // CR3 register: the base addr of Page Directroy Table(PDT)
uint32_t flags; // Process flag
char name[PROC_NAME_LEN + 1]; // Process name
list_entry_t list_link; // Process link list
list_entry_t hash_link; // Process hash list
int exit_code; // exit code (be sent to parent proc)
uint32_t wait_state; // waiting state
struct proc_struct *cptr, *yptr, *optr; // relations between processes
struct run_queue *rq; // running queue contains Process
list_entry_t run_link; // the entry linked in run queue
// 该进程的调度链表结构,该结构内部的连接组成了 运行队列 列表

int time_slice; // time slice for occupying the CPU
// 进程剩余的时间片
skew_heap_entry_t lab6_run_pool; // FOR LAB6 ONLY: the entry in the run pool
//在优先队列中用到的

uint32_t lab6_stride; // FOR LAB6 ONLY: the current stride of the process
// 步进值

uint32_t lab6_priority; // FOR LAB6 ONLY: the priority of process, set by lab6_set_priority(uint32_t)
// 优先级

};

RR调度算法在RR_sched_class调度策略类中实现。
通过数据结构 struct run_queue 来描述完整的 run_queue(运行队列)。它的主要结构如下:

1
2
3
4
5
6
7
8
9
10
struct run_queue {
//其运行队列的哨兵结构,可以看作是队列头和尾
list_entry_t run_list;
//优先队列形式的进程容器,只在 LAB6 中使用
skew_heap_entry_t *lab6_run_pool;
//表示其内部的进程总数
unsigned int proc_num;
//每个进程一轮占用的最多时间片
int max_time_slice;
};

在 ucore 框架中,运行队列存储的是当前可以调度的进程,所以,只有状态为runnable的进程才能够进入运行队列。当前正在运行的进程并不会在运行队列中。

调度点的相关关键函数

如果我们能够让wakup_procschedulerun_timer_list这三个调度相关函数的实现与具体调度算法无关,那么就可以认为ucore实现了一个与调度算法无关的调度框架。

wakeup_proc函数完成了把一个就绪进程放入到就绪进程队列中的工作,为此还调用了一个调度类接口函数sched_class_enqueue,这使得wakeup_proc的实现与具体调度算法无关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void wakeup_proc(struct proc_struct *proc) {
assert(proc->state != PROC_ZOMBIE);
bool intr_flag;
local_intr_save(intr_flag);
{
if (proc->state != PROC_RUNNABLE) {
proc->state = PROC_RUNNABLE;
proc->wait_state = 0;
if (proc != current) {
sched_class_enqueue(proc);
}
}
else {
warn("wakeup runnable process.\n");
}
}
local_intr_restore(intr_flag);
}

schedule函数完成了与调度框架和调度算法相关三件事情:

  • 把当前继续占用CPU执行的运行进程放放入到就绪进程队列中;
  • 从就绪进程队列中选择一个“合适”就绪进程;
  • 把这个“合适”的就绪进程从就绪进程队列中取出;
  • 如果没有的话,说明现在没有合适的进程可以执行,就执行idle_proc;
  • 加了一个runs,表明这个进程运行过几次了;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void schedule(void) {
bool intr_flag;
struct proc_struct *next;
local_intr_save(intr_flag);
{
current->need_resched = 0;
if (current->state == PROC_RUNNABLE) {
sched_class_enqueue(current);
}
if ((next = sched_class_pick_next()) != NULL) {
sched_class_dequeue(next);
}
if (next == NULL) {
next = idleproc;
}
next->runs ++;
if (next != current) {
proc_run(next);
}
}
local_intr_restore(intr_flag);
}

run_time_list在lab6中并没有涉及,是在lab7中的。

通过调用三个调度类接口函数sched_class_enqueuesched_class_pick_nextsched_class_enqueue来使得完成这三件事情与具体的调度算法无关。run_timer_list函数在每次timer中断处理过程中被调用,从而可用来调用调度算法所需的timer时间事件感知操作,调整相关进程的进程调度相关的属性值。通过调用调度类接口函数sched_class_proc_tick使得此操作与具体调度算法无关。
这里涉及了一系列调度类接口函数:

  • sched_class_enqueue
  • sched_class_dequeue
  • sched_class_pick_next
  • sched_class_proc_tick

这4个函数的实现其实就是调用某基于sched_class数据结构的特定调度算法实现的4个指针函数。采用这样的调度类框架后,如果我们需要实现一个新的调度算法,则我们需要定义一个针对此算法的调度类的实例,一个就绪进程队列的组织结构描述就行了,其他的事情都可交给调度类框架来完成。

RR调度算法

RR调度算法的调度思想是让所有runnable态的进程分时轮流使用CPU时间。

RR调度器维护当前runnable进程的有序运行队列。当前进程的时间片用完之后,调度器将当前进程放置到运行队列的尾部,再从其头部取出进程进行调度。

RR调度算法的就绪队列在组织结构上也是一个双向链表,只是增加了一个成员变量,表明在此就绪进程队列中的最大执行时间片。而且在进程控制块proc_struct中增加了一个成员变量time_slice,用来记录进程当前的可运行时间片段。这是由于RR调度算法需要考虑执行进程的运行时间不能太长。在每个timer到时的时候,操作系统会递减当前执行进程的time_slice,当time_slice为0时,就意味着这个进程运行了一段时间(这个时间片段称为进程的时间片),需要把CPU让给其他进程执行,于是操作系统就需要让此进程重新回到rq的队列尾,且重置此进程的时间片为就绪队列的成员变量最大时间片max_time_slice值,然后再从rq的队列头取出一个新的进程执行。

RR_enqueue的函数实现如下表所示。即把某进程的进程控制块指针放入到rq队列末尾,且如果进程控制块的时间片为0,则需要把它重置为rq成员变量max_time_slice。这表示如果进程在当前的执行时间片已经用完,需要等到下一次有机会运行时,才能再执行一段时间。

1
2
3
4
5
6
7
8
9
static void RR_enqueue(struct run_queue *rq, struct proc_struct *proc) {
assert(list_empty(&(proc->run_link)));
list_add_before(&(rq->run_list), &(proc->run_link));
if (proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
proc->time_slice = rq->max_time_slice;
}
proc->rq = rq;
rq->proc_num ++;
}

RR_pick_next的函数实现如下表所示。即选取就绪进程队列rq中的队头队列元素,并把队列元素转换成进程控制块指针。
1
2
3
4
5
6
7
8
static struct proc_struct *
RR_pick_next(struct run_queue *rq) {
list_entry_t *le = list_next(&(rq->run_list));
if (le != &(rq->run_list)) {
return le2proc(le, run_link);
}
return NULL;
}

RR_dequeue的函数实现如下表所示。即把就绪进程队列rq的进程控制块指针的队列元素删除,并把表示就绪进程个数的proc_num减一。
1
2
3
4
5
static void RR_dequeue(struct run_queue *rq, struct proc_struct *proc) {
assert(!list_empty(&(proc->run_link)) && proc->rq == rq);
list_del_init(&(proc->run_link));
rq->proc_num --;
}

RR_proc_tick的函数实现如下表所示。每次timer到时后,trap函数将会间接调用此函数来把当前执行进程的时间片time_slice减一。如果time_slice降到零,则设置此进程成员变量need_resched标识为1,这样在下一次中断来后执行trap函数时,会由于当前进程程成员变量need_resched标识为1而执行schedule函数,从而把当前执行进程放回就绪队列末尾,而从就绪队列头取出在就绪队列上等待时间最久的那个就绪进程执行。
1
2
3
4
5
6
7
8
9
static void
RR_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
if (proc->time_slice > 0) {
proc->time_slice --;
}
if (proc->time_slice == 0) {
proc->need_resched = 1;
}
}

Stride Scheduling

基本思路

  1. 为每个runnable的进程设置一个当前状态stride,表示该进程当前的调度权,也可以表示这个进程执行了多久了。另外定义其对应的pass值,表示对应进程在调度后,stride 需要进行的累加值。
  2. 每次需要调度时,从当前 runnable 态的进程中选择stride最小的进程调度。
  3. 对于获得调度的进程P,将对应的stride加上其对应的步长pass(只与进程的优先权有关系)。
  4. 在一段固定的时间之后,回到2步骤,重新调度当前stride最小的进程。

可以证明,如果令P.pass =BigStride / P.priority,其中P.priority表示进程的优先权(大于 1),而 BigStride 表示一个预先定义的大常数,则该调度方案为每个进程分配的时间将与其优先级成正比。

将该调度器应用到 ucore 的调度器框架中来,则需要将调度器接口实现如下:

  • init:
    • 初始化调度器类的信息(如果有的话)。
    • 初始化当前的运行队列为一个空的容器结构。(比如和RR调度算法一样,初始化为一个有序列表)
  • enqueue
    • 初始化刚进入运行队列的进程 proc的stride属性。
    • 将 proc插入放入运行队列中去(注意:这里并不要求放置在队列头部)。
  • dequeue
    • 从运行队列中删除相应的元素。
  • pick next
    • 扫描整个运行队列,返回其中stride值最小的对应进程。
    • 更新对应进程的stride值,即pass = BIG_STRIDE / P->priority; P->stride += pass。
  • proc tick:
    • 检测当前进程是否已用完分配的时间片。如果时间片用完,应该正确设置进程结构的相关标记来引起进程切换。
    • 一个 process 最多可以连续运行 rq.max_time_slice个时间片。

使用优先队列实现 Stride Scheduling

使用优化的优先队列数据结构实现该调度。

优先队列是这样一种数据结构:使用者可以快速的插入和删除队列中的元素,并且在预先指定的顺序下快速取得当前在队列中的最小(或者最大)值及其对应元素。可以看到,这样的数据结构非常符合 Stride 调度器的实现。

libs/skew_heap.h中是优先队列的一个实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static inline void skew_heap_init(skew_heap_entry_t *a) __attribute__((always_inline));
// 初始化一个队列节点

static inline skew_heap_entry_t *skew_heap_merge(
skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp);
// 合并两个优先队列

static inline skew_heap_entry_t *skew_heap_insert(
skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp) __attribute__((always_inline));
// 将节点 b 插入至以节点 a 为队列头的队列中去,返回插入后的队列

static inline skew_heap_entry_t *skew_heap_remove(
skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp) __attribute__((always_inline));
// 将节点 b 插入从以节点 a 为队列头的队列中去,返回删除后的队列

当使用优先队列作为Stride调度器的实现方式之后,运行队列结构也需要作相关改变,其中包括:

  1. struct run_queue中的lab6_run_pool指针,在使用优先队列的实现中表示当前优先队列的头元素,如果优先队列为空,则其指向空指针(NULL)。
  2. struct proc_struct中的lab6_run_pool结构,表示当前进程对应的优先队列节点。本次实验已经修改了系统相关部分的代码,使得其能够很好地适应LAB6新加入的数据结构和接口。而在实验中我们需要做的是用优先队列实现一个正确和高效的Stride调度器,如果用较简略的伪代码描述,则有:
  • init(rq):
    • Initialize rq->run_list
    • Set rq->lab6_run_pool to NULL
    • Set rq->proc_num to 0
  • enqueue(rq, proc)
    • Initialize proc->time_slice
    • Insert proc->lab6_run_pool into rq->lab6_run_pool
    • rq->proc_num ++
  • dequeue(rq, proc)
    • Remove proc->lab6_run_pool from rq->lab6_run_pool
    • rq->proc_num —
  • pick_next(rq)
    • If rq->lab6_run_pool == NULL, return NULL
    • Find the proc corresponding to the pointer rq->lab6_run_pool
    • proc->lab6_stride += BIG_STRIDE / proc->lab6_priority
    • Return proc
  • proc_tick(rq, proc):
    • If proc->time_slice > 0, proc->time_slice —
      – If proc->time_slice == 0, set the flag proc->need_resched

练习1: 使用 Round Robin 调度算法(不需要编码)

与之前相比,新增了斜堆数据结构的实现;新增了调度算法Round Robin的实现,具体为调用sched.c文件中的sched_class的一系列函数,主要有enqueue、dequeue、pick_next等。之后,这些函数进一步调用调度器中的相应函数,默认该调度器为Round Robin调度器,这是在default_sched.[c|h]中定义的;新增了set_priority,get_time等函数;

首先在init.c中调用了sched_init函数,在这里把sched_class赋值为default_sched_class,也就是RR,如下:

1
2
3
4
5
6
7
8
9
10
void
sched_init(void) {
list_init(&timer_list);

sched_class = &default_sched_class;
rq = &__rq;
rq->max_time_slice = MAX_TIME_SLICE;
sched_class->init(rq);
cprintf("sched class: %s\n", sched_class->name);
}

  • RR_init函数:这个函数会被封装为sched_init函数,用于调度算法的初始化,它是在ucore的init.c里面被调用进行初始化,主要完成了计时器list、run_queue的run_list的初始化;
  • enqueue函数:将某个进程放入调用算法中的可执行队列中,被封装成sched_class_enqueue函数,这个函数仅在wakeup_proc和schedule函数中被调用,wakeup_proc将某个不是RUNNABLE的进程改成RUNNABLE的并调用enqueue加入可执行队列,而后者是将正在执行的进程换出到可执行队列中去并取出一个可执行进程;
  • dequeue函数:将某个在队列中的进程取出,sched_class_dequeue将其封装并在schedule中被调用,将调度算法选择的进程从等待的可执行进程队列中取出;
  • pick_next函数:根据调度算法选择下一个要执行的进程,仅在schedule中被调用;
  • proc_tick函数:在时钟中断时执行的操作,时间片减一,当时间片为0时,说明这个进程需要重新调度了。仅在进行时间中断的ISR中调用;

请理解并分析sched_calss中各个函数指针的用法,并接合Round Robin 调度算法描述ucore的调度执行过程:

  • ucore中的调度主要通过schedule和wakeup_proc函数完成,schedule主要把当前执行的进程入队,调用sched_class_pick_next选择下一个执行的进程并将其出队,开始执行。scheduleha函数把当前的进程入队,挑选一个进程将其出队并开始执行。
  • 当需要将某一个进程加入就绪进程队列中,需要调用enqueue,将其插入到使用链表组织run_queue的队尾,将这个进程的能够使用的时间片初始化为max_time_slice;
  • 当需要将某一个进程从就绪队列中取出,需要调用dequeue,调用list_del_init将其直接删除即可;
  • 当需要取出执行的下一个进程时,只需调用pick_next将就绪队列run_queue的队头取出即可;
  • 在一个时钟中断中,调用proc_tick将当前执行的进程的剩余可执行时间减1,一旦减到了0,则这个进程的need_resched为1,设成可以被调度的,这样之后就会调用schedule函数将这个进程切换出去;

请在实验报告中简要说明如何设计实现”多级反馈队列调度算法“,给出概要设计,鼓励给出详细设计;

调度机制:

  1. 进程在进入待调度的队列等待时,首先进入优先级最高的Q1等待。
  2. 设置多个就绪队列。在系统中设置多个就绪队列,并为每个队列赋予不同的优先级,从第一个开始逐个降低。不同队列进程中所赋予的执行时间也不同,优先级越高,时间片越小。
  3. 每个队列都采用FCFS(先来先服务)算法。轮到该进程执行时,若在该时间片内完成,便撤离操作系统,否则调度程序将其转入第二队列的末尾等待调度,…….。若进程最后被调到第N队列中时,便采用RR方式运行。
  4. 按队列优先级调度。调度按照优先级最高队列中诸进程运行,仅当第一队列空闲时才调度第二队列进程执行。若低优先级队列执行中有优先级高队列进程执行,应立刻将此进程放入队列末尾,把处理机分配给新到高优先级进程。
  • 设置N个多级反馈队列的入口,Q0,Q1,Q2,Q3,…,编号越靠前的队列优先级越低,优先级越低的队列上时间片的长度越大;
  • 调用sched_init对调度算法初始化的时候需要同时对N个队列进行初始化;
  • 在将进程加入到就绪进程集合的时候,观察这个进程的时间片有没有使用完,如果使用完了,就将所在队列的优先级调低,加入到优先级低一级的队列中去,如果没有使用完时间片,则加入到当前优先级的队列中去;
  • 在同一个优先级的队列内使用时间片轮转算法;
  • 在选择下一个执行的进程的时候,先考虑更高优先级的队列中是否存在任务,如果不存在在去找较低优先级的队列;
  • 从就绪进程集合中删除某一个进程的话直接在对应队列中删除;

练习2:实现 Stride Scheduling 调度算法(需要编码)

啊啊啊忘了在trap.c里改怪不得怎么都搞不对啊啊啊啊啊啊啊啊啊这下子总算有170了!!!

还是先看看代码里斜堆(skew heap)的实现吧,好多地方要用到这个结构,具体可以在yuhao0102.github.io里仔细看。
在libs/skew.h中定义了skew heap。

猜测这只是一个入口,类似链表那种实现,不包括数据,只有指针。

1
2
3
struct skew_heap_entry {
struct skew_heap_entry *parent, *left, *right;
};

proc_stride_comp_f函数是用来比较这两个进程的stride的,a比b大返回1,相等返回0,a比b小返回-1。

1
2
3
4
5
6
7
8
9
10
11
/* The compare function for two skew_heap_node_t's and the
* corresponding procs*/
static int proc_stride_comp_f(void *a, void *b)
{
struct proc_struct *p = le2proc(a, lab6_run_pool);
struct proc_struct *q = le2proc(b, lab6_run_pool);
int32_t c = p->lab6_stride - q->lab6_stride;
if (c > 0) return 1;
else if (c == 0) return 0;
else return -1;
}

这是初始化的函数,把三个指针初始化为NULL

1
2
3
4
5
static inline void
skew_heap_init(skew_heap_entry_t *a)
{
a->left = a->right = a->parent = NULL;
}

这个是把两个堆merge在一起的操作,强行内联hhh,这个是递归的!

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
static inline skew_heap_entry_t *
skew_heap_merge(skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp)
{
if (a == NULL) return b;
else if (b == NULL) return a;
// 如果a或b有一个为空,则返回另一个

skew_heap_entry_t *l, *r;
if (comp(a, b) == -1)
{
r = a->left;
l = skew_heap_merge(a->right, b, comp);

a->left = l;
a->right = r;
if (l) l->parent = a;

return a;
// 否则判断a和b的值哪个大,如果a比b小,则a的右子树和b合并,a作为堆顶
}
else
{
r = b->left;
l = skew_heap_merge(a, b->right, comp);

b->left = l;
b->right = r;
if (l)
l->parent = b;
return b;
// 另一种情况
}
}

insert就是把一个单节点的堆跟大堆合并

1
2
3
4
5
6
7
static inline skew_heap_entry_t *
skew_heap_insert(skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp)
{
skew_heap_init(b);
return skew_heap_merge(a, b, comp);
}

删除就是把节点的左右子树进行merge,比较简单,记得删掉这个节点之后补充它的parent即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static inline skew_heap_entry_t *
skew_heap_remove(skew_heap_entry_t *a, skew_heap_entry_t *b,
compare_f comp)
{
skew_heap_entry_t *p = b->parent;
skew_heap_entry_t *rep = skew_heap_merge(b->left, b->right, comp);
if (rep) rep->parent = p;

if (p)
{
if (p->left == b)
p->left = rep;
else p->right = rep;
return a;
}
else return rep;
}

首先把default_sched.c中设置RR调度器为默认调度器的部分注释掉,然后把default_sched_stride_c改成default_sched_stride.c,这里对默认调度器进行了重新定义。

1
2
3
4
5
6
7
8
struct sched_class default_sched_class = {
.name = "stride_scheduler",
.init = stride_init,
.enqueue = stride_enqueue,
.dequeue = stride_dequeue,
.pick_next = stride_pick_next,
.proc_tick = stride_proc_tick,
};

针对PCB的初始化,代码如下,综合了几个实验的初始化代码,也是一个总结:
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
//LAB4:EXERCISE1 YOUR CODE
/*
* below fields in proc_struct need to be initialized
* enum proc_state state; // Process state
* int pid; // Process ID
* int runs; // the running times of Proces
* uintptr_t kstack; // Process kernel stack
* volatile bool need_resched; // bool value: need to be rescheduled to release CPU?
* struct proc_struct *parent; // the parent process
* struct mm_struct *mm; // Process's memory management field
* struct context context; // Switch here to run process
* struct trapframe *tf; // Trap frame for current interrupt
* uintptr_t cr3; // CR3 register: the base addr of Page Directroy Table(PDT)
* uint32_t flags; // Process flag
* char name[PROC_NAME_LEN + 1]; // Process name
*/
proc->state = PROC_UNINIT;
proc->pid = -1;
proc->cr3 = boot_cr3;

proc->runs = 0;
proc->kstack = 0;
proc->need_resched = 0;
proc->parent = NULL;
proc->mm = NULL;
memset(&proc->context, 0, sizeof(struct context));
proc->tf = NULL;
proc->flags = 0;
memset(proc->name, 0, PROC_NAME_LEN);

//LAB5 YOUR CODE : (update LAB4 steps)
/*
* below fields(add in LAB5) in proc_struct need to be initialized
* uint32_t wait_state; // waiting state
* struct proc_struct *cptr, *yptr, *optr; // relations between processes
*/
proc->wait_state = 0;
proc->cptr = proc->optr = proc->yptr = NULL;
//LAB6 YOUR CODE : (update LAB5 steps)
/*
* below fields(add in LAB6) in proc_struct need to be initialized
* struct run_queue *rq; // running queue contains Process
* list_entry_t run_link; // the entry linked in run queue
* int time_slice; // time slice for occupying the CPU
* skew_heap_entry_t lab6_run_pool; // FOR LAB6 ONLY: the entry in the run pool
* uint32_t lab6_stride; // FOR LAB6 ONLY: the current stride of the process
* uint32_t lab6_priority; // FOR LAB6 ONLY: the priority of process, set by lab6_set_priority(uint32_t)
*/
proc->rq = NULL;
memset(&proc->run_link, 0, sizeof(list_entry_t));
proc->time_slice = 0;
memset(&proc->lab6_run_pool,0,sizeof(skew_heap_entry_t));
proc->lab6_stride=0;
proc->lab6_priority=1;

主要就是在vim kern/schedule/default_sched_stride.c里的修改。
1
#define BIG_STRIDE ((uint32_t)(1<<31)-3)

BIG_STRIDE应该设置成小于2^32-1的一个常数。

这个函数用来对run_queue进行初始化等操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*
* stride_init initializes the run-queue rq with correct assignment for
* member variables, including:
*
* - run_list: should be a empty list after initialization.
* - lab6_run_pool: NULL
* - proc_num: 0
* - max_time_slice: no need here, the variable would be assigned by the caller.
*
* hint: see libs/list.h for routines of the list structures.
*/
static void
stride_init(struct run_queue *rq) {
/* LAB6: YOUR CODE
* (1) init the ready process list: rq->run_list
* (2) init the run pool: rq->lab6_run_pool
* (3) set number of process: rq->proc_num to 0
*/
list_init(&rq->run_list);
rq->lab6_run_pool = NULL;
rq->proc_num = 0;
}

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
/*
* stride_enqueue inserts the process ``proc'' into the run-queue
* ``rq''. The procedure should verify/initialize the relevant members
* of ``proc'', and then put the ``lab6_run_pool'' node into the
* queue(since we use priority queue here). The procedure should also
* update the meta date in ``rq'' structure.
*
* proc->time_slice denotes the time slices allocation for the
* process, which should set to rq->max_time_slice.
*
* hint: see libs/skew_heap.h for routines of the priority
* queue structures.
*/
static void
stride_enqueue(struct run_queue *rq, struct proc_struct *proc) {
/* LAB6: YOUR CODE
* (1) insert the proc into rq correctly
* NOTICE: you can use skew_heap or list. Important functions
* skew_heap_insert: insert a entry into skew_heap
* list_add_before: insert a entry into the last of list
* (2) recalculate proc->time_slice
* (3) set proc->rq pointer to rq
* (4) increase rq->proc_num
*/
rq->lab6_run_pool = skew_heap_insert(rq->lab6_run_pool, &proc->lab6_run_pool, proc_stride_comp_f);
// 做插入操作,把这个进程插到run_pool里。
if(proc->time_slice == 0 || proc->time_slice > rq->max_time_slice) {
proc->time_slice = rq->max_time_slice;
}
// 如果这个进程的时间片不符合要求,就把它初始化成最大值。
proc->rq = rq;
rq->proc_num ++;
//run_queue里的进程数++
}

做删除操作,把这个进程从run_pool里删除,并且将run_queue里的进程数减一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
* stride_dequeue removes the process ``proc'' from the run-queue
* ``rq'', the operation would be finished by the skew_heap_remove
* operations. Remember to update the ``rq'' structure.
*
* hint: see libs/skew_heap.h for routines of the priority
* queue structures.
*/
static void
stride_dequeue(struct run_queue *rq, struct proc_struct *proc) {
/* LAB6: YOUR CODE
* (1) remove the proc from rq correctly
* NOTICE: you can use skew_heap or list. Important functions
* skew_heap_remove: remove a entry from skew_heap
* list_del_init: remove a entry from the list
*/
rq->lab6_run_pool = skew_heap_remove(rq->lab6_run_pool, &proc->lab6_run_pool, proc_stride_comp_f);
rq->proc_num --;
}

pick_next从run_queue中选择stride值最小的进程,即斜堆的根节点对应的进程,并且返回这个proc,同时更新这个proc的stride

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
/*
* stride_pick_next pick the element from the ``run-queue'', with the
* minimum value of stride, and returns the corresponding process
* pointer. The process pointer would be calculated by macro le2proc,
* see kern/process/proc.h for definition. Return NULL if
* there is no process in the queue.
*
* When one proc structure is selected, remember to update the stride
* property of the proc. (stride += BIG_STRIDE / priority)
*
* hint: see libs/skew_heap.h for routines of the priority
* queue structures.
*/
static struct proc_struct *
stride_pick_next(struct run_queue *rq) {
/* LAB6: YOUR CODE
* (1) get a proc_struct pointer p with the minimum value of stride
(1.1) If using skew_heap, we can use le2proc get the p from rq->lab6_run_poll
(1.2) If using list, we have to search list to find the p with minimum stride value
* (2) update p;s stride value: p->lab6_stride
* (3) return p
*/
if (rq->lab6_run_pool == NULL)
return NULL;
struct proc_struct *p = le2proc(rq->lab6_run_pool, lab6_run_pool);
p->lab6_stride += BIG_STRIDE/p->lab6_priority;
return p;
}

要在trap的时候调用!!!!如果这个proc的时间片还有的话,就减一,如果这个时间片为0了,就把它设成可调度的,参与调度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* stride_proc_tick works with the tick event of current process. You
* should check whether the time slices for current process is
* exhausted and update the proc struct ``proc''. proc->time_slice
* denotes the time slices left for current
* process. proc->need_resched is the flag variable for process
* switching.
*/
static void
stride_proc_tick(struct run_queue *rq, struct proc_struct *proc) {
/* LAB6: YOUR CODE */
if (proc->time_slice > 0) {
proc->time_slice --;
}
if (proc->time_slice == 0) {
proc->need_resched = 1;
}
}

实验七

实验目的

  • 理解操作系统的同步互斥的设计实现;
  • 理解底层支撑技术:禁用中断、定时器、等待队列;
  • 在ucore中理解信号量(semaphore)机制的具体实现;
  • 理解管程机制,在ucore内核中增加基于管程(monitor)的条件变量(condition variable)的支持;
  • 了解经典进程同步问题,并能使用同步机制解决进程同步问题。

实验内容

lab6已经可以调度运行多个进程,如果多个进程需要协同操作或访问共享资源,则存在如何同步和有序竞争的问题。本次实验,主要是熟悉ucore的进程同步机制—信号量(semaphore)机制,以及基于信号量的哲学家就餐问题解决方案。然后掌握管程的概念和原理,并参考信号量机制,实现基于管程的条件变量机制和基于条件变量来解决哲学家就餐问题。

在本次实验中,在kern/sync/check_sync.c中提供了一个基于信号量的哲学家就餐问题解法。同时还需完成练习,即实现基于管程(主要是灵活运用条件变量和互斥信号量)的哲学家就餐问题解法。

哲学家就餐问题描述如下:有五个哲学家,他们的生活方式是交替地进行思考和进餐。哲学家们公用一张圆桌,周围放有五把椅子,每人坐一把。在圆桌上有五个碗和五根筷子,当一个哲学家思考时,他不与其他人交谈,饥饿时便试图取用其左、右最靠近他的筷子,但他可能一根都拿不到。只有在他拿到两根筷子时,方能进餐,进餐完后,放下筷子又继续思考。

同步互斥的设计与实现

实验执行流程概述

互斥是指某一资源同时只允许一个进程对其进行访问,具有唯一性排它性,但互斥不用限制进程对资源的访问顺序,即访问可以是无序的。同步是指在进程间的执行必须严格按照规定的某种先后次序来运行,即访问是有序的,这种先后次序取决于要系统完成的任务需求。在进程写资源情况下,进程间要求满足互斥条件。在进程读资源情况下,可允许多个进程同时访问资源。

实验七设计实现了多种同步互斥手段,包括时钟中断管理、等待队列、信号量、管程机制(包含条件变量设计)等,并基于信号量实现了哲学家问题的执行过程。而本次实验的练习是要求用管程机制实现哲学家问题的执行过程。在实现信号量机制和管程机制时,需要让无法进入临界区的进程睡眠,为此在ucore中设计了等待队列wait_queue。当进程无法进入临界区(即无法获得信号量)时,可让进程进入等待队列,这时的进程处于等待状态(也可称为阻塞状态),从而会让实验六中的调度器选择一个处于就绪状态(即RUNNABLE_STATE)的进程,进行进程切换,让新进程有机会占用CPU执行,从而让整个系统的运行更加高效。

lab7/kern/sync/check_sync.c中的check_sync函数可以理解为是实验七的起始执行点,是实验七的总控函数。进一步分析此函数,可以看到这个函数主要分为了两个部分,第一部分是实现基于信号量的哲学家问题,第二部分是实现基于管程的哲学家问题。

  • 对于check_sync函数的第一部分,首先实现初始化了一个互斥信号量,然后创建了对应5个哲学家行为的5个信号量,并创建5个内核线程代表5个哲学家,每个内核线程完成了基于信号量的哲学家吃饭睡觉思考行为实现。
  • 对于check_sync函数的第二部分,首先初始化了管程,然后又创建了5个内核线程代表5个哲学家,每个内核线程要完成基于管程的哲学家吃饭、睡觉、思考的行为实现。

同步互斥的底层支撑

由于调度的存在,且进程在访问某类资源暂时无法满足的情况下会进入等待状态,导致了多进程执行时序的不确定性和潜在执行结果的不确定性。为了确保执行结果的正确性,本试验需要设计更加完善的进程等待和互斥的底层支撑机制,确保能正确提供基于信号量和条件变量的同步互斥机制。

由于有定时器、屏蔽/使能中断、等待队列wait_queue支持test_and_set_bit等原子操作机器指令(在本次实验中没有用到)的存在,使得我们在实现进程等待、同步互斥上得到了极大的简化。下面将对定时器、屏蔽/使能中断和等待队列进行进一步讲解。

定时器

在传统的操作系统中,定时器提供了基于时间事件的调度机制。在ucore中,两次时间中断之间的时间间隔为一个时间片,timer splice。

基于此时间单位,操作系统得以向上提供基于时间点的事件,并实现基于时间长度的睡眠等待和唤醒机制。在每个时钟中断发生时,操作系统产生对应的时间事件。

sched.h, sched.c定义了有关timer的各种相关接口来使用 timer 服务,其中主要包括:

  • typedef struct {……} timer_t:定义了 timer_t 的基本结构,其可以用 sched.h 中的timer_init函数对其进行初始化。
  • void timer_init(timer t *timer, struct proc_struct *proc, int expires): 对某定时器进行初始化,让它在expires时间片之后唤醒proc进程。
  • void add_timer(timer t *timer):向系统添加某个初始化过的timer_t,该定时器在指定时间后被激活,并将对应的进程唤醒至runnable(如果当前进程处在等待状态)。
  • void del_timer(timer_t *time):向系统删除(或者说取消)某一个定时器。该定时器在取消后不会被系统激活并唤醒进程。
  • void run_timer_list(void):更新当前系统时间点,遍历当前所有处在系统管理内的定时器,找出所有应该激活的计数器,并激活它们。该过程在且只在每次定时器中断时被调用。在ucore中,其还会调用调度器事件处理程序。

一个 timer_t 在系统中的存活周期可以被描述如下:

  • timer_t在某个位置被创建和初始化,并通过add_timer加入系统管理列表中;
  • 系统时间被不断累加,直到 run_timer_list 发现该 timer_t到期;
  • run_timer_list更改对应的进程状态,并从系统管理列表中移除该timer_t;

屏蔽与使能中断

之前用过,这里简单看看。

在ucore中提供的底层机制包括中断屏蔽/使能控制等。kern/sync.c有开关中断的控制函数local_intr_save(x)local_intr_restore(x),它们是基于kern/driver文件下的intr_enable()intr_disable()函数实现的。具体调用关系为:

关中断:local_intr_save —> __intr_save —> intr_disable —> cli
开中断:local_intr_restore —> __intr_restore —> intr_enable —> sti

最终的cli和sti是x86的机器指令,最终实现了关(屏蔽)中断和开(使能)中断,即设置了eflags寄存器中与中断相关的位。通过关闭中断,可以防止对当前执行的控制流被其他中断事件处理所打断。既然不能中断,那也就意味着在内核运行的当前进程无法被打断或被重新调度,即实现了对临界区的互斥操作。所以在单处理器情况下,可以通过开关中断实现对临界区的互斥保护,需要互斥的临界区代码的一般写法为:

1
2
3
4
5
local_intr_save(intr_flag);
{
临界区代码
}
local_intr_restore(intr_flag);

但是,在多处理器情况下,这种方法是无法实现互斥的,因为屏蔽了一个CPU的中断,只能阻止本地CPU上的进程不会被中断或调度,并不意味着其他CPU上执行的进程不能执行临界区的代码。所以,开关中断只对单处理器下的互斥操作起作用。

等待队列

在课程中提到用户进程或内核线程可以转入等待状态以等待某个特定事件(比如睡眠,等待子进程结束,等待信号量等),当该事件发生时这些进程能够被再次唤醒。内核实现这一功能的一个底层支撑机制就是等待队列wait_queue,等待队列和每一个事件(睡眠结束、时钟到达、任务完成、资源可用等)联系起来。需要等待事件的进程在转入休眠状态后插入到等待队列中。当事件发生之后,内核遍历相应等待队列,唤醒休眠的用户进程或内核线程,并设置其状态为就绪状态(PROC_RUNNABLE),并将该进程从等待队列中清除。

ucore在kern/sync/{ wait.h, wait.c }中实现了等待项wait结构和等待队列wait queue结构以及相关函数),这是实现ucore中的信号量机制和条件变量机制的基础,进入wait queue的进程会被设为等待状态(PROC_SLEEPING),直到他们被唤醒。

数据结构定义

1
2
3
4
5
6
7
8
9
10
11
12
typedef  struct {
struct proc_struct *proc; //等待进程的指针
uint32_t wakeup_flags; //进程被放入等待队列的原因标记
wait_queue_t *wait_queue; //指向此wait结构所属于的wait_queue
list_entry_t wait_link; //用来组织wait_queue中wait节点的连接
} wait_t;

typedef struct {
list_entry_t wait_head; //wait_queue的队头
} wait_queue_t;

le2wait(le, member) //实现wait_t中成员的指针向wait_t 指针的转化

相关函数说明
与wait和wait queue相关的函数主要分为两层,底层函数是对wait queue的初始化、插入、删除和查找操作,相关函数如下:

wait_init:初始化wait结构,将放入等待队列的原因标记设置为WT_INTERRUPTED,意为可以被打断等待状态

1
2
3
4
5
6
void
wait_init(wait_t *wait, struct proc_struct *proc) {
wait->proc = proc;
wait->wakeup_flags = WT_INTERRUPTED;
list_init(&(wait->wait_link));
}

wait_in_queue:wait是否在wait queue中

1
2
3
4
bool
wait_in_queue(wait_t *wait) {
return !list_empty(&(wait->wait_link));
}

wait_queue_init:初始化wait_queue结构

1
2
3
4
void
wait_queue_init(wait_queue_t *queue) {
list_init(&(queue->wait_head));
}

wait_queue_add:设置当前等待项wait的等待队列,并把wait前插到wait queue中

1
2
3
4
5
6
void
wait_queue_add(wait_queue_t *queue, wait_t *wait) {
assert(list_empty(&(wait->wait_link)) && wait->proc != NULL);
wait->wait_queue = queue;
list_add_before(&(queue->wait_head), &(wait->wait_link));
}

wait_queue_del:从wait queue中删除wait

1
2
3
4
5
void
wait_queue_del(wait_queue_t *queue, wait_t *wait) {
assert(!list_empty(&(wait->wait_link)) && wait->wait_queue == queue);
list_del_init(&(wait->wait_link));
}

wait_queue_next:取得wait_queue中wait等待项的后一个链接指针

1
2
3
4
5
6
7
8
9
wait_t *
wait_queue_next(wait_queue_t *queue, wait_t *wait) {
assert(!list_empty(&(wait->wait_link)) && wait->wait_queue == queue);
list_entry_t *le = list_next(&(wait->wait_link));
if (le != &(queue->wait_head)) {
return le2wait(le, wait_link);
}
return NULL;
}

wait_queue_prev:取得wait_queue中wait等待项的前一个链接指针

1
2
3
4
5
6
7
8
9
wait_t *
wait_queue_prev(wait_queue_t *queue, wait_t *wait) {
assert(!list_empty(&(wait->wait_link)) && wait->wait_queue == queue);
list_entry_t *le = list_prev(&(wait->wait_link));
if (le != &(queue->wait_head)) {
return le2wait(le, wait_link);
}
return NULL;
}

wait_queue_first:取得wait queue的第一个wait

1
2
3
4
5
6
7
8
wait_t *
wait_queue_first(wait_queue_t *queue) {
list_entry_t *le = list_next(&(queue->wait_head));
if (le != &(queue->wait_head)) {
return le2wait(le, wait_link);
}
return NULL;
}

wait_queue_last:取得wait queue的最后一个wait

1
2
3
4
5
6
7
8
wait_t *
wait_queue_last(wait_queue_t *queue) {
list_entry_t *le = list_prev(&(queue->wait_head));
if (le != &(queue->wait_head)) {
return le2wait(le, wait_link);
}
return NULL;
}

bool wait_queue_empty:wait queue是否为空

1
2
3
4
bool
wait_queue_empty(wait_queue_t *queue) {
return list_empty(&(queue->wait_head));
}

高层函数基于底层函数实现了让进程进入等待队列—wait_current_set,以及从等待队列中唤醒进程—wakeup_wait,相关函数如下:

wait_current_set:进程进入等待队列,当前进程的状态设置成睡眠

1
2
3
4
5
6
7
8
void
wait_current_set(wait_queue_t *queue, wait_t *wait, uint32_t wait_state) {
assert(current != NULL);
wait_init(wait, current);
current->state = PROC_SLEEPING;
current->wait_state = wait_state;
wait_queue_add(queue, wait);
}

wait_current_del:把与当前进程关联的wait从等待队列queue中删除

1
2
3
4
5
6
#define wait_current_del(queue, wait)                                       \
do { \
if (wait_in_queue(wait)) { \
wait_queue_del(queue, wait); \
} \
} while (0)

wakeup_wait:唤醒等待队列上的wait所关联的进程

1
2
3
4
5
6
7
8
void
wakeup_wait(wait_queue_t *queue, wait_t *wait, uint32_t wakeup_flags, bool del) {
if (del) {
wait_queue_del(queue, wait);
}
wait->wakeup_flags = wakeup_flags;
wakeup_proc(wait->proc);
}

void wakeup_first:唤醒等待队列上第一个的等待的进程

1
2
3
4
5
6
7
void
wakeup_first(wait_queue_t *queue, uint32_t wakeup_flags, bool del) {
wait_t *wait;
if ((wait = wait_queue_first(queue)) != NULL) {
wakeup_wait(queue, wait, wakeup_flags, del);
}
}

wakeup_queue:唤醒等待队列上的所有等待进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void
wakeup_queue(wait_queue_t *queue, uint32_t wakeup_flags, bool del) {
wait_t *wait;
if ((wait = wait_queue_first(queue)) != NULL) {
if (del) {
do {
wakeup_wait(queue, wait, wakeup_flags, 1);
} while ((wait = wait_queue_first(queue)) != NULL);
}
else {
do {
wakeup_wait(queue, wait, wakeup_flags, 0);
} while ((wait = wait_queue_next(queue, wait)) != NULL);
}
}
}

信号量

信号量是一种同步互斥机制的实现,普遍存在于现在的各种操作系统内核里。相对于spinlock 的应用对象,信号量的应用对象是在临界区中运行的时间较长的进程。等待信号量的进程需要睡眠来减少占用 CPU 的开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct semaphore {
int count;
queueType queue;
};
void semWait(semaphore s)
{
s.count--;
if (s.count < 0) {
/* place this process in s.queue */;
/* block this process */;
}
}
void semSignal(semaphore s)
{
s.count++;
if (s.count<= 0) {
/* remove a process P from s.queue */;
/* place process P on ready list */;
}
}

基于上诉信号量实现可以认为,当多个(>1)进程可以进行互斥或同步合作时,一个进程会由于无法满足信号量设置的某条件而在某一位置停止,直到它接收到一个特定的信号(表明条件满足了)。为了发信号,需要使用一个称作信号量的特殊变量。为通过信号量s传送信号,信号量的V操作采用进程可执行原语semSignal(s);为通过信号量s接收信号,信号量的P操作采用进程可执行原语semWait(s);如果相应的信号仍然没有发送,则进程被阻塞或睡眠,直到发送完为止。
ucore中信号量参照上述原理描述,建立在开关中断机制和wait_queue的基础上进行了具体实现。信号量的数据结构定义如下:
1
2
3
4
typedef struct {
int value; //信号量的当前值
wait_queue_t wait_queue; //信号量对应的等待队列
} semaphore_t;

semaphore_t是最基本的记录型信号量(record semaphore)结构,包含了用于计数的整数值value,和一个进程等待队列wait_queue,一个等待的进程会挂在此等待队列上。

在ucore中最重要的信号量操作是P操作函数down(semaphore_t *sem)和V操作函数up(semaphore_t *sem)。但这两个函数的具体实现是__down(semaphore_t *sem, uint32_t wait_state)函数和__up(semaphore_t *sem, uint32_t wait_state)函数,二者的具体实现描述如下:

__down(semaphore_t *sem, uint32_t wait_state, timer_t *timer):具体实现信号量的P操作,首先关掉中断,然后判断当前信号量的value是否大于0。如果是>0,则表明可以获得信号量,故让value减一,并打开中断返回即可;如果不是>0,则表明无法获得信号量,故需要将当前的进程加入到等待队列中,并打开中断,然后运行调度器选择另外一个进程执行。如果被V操作唤醒,则把自身关联的wait从等待队列中删除(此过程需要先关中断,完成后开中断)。

__up(semaphore_t *sem, uint32_t wait_state):具体实现信号量的V操作,首先关中断,如果信号量对应的wait queue中没有进程在等待,直接把信号量的value加一,然后开中断返回;如果有进程在等待且进程等待的原因是semophore设置的,则调用wakeup_wait函数将waitqueue中等待的第一个wait删除,且把此wait关联的进程唤醒,最后开中断返回。

对照信号量的原理性描述和具体实现,可以发现二者在流程上基本一致,只是具体实现采用了关中断的方式保证了对共享资源的互斥访问,通过等待队列让无法获得信号量的进程睡眠等待。另外,我们可以看出信号量的计数器value具有有如下性质:

  • value>0,表示共享资源的空闲数
  • vlaue<0,表示该信号量的等待队列里的进程数
  • value=0,表示等待队列为空

管程和条件变量

原理回顾

引入了管程是为了将对共享资源的所有访问及其所需要的同步操作集中并封装起来。Hansan为管程所下的定义:“一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作,这组操作能同步进程和改变管程中的数据”。有上述定义可知,管程由四部分组成:

  • 管程内部的共享变量;
  • 管程内部的条件变量;
  • 管程内部并发执行的进程;
  • 对局部于管程内部的共享数据设置初始值的语句。

局限在管程中的数据结构,只能被局限在管程的操作过程所访问,任何管程之外的操作过程都不能访问它;另一方面,局限在管程中的操作过程也主要访问管程内的数据结构。由此可见,管程相当于一个隔离区,它把共享变量和对它进行操作的若干个过程围了起来,所有进程要访问临界资源时,都必须经过管程才能进入,而管程每次只允许一个进程进入管程,从而需要确保进程之间互斥。

但在管程中仅仅有互斥操作是不够用的。进程可能需要等待某个条件Cond为真才能继续执行。如果采用忙等(busy waiting)方式:

1
while not( Cond ) do {}

在单处理器情况下,将会导致所有其它进程都无法进入临界区使得该条件Cond为真,该管程的执行将会发生死锁。为此,可引入条件变量(Condition Variables,简称CV)。一个条件变量CV可理解为一个进程的等待队列,队列中的进程正等待某个条件Cond变为真。每个条件变量关联着一个条件,如果条件Cond不为真,则进程需要等待,如果条件Cond为真,则进程可以进一步在管程中执行。需要注意当一个进程等待一个条件变量CV(即等待Cond为真),该进程需要退出管程,这样才能让其它进程可以进入该管程执行,并进行相关操作,比如设置条件Cond为真,改变条件变量的状态,并唤醒等待在此条件变量CV上的进程。因此对条件变量CV有两种主要操作:

  • wait_cv: 被一个进程调用,以等待断言Pc被满足后该进程可恢复执行. 进程挂在该条件变量上等待时,不被认为是占用了管程。
  • signal_cv:被一个进程调用,以指出断言Pc现在为真,从而可以唤醒等待断言Pc被满足的进程继续执行。

“哲学家就餐”实例
有了互斥和信号量支持的管程就可用用了解决各种同步互斥问题。“用管程解决哲学家就餐问题”如下:

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
monitor dp
{
enum {THINKING, HUNGRY, EATING} state[5];
condition self[5];

void pickup(int i) {
state[i] = HUNGRY;
test(i);
if (state[i] != EATING)
self[i].wait_cv();
}

void putdown(int i) {
state[i] = THINKING;
test((i + 4) % 5);
test((i + 1) % 5);
}

void test(int i) {
if ((state[(i + 4) % 5] != EATING) &&
(state[i] == HUNGRY) &&
(state[(i + 1) % 5] != EATING)) {
state[i] = EATING;
self[i].signal_cv();
}
}

initialization code() {
for (int i = 0; i < 5; i++)
state[i] = THINKING;
}
}

关键数据结构

虽然大部分教科书上说明管程适合在语言级实现比如java等高级语言,没有提及在采用C语言的OS中如何实现。下面我们将要尝试在ucore中用C语言实现采用基于互斥和条件变量机制的管程基本原理。
ucore中的管程机制是基于信号量和条件变量来实现的。ucore中的管程的数据结构monitor_t定义如下:

1
2
3
4
5
6
7
8
9
typedef struct monitor{
semaphore_t mutex; // the mutex lock for going into the routines in monitor, should be initialized to 1
// the next semaphore is used to
// (1) procs which call cond_signal funciton should DOWN next sema after UP cv.sema
// OR (2) procs which call cond_wait funciton should UP next sema before DOWN cv.sema
semaphore_t next;
int next_count; // the number of of sleeped procs which cond_signal funciton
condvar_t *cv; // the condvars in monitor
} monitor_t;

管程中的成员变量mutex是一个二值信号量,是实现每次只允许一个进程进入管程的关键元素,确保了互斥访问性质。管程中的条件变量cv通过执行wait_cv,会使得等待某个条件Cond为真的进程能够离开管程并睡眠,且让其他进程进入管程继续执行;而进入管程的某进程设置条件Cond为真并执行signal_cv时,能够让等待某个条件Cond为真的睡眠进程被唤醒,从而继续进入管程中执行。

注意:管程中的成员变量信号量next和整型变量next_count是配合进程对条件变量cv的操作而设置的,这是由于发出signal_cv的进程A会唤醒由于wait_cv而睡眠的进程B,由于管程中只允许一个进程运行,所以进程B执行会导致唤醒进程B的进程A睡眠,直到进程B离开管程,进程A才能继续执行,这个同步过程是通过信号量next完成的;而next_count表示了由于发出singal_cv而睡眠的进程个数。
管程中的条件变量的数据结构condvar_t定义如下:

1
2
3
4
5
typedef struct condvar{
semaphore_t sem; // the sem semaphore is used to down the waiting proc, and the signaling proc should up the waiting proc
int count;   // the number of waiters on condvar
monitor_t * owner; // the owner(monitor) of this condvar
} condvar_t;

条件变量的定义中也包含了一系列的成员变量,信号量sem用于让发出wait_cv操作的等待某个条件Cond为真的进程睡眠,而让发出signal_cv操作的进程通过这个sem来唤醒睡眠的进程。count表示等在这个条件变量上的睡眠进程的个数。owner表示此条件变量的宿主是哪个管程。

条件变量的signal和wait的设计

理解了数据结构的含义后,我们就可以开始管程的设计实现了。ucore设计实现了条件变量wait_cv操作和signal_cv操作对应的具体函数,即cond_wait函数和cond_signal函数,此外还有cond_init初始化函数。

首先来看wait_cv的原理实现:

1
2
3
4
5
6
7
cv.count++;
if(monitor.next_count > 0)
sem_signal(monitor.next);
else
sem_signal(monitor.mutex);
sem_wait(cv.sem);
cv.count -- ;

对照着可分析出cond_wait函数的具体执行过程。可以看出如果进程A执行了cond_wait函数,表示此进程等待某个条件Cond不为真,需要睡眠。因此表示等待此条件的睡眠进程个数cv.count要加一。接下来会出现两种情况。

情况一:如果monitor.next_count如果大于0,表示有大于等于1个进程执行cond_signal函数且睡了,就睡在了monitor.next信号量上(假定这些进程挂在monitor.next信号量相关的等待队列S上),因此需要唤醒等待队列S中的一个进程B;然后进程A睡在cv.sem上。如果进程A醒了,则让cv.count减一,表示等待此条件变量的睡眠进程个数少了一个,可继续执行了!

这里隐含这一个现象,即某进程A在时间顺序上先执行了cond_signal,而另一个进程B后执行了cond_wait,这会导致进程A没有起到唤醒进程B的作用。

问题: 在cond_wait有sem_signal(mutex),但没有看到哪里有sem_wait(mutex),这好像没有成对出现,是否是错误的? 答案:其实在管程中的每一个函数的入口处会有wait(mutex),这样二者就配好对了。

情况二:如果monitor.next_count如果小于等于0,表示目前没有进程执行cond_signal函数且睡着了,那需要唤醒的是由于互斥条件限制而无法进入管程的进程,所以要唤醒睡在monitor.mutex上的进程。然后进程A睡在cv.sem上,如果睡醒了,则让cv.count减一,表示等待此条件的睡眠进程个数少了一个,可继续执行了!
然后来看signal_cv的原理实现:

1
2
3
4
5
6
if( cv.count > 0) {
monitor.next_count ++;
sem_signal(cv.sem);
sem_wait(monitor.next);
monitor.next_count -- ;
}

对照着可分析出cond_signal函数的具体执行过程。首先进程B判断cv.count,如果不大于0,则表示当前没有执行cond_wait而睡眠的进程,因此就没有被唤醒的对象了,直接函数返回即可;如果大于0,这表示当前有执行cond_wait而睡眠的进程A,因此需要唤醒等待在cv.sem上睡眠的进程A。由于只允许一个进程在管程中执行,所以一旦进程B唤醒了别人(进程A),那么自己就需要睡眠。故让monitor.next_count加一,且让自己(进程B)睡在信号量monitor.next上。如果睡醒了,这让monitor.next_count减一。

管程中函数的入口出口设计

为了让整个管程正常运行,还需在管程中的每个函数的入口和出口增加相关操作,即:

1
2
3
4
5
6
7
8
9
10
11
function_in_monitor (…)
{
sem.wait(monitor.mutex);
//-----------------------------
the real body of function;
//-----------------------------
if(monitor.next_count > 0)
sem_signal(monitor.next);
else
sem_signal(monitor.mutex);
}

这样带来的作用有两个,(1)只有一个进程在执行管程中的函数。(2)避免由于执行了cond_signal函数而睡眠的进程无法被唤醒。对于第二点,如果进程A由于执行了cond_signal函数而睡眠(这会让monitor.next_count大于0,且执行sem_wait(monitor.next)),则其他进程在执行管程中的函数的出口,会判断monitor.next_count是否大于0,如果大于0,则执行sem_signal(monitor.next),从而执行了cond_signal函数而睡眠的进程被唤醒。上诉措施将使得管程正常执行。

练习1:理解内核级信号量的实现和基于内核级信号量的哲学家就餐问题

首先把trap.c中处理时钟中断的时候调用的sched_class_proc_tick函数替换为run_timer_list函数(后者中已经包括了前者),用于支持定时器机制;

在sem.c定义了内核级信号量机制的函数,先来学习这个文件。sem.h中是定义,这个semphore_t结构体就是信号量的定义了。里边有一个value和一个队列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#ifndef __KERN_SYNC_SEM_H__
#define __KERN_SYNC_SEM_H__

#include <defs.h>
#include <atomic.h>
#include <wait.h>

typedef struct {
int value;
wait_queue_t wait_queue;
} semaphore_t;

void sem_init(semaphore_t *sem, int value);
void up(semaphore_t *sem);
void down(semaphore_t *sem);
bool try_down(semaphore_t *sem);

#endif /* !__KERN_SYNC_SEM_H__ */

sem_init对信号量进行初始化,信号量包括了一个整型数值变量和一个等待队列,该函数将该变量设置为指定的初始值(有几个资源),并且将等待队列初始化即可;wait_queue_init是把这个队列初始化。
1
2
3
4
5
6
7
8
9
10
void
sem_init(semaphore_t *sem, int value) {
sem->value = value;
wait_queue_init(&(sem->wait_queue));
}

void
wait_queue_init(wait_queue_t *queue) {
list_init(&(queue->wait_head));
}

__up: 这个函数是释放一个该信号量对应的资源,如果它的等待队列中没有等待的请求,则直接把资源数加一,返回即可;如果在等待队列上有等在这个信号量上的进程,则调用wakeup_wait将其唤醒执行;在函数中禁用了中断,保证了操作的原子性,函数中操作的具体流程为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static __noinline void __up(semaphore_t *sem, uint32_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
{
wait_t *wait;
//查询等待队列是否为空
if ((wait = wait_queue_first(&(sem->wait_queue))) == NULL) {
sem->value ++;
//如果是空的话,没有等待的线程,给整型变量加1;
}
else {
//如果等待队列非空,有等待的线程,取出其中的一个进程唤醒;
assert(wait->proc->wait_state == wait_state);
wakeup_wait(&(sem->wait_queue), wait, wait_state, 1);
//这个函数找到等待的线程并唤醒
}
}
local_intr_restore(intr_flag);
}

__down: 是原理课中的P操作,表示请求一个该信号量对应的资源,同样禁用中断,保证原子性。首先查询整型变量看是否大于0,如果大于0则表示存在可分配的资源,整型变量减1,直接返回;如果整型变量小于等于0,表示没有可用的资源,那么当前进程的需求得不到满足,因此在wait_current_set中将其状态改为SLEEPING态,然后调用wait_queue_add将其挂到对应信号量的等待队列中,调用schedule函数进行调度,让出CPU,在资源得到满足,重新被唤醒之后,将自身从等待队列上删除掉;

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
static __noinline uint32_t __down(semaphore_t *sem, uint32_t wait_state) {
bool intr_flag;
local_intr_save(intr_flag);
if (sem->value > 0) {
sem->value --;
local_intr_restore(intr_flag);
return 0;
}
wait_t __wait, *wait = &__wait;
wait_current_set(&(sem->wait_queue), wait, wait_state);
// 挂起这个等待线程并加入等待队列
local_intr_restore(intr_flag);

schedule();

local_intr_save(intr_flag);
wait_current_del(&(sem->wait_queue), wait);
local_intr_restore(intr_flag);
// 有可能当前线程被唤醒的原因跟之前等待的原因不一致
// 要把原因返回,由高层判断是否是合理状态。
if (wait->wakeup_flags != wait_state) {
return wait->wakeup_flags;
}
return 0;
}

void
wait_current_set(wait_queue_t *queue, wait_t *wait, uint32_t wait_state) {
assert(current != NULL);
wait_init(wait, current);
current->state = PROC_SLEEPING;
current->wait_state = wait_state;
wait_queue_add(queue, wait);
}

try_down: 简化版的P操作,如果资源数大于0则分配,资源数小于0也不进入等待队列,即使获取资源失败也不会堵塞当前进程;
1
2
3
4
5
6
7
8
9
bool try_down(semaphore_t *sem) {
bool intr_flag, ret = 0;
local_intr_save(intr_flag);
if (sem->value > 0) {
sem->value --, ret = 1;
}
local_intr_restore(intr_flag);
return ret;
}

请在实验报告中给出给用户态进程/线程提供信号量机制的设计方案,并比较说明给内核级提供信号量机制的异同。

用于保证操作原子性的禁用中断机制、以及CPU提供的Test and Set指令机制都只能在用户态下运行,为了方便起见,可以将信号量机制的实现放在OS中来提供,然后使用系统调用的方法统一提供出若干个管理信号量的系统调用,分别如下所示:

  • 申请创建一个信号量的系统调用,可以指定初始值,返回一个信号量描述符(类似文件描述符);
  • 将指定信号量执行P操作;
  • 将指定信号量执行V操作;
  • 将指定信号量释放掉;

给内核级线程提供信号量机制和给用户态进程/线程提供信号量机制的异同点在于:

相同点:
提供信号量机制的代码实现逻辑是相同的;
不同点:
由于实现原子操作的中断禁用、Test and Set指令等均需要在内核态下运行,因此提供给用户态进程的信号量机制是通过系统调用来实现的,而内核级线程只需要直接调用相应的函数就可以了;

练习2: 完成内核级条件变量和基于内核级条件变量的哲学家就餐问题

首先掌握管程机制,然后基于信号量实现完成条件变量实现,然后用管程机制实现哲学家就餐问题的解决方案(基于条件变量)。

In [OS CONCEPT] 7.7 section, the accurate define and approximate implementation of MONITOR was introduced.

通常,管程是一种语言结构,编译器通常会强制执行互斥。 将其与信号量进行比较,信号量通常是OS构造。

  • DEFNIE & CHARACTERISTIC:
  • 管程是组合在一起的过程、变量和数据结构的集合。
  • 进程可以调用监视程序但无法访问内部数据结构。
  • 管程中一次只能有一个进程处于活动状态。
  • 条件变量允许阻塞和解除阻塞。
    • cv.wait() 阻塞一个进程
      • 该过程等待条件变量cv。
    • cv.signal() (也视为 cv.notify) 解除一个等待条件变量cv的进程的阻塞状态。
      发生这种情况时,我们仍然需要在管程中只有一个进程处于活动状态。 这可以通过以下几种方式完成:
      • 在某些系统上,旧进程(执行信号的进程)离开管程,新进程进入
      • 在某些系统上,信号必须是管程内执行的最后一个语句。
      • 在某些系统上,旧进程将阻塞,直到管程再次可用。
      • 在某些系统上,新进程(未被信号阻止的进程)将保持阻塞状态,直到管程再次可用。
  • 如果在没有人等待的情况下发出条件变量信号,则信号丢失。 将此与信号量进行比较,其中信号将允许将来执行等待的进程无阻塞。
  • 不应该将条件变量视为传统意义上的变量。
  • 它没有价值。
  • 将其视为OOP意义上的对象。
  • 它有两种方法,wait和signal来操纵调用过程。
  • 定义如下,mutex保证对操作的互斥访问,这些访问主要是对共享变量的访问,所以需要互斥;cv是条件变量。
1
2
3
4
5
6
7
8
monitor mt {
----------------variable------------------
semaphore mutex;
semaphore next;
int next_count;
condvar {int count, sempahore sem} cv[N];
other variables in mt;
}

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct condvar{
semaphore_t sem; // the sem semaphore is used to down the waiting proc,
// and the signaling proc should up the waiting proc
int count; // the number of waiters on condvar
monitor_t * owner; // the owner(monitor) of this condvar
} condvar_t;

typedef struct monitor{
semaphore_t mutex; // the mutex lock for going into the routines in monitor, should be initialized to 1 semaphore_t next; // the next semaphore is used to down the signaling proc itself,
// and the other OR wakeuped waiting proc should wake up the sleeped signaling proc.
int next_count; // the number of of sleeped signaling proc
condvar_t *cv; // the condvars in monitor
} monitor_t;

这是一个管程里的操作,首先在操作开始和结束有wait和signal,保证对中间的访问是互斥的,条件不满足则执行wait执行等待。特殊信号量next和后边的if-else是有对应关系的。

1
2
3
4
5
6
7
8
9
10
11
--------routines in monitor---------------
routineA_in_mt () {
wait(mt.mutex);
...
real body of routineA
...
if(next_count>0)
signal(mt.next);
else
signal(mt.mutex);
}

条件变量是管程的重要组成部分。
cond_wait: 一个条件得不到满足,则睡眠,如果这个条件得到满足,则另一个进程调用signal唤醒这个进程。该函数的功能为将当前进程等待在指定信号量上。等待队列的计数加1,然后释放管程的锁或者唤醒一个next上的进程来释放锁(否则会造成管程被锁死无法继续访问,同时这个操作不能和前面的等待队列计数加1的操作互换顺序,要不不能保证共享变量访问的互斥性),然后把自己等在条件变量的等待队列上,直到有signal信号将其唤醒,正常退出函数;

1
2
3
4
5
6
7
8
9
10
--------condvar wait/signal---------------
cond_wait (cv) {
cv.count ++;
if(mt.next_count>0)
signal(mt.next)
else
signal(mt.mutex);
wait(cv.sem);//由于条件不满足,则wait,这里时cv的sem
cv.count --;
}

实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Suspend calling thread on a condition variable waiting for condition Atomically unlocks
// mutex and suspends calling thread on conditional variable after waking up locks mutex. Notice: mp is mutex semaphore for monitor's procedures
void
cond_wait (condvar_t *cvp) {
//LAB7 EXERCISE1: YOUR CODE
cprintf("cond_wait begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner
->next_count);
cvp->count ++;
if (cvp->owner->next_count > 0) {
up(&cvp->owner->next);
} else {
up(&cvp->owner->mutex);
}
down(&cvp->sem);
cvp->count --;
cprintf("cond_wait end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->
next_count);
}

cond_signal: 将指定条件变量上等待队列中的一个线程进行唤醒,并且将控制权转交给这个进程。判断当前的条件变量的等待队列是否大于0,即队列上是否有正在等待的进程,如果没有则不需要进行任何操作;如果有正在等待的进程,则将其中的一个唤醒,这里的等待队列是使用了一个信号量来进行实现的,由于信号量中已经包括了对等待队列的操作,因此要进行唤醒只需要对信号量执行up操作即可;接下来当前进程为了将控制权转交给被唤醒的进程,将自己等待到了这个条件变量所述的管程的next信号量上,这样的话就可以切换到被唤醒的进程。

有线程处于等待时,它的cv.count大于0,会有进一步的操作,唤醒其他进程,自身处于睡眠状态。上边的wait如果A进程中monitor.next_count大于0,那么可以唤醒monitor.next,正好与这里的wait对应。

如果cv.count大于0,有线程正在等待,把线程A从等待队列中移除,并唤醒线程A。在A的real_body之后的那个signal是唤醒B的实际函数。这里的next_count是发出条件变量signal的线程的个数。当B发出了条件变量signal操作,且把自身置成睡眠状态,使得被唤醒的A有机会在它自己退出的时候唤醒B。这是因为A和B都是在管程中执行的函数,都会涉及到对共享变量的访问,但是只允许一个进程对共享变量访问,保证互斥!

1
2
3
4
5
6
7
8
cond_signal(cv) {
if(cv.count>0) {
mt.next_count ++;
signal(cv.sem);
wait(mt.next);
mt.next_count--;
}
}

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Unlock one of threads waiting on the condition variable.
void
cond_signal (condvar_t *cvp) {
//LAB7 EXERCISE1: YOUR CODE
cprintf("cond_signal begin: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner
->next_count);
if(cvp->count>0) {
cvp->owner->next_count ++;
up(&cvp->sem);
down(&cvp->owner->next);
cvp->owner->next_count --;
}
cprintf("cond_signal end: cvp %x, cvp->count %d, cvp->owner->next_count %d\n", cvp, cvp->count, cvp->owner->
next_count);
}

哲学家就餐问题:
phi_take_forks_condvar表示指定的哲学家尝试获得自己所需要进餐的两把叉子,如果不能获得则阻塞。首先给管程上锁,将哲学家的状态修改为HUNGER,判断当前哲学家是否可以获得足够的资源进行就餐,即判断与之相邻的哲学家是否正在进餐;如果能够进餐,将自己的状态修改成EATING,然后释放锁,离开管程即可;如果不能进餐,等待在自己对应的条件变量上,等待相邻的哲学家释放资源的时候将自己唤醒;
最终具体的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

void phi_take_forks_condvar(int i) {
//--------into routine in monitor--------------
// LAB7 EXERCISE1: YOUR CODE
// I am hungry
// try to get fork
//--------leave routine in monitor--------------
down(&(mtp->mutex));
state_condvar[i]=HUNGRY;
if(state_condvar[(i+4)%5]!=EATING && state_condvar[(i+1)%5]!=EATING){
state_condvar[i]=EATING;
}
else
{
cprintf("phi_take_forks_condvar: %d didn’t get fork and will wait\n", i);
cond_wait(mtp->cv + i);
}

if(mtp->next_count>0)
up(&(mtp->next));
else
up(&(mtp->mutex));
}

phi_put_forks_condvar函数则是释放当前哲学家占用的叉子,并且唤醒相邻的因为得不到资源而进入等待的哲学家。首先获取管程的锁,将自己的状态修改成THINKING,检查相邻的哲学家是否在自己释放了叉子的占用之后满足了进餐的条件,如果满足,将其从等待中唤醒(使用cond_signal);释放锁,离开管程;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void phi_put_forks_condvar(int i) {
//--------into routine in monitor--------------
// LAB7 EXERCISE1: YOUR CODE
// I ate over
// test left and right neighbors
//--------leave routine in monitor--------------
down(&(mtp->mutex));
state_condvar[i] = THINKING;
cprintf("phi_put_forks_condvar: %d finished eating\n", i);
phi_test_condvar((i + N - 1) % N);
phi_test_condvar((i + 1) % N);
if(mtp->next_count>0)
up(&(mtp->next));
else
up(&(mtp->mutex));
}

phi_test_sema检查了第i个哲学家左右两边的人是不是处于EATING状态,如果都不是的话,而且第i个人又是HUNGRY的,则唤醒第i个。
1
2
3
4
5
6
7
8
9
10
11
12
#define LEFT (i-1+N)%N /* i的左邻号码 */
#define RIGHT (i+1)%N /* i的右邻号码 */
void phi_test_sema(i) /* i:哲学家号码从0到N-1 */
{
if(state_sema[i]==HUNGRY&&state_sema[LEFT]!=EATING
&&state_sema[RIGHT]!=EATING)
{
state_sema[i]=EATING;
up(&s[i]);
}
}


请在实验报告中给出给用户态进程/线程提供条件变量机制的设计方案,并比较说明给内核级 提供条件变量机制的异同。

本实验中管程的实现中互斥访问的保证是完全基于信号量的,如果根据上文中的说明使用系统调用实现用户态的信号量的实现机制,那么就可以按照相同的逻辑在用户态实现管程机制和条件变量机制;

实验八

实验目的

通过完成本次实验,希望能达到以下目标:

  • 了解基本的文件系统系统调用的实现方法;
  • 了解一个基于索引节点组织方式的Simple FS文件系统的设计与实现;
  • 了解文件系统抽象层-VFS的设计与实现;

实验内容

本次实验涉及的是文件系统,通过分析了解ucore文件系统的总体架构设计,完善读写文件操作,从新实现基于文件系统的执行程序机制(即改写do_execve),从而可以完成执行存储在磁盘上的文件和实现文件读写等功能。

文件系统设计与实现

ucore 文件系统总体介绍

UNIX提出了四个文件系统抽象概念:文件(file)、目录项(dentry)、索引节点(inode)和安装点(mount point)

  • 文件:文件中的内容可理解为是一有序字节,文件有一个方便应用程序识别的文件名称(也称文件路径名)。典型的文件操作有读、写、创建和删除等。
  • 目录项:目录项不是目录(又称文件路径),而是目录的组成部分。在UNIX中目录被看作一种特定的文件,而目录项是文件路径中的一部分。如一个文件路径名是“/test/testfile”,则包含的目录项为:
    • 根目录“/”,
    • 目录“test”和文件“testfile”
    • 这三个都是目录项。
    • 一般而言,目录项包含目录项的名字(文件名或目录名)和目录项的索引节点位置。
  • 索引节点:UNIX将文件的相关元数据信息(如访问控制权限、大小、拥有者、创建时间、数据内容等等信息)存储在一个单独的数据结构中,该结构被称为索引节点。
  • 安装点:在UNIX中,文件系统被安装在一个特定的文件路径位置,这个位置就是安装点。所有的已安装文件系统都作为根文件系统树中的叶子出现在系统中。

ucore模仿了UNIX的文件系统设计,ucore的文件系统架构主要由四部分组成:

  • 通用文件系统访问接口层:该层提供了一个从用户空间到文件系统的标准访问接口。这一层访问接口让应用程序能够通过一个简单的接口获得ucore内核的文件系统服务。
  • 文件系统抽象层:向上提供一个一致的接口给内核其他部分(文件系统相关的系统调用实现模块和其他内核功能模块)访问。向下提供一个同样的抽象函数指针列表和数据结构屏蔽不同文件系统的实现细节。
  • Simple FS文件系统层:一个基于索引方式的简单文件系统实例。向上通过各种具体函数实现以对应文件系统抽象层提出的抽象函数。向下访问外设接口
  • 外设接口层:向上提供device访问接口屏蔽不同硬件细节。向下实现访问各种具体设备驱动的接口,比如disk设备接口/串口设备接口/键盘设备接口等。

假如应用程序操作文件(打开/创建/删除/读写):

  1. 通过文件系统的通用文件系统访问接口层为用户空间提供的访问接口进入文件系统内部;
  2. 文件系统抽象层把访问请求转发给某一具体文件系统(比如SFS文件系统);
  3. 具体文件系统(Simple FS文件系统层)把应用程序的访问请求转化为对磁盘上的block的处理请求,并通过外设接口层交给磁盘驱动例程来完成具体的磁盘操作。
  • 通用文件系统访问接口
    • 文件系统相关用户库
      • write::usr/libs/file.c
    • 用户态文件系统相关系统调用访问接口
      • sys_write/sys_call::/usr/libs/syscall.c
    • 内核态文件系统相关系统调用实现
      • sys_write::/kern/syscall/syscall.c
  • 文件系统抽象层VFS
    • dir接口
    • file接口
    • inode接口
    • etc…
    • sysfile_write::kern/fs/sysfile.c
    • file_write::/kern/fs/file.c
    • vop_write::/kern/fs/vfs/inode.h
  • Simple FS文件系统实现
    • sfs的inode实现
    • sfs的外设访问接口
    • sfs_write::kern/fs/sfs/sfs_inode.c
    • sfs_wbuf::/kern/fs/sfs/sfs_io.c
  • 文件系统IO设备接口
    • device访问接口
    • stdin/stdout访问接口
    • etc…
    • dop_io::/kern/fs/devs/dev.h
    • disk0_io::/kern/fs/devs/dev_disk0.c
  • 硬盘驱动、串口驱动
    • ide_write_secs::/kern/driver/ide.c

ucore文件系统总体结构

从ucore操作系统不同的角度来看,ucore中的文件系统架构包含四类主要的数据结构, 它们分别是:

  • 超级块(SuperBlock),它主要从文件系统的全局角度描述特定文件系统的全局信息。它的作用范围是整个OS空间。
  • 索引节点(inode):它主要从文件系统的单个文件的角度描述了文件的各种属性和数据所在位置。它的作用范围是整个OS空间。
  • 目录项(dentry):它主要从文件系统的文件路径的角度描述了文件路径中的一个特定的目录项(注:一系列目录项形成目录/文件路径)。它的作用范围是整个OS空间。
    • 对于SFS而言,inode(具体为struct sfs_disk_inode)对应于物理磁盘上的具体对象,
    • dentry(具体为struct sfs_disk_entry)是一个内存实体,其中的ino成员指向对应的inode number,另外一个成员是file name(文件名).
  • 文件(file),它主要从进程的角度描述了一个进程在访问文件时需要了解的文件标识,文件读写的位置,文件引用情况等信息。它的作用范围是某一具体进程。

ucore中文件相关关键数据结构及其关系

通用文件系统访问接口

文件和目录相关用户库函数

在文件操作方面,最基本的相关函数是open、close、read、write。

  • 在读写一个文件之前,首先要用open系统调用将其打开。
    • open的第一个参数指定文件的路径名,可使用绝对路径名;
    • 第二个参数指定打开的方式,可设置为O_RDONLY、O_WRONLY、O_RDWR,分别表示只读、只写、可读可写。
    • 在打开一个文件后,就可以使用它返回的文件描述符fd对文件进行相关操作。
  • 在使用完一个文件后,还要用close系统调用把它关闭,其参数就是文件描述符fd。这样它的文件描述符就可以空出来,给别的文件使用。
  • 读写文件内容的系统调用是read和write。read系统调用有三个参数:
    • 一个指定所操作的文件描述符,一个指定读取数据的存放地址,最后一个指定读多少个字节。在C程序中调用该系统调用的方法如下:count = read(filehandle, buffer, nbytes);
    • 该系统调用会把实际读到的字节数返回给count变量。在正常情形下这个值与nbytes相等,但有时可能会小一些。例如,在读文件时碰上了文件结束符,从而提前结束此次读操作。

对于目录而言,最常用的操作是跳转到某个目录,这里对应的用户库函数是chdir。然后就需要读目录的内容了,即列出目录中的文件或目录名,这在处理上与读文件类似,即需要:通过opendir函数打开目录,通过readdir来获取目录中的文件信息,读完后还需通过closedir函数来关闭目录。由于在ucore中把目录看成是一个特殊的文件,所以opendir和closedir实际上就是调用与文件相关的open和close函数。只有readdir需要调用获取目录内容的特殊系统调用sys_getdirentry。而且这里没有写目录这一操作。在目录中增加内容其实就是在此目录中创建文件,需要用到创建文件的函数。

文件和目录访问相关系统调用

与文件相关的open、close、read、write用户库函数对应的是sys_open、sys_close、sys_read、sys_write四个系统调用接口。与目录相关的readdir用户库函数对应的是sys_getdirentry系统调用。这些系统调用函数接口将通过syscall函数来获得ucore的内核服务。当到了ucore内核后,在调用文件系统抽象层的file接口和dir接口。

文件系统抽象层 - VFS

文件系统抽象层是把不同文件系统的对外共性接口提取出来,形成一个函数指针数组,这样,通用文件系统访问接口层只需访问文件系统抽象层,而不需关心具体文件系统的实现细节和接口。

file & dir接口

file&dir接口层定义了进程在内核中直接访问的文件相关信息,这定义在file数据结构中,具体描述如下:

1
2
3
4
5
6
7
8
9
10
11
struct file {
enum {
FD_NONE, FD_INIT, FD_OPENED, FD_CLOSED,
} status; //访问文件的执行状态
bool readable; //文件是否可读
bool writable; //文件是否可写
int fd; //文件在filemap中的索引值
off_t pos; //访问文件的当前位置
struct inode *node; //该文件对应的内存inode指针
int open_count; //打开此文件的次数
};

而在kern/process/proc.h中的proc_struct结构中描述了进程访问文件的数据接口files_struct,其数据结构定义如下:
1
2
3
4
5
6
struct files_struct {
struct inode *pwd; //进程当前执行目录的内存inode指针
struct file *fd_array; //进程打开文件的数组
atomic_t files_count; //访问此文件的线程个数
semaphore_t files_sem; //确保对进程控制块中fs_struct的互斥访问
};

当创建一个进程后,该进程的files_struct将会被初始化或复制父进程的files_struct。当用户进程打开一个文件时,将从fd_array数组中取得一个空闲file项,然后会把此file的成员变量node指针指向一个代表此文件的inode的起始地址。

inode 接口

index node是位于内存的索引节点,它是VFS结构中的重要数据结构,因为它实际负责把不同文件系统的特定索引节点信息(甚至不能算是一个索引节点)统一封装起来,避免了进程直接访问具体文件系统。其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct inode {
union { //包含不同文件系统特定inode信息的union成员变量
struct device __device_info; //设备文件系统内存inode信息
struct sfs_inode __sfs_inode_info; //SFS文件系统内存inode信息
} in_info;
enum {
inode_type_device_info = 0x1234,
inode_type_sfs_inode_info,
} in_type; //此inode所属文件系统类型
atomic_t ref_count; //此inode的引用计数
atomic_t open_count; //打开此inode对应文件的个数
struct fs *in_fs; //抽象的文件系统,包含访问文件系统的函数指针
const struct inode_ops *in_ops; //抽象的inode操作,包含访问inode的函数指针
};

在inode中,有一成员变量为in_ops,这是对此inode的操作函数指针列表,其数据结构定义如下:
1
2
3
4
5
6
7
8
9
10
11
struct inode_ops {
unsigned long vop_magic;
int (*vop_open)(struct inode *node, uint32_t open_flags);
int (*vop_close)(struct inode *node);
int (*vop_read)(struct inode *node, struct iobuf *iob);
int (*vop_write)(struct inode *node, struct iobuf *iob);
int (*vop_getdirentry)(struct inode *node, struct iobuf *iob);
int (*vop_create)(struct inode *node, const char *name, bool excl, struct inode **node_store);
int (*vop_lookup)(struct inode *node, char *path, struct inode **node_store);
……
};

参照上面对SFS中的索引节点操作函数的说明,可以看出inode_ops是对常规文件、目录、设备文件所有操作的一个抽象函数表示。对于某一具体的文件系统中的文件或目录,只需实现相关的函数,就可以被用户进程访问具体的文件了,且用户进程无需了解具体文件系统的实现细节。

Simple FS 文件系统

ucore内核把所有文件都看作是字节流,任何内部逻辑结构都是专用的,由应用程序负责解释。但是ucore区分文件的物理结构。ucore目前支持如下几种类型的文件:

  • 常规文件:文件中包括的内容信息是由应用程序输入。SFS文件系统在普通文件上不强加任何内部结构,把其文件内容信息看作为字节。
  • 目录:包含一系列的entry,每个entry包含文件名和指向与之相关联的索引节点(index node)的指针。目录是按层次结构组织的。
  • 链接文件:实际上一个链接文件是一个已经存在的文件的另一个可选择的文件名。
  • 设备文件:不包含数据,但是提供了一个映射物理设备(如串口、键盘等)到一个文件名的机制。可通过设备文件访问外围设备。
  • 管道:管道是进程间通讯的一个基础设施。管道缓存了其输入端所接受的数据,以便在管道输出端读的进程能一个先进先出的方式来接受数据。

SFS文件系统中目录和常规文件具有共同的属性,而这些属性保存在索引节点中。SFS通过索引节点来管理目录和常规文件,索引节点包含操作系统所需要的关于某个文件的关键信息,比如文件的属性、访问许可权以及其它控制信息都保存在索引节点中。可以有多个文件名可指向一个索引节点。

文件系统的布局

文件系统通常保存在磁盘上。在本实验中,第三个磁盘(即disk0,前两个磁盘分别是ucore.imgswap.img)用于存放一个SFS文件系统(Simple Filesystem)。通常文件系统中,磁盘的使用是以扇区(Sector)为单位的,但是为了实现简便,SFS 中以 block (4K,与内存 page 大小相等)为基本单位。
SFS文件系统的布局如下图所示。
superblock -> root-dir inode -> freemap -> inode/file_data/dir_data_blocks

第0个块(4K)是超级块(superblock),它包含了关于文件系统的所有关键参数,当计算机被启动或文件系统被首次接触时,超级块的内容就会被装入内存。其定义如下:

1
2
3
4
5
6
struct sfs_super {
uint32_t magic; /* magic number, should be SFS_MAGIC */
uint32_t blocks; /* # of blocks in fs */
uint32_t unused_blocks; /* # of unused blocks in fs */
char info[SFS_MAX_INFO_LEN + 1]; /* infomation for sfs */
};

可以看到,包含:

  • 成员变量魔数magic,其值为0x2f8dbe2a,内核通过它来检查磁盘镜像是否是合法的 SFS img;
  • 成员变量blocks记录了SFS中所有block的数量,即 img 的大小;
  • 成员变量unused_block记录了SFS中还没有被使用的block的数量;
  • 成员变量info包含了字符串”simple file system”。

第1个块放了一个root-dir的inode,用来记录根目录的相关信息。有关inode还将在后续部分介绍。通过这个root-dir的inode信息就可以定位并查找到根目录下的所有文件信息。

从第2个块开始,根据SFS中所有块的数量,用1个bit来表示一个块的占用和未被占用的情况。这个区域称为SFS的freemap区域,这将占用若干个块空间。为了更好地记录和管理freemap区域,专门提供了两个文件kern/fs/sfs/bitmap.[ch]来完成根据一个块号查找或设置对应的bit位的值。

1
2
3
4
5
struct bitmap {
uint32_t nbits;
uint32_t nwords;
WORD_TYPE *map;
};

最后在剩余的磁盘空间中,存放了所有其他目录和文件的inode信息和内容数据信息。需要注意的是虽然inode的大小小于一个块的大小(4096B),但为了实现简单,每个 inode 都占用一个完整的 block。
在sfs_fs.c文件中的sfs_do_mount函数中,完成了加载位于硬盘上的SFS文件系统的超级块superblock和freemap的工作。这样,在内存中就有了SFS文件系统的全局信息。

在fs_init中分别调用了vfs_init()dev_init()sfs_init()sfs_init()中调用了sfs_mount("disk0")sfs_mount中调用了vfs_mount(devname, sfs_do_mount);vfs_mount()中从设备列表中找到一个名字相同的设备,这个设备的fs应该是NULL,即它是没有被挂载到某个文件系统的。找到这个设备的inode中in_info,调用传进来的mountfunc,即sfs_do_mount

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
/*
* sfs_do_mount - mount sfs file system.
*
* @dev: the block device contains sfs file system
* @fs_store: the fs struct in memroy
*/
static int
sfs_do_mount(struct device *dev, struct fs **fs_store) {
static_assert(SFS_BLKSIZE >= sizeof(struct sfs_super));
static_assert(SFS_BLKSIZE >= sizeof(struct sfs_disk_inode));
static_assert(SFS_BLKSIZE >= sizeof(struct sfs_disk_entry));

if (dev->d_blocksize != SFS_BLKSIZE) {
return -E_NA_DEV;
}

/* 分配一个fs的结构 */
struct fs *fs;
if ((fs = alloc_fs(sfs)) == NULL) {
return -E_NO_MEM;
}
/* 获取这个sfs的sfs_fs */
struct sfs_fs *sfs = fsop_info(fs, sfs);
sfs->dev = dev;

int ret = -E_NO_MEM;
void *sfs_buffer;
if ((sfs->sfs_buffer = sfs_buffer = kmalloc(SFS_BLKSIZE)) == NULL) {
goto failed_cleanup_fs;
}

/* 专门用来读超级块的 */
if ((ret = sfs_init_read(dev, SFS_BLKN_SUPER, sfs_buffer)) != 0) {
goto failed_cleanup_sfs_buffer;
}

ret = -E_INVAL;

struct sfs_super *super = sfs_buffer;
if (super->magic != SFS_MAGIC) {
// 开头一定要是魔数
cprintf("sfs: wrong magic in superblock. (%08x should be %08x).\n",
super->magic, SFS_MAGIC);
goto failed_cleanup_sfs_buffer;
}
if (super->blocks > dev->d_blocks) {
cprintf("sfs: fs has %u blocks, device has %u blocks.\n",
super->blocks, dev->d_blocks);
goto failed_cleanup_sfs_buffer;
}
super->info[SFS_MAX_INFO_LEN] = '\0';
sfs->super = *super;
ret = -E_NO_MEM;

uint32_t i;

/* alloc and initialize hash list, 用于inode */
list_entry_t *hash_list;
if ((sfs->hash_list = hash_list = kmalloc(sizeof(list_entry_t) * SFS_HLIST_SIZE)) == NULL) {
goto failed_cleanup_sfs_buffer;
}
for (i = 0; i < SFS_HLIST_SIZE; i ++) {
list_init(hash_list + i);
}

/* load and check freemap */
struct bitmap *freemap;
uint32_t freemap_size_nbits = sfs_freemap_bits(super);
if ((sfs->freemap = freemap = bitmap_create(freemap_size_nbits)) == NULL) {
goto failed_cleanup_hash_list;
}
uint32_t freemap_size_nblks = sfs_freemap_blocks(super);
if ((ret = sfs_init_freemap(dev, freemap, SFS_BLKN_FREEMAP, freemap_size_nblks, sfs_buffer)) != 0) {
goto failed_cleanup_freemap;
}

uint32_t blocks = sfs->super.blocks, unused_blocks = 0;
for (i = 0; i < freemap_size_nbits; i ++) {
if (bitmap_test(freemap, i)) {
unused_blocks ++;
}
}
assert(unused_blocks == sfs->super.unused_blocks);

/* and other fields */
sfs->super_dirty = 0;
sem_init(&(sfs->fs_sem), 1);
sem_init(&(sfs->io_sem), 1);
sem_init(&(sfs->mutex_sem), 1);
list_init(&(sfs->inode_list));
cprintf("sfs: mount: '%s' (%d/%d/%d)\n", sfs->super.info,
blocks - unused_blocks, unused_blocks, blocks);

/* link addr of sync/get_root/unmount/cleanup funciton fs's function pointers*/
fs->fs_sync = sfs_sync;
fs->fs_get_root = sfs_get_root;
fs->fs_unmount = sfs_unmount;
fs->fs_cleanup = sfs_cleanup;
*fs_store = fs;
return 0;

failed_cleanup_freemap:
bitmap_destroy(freemap);
failed_cleanup_hash_list:
kfree(hash_list);
failed_cleanup_sfs_buffer:
kfree(sfs_buffer);
failed_cleanup_fs:
kfree(fs);
return ret;
}

索引节点

在SFS文件系统中,需要记录文件内容的存储位置以及文件名与文件内容的对应关系。

  • sfs_disk_inode记录了文件或目录的内容存储的索引信息,该数据结构在硬盘里储存,需要时读入内存。
  • sfs_disk_entry表示一个目录中的一个文件或目录,包含该项所对应inode的位置和文件名,同样也在硬盘里储存,需要时读入内存。
磁盘索引节点

SFS中的磁盘索引节点代表了一个实际位于磁盘上的文件。首先我们看看在硬盘上的索引节点的内容:

1
2
3
4
5
6
7
8
struct sfs_disk_inode {
uint32_t size; 如果inode表示常规文件,则size是文件大小
uint16_t type; inode的文件类型
uint16_t nlinks; 此inode的硬链接数
uint32_t blocks; 此inode的数据块数的个数
uint32_t direct[SFS_NDIRECT]; 此inode的直接数据块索引值(有SFS_NDIRECT个)
uint32_t indirect; 此inode的一级间接数据块索引值
};

通过上表可以看出,如果inode表示的是文件,则成员变量direct[]直接指向了保存文件内容数据的数据块索引值。indirect间接指向了保存文件内容数据的数据块,indirect指向的是间接数据块(indirect_block),此数据块实际存放的全部是数据块索引,这些数据块索引指向的数据块才被用来存放文件内容数据。

默认的,ucore 里 SFS_NDIRECT 是 12,即直接索引的数据页大小为 12 4k = 48k;当使用一级间接数据块索引时,ucore 支持最大的文件大小为 12 4k + 1024 * 4k = 48k + 4m。数据索引表内,0 表示一个无效的索引,inode 里 blocks 表示该文件或者目录占用的磁盘的 block 的个数。indiret 为 0 时,表示不使用一级索引块。(因为 block 0 用来保存 super block,它不可能被其他任何文件或目录使用,所以这么设计也是合理的)。

对于普通文件,索引值指向的 block 中保存的是文件中的数据。而对于目录,索引值指向的数据保存的是目录下所有的文件名以及对应的索引节点所在的索引块(磁盘块)所形成的数组。数据结构如下:

1
2
3
4
5
/* file entry (on disk) */
struct sfs_disk_entry {
uint32_t ino; 索引节点所占数据块索引值
char name[SFS_MAX_FNAME_LEN + 1]; 文件名
};

操作系统中,每个文件系统下的 inode 都应该分配唯一的 inode 编号。SFS 下,为了实现的简便,每个 inode 直接用他所在的磁盘 block 的编号作为 inode 编号。比如,root block 的 inode 编号为 1;每个 sfs_disk_entry 数据结构中,name 表示目录下文件或文件夹的名称,ino 表示磁盘 block 编号,通过读取该 block 的数据,能够得到相应的文件或文件夹的 inode。ino 为0时,表示一个无效的 entry。
此外,和 inode 相似,每个 sfs_dirent_entry 也占用一个 block。

内存中的索引节点

1
2
3
4
5
6
7
8
9
10
11
/* inode for sfs */
struct sfs_inode {
struct sfs_disk_inode *din; /* on-disk inode */
uint32_t ino; /* inode number */
uint32_t flags; /* inode flags */
bool dirty; /* true if inode modified */
int reclaim_count; /* kill inode if it hits zero */
semaphore_t sem; /* semaphore for din */
list_entry_t inode_link; /* entry for linked-list in sfs_fs */
list_entry_t hash_link; /* entry for hash linked-list in sfs_fs */
};

可以看到SFS中的内存inode包含了SFS的硬盘inode信息,而且还增加了其他一些信息,这属于是便于进行是判断否改写、互斥操作、回收和快速地定位等作用。需要注意,一个内存inode是在打开一个文件后才创建的,如果关机则相关信息都会消失。而硬盘inode的内容是保存在硬盘中的,只是在进程需要时才被读入到内存中,用于访问文件或目录的具体内容数据

为了方便实现上面提到的多级数据的访问以及目录中 entry 的操作,对 inode SFS实现了一些辅助的函数:

  • sfs_bmap_load_nolock:将对应 sfs_inode 的第 index 个索引指向的 block 的索引值取出存到相应的指针指向的单元(ino_store)。该函数只接受 index <= inode->blocks 的参数。当 index == inode->blocks 时,该函数理解为需要为 inode 增长一个 block。并标记 inode 为 dirty(所有对 inode 数据的修改都要做这样的操作,这样,当 inode 不再使用的时候,sfs 能够保证 inode 数据能够被写回到磁盘)。sfs_bmap_load_nolock 调用的 sfs_bmap_get_nolock 来完成相应的操作,阅读 sfs_bmap_get_nolock,了解他是如何工作的。(sfs_bmap_get_nolock 只由 sfs_bmap_load_nolock 调用)
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/*
* sfs_bmap_load_nolock - according to the DIR's inode and the logical index of block in inode, find the NO. of
disk block.
* @sfs: sfs file system
* @sin: sfs inode in memory
* @index: the logical index of disk block in inode
* @ino_store:the NO. of disk block
*/
static int
sfs_bmap_load_nolock(struct sfs_fs *sfs, struct sfs_inode *sin, uint32_t index, uint32_t *ino_store) {
struct sfs_disk_inode *din = sin->din;
assert(index <= din->blocks);
int ret;
uint32_t ino;
bool create = (index == din->blocks);
if ((ret = sfs_bmap_get_nolock(sfs, sin, index, create, &ino)) != 0) {
return ret;
}
assert(sfs_block_inuse(sfs, ino));
if (create) {
din->blocks ++;
}
if (ino_store != NULL) {
*ino_store = ino;
}
return 0;
}

/*
* sfs_bmap_get_nolock - according sfs_inode and index of block, find the NO. of disk block
* no lock protect
* @sfs: sfs file system
* @sin: sfs inode in memory
* @index: the index of block in inode
* @create: BOOL, if the block isn't allocated, if create = 1 the alloc a block, otherwise just do nothing
* @ino_store: 0 OR the index of already inused block or new allocated block.
*/
static int
sfs_bmap_get_nolock(struct sfs_fs *sfs, struct sfs_inode *sin, uint32_t index, bool create, uint32_t *ino_store)
{
struct sfs_disk_inode *din = sin->din;
int ret;
uint32_t ent, ino;
// the index of disk block is in the fist SFS_NDIRECT direct blocks
if (index < SFS_NDIRECT) {
if ((ino = din->direct[index]) == 0 && create) {
if ((ret = sfs_block_alloc(sfs, &ino)) != 0) {
return ret;
}
din->direct[index] = ino;
sin->dirty = 1;
}
goto out;
}
// the index of disk block is in the indirect blocks.
index -= SFS_NDIRECT;
if (index < SFS_BLK_NENTRY) {
ent = din->indirect;
if ((ret = sfs_bmap_get_sub_nolock(sfs, &ent, index, create, &ino)) != 0) {
return ret;
}
if (ent != din->indirect) {
assert(din->indirect == 0);
din->indirect = ent;
sin->dirty = 1;
}
goto out;
} else {
panic ("sfs_bmap_get_nolock - index out of range");
}
out:
assert(ino == 0 || sfs_block_inuse(sfs, ino));
*ino_store = ino;
return 0;
}
  • sfs_bmap_truncate_nolock:将多级数据索引表的最后一个 entry 释放掉。他可以认为是 sfs_bmap_load_nolock 中,index == inode->blocks 的逆操作。当一个文件或目录被删除时,sfs 会循环调用该函数直到 inode->blocks 减为 0,释放所有的数据页。函数通过 sfs_bmap_free_nolock 来实现,他应该是 sfs_bmap_get_nolock 的逆操作。和 sfs_bmap_get_nolock 一样,调用 sfs_bmap_free_nolock 也要格外小心。
  • sfs_dirent_read_nolock:将目录的第 slot 个 entry 读取到指定的内存空间。他通过上面提到的函数来完成。
  • sfs_dirent_search_nolock:是常用的查找函数。他在目录下查找 name,并且返回相应的搜索结果(文件或文件夹)的 inode 的编号(也是磁盘编号),和相应的 entry 在该目录的 index 编号以及目录下的数据页是否有空闲的 entry。(SFS 实现里文件的数据页是连续的,不存在任何空洞;而对于目录,数据页不是连续的,当某个 entry 删除的时候,SFS 通过设置 entry->ino 为0将该 entry 所在的 block 标记为 free,在需要添加新 entry 的时候,SFS 优先使用这些 free 的 entry,其次才会去在数据页尾追加新的 entry。

注意,这些后缀为 nolock 的函数,只能在已经获得相应 inode 的semaphore才能调用。

inode的文件操作函数

1
2
3
4
5
6
7
8
static const struct inode_ops sfs_node_fileops = {
.vop_magic = VOP_MAGIC,
.vop_open = sfs_openfile,
.vop_close = sfs_close,
.vop_read = sfs_read,
.vop_write = sfs_write,
……
};

上述sfs_openfile、sfs_close、sfs_read和sfs_write分别对应用户进程发出的open、close、read、write操作。其中sfs_openfile不用做什么事;sfs_close需要把对文件的修改内容写回到硬盘上,这样确保硬盘上的文件内容数据是最新的;sfs_read和sfs_write函数都调用了一个函数sfs_io,并最终通过访问硬盘驱动来完成对文件内容数据的读写。

inode的目录操作函数
1
2
3
4
5
6
7
8
static const struct inode_ops sfs_node_dirops = {
.vop_magic = VOP_MAGIC,
.vop_open = sfs_opendir,
.vop_close = sfs_close,
.vop_getdirentry = sfs_getdirentry,
.vop_lookup = sfs_lookup,
……
};

对于目录操作而言,由于目录也是一种文件,所以sfs_opendir、sys_close对应户进程发出的open、close函数。相对于sfs_open,sfs_opendir只是完成一些open函数传递的参数判断,没做其他更多的事情。目录的close操作与文件的close操作完全一致。由于目录的内容数据与文件的内容数据不同,所以读出目录的内容数据的函数是sfs_getdirentry,其主要工作是获取目录下的文件inode信息。

设备层文件 IO 层

在本实验中,为了统一地访问设备,我们可以把一个设备看成一个文件,通过访问文件的接口来访问设备。目前实现了stdin设备文件文件、stdout设备文件、disk0设备。stdin设备就是键盘,stdout设备就是CONSOLE(串口、并口和文本显示器),而disk0设备是承载SFS文件系统的磁盘设备。下面我们逐一分析ucore是如何让用户把设备看成文件来访问。

关键数据结构

为了表示一个设备,需要有对应的数据结构,ucore为此定义了struct device,其描述如下:

1
2
3
4
5
6
7
8
struct device {
size_t d_blocks; //设备占用的数据块个数
size_t d_blocksize; //数据块的大小
int (*d_open)(struct device *dev, uint32_t open_flags); //打开设备的函数指针
int (*d_close)(struct device *dev); //关闭设备的函数指针
int (*d_io)(struct device *dev, struct iobuf *iob, bool write); //读写设备的函数指针
int (*d_ioctl)(struct device *dev, int op, void *data); //用ioctl方式控制设备的函数指针
};

这个数据结构能够支持对块设备(比如磁盘)、字符设备(比如键盘、串口)的表示,完成对设备的基本操作。ucore虚拟文件系统为了把这些设备链接在一起,还定义了一个设备链表,即双向链表vdev_list,这样通过访问此链表,可以找到ucore能够访问的所有设备文件。

但这个设备描述没有与文件系统以及表示一个文件的inode数据结构建立关系,为此,还需要另外一个数据结构把device和inode联通起来,这就
是vfs_dev_t数据结构:

1
2
3
4
5
6
7
8
// device info entry in vdev_list 
typedef struct {
const char *devname;
struct inode *devnode;
struct fs *fs;
bool mountable;
list_entry_t vdev_link;
} vfs_dev_t;

利用vfs_dev_t数据结构,就可以让文件系统通过一个链接vfs_dev_t结构的双向链表找到device对应的inode数据结构,一个inode节点的成员变量in_type的值是0x1234,则此 inode的成员变量in_info将成为一个device结构。这样inode就和一个设备建立了联系,这个inode就是一个设备文件。

stdout设备文件

初始化

既然stdout设备是设备文件系统的文件,自然有自己的inode结构。在系统初始化时,即只需如下处理过程

1
2
3
4
5
6
7
kern_init ——>
fs_init ——>
dev_init ——>
dev_init_stdout ——>
dev_create_inode ——>
stdout_device_init ——>
vfs_add_dev

在dev_init_stdout中完成了对stdout设备文件的初始化。即首先创建了一个inode,然后通过stdout_device_init完成对inode中的成员变量inode->__device_info进行初始:
这里的stdout设备文件实际上就是指的console外设(它其实是串口、并口和CGA的组合型外设)。这个设备文件是一个只写设备,如果读这个设备,就会出错。接下来我们看看stdout设备的相关处理过程。

初始化

stdout设备文件的初始化过程主要由stdout_device_init完成,其具体实现如下:

1
2
3
4
5
6
7
8
9
static void
stdout_device_init(struct device *dev) {
dev->d_blocks = 0;
dev->d_blocksize = 1;
dev->d_open = stdout_open;
dev->d_close = stdout_close;
dev->d_io = stdout_io;
dev->d_ioctl = stdout_ioctl;
}

可以看到,stdout_open函数完成设备文件打开工作,如果发现用户进程调用open函数的参数flags不是只写(O_WRONLY),则会报错。
1
2
3
4
5
6
7
static int
stdout_open(struct device *dev, uint32_t open_flags) {
if (open_flags != O_WRONLY) {
return -E_INVAL;
}
return 0;
}

访问操作实现

stdout_io函数完成设备的写操作工作,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
static int
stdout_io(struct device *dev, struct iobuf *iob, bool write) {
if (write) {
char *data = iob->io_base;
for (; iob->io_resid != 0; iob->io_resid --) {
cputchar(*data ++);
}
return 0;
}
return -E_INVAL;
}

可以看到,要写的数据放在iob->io_base所指的内存区域,一直写到iob->io_resid的值为0为止。每次写操作都是通过cputchar来完成的,此函数最终将通过console外设驱动来完成把数据输出到串口、并口和CGA显示器上过程。另外,也可以注意到,如果用户想执行读操作,则stdout_io函数直接返回错误值-E_INVAL。

stdin 设备文件

这里的stdin设备文件实际上就是指的键盘。这个设备文件是一个只读设备,如果写这个设备,就会出错。接下来我们看看stdin设备的相关处理过程。

初始化

stdin设备文件的初始化过程主要由stdin_device_init完成了主要的初始化工作,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
static void
stdin_device_init(struct device *dev) {
dev->d_blocks = 0;
dev->d_blocksize = 1;
dev->d_open = stdin_open;
dev->d_close = stdin_close;
dev->d_io = stdin_io;
dev->d_ioctl = stdin_ioctl;

p_rpos = p_wpos = 0;
wait_queue_init(wait_queue);
}

相对于stdout的初始化过程,stdin的初始化相对复杂一些,多了一个stdin_buffer缓冲区,描述缓冲区读写位置的变量p_rpos、p_wpos以及用于等待缓冲区的等待队列wait_queue。在stdin_device_init函数的初始化中,也完成了对p_rpos、p_wpos和wait_queue的初始化。

访问操作实现

stdin_io函数负责完成设备的读操作工作,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
static int
stdin_io(struct device *dev, struct iobuf *iob, bool write) {
if (!write) {
int ret;
if ((ret = dev_stdin_read(iob->io_base, iob->io_resid)) > 0) {
iob->io_resid -= ret;
}
return ret;
}
return -E_INVAL;
}

可以看到,如果是写操作,则stdin_io函数直接报错返回。所以这也进一步说明了此设备文件是只读文件。如果此读操作,则此函数进一步调用dev_stdin_read函数完成对键盘设备的读入操作。dev_stdin_read函数的实现相对复杂一些,主要的流程如下:
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
static int
dev_stdin_read(char *buf, size_t len) {
int ret = 0;
bool intr_flag;
local_intr_save(intr_flag);
{
for (; ret < len; ret ++, p_rpos ++) {
try_again:
if (p_rpos < p_wpos) {
*buf ++ = stdin_buffer[p_rpos % stdin_BUFSIZE];
}
else {
wait_t __wait, *wait = &__wait;
wait_current_set(wait_queue, wait, WT_KBD);
local_intr_restore(intr_flag);

schedule();

local_intr_save(intr_flag);
wait_current_del(wait_queue, wait);
if (wait->wakeup_flags == WT_KBD) {
goto try_again;
}
break;
}
}
}
local_intr_restore(intr_flag);
return ret;
}

在上述函数中可以看出,如果p_rpos < p_wpos,则表示有键盘输入的新字符在stdin_buffer中,于是就从stdin_buffer中取出新字符放到iobuf指向的缓冲区中;如果p_rpos >=p_wpos,则表明没有新字符,这样调用read用户态库函数的用户进程就需要采用等待队列的睡眠操作进入睡眠状态,等待键盘输入字符的产生。

当识别出中断是键盘中断(中断号为IRQ_OFFSET + IRQ_KBD)时,会调用dev_stdin_write函数,来把字符写入到stdin_buffer中,且会通过等待队列的唤醒操作唤醒正在等待键盘输入的用户进程。

实验执行流程概述

kern_init函数增加了对fs_init函数的调用。fs_init函数就是文件系统初始化的总控函数,它进一步调用了虚拟文件系统初始化函数vfs_init,与文件相关的设备初始化函数dev_init和Simple FS文件系统的初始化函数sfs_init。这三个初始化函数联合在一起,协同完成了整个虚拟文件系统、SFS文件系统和文件系统对应的设备(键盘、串口、磁盘)的初始化工作。其函数调用关系图如下所示:

vfs_init如下所示:

1
2
3
4
5
6
// vfs_init -  vfs initialize
void
vfs_init(void) {
sem_init(&bootfs_sem, 1);
vfs_devlist_init();
}

sem_init函数主要是初始化了信号量和等待队列:
1
2
3
4
5
void
sem_init(semaphore_t *sem, int value) {
sem->value = value;
wait_queue_init(&(sem->wait_queue));
}

vfs_devlist_init主要是初始化设备列表,建立了一个device list双向链表vdev_list,为后续具体设备(键盘、串口、磁盘)以文件的形式呈现建立查找访问通道
1
2
3
4
5
void
vfs_devlist_init(void) {
list_init(&vdev_list);
sem_init(&vdev_list_sem, 1);
}

dev_init函数通过进一步调用disk0/stdin/stdout_device_init完成对具体设备的初始化,把它们抽象成一个设备文件,并建立对应的inode数据结构,最后把它们链入到vdev_list中。这样通过虚拟文件系统就可以方便地以文件的形式访问这些设备了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define init_device(x)                                  \
do { \
extern void dev_init_##x(void); \
dev_init_##x(); \
} while (0)

/* dev_init - Initialization functions for builtin vfs-level devices. */
void
dev_init(void) {
// init_device(null);
init_device(stdin);
init_device(stdout);
init_device(disk0);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
void
dev_init_disk0(void) {
struct inode *node;
if ((node = dev_create_inode()) == NULL) {
panic("disk0: dev_create_node.\n");
}
disk0_device_init(vop_info(node, device));

int ret;
if ((ret = vfs_add_dev("disk0", node, 1)) != 0) {
panic("disk0: vfs_add_dev: %e.\n", ret);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
dev_init_stdin(void) {
struct inode *node;
if ((node = dev_create_inode()) == NULL) {
panic("stdin: dev_create_node.\n");
}
stdin_device_init(vop_info(node, device));

int ret;
if ((ret = vfs_add_dev("stdin", node, 0)) != 0) {
panic("stdin: vfs_add_dev: %e.\n", ret);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
void
dev_init_stdout(void) {
struct inode *node;
if ((node = dev_create_inode()) == NULL) {
panic("stdout: dev_create_node.\n");
}
stdout_device_init(vop_info(node, device));

int ret;
if ((ret = vfs_add_dev("stdout", node, 0)) != 0) {
panic("stdout: vfs_add_dev: %e.\n", ret);
}
}

sfs_init是完成对Simple FS的初始化工作,并把此实例文件系统挂在虚拟文件系统中,从而让ucore的其他部分能够通过访问虚拟文件系统的接口来进一步访问到SFS实例文件系统。
1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* sfs_init - mount sfs on disk0
*
* CALL GRAPH:
* kern_init-->fs_init-->sfs_init
*/
void
sfs_init(void) {
int ret;
if ((ret = sfs_mount("disk0")) != 0) {
panic("failed: sfs: sfs_mount: %e.\n", ret);
}
}

在sfs_init中调用了sfs_mount —> vfs_mount 进行挂载:
1
2
3
4
int
sfs_mount(const char *devname) {
return vfs_mount(devname, sfs_do_mount);
}

vfs_mount把一个文件系统挂载到系统上
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
/*
* vfs_mount - Mount a filesystem. Once we've found the device, call MOUNTFUNC to
* set up the filesystem and hand back a struct fs.
*
* The DATA argument is passed through unchanged to MOUNTFUNC.
*/
int
vfs_mount(const char *devname, int (*mountfunc)(struct device *dev, struct fs **fs_store)) {
int ret;
lock_vdev_list();
// 信号量操作
vfs_dev_t *vdev;
if ((ret = find_mount(devname, &vdev)) != 0) {
// 找一个同名设备
goto out;
}
if (vdev->fs != NULL) {
ret = -E_BUSY;
// 如果这个设备已经被挂载到一个文件系统上了,就不能被再挂载
goto out;
}
assert(vdev->devname != NULL && vdev->mountable);

struct device *dev = vop_info(vdev->devnode, device);
if ((ret = mountfunc(dev, &(vdev->fs))) == 0) {
assert(vdev->fs != NULL);
cprintf("vfs: mount %s.\n", vdev->devname);
}

out:
unlock_vdev_list();
// 解锁
return ret;
}

对于vop_info:
1
2
3
4
5
6
7
8
9
#define __vop_info(node, type)                                      \
({ \
struct inode *__node = (node); \
assert(__node != NULL && check_inode_type(__node, type)); \
&(__node->in_info.__##type##_info); \
})

#define vop_info(node, type) __vop_info(node, type)


__##type##_info是一个struct devicestruct sfs_inode的结构体,一般调用vop_info的时候都是给一个变量赋值为一个设备的结构体。

mountfunc竟然是一个参数,流批流批。。。溯源的话有sfs_do_mount作为参数,下文介绍sfs_do_mount,太多了。。。

文件操作实现

打开文件

有了上述分析后,我们可以看看如果一个用户进程打开文件会做哪些事情?首先假定用户进程需要打开的文件已经存在在硬盘上。以user/sfs_filetest1.c为例,首先用户进程会调用在main函数中的如下语句:

1
int fd1 = safe_open("sfs\_filetest1", O_RDONLY);

如果ucore能够正常查找到这个文件,就会返回一个代表文件的文件描述符fd1,这样在接下来的读写文件过程中,就直接用这样fd1来代表就可以了。

safe_open实现如下,在open中调用了sys_open,接着调用了syscall,执行系统调用:

1
2
3
4
5
6
7
static int safe_open(const char *path, int open_flags)
{
int fd = open(path, open_flags);
printf("fd is %d\n",fd);
assert(fd >= 0);
return fd;
}

通用文件访问接口层的处理流程

进一步调用如下用户态函数: open->sys_open->syscall,从而引起系统调用进入到内核态。到了内核态后,通过中断处理例程,会调用到sys_open内核函数,并进一步调用sysfile_open内核函数。到了这里,需要把位于用户空间的字符串”sfs_filetest1”拷贝到内核空间中的字符串path中,这里copy_path完成了本功能,这里不再列出。进入到文件系统抽象层的处理流程完成进一步的打开文件操作中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int
sys_open(uint32_t arg[]) {
const char *path = (const char *)arg[0];
uint32_t open_flags = (uint32_t)arg[1];
return sysfile_open(path, open_flags);
}

/* sysfile_open - open file */
int
sysfile_open(const char *__path, uint32_t open_flags) {
int ret;
char *path;
if ((ret = copy_path(&path, __path)) != 0) {
return ret;
}
ret = file_open(path, open_flags);
kfree(path);
return ret;
}

文件系统抽象层的处理流程
  • 分配一个空闲的file数据结构变量file。

    • 在文件系统抽象层的处理中,首先调用的是file_open函数,它要给这个即将打开的文件分配一个file数据结构的变量,这个变量其实是当前进程的打开文件数组current->fs_struct->filemap[]中的一个空闲元素(即还没用于一个打开的文件),而这个元素的索引值就是最终要返回到用户进程并赋值给变量fd1。到了这一步还仅仅是给当前用户进程分配了一个file数据结构的变量,还没有找到对应的文件索引节点。
  • 调用vfs_open函数来找到path指出的文件所对应的基于inode数据结构的VFS索引节点node。

    • vfs_open函数需要完成:
      • 确定读写权限;
      • 通过vfs_lookup找到path对应文件的inode;首先是调用get_device,先对路径字符串进行判断,看是不是声明了设备(有:)或者是绝对路径(有/)。如果是相对路径,调用vfs_get_curdir获得当前的路径。如果有设备名,则根据路径中的设备名在设备list中找到这个设备,返回一个inode。如果是绝对路径,则返回根目录。如果开头有个‘:’,说明是在当前文件系统中,返回的是当前目录。
      • 找到文件设备的根目录“/”的索引节点需要注意,这里的vfs_lookup函数是一个针对目录的操作函数,它会调用vop_lookup函数来找到SFS文件系统中的“/”目录下的“sfs_filetest1”文件。为此,vfs_lookup函数首先调用get_device函数,并进一步调用vfs_get_bootfs函数来找到根目录“/”对应的inode。这个inode就是位于vfs.c中的inode变量bootfs_node。这个变量在init_main函数(位于kern/process/proc.c)执行时获得了赋值。
      • 通过调用vop_lookup函数来查找到根目录“/”下对应文件sfs_filetest1的索引节点,如果找到就返回此索引节点。
    • 调用vop_open函数打开文件。
    • 调用了vop_truncate(应该是这个sfs_truncfile),调整文件大小到适当的大小(按照块个数计算)
    • 调用了vfs_fsync,如果发生了什么使得这个块变成dirty了,就调用d_io把它写进去。
  • 把file和node建立联系,设置file的读写权限,如果是append模式的话还要把file的pos设置到末尾。完成后,将返回到file_open函数中,通过执行语句“file->node=node;”,就把当前进程的current->fs_struct->filemap[fd](即file所指变量)的成员变量node指针指向了代表sfs_filetest1文件的索引节点inode。

  • 这时返回fd。经过重重回退,通过系统调用返回,用户态的syscall->sys_open->open->safe_open等用户函数的层层函数返回,最终把fd赋值给fd1。自此完成了打开文件操作。
    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
    // open file
    int
    file_open(char *path, uint32_t open_flags) {
    bool readable = 0, writable = 0;
    switch (open_flags & O_ACCMODE) {
    case O_RDONLY: readable = 1; break;
    case O_WRONLY: writable = 1; break;
    case O_RDWR:
    readable = writable = 1;
    break;
    default:
    return -E_INVAL;
    }

    int ret;
    struct file *file;
    if ((ret = fd_array_alloc(NO_FD, &file)) != 0) {
    return ret;
    }
    //分配一个file数据结构的变量

    struct inode *node;
    if ((ret = vfs_open(path, open_flags, &node)) != 0) {
    fd_array_free(file);
    return ret;
    }
    //找到path指出的文件所对应的基于inode数据结构的VFS索引节点node

    file->pos = 0;
    if (open_flags & O_APPEND) {
    struct stat __stat, *stat = &__stat;
    if ((ret = vop_fstat(node, stat)) != 0) {
    vfs_close(node);
    fd_array_free(file);
    return ret;
    }
    file->pos = stat->st_size;
    }
    // 根据open_flags找当前指针应该指在文件的什么位置

    file->node = node;
    file->readable = readable;
    file->writable = writable;
    fd_array_open(file);
    return file->fd;
    }
SFS文件系统层的处理流程

在sfs_inode.c中的sfs_node_dirops变量定义了“.vop_lookup = sfs_lookup”,所以我们重点分析sfs_lookup的实现。

sfs_lookup有三个参数:node,path,node_store。其中node是根目录“/”所对应的inode节点;path是文件sfs_filetest1的绝对路径/sfs_filetest1,而node_store是经过查找获得的sfs_filetest1所对应的inode节点。
sfs_lookup函数以“/”为分割符,从左至右逐一分解path获得各个子目录和最终文件对应的inode节点。在本例中是调用sfs_lookup_once查找以根目录下的文件sfs_filetest1所对应的inode节点。当无法分解path后,就意味着找到了sfs_filetest1对应的inode节点,就可顺利返回了。

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
/*
* sfs_lookup - Parse path relative to the passed directory
* DIR, and hand back the inode for the file it
* refers to.
*/
static int
sfs_lookup(struct inode *node, char *path, struct inode **node_store) {
struct sfs_fs *sfs = fsop_info(vop_fs(node), sfs);
assert(*path != '\0' && *path != '/');
vop_ref_inc(node);
struct sfs_inode *sin = vop_info(node, sfs_inode);
// 找到sfs_inode __sfs_inode_info。
if (sin->din->type != SFS_TYPE_DIR) {
vop_ref_dec(node);
return -E_NOTDIR;
}
struct inode *subnode;
int ret = sfs_lookup_once(sfs, sin, path, &subnode, NULL);
// 找到与路径相符的inode并加载到subnode里。
vop_ref_dec(node);
if (ret != 0) {
return ret;
}
*node_store = subnode;
return 0;
}

读文件

用户进程有如下语句:

1
read(fd, data, len);

即读取fd对应文件,读取长度为len,存入data中。下面来分析一下读文件的实现。

通用文件访问接口层的处理流程

进一步调用如下用户态函数:read->sys_read->syscall,从而引起系统调用进入到内核态。到了内核态以后,通过中断处理例程,会调用到sys_read内核函数,并进一步调用sysfile_read内核函数,进入到文件系统抽象层处理流程完成进一步读文件的操作。

1
2
3
4
5
6
7
static int
sys_read(uint32_t arg[]) {
int fd = (int)arg[0];
void *base = (void *)arg[1];
size_t len = (size_t)arg[2];
return sysfile_read(fd, base, len);
}

文件系统抽象层的处理流程
  • 检查错误,即检查读取长度是否为0和文件是否可读。
  • 分配buffer空间,即调用kmalloc函数分配4096字节的buffer空间。
  • 读文件过程
    • 实际读文件。
      • 循环读取文件,每次读取buffer大小。
      • 每次循环中,先检查剩余部分大小,若其小于4096字节,则只读取剩余部分的大小。
      • 调用file_read函数(详细分析见后)将文件内容读取到buffer中,alen为实际大小。
      • 调用copy_to_user函数将读到的内容拷贝到用户的内存空间中。
      • 调整各变量以进行下一次循环读取,直至指定长度读取完成。
      • 最后函数调用层层返回至用户程序,用户程序收到了读到的文件内容。
    • file_read函数
      • 这个函数是读文件的核心函数。函数有4个参数,
        • fd是文件描述符,
        • base是缓存的基地址,
        • len是要读取的长度,
        • copied_store存放实际读取的长度。
      • 函数首先调用fd2file函数找到对应的file结构,并检查是否可读。
      • 调用filemap_acquire函数使打开这个文件的计数加1。
      • 调用vop_read函数将文件内容读到iob中(详细分析见后)。
      • 调整文件指针偏移量pos的值,使其向后移动实际读到的字节数iobuf_used(iob)。
      • 调用filemap_release函数使打开这个文件的计数减1,若打开计数为0,则释放file。
        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
        /* sysfile_read - read file */
        int
        sysfile_read(int fd, void *base, size_t len) {
        struct mm_struct *mm = current->mm;
        if (len == 0) {
        return 0;
        }
        if (!file_testfd(fd, 1, 0)) {
        return -E_INVAL;
        }
        // 检查读取长度是否为0和文件是否可读

        void *buffer;
        if ((buffer = kmalloc(IOBUF_SIZE)) == NULL) {
        return -E_NO_MEM;
        }
        // 调用kmalloc函数分配4096字节的buffer空间

        int ret = 0;
        size_t copied = 0, alen;
        while (len != 0) {
        if ((alen = IOBUF_SIZE) > len) {
        alen = len;
        }
        ret = file_read(fd, buffer, alen, &alen);
        // 将文件内容读取到buffer中,alen为实际大小
        if (alen != 0) {
        lock_mm(mm);
        {
        if (copy_to_user(mm, base, buffer, alen)) {
        // copy_to_user在vmm.c中,检查权限后memcpy
        assert(len >= alen);
        base += alen, len -= alen, copied += alen;
        }
        // 调用copy_to_user函数将读到的内容拷贝到用户的内存空间中
        // 调整各变量以进行下一次循环读取,直至指定长度读取完成
        else if (ret == 0) {
        ret = -E_INVAL;
        }
        }
        unlock_mm(mm);
        }
        if (ret != 0 || alen == 0) {
        goto out;
        }
        }

        out:
        kfree(buffer);
        if (copied != 0) {
        return copied;
        }
        return ret;
        }
        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

        // read file
        int
        file_read(int fd, void *base, size_t len, size_t *copied_store) {
        int ret;
        struct file *file;
        *copied_store = 0;
        if ((ret = fd2file(fd, &file)) != 0) {
        return ret;
        }
        // 找到对应的file结构

        if (!file->readable) {
        return -E_INVAL;
        }
        fd_array_acquire(file);
        // 打开这个文件的计数加1

        struct iobuf __iob, *iob = iobuf_init(&__iob, base, len, file->pos);
        ret = vop_read(file->node, iob);
        // 文件内容读到iob中,通过sfs_read --> sfs_io,获取到inode,执行sfs_io_nolock。

        size_t copied = iobuf_used(iob);
        if (file->status == FD_OPENED) {
        file->pos += copied;
        }
        *copied_store = copied;
        fd_array_release(file);
        return ret;
        }
SFS文件系统层的处理流程

vop_read函数实际上是对sfs_read的包装。在sfs_inode.c中sfs_node_fileops变量定义了.vop_read = sfs_read,所以下面来分析sfs_read函数的实现。

  • sfs_read函数调用sfs_io函数。
    • 它有三个参数,node是对应文件的inode,iob是缓存,write表示是读还是写的布尔值(0表示读,1表示写),这里是0。
    • 函数先找到inode对应sfs和sin,
    • 然后调用sfs_io_nolock函数进行读取文件操作,
    • 最后调用iobuf_skip函数调整iobuf的指针。
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
/*
* sfs_io - Rd/Wr file. the wrapper of sfs_io_nolock
with lock protect
*/
static inline int
sfs_io(struct inode *node, struct iobuf *iob, bool write) {
struct sfs_fs *sfs = fsop_info(vop_fs(node), sfs);
struct sfs_inode *sin = vop_info(node, sfs_inode);
int ret;
lock_sin(sin);
{
size_t alen = iob->io_resid;
ret = sfs_io_nolock(sfs, sin, iob->io_base, iob->io_offset, &alen, write);
if (alen != 0) {
iobuf_skip(iob, alen);
}
}
unlock_sin(sin);
return ret;
}

/*
* iobuf_skip - change the current position of io buffer
*/
void
iobuf_skip(struct iobuf *iob, size_t n) {
assert(iob->io_resid >= n);
iob->io_base += n, iob->io_offset += n, iob->io_resid -= n;
}

练习1: 完成读文件操作的实现

首先完成proc.c中process控制块的初始化,在static struct proc_struct *alloc_proc(void)中添加:

1
proc->filesp = NULL;

如果调用了read系统调用,继续调用sys_read函数,和sysfile_read函数,在这个函数中,创建了缓冲区,进一步复制到用户空间的指定位置去;从文件读取数据的函数是file_read。

在file_read函数中,通过文件描述符找到相应文件对应的内存中的inode信息,调用vop_read进行读取处理,vop_read继续调用sfs_read函数,然后调用sfs_io函数和sfs_io_nolock函数。

  • 在sfs_io_nolock函数中,
    • 先计算一些辅助变量,并处理一些特殊情况(比如越界),
    • 然后有sfs_buf_op = sfs_rbufsfs_block_op = sfs_rblock,设置读取的函数操作。
    • 先处理起始的没有对齐到块的部分,再以块为单位循环处理中间的部分,最后处理末尾剩余的部分。
    • 每部分中都调用sfs_bmap_load_nolock函数得到blkno对应的inode编号,
    • 并调用sfs_rbufsfs_rblock函数读取数据(中间部分调用sfs_rblock,起始和末尾部分调用sfs_rbuf),调整相关变量。
    • 完成后如果offset + alen > din->fileinfo.size(写文件时会出现这种情况,读文件时不会出现这种情况,alen为实际读写的长度),则调整文件大小为offset + alen并设置dirty变量。
  • sfs_bmap_load_nolock函数将对应sfs_inode的第index个索引指向的block的索引值取出存到相应的指针指向的单元(ino_store)。
    • 调用sfs_bmap_get_nolock来完成相应的操作。
    • sfs_rbuf和sfs_rblock函数最终都调用sfs_rwblock_nolock函数完成操作,
    • 而sfs_rwblock_nolock函数调用dop_io->disk0_io->disk0_read_blks_nolock->ide_read_secs完成对磁盘的操作。
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/*
* sfs_io_nolock - Rd/Wr a file contentfrom offset position to offset+ length disk blocks<-->buffer (in memroy) * * @sfs: sfs file system
* @sin: sfs inode in memory
* @buf: the buffer Rd/Wr
* @offset: the offset of file
* @alenp: the length need to read (is a pointer). and will RETURN the really Rd/Wr lenght
* @write: BOOL, 0 read, 1 write
*/
static int
sfs_io_nolock(struct sfs_fs *sfs, struct sfs_inode *sin, void *buf, off_t offset, size_t *alenp, bool write) {
struct sfs_disk_inode *din = sin->din;
assert(din->type != SFS_TYPE_DIR);
off_t endpos = offset + *alenp, blkoff;
*alenp = 0;
// 计算出读写的长度,从初始偏移量走到文件的哪个位置
if (offset < 0 || offset >= SFS_MAX_FILE_SIZE || offset > endpos) {
return -E_INVAL;
}
if (offset == endpos) {
return 0;
}
if (endpos > SFS_MAX_FILE_SIZE) {
endpos = SFS_MAX_FILE_SIZE;
}
// 文件过大,到了最大支持的文件长度了

if (!write) {
if (offset >= din->size) {
return 0;
}
if (endpos > din->size) {
endpos = din->size;
}
}
// 如果end position超过了文件大小,就把它移动到这个文件的末尾

int (*sfs_buf_op)(struct sfs_fs *sfs, void *buf, size_t len, uint32_t blkno, off_t offset);
int (*sfs_block_op)(struct sfs_fs *sfs, void *buf, uint32_t blkno, uint32_t nblks);
if (write) {
sfs_buf_op = sfs_wbuf, sfs_block_op = sfs_wblock;
}
else {
sfs_buf_op = sfs_rbuf, sfs_block_op = sfs_rblock;
}
// 设置读取/写入的函数操作

int ret = 0;
size_t size, alen = 0;
uint32_t ino;
uint32_t blkno = offset / SFS_BLKSIZE; // 起始的block序号
uint32_t nblks = endpos / SFS_BLKSIZE - blkno; // 一共要读写多少个block?

//LAB8:EXERCISE1 YOUR CODE
//HINT: call sfs_bmap_load_nolock, sfs_rbuf, sfs_rblock,etc.
// read different kind of blocks in file
/*
* (1) If offset isn't aligned with the first block, Rd/Wr some content from offset to the end of the first block
* NOTICE: useful function: sfs_bmap_load_nolock, sfs_buf_op
* Rd/Wr size = (nblks != 0) ? (SFS_BLKSIZE - blkoff) : (endpos - offset)
* (2) Rd/Wr aligned blocks
* NOTICE: useful function: sfs_bmap_load_nolock, sfs_block_op
* (3) If end position isn't aligned with the last block, Rd/Wr some content from begin to the (endpos % SFS_BLKSIZE) of the last block
* NOTICE: useful function: sfs_bmap_load_nolock, sfs_buf_op
*/
if (offset % SFS_BLKSIZE != 0 || endpos / SFS_BLKSIZE == offset / SFS_BLKSIZE){
blkoff = offset % SFS_BLKSIZE;
size = (nblks != 0) ? (SFS_BLKSIZE - blkoff) : (endpos - offset);
if ((ret = sfs_bmap_load_nolock(sfs, sin, blkno, &ino)) != 0) goto out;
if ((ret = sfs_buf_op(sfs, buf, size, ino, blkoff)) != 0) goto out;
alen += size;
buf += size;
}
// 处理如果不是从块的开头开始写的情况,如果偏移量%块大小不是0则是从块内部开始写的。如果nblks是0的话说明只有一个块里的一部分需要写。先把这个写了。

uint32_t my_nblks = nblks;
if (offset % SFS_BLKSIZE != 0 && my_nblks > 0)
my_nblks --;
// 如果是从一个块的一部分开始写的,那在总的块数上需要减一。
if (my_nblks > 0) {
int temp_blkno = (offset % SFS_BLKSIZE == 0) ? blkno: blkno + 1
if ((ret = sfs_bmap_load_nolock(sfs, sin, temp_blkno, &ino)) != 0)
goto out;
if ((ret = sfs_block_op(sfs, buf, ino, my_nblks)) != 0)
// 这里的sfs_block_op是一个循环,把mu_nblks个块进行读写,跟开头和结尾的那个sfs_buf_op不一样
goto out;
size = SFS_BLKSIZE * my_nblks;
alen += size;
buf += size;
}

//下边就是处理如果最后一部分是最后一块的一部分的了,ino存储了disk上的inode的编号,然后在下边的sfs_buf_op中,处理最后一小块
if (endpos % SFS_BLKSIZE != 0 && endpos / SFS_BLKSIZE != offset / SFS_BLKSIZE) {
size = endpos % SFS_BLKSIZE;
if ((ret = sfs_bmap_load_nolock(sfs, sin, endpos / SFS_BLKSIZE, &ino) == 0) != 0) goto out;
if ((ret = sfs_buf_op(sfs, buf, size, ino, 0)) != 0) goto out;
alen += size;
buf += size;
}
out:
*alenp = alen;
if (offset + alen > sin->din->size) {
sin->din->size = offset + alen;
sin->dirty = 1;
}
return ret;
}
  • 请在实验报告中给出设计实现”UNIX的PIPE机制“的概要设方案,鼓励给出详细设计方案。
    • PIPE机制可以看成是一个缓冲区,可以在磁盘上(或内存中?)保留一部分空间作为pipe机制的缓冲区。当两个进程之间要求建立pipe时,在两个进程的进程控制块上修改某些属性表明这个进程是管道数据的发送方还是接受方,这样就可以将stdin或stdout重定向到生成的临时文件里,在两个进程中打开这个临时文件。
    • 当进程A使用stdout写时,查询PCB中的相关变量,把这些stdout数据输出到临时文件中;
    • 当进程B使用stdin的时候,查询PCB中的信息,从临时文件中读取数据;

练习2: 完成基于文件系统的执行程序机制的实现

改写proc.c中的load_icode函数和其他相关函数,实现基于文件系统的执行程序机制。首先是在do_execve中进行文件名和命令行参数的复制,执行sysfie_open打开相关文件,fd是已经打开的这个文件。执行: make qemu。如果能看看到sh用户程序的执行界面,则基本成功了。如果在sh用户界面上可 以执行”ls”,”hello”等其他放置在sfs文件系统中的其他执行程序,则可以认为本实验基本成功。

  • 给要执行的用户进程创建一个新的内存管理结构mm,
  • 创建用户内存空间的新的页目录表;
  • 将磁盘上的ELF文件的TEXT/DATA/BSS段正确地加载到用户空间中;
  • 从磁盘中读取elf文件的header;
  • 根据elfheader中的信息,获取到磁盘上的program header;
  • 对于每一个program header:
    • 为TEXT/DATA段在用户内存空间上的保存分配物理内存页,同时建立物理页和虚拟页的映射关系;
    • 从磁盘上读取TEXT/DATA段,并且复制到用户内存空间上去;
    • 根据program header得知是否需要创建BBS段,如果是,则分配相应的内存空间,并且全部初始化成0,并且建立物理页和虚拟页的映射关系;
  • 将用户栈的虚拟空间设置为合法,并且为栈顶部分先分配4个物理页,建立好映射关系;
  • 切换到用户地址空间;
  • 设置好用户栈上的信息,即需要传递给执行程序的参数;
  • 设置好中断帧;
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
static int
load_icode(int fd, int argc, char **kargv) {
/* LAB8:EXERCISE2 YOUR CODE HINT:how to load the file with handler fd in to process's memory? how to setup argc/argv?
* MACROs or Functions:
* mm_create - create a mm
* setup_pgdir - setup pgdir in mm
* load_icode_read - read raw data content of program file
* mm_map - build new vma
* pgdir_alloc_page - allocate new memory for TEXT/DATA/BSS/stack parts
* lcr3 - update Page Directory Addr Register -- CR3
*
* (1) create a new mm for current process
* (2) create a new PDT, and mm->pgdir= kernel virtual addr of PDT
* (3) copy TEXT/DATA/BSS parts in binary to memory space of process
* (3.1) read raw data content in file and resolve elfhdr
* (3.2) read raw data content in file and resolve proghdr based on info in elfhdr
* (3.3) call mm_map to build vma related to TEXT/DATA
* (3.4) callpgdir_alloc_page to allocate page for TEXT/DATA, read contents in file
* and copy them into the new allocated pages
* (3.5) callpgdir_alloc_page to allocate pages for BSS, memset zero in these pages
* (4) call mm_map to setup user stack, and put parameters into user stack
* (5) setup current process's mm, cr3, reset pgidr (using lcr3 MARCO)
* (6) setup uargc and uargv in user stacks
* (7) setup trapframe for user environment
* (8) if up steps failed, you should cleanup the env.
*/
if (current->mm != NULL) {
panic("load_icode: current->mm must be empty.\n");
}

int ret = -E_NO_MEM;
struct mm_struct *mm;
//(1) create a new mm for current process
if ((mm = mm_create()) == NULL) {
goto bad_mm;
}
//(2) create a new PDT, and mm->pgdir= kernel virtual addr of PDT
if (setup_pgdir(mm) != 0) {
goto bad_pgdir_cleanup_mm;
}
//(3) copy TEXT/DATA section, build BSS parts in binary to memory space of process
struct Page *page;
//(3.1) get the file header of the bianry program (ELF format)
struct elfhdr elf;
off_t offset = 0;

if((ret = load_icode_read(fd, (void*)&elf, sizeof(struct elfhdr), 0)) != 0) {
// elf header读取到elf中,这里的参数比较复杂需要先取地址再类型转换
goto bad_elf_cleanup_pgdir;
}
if (elf.e_magic != ELF_MAGIC) {
//检查是不是魔数,如果是的话才是对的elf文件
ret = -E_INVAL_ELF;
goto bad_elf_cleanup_pgdir;
}
offset += sizeof(struct elfhdr);
// 这个文件已经读取到elf header 之后了

uint32_t vm_flags, perm;
struct proghdr ph;
for (int i=0; i < elf.e_phnum; i ++) {
// e_phnum is number of entries in program header.
//(3.4) find every program section headers
// 第二三个参数分别是读取的长度和在文件中的偏移量。
off_t phoff = elf.e_phoff + sizeof(struct proghdr) * i;
load_icode_read(fd, (void*)&ph, sizeof(struct proghdr), phoff);
if (ph.p_type != ELF_PT_LOAD) {
continue ;
}
if (ph.p_filesz > ph.p_memsz) {
ret = -E_INVAL_ELF;
goto bad_cleanup_mmap;
}
if (ph.p_filesz == 0) {
continue ;
}
// call mm_map fun to setup the new vma ( ph->p_va, ph->p_memsz)
vm_flags = 0, perm = PTE_U;
if (ph.p_flags & ELF_PF_X) vm_flags |= VM_EXEC;
if (ph.p_flags & ELF_PF_W) vm_flags |= VM_WRITE;
if (ph.p_flags & ELF_PF_R) vm_flags |= VM_READ;
if (vm_flags & VM_WRITE) perm |= PTE_W;
if ((ret = mm_map(mm, ph.p_va, ph.p_memsz, vm_flags, NULL)) != 0) {
goto bad_cleanup_mmap;
}
// 虚拟内存管理的权限控制,并设置映射

offset = ph.p_offset;
size_t off, size;
uintptr_t start = ph.p_va, end=ph.p_va+ph.p_filesz, la = ROUNDDOWN(start, PGSIZE);
// start 和 end 是vma中的segment的起始和结尾
ret = -E_NO_MEM;

while (start < end) {
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
ret = -E_NO_MEM;
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la) {
size -= la - end;
}
load_icode_read(fd, page2kva(page)+off, size, offset);
//memcpy(page2kva(page) + off, from, size);
start += size, offset += size;
}

// build BSS section of binary program
end = ph.p_va + ph.p_memsz;
if (start < la) {
/* ph->p_memsz == ph->p_filesz */
if (start == end) {
continue ;
}
off = start + PGSIZE - la, size = PGSIZE - off;
if (end < la) {
size -= la - end;
}
memset(page2kva(page) + off, 0, size);
start += size;
assert((end < la && start == end) || (end >= la && start == la));
}
while (start < end) {
if ((page = pgdir_alloc_page(mm->pgdir, la, perm)) == NULL) {
ret = -E_NO_MEM;
goto bad_cleanup_mmap;
}
off = start - la, size = PGSIZE - off, la += PGSIZE;
if (end < la) {
size -= la - end;
}
memset(page2kva(page), 0, size);
start += size;
}
}
sysfile_close(fd);
//(4) build user stack memory
vm_flags = VM_READ | VM_WRITE | VM_STACK;
if ((ret = mm_map(mm, USTACKTOP - USTACKSIZE, USTACKSIZE, vm_flags, NULL)) != 0) {
goto bad_cleanup_mmap;
}

uint32_t stacktop = USTACKTOP;
uint32_t argsize = 0;
for(int j = 0; j< argc ; j++)
argsize += (1 + strlen(kargv[j]));
// 计算传进来的参数的大小和长度,并进行取整
argsize = (argsize / sizeof(long)+1)*sizeof(long);
argsize += (2+argc)*sizeof(long);
stacktop = USTACKTOP - argsize;
uint32_t pagen = argsize / PGSIZE + 4;
for (int j = 1; j <= 4; ++ j) {
assert(pgdir_alloc_page(mm->pgdir, USTACKTOP-PGSIZE*j , PTE_USER) != NULL);
}
//(5) set current process's mm, sr3, and set CR3 reg = physical addr of Page Directory
mm_count_inc(mm);
current->mm = mm;
current->cr3 = PADDR(mm->pgdir);
lcr3(PADDR(mm->pgdir));

//(6) setup trapframe for user environment
uint32_t now_pos = stacktop, argvp;
*((uint32_t*)now_pos) = argc;
now_pos += 4;
*((uint32_t *) now_pos) = argvp = now_pos + 4;
now_pos += 4;
now_pos += argc*4;
//压栈
for (int j = 0; j < argc; ++ j) {
argsize = strlen(kargv[j]) + 1;
memcpy((void *) now_pos, kargv[j], argsize);
*((uint32_t *) (argvp + j * 4)) = now_pos;
now_pos += argsize;
}

/* LAB5:EXERCISE1 YOUR CODE
* should set tf_cs,tf_ds,tf_es,tf_ss,tf_esp,tf_eip,tf_eflags
* NOTICE: If we set trapframe correctly, then the user level process can return to USER MODE from kernel. So
* tf_cs should be USER_CS segment (see memlayout.h)
* tf_ds=tf_es=tf_ss should be USER_DS segment
* tf_esp should be the top addr of user stack (USTACKTOP)
* tf_eip should be the entry point of this binary program (elf->e_entry)
* tf_eflags should be set to enable computer to produce Interrupt
*/
struct trapframe *tf = current->tf;
memset(tf, 0, sizeof(struct trapframe));
tf->tf_cs = USER_CS;
tf->tf_ds = tf->tf_es = tf->tf_ss = USER_DS;
tf->tf_esp = stacktop;
tf->tf_eip = elf.e_entry;
tf->tf_eflags = 0x2 | FL_IF; // to enable interrupt
ret = 0;
out:
return ret;
bad_cleanup_mmap:
exit_mmap(mm);
bad_elf_cleanup_pgdir:
put_pgdir(mm);
bad_pgdir_cleanup_mm:
mm_destroy(mm);
bad_mm:
goto out;
}

UNIX的硬链接和软链接机制:

硬链接:

  • 文件有相同的 inode 及 data block;
  • 只能对已存在的文件进行创建;
  • 不能交叉文件系统进行硬链接的创建;
  • 不能对目录进行创建,只可对文件创建;
  • 删除一个硬链接文件并不影响其他有相同 inode 号的文件。

软链接:

  • 软链接有自己的文件属性及权限等;
  • 可对不存在的文件或目录创建软链接;
  • 软链接可交叉文件系统;
  • 软链接可对文件或目录创建;
  • 创建软链接时,链接计数 i_nlink 不会增加;
  • 删除软链接并不影响被指向的文件,但若被指向的原文件被删除,则相关软连接被称为死链接

硬链接: 与普通文件没什么不同,inode 都指向同一个文件在硬盘中的区块
软链接: 保存了其代表的文件的绝对路径,是另外一种文件,在硬盘上有独立的区块,访问时替换自身路径。

sfs_disk_inode结构体中有一个nlinks变量,如果要创建一个文件的软链接,这个软链接也要创建inode,只是它的类型是链接,找一个域设置它所指向的文件inode,如果文件是一个链接,就可以通过保存的inode位置进行操作;当删除一个软链接时,直接删掉inode即可;

硬链接与文件是共享inode的,如果创建一个硬链接,需要将源文件中的被链接的计数加1;当删除一个硬链接的时候,除了需要删掉inode之外,还需要将硬链接指向的文件的被链接计数减1,如果减到了0,则需要将A删除掉;

开始学学golang这门伟大的语言。

结构

Go的基础组成有以下几个部分:

  • 包声明
  • 引入包
  • 函数
  • 变量
  • 语句 & 表达式
  • 注释
1
2
3
4
5
6
7
8
9
package main
/* 包的名字是main,每个程序都有一个main的包 */
import "fmt"
/* 需要fmt这个包 */

func main() {
/* 这是我的第一个简单的程序 */
fmt.Println("Hello, World!")
}

(太奇葩了,竟然是以大小写作为权限控制的。)当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 protected )。

运行的话:

1
go run hello.go

最奇葩的是 { 不能单独放在一行

基础

行分隔符

在 Go 程序中,一行代表一个语句结束。每个语句不需要像C一样以分号结尾,因为这些工作都将由 Go 编译器自动完成。

标识符用来命名变量、类型等程序实体。一个标识符实际上就是一个或是多个字母、数字、下划线组成的序列,但是第一个字符必须是字母或下划线而不能是数字。

Go 语言的字符串可以通过 + 实现:

1
2
3
4
5
package main
import "fmt"
func main() {
fmt.Println("Google" + "Runoob")
}

数据类型

在 Go 编程语言中,数据类型用于声明函数和变量。

数据类型的出现是为了把数据分成所需内存大小不同的数据,编程的时候需要用大数据的时候才需要申请大内存,就可以充分利用内存。

Go 语言按类别有以下几种数据类型:

  • 布尔型:布尔型的值只可以是常量 true 或者 false。一个简单的例子:var b bool = true。
  • 数字类型:整型 int 和浮点型 float32、float64,Go 语言支持整型和浮点型数字,并且支持复数,其中位的运算采用补码。
  • 字符串类型:字符串就是一串固定长度的字符连接起来的字符序列。Go 的字符串是由单个字节连接起来的。Go 语言的字符串的字节使用 UTF-8 编码标识 Unicode 文本。
  • 派生类型:
    • (a) 指针类型(Pointer)
    • (b) 数组类型
    • (c) 结构化类型(struct)
    • (d) Channel 类型
    • (e) 函数类型
    • (f) 切片类型
    • (g) 接口类型(interface)
    • (h) Map 类型

数字类型

Go 也有基于架构的类型,例如:int、uint 和 uintptr。

  • uint8:无符号 8 位整型 (0 到 255)
  • uint16:无符号 16 位整型 (0 到 65535)
  • uint32:无符号 32 位整型 (0 到 4294967295)
  • uint64:无符号 64 位整型 (0 到 18446744073709551615)
  • int8:有符号 8 位整型 (-128 到 127)
  • int16:有符号 16 位整型 (-32768 到 32767)
  • int32:有符号 32 位整型 (-2147483648 到 2147483647)
  • int64:有符号 64 位整型 (-9223372036854775808 到 9223372036854775807)

浮点型

  • float32:IEEE-754 32位浮点型数
  • float64:IEEE-754 64位浮点型数
  • complex64:32 位实数和虚数
  • complex128:64 位实数和虚数

其他数字类型

  • byte:类似 uint8
  • rune:类似 int32
  • uint:32 或 64 位
  • int:与 uint 一样大小
  • uintptr:无符号整型,用于存放一个指针

变量

Go 语言变量名由字母、数字、下划线组成,其中首个字符不能为数字。

声明变量的一般形式是使用 var 关键字:

1
var identifier type

可以一次声明多个变量:
1
var identifier1, identifier2 type

实例
1
2
3
4
5
6
7
8
9
package main
import "fmt"
func main() {
var a string = "Runoob"
fmt.Println(a)

var b, c int = 1, 2
fmt.Println(b, c)
}

变量声明

第一种,指定变量类型,如果没有初始化,则变量默认为零值。

1
2
var v_name v_type
v_name = value

未初始化的时候:

  • 数值类型(包括complex64/128)为 0
  • 布尔类型为 false
  • 字符串为 “”(空字符串)
  • 以下几种类型为 nil:
    • var a *int
    • var a []int
    • var a map[string] int
    • var a chan int
    • var a func(string) int
    • var a error // error 是接口

第二种,根据值自行判定变量类型。

1
var v_name = value

实例
1
2
3
4
5
6
package main
import "fmt"
func main() {
var d = true
fmt.Println(d)
}

第三种,省略 var, 注意 := 左侧如果没有声明新的变量,就产生编译错误,格式:

1
v_name := value

例如:
1
2
3
var intVal int 
intVal :=1 // 这时候会产生编译错误
intVal,intVal1 := 1,2 // 此时不会产生编译错误,因为有声明新的变量,因为 := 是一个声明语句

可以将 var f string = "Runoob" 简写为 f := "Runoob"

实例

1
2
3
4
5
6
7
package main
import "fmt"
func main() {
f := "Runoob" // var f string = "Runoob"

fmt.Println(f)
}

多变量声明
//类型相同多个变量, 非全局变量

1
2
3
4
var vname1, vname2, vname3 type
vname1, vname2, vname3 = v1, v2, v3
var vname1, vname2, vname3 = v1, v2, v3 // 和 python 很像,不需要显示声明类型,自动推断
vname1, vname2, vname3 := v1, v2, v3 // 出现在 := 左侧的变量不应该是已经被声明过的,否则会导致编译错误

这种因式分解关键字的写法一般用于声明全局变量

1
2
3
4
var (
vname1 v_type1
vname2 v_type2
)

可以在变量的初始化时省略变量的类型而由系统自动推断,声明语句写上 var 关键字其实是显得有些多余了,因此我们可以将它们简写为 a := 50 或 b := false。

a 和 b 的类型(int 和 bool)将由编译器自动推断。

这是使用变量的首选形式,但是它只能被用在函数体内,而不可以用于全局变量的声明与赋值。使用操作符 := 可以高效地创建一个新的变量,称之为初始化声明。

如果在相同的代码块中,我们不可以再次对于相同名称的变量使用初始化声明,例如:a := 20 就是不被允许的,编译器会提示错误 no new variables on left side of :=,但是 a = 20 是可以的,因为这是给相同的变量赋予一个新的值。

如果你在定义变量 a 之前使用它,则会得到编译错误 undefined: a。

如果你声明了一个局部变量却没有在相同的代码块中使用它,同样会得到编译错误

常量

常量是一个简单值的标识符,在程序运行时,不会被修改的量。

常量中的数据类型只可以是布尔型、数字型(整数型、浮点型和复数)和字符串型。

常量的定义格式:

1
const identifier [type] = value

你可以省略类型说明符 [type],因为编译器可以根据变量的值来推断其类型。

显式类型定义: const b string = “abc”
隐式类型定义: const b = “abc”
多个相同类型的声明可以简写为:

1
const c_name1, c_name2 = value1, value2

常量还可以用作枚举:

1
2
3
4
5
const (
Unknown = 0
Female = 1
Male = 2
)

数字 0、1 和 2 分别代表未知性别、女性和男性。

常量可以用len(), cap(), unsafe.Sizeof()函数计算表达式的值。常量表达式中,函数必须是内置函数,否则编译不过

iota

iota,特殊常量,可以认为是一个可以被编译器修改的常量。

iota 在 const 关键字出现时将被重置为 0(const 内部的第一行之前),const 中每新增一行常量声明将使 iota 计数一次(iota 可理解为 const 语句块中的行索引)。

iota 可以被用作枚举值:

1
2
3
4
5
const (
a = iota
b = iota
c = iota
)

第一个 iota 等于 0,每当 iota 在新的一行被使用时,它的值都会自动加 1;所以 a=0, b=1, c=2 可以简写为如下形式:
1
2
3
4
5
const (
a = iota
b
c
)

iota 用法

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
const (
a = iota //0
b //1
c //2
d = "ha" //独立值,iota += 1
e //"ha" iota += 1
f = 100 //iota +=1
g //100 iota +=1
h = iota //7,恢复计数
i //8
)
fmt.Println(a,b,c,d,e,f,g,h,i)
}

以上实例运行结果为:
1
0 1 2 ha ha 100 100 7 8

再看个有趣的的 iota 实例:

实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import "fmt"
const (
i=1<<iota
j=3<<iota
k
l
)

func main() {
fmt.Println("i=",i)
fmt.Println("j=",j)
fmt.Println("k=",k)
fmt.Println("l=",l)
}

以上实例运行结果为:
1
2
3
4
i= 1
j= 6
k= 12
l= 24

iota 表示从 0 开始自动加 1,所以 i=1<<0, j=3<<1(<< 表示左移的意思),即:i=1, j=6,这没问题,关键在 k 和 l,从输出结果看 k=3<<2,l=3<<3。

简单表述:

1
2
3
4
i=1:左移 0 位,不变仍为 1;
j=3:左移 1 位,变为二进制 110, 即 6;
k=3:左移 2 位,变为二进制 1100, 即 12;
l=3:左移 3 位,变为二进制 11000,即 24。

部分运算符

假定 A 为60,B 为13:

运算符 描述 实例
& 按位与运算符”&”是双目运算符。 其功能是参与运算的两数各对应的二进位相与。 (A & B) 结果为 12, 二进制为 0000 1100
竖线或 按位或运算符是双目运算符。 其功能是参与运算的两数各对应的二进位相或。 (A 或 B) 结果为 61, 二进制为 0011 1101
^ 按位异或运算符”^”是双目运算符。 其功能是参与运算的两数各对应的二进位相异或,当两对应的二进位相异时,结果为1。 (A ^ B) 结果为 49, 二进制为 0011 0001
<< 左移运算符”<<”是双目运算符。左移n位就是乘以2的n次方。 其功能把”<<”左边的运算数的各二进位全部左移若干位,由”<<”右边的数指定移动的位数,高位丢弃,低位补0。 A << 2 结果为 240 ,二进制为 1111 0000
>> 右移运算符>>是双目运算符。右移n位就是除以2的n次方。 其功能是把>>左边的运算数的各二进位全部右移若干位,>>右边的数指定移动的位数。 A >> 2 结果为 15 ,二进制为 0000 1111
运算符 描述 实例
& 返回变量存储地址 &a; 将给出变量的实际地址。
* 指针变量。 *a; 是一个指针变量

条件语句

if 语句的语法如下:

1
2
3
if 布尔表达式 {
/* 在布尔表达式为 true 时执行 */
}

If 在布尔表达式为 true 时,其后紧跟的语句块执行,如果为 false 则不执行。

Go 编程语言中 if…else 语句的语法如下:

1
2
3
4
5
if 布尔表达式 {
/* 在布尔表达式为 true 时执行 */
} else {
/* 在布尔表达式为 false 时执行 */
}

If 在布尔表达式为 true 时,其后紧跟的语句块执行,如果为 false 则执行 else 语句块。

Go 编程语言中 if…else 语句的语法如下:

1
2
3
4
5
6
if 布尔表达式 1 {
/* 在布尔表达式 1 为 true 时执行 */
if 布尔表达式 2 {
/* 在布尔表达式 2 为 true 时执行 */
}
}

switch

switch 语句用于基于不同条件执行不同动作,每一个 case 分支都是唯一的,从上至下逐一测试,直到匹配为止。

switch 语句执行的过程从上至下,直到找到匹配项,匹配项后面也不需要再加 break。

switch 默认情况下 case 最后自带 break 语句,匹配成功后就不会执行其他 case,如果我们需要执行后面的 case,可以使用 fallthrough 。

语法
Go 编程语言中 switch 语句的语法如下:

1
2
3
4
5
6
7
8
switch var1 {
case val1:
...
case val2:
...
default:
...
}

变量 var1 可以是任何类型,而 val1 和 val2 则可以是同类型的任意值。类型不被局限于常量或整数,但必须是相同的类型;或者最终结果为相同类型的表达式。

您可以同时测试多个可能符合条件的值,使用逗号分割它们,例如:case val1, val2, val3。

switch 语句还可以被用于 type-switch 来判断某个 interface 变量中实际存储的变量类型。

Type Switch 语法格式如下:

1
2
3
4
5
6
7
8
9
switch x.(type){
case type:
statement(s);
case type:
statement(s);
/* 你可以定义任意个数的case */
default: /* 可选 */
statement(s);
}

fallthrough

使用 fallthrough 会强制执行后面的 case 语句,fallthrough 不会判断下一条 case 的表达式结果是否为 true。

select

select 是 Go 中的一个控制结构,类似于用于通信的 switch 语句。每个 case 必须是一个通信操作,要么是发送要么是接收。

select 随机执行一个可运行的 case。如果没有 case 可运行,它将阻塞,直到有 case 可运行。一个默认的子句应该总是可运行的。

语法
Go 编程语言中 select 语句的语法如下:

1
2
3
4
5
6
7
8
9
select {
case communication clause :
statement(s);
case communication clause :
statement(s);
/* 你可以定义任意数量的 case */
default : /* 可选 */
statement(s);
}

以下描述了 select 语句的语法:

  • 每个 case 都必须是一个通信
  • 所有 channel 表达式都会被求值
  • 所有被发送的表达式都会被求值
  • 如果任意某个通信可以进行,它就执行,其他被忽略。
  • 如果有多个 case 都可以运行,Select 会随机公平地选出一个执行。其他不会执行。

否则:

  • 如果有 default 子句,则执行该语句。
  • 如果没有 default 子句,select 将阻塞,直到某个通信可以运行;Go 不会重新对 channel 或值进行求值。

循环

Go语言的For循环有3中形式,只有其中的一种使用分号。

和 C 语言的 for 一样:

1
for init; condition; post { }

和 C 的 while 一样:
1
for condition { }

和 C 的 for(;;) 一样:
1
for { }

  • init: 一般为赋值表达式,给控制变量赋初值;
  • condition: 关系表达式或逻辑表达式,循环控制条件;
  • post: 一般为赋值表达式,给控制变量增量或减量。

for语句执行过程如下:

  • 先对表达式1赋初值;
  • 判别赋值表达式 init 是否满足给定条件,若其值为真,满足循环条件,则执行循环体内语句,然后执行 post,进入第二次循环,再判别 condition;
  • 否则判断 condition 的值为假,不满足条件,就终止for循环,执行循环体外语句。

for 循环的 range 格式可以对 slice、map、数组、字符串等进行迭代循环。格式如下:

1
2
3
for key, value := range oldMap {
newMap[key] = value
}

break 语句

Go 语言循环语句 Go语言循环语句

Go 语言中 break 语句用于以下两方面:

  • 用于循环语句中跳出循环,并开始执行循环之后的语句。
  • break 在 switch(开关语句)中在执行一条case后跳出语句的作用。

Go 语言的 continue 语句 有点像 break 语句。但是 continue 不是跳出循环,而是跳过当前循环执行下一次循环语句。

for 循环中,执行 continue 语句会触发for增量语句的执行。

函数

函数是基本的代码块,用于执行一个任务。

Go 语言最少有个 main() 函数。

你可以通过函数来划分不同功能,逻辑上每个函数执行的是指定的任务。函数声明告诉了编译器函数的名称,返回类型,和参数。

Go 语言标准库提供了多种可动用的内置的函数。例如,len() 函数可以接受不同类型参数并返回该类型的长度。如果我们传入的是字符串则返回字符串的长度,如果传入的是数组,则返回数组中包含的元素个数。

Go 语言函数定义格式如下:

1
2
3
func function_name( [parameter list] ) [return_types] {
函数体
}

  • func:函数由 func 开始声明
  • function_name:函数名称,函数名和参数列表一起构成了函数签名。
  • parameter list:参数列表,参数就像一个占位符,当函数被调用时,你可以将值传递给参数,这个值被称为实际参数。参数列表指定的是参数类型、顺序、及参数个数。参数是可选的,也就是说函数也可以不包含参数。
  • return_types:返回类型,函数返回一列值。return_types 是该列值的数据类型。有些功能不需要返回值,这种情况下 return_types 不是必须的。
  • 函数体:函数定义的代码集合。

函数返回多个值

Go 函数可以返回多个值,例如:

实例

1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"

func swap(x, y string) (string, string) {
return y, x
}

func main() {
a, b := swap("Google", "Runoob")
fmt.Println(a, b)
}

值传递

传递是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。默认情况下,Go 语言使用的是值传递,即在调用过程中不会影响到实际参数。

引用传递

引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数。

引用传递指针参数传递到函数内,以下是交换函数 swap() 使用了引用传递:

1
2
3
4
5
6
7
/* 定义交换值函数*/
func swap(x *int, y *int) {
var temp int
temp = *x /* 保持 x 地址上的值 */
*x = *y /* 将 y 值赋给 x */
*y = temp /* 将 temp 值赋给 y */
}

函数作为实参

Go 语言可以很灵活的创建函数,并作为另外一个函数的实参。以下实例中我们在定义的函数中初始化一个变量,该函数仅仅是为了使用内置函数 math.sqrt(),实例为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"math"
)

func main(){
/* 声明函数变量 */
getSquareRoot := func(x float64) float64 {
return math.Sqrt(x)
}

/* 使用函数 */
fmt.Println(getSquareRoot(9))

}

函数闭包

go支持匿名函数,可作为闭包。匿名函数是一个”内联”语句或表达式。匿名函数的优越性在于可以直接使用函数内的变量,不必申明。

以下实例中,我们创建了函数 getSequence() ,返回另外一个函数。该函数的目的是在闭包中递增 i 变量,代码如下:

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
package main
import "fmt"

func getSequence() func() int {
i:=0
return func() int {
i+=1
return i
}
}

func main(){
/* nextNumber 为一个函数,函数 i 为 0 */
nextNumber := getSequence()

/* 调用 nextNumber 函数,i 变量自增 1 并返回 */
fmt.Println(nextNumber())
fmt.Println(nextNumber())
fmt.Println(nextNumber())

/* 创建新的函数 nextNumber1,并查看结果 */
nextNumber1 := getSequence()
fmt.Println(nextNumber1())
fmt.Println(nextNumber1())
}

方法

Go 语言中同时有函数和方法。一个方法就是一个包含了接受者的函数,接受者可以是命名类型或者结构体类型的一个值或者是一个指针。所有给定类型的方法属于该类型的方法集。语法格式如下:

1
2
3
func (variable_name variable_data_type) function_name() [return_type]{
/* 函数体*/
}

下面定义一个结构体类型和该类型的一个方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
)

/* 定义结构体 */
type Circle struct {
radius float64
}

func main() {
var c1 Circle
c1.radius = 10.00
fmt.Println("圆的面积 = ", c1.getArea())
}

//该 method 属于 Circle 类型对象中的方法
func (c Circle) getArea() float64 {
//c.radius 即为 Circle 类型对象中的属性
return 3.14 * c.radius * c.radius
}

变量作用域

作用域为已声明标识符所表示的常量、类型、变量、函数或包在源代码中的作用范围。

Go 语言中变量可以在三个地方声明:

  • 函数内定义的变量称为局部变量
  • 函数外定义的变量称为全局变量
  • 函数定义中的变量称为形式参数

接下来让我们具体了解局部变量、全局变量和形式参数。

局部变量

在函数体内声明的变量称之为局部变量,它们的作用域只在函数体内,参数和返回值变量也是局部变量。

全局变量

在函数体外声明的变量称之为全局变量,全局变量可以在整个包甚至外部包(被导出后)使用。

Go 语言程序中全局变量与局部变量名称可以相同,但是函数内的局部变量会被优先考虑。

形式参数

形式参数会作为函数的局部变量来使用。

数组

Go 语言提供了数组类型的数据结构。

数组是具有相同唯一类型的一组已编号且长度固定的数据项序列,这种类型可以是任意的原始类型例如整形、字符串或者自定义类型。

相对于去声明 number0, number1, …, number99 的变量,使用数组形式 numbers[0], numbers[1] …, numbers[99] 更加方便且易于扩展。

数组元素可以通过索引(位置)来读取(或者修改),索引从 0 开始,第一个元素索引为 0,第二个索引为 1,以此类推。

多维数组

Go 语言支持多维数组,以下为常用的多维数组声明方式:

1
var variable_name [SIZE1][SIZE2]...[SIZEN] variable_type

以下实例声明了三维的整型数组:
1
var threedim [5][10][4]int

二维数组是最简单的多维数组,二维数组本质上是由一维数组组成的。二维数组定义方式如下:
1
var arrayName [ x ][ y ] variable_type

指针

Go 语言的取地址符是&,放到一个变量前使用就会返回相应变量的内存地址。

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
var a int = 10

fmt.Printf("变量的地址: %x\n", &a )
}

指针使用流程:

  • 定义指针变量。
  • 为指针变量赋值。
  • 访问指针变量中指向地址的值。

在指针类型前面加上 * 号(前缀)来获取指针所指向的内容。

空指针

当一个指针被定义后没有分配到任何变量时,它的值为 nil。nil 指针也称为空指针。nil在概念上和其它语言的null、None、nil、NULL一样,都指代零值或空值。

一个指针变量通常缩写为 ptr。

结构体

Go 语言中数组可以存储同一类型的数据,但在结构体中我们可以为不同项定义不同的数据类型。

结构体是由一系列具有相同类型或不同类型的数据构成的数据集合。

定义结构体

结构体定义需要使用 type 和 struct 语句。struct 语句定义一个新的数据类型,结构体有中有一个或多个成员。type 语句设定了结构体的名称。结构体的格式如下:

1
2
3
4
5
6
type struct_variable_type struct {
member definition;
member definition;
...
member definition;
}

一旦定义了结构体类型,它就能用于变量的声明,语法格式如下:

1
variable_name := structure_variable_type {value1, value2...valuen}


1
variable_name := structure_variable_type { key1: value1, key2: value2..., keyn: valuen}

你可以像其他数据类型一样将结构体类型作为参数传递给函数。

1
2
3
4
5
6
func printBook( book Books ) {
fmt.Printf( "Book title : %s\n", book.title);
fmt.Printf( "Book author : %s\n", book.author);
fmt.Printf( "Book subject : %s\n", book.subject);
fmt.Printf( "Book book_id : %d\n", book.book_id);
}

切片

Go 语言切片是对数组的抽象。

Go 数组的长度不可改变,因此提供了一种灵活,功能强悍的内置类型切片(“动态数组”),与数组相比切片的长度是不固定的,可以追加元素,在追加时可能使切片的容量增大。

定义切片

你可以声明一个未指定大小的数组来定义切片:

1
var identifier []type

切片不需要说明长度。

或使用make()函数来创建切片:

1
var slice1 []type = make([]type, len)

也可以简写为
1
slice1 := make([]type, len)

也可以指定容量,其中capacity为可选参数。
1
make([]T, length, capacity)

这里 len 是数组的长度并且也是切片的初始长度。

切片初始化

1
s :=[] int {1,2,3 } 

直接初始化切片,[]表示是切片类型,{1,2,3}初始化值依次是1,2,3.其cap=len=3

1
s := arr[:] 

初始化切片s,是数组arr的引用
1
s := arr[startIndex:endIndex] 

将arr中从下标startIndex到endIndex-1 下的元素创建为一个新的切片
1
s := arr[startIndex:] 

缺省endIndex时将表示一直到arr的最后一个元素
1
s := arr[:endIndex] 

缺省startIndex时将表示从arr的第一个元素开始
1
s1 := s[startIndex:endIndex] 

通过切片s初始化切片s1
1
s :=make([]int,len,cap) 

通过内置函数make()初始化切片s,[]int 标识为其元素类型为int的切片

len() 和 cap() 函数

切片是可索引的,并且可以由 len() 方法获取长度。

切片提供了计算容量的方法 cap() 可以测量切片最长可以达到多少。

空(nil)切片

一个切片在未初始化之前默认为 nil,长度为 0.

范围(range)

range 关键字用于 for 循环中迭代数组(array)、切片(slice)、通道(channel)或集合(map)的元素。在数组和切片中它返回元素的索引和索引对应的值,在集合中返回 key-value 对的 key 值。

1
2
3
4
kvs := map[string]string{"a": "apple", "b": "banana"}
for k, v := range kvs {
fmt.Printf("%s -> %s\n", k, v)
}

Map(集合)

Map 是一种无序的键值对的集合。Map 最重要的一点是通过 key 来快速检索数据,key 类似于索引,指向数据的值。

Map 是一种集合,所以我们可以像迭代数组和切片那样迭代它。不过,Map 是无序的,我们无法决定它的返回顺序,这是因为 Map 是使用 hash 表来实现的。

定义 Map

可以使用内建函数 make 也可以使用 map 关键字来定义 Map:

1
2
3
4
5
/* 声明变量,默认 map 是 nil */
var map_variable map[key_data_type]value_data_type

/* 使用 make 函数 */
map_variable := make(map[key_data_type]value_data_type)

1
2
3
4
5
6
7
8
var countryCapitalMap map[string]string /*创建集合 */
countryCapitalMap = make(map[string]string)

/* map插入key - value对,各个国家对应的首都 */
countryCapitalMap [ "France" ] = "巴黎"
countryCapitalMap [ "Italy" ] = "罗马"
countryCapitalMap [ "Japan" ] = "东京"
countryCapitalMap [ "India " ] = "新德里"

递归

Go 语言支持递归。但我们在使用递归时,开发者需要设置退出条件,否则递归将陷入无限循环中。

类型转换

类型转换用于将一种数据类型的变量转换为另外一种类型的变量。Go 语言类型转换基本格式如下:

1
type_name(expression)

type_name 为类型,expression 为表达式。

接口

Go 语言提供了另外一种数据类型即接口,它把所有的具有共性的方法定义在一起,任何其他类型只要实现了这些方法就是实现了这个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type interface_name interface {
method_name1 [return_type]
method_name2 [return_type]
method_name3 [return_type]
...
method_namen [return_type]
}

/* 定义结构体 */
type struct_name struct {
/* variables */
}

/* 实现接口方法 */
func (struct_name_variable struct_name) method_name1() [return_type] {
/* 方法实现 */
}
...
func (struct_name_variable struct_name) method_namen() [return_type] {
/* 方法实现*/
}

错误处理

Go 语言通过内置的错误接口提供了非常简单的错误处理机制。

error类型是一个接口类型,这是它的定义:

1
2
3
type error interface {
Error() string
}

我们可以在编码中通过实现 error 接口类型来生成错误信息。

函数通常在最后的返回值中返回错误信息。使用errors.New 可返回一个错误信息:

1
2
3
4
5
6
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("math: square root of negative number")
}
// 实现
}

在下面的例子中,我们在调用Sqrt的时候传递的一个负数,然后就得到了non-nil的error对象,将此对象与nil比较,结果为true,所以fmt.Println(fmt包在处理error时会调用Error方法)被调用,以输出错误,请看下面调用的示例代码:
1
2
3
4
5
result, err:= Sqrt(-1)

if err != nil {
fmt.Println(err)
}

并发

Go 语言支持并发,我们只需要通过 go 关键字来开启 goroutine 即可。

goroutine 是轻量级线程,goroutine 的调度是由 Golang 运行时进行管理的。

goroutine 语法格式:

1
go 函数名( 参数列表 )

例如:
1
go f(x, y, z)

开启一个新的 goroutine:
1
f(x, y, z)

Go 允许使用 go 语句开启一个新的运行期线程, 即 goroutine,以一个不同的、新创建的 goroutine 来执行一个函数。 同一个程序中的所有 goroutine 共享同一个地址空间。

通道

通道(channel)是用来传递数据的一个数据结构。

通道可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。操作符 <- 用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。

1
2
3
ch <- v    // 把 v 发送到通道 ch
v := <-ch // 从 ch 接收数据
// 并把值赋给 v

声明一个通道很简单,我们使用chan关键字即可,通道在使用前必须先创建:

1
ch := make(chan int)

注意:默认情况下,通道是不带缓冲区的。发送端发送数据,同时必须又接收端相应的接收数据。

以下实例通过两个 goroutine 来计算数字之和,在 goroutine 完成计算后,它会计算两个结果的和:

通道缓冲区

通道可以设置缓冲区,通过 make 的第二个参数指定缓冲区大小:

1
ch := make(chan int, 100)

带缓冲区的通道允许发送端的数据发送和接收端的数据获取处于异步状态,就是说发送端发送的数据可以放在缓冲区里面,可以等待接收端去获取数据,而不是立刻需要接收端去获取数据。

不过由于缓冲区的大小是有限的,所以还是必须有接收端来接收数据的,否则缓冲区一满,数据发送端就无法再发送数据了。

注意:如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。

遍历通道与关闭通道

Go 通过 range 关键字来实现遍历读取到的数据,类似于与数组或切片。格式如下:

1
v, ok := <-ch

如果通道接收不到数据后 ok 就为 false,这时通道就可以使用 close() 函数来关闭。

Go语言并发之道1-3章

原子性

原子性是指一个操作在运行的环境中是不可被分割的或不可被中断的。操作的原子性是根据当前定义的范围而改变的,上下文不同则一个操作可能不是原子性的。

使一个操作变为原子操作取决于你想让它在哪个上下文中,如果上下文是没有并发的,则该代码是原子性的。

内存访问同步

程序中需要独占访问共享资源的部分叫做“临界区”,看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
var memoryAccess sync.Mutex
var value int

go func() {
memoryAccess.Lock()
value++
memoryAccess.Unlock()
}()

memoryAccess.Lock()
if value == 0 {
fmt.Printf("the value is %v.\n", value)
} else {
fmt.Printf("the value is %v.\n", value)
}
memoryAccess.Unlock()
}

这里我们添加了一个sync.Mutex类型,声明一下在哪个部分里应该独占value这个变量。如果想要访问value这个变量,就要首先调用Lock,当访问结束后,调用Unlock。当然,也可能造成维护和性能的问题。

defer关键字

defer代码块会在函数调用链表中增加一个函数调用。这个函数调用不是普通的函数调用,而是会在函数正常返回,也就是return之后添加一个函数调用。因此,defer通常用来释放函数内部变量。

当defer被声明时,其参数就会被实时解析

我们通过以下代码来解释这条规则:

1
2
3
4
5
6
func a() {
i := 0
defer fmt.Println(i)
i++
return
}

虽然我们在defer后面定义的是一个带变量的函数: fmt.Println(i). 但这个变量在defer被声明的时候,就已经确定其确定的值了。 换言之,上面的代码等同于下面的代码:
1
2
3
4
5
6
func a() {
i := 0
defer fmt.Println(0) //因为i=0,所以此时就明确告诉golang在程序退出时,执行输出0的操作
i++
return
}

为了更为明确的说明这个问题,我们继续定义一个defer:
1
2
3
4
5
6
7
func a() {
i := 0
defer fmt.Println(i) //输出0,因为i此时就是0
i++
defer fmt.Println(i) //输出1,因为i此时就是1
return
}

通过运行结果,可以看到defer输出的值,就是定义时的值。而不是defer真正执行时的变量值(很重要,搞不清楚的话就会产生于预期不一致的结果)

但为什么是先输出1,在输出0呢? 看下面的规则二。

defer执行顺序为先进后出

当同时定义了多个defer代码块时,golang安装先定义后执行的顺序依次调用defer。不要为什么,golang就是这么定义的。我们用下面的代码加深记忆和理解:

1
2
3
4
5
func b() {
for i := 0; i < 4; i++ {
defer fmt.Print(i)
}
}

在循环中,依次定义了四个defer代码块。结合规则一,我们可以明确得知每个defer代码块应该输出什么值。 安装先进后出的原则,我们可以看到依次输出了3210.

defer可以读取有名返回值

先看下面的代码:

1
2
3
4
func c() (i int) {
defer func() { i++ }()
return 1
}

输出结果是12. 在开头的时候,我们说过defer是在return调用之后才执行的。 这里需要明确的是defer代码块的作用域仍然在函数之内,结合上面的函数也就是说,defer的作用域仍然在c函数之内。因此defer仍然可以读取c函数内的变量(如果无法读取函数内变量,那又如何进行变量清除呢….)。

当执行return 1 之后,i的值就是1. 此时此刻,defer代码块开始执行,对i进行自增操作。 因此输出2.

掌握了defer以上三条使用规则,那么当我们遇到defer代码块时,就可以明确得知defer的预期结果。

死锁、活锁、饥饿

死锁是所有并发进程等待的程序,在这种情况下,如果没有外界干预,这个程序将无法恢复。

Coffman条件

出现死锁的条件有以下几个必要条件:

  • 相互排斥:并发进程同时拥有资源的独占权
  • 等待条件:并发进程必须同时拥有一个资源,并等待额外的资源
  • 没有抢占:并发进程拥有的资源只能被该进程释放
  • 循环等待:一个并发进程只能等待一系列其他并发进程,这些并发进程也在等待

活锁

正在主动执行并发操作的程序,但是无法向前推进程序的状态。看起来程序在工作。

饥饿

在任何情况下,并发进程欧步伐获得执行工作所需的所有资源。饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能地有效完成工作,或者阻止全部并发进程。

通信顺序进程

并行与并发

并行属于一个运行中的程序,并发属于代码。

并发哲学

CSP即Communicating Sequential Process,通信顺序进程。

Go的运行时自动将goroutine映射到系统的线程上,并管理调度,因此可以在像goroutine阻塞等待IO之类的事情上进行内省,从而智能的把OS的线程分配到没有阻塞的goroutine上。

如果有一块产生计算结果并想共享结果给其他代码块的代码,则需要传递数据的所有权。并发程序安全就是保证同时只有一个并发上下文拥有数据的所有权。通过channel类型解决,可以创建一个带缓存的channel实现低成本的在内存中的队列来解耦生产者和消费者。

使用channel时可以更简单的控制软件中出现的复杂性。

并发组件

goroutine

每个Go程序中都有至少一个goroutine: main goroutine。goroutine是一个并发的函数,在一个函数前添加go关键字来触发。匿名函数也行:

1
2
3
go func() {
fmt.Println("hello")
} ()

函数赋值也行:
1
2
3
4
5
sayhello := func() {
fmt.Println("hello")
}

go sayhello()

go中的goroutine是一个更高级别的抽象,称为协程,一中非抢占式的简单并发子程序,不能被中断,允许暂停或重入。Go的运行时会观察goroutine的运行时行为,并在它们阻塞时自动挂起它们,然后在它们不被阻塞时自动恢复它们。

go的主机托管机制是一个名为M:N调度器的实现。将M个绿色线程映射到N个OS线程,然后将goroutine安排在绿色线程上。

go遵循一个fork-join并发模型,将执行的子分支与其父节点同时运行,这些并发的执行分支将会在未来合并在一起。为了创建一个join点,必须对程序进行同步,这里可以通过sync.Watigroup实现。

在下边这个程序中,输出的是“world”,因此可以说明goroutine在它们所创建的相同地址空间内执行。

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var wg sync.WaitGroup
salutation := "hello"
wg.Add(1)
go func(){
defer wg.Done()
salutation = "world"
}()
wg.Wait()
fmt.Println(salutation)
}


可以以如下方式将参数传到函数中,以输出正确结果。
1
2
3
4
5
6
7
8
9
for _, salt := range []string{"hello", "greetings", "good day"} {
wg.Add(1)
go func(salt string) {
defer wg.Done()
fmt.Println(salt)
} (salt)
}
wg.Wait()

sync包

sync包包含了对低级别内存访问同步最有用的并发原语。

WaitGroup

可以调用Add表明n个goroutine已经开始了,使用defer关键字确保在goroutine退出之前执行Done操作。执行Wait操作将会阻塞main goroutine直到所有goroutine表明它们已经退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("1st goroutine sleeping")
time.Sleep(1)
} ()

wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("2nd goroutine sleeping")
time.Sleep(2)
}()

wg.Wait()
fmt.Println("All goroutine complete")

WaitGroup调用通过传入的整数执行Add操作增加计数器的增量,并调用Done递减,Wait阻塞,直到计数器为0.

互斥锁和读写锁

channel通过通信共享内存,而Mutex通过开发人员的约定同步访问共享内存。

Mutex有两个函数,Lock和Unlock,在defer中调用Unlock保证即使出现了panic,也可以及时调用Unlock,避免死锁。

进入和退出一个临界区是有开销的,所以要减少临界区的范围,可能存在多个并发进程之间共享内存,但这些进程不是都需要读写此内存,可以利用不同类型的互斥对象,sync.RWMutex。可以请求一个锁用于读或者写。

cond

cond是一个goroutine的集合点,等待或发布一个event,在这里一个event是两个或两个以上的goroutine之间的任意信号。

1
2
3
4
5
6
c := sync.NewCond(&sync.Mutex{})
c.L.Lock()
for conditionTrue() == false {
c.Wait()
}
c.L.Unlock()

上述代码实例化一个cond,NewCond创建一个类型,cond类型能够以一种并发安全的方式与其他goroutine协调。

Broadcast提供了同时与多个goroutine通信的方法,在Clicked Cond上调用Broadcast,则所有三个函数都将运行。它内部维护一个FIFO列表,等待接收信号,向所有等待的goroutine发送信号。

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
func main() {
type Button struct {
Clicked *sync.Cond
}

button := Button{ Clicked: sync.NewCond(&sync.Mutex{}) }
subscribe := func(c *sync.Cond, fn func()) {
var goroutineRunning sync.WaitGroup
goroutineRunning.Add(1)
go func(){
goroutineRunning.Done()
c.L.Lock()
defer c.L.Unlock()
c.Wait()
fn()
}()
goroutineRunning.Wait()
}

var clickRegistered sync.WaitGroup
clickRegistered.Add(3)
subscribe(button.Clicked, func() {
fmt.Println("Maximizing window")
clickRegistered.Done()
})
subscribe(button.Clicked, func() {
fmt.Println("Displaying annoying dialog box!")
clickRegistered.Done()
})
subscribe(button.Clicked, func() {
fmt.Println("Mouse clicked")
clickRegistered.Done()
})

button.Clicked.Broadcast()
clickRegistered.Wait()
}

once

sync.Once在内部调用一些原语,确保即使在不同的goroutine上也只会调用一次Do方法处理传进来的函数。

1
2
3
4
5
6
7
var count int
increment := func() {count++}
decrement := func() {count--}

var once sync.Once
once.Do(increment)
once.Do(decrement)

上述程序输出的是1,因为once只计算Do调用的次数,不管Do函数里边的参数是什么。

Pool模式是一种创建和提供可供使用的固定数量实例或Pool实例的方法,用于约束创建昂贵的场景,以便只创建固定数量的实例,但不确定数量的操作仍然可以请求访问这些场景。

Pool的主接口是Get方法,首先检查池中是否有可用的实例,如果没有则调用new方法创建一个,完成时调用者调用Put方法将实例归还。

Pool也用来尽可能快地将预先分配的对象缓存加载启动,通过提前加载获取引用到另一个对象所需的时间,来节省消费者的时间。

  • 实例化sync.Pool时,使用new方法创建一个成员变量,在调用时是线程安全的。
  • 收到来自Get的实例时,不要对接收的对象的状态做出任何假设。
  • 当你用完了从Pool中取出的对象时一定要调用Put否则Pool无法复用这个实例。
  • Pool内的分布大致均匀。

channel

channel充当着信息传送的管道,值可以沿着channel传递。

1
2
var dataStream chan interface{}
dataStream = make(chan interface{})

上面声明了一个新channel,因为声明的类型是空接口,所以类型是interface{},并且使用内置的make函数实例化channel。

声明一个单向channel只需包含“<-”,声明一个只能读取的channel,将“<-”放在左边:

1
2
var dataStream <-chan interface{}
dataStream = make(<-chan interface{})

声明一个只能发送的channel,则将“<-”放在右边。

通过将“<-”放到channel的右边实现发送操作,通过将“<-”放到channel的左边实现接收操作。另一种方法是数据流向箭头所指方向的变量。

1
2
3
4
5
stringStream := make(chan string)
go func(){
stringStream <- "hello"
}()
fmt.Println(<-stringStream)

上述代码实现了将字符串文本传递到stringStream channel并读取channel的字符串并打印到stdout。

可以从channel中获取,然后通过range遍历,并且在channel关闭时自动中断循环:

1
2
3
4
5
6
7
8
9
10
11
intStream := make(chan int)
go func() {
defer close(intStream)
for i:= 1; i <= 5; i ++ {
intStream <- i
}
}()
for integer := range intStream {
fmt.Printf("%v ",integer)
}

关闭channel也是一种同时给多个goroutine发信号的方法,如果有n个goroutine在一个channel上等待,而不是在channel上写n次来打开每个goroutine,可以简单地关闭channel。

更可以创建buffered channel,在实例化时提供容量。即使没有在channel上执行读取操作,goroutine仍然可以写入n次。

如果说channel是满的,那么写入channel阻塞。无缓冲的channel容量为0,因此在任何写入之前就已经满了,缓冲channel是一个内存中的FIFO队列,用于并发进程通信。

我们需要在正确的环境中配置channel,channel的所有者对channel拥有写访问视图,使用者只有读访问视图。拥有channel的goroutine应该:

  1. 实例化channel;
  2. 执行写操作,或将所有权传递给另一个goroutine;
  3. 关闭channel
  4. 通过只读channel将上述三件事暴露出来。

select

select是将channel绑定在一起的粘合剂,在一个系统中两个或多个组件的交集中,可以在本地、单个函数或类型以及全局范围内找到select语句绑定在一起的channel。

1
2
3
4
5
6
7
8
9
10
var c1, c2 <-chan interface{}
var c3 chan<- interface{}
select {
case <- c1:
....
case <- c2:
....
case <- c3:
....
}

如果多个channel是可用的,则执行伪随机选择,每一个都可能被执行到。如果没有任何channel可用,则我们需要使用time包中的超时机制,time.After。

GOMAXPROCS控制

这是runtime中的一个函数,这个函数控制的OS线程的数量将承载所谓的“工作队列”。runtime.GOMAXPROCS总是被设置成为主机上逻辑CPU的数量。

Go语言并发之道第4章

提示:interface{}可用于向函数传递任意类型的变量,但对于函数内部,该变量仍然为interface{}类型(空接口类型),

Go的并发模式

约束

约束是一种确保了信息只能从一个并发过程中获取到的简单且强大的方法,特定约束是指通过公约实现约束,词法约束涉及使用词法作用域仅公开用于多个并发进程的正确数据和并发原语。

for-select循环

1
2
3
4
5
6
for {
// 要不就无限循环,要不就使用range循环
select {
//使用channel作业
}
}

向channel发送迭代变量

1
2
3
4
5
6
7
for _, s := range []string{"a", "b", "c"} {
select {
case <- done:
return
case stringStream <- s:
}
}

循环等待停止

创建循环,无限直至停止。

1
2
3
4
5
6
7
8
for {
select {
case <- done:
return
default:
}
// 非抢占式任务
}

防止goroutine泄露

main goroutine可能会在其生命周期内将其他的goroutine设置为自旋,导致内存利用率下降。减轻这种情况的方法是在父goroutine和子goroutine之间建立一个信号,让父goroutine向其子goroutine发出信号通知。父goroutine将该channel发送给子goroutine,然后在想要取消子goroutine时关闭该channel。

确保:如果goroutine负责创建goroutine,那么它也负责确保可以停止goroutine。

or-channel

使用or-channel模式将多个channel组合起来。通过递归和goroutine创建一个符合done channel

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    var or func(channels ...<-chan interface{}) <-chan interface{}
or = func(channels ...<-chan interface{}) <-chan interface{} {
switch len(channels) {
case 0:
return nil
case 1:
return channels[0]
}

orDone := make(chan interface{})
go func() {
defer close()
switch len(channels):
case 2:
select{
case <-channels[0]:
case <-channels[1]:
case <-channels[1]:
case <-channels[2]:
}
}()
}
}

错误处理

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
type Result struct {
Error error
Response *http.Response
}

func main() {

checkStatus := func(
done <-chan interface{},
urls ...string,
) <-chan Result {
results := make(chan Result)
go func(){
defer close(results)
for _, url := range urls {
var result Result
resp, err := http.Get(url)
result = Result{Error: err, Response: resp}
select {
case <-done:
return
case results <-result:
}
}
}()
return results
}

done := make(chan interface{})
defer close(done)
urls := []string{"https://www.baidu.com", "https://badhost"}
for result := range checkStatus(done, urls...){
if result.Error != nil {
fmt.Printf("Error: %v.\n", result.Error)
continue
}
fmt.Printf("Response: %v.\n", result.Response.Status)
}
}

pipeline

一个stage是将数据输入,对其进行转换并将数据发回。

1
2
3
4
5
6
7
multiply := func(values []int, len(values)) []int {}
add := func(values []int, additive int) []int {}

ints := []int{1, 2, 3, 4}
for _, v := range add(multiply(ints, 2), 1) {
fmt.Println(v)
}

在range子句中结合加法和乘法,这样构建了一个具有pipeline stage的属性,组合形成pipeline。

pipeline stage的属性是:

  • 一个stage消耗并返回相同的类型;
  • 一个stage必须用语言来表达,以便可以被传递;

channel适合在Go中构建pipeline,可以接受和产生值,且可以安全的使用。

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
func main() {
generator := func(done <-chan interface{}, integers ...int) <-chan int {
intStream := make(chan int)
go func() {
defer close(intStream)
for _, i := range integers {
select {
case <-done:
return
case intStream <- i:
}
}
}()
return intStream
}

multiply := func(done <-chan interface{}, intStream <-chan int, multiplier int) <-chan int {
multipliedStream := make(chan int)
go func() {
defer close(multipliedStream)
for i := range intStream {
select {
case <-done:
return
case multipliedStream <- i * multiplier:
}
}
}()
return multipliedStream
}

add := func(done <-chan interface{}, intStream <-chan int, additive int) <-chan int {

addedStream := make(chan int)
go func() {
defer close(addedStream)
for i := range intStream {
select {
case <-done:
return
case addedStream <- i + additive:
}
}
}()
return addedStream
}

done := make(chan interface{})
defer close(done)

intStream := generator(done, 1, 2, 3, 4)
pipeline := multiply(done, add(done, multiply(done, intStream, 2), 1), 2)

for v := range pipeline {
fmt.Println(v)
}
}

挺有意思的,显示了流水线的操作。

generator接受一个可变的整数切片,构造一个缓存长度等于输入片段的整数channel,启动goroutine并返回构造的channel,将一组离散值转化成一个channel上的数据流。

扇入扇出

扇出是描述启动多个goroutine以处理来自pipeline的输入的过程;扇入是描述将多个结果组合到一个channel的过程中。

1
2
3
4
5
6
7
primeStream := primeFinder(done, randIntStream)

numFinders := runtime.NumCPU()
finders := make([]<-chan int, numFinders)
for i := 0; i < numFinders; i ++ {
finders[i] = primeFinder(done, randIntStream)
}

这里启动了stage的多个副本,有n个goroutine从随机数发生器中拉出并试图确定数字是否为素数。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
package pips

import (
"sync"
)

type PrimePip struct {
}

func NewPrimePip() *PrimePip {
primePip := &PrimePip{}
return primePip
}

func (primePip *PrimePip) RepeatFn(
done <-chan interface{},
fn func() interface{},
) <-chan interface{} {
valueStream := make(chan interface{})
go func() {
defer close(valueStream)
for {
select {
case <-done:
return
case valueStream <- fn():
}
}
}()
return valueStream
}

func (primePip *PrimePip) Take(
done <-chan interface{},
valueStream <-chan interface{},
num int,
) <-chan interface{} {
takeStream := make(chan interface{})
go func() {
defer close(takeStream)
for i := 0; i < num; i++ {
select {
case <-done:
return
case takeStream <- <-valueStream:
}
}
}()
return takeStream
}

func (primePip *PrimePip) ToInt(
done <-chan interface{},
valueStream <-chan interface{},
) <-chan int {
intStream := make(chan int)
go func() {
defer close(intStream)
for v := range valueStream {
select {
case <-done:
return
case intStream <- v.(int):
}
}
}()
return intStream
}

func (primePip *PrimePip) PrimeFinder(
done <-chan interface{},
intStream <-chan int,
) <-chan interface{} {
primeStream := make(chan interface{})
go func() {
defer close(primeStream)
for integer := range intStream {
integer -= 1
prime := true
for divisor := integer - 1; divisor > 1; divisor-- {
if integer%divisor == 0 {
prime = false
break
}
}

if prime {
select {
case <-done:
return
case primeStream <- integer:
}
}
}
}()
return primeStream
}

func (primePip *PrimePip) FanIn(
done <-chan interface{},
channels ...<-chan interface{},
) <-chan interface{} {
var wg sync.WaitGroup
multiplexedStream := make(chan interface{})

multiplexed := func(c <-chan interface{}) {
defer wg.Done()
for i := range c {
select {
case <-done:
return
case multiplexedStream <- i:
}
}
}

wg.Add(len(channels))
for _, c := range channels {
go multiplexed(c)
}

go func() {
wg.Wait()
close(multiplexedStream)
}()

return multiplexedStream
}

or-done-channel

用于处理来自系统各个分散部分的channel,需要用channel中的select语句来包装我们的读操作,并从已完成的channel中进行选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
orDone := func(done, c <-chan interface{}) <-chan interface{} {
valStream := make(chan interface{})
go func() {
defer close(valStream)
for {
select {
case <-done:
return
case v, ok := <-c:
if ok == false{
return
}
select {
case valStream <- v:
case <-done:
}
}
}
} ()
return valStream
}

tee-channel

分割一个来自channel的值,以便将他们发送到代码的两个独立区域。

队列排队

在队列尚未准备好的时候开始接受请求,只要stage完成了工作,就会把结果存放在一个稍后其他stage可以获取到的临时位置。

  • 在一个stage批处理请求节省时间
  • 如果stage中的延迟产生反馈回路进入系统。

context包

主要包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
var Canceled = errors.New("context canceled")
var DeadlineExceeded error = deadlineExceededError{}

type CancelFunc
type Context

func Background() Context
func TODO() Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

上下文包有两个目的:

  • 提供可以取消调用图中分支的API
  • 提供用于通过呼叫传输请求范围数据的数据包

context类型将是函数的第一个参数,此外,接收context的函数并不能取消它,这保护了调用堆栈上的函数被子函数取消上下文的情况。

上述context包中的函数都接收一个Context参数,并返回一个Context。WithCancel返回新Context,它在调用返回的cancel函数时关闭其done channel。WithDeadline返回一个新的Context,当机器的时钟超过给定的最后期限时,它关闭完成的channel。WithTimeout返回一个新的Context,它在给定的超时时间后关闭完成的channel。

如果函数以某种方式在调用图中取消它后面的函数,它将调用其中一个函数并传递给它的上下文,然后将返回的上下文传递给它的子元素,如果函数不需要修改取消行为,则只传递给定的上下文。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
func main() {
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wg.Add(1)

go func() {
defer wg.Done()
if err := printGreeting(ctx); err != nil {
fmt.Printf("cannot print greeting: %v\n", err)
cancel()
}
}()

wg.Add(1)
go func() {
defer wg.Done()
if err := printFarewell(ctx); err != nil {
fmt.Printf("cannot print greeting: %v\n", err)
cancel()
}
}()
wg.Wait()
}

func printGreeting(ctx context.Context) error {
greeting, err := genGreeting(ctx)
if err != nil {
return err
}
fmt.Printf("%s world!\n", greeting)
return nil
}

func printFarewell(ctx context.Context) error {
farewell, err := genFarewell(ctx)
if err != nil {
return err
}
fmt.Printf("%s world!\n", farewell)
return nil
}

func genGreeting(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()
switch locale, err := locale(ctx); {
case err != nil:
return "", err
case locale == "EN/US":
return "hello", nil
}
return "", fmt.Errorf("unsupported locale")
}

func genFarewell(ctx context.Context) (string, error) {
switch locale, err := locale(ctx); {
case err != nil:
return "", err
case locale == "EN/US":
return "godbye", nil
}
return "", fmt.Errorf("unsupported locale")
}

func locale(ctx context.Context) (string, error) {
if deadline, ok := ctx.Deadline(); ok {
if deadline.Sub(time.Now().Add(1*time.Minute)) <= 0 {
return "", context.DeadlineExceeded
}
}
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(1 * time.Minute):
}
return "EN/US", nil
}

上述程序允许locale函数快速失败,不必实际等待超时发生。

context包的另一个功能是用于存储和检索请求范围数据的Context数据包。

1
2
3
4
5
6
7
8
9
10
11
12
func ProcessRequest(userID, authToken string) {
ctx := context.WithValue(context.Background(), "userID", userID)
ctx = context.WithValue(ctx, "authToken", authToken)

HandleResponse(ctx)

}

func HandleResponse(ctx context.Context) {
fmt.Printf("handling response for %v (%v)\n", ctx.Value("userID"), ctx.Value("authToken"))
}

  • 我们使用的键值必须满足Go的可比性概念,即==和!=在使用时需要返回正确的结果。
  • 返回值必须安全,才能从多个goroutine访问

由于context的键和值都被定义为interface{},所以当试图检索值时,我们会失去Go的类型安全性,key可以是不同的类型,或者与我们提供的key略有不同。建议在软件包里定义一个自定义键类型:

1
2
3
4
5
6
7
8
type foo int
type bar int

m := make(map[interface{}] int)
m[foo(1)] = 1
m[bar(1)] = 1

fmt.Printf("%v", m)

输出为:

1
map[1:1, 2:2]

虽然基础值是相同的,但是科通通过不同的类型信息在map中区分它们。

Go语言并发之道第5章

异常传递

我们需要对传入的异常信息进行传递和处理,如:

1
2
3
4
5
6
7
8
9
func PostReport(id string) error {
result, err := lowlevel.DoWork()
if err != nil{
if _, ok := err.(lowlevel.Error); ok {
err = WrapErr(err, "cannot post report with id %q", id)
}
return err
}
}

在这里检查接收到的异常信息,确保结构良好,使用一个假设的函数将传入的异常和模块相关信息封装起来,并赋予一个新类型。

创建一个异常类型:

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
60
61
62
63
64
65
66
type MyError struct {
Inner error
Message string
StackTrace string
Misc map[string]interface{}
}

func wrapError(err error, messagef string, msgArgs ...interface{}) MyError {
return MyError{
Inner: err,
Message: fmt.Sprintf(messagef, msgArgs...),
StackTrace: "stack!!!",
Misc: make(map[string]interface{}),
}
}

func (err MyError) Error() string {
return err.Message
}

type LowLevelErr struct {
error
}
func isGloballyExec(path string) (bool, error) {
info, err := os.Stat(path)
if err != nil {
return false, LowLevelErr{(wrapError(err, err.Error()))}
}
return info.Mode().Perm()&0100 == 0100, nil
}

type IntermediateErr struct {
error
}

func runJob(id string) error {
const jobBinPath = "/bad/job/binary"
isExecutable, err := isGloballyExec(jobBinPath)

if err != nil {
return IntermediateErr{wrapError(err, "cannot run job %q: requisite binaries not available", id)}
} else if isExecutable == false {
return wrapError(nil, "job binary is not executable", id)
}

return exec.Command(jobBinPath, "--id="+id).Run()
}
func handleError(key int, err error, message string) {
log.SetPrefix(fmt.Sprintf("[logID: %v]: ", key))
log.Printf("%#v", err)
fmt.Printf("[%v] %v", key, message)
}

func main() {
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)
err := runJob("1")

if err != nil {
msg := "There was an unexpected issue; please report this as a bug."
if _, ok := err.(IntermediateErr); ok {
msg = err.Error()
}
handleError(1, err, msg)
}
}

超时和取消

有几个原因使我们需要支持超时:

  1. 系统饱和:希望超出的请求返回超时,而不是花很长时间等待响应。请求在超时时不太可能重复,或没有资源来存储请求,或者对系统响应或请求发送数据有时效性的要求时,需要超时操作。
  2. 陈旧的数据:数据通常有窗口期,如果并发进程处理数据需要的时间比这个窗口期长,则会想返回超时并取消并发进程。可以使用context.WithDeadline或者context.WithTimeout创建的context.Context传递给并发进程。
  3. 试图防止死锁:为了防止死锁,建议在所有并发操作中增加超时操作。

心跳

有两种不同的心跳:

  • 一段时间间隔内发出的心跳
  • 在工作单元开始时发出的心跳
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
func main() {
doWork := func(
done <-chan interface{},
pulseInterval time.Duration,
) (<-chan interface{}, <-chan time.Time) {

// 建立一个发送心跳的channel,返回给doWork

heartbeat := make(chan interface{})
results := make(chan time.Time)
go func() {
defer close(heartbeat)
defer close(results)

pulse := time.Tick(pulseInterval)
workGen := time.Tick(2 * pulseInterval)
// 设定心跳间隔
sendPulse := func() {
select {
case heartbeat <- struct{}{}:
default:
// 可能没有人接收心跳,所以加一个default
}
}
sendResult := func(r time.Time) {
for {
select {
case <-done:
return
case <-pulse:
sendPulse()
case results <- r:
return
}
}
}

for {
select {
case <-done:
return
case <-pulse:
sendPulse()
case r := <-workGen:
sendResult(r)
}
}
}()
return heartbeat, results
}

done := make(chan interface{})
time.AfterFunc(10*time.Second, func() { close(done) })

const timeout = 2 * time.Second
// 设置了超时时间
heartbeat, results := doWork(done, timeout/2)
// timeout/2 使我们的心跳有额外的响应时间

for {
select {
// 处理心跳,如果没有消息时,至少timeout/2后会从心跳channel发出一条消息
case _, ok := <-heartbeat:
if ok == false {
return
}
fmt.Println("pulse")
case r, ok := <-results:
if ok == false {
return
}
fmt.Printf("result %v\n", r.Second())
case <-time.After(timeout):
return
}
}
}

以下是每个工作单元开始之前发出的心跳

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
func main() {
doWork := func(
done <-chan interface{},
) (<-chan interface{}, <-chan int) {
heartbeatStream := make(chan interface{}, 1)
// 创建一个缓冲区大小为1的heartbeat channel,确保了即使没有及时接收发送消息也能发出一个心跳

workStream := make(chan int)
go func() {
defer close(heartbeatStream)
defer close(workStream)

for i := 0; i < 10; i++ {
select {
case heartbeatStream <- struct{}{}:
default:
}
select {
case <-done:
return
case workStream <- rand.Intn(10):
}
}
// 这里为心跳设置了单独的select块,将发送result和发送心跳分开,如果接收者没有准备好接受结果,作为替代它将收到一个心跳,而代表当前结果的值将会丢失。
// 为了防止没人接收心跳,增加了default,因为我们的heart channel创建时有一个缓冲区,所以如果有人正在监听暗示没有及时收到第一个心跳,接收者也可以收到心跳。
}()

return heartbeatStream, workStream
}

done := make(chan interface{})
defer close(done)

heartbeat, results := doWork(done)

for {
select {
case _, ok := <-heartbeat:
if ok == false {
return
} else {
fmt.Println("pulse")
}
case r, ok := <-results:
if ok == false {
return
} else {
fmt.Printf("result %v\n", r)
}
}
}
}

一些外部因素会导致goroutine花费更长的时间来进行第一次迭代,无论goroutine在调度上是否是第一位执行的。使用goroutine来解决这个问题。

复制请求

可以将请求分发到多个处理程序,其中一个将比其他处理程序返回更快,可以立即返回结果。

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
func main() {
doWork := func(
done <-chan interface{},
id int,
wg *sync.WaitGroup,
result chan<- int,
) {
started := time.Now()
defer wg.Done()

simulatedLoadTime := time.Duration(1+rand.Intn(5)) * time.Second
select {
case <-done:
case <-time.After(simulatedLoadTime):
}
select {
case <-done:
case result <- id:
}

took := time.Since(started)
if took < simulatedLoadTime {
took = simulatedLoadTime
}
fmt.Printf("%v took %v.\n", id, took)
}

done := make(chan interface{})
result := make(chan int)

var wg sync.WaitGroup
wg.Add(10)

for i := 0; i < 10; i++ {
go doWork(done, i, &wg, result)
}

firstReturned := <-result
close(done)

wg.Wait()

fmt.Printf("Received an answer from #%v.\n", firstReturned)
}

在这里我们启动了10个处理程序来处理请求,并获得了第一个返回值,如果得到了第一个返回值,则取消其它的处理程序,以保证不会做多余的工作。

速率限制

速率限制允许你将系统的性能和稳定性平衡在可控范围内。Go中大多数的限速是基于令牌算法的。

如果要访问资源,必须拥有资源的访问令牌,没有令牌的请求会被拒绝。假设令牌存储在一个等待被检索使用的桶中,桶的深度是d,表示一个桶可以容纳d个访问令牌。

每当需要访问资源时,都会在桶中删除一个令牌,请求必须排队等待直到有令牌可以用,或者被拒绝操作。将r定义为向桶中添加令牌的速率。只要用户拥有可用的令牌,集中的请求可能会使用户突破系统的可用范围。有些用户会间歇性访问系统,但是又想要尽可能快的获得结果,就会出现突发性的事件,只需要确保系统能同时处理所有用户的突发请求,或者在统计上不会有太多用户同时突发访问。

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
func Open() *APIConnection {
return &APIConnection{}
}

type APIConnection struct{}

func (a *APIConnection) ReadFile(ctx context.Context) error {
return nil
}

func (a *APIConnection) ResolveAddress(ctx context.Context) error {
return nil
}

func main() {
defer log.Printf("Done.")
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)

apiConnection := Open()
var wg sync.WaitGroup
wg.Add(20)

for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
err := apiConnection.ReadFile(context.Background())
if err != nil {
log.Printf("cannot ReadFile: %v", err)
}
log.Printf("ReadFile")
}()
}

for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
err := apiConnection.ResolveAddress(context.Background())
if err != nil {
log.Printf("cannot ResolveAddress: %v", err)
}
log.Printf("ResolveAddress")
}()
}

wg.Wait()
}

所有的API请求同时进行,没有进行限速,所以客户端可以自由访问系统,下面引入限速器,把限速器放在APIConnection中。这里用到了golang.org/x/time/rate包中的令牌桶限速器实现,具体安装如下:

golang.org/x包放到了https://github.com/golang/time.git中,下载时需要先在本地建立golang.org/x的目录后,再下载。

1
2
mkdir -p golang.org/x
git clone https://github.com/golang/time.git

我们使用了这个包的两个部分,分别是Limit类型和NewLimiter函数。Limit表示某个事件的最大频率,每秒事件数;NewLimiter返回一个新的Limit,允许事件速率为r,并允许最大为b的token。

rate包也包含一个辅助方法Every,将时间间隔转换为Limit。针对每次操作的间隔时间进行测量:

1
2
3
func Per(eventCount int, duration time.Duration) rate.Limit {
return rate.Every(duration / time.Duration(eventCount))
}

创建rate.Limiter后,使用它来阻塞我们的请求,直到获得访问令牌,使用Wait实现。
1
2
3
4
5
6
func (lim *Limiter) Wait(ctx context.Context) 
// Wait是WaitN(ctx, 1)的缩写
// WaitN会执行直到有n个事件发生,
// 如果n超过Limiter的突发大小,ctx被取消,或者逾期等待时间超过context的deadline,会返回一个错误

func (lim *Limiter) WaitN(ctx context.Context, n int) (err error)

修改后的APIConnection:

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

func Open() *APIConnection {
return &APIConnection{
rateLimiter: rate.NewLimiter(rate.Limit(1), 1),
}
}

type APIConnection struct {
rateLimiter *rate.Limiter
}

func (a *APIConnection) ReadFile(ctx context.Context) error {
if err := a.rateLimiter.Wait(ctx); err != nil {
return err
}
return nil
}

func (a *APIConnection) ResolveAddress(ctx context.Context) error {
if err := a.rateLimiter.Wait(ctx); err != nil {
return err
}
return nil
}

func main() {
defer log.Printf("Done.")
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)

apiConnection := Open()
var wg sync.WaitGroup
wg.Add(20)

for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
err := apiConnection.ReadFile(context.Background())
if err != nil {
log.Printf("cannot ReadFile: %v", err)
}
log.Printf("ReadFile")
}()
}

for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
err := apiConnection.ResolveAddress(context.Background())
if err != nil {
log.Printf("cannot ResolveAddress: %v", err)
}
log.Printf("ResolveAddress")
}()
}

wg.Wait()
}

这样实现了所有API连接的速率限制为每秒一次。

聚合限速器:

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
type RateLimiter interface {
Wait(context.Context) error
Limit() rate.Limit
}

func MultiLimiter(limiters ...RateLimiter) *multiLimiter {
byLimit := func(i, j int) bool {
return limiters[i].Limit() < limiters[j].Limit()
}
sort.Slice(limiters, byLimit)
return &multiLimiter{limiters: limiters}
}

type multiLimiter struct {
limiters []RateLimiter
}

func (l *multiLimiter) Wait(ctx context.Context) error {
for _, l := range l.limiters {
if err := l.Wait(ctx); err != nil {
return err
}
}
return nil
}

func (l *multiLimiter) Limit() rate.Limit {
return l.limiters[0].Limit()
}

定义了一个RateLimiter接口,使MultiLimiter可以递归定义其他的MultiLimiter实例,并且实现了一个优化,根据每个RateLimiter的Limit()排序,可以直接返回限制最多的限制器,这将是切片(slice)的第一个元素。

Wait犯法会遍历所有的子限速器,并调用Wait。

可以考虑增加对API请求的限制,对磁盘的限制:

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
func Open() *APIConnection {
return &APIConnection{
apiLimit: MultiLimiter(
rate.NewLimiter(Per(2, time.Second), 2)
rate.NewLimiter(Per(10, time.Minute), 10),
),
diskLimit: MultiLimiter(
rate.NewLimiter(rate.Limit(1), 1)
),
networkLimit: MultiLimiter(
rate.NewLimiter(Per(3, time.Second), 3),
),
}
}

func (a *APIConnection) ReadFile(ctx context.Context) error {
if err := MultiLimiter(a.apiLimit,a.diskLimit).Wait(ctx); err != nil {
return err
}
return nil
}

func (a *APIConnection) ResolveAddress(ctx context.Context) error {
if err := MultiLimiter(a.apiLimit,a.diskLimit).Wait(ctx); err != nil {
return err
}
return nil
}



上面为API调用和磁盘读取设置了限速器。

治愈异常的goroutine

建立一个机制来监控goroutine是否处于健康的状态,当它们变得异常时就可以尽快重启。需要使用心跳模式来检查正在监控的goroutine是否活跃,心跳的类型取决于想要监控的内容,如果goroutine有可能会产生活锁,需要确保心跳包含某些信息,表明goroutine正在工作而不是只是活着。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
type startGoroutineFn func(
done <-chan interface{},
pulseInterval time.Duration,
) (heartbeat <-chan interface{})
//定义一个可以监控和重启goroutine的信号。

func main() {
newSteward := func(
timeout time.Duration,
startGoroutine startGoroutineFn,
) startGoroutineFn {
return func(
done <-chan interface,
pulseInterval time.Duration,
// 监控goroutine需要timeout变量,一个函数startGoroutineFn表示管理员本身也是可监控的

) (<-chan interface{}) {
heartbeat :=make(chan interface{})
go func() {
defer close(heartbeat)
var wardDone chan interface{}
var wardHeartbeat <- chan interface{}

startWard := func() {
wardDone = make(chan interface{})
wardHeartbeat = startGoroutine(or(wardDone, done),timeout/2)
}
// 定义了一个闭包,实现了统一的方法来启动正在监视的goroutine
// 创建一个新的channel,如果需要发出停止信号则使用它传入goroutine
// 启动将要监控的goroutine,如果管理员被停止或者想要停止goroutine,希望这些信息能传给管理区的goroutine
// 所以使用了逻辑或来包装。

startWard()
pulse := time.Tick(pulseInterval)

monitorLoop:
for {
timeoutSignal := time.After(timeout)
for {
select {
case <-pulse:
select{
case heartbeat <- struct{}{}:
default:
}
case <-wardHeartbeat:
continue monitorLoop
case <-timeoutSignal:
log.Println("steward: ward unhealthy; restarting")
close(wardDone)
startWard()
continue monitorLoop
case <-done:
return
}
}
}
}()
return heartbeat
}
}

log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)

doWork := func(done <-chan interface{}, _ time.Duration) <-chan interface{} {
log.Println("ward: hello, I'm irresponsible!")
go func(){
<-done
log.Println("ward: I am halting")
}()
retunr nil
}
doWorkWithSteward := newSteward(4*time.Second, doWork)
// 超时时间是4s

done := make(chan interface{})
time.AfterFunc(9*time.Second, func(){
log.Println("main: halting steward and ward.")
close(done)
})
// 9s后停止管理员和goroutine

for range doWorkWithSteward(done, 4*time.Second) {}
log.Println("done")

}

管理区可以使用桥接channel模式向消费者提供公用的channel,避免中断,使用这些技术,管理区可以简单的通过组合各种模式变得任意复杂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
log.SetOutput(os.Stdout)
log.SetFlags(log.Ltime | log.LUTC)

done := make(chan interface{})
defer close(done)

doWork, intStream := doWorkFn(done, 1, 2, -1, 3, 4, 5)
// 创建管理区函数,允许结束可变整数切片,返回用来返回的流

doWorkWithSteward := newSteward(1*time.Millisecond, doWork)
// 创建管理员,监听doWork

doWorkWithSteward(done, 1*time.Hour)
// 启动管理区并开始监控

for intVal := range take(done, intStream, 6) {
fmt.Println("Received %v.\n", intVal)
}

Go语言并发之道第6章

goroutine和Go语言进行时

工作窃取

为了确保所有CPU有相同的使用率,可以在所有可用的处理器上平均分配负载。在实际使用过程中,基于朴素策略在处理器上分配任务可能会导致其中一个处理器利用率不足。不仅如此,还可能导致缓存的位置偏差,因为需要调用这些数据的任务跑在其他处理器上。

可以采取:工作任务加入队列中进行调度,处理器在有空闲的时候将任务出队,或者阻塞连接。这样引入了一个集中化的队列,所有的处理器都必须使用这个数据结构,每次想要入队或出队一个任务时继续要将这个队列加载到每个处理器的缓存中。

也可以拆分工作队列,给每个处理器一个独立线程和双端队列。

首先需要强调,Go遵循fork-join模型进行并发,在goroutine开始的时候fork,join点事两个或更多的goroutine通过channel或sync包中的类型进行同步。工作窃取算法对于给定线程:

  1. 在fork点,将任务添加到与线程相关的双端队列尾部;
  2. 如果线程空闲则随机选取一个线程,从它关联的双端队列头部窃取工作;
  3. 如果在未准备好的join点则将工作从线程的双端队列尾部出栈;
  4. 如果线程的双端队列是空的,则暂停加入或从随机线程关联的双端队列中窃取工作。

以下是计算fibonacci数列的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
var fib func(n int) <-chan int
fib = func(n int) <-chan int {
result := make(chan int)
go func() {
defer close(result)
if n <= 2 {
result <- 1
return
}
result <- <-fib(n-1) + <-fib(n-2)
}()
return result
}

fmt.Printf("fib(4) = %d.\n", <-fib(4))
}

首先只有一个goroutine,main goroutine,假设在处理器1上;接下来调用fib(4),这个goroutine被安排在T1的工作队列尾部,并且父goroutine将继续运行;此时根据时机不同,可能会发生T1或T2盗取调用fib(4)的goroutine,如果fib(4)在T1上,则在T1的工作队列上将添加fib(3)和fib(2)。

此时T2仍然是空闲的,所以从T1的队列头部取出fib(3)。此时fib(2)是fib(4)推入队列的最后一个任务,因此T1最有可能需要计算的第一个任务仍然在T1上!与此同时,由于在fib(3)和fib(2)返回的channel上等待着,T1不足以继续处理fib(4),它会自己从队列中出栈一个fib(2)。

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3)
fib(4)(等待join)
fib(2)

调用fib(3)的goroutine:

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3) fib(2)
fib(4)(等待join) fib(1)
fib(2)

T1到达了Fibonacci收敛处,返回1:

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3) fib(2)
fib(4)(等待join) fib(1)
fib(1)

T2到达了join点,并从其队列的尾部出栈一个任务:

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3)(等待join) fib(2)
fib(4)(等待join) fib(1)
return 1

T1又一次处于空闲所以从T2的队列中窃取工作:

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3)(等待join)
fib(4)(等待join) fib(1)
fib(2)

T2到达终点返回1:

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3)(等待join)
fib(4)(等待join) return 1
fib(2)

T1到达终点返回1:

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) fib(3)(等待join)
fib(4)(等待join) return 1
return 1

T2对fib(3)的调用现在有两个已完成的join点,fib(2)和fib(1)已经通过channel返回了结果,并且fib(3)产生的两个goroutine已经运行结束。

T1调用栈 T1工作队列 T2调用栈 T2工作队列
(main goroutine)(等待join) return 2
fib(4)(等待join)

fib(4)调用的goroutine有两个join点,fib(3)和fib(2),在T2最后一个任务结束时完成了fib(2)的join。执行加法,通过fib(4)的channel返回。

位于队列尾部的任务:

  1. 最有可能完成父进程join的任务
  2. 最有可能存在于处理器缓存中的任务

当一个线程到达join时,必须暂停等待回调以窃取任务。

Go中的调度器

G:goroutine

M:OS线程,在源代码中也称为机器

P:上下文,在源代码中也被称为处理器

在Go的运行时中,首先启动M,然后是P,最后是调度运行G。

正如之前说的,设置GOMAXPROCS可以控制运行时使用多少上下文。默认设置是主机上每个逻辑CPU分配一个上下文。并且总会有足够的系统线程可以用来处理每个上下文。这使运行时可以进行一些重要的优化。

如果一个goroutine被阻塞,管理goroutine的系统线程也会被阻塞,并且无法继续执行或切换到其他的goroutine。从性能上,Go会进行更多的处理以尽可能让机器上的处理器保持活跃,Go会从系统线程分离上下文,将上下文切换到另一个无阻塞的系统线程上。当goroutine阻塞最终结束时,主机系统线程会尝试使用一个其他系统线程来回退上下文,以便它可以继续执行先前被阻塞的goroutine。或者把它的goroutine放在全局上下文中然后线程进入休眠状态,并将其放入运行时的线程池以供将来使用。

竞争检测

在Go中为大多数命令增加了race参数。

竞争检测器可以自动检测代码中的竞态条件。

1
2
3
4
5
6
7
8
9
10
func main() {
var data int
go func() {
data++
}()
if data == 0 {
fmt.Printf("the value is %d.\n", data)
}
}

执行go run -race test19.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
the value is 0.
==================
WARNING: DATA RACE
Write at 0x00c0000200c8 by goroutine 6:
main.main.func1()
/home/yuhao/tool/go/test/test19.go:8 +0x4e

Previous read at 0x00c0000200c8 by main goroutine:
main.main()
/home/yuhao/tool/go/test/test19.go:10 +0x88

Goroutine 6 (running) created at:
main.main()
/home/yuhao/tool/go/test/test19.go:7 +0x7a
==================
Found 1 data race(s)
exit status 66

分别表示goroutine试图进行非同步内存写入,或者试图读取相同的内存。

原文:http://blog.jobbole.com/83461/

所谓元编程就是编写直接生成或操纵程序的程序,C++ 模板给 C++ 语言提供了元编程的能力,模板使 C++ 编程变得异常灵活,能实现很多高级动态语言才有的特性(语法上可能比较丑陋,一些历史原因见下文)。普通用户对 C++ 模板的使用可能不是很频繁,大致限于泛型编程,但一些系统级的代码,尤其是对通用性、性能要求极高的基础库(如 STL、Boost)几乎不可避免的都大量地使用 C++ 模板,一个稍有规模的大量使用模板的程序,不可避免的要涉及元编程(如类型计算)。本文就是要剖析 C++ 模板元编程的机制。

C++模板的语法

函数模板(function template)和类模板(class template)的简单示例如下:

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
#include <iostream>

// 函数模板
template<typename T>
bool equivalent(const T& a, const T& b){
return !(a < b) && !(b < a);
}
// 类模板
template<typename T=int> // 默认参数
class bignumber{
T _v;
public:
bignumber(T a) : _v(a) { }
inline bool operator<(const bignumber& b) const; // 等价于 (const bignumber<T> b)
};
// 在类模板外实现成员函数
template<typename T>
bool bignumber<T>::operator<(const bignumber& b) const{
return _v < b._v;
}

int main()
{
bignumber<> a(1), b(1); // 使用默认参数,"<>"不能省略
std::cout << equivalent(a, b) << '\n'; // 函数模板参数自动推导
std::cout << equivalent<double>(1, 2) << '\n';
std::cin.get(); return 0;
}

程序输出如下:
1
2
1
0

关于模板(函数模板、类模板)的模板参数:

  • 类型参数(type template parameter),用 typename 或 class 标记;
  • 非类型参数(non-type template parameter)可以是:整数及枚举类型、对象或函数的指针、对象或函数的引用、对象的成员指针,非类型参数是模板实例的常量;
  • 模板型参数(template template parameter),如template<typename T, template<typename> class A> someclass {};
  • 模板参数可以有默认值(函数模板参数默认是从 C++11 开始支持);
  • 函数模板的和函数参数类型有关的模板参数可以自动推导,类模板参数不存在推导机制;

模板特例化(template specialization,又称特例、特化)的简单示例如下:

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
// 实现一个向量类
template<typename T, int N>
class Vec{
T _v[N];
// ... // 模板通例(primary template),具体实现
};
template<>
class Vec<float, 4>{
float _v[4];
// ... // 对 Vec<float, 4> 进行专门实现,如利用向量指令进行加速
};
template<int N>
class Vec<bool, N>{
char _v[(N+sizeof(char)-1)/sizeof(char)];
// ... // 对 Vec<bool, N> 进行专门实现,如用一个比特位表示一个bool
};
template<typename T, int N>
class Vec{
T _v[N];
// ... // 模板通例(primary template),具体实现
};
template<>
class Vec<float, 4>{
float _v[4];
// ... // 对 Vec<float, 4> 进行专门实现,如利用向量指令进行加速
};
template<int N>
class Vec<bool, N>{
char _v[(N+sizeof(char)-1)/sizeof(char)];
// ... // 对 Vec<bool, N> 进行专门实现,如用一个比特位表示一个bool
};

所谓模板特例化即对于通例中的某种或某些情况做单独专门实现,最简单的情况是对每个模板参数指定一个具体值,这成为完全特例化(full specialization),另外,可以限制模板参数在一个范围取值或满足一定关系等,这称为部分特例化(partial specialization),用数学上集合的概念,通例模板参数所有可取的值组合构成全集U,完全特例化对U中某个元素进行专门定义,部分特例化对U的某个真子集进行专门定义。

更多模板特例化的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T, int i> class cp00; // 用于模板型模板参数
// 通例
template<typename T1, typename T2, int i, template<typename, int> class CP>
class TMP;
// 完全特例化
template<>
class TMP<int, float, 2, cp00>;
// 第一个参数有const修饰
template<typename T1, typename T2, int i, template<typename, int> class CP>
class TMP<const T1, T2, i, CP>;
// 第一二个参数为cp00的实例且满足一定关系,第四个参数为cp00
template<typename T, int i>
class TMP<cp00<T, i>, cp00<T, i+10>, i, cp00>;
// 编译错误!,第四个参数类型和通例类型不一致
//template<template<int i> CP>
//class TMP<int, float, 10, CP>;

关于模板特例化:

  • 在定义模板特例之前必须已经有模板通例(primary template)的声明;
  • 模板特例并不要求一定与通例有相同的接口,但为了方便使用(体会特例的语义)一般都相同;
  • 匹配规则,在模板实例化时如果有模板通例、特例加起来多个模板版本可以匹配,则依据如下规则:对版本AB,如果 A 的模板参数取值集合是B的真子集,则优先匹配 A,如果 AB 的模板参数取值集合是“交叉”关系(AB 交集不为空,且不为包含关系),则发生编译错误,对于函数模板,用函数重载分辨(overload resolution)规则和上述规则结合并优先匹配非模板函数。

对模板的多个实例,类型等价(type equivalence)判断规则:同一个模板(模板名及其参数类型列表构成的模板签名(template signature)相同,函数模板可以重载,类模板不存在重载)且指定的模板实参等价(类型参数是等价类型,非类型参数值相同)。如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
// 识别两个类型是否相同,提前进入模板元编程^_^
template<typename T1, typename T2> // 通例,返回 false
class theSameType { public: enum { ret = false }; };
template<typename T> // 特例,两类型相同时返回 true
class theSameType<T, T> { public: enum { ret = true }; };

template<typename T, int i> class aTMP { };

int main(){
typedef unsigned int uint; // typedef 定义类型别名而不是引入新类型
typedef uint uint2;
std::cout << theSameType<unsigned, uint2>::ret << '\n';
// 感谢 C++11,连续角括号“>>”不会被当做流输入符号而编译错误
std::cout << theSameType<aTMP<unsigned, 2>, aTMP<uint2, 2>>::ret << '\n';
std::cout << theSameType<aTMP<int, 2>, aTMP<int, 3>>::ret << '\n';
std::cin.get(); return 0;
}

1
2
3
1
1
0

关于模板实例化(template instantiation):

  • 指在编译或链接时生成函数模板或类模板的具体实例源代码,即用使用模板时的实参类型替换模板类型参数(还有非类型参数和模板型参数);
  • 隐式实例化(implicit instantiation):当使用实例化的模板时自动地在当前代码单元之前插入模板的实例化代码,模板的成员函数一直到引用时才被实例化;
  • 显式实例化(explicit instantiation):直接声明模板实例化,模板所有成员立即都被实例化;
  • 实例化也是一种特例化,被称为实例化的特例(instantiated (or generated) specialization)。

隐式实例化时,成员只有被引用到才会进行实例化,这被称为推迟实例化(lazy instantiation),由此可能带来的问题如下面的例子(文献[6],文献[7]):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

template<typename T>
class aTMP {
public:
void f1() { std::cout << "f1()\n"; }
void f2() { std::ccccout << "f2()\n"; } // 敲错键盘了,语义错误:没有 std::ccccout
};

int main(){
aTMP<int> a;
a.f1();
// a.f2(); // 这句代码被注释时,aTMP<int>::f2() 不被实例化,从而上面的错误被掩盖!
std::cin.get(); return 0;
}

所以模板代码写完后最好写个诸如显示实例化的测试代码,更深入一些,可以插入一些模板调用代码使得编译器及时发现错误,而不至于报出无限长的错误信息。另一个例子如下(GCC 4.8 下编译的输出信息,VS2013 编译输出了 500 多行错误信息):

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

// 计算 N 的阶乘 N!
template<int N>
class aTMP{
public:
enum { ret = N==0 ? 1 : N * aTMP<N-1>::ret }; // Lazy Instantiation,将产生无限递归!
};

int main(){
std::cout << aTMP<10>::ret << '\n';
std::cin.get(); return 0;
}

1
2
3
4
5
6
7
8
9
sh-4.2# g++ -std=c++11 -o main *.cpp
main.cpp:7:28: error: template instantiation depth exceeds maximum of 900 (use -ftemplate-depth= to increase the maximum) instantiating 'class aTMP<-890>'
enum { ret = N==0 ? 1 : N * aTMP<N-1>::ret };
^
main.cpp:7:28: recursively required from 'class aTMP<9>'
main.cpp:7:28: required from 'class aTMP<10>'
main.cpp:11:23: required from here

main.cpp:7:28: error: incomplete type 'aTMP<-890>' used in nested name specifier

上面的错误是因为,当编译aTMP<N>时,并不判断 N==0,而仅仅知道其依赖 aTMP(lazy instantiation),从而产生无限递归,纠正方法是使用模板特例化,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

// 计算 N 的阶乘 N!
template<int N>
class aTMP{
public:
enum { ret = N * aTMP<N-1>::ret };
};
template<>
class aTMP<0>{
public:
enum { ret = 1 };
};

int main(){
std::cout << aTMP<10>::ret << '\n';
std::cin.get(); return 0;
}

1
3228800

关于模板的编译和链接:

  • 包含模板编译模式:编译器生成每个编译单元中遇到的所有的模板实例,并存放在相应的目标文件中;链接器合并等价的模板实例,生成可执行文件,要求实例化时模板定义可见,不能使用系统链接器;
  • 分离模板编译模式(使用 export 关键字):不重复生成模板实例,编译器设计要求高,可以使用系统链接器;
  • 包含编译模式是主流,C++11 已经弃用 export 关键字(对模板引入 extern 新用法),一般将模板的全部实现代码放在同一个头文件中并在用到模板的地方用 #include 包含头文件,以防止出现实例不一致(如下面紧接着例子);

实例化,编译链接的简单例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// file: a.cpp
#include <iostream>
template<typename T>
class MyClass { };
template MyClass<double>::MyClass(); // 显示实例化构造函数 MyClass<double>::MyClass()
template class MyClass<long>; // 显示实例化整个类 MyClass<long>

template<typename T>
void print(T const& m) { std::cout << "a.cpp: " << m << '\n'; }

void fa() {
print(1); // print<int>,隐式实例化
print(0.1); // print<double>
}
void fb(); // fb() 在 b.cpp 中定义,此处声明

int main(){
fa();
fb();
std::cin.get(); return 0;
}

1
2
3
4
5
6
7
8
9
// file: b.cpp
#include <iostream>
template<typename T>
void print(T const& m) { std::cout << "b.cpp: " << m << '\n'; }

void fb() {
print('2'); // print<char>
print(0.1); // print<double>
}

1
2
3
4
a.cpp: 1
a.cpp: 0.1
b.cpp: 2
a.cpp: 0.1

上例中,由于 a.cpp 和 b.cpp 中的 print 实例等价(模板实例的二进制代码在编译生成的对象文件 a.obj、b.obj 中),故链接时消除了一个(消除哪个没有规定,上面消除了 b.cpp 中的)。

关于 template、typename、this 关键字的使用:

  • 依赖于模板参数(template parameter,形式参数,实参英文为 argument)的名字被称为依赖名字(dependent name),C++标准规定,如果解析器在一个模板中遇到一个嵌套依赖名字,它假定那个名字不是一个类型,除非显式用 typename 关键字前置修饰该名字;
  • 和上一条 typename 用法类似,template 用于指明嵌套类型或函数为模板;
    this 用于指定查找基类中的成员(当基类是依赖模板参数的类模板实例时,由于实例化总是推迟,这时不依赖模板参数的名字不在基类中查找)。

一个例子如下:

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
#include <iostream>

template<typename T>
class aTMP{
public: typedef const T reType;
};

void f() { std::cout << "global f()\n"; }

template<typename T>
class Base {
public:
template <int N = 99>
void f() { std::cout << "member f(): " << N << '\n'; }
};

template<typename T>
class Derived : public Base<T> {
public:
typename T::reType m; // typename 不能省略
Derived(typename T::reType a) : m(a) { }
void df1() { f(); } // 调用全局 f(),而非想象中的基类 f()
void df2() { this->template f(); } // 基类 f<99>()
void df3() { Base<T>::template f<22>(); } // 强制基类 f<22>()
void df4() { ::f(); } // 强制全局 f()
};

int main(){
Derived<aTMP<int>> a(10);
a.df1(); a.df2(); a.df3(); a.df4();
std::cin.get(); return 0;
}

1
2
3
4
global f()
member f(): 99
member f(): 22
global f()

C++11 关于模板的新特性:

  • “>>” 根据上下文自动识别正确语义;
  • 函数模板参数默认值;
  • 变长模板参数(扩展 sizeof…() 获取参数个数);
  • 模板别名(扩展 using 关键字);
  • 外部模板实例(拓展 extern 关键字),弃用 export template。

在本文中,如无特别声明将不使用 C++11 的特性(除了 “>>”)。

模板元编程概述

如果对 C++ 模板不熟悉(光熟悉语法还不算熟悉),可以先跳过本节,往下看完例子再回来。

C++ 模板最初是为实现泛型编程设计的,但人们发现模板的能力远远不止于那些设计的功能。一个重要的理论结论就是:C++ 模板是图灵完备的(Turing-complete),其证明过程请见文献[8](就是用 C++ 模板模拟图灵机),理论上说 C++ 模板可以执行任何计算任务,但实际上因为模板是编译期计算,其能力受到具体编译器实现的限制(如递归嵌套深度,C++11 要求至少 1024,C++98 要求至少 17)。C++ 模板元编程是“意外”功能,而不是设计的功能,这也是 C++ 模板元编程语法丑陋的根源。

C++ 模板是图灵完备的,这使得 C++ 成为两层次语言(two-level languages,中文暂且这么翻译,文献[9]),其中,执行编译计算的代码称为静态代码(static code),执行运行期计算的代码称为动态代码(dynamic code),C++ 的静态代码由模板实现(预处理的宏也算是能进行部分静态计算吧,也就是能进行部分元编程,称为宏元编程,见 Boost 元编程库即 BCCL,文献[16]和文献[1] 10.4)。

具体来说 C++ 模板可以做以下事情:编译期数值计算、类型计算、代码计算(如循环展开),其中数值计算实际不太有意义,而类型计算和代码计算可以使得代码更加通用,更加易用,性能更好(也更难阅读,更难调试,有时也会有代码膨胀问题)。编译期计算在编译过程中的位置请见下图(取自文献[10]),可以看到关键是模板的机制在编译具体代码(模板实例)前执行:

C++ 模板元编程

从编程范型(programming paradigm)上来说,C++ 模板是函数式编程(functional programming),它的主要特点是:函数调用不产生任何副作用(没有可变的存储),用递归形式实现循环结构的功能。C++ 模板的特例化提供了条件判断能力,而模板递归嵌套提供了循环的能力,这两点使得其具有和普通语言一样通用的能力(图灵完备性)。

从编程形式来看,模板的“<>”中的模板参数相当于函数调用的输入参数,模板中的 typedef 或 static const 或 enum 定义函数返回值(类型或数值,数值仅支持整型,如果需要可以通过编码计算浮点数),代码计算是通过类型计算进而选择类型的函数实现的(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
#include <iostream>

template<typename T, int i=1>
class someComputing {
public:
typedef volatile T* retType; // 类型计算
enum { retValume = i + someComputing<T, i-1>::retValume }; // 数值计算,递归
static void f() { std::cout << "someComputing: i=" << i << '\n'; }
};
template<typename T> // 模板特例,递归终止条件
class someComputing<T, 0> {
public:
enum { retValume = 0 };
};

template<typename T>
class codeComputing {
public:
static void f() { T::f(); } // 根据类型调用函数,代码计算
};

int main(){
someComputing<int>::retType a=0;
std::cout << sizeof(a) << '\n'; // 64-bit 程序指针
// VS2013 默认最大递归深度500,GCC4.8 默认最大递归深度900(-ftemplate-depth=n)
std::cout << someComputing<int, 500>::retValume << '\n'; // 1+2+...+500
codeComputing<someComputing<int, 99>>::f();
std::cin.get(); return 0;
}

1
2
3
8
125250
someComputing: i=99

编译期数值计算

第一个 C++ 模板元程序是 Erwin Unruh 在 1994 年写的(文献[14]),这个程序计算小于给定数 N 的全部素数(又叫质数),程序并不运行(都不能通过编译),而是让编译器在错误信息中显示结果(直观展现了是编译期计算结果,C++ 模板元编程不是设计的功能,更像是在戏弄编译器,当然 C++11 有所改变),由于年代久远,原来的程序用现在的编译器已经不能编译了,下面的代码在原来程序基础上稍作了修改(GCC 4.8 下使用 -fpermissvie,只显示警告信息):

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
// Prime number computation by Erwin Unruh
template<int i> struct D { D(void*); operator int(); }; // 构造函数参数为 void* 指针

template<int p, int i> struct is_prime { // 判断 p 是否为素数,即 p 不能整除 2...p-1
enum { prim = (p%i) && is_prime<(i>2?p:0), i-1>::prim };
};
template<> struct is_prime<0, 0> { enum { prim = 1 }; };
template<> struct is_prime<0, 1> { enum { prim = 1 }; };

template<int i> struct Prime_print {
Prime_print<i-1> a;
enum { prim = is_prime<i, i-1>::prim };
// prim 为真时, prim?1:0 为 1,int 到 D<i> 转换报错;假时, 0 为 NULL 指针不报错
void f() { D<i> d = prim?1:0; a.f(); } // 调用 a.f() 实例化 Prime_print<i-1>::f()
};
template<> struct Prime_print<2> { // 特例,递归终止
enum { prim = 1 };
void f() { D<2> d = prim?1:0; }
};

#ifndef LAST
#define LAST 10
#endif

int main() {
Prime_print<LAST> a; a.f(); // 必须调用 a.f() 以实例化 Prime_print<LAST>::f()
}

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
sh-4.2# g++ -std=c++11 -fpermissive -o main *.cpp
main.cpp: In member function 'void Prime_print<2>::f()':
main.cpp:17:33: warning: invalid conversion from 'int' to 'void*' [-fpermissive]
void f() { D<2> d = prim ? 1 : 0; }
^
main.cpp:2:28: warning: initializing argument 1 of 'D<i>::D(void*) [with int i = 2]' [-fpermissive]
template<int i> struct D { D(void*); operator int(); };
^
main.cpp: In instantiation of 'void Prime_print<i>::f() [with int i = 7]':
main.cpp:13:36: recursively required from 'void Prime_print<i>::f() [with int i = 9]'
main.cpp:13:36: required from 'void Prime_print<i>::f() [with int i = 10]'
main.cpp:25:27: required from here
main.cpp:13:33: warning: invalid conversion from 'int' to 'void*' [-fpermissive]
void f() { D<i> d = prim ? 1 : 0; a.f(); }
^
main.cpp:2:28: warning: initializing argument 1 of 'D<i>::D(void*) [with int i = 7]' [-fpermissive]
template<int i> struct D { D(void*); operator int(); };
^
main.cpp: In instantiation of 'void Prime_print<i>::f() [with int i = 5]':
main.cpp:13:36: recursively required from 'void Prime_print<i>::f() [with int i = 9]'
main.cpp:13:36: required from 'void Prime_print<i>::f() [with int i = 10]'
main.cpp:25:27: required from here
main.cpp:13:33: warning: invalid conversion from 'int' to 'void*' [-fpermissive]
void f() { D<i> d = prim ? 1 : 0; a.f(); }
^
main.cpp:2:28: warning: initializing argument 1 of 'D<i>::D(void*) [with int i = 5]' [-fpermissive]
template<int i> struct D { D(void*); operator int(); };
^
main.cpp: In instantiation of 'void Prime_print<i>::f() [with int i = 3]':
main.cpp:13:36: recursively required from 'void Prime_print<i>::f() [with int i = 9]'
main.cpp:13:36: required from 'void Prime_print<i>::f() [with int i = 10]'
main.cpp:25:27: required from here
main.cpp:13:33: warning: invalid conversion from 'int' to 'void*' [-fpermissive]
void f() { D<i> d = prim ? 1 : 0; a.f(); }
^
main.cpp:2:28: warning: initializing argument 1 of 'D<i>::D(void*) [with int i = 3]' [-fpermissive]
template<int i> struct D { D(void*); operator int(); };

上面的编译输出信息只给出了前一部分,虽然信息很杂,但还是可以看到其中有 10 以内全部素数:2、3、5、7(已经加粗显示关键行)。

到目前为止,虽然已经看到了阶乘、求和等递归数值计算,但都没涉及原理,下面以求和为例讲解 C++ 模板编译期数值计算的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 
template<int N>
class sumt{
public: static const int ret = sumt<N-1>::ret + N;
};
template<>
class sumt<0>{
public: static const int ret = 0;
};

int main() {
std::cout << sumt<5>::ret << '\n';
std::cin.get(); return 0;
}

1
15

当编译器遇到sumt<5>时,试图实例化之,sumt<5>引用了sumt<5-1>sumt<4>,试图实例化sumt<4>,以此类推,直到sumt<0>sumt<0>匹配模板特例,sumt<0>::ret为 0,sumt<1>::retsumt<0>::ret+1为 1,以此类推,sumt<5>::ret为 15。值得一提的是,虽然对用户来说程序只是输出了一个编译期常量sumt<5>::ret,但在背后,编译器其实至少处理了sumt<0>sumt<5>共 6 个类型。

从这个例子我们也可以窥探 C++ 模板元编程的函数式编程范型,对比结构化求和程序:for(i=0,sum=0; i<=N; ++i) sum+=i; 用逐步改变存储(即变量 sum)的方式来对计算过程进行编程,模板元程序没有可变的存储(都是编译期常量,是不可变的变量),要表达求和过程就要用很多个常量:sumt<0>::ret,sumt<1>::ret,…,sumt<5>::ret 。函数式编程看上去似乎效率低下(因为它和数学接近,而不是和硬件工作方式接近),但有自己的优势:描述问题更加简洁清晰(前提是熟悉这种方式),没有可变的变量就没有数据依赖,方便进行并行化。

模板下的控制结构

模板实现的条件 if 和 while 语句如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 通例为空,若不匹配特例将报错,很好的调试手段(这里是 bool 就无所谓了)
template<bool c, typename Then, typename Else> class IF_ { };
template<typename Then, typename Else>
class IF_<true, Then, Else> { public: typedef Then reType; };
template<typename Then, typename Else>
class IF_<false,Then, Else> { public: typedef Else reType; };

// 隐含要求: Condition 返回值 ret,Statement 有类型 Next
template<template<typename> class Condition, typename Statement>
class WHILE_ {
template<typename Statement> class STOP { public: typedef Statement reType; };
public:
typedef typename
IF_<Condition<Statement>::ret,
WHILE_<Condition, typename Statement::Next>,
STOP<Statement>>::reType::reType
reType;
};

IF_<> 的使用示例见下面:

1
2
3
4
5
6
7
8
9
const int len = 4;
typedef
IF_<sizeof(short)==len, short,
IF_<sizeof(int)==len, int,
IF_<sizeof(long)==len, long,
IF_<sizeof(long long)==len, long long,
void>::reType>::reType>::reType>::reType
int_my; // 定义一个指定字节数的类型
std::cout << sizeof(int_my) << '\n';

1
4

WHILE_<> 的使用示例见下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 计算 1^e+2^e+...+n^e
template<int n, int e>
class sum_pow {
template<int i, int e> class pow_e{ public: enum{ ret=i*pow_e<i,e-1>::ret }; };
template<int i> class pow_e<i,0>{ public: enum{ ret=1 }; };
// 计算 i^e,嵌套类使得能够定义嵌套模板元函数,private 访问控制隐藏实现细节
template<int i> class pow{ public: enum{ ret=pow_e<i,e>::ret }; };
template<typename stat>
class cond { public: enum{ ret=(stat::ri<=n) }; };
template<int i, int sum>
class stat { public: typedef stat<i+1, sum+pow<i>::ret> Next;
enum{ ri=i, ret=sum }; };
public:
enum{ ret = WHILE_<cond, stat<1,0>>::reType::ret };
};

int main() {
std::cout << sum_pow<10, 2>::ret << '\n';
std::cin.get(); return 0;
}

1
385

为了展现编译期数值计算的强大能力,下面是一个更复杂的计算:最大公约数(Greatest Common Divisor,GCD)和最小公倍数(Lowest Common Multiple,LCM),经典的辗转相除算法:
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
// 最小公倍数,普通函数
int lcm(int a, int b){
int r, lcm=a*b;
while(r=a%b) { a = b; b = r; } // 因为用可变的存储,不能写成 a=b; b=a%b;
return lcm/b;
}
// 递归函数版本
int gcd_r(int a, int b) { return b==0 ? a : gcd_r(b, a%b); } // 简洁
int lcm_r(int a, int b) { return a * b / gcd_r(a,b); }

// 模板版本
template<int a, int b>
class lcm_T{
template<typename stat>
class cond { public: enum{ ret=(stat::div!=0) }; };
template<int a, int b>
class stat { public: typedef stat<b, a%b> Next; enum{ div=a%b, ret=b }; };
static const int gcd = WHILE_<cond, stat<a,b>>::reType::ret;
public:
static const int ret = a * b / gcd;
};
// 递归模板版本
template<int a, int b>
class lcm_T_r{
template<int a, int b> class gcd { public: enum{ ret = gcd<b,a%b>::ret }; };
template<int a> class gcd<a, 0> { public: enum{ ret = a }; };
public:
static const int ret = a * b / gcd<a,b>::ret;
};

int main() {
std::cout << lcm(100, 36) << '\n';
std::cout << lcm_r(100, 36) << '\n';
std::cout << lcm_T<100, 36>::ret << '\n';
std::cout << lcm_T_r<100, 36>::ret << '\n';
std::cin.get(); return 0;
}

1
2
3
4
900
900
900
900

上面例子中,定义一个类的整型常量,可以用 enum,也可以用 static const int,需要注意的是 enum 定义的常量的字节数不会超过 sizeof(int) (文献[2])。

循环展开

文献[11]展示了一个循环展开(loop unrolling)的例子 — 冒泡排序:

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
#include <utility>  // std::swap

// dynamic code, 普通函数版本
void bubbleSort(int* data, int n)
{
for(int i=n-1; i>0; --i) {
for(int j=0; j<i; ++j)
if (data[j]>data[j+1]) std::swap(data[j], data[j+1]);
}
}
// 数据长度为 4 时,手动循环展开
inline void bubbleSort4(int* data)
{
#define COMP_SWAP(i, j) if(data[i]>data[j]) std::swap(data[i], data[j])
COMP_SWAP(0, 1); COMP_SWAP(1, 2); COMP_SWAP(2, 3);
COMP_SWAP(0, 1); COMP_SWAP(1, 2);
COMP_SWAP(0, 1);
}

// 递归函数版本,指导模板思路,最后一个参数是哑参数(dummy parameter),仅为分辨重载函数
class recursion { };
void bubbleSort(int* data, int n, recursion)
{
if(n<=1) return;
for(int j=0; j<n-1; ++j) if(data[j]>data[j+1]) std::swap(data[j], data[j+1]);
bubbleSort(data, n-1, recursion());
}

// static code, 模板元编程版本
template<int i, int j>
inline void IntSwap(int* data) { // 比较和交换两个相邻元素
if(data[i]>data[j]) std::swap(data[i], data[j]);
}

template<int i, int j>
inline void IntBubbleSortLoop(int* data) { // 一次冒泡,将前 i 个元素中最大的置换到最后
IntSwap<j, j+1>(data);
IntBubbleSortLoop<j<i-1?i:0, j<i-1?(j+1):0>(data);
}
template<>
inline void IntBubbleSortLoop<0, 0>(int*) { }

template<int n>
inline void IntBubbleSort(int* data) { // 模板冒泡排序循环展开
IntBubbleSortLoop<n-1, 0>(data);
IntBubbleSort<n-1>(data);
}
template<>
inline void IntBubbleSort<1>(int* data) { }

对循环次数固定且比较小的循环语句,对其进行展开并内联可以避免函数调用以及执行循环语句中的分支,从而可以提高性能,对上述代码做如下测试,代码在 VS2013 的 Release 下编译运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <omp.h>
#include <string.h> // memcpy

int main() {
double t1, t2, t3; const int num=100000000;
int data[4]; int inidata[4]={3,4,2,1};
t1 = omp_get_wtime();
for(int i=0; i<num; ++i) { memcpy(data, inidata, 4); bubbleSort(data, 4); }
t1 = omp_get_wtime()-t1;
t2 = omp_get_wtime();
for(int i=0; i<num; ++i) { memcpy(data, inidata, 4); bubbleSort4(data); }
t2 = omp_get_wtime()-t2;
t3 = omp_get_wtime();
for(int i=0; i<num; ++i) { memcpy(data, inidata, 4); IntBubbleSort<4>(data); }
t3 = omp_get_wtime()-t3;
std::cout << t1/t3 << '\t' << t2/t3 << '\n';
std::cin.get(); return 0;
}

1
2.38643 0.926521

上述结果表明,模板元编程实现的循环展开能够达到和手动循环展开相近的性能(90% 以上),并且性能是循环版本的 2 倍多(如果扣除 memcpy 函数占据的部分加速比将更高,根据 Amdahl 定律)。这里可能有人会想,既然循环次数固定,为什么不直接手动循环展开呢,难道就为了使用模板吗?当然不是,有时候循环次数确实是编译期固定值,但对用户并不是固定的,比如要实现数学上向量计算的类,因为可能是 2、3、4 维,所以写成模板,把维度作为 int 型模板参数,这时因为不知道具体是几维的也就不得不用循环,不过因为维度信息在模板实例化时是编译期常量且较小,所以编译器很可能在代码优化时进行循环展开,但我们想让这一切发生的更可控一些。

上面用三个函数模板 IntSwap<>()、 IntBubbleSortLoop<>()、 IntBubbleSort<>() 来实现一个排序功能,不但显得分散(和封装原理不符),还暴露了实现细节,我们可以仿照上一节的代码,将 IntBubbleSortLoop<>()、 IntBubbleSort<>() 嵌入其他模板内部,因为函数不允许嵌套,我们只能用类模板:

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
// 整合成一个类模板实现,看着好,但引入了 代码膨胀
template<int n>
class IntBubbleSortC {
template<int i, int j>
static inline void IntSwap(int* data) { // 比较和交换两个相邻元素
if(data[i]>data[j]) std::swap(data[i], data[j]);
}
template<int i, int j>
static inline void IntBubbleSortLoop(int* data) { // 一次冒泡
IntSwap<j, j+1>(data);
IntBubbleSortLoop<j<i-1?i:0, j<i-1?(j+1):0>(data);
}
template<>
static inline void IntBubbleSortLoop<0, 0>(int*) { }
public:
static inline void sort(int* data) {
IntBubbleSortLoop<n-1, 0>(data);
IntBubbleSortC<n-1>::sort(data);
}
};
template<>
class IntBubbleSortC<0> {
public:
static inline void sort(int* data) { }
};

int main() {
int data[4] = {3,4,2,1};
IntBubbleSortC<4>::sort(data); // 如此调用
std::cin.get(); return 0;
}

上面代码看似很好,不仅整合了代码,借助类成员的访问控制,还隐藏了实现细节。不过它存在着很大问题,如果实例化 IntBubbleSortC<4>、 IntBubbleSortC<3>、 IntBubbleSortC<2>,将实例化成员函数 IntBubbleSortC<4>::IntSwap<0, 1>()、 IntBubbleSortC<4>::IntSwap<1, 2>()、 IntBubbleSortC<4>::IntSwap<2, 3>()、 IntBubbleSortC<3>::IntSwap<0, 1>()、 IntBubbleSortC<3>::IntSwap<1, 2>()、 IntBubbleSortC<2>::IntSwap<0, 1>(),而在原来的看着分散的代码中 IntSwap<0, 1>() 只有一个。这将导致代码膨胀(code bloat),即生成的可执行文件体积变大(代码膨胀另一含义是源代码增大,见文献[1]第11章)。不过这里使用了内联(inline),如果编译器确实内联展开代码则不会导致代码膨胀(除了循环展开本身会带来的代码膨胀),但因为重复编译原本可以复用的模板实例,会增加编译时间。在上一节的例子中,因为只涉及编译期常量计算,并不涉及函数(函数模板,或类模板的成员函数,函数被编译成具体的机器二进制代码),并不会出现代码膨胀。

为了清晰证明上面的论述,我们去掉所有 inline 并将函数实现放到类外面(类里面实现的成员函数都是内联的,因为函数实现可能被包含多次,见文献[2] 10.2.9,不过现在的编译器优化能力很强,很多时候加不加 inline 并不影响编译器自己对内联的选择…),分别编译分散版本和类模板封装版本的冒泡排序代码编译生成的目标文件(VS2013 下是 .obj 文件)的大小,代码均在 VS2013 Debug 模式下编译(防止编译器优化),比较 main.obj (源文件是 main.cpp)大小。

类模板封装版本代码如下,注意将成员函数在外面定义的写法:

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
#include <iostream>
#include <utility> // std::swap

// 整合成一个类模板实现,看着好,但引入了 代码膨胀
template<int n>
class IntBubbleSortC {
template<int i, int j> static void IntSwap(int* data);
template<int i, int j> static void IntBubbleSortLoop(int* data);
template<> static void IntBubbleSortLoop<0, 0>(int*) { }
public:
static void sort(int* data);
};
template<>
class IntBubbleSortC<0> {
public:
static void sort(int* data) { }
};

template<int n> template<int i, int j>
void IntBubbleSortC<n>::IntSwap(int* data) {
if(data[i]>data[j]) std::swap(data[i], data[j]);
}
template<int n> template<int i, int j>
void IntBubbleSortC<n>::IntBubbleSortLoop(int* data) {
IntSwap<j, j+1>(data);
IntBubbleSortLoop<j<i-1?i:0, j<i-1?(j+1):0>(data);
}
template<int n>
void IntBubbleSortC<n>::sort(int* data) {
IntBubbleSortLoop<n-1, 0>(data);
IntBubbleSortC<n-1>::sort(data);
}

int main() {
int data[40] = {3,4,2,1};
IntBubbleSortC<2>::sort(data); IntBubbleSortC<3>::sort(data);
IntBubbleSortC<4>::sort(data); IntBubbleSortC<5>::sort(data);
IntBubbleSortC<6>::sort(data); IntBubbleSortC<7>::sort(data);
IntBubbleSortC<8>::sort(data); IntBubbleSortC<9>::sort(data);
IntBubbleSortC<10>::sort(data); IntBubbleSortC<11>::sort(data);
#if 0
IntBubbleSortC<12>::sort(data); IntBubbleSortC<13>::sort(data);
IntBubbleSortC<14>::sort(data); IntBubbleSortC<15>::sort(data);
IntBubbleSortC<16>::sort(data); IntBubbleSortC<17>::sort(data);
IntBubbleSortC<18>::sort(data); IntBubbleSortC<19>::sort(data);
IntBubbleSortC<20>::sort(data); IntBubbleSortC<21>::sort(data);

IntBubbleSortC<22>::sort(data); IntBubbleSortC<23>::sort(data);
IntBubbleSortC<24>::sort(data); IntBubbleSortC<25>::sort(data);
IntBubbleSortC<26>::sort(data); IntBubbleSortC<27>::sort(data);
IntBubbleSortC<28>::sort(data); IntBubbleSortC<29>::sort(data);
IntBubbleSortC<30>::sort(data); IntBubbleSortC<31>::sort(data);
#endif
std::cin.get(); return 0;
}

分散定义函数模板版本代码如下,为了更具可比性,也将函数放在类里面作为成员函数:

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
60
61
62
63
64
#include <iostream>
#include <utility> // std::swap

// static code, 模板元编程版本
template<int i, int j>
class IntSwap {
public: static void swap(int* data);
};

template<int i, int j>
class IntBubbleSortLoop {
public: static void loop(int* data);
};
template<>
class IntBubbleSortLoop<0, 0> {
public: static void loop(int* data) { }
};

template<int n>
class IntBubbleSort {
public: static void sort(int* data);
};
template<>
class IntBubbleSort<0> {
public: static void sort(int* data) { }
};

template<int i, int j>
void IntSwap<i, j>::swap(int* data) {
if(data[i]>data[j]) std::swap(data[i], data[j]);
}
template<int i, int j>
void IntBubbleSortLoop<i, j>::loop(int* data) {
IntSwap<j, j+1>::swap(data);
IntBubbleSortLoop<j<i-1?i:0, j<i-1?(j+1):0>::loop(data);
}
template<int n>
void IntBubbleSort<n>::sort(int* data) {
IntBubbleSortLoop<n-1, 0>::loop(data);
IntBubbleSort<n-1>::sort(data);
}

int main() {
int data[40] = {3,4,2,1};
IntBubbleSort<2>::sort(data); IntBubbleSort<3>::sort(data);
IntBubbleSort<4>::sort(data); IntBubbleSort<5>::sort(data);
IntBubbleSort<6>::sort(data); IntBubbleSort<7>::sort(data);
IntBubbleSort<8>::sort(data); IntBubbleSort<9>::sort(data);
IntBubbleSort<10>::sort(data); IntBubbleSort<11>::sort(data);
#if 0
IntBubbleSort<12>::sort(data); IntBubbleSort<13>::sort(data);
IntBubbleSort<14>::sort(data); IntBubbleSort<15>::sort(data);
IntBubbleSort<16>::sort(data); IntBubbleSort<17>::sort(data);
IntBubbleSort<18>::sort(data); IntBubbleSort<19>::sort(data);
IntBubbleSort<20>::sort(data); IntBubbleSort<21>::sort(data);

IntBubbleSort<22>::sort(data); IntBubbleSort<23>::sort(data);
IntBubbleSort<24>::sort(data); IntBubbleSort<25>::sort(data);
IntBubbleSort<26>::sort(data); IntBubbleSort<27>::sort(data);
IntBubbleSort<28>::sort(data); IntBubbleSort<29>::sort(data);
IntBubbleSort<30>::sort(data); IntBubbleSort<31>::sort(data);
#endif
std::cin.get(); return 0;
}

程序中条件编译都未打开时(#if 0),main.obj 大小分别为 264 KB 和 211 KB,条件编译打开时(#if 1),main.obj 大小分别为 1073 KB 和 620 KB。可以看到,类模板封装版的对象文件不但绝对大小更大,而且增长更快,这和之前分析是一致的。

表达式模板,向量运算

文献[12]展示了一个表达式模板(Expression Templates)的例子:

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include <iostream> // std::cout
#include <cmath> // std::sqrt()

// 表达式类型
class DExprLiteral { // 文字量
double a_;
public:
DExprLiteral(double a) : a_(a) { }
double operator()(double x) const { return a_; }
};
class DExprIdentity { // 自变量
public:
double operator()(double x) const { return x; }
};
template<class A, class B, class Op> // 双目操作
class DBinExprOp {
A a_; B b_;
public:
DBinExprOp(const A& a, const B& b) : a_(a), b_(b) { }
double operator()(double x) const { return Op::apply(a_(x), b_(x)); }
};
template<class A, class Op> // 单目操作
class DUnaryExprOp {
A a_;
public:
DUnaryExprOp(const A& a) : a_(a) { }
double operator()(double x) const { return Op::apply(a_(x)); }
};
// 表达式
template<class A>
class DExpr {
A a_;
public:
DExpr() { }
DExpr(const A& a) : a_(a) { }
double operator()(double x) const { return a_(x); }
};

// 运算符,模板参数 A、B 为参与运算的表达式类型
// operator /, division
class DApDiv { public: static double apply(double a, double b) { return a / b; } };
template<class A, class B> DExpr<DBinExprOp<DExpr<A>, DExpr<B>, DApDiv> >
operator/(const DExpr<A>& a, const DExpr<B>& b) {
typedef DBinExprOp<DExpr<A>, DExpr<B>, DApDiv> ExprT;
return DExpr<ExprT>(ExprT(a, b));
}
// operator +, addition
class DApAdd { public: static double apply(double a, double b) { return a + b; } };
template<class A, class B> DExpr<DBinExprOp<DExpr<A>, DExpr<B>, DApAdd> >
operator+(const DExpr<A>& a, const DExpr<B>& b) {
typedef DBinExprOp<DExpr<A>, DExpr<B>, DApAdd> ExprT;
return DExpr<ExprT>(ExprT(a, b));
}
// sqrt(), square rooting
class DApSqrt { public: static double apply(double a) { return std::sqrt(a); } };
template<class A> DExpr<DUnaryExprOp<DExpr<A>, DApSqrt> >
sqrt(const DExpr<A>& a) {
typedef DUnaryExprOp<DExpr<A>, DApSqrt> ExprT;
return DExpr<ExprT>(ExprT(a));
}
// operator-, negative sign
class DApNeg { public: static double apply(double a) { return -a; } };
template<class A> DExpr<DUnaryExprOp<DExpr<A>, DApNeg> >
operator-(const DExpr<A>& a) {
typedef DUnaryExprOp<DExpr<A>, DApNeg> ExprT;
return DExpr<ExprT>(ExprT(a));
}

// evaluate()
template<class Expr>
void evaluate(const DExpr<Expr>& expr, double start, double end, double step) {
for(double i=start; i<end; i+=step) std::cout << expr(i) << ' ';
}

int main() {
DExpr<DExprIdentity> x;
evaluate( -x / sqrt( DExpr<DExprLiteral>(1.0) + x ) , 0.0, 10.0, 1.0);
std::cin.get(); return 0;
}

1
-0 -0.707107 -1.1547 -1.5 -1.78885 -2.04124 -2.26779 -2.47487 -2.66667 -2.84605

代码有点长(我已经尽量压缩行数),请先看最下面的 main() 函数,表达式模板允许我们以 “-x / sqrt( 1.0 + x )” 这种类似数学表达式的方式传参数,在 evaluate() 内部,将 0-10 的数依次赋给自变量 x 对表达式进行求值,这是通过在 template<> DExpr 类模板内部重载 operator() 实现的。我们来看看这一切是如何发生的。

在 main() 中调用 evaluate() 时,编译器根据全局重载的加号、sqrt、除号、负号推断“-x / sqrt( 1.0 + x )” 的类型是 Dexpr, DApNeg>>, Dexpr, Dexpr, DApAdd>>, DApSqrt>>, DApDiv>>(即将每个表达式编码到一种类型,设这个类型为 ultimateExprType),并用此类型实例化函数模板 evaluate(),类型的推导见下图。在 evaluate() 中,对表达式进行求值 expr(i),调用 ultimateExprType 的 operator(),这引起一系列的 operator() 和 Op::apply() 的调用,最终遇到基础类型 “表达式类型” DExprLiteral 和 DExprIdentity,这个过程见下图。总结就是,请看下图,从下到上类型推断,从上到下 operator() 表达式求值。

表达式模板,Expression Templates

上面代码函数实现写在类的内部,即内联,如果编译器对内联支持的好的话,上面代码几乎等价于如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream> // std::cout
#include <cmath> // std::sqrt()

void evaluate(double start, double end, double step) {
double _temp = 1.0;
for(double i=start; i<end; i+=step)
std::cout << -i / std::sqrt(_temp + i) << ' ';
}

int main() {
evaluate(0.0, 10.0, 1.0);
std::cin.get(); return 0;
}

1
-0 -0.707107 -1.1547 -1.5 -1.78885 -2.04124 -2.26779 -2.47487 -2.66667 -2.84605

和表达式模板类似的技术还可以用到向量计算中,以避免产生临时向量变量,见文献[4] Expression templates 和文献[12]的后面。传统向量计算如下:

1
2
3
4
5
6
7
8
9
class DoubleVec; // DoubleVec 重载了 + - * / 等向量元素之间的计算
DoubleVec y(1000), a(1000), b(1000), c(1000), d(1000); // 向量长度 1000
// 向量计算
y = (a + b) / (c - d);
// 等价于
DoubleVec __t1 = a + b;
DoubleVec __t2 = c - d;
DoubleVec __t3 = __t1 / __t2;
y = __t3;

模板代码实现向量计算如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class A> DVExpr;
class DVec{
// ...
template<class A>
DVec& operator=(const DVExpr<A>&); // 由 = 引起向量逐个元素的表达式值计算并赋值
};
DVec y(1000), a(1000), b(1000), c(1000), d(1000); // 向量长度 1000
// 向量计算
y = (a + b) / (c - d);
// 等价于
for(int i=0; i<1000; ++i) {
y[i] = (a[i] + b[i]) / (c[i] + d[i]);
}

不过值得一提的是,传统代码可以用 C++11 的右值引用提升性能,C++11 新特性我们以后再详细讨论。

我们这里看下文献[4] Expression templates 实现的版本,它用到了编译期多态,编译期多态示意代码如下(关于这种代码形式有个名字叫 curiously recurring template pattern, CRTP,见文献[4]):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 模板基类,定义接口,具体实现由模板参数,即子类实现
template <typename D>
class base {
public:
void f1() { static_cast<E&>(*this).f1(); } // 直接调用子类实现
int f2() const { static_cast<const E&>(*this).f1(); }
};
// 子类
class dirived1 : public base<dirived1> {
public:
void f1() { /* ... */ }
int f2() const { /* ... */ }
};
template<typename T>
class dirived2 : public base<dirived2<T>> {
public:
void f1() { /* ... */ }
int f2() const { /* ... */ }
};

简化后(向量长度固定为1000,元素类型为 double)的向量计算代码如下:

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
#include <iostream> // std::cout

// A CRTP base class for Vecs with a size and indexing:
template <typename E>
class VecExpr {
public:
double operator[](int i) const { return static_cast<E const&>(*this)[i]; }
operator E const&() const { return static_cast<const E&>(*this); } // 向下类型转换
};
// The actual Vec class:
class Vec : public VecExpr<Vec> {
double _data[1000];
public:
double& operator[](int i) { return _data[i]; }
double operator[](int i) const { return _data[i]; }
template <typename E>
Vec const& operator=(VecExpr<E> const& vec) {
E const& v = vec;
for (int i = 0; i<1000; ++i) _data[i] = v[i];
return *this;
}
// Constructors
Vec() { }
Vec(double v) { for(int i=0; i<1000; ++i) _data[i] = v; }
};

template <typename E1, typename E2>
class VecDifference : public VecExpr<VecDifference<E1, E2> > {
E1 const& _u; E2 const& _v;
public:
VecDifference(VecExpr<E1> const& u, VecExpr<E2> const& v) : _u(u), _v(v) { }
double operator[](int i) const { return _u[i] - _v[i]; }
};
template <typename E>
class VecScaled : public VecExpr<VecScaled<E> > {
double _alpha; E const& _v;
public:
VecScaled(double alpha, VecExpr<E> const& v) : _alpha(alpha), _v(v) { }
double operator[](int i) const { return _alpha * _v[i]; }
};

// Now we can overload operators:
template <typename E1, typename E2> VecDifference<E1, E2> const
operator-(VecExpr<E1> const& u, VecExpr<E2> const& v) {
return VecDifference<E1, E2>(u, v);
}
template <typename E> VecScaled<E> const
operator*(double alpha, VecExpr<E> const& v) {
return VecScaled<E>(alpha, v);
}

int main() {
Vec u(3), v(1); double alpha=9; Vec y;
y = alpha*(u - v);
std::cout << y[999] << '\n';
std::cin.get(); return 0;
}

1
18

这里可以看到基类的作用:提供统一的接口,让 operator- 和 operator* 可以写成统一的模板形式。

特性,策略,标签

利用迭代器,我们可以实现很多通用算法,迭代器在容器与算法之间搭建了一座桥梁。求和函数模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream> // std::cout
#include <vector>

template<typename iter>
typename iter::value_type mysum(iter begin, iter end) {
typename iter::value_type sum(0);
for(iter i=begin; i!=end; ++i) sum += *i;
return sum;
}

int main() {
std::vector<int> v;
for(int i = 0; i<100; ++i) v.push_back(i);
std::cout << mysum(v.begin(), v.end()) << '\n';
std::cin.get(); return 0;
}

1
4950

我们想让 mysum() 对指针参数也能工作,毕竟迭代器就是模拟指针,但指针没有嵌套类型 value_type,可以定义 mysum() 对指针类型的特例,但更好的办法是在函数参数和 value_type 之间多加一层 — 特性(traits)(参考了文献[1]第72页,特性详见文献[1] 12.1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 特性,traits
template<typename iter>
class mytraits{
public: typedef typename iter::value_type value_type;
};
template<typename T>
class mytraits<T*>{
public: typedef T value_type;
};

template<typename iter>
typename mytraits<iter>::value_type mysum(iter begin, iter end) {
typename mytraits<iter>::value_type sum(0);
for(iter i=begin; i!=end; ++i) sum += *i;
return sum;
}

int main() {
int v[4] = {1,2,3,4};
std::cout << mysum(v, v+4) << '\n';
std::cin.get(); return 0;
}

1
10

其实,C++ 标准定义了类似的 traits:std::iterator_trait(另一个经典例子是 std::numeric_limits) 。特性对类型的信息(如 value_type、 reference)进行包装,使得上层代码可以以统一的接口访问这些信息。C++ 模板元编程会涉及大量的类型计算,很多时候要提取类型的信息(typedef、 常量值等),如果这些类型的信息的访问方式不一致(如上面的迭代器和指针),我们将不得不定义特例,这会导致大量重复代码的出现(另一种代码膨胀),而通过加一层特性可以很好的解决这一问题。另外,特性不仅可以对类型的信息进行包装,还可以提供更多信息,当然,因为加了一层,也带来复杂性。特性是一种提供元信息的手段。

策略(policy)一般是一个类模板,典型的策略是 STL 容器(如 std::vector<>,完整声明是template> class vector;)的分配器(这个参数有默认参数,即默认存储策略),策略类将模板的经常变化的那一部分子功能块集中起来作为模板参数,这样模板便可以更为通用,这和特性的思想是类似的(详见文献[1] 12.3)。

标签(tag)一般是一个空类,其作用是作为一个独一无二的类型名字用于标记一些东西,典型的例子是 STL 迭代器的五种类型的名字(input_iterator_tag, output_iterator_tag, forward_iterator_tag, bidirectional_iterator_tag, random_access_iterator_tag),std::vector::iterator::iterator_category 就是 random_access_iterator_tag,可以用第1节判断类型是否等价的模板检测这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
#include <vector>

template<typename T1, typename T2> // 通例,返回 false
class theSameType { public: enum { ret = false }; };
template<typename T> // 特例,两类型相同时返回 true
class theSameType<T, T> { public: enum { ret = true }; };

int main(){
std::cout << theSameType< std::vector<int>::iterator::iterator_category,
std::random_access_iterator_tag >::ret << '\n';
std::cin.get(); return 0;
}

1
1

有了这样的判断,还可以根据判断结果做更复杂的元编程逻辑(如一个算法以迭代器为参数,根据迭代器标签进行特例化以对某种迭代器特殊处理)。标签还可以用来分辨函数重载,第5节中就用到了这样的标签(recursion)(标签详见文献[1] 12.1)。

更多类型计算

在第1节我们讲类型等价的时候,已经见到了一个可以判断两个类型是否等价的模板,这一节我们给出更多例子,下面是判断一个类型是否可以隐式转换到另一个类型的模板(参考了文献[6] Static interface checking):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream> // std::cout

// whether T could be converted to U
template<class T, class U>
class ConversionTo {
typedef char Type1[1]; // 两种 sizeof 不同的类型
typedef char Type2[2];
static Type1& Test( U ); // 较下面的函数,因为参数取值范围小,优先匹配
static Type2& Test(...); // 变长参数函数,可以匹配任何数量任何类型参数
static T MakeT(); // 返回类型 T,用这个函数而不用 T() 因为 T 可能没有默认构造函数
public:
enum { ret = sizeof(Test(MakeT()))==sizeof(Type1) }; // 可以转换时调用返回 Type1 的 Test()
};

int main() {
std::cout << ConversionTo<int, double>::ret << '\n';
std::cout << ConversionTo<float, int*>::ret << '\n';
std::cout << ConversionTo<const int&, int&>::ret << '\n';
std::cin.get(); return 0;
}

1
2
3
1
0
0

下面这个例子检查某个类型是否含有某个嵌套类型定义(参考了文献[4] Substitution failure is not an erro (SFINAE)),这个例子是个内省(反射的一种):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <vector>

// thanks to Substitution failure is not an erro (SFINAE)
template<typename T>
struct has_typedef_value_type {
typedef char Type1[1];
typedef char Type2[2];
template<typename C> static Type1& test(typename C::value_type*);
template<typename> static Type2& test(...);
public:
static const bool ret = sizeof(test<T>(0)) == sizeof(Type1); // 0 == NULL
};

struct foo { typedef float lalala; };

int main() {
std::cout << has_typedef_value_type<std::vector<int>>::ret << '\n';
std::cout << has_typedef_value_type<foo>::ret << '\n';
std::cin.get(); return 0;
}

1
2
1
0

这个例子是有缺陷的,因为不存在引用的指针,所以不用用来检测引用类型定义。可以看到,因为只涉及类型推断,都是编译期的计算,不涉及任何可执行代码,所以类的成员函数根本不需要具体实现。

元容器

文献[1]第 13 章讲了元容器,所谓元容器,就是类似于 std::vector<> 那样的容器,不过它存储的是元数据 — 类型,有了元容器,我们就可以判断某个类型是否属于某个元容器之类的操作。

在讲元容器之前,我们先来看看伪变长参数模板,一个可以存储小于某个数(例子中为 4 个)的任意个数,任意类型数据的元组(tuple)的例子如下:

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
60
61
62
63
64
65
66
67
68
69
70
#include <iostream>

class null_type {}; // 标签类,标记参数列表末尾
template<typename T0, typename T1, typename T2, typename T3>
class type_shift_node {
public:
typedef T0 data_type;
typedef type_shift_node<T1, T2, T3, null_type> next_type; // 参数移位了
static const int num = next_type::num + 1; // 非 null_type 模板参数个数
data_type data; // 本节点数据
next_type next; // 后续所有节点数据
type_shift_node() :data(), next() { } // 构造函数
type_shift_node(T0 const& d0, T1 const& d1, T2 const& d2, T3 const& d3)
:data(d0), next(d1, d2, d3, null_type()) { } // next 参数也移位了
};
template<typename T0> // 特例,递归终止
class type_shift_node<T0, null_type, null_type, null_type> {
public:
typedef T0 data_type;
static const int num = 1;
data_type data; // 本节点数据
type_shift_node() :data(), next() { } // 构造函数
type_shift_node(T0 const& d0, null_type, null_type, null_type) : data(d0) { }
};
// 元组类模板,默认参数 + 嵌套递归
template<typename T0, typename T1=null_type, typename T2=null_type,
typename T3=null_type>
class my_tuple {
public:
typedef type_shift_node<T0, T1, T2, T3> tuple_type;
static const int num = tuple_type::num;
tuple_type t;
my_tuple(T0 const& d0=T0(),T1 const& d1=T1(),T2 const& d2=T2(),T3 const& d3=T3())
: t(d0, d1, d2, d3) { } // 构造函数,默认参数
};

// 为方便访问元组数据,定义 get<unsigned>(tuple) 函数模板
template<unsigned i, typename T0, typename T1, typename T2, typename T3>
class type_shift_node_traits {
public:
typedef typename
type_shift_node_traits<i-1,T0,T1,T2,T3>::node_type::next_type node_type;
typedef typename node_type::data_type data_type;
static node_type& get_node(type_shift_node<T0,T1,T2,T3>& node)
{ return type_shift_node_traits<i-1,T0,T1,T2,T3>::get_node(node).next; }
};
template<typename T0, typename T1, typename T2, typename T3>
class type_shift_node_traits<0, T0, T1, T2, T3> {
public:
typedef typename type_shift_node<T0,T1,T2,T3> node_type;
typedef typename node_type::data_type data_type;
static node_type& get_node(type_shift_node<T0,T1,T2,T3>& node)
{ return node; }
};
template<unsigned i, typename T0, typename T1, typename T2, typename T3>
typename type_shift_node_traits<i,T0,T1,T2,T3>::data_type
get(my_tuple<T0,T1,T2,T3>& tup) {
return type_shift_node_traits<i,T0,T1,T2,T3>::get_node(tup.t).data;
}

int main(){
typedef my_tuple<int, char, float> tuple3;
tuple3 t3(10, 'm', 1.2f);
std::cout << t3.t.data << ' '
<< t3.t.next.data << ' '
<< t3.t.next.next.data << '\n';
std::cout << tuple3::num << '\n';
std::cout << get<2>(t3) << '\n'; // 从 0 开始,不要出现 3,否则将出现不可理解的编译错误
std::cin.get(); return 0;
}

1
2
3
10 m 1.2
3
1.2

C++11 引入了变长模板参数,其背后的原理也是模板递归(文献[1]第 230 页)。

利用和上面例子类似的模板参数移位递归的原理,我们可以构造一个存储“类型”的元组,即元容器,其代码如下(和文献[1]第 237 页的例子不同):

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
60
61
62
63
64
65
66
67
68
69
70
71
#include <iostream>

// 元容器
template<typename T0=void, typename T1=void, typename T2=void, typename T3=void>
class meta_container {
public:
typedef T0 type;
typedef meta_container<T1, T2, T3, void> next_node; // 参数移位了
static const int size = next_node::size + 1; // 非 null_type 模板参数个数
};
template<> // 特例,递归终止
class meta_container<void, void, void, void> {
public:
typedef void type;
static const int size = 0;
};

// 访问元容器中的数据
template<typename C, unsigned i>
class get {
public:
static_assert(i<C::size, "get<C,i>: index exceed num"); // C++11 引入静态断言
typedef typename get<C,i-1>::c_type::next_node c_type;
typedef typename c_type::type ret_type;
};
template<typename C>
class get<C, 0> {
public:
static_assert(0<C::size, "get<C,i>: index exceed num"); // C++11 引入静态断言
typedef C c_type;
typedef typename c_type::type ret_type;
};

// 在元容器中查找某个类型,找到返回索引,找不到返回 -1
template<typename T1, typename T2> class same_type { public: enum { ret = false }; };
template<typename T> class same_type<T, T> { public: enum { ret = true }; };

template<bool c, typename Then, typename Else> class IF_ { };
template<typename Then, typename Else>
class IF_<true, Then, Else> { public: typedef Then reType; };
template<typename Then, typename Else>
class IF_<false, Then, Else> { public: typedef Else reType; };

template<typename C, typename T>
class find {
template<int i> class number { public: static const int ret = i; };
template<typename C, typename T, int i>
class find_i {
public:
static const int ret = IF_< same_type<get<C,i>::ret_type, T>::ret,
number<i>, find_i<C,T,i-1> >::reType::ret;
};
template<typename C, typename T>
class find_i<C, T, -1> {
public:
static const int ret = -1;
};
public:
static const int ret = find_i<C, T, C::size-1>::ret;
};

int main(){
typedef meta_container<int, int&, const int> mc;
int a = 9999;
get<mc, 1>::ret_type aref = a;
std::cout << mc::size << '\n';
std::cout << aref << '\n';
std::cout << find<mc, const int>::ret << '\n';
std::cout << find<mc, float>::ret << '\n';
std::cin.get(); return 0;
}

1
2
3
4
3
9999
2
-1

上面例子已经实现了存储类型的元容器,和元容器上的查找算法,但还有一个小问题,就是它不能处理模板,编译器对模板的操纵能力远不如对类型的操纵能力强(提示:类模板实例是类型),我们可以一种间接方式实现存储“模板元素”,即用模板的一个代表实例(如全用 int 为参数的实例)来代表这个模板,这样对任意模板实例,只需判断其模板的代表实例是否在容器中即可,这需要进行类型过滤:对任意模板的实例将其替换为指定模板参数的代表实例,类型过滤实例代码如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/ 类型过滤,meta_filter 使用时只用一个参数,设置四个模板参数是因为,模板通例的参数列表
// 必须能够包含特例参数列表,后面三个参数设置默认值为 void 或标签模板
template<typename T> class dummy_template_1 {};
template<typename T0, typename T1> class dummy_template_2 {};
template<typename T0, typename T1 = void,
template<typename> class tmp_1 = dummy_template_1,
template<typename, typename> class tmp_2 = dummy_template_2>
class meta_filter { // 通例,不改变类型
public:
typedef T0 ret_type;
};
// 匹配任何带有一个类型参数模板的实例,将模板实例替换为代表实例
template<template<typename> class tmp_1, typename T>
class meta_filter<tmp_1<T>, void, dummy_template_1, dummy_template_2> {
public:
typedef tmp_1<int> ret_type;
};
// 匹配任何带有两个类型参数模板的实例,将模板实例替换为代表实例
template<template<typename, typename> class tmp_2, typename T0, typename T1>
class meta_filter<tmp_2<T0, T1>, void, dummy_template_1, dummy_template_2> {
public:
typedef tmp_2<int, int> ret_type;
};

现在,只需将上面元容器和元容器查找函数修改为:对模板实例将其换为代表实例,即修改 meta_container<> 通例中“typedef T0 type;”语句为“typedef typename meta_filter::ret_type type;”,修改 find<> 的最后一行中“T”为“typename meta_filter::ret_type”。修改后,下面代码的执行结果是:

1
2
3
4
5
6
template<typename, typename> class my_tmp_2;

// 自动将 my_tmp_2<float, int> 过滤为 my_tmp_2<int, int>
typedef meta_container<int, float, my_tmp_2<float, int>> mc2;
// 自动将 my_tmp_2<char, double> 过滤为 my_tmp_2<int, int>
std::cout << find<mc2, my_tmp_2<char, double>>::ret << '\n'; // 输出 2
1
2

模版与特化的概念

函数模版与类模版

C++中模板分为函数模板和类模板

  • 函数模板:是一种抽象函数定义,它代表一类同构函数。
  • 类模板:是一种更高层次的抽象的类定义。

特化的概念

所谓特化,就是将泛型搞得具体化一些,从字面上来解释,就是为已有的模板参数进行一些使其特殊化的指定,使得以前不受任何约束的模板参数,或受到特定的修饰(例如const或者摇身一变成为了指针之类的东东,甚至是经过别的模板类包装之后的模板类型)或完全被指定了下来。

模板特化的分类

针对特化的对象不同,分为两类:函数模板的特化和类模板的特化

函数模板的特化

当函数模板需要对某些类型进行特化处理,称为函数模板的特化。

类模板的特化

当类模板内需要对某些类型进行特别处理时,使用类模板的特化。

特化整体上分为全特化和偏特化

全特化

就是模板中模板参数全被指定为确定的类型。

全特化也就是定义了一个全新的类型,全特化的类中的函数可以与模板类不一样。

偏特化

就是模板中的模板参数没有被全部确定,需要编译器在编译时进行确定。

全特化的标志就是产生出完全确定的东西,而不是还需要在编译期间去搜寻适合的特化实现,貌似在我的这种理解下,全特化的 东西不论是类还是函数都有这样的特点,

  1. 模板函数只能全特化,没有偏特化(以后可能有)。
  2. 模板类是可以全特化和偏特化的。

template <>然后是完全和模板类型没有一点关系的类实现或者函数定义,如果你要说,都完全确定下来了,那还搞什么模板呀,直接定义不就完事了?

但是很多时候,我们既需要一个模板能应对各种情形,又需要它对于某个特定的类型(比如bool)有着特别的处理,这种情形下特化就是需要的了。

全特化的标志:template <>然后是完全和模板类型没有一点关系的类实现或者函数定义
偏特化的标志:template

函数模版特化

目前的标准中,模板函数只能全特化,没有偏特化

至于为什么函数不能偏特化,似乎不是因为语言实现不了,而是因为偏特化的功能可以通过函数的重载完成。

函数模版的特化技巧

函数模板的特化:当函数模板需要对某些类型进行特别处理,称为函数模板的特化。

例如,我们编写了一个泛化的比较程序

1
2
3
4
5
6
template <class T>
int compare(const T &left, const T&right)
{
std::cout <<"in template<class T>..." <<std::endl;
return (left - right);
}

这个函数满足我们的需求了么,显然不,它支持常见int, float等类型的数据的比较,但是不支持char*(string)类型。

所以我们必须对其进行特化,以让它支持两个字符串的比较,因此我们实现了如下的特化函数。

1
2
3
4
5
6
7
template < >
int compare<const char*>(const char* left, const char* right)
{
std::cout <<"in special template< >..." <<std::endl;

return strcmp(left, right);
}

也可以

1
2
3
4
5
6
7
template < >
int compare(const char* left, const char* right)
{
std::cout <<"in special template< >..." <<std::endl;

return strcmp(left, right);
}

示例程序1–比较两个数据

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
#include <iostream>
#include <cstring>

/// 模版特化

template <class T>
int compare(const T left, const T right)
{
std::cout <<"in template<class T>..." <<std::endl;
return (left - right);
}


// 这个是一个特化的函数模版
template < >
int compare<const char*>(const char* left, const char* right)
{
std::cout <<"in special template< >..." <<std::endl;

return strcmp(left, right);
}
// 特化的函数模版, 两个特化的模版本质相同, 因此编译器会报错
// error: redefinition of 'int compare(T, T) [with T = const char*]'|
//template < >
//int compare(const char* left, const char* right)
//{
// std::cout <<"in special template< >..." <<std::endl;
//
// return strcmp(left, right);
//}


// 这个其实本质是函数重载
int compare(char* left, char* right)
{
std::cout <<"in overload function..." <<std::endl;

return strcmp(left, right);
}

int main( )
{
compare(1, 4);

const char *left = "gatieme";
const char *right = "jeancheng";
compare(left, right);

return 0;
}

函数模版的特化,当函数调用发现有特化后的匹配函数时,会优先调用特化的函数,而不再通过函数模版来进行实例化。

示例程序二-判断两个数据是否相等

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
#include <iostream>
#include <cstring>

using namespace std;
//函数模板
template<class T>
bool IsEqual(T t1,T t2){
return t1==t2;
}

template<> //函数模板特化
bool IsEqual(char *t1,char *t2){
return strcmp(t1,t2)==0;
}

int main(int argc, char* argv[])
{
char str1[]="abc";
char str2[]="abc";
cout<<"函数模板和函数模板特化"<<endl;
cout<<IsEqual(1,1)<<endl;
cout<<IsEqual(str1,str2)<<endl;

return 0;
}

类模版特化

类模板的特化:与函数模板类似,当类模板内需要对某些类型进行特别处理时,使用类模板的特化。例如:

这里归纳了针对一个模板参数的类模板特化的几种类型

一是特化为绝对类型;

二是特化为引用,指针类型;

三是特化为另外一个类模板。

这里用一个简单的例子来说明这三种情况:

特化为绝对类型

也就是说直接为某个特定类型做特化,这是我们最常见的一种特化方式, 如特化为float, double等

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
#include <iostream>
#include <cstring>
#include <cmath>
// general version
template<class T>
class Compare
{
public:
static bool IsEqual(const T& lh, const T& rh)
{
std::cout <<"in the general class..." <<std::endl;
return lh == rh;
}
};


// specialize for float
template<>
class Compare<float>
{
public:
static bool IsEqual(const float& lh, const float& rh)
{
std::cout <<"in the float special class..." <<std::endl;

return std::abs(lh - rh) < 10e-3;
}
};

// specialize for double
template<>
class Compare<double>
{
public:
static bool IsEqual(const double& lh, const double& rh)
{
std::cout <<"in the double special class..." <<std::endl;

return std::abs(lh - rh) < 10e-6;
}
};


int main(void)
{
Compare<int> comp1;
std::cout <<comp1.IsEqual(3, 4) <<std::endl;
std::cout <<comp1.IsEqual(3, 3) <<std::endl;

Compare<float> comp2;
std::cout <<comp2.IsEqual(3.14, 4.14) <<std::endl;
std::cout <<comp2.IsEqual(3, 3) <<std::endl;

Compare<double> comp3;
std::cout <<comp3.IsEqual(3.14159, 4.14159) <<std::endl;
std::cout <<comp3.IsEqual(3.14159, 3.14159) <<std::endl;
return 0;
}

如果期望使用偏特化,那么

1
2
3
4
5
6
7
8
9
template<class T1, class T2>
class A
{
}

template<class T1>
class A<T1, int>
{
}

特化为引用,指针类型

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
template <class _Iterator>
struct iterator_traits {
typedef typename _Iterator::iterator_category iterator_category;
typedef typename _Iterator::value_type value_type;
typedef typename _Iterator::difference_type difference_type;
typedef typename _Iterator::pointer pointer;
typedef typename _Iterator::reference reference;
};

// specialize for _Tp*
template <class _Tp>
struct iterator_traits<_Tp*> {
typedef random_access_iterator_tag iterator_category;
typedef _Tp value_type;
typedef ptrdiff_t difference_type;
typedef _Tp* pointer;
typedef _Tp& reference;
};

// specialize for const _Tp*
template <class _Tp>
struct iterator_traits<const _Tp*> {
typedef random_access_iterator_tag iterator_category;
typedef _Tp value_type;
typedef ptrdiff_t difference_type;
typedef const _Tp* pointer;
typedef const _Tp& reference;
};

当然,除了T*, 我们也可以将T特化为 const T*, T&, const T&等,以下还是以T*为例:

1
2
3
4
5
6
7
8
9
10
// specialize for T*
template<class T>
class Compare<T*>
{
public:
static bool IsEqual(const T* lh, const T* rh)
{
return Compare<T>::IsEqual(*lh, *rh);
}
};

这种特化其实是就不是一种绝对的特化, 它只是对类型做了某些限定,但仍然保留了其一定的模板性,这种特化给我们提供了极大的方便, 如这里, 我们就不需要对int*, float*, double*等等类型分别做特化了。

这其实是第二种方式的扩展,其实也是对类型做了某种限定,而不是绝对化为某个具体类型,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// specialize for vector<T>
template<class T>
class Compare<vector<T> >
{
public:
static bool IsEqual(const vector<T>& lh, const vector<T>& rh)
{
if(lh.size() != rh.size()) return false;
else
{
for(int i = 0; i < lh.size(); ++i)
{
if(lh[i] != rh[i]) return false;
}
}
return true;
}
};

这就把IsEqual的参数限定为一种vector类型, 但具体是vector还是vector, 我们可以不关心, 因为对于这两种类型,我们的处理方式是一样的,我们可以把这种方式称为“半特化”。

当然, 我们可以将其“半特化”为任何我们自定义的模板类类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// specialize for any template class type
template <class T1>
struct SpecializedType
{
T1 x1;
T1 x2;
};
template <class T>
class Compare<SpecializedType<T> >
{
public:
static bool IsEqual(const SpecializedType<T>& lh, const SpecializedType<T>& rh)
{
return Compare<T>::IsEqual(lh.x1 + lh.x2, rh.x1 + rh.x2);
}
};

这就是三种类型的模板特化, 我们可以这么使用这个Compare类:

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
// int
int i1 = 10;
int i2 = 10;
bool r1 = Compare<int>::IsEqual(i1, i2);

// float
float f1 = 10;
float f2 = 10;
bool r2 = Compare<float>::IsEqual(f1, f2);

// double
double d1 = 10;
double d2 = 10;
bool r3 = Compare<double>::IsEqual(d1, d2);

// pointer
int* p1 = &i1;
int* p2 = &i2;
bool r4 = Compare<int*>::IsEqual(p1, p2);

// vector<T>
vector<int> v1;
v1.push_back(1);
v1.push_back(2);

vector<int> v2;
v2.push_back(1);
v2.push_back(2);
bool r5 = Compare<vector<int> >::IsEqual(v1, v2);

// custom template class
SpecializedType<float> s1 = {10.1f,10.2f};
SpecializedType<float> s2 = {10.3f,10.0f};
bool r6 = Compare<SpecializedType<float> >::IsEqual(s1, s2);

类型萃取

在实现vector的时候,我们遇到了对于不同类型实现拷贝方式的方式不同。
比如:对于int,char使用memcpy就已经可以实现了,当然使用operator=也是没问题的,但是显然效率前者会高那么一些。
但是对于,string这种对象,或是与深浅拷贝有关的自定义类型,使用memcpy就会出现问题,使用operator=赋值就更加合适,避免出现深浅拷贝时出现的问题。

那么我有没有一种方法能够在同一个类中实现对不同类型去执行不同的方法,比如上例中的,如果是int,char等我就去执行memcpy方法,如果是string就去执行operator=。

c++提供了类型萃取,可以实现这种功能

下面从代码的角度来叙述
第一步:定义类型,区分内置类型与自定义类型

1
2
3
4
5
struct _TrueType//是无关紧要的类型,即内置类型
{};

struct _FalseType//不是无关紧要的类型,即自定义类型
{};

第二步:
特化需要特化的类型,自定义类型显然无穷无尽,我们特化不完,所以我们可以把有限的内置类型特化完全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <class T>
struct TypeTraits
{
typedef _FalseType IsPodType; //自定义类型,不是无关痛痒的类型
};
//以下特化内置类型
template<>
struct TypeTraits<int>
{
typedef _TrueType IsPodType;//是无关痛痒的类型吗?是的
};

template<>
struct TypeTraits<char>
{
typedef _TrueType IsPodType;//是无关痛痒的类型吗?是的
};

template<>
struct TypeTraits<double>
{
typedef _TrueType IsPodType;//是无关痛痒的类型吗?是的
};

接下来,重载拷贝函数,针对自定义类型与内置类型分别给出两种不同的方法,以TrueType,FalseType区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<class T>
void __TypeCopy(T* dst,const T* src,size_t size,_TrueType)
{
cout<<"__TrueType"<<endl;
memcpy(dst,src,size);
}

template<class T>
void __TypeCopy(T* dst,const T* src,size_t size,_FalseType)
{
cout<<"__FalseType"<<endl;
for(size_t i=0;i<size;i++)
{
dst[i]=src[i];
}
}

调用函数:取出IsPODType,判断是否为无关痛痒的类型,也就是判断你到底是TrueType还是FalseType,然后根据你是什么类型去调你自己的方法。

1
2
3
4
5
template<class T>
void TypeCopy(T* dst,const T* src,size_t size)
{
__TypeCopy(dst,src,size,TypeTraits<T>::IsPodType());
};

总结

博文比较长,总结一下所涉及的东西:

  • C++ 模板包括函数模板和类模板,模板参数形式有:类型、模板型、非类型(整型、指针);
  • 模板的特例化分完全特例化和部分特例化,实例将匹配参数集合最小的特例;
  • 用实例参数替换模板形式参数称为实例化,实例化的结果是产生具体类型(类模板)或函数(函数模板),同一模板实参完全等价将产生等价的实例类型或函数;
  • 模板一般在头文件中定义,可能被包含多次,编译和链接时会消除等价模板实例;
  • template、typename、this 关键字用来消除歧义,避免编译错误或产生不符预期的结果;
  • C++11 对模板引入了新特性:“>>”、函数模板也可以有默认参数、变长模板参数、外部模板实例(extern),并弃用 export template;
  • C++ 模板是图灵完备的,模板编程是函数编程风格,特点是:没有可变的存储、递归,以“<>”为输入,typedef 或静态常量为输出;
  • 编译期数值计算虽然实际意义不大,但可以很好证明 C++ 模板的能力,可以用模板实现类似普通程序中的 if 和 while 语句;
  • 一个实际应用是循环展开,虽然编译器可以自动循环展开,但我们可以让这一切更可控;
  • C++ 模板编程的两个问题是:难调试,会产生冗长且难以阅读的编译错误信息、代码膨胀(源代码膨胀、二进制对象文件膨胀),改进的方法是:增加一些检查代码,让编译器及时报错,使用特性、策略等让模板更通用,可能的话合并一些模板实例(如将代码提出去做成单独模板);
  • 表达式模板和向量计算是另一个可加速程序的例子,它们将计算表达式编码到类型,这是通过模板嵌套参数实现的;
  • 特性,策略,标签是模板编程常用技巧,它们可以是模板变得更加通用;
  • 模板甚至可以获得类型的内部信息(是否有某个 typedef),这是反射中的内省,C++ 在语言层面对反射支持很少(typeid),这不利于模板元编程;
  • 可以用递归实现伪变长参数模板,C++11 变长参数模板背后的原理也是模板递归;
  • 元容器存储元信息(如类型)、类型过滤过滤某些类型,它们是元编程的高级特性。

Leetcode501. Find Mode in Binary Search Tree

Given a binary search tree (BST) with duplicates, find all the mode(s) (the most frequently occurred element) in the given BST. Assume a BST is defined as follows:

For example:

1
2
3
4
5
6
7
Given BST [1,null,2,2],
1
\
2
/
2
return [2].

Note: If a tree has more than one mode, you can return them in any order.

这道题让我们求二分搜索树中的众数,这里定义的二分搜索树中左根右结点之间的关系是小于等于的,有些题目中是严格小于的,所以一定要看清题目要求。所谓的众数就是出现最多次的数字,可以有多个,那么这道题比较直接点思路就是利用一个哈希表来记录数字和其出现次数之前的映射,然后维护一个变量mx来记录当前最多的次数值,这样在遍历完树之后,根据这个mx值就能把对应的元素找出来。那么用这种方法的话就不需要用到二分搜索树的性质了,随意一种遍历方式都可以。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:

void inorder(TreeNode* root, unordered_map<int, int>& m, int& mx) {
if(root == NULL)
return;
inorder(root->left, m, mx);
mx = max(mx, ++m[root->val]);
inorder(root->right, m, mx);
}

vector<int> findMode(TreeNode* root) {
vector<int> res;
unordered_map<int, int> m;
int mx = -1;
inorder(root, m, mx);
for(unordered_map<int, int>::iterator p = m.begin(); p != m.end(); p ++) {
if(p->second == mx)
res.push_back(p->first);
}
return res;
}
};

Leetcode502. IPO

Suppose LeetCode will start its IPO soon. In order to sell a good price of its shares to Venture Capital, LeetCode would like to work on some projects to increase its capital before the IPO. Since it has limited resources, it can only finish at most k distinct projects before the IPO. Help LeetCode design the best way to maximize its total capital after finishing at most k distinct projects.

You are given several projects. For each project i, it has a pure profit Pi and a minimum capital of Ci is needed to start the corresponding project. Initially, you have W capital. When you finish a project, you will obtain its pure profit and the profit will be added to your total capital.

To sum up, pick a list of at most k distinct projects from given projects to maximize your final capital, and output your final maximized capital.

Example 1:

1
2
3
4
5
6
7
8
9
Input: k=2, W=0, Profits=[1,2,3], Capital=[0,1,1].

Output: 4

Explanation: Since your initial capital is 0, you can only start the project indexed 0.
After finishing it you will obtain profit 1 and your capital becomes 1.
With capital 1, you can either start the project indexed 1 or the project indexed 2.
Since you can choose at most 2 projects, you need to finish the project indexed 2 to get the maximum capital.
Therefore, output the final maximized capital, which is 0 + 1 + 3 = 4.

Note:

  • You may assume all numbers in the input are non-negative integers.
  • The length of Profits array and Capital array will not exceed 50,000.
  • The answer is guaranteed to fit in a 32-bit signed integer.

这道题说初始时我们的资本为0,可以交易k次,并且给了我们提供了交易所需的资本和所能获得的利润,让我们求怎样选择k次交易,使我们最终的资本最大。虽然题目中给我们的资本数组是有序的,但是OJ里的test case肯定不都是有序的,还有就是不一定需要资本大的交易利润就多,该遍历的时候还得遍历。我们可以用贪婪算法来解,每一次都选择资本范围内最大利润的进行交易,那么我们首先应该建立资本和利润对,然后根据资本的大小进行排序,然后我们根据自己当前的资本,用二分搜索法在有序数组中找第一个大于当前资本的交易的位置,然后往前退一步就是最后一个不大于当前资本的交易,然后向前遍历,找到利润最大的那个的进行交易,把利润加入资本W中,然后将这个交易对删除,这样我们就可以保证在进行k次交易后,我们的总资本最大,参见代码如下:

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
class Solution {
public:
int findMaximizedCapital(int k, int W, vector<int>& Profits, vector<int>& Capital) {
vector<pair<int, int>> v;
for (int i = 0; i < Capital.size(); ++i) {
v.push_back({Capital[i], Profits[i]});
}
sort(v.begin(), v.end());
for (int i = 0; i < k; ++i) {
int left = 0, right = v.size(), mx = 0, idx = 0;
while (left < right) {
int mid = left + (right - left) / 2;
if (v[mid].first <= W) left = mid + 1;
else right = mid;
}
for (int j = right - 1; j >= 0; --j) {
if (mx < v[j].second) {
mx = v[j].second;
idx = j;
}
}
W += mx;
v.erase(v.begin() + idx);
}
return W;
}
};

看论坛上的大神们都比较喜欢用一些可以自动排序的数据结构来做,比如我们可以使用一个最大堆和一个最小堆,把资本利润对放在最小堆中,这样需要资本小的交易就在队首,然后从队首按顺序取出资本小的交易,如果所需资本不大于当前所拥有的资本,那么就把利润资本存入最大堆中,注意这里资本和利润要翻个,因为我们希望把利润最大的交易放在队首,便于取出,这样也能实现我们的目的,参见代码如下:

解法二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int findMaximizedCapital(int k, int W, vector<int>& Profits, vector<int>& Capital) {
priority_queue<pair<int, int>> maxH;
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> minH;
for (int i = 0; i < Capital.size(); ++i) {
minH.push({Capital[i], Profits[i]});
}
for (int i = 0; i < k; ++i) {
while (!minH.empty() && minH.top().first <= W) {
auto t = minH.top(); minH.pop();
maxH.push({t.second, t.first});
}
if (maxH.empty()) break;
W += maxH.top().first; maxH.pop();
}
return W;
}
};

Leetcode503. Next Greater Element II

Given a circular array (the next element of the last element is the first element of the array), print the Next Greater Number for every element. The Next Greater Number of a number x is the first greater number to its traversing-order next in the array, which means you could search circularly to find its next greater number. If it doesn’t exist, output -1 for this number.

Example 1:

1
2
3
4
5
Input: [1,2,1]
Output: [2,-1,2]
Explanation: The first 1's next greater number is 2;
The number 2 can't find next greater number;
The second 1's next greater number needs to search circularly, which is also 2.

我的做法简单粗暴,循环遍历,直到找到正确的最大值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int i, j, size = nums.size();
vector<int> res;
for(i = 0; i < size; i ++) {
for(j = i+1; j < size*2; j ++) {
if(nums[i] < nums[j%size]) {
res.push_back(nums[j%size]);
break;
}
}
if(j == size*2)
res.push_back(-1);
}
return res;
}
};

也可以在O(n)内完成,它是一个循环找peek问题,但没关系,复制一份同样的数组,放在它的后面就好了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public int[] nextGreaterElements(int[] nums) {
int len = nums.length;
int[] ans = new int[len];
Arrays.fill(ans, -1);

Stack<Integer> stack = new Stack<>();
for (int i = 0; i < 2 * len; i++){
int num = nums[i % len];
while(!stack.isEmpty() && nums[stack.peek()] < num)
ans[stack.pop()] = num;

if (i < len) stack.push(i);
}

return ans;
}

Leetcode504. Base 7

Given an integer, return its base 7 string representation.

Example 1:

1
2
Input: 100
Output: "202"

Example 2:
1
2
Input: -7
Output: "-10"

将数字转化为7进制,用字符串形式输出。若是负数,在不考虑正负号的情况下算出7进制表示的数,最后在前面加上负号就行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
string convertToBase7(int num) {
string res;
int num1 = abs(num);
if(num == 0)
return "0";
while(num1 != 0) {
int temp = num1 % 7;
num1 /= 7;
res = to_string(temp) + res;
}
if(num < 0)
res = "-" + res;
return res;
}
};

Leetcode506. Relative Ranks

Given scores of N athletes, find their relative ranks and the people with the top three highest scores, who will be awarded medals: “Gold Medal”, “Silver Medal” and “Bronze Medal”.

Example 1:

1
2
3
Input: [5, 4, 3, 2, 1]
Output: ["Gold Medal", "Silver Medal", "Bronze Medal", "4", "5"]
Explanation: The first three athletes got the top three highest scores, so they got "Gold Medal", "Silver Medal" and "Bronze Medal". For the left two athletes, you just need to output their relative ranks according to their scores.

对于给予的得分情况,找出前三名并给予相应的称号,其余以数字作为其名词,记录每个元素的位置和元素值.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<string> findRelativeRanks(vector<int>& nums) {
unordered_map<int, int> mp;
for(int i = 0; i < nums.size(); i++) {
mp[nums[i]] = i;
}
sort(nums.begin(), nums.end(), greater<int>());
vector<string> res(nums.size(), "");
for(int i = 0; i < nums.size(); i ++) {
if(i == 0)
res[mp[nums[i]]] = "Gold Medal";
else if(i == 1)
res[mp[nums[i]]] = "Silver Medal";
else if(i == 2)
res[mp[nums[i]]] = "Bronze Medal";
else res[mp[nums[i]]] = to_string(i+1);
}
return res;
}
};

另一种做法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
vector<string> findRelativeRanks(vector<int>& nums) {
const int n = nums.size();
vector<string> ans(n);
map<int, int> dict;
for(int i = 0; i < n; i++) dict[-nums[i]] = i;
int cnt = 0;
vector<string> top3{"Gold Medal", "Silver Medal", "Bronze Medal"};
for(auto& [k, i]: dict){
cnt++;
if(cnt <=3) ans[i] = top3[cnt-1];
else ans[i] = to_string(cnt);
}
return ans;
}

Leetcode507. Perfect Number

We define the Perfect Number is a positive integer that is equal to the sum of all its positive divisors except itself.

Now, given an integer n, write a function that returns true when it is a perfect number and false when it is not.
Example:

1
2
3
Input: 28
Output: True
Explanation: 28 = 1 + 2 + 4 + 7 + 14

把一个数的所有因子找出来然后求和,找一个数的所有因子的时候,并不是从 1 开始直到自身,而是从 1 开始直到 sqrt(自身)
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
bool checkPerfectNumber(int num) {
int sum = 0;
if(num == 0)
return false;
for(int i = 1; i <= num/2; i ++) {
if(num%i == 0)
sum += i;
}
return sum == num;
}
};

Leetcode508. Most Frequent Subtree Sum

Given the root of a binary tree, return the most frequent subtree sum. If there is a tie, return all the values with the highest frequency in any order.

The subtree sum of a node is defined as the sum of all the node values formed by the subtree rooted at that node (including the node itself).

Example 1:

1
2
Input: root = [5,2,-3]
Output: [2,-3,4]

Example 2:

1
2
Input: root = [5,2,-5]
Output: [2]

这道题给了我们一个二叉树,让我们求出现频率最高的子树之和,求树的结点和并不是很难,就是遍历所有结点累加起来即可。那么这道题的暴力解法就是遍历每个结点,对于每个结点都看作子树的根结点,然后再遍历子树所有结点求和,这样也许可以通过 OJ,但是绝对不是最好的方法。我们想下子树有何特点,必须是要有叶结点,单独的一个叶结点也可以当作是子树,那么子树是从下往上构建的,这种特点很适合使用后序遍历,我们使用一个 HashMap 来建立子树和跟其出现频率的映射,用一个变量 cnt 来记录当前最多的次数,递归函数返回的是以当前结点为根结点的子树结点值之和,然后在递归函数中,我们先对当前结点的左右子结点调用递归函数,然后加上当前结点值,然后更新对应的 HashMap 中的值,然后看此时 HashMap 中的值是否大于等于 cnt,大于的话首先要清空 res,等于的话不用,然后将 sum 值加入结果 res 中即可,参见代码如下:

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
class Solution {
public:
vector<int> findFrequentTreeSum(TreeNode* root) {
vector<int> res;
int cnt = 0;
unordered_map<int, int> map;
dfs(root, map, res, cnt);
return res;
}

int dfs(TreeNode* root, unordered_map<int, int> &map, vector<int>& res, int &cnt) {
if (root == NULL)
return 0;
int left = dfs(root->left, map, res, cnt);
int right = dfs(root->right, map, res, cnt);
int sum = left + right + root->val;
map[sum] ++;

if (map[sum] >= cnt) {
if (map[sum] > cnt)
res.clear();
cnt = map[sum];
res.push_back(sum);
}
return sum;
}
};

Leetcode509. Fibonacci Number

The Fibonacci numbers, commonly denoted F(n) form a sequence, called the Fibonacci sequence, such that each number is the sum of the two preceding ones, starting from 0 and 1. That is,

F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), for N > 1.
Given N, calculate F(N).

Example 1:

1
2
3
Input: 2
Output: 1
Explanation: F(2) = F(1) + F(0) = 1 + 0 = 1.

Example 2:
1
2
3
Input: 3
Output: 2
Explanation: F(3) = F(2) + F(1) = 1 + 1 = 2.

Example 3:
1
2
3
Input: 4
Output: 3
Explanation: F(4) = F(3) + F(2) = 2 + 1 = 3.

Note:

  • 0 ≤ N ≤ 30.

斐波那契,不解释。

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int fib(int N) {
if(N==0)
return 0;
if(N==1)
return 1;
return fib(N-1)+fib(N-2);
}
};

另外的解法:动态规划。使用数组存储以前计算的斐波纳契值。Time Complexity - O(N),Space Complexity - O(N)
1
2
3
4
5
6
7
8
9
10
int fib(int N) {
if(N < 2)
return N;
int memo[N+1];
memo[0] = 0;
memo[1] = 1;
for(int i=2; i<=N; i++)
memo[i] = memo[i-1] + memo[i-2];
return memo[N];
}

Solution 3:使用Imperative方法,我们通过循环并通过在两个变量中仅存储两个先前的斐波那契值来优化空间。Time Complexity - O(N),Space Complexity - O(1)
1
2
3
4
5
6
7
8
9
10
11
12
int fib(int N) {
if(N < 2)
return N;
int a = 0, b = 1, c = 0;
for(int i = 1; i < N; i++)
{
c = a + b;
a = b;
b = c;
}
return c;
}

Leetcode513. Find Bottom Left Tree Value

Given the root of a binary tree, return the leftmost value in the last row of the tree.

Example 1:

1
2
Input: root = [2,1,3]
Output: 1

Example 2:

1
2
Input: root = [1,2,3,4,null,5,6,null,null,7]
Output: 7

这道题让我们求二叉树的最左下树结点的值,也就是最后一行左数第一个值,那么我首先想的是用先序遍历来做,我们维护一个最大深度和该深度的结点值,由于先序遍历遍历的顺序是根-左-右,所以每一行最左边的结点肯定最先遍历到,那么由于是新一行,那么当前深度肯定比之前的最大深度大,所以我们可以更新最大深度为当前深度,结点值res为当前结点值,这样在遍历到该行其他结点时就不会更新结果res了,参见代码如下:

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
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
* };
*/
class Solution {
public:
int findBottomLeftValue(TreeNode* root) {
int res, max_depth = -1;
dfs(root, 0, max_depth, res);
return res;
}

void dfs(TreeNode* root, int depth, int& max_depth, int& res) {
if (root == NULL)
return;
dfs(root->left, depth+1, max_depth, res);
dfs(root->right, depth+1, max_depth, res);

if (depth > max_depth) {
res = root->val;
max_depth = depth;
}
}
};

Leetcode515. Find Largest Value in Each Tree Row

Given the root of a binary tree, return an array of the largest value in each row of the tree (0-indexed).

Example 1:

1
2
Input: root = [1,3,2,5,3,null,9]
Output: [1,3,9]

Example 2:

1
2
Input: root = [1,2,3]
Output: [1,3]

Example 3:

1
2
Input: root = [1]
Output: [1]

Example 4:

1
2
Input: root = [1,null,2]
Output: [1,2]

这道题让我们找二叉树每行的最大的结点值,那么实际上最直接的方法就是用层序遍历,然后在每一层中找到最大值,加入结果res中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<int> largestValues(TreeNode* root) {
if (root == NULL)
return {};
queue<TreeNode*> q;
q.push(root);
vector<int> res;
while(!q.empty()) {
int cnt = q.size();
int maxx = INT_MIN;
for (int i = 0; i < cnt; i ++) {
TreeNode* temp = q.front(); q.pop();
maxx = max(maxx, temp->val);
if (temp->left) q.push(temp->left);
if (temp->right) q.push(temp->right);
}
res.push_back(maxx);
}
return res;
}
};

Leetcode516. Longest Palindromic Subsequence

Given a string s, find the longest palindromic subsequence’s length in s. You may assume that the maximum length of s is 1000.

Example 1:

1
2
3
Input: "bbbab"
Output: 4
One possible longest palindromic subsequence is “bbbb”.

Example 2:

1
2
3
Input: "cbbd"
Output: 2
One possible longest palindromic subsequence is “bb”.

Constraints:

  • 1 <= s.length <= 1000
  • s consists only of lowercase English letters.

这道题给了我们一个字符串,让求最大的回文子序列,子序列和子字符串不同,不需要连续。而关于回文串的题之前也做了不少,处理方法上就是老老实实的两两比较吧。像这种有关极值的问题,最应该优先考虑的就是贪婪算法和动态规划,这道题显然使用DP更加合适。这里建立一个二维的DP数组,其中dp[i][j]表示[i,j]区间内的字符串的最长回文子序列,那么对于递推公式分析一下,如果s[i]==s[j],那么i和j就可以增加2个回文串的长度,我们知道中间dp[i + 1][j - 1]的值,那么其加上2就是dp[i][j]的值。如果s[i] != s[j],就可以去掉i或j其中的一个字符,然后比较两种情况下所剩的字符串谁dp值大,就赋给dp[i][j],那么递推公式如下:

1
2
3
              /  dp[i + 1][j - 1] + 2                       if (s[i] == s[j])
dp[i][j] =
\ max(dp[i + 1][j], dp[i][j - 1]) if (s[i] != s[j])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n));
for (int i = n - 1; i >= 0; --i) {
dp[i][i] = 1;
for (int j = i + 1; j < n; ++j) {
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
};

下面是递归形式的解法,memo 数组这里起到了一个缓存已经计算过了的结果,这样能提高运算效率,使其不会 TLE,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int longestPalindromeSubseq(string s) {
int n = s.size();
vector<vector<int>> memo(n, vector<int>(n, -1));
return helper(s, 0, n - 1, memo);
}
int helper(string& s, int i, int j, vector<vector<int>>& memo) {
if (memo[i][j] != -1) return memo[i][j];
if (i > j) return 0;
if (i == j) return 1;
if (s[i] == s[j]) {
memo[i][j] = helper(s, i + 1, j - 1, memo) + 2;
} else {
memo[i][j] = max(helper(s, i + 1, j, memo), helper(s, i, j - 1, memo));
}
return memo[i][j];
}
};

Leetcode518. Coin Change 2

You are given coins of different denominations and a total amount of money. Write a function to compute the number of combinations that make up that amount. You may assume that you have infinite number of each kind of coin.

Note: You can assume that

  • 0 <= amount <= 5000
  • 1 <= coin <= 5000
  • the number of coins is less than 500
  • the answer is guaranteed to fit into signed 32-bit integer

Example 1:

1
2
3
4
5
6
7
Input: amount = 5, coins = [1, 2, 5]
Output: 4
Explanation: there are four ways to make up the amount:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

Example 2:

1
2
3
Input: amount = 3, coins = [2]
Output: 0
Explanation: the amount of 3 cannot be made up just with coins of 2.

Example 3:

1
2
Input: amount = 10, coins = [10] 
Output: 1

这道题是之前那道 Coin Change 的拓展,那道题问我们最少能用多少个硬币组成给定的钱数,而这道题问的是组成给定钱数总共有多少种不同的方法。还是要使用 DP 来做,首先来考虑最简单的情况,如果只有一个硬币的话,那么给定钱数的组成方式就最多有1种,就看此钱数能否整除该硬币值。当有两个硬币的话,组成某个钱数的方式就可能有多种,比如可能由每种硬币单独来组成,或者是两种硬币同时来组成,怎么量化呢?比如我们有两个硬币 [1,2],钱数为5,那么钱数的5的组成方法是可以看作两部分组成,一种是由硬币1单独组成,那么仅有一种情况 (1+1+1+1+1);另一种是由1和2共同组成,说明组成方法中至少需要有一个2,所以此时先取出一个硬币2,然后只要拼出钱数为3即可,这个3还是可以用硬币1和2来拼,所以就相当于求由硬币 [1,2] 组成的钱数为3的总方法。是不是不太好理解,多想想。这里需要一个二维的 dp 数组,其中 dp[i][j] 表示用前i个硬币组成钱数为j的不同组合方法,怎么算才不会重复,也不会漏掉呢?我们采用的方法是一个硬币一个硬币的增加,每增加一个硬币,都从1遍历到 amount,对于遍历到的当前钱数j,组成方法就是不加上当前硬币的拼法 dp[i-1][j],还要加上,去掉当前硬币值的钱数的组成方法,当然钱数j要大于当前硬币值,状态转移方程也在上面的分析中得到了:

1
dp[i][j] = dp[i - 1][j] + (j >= coins[i - 1] ? dp[i][j - coins[i - 1]] : 0)

注意要初始化每行的第一个位置为0,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<vector<int>> dp(coins.size() + 1, vector<int>(amount + 1, 0));
dp[0][0] = 1;
for (int i = 1; i <= coins.size(); ++i) {
dp[i][0] = 1;
for (int j = 1; j <= amount; ++j) {
dp[i][j] = dp[i - 1][j] + (j >= coins[i - 1] ? dp[i][j - coins[i - 1]] : 0);
}
}
return dp[coins.size()][amount];
}
};

我们可以对空间进行优化,由于dp[i][j]仅仅依赖于dp[i - 1][j]dp[i][j - coins[i - 1]]这两项,就可以使用一个一维dp数组来代替,此时的dp[i]表示组成钱数i的不同方法。其实最开始的时候,博主就想着用一维的 dp 数组来写,但是博主开始想的方法是把里面两个 for 循环调换了一个位置,结果计算的种类数要大于正确答案,所以一定要注意 for 循环的顺序不能搞反,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1;
for (int coin : coins) {
for (int i = coin; i <= amount; ++i) {
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
};

在 CareerCup 中,有一道极其相似的题 9.8 Represent N Cents 美分的组成,书里面用的是那种递归的方法,博主想将其解法直接搬到这道题里,但是失败了,博主发现使用那种的递归的解法必须要有值为1的硬币存在,这点无法在这道题里满足。你以为这样博主就没有办法了吗?当然有,博主加了判断,当用到最后一个硬币时,判断当前还剩的钱数是否能整除这个硬币,不能的话就返回0,否则返回1。还有就是用二维数组的 memo 会 TLE,所以博主换成了 map,就可以通过啦~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int change(int amount, vector<int>& coins) {
if (amount == 0) return 1;
if (coins.empty()) return 0;
map<pair<int, int>, int> memo;
return helper(amount, coins, 0, memo);
}
int helper(int amount, vector<int>& coins, int idx, map<pair<int, int>, int>& memo) {
if (amount == 0) return 1;
else if (idx >= coins.size()) return 0;
else if (idx == coins.size() - 1) return amount % coins[idx] == 0;
if (memo.count({amount, idx})) return memo[{amount, idx}];
int val = coins[idx], res = 0;
for (int i = 0; i * val <= amount; ++i) {
int rem = amount - i * val;
res += helper(rem, coins, idx + 1, memo);
}
return memo[{amount, idx}] = res;
}
};

Leetcode519. Random Flip Matrix

You are given the number of rows n_rows and number of columns n_cols of a 2D binary matrix where all values are initially 0. Write a function flip which chooses a 0 value uniformly at random, changes it to 1, and then returns the position [row.id, col.id] of that value. Also, write a function reset which sets all values back to 0. Try to minimize the number of calls to system’s Math.random() and optimize the time and space complexity.

Note:

  • 1 <= n_rows, n_cols <= 10000
  • 0 <= row.id < n_rows and 0 <= col.id < n_cols
  • flip will not be called when the matrix has no 0 values left.
  • the total number of calls to flip and reset will not exceed 1000.

Example 1:

1
2
3
4
Input: 
["Solution","flip","flip","flip","flip"]
[[2,3],[],[],[],[]]
Output: [null,[0,1],[1,2],[1,0],[1,1]]

Example 2:

1
2
3
4
5
6
Input: 
["Solution","flip","flip","reset","flip"]
[[1,2],[],[],[],[]]
Output: [null,[0,0],[0,1],null,[0,0]]
Explanation of Input Syntax:
The input is two lists: the subroutines called and their arguments. Solution's constructor has two arguments, n_rows and n_cols. flip and resethave no arguments. Arguments are always wrapped with a list, even if there aren't any.

这道题给了一个矩形的长和宽,让每次随机翻转其中的一个点,其中的隐含条件是,之前翻转过的点,下一次不能再翻转回来,而随机生成点是有可能有重复的,一旦很多点都被翻转后,很大概率会重复生成之前的点,所以需要有去重复的操作,而这也是本题的难点所在。可以用一个 HashSet 来记录翻转过了点,这样也方便进行查重操作。所以每次都随机出一个长和宽,然后看这个点是否已经在 HashSe t中了,不在的话,就加入 HashSet,然后返回即可,参见代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
Solution(int n_rows, int n_cols) {
row = n_rows; col = n_cols;
}

vector<int> flip() {
while (true) {
int x = rand() % row, y = rand() % col;
if (!flipped.count(x * col + y)) {
flipped.insert(x * col + y);
return {x, y};
}
}
}

void reset() {
flipped.clear();
}

private:
int row, col;
unordered_set<int> flipped;
};

Leetcode520. Detect Capital

Given a word, you need to judge whether the usage of capitals in it is right or not.

We define the usage of capitals in a word to be right when one of the following cases holds:

  • All letters in this word are capitals, like “USA”.
  • All letters in this word are not capitals, like “leetcode”.
  • Only the first letter in this word is capital, like “Google”.
  • Otherwise, we define that this word doesn’t use capitals in a right way.

Example 1:

1
2
Input: "USA"
Output: True

Example 2:
1
2
Input: "FlaG"
Output: False

看一个单词是不是只有第一个字母是大写的,或者是所有字母都是大写/小写。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:

bool isbig(char c) {
if(c >= 'A' && c <= 'Z')
return true;
return false;
}

bool detectCapitalUse(string word) {
int flag = false;
if(isbig(word[0]))
flag = isbig(word[1]);
else
flag = false;

for(int i = 1; i < word.length(); i ++)
if(flag != isbig(word[i]))
return false;
return true;
}
};

Leetcode521. Longest Uncommon Subsequence I

Given two strings, you need to find the longest uncommon subsequence of this two strings. The longest uncommon subsequence is defined as the longest subsequence of one of these strings and this subsequence should not be any subsequence of the other string.

A subsequence is a sequence that can be derived from one sequence by deleting some characters without changing the order of the remaining elements. Trivially, any string is a subsequence of itself and an empty string is a subsequence of any string.

The input will be two strings, and the output needs to be the length of the longest uncommon subsequence. If the longest uncommon subsequence doesn’t exist, return -1.

Example 1:

1
2
3
4
5
6
Input: a = "aba", b = "cdc"
Output: 3
Explanation: The longest uncommon subsequence is "aba",
because "aba" is a subsequence of "aba",
but not a subsequence of the other string "cdc".
Note that "cdc" can be also a longest uncommon subsequence.

Example 2:
1
2
Input: a = "aaa", b = "bbb"
Output: 3

如果两个元素不等长,那么其中长字符本身就不是另一个字符的子序列,输出长度就行,如果等长,那么如果两个字符串相同,返回-1,不同返回长度,因为一个不是另一个的子序列。
1
2
3
4
5
6
class Solution {
public:
int findLUSlength(string a, string b) {
return a == b ? -1 : max(a.length(), b.length());
}
};

Leetcode522. Longest Uncommon Subsequence II 题解

Given a list of strings, you need to find the longest uncommon subsequence among them. The longest uncommon subsequence is defined as the longest subsequence of one of these strings and this subsequence should not be any subsequence of the other strings.

A subsequence is a sequence that can be derived from one sequence by deleting some characters without changing the order of the remaining elements. Trivially, any string is a subsequence of itself and an empty string is a subsequence of any string.

The input will be a list of strings, and the output needs to be the length of the longest uncommon subsequence. If the longest uncommon subsequence doesn’t exist, return -1.

Example 1:

1
2
3
4
5
Input: "aba", "cdc", "eae"
Output: 3
Note:
All the given strings' lengths will not exceed 10.
The length of the given list will be in the range of [2, 50].

由题意知,给定一个装有多个字符串的容器,我们需要找到其中最长的“非公共子序列”的长度。其中,这里的“子序列”是指:对于一个字符串,去掉这个字符串中任意几个字符,但剩余的字符在这个字符串中相对位置不变的字符串。“非公共子序列”是指某字符串与容器中其它任意字符串都不会构成如上定义的“子序列”关系,即某字符串不是其它字符串的“子序列”。

我们用双重for循环遍历的方法来做这道题,对于每个字符串,使其与其它字符串相比较,当两个字符串相同时,直接跳过。如果一个字符串不是其它任意一个字符串的“子序列”,那么这个字符串就是一个如上定义的“非公共子序列”,我们记录下它的长度。最后取最长的“非公共子序列”的长度返回。

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
class Solution {
public:
int findLUSlength(vector<string>& strs) {
int len = strs.size();
int res = -1;
for (int i = 0; i < len; i ++) {
int j = 0;
for (j = 0; j < len; j ++) {
if (i == j)
continue;
if (issubstr(strs[j], strs[i]))
break;
}
if (j == len)
res = max(res, (int)(strs[i].length()));

}
return res;
}

int issubstr(string& str1, string& str2) {
int p1 = 0, p2 = 0;
int len1 = str1.length(), len2 = str2.length();
while(p1 < len1) {
if (p2 >= len2)
break;
if (str1[p1] == str2[p2])
p2 ++;
p1 ++;
}
return p2 == len2;
}
};

Leetcode523. Continuous Subarray Sum

Given an integer array nums and an integer k, return true if nums has a continuous subarray of size at least two whose elements sum up to a multiple of k, or false otherwise.

An integer x is a multiple of k if there exists an integer n such that x = n * k. 0 is always a multiple of k.

Example 1:

1
2
3
Input: nums = [23,2,4,6,7], k = 6
Output: true
Explanation: [2, 4] is a continuous subarray of size 2 whose elements sum up to 6.

Example 2:

1
2
3
4
Input: nums = [23,2,6,4,7], k = 6
Output: true
Explanation: [23, 2, 6, 4, 7] is an continuous subarray of size 5 whose elements sum up to 42.
42 is a multiple of 6 because 42 = 7 * 6 and 7 is an integer.

Example 3:

1
2
Input: nums = [23,2,6,4,7], k = 13
Output: false

这道题给了我们一个数组和一个数字k,让求是否存在这样的一个连续的子数组,该子数组的数组之和可以整除k。

下面这种方法用了些技巧,那就是,若数字a和b分别除以数字c,若得到的余数相同,那么 (a-b) 必定能够整除c。用一个集合 HashSet 来保存所有出现过的余数,如果当前的累加和除以k得到的余数在 HashSet 中已经存在了,那么说明之前必定有一段子数组和可以整除k。需要注意的是k为0的情况,由于无法取余,就把当前累加和放入 HashSet 中。还有就是题目要求子数组至少需要两个数字,那么需要一个变量 pre 来记录之前的和,每次存入 HashSet 中的是 pre,而不是当前的累积和,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
bool checkSubarraySum(vector<int>& nums, int k) {
int n = nums.size(), sum = 0, pre = 0;
unordered_set<int> st;
for (int i = 0; i < n; ++i) {
sum += nums[i];
int t = (k == 0) ? sum : (sum % k);
if (st.count(t)) return true;
st.insert(pre);
pre = t;
}
return false;
}
};

既然 HashSet 可以做,一般来说用 HashMap 也可以做,这里我们建立余数和当前位置之间的映射,由于有了位置信息,就不需要 pre 变量了,之前用保存的坐标和当前位置i比较判断就可以了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
bool checkSubarraySum(vector<int>& nums, int k) {
int n = nums.size(), sum = 0;
unordered_map<int, int> m{{0,-1}};
for (int i = 0; i < n; ++i) {
sum += nums[i];
int t = (k == 0) ? sum : (sum % k);
if (m.count(t)) {
if (i - m[t] > 1) return true;
} else m[t] = i;
}
return false;
}
};

Leetcode524. Longest Word in Dictionary through Deleting

Given a string and a string dictionary, find the longest string in the dictionary that can be formed by deleting some characters of the given string. If there are more than one possible results, return the longest word with the smallest lexicographical order. If there is no possible result, return the empty string.

Example 1:

1
2
Input: s = "abpcplea", d = ["ale","apple","monkey","plea"]
Output: "apple"

Example 2:

1
2
Input: s = "abpcplea", d = ["a","b","c"]
Output: "a"

Note:

  • All the strings in the input will only contain lower-case letters.
  • The size of the dictionary won’t exceed 1,000.
  • The length of all the strings in the input won’t exceed 1,000.

这道题给了我们一个字符串,和一个字典,让我们找到字典中最长的一个单词,这个单词可以通过给定单词通过删除某些字符得到。由于只能删除某些字符,并不能重新排序,所以我们不能通过统计字符出现个数的方法来判断是否能得到该单词,而是只能老老实实的按顺序遍历每一个字符。我们可以给字典排序,通过重写comparator来实现按长度由大到小来排,如果长度相等的就按字母顺序来排。然后我们开始遍历每一个单词,用一个变量i来记录单词中的某个字母的位置,我们遍历给定字符串,如果遍历到单词中的某个字母来,i自增1,如果没有,就继续往下遍历。这样如果最后i和单词长度相等,说明单词中的所有字母都按顺序出现在了字符串s中,由于字典中的单词已经按要求排过序了,所以第一个通过验证的单词一定是正确答案,我们直接返回当前单词即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
static bool comp(string& a, string& b) {
if (a.length() == b.length())
return a < b;
return a.length() > b.length();
}

string findLongestWord(string s, vector<string>& dictionary) {
int lens = s.length();
sort(dictionary.begin(), dictionary.end(), comp);
for (int i = 0; i < dictionary.size(); i ++) {
int ps = 0, pd = 0;
for (int j = 0; j < s.length(); j ++)
if (s[j] == dictionary[i][pd])
pd ++;
if (pd == dictionary[i].length())
return dictionary[i];
}
return "";
}
};

Leetcode525. Contiguous Array

Given a binary array nums, return the maximum length of a contiguous subarray with an equal number of 0 and 1.

Example 1:

1
2
3
Input: nums = [0,1]
Output: 2
Explanation: [0, 1] is the longest contiguous subarray with an equal number of 0 and 1.

Example 2:

1
2
3
Input: nums = [0,1,0]
Output: 2
Explanation: [0, 1] (or [1, 0]) is a longest contiguous subarray with equal number of 0 and 1.

这道题给了我们一个二进制的数组,让找邻近的子数组使其0和1的个数相等。对于求子数组的问题,需要时刻记着求累积和是一种很犀利的工具,但是这里怎么将子数组的和跟0和1的个数之间产生联系呢?这里需要用到一个 trick,遇到1就加1,遇到0,就减1,这样如果某个子数组和为0,就说明0和1的个数相等。知道了这一点,就用一个 HashMap 建立子数组之和跟结尾位置的坐标之间的映射。如果某个子数组之和在 HashMap 里存在了,说明当前子数组减去 HashMap 中存的那个子数组,得到的结果是中间一段子数组之和,必然为0,说明0和1的个数相等,更新结果 res。注意这里需要在 HashMap 初始化一个 0 -> -1 的映射,这是为了当 sum 第一次出现0的时候,即这个子数组是从原数组的起始位置开始,需要计算这个子数组的长度,而不是建立当前子数组之和 sum 和其结束位置之间的映射。比如就拿例子1来说,nums = [0, 1],当遍历0的时候,sum = -1,此时建立 -1 -> 0 的映射,当遍历到1的时候,此时 sum = 0 了,若 HashMap 中没有初始化一个 0 -> -1 的映射,此时会建立 0 -> 1 的映射,而不是去更新这个满足题意的子数组的长度,所以要这么初始化,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int findMaxLength(vector<int>& nums) {
map<int, int> m{{0, -1}};
int sum = 0, res = 0;
for (int i = 0;i < nums.size(); i ++) {
if (nums[i] == 1)
sum ++;
else
sum --;
if (m.count(sum))
res = max(res, i-m[sum]);
else
m[sum] = i;
}
return res;
}
};

Leetcode526. Beautiful Arrangement

Suppose you have n integers labeled 1 through n. A permutation of those n integers perm (1-indexed) is considered a beautiful arrangement if for every i (1 <= i <= n), either of the following is true:

  • perm[i] is divisible by i.
  • i is divisible by perm[i].

Given an integer n, return the number of the beautiful arrangements that you can construct.

Example 1:

1
2
3
4
5
6
7
8
9
Input: n = 2
Output: 2
Explanation:
The first beautiful arrangement is [1,2]:
- perm[1] = 1 is divisible by i = 1
- perm[2] = 2 is divisible by i = 2
The second beautiful arrangement is [2,1]:
- perm[1] = 2 is divisible by i = 1
- i = 2 is divisible by perm[2] = 1

Example 2:

1
2
Input: n = 1
Output: 1

这道题给了我们1到N,总共N个正数,然后定义了一种优美排列方式,对于该排列中的所有数,如果数字可以整除下标,或者下标可以整除数字,那么我们就是优美排列,让我们求出所有优美排列的个数。那么对于求种类个数,或者是求所有情况,这种问题通常要用递归来做,递归简直是暴力的不能再暴力的方法了。而递归方法等难点在于写递归函数,如何确定终止条件,还有for循环中变量的起始位置如何确定。那么这里我们需要一个visited数组来记录数字是否已经访问过,因为优美排列中不能有重复数字。我们用变量pos来标记已经生成的数字的个数,如果大于N了,说明已经找到了一组排列,结果res自增1。在for循环中,i应该从1开始,因为我们遍历1到N中的所有数字,如果该数字未被使用过,且满足和坐标之间的整除关系,那么我们标记该数字已被访问过,再调用下一个位置的递归函数,之后不要忘记了恢复初始状态,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int countArrangement(int n) {
int res = 0;
vector<bool> visited(n, false);
dfs(n, 1, visited, res);
return res;
}

void dfs(int n, int cur, vector<bool> visited, int& res) {
if (cur > n) {
res ++;
return ;
}
for (int i = 1; i <= n; i ++) {
if (visited[i-1] || (i%cur != 0 && cur%i != 0))
continue;
visited[i-1] = true;
dfs(n, cur+1, visited, res);
visited[i-1] = false;
}
}
};

Leetcode528. Random Pick with Weight

Given an array w of positive integers, where w[i] describes the weight of index i, write a function pickIndex which randomly picks an index in proportion to its weight.

Note:

  • 1 <= w.length <= 10000
  • 1 <= w[i] <= 10^5
  • pickIndex will be called at most 10000 times.

Example 1:

1
2
3
4
Input: 
["Solution","pickIndex"]
[[[1]],[]]
Output: [null,0]

Example 2:

1
2
3
4
Input: 
["Solution","pickIndex","pickIndex","pickIndex","pickIndex","pickIndex"]
[[[1,3]],[],[],[],[],[]]
Output: [null,0,1,1,1,0]

Explanation of Input Syntax: The input is two lists: the subroutines called and their arguments. Solution‘s constructor has one argument, the array w. pickIndex has no arguments. Arguments are always wrapped with a list, even if there aren’t any.

这道题给了一个权重数组,让我们根据权重来随机取点,现在的点就不是随机等概率的选取了,而是要根据权重的不同来区别选取。比如题目中例子2,权重为 [1, 3],表示有两个点,权重分别为1和3,那么就是说一个点的出现概率是四分之一,另一个出现的概率是四分之三。由于我们的rand()函数是等概率的随机,那么我们如何才能有权重的随机呢,我们可以使用一个trick,由于权重是1和3,相加为4,那么我们现在假设有4个点,然后随机等概率取一个点,随机到第一个点后就表示原来的第一个点,随机到后三个点就表示原来的第二个点,这样就可以保证有权重的随机啦。那么我们就可以建立权重数组的累加和数组,比如若权重数组为 [1, 3, 2] 的话,那么累加和数组为 [1, 4, 6],整个的权重和为6,我们 rand() % 6,可以随机出范围 [0, 5] 内的数,随机到 0 则为第一个点,随机到 1,2,3 则为第二个点,随机到 4,5 则为第三个点,所以我们随机出一个数字x后,然后再累加和数组中查找第一个大于随机数x的数字,使用二分查找法可以找到第一个大于随机数x的数字的坐标,即为所求,参见代码如下:

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
class Solution {
public:

vector<int> cumulative;
int sum;

Solution(vector<int>& w) {
cumulative.resize(w.size(), w[0]);
sum = w[0];
for (int i = 1; i < w.size(); i ++) {
cumulative[i] = cumulative[i-1] + w[i];
sum += w[i];
}
}

int pickIndex() {
int x = rand() % sum;
int left = 0, right = cumulative.size()-1, mid;
while(left < right) {
mid = left + (right-left) / 2;
if (cumulative[mid] <= x)
left = mid + 1;
else
right = mid;
}
return right;
}
};

Leetcode529. Minesweeper

Let’s play the minesweeper game (Wikipedia, online game)!

You are given an m x n char matrix board representing the game board where:

  • ‘M’ represents an unrevealed mine,
  • ‘E’ represents an unrevealed empty square,
  • ‘B’ represents a revealed blank square that has no adjacent mines (i.e., above, below, left, right, and all 4 diagonals),
  • digit (‘1’ to ‘8’) represents how many mines are adjacent to this revealed square, and
  • ‘X’ represents a revealed mine.

You are also given an integer array click where click = [clickr, clickc] represents the next click position among all the unrevealed squares (‘M’ or ‘E’).

Return the board after revealing this position according to the following rules:

  1. If a mine ‘M’ is revealed, then the game is over. You should change it to ‘X’.
  2. If an empty square ‘E’ with no adjacent mines is revealed, then change it to a revealed blank ‘B’ and all of its adjacent unrevealed squares should be revealed recursively.
  3. If an empty square ‘E’ with at least one adjacent mine is revealed, then change it to a digit (‘1’ to ‘8’) representing the number of adjacent mines.
  4. Return the board when no more squares will be revealed.

Example 1:

1
2
Input: board = [["E","E","E","E","E"],["E","E","M","E","E"],["E","E","E","E","E"],["E","E","E","E","E"]], click = [3,0]
Output: [["B","1","E","1","B"],["B","1","M","1","B"],["B","1","1","1","B"],["B","B","B","B","B"]]

Example 2:

1
2
Input: board = [["B","1","E","1","B"],["B","1","M","1","B"],["B","1","1","1","B"],["B","B","B","B","B"]], click = [1,2]
Output: [["B","1","E","1","B"],["B","1","X","1","B"],["B","1","1","1","B"],["B","B","B","B","B"]]

这道题就是经典的扫雷游戏啦,经典到不能再经典,从Win98开始,附件中始终存在的游戏,和纸牌、红心大战、空当接龙一起称为四大天王,曾经消耗了博主太多的时间。小时侯一直不太会玩扫雷,就是瞎点,完全不根据数字分析,每次点几下就炸了,就觉得这个游戏好无聊。后来长大了一些,慢慢的理解了游戏的玩法,才发现这个游戏果然很经典,就像破解数学难题一样,充满了挑战与乐趣。花样百出的LeetCode这次把扫雷出成题,让博主借机回忆了一把小时侯,不错不错,那么来做题吧。题目中图文并茂,相信就算是没玩过扫雷的也能弄懂了,而且规则也说的比较详尽了,那么我们相对应的做法也就明了了。对于当前需要点击的点,我们先判断是不是雷,是的话直接标记X返回即可。如果不是的话,我们就数该点周围的雷个数,如果周围有雷,则当前点变为雷的个数并返回。如果没有的话,我们再对周围所有的点调用递归函数再点击即可。参见代码如下:

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
class Solution {
public:
vector<vector<char>> updateBoard(vector<vector<char>>& board, vector<int>& click) {
if (board.empty() || board[0].empty()) return {};
int m = board.size(), n = board[0].size(), row = click[0], col = click[1], cnt = 0;
if (board[row][col] == 'M') {
board[row][col] = 'X';
} else {
for (int i = -1; i < 2; ++i) {
for (int j = -1; j < 2; ++j) {
int x = row + i, y = col + j;
if (x < 0 || x >= m || y < 0 || y >= n) continue;
if (board[x][y] == 'M') ++cnt;
}
}
if (cnt > 0) {
board[row][col] = cnt + '0';
} else {
board[row][col] = 'B';
for (int i = -1; i < 2; ++i) {
for (int j = -1; j < 2; ++j) {
int x = row + i, y = col + j;
if (x < 0 || x >= m || y < 0 || y >= n) continue;
if (board[x][y] == 'E') {
vector<int> nextPos{x, y};
updateBoard(board, nextPos);
}
}
}
}
}
return board;
}
};

下面这种解法跟上面的解法思路基本一样,写法更简洁了一些。可以看出上面的解法中的那两个for循环出现了两次,这样显得代码比较冗余,一般来说对于重复代码是要抽离成函数的,但那样还要多加个函数,也麻烦。我们可以根据第一次找周围雷个数的时候,若此时cnt个数为0并且标识是E的位置记录下来,那么如果最后雷个数确实为0了的话,我们直接遍历我们保存下来为E的位置调用递归函数即可,就不用再写两个for循环了,参见代码如下:

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
class Solution {
public:
vector<vector<char>> updateBoard(vector<vector<char>>& board, vector<int>& click) {
if (board.empty() || board[0].empty()) return {};
int m = board.size(), n = board[0].size(), row = click[0], col = click[1], cnt = 0;
if (board[row][col] == 'M') {
board[row][col] = 'X';
} else {
vector<vector<int>> neighbors;
for (int i = -1; i < 2; ++i) {
for (int j = -1; j < 2; ++j) {
int x = row + i, y = col + j;
if (x < 0 || x >= m || y < 0 || y >= n) continue;
if (board[x][y] == 'M') ++cnt;
else if (cnt == 0 && board[x][y] == 'E') neighbors.push_back({x, y});
}
}
if (cnt > 0) {
board[row][col] = cnt + '0';
} else {
for (auto a : neighbors) {
board[a[0]][a[1]] = 'B';
updateBoard(board, a);
}
}
}
return board;
}
};

下面这种方法是上面方法的迭代写法,用queue来存储之后要遍历的位置,这样就不用递归调用函数了,参见代码如下:

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
class Solution {
public:
vector<vector<char>> updateBoard(vector<vector<char>>& board, vector<int>& click) {
if (board.empty() || board[0].empty()) return {};
int m = board.size(), n = board[0].size();
queue<pair<int, int>> q({{click[0], click[1]}});
while (!q.empty()) {
int row = q.front().first, col = q.front().second, cnt = 0; q.pop();
vector<pair<int, int>> neighbors;
if (board[row][col] == 'M') board[row][col] = 'X';
else {
for (int i = -1; i < 2; ++i) {
for (int j = -1; j < 2; ++j) {
int x = row + i, y = col + j;
if (x < 0 || x >= m || y < 0 || y >= n) continue;
if (board[x][y] == 'M') ++cnt;
else if (cnt == 0 && board[x][y] == 'E') neighbors.push_back({x, y});
}
}
}
if (cnt > 0) board[row][col] = cnt + '0';
else {
for (auto a : neighbors) {
board[a.first][a.second] = 'B';
q.push(a);
}
}
}
return board;
}
};

Leetcode530. Minimum Absolute Difference in BST

Given a binary search tree with non-negative values, find the minimum absolute difference between values of any two nodes.

Example:

1
2
3
4
5
6
7
8
Input:
1
\
3
/
2
Output:
1

Explanation:
The minimum absolute difference is 1, which is the difference between 2 and 1 (or between 2 and 3).

这道题给了我们一棵二叉搜索树,让我们求任意个节点值之间的最小绝对差。由于BST的左<根<右的性质可知,如果按照中序遍历会得到一个有序数组,那么最小绝对差肯定在相邻的两个节点值之间产生。所以我们的做法就是对BST进行中序遍历,然后当前节点值和之前节点值求绝对差并更新结果res。这里需要注意的就是在处理第一个节点值时,由于其没有前节点,所以不能求绝对差。这里我们用变量pre来表示前节点值,这里由于题目中说明了所以节点值不为负数,所以我们给pre初始化-1,这样我们就知道pre是否存在。如果没有题目中的这个非负条件,那么就不能用int变量来,必须要用指针,通过来判断是否为指向空来判断前结点是否存在。还好这里简化了问题,用-1就能搞定了,这里我们先来看中序遍历的递归写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:

void inorder(TreeNode* root, int &pre, int &res) {
if(root == NULL)
return;
inorder(root->left, pre, res);
if(pre != -1)
res = min(abs(pre-root->val), res);
pre = root->val;
inorder(root->right, pre, res);
}

int getMinimumDifference(TreeNode* root) {
int res = INT_MAX, pre = -1;
inorder(root, pre, res);
return res;
}
};

Leetcode532. K-diff Pairs in an Array

Given an array of integers and an integer k, you need to find the number of unique k-diff pairs in the array. Here a k-diff pair is defined as an integer pair (i, j), where i and j are both numbers in the array and their absolute difference is k.

Example 1:

1
2
3
4
Input: [3, 1, 4, 1, 5], k = 2
Output: 2
Explanation: There are two 2-diff pairs in the array, (1, 3) and (3, 5).
Although we have two 1s in the input, we should only return the number of unique pairs.

Example 2:
1
2
3
Input:[1, 2, 3, 4, 5], k = 1
Output: 4
Explanation: There are four 1-diff pairs in the array, (1, 2), (2, 3), (3, 4) and (4, 5).

Example 3:
1
2
3
Input: [1, 3, 1, 5, 4], k = 0
Output: 1
Explanation: There is one 0-diff pair in the array, (1, 1).

这道题给了我们一个含有重复数字的无序数组,还有一个整数k,让找出有多少对不重复的数对 (i, j) 使得i和j的差刚好为k。由于k有可能为0,而只有含有至少两个相同的数字才能形成数对,那么就是说需要统计数组中每个数字的个数。可以建立每个数字和其出现次数之间的映射,然后遍历 HashMap 中的数字,如果k为0且该数字出现的次数大于1,则结果 res 自增1;如果k不为0,且用当前数字加上k后得到的新数字也在数组中存在,则结果 res 自增1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int findPairs(vector<int>& nums, int k) {
unordered_map<int, int> mp;
int res = 0;
for(int i : nums)
mp[i] ++;
for(auto i : mp) {
if(k == 0 && i.second > 1)
res ++;
if(k > 0 && mp.count(i.first+k)) {
//i.second --;
//mp[i.first+k] --;
// 不需要减1了,因为可以有重复。
res ++;
}
}
return res;
}
};

Leetcode535. Encode and Decode TinyURL

Note: This is a companion problem to the System Design problem: Design TinyURL

TinyURL is a URL shortening service where you enter a URL such as https://leetcode.com/problems/design-tinyurl and it returns a short URL such as http://tinyurl.com/4e9iAk.

Design the encode and decode methods for the TinyURL service. There is no restriction on how your encode/decode algorithm should work. You just need to ensure that a URL can be encoded to a tiny URL and the tiny URL can be decoded to the original URL.

这道题其实不难,给一个url,要求转成一个短字符串,并且能还原出来。为什么专门做这种题呢,其实是想复习C++一些STL的用法,这道题涉及了string和map的用法,先讲题,再专门开两个md谈用法。我的代码:

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
class Solution {
public:
map<string, int> map1;
map<int, string> map2;
string s="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
// Encodes a URL to a shortened URL.
string encode(string longUrl) {
map<string,int>::iterator key = map1.find(longUrl);
if(key==map1.end())
{
map1.insert(map<string, int>::value_type (longUrl,map1.size()+1));
map2.insert(map<int, string>::value_type (map2.size()+1,longUrl));
}
int n=map2.size();

string result;
// n is the number of longUrl
while(n>0){
printf("(%d) ",n);
int r = n%62;
n /= 62;
result.append(1,s[r]);
}
//printf("%s\n",result);
return result;
}

// Decodes a shortened URL to its original URL.
string decode(string shortUrl) {
int length = shortUrl.size();
int val=0;
for(int i=0;i<length;i++){
val = val*62+s.find(shortUrl[i]);
}
return map2.find(val)->second;
}
};

// Your Solution object will be instantiated and called as such:
// Solution solution;
// solution.decode(solution.encode(url));

别人的代码:
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
class Solution {
public:

string alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
unordered_map<string, string> map;
string key = getRandom();

string getRandom() {
string s;
for (int i = 0; i < 6 ; i++) {
s += alphabet[rand() % 61]; }
return s;
}

// Encodes a URL to a shortened URL.
string encode(string longUrl) {
while(map.count(key)) {
key = getRandom();
}
map.insert(make_pair(key, longUrl));
return "http://tinyurl.com/" + key;
}
// Decodes a shortened URL to its original URL.
string decode(string shortUrl) {
return map.at(shortUrl.replace(0,shortUrl.size()-6,""));
}
};

Leetcode537. Complex Number Multiplication

Given two strings representing two complex numbers. You need to return a string representing their multiplication. Note i2 = -1 according to the definition.

Example 1:

1
2
3
Input: "1+1i", "1+1i"
Output: "0+2i"
Explanation: (1 + i) * (1 + i) = 1 + i2 + 2 * i = 2i, and you need convert it to the form of 0+2i.

Example 2:
1
2
3
Input: "1+-1i", "1+-1i"
Output: "0+-2i"
Explanation: (1 - i) * (1 - i) = 1 + i2 - 2 * i = -2i, and you need convert it to the form of 0+-2i.

Note:

  • The input strings will not have extra blank.
  • The input strings will be given in the form of a+bi, where the integer a and b will both belong to the range of [-100, 100]. - And the output should be also in this form.

复数相乘,简单。两种做法,第一种我写的,自己实现字符串解析,memory用的少但是时间慢一些,第二种用了库,时间短但是memory用的多。

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
class Solution {
public:

pair<int,int> cal(string a){
pair<int ,int> aa;
int i;
int temp=0;
for(i=0;i<a.length();i++){
if(i!=0&&(a[i]<'0'||a[i]>'9'))
break;
else if(a[i]>='0'&&a[i]<='9'){
temp=temp*10+(a[i]-'0');
}
}
if(a[0]=='-')
temp=-temp;
int j=i+1,temp2=0;
for(;j<a.length();j++){
if(j!=i+1&&(a[j]<'0'||a[j]>'9'))
break;
else if(a[j]>='0'&&a[j]<='9'){
temp2=temp2*10+(a[j]-'0');
}
}
if(a[i+1]=='-')
temp2=-temp2;
aa.first=temp;
aa.second=temp2;
return aa;
}

string complexNumberMultiply(string a, string b) {
pair<int ,int> aa,bb;
//aa=cal(a);
//bb=cal(b);
//第一种
//第二种
int i;
for(i=0;i<a.length();i++)
if(a[i]=='+')
break;
aa.first=stoi(a.substr(0,i));
aa.second=stoi(a.substr(i+1,a.length()-2-i));

for(i=0;i<b.length();i++)
if(b[i]=='+')
break;
bb.first=stoi(b.substr(0,i));
bb.second=stoi(b.substr(i+1,b.length()-2-i));

int temp1,temp2;
temp1=aa.first*bb.first - aa.second*bb.second;
temp2=aa.first*bb.second + aa.second*bb.first;

string res=to_string(temp1)+"+"+to_string(temp2)+"i";

return res;
}
};

Leetcode538. Convert BST to Greater Tree

Given a Binary Search Tree (BST), convert it to a Greater Tree such that every key of the original BST is changed to the original key plus sum of all keys greater than the original key in BST.

Example:

1
2
3
4
5
6
7
8
9
Input: The root of a Binary Search Tree like this:
5
/ \
2 13

Output: The root of a Greater Tree like this:
18
/ \
20 13

这道题让我们将二叉搜索树转为较大树,通过题目汇总的例子可以明白,是把每个结点值加上所有比它大的结点值总和当作新的结点值。仔细观察题目中的例子可以发现,2变成了20,而20是所有结点之和,因为2是最小结点值,要加上其他所有结点值,所以肯定就是所有结点值之和。5变成了18,是通过20减去2得来的,而13还是13,是由20减去7得来的,而7是2和5之和。通过看论坛,发现还有更巧妙的方法,不用先求出的所有的结点值之和,而是巧妙的将中序遍历左根右的顺序逆过来,变成右根左的顺序,这样就可以反向计算累加和sum,同时更新结点值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:

void inorder(TreeNode* root, int& sum) {
if(root == NULL)
return;
inorder(root->right, sum);
root->val += sum;
sum = root->val;
inorder(root->left, sum);
}

TreeNode* convertBST(TreeNode* root) {
int sum = 0;
inorder(root, sum);
return root;
}
};

Leetcode539. Minimum Time Difference

Given a list of 24-hour clock time points in “Hour:Minutes” format, find the minimum minutes difference between any two time points in the list.

Example 1:

1
2
Input: ["23:59","00:00"]
Output: 1

Note:

  • The number of time points in the given list is at least 2 and won’t exceed 20000.
  • The input time is legal and ranges from 00:00 to 23:59.

这道题给了我们一系列无序的时间点,让我们求最短的两个时间点之间的差值。那么最简单直接的办法就是给数组排序,这样时间点小的就在前面了,然后我们分别把小时和分钟提取出来,计算差值,注意唯一的特殊情况就是第一个和末尾的时间点进行比较,第一个时间点需要加上24小时再做差值,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:

vector<int> gettime(string& timePoint) {
return {(timePoint[0]-'0')*10 + (timePoint[1]-'0'), (timePoint[3]-'0')*10 + (timePoint[4]-'0')};
}

int findMinDifference(vector<string>& timePoints) {
int n = timePoints.size(), res = INT_MAX, tmp;
sort(timePoints.begin(), timePoints.end());
vector<int> firsttime = gettime(timePoints[0]), secondtime;
for (int i = 0; i < n; i ++) {
secondtime = gettime(timePoints[(i+1) % n]);
tmp = secondtime[1]-firsttime[1] + (secondtime[0]-firsttime[0])*60;
if (i == n-1)
tmp += 24*60;
res = min(res, tmp);
firsttime = secondtime;
}
return res;
}
};

下面这种写法跟上面的大体思路一样,写法上略有不同,是在一开始就把小时和分钟数提取出来并计算总分钟数存入一个新数组,然后再对新数组进行排序,再计算两两之差,最后还是要处理首尾之差,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:

int gettime(string& timePoint) {
return ((timePoint[0]-'0')*10 + timePoint[1]-'0')*60 + (timePoint[3]-'0')*10 + timePoint[4]-'0';
}

int findMinDifference(vector<string>& timePoints) {
int n = timePoints.size(), res = INT_MAX, tmp;
vector<int> times;
for (int i = 0; i < n; i ++)
times.push_back(gettime(timePoints[i]));
sort(times.begin(), times.end());

for (int i = 0; i < n; i ++) {
tmp = times[(i+1)%n] - times[i];
if (i == n-1)
tmp += 24*60;
res = min(res, tmp);
}
return res;
}
};

Leetcode540. Single Element in a Sorted Array

Given a sorted array consisting of only integers where every element appears twice except for one element which appears once. Find this single element that appears only once.

Example 1:

1
2
Input: [1,1,2,3,3,4,4,8,8]
Output: 2

Example 2:

1
2
Input: [3,3,7,7,10,11,11]
Output: 10

Note: Your solution should run in O(log n) time and O(1) space.

这道题给我们了一个有序数组,说是所有的元素都出现了两次,除了一个元素,让我们找到这个元素。如果没有时间复杂度的限制,我们可以用多种方法来做,最straightforward的解法就是用个双指针,每次检验两个,就能找出落单的。也可以像Single Number里的方法那样,将所有数字亦或起来,相同的数字都会亦或成0,剩下就是那个落单的数字。那么由于有了时间复杂度的限制,需要为O(logn),而数组又是有序的,不难想到要用二分搜索法来做。二分搜索法的难点在于折半了以后,如何判断将要去哪个分支继续搜索,而这道题确实判断条件不明显,比如下面两个例子:

1 1 2 2 3

1 2 2 3 3

这两个例子初始化的时候left=0, right=4一样,mid算出来也一样为2,但是他们要去的方向不同,如何区分出来呢?仔细观察我们可以发现,如果当前数字出现两次的话,我们可以通过数组的长度跟当前位置的关系,计算出右边和当前数字不同的数字的总个数,如果是偶数个,说明落单数左半边,反之则在右半边。有了这个规律就可以写代码了,为啥我们直接就能跟mid+1比呢,不怕越界吗?当然不会,因为left如何跟right相等,就不会进入循环,所以mid一定会比right小,一定会有mid+1存在。当然mid是有可能为0的,所以此时当mid和mid+1的数字不等时,我们直接返回mid的数字就可以了,参见代码如下:

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
class Solution {
public:
int singleNonDuplicate(vector<int>& nums) {
int n = nums.size();
int left = 0, right = nums.size()-1, mid;
while(left < right) {
mid = left + (right - left) / 2;
if (nums[mid] == nums[mid+1]) {
if ((n-1-mid) % 2 == 1)
right = mid;
else
left = mid + 1;
}
else {
if (mid == 0 || nums[mid] != nums[mid-1])
return nums[mid];
if ((n-1-mid) % 2 == 1)
left = mid + 1;
else
right = mid;
}
}
return nums[left];
}
};

下面这种解法是对上面的分支进行合并,使得代码非常的简洁。使用到了亦或1这个小技巧,为什么要亦或1呢,原来我们可以将坐标两两归为一对,比如0和1,2和3,4和5等等。而亦或1可以直接找到你的小伙伴,比如对于2,亦或1就是3,对于3,亦或1就是2。如果你和你的小伙伴相等了,说明落单数在右边,如果不等,说明在左边,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int singleNonDuplicate(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == nums[mid ^ 1]) left = mid + 1;
else right = mid;
}
return nums[left];
}
};

Leetcode541. Reverse String II

Given a string and an integer k, you need to reverse the first k characters for every 2k characters counting from the start of the string. If there are less than k characters left, reverse all of them. If there are less than 2k but greater than or equal to k characters, then reverse the first k characters and left the other as original.

Example:

1
2
Input: s = "abcdefg", k = 2
Output: "bacdfeg"

这是一道字符逆序操作题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
string reverseStr(string s, int k) {
int len = s.length();
if(len == 0)
return "";
string sb = "";
int index = 0;
while (index < len){
string tmp = "";
for (int i = index; i < k + index && i < len; i++) {
tmp += s[i];
}
index += k;
reverse(tmp.begin(), tmp.end());
sb = sb + tmp;
for (int i = index; i < k + index && i < len; i++){
sb += s[i];
}
index += k;
}
return sb;
}
};

Leetcode542. 01 Matrix

Given an m x n binary matrix mat, return the distance of the nearest 0 for each cell.

The distance between two adjacent cells is 1.

Example 1:

1
2
3
4
5
6
7
8
Input: mat = [
[0,0,0],
[0,1,0],
[0,0,0]]
Output: [
[0,0,0],
[0,1,0],
[0,0,0]]

Example 2:

1
2
Input: mat = [[0,0,0],[0,1,0],[1,1,1]]
Output: [[0,0,0],[0,1,0],[1,2,1]]

这道题给了我们一个只有0和1的矩阵,让我们求每一个1到离其最近的0的距离,其实也就是求一个BFS。我们可以首先遍历一次矩阵,将值为0的点都存入queue,将值为1的点改为INT_MAX。之前像什么遍历迷宫啊,起点只有一个,而这道题所有为0的点都是起点。然后开始BFS遍历,从queue中取出一个数字,遍历其周围四个点,如果越界或者周围点的值小于等于当前值加1,则直接跳过。因为周围点的距离更小的话,就没有更新的必要,否则将周围点的值更新为当前值加1,然后把周围点的坐标加入queue,参见代码如下:

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
class Solution {
public:
vector<vector<int>> updateMatrix(vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();
vector<vector<int>> dir = {{-1, 0}, {1, 0}, {0, 1}, {0, -1}};
queue<pair<int, int>> q;
for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
if (mat[i][j] == 0)
q.push({i, j});
else
mat[i][j] = INT_MAX;
while(!q.empty()) {
pair<int, int> t = q.front();
q.pop();
for (int i = 0; i < 4; i ++) {
int x = t.first + dir[i][0];
int y = t.second + dir[i][1];
if (x < 0 || x >= m || y < 0 || y >= n || mat[x][y] <= mat[t.first][t.second])
continue;
mat[x][y] = mat[t.first][t.second] + 1;
q.push({x, y});
}
}
return mat;
}
};

Leetcode543. Diameter of Binary Tree

Given a binary tree, you need to compute the length of the diameter of the tree. The diameter of a binary tree is the length of the longest path between any two nodes in a tree. This path may or may not pass through the root.

Example:

1
2
3
4
5
6
7
Given a binary tree
1
/ \
2 3
/ \
4 5
Return 3, which is the length of the path [4,2,1,3] or [5,2,1,3].

递归解题。遍历整个数,根据题意,直径等于左子树深度加上右子树深度,实时更新max,返回值是左右子树较大的深度值加1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:

int dfs(TreeNode* root, int &maxx) {
if(root == NULL)
return 0;
int left = dfs(root->left, maxx);
int right = dfs(root->right, maxx);
maxx = max(right+left, maxx);
return max(right, left) + 1;
}

int diameterOfBinaryTree(TreeNode* root) {
if(root == NULL)
return 0;
int maxx = -1;
dfs(root, maxx);
return maxx;
}
};

Leetcode547. Number of Provinces

There are n cities. Some of them are connected, while some are not. If city a is connected directly with city b, and city b is connected directly with city c, then city a is connected indirectly with city c.

A province is a group of directly or indirectly connected cities and no other cities outside of the group.

You are given an n x n matrix isConnected where isConnected[i][j] = 1 if the ith city and the jth city are directly connected, and isConnected[i][j] = 0 otherwise.

Return the total number of provinces.

Example 1:

1
2
Input: isConnected = [[1,1,0],[1,1,0],[0,0,1]]
Output: 2

Example 2:

1
2
Input: isConnected = [[1,0,0],[0,1,0],[0,0,1]]
Output: 3

这道题让我们求省的个数,题目中对于省的定义是可以传递的,比如A和B同省,B和C是同省,那么即使A和C不同省,那么他们三人也属于同省。那么比较直接的解法就是 DFS 搜索,对于某个城市,遍历其临近城市,然后再遍历其邻居的邻居,那么就能把属于同一个省的城市都遍历一遍,同时标记出已经遍历过的城市,然后累积省的个数,再去对于没有遍历到的城市在找临近的城市,这样就能求出个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
if (isConnected.size() == 0)
return 0;
int res = 0, n = isConnected.size();
vector<int> visited(n, 0);
for (int i = 0; i < n; i ++) {
if (visited[i])
continue;
helper(isConnected, i, n, visited);
res ++;
}
return res;
}

void helper(vector<vector<int>>& isConnected, int i, int n, vector<int>& visited) {
visited[i] = true;
for (int ii = 0; ii < n; ii ++)
if (isConnected[i][ii] && !visited[ii])
helper(isConnected, ii, n, visited);
}
};

Leetcode551. Student Attendance Record I

You are given a string representing an attendance record for a student. The record only contains the following three characters:

  • ‘A’ : Absent.
  • ‘L’ : Late.
  • ‘P’ : Present.
    A student could be rewarded if his attendance record doesn’t contain more than one ‘A’ (absent) or more than two continuous ‘L’ (late).

You need to return whether the student could be rewarded according to his attendance record.

Example 1:

1
2
Input: "PPALLP"
Output: True

Example 2:
1
2
Input: "PPALLL"
Output: False

简单字符串统计,如果出席记录不包含多于一个“A”(缺席)或超过两个连续的“L”(晚),学生可以获得奖励。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
bool checkRecord(string s) {
int aa[3] = {0};
for(char c : s) {
if(c == 'A') {
aa[0] ++;
aa[1] = 0;
}
if(c == 'L') {
aa[1] ++;
if(aa[1] > 2)
break;
}else {
aa[1] = 0;
}
}
if(aa[0] <= 1 && aa[1] <= 2)
return true;
return false;
}
};

Leetcode553. Optimal Division

You are given an integer array nums. The adjacent integers in nums will perform the float division.

For example, for nums = [2,3,4], we will evaluate the expression “2/3/4”.
However, you can add any number of parenthesis at any position to change the priority of operations. You want to add these parentheses such the value of the expression after the evaluation is maximum.

Return the corresponding expression that has the maximum value in string format.

Note: your expression should not contain redundant parenthesis.

Example 1:

1
2
3
4
5
6
7
8
9
10
Input: nums = [1000,100,10,2]
Output: "1000/(100/10/2)"
Explanation:
1000/(100/10/2) = 1000/((100/10)/2) = 200
However, the bold parenthesis in "1000/((100/10)/2)" are redundant, since they don't influence the operation priority. So you should return "1000/(100/10/2)".
Other cases:
1000/(100/10)/2 = 50
1000/(100/(10/2)) = 50
1000/100/10/2 = 0.5
1000/100/(10/2) = 2

Example 2:

1
2
Input: nums = [2,3,4]
Output: "2/(3/4)"

Example 3:

1
2
Input: nums = [2]
Output: "2"

这道题给了我们一个数组,让我们确定除法的顺序,从而得到值最大的运算顺序,并且不能加多余的括号。刚开始博主没看清题,以为是要返回最大的值,就直接写了个递归的暴力搜索的方法,结果发现是要返回带括号的字符串,尝试的修改了一下,觉得挺麻烦。于是直接放弃抵抗,上网参考大神们的解法,结果大吃一惊,这题原来还可以这么解,完全是数学上的知识啊,太tricky了。数组中n个数字,如果不加括号就是:

x1 / x2 / x3 / … / xn

那么我们如何加括号使得其值最大呢,那么就是将x2后面的除数都变成乘数,比如只有三个数字的情况 a / b / c,如果我们在后两个数上加上括号 a / (b / c),实际上就是a / b * c。而且b永远只能当除数,a也永远只能当被除数。同理,x1只能当被除数,x2只能当除数,但是x3之后的数,只要我们都将其变为乘数,那么得到的值肯定是最大的,所以就只有一种加括号的方式,即:

x1 / (x2 / x3 / … / xn)

这样的话就完全不用递归了,这道题就变成了一个道简单的字符串操作的题目了,这思路,博主服了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
string optimalDivision(vector<int>& nums) {
if (nums.size() == 0)
return "";
int len = nums.size();
string res = "";
res += to_string(nums[0]);
for (int i = 1; i < len; i ++) {
if (i != 1 || len == 2)
res += ("/" + to_string(nums[i]));
else
res += ("/(" + to_string(nums[i]));
}
if (len > 2)
res += ")";
return res;
}
};

Leetcode554. Brick Wall

There is a brick wall in front of you. The wall is rectangular and has several rows of bricks. The bricks have the same height but different width. You want to draw a vertical line from the top to the bottom and cross the leastbricks.

The brick wall is represented by a list of rows. Each row is a list of integers representing the width of each brick in this row from left to right.

If your line go through the edge of a brick, then the brick is not considered as crossed. You need to find out how to draw the line to cross the least bricks and return the number of crossed bricks.

You cannot draw a line just along one of the two vertical edges of the wall, in which case the line will obviously cross no bricks.

Example:

1
2
3
4
5
6
7
8
Input: 
[[1,2,2,1],
[3,1,2],
[1,3,2],
[2,4],
[3,1,2],
[1,3,1,1]]
Output: 2

Note:

  • The width sum of bricks in different rows are the same and won’t exceed INT_MAX.
  • The number of bricks in each row is in range [1,10,000]. The height of wall is in range [1,10,000]. Total number of bricks of the wall won’t exceed 20,000.

这道题给了我们一个砖头墙壁,上面由不同的长度的砖头组成,让选个地方从上往下把墙劈开,使得被劈开的砖头个数最少,前提是不能从墙壁的两边劈,这样没有什么意义。这里使用一个 HashMap 来建立每一个断点的长度和其出现频率之间的映射,这样只要从断点频率出现最多的地方劈墙,损坏的板砖一定最少。遍历砖墙的每一层,新建一个变量 sum,然后从第一块转头遍历到倒数第二块,将当前转头长度累加到 sum 上,这样每次得到的 sum 就是断点的长度,将其在 HashMap 中的映射值自增1,并且每次都更新下最大的映射值到变量 mx,这样最终 mx 就是出现次数最多的断点值,在这里劈开,绝对损伤的转头数量最少,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int leastBricks(vector<vector<int>>& wall) {
map<int, int> m;
int res = 0;
for (int i = 0; i < wall.size(); i ++) {
int sum = 0;
for (int j = 0; j < wall[i].size()-1; j ++) {
sum += wall[i][j];
m[sum] ++;
res = max(res, m[sum]);
}
}
for (auto it = m.begin(); it != m.end(); it ++)
res = max(res, it->second);
return wall.size() - res;
}
};

Leetcode556. Next Greater Element III

Given a positive 32-bit integer n, you need to find the smallest 32-bit integer which has exactly the same digits existing in the integer n and is greater in value than n. If no such positive 32-bit integer exists, you need to return -1.

Example 1:

1
2
Input: 12
Output: 21

Example 2:

1
2
Input: 21
Output: -1

这道题给了我们一个数字,让我们对各个位数重新排序,求出刚好比给定数字大的一种排序,如果不存在就返回-1。这道题给的例子的数字都比较简单,我们来看一个复杂的,比如12443322,这个数字的重排序结果应该为13222344,如果我们仔细观察的话会发现数字变大的原因是左数第二位的2变成了3,细心的童鞋会更进一步的发现后面的数字由降序变为了升序,这也不难理解,因为我们要求刚好比给定数字大的排序方式。那么我们再观察下原数字,看看2是怎么确定的,我们发现,如果从后往前看的话,2是第一个小于其右边位数的数字,因为如果是个纯降序排列的数字,做任何改变都不会使数字变大,直接返回-1。知道了找出转折点的方法,再来看如何确定2和谁交换,这里2并没有跟4换位,而是跟3换了,那么如何确定的3?其实也是从后往前遍历,找到第一个大于2的数字交换,然后把转折点之后的数字按升序排列就是最终的结果了。最后记得为防止越界要转为长整数型,然后根据结果判断是否要返回-1即可,参见代码如下:

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
class Solution {
public:

static bool comp(int &a, int &b) {
return a > b;
}

int nextGreaterElement(int n) {
vector<int> nums;
while(n > 0) {
nums.push_back(n%10);
n /= 10;
}
int len = nums.size();
int j, i = len-1;
for (i = 0; i < len-1; i ++)
if (nums[i] > nums[i+1])
break;
if (i == len-1)
return -1;
i ++;
for (j = 0; j < len-1; j ++)
if (nums[j] > nums[i])
break;
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
sort(nums.begin(), nums.begin()+i, comp);
long int res = 0;
for (i = len-1; i >= 0; i --) {
res = res * 10 + nums[i];
if (res > INT_MAX)
return -1;
}
return res;
}
};

Leetcode557. Reverse Words in a String III

Given a string, you need to reverse the order of characters in each word within a sentence while still preserving whitespace and initial word order.

Example 1:
Input: “Let’s take LeetCode contest”
Output: “s’teL ekat edoCteeL tsetnoc”
Note: In the string, each word is separated by single space and there will not be any extra space in the string.

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
class Solution {
public:

string resverse(string s, int begin, int end){
string ss;
for(int i=end;i>=begin;i--)
ss += s[i];
return ss;
}
// 这个函数没用的哦,之前用了这个函数结果效率相当低

string reverseWords(string s) {
string res;
int begin=0, temp;
char c;
for(int i=0;i<s.length();i++){
if(s[i]==' '){
temp=i-1;
for(int j=begin;j<temp;j++,temp--){
c=s[j];
s[j]=s[temp];
s[temp]=c;
}
begin = i+1;
res += " ";
}
}
temp=s.length()-1;
for(int j=begin;j<temp;j++,temp--){
c=s[j];
s[j]=s[temp];
s[temp]=c;
}
return s;
}
};

另一种方法:

1
2
3
4
5
6
7
8
9
string reverseWords(string s) {
size_t front = 0;
for(int i = 0; i <= s.length(); ++i){
if(i == s.length() || s[i] == ' '){
reverse(&s[front], &s[i]);
front = i + 1;
}
}
return s;

用python一行就可以搞定

1
return " ".join([i[::-1] for i in s.split()])

Leetcode558. Quad Tree Intersection

A quadtree is a tree data in which each internal node has exactly four children: topLeft, topRight, bottomLeft and bottomRight. Quad trees are often used to partition a two-dimensional space by recursively subdividing it into four quadrants or regions.

We want to store True/False information in our quad tree. The quad tree is used to represent a N * N boolean grid. For each node, it will be subdivided into four children nodes until the values in the region it represents are all the same. Each node has another two boolean attributes : isLeaf and val. isLeafis true if and only if the node is a leaf node. The val attribute for a leaf node contains the value of the region it represents.

For example, below are two quad trees A and B:

A:

1
2
3
4
5
6
7
8
9
10
11
12
13
+-------+-------+   T: true
| | | F: false
| T | T |
| | |
+-------+-------+
| | |
| F | F |
| | |
+-------+-------+
topLeft: T
topRight: T
bottomLeft: F
bottomRight: F

B:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
+-------+---+---+
| | F | F |
| T +---+---+
| | T | T |
+-------+---+---+
| | |
| T | F |
| | |
+-------+-------+
topLeft: T
topRight:
topLeft: F
topRight: F
bottomLeft: T
bottomRight: T
bottomLeft: T
bottomRight: F

Your task is to implement a function that will take two quadtrees and return a quadtree that represents the logical OR (or union) of the two trees.

1
2
3
4
5
6
7
8
9
10
A:                 B:                 C (A or B):
+-------+-------+ +-------+---+---+ +-------+-------+
| | | | | F | F | | | |
| T | T | | T +---+---+ | T | T |
| | | | | T | T | | | |
+-------+-------+ +-------+---+---+ +-------+-------+
| | | | | | | | |
| F | F | | T | F | | T | F |
| | | | | | | | |
+-------+-------+ +-------+-------+ +-------+-------+

Note:

  • Both A and B represent grids of size N * N.
  • N is guaranteed to be a power of 2.
  • If you want to know more about the quad tree, you can refer to its wiki.
  • The logic OR operation is defined as this: “A or B” is true if A is true, or if B is true, or if both A and B are true.

这道题又是一道四叉树的题,说是给了我们两个四叉树,然后让我们将二棵树相交形成了一棵四叉树,相交的机制采用的是或,即每个自区域相‘或’,题目中给的例子很好的说明了一些相‘或’的原则,比如我们看A和B中的右上结点,我们发现A树的右上结点已经是一个值为true的叶结点,而B的右上结点还是一个子树,那么此时不论子树里有啥内容,我们相交后的树的右上结点应该跟A树的右上结点保持一致,假如A树的右上结点值是false的话,相‘或’起不到任何作用,那么相交后的树的右上结点应该跟B树的右上结点保持一致。那么我们可以归纳出,只有某一个结点是叶结点了,我们看其值,如果是true,则相交后的结点和此结点保持一致,否则跟另一个结点保持一致。比较麻烦的情况是当两个结点都不是叶结点的情况,此时我们需要对相对应的四个子结点分别调用递归函数,调用之后还需要进行进一步处理,因为一旦四个子结点的值相同,且都是叶结点的话,那么此时应该合并为一个大的叶结点,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
Node* intersect(Node* quadTree1, Node* quadTree2) {
if (quadTree1->isLeaf) return quadTree1->val ? quadTree1 : quadTree2;
if (quadTree2->isLeaf) return quadTree2->val ? quadTree2 : quadTree1;
Node *tl = intersect(quadTree1->topLeft, quadTree2->topLeft);
Node *tr = intersect(quadTree1->topRight, quadTree2->topRight);
Node *bl = intersect(quadTree1->bottomLeft, quadTree2->bottomLeft);
Node *br = intersect(quadTree1->bottomRight, quadTree2->bottomRight);
if (tl->val == tr->val && tl->val == bl->val && tl->val == br->val && tl->isLeaf && tr->isLeaf && bl->isLeaf && br->isLeaf) {
return new Node(tl->val, true, NULL, NULL, NULL, NULL);
} else {
return new Node(false, false, tl, tr, bl, br);
}
}
};

Leetcode559. Maximum Depth of N-ary Tree

Given a n-ary tree, find its maximum depth.

The maximum depth is the number of nodes along the longest path from the root node down to the farthest leaf node.

For example, given a 3-ary tree:

We should return its max depth, which is 3.

Note:

The depth of the tree is at most 1000.
The total number of nodes is at most 5000.

多叉树最大深度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int maxDepth(Node* root) {
if(root == NULL)
return 0;
if(root->children.size() == 0)
return 1;
int maxx = -1;
for(int i = 0; i < root->children.size(); i ++){
int temp = maxDepth(root->children[i]);
if(maxx < temp)
maxx = temp;
}
return maxx+1;
}
};

Leetcode560. Subarray Sum Equals K

Given an array of integers and an integer k, you need to find the total number of continuous subarrays whose sum equals to k.

Example 1:

1
2
Input:nums = [1,1,1], k = 2
Output: 2

Note:

  • The length of the array is in range [1, 20,000].
  • The range of numbers in the array is [-1000, 1000] and the range of the integer k is [-1e7, 1e7].

这道题给了我们一个数组,让求和为k的连续子数组的个数,博主最开始看到这道题想着肯定要建立累加和数组啊,然后遍历累加和数组的每个数字,首先看其是否为k,是的话结果 res 自增1,然后再加个往前的循环,这样可以快速求出所有的子数组之和,看是否为k,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
int res = 0, n = nums.size();
vector<int> sums = nums;
for (int i = 1; i < n; ++i) {
sums[i] = sums[i - 1] + nums[i];
}
for (int i = 0; i < n; ++i) {
if (sums[i] == k) ++res;
for (int j = i - 1; j >= 0; --j) {
if (sums[i] - sums[j] == k) ++res;
}
}
return res;
}
};

用一个 HashMap 来建立连续子数组之和跟其出现次数之间的映射,初始化要加入 {0,1} 这对映射,这是为啥呢,因为解题思路是遍历数组中的数字,用 sum 来记录到当前位置的累加和,建立 HashMap 的目的是为了可以快速的查找 sum-k 是否存在,即是否有连续子数组的和为 sum-k,如果存在的话,那么和为k的子数组一定也存在,这样当 sum 刚好为k的时候,那么数组从起始到当前位置的这段子数组的和就是k,满足题意,如果 HashMap 中事先没有 m[0] 项的话,这个符合题意的结果就无法累加到结果 res 中,这就是初始化的用途。上面讲解的内容顺带着也把 for 循环中的内容解释了,这里就不多阐述了,有疑问的童鞋请在评论区留言哈,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
int res = 0, sum = 0, n = nums.size();
unordered_map<int, int> m{{0, 1}};
for (int i = 0; i < n; ++i) {
sum += nums[i];
res += m[sum - k];
++m[sum];
}
return res;
}
};

Leetcode561. Array Partition I

Given an array of 2n integers, your task is to group these integers into n pairs of integer, say (a1, b1), (a2, b2), …, (an, bn) which makes sum of min(ai, bi) for all i from 1 to n as large as possible.

Example 1:

1
2
Input: [1,4,3,2]
Output: 4

Explanation: n is 2, and the maximum sum of pairs is 4 = min(1, 2) + min(3, 4).
Note:
n is a positive integer, which is in the range of [1, 10000].
All the integers in the array will be in the range of [-10000, 10000].

这道题目给了我们一个数组有2n integers, 需要我们把这个数组分成n对,然后从每一对里面拿小的那个数字,把所有的加起来,返回这个sum。并且要使这个sum 尽量最大。如何让sum 最大化呢,我们想一下,如果是两个数字,一个很小,一个很大,这样的话,取一个小的数字,就浪费了那个大的数字。所以我们要使每一对的两个数字尽可能接近。我们先把nums sort 一下,让它从小到大排列,接着每次把index: 0, 2, 4…偶数位的数字加起来就可以了。

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int arrayPairSum(vector<int>& nums) {
sort(nums.begin(),nums.end());
int sum=0;
for(int i=0;i<nums.size();i+=2)
sum+=nums[i];
return sum;
}
};

Leetcode563. Binary Tree Tilt

Given a binary tree, return the tilt of the whole tree.

The tilt of a tree node is defined as the absolute difference between the sum of all left subtree node values and the sum of all right subtree node values. Null node has tilt 0.

The tilt of the whole tree is defined as the sum of all nodes’ tilt.

Example:

1
2
3
4
5
6
7
8
9
10
Input: 
1
/ \
2 3
Output: 1
Explanation:
Tilt of node 2 : 0
Tilt of node 3 : 0
Tilt of node 1 : |2-3| = 1
Tilt of binary tree : 0 + 0 + 1 = 1

这道题其实是要求 求出各个节点左右子树的差的绝对值,将这些绝对值求和并返回。左右子树的差 = | 左子树所有节点的值的和 - 右子树所有节点的值的和 |。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:

int dfs(TreeNode* root, int& ans) {
if(root == NULL)
return 0;
int left, right;
left = dfs(root->left, ans);
right = dfs(root->right, ans);
int tilt = abs(left - right);
ans += tilt;
return left + right + root->val;
}

int findTilt(TreeNode* root) {
int ans = 0;
dfs(root, ans);
return ans;
}
};

Leetcode565. Array Nesting

A zero-indexed array A of length N contains all integers from 0 to N-1. Find and return the longest length of set S, where S[i] = {A[i], A[A[i]], A[A[A[i]]], … } subjected to the rule below.

Suppose the first element in S starts with the selection of element A[i] of index = i, the next element in S should be A[A[i]], and then A[A[A[i]]]… By that analogy, we stop adding right before a duplicate element occurs in S.

Example 1:

1
2
3
4
5
6
7
Input: A = [5,4,0,3,1,6,2]
Output: 4
Explanation:
A[0] = 5, A[1] = 4, A[2] = 0, A[3] = 3, A[4] = 1, A[5] = 6, A[6] = 2.

One of the longest S[K]:
S[0] = {A[0], A[5], A[6], A[2]} = {5, 6, 2, 0}

Note:

  • N is an integer within the range [1, 20,000].
  • The elements of A are all distinct.
  • Each element of A is an integer within the range [0, N-1].

这道题让我们找嵌套数组的最大个数,给的数组总共有n个数字,范围均在 [0, n-1] 之间,题目中也把嵌套数组的生成解释的很清楚了,其实就是值变成坐标,得到的数值再变坐标。那么实际上当循环出现的时候,嵌套数组的长度也不能再增加了,而出现的这个相同的数一定是嵌套数组的首元素。其实对于遍历过的数字,我们不用再将其当作开头来计算了,而是只对于未遍历过的数字当作嵌套数组的开头数字,不过在进行嵌套运算的时候,并不考虑中间的数字是否已经访问过,而是只要找到和起始位置相同的数字位置,然后更新结果 res,参见代码如下:

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
class Solution {
public:
int arrayNesting(vector<int>& nums) {
int len = nums.size();
vector<bool> visited(len, false);
int res = 0;
for (int i = 0; i < len; i ++) {
if (visited[i])
continue;
res = max(res, helper(nums, i, visited));
}
return res;
}

int helper(vector<int>& nums, int start, vector<bool> &visited) {
int len = nums.size();
int res = 0;
while(start < len && !visited[start]) {
visited[start] = true;
res ++;
start = nums[start];
}
return res;
}
};

Leetcode566. Reshape the Matrix

In MATLAB, there is a very useful function called ‘reshape’, which can reshape a matrix into a new one with different size but keep its original data.

You’re given a matrix represented by a two-dimensional array, and two positive integers r and c representing the row number and column number of the wanted reshaped matrix, respectively.

The reshaped matrix need to be filled with all the elements of the original matrix in the same row-traversing order as they were.

If the ‘reshape’ operation with given parameters is possible and legal, output the new reshaped matrix; Otherwise, output the original matrix.

Example 1:

1
2
3
4
5
6
7
8
9
Input: 
nums =
[[1,2],
[3,4]]
r = 1, c = 4
Output:
[[1,2,3,4]]
Explanation:
The row-traversing of nums is [1,2,3,4]. The new reshaped matrix is a 1 * 4 matrix, fill it row by row by using the previous list.

Example 2:
1
2
3
4
5
6
7
8
9
10
Input: 
nums =
[[1,2],
[3,4]]
r = 2, c = 4
Output:
[[1,2],
[3,4]]
Explanation:
There is no way to reshape a 2 * 2 matrix to a 2 * 4 matrix. So output the original matrix.

把矩阵换个样子输出出来,效率还挺高的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<vector<int>> matrixReshape(vector<vector<int>>& nums, int r, int c) {
int m = nums.size(), n = nums[0].size();
if(m*n != r*c)
return nums;
vector<vector<int>> res(r, vector<int>(c, 0));
int ii = 0, jj = 0;
for(int i = 0; i < m; i ++)
for(int j = 0; j < n; j ++) {
res[ii][jj] = nums[i][j];
if(jj == c - 1) {
ii ++;
jj = 0;
}
else
jj ++;
}
return res;
}
};

Leetcode567. Permutation in String

Given two strings s1 and s2, write a function to return true if s2 contains the permutation of s1. In other words, one of the first string’s permutations is the substring of the second string.

Example 1:

1
2
3
Input:s1 = "ab" s2 = "eidbaooo"
Output:True
Explanation: s2 contains one permutation of s1 ("ba").

Example 2:

1
2
Input:s1= "ab" s2 = "eidboaoo"
Output: False

Note:

  • The input strings only contain lower case letters.
  • The length of both given strings is in range [1, 10,000].

这道题给了两个字符串s1和s2,问我们s1的全排列的字符串任意一个是否为s2的字串。这道题的正确做法应该是使用滑动窗口Sliding Window的思想来做,可以使用两个哈希表来做,或者是使用一个哈希表配上双指针来做。我们先来看使用两个哈希表来做的情况,我们先来分别统计s1和s2中前n1个字符串中各个字符出现的次数,其中n1为字符串s1的长度,这样如果二者字符出现次数的情况完全相同,说明s1和s2中前n1的字符互为全排列关系,那么符合题意了,直接返回true。如果不是的话,那么我们遍历s2之后的字符,对于遍历到的字符,对应的次数加1,由于窗口的大小限定为了n1,所以每在窗口右侧加一个新字符的同时就要在窗口左侧去掉一个字符,每次都比较一下两个哈希表的情况,如果相等,说明存在,参见代码如下:

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
class Solution {
public:
bool checkInclusion(string s1, string s2) {
int len1 = s1.length(), len2 = s2.length();
if (len1 > len2)
return false;
unordered_map<char, int>::iterator it;
unordered_map<char, int> m1, m2;
for (int i = 0; i < len1; i ++) {
m1[s1[i]] ++;
m2[s2[i]] ++;
}
for (int i = len1; i < len2; i ++) {
for (it = m1.begin(); it != m1.end(); it ++) {
if (it->second != m2[it->first])
break;
}
if (it == m1.end())
return true;
m2[s2[i-len1]] --;
m2[s2[i]] ++;
}
for (it = m1.begin(); it != m1.end(); it ++) {
if (it->second != m2[it->first])
break;
}
if (it == m1.end())
return true;
return false;
}
};

Leetcode572. Subtree of Another Tree

Given two non-empty binary trees s and t, check whether tree t has exactly the same structure and node values with a subtree of s. A subtree of s is a tree consists of a node in s and all of this node’s descendants. The tree s could also be considered as a subtree of itself.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
Given tree s:
3
/ \
4 5
/ \
1 2
Given tree t:
4
/ \
1 2
Return true, because t has the same structure and node values with a subtree of s.

Example 2:
1
2
3
4
5
6
7
8
9
10
11
12
13
Given tree s:
3
/ \
4 5
/ \
1 2
/
0
Given tree t:
4
/ \
1 2
Return false.

这道题让我们求一个数是否是另一个树的子树,从题目中的第二个例子中可以看出,子树必须是从叶结点开始的,中间某个部分的不能算是子树,那么我们转换一下思路,是不是从s的某个结点开始,跟t的所有结构都一样,那么问题就转换成了判断两棵树是否相同,也就是Same Tree的问题了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:

bool issame(TreeNode* s, TreeNode* t) {
if(!s && !t)
return true;
if(!s || !t)
return false;
if(s->val != t->val)
return false;
return issame(s->left, t->left) && issame(s->right, t->right);
}

bool isSubtree(TreeNode* s, TreeNode* t) {
if (!s) return false;
if (issame(s, t)) return true;
return isSubtree(s->left, t) || isSubtree(s->right, t);
}
};

Leetcode575. Distribute Candies

Given an integer array with even length, where different numbers in this array represent different kinds of candies. Each number means one candy of the corresponding kind. You need to distribute these candies equally in number to brother and sister. Return the maximum number of kinds of candies the sister could gain.

Example 1:

1
2
3
4
5
6
Input: candies = [1,1,2,2,3,3]
Output: 3
Explanation:
There are three different kinds of candies (1, 2 and 3), and two candies for each kind.
Optimal distribution: The sister has candies [1,2,3] and the brother has candies [1,2,3], too.
The sister has three different kinds of candies.

Example 2:
1
2
3
4
Input: candies = [1,1,2,3]
Output: 2
Explanation: For example, the sister has candies [2,3] and the brother has candies [1,1].
The sister has two different kinds of candies, the brother has only one kind of candies.

记录糖果种类,若糖果种类大于数组的一半,妹妹最多得到candies.size()/2种糖果,否则每种糖果都可以得到
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int distributeCandies(vector<int>& candies) {
int len = candies.size();
int unique = 0;
sort(candies.begin(), candies.end());
for(int i = 0; i < len; i ++)
if(i == 0 || candies[i] != candies[i-1])
unique ++;
return min(unique, len/2);
}
};

LeetCode576. Out of Boundary Paths

There is an m by n grid with a ball. Given the start coordinate (i,j) of the ball, you can move the ball to adjacent cell or cross the grid boundary in four directions (up, down, left, right). However, you can at most move N times. Find out the number of paths to move the ball out of grid boundary. The answer may be very large, return it after mod 109 + 7.

Example 1:

1
2
Input:m = 2, n = 2, N = 2, i = 0, j = 0
Output: 6

Example 2:

1
2
Input:m = 1, n = 3, N = 3, i = 0, j = 1
Output: 12

Note:

  • Once you move the ball out of boundary, you cannot move it back.
  • The length and height of the grid is in range [1,50].
  • N is in range [0,50].

这道题给了我们一个二维的数组,某个位置放个足球,每次可以在上下左右四个方向中任意移动一步,总共可以移动N步,问我们总共能有多少种移动方法能把足球移除边界,由于结果可能是个巨大的数,所以让我们对一个大数取余。那么我们知道对于这种结果很大的数如果用递归解法很容易爆栈,所以最好考虑使用DP来解。那么我们使用一个三维的DP数组,其中dp[k][i][j]表示总共走k步,从(i,j)位置走出边界的总路径数。那么我们来找递推式,对于dp[k][i][j],走k步出边界的总路径数等于其周围四个位置的走k-1步出边界的总路径数之和,如果周围某个位置已经出边界了,那么就直接加上1,否则就在dp数组中找出该值,这样整个更新下来,我们就能得出每一个位置走任意步数的出界路径数了,最后只要返回dp[N][i][j]就是所求结果了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
vector<vector<vector<int>>> dp(maxMove+1, vector<vector<int>>(m, vector<int>(n, 0)));
for (int k = 1; k <= maxMove; k ++)
for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++) {
long long int x1 = j == n-1 ? 1 : dp[k-1][i][j+1];
long long int x2 = j == 0 ? 1 : dp[k-1][i][j-1];
long long int x3 = i == m-1 ? 1 : dp[k-1][i+1][j];
long long int x4 = i == 0 ? 1 : dp[k-1][i-1][j];
dp[k][i][j] = (x1+x2+x3+x4) % 1000000007;
}
return dp[maxMove][startRow][startColumn];
}
};

Leetcode581. Shortest Unsorted Continuous Subarray

Given an integer array, you need to find one continuous subarray that if you only sort this subarray in ascending order, then the whole array will be sorted in ascending order, too.

You need to find the shortest such subarray and output its length.

Example 1:

1
2
3
Input: [2, 6, 4, 8, 10, 9, 15]
Output: 5
Explanation: You need to sort [6, 4, 8, 10, 9] in ascending order to make the whole array sorted in ascending order.

这道题是要找出最短的子数组,如果此子数组按照升序排列,则整个数组按照升序排列。先用一个数组temp保存nums,然后对temp排序,然后用两个变量start和end去找两个数组出现不同之处的第一个位置和最后一个位置,最后返回end-start+1就是要找的数组长度。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int findUnsortedSubarray(vector<int>& nums) {
vector<int> temp(nums);
sort(nums.begin(), nums.end());
int start, end;
for(start = 0; start < nums.size(); start ++) {
if(temp[start] != nums[start]) {
break;
}
}
for(end = nums.size()-1; end >= start; end --) {
if(temp[end] != nums[end]) {
break;
}
}
return end - start + 1;
}
};

Leetcode583. Delete Operation for Two Strings

Given two words word1 and word2 , find the minimum number of steps required to make word1 and word2 the same, where in each step you can delete one character in either string.

Example 1:

1
2
3
Input: "sea", "eat"
Output: 2
Explanation: You need one step to make "sea" to "ea" and another step to make "eat" to "ea".

Note:

  • The length of given words won’t exceed 500.
  • Characters in given words can only be lower-case letters.

这道题给了我们两个单词,问最少需要多少步可以让两个单词相等,每一步可以在任意一个单词中删掉一个字符。那么来分析怎么能让步数最少呢,是不是知道两个单词最长的相同子序列的长度,并乘以2,被两个单词的长度之和减,就是最少步数了。

定义一个二维的 dp 数组,其中dp[i][j]表示 word1 的前i个字符和 word2 的前j个字符组成的两个单词的最长公共子序列的长度。下面来看状态转移方程dp[i][j]怎么求,首先来考虑dp[i][j]dp[i-1][j-1]之间的关系,可以发现,如果当前的两个字符相等,那么dp[i][j] = dp[i-1][j-1] + 1,这不难理解吧,因为最长相同子序列又多了一个相同的字符,所以长度加1。由于 dp 数组的大小定义的是(n1+1) x (n2+1),所以比较的是word1[i-1]word2[j-1]。如果这两个字符不相等呢,难道直接将dp[i-1][j-1]赋值给dp[i][j]吗,当然不是,这里还要错位相比嘛,比如就拿题目中的例子来说,”sea” 和 “eat”,当比较第一个字符,发现 ‘s’ 和 ‘e’ 不相等,下一步就要错位比较啊,比较 sea 中第一个 ‘s’ 和 eat 中的 ‘a’,sea 中的 ‘e’ 跟 eat 中的第一个 ‘e’ 相比,这样dp[i][j]就要取dp[i-1][j]dp[i][j-1]中的较大值了,最后求出了最大共同子序列的长度,就能直接算出最小步数了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.length(), n = word2.length();
vector<vector<int>> dp(m+1, vector(n+1, 0));
for (int i = 1; i <= m; i ++)
for (int j = 1; j <= n; j ++)
if (word1[i-1] == word2[j-1])
dp[i][j] = dp[i-1][j-1] + 1;
else
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
return m + n - 2 * dp[m][n];
}
};

Leetcode589. N-ary Tree Preorder Traversal

Given an n-ary tree, return the preorder traversal of its nodes’ values.

For example, given a 3-ary tree:

Return its preorder traversal as: [1,3,5,6,2,4].

Note:

Recursive solution is trivial, could you do it iteratively?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> res;
void des(Node* root){
if(root==NULL)
return ;
res.push_back(root->val);
for(int i=0;i<root->children.size();i++)
des(root->children[i]);
return;
}

vector<int> preorder(Node* root) {
des(root);
return res;
}
};

Leetcode590. N-ary Tree Postorder Traversal

Given an n-ary tree, return the postorder traversal of its nodes’ values.

For example, given a 3-ary tree:

Return its postorder traversal as: [5,6,3,2,4,1].

Note:

Recursive solution is trivial, could you do it iteratively?

遍历一棵n叉树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:

vector<int> res;

void des(Node* root){
if(root==NULL)
return ;
if(root->children.size()==0){
res.push_back(root->val);
return ;
}
for(int i=0;i<root->children.size();i++){
des(root->children[i]);
}
res.push_back(root->val);
return ;
}
vector<int> postorder(Node* root) {
des(root);
return res;
}
};

Leetcode592. Fraction Addition and Subtraction

Given a string expression representing an expression of fraction addition and subtraction, return the calculation result in string format.

The final result should be an irreducible fraction. If your final result is an integer, say 2, you need to change it to the format of a fraction that has a denominator 1. So in this case, 2 should be converted to 2/1.

Example 1:

1
2
Input: expression = "-1/2+1/2"
Output: "0/1"

Example 2:

1
2
Input: expression = "-1/2+1/2+1/3"
Output: "1/3"

Example 3:

1
2
Input: expression = "1/3-1/2"
Output: "-1/6"

Example 4:

1
2
Input: expression = "5/3+1/3"
Output: "2/1"

这道题让我们做分数的加减法,给了我们一个分数加减法式子的字符串,然我们算出结果,结果当然还是用分数表示了。那么其实这道题主要就是字符串的拆分处理,再加上一点中学的数学运算的知识就可以了。中学数学告诉我们必须将分母变为同一个数,分子才能相加,为了简便,我们不求最小公倍数,而是直接乘上另一个数的分母,然后相加。不过得到的结果需要化简一下,我们求出分子分母的最大公约数,记得要取绝对值,然后分子分母分别除以这个最大公约数就是最后的结果了,参见代码如下:

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
60
61
62
63
64
65
66
class Solution {
public:

int fun(int m, int n){
if (m < 0)
m = -m;
if (n < 0)
n = -n;
if (m < n) {
int tmp = m;
m = n;
n = tmp;
}
int rem;
while(n > 0){
rem = m % n;
m = n;
n = rem;
}
return m;
}

int get_num(string e, int &i, int len) {
int tmp = 0, flag = 1;
if (e[i] == '-') {
flag = -1;
i ++;
}
else if (e[i] == '+' || e[i] == '/')
i ++;
while(i < len && '0' <= e[i] && e[i] <= '9') {
tmp = tmp*10 + e[i] - '0';
i ++;
}
return tmp*flag;
}

string fractionAddition(string expression) {
int len = expression.length(), i = 0;
vector<int> res;

while(i < len) {
if (res.size() == 0) {
res.push_back(get_num(expression, i, len));
res.push_back(get_num(expression, i, len));
}
else {
int res0 = get_num(expression, i, len);
int res1 = get_num(expression, i, len);
res0 = res0 * res[1];
res[0] = res[0] * res1 + res0;
res[1] = res[1] * res1;
}
}

if (res[0] == 0)
return "0/1";
else {
int t = fun(res[0], res[1]);
res[0] /= t;
res[1] /= t;
return to_string(res[0]) + "/" + to_string(res[1]);
}
return "";
}
};

Leetcode593. Valid Square

Given the coordinates of four points in 2D space p1, p2, p3 and p4, return true if the four points construct a square.

The coordinate of a point pi is represented as [xi, yi]. The input is not given in any order.

A valid square has four equal sides with positive length and four equal angles (90-degree angles).

Example 1:

1
2
Input: p1 = [0,0], p2 = [1,1], p3 = [1,0], p4 = [0,1]
Output: true

Example 2:

1
2
Input: p1 = [0,0], p2 = [1,1], p3 = [1,0], p4 = [0,12]
Output: false

Example 3:

1
2
Input: p1 = [1,0], p2 = [-1,0], p3 = [0,1], p4 = [0,-1]
Output: true

这道题给了我们四个点,让验证这四个点是否能组成一个正方形,刚开始博主考虑的方法是想判断四个角是否是直角,但即便四个角都是直角,也不能说明一定就是正方形,还有可能是矩形。还得判断各边是否相等。其实这里可以仅通过边的关系的来判断是否是正方形,根据初中几何的知识可以知道正方形的四条边相等,两条对角线相等,满足这两个条件的四边形一定是正方形。那么这样就好办了,只需要对四个点,两两之间算距离,如果计算出某两个点之间距离为0,说明两点重合了,直接返回 false,如果不为0,那么就建立距离和其出现次数之间的映射,最后如果我们只得到了两个不同的距离长度,那么就说明是正方形了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool validSquare(vector<int>& p1, vector<int>& p2, vector<int>& p3, vector<int>& p4) {
unordered_map<int, int> m;
vector<vector<int>> v{p1, p2, p3, p4};
for (int i = 0; i < 4; ++i) {
for (int j = i + 1; j < 4; ++j) {
int x1 = v[i][0], y1 = v[i][1], x2 = v[j][0], y2 = v[j][1];
int dist = (x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2);
if (dist == 0) return false;
++m[dist];
}
}
return m.size() == 2;
}
};

Leetcode594. Longest Harmonious Subsequence

We define a harmounious array as an array where the difference between its maximum value and its minimum value is exactly 1. Now, given an integer array, you need to find the length of its longest harmonious subsequence among all its possible subsequences.

Example 1:

1
2
3
Input: [1,3,2,2,5,2,3,7]
Output: 5
Explanation: The longest harmonious subsequence is [3,2,2,2,3].

由于所需子序列有且只有两种元素,且相差为1,所以可以用map将所有数字的个数记录下来,再遍历map,如果对于一个key,如果key+1也存在于map中,则存在以key和key+1两个数字组成的和谐子序列,长度为两个数字的个数之和。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int findLHS(vector<int>& nums) {
int res = 0;
unordered_map<int, int> mp;
for(int i = 0; i < nums.size(); i ++)
mp[nums[i]] ++;
for(auto i = mp.begin(); i != mp.end(); i ++) {
if(mp.count(i->first+1))
res = max(res, i->second + mp[i->first+1]);
}
return res;
}
};

Leetcode595. Big Countries

We define a harmonious array is an array where the difference between its maximum value and its minimum value is exactly 1.

Now, given an integer array, you need to find the length of its longest harmonious subsequence among all its possible subsequences.

Example 1:

1
2
3
Input: [1,3,2,2,5,2,3,7]
Output: 5
Explanation: The longest harmonious subsequence is [3,2,2,2,3].

这道题给了我们一个数组,让我们找出最长的和谐子序列,关于和谐子序列就是序列中数组的最大最小差值均为1。由于这里只是让我们求长度,并不需要返回具体的子序列。所以我们可以对数组进行排序,那么实际上我们只要找出来相差为1的两个数的总共出现个数就是一个和谐子序列的长度了。明白了这一点,我们就可以建立一个数字和其出现次数之间的映射,利用 TreeMap 的自动排序的特性,那么我们遍历 TreeMap 的时候就是从小往大开始遍历,我们从第二个映射对开始遍历,每次跟其前面的映射对比较,如果二者的数字刚好差1,那么就把二个数字的出现的次数相加并更新结果 res 即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int findLHS(vector<int>& nums) {
if (nums.empty()) return 0;
int res = 0;
map<int, int> m;
for (int num : nums) ++m[num];
for (auto it = next(m.begin()); it != m.end(); ++it) {
auto pre = prev(it);
if (it->first == pre->first + 1) {
res = max(res, it->second + pre->second);
}
}
return res;
}
};

Leetcode596. Classes More Than 5 Students

There is a table courses with columns: student and class Please list out all classes which have more than or equal to 5 students. For example, the table:

1
2
3
4
5
6
7
8
9
10
11
12
13
+---------+------------+
| student | class |
+---------+------------+
| A | Math |
| B | English |
| C | Math |
| D | Biology |
| E | Math |
| F | Computer |
| G | Math |
| H | Math |
| I | Math |
+---------+------------+

Should output:
1
2
3
4
5
+---------+
| class |
+---------+
| Math |
+---------+

1
2
3
SELECT class FROM courses
GROUP BY class
HAVING COUNT(DISTINCT(student)) >= 5

Leetcode598. Range Addition II

Given an m * n matrix M initialized with all 0’s and several update operations.

Operations are represented by a 2D array, and each operation is represented by an array with two positive integers a and b, which means M[i][j] should be added by one for all 0 <= i < a and 0 <= j < b.

You need to count and return the number of maximum integers in the matrix after performing all the operations.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Input: 
m = 3, n = 3
operations = [[2,2],[3,3]]
Output: 4
Explanation:
Initially, M =
[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]

After performing [2,2], M =
[[1, 1, 0],
[1, 1, 0],
[0, 0, 0]]

After performing [3,3], M =
[[2, 2, 1],
[2, 2, 1],
[1, 1, 1]]

So the maximum integer in M is 2, and there are four of it in M. So return 4.

求ops[0 .. len][0]和ops[0 .. len][1]的最小值,矩阵越靠近左上角的元素值越大,因为要加1的元素 行和列索引是从0开始的。那么只需要找到操作次数最多的元素位置即可。而操作次数最多的元素肯定是偏向于靠近矩阵左上角的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int maxCount(int m, int n, vector<vector<int>>& ops) {
if(ops.size() == 0)
return m * n;
int res = 0;
int min1 = 99999, min2 = 99999;
for(int i = 0; i < ops.size(); i ++) {
if(min1 > ops[i][0])
min1 = ops[i][0];
if(min2 > ops[i][1])
min2 = ops[i][1];
}
return min1 * min2;
}
};

Leetcode599. Minimum Index Sum of Two Lists

Suppose Andy and Doris want to choose a restaurant for dinner, and they both have a list of favorite restaurants represented by strings.

You need to help them find out their common interest with the least list index sum. If there is a choice tie between answers, output all of them with no order requirement. You could assume there always exists an answer.

Example 1:

1
2
3
4
5
Input:
["Shogun", "Tapioca Express", "Burger King", "KFC"]
["Piatti", "The Grill at Torrey Pines", "Hungry Hunter Steakhouse", "Shogun"]
Output: ["Shogun"]
Explanation: The only restaurant they both like is "Shogun".

Example 2:
1
2
3
4
5
Input:
["Shogun", "Tapioca Express", "Burger King", "KFC"]
["KFC", "Shogun", "Burger King"]
Output: ["Shogun"]
Explanation: The restaurant they both like and have the least index sum is "Shogun" with index sum 1 (0+1).

如果两者只有一个共同喜欢的餐馆,直接将其返回;如果不止一个,则返回下标之和最小的一个。两个列表均没有重复元素,长度均在[1, 1000]范围内,其中的元素长度均在[1, 30]范围内。万一有多个答案的话?如果index之和的最小值大于等于当前这一组index之和,那有两种情况,一个是大于,那么更新结果vector,另一种是等于,那么直接把当前字符串加入结果vector。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<string> findRestaurant(vector<string>& list1, vector<string>& list2) {
unordered_map<string, int> mp;
vector<string> ans;
int res = 9999999;
for(int i = 0; i < list1.size(); i ++)
mp[list1[i]] = i;
for(int i = 0; i < list2.size(); i ++) {
auto temp = mp.find(list2[i]);
if(temp != mp.end())
if(res >= i + temp->second) {
if(res > i + temp->second) {
ans.clear();
res = i + temp->second;
}
ans.push_back(temp->first);
}
}
return ans;
}
};

Leetcode600. Non-negative Integers without Consecutive Ones

Given a positive integer n, find the number of non-negative integers less than or equal to n, whose binary representations do NOT contain consecutive ones.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
Input: 5
Output: 5
Explanation:
Here are the non-negative integers <= 5 with their corresponding binary representations:
0 : 0
1 : 1
2 : 10
3 : 11
4 : 100
5 : 101
Among them, only integer 3 disobeys the rule (two consecutive ones) and the other 5 satisfy the rule.

这道题给了我们一个数字,让我们求不大于这个数字的所有数字中,其二进制的表示形式中没有连续1的个数。根据题目中的例子也不难理解题意。我们首先来考虑二进制的情况,对于1来说,有0和1两种,对于11来说,有00,01,10,三种情况,那么有没有规律可寻呢,其实是有的,我们可以参见这个帖子,这样我们就可以通过DP的方法求出长度为k的二进制数的无连续1的数字个数。由于题目给我们的并不是一个二进制数的长度,而是一个二进制数,比如100,如果我们按长度为3的情况计算无连续1点个数个数,就会多计算101这种情况。所以我们的目标是要将大于num的情况去掉。下面从头来分析代码,首先我们要把十进制数转为二进制数,将二进制数存在一个字符串中,并统计字符串的长度。然后我们利用这个帖子中的方法,计算该字符串长度的二进制数所有无连续1的数字个数,然后我们从倒数第二个字符开始往前遍历这个二进制数字符串,如果当前字符和后面一个位置的字符均为1,说明我们并没有多计算任何情况,不明白的可以带例子来看。

如果当前字符和后面一个位置的字符均为0,说明我们有多计算一些情况,就像之前举的100这个例子,我们就多算了101这种情况。我们怎么确定多了多少种情况呢,假如给我们的数字是8,二进制为1000,我们首先按长度为4算出所有情况,共8种。仔细观察我们十进制转为二进制字符串的写法,发现转换结果跟真实的二进制数翻转了一下,所以我们的t为”0001”,那么我们从倒数第二位开始往前遍历,到i=1时,发现有两个连续的0出现,那么i=1这个位置上能出现1的次数,就到one数组中去找,那么我们减去1,减去的就是0101这种情况,再往前遍历,i=0时,又发现两个连续0,那么i=0这个位置上能出1的次数也到one数组中去找,我们再减去1,减去的是1001这种情况,参见代码如下:

解法一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int findIntegers(int num) {
int cnt = 0, n = num;
string t = "";
while (n > 0) {
++cnt;
t += (n & 1) ? "1" : "0";
n >>= 1;
}
vector<int> zero(cnt), one(cnt);
zero[0] = 1; one[0] = 1;
for (int i = 1; i < cnt; ++i) {
zero[i] = zero[i - 1] + one[i - 1];
one[i] = zero[i - 1];
}
int res = zero[cnt - 1] + one[cnt - 1];
for (int i = cnt - 2; i >= 0; --i) {
if (t[i] == '1' && t[i + 1] == '1') break;
if (t[i] == '0' && t[i + 1] == '0') res -= one[i];
}
return res;
}
};

下面这种解法其实蛮有意思的,其实长度为k的二进制数字符串没有连续的1的个数是一个斐波那契数列f(k)。比如当k=5时,二进制数的范围是00000-11111,我们可以将其分为两个部分,00000-01111和10000-10111,因为任何大于11000的数字都是不成立的,因为有开头已经有了两个连续1。而我们发现其实00000-01111就是f(4),而10000-10111就是f(3),所以f(5) = f(4) + f(3),这就是一个斐波那契数列啦。那么我们要做的首先就是建立一个这个数组,方便之后直接查值。我们从给定数字的最高位开始遍历,如果某一位是1,后面有k位,就加上f(k),因为如果我们把当前位变成0,那么后面k位就可以直接从斐波那契数列中取值了。然后标记pre为1,再往下遍历,如果遇到0位,则pre标记为0。如果当前位是1,pre也是1,那么直接返回结果。最后循环退出后我们要加上数字本身这种情况,参见代码如下:

解法二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int findIntegers(int num) {
int res = 0, k = 31, pre = 0;
vector<int> f(32, 0);
f[0] = 1; f[1] = 2;
for (int i = 2; i < 31; ++i) {
f[i] = f[i - 2] + f[i - 1];
}
while (k >= 0) {
if (num & (1 << k)) {
res += f[k];
if (pre) return res;
pre = 1;
} else pre = 0;
--k;
}
return res + 1;
}
};

数据

基本数据类型

整型包括字符、短整型、整型和长整型,它们都分为有符号(singed)和无符号(皿sied)两种版本。长整型至少应该和整型一样长,而整型至少应该和短整型一样长。

字符在本质上是小整型值。缺省的char要么是signed char,要么是unsigned char,这取决于编译器,只有当程序所使用的char型变量的值位于signed charunsigned char的交集中,这个程序才是可移植的。

字符串常量:书写方式是"Hello""\aWarning!\a""Line1\nLine2"

链接属性

一共有三种,externalinternalnone,none被当作单独的个体,该标识符的多个声明被当作独立不同的实体。属于internal链接属性的标识符在同一个源文件内的所有声明中都指向同一个实体。属于external属性的标识符不管位于几个源文件都表示同一个实体。关键字externalstatic用于在声明中修改标识符的链接属性,external可以访问在其他任何位置定义的这个实体。在C中,static主要定义全局静态变量、定义局部静态变量、定义静态函数

  • 定义全局静态变量:在全局变量前面加上关键字static,该全局变量变成了全局静态变量。全局静态变量有以下特点。
    • 在全局区分配内存。
    • 如果没有初始化,其默认值为0.
    • 该变量在本文件内从定义开始到文件结束可见。
  • 定义局部静态变量:在局部变量前面加上关键字static,其特点如下:
    • 该变量在全局数据区分配内存。
    • 它始终驻留在全局数据区,直到程序运行结束。
    • 其作用域为局部作用域,当定义它的函数或语句块结束时,其作用域随之结束。
  • 定义静态函数:在函数返回类型前加上static关键字,函数即被定义为静态函数,其特点如下:
    • 静态函数只能在本源文件中使用
    • 在文件作用域中声明的inline函数默认为static类型

总结:用static定义的全局和局部静态变量的区别是,全局的静态变量的作用域和可见域都是从文件的定义开始到整个文件结束;而局部的静态变量可见域是从文件的定义开始到整个文件结束,作用域是从该语句块的定义开始到该语句块结束

extern的用法:

  • 声明一个全局(外部)变量。当用extern声明一个全局变量的时候,首先应明确一点:extern的作用范围是整个工程,也就是说当我们在.h文件中写了extern int a;链接的时候链接器会去其他的.c文件中找有没有int a的定义,如果没有,链接报错;当extern int a;写在.c文件中时,链接器会在这个.c文件该声明语句之后找有没有int a的定义,然后去其他的.cpp文件中找,如果都找不到,链接报错。值得注意的一点:当extern语句出现在头文件中时,不要将声明和定义在一条语句中给出,也就是不要在头文件中写类似于这样的语句:extern int a = 1;,这种写法,在gcc编译时会给出一个警告:warning: 'a' initialized and declared 'extern'
  • 所有一般(提倡)的做法是:只在头文件中通过extern给出全局变量的声明(即external int a; 而不要写成external int a = 1;),并在源文件中给出定义(并且只能定义一次)
  • extern “C” { /*用C实现的内容(通常写在另外的.c文件中)*/ }。C++完全兼容C,当extern与“C”连用时,作用是告诉编译器用C的编译规则去解析extern “C”后面的内容。最常见的差别就是C++支持函数重载,而标准C是不支持的。如果不指明extern “C”,C++编译器会根据自己的规则在编译函数时为函数名加上特定的后缀以区别不同的重载版本,而如果是按C的标准来编译的话,则不需要。

static和external定义的全局变量区别:

  • static修饰全局变量时,声明和定义是同时给出的;而extern一般是定义和声明分开,且定义只能一次
  • static的全局作用域只是自身编译单元(即一个.c文件以及这个.c文件所包含的.h文件);而extern的全局作用域是整个工程(一个工程可以包含很多个.h和.c文件)。即区别就在于“全局”的范围是整个工程,还是自身编译单元。

存储类型

变量的存储类型(storagecs)是指存储变量值的内存类型。变量的存储类型决定变量何时创建、何时销毁以及它的值将保持多久。有三个地方可以用于存储变量:普通内存运行时堆栈硬件寄存器。凡是在任何代码块之外声明的变量总是存储于静态内存中,也就是不属于堆栈的内存,这类变量称为静态(static)变量。静态变量在程序运行之前创建,在程序的整个执行期间始终存在。它始终保持原先的值,除非给它赋一个不同的值或者程序结束。

在代码块内部声明的变量的缺省存储类型是自动(automatic),也就是说它存储于堆栈中,称为自动(auto)变量。在程序执行到声明自动变量的代码块时,自动变量才被创建,当程序的执行流离开该代码块时,这些自动变量便自行销毁。如果该代码块被数次执行,这些自动变量每次都将重新创建。对于在代码块内部声明的变量,如果给它加上关键字static,可以使它的存储类型从自动变为静态。具有静态存储类型的变量在整个程序执行过程中一直存在,而不仅仅在声明它的代码块的执行时存在函数的形式参数不能声明为静态,因为实参总是在堆栈中传递给函数,用于支持递归。最后,关键字register可以用于自动变量的声明,提示它们应该存储于机器的硬件寄存器而不是内存中,这类变量称为寄存器变量。通常,寄存器变量比存储于内存的变量访问起来效率更高。

static关键字

当它用于函数定义时,或用于代码块之外的变量声明时,static关键字用于修改标识符的链接属性,从external到internal,但标识符的存储类型和作用域不受影响。当它用于代码块内部的变量声明时,static关键字用于修改变量的存储类型,从自动变量修改为静态变量,但变量的链接属性和作用域不受影响。

总结

具有external链接属性的实体在其他语言的术语中成为全局实体,所有源文件中的所有函数均可以访问它。只要变量并非声明于代码块或函数定义内部,它在缺省情况下的链接属性即为external。如果一个变量声明于代码块内部,在它前面添加extern关键字将使它所引用的是全局变量而非局部变量。

具有extemal链接属性的实体总是具有静态存储类型。全局变量在程序开始执行前创建,并在程序整个执行过程中始终存在。从属于函数的局部变量在函数开始执行时创建,在函数执行完毕后销毁,但用于执行函数的机器指令在程序的生命期内一直存在。局部变量由函数内部使用,不能被其他函数通过名字引用。它在缺省情况下的存储类型为自动,这是基于两个原因:其一,当这些变量需要时才为它们分配存储,这样可以减少内存的总需求量。其二,在堆栈上为它们分配存储可以有效地实现递归。如果你觉得让变量的值在函数的多次调用中始终保持原先的值非常重要的话,你可以修改它的存储类型,把它从自动变量改为静态变量。

操作符和表达式

操作符

移位操作简单地把一个值的位向左或向右移动。在左移位中,值最左边的几位被丢弃掉,右边多出来的几个空位则由0补齐。算术左移和逻辑左移是一样的。右移位时,一种是逻辑移位,左边移入的位用0填充,另一种是算术移位,左边移入的位由原先的符号位决定,保证原数的正负形式不变。

无符号值的所有移位操作都是逻辑移位,对于有符号值,采用逻辑移位还是算术移位取决于编译器。

第一个把指定的位设置为1:value = value | 1 << bit_number,第二个把指定的位清0:value = value & ~ (1 << bit_number)

前缀和后缀形式的增值操作符都是复制一份变量值的拷贝,用于递增表达式的值正是这份拷贝,前缀操作符在进行复制之前增加变量的值,后缀操作符在进行复制之后才增加变量的值。这些操作符的结果是变量值的拷贝

逻辑操作符(&&和||)具有短路性质,如果表达式的值根据左操作数即可决定,它就不再对右操作数进行求值。

布尔值

零是假,任何非零值皆为真。

左值和右值

左值就是能够出现在赋值符号左边的东西,右值就是能够出现在赋值符号右边的东西。如a = b + 25,a是个左值,因为它表示了一个可以存储结果值的地点,b + 25是个右值,因为它指定了一个值。

表达式求值

隐式类型转换

C的整形算术运算至少以缺省整型类型的精度来进行,为了达到这个精度,表达式中的字符型和短整型操作数在使用之前需要被转换成普通整型。如果某个操作符的各个操作数属于不同类型,那么一个操作数转换为另一个操作数的类型。

操作符的属性

两个相邻的操作符哪个先执行取决于它们的优先级,如果优先级相同,则执行顺序由结合性决定。每个操作符的所有属性都在优先级表中。

警告

有操作符的右移位操作是不可移植的。移位操作的位数不可以是个负值。连续赋值中各个变量的长度需要一致。

指针

内存和地址

尽管一个字包含了4个字节,但是它仍然只有一个地址,至于它的地址是最左边的字节的位置还是最右边的字节的位置,取决于机器。另一个需要注意的是边界对齐,整型值存储的起始位置只能是某些特定的字节,通常是2或4的倍数。硬件仍然通过地址访问内存位置。

值和类型

不能简单地通过检查一个值的位来判断它的类型,必须观察这个值的使用方式。比如01100111011011000110111101100010这个值,可能被解释成多种:

间接访问操作符

通过指针访问所指向的地址的过程称为间接访问解引用指针,这个用于执行间接访问的操作符是*

未初始化和非法的指针

1
2
int *a;
*a = 12;

这个声明创建了一个名叫a的指针变量,后边那条赋值语句把12存储在a所指的内存位置,但是不知道a具体指向的位置,声明一个指向int的指针也不会创建用于存储整型值的空间。在UNIX中,这个错误被称为段违例(segmentation violation),它提示程序试图访问一个并未分配给程序的内存位置。

指针常量

NULL表示指针未指向任何东西。

*100 = 25是错误的,间接访问操作只能作用于指针类型表达式,如果确实想把25存于位置100,需要使用强制类型转换*(int*)100 = 25

指针表达式

1
2
char ch = 'a';
char *cp = &ch;

ch表达式,当它作为右值使用时,表达式的值为'a',当这个表达式作为左值使用时,它是这个内存的地址而不是该地址所包含的值。

作为右值,这个表达式的值是变量ch的地址。

*的优先级高于+,所以首先执行间接访问操作,可以得到它的值,取这个值的一份拷贝并把它与1相加,最终结果是’b’,


使用后缀++操作符产生的结果不同,它的右值和左值分别是变量ch的值和ch的内存位置,也就是cp原先所指。间接访问操作符和后缀++的组合令人费解,这里涉及三个步骤:

  • ++操作符产生cp的一份拷贝
  • ++操作符增加cp的值
  • 在cp的拷贝上执行间接访问

当一个指针和一个整数量执行算术运算时,整数在执行加法运算前始终会根据指针所指向类型的大小进行调整,“调整”就是把整数值和“合适的大小”相乘。如果两个指针所指向的不是同一个数组的元素,那么他们之间相减的结果是未定义的,如果是,则结果为两个指针之间的距离。

对指针执行关系运算也是有限制的,用关系操作符对两个指针值进行比较是可能的,不过前提是他们指向同一个数组的元素。下边的循环使数组以相反的次序清除,让vp指向数组最后那个元素后边的内存位置,但在对它进行间接访问之前先执行自减操作,当vp指向数组第一个元素时,循环便告终止,不过这发生在第一个数组元素被清除之后。

1
2
for(vp = &value[N_VALUE]; vp > &value[0];)
*--vp = 0;

如果对其简化,现在vp指向数组最后一个元素,它的自减操作放在for的调整部分执行,在第一个元素被清除之后,vp的值还将减去1,而接下去的一次比较是用于结束循环的,比较表达式vp >= &value[0]的值未定义,因为vp移动到了数组边界之外。

1
2
for(vp = &value[N_VALUE-1]; vp >= &value[0]; vp --)
*vp = 0;

函数

函数的参数

C函数的所有参数均以传值调用方式进行传递,这意味着函数将获得参数值的一份拷贝。如果被传递的参数是一个数组名,函数将访问调用程序的数组元素,数组并不会被复制。这个行为被称为传址调用。数组名的值实际上是一个指针,传递给函数的就是这个指针的一份拷贝。下标引用实际上是间接访问的另一种形式,它可以对指针执行间接访问操作,访问指针指向的内存位置。只要记住两个规则:

  1. 传递给函数的标量参数是传值调用的。
  2. 传递给函数的数组参数在行为上就像它们是通过传址调用的那样。

数组

指针的效率

  1. 当你根据某个固定数目的增量在一个数组中移动时,使用指针变量将比使用下标产生效率更高的代码。当这个增量是1并且机器具有地址自动增量模型时,这点表现得更为突出。
  2. 声明为寄存器变量的指针通常比位于静态内存和堆栈中的指针效率更高(具体提高的幅度取决于你所使用的机器)。
  3. 如果你可以通过测试一些己经初始化并经过调整的内容来判断循环是否应该终止,那么你就不需要使用一个单独的计数器。
  4. 那些必须在运行时求值的表达式较之诸如&array[SIZE]array+SIZE这样的常量表达式往往代价更高。

初始化

静态和自动初始化

数组初始化的方式取决于它们的存储类型。存储于静态内存的数组只初始化一次,也就是在程序开始执行之前。程序并不需要执行指令把这些值放到合适的位置,这由链接器完成的,它用包含可执行程序的文件中合适的值对数组元素进行初始化。如果数组未被初始化,数组元素的初始值将会自动设置为零。当这个文件载入到内存中准备执行时,初始化后的数组值和程序指令一样也被载入到内存中。自动变量在缺省情况下是未初始化的。如果自动变量的声明中给出了初始值,则每次执行流执行到这里时都会初始化。

如果初始化不完整,如int vector[5] = {1, 2, 3},则之后的元素都会被初始化为0。如果声明中没有给出长度,编译器就把数组的长度设置为刚好容纳所有的初始值的长度。

char message1[] = "hello"char *message2 = "hello"具有不同的含义,前者初始化一个字符数组的元素,后者则是一个真正的字符串常量。

多维数组

C中,多维数组的元素存储按照最右边的下标率先变化的原则,称为行主序。作为函数参数的多维数组的实际传递的是个指向数组第一个元素的指针,但是编译器需要知道维数。如void func(int matrix[][10])

字符串、字符和字节

不受限制的字符串函数

常用的字符串函数都是“不受限制”的,只是通过寻找字符串参数结尾的NULL字节来判断长度。必须保证字符串不会溢出。如strcmpstrcpystrcat。标准库还包含了一类函数,接收一个显式的长度参数用于限定进行复制或比较的字符数,如strncmpstrncpystrncat

strcpy一样,strncpy把源字符串的字符复制到目标数组。然而,它总是正好向dst写入len个字符。如果strlen(src0)的值小于len,dst数组就用额外的NUL字节填充到len长度。如果strlen(src)的值大于或等于len,那么只有len个字符被复制到dst中。注意!它的结果将不会以NUL字节结尾

字符串查找基础

在字符串中查找字符最简单的方法是char *strchr(char const *str, int ch)char *strrchr(char const *str, int ch),在str中查找ch第一次出现的位置。strrchr返回最后一次出现的位置。

strpbrk查找任何一组字符第一次在字符串中出现的位置,char *strpbrk(char const *str, char const *group),返回一个指向str中第一个匹配group中任何一个字符的字符位置。strstr查找s1在整个s2中第一次出现的位置。

高级字符串查找

strspnstrcspn用于在字符串的起始位置对字符计数,计算字符串str中连续有几个字符都属于字符串accept,原型为size_t strspn(const char *str, const char * accept);

  • 【函数说明】strspn() 从参数 str 字符串的开头计算连续的字符,而这些字符都完全是 accept 所指字符串中的字符。简单的说,若 strspn() 返回的数值为n,则代表字符串 str 开头连续有 n 个字符都是属于字符串 accept 内的字符。
  • 【返回值】返回字符串 str 开头连续包含字符串 accept 内的字符数目。所以,如果 str 所包含的字符都属于 accept,那么返回 str 的长度;如果 str 的第一个字符不属于 accept,那么返回 0。

  • 注意:检索的字符是区分大小写的。

  • 提示:函数 strcspn() 的含义与 strspn() 相反,可以对比学习。
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main ()
{
int i;
char str[] = "129th";
char accept[] = "1234567890";
i = strspn(str, accept);
printf("str 前 %d 个字符都属于 accept\n",i);
system("pause");
return 0;
}

执行结果:str 前 3 个字符都属于 accept

C语言strcspn()函数:计算字符串str中连续有几个字符都不属于字符串accept,头文件:#inclued<string.h>。strcspn() 用来计算字符串 str 中连续有几个字符都不属于字符串 accept,其原型为:int strcspn(char *str, char *accept);

  • 【参数说明】str、accept为要进行查找的两个字符串。strcspn() 从字符串 str 的开头计算连续的字符,而这些字符都完全不在字符串 accept 中。简单地说,若 strcspn() 返回的数值为 n,则代表字符串 str 开头连续有 n 个字符都不含字符串 accept 中的字符。
  • 【返回值】返回字符串 str 开头连续不含字符串 accept 内的字符数目。
  • 注意:如果 str 中的字符都没有在 accept 中出现,那么将返回 atr 的长度;检索的字符是区分大小写的。
  • 提示:函数 strspn() 的含义与 strcspn() 相反,可以对比学习。

【示例】返回s1、s2包含的相同字符串的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<stdio.h>
#include <stdlib.h>
#include<string.h>
int main()
{
char* s1 = "http://c.biancheng.net/cpp/u/biaozhunku/";
char* s2 = "c is good";
int n = strcspn(s1,s2);
printf("The first char both in s1 and s2 is :%c\n",s1[n]);
printf("The position in s1 is: %d\n",n);
system("pause");
return 0;
}

strtok从字符串中隔离各个单独的称为标记的部分,并丢弃分隔符。char * strtok(char *s, const char *delim);strtok()用来将字符串分割成一个个片段。参数s 指向欲分割的字符串,参数delim 则为分割字符串,当strtok()在参数s 的字符串中发现到参数delim 的分割字符时则会将该字符改为\0 字符。在第一次调用时,strtok()必需给予参数s 字符串,往后的调用则将参数s 设置成NULL。每次调用成功则返回下一个分割后的字符串指针。

返回值:返回下一个分割后的字符串指针,如果已无从分割则返回NULL。

1
2
3
4
5
6
7
8
9
10
#include <string.h>
main(){
char s[] = "ab-cd : ef;gh :i-jkl;mnop;qrs-tu: vwx-y;z";
char *delim = "-: ";
char *p;
printf("%s ", strtok(s, delim));
while((p = strtok(NULL, delim)))
printf("%s ", p);
printf("\n");
}

字符操作

以下函数位于ctype.h中。

转换函数用于把大写字符转化为小写,tolowertoupper

内存操作

非字符串数据内部包含0值时,无法用字符串函数来处理。不过可以使用另一组相关的函数,他们的操作与字符串函数类似。

  • void *memcpy(void *dst, void const *src, size_t length)从src的起始位置复制length个字节到dst的内存起始位置。
  • void *memmove(void *dst, void const *src, size_t length)和memcpy的行为差不多,不过它的源和目标操作数可以重叠。
  • void *memcmp(void const *a, void const *b, size_t length)对两端内存的内容进行比较,这些值按照无符号字符逐字节比较。
  • void *memchr(void const *a, int ch, size_t length)从a的起始位置开始查找字符ch第一次出现的位置,并返回一个指向该位置的指针。
  • void *memset(void *a, int ch, size_t length)把从a开始的length个字节都设置为字符值ch。

结构和联合

结构的存储分配

考虑这个结构

1
2
3
4
5
struct ALIGN {
char a;
int b;
char c;
};

如果某机器的整型值长度为4个字节,并且它的起始存储位置必须被4整除,那么这个结构在内存中将如下:

所有结构起始存储位置必须是结构中边界要求最严格的数据类型所要求的。成员a必须存储于一个能被4整除的地址。下一个成员是整型值,所以必须跳过3个字节到达合适的边界。可以在声明中对结构的成员列表重新排列,让那些对边界要求最严格的成员首先出现

sizeof操作符能够得出一个结构的整体长度,包括因边界对齐而跳过的那些字节。如果你必须确定结构某个成员的实际位置,应该考虑边界对齐因素,可以使用offsetof宏(定义于stddef.h)。offsetof(type,member),type就是结构的类型,member就是你需要的那个成员名。表达式的结果是一个size_t值,表示这个指定成员开始存储的位置距离结构开始存储的位置偏移几个字节。例如,对前面那个声明而言offsetof(struct ALIGN, b)的返回值是4。

位段

位段的成员是一个或多个位的字段,让这些不同长度的字段其实存在于一个或多个整型变量中。位段成员必须声明为intsigned intunsigned int三种,其次,在成员名后边是一个冒号和一整数,整数指定为该位段所占用的位的数目。

注重可移植性的程序应该避免使用位段。由于下面这些与实现有关的依赖性,位段在不同的系统中可能有不同的结果。

  1. int位段被当作有符号数还是无符号数。
  2. 位段中位的最大数目。许多编译器把位段成员的长度限制在一个整型值的长度之内,所以一个能够运行于32位整数的机器上的位段声明可能在16位整数的机器上无法运行。
  3. 位段中的成员在内存中是从左向右分配的还是从右向左分配的。
  4. 当一个声明指定了两个位段,第2个位段比较大,无法容纳于第1个位段剩余的位时,编译器有可能把第2个位段放在内存的下一个字,也可能直接放在第1个位段后面,从而在两个内存位置的边界上形成重叠。
1
2
3
4
5
struct CHAR {
unsigned ch : 7;
unsigned font : 6;
unsigned size : 19;
}

位段能够利用存储ch和font所剩余的位来增加size的位数,这样避免了声名一个32位的整数来存储size位段。它也可以很方便的访问一个整型值的部分内容。假定磁盘控制器其中一个寄存器是如下定义的:

前五个位段每个都占1位,其余几个位段长些,在一个从右向左分配位段的机器上,下面这个声明允许方便地对寄存器的不同位段进行访问:

动态内存分配

malloc和free

C函数库提供了两个函数,mallocfree,分别用于执行动态内存分配和释放。这些函数维护一个可用内存池。malloc从内存池中提取一块合适的内存,并向该程序返回一个指向这块内存的指针。当一块以前分配的内存不再使用时,程序调用free函数把它归还给内存池供以后之需。
void* malloc(size_t size)的参数就是需要分配的内存字节(字符)数。如果内存池中的可用内存可以满足这个需求,malloc就返回一个指向被分配的内存块起始位置的指针。maloc所分配的是一块连续的内存。如果内存池的可用内存无法满足你的请求,malloc函数向操作系统请求,要求得到更多的内存,并在这块新内存上执行分配任务。如果操作系统无法向malloc提供更多的内存,maloc就返回一个NULL指针。因此,对每个从malloc返回的指针都进行检查,确保它并非NULL是非常重要的

void free(void *pointer)的参数必须要么是NULL,要么是一个先前从malloc、calloc或realloc(稍后描述)返回的值。向free传递一个NULL参数不会产生任何效果。

对于要求边界对齐的机器,malloc所返回的内存的起始位置将始终能够满足对边界对齐要求最严格的类型的要求。

calloc和realloc

另外还有两个内存分配函数,calloc和realloco它们的原型如下所示:

1
2
void* calloc(size_t num_elements, size_t element_size);
void realloc(void* ptr, size_t new_size);

calloc也用于分配内存,在返回指向内存的指针之前把它初始化为0。realloc用于修改一个原先已经分配的内存块的大小,如果它用于扩大一个内润康,那么这块内存原先的内容依然保留,新添加的内存块在原先内存块后边,如果原先内存块无法改变大小,realloc会分配另一块正确大小的内存。

动态内存分配最常见的错误就是忘记检查所请求的内存是否成功分配。动态内存分配的第二大错误来源是操作内存时超出了分配内存的边界。例如,如果你得到一个25个整型的数组,进行下标引用作时如果下标值小于0或大于24将引起两种类型的问题。

  • 第1种问题显而易见:被访问的内存可能保存了其他变量的值。对它进行修改将破坏那个变量,修改那个变量将破坏你存储在那里的值。这种类型的bug非常难以发现。
  • 第2种问题不是那么明显。在malloc和free的有些实现中,它们以链表的形式维护可用的内存池。对分配的内存之外的区域进行访问可能破坏这个链表,这有可能产生异常,从而终止程序。

动态分配的内存不再需要时,它应该被释放,分配内存但在使用完毕后不释放将引起内存泄漏。

预处理器

预定义符号

预处理器定义了一些符号:

#define

#define的正式描述为#define name stuff,每当有符号name出现在这条指令之后时,预处理器就会把它替换为stuff。如果定义中的stuff很长,可以加上\

1
2
3
4
#define DEBUG_PRINT printf("File %s line %d" \
"x = %d, y = %d, z = %d", \
__FILE__, __LINE__, \
x, y, z)

#define机制包括了一个规定,允许把参数替换到文本中,这种方法叫做,所有用于对数值表达式进行求值的宏定义都应该加上括号,避免使用宏时参数中的操作符或邻近的操作符之间的相互作用。

** 识别结果 1**

在程序中扩展#define定义符号和宏时,需要涉及儿个步骤。

  1. 在调用宏时,首先对参数进行检查,看看是否包含了任何由#define定义的符号。如果是,它们首先被替换。
  2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被它们的值所替代。
  3. 最后,再次对结果文本进行扫描,看看它是否包含了任何由#define定义的符号。如果是,就重复上述处理过程。

这样,宏参数和#define定义可以包含其他#define定义的符号。但是,宏不可以出现递归。当预处理器搜索#define定义的符号时,字符串常量的内容并不进行检查。你如果想把宏参数插入到字符串常量中,可以使用两种技巧。

  • 首先,邻近字符串自动连接的特性使我们很容易把一个字符串分成几段,每段实际上都是一个宏参数。
  • 使用预处理器把一个宏参数转换为一个字符串,#argument这种结构会被预处理器翻译为argument
1
2
3
4
5
6
7
8
#define PRINT(FORMAT, VALUE)   \
printf("The value of #VALUE \
" is " FORMAT "\n", VALUE)

PRINT("%d", x + 3);

生成:
The value of x + 3 is 25

##结构把位于两边的符号连接成一个符号,允许宏定义从分离的文本片段创建标识符。

许多C编译器允许在命令行中定义符号,用于启动编译过程,在UNIX编译器中,-D可以完成,如-Dname-Dname=stuff

条件编译

条件编译可以选择代码的一部分是被正常编译还是完全忽略。用于支持条件编译的基本结构是#if指令和与其匹配的#endif指令。下面显示了它最简单的语法形式。

1
2
3
#if constant-expression
statements
#endif

其中,constant-expression(常量表达式)由预处理器进行求值。如果它的值是非零值(真),那么statements部分就被正常编译,否则预处理器就安静地删除它们。所谓常量表达式,就是说它或者是字面值常量,或者是一个由#define定义的符号。如果变量在执行期之前无法获得它们的值,那么它们如果出现在常量表达式中就是非法的,因为它们的值在编译时是不可预测的。

#include指令用于实现文件包含。它具有两种形式。

  • 如果文件名位于一对尖括号中,编译器将在由编译器定义的标准位置查找这个文件。这种形式通常用于包含函数库头文件时。
  • 另一种形式,文件名出现在一对双引号内。不同的编译器可以用不同的方式处理这种形式。
  • 但是,如果用于处理本地头文件的任何特殊处理方法无法找到这个头文件,那么编译器接下来就使用标准查找过程来寻找它。

#error指令在编译时产生一条错误信息,信息中包含的是你所选择的文本。#line指令允许你告诉编译器下一行输入的行号,如果它加上了可选内容,它还将告诉编译器输入源文件的名字。因编译器而异的#progma指令允许编译器提供不标准的处理过程,比如向一个函数插入内联的汇编代码。

输入输出函数

错误报告

perror函数可以报告错误。原型是void perror(char const * msg),如果msg不是NULL并且指向一个非空的字符串,perror会打印出这个字符串,并打印当前错误代码的信息。

另一个有用的函数是exit,它用于终止一个程序的执行。它的原型定义于stdlib.h,如下所示:void exit(int status),status参数返回给操作系统,用于提示程序是否正常完成。这个值和main函数返回的整型状态值相同。预定义符号EXIT_SUCCESSEXIT_FAILURE分别提示程序的终止是成功还是失败。这个函数没有返回值。当exit函数结束时,程序己经消失,所以它无处可返。

标准IO函数库

K&R C最早的编译器的函数库在支持输入和输出方面功能甚弱。其结果是,程序员如果需要使用比函数库所提供的I/O更为复杂的功能时,他不得不自己实现。
有了标准I/O函数之后,这种情况得到了极大的改观。标准IO函数库具有一组IO函数,实现了在原先的IO库基础上许多程序员自行添加实现的额外功能。这个函数库对现存的函数进行了扩展,例如为printf创建了不同的版本,可以用于各种不同的场合。

头文件stdio.h包含了与ANSI函数库的I/O部分有关的声明。ANSI进一步对IO的概念进行了抽象。就C程序而言,所有的1/0操作只是简单地从程序移进或移出字节的事情。因此,毫不惊奇的是,这种字节流便被称为流(stream)。

绝大多数流是完全缓冲的(fully buffered),这意味着“读取”和“写入”实际上是从一块被称为缓冲区的内存区域来回复制数据。从内存中来回复制数据是非常快速的。用于输出流的缓冲区只有当它写满时才会被刷新(flush,物理写入)到设备或文件中。一次性把写满的缓冲区写入和逐片把程序产生的输出分别写入相比效率更高。类似,输入缓冲区当它为空时通过从设备或文件读取下一块较大的输入,重新填充缓冲区。

如果程序失攸,缓冲输出可能不会被实际写入,这就可能使程序员得到关于错误出现位置的不正确结论。这个问题的解决方法就是在每个用于调试的printf函数之后立即调用fflush,如下所示:printf("something or other"); fflush(stdout)

流IO总览

标准库函数使我们在C程序中执行与文件相关的IO任务非常方便。

  1. 程序为必须同时处于活动状态的每个文件声明一个指针变量,其类型为FILE*。这个指针指向这个FILE结构,当它处于活动状态时由流使用。
  2. 流通过调用fopen函数打开。为了打开一个流,你必须指定需要访问的文件或设备以及它们的访问方式(例如,读、写或者既读又写)。fopen和操作系统验证文件或设备确实存在并初始化FILE结构。
  3. 然后,根据需要对该文件进行读取或写入。
  4. 最后,调用fclose函数关闭流。关闭一个流可以防止与它相关联的文件被再次访问,保证任何存储于缓冲区的数据被正确地写到文件中,并且释放FILE结构使它可以用于另外的文件。

I/O函数以三种基本的形式处理数据:单个字符文本行二进制数据。对于每种形式,都有一组特定的函数对它们进行处理。

这些函数的区别在于获得输入的来源或输出写入的地方不同。这些变种用于执行下面的任务:

  1. 只用于stdin或stdout
  2. 随作为参数的流使用。
  3. 使用内存中的字符串而不是流。

打开流

fopen函数打开一个特定的文件,并把一个流和这个文件相关联。它的原型下所示:FILE *fopen(char ccnst *name, char const *mode);。两个参数都是字符串。name是你希望打开的文件或设备的名字。创建文件名的规则在不同的系统中可能各不相同,所以fopen把文件名作为一个字符串而不是作为路径名、驱动器字母、文件扩展名等各准备一个参数。mode(模式)参数提示流是用于只读、只写还是既读又写,以及它是文本流还是二进制流。下面的表格列出了一些常用的模式。

mode以r、w或a开头,分别表示打开的流用于读取、写入还是添加。如果一个文件打开是用于读取的,那么它必须是原先已经存在的。但是,如果一个文件打开是用于写入的,如果它原先己经存在,那么它原来的内容就会被删除。如果它原先不存在,那么就创建一个新文件。如果一个打开用于添加的文件原先并不存在,那么它将被创建。如果它原先己经存在,它原先的内容并不会被删除。

如果fopen函数执行成功,它返回一个指向FILE结构的指针,该结构代表这个新创建的流。如果函数执行失败,它就返回一个NULL指针,errno会提示问题的性质。

流使用函数fclose关闭的,int fclose(FILE* f),fclose在文件关闭之前刷新缓冲区,如果它执行成功则返回0,否则返回EOF。

字符IO

字符输入是由getchar函数家族执行的,它们的原型如下所示。

1
2
3
int fgetc(FILE *stream);
int getc(FILE *strearn);
int getchar(void);

需要操作的流作为参数传递给getc和fgetc,但getchar始终从标准输入读取。每个函数从流中读取下一个字符,并把它作为函数的返回值返回。如果流中不存在更多的字符,函数就返回常量值EOF。返回int型值的真正原因是为了允许报告文件的末尾(EOF)。如果返回值是char型,那么在256个字符中必须有一个被指定用于表示EOF。如果这个字符出现在文件内部,那么这个字符以后的内容将不会被读取,因为它被解释为EOF标志。

EOF被定义为一个整型,它的值在任何可能出现的字符范围之外。这种解决方法允许我们使用这些函数来读取二进制文件。

为了把单个字符写入到流中,你可以使用putchar函数家族。它们的原型如下:

1
2
3
int fputc(int character, FILE *stream);
int putc(int character, FILE *stream);
int putchar(int character);

第1个参数是要被打印的字符。在打印之前,函数把这个整型参数裁剪为一个无符号字符型值,所以putchar('abc')仅仅打印一个字符。

fgetcfputc都是真正的函数,但getcputcgetcharputchar都是通过#define指令定义的宏。之所以提供两种类型的方法,是为了允许你根据程序的长度和执行速度哪个更重要选择正确的方法。

未格式化的行IO

未格式化的IO(unformatted line IO)简单读取或写入字符串,而格式化的IO则执行数字和其他变量的内部和外部表示形式之间的转换。gets和puts函数家族是用于操作字符串而不是单个字符。这个特征使它们在那些处理一行行文本输入的程序中非常有用。这些函数的原型如下所示。

1
2
3
4
char *fgets(char *buffer, int buffer_size, FILE *stream);
char *gets(char *buffer);
int fputs(char const *buffer, FILE *stream);
int puts(char const *buffer);

fgets从指定的stream读取字符并把它们复制到buffer中。当它读取一个换行符并存储到缓冲区之后就不再读取。如果缓冲区内存储的字符数达到buffer_size-1个时它也停止读取。在这种情况下,并不会出现数据丢失的情况,因为下一次调用fgets将从流的下一个字符开始读取。在任何一种情况
下,一个NULL字节将被添加到缓冲区所存储数据的末尾,使它成为一个字符串。如果在任何字符读取前就到达了文件尾,缓冲区就未进行修改,fgets函数返回一个NULL指针。否则,fgets返回它的第1个参数(指向缓冲区的指针)。这个返回值通常只用于检查是否到达了文件尾。

二进制IO

fread用于读取二进制数据,fwrite用于写入二进制数据:

1
2
size_t fread(void *buffer, size_t size, size_t count, FILE *stream);
size_t fwrite(void *buffer, size_t size, size_t count, FILE *stream);

buffer是一个指向用于保存数据的内存位置的指针,size是缓冲区中每个元素的字节数,count是读取或写入的元素数,当然stream是数据读取或写入的流。buffer参数被解释为一个或多个值的数组。count参数指定数组中有多少个值,所以读取或写入一个标量时,count的值应为函数的返回值是实际读取或写入的元素(并非字节)数目。

1
2
3
4
5
6
7
8
struct VALUE {
long a;
float b;
char c[SIZE];
} values[ARRAY_SIZE];
n_value = fread(values, sizeof(struct VALUE), ARRAY_SIZE, input_stream);
(处理数组中的数据)
fwrite(values, sizeof(struct VALUE), n_value, output_stream);

这个程序从一个输入文件读取二进制数据,对它执行某种类型的处理,把结果写入到一个输出文件。这种类型的IO效率很高,因为每个值中的位直接从流读取或向流写入,不需要任何转换。

刷新和定位函数

fflush迫使一个输出流的缓冲区内的数据进行物理写入,不管它是不是已经写满。int fllush(FILE *stream)

C同时支持随机访问I/O,也就是以任意顺序访问文件的不同位置。随机访问是通过在读取或写入先前定位到文件中需要的位置来实现的。有两个函数用于执行这项操作:

1
2
long ftell(FILE *stream);
int fseek(FILE *stream, long offset, int from);

ftell函数返回流的当前位置,也就是说,下一个读取或写入将要开始的位置距离文件起始位置的偏移量。这个函数允许你保存一个文件的当前位置,这样你可能在将来会返回到这个位置。在二进制流中,这个值就是当前位置距离文件起始位置之间的字节数。在文本流中,这个值表示一个位置,但它并不一定准确地表示当前位置和文件起始位置之间的字符数,因为有些系统将对行末字符进行翻译转换。

fseek函数允许你在一个流中定位。这个操作将改变下一个读取或写入操作的位置。它的第1个参数是需要改变的流。它的第2和第3个参数标识文件中需要定位的位置。

试图定位到一个文件的起始位置之前是一个错误。定位到文件尾之后并进行写入将扩展这个文件。定位到文件尾之后并进行读取将导致返回一条“到达文件尾”的信息。在二进制流中,从SEEK_END进行定位可能不被支持,所以应该避免。在文本流中,如果from是SEEK_CUR或SEEK_END,offset必须是零。如果from是SEEK_SET,offset必须是一个从同一个流中以前调用ftell所返回的值。

用fseek改变一个流的位置会带来三个副作用。

  • 首先,行末指示字符被清除。
  • 其次,如果在fseek之前使用ungetc把一个字符返回到流中,那么这个被退回的字符会被丢弃,因为在定位操作以后,它不再是“下一个字符”。
  • 最后,定位允许你从写入模式切换到读取模式,或者回到打开的流以便更新。

另外还有三个额外的函数,用一些限制更严的方式执行相同的任务。它们的原型如下:

1
2
3
void rewind(FILE *stream);
int fgetpos(FILE *stream, fpos_t *positicn);
int fsetpos(FILE *streamr, fpos_t const *possiton);

rewind函数将读/写指针设置回指定流的起始位置。它同时清除流的错误提示标志。fgetpos和fsetpos函数分别是ftell和fseek函数的替代方案。它们的主要区别在于这对函数接受一个指向fpos_t的指针作为参数。fgetpos在这个位置存储文件的当前位置,fsetpos把文件位置设置为存储在这个位置的值。

改变缓冲方式

下面两个函数可以用于对缓冲方式进行修改。这两个函数只有当指定的流被打开但还没有在它上面执行任何其他操作前才能被调用。

1
2
void setbuf(FILE *stream, char *buf);
int setvbuf(FILE *stream, char *buf);

setbuf设置了另一个数组,用于对流进行缓冲。这个数组的字符长度必须为BUFSIZ(它在stdio.h中定义)。为一个流自行指定缓冲区可以防止IO函数库为它动态分配一个缓冲区。如果用一个NULL参数调用这个函数,setbuf函数将关闭流的所有缓冲方式。字符准确地将程序所规引的方式进行读取和写入。

为流缓冲区使用一个自动数组是很危险的。如果在流关闭之前,程序的执行流离开了数组声明所在的代码块,流就会继续使用这块内存,但此时它可能已经分配给了其他函数另作它用。

setvbuf函数更为通用。mode参数用于指定缓冲的类型。_IOFBF指定一个完全缓冲的流,_IONBF指定一个不缓冲的流,_IOLBF指定一个行缓冲流。所谓行缓冲,就是每当一个换行符写入到缓冲区时,缓冲区便进行刷新。buf和size参数用于指定需要使用的缓冲区。如果buf为NULL,那么size的值必须是0。一般
而言,最好用一个长度为BUFSIZ的字符数组作为缓冲区。尽管使用一个非常大的缓冲区可能可以稍稍提高程序的效率,但如果使用不当,它也有可能降低程序的效率。

流错误函数

下面的函数用于判断流的状态:

1
2
3
int feof(FILE *stream);
int ferror(FILE *stream);
void clearerr(FILE *stream);

如果流当前处于文件尾,feof函数返回真。这个状态可以通过对流执行fseek、rewind或fsetpos函数来清除。ferror函数报告流的错误状态,如果出现任何读/写错误函数就返回真。最后,clearerr函数对指定流的错误标志进行重置。

临时文件

tmpfile函数用于创建临时文件。

1
FILE *tmpfile(void);

这个函数创建了一个文件,当文件被关闭或程序终止时这个文件便自动删除。该文件以wb+模式打开,这使它可用于二进制和文本数据。如果临时文件必须以其他模式打开或者由一个程序打开但由另一个程序读取,就不适合用tmpfile函数创建。

文件操纵函数

有两个函数用于操纵文件但不执行任何输入/输出操作。它们的原型如下所示。如果执行成功,这两个函数都返回零值。如果失败,它们都返回非零值。

1
2
int remove(char const *filename);
int rename(char const *oldname, char const *newname);

remove函数删除一个指定文件,如果当remove被调用时文件处于打开状态,其结果将取决于编译器。rename用于改变一个文件的名字。

标准函数库

整型函数

算数

1
2
3
4
int abs(int value);
long int labs(long int value);
div_t div(int numerator,int denominator);
ldiv_t ldiv(long int number,long int denom);

abs函数返回绝对值。labs用于长整数。div函数把第二个参数除以第1个参数,产生商和余数,用一个div_t结构返回。这个结构包含

1
2
int quot;     //商
int rem; //余数

随机数

下面两个函数合在一起使用能够产生伪随机数pseudo-random number。他们通过计算差生随机数,因此有可能重复出现,并不是真正的随机数。

1
2
int rand(void);
void srand(unsigned int seed);

rand返回一个范围在0和RAND_MAX(至少为32767)之间的伪随机数。当它重复调用时,函数返回这个范围内的其他数。为了得到一个更小范围的伪随机数,首先把这个函数的返回值根据所需范围的大小进行取模,然后通过加上或减去一个偏移量对它进行调整。

为了避免程序每次运行时获得相同的随机数序列,可以调用srand函数。它用它的参数值对随机数发生器进行初始化。一个常用的技巧是使用每天的时间作为随机数产生器的种子seed。srand((unsigned int)time(0))

字符串转换

把字符串转换为数值。atoi和atol执行基数为10的转换。strtol和strtoul允许在转换时指定基数,同时还允许访问字符串的剩余部分。

1
2
3
4
int atoi(char const *string);
long int atol(char const *string);
long int strtol(char const *string,char **unused,int base);
unsigned long int strtoul(char const *string,char **unused,int base);

如果任何一个上述函数的的第一个参数包含了前导空白字符,他们将被跳过。然后函数把合法的字符转换为指定类型的值。如果存在任何非法缀尾字符,他们也将被忽略。

atoi和atol分别把字符转换为整数和长整数值。strtol和atol同样把参数字符串转换为long。但是strtol保存一个指向转换至后面第1个字符的指针。如果函数的第二个参数并非NULL,这个指针便保存在第二个参数所指向的位置。这个指针允许字符串的剩余部分进行处理而无需推测转换在字符串的哪个位置终止。strtoul和strtol的执行方式仙童,但它产生一个无符号长整数。

这两个函数的第3个参数是转换所执行的基数。如果基数为0,任何在程序中用于书写整数字面值的形式都将被接受,包括指定数字基数的形式。否则基数值应该在2到36的范围内——然后转换根据这个给定的基数进行。对于基数11到36,字母A到Z分别被解释为10到35.在这个上下文环境中,小写字母a-z被解释为与对应的大写字母相同的意思。

如果这些函数的string参数中并不包含一个合法的值,函数就返回0。如果被转换的值无法表示,函数便在errno中存储ERANGE这个值,并返回以下一个值。

  • strtol 返回值如果太大且为负返回LONG_MIN。如果值太大且为正返回LONG_MAX
  • strtoul如果值太大返回ULONG_MAX

浮点型函数

math.h包含了函数库中剩余的数学函数的声明。

三角函数

1
2
3
4
5
6
7
double sin(double angle);
double cos(double angle);
double tan(double angle);
double asin(double value);
double acos(double value);
double atan(double value);
double atan2(double x,double y);

sin、cos、tan参数是一个用弧度表示的角度,返回正弦余弦正切。asin、acos、atan返回反正弦、反余弦、反正切。如果asin和acos的参数不位于-1和1之间,就出现一个定义域错误。asin和atan的返回值是在-π/2和π/2之间的一个弧度,acos的返回值是一个返回在0和π之间的弧度。

双曲函数

1
2
3
double sinh(double angle);
double cosh(double angle);
double tanh(double angle);

对数和指数函数

1
2
3
double exp(double x);     //e的x次幂     
double log(double x); //x的自然对数
double log10(double x); //x以10为低的对数

浮点表示形式

1
2
3
double frexp(double value,int *exponet);
double ledexp(doub fraction,int exponet);
double modf(double value,double *ipart);

frexp函数计算一个指数exponent和小数fraction,这样fraction × 2^exponent = value,函数返回fraction。ledexp返回值是fraction × 2^exponent。modf把一个浮点值分成整数和小数两个部分,整数部分以double类型存储在第二个参数所指向的内存位置,小数部分作为函数的返回值返回。

1
2
double pow(double x,double y);
double sqrt(double x);

底数、顶数、绝对值和余数

1
2
3
4
double floor(double x);
double ceil(double x);
double fabs(double x);
double fmod(double x,double y);

floor函数返回不大于其参数的最大整数值,这个值以double返回,ceil函数返回不小于其参数的最小整数值。fabs返回其参数的绝对值。fmod返回x除以y所产生的余数。

字符串转换

1
2
double atof(char const *string);
double strtod(char const *string,char **unused);

如果任一函数的参数包含了前导的空白字符,这些字符将被忽略。函数随后把合法的字符转换为一个double值,忽略任何缀尾的非法字符。这两个函数都接受程序中所有浮点数字面值的书写形式。strtod函数把参数字符串转换为一个double值,其方法和atof类似,但它保存一个指向字符串中被转换的值后面的第1个字符的指针。如果函数的第2个参数不是NULL,那么这个被保存的指针就存储于第2个参数所指向的内存位置。这个指针允许对字符串的剩余部分进行处理,而不用猜测转换会在字符串中的什么位置结束。

如果这两个函数的字符串参数并不包含任何合法的数值字符,函数就返回零。如果转换值太大或太小,无法用double表示,那么函数就在errno中存储ERANGE这个值,如果值太大(无论是正数还是负数),函数返回HUGE_VALO如果值太小,函数返回零。

日期和时间函数

处理器时间

1
clock_t clock(void);

返回从程序开始执行器处理器所消耗的时间,应该把它除以常量CLOCKS_PER_SEC。

当天时间

1
time_t time(time_t *returned_value);

返回当前的日期和时间

日期和时间的转换

1
2
3
4
char *ctime(time_t const *time_value);
double difftime(time_t time1,time_t time2);
struct tm *gmtimetime_t const *time)value);
struct tm *localtime(time_t const *time_value);

ctime的参数是一个指向time_t的指针,并返回一个指向字符串的指针:Sun Jul 4 04:02:28 1976\n\0。difftime计算两个时间之差,并把结果转换成秒。gmtime把时间值转换为世界协调时间Coordinated Universal Time,UTC。以前被称为格林尼治标准时间Greenwich Mean Time,返回值为tm结构:

1
2
char *asctime(struct tm const *tm_ptr);
size_t strftime(char *string ,size_t maxsize,char const *format, struct tm const *tm_ptr);

asctime将tm表示的时间值转换成ctime函数所用的一样的格式。

strftime函数把一个tm结构体转换为一个根据某个格式字符串而定的字符串。如果转换结果字符串的长度小于maxsize参数,返回字符串长度,否则返回-1且数组内容未定义。格式字符串包含了普通字符和格式代码。普通字符被复制到它们原先在字符串中出现的位置。格式代码则被一个日期或时间值代替。格式代码包括一个%字符,后面跟一个表示所需值的字符。

最后,mktime函数用于把tm结构转换为一个time_t的值。tm结构中的tm_wday和tm_yday值被忽略,其他字段的值也无需限制在它们的通常范围内。转换之后,该tm结构会进行规格化。

1
time_t mktime( struct tm *tm_ptr );

非本地跳转

setjmp和longjmp函数提供一种类似goto语句的机制,但它并不局限于一个函数的作用域之内。这些函数常用于深层嵌套的函数调用链。如果在某个底层的函数中检测到一个错误,可以立即返回顶层的函数,不必向调用链中的每个中间层函数返回一个错误标志。

1
2
int setjmp( jmp_buf state );
void longjmp( jmp_buf state, int value );

声明一个jmp_buf变量,并调用setjmp函数初始化,返回值为0。setjmp把程序的状态信息(例如,堆栈指针的当前位置和程序的计数器)保存到跳转缓冲区。调用该函数的函数成为“顶层”函数。以后,在顶层函数或者其他任何它所调用的函数(无论是直接调用还是间接调用)内调用longjmp函数,将会导致这个被保存的状态重新恢复。longjmp的效果是使执行流通过再次从setjmp返回,从而立即跳转回顶层函数中,此时,setjmp返回的值是longjmp的第2个参数。

信号

信号(signal)表示一种事件,它可能异步的发生,也就是并不与程序执行过程的任何事件同步。

信号名

信号 含义
SIGABRT 程序请求异常终止,由abort函数引发。
SIGFPE 具体错误由编译器确定,常见有算术上溢、下溢以及除零错误
SIGILL 检测到非法指令,可能由不正确的编译器设置导致
SIGSEGV 检测到内存的非法访问,程序访问未分配内存或者访问超过内存访问的边界(segmentation violation)
SIGINT 程序外部产生,通常是用户尝试中断程序时发生,一般定义处理函数来执行日常维护和退出前保存数据(interrupt)
SIGTERM 程序外部产生,请求终止程序的信号(terminate)

处理信号

raise函数用于显示的引发参数所指定的信号。当一个信号发生时,程序可以使用三种方式对其作出反应。默认的反应由编译器定义,一般是终止程序。程序也可以指定其他对信号的反应行为:忽略或者信号处理函数:

1
int raise( int sig );

调用这个函数将引发它的参数所指定的信号。

signal函数将用于指定程序希望采取的反应。

1
void ( *signal( int sig, void ( *handler )( int ) ) )( int );

signal接收2个参数,第1个参数是信号,第2个参数是希望为这个信号设置的信号处理函数的指针。返回值是一个接收1个整型参数返回值是空的函数指针。事实上,signal函数返回一个指向该信号以前的处理函数的指针。如果因为非法信号导致调用失败,signal返回SIG_ERR。SIG_DEF和SIG_IGN可以用作signal函数的第2个参数。

信号处理函数

当一个已经设置了信号处理函数的信号发生时,系统为了防止如果信号处理函数内部也产生这个信号可能导致的无限循环,将首先恢复对该信号的默认行为,然后调用信号处理函数。

信号处理函数可能执行的工作类型是很有限的。如果信号是异步的,也就是说不是由于调用abort或raise函数引起的,信号处理函数就不应调用除signal之外的任何的库函数,因为在这种情况下其结果是未定义的。而且,信号处理函数除了能向一个类型为volatile sig_atomic_t的静态变量赋一个值以外,可能无法访问其他静态数据。(信号处理函数修改的变量值可能会在任何时候发生改变,因此可能在两条相邻的程序语句语句中变量的值不同,volatile关键字将告诉编译器这个事实。即当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问)

从一个信号处理函数返回导致程序的执行流从信号发生的地点恢复执行(SIGFPE例外)。如果希望捕捉将来同种信号,从当前这个信号的处理函数返回之前注意要调用signal函数重新设置信号处理函数。否则,只有第1个信号才会被捕捉,接下来的同种信号将按默认处理。

打印可变参数列表

1
2
3
int vprintf( char const *format, va_list arg );
int vfprintf( FILE *stream, char const *format, va_list arg );
int vsprintf( char *buffer, char const *format, va_list arg );

这组函数用于可变参数列表必须被打印的场合。必须包含<stdio.h><stdarg.h>。在调用这些函数之前,arg参数必须使用va_start进行初始化,这些函数不需要调用va_end。

执行环境

这些函数与程序的执行环境进行通信或者对程序的执行环境施加影响。

终止执行

1
2
3
void abort( void );
void atexit( void (func)( void ) );
void exit( int status );

abort函数用于不正常地终止一个正在执行的程序,将触发SIGABRT信号,若设置了信号处理函数,在程序终止前可以采取任何措施,哪怕不终止程序。atexit函数可以把一些函数注册为退出函数(exit function)。当程序将要正常终止(或者由于调用exit,或者由于main函数返回),退出函数将被调用。当exit函数被调用时,所有被atexit函数注册为退出函数的函数将按照它们所注册的顺序被反序调用。然后,所有用于流的缓冲区被刷新,所有打开文件被关闭。用tmpfile函数创建的文件被删除。然后退出状态返回给宿主环境,程序停止执行。

断言

1
void assert( int expression );

assert宏由ANSIC实现,常用于调试程序。当assert被执行时,这个宏对表达式参数进行测试。如果参数表达式值为0,它就向标准错误打印一条诊断信息并终止程序,这个消息格式由编译器定义,但会包含这个表达式和源文件的名字以及这个断言所在行号。

该宏提供了一个对应该为真的东西进行检查的方便方法,例如函数在对一个不能为NULL的指针参数进行调用前用assert进行验证。当程序被完整地测试完毕之后,可以在编译时通过定义NDEBUG消除所有断言(使用-DNDEBUG编译器命令行选项或在源文件assert.h被包含之前增加#define NDEBUG语句)。

环境

环境是一个由编译器定义的名字/值对的列表,由操作系统进行维护。getenv函数在这个列表中查找一个特定的名字,如果找到,返回一个指向其对应值的指针,程序不能修改返回的字符串。如果名字未找到,函数就返回NULL指针。

1
char *getenv( char const *name );

执行系统命令

1
void system( char const *command );

system函数把它的字符串参数传递给宿主操作系统,由系统的命令处理器执行。如果参数是NULL,则system用于询问命令处理器是否实际存在。在这种情况下,如果存在一个可用的命令处理器,system返回非0值,否则返回0。

排序和查找

1
void qsort( void *base, size_t n_elements, size_t el_size, int (*compare)(void const *, void const *) );

qsort函数在一个数组中以升序的方式对数据进行排序,与类型无关,只是数组内元素的长度需固定。第1个参数指向需要排序的数组,第2个参数指定数组中元素的数目,第3个参数指定每个元素的长度(以字节为单位)。第4个参数是一个函数指针,用于对需要排序的元素类型进行比较。比较函数应该返回一个整数,大于0、等于0和小于0表示第1个参数大于、等于和小于第2个参数。

bsearch函数在一个己经排好序的数组中用二分法查找一个特定的元素。如果数组尚未排序,其结果是未定义的。第1个参数指向你需要查找的值,第2个参数指向查找所在的数组,第3个参数指定数组中元素的数目,第4个参数是每个元素的长度(以字符为单位)。最后一个参数是和qsort中相同的指向比较函数的指针。bsech函数返回一个指向查找到的数组元素的指针。如果需要查找的值不存在,函数返回一个NULL指针。

1
void *bsearch( void const *key, coid const *base, size_t n_elements, size_t el_size, int (*compare)(void const *, void const *) );

locale

为了使C语言在全世界的范围内更为通用,标准定义了locale,这是一组特定的参数,每个国家可能各不相同。

1
char *setlocale( int category, char const *locale );

setlocale常用于修改整个或部分locale,可能影响库函数的运行方式。category参数指定locale的哪个部分需要进行修改,允许出现的值列于下表。如果第2个参数locale为NULL,函数将返回一个指向给定类型的当前locale的名字的指针。这个值可能被保存并继续在后续的setlocale中使用用以恢复。如果第2个参数不是NULL,它指定需要使用的新locale。如果函数调用成功,它将返回新locale的值,否则返回一个NULL指针,原来的locale不受影响。

数值和货币格式

1
struct lconv *localeconv( void );

localeconv函数用于获得根据当前的locale对非货币值和货币值进行合适的格式化所需要的信息。该函数不实际执行格式化任务,只是提供一些如何进行格式化的信息。lconv结构包含两种类型的参数:字符和字符指针。字符参数为非负值,如果一个字符参数为CHAR_MAX,那么这个值就在当前的locale中不可用(不使用)。对于字符指针,如果指向一个空字符串,与前者同意。

格式化非货币数值的参数

字段和类型 含义
char *decimal_point 用作小数点的字符。这个值绝不能是个空字符串。例如:”.”
char *thousands_sep 用作分隔小数点左边各组数字的符号。例如:”,”
char *grouping 指定小数点左边多少数字组成。例如:”\3”

格式化本地货币值的参数

字段和类型 含义
char *currency_symbol 本地货币符号
char *mon_decimal_point 小数点字符
char *mon_thousands_sep 用于分隔小数点左边各组数字的字符
char *mon_group 指定出现在小数点左边各组数字的数字个数
char *postive_sign 用于提示非负值的字符串
char *negative_sign 用于提示负值的字符串
char frac_digits 出现在小数点右边的数字个数
char p_cs_precedes 如果currency_symbol出现在一个非负值之前,其值为’\1’;如果出现在后面,其值为’\0’
char n_cs_precedes 如果currency_symbol出现在一个负值之前,其值为’\1’;如果出现在后面,其值为’\0’
char p_sep_by_space 如果currency_symbol和非负值之间用一个空格字符分隔,其值为’\1’;否则其值为’\0’
char n_sep_by_space 如果currency_symbol和负值之间用一个空格字符分隔,其值为’\1’;否则其值为’\0’
char n_sign_posn 提示negative_sign出现在一个负值中的位置。用于p_sign_posn的值也可用于此处
char p_sign_posn 提示positive_sign出现在一个非负值的位置

符号串和locale

1
2
int strcoll( char const *s1, char const *s2 );
size_t strcfrm( char *s1, char const *s2, size_t size );

一个机器的字符集的对照序列是固定的。但setlocale提供了一种方法指定不同的序列,当使用一个并非默认的对照列表时,可以采用上面两个函数。strcoll函数对两个根据当前locale的LC_COLLATE类型参数指定的字符串进行比较,比较可能比strcmp需要多得多的计算了,因为其需要遵循一个并非本地机器的对照序列。当字符串必须以这种方式反复进行比较时,使用strcfrm函数可以减少计算量。strcfrm把根据当前locale解释的第2个参数转换成一个不依赖于locale的字符串,尽管转换后的字符串内容不确定,但比较结果和strcoll相同。

改变locale的效果

locale可能向正在执行的程序所使用的字符集增加字符(但可能不会改变现存字符的含义)。例如,许多欧洲语言使用了能够提示重音、货币符号和其他特殊符号的扩展字符集。

打印的方向可能会改变。尤其,locale决定一个字符应该根据前面一个被打印的字符的哪个方向进行打印。printf和scanf函数机组使用当前locale定义的小数点符号。如果locale扩展了正在使用的字符集,isalpha、islower、isspace和isupper函数可能比以前包含更多的字符。正在使用的字符集的对照序列可能会改变。这个序列有strcoll函数使用,用于字符串之间的相互比较。strftime函数产生的日期和时间格式的很多方面都是特定于locale的。

运行时环境

判断运行时环境

第一步骤是从你的编译器获得一个汇编语言代码列表。

  • 测试程序
  • 静态变量和初始化
  • 堆栈帧
    • 一个函数分成三个部分:函数序、函数体、函数跋。
  • 寄存器变量
  • 外部标识符的长度
  • 判断堆栈帧布局
    • 运行时堆栈保存了每个函数运行时所需要的数据,包括它的自动变量和返回地址。
      • 传递函数参数
      • 函数序
      • 堆栈中的参数次序
      • 最终的堆栈帧布局
      • 函数跋
      • 返回值
      • 表达式的副作用

C和汇编语言的接口

编写能够调用C程序或者被C程序调用的汇编语言程序所需的内容。与这个环境相关的结果总结如下—你的环境肯定在某些方面与它不同!

  • 首先,汇编程序中的名字必须遵循外部标识符的规则。
  • 其次,汇编程序必须遵循正确的函数调用/返回协议。有两种情况:从一个汇编语言程序调用一个C程序和从一个程序调用一个汇编程序。为了从汇编程序调用C程序:
  • 如果寄存器d0、d1、a0或a1保存了重要的值,它们必须在调用C程序之前进行保存,因为C函数不会保存它们的值。
  • 任何函数的参数必须以参数列表相反的顺序压入到堆栈中。
  • 函数必须由一条“跳转子程序”类型的指令调用,它会把返回地址压入到堆栈中。
  • 当C函数返回时,汇编程序必须清除堆栈中的任何参数。
  • 如果汇编程序期望接受一个返回值,它将保持在d0(如果返回值的类型为double,它的另一半将位于d1)。
  • 任何在调用之前进行过保存的寄存器此时可以恢复。
  • 为了编写一个由C程序调用的汇编程序:
    • 保存任何你希望修改的寄存器(除d0、d1、a0或a1之外)。
    • 参数值从堆栈中获得,因为调用它的C函数把参数压入到堆栈中。
    • 如果函数应该返回一个值,它的值应该保存在d0中(在这种情况下,d0不能进行保存和恢复)。
    • 在返回之前,函数必须清除任何它压入到堆栈中的内容。

运行时效率

即使在一些现代的机器上,一个必须存储于ROM的程序必须相当小才有可能装入到有限的内存空间中。但许多现代计算机系统在这方面的限制大不如前,这是因为它们提供了虚拟内存。虚拟内存是由操作系统实现的,它在需要时把程序活动部分放入内存并把不活动的部分复制到磁盘中,这样就允许系统运行大型的程序。但程序越大,需要进行的复制就越多。所以大型程序不是想以前那样根本无法运行,而是随着程序的增大,它的执行效率逐渐降低。

如果一个程序太大或太慢,较之专研每个变量,看看把它们声明为register能不能提高效率,选一种效率更高的算法或数据结构往往效果要满意得多。然而这并不是说你可以在代码中胡作非为,因为风格恶劣的代码总是会把事情弄得更糟。

如果一个程序太大,很容易想到的着手方向:最大的函数和数据结构。如果程序太慢,着手方向:对程序进行性能测评,花费时间最多的部分程序和使用最频繁的那部分代码显然是需要优化的目标。如果这方面能够提升,将能大大提高程序的整体运行速度。
三个努力方向:

  • 在耗时最多的函数中,有些是库函数。如果能减少或不用可帮助大大提升性能。
  • 有效函数之所以耗费了大量的时间是因为它们被调用的次数非常多
  • 有些函数调用次数不多,但每次调用耗费时间却很长。寻找更优质的算法重构是努力的方向。
  • 可以对单个函数进行汇编语言重新编码,函数越小,重新编码越容易。

总结

绝大多数环境都创建某种类型的堆栈帧,函数用它来保存它们的数据,堆栈帧的细节可能各不相同,但它们的基本思路是相当一致的。

提高效率的最好方法是为它选择一种更好的算法,接下来的一种提高程序执行速度的最佳手段是对程序进行性能测评,看看程序在哪个地方花费的时间最多,把优化措施集中在程序的这部分将产生最好的结果。

警告总结

  • 是链接器而不是编译器决定外部标识符的最大长度;
  • 你无法链接由不同编译器产生的程序;

指针详解

前言:复杂类型说明

要了解指针,多多少少会出现一些比较复杂的类型,所以我先介绍一下如何完全理解一个复杂类型,要理解复杂类型其实很简单,一个类型里会出现很多运算符,他们也像普通的表达式一样,有优先级,其优先级和运算优先级一样,所以我总结了一下其原则:从变量名处起,根据运算符优先级结合,一步一步分析.下面让我们先从简单的类型开始慢慢分析吧:

  • int p;:这是一个普通的整型变量
  • int *p;:首先从P处开始,先与*结合,所以说明P是一个指针,然后再与int 结合,说明指针所指向的内容的类型为int 型.所以P是一个返回整型数据的指针
  • int p[3];:首先从P处开始,先与[]结合,说明P是一个数组,然后与int 结合,说明数组里的元素是整型的,所以P是一个由整型数据组成的数组
  • int *p[3];:首先从P处开始,先与[]结合,因为其优先级比*高,所以P是一个数组,然后再与*结合,说明数组里的元素是指针类型,然后再与int 结合,说明指针所指向的内容的类型是整型的,所以P是一个由返回整型数据的指针所组成的数组
  • int (*p)[3];:首先从P处开始,先与*结合,说明P是一个指针,然后再与[]结合(与”()”这步可以忽略,只是为了改变优先级),说明指针所指向的内容是一个数组,然后再与int 结合,说明数组里的元素是整型的.所以P是一个指向由整型数据组成的数组的指针
  • int **p;:首先从P开始,先与*结合,说是P是一个指针,然后再与*结合,说明指针所指向的元素是指针,然后再与int 结合,说明该指针所指向的元素是整型数据.由于二级指针以及更高级的指针极少用在复杂的类型中,所以后面更复杂的类型我们就不考虑多级指针了,最多只考虑一级指针
  • int p(int);:从P处起,先与()结合,说明P是一个函数,然后进入()里分析,说明该函数有一个整型变量的参数,然后再与外面的int 结合,说明函数的返回值是一个整型数据
  • int (*p)(int);:从P处开始,先与指针结合,说明P是一个指针,然后与()结合,说明指针指向的是一个函数,然后再与()里的int 结合,说明函数有一个int 型的参数,再与最外层的int 结合,说明函数的返回类型是整型,所以P是一个指向有一个整型参数且返回类型为整型的函数的指针
  • int *(*p(int))[3];:可以先跳过,不看这个类型,过于复杂从P开始,先与()结合,说明P是一个函数,然后进入()里面,与int 结合,说明函数有一个整型变量参数,然后再与外面的*结合,说明函数返回的是一个指针,,然后到最外面一层,先与[]结合,说明返回的指针指向的是一个数组,然后再与*结合,说明数组里的元素是指针,然后再与int 结合,说明指针指向的内容是整型数据.所以P是一个参数为一个整数据且返回一个指向由整型指针变量组成的数组的指针变量的函数.

说到这里也就差不多了,我们的任务也就这么多,理解了这几个类型,其它的类型对我们来说也是小菜了,不过我们一般不会用太复杂的类型,那样会大大减小程序的可读性,请慎用,这上面的几种类型已经足够我们用了。

细说指针

指针是一个特殊的变量,它里面存储的数值被解释成为内存里的一个地址。要搞清一个指针需要搞清指针的四方面的内容:指针的类型、指针所指向的类型、指针的值或者叫指针所指向的内存区、指针本身所占据的内存区。让我们分别说明。先声明几个指针放着做例子:
例一:

1
2
3
4
5
int*ptr;
char*ptr;
int**ptr;
int(*ptr)[3];
int*(*ptr)[4];

指针的类型

从语法的角度看,你只要把指针声明语句里的指针名字去掉,剩下的部分就是这个指针的类型。这是指针本身所具有的类型。让我们看看例一中各个指针的类型:

1
2
3
4
5
(1)int*ptr;//指针的类型是int*
(2)char*ptr;//指针的类型是char*
(3)int**ptr;//指针的类型是int**
(4)int(*ptr)[3];//指针的类型是int(*)[3]
(5)int*(*ptr)[4];//指针的类型是int*(*)[4]

怎么样?找出指针的类型的方法是不是很简单呢?

指针所指向的类型

当你通过指针来访问指针所指向的内存区时,指针所指向的类型决定了编译器将把那片内存区里的内容当做什么来看待。从语法上看,你只须把指针声明语句中的指针名字和名字左边的指针声明符*去掉,剩下的就是指针所指向的类型。例如:

1
2
3
4
5
(1)int*ptr; //指针所指向的类型是int
(2)char*ptr; //指针所指向的的类型是char
(3)int**ptr; //指针所指向的的类型是int*
(4)int(*ptr)[3]; //指针所指向的的类型是int()[3]
(5)int*(*ptr)[4]; //指针所指向的的类型是int*()[4]

在指针的算术运算中,指针所指向的类型有很大的作用。指针的类型(即指针本身的类型)和指针所指向的类型是两个概念。当你对C 越来越熟悉时,你会发现,把与指针搅和在一起的”类型”这个概念分成”指针的类型”和”指针所指向的类型”两个概念,是精通指针的关键点之一。我看了不少书,发现有些写得差的书中,就把指针的这两个概念搅在一起了,所以看起书来前后矛盾,越看越糊涂。

指针的值

指针的值是指针本身存储的数值,这个值将被编译器当作一个地址,而不是一个一般的数值。在32 位程序里,所有类型的指针的值都是一个32 位整数,因为32 位程序里内存地址全都是32 位长。指针所指向的内存区就是从指针的值所代表的那个内存地址开始,长度为sizeof(指针所指向的类型)的一片内存区。以后,我们说一个指针的值是XX,就相当于说该指针指向了以XX 为首地址的一片内存区域;我们说一个指针指向了某块内存区域,就相当于说该指针的值是这块内存区域的首地址。指针所指向的内存区和指针所指向的类型是两个完全不同的概念。在例一中,指针所指向的类型已经有了,但由于指针还未初始化,所以它所指向的内存区是不存在的,或者说是无意义的。以后,每遇到一个指针,都应该问问:这个指针的类型是什么?指针指的类型是什么?该指针指向了哪里?(重点注意)

指针本身所占据的内存区

指针本身占了多大的内存?你只要用函数sizeof(指针的类型)测一下就知道了。在32 位平台里,指针本身占据了4 个字节的长度。指针本身占据的内存这个概念在判断一个指针表达式(后面会解释)是否是左值时很有用。

指针的算术运算

指针可以加上或减去一个整数。指针的这种运算的意义和通常的数值的加减运算的意义是不一样的,以单元为单位。例如:

1
2
3
char a[20];
int *ptr=(int *)a; //强制类型转换并不会改变a 的类型
ptr++;

在上例中,指针ptr 的类型是int*,它指向的类型是int,它被初始化为指向整型变量a。接下来的第3句中,指针ptr被加了1,编译器是这样处理的:它把指针ptr 的值加上了sizeof(int),在32 位程序中,是被加上了4,因为在32 位程序中,int 占4 个字节。由于地址是用字节做单位的,故ptr 所指向的地址由原来的变量a 的地址向高地址方向增加了4 个字节。由于char 类型的长度是一个字节,所以,原来ptr 是指向数组a 的第0 号单元开始的四个字节,此时指向了数组a 中从第4 号单元开始的四个字节。我们可以用一个指针和一个循环来遍历一个数组,看例子:

1
2
3
4
5
6
7
int array[20] = {0};
int *ptr = array;
for (i = 0; i < 20; i ++)
{
(*ptr) ++;
ptr ++;
}

这个例子将整型数组中各个单元的值加1。由于每次循环都将指针ptr加1 个单元,所以每次循环都能访问数组的下一个单元。再看例子:

1
2
3
char a[20] = "You_are_a_girl";
int *ptr = (int*)a;
ptr += 5;

在这个例子中,ptr 被加上了5,编译器是这样处理的:将指针ptr 的值加上5 乘sizeof(int),在32 位程序中就是加上了5 乘4=20。由于地址的单位是字节,故现在的ptr 所指向的地址比起加5 后的ptr 所指向的地址来说,向高地址方向移动了20个字节。在这个例子中,没加5前的ptr指向数组a的第0号单元开始的四个字节,加5后,ptr已经指向了数组a的合法范围之外了。虽然这种情况在应用上会出问题,但在语法上却是可以的。这也体现出了指针的灵活性。

如果上例中,ptr 是被减去5,那么处理过程大同小异,只不过ptr 的值是被减去5 乘sizeof(int),新的ptr 指向的地址将比原来的ptr 所指向的地址向低地址方向移动了20 个字节。下面请允许我再举一个例子:(一个误区)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>
int main()
{
char a[20]=" You_are_a_girl";
char *p=a;
char **ptr=&p;
//printf("p=%d\n",p);
//printf("ptr=%d\n",ptr);
//printf("*ptr=%d\n",*ptr);
printf("**ptr=%c\n",**ptr);
ptr++;
//printf("ptr=%d\n",ptr);
//printf("*ptr=%d\n",*ptr);
printf("**ptr=%c\n",**ptr);
}

误区一:输出答案为Y 和o

误解:ptr 是一个char 的二级指针,当执行ptr++;时,会使指针加一个sizeof(char),所以输出如上结果,这个可能只是少部分人的结果.

误区二:输出答案为Y 和a

误解:ptr 指向的是一个char *类型,当执行ptr++;时,会使指针加一个sizeof(char *)(有可能会有人认为这个值为1,那就会得到误区一的答案,这个值应该是4,参考前面内容), 即&p+4; 那进行一次取值运算不就指向数组中的第五个元素了吗?那输出的结果不就是数组中第五个元素了吗?答案是否定的。

正解: ptr 的类型是char **,指向的类型是一个char *类型,该指向的地址就是p的地址(&p),当执行ptr++;时,会使指针加一个sizeof(char*),即&p+4;*(&p+4)指向哪呢,这个你去问上帝吧,或者他会告诉你在哪?所以最后的输出会是一个随机的值,或许是一个非法操作.

总结一下:一个指针ptrold加(减)一个整数n 后,结果是一个新的指针ptrnewptrnew 的类型和ptrold 的类型相同,ptrnew 所指向的类型和ptrold所指向的类型也相同。ptrnew 的值将比ptrold 的值增加(减少)了n乘sizeof(ptrold 所指向的类型)个字节。就是说,ptrnew所指向的内存区将比ptrold 所指向的内存区向高(低)地址方向移动了n乘sizeof(ptrold 所指向的类型)个字节。

指针和指针进行加减:两个指针不能进行加法运算,这是非法操作,因为进行加法后,得到的结果指向一个不知所向的地方,而且毫无意义。两个指针可以进行减法操作,但必须类型相同,一般用在数组方面,不多说了。

运算符&*

这里&是取地址运算符,*是间接运算符。&a的运算结果是一个指针,指针的类型是a的类型加个*,指针所指向的类型是a 的类型,指针所指向的地址嘛,那就是a 的地址。*P的运算结果就五花八门了。总之*P的结果是P所指向的东西,这个东西有这些特点:它的类型是P指向的类型,它所占用的地址是p所指向的地址。

例六:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int a=12; 
int b;
int *p;
int **ptr;

p=&a; //&a 的结果是一个指针,类型是int*,指向的类型是
//int,指向的地址是a 的地址。

*p=24; //*P的结果,在这里它的类型是int,它所占用的地址是
//P所指向的地址,显然,*P就是变量a。

ptr=&p; //&P的结果是个指针,该指针的类型是P的类型加个*,
//在这里是int **。该指针所指向的类型是P的类型,这
//里是int*。该指针所指向的地址就是指针P自己的地址。

*ptr=&b; //*ptr 是个指针,&b 的结果也是个指针,且这两个指针
//的类型和所指向的类型是一样的,所以用&b 来给*ptr 赋
//值就是毫无问题的了。

**ptr=34; //*ptr 的结果是ptr 所指向的东西,在这里是一个指针,
//对这个指针再做一次*运算,结果是一个int 类型的变量。

指针表达式

一个表达式的结果如果是一个指针,那么这个表达式就叫指针表式。下面是一些指针表达式的例子:

例七:

1
2
3
4
5
6
7
8
int a,b;
int array[10];
int *pa;
pa=&a; //&a 是一个指针表达式。
int **ptr=&pa; //&pa 也是一个指针表达式。
*ptr=&b; //*ptr 和&b 都是指针表达式。
pa=array;
pa++; //这也是指针表达式。

例八:

1
2
3
4
5
6
char *arr[20];
char **parr=arr; //如果把arr 看作指针的话,arr 也是指针表达式
char *str;
str=*parr; //*parr 是指针表达式
str=*(parr+1); //*(parr+1)是指针表达式
str=*(parr+2); //*(parr+2)是指针表达式

由于指针表达式的结果是一个指针,所以指针表达式也具有指针所具有的四个要素:指针的类型,指针所指向的类型,指针指向的内存区,指针自身占据的内存。好了,当一个指针表达式的结果指针已经明确地具有了指针自身占据的内存的话,这个指针表达式就是一个左值,否则就不是一个左值。在例七中,&a不是一个左值,因为它还没有占据明确的内存。*ptr是一个左值,因为*ptr这个指针已经占据了内存,其实*ptr就是指针pa,既然pa 已经在内存中有了自己的位置,那么*ptr当然也有了自己的位置。

数组和指针的关系

数组的数组名其实可以看作一个指针。看下例:

1
2
3
4
int array[10]={0,1,2,3,4,5,6,7,8,9},value;
value=array[0]; //也可写成:value=*array;
value=array[3]; //也可写成:value=*(array+3);
value=array[4]; //也可写成:value=*(array+4);

上例中,一般而言数组名array 代表数组本身,类型是int[10],但如果把array 看做指针的话,它指向数组的第0 个单元,类型是int*所指向的类型是数组单元的类型即int。因此*array 等于0 就一点也不奇怪了。同理,array+3 是一个指向数组第3 个单元的指针,所以*(array+3)等于3。其它依此类推。

1
2
3
4
5
6
7
8
9
char *str[3]={
"Hello,thisisasample!",
"Hi,goodmorning.",
"Helloworld"
};
char s[80];
strcpy(s,str[0]); //也可写成strcpy(s,*str);
strcpy(s,str[1]); //也可写成strcpy(s,*(str+1));
strcpy(s,str[2]); //也可写成strcpy(s,*(str+2));

上例中,str 是一个三单元的数组,该数组的每个单元都是一个指针,这些指针各指向一个字符串。把指针数组名str 当作一个指针的话,它指向数组的第0 号单元,它的类型是char **,它指向的类型是char **str也是一个指针,它的类型是char *,它所指向的类型是char,它指向的地址是字符串”Hello,thisisasample!”的第一个字符的地址,即’H’的地址。

注意:字符串相当于是一个数组,在内存中以数组的形式储存,只不过字符串是一个数组常量,内容不可改变,且只能是右值.如果看成指针的话,他即是常量指针,也是指针常量。str+1 也是一个指针,它指向数组的第1 号单元,它的类型是char**,它指向的类型是char**(str+1)也是一个指针,它的类型是char*,它所指向的类型是char,它指向”Hi,goodmorning.”的第一个字符’H’

下面总结一下数组的数组名(数组中储存的也是数组)的问题:
声明了一个数组TYPE array[n],则数组名称array 就有了两重含义:

  • 第一,它代表整个数组,它的类型是TYPE[n];
  • 第二,它是一个常量指针,该指针的类型是TYPE*,该指针指向的类型是TYPE,也就是数组单元的类型,该指针指向的内存区就是数组第0 号单元,该指针自己占有单独的内存区,注意它和数组第0 号单元占据的内存区是不同的。该指针的值是不能修改的,即类似array++的表达式是错误的。在不同的表达式中数组名array 可以扮演不同的角色。在表达式sizeof(array)中,数组名array 代表数组本身,故这时sizeof 函数测出的是整个数组的大小。在表达式*array 中,array 扮演的是指针,因此这个表达式的结果就是数组第0 号单元的值。sizeof(*array)测出的是数组单元的大小。表达式array+n(其中n=0,1,2,…..)中,array 扮演的是指针,故array+n 的结果是一个指针,它的类型是TYPE *,它指向的类型是TYPE,它指向数组第n号单元。故sizeof(array+n)测出的是指针类型的大小。在32 位程序中结果是4。
1
2
3
int array[10];
int (*ptr)[10];
ptr = &array;

上例中ptr 是一个指针,它的类型是int(*)[10],他指向的类型是int[10] ,我们用整个数组的首地址来初始化它。在语句ptr = &array中,array 代表数组本身。本节中提到了函数sizeof(),那么我来问一问,sizeof(指针名称)测出的究竟是指针自身类型的大小呢还是指针所指向的类型的大小?

答案是前者。例如:

1
int(*ptr)[10];

则在32 位程序中,有:

1
2
3
sizeof(int(*)[10])==4
sizeof(int[10])==40
sizeof(ptr)==4

实际上,sizeof(对象)测出的都是对象自身的类型的大小,而不是别的什么类型的大小。

指针和结构类型的关系

可以声明一个指向结构类型对象的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct MyStruct
{
int a;
int b;
int c;
};
struct MyStruct ss={20,30,40};
//声明了结构对象ss,并把ss 的成员初始化为20,30 和40。

struct MyStruct *ptr=&ss;
//声明了一个指向结构对象ss 的指针。它的类型是
//MyStruct *,它指向的类型是MyStruct。

int *pstr=(int*)&ss;
//声明了一个指向结构对象ss 的指针。但是pstr 和
//它被指向的类型ptr 是不同的。

请问怎样通过指针ptr 来访问ss 的三个成员变量?
答案:

1
2
3
ptr->a; //指向运算符,或者可以这们(*ptr).a,建议使用前者
ptr->b;
ptr->c;

又请问怎样通过指针pstr 来访问ss 的三个成员变量?
答案:

1
2
3
*pstr; //访问了ss 的成员a。
*(pstr+1); //访问了ss 的成员b。
*(pstr+2) //访问了ss 的成员c。

虽然我在我的MSVC++6.0 上调式过上述代码,但是要知道,这样使用pstr 来访问结构成员是不正规的,为了说明为什么不正规,让我们看看怎样通过指针来访问数组的各个单元: (将结构体换成数组)

例十三:

1
2
3
4
5
6
int array[3]={35,56,37};
int *pa=array;
//通过指针pa 访问数组array 的三个单元的方法是:
*pa; //访问了第0 号单元
*(pa+1); //访问了第1 号单元
*(pa+2); //访问了第2 号单元

从格式上看倒是与通过指针访问结构成员的不正规方法的格式一样。所有的C/C++编译器在排列数组的单元时,总是把各个数组单元存放在连续的存储区里,单元和单元之间没有空隙。但在存放结构对象的各个成员时,在某种编译环境下,可能会需要字对齐或双字对齐或者是别的什么对齐,需要在相邻两个成员之间加若干个”填充字节”,这就导致各个成员之间可能会有若干个字节的空隙。所以,在例十二中,即使*pstr访问到了结构对象ss 的第一个成员变量a,也不能保证*(pstr+1)就一定能访问到结构成员b。因为成员a 和成员b 之间可能会有若干填充字节,说不定*(pstr+1)就正好访问到了这些填充字节呢。这也证明了指针的灵活性。要是你的目的就是想看看各个结构成员之间到底有没有填充字节,嘿,这倒是个不错的方法。不过指针访问结构成员的正确方法应该是象例十二中使用指针ptr 的方法。

指针和函数的关系

可以把一个指针声明成为一个指向函数的指针。

1
2
3
4
int fun1(char *,int);
int (*pfun1)(char *,int);
pfun1=fun1;
int a=(*pfun1)("abcdefg",7); //通过函数指针调用函数。

可以把指针作为函数的形参。在函数调用语句中,可以用指针表达式来作为实参。

例十四:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int fun(char *);
int a;
char str[] = "abcdefghijklmn";
a = fun(str);

int fun(char *s)
{
int num = 0;
for(int i = 0; ; )
{
num += *s;
s ++;
}
return num;
}

这个例子中的函数fun 统计一个字符串中各个字符的ASCII 码值之和。前面说了,数组的名字也是一个指针。在函数调用中,当把str作为实参传递给形参s 后,实际是把str 的值传递给了s,s 所指向的地址就和str 所指向的地址一致,但是str 和s 各自占用各自的存储空间。在函数体内对s 进行自加1 运算,并不意味着同时对str 进行了自加1 运算。

指针类型转换

当我们初始化一个指针或给一个指针赋值时,赋值号的左边是一个指针,赋值号的右边是一个指针表达式。在我们前面所举的例子中,绝大多数情况下,指针的类型和指针表达式的类型是一样的,指针所指向的类型和指针表达式所指向的类型是一样的。

例十五:

1
2
3
float f = 12.3;
float *fptr = &f;
int *p;

在上面的例子中,假如我们想让指针P指向实数f,应该怎么办?是用下面的语句吗?

1
p = &f;

不对。因为指针P的类型是int *,它指向的类型是int。表达式&f的结果是一个指针,指针的类型是float *,它指向的类型是float。两者不一致,直接赋值的方法是不行的。至少在我的MSVC++6.0 上,对指针的赋值语句要求赋值号两边的类型一致,所指向的类型也一致,其它的编译器上我没试过,大家可以试试。

为了实现我们的目的,需要进行”强制类型转换”:p=(int*)&f;
如果有一个指针p,我们需要把它的类型和所指向的类型改为TYEP *TYPE, 那么语法格式是: (TYPE *)p

这样强制类型转换的结果是一个新指针,该新指针的类型是TYPE *,它指向的类型是TYPE,它指向的地址就是原指针指向的地址。而原来的指针P的一切属性都没有被修改。(切记)

一个函数如果使用了指针作为形参,那么在函数调用语句的实参和形参的结合过程中,必须保证类型一致,否则需要强制转换。

1
2
3
4
5
6
7
8
9
void fun(char*);
int a = 125,b;
fun((char*)&a);
void fun(char*s)
{
char c;
c = *(s+3);*(s+3)=*(s+0);*(s+0)=c;
c = *(s+2);*(s+2)=*(s+1);*(s+1)=c;
}

注意这是一个32 位程序,故int 类型占了四个字节,char 类型占一个字节。函数fun 的作用是把一个整数的四个字节的顺序来个颠倒。注意到了吗?在函数调用语句中,实参&a的结果是一个指针,它的类型是int *,它指向的类型是int。形参这个指针的类型是char *,它指向的类型是char。这样,在实参和形参的结合过程中,我们必须进行一次从int *类型到char *类型的转换。

结合这个例子,我们可以这样来想象编译器进行转换的过程:编译器先构造一个临时指针char *temp,然后执行temp=(char *)&a,最后再把temp的值传递给s。

所以最后的结果是:s 的类型是char *,它指向的类型是char,它指向的地址就是a 的首地址。我们已经知道,指针的值就是指针指向的地址,在32 位程序中,指针的值其实是一个32 位整数。那可不可以把一个整数当作指针的值直接赋给指针呢?就象下面的语句:

1
2
3
4
5
6
unsigned int a;
TYPE *ptr; //TYPE 是int,char 或结构类型等等类型。
a=20345686;
ptr=20345686; //我们的目的是要使指针ptr 指向地址20345686

ptr=a; //我们的目的是要使指针ptr 指向地址20345686

编译一下吧。结果发现后面两条语句全是错的。那么我们的目的就不能达到了吗?不,还有办法:

1
2
3
4
unsigned int a;
TYPE *ptr; //TYPE 是int,char 或结构类型等等类型。
a=N //N 必须代表一个合法的地址;
ptr=(TYPE*)a; //呵呵,这就可以了。

严格说来这里的(TYPE *)和指针类型转换中的(TYPE *)还不一样。这里的(TYPE*)的意思是把无符号整数a 的值当作一个地址来看待。上面强调了a 的值必须代表一个合法的地址,否则的话,在你使用ptr 的时候,就会出现非法操作错误。想想能不能反过来,把指针指向的地址即指针的值当作一个整数取出来。完全可以。下面的例子演示了把一个指针的值当作一个整数取出来,然后再把这个整数当作一个地址赋给一个指针:

例十七:

1
2
3
4
5
int a = 123, b;
int *ptr = &a;
char *str;
b = (int)ptr; //把指针ptr 的值当作一个整数取出来。
str = (char*)b; //把这个整数的值当作一个地址赋给指针str。

现在我们已经知道了,可以把指针的值当作一个整数取出来,也可以把一个整数值当作地址赋给一个指针。

指针的安全问题

看下面的例子:

1
2
3
4
char s = 'a';
int *ptr;
ptr = (int *)&s;
*ptr = 1298;

指针ptr 是一个int *类型的指针,它指向的类型是int。它指向的地址就是s 的首地址。在32 位程序中,s 占一个字节,int 类型占四个字节。最后一条语句不但改变了s 所占的一个字节,还把和s 相临的高地址方向的三个字节也改变了。这三个字节是干什么的?只有编译程序知道,而写程序的人是不太可能知道的。也许这三个字节里存储了非常重要的数据,也许这三个字节里正好是程序的一条代码,而由于你对指针的马虎应用,这三个字节的值被改变了!这会造成崩溃性的错误。让我们再来看一例:

例十九:

1
2
3
4
char a;
int *ptr = &a;
ptr++;
*ptr = 115;

该例子完全可以通过编译,并能执行。但是看到没有?第3句对指针ptr 进行自加1 运算后,ptr 指向了和整形变量a 相邻的高地址方向的一块存储区。这块存储区里是什么?我们不知道。有可能它是一个非常重要的数据,甚至可能是一条代码。而第4 句竟然往这片存储区里写入一个数据!这是严重的错误。

所以在使用指针时,程序员心里必须非常清楚:我的指针究竟指向了哪里。在用指针访问数组的时候,也要注意不要超出数组的低端和高端界限,否则也会造成类似的错误。在指针的强制类型转换:ptr1=(TYPE *)ptr2中,如果sizeof(ptr2的类型)大于sizeof(ptr1 的类型),那么在使用指针ptr1 来访问ptr2所指向的存储区时是安全的。如果sizeof(ptr2 的类型)小于sizeof(ptr1 的类型),那么在使用指针ptr1 来访问ptr2 所指向的存储区时是不安全的。

预处理的工作方式

在C语言的程序中可包括各种以符号#开头的编译指令,这些指令称为预处理命令。预处理命令属于C语言编译器,而不是C语言的组成部分。通过预处理命令可扩展C语言程序设计的环境。

预处理的功能

在集成开发环境中,编译,链接是同时完成的。其实,C语言编译器在对源代码编译之前,还需要进一步的处理:预编译。
所以,完整的步骤是:预编译 -> 编译 -> 链接
预编译的主要作用如下:

  1. 将源文件中以”include”格式包含的文件复制到编译的源文件中。
  2. 用实际值替换用“#define”定义的字符串。
  3. 根据“#if”后面的条件决定需要编译的代码。

预处理的工作方式

预处理的行为是由指令控制的。这些指令是由#字符开头的一些命令。

#define指令定义了一个宏—-用来代表其他东西的一个命令,通常是某一个类型的常量。预处理会通过将宏的名字和它的定义存储在一起来响应#define指令。当这个宏在后面的程序中使用到时,预处理器”扩展”了宏,将宏替换为它所定义的值。例如:下面这行命令:

1
#define PI 3.141592654

#include指令告诉预处理器打开一个特定的文件,将它的内容作为正在编译的文件的一部分“包含”进来。例如:下面这行命令:
1
#include<stdio.h>

指示预处理器打开一个名字为stdio.h的文件,并将它的内容加到当前的程序中。

预处理器的输入是一个C语言程序,程序可能包含指令。预处理器会执行这些指令,并在处理过程中删除这些指令。预处理器的输出是另外一个程序:原程序的一个编辑后的版本,不再包含指令。预处理器的输出被直接交给编译器,编译器检查程序是否有错误,并经程序翻译为目标代码。

预处理指令

预处理指令

大多数预处理器指令属于下面3种类型:

  1. 宏定义:#define 指令定义一个宏,#undef指令删除一个宏定义。
  2. 文件包含:#include指令导致一个指定文件的内容被包含到程序中。
  3. 条件编译:#if,#ifdef,#ifndef,#elif,#else和#endif指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。

剩下的#error,#line和#pragma指令更特殊的指令,较少用到。

指令规则

指令都是以#开始。#符号不需要在一行的行首,只要她之前有空白字符就行。在#后是指令名,接着是指令所需要的其他信息。
在指令的符号之间可以插入任意数量的空格或横向制表符。
指令总是第一个换行符处结束,除非明确地指明要继续。
指令可以出现在程序中任何地方。我们通常将#define和#include指令放在文件的开始,其他指令则放在后面,甚至在函数定义的中间。
注释可以与指令放在同一行。

宏定义命令——#define

使用#define命令并不是真正的定义符号常量,而是定义一个可以替换的宏。被定义为宏的标示符称为“宏名”。在编译预处理过程时,对程序中所有出现的“宏名”,都用宏定义中的字符串去代换,这称为“宏代换”或“宏展开”。

在C语言中,宏分为有参数和无参数两种。

无参数的宏

其定义格式如下:

1
#define 宏名  字符串

在以上宏定义语句中,各部分的含义如下:

  • :表示这是一条预处理命令(凡是以“#”开始的均为预处理命令)。

  • define:关键字“define”为宏定义命令。
  • 宏名:是一个标示符,必须符合C语言标示符的规定,一般以大写字母标示宏名。
  • 字符串:可以是常数,表达式,格式串等。在前面使用的符号常量的定义就是一个无参数宏定义。

Notice:预处理命令语句后面一般不会添加分号,如果在#define最后有分号,在宏替换时分号也将替换到源代码中去。在宏名和字符串之间可以有任意个空格。

1
#define PI 3.14

在使用宏定义时,还需要注意以下几点:

  • 宏定义是宏名来表示一个字符串,在宏展开时又以该字符串取代宏名。这只是一种简单的代换,字符串中可以含任何字符,可以是常数,也可以是表达式,预处理程序对它不作任何检查。如有错误,只能在编译已被宏展开后的源程序时发现。
  • 宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。
  • 宏名在源程序中若用引号括起来,则预处理程序不对其作宏替换。
  • 宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名。在宏展开时由预处理程序层层替换。
  • 习惯上宏名可用大写字母表示,以方便与变量区别。但也允许用小写字母。

带参数的宏

#define命令定义宏时,还可以为宏设置参数。与函数中的参数类似,在宏定于中的参数为形式参数,在宏调用中的参数称为实际参数。对带参数的宏,在调用中,不仅要宏展开,还要用实参去代换形参。
带参宏定义的一般形式为:

1
#define 宏名(形参表) 字符串  

在定义带参数的宏时,宏名和形参表之间不能有空格出现,否则,就将宏定义成为无参数形式,而导致程序出错。
1
#define ABS(x)  (x)<0?-(x):(x)

以上的宏定义中,如果x的值小于0,则使用一元运算符(-)对其取负,得到正数。

带参的宏和带参的函数相似,但其本质是不同的。使用带参宏时,在预处理时将程序源代码替换到相应的位置,编译时得到完整的目标代码,而不进行函数调用,因此程序执行效率要高些。而函数调用只需要编译一次函数,代码量较少,一般情况下,对于简单的功能,可使用宏替换的形式来使用。

预处理操作符#和

操作符

在使用#define定义宏时,可使用操作符#在字符串中输出实参。Eg:

1
#define AREA(x,y) printf(“长为“#x”,宽为“#y”的长方形的面积:%d\n”,(x)*(y));

操作符

与操作符#类似,操作符##也可用在带参宏中替换部分内容。该操作符将宏中的两个部分连接成一个内容。例如,定义如下宏:

1
#define VAR(n)   v##n 

当使用一下方式引用宏:VAR(1)
预处理时,将得到以下形式:v1

如果使用以下宏定义:

1
#define FUNC(n)  oper##n  

当实参为1时,预处理后得到一下形式:
1
oper1

文件包含———include

当一个C语言程序由多个文件模块组成时,主模块中一般包含main函数和一些当前程序专用的函数。程序从main函数开始执行,在执行过程中,可调用当前文件中的函数,也可调用其他文件模块中的函数。

如果在模块中要调用其他文件模块中的函数,首先必须在主模块中声明该函数原型。一般都是采用文件包含的方法,包含其他文件模块的头文件。

文件包含中指定的文件名即可以用引号括起来,也可以用尖括号括起来,格式如下:

1
#include< 文件名>


1
#include“文件名”

如果使用尖括号<>括起文件名,则编译程序将到C语言开发环境中设置好的 include文件中去找指定的文件。

因为C语言的标准头文件都存放在include文件夹中,所以一般对标准头文件采用尖括号;对编程自己编写的文件,则使用双引号。

如果自己编写的文件不是存放在当前工作文件夹,可以在#include命令后面加在路径。

#include命令的作用是把指定的文件模块内容插入到#include所在的位置,当程序编译链接时,系统会把所有#include指定的文件链接生成可执行代码。文件包含必须以#开头,表示这是编译预处理命令,行尾不能用分号结束。
  
#include所包含的文件,其扩展名可以是“.c”,表示包含普通C语言源程序。也可以是 “.h”,表示C语言程序的头文件。C语言系统中大量的定义与声明是以头文件形式提供的。 “.h”是接口文件,如果想理解C语言接口的写法,有必要琢磨一下 “.h”。

通过#define包含进来的文件模块中还可以再包含其他文件,这种用法称为嵌套包含。嵌套的层数与具体C语言系统有关,但是一般可以嵌套8层以上。

条件编译

预处理器还提供了条件编译功能。在预处理时,按照不同的条件去编译程序的不同部分,从而得到不同的目标代码。

使用条件编译,可方便地处理程序的调试版本和正式版本,也可使用条件编译使程序的移植更方便。

使用#if

与C语言的条件分支语句类似,在预处理时,也可以使用分支,根据不同的情况编译不同的源代码段。

#if的使用格式如下:

1
2
3
4
5
#if 常量表达式
程序段
#else
程序段
#endif

该条件编译命令的执行过程为:若常量表达式的值为真(非0),则对程序段1进行编译,否则对程序段2进行编译。因此可以使程序在不同条件下完成不同的功能。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define DEBUG 1
int main()
{
int i,j;
char ch[26];
for(i='a';j=0;i<='z';i++,j++)
{
ch[j]=i;
#if DEBUG
printf("ch[%d]=%c\n",j,ch[j]);
#endif
}
for(j=0;j<26;j++)
{
printf("%c",ch[j]);
}
return 0;
}

#if预编译命令还可使用多分支语句格式,具体格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#if 常量表达式 1

程序段 1

#elif 常量表达式 2

程序段 2

… …

#elif 常量表达式 n

程序段 n

#else

程序段 m

#endif

关键字#elif与多分支if语句中的else if类似。
举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define os win

#if os=win

#include"win.h"

#elif os=linux

#include"linux.h"

#elif os=mac

#include"mac.h"

#endif

#if#elif还可以进行嵌套,C89标准中,嵌套深度可以到达8层,而C99允许嵌套达到63层。在嵌套时,每个#endif,#else或#elif与最近的#if或#elif配对。
Eg:

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
#define MAX 100
#define OLD -1

int main()
{
int i;
#if MAX>50
{
#if OLD>3
{
i=1;
{
#elif OLD>0
{
i=2;
}
#else
{
i=3;
}
#endif
}
#else
{
#if OLD>3
{
i=4;
}
#elif OLD>4
{
i=5;
}
#else
{
i=6;
}
#endif
}
#endif
return 0;
}

使用#ifdef和#ifndef

在上面的#if条件编译命令中,需要判断符号常量定义的具体值。在很多情况下,其实不需要判断符号常量的值,只需要判断是否定义了该符号常量。这时,可不使用#if命令,而使用另外一个预编译命令———#ifdef.

1
2
3
4
5
6
7
8
9
#ifdef命令的使用格式如下:
#ifdef 标识符
程序段 1

#else

程序段 2

#endif

其意义是,如果#ifdef后面的标识符已被定义过,则对“程序段1”进行编译;如果没有定义标识符,则编译“程序段2”。一般不使用#else及后面的“程序2”。

而#ifndef的意义与#ifdef相反,其格式如下:

1
2
3
4
5
6
7
8
#ifndef 标识符
程序段 1

#else

程序段 2

#endif

其意义是:如果未定义标识符,则编译“程序段1”;否则编译“程序段2”。

使用#defined和#undef

与#ifdef类似的,可以在#if命令中使用define来判断是否已定义指定的标识符。例如:

1
2
3
4
#if defined 标识符
程序段 1

#endif

与下面的标示方式意义相同。

1
2
3
4
#ifdef 标识符
程序段 1

#endif

也可使用逻辑运算符,对defined取反。例如:

1
2
3
4
#if ! define 标识符
程序段 1

#endif

与下面的标示方式意义相同。

1
2
3
4
#ifndef 标识符
程序段 1

#endif

在#ifdef和#ifndef命令后面的标识符是使用#define进行定义的。在程序中,还可以使用#undef取消对标识符的定义,其形式为:

1
#undef 标识符  

举个例子:
1
2
3
#define MAX 100 
……
#undef MAX

在以上代码中,首先使用#define定义标识符MAX,经过一段程序代码后,又可以使用#undef取消已定义的标识符。使用#undef命令后,再使用#ifdef max,将不会编译后的源代码,因为此时标识符MAX已经被取消定义了。

其他预处理命令

预定义的宏名

ANSI C标准预定义了五个宏名,每个宏名的前后均有两个下画线,避免与程序员定义相同的宏名(一般都不会定义前后有两个下划线的宏)。这5个宏名如下:

  • __DATE__:当前源程序的创建日期。
  • __FILE__:当前源程序的文件名称(包括盘符和路径)。
  • __LINE__:当前被编译代码的行号。
  • __STDC__:返回编译器是否位标准C,若其值为1表示符合标准C,否则不是标准C.
  • __TIME__:当前源程序的创建时间。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
#include<stdio.h>
int main()
{
int j;
printf("日期:%s\n",__DATE__);
printf("时间:%s\n",__TIME__};
printf("文件名:%s\n",__FILE__);
printf("这是第%d行代码\n",__LINE__);
printf("本编译器%s标准C\n",(__STD__)?"符合":"不符合");
return 0;
}

重置行号和文件名命令——————#line

使用__LINE__预定义宏名赈灾编译的程序行号。使用#line命令可改变预定义宏__LINE____FILE__的内容,该命令的基本形如下:

1
#line number[“filename”]

其中的数字为一个正整数,可选的文件名为有效文件标识符。行号为源代码中当前行号,文件名为源文件的名字。命令为#line主要用于调试以及其他特殊应用。
举个例子:
1
2
3
4
5
6
7
8
9
#include<stdio.h>
#include<stdlib.h>

#line 1000
int main()
{
printf("当前行号:%d\n",__LINE__);
return 0;
}

在以上程序中,在第4行中使用#line定义的行号为从1000开始(不包括#line这行)。所以第5行的编号将为1000,第6行为1001,第7行为1002,第8行为1003.

修改编译器设置命令 pragma

#pragma命令的作用是设定编译器的状态,或者指示编译器完全一些特定的动作。#pragma命令对每个编译器给出了一个方法,在保持与C语言完全兼容的情况下,给出主机或者操作系统专有的特征。其格式一般为:

1
#pragma Para

其中,Para为参数,可使用的参数很多,下面列出常用的参数:

  • Message参数,该参数能够在编译信息输出窗口中输出对应的信息,这对于源代码信息的控制是非常重要的,其使用方法是:
    1
    #pragma message(消息文本)
    当编译器遇到这条指令时,就在编译输出窗口中将消息文本显示出来。
  • 另外一个使用比较多得pragma参数是code_seg.格式如:
    1
    #pragma code_seg([“section_name”[,section_class]])
    它能够设置程序中函数代码存放的代码段,在开发驱动程序的时候就会使用到它。

参数once,可保证头文件被编译一次,其格式为:

1
#pragma once

只要在头文件的最开始加入这条指令就能够保证头文件被编译一次。

产生错误信息命令 ——————#error

#error命令强制编译器停止编译,并输出一个错误信息,主要用于程序调试。其使用如下:

1
#error 信息错误  

注意,错误信息不用双括号括起来。当遇到#error命令时,错误信息将显示出来。

例如,以下编译预处理器命令判断预定义宏__STDC__,如果其值不为1,则显示一个错误信息,提示程序员该编译器不支持ANSI C标准。

1
2
3
4
#if __STDC__!=1

#error NOT ANSI C
#endif

内联函数

在使用#define定义带参数宏时,在调用函数时,一般需要增加系统的开销,如参数传递,跳转控制,返回结果等额外操作需要系统内存和执行时间。而使用带参数宏时,通过宏替换可再编译前将函数代码展开导源代码中,使编译后的目标文件含有多段重复的代码。这样做,会增加程序的代码量,都可以减少执行时间。
  
在C99标准钟,还提供另外一种解决方法:使用内联函数。

在程序编译时,编译器将程序中出现的内联函数的调用表达式用内联函数的函数体来进行替代。显然,这种做法不会产生转去转回得问题。都是由于在编译时将函数体中的代码被替代到程序中,因此会增加目标代码量,进而增加空间的开销,而在时间开销上不像函数调用时那么大,可见它是以增加目标代码为代码来换取时间的节省。
定义内联函数的方法很简单,只要在定义函数头的前面加上关键字inline即可。内联函数的定义与一般函数一样。例如,定于一个两个整数相加的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<stdio.h>
#include<stdlib.h>

inline int add(int x,int y);
inline int add(int x,int y)
{
return x+y;
}

int main()
{
int i,j,k;
printf("请输入两个整数的值:\n");
scanf("%d %d",&i,&j);
k=add(i,j);
printf("k=%d\n",k);
return 0;
}

在程序中,调用函数add时,该函数在编译时会将以上代码复制过来,而不是像一般函数那样是运行时被调用。

内联函数具有一般函数的特性,它与一般函数所不同之处在于函数调用的处理。一般函数进行调用时,要讲程序执行权转导被调函数中,然后再返回到调用到它的函数中;而内联函数在调用时,是将调用表达式用内联函数体来替换。在使用内联函数时,应该注意如下几点:

  • 在内联函数内部允许用循环语句和开关语句。但是,程序在经过编译之后,这个函数是不会作为内联函数进行调用的。
    内联函数的定义必须出现在内联函数第一次被调用之前。
  • 其实,在程序中声明一个函数为内联时,编译以后这个函数不一定是内联的,

即程序只是建议编译器使用内联函数,但是编译器会根据函数情况决定是否使用内联,所以如果编写的内联函数中出现循环或者开关语句,程序也不会提示出错,但那个函数已经不是内联函数了。

一般都是将一个小型函数作为内联函数。

函数指针

定义

顾名思义,函数指针就是函数的指针。它是一个指针,指向一个函数。看例子:

1
2
3
A) char * (*fun1)(char * p1,char * p2);
B) char * *fun2(char * p1,char * p2);
C) char * fun3(char * p1,char * p2);

看看上面三个表达式分别是什么意思?

  • C)这很容易,fun3是函数名,p1,p2是参数,其类型为char 型,函数的返回值为char 类型。
  • B) 也很简单,与C)表达式相比,唯一不同的就是函数的返回值类型为char**,是个二级指针。
  • A) fun1是函数名吗?回忆一下前面讲解数组指针时的情形。我们说数组指针这么定义或许更清晰:
1
int (*)[10] p;

再看看A)表达式与这里何其相似!明白了吧。这里fun1不是什么函数名,而是一个指针变量,它指向一个函数。这个函数有两个指针类型的参数,函数的返回值也是一个指针。同样,我们把这个表达式改写一下:

1
char * (*)(char * p1,char * p2) fun1;

这样子是不是好看一些呢?只可惜编译器不这么想。^_^。

使用的例子

上面我们定义了一个函数指针,但如何来使用它呢?先看如下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <string.h>
char * fun(char * p1,char * p2)
{
  int i = 0;
  i = strcmp(p1,p2);
  if (0 == i)
  {
    return p1;
  }
  else
  {
    return p2;
  }
}
int main()
{
  char * (*pf)(char * p1,char * p2);
  pf = &fun;
  (*pf) ("aa","bb");
  return 0;
}

我们使用指针的时候,需要通过钥匙(“*”)来取其指向的内存里面的值,函数指针使用也如此。通过用(*pf)取出存在这个地址上的函数,然后调用它。

这里需要注意到是,在Visual C++6.0里,给函数指针赋值时,可以用&fun或直接用函数名fun。这是因为函数名被编译之后其实就是一个地址,所以这里两种用法没有本质的差别。这个例子很简单,就不再详细讨论了。

复杂的例子

也许上面的例子过于简单,我们看看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
void Function()
{
  printf("Call Function!\n");
}
int main()
{
  void (*p)();
  *(int*)&p=(int)Function;
  (*p)();
  return 0;
} 

这是在干什么?*(int*)&p=(int)Function;表示什么意思?
别急,先看这行代码:

1
void (*p)();

这行代码定义了一个指针变量p,p指向一个函数,这个函数的参数和返回值都是void。&p是求指针变量p本身的地址,这是一个32位的二进制常数(32位系统)。

(int*)&p表示将地址强制转换成指向int类型数据的指针。(int)Function表示将函数的入口地址强制转换成int类型的数据。分析到这里,相信你已经明白*(int*)&p=(int)Function;表示将函数的入口地址赋值给指针变量p。

那么(*p) ();就是表示对函数的调用。

讲解到这里,相信你已经明白了。其实函数指针与普通指针没什么差别,只是指向的内容不同而已。
使用函数指针的好处在于,可以将实现同一功能的多个模块统一起来标识,这样一来更容易后期的维护,系统结构更加清晰。或者归纳为:便于分层设计、利于系统抽象、降低耦合度以及使接口与实现分开。

另一个复杂的例子

是不是感觉上面的例子太简单,不够刺激?好,那就来点刺激的,看下面这个例子:

1
(*(void(*) ())0)();

这是《C Traps and Pitfalls》这本经典的书中的一个例子。没有发狂吧?下面我们就来分析分析:

  • 第一步:void(*) (),可以明白这是一个函数指针类型。这个函数没有参数,没有返回值。
  • 第二步:(void(*) ())0,这是将0强制转换为函数指针类型,0是一个地址,也就是说一个函数存在首地址为0的一段区域内。
  • 第三步:(*(void(*) ())0),这是取0地址开始的一段内存里面的内容,其内容就是保存在首地址为0的一段区域内的函数。
  • 第四步:(*(void(*) ())0)(),这是函数调用。

好像还是很简单是吧,上面的例子再改写改写:

1
(*(char**(*) (char **,char **))0) ( char **,char **);

如果没有上面的分析,肯怕不容易把这个表达式看明白吧。不过现在应该是很简单的一件事了。读者以为呢?

函数指针数组

现在我们清楚表达式

1
char * (*pf)(char * p);

定义的是一个函数指针pf。既然pf是一个指针,那就可以储存在一个数组里。把上式修改一下:

1
char * (*pf[3])(char * p);

这是定义一个函数指针数组。

它是一个数组,数组名为pf,数组内存储了3个指向函数的指针。这些指针指向一些返回值类型为指向字符的指针、参数为一个指向字符的指针的函数。

这念起来似乎有点拗口。不过不要紧,关键是你明白这是一个指针数组,是数组。函数指针数组怎么使用呢?这里也给出一个非常简单的例子,只要真正掌握了使用方法,再复杂的问题都可以应对。

如下:

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
#include <stdio.h>
#include <string.h>
char * fun1(char * p)
{
  printf("%s\n",p);
  return p;
}
char * fun2(char * p)
{
  printf("%s\n",p);
  return p;
}
char * fun3(char * p)
{
  printf("%s\n",p);
  return p;
}
<br>int main()
{
  char * (*pf[3])(char * p);
  pf[0] = fun1; //可以直接用函数名
  pf[1] = &fun2; //可以用函数名加上取地址符
  pf[2] = &fun3;<br>
  pf[0]("fun1");
  pf[0]("fun2");
  pf[0]("fun3");
  return 0;
} 

函数指针数组的指针

看着这个标题没发狂吧?函数指针就够一般初学者折腾了,函数指针数组就更加麻烦,现在的函数指针数组指针就更难理解了。
其实,没这么复杂。前面详细讨论过数组指针的问题,这里的函数指针数组指针不就是一个指针嘛。只不过这个指针指向一个数组,这个数组里面存的都是指向函数的指针。仅此而已。

下面就定义一个简单的函数指针数组指针:

1
char * (*(*pf)[3])(char * p);

注意,这里的pf和上一节的pf就完全是两码事了。上一节的pf并非指针,而是一个数组名;这里的pf确实是实实在在的指针。这个指针指向一个包含了3个元素的数组;这个数字里面存的是指向函数的指针;这些指针指向一些返回值类型为指向字符的指针、参数为一个指向字符的指针的函数。

这比上一节的函数指针数组更拗口。其实你不用管这么多,明白这是一个指针就ok了。其用法与前面讲的数组指针没有差别。下面列一个简单的例子:

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
#include <stdio.h>
#include <string.h>

char * fun1(char * p)
{
printf("%s\n",p);
return p;
}

char * fun2(char * p)
{
printf("%s\n",p);
return p;
}

char * fun3(char * p)
{
printf("%s\n",p);
return p;
}

int main()
{
char * (*a[3])(char * p);
char * (*(*pf)[3])(char * p);
pf = &a;

a[0] = fun1;
a[1] = &fun2;
a[2] = &fun3;

pf[0][0]("fun1");
pf[0][1]("fun2");
pf[0][2]("fun3");
return 0;
}

内存泄漏问题原理

堆内存在C代码中的存储方式

内存泄漏问题只有在使用堆内存的时候才会出现,栈内存不存在内存泄漏问题,因为栈内存会自动分配和释放。C代码中堆内存的申请函数是malloc,常见的内存申请代码如下:

1
2
3
4
5
6
7
8
char *info = NULL;    /**转换后的字符串**/

info = (char*)malloc(NB_MEM_SPD_INFO_MAX_SIZE);
if ( NULL == info)
{
(void)tdm_error("malloc error!\n");
return NB_SA_ERR_HPI_OUT_OF_MEMORY;
}

由于malloc函数返回的实际上是一个内存地址,所以保存堆内存的变量一定是一个指针(除非代码编写极其不规范)。再重复一遍,保存堆内存的变量一定是一个指针,这对本文主旨的理解很重要。当然,这个指针可以是单指针,也可以是多重指针。

malloc函数有很多变种或封装,如g_malloc、g_malloc0、VOS_Malloc等,这些函数最终都会调用malloc函数。

堆内存的获取方法

看到本小节标题,可能有些同学有疑惑,上一小节中的malloc函数,不就是堆内存的获取方法吗?的确是,通过malloc函数申请是最直接的获取方法,如果只知道这种堆内存获取方法,就容易掉到坑里了。一般的来讲,堆内存有如下两种获取方法:

方法一:将函数返回值直接赋给指针,一般表现形式如下:

1
2
char *local_pointer_xx = NULL;
local_pointer_xx = (char*)function_xx(para_xx, …);

该类涉及到内存申请的函数,返回值一般都指针类型,例如:

1
GSList* g_slist_append (GSList   *list, gpointer  data)

方法二:将指针地址作为函数返回参数,通过返回参数保存堆内存地址,一般表现形式如下:

1
2
3
int ret;
char *local_pointer_xx = NULL; /**转换后的字符串**/
ret = (char*)function_xx(..., &local_pointer_xx, ...);

该类涉及到内存申请的函数,一般都有一个入参是双重指针,例如:

1
2
__STDIO_INLINE _IO_ssize_t
getline (char **__lineptr, size_t *__n, FILE *__stream)

前面说通过malloc申请内存,就属于方法一的一个具体表现形式。其实这两类方法的本质是一样的,都是函数内部间接申请了内存,只是传递内存的方法不一样,方法一通过返回值传递内存指针,方法二通过参数传递内存指针。

内存泄漏三要素

最常见的内存泄漏问题,包含以下三个要素:

  • 要素一:函数内有局部指针变量定义;
  • 要素二:对该局部指针有通过上一小节中“两种堆内存获取方法”之一获取内存;
  • 要素三:在函数返回前(含正常分支和异常分支)未释放该内存,也未保存到其它全局变量或返回给上一级函数。

内存释放误区

稍微使用过C语言编写代码的人,都应该知道堆内存申请之后是需要释放的。但为何还这么容易出现内存泄漏问题呢?一方面,是开发人员经验不足、意识不到位或一时疏忽导致;另一方面,是内存释放误区导致。很多开发人员,认为要释放的内存应该局限于以下两种:

  1. 直接使用内存申请函数申请出来的内存,如malloc、g_malloc等;
  2. 该开发人员熟悉的接口中,存在内存申请的情况,如iBMC的兄弟,都应该知道调用如下接口需要释放list指向的内存:
1
dfl_get_object_list(const char* class_name, GSList **list)

按照以上思维编写代码,一旦遇到不熟悉的接口中需要释放内存的问题,就完全没有释放内存的意识,内存泄漏问题就自然产生了。

内存泄漏问题检视方法

检视内存泄漏问题,关键还是要养成良好的编码检视习惯。与内存泄漏三要素对应,需

要做到如下三点:

  1. 在函数中看到有局部指针,就要警惕内存泄漏问题,养成进一步排查的习惯
  2. 分析对局部指针的赋值操作,是否属于前面所说的“两种堆内存获取方法”之一,如果是,就要分析函数返回的指针到底指向啥?是全局数据、静态数据还是堆内存?对于不熟悉的接口,要找到对应的接口文档或源代码分析;又或者看看代码中其它地方对该接口的引用,是否进行了内存释放;
  3. 如果确认对局部指针存在内存申请操作,就需要分析该内存的去向,是会被保存在全局变量吗?又或者会被作为函数返回值吗?如果都不是,就需要排查函数所有有”return“的地方,保证内存被正确释放。

概念

  1. 实体(entity):就是实际应用中要用数据描述的事物,一般是名词。
  2. 字段(fields):就是一项数据,也就是我们平常所说的“列”。
  3. 记录(record):一个实体的一个实例所特有的相关数据项的集合,也就是我们平常所说的“行”。
  4. 键(key):可唯一标识一条记录的一个字段或字段集,有时翻译为“码”。
  5. 主键(primary key):用于唯一标识一个表中的一条记录的键。每个主键应该具有下列特征:

    • 唯一的。
    • 最小 的(尽量选择最少键的组合)。
    • 非空。
    • 不可更新的(不能随时更改)
  6. 外键(foreign keys):对连接父表和子表的相关记录的主键字段的复制。

  7. 依赖表(dependent table):也称为弱实体(weak entity)是需要用父表标识的子表。
  8. 关联表(associative table):是多对多关系中两个父表的子表。
  9. 实体完整性:每个表必须有一个有效的主 键。
  10. 参照完整性:没有不相匹配的外键值。

名词解释

函数依赖:
通俗描述:描述一个学生的关系,可以有学号(SNO),姓名(SNAME),系名(SDEPT)等几个属性。由于一个学号只对应一个学生,一个学生只在一个系学习。因此当学号确定之后,姓名和该学生所在系的值也就唯一被确定了,就像自变量x确定之后,相应的函数值f(x)也就唯一地被确定了一样,称SNO函数决定SNAME和SDEPT,或者说SNAME,SDEPT函数依赖于SNO,记为:SNO -> SNAME, SNO -> SDEPT.

严格定义:设R(U)是属性集U上的关系模式。X,Y是U的子集。若对于R(U)的任意一个可能的关系r,r中不可能存在两个元组在X上的属性值相等,而在Y上的属性值不相等,则称X函数确定Y或者Y函数依赖于X。记为X->Y。

(如果不知道“关系”、“属性集”等定义,自己看大学教材去。这里的定义摘自萨师煊&王珊《数据库系统概论》第三版)

完全函数依赖:
在R(U)中,如果Y函数依赖于X,并且对于X的任何一个真子集X’,都有Y不函数依赖于X’, 则称Y对X完全函数依赖。否则称Y对X部分函数依赖。

举个例子就明白了。假设一个学生有几个属性

SNO 学号
SNAME 姓名
SDEPT 系
SAGE 年龄
CNO 班级号
G 成绩

对于(SNO,SNAME,SDEPT,SAGE,CNO,G)来说,G完全依赖于(SNO, CNO), 因为(SNO,CNO)可以决定G,而SNO和CNO都不能单独决定G。

而SAGE部分函数依赖于(SNO,CNO),因为(SNO,CNO)可以决定SAGE,而单独的SNO也可以决定SAGE。

传递函数依赖:
在R(U)中,如果X->Y, Y->Z, 则称Z对X传递函数依赖。

候选键:
(又称候选码,候选关键字,码 ,candidate key):

设K是一个R(U)中的属性或属性集合(注意可以是属性集合,也即多个属性的组合),若K完全函数确定U,则K为R的候选键(Candidate key);

通俗地说就是,能够确定全部属性的某个属性或某组属性,称为候选键。若候选键多于一个,则选定其中一个作为主键。

主属性:
包含在任何一个候选键中的属性,叫做主属性(Prime attribute),不包含在任何候选键中的属性称为非主属性或非键属性或非关键字段。

例子:
在(SNO, CNO, G)中,SNO和CNO这俩合起来就是一个候选键,因为每个元组只要确定了SNO和CNO,则其它所有属性都可以根据SNO和CNO来确定。而SNO和CNO就都是“主属性”,G是“非主属性”。由于此例中只有一个候选键,于是只能选择(SNO, CNO)作为主键。

在(SNO,SDEPT, SNAME)中,SNO是一个候选键,因为只要SNO确定了,其它所有属性也都确定了,如果保证没有重名的话,则SNAME也是一个候选键,于是可以选SNO或者SNAME之一作为候选键。如果不能保证没有重名,就不能把SNAME当成候选键,于是就只有SNO能够做主键。

范式:
第一范式:
指数据库表的每一列都是不可分割的基本数据项
在任何一个关系数据库中,第一范式(1NF)是对关系模式的基本要求,不满足第一范式(1NF)的数据库就不是关系数据库。

第二范式:
数据库表中不存在非关键字段对任一候选键的部分函数依赖,也即所有非关键字 段都完全依赖于任意一组候选关键字。

2NF的违例只会出现在候选键由超过一个字段构成的表中,因为对单关键字字段不存在部分依赖问题。

例子:(学号, 姓名, 年龄, 课程名称, 成绩, 学分)

候选键只有一个,就是(姓名,课程名称),则主键就是(姓名,课程名称)

存在如下决定关系:

1:(学号, 课程名称) → (姓名, 年龄, 成绩, 学分)
2:(课程名称) → (学分)
3:(学号) → (姓名, 年龄)

其中,姓名、年龄、学分是部分依赖于主键的,而成绩是完全依赖于主键的,存在部分依赖关系,所以不满足第二范式。

这会造成如下问题

  1. 数据冗余:
    同一门课程由n个学生选修,”学分”就重复n-1次;同一个学生选修了m门课程,姓名和年龄就重复了m-1次。
  2. 更新异常:
    若调整了某门课程的学分,数据表中所有行的”学分”值都要更新,否则会出现同一门课程学分不同的情况。
  3. 插入异常:
    假设要开设一门新的课程,暂时还没有人选修。这样,由于还没有”学号”关键字,课程名称和学分也无法记录入数据 库。
  4. 删除异常:
    假设一批学生已经完成课程的选修,这些选修记录就应该从数据库表中删除。但是,与此同 时,课程名称和学分信息也被删除了。很显然,这也会导致插入异常。

问题就在于存在非主属性对主键的部分依赖

解决办法:把原表(学号, 姓名, 年龄, 课程名称, 成绩, 学分)分成三个表:

学生:Student(学号, 姓名, 年龄);

课程:Course(课程名称, 学分);

选课关 系:SelectCourse(学号, 课程名称, 成绩)。

第三范式:
在第二范式的基础上,数据表中如果不存在非关键字段对任一候选关键字段的传递函数依赖则符合第三范式

出现传递依赖A->B->C,即主键A可以确定出某一非关键字段B,而B又可以确定出C,这意味着C依赖于一个非关键字段B。因此第三范式又可描述为:表中不存在可以确定其他非关键字的非键字段

例子:表:(学号, 姓名, 年龄, 所在学院, 学院地点, 学院电话)

该表中候选字段只有“学号”,于是“学号”做主键。由于主键是单一属性,所以不存在非主属性对主键的部分函数依赖的问题,所以必然满足第二范式。但是存在如下传递依赖

(学号) → (所在学院) → (学院地点, 学院电话)

学院地点和学院电话传递依赖于学号,而学院地点和学院电话都是非关键字段,即表中出现了“某一非关键字段可以确定出其它非关键字段”的情况,于是违反了第三范式。

解决办法:

把原表分成两个表:

学生:(学号, 姓名, 年龄, 所在学院);

学院:(学院, 地点, 电话)。

BCNF:
BCNF意味着在关系模式中每一个决定因素都包含候选键,也就是说,只要属性或属性组A能够决定任何一个属性B,则A的子集中必须有候选键。BCNF范式排除了任何属性(不光是非主属性,2NF和3NF所限制的都是非主属性)对候选键的传递依赖与部分依赖。

例子:

例子二:

假设仓库管理关系表为StorehouseManage(仓库ID, 存储物品ID, 管理员ID, 数量),且有一个管理员只在一个仓库工作;一个仓库可以存储多种物品。这个数据库表中存在如下决定关系:

(仓库ID, 存储物品ID) →(管理员ID, 数量)

(管理员ID, 存储物品ID) → (仓库ID, 数量)

所以,(仓库ID, 存储物品ID)和(管理员ID, 存储物品ID)都是StorehouseManage的候选关键字,表中的唯一非关键字段为数量,它是符合第三范式的。但是,由于存在如下决定关系:

(仓库ID) → (管理员ID)

(管理员ID) → (仓库ID)

仓库I是决定因素,但仓库ID不包含候选键(candidate key,也就是候选码,简称码)。

同样的,管理员ID也是决定因素,但不包含候选键。

所以该表不满足BCNF。

3NF和BCNF是在函数依赖的条件下对模式分解所能达到的最大程度。一个模式中的关系模式如果都属于BCNF,那么在函数依赖范围内,它已经实现了彻底的分离,已消除了插入和删除的异常。3NF的“不彻底”性表现在可能存在主属性对键的部分依赖和传递依赖。

系统调用

一般情况下进程不能访问内核所占内存空间也不能调用内核函数。为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。系统调用就是用户空间应用程序和内核提供的服务之间的一个接口。

系统调用在用户空间进程和硬件设备之间添加了一个中间层,其为用户空间提供了一种统一的硬件的抽象接口,保证了系统的稳定和安全,使用户程序具有可移植性。例如fork()read()write()等用户程序可以使用的函数都是系统调用。

用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。那么应用程序应该以何种方式通知系统,系统如何切换到内核态?

其实这种改变是通过软中断来实现。首先,用户程序为系统调用设置参数。其中一个参数是系统调用编号。参数设置完成后,程序执行“系统调用”指令。x86系统上的软中断由int产生。这个指令会导致一个异常:产生一个事件,这个事件会致使处理器切换到内核态并执行0x80号异常处理程序。此时的异常处理程序实际上就是系统调用处理程序,该处理程序的名字为system_call,它与硬件体系结构紧密相关。对于x86-32系统来说,该处理程序位于arch/x86/kernel/entry_32.S`文件中,代码为:

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
......
# system call handler stub
ENTRY(system_call)
RING0_INT_FRAME # can't unwind into user space anyway
pushl %eax # save orig_eax
CFI_ADJUST_CFA_OFFSET 4
SAVE_ALL
GET_THREAD_INFO(%ebp)
# system call tracing in operation / emulation
testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax
jae syscall_badsys
syscall_call:
call *sys_call_table(,%eax,4) //此处执行相应的系统调用
movl %eax,PT_EAX(%esp) # store the return value
syscall_exit:
LOCKDEP_SYS_EXIT
DISABLE_INTERRUPTS(CLBR_ANY) # make sure we don't miss an interrupt
# setting need_resched or sigpending
# between sampling and the iret
TRACE_IRQS_OFF
movl TI_flags(%ebp), %ecx
testl $_TIF_ALLWORK_MASK, %ecx # current->work
jne syscall_exit_work
......

在Linux中,每个系统调用被赋予一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用。进程不会提及系统调用的名称。系统调用号定义文件以及形式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat ./arch/x86/include/asm/unistd.h
#ifdef __KERNEL__
# ifdef CONFIG_X86_32
# include "unistd_32.h"
# else
# include "unistd_64.h"
# endif
#else
# ifdef __i386__
# include "unistd_32.h"
# else
# include "unistd_64.h"
# endif
#endif

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
# cat arch/x86/include/asm/unistd_32.h
#ifndef _ASM_X86_UNISTD_32_H
#define _ASM_X86_UNISTD_32_H

/*
* This file contains the system call numbers.
*/

#define __NR_restart_syscall 0
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
#define __NR_waitpid 7
#define __NR_creat 8
#define __NR_link 9
#define __NR_unlink 10
#define __NR_execve 11
#define __NR_chdir 12
#define __NR_time 13
#define __NR_mknod 14
#define __NR_chmod 15
#define __NR_lchown 16
#define __NR_break 17
#define __NR_oldstat 18
#define __NR_lseek 19
#define __NR_getpid 20
#define __NR_mount 21
......

系统调用号相当关键,一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。Linux有一个“未实现”系统调用sys_ni_syscall(),它除了返回一ENOSYS外不做任何其他工作,这个错误号就是专门针对无效的系统调用而设的。

因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上,系统调用号是通过eax寄存器传递给内核的。在陷人内核之前,用户空间就把相应系统调用所对应的号放入eax中了。这样系统调用处理程序一旦运行,就可以从eax中得到数据。其他体系结构上的实现也都类似。

内核记录了系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table中。它与体系结构有关,32位x86一般定义在arch/x86/kernel/syscall_table_32.s文件中。这个表中为每一个有效的系统调用指定了惟一的系统调用号。sys_call_table是一张由指向实现各种系统调用的内核函数的函数指针组成的表。syscall_table_32.s文件如下:

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
ENTRY(sys_call_table)
.long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */
.long sys_exit
.long ptregs_fork
.long sys_read
.long sys_write
.long sys_open /* 5 */
.long sys_close
.long sys_waitpid
.long sys_creat
.long sys_link
.long sys_unlink /* 10 */
.long ptregs_execve
......
.long sys_timerfd_settime /* 325 */
.long sys_timerfd_gettime
.long sys_signalfd4
.long sys_eventfd2
.long sys_epoll_create1
.long sys_dup3 /* 330 */
.long sys_pipe2
.long sys_inotify_init1
.long sys_preadv
.long sys_pwritev
.long sys_rt_tgsigqueueinfo /* 335 */
.long sys_perf_event_open

system_call()函数通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或者等于NR syscalls,该函数就返回一ENOSYS。否则,就执行相应的系统调用。

1
call *sys_call_table(,%eax, 4)

由于系统调用表中的表项是以32位(4字节)类型存放的,所以内核需要将给定的系统调用号乘以4,然后用所得的结果在该表中查询其位置。

除了系统调用号以外,大部分系统调用都还需要一些外部的参数输入。所以,在发生异常的时候,应该把这些参数从用户空间传给内核。最简单的办法就是像传递系统调用号一样把这些参数也存放在寄存器里。在x86系统上,ebxecxedxesiedi按照顺序存放前五个参数。需要六个或六个以上参数的情况不多见,此时,应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针。给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。

下面我们看看用中断的方式如何完成系统调用功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(int argc, const char *argv[])
{
pid_t pid;
asm volatile (
"mov $0, %%ebx\n\t"
"mov $20, %%eax\n\t" //把系统调用号20放入`eax`寄存器中,20对应于`SYS_getpid()系统调用
"int $0x80\n\t" //0x80中断
"mov %%eax, %0\n\t" //将执行结果存放在`pid`变量中
:"=m"(pid)
);
printf("int PID: %d\n", pid);
printf("api PID: %d\n", getpid());
return 0;
}

此处没有传递参数,因为getpid不需要参数。本实例执行结果为:
1
2
3
$ ./target_bin
int PID: 4911
api PID: 4911

一般情况下,应用程序通过在用户空间实现的应用编程接口(API)而不是系统调用来编程。API是一个函数定义,说明了如何获得一个给定的服务,比如read()malloc()free()abs()等。它有可能和系统调用形式上一致,比如read()接口就和read系统调用对应,但这种对应并非一一对应,往往会出现几种不同的API内部用到统一个系统调用,比如malloc()free()内部利用brk()系统调用来扩大或缩小进程的堆;或一个API利用了好几个系统调用组合完成服务。更有些API甚至不需要任何系统调用——因为它不必需要内核服务,如计算整数绝对值的abs()接口。

Linux的用户编程接口遵循了在Unix世界中最流行的应用编程界面标准——POSIX标准,这套标准定义了一系列API。在Linux中(Unix也如此)这些API主要是通过C库(libc)实现的,它除了定义的一些标准的C函数外,一个很重要的任务就是提供了一套封装例程将系统调用在用户空间包装后供用户编程使用。不过封装并非必须的,如果你愿意直接调用,内核也提供了一个syscall()函数来实现调用。如下示例为使用C库调用和直接调用分别来获取当前进程ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/syscall.h>
int main(int argc, const char *argv[])
{
pid_t pid, pidt;
pid = getpid();
pidt = syscall(SYS_getpid);
printf("getpid: %d\n", pid);
printf("SYS_getpid: %d\n", pidt);
return 0;
}

系统调用在内核有一个实现函数,以getpid为例,其在内核实现为:
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* sys_getpid - return the thread group id of the current process
*
* Note, despite the name, this returns the tgid not the pid. The tgid and
* the pid are identical unless CLONE_THREAD was specified on clone() in
* which case the tgid is the same in all threads of the same group.
*
* This is SMP safe as current->tgid does not change.
*/
SYSCALL_DEFINE0(getpid)
{
return task_tgid_vnr(current);
}

其中SYSCALL_DEFINE0为一个宏,它定义一个无参数(尾部数字代表参数个数)的系统调用,展开后代码如下:
1
2
3
4
asmlinkage long sys_getpid(void)
{
return current->tpid;
}

其中asmlinkage是一个编译指令,通知编译器仅从栈中提取该函数参数,所有系统调用都需要这个限定词。系统调用getpid()在内核中被定义成sys_getpid(),这是Linux所有系统调用都应该遵守的命名规则。

Linux中实现系统调用利用了0x86体系结构中的软件中断,也就是调用int $0x80汇编指令,这条汇编指令将产生向量为128的编程异常,此时处理器切换到内核态并执行0x80号异常处理程序。此时的异常处理程序实际上就是系统调用处理程序,该处理程序的名字为system_call(),对于x86-32系统来说,该处理程序位于arch/x86/kernel/entry_32.S文件中,使用汇编语言编写。那么所有的系统调用都会转到这里。在执行int 0x80前,系统调用号被装入eax寄存器(相应参数也会传递到其它寄存器中),这个系统调用号被用来指明到底是要执行哪个系统调用,这样系统调用处理程序一旦运行,就从eax中得到系统调用号,然后根据系统调用号在系统调用表中寻找相应服务例程(例如sys_getpid()函数)。当服务例程结束时,system_call()eax获得系统调用的返回值,并把这个返回值存放在曾保存用户态eax寄存器栈单元的那个位置上,最后该函数再负责切换到用户空间,使用户进程继续执行。

硬中断及中断处理

操作系统负责管理硬件设备,为了使系统和硬件设备的协同工作不降低机器性能,系统和硬件的通信使用中断的机制,也就是让硬件在需要的时候向内核发出信号,这样使得内核不用去轮询设备而导致做很多无用功。

中断使得硬件可以发出通知给处理器,硬件设备生成中断的时候并不考虑与处理器的时钟同步,中断可以随时产生。也就是说,内核随时可能因为新到来的中断而被打断。当接收到一个中断后,中断控制器会给处理器发送一个电信号,处理器检测到该信号便中断自己当前工作而处理中断。

在响应一个中断时,内核会执行一个函数,该函数叫做中断处理程序或中断服务例程(ISR)。中断处理程序运行与中断上下文,中断上下文中执行的代码不可阻塞,应该快速执行,这样才能保证尽快恢复被中断的代码的执行。中断处理程序是管理硬件驱动的驱动程序的组成部分,如果设备使用中断,那么相应的驱动程序就注册一个中断处理程序。

在驱动程序中,通常使用request_irq()来注册中断处理程序。该函数在文件<include/linux/interrupt.h>中声明:

1
2
3
extern int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
const char *name, void *dev);

第一个参数为要分配的中断号;第二个参数为指向中断处理程序的指针;第三个参数为中断处理标志。该函数实现如下:
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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
static inline int __must_check
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
const char *name, void *dev)
{
return request_threaded_irq(irq, handler, NULL, flags, name, dev);
}

int request_threaded_irq(unsigned int irq, irq_handler_t handler,
irq_handler_t thread_fn, unsigned long irqflags,
const char *devname, void *dev_id)
{
struct irqaction *action;
struct irq_desc *desc;
int retval;
/*
* handle_IRQ_event() always ignores IRQF_DISABLED except for
* the _first_ irqaction (sigh). That can cause oopsing, but
* the behavior is classified as "will not fix" so we need to
* start nudging drivers away from using that idiom.
*/
if ((irqflags & (IRQF_SHARED|IRQF_DISABLED)) == (IRQF_SHARED|IRQF_DISABLED)) {
pr_warning("IRQ %d/%s: IRQF_DISABLED is not guaranteed on shared IRQs\n",
irq, devname);
}
#ifdef CONFIG_LOCKDEP
/*
* Lockdep wants atomic interrupt handlers:
*/
irqflags |= IRQF_DISABLED;
#endif
/*
* Sanity-check: shared interrupts must pass in a real dev-ID,
* otherwise we'll have trouble later trying to figure out
* which interrupt is which (messes up the interrupt freeing
* logic etc).
*/
if ((irqflags & IRQF_SHARED) && !dev_id)
return -EINVAL;
desc = irq_to_desc(irq);
if (!desc)
return -EINVAL;
if (desc->status & IRQ_NOREQUEST)
return -EINVAL;
if (!handler) {
if (!thread_fn)
return -EINVAL;
handler = irq_default_primary_handler;
}
//分配一个irqaction
action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
if (!action)
return -ENOMEM;
action->handler = handler;
action->thread_fn = thread_fn;
action->flags = irqflags;
action->name = devname;
action->dev_id = dev_id;
chip_bus_lock(irq, desc);

//将创建并初始化完在的action加入desc
retval = __setup_irq(irq, desc, action);
chip_bus_sync_unlock(irq, desc);
if (retval)
kfree(action);
#ifdef CONFIG_DEBUG_SHIRQ
if (irqflags & IRQF_SHARED) {
/*
* It's a shared IRQ -- the driver ought to be prepared for it
* to happen immediately, so let's make sure....
* We disable the irq to make sure that a 'real' IRQ doesn't
* run in parallel with our fake.
*/
unsigned long flags;
disable_irq(irq);
local_irq_save(flags);
handler(irq, dev_id);
local_irq_restore(flags);
enable_irq(irq);
}
#endif
return retval;
}

下面看一下中断处理程序的实例,以rtc驱动程序为例,代码位于<drivers/char/rtc.c>中。当RTC驱动装载时,rtc_init()函数会被调用来初始化驱动程序,包括注册中断处理函数:
1
2
3
4
5
6
7
8
9
10
/*
* XXX Interrupt pin #7 in Espresso is shared between RTC and
* PCI Slot 2 INTA# (and some INTx# in Slot 1).
*/
if (request_irq(rtc_irq, rtc_interrupt, IRQF_SHARED, "rtc",
(void *)&rtc_port)) {
rtc_has_irq = 0;
printk(KERN_ERR "rtc: cannot register IRQ %d\n", rtc_irq);
return -EIO;
}

处理程序函数rtc_interrupt()
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
/*
* A very tiny interrupt handler. It runs with IRQF_DISABLED set,
* but there is possibility of conflicting with the set_rtc_mmss()
* call (the rtc irq and the timer irq can easily run at the same
* time in two different CPUs). So we need to serialize
* accesses to the chip with the rtc_lock spinlock that each
* architecture should implement in the timer code.
* (See ./arch/XXXX/kernel/time.c for the set_rtc_mmss() function.)
*/
static irqreturn_t rtc_interrupt(int irq, void *dev_id)
{
/*
* Can be an alarm interrupt, update complete interrupt,
* or a periodic interrupt. We store the status in the
* low byte and the number of interrupts received since
* the last read in the remainder of rtc_irq_data.
*/
spin_lock(&rtc_lock); //保证`rtc_irq_data`不被`SMP`机器上其他处理器同时访问
rtc_irq_data += 0x100;
rtc_irq_data &= ~0xff;
if (is_hpet_enabled()) {
/*
* In this case it is HPET RTC interrupt handler
* calling us, with the interrupt information
* passed as arg1, instead of irq.
*/
rtc_irq_data |= (unsigned long)irq & 0xF0;
} else {
rtc_irq_data |= (CMOS_READ(RTC_INTR_FLAGS) & 0xF0);
}
if (rtc_status & RTC_TIMER_ON)
mod_timer(&rtc_irq_timer, jiffies + HZ/rtc_freq + 2*HZ/100);
spin_unlock(&rtc_lock);
/* Now do the rest of the actions */
spin_lock(&rtc_task_lock); //避免`rtc_callback`出现系统情况,RTC`驱动允许注册一个回调函数在每个`RTC`中断到来时执行。
if (rtc_callback)
rtc_callback->func(rtc_callback->private_data);
spin_unlock(&rtc_task_lock);
wake_up_interruptible(&rtc_wait);
kill_fasync(&rtc_async_queue, SIGIO, POLL_IN);
return IRQ_HANDLED;
}

在内核中,中断的旅程开始于预定义入口点,这类似于系统调用。对于每条中断线,处理器都会跳到对应的一个唯一的位置。这样,内核就可以知道所接收中断的IRQ号了。初始入口点只是在栈中保存这个号,并存放当前寄存器的值(这些值属于被中断的任务);然后,内核调用函数do_IRQ()。从这里开始,大多数中断处理代码是用C写的。do_IRQ()的声明如下:
1
unsigned int do_IRQ(struct pt_regs regs)

因为C的调用惯例是要把函数参数放在栈的顶部,因此pt_regs结构包含原始寄存器的值,这些值是以前在汇编入口例程中保存在栈上的。中断的值也会得以保存,所以,do_IRQ()可以将它提取出来,X86的代码为:
1
int irq = regs.orig_eax & 0xff

计算出中断号后,do_IRQ()对所接收的中断进行应答,禁止这条线上的中断传递。在普通的PC机器上,这些操作是由mask_and_ack_8259A()来完成的,该函数由do_IRQ()调用。接下来,do_IRQ()需要确保在这条中断线上有一个有效的处理程序,而且这个程序已经启动但是当前没有执行。如果这样的话,do_IRQ()就调用handle_IRQ_event()来运行为这条中断线所安装的中断处理程序,函数位于<kernel/irq/handle.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
59
60
61
62
63
64
65
66
67
68
69
70
/**  
* handle_IRQ_event - irq action chain handler
* @irq: the interrupt number
* @action: the interrupt action chain for this irq
*
* Handles the action chain of an irq event
*/
irqreturn_t handle_IRQ_event(unsigned int irq, struct irqaction *action)
{
irqreturn_t ret, retval = IRQ_NONE;
unsigned int status = 0;

//如果没有设置`IRQF_DISABLED,将CPU中断打开,应该尽量避免中断关闭情况,本地中断关闭情况下会导致中断丢失。
if (!(action->flags & IRQF_DISABLED))
local_irq_enable_in_hardirq();

do { //遍历运行中断处理程序
trace_irq_handler_entry(irq, action);
ret = action->handler(irq, action->dev_id);
trace_irq_handler_exit(irq, action, ret);

switch (ret) {
case IRQ_WAKE_THREAD:
/*
* Set result to handled so the spurious check
* does not trigger.
*/
ret = IRQ_HANDLED;

/*
* Catch drivers which return WAKE_THREAD but
* did not set up a thread function
*/
if (unlikely(!action->thread_fn)) {
warn_no_thread(irq, action);
break;
}

/*
* Wake up the handler thread for this
* action. In case the thread crashed and was
* killed we just pretend that we handled the
* interrupt. The hardirq handler above has
* disabled the device interrupt, so no irq
* storm is lurking.
*/
if (likely(!test_bit(IRQTF_DIED,
&action->thread_flags))) {
set_bit(IRQTF_RUNTHREAD, &action->thread_flags);
wake_up_process(action->thread);
}
/* Fall through to add to randomness */
case IRQ_HANDLED:
status |= action->flags;
break;

default:
break;
}

retval |= ret;
action = action->next;
} while (action);

if (status & IRQF_SAMPLE_RANDOM)
add_interrupt_randomness(irq);
local_irq_disable();//关中断

return retval;
}

前面说到中断应该尽快执行完,以保证被中断代码可以尽快的恢复执行。但事实上中断通常有很多工作要做,包括应答、重设硬件、数据拷贝、处理请求、发送请求等。为了求得平衡,内核把中断处理工作分成两半,中断处理程序是上半部——接收到中断就开始执行。能够稍后完成的工作推迟到下半部操作,下半部在合适的时机被开中段执行。例如网卡收到数据包时立即发出中断,内核执行网卡已注册的中断处理程序,此处工作就是通知硬件拷贝最新的网络数据包到内存,然后将控制权交换给系统之前被中断的任务,其他的如处理和操作数据包等任务被放到随后的下半部中去执行。下一节我们将了解中断处理的下半部。

下半部机制之软中断

中断处理程序以异步方式执行,其会打断其他重要代码,其运行时该中断同级的其他中断会被屏蔽,并且当前处理器上所有其他中断都有可能会被屏蔽掉,还有中断处理程序不能阻塞,所以中断处理需要尽快结束。由于中断处理程序的这些缺陷,导致了中断处理程序只是整个硬件中断处理流程的一部分,对于那些对时间要求不高的任务,留给中断处理流程的另外一部分,也就是本节要讲的中断处理流程的下半部。

那哪些工作由中断处理程序完成,哪些工作留给下半部来执行呢?其实上半部和下半部的工作划分不存在某种严格限制,这主要取决于驱动程序开发者自己的判断,一般最好能将中断处理程序执行时间缩短到最小。中断处理程序几乎都需要通过操作硬件对中断的到达进行确认,有时还会做对时间非常敏感的工作(如拷贝数据),其余的工作基本上留给下半部来处理,下半部就是执行与中断处理密切相关但中断处理程序本身不执行的工作。一般对时间非常敏感、和硬件相关、要保证不被其它中断(特别是相同的中断)打断的这些任务放在中断处理程序中执行,其他任务考虑放在下半部执行。

那下半部什么时候执行呢?下半部不需要指定明确执行时间,只要把任务推迟一点,让它们在系统不太忙且中断恢复后执行就可以了,而且执行期间可以相应所有中断。

上半部只能通过中断处理程序实现,而下半部可以有多种机制来实现,在2.6.32版本中,有三种不同形式的下半部实现机制:软中断、tasklet、工作队列。下面来看一下这三种下半部的实现。

软中断
start_kernerl()函数中,系统初始化软中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
asmlinkage void __init start_kernel(void)
{
char * command_line;
extern struct kernel_param __start___param[], __stop___param[];

smp_setup_processor_id();
......
softirq_init();//初始化软中断
......

/* Do the rest non-__init'ed, we're now alive */
rest_init();
}

softirq_init()中会注册两个常用类型的软中断,具体代码如下(位于kernel/softirq.c):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void __init softirq_init(void)
{
int cpu;

for_each_possible_cpu(cpu) {
int i;

per_cpu(tasklet_vec, cpu).tail =
&per_cpu(tasklet_vec, cpu).head;
per_cpu(tasklet_hi_vec, cpu).tail =
&per_cpu(tasklet_hi_vec, cpu).head;
for (i = 0; i < NR_SOFTIRQS; i++)
INIT_LIST_HEAD(&per_cpu(softirq_work_list[i], cpu));
}

register_hotcpu_notifier(&remote_softirq_cpu_notifier);

//此处注册两个软中断
open_softirq(TASKLET_SOFTIRQ, tasklet_action);
open_softirq(HI_SOFTIRQ, tasklet_hi_action);
}

注册函数open_softirq()参数含义:

  • nr:软中断类型
  • action:软中断处理函数
1
2
3
4
void open_softirq(int nr, void (*action)(struct softirq_action *))
{
softirq_vec[nr].action = action;
}

softirq_action结构表示软中断,定义在<include/linux/interrupt.h>

1
2
3
4
struct softirq_action
{
void (*action)(struct softirq_action *);
}

文件<kernel/softirq.c>中定义了32个该结构体的数组:

1
static struct softirq_action softirq_vec[NR_SOFTIRQS] __cacheline_aligned_in_smp;

每注册一个软中断都会占该数组一个位置,因此系统中最多有32个软中断。从上面的代码中,我们可以看到open_softirq()中。其实就是对softirq_vec数组的nr项赋值。softirq_vec是一个32元素的数组,实际上Linux内核只使用了几项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* PLEASE, avoid to allocate new softirqs, if you need not _really_ high
frequency threaded job scheduling. For almost all the purposes
tasklets are more than enough. F.e. all serial device BHs et
al. should be converted to tasklets, not to softirqs.
*/

enum
{
HI_SOFTIRQ=0,
TIMER_SOFTIRQ,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
BLOCK_SOFTIRQ,
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ,
SCHED_SOFTIRQ,
HRTIMER_SOFTIRQ,
RCU_SOFTIRQ, /* Preferable RCU should always be the last softirq */

NR_SOFTIRQS
};

那么软中断注册完成之后,什么时候触发软中断处理函数执行呢?通常情况下,软中断会在中断处理程序返回前标记它,使其在稍后合适的时候被执行。在下列地方,待处理的软中断会被检查和执行:

  1. 处理完一个硬件中断以后;
  2. ksoftirqd内核线程中;
  3. 在那些显示检查和执行待处理的软中断的代码中,如网络子系统中。

无论如何,软中断会在do_softirq()(位于<kernel/softirq.c>中)中执行,如果有待处理的软中断,do_softirq会循环遍历每一个,调用他们的软中断处理程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
asmlinkage void do_softirq(void) {     
__u32 pending;
unsigned long flags;
//如果在硬件中断环境中就退出,软中断不可以在硬件中断上下文或者是在软中断环境中使用,使用`in_interrupt()来防止软中断嵌套,和抢占硬中断环境。
if (in_interrupt())
return; //禁止本地中断
local_irq_save(flags);
pending = local_softirq_pending();
//如果有软中断要处理,则进入__do_softirq()
if (pending)
__do_softirq();
local_irq_restore(flags);
}

下面看一下__do_softirq()的实现:
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
asmlinkage void __do_softirq(void)
{
struct softirq_action *h;
__u32 pending;
int max_restart = MAX_SOFTIRQ_RESTART;
int cpu;

pending = local_softirq_pending(); //pending`用于保留待处理软中断32位位图
account_system_vtime(current);

__local_bh_disable((unsigned long)__builtin_return_address(0));
lockdep_softirq_enter();

cpu = smp_processor_id();
restart:
/* Reset the pending bitmask before enabling irqs */
set_softirq_pending(0);

local_irq_enable();

h = softirq_vec;

do {
if (pending & 1) { //如果`pending`第`n`位被设置为1,那么处理第`n`位对应类型的软中断
int prev_count = preempt_count();
kstat_incr_softirqs_this_cpu(h - softirq_vec);

trace_softirq_entry(h, softirq_vec);
h->action(h); //执行软中断处理函数
trace_softirq_exit(h, softirq_vec);
if (unlikely(prev_count != preempt_count())) {
printk(KERN_ERR "huh, entered softirq %td %s %p"
"with preempt_count %08x,"
" exited with %08x?\n", h - softirq_vec,
softirq_to_name[h - softirq_vec],
h->action, prev_count, preempt_count());
preempt_count() = prev_count;
}

rcu_bh_qs(cpu);
}
h++;
pending >>= 1; //pending`右移一位,循环检查其每一位
} while (pending); //直到`pending`变为0,pending`最多32位,所以循环最多执行32次。

local_irq_disable();

pending = local_softirq_pending();
if (pending && --max_restart)
goto restart;

if (pending)
wakeup_softirqd();

lockdep_softirq_exit();

account_system_vtime(current);
_local_bh_enable();
}

使用软中断必须要在编译期间静态注册,一般只有像网络这样对性能要求高的情况才使用软中断,文章前面我们也看到,系统中注册的软中断就那么几个。大部分时候,使用下半部另外一种机制tasklet的情况更多一些,tasklet可以动态的注册,可以被看作是一种性能和易用性之间寻求平衡的一种产物。事实上,大部分驱动程序都是用tasklet来实现他们的下半部。

下半部机制之tasklet

tasklet是利用软中断实现的一种下半部机制。tasklet相比于软中断,其接口更加简单方便,锁保护要求较低。tasklettasklet_struct结构体表示:

1
2
3
4
5
6
7
8
struct tasklet_struct
{
struct tasklet_struct *next; //链表中下一个tasklet
unsigned long state; //tasklet状态
atomic_t count; //引用计数
void (*func)(unsigned long); //tasklet处理函数
unsigned long data; //给tasklet处理函数的参数
};

tasklet还分为了高优先级tasklet与一般tasklet,前面分析软中断时softirq_init()注册的两个tasklet软中断。

1
2
3
4
5
6
7
8
void __init softirq_init(void)
{
......
//此处注册两个软中断
open_softirq(TASKLET_SOFTIRQ, tasklet_action);
open_softirq(HI_SOFTIRQ, tasklet_hi_action);
......
}

其处理函数分别为tasklet_action()tasklet_hi_action()

tasklet_action()函数实现为:

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
static void tasklet_action(struct softirq_action *a)
{
struct tasklet_struct *list;

local_irq_disable();
list = __get_cpu_var(tasklet_vec).head;
__get_cpu_var(tasklet_vec).head = NULL;
__get_cpu_var(tasklet_vec).tail = &__get_cpu_var(tasklet_vec).head;
local_irq_enable();

while (list) {
struct tasklet_struct *t = list;

list = list->next;

if (tasklet_trylock(t)) {
if (!atomic_read(&t->count)) { //t->count`为零才会调用`task_struct`里的函数
if (!test_and_clear_bit(TASKLET_STATE_SCHED, &t->state))
BUG();

t->func(t->data); //设置了`TASKLET_STATE_SCHED`标志才会被遍历到链表上对应的函数
tasklet_unlock(t);
continue;
}
tasklet_unlock(t);
}

local_irq_disable();
t->next = NULL;
*__get_cpu_var(tasklet_vec).tail = t;
__get_cpu_var(tasklet_vec).tail = &(t->next);
__raise_softirq_irqoff(TASKLET_SOFTIRQ);
local_irq_enable();
}
}

tasklet_hi_action函数实现类似

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
static void tasklet_hi_action(struct softirq_action *a)
{
struct tasklet_struct *list;

local_irq_disable();
list = __get_cpu_var(tasklet_hi_vec).head;
__get_cpu_var(tasklet_hi_vec).head = NULL;
__get_cpu_var(tasklet_hi_vec).tail = &__get_cpu_var(tasklet_hi_vec).head;
local_irq_enable();

while (list) {
struct tasklet_struct *t = list;

list = list->next;

if (tasklet_trylock(t)) {
if (!atomic_read(&t->count)) {
if (!test_and_clear_bit(TASKLET_STATE_SCHED, &t->state))
BUG();
t->func(t->data);
tasklet_unlock(t);
continue;
}
tasklet_unlock(t);
}

local_irq_disable();
t->next = NULL;
*__get_cpu_var(tasklet_hi_vec).tail = t;
__get_cpu_var(tasklet_hi_vec).tail = &(t->next);
__raise_softirq_irqoff(HI_SOFTIRQ);
local_irq_enable();
}
}

这两个函数主要是做了如下动作:

  1. 禁止中断,并为当前处理器检索tasklet_vectasklet_hi_vec链表。
  2. 将当前处理器上的该链表设置为`NULL,达到清空的效果。
  3. 运行相应中断。
  4. 循环遍历获得链表上的每一个待处理的`tasklet。
  5. 如果是多处理器系统,通过检查TASKLET_STATE_RUN来判断这个tasklet是否正在其他处理器上运行。如果它正在运行,那么现在就不要执行,跳到下一个待处理的tasklet去。
  6. 如果当前这个tasklet没有执行,将其状态设置为TASKLETLET_STATE_RUN,这样别的处理器就不会再去执行它了。
  7. 检查count值是否为0,确保tasklet没有被禁止。如果tasklet被禁止,则跳到下一个挂起的tasklet去。
  8. 现在可以确定这个tasklet没有在其他地方执行,并且被我们设置为执行状态,这样它在其他部分就不会被执行,并且引用计数器为0,现在可以执行tasklet的处理程序了。
  9. 重复执行下一个tasklet,直至没有剩余的等待处理的tasklets

一般情况下,都是用tasklet来实现下半部,tasklet可以动态创建、使用方便、执行速度快。下面来看一下如何创建自己的tasklet呢?

第一步,声明自己的tasklet。既可以静态也可以动态创建,这取决于选择是想有一个对tasklet的直接引用还是间接引用。静态创建方法(直接引用),可以使用下列两个宏的一个(在Linux/interrupt.h中定义):

1
2
DECLARE_TASKLET(name,func,data)
DECLARE_TASKLET_DISABLED(name,func,data)

这两个宏的实现为:
1
2
3
4
5
#define DECLARE_TASKLET(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }

#define DECLARE_TASKLET_DISABLED(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(1), func, data }

这两个宏之间的区别在于引用计数器的初始值不同,前面一个把创建的tasklet的引用计数器设置为0,使其处于激活状态,另外一个将其设置为1,处于禁止状态。而动态创建(间接引用)的方式如下:

1
tasklet_init(t,tasklet_handler,dev);

其实现代码为:
1
2
3
4
5
6
7
8
9
void tasklet_init(struct tasklet_struct *t,
void (*func)(unsigned long), unsigned long data)
{
t->next = NULL;
t->state = 0;
atomic_set(&t->count, 0);
t->func = func;
t->data = data;
}

第二步,编写tasklet处理程序。tasklet处理函数类型是void tasklet_handler(unsigned long data)。因为是靠软中断实现,所以tasklet不能休眠,也就是说不能在tasklet中使用信号量或者其他什么阻塞式的函数。由于tasklet运行时允许响应中断,所以必须做好预防工作,如果新加入的tasklet和中断处理程序之间共享了某些数据额的话。两个相同的tasklet绝不能同时执行,如果新加入的tasklet和其他的tasklet或者软中断共享了数据,就必须要进行适当地锁保护。

第三步,调度自己的tasklet。调用tasklet_schedule()(或tasklet_hi_schedule())函数,tasklet就会进入挂起状态以便执行。如果在还没有得到运行机会之前,如果有一个相同的tasklet又被调度了,那么它仍然只会运行一次。如果这时已经开始运行,那么这个新的tasklet会被重新调度并再次运行。一种优化策略是一个tasklet总在调度它的处理器上执行。

调用tasklet_disable()来禁止某个指定的tasklet,如果该tasklet当前正在执行,这个函数会等到它执行完毕再返回。调用tasklet_disable_nosync()也是来禁止的,只是不用在返回前等待tasklet执行完毕,这么做不太安全,因为没法估计该tasklet是否仍在执行。tasklet_enable()激活一个tasklet。可以使用tasklet_kill()函数从挂起的对列中去掉一个tasklet。这个函数会首先等待该tasklet执行完毕,然后再将其移去。当然,没有什么可以阻止其他地方的代码重新调度该tasklet。由于该函数可能会引起休眠,所以禁止在中断上下文中使用它。

下面来看一下函数tasklet_schedule的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static inline void tasklet_schedule(struct tasklet_struct *t)
{
//检查tasklet的状态是否为TASKLET_STATE_SCHED.如果是,说明tasklet已经被调度过了,函数返回。
if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state))
__tasklet_schedule(t);
}

void __tasklet_schedule(struct tasklet_struct *t)
{
unsigned long flags;

//保存中断状态,然后禁止本地中断。在执行tasklet代码时,这么做能够保证处理器上的数据不会弄乱。
local_irq_save(flags);

//把需要调度的tasklet加到每个处理器一个的tasklet_vec链表或task_hi_vec链表的表头上去。
t->next = NULL; *__get_cpu_var(tasklet_vec).tail = t;
__get_cpu_var(tasklet_vec).tail = &(t->next);

//唤起TASKLET_SOFTIRQ或HI_SOFTIRQ软中断,这样在下一次调用do_softirq()时就会执行该tasklet。
raise_softirq_irqoff(TASKLET_SOFTIRQ);

//恢复中断到原状态并返回。
local_irq_restore(flags);
}

tasklet_hi_schedule()函数的实现细节类似。

对于软中断,内核会选择几个特殊的实际进行处理(常见的是中 断处理程序返回时)。软中断被触发的频率有时会很好,而且还可能会自行重复触发,这带来的结果就是用户空间的进程无法获得足够的处理器时间,因为处于饥饿 状态。同时,如果单纯的对重复触发的软中断采取不立即处理的策略也是无法接受的。

内核选中的方案是不会立即处理重新触发的软中断,作为改进,当大量软中断出现的时候,内核会唤醒一组内核线程来处理这些负载。这些线程在最低优先级上运行(nice值为19)。这种这种方案能够保证在软中断负担很 重的时候用户程序不会因为得不到处理时间而处理饥饿状态。相应的,也能保证“过量”的软中断终究会得到处理。最后,在空闲系统上,这个方案同样表现良好,软中断处理得非常迅速(因为仅存的内存线程肯定会马上调度)。为了保证只要有空闲的处理器,它们就会处理软中断,所以给每个处理器都分配一个这样的线程。 所有线程的名字都叫做ksoftirad/n,区别在于n,它对应的是处理器的编号。一旦该线程被初始化,它就会执行类似下面这样的死循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
for(;;){
if(!softirq_pending(cpu))//softirq_pending()负责发现是否有待处理的软中断
schedule(); //没有待处理软中断就唤起调度程序选择其他可执行进程投入运行
set_current_state(TASK_RUNNING);
while(softirq_pending(cpu)){
do_softirq();//有待处理的软中断,ksoftirq调用do_softirq()去处理他。
if(need_resched()) //如果有必要的话,每次软中断完成之后调用schedule函数让其他重要进程得到处理机会
schedule();
}

//当所有需要执行的操作都完成以后,该内核线程将自己设置为 TASK_INTERRUPTIBLE状态
set_current_state(TASK_INTERRUPTIBLE);
}

下半部机制之工作队列及几种机制的选择

工作队列是下半部的另外一种将工作推后执行形式。和软中断、tasklet不同,工作队列将工作推后交由一个内核线程去执行,并且该下半部总会在进程上下文中执行。这样,工作队列允许重新调度甚至是睡眠。

所以,如果推后执行的任务需要睡眠,就选择工作队列。如果不需要睡眠,那就选择软中断或`tasklet。工作队列是唯一能在进程上下文中运行的下半部实现机制,也只有它才可以睡眠。

工作队列子系统是一个用于创建内核线程的接口,通过它创建的进程负责执行由内核其他部分排到队列里的任务。它创建的这些内核线程称作工作者线程。工作队列可以让你的驱动程序创建一个专门的工作者线程来处理需要推后的工作。不过,工作队列子系统提供了一个缺省的工作者线程来处理这些工作。因此,工作队列最基本的表现形式就转变成一个把需要推后执行的任务交给特定的通用线程这样一种接口。缺省的工作线程叫做event/n。每个处理器对应一个线程,这里的n代表了处理器编号。除非一个驱动程序或者子系统必须建立一个属于自己的内核线程,否则最好还是使用缺省线程。

使用下面命令可以看到默认event工作者线程,每个处理器对应一个线程:

1
2
3
# ps x | grep event | grep -v grep
9 ? S 0:00 [events/0]
10 ? S 0:00 [events/1]

工作者线程使用workqueue_struct结构表示(位于<kernel/workqueue.c>中):

1
2
3
4
5
6
7
8
9
10
11
struct workqueue_struct {
struct cpu_workqueue_struct *cpu_wq; //该数组每一项对应系统中的一个处理器
struct list_head list;
const char *name;
int singlethread;
int freezeable; /* Freeze threads during suspend */
int rt;
#ifdef CONFIG_LOCKDEP
struct lockdep_map lockdep_map;
#endif
}

每个处理器,每个工作者线程对应对应一个cpu_workqueue_struct结构体(位于<kernel/workqueue.c>中):

1
2
3
4
5
6
7
8
9
10
struct cpu_workqueue_struct {
spinlock_t lock; //保护该结构

struct list_head worklist; //工作列表
wait_queue_head_t more_work; //等待队列,其中的工作者线程因等待而处于睡眠状态
struct work_struct *current_work;

struct workqueue_struct *wq; //关联工作队列结构
struct task_struct *thread; // 关联线程,指向结构中工作者线程的进程描述符指针
} ____cacheline_aligned;

每个工作者线程类型关联一个自己的workqueue_struct,在该结构体里面,给每个线程分配一个cpu_workqueue_struct,因而也就是给每个处理器分配一个,因为每个处理器都有一个该类型的工作者线程。

所有的工作者线程都是使用普通的内核线程实现的,他们都要执行worker_thread()函数。在它初始化完以后,这个函数执行一个死循环执行一个循环并开始休眠,当有操作被插入到队列的时候,线程就会被唤醒,以便执行这些操作。当没有剩余的时候,它又会继续休眠。工作由work_struct(位于<kernel/workqueue.c>中)结构表示:

1
2
3
4
5
6
7
struct work_struct {
atomic_long_t data;
......
struct list_head entry;//连接所有链表
work_func_t func;
.....
};

当一个工作线程被唤醒时,它会执行它的链表上的所有工作。工作一旦执行完毕,它就将相应的work_struct对象从链表上移去,当链表不再有对象时,它就继续休眠。woker_thread()函数如下:

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
static int worker_thread(void *__cwq)
{
struct cpu_workqueue_struct *cwq = __cwq;
DEFINE_WAIT(wait);

if (cwq->wq->freezeable)
set_freezable();

for (;;) {
//线程将自己设置为休眠状态并把自己加入等待队列
prepare_to_wait(&cwq->more_work, &wait, TASK_INTERRUPTIBLE);
if (!freezing(current) &&
!kthread_should_stop() &&
list_empty(&cwq->worklist))
schedule();//如果工作对列是空的,线程调用`schedule()函数进入睡眠状态
finish_wait(&cwq->more_work, &wait);

try_to_freeze();

//如果链表有对象,线程就将自己设为运行态,脱离等待队列
if (kthread_should_stop())
break;

//再次调用`run_workqueue()执行推后的工作
run_workqueue(cwq);
}

return 0;
}

之后由run_workqueue()函数来完成实际推后到此的工作:

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
static void run_workqueue(struct cpu_workqueue_struct *cwq) 
{
spin_lock_irq(&cwq->lock);
while (!list_empty(&cwq->worklist)) {
//链表不为空时,选取下一个节点对象
struct work_struct *work = list_entry(cwq->worklist.next,
struct work_struct, entry);
//获取希望执行的函数`func`及其参数`data
work_func_t f = work->func;
......
trace_workqueue_execution(cwq->thread, work);
cwq->current_work = work;
//把该结点从链表上解下来
list_del_init(cwq->worklist.next);
spin_unlock_irq(&cwq->lock);

BUG_ON(get_wq_data(work) != cwq);
//将待处理标志位`pending`清0
work_clear_pending(work);
lock_map_acquire(&cwq->wq->lockdep_map);
lock_map_acquire(&lockdep_map);
//执行函数
f(work);
lock_map_release(&lockdep_map);
lock_map_release(&cwq->wq->lockdep_map);

......
spin_lock_irq(&cwq->lock);
cwq->current_work = NULL;
}
spin_unlock_irq(&cwq->lock);
}

系统允许有多种类型工作者线程存在,默认情况下内核只有event这一种类型的工作者线程,每个工作者线程都由一个cpu_workqueue_struct结构体表示,大部分情况下,驱动程序都使用现存的默认工作者线程。

工作队列的使用很简单。可以使用缺省的events任务队列,也可以创建新的工作者线程。
第一步、创建需要推后完成的工作。

1
2
DECLARE_WORK(name,void (*func)(void *),void *data);        //编译时静态创建
INIT_WORK(struct work_struct *work, void (*func)(void *));    //运行时动态创建

第二步、编写队列处理函数,处理函数会由工作者线程执行,因此,函数会运行在进程上下文中,默认情况下,允许相应中断,并且不持有锁。如果需要,函数可以睡眠。需要注意的是,尽管处理函数运行在进程上下文中,但它不能访问用户空间,因为内核线程在用户空间没有相应的内存映射。函数原型如下:

1
void work_hander(void *data);

第三步、调度工作队列。调用schedule_work(&work)work马上就会被调度,一旦其所在的处理器上的工作者线程被唤醒,它就会被执行。当然如果不想快速执行,而是想延迟一段时间执行,调用schedule_delay_work(&work,delay)delay是要延迟的时间节拍。

默认工作者线程的调度函数其实就是做了一层封装,减少了 默认工作者线程的参数输入,如下:

1
2
3
4
5
6
7
8
9
int schedule_work(struct work_struct *work)
{
return queue_work(keventd_wq, work);
}

int schedule_delayed_work(struct delayed_work *dwork, unsigned long delay)
{
return queue_delayed_work(keventd_wq, dwork, delay);
}

第四步、刷新操作,插入队列的工作会在工作者线程下一次被唤醒的时候执行。有时,在继续下一步工作之前,你必须保证一些操作已经执行完毕等等。由于这些原因,内核提供了一个用于刷新指定工作队列的函数:

1
void flush_scheduled_work(void);

这个函数会一直等待,直到队列中所有的对象都被执行后才返回。在等待所有待处理的工作执行的时候,该函数会进入休眠状态,所以只能在进程上下文中使用它。需要说明的是,该函数并不取消任何延迟执行的工作。取消延迟执行的工作应该调用:int cancel_delayed_work(struct work_struct *work);这个函数可以取消任何与work_struct相关挂起的工作。
下面为一个示例:
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
#include <linux/init.h>
#include <linux/module.h>
#include <linux/workqueue.h> //work_strcut

//struct work_struct ws;
struct delayed_work dw;

void workqueue_func(struct work_struct *ws) //处理函数
{
printk(KERN_ALERT"Hello, this is shallnet!\n");
}

static int __init kwq_init(void)
{
printk(KERN_ALERT"===%s===\n", __func__);

//INIT_WORK(&ws, workqueue_func); //建需要推后完成的工作
//schedule_work(&ws); //调度工作

INIT_DELAYED_WORK(&dw, workqueue_func);
schedule_delayed_work(&dw, 10000);

return 0;
}

static void __exit kwq_exit(void)
{
printk(KERN_ALERT"===%s===\n", __func__);

flush_scheduled_work();
}

module_init(kwq_init);
module_exit(kwq_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("shallnet");
MODULE_DESCRIPTION("blog.csdn.net/shallnet");

上面的操作是使用缺省的工作队列,下面来看一下创建一个新的工作队列是如何操作的?

创建一个新的工作队列和与之相应的工作者线程,方法很简单,使用如下函数:

1
struct workqueue_struct *create_workqueue(const char *name);

name是新内核线程的名字。比如缺省events队列的创建是这样使用的:

1
2
struct workqueue_struct    *keventd_wq
kevent_wq = create_workqueue("event");

这样就创建了所有的工作者线程,每个处理器都有一个。然后调用如下函数进行调度:

1
2
int queue_work(struct workqueue_struct *wq, struct work_struct *work);
int queue_delayed_work(struct workqueue_struct *wq,struct delayed_work *work,unsigned long delay);

最后可以调用flush_workqueue(struct workqueue_struct *wq);刷新指定工作队列。

下面为自定义新的工作队列的示例:

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
#include <linux/init.h>
#include <linux/module.h>
#include <linux/workqueue.h> //work_strcut

struct workqueue_struct *sln_wq = NULL;
//struct work_struct ws;
struct delayed_work dw;

void workqueue_func(struct work_struct *ws)
{
printk(KERN_ALERT"Hello, this is shallnet!\n");
}

static int __init kwq_init(void)
{
printk(KERN_ALERT"===%s===\n", __func__);

sln_wq = create_workqueue("sln_wq"); //创建名为`sln_wq`的工作队列

//INIT_WORK(&ws, workqueue_func);
//queue_work(sln_wq, &ws);

INIT_DELAYED_WORK(&dw, workqueue_func); //
queue_delayed_work(sln_wq, &dw, 10000); //

return 0;
}

static void __exit kwq_exit(void)
{
printk(KERN_ALERT"===%s===\n", __func__);

flush_workqueue(sln_wq);
}

module_init(kwq_init);
module_exit(kwq_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("shallnet");
MODULE_DESCRIPTION("blog.csdn.net/shallnet");

使用ps可以查看到名为sln_wq的工作者线程。

在当前2.6.32版本中,我们讲了三种下半部机制:软中断、tasklet、工作队列。其中tasklet基于软中断,而工作队列靠内核线程实现。

使用软中断必须要确保共享数据的安全,因为相同类别的软中断可能在不同处理器上同时执行。在对于时间要求是否严格和执行频率很高的应用,或准备利用每一处理器上的变量或类型情形,可以考虑使用软中断,如网络子系统。

tasklet接口简单,可以动态创建,且两个通知类型的tasklet不能同时执行,所以实现起来较简单。驱动程序应该尽量选择tasklet而不是软中断。

工作队列工作于进程上下文,易于使用。由于牵扯到内核线程或上下文的切换,可能开销较大。如果你需要把任务推后到进程上下文中,或你需要休眠,那就只有使用工作队列了。

内核时钟中断

内核中很多函数是基于时间驱动的,其中有些函数需要周期或定期执行。比如有的每秒执行100次,有的在等待一个相对时间之后执行。除此之外,内核还必须管理系统运行的时间日期。

周期性产生的时间都是有系统定时器驱动的,系统定时器是一种可编程硬件芯片,它可以以固定频率产生中断,该中断就是所谓的定时器中断,其所对应的中断处理程序负责更新系统时间,也负责执行需要周期性运行的任务。

系统定时器以某种频率自行触发时钟中断,该频率可以通过编程预定,称作节拍率。当时钟中断发生时,内核就通过一种特殊的中断处理器对其进行处理。内核知道连续两次时钟中断的间隔时间,该间隔时间就称为节拍。内核就是靠这种已知的时钟中断间隔来计算实际时间和系统运行时间的。内核通过控制时钟中断维护实际时间,另外内核也为用户提供一组系统调用获取实际日期和实际时间。时钟中断对才操作系统的管理来说十分重要,系统更新运行时间、更新实际时间、均衡调度程序中个处理器上运行队列、检查进程是否用尽时间片等工作都利用时钟中断来周期执行。

内核有一个全局变量jiffies,该变量用来记录系统起来以后产生的节拍总数。系统启动是,该变量被设置为0,此后每产生一次时钟中断就增加该变量的值。jiffies每一秒增加的值就是HZjiffies定义于头文件<include/linux/jiffies.h>中:

1
extern unsigned long volatile __jiffy_data jiffies;

对于32位unsigned long,可以存放最大值为4294967295,所以当节拍数达到最大值后还要继续增加的话,它的值就会回到0值。内核提供了四个宏(位于文件<include/linux/jiffies.h>中)来比较节拍数,这些宏可以正确处理节拍计数回绕情况。

1
2
3
4
5
6
7
8
9
10
#define time_after(a,b)         \
(typecheck(unsigned long, a) && \
typecheck(unsigned long, b) && \
((long)(b) - (long)(a) < 0))
#define time_before(a,b) time_after(b,a)
#define time_after_eq(a,b) \
(typecheck(unsigned long, a) && \
typecheck(unsigned long, b) && \
((long)(a) - (long)(b) >= 0))
#define time_before_eq(a,b) time_after_eq(b,a)

下面示例来打印出当前系统启动后经过的jiffies以及秒数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <linux/init.h>
#include <linux/module.h>
#include <linux/jiffies.h> //jiffies
#include <asm/param.h> //HZ
static int __init jiffies_init(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
printk(KERN_ALERT"Current ticks is: %lu, seconds: %lu\n", jiffies, jiffies/HZ);
return 0;
}
static void __exit jiffies_exit(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
}
module_init(jiffies_init);
module_exit(jiffies_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("shallnet");
MODULE_DESCRIPTION("blog.csdn.net/shallnet");

执行输出结果为:

1
2
3
# insmod jfs.ko
===jiffies_init===
Current ticks is: 10106703, seconds: 10106

时钟中断发生时,会触发时钟中断处理程序,始终中断处理程序部分和体系结构相关,下面简单分析一下x86体系的处理:

时钟的初始化在time_init()中,在start_kernel()中调用time_init(),如下:

1
2
3
4
5
6
asmlinkage void __init start_kernel(void)
{
......
time_init();
......
}

下面分析一下time_init()的实现,该函数位于文件<arch/x86/kernel/time.c>中:
1
2
3
4
5
6
7
8
9
10
void __init time_init(void)
{
late_time_init = x86_late_time_init;
}

static __init void x86_late_time_init(void)
{
x86_init.timers.timer_init(); //
tsc_init();
}

结构体x86_init位于arch/x86/kernel/x86_init.c

1
2
3
4
5
6
7
8
struct x86_init_ops x86_init __initdata = { 
......
.timers = {
.setup_percpu_clockev>--= setup_boot_APIC_clock,
.tsc_pre_init = x86_init_noop,
.timer_init = hpet_time_init,
}
}

默认timer初始化函数为:
1
2
3
4
5
6
void __init hpet_time_init(void)
{
if (!hpet_enable())
setup_pit_timer();
setup_default_timer_irq();
}

函数setup_default_timer_irq();注册中断处理函数:

1
2
3
4
5
6
7
8
9
10
void __init setup_default_timer_irq(void)
{
setup_irq(0, &irq0);
}

static struct irqaction irq0 = {
.handler = timer_interrupt,
.flags = IRQF_DISABLED | IRQF_NOBALANCING | IRQF_IRQPOLL | IRQF_TIMER,
.name = "timer"
};

对应的中断处理函数为:timer_interrupt():

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
static irqreturn_t timer_interrupt(int irq, void *dev_id)
{
/* Keep nmi watchdog up to date */
inc_irq_stat(irq0_irqs);

/* Optimized out for !IO_APIC and x86_64 */
if (timer_ack) {
/*
* Subtle, when I/O APICs are used we have to ack timer IRQ
* manually to deassert NMI lines for the watchdog if run
* on an 82489DX-based system.
*/
spin_lock(&i8259A_lock);
outb(0x0c, PIC_MASTER_OCW3);
/* Ack the IRQ; AEOI will end it automatically. */
inb(PIC_MASTER_POLL);
spin_unlock(&i8259A_lock);
}

//在此处调用体系无关的时钟处理例程
global_clock_event->event_handler(global_clock_event);

/* MCA bus quirk: Acknowledge irq0 by setting bit 7 in port 0x61 */
if (MCA_bus)
outb_p(inb_p(0x61)| 0x80, 0x61);

return IRQ_HANDLED;
}

时钟例程在系统启动时start_kernel()函数中调用tick_init()初始化:
1
2
3
4
void __init tick_init(void)
{
clockevents_register_notifier(&tick_notifier);
}

tick_notifier定义如下:

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
static struct notifier_block tick_notifier = {
.notifier_call = tick_notify,
};

static int tick_notify(struct notifier_block *nb, unsigned long reason, void *dev)
{
switch (reason) {
case CLOCK_EVT_NOTIFY_RESUME:
tick_resume();
break;
default:
break;
}
return NOTIFY_OK;
}

static void tick_resume(void)
{
struct tick_device *td = &__get_cpu_var(tick_cpu_device);
unsigned long flags;
int broadcast = tick_resume_broadcast();

spin_lock_irqsave(&tick_device_lock, flags);
clockevents_set_mode(td->evtdev, CLOCK_EVT_MODE_RESUME);

if (!broadcast) {
if (td->mode == TICKDEV_MODE_PERIODIC)
tick_setup_periodic(td->evtdev, 0);
else
tick_resume_oneshot();
}
spin_unlock_irqrestore(&tick_device_lock, flags);
}

/*
* Setup the device for a periodic tick
*/
void tick_setup_periodic(struct clock_event_device *dev, int broadcast)
{
tick_set_periodic_handler(dev, broadcast);

......
}

/*
* 根据broadcast设置周期性的处理函数(kernel/time/tick-broadcast.c),这里就设置了始终中断函数timer_interrupt中调用的时钟处理例程
*/
void tick_set_periodic_handler(struct clock_event_device *dev, int broadcast)
{
if (!broadcast)
dev->event_handler = tick_handle_periodic;
else
dev->event_handler = tick_handle_periodic_broadcast;
}

/*
* ,以tick_handle_periodic为例,每一个始终节拍都调用该处理函数,而该处理过程中,主要处理工作处于tick_periodic()函数中。
*/
void tick_handle_periodic(struct clock_event_device *dev)
{
int cpu = smp_processor_id();
ktime_t next;

tick_periodic(cpu);

if (dev->mode != CLOCK_EVT_MODE_ONESHOT)
return;

next = ktime_add(dev->next_event, tick_period);
for (;;) {
if (!clockevents_program_event(dev, next, ktime_get()))
return;

if (timekeeping_valid_for_hres())
tick_periodic(cpu);
next = ktime_add(next, tick_period);
}
}

tick_periodic()函数主要有以下工作:
下面来看分析一下该函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* Periodic tick
*/
static void tick_periodic(int cpu)
{
if (tick_do_timer_cpu == cpu) {
write_seqlock(&xtime_lock);

/* 记录下一个节拍事件 */
tick_next_period = ktime_add(tick_next_period, tick_period);

do_timer(1);
write_sequnlock(&xtime_lock);
}

update_process_times(user_mode(get_irq_regs()));//更新所耗费的各种节拍数
profile_tick(CPU_PROFILING);
}

其中函数do_timer()(位于kernel/timer.c中)对jiffies_64做增加操作:
1
2
3
4
5
6
void do_timer(unsigned long ticks)
{
jiffies_64 += ticks;
update_wall_time(); //更新墙上时钟
calc_global_load(); //更新系统平均负载统计值
}

update_process_times更新所耗费的各种节拍数。

1
2
3
4
5
6
7
8
9
10
11
12
13
void update_process_times(int user_tick)
{
struct task_struct *p = current;
int cpu = smp_processor_id();

/* Note: this timer irq context must be accounted for as well. */
account_process_tick(p, user_tick);
run_local_timers();
rcu_check_callbacks(cpu, user_tick);
printk_tick();
scheduler_tick();
run_posix_cpu_timers(p);
}

函数run_local_timers()会标记一个软中断去处理所有到期的定时器。
1
2
3
4
5
6
void run_local_timers(void)
{
hrtimer_run_queues();
raise_softirq(TIMER_SOFTIRQ);
softlockup_tick();
}

在时钟中断处理函数time_interrupt()函数调用体系结构无关的时钟处理例程完成之后,返回到与体系结构的相关的中断处理函数中。以上所有的工作每一次时钟中断都会运行,也就是说如果HZ=100,那么时钟中断处理程序每一秒就会运行100次。

内核定时器和定时执行

前面章节说到了把工作推后到除现在以外的时间执行的机制是下半部机制,但是当你需要将工作推后到某个确定的时间段之后执行,使用定时器是很好的选择。

上一节内核时间管理中讲到内核在始终中断发生执行定时器,定时器作为软中断在下半部上下文中执行。时钟中断处理程序会执行update_process_times函数,在该函数中运行run_local_timers()函数来标记一个软中断去处理所有到期的定时器。如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void update_process_times(int user_tick)
{
struct task_struct *p = current;
int cpu = smp_processor_id();
/* Note: this timer irq context must be accounted for as well. */
account_process_tick(p, user_tick);
run_local_timers();
rcu_check_callbacks(cpu, user_tick);
printk_tick();
scheduler_tick();
run_posix_cpu_timers(p);
}
void run_local_timers(void)
{
hrtimer_run_queues();
raise_softirq(TIMER_SOFTIRQ);
softlockup_tick();
}

在分析定时器的实现之前我们先来看一看使用内核定时器的一个实例,示例如下:
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
#include <linux/module.h>
#include <linux/init.h>
#include <linux/version.h>
#include <linux/timer.h>
#include <linux/delay.h>
struct timer_list sln_timer;
void sln_timer_do(unsigned long l)
{
mod_timer(&sln_timer, jiffies + HZ);
printk(KERN_ALERT"param: %ld, jiffies: %ld\n", l, jiffies);
}
void sln_timer_set(void)
{
init_timer(&sln_timer);
sln_timer.expires = jiffies + HZ; //1s
sln_timer.function = sln_timer_do;
sln_timer.data = 9527;
add_timer(&sln_timer);
}
static int __init sln_init(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
sln_timer_set();
return 0;
}
static void __exit sln_exit(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
del_timer(&sln_timer);
}
module_init(sln_init);
module_exit(sln_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("allen");

该示例作用是每秒钟打印出当前系统jiffies的值。

内核定时器由结构timer_list表示,定义在文件<include/linux/timer.h>中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_base *base;
#ifdef CONFIG_TIMER_STATS
void *start_site;
char start_comm[16];
int start_pid;
#endif
#ifdef CONFIG_LOCKDEP
struct lockdep_map lockdep_map;
#endif
};

如示例,内核提供部分操作接口来简化管理定时器,
第一步、定义一个定时器:struct timer_list sln_timer;

第二步、初始化定时器数据结构的内部值。

1
init_timer(&sln_timer);//初始化定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define init_timer(timer)\
init_timer_key((timer), NULL, NULL)

void init_timer_key(struct timer_list *timer,
const char *name,
struct lock_class_key *key)
{
debug_init(timer);
__init_timer(timer, name, key);
}

static void __init_timer(struct timer_list *timer,
const char *name,
struct lock_class_key *key)
{
timer->entry.next = NULL;
timer->base = __raw_get_cpu_var(tvec_bases);
#ifdef CONFIG_TIMER_STATS
timer->start_site = NULL;
timer->start_pid = -1;
memset(timer->start_comm, 0, TASK_COMM_LEN);
#endif
lockdep_init_map(&timer->lockdep_map, name, key, 0);
}

第三步、填充timer_list结构中需要的值:

1
2
3
sln_timer.expires = jiffies + HZ;   //1s`后执行  
sln_timer.function = sln_timer_do; //执行函数
sln_timer.data = 9527;

sln_timer.expires表示超时时间,它以节拍为单位的绝对计数值。如果当前jiffies计数等于或大于sln_timer.expires的值,那么sln_timer.function所指向的处理函数sln_timer_do就会执行,并且该函数还要使用长整型参数sln_timer.dat

1
void sln_timer_do(unsigned long l)

第四步、激活定时器:

1
add_timer(&sln_timer);    //向内核注册定时器

这样定时器就可以运行了。

add_timer()的实现如下:

1
2
3
4
5
void add_timer(struct timer_list *timer)
{
BUG_ON(timer_pending(timer));
mod_timer(timer, timer->expires);
}

add_timer()调用了mod_timer()mod_timer()用于修改定时器超时时间。

1
mod_timer(&sln_timer, jiffies + HZ);

由于add_timer()是通过调用mod_timer()来激活定时器,所以也可以直接使用mod_timer()来激活定时器,如果定时器已经初始化但没有激活,mod_timer()也会激活它。

如果需要在定时器超时前停止定时器,使用del_timer()函数来完成。

1
del_timer(&sln_timer);

该函数实现如下:

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
int del_timer(struct timer_list *timer)
{
struct tvec_base *base;
unsigned long flags;
int ret = 0;
timer_stats_timer_clear_start_info(timer);
if (timer_pending(timer)) {
base = lock_timer_base(timer, &flags);
if (timer_pending(timer)) {
detach_timer(timer, 1);
if (timer->expires == base->next_timer &&
!tbase_get_deferrable(timer->base))
base->next_timer = base->timer_jiffies;
ret = 1;
}
spin_unlock_irqrestore(&base->lock, flags);
}
return ret;
}
static inline void detach_timer(struct timer_list *timer,
int clear_pending)
{
struct list_head *entry = &timer->entry;
debug_deactivate(timer);
__list_del(entry->prev, entry->next);
if (clear_pending)
entry->next = NULL;
entry->prev = LIST_POISON2;
}

当使用del_timer()返回后,定时器就不会再被激活,但在多处理器机器上定时器上定时器中断可能已经在其他处理器上运行了,所以删除定时器时需要等待可能在其他处理器上运行的定时器处理I程序都退出,这时就要使用del_timer_sync()函数执行删除工作:

1
del_timer_sync(&sln_timer);

该函数不能在中断上下文中使用。

该函数详细实现如下:

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
int del_timer_sync(struct timer_list *timer)
{
#ifdef CONFIG_LOCKDEP
unsigned long flags;
local_irq_save(flags);
lock_map_acquire(&timer->lockdep_map);
lock_map_release(&timer->lockdep_map);
local_irq_restore(flags);
#endif
for (;;) { //一直循环,直到删除`timer`成功再退出
int ret = try_to_del_timer_sync(timer);
if (ret >= 0)
return ret;
cpu_relax();
}
}
int try_to_del_timer_sync(struct timer_list *timer)
{
struct tvec_base *base;
unsigned long flags;
int ret = -1;
base = lock_timer_base(timer, &flags);
if (base->running_timer == timer)
goto out;
ret = 0;
if (timer_pending(timer)) {
detach_timer(timer, 1);
if (timer->expires == base->next_timer &&
!tbase_get_deferrable(timer->base))
base->next_timer = base->timer_jiffies;
ret = 1;
}
out:
spin_unlock_irqrestore(&base->lock, flags);
return ret;
}

一般情况下应该使用del_timer_sync()函数代替del_timer()函数,因为无法确定在删除定时器时,他是否在其他处理器上运行。为了防止这种情况的发生,应该调用del_timer_sync()函数而不是del_timer()函数。否则,对定时器执行删除操作后,代码会继续执行,但它有可能会去操作在其它处理器上运行的定时器正在使用的资源,因而造成并发访问,所有优先使用删除定时器的同步方法。

除了使用定时器来推迟任务到指定时间段运行之外,还有其他的方法处理延时请求。有的方法会在延迟任务时挂起处理器,有的却不会。实际上也没有方法能够保证实际的延迟时间刚好等于指定的延迟时间。

  1. 最简单的 延迟方法是忙等待,该方法实现起来很简单,只需要在循环中不断旋转直到希望的时钟节拍数耗尽。比如:
    1
    2
    3
    unsigned long delay = jiffies+10;   //延迟10个节拍
    while(time_before(jiffies,delay))

这种方法当代码等待时,处理器只能在原地旋转等待,它不会去处理其他任何任务。最好在任务等待时,允许内核重新调度其它任务执行。将上面代码修改如下:

1
2
3
unsigned long delay = jiffies+10;   //10个节拍
while(time_before(jiffies,delay))
cond_resched();

看一下cond_resched()函数具体实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define cond_resched() ({           \
__might_sleep(__FILE__, __LINE__, 0); \
_cond_resched(); \
})

int __sched _cond_resched(void)
{
if (should_resched()) {
__cond_resched();
return 1;
}
return 0;
}

static void __cond_resched(void)
{
add_preempt_count(PREEMPT_ACTIVE);
schedule(); //最终还是调用`schedule()函数来重新调度其它程序运行
sub_preempt_count(PREEMPT_ACTIVE);
}

函数cond_resched()将重新调度一个新程序投入运行,但它只有在设置完need_resched标志后才能生效。换句话说,就是系统中存在更重要的任务需要运行。再由于该方法需要调用调度程序,所以它不能在中断上下文中使用——只能在进程上下文中使用。事实上,所有延迟方法在进程上下文中使用,因为中断处理程序都应该尽可能快的执行。另外,延迟执行不管在哪种情况下都不应该在持有锁时或者禁止中断时发生。

  1. 有时内核需要更短的延迟,甚至比节拍间隔还要短。这时可以使用内核提供的ms、ns、us级别的延迟函数。

    1
    2
    3
    void udelay(unsigned long usecs);    //arch/x86/include/asm/delay.h
    void ndelay(unsigned long nsecs); //arch/x86/include/asm/delay.h
    void mdelay(unsigned long msecs);

    udelay()使用忙循环将任务延迟指定的ms后执行,其依靠执行数次循环达到延迟效果,mdelay()函数是通过udelay()函数实现,如下:

    1
    2
    3
    4
    #define mdelay(n) (\ 
    (__builtin_constant_p(n) && (n)<=MAX_UDELAY_MS) ? udelay((n)*1000) : \
    ({unsigned long __ms=(n); while (__ms--) udelay(1000);}))
    #endif

    udelay()函数仅能在要求的延迟时间很短的情况下执行,而在高速机器中时间很长的延迟会造成溢出。对于较长的延迟,mdelay()工作良好。

  2. schedule_timeout()函数是更理想的延迟执行方法。该方法会让需要延迟执行的任务睡眠到指定的延迟时间耗尽后再重新运行。但该方法也不能保证睡眠时间正好等于指定的延迟时间,只能尽量是睡眠时间接近指定的延迟时间。当指定的时间到期后,内核唤醒被延迟的任务并将其重新放回运行队列。用法如下:

    1
    2
    set_current_state(TASK_INTERRUPTIBLE);    //将任务设置为可中断睡眠状态
    schedule_timeout(s*HZ); //小睡一会儿,“s”秒后唤醒

唯一的参数是延迟的相对时间,单位是jiffies,上例中将相应的任务推入可中断睡眠队列,睡眠s秒。在调用函数schedule_timeout之前,不要要将任务设置成可中断或不和中断的一种,否则任务不会休眠。这个函数需要调用调度程序,所以调用它的代码必须保证能够睡眠,简而言之,调用代码必须处于进程上下文中,并且不能持有锁。

事实上schedule_timeout()函数的实现就是内核定时器的一个简单应用。

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
signed long __sched schedule_timeout(signed long timeout)
{
struct timer_list timer;
unsigned long expire;

switch (timeout)
{
case MAX_SCHEDULE_TIMEOUT:
/*
* These two special cases are useful to be comfortable
* in the caller. Nothing more. We could take
* MAX_SCHEDULE_TIMEOUT from one of the negative value
* but I' d like to return a valid offset (>=0) to allow
* the caller to do everything it want with the retval.
*/
schedule();
goto out;
default:
/*
* Another bit of PARANOID. Note that the retval will be
* 0 since no piece of kernel is supposed to do a check
* for a negative retval of schedule_timeout() (since it
* should never happens anyway). You just have the printk()
* that will tell you if something is gone wrong and where.
*/
if (timeout < 0) {
printk(KERN_ERR "schedule_timeout: wrong timeout "
"value %lx\n", timeout);
dump_stack();
current->state = TASK_RUNNING;
goto out;
}
}

expire = timeout + jiffies;

//下一行代码设置了超时执行函数`process_timeout()。
setup_timer_on_stack(&timer, process_timeout, (unsigned long)current);
__mod_timer(&timer, expire, false, TIMER_NOT_PINNED); //激活定时器
schedule(); //调度其他新任务
del_singleshot_timer_sync(&timer);

/* Remove the timer from the object tracker */
destroy_timer_on_stack(&timer);

timeout = expire - jiffies;

out:
return timeout < 0 ? 0 : timeout;
}
当定时器超时时,process_timeout()函数被调用:
static void process_timeout(unsigned long __data)
{
wake_up_process((struct task_struct *)__data);
}

当任务被重新调度时,将返回代码进入睡眠前的位置继续执行,位置正好在schedule()处。

进程上下文的代码为了等待特定时间发生,可以将自己放入等待队列。但是,等待队列上的某个任务可能既在等待一个特定事件到来,又在等待一个特定时间到期,就看谁来得更快。这种情况下,代码可以简单的使用scedule_timeout()函数代替schedule()函数,这样一来,当希望指定时间到期后,任务都会被唤醒,当然,代码需要检查被唤醒的原因,有可能是被事件唤醒,也有可能是因为延迟的时间到期,还可能是因为接收到了信号,然后执行相应的操作。

进程管理分析

进程其实就是程序的执行时的实例,是处于执行期的程序。在Linux内核中,进程列表被存放在一个双向循环链表中,链表中每一项都是类型为task_struct的结构,该结构称作进程描述符,进程描述符包含一个具体进程的所有信息,这个结构就是我们在操作系统中所说的PCB(Process Control Block)。该结构定义于<include/linux/sched.h>文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct task_struct {
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
void *stack;
atomic_t usage;
unsigned int flags; /* per process flags, defined below */
unsigned int ptrace;

int lock_depth; /* BKL lock depth */

......

int prio, static_prio, normal_prio;
unsigned int rt_priority;
const struct sched_class *sched_class;
struct sched_entity se;
struct sched_rt_entity rt;
......
struct task_struct *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list */
......
};

该结构体中包含的数据可以完整的描述一个正在执行的程序:打开的文件、进程的地址空间、挂起的信号、进程的状态、以及其他很多信息。

在系统运行过程中,进程频繁切换,所以我们需要一种方式能够快速获得当前进程的task_struct,于是进程内核堆栈底部存放着struct thread_info。该结构中有一个成员指向当前进程的task_struct。在x86上,struct thread_info在文件<arch/x86/include/asm/thread_info.h>中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct thread_info {
struct task_struct *task; /* 该指针存放的是指向该任务实际`task_struct`的指针 */
struct exec_domain *exec_domain; /* execution domain */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
int preempt_count; /* 0 => preemptable, <0 => BUG */
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp; /* ESP of the previous stack in
case of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};

使用current宏就可以获得当前进程的进程描述符。

每一个进程都有一个父进程,每个进程管理自己的子进程。每个进程都是init进程的子进程,init进程在内 核系统启动的最后阶段启动init进程,该进程读取系统的初始化脚本并执行其他相关程序,最终完成系统启动的整个过程。每个进程有0个或多个子进程,进程间的关系存放在进程描述符中。task_struct中有一个parent的指针,指向其父进程;还有个children的指针指向其子进程的链表。所以,对于当前进程,可以通过current宏来获得父进程和子进程的进程描述符。

下面程序打印当前进程、父进程信息和所有子进程信息:

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
60
61
62
63
64
65
#include <linux/module.h>
#include <linux/init.h>
#include <linux/version.h>

#include <linux/sched.h>

void sln_taskstruct_do(void)
{
struct task_struct *cur,
*parent,
*task;
struct list_head *first_child,
*child_list,
*cur_chd;

//获取当前进程信息
cur = current;

printk(KERN_ALERT"Current: %s[%d]\n",
cur->comm, cur->pid);

//获取父进程信息
parent = current->parent;
printk(KERN_ALERT"Parent: %s[%d]\n",
parent->comm, parent->pid);

//获取所有祖先进程信息
for (task = cur; task != &init_task; task = task->parent) {
printk(KERN_ALERT"ancestor: %s[%d]\n",
task->comm, task->pid);
}

//获取所有子进程信息
child_list = &cur->children;
first_child = &cur->children;
for (cur_chd = child_list->next;
cur_chd != first_child;
cur_chd = cur_chd->next) {
task = list_entry(child_list, struct task_struct, sibling);

printk(KERN_ALERT"Children: %s[%d]\n",
task->comm, task->pid);
}

}

static int __init sln_init(void)
{
printk(KERN_ALERT"===%s===\n", __func__);

sln_taskstruct_do();
return 0;
}

static void __exit sln_exit(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
}

module_init(sln_init);
module_exit(sln_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("shallnet");
MODULE_DESCRIPTION("blog.csdn.net/shallnet");

执行结果如下:
1
2
3
4
5
6
7
8
 # insmod task.ko
===sln_init===
Current: insmod[4315]
Parent: bash[4032]
ancestor: insmod[4315]
ancestor: bash[4032]
ancestor: login[2563]
ancestor: init[1]

Linux操作系统提供产生进程的机制,在Linux下的fork()使用写时拷贝(copy-on-write)页实现。这种技术原理是:内存并不复制整个进程地址空间,而是让父进程和子进程共享同一拷贝,只有在需要写入的时候,数据才会被复制。也就是资源的复制只是发生在需要写入的时候才进行,在此之前都是以只读的方式共享。

Linux通过clone()系统调用实现fork(),然后clone()去调用do_fork()do_fork()完成创建中大部分工作。库函数vfork()__clone()都根据各自需要的参数标志去调用clone()fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。
用户空间的fork()经过系统调用进入内核,在内核中对应的处理函数为sys_fork(),定义于<arch/x86/kernel/process.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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
int sys_fork(struct pt_regs *regs)
{
return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}

long do_fork(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
struct task_struct *p;
int trace = 0;
long nr;
......
p = copy_process(clone_flags, stack_start, regs, stack_size,
child_tidptr, NULL, trace);
......
return nr;
}

static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
struct pt_regs *regs,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace)
{
int retval;
struct task_struct *p;
int cgroup_callbacks_done = 0;
if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
return ERR_PTR(-EINVAL);
/*
* Thread groups must share signals as well, and detached threads
* can only be started up within the thread group.
*/
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);
/*
* Shared signal handlers imply shared VM. By way of the above,
* thread groups also imply shared VM. Blocking this case allows
* for various simplifications in other code.
*/
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);
/*
* Siblings of global init remain as zombies on exit since they are
* not reaped by their parent (swapper). To solve this and to avoid
* multi-rooted process trees, prevent global and container-inits
* from creating siblings.
*/
if ((clone_flags & CLONE_PARENT) &&
current->signal->flags & SIGNAL_UNKILLABLE)
return ERR_PTR(-EINVAL);
retval = security_task_create(clone_flags);
if (retval)
goto fork_out;
retval = -ENOMEM;
p = dup_task_struct(current);
if (!p)
goto fork_out;
ftrace_graph_init_task(p);
rt_mutex_init_task(p);
#ifdef CONFIG_PROVE_LOCKING
DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);
DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
#endif
retval = -EAGAIN;
if (atomic_read(&p->real_cred->user->processes) >=
p->signal->rlim[RLIMIT_NPROC].rlim_cur) {
if (!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE) &&
p->real_cred->user != INIT_USER)
goto bad_fork_free;
}
retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;
/*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
retval = -EAGAIN;
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;
if (!try_module_get(task_thread_info(p)->exec_domain->module))
goto bad_fork_cleanup_count;
p->did_exec = 0;
delayacct_tsk_init(p); /* Must remain after dup_task_struct() */
copy_flags(clone_flags, p);
INIT_LIST_HEAD(&p->children);
INIT_LIST_HEAD(&p->sibling);
rcu_copy_process(p);
p->vfork_done = NULL;
spin_lock_init(&p->alloc_lock);
init_sigpending(&p->pending);
p->utime = cputime_zero;
p->stime = cputime_zero;
p->gtime = cputime_zero;
p->utimescaled = cputime_zero;
p->stimescaled = cputime_zero;
p->prev_utime = cputime_zero;
p->prev_stime = cputime_zero;
p->default_timer_slack_ns = current->timer_slack_ns;
task_io_accounting_init(&p->ioac);
acct_clear_integrals(p);
posix_cpu_timers_init(p);
p->lock_depth = -1; /* -1 = no lock */
do_posix_clock_monotonic_gettime(&p->start_time);
p->real_start_time = p->start_time;
monotonic_to_bootbased(&p->real_start_time);
p->io_context = NULL;
p->audit_context = NULL;
cgroup_fork(p);
#ifdef CONFIG_NUMA
p->mempolicy = mpol_dup(p->mempolicy);
if (IS_ERR(p->mempolicy)) {
retval = PTR_ERR(p->mempolicy);
p->mempolicy = NULL;
goto bad_fork_cleanup_cgroup;
}
mpol_fix_fork_child_flag(p);
#endif
#ifdef CONFIG_TRACE_IRQFLAGS
p->irq_events = 0;
#ifdef __ARCH_WANT_INTERRUPTS_ON_CTXSW
p->hardirqs_enabled = 1;
#else
p->hardirqs_enabled = 0;
#endif
p->hardirq_enable_ip = 0;
p->hardirq_enable_event = 0;
p->hardirq_disable_ip = _THIS_IP_;
p->hardirq_disable_event = 0;
p->softirqs_enabled = 1;
p->softirq_enable_ip = _THIS_IP_;
p->softirq_enable_event = 0;
p->softirq_disable_ip = 0;
p->softirq_disable_event = 0;
p->hardirq_context = 0;
p->softirq_context = 0;
#endif
#ifdef CONFIG_LOCKDEP
p->lockdep_depth = 0; /* no locks held yet */
p->curr_chain_key = 0;
p->lockdep_recursion = 0;
#endif
#ifdef CONFIG_DEBUG_MUTEXES
p->blocked_on = NULL; /* not blocked yet */
#endif
p->bts = NULL;
p->stack_start = stack_start;
/* Perform scheduler related setup. Assign this task to a CPU. */
sched_fork(p, clone_flags);
retval = perf_event_init_task(p);
if (retval)
goto bad_fork_cleanup_policy;
if ((retval = audit_alloc(p)))
goto bad_fork_cleanup_policy;
/* copy all the process information */
if ((retval = copy_semundo(clone_flags, p)))
goto bad_fork_cleanup_audit;
if ((retval = copy_files(clone_flags, p)))
goto bad_fork_cleanup_semundo;
if ((retval = copy_fs(clone_flags, p)))
goto bad_fork_cleanup_files;
if ((retval = copy_sighand(clone_flags, p)))
goto bad_fork_cleanup_fs;
if ((retval = copy_signal(clone_flags, p)))
goto bad_fork_cleanup_sighand;
if ((retval = copy_mm(clone_flags, p)))
goto bad_fork_cleanup_signal;
if ((retval = copy_namespaces(clone_flags, p)))
goto bad_fork_cleanup_mm;
if ((retval = copy_io(clone_flags, p)))
goto bad_fork_cleanup_namespaces;
retval = copy_thread(clone_flags, stack_start, stack_size, p, regs);
if (retval)
goto bad_fork_cleanup_io;
if (pid != &init_struct_pid) {
retval = -ENOMEM;
pid = alloc_pid(p->nsproxy->pid_ns);
if (!pid)
goto bad_fork_cleanup_io;
if (clone_flags & CLONE_NEWPID) {
retval = pid_ns_prepare_proc(p->nsproxy->pid_ns);
if (retval < 0)
goto bad_fork_free_pid;
}
}
p->pid = pid_nr(pid);
p->tgid = p->pid;
if (clone_flags & CLONE_THREAD)
p->tgid = current->tgid;
if (current->nsproxy != p->nsproxy) {
retval = ns_cgroup_clone(p, pid);
if (retval)
goto bad_fork_free_pid;
}
p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ? child_tidptr : NULL;
/*
* Clear TID on mm_release()?
*/
p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? child_tidptr: NULL;
#ifdef CONFIG_FUTEX
p->robust_list = NULL;
#ifdef CONFIG_COMPAT
p->compat_robust_list = NULL;
#endif
INIT_LIST_HEAD(&p->pi_state_list);
p->pi_state_cache = NULL;
#endif
/*
* sigaltstack should be cleared when sharing the same VM
*/
if ((clone_flags & (CLONE_VM|CLONE_VFORK)) == CLONE_VM)
p->sas_ss_sp = p->sas_ss_size = 0;
/*
* Syscall tracing should be turned off in the child regardless
* of CLONE_PTRACE.
*/
clear_tsk_thread_flag(p, TIF_SYSCALL_TRACE);
#ifdef TIF_SYSCALL_EMU
clear_tsk_thread_flag(p, TIF_SYSCALL_EMU);
#endif
clear_all_latency_tracing(p);
/* ok, now we should be set up.. */
p->exit_signal = (clone_flags & CLONE_THREAD) ? -1 : (clone_flags & CSIGNAL);
p->pdeath_signal = 0;
p->exit_state = 0;
/*
* Ok, make it visible to the rest of the system.
* We dont wake it up yet.
*/
p->group_leader = p;
INIT_LIST_HEAD(&p->thread_group);
/* Now that the task is set up, run cgroup callbacks if
* necessary. We need to run them before the task is visible
* on the tasklist. */
cgroup_fork_callbacks(p);
cgroup_callbacks_done = 1;
/* Need tasklist lock for parent etc handling! */
write_lock_irq(&tasklist_lock);
/*
* The task hasn't been attached yet, so its cpus_allowed mask will
* not be changed, nor will its assigned CPU.
*
* The cpus_allowed mask of the parent may have changed after it was
* copied first time - so re-copy it here, then check the child's CPU
* to ensure it is on a valid CPU (and if not, just force it back to
* parent's CPU). This avoids alot of nasty races.
*/
p->cpus_allowed = current->cpus_allowed;
p->rt.nr_cpus_allowed = current->rt.nr_cpus_allowed;
if (unlikely(!cpu_isset(task_cpu(p), p->cpus_allowed) ||
!cpu_online(task_cpu(p))))
set_task_cpu(p, smp_processor_id());
/* CLONE_PARENT re-uses the old parent */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent;
p->parent_exec_id = current->parent_exec_id;
} else {
p->real_parent = current;
p->parent_exec_id = current->self_exec_id;
}
spin_lock(¤t->sighand->siglock);
/*
* Process group and session signals need to be delivered to just the
* parent before the fork or both the parent and the child after the
* fork. Restart if a signal comes in before we add the new process to
* it's process group.
* A fatal signal pending means that current will exit, so the new
* thread can't slip out of an OOM kill (or normal SIGKILL).
*/
recalc_sigpending();
if (signal_pending(current)) {
spin_unlock(¤t->sighand->siglock);
write_unlock_irq(&tasklist_lock);
retval = -ERESTARTNOINTR;
goto bad_fork_free_pid;
}
if (clone_flags & CLONE_THREAD) {
atomic_inc(¤t->signal->count);
atomic_inc(¤t->signal->live);
p->group_leader = current->group_leader;
list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group);
}
if (likely(p->pid)) {
list_add_tail(&p->sibling, &p->real_parent->children);
tracehook_finish_clone(p, clone_flags, trace);
if (thread_group_leader(p)) {
if (clone_flags & CLONE_NEWPID)
p->nsproxy->pid_ns->child_reaper = p;
p->signal->leader_pid = pid;
tty_kref_put(p->signal->tty);
p->signal->tty = tty_kref_get(current->signal->tty);
attach_pid(p, PIDTYPE_PGID, task_pgrp(current));
attach_pid(p, PIDTYPE_SID, task_session(current));
list_add_tail_rcu(&p->tasks, &init_task.tasks);
__get_cpu_var(process_counts)++;
}
attach_pid(p, PIDTYPE_PID, pid);
nr_threads++;
}
total_forks++;
spin_unlock(¤t->sighand->siglock);
write_unlock_irq(&tasklist_lock);
proc_fork_connector(p);
cgroup_post_fork(p);
perf_event_fork(p);
return p;
bad_fork_free_pid:
if (pid != &init_struct_pid)
free_pid(pid);
bad_fork_cleanup_io:
put_io_context(p->io_context);
bad_fork_cleanup_namespaces:
exit_task_namespaces(p);
bad_fork_cleanup_mm:
if (p->mm)
mmput(p->mm);
bad_fork_cleanup_signal:
if (!(clone_flags & CLONE_THREAD))
__cleanup_signal(p->signal);
bad_fork_cleanup_sighand:
__cleanup_sighand(p->sighand);
bad_fork_cleanup_fs:
exit_fs(p); /* blocking */
bad_fork_cleanup_files:
exit_files(p); /* blocking */
bad_fork_cleanup_semundo:
exit_sem(p);
bad_fork_cleanup_audit:
audit_free(p);
bad_fork_cleanup_policy:
perf_event_free_task(p);
#ifdef CONFIG_NUMA
mpol_put(p->mempolicy);
bad_fork_cleanup_cgroup:
#endif
cgroup_exit(p, cgroup_callbacks_done);
delayacct_tsk_free(p);
module_put(task_thread_info(p)->exec_domain->module);
bad_fork_cleanup_count:
atomic_dec(&p->cred->user->processes);
exit_creds(p);
bad_fork_free:
free_task(p);
fork_out:
return ERR_PTR(retval);
}

上面执行完以后,回到do_fork()函数,如果copy_process()函数成功返回。新创建的子进程被唤醒并让其投入运行。内核有意选择子进程先运行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销。如果父进程首先执行的话,有可能会开始向地址空间写入。

线程机制提供了在同一程序内共享内存地址空间运行的一组线程。线程机制支持并发程序设计技术,可以共享打开的文件和其他资源。如果你的系统是多核心的,那多线程技术可保证系统的真正并行。在Linux中,并没有线程这个概念,Linux中所有的线程都当作进程来处理,换句话说就是在内核中并没有什么特殊的结构和算法来表示线程。在Linux中,线程仅仅是一个使用共享资源的进程。每个线程都拥有一个隶属于自己的task_struct。所以说线程本质上还是进程,只不过该进程可以和其他一些进程共享某些资源信息。

内核有时需要在后台执行一些操作,这种任务可以通过内核线程完成,内核线程独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间。它们只在讷河空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。内核线程也只能由其它内核线程创建,内核是通过从kthreadd内核进程中衍生出所有新的内核线程来自动处理这一点的。在内核中创建一个的内核线程方法如下:

1
2
3
struct task_struct *kthread_create(int (*threadfn)(void *data),
void *data,
const char namefmt[], ...)

该函数实现如下:
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
struct task_struct *kthread_create(int (*threadfn)(void *data),
void *data,
const char namefmt[],
...)
{
struct kthread_create_info create;
create.threadfn = threadfn;
create.data = data;
init_completion(&create.done);
spin_lock(&kthread_create_lock);
list_add_tail(&create.list, &kthread_create_list);
spin_unlock(&kthread_create_lock);
wake_up_process(kthreadd_task);
wait_for_completion(&create.done);
if (!IS_ERR(create.result)) {
struct sched_param param = { .sched_priority = 0 };
va_list args;
va_start(args, namefmt);
vsnprintf(create.result->comm, sizeof(create.result->comm),
namefmt, args);
va_end(args);
/*
* root may have changed our (kthreadd's) priority or CPU mask.
* The kernel thread should not inherit these properties.
*/
sched_setscheduler_nocheck(create.result, SCHED_NORMAL, ¶m);
set_cpus_allowed_ptr(create.result, cpu_all_mask);
}

新的任务是由kthread内核进程通过clone()系统调用而创建的。新的进程将运行threadfn函数,给其传递参数data,新的进程名称为namefmt,新创建的进程处于不可运行状态,需要调用wake_up_process()明确的唤醒它,否则它不会主动运行。也可以通过调用kthread_run()来创建一个进程并让它运行起来。

1
2
3
4
5
6
7
8
#define kthread_run(threadfn, data, namefmt, ...)              \                        
({ \
struct task_struct *__k \
= kthread_create(threadfn, data, namefmt, ## __VA_ARGS__); \
if (!IS_ERR(__k)) \
wake_up_process(__k); \
__k; \
})

kthread_run其实就是创建了一个内核线程并且唤醒了。内核线程启动后就一直运行直到调用do_exit()退出或者内核的其他部分调用kthread_stop()退出。

1
int kthread_stop(struct task_struct *k); 

下面为一个使用内核线程的示例:
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
#include <linux/module.h>
#include <linux/init.h>
#include <linux/version.h>
#include <linux/sched.h> //schdule_timeout()
#include <linux/kthread.h>
struct task_struct *sln_task;
int sln_kthread_func(void *arg)
{
while (!kthread_should_stop()) {
printk(KERN_ALERT"===%s===\n", __func__);
set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(2*HZ);
}
return 0;
}
void sln_init_do(void)
{
int data = 9527;
sln_task = kthread_create(sln_kthread_func,
&data,
"sln_kthread_task");
if (IS_ERR(sln_task)) {
printk(KERN_ALERT"kthread_create() failed!\n");
return;
}
wake_up_process(sln_task);
}
void sln_exit_do(void)
{
if (NULL != sln_task) {
kthread_stop(sln_task);
sln_task = NULL;
}
}
static int __init sln_init(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
sln_init_do();
return 0;
}
static void __exit sln_exit(void)
{
printk(KERN_ALERT"===%s===\n", __func__);
sln_exit_do();
}
module_init(sln_init);
module_exit(sln_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("shallnet");
MODULE_DESCRIPTION("blog.csdn.net/shallnet");

既然有进程的创建,那就有进程的终结,终结时内核必须释放它所占有的资源。内核终结时,大部分任务都是靠do_exit()来完成。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
NORET_TYPE void do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
profile_task_exit(tsk);
WARN_ON(atomic_read(&tsk->fs_excl));
//不可在中断上下文中使用该函数
if (unlikely(in_interrupt()))
panic("Aiee, killing interrupt handler!");
if (unlikely(!tsk->pid))
panic("Attempted to kill the idle task!");
tracehook_report_exit(&code);
validate_creds_for_do_exit(tsk);
/*
* We're taking recursive faults here in do_exit. Safest is to just
* leave this task alone and wait for reboot.
*/
if (unlikely(tsk->flags & PF_EXITING)) {
printk(KERN_ALERT
"Fixing recursive fault but reboot is needed!\n");

//设置PF_EXITING:表示进程正在退出
tsk->flags |= PF_EXITPIDONE;
set_current_state(TASK_UNINTERRUPTIBLE);
schedule();
}
exit_irq_thread();
exit_signals(tsk); /* sets PF_EXITING */
/*
* tsk->flags are checked in the futex code to protect against
* an exiting task cleaning up the robust pi futexes.
*/
smp_mb();
spin_unlock_wait(&tsk->pi_lock);
if (unlikely(in_atomic()))
printk(KERN_INFO "note: %s[%d] exited with preempt_count %d\n",
current->comm, task_pid_nr(current),
preempt_count());
acct_update_integrals(tsk);
group_dead = atomic_dec_and_test(&tsk->signal->live);
if (group_dead) {
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk->signal);
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
}
acct_collect(code, group_dead);
if (group_dead)
tty_audit_exit();
if (unlikely(tsk->audit_context))
audit_free(tsk);
tsk->exit_code = code;
taskstats_exit(tsk, group_dead);
//调用__exit_mm()函数放弃进程占用的mm_struct,如果没有别的进程使用它们即没被共享,就彻底释放它们
exit_mm(tsk);
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
exit_sem(tsk); //调用sem_exit()函数。如果进程排队等候IPC信号,它则离开队列
//分别递减文件描述符,文件系统数据等的引用计数。当引用计数的值为0时,就代表没有进程在使用这些资源,此时就释放
exit_files(tsk);
exit_fs(tsk);
check_stack_usage();
exit_thread();
cgroup_exit(tsk, 1);
if (group_dead && tsk->signal->leader)
disassociate_ctty(1);
module_put(task_thread_info(tsk)->exec_domain->module);
proc_exit_connector(tsk);
/*
* Flush inherited counters to the parent - before the parent
* gets woken up by child-exit notifications.
*/
perf_event_exit_task(tsk);
//调用exit_notify()向父进程发送信号,将子进程的父进程重新设置为线程组中的其他线程或init进程,并把进程状态设为TASK_ZOMBIE.
exit_notify(tsk, group_dead);
#ifdef CONFIG_NUMA
mpol_put(tsk->mempolicy);
tsk->mempolicy = NULL;
#endif
#ifdef CONFIG_FUTEX
if (unlikely(current->pi_state_cache))
kfree(current->pi_state_cache);
#endif
/*
* Make sure we are holding no locks:
*/
debug_check_no_locks_held(tsk);
/*
* We can do this unlocked here. The futex code uses this flag
* just to verify whether the pi state cleanup has been done
* or not. In the worst case it loops once more.
*/
tsk->flags |= PF_EXITPIDONE;
if (tsk->io_context)
exit_io_context();
if (tsk->splice_pipe)
__free_pipe_info(tsk->splice_pipe);
validate_creds_for_do_exit(tsk);
preempt_disable();
exit_rcu();
/* causes final put_task_struct in finish_task_switch(). */
tsk->state = TASK_DEAD;
schedule(); //调用`schedule()切换到其他进程
BUG();
/* Avoid "noreturn function does return". */
for (;;)
cpu_relax(); /* For when BUG is null */
}

进程终结时所需的清理工作和进程描述符的删除被分开执行,这样尽管在调用了do_exit()之后,线程已经僵死不能允许情况下,系统还是保留了它的进程描述符。在父进程获得已经终结的子进程信息后,子进程的task_struct结构才被释放。Linux中有一系列wait()函数,这些函数都是基于系统调用wait4()实现的。它的动作就是挂起调用它的进程直到其中的一个子进程退出,此时函数会返回该退出子进程的PID。 最终释放进程描述符时,会调用release_task()

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
void release_task(struct task_struct * p)
{
struct task_struct *leader;
int zap_leader;
repeat:
tracehook_prepare_release_task(p);
/* don't need to get the RCU readlock here - the process is dead and
* can't be modifying its own credentials */
atomic_dec(&__task_cred(p)->user->processes);
proc_flush_task(p);
write_lock_irq(&tasklist_lock);
tracehook_finish_release_task(p);
__exit_signal(p); //释放目前僵死进程所使用的所有剩余资源,并进行统计记录

zap_leader = 0;
leader = p->group_leader;
//如果进程是线程组最后一个进程,并且领头进程已经死掉,那么就通知僵死的领头进程的父进程
if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE)
{
BUG_ON(task_detached(leader));
do_notify_parent(leader, leader->exit_signal);
zap_leader = task_detached(leader);

if (zap_leader)
leader->exit_state = EXIT_DEAD;
}
write_unlock_irq(&tasklist_lock);
release_thread(p);
call_rcu(&p->rcu, delayed_put_task_struct);
p = leader;
if (unlikely(zap_leader))
goto repeat;
}

子进程不一定能保证在父进程前边退出,所以必须要有机制来保证子进程在这种情况下能找到一个新的父进程。否则的话,这些成为孤儿的进程就会在退出时永远处于僵死状态,白白的耗费内存。解决这个问题的办法,就是给子进程在当前线程组内找一个线程作为父亲。一旦系统给进程成功地找到和设置了新的父进程,就不会再有出现驻留僵死进程的危险了,init进程会例行调用wait()来等待子进程,清除所有与其相关的僵死进程。

进程调度

Linux为多任务系统,正常情况下都存在成百上千个任务。由于Linux提供抢占式的多任务模式,所以Linux能同时并发地交互执行多个进程,而调度程序将决定哪一个进程投入运行、何时运行、以及运行多长时间。调度程序是像Linux这样的多任务操作系统的基础,只有通过调度程序的合理调度,系统资源才能最大限度地发挥作用,多进程才会有并发执行的效果。当系统中可运行的进程数目比处理器的个数多,就注定在某一时刻有一些进程不能执行,这些不能执行的进程在等待执行。调度程序的基本工作就是停止一个进程的运行,再在这些等待执行的进程中选择一个来执行。

调度程序停止一个进程的运行,再选择一个另外进程的动作开始运行的动作被称作抢占(preemption)。一个进程在被抢占之前能够运行的时间是预先设置好的,这个预先设置好的时间就是进程的的时间片(timeslice)。时间片就是分配给每个可运行进程的处理器时间段,它表明进程在被抢占前所能持续运行时间。

处理器的调度策略决定调度程序在何时让什么进程投入运行。调度策略通常需要在进程响应迅速(相应时间短)和进程吞吐量高之间寻找平衡。所以调度程序通常采用一套非常复杂的算法来决定最值得运行的进程投入运行。调度算法中最基本的一类当然就是基于优先级的调度,也就是说优先级高的先运行,相同优先级的按轮转式进行调度。优先级高 的进程使用的时间片也长。调度程序总是选择时间片未用尽且优先级最高的进程运行。这句话就是说用户和系统可以通过设置进程的优先级来响应系统的调度。基于此,Linux设计上一套动态优先级的调度方法。一开始,先为进程设置一个基本的优先级,然而它允许调度程序根据需要来加减优先级。Linux内核提供了两组独立的优先级范围。第一种是nice值,范围从-20到19,默认为0。nice值越大优先级越小。另外nice值也用来决定分配给进程时间片的长短。Linux下通过命令可以查看进程对应nice值,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ ps -el

F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
4 S 0 1 0 0 80 0 - 725 ? ? 00:00:01 init
1 S 0 2 0 0 80 0 - 0 ? ? 00:00:00 kthreadd
1 S 0 3 2 0 -40 - - 0 ? ? 00:00:01 migration/0
1 S 0 4 2 0 80 0 - 0 ? ? 00:00:00 ksoftirqd/0
1 S 0 9 2 0 80 0 - 0 ? ? 00:00:00 ksoftirqd/1

......

1 S 0 39 2 0 85 5 - 0 ? ? 00:00:00 ksmd
......

1 S 0 156 2 0 75 -5 - 0 ? ? 00:00:00 kslowd000
1 S 0 157 2 0 75 -5 - 0 ? ? 00:00:00 kslowd001
......
4 S 499 2951 1 0 81 1 - 6276 ? ? 00:00:00 rtkit-daemon
......

第二种范围是实时优先级,默认范围是从0到99。任何实时的优先级都高于普通优先级。

进程执行时,它会根据具体情况改变状态,进程状态是调度和对换的依据。Linux 将进程状态分为五种: TASK_RUNNINGTASK_INTERRUPTIBLETASK_UNINTERRUPTIBLETASK_STOPPEDTASK_ZOMBILE。进程的状态随着进程的调度发生改变 。

状态
TASK_RUNNING 可运行
TASK_INTERRUPTIBLE 可中断的等待状态
TASK_UNINTERRUPTIBLE 不可中断的等待状态
TASK_STOPPED 停止状态
TASK_TRACED 被跟踪状态

TASK_RUNNING (运行):无论进程是否正在占用 CPU ,只要具备运行条件,都处于该状态。 Linux 把处于该状态的所有 PCB 组织成一个可运行队列 run_queue ,调度程序从这个队列中选择进程运行。事实上,Linux 是将就绪态和运行态合并为了一种状态。

TASK_INTERRUPTIBLE (可中断阻塞): Linux 将阻塞态划分成 TASK_INTERRUPTIBLE 、 TASK_UNINTERRUPTIBLE 、 TASK_STOPPED 三种不同的状态。处于 TASK_INTERRUPTIBLE 状态的进程在资源有效时被唤醒,也可以通过信号或定时中断唤醒。

TASK_UNINTERRUPTIBLE (不可中断阻塞):另一种阻塞状态,处于该状态的进程只有当资源有效时被唤醒,不能通过信号或定时中断唤醒。在执行ps命令时,进程状态为D且不能被杀死。

TASK_STOPPED (停止):第三种阻塞状态,处于该状态的进程只能通过其他进程的信号才能唤醒。

TASK_TRACED (被跟踪):进程正在被另一个进程监视,比如在调试的时候。

我们在设置这些状态的时候是可以直接用语句进行的比如:p—>state = TASK_RUNNING。同时内核也会使用set_task_state()set_current_state()函数来进行。

Linux调度器是以模块方式提供的,这样允许不同类型的进程可以有针对性地选择调度算法。完全公平调度(CFS)是针对普通进程的调度类,CFS采用的方法是对时间片分配方式进行根本性的重新设计,完全摒弃时间片而是分配给进程一个处理器使用比重。通过这种方式,CFS确保了进程调度中能有恒定的公平性,而将切换频率置于不断变动之中。

与Linux 2.6之前调度器不同,2.6版本内核的CFS没有将任务维护在链表式的运行队列中,它抛弃了active/expire数组,而是对每个CPU维护一个以时间为顺序的红黑树。该树方法能够良好运行的原因在于:

  • 红黑树可以始终保持平衡,这意味着树上没有路径比任何其他路径长两倍以上。
  • 由于红黑树是二叉树,查找操作的时间复杂度为`O(log n)。但是除了最左侧查找以外,很难执行其他查找,并且最左侧的节点指针始终被缓存。
  • 对于大多数操作(插入、删除、查找等),红黑树的执行时间为O(log n),而以前的调度程序通过具有固定优先级的优先级数组使用O(1)O(log n)行为具有可测量的延迟,但是对于较大的任务数无关紧要。
  • 红黑树可通过内部存储实现,即不需要使用外部分配即可对数据结构进行维护。

要实现平衡,CFS使用“虚拟运行时”表示某个任务的时间量。任务的虚拟运行时越小,意味着任务被允许访问服务器的时间越短,其对处理器的需求越高。CFS还包含睡眠公平概念以便确保那些目前没有运行的任务(例如,等待 I/O)在其最终需要时获得相当份额的处理器。

对于实时进程,Linux 采用了两种调度策略,即先来先服务调度( First-In, First-Out , FIFO )和时间片轮转调度( Round Robin , RR )。因为实时进程具有一定程度的紧迫性,所以衡量一个实时进程是否应该运行,Linux 采用了一个比较固定的标准。

下面是调度相关的一些数据结构:调度实体:struct sched_entity

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
60
61
62
63
64
struct sched_entity {
struct load_weight load; /* for load-balancing */
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;

u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;

u64 last_wakeup;
u64 avg_overlap;

u64 nr_migrations;

u64 start_runtime;
u64 avg_wakeup;

u64 avg_running;

#ifdef CONFIG_SCHEDSTATS
u64 wait_start;
u64 wait_max;
u64 wait_count;
u64 wait_sum;
u64 iowait_count;
u64 iowait_sum;

u64 sleep_start;
u64 sleep_max;
s64 sum_sleep_runtime;

u64 block_start;
u64 block_max;
u64 exec_max;
u64 slice_max;

u64 nr_migrations_cold;
u64 nr_failed_migrations_affine;
u64 nr_failed_migrations_running;
u64 nr_failed_migrations_hot;
u64 nr_forced_migrations;
u64 nr_forced2_migrations;

u64 nr_wakeups;
u64 nr_wakeups_sync;
u64 nr_wakeups_migrate;
u64 nr_wakeups_local;
u64 nr_wakeups_remote;
u64 nr_wakeups_affine;
u64 nr_wakeups_affine_attempts;
u64 nr_wakeups_passive;
u64 nr_wakeups_idle;
#endif

#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity *parent;
/* rq on which this entity is (to be) queued: */
struct cfs_rq *cfs_rq;
/* rq "owned" by this entity/group: */
struct cfs_rq *my_q;
#endif
};

该结构在./linux/include/linux/sched.h中,表示一个可调度实体(进程,进程组,等等)。它包含了完整的调度信息,用于实现对单个任务或任务组的调度。调度实体可能与进程没有关联。这里包括负载权重load、对应的红黑树结点run_node、虚拟运行时vruntime(表示进程的运行时间,并作为红黑树的索引)、开始执行时间、最后唤醒时间、各种统计数据、用于组调度的CFS运行队列信息cfs_rq,等等。

调度类:struct sched_class。该调度类也在sched.h中,是对调度器操作的面向对象抽象,协助内核调度程序的各种工作。调度类是调度器管理器的核心,每种调度算法模块需要实现struct sched_class建议的一组函数。

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
struct sched_class {
const struct sched_class *next;

void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup);
void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep);
void (*yield_task) (struct rq *rq);

void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);

struct task_struct * (*pick_next_task) (struct rq *rq);
void (*put_prev_task) (struct rq *rq, struct task_struct *p);

#ifdef CONFIG_SMP
int (*select_task_rq)(struct task_struct *p, int sd_flag, int flags);

unsigned long (*load_balance) (struct rq *this_rq, int this_cpu,
struct rq *busiest, unsigned long max_load_move,
struct sched_domain *sd, enum cpu_idle_type idle,
int *all_pinned, int *this_best_prio);

int (*move_one_task) (struct rq *this_rq, int this_cpu,
struct rq *busiest, struct sched_domain *sd,
enum cpu_idle_type idle);
void (*pre_schedule) (struct rq *this_rq, struct task_struct *task);
void (*post_schedule) (struct rq *this_rq);
void (*task_wake_up) (struct rq *this_rq, struct task_struct *task);

void (*set_cpus_allowed)(struct task_struct *p,
const struct cpumask *newmask);

void (*rq_online)(struct rq *rq);
void (*rq_offline)(struct rq *rq);
#endif

void (*set_curr_task) (struct rq *rq);
void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);
void (*task_new) (struct rq *rq, struct task_struct *p);

void (*switched_from) (struct rq *this_rq, struct task_struct *task,
int running);
void (*switched_to) (struct rq *this_rq, struct task_struct *task,
int running);
void (*prio_changed) (struct rq *this_rq, struct task_struct *task,
int oldprio, int running);

unsigned int (*get_rr_interval) (struct task_struct *task);

#ifdef CONFIG_FAIR_GROUP_SCHED
void (*moved_group) (struct task_struct *p);
#endif
};

其中的主要函数:

  • enqueue_task:当某个任务进入可运行状态时,该函数将得到调用。它将调度实体(进程)放入红黑树中,并对nr_running变量加 1。从前面“Linux进程管理”的分析中可知,进程创建的最后会调用该函数。
  • dequeue_task:当某个任务退出可运行状态时调用该函数,它将从红黑树中去掉对应的调度实体,并从nr_running变量中减 1。
  • yield_task:在compat_yield sysctl关闭的情况下,该函数实际上执行先出队后入队;在这种情况下,它将调度实体放在红黑树的最右端。
  • check_preempt_curr:该函数将检查当前运行的任务是否被抢占。在实际抢占正在运行的任务之前,CFS 调度程序模块将执行公平性测试。这将驱动唤醒式(wakeup)抢占。
  • pick_next_task:该函数选择接下来要运行的最合适的进程。
  • load_balance:每个调度程序模块实现两个函数,load_balance_start()load_balance_next(),使用这两个函数实现一个迭代器,在模块的load_balance例程中调用。内核调度程序使用这种方法实现由调度模块管理的进程的负载平衡。
  • set_curr_task:当任务修改其调度类或修改其任务组时,将调用这个函数。
  • task_tick:该函数通常调用自 time tick 函数;它可能引起进程切换。这将驱动运行时(running)抢占。

调度类的引入是接口和实现分离的设计典范,你可以实现不同的调度算法(例如普通进程和实时进程的调度算法就不一样),但由于有统一的接口,使得调度策略 被模块化,一个Linux调度程序可以有多个不同的调度策略。调度类显著增强了内核调度程序的可扩展性。每个任务都属于一个调度类,这决定了任务将如何调 度。 调度类定义一个通用函数集,函数集定义调度器的行为。例如,每个调度器提供一种方式,添加要调度的任务、调出要运行的下一个任务、提供给调度器等等。每个 调度器类都在一对一连接的列表中彼此相连,使类可以迭代(例如,要启用给定处理器的禁用)。注意,将任务函数加入队列或脱离队列只需从特定调度结构中加入或移除任务。 核心函数 pick_next_task 选择要执行的下一个任务(取决于调度类的具体策略)。

sched_rt.csched_fair.csched_idletask.c等(都在kernel/目录下)就是不同的调度算法实现。不要忘了调度类是任务结构本身的一部分(参见 task_struct)。这一点简化了任务的操作,无论其调度类如何。因为进程描述符中有sched_class引用,这样就可以直接通过进程描述符来 调用调度类中的各种操作。在调度类中,随着调度域的增加,其功能也在增加。 这些域允许您出于负载平衡和隔离的目的将一个或多个处理器按层次关系分组。 一个或多个处理器能够共享调度策略(并在其之间保持负载平衡),或实现独立的调度策略。

可运行队列:struct rq。调度程序每次在进程发生切换时,都要从可运行队列中选取一个最佳的进程来运行。Linux内核使用rq数据结构(以前的内核中该结构为 runqueue)表示一个可运行队列信息(也就是就绪队列),每个CPU都有且只有一个这样的结构。该结构在kernel/sched.c中,不仅描述了每个处理器中处于可运行状态(TASK_RUNNING),而且还描述了该处理器的调度信息。如下:

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
struct rq {
/* runqueue lock: */
spinlock_t lock;

unsigned long nr_running;
#define CPU_LOAD_IDX_MAX 5
unsigned long cpu_load[CPU_LOAD_IDX_MAX];

/* capture load from *all* tasks on this cpu: */
struct load_weight load;
unsigned long nr_load_updates;
u64 nr_switches;
u64 nr_migrations_in;

struct cfs_rq cfs;
struct rt_rq rt;

unsigned long nr_uninterruptible;

struct task_struct *curr, *idle;
unsigned long next_balance;
struct mm_struct *prev_mm;

u64 clock;

atomic_t nr_iowait;


/* calc_load related fields */
unsigned long calc_load_update;
long calc_load_active;

......
};

进程调度的入口点是函数schedule(),该函数调用pick_next_task()pick_next_task()会以优先级为序,从高到低,一次检查每一个调度类,且从最高优先级的调度类中,选择最高优先级的进程。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
asmlinkage void __sched schedule(void)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq *rq;
int cpu;

need_resched:
preempt_disable();
cpu = smp_processor_id();
rq = cpu_rq(cpu);
rcu_sched_qs(cpu);
prev = rq->curr;
switch_count = &prev->nivcsw;

release_kernel_lock(prev);
need_resched_nonpreemptible:

schedule_debug(prev);

if (sched_feat(HRTICK))
hrtick_clear(rq);

spin_lock_irq(&rq->lock);
update_rq_clock(rq);
clear_tsk_need_resched(prev);

if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) {
if (unlikely(signal_pending_state(prev->state, prev)))
prev->state = TASK_RUNNING;
else
deactivate_task(rq, prev, 1);
switch_count = &prev->nvcsw;
}

pre_schedule(rq, prev);

if (unlikely(!rq->nr_running))
idle_balance(cpu, rq);

put_prev_task(rq, prev);
<strong>next = pick_next_task(rq);    //<span><span><span style="font-size:14px;">//挑选最高优先级别的任务</span></span></span></strong>

if (likely(prev != next)) {
sched_info_switch(prev, next);
perf_event_task_sched_out(prev, next, cpu);

rq->nr_switches++;
rq->curr = next;
++*switch_count;

context_switch(rq, prev, next); /* unlocks the rq */
/*
* the context switch might have flipped the stack from under
* us, hence refresh the local variables.
*/
cpu = smp_processor_id();
rq = cpu_rq(cpu);
} else
spin_unlock_irq(&rq->lock);

post_schedule(rq);

if (unlikely(reacquire_kernel_lock(current) < 0))
goto need_resched_nonpreemptible;

preempt_enable_no_resched();
if (need_resched())
goto need_resched;
}

static inline struct task_struct *
pick_next_task(struct rq *rq)
{
const struct sched_class *class;
struct task_struct *p;

/*
* Optimization: we know that if all tasks are in
* the fair class we can call that function directly:
*/
if (likely(rq->nr_running == rq->cfs.nr_running)) {
p = fair_sched_class.pick_next_task(rq);
if (likely(p))
return p;
}
//从最高优先级类开始,遍历每一个调度类。每一个调度类都实现了pick_next_task,他会返回指向下一个可运行进程的指针,没有时返回NULL。
class = sched_class_highest;
for ( ; ; ) {
p = class->pick_next_task(rq);
if (p)
return p;
/*
* Will never be NULL as the idle class always
* returns a non-NULL p:
*/
class = class->next;
}
}

被阻塞(休眠)的进程处于不可执行状态,是不能被调度的。进程休眠一般是由于等待一些事件,内核首先把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒的过程刚好相反,进程设置为可执行状态,然后从等待队列中移到可执行红黑树中。

等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head_t来代表队列。进程把自己放入等待队列中并设置成不可执状态。当等待队列相关事件发生时,队列上进程会被唤醒。函数inotify_read()是实现等待队列的一个典型用法:

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
static ssize_t inotify_read(struct file *file, char __user *buf,
size_t count, loff_t *pos)
{
struct fsnotify_group *group;
struct fsnotify_event *kevent;
char __user *start;
int ret;
DEFINE_WAIT(wait);

start = buf;
group = file->private_data;

while (1) {

//进程的状态变更为`TASK_INTERRUPTIBLE`或`TASK_UNINTERRUPTIBLE。
prepare_to_wait(&group->notification_waitq, &wait, TASK_INTERRUPTIBLE);

mutex_lock(&group->notification_mutex);
kevent = get_one_event(group, count);
mutex_unlock(&group->notification_mutex);

if (kevent) {
ret = PTR_ERR(kevent);
if (IS_ERR(kevent))
break;
ret = copy_event_to_user(group, kevent, buf);
fsnotify_put_event(kevent);
if (ret < 0)
break;
buf += ret;
count -= ret;
continue;
}

ret = -EAGAIN;
if (file->f_flags & O_NONBLOCK)
break;
ret = -EINTR;
if (signal_pending(current))
break;

if (start != buf)
break;

schedule();
}

finish_wait(&group->notification_waitq, &wait);
if (start != buf && ret != -EFAULT)
ret = buf - start;
return ret;
}

唤醒是通过wake_up()进行。她唤醒指定的等待队列上 的所有进程。它调用try_to_wake_up,该函数负责将进程设置为TASK_RUNNING状态,调用active_task()将此进程放入可 执行队列,如果被唤醒进程的优先级比当前正在执行的进程的优先级高,还要设置need_resched标志。

上下文切换,就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/sched.ccontext_switch函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule`就会调用该函数。它主要完成如下两个工作:

  1. 调用定义在include/asm/mmu_context.h中的switch_mm()。该函数负责把虚拟内存从上一个进程映射切换到新进程中。
  2. 调用定义在include/asm/system.hswitch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态,这包括保存,恢复栈信息和寄存器信息。

内核也必须知道什么时候调用schedule(),单靠用户代码显示调用schedule(),他们可能就会永远地执行下去,相反,内核提供了一个need_resched标志来表明是否需要重新执行一次调度。当 某个进程耗尽它的时间片时,scheduler_tick()就会设置这个标志,当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志。内核检查该标志,确认其被设置,调用schedule()来切换到一个新的进程。该标志对内核来讲是一个信息,它表示应当有其他进程应当被运行了。

用于访问和操作need_resched的函数:

  • set_tsk_need_resched(task):设置指定进程中的need_resched标志
  • clear_tsk_need_resched(task):清除指定进程中的nedd_resched标志
  • need_resched():检查need_resched标志的值,如果被设置就返回真,否则返回

在返回用户空间以及从中断返回的时候,内核也会检查need_resched标志,如果已被设置,内核会在继续执行之前调用该调度程序。最后,每个进程都包含一个need_resched标志,这是因为访 问进程描述符内的数值要比访问一个全局变量要快(因为current宏速度很快并且描述符通常都在高速缓存中)。在2.6内核中,他被移到了thread_info结构体里。

用户抢占发生在:

  1. 从系统调用返回时;
  2. 从终端处理程序返回用户空间时。

内核抢占发生在:

  1. 中断处理正在执行,且返回内核空间前;
  2. 内核代码再一次具有可抢占性的时候;
  3. 内核任务显式调用schedule()函数;
  4. 内核中的任务阻塞的时候。

内核同步

如同Linux应用一样,内核的共享资源也要防止并发,因为如果多个执行线程同时访问和操作数据有可能发生各个线程之间相互覆盖共享数据的情况。

在Linux只是单一处理器的时候,只有在中断发生或内核请求重新调度执行另一个任务时,数据才可能会并发访问。但自从内核开始支持对称多处理器之后,内核代码可以同时运行在多个处理器上,如果此时不加保护,运行在多个处理器上的代码完全可能在同一时刻并发访问共享数据。

一般把访问和操作共享数据的代码段称作临界区,为了避免在临界区中发生并发访问,程序员必须保证临界区代码原子执行,也就是要么全部执行,要么不执行。如果两个执行线程在同一个临界区同时执行,就发生了竞态(race conditions),避免并发防止竞态就是所谓的同步。

在Linux内核中造成并发的原因主要有如下:

  • 中断 — 中断几乎可以在任何时刻异步发生,也就可能随时打断当前正在执行的代码。
  • 软中断和tasklet — 内核能在任何时刻唤醒或调度软中断和tasklet,打断当前正在执行的代码。
  • 内核抢占 — 因为内核具有抢占性,所以内核中的任务可能会被另一任务抢占。
  • 睡眠及与用户空间的同步 — 在内核执行的进程可能会睡眠,这就会唤醒调度程序,从而导致调度一个新的用户进程执行。
  • 对称多处理 — 两个或多个处理器可以同时执行代码。(真并发)

通过锁可以防止并发执行,并且保护临界区不受竞态影响。任何执行线程要访问临界区代码时首先先获得锁,这样当后面另外的执行线程要访问临界区时就不能再获得该锁,这样临界区就禁止后来执行线程访问。Linux自身实现了多种不同的锁机制,各种锁各有差别,区别主要在于当锁被争用时,有些会简单地执行等待,而有些锁会使当前任务睡眠直到锁可用为止。本节将会分析各锁的使用和实现。但是使用锁也会带来副作用,锁使得线程按串行方式对资源进行访问,所以使用锁无疑会降低系统性能;并且锁使用不当还会造成死锁。

下面来看一下Linux下同步的方法,包括原子操作、自旋锁、信号量等方式。

原子操作

该操作是其它同步方法的基础,原子操作可以保证指令以原子的方式执行,执行过程不会被打断。Linux内核提供了两组原子操作接口:原子整数操作和原子位操作。

针对整数的原子操作只能对atomic_t类型的数据进行处理。该类类型定义与文件<include/linux/types.h>:

1
2
3
typedef struct {
volatile int counter;
} atomic_t;

下面举例说明原子操作的用法:
定义一个atomic_c类型的数据很简单,还可以定义时给它设定初值:
1
2
3
4
atomic_t u;
     
/* 定义 u */
atomic_t v = ATOMIC_INIT(0)     /*定义 v 并把它初始化为0*/

对其操作:

1
2
3
4
5
6
7
8
9
atomic_set(&v,4)

/* v = 4 ( 原子地)*/
atomic_add(2,&v)

/* v = v + 2 = 6 (原子地) */
atomic_inc(&v)
   
/* v = v + 1 =7(原子地)*/

如果需要将atomic_t转换成int型,可以使用atomic_read()来完成:

1
printk("%d\n", atomic_read(&v));    /* 会打印7*/

原子整数操作最常见的用途就是实现计数器。使用复杂的锁机制来保护一个单纯的计数器是很笨拙的,所以,开发者最好使用atomic_inc()atomic_dec()这两个相对来说轻便一点的操作。

还可以用原子整数操作原子地执行一个操作并检查结果。一个常见的例子是原子的减操作和检查。

1
int atomic_dec_and_test(atomic_t *v)

这个函数让给定的原子变量减1,如果结果为0,就返回1;否则返回0。特定体系结构的所有原子整数操作可以在文件<arch/x86/include/asm/atomic.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
#ifndef _ASM_X86_ATOMIC_32_H
#define _ASM_X86_ATOMIC_32_H

#include <linux/compiler.h>
#include <linux/types.h>
#include <asm/processor.h>
#include <asm/cmpxchg.h>

#define ATOMIC_INIT(i) { (i) }

static inline int atomic_read(const atomic_t *v)
{
return v->counter;
}

static inline void atomic_set(atomic_t *v, int i)
{
v->counter = i;
}

static inline void atomic_add(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "addl %1,%0"
: "+m" (v->counter)
: "ir" (i));
}

static inline void atomic_sub(int i, atomic_t *v)
{
asm volatile(LOCK_PREFIX "subl %1,%0"
: "+m" (v->counter)
: "ir" (i));
}

static inline int atomic_sub_and_test(int i, atomic_t *v)
{
unsigned char c;

asm volatile(LOCK_PREFIX "subl %2,%0; sete %1"
: "+m" (v->counter), "=qm" (c)
: "ir" (i) : "memory");
return c;
}
......

除了原子整数之外,内核还提供了一组针对位操作的函数,这些操作也是和体系结构相关的。例如在x86下set_bit实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static __always_inline void
set_bit(unsigned int nr, volatile unsigned long *addr)
{
if (IS_IMMEDIATE(nr)) {
asm volatile(LOCK_PREFIX "orb %1,%0"
: CONST_MASK_ADDR(nr, addr)
: "iq" ((u8)CONST_MASK(nr))
: "memory");
} else {
asm volatile(LOCK_PREFIX "bts %1,%0"
: BITOP_ADDR(addr) : "Ir" (nr) : "memory");
}
}

原子操作是对普通内存地址指针进行的操作,只要指针指向了任何你希望的数据,你就可以对它进行操作。原子位操作(以x86为例)相关函数定义在文件<arch/x86/include/asm/bitops.h>中。

自旋锁

不是所有的临界区都是像增加或减少变量这么简单,有的时候临界区可能会跨越多个函数,这这时就需要使用更为复杂的同步方法——锁。Linux内核中最常见的锁是自旋锁,自旋锁最多只能被一个可执行线程持有,如果一个可执行线程视图获取一个已经被持有的锁,那么该线程将会一直进行忙循环等待锁重新可用。在任意时候,自旋锁都可以防止多余一个执行线程同时进入临界区。由于自旋忙等过程是很费时间的,所以自旋锁不应该被长时间持有。

自旋锁相关方法如下:

方法 spinlock中的定义
定义spin lock并初始化 DEFINE_SPINLOCK()
动态初始化spin lock spin_lock_init()
获取指定的spin lock spin_lock()
获取指定的spin lock同时disable本CPU中断 spin_lock_irq()
保存本CPU当前的irq状态,disable本CPU中断并获取指定的spin lock spin_lock_irqsave()
获取指定的spin lock同时disable本CPU的`bottom half spin_lock_bh()
释放指定的spin lock spin_unlock()
释放指定的spin lock同时enable本CPU中断 spin_lock_irq()
释放指定的spin lock同时恢复本CPU的中断状态 spin_lock_irqsave()
获取指定的spin lock同时enable本CPU的bottom half spin_unlock_bh()
尝试去获取spin lock,如果失败,不会spin,而是返回非零值 spin_trylock()
判断spin lock是否是locked,如果其他的thread已经获取了该lock,那么返回非零值,否则返回0 spin_is_locked()

自旋锁的实现和体系结构密切相关,代码通常通过汇编实现。与体系结构相关的部分定义在<asm/spinlock.h>,实际需要用到的接口定义在文件<linux/spinlock.h>中。一个实际的锁的类型为spinlock_t,定义在文件<include/linux/spinlock_types.h>中:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct {
raw_spinlock_t raw_lock;
#ifdef CONFIG_GENERIC_LOCKBREAK
unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
unsigned int magic, owner_cpu;
void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
} spinlock_t;

自旋锁基本使用形式如下:
1
2
3
4
DEFINE_SPINLOCK(lock);
spin_lock(&lock);
/* 临界区 */
spin_unlock(&lock);

实际上有 4 个函数可以加锁一个自旋锁:
1
2
3
4
5
void spin_lock(spinlock_t *lock);
void spin_lock_irq(spinlock_t *lock); //相当于`spin_lock() + local_irq_disable()。
void spin_lock_irqsave(spinlock_t *lock, unsigned long flags);
//禁止中断(只在本地处理器)在获得自旋锁之前; 之前的中断状态保存在flags里。相当于spin_lock() + local_irq_save()。
void spin_lock_bh(spinlock_t *lock); //获取锁之前禁止软件中断,但是硬件中断留作打开的,相当于spin_lock() + local_bh_disable()。

也有 4 个方法来释放一个自旋锁; 你用的那个必须对应你用来获取锁的函数。

1
2
3
4
void spin_unlock(spinlock_t *lock);
void spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags);
void spin_unlock_irq(spinlock_t *lock);
void spin_unlock_bh(spinlock_t *lock);

下面看一下DEFINE_SPINLOCK()spin_lock_init()spin_lock()spin_lock_irqsave()的实现:
1
2
3
4
5
#define DEFINE_SPINLOCK(x)    spinlock_t x = __SPIN_LOCK_UNLOCKED(x)

# define spin_lock_init(lock) \
do { *(lock) = SPIN_LOCK_UNLOCKED; } while (0)
#endif

spin_lock:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define spin_lock(lock)            _spin_lock(lock)

void __lockfunc _spin_lock(spinlock_t *lock)
{
__spin_lock(lock);
}

static inline void __spin_lock(spinlock_t *lock)
{
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);
LOCK_CONTENDED(lock, _raw_spin_trylock, _raw_spin_lock);
}

spin_lock_irqsave:
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
#define spin_lock_irqsave(lock, flags)            \
do { \
typecheck(unsigned long, flags); \
flags = _spin_lock_irqsave(lock); \
} while (0)

unsigned long __lockfunc _spin_lock_irqsave(spinlock_t *lock)
{
return __spin_lock_irqsave(lock);
}
static inline unsigned long __spin_lock_irqsave(spinlock_t *lock)
{
unsigned long flags;

local_irq_save(flags); //spin_lock的实现没有禁止本地中断这一步
preempt_disable();
spin_acquire(&lock->dep_map, 0, 0, _RET_IP_);

#ifdef CONFIG_LOCKDEP
LOCK_CONTENDED(lock, _raw_spin_trylock, _raw_spin_lock);
#else
_raw_spin_lock_flags(lock, &flags);
#endif
return flags;
}

读写自旋锁一种比自旋锁粒度更小的锁机制,它保留了“自旋”的概念,但是在写操作方面,只能最多有1个写进程,在读操作方面,同时可以有多个读执行单元。当然,读和写也不能同时进行。读者写者锁有一个类型rwlock_t,在<linux/spinlokc.h>中定义。 它们可以以 2 种方式被声明和被初始化:
静态方式:

1
rwlock_t my_rwlock = RW_LOCK_UNLOCKED;

动态方式:
1
2
rwlock_t my_rwlock;
rwlock_init(&my_rwlock);

可用函数的列表现在应当看来相当类似。 对于读者,有下列函数可用:
1
2
3
4
5
6
7
8
void read_lock(rwlock_t *lock);
void read_lock_irqsave(rwlock_t *lock, unsigned long flags);
void read_lock_irq(rwlock_t *lock);
void read_lock_bh(rwlock_t *lock);
void read_unlock(rwlock_t *lock);
void read_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void read_unlock_irq(rwlock_t *lock);
void read_unlock_bh(rwlock_t *lock);

对于写存取的函数是类似的:

1
2
3
4
5
6
7
8
9
void write_lock(rwlock_t *lock);
void write_lock_irqsave(rwlock_t *lock, unsigned long flags);
void write_lock_irq(rwlock_t *lock);
void write_lock_bh(rwlock_t *lock);
int write_trylock(rwlock_t *lock);
void write_unlock(rwlock_t *lock);
void write_unlock_irqrestore(rwlock_t *lock, unsigned long flags);
void write_unlock_irq(rwlock_t *lock);
void write_unlock_bh(rwlock_t *lock);

在与下半部配合使用时,锁机制必须要小心使用。由于下半部可以抢占进程上下文的代码,所以当下半部和进程上下文共享数据时,必须对进程上下文的共享数据进行保护,所以需要加锁的同时还要禁止下半部执行。同样的,由于中断处理器可以抢占下半部,所以如果中断处理器程序和下半部共享数据,那么就必须在获取恰当的锁的同时还要禁止中断。 同类的tasklet不可能同时运行,所以对于同类tasklet中的共享数据不需要保护,但是当数据被两个不同种类的tasklet共享时,就需要在访问下半部中的数据前先获得一个普通的自旋锁。由于同种类型的两个软中断也可以同时运行在一个系统的多个处理器上,所以被软中断共享的数据必须得到锁的保护。

信号量

一个被占有的自旋锁使得请求它的线程循环等待而不会睡眠,这很浪费处理器时间,所以自旋锁使用段时间占有的情况。Linux提供另外的同步方式可以在锁争用时让请求线程睡眠,直到锁重新可用时在唤醒它,这样处理器就不必循环等待,可以去执行其它代码。这种方式就是即将讨论的信号量。

信号量是一种睡眠锁,如果有一个任务试图获得一个已经被占用的信号量时,信号量会将其放入一个等待队列,然后睡眠。当持有的信号量被释放后,处于等待队列中的那个任务将被唤醒,并获得信号量。信号量比自旋锁提供了更好的处理器利用率,因为没有把时间花费在忙等带上。但是信号量也会有一定的开销,被阻塞的线程换入换出有两次明显的上下文切换,这样的开销比自旋锁要大的多。

如果需要在自旋锁和信号量中做出选择,应该根据锁被持有的时间长短做判断,如果加锁时间不长并且代码不会休眠,利用自旋锁是最佳选择。相反,如果加锁时间可能很长或者代码在持有锁有可能睡眠,那么最好使用信号量来完成加锁功能。信号量一个有用特性就是它可以同时允许任意数量的锁持有者,而自旋锁在一个时刻最多允许一个任务持有它。信号量同时允许的持有者数量可以在声明信号量时指定,当为1时,成为互斥信号量,否则成为计数信号量。

信号量的实现与体系结构相关,信号量使用struct semaphore类型用来表示信号量,定义于文件<include/linux/semaphore.h>中:

1
2
3
4
5
struct semaphore {
spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};

信号量初始化方法有如下:

方法一:

1
2
3
4
5
6
7
8
struct semaphore sem;  
void sema_init(struct semaphore *sem, int val);//初始化信号量,并设置信号量 sem 的值为 val。
static inline void sema_init(struct semaphore *sem, int val)
{
static struct lock_class_key __key;
*sem = (struct semaphore) __SEMAPHORE_INITIALIZER(*sem, val);
lockdep_init_map(&sem->lock.dep_map, "semaphore->lock", &__key, 0);
}

方法二:

1
DECLARE_MUTEX(name);

定义一个名为 name 的信号量并初始化为1。
其实现为:
1
2
#define DECLARE_MUTEX(name)    \
struct semaphore name = __SEMAPHORE_INITIALIZER(name, 1)

方法三:

1
2
3
4
5
6
7
#define init_MUTEX(sem)        sema_init(sem, 1)

//以不加锁状态动态创建信号量

#define init_MUTEX_LOCKED(sem) sema_init(sem, 0)

//以加锁状态动态创建信号量

信号量初始化后就可以使用了,使用信号量主要有如下方法:
1
2
3
4
5
6
7
8
9
10
void down(struct semaphore * sem);
//该函数用于获得信号量 sem,它会导致睡眠,因此不能在中断上下文使用;
int down_interruptible(struct semaphore * sem);
//该函数功能与 down 类似,不同之处为,因为 down()而进入睡眠状态的进程不能被信号打断,但因为 down_interruptible()而进入睡眠状态的进程能被信号打断,信号也会导致该函数返回,这时候函数的返回值非 0;

int down_trylock(struct semaphore * sem);
//该函数尝试获得信号量`sem,如果能够立刻获得,它就获得该信号量并返回0, 否则,返回非0值。它不会导致调用者睡眠,可以在中断上下文使用。

up(struct semaphore * sem);
//释放指定信号量,如果睡眠队列不空,则唤醒其中一个队列。

信号量一般这样使用:

1
2
3
4
5
6
7
8
/* 定义信号量  
DECLARE_MUTEX(mount_sem);
//down(&mount_sem);/* 获取信号量,保护临界区,信号量被占用之后进入不可中断睡眠状态
down_interruptible(&mount_sem);/* 获取信号量,保护临界区,信号量被占用之后进入不可中断睡眠状态
. . .
critical section /* 临界区
. . .
up(&mount_sem);/* 释放信号量

下面看一下这些函数的实现:

down():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void down(struct semaphore *sem)
{
unsigned long flags;

spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0))
sem->count--;
else
__down(sem);
spin_unlock_irqrestore(&sem->lock, flags);
}
static noinline void __sched __down(struct semaphore *sem)
{
__down_common(sem, TASK_UNINTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
}

down_interruptible():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int down_interruptible(struct semaphore *sem)
{
unsigned long flags;
int result = 0;

spin_lock_irqsave(&sem->lock, flags);
if (likely(sem->count > 0))
sem->count--;
else
result = __down_interruptible(sem);
spin_unlock_irqrestore(&sem->lock, flags);

return result;
}
static noinline int __sched __down_interruptible(struct semaphore *sem)
{
return __down_common(sem, TASK_INTERRUPTIBLE, MAX_SCHEDULE_TIMEOUT);
}

down_trylock():
1
2
3
4
5
6
7
8
9
10
11
12
13
int down_trylock(struct semaphore *sem)
{
unsigned long flags;
int count;

spin_lock_irqsave(&sem->lock, flags);
count = sem->count - 1;
if (likely(count >= 0))
sem->count = count;
spin_unlock_irqrestore(&sem->lock, flags);

return (count < 0);
}

up():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void up(struct semaphore *sem)
{
unsigned long flags;

spin_lock_irqsave(&sem->lock, flags);
if (likely(list_empty(&sem->wait_list)))
sem->count++;
else
__up(sem);
spin_unlock_irqrestore(&sem->lock, flags);
}
static noinline void __sched __up(struct semaphore *sem)
{
struct semaphore_waiter *waiter = list_first_entry(&sem->wait_list,
struct semaphore_waiter, list);
list_del(&waiter->list);
waiter->up = 1;
wake_up_process(waiter->task);
}

正如自旋锁一样,信号量也有区分读写访问的可能,读写信号量在内核中使用rw_semaphore结构表示,x86体系结构定义在<arch/x86/include/asm/rwsem.h>文件中:

1
2
3
4
5
6
7
8
struct rw_semaphore {
signed long count;
spinlock_t wait_lock;
struct list_head wait_list;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
struct lockdep_map dep_map;
#endif
};

读写信号量的使用方法和信号量类似,其操作函数有如下:
1
2
3
4
5
6
7
8
9
DECLARE_RWSEM(name)  //声明名为name的读写信号量,并初始化它。
void init_rwsem(struct rw_semaphore *sem); //对读写信号量`sem`进行初始化。
void down_read(struct rw_semaphore *sem); //读者用来获取`sem,若没获得时,则调用者睡眠等待。
void up_read(struct rw_semaphore *sem); //读者释放`sem。
int down_read_trylock(struct rw_semaphore *sem); //读者尝试获取sem,如果获得返回1,如果没有获得返回0。可在中断上下文使用。
void down_write(struct rw_semaphore *sem); //写者用来获取`sem,若没获得时,则调用者睡眠等待。
int down_write_trylock(struct rw_semaphore *sem); //写者尝试获取sem,如果获得返回1,如果没有获得返回0。可在中断上下文使用
void up_write(struct rw_semaphore *sem); //写者释放sem。
void downgrade_write(struct rw_semaphore *sem); //把写者降级为读者。

互斥体

除了信号量之外,内核拥有一个更简单的且可睡眠的锁,那就是互斥体。互斥体的行为和计数是1的信号量类似,其接口简单,实现更高效。 互斥体在内核中使用mutex表示,定义于<include/linux/mutex.h>

1
2
3
4
5
6
7
struct mutex {
/* 1: unlocked, 0: locked, negative: locked, possible waiters */
atomic_t count;
spinlock_t wait_lock;
struct list_head wait_list;
......
};

静态定义mutex
1
DEFINE_MUTEX(name);

实现如下:
1
2
3
4
5
6
7
8
9
#define DEFINE_MUTEX(mutexname) \
struct mutex mutexname = __MUTEX_INITIALIZER(mutexname)

#define __MUTEX_INITIALIZER(lockname) \
{ .count = ATOMIC_INIT(1) \
, .wait_lock = __SPIN_LOCK_UNLOCKED(lockname.wait_lock) \
, .wait_list = LIST_HEAD_INIT(lockname.wait_list) \
__DEBUG_MUTEX_INITIALIZER(lockname) \
__DEP_MAP_MUTEX_INITIALIZER(lockname) }

动态定义mutex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mutex_init(&mutex);
# define mutex_init(mutex) \
do { \
static struct lock_class_key __key; \
\
__mutex_init((mutex), #mutex, &__key); \
} while (0)

void
__mutex_init(struct mutex *lock, const char *name, struct lock_class_key *key)
{
atomic_set(&lock->count, 1);
spin_lock_init(&lock->wait_lock);
INIT_LIST_HEAD(&lock->wait_list);
mutex_clear_owner(lock);

debug_mutex_init(lock, name, key);
}

锁定和解锁如下:
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
mutex_lock(&mutex);
/* 临界区 */
mutex_unlock(&mutex);

void __sched mutex_lock(struct mutex *lock)
{
might_sleep();
/*
* The locking fastpath is the 1->0 transition from
* 'unlocked' into 'locked' state.
*/
__mutex_fastpath_lock(&lock->count, __mutex_lock_slowpath);
mutex_set_owner(lock);
}

void __sched mutex_unlock(struct mutex *lock)
{
/*
* The unlocking fastpath is the 0->1 transition from 'locked'
* into 'unlocked' state:
*/
#ifndef CONFIG_DEBUG_MUTEXES
/*
* When debugging is enabled we must not clear the owner before time,
* the slow path will always be taken, and that clears the owner field
* after verifying that it was indeed current.
*/
mutex_clear_owner(lock);
#endif
__mutex_fastpath_unlock(&lock->count, __mutex_unlock_slowpath);
}

其他mutex方法:
1
2
int mutex_trylock(struct mutex *);    //视图获取指定互斥体,成功返回1;否则返回0。
int mutex_is_locked(struct mutex *lock); //判断锁是否被占用,是返回1,否则返回0。

使用mutex时,要注意一下:

  • mutex的使用技术永远是1;在同一上下文中上锁解锁;
  • 当进程持有一个mutex时,进程不可退出;
  • mutex不能在中断或下半部中使用。

抢占禁止

在前面章节讲进程管理的时候听到过内核抢占,由于内核可抢占,内核中的进程随时都可能被另外一个具有更高优先权的进程打断,这也就意味着一个任务与被抢占的任务可能会在同一个临界区运行。所以才有本节前面自旋锁来避免竞态的发生,自旋锁有禁止内核抢占的功能。但像每CPU变量的数据只能被一个处理器访问,可以不需要使用锁来保护,如果没有使用锁,内核又是抢占式的,那么新调度的任务就可能访问同一个变量。这个时候就可以通过禁止内核抢占来避免竞态的发生,禁止内核抢占使用preetmpt_disable()函数,这是一个可以嵌套调用的函数,可以使用任意次。每次调用都必须有一个相应的preempt_enable()调用,当最后一次preempt_enable()被调用时,内核抢占才重新启用。内核抢占相关函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
preempt_enable() 
//内核抢占计数preempt_count减1
preempt_disable()
//内核抢占计数preempt_count加1,当该值降为0时检查和执行被挂起的需要调度的任务
preempt_enable_no_resched()
//内核抢占计数preempt_count减1,但不立即抢占式调度
preempt_check_resched ()
//如果必要进行调度
preempt_count()
//返回抢占计数
preempt_schedule()
//内核抢占时的调度程序的入口点

preempt_enable()为例,看一下其实现:

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
#define preempt_enable() \
do { \
preempt_enable_no_resched(); \
barrier(); \
preempt_check_resched(); \
} while (0)

#define preempt_enable_no_resched() \
do { \
barrier(); \
dec_preempt_count(); \
} while (0)

#define dec_preempt_count() sub_preempt_count(1)

#define sub_preempt_count(val) do { preempt_count() -= (val); } while (0)

#define preempt_check_resched() \
do { \
if (unlikely(test_thread_flag(TIF_NEED_RESCHED))) \
preempt_schedule(); \
} while (0)

asmlinkage void __sched preempt_schedule(void)
{
struct thread_info *ti = current_thread_info();

/*
* If there is a non-zero preempt_count or interrupts are disabled,
* we do not want to preempt the current task. Just return..
*/
if (likely(ti->preempt_count || irqs_disabled()))
return;

do {
add_preempt_count(PREEMPT_ACTIVE);
schedule();
sub_preempt_count(PREEMPT_ACTIVE);

/*
* Check again in case we missed a preemption opportunity
* between schedule and now.
*/
barrier();
} while (need_resched());

内存管理之页的分配与回收

内存管理单元(MMU)负责将管理内存,在把虚拟地址转换为物理地址的硬件的时候是按页为单位进行处理,从虚拟内存的角度来看,页就是内存管理中的最小单位。页的大小与体系结构有关,在 x86 结构中一般是4KB(32位)或者8KB(64位)。
通过 getconf 命令可以查看系统的page的大小:

1
2
3
4
5
# getconf -a | grep PAGE
PAGESIZE 4096
PAGE_SIZE 4096
_AVPHYS_PAGES 230873
_PHYS_PAGES 744957

内核中的每个物理页用struct page结构表示,结构定义于文件<include/linux/mm_types.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
struct page {
unsigned long flags; /*页的状态*/
atomic_t _count; /* 页引用计数 */
union {
atomic_t _mapcount; /* 已经映射到mms的pte的个数*/
struct { /* */
u16 inuse;
u16 objects;
};
};
union {
struct {
unsigned long private;
struct address_space *mapping;
};
#if USE_SPLIT_PTLOCKS
spinlock_t ptl;
#endif
struct kmem_cache *slab; /* 指向slab层 */
struct page *first_page; /* Compound tail pages */
};
union {
pgoff_t index; /* Our offset within mapping. */
void *freelist; /* SLUB: freelist req. slab lock */
};
struct list_head lru; /* 将页关联起来的链表项 */

#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
#ifdef CONFIG_WANT_PAGE_DEBUG_FLAGS
unsigned long debug_flags; /* Use atomic bitops on this */
#endif

#ifdef CONFIG_KMEMCHECK
void *shadow;
#endif
};

内核使用这一结构来管理系统中所有的页,因为内核需要知道一个该页是否被分配,是被谁拥有的等信息。

由于ISA总线的DMA处理器有严格的限制,只能对物理内存前16M寻址,内核线性地址空间只有1G,CPU不能直接访问所有的物理内存。这样就导致有一些内存不能永久地映射在内核空间上。所以在Linux中,把页分为不同的区,使用区来对具有相似特性的页进行分组。分组如下(以x86-32为例):

区域 用途
ZONE_DMA 小于16M内存页框,这个区包含的页用来执行DMA操作。
ZONE_NORMAL 16M~896M内存页框,个区包含的都是能正常映射的页。
ZONE_HIGHMEM 大于896M内存页框,这个区包”高端内存”,其中的页能不永久地映射到内核地址空间。

linux 把系统的页划分区,形成不同的内存池,这样就可以根据用途进行分配了。

每个区都用struct zone表示,定义于<include/linux/mmzone.h>中。该结构体较大,详细结构体信息可以查看源码文件。

Linux提供了几个以页为单位分配释放内存的接口,定义于<include/linux/gfp.h>中。分配内存主要有以下方法:

函数 用途
alloc_page(gfp_mask) 只分配一页,返回指向页结构的指针
alloc_pages(gfp_mask, order) 分配 2^order 个页,返回指向第一页页结构的指针
__get_free_page(gfp_mask) 只分配一页,返回指向其逻辑地址的指针
__get_free_pages(gfp_mask, order) 分配 2^order 个页,返回指向第一页逻辑地址的指针
get_zeroed_page(gfp_mask) 只分配一页,让其内容填充为0,返回指向其逻辑地址的指针

alloc_*函数返回的是内存的物理地址,get_*函数返回内存物理地址映射后的逻辑地址。如果无须直接操作物理页结构体的话,一般使用 get_*函数。

释放页的函数有:

1
2
3
extern void __free_pages( struct page *page, unsignedintorder); 
extern void free_pages(unsigned longaddr, unsigned intorder);
extern void free_hot_page( struct page *page);

当需要以页为单位的连续物理页时,可以使用上面这些分配页的函数,对于常用以字节为单位的分配来说,内核提供来kmalloc()函数。

kmalloc()函数和用户空间一族函数类似,它可以以字节为单位分配内存,对于大多数内核分配来说,kmalloc函数用得更多。

1
void *kmalloc(size_t size, gfp_t gfp_mask)

参数中有个gfp_mask标志,这个标志是控制分配内存时必须遵守的一些规则。

gfp_mask标志有3类:

  • 行为标志 :控制分配内存时,分配器的一些行为,如何分配所需内存。
  • 区标志 :控制内存分配在那个区(ZONE_DMA, ZONE_NORMAL, ZONE_HIGHMEM 之类)。
  • 类型标志 :由上面2种标志组合而成的一些常用的场景。

行为标志主要有以下几种:

行为标志 描述
__GFP_WAIT 分配器可以睡眠
__GFP_HIGH 分配器可以访问紧急事件缓冲池
__GFP_IO 分配器可以启动磁盘`I/O
__GFP_FS 分配器可以启动文件系统`I/O
__GFP_COLD 分配器应该使用高速缓存中快要淘汰出去的页
__GFP_NOWARN 分配器将不打印失败警告
__GFP_REPEAT 分配器在分配失败时重复进行分配,但是这次分配还存在失败的可能
__GFP_NOFALL 分配器将无限的重复进行分配。分配不能失败
__GFP_NORETRY 分配器在分配失败时不会重新分配
__GFP_NO_GROW 由slab层内部使用
__GFP_COMP 添加混合页元数据,在 hugetlb 的代码内部使用

标志主要有以下3种:

区标志 描述
__GFP_DMA 从 ZONE_DMA 分配
__GFP_DMA32 只在 ZONE_DMA32 分配 ,和 ZONE_DMA 类似,该区包含的页也可以进行DMA操作
__GFP_HIGHMEM 从 ZONE_HIGHMEM 或者 ZONE_NORMAL 分配,优先从 ZONE_HIGHMEM 分配,如果 ZONE_HIGHMEM 没有多余的页则从ZONE_NORMAL 分配

类型标志是编程中最常用的,在使用标志时,应首先看看类型标志中是否有合适的,如果没有,再去自己组合 行为标志和区标志。

类型标志 描述 实际标志
GFP_ATOMIC 这个标志用在中断处理程序,下半部,持有自旋锁以及其他不能睡眠的地方 __GFP_HIGH
GFP_NOWAIT 与 GFP_ATOMIC 类似,不同之处在于,调用不会退给紧急内存池。这就增加了内存分配失败的可能性 0
GFP_NOIO 这种分配可以阻塞,但不会启动磁盘I/O。这个标志在不能引发更多磁盘I/O时能阻塞I/O代码,可能会导致递归 __GFP_WAIT
GFP_NOFS 这种分配在必要时可能阻塞,也可能启动磁盘I/O,但不会启动文件系统操作。这个标志在你不能再启动另一个文件系统的操作时,用在文件系统部分的代码中 (__GFP_WAIT| __GFP_IO)
GFP_KERNEL 这是常规的分配方式,可能会阻塞。这个标志在睡眠安全时用在进程上下文代码中。为了获得调用者所需的内存,内核会尽力而为。这个标志应当为首选标志 (__GFP_WAIT| __GFP_IO | __GFP_FS )
GFP_USER 这是常规的分配方式,可能会阻塞。用于为用户空间进程分配内存时 (__GFP_WAIT| __GFP_IO | __GFP_FS )
GFP_HIGHUSER 从 ZONE_HIGHMEM 进行分配,可能会阻塞。用于为用户空间进程分配内存 (__GFP_WAIT| __GFP_IO | __GFP_FS |__GFP_HIGHMEM)
GFP_DMA 从 ZONE_DMA 进行分配。需要获取能供DMA使用的内存的设备驱动程序使用这个标志。通常与以上的某个标志组合在一起使用。 __GFP_DMA

以上各种类型标志的使用场景总结:

场景 相应标志
进程上下文,可以睡眠 使用 GFP_KERNEL
进程上下文,不可以睡眠 使用 GFP_ATOMIC,在睡眠之前或之后以 GFP_KERNEL 执行内存分配
中断处理程序 使用 GFP_ATOMIC
软中断 使用 GFP_ATOMIC
tasklet 使用 GFP_ATOMIC
需要用于DMA的内存,可以睡眠 使用 (GFP_DMA|GFP_KERNEL)
需要用于DMA的内存,不可以睡眠 使用 (GFP_DMA|GFP_ATOMIC),或者在睡眠之前执行内存分配

kmalloc 所对应的释放内存的方法为:

1
void kfree(const void *)

vmalloc()也可以按字节来分配内存。
1
void *vmalloc(unsigned long size)

kmalloc是一样的作用,不同在于前者分配的内存虚拟地址是连续的,而物理地址则无需连续。kmalloc()可以保证在物理地址上都是连续的,虚拟地址当然也是连续的。vmalloc()函数只确保页在虚拟机地址空间内是连续的。它通过分配非联系的物理内存块,再“修正”页表,把内存映射到逻辑地址空间的连续区域中,就能做到这点。但很显然这样会降低处理性能,因为内核不得不做“拼接”的工作。所以这也是为什么不得已才使用vmalloc()的原因 。vmalloc()可能睡眠,不能从中断上下文中进行调用,也不能从其他不允许阻塞的情况下进行调用。释放时必须使用vfree()
1
void vfree(const void *)

对于内存页面的管理,通常是先在虚存空间中分配一个虚存区间,然后才根据需要为此区间分配相应的物理页面并建立起映射,也就是说,虚存区间的分配在前,而物理页面的分配在后。但由于频繁的请求和释放不同大小的连续页框,必然导致在已分配页框的块内分散了许多小块的空闲页框,由此产生的问题是:即使有足够的空闲页框可以满足请求,但当要分配一个大块的连续页框时,无法满足请求。这就是著名的内存管理问题:外碎片问题。Linux采用著名的伙伴(Buddy)系统算法来解决外碎片问题。

把所有的空闲页框分组为11个块链表。每个块链表包含大小为1,2,4,8,16,32,64,128,256,512,1024个的页框。伙伴系统算法原理为:

假设请求一个256个页框的块,先在256个页框的链表内检查是否有一个空闲的块。如果没有这样的块,算法会查找下一个更大的块,在512个页框的链表中找一个空闲块。如果存在这样的块,内核就把512的页框分成两半,一半用作满足请求,另一半插入256个页框的链表中。如果512个页框的块链表也没有空闲块,就继续找更大的块,1024个页框的块。如果这样的块存在,内核把1024个页框的256个页框用作请求,然后从剩余的768个中拿出512个插入512个页框的链表中,把最后256个插入256个页框的链表中。

页框块的释放过程如下:

如果两个块具有相同的大小:a,并且他们的物理地址连续那么这两个块成为伙伴,内核就会试图把大小为a的一对空闲伙伴块合并为一个大小为2a的单独块。该算法还是迭代的,如果合并成功的话,它还会试图合并2a的块。

管理分区数据结构struct zone_struct中,涉及到空闲区数据结构。

1
2
3
4
5
6
struct free_area    free_area[MAX_ORDER];

struct free_area {
 struct list_head free_list[MIGRATE_TYPES];
 unsigned long nr_free;
};

采用伙伴算法分配内存时,每次至少分配一个页面。但当请求分配的内存大小为几十个字节或几百个字节时应该如何处理?如何在一个页面中分配小的内存区,小内存区的分配所产生的内碎片又如何解决?slab的分配模式可以解决该问题。

内存管理之slab分配器

上一节最后说到对于小内存区的请求,如果采用伙伴系统来进行分配,则会在页内产生很多空闲空间无法使用,因此产生slab分配器来处理对小内存区(几十或几百字节)的请求。Linux中引入slab的主要目的是为了减少对伙伴算法的调用次数。

内核经常反复使用某一内存区。例如,只要内核创建一个新的进程,就要为该进程相关的数据结构(task_struct、打开文件对象等)分配内存区。当进程结束时,收回这些内存区。因为进程的创建和撤销非常频繁,Linux把那些频繁使用的页面保存在高速缓存中并重新使用。

slab分配器基于对象进行管理,相同类型的对象归为一类(如进程描述符就是一类),每当要申请这样一个对象,slab分配器就分配一个空闲对象出去,而当要释放时,将其重新保存在slab分配器中,而不是直接返回给伙伴系统。对于频繁请求的对象,创建适当大小的专用对象来处理。对于不频繁的对象,用一系列几何分布大小的对象来处理(详见通用对象)。

slab分配模式把对象分组放进缓冲区,为缓冲区的组织和管理与硬件高速缓存的命中率密切相关,因此,slab缓冲区并非由各个对象直接构成,而是由一连串的“大块(Slab)”构成,而每个大块中则包含了若干个同种类型的对象,这些对象或已被分配,或空闲。实际上,缓冲区就是主存中的一片区域,把这片区域划分为多个块,每块就是一个slab,每个slab由一个或多个页面组成,每个slab中存放的就是对象。

slab相关数据结构:

缓冲区数据结构使用kmem_cache结构来表示。

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
struct kmem_cache {
/* 1) per-cpu data, touched during every alloc/free */
struct array_cache *array[NR_CPUS];
/* 2) Cache tunables. Protected by cache_chain_mutex */
unsigned int batchcount;
unsigned int limit;
unsigned int shared;

unsigned int buffer_size;
u32 reciprocal_buffer_size;
/* 3) touched by every alloc & free from the backend */

unsigned int flags; /* constant flags */
unsigned int num; /* # of objs per slab */

/* 4) cache_grow/shrink */
/* order of pgs per slab (2^n) */
unsigned int gfporder;

/* force GFP flags, e.g. GFP_DMA */
gfp_t gfpflags;

size_t colour; /* cache colouring range */
unsigned int colour_off; /* colour offset */
struct kmem_cache *slabp_cache;
unsigned int slab_size;
unsigned int dflags; /* dynamic flags */

/* constructor func */
void (*ctor)(void *obj);

/* 5) cache creation/removal */
const char *name;
struct list_head next;

/* 6) statistics */
#ifdef CONFIG_DEBUG_SLAB
unsigned long num_active;
unsigned long num_allocations;
unsigned long high_mark;
unsigned long grown;
unsigned long reaped;
unsigned long errors;
unsigned long max_freeable;
unsigned long node_allocs;
unsigned long node_frees;
unsigned long node_overflow;
atomic_t allochit;
atomic_t allocmiss;
atomic_t freehit;
atomic_t freemiss;

/*
* If debugging is enabled, then the allocator can add additional
* fields and/or padding to every object. buffer_size contains the total
* object size including these internal fields, the following two
* variables contain the offset to the user object and its size.
*/
int obj_offset;
int obj_size;
#endif /* CONFIG_DEBUG_SLAB */

/*
* We put nodelists[] at the end of kmem_cache, because we want to size
* this array to nr_node_ids slots instead of MAX_NUMNODES
* (see kmem_cache_init())
* We still use [MAX_NUMNODES] and not [1] or [0] because cache_cache
* is statically defined, so we reserve the max number of nodes.
*/
struct kmem_list3 *nodelists[MAX_NUMNODES];
/*
* Do not add fields after nodelists[]
*/
};

其中struct kmem_list3结构体链接slab,共享高速缓存,其定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* The slab lists for all objects.
*/
struct kmem_list3 {
struct list_head slabs_partial; /* partial list first, better asm code */
struct list_head slabs_full;
struct list_head slabs_free;
unsigned long free_objects;
unsigned int free_limit;
unsigned int colour_next; /* Per-node cache coloring */
spinlock_t list_lock;
struct array_cache *shared; /* shared per node */
struct array_cache **alien; /* on other nodes */
unsigned long next_reap; /* updated without locking */
int free_touched; /* updated without locking */
};

该结构包含三个链表:slabs_partialslabs_fullslabs_free,这些链表包含缓冲区所有slab,slab描述符struct slab用于描述每个slab:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* struct slab
*
* Manages the objs in a slab. Placed either at the beginning of mem allocated
* for a slab, or allocated from an general cache.
* Slabs are chained into three list: fully used, partial, fully free slabs.
*/
struct slab {
struct list_head list;
unsigned long colouroff;
void *s_mem; /* including colour offset */
unsigned int inuse; /* num of objs active in slab */
kmem_bufctl_t free;
unsigned short nodeid;
};

一个新的缓冲区使用如下函数创建:
1
struct kmem_cache *kmem_cache_create (const char *name, size_t size, size_t align, unsigned long flags, void (*ctor)(void *)); 

函数创建成功会返回一个指向所创建缓冲区的指针;撤销一个缓冲区调用如下函数:
1
void kmem_cache_destroy(struct kmem_cache *cachep)

上面两个函数都不能在中断上下文中使用,因为它可能睡眠。
在创建来缓冲区之后,可以通过下列函数获取对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* kmem_cache_alloc - Allocate an object
* @cachep: The cache to allocate from.
* @flags: See kmalloc().
*
* Allocate an object from this cache. The flags are only relevant
* if the cache has no available objects.
*/
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags)
{
void *ret = __cache_alloc(cachep, flags, __builtin_return_address(0));

trace_kmem_cache_alloc(_RET_IP_, ret,
obj_size(cachep), cachep->buffer_size, flags);

return ret;
}

该函数从给点缓冲区cachep中返回一个指向对象的指针。如果缓冲区的所有slab中都没有空闲对象,那么slab层必须通过kmem_getpages()获取新的页,参数flags传递给_get_free_pages()

1
static void *kmem_getpages(struct kmem_cache *cachep, gfp_t flags, int nodeid)

释放对象使用如下函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* kmem_cache_free - Deallocate an object
* @cachep: The cache the allocation was from.
* @objp: The previously allocated object.
*
* Free an object which was previously allocated from this
* cache.
*/
void kmem_cache_free(struct kmem_cache *cachep, void *objp)
{
unsigned long flags;

local_irq_save(flags);
debug_check_no_locks_freed(objp, obj_size(cachep));
if (!(cachep->flags & SLAB_DEBUG_OBJECTS))
debug_check_no_obj_freed(objp, obj_size(cachep));
__cache_free(cachep, objp);
local_irq_restore(flags);

trace_kmem_cache_free(_RET_IP_, objp);
}

如果你要频繁的创建很多相同类型的对象,就要当考虑使用slab高速缓存区。

实际上上一节所讲kmalloc()函数也是使用slab分配器分配的。

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
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
struct kmem_cache *cachep;
void *ret;

if (__builtin_constant_p(size)) {
int i = 0;

if (!size)
return ZERO_SIZE_PTR;

#define CACHE(x) \
if (size <= x) \
goto found; \
else \
i++;
#include <linux/kmalloc_sizes.h>
#undef CACHE
return NULL;
found:
#ifdef CONFIG_ZONE_DMA
if (flags & GFP_DMA)
cachep = malloc_sizes[i].cs_dmacachep;
else
#endif
cachep = malloc_sizes[i].cs_cachep;

ret = kmem_cache_alloc_notrace(cachep, flags);

trace_kmalloc(_THIS_IP_, ret,
size, slab_buffer_size(cachep), flags);

return ret;
}
return __kmalloc(size, flags);
}

kfree函数实现如下:

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
/**
* kfree - free previously allocated memory
* @objp: pointer returned by kmalloc.
*
* If @objp is NULL, no operation is performed.
*
* Don't free memory not originally allocated by kmalloc()
* or you will run into trouble.
*/
void kfree(const void *objp)
{
struct kmem_cache *c;
unsigned long flags;

trace_kfree(_RET_IP_, objp);

if (unlikely(ZERO_OR_NULL_PTR(objp)))
return;
local_irq_save(flags);
kfree_debugcheck(objp);
c = virt_to_cache(objp);
debug_check_no_locks_freed(objp, obj_size(c));
debug_check_no_obj_freed(objp, obj_size(c));
__cache_free(c, (void *)objp);
local_irq_restore(flags);
}

最后,结合上一节,看看分配函数的选择:

  • 如果需要连续的物理页,就可以使用某个低级页分配器或kmalloc()
  • 如果想从高端内存进行分配,使用alloc_pages()
  • 如果不需要物理上连续的页,而仅仅是虚拟地址上连续的页,那么就是用vmalloc
  • 如果要创建和销毁很多大的数据结构,那么考虑建立slab高速缓存。

内存管理之进程地址空间

进程地址空间由进程可寻址的虚拟内存组成,Linux 的虚拟地址空间为0~4G字节(注:本节讲述均以32为为例)。Linux内核将这 4G 字节的空间分为两部分。将最高的 1G 字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为“内核空间”。而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为“用户空间” 。因为每个进程可以通过系统调用进入内核。因此,Linux 内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。

尽管一个进程可以寻址4G的虚拟内存,但就不代表它就有权限访问所有的地址空间,虚拟内存空间必须映射到某个物理存储空间(内存或磁盘空间),才真正地可以被使用。进程只能访问合法的地址空间,如果一个进程访问了不合法的地址空间,内核就会终止该进程,并返回“段错误”。虚拟内存的合法地址空间在哪而呢?我们先来看看进程虚拟地址空间的划分:

其中堆栈安排在虚拟地址空间顶部,数据段和代码段分布在虚拟地址空间底部,空洞部分就是进程运行时可以动态分布的空间,包括映射内核地址空间内容、动态申请地址空间、共享库的代码或数据等。在虚拟地址空间中,只有那些映射到物理存储空间的地址才是合法的地址空间。每一片合法的地址空间片段都对应一个独立的虚拟内存区域(VMA,virtual memory areas ),而进程的进程地址空间就是由这些内存区域组成。

Linux 采用了复杂的数据结构来跟踪进程的虚拟地址,进程地址空间使用内存描述符结构体来表示,内存描述符由mm_struct结构体表示,该结构体表示在<include/linux/mm_types.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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct rb_root mm_rb;
struct vm_area_struct * mmap_cache; /* last find_vma result */
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
void (*unmap_area) (struct mm_struct *mm, unsigned long addr);
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
unsigned long cached_hole_size; /* if non-zero, the largest hole below free_area_cache */
unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */
pgd_t * pgd;
atomic_t mm_users; /* How many users with user space? */
atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */
int map_count; /* number of VMAs */
struct rw_semaphore mmap_sem;
spinlock_t page_table_lock; /* Protects page tables and some counters */

struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung together off init_mm.mmlist, and are protected by mmlist_lock */

/* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
mm_counter_t _file_rss;
mm_counter_t _anon_rss;

unsigned long hiwater_rss; /* High-watermark of RSS usage */
unsigned long hiwater_vm; /* High-water virtual memory usage */

unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;

unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */

struct linux_binfmt *binfmt;

cpumask_t cpu_vm_mask;

/* Architecture-specific MM context */
mm_context_t context;

/* Swap token stuff */
/*
* Last value of global fault stamp as seen by this process.
* In other words, this value gives an indication of how long
* it has been since this task got the token.
* Look at mm/thrash.c
*/
unsigned int faultstamp;
unsigned int token_priority;
unsigned int last_interval;

unsigned long flags; /* Must use atomic bitops to access the bits */

struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
spinlock_t ioctx_lock;
struct hlist_head ioctx_list;
#endif
#ifdef CONFIG_MM_OWNER
/*
* "owner" points to a task that is regarded as the canonical
* user/owner of this mm. All of the following must be true in
* order for it to be changed:
*
* current == mm->owner
* current->mm != mm
* new_owner->mm == mm
* new_owner->alloc_lock is held
*/
struct task_struct *owner;
#endif

#ifdef CONFIG_PROC_FS
/* store ref to file /proc/<pid>/exe symlink points to */
struct file *exe_file;
unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
struct mmu_notifier_mm *mmu_notifier_mm;
#endif
};

该结构体中第一行成员mmap就是内存区域,用结构体struct vm_area_struct来表示:

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
60
61
62
/*
* This struct defines a memory VMM memory area. There is one of these
* per VM-area/task. A VM area is any part of the process virtual memory
* space that has a special rule for the page-fault handlers (ie a shared
* library, the executable area etc).
*/
struct vm_area_struct {
struct mm_struct * vm_mm; /* The address space we belong to. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */

/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;

pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, see mm.h. */

struct rb_node vm_rb;

/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap prio tree, or
* linkage to the list of like vmas hanging off its node, or
* linkage of vma in the address_space->i_mmap_nonlinear list.
*/
union {
struct {
struct list_head list;
void *parent; /* aligns with prio_tree_node parent */
struct vm_area_struct *head;
} vm_set;

struct raw_prio_tree_node prio_tree_node;
} shared;

/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_node; /* Serialized by anon_vma->lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */

/* Function pointers to deal with this struct. */
const struct vm_operations_struct *vm_ops;

/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
unsigned long vm_truncate_count;/* truncate_count or restart_addr */

#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
};

vm_area_struct结构体描述了进程地址空间内连续区间上的一个独立内存范围,每一个内存区域都使用该结构体表示,每一个结构体以双向链表的形式连接起来。除链表结构外,Linux 还利用红黑树mm_rb来组织 vm_area_struct。通过这种树结构,Linux 可以快速定位某个虚拟内存地址。

该结构体中成员vm_startvm_end表示内存区间的首地址和尾地址,两个值相减就是内存区间的长度。

成员vm_mm则指向其属于的进程地址空间结构体。所以两个不同的进程将同一个文件映射到自己的地址空间中,他们分别都会有一个vm_area_struct结构体来标识自己的内存区域。两个共享地址空间的线程则只有一个vm_area_struct结构体来标识,因为他们使用的是同一个进程地址空间。

vm_flags标识内存区域所包含的页面的行为和信息,反映内核处理页面所需要遵守的行为准则。

可以使用cat /proc/PID/maps命令和pmap命令查看给定进程空间和其中所含的内存区域。以笔者系统上进程号为17192的进程为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# cat /proc/17192/maps     //显示该进程地址空间中全部内存区域
001e3000-00201000 r-xp 00000000 fd:00 789547 /lib/ld-2.12.so
00201000-00202000 r--p 0001d000 fd:00 789547 /lib/ld-2.12.so
00202000-00203000 rw-p 0001e000 fd:00 789547 /lib/ld-2.12.so
00209000-00399000 r-xp 00000000 fd:00 789548 /lib/libc-2.12.so
00399000-0039a000 ---p 00190000 fd:00 789548 /lib/libc-2.12.so
0039a000-0039c000 r--p 00190000 fd:00 789548 /lib/libc-2.12.so
0039c000-0039d000 rw-p 00192000 fd:00 789548 /lib/libc-2.12.so
0039d000-003a0000 rw-p 00000000 00:00 0
08048000-08049000 r-xp 00000000 fd:00 1191771 /home/allen/Myprojects/blog/conn_user_kernel/test/a.out
08049000-0804a000 rw-p 00000000 fd:00 1191771 /home/allen/Myprojects/blog/conn_user_kernel/test/a.out
b7755000-b7756000 rw-p 00000000 00:00 0
b776d000-b776e000 rw-p 00000000 00:00 0
b776e000-b776f000 r-xp 00000000 00:00 0 [vdso]
bfc9f000-bfcb4000 rw-p 00000000 00:00 0 [stack]
#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# pmap 17192
17192: ./a.out
001e3000 120K r-x-- /lib/ld-2.12.so //本行和下面两行为动态链接程序ld.so的代码段、数据段、bss段
00201000 4K r---- /lib/ld-2.12.so
00202000 4K rw--- /lib/ld-2.12.so
00209000 1600K r-x-- /lib/libc-2.12.so //本行和下面为C库中libc.so的代码段、数据段和bss段
00399000 4K ----- /lib/libc-2.12.so
0039a000 8K r---- /lib/libc-2.12.so
0039c000 4K rw--- /lib/libc-2.12.so
0039d000 12K rw--- [ anon ]
08048000 4K r-x-- /home/allen/Myprojects/blog/conn_user_kernel/test/a.out //可执行对象的代码段
08049000 4K rw--- /home/allen/Myprojects/blog/conn_user_kernel/test/a.out //可执行对象的数据段
b7755000 4K rw--- [ anon ]
b776d000 4K rw--- [ anon ]
b776e000 4K r-x-- [ anon ]
bfc9f000 84K rw--- [ stack ] //堆栈段
total 1860K

结构体中vm_ops域指定内存区域相关操作函数表,内核使用表中方法操作VMA,操作函数表由vm_operations_struct结构体表示,定义在<include/linux/mm.h>文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* These are the virtual MM functions - opening of an area, closing and
* unmapping it (needed to keep files on disk up-to-date etc), pointer
* to the functions called when a no-page or a wp-page exception occurs.
*/
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area); //指定内存区域被加载到一个地址空间时函数被调用
void (*close)(struct vm_area_struct * area); //指定内存区域从地址空间删除时函数被调用
int (*fault)(struct vm_area_struct *vma, struct vm_fault *vmf); //没有出现在物理内存中的页面被访问时,页面故障处理调用该函数

/* notification that a previously read-only page is about to become
* writable, if an error is returned it will cause a SIGBUS */
int (*page_mkwrite)(struct vm_area_struct *vma, struct vm_fault *vmf);

/* called by access_process_vm when get_user_pages() fails, typically
* for use by special VMAs that can switch between memory and hardware
*/
int (*access)(struct vm_area_struct *vma, unsigned long addr,
void *buf, int len, int write);
#ifdef CONFIG_NUMA
......
#endif
};

在内核中,给定一个属于某个进程的虚拟地址,要求找到其所属的区间以及vma_area_struct结构,这通过find_vma()来实现,这种搜索通过红-黑树进行。该函数定义于<mm/mmap.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
/* Look up the first VMA which satisfies  addr < vm_end,  NULL if none. */
struct vm_area_struct *find_vma(struct mm_struct *mm, unsigned long addr)
{
struct vm_area_struct *vma = NULL;

if (mm) {
/* 首先检查最近使用的内存区域,看缓存的VMA是否包含所需地址 */
/* (命中率接近35%.) */
vma = mm->mmap_cache;
//如果缓存中不包含未包含希望的VMA,该函数搜索红-黑树。
if (!(vma && vma->vm_end > addr && vma->vm_start <= addr)) {
struct rb_node * rb_node;

rb_node = mm->mm_rb.rb_node;
vma = NULL;

while (rb_node) {
struct vm_area_struct * vma_tmp;

vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);

if (vma_tmp->vm_end > addr) {
vma = vma_tmp;
if (vma_tmp->vm_start <= addr)
break;
rb_node = rb_node->rb_left;
} else
rb_node = rb_node->rb_right;
}
if (vma)
mm->mmap_cache = vma;
}
}
return vma;
}

当某个程序的映像开始执行时,可执行映像必须装入到进程的虚拟地址空间。如果该进程用到了任何一个共享库,则共享库也必须装入到进程的虚拟地址空间。由此可看出,Linux并不将映像装入到物理内存,相反,可执行文件只是被连接到进程的虚拟地址空间中。随着程序的运行,被引用的程序部分会由操作系统装入到物理内存,这种将映像链接到进程地址空间的方法被称为“内存映射”。

当可执行映像映射到进程的虚拟地址空间时,将产生一组vm_area_struct结构来描述虚拟内存区间的起始点和终止点,每个vm_area_struct结构代表可执行映像的一部分,可能是可执行代码,也可能是初始化的变量或未初始化的数据,这些都是在函数do_mmap()中来实现的。随着vm_area_struct结构的生成,这些结构所描述的虚拟内存区间上的标准操作函数也由 Linux 初始化。

1
2
3
4
5
6
7
8
9
10
11
12
static inline unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flag, unsigned long offset)
{
unsigned long ret = -EINVAL;
if ((offset + PAGE_ALIGN(len)) < offset)
goto out;
if (!(offset & ~PAGE_MASK))
ret = do_mmap_pgoff(file, addr, len, prot, flag, offset >> PAGE_SHIFT);
out:
return ret;
}

该函数会将一个新的地址区间加入到进程的地址空间中。定义于<include/linux/mm.h>
函数中参数的含义:

  • file:表示要映射的文件。
  • offset:文件内的偏移量,因为我们并不是一下子全部映射一个文件,可能只是映射文件的一部分,off 就表示那部分的起始位置。
  • len:要映射的文件部分的长度。
  • addr:虚拟空间中的一个地址,表示从这个地址开始查找一个空闲的虚拟区。
  • prot:这个参数指定对这个虚拟区所包含页的存取权限。可能的标志有PROT_READPROT_WRITEPROT_EXECPROT_NONE。前 3 个标志与标志VM_READVM_WRITEVM_EXEC的意义一样。PROT_NONE表示进程没有以上 3 个存取权限中的任意一个。
  • flag`:这个参数指定虚拟区的其他标志。

该函数调用do_mmap_pgoff()函数,该函数做内存映射的主要工作,该函数比较长,详细实现可查看<mm/mmap.c>文件。

由于文件到虚存的映射仅仅是建立了一种映射关系,虚存页面到物理页面之间的映射还没有建立。当某个可执行映象映射到进程虚拟内存中并开始执行时,因为只有很少一部分虚拟内存区间装入到了物理内存,很可能会遇到所访问的数据不在物理内存。这时,处理器将向 Linux 报告一个页故障及其对应的故障原因,

内核必须从磁盘映像或交换文件(此页被换出)中将其装入物理内存,这就是请页机制。