Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

decltype简介

我们之前使用的typeid运算符来查询一个变量的类型,这种类型查询在运行时进行。RTTI机制为每一个类型产生一个type_info类型的数据,而typeid查询返回的变量相应type_info数据,通过name成员函数返回类型的名称。同时在C++11中typeid还提供了hash_code这个成员函数,用于返回类型的唯一哈希值。RTTI会导致运行时效率降低,且在泛型编程中,我们更需要的是编译时就要确定类型,RTTI并无法满足这样的要求。编译时类型推导的出现正是为了泛型编程,在非泛型编程中,我们的类型都是确定的,根本不需要再进行推导。

而编译时类型推导,除了我们说过的auto关键字,还有本文的decltype。

decltype与auto关键字一样,用于进行编译时类型推导,不过它与auto还是有一些区别的。decltype的类型推导并不是像auto一样是从变量声明的初始化表达式获得变量的类型,而是总是以一个普通表达式作为参数,返回该表达式的类型,而且decltype并不会对表达式进行求值。

decltype用法

推导出表达式类型

1
2
int i = 4;
decltype(i) a; //推导结果为int。a的类型为int。

与using/typedef合用,用于定义类型。

1
2
3
4
5
6
7
8
9
10
using size_t = decltype(sizeof(0));//sizeof(a)的返回值为size_t类型
using ptrdiff_t = decltype((int*)0 - (int*)0);
using nullptr_t = decltype(nullptr);
vector<int >vec;

typedef decltype(vec.begin()) vectype;
for (vectype i = vec.begin; i != vec.end(); i++)
{
//...
}

这样和auto一样,也提高了代码的可读性。

重用匿名类型

在C++中,我们有时候会遇上一些匿名类型,如:

1
2
3
4
5
struct 
{
int d ;
doubel b;
}anon_s;

而借助decltype,我们可以重新使用这个匿名的结构体:

1
decltype(anon_s) as ;//定义了一个上面匿名的结构体

泛型编程中结合auto,用于追踪函数的返回值类型

这也是decltype最大的用途了。

1
2
3
4
5
template <typename _Tx, typename _Ty>
auto multiply(_Tx x, _Ty y)->decltype(_Tx*_Ty)
{
return x*y;
}

decltype推导四规则

  1. 如果e是一个没有带括号的标记符表达式或者类成员访问表达式,那么的decltype(e)就是e所命名的实体的类型。此外,如果e是一个被重载的函数,则会导致编译错误。
  2. 否则 ,假设e的类型是T,如果e是一个将亡值,那么decltype(e)为T&&
  3. 否则,假设e的类型是T,如果e是一个左值,那么decltype(e)为T&。
  4. 否则,假设e的类型是T,则decltype(e)为T。

标记符指的是除去关键字、字面量等编译器需要使用的标记之外的程序员自己定义的标记,而单个标记符对应的表达式即为标记符表达式。例如:

1
int arr[4]

则arr为一个标记符表达式,而arr[3]+0不是。

我们来看下面这段代码:

1
2
3
int i=10;
decltype(i) a; //a推导为int
decltype((i))b=i;//b推导为int&,必须为其初始化,否则编译错误

仅仅为i加上了(),就导致类型推导结果的差异。这是因为,i是一个标记符表达式,根据推导规则1,类型被推导为int。而(i)为一个左值表达式,所以类型被推导为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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
int i = 4;
int arr[5] = { 0 };
int *ptr = arr;
struct S{ double d; }s ;
void Overloaded(int);
void Overloaded(char);//重载的函数
int && RvalRef();
const bool Func(int);

//规则一:推导为其类型
decltype (arr) var1; //int 标记符表达式

decltype (ptr) var2;//int * 标记符表达式

decltype(s.d) var3;//doubel 成员访问表达式

//decltype(Overloaded) var4;//重载函数。编译错误。

//规则二:将亡值。推导为类型的右值引用。

decltype (RvalRef()) var5 = 1;

//规则三:左值,推导为类型的引用。

decltype ((i))var6 = i; //int&

decltype (true ? i : i) var7 = i; //int& 条件表达式返回左值。

decltype (++i) var8 = i; //int& ++i返回i的左值。

decltype(arr[5]) var9 = i;//int&. []操作返回左值

decltype(*ptr)var10 = i;//int& *操作返回左值

decltype("hello")var11 = "hello"; //const char(&)[9] 字符串字面常量为左值,且为const左值。


//规则四:以上都不是,则推导为本类型

decltype(1) var12;//const int

decltype(Func(1)) var13=true;//const bool

decltype(i++) var14 = i;//int i++返回右值

这里需要提示的是,字符串字面值常量是个左值,且是const左值,而非字符串字面值常量则是个右值。
这么多规则,对于我们写代码的来说难免太难记了,特别是规则三。我们可以利用C++11标准库中添加的模板类is_lvalue_reference来判断表达式是否为左值:
1
cout << is_lvalue_reference<decltype(++i)>::value << endl;

结果1表示为左值,结果为0为非右值。
同样的,也有is_rvalue_reference这样的模板类来判断decltype推断结果是否为右值。

几种继承及其特点

public的变量和函数在类的内部外部都可以访问。

protected的变量和函数只能在类的内部和其派生类中访问。

private修饰的元素只能在类内访问。

成员默认属性

  • struct的成员默认是公有的
  • class的成员默认是私有的
  • class继承默认是私有继承
  • struct的继承默认是公有的

公有继承方式(public)

注意事项:

  • 基类的私有成员,子类不可以访问
  • 基类的保护成员,子类可以继承为自己的保护成员,在派生类可以访问,在外部不可以访问。
  • 基类的公有成员,子类可以继承为自己的公有成员。在派生类可以访问,在外部也可以访问。

保护继承(protected)

  • 基类公有成员,子类中继承为自己的保护成员,在派生类可以访问,在外部不可以访问
  • 基类保护成员,子类中继承为自己的保护成员,在派生类可以访问,在外部不可以访问
  • 基类私有成员,子类一样不可以访问基类的私有成员。

私有继承(private)

私有继承方式的,就是在继承时,把protected变成private,它需要注意的事项为:

  1. 基类公有成员,子类中继承为自己的私有成员,在派生类可以访问,在外部不可以访问。
  2. 基类保护成员,子类中继承为自己的私有成员,在派生类可以访问,在外部不可以访问。
  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
class Base
{
public:
void fun()
{
cout << "Base::fun()" << endl;
}
};
// 子类 父类
class D :public Base // 继承
{
public:
void fun()
{
cout << "D::fun()" << endl;
}
void show()
{
cout << "D::shoe()" << endl;
}
};
void main()
{
D d;
Base *pb = &d;
pb->fun();// 只能访问子类中父类所有的fun函数
d.fun(); // 只能访问子类自己的fun函数
}

派生类的默认成员函数

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函 数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类 对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调基类的析构

继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。

同样我们看代码 如下:

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
//继承与静态成员
//基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。如下
class Test
{
public:
Test()
{
count++;
}
public:
int GetCount()const
{
return count;
}
//int GetCount()const
//{
// return GetOBJCount();
//}
private:
static int count;// 类的静态成员必须在类外初始化
//因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的
};
int Test::count = 0;
class D1 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
class D2 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
class D3 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
class D4 :public Test
{
public:
//int GetCount()const
//{
// return GetOBJCount();
//}
};
void main()
{
D1 d1;
cout << d1.GetCount() << endl;
D2 d2;
cout << d2.GetCount() << endl;
D3 d3;
cout << d3.GetCount() << endl;
D4 d4;
cout << d4.GetCount() << endl;
}

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一 个static成员实例 。

static修饰的成员,只能在类中进行声明,类外定义,原因是因为静态成员属于整个类,而不属于某个对象,如果在类内初始化,会导致每个对象都包含该静态成员,这是矛盾的。

多态性

(1)解释多态性:函数的多种不同的实现方式即为多态

(2)必要性:在继承中,有时候基类的一些函数在派生类中也是有用的,但是功能不够全或者两者的功能实现方式就是不一样的,这个时候就希望重载那个基类的函数,但是为了不再调用这个函数时,出现不知道调用基类的还是子类的情况出现,于是就提出了多态。如果语言不知多态,则不能称为面向对象的。

(3)多态性是如何实现的:多态是实现是依赖于虚函数来实现的,之所以虚函数可以分清楚当前调用的函数是基类的还是派生类的,主要在于基类和派生类分别有着自己的虚函数表,再调用虚函数时,它们是通过去虚函数表去对应的函数的。

其实虚函数表的本质就是一种迟后联编的过程,正常编译都是先期联编的,但是当代码遇到了virtual时,就会把它当做迟后联编,但是为了迟后编译,就生成了局部变量–虚函数表,这就增大了一些空间上的消耗。(前提是两个函数的返回类型,参数类型,参数个数都得相同,不然就起不到多态的作用)

使用虚函数的一些限制

  1. 只有类成员函数才能声明为虚函数,这是因为虚函数只适用于有继承关系的类对象中。
  2. 静态成员函数不能说明为虚函数,因为静态成员函数不受限与某个对象,整个内存中只有一个,所以不会出现混淆的情况
  3. 内联函数不可以被继承,因为内联函数是不能子啊运行中动态的确认其位置的。
  4. 构造函数不可以被继承。
  5. 析构函数可以被继承,而且通常声明为虚函数。

纯虚函数

(1)解释:虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态重载。纯虚函数的声明有着特殊的语法格式:virtual 返回值类型成员函数名(参数表)=0;

(2)必要性:在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重载以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。

(3)抽象类的解释:包含纯虚函数的类称为抽象类。由于抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。在C++中,抽象类只能用于被继承而不能直接创建对象的类(Abstract 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
#include<iostream>
#include<cmath>
using namespace std;

class A
{
public :
virtual void fun() = 0;
};
class B :public A
{
public :
virtual void fun()
{
cout << "B: " << endl;

}
};

int main()
{
B b;
b.fun();
return 0;
}

继承权限

  • public继承
    • 公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问
  • protected继承
    • 保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的.
  • private继承
    • 私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承

模板类的继承

模板类的继承包括四种:

1.(普通类继承模板类)

1
2
3
4
5
6
7
8
template<class T>
class TBase{
T data;
……
};
class Derived:public TBase<int>{
……
};

2.(模板类继承了普通类(非常常见))

1
2
3
4
5
6
7
8
class TBase{
……
};
template<class T>
class TDerived:public TBase{
T data;
……
};

3.(类模板继承类模板)

1
2
3
4
5
6
7
8
9
10
template<class T>
class TBase{
T data1;
……
};
template<class T1,class T2>
class TDerived:public TBase<T1>{
T2 data2;
……
};

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
#include<iostream>
using namespace std;

class BaseA{
public:
BaseA(){cout<<"BaseA founed"<<endl;}
};
class BaseB{
public:
BaseB(){cout<<"BaseB founed"<<endl;}
};
template<typename T, int rows>
class BaseC{
private:
T data;
public:
BaseC():data(rows){
cout<<"BaseC founed "<< data << endl;}
};
template<class T>
class Derived:public T{
public:
Derived():T(){cout<<"Derived founed"<<endl;}
};

void main()
{
Derived<BaseA> x;// BaseA作为基类
Derived<BaseB> y;// BaseB作为基类
Derived<BaseC<int, 3> > z; // BaseC<int,3>作为基类
}

reverse函数

C++ < algorithm > 中定义的reverse函数用于反转在[first,last)范围内的顺序

1
2
template <class BidirectionalIterator>
void reverse (BidirectionalIterator first,BidirectionalIterator last);

例如,交换vector容器中元素的顺序
1
2
vector<int> v={1,2,3,4,5};
reverse(v.begin(),v.end());//v的值为5,4,3,2,1

当然,你也可以通过它方便的反转string类的字符串
1
2
string str="C++REVERSE";
reverse(str.begin(),str.end());//str结果为ESREVER++C

该函数等价于通过调用iter_swap来交换元素位置

1
2
3
4
5
6
7
8
9
template <class BidirectionalIterator>
void reverse (BidirectionalIterator first, BidirectionalIterator last)
{
while ((first!=last)&&(first!=--last))
{
std::iter_swap (first,last);
++first;
}
}

C++中constexpr作用

constexpr 是 C++ 11 标准新引入的关键字,不过在讲解其具体用法和功能之前,读者需要先搞清楚 C++ 常量表达式的含义。

所谓常量表达式,指的就是由多个(≥1)常量组成的表达式。换句话说,如果表达式中的成员都是常量,那么该表达式就是一个常量表达式。这也意味着,常量表达式一旦确定,其值将无法修改。

实际开发中,我们经常会用到常量表达式。以定义数组为例,数组的长度就必须是一个常量表达式:

1
2
3
4
5
6
7
// 1)
int url[10];//正确
// 2)
int url[6 + 4];//正确
// 3)
int length = 6;
int url[length];//错误,length是变量

上述代码演示了 3 种定义 url 数组的方式,其中第 1、2 种定义 url 数组时,长度分别为 10 和 6+4,显然它们都是常量表达式,可以用于表示数组的长度;第 3 种 url 数组的长度为 length,它是变量而非常量,因此不是一个常量表达式,无法用于表示数组的长度。
常量表达式的应用场景还有很多,比如匿名枚举、switch-case 结构中的 case 表达式等,感兴趣的读者可自行编码测试,这里不再过多举例。

我们知道,C++ 程序的执行过程大致要经历编译、链接、运行这 3 个阶段。值得一提的是,常量表达式和非常量表达式的计算时机不同,非常量表达式只能在程序运行阶段计算出结果;而常量表达式的计算往往发生在程序的编译阶段,这可以极大提高程序的执行效率,因为表达式只需要在编译阶段计算一次,节省了每次程序运行时都需要计算一次的时间。

对于用 C++ 编写的程序,性能往往是永恒的追求。那么在实际开发中,如何才能判定一个表达式是否为常量表达式,进而获得在编译阶段即可执行的“特权”呢?除了人为判定外,C++11 标准还提供有 constexpr 关键字。

constexpr 关键字的功能是使指定的常量表达式获得在程序编译阶段计算出结果的能力,而不必等到程序运行阶段。C++ 11 标准中,constexpr 可用于修饰普通变量、函数(包括模板函数)以及类的构造函数。
注意,获得在编译阶段计算出结果的能力,并不代表 constexpr 修饰的表达式一定会在程序编译阶段被执行,具体的计算时机还是编译器说了算。

constexpr修饰普通变量

C++11 标准中,定义变量时可以用 constexpr 修饰,从而使该变量获得在编译阶段即可计算出结果的能力。

值得一提的是,使用 constexpr 修改普通变量时,变量必须经过初始化且初始值必须是一个常量表达式。举个例子:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;
int main()
{
constexpr int num = 1 + 2 + 3;
int url[num] = {1,2,3,4,5,6};
couts<< url[1] << endl;
return 0;
}

程序执行结果为:
1
2

读者可尝试将 constexpr 删除,此时编译器会提示“url[num] 定义中 num 不可用作常量”。

可以看到,程序第 6 行使用 constexpr 修饰 num 变量,同时将 “1+2+3” 这个常量表达式赋值给 num。由此,编译器就可以在编译时期对 num 这个表达式进行计算,因为 num 可以作为定义数组时的长度。

有读者可能发现,将此示例程序中的 constexpr 用 const 关键字替换也可以正常执行,这是因为 num 的定义同时满足“num 是 const 常量且使用常量表达式为其初始化”这 2 个条件,由此编译器会认定 num 是一个常量表达式。
注意,const 和 constexpr 并不相同,关于它们的区别,我们会在下一节做详细讲解。

另外需要重点提出的是,当常量表达式中包含浮点数时,考虑到程序编译和运行所在的系统环境可能不同,常量表达式在编译阶段和运行阶段计算出的结果精度很可能会受到影响,因此 C++11 标准规定,浮点常量表达式在编译阶段计算的精度要至少等于(或者高于)运行阶段计算出的精度。

constexpr修饰函数

constexpr 还可以用于修饰函数的返回值,这样的函数又称为“常量表达式函数”。

注意,constexpr 并非可以修改任意函数的返回值。换句话说,一个函数要想成为常量表达式函数,必须满足如下 4 个条件。

1) 整个函数的函数体中,除了可以包含 using 指令、typedef 语句以及 static_assert 断言外,只能包含一条 return 返回语句。

举个例子:

1
2
3
4
constexpr int display(int x) {
int ret = 1 + 2 + x;
return ret;
}

注意,这个函数是无法通过编译的,因为该函数的返回值用 constexpr 修饰,但函数内部包含多条语句。

如下是正确的定义 display() 常量表达式函数的写法:

1
2
3
4
constexpr int display(int x) {
//可以添加 using 执行、typedef 语句以及 static_assert 断言
return 1 + 2 + x;
}

可以看到,display() 函数的返回值是用 constexpr 修饰的 int 类型值,且该函数的函数体中只包含一个 return 语句。

2) 该函数必须有返回值,即函数的返回值类型不能是 void。

举个例子:

1
2
3
constexpr void display() {
//函数体
}

像上面这样定义的返回值类型为 void 的函数,不属于常量表达式函数。原因很简单,因为通过类似的函数根本无法获得一个常量。

3) 函数在使用之前,必须有对应的定义语句。我们知道,函数的使用分为“声明”和“定义”两部分,普通的函数调用只需要提前写好该函数的声明部分即可(函数的定义部分可以放在调用位置之后甚至其它文件中),但常量表达式函数在使用前,必须要有该函数的定义。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;
//普通函数的声明
int noconst_dis(int x);
//常量表达式函数的声明
constexpr int display(int x);
//常量表达式函数的定义
constexpr int display(int x){
return 1 + 2 + x;
}
int main()
{
//调用常量表达式函数
int a[display(3)] = { 1,2,3,4 };
cout << a[2] << endl;
//调用普通函数
cout << noconst_dis(3) << endl;
return 0;
}
//普通函数的定义
int noconst_dis(int x) {
return 1 + 2 + x;
}

程序执行结果为:
1
2
3
6

读者可自行将 display() 常量表达式函数的定义调整到 main() 函数之后,查看编译器的报错信息。

可以看到,普通函数在调用时,只需要保证调用位置之前有相应的声明即可;而常量表达式函数则不同,调用位置之前必须要有该函数的定义,否则会导致程序编译失败。

4) return 返回的表达式必须是常量表达式,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
using namespace std;
int num = 3;
constexpr int display(int x){
return num + x;
}
int main()
{
//调用常量表达式函数
int a[display(3)] = { 1,2,3,4 };
return 0;
}

该程序无法通过编译,编译器报“display(3) 的结果不是常量”的异常。

常量表达式函数的返回值必须是常量表达式的原因很简单,如果想在程序编译阶段获得某个函数返回的常量,则该函数的 return 语句中就不能包含程序运行阶段才能确定值的变量。

注意,在常量表达式函数的 return 语句中,不能包含赋值的操作(例如 return x=1 在常量表达式函数中不允许的)。另外,用 constexpr 修改函数时,函数本身也是支持递归的,感兴趣的读者可自行尝试编码测试。

constexpr修饰类的构造函数

对于 C++ 内置类型的数据,可以直接用 constexpr 修饰,但如果是自定义的数据类型(用 struct 或者 class 实现),直接用 constexpr 修饰是不行的。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;
//自定义类型的定义
constexpr struct myType {
const char* name;
int age;
//其它结构体成员
};
int main()
{
constexpr struct myType mt { "zhangsan", 10 };
cout << mt.name << " " << mt.age << endl;
return 0;
}

此程序是无法通过编译的,编译器会抛出“constexpr不能修饰自定义类型”的异常。

当我们想自定义一个可产生常量的类型时,正确的做法是在该类型的内部添加一个常量构造函数。例如,修改上面的错误示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;
//自定义类型的定义
struct myType {
constexpr myType(char *name,int age):name(name),age(age){};
const char* name;
int age;
//其它结构体成员
};
int main()
{
constexpr struct myType mt { "zhangsan", 10 };
cout << mt.name << " " << mt.age << endl;
return 0;
}

程序执行结果为:
1
zhangsan 10

可以看到,在 myType 结构体中自定义有一个构造函数,借助此函数,用 constexpr 修饰的 myType 类型的 my 常量即可通过编译。

注意,constexpr 修饰类的构造函数时,要求该构造函数的函数体必须为空,且采用初始化列表的方式为各个成员赋值时,必须使用常量表达式。

前面提到,constexpr 可用于修饰函数,而类中的成员方法完全可以看做是“位于类这个命名空间中的函数”,所以 constexpr 也可以修饰类中的成员函数,只不过此函数必须满足前面提到的 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
#include <iostream>
using namespace std;
//自定义类型的定义
class myType {
public:
constexpr myType(const char *name,int age):name(name),age(age){};
constexpr const char * getname(){
return name;
}
constexpr int getage(){
return age;
}
private:
const char* name;
int age;
//其它结构体成员
};
int main()
{
constexpr struct myType mt { "zhangsan", 10 };
constexpr const char * name = mt.getname();
constexpr int age = mt.getage();
cout << name << " " << age << endl;
return 0;
}

程序执行结果为:
1
zhangsan 10

注意,C++11 标准中,不支持用 constexpr 修饰带有 virtual 的成员方法。

constexpr修饰模板函数

C++11 语法中,constexpr 可以修饰模板函数,但由于模板中类型的不确定性,因此模板函数实例化后的函数是否符合常量表达式函数的要求也是不确定的。

针对这种情况下,C++11 标准规定,如果 constexpr 修饰的模板函数实例化结果不满足常量表达式函数的要求,则 constexpr 会被自动忽略,即该函数就等同于一个普通函数。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
using namespace std;
//自定义类型的定义
struct myType {
const char* name;
int age;
//其它结构体成员
};
//模板函数
template<typename T>
constexpr T dispaly(T t){
return t;
}
int main()
{
struct myType stu{"zhangsan",10};
//普通函数
struct myType ret = dispaly(stu);
cout << ret.name << " " << ret.age << endl;
//常量表达式函数
constexpr int ret1 = dispaly(10);
cout << ret1 << endl;
return 0;
}

程序执行结果为:
1
2
zhangsan 10
10

可以看到,示例程序中定义了一个模板函数 display(),但由于其返回值类型未定,因此在实例化之前无法判断其是否符合常量表达式函数的要求:

第 20 行代码处,当模板函数中以自定义结构体 myType 类型进行实例化时,由于该结构体中没有定义常量表达式构造函数,所以实例化后的函数不是常量表达式函数,此时 constexpr 是无效的;

第 23 行代码处,模板函数的类型 T 为 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
29
30
31
32
33
#include <iostream>
using namespace std;
// C++98/03
template<int N> struct Factorial
{
const static int value = N * Factorial<N - 1>::value;
};
template<> struct Factorial<0>
{
const static int value = 1;
};
// C++11
constexpr int factorial(int n)
{
return n == 0 ? 1 : n * factorial(n - 1);
}
// C++14
constexpr int factorial2(int n)
{
int result = 1;
for (int i = 1; i <= n; ++i)
result *= i;
return result;
}

int main()
{
static_assert(Factorial<3>::value == 6, "error");
static_assert(factorial(3) == 6, "error");
static_assert(factorial2(3) == 6, "error");
int n = 3;
cout << factorial(n) << factorial2(n) << endl; //66
}

代码说明:

  • 以上代码演示了如何在编译期计算3的阶乘。
  • 在C++11之前,在编译期进行数值计算必须使用模板元编程技巧。具体来说我们通常需要定义一个内含编译期常量value的类模板(也称作元函数)。这个类模板的定义至少需要分成两部分,分别用于处理一般情况和特殊情况。
  • 代码示例中Factorial元函数的定义分为两部分:
    • 当模板参数大于0时,利用公式 N!=N*(N-1)! 递归调用自身来计算value的值。
    • 当模板参数为0时,将value设为1这个特殊情况下的值。
  • 在C++11之后,编译期的数值计算可以通过使用constexpr声明并定义编译期函数来进行。相对于模板元编程,使用constexpr函数更贴近普通的C++程序,计算过程显得更为直接,意图也更明显。
  • 但在C++11中constexpr函数所受到的限制较多,比如函数体通常只有一句return语句,函数体内既不能声明变量,也不能使用for语句之类的常规控制流语句。
  • 如factorial函数所示,使用C++11在编译期计算阶乘仍然需要利用递归技巧。
  • C++14解除了对constexpr函数的大部分限制。在C++14的constexpr函数体内我们既可以声明变量,也可以使用goto和try之外大部分的控制流语句。
  • 如factorial2函数所示,使用C++14在编译期计算阶乘只需利用for语句进行常规计算即可。
  • 虽说constexpr函数所定义的是编译期的函数,但实际上在运行期constexpr函数也能被调用。事实上,如果使用编译期常量参数调用constexpr函数,我们就能够在编译期得到运算结果;而如果使用运行期变量参数调用constexpr函数,那么在运行期我们同样也能得到运算结果。
  • 代码第32行所演示的是在运行期使用变量n调用constexpr函数的结果。
  • 准确的说,constexpr函数是一种在编译期和运行期都能被调用并执行的函数。出于constexpr函数的这个特点,在C++11之后进行数值计算时,无论在编译期还是运行期我们都可以统一用一套代码来实现。编译期和运行期在数值计算这点上得到了部分统一。

const的用法

const是不改变的。在C和C++中,我们使用关键字const来使程序元素保持不变。const关键字可以在C++程序的许多上下文中使用。它可以用于:变量、指针、函数参数和返回类型、类数据成员、类成员函数、对象。

  1. 修饰变量,说明该变量不可以被改变;
  2. 修饰指针,分为指向常量的指针(pointer to const)和自身是常量的指针(常量指针,const pointer);
  3. 修饰引用,指向常量的引用(reference to const),用于形参类型,即避免了拷贝,又避免了函数对值的修改;
  4. 修饰成员函数,说明该成员函数内不能修改成员变量。

下面的声明都是什么意思?

1
2
3
4
5
const int a; a是一个常整型数
int const a; a是一个常整型数
const int *a; a是一个指向常整型数的指针,整型数是不可修改的,但指针可以
int * const a; a为指向整型数的常指针,指针指向的整型数可以修改,但指针是不可修改的
int const * a const; a是一个指向常整型数的常指针,指针指向的整型数是不可修改的,同时指针也是不可修改的

const变量

如果你用const关键字做任何变量,你就不能改变它的值。同样,必须在声明的时候初始化常数变量。
Example:

1
2
3
4
5
6
int main
{
const int i = 10;
const int j = i + 10; // works fine
i++; // this leads to Compile time error
}

上面的代码中,我们使 i 成为常量,因此如果我们试图改变它的值,我们将得到编译时错误。尽管我们可以用它来代替其他变量。

指针与const关键字

指针也可以使用const关键字来声明。当我们使用const和指针时,我们可以用两种方式来做:可以把const应用到指针指向的地方,或者我们可以使指针本身成为一个常数。

指向const变量的指针:

意味着指针指向一个const变量。

1
const int* u;

这里,表示u是一个指针,可以指向const int类型变量。指针指向的内容不可改变。简称左定值,因为const位于*号的左边。

我们也可以这样写,

1
char const* v;

表示v是指向const类型的char的指针。
指向const变量的指针非常有用,因为它可以用来使任何字符串或数组不可变

const指针

为了使指针保持不变,我们必须把const关键字放到右边。对于const指针p其指向的内存地址不能够被改变,但其内容可以改变。简称,右定向。因为const位于*号的右边。

1
2
int x = 1;
int* const w = &x;

里,w是一个指针,它是const,指向一个int,现在我们不能改变指针,这意味着它总是指向变量x但是可以改变它指向的值,通过改变x的值。

当你想要一个可以在值中改变但不会在内存中移动的存储器时,常量指针指向一个变量是很有用的。因为指针总是指向相同的内存位置,因为它是用const关键字定义的,但是那个内存位置的值可以被更改。
左定值,右定向,const修饰不变量

const函数参数和返回类型

1
2
3
4
5
6
7
8
9
void f(const int i)
{
i++; // error
}

const int g()
{
return 1;
}

注意几个要点:

①对于内置数据类型,返回const或非const值,不会有任何影响。

1
2
3
4
5
6
7
8
9
10
const int h()
{
return 1;
}

int main()
{
const int j = h();
int k = h();
}

j和k都将被赋值为1。不会出现错误。

②对于用户定义的数据类型,返回const,将阻止它的修改。此时返回的值不能作为左值使用,既不能被赋值,也不能被修改。const 修饰返回的指针或者引用,是否返回一个指向 const 的指针,取决于我们想让用户干什么。

③在程序执行时创建的临时对象总是const类型。值传递的 const 修饰传递,一般这种情况不需要 const 修饰,因为函数会自动产生临时变量复制实参值。
当 const 参数为指针时,可以防止指针被意外篡改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include<iostream>

using namespace std;

void Cpf(int *const a)
{
cout<<*a<<" ";
*a = 9;
}

int main(void)
{
int a = 8;
Cpf(&a);
cout<<a; // a 为 9
system("pause");
return 0;
}

自定义类型的参数传递,需要临时对象复制参数,对于临时对象的构造,需要调用构造函数,比较浪费时间,因此我们采取 const 外加引用传递的方法。并且对于一般的 int、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
#include<iostream>

using namespace std;

class Test
{
public:
Test(){}
Test(int _m):_cm(_m){}
int get_cm()const
{
return _cm;
}

private:
int _cm;
};

void Cmf(const Test& _tt)
{
cout<<_tt.get_cm();
}

int main(void)
{
Test t(8);
Cmf(t);
system("pause");
return 0;
}

④如果一个函数有一个非const参数,它在发出调用时不能传递const参数。

1
2
3
4
void t(int*) 
{
// function logic
}

如果我们把一个const int参数传递给函数t,会出现错误。

⑤但是,一个具有const类型参数的函数,可以传递一个const类型参数以及一个非const参数。

1
2
3
4
void g(const int*) 
{
// function logic
}

这个函数可以有一个int,也可以有const int类型参数。

const修饰函数返回值

(1)指针传递

如果返回const data,non-const pointer,返回值也必须赋给const data,non-const pointer。因为指针指向的数据是常量不能修改。

1
2
3
4
5
6
7
8
9
10
11
const int * mallocA(){  ///const data,non-const pointer
int *a=new int(2);
return a;
}

int main()
{
const int *a = mallocA();
///int *b = mallocA(); ///编译错误
return 0;
}

(2)值传递

如果函数返回值采用“值传递方式”,由于函数会把返回值复制到外部临时的存储单元中,加const 修饰没有任何价值。所以,对于值传递来说,加const没有太多意义。

所以:

  • 不要把函数int GetInt(void)写成const int GetInt(void)
  • 不要把函数A GetA(void)写成const A GetA(void),其中A 为用户自定义的数据类型。

将类数据成员定义为const

这些是类中的数据变量,使用const关键字定义。它们在声明期间未初始化。它们的初始化在构造函数中完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Test
{
const int i;
public:
Test (int x) : i(x)
{
}
};

int main()
{
Test t(10);
Test s(20);
}

在这个程序中,i 是一个常量数据成员,在每个对象中它的独立副本将会出现,因此它使用构造函数对每个对象进行初始化。一旦初始化,它的值就不能改变

把类对象定义为const

当一个对象被声明或使用const关键字创建时,它的数据成员在对象的生命周期中永远不会被改变。

语法:

1
const class_name object;

例如,如果在上面定义的类测试中,我们想要定义一个常数对象,我们可以这样做:
1
const Test r(30);

将类的成员函数定义为const

const成员函数决不会修改对象中的数据成员。注意:const关键字不能与static关键字同时使用,因为static关键字修饰静态成员函数,静态成员函数不含有this指针,即不能实例化,const成员函数必须具体到某一实例。

如果有个成员函数想修改对象中的某一个成员怎么办?这时我们可以使用mutable关键字修饰这个成员,mutable的意思也是易变的,容易改变的意思,被mutable关键字修饰的成员可以处于不断变化中。

const成员函数不能调用非const成员函数,因为非const成员函数可以会修改成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
class Point{
public :
Point(int _x):x(_x){}
void testConstFunction(int _x) const{

///错误,在const成员函数中,不能修改任何类成员变量
x=_x;

///错误,const成员函数不能调用非onst成员函数,因为非const成员函数可以会修改成员变量
modify_x(_x);
}
void modify_x(int _x){
x=_x;
}
int x;
};

语法:

1
return_type function_name() const;

const对象和const成员函数的例子:

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 StarWars
{
public:
int i;
StarWars(int x) // constructor
{
i = x;
}

int falcon() const // constant function
{
/*
can do anything but will not
modify any data members
*/
cout << "Falcon has left the Base";
}

int gamma()
{
i++;
}
};

int main()
{
StarWars objOne(10); // non const object
const StarWars objTwo(20); // const object

objOne.falcon(); // No error
objTwo.falcon(); // No error

cout << objOne.i << objTwo.i;

objOne.gamma(); // No error
objTwo.gamma(); // Compile time error
}

输出结果:
1
2
3
Falcon has left the Base
Falcon has left the Base
10 20

在这里,我们可以看到,const成员函数永远不会改变类的数据成员,并且它可以与const和非const对象一起使用。但是const对象不能与试图改变其数据成员的成员函数一起使用。

关于const的疑问:

const常量的判别标准:

  1. 只有字面量初始化的const常量才会进入符号表
  2. 使用其他变量初始化的const常量仍然是只读变量
  3. 被volatile修饰的const常量不会进入符号表

注意:

  1. const引用的类型与初始化变量的类型相同时:初始化变量成为只读变量
  2. const引用的类型与初始化变量的类型不相同时:初生成一个新的只读变量

Example:

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
#include <stdio.h>

int main()
{
const int x = 1; //字面量初始化,此时x为常量,进入符号表
const int& rx = x; //rx代表只读变量

int& nrx = const_cast<int&>(rx); //去掉rx的只读属性

nrx = 5; //改变了nrx内存空间的值

printf("x = %d\n", x); // 1
printf("rx = %d\n", rx); // 5
printf("nrx = %d\n", nrx); // 5
printf("&x = %p\n", &x); // &x = 002CFD80
printf("&rx = %p\n", &rx); // &x = 002CFD80
printf("&nrx = %p\n", &nrx); // &x = 002CFD80
//输出的地址相同,说明了x、rx、nrx代表同样的内存空间

volatile const int y = 2;//volatile代表易变的
int* p = const_cast<int*>(&y);

*p = 6;

printf("y = %d\n", y); //y = 6
printf("p = %p\n", p); //p = 001BF928

//判别是否是常量是编译器在编译时能不能确认它的值
const int z = y;

p = const_cast<int*>(&z);

*p = 7;

printf("z = %d\n", z); // z = 7
printf("p = %p\n", p); //p = 001BF910


char c = 'c';
char& rc = c;
const int& trc = c;

rc = 'a';

printf("c = %c\n", c); // c = a
printf("rc = %c\n", rc);// rc = a
printf("trc = %c\n", trc);//trc = c
//变量c是char类型,而trc是int类型,所以生成了一个新的只读变量

return 0;
}

输出结果:

const与#define的区别

  • const定义的常量是变量带类型,而#define定义的只是个常数不带类型;
  • define只在预处理阶段起作用,简单的文本替换,而const在编译、链接过程中起作用;
  • define只是简单的字符串替换没有类型检查。而const是有数据类型的,是要进行判断的,可以避免一些低级错误;
  • define预处理后,占用代码段空间,const占用数据段空间;
  • const不能重定义,而define可以通过#undef取消某个符号的定义,进行重定义;
  • define独特功能,比如可以用来防止文件重复引用。

const重载

可以看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A {
int count() {
std::cout << "non const" << std::endl;
return 1;
}

int count() const {
std::cout << "const" << std::endl;
return 1;
}
};

int main() {
A a;
a.count();
const A b;
b.count();
}

这段代码输出的是这样:

1
2
non const
const

const修饰的对象调用的是使用const修饰的方法,非const对象调用的是非const的方法。

看下面的这段代码:

1
2
A a;
a.func();

其实到底层,函数可能会变成这样:

1
func(A* a);

函数是在代码段,对象是在数据段,调用不同对象的函数,其实只不过是同一个函数,传递了不同的数据参数而已。

上面的是把对象的this指针传进去。

再回到上面的代码:

1
2
3
4
5
6
7
8
9
10
struct A {
int count() {
std::cout << "non const" << std::endl;
return 1;
}
int count() const {
std::cout << "const" << std::endl;
return 1;
}
};

可以理解为:

1
2
int count(A *);
int count(const A*);

咦,这不就是重载吗,难道还有const重载?

还真有,看下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct A {
int count(const int& s) {
std::cout << "const" << std::endl;
return 1;
}

int count(int& s) {
std::cout << "non const" << std::endl;
return 1;
}
};

int main() {
A a;
a.count(4);
int c = 5;
a.count(c);
}

输出如下:

1
2
const
non const

所以得出结论:

不只是参数类型和个数不同会产生重载,const修饰的参数也会有重载。

但是只有当const修饰的是指针或者引用类型时才可以,普通的int和const int会编译失败的,具体大家可以自己写代码试试。

宏定义#define的理解与资料整理

利用define来定义 数值宏常量

#define宏定义是个演技非常高超的替身演员,但也会经常耍大牌的,所以我们用它要慎之又慎。它可以出现在代码的任何地方,从本行宏定义开始,以后的代码就就都认识这个宏了;也可以把任何东西定义成宏。因为编译器会在预编译的时候用真身替换替身,而在我们的代码里面却又用常常用替身来帮忙。

看例子:

1
#define PI 3.141592654

在此后的代码中你尽可以使用PI 来代替3.141592654,而且你最好就这么做。不然的话,如果我要把PI 的精度再提高一些,你是否愿意一个一个的去修改这串数呢?你能保证不漏不出错?而使用PI 的话,我们却只需要修改一次(这是十分高效的)。

这种情况还不是最要命的,我们再看一个例子:

1
#define ERROR_POWEROFF  -1

如果你在代码里不用ERROR_POWEROFF 这个宏而用-1,尤其在函数返回错误代码的时候(往往一个开发一个系统需要定义很多错误代码)。肯怕上帝都无法知道-1 表示的是什么意思吧。这个-1,我们一般称为“魔鬼数”,上帝遇到它也会发狂的。所以,我奉劝你代码里一定不要出现“魔鬼数”。(这里是从代码可读性的角度进行考虑!)

但是我们利用define来定义数值类型的数据,一般只是用来定义 常量 ,如果 要定义一些变量,则可以使用c语言中const这个关键字。

我们已经讨论了const 这个关键字,我们知道const 修饰的数据是有类型的,而define 宏定义的数据没有类型。为了安全,我建议你以后在定义一些宏常数的时候用const代替,编译器会给const 修饰的只读变量做类型校验,减少错误的可能。

但一定要注意const修饰的不是常量而是readonly 的变量,const 修饰的只读变量不能用来作为定义数组的维数,也不能放在case 关键字后面。

利用define来定义 字符串宏常量

除了定义宏常数之外,经常还用来定义字符串,尤其是路径:

1
2
#define ENG_PATH_1 E:\English\listen_to_this\listen_to_this_3
#define ENG_PATH_2 “E:\English\listen_to_this\listen_to_this_3”

噢,到底哪一个正确呢?如果路径太长,一行写下来比较别扭怎么办?用反斜杠接续符 ‘\’ 啊:

1
#define ENG_PATH_3 E:\English\listen_to_this\listen\_to_this_3

还没发现问题?这里用了4 个反斜杠,到底哪个是接续符?回去看看接续符反斜杠。

反斜杠作为接续符时,在本行其后面不能再有任何字符,空格都不行。所以,只有最后一个反斜杠才是接续符。至于A)和B),那要看你怎么用了,既然define 宏只是简单的替换,那给ENG_PATH_1 加上双引号不就成了:“ENG_PATH_1”。

但是请注意:有的系统里规定路径的要用双反斜杠“\\”,比如(这是正确的版本):

1
#define ENG_PATH_4 E:\\English\\listen_to_this\\listen_to_this_3

用define 宏定义注释符号

上面对define 的使用都很简单,再看看下面的例子:

1
2
3
4
5
#define BSC //
#define BMC /*
#define EMC */
BSC my single-line comment
BMC my multi-line comment EMC

D)和E)都错误,为什么呢?因为注释先于预处理指令被处理,当这两行被展开成//…或//时,注释已处理完毕,此时再出现//…或//自然错误。

因此,试图用宏开始或结束一段注释是不行的。

用define 宏定义表达式

这些都好理解,下面来点有“技术含量”的,定义一年有多少秒:

1
#define SEC_A_YEAR 60*60*24*365

这个定义没错吧?很遗憾,很有可能错了,至少不可靠。你有没有考虑在16 位系统下把这样一个数赋给整型变量的时候可能会发生溢出?一年有多少秒也不可能是负数吧。

改一下:

1
#define SEC_A_YEAR (60*60*24*365)UL

又出现一个问题,这里的括号到底需不需要呢?继续看一个例子,定义一个宏函数,求x 的平方:
1
#define SQR (x) x * x

对不对?试试:假设x 的值为10,SQR (x)被替换后变成10*10。没有问题。

再试试:假设x 的值是个表达式10+1,SQR (x)被替换后变成10+1*10+1。问题来了,这并不是我想要得到的。怎么办?括号括起来不就完了?

1
#define SQR (x) ((x)*(x))

最外层的括号最好也别省了,看例子,求两个数的和:
1
#define SUM (x) (x)+(x)

如果x 的值是个表达式5*3,而代码又写成这样:SUM (x)* SUM (x)。替换后变成:(5*3)+(5*3)*(5*3)+(5*3)。又错了!所以最外层的括号最好也别省了。我说过define是个演技高超的替身演员,但也经常耍大牌。要搞定它其实很简单,别吝啬括号就行了。

注意这一点:宏函数被调用时是以实参代换形参。而不是“值传送”。

宏定义中的空格

另外还有一个问题需要引起注意,看下面例子:

1
#define SUM (x) (x)+(x)

编译器认为这是定义了一个宏:SUM,其代表的是(x) (x)+(x)。

为什么会这样呢?其关键问题还是在于SUM 后面的这个空格。所以在定义宏的时候一定要注意什么时候该用空格,什么时候不该用空格。这个空格仅仅在定义的时候有效,在使用这个宏函数的时候,空格会被编译器忽略掉。也就是说,上一节定义好的宏函数SUM(x)在使用的时候在SUM 和(x)之间留有空格是没问题的。比如:SUM(3)和SUM (3)的意思是一样的。

undef

#undef是用来撤销宏定义的,用法如下:

1
2
3
4
5
#define PI 3.141592654

// code
#undef PI
//下面的代码就不能用PI 了,它已经被撤销了宏定义。

写好C语言,漂亮的宏定义很重要,使用宏定义可以防止出错,提高可移植性,可读性,方便性 等等。下面列举一些成熟软件中常用得宏定义:

防止一个头文件被重复包含

1
2
3
4
5
6
7
#ifndef COMDEF_H

#define COMDEF_H

//头文件内容

#endif

重新定义一些类型
防止由于各种平台和编译器的不同,而产生的类型字节数差异,方便移植。这里已经不是#define的范畴了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef unsigned char boolean; /* Boolean value type. */
typedef unsigned long int uint32; /* Unsigned 32 bit value */
typedef unsigned short uint16; /* Unsigned 16 bit value */
typedef unsigned char uint8; /* Unsigned 8 bit value */
typedef signed long int int32; /* Signed 32 bit value */
typedef signed short int16; /* Signed 16 bit value */
typedef signed char int8; /* Signed 8 bit value */
//下面的不建议使用
typedef unsigned char byte; /* Unsigned 8 bit value type. */
typedef unsigned short word; /* Unsinged 16 bit value type. */
typedef unsigned long dword; /* Unsigned 32 bit value type. */
typedef unsigned char uint1; /* Unsigned 8 bit value type. */
typedef unsigned short uint2; /* Unsigned 16 bit value type. */
typedef unsigned long uint4; /* Unsigned 32 bit value type. */
typedef signed char int1; /* Signed 8 bit value type. */
typedef signed short int2; /* Signed 16 bit value type. */
typedef long int int4; /* Signed 32 bit value type. */
typedef signed long sint31; /* Signed 32 bit value */
typedef signed short sint15; /* Signed 16 bit value */
typedef signed char sint7; /* Signed 8 bit value */  

得到指定地址上的一个字节或字

1
2
#define MEM_B( x ) ( *( (byte *) (x) ) )
#define MEM_W( x ) ( *( (word *) (x) ) )

求最大值和最小值

1
2
#define MAX( x, y ) ( ((x) > (y)) ? (x) : (y) )
#define MIN( x, y ) ( ((x) < (y)) ? (x) : (y) )

得到一个field在结构体(struct)中的偏移量

1
2
#define FPOS( type, field ) \
/*lint -e545 */ ( (dword) &(( type *) 0)-> field ) /*lint +e545 */

得到一个结构体中field所占用的字节数

1
#define FSIZ( type, field ) sizeof( ((type *) 0)->field )

按照LSB格式把两个字节转化为一个Word

1
#define FLIPW( ray ) ( (((word) (ray)[0]) * 256) + (ray)[1] )

按照LSB格式把一个Word转化为两个字节

1
2
3
#define FLOPW( ray, val ) \
(ray)[0] = ((val) / 256); \
(ray)[1] = ((val) & 0xFF)

得到一个变量的地址(word宽度)

1
2
#define B_PTR( var ) ( (byte *) (void *) &(var) )
#define W_PTR( var ) ( (word *) (void *) &(var) )

得到一个字的高位和低位字节

1
2
#define WORD_LO(xxx) ((byte) ((word)(xxx) & 255))
#define WORD_HI(xxx) ((byte) ((word)(xxx) >> 8))

返回一个比X大的最接近的8的倍数

1
#define RND8( x ) ((((x) + 7) / 8 ) * 8 )

将一个字母转换为大写

1
#define UPCASE( c ) ( ((c) >= 'a' && (c) <= 'z') ? ((c) - 0x20) : (c) )

判断字符是不是10进值的数字

1
#define DECCHK( c ) ((c) >= '0' && (c) <= '9')

判断字符是不是16进值的数字

1
2
3
#define HEXCHK( c ) ( ((c) >= '0' && (c) <= '9') ||\
((c) >= 'A' && (c) <= 'F') ||\
((c) >= 'a' && (c) <= 'f') )

防止溢出的一个方法

1
#define INC_SAT( val ) (val = ((val)+1 > (val)) ? (val)+1 : (val))

返回数组元素的个数

1
#define ARR_SIZE( a ) ( sizeof( (a) ) / sizeof( (a[0]) ) )

返回一个无符号数n尾的值MOD_BY_POWER_OF_TWO(X,n)=X%(2^n)

1
2
#define MOD_BY_POWER_OF_TWO( val, mod_by ) \
( (dword)(val) & (dword)((mod_by)-1) )

对于IO空间映射在存储空间的结构,输入输出处理

1
2
3
4
5
6
#define inp(port) (*((volatile byte *) (port)))
#define inpw(port) (*((volatile word *) (port)))
#define inpdw(port) (*((volatile dword *)(port)))
#define outp(port, val) (*((volatile byte *) (port)) = ((byte) (val)))
#define outpw(port, val) (*((volatile word *) (port)) = ((word) (val)))
#define outpdw(port, val) (*((volatile dword *) (port)) = ((dword) (val)))

使用一些宏跟踪调试
ANSI标准说明了五个预定义的宏名。它们是:

1
2
3
4
5
__LINE__
__FILE__
__DATE__
__TIME__
__STDC__

可以定义宏,例如:

当定义了_DEBUG,输出数据信息和所在文件所在行

1
2
3
4
5
#ifdef _DEBUG
#define DEBUGMSG(msg,date) printf(msg);printf(“%d%d%d”,date,_LINE_,_FILE_)
#else
#define DEBUGMSG(msg,date)
#endif

宏定义防止使用错误,用小括号包含。例如:

1
#define ADD(a,b) (a+b)

用do{}while(0)语句包含多语句防止错误,例如:

1
2
#define DO(a,b) a+b;\
a++;

应用时:

1
2
3
if(….)
DO(a,b); //产生错误
else

解决方法: 代码就只会执行一次。和直接加花括号有什么区别呢。哦对,不能随便在程序中,任意加{},组成代码块的。

1
2
#define DO(a,b) do{a+b;\
a++;}while(0)

new 操作符

当你写这种代码:

1
string *ps = new string("Memory Management");

你使用的new是new操作符。这个操作符就象sizeof一样是语言内置的。你不能改变它的含义,它的功能总是一样的。它要完毕的功能分成两部分。第一部分是分配足够的内存以便容纳所需类型的对象。第二部分是它调用构造函数初始化内存中的对象。new操作符总是做这两件事情,你不能以不论什么方式改变它的行为。

operator new

你所能改变的是怎样为对象分配内存。

new操作符调用一个函数来完毕必需的内存分配,你可以重写或重载这个函数来改变它的行为。new操作符为分配内存所调用函数的名字是operator new。

函数operator new 通常这样声明:

1
void * operator new(size_t size);

返回值类型是void*,由于这个函数返回一个未经处理(raw)的指针。未初始化的内存。參数size_t确定分配多少内存。

你能添加额外的參数重载函数operator new,可是第一个參数类型必须是size_t。

你一般不会直接调用operator new,可是一旦这么做。你能够象调用其他函数一样调用它:

1
void *rawMemory = operator new(sizeof(string));

操作符operator new将返回一个指针,指向一块足够容纳一个string类型对象的内存。就象malloc一样,operator new的职责仅仅是分配内存。

它对构造函数一无所知。operator new所了解的是内存分配。把operator new 返回的未经处理的指针传递给一个对象是new操作符的工作。当你的编译器遇见这种语句:

1
string *ps = new string("Memory Management");

它生成的代码或多或少与以下的代码相似:

1
2
3
4
void *memory = operator new(sizeof(string)); // 得到未经处理的内存,为String对象
call string::string("Memory Management")
on *memory; // 内存中的对象
string *ps = static_cast<string*>(memory); // 使ps指针指向新的对象

注意第二步包括了构造函数的调用,你做为一个程序猿被禁止这样去做。你的编译器则没有这个约束,它能够做它想做的一切。

因此假设你想建立一个堆对象就必须用new操作符。不能直接调用构造函数来初始化对象。(总结:operator new是用来分配内存的函数,为new操作符调用。能够被重载(有限制))

placement new

有时你确实想直接调用构造函数。在一个已存在的对象上调用构造函数是没有意义的,由于构造函数用来初始化对象。而一个对象只能在给它初值时被初始化一次。

可是有时你有一些已经被分配可是尚未处理的的(raw)内存,你须要在这些内存中构造一个对象。你能够使用一个特殊的operator new ,它被称为placement new。

以下的样例是placement new怎样使用,考虑一下:

1
2
3
4
5
6
7
8
9
10
class Widget {
 public:
  Widget(int widgetSize);
  ...
};

Widget * constructWidgetInBuffer(void *buffer,int widgetSize)
{
 return new (buffer) Widget(widgetSize);
}

这个函数返回一个指针。指向一个Widget对象,对象在转递给函数的buffer里分配。
当程序使用共享内存或memory-mapped I/O时这个函数可能实用,由于在这样程序里对象必须被放置在一个确定地址上或一块被例程分配的内存里。

在constructWidgetInBuffer里面。返回的表达式是:new (buffer) Widget(widgetSize)

这初看上去有些陌生,可是它是new操作符的一个使用方法,须要使用一个额外的变量(buffer)。当new操作符隐含调用operator new函数时。把这个变量传递给它。被调用的operator new函数除了带有强制的參数size_t外,还必须接受void*指针參数。指向构造对象占用的内存空间。这个operator new就是placement new,它看上去象这样:

1
2
3
4
void * operator new(size_t, void *location)
{
 return location;
}

这可能比你期望的要简单,可是这就是placement new须要做的事情。毕竟operator new的目的是为对象分配内存然后返回指向该内存的指针。在使用placement new的情况下,调用者已经获得了指向内存的指针。由于调用者知道对象应该放在哪里。placement new必须做的就是返回转递给它的指针。。

(总结:placement new是一种特殊的operator new,作用于一块已分配但未处理或未初始化的raw内存)

小结

让我们从placement new回来片刻,看看new操作符(new operator)与operator new的关系,(new操作符调用operator new)你想在堆上建立一个对象,应该用new操作符。它既分配内存又为对象调用构造函数。假设你只想分配内存,就应该调用operator new函数;它不会调用构造函数。假设你想定制自己的在堆对象被建立时的内存分配过程,你应该写你自己的operator new函数。然后使用new操作符,new操作符会调用你定制的operator new。假设你想在一块已经获得指针的内存里建立一个对象。应该用placement new。

Deletion and Memory Deallocation

为了避免内存泄漏,每一个动态内存分配必须与一个等同相反的deallocation相应。

函数operator delete与delete操作符的关系与operator new与new操作符的关系一样。当你看到这些代码:

1
2
3
string *ps;
...
delete ps; // 使用delete 操作符

你的编译器会生成代码来析构对象并释放对象占有的内存。

Operator delete用来释放内存。它被这样声明:

1
2
3
4
5
6
7
void operator delete(void *memoryToBeDeallocated);
···

因此, delete ps; 导致编译器生成类似于这种代码:
```C++
ps->~string(); // call the object's dtor
operator delete(ps); // deallocate the memory the object occupied

这有一个隐含的意思是假设你仅仅想处理未被初始化的内存,你应该绕过new和delete操作符,而调用operator new 获得内存和operator delete释放内存给系统:

1
2
3
4
5
void *buffer = operator new(50*sizeof(char)); // 分配足够的内存以容纳50个char
//没有调用构造函数
...
operator delete(buffer); // 释放内存
// 没有调用析构函数

这与在C中调用malloc和free等同。

假设你用placement new在内存中建立对象,你应该避免在该内存中用delete操作符。

由于delete操作符调用operator delete来释放内存,可是包括对象的内存最初不是被operator new分配的。placement new仅仅是返回转递给它的指针。谁知道这个指针来自何方?而你应该显式调用对象的析构函数来解除构造函数的影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在共享内存中分配和释放内存的函数 void * mallocShared(size_t size);

void freeShared(void *memory);
void *sharedMemory = mallocShared(sizeof(Widget));
Widget *pw = // 如上所看到的,
constructWidgetInBuffer(sharedMemory, 10); // 使用

// placement new
...
delete pw; // 结果不确定! 共享内存来自
// mallocShared, 而不是operator new
pw->~Widget(); // 正确。 析构 pw指向的Widget,
// 可是没有释放
//包括Widget的内存
freeShared(pw); // 正确。 释放pw指向的共享内存
// 可是没有调用析构函数

如上例所看到的,假设传递给placement new的raw内存是自己动态分配的(通过一些不经常使用的方法),假设你希望避免内存泄漏,你必须释放它。

数组

如何分配数组?会发生什么?

1
string *ps = new string[10]; // allocate an array of objects

被使用的new仍然是new操作符,可是建立数组时new操作符的行为与单个对象建立有少许不同。
第一是内存不再用operator new分配,取代以等同的数组分配函数,叫做operator new[](常常被称为array new)。

它与operator new一样能被重载。

在这种编译器下定制数组内存分配是困难的。由于它须要重写全局operator new。这可不是一个能轻易接受的任务。

缺省情况下,全局operator new处理程序中全部的动态内存分配,所以它行为的不论什么改变都将有深入和普遍的影响。并且全局operator new有一个正常的签名(normal signature)。

第二个不同是new操作符调用构造函数的数量。对于数组,在数组里的每个对象的构造函数都必须被调用:

1
2
3
string *ps = new string[10]; // 调用operator new[]为10个string对象分配内存,

// 然后对每一个数组元素调用string对象的缺省构造函数。

相同当delete操作符用于数组时,它为每一个数组元素调用析构函数,然后调用operator delete来释放内存。

就象你能替换或重载operator delete一样,你也替换或重载operator delete[]。

static关键字

首先说一下内存的五个区:

  • 栈(stack):由编译器自动分配释放,存放函数的参数值,局部变量的值(除static),其操作方式类似于数据结构中的栈。
  • 堆(heap):一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆(优先队列)是两回事,分配方式倒是类似于链表。
  • 全局区(静态区):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域(BSS),程序结束后由系统释放。
  • 文字常量区:常量字符串就是放在这里的,如char str[]=”hello”,程序结束后由系统释放,区别const修饰的变量。
  • 程序代码区:存放函数体的二进制代码。

作用

  1. 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在 main 函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。
  2. 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为 static。
  3. 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。
  4. 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在 static 函数内不能访问非静态成员。
  • 在函数体,一个被声明为静态的变量在这一函数被调用过程中维持其值不变。
  • 在模块内(但在函数体外),一个被声明为静态的变量可以被模块内所用函数访问,但不能被模块外其它函数访问。它是一个本地的全局变量。
  • 在模块内,一个被声明为静态的函数只可被这一模块内的其它函数调用。那就是,这个函数被限制在声明它的模块的本地范围内使用
  • 类内的static成员变量属于整个类所拥有,不能在类内进行定义,只能在类的作用域内进行定义
  • 类内的static成员函数属于整个类所拥有,不能包含this指针,只能调用static成员函数

全局变量和static变量的区别

  • 全局变量(外部变量)的说明之前再冠以static就构成了静态的全局变量。
    • 全局变量本身就是静态存储方式,静态全局变量当然也是静态存储方式。
    • 这两者在存储方式上并无不同。这两者的区别在于非静态全局变量的作用域是整个源程序,当一个源程序由多个原文件组成时,非静态的全局变量在各个源文件中都是有效的。
    • 而静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
  • static全局变量与普通的全局变量的区别是static全局变量只初始化一次,防止在其他文件单元被引用。

static函数与普通的函数作用域不同。尽在本文件中。只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。

对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。static函数与普通函数最主要区别是static函数在内存中只有一份,普通静态函数在每个被调用中维持一份拷贝程序的局部变量存在于(堆栈)中,全局变量存在于(静态区)中,动态申请数据存在于(堆)

static 变量

静态局部变量保存在全局数据区(静态区),而不是保存在栈中,每次的值保持到下一次调用,直到下次赋新值。

  • static全局变量与普通的全局变量有什么区别:static全局变量只初使化一次,防止在其他文件单元中被引用;
  • static局部变量和普通局部变量有什么区别:static局部变量只被初始化一次,下一次依据上一次结果值;
  • static函数与普通函数有什么区别:static函数在内存中只有一份,普通函数在每个被调用中维持一份拷贝

static 成员变量

定义必须在类定义体的外部,在类的内部只是声明,声明必须加static,定义不需要。static类对象必须要在类外进行初始化,static修饰的变量先于对象存在,所以static修饰的变量要在类外初始化;

1
2
3
4
5
6
7
8
9
10
11
12
class A
{
public:
// 声明static变量,任何声明都不可初始化,如extern外部变量
static int a;
private:
static int b;
};
// 定义static成员变量,可初始化
int A::a = 5;
// 私有静态成员变量,不能直接用类名调用或者对象调用,只能在类内调用
int A::b = 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
#include <iostream>
using namespace std;
class A
{
public:
// 声明static变量,任何声明都不可初始化,如extern外部变量
static int a;
private:
static int b;
public:
static int getAValue()
{
return this.a;
}
};
// 定义static成员变量,可初始化
int A::a = 5;
// 私有静态成员变量,不能直接用类名调用或者对象调用,只能在类内调用
int A::b = 1;
int main(int argc, char *argv[])
{
// new 两个个实例(对象)
A * instanceA = new A();
A * instanceB = new A();
// 改变值,均输出1
instanceA->a = 1;
cout << A::a << endl;
cout << instanceA->getAValue() << endl;
cout << instanceB->getAValue() << endl;
return 0;
}

static 函数

1
2
3
4
5
6
#include <stdio.h>
int a = 5;
void printHello()
{
printf("hello world");
}
1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
// 声明
void printHello();
int main(int argc,char *argv[])
{
// 声明
extern int a;
printf("a = %d\n",a);
printHello();
return 0;
}

【编译】

1
g++ a.cpp b.cpp -o ab.exe

【输出】
1
2
a = 5
hello world

如果在a.cpp中的int a = 5;定义前面加上static修饰,那么再次去编译,就会b.cpp报未定义错误。如果在a.cpp中的void printHello()函数前加static修饰,再次去编译,一样会报未定义错误。很明显,所有未加static修饰的函数和全局变量具有全局可见性,其他的源文件也能够访问。static修饰函数和变量这一特性可以在不同的文件中定义同名函数和同名变量,而不必担心命名冲突。static可以用作函数和变量的前缀,对于函数来讲,static的作用仅限于隐藏。这有点类似于C++中的名字空间。

static 成员函数

同样的和成员变量一样,跟类相关的,跟具体的类的对象无关,可以通过类名来调用。static成员函数里面不能访问非静态成员变量,也不能调用非静态成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
using namespace std;
class A
{
public:
void printStr()
{
printf("hello world");
}
static void print()
{
// 错误,静态成员函数不能调用非静态成员函数
printStr();
}
};
int main(int argc, char *argv[])
{
return 0;
}

静态成员函数没有this隐含指针修饰,存在一种情况,用const修饰类的成员函数(写在函数的最后,不是前面,前面是返回值为常量),表示该函数不能修改该类的状态,如不能在改函数里修改成员变量(除去mutable修饰的外),因为该函数存在一个隐式的this,const修饰后为const this,但是当static修饰成员函数的时候是没有this指针的,所以不能同时用static和const修饰同一个成员函数,不过可以修饰同一个成员变量。

static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;

虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual

vector和set使用sort方法进行排序

C++中vector和set都是非常方便的容器,

sort方法是algorithm头文件里的一个标准函数,能进行高效的排序,默认是按元素从小到大排序

将sort方法用到vector和set中能实现多种符合自己需求的排序

首先sort方法可以对静态的数组进行排序

1
2
3
4
5
6
7
8
9
#include<iostream>
using namespace std;
int main(){
int a[10] = { 9, 0, 1, 2, 3, 7, 4, 5, 100, 10 };
sort(a, a +10);
for (int i = 0; i < 10; i++)
cout << a[i] << endl;
return 0;
}

运行结果如下:
结果

这里可以看到是sort(a,a+10),但是数组a一共只有9个元素,为什么是a+10而不是a+9呢?

因为sort方法实际上最后一位地址对应的数是不取的,

而且vector,set,map这些容器的end()取出来的值实际上并不是最后一个值,而end的前一个才是最后一个值!

需要用prev(xxx.end()),才能取出容器中最后一个元素。

对vector使用sort函数

第一种情形:基本类型,如vector<int>vector<double>vector<string>也是可以的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
int main(){
vector<int> a;
int n = 5;
while (n--){
int score;
cin >> score;
a.push_back(score);
}
//cout <<" a.end()"<< *a.end() << endl; 执行这句话会报错!
cout << " prev(a.end)" << *prev(a.end()) << endl;
sort(a.begin(), a.end());
for (vector<int>::iterator it = a.begin(); it != a.end(); it++){
cout << *it << endl;
}
return 0;
}

看到了吗,实际上end的前一个指针指向的元素才是插入时的最后一个值!

排序后从小大大。

第二种情形:用自定义的结构体进行sort算法,

这时候需要自己定义个比较函数,因为sort算法是基于容器中的元素是可以两两比较的,然后从小到大排序,所以要自定义怎么样才是小于(’<’)

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
#include<iostream>
#include<vector>
#include<set>
#include<string>
#include<algorithm>
using namespace std;
struct student{
char name[10];
int score;
};
//自定义“小于”
bool comp(const student &a, const student &b){
return a.score < b.score;
}
int main(){
vector<student> vectorStudents;
int n = 5;
while (n--){
student oneStudent;
string name;
int score;
cin >> name >> score;
strcpy(oneStudent.name, name.c_str());
oneStudent.score = score;
vectorStudents.push_back(oneStudent);
}
cout << "===========排序前================" << endl;
for (vector<student>::iterator it = vectorStudents.begin(); it != vectorStudents.end(); it++){
cout << "name: " << it->name << " score: " << it->score << endl;
}
sort(vectorStudents.begin(),vectorStudents.end(),comp);
cout << "===========排序后================" << endl;
for (vector<student>::iterator it = vectorStudents.begin(); it != vectorStudents.end(); it++){
cout << "name: " << it->name << " score: " << it->score << endl;
}
return 0;
}

对于set做类似的操作。

set是一个集合,内部的元素不会重复,同时它会自动进行排序,也是从小到大

而且set的insert方法没有insert(a,cmp)这种重载,所以如果要把结构体插入set中,我们就要重载’<’运算符。

set方法在插入的时候也是从小到大的,那么我们重载一下<运算符让它从大到小排序

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
#include<iostream>
#include<vector>
#include<set>
#include<string>
#include<algorithm>
using namespace std;
struct student{
char name[10];
int score;
};
//自定义“小于”
bool comp(const student &a, const student &b){
return a.score < b.score;
}
bool operator < (const student & stu1,const student &stu2){
return stu1.score > stu2.score;
}
int main(){
//vector<student> vectorStudents;
set<student> setStudents;
//int n = 5;
int n = 6;
while (n--){
student oneStudent;
string name;
int score;
cin >> name >> score;
strcpy(oneStudent.name, name.c_str());
oneStudent.score = score;
setStudents.insert(oneStudent);
}
cout << "===========排序前================" << endl;
for (set<student>::iterator it = setStudents.begin(); it != setStudents.end(); it++){
cout << "name: " << it->name << " score: " << it->score << endl;
}
//sort(setStudents.begin(), setStudents.end(), comp);
//cout << "===========排序后================" << endl;
//for (set<student>::iterator it = setStudents.begin(); it != setStudents.end(); it++){
// cout << "name: " << it->name << " score: " << it->score << endl;
//}
return 0;
}

restrict与GCC的编译优化

restrict是C99标准中新添加的关键字,对于从C89标准开始起步学习C语言的同学来说,第一次看到restrict还是相当陌生的。简单说来,restrict关键字是编程者对编译器所做的一个“承诺”:使用restrict修饰过的指针,它所指向的内容只能经由该指针(或从该指针继承而来的指针,如通过该指针赋值或做指针运算而得到的其他指针)修改,而不会被其他不相干的指针所修改。

有了编程者的承诺,编译器便可以对一些通过指针的运算进行大胆的优化了。

观察编译器优化的最好办法当然是查看编译后的汇编代码。Wikipedia上有一个很好的例子,测试环境:Ubuntu 11.04 (x86-64) + Linux 2.6.38 + gcc 4.5.2。测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>

#ifdef RES
void multi_add(int* restrict p1, int* restrict p2, int* restrict pi)
#else
void multi_add(int* p1, int* p2, int* pi)
#endif
{
*p1 += *pi;
*p2 += *pi;
}

int main()
{
int a = 1, b = 2;
int inc = 1;

// increase both a and b by 1
multi_add(&a, &b, &inc);

// print the result
printf("a = %d, b = %d\n", a, b);
}

multi_add函数的功能很简单,将p1和p2指针所指向的内容都加上pi指针的内容。为了测试方便,使用了条件编译指令:如果定义RES宏,则使用带restrict的函数声明。

分别编译出两个版本的程序:

1
2
gcc restrict.c -o without_restrict
gcc restrict.c -o with_restrict -DRES --std=c99

使用objdump查看目标文件的汇编代码(-d选项表示disassemble):

1
objdump -d without_restrict

PS:gcc默认使用的是AT&T汇编,与很多同学在初次学习汇编时接触的Intel x86汇编有些不同

除了表示上的细微符号差别,最大的区别是src/dest的顺序,两者恰好相反:

1
2
3
Intel : mov  eax  2      (先dest后src)

AT&T : mov %2 %eax (先src后dest)

然而这次的结果让人失望:两个版本的程序拥有一模一样的multi_add函数,汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
push   %rbp
mov %rsp,%rbp
mov %rdi,-0x8(%rbp)
mov %rsi,-0x10(%rbp)
mov %rdx,-0x18(%rbp)
mov -0x8(%rbp),%rax
mov (%rax),%edx
mov -0x18(%rbp),%rax
mov (%rax),%eax
add %eax,%edx
mov -0x8(%rbp),%rax
mov %edx,(%rax)
mov -0x10(%rbp),%rax
mov (%rax),%edx
mov -0x18(%rbp),%rax
mov (%rax),%eax
add %eax,%edx
mov -0x10(%rbp),%rax
mov %edx,(%rax)
leaveq
retq

其中寄存器rdi存放p1的地址,rsi存放p2的地址,rdx存放的是pi的地址。大段的汇编代码,无非是将寄存器中的内容mov到栈上的临时变量上,再把临时变量的值mov进寄存器进行加法运算。

难道restrict关键字没有任何作用?我怀疑很可能是编辑器优化程度不够。这次,使用-O1重新编译源代码并反汇编,终于观察到差别:

未使用restrict的版本:

1
2
3
4
mov (%rdx), %eax
add %eax, (%rdi)
mov (%rdx), %eax
add %eax, (%rsi)

使用了restrict的版本:

1
2
3
mov (%rdx), %eax
add %eax, (%rdi)
add %eax, (%rsi)

可以看出,-O1的编译优化还是很给力的,所有运算直接在寄存器中进行,不再蛋疼地先mov进栈变量,再mov进寄存器进行add运算(在这个简单的例子中,确实没有必要)。

最大的区别在于将rdx寄存器间接引用的值mov进eax的语句只在一开始执行了1次。可以理解,当程序员“承诺”这些指针都是相互独立不再干扰时,pi指针的内容在函数范围内可以视之为常量,只需要load进寄存器一次。

而没有restrict关键字时,即使程序中没有对pi的内容进行操作,编译器仍然不能保证pi的内容在函数范围内是常量:因为有pointer aliasing的可能,即p1和p2指向的内容和pi相关(简单情况:p1和pi实际是同一个指针)。

需要注意的是,restrict是程序员给出的“承诺“,编译器没有指针的合法使用进行检查的职责,也没有这样的能力。

事实上,打开restrict关键字,如果这样调用:

1
multi_add(&a, &b, &a);

编译器不会报错。(事实上编译期完全有能力检查出简单alias的pointer)

而使用不同的编译优化级别(不优化,-O1, -O2),则产生了相当不同的结果。

  • 不优化 : a = 2, b = 4
  • -O1 : a = 2, b = 3
  • -O2以上: a = 2, b = 4

前面已经提到,没有开启-O选项时,gcc没有对restrict关键字进行优化(至少在这个例子中),所以应当是正确的行为(尽管此行为可能与编写multi_add函数的初衷不符合)

在O1下,restrict被优化,pi的值一开始即被缓存,所以产生了a和b都增加了1的结果

那么为什么O2以上,行为又开始变得正确了呢?

继续反汇编代码,发现-O2以上时,multi_add函数本身代码保持不变(确实在O1已经优化的相当简洁了),但main函数已经面目全非了:调用multi_add的代码已经改变,准确地说:

multi_add函数已经不再被main调用了

这里不再列出相关的汇编代码,因为这里的优化策略是相当复杂的。在这个例子中,由于a和b都是常量,a和b的值直接在编译期被算了出来,并放入寄存器中进行后续printf的调用。

可以看出,restrict确实是优化的利器。但是如果不仔细使用,它还是相当危险的,甚至能够导致在不同的优化级别下,出现完全不同的程序行为。

volatile

why volatile

volatile 关键词,最早出现于20世纪70年代,被用于处理 MMIO(Memory-mapped I/O) 带来的问题。在引入 MMIO 之后,一块内存地址既有可能是真正的内存,也有可能是映射的一个I/O端口。因此,读/写一个内存地址,既有可能是真正地操作内存,也有可能是读/写一个I/O设备。

那么 MMIO 为什么需要引入 volatile 关键词呢?我们结合下面这段示例代码进行解释:

1
2
3
4
5
6
7
8
9
unsigned int *p = FunB();
unsigned int a;
unsigned int b;

a = *p; // 语句1
b = *p; // 语句2

*p = a; // 语句3
*p = b; // 语句4

在上述代码片段中,指针p既有可能指向一个内存地址,也有可能指向一个I/O设备。如果指针p指向的是I/O设备,那么语句1和语句2中的变量a和变量b,就会接收到I/O设备的连续两个字节。但是,指针p也有可能指向内存地址,这种情况下,编译器就会进行语句优化,编译器的优化策略会判断变量a和变量b同时从同一个内存地址读取数据,因此在执行完语句1之后,直接将变量a赋值给变量b。对于指针p指向I/O设备的这种情况,就需要防止编译器进行此优化,即不能假设指针b指向的内容不变(对应 volatile 的易变性特性)。

同样,语句3和语句4也有类似的问题,编译器发现将变量a和b同时赋值给指针p是无意义的,因此可能会优化语句3中的赋值操作,而仅仅保留语句4。对于指针p指向I/O设备的情况,也需要防止编译器将类似的写操作给优化消失了(对应 volatile 的不可优化特性)。

对于I/O设备,编译器不能随意交互指令的顺序,因为指令顺序一变,写入I/O设备的内容也就发生变化了(对应 volatile 的顺序性)。

为了满足 MMIO 的这三点需求,就有了 volatile 关键字。

IN C/C++

在C/C++语言中,使用 volatile 关键字声明的变量(或对象)通常具有与优化、多线程相关的特殊属性。通常,volatile 关键字用来阻止(伪)编译器对其认为的、无法“被代码本身”改变的代码(变量或对象)进行优化。如在C/C++中,volatile 关键字可以用来提醒编译器使用 volatile 声明的变量随时有可能改变,因此编译器在代码编译时就不会对该变量进行某些激进的优化,故而编译生成的程序在每次存储或读取该变量时,都会直接从内存地址中读取数据。相反,如果该变量没有使用 volatile 关键字进行声明,则编译器可能会优化读取和存储操作,可能暂时使用寄存器中该变量的值,而如果这个变量由别的程序(线程)更新了的话,就会出现(内存中与寄存器中的)变量值不一致的现象。

定义为volatile的变量是说这变量可能会被意想不到地改变,即在你程序运行过程中一直会变,你希望这个值被正确的处理,每次从内存中去读这个值,而不是因编译器优化从缓存的地方读取,比如读取缓存在寄存器中的数值,从而保证volatile变量被正确的读取。

在单任务的环境中,一个函数体内部,如果在两次读取变量的值之间的语句没有对变量的值进行修改,那么编译器就会设法对可执行代码进行优化。由于访问寄存器的速度要快过RAM(从RAM中读取变量的值到寄存器),以后只要变量的值没有改变,就一直从寄存器中读取变量的值,而不对RAM进行访问。

而在多任务环境中,虽然在一个函数体内部,在两次读取变量之间没有对变量的值进行修改,但是该变量仍然有可能被其他的程序(如中断程序、另外的线程等)所修改。如果这时还是从寄存器而不是从RAM中读取,就会出现被修改了的变量值不能得到及时反应的问题。

因为访问寄存器要比访问内存单元快的多,所以编译器一般都会作减少存取内存的优化,但有可能会读脏数据。当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。精确地说就是,遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问;如果不使用valatile,则编译器将对所声明的语句进行优化。(简洁的说就是:volatile关键词影响编译器编译的结果,用volatile声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化,以免出错。加了volatile修饰的变量,编译器将不对其相关代码执行优化,而是生成对应代码直接存取原始内存地址)。

一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。一般说来,volatile用在如下的几个地方:

  1. 并行设备的硬件寄存器(如:状态寄存器)
  2. 中断服务程序中修改的供其它程序检测的变量需要加volatile;
  3. 多任务环境下各任务间共享的标志应该加volatile;
  4. 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能有不同意义;

在C/C++语言中,使用 volatile 关键字声明的变量具有三种特性:易变的、不可优化的、顺序执行的。下面分别对这三种特性进行介绍。

易变的

volatile 在词典中的主要释义就是“易变的”。

在 C/C++ 语言中,volatile 的易变性体现在:假设有读、写两条语句,依次对同一个 volatile 变量进行操作,那么后一条的读操作不会直接使用前一条的写操作对应的 volatile 变量的寄存器内容,而是重新从内存中读取该 volatile 变量的值。

上述描述的(部分)示例代码如下:

1
2
3
4
volatile int nNum = 0;  // 将nNum声明为volatile
int nSum = 0;
nNum = FunA(); // nNum被写入的新内容,其值会缓存在寄存器中
nSum = nNum + 1; // 此处会从内存(而非寄存器)中读取nNum的值

不可优化的

在 C/C++ 语言中,volatile 的第二个特性是“不可优化性”。volatile 会告诉编译器,不要对 volatile 声明的变量进行各种激进的优化(甚至将变量直接消除),从而保证程序员写在代码中的指令一定会被执行。

上述描述的(部分)示例代码如下:

1
2
3
volatile int nNum;  // 将nNum声明为volatile
nNum = 1;
printf("nNum is: %d", nNum);

在上述代码中,如果变量 nNum 没有声明为 volatile 类型,则编译器在编译过程中就会对其进行优化,直接使用常量“1”进行替换(这样优化之后,生成的汇编代码很简介,执行时效率很高)。而当我们使用 volatile 进行声明后,编译器则不会对其进行优化,nNum 变量仍旧存在,编译器会将该变量从内存中取出,放入寄存器之中,然后再调用 printf() 函数进行打印。

顺序执行的

在 C/C++ 语言中,volatile 的第三个特性是“顺序执行特性”,即能够保证 volatile 变量间的顺序性,不会被编译器进行乱序优化。

说明:C/C++ 编译器最基本优化原理:保证一段程序的输出,在优化前后无变化。

为了对本特性进行深入了解,下面以两个变量(nNum1 和 nNum2)为例(既然存在“顺序执行”,那描述对象必然大于一个),结合如下示例代码,介绍 volatile 的顺序执行特性。

1
2
3
4
int nNum1;
int nNum2;
nNum2 = nNum1 + 1; // 语句1
nNum1 = 10; // 语句2

在上述代码中:

  • 当 nNum1 和 nNum2 都没有使用 volatile 关键字进行修饰时,编译器会对“语句1”和“语句2”的执行顺序进行优化:即先执行“语句2”、再执行“语句1”;
  • 当 nNum2 使用 volatile 关键字进行修饰时,编译器也可能会对“语句1”和“语句2”的执行顺序进行优化:即先执行“语句2”、再执行“语句1”;
  • 当 nNum1 和 nNum2 都使用 volatile 关键字进行修饰时,编译器不会对“语句1”和“语句2”的执行顺序进行优化:即先执行“语句1”、再执行“语句2”;

说明:上述论述可通过观察代码的生成的汇编代码进行验证。

volatile与多线程语义

对于多线程编程而言,在临界区内部,可以通过互斥锁(mutex)保证只有一个线程可以访问该临界区的内容,因此临界区内的变量不需要是 volatile 的;而在临界区外部,被多个线程访问的变量应声明为 volatile 的,这也符合了 volatile 的原意:防止编译器缓存(cache)了被多个线程并发用到的变量。

不过,需要注意的是,由于 volatile 关键字的“顺序执行特性”并非会完全保证语句的顺序执行(如 volatile 变量与非volatile 变量之间的操作;又如一些 CPU 也会对语句的执行顺序进行优化),因此导致了对 volatile 变量的操作并不是原子的,也不能用来为线程建立严格的 happens-before 关系。

对于上述描述,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int nNum1 = 0;
volatile bool flag = false;

thread1()
{
// some code

nNum1 = 666; // 语句1
flag = true; // 语句2
}

thread2()
{
// some code

if (true == flag)
{
// 语句3:按照程序设计的预想,此处的nNum1的值应为666,并据此进行逻辑设计
}
}

在上述代码中,我们的设计思路是先执行 thread1() 中的“语句1”、“语句2”、再执行 thread2() 中的“语句3”,不过实际上程序的执行结果未必如此。根据 volatile 的“顺序性”,非 volatile 变量 nNum1 和 volatile 变量 flag 的执行顺序,可能会被编译器(或 CPU)进行乱序优化,最终导致thread1中的“语句2”先于“语句1”执行,当“语句2”执行完成但“语句1”尚未执行时,此时 thread2 中的判断语句“if (true == flag)”是成立的,但实际上 nNum1 尚未进行赋值为666(语句1尚未执行),所以在判断语句中针对 nNum1 为666的前提下进行的相关操作,就会有问题了。

这是一个在多线程编程中,使用 volatile 不容易发现的问题。

实际上,上述多线程代码想实现的就是一个 happens-before 语义,即保证 thread1 代码块中的所有代码,一定要在 thread2 代码块的第一条代码之前完成。使用互斥锁(mutex)可以保证 happens-before 语义。但是,在 C/C++ 中的 volatile 关键词不能保证这个语义,也就意味着在多线程环境下使用 C/C++ 的 volatile 关键词,如果不够细心,就可能会出现上述问题。

说明:由于 Java 语言的 volatile 关键字支持 Acquire、Release 语义,因此 Java 语言的 volatile 能够用来构建 happens-before 语义。也就是说,前面提到的 C/C++ 中 volatile 在多线程下使用出现的问题,在 Java 语言中是不存在的。

不保证原子性

volatile只保证其“可见性”,不保证其“原子性”。

执行count++;这条语句由3条指令组成:

  1. 将 count 的值从内存加载到 cpu 的某个 寄存器r;
  2. 将 寄存器r 的值 +1,结果存放在 寄存器s;
  3. 将 寄存器s 中的值写回内存。

所以,如果有多个线程同时在执行 count++,在某个线程执行完第(3)步之前,其它线程是看不到它的执行结果的。(这里有疑惑:线程同时执行count++,为了保证其原子性,为何不加mutex lock?而是寻求volatile?)

在没有volatile的时候,执行完count++,执行结果其实是写到CPU缓存中,没有马上写回到内存中,后续在某些情况下(比如CPU缓存不够用)再将CPU缓存中的值flush到内存。因为没有存到内存里,其他线程是不能及时看到执行结果的。

在有volatile的时候,执行完count++,执行结果写入缓存中,并同时写入内存中,所以可以保证其它线程马上看到执行的结果。

但是,volatile 并没有保证原子性,在某个线程执行(1)(2)(3)的时候,volatile 并没有锁定 count 的值,也就是并不能阻塞其他线程也执行(1)(2)(3)。可能有两个线程同时执行(1),所以(2)计算出来一样的结果,然后(3)存回的也是同一个值。
考虑下面一段代码:

1
2
3
4
5
int some_int = 100;
while(some_int == 100)
{
//your code
}

因为编译器认为some_int没被改变过,一直是100。但是在多线程时,如果执行完第一行,但是还没执行到第三行时,另一个线程修改了some_int,while就不能进入循环了。加了volatile后,阻止了编译器优化,每次读到some_int会从内存中读取,而不是本线程的寄存去(当然这会损失效率)。这就是volatile的作用。

一句话总结:volatile保证线程能读到最新的数据,因为是从内存中读取,且存入内存中。而不是线程各自的寄存器中读写。

inline 内联函数

内联函数和普通函数相比可以加快程序运行的速度,因为不需要中断调用,在编译的时候内联函数可以直接嵌入到目标代码中。

  • 相当于把内联函数里面的内容写在调用内联函数处;
  • 相当于不用执行进入函数的步骤,直接执行函数体;
  • 相当于宏,却比宏多了类型检查,真正具有函数特性;
  • 编译器一般不内联包含循环、递归、switch 等复杂操作的内联函数;
  • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数。
  • 作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 声明1(加 inline,建议使用)
inline int functionName(int first, int second,...);

// 声明2(不加 inline)
int functionName(int first, int second,...);

// 定义
inline int functionName(int first, int second,...) {/****/};

// 类内定义,隐式内联
class A {
int doA() { return 0; } // 隐式内联
}

// 类外定义,需要显式内联
class A {
int doA();
}
inline int A::doA() { return 0; } // 需要显式内联

编译器对 inline 函数的处理步骤

  1. 将 inline 函数体复制到 inline 函数调用点处;
  2. 为所用 inline 函数中的局部变量分配内存空间;
  3. 将 inline 函数的的输入参数和返回值映射到调用方法的局部变量空间中;
  4. 如果 inline 函数有多个返回点,将其转变为 inline 函数代码块末尾的分支(使用 GOTO)。

优点

  1. 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度。
  2. 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会。
  3. 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能。
  4. 内联函数在运行时可调试,而宏定义不可以。

缺点

  1. 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。
  2. inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接。
  3. 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。

内联函数和宏定义的区别

内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:

  • 函数体内的代码比较长,将导致内存消耗代价
  • 函数体内有循环,函数执行时间要比函数调用开销大

主要区别

  • 内联函数在编译时展开,宏在预编译时展开
  • 内联函数直接嵌入到目标代码中,宏是简单的做文本替换
  • 内联函数有类型、语法判断等功能,而宏没有
  • 内联函数是函数,宏不是
  • 宏定义时要注意书写(参数要括起来)否则容易出现歧义,内联函数不会产生歧义
  • 内联函数代码是被放到符号表中,使用时像宏一样展开,没有调用的开销,效率很高;
  • 在使用时,宏只做简单字符串替换(编译前)。而内联函数可以进行参数类型检查(编译时),且具有返回值。
  • 内联函数可以作为某个类的成员函数,这样可以使用类的保护成员和私有成员,进而提升效率。而当一个表达式涉及到类保护成员或私有成员时,宏就不能实现了。

union

联合(union)是一种节省空间的特殊的类,一个 union 可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当某个成员被赋值后其他成员变为未定义状态。联合有如下特点:

  • 默认访问控制符为 public
  • 可以含有构造函数、析构函数
  • 不能含有引用类型的成员
  • 不能继承自其他类,不能作为基类
  • 不能含有虚函数
  • 匿名 union 在定义所在作用域可直接访问 union 成员
  • 匿名 union 不能包含 protected 成员或 private 成员
  • 全局匿名联合必须是静态(static)的

C++11 标准规定,任何非引用类型都可以成为联合体的数据成员,这种联合体也被称为非受限联合体。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Student{
public:
Student(bool g, int a): gender(g), age(a) {}
private:
bool gender;
int age;
};
union T{
Student s; // 含有非POD类型的成员,gcc-5.1.0 版本报错
char name[10];
};
int main(){
return 0;
}

上面的代码中,因为 Student 类带有自定义的构造函数,所以是一个非 POD 类型的,这导致编译器报错。这种规定只是 C++ 为了兼容C语言而制定,然而在长期的编程实践中发现,这种规定是没有必要的。

C++11 允许非 POD 类型

C++98 不允许联合体的成员是非 POD 类型,但是 C++11 取消了这种限制。POD 是英文 Plain Old Data 的缩写,用来描述一个类型的属性。POD 类型一般具有以下几种特征(包括 class、union 和 struct等):

  • 没有用户自定义的构造函数、析构函数、拷贝构造函数和移动构造函数。
  • 不能包含虚函数和虚基类。
  • 非静态成员必须声明为 public。
  • 类中的第一个非静态成员的类型与其基类不同
  • 在类或者结构体继承时,满足以下两种情况之一:
    • 派生类中有非静态成员,且只有一个仅包含静态成员的基类;
    • 基类有非静态成员,而派生类没有非静态成员。
  • 所有非静态数据成员均和其基类也符合上述规则(递归定义),也就是说 POD 类型不能包含非 POD 类型的数据。
  • 此外,所有兼容C语言的数据类型都是 POD 类型(struct、union 等不能违背上述规则)。
1
2
class B1{};
class B2 : B1 { B1 b; };

class B2 的第一个非静态成员 b 是基类类型,所以它不是 POD 类型。

1
2
3
class B1 { static int n; };
class B2 : B1 { int n1; };
class B3 : B2 { static int n2; };

对于 B2,派生类 B2 中有非静态成员,且只有一个仅包含静态成员的基类 B1,所以它是 POD 类型。对于 B3,基类 B2 有非静态成员,而派生类 B3 没有非静态成员,所以它也是 POD 类型。

C++11 允许联合体有静态成员

C++11 删除了联合体不允许拥有静态成员的限制。例如:

1
2
3
4
5
6
union U {
static int func() {
int n = 3;
return n;
}
};

需要注意的是,静态成员变量只能在联合体内定义,却不能在联合体外使用,这使得该规则很没用。

非受限联合体的赋值注意事项

C++11 规定,如果非受限联合体内有一个非 POD 的成员,而该成员拥有自定义的构造函数,那么这个非受限联合体的默认构造函数将被编译器删除;其他的特殊成员函数,例如默认拷贝构造函数、拷贝赋值操作符以及析构函数等,也将被删除。

这条规则可能导致对象构造失败,请看下面的例子:

1
2
3
4
5
6
7
8
9
10
#include <string>
using namespace std;
union U {
string s;
int n;
};
int main() {
U u; // 构造失败,因为 U 的构造函数被删除
return 0;
}

在上面的例子中,因为 string 类拥有自定义的构造函数,所以 U 的构造函数被删除;定义 U 的类型变量 u 需要调用默认构造函数,所以 u 也就无法定义成功。

解决上面问题的一般需要用到 placement new,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <string>
using namespace std;
union U {
string s;
int n;
public:
U() { new(&s) string; }
~U() { s.~string(); }
};
int main() {
U u;
return 0;
}

构造时,采用placement new将 s 构造在其地址 &s 上,这里placement new的唯一作用只是调用了一下 string 类的构造函数。注意,在析构时还需要调用 string 类的析构函数。

非受限联合体的匿名声明和“枚举式类”

匿名联合体是指不具名的联合体(也即没有名字的联合体),一般定义如下:

1
2
3
union U{
union { int x; }; //此联合体为匿名联合体
};

可以看到,联合体 U 内定义了一个不具名的联合体,该联合体包含一个 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
29
30
31
32
33
34
#include <cstring>
using namespace std;
class Student{
public:
Student(bool g, int a): gender(g), age(a){}
bool gender;
int age;
};
class Singer {
public:
enum Type { STUDENT, NATIVE, FOREIGENR };
Singer(bool g, int a) : s(g, a) { t = STUDENT; }
Singer(int i) : id(i) { t = NATIVE; }
Singer(const char* n, int s) {
int size = (s > 9) ? 9 : s;
memcpy(name , n, size);
name[s] = '\0';
t = FOREIGENR;
}
~Singer(){}
private:
Type t;
union {
Student s;
int id;
char name[10];
};
};
int main() {
Singer(true, 13);
Singer(310217);
Singer("J Michael", 9);
return 0;
}

上面的代码中使用了一个匿名非受限联合体,它作为类 Singer 的“变长成员”来使用,这样的变长成员给类的编写带来了更大的灵活性,这是 C++98 标准中无法达到的。

assert()

断言,是宏,而非函数。assert宏的原型定义在 <assert.h>(C)、<cassert>(C++)中,其作用是如果它的条件返回错误,则终止程序执行。可以通过定义 NDEBUG 来关闭 assert,但是需要在源代码的开头,include <assert.h> 之前。

assert()会对表达式expression进行检测:

  • 如果expression的结果为 0(条件不成立),那么断言失败,表明程序出错,assert()会向标准输出设备(一般是显示器)打印一条错误信息,并调用 abort() 函数终止程序的执行。
  • 如果expression的结果为非 0(条件成立),那么断言成功,表明程序正确,assert()不进行任何操作。

参数:

  • expression:要检测的表达式。如果表达式的值为 0,那么断言失败,程序终止执行;如果表达式的值为非 0,那么断言成功,assert() 不进行任何操作。

assert() 的用法和机制

assert()的用法很简单,我们只要传入一个表达式,它会计算这个表达式的结果:如果表达式的结果为“假”,assert()会打印出断言失败的信息,并调用abort()函数终止程序的执行;如果表达式的结果为“真”,assert()就什么也不做,程序继续往后执行。

下面是一个具体的例子:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <assert.h>
int main(){
int m, n, result;
scanf("%d %d", &m, &n);
assert(n != 0); //写作 assert(n) 更加简洁
result = m / n;
printf("result = %d\n", result);
return 0;
}

NDEBUG 宏

如果查看<assert.h>头文件的源码,会发现assert()被定义为下面的样子:

1
2
3
4
5
6
#ifdef NDEBUG
#define assert(e) ((void)0)
#else
#define assert(e) \
((void) ((e) ? ((void)0) : __assert (#e, __FILE__, __LINE__)))
#endif

这意味着,一旦定义了NDEBUG宏,assert()就无效了。

NDEBUG是”No Debug“的意思,也即“非调试”。有的编译器(例如 Visual Studio)在发布(Release)模式下会定义 NDEBUG 宏,在调试(Debug)模式下不会定义定义这个宏;有的编译器(例如 Xcode)在发布模式和调试模式下都不会定义 NDEBUG 宏,这样当我们以发布模式编译程序时,就必须自己在编译参数中增加NDEBUG宏,或者在包含<assert.h>头文件之前定义NDEBUG宏。

调试模式是程序员在测试代码期间使用的编译模式,发布模式是将程序提供给用户时使用的编译模式。在发布模式下,我们不应该再依赖assert()宏,因为程序一旦出错,assert()会抛出一段用户看不懂的提示信息,并毫无预警地终止程序执行,这样会严重影响软件的用户体验,所以在发布模式下应该让assert()失效。

修改上面的代码,在包含<assert.h>之前定义NDEBUG宏:

1
2
3
4
5
6
7
8
9
10
11
#define NDEBUG
#include <stdio.h>
#include <assert.h>
int main(){
int m, n, result;
scanf("%d %d", &m, &n);
assert(n);
result = m / n;
printf("result = %d\n", result);
return 0;
}

当以发布模式编译这段代码时,assert()就会失效。如果希望继续以调试模式编译这段代码,去掉NDEBUG宏即可。

注意事项

使用assert()时,被检测的表达式最好不要太复杂,以下面的代码为例:assert( expression1 && expression2 && expression3);

当发生错误时,assert()只会告诉我们expression1 && expression2 && expression3整个表达式为不成立,但是这个大的表达式还包含了三个小的表达式,并且它们之间是&&运算,任何一个小表达式为不成立都会导致整个表达式为不成立,这样我们就无法推断到底是expression1有问题,还是expression2或者expression3有问题,从而给排错带来麻烦。

这里我们应该遵循使用assert()的一个原则:每次断言只能检验一个表达式。根据这个原则,上面的代码应改为:

1
2
3
assert(expression1);
assert(expression2);
assert(expression3);

如此,一旦程序出错,我们就知道是哪个小的表达式断言失败了,从而快速定位到有问题的代码。

使用assert()的另外一个注意事项是:不要用会改变环境的语句作为断言的表达式。请看下面的代码:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <assert.h>
int main(){
int i = 0;
while(i <= 110){
assert(++i <= 100);
printf("我是第%d行\n",i);
}
return 0;
}

在 Debug 模式下运行,程序循环到第 101 次时,i 的值为 100,++i <= 100不再成立,断言失败,程序终止运行。

在 Release 模式下运行,编译参数中设置了NDEBUG宏(如果编译器没有默认设置,那么需要你自己来设置),assert()会失效,++i <= 100这个表达式也不起作用了,while()无法终止,成为一个死循环。

定义了NDEBUG宏后,assert(++i <= 100)会被替换为((void)0)

pair类型

pair类型的定义和初始化

pair类型是在有文件utility中定义的,pair类型包含了两个数据值,通常有以下的一些定义和初始化的一些方法:

1
2
3
pair<T1, T2> p;
pair<T1, T2> p(v1, v2);
make_pair(v1, v2)

上述第一种方法是定义了一个空的pair对象p,第二种方法是定义了包含初始值为v1和v2的pair对象p。第三种方法是以v1和v2值创建的一个新的pair对象。

pair对象的一些操作

除此之外,pair对象还有一些方法,如取出pair对象中的每一个成员的值:

1
2
p.first
p.second

一个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>
#include <string>
#include <utility>
using namespace std;

int main(){
pair<int, string> p1(0, "Hello");
printf("%d, %s\n", p1.first, p1.second.c_str());
pair<int, string> p2 = make_pair(1, "World");
printf("%d, %s\n", p2.first, p2.second.c_str());
return 0;
}

map

标准库map类型是一种以键-值(key-value)存储的数据类型。

  • 第一个可以称为关键字(key),每个关键字只能在map中出现一次;
  • 第二个可能称为该关键字的值(value);

map以模板(泛型)方式实现,可以存储任意类型的数据,包括使用者自定义的数据类型。Map主要用于资料一对一映射(one-to-one)的情況,map內部的实现自建一颗红黑树,这颗树具有对数据自动排序的功能。在map内部所有的数据都是有序的。

以下分别从以下的几个方面总结:

  • map对象的定义和初始化
  • map对象的基本操作,主要包括添加元素,遍历等

map对象的定义和初始化

map是键-值对的组合,有以下的一些定义的方法:

1
2
3
map<k, v> m;
map<k, v> m(m2);
map<k, v> m(b, e);

上述第一种方法定义了一个名为m的空的map对象;第二种方法创建了m2的副本m;第三种方法创建了map对象m,并且存储迭代器b和e范围内的所有元素的副本。

map的value_type是存储元素的键以及值的pair类型,键为const。

使用map得包含map类所在的头文件

1
#include <map>  //注意,STL头文件没有扩展名.h

map对象是模板类,需要关键字和存储对象两个模板参数:
1
std:map<int, string> personnel;

这样就定义了一个用int作为索引,并拥有相关联的指向string的指针.

为了使用方便,可以对模板类进行一下类型定义,

1
typedef aap<int,CString> UDT_MAP_INT_CSTRING;

map共提供了6个构造函数,这块涉及到内存分配器这些东西,略过不表,在下面我们将接触到一些map的构造方法,这里要说下的就是,我们通常用如下方法构造一个map:

1
map<int, string> mapStudent;

map中元素的插入

在map中元素有两种插入方法:

  • 使用下标
  • 使用insert函数

在map中使用下标访问不存在的元素将导致在map容器中添加一个新的元素。

insert函数的插入方法主要有如下:

  • m.insert(e)
  • m.insert(beg, end)
  • m.insert(iter, e)

上述的e一个value_type类型的值。beg和end标记的是迭代器的开始和结束。

两种插入方法如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <map>
using namespace std;

int main(){
map<int, int> mp;
for (int i = 0; i < 10; i ++){
mp[i] = i;
}
for (int i = 10; i < 20; i++){
mp.insert(make_pair(i, i));
}
map<int, int>::iterator it;
for (it = mp.begin(); it != mp.end(); it++){
printf("%d-->%d\n", it->first, it->second);
}
return 0;
}

另外的方法:

1
2
3
4
5
6
7
8
9
10
11
12
// 定义一个map对象
map<int, string> mapStudent;

// 第一种 用insert函數插入pair
mapStudent.insert(pair<int, string>(000, "student_zero"));

// 第二种 用insert函数插入value_type数据
mapStudent.insert(map<int, string>::value_type(001, "student_one"));

// 第三种 用"array"方式插入
mapStudent[123] = "student_first";
mapStudent[456] = "student_second";

以上三种用法,虽然都可以实现数据的插入,但是它们是有区别的,当然了第一种和第二种在效果上是完成一样的,用insert函数插入数据,在数据的 插入上涉及到集合的唯一性这个概念,即当map中有这个关键字时,insert操作是不能在插入数据的,但是用数组方式就不同了,它可以覆盖以前该关键字对 应的值,用程序说明如下:

1
2
mapStudent.insert(map<int, string>::value_type (001, "student_one"));
mapStudent.insert(map<int, string>::value_type (001, "student_two"));

map中元素的查找和读取

注意:上述采用下标的方法读取map中元素时,若map中不存在该元素,则会在map中插入。

因此,若只是查找该元素是否存在,可以使用函数count(k),该函数返回的是k出现的次数;若是想取得key对应的值,可以使用函数find(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
#include <stdio.h>
#include <map>
using namespace std;

int main(){
map<int, int> mp;
for (int i = 0; i < 20; i++){
mp.insert(make_pair(i, i));
}

if (mp.count(0)){
printf("yes!\n");
}else{
printf("no!\n");
}

map<int, int>::iterator it_find;
it_find = mp.find(0);
if (it_find != mp.end()){
it_find->second = 20;
}else{
printf("no!\n");
}

map<int, int>::iterator it;
for (it = mp.begin(); it != mp.end(); it++){
printf("%d->%d\n", it->first, it->second);
}
return 0;
}

从map中删除元素

从map中删除元素的函数是erase(),该函数有如下的三种形式:

1
2
3
m.erase(k)
m.erase(p)
m.erase(b, e)

第一种方法删除的是m中键为k的元素,返回的是删除的元素的个数;第二种方法删除的是迭代器p指向的元素,返回的是void;第三种方法删除的是迭代器b和迭代器e范围内的元素,返回void。

如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <map>
using namespace std;

int main(){
map<int, int> mp;
for (int i = 0; i < 20; i++){
mp.insert(make_pair(i, i));
}

mp.erase(0);
mp.erase(mp.begin());
map<int, int>::iterator it;
for (it = mp.begin(); it != mp.end(); it++){
printf("%d->%d\n", it->first, it->second);
}

return 0;
}

map的基本操作函数:

C++ maps是一种关联式容器,包含“关键字/值”对

  • begin() 返回指向map头部的迭代器
  • clear() 删除所有元素
  • count() 返回指定元素出现的次数
  • empty() 如果map为空则返回true
  • end() 返回指向map末尾的迭代器
  • equal_range() 返回特殊条目的迭代器对
  • erase() 删除一个元素
  • find() 查找一个元素
  • get_allocator() 返回map的配置器
  • insert() 插入元素
  • key_comp() 返回比较元素key的函数
  • lower_bound() 返回键值>=给定元素的第一个位置
  • max_size() 返回可以容纳的最大元素个数
  • rbegin() 返回一个指向map尾部的逆向迭代器
  • rend() 返回一个指向map头部的逆向迭代器
  • size() 返回map中元素的个数
  • swap() 交换两个map
  • upper_bound() 返回键值>给定元素的第一个位置
  • value_comp() 返回比较元素value的函数

stack

函数名 功能 复杂度
size() 返回栈的元素数 O(1)
top() 返回栈顶的元素 O(1)
pop() 从栈中取出并删除元素 O(1)
push(x) 向栈中添加元素x O(1)
empty() 在栈为空时返回true O(1)

贴一些代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "stdafx.h"
#include<iostream>
#include<stack>
using namespace std;
int main()
{
stack<int> S;
S.push(3);
S.push(7);
S.push(1);
cout << S.size() << " ";
cout << S.top() << " ";
S.pop();
cout << S.top() << " ";
S.pop();
cout << S.top() << " ";
S.push(5);
cout << S.top() << " ";
S.pop();
cout << S.top() << endl;
return 0;
}

queues

C++队列是一种容器适配器,它给予程序员一种先进先出(FIFO)的数据结构。

  1. back() 返回一个引用,指向最后一个元素
  2. empty() 如果队列空则返回真
  3. front() 返回第一个元素
  4. pop() 删除第一个元素
  5. push() 在末尾加入一个元素
  6. size() 返回队列中元素的个数

队列可以用线性表(list)或双向队列(deque)来实现(注意vector container 不能用来实现queue,因为vector 没有成员函数pop_front!):

1
2
queue<list<int>> q1
queue<deque<int>> q2

其成员函数有“判空(empty)” 、“尺寸(Size)” 、“首元(front)” 、“尾元(backt)” 、“加入队列(push)” 、“弹出队列(pop)”等操作。

例:

1
2
3
4
5
6
7
8
int main()
{
queue<int> q;
q.push(4);
q.push(5);
printf("%d\n",q.front());
q.pop();
}

Priority Queues

C++优先队列类似队列,但是在这个数据结构中的元素按照一定的断言排列有序。

  1. empty()如果优先队列为空,则返回真
  2. pop()删除第一个元素
  3. push()加入一个元素
  4. size()返回优先队列中拥有的元素的个数
  5. top()返回优先队列中有最高优先级的元素

优先级队列可以用向量(vector)或双向队列(deque)来实现(注意list container 不能用来实现queue,因为list 的迭代器不是任意存取iterator,而pop 中用到堆排序时是要求randomaccess iterator 的!):

  • priority_queue<vector<int>, less<int>> pq1; 使用递增less函数对象排序
  • priority_queue<deque<int>, greater<int>> pq2; 使用递减greater函数对象排序
  • 其成员函数有“判空(empty)” 、“尺寸(Size)” 、“栈顶元素(top)” 、“压栈(push)” 、“弹栈(pop)”等。

priority_queue模版类有三个模版参数,元素类型,容器类型,比较算子。其中后两个都可以省略,默认容器为vector,默认算子为less,即小的往前排,大的往后排(出队时序列尾的元素出队)。

初学者在使用priority_queue时,最困难的可能就是如何定义比较算子了。如果是基本数据类型,或已定义了比较运算符的类,可以直接用STL的less算子和greater算子——默认为使用less算子,即小的往前排,大的先出队。如果要定义自己的比较算子,方法有多种,这里介绍其中的一种:重载比较运算符。优先队列试图将两个元素x和y代入比较运算符(对less算子,调用xy),若结果为真,则x排在y前面,y将先于x出队,反之,则将y排在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
29
30
#include <iostream>
#include <queue>
using namespace std;

class T {
public:
int x, y, z;
T(int a, int b, int c):x(a), y(b), z(c)
{
}
};
bool operator < (const T &t1, const T &t2)
{
return t1.z < t2.z; // 按照z的顺序来决定t1和t2的顺序
}
int main()
{
priority_queue<T> q;
q.push(T(4,4,3));
q.push(T(2,2,5));
q.push(T(1,5,4));
q.push(T(3,3,6));
while (!q.empty())
{
T t = q.top();
q.pop();
cout << t.x << " " << t.y << " " << t.z << endl;
}
return 1;
}

输出结果为(注意是按照z的顺序从大到小出队的):

1
2
3
4
3 3 6 
2 2 5
1 5 4
4 4 3

再看一个按照z的顺序从小到大出队的例子:

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
#include <iostream> 
#include <queue>
using namespace std;
class T
{
public:
int x, y, z;
T(int a, int b, int c):x(a), y(b), z(c)
{
}
};
bool operator > (const T &t1, const T &t2)
{
return t1.z > t2.z;
}
main()
{
priority_queue<T, vector<T>, greater<T> > q;
q.push(T(4,4,3));
q.push(T(2,2,5));
q.push(T(1,5,4));
q.push(T(3,3,6));
while (!q.empty())
{
T t = q.top();
q.pop();
cout << t.x << " " << t.y << " " << t.z << endl;
}
return 1;
}

输出结果为:
1
2
3
4
4 4 3 
1 5 4
2 2 5
3 3 6

vector的内部实现原理及基本用法

本文基于STL vector源代码,但是不考虑分配器allocator,迭代器iterator,异常处理try/catch等内容,同时对_Ucopy()、 _Umove()、 _Ufill()函数也不会过度分析。

vector的定义

1
2
3
4
5
6
7
8
9
10
11
12
template<class _Ty,
class _Ax>
class vector
: public _Vector_val<_Ty, _Ax>
{ // varying size array of values
public:
/********/
protected:
pointer _Myfirst; // pointer to beginning of array
pointer _Mylast; // pointer to current end of sequence
pointer _Myend; // pointer to end of array
};

简单理解,就是vector是利用上述三个指针来表示的,基本示意图如下:

两个关键大小:

  • 大小:size=_Mylast - _Myfirst;
  • 容量:capacity=_Myend - _Myfirst;

分别对应于resize()、reserve()两个函数。size表示vector中已有元素的个数,容量表示vector最多可存储的元素的个数;为了降低二次分配时的成本,vector实际配置的大小可能比客户需求的更大一些,以备将来扩充,这就是容量的概念。即capacity>=size,当等于时,容器此时已满,若再要加入新的元素时,就要重新进行内存分配,整个vector的数据都要移动到新内存。二次分配成本较高,在实际操作时,应尽量预留一定空间,避免二次分配。

构造与析构

构造

vector的构造函数主要有以下几种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vector() : _Mybase()
{ // construct empty vector
_Buy(0);
}
explicit vector(size_type _Count) : _Mybase()
{ // construct from _Count * _Ty()
_Construct_n(_Count, _Ty());
}
vector(size_type _Count, const _Ty& _Val) : _Mybase()
{ // construct from _Count * _Val
_Construct_n(_Count, _Val);
}
vector(const _Myt& _Right) : _Mybase(_Right._Alval)
{ // construct by copying _Right
if (_Buy(_Right.size()))
_Mylast = _Ucopy(_Right.begin(), _Right.end(), _Myfirst);
}

vector优异性能的秘诀之一,就是配置比其所容纳的元素所需更多的内存,一般在使用vector之前,就先预留足够空间,以避免二次分配,这样可以使vector的性能达到最佳。因此元素个数_Count是个远比元素值 _Val重要的参数,因此当构造一个vector时,首要参数一定是元素个数。
由上各构造函数可知,基本上所有构造函数都是基于_Construct _n() 的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool _Buy(size_type _Capacity)
{ // allocate array with _Capacity elements
_Myfirst = 0, _Mylast = 0, _Myend = 0;
if (_Capacity == 0) //_Count为0时,直接返回
return (false);
else
{ // nonempty array, allocate storage
_Myfirst = this->_Alval.allocate(_Capacity); //分配内存,并更新成员变量
_Mylast = _Myfirst;
_Myend = _Myfirst + _Capacity;
}
return (true);
}

void _Construct_n(size_type _Count, const _Ty& _Val)
{ // 构造含有_Count个值为_Val的元素的容器
if (_Buy(_Count))
_Mylast = _Ufill(_Myfirst, _Count, _Val);
}

这样就完成了vector容器的构造了。

析构

vector的析构函数很简单,就是先销毁所有已存在的元素,然后释放所有内存

1
2
3
4
5
6
7
8
9
void _Tidy()
{ // free all storage
if (_Myfirst != 0)
{ // something to free, destroy and deallocate it
_Destroy(_Myfirst, _Mylast);
this->_Alval.deallocate(_Myfirst, _Myend - _Myfirst);
}
_Myfirst = 0, _Mylast = 0, _Myend = 0;
}

插入和删除元素

vector的插入和删除元素是通过push_back ()pop_back()两个接口来实现的,他们的内部实现也非常简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void push_back(const _Ty& _Val)
{ // insert element at end
if (size() < capacity())
_Mylast = _Ufill(_Mylast, 1, _Val);
else
insert(end(), _Val); //空间不足时,就会触发内存的二次分配
}
void pop_back()
{ // erase element at end
if (!empty())
{ // erase last element
_Destroy(_Mylast - 1, _Mylast);
--_Mylast;
}
}

其他接口

reserve()操作。之前提到过reserve(Count) 函数主要是预留Count大小的空间,对应的是容器的容量,目的是保证(_Myend - _Myfirst)>=Count。只有当空间不足时,才会操作,即重新分配一块内存,将原有元素拷贝到新内存,并销毁原有内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void reserve(size_type _Count)
{ // determine new minimum length of allocated storage
if (capacity() < _Count)
{ // not enough room, reallocate
pointer _Ptr = this->_Alval.allocate(_Count);
_Umove(begin(), end(), _Ptr);
size_type _Size = size();
if (_Myfirst != 0)
{ // destroy and deallocate old array
_Destroy(_Myfirst, _Mylast);
this->_Alval.deallocate(_Myfirst, _Myend - _Myfirst);
}
_Myend = _Ptr + _Count;
_Mylast = _Ptr + _Size;
_Myfirst = _Ptr;
}
}

resize()操作。resize(Count) 函数主要是用于改变size的,也就是改变vector的大小,最终改变的是(_Mylast - _Myfirst)的值,当size < Count时,就插入元素,当size >Count时,就擦除元素。

1
2
3
4
5
6
7
void resize(size_type _Newsize, _Ty _Val)
{ // determine new length, padding with _Val elements as needed
if (size() < _Newsize)
_Insert_n(end(), _Newsize - size(), _Val);
else if (_Newsize < size())
erase(begin() + _Newsize, end());
}

_Insert_n()操作。resize()操作和insert()操作都会利用到_Insert_n()这个函数,这个函数非常重要,也比其他函数稍微复杂一点。虽然_Insert_n(_where, _Count, _Val )函数比较长,但是操作都非常简单,主要可以分为以下几种情况:

  1. _Count == 0,不需要插入,直接返回
  2. max_size() - size() < _Count,超过系统设置的最大容量,会溢出,造成Xlen()异常
  3. _Capacity < size() + _Count,vector的容量不足以插入Count个元素,需要进行二次分配,扩大vector的容量。 在VS下,vector容量会扩大50%,即 _Capacity = _Capacity + _Capacity / 2;
    若仍不足,则 _Capacity = size() + _Count;
1
2
3
4
5
6
7
8
9
10
11
12
13
else if (_Capacity < size() + _Count)
{ // not enough room, reallocate
_Capacity = max_size() - _Capacity / 2 < _Capacity
? 0 : _Capacity + _Capacity / 2; // try to grow by 50%
if (_Capacity < size() + _Count)
_Capacity = size() + _Count;
pointer _Newvec = this->_Alval.allocate(_Capacity);
pointer _Ptr = _Newvec;
_Ptr = _Umove(_Myfirst, _VEC_ITER_BASE(_Where),_Newvec); // copy prefix
_Ptr = _Ufill(_Ptr, _Count, _Val); // add new stuff
_Umove(_VEC_ITER_BASE(_Where), _Mylast, _Ptr); // copy suffix
//内存释放与变量更新
}

这种情况下,数据从原始容器移动到新分配内存时是从前到后移动的

  1. 空间足够,且被插入元素的位置比较靠近_Mylast,即已有元素的尾部

这种情况下不需要再次进行内存分配,且数据是从后往前操作的。首先是将where~last向后移动,为待插入数据预留Count大小的空间,然后从_Mylast处开始填充,然后将从where处开始填充剩余元素

1
2
3
4
5
6
7
else if ((size_type)(_Mylast - _VEC_ITER_BASE(_Where)) < _Count)
{ // new stuff spills off end
_Umove(_VEC_ITER_BASE(_Where), _Mylast, _VEC_ITER_BASE(_Where) + _Count); // copy suffix
_Ufill(_Mylast, _Count - (_Mylast - _VEC_ITER_BASE(_Where)), _Val); // insert new stuff off end
_Mylast += _Count;
std::fill(_VEC_ITER_BASE(_Where), _Mylast - _Count, _Val); // insert up to old end
}

  1. 空间足够,但插入的位置比较靠前
    1
    2
    3
    4
    5
    6
    7
    8
    {   // new stuff can all be assigned
    _Ty _Tmp = _Val; // in case _Val is in sequence

    pointer _Oldend = _Mylast;
    _Mylast = _Umove(_Oldend - _Count, _Oldend, _Mylast); // copy suffix
    _STDEXT _Unchecked_move_backward(_VEC_ITER_BASE(_Where), _Oldend - _Count, _Oldend); // copy hole
    std::fill(_VEC_ITER_BASE(_Where), _VEC_ITER_BASE(_Where) + _Count, _Tmp); // insert into hole
    }

erase()操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
iterator erase(const_iterator _First_arg,
const_iterator _Last_arg)
{ // erase [_First, _Last)
iterator _First = _Make_iter(_First_arg);
iterator _Last = _Make_iter(_Last_arg);

if (_First != _Last)
{ // worth doing, copy down over hole
pointer _Ptr = _STDEXT unchecked_copy(_VEC_ITER_BASE(_Last), _Mylast,
_VEC_ITER_BASE(_First));

_Destroy(_Ptr, _Mylast);
_Mylast = _Ptr;
}
return (_First);
}

主要操作就是将后半部分的有效元素向前拷贝,并将后面空间的无效元素析构,并更新_Mylast变量

assign()操作最终都会调用到下面的函数,主要操作是首先擦除容器中已有的全部元素,在从头开始插入Count个Val元素

1
2
3
4
5
6
void _Assign_n(size_type _Count, const _Ty& _Val)
{ // assign _Count * _Val
_Ty _Tmp = _Val; // in case _Val is in sequence
erase(begin(), end());
insert(begin(), _Count, _Tmp);
}

基本使用

在经过上述对vector内部实现的分析后,再来理解相应接口就变得简单得多。vector对外接口主要可以分为:

构造、析构:

1
2
3
4
5
6
vector<Elem> c
vector <Elem> c1(c2)
vector <Elem> c(n)
vector <Elem> c(n, elem)
vector <Elem> c(beg,end)
c.~ vector <Elem>()

插入、删除、赋值

1
2
3
4
5
6
7
8
9
10
c.push_back(elem)
c.pop_back()
c.insert(pos,elem)
c.insert(pos,n,elem)
c.insert(pos,beg,end)
c.erase(pos)
c.erase(beg,end)
c.clear()
c.assign(beg,end)
c.assign(n,elem)

大小相关

1
2
3
4
5
c.capacity()
c.max_size()
c.resize(num)
c.reserve()
c.size()

获取迭代器

1
2
3
4
c.begin()
c.end()
c.rbegin()
c.rend()

获取数据

1
2
3
4
operator[]
c.at(idx)
c.front()
c.back()

size_t

在标准C库中的许多函数使用的参数或者返回值都是表示的用字节表示的对象大小,比如说malloc(n)函数的参数n指明了需要申请的空间大小,还有memcpy(s1, s2, n)的最后一个参数,表明需要复制的内存大小,strlen(s)函数的返回值表明了以’\0’结尾的字符串的长度(不包括’\0’),其返回值并不是该字符串的实际长度,因为要去掉’\0’。

或许你会认为这些参数或者返回值应该被申明为int类型(或者long或者unsigned),但是事实上并不是。C标准中将他们定义为size_t。标准中记载malloc的申明应该出现在,定义为:

1
void *malloc(size_t n);

memcpy和strlen的申明应该出现在中:

1
2
void *memcpy(void *s1, void const *s2, size_t n);
size_t strlen(char const *s);

size_t还经常出现在C++标准库中,此外,C++库中经常会使用一个相似的类型size_type,用的可能比size_t还要多。

可移植性问题

回忆memcpy(s1, s2, n)函数,它将s2指向地址开始的n个字节拷贝到s2指向的地址,返回s1,这个函数可以拷贝任何数据类型,所以参数和返回值的类型应该为可以指向任何类型的void,同时,>源地址不应该被改变,所以第二个参数s2类型应该为`const void`,这些都不是问题。真正的问题在于我们如何申明第三个参数,它代表了源对象的大小,我相信大部分程序员都会选择int:

1
void *memcpy(void *s1, void const *s2, int n);

使用int类型在大部分情况下都是可以的,但是我们可以使用unsigned int代替它让第三个参数表示的范围更大。在大部分机器上,unsigned int的最大值要比int的最大值大两倍。使用unsigned int修饰第三个参数的代价与int是相同的:
1
void *memcpy(void *s1, void const *s2, unsigned int n);

这样似乎没有问题了,unsigned int可以表示最大类型的对象大小了,这种情况只有在整形和指针类型具有相同大小的情况下,比如说在IP16中,整形和指针都占2个字节(16位),而在IP32上面,整形和指针都占4个字节(32位)。

使用size_t

size_t是一种数据相关的无符号类型,它被设计得足够大以便能够内存中任意对象的大小。在C++中,设计 size_t 就是为了适应多个平台的。ize_t的引入增强了程序在不同平台上的可移植性。

size_t的定义在<stddef.h>,<stdio.h>,<stdlib.h>,<string.h>, <time.h><wchar.h>这些标准C头文件中,也出现在相应的C++头文件, 等等中,你应该在你的头文件中至少包含一个这样的头文件在使用size_t之前。包含以上任何C头文件(由C或C++编译的程序)表明将size_t作为全局关键字。根据定义,size_t是sizeof关键字(注:sizeof是关键字,并非运算符)运算结果的类型。所以,应当通过适当的方式声明n来完成赋值:

1
n = sizeof(thing);

考虑到可移植性和程序效率,n应该被申明为size_t类型。类似的,下面的foo函数的参数也应当被申明为sizeof:
1
foo(sizeof(thing));

参数中带有size_t的函数通常会含有局部变量用来对数组的大小或者索引进行计算,在这种情况下,size_t是个不错的选择。

size_t的大小并非像很多网上描述的那样,其大小是由系统的位数决定的。size_t的大小是由你生成的程序类型决定的,只是生成的程序类型与系统的类型有一定关系。32bits的程序既可以在64bits的系统上运行,也可以在32bits的系统上运行。但是64bits的程序只能在64bits的系统上运行。然而我们编译的程序一般是32bits的,因此size_t的大小也就变成了4个字节。

内存对齐

struct/class/union内存对齐原则有四个:

  1. 数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员>有子成员,比如说是数组,结构体等)的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储),基本类型不包括struct/class/uinon。
  2. 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部”最宽基本类型成员”的整数倍地址开始存储.(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储.)。
  3. 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的”最宽基本类型成员”的整数倍.不足的要补齐.(基本类型不包括struct/class/uinon)。
  4. sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。

实例解释:下面以class为代表

1
2
3
4
5
6
7
No. 1
class Data
{
char c;
int a;
};
cout << sizeof(Data) << endl;

1
2
3
4
5
6
7
8
No. 2
class Data
{
char c;
double a;
};

cout << sizeof(Data) << endl;

显然程序No.1 输出的结果为 8, No.2 输出的结果为 16。No.1最大的数据成员是4bytes,1+4=5,补齐为4的倍数,也就是8。而No.2为8bytes,1+8=9,补齐为8的倍数,也就是16。

内存对齐的主要作用是:

  1. 平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
  2. 性能原因:经过内存对齐后,CPU的内存访问速度大大提升。具体原因稍后解释。

strlen和sizeof区别?

sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。

sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。

因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。

1
2
3
4
5
6
int main(int argc, char const *argv[]){   
const char* str = "name";
sizeof(str); // 取的是指针str的长度,是8
strlen(str); // 取的是这个字符串的长度,不包含结尾的 \0。大小是4
return 0;
}

数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名);数组大小:sizeof(数组名)/sizeof(数组元素数据类型);用运算符sizeof 可以计算出数组的容量(字节数)。sizeof(p),p 为指针得到的是一个指针变量的字节数,而不是p 所指的内存容量。

OFFSETOF

OFFSETOF(s, m)的宏定义,s是结构类型,m是s的成员,求m在s中的偏移量。

1
#define OFFSETOF(s, m) size_t(&((s*)0)->m)

sizeof

sizeof一个类求大小(注意成员变量,函数,虚函数,继承等等对大小的影响)以下运行环境都是一般的,在32位编译环境中。

基本数据类型的sizeof

1
2
3
4
5
6
7
cout<<sizeof(char)<<endl;                     结果是1
cout<<sizeof(int)<<endl; 结果是4
cout<<sizeof(unsigned int)<<endl; 结果是4
cout<<sizeof(long int)<<endl; 结果是4
cout<<sizeof(short int)<<endl; 结果是2
cout<<sizeof(float)<<endl; 结果是4
cout<<sizeof(double)<<endl; 结果是8

指针变量的sizeof

1
2
3
4
5
6
7
8
9
10
11
12
char *pc ="abc";
sizeof( pc ); // 结果为4
sizeof(*pc); // 结果为1
int *pi;
sizeof( pi ); //结果为4
sizeof(*pi); //结果为4
char **ppc = &pc;
sizeof( ppc ); // 结果为4
sizeof( *ppc ); // 结果为4
sizeof( **ppc ); // 结果为1
void (*pf)();// 函数指针
sizeof( pf );// 结果为4

数组的sizeof数组的sizeof值等于数组所占用的内存字节数,如:

1
2
3
4
char a1[] = "abc";
int a2[3];
sizeof( a1 ); // 结果为4,字符 末尾还存在一个NULL终止符
sizeof( a2 ); // 结果为3*4=12(依赖于int)

写到这里,提一问,下面的c3,c4值应该是多少呢
1
2
3
4
5
6
7
8
void foo3(char a3[3])
{
int c3 = sizeof( a3 ); // c3 == 4
}
void foo4(char a4[])
{
int c4 = sizeof( a4 ); // c4 == 4
}

也许当你试图回答c4的值时已经意识到c3答错了,是的,c3!=3。这里函数参数a3已不再是数组类型,而是蜕变成指针,相当于char* a3,为什么仔细想想就不难明白,我们调用函数foo1时,程序会在栈上分配一个大小为3的数组吗不会!数组是“传址”的,调用者只需将实参的地址传递过去,所以a3自然为指针类型char*,c3的值也就为4。

结构体的sizeof

1
2
3
4
5
6
struct MyStruct
{
double dda1;
char dda;
int type
};

结果为16,为上面的结构分配空间的时候,VC根据成员变量出现的顺序和对齐方式,先为第一个成员dda1分配空间,其起始地址跟结构的起始地址相同(刚好偏移量0刚好为sizeof(double)的倍数),该成员变量占用sizeof(double)=8个字节;接下来为第二个成员dda分配空间,这时下一个可以分配的地址对于结构的起始地址的偏移量为8,是sizeof(char)的倍数,所以把dda存放在偏移量为8的地方满足对齐方式,该成员变量占用sizeof(char)=1个字节;接下来为第三个成员type分配空间,这时下一个可以分配的地址对于结构的起始地址的偏移量为9,不是sizeof(int)=4的倍数,为了满足对齐方式对偏移量的约束问题,VC自动填充3个字节(这三个字节没有放什么东西),这时下一个可以分配的地址对于结构的起始地址的偏移量为12,刚好是sizeof(int)=4的倍数,所以把type存放在偏移量为12的地方,该成员变量占用sizeof(int)=4个字节;这时整个结构的成员变量已经都分配了空间,总的占用的空间大小为:8+1+3+4=16,刚好为结构的字节边界数(即结构中占用最大空间的类型所占用的字节数sizeof(double)=8)的倍数,所以没有空缺的字节需要填充。

含位域结构体的sizeof

1
2
3
4
5
6
struct BF1
{
char f1 : 3;
char f2 : 4;
char f3 : 5;
};

位域类型为char,第1个字节仅能容纳下f1和f2,所以f2被压缩到第1个字节中,而f3只能从下一个字节开始。因此sizeof(BF1)的结果为2。

含有联合体的结构体的sizeof

1
2
3
4
5
6
7
8
9
10
struct s1
{
char *ptr,ch;
union A
{
short a,b;
unsigned int c:2, d:1;
};
struct s1* next;
};

这样是8+4=12个字节
1
2
3
4
5
6
7
8
9
10
struct s1
{
char *ptr,ch;
union //联合体是结构体的成员,占内存,并且最大类型是unsigned int,占4
{
short a,b;
unsigned int c:2, d:1;
};
struct s1* next;
};

这样是8+4+4=16个字节

结构体含有结构体的sizeof

1
2
3
4
5
6
7
8
9
10
11
12
struct S1
{
char c;
int i;
};
struct S3
{
char c1;
S1 s;
char c2;
};
cout<<sizeof(S3); //S3=16

S1的最宽简单成员的类型为int,S3在考虑最宽简单类型成员时是将S1“打散”看的,所以S3的最宽简单类型为int,这样,通过S3定义的变量,其存储空间首地址需要被4整除,整个sizeof(S3)的值也应该被4整除。

c1的偏移量为0,s的偏移量呢这时s是一个整体,它作为结构体变量也满足前面三个准则,所以其大小为8,偏移量为4,c1与s之间便需要3个填充字节,而c2与s之间就不需要了,所以c2的偏移量为12,算上c2的大小为13,13是不能被4整除的,这样末尾还得补上3个填充字节。最后得到sizeof(S3)的值为16。

带有#pragma pack的sizeof:它是用来调整结构体对齐方式的,不同编译器名称和用法略有不同,VC6中通过#pragma pack实现,也可以直接修改/Zp编译开关。#pragma pack的基本用法为:#pragma pack(n),n为字节对齐数,其取值为1、2、4、8、16,默认是8,如果这个值比结构体成员的sizeof值小,那么该成员的偏移量应该以此值为准,即是说,结构体成员的偏移量应该取二者的最小值,

再看示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma pack(push) // 将当前pack设置压栈保存
#pragma pack(2)// 必须在结构体定义之前使用
struct S1
{
char c;
int i;
};
struct S3
{
char c1;
S1 s;
char c2
};
#pragma pack(pop) // 恢复先前的pack设置

计算sizeof(S1)时,min(2, sizeof(i))的值为2,所以i的偏移量为2,加上sizeof(i)等于6,能够被2整除,所以整个S1的大小为6。

同样,对于sizeof(S3),s的偏移量为2,c2的偏移量为8,加上sizeof(c2)等于9,不能被2整除,添加一个填充字节,所以sizeof(S3)等于10。

空结构体的sizeof

1
2
struct S5 { };
sizeof( S5 ); // 结果为1

类的sizeof
类的sizeof值等于类中成员变量所占用的内存字节数。如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public:
int b;
float c;
char d;
};

int main(void)
{
A object;
cout << "sizeof(object) is " << sizeof(object) << endl;
return 0 ;
}

输出结果为12(我的机器上sizeof(float)值为4,字节对其前面已经讲过)。

不过需要注意的是,如果类中存在静态成员变量,结果又会是什么样子呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A
{
public:
static int a;
int b;
float c;
char d;
};

int main()
{
A object;
cout << "sizeof(object) is " << sizeof(object) << endl;
return 0 ;
}

16?不对。结果仍然是12.

因为在程序编译期间,就已经为static变量在静态存储区域分配了内存空间,并且这块内存在程序的整个运行期间都存在。而每次声明了类A的一个对象的时候,为该对象在堆上,根据对象的大小分配内存。

如果类A中包含成员函数,那么又会是怎样的情况呢?看下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A
{
public:
static int a;
int b;
float c;
char d;
int add(int x,int y)
{
return x+y;
}
};

int main()
{
A object;
cout << "sizeof(object) is " << sizeof(object) << endl;
b = object.add(3,4);
cout << "sizeof(object) is " << sizeof(object) << endl;
return 0 ;
}

结果仍为12。

因为只有非静态类成员变量在新生成一个object的时候才需要自己的副本。所以每个非静态成员变量在生成新object需要内存,而function是不需要的。

标准C++中的string类

相信使用过MFC编程的朋友对CString这个类的印象应该非常深刻吧?的确,MFC中的CString类使用起来真的非常的方便好用。但是如果离开了MFC框架,还有没有这样使用起来非常方便的类呢?答案是肯定的。也许有人会说,即使不用MFC框架,也可以想办法使用MFC中的API,具体的操作方法在本文最后给出操作方法。其实,可能很多人很可能会忽略掉标准C++中string类的使用。标准C++中提供的string类得功能也是非常强大的,一般都能满足我们开发项目时使用。现将具体用法的一部分罗列如下,只起一个抛砖引玉的作用吧,好了,废话少说,直接进入正题吧!

要想使用标准C++中string类,必须要包含

1
2
3
#include <string>// 注意是<string>,不是<string.h>,带.h的是C语言中的头文件
using std::string;
using std::wstring;


1
using namespace std;

下面你就可以使用string/wstring了,它们两分别对应着char和wchar_t。

string和wstring的用法是一样的,以下只用string作介绍:

string类的构造函数

1
2
string(const char *s);    //用c字符串s初始化
string(int n,char c); //用n个字符c初始化

此外,string类还支持默认构造函数和复制构造函数,如string s1;string s2=”hello”;都是正确的写法。当构造的string太长而无法表达时会抛出length_error异常 ;

string类的字符操作

1
2
3
4
const char &operator[](int n)const;
const char &at(int n)const;
char &operator[](int n);
char &at(int n);

operator[]at()均返回当前字符串中第n个字符的位置,但at函数提供范围检查,当越界时会抛出out_of_range异常,下标运算符[]不提供检查访问。

1
2
3
const char *data()const;//返回一个非null终止的c字符数组
const char *c_str()const;//返回一个以null终止的c字符串
int copy(char *s, int n, int pos = 0) const;//把当前串中以pos开始的n个字符拷贝到以s为起始位置的字符数组中,返回实际拷贝的数目

string的特性描述

1
2
3
4
5
6
int capacity()const;    //返回当前容量(即string中不必增加内存即可存放的元素个数)
int max_size()const; //返回string对象中可存放的最大字符串的长度
int size()const; //返回当前字符串的大小
int length()const; //返回当前字符串的长度
bool empty()const; //当前字符串是否为空
void resize(int len,char c);//把字符串当前大小置为len,并用字符c填充不足的部分

string类的输入输出操作

string类重载运算符operator>>用于输入,同样重载运算符operator<<用于输出操作。

函数getline(istream &in,string &s);用于从输入流in中读取字符串到s中,以换行符’\n’分开。

string的赋值

1
2
3
4
5
6
7
string &operator=(const string &s);//把字符串s赋给当前字符串
string &assign(const char *s);//用c类型字符串s赋值
string &assign(const char *s,int n);//用c字符串s开始的n个字符赋值
string &assign(const string &s);//把字符串s赋给当前字符串
string &assign(int n,char c);//用n个字符c赋值给当前字符串
string &assign(const string &s,int start,int n);//把字符串s中从start开始的n个字符赋给当前字符串
string &assign(const_iterator first,const_itertor last);//把first和last迭代器之间的部分赋给字符串

string的连接

1
2
3
4
5
6
7
string &operator+=(const string &s);//把字符串s连接到当前字符串的结尾 
string &append(const char *s); //把c类型字符串s连接到当前字符串结尾
string &append(const char *s,int n);//把c类型字符串s的前n个字符连接到当前字符串结尾
string &append(const string &s); //同operator+=()
string &append(const string &s,int pos,int n);//把字符串s中从pos开始的n个字符连接到当前字符串的结尾
string &append(int n,char c); //在当前字符串结尾添加n个字符c
string &append(const_iterator first,const_iterator last);//把迭代器first和last之间的部分连接到当前字符串的结尾

string的比较

1
bool operator==(const string &s1,const string &s2)const;//比较两个字符串是否相等

运算符”>”,”<”,”>=”,”<=”,”!=”均被重载用于字符串的比较;

1
2
3
4
5
6
int compare(const string &s) const;//比较当前字符串和s的大小
int compare(int pos, int n,const string &s)const;//比较当前字符串从pos开始的n个字符组成的字符串与s的大小
int compare(int pos, int n,const string &s,int pos2,int n2)const;//比较当前字符串从pos开始的n个字符组成的字符串与s中pos2开始的n2个字符组成的字符串的大小
int compare(const char *s) const;
int compare(int pos, int n,const char *s) const;
int compare(int pos, int n,const char *s, int pos2) const;

compare函数在>时返回1,<时返回-1,==时返回0

string的子串

1
string substr(int pos = 0,int n = npos) const;//返回pos开始的n个字符组成的字符串

string的交换

1
void swap(string &s2);    //交换当前字符串与s2的值

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
int find(char c, int pos = 0) const;//从pos开始查找字符c在当前字符串的位置
int find(const char *s, int pos = 0) const;//从pos开始查找字符串s在当前串中的位置
int find(const char *s, int pos, int n) const;//从pos开始查找字符串s中前n个字符在当前串中的位置
int find(const string &s, int pos = 0) const;//从pos开始查找字符串s在当前串中的位置
//查找成功时返回所在位置,失败返回string::npos的值
int rfind(char c, int pos = npos) const;//从pos开始从后向前查找字符c在当前串中的位置
int rfind(const char *s, int pos = npos) const;
int rfind(const char *s, int pos, int n = npos) const;
int rfind(const string &s,int pos = npos) const;
//从pos开始从后向前查找字符串s中前n个字符组成的字符串在当前串中的位置,成功返回所在位置,失败时返回string::npos的值
int find_first_of(char c, int pos = 0) const;//从pos开始查找字符c第一次出现的位置
int find_first_of(const char *s, int pos = 0) const;
int find_first_of(const char *s, int pos, int n) const;
int find_first_of(const string &s,int pos = 0) const;
//从pos开始查找当前串中第一个在s的前n个字符组成的数组里的字符的位置。查找失败返回string::npos
int find_first_not_of(char c, int pos = 0) const;
int find_first_not_of(const char *s, int pos = 0) const;
int find_first_not_of(const char *s, int pos,int n) const;
int find_first_not_of(const string &s,int pos = 0) const;
//从当前串中查找第一个不在串s中的字符出现的位置,失败返回string::npos
int find_last_of(char c, int pos = npos) const;
int find_last_of(const char *s, int pos = npos) const;
int find_last_of(const char *s, int pos, int n = npos) const;
int find_last_of(const string &s,int pos = npos) const;
int find_last_not_of(char c, int pos = npos) const;
int find_last_not_of(const char *s, int pos = npos) const;
int find_last_not_of(const char *s, int pos, int n) const;
int find_last_not_of(const string &s,int pos = npos) const;
//find_last_of和find_last_not_of与find_first_of和find_first_not_of相似,只不过是从后向前查找

string类的替换函数

1
2
3
4
5
6
7
8
9
10
string &replace(int p0, int n0,const char *s);//删除从p0开始的n0个字符,然后在p0处插入串s
string &replace(int p0, int n0,const char *s, int n);//删除p0开始的n0个字符,然后在p0处插入字符串s的前n个字符
string &replace(int p0, int n0,const string &s);//删除从p0开始的n0个字符,然后在p0处插入串s
string &replace(int p0, int n0,const string &s, int pos, int n);//删除p0开始的n0个字符,然后在p0处插入串s中从pos开始的n个字符
string &replace(int p0, int n0,int n, char c);//删除p0开始的n0个字符,然后在p0处插入n个字符c
string &replace(iterator first0, iterator last0,const char *s);//把[first0,last0)之间的部分替换为字符串s
string &replace(iterator first0, iterator last0,const char *s, int n);//把[first0,last0)之间的部分替换为s的前n个字符
string &replace(iterator first0, iterator last0,const string &s);//把[first0,last0)之间的部分替换为串s
string &replace(iterator first0, iterator last0,int n, char c);//把[first0,last0)之间的部分替换为n个字符c
string &replace(iterator first0, iterator last0,const_iterator first, const_iterator last);//把[first0,last0)之间的部分替换成[first,last)之间的字符串

string类的插入函数

1
2
3
4
5
6
7
8
9
string &insert(int p0, const char *s);
string &insert(int p0, const char *s, int n);
string &insert(int p0,const string &s);
string &insert(int p0,const string &s, int pos, int n);
//前4个函数在p0位置插入字符串s中pos开始的前n个字符
string &insert(int p0, int n, char c);//此函数在p0处插入n个字符c
iterator insert(iterator it, char c);//在it处插入字符c,返回插入后迭代器的位置
void insert(iterator it, const_iterator first, const_iterator last);//在it处插入[first,last)之间的字符
void insert(iterator it, int n, char c);//在it处插入n个字符c

string类的删除函数

1
2
3
iterator erase(iterator first, iterator last);//删除[first,last)之间的所有字符,返回删除后迭代器的位置
iterator erase(iterator it);//删除it指向的字符,返回删除后迭代器的位置
string &erase(int pos = 0, int n = npos);//删除pos开始的n个字符,返回修改后的字符串

string类的迭代器处理

string类提供了向前和向后遍历的迭代器iterator,迭代器提供了访问各个字符的语法,类似于指针操作,迭代器不检查范围。用string::iteratorstring::const_iterator声明迭代器变量,const_iterator不允许改变迭代的内容。常用迭代器函数有:

1
2
3
4
5
6
7
8
const_iterator begin()const;
iterator begin(); //返回string的起始位置
const_iterator end()const;
iterator end(); //返回string的最后一个字符后面的位置
const_iterator rbegin()const;
iterator rbegin(); //返回string的最后一个字符的位置
const_iterator rend()const;
iterator rend(); //返回string第一个字符位置的前面

rbegin和rend用于从后向前的迭代访问,通过设置迭代器string::reverse_iterator,string::const_reverse_iterator实现

字符串流处理

通过定义ostringstream和istringstream变量实现,#include <sstream>头文件中。例如:

1
2
3
4
5
6
7
string input("hello,this is a test");
istringstream is(input);
string s1,s2,s3,s4;
is>>s1>>s2>>s3>>s4;//s1="hello,this",s2="is",s3="a",s4="test"
ostringstream os;
os<<s1<<s2<<s3<<s4;
cout<<os.str();

以上就是对C++ string类的一个简要介绍。

string特性描述

可用下列函数来获得string的一些特性:

1
2
3
4
5
6
int capacity()const;    //返回当前容量(即string中不必增加内存即可存放的元素个数)
int max_size()const; //返回string对象中可存放的最大字符串的长度
int size()const; //返回当前字符串的大小
int length()const; //返回当前字符串的长度
bool empty()const; //当前字符串是否为空
void resize(int len,char c); //把字符串当前大小置为len,多去少补,多出的字符c填充不足的部分

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <string>
using namespace std;
int main()
{
string str;
if (str.empty())
cout<<"str is NULL."<<endl;
else
cout<<"str is not NULL."<<endl;
str = str + "abcdefg";
cout<<"str is "<<str<<endl;
cout<<"str's size is "<<str.size()<<endl;
cout<<"str's capacity is "<<str.capacity()<<endl;
cout<<"str's max size is "<<str.max_size()<<endl;
cout<<"str's length is "<<str.length()<<endl;
str.resize(20,'c');
cout<<"str is "<<str<<endl;
str.resize(5);
cout<<"str is "<<str<<endl;
return 0;
}

string的查找

由于查找是使用最为频繁的功能之一,string提供了非常丰富的查找函数:(注:string::npos)

size_type find( const basic_string &str, size_type index ); //返回str在字符串中第一次出现的位置(从index开始查找),如果没找到则返回string::npos

size_type find( const char *str, size_type index ); // 同上

size_type find( const char *str, size_type index, size_type length ); //返回str在字符串中第一次出现的位置(从index开始查找,长度为length),如果没找到就返回string::npos

size_type find( char ch, size_type index ); // 返回字符ch在字符串中第一次出现的位置(从index开始查找),如果没找到就返回string::npos

注意:查找字符串a是否包含子串b,不是用 strA.find(strB) > 0 而是 strA.find(strB) != string:npos 这是为什么呢?(初学者比较容易犯的一个错误)本部分参考自web100与luhao1993

先看下面的代码

1
2
int idx = str.find("abc");
if (idx == string::npos);

上述代码中,idx的类型被定义为int,这是错误的,即使定义为unsigned int 也是错的,它必须定义为 string::size_type。npos 是这样定义的: static const size_type npos = -1; 因为 string::size_type (由字符串配置器 allocator 定义) 描述的是 size,故需为无符号整数型别。因为缺省配置器以型别 size_t 作为 size_type,于是 -1 被转换为无符号整数型别,npos 也就成了该型别的最大无符号值。不过实际数值还是取决于型别 size_type 的实际定义。不幸的是这些最大值都不相同。事实上,(unsigned long)-1 和 (unsigned short)-1 不同(前提是两者型别大小不同)。因此,比较式 idx == string::npos 中,如果 idx 的值为-1,由于 idx 和字符串string::npos 型别不同,比较结果可能得到 false。因此要想判断 find()等查找函数的结果是否为npos,最好的办法是直接比较。

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
#include<string>
using namespace std;
int main(){
int loc;
string s="study hard and make progress everyday! every day!!";
loc=s.rfind("make",10);
cout<<"the word make is at index"<<loc<<endl;//-1表示没找到
loc=s.rfind("make");//缺省状态下,从最后一个往前找
cout<<"the word make is at index"<<loc<<endl;
loc=s.find_first_of("day");
cout<<"the word day(first) is at index "<<loc<<endl;
loc=s.find_first_not_of("study");
cout<<"the first word not of study is at index"<<loc<<endl;
loc=s.find_last_of("day");
cout<<"the last word of day is at index"<<loc<<endl;
loc=s.find("day");//缺陷状态下从第一个往后找
cout<<loc;
return 0;
}

运行结果:

其他常用函数

  • string &insert(int p,const string &s); //在p位置插入字符串s
  • string &replace(int p, int n,const char *s); //删除从p开始的n个字符,然后在p处插入串s
  • string &erase(int p, int n); //删除p开始的n个字符,返回修改后的字符串
  • string substr(int pos = 0,int n = npos) const; //返回pos开始的n个字符组成的字符串
  • void swap(string &s2); //交换当前字符串与s2的值
  • string &append(const char *s); //把字符串s连接到当前字符串结尾
  • void push_back(char c) //当前字符串尾部加一个字符c
  • const char *data()const; //返回一个非null终止的c字符数组,data():与c_str()类似,用于string转const char*其中它返回的数组是不以空字符终止,
  • const char *c_str()const; //返回一个以null终止的c字符串,即c_str()函数返回一个指向正规C字符串的指针, 内容与本string串相同,用于string转const char*

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>
using namespace std;
int main()
{
string str1 = "abc123defg";
string str2 = "swap!";
cout<<str1<<endl;
cout<<str1.erase(3,3)<<endl; //从索引3开始的3个字符,即删除掉了"123"
cout<<str1.insert(0,"123")<<endl; //在头部插入
cout<<str1.append("123")<<endl; //append()方法可以添加字符串
str1.push_back('A'); //push_back()方法只能添加一个字符
cout<<str1<<endl;
cout<<str1.replace(0,3,"hello")<<endl; //即将索引0开始的3个字符替换成"hello"
cout<<str1.substr(5,7)<<endl; //从索引5开始7个字节
str1.swap(str2);
cout<<str1<<endl;
const char* p = str.c_str();
printf("%s\n",p);
return 0;
}

程序执行结果为:

1
2
3
4
5
6
7
8
9
abc123defg
abcdefg
123abcdefg
123abcdefg123
123abcdefg123A
helloabcdefg123A
abcdefg
swap!
swap!

this指针

this指针的用处

一个对象的this指针并不是对象本身的一部分,不会影响sizeof(对象)的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候,编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。 例如,调用date.SetMonth(9) <==> SetMonth(&date, 9),this帮助完成了这一转换。

this指针的使用

一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用return *this;另外一种情况是当参数与成员变量名相同时,如this->n = n (不能写成n = n)。

this指针程序示例

this指针存在于类的成员函数中,指向被调用函数所在的类实例的地址。根据以下程序来说明this指针

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point   
{   
int x, y;   
public:   
Point(int a, int b) { x=a; y=b;}   
void MovePoint( int a, int b){ x+=a; y+=b;}   
void print(){ cout<<"x="<<x<<"y="<<y<<endl;} <="" font="">  
};   
void main( )   {   
Point point1( 10,10);   
point1.MovePoint(2,2);   
point1.print();   
}

当对象point1调用MovePoint(2,2)函数时,即将point1对象的地址传递给了this指针。

MovePoint函数的原型应该是

1
void MovePoint( Point *this, int a, int b);

第一个参数是指向该类对象的一个指针,我们在定义成员函数时没看见是因为这个参数在类中是隐含的。这样point1的地址传递给了this,所以在MovePoint函数中便显式的写成:
1
void MovePoint(int a, int b) { this->x +=a; this-> y+= b;}

即可以知道,point1调用该函数后,也就是point1的数据成员被调用并更新了值。

即该函数过程可写成

1
point1.x+= a; point1. y + = b;

关于this指针的一个经典回答

当你进入一个房子后,你可以看见桌子、椅子、地板等,但是房子你是看不到全貌了。

对于一个类的实例来说,你可以看到它的成员函数、成员变量,但是实例本身呢?this是一个指针,它时时刻刻指向你这个实例本身

类的this指针有以下特点:

(1)this只能在成员函数中使用。全局函数、静态函数都不能使用this。实际上,成员函数默认第一个参数为T * const this。如:

1
2
3
4
5
6
7
class A
{
public:
int func(int p)
{
}
};

其中,func的原型在编译器看来应该是:
1
int func(A * const this,int p);

(2)由此可见,this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:

1
2
A a;
a.func(10);

此处,编译器将会编译成:
1
A::func(&a,10);

看起来和静态函数没差别,对吗?不过,区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高—如VC通常是通过ecx寄存器传递this参数的。

(3)几个this指针的易混问题。

A. this指针是什么时候创建的?

this在成员函数的开始执行前构造,在成员的执行结束后清除。

但是如果class或者struct里面没有方法的话,它们是没有构造函数的,只能当做C的struct使用。采用 TYPE xx的方式定义的话,在栈里分配内存,这时候this指针的值就是这块内存的地址。采用new的方式 创建对象的话,在堆里分配内存,new操作符通过eax返回分配 的地址,然后设置给指针变量。之后去调 用构造函数(如果有构造函数的话),这时将这个内存块的地址传给ecx。

B. this指针存放在何处?堆、栈、全局变量,还是其他?

this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。在汇编级 别里面,一个值只会以3种形式出现:立即数、寄存器值和内存变量值。不是存放在寄存器就是存放在内 存中,它们并不是和高级语言变量对应的。

C. this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针 又是如何找到“类实例后函数的”?

大多数编译器通过ecx寄存器传递this指针。事实上,这也是一个潜规则。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。

在call之前,编译器会把对应的对象地址放到eax中。this是通过函数参数的首参来传递的。this指针在调用之前生成,至于“类实例后函数”,没有这个说法。类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的。

D. this指针是如何访问类中的变量的?

如果不是类,而是结构体的话,那么,如何通过结构指针来访问结构中的变量呢?如果你明白这一点的话,就很容易理解这个问题了。

在C++中 ,类和结构是只有一个区别的:类的成员默认是private,而结构是public。

this是类的指针,如果换成结构,那this就是结构的指针了。

E. 我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗?

this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。

F. 每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数?

普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译器能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。

注意事项

  • this 指针是一个隐含于每一个非静态成员函数中的特殊指针。它指向调用该成员函数的那个对象。
  • 类内定义的静态方法不能指向实例本身,也就是没有this指针
  • 当对一个对象调用成员函数时,编译程序先将对象的地址赋给 this 指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用 this 指针。
  • 当一个成员函数被调用时,自动向它传递一个隐含的参数,该参数是一个指向这个成员函数所在的对象的指针。
  • this 指针被隐含地声明为: ClassName *const this,这意味着不能给 this 指针赋值;在 ClassName 类的 const 成员函数中,this 指针的类型为:const ClassName* const,这说明不能对 this 指针所指向的这种对象是不可修改的(即不能对这种对象的数据成员进行赋值操作);
  • this 并不是一个常规变量,而是个右值,所以不能取得 this 的地址(不能 &this)。
  • 在以下场景中,经常需要显式引用 this 指针:
    • 为实现对象的链式引用;
    • 为避免对同一对象进行赋值操作;
    • 在实现一些数据结构时,如 list

变长参数函数

首先回顾一下较多使用的变长参数函数,最经典的便是printf。

1
extern int printf(const char *format, ...);

以上是一个变长参数的函数声明。我们自己定义一个测试函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdarg.h>
#include <stdio.h>

int testparams(int count, ...)
{
va_list args;
va_start(args, count);
for (int i = 0; i < count; ++i)
{
int arg = va_arg(args, int);
printf("arg %d = %d", i, arg);
}
va_end(args);
return 0;
}

int main()
{
testparams(3, 10, 11, 12);
return 0;
}

变长参数函数的解析,使用到三个宏va_start,va_arg 和va_end,再看va_list的定义typedef char* va_list; 只是一个char指针。

这几个宏如何解析传入的参数呢?

函数的调用,是一个压栈,保存,跳转的过程。简单的流程描述如下:

  1. 把参数从右到左依次压入栈;
  2. 调用call指令,把下一条要执行的指令的地址作为返回地址入栈;(被调用函数执行完后会回到该地址继续执行)
  3. 当前的ebp(基址指针)入栈保存,然后把当前esp(栈顶指针)赋给ebp作为新函数栈帧的基址;
  4. 执行被调用函数,局部变量等入栈;
  5. 返回值放入eax,leave,ebp赋给esp,esp所存的地址赋给ebp;(这里可能需要拷贝临时返回对象)
  6. 从返回地址开始继续执行;(把返回地址所存的地址给eip)

由于开始的时候从右至左把参数压栈,va_start 传入最左侧的参数,往右的参数依次更早被压入栈,因此地址依次递增(栈顶地址最小)。va_arg传入当前需要获得的参数的类型,便可以利用 sizeof 计算偏移量,依次获取后面的参数值。

1
2
3
4
5
6
7
8
9
10
11
12
13
#define _INTSIZEOF(n)          ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))

#define _ADDRESSOF(v) (&const_cast<char&>(reinterpret_cast<const volatile char&>(v)))

#define __crt_va_start_a(ap, v) ((void)(ap = (va_list)_ADDRESSOF(v) + _INTSIZEOF(v)))
#define __crt_va_arg(ap, t) (*(t*)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
#define __crt_va_end(ap) ((void)(ap = (va_list)0))

#define __crt_va_start(ap, x) ((void)(__vcrt_va_start_verify_argument_type<decltype(x)>(), __crt_va_start_a(ap, x)))

#define va_start __crt_va_start
#define va_arg __crt_va_arg
#define va_end __crt_va_end

上述宏定义中,_INTSIZEOF(n)将地址的低2位指令,做内存的4字节对齐。每次取参数时,调用__crt_va_arg(ap,t),返回t类型参数地址的值,同时将ap偏移到t之后。最后,调用_crt_va_end(ap)将ap置0.

变长参数的函数的使用及其原理看了宏定义是很好理解的。从上文可知,要使用变长参数函数的参数,我们必须知道传入的每个参数的类型。printf中,有format字符串中的特殊字符组合来解析后面的参数类型。但是当传入类的构造函数的参数时,我们并不知道每个参数都是什么类型,虽然参数能够依次传入函数,但无法解析并获取每个参数的数值。因此传统的变长参数函数并不足以解决传入任意构造函数参数的问题。

变长参数模板

我们需要用到C++11的新特性,变长参数模板。

这里举一个使用自定义内存池的例子。定义一个内存池类MemPool.h,以count个类型T为单元分配内存,默认分配一个对象。每当内存内空闲内存不够,则一次申请MEMPOOL_NEW_SIZE个内存对象。内存池本身只负责内存分配,不做初始化工作,因此不需要传入任何参数,只需实例化模板分配相应类型的内存即可。

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
#ifndef UTIL_MEMPOOL_H
#define UTIL_MEMPOOL_H

#include <stdlib.h>

#define MEMPOOL_NEW_SIZE 8

template<typename T, size_t count = 1>
class MemPool
{
private:
union MemObj {
char _obj[1];
MemObj* _freelink;
};

public:
static void* Allocate()
{
if (!_freelist) {
refill();
}
MemObj* alloc_mem = _freelist;
_freelist = _freelist->_freelink;
++_size;
return (void*)alloc_mem;
}

static void DeAllocate(void* p)
{
MemObj* q = (MemObj*)p;
q->_freelink = _freelist;
_freelist = q;
--_size;
}

static size_t TotalSize() {
return _totalsize;
}

static size_t Size() {
return _size;
}
private:
static void refill()
{
size_t size = sizeof(T) * count;
char* new_mem = (char*)malloc(size * MEMPOOL_NEW_SIZE);
for (int i = 0; i < MEMPOOL_NEW_SIZE; ++i) {
MemObj* free_mem = (MemObj*)(new_mem + i * size);
free_mem->_freelink = _freelist;
_freelist = free_mem;
}
_totalsize += MEMPOOL_NEW_SIZE;
}

static MemObj* _freelist;
static size_t _totalsize;
static size_t _size;
};

template<typename T, size_t count>
typename MemPool<T, count>::MemObj* MemPool<T, count>::_freelist = NULL;

template<typename T, size_t count>
size_t MemPool<T, count>::_totalsize = 0;

template<typename T, size_t count>
size_t MemPool<T, count>::_size = 0;
#endif

接下来在没有变长参数的情况下,实现通用MemNew和MemDelete函数模板。这里不对函数模板作详细解释,用函数模板我们可以对不同的类型实现同样的内存池分配操作。如下:

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
template<class T>
T *MemNew(size_t count)
{
T *p = (T*)MemPool<T, count>::Allocate();
if (p != NULL)
{
if (!std::is_pod<T>::value)
{
for (size_t i = 0; i < count; ++i)
{
new (&p[i]) T();
}
}
}
return p;
}

template<class T>
T *MemDelete(T *p, size_t count)
{
if (p != NULL)
{
if (!std::is_pod<T>::value)
{
for (size_t i = 0; i < count; ++i)
{
p[i].~T();
}
}
MemPool<T, count>::DeAllocate(p);
}
}

上述实现中,使用placement new对申请的内存进行构造,使用了默认构造函数,当申请内存的类型不具备默认构造函数时,placement new将报错。对于pod类型,可以省去调用构造函数的过程。

引入C++11变长模板参数后MemNew修改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<class T, class... Args>
T *MemNew(size_t count, Args&&... args)
{
T *p = (T*)MemPool<T, count>::Allocate();
if (p != NULL)
{
if (!std::is_pod<T>::value)
{
for (size_t i = 0; i < count; ++i)
{
new (&p[i]) T(std::forward<Args>(args)...);
}
}
}
return p;
}

以上函数定义包含了多个特性,后面我将一一解释,其中class… Args 表示变长参数模板,函数参数中Args&& 为右值引用。std::forward 实现参数的完美转发。这样,无论传入的类型具有什么样的构造函数,都能够完美执行placement new。

C++11中引入了变长参数模板的概念,来解决参数个数不确定的模板。

1
2
3
4
5
6
7
8
9
10
template<class... T> class Test {};
Test<> test0;
Test<int> test1;
Test<int,int> test2;
Test<int,int,long> test3;

template<class... T> void test(T... args);
test();
test<int>(0);
test<int,int,long>(0,0,0L);

变长参数函数模板

T… args 为形参包,其中args是模式,形参包中可以有0到任意多个参数。调用函数时,可以传任意多个实参。对于函数定义来说,该如何使用参数包呢?在上文的MemNew中,我们使用std::forward依次将参数包传入构造函数,并不关注每个参数具体是什么。如果需要,我们可以用sizeof…(args)操作获取参数个数,也可以把参数包展开,对每个参数做更多的事。展开的方法有两种,递归函数,逗号表达式。

递归函数方式展开,模板推导的时候,一层层递归展开,最后到没有参数时用定义的一般函数终止。

1
2
3
4
5
6
7
8
9
10
11
12
void test()
{
}

template<class T, class... Args>
void test(T first, Args... args)
{
std::cout << typeid(T).name() << " " << first << std::endl;
test(args...);
}

test<int, int, long>(0, 0, 0L);

output:

1
2
3
int 0
int 0
long 0

逗号表达式方式展开,利用数组的参数初始化列表和逗号表达式,逐一执行print每个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
void print(T arg)
{
std::cout << typeid(T).name() << " " << arg << std::endl;
}

template<class... Args>
void test(Args... args)
{
int arr[] = { (print(args), 0)... };
}

test(0, 0, 0L);

output:
1
2
3
int 0
int 0
long 0

变长参数类模板

变长参数类模板,一般情况下可以方便我们做一些编译期计算。可以通过偏特化和递归推导的方式依次展开模板参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class T, class... Types>
class Test
{
public:
enum {
value = Test<T>::value + Test<Types...>::value,
};
};

template<class T>
class Test<T>
{
public:
enum {
value = sizeof(T),
};
};

Test<int, int, long> test;
std::cout << test.value;

output: 12

右值引用和完美转发

对于变长参数函数模板,需要将形参包展开逐个处理的需求不多,更多的还是像本文的MemNew这样的需求,最终整个传入某个现有的函数。我们把重点放在参数的传递上。

要理解右值引用,需要先说清楚左值和右值。左值是内存中有确定存储地址的对象的表达式的值;右值则是非左值的表达式的值。const左值不可被赋值,临时对象的右值可以被赋值。左值与右值的根本区别在于是否能用&运算符获得内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
int i =0;//i 左值
int *p = &i;// i 左值
int& foo();
foo() = 42;// foo() 左值
int* p1 = &foo();// foo() 左值

int foo1();
int j = 0;
j = foo1();// foo 右值
int k = j + 1;// j + 1 右值
int *p2 = &foo1(); // 错误,无法取右值的地址
j = 1;// 1 右值

理解左值和右值之后,再来看引用,对左值的引用就是左值引用,对右值(纯右值和临终值)的引用就是右值引用。

如下函数foo,传入int类型,返回int类型,这里传入函数的参数0和返回值0都是右值(不能用&取得地址)。于是,未做优化的情况下,传入参数0的时候,我们需要把右值0拷贝给param,函数返回的时候需要将0拷贝给临时对象,临时对象再拷贝给res。当然现在的编译器都做了返回值优化,返回对象是直接创建在返回后的左值上的,这里只用来举个例子

1
2
3
4
5
6
7
int foo(int param)
{
printf("%d", param);
return 0;
}

int res = foo(0);

显然,这里的拷贝都是多余的。可能我们会想要优化,首先将参数int改为int&,传入左值引用,于是0无法传入了,当然我们可以改成const int&,这样终于省去了传参的拷贝。

1
2
3
4
5
int foo(const int& param)
{
printf("%d", param);
return 0;
}

由于const int& 既可以是左值也可以是右值,传入0或者int变量都能够满足。(但是似乎既然有左值引用的int&类型,就应该有对应的传入右值引用的类型int&&)。另外,这里返回的右值0,似乎不通过拷贝就无法赋值给左值res。

于是有了移动语义,把临时对象的内容直接移动给被赋值的左值对象(std::move)。和右值引用,X&&是到数据类型X的右值引用。

1
2
3
4
5
6
7
8
9
int result = 0;
int&& foo(int&& param)
{
printf("%d", param);
return std::move(result);
}

int&& res = foo(0);
int *pres = &res;

将foo改为右值引用参数和返回值,返回右值引用,免去拷贝。这里res是具名引用,运算符右侧的右值引用作为左值,可以取地址。右值引用既有左值性质,也有右值性质。

上述例子还只存在于拷贝的性能问题。回到MemNew这样的函数模板。

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>
T* Test(T arg)
{
return new T(arg);
}

template<class T>
T* Test(T& arg)
{
return new T(arg);
}

template<class T>
T* Test(const T& arg)
{
return new T(arg);
}

template<class T>
T* Test(T&& arg)
{
return new T(std::forward<T>(arg));
}

上述的前三种方式传参,第一种首先有拷贝消耗,其次有的参数就是需要修改的左值。第二种方式则无法传常数等右值。第三种方式虽然左值右值都能传,却无法对传入的参数进行修改。第四种方式使用右值引用,可以解决参数完美转发的问题。

std::forward能够根据实参的数据类型,返回相应类型的左值和右值引用,将参数完整不动的传递下去。
解释这个原理涉及到引用塌缩规则

1
2
3
4
T& & ->T&
T& &&->T&
T&& &->T&
T&& &&->T&&

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template< class T > struct remove_reference      {typedef T type;};
template< class T > struct remove_reference<T&> {typedef T type;};
template< class T > struct remove_reference<T&&> {typedef T type;};

template< class T > T&& forward( typename std::remove_reference<T>::type& t )
{
return static_cast<T&&>(t);
}

template<class T>
typename std::remove_reference<T>::type&& move(T&& a) noexcept
{
return static_cast<typename std::remove_reference<T>::type&&>(a);
}

对于函数模板

1
2
3
4
5
template<class T>
T* Test(T&& arg)
{
return new T(std::forward<T>(arg));
}

当传入实参为X类型左值时,T为X&,最后的类型为X&。当实参为X类型右值时,T为X,最后的类型为X&&。

x为左值时:

1
2
X x;
Test(x);

T为X&,实例化后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
X& && std::forward(remove_reference<X&>::type& a) noexcept
{
return static_cast<X& &&>(a);
}

X* Test(X& && arg)
{
return new X(std::forward<X&>(arg));
}

// 塌陷后

X& std::forward(X& a)
{
return static_cast<X&>(a);
}

X* Test(X& arg)
{
return new X(std::forward<X&>(arg));
}

x为右值时:

1
2
X foo();
Test(foo());

T为X,实例化后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
X&& std::forward(remove_reference<X>::type& a) noexcept
{
return static_cast<X&&>(a);
}

X* Test(X&& arg)
{
return new X(std::forward<X>(arg));
}

// 塌陷后

X&& std::forward(X& a)
{
return static_cast<X&&>(a);
}

X* Test(X&& arg)
{
return new X(std::forward<X>(arg));
}

可以看到最终实参总是被推导为和传入时相同的类型引用。

至此,我们讨论了变长参数模板,讨论了右值引用和函数模板的完美转发,完整的解释了MemNew对任意多个参数的构造函数的参数传递过程。利用变长参数函数模板,右值引用和std::forward,可以完成参数的完美转发。

str相关函数

C语言str系列库函数在不同的库中有不同的实现方法,但原理都是一样的。因为库函数都是没有进行入口参数检查的,并且str系列库函数在面试中经常容易被面试官喊在纸上写某一个函数的实现,因此本文参考了OpenBSD和vc++ 8.0库中的代码,结合自己的编程习惯,部分整理如下:

1、strcpy

1
2
3
4
5
6
7
8
9
10
11
12
13
char * strcpy(char *dst, const char *src)  
{
char *d;

if (dst == NULL || src == NULL)
return dst;

d = dst;
while (*d++ = *src++) // while ((*d++ = *src++) != '\0')
;

return dst;
}

2、strncpy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//copy at most n characters of src to dst  
//Pad with '\0' if src fewer than n characters
char *strncpy(char *dst, const char*src, size_t n)
{
char *d;

if (dst == NULL || src == NULL)
return dst;

d = dst;
while (n != 0 && (*d++ = *src++)) /* copy string */
n--;
if (n != 0)
while (--n != 0)
*d++ == '\0'; /* pad out with zeroes */

return dst;
}

注意n是unsigned int,在进行n—操作时特别要小心。如果不小心写成下面这样就会出错:
1
2
3
4
while (n-- != 0 && (*d++ = *src++))  
;
while (n-- != 0)
*d++ = '\0';

第一个while循环中,当n变为0时,仍然会执行n—一,此时n等于经由-1变成的大正数,导致后面对n的使用出错。

3、strcat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char *strcat(char *dst, const char *src)  
{
char *d;
if (dst == NULL || src == NULL)
return dst;

d = dst;
while (*d)
d++;
//while (*d++ != 0);
//d--;

while (*d++ = *src++)
;

return dst;
}

4、strncat
写法1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//concatenate at most n characters of src to the end of dst  
//terminates dst with '\0'
char *strncat(char *dst, const char *src, size_t n)
{
if (NULL == dst || NULL == src)
return dst;

if (n != 0)
{
char *d = dst;
do
{
if ((*d = *src++) == '\0' )
return dst; //break
d++;
} while (--n != 0);
*d = '\0';
}

return dst;
}

写法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
char *strncat(char *dst, const char *src, size_t n)  
{
char *d;

if (dst == NULL || src == NULL)
return dst;

d = dst;
while (*d)
d++;
//(1)
while (n != 0)
{
if ((*d++ = *src++) == '\0')
return dst;
n--;
}

//(2)
//while (n--) //这种方式写最后n的值不为0,不过这个n后面不会再被使用
// if ((*d++ == *src++) == '\0')
// return dst;

*d = '\0';

return dst;
}

5、strcmp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int strcmp(const char *s1, const char *s2)  
{
if (s1 == NULL || s2 == NULL)
return 0;
//(1)
//while (*s1 == *s2++)
// if (*s1++ == '\0')
// return 0;

//(2)
for (; *s1 == *s2; s1++, s2++)
if (*s1 == '\0')
return 0;
return *(unsigned char*)s1 - *(unsigned char*)s2;
}

6、strncmp
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
int strncmp(const char *s1, const char *s2, size_t n)  
{
if (s1 == NULL || s2 == NULL)
return 0;

if (n == 0)
return 0;
do
{
if (*s1 != *s2++)
return *(unsigned char*)s1 - *(unsigned char*)--s2;
if (*s1++ == '\0')
break;
} while (--n != 0);

//do
//{
// if (*s1 != *s2)
// return *(unsigned char*)s1 - *(unsigned char*)s2;
// if (*s1 == '\0')
// break;
// s1++;
// s2++;
//} while (--n != 0);

return 0;
}

7、strstr
写法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
//return pointer to first  occurrence of find in s  
//or NULL if not present
char *strstr(const char *s, const char *find)
{
char *cp = (char*)s;
char *s1, *s2;

if (s == NULL || find == NULL)
return NULL;

while (*cp != '\0')
{
s1 = cp;
s2 = (char*)find;

while (*s1 && *s2 && *s1 == *s2)
s1++, s2++;

if(*s2 == '\0')
return cp;

cp++;
}
return NULL;
}

写法2:参照简单模式匹配算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
char *strstr(const char *s, const char *find)  
{
int i = 0, j = 0;
while (*(s + i) != '\0' && *(find + j) != '\0')
{
if (*(s + i + j) == *(find + j))
j++; //继续比较后一字符
else
{
i++; //开始新一轮比较
j = 0;
}
}

return *(find + j) == '\0' ? (char*)(s + i) : NULL;
}

8、strchr

1
2
3
4
5
6
7
8
9
10
11
//return pointer to first occurrence of ch in str  
//NULL if not present
char *strchr(const char*str, int ch)
{
while (*str != '\0' && *str != (char)ch)
str++;

if(*str == (char)ch)
return (char*)str;
return NULL;
}

9、strrchr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//return pointer to last occurrence of ch in str  
//NULL if not present
char *strrchr(const char *str, int ch)
{
if (str == NULL)
return NULL;

char *s = (char*)str;

while (*s++)
; /* find end of string */

while (--s != str && *s != (char)ch)
; /* search towards front */

if(*s == (char)ch)
return (char*)s;
return NULL;
}

10、strlen

1
2
3
4
5
6
7
8
9
10
size_t strlen(const char *str)  
{
if (str == NULL)
return 0;

const char *eos = str;
while (*eos++)
;
return (eos - 1 - str);
}

RVO

函数如何返回值

函数返回值的传递分为两种情况。

当返回的对象的大小不超过8字节时,通过寄存器(eax edx)返回。

当返回的对象的大小大于8字节时,通过栈返回。但是,如果返回struct/class对象,尽管其大小不大于8字节,也是通过栈返回的。

在通过栈返回的时候,栈上会有一块空间来保存函数的返回值。当函数结束的时候,会把要返回的对象拷贝到这块区域,对于内置类型是直接拷贝,类类型的话是调用copy ctor。这块区域又称为函数返回的临时对象(temporary object)。

下面用代码看一下是不是这样。

首先,编写Base类和func()函数。

1
2
3
4
5
6
7
8
9
10
11
12
struct Base{
Base() { cout << "default ctor" << endl; };
Base(const Base& b) { cout << "copy ctor " << endl; }
Base& operator=(const Base& b){ cout << "operator=" << endl; a = b.a; return *this;}
~Base(){cout << "dtor " << endl;};
int a = 0;
};

Base func(){
Base a;
return a;
}

调用函数:(为了确保临时对象的存在,我绑定一个const引用到它上面;其实不绑定的话,直接func();也会有临时对象的存在)
1
const Base &r = func();

输出
1
2
default ctor
dtor

按理说,存在临时对象,输出应该是
1
2
3
4
default ctor
copy ctor
dtor
dtor

因为这里C++做了返回值优化(RVO)。RVO是一种编译器优化的技术,它把要返回的局部变量直接构造在临时对象所在的区域,达到少调用一次copy ctor的目的。

为了避免RVO,把func()重新编写。这样编译器不清楚哪个局部变量会被返回,所以就避免了返回值优化。

1
2
3
4
5
6
7
8
9
10
Base func(int i){
if(i > 0) {
Base a;
return a;
}
else{
Base b;
return b;
}
}

调用func:
1
func(0);

输出
1
2
3
4
default ctor // 函数内的局部对象
copy ctor //局部对象->临时对象
dtor // 局部对象析构
dtor // 临时对象析构

结果符合预期。

如果这样调用:

1
Base a = func(0);

输出:
1
2
3
4
default ctor // 函数内的局部对象
copy ctor // ?
dtor // 局部对象析构
dtor // ?

为何是这样?不应该是还有一次临时对象到a的copy ctor和a的dtor吗?
这里我猜测进行了另外的优化,将两者合并到了一起,也就是把a的存储区域作为临时对象的区域。

下面这样调用:

1
2
3
Base a = func(0);
cout << endl;
a = func(0);

输出是:
1
2
3
4
5
6
7
8
9
10
default ctor // func的局部对象
copy ctor // func的局部对象->临时对象
dtor // func的局部对象析构

default ctor // func的局部对象
copy ctor // func的局部对象->临时对象(也就是a)
dtor // func的局部对象析构
operator= // 临时对象->a
dtor // 临时对象析构
dtor // a析构

输出十分合理!

RVO,是Return Value Optimization。这是在函数返回返回值的时候编译器所做出的优化,是C++11标准的一部分,C++11称之为copy elision。

在第一次编写的func里面,编译器明确知道函数会返回哪一个局部对象,那么编译器会把存储这个局部对象的地址和存储返回值临时对象的地址进行复用,也就是说避免了从局部对象到临时对象的拷贝操作。这就是RVO。

现在把func重新改为:

1
2
3
4
Base func(){
Base b;
return b;
}

以下面三种方式调用func。
1
2
3
4
5
func();
cout << endl;
Base a = func();
cout << endl;
a = func();

输出
1
2
3
4
5
6
7
8
9
default ctor // 局部对象b(也是临时对象)的构造
dtor

default ctor // 局部对象b(也是临时对象,也是要初始化的对象a)的构造

default ctor // 局部对象b(也是临时对象)的构造
operator= // 局部对象b(也是临时对象)-> 对象a
dtor // 局部对象b
dtor // 对象a

输出十分合理!

std::move()

在查阅RVO的资料的时候,看到了这篇博客RVO V.S. std::move,讲的特别好。除了RVO里面还提到了std:move(),为了加深对std::move的理解,我又做了下面几个实验。

重新编写func:

1
2
3
4
Base func(){
Base b;
return std::move(b);
}

然后向Base添加下面的成员:
1
2
Base& operator=(Base&& b){  cout << "move operator=" << endl; a = b.a; return *this;}
Base(Base&& b) { cout << "move ctor" << endl;}

调用:
1
2
3
4
5
func();
cout << endl;
Base a = func();
cout << endl;
a = func();

输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
default ctor // 局部对象b
move ctor // 局部对象b向临时对象的移动
dtor
dtor

default ctor // 局部对象b
move ctor // 局部对象b向临时对象(也是要初始化的对象a)的移动
dtor

default ctor // 局部对象b
move ctor // 局部对象b向临时对象的移动
dtor // 局部对象b析构
move operator= // 临时对象到a的移动,临时对象是右值,所以用move
dtor // 临时对象析构
dtor

func的函数返回类型仍然是Base,而不是Base&&。这意味着函数还是会创建一个Base类的临时对象,只是临时对象是通过右值引用得到的,也就是说通过移动构造函数移动得到的。

把func的返回类型改为Base&&:

1
2
3
4
Base&& func(){
Base b;
return std::move(b);
}

还是调用,
1
2
3
4
5
func();
cout << endl;
Base a = func();
cout << endl;
a = func();

输出:
1
2
3
4
5
6
7
8
9
10
11
default ctor // 局部对象
dtor

default ctor // 局部对象
dtor // 局部对象
move ctor // 局部对象到a的移动(注意:因为这里局部对象已经析构,所以这里的行为是undefined,十分危险)

default ctor // 局部对象
dtor // 局部对象
move operator= // 局部对象到a的移动
dtor

总结:

  • 函数的返回类型是类类型,return局部对象,可能会有RVO;
  • 函数的返回类型是类类型,return右值引用,肯定不会有RVO;
  • 函数的返回类型是右值引用,return右值引用,没有临时对象的消耗,但是仍不可取,因为右值引用的对对象在使用前已经析构了。

cpp的tr1_function使用

介绍

function是一种通用、多态的函数封装。std::function的实例可以对任何可以调用的目标 进行存储、复制、和调用操作,这些目标包括函数、lambda表达式、绑定表达式、以及其它函数对象等。(c++11起的版本可用)

function(和bind一样)可以实现类似函数指针的功能,却比函数指针更加灵活(体现在占位符上面),尤其是在很多成员调用同一个函数(仅仅是参数类型不同)的时候比较方便。

  1. 可以作为函数和成员函数。
  2. 可做回调函数,取代函数指针。
  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
#include <iostream>
#include <tr1/functional>

typedef std::tr1::function<void()> HandleEvent;

class Animal{
public:
Animal(){}
~Animal(){}

static void Move(){
std::cout<<"I am moving...\n";
}
};

class Fish: public Animal{
public:
Fish(){}
~Fish(){}

static void Move(){
std::cout<<"I am swimming...\n";
}
};

int main(){

std::tr1::function<void()> move = &Animal::Move;
move();

move = &Fish::Move;
move();
return 0;
}

Animal类是父类,Fish继承于Animal。测试程序中分别将子类和父类的Move()函数地址赋值给function的指针。调用的结果如下:
  

1
2
I am moving… 
I am swimming…

为了体现function可以作为函数的参数传入,我们再写一个函数加到原来的代码中进行测试:
  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void Moving(int option, std::tr1::function<void()> move){
if(option & 1 == 0){ //如果option为偶数,则执行Animal类中的Move方法
move = &Animal::Move;
}
else{
move = &Fish::Move;
}

move();
}
int main(){

std::tr1::function<void()> move = &Animal::Move;
move();

move = &Fish::Move;
move();

std::cout<<"-------------divid line------------\n";
Moving(4,move);

return 0;
}

测试结果如下:
  

1
2
3
4
I am moving… 
I am swimming…
————-divid line————
I am moving…

C++函数调用分析

这里以一个简单的C语言代码为例,来分析函数调用过程

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int func(int param1 ,int param2,int param3)
{
int var1 = param1;
int var2 = param2;
int var3 = param3;

printf("var1=%d,var2=%d,var3=%d",var1,var2,var3);
return var1;
}

int main(int argc, char* argv[])
{
int result = func(1,2,3);

return 0;
}

首先说明,在堆栈中变量分布是从高地址到低地址分布,EBP是指向栈底的指针,在过程调用中不变,又称为帧指针。ESP指向栈顶,程序执行时移动,ESP减小分配空间,ESP增大释放空间,ESP又称为栈指针。

下面来逐步分析函数的调用过程

函数main执行,main各个参数从右向左逐步压入栈中,最后压入返回地址

执行第15行,3个参数以从左向右的顺序压入堆栈,及从param3到param1,栈内分布如下图:

然后是返回地址入栈:此时的栈内分布如下:

第3行函数调用时,通过跳转指令进入函数后,函数地址入栈后,EBP入栈,然后把当前ESP的值给EBP,对应的汇编指令:

1
2
push ebp
mov ebp esp

此时栈顶和栈底指向同一位置,栈内分布如下:

第5行开始执行, int var1 = param1; int var2 = param2; int var3 = param3;按申明顺序依次存储。对应的汇编:

1
2
mov 0x8(%ebp),%eax
mov %eax,-0x4(%ebp)

其中将[EBP+0x8]地址里的内容赋给EAX,即把param的值赋给EAX,然后把EAX的中的值放到[EBP-4]这个地址里,即把EAX值赋给var1,完成C代码 int var1 = param1,其他变量雷同。

第9行,输出结果,第10行执行 对应的汇编代码:

1
mov  -0x4(%ebp),%eax

最后通过eax寄存器保存函数的返回值;

调用执行函数完毕,局部变量var3,var2,var1一次出栈,EBP恢复原值,返回地址出栈,找到原执行地址,param1,param2,param3依次出栈,函数调用执行完毕。图略

深入理解C++的动态绑定和静态绑定

为了支持c++的多态性,才用了动态绑定和静态绑定。理解他们的区别有助于更好的理解多态性,以及在编程的过程中避免犯错误。需要理解四个名词:

  • 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。
  • 对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。

关于对象的静态类型和动态类型,看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class B
{
}
class C : public B
{
}
class D : public B
{
}
D* pD = new D();//pD的静态类型是它声明的类型D*,动态类型也是D*
B* pB = pD;//pB的静态类型是它声明的类型B*,动态类型是pB所指向的对象pD的类型D*
C* pC = new C();
pB = pC;//pB的动态类型是可以更改的,现在它的动态类型是C*

  • 静态绑定:绑定的是对象的静态类型,某特性(比如函数)依赖于对象的静态类型,发生在编译期。
  • 动态绑定:绑定的是对象的动态类型,某特性(比如函数)依赖于对象的动态类型,发生在运行期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class B
{
void DoSomething();
virtual void vfun();
}
class C : public B
{
void DoSomething();//首先说明一下,这个子类重新定义了父类的no-virtual函数,这是一个不好的设计,会导致名称遮掩;这里只是为了说明动态绑定和静态绑定才这样使用。
virtual void vfun();
}
class D : public B
{
void DoSomething();
virtual void vfun();
}
D* pD = new D();
B* pB = pD;

让我们看一下,pD->DoSomething()pB->DoSomething()调用的是同一个函数吗?

不是的,虽然pD和pB都指向同一个对象。因为函数DoSomething是一个no-virtual函数,它是静态绑定的,也就是编译器会在编译期根据对象的静态类型来选择函数。pD的静态类型是D,那么编译器在处理pD->DoSomething()的时候会将它指向D::DoSomething()。同理,pB的静态类型是B,那pB->DoSomething()调用的就是B::DoSomething()

让我们再来看一下,pD->vfun()pB->vfun()调用的是同一个函数吗?

是的。因为vfun是一个虚函数,它动态绑定的,也就是说它绑定的是对象的动态类型,pB和pD虽然静态类型不同,但是他们同时指向一个对象,他们的动态类型是相同的,都是D*,所以,他们的调用的是同一个函数:D::vfun()

上面都是针对对象指针的情况,对于引用(reference)的情况同样适用。

指针和引用的动态类型和静态类型可能会不一致,但是对象的动态类型和静态类型是一致的。

1
2
D D;
D.DoSomething()和D.vfun()永远调用的都是D::DoSomething()和D::vfun()。

我总结了一句话:只有虚函数才使用的是动态绑定,其他的全部是静态绑定。目前我还没有发现不适用这句话的,如果有错误,希望你可以指出来。

特别需要注意的地方

当缺省参数和虚函数一起出现的时候情况有点复杂,极易出错。我们知道,虚函数是动态绑定的,但是为了执行效率,缺省参数是静态绑定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
class B
{
virtual void vfun(int i = 10);
}
class D : public B
{
virtual void vfun(int i = 20);
}
D* pD = new D();
B* pB = pD;
pD->vfun();
pB->vfun();

有上面的分析可知pD->vfun()pB->vfun()调用都是函数D::vfun(),但是他们的缺省参数是多少?

分析一下,缺省参数是静态绑定的,pD->vfun()时,pD的静态类型是D*,所以它的缺省参数应该是20;同理,pB->vfun()的缺省参数应该是10。编写代码验证了一下,正确。

对于这个特性,估计没有人会喜欢。所以,永远记住:

绝不重新定义继承而来的缺省参数(Never redefine function’s inherited default parameters value.)

mem函数的类型及用法

memccpy函数原型:

1
void   *memccpy(void   *dest,   const   void   *src,   int   c,   size_t   n) 

函数功能:字符串拷贝,到指定长度或遇到指定字符时停止拷贝

参数说明: src-源字符串指针,c-中止拷贝检查字符,n-长度,dest-拷贝底目的字符串指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include   <string.h>; 
#include <stdio.h>;
int main()
{
char *src= "This is the source string ";
char dest[50];
char *ptr;
ptr=memccpy(dest,src, 'c ',strlen(src));
if (ptr)
{
*ptr= '\0 ';
printf( "The character was found:%s ",dest);
}
else
printf( "The character wasn 't found ");
return 0;
}

memchr函数原型:

1
void   *memchr(const   void   *s,   int   c,   size_t   n) 

在字符串中第开始n个字符中寻找某个字符c的位置

函数返回: 返回c的位置指针,返回NULL时表示未找到

参数说明: s-要搜索的字符串,c-要寻找的字符,n-指定长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include   <string.h>; 
#include <stdio.h>;
int main()
{
char str[17];
char *ptr;
strcpy(str, "This is a string ");
ptr=memchr(str, 'r ',strlen(str));
if(ptr)
printf( "The character 'r ' is at position:%d ",ptr-str);
else
printf( "The character was not found ");
return 0;
}

memcmp函数原型:

1
int   memcmp(const   void   *s1,   const   void   *s2,   size_t   n) 

函数功能: 按字典顺序对字符串s1,s2比较,并只比较前n个字符

函数返回: 返回数值表示比较结果

参数说明: s1,s2-要比较的字符串,n-比较的长度

1
2
3
4
5
6
7
8
9
10
11
#include   <stdio.h>
#include <string.h>
int main()
{
auto char buffer[80];
strcpy(buffer, "world ");
if( memcmp(buffer, "would ",6)<0){
printf( "Less than\n ");
}
return 0;
}

memicmp函数原型:

1
int   memicmp(const   void   *s1,   const   void   *s2,   size_t   n) 

函数功能: 按字典顺序、不考虑字母大小写对字符串s1,s2比较,并只比较前n个字符

函数返回: 返回数值表示比较结果

参数说明: s1,s2-要比较的字符串,n-比较的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include   <stdio.h>
#include <string.h>
int main()
{
char *buf1 = "ABCDE123 ";
char *buf2 = "abcde456 ";
int stat;
stat = memicmp(buf1, buf2, 5);
printf( "The strings to position 5 are ");
if(stat)
printf( "not ");
printf( "the same ");
return 0;
}

memcpy函数原型:

1
void   *memcpy(void   *dest,   const   void   *src,   size_t   n) 

函数功能: 字符串拷贝

函数返回: 指向dest的指针

参数说明: src-源字符串,n-拷贝的最大长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include   <stdio.h>
#include <string.h>
int main()
{
char src[] = "****************************** ";
char dest[] = "abcdefghijlkmnopqrstuvwxyz0123456709 ";
char *ptr;
printf( "destination before memcpy: %s ",dest);
ptr=memcpy(dest,src,strlen(src));
if(ptr)
printf( "destination after memcpy:%s ",dest);
else
printf( "memcpy failed ");
return 0;
}

memmove函数原型:

1
void   *memmove(void   *dest,   const   void   *src,   size_t   n)

函数功能: 字符串拷贝

函数返回: 指向dest的指针

参数说明: src-源字符串,n-拷贝的最大长度

1
2
3
4
5
6
7
8
9
10
#include   <string.h>
#include <stdio.h>
int main()
{
char dest[40]= "abcdefghijklmnopqrstuvwxyz0123456789 ";
printf( "destination prior to memmove:%s\n ",dest);
memmove(dest+1,dest,35);
printf( "destination after memmove:%s ",dest);
return 0;
}

memset函数原型:

1
void   *memset(void   *s,   int   c,   size_t   n) 

函数功能: 字符串中的n个字节内容设置为c

参数说明: s-要设置的字符串,c-设置的内容,n-长度

1
2
3
4
5
6
7
8
9
10
11
#include   <string.h>
#include <stdio.h>
#include <mem.h>
int main()
{
char buffer[] = "Hello world ";
printf( "Buffer before memset:%s ",buffer);
memset(buffer, '* ',strlen(buffer)-1);
printf( "Buffer after memset:%s ",buffer);
return 0;
}

函数的重载、隐藏和覆盖

函数重载只会发生在同一个类中,函数名相同,只能通过参数类型,参数个数或者有无const来区分。不能通过返回值类型区分,而且virtual也是可有可无的,即虚函数和普通函数在同一类中也可以构成函数重载。

基类和派生类中只能是隐藏或者覆盖。

  1. 隐藏是指派生类中有函数与基类中函数同名,但是没有构成虚函数覆盖,就是隐藏。隐藏的表现:若基类中函数func()被派生类中函数func()隐藏,那么无法通过派生类对象访问基类中的func() 函数,派生类对象只能访问到派生类中的func()函数。不过基类中的func()确实继承到了派生类中。
  2. 虚函数也只是在基类和派生类中发挥多态的作用,而在同一类中虚函数也可以重载。

虚函数实现多态的条件:

  • 基类中将这些成员声明为virtual。
  • 基类和派生类中的这些函数必须同名且参数类型,参数个数,返回值类型必须相同。
  • 将派生类的对象赋给基类指针或者引用,实现多态。

缺少任何一条,只会是基类和派生类之间的隐藏,而不是覆盖

如何判断基类和派生类中函数是否是隐藏?当基类和派生类存在同名函数,不论参数类型,参数个数是否相同,派生类中的同名函数都会将基类中的同名函数隐藏掉。

  • 基类和派生类都是虚函数,并且同名,但是形参类型或者形参个数不同,多态不满足,但是构成了隐藏,只是没有虚特性。
  • 基类中不是虚函数,派生类中定义为虚函数,不构成多态,只是隐藏关系。
  • 基类和派生类的两个函数同名,都是虚函数,形参的个数和类型也都相同,但是返回值类型不同,这时编译会报错,因为两个虚函数在隐藏时,返回值类型发生了冲突,因此隐藏发生错误。注意,如果这两个函数不是虚函数,这不会报错,隐藏会成功;同时,如果派生类中是虚函数,基类中不是虚函数,也不过报错,隐藏也是成功的。但是如果基类中为虚函数,派生类中不是,也会报错。这些说明,虚化并隐藏时,返回值类型一定要保持相同。

虚函数要求返回值类型也一样,但是有一种情况允许虚函数返回值时本类对象的引用或者指针,也可以构成覆盖。这个是“协变”规则,具体协变看例子:

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 A:
{
public:
virtual A* func()
{
cout<<"A"<<endl;
return this;
}
};
class B:public A
{
public:
virtual B* func()
{
cout<<"B"<<endl;
return this;
}
};
int main()
{
A *pa=new B;
B* pb=pa->func();//编译无法通过,因为pa是A*类型指针,编译时,对于pa->func()翻译成调用的是A类的函数,返回值为 A*类型。而A*类型无法赋值给派生类指针
B* pb=(B*)pa->func();//正确
B* pb=(B*)(pa->func());//正确
}

A *pa=new B;对于虚函数将基类指针指向派生类对象,调用派生类的虚函数。该基类指针能解引用的内存空间是继承到派生类中的基类的内存空间。基类指针调用派生类的虚函数,在虚函数中,this指针指向的是派生类本身,也就是在虚函数中将基类指针强制转换成了派生类指针。其实基类指针pa和派生类中的this指针值相同,都是派生类对象的地址。

协变的存在是为了解决返回值的强制类型转换,真正用途是,通过派生类对象调用虚函数,直接返回派生类指针。若无协变,则会返回基类指针,需要再将基类指针强制转换成派生类指针。具体的意思看例子:

若没有协变,那么上述的代码中派生类中虚函数需要改成以下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class B :public A
{
public:
virtual A* func()
{
cout<<"B"<<endl;
return this;//返回值this为B*类型指针,但是因为没有协变,返回的时候将B*类型赋值给了A*类型,然后以A*类型返回到main函数中
}
};
int main()
{
B b;
A *pa=b.func();
B *pb=dynamic<B*> (pa);//将返回的A*类型强制转换成B*类型
}

编译器总是根据类型来调用类成员函数。但是一个派生类的指针可以安全地转化为一个基类的指针。这样删除一个基类的指针的时候,C++不管这个指针指向一个基类对象还是一个派生类的对象,调用的都是基类的析构函数而不是派生类的。如果你依赖于派生类的析构函数的代码来释放资源,而没有重载析构函数,那么会有资源泄漏。所以建议的方式是将析构函数声明为虚函数

也就是delete a的时候,也会执行派生类的析构函数。

一个函数一旦声明为虚函数,那么不管你是否加上virtual修饰符,它在所有派生类中都成为虚函数。但是由于理解明确起见,建议的方式还是加上virtual修饰符。

构造方法用来初始化类的对象,与父类的其它成员不同,它不能被子类继承(子类可以继承父类所有的成员变量和成员方法,但不继承父类的构造方法)。因此,在创建子类对象时,为了初始化从父类继承来的数据成员,系统需要调用其父类的构造方法。

如果没有显式的构造函数,编译器会给一个默认的构造函数,并且该默认的构造函数仅仅在没有显式地声明构造函数情况下创建。

构造原则如下:

  1. 如果子类没有定义构造方法,则调用父类的无参数的构造方法。
  2. 如果子类定义了构造方法,不论是无参数还是带参数,在创建子类的对象的时候,首先执行父类无参数的构造方法,然后执行自己的构造方法。
  3. 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数,则会调用父类的默认无参构造函数。
  4. 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数且父类自己提供了无参构造函数,则会调用父类自己的无参构造函数。
  5. 在创建子类对象时候,如果子类的构造函数没有显示调用父类的构造函数且父类只定义了自己的有参构造函数,则会出错(如果父类只有有参数的构造方法,则子类必须显示调用此带参构造方法)。
  6. 如果子类调用父类带参数的构造方法,需要用初始化父类成员对象的方式,比如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream.h>
class animal
{
public:
animal(int height, int weight)
{
cout<<"animal construct"<<endl;
}
};
class fish:public animal
{
public:
int a;
fish() : animal(400,300), a(1)
{
cout<<"fish construct"<<endl;
}
};
void main()
{
fish fh;
}

强制类型转换运算符

将类型名作为强制类型转换运算符的做法是C语言的老式做法,C++ 为保持兼容而予以保留。

C++ 引入了四种功能不同的强制类型转换运算符以进行强制类型转换:static_castreinterpret_castconst_castdynamic_cast

强制类型转换是有一定风险的,有的转换并不一定安全,如把整型数值转换成指针,把基类指针转换成派生类指针,把一种函数指针转换成另一种函数指针,把常量指针转换成非常量指针等。C++ 引入新的强制类型转换机制,主要是为了克服C语言强制类型转换的以下三个缺点。

  • 没有从形式上体现转换功能和风险的不同。例如,将 int 强制转换成 double 是没有风险的,而将常量指针转换成非常量指针,将基类指针转换成派生类指针都是高风险的,而且后两者带来的风险不同(即可能引发不同种类的错误),C语言的强制类型转换形式对这些不同并不加以区分。
  • 将多态基类指针转换成派生类指针时不检查安全性,即无法判断转换后的指针是否确实指向一个派生类对象。
  • 难以在程序中寻找到底什么地方进行了强制类型转换。

强制类型转换是引发程序运行时错误的一个原因,因此在程序出错时,可能就会想到是不是有哪些强制类型转换出了问题。

如果采用C语言的老式做法,要在程序中找出所有进行了强制类型转换的地方,显然是很麻烦的,因为这些转换没有统一的格式。

而用 C++ 的方式,则只需要查找_cast字符串就可以了。甚至可以根据错误的类型,有针对性地专门查找某一种强制类型转换。例如,怀疑一个错误可能是由于使用了 reinterpret_cast 导致的,就可以只查找reinterpret_cast字符串。

C++ 强制类型转换运算符的用法如下:

1
强制类型转换运算符 <要转换到的类型> (待转换的表达式)

例如:
1
double d = static_cast <double> (3*5);  //将 3*5 的值转换成实数

下面分别介绍四种强制类型转换运算符。

static_cast

static_cast用于进行比较“自然”和低风险的转换,如整型和浮点型、字符型之间的互相转换。另外,如果对象所属的类重载了强制类型转换运算符 T(如 T 是 int、int* 或其他类型名),则 static_cast 也能用来进行对象到 T 类型的转换。

static_cast 不能用于在不同类型的指针之间互相转换,也不能用于整型和指针之间的互相转换,当然也不能用于不同类型的引用之间的转换。因为这些属于风险比较高的转换。

static_cast 用法示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;
class A
{
public:
operator int() { return 1; }
operator char*() { return NULL; }
};
int main()
{
A a;
int n;
char* p = "New Dragon Inn";
n = static_cast <int> (3.14); // n 的值变为 3
n = static_cast <int> (a); //调用 a.operator int,n 的值变为 1
p = static_cast <char*> (a); //调用 a.operator char*,p 的值变为 NULL
n = static_cast <int> (p); //编译错误,static_cast不能将指针转换成整型
p = static_cast <char*> (n); //编译错误,static_cast 不能将整型转换成指针
return 0;
}

reinterpret_cast

reinterpret_cast 用于进行各种不同类型的指针之间、不同类型的引用之间以及指针和能容纳指针的整数类型之间的转换。转换时,执行的是逐个比特复制的操作。

这种转换提供了很强的灵活性,但转换的安全性只能由程序员的细心来保证了。例如,程序员执意要把一个 int 指针、函数指针或其他类型的指针转换成 string 类型的指针也是可以的,至于以后用转换后的指针调用 string 类的成员函数引发错误,程序员也只能自行承担查找错误的烦琐工作:(C++ 标准不允许将函数指针转换成对象指针,但有些编译器,如 Visual Studio 2010,则支持这种转换)。

reinterpret_cast 用法示例如下:

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>
using namespace std;
class A
{
public:
int i;
int j;
A(int n):i(n),j(n) { }
};
int main()
{
A a(100);
int &r = reinterpret_cast<int&>(a); //强行让 r 引用 a
r = 200; //把 a.i 变成了 200
cout << a.i << "," << a.j << endl; // 输出 200,100
int n = 300;
A *pa = reinterpret_cast<A*> ( & n); //强行让 pa 指向 n
pa->i = 400; // n 变成 400
pa->j = 500; //此条语句不安全,很可能导致程序崩溃
cout << n << endl; // 输出 400
long long la = 0x12345678abcdLL;
pa = reinterpret_cast<A*>(la); //la太长,只取低32位0x5678abcd拷贝给pa
unsigned int u = reinterpret_cast<unsigned int>(pa);//pa逐个比特拷贝到u
cout << hex << u << endl; //输出 5678abcd
typedef void (* PF1) (int);
typedef int (* PF2) (int,char *);
PF1 pf1; PF2 pf2;
pf2 = reinterpret_cast<PF2>(pf1); //两个不同类型的函数指针之间可以互相转换
}

程序的输出结果是:

1
2
3
200, 100
400
5678abed

第 19 行的代码不安全,因为在编译器看来,pa->j 的存放位置就是 n 后面的 4 个字节。 本条语句会向这 4 个字节中写入 500。但这 4 个字节不知道是用来存放什么的,贸然向其中写入可能会导致程序错误甚至崩溃。

上面程序中的各种转换都没有实际意义,只是为了演示 reinteipret_cast 的用法而已。在编写黑客程序、病毒或反病毒程序时,也许会用到这样怪异的转换。

reinterpret_cast体现了 C++ 语言的设计思想:用户可以做任何操作,但要为自己的行为负责。

const_cast

const_cast 运算符仅用于进行去除 const 属性的转换,它也是四个强制类型转换运算符中唯一能够去除 const 属性的运算符。

将 const 引用转换为同类型的非 const 引用,将 const 指针转换为同类型的非 const 指针时可以使用 const_cast 运算符。例如:

1
2
3
const string s = "Inception";
string& p = const_cast <string&> (s);
string* ps = const_cast <string*> (&s); // &s 的类型是 const string*

dynamic_cast

用 reinterpret_cast 可以将多态基类(包含虚函数的基类)的指针强制转换为派生类的指针,但是这种转换不检查安全性,即不检查转换后的指针是否确实指向一个派生类对象。dynamic_cast专门用于将多态基类的指针或引用强制转换为派生类的指针或引用,而且能够检查转换的安全性。对于不安全的指针转换,转换结果返回 NULL 指针。

dynamic_cast 是通过“运行时类型检查”来保证安全性的。dynamic_cast 不能用于将非多态基类的指针或引用强制转换为派生类的指针或引用——这种转换没法保证安全性,只好用 reinterpret_cast 来完成。

dynamic_cast 示例程序如下:

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
#include <iostream>
#include <string>
using namespace std;
class Base
{ //有虚函数,因此是多态基类
public:
virtual ~Base() {}
};
class Derived : public Base { };
int main()
{
Base b;
Derived d;
Derived* pd;
pd = reinterpret_cast <Derived*> (&b);
if (pd == NULL)
//此处pd不会为 NULL。reinterpret_cast不检查安全性,总是进行转换
cout << "unsafe reinterpret_cast" << endl; //不会执行
pd = dynamic_cast <Derived*> (&b);
if (pd == NULL) //结果会是NULL,因为 &b 不指向派生类对象,此转换不安全
cout << "unsafe dynamic_cast1" << endl; //会执行
pd = dynamic_cast <Derived*> (&d); //安全的转换
if (pd == NULL) //此处 pd 不会为 NULL
cout << "unsafe dynamic_cast2" << endl; //不会执行
return 0;
}

程序的输出结果是:
1
unsafe dynamic_cast1

第 20 行,通过判断 pd 的值是否为 NULL,就能知道第 19 行进行的转换是否是安全的。第 23 行同理。

如果上面的程序中出现了下面的语句:

1
Derived & r = dynamic_cast <Derived &> (b);

那该如何判断该转换是否安全呢?不存在空引用,因此不能通过返回值来判断转换是否安全。C++ 的解决办法是:dynamic_cast 在进行引用的强制转换时,如果发现转换不安全,就会拋出一个异常,通过处理异常,就能发现不安全的转换。

attribute二三事

1
2
3
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));

这个函数是在做uCore的时候发现的,有一个特别的地方attribute((always_inline)),之前从来没见过,于是去查了一下,不查不知道,一查下一跳啊,这竟然是GUN C的一个从来没听过的属性。

当我们用__inline__ __attribute__((always_inline))修饰一个函数的时候,编译器会将我们的代码编译.在调用的地方将我们的函数,插入到调用的地方.

attribute是GNU C特色之一,在iOS用的比较广泛.系统中有许多地方使用到. attribute可以设置函数属性(Function Attribute )、变量属性(Variable Attribute )和类型属性(Type Attribute)等.

函数属性(Function Attribute)

  • noreturn
  • noinline
  • always_inline
  • pure
  • const
  • nothrow
  • sentinel
  • format
  • format_arg
  • no_instrument_function
  • section
  • constructor
  • destructor
  • used
  • unused
  • deprecated
  • weak
  • malloc
  • alias
  • warn_unused_result
  • nonnull

类型属性(Type Attributes)

  • aligned
  • packed
  • transparent_union,
  • unused,
  • deprecated
  • may_alias

变量属性(Variable Attribute)

  • aligned
  • packed

Clang特有的

  • availability
  • overloadable

书写格式

书写格式:attribute后面会紧跟一对原括弧,括弧里面是相应的attribute参数

1
__attribute__(xxx)

常见的系统用法

format

1
#define NS_FORMAT_FUNCTION(F,A) __attribute__((format(__NSString__, F, A)))

format属性可以给被声明的函数加上类似printf或者scanf的特征,它可以使编译器检查函数声明和函数实际调用参数之间的格式化字符串是否匹配。该功能十分有用,尤其是处理一些很难发现的bug。对于format参数的使用如下

1
format (archetype, string-index, first-to-check)

第一参数需要传递“archetype”指定是哪种风格,这里是 NSString;“string-index”指定传入函数的第几个参数是格式化字符串;“first-to-check”指定第一个可变参数所在的索引.

noreturn

官方例子: abort() 和 exit()

该属性通知编译器函数从不返回值。当遇到类似函数还未运行到return语句就需要退出来的情况,该属性可以避免出现错误信息。

availability

官方例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (CGSize)sizeWithFont:(UIFont *)font NS_DEPRECATED_IOS(2_0, 7_0, "Use -sizeWithAttributes:") __TVOS_PROHIBITED;

//来看一下 后边的宏
#define NS_DEPRECATED_IOS(_iosIntro, _iosDep, ...) CF_DEPRECATED_IOS(_iosIntro, _iosDep, __VA_ARGS__)

#define CF_DEPRECATED_IOS(_iosIntro, _iosDep, ...) __attribute__((availability(ios,introduced=_iosIntro,deprecated=_iosDep,message="" __VA_ARGS__)))

//宏展开以后如下
__attribute__((availability(ios,introduced=2_0,deprecated=7_0,message=""__VA_ARGS__)));
//ios即是iOS平台
//introduced 从哪个版本开始使用
//deprecated 从哪个版本开始弃用
//message 警告的消息

availability属性是一个以逗号为分隔的参数列表,以平台的名称开始,包含一些放在附加信息里的一些里程碑式的声明。

  • introduced:第一次出现的版本。
  • deprecated:声明要废弃的版本,意味着用户要迁移为其他API
  • obsoleted: 声明移除的版本,意味着完全移除,再也不能使用它
  • unavailable:在这些平台不可用
  • message:一些关于废弃和移除的额外信息,clang发出警告的时候会提供这些信息,对用户使用替代的API非常有用。
  • 这个属性支持的平台:ios,macosx。
1
2
3
4
5
6
7
8
//如果经常用,建议定义成类似系统的宏
- (void)oldMethod:(NSString *)string __attribute__((availability(ios,introduced=2_0,deprecated=7_0,message="用 -newMethod: 这个方法替代 "))){
NSLog(@"我是旧方法,不要调我");
}

- (void)newMethod:(NSString *)string{
NSLog(@"我是新方法");
}

visibility

语法:

1
__attribute__((visibility("visibility_type")))

其中,visibility_type 是下列值之一:

  • default:假定的符号可见性可通过其他选项进行更改。缺省可见性将覆盖此类更改。缺省可见性与外部链接对应。
  • hidden:该符号不存放在动态符号表中,因此,其他可执行文件或共享库都无法直接引用它。使用函数指针可进行间接引用。
  • internal:除非由特定于处理器的应用二进制接口 (psABI) 指定,否则,内部可见性意味着不允许从另一模块调用该函数。
  • protected:该符号存放在动态符号表中,但定义模块内的引用将与局部符号绑定。也就是说,另一模块无法覆盖该符号。

除指定 default 可见性外,此属性都可与在这些情况下具有外部链接的声明结合使用。
您可在 C 和 C++ 中使用此属性。在 C++ 中,还可将它应用于类型、成员函数和命名空间声明。

系统用法:

1
2
3
4
5
6
//  UIKIT_EXTERN     extern
#ifdef __cplusplus
#define UIKIT_EXTERN extern "C" __attribute__((visibility ("default")))
#else
#define UIKIT_EXTERN extern __attribute__((visibility ("default")))
#endif

nonnull

编译器对函数参数进行NULL的检查,参数类型必须是指针类型(包括对象)

1
2
3
4
5
6
7
- (int)addNum1:(int *)num1 num2:(int *)num2  __attribute__((nonnull (1,2))){//1,2表示第一个和第二个参数不能为空
return *num1 + *num2;
}

- (NSString *)getHost:(NSURL *)url __attribute__((nonnull (1))){//第一个参数不能为空
return url.host;
}

常见用法

aligned

__attribute((aligned (n))),让所作用的结构成员对齐在n字节自然边界上。如果结构中有成员的长度大于n,则按照最大成员的长度来对齐.例如:

不加修饰的情况

1
2
3
4
5
6
typedef struct
{
char member1;
int member2;
short member3;
}Family;

输出字节:
1
NSLog(@"Family size is %zd",sizeof(Family));

输出结果为:
1
2016-07-25 10:28:45.380 Study[917:436064] Family size is 12

修改字节对齐为1

1
2
3
4
5
6
typedef struct
{
char member1;
int member2;
short member3;
}__attribute__ ((aligned (1))) Family;

输出字节:
1
NSLog(@"Family size is %zd",sizeof(Family));

输出结果为:
1
2016-07-25 10:28:05.315 Study[914:435764] Family size is 12

和上面的结果一致,因为设定的字节对齐为1.而结构体中成员的最大字节数是int 4个字节,1 < 4,按照4字节对齐,和系统默认一致.

修改字节对齐为8

1
2
3
4
5
6
typedef struct
{
char member1;
int member2;
short member3;
}__attribute__ ((aligned (8))) Family;

输出字节:
1
NSLog(@"Family size is %zd",sizeof(Family));

输出结果为:
1
2016-07-25 10:28:05.315 Study[914:435764] Family size is 16

这里 8 > 4,按照8字节对齐,结果为16。

可是想了半天,也不知道这玩意有什么用,设定值小于系统默认的,和没设定一样,设定大了,又浪费空间,效率也没提高,感觉学习学习就好.

packed

让指定的结构结构体按照一字节对齐,测试:

1
2
3
4
5
6
7
//不加packed修饰
typedef struct {
char version;
int16_t sid;
int32_t len;
int64_t time;
} Header;

计算长度:
1
NSLog(@"size is %zd",sizeof(Header));

输出结果为:
1
2016-07-22 11:53:47.728 Study[14378:5523450] size is 16

可以看出,默认系统是按照4字节对齐
1
2
3
4
5
6
7
//加packed修饰
typedef struct {
char version;
int16_t sid;
int32_t len;
int64_t time;
}__attribute__ ((packed)) Header;

计算长度
1
NSLog(@"size is %zd",sizeof(Header));

输出结果为:
1
2016-07-22 11:57:46.970 Study[14382:5524502] size is 15

用packed修饰后,变为1字节对齐,这个常用于与协议有关的网络传输中.

noinline & always_inline

内联函数:内联函数从源代码层看,有函数的结构,而在编译后,却不具备函数的性质。内联函数不是在调用时发生控制转移,而是在编译时将函数体嵌入在每一个调用处。编译时,类似宏替换,使用函数体替换调用处的函数名。一般在代码中用inline修饰,但是能否形成内联函数,需要看编译器对该函数定义的具体处理。这两个都是用在函数上

  • noinline 不内联
  • always_inline 总是内联

内联的本质是用代码块直接替换掉函数调用处,好处是:快减少系统开销.

使用例子:

1
2
//函数声明
void test(int a) __attribute__((always_inline));

warn_unused_result

当函数或者方法的返回值很重要时,要求调用者必须检查或者使用返回值,否则编译器会发出警告提示

1
2
3
4
 - (BOOL)availiable __attribute__((warn_unused_result))
{
return 10;
}

constructor / destructor

意思是: 构造器和析构器;constructor修饰的函数会在main函数之前执行,destructor修饰的函数会在程序exit前调用.
示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(int argc, char * argv[]) {
@autoreleasepool {
NSLog(@"main");
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

__attribute__((constructor))
void before(){
NSLog(@"before main");
}

__attribute__((destructor))
void after(){
NSLog(@"after main");
}

//在viewController中调用exit
- (void)viewDidLoad {
[super viewDidLoad];

exit(0);
}

输出如下:
1
2
3
2016-07-21 21:49:17.446 Study[14162:5415982] before main
2016-07-21 21:49:17.447 Study[14162:5415982] main
2016-07-21 21:49:17.534 Study[14162:5415982] after main

注意点:

  • 程序退出的时候才会调用after函数,经测试,手动退出程序会执行
  • 上面两个函数不管写在哪个类里,哪个文件中效果都一样
  • 如果存在多个修饰的函数,那么都会执行,顺序不定
  • 实际上如果存在多个修饰过的函数,可以它们的调整优先级

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main(int argc, char * argv[]) {
@autoreleasepool {
NSLog(@"main");
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}

__attribute__((constructor(101)))
void before1(){
NSLog(@"before main - 1");
}
__attribute__((constructor(102)))
void before2(){
NSLog(@"before main - 2");
}

__attribute__((destructor(201)))
void after1(){
NSLog(@"after main - 1");
}
__attribute__((destructor(202)))
void after2(){
NSLog(@"after main - 2");
}

输出结果如下:
1
2
3
4
5
2016-07-21 21:59:35.622 Study[14171:5418393] before main - 1
2016-07-21 21:59:35.624 Study[14171:5418393] before main - 2
2016-07-21 21:59:35.624 Study[14171:5418393] main
2016-07-21 21:59:35.704 Study[14171:5418393] after main - 2
2016-07-21 21:59:35.704 Study[14171:5418393] after main - 1

注意点:

  • 括号内的值表示优先级,[0,100]这个返回时系统保留的,自己千万别调用.
  • 根据输出结果可以看出,main函数之前的,数值越小,越先调用;main函数之后的数值越大,越先调用.
  • 当函数声明和函数实现分开写时,格式如下:
1
2
3
4
5
static void before() __attribute__((constructor));

static void before() {
printf("before\n");
}

讨论:+load,constructor,main的执行顺序,代码如下:

1
2
3
4
5
6
7
+ (void)load{
NSLog(@"load");
}
__attribute__((constructor))
void before(){
NSLog(@"before main");
}

输出结果如下:
1
2
3
2016-07-21 22:13:58.591 Study[14185:5421811] load
2016-07-21 22:13:58.592 Study[14185:5421811] before main
2016-07-21 22:13:58.592 Study[14185:5421811] main

可以看出执行顺序为:load->constructor->main。为什么呢?

因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法,然后才调用main函数.

enable_if

用来检查参数是否合法,只能用来修饰函数:

1
2
3
4
5
void printAge(int age)
__attribute__((enable_if(age > 0 && age < 120, "你丫太监?")))
{
NSLog(@"%d",age);
}

表示只能输入的参数只能是 0 ~ 120左右,否则编译报错.

内存管理

伟大的Bill Gates 曾经失言:

640K ought to be enough for everybody —— Bill Gates 1981

程序员们经常编写内存管理程序,往往提心吊胆。如果不想触雷,唯一的解决办法就是发现所有潜伏的地雷并且排除它们,躲是躲不了的。本文的内容比一般教科书的要深入得多,读者需细心阅读,做到真正地通晓内存管理。

C++内存管理详解

内存分配方式

分配方式简介

在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。

堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

自由存储区,就是那些由malloc等分配的内存块,他和堆是十分相似的,不过它是用free来结束自己的生命的。

全局/静态存储区,全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

明确区分堆与栈

在bbs上,堆与栈的区分问题,似乎是一个永恒的话题,由此可见,初学者对此往往是混淆不清的,所以我决定拿他第一个开刀。

首先,我们举一个例子:

1
void f() { int* p=new int[5]; }

这条短短的一句话就包含了堆与栈,看到new,我们首先就应该想到,我们分配了一块堆内存,那么指针p呢?他分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中,他在VC6下的汇编代码如下:

1
2
3
4
5
6
00401028 push 14h
0040102A call operator new (00401060)
0040102F add esp,4
00401032 mov dword ptr [ebp-8],eax
00401035 mov eax,dword ptr [ebp-8]
00401038 mov dword ptr [ebp-4],eax

这里,我们为了简单并没有释放内存,那么该怎么去释放呢?是delete p么?澳,错了,应该是delete []p,这是为了告诉编译器:我删除的是一个数组,VC6就会根据相应的Cookie信息去进行释放内存的工作。

堆和栈究竟有什么区别?

好了,我们回到我们的主题:堆和栈究竟有什么区别?

主要的区别由以下几点:
1.管理方式不同;
2.空间大小不同;
3.能否产生碎片不同;
4.生长方向不同;
5.分配方式不同;
6.分配效率不同;
管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;对于堆来说,释放工作由程序员控制,容易产生memory leak。

空间大小:一般来讲在32位系统下,堆内存可以达到4G的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在VC6下面,默认的栈空间大小是1M(好像是,记不清楚了)。当然,我们可以修改:

打开工程,依次操作菜单如下:Project->Setting->Link,在Category 中选中Output,然后在Reserve中设定堆栈的最大值和commit。

注意:reserve最小值为4Byte;commit是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。

碎片问题:对于堆来讲,频繁的new/delete势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出,详细的可以参考数据结构,这里我们就不再一一讨论了。

生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。

分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由alloca函数进行分配,但是栈的动态分配和堆是不同的,他的动态分配是由编译器进行释放,无需我们手工实现。

分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

从这里我们可以看到,堆和栈相比,由于大量new/delete的使用,容易造成大量的内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和核心态的切换,内存的申请,代价变得更加昂贵。所以栈在程序中是应用最广泛的,就算是函数的调用也利用栈去完成,函数调用过程中的参数,返回地址,EBP和局部变量都采用栈的方式存放。所以,我们推荐大家尽量用栈,而不是用堆。

虽然栈有如此众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,还是用堆好一些。

无论是堆还是栈,都要防止越界现象的发生(除非你是故意使其越界),因为越界的结果要么是程序崩溃,要么是摧毁程序的堆、栈结构,产生以想不到的结果,就算是在你的程序运行过程中,没有发生上面的问题,你还是要小心,说不定什么时候就崩掉,那时候debug可是相当困难的:)

控制C++的内存分配

在嵌入式系统中使用C++的一个常见问题是内存分配,即对new和delete操作符的失控。

具有讽刺意味的是,问题的根源却是C++对内存的管理非常的容易而且安全。具体地说,当一个对象被消除时,它的析构函数能够安全的释放所分配的内存。

这当然是个好事情,但是这种使用的简单性使得程序员们过度使用new和delete,而不注意在嵌入式C++环境中的因果关系。并且,在嵌入式系统中,由于内存的限制,频繁的动态分配不定大小的内存会引起很大的问题以及堆破碎的风险。

作为忠告,保守的使用内存分配是嵌入式环境中的第一原则。

但当你必须要使用new 和delete时,你不得不控制C++中的内存分配。你需要用一个全局的new和delete来代替系统的内存分配符,并且一个类一个类的重载new和delete。

一个防止堆破碎的通用方法是从不同固定大小的内存持中分配不同类型的对象。对每个类重载new 和delete就提供了这样的控制。

重载全局的new和delete操作符

可以很容易地重载new 和 delete 操作符,如下所示:

1
2
3
4
5
6
7
8
9
10
void * operator new(size_t size)
{
void *p = malloc(size);
return (p);
}

void operator delete(void *p);
{
free(p);
}

这段代码可以代替默认的操作符来满足内存分配的请求。出于解释C++的目的,我们也可以直接调用malloc() 和free()。

也可以对单个类的new 和 delete 操作符重载。这是你能灵活的控制对象的内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TestClass {
public:
void * operator new(size_t size);
void operator delete(void *p);
// .. other members here ...
};
void *TestClass::operator new(size_t size)
{
void *p = malloc(size); // Replace this with alternative allocator
return (p);
}
void TestClass::operator delete(void *p)
{
free(p); // Replace this with alternative de-allocator
}

所有TestClass 对象的内存分配都采用这段代码。更进一步,任何从TestClass 继承的类也都采用这一方式,除非它自己也重载了new 和 delete 操作符。通过重载new 和 delete 操作符的方法,你可以自由地采用不同的分配策略,从不同的内存池中分配不同的类对象。

为单个的类重载 new[ ]和delete[ ]

必须小心对象数组的分配。你可能希望调用到被你重载过的new 和 delete 操作符,但并不如此。内存的请求被定向到全局的new[ ]和delete[ ] 操作符,而这些内存来自于系统堆。

C++将对象数组的内存分配作为一个单独的操作,而不同于单个对象的内存分配。为了改变这种方式,你同样需要重载new[ ] 和 delete[ ]操作符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TestClass {
public:
void * operator new[ ](size_t size);
void operator delete[ ](void *p);
// .. other members here ..
};
void *TestClass::operator new[ ](size_t size)
{
void *p = malloc(size);
return (p);
}
void TestClass::operator delete[ ](void *p)
{
free(p);
}
int main(void)
{
TestClass *p = new TestClass[10];
// ... etc ...
delete[ ] p;
}

但是注意:对于多数C++的实现,new[]操作符中的个数参数是数组的大小加上额外的存储对象数目的一些字节。在你的内存分配机制重要考虑的这一点。你应该尽量避免分配对象数组,从而使你的内存分配策略简单。

常见的内存错误及其对策

发生内存错误是件非常麻烦的事情。编译器不能自动发现这些错误,通常是在程序运行时才能捕捉到。而这些错误大多没有明显的症状,时隐时现,增加了改错的难度。有时用户怒气冲冲地把你找来,程序却没有发生任何问题,你一走,错误又发作了。 常见的内存错误及其对策如下:

  • 内存分配未成功,却使用了它。

编程新手常犯这种错误,因为他们没有意识到内存分配会不成功。常用解决办法是,在使用内存之前检查指针是否为NULL。如果指针p是函数的参数,那么在函数的入口处用assert(p!=NULL)进行

检查。如果是用malloc或new来申请内存,应该用if(p==NULL) 或if(p!=NULL)进行防错处理。

  • 内存分配虽然成功,但是尚未初始化就引用它。

犯这种错误主要有两个起因:一是没有初始化的观念;二是误以为内存的缺省初值全为零,导致引用初值错误(例如数组)。 内存的缺省初值究竟是什么并没有统一的标准,尽管有些时候为零值,我们宁可信其无不可信其有。所以无论用何种方式创建数组,都别忘了赋初值,即便是赋零值也不可省略,不要嫌麻烦。

  • 内存分配成功并且已经初始化,但操作越过了内存的边界。

例如在使用数组时经常发生下标”多1”或者”少1”的操作。特别是在for循环语句中,循环次数很容易搞错,导致数组操作越界。

  • 忘记了释放内存,造成内存泄露。

含有这种错误的函数每被调用一次就丢失一块内存。刚开始时系统的内存充足,你看不到错误。终有一次程序突然死掉,系统出现提示:内存耗尽。

动态内存的申请与释放必须配对,程序中malloc与free的使用次数一定要相同,否则肯定有错误(new/delete同理)。

  • 释放了内存却继续使用它。

有三种情况:

(1)程序中的对象调用关系过于复杂,实在难以搞清楚某个对象究竟是否已经释放了内存,此时应该重新设计数据结构,从根本上解决对象管理的混乱局面。

(2)函数的return语句写错了,注意不要返回指向”栈内存”的”指针”或者”引用”,因为该内存在函数体结束时被自动销毁。

(3)使用free或delete释放了内存后,没有将指针设置为NULL。导致产生”野指针”。

【规则1】用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。

【规则2】不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。

【规则3】避免数组或指针的下标越界,特别要当心发生”多1”或者”少1”操作。

【规则4】动态内存的申请与释放必须配对,防止内存泄漏。

【规则5】用free或delete释放了内存之后,立即将指针设置为NULL,防止产生”野指针”。

指针与数组的对比

C++/C程序中,指针和数组在不少地方可以相互替换着用,让人产生一种错觉,以为两者是等价的。

数组要么在静态存储区被创建(如全局数组),要么在栈上被创建。数组名对应着(而不是指向)一块内存,其地址与容量在生命期内保持不变,只有数组的内容可以改变。

指针可以随时指向任意类型的内存块,它的特征是”可变”,所以我们常用指针来操作动态内存。指针远比数组灵活,但也更危险。

下面以字符串为例比较指针与数组的特性。

修改内容

下面示例中,字符数组a的容量是6个字符,其内容为hello。a的内容可以改变,如a[0]= ‘X’。指针p指向常量字符串”world”(位于静态存储区,内容为world),常量字符串的内容是不可以被修改的。从语法上看,编译器并不觉得语句p[0]=’X’有什么不妥,但是该语句企图修改常量字符串的内容而导致运行错误。

1
2
3
4
5
6
char a[] ="hello";
a[0] = 'X';
cout<<a<<endl;
char *p = "world"; // 注意p指向常量字符串
p[0] = 'X'; // 编译器不能发现该错误
cout<<p<<endl;

内容复制与比较

不能对数组名进行直接复制与比较。若想把数组a的内容复制给数组b,不能用语句 b = a ,否则将产生编译错误。应该用标准库函数strcpy进行复制。同理,比较b和a的内容是否相同,不能用if(b==a) 来判断,应该用标准库函数strcmp进行比较。

语句p = a 并不能把a的内容复制指针p,而是把a的地址赋给了p。要想复制a的内容,可以先用库函数malloc为p申请一块容量为strlen(a)+1个字符的内存,再用strcpy进行字符串复制。同理,语句if(p==a) 比较的不是内容而是地址,应该用库函数strcmp来比较。

1
2
3
4
5
6
7
8
9
10
11
// 数组…
char a[] = "hello";
char b[10];
strcpy(b, a); // 不能用 b = a;
if(strcmp(b, a) == 0) // 不能用 if (b == a)

// 指针…
int len = strlen(a);
char *p = (char *)malloc(sizeof(char)*(len+1));
strcpy(p,a); // 不要用 p = a;
if(strcmp(p, a) == 0) // 不要用 if (p == a)

计算内存容量

用运算符sizeof可以计算出数组的容量(字节数)。如下示例中,sizeof(a)的值是12(注意别忘了’’)。指针p指向a,但是sizeof(p)的值却是4。这是因为sizeof(p)得到的是一个指针变量的字节数,相当于sizeof(char*),而不是p所指的内存容量。C++/C语言没有办法知道指针所指的内存容量,除非在申请内存时记住它。

1
2
3
4
char a[] = "hello world";
char *p = a;
cout<< sizeof(a) << endl; // 12字节
cout<< sizeof(p) << endl; // 4字节

注意当数组作为函数的参数进行传递时,该数组自动退化为同类型的指针。如下示例中,不论数组a的容量是多少,sizeof(a)始终等于sizeof(char *)。

1
2
3
4
void Func(char a[100])
{
cout<< sizeof(a) << endl; // 4字节而不是100字节
}

指针参数是如何传递内存的?

如果函数的参数是一个指针,不要指望用该指针去申请动态内存。如下示例中,Test函数的语句GetMemory(str, 200)并没有使str获得期望的内存,str依旧是NULL,为什么?

1
2
3
4
5
6
7
8
9
10
void GetMemory(char *p, int num)
{
 p = (char *)malloc(sizeof(char) * num);
}
void Test(void)
{
 char *str = NULL;
 GetMemory(str, 100); // str 仍然为 NULL
 strcpy(str, "hello"); // 运行错误
}

毛病出在函数GetMemory中。编译器总是要为函数的每个参数制作临时副本,指针参数p的副本是 _p,编译器使 _p = p。如果函数体内的程序修改了_p的内容,就导致参数p的内容作相应的修改。这就是指针可以用作输出参数的原因。在本例中,_p申请了新的内存,只是把_p所指的内存地址改变了,但是p丝毫未变。所以函数GetMemory并不能输出任何东西。事实上,每执行一次GetMemory就会泄露一块内存,因为没有用free释放内存。

如果非得要用指针参数去申请内存,那么应该改用“指向指针的指针”,见示例:

1
2
3
4
5
6
7
8
9
10
11
12
void GetMemory2(char **p, int num)
{
*p = (char *)malloc(sizeof(char) * num);
}
void Test2(void)
{
char *str = NULL;
GetMemory2(&str, 100); // 注意参数是 &str,而不是str
strcpy(str, "hello");
cout<< str << endl;
free(str);
}

由于“指向指针的指针”这个概念不容易理解,我们可以用函数返回值来传递动态内存。这种方法更加简单,见示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
char *GetMemory3(int num)
{
char *p = (char *)malloc(sizeof(char) * num);
return p;
}
void Test3(void)
{
char *str = NULL;
str = GetMemory3(100);
strcpy(str, "hello");
cout<< str << endl;
free(str);
}

用函数返回值来传递动态内存这种方法虽然好用,但是常常有人把return语句用错了。这里强调不要用return语句返回指向”栈内存”的指针,因为该内存在函数结束时自动消亡,见示例:

1
2
3
4
5
6
7
8
9
10
11
char *GetString(void)
{
char p[] = "hello world";
return p; // 编译器将提出警告
}
void Test4(void)
{
char *str = NULL;
str = GetString(); // str 的内容是垃圾
cout<< str << endl;
}

用调试器逐步跟踪Test4,发现执行str = GetString语句后str不再是NULL指针,但是str的内容不是“hello world”而是垃圾。

如果把上述示例改写成如下示例,会怎么样?

1
2
3
4
5
6
7
8
9
10
11
char *GetString2(void)
{
char *p = "hello world";
return p;
}
void Test5(void)
{
char *str = NULL;
str = GetString2();
cout<< str << endl;
}

函数Test5运行虽然不会出错,但是函数GetString2的设计概念却是错误的。因为GetString2内的“hello world”是常量字符串,位于静态存储区,它在程序生命期内恒定不变。无论什么时候调用GetString2,它返回的始终是同一个“只读”的内存块。

杜绝“野指针”

“野指针”不是NULL指针,是指向“垃圾”内存的指针。人们一般不会错用NULL指针,因为用if语句很容易判断。但是“野指针”是很危险的,if语句对它不起作用。 “野指针”的成因主要有两种:

  1. 指针变量没有被初始化。任何指针变量刚被创建时不会自动成为NULL指针,它的缺省值是随机的,它会乱指一气。所以,指针变量在创建的同时应当被初始化,要么将指针设置为NULL,要么让它指向合法的内存。例如
1
2
char *p = NULL;
char *str = (char *) malloc(100);

指针p被free或者delete之后,没有置为NULL,让人误以为p是个合法的指针。

  1. 指针操作超越了变量的作用域范围。这种情况让人防不胜防,示例程序如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A
{
public:
void Func(void){ cout << “Func of class A” << endl; }
};
void Test(void)
{
A *p;
{
A a;
p = &a; // 注意 a 的生命期
}
p->Func(); // p是"野指针"
}

函数Test在执行语句p->Func()时,对象a已经消失,而p是指向a的,所以p就成了”野指针”。但奇怪的是我运行这个程序时居然没有出错,这可能与编译器有关。

有了malloc/free为什么还要new/delete?

malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。

对于非内部数据类型的对象而言,光用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。

因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。我们先看一看malloc/free和new/delete如何实现对象的动态内存管理,见示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Obj
{
public :
Obj(void){ cout << “Initialization” << endl; }
~Obj(void){ cout << “Destroy” << endl; }
void Initialize(void){ cout << “Initialization” << endl; }
void Destroy(void){ cout << “Destroy” << endl; }
};
void UseMallocFree(void)
{
Obj *a = (obj *)malloc(sizeof(obj)); // 申请动态内存
a->Initialize(); // 初始化
//…
a->Destroy(); // 清除工作
free(a); // 释放内存
}
void UseNewDelete(void)
{
Obj *a = new Obj; // 申请动态内存并且初始化
//…
delete a; // 清除并且释放内存
}

类Obj的函数Initialize模拟了构造函数的功能,函数Destroy模拟了析构函数的功能。函数UseMallocFree中,由于malloc/free不能执行构造函数与析构函数,必须调用成员函数Initialize和Destroy来完成初始化与清除工作。函数UseNewDelete则简单得多。

所以我们不要企图用malloc/free来完成动态对象的内存管理,应该用new/delete。由于内部数据类型的”对象”没有构造与析构的过程,对它们而言malloc/free和new/delete是等价的。

既然new/delete的功能完全覆盖了malloc/free,为什么C++不把malloc/free淘汰出局呢?这是因为C++程序经常要调用C函数,而C程序只能用malloc/free管理动态内存。

如果用free释放”new创建的动态对象”,那么该对象因无法执行析构函数而可能导致程序出错。如果用delete释放”malloc申请的动态内存”,结果也会导致程序出错,但是该程序的可读性很差。所以new/delete必须配对使用,malloc/free也一样。

内存耗尽怎么办?

如果在申请动态内存时找不到足够大的内存块,malloc和new将返回NULL指针,宣告内存申请失败。通常有三种方式处理”内存耗尽”问题。

  1. 判断指针是否为NULL,如果是则马上用return语句终止本函数。例如:
1
2
3
4
5
6
7
8
void Func(void)
{
A *a = new A;
if(a == NULL)
{
return;
}
}
  1. 判断指针是否为NULL,如果是则马上用exit(1)终止整个程序的运行。例如:
1
2
3
4
5
6
7
8
9
void Func(void)
{
A *a = new A;
if(a == NULL)
{
cout << “Memory Exhausted” << endl;
exit(1);
}
}
  1. 为new和malloc设置异常处理函数。例如Visual C++可以用_set_new_hander函数为new设置用户自己定义的异常处理函数,也可以让malloc享用与new相同的异常处理函数。详细内容请参考C++使用手册。

上述(1)(2)方式使用最普遍。如果一个函数内有多处需要申请动态内存,那么方式(1)就显得力不从心(释放内存很麻烦),应该用方式(2)来处理。

很多人不忍心用exit(1),问:”不编写出错处理程序,让操作系统自己解决行不行?”

不行。如果发生”内存耗尽”这样的事情,一般说来应用程序已经无药可救。如果不用exit(1)把坏程序杀死,它可能会害死操作系统。道理如同:如果不把歹徒击毙,歹徒在老死之前会犯下更多的罪。

有一个很重要的现象要告诉大家。对于32位以上的应用程序而言,无论怎样使用malloc与new,几乎不可能导致”内存耗尽”。我在Windows 98下用Visual C++编写了测试程序,见示例7。这个程序会无休止地运行下去,根本不会终止。因为32位操作系统支持”虚存”,内存用完了,自动用硬盘空间顶替。我只听到硬盘嘎吱嘎吱地响,Window 98已经累得对键盘、鼠标毫无反应。

我可以得出这么一个结论:对于32位以上的应用程序,”内存耗尽”错误处理程序毫无用处。这下可把Unix和Windows程序员们乐坏了:反正错误处理程序不起作用,我就不写了,省了很多麻烦。

我不想误导读者,必须强调:不加错误处理将导致程序的质量很差,千万不可因小失大。

1
2
3
4
5
6
7
8
9
10
11
void main(void)
{
 float *p = NULL;
 while(TRUE)
 {
  p = new float[1000000];
  cout << “eat memory” << endl;
  if(p==NULL)
   exit(1);
 }
}

malloc/free的使用要点

函数malloc的原型如下:

1
void * malloc(size_t size);

用malloc申请一块长度为length的整数类型的内存,程序如下:

1
int *p = (int *) malloc(sizeof(int) * length);

我们应当把注意力集中在两个要素上:”类型转换”和”sizeof”。

  • malloc返回值的类型是void ,所以在调用malloc时要显式地进行类型转换,将void 转换成所需要的指针类型。

  • malloc函数本身并不识别要申请的内存是什么类型,它只关心内存的总字节数。我们通常记不住int, float等数据类型的变量的确切字节数。例如int变量在16位系统下是2个字节,在32位下是4个字节;而float变量在16位系统下是4个字节,在32位下也是4个字节。最好用以下程序作一次测试:

1
2
3
4
5
6
7
8
cout << sizeof(char) << endl;
cout << sizeof(int) << endl;
cout << sizeof(unsigned int) << endl;
cout << sizeof(long) << endl;
cout << sizeof(unsigned long) << endl;
cout << sizeof(float) << endl;
cout << sizeof(double) << endl;
cout << sizeof(void *) << endl;

在malloc的”()”中使用sizeof运算符是良好的风格,但要当心有时我们会昏了头,写出 p = malloc(sizeof(p))这样的程序来。

函数free的原型如下:

1
void free( void * memblock );

为什么free函数不象malloc函数那样复杂呢?这是因为指针p的类型以及它所指的内存的容量事先都是知道的,语句free(p)能正确地释放内存。如果p是NULL指针,那么free对p无论操作多少次都不会出问题。如果p不是NULL指针,那么free对p连续操作两次就会导致程序运行错误。

new/delete的使用要点

运算符new使用起来要比函数malloc简单得多,例如:

1
2
3
int *p1 = (int *)malloc(sizeof(int) * length);

int *p2 = new int[length];

这是因为new内置了sizeof、类型转换和类型安全检查功能。对于非内部数据类型的对象而言,new在创建动态对象的同时完成了初始化工作。如果对象有多个构造函数,那么new的语句也可以有多种形式。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Obj
{
public :
Obj(void); // 无参数的构造函数
Obj(int x); // 带一个参数的构造函数
}

void Test(void)
{
Obj *a = new Obj;
Obj *b = new Obj(1); // 初值为1
delete a;
delete b;
}

如果用new创建对象数组,那么只能使用对象的无参数构造函数。例如:

1
Obj *objects = new Obj[100]; // 创建100个动态对象

不能写成:

1
Obj *objects = new Obj[100](1);// 创建100个动态对象的同时赋初值1

在用delete释放对象数组时,留意不要丢了符号’[]’。例如:

1
2
delete []objects; // 正确的用法
delete objects; // 错误的用法

后者有可能引起程序崩溃和内存泄漏。

C++中的健壮指针和资源管理

我最喜欢的对资源的定义是:”任何在你的程序中获得并在此后释放的东西?quot;内存是一个相当明显的资源的例子。它需要用new来获得,用delete来释放。同时也有许多其它类型的资源文件句柄、重要的片断、Windows中的GDI资源,等等。将资源的概念推广到程序中创建、释放的所有对象也是十分方便的,无论对象是在堆中分配的还是在栈中或者是在全局作用于内生命的。

对于给定的资源的拥有着,是负责释放资源的一个对象或者是一段代码。所有权分立为两种级别——自动的和显式的(automatic and explicit),如果一个对象的释放是由语言本身的机制来保证的,这个对象的就是被自动地所有。例如,一个嵌入在其他对象中的对象,他的清除需要其他对象来在清除的时候保证。外面的对象被看作嵌入类的所有者。 类似地,每个在栈上创建的对象(作为自动变量)的释放(破坏)是在控制流离开了对象被定义的作用域的时候保证的。这种情况下,作用于被看作是对象的所有者。注意所有的自动所有权都是和语言的其他机制相容的,包括异常。无论是如何退出作用域的————正常流程控制退出、一个break语句、一个return、一个goto、或者是一个throw————自动资源都可以被清除。

到目前为止,一切都很好!问题是在引入指针、句柄和抽象的时候产生的。如果通过一个指针访问一个对象的话,比如对象在堆中分配,C++不自动地关注它的释放。程序员必须明确的用适当的程序方法来释放这些资源。比如说,如果一个对象是通过调用new来创建的,它需要用delete来回收。一个文件是用CreateFile(Win32 API)打开的,它需要用CloseHandle来关闭。用EnterCritialSection进入的临界区(Critical Section)需要LeaveCriticalSection退出,等等。一个”裸”指针,文件句柄,或者临界区状态没有所有者来确保它们的最终释放。基本的资源管理的前提就是确保每个资源都有他们的所有者。

第一条规则(RAII)

一个指针,一个句柄,一个临界区状态只有在我们将它们封装入对象的时候才会拥有所有者。这就是我们的第一规则:在构造函数中分配资源,在析构函数中释放资源。

当你按照规则将所有资源封装的时候,你可以保证你的程序中没有任何的资源泄露。这点在当封装对象(Encapsulating Object)在栈中建立或者嵌入在其他的对象中的时候非常明显。但是对那些动态申请的对象呢?不要急!任何动态申请的东西都被看作一种资源,并且要按照上面提到的方法进行封装。这一对象封装对象的链不得不在某个地方终止。它最终终止在最高级的所有者,自动的或者是静态的。这些分别是对离开作用域或者程序时释放资源的保证。

下面是资源封装的一个经典例子。在一个多线程的应用程序中,线程之间共享对象的问题是通过用这样一个对象联系临界区来解决的。每一个需要访问共享资源的客户需要获得临界区。例如,这可能是Win32下临界区的实现方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CritSect
{
friend class Lock;
public:
CritSect () { InitializeCriticalSection (&_critSection); }
~CritSect () { DeleteCriticalSection (&_critSection); }
private:
void Acquire ()
{
EnterCriticalSection (&_critSection);
}
void Release ()
{
LeaveCriticalSection (&_critSection);
}
private:
CRITICAL_SECTION _critSection;
};

这里聪明的部分是我们确保每一个进入临界区的客户最后都可以离开。”进入”临界区的状态是一种资源,并应当被封装。封装器通常被称作一个锁(lock)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Lock
{
public:
Lock (CritSect& critSect) : _critSect (critSect)
{
_critSect.Acquire ();
}
~Lock ()
{
_critSect.Release ();
}
private
CritSect & _critSect;
};

锁一般的用法如下:

1
2
3
4
5
6
void Shared::Act () throw (char *)
{
Lock lock (_critSect);
// perform action —— may throw
// automatic destructor of lock
}

注意无论发生什么,临界区都会借助于语言的机制保证释放。

还有一件需要记住的事情————每一种资源都需要被分别封装。这是因为资源分配是一个非常容易出错的操作,是要资源是有限提供的。我们会假设一个失败的资源分配会导致一个异常————事实上,这会经常的发生。所以如果你想试图用一个石头打两只鸟的话,或者在一个构造函数中申请两种形式的资源,你可能就会陷入麻烦。只要想想在一种资源分配成功但另一种失败抛出异常时会发生什么。因为构造函数还没有全部完成,析构函数不可能被调用,第一种资源就会发生泄露。

这种情况可以非常简单的避免。无论何时你有一个需要两种以上资源的类时,写两个小的封装器将它们嵌入你的类中。每一个嵌入的构造都可以保证删除,即使包装类没有构造完成。

Smart Pointers

我们至今还没有讨论最常见类型的资源————用操作符new分配,此后用指针访问的一个对象。我们需要为每个对象分别定义一个封装类吗?(事实上,C++标准模板库已经有了一个模板类,叫做auto_ptr,其作用就是提供这种封装。我们一会儿在回到auto_ptr。)让我们从一个极其简单、呆板但安全的东西开始。看下面的Smart Pointer模板类,它十分坚固,甚至无法实现。

1
2
3
4
5
6
7
8
9
10
11
12
template <class T>
class SmartPointer
{
public:
~SmartPointer () { delete _p; }
T * operator->() { return _p; }
T const * operator->() const { return _p; }
protected:
SmartPointer (): _p (0) {}
explicit SmartPointer (T* p): _p (p) {}
T * _p;
};

为什么要把SmartPointer的构造函数设计为protected呢?如果我需要遵守第一条规则,那么我就必须这样做。资源————在这里是class T的一个对象————必须在封装器的构造函数中分配。但是我不能只简单的调用new T,因为我不知道T的构造函数的参数。因为,在原则上,每一个T都有一个不同的构造函数;我需要为他定义个另外一个封装器。模板的用处会很大,为每一个新的类,我可以通过继承SmartPointer定义一个新的封装器,并且提供一个特定的构造函数。

1
2
3
4
5
6
class SmartItem: public SmartPointer<Item>
{
public:
explicit SmartItem (int i)
  : SmartPointer<Item> (new Item (i)) {}
};

为每一个类提供一个Smart Pointer真的值得吗?说实话————不!他很有教学的价值,但是一旦你学会如何遵循第一规则的话,你就可以放松规则并使用一些高级的技术。这一技术是让SmartPointer的构造函数成为public,但是只是是用它来做资源转换(Resource Transfer)我的意思是用new操作符的结果直接作为SmartPointer的构造函数的参数,像这样:

1
SmartPointer<Item> item (new Item (i));

这个方法明显更需要自控性,不只是你,而且包括你的程序小组的每个成员。他们都必须发誓出了作资源转换外不把构造函数用在人以其他用途。幸运的是,这条规矩很容易得以加强。只需要在源文件中查找所有的new即可。

Resource Transfer

到目前为止,我们所讨论的一直是生命周期在一个单独的作用域内的资源。现在我们要解决一个困难的问题————如何在不同的作用域间安全的传递资源。这一问题在当你处理容器的时候会变得十分明显。你可以动态的创建一串对象,将它们存放至一个容器中,然后将它们取出,并且在最终安排它们。为了能够让这安全的工作————没有泄露————对象需要改变其所有者。

这个问题的一个非常显而易见的解决方法是使用Smart Pointer,无论是在加入容器前还是还找到它们以后。这是他如何运作的,你加入Release方法到Smart Pointer中:

1
2
3
4
5
6
7
template <class T>
T * SmartPointer<T>::Release ()
{
T * pTmp = _p;
_p = 0;
return pTmp;
}

注意在Release调用以后,Smart Pointer就不再是对象的所有者了————它内部的指针指向空。现在,调用了Release都必须是一个负责的人并且迅速隐藏返回的指针到新的所有者对象中。在我们的例子中,容器调用了Release,比如这个Stack的例子:

1
2
3
4
5
6
void Stack::Push (SmartPointer <Item> & item) throw (char *)
{
if (_top == maxStack)
throw "Stack overflow";
_arr [_top++] = item.Release ();
};

同样的,你也可以再你的代码中用加强Release的可靠性。

相应的Pop方法要做些什么呢?他应该释放了资源并祈祷调用它的是一个负责的人而且立即作一个资源传递它到一个Smart Pointer?这听起来并不好。

Strong Pointers

资源管理在内容索引(Windows NT Server上的一部分,现在是Windows 2000)上工作,并且,我对这十分满意。然后我开始想……这一方法是在这样一个完整的系统中形成的,如果可以把它内建入语言的本身岂不是一件非常好?我提出了强指针(Strong Pointer)和弱指针(Weak Pointer)。一个Strong Pointer会在许多地方和我们这个SmartPointer相似—它在超出它的作用域后会清除他所指向的对象。资源传递会以强指针赋值的形式进行。也可以有Weak Pointer存在,它们用来访问对象而不需要所有对象—比如可赋值的引用。

任何指针都必须声明为Strong或者Weak,并且语言应该来关注类型转换的规定。例如,你不可以将Weak Pointer传递到一个需要Strong Pointer的地方,但是相反却可以。Push方法可以接受一个Strong Pointer并且将它转移到Stack中的Strong Pointer的序列中。Pop方法将会返回一个Strong Pointer。把Strong Pointer的引入语言将会使垃圾回收成为历史。

这里还有一个小问题—修改C++标准几乎和竞选美国总统一样容易。当我将我的注意告诉给Bjarne Stroutrup的时候,他看我的眼神好像是我刚刚要向他借一千美元一样。

然后我突然想到一个念头。我可以自己实现Strong Pointers。毕竟,它们都很想Smart Pointers。给它们一个拷贝构造函数并重载赋值操作符并不是一个大问题。事实上,这正是标准库中的auto_ptr有的。重要的是对这些操作给出一个资源转移的语法,但是这也不是很难。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <class T>
SmartPointer<T>::SmartPointer (SmartPointer<T> & ptr)
{
_p = ptr.Release ();
}
template <class T>
void SmartPointer<T>::operator = (SmartPointer<T> & ptr)
{
if (_p != ptr._p)
{
delete _p;
_p = ptr.Release ();
}
}

使这整个想法迅速成功的原因之一是我可以以值方式传递这种封装指针!我有了我的蛋糕,并且也可以吃了。看这个Stack的新的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Stack
{
enum { maxStack = 3 };
public:
Stack ()
: _top (0)
{}
void Push (SmartPointer<Item> & item) throw (char *)
{
if (_top >= maxStack)
throw "Stack overflow";
_arr [_top++] = item;
}
SmartPointer<Item> Pop ()
{
if (_top == 0)
return SmartPointer<Item> ();
return _arr [--_top];
}
private
int _top;
SmartPointer<Item> _arr [maxStack];
};

Pop方法强制客户将其返回值赋给一个Strong Pointer,SmartPointer。任何试图将他对一个普通指针的赋值都会产生一个编译期错误,因为类型不匹配。此外,因为Pop以值方式返回一个Strong Pointer(在Pop的声明时SmartPointer后面没有&符号),编译器在return时自动进行了一个资源转换。他调用了operator =来从数组中提取一个Item,拷贝构造函数将他传递给调用者。调用者最后拥有了指向Pop赋值的Strong Pointer指向的一个Item。

我马上意识到我已经在某些东西之上了。我开始用了新的方法重写原来的代码。

Parser

我过去有一个老的算术操作分析器,是用老的资源管理的技术写的。分析器的作用是在分析树中生成节点,节点是动态分配的。例如分析器的Expression方法生成一个表达式节点。我没有时间用Strong Pointer去重写这个分析器。我令Expression、Term和Factor方法以传值的方式将Strong Pointer返回到Node中。看下面的Expression方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SmartPointer<Node> Parser::Expression()
{
// Parse a term
SmartPointer<Node> pNode = Term ();
EToken token = _scanner.Token();
if ( token == tPlus || token == tMinus )
{
// Expr := Term { ('+' | '-') Term }
SmartPointer<MultiNode> pMultiNode = new SumNode (pNode);
do
{
_scanner.Accept();
SmartPointer<Node> pRight = Term ();
pMultiNode->AddChild (pRight, (token == tPlus));
token = _scanner.Token();
} while (token == tPlus || token == tMinus);
pNode = up_cast<Node, MultiNode> (pMultiNode);
}
// otherwise Expr := Term
return pNode; // by value!
}

最开始,Term方法被调用。他传值返回一个指向Node的Strong Pointer并且立刻把它保存到我们自己的Strong Pointer,pNode中。如果下一个符号不是加号或者减号,我们就简单的把这个SmartPointer以值返回,这样就释放了Node的所有权。另外一方面,如果下一个符号是加号或者减号,我们创建一个新的SumMode并且立刻(直接传递)将它储存到MultiNode的一个Strong Pointer中。这里,SumNode是从MultiMode中继承而来的,而MulitNode是从Node继承而来的。原来的Node的所有权转给了SumNode。

只要是他们在被加号和减号分开的时候,我们就不断的创建terms,我们将这些term转移到我们的MultiNode中,同时MultiNode得到了所有权。最后,我们将指向MultiNode的Strong Pointer向上映射为指向Mode的Strong Pointer,并且将他返回调用着。

我们需要对Strong Pointers进行显式的向上映射,即使指针是被隐式的封装。例如,一个MultiNode是一个Node,但是相同的is-a关系在SmartPointer和SmartPointer之间并不存在,因为它们是分离的类(模板实例)并不存在继承关系。up-cast模板是像下面这样定义的:

1
2
3
4
5
template<class To, class From>
inline SmartPointer<To> up_cast (SmartPointer<From> & from)
{
return SmartPointer<To> (from.Release ());
}

如果你的编译器支持新加入标准的成员模板(member template)的话,你可以为SmartPointer定义一个新的构造函数用来从接受一个class U。

1
2
3
4
template <class T>
template <class U> SmartPointer<T>::SmartPointer (SPrt<U> & uptr)
: _p (uptr.Release ())
{}

这里的这个花招是模板在U不是T的子类的时候就不会编译成功(换句话说,只在U is-a T的时候才会编译)。这是因为uptr的缘故。Release()方法返回一个指向U的指针,并被赋值为_p,一个指向T的指针。所以如果U不是一个T的话,赋值会导致一个编译时刻错误。

1
std::auto_ptr

后来我意识到在STL中的auto_ptr模板,就是我的Strong Pointer。在那时候还有许多的实现差异(auto_ptr的Release方法并不将内部的指针清零—你的编译器的库很可能用的就是这种陈旧的实现),但是最后在标准被广泛接受之前都被解决了。

Transfer Semantics

目前为止,我们一直在讨论在C++程序中资源管理的方法。宗旨是将资源封装到一些轻量级的类中,并由类负责它们的释放。特别的是,所有用new操作符分配的资源都会被储存并传递进Strong Pointer(标准库中的auto_ptr)的内部。

这里的关键词是传递(passing)。一个容器可以通过传值返回一个StrongPointer来安全的释放资源。容器的客户只能够通过提供一个相应的Strong Pointer来保存这个资源。任何一个将结果赋给一个”裸”指针的做法都立即会被编译器发现。

1
2
auto_ptr<Item> item = stack.Pop (); // ok
Item * p = stack.Pop (); // Error! Type mismatch.

以传值方式被传递的对象有value semantics 或者称为 copy semantics。Strong Pointers是以值方式传递的—但是我们能说它们有copy semantics吗?不是这样的!它们所指向的对象肯定没有被拷贝过。事实上,传递过后,源auto_ptr不在访问原有的对象,并且目标auto_ptr成为了对象的唯一拥有者(但是往往auto_ptr的旧的实现即使在释放后仍然保持着对对象的所有权)。自然而然的我们可以将这种新的行为称作Transfer Semantics。

拷贝构造函数(copy construcor)和赋值操作符定义了auto_ptr的Transfer Semantics,它们用了非const的auto_ptr引用作为它们的参数。

1
2
auto_ptr (auto_ptr<T> & ptr);
auto_ptr & operator = (auto_ptr<T> & ptr);

这是因为它们确实改变了他们的源—剥夺了对资源的所有权。

通过定义相应的拷贝构造函数和重载赋值操作符,你可以将Transfer Semantics加入到许多对象中。例如,许多Windows中的资源,比如动态建立的菜单或者位图,可以用有Transfer Semantics的类来封装。

Strong Vectors

标准库只在auto_ptr中支持资源管理。甚至连最简单的容器也不支持ownership semantics。你可能想将auto_ptr和标准容器组合到一起可能会管用,但是并不是这样的。例如,你可能会这样做,但是会发现你不能够用标准的方法来进行索引。

1
vector< auto_ptr<Item> > autoVector;

这种建造不会编译成功;

1
Item * item = autoVector [0];

另一方面,这会导致一个从autoVect到auto_ptr的所有权转换:

1
auto_ptr<Item> item = autoVector [0];

我们没有选择,只能够构造我们自己的Strong Vector。最小的接口应该如下:

1
2
3
4
5
6
7
8
9
10
11
12
template <class T>
class auto_vector
{
public:
explicit auto_vector (size_t capacity = 0);
T const * operator [] (size_t i) const;
T * operator [] (size_t i);
void assign (size_t i, auto_ptr<T> & p);
void assign_direct (size_t i, T * p);
void push_back (auto_ptr<T> & p);
auto_ptr<T> pop_back ();
};

你也许会发现一个非常防御性的设计态度。我决定不提供一个对vector的左值索引的访问,取而代之,如果你想设定(set)一个值的话,你必须用assign或者assign_direct方法。我的观点是,资源管理不应该被忽视,同时,也不应该在所有的地方滥用。在我的经验里,一个strong vector经常被许多push_back方法充斥着。

Strong vector最好用一个动态的Strong Pointers的数组来实现:

1
2
3
4
5
6
7
8
9
template <class T>
class auto_vector
{
private
void grow (size_t reqCapacity);
auto_ptr<T> *_arr;
size_t _capacity;
size_t _end;
};

grow方法申请了一个很大的auto_ptr的数组,将所有的东西从老的书组类转移出来,在其中交换,并且删除原来的数组。

auto_vector的其他实现都是十分直接的,因为所有资源管理的复杂度都在auto_ptr中。例如,assign方法简单的利用了重载的赋值操作符来删除原有的对象并转移资源到新的对象:

1
2
3
4
void assign (size_t i, auto_ptr<T> & p)
{
_arr [i] = p;
}

我已经讨论了push_back和pop_back方法。push_back方法传值返回一个auto_ptr,因为它将所有权从auto_vector转换到auto_ptr中。

对auto_vector的索引访问是借助auto_ptr的get方法来实现的,get简单的返回一个内部指针。

1
2
3
4
T * operator [] (size_t i)
{
return _arr [i].get ();
}

没有容器可以没有iterator。我们需要一个iterator让auto_vector看起来更像一个普通的指针向量。特别是,当我们废弃iterator的时候,我们需要的是一个指针而不是auto_ptr。我们不希望一个auto_vector的iterator在无意中进行资源转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<class T>
class auto_iterator: public
iterator<random_access_iterator_tag, T *>
{
public:
auto_iterator () : _pp (0) {}
auto_iterator (auto_ptr<T> * pp) : _pp (pp) {}
bool operator != (auto_iterator<T> const & it) const
{ return it._pp != _pp; }
auto_iterator const & operator++ (int) { return _pp++; }
auto_iterator operator++ () { return ++_pp; }
T * operator * () { return _pp->get (); }
private
auto_ptr<T> * _pp;
};

我们给auto_vect提供了标准的begin和end方法来找回iterator:

1
2
3
4
5
6
7
class auto_vector
{
public:
typedef auto_iterator<T> iterator;
iterator begin () { return _arr; }
iterator end () { return _arr + _end; }
};

你也许会问我们是否要利用资源管理重新实现每一个标准的容器?幸运的是,不;事实是strongvector解决了大部分所有权的需求。当你把你的对象都安全的放置到一个strong vector中,你可以用所有其它的容器来重新安排(weak)pointer。

设想,例如,你需要对一些动态分配的对象排序的时候。你将它们的指针保存到一个strongvector中。然后你用一个标准的vector来保存从strong vector中获得的weak指针。你可以用标准的算法对这个vector进行排序。这种中介vector叫做permutation vector。相似的,你也可以用标准的maps, priority queues, heaps, hash tables等等。

Code Inspection

如果你严格遵照资源管理的条款,你就不会再资源泄露或者两次删除的地方遇到麻烦。你也降低了访问野指针的几率。同样的,遵循原有的规则,用delete删除用new申请的德指针,不要两次删除一个指针。你也不会遇到麻烦。但是,那个是更好的注意呢?

这两个方法有一个很大的不同点。就是和寻找传统方法的bug相比,找到违反资源管理的规定要容易的多。后者仅需要一个代码检测或者一个运行测试,而前者则在代码中隐藏得很深,并需要很深的检查。

设想你要做一段传统的代码的内存泄露检查。第一件事,你要做的就是grep所有在代码中出现的new,你需要找出被分配空间地指针都作了什么。你需要确定导致删除这个指针的所有的执行路径。你需要检查break语句,过程返回,异常。原有的指针可能赋给另一个指针,你对这个指针也要做相同的事。

相比之下,对于一段用资源管理技术实现的代码。你也用grep检查所有的new,但是这次你只需要检查邻近的调用:

● 这是一个直接的Strong Pointer转换,还是我们在一个构造函数的函数体中?

● 调用的返回知是否立即保存到对象中,构造函数中是否有可以产生异常的代码。?

● 如果这样的话析构函数中时候有delete?

下一步,你需要用grep查找所有的release方法,并实施相同的检查。

不同点是需要检查、理解单个执行路径和只需要做一些本地的检验。这难道不是提醒你非结构化的和结构化的程序设计的不同吗?原理上,你可以认为你可以应付goto,并且跟踪所有的可能分支。另一方面,你可以将你的怀疑本地化为一段代码。本地化在两种情况下都是关键所在。

在资源管理中的错误模式也比较容易调试。最常见的bug是试图访问一个释放过的strong pointer。这将导致一个错误,并且很容易跟踪。

共享的所有权

为每一个程序中的资源都找出或者指定一个所有者是一件很容易的事情吗?答案是出乎意料的,是!如果你发现了一些问题,这可能说明你的设计上存在问题。还有另一种情况就是共享所有权是最好的甚至是唯一的选择。

共享的责任分配给被共享的对象和它的客户(client)。一个共享资源必须为它的所有者保持一个引用计数。另一方面,所有者再释放资源的时候必须通报共享对象。最后一个释放资源的需要在最后负责free的工作。

最简单的共享的实现是共享对象继承引用计数的类RefCounted:

1
2
3
4
5
6
7
8
9
10
class RefCounted
{
public:
RefCounted () : _count (1) {}
int GetRefCount () const { return _count; }
void IncRefCount () { _count++; }
int DecRefCount () { return --_count; }
private
int _count;
};

按照资源管理,一个引用计数是一种资源。如果你遵守它,你需要释放它。当你意识到这一事实的时候,剩下的就变得简单了。简单的遵循规则—再构造函数中获得引用计数,在析构函数中释放。甚至有一个RefCounted的smart pointer等价物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <class T>
class RefPtr
{
public:
RefPtr (T * p) : _p (p) {}
RefPtr (RefPtr<T> & p)
{
_p = p._p;
_p->IncRefCount ();
}
~RefPtr ()
{
if (_p->DecRefCount () == 0)
delete _p;
}
private
T * _p;
};

注意模板中的T不比成为RefCounted的后代,但是它必须有IncRefCount和DecRefCount的方法。当然,一个便于使用的RefPtr需要有一个重载的指针访问操作符。在RefPtr中加入转换语义学(transfer semantics)是读者的工作。

所有权网络

链表是资源管理分析中的一个很有意思的例子。如果你选择表成为链(link)的所有者的话,你会陷入实现递归的所有权。每一个link都是它的继承者的所有者,并且,相应的,余下的链表的所有者。下面是用smart pointer实现的一个表单元:

1
2
3
4
5
6
class Link
{
// ...
private
auto_ptr<Link> _next;
};

最好的方法是,将连接控制封装到一个弄构进行资源转换的类中。

对于双链表呢?安全的做法是指明一个方向,如forward:

1
2
3
4
5
6
7
class DoubleLink
{
// ...
private
DoubleLink *_prev;
auto_ptr<DoubleLink> _next;
};

注意不要创建环形链表。

这给我们带来了另外一个有趣的问题—资源管理可以处理环形的所有权吗?它可以,用一个mark-and-sweep的算法。这里是实现这种方法的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class T>
class CyclPtr
{
public:
CyclPtr (T * p)
:_p (p), _isBeingDeleted (false)
{}
~CyclPtr ()
{
_isBeingDeleted = true;
if (!_p->IsBeingDeleted ())
delete _p;
}
void Set (T * p)
{
_p = p;
}
bool IsBeingDeleted () const { return _isBeingDeleted; }
private
T * _p;
bool _isBeingDeleted;
};

注意我们需要用class T来实现方法IsBeingDeleted,就像从CyclPtr继承。对特殊的所有权网络普通化是十分直接的。

将原有代码转换为资源管理代码

如果你是一个经验丰富的程序员,你一定会知道找资源的bug是一件浪费时间的痛苦的经历。我不必说服你和你的团队花费一点时间来熟悉资源管理是十分值得的。你可以立即开始用这个方法,无论你是在开始一个新项目或者是在一个项目的中期。转换不必立即全部完成。下面是步骤。

  1. 首先,在你的工程中建立基本的Strong Pointer。然后通过查找代码中的new来开始封装裸指针。

  2. 最先封装的是在过程中定义的临时指针。简单的将它们替换为auto_ptr并且删除相应的delete。如果一个指针在过程中没有被删除而是被返回,用auto_ptr替换并在返回前调用release方法。在你做第二次传递的时候,你需要处理对release的调用。注意,即使是在这点,你的代码也可能更加”精力充沛”—你会移出代码中潜在的资源泄漏问题。

  3. 下面是指向资源的裸指针。确保它们被独立的封装到auto_ptr中,或者在构造函数中分配在析构函数中释放。如果你有传递所有权的行为的话,需要调用release方法。如果你有容器所有对象,用Strong Pointers重新实现它们。

  4. 接下来,找到所有对release的方法调用并且尽力清除所有,如果一个release调用返回一个指针,将它修改传值返回一个auto_ptr。

  5. 重复着一过程,直到最后所有new和release的调用都在构造函数或者资源转换的时候发生。这样,你在你的代码中处理了资源泄漏的问题。对其他资源进行相似的操作。

  6. 你会发现资源管理清除了许多错误和异常处理带来的复杂性。不仅仅你的代码会变得精力充沛,它也会变得简单并容易维护。

内存泄漏

C++中动态内存分配引发问题的解决方案

假设我们要开发一个String类,它可以方便地处理字符串数据。我们可以在类中声明一个数组,考虑到有时候字符串极长,我们可以把数组大小设为200,但一般的情况下又不需要这么多的空间,这样是浪费了内存。对了,我们可以使用new操作符,这样是十分灵活的,但在类中就会出现许多意想不到的问题,本文就是针对这一现象而写的。现在,我们先来开发一个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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/* String.h */
#ifndef STRING_H_
#define STRING_H_
class String
{
private:
char * str; //存储数据
int len; //字符串长度
public:
String(const char * s); //构造函数
String(); // 默认构造函数
~String(); // 析构函数
friend ostream & operator<<(ostream & os,const String& st);
};
#endif

/*String.cpp*/
#include <iostream>
#include <cstring>
#include "String.h"
using namespace std;
String::String(const char * s)
{
len = strlen(s);
str = new char[len + 1];
strcpy(str, s);
}//拷贝数据
String::String()
{
len =0;
str = new char[len+1];
str[0]='"0';
}
String::~String()
{
cout<<"这个字符串将被删除:"<<str<<'"n';//为了方便观察结果,特留此行代码。
delete [] str;
}
ostream & operator<<(ostream & os, const String & st)
{
os << st.str;
return os;
}

/*test_right.cpp*/
#include <iostream>
#include <stdlib.h>
#include "String.h"
using namespace std;
int main()
{
String temp("天极网");
cout<<temp<<'"n';
system("PAUSE");
return 0;
}

运行结果:

1
2
3
天极网

按任意键继续. . .

大家可以看到,以上程序十分正确,而且也是十分有用的。可是,我们不能被表面现象所迷惑!下面,请大家用test_String.cpp文件替换test_right.cpp文件进行编译,看看结果。有的编译器可能就是根本不能进行编译!

test_String.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
#include <iostream>
#include <stdlib.h>
#include "String.h"
using namespace std;
void show_right(const String&);
void show_String(const String);//注意,参数非引用,而是按值传递。
int main()
{
String test1("第一个范例。");
String test2("第二个范例。");
String test3("第三个范例。");
String test4("第四个范例。");
cout<<"下面分别输入三个范例";
cout<<test1<<endl;
cout<<test2<<endl;
cout<<test3<<endl;
String* String1=new String(test1);
cout<<*String1<<endl;
delete String1;
cout<<test1<<endl; //在Dev-cpp上没有任何反应。
cout<<"使用正确的函数:"<<endl;
show_right(test2);
cout<<test2<<endl;
cout<<"使用错误的函数:"<<endl;
show_String(test2);
cout<<test2<<endl; //这一段代码出现严重的错误!
String String2(test3);
cout<<"String2: "<<String2<<endl;
String String3;
String3=test4;
cout<<"String3: "<<String3<<endl;
cout<<"下面,程序结束,析构函数将被调用。"<<endl;
return 0;
}

void show_right(const String& a)
{
cout<<a<<endl;
}
void show_String(const String a)
{
cout<<a<<endl;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
下面分别输入三个范例:
第一个范例。
第二个范例。
第三个范例。
第一个范例。
这个字符串将被删除:第一个范例。
使用正确的函数:
第二个范例。
第二个范例。
使用错误的函数:
第二个范例。
这个字符串将被删除:第二个范例。
这个字符串将被删除:?=
?=
String2: 第三个范例。
String3: 第四个范例。
下面,程序结束,析构函数将被调用。
这个字符串将被删除:第四个范例。
这个字符串将被删除:第三个范例。
这个字符串将被删除:?=
这个字符串将被删除:x =
这个字符串将被删除:?=
这个字符串将被删除:

现在,请大家自己试试运行结果,或许会更加惨不忍睹呢!下面,我为大家一一分析原因。

首先,大家要知道,C++类有以下这些极为重要的函数:

一:复制构造函数。

二:赋值函数。

我们先来讲复制构造函数。什么是复制构造函数呢?比如,我们可以写下这样的代码:String test1(test2);这是进行初始化。我们知道,初始化对象要用构造函数。可这儿呢?按理说,应该有声明为这样的构造函数:String(const String &);可是,我们并没有定义这个构造函数呀?答案是,C++提供了默认的复制构造函数,问题也就出在这儿。

(1):什么时候会调用复制构造函数呢?(以String类为例。)

在我们提供这样的代码:String test1(test2)时,它会被调用;当函数的参数列表为按值传递,也就是没有用引用和指针作为类型时,如:void show_String(const String),它会被调用。其实,还有一些情况,但在这儿就不列举了。

(2):它是什么样的函数。

它的作用就是把两个类进行复制。拿String类为例,C++提供的默认复制构造函数是这样的:

1
2
3
4
5
String(const String& a)
{
str=a.str;
len=a.len;
}

在平时,这样并不会有任何的问题出现,但我们用了new操作符,涉及到了动态内存分配,我们就不得不谈谈浅复制和深复制了。以上的函数就是实行的浅复制,它只是复制了指针,而并没有复制指针指向的数据,可谓一点儿用也没有。打个比方吧!就像一个朋友让你把一个程序通过网络发给他,而你大大咧咧地把快捷方式发给了他,有什么用处呢?我们来具体谈谈:

假如,A对象中存储了这样的字符串:”C++”。它的地址为2000。现在,我们把A对象赋给B对象:String B=A。现在,A和B对象的str指针均指向2000地址。看似可以使用,但如果B对象的析构函数被调用时,则地址2000处的字符串”C++”已经被从内存中抹去,而A对象仍然指向地址2000。这时,如果我们写下这样的代码:cout<<A<<endl;或是等待程序结束,A对象的析构函数被调用时,A对象的数据能否显示出来呢?只会是乱码。而且,程序还会这样做:连续对地址2000处使用两次delete操作符,这样的后果是十分严重的!

本例中,有这样的代码:

1
2
3
4
5
String* String1=new String(test1);

cout<<*String1<<endl;

delete String1;

假设test1中str指向的地址为2000,而String中str指针同样指向地址2000,我们删除了2000处的数据,而test1对象呢?已经被破坏了。大家从运行结果上可以看到,我们使用cout<<test1时,一点反应也没有。而在test1的析构函数被调用时,显示是这样:”这个字符串将被删除:”。

再看看这段代码:

1
2
3
cout<<"使用错误的函数:"<<endl;
show_String(test2);
cout<<test2<<endl;//这一段代码出现严重的错误!

show_String函数的参数列表void show_String(const String a)是按值传递的,所以,我们相当于执行了这样的代码:String a=test2;函数执行完毕,由于生存周期的缘故,对象a被析构函数删除,我们马上就可以看到错误的显示结果了:这个字符串将被删除:?=。当然,test2也被破坏了。解决的办法很简单,当然是手工定义一个复制构造函数喽!人力可以胜天!

1
2
3
4
5
6
String::String(const String& a)
{
len=a.len;
str=new char(len+1);
strcpy(str,a.str);
}

我们执行的是深复制。这个函数的功能是这样的:假设对象A中的str指针指向地址2000,内容为”I am a C++ Boy!”。我们执行代码String B=A时,我们先开辟出一块内存,假设为3000。我们用strcpy函数将地址2000的内容拷贝到地址3000中,再将对象B的str指针指向地址3000。这样,就互不干扰了。

大家把这个函数加入程序中,问题就解决了大半,但还没有完全解决,问题在赋值函数上。我们的程序中有这样的段代码:

1
2
String String3;
String3=test4;

经过我前面的讲解,大家应该也会对这段代码进行寻根摸底:凭什么可以这样做:String3=test4???原因是,C++为了用户的方便,提供的这样的一个操作符重载函数:operator=。所以,我们可以这样做。大家应该猜得到,它同样是执行了浅复制,出了同样的毛病。比如,执行了这段代码后,析构函数开始大展神威。由于这些变量是后进先出的,所以最后的String3变量先被删除:这个字符串将被删除:第四个范例。很正常。最后,删除到test4的时候,问题来了:这个字符串将被删除:?=。原因我不用赘述了,只是这个赋值函数怎么写,还有一点儿学问呢!大家请看:

平时,我们可以写这样的代码:x=y=z。(均为整型变量。)而在类对象中,我们同样要这样,因为这很方便。而对象A=B=C就是A.operator=(B.operator=(c))。而这个operator=函数的参数列表应该是:const String& a,所以,大家不难推出,要实现这样的功能,返回值也要是String&,这样才能实现A=B=C。我们先来写写看:

1
2
3
4
5
6
7
8
String& String::operator=(const String& a)
{
delete [] str;//先删除自身的数据
len=a.len;
str=new char[len+1];
strcpy(str,a.str);//此三行为进行拷贝
return *this;//返回自身的引用
}

是不是这样就行了呢?我们假如写出了这种代码:A=A,那么大家看看,岂不是把A对象的数据给删除了吗?这样可谓引发一系列的错误。所以,我们还要检查是否为自身赋值。只比较两对象的数据是不行了,因为两个对象的数据很有可能相同。我们应该比较地址。以下是完好的赋值函数:

1
2
3
4
5
6
7
8
9
10
String& String::operator=(const String& a)
{
if(this==&a)
return *this;
delete [] str;
len=a.len;
str=new char[len+1];
strcpy(str,a.str);
return *this;
}

把这些代码加入程序,问题就完全解决,下面是运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
下面分别输入三个范例:
第一个范例
第二个范例
第三个范例
第一个范例
这个字符串将被删除:第一个范例。
第一个范例
使用正确的函数:
第二个范例。
第二个范例。
使用错误的函数:
第二个范例。
这个字符串将被删除:第二个范例。
第二个范例。
String2: 第三个范例。
String3: 第四个范例。
下面,程序结束,析构函数将被调用。
这个字符串将被删除:第四个范例。
这个字符串将被删除:第三个范例。
这个字符串将被删除:第四个范例。
这个字符串将被删除:第三个范例。
这个字符串将被删除:第二个范例。
这个字符串将被删除:第一个范例。

如何对付内存泄漏?

写出那些不会导致任何内存泄漏的代码。很明显,当你的代码中到处充满了new 操作、delete操作和指针运算的话,你将会在某个地方搞晕了头,导致内存泄漏,指针引用错误,以及诸如此类的问题。这和你如何小心地对待内存分配工作其实完全没有关系:代码的复杂性最终总是会超过你能够付出的时间和努力。于是随后产生了一些成功的技巧,它们依赖于将内存分配(allocations)与重新分配(deallocation)工作隐藏在易于管理的类型之后。标准容器(standard containers)是一个优秀的例子。它们不是通过你而是自己为元素管理内存,从而避免了产生糟糕的结果。想象一下,没有string和vector的帮助,写出这个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<vector>
#include<string>
#include<iostream>
#include<algorithm>
using namespace std;
int main() // small program messing around with strings
{
cout << "enter some whitespace-separated words:"n";
vector<string> v;
string s;
while (cin>>s)
v.push_back(s);
sort(v.begin(),v.end());
string cat;
typedef vector<string>::const_iterator Iter;
for (Iter p = v.begin(); p!=v.end(); ++p)
cat += *p+"+";
cout << cat << '"n';
}

你有多少机会在第一次就得到正确的结果?你又怎么知道你没有导致内存泄漏呢?

注意,没有出现显式的内存管理,宏,造型,溢出检查,显式的长度限制,以及指针。通过使用函数对象和标准算法(standard algorithm),我可以避免使用指针————例如使用迭代子(iterator),不过对于一个这么小的程序来说有点小题大作了。

这些技巧并不完美,要系统化地使用它们也并不总是那么容易。但是,应用它们产生了惊人的差异,而且通过减少显式的内存分配与重新分配的次数,你甚至可以使余下的例子更加容易被跟踪。早在1981年,我就指出,通过将我必须显式地跟踪的对象的数量从几万个减少到几打,为了使程序正确运行而付出的努力从可怕的苦工,变成了应付一些可管理的对象,甚至更加简单了。

如果你的程序还没有包含将显式内存管理减少到最小限度的库,那么要让你程序完成和正确运行的话,最快的途径也许就是先建立一个这样的库。

模板和标准库实现了容器、资源句柄以及诸如此类的东西,更早的使用甚至在多年以前。异常的使用使之更加完善。

如果你实在不能将内存分配/重新分配的操作隐藏到你需要的对象中时,你可以使用资源句柄(resource handle),以将内存泄漏的可能性降至最低。这里有个例子:我需要通过一个函数,在空闲内存中建立一个对象并返回它。这时候可能忘记释放这个对象。毕竟,我们不能说,仅仅关注当这个指针要被释放的时候,谁将负责去做。使用资源句柄,这里用了标准库中的auto_ptr,使需要为之负责的地方变得明确了。

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<memory>
#include<iostream>
using namespace std;
struct S {
S() { cout << "make an S"n"; }
~S() { cout << "destroy an S"n"; }
S(const S&) { cout << "copy initialize an S"n"; }
S& operator=(const S&) { cout << "copy assign an S"n"; }
};
S* f()
{
return new S; // 谁该负责释放这个S?
};
auto_ptr<S> g()
{
return auto_ptr<S>(new S); // 显式传递负责释放这个S
}
int main()
{
cout << "start main"n";
S* p = f();
cout << "after f() before g()"n";
// S* q = g(); // 将被编译器捕捉
auto_ptr<S> q = g();
cout << "exit main"n";
// *p产生了内存泄漏
// *q被自动释放
}

在更一般的意义上考虑资源,而不仅仅是内存。

如果在你的环境中不能系统地应用这些技巧(例如,你必须使用别的地方的代码,或者你的程序的另一部分简直是原始人类(译注:原文是Neanderthals,尼安德特人,旧石器时代广泛分布在欧洲的猿人)写的,如此等等),那么注意使用一个内存泄漏检测器作为开发过程的一部分,或者插入一个垃圾收集器(garbage collector)。

浅谈C/C++内存泄漏及其检测工具

对于一个c/c++程序员来说,内存泄漏是一个常见的也是令人头疼的问题。已经有许多技术被研究出来以应对这个问题,比如Smart Pointer,Garbage Collection等。Smart Pointer技术比较成熟,STL中已经包含支持Smart Pointer的class,但是它的使用似乎并不广泛,而且它也不能解决所有的问题;Garbage Collection技术在Java中已经比较成熟,但是在c/c++领域的发展并不顺畅,虽然很早就有人思考在C++中也加入GC的支持。现实世界就是这样的,作为一个c/c++程序员,内存泄漏是你心中永远的痛。不过好在现在有许多工具能够帮助我们验证内存泄漏的存在,找出发生问题的代码。

内存泄漏的定义

一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定),使用完后必须显示释放的内存。应用程序一般使用malloc,realloc,new等函数从堆中分配到一块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。以下这段小程序演示了堆内存发生泄漏的情形:

1
2
3
4
5
6
7
8
9
10
void MyFunction(int nSize)
{
char* p= new char[nSize];
if( !GetStringFrom( p, nSize ) ){
MessageBox(“Error”);
return;
}
…//using the string pointed by p;
delete p;
}

当函数GetStringFrom()返回零的时候,指针p指向的内存就不会被释放。这是一种常见的发生内存泄漏的情形。程序在入口处分配内存,在出口处释放内存,但是c函数可以在任何地方退出,所以一旦有某个出口处没有释放应该释放的内存,就会发生内存泄漏。

广义的说,内存泄漏不仅仅包含堆内存的泄漏,还包含系统资源的泄漏(resource leak),比如核心态HANDLE,GDI Object,SOCKET, Interface等,从根本上说这些由操作系统分配的对象也消耗内存,如果这些对象发生泄漏最终也会导致内存的泄漏。而且,某些对象消耗的是核心态内存,这些对象严重泄漏时会导致整个操作系统不稳定。所以相比之下,系统资源的泄漏比堆内存的泄漏更为严重。

GDI Object的泄漏是一种常见的资源泄漏:

1
2
3
4
5
6
7
8
9
10
11
12
13
void CMyView::OnPaint( CDC* pDC )
{
CBitmap bmp;
CBitmap* pOldBmp;
bmp.LoadBitmap(IDB_MYBMP);
pOldBmp = pDC->SelectObject( &bmp );
...
if( Something() ){
return;
}
pDC->SelectObject( pOldBmp );
return;
}

当函数Something()返回非零的时候,程序在退出前没有把pOldBmp选回pDC中,这会导致pOldBmp指向的HBITMAP对象发生泄漏。这个程序如果长时间的运行,可能会导致整个系统花屏。这种问题在Win9x下比较容易暴露出来,因为Win9x的GDI堆比Win2k或NT的要小很多。

内存泄漏的发生方式

以发生的方式来分类,内存泄漏可以分为4类:

  1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。比如例二,如果Something()函数一直返回True,那么pOldBmp指向的HBITMAP对象总是发生泄漏。

  2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。比如例二,如果Something()函数只有在特定环境下才返回True,那么pOldBmp指向的HBITMAP对象并不总是发生泄漏。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。

  3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,但是因为这个类是一个Singleton,所以内存泄漏只会发生一次。另一个例子:

1
2
3
4
5
6
7
8
char* g_lpszFileName = NULL;
void SetFileName( const char* lpcszFileName )
{
if( g_lpszFileName ){
free( g_lpszFileName );
}
g_lpszFileName = strdup( lpcszFileName );
}

如果程序在结束的时候没有释放g_lpszFileName指向的字符串,那么,即使多次调用SetFileName(),总会有一块内存,而且仅有一块内存发生泄漏。

  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
class Connection
{
public:
Connection( SOCKET s);
~Connection();
private:
SOCKET _socket;
};
class ConnectionManager
{
public:
ConnectionManager(){}
~ConnectionManager(){
list::iterator it;
for( it = _connlist.begin(); it != _connlist.end(); ++it ){
delete (*it);
}
_connlist.clear();
}
void OnClientConnected( SOCKET s ){
Connection* p = new Connection(s);
_connlist.push_back(p);
}
void OnClientDisconnected( Connection* pconn ){
_connlist.remove( pconn );
delete pconn;
}
private:
list _connlist;
};

假设在Client从Server端断开后,Server并没有呼叫OnClientDisconnected()函数,那么代表那次连接的Connection对象就不会被及时的删除(在Server程序退出的时候,所有Connection对象会在ConnectionManager的析构函数里被删除)。当不断的有连接建立、断开时隐式内存泄漏就发生了。

从用户使用程序的角度来看,内存泄漏本身不会产生什么危害,作为一般的用户,根本感觉不到内存泄漏的存在。真正有危害的是内存泄漏的堆积,这会最终消耗尽系统所有的内存。从这个角度来说,一次性内存泄漏并没有什么危害,因为它不会堆积,而隐式内存泄漏危害性则非常大,因为较之于常发性和偶发性内存泄漏它更难被检测到。

检测内存泄漏

检测内存泄漏的关键是要能截获住对分配内存和释放内存的函数的调用。截获住这两个函数,我们就能跟踪每一块内存的生命周期,比如,每当成功的分配一块内存后,就把它的指针加入一个全局的list中;每当释放一块内存,再把它的指针从list中删除。这样,当程序结束的时候,list中剩余的指针就是指向那些没有被释放的内存。这里只是简单的描述了检测内存泄漏的基本原理,详细的算法可以参见Steve Maguire的<>。

如果要检测堆内存的泄漏,那么需要截获住malloc/realloc/free和new/delete就可以了(其实new/delete最终也是用malloc/free的,所以只要截获前面一组即可)。对于其他的泄漏,可以采用类似的方法,截获住相应的分配和释放函数。比如,要检测BSTR的泄漏,就需要截获SysAllocString/SysFreeString;要检测HMENU的泄漏,就需要截获CreateMenu/ DestroyMenu。(有的资源的分配函数有多个,释放函数只有一个,比如,SysAllocStringLen也可以用来分配BSTR,这时就需要截获多个分配函数)

在Windows平台下,检测内存泄漏的工具常用的一般有三种,MS C-Runtime Library内建的检测功能;外挂式的检测工具,诸如,Purify,BoundsChecker等;利用Windows NT自带的Performance Monitor。这三种工具各有优缺点,MS C-Runtime Library虽然功能上较之外挂式的工具要弱,但是它是免费的;Performance Monitor虽然无法标示出发生问题的代码,但是它能检测出隐式的内存泄漏的存在,这是其他两类工具无能为力的地方。

以下我们详细讨论这三种检测工具:

VC下内存泄漏的检测方法

用MFC开发的应用程序,在DEBUG版模式下编译后,都会自动加入内存泄漏的检测代码。在程序结束后,如果发生了内存泄漏,在Debug窗口中会显示出所有发生泄漏的内存块的信息,以下两行显示了一块被泄漏的内存块的信息:

1
2
E:"TestMemLeak"TestDlg.cpp(70) : {59} normal block at 0x00881710, 200 bytes long.
Data: <abcdefghijklmnop> 61 62 63 64 65 66 67 68 69 6A 6B 6C 6D 6E 6F 70

第一行显示该内存块由TestDlg.cpp文件,第70行代码分配,地址在0x00881710,大小为200字节,{59}是指调用内存分配函数的Request Order,关于它的详细信息可以参见MSDN中_CrtSetBreakAlloc()的帮助。第二行显示该内存块前16个字节的内容,尖括号内是以ASCII方式显示,接着的是以16进制方式显示。

一般大家都误以为这些内存泄漏的检测功能是由MFC提供的,其实不然。MFC只是封装和利用了MS C-Runtime Library的Debug Function。非MFC程序也可以利用MS C-Runtime Library的Debug Function加入内存泄漏的检测功能。MS C-Runtime Library在实现malloc/free,strdup等函数时已经内建了内存泄漏的检测功能。

注意观察一下由MFC Application Wizard生成的项目,在每一个cpp文件的头部都有这样一段宏定义:

1
2
3
4
5
6
7
8
9
#ifdef _DEBUG

#define new DEBUG_NEW

#undef THIS_FILE

static char THIS_FILE[] = __FILE__;

#endif

有了这样的定义,在编译DEBUG版时,出现在这个cpp文件中的所有new都被替换成DEBUG_NEW了。那么DEBUG_NEW是什么呢?DEBUG_NEW也是一个宏,以下摘自afx.h,1632行

1
#define DEBUG_NEW new(THIS_FILE, __LINE__)

所以如果有这样一行代码:

1
char* p = new char[200];

经过宏替换就变成了:

1
char* p = new( THIS_FILE, __LINE__)char[200];

根据C++的标准,对于以上的new的使用方法,编译器会去找这样定义的operator new:

1
void* operator new(size_t, LPCSTR, int)

我们在afxmem.cpp 63行找到了一个这样的operator new 的实现

1
2
3
4
5
6
7
8
9
10
11
void* AFX_CDECL operator new(size_t nSize, LPCSTR lpszFileName, int nLine)
{
return ::operator new(nSize, _NORMAL_BLOCK, lpszFileName, nLine);
}
void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)
{
pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);
if (pResult != NULL)
return pResult;
...
}

第二个operator new函数比较长,为了简单期间,我只摘录了部分。很显然最后的内存分配还是通过_malloc_dbg函数实现的,这个函数属于MS C-Runtime Library 的Debug Function。这个函数不但要求传入内存的大小,另外还有文件名和行号两个参数。文件名和行号就是用来记录此次分配是由哪一段代码造成的。如果这块内存在程序结束之前没有被释放,那么这些信息就会输出到Debug窗口里。

这里顺便提一下THIS_FILE,FILE和LINEFILELINE都是编译器定义的宏。当碰到FILE时,编译器会把FILE替换成一个字符串,这个字符串就是当前在编译的文件的路径名。当碰到LINE时,编译器会把LINE替换成一个数字,这个数字就是当前这行代码的行号。在DEBUG_NEW的定义中没有直接使用FILE,而是用了THIS_FILE,其目的是为了减小目标文件的大小。假设在某个cpp文件中有100处使用了new,如果直接使用FILE__,那编译器会产生100个常量字符串,这100个字符串都是cpp文件的路径名,显然十分冗余。如果使用THIS_FILE,编译器只会产生一个常量字符串,那100处new的调用使用的都是指向常量字符串的指针。

再次观察一下由MFC Application Wizard生成的项目,我们会发现在cpp文件中只对new做了映射,如果你在程序中直接使用malloc函数分配内存,调用malloc的文件名和行号是不会被记录下来的。如果这块内存发生了泄漏,MS C-Runtime Library仍然能检测到,但是当输出这块内存块的信息,不会包含分配它的的文件名和行号。

要在非MFC程序中打开内存泄漏的检测功能非常容易,你只要在程序的入口处加入以下几行代码:

1
2
3
int tmpFlag = _CrtSetDbgFlag( _CRTDBG_REPORT_FLAG );
tmpFlag |= _CRTDBG_LEAK_CHECK_DF;
_CrtSetDbgFlag( tmpFlag );

这样,在程序结束的时候,也就是winmain,main或dllmain函数返回之后,如果还有内存块没有释放,它们的信息会被打印到Debug窗口里。

如果你试着创建了一个非MFC应用程序,而且在程序的入口处加入了以上代码,并且故意在程序中不释放某些内存块,你会在Debug窗口里看到以下的信息:

1
2
3
{47} normal block at 0x00C91C90, 200 bytes long.

Data: < > 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

内存泄漏的确检测到了,但是和上面MFC程序的例子相比,缺少了文件名和行号。对于一个比较大的程序,没有这些信息,解决问题将变得十分困难。

为了能够知道泄漏的内存块是在哪里分配的,你需要实现类似MFC的映射功能,把new,maolloc等函数映射到_malloc_dbg函数上。这里我不再赘述,你可以参考MFC的源代码。

由于Debug Function实现在MS C-RuntimeLibrary中,所以它只能检测到堆内存的泄漏,而且只限于malloc,realloc或strdup等分配的内存,而那些系统资源,比如HANDLE,GDI Object,或是不通过C-Runtime Library分配的内存,比如VARIANT,BSTR的泄漏,它是无法检测到的,这是这种检测法的一个重大的局限性。另外,为了能记录内存块是在哪里分配的,源代码必须相应的配合,这在调试一些老的程序非常麻烦,毕竟修改源代码不是一件省心的事,这是这种检测法的另一个局限性。

对于开发一个大型的程序,MS C-Runtime Library提供的检测功能是远远不够的。接下来我们就看看外挂式的检测工具。我用的比较多的是BoundsChecker,一则因为它的功能比较全面,更重要的是它的稳定性。这类工具如果不稳定,反而会忙里添乱。到底是出自鼎鼎大名的NuMega,我用下来基本上没有什么大问题。

使用BoundsChecker检测内存泄漏

BoundsChecker采用一种被称为 Code Injection的技术,来截获对分配内存和释放内存的函数的调用。简单地说,当你的程序开始运行时,BoundsChecker的DLL被自动载入进程的地址空间(这可以通过system-level的Hook实现),然后它会修改进程中对内存分配和释放的函数调用,让这些调用首先转入它的代码,然后再执行原来的代码。BoundsChecker在做这些动作的时,无须修改被调试程序的源代码或工程配置文件,这使得使用它非常的简便、直接。

这里我们以malloc函数为例,截获其他的函数方法与此类似。

需要被截获的函数可能在DLL中,也可能在程序的代码里。比如,如果静态连结C-Runtime Library,那么malloc函数的代码会被连结到程序里。为了截获住对这类函数的调用,BoundsChecker会动态修改这些函数的指令。

以下两段汇编代码,一段没有BoundsChecker介入,另一段则有BoundsChecker的介入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 push ebp
00403C11 mov ebp,esp
130: return _nh_malloc_dbg(nSize, _newmode, _NORMAL_BLOCK, NULL, 0);
00403C13 push 0
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }

以下这一段代码有BoundsChecker介入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
126: _CRTIMP void * __cdecl malloc (
127: size_t nSize
128: )
129: {
00403C10 jmp 01F41EC8
00403C15 push 0
00403C17 push 1
00403C19 mov eax,[__newmode (0042376c)]
00403C1E push eax
00403C1F mov ecx,dword ptr [nSize]
00403C22 push ecx
00403C23 call _nh_malloc_dbg (00403c80)
00403C28 add esp,14h
131: }

当BoundsChecker介入后,函数malloc的前三条汇编指令被替换成一条jmp指令,原来的三条指令被搬到地址01F41EC8处了。当程序进入malloc后先jmp到01F41EC8,执行原来的三条指令,然后就是BoundsChecker的天下了。大致上它会先记录函数的返回地址(函数的返回地址在stack上,所以很容易修改),然后把返回地址指向属于BoundsChecker的代码,接着跳到malloc函数原来的指令,也就是在00403c15的地方。当malloc函数结束的时候,由于返回地址被修改,它会返回到BoundsChecker的代码中,此时BoundsChecker会记录由malloc分配的内存的指针,然后再跳转到到原来的返回地址去。

如果内存分配/释放函数在DLL中,BoundsChecker则采用另一种方法来截获对这些函数的调用。BoundsChecker通过修改程序的DLL Import Table让table中的函数地址指向自己的地址,以达到截获的目的。

截获住这些分配和释放函数,BoundsChecker就能记录被分配的内存或资源的生命周期。接下来的问题是如何与源代码相关,也就是说当BoundsChecker检测到内存泄漏,它如何报告这块内存块是哪段代码分配的。答案是调试信息(Debug Information)。当我们编译一个Debug版的程序时,编译器会把源代码和二进制代码之间的对应关系记录下来,放到一个单独的文件里(.pdb)或者直接连结进目标程序,通过直接读取调试信息就能得到分配某块内存的源代码在哪个文件,哪一行上。使用Code Injection和Debug Information,使BoundsChecker不但能记录呼叫分配函数的源代码的位置,而且还能记录分配时的Call Stack,以及Call Stack上的函数的源代码位置。这在使用像MFC这样的类库时非常有用,以下我用一个例子来说明:

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
void ShowXItemMenu()
{
...
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
...
}
void ShowYItemMenu( )
{
...
CMenu menu;
menu.CreatePopupMenu();
//add menu items.
menu.TrackPropupMenu();
menu.Detach();//this will cause HMENU leak
...
}
BOOL CMenu::CreatePopupMenu()
{
...
hMenu = CreatePopupMenu();
...
}

当调用ShowYItemMenu()时,我们故意造成HMENU的泄漏。但是,对于BoundsChecker来说被泄漏的HMENU是在class CMenu::CreatePopupMenu()中分配的。假设的你的程序有许多地方使用了CMenu的CreatePopupMenu()函数,如CMenu::CreatePopupMenu()造成的,你依然无法确认问题的根结到底在哪里,在ShowXItemMenu()中还是在ShowYItemMenu()中,或者还有其它的地方也使用了CreatePopupMenu()?有了Call Stack的信息,问题就容易了。BoundsChecker会如下报告泄漏的HMENU的信息:

1
2
3
4
5
6
7
8
9
Function
File
Line
CMenu::CreatePopupMenu
E:"8168"vc98"mfc"mfc"include"afxwin1.inl
1009
ShowYItemMenu
E:"testmemleak"mytest.cpp
100

这里省略了其他的函数调用

如此,我们很容易找到发生问题的函数是ShowYItemMenu()。当使用MFC之类的类库编程时,大部分的API调用都被封装在类库的class里,有了Call Stack信息,我们就可以非常容易的追踪到真正发生泄漏的代码。

记录Call Stack信息会使程序的运行变得非常慢,因此默认情况下BoundsChecker不会记录Call Stack信息。可以按照以下的步骤打开记录Call Stack信息的选项开关:

  1. 打开菜单:BoundsChecker|Setting…
  2. 在Error Detection页中,在Error Detection Scheme的List中选择Custom
  3. 在Category的Combox中选择 Pointer and leak error check
  4. 钩上Report Call Stack复选框
  5. 点击Ok

基于Code Injection,BoundsChecker还提供了API Parameter的校验功能,memory over run等功能。这些功能对于程序的开发都非常有益。由于这些内容不属于本文的主题,所以不在此详述了。

尽管BoundsChecker的功能如此强大,但是面对隐式内存泄漏仍然显得苍白无力。所以接下来我们看看如何用Performance Monitor检测内存泄漏。

使用Performance Monitor检测内存泄漏

NT的内核在设计过程中已经加入了系统监视功能,比如CPU的使用率,内存的使用情况,I/O操作的频繁度等都作为一个个Counter,应用程序可以通过读取这些Counter了解整个系统的或者某个进程的运行状况。Performance Monitor就是这样一个应用程序。

为了检测内存泄漏,我们一般可以监视Process对象的Handle Count,Virutal Bytes 和Working Set三个Counter。Handle Count记录了进程当前打开的HANDLE的个数,监视这个Counter有助于我们发现程序是否有Handle泄漏;Virtual Bytes记录了该进程当前在虚地址空间上使用的虚拟内存的大小,NT的内存分配采用了两步走的方法,首先,在虚地址空间上保留一段空间,这时操作系统并没有分配物理内存,只是保留了一段地址。然后,再提交这段空间,这时操作系统才会分配物理内存。所以,Virtual Bytes一般总大于程序的Working Set。监视Virutal Bytes可以帮助我们发现一些系统底层的问题; Working Set记录了操作系统为进程已提交的内存的总量,这个值和程序申请的内存总量存在密切的关系,如果程序存在内存的泄漏这个值会持续增加,但是Virtual Bytes却是跳跃式增加的。

监视这些Counter可以让我们了解进程使用内存的情况,如果发生了泄漏,即使是隐式内存泄漏,这些Counter的值也会持续增加。但是,我们知道有问题却不知道哪里有问题,所以一般使用Performance Monitor来验证是否有内存泄漏,而使用BoundsChecker来找到和解决。

当Performance Monitor显示有内存泄漏,而BoundsChecker却无法检测到,这时有两种可能:第一种,发生了偶发性内存泄漏。这时你要确保使用Performance Monitor和使用BoundsChecker时,程序的运行环境和操作方法是一致的。第二种,发生了隐式的内存泄漏。这时你要重新审查程序的设计,然后仔细研究Performance Monitor记录的Counter的值的变化图,分析其中的变化和程序运行逻辑的关系,找到一些可能的原因。这是一个痛苦的过程,充满了假设、猜想、验证、失败,但这也是一个积累经验的绝好机会。

探讨C++内存回收

C++内存对象大会战

如果一个人自称为程序高手,却对内存一无所知,那么我可以告诉你,他一定在吹牛。用C或C++写程序,需要更多地关注内存,这不仅仅是因为内存的分配是否合理直接影响着程序的效率和性能,更为主要的是,当我们操作内存的时候一不小心就会出现问题,而且很多时候,这些问题都是不易发觉的,比如内存泄漏,比如悬挂指针。笔者今天在这里并不是要讨论如何避免这些问题,而是想从另外一个角度来认识C++内存对象。

我们知道,C++将内存划分为三个逻辑区域:堆、栈和静态存储区。既然如此,我称位于它们之中的对象分别为堆对象,栈对象以及静态对象。那么这些不同的内存对象有什么区别了?堆对象和栈对象各有什么优劣了?如何禁止创建堆对象或栈对象了?这些便是今天的主题。

基本概念

先来看看栈。栈,一般用于存放局部变量或对象,如我们在函数定义中用类似下面语句声明的对象:

1
Type stack_object ; 

stack_object便是一个栈对象,它的生命期是从定义点开始,当所在函数返回时,生命结束。

另外,几乎所有的临时对象都是栈对象。比如,下面的函数定义:

1
Type fun(Type object);

这个函数至少产生两个临时对象,首先,参数是按值传递的,所以会调用拷贝构造函数生成一个临时对象object_copy1 ,在函数内部使用的不是使用的不是object,而是object_copy1,自然,object_copy1是一个栈对象,它在函数返回时被释放;还有这个函数是值返回的,在函数返回时,如果我们不考虑返回值优化(NRV),那么也会产生一个临时对象object_copy2,这个临时对象会在函数返回后一段时间内被释放。比如某个函数中有如下代码:

1
2
3
Type tt ,result ; //生成两个栈对象

tt = fun(tt); //函数返回时,生成的是一个临时对象object_copy2

上面的第二个语句的执行情况是这样的,首先函数fun返回时生成一个临时对象object_copy2 ,然后再调用赋值运算符执行

1
tt = object_copy2 ; //调用赋值运算符

看到了吗?编译器在我们毫无知觉的情况下,为我们生成了这么多临时对象,而生成这些临时对象的时间和空间的开销可能是很大的,所以,你也许明白了,为什么对于”大”对象最好用const引用传递代替按值进行函数参数传递了。

接下来,看看堆。堆,又叫自由存储区,它是在程序执行的过程中动态分配的,所以它最大的特性就是动态性。在C++中,所有堆对象的创建和销毁都要由程序员负责,所以,如果处理不好,就会发生内存问题。如果分配了堆对象,却忘记了释放,就会产生内存泄漏;而如果已释放了对象,却没有将相应的指针置为NULL,该指针就是所谓的”悬挂指针”,再度使用此指针时,就会出现非法访问,严重时就导致程序崩溃。

那么,C++中是怎样分配堆对象的?唯一的方法就是用new(当然,用类malloc指令也可获得C式堆内存),只要使用new,就会在堆中分配一块内存,并且返回指向该堆对象的指针。

再来看看静态存储区。所有的静态对象、全局对象都于静态存储区分配。关于全局对象,是在main()函数执行前就分配好了的。其实,在main()函数中的显示代码执行之前,会调用一个由编译器生成的_main()函数,而_main()函数会进行所有全局对象的的构造及初始化工作。而在main()函数结束之前,会调用由编译器生成的exit函数,来释放所有的全局对象。比如下面的代码:

1
2
3
4
void main(void)
{
 ... ...// 显式代码
}

实际上,被转化成这样:

1
2
3
4
5
6
7
void main(void)
{
_main(); //隐式代码,由编译器产生,用以构造所有全局对象
... ... // 显式代码
... ...
exit() ; // 隐式代码,由编译器产生,用以释放所有全局对象
}

所以,知道了这个之后,便可以由此引出一些技巧,如,假设我们要在main()函数执行之前做某些准备工作,那么我们可以将这些准备工作写到一个自定义的全局对象的构造函数中,这样,在main()函数的显式代码执行之前,这个全局对象的构造函数会被调用,执行预期的动作,这样就达到了我们的目的。 刚才讲的是静态存储区中的全局对象,那么,局部静态对象了?局部静态对象通常也是在函数中定义的,就像栈对象一样,只不过,其前面多了个static关键字。局部静态对象的生命期是从其所在函数第一次被调用,更确切地说,是当第一次执行到该静态对象的声明代码时,产生该静态局部对象,直到整个程序结束时,才销毁该对象。

还有一种静态对象,那就是它作为class的静态成员。考虑这种情况时,就牵涉了一些较复杂的问题。

第一个问题是class的静态成员对象的生命期,class的静态成员对象随着第一个class object的产生而产生,在整个程序结束时消亡。也就是有这样的情况存在,在程序中我们定义了一个class,该类中有一个静态对象作为成员,但是在程序执行过程中,如果我们没有创建任何一个该class object,那么也就不会产生该class所包含的那个静态对象。还有,如果创建了多个class object,那么所有这些object都共享那个静态对象成员。

第二个问题是,当出现下列情况时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Base
{
public:
static Type s_object ;
}
class Derived1 : public Base / / 公共继承
{
 ... ...// other data
}
class Derived2 : public Base / / 公共继承
{
 ... ...// other data
}
Base example ;
Derivde1 example1 ;
Derivde2 example2 ;
**example.s_object = ...... ;**
**example1.s_object = ...... ;**
**example2.s_object = ...... ; **

请注意上面标为黑体的三条语句,它们所访问的s_object是同一个对象吗?答案是肯定的,它们的确是指向同一个对象,这听起来不像是真的,是吗?但这是事实,你可以自己写段简单的代码验证一下。我要做的是来解释为什么会这样? 我们知道,当一个类比如Derived1,从另一个类比如Base继承时,那么,可以看作一个Derived1对象中含有一个Base型的对象,这就是一个subobject。一个Derived1对象的大致内存布局如下:

让我们想想,当我们将一个Derived1型的对象传给一个接受非引用Base型参数的函数时会发生切割,那么是怎么切割的呢?相信现在你已经知道了,那就是仅仅取出了Derived1型的对象中的subobject,而忽略了所有Derived1自定义的其它数据成员,然后将这个subobject传递给函数(实际上,函数中使用的是这个subobject的拷贝)。

所有继承Base类的派生类的对象都含有一个Base型的subobject(这是能用Base型指针指向一个Derived1对象的关键所在,自然也是多态的关键了),而所有的subobject和所有Base型的对象都共用同一个s_object对象,自然,从Base类派生的整个继承体系中的类的实例都会共用同一个s_object对象了。上面提到的example、example1、example2的对象布局如下图所示:

三种内存对象的比较

栈对象的优势是在适当的时候自动生成,又在适当的时候自动销毁,不需要程序员操心;而且栈对象的创建速度一般较堆对象快,因为分配堆对象时,会调用operator new操作,operator new会采用某种内存空间搜索算法,而该搜索过程可能是很费时间的,产生栈对象则没有这么麻烦,它仅仅需要移动栈顶指针就可以了。但是要注意的是,通常栈空间容量比较小,一般是1MB~2MB,所以体积比较大的对象不适合在栈中分配。特别要注意递归函数中最好不要使用栈对象,因为随着递归调用深度的增加,所需的栈空间也会线性增加,当所需栈空间不够时,便会导致栈溢出,这样就会产生运行时错误。

堆对象,其产生时刻和销毁时刻都要程序员精确定义,也就是说,程序员对堆对象的生命具有完全的控制权。我们常常需要这样的对象,比如,我们需要创建一个对象,能够被多个函数所访问,但是又不想使其成为全局的,那么这个时候创建一个堆对象无疑是良好的选择,然后在各个函数之间传递这个堆对象的指针,便可以实现对该对象的共享。另外,相比于栈空间,堆的容量要大得多。实际上,当物理内存不够时,如果这时还需要生成新的堆对象,通常不会产生运行时错误,而是系统会使用虚拟内存来扩展实际的物理内存。

接下来看看static对象。

首先是全局对象。全局对象为类间通信和函数间通信提供了一种最简单的方式,虽然这种方式并不优雅。一般而言,在完全的面向对象语言中,是不存在全局对象的,比如C#,因为全局对象意味着不安全和高耦合,在程序中过多地使用全局对象将大大降低程序的健壮性、稳定性、可维护性和可复用性。C++也完全可以剔除全局对象,但是最终没有,我想原因之一是为了兼容C。

其次是类的静态成员,上面已经提到,基类及其派生类的所有对象都共享这个静态成员对象,所以当需要在这些class之间或这些class objects之间进行数据共享或通信时,这样的静态成员无疑是很好的选择。

接着是静态局部对象,主要可用于保存该对象所在函数被屡次调用期间的中间状态,其中一个最显著的例子就是递归函数,我们都知道递归函数是自己调用自己的函数,如果在递归函数中定义一个nonstatic局部对象,那么当递归次数相当大时,所产生的开销也是巨大的。这是因为nonstatic局部对象是栈对象,每递归调用一次,就会产生一个这样的对象,每返回一次,就会释放这个对象,而且,这样的对象只局限于当前调用层,对于更深入的嵌套层和更浅露的外层,都是不可见的。每个层都有自己的局部对象和参数。

在递归函数设计中,可以使用static对象替代nonstatic局部对象(即栈对象),这不仅可以减少每次递归调用和返回时产生和释放nonstatic对象的开销,而且static对象还可以保存递归调用的中间状态,并且可为各个调用层所访问。

使用栈对象的意外收获

前面已经介绍到,栈对象是在适当的时候创建,然后在适当的时候自动释放的,也就是栈对象有自动管理功能。那么栈对象会在什么会自动释放了?第一,在其生命期结束的时候;第二,在其所在的函数发生异常的时候。你也许说,这些都很正常啊,没什么大不了的。是的,没什么大不了的。但是只要我们再深入一点点,也许就有意外的收获了。

栈对象,自动释放时,会调用它自己的析构函数。如果我们在栈对象中封装资源,而且在栈对象的析构函数中执行释放资源的动作,那么就会使资源泄漏的概率大大降低,因为栈对象可以自动的释放资源,即使在所在函数发生异常的时候。实际的过程是这样的:函数抛出异常时,会发生所谓的stack_unwinding(堆栈回滚),即堆栈会展开,由于是栈对象,自然存在于栈中,所以在堆栈回滚的过程中,栈对象的析构函数会被执行,从而释放其所封装的资源。除非,除非在析构函数执行的过程中再次抛出异常――而这种可能性是很小的,所以用栈对象封装资源是比较安全的。基于此认识,我们就可以创建一个自己的句柄或代理来封装资源了。智能指针(auto_ptr)中就使用了这种技术。在有这种需要的时候,我们就希望我们的资源封装类只能在栈中创建,也就是要限制在堆中创建该资源封装类的实例。

禁止产生堆对象

上面已经提到,你决定禁止产生某种类型的堆对象,这时你可以自己创建一个资源封装类,该类对象只能在栈中产生,这样就能在异常的情况下自动释放封装的资源。

那么怎样禁止产生堆对象了?我们已经知道,产生堆对象的唯一方法是使用new操作,如果我们禁止使用new不就行了么。再进一步,new操作执行时会调用operator new,而operator new是可以重载的。方法有了,就是使new operator 为private,为了对称,最好将operator delete也重载为private。现在,你也许又有疑问了,难道创建栈对象不需要调用new吗?是的,不需要,因为创建栈对象不需要搜索内存,而是直接调整堆栈指针,将对象压栈,而operator new的主要任务是搜索合适的堆内存,为堆对象分配空间,这在上面已经提到过了。好,让我们看看下面的示例代码:

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
#include <stdlib.h> //需要用到C式内存分配函数
class Resource ; //代表需要被封装的资源类
class NoHashObject
{
private:
Resource* ptr ;//指向被封装的资源
... ... //其它数据成员
void* operator new(size_t size) //非严格实现,仅作示意之用
{
return malloc(size) ;
}
void operator delete(void* pp) //非严格实现,仅作示意之用
{
free(pp) ;
}
public:
NoHashObject()
{
//此处可以获得需要封装的资源,并让ptr指针指向该资源
ptr = new Resource() ;
}
~NoHashObject()
{
delete ptr ; //释放封装的资源
}
};

NoHashObject现在就是一个禁止堆对象的类了,如果你写下如下代码:

1
2
NoHashObject* fp = new NoHashObject() ; //编译期错误!
delete fp ;

上面代码会产生编译期错误。好了,现在你已经知道了如何设计一个禁止堆对象的类了,你也许和我一样有这样的疑问,难道在类NoHashObject的定义不能改变的情况下,就一定不能产生该类型的堆对象了吗?不,还是有办法的,我称之为”暴力破解法”。C++是如此地强大,强大到你可以用它做你想做的任何事情。这里主要用到的是技巧是指针类型的强制转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void main(void)
{
char* temp = new char[sizeof(NoHashObject)] ;
//强制类型转换,现在ptr是一个指向NoHashObject对象的指针
NoHashObject* obj_ptr = (NoHashObject*)temp ;
temp = NULL ; //防止通过temp指针修改NoHashObject对象
//再一次强制类型转换,让rp指针指向堆中NoHashObject对象的ptr成员
Resource* rp = (Resource*)obj_ptr ;
//初始化obj_ptr指向的NoHashObject对象的ptr成员
rp = new Resource() ;
//现在可以通过使用obj_ptr指针使用堆中的NoHashObject对象成员了
.. ...
delete rp ;//释放资源
temp = (char*)obj_ptr ;
obj_ptr = NULL ;//防止悬挂指针产生
delete [] temp ;//释放NoHashObject对象所占的堆空间。
}

上面的实现是麻烦的,而且这种实现方式几乎不会在实践中使用,但是我还是写出来路,因为理解它,对于我们理解C++内存对象是有好处的。对于上面的这么多强制类型转换,其最根本的是什么了?我们可以这样理解:

某块内存中的数据是不变的,而类型就是我们戴上的眼镜,当我们戴上一种眼镜后,我们就会用对应的类型来解释内存中的数据,这样不同的解释就得到了不同的信息。

所谓强制类型转换实际上就是换上另一副眼镜后再来看同样的那块内存数据。

另外要提醒的是,不同的编译器对对象的成员数据的布局安排可能是不一样的,比如,大多数编译器将NoHashObject的ptr指针成员安排在对象空间的头4个字节,这样才会保证下面这条语句的转换动作像我们预期的那样执行:

1
Resource* rp = (Resource*)obj_ptr ; 

但是,并不一定所有的编译器都是如此。

既然我们可以禁止产生某种类型的堆对象,那么可以设计一个类,使之不能产生栈对象吗?当然可以。

禁止产生栈对象

前面已经提到了,创建栈对象时会移动栈顶指针以”挪出”适当大小的空间,然后在这个空间上直接调用对应的构造函数以形成一个栈对象,而当函数返回时,会调用其析构函数释放这个对象,然后再调整栈顶指针收回那块栈内存。在这个过程中是不需要operator new/delete操作的,所以将operator new/delete设置为private不能达到目的。当然从上面的叙述中,你也许已经想到了:将构造函数或析构函数设为私有的,这样系统就不能调用构造/析构函数了,当然就不能在栈中生成对象了。

这样的确可以,而且我也打算采用这种方案。但是在此之前,有一点需要考虑清楚,那就是,如果我们将构造函数设置为私有,那么我们也就不能用new来直接产生堆对象了,因为new在为对象分配空间后也会调用它的构造函数啊。所以,我打算只将析构函数设置为private。再进一步,将析构函数设为private除了会限制栈对象生成外,还有其它影响吗?是的,这还会限制继承。

如果一个类不打算作为基类,通常采用的方案就是将其析构函数声明为private。

为了限制栈对象,却不限制继承,我们可以将析构函数声明为protected,这样就两全其美了。如下代码所示:

1
2
3
4
5
6
7
8
9
10
class NoStackObject
{
protected:
~NoStackObject() { }
public:
void destroy()
{
delete this ;//调用保护析构函数
}
};

接着,可以像这样使用NoStackObject类:

1
2
3
NoStackObject* hash_ptr = new NoStackObject() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;

呵呵,是不是觉得有点怪怪的,我们用new创建一个对象,却不是用delete去删除它,而是要用destroy方法。很显然,用户是不习惯这种怪异的使用方式的。所以,我决定将构造函数也设为private或protected。这又回到了上面曾试图避免的问题,即不用new,那么该用什么方式来生成一个对象了?我们可以用间接的办法完成,即让这个类提供一个static成员函数专门用于产生该类型的堆对象。(设计模式中的singleton模式就可以用这种方式实现。)让我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class NoStackObject
{
protected:
NoStackObject() { }
~NoStackObject() { }
public:
static NoStackObject* creatInstance()
{
return new NoStackObject() ;//调用保护的构造函数
}
void destroy()
{
delete this ;//调用保护的析构函数
}
};

现在可以这样使用NoStackObject类了:

1
2
3
4
NoStackObject* hash_ptr = NoStackObject::creatInstance() ;
... ... //对hash_ptr指向的对象进行操作
hash_ptr->destroy() ;
hash_ptr = NULL ; //防止使用悬挂指针

现在感觉是不是好多了,生成对象和释放对象的操作一致了。

浅议C++ 中的垃圾回收方法

许多 C 或者 C++ 程序员对垃圾回收嗤之以鼻,认为垃圾回收肯定比自己来管理动态内存要低效,而且在回收的时候一定会让程序停顿在那里,而如果自己控制内存管理的话,分配和释放时间都是稳定的,不会导致程序停顿。最后,很多 C/C++ 程序员坚信在C/C++ 中无法实现垃圾回收机制。这些错误的观点都是由于不了解垃圾回收的算法而臆想出来的。

其实垃圾回收机制并不慢,甚至比动态内存分配更高效。因为我们可以只分配不释放,那么分配内存的时候只需要从堆上一直的获得新的内存,移动堆顶的指针就够了;而释放的过程被省略了,自然也加快了速度。现代的垃圾回收算法已经发展了很多,增量收集算法已经可以让垃圾回收过程分段进行,避免打断程序的运行了。而传统的动态内存管理的算法同样有在适当的时间收集内存碎片的工作要做,并不比垃圾回收更有优势。

而垃圾回收的算法的基础通常基于扫描并标记当前可能被使用的所有内存块,从已经被分配的所有内存中把未标记的内存回收来做的。C/C++ 中无法实现垃圾回收的观点通常基于无法正确扫描出所有可能还会被使用的内存块,但是,看似不可能的事情实际上实现起来却并不复杂。首先,通过扫描内存的数据,指向堆上动态分配出来内存的指针是很容易被识别出来的,如果有识别错误,也只能是把一些不是指针的数据当成指针,而不会把指针当成非指针数据。这样,回收垃圾的过程只会漏回收掉而不会错误的把不应该回收的内存清理。其次,如果回溯所有内存块被引用的根,只可能存在于全局变量和当前的栈内,而全局变量(包括函数内的静态变量)都是集中存在于 bss 段或 data段中。

垃圾回收的时候,只需要扫描 bss 段, data 段以及当前被使用着的栈空间,找到可能是动态内存指针的量,把引用到的内存递归扫描就可以得到当前正在使用的所有动态内存了。

如果肯为你的工程实现一个不错的垃圾回收器,提高内存管理的速度,甚至减少总的内存消耗都是可能的。如果有兴趣的话,可以搜索一下网上已有的关于垃圾回收的论文和实现了的库,开拓视野对一个程序员尤为重要。

C++虚继承

概念

为了解决从不同途径继承来的同名的数据成员在内存中有不同的拷贝造成数据不一致问题,将共同基类设置为虚基类。这时从不同的路径继承过来的同名数据成员在内存中就只有一个拷贝,同一个函数名也只有一个映射。这样不仅就解决了二义性问题,也节省了内存,避免了数据不一致的问题。

class 派生类名:virtual 继承方式 基类名

virtual是关键字,声明该基类为派生类的虚基类。

在多继承情况下,虚基类关键字的作用范围和继承方式关键字相同,只对紧跟其后的基类起作用。

声明了虚基类之后,虚基类在进一步派生过程中始终和派生类一起,维护同一个基类子对象的拷贝。

底层实现原理与编译器相关,一般通过虚基类指针虚基类表实现,每个虚继承的子类都有一个虚基类指针(占用一个指针的存储空间,4字节)和虚基类表(不占用类对象的存储空间)(需要强调的是,虚基类依旧会在子类里面存在拷贝,只是仅仅最多存在一份而已,并不是不在子类里面了);当虚继承的子类被当做父类继承时,虚基类指针也会被继承。

实际上,vbptr 指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

执行顺序

首先执行虚基类的构造函数,多个虚基类的构造函数按照被继承的顺序构造;

执行基类的构造函数,多个基类的构造函数按照被继承的顺序构造;

执行成员对象的构造函数,多个成员对象的构造函数按照申明的顺序构造;

执行派生类自己的构造函数;

析构以与构造相反的顺序执行;

mark

从虚基类直接或间接派生的派生类中的构造函数的成员初始化列表中都要列出对虚基类构造函数的调用。但只有用于建立对象的最派生类的构造函数调用虚基类的构造函数,而该派生类的所有基类中列出的对虚基类的构造函数的调用在执行中被忽略,从而保证对虚基类子对象只初始化一次。

在一个成员初始化列表中同时出现对虚基类和非虚基类构造函数的调用时,虚基类的构造函数先于非虚基类的构造函数执行。

虚继承与继承的差异

首先,虚拟继承与普通继承的区别有:

假设derived 继承自base类,那么derived与base是一种“is a”的关系,即derived类是base类,而反之错误;

假设derived 虚继承自base类,那么derivd与base是一种“has a”的关系,即derived类有一个指向base类的vptr。(貌似有些牵强!某些编译器确实如此,关于虚继承与普通继承的差异见: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
class stream
{
public:
stream(){cout<<"stream::stream()!"<<endl;}
};

class iistream:virtual stream
{
public:
iistream(){cout<<"istream::istream()!"<<endl;}
};

class oostream:virtual stream
{
public:
oostream(){cout<<"ostream::ostream()!"<<endl;}
};

class iiostream:public iistream,public oostream
{
public:
iiostream(){cout<<"iiostream::iiostream()!"<<endl;}
};

int main(int argc, const char * argv[])
{
iiostream oo;
}

程序运行的输出结果为:

1
2
3
4
stream::stream()!
istream::istream()!
ostream::ostream()!
iiostream::iiostream()!   

输出这样的结果是毫无悬念的!本来虚拟继承的目的就是当多重继承出现重复的基类时,其只保存一份基类。减少内存开销。其继承结构为:

1
2
3
4
5
6
7
8
9
            stream 

           /      \   

     istream   ostream   

           \      /

           iiostream  

这样子的菱形结构,使公共基类只产生一个拷贝。

从基类 stream 派生新类时,使用 virtual 将类stream说明为虚基类,这时派生类istream、ostream包含一个指向虚基类的vptr,而不会产生实际的stream空间。所以最终iiostream也含有一个指向虚基类的vptr,调用stream中的成员方法时,通过vptr去调用,不会产生二义性。
而现在我们换种方式使用虚继承:

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 stream
{
public:
stream(){cout<<"stream::stream()!"<<endl;}
};

class iistream:public stream
{
public:
iistream(){cout<<"istream::istream()!"<<endl;}
};

class oostream:public stream
{
public:
oostream(){cout<<"ostream::ostream()!"<<endl;}
};

class iiostream:virtual iistream,virtual oostream
{
public:
iiostream(){cout<<"iiostream::iiostream()!"<<endl;}
};

int main(int argc, const char * argv[])
{
iiostream oo;
}

其输出结果为:

1
2
3
4
5
stream::stream()!
istream::istream()!
stream::stream()!
ostream::ostream()!
iiostream::iiostream()!

从结果可以看到,其构造过程中重复出现基类stream的构造过程。这样就完全没有达到虚拟继承的目的。其继承结构为:

1
2
3
4
5
6
7
8
9
stream      stream                                                                    

\            /                               

istream    ostream                                      

 \          /                                                             

   iiostream  

从继承结构可以看出,如果iiostream对象调用基类stream中的成员方法,会导致方法的二义性。因为iiostream含有指向其虚继承基类 istream,ostream的vptr。而 istream,ostream包含了stream的空间,所以导致iiostream不知道导致是调用那个stream的方法。要解决改问题,可以指定vptr,即在调用成员方法是需要加上作用域,例如

1
2
3
4
5
6
7
8
9
class stream
{
void f(){cout<<"here!"<<endl;}
}
main()
{
iiostream ii;
ii.f();
}

编译器提示调用f方法错误。而采用

1
ii.istream::f();

编译通过,并且会调用istream类vptr指向的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
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
class B1
{
public:
B1(){cout<<"B1::B1()!<"<<endl;}
void f() {cout<<"i'm here!"<<endl;}
};

class V1: public B1
{
public:
V1(){cout<<"V1::V1()!<"<<endl;}
};

class D1: virtual public V1
{
public:
D1(){cout<<"D1::D1()!<"<<endl;}
};

class B2
{
public:
B2(){cout<<"B2::B2()!<"<<endl;}
};

class B3
{
public:
B3(){cout<<"B3::B3()!<"<<endl;}
};

class V2:public B1, public B2
{
public:
V2(){cout<<"V2::V2()!<"<<endl;}
};

class D2:virtual public V2, public B3
{
public:
D2(){cout<<"D2::D2()!<"<<endl;}
};

class M1
{
public:
M1(){cout<<"M1::M1()!<"<<endl;}
};

class M2
{
public:
M2(){cout<<"M2::M2()!<"<<endl;}
};

class X:public D1, public D2
{
M1 m1;
M2 m2;
};
int main(int argc, const char * argv[])
{
X x;
}

上面的代码是来自《Exceptional C++ Style》中关于继承顺序的一段代码。可以看到,上面的代码继承关系非常复杂,而且层次不是特别的清楚。而虚继承的加入更是让继承结构更加无序。不管怎么样,我们还是可以根据c++的标准来分析上面代码的构造顺序。c++对于创建一个类类型的初始化顺序是这样子的:

  1. 最上层派生类的构造函数负责调用虚基类子对象的构造函数。所有虚基类子对象会按照深度优先、从左到右的顺序进行初始化;
  2. 直接基类子对象按照它们在类定义中声明的顺序被一一构造起来;
  3. 非静态成员子对象按照它们在类定义体中的声明的顺序被一一构造起来;
  4. 最上层派生类的构造函数体被执行。

根据上面的规则,可以看出,最先构造的是虚继承基类的构造函数,并且是按照深度优先,从左往右构造。因此,我们需要将继承结构划分层次。显然上面的代码可以认为是4层继承结构。其中最顶层的是B1,B2类。第二层是V1,V2,V3。第三层是D1,D2.最底层是X。而D1虚继承V1,D2虚继承V2,且D1和D2在同一层。所以V1最先构造,其次是V2.在V2构造顺序中,B1先于B2.虚基类构造完成后,接着是直接基类子对象构造,其顺序为D1,D2.最后为成员子对象的构造,顺序为声明的顺序。构造完毕后,开始按照构造顺序执行构造函数体了。所以其最终的输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
B1::B1()!<

V1::V1()!<

B1::B1()!<

B2::B2()!<

V2::V2()!<

D1::D1()!<

B3::B3()!<

D2::D2()!<

M1::M1()!<

M2::M2()!<

从结果也可以看出其构造顺序完全符合上面的标准。而在结果中,可以看到B1重复构造。还是因为没有按照要求使用virtual继承导致的结果。要想只构造B1一次,可以将virtual全部改在B1上,如下面的代码:

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
class B1
{
public:
B1(){cout<<"B1::B1()!<"<<endl;}
void f() {cout<<"i'm here!"<<endl;}
};

class V1: virtual public B1 //public修改为virtual
{
public:
V1(){cout<<"V1::V1()!<"<<endl;}
};

class D1: public V1
{
public:
D1(){cout<<"D1::D1()!<"<<endl;}
};

class B2
{
public:
B2(){cout<<"B2::B2()!<"<<endl;}
};

class B3
{
public:
B3(){cout<<"B3::B3()!<"<<endl;}
};

class V2:virtual public B1, public B2 //public B1修改为virtual public B1
{
public:
V2(){cout<<"V2::V2()!<"<<endl;}
};

class D2: public V2, public B3
{
public:
D2(){cout<<"D2::D2()!<"<<endl;}
};

class M1
{
public:
M1(){cout<<"M1::M1()!<"<<endl;}
};

class M2
{
public:
M2(){cout<<"M2::M2()!<"<<endl;}
};

class X:public D1, public D2
{
M1 m1;
M2 m2;
}

根据上面的代码,其输出结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
B1::B1()!<

V1::V1()!<

D1::D1()!<

B2::B2()!<

V2::V2()!<

B3::B3()!<

D2::D2()!<

M1::M1()!<

M2::M2()!<

由于虚继承导致其构造顺序发生比较大的变化。不管怎么,分析的规则还是一样。

上面分析了这么多,我们知道了虚继承有一定的好处,但是虚继承会增大占用的空间。这是因为每一次虚继承会产生一个vptr指针。空间因素在编程过程中,我们很少考虑,而构造顺序却需要小心,因此使用未构造对象的危害是相当大的。因此,我们需要小心的使用继承,更要确保在使用继承的时候保证构造顺序不会出错。下面我再着重强调一下基类的构造顺序规则:

  1. 最上层派生类的构造函数负责调用虚基类子对象的构造函数。所有虚基类子对象会按照深度优先、从左到右的顺序进行初始化;
  2. 直接基类子对象按照它们在类定义中声明的顺序被一一构造起来;
  3. 非静态成员子对象按照它们在类定义体中的声明的顺序被一一构造起来;
  4. 最上层派生类的构造函数体被执行。

C++中虚函数

概念

在某基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数,用法格式为:virtual 函数返回类型 函数名(参数表) {函数体};实现多态性,通过指向派生类的基类指针或引用,访问派生类中同名覆盖成员函数。

定义

简单地说,那些被virtual关键字修饰的成员函数,就是虚函数。虚函数的作用,用专业术语来解释就是实现多态性(Polymorphism),多态性是将接口与实现进行分离;用形象的语言来解释就是实现以共同的方法,但因个体差异,而采用不同的策略。多态性底层的原理是什么?这里需要引出虚表和虚基表指针的概念。

  • 虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表
    • 虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
    • 虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段
    • 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中
  • 虚表指针:在含有虚函数的类实例化对象时,对象地址的前四个字节存储的指向虚表的指针

C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

由于虚表指针vptr跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。

生成

编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。

编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数

在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表

当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面。这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。

构造函数/析构函数能否声明为虚函数或者纯虚函数

析构函数:析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。

只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。

析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。

构造函数不能定义为虚函数。

  • 创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型
  • 虚函数的调用需要虚函数表指针vptr,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表vtable地址用来调用虚构造函数了
  • 虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数

将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。

有的人认为虚函数被声明为inline,但是编译器并没有对其内联,他们给出的理由是inline是编译期决定的,而虚函数是运行期决定的,即在不知道将要调用哪个函数的情况下,如何将函数内联呢?

上述观点看似正确,其实不然,如果虚函数在编译器就能够决定将要调用哪个函数时,就能够内联。当是指向派生类的指针(多态性)调用声明为inline的虚函数时,不会内联展开;当是对象本身调用虚函数时,会内联展开,当然前提依然是函数并不复杂的情况下

目的

直接的讲,C++中基类采用virtual虚析构函数是为了防止内存泄漏。

具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。

所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

构造函数和析构函数可以调用虚函数吗,为什么

  • 在C++中,提倡不在构造函数和析构函数中调用虚函数;
  • 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
  • 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;
  • 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子
    类的虚函数没有任何意义。

虚析构函数的作用,父类的析构函数是否要设置为虚函数?

1) C++中基类采用virtual虚析构函数是为了防止内存泄漏。

具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。

假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。

那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。

2) 纯虚析构函数一定得定义,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。因此,缺乏任何一个基类析构函数的定义,就会导致链接失败,最好不要把虚析构函数定义为纯虚析构函数。

纯虚函数

定义

纯虚函数是一种特殊的虚函数,它的一般格式如下:

1
2
3
4
5
  class <类名>
 {
 virtual <类型><函数名>(<参数表>)=0;
 …
 };

在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。

纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。

凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。

抽象类

抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。

(1)抽象类的定义:称带有纯虚函数的类为抽象类。

(2)抽象类的作用:抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。

(3)使用抽象类时注意:抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。

抽象类是不能定义对象的。一个纯虚函数不需要(但是可以)被定义。

虚函数的代价?

  1. 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类;
  2. 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;
  3. 不能再是内敛的函数,因为内敛函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内敛函数。

哪些函数不能是虚函数?

  • 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
  • 内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
  • 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
  • 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
  • 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。

C++中手动获取调用堆栈

原文链接;https://blog.csdn.net/kevinlynx/article/details/39269507

要了解调用栈,首先需要了解函数的调用过程,下面用一段代码作为例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

int add(int a, int b) {
int result = 0;
result = a + b;
return result;
}

int main(int argc, char *argv[]) {
int result = 0;
result = add(1, 2);
printf("result = %d \r\n", result);
return 0;
}

使用gcc编译,然后gdb反汇编main函数,看看它是如何调用add函数的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(gdb) disassemble main 
Dump of assembler code for function main:
0x08048439 <+0>: push %ebp
0x0804843a <+1>: mov %esp,%ebp
0x0804843c <+3>: and $0xfffffff0,%esp
0x0804843f <+6>: sub $0x20,%esp
0x08048442 <+9>: movl $0x0,0x1c(%esp) # 给result变量赋0值
0x0804844a <+17>: movl $0x2,0x4(%esp) # 将第2个参数压栈(该参数偏移为esp+0x04)
0x08048452 <+25>: movl $0x1,(%esp) # 将第1个参数压栈(该参数偏移为esp+0x00)
0x08048459 <+32>: call 0x804841c <add> # 调用add函数
0x0804845e <+37>: mov %eax,0x1c(%esp) # 将add函数的返回值赋给result变量
0x08048462 <+41>: mov 0x1c(%esp),%eax
0x08048466 <+45>: mov %eax,0x4(%esp)
0x0804846a <+49>: movl $0x8048510,(%esp)
0x08048471 <+56>: call 0x80482f0 <printf@plt>
0x08048476 <+61>: mov $0x0,%eax
0x0804847b <+66>: leave
0x0804847c <+67>: ret
End of assembler dump.

可以看到,参数是在add函数调用前压栈,换句话说,参数压栈由调用者进行,参数存储在调用者的栈空间中,下面再看一下进入add函数后都做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(gdb) disassemble add
Dump of assembler code for function add:
0x0804841c <+0>: push %ebp # 将ebp压栈(保存函数调用者的栈基址)
0x0804841d <+1>: mov %esp,%ebp # 将ebp指向栈顶esp(设置当前函数的栈基址)
0x0804841f <+3>: sub $0x10,%esp # 分配栈空间(栈向低地址方向生长)
0x08048422 <+6>: movl $0x0,-0x4(%ebp) # 给result变量赋0值(该变量偏移为ebp-0x04)
0x08048429 <+13>: mov 0xc(%ebp),%eax # 将第2个参数的值赋给eax(准备运算)
0x0804842c <+16>: mov 0x8(%ebp),%edx # 将第1个参数的值赋给edx(准备运算)
0x0804842f <+19>: add %edx,%eax # 加法运算(edx+eax),结果保存在eax中
0x08048431 <+21>: mov %eax,-0x4(%ebp) # 将运算结果eax赋给result变量
0x08048434 <+24>: mov -0x4(%ebp),%eax # 将result变量的值赋给eax(eax将作为函数返回值)
0x08048437 <+27>: leave # 恢复函数调用者的栈基址(pop %ebp)
0x08048438 <+28>: ret # 返回(准备执行下条指令)
End of assembler dump.

进入add函数后,首先进行的操作是将当前的栈基址ebp压栈(此栈基址是调用者main函数的),然后将ebp指向栈顶esp,接下来再进行函数内的处理流程。函数结束前,会将函数调用者的栈基址恢复,然后返回准备执行下一指令。这个过程中,栈上的空间会是下面的样子:

可以发现,每调用一次函数,都会对调用者的栈基址(ebp)进行压栈操作,并且由于栈基址是由当时栈顶指针(esp)而来,会发现,各层函数的栈基址很巧妙的构成了一个链,即当前的栈基址指向下一层函数栈基址所在的位置,如下图所示:

了解了函数的调用过程,想要回溯调用栈也就很简单了,首先获取当前函数的栈基址(寄存器ebp)的值,然后获取该地址所指向的栈的值,该值也就是下层函数的栈基址,找到下层函数的栈基址后,重复刚才的动作,即可以将每一层函数的栈基址都找出来,这也就是我们所需要的调用栈了。

下面是根据原理实现的一段获取函数调用栈的代码,供参考。

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
#include <stdio.h>

/* 打印调用栈的最大深度 */
#define DUMP_STACK_DEPTH_MAX 16

/* 获取寄存器ebp的值 */
void get_ebp(unsigned long *ebp) {
__asm__ __volatile__ (
"mov %%ebp, %0"
:"=m"(*ebp)
::"memory");
}

/* 获取调用栈 */
int dump_stack(void **stack, int size) {
unsigned long ebp = 0;
int depth = 0;

/* 1.得到首层函数的栈基址 */
get_ebp(&ebp);

/* 2.逐层回溯栈基址 */
for (depth = 0; (depth < size) && (0 != ebp) && (0 != *(unsigned long *)ebp) && (ebp != *(unsigned long *)ebp); ++depth) {
stack[depth] = (void *)(*(unsigned long *)(ebp + sizeof(unsigned long)));
ebp = *(unsigned long *)ebp;
}

return depth;
}

/* 测试函数 2 */
void test_meloner() {
void *stack[DUMP_STACK_DEPTH_MAX] = {0};
int stack_depth = 0;
int i = 0;

/* 获取调用栈 */
stack_depth = dump_stack(stack, DUMP_STACK_DEPTH_MAX);

/* 打印调用栈 */
printf(" Stack Track: \r\n");
for (i = 0; i < stack_depth; ++i) {
printf(" [%d] %p \r\n", i, stack[i]);
}

return;
}

/* 测试函数 1 */
void test_hutaow() {
test_meloner();
return;
}

/* 主函数 */
int main(int argc, char *argv[]) {
test_hutaow();
return 0;
}

需要知道的信息:

  • 函数调用对应的call指令本质上是先压入下一条指令的地址到堆栈,然后跳转到目标函数地址
  • 函数返回指令ret则是从堆栈取出一个地址,然后跳转到该地址
  • EBP寄存器始终指向当前执行函数相关信息(局部变量)所在栈中的位置,ESP则始终指向栈顶
  • 每一个函数入口都会保存调用者的EBP值,在出口处都会重设EBP值,从而实现函数调用的现场保存及现场恢复
  • 64位机器增加了不少寄存器,从而使得函数调用的参数大部分时候可以通过寄存器传递;同时寄存器名字发生改变,例如EBP变为RBP

在函数调用中堆栈的情况可用下图说明:

将代码对应起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void g() {
int *p = 0;
long a = 0x1234;
printf("%p %x\n", &a, a);
printf("%p %x\n", &p, p);
f();
*p = 1;
}

void b(int argc, char **argv) {
printf("%p %p\n", &argc, &argv);
g();
}

int main(int argc, char **argv) {
b(argc, argv);
return 0;
}

在函数g()中断点,看看堆栈中的内容(64位机器):

1
2
3
4
5
6
7
8
9
10
11
(gdb) p $rbp
$2 = (void *) 0x7fffffffe370
(gdb) p &p
$3 = (int **) 0x7fffffffe368
(gdb) p $rsp
$4 = (void *) 0x7fffffffe360
(gdb) x/8ag $rbp-16
0x7fffffffe360: 0x1234 0x0
0x7fffffffe370: 0x7fffffffe390 0x400631 <b(int, char**)+43>
0x7fffffffe380: 0x7fffffffe498 0x1a561cbc0
0x7fffffffe390: 0x7fffffffe3b0 0x40064f <main(int, char**)+27>

对应的堆栈图:

可以看看例子中0x400631 <b(int, char**)+43>0x40064f <main(int, char**)+27>中的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) disassemble 0x400631
...
0x0000000000400627 <b(int, char**)+33>: callq 0x400468 <printf@plt>
0x000000000040062c <b(int, char**)+38>: callq 0x4005ae <g()>
0x0000000000400631 <b(int, char**)+43>: leaveq # call的下一条指令
...

(gdb) disassemble 0x40064f
...
0x000000000040063f <main(int, char**)+11>: mov %rsi,-0x10(%rbp)
0x0000000000400643 <main(int, char**)+15>: mov -0x10(%rbp),%rsi
0x0000000000400647 <main(int, char**)+19>: mov -0x4(%rbp),%edi
0x000000000040064a <main(int, char**)+22>: callq 0x400606 <b(int, char**)>
0x000000000040064f <main(int, char**)+27>: mov $0x0,%eax # call的下一条指令
...

顺带一提,每个函数入口和出口,对应的设置RBP代码为:
1
2
3
4
5
6
7
8
(gdb) disassemble g
...
0x00000000004005ae <g()+0>: push %rbp # 保存调用者的RBP到堆栈
0x00000000004005af <g()+1>: mov %rsp,%rbp # 设置自己的RBP
...
0x0000000000400603 <g()+85>: leaveq # 等同于:movq %rbp, %rsp
# popq %rbp
0x0000000000400604 <g()+86>: retq

由以上可见,通过当前的RSP或RBP就可以找到调用堆栈中所有函数的RBP;找到了RBP就可以找到函数地址。因为,任何时候的RBP指向的堆栈位置就是上一个函数的RBP;而任何时候RBP所在堆栈中的前一个位置就是函数返回地址。

由此我们可以自己构建一个导致gdb无法取得调用堆栈的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void f() {
long *p = 0;
p = (long*) (&p + 1); // 取得g()的RBP
*p = 0; // 破坏g()的RBP
}

void g() {
int *p = 0;
long a = 0x1234;
printf("%p %x\n", &a, a);
printf("%p %x\n", &p, p);
f();
*p = 1; // 写0地址导致一次core
}

void b(int argc, char **argv) {
printf("%p %p\n", &argc, &argv);
g();
}

int main(int argc, char **argv) {
b(argc, argv);
return 0;
}

使用gdb运行该程序:

1
2
3
4
5
6
7
Program received signal SIGSEGV, Segmentation fault.
g () at ebp.c:37
37 *p = 1;
(gdb) bt
Cannot access memory at address 0x8
(gdb) p $rbp
$1 = (void *) 0x0

bt无法获取堆栈,在函数g()中RBP被改写为0,gdb从0偏移一个地址长度即0x8,尝试从0x8内存位置获取函数地址,然后提示Cannot access memory at address 0x8。

RBP出现了问题,我们就可以通过RSP来手动获取调用堆栈。因为RSP是不会被破坏的,要通过RSP获取调用堆栈则需要偏移一些局部变量所占的空间:

1
2
3
4
5
6
7
(gdb) p $rsp
$2 = (void *) 0x7fffffffe360
(gdb) x/8ag $rsp+16 # g()中局部变量占16字节
0x7fffffffe370: 0x7fffffffe390 0x400631 <b(int, char**)+43>
0x7fffffffe380: 0x7fffffffe498 0x1a561cbc0
0x7fffffffe390: 0x7fffffffe3b0 0x40064f <main(int, char**)+27>
0x7fffffffe3a0: 0x7fffffffe498 0x100000000

基于以上就可以手工找到调用堆栈:
1
2
3
g()
0x400631 <b(int, char**)+43>
0x40064f <main(int, char**)+27>

上面的例子本质上也是破坏堆栈,并且仅仅破坏了保存了的RBP。在实际情况中,堆栈可能会被破坏得更多,则可能导致手动定位也较困难。

堆栈被破坏还可能导致更多的问题,例如覆盖了函数返回地址,则会导致RIP错误;例如堆栈的不平衡。导致堆栈被破坏的原因也有很多,例如局部数组越界;delete/free栈上对象等。

omit-frame-pointer
使用RBP获取调用堆栈相对比较容易。但现在编译器都可以设置不使用RBP(gcc使用-fomit-frame-pointer,msvc使用/Oy),对于函数而言不设置其RBP意味着可以节省若干条指令。在函数内部则完全使用RSP的偏移来定位局部变量,包括嵌套作用域里的局部变量,即使程序实际运行时不会进入这个作用域。

例如:

1
2
3
4
5
6
7
void f2() {
int a = 0x1234;
if (a > 0) {
int b = 0xff;
b = a;
}
}

gcc中使用-fomit-frame-pointer生成的代码为:
1
2
3
4
5
6
7
8
9
(gdb) disassemble f2
Dump of assembler code for function f2:
0x00000000004004a5 <f2+0>: movl $0x1234,-0x8(%rsp) # int a = 0x1234
0x00000000004004ad <f2+8>: cmpl $0x0,-0x8(%rsp)
0x00000000004004b2 <f2+13>: jle 0x4004c4 <f2+31>
0x00000000004004b4 <f2+15>: movl $0xff,-0x4(%rsp) # int b = 0xff
0x00000000004004bc <f2+23>: mov -0x8(%rsp),%eax
0x00000000004004c0 <f2+27>: mov %eax,-0x4(%rsp)
0x00000000004004c4 <f2+31>: retq

C++智能指针

智能指针的作用

1) C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

2) 智能指针在C++11版本之后提供,包含在头文件中,shared_ptr、unique_ptr、weak_pptr。shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

3) 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptrp4 = new int(1);的写法是错误的

4) unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。

5) 智能指针类将一个计数器与类指向的对象相关联,引用计数跟踪该类有多少个对象共享同一指针。每次创建类的新对象时,初始化指针并将引用计数置为1;当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数;对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数;调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)。

6) weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr。 weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少.

说说你了解的auto_ptr作用

  • auto_ptr的出现,主要是为了解决“有异常抛出时发生内存泄漏”的问题;抛出异常,将导致指针p所指向的空间得不到释放而导致内存泄漏;
  • auto_ptr构造时取得某个对象的控制权,在析构时释放该对象。我们实际上是创建一个auto_ptr类型的局部对象,该局部对象析构时,会将自身所拥有的指针空间释放,所以不会有内存泄漏;
  • auto_ptr的构造函数是explicit,阻止了一般指针隐式转换为 auto_ptr的构造,所以不能直接将一般类型的指针赋值给auto_ptr类型的对象,必须用auto_ptr的构造函数创建对象;
  • 由于auto_ptr对象析构时会删除它所拥有的指针,所以使用时避免多个auto_ptr对象管理同一个指针;
  • auto_ptr内部实现,析构函数中删除对象用的是delete而不是delete[],所以auto_ptr不能管理数组;
  • auto_ptr支持所拥有的指针类型之间的隐式类型转换。
  • 可以通过*和->运算符对auto_ptr所有用的指针进行提领操作;
  • T get(),获得auto_ptr所拥有的指针;T release(),释放auto_ptr的所有权,并将所
    有用的指针返回。

智能指针的循环引用

循环引用是指使用多个智能指针share_ptr时,出现了指针之间相互指向,从而形成环的情况,有点类似于死锁的情况,这种情况下,智能指针往往不能正常调用对象的析构函数,从而造成内存泄漏。举个例子:

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
#include <iostream>
using namespace std;

template <typename T>
class Node
{
public:
Node(const T& value)
:_pPre(NULL)
, _pNext(NULL)
, _value(value)
{
cout << "Node()" << endl;
}
~Node()
~Node()
{
cout << "~Node()" << endl;
cout << "this:" << this << endl;
}

shared_ptr<Node<T>> _pPre;
shared_ptr<Node<T>> _pNext;
T _value;
};

void Funtest()
{
shared_ptr<Node<int>> sp1(new Node<int>(1));
shared_ptr<Node<int>> sp2(new Node<int>(2));

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;

sp1->_pNext = sp2; //sp1的引用+1
sp2->_pPre = sp1; //sp2的引用+1

cout << "sp1.use_count:" << sp1.use_count() << endl;
cout << "sp2.use_count:" << sp2.use_count() << endl;
}
int main()
{
Funtest();
system("pause");
return 0;
}
//输出结果
//Node()
//Node()
//sp1.use_count:1
//sp2.use_count:1
//sp1.use_count:2
//sp2.use_count:2

从上面shared_ptr的实现中我们知道了只有当引用计数减减之后等于0,析构时才会释放对象,而上述情况造成了一个僵局,那就是析构对象时先析构sp2,可是由于sp2的空间sp1还在使用中,所以sp2.use_count减减之后为1,不释放,sp1也是相同的道理,由于sp1的空间sp2还在使用中,所以sp1.use_count减减之后为1,也不释放。sp1等着sp2先释放,sp2等着sp1先释放,二者互不相让,导致最终都没能释放,内存泄漏。

在实际编程过程中,应该尽量避免出现智能指针之间相互指向的情况,如果不可避免,可以使用弱指针—weak_ptr,它不增加引用计数,只要出了作用域就会自动析构。

使用智能指针管理内存资源,RAII是怎么回事?

RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。

因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。

智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。

毫不夸张的来讲,有了智能指针,代码中几乎不需要再出现delete了。

智能指针背后的设计思想

无智能指针造成内存泄漏的例子

1
2
3
4
5
6
7
8
9
10
void remodel(std::string & str)
{
std::string * ps = new std::string(str);//堆内存
...
if (weird_thing())
throw exception();
str = *ps;
delete ps;
return;
}

当出现异常时(weird_thing()返回true),delete将不被执行,因此将导致内存泄露 。

常规解决方案:

  • 在throw exception()之前添加delete ps;
  • 不要忘了最后一个delete ps;

智能指针的设计思想

仿照本地变量能够自动从栈内存中删除的思想,对指针设计一个析构函数,该析构函数将在指针过期时自动释放它指向的内存,总结来说就是:将基本类型指针封装为类对象指针(这个类肯定是个模板,以适应不同基本类型的需求),并在析构函数中编写delete语句以用来删除指针指向的内存空间。

转换remodel()函数的步骤:

  • 包含头文件memory(智能指针所在的头文件);
  • 将指向string的指针替换为指向string的智能指针对象;
  • 删除delete语句。

使用auto_ptr修改该函数的结果:

1
2
3
4
5
6
7
8
9
10
11
#include <memory>
void remodel (std::string & str)
{
std::auto_ptr<std::string> ps (new std::string(str))
...
if (weird_thing ())
throw exception()
str = *ps;
// delete ps; NO LONGER NEEDED
return;
}

C++智能指针简单介绍

STL一共给我们提供了四种智能指针:auto_ptr、unique_ptr、shared_ptr和weak_ptr。

其中:auto_ptr在C++11中已将其摒弃。

使用注意点:

所有的智能指针类都有一个explicit构造函数,以指针作为参数。比如auto_ptr的类模板原型为:

1
2
3
4
5
templet<class T>
class auto_ptr {
explicit auto_ptr(X* p = 0) ;
...
};

因此不能自动将指针转换为智能指针对象,必须显示调用:

1
2
3
4
5
6
shared_ptr<double> pd;
double *p_reg = new double;
pd = p_reg;//NOT ALLOWED(implicit conversion)
pd = shared_ptr<double>(p_reg);// ALLOWED (explicit conversion)
shared_ptr<double> pshared = p_reg;//NOT ALLOWED (implicit conversion)
shared_ptr<double> pshared(p_reg);//ALLOWED (explicit conversion)

对全部三种智能指针都应避免的一点:

1
2
string vacation("I wandered lonely as a child."); //heap param
shared_ptr<string> pvac(&vacation);//NO!!

pvac过期时,程序将把delete运算符用于非堆(栈)内存,这是错误的!

使用实例

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
#include <iostream>
#include <string>
#include <memory>

class report
{
private:
std::string str;
public:
report(const std::string s) : str(s){
std::cout<<"Object created.\n";
}
~report(){
std::cout<<"Object deleted.\n";
}
void comment() const {
std::cout<<str<<"\n";
}
};

int main(){
{
std::auto_ptr<report> ps(new report("using auto ptr"));
ps->comment();
}//auto_ptr 作用域结束
{
std::shared_ptr<report> ps(new report("using shared_ptr"));
ps->comment();
}//shared_ptr 作用域结束
{
std::unique_ptr<report> ps(new report("using unique ptr"));
ps->comment();
}//unique_ptr 作用域结束
return 0;
}

为什么摒弃auto_ptr?

问题来源:

1
2
3
auto_ptr<string> ps (new string("I reigned lonely as a cloud."));
auto_ptr<string> vocation;
vocation = ps;

如果ps和vocation是常规指针,则两个指针指向同一个string对象,当指针过期时,则程序会试图删除同一个对象,要避免这种问题,解决办法:

定义赋值运算符,使之执行深复制。这样两个指针将指向不同的对象,其中的一个对象是另一个对象的副本,缺点是浪费空间,所以智能指针都未采取此方案。

建立所有权(ownership)概念。对于特定的对象,智能有一个智能对象可拥有,这样只能拥有对象的智能指针的析构函数会删除该对象。然后让赋值操作转让所有权。这就是用于auto_ptr和unique_ptr的策略,但unique_ptr的策略更严格。

创建智能更高的指针,跟踪引用特定对象的智能指针数。这称为引用计数。例如,赋值时,计数将加1,而指针过期时,计数将减1,当减为0时才调用delete。这是shared_ptr采用的策略。同样的策略也适用于复制构造函数。

摒弃auto_ptr的例子:

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 <string>
#include <memory>
using namespace std;

int main(){
auto_ptr<string> films[5] = {
auto_ptr<string> (new string("Fowl Balls")),
auto_ptr<string> (new string("Duck Walks")),
auto_ptr<string> (new string("Chicken Runs")),
auto_ptr<string> (new string("Turkey Errors")),
auto_ptr<string> (new string("Goose Eggs"))
};
auto_ptr<string> pwin;
pwin = films[2];//films[2] loses owership,将所有权从films[2]转让给pwin,此时films[2]不再引用该字符串从而变成空指针
cout<<"The nominees for best avian baseball film are\n";
for(int i = 0;i < 5;++i)
{
cout<< *films[i]<<endl;
}
cout<<"The winner is "<<*pwin<<endl;
cin.get();

return 0;
}

运行下发现程序崩溃了,原因是films[2]已经是空指针了,输出空指针就会崩溃。如果把auto_ptr换成shared_ptr或unique_ptr后,程序就不会崩溃,原因如下:

适用shared_ptr时运行正常,因为shared_ptr采用引用计数,pwin和films[2]都指向同一块内存,在释放空间时因为事先要判断引用计数值的大小,因此不会出现多次删除一个对象的错误。

适用unique_ptr时编译出错,与auto_ptr一样,unique_ptr也采用所有权模型,但在适用unique_ptr时,程序不会等到运行阶段崩溃,在编译阶段下属代码就会出现错误:

1
2
unique_ptr<string> pwin;
pwin = films[2];//films[2] loses ownership

这就是为何摒弃auto_ptr的原因:避免潜在的内存泄漏问题。

unique_ptr为何优于auto_ptr?

使用规则更严格

1
2
3
auto_ptr<string> p1(new string("auto"));  //#1
auto_ptr<string> p2; //#2
p2 = p1; //#3

在语句#3中,p2接管string对象的所有权后,p1的所有权将被剥夺。–>可防止p1和p2的析构函数试图删除同一个对象。但如果随后试图使用p1,则会出现错误。

1
2
3
unique_ptr<string> p3(new string("auto"));//#4
unique_ptr<string> p4;//#5
p4=p3;//#6

编译器会认为#6语句为非法,可以避免上述问题。

对悬挂指针的操作更智能

总体来说:允许临时悬挂指针的赋值,禁止其他情况的出现。

示例:函数定义如下:

1
2
3
4
unique_ptr<string> demo(const char *s){
unique_ptr<string> temp (new string(a));
return temp;
}

在程序中调用函数:

1
2
unique_ptr<string> ps;
ps = demo("unique special");

编译器允许此种赋值方式。总之:当程序试图将一个unique_ptr赋值给另一个时,如果源unique_ptr是个临时右值,编译器允许这么做;如果源unique_ptr将存在一段时间,编译器将禁止这么做。

1
2
3
4
5
unique_ptr<string> pu1(new string("hello world"));
unique_ptr<string> pu2;
pu2 = pu1;//#1 not allowed
unique_ptr<string> pu3;
pu3 = unique_ptr<string>(new string("you"));//#2 allowed

如果确实想执行类似#1的操作,仅当以非智能的方式使用摒弃的智能指针时(如解除引用时),这种赋值才不安全。要安全的重用这种指针,可给它赋新值。C++有一个标准库函数std::move(),可以将原来的指针转让所有权变成空指针,可以对其重新赋值。

1
2
3
4
5
unque_ptr<string> ps1,ps2;
ps1 = demo("hello");
ps2 = move(ps1);
ps1 = demo("alexia");
cout<<*ps2<<*ps1<<endl;

如何选择智能指针

使用指南:

如果程序要使用多个指向同一个对象的指针,应选用shared_ptr。这样的情况包括:

  • 有一个指针数组,并使用一些辅助指针来标示特定的元素,如最大的元素和最小的元素;
  • 连个对象包含指向第三个对象的指针;
  • STL容器包含指针。很多STL算法都支持复制和赋值操作,这些操作可用于shared_ptr,但不能用于unique_ptr(编译器发出warning)和auto_ptr(行为不确定)。如果你的编译器没有提供shared_ptr,可使用Boost库提供的shared_ptr。

如果程序不需要多个指向同一个对象的指针,则可使用unique_ptr。如果函数使用new分配内存,并返还指向该内存的指针,将其返回类型声明为unique_ptr是不错的选择。这样,所有权转让给接受返回值的unique_ptr,而该智能指针将负责调用delete。可将unique_ptr储存到STL容器中,只要不调用将unique_ptr复制或赋值给另一个算法(如sort())。例如,可在程序中使用类似于下面的代码段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unique_ptr<int> make_int(int n){
return unique_ptr<int>(new int(n));
}
void show(unique_ptr<int> &p1){
cout<<*a<<' ';
}
int main(){
...
vector<unique_ptr<int>> vp(size);
for(int i=0; i<vp.size();i++){
vp[i] = make_int(rand() %1000);//copy temporary unique_ptr
}
vp.push_back(make_int(rand()%1000));// ok because arg is temporary
for_each(vp.begin(),vp.end(),show); //use for_each();
}

其中push_back调用没有问题,因为它返回一个临时unique_ptr,该unique_ptr被赋值给vp中的一个unique_ptr。另外,如果按值而不是按引用给show()传递对象,for_each()将非法,因为这将导致使用一个来自vp的非临时unique_ptr初始化p1,而这是不允许的。前面说过,编译器将发现错误使用unique_ptr的企图。

在unique_ptr为右值时,可将其赋给shared_ptr,这与将一个unique_ptr赋给一个需要满足的条件相同。与前面一样,在下面的代码中,make_int()的返回类型为unique_ptr<int>

1
2
3
unique_ptr<int> pup(make_int(rand() % 1000));   // ok
shared_ptr<int> spp(pup); // not allowed, pup as lvalue
shared_ptr<int> spr(make_int(rand() % 1000)); // ok

模板shared_ptr包含一个显式构造函数,可用于将右值unique_ptr转换为shared_ptr。shared_ptr将接管原来归unique_ptr所有的对象。

在满足unique_ptr要求的条件时,也可使用auto_ptr,但unique_ptr是更好的选择。如果你的编译器没有unique_ptr,可考虑使用Boost库提供的scoped_ptr,它与unique_ptr类似。

弱引用智能指针 weak_ptr

设计weak_ptr的原因:解决使用shared_ptr因循环引用而不能释放资源的问题。

空悬指针问题

有两个指针p1和p2,指向堆上的同一个对象Object,p1和p2位于不同的线程中。假设线程A通过p1指针将对象销毁了(尽管把p1置为NULL),那p2就成了空悬指针。这是一种典型的C/C++内存错误。

使用weak_ptr能够帮助我们轻松解决上述的空悬指针问题(直接使用shared_ptr也是可以的)。

weak_ptr不控制对象的生命期,但是它知道对象是否还活着,如果对象还活着,那么它可以提升为有效的shared_ptr(提升操作通过lock()函数获取所管理对象的强引用指针);如果对象已经死了,提升会失败,返回一个空的shared_ptr。

举个栗子 :

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
#include <iostream>
#include <memory>

int main()
{
// OLD, problem with dangling pointer
// PROBLEM: ref will point to undefined data!

int* ptr = new int(10);
int* ref = ptr;
delete ptr;

// NEW
// SOLUTION: check expired() or lock() to determine if pointer is valid
// empty definition
std::shared_ptr<int> sptr;
// takes ownership of pointer
sptr.reset(new int);
*sptr = 10;
// get pointer to data without taking ownership
std::weak_ptr<int> weak1 = sptr;
// deletes managed object, acquires new pointer
sptr.reset(new int);
*sptr = 5;
// get pointer to new data without taking ownership
std::weak_ptr<int> weak2 = sptr;
// weak1 is expired!
if(auto tmp = weak1.lock())
std::cout << *tmp << '\n';
else
std::cout << "weak1 is expired\n";
// weak2 points to new data (5)
if(auto tmp = weak2.lock())
std::cout << *tmp << '\n';
else
std::cout << "weak2 is expired\n";
}

循环引用问题
栗子 大法:

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 <iostream>
#include <boost/smart_ptr.hpp>
using namespace std;
using namespace boost;

class BB;
class AA
{
public:
AA() { cout << "AA::AA() called" << endl; }
~AA() { cout << "AA::~AA() called" << endl; }
shared_ptr<BB> m_bb_ptr; //!
};

class BB
{
public:
BB() { cout << "BB::BB() called" << endl; }
~BB() { cout << "BB::~BB() called" << endl; }
shared_ptr<AA> m_aa_ptr; //!
};

int main()
{
shared_ptr<AA> ptr_a (new AA);
shared_ptr<BB> ptr_b ( new BB);
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
//下面两句导致了AA与BB的循环引用,结果就是AA和BB对象都不会析构
ptr_a->m_bb_ptr = ptr_b;
ptr_b->m_aa_ptr = ptr_a;
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
}

运行结果:

可以看到由于AA和BB内部的shared_ptr各自保存了对方的一次引用,所以导致了ptr_a和ptr_b销毁的时候都认为内部保存的指针计数没有变成0,所以AA和BB的析构函数不会被调用。解决方法就是把一个shared_ptr替换成weak_ptr。

可以看到由于AA和BB内部的shared_ptr各自保存了对方的一次引用,所以导致了ptr_a和ptr_b销毁的时候都认为内部保存的指针计数没有变成0,所以AA和BB的析构函数不会被调用。解决方法就是把一个shared_ptr替换成weak_ptr。

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 <iostream>
#include <boost/smart_ptr.hpp>
using namespace std;
using namespace boost;

class BB;
class AA
{
public:
AA() { cout << "AA::AA() called" << endl; }
~AA() { cout << "AA::~AA() called" << endl; }
weak_ptr<BB> m_bb_ptr; //!
};

class BB
{
public:
BB() { cout << "BB::BB() called" << endl; }
~BB() { cout << "BB::~BB() called" << endl; }
shared_ptr<AA> m_aa_ptr; //!
};

int main()
{
shared_ptr<AA> ptr_a (new AA);
shared_ptr<BB> ptr_b ( new BB);
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
//下面两句导致了AA与BB的循环引用,结果就是AA和BB对象都不会析构
ptr_a->m_bb_ptr = ptr_b;
ptr_b->m_aa_ptr = ptr_a;
cout << "ptr_a use_count: " << ptr_a.use_count() << endl;
cout << "ptr_b use_count: " << ptr_b.use_count() << endl;
}

运行结果:

最后值得一提的是,虽然通过弱引用指针可以有效的解除循环引用,但这种方式必须在能预见会出现循环引用的情况下才能使用,即这个仅仅是一种编译期的解决方案,如果程序在运行过程中出现了循环引用,还是会造成内存泄漏的。因此,不要认为只要使用了智能指针便能杜绝内存泄漏。

智能指针源码解析

在介绍智能指针源码前,需要明确的是,智能指针本身是一个栈上分配的对象。根据栈上分配的特性,在离开作用域后,会自动调用其析构方法。智能指针根据这个特性实现了对象内存的管理和自动释放。

本文所分析的智能指针源码基于 Android ndk-16b 中 llvm-libc++的 memory 文件。

unique_ptr

先看下 unique_ptr的声明。unique_ptr有两个模板参数,分别为_Tp和_Dp。

  • _Tp表示原生指针的类型。
  • _Dp则表示析构器,开发者可以自定义指针销毁的代码。其拥有一个默认值default_delete<_Tp>,其实就是标准的delete函数。

函数声明中typename __pointer_type<_Tp, deleter_type>::type可以简单理解为_Tp*,即原生指针类型。

1
2
3
4
5
6
7
8
template <class _Tp, class _Dp = default_delete<_Tp> >
class _LIBCPP_TEMPLATE_VIS unique_ptr {
public:
typedef _Tp element_type;
typedef _Dp deleter_type;
typedef typename __pointer_type<_Tp, deleter_type>::type pointer;
//...
}

unique_ptr中唯一的数据成员就是原生指针和析构器的 pair。

1
2
private:
__compressed_pair<pointer, deleter_type> __ptr_;

下面看下unique_ptr的构造函数。

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
template <class _Tp, class _Dp = default_delete<_Tp> >
class _LIBCPP_TEMPLATE_VIS unique_ptr {

public:
// 默认构造函数,用pointer的默认构造函数初始化__ptr_
constexpr unique_ptr() noexcept : __ptr_(pointer()) {}

// 空指针的构造函数,同上
constexpr unique_ptr(nullptr_t) noexcept : __ptr_(pointer()) {}

// 原生指针的构造函数,用原生指针初始化__ptr_
explicit unique_ptr(pointer __p) noexcept : __ptr_(__p) {}

// 原生指针和析构器的构造函数,用这两个参数初始化__ptr_,当前析构器为左值引用
unique_ptr(pointer __p, _LValRefType<_Dummy> __d) noexcept
: __ptr_(__p, __d) {}

// 原生指针和析构器的构造函数,析构器使用转移语义进行转移
unique_ptr(pointer __p, _GoodRValRefType<_Dummy> __d) noexcept
: __ptr_(__p, _VSTD::move(__d)) {
static_assert(!is_reference<deleter_type>::value,
"rvalue deleter bound to reference");
}

// 移动构造函数,取出原有unique_ptr的指针和析构器进行构造
unique_ptr(unique_ptr&& __u) noexcept
: __ptr_(__u.release(), _VSTD::forward<deleter_type>(__u.get_deleter())) {
}

// 移动赋值函数,取出原有unique_ptr的指针和析构器进行构造
unique_ptr& operator=(unique_ptr&& __u) _NOEXCEPT {
reset(__u.release());
__ptr_.second() = _VSTD::forward<deleter_type>(__u.get_deleter());
return *this;
}

}

再看下unique_ptr几个常用函数的实现。

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 _Tp, class _Dp = default_delete<_Tp> >
class _LIBCPP_TEMPLATE_VIS unique_ptr {

// 返回原生指针
pointer get() const _NOEXCEPT {
return __ptr_.first();
}

// 判断原生指针是否为空
_LIBCPP_EXPLICIT operator bool() const _NOEXCEPT {
return __ptr_.first() != nullptr;
}

// 将__ptr置空,并返回原有的指针
pointer release() _NOEXCEPT {
pointer __t = __ptr_.first();
__ptr_.first() = pointer();
return __t;
}

// 重置原有的指针为新的指针,如果原有指针不为空,对原有指针所指对象进行销毁
void reset(pointer __p = pointer()) _NOEXCEPT {
pointer __tmp = __ptr_.first();
__ptr_.first() = __p;
if (__tmp)
__ptr_.second()(__tmp);
}
}

再看下unique_ptr指针特性的两个方法。

1
2
3
4
5
6
7
8
9
// 返回原生指针的引用
typename add_lvalue_reference<_Tp>::type
operator*() const {
return *__ptr_.first();
}
// 返回原生指针
pointer operator->() const _NOEXCEPT {
return __ptr_.first();
}

最后再看下unique_ptr的析构函数。

1
2
// 通过reset()方法进行对象的销毁
~unique_ptr() { reset(); }

shared_ptr

shared_ptr 与unique_ptr最核心的区别就是比unique_ptr多了一个引用计数,并由于引用计数的加入,可以支持拷贝。

先看下shared_ptr的声明。shared_ptr主要有两个成员变量,一个是原生指针,一个是控制块的指针,用来存储这个原生指针的shared_ptr和weak_ptr的数量。

1
2
3
4
5
6
7
8
9
10
11
template<class _Tp>
class shared_ptr
{
public:
typedef _Tp element_type;

private:
element_type* __ptr_;
__shared_weak_count* __cntrl_;
//...
}

我们重点看下__shared_weak_count的定义。

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
// 共享计数类
class __shared_count
{
__shared_count(const __shared_count&);
__shared_count& operator=(const __shared_count&);

protected:
// 共享计数
long __shared_owners_;
virtual ~__shared_count();
private:
// 引用计数变为0的回调,一般是进行内存释放
virtual void __on_zero_shared() _NOEXCEPT = 0;

public:
// 构造函数,需要注意内部存储的引用计数是从0开始,外部看到的引用计数其实为1
explicit __shared_count(long __refs = 0) _NOEXCEPT
: __shared_owners_(__refs) {}

// 增加共享计数
void __add_shared() _NOEXCEPT {
__libcpp_atomic_refcount_increment(__shared_owners_);
}

// 释放共享计数,如果共享计数为0(内部为-1),则调用__on_zero_shared进行内存释放
bool __release_shared() _NOEXCEPT {
if (__libcpp_atomic_refcount_decrement(__shared_owners_) == -1) {
__on_zero_shared();
return true;
}
return false;
}

// 返回引用计数,需要对内部存储的引用计数+1处理
long use_count() const _NOEXCEPT {
return __libcpp_relaxed_load(&amp;__shared_owners_) + 1;
}
};
class __shared_weak_count
: private __shared_count
{
// weak ptr计数
long __shared_weak_owners_;

public:
// 内部共享计数和weak计数都为0
explicit __shared_weak_count(long __refs = 0) _NOEXCEPT
: __shared_count(__refs),
__shared_weak_owners_(__refs) {}
protected:
virtual ~__shared_weak_count();

public:
// 调用通过父类的__add_shared,增加共享引用计数
void __add_shared() _NOEXCEPT {
__shared_count::__add_shared();
}
// 增加weak引用计数
void __add_weak() _NOEXCEPT {
__libcpp_atomic_refcount_increment(__shared_weak_owners_);
}
// 调用父类的__release_shared,如果释放了原生指针的内存,还需要调用__release_weak,因为内部weak计数默认为0
void __release_shared() _NOEXCEPT {
if (__shared_count::__release_shared())
__release_weak();
}
// weak引用计数减1
void __release_weak() _NOEXCEPT;
// 获取共享计数
long use_count() const _NOEXCEPT {return __shared_count::use_count();}
__shared_weak_count* lock() _NOEXCEPT;

private:
// weak计数为0的处理
virtual void __on_zero_shared_weak() _NOEXCEPT = 0;
};

其实__shared_weak_count也是虚类,具体使用的是__shared_ptr_pointer__shared_ptr_pointer中有一个成员变量__data_,用于存储原生指针、析构器、分配器。__shared_ptr_pointer继承了__shared_weak_count,因此它就主要负责内存的分配、销毁,引用计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class __shared_ptr_pointer
: public __shared_weak_count
{
__compressed_pair<__compressed_pair<_Tp, _Dp>, _Alloc> __data_;
public:
_LIBCPP_INLINE_VISIBILITY
__shared_ptr_pointer(_Tp __p, _Dp __d, _Alloc __a)
: __data_(__compressed_pair<_Tp, _Dp>(__p, _VSTD::move(__d)), _VSTD::move(__a)) {}

#ifndef _LIBCPP_NO_RTTI
virtual const void* __get_deleter(const type_info&) const _NOEXCEPT;
#endif

private:
virtual void __on_zero_shared() _NOEXCEPT;
virtual void __on_zero_shared_weak() _NOEXCEPT;
};

了解了引用计数的基本原理后,再看下shared_ptr的实现。

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
// 使用原生指针构造shared_ptr时,会构建__shared_ptr_pointer的控制块
shared_ptr<_Tp>::shared_ptr(_Yp* __p,
typename enable_if<is_convertible<_Yp*, element_type*>::value, __nat>::type)
: __ptr_(__p)
{
unique_ptr<_Yp> __hold(__p);
typedef typename __shared_ptr_default_allocator<_Yp>::type _AllocT;
typedef __shared_ptr_pointer<_Yp*, default_delete<_Yp>, _AllocT > _CntrlBlk;
__cntrl_ = new _CntrlBlk(__p, default_delete<_Yp>(), _AllocT());
__hold.release();
__enable_weak_this(__p, __p);
}

// 如果进行shared_ptr的拷贝,会增加引用计数
template<class _Tp>
inline
shared_ptr<_Tp>::shared_ptr(const shared_ptr& __r) _NOEXCEPT
: __ptr_(__r.__ptr_),
__cntrl_(__r.__cntrl_)
{
if (__cntrl_)
__cntrl_->__add_shared();
}

// 销毁shared_ptr时,会使共享引用计数减1,如果减到0会销毁内存
template<class _Tp>
shared_ptr<_Tp>::~shared_ptr()
{
if (__cntrl_)
__cntrl_->__release_shared();
}

weak_ptr

了解完shared_ptr,weak_ptr也就比较简单了。weak_ptr也包括两个对象,一个是原生指针,一个是控制块。虽然weak_ptr内存储了原生指针,不过由于未实现operator->因此不能直接使用。

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 _LIBCPP_TEMPLATE_VIS weak_ptr
{
public:
typedef _Tp element_type;
private:
element_type* __ptr_;
__shared_weak_count* __cntrl_;

}
// 通过shared_ptr构造weak_ptr。会将shared_ptr的成员变量地址进行复制。增加weak引用计数
weak_ptr<_Tp>::weak_ptr(shared_ptr<_Yp> const&amp; __r,
typename enable_if<is_convertible<_Yp*, _Tp*>::value, __nat*>::type)
_NOEXCEPT
: __ptr_(__r.__ptr_),
__cntrl_(__r.__cntrl_)
{
if (__cntrl_)
__cntrl_->__add_weak();
}

// weak_ptr析构器
template<class _Tp>
weak_ptr<_Tp>::~weak_ptr()
{
if (__cntrl_)
__cntrl_->__release_weak();
}

MSVC C++ STL 源码解析系列介绍

std::unique_ptr是 c++ 11 添加的智能指针之一,是裸指针的封装,我们可以直接使用裸指针来构造std::unique_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct TestStruct {
int a;
int b;
};

class TestClass {
public:
TestClass() = default;
TestClass(int a, int b) : a(a), b(b) {}

private:
int a;
int b;
};

std::unique_ptr<int> p0 = std::unique_ptr<int>(new int { 1 });
std::unique_ptr<TestStruct> p1 = std::unique_ptr<TestStruct>(new TestStruct { 1, 2 });
std::unique_ptr<TestClass> p2 = std::unique_ptr<TestClass>(new TestClass(1, 2));

在 c++ 14 及以上,可以使用std::make_unique来更方便地构造std::unique_ptr,参数列表需匹配创建对象的构造函数:

1
2
3
std::unique_ptr<int> p0 = std::make_unique<int>(1);
std::unique_ptr<TestStruct> p1 = std::make_unique<TestStruct>(TestStruct { 1, 2 });
std::unique_ptr<TestClass> p2 = std::make_unique<TestClass>(1, 2);

除了保存普通对象,std::unique_ptr还能保存数组,这时std::make_unique的参数表示数组的长度:

1
2
3
std::unique_ptr<int[]> p0 = std::make_unique<int[]>(1);
std::unique_ptr<TestStruct[]> p1 = std::make_unique<TestStruct[]>(2);
std::unique_ptr<TestClass[]> p2 = std::make_unique<TestClass[]>(3);

std::unique_ptr重载了operator->,你可以像使用普通指针一样使用它:

1
2
3
4
5
std::unique_ptr<TestStruct> p = std::make_unique<TestStruct>(TestStruct { 1, 2 });
std::cout << "a: " << p->a << ", b: " << p->b << std::endl;

// 输出:
// a: 1, b: 2

当然,直接使用nullptr对其赋值,或者拿std::unique_ptrnullptr进行比较,都是可以的:

1
2
3
4
5
6
7
8
std::unique_ptr<TestClass> p = nullptr;
std::cout << (p == nullptr) << std::endl;
p = std::make_unique<TestClass>();
std::cout << (p == nullptr) << std::endl;

// 输出:
// 1
// 0

std::unique_ptr在离开其作用域时,所保存的对象会自动销毁:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::cout << "block begin" << std::endl;
{
auto p = std::make_unique<LifeCycleTestClass>();
p->PrintHello();
}
std::cout << "block end" << std::endl;

// 输出
// block begin
// constructor
// hello
// destructor
// block end

比较重要的一点是std::unique_ptr删除了拷贝构造,所有它对对象的所有权是独享的,你没有办法直接将std::unique_ptr相互拷贝,而只能通过std::move来转移所有权:

1
2
3
auto p1 = std::make_unique<TestClass>();
// 编译错误:Call to deleted constructor of 'std::unique_ptr<TestClass>'
auto p2 = p1;

正确的做法是:

1
2
auto p1 = std::make_unique<TestClass>();
auto p2 = std::move(p1);

因为触发了移动语义,转移所有权期间,对象不会重新构造。

除了上面这些特性,std::unique_ptr还提供了一些与裸指针相关的成员函数,你可以使用get()来直接获取裸指针:

1
2
auto p = std::make_unique<TestClass>();
TestClass* rawP = p.get();

也可以使用release()来释放裸指针,在释放后,原来的std::unique_ptr会变成nullptr

1
2
auto p = std::make_unique<TestClass>();
TestClass* rawP = p.release();

要注意的是,get()release()都不会销毁原有对象,只是单纯对裸指针进行操作而已。

在实际编程实践中,std::unique_ptr要比std::shared_ptr更实用,因为std::unique_ptr对对象的所有权是明确的,销毁时机也是明确的,可以很好地避免使用 new。

源码解析

下面的源码解析基于 MSVC 16 2019 (64-Bit),其他编译器可能有所不同。

_Compressed_pair

_Compressed_pairstd::unique_ptr内部用于存储 deleter 和裸指针的工具,从字面意思来看,它实现的功能和std::pair是类似的,但是有所差异的一点是在某些场景下,_Compressed_pair相比std::pair做了额外的压缩,我们先来看看源码:

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
struct _Zero_then_variadic_args_t {
explicit _Zero_then_variadic_args_t() = default;
}; // tag type for value-initializing first, constructing second from remaining args

struct _One_then_variadic_args_t {
explicit _One_then_variadic_args_t() = default;
}; // tag type for constructing first from one arg, constructing second from remaining args

template <class _Ty1, class _Ty2, bool = is_empty_v<_Ty1> && !is_final_v<_Ty1>>
class _Compressed_pair final : private _Ty1 { // store a pair of values, deriving from empty first
public:
_Ty2 _Myval2;

using _Mybase = _Ty1; // for visualization

template <class... _Other2>
constexpr explicit _Compressed_pair(_Zero_then_variadic_args_t, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_default_constructible<_Ty1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Ty1(), _Myval2(_STD forward<_Other2>(_Val2)...) {}

template <class _Other1, class... _Other2>
constexpr _Compressed_pair(_One_then_variadic_args_t, _Other1&& _Val1, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_constructible<_Ty1, _Other1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Ty1(_STD forward<_Other1>(_Val1)), _Myval2(_STD forward<_Other2>(_Val2)...) {}

constexpr _Ty1& _Get_first() noexcept {
return *this;
}

constexpr const _Ty1& _Get_first() const noexcept {
return *this;
}
};

template <class _Ty1, class _Ty2>
class _Compressed_pair<_Ty1, _Ty2, false> final { // store a pair of values, not deriving from first
public:
_Ty1 _Myval1;
_Ty2 _Myval2;

template <class... _Other2>
constexpr explicit _Compressed_pair(_Zero_then_variadic_args_t, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_default_constructible<_Ty1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Myval1(), _Myval2(_STD forward<_Other2>(_Val2)...) {}

template <class _Other1, class... _Other2>
constexpr _Compressed_pair(_One_then_variadic_args_t, _Other1&& _Val1, _Other2&&... _Val2) noexcept(
conjunction_v<is_nothrow_constructible<_Ty1, _Other1>, is_nothrow_constructible<_Ty2, _Other2...>>)
: _Myval1(_STD forward<_Other1>(_Val1)), _Myval2(_STD forward<_Other2>(_Val2)...) {}

constexpr _Ty1& _Get_first() noexcept {
return _Myval1;
}

constexpr const _Ty1& _Get_first() const noexcept {
return _Myval1;
}
};

可以看到,_Compressed_pair在满足条件is_empty_v<_Ty1> && !is_final_v<_Ty1>时,会走上面的定义,使用 Empty base optimization 即空基类优化,不满足时,则走下面的特化,退化成普通的pair,我们来通过一段示例代码看一下压缩效果:

1
2
3
4
5
6
std::cout << sizeof(std::pair<A, int>) << std::endl;
std::cout << sizeof(std::_Compressed_pair<A, int>) << std::endl;

// 输出
// 8
// 4

当 A 为空类时,由于 c++ 的机制,会为其保留 1 字节的空间,A 和 int 联合存放在std::pair里时,因为需要进行对齐,就变成了 4 + 4 字节,而_Compressed_pair则通过空基类优化避免了这个问题。

unique_ptr

先来看看保存普通对象的std::unique_ptr的定义:

1
2
template <class _Ty, class _Dx = default_delete<_Ty>>
class unique_ptr;

这里的模板参数_Ty是保存的对象类型,_Dx是删除器类型,默认为default_delete<_Ty>,下面是具体的定义:

1
2
3
4
5
6
7
8
9
10
11
12
template <class _Ty>
struct default_delete { // default deleter for unique_ptr
constexpr default_delete() noexcept = default;

template <class _Ty2, enable_if_t<is_convertible_v<_Ty2*, _Ty*>, int> = 0>
default_delete(const default_delete<_Ty2>&) noexcept {}

void operator()(_Ty* _Ptr) const noexcept /* strengthened */ { // delete a pointer
static_assert(0 < sizeof(_Ty), "can't delete an incomplete type");
delete _Ptr;
}
};

很简单,只是一个重载了operator()的结构体而已,operator()中则直接调用delete

std::unique_ptr中定义了几个 using:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class _Ty, class _Dx_noref, class = void>
struct _Get_deleter_pointer_type { // provide fallback
using type = _Ty*;
};

template <class _Ty, class _Dx_noref>
struct _Get_deleter_pointer_type<_Ty, _Dx_noref, void_t<typename _Dx_noref::pointer>> { // get _Dx_noref::pointer
using type = typename _Dx_noref::pointer;
};

using pointer = typename _Get_deleter_pointer_type<_Ty, remove_reference_t<_Dx>>::type;
using element_type = _Ty;
using deleter_type = _Dx;

这里element_type为元素类型,deleter_type为删除器类型,我们主要关注pointerpointer的类型由_Get_deleter_pointer_type决定,我们可以发现它有两个定义,前者是默认定义,当删除器中没有定义pointer时会fallback到这个定义,如果删除器定义了pointer,则会使用删除器中的pointer类型。下面是一段实验代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <class Ty>
struct deleter {
using pointer = void*;

constexpr deleter() noexcept = default;

template <class Ty2, std::enable_if_t<std::is_convertible_v<Ty2*, Ty*>, int> = 0>
explicit deleter(const deleter<Ty2>&) noexcept {}

void operator()(Ty* Ptr) const noexcept /* strengthened */ { // delete a pointer
delete Ptr;
}
};

struct A {};

int main(int argc, char* argv[])
{
std::cout << typeid(std::_Get_deleter_pointer_type<A, std::remove_reference_t<std::default_delete<A>>>::type).name() << std::endl;
std::cout << typeid(std::_Get_deleter_pointer_type<A, std::remove_reference_t<deleter<A>>>::type).name() << std::endl;
}

输出结果:

1
2
struct A * __ptr64
void * __ptr64

然后我们来看一下std::unique_ptr的 private block:

1
2
3
4
5
private:
template <class, class>
friend class unique_ptr;

_Compressed_pair<_Dx, pointer> _Mypair;

只是定义了一个_Compressed_pair来同时保存删除器和裸指针,这里要注意的是,pair中保存的顺序,first是删除器,secondpointer

接下来看一下std::unique_ptr的各种构造和operator=,首先是默认构造:

1
2
template <class _Dx2 = _Dx, _Unique_ptr_enable_default_t<_Dx2> = 0>
constexpr unique_ptr() noexcept : _Mypair(_Zero_then_variadic_args_t{}) {}

这里的_Zero_then_variadic_args_t在上面也出现过,是一个空结构体,作用于用于标记参数数量,然后决定具体使用_Compressed_pair的哪一个构造。

接下来是nullptr_t的构造和operator=

1
2
3
4
5
6
7
template <class _Dx2 = _Dx, _Unique_ptr_enable_default_t<_Dx2> = 0>
constexpr unique_ptr(nullptr_t) noexcept : _Mypair(_Zero_then_variadic_args_t{}) {}

unique_ptr& operator=(nullptr_t) noexcept {
reset();
return *this;
}

主要是针对空指针的处理,当使用空指针进行构造和赋值的时候,相当于把std::unique_ptr重置。

接下来是更常用的构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <class _Dx2>
using _Unique_ptr_enable_default_t =
enable_if_t<conjunction_v<negation<is_pointer<_Dx2>>, is_default_constructible<_Dx2>>, int>;

template <class _Dx2 = _Dx, _Unique_ptr_enable_default_t<_Dx2> = 0>
explicit unique_ptr(pointer _Ptr) noexcept : _Mypair(_Zero_then_variadic_args_t{}, _Ptr) {}

template <class _Dx2 = _Dx, enable_if_t<is_constructible_v<_Dx2, const _Dx2&>, int> = 0>
unique_ptr(pointer _Ptr, const _Dx& _Dt) noexcept : _Mypair(_One_then_variadic_args_t{}, _Dt, _Ptr) {}

template <class _Dx2 = _Dx,
enable_if_t<conjunction_v<negation<is_reference<_Dx2>>, is_constructible<_Dx2, _Dx2>>, int> = 0>
unique_ptr(pointer _Ptr, _Dx&& _Dt) noexcept : _Mypair(_One_then_variadic_args_t{}, _STD move(_Dt), _Ptr) {}

template <class _Dx2 = _Dx,
enable_if_t<conjunction_v<is_reference<_Dx2>, is_constructible<_Dx2, remove_reference_t<_Dx2>>>, int> = 0>
unique_ptr(pointer, remove_reference_t<_Dx>&&) = delete;

单参数的构造只传入指针,当满足删除器类型不是指针而且可默认构造的情况下启用,直接把传入的裸指针存入pair,这时候由于删除器是可默认构造的,pair中保存的删除器会被直接默认构造。另外的三个也需要满足一定条件,这时可以从外部传入删除器,并将其保存至pair中。

然后是移动构造:

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
template <class _Dx2 = _Dx, enable_if_t<is_move_constructible_v<_Dx2>, int> = 0>
unique_ptr(unique_ptr&& _Right) noexcept
: _Mypair(_One_then_variadic_args_t{}, _STD forward<_Dx>(_Right.get_deleter()), _Right.release()) {}

template <class _Ty2, class _Dx2,
enable_if_t<
conjunction_v<negation<is_array<_Ty2>>, is_convertible<typename unique_ptr<_Ty2, _Dx2>::pointer, pointer>,
conditional_t<is_reference_v<_Dx>, is_same<_Dx2, _Dx>, is_convertible<_Dx2, _Dx>>>,
int> = 0>
unique_ptr(unique_ptr<_Ty2, _Dx2>&& _Right) noexcept
: _Mypair(_One_then_variadic_args_t{}, _STD forward<_Dx2>(_Right.get_deleter()), _Right.release()) {}

#if _HAS_AUTO_PTR_ETC
template <class _Ty2,
enable_if_t<conjunction_v<is_convertible<_Ty2*, _Ty*>, is_same<_Dx, default_delete<_Ty>>>, int> = 0>
unique_ptr(auto_ptr<_Ty2>&& _Right) noexcept : _Mypair(_Zero_then_variadic_args_t{}, _Right.release()) {}
#endif // _HAS_AUTO_PTR_ETC

template <class _Ty2, class _Dx2,
enable_if_t<conjunction_v<negation<is_array<_Ty2>>, is_assignable<_Dx&, _Dx2>,
is_convertible<typename unique_ptr<_Ty2, _Dx2>::pointer, pointer>>,
int> = 0>
unique_ptr& operator=(unique_ptr<_Ty2, _Dx2>&& _Right) noexcept {
reset(_Right.release());
_Mypair._Get_first() = _STD forward<_Dx2>(_Right._Mypair._Get_first());
return *this;
}

template <class _Dx2 = _Dx, enable_if_t<is_move_assignable_v<_Dx2>, int> = 0>
unique_ptr& operator=(unique_ptr&& _Right) noexcept {
if (this != _STD addressof(_Right)) {
reset(_Right.release());
_Mypair._Get_first() = _STD forward<_Dx>(_Right._Mypair._Get_first());
}
return *this;
}

条件判断比较多,不过归根到底都是直接移动删除器,然后调用原std::unique_ptrrelease()释放裸指针,再将裸指针填入新的pair中。

最后,有关构造和赋值比较重要的是被删除的两个方法:

1
2
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;

这直接决定了std::unique_ptr没办法复制与相互赋值,这是语义上独享内存所有权的基石。

我们再看析构:

1
2
3
4
5
~unique_ptr() noexcept {
if (_Mypair._Myval2) {
_Mypair._Get_first()(_Mypair._Myval2);
}
}

比较简单,先判断pair中保存的裸指针是否为空,不为空的话则调用pair中保存的deleter来释放内存。

std::unique_ptr和大部分 stl 类一样提供了swap()方法:

1
2
3
4
void swap(unique_ptr& _Right) noexcept {
_Swap_adl(_Mypair._Myval2, _Right._Mypair._Myval2);
_Swap_adl(_Mypair._Get_first(), _Right._Mypair._Get_first());
}

有关删除器,std::unique_ptr还提供了getter方法来获取删除器:

1
2
3
4
5
6
7
_NODISCARD _Dx& get_deleter() noexcept {
return _Mypair._Get_first();
}

_NODISCARD const _Dx& get_deleter() const noexcept {
return _Mypair._Get_first();
}

接下来看与指针息息相关的几个操作符重载:

1
2
3
4
5
6
7
8
9
10
11
_NODISCARD add_lvalue_reference_t<_Ty> operator*() const noexcept /* strengthened */ {
return *_Mypair._Myval2;
}

_NODISCARD pointer operator->() const noexcept {
return _Mypair._Myval2;
}

explicit operator bool() const noexcept {
return static_cast<bool>(_Mypair._Myval2);
}

这使得我们可以像使用普通指针一样使用std::unique_ptr

最后是三个对裸指针的直接操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_NODISCARD pointer get() const noexcept {
return _Mypair._Myval2;
}

pointer release() noexcept {
return _STD exchange(_Mypair._Myval2, nullptr);
}

void reset(pointer _Ptr = nullptr) noexcept {
pointer _Old = _STD exchange(_Mypair._Myval2, _Ptr);
if (_Old) {
_Mypair._Get_first()(_Old);
}
}

从代码上可以看出来,get()release()并不会触发内存销毁,而reset()的内存销毁也是有条件的,只有reset()为空指针时才会触发销毁。

整体上来看std::unique_ptr的代码并不算复杂,只是裸指针的一层封装而已。

1
unique_ptr<_Ty[], _Dx>

std::unique_ptr还有另外一个定义,即:

1
2
template <class _Ty, class _Dx>
class unique_ptr<_Ty[], _Dx>;

这个定义是针对数组的。大部分代码其实都跟前面相同,我们主要关注不一样的地方,首先是default_delete的特化:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class _Ty>
struct default_delete<_Ty[]> { // default deleter for unique_ptr to array of unknown size
constexpr default_delete() noexcept = default;

template <class _Uty, enable_if_t<is_convertible_v<_Uty (*)[], _Ty (*)[]>, int> = 0>
default_delete(const default_delete<_Uty[]>&) noexcept {}

template <class _Uty, enable_if_t<is_convertible_v<_Uty (*)[], _Ty (*)[]>, int> = 0>
void operator()(_Uty* _Ptr) const noexcept /* strengthened */ { // delete a pointer
static_assert(0 < sizeof(_Uty), "can't delete an incomplete type");
delete[] _Ptr;
}
};

针对数组,这里的operator()的实现由delete改成了delete[]

然后是一些操作符重载上的不同:

1
2
3
4
5
6
7
_NODISCARD _Ty& operator[](size_t _Idx) const noexcept /* strengthened */ {
return _Mypair._Myval2[_Idx];
}

explicit operator bool() const noexcept {
return static_cast<bool>(_Mypair._Myval2);
}

与普通的std::unique_ptr不同的是,它不再提供operator*operator->,取而代之的是operator[],这也与普通数组的操作一致。

其他的一些代码,主要是构造、析构、operator=,基本都与普通的定义一致,就不再赘述了。

make_unique

std::make_unique的用法在前面也说过了,主要是用于更优雅地构造std::unique_ptr的,代码其实也很简单,只是一层简单的透传:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// FUNCTION TEMPLATE make_unique
template <class _Ty, class... _Types, enable_if_t<!is_array_v<_Ty>, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique(_Types&&... _Args) { // make a unique_ptr
return unique_ptr<_Ty>(new _Ty(_STD forward<_Types>(_Args)...));
}

template <class _Ty, enable_if_t<is_array_v<_Ty> && extent_v<_Ty> == 0, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique(const size_t _Size) { // make a unique_ptr
using _Elem = remove_extent_t<_Ty>;
return unique_ptr<_Ty>(new _Elem[_Size]());
}

template <class _Ty, class... _Types, enable_if_t<extent_v<_Ty> != 0, int> = 0>
void make_unique(_Types&&...) = delete;

在 C++ 20 之后,标准库还提供了std::make_unique_for_overwrite来构造std::unique_ptr,与std::make_unique的区别在于,它不需要传递额外参数,直接使用目标类型的默认构造,下面是源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#if _HAS_CXX20
// FUNCTION TEMPLATE make_unique_for_overwrite
template <class _Ty, enable_if_t<!is_array_v<_Ty>, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique_for_overwrite() { // make a unique_ptr with default initialization
return unique_ptr<_Ty>(new _Ty);
}

template <class _Ty, enable_if_t<is_unbounded_array_v<_Ty>, int> = 0>
_NODISCARD unique_ptr<_Ty> make_unique_for_overwrite(
const size_t _Size) { // make a unique_ptr with default initialization
using _Elem = remove_extent_t<_Ty>;
return unique_ptr<_Ty>(new _Elem[_Size]);
}

template <class _Ty, class... _Types, enable_if_t<is_bounded_array_v<_Ty>, int> = 0>
void make_unique_for_overwrite(_Types&&...) = delete;
#endif // _HAS_CXX20

也很简单,透传而已。

总结

  • std::unique_ptr有两个定义,分别针对普通类型和数组类型
  • std::unique_ptr第二个模板参数是删除器,不传递的情况下使用的是default_delete
  • std::unique_ptr重载了指针、数组相关的操作符,实现与裸指针类似的操作
  • std::unique_ptr不允许拷贝,语义上表示一段内存的所有权,转移所有权需要使用std::move产生移动语义
  • std::unique_ptr提供了get()release()来直接对裸指针进行操作
  • std::unqiue_ptr可以直接与nullptr比较,也可以使用nullptr赋值
  • 可以使用std::make_uniquestd::make_unique_for_overwrite来更方便地构造std::unique_ptr

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的“不彻底”性表现在可能存在主属性对键的部分依赖和传递依赖。