Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

设计模式总结:

前言:个人觉得设计模式就是各个对象在不同的时机、不同的调用方被创建,组合结构和封装的侧重点有些不同,从而形成了各个模式的概念。

简单工厂模式

通过在工厂类中进行判断,然后创建需要的功能类。
简单工厂模式是工厂模式中最简单的一种,他可以用比较简单的方式隐藏创建对象的细节,一般只需要告诉工厂类所需要的类型,工厂类就会返回需要的产品类,但客户端看到的只是产品的抽象对象,无需关心到底是返回了哪个子类。客户端唯一需要知道的具体子类就是工厂子类。除了这点,基本是达到了依赖倒转原则的要求。

假如,我们不用工厂类,只用AbstractProduct和它的子类,那客户端每次使用不同的子类的时候都需要知道到底是用哪一个子类,当类比较少的时候还没什么问题,但是当类比较多的时候,管理起来就非常的麻烦了,就必须要做大量的替换,一个不小心就会发生错误。

而使用了工厂类之后,就不会有这样的问题,不管里面多少个类,我只需要知道类型号即可。不过,这里还有一个疑问,那就是如果我每次用工厂类创建的类型都不相同,这样修改起来的时候还是会出现问题,还是需要大量的替换。所以简单工厂模式一般应该于程序中大部分地方都只使用其中一种产品,工厂类也不用频繁创建产品类的情况。这样修改的时候只需要修改有限的几个地方即可。

客户只需要知道SimpleFactory就可以了,使用的时候也是使用的AbstractFactory,这样客户端只在第一次创建工厂的时候是知道具体的细节的,其他时候它都只知道AbstractFactory,这样就完美的达到了依赖倒转的原则。

常用的场景:
例如部署多种数据库的情况,可能在不同的地方要使用不同的数据库,此时只需要在配置文件中设定数据库的类型,每次再根据类型生成实例,这样,不管下面的数据库类型怎么变化,在客户端看来都是只有一个AbstractProduct,使用的时候根本无需修改代码。提供的类型也可以用比较便于识别的字符串,这样不用记很长的类名,还可以保存为配置文件。这样,每次只需要修改配置文件和添加新的产品子类即可。所以简单工厂模式一般应用于多种同类型类的情况,将这些类隐藏起来,再提供统一的接口,便于维护和修改。

优点

  1. 隐藏了对象创建的细节,将产品的实例化推迟到子类中实现。
  2. 客户端基本不用关心使用的是哪个产品,只需要知道用哪个工厂就行了,提供的类型也可以用比较便于识别的字符串。
  3. 方便添加新的产品子类,每次只需要修改工厂类传递的类型值就行了。
  4. 遵循了依赖倒转原则。

缺点

  1. 要求产品子类的类型差不多,使用的方法名都相同,如果类比较多,而所有的类又必须要添加一种方法,则会是非常麻烦的事情。或者是一种类另一种类有几种方法不相同,客户端无法知道是哪一个产品子类,也就无法调用这几个不相同的方法。
  2. 每添加一个产品子类,都必须在工厂类中添加一个判断分支,这违背了开放-封闭原则。

代码演示:
抽象产品类代码:

1
2
3
4
5
6
7
8
9
10
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 抽象产品类: 汽车
/// </summary>
public interface ICar
{
void GetCar();
}
}

具体产品类代码:
1
2
3
4
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
namespace CNBlogs.DesignPattern.Common
{
public enum CarType
{
SportCarType = 0,
JeepCarType = 1,
HatchbackCarType = 2
}

/// <summary>
/// 具体产品类: 跑车
/// </summary>
public class SportCar : ICar
{
public void GetCar()
{
Console.WriteLine("场务把跑车交给范·迪塞尔");
}
}

/// <summary>
/// 具体产品类: 越野车
/// </summary>
public class JeepCar : ICar
{
public void GetCar()
{
Console.WriteLine("场务把越野车交给范·迪塞尔");
}
}

/// <summary>
/// 具体产品类: 两箱车
/// </summary>
public class HatchbackCar : ICar
{
public void GetCar()
{
Console.WriteLine("场务把两箱车交给范·迪塞尔");
}
}
}

简单工厂核心代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace CNBlogs.DesignPattern.Common
{
public class Factory
{
public ICar GetCar(CarType carType)
{
switch (carType)
{
case CarType.SportCarType:
return new SportCar();
case CarType.JeepCarType:
return new JeepCar();
case CarType.HatchbackCarType:
return new HatchbackCar();
default:
throw new Exception("爱上一匹野马,可我的家里没有草原. 你走吧!");
}
}
}
}

客户端调用代码:
1
2
3
4
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
//------------------------------------------------------------------------------
// <copyright file="Program.cs" company="CNBlogs Corporation">
// Copyright (C) 2015-2016 All Rights Reserved
// 原博文地址: http://www.cnblogs.com/toutou/
// 作 者: 请叫我头头哥
// </copyright>
//------------------------------------------------------------------------------
namespace CNBlogs.DesignPattern
{
using System;
using CNBlogs.DesignPattern.Common;

class Program
{
static void Main(string[] args)
{
ICar car;
try
{
Factory factory = new Factory();

Console.WriteLine("范·迪塞尔下一场戏开跑车。");
car = factory.GetCar(CarType.SportCarType);
car.GetCar();
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}
}
}

策略模式

假设一个功能类是一个策略,调用的时候需要创建这个策略的实例,传进一个类似策略控制中心的方法中,然后通过策略基类调用这个传进去的实例子类的方法。

优点:就是相对工厂模式免去了创建那个功能类的判断,简化了工厂模式。缺点:就是把子类实例赋值给了父类,这样就丢掉了子类新增的功能。

工厂方法模式(属于工厂模式)

把简单工厂模式中的工厂类,做了进一步的抽象为接口或抽象类,给各个功能创建一个对应的工厂类,然后在这个工厂类里面去创建对应的实例。
工厂模式基本与简单工厂模式差不多,上面也说了,每次添加一个产品子类都必须在工厂类中添加一个判断分支,这样违背了开放-封闭原则,因此,工厂模式就是为了解决这个问题而产生的。

既然每次都要判断,那我就把这些判断都生成一个工厂子类,这样,每次添加产品子类的时候,只需再添加一个工厂子类就可以了。这样就完美的遵循了开放-封闭原则。但这其实也有问题,如果产品数量足够多,要维护的量就会增加,好在一般工厂子类只用来生成产品类,只要产品子类的名称不发生变化,那么基本工厂子类就不需要修改,每次只需要修改产品子类就可以了。

同样工厂模式一般应该于程序中大部分地方都只使用其中一种产品,工厂类也不用频繁创建产品类的情况。这样修改的时候只需要修改有限的几个地方即可。

常用的场景
基本与简单工厂模式一致,只不过是改进了简单工厂模式中的开放-封闭原则的缺陷,使得模式更具有弹性。将实例化的过程推迟到子类中,由子类来决定实例化哪个。

优点:基本与简单工厂模式一致,多的一点优点就是遵循了开放-封闭原则,使得模式的灵活性更强。
缺点:当新增一个功能类,就需要创建对于的工厂类,相比简单工厂模式,免去了判断创建那个具体实例,但会创建过多的类,还不如策略模式。

代码演示:

抽象工厂代码:

1
2
3
4
5
6
7
namespace CNBlogs.DesignPattern.Common
{
public interface IFactory
{
ICar CreateCar();
}
}

抽象产品代码:
1
2
3
4
5
6
7
namespace CNBlogs.DesignPattern.Common
{
public interface ICar
{
void GetCar();
}
}

具体工厂代码:
1
2
3
4
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
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 具体工厂类: 用于创建跑车类
/// </summary>
public class SportFactory : IFactory
{
public ICar CreateCar()
{
return new SportCar();
}
}

/// <summary>
/// 具体工厂类: 用于创建越野车类
/// </summary>
public class JeepFactory : IFactory
{
public ICar CreateCar()
{
return new JeepCar();
}
}

/// <summary>
/// 具体工厂类: 用于创建两厢车类
/// </summary>
public class HatchbackFactory : IFactory
{
public ICar CreateCar()
{
return new HatchbackCar();
}
}
}

具体产品代码:
1
2
3
4
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
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 具体产品类: 跑车
/// </summary>
public class SportCar : ICar
{
public void GetCar()
{
Console.WriteLine("场务把跑车交给范·迪塞尔");
}
}

/// <summary>
/// 具体产品类: 越野车
/// </summary>
public class JeepCar : ICar
{
public void GetCar()
{
Console.WriteLine("场务把越野车交给范·迪塞尔");
}
}

/// <summary>
/// 具体产品类: 两箱车
/// </summary>
public class HatchbackCar : ICar
{
public void GetCar()
{
Console.WriteLine("场务把两箱车交给范·迪塞尔");
}
}
}

客户端代码:
1
2
3
4
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
//------------------------------------------------------------------------------
// <copyright file="Program.cs" company="CNBlogs Corporation">
// Copyright (C) 2015-2016 All Rights Reserved
// 原博文地址: http://www.cnblogs.com/toutou/
// 作 者: 请叫我头头哥
// </copyright>
//------------------------------------------------------------------------------
namespace CNBlogs.DesignPattern
{
using System.IO;
using System.Configuration;
using System.Reflection;
using CNBlogs.DesignPattern.Common;

class Program
{
static void Main(string[] args)
{
// 工厂类的类名写在配置文件中可以方便以后修改
string factoryType = ConfigurationManager.AppSettings["FactoryType"];

// 这里把DLL配置在数据库是因为以后数据可能发生改变
// 比如说现在的数据是从sql server取的,以后需要从oracle取的话只需要添加一个访问oracle数据库的工程就行了
string dllName = ConfigurationManager.AppSettings["DllName"];

// 利用.NET提供的反射可以根据类名来创建它的实例,非常方便
var currentAssembly = System.Reflection.Assembly.GetExecutingAssembly();
string codeBase = currentAssembly.CodeBase.ToLower().Replace(currentAssembly.ManifestModule.Name.ToLower(), string.Empty);
IFactory factory = Assembly.LoadFrom(Path.Combine(codeBase, dllName)).CreateInstance(factoryType) as IFactory;
ICar car = factory.CreateCar();
car.GetCar();
}
}
}

装饰模式

一般情况下,当一个基类写好之后,我们也许不愿意去改动,也不能改动,原因是
这样的在项目中用得比较久的基类,一旦改动,也许会影响其他功能模块,但是,
又要在该类上面添加功能。使用继承,当在A阶段,写出继承类,用过一段时间,发
现又要添加新功能,于是又要从原始类或A阶段的类继承,周而复始,慢慢的,子类就越来越多,层级就越来越深。然而,事实上,在C阶段需要A阶段的功能,但不需要B阶段的功能,在这种复杂情形下,继承就显得不灵活,于是想到了装饰模式。
装饰模式:
需要扩展一个类的功能,或给一个类增加附加责任
需要动态地给一个对象增加功能,这些功能可以再动态地撤销。
需要增加由一些基本功能的排列组合而产生的非常大量的功能,从而使继承关系变得不现实。

在使用装饰模式前,需要了解虚方法和抽象方法的区别:虚方法,是实例方法,可以在子类中覆盖,也可以由该类对象直接调用。抽象方法需要写在抽象类中,抽象类不能实例化,所以要使用抽象方法必须由子类实现后方可调用。

该模式中,要被扩展的类可以是包含抽象方法的抽象类,也可以是包含虚方法的实例类,也可以是普通实例类。装饰模式就是在原有基类上做扩展,至于基类是什么性质并不重要.

装饰模式在C#代码,和扩展方法,惊人的类似。

  • Component为统一接口,也是装饰类和被装饰类的基本类型。
  • ConcreteComponent为具体实现类,也是被装饰类,他本身是个具有一些功能的完整的类。
  • Decorator是装饰类,实现了Component接口的同时还在内部维护了一个ConcreteComponent的实例,并可以通过构造函数初始化。而Decorator本身,- 通常采用默认实现,他的存在仅仅是一个声明:我要生产出一些用于装饰的子类了。而其子类才是赋有具体装饰效果的装饰产品类。
  • ConcreteDecorator是具体的装饰产品类,每一种装饰产品都具有特定的装饰效果。可以通过构造器声明装饰哪种类型的ConcreteComponent,从而对其进行装饰。

最简单的代码实现装饰器模式

1
2
3
4
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
//基础接口
public interface Component {

public void biu();
}
//具体实现类
public class ConcretComponent implements Component {

public void biu() {

System.out.println("biubiubiu");
}
}
//装饰类
public class Decorator implements Component {

public Component component;

public Decorator(Component component) {

this.component = component;
}

public void biu() {

this.component.biu();
}
}
//具体装饰类
public class ConcreteDecorator extends Decorator {

public ConcreteDecorator(Component component) {

super(component);
}

public void biu() {

System.out.println("ready?go!");
this.component.biu();
}
}

这样一个基本的装饰器体系就出来了,当我们想让Component在打印之前都有一个ready?go!的提示时,就可以使用ConcreteDecorator类了。具体方式如下:

1
2
3
4
5
6
7
  //使用装饰器
  Component component = new ConcreteDecorator(new ConcretComponent());
  component.biu();

  //console:
  ready?go!
  biubiubiu

为何使用装饰器模式?
一个设计模式的出现一定有他特殊的价值。仅仅看见上面的结构图你可能会想,为何要兜这么一圈来实现?仅仅是想要多一行输出,我直接继承ConcretComponent,或者直接在另一个Component的实现类中实现不是一样吗?

首先,装饰器的价值在于装饰,他并不影响被装饰类本身的核心功能。在一个继承的体系中,子类通常是互斥的。比如一辆车,品牌只能要么是奥迪、要么是宝马,不可能同时属于奥迪和宝马,而品牌也是一辆车本身的重要属性特征。但当你想要给汽车喷漆,换坐垫,或者更换音响时,这些功能是互相可能兼容的,并且他们的存在不会影响车的核心属性:那就是他是一辆什么车。这时你就可以定义一个装饰器:喷了漆的车。不管他装饰的车是宝马还是奥迪,他的喷漆效果都可以实现。

再回到这个例子中,我们看到的仅仅是一个ConcreteComponent类。在复杂的大型项目中,同一级下的兄弟类通常有很多。当你有五个甚至十个ConcreteComponent时,再想要为每个类都加上“ready?go!”的效果,就要写出五个子类了。毫无疑问这是不合理的。装饰器模式在不影响各个ConcreteComponent核心价值的同时,添加了他特有的装饰效果,具备非常好的通用性,这也是他存在的最大价值。

实战中使用装饰器模式

写这篇博客的初衷也是恰好在工作中使用到了这个模式,觉得非常好用。需求大致是这样:采用sls服务监控项目日志,以Json的格式解析,所以需要将项目中的日志封装成json格式再打印。现有的日志体系采用了log4j + slf4j框架搭建而成。调用起来是这样的:

1
2
private static final Logger logger = LoggerFactory.getLogger(Component.class);
logger.error(string);

这样打印出来的是毫无规范的一行行字符串。在考虑将其转换成json格式时,我采用了装饰器模式。目前有的是统一接口Logger和其具体实现类,我要加的就是一个装饰类和真正封装成Json格式的装饰产品类。具体实现代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* logger decorator for other extension
* this class have no specific implementation
* just for a decorator definition
* @author jzb
*
*/
public class DecoratorLogger implements Logger {

public Logger logger;

public DecoratorLogger(Logger logger) {

this.logger = logger;
}
    
@Override
public void error(String str) {}

@Override
public void info(String str) {}

//省略其他默认实现
}

1
2
3
4
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
/**
* json logger for formatted output
* @author jzb
*
*/
public class JsonLogger extends DecoratorLogger {
public JsonLogger(Logger logger) {

super(logger);
}

@Override
public void info(String msg) {

JSONObject result = composeBasicJsonResult();
result.put("MESSAGE", msg);
logger.info(result.toString());
}

@Override
public void error(String msg) {

JSONObject result = composeBasicJsonResult();
result.put("MESSAGE", msg);
logger.error(result.toString());
}

public void error(Exception e) {

JSONObject result = composeBasicJsonResult();
result.put("EXCEPTION", e.getClass().getName());
String exceptionStackTrace = ExceptionUtils.getStackTrace(e);
result.put("STACKTRACE", exceptionStackTrace);
logger.error(result.toString());
}

public static class JsonLoggerFactory {

@SuppressWarnings("rawtypes")
public static JsonLogger getLogger(Class clazz) {

Logger logger = LoggerFactory.getLogger(clazz);
return new JsonLogger(logger);
}
}

private JSONObject composeBasicJsonResult() {
//拼装了一些运行时信息
}
}

可以看到,在JsonLogger中,对于Logger的各种接口,我都用JsonObject对象进行一层封装。在打印的时候,最终还是调用原生接口logger.error(string),只是这个string参数已经被我们装饰过了。如果有额外的需求,我们也可以再写一个函数去实现。比如error(Exception e),只传入一个异常对象,这样在调用时就非常方便了。

另外,为了在新老交替的过程中尽量不改变太多的代码和使用方式。我又在JsonLogger中加入了一个内部的工厂类JsonLoggerFactory(这个类转移到DecoratorLogger中可能更好一些),他包含一个静态方法,用于提供对应的JsonLogger实例。最终在新的日志体系中,使用方式如下:

1
2
private static final Logger logger = JsonLoggerFactory.getLogger(Component.class);
logger.error(string);

他唯一与原先不同的地方,就是LoggerFactory -> JsonLoggerFactory,这样的实现,也会被更快更方便的被其他开发者接受和习惯。

代理模式

代理类成为实际想调用对象的中间件,可以控制对实际调用对象的访问权限;维护实际调用对象的一个引用。

原型模式

创建好了一个实例,然后用这个实例,通过克隆方式创建另一个同类型的实例,而不必关心这个新实例是如何创建的。

原型模式使用时需要注意浅拷贝与深拷贝的问题。

建造者模式

每个对象都具备自己的功能,但是,它们的创建方式却是一样的。这个时候就需要中间这个建造者类来负责功能对象实例的创建。在调用端只需调用特定的方法即可。

这个和策略模式有点类似。

抽象工厂模式

使用该功能类的功能类,利用抽象工厂去创建该功能类的实例。这样的好处在于尽可能的避免去创建功能的实例。
更牛逼的做法就是使用反射去创建这个功能类的实例,在调用端就一点都不需要知道要去实例化那个具体的功能类。这当然不是抽象工厂模式独有的。
抽象工厂模式就变得比工厂模式更为复杂,就像上面提到的缺点一样,工厂模式和简单工厂模式要求产品子类必须要是同一类型的,拥有共同的方法,这就限制了产品子类的扩展。于是为了更加方便的扩展,抽象工厂模式就将同一类的产品子类归为一类,让他们继承同一个抽象子类,我们可以把他们一起视作一组,然后好几组产品构成一族。

此时,客户端要使用时必须知道是哪一个工厂并且是哪一组的产品抽象类。每一个工厂子类负责产生一族产品,而子类的一种方法产生一种类型的产品。在客户端看来只有AbstractProductA和AbstractProductB两种产品,使用的时候也是直接使用这两种产品。而通过工厂来识别是属于哪一族产品。

产品ProductA_1和ProductB_1构成一族产品,对应于有Factory1来创建,也就是说Factory1总是创建的ProductA_1和ProductB_1的产品,在客户端看来只需要知道是哪一类工厂和产品组就可以了。一般来说, ProductA_1和ProductB_1都是适应同一种环境的,所以他们会被归为一族。

常用的场景
例如Linux和windows两种操作系统下,有2个挂件A和B,他们在Linux和Windows下面的实现方式不同,Factory1负责产生能在Linux下运行的挂件A和B,Factory2负责产生能在Windows下运行的挂件A和B,这样如果系统环境发生变化了,我们只需要修改工厂就行了。

优点

  1. 封装了产品的创建,使得不需要知道具体是哪种产品,只需要知道是哪个工厂就行了。
  2. 可以支持不同类型的产品,使得模式灵活性更强。
  3. 可以非常方便的使用一族中间的不同类型的产品。

缺点

  1. 结构太过臃肿,如果产品类型比较多,或者产品族类比较多,就会非常难于管理。
  2. 每次如果添加一组产品,那么所有的工厂类都必须添加一个方法,这样违背了开放-封闭原则。所以一般适用于产品组合产品族变化不大的情况。

抽象工厂代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 抽象工厂类
/// </summary>
public abstract class AbstractEquipment
{
/// <summary>
/// 抽象方法: 创建一辆车
/// </summary>
/// <returns></returns>
public abstract AbstractCar CreateCar();

/// <summary>
/// 抽象方法: 创建背包
/// </summary>
/// <returns></returns>
public abstract AbstractBackpack CreateBackpack();
}
}

抽象产品代码:
1
2
3
4
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
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 抽象产品: 车抽象类
/// </summary>
public abstract class AbstractCar
{
/// <summary>
/// 车的类型属性
/// </summary>
public abstract string Type
{
get;
}

/// <summary>
/// 车的颜色属性
/// </summary>
public abstract string Color
{
get;
}
}

/// <summary>
/// 抽象产品: 背包抽象类
/// </summary>
public abstract class AbstractBackpack
{
/// <summary>
/// 包的类型属性
/// </summary>
public abstract string Type
{
get;
}

/// <summary>
/// 包的颜色属性
/// </summary>
public abstract string Color
{
get;
}
}
}

具体工厂代码:
1
2
3
4
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
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 运动装备
/// </summary>
public class SportEquipment : AbstractEquipment
{
public override AbstractCar CreateCar()
{
return new SportCar();
}

public override AbstractBackpack CreateBackpack()
{
return new SportBackpack();
}
}

/// <summary>
/// 越野装备 这里就不添加了,同运动装备一个原理,demo里只演示一个,实际项目中可以按需添加
/// </summary>
//public class JeepEquipment : AbstractEquipment
//{
// public override AbstractCar CreateCar()
// {
// return new JeeptCar();
// }

// public override AbstractBackpack CreateBackpack()
// {
// return new JeepBackpack();
// }
//}
}

具体产品代码:
1
2
3
4
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
namespace CNBlogs.DesignPattern.Common
{
/// <summary>
/// 跑车
/// </summary>
public class SportCar : AbstractCar
{
private string type = "Sport";
private string color = "Red";

/// <summary>
/// 重写基类的Type属性
/// </summary>
public override string Type
{
get
{
return type;
}
}

/// <summary>
/// 重写基类的Color属性
/// </summary>
public override string Color
{
get
{
return color;
}
}
}

/// <summary>
/// 运动背包
/// </summary>
public class SportBackpack : AbstractBackpack
{
private string type = "Sport";
private string color = "Red";

/// <summary>
/// 重写基类的Type属性
/// </summary>
public override string Type
{
get
{
return type;
}
}

/// <summary>
/// 重写基类的Color属性
/// </summary>
public override string Color
{
get
{
return color;
}
}
}
}
//具体产品可以有很多很多, 至于越野类的具体产品这里就不列出来了。

创建装备代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace CNBlogs.DesignPattern.Common
{
public class CreateEquipment
{
private AbstractCar fanCar;
private AbstractBackpack fanBackpack;
public CreateEquipment(AbstractEquipment equipment)
{
fanCar = equipment.CreateCar();
fanBackpack = equipment.CreateBackpack();
}

public void ReadyEquipment()
{
Console.WriteLine(string.Format("老范背着{0}色{1}包开着{2}色{3}车。",
fanBackpack.Color,
fanBackpack.Type,
fanCar.Color,
fanCar.Type
));
}
}
}

客户端代码:
1
2
3
4
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
//------------------------------------------------------------------------------
// <copyright file="Program.cs" company="CNBlogs Corporation">
// Copyright (C) 2015-2016 All Rights Reserved
// 原博文地址: http://www.cnblogs.com/toutou/
// 作 者: 请叫我头头哥
// </copyright>
//------------------------------------------------------------------------------
namespace CNBlogs.DesignPattern
{
using System;
using System.Configuration;
using System.Reflection;

using CNBlogs.DesignPattern.Common;

class Program
{
static void Main(string[] args)
{
// ***具体app.config配置如下*** //
//<add key="assemblyName" value="CNBlogs.DesignPattern.Common"/>
//<add key="nameSpaceName" value="CNBlogs.DesignPattern.Common"/>
//<add key="typename" value="SportEquipment"/>
// 创建一个工厂类的实例
string assemblyName = ConfigurationManager.AppSettings["assemblyName"];
string fullTypeName = string.Concat(ConfigurationManager.AppSettings["nameSpaceName"], ".", ConfigurationManager.AppSettings["typename"]);
AbstractEquipment factory = (AbstractEquipment)Assembly.Load(assemblyName).CreateInstance(fullTypeName);
CreateEquipment equipment = new CreateEquipment(factory);
equipment.ReadyEquipment();
Console.Read();
}
}
}

外观模式

外观模式:为外界调用提供一个统一的接口,把其他类中需要用到的方法提取出来,由外观类进行调用。然后在调用段实例化外观类,以间接调用需要的方法。这种方式形式上和代理模式有异曲同工之妙。

模板模式

模板模式:其实就是抽象出各个具体操作类的公共操作方法,在子类重新实现,然后使用子类去实例化父类。这个模板类其实可以使用接口替换。事实上接口才是专门用来定义操作规范。当然,当有些公共方法,各个子类均有一致需求,此时就不应使用接口,使用抽象类。

状态模式

一个方法的判断逻辑太长,就不容易修改。方法过长,其本质就是,就是本类在不同条件下的状态转移。状态模式,就是将这些判断分开到各个能表示当前状态的独立类中。

备忘录模式

备忘录模式:事实上我觉得这个东西没什么用,按照这种方式进行备份,会因为值类型与引用类型的不同而导致数据丢失。

适配器模式

适配器模式:其实就是代理模式的一个变种,代码的编写方式都差不多。只是,使用这两种模式的出发点不一样,导致这两种模式产生了细微的差别。

组合模式

当对象或系统之间出现部分与整体,或类似树状结构的情况时,考虑组合模式。相对装饰模式来说,这两个有异曲同工之妙,都强调对象间的组合,但是,装饰模式同时强调组合的顺序,而组合模式则是随意组合与移除。

单例模式

能避免同一对象被反复实例化。比如说,访问数据库的连接对象就比普通对象实例化的时间要长;WCF中,维护服务器端远程对象的创建等,这类情况,很有必要用单例模式进行处理对象的实例化。

单例模式也称为单件模式、单子模式,可能是使用最广泛的设计模式。其意图是保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。有很多地方需要这样的功能模块,如系统的日志输出,GUI应用必须是单鼠标,MODEM的联接需要一条且只需要一条电话线,操作系统只能有一个窗口管理器,一台PC连一个键盘。

单例模式有许多种实现方法,在C++中,甚至可以直接用一个全局变量做到这一点,但这样的代码显的很不优雅。 使用全局对象能够保证方便地访问实例,但是不能保证只声明一个对象——也就是说除了一个全局实例外,仍然能创建相同类的本地实例。
《设计模式》一书中给出了一种很不错的实现,定义一个单例类,使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。

单例模式通过类本身来管理其唯一实例,这种特性提供了解决问题的方法。唯一的实例是类的一个普通对象,但设计这个类时,让它只能创建一个实例并提供对此实例的全局访问。唯一实例类Singleton在静态成员函数中隐藏创建实例的操作。习惯上把这个成员函数叫做Instance(),它的返回值是唯一实例的指针。
定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CSingleton
{
private:
CSingleton() //构造函数是私有的
{
}
static CSingleton *m_pInstance;
public:
static CSingleton * GetInstance()
{
if(m_pInstance == NULL) //判断是否第一次调用
m_pInstance = new CSingleton();
return m_pInstance;
}
};

用户访问唯一实例的方法只有GetInstance()成员函数。如果不通过这个函数,任何创建实例的尝试都将失败,因为类的构造函数是私有的。GetInstance()使用懒惰初始化,也就是说它的返回值是当这个函数首次被访问时被创建的。这是一种防弹设计——所有GetInstance()之后的调用都返回相同实例的指针:
1
2
3
CSingleton* p1 = CSingleton :: GetInstance();
CSingleton* p2 = p1->GetInstance();
CSingleton & ref = * CSingleton :: GetInstance();

对GetInstance稍加修改,这个设计模板便可以适用于可变多实例情况,如一个类允许最多五个实例。

单例类CSingleton有以下特征:

  • 它有一个指向唯一实例的静态指针m_pInstance,并且是私有的;
  • 它有一个公有的函数,可以获取这个唯一的实例,并且在需要的时候创建该实例;
  • 它的构造函数是私有的,这样就不能从别处创建该类的实例。

大多数时候,这样的实现都不会出现问题。有经验的读者可能会问,m_pInstance指向的空间什么时候释放呢?更严重的问题是,该实例的析构函数什么时候执行?
如果在类的析构行为中有必须的操作,比如关闭文件,释放外部资源,那么上面的代码无法实现这个要求。我们需要一种方法,正常的删除该实例。
可以在程序结束时调用GetInstance(),并对返回的指针掉用delete操作。这样做可以实现功能,但不仅很丑陋,而且容易出错。因为这样的附加代码很容易被忘记,而且也很难保证在delete之后,没有代码再调用GetInstance函数。
一个妥善的方法是让这个类自己知道在合适的时候把自己删除,或者说把删除自己的操作挂在操作系统中的某个合适的点上,使其在恰当的时候被自动执行。
我们知道,程序在结束的时候,系统会自动析构所有的全局变量。事实上,系统也会析构所有的类的静态成员变量,就像这些静态成员也是全局变量一样。利用这个特征,我们可以在单例类中定义一个这样的静态成员变量,而它的唯一工作就是在析构函数中删除单例类的实例。如下面的代码中的CGarbo类(Garbo意为垃圾工人):

1
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 CSingleton
{
private:
CSingleton()
{
}
static CSingleton *m_pInstance;
class CGarbo //它的唯一工作就是在析构函数中删除CSingleton的实例
{
public:
~CGarbo()
{
if(CSingleton::m_pInstance)
delete CSingleton::m_pInstance;
}
};
static CGarbo Garbo; //定义一个静态成员变量,程序结束时,系统会自动调用它的析构函数
public:
static CSingleton * GetInstance()
{
if(m_pInstance == NULL) //判断是否第一次调用
m_pInstance = new CSingleton();
return m_pInstance;
}
};

类CGarbo被定义为CSingleton的私有内嵌类,以防该类被在其他地方滥用。
程序运行结束时,系统会调用CSingleton的静态成员Garbo的析构函数,该析构函数会删除单例的唯一实例。
使用这种方法释放单例对象有以下特征:

  • 在单例类内部定义专有的嵌套类;
  • 在单例类内定义私有的专门用于释放的静态成员;
  • 利用程序在结束时析构全局变量的特性,选择最终的释放时机;
  • 使用单例的代码不需要任何操作,不必关心对象的释放。

但是添加一个类的静态对象,总是让人不太满意,所以有人用如下方法来重新实现单例和解决它相应的问题,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class CSingleton
{
private:
CSingleton() //构造函数是私有的
{
}
public:
static CSingleton & GetInstance()
{
static CSingleton instance; //局部静态变量
return instance;
}
};

使用局部静态变量,非常强大的方法,完全实现了单例的特性,而且代码量更少,也不用担心单例销毁的问题。
但使用此种方法也会出现问题,当如下方法使用单例时问题来了,
Singleton singleton = Singleton :: GetInstance();
这么做就出现了一个类拷贝的问题,这就违背了单例的特性。产生这个问题原因在于:编译器会为类生成一个默认的构造函数,来支持类的拷贝。
最后没有办法,我们要禁止类拷贝和类赋值,禁止程序员用这种方式来使用单例,当时领导的意思是GetInstance()函数返回一个指针而不是返回一个引用,函数的代码改为如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
class CSingleton
{
private:
CSingleton() //构造函数是私有的
{
}
public:
static CSingleton * GetInstance()
{
static CSingleton instance; //局部静态变量
return &instance;
}
};

但我总觉的不好,为什么不让编译器不这么干呢。这时我才想起可以显示的声明类拷贝的构造函数,和重载 = 操作符,新的单例类如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CSingleton
{
private:
CSingleton() //构造函数是私有的
{
}
CSingleton(const CSingleton &);
CSingleton & operator = (const CSingleton &);
public:
static CSingleton & GetInstance()
{
static CSingleton instance; //局部静态变量
return instance;
}
};

关于Singleton(const Singleton);Singleton & operate = (const Singleton&);函数,需要声明成私有的,并且只声明不实现。这样,如果用上面的方式来使用单例时,不管是在友元类中还是其他的,编译器都是报错。
不知道这样的单例类是否还会有问题,但在程序中这样子使用已经基本没有问题了。

考虑到线程安全、异常安全,可以做以下扩展

1
2
3
4
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
class Lock
{
private:
CCriticalSection m_cs;
public:
Lock(CCriticalSection cs) : m_cs(cs)
{
m_cs.Lock();
}
~Lock()
{
m_cs.Unlock();
}
};

class Singleton
{
private:
Singleton();
Singleton(const Singleton &);
Singleton& operator = (const Singleton &);

public:
static Singleton *Instantialize();
static Singleton *pInstance;
static CCriticalSection cs;
};

Singleton* Singleton::pInstance = 0;

Singleton* Singleton::Instantialize()
{
if(pInstance == NULL)
{ //double check
Lock lock(cs); //用lock实现线程安全,用资源管理类,实现异常安全
//使用资源管理类,在抛出异常的时候,资源管理类对象会被析构,析构总是发生的无论是因为异常抛出还是语句块结束。
if(pInstance == NULL)
{
pInstance = new Singleton();
}
}
return pInstance;
}

之所以在Instantialize函数里面对pInstance 是否为空做了两次判断,因为该方法调用一次就产生了对象,pInstance == NULL 大部分情况下都为false,如果按照原来的方法,每次获取实例都需要加锁,效率太低。而改进的方法只需要在第一次 调用的时候加锁,可大大提高效率。

迭代器模式

提供一种方法访问一个容器对象中各个元素,而又不需暴露该对象的内部细节。

Foreach就是这种模式应用的代表。

职责链模式

职责链模式:就是一个将请求或命令进行转发的流程,类似工作流。并且,也非常类似状态模式,它们共同的特点就是将一个复杂的判断逻辑,转移到各个子类,然后在由子类进行简单判断。

状态模式与职责链模式的区别:状态模式是让各个状态对象自己知道其下一个处理的对象是谁,即在编译时便设定好了的;而职责链模式中的各个对象并不指定其下一个处理的对象到底是谁,只有在客户端才设定。

命令模式

当有客户端发送了一系列的命令或请求,去要求某个对象实现什么操作,可使用命令模式,相当于多个命令发给一个对象。

这一点和观察者模式非常的类似。观察者模式也是某个对象,发出消息,然后由中间对象通知观察者然后去做什么,封装的是要执行操作的对象。而命令模式,则是将各个操作封装成类,然后告知某个对象该做什么。两者的区别是封装的角度不同。

桥接模式

依据合成/聚合原则,优先使用类之间的不同组合,来实现各个类要表现的功能,而不是使用继承。比如说:继承会延续父类的功能,然而,并不是所有的子类都需要这样的功能,但是抽象出的东西在父类,导致子类又必须要实现它,这样,父类就越来越庞大,子类又多了很多不必要的东西。因此,桥接模式更强调类之间的组合从而实现解耦。

对比组合模式,它更强调的是部分与整体间的组合,桥接模式强调的是平行级别上不同类的组合。

解释器模式

举例:写好了C#代码,VB代码,此时需要个编译器来编译。这时,这个编译器就相当于解释器,解释好了交给CPU执行。

解释器跟适配器模式有点类似,但是,适配器模式不需要预先知道要适配的规则,解释器是根据规则去执行解释。

享元模式

享元模式其实是为了避免创建过多的数据对象。比如此列:在象棋中只有红黑双方,红棋子只是红棋中的一颗,很多红棋其实可以使用一个红棋对象表示即可,在外部只需公开该棋的状态即可区分那个红棋,从而达到减少内存消耗的目的。

中介者模式

中介者模式:中介者类唯一要干的事情就是给各个成员对象发出通知。因此,中介者事先就应该知道有哪些成员。

中介者模式和代理模式,观察者模式非常的像。但是其它两种模式在调用的时候,并不需要事先设置那个类被代理,或是事先那些对象需要被通知。

访问者模式

在不改变原有代码的结构上,又想去影响原来的类,或是访问原来类的成员,此时就可以使用访问者模式。但需要注意的是:事先需要构造好那些要访问的对象的对象结构。这个结构在访问者类中去维护。

观察者模式

就是消息订阅—发布模式。本来原始的状况是需要在观察者类内部设置需要通知的对象。结果现在出现了事件。定义委托来通知其他对象,显得更简洁。

进程组

一个或多个进程的集合

eg:显示子进程与父进程的进程组id

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

int main() {
pid_t pid;

if ((pid=fork())<0) {
printf("fork error!");
}else if (pid==0) {
printf("The child process PID is %d.\n",getpid());
printf("The Group ID is %d.\n",getpgrp());
printf("The Group ID is %d.\n",getpgid(0));
printf("The Group ID is %d.\n",getpgid(getpid()));
exit(0);
}

sleep(3);
printf("The parent process PID is %d.\n",getpid());
printf("The Group ID is %d.\n",getpgrp());

return 0;
}

进程组id = 父进程id,即父进程为组长进程

组长进程

  • 组长进程标识: 其进程组ID==其进程ID
  • 组长进程可以创建一个进程组,创建该进程组中的进程,然后终止
  • 只要进程组中有一个进程存在,进程组就存在,与组长进程是否终止无关
  • 进程组生存期: 进程组创建到最后一个进程离开(终止或转移到另一个进程组)

一个进程可以为自己或子进程设置进程组ID,setpgid()加入一个现有的进程组或创建一个新进程组

1
2
3
4
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 <stdlib.h>
#include <unistd.h>

int main() {
pid_t pid;

if ((pid=fork())<0) {
printf("fork error!");
exit(1);
}else if (pid==0) {
printf("The child process PID is %d.\n",getpid());
printf("The Group ID of child is %d.\n",getpgid(0)); // 返回组id
sleep(5);
printf("The Group ID of child is changed to %d.\n",getpgid(0));
exit(0);
}

sleep(1);
setpgid(pid,pid); // 改变子进程的组id为子进程本身

sleep(5);
printf("The parent process PID is %d.\n",getpid());
printf("The parent of parent process PID is %d.\n",getppid());
printf("The Group ID of parent is %d.\n",getpgid(0));
setpgid(getpid(),getppid()); // 改变父进程的组id为父进程的父进程
printf("The Group ID of parent is changed to %d.\n",getpgid(0));

return 0;
}

会话

一个或多个进程组的集合,开始于用户登录,终止与用户退出,此期间所有进程都属于这个会话期

建立新会话:setsid()函数。该调用进程是组长进程,则出错返回,先调用fork, 父进程终止,子进程调用,该调用进程不是组长进程,则创建一个新会话。

  • 该进程变成新会话首进程(session header)
  • 该进程成为一个新进程组的组长进程。
  • 该进程没有控制终端,如果之前有,则会被中断

组长进程不能成为新会话首进程,新会话首进程必定会成为组长进程…

会话ID:会话首进程的进程组ID。获取会话ID: getsid()函数

1
2
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 <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
pid_t pid;

if ((pid=fork())<0) {
printf("fork error!");
exit(1);
}else if (pid==0) {
printf("The child process PID is %d.\n",getpid());
printf("The Group ID of child is %d.\n",getpgid(0));
printf("The Session ID of child is %d.\n",getsid(0));
sleep(10);
setsid(); // 子进程非组长进程,故其成为新会话首进程,且成为组长进程。该进程组id即为会话进程
printf("Changed:\n");
printf("The child process PID is %d.\n",getpid());
printf("The Group ID of child is %d.\n",getpgid(0));
printf("The Session ID of child is %d.\n",getsid(0));
sleep(20);
exit(0);
}

return 0;
}


在子进程中调用setsid()后,子进程成为新会话首进程,且成为一个组长进程,其进程组id等于会话id

守护进程

Linux大多数服务都是通过守护进程实现的,完成许多系统任务

  • 0: 调度进程,称为交换进程(swapper),内核一部分,系统进程
  • 1: init进程, 内核调用,负责内核启动后启动Linux系统

让某个进程不因为用户、终端或者其他的变化而受到影响,那么就必须把这个进程变成一个守护进程

守护进程编程步骤

  • 创建子进程,父进程退出
    • 所有工作在子进程中进行
    • 形式上脱离了控制终端
  • 在子进程中创建新会话
    • setsid()函数
    • 使子进程完全独立出来,脱离控制
  • 改变当前目录为根目录
    • chdir()函数
    • 防止占用可卸载的文件系统
    • 也可以换成其它路径
  • 重设文件权限掩码
    • umask()函数
    • 防止继承的文件创建屏蔽字拒绝某些权限
    • 增加守护进程灵活性
  • 关闭文件描述符
    • 继承的打开文件不会用到,浪费系统资源,无法卸载
    • getdtablesize()
    • 返回所在进程的文件描述符表的项数,即该进程打开的文件数目

1
2
3
4
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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <fcntl.h>

int main() {
pid_t pid;
int i,fd;
char *buf="This is a daemon program.\n";

if ((pid=fork())<0) {
printf("fork error!");
exit(1);
}else if (pid>0) // fork且退出父进程
exit(0);

setsid(); // 在子进程中创建新会话。
chdir("/"); // 设置工作目录为根
umask(0); // 设置权限掩码
for(i=0;i<getdtablesize();i++) //getdtablesize返回子进程文件描述符表的项数
close(i); // 关闭这些不将用到的文件描述符

while(1) {// 死循环表征它将一直运行
// 以读写方式打开"/tmp/daemon.log",返回的文件描述符赋给fd
if ((fd=open("/tmp/daemon.log",O_CREAT|O_WRONLY|O_APPEND,0600))<0) {
printf("Open file error!\n");
exit(1);
}
// 将buf写到fd中
write(fd,buf,strlen(buf)+1);
close(fd);
sleep(10);
printf("Never output!\n");
}

return 0;
}

因为stdout被关掉了,所以“Never ouput!”不会输出。
查看/tmp/daemon.log,说明该程序一直在运行

fork()

fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。在fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。
fork时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。

每个进程都有自己的虚拟地址空间,不同进程的相同的虚拟地址显然可以对应不同的物理地址。因此地址相同(虚拟地址)而值不同没什么奇怪。

具体过程是这样的:
fork子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似mmap的private的方式),如果父子进程一直对这个页面是同一个页面,知道其中任何一个进程要对共享的页面“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的只读页面标记为“可写”,留给另外一个进程使用。

这就是所谓的“写时复制”。正因为fork采用了这种写时复制的机制,所以fork出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行exec,会清空栈、堆。。这些和父进程共享的空间,加载新的代码段。。。,这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能写共享页面,会产生“写时复制”的无用功。所以,一般是子进程先调度滴。

子进程与父进程之间除了代码是共享的之外,堆栈数据和全局数据均是独立的。

pid = fork()返回两次,第一次父进程,第二次子进程:

1
2
3
[root@localhost timetest]# ./test
This is parent process: 4016
This is child process: 4017

注意:这里不是绝对的先返回父进程的pid,具体先执行那个进程,要看操作系统的进程调度算法。

操作系统创建一个新的进程(子进程),并且在进程表中相应为它建立一个新的表项。新进程和原有进程的可执行程序是同一个程序;上下文和数据,绝大部分就是原进程(父进程)的拷贝,但它们是两个相互独立的进程!此时程序寄存器pc,在父、子进程的上下文中都声称,这个进程目前执行到fork调用即将返回(此时子进程不占有CPU,子进程的pc不是真正保存在寄存器中,而是作为进程上下文保存在进程表中的对应表项内)。问题是怎么返回,在父子进程中就分道扬镳。

父进程继续执行,操作系统对fork的实现,使这个调用在父进程中返回刚刚创建的子进程的pid(一个正整数),所以下面的if语句中pid<0, pid==0的两个分支都不会执行。所以输出i am the parent process…

子进程在之后的某个时候得到调度,它的上下文被换入,占据 CPU,操作系统对fork的实现,使得子进程中fork调用返回0。所以在这个进程(注意这不是父进程了哦,虽然是同一个程序,但是这是同一个程序的另外一次执行,在操作系统中这次执行是由另外一个进程表示的,从执行的角度说和父进程相互独立)中pid=0。这个进程继续执行的过程中,if语句中 pid<0不满足,但是pid= =0是true。所以输出i am the child process…

为什么看上去程序中互斥的两个分支都被执行了?在一个程序的一次执行中,这当然是不可能的;但是你看到的两行输出是来自两个进程,这两个进程来自同一个程序的两次执行。

fork之后,操作系统会复制一个与父进程完全相同的子进程,虽说是父子关系,但是在操作系统看来,他们更像兄弟关系,这2个进程共享代码空间,但是数据空间是互相独立的,子进程数据空间中的内容是父进程的完整拷贝,指令指针也完全相同,但只有一点不同,如果fork成功,子进程中fork的返回值是0,父进程中fork的返回值是子进程的进程号,如果fork不成功,父进程会返回错误。

可以这样想象,2个进程一直同时运行,而且步调一致,在fork之后,他们分别作不同的工作,也就是分岔了。这也是fork为什么叫fork的原因。

在程序段里用了fork()之后程序出了分岔,派生出了两个进程。具体哪个先运行就看该系统的调度算法了。

如果需要父子进程协同,可以通过原语的办法解决。

父进程为什么要创建子进程呢?

前面我们已经说过了Linux是一个多用户操作系统,在同一时间会有许多的用户在争夺系统的资源.有时进程为了早一点完成任务就创建子进程来争夺资源. 一旦子进程被创建,父子进程一起从fork处继续执行,相互竞争系统的资源.有时候我们希望子进程继续执行,而父进程阻塞,直到子进程完成任务.这个时候我们可以调用wait或者waitpid系统调用.

对子进程来说,fork返回给它0,但它的pid绝对不会是0;之所以fork返回0给它,是因为它随时可以调用getpid()来获取自己的pid;

fork之后父子进程除非采用了同步手段,否则不能确定谁先运行,也不能确定谁先结束。认为子进程结束后父进程才从fork返回的,这是不对的,fork不是这样的,vfork才这样。

为什么返回0呢

  • 首先必须有一点要清楚,函数的返回值是储存在寄存器eax中的。
  • 其次,当fork返回时,新进程会返回0是因为在初始化任务结构时,将eax设置为0;
  • 在fork中,把子进程加入到可运行的队列中,由进程调度程序在适当的时机调度运行。也就是从此时开始,当前进程分裂为两个并发的进程。
  • 无论哪个进程被调度运行,都将继续执行fork函数的剩余代码,执行结束后返回各自的值。

【NOTE5】
对于fork来说,父子进程共享同一段代码空间,所以给人的感觉好像是有两次返回,其实对于调用fork的父进程来说,如果fork出来的子进程没有得到调度,那么父进程从fork系统调用返回,同时分析sys_fork知道,fork返回的是子进程的id。再看fork出来的子进程,由 copy_process函数可以看出,子进程的返回地址为ret_from_fork(和父进程在同一个代码点上返回),返回值直接置为0。所以当子进程得到调度的时候,也从fork返回,返回值为0。

关键注意两点:

  1. fork返回后,父进程或子进程的执行位置。(首先会将当前进程eax的值做为返回值)
  2. 两次返回的pid存放的位置。(eax中)

pid=fork(),当执行这一句时,当前进程进入fork()运行,此时,fork()内会用一段嵌入式汇编进行系统调用:int 0x80。这时进入内核根据此前写入eax的系统调用功能号便会运行sys_fork系统调用。接着,sys_fork中首先会调用C函数find_empty_process产生一个新的进程,然后会调用C函数 copy_process将父进程的内容复制给子进程,但是子进程tss中的eax值赋值为0(这也是为什么子进程中返回0的原因),当赋值完成后, copy_process会返回新进程(该子进程)的pid,这个值会被保存到eax中。这时子进程就产生了,此时子进程与父进程拥有相同的代码空间,程序指针寄存器eip指向相同的下一条指令地址,当fork正常返回调用其的父进程后,因为eax中的值是新创建的子进程号,所以,fork()返回子进程号,执行else(pid > 0);当产生进程切换运行子进程时,首先会恢复子进程的运行环境即装入子进程的tss任务状态段,其中的eax 值(copy_process中置为0)也会被装入eax寄存器,所以,当子进程运行时,fork返回的是0执行if(pid==0)。

多进程

首先,先来讲一下fork之后,发生了什么事情。

由fork创建的新进程被称为子进程(child process)。该函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值则是新进程(子进程)的进程 id。将子进程id返回给父进程的理由是:因为一个进程的子进程可以多于一个,没有一个函数使一个进程可以获得其所有子进程的进程id。对子进程来说,之所以fork返回0给它,是因为它随时可以调用getpid()来获取自己的pid;也可以调用getppid()来获取父进程的id。(进程id 0总是由交换进程使用,所以一个子进程的进程id不可能为0 )。

fork之后,操作系统会复制一个与父进程完全相同的子进程,虽说是父子关系,但是在操作系统看来,他们更像兄弟关系,这2个进程共享代码空间,但是数据空间是互相独立的,子进程数据空间中的内容是父进程的完整拷贝,指令指针也完全相同,子进程拥有父进程当前运行到的位置(两进程的程序计数器pc值相同,也就是说,子进程是从fork返回处开始执行的),但有一点不同,如果fork成功,子进程中fork的返回值是0,父进程中fork的返回值是子进程的进程号,如果fork不成功,父进程会返回错误。
可以这样想象,2个进程一直同时运行,而且步调一致,在fork之后,他们分别作不同的工作,也就是分岔了。这也是fork为什么叫fork的原因

至于哪一个最先运行,可能与操作系统(调度算法)有关,而且这个问题在实际应用中并不重要,如果需要父子进程协同,可以通过原语的办法解决。

常见的通信方式:

  1. 管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
  2. 命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  3. 消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  4. 共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
  5. 信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  6. 套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
  7. 信号(sinal):信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

信号

中断和异常

中断(也称硬件中断)

定义:中断是由其他硬件设备依照CPU时钟周期信号随机产生的。
分类: 可屏蔽中断、非可屏蔽中断
来源:间隔定时器和I/O

异常(也称软件中断)

定义:当指令执行时由CPU控制单元产生的,异常也称为“异步中断”是因为只有在 一条指令终止执行后CPU才会发出中断。
分类: 处理器探测到的异常、故障、陷阱、异常终止、编程异常(也称软中断)、int指令
来源:程序的错误产生的
内核必须处理的异常(例如:缺页和内核服务的请求-int)

异常处理

当发生异常时,CPU控制单元产生一个硬件出错码。CPU根据该中断吗找到中断向量表内的对应向量,根据该向量转到中断处理程序。中断处理程序处理完之后向当前进程发送一个SIG*信号。若进程定义了相应的信号处理程序则转移到相应的程序执行,若没有,则执行内核定义的操作。

中断处理

设备产生中断,PIC(可编程中断控制器)会产生一个对应的中断向量和中断向量表中的每一个中断向量进行比较,转到对应的中断处理程序,中断处理程序进行保存现场,做相关处理,恢复现场,内核调度,返回用户进程。

硬件中断的上半部和下半部及实现方式

硬件中断的分类

  • 紧急的 —— 这类中断必须立即执行
  • 非紧急的 —— 也必须立即执行
  • 非紧急可延迟的 —— 上半部立即执行,下半部延迟执行

硬件中断任务(处理程序)是一个快速、异步、简单地对硬件做出迅速响应并在最短时间内完成必要操作的中断处理程序。硬中断处理程序可以抢占内核任务并且执 行时还会屏蔽同级中断或其它中断,因此中断处理必须要快、不能阻塞。这样一来对于一些要求处理过程比较复杂的任务就不合适在中断任务中一次处理。比如,网卡接收数据的过程中,首先网卡发送中断信号告诉CPU来取数据,然后系统从网卡中读取数据存入系统缓冲区中,再下来解析数据然后送入应用层。这些如果都让中断处理程序来处理显然过程太长,造成新来的中断丢失。因此Linux开发人员将这种任务分割为两个部分,一个叫上底,即中断处理程序,短平快地处理与硬 件相关的操作(如从网卡读数据到系统缓存);而把对时间要求相对宽松的任务(如解析数据的工作)放在另一个部分执行,这个部分就是我们这里要讲的下半底。

下半底是一种推后执行任务,它将某些不那么紧迫的任务推迟到系统更方便的时刻运行。因为并不是非常紧急,通常还是比较耗时的,因此由系统自行安排运行时机,不在中断服务上下文中执行。内核中实现 下半底的手段经过不断演化,目前已经从最原始的BH(bottom thalf)演生出BH、任务队列(Task queues)、软中断(Softirq)、Tasklet、工作队列(Work queues)(2.6内核中新出现的)。

关于软中断和硬中断的其它解析:

软中断一般是指由指令int引起的“伪”中断动作——给CPU制造一个中断的假象;而硬中断则是实实在在由8259的连线触发的中断。因此,严格的 讲,int与IRQ毫无关系,但二者均与中断向量有关系。int引起的中断,CPU是从指令中取得中断向量号;而IRQ引起的中断,CPU必须从数据线上取回中断号,接下来CPU的工作就一样了:保护现场、根据中断号得到中断处理程序地址、执行中断处理、恢复现场继续执行被中断的指令。

在软中断和硬中断之间的区别是什么?

  1. 硬中断是由外部事件引起的因此具有随机性和突发性;软中断是执行中断指令产生的,无面外部施加中断请求信 号,因此中断的发生不是随机的而是由程序安排好的。
  2. 硬中断的中断响应周期,CPU需要发中断回合信号(NMI不需要),软中断的中断响应周 期,CPU不需发中断回合信号。
  3. 硬中断的中断号是由中断控制器提供的(NMI硬中断中断号系统指定为02H);软中断的中断号由指令直接给出, 无需使用中断控制器。
  4. 硬中断是可屏蔽的(NMI硬中断不可屏蔽),软中断不可屏蔽。

硬中断:

  1. 硬中断是由硬件产生的,比如,像磁盘,网卡,键盘,时钟等。每个设备或设备集都有它自己的IRQ(中断请求)。基于IRQ,CPU可以将相应的请求分发到对应的硬件驱动上(注:硬件驱动通常是内核中的一个子程序,而不是一个独立的进程)。
  2. 处理中断的驱动是需要运行在CPU上的,因此,当中断产生的时候,CPU会中断当前正在运行的任务,来处理中断。在有多核心的系统上,一个中断通常只能中断一颗CPU(也有一种特殊的情况,就是在大型主机上是有硬件通道的,它可以在没有主CPU的支持下,可以同时处理多个中断。)。
  3. 硬中断可以直接中断CPU。它会引起内核中相关的代码被触发。对于那些需要花费一些时间去处理的进程,中断代码本身也可以被其他的硬中断中断。
  4. 对于时钟中断,内核调度代码会将当前正在运行的进程挂起,从而让其他的进程来运行。它的存在是为了让调度代码(或称为调度器)可以调度多任务。

软中断:

  1. 软中断的处理非常像硬中断。然而,它们仅仅是由当前正在运行的进程所产生的。
  2. 通常,软中断是一些对I/O的请求。这些请求会调用内核中可以调度I/O发生的程序。对于某些设备,I/O请求需要被立即处理,而磁盘I/O请求通常可以排队并且可以稍后处理。根据I/O模型的不同,进程或许会被挂起直到I/O完成,此时内核调度器就会选择另一个进程去运行。I/O可以在进程之间产生并且调度过程通常和磁盘I/O的方式是相同。
  3. 软中断仅与内核相联系。而内核主要负责对需要运行的任何其他的进程进行调度。一些内核允许设备驱动的一些部分存在于用户空间,并且当需要的时候内核也会调度这个进程去运行。
  4. 软中断并不会直接中断CPU。也只有当前正在运行的代码(或进程)才会产生软中断。这种中断是一种需要内核为正在运行的进程去做一些事情(通常为I/O)的请求。有一个特殊的软中断是Yield调用,它的作用是请求内核调度器去查看是否有一些其他的进程可以运行。

信号本质

软中断信号(signal,又简称为信号)用来通知进程发生了异步事件。在软件层次上是对中断机制的一种模拟,在原理上,一个进程收到一个信号与处理器收到一个中断请求可以说是一样的。信号是进程间通信机制中唯一的异步通信机制,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。进程之间可以互相通过系统调用kill发送软中断信号。内核也可以因为内部事件而给进程发送信号,通知进程发生了某个事件。信号机制除了基本通知功能外,还可以传递附加信息。

产生信号的条件主要有:

  1. 用户在终端 按下某些键时,终端驱动程序会发送信号给前台进程,例如Ctrl-C产生SIGINT信 号,Ctrl-/产生SIGQUIT信号,Ctrl-Z产生SIGTSTP信号。
  2. 硬件异常产生信号,这些条件由硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了 除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进 程。再比如当前进程访问了非法内存地址,,MMU会产生异常,内核将这个异常解释为SIGSEGV信 号发送给进程。
  3. 一个进程调用kill(2)函数可以发送信 号给另一个进程。
  4. 可以用kill(1)命令发送信号给某个 进程,kill(1)命令也是调用kill(2)函 数实现的,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。
  5. 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产生SIGALRM信 号,向读端已关闭的管道写数据时产生SIGPIPE信号。

收到信号的进程对各种信号有不同的处理方法。处理方法可以分为三类:

  • 第一种是类似中断的处理程序,对于需要处理的信号,进程可以指定处理函数,由该函数来处理。
  • 第二种方法是,忽略某个信号,对该信号不做任何处理,就象未发生过一样。
  • 第三种方法是,对该信号的处理保留系统的默认值,这种缺省操作,对大部分的信号的缺省操作是使得进程终止。进程通过系统调用signal来指定进程对某个信号的处理行为。

信号的种类

可以从两个不同的分类角度对信号进行分类:

  • 可靠性方面:可靠信号与不可靠信号;
  • 与时间的关系上:实时信号与非实时信号。

可靠信号与不可靠信号

Linux信号机制基本上是从Unix系统中继承过来的。早期Unix系统中的信号机制比较简单和原始,信号值小于SIGRTMIN的信号都是不可靠信号。这就是”不可靠信号”的来源。它的主要问题是信号可能丢失。

随着时间的发展,实践证明了有必要对信号的原始机制加以改进和扩充。由于原来定义的信号已有许多应用,不好再做改动,最终只好又新增加了一些信号,并在一开始就把它们定义为可靠信号,这些信号支持排队,不会丢失。

信号值位于SIGRTMIN和SIGRTMAX之间的信号都是可靠信号,可靠信号克服了信号可能丢失的问题。Linux在支持新版本的信号安装函数sigation()以及信号发送函数sigqueue()的同时,仍然支持早期的signal()信号安装函数,支持信号发送函数kill()。

信号的可靠与不可靠只与信号值有关,与信号的发送及安装函数无关。目前linux中的signal()是通过sigation()函数实现的,因此,即使通过signal()安装的信号,在信号处理函数的结尾也不必再调用一次信号安装函数。同时,由signal()安装的实时信号支持排队,同样不会丢失。

对于目前linux的两个信号安装函数:signal()及sigaction()来说,它们都不能把SIGRTMIN以前的信号变成可靠信号(都不支持排队,仍有可能丢失,仍然是不可靠信号),而且对SIGRTMIN以后的信号都支持排队。这两个函数的最大区别在于,经过sigaction安装的信号都能传递信息给信号处理函数,而经过signal安装的信号不能向信号处理函数传递信息。对于信号发送函数来说也是一样的。

实时信号与非实时信号

早期Unix系统只定义了32种信号,前32种信号已经有了预定义值,每个信号有了确定的用途及含义,并且每种信号都有各自的缺省动作。如按键盘的CTRL ^C时,会产生SIGINT信号,对该信号的默认反应就是进程终止。后32个信号表示实时信号,等同于前面阐述的可靠信号。这保证了发送的多个实时信号都被接收。

非实时信号都不支持排队,都是不可靠信号;实时信号都支持排队,都是可靠信号。

信号处理流程

对于一个完整的信号生命周期(从信号发送到相应的处理函数执行完毕)来说,可以分为三个阶段:

  • 信号诞生
  • 信号在进程中注册
  • 信号的执行和注销

信号诞生

信号事件的发生有两个来源:硬件来源(比如我们按下了键盘或者其它硬件故障);软件来源,最常用发送信号的系统函数是kill, raise, alarm和setitimer以及sigqueue函数,软件来源还包括一些非法运算等操作。

这里按发出信号的原因简单分类,以了解各种信号:

  1. 与进程终止相关的信号。当进程退出,或者子进程终止时,发出这类信号。
  2. 与进程例外事件相关的信号。如进程越界,或企图写一个只读的内存区域(如程序正文区),或执行一个特权指令及其他各种硬件错误。
  3. 与在系统调用期间遇到不可恢复条件相关的信号。如执行系统调用exec时,原有资源已经释放,而目前系统资源又已经耗尽。
  4. 与执行系统调用时遇到非预测错误条件相关的信号。如执行一个并不存在的系统调用。
  5. 在用户态下的进程发出的信号。如进程调用系统调用kill向其他进程发送信号。
  6. 与终端交互相关的信号。如用户关闭一个终端,或按下break键等情况。
  7. 跟踪进程执行的信号。

Linux支持的信号列表如下。很多信号是与机器的体系结构相关的

  1. SIGHUP:当用户退出Shell时,由该Shell启的发所有进程都退接收到这个信号,默认动作为终止进程。
  2. SIGINT:用户按下组合键时,用户端时向正在运行中的由该终端启动的程序发出此信号。默认动作为终止进程。
  3. SIGQUIT:当用户按下组合键时产生该信号,用户终端向正在运行中的由该终端启动的程序发出此信号。默认动作为终止进程并产生core文件。
  4. SIGILL :CPU检测到某进程执行了非法指令。默认动作为终止进程并产生core文件。
  5. SIGTRAP:该信号由断点指令或其他trap指令产生。默认动作为终止进程并产生core文件。
  6. SIGABRT:调用abort函数时产生该信号。默认动作为终止进程并产生core文件。
  7. SIGBUS:非法访问内存地址,包括内存地址对齐(alignment)出错,默认动作为终止进程并产生core文件。
  8. SIGFPE:在发生致命的算术错误时产生。不仅包括浮点运行错误,还包括溢出及除数为0等所有的算术错误。默认动作为终止进程并产生core文件。
  9. SIGKILL:无条件终止进程。本信号不能被忽略、处理和阻塞。默认动作为终止进程。它向系统管理员提供了一种可以杀死任何进程的方法。
  10. SIGUSR1:用户定义的信号,即程序可以在程序中定义并使用该信号。默认动作为终止进程。
  11. SIGSEGV:指示进程进行了无效的内存访问。默认动作为终止进程并使用该信号。默认动作为终止进程。
  12. SIGUSR2:这是另外一个用户定义信号,程序员可以在程序中定义并使用该信号。默认动作为终止进程。
  13. SIGPIPE:Broken pipe:向一个没有读端的管道写数据。默认动作为终止进程。
  14. SIGALRM:定时器超时,超时的时间由系统调用alarm设置。默认动作为终止进程。
  15. SIGTERM:程序结束(terminate)信号,与SIGKILL不同的是,该信号可以被阻塞和处理。通常用来要求程序正常退出。执行Shell命令kill时,缺少产生这个信号。默认动作为终止进程。
  16. SIGCHLD:子程序结束时,父进程会收到这个信号。默认动作为忽略该信号。
  17. SIGCONT:让一个暂停的进程继续执行。
  18. SIGSTOP:停止(stopped)进程的执行。注意它和SIGTERM以及SIGINT的区别:该进程还未结束,只是暂停执行。本信号不能被忽略、处理和阻塞。默认作为暂停进程。
  19. SIGTSTP:停止进程的动作,但该信号可以被处理和忽略。按下组合键时发出该信号。默认动作为暂停进程。
  20. SIGTTIN:当后台进程要从用户终端读数据时,该终端中的所有进程会收到SIGTTIN信号。默认动作为暂停进程。
  21. SIGTTOU:该信号类似于SIGTIN,在后台进程要向终端输出数据时产生。默认动作为暂停进程。
  22. SIGURG:套接字(socket)上有紧急数据时,向当前正在运行的进程发出此信号,报告有紧急数据到达。默认动作为忽略该信号。
  23. SIGXCPU:进程执行时间超过了分配给该进程的CPU时间,系统产生该信号并发送给该进程。默认动作为终止进程。
  24. SIGXFSZ:超过文件最大长度的限制。默认动作为yl终止进程并产生core文件。
  25. SIGVTALRM:虚拟时钟超时时产生该信号。类似于SIGALRM,但是它只计算该进程占有用的CPU时间。默认动作为终止进程。
  26. SIGPROF:类似于SIGVTALRM,它不仅包括该进程占用的CPU时间还抱括执行系统调用的时间。默认动作为终止进程。
  27. SIGWINCH:窗口大小改变时发出。默认动作为忽略该信号。
  28. SIGIO:此信号向进程指示发出一个异步IO事件。默认动作为忽略。
  29. SIGPWR:关机。默认动作为终止进程。
  30. SIGRTMIN~SIGRTMAX:Linux的实时信号,它没有固定的含义(或者说可以由用户自由使用)。注意,Linux线程机制使用了前3个实时信号。所有的实时信号的默认动作都是终止进程。

信号在目标进程中注册

在进程表的表项中有一个软中断信号域,该域中每一位对应一个信号。内核给一个进程发送软中断信号的方法,是在进程所在的进程表项的信号域设置对应于该信号的位。如果信号发送给一个正在睡眠的进程,如果进程睡眠在可被中断的优先级上,则唤醒进程;否则仅设置进程表中信号域相应的位,而不唤醒进程。如果发送给一个处于可运行状态的进程,则只置相应的域即可。

进程的task_struct结构中有关于本进程中未决信号的数据成员:struct sigpending pending

1
2
3
4
struct sigpending{
struct sigqueue *head, *tail;
sigset_t signal;
};

第三个成员是进程中所有未决信号集,第一、第二个成员分别指向一个sigqueue类型的结构链(称之为”未决信号信息链”)的首尾,信息链中的每个sigqueue结构刻画一个特定信号所携带的信息,并指向下一个sigqueue结构:
1
2
3
4
struct sigqueue{
struct sigqueue *next;
siginfo_t info;
}

信号在进程中注册指的就是信号值加入到进程的未决信号集sigset_t signal(每个信号占用一位)中,并且信号所携带的信息被保留到未决信号信息链的某个sigqueue结构中。只要信号在进程的未决信号集中,表明进程已经知道这些信号的存在,但还没来得及处理,或者该信号被进程阻塞。

当一个实时信号发送给一个进程时,不管该信号是否已经在进程中注册,都会被再注册一次,因此,信号不会丢失,因此,实时信号又叫做”可靠信号”。这意味着同一个实时信号可以在同一个进程的未决信号信息链中占有多个sigqueue结构(进程每收到一个实时信号,都会为它分配一个结构来登记该信号信息,并把该结构添加在未决信号链尾,即所有诞生的实时信号都会在目标进程中注册)。

当一个非实时信号发送给一个进程时,如果该信号已经在进程中注册(通过sigset_t signal指示),则该信号将被丢弃,造成信号丢失。因此,非实时信号又叫做”不可靠信号”。这意味着同一个非实时信号在进程的未决信号信息链中,至多占有一个sigqueue结构。

总之信号注册与否,与发送信号的函数(如kill()或sigqueue()等)以及信号安装函数(signal()及sigaction())无关,只与信号值有关(信号值小于SIGRTMIN的信号最多只注册一次,信号值在SIGRTMIN及SIGRTMAX之间的信号,只要被进程接收到就被注册)

信号的执行和注销

内核处理一个进程收到的软中断信号是在该进程的上下文中,因此,进程必须处于运行状态。当其由于被信号唤醒或者正常调度重新获得CPU时,在其从内核空间返回到用户空间时会检测是否有信号等待处理。如果存在未决信号等待处理且该信号没有被进程阻塞,则在运行相应的信号处理函数前,进程会把信号在未决信号链中占有的结构卸掉。

对于非实时信号来说,由于在未决信号信息链中最多只占用一个sigqueue结构,因此该结构被释放后,应该把信号在进程未决信号集中删除(信号注销完毕);而对于实时信号来说,可能在未决信号信息链中占用多个sigqueue结构,因此应该针对占用sigqueue结构的数目区别对待:如果只占用一个sigqueue结构(进程只收到该信号一次),则执行完相应的处理函数后应该把信号在进程的未决信号集中删除(信号注销完毕)。否则待该信号的所有sigqueue处理完毕后再在进程的未决信号集中删除该信号。

当所有未被屏蔽的信号都处理完毕后,即可返回用户空间。对于被屏蔽的信号,当取消屏蔽后,在返回到用户空间时会再次执行上述检查处理的一套流程。

内核处理一个进程收到的信号的时机是在一个进程从内核态返回用户态时。所以,当一个进程在内核态下运行时,软中断信号并不立即起作用,要等到将返回用户态时才处理。进程只有处理完信号才会返回用户态,进程在用户态下不会有未处理完的信号。

处理信号有三种类型:

  • 进程接收到信号后退出;
  • 进程忽略该信号;
  • 进程收到信号后执行用户设定用系统调用signal的函数。

当进程接收到一个它忽略的信号时,进程丢弃该信号,就象没有收到该信号似的继续运行。如果进程收到一个要捕捉的信号,那么进程从内核态返回用户态时执行用户定义的函数。而且执行用户定义的函数的方法很巧妙,内核是在用户栈上创建一个新的层,该层中将返回地址的值设置成用户定义的处理函数的地址,这样进程从内核返回弹出栈顶时就返回到用户定义的函数处,从函数返回再弹出栈顶时,才返回原先进入内核的地方。这样做的原因是用户定义的处理函数不能且不允许在内核态下执行(如果用户定义的函数在内核态下运行的话,用户就可以获得任何权限)。

信号的安装

如果进程要处理某一信号,那么就要在进程中安装该信号。安装信号主要用来确定信号值及进程针对该信号值的动作之间的映射关系,即进程将要处理哪个信号;该信号被传递给进程时,将执行何种操作。

linux主要有两个函数实现信号的安装:signal()、sigaction()。其中signal()只有两个参数,不支持信号传递信息,主要是用于前32种非实时信号的安装;而sigaction()是较新的函数(由两个系统调用实现:sys_signal以及sys_rt_sigaction),有三个参数,支持信号传递信息,主要用来与 sigqueue() 系统调用配合使用,当然,sigaction()同样支持非实时信号的安装。sigaction()优于signal()主要体现在支持信号带有参数。

signal()

1
2
#include <signal.h>
void (*signal(int signum, void (*handler))(int)))(int);

如果该函数原型不容易理解的话,可以参考下面的分解方式来理解:

1
2
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler));

第一个参数指定信号的值,第二个参数指定针对前面信号值的处理,可以忽略该信号(参数设为SIG_IGN);可以采用系统默认方式处理信号(参数设为SIG_DFL);也可以自己实现处理方式(参数指定一个函数地址)。

如果signal()调用成功,返回最后一次为安装信号signum而调用signal()时的handler值;失败则返回SIG_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
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
void sigroutine(int dunno)
{ /* 信号处理例程,其中dunno将会得到信号的值 */
switch (dunno) {
case 1:
printf("Get a signal -- SIGHUP ");
break;
case 2:
printf("Get a signal -- SIGINT ");
break;
case 3:
printf("Get a signal -- SIGQUIT ");
break;
}
return;
}

int main() {
printf("process id is %d ",getpid());
signal(SIGHUP, sigroutine); //* 下面设置三个信号的处理方法
signal(SIGINT, sigroutine);
signal(SIGQUIT, sigroutine);
for (;;) ;
}

其中信号SIGINT由按下Ctrl-C发出,信号SIGQUIT由按下Ctrl-发出。该程序执行的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
localhost:~$ ./sig_test
process id is 463
Get a signal -SIGINT //按下Ctrl-C得到的结果
Get a signal -SIGQUIT //按下Ctrl-得到的结果
//按下Ctrl-z将进程置于后台
[1]+ Stopped ./sig_test
localhost:~$ bg
[1]+ ./sig_test &
localhost:~$ kill -HUP 463 //向进程发送SIGHUP信号
localhost:~$ Get a signal – SIGHUP
kill -9 463 //向进程发送SIGKILL信号,终止进程
localhost:~$

sigaction()

1
2
#include <signal.h>
int sigaction(int signum,const struct sigaction *act,struct sigaction *oldact));

sigaction函数用于改变进程接收到特定信号后的行为。该函数的第一个参数为信号的值,可以为除SIGKILL及SIGSTOP外的任何一个特定有效的信号(为这两个信号定义自己的处理函数,将导致信号安装错误)。第二个参数是指向结构sigaction的一个实例的指针,在结构sigaction的实例中,指定了对特定信号的处理,可以为空,进程会以缺省方式对信号处理;第三个参数oldact指向的对象用来保存返回的原来对相应信号的处理,可指定oldact为NULL。如果把第二、第三个参数都设为NULL,那么该函数可用于检查信号的有效性。

第二个参数最为重要,其中包含了对指定信号的处理、信号所传递的信息、信号处理函数执行过程中应屏蔽掉哪些信号等等。

sigaction结构定义如下:

1
2
3
4
5
6
7
8
struct sigaction {
union{
__sighandler_t _sa_handler;
void (*_sa_sigaction)(int,struct siginfo *, void *);
}_u
sigset_t sa_mask;
unsigned long sa_flags;
}

  1. 联合数据结构中的两个元素_sa_handler以及*_sa_sigaction指定信号关联函数,即用户指定的信号处理函数。除了可以是用户自定义的处理函数外,还可以为SIG_DFL(采用缺省的处理方式),也可以为SIG_IGN(忽略信号)。
  2. 由_sa_sigaction是指定的信号处理函数带有三个参数,是为实时信号而设的(当然同样支持非实时信号),它指定一个3参数信号处理函数。第一个参数为信号值,第三个参数没有使用,第二个参数是指向siginfo_t结构的指针,结构中包含信号携带的数据值,参数所指向的结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
siginfo_t {
int si_signo; /* 信号值,对所有信号有意义*/
int si_errno; /* errno值,对所有信号有意义*/
int si_code; /* 信号产生的原因,对所有信号有意义*/
union{ /* 联合数据结构,不同成员适应不同信号 */
//确保分配足够大的存储空间
int _pad[SI_PAD_SIZE];
//对SIGKILL有意义的结构
struct{
...
}...
... ...
... ...
//对SIGILL, SIGFPE, SIGSEGV, SIGBUS有意义的结构
struct{
...
}...
... ...
}
}

前面在讨论系统调用sigqueue发送信号时,sigqueue的第三个参数就是sigval联合数据结构,当调用sigqueue时,该数据结构中的数据就将拷贝到信号处理函数的第二个参数中。这样,在发送信号同时,就可以让信号传递一些附加信息。信号可以传递信息对程序开发是非常有意义的。

  1. sa_mask指定在信号处理程序执行过程中,哪些信号应当被阻塞。缺省情况下当前信号本身被阻塞,防止信号的嵌套发送,除非指定SA_NODEFER或者SA_NOMASK标志位。

注:请注意sa_mask指定的信号阻塞的前提条件,是在由sigaction()安装信号的处理函数执行过程中由sa_mask指定的信号才被阻塞。

  1. sa_flags中包含了许多标志位,包括刚刚提到的SA_NODEFER及SA_NOMASK标志位。另一个比较重要的标志位是SA_SIGINFO,当设定了该标志位时,表示信号附带的参数可以被传递到信号处理函数中,因此,应该为sigaction结构中的sa_sigaction指定处理函数,而不应该为sa_handler指定信号处理函数,否则,设置该标志变得毫无意义。即使为sa_sigaction指定了信号处理函数,如果不设置SA_SIGINFO,信号处理函数同样不能得到信号传递过来的数据,在信号处理函数中对这些信息的访问都将导致段错误(Segmentation fault)。

信号的发送

发送信号的主要函数有:kill()、raise()、 sigqueue()、alarm()、setitimer()以及abort()。

kill()

1
2
3
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid,int signo)

该系统调用可以用来向任何进程或进程组发送任何信号。参数pid的值为信号的接收进程

  • pid>0 进程ID为pid的进程
  • pid=0 同一个进程组的进程
  • pid<0 pid!=-1 进程组ID为 -pid的所有进程
  • pid=-1 除发送进程自身外,所有进程ID大于1的进程

Sinno是信号值,当为0时(即空信号),实际不发送任何信号,但照常进行错误检查,因此,可用于检查目标进程是否存在,以及当前进程是否具有向目标发送信号的权限(root权限的进程可以向任何进程发送信号,非root权限的进程只能向属于同一个session或者同一个用户的进程发送信号)。

Kill()最常用于pid>0时的信号发送。该调用执行成功时,返回值为0;错误时,返回-1,并设置相应的错误代码errno。下面是一些可能返回的错误代码:
-EINVAL:指定的信号sig无效。
-ESRCH:参数pid指定的进程或进程组不存在。注意,在进程表项中存在的进程,可能是一个还没有被wait收回,但已经终止执行的僵死进程。
-EPERM: 进程没有权力将这个信号发送到指定接收信号的进程。因为,一个进程被允许将信号发送到进程pid时,必须拥有root权力,或者是发出调用的进程的UID 或EUID与指定接收的进程的UID或保存用户ID(savedset-user-ID)相同。如果参数pid小于-1,即该信号发送给一个组,则该错误表示组中有成员进程不能接收该信号。

sigqueue()

1
2
3
#include <sys/types.h>
#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval val)

调用成功返回 0;否则,返回 -1。

sigqueue()是比较新的发送信号系统调用,主要是针对实时信号提出的(当然也支持前32种),支持信号带有参数,与函数sigaction()配合使用。

sigqueue的第一个参数是指定接收信号的进程ID,第二个参数确定即将发送的信号,第三个参数是一个联合数据结构union sigval,指定了信号传递的参数,即通常所说的4字节值。

1
2
3
4
typedef union sigval {
int sival_int;
void *sival_ptr;
}sigval_t;

sigqueue()比kill()传递了更多的附加信息,但sigqueue()只能向一个进程发送信号,而不能发送信号给一个进程组。如果signo=0,将会执行错误检查,但实际上不发送任何信号,0值信号可用于检查pid的有效性以及当前进程是否有权限向目标进程发送信号。

在调用sigqueue时,sigval_t指定的信息会拷贝到对应sig 注册的3参数信号处理函数的siginfo_t结构中,这样信号处理函数就可以处理这些信息了。由于sigqueue系统调用支持发送带参数信号,所以比kill()系统调用的功能要灵活和强大得多。

alarm()

1
2
#include <unistd.h>
unsigned int alarm(unsigned int seconds)

系统调用alarm安排内核为调用进程在指定的seconds秒后发出一个SIGALRM的信号。如果指定的参数seconds为0,则不再发送 SIGALRM信号。后一次设定将取消前一次的设定。该调用返回值为上次定时调用到发送之间剩余的时间,或者因为没有前一次定时调用而返回0。

注意,在使用时,alarm只设定为发送一次信号,如果要多次发送,就要多次使用alarm调用。

setitimer()

现在的系统中很多程序不再使用alarm调用,而是使用setitimer调用来设置定时器,用getitimer来得到定时器的状态,这两个调用的声明格式如下:

1
2
int getitimer(int which, struct itimerval *value);
int setitimer(int which, const struct itimerval *value, struct itimerval *ovalue);

在使用这两个调用的进程中加入以下头文件:
1
#include <sys/time.h>

该系统调用给进程提供了三个定时器,它们各自有其独有的计时域,当其中任何一个到达,就发送一个相应的信号给进程,并使得计时器重新开始。三个计时器由参数which指定,如下所示:

TIMER_REAL:按实际时间计时,计时到达将给进程发送SIGALRM信号。

ITIMER_VIRTUAL:仅当进程执行时才进行计时。计时到达将发送SIGVTALRM信号给进程。

ITIMER_PROF:当进程执行时和系统为该进程执行动作时都计时。与ITIMER_VIR-TUAL是一对,该定时器经常用来统计进程在用户态和内核态花费的时间。计时到达将发送SIGPROF信号给进程。

定时器中的参数value用来指明定时器的时间,其结构如下:

1
2
3
4
struct itimerval {
struct timeval it_interval; /* 下一次的取值 */
struct timeval it_value; /* 本次的设定值 */
};

该结构中timeval结构定义如下:
1
2
3
4
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 微秒,1秒 = 1000000 微秒*/
};

在setitimer 调用中,参数ovalue如果不为空,则其中保留的是上次调用设定的值。定时器将it_value递减到0时,产生一个信号,并将it_value的值设定为it_interval的值,然后重新开始计时,如此往复。当it_value设定为0时,计时器停止,或者当它计时到期,而it_interval 为0时停止。调用成功时,返回0;错误时,返回-1,并设置相应的错误代码errno:

EFAULT:参数value或ovalue是无效的指针。

EINVAL:参数which不是ITIMER_REAL、ITIMER_VIRT或ITIMER_PROF中的一个。

下面是关于setitimer调用的一个简单示范,在该例子中,每隔一秒发出一个SIGALRM,每隔0.5秒发出一个SIGVTALRM信号:

1
2
3
4
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 <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/time.h>
int sec;
void sigroutine(int signo) {
switch (signo) {
case SIGALRM:
printf("Catch a signal -- SIGALRM ");
break;
case SIGVTALRM:
printf("Catch a signal -- SIGVTALRM ");
break;
}
return;
}
int main()
{
struct itimerval value,ovalue,value2;
sec = 5;
printf("process id is %d ",getpid());
signal(SIGALRM, sigroutine);
signal(SIGVTALRM, sigroutine);
value.it_value.tv_sec = 1;
value.it_value.tv_usec = 0;
value.it_interval.tv_sec = 1;
value.it_interval.tv_usec = 0;
setitimer(ITIMER_REAL, &value, &ovalue);
value2.it_value.tv_sec = 0;
value2.it_value.tv_usec = 500000;
value2.it_interval.tv_sec = 0;
value2.it_interval.tv_usec = 500000;
setitimer(ITIMER_VIRTUAL, &value2, &ovalue);
for (;;) ;
}

该例子的屏幕拷贝如下:

1
2
3
4
5
6
7
8
localhost:~$ ./timer_test
process id is 579
Catch a signal – SIGVTALRM
Catch a signal – SIGALRM
Catch a signal – SIGVTALRM
Catch a signal – SIGVTALRM
Catch a signal – SIGALRM
Catch a signal –GVTALRM

abort()

1
2
#include <stdlib.h>
void abort(void);

向进程发送SIGABORT信号,默认情况下进程会异常退出,当然可定义自己的信号处理函数。即使SIGABORT被进程设置为阻塞信号,调用abort()后,SIGABORT仍然能被进程接收。该函数无返回值。

raise()

1
2
#include <signal.h>
int raise(int signo)

向进程本身发送信号,参数为即将发送的信号值。调用成功返回 0;否则,返回 -1。

信号集及信号集操作函数:

信号集被定义为一种数据类型:

1
2
3
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t

信号集用来描述信号的集合,每个信号占用一位。Linux所支持的所有信号可以全部或部分的出现在信号集中,主要与信号阻塞相关函数配合使用。下面是为信号集操作定义的相关函数:
1
2
3
4
5
6
7
8
9
10
11
#include <signal.h>
int sigemptyset(sigset_t *set)
int sigfillset(sigset_t *set)
int sigaddset(sigset_t *set, int signum)
int sigdelset(sigset_t *set, int signum)
int sigismember(const sigset_t *set, int signum)
sigemptyset(sigset_t *set)初始化由set指定的信号集,信号集里面的所有信号被清空;
sigfillset(sigset_t *set)调用该函数后,set指向的信号集中将包含linux支持的64种信号;
sigaddset(sigset_t *set, int signum)在set指向的信号集中加入signum信号;
sigdelset(sigset_t *set, int signum)在set指向的信号集中删除signum信号;
sigismember(const sigset_t *set, int signum)判定信号signum是否在set指向的信号集中。

信号阻塞与信号未决

每个进程都有一个用来描述哪些信号递送到进程时将被阻塞的信号集,该信号集中的所有信号在递送到进程后都将被阻塞。下面是与信号阻塞相关的几个函数:

1
2
3
4
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset));
int sigpending(sigset_t *set));
int sigsuspend(const sigset_t *mask));

sigprocmask()函数能够根据参数how来实现对信号集的操作,操作主要有三种:

  • SIG_BLOCK 在进程当前阻塞信号集中添加set指向信号集中的信号
  • SIG_UNBLOCK 如果进程阻塞信号集中包含set指向信号集中的信号,则解除对该信号的阻塞
  • SIG_SETMASK 更新进程阻塞信号集为set指向的信号集

sigpending(sigset_t *set))获得当前已递送到进程,却被阻塞的所有信号,在set指向的信号集中返回结果。

sigsuspend(const sigset_t *mask))用于在接收到某个信号之前, 临时用mask替换进程的信号掩码, 并暂停进程执行,直到收到信号为止。sigsuspend 返回后将恢复调用之前的信号掩码。信号处理函数完成后,进程将继续执行。该系统调用始终返回-1,并将errno设置为EINTR。

信号应用实例

linux下的信号应用并没有想象的那么恐怖,程序员所要做的最多只有三件事情:

  • 安装信号(推荐使用sigaction());
  • 实现三参数信号处理函数,handler(int signal,struct siginfo info, void );
  • 发送信号,推荐使用sigqueue()。

实际上,对有些信号来说,只要安装信号就足够了(信号处理方式采用缺省或忽略)。其他可能要做的无非是与信号集相关的几种操作。

实例一:信号发送及处理

实现一个信号接收程序sigreceive(其中信号安装由sigaction())。

1
2
3
4
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 <signal.h>
#include <sys/types.h>
#include <unistd.h>
void new_op(int,siginfo_t*,void*);
int main(int argc,char**argv)
{
struct sigaction act;
int sig;
sig=atoi(argv[1]);

sigemptyset(&act.sa_mask);
act.sa_flags=SA_SIGINFO;
act.sa_sigaction=new_op;

if(sigaction(sig,&act,NULL) < 0)
{
printf("install sigal error\n");
}

while(1)
{
sleep(2);
printf("wait for the signal\n");
}
}

void new_op(int signum,siginfo_t *info,void *myact)
{
printf("receive signal %d", signum);
sleep(5);
}

说明,命令行参数为信号值,后台运行sigreceive signo &,可获得该进程的ID,假设为pid,然后再另一终端上运行kill -s signo 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
34
35
36
37
38
39
40
41
42
43
44
45
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
void new_op(int,siginfo_t*,void*);
int main(int argc,char**argv)
{
struct sigaction act;
union sigval mysigval;
int i;
int sig;
pid_t pid;
char data[10];
memset(data,0,sizeof(data));
for(i=0;i < 5;i++)
data[i]='2';
mysigval.sival_ptr=data;

sig=atoi(argv[1]);
pid=getpid();

sigemptyset(&act.sa_mask);
act.sa_sigaction=new_op;//三参数信号处理函数
act.sa_flags=SA_SIGINFO;//信息传递开关,允许传说参数信息给new_op
if(sigaction(sig,&act,NULL) < 0)
{
printf("install sigal error\n");
}
while(1)
{
sleep(2);
printf("wait for the signal\n");
sigqueue(pid,sig,mysigval);//向本进程发送信号,并传递附加信息
}
}

void new_op(int signum,siginfo_t *info,void *myact)//三参数信号处理函数的实现
{
int i;
for(i=0;i<10;i++)
{
printf("%c\n ",(*( (char*)((*info).si_ptr)+i)));
}

printf("handle signal %d over;",signum);
}

这个例子中,信号实现了附加信息的传递,信号究竟如何对这些信息进行处理则取决于具体的应用。

不同进程间传递整型参数:

把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 <signal.h>
#include <sys/types.h>
#include <unistd.h>
void new_op(int,siginfo_t*,void*);
int main(int argc,char**argv)
{
struct sigaction act;
int sig;
pid_t pid;

pid=getpid();
sig=atoi(argv[1]);

sigemptyset(&act.sa_mask);
act.sa_sigaction=new_op;
act.sa_flags=SA_SIGINFO;
if(sigaction(sig,&act,NULL)<0)

{
printf("install sigal error\n");
}
while(1)
{
sleep(2);
printf("wait for the signal\n");
}
}
void new_op(int signum,siginfo_t *info,void *myact)
{
printf("the int value is %d \n",info->si_int);
}

信号发送程序:
命令行第二个参数为信号值,第三个参数为接收进程ID。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <signal.h>
#include <sys/time.h>
#include <unistd.h>
#include <sys/types.h>
main(int argc,char**argv)
{
pid_t pid;
int signum;
union sigval mysigval;
signum=atoi(argv[1]);
pid=(pid_t)atoi(argv[2]);
mysigval.sival_int=8;//不代表具体含义,只用于说明问题
if(sigqueue(pid,signum,mysigval)==-1)
printf("send error\n");
sleep(2);
}

注:实例2的两个例子侧重点在于用信号来传递信息,目前关于在linux下通过信号传递信息的实例非常少,倒是Unix下有一些,但传递的基本上都是关于传递一个整数

实例三:信号阻塞及信号集操作

1
2
3
4
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 "signal.h"
#include "unistd.h"
static void my_op(int);
main()
{
sigset_t new_mask,old_mask,pending_mask;
struct sigaction act;
sigemptyset(&act.sa_mask);
act.sa_flags=SA_SIGINFO;

act.sa_sigaction=(void*)my_op;
if(sigaction(SIGRTMIN+10,&act,NULL))
printf("install signal SIGRTMIN+10 error\n");
sigemptyset(&new_mask);
sigaddset(&new_mask,SIGRTMIN+10);
if(sigprocmask(SIG_BLOCK, &new_mask,&old_mask))
printf("block signal SIGRTMIN+10 error\n");
sleep(10);
printf("now begin to get pending mask and unblock SIGRTMIN+10\n");

if(sigpending(&pending_mask)<0)
printf("get pending mask error\n");
if(sigismember(&pending_mask,SIGRTMIN+10))
printf("signal SIGRTMIN+10 is pending\n");
if(sigprocmask(SIG_SETMASK,&old_mask,NULL)<0)
printf("unblock signal error\n");
printf("signal unblocked\n");
sleep(10);
}

static void my_op(int signum)
{
printf("receive signal %d \n",signum);
}

编译该程序,并以后台方式运行。在另一终端向该进程发送信号(运行kill -s 42 pid,SIGRTMIN+10为42),查看结果可以看出几个关键函数的运行机制,信号集相关操作比较简单。

总结

一旦有信号产生,用户进程对信号产生的相应有三种方式:

  1. 执行默认操作,linux对每种信号都规定了默认操作。
  2. 捕捉信号,定义信号处理函数,当信号发生时,执行相应的处理函数。
  3. 忽略信号,当不希望接收到的信号对进程的执行产生影响,而让进程继续执行时,可以忽略该信号,即不对信号进程作任何处理。

有两个信号是应用进程无法捕捉和忽略的,即SIGKILL和SEGSTOP,这是为了使系统管理员能在任何时候中断或结束某一特定的进程。

上图表示了Linux中常见的命令

信号发送:

信号发送的关键使得系统知道向哪个进程发送信号以及发送什么信号。下面是信号操作中常用的函数:

例子:创建子进程,为了使子进程不在父进程发出信号前结束,子进程中使用raise函数发送sigstop信号,使自己暂停;父进程使用信号操作的kill函数,向子进程发送sigkill信号,子进程收到此信号,结束子进程。

信号处理

当某个信号被发送到一个正在运行的进程时,该进程即对次特定的信号注册相应的信号处理函数,以完成所需处理。设置信号处理方式的是signal函数,在程序正常结束前,在应用signal函数恢复系统对信号的默认处理方式。


信号阻塞

有时候既不希望进程在接收到信号时立刻中断进程的执行,也不希望此信号完全被忽略掉,而是希望延迟一段时间再去调用信号处理函数,这个时候就需要信号阻塞来完成。

例子:主程序阻塞了cltr+c的sigint信号。用sigpromask将sigint假如阻塞信号集合。

管道:

管道允许在进程之间按先进先出的方式传送数据,是进程间通信的一种常见方式。

管道是Linux 支持的最初Unix IPC形式之一,具有以下特点:

  1. 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道;
  2. 匿名管道只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程);
  3. 单独构成一种独立的文件系统:管道对于管道两端的进程而言,就是一个文件,但它不是普通的文件,它不属于某种文件系统,而是自立门户,单独构成一种文件系统,并且只存在与内存中。

管道分为pipe(无名管道)和fifo(命名管道)两种,除了建立、打开、删除的方式不同外,这两种管道几乎是一样的。他们都是通过内核缓冲区实现数据传输。

pipe用于相关进程之间的通信,例如父进程和子进程,它通过pipe()系统调用来创建并打开,当最后一个使用它的进程关闭对他的引用时,pipe将自动撤销。

FIFO即命名管道,在磁盘上有对应的节点,但没有数据块——换言之,只是拥有一个名字和相应的访问权限,通过mknode()系统调用或者mkfifo()函数来建立的。一旦建立,任何进程都可以通过文件名将其打开和进行读写,而不局限于父子进程,当然前提是进程对FIFO有适当的访问权。当不再被进程使用时,FIFO在内存中释放,但磁盘节点仍然存在。

管道的实质是一个内核缓冲区,进程以先进先出的方式从缓冲区存取数据:管道一端的进程顺序地将进程数据写入缓冲区,另一端的进程则顺序地读取数据,该缓冲区可以看做一个循环队列,读和写的位置都是自动增加的,一个数据只能被读一次,读出以后再缓冲区都不复存在了。当缓冲区读空或者写满时,有一定的规则控制相应的读进程或写进程是否进入等待队列,当空的缓冲区有新数据写入或慢的缓冲区有数据读出时,就唤醒等待队列中的进程继续读写。

管道的作用是在具有亲缘关系的进程之间传递消息,所谓有亲缘关系,是指有同一个祖先。所以管道并不是只可以用于父子进程通信,也可以在兄弟进程之间还可以用在祖孙之间等,反正只要共同的祖先调用了pipe函数,打开的管道文件就会在fork之后,被各个后代所共享。
不过由于管道是字节流通信,没有消息边界,多个进程同时发送的字节流混在一起,则无法分辨消息,所有管道一般用于2个进程之间通信,另外管道的内容读完后不会保存,管道是单向的,一边要么读,一边要么写,不可以又读又写,想要一边读一边写,那就创建2个管道,如下图

管道是一种文件,可以调用read、write和close等操作文件的接口来操作管道。另一方面管道又不是一种普通的文件,它属于一种独特的文件系统:pipefs。管道的本质是内核维护了一块缓冲区与管道文件相关联,对管道文件的操作,被内核转换成对这块缓冲区内存的操作。下面我们来看一下如何使用管道。

1
2
#include<unistd.h>
int pipe(int fd[2])

如果成功,则返回值是0,如果失败,则返回值是-1,并且设置errno。
成功调用pipe函数之后,会返回两个打开的文件描述符,一个是管道的读取端描述符pipefd[0],另一个是管道的写入端描述符pipefd[1]。管道没有文件名与之关联,因此程序没有选择,只能通过文件描述符来访问管道,只有那些能看到这两个文件描述符的进程才能够使用管道。那么谁能看到进程打开的文件描述符呢?只有该进程及该进程的子孙进程才能看到。这就限制了管道的使用范围。

成功调用pipe函数之后,可以对写入端描述符pipefd[1]调用write,向管道里面写入数据,代码如下所示:

1
write(pipefd[1],wbuf,count);

一旦向管道的写入端写入数据后,就可以对读取端描述符pipefd[0]调用read,读出管道里面的内容。如下所示,管道上的read调用返回的字节数等于请求字节数和管道中当前存在的字节数的最小值。如果当前管道为空,那么read调用会阻塞(如果没有设置O_NONBLOCK标志位的话)。

无名管道:

pipe的例子:父进程创建管道,并在管道中写入数据,而子进程从管道读出数据

命名管道:

和无名管道的主要区别在于,命名管道有一个名字,命名管道的名字对应于一个磁盘索引节点,有了这个文件名,任何进程有相应的权限都可以对它进行访问。

而无名管道却不同,进程只能访问自己或祖先创建的管道,而不能访任意访问已经存在的管道——因为没有名字。

Linux中通过系统调用mknod()或makefifo()来创建一个命名管道。最简单的方式是通过直接使用shell

1
mkfifo myfifo

等价于
1
mknod myfifo p

以上命令在当前目录下创建了一个名为myfifo的命名管道。用ls -p命令查看文件的类型时,可以看到命名管道对应的文件名后有一条竖线”|”,表示该文件不是普通文件而是命名管道。

使用open()函数通过文件名可以打开已经创建的命名管道,而无名管道不能由open来打开。当一个命名管道不再被任何进程打开时,它没有消失,还可以再次被打开,就像打开一个磁盘文件一样。

可以用删除普通文件的方法将其删除,实际删除的事磁盘上对应的节点信息。

例子:用命名管道实现聊天程序,一个张三端,一个李四端。两个程序都建立两个命名管道,fifo1,fifo2,张三写fifo1,李四读fifo1;李四写fifo2,张三读fifo2。

用select把管道描述符和stdin加入集合,用select进行阻塞,如果有i/o的时候唤醒进程。(粉红色部分为select部分,黄色部分为命名管道部分)



在linux系统中,除了用pipe系统调用建立管道外,还可以使用C函数库中管道函数popen函数来建立管道,使用pclose关闭管道。

例子:设计一个程序用popen创建管道,实现 ls -l |grep main.c的功能

分析:先用popen函数创建一个读管道,调用fread函数将ls -l的结果存入buf变量,用printf函数输出内容,用pclose关闭读管道;

接着用popen函数创建一个写管道,调用fprintf函数将buf的内容写入管道,运行grep命令。

popen的函数原型:

1
FILE* popen(const char* command,const char* type);

参数说明:command是子进程要执行的命令,type表示管道的类型,r表示读管道,w代表写管道。如果成功返回管道文件的指针,否则返回NULL。

使用popen函数读写管道,实际上也是调用pipe函数调用建立一个管道,再调用fork函数建立子进程,接着会建立一个shell 环境,并在这个shell环境中执行参数所指定的进程。

消息队列:

消息队列,就是一个消息的链表,是一系列保存在内核中消息的列表。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。

消息队列与管道通信相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序,而是可以根据自定义条件接收特定类型的消息。

可以把消息看做一个记录,具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以向消息队列中按照一定的规则添加新消息,对消息队列有读权限的进程可以从消息队列中读取消息。

消息队列的常用函数如下表:

进程间通过消息队列通信,主要是:创建或打开消息队列,添加消息,读取消息和控制消息队列。

例子:用函数msget创建消息队列,调用msgsnd函数,把输入的字符串添加到消息队列中,然后调用msgrcv函数,读取消息队列中的消息并打印输出,最后再调用msgctl函数,删除系统内核中的消息队列。(黄色部分是消息队列相关的关键代码,粉色部分是读取stdin的关键代码)


消息队列可以认为是一个链表。进程(线程)可以往里写消息,也可以从里面取出消息。一个进程可以往某个消息队列里写消息,然后终止,另一个进程随时可以从消息队列里取走这些消息。这里也说明了,消息队列具有随内核的持续性,也就是系统不重启,消息队列永久存在。

创建(并打开)、关闭、删除一个消息队列

1
2
3
4
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 <stdio.h>  
#include <stdlib.h>
#include <mqueue.h> //头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

#define MQ_NAME ("/tmp")
#define MQ_FLAG (O_RDWR | O_CREAT | O_EXCL) // 创建MQ的flag
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) // 设定创建MQ的权限

int main()

{
mqd_t posixmq;
int rc = 0;

/*
函数说明:函数创建或打开一个消息队列
返回值:成功返回消息队列描述符,失败返回-1,错误原因存于errno中
*/
posixmq = mq_open(MQ_NAME, MQ_FLAG, FILE_MODE, NULL);

if(-1 == posixmq)
{
perror("创建MQ失败");
exit(1);
}

/*
函数说明:关闭一个打开的消息队列,表示本进程不再对该消息队列读写
返回值:成功返回0,失败返回-1,错误原因存于errno中
*/
rc = mq_close(posixmq);
if(0 != rc)
{
perror("关闭失败");
exit(1);
}

/*
函数说明:删除一个消息队列,好比删除一个文件,其他进程再也无法访问
返回值:成功返回0,失败返回-1,错误原因存于errno中
*/
rc = mq_unlink(MQ_NAME);
if(0 != rc)
{
perror("删除失败");
exit(1);
}

return 0;
}

编译并执行:
1
2
3
4
5
6
root@linux:/mnt/hgfs/C_libary# gcc -o crtmq crtmq.c
/tmp/ccZ9cTxo.o: In function `main':
crtmq.c:(.text+0x31): undefined reference to `mq_open'
crtmq.c:(.text+0x60): undefined reference to `mq_close'
crtmq.c:(.text+0x8f): undefined reference to `mq_unlink'
collect2: ld returned 1 exit status

因为mq_XXX()函数不是标准库函数,链接时需要指定;库-lrt;
1
2
root@linux:/mnt/hgfs/C_libary# gcc -o crtmq crtmq.c -lrt
root@linux:/mnt/hgfs/C_libary# ./crtmq

最后程序并没有删除消息队列(消息队列有随内核持续性),如再次执行该程序则会给出错误信息:
1
2
root@linux:/mnt/hgfs/C_libary# ./crtmq 
创建MQ失败: File exit(0)

编译这个程序需要注意几点:

  1. 消息队列的名字最好使用“/”打头,并且只有一个“/”的名字。否则可能出现移植性问题;(还需保证在根目录有写权限,为了方便我在root权限下测试)
  2. 创建成功的消息队列不一定能看到,使用一些方法也可以看到,本文不做介绍;

消息队列的名字有如此规定,引用《UNIX网络编程 卷2》的相关描述: mq_open,sem_open,shm_open这三个函数的第一个参数是
一个IPC名字,它可能是某个文件系统中的一个真正存在的路径名,也可能不是。Posix.1是这样描述Posix IPC名字的。

  1. 它必须符合已有的路径名规则(最多由PATH_MAX个字节构成,包括结尾的空字节)
  2. 如果它以斜杠开头,那么对这些函数的不同调用将访问同一个队列,否则效果取决于实现(也就是效果没有标准化)
  3. 名字中的额外的斜杠符的解释由实现定义(同样是没有标准化) 因此,为便于移植起见,Posix IPC名字必须以一个斜杠打头,并且不能再包含任何其他斜杠符。

IPC通信:Posix消息队列读,写
创建消息队列的程序:

1
2
3
4
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 <stdio.h>  
#include <stdlib.h>
#include <mqueue.h> //头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

#define MQ_NAME ("/tmp")
#define MQ_FLAG (O_RDWR | O_CREAT | O_EXCL) // 创建MQ的flag
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) // 设定创建MQ的权限

int main()

{
mqd_t posixmq;
int rc = 0;

/*
函数说明:函数创建或打开一个消息队列
返回值:成功返回消息队列描述符,失败返回-1,错误原因存于errno中
*/
posixmq = mq_open(MQ_NAME, MQ_FLAG, FILE_MODE, NULL);

if(-1 == posixmq)
{
perror("创建MQ失败");
exit(1);
}

/*
函数说明:关闭一个打开的消息队列,表示本进程不再对该消息队列读写
返回值:成功返回0,失败返回-1,错误原因存于errno中
*/
rc = mq_close(posixmq);
if(0 != rc)
{
perror("关闭失败");
exit(1);
}

#if 0
/*
函数说明:删除一个消息队列,好比删除一个文件,其他进程再也无法访问
返回值:成功返回0,失败返回-1,错误原因存于errno中
*/
rc = mq_unlink(MQ_NAME);
if(0 != rc)
{
perror("删除失败");
exit(1);
}

return 0;
#endif
}

编译并执行:
1
2
root@linux:/mnt/hgfs/C_libary# gcc -o crtmq crtmq.c -lrt
root@linux:/mnt/hgfs/C_libary# ./crtmq

程序并没有删除消息队列(消息队列有随内核持续性),如再次执行该程序则会给出错误信息:
1
2
root@linux:/mnt/hgfs/C_libary# ./crtmq 
创建MQ失败: File exit(0)

向消息队列写消息的程序:

消息队列的读写主要使用下面两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*头文件*/
#include <mqueue.h>

/*返回:若成功则为消息中字节数,若出错则为-1 */
int mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned msg_prio);

/*返回:若成功则为0, 若出错则为-1*/
ssize_t mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned *msg_prio);

/*消息队列属性结构体*/
struct mq_attr {
long mq_flags; /* Flags: 0 or O_NONBLOCK */
long mq_maxmsg; /* Max. # of messages on queue */
long mq_msgsize; /* Max. message size (bytes) */
long mq_curmsgs; /* # of messages currently in 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
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 <stdio.h>  
#include <stdlib.h>
#include <mqueue.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

/*向消息队列发送消息,消息队列名及发送的信息通过参数传递*/
int main(int argc, char *argv[])
{
mqd_t mqd;
char *ptr;
size_t len;
unsigned int prio;
int rc;

if(argc != 4)
{
printf("Usage: sendmq <name> <bytes> <priority>\n");
exit(1);
}

len = atoi(argv[2]);
prio = atoi(argv[3]);

//只写模式找开消息队列
mqd = mq_open(argv[1], O_WRONLY);
if(-1 == mqd)
{
perror("打开消息队列失败");
exit(1);
}

// 动态申请一块内存
ptr = (char *) calloc(len, sizeof(char));
if(NULL == ptr)
{
perror("申请内存失败");
mq_close(mqd);
exit(1);
}

/*向消息队列写入消息,如消息队列满则阻塞,直到消息队列有空闲时再写入*/
rc = mq_send(mqd, ptr, len, prio);
if(rc < 0)
{
perror("写入消息队列失败");
mq_close(mqd);
exit(1);
}

// 释放内存
free(ptr);
return 0;
}

编译并执行:
1
2
3
4
5
root@linux:/mnt/hgfs/C_libary# gcc -o sendmq sendmq.c -lrt
root@linux:/mnt/hgfs/C_libary# ./sendmq /tmp 30 15
root@linux:/mnt/hgfs/C_libary# ./sendmq /tmp 30 16
root@linux:/mnt/hgfs/C_libary# ./sendmq /tmp 30 17
root@linux:/mnt/hgfs/C_libary# ./sendmq /tmp 30 18

上面先后向消息队列“/tmp”写入了四条消息,因为先前创建的消息队列只允许存放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
#include <stdio.h>  
#include <stdlib.h>
#include <mqueue.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

/*读取某消息队列,消息队列名通过参数传递*/
int main(int argc, char *argv[])
{
mqd_t mqd;
struct mq_attr attr;
char *ptr;
unsigned int prio;
size_t n;
int rc;

if(argc != 2)
{
printf("Usage: readmq <name>\n");
exit(1);
}

/*只读模式打开消息队列*/
mqd = mq_open(argv[1], O_RDONLY);
if(mqd < 0)
{
perror("打开消息队列失败");
exit(1);
}

// 取得消息队列属性,根据mq_msgsize动态申请内存
rc = mq_getattr(mqd, &attr);
if(rc < 0)
{
perror("取得消息队列属性失败");
exit(1);
}

/*动态申请保证能存放单条消息的内存*/
ptr = calloc(attr.mq_msgsize, sizeof(char));
if(NULL == ptr)
{
printf("动态申请内存失败\n");
mq_close(mqd);
exit(1);
}

/*接收一条消息*/
n = mq_receive(mqd, ptr, attr.mq_msgsize, &prio);
if(n < 0)
{
perror("读取失败");
mq_close(mqd);
free(ptr);
exit(1);
}

printf("读取 %ld 字节\n 优先级为 %u\n", (long)n, prio);
return 0;
}

编译并执行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
root@linux:/mnt/hgfs/C_libary# vi readmq.c
root@linux:/mnt/hgfs/C_libary# vi readmq.c
root@linux:/mnt/hgfs/C_libary# gcc -o readmq readmq.c -lrt
root@linux:/mnt/hgfs/C_libary# ./readmq /tmp
读取 30 字节
优先级为 18
root@linux:/mnt/hgfs/C_libary# ./readmq /tmp
读取 30 字节
优先级为 17
root@linux:/mnt/hgfs/C_libary# ./readmq /tmp
读取 30 字节
优先级为 16
root@linux:/mnt/hgfs/C_libary# ./readmq /tmp
读取 30 字节
优先级为 15
root@linux:/mnt/hgfs/C_libary# ./readmq /tmp

程序执行五次,第一次执行完,先前阻塞在写处的程序成功返回。第五次执行,因为消息队列已经为空,程序阻塞。直到另外的进程向消息队列写入一条消息。另外,还可以看出Posix消息队列每次读出的都是消息队列中优先级最高的消息。

IPC通信:Posix消息队列的属性设置

Posix消息队列的属性使用如下结构存放:

1
2
3
4
5
6
7
struct mq_attr  
{
long mq_flags; /*阻塞标志位,0为非阻塞(O_NONBLOCK)*/
long mq_maxmsg; /*队列所允许的最大消息条数*/
long mq_msgsize; /*每条消息的最大字节数*/
long mq_curmsgs; /*队列当前的消息条数*/
};

队列可以在创建时由mq_open()函数的第四个参数指定mq_maxmsg,mq_msgsize。 如创建时没有指定则使用默认值,一旦创建,则不可再改变。
队列可以在创建后由mq_setattr()函数设置mq_flags
1
2
3
4
5
6
7
8
9
#include <mqueue.h>  

/*取得消息队列属性,放到mqstat地fh*/
int mq_getattr(mqd_t mqdes, struct mq_attr *mqstat);

/*设置消息队列属性,设置值由mqstat提供,原先值写入omqstat*/
int mq_setattr(mqd_t mqdes, const struct mq_attr *mqstat, struct mq_attr *omqstat);

均返回:若成功则为0,若出错为-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
#include <stdio.h>  
#include <stdlib.h>
#include <mqueue.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

#define MQ_NAME ("/tmp")
#define MQ_FLAG (O_RDWR | O_CREAT | O_EXCL) // 创建MQ的flag
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) // 设定创建MQ的权限

int main()
{
mqd_t posixmq;
int rc = 0;

struct mq_attr mqattr;

// 创建默认属性的消息队列
posixmq = mq_open(MQ_NAME, MQ_FLAG, FILE_MODE, NULL);
if(-1 == posixmq)
{
perror("创建MQ失败");
exit(1);
}

// 获取消息队列的默认属性
rc = mq_getattr(posixmq, &mqattr);
if(-1 == rc)
{
perror("获取消息队列属性失败");
exit(1);
}

printf("队列阻塞标志位:%ld\n", mqattr.mq_flags);
printf("队列允许最大消息数:%ld\n", mqattr.mq_maxmsg);
printf("队列消息最大字节数:%ld\n", mqattr.mq_msgsize);
printf("队列当前消息条数:%ld\n", mqattr.mq_curmsgs);

rc = mq_close(posixmq);
if(0 != rc)
{
perror("关闭失败");
exit(1);
}

rc = mq_unlink(MQ_NAME);
if(0 != rc)
{
perror("删除失败");
exit(1);
}
return 0;
}

编译并执行:
1
2
3
4
5
6
7
root@linux:/mnt/hgfs/C_libary# gcc -o attrmq attrmq.c -lrt
root@linux:/mnt/hgfs/C_libary# ./attrmq
队列阻塞标志位:0
队列允许最大消息数:10
队列消息最大字节数:8192
队列当前消息条数:0
root@linux:/mnt/hgfs/C_libary#

设置消息队列的属性:
1
2
3
4
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 <stdio.h>  
#include <stdlib.h>
#include <mqueue.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>

#define MQ_NAME ("/tmp")
#define MQ_FLAG (O_RDWR | O_CREAT | O_EXCL) // 创建MQ的flag
#define FILE_MODE (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) // 设定创建MQ的权限

int main()
{
mqd_t posixmq;
int rc = 0;

struct mq_attr mqattr;

// 创建默认属性的消息队列
mqattr.mq_maxmsg = 5; // 注意不能超过系统最大限制
mqattr.mq_msgsize = 8192;
//posixmq = mq_open(MQ_NAME, MQ_FLAG, FILE_MODE, NULL);
posixmq = mq_open(MQ_NAME, MQ_FLAG, FILE_MODE, &mqattr);

if(-1 == posixmq)
{
perror("创建MQ失败");
exit(1);
}

mqattr.mq_flags = 0;
mq_setattr(posixmq, &mqattr, NULL);// mq_setattr()只关注mq_flags,adw

// 获取消息队列的属性
rc = mq_getattr(posixmq, &mqattr);
if(-1 == rc)
{
perror("获取消息队列属性失败");
exit(1);
}

printf("队列阻塞标志位:%ld\n", mqattr.mq_flags);
printf("队列允许最大消息数:%ld\n", mqattr.mq_maxmsg);
printf("队列消息最大字节数:%ld\n", mqattr.mq_msgsize);
printf("队列当前消息条数:%ld\n", mqattr.mq_curmsgs);

rc = mq_close(posixmq);
if(0 != rc)
{
perror("关闭失败");
exit(1);
}

rc = mq_unlink(MQ_NAME);
if(0 != rc)
{
perror("删除失败");
exit(1);
}

return 0;
}

编译运行:
1
2
3
4
5
6
root@linux:/mnt/hgfs/C_libary# gcc -o setattrmq setattrmq.c -lrt
root@linux:/mnt/hgfs/C_libary# ./setattrmq
队列阻塞标志位:0
队列允许最大消息数:5
队列消息最大字节数:8192
队列当前消息条数:0

共享内存:

共享内存允许两个或多个进程共享一个给定的存储区,这一段存储区可以被两个或两个以上的进程映射至自身的地址空间中,一个进程写入共享内存的信息,可以被其他使用这个共享内存的进程,通过一个简单的内存读取错做读出,从而实现了进程间的通信。

采用共享内存进行通信的一个主要好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝,对于像管道和消息队里等通信方式,则需要再内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次:一次从输入文件到共享内存区,另一次从共享内存到输出文件。

一般而言,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时在重新建立共享内存区域;而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件,因此,采用共享内存的通信方式效率非常高。

共享内存有两种实现方式:1、内存映射 2、共享内存机制

什么是共享内存

顾名思义,共享内存就是允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式。不同进程之间共享的内存通常安排为同一段物理内存。进程可以将同一段共享内存连接到它们自己的地址空间中,所有进程都可以访问共享内存中的地址,就好像它们是由用C语言函数malloc分配的内存一样。而如果某个进程向共享内存写入数据,所做的改动将立即影响到可以访问同一段共享内存的任何其他进程。

特别提醒:共享内存并未提供同步机制,也就是说,在第一个进程结束对共享内存的写操作之前,并无自动机制可以阻止第二个进程开始对它进行读取。所以我们通常需要用其他的机制来同步对共享内存的访问,例如前面说到的信号量。

共享内存的使得

与信号量一样,在Linux中也提供了一组函数接口用于使用共享内存,而且使用共享共存的接口还与信号量的非常相似,而且比使用信号量的接口来得简单。它们声明在头文件 sys/shm.h中。

shmget函数

该函数用来创建共享内存,它的原型为:

1
int shmget(key_t key, size_t size, int shmflg);

第一个参数,与信号量的semget函数一样,程序需要提供一个参数key(非0整数),它有效地为共享内存段命名,shmget函数成功时返回一个与key相关的共享内存标识符(非负整数),用于后续的共享内存函数。调用失败返回-1.

不相关的进程可以通过该函数的返回值访问同一共享内存,它代表程序可能要使用的某个资源,程序对所有共享内存的访问都是间接的,程序先通过调用shmget函数并提供一个键,再由系统生成一个相应的共享内存标识符(shmget函数的返回值),只有shmget函数才直接使用信号量键,所有其他的信号量函数使用由semget函数返回的信号量标识符。

第二个参数,size以字节为单位指定需要共享的内存容量

第三个参数,shmflg是权限标志,它的作用与open函数的mode参数一样,如果要想在key标识的共享内存不存在时,创建它的话,可以与IPC_CREAT做或操作。共享内存的权限标志与文件的读写权限一样,举例来说,0644,它表示允许一个进程创建的共享内存被内存创建者所拥有的进程向共享内存读取和写入数据,同时其他用户创建的进程只能读取共享内存。

shmat函数

第一次创建完共享内存时,它还不能被任何进程访问,shmat函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间。它的原型如下:

1
void *shmat(int shm_id, const void *shm_addr, int shmflg);

第一个参数,shm_id是由shmget函数返回的共享内存标识。
第二个参数,shm_addr指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址。
第三个参数,shm_flg是一组标志位,通常为0。

调用成功时返回一个指向共享内存第一个字节的指针,如果调用失败返回-1.

shmdt函数

该函数用于将共享内存从当前进程中分离。注意,将共享内存分离并不是删除它,只是使该共享内存对当前进程不再可用。它的原型如下:
int shmdt(const void *shmaddr);
参数shmaddr是shmat函数返回的地址指针,调用成功时返回0,失败时返回-1.

shmctl函数

与信号量的semctl函数一样,用来控制共享内存,它的原型如下:

1
int shmctl(int shm_id, int command, struct shmid_ds *buf);

第一个参数,shm_id是shmget函数返回的共享内存标识符。

第二个参数,command是要采取的操作,它可以取下面的三个值 :

  • IPC_STAT:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值。
  • IPC_SET:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值
  • IPC_RMID:删除共享内存段

第三个参数,buf是一个结构指针,它指向共享内存模式和访问权限的结构。
shmid_ds结构至少包括以下成员:

1
2
3
4
5
6
struct shmid_ds
{
uid_t shm_perm.uid;
uid_t shm_perm.gid;
mode_t shm_perm.mode;
};

使用共享内存进行进程间通信

说了这么多,又到了实战的时候了。下面就以两个不相关的进程来说明进程间如何通过共享内存来进行通信。其中一个文件shmread.c创建共享内存,并读取其中的信息,另一个文件shmwrite.c向共享内存中写入数据。为了方便操作和数据结构的统一,为这两个文件定义了相同的数据结构,定义在文件shmdata.c中。结构shared_use_st中的written作为一个可读或可写的标志,非0:表示可读,0表示可写,text则是内存中的文件。

shmdata.h的源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef _SHMDATA_H_HEADER
#define _SHMDATA_H_HEADER

#define TEXT_SZ 2048

struct shared_use_st
{
int written;//作为一个标志,非0:表示可读,0表示可写
char text[TEXT_SZ];//记录写入和读取的文本
};

#endif

源文件shmread.c的源代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/shm.h>
#include "shmdata.h"

int main()
{
int running = 1;//程序是否继续运行的标志
void *shm = NULL;//分配的共享内存的原始首地址
struct shared_use_st *shared;//指向shm
int shmid;//共享内存标识符
//创建共享内存
shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
if(shmid == -1)
{
fprintf(stderr, "shmget failed\n");
exit(EXIT_FAILURE);
}
//将共享内存连接到当前进程的地址空间
shm = shmat(shmid, 0, 0);
if(shm == (void*)-1)
{
fprintf(stderr, "shmat failed\n");
exit(EXIT_FAILURE);
}
printf("\nMemory attached at %X\n", (int)shm);
//设置共享内存
shared = (struct shared_use_st*)shm;
shared->written = 0;
while(running)//读取共享内存中的数据
{
//没有进程向共享内存定数据有数据可读取
if(shared->written != 0)
{
printf("You wrote: %s", shared->text);
sleep(rand() % 3);
//读取完数据,设置written使共享内存段可写
shared->written = 0;
//输入了end,退出循环(程序)
if(strncmp(shared->text, "end", 3) == 0)
running = 0;
}
else//有其他进程在写数据,不能读取数据
sleep(1);
}
//把共享内存从当前进程中分离
if(shmdt(shm) == -1)
{
fprintf(stderr, "shmdt failed\n");
exit(EXIT_FAILURE);
}
//删除共享内存
if(shmctl(shmid, IPC_RMID, 0) == -1)
{
fprintf(stderr, "shmctl(IPC_RMID) failed\n");
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}

源文件shmwrite.c的源代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/shm.h>
#include "shmdata.h"

int main()
{
int running = 1;
void *shm = NULL;
struct shared_use_st *shared = NULL;
char buffer[BUFSIZ + 1];//用于保存输入的文本
int shmid;
//创建共享内存
shmid = shmget((key_t)1234, sizeof(struct shared_use_st), 0666|IPC_CREAT);
if(shmid == -1)
{
fprintf(stderr, "shmget failed\n");
exit(EXIT_FAILURE);
}
//将共享内存连接到当前进程的地址空间
shm = shmat(shmid, (void*)0, 0);
if(shm == (void*)-1)
{
fprintf(stderr, "shmat failed\n");
exit(EXIT_FAILURE);
}
printf("Memory attached at %X\n", (int)shm);
//设置共享内存
shared = (struct shared_use_st*)shm;
while(running)//向共享内存中写数据
{
//数据还没有被读取,则等待数据被读取,不能向共享内存中写入文本
while(shared->written == 1)
{
sleep(1);
printf("Waiting...\n");
}
//向共享内存中写入数据
printf("Enter some text: ");
fgets(buffer, BUFSIZ, stdin);
strncpy(shared->text, buffer, TEXT_SZ);
//写完数据,设置written使共享内存段可读
shared->written = 1;
//输入了end,退出循环(程序)
if(strncmp(buffer, "end", 3) == 0)
running = 0;
}
//把共享内存从当前进程中分离
if(shmdt(shm) == -1)
{
fprintf(stderr, "shmdt failed\n");
exit(EXIT_FAILURE);
}
sleep(2);
exit(EXIT_SUCCESS);
}

再来看看运行的结果:

分析:

  1. 程序shmread创建共享内存,然后将它连接到自己的地址空间。在共享内存的开始处使用了一个结构struct_use_st。该结构中有个标志written,当共享内存中有其他进程向它写入数据时,共享内存中的written被设置为0,程序等待。当它不为0时,表示没有进程对共享内存写入数据,程序就从共享内存中读取数据并输出,然后重置设置共享内存中的written为0,即让其可被shmwrite进程写入数据。

  2. 程序shmwrite取得共享内存并连接到自己的地址空间中。检查共享内存中的written,是否为0,若不是,表示共享内存中的数据还没有被完,则等待其他进程读取完成,并提示用户等待。若共享内存的written为0,表示没有其他进程对共享内存进行读取,则提示用户输入文本,并再次设置共享内存中的written为1,表示写完成,其他进程可对共享内存进行读操作。

关于前面的例子的安全性讨论

这个程序是不安全的,当有多个程序同时向共享内存中读写数据时,问题就会出现。可能你会认为,可以改变一下written的使用方式,例如,只有当written为0时进程才可以向共享内存写入数据,而当一个进程只有在written不为0时才能对其进行读取,同时把written进行加1操作,读取完后进行减1操作。这就有点像文件锁中的读写锁的功能。咋看之下,它似乎能行得通。但是这都不是原子操作,所以这种做法是行不能的。试想当written为0时,如果有两个进程同时访问共享内存,它们就会发现written为0,于是两个进程都对其进行写操作,显然不行。当written为1时,有两个进程同时对共享内存进行读操作时也是如些,当这两个进程都读取完是,written就变成了-1.

要想让程序安全地执行,就要有一种进程同步的进制,保证在进入临界区的操作是原子操作。例如,可以使用前面所讲的信号量来进行进程的同步。因为信号量的操作都是原子性的。

使用共享内存的优缺点

  1. 优点:我们可以看到使用共享内存进行进程间的通信真的是非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像匿名管道那样要求通信的进程有一定的父子关系。

  2. 缺点:共享内存没有提供同步的机制,这使得我们在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作。

内存映射

内存映射 memory map机制使进程之间通过映射同一个普通文件实现共享内存,通过mmap()系统调用实现。普通文件被映射到进程地址空间后,进程可以

像访问普通内存一样对文件进行访问,不必再调用read/write等文件操作函数。

例子:创建子进程,父子进程通过匿名映射实现共享内存。

分析:主程序中先调用mmap映射内存,然后再调用fork函数创建进程。那么在调用fork函数之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap函数的返回地址,这样,父子进程就可以通过映射区域进行通信了。

UNIX System V共享内存机制

IPC的共享内存指的是把所有的共享数据放在共享内存区域(IPC shared memory region),任何想要访问该数据的进程都必须在本进程的地址空间新增一块内存区域,用来映射存放共享数据的物理内存页面。

和前面的mmap系统调用通过映射一个普通文件实现共享内存不同,UNIX system V共享内存是通过映射特殊文件系统shm中的文件实现进程间的共享内存通信。

例子:设计两个程序,通过unix system v共享内存机制,一个程序写入共享区域,另一个程序读取共享区域。

分析:一个程序调用fotk函数产生标准的key,接着调用shmget函数,获取共享内存区域的id,调用shmat函数,映射内存,循环计算年龄,另一个程序读取共享内存。

(fotk函数在消息队列部分已经用过了,根据pathname指定的文件(或目录)名称,以及proj参数指定的数字,ftok函数为IPC对象生成一个唯一性的键值。)

1
key_t ftok(char* pathname,char proj)


信号量

POSIX信号量是属于POSIX标准系统接口定义的实时扩展部分。在SUS(Single UNIX Specification)单一规范中,定义的XSI IPC中也同样定义了人们通常称为System V信号量的系统接口。信号量作为进程间同步的工具是很常用的一种同步IPC类型。

信号量是一种用于不同进程间进行同步的工具,当然对于进程安全的对于线程也肯定是安全的,所以信号量也理所当然可以用于同一进程内的不同线程的同步。

有了互斥量和条件变量还提供信号量的原因是:信号量的主要目的是提供一种进程间同步的方式。这种同步的进程可以共享也可以不共享内存区。虽然信号量的意图在于进程间的同步,互斥量和条件变量的意图在于线程间同步,但信号量也可用于线程间同步,互斥量和条件变量也可通过共享内存区进行进程间同步。但应该根据具体应用考虑到效率和易用性进行具体的选择。

POSIX信号量的操作

POSIX信号量有两种:有名信号量和无名信号量,无名信号量也被称作基于内存的信号量。有名信号量通过IPC名字进行进程间的同步,而无名信号量如果不是放在进程间的共享内存区中,是不能用来进行进程间同步的,只能用来进行线程同步。

POSIX信号量有三种操作:

(1)创建一个信号量。创建的过程还要求初始化信号量的值。

根据信号量取值(代表可用资源的数目)的不同,POSIX信号量还可以分为:

二值信号量:信号量的值只有0和1,这和互斥量很类型,若资源被锁住,信号量的值为0,若资源可用,则信号量的值为1;
计数信号量:信号量的值在0到一个大于1的限制值(POSIX指出系统的最大限制值至少要为32767)。该计数表示可用的资源的个数。
(2)等待一个信号量(wait)。该操作会检查信号量的值,如果其值小于或等于0,那就阻塞,直到该值变成大于0,然后等待进程将信号量的值减1,进程获得共享资源的访问权限。这整个操作必须是一个原子操作。该操作还经常被称为P操作(荷兰语Proberen,意为:尝试)。

(3)挂出一个信号量(post)。该操作将信号量的值加1,如果有进程阻塞着等待该信号量,那么其中一个进程将被唤醒。该操作也必须是一个原子操作。该操作还经常被称为V操作(荷兰语Verhogen,意为:增加)

下面演示经典的生产者消费者问题,单个生产者和消费者共享一个缓冲区;

下面是生产者和消费者同步的伪代码:

1
2
3
4
5
6
7
8
9
10
//信号量的初始化
get = 0;//表示可读资源的数目
put = 1;//表示可写资源的数目

//生产者进程 //消费者进程
for(; ;){ for(; ;){
Sem_wait(put); Sem_wait(get);
写共享缓冲区; 读共享缓冲区;
Sem_post(get); Sem_post(put);
} }

上面的代码大致流程如下:当生产者和消费者开始都运行时,生产者获取put信号量,此时put为1表示有资源可用,生产者进入共享缓冲区,进行修改。而消费者获取get信号量,而此时get为0,表示没有资源可读,于是消费者进入等待序列,直到生产者生产出一个数据,然后生产者通过挂出get信号量来通知等待的消费者,有数据可以读。
很多时候信号量和互斥量,条件变量三者都可以在某种应用中使用,那这三者的差异有哪些呢,下面列出了这三者之间的差异:

互斥量必须由给它上锁的线程解锁。而信号量不需要由等待它的线程进行挂出,可以在其他进程进行挂出操作。
互斥量要么被锁住,要么是解开状态,只有这两种状态。而信号量的值可以支持多个进程成功进行wait操作。
信号量的挂出操作总是被记住,因为信号量有一个计数值,挂出操作总会将该计数值加1,然而当向条件变量发送一个信号时,如果没有线程等待在条件变量,那么该信号会丢失。

POSIX信号量函数接口

POSIX信号量的函数接口如下图所示:

有名信号量的创建和删除

1
2
3
4
5
6
#include <semaphore.h>

sem_t *sem_open(const char *name, int oflag);
sem_t *sem_open(const char *name, int oflag,
mode_t mode, unsigned int value);
//成功返回信号量指针,失败返回SEM_FAILED

sem_open用于创建或打开一个信号量,信号量是通过name参数即信号量的名字来进行标识的。关于POSX IPC的名字可以参考《UNIX网络编程 卷2:进程间通信》P14。

oflag参数可以为:0,O_CREAT,O_EXCL。如果为0表示打开一个已存在的信号量,如果为O_CREAT,表示如果信号量不存在就创建一个信号量,如果存在则打开被返回。此时mode和value需要指定。如果为O_CREAT | O_EXCL,表示如果信号量已存在会返回错误。

mode参数用于创建信号量时,表示信号量的权限位,和open函数一样包括:S_IRUSR,S_IWUSR,S_IRGRP,S_IWGRP,S_IROTH,S_IWOTH。

value表示创建信号量时,信号量的初始值。

1
2
3
4
5
#include <semaphore.h>

int sem_close(sem_t *sem);
int sem_unlink(const char *name);
//成功返回0,失败返回-1

sem_close用于关闭打开的信号量。当一个进程终止时,内核对其上仍然打开的所有有名信号量自动执行这个操作。调用sem_close关闭信号量并没有把它从系统中删除它,POSIX有名信号量是随内核持续的。即使当前没有进程打开某个信号量它的值依然保持。直到内核重新自举或调用sem_unlink()删除该信号量。

sem_unlink用于将有名信号量立刻从系统中删除,但信号量的销毁是在所有进程都关闭信号量的时候。

信号量的P操作

1
2
3
4
5
6
7
8
9
10
#include <semaphore.h>

int sem_wait (sem_t *sem);

#ifdef __USE_XOPEN2K
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);
#endif

int sem_trywait (sem_t * sem);
//成功返回0,失败返回-1

sem_wait()用于获取信号量,首先会测试指定信号量的值,如果大于0,就会将它减1并立即返回,如果等于0,那么调用线程会进入睡眠,指定信号量的值大于0.
sem_trywait和sem_wait的差别是,当信号量的值等于0的,调用线程不会阻塞,直接返回,并标识EAGAIN错误。

sem_timedwait和sem_wait的差别是当信号量的值等于0时,调用线程会限时等待。当等待时间到后,信号量的值还是0,那么就会返回错误。其中struct timespec *abs_timeout是一个绝对时间,具体可以参考条件变量关于等待时间的使用

信号量的V操作

1
2
3
4
#include <semaphore.h>

int sem_post(sem_t *sem);
//成功返回0,失败返回-1

当一个线程使用完某个信号量后,调用sem_post,使该信号量的值加1,如果有等待的线程,那么会唤醒等待的一个线程。

获取当前信号量的值

1
2
3
4
#include <semaphore.h>

int sem_getvalue(sem_t *sem, int *sval);
//成功返回0,失败返回-1

该函数返回当前信号量的值,通过sval输出参数返回,如果当前信号量已经上锁(即同步对象不可用),那么返回值为0,或为负数,其绝对值就是等待该信号量解锁的线程数。

下面测试在Linux下的信号量是否会出现负值:

1
2
3
4
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
#include <iostream>

#include <unistd.h>
#include <semaphore.h>
#include <fcntl.h>

using namespace std;

#define SEM_NAME "/sem_name"

sem_t *pSem;

void * testThread (void *ptr)
{
sem_wait(pSem);
sleep(10);
sem_close(pSem);
}

int main()
{
pSem = sem_open(SEM_NAME, O_CREAT, 0666, 5);

pthread_t pid;
int semVal;

for (int i = 0; i < 7; ++i)
{
pthread_create(&pid, NULL, testThread, NULL);

sleep(1);

sem_getvalue(pSem, &semVal);
cout<<"semaphore value:"<<semVal<<endl;
}

sem_close(pSem);
sem_unlink(SEM_NAME);
}

执行结果如下:
1
2
3
4
5
6
7
semaphore value:4
semaphore value:3
semaphore value:2
semaphore value:1
semaphore value:0
semaphore value:0
semaphore value:0

这说明在Linux 2.6.18中POSIX信号量是不会出现负值的。

无名信号量的创建和销毁

1
2
3
4
5
6
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
//若出错则返回-1
int sem_destroy(sem_t *sem);
//成功返回0,失败返回-1

sem_init()用于无名信号量的初始化。无名信号量在初始化前一定要在内存中分配一个sem_t信号量类型的对象,这就是无名信号量又称为基于内存的信号量的原因。

sem_init()第一个参数是指向一个已经分配的sem_t变量。第二个参数pshared表示该信号量是否由于进程间通步,当pshared = 0,那么表示该信号量只能用于进程内部的线程间的同步。当pshared != 0,表示该信号量存放在共享内存区中,使使用它的进程能够访问该共享内存区进行进程同步。第三个参数value表示信号量的初始值。

这里需要注意的是,无名信号量不使用任何类似O_CREAT的标志,这表示sem_init()总是会初始化信号量的值,所以对于特定的一个信号量,我们必须保证只调用sem_init()进行初始化一次,对于一个已初始化过的信号量调用sem_init()的行为是未定义的。如果信号量还没有被某个线程调用还好,否则基本上会出现问题。

使用完一个无名信号量后,调用sem_destroy摧毁它。这里要注意的是:摧毁一个有线程阻塞在其上的信号量的行为是未定义的。

有名和无名信号量的持续性

有名信号量是随内核持续的。当有名信号量创建后,即使当前没有进程打开某个信号量它的值依然保持。直到内核重新自举或调用sem_unlink()删除该信号量。

无名信号量的持续性要根据信号量在内存中的位置:

如果无名信号量是在单个进程内部的数据空间中,即信号量只能在进程内部的各个线程间共享,那么信号量是随进程的持续性,当进程终止时它也就消失了。
如果无名信号量位于不同进程的共享内存区,因此只要该共享内存区仍然存在,该信号量就会一直存在。所以此时无名信号量是随内核的持续性。

信号量的继承

对于有名信号量在父进程中打开的任何有名信号量在子进程中仍是打开的。即下面代码是正确的:

1
2
3
4
5
6
7
8
9
sem_t *pSem;
pSem = sem_open(SEM_NAME, O_CREAT, 0666, 5);

if(fork() == 0)
{
//...
sem_wait(pSem);
//...
}

对于无名信号量的继承要根据信号量在内存中的位置:

如果无名信号量是在单个进程内部的数据空间中,那么信号量就是进程数据段或者是堆栈上,当fork产生子进程后,该信号量只是原来的一个拷贝,和之前的信号量是独立的。下面是测试代码:

1
2
3
4
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
int main()
{
sem_t mSem;
sem_init(&mSem, 0, 3);

int val;
sem_getvalue(&mSem, &val);
cout<<"parent:semaphore value:"<<val<<endl;

sem_wait(&mSem);
sem_getvalue(&mSem, &val);
cout<<"parent:semaphore value:"<<val<<endl;

if(fork() == 0)
{
sem_getvalue(&mSem, &val);
cout<<"child:semaphore value:"<<val<<endl;

sem_wait(&mSem);

sem_getvalue(&mSem, &val);
cout<<"child:semaphore value:"<<val<<endl;

exit(0);
}
sleep(1);

sem_getvalue(&mSem, &val);
cout<<"parent:semaphore value:"<<val<<endl;
}

测试结果如下:
1
2
3
4
5
parent:semaphore value:3
parent:semaphore value:2
child:semaphore value:2
child:semaphore value:1
parent:semaphore value:2

如果无名信号量位于不同进程的共享内存区,那么fork产生的子进程中的信号量仍然会存在该共享内存区,所以该信号量仍然保持着之前的状态。

信号量的销毁

对于有名信号量,当某个持有该信号量的进程没有解锁该信号量就终止了,内核并不会将该信号量解锁。这跟记录锁不一样。

对于无名信号量,如果信号量位于进程内部的内存空间中,当进程终止后,信号量也就不存在了,无所谓解锁了。如果信号量位于进程间的共享内存区中,当进程终止后,内核也不会将该信号量解锁。

下面是测试代码:

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()
{
sem_t *pSem;
pSem = sem_open(SEM_NAME, O_CREAT, 0666, 5);

int val;
sem_getvalue(pSem, &val);
cout<<"parent:semaphore value:"<<val<<endl;

if(fork() == 0)
{
sem_wait(pSem);
sem_getvalue(pSem, &val);
cout<<"child:semaphore value:"<<val<<endl;

exit(0);
}
sleep(1);

sem_getvalue(pSem, &val);
cout<<"parent:semaphore value:"<<val<<endl;

sem_unlink(SEM_NAME);
}

下面是测试结果:
1
2
3
parent:semaphore value:5
child:semaphore value:4
parent:semaphore value:4

信号量代码测试

对于有名信号量在父进程中打开的任何有名信号量在子进程中仍是打开的。即下面代码是正确的:

对于信号量用于进程间同步的代码的测试,我没有采用经典的生产者和消费者问题,原因是这里会涉及到共享内存的操作。我只是简单的用一个同步文件操作的例子进行描述。 在下面的测试代码中,POSIX有名信号量初始值为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
40
41
42
43
44
45
46
#include <iostream>
#include <fstream>
#include <cstdlib>

#include <unistd.h>
#include <semaphore.h>
#include <fcntl.h>

using namespace std;

#define SEM_NAME "/sem_name"

void semTest(int flag)
{
sem_t *pSem;
pSem = sem_open(SEM_NAME, O_CREAT, 0666, 2);

sem_wait(pSem);

ofstream fileStream("./test.txt", ios_base::app);

for (int i = 0; i < 5; ++i)
{
sleep(1);

fileStream<<flag;
fileStream<<' '<<flush;
}

sem_post(pSem);
sem_close(pSem);
}

int main()
{
for (int i = 1; i <= 3; ++i)
{
if (fork() == 0)
{
semTest(i);

sleep(1);
exit(0);
}
}
}

程序的运行结果,“./test.txt”文件的内容如下:
1
2
//./test.txt
1 2 1 2 1 2 1 2 1 2 3 3 3 3 3

线程之间的通信方式

  • 锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)
  • 互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法。
  • 读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的。
  • 自旋锁(spin lock)与互斥锁类似,都是为了保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。
  • 条件变量(condition):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
  • 信号量机制(Semaphore)
  • 无名线程信号量
  • 命名线程信号量
  • 信号机制(Signal):类似进程间的信号处理
  • 屏障(barrier):屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。

线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制

Leetcode605. Can Place Flowers

Suppose you have a long flowerbed in which some of the plots are planted and some are not. However, flowers cannot be planted in adjacent plots - they would compete for water and both would die.

Given a flowerbed (represented as an array containing 0 and 1, where 0 means empty and 1 means not empty), and a number n, return if n new flowers can be planted in it without violating the no-adjacent-flowers rule.

Example 1:

1
2
Input: flowerbed = [1,0,0,0,1], n = 1
Output: True

Example 2:
1
2
Input: flowerbed = [1,0,0,0,1], n = 2
Output: False

逐次的添加新的到数组中,然后统计最大可承受的数量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool canPlaceFlowers(vector<int>& flowerbed, int n) {
int ans = 0;
for(int i = 0; i < flowerbed.size(); i ++) {
if(flowerbed[i] == 0)
if(i-1 >= 0 && flowerbed[i-1] == 0 || i==0)
if(i+1 < flowerbed.size() && flowerbed[i+1] == 0 || i == flowerbed.size()-1) {
flowerbed[i] = 1;
ans ++;
}
}
cout << ans <<endl;
return ans >= n;
}
};

Leetcode606. Construct String from Binary Tree

You need to construct a string consists of parenthesis and integers from a binary tree with the preorder traversing way.

The null node needs to be represented by empty parenthesis pair “()”. And you need to omit all the empty parenthesis pairs that don’t affect the one-to-one mapping relationship between the string and the original binary tree.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
Input: Binary tree: [1,2,3,4]
1
/ \
2 3
/
4

Output: "1(2(4))(3)"

Explanation: Originallay it needs to be "1(2(4)())(3()())",
but you need to omit all the unnecessary empty parenthesis pairs.
And it will be "1(2(4))(3)".

Example 2:
1
2
3
4
5
6
7
8
9
10
Input: Binary tree: [1,2,3,null,4]
1
/ \
2 3
\
4

Output: "1(2()(4))(3)"
Explanation: Almost the same as the first example,
except we can't omit the first parenthesis pair to break the one-to-one mapping relationship between the input and the output.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:

string tree2str(TreeNode* t) {
if(t == NULL)
return "";
if(!t->left && !t->right)
return to_string(t->val);
if (t->left != NULL && t->right == NULL) {
return to_string(t->val) + "(" + tree2str(t->left) + ")";
}
if (t->left == NULL && t->right != NULL) {
return to_string(t->val) + "()" + "(" + tree2str(t->right) + ")";
}
return to_string(t->val) + "(" + tree2str(t->left) + ")" + "(" + tree2str(t->right) + ")";
}
};

Leetcode609. Find Duplicate File in System

Given a list of directory info including directory path, and all the files with contents in this directory, you need to find out all the groups of duplicate files in the file system in terms of their paths.

A group of duplicate files consists of at least two files that have exactly the same content.

A single directory info string in the input list has the following format:

“root/d1/d2/…/dm f1.txt(f1_content) f2.txt(f2_content) … fn.txt(fn_content)”

It means there are n files (f1.txt, f2.txt … fn.txt with content f1_content, f2_content … fn_content, respectively) in directory root/d1/d2/…/dm. Note that n >= 1 and m >= 0. If m = 0, it means the directory is just the root directory.

The output is a list of group of duplicate file paths. For each group, it contains all the file paths of the files that have the same content. A file path is a string that has the following format:

“directory_path/file_name.txt”

Example 1:

1
2
3
4
Input:
["root/a 1.txt(abcd) 2.txt(efgh)", "root/c 3.txt(abcd)", "root/c/d 4.txt(efgh)", "root 4.txt(efgh)"]
Output:
[["root/a/2.txt","root/c/d/4.txt","root/4.txt"],["root/a/1.txt","root/c/3.txt"]]

这道题给了我们一堆字符串数组,每个字符串中包含了文件路径,文件名称和内容,让我们找到重复的文件,这里只要文件内容相同即可,不用管文件名是否相同,而且返回结果中要带上文件的路径。博主个人感觉这实际上应该算是字符串操作的题目,因为思路上并不是很难想,就是要处理字符串,把路径,文件名,和文件内容从一个字符串中拆出来,我们这里建立一个文件内容和文件路径加文件名组成的数组的映射,因为会有多个文件有相同的内容,所以我们要用数组。然后把分离出的路径和文件名拼接到一起,最后我们只要看哪些映射的数组元素个数多于1个的,就说明有重复文件,我们把整个数组加入结果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<vector<string>> findDuplicate(vector<string>& paths) {
vector<vector<string>> res;
unordered_map<string, vector<string>> m;
for (string path : paths) {
istringstream is(path);
string pre = "", t = "";
is >> pre;
while (is >> t) {
int idx = t.find_last_of('(');
string dir = pre + "/" + t.substr(0, idx);
string content = t.substr(idx + 1, t.size() - idx - 2);
m[content].push_back(dir);
}
}
for (auto a : m) {
if (a.second.size() > 1)res.push_back(a.second);
}
return res;
}
};

Leetcode611. Valid Triangle Number

Given an array consists of non-negative integers, your task is to count the number of triplets chosen from the array that can make triangles if we take them as side lengths of a triangle.

Example 1:

1
2
3
4
5
6
7
Input: [2,2,3,4]
Output: 3
Explanation:
Valid combinations are:
2,3,4 (using the first 2)
2,3,4 (using the second 2)
2,2,3

Note:

  • The length of the given array won’t exceed 1000.
  • The integers in the given array are in the range of [0, 1000].

这道题给了我们一堆数字,问我们能组成多少个正确的三角形,我们初中就知道三角形的性质,任意两条边之和要大于第三边。那么问题其实就变成了找出所有这样的三个数字,使得任意两个数字之和都大于第三个数字。那么可以转变一下,三个数字中如果较小的两个数字之和大于第三个数字,那么任意两个数字之和都大于第三个数字,这很好证明,因为第三个数字是最大的,所以它加上任意一个数肯定大于另一个数。先确定前两个数,将这两个数之和sum作为目标值,然后用二分查找法来快速确定第一个小于目标值的数,我们找到这个临界值,那么这之前一直到j的位置之间的数都满足题意,直接加起来即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int triangleNumber(vector<int>& nums) {
int res = 0, n = nums.size();
sort(nums.begin(), nums.end());
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
int sum = nums[i] + nums[j], left = j + 1, right = n;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < sum) left = mid + 1;
else right = mid;
}
res += right - 1 - j;
}
}
return res;
}
};

其实还有更进一步优化的方法,思路是排序之后,从数字末尾开始往前遍历,将left指向首数字,将right之前遍历到的数字的前面一个数字,然后如果left小于right就进行循环,循环里面判断如果left指向的数加上right指向的数大于当前的数字的话,那么right到left之间的数字都可以组成三角形,这是为啥呢,相当于此时确定了i和right的位置,可以将left向右移到right的位置,中间经过的数都大于left指向的数,所以都能组成三角形。加完之后,right自减一,即向左移动一位。如果left和right指向的数字之和不大于nums[i],那么left自增1,即向右移动一位,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int triangleNumber(vector<int>& nums) {
int res = 0, n = nums.size();
sort(nums.begin(), nums.end());
for (int i = n - 1; i >= 2; --i) {
int left = 0, right = i - 1;
while (left < right) {
if (nums[left] + nums[right] > nums[i]) {
res += right - left;
--right;
} else {
++left;
}
}
}
return res;
}
};

Leetcode617. Merge Two Binary Trees

Given two binary trees and imagine that when you put one of them to cover the other, some nodes of the two trees are overlapped while the others are not.

You need to merge them into a new binary tree. The merge rule is that if two nodes overlap, then sum node values up as the new value of the merged node. Otherwise, the NOT null node will be used as the node of new tree.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Input: 
Tree 1 Tree 2
1 2
/ \ / \
3 2 1 3
/ \ \
5 4 7
Output:
Merged tree:
3
/ \
4 5
/ \ \
5 4 7

把两棵树合并,比较简单,递归即可。

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:
TreeNode* res(TreeNode* t1, TreeNode* t2){
if(!t1 && !t2){
return NULL;
}
if(t1 && !t2){
return t1;
}
if(!t1 && t2){
return t2;
}
t1->val+=t2->val;
t1->left = res(t1->left,t2->left);
t1->right = res(t1->right,t2->right);
return t1;
}

TreeNode* mergeTrees(TreeNode* t1, TreeNode* t2) {
return res(t1,t2);
}
};

Leetcode620. Not Boring Movies

X city opened a new cinema, many people would like to go to this cinema. The cinema also gives out a poster indicating the movies’ ratings and descriptions.
Please write a SQL query to output movies with an odd numbered ID and a description that is not ‘boring’. Order the result by rating.

For example, table cinema:

1
2
3
4
5
6
7
8
9
+---------+-----------+--------------+-----------+
| id | movie | description | rating |
+---------+-----------+--------------+-----------+
| 1 | War | great 3D | 8.9 |
| 2 | Science | fiction | 8.5 |
| 3 | irish | boring | 6.2 |
| 4 | Ice song | Fantacy | 8.6 |
| 5 | House card| Interesting| 9.1 |
+---------+-----------+--------------+-----------+

For the example above, the output should be:
1
2
3
4
5
6
+---------+-----------+--------------+-----------+
| id | movie | description | rating |
+---------+-----------+--------------+-----------+
| 5 | House card| Interesting| 9.1 |
| 1 | War | great 3D | 8.9 |
+---------+-----------+--------------+-----------+

1
SELECT id, movie, description, rating from cinema where (id%2) != 0 AND (description != "boring") ORDER BY rating DESC;

Leetcode621. Task Scheduler

Given a char array representing tasks CPU need to do. It contains capital letters A to Z where different letters represent different tasks.Tasks could be done without original order. Each task could be done in one interval. For each interval, CPU could finish one task or just be idle.

However, there is a non-negative cooling interval n that means between two same tasks, there must be at least n intervals that CPU are doing different tasks or just be idle.

You need to return the least number of intervals the CPU will take to finish all the given tasks.

Example 1:

1
2
3
Input: tasks = ['A','A','A','B','B','B'], n = 2
Output: 8
Explanation: A -> B -> idle -> A -> B -> idle -> A -> B.

Note:

  • The number of tasks is in the range [1, 10000].
  • The integer n is in the range [0, 100].

这道题让我们安排CPU的任务,规定在两个相同任务之间至少隔n个时间点。说实话,刚开始博主并没有完全理解题目的意思,后来看了大神们的解法才悟出个道理来。由于题目中规定了两个相同任务之间至少隔n个时间点,那么我们首先应该处理的出现次数最多的那个任务,先确定好这些高频任务,然后再来安排那些低频任务。如果任务F的出现频率最高,为k次,那么我们用n个空位将每两个F分隔开,然后我们按顺序加入其他低频的任务,来看一个例子:

AAAABBBEEFFGG 3

我们发现任务A出现了4次,频率最高,于是我们在每个A中间加入三个空位,如下:

A—-A—-A—-A

AB—AB—AB—A (加入B)

ABE-ABE-AB—A (加入E)

ABEFABE-ABF-A (加入F,每次尽可能填满或者是均匀填充)

ABEFABEGABFGA (加入G)

再来看一个例子:

ACCCEEE 2

我们发现任务C和E都出现了三次,那么我们就将CE看作一个整体,在中间加入一个位置即可:

CE-CE-CE

CEACE-CE (加入A)

注意最后面那个idle不能省略,不然就不满足相同两个任务之间要隔2个时间点了。

这道题好在没有让我们输出任务安排结果,而只是问所需的时间总长,那么我们就想个方法来快速计算出所需时间总长即可。我们仔细观察上面两个例子可以发现,都分成了(mx - 1)块,再加上最后面的字母,其中mx为最大出现次数。比如例子1中,A出现了4次,所以有A—模块出现了3次,再加上最后的A,每个模块的长度为4。例子2中,CE-出现了2次,再加上最后的CE,每个模块长度为3。我们可以发现,模块的次数为任务最大次数减1,模块的长度为n+1,最后加上的字母个数为出现次数最多的任务,可能有多个并列。这样三个部分都搞清楚了,写起来就不难了,我们统计每个大写字母出现的次数,然后排序,这样出现次数最多的字母就到了末尾,然后我们向前遍历,找出出现次数一样多的任务个数,就可以迅速求出总时间长了,下面这段代码可能最不好理解的可能就是最后一句了,那么我们特别来讲解一下。先看括号中的第二部分,前面分析说了mx是出现的最大次数,mx-1是可以分为的块数,n+1是每块中的个数,而后面的 25-i 是还需要补全的个数,用之前的例子来说明:

AAAABBBEEFFGG 3

A出现了4次,最多,mx=4,那么可以分为mx-1=3块,如下:

A—A—A—

每块有n+1=4个,最后还要加上末尾的一个A,也就是25-24=1个任务,最终结果为13:

ABEFABEGABFGA

再来看另一个例子:

ACCCEEE 2

C和E都出现了3次,最多,mx=3,那么可以分为mx-1=2块,如下:

CE-CE-

每块有n+1=3个,最后还要加上末尾的一个CE,也就是25-23=2个任务,最终结果为8:

CEACE-CE

好,那么此时你可能会有疑问,为啥还要跟原任务个数len相比,取较大值呢?我们再来看一个例子:

AAABBB 0

A和B都出现了3次,最多,mx=3,那么可以分为mx-1=2块,如下:

ABAB

每块有n+1=1个?你会发现有问题,这里明明每块有两个啊,为啥这里算出来n+1=1呢,因为给的n=0,这有没有矛盾呢,没有!因为n表示相同的任务间需要间隔的个数,那么既然这里为0了,说明相同的任务可以放在一起,这里就没有任何限制了,我们只需要执行完所有的任务就可以了,所以我们最终的返回结果一定不能小于任务的总个数len的,这就是要对比取较大值的原因了。

参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int leastInterval(vector<char>& tasks, int n) {
vector<int> cnt(26, 0);
for (char task : tasks) {
++cnt[task - 'A'];
}
sort(cnt.begin(), cnt.end());
int i = 25, mx = cnt[25], len = tasks.size();
while (i >= 0 && cnt[i] == mx) --i;
return max(len, (mx - 1) * (n + 1) + 25 - i);
}
};

Leetcode622. Design Circular Queue

Design your implementation of the circular queue. The circular queue is a linear data structure in which the operations are performed based on FIFO (First In First Out) principle and the last position is connected back to the first position to make a circle. It is also called “Ring Buffer”.

One of the benefits of the circular queue is that we can make use of the spaces in front of the queue. In a normal queue, once the queue becomes full, we cannot insert the next element even if there is a space in front of the queue. But using the circular queue, we can use the space to store new values.

Implementation the MyCircularQueue class:

  • MyCircularQueue(k) Initializes the object with the size of the queue to be k.
  • int Front() Gets the front item from the queue. If the queue is empty, return -1.
  • int Rear() Gets the last item from the queue. If the queue is empty, return -1.
  • boolean enQueue(int value) Inserts an element into the circular queue. Return true if the operation is successful.
  • boolean deQueue() Deletes an element from the circular queue. Return true if the operation is successful.
  • boolean isEmpty() Checks whether the circular queue is empty or not.
  • boolean isFull() Checks whether the circular queue is full or not.

手写循环队列。

1
2
3
4
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
class MyCircularQueue {
public:
int size, head, tail, cnt;
vector<int> v;

MyCircularQueue(int k) {
size = k;
cnt = 0;
v.resize(k);
head = tail = 0;
}

bool enQueue(int value) {
if (isFull())
return false;
v[tail] = value;
tail = (tail+1) % size;
cnt ++;
return true;
}

bool deQueue() {
if (isEmpty())
return false;
cnt --;
head = (head+1) % size;
return true;
}

int Front() {
if (isEmpty())
return -1;
return v[head];
}

int Rear() {
if (isEmpty())
return -1;
return v[(tail+size-1) % size];
}

bool isEmpty() {
return cnt == 0;
}

bool isFull() {
return cnt == size;
}
};

Leetcode623. Add One Row to Tree

Given the root of a binary tree and two integers val and depth, add a row of nodes with value val at the given depth depth.

Note that the root node is at depth 1.

The adding rule is:

  • Given the integer depth, for each not null tree node cur at the depth depth - 1, create two tree nodes with value val as cur’s left subtree root and right subtree root.
  • cur’s original left subtree should be the left subtree of the new left subtree root.
  • cur’s original right subtree should be the right subtree of the new right subtree root.
  • If depth == 1 that means there is no depth depth - 1 at all, then create a tree node with value val as the new root of the whole original tree, and the original tree is the new root’s left subtree.

Example 1:

1
2
Input: root = [4,2,6,3,1,5], val = 1, depth = 2
Output: [4,1,1,2,null,null,6,3,1,5]

Example 2:

1
2
Input: root = [4,2,null,3,1], val = 1, depth = 3
Output: [4,2,null,1,1,3,null,null,1]

这道题让我们给二叉树增加一行,给了需要增加的值,还有需要增加的位置深度,题目中给的例子也比较能清晰的说明问题。但是漏了一种情况,那就是当d=1时,这该怎么加?这时候就需要替换根结点了。其他情况的处理方法都一样,每遍历完一层,d自减1,当d==1时,需要对于当前层的每一个结点,先用临时变量保存其原有的左右子结点,然后新建值为v的左右子结点,将原有的左子结点连到新建的左子结点的左子结点上,将原有的右子结点连到新建的右子结点的右子结点。如果d不为1,那么就是层序遍历原有的排入队列操作,记得当检测到d为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
class Solution {
public:
TreeNode* addOneRow(TreeNode* root, int val, int depth) {
if (depth == 1)
return new TreeNode(val, root, NULL);
queue<TreeNode*> q;
int cur_dep = 1;
q.push(root);
while(!q.empty()) {
vector<TreeNode*> v;
v.clear();
int size = q.size();
while(size --) {
TreeNode* tmp = q.front();
v.push_back(tmp);
q.pop();
}
int i = 0;
size = v.size();
if (depth == cur_dep + 1) {
for (i = 0; i < size; i++) {
v[i]->left = new TreeNode(val, v[i]->left, NULL);
v[i]->right = new TreeNode(val, NULL, v[i]->right);
}
return root;
}

i = 0;
size = v.size();
while(i < size) {
if (v[i]->left) q.push(v[i]->left);
if (v[i]->right) q.push(v[i]->right);
i ++;
}
cur_dep ++;
}
return root;
}
};

Leetcode626. Exchange Seats

Mary is a teacher in a middle school and she has a table seat storing students’ names and their corresponding seat ids.

The column id is continuous increment.

Mary wants to change seats for the adjacent students.

Can you write a SQL query to output the result for Mary?

1
2
3
4
5
6
7
8
9
+---------+---------+
| id | student |
+---------+---------+
| 1 | Abbot |
| 2 | Doris |
| 3 | Emerson |
| 4 | Green |
| 5 | Jeames |
+---------+---------+

For the sample input, the output is:

1
2
3
4
5
6
7
8
9
+---------+---------+
| id | student |
+---------+---------+
| 1 | Doris |
| 2 | Abbot |
| 3 | Green |
| 4 | Emerson |
| 5 | Jeames |
+---------+---------+

Note: If the number of students is odd, there is no need to change the last one’s seat.

交换相邻的两个学生的位置。IF语句及SELECT子句使用,如下所示:

1
2
# Write your MySQL query statement below
SELECT IF(id%2 = 0, id-1, IF (id = (SELECT COUNT(*) FROM seat), id, id + 1)) as id , student from seat ORDER BY id;

Leetcode628. Maximum Product of Three Numbers

Given an integer array, find three numbers whose product is maximum and output the maximum product.

Example 1:

1
2
Input: [1,2,3]
Output: 6

Example 2:
1
2
Input: [1,2,3,4]
Output: 24

先排序,然后找最大的三个数,或者最大的一个数和最小的两个数,看哪个乘积大。
1
2
3
4
5
6
7
8
class Solution {
public:
int maximumProduct(vector<int>& nums) {
sort(nums.begin(), nums.end());
int len = nums.size() - 1;
return max(nums[len] * nums[len-1] * nums[len-2], nums[len] * nums[0] * nums[1]);
}
};

Leetcode630. Course Schedule III

There are n different online courses numbered from 1 to n. Each course has some duration(course length) tand closed on dth day. A course should be taken continuously for t days and must be finished before or on the dth day. You will start at the 1st day.

Given n online courses represented by pairs (t,d), your task is to find the maximal number of courses that can be taken.

Example:

1
2
Input: [[100, 200], [200, 1300], [1000, 1250], [2000, 3200]]
Output: 3

Explanation:

  • There’re totally 4 courses, but you can take 3 courses at most:
  • First, take the 1st course, it costs 100 days so you will finish it on the 100th day, and ready to take the next course on the 101st day.
  • Second, take the 3rd course, it costs 1000 days so you will finish it on the 1100th day, and ready to take the next course on the 1101st day.
  • Third, take the 2nd course, it costs 200 days so you will finish it on the 1300th day.
  • The 4th course cannot be taken now, since you will finish it on the 3300th day, which exceeds the closed date.

Note:

  • The integer 1 <= d, t, n <= 10,000.
  • You can’t take two courses simultaneously.

这道题给了我们许多课程,每个课程有两个参数,第一个是课程的持续时间,第二个是课程的最晚结束日期,让我们求最多能上多少门课。这道题给的提示是用贪婪算法,那么我们首先给课程排个序,按照结束时间的顺序来排序,我们维护一个当前的时间,初始化为0,再建立一个优先数组,然后我们遍历每个课程,对于每一个遍历到的课程,当前时间加上该课程的持续时间,然后将该持续时间放入优先数组中,然后我们判断如果当前时间大于课程的结束时间,说明这门课程无法被完成,我们并不是直接减去当前课程的持续时间,而是取出优先数组的顶元素,即用时最长的一门课,这也make sense,因为我们的目标是尽可能的多上课,既然非要去掉一门课,那肯定是去掉耗时最长的课,这样省下来的时间说不定能多上几门课呢,最后返回优先队列中元素的个数就是能完成的课程总数啦,参见代码如下:

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:

static bool comp(vector<int>& a, vector<int>& b) {
return a[1] < b[1];
}

int scheduleCourse(vector<vector<int>>& courses) {
int res = 0, cur = 0;
priority_queue<int> q;
sort(courses.begin(), courses.end(), comp);
for (vector<int> a : courses) {
cur += a[0];
q.push(a[0]);
if (cur > a[1]) {
cur -= q.top();
q.pop();
}
}

return q.size();
}
};

Leetcode632. Smallest Range Covering Elements from K Lists

You have k lists of sorted integers in ascending order. Find the smallest range that includes at least one number from each of the k lists.

We define the range [a,b] is smaller than range [c,d] if b-a < d-c or a < c if b-a == d-c.

Example 1:

1
2
3
4
5
6
Input:[[4,10,15,24,26], [0,9,12,20], [5,18,22,30]]
Output: [20,24]
Explanation:
List 1: [4, 10, 15, 24,26], 24 is in range [20,24].
List 2: [0, 9, 12, 20], 20 is in range [20,24].
List 3: [5, 18, 22, 30], 22 is in range [20,24].

Note:

  • The given list may contain duplicates, so ascending order means >= here.
  • 1 <= k <= 3500
  • -105 <= value of elements <= 105.
  • For Java users, please note that the input type has been changed to List. And after you reset the code template, you’ll see this point.

这道题给了我们一些数组,都是排好序的,让求一个最小的范围,使得这个范围内至少会包括每个数组中的一个数字。虽然每个数组都是有序的,但是考虑到他们之间的数字差距可能很大,所以最好还是合并成一个数组统一处理比较好,但是合并成一个大数组还需要保留其原属数组的序号,所以大数组中存pair对,同时保存数字和原数组的序号。然后重新按照数字大小进行排序,这样问题实际上就转换成了求一个最小窗口,使其能够同时包括所有数组中的至少一个数字。这不就变成了那道 Minimum Window Substring。所以说啊,这些题目都是换汤不换药的,总能变成我们见过的类型。这里用两个指针 left 和 right 来确定滑动窗口的范围,还要用一个 HashMap 来建立每个数组与其数组中数字出现的个数之间的映射,变量 cnt 表示当前窗口中的数字覆盖了几个数组,diff 为窗口的大小,让 right 向右滑动,然后判断如果 right 指向的数字所在数组没有被覆盖到,cnt 自增1,然后 HashMap 中对应的数组出现次数自增1,然后循环判断如果 cnt 此时为k(数组的个数)且 left 不大于 right,那么用当前窗口的范围来更新结果,然后此时想缩小窗口,通过将 left 向右移,移动之前需要减小 HashMap 中的映射值,因为去除了数字,如果此时映射值为0了,说明有个数组无法覆盖到了,cnt 就要自减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
class Solution {
public:
vector<int> smallestRange(vector<vector<int>>& nums) {
vector<int> res;
vector<pair<int, int>> v;
unordered_map<int, int> m;
for (int i = 0; i < nums.size(); ++i) {
for (int num : nums[i]) {
v.push_back({num, i});
}
}
sort(v.begin(), v.end());
int left = 0, n = v.size(), k = nums.size(), cnt = 0, diff = INT_MAX;
for (int right = 0; right < n; ++right) {
if (m[v[right].second] == 0) ++cnt;
++m[v[right].second];
while (cnt == k && left <= right) {
if (diff > v[right].first - v[left].first) {
diff = v[right].first - v[left].first;
res = {v[left].first, v[right].first};
}
if (--m[v[left].second] == 0) --cnt;
++left;
}
}
return res;
}
};

这道题还有一种使用 priority_queue 来做的,优先队列默认情况是最大堆,但是这道题我们需要使用最小堆,重新写一下 comparator 就行了。解题的主要思路很上面的解法很相似,只是具体的数据结构的使用上略有不同,这 curMax 表示当前遇到的最大数字,用一个 idx 数组表示每个 list 中遍历到的位置,然后优先队列里面放一个pair,是数字和其所属list组成的对儿。遍历所有的list,将每个 list 的首元素和该 list 序号组成 pair 放入队列中,然后 idx 数组中每个位置都赋值为1,因为0的位置已经放入队列了,所以指针向后移一个位置,还要更新当前最大值 curMax。此时 queue 中是每个 list 各有一个数字,由于是最小堆,所以最小的数字就在队首,再加上最大值 curMax,就可以初始化结果 res 了。然后进行循环,注意这里循环的条件不是队列不为空,而是当某个 list 的数字遍历完了就结束循环,因为范围要 cover 每个 list 至少一个数字。所以 while 循环条件即是队首数字所在的 list 的遍历位置小于该 list 的总个数,在循环中,取出队首数字所在的 list 序号t,然后将该 list 中下一个位置的数字和该 list 序号t组成 pair,加入队列中,然后用这个数字更新 curMax,同时 idx 中t对应的位置也自增1。现在来更新结果 res,如果结果 res 中两数之差大于 curMax 和队首数字之差,则更新结果 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:
vector<int> smallestRange(vector<vector<int>>& nums) {
int curMax = INT_MIN, n = nums.size();
vector<int> idx(n, 0);
auto cmp = [](pair<int, int>& a, pair<int, int>& b) {return a.first > b.first;};
priority_queue<pair<int, int>, vector<pair<int, int>>, decltype(cmp) > q(cmp);
for (int i = 0; i < n; ++i) {
q.push({nums[i][0], i});
idx[i] = 1;
curMax = max(curMax, nums[i][0]);
}
vector<int> res{q.top().first, curMax};
while (idx[q.top().second] < nums[q.top().second].size()) {
int t = q.top().second; q.pop();
q.push({nums[t][idx[t]], t});
curMax = max(curMax, nums[t][idx[t]]);
++idx[t];
if (res[1] - res[0] > curMax - q.top().first) {
res = {q.top().first, curMax};
}
}
return res;
}
};

Leetcode633. Sum of Square Numbers

Given a non-negative integer c, your task is to decide whether there’re two integers a and b such that a2 + b2 = c.

Example 1:

1
2
3
Input: 5
Output: True
Explanation: 1 * 1 + 2 * 2 = 5

Example 2:
1
2
Input: 3
Output: False

判断一个数是不是两个数的平方之和,不要用加法,容易溢出,要用减法判断。
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
bool judgeSquareSum(int c) {
int len = sqrt(c);
for(int i = 0; i <= len; i ++) {
int remain = c - i*i;
int rr = sqrt(remain);
if(rr * rr == remain)
return true;
}
return false;
}
};

Leetcode636. Exclusive Time of Functions

On a single-threaded CPU, we execute a program containing n functions. Each function has a unique ID between 0 and n-1.

Function calls are stored in a call stack: when a function call starts, its ID is pushed onto the stack, and when a function call ends, its ID is popped off the stack. The function whose ID is at the top of the stack is the current function being executed. Each time a function starts or ends, we write a log with the ID, whether it started or ended, and the timestamp.

You are given a list logs, where logs[i] represents the ith log message formatted as a string “{function_id}:{“start” | “end”}:{timestamp}”. For example, “0:start:3” means a function call with function ID 0 started at the beginning of timestamp 3, and “1:end:2” means a function call with function ID 1 ended at the end of timestamp 2. Note that a function can be called multiple times, possibly recursively.

A function’s exclusive time is the sum of execution times for all function calls in the program. For example, if a function is called twice, one call executing for 2 time units and another call executing for 1 time unit, the exclusive time is 2 + 1 = 3.

Return the exclusive time of each function in an array, where the value at the ith index represents the exclusive time for the function with ID i.

Example 1:

1
2
Input: n = 2, logs = ["0:start:0","1:start:2","1:end:5","0:end:6"]
Output: [3,4]

Explanation:

  • Function 0 starts at the beginning of time 0, then it executes 2 for units of time and reaches the end of time 1.
  • Function 1 starts at the beginning of time 2, executes for 4 units of time, and ends at the end of time 5.
  • Function 0 resumes execution at the beginning of time 6 and executes for 1 unit of time.
  • So function 0 spends 2 + 1 = 3 units of total time executing, and function 1 spends 4 units of total time executing.

Example 2:

1
2
Input: n = 1, logs = ["0:start:0","0:start:2","0:end:5","0:start:6","0:end:6","0:end:7"]
Output: [8]

Explanation:

  • Function 0 starts at the beginning of time 0, executes for 2 units of time, and recursively calls itself.
  • Function 0 (recursive call) starts at the beginning of time 2 and executes for 4 units of time.
  • Function 0 (initial call) resumes execution then immediately calls itself again.
  • Function 0 (2nd recursive call) starts at the beginning of time 6 and executes for 1 unit of time.
  • Function 0 (initial call) resumes execution at the beginning of time 7 and executes for 1 unit of time.
  • So function 0 spends 2 + 4 + 1 + 1 = 8 units of total time executing.

Example 3:

1
2
Input: n = 2, logs = ["0:start:0","0:start:2","0:end:5","1:start:6","1:end:6","0:end:7"]
Output: [7,1]

Explanation:

  • Function 0 starts at the beginning of time 0, executes for 2 units of time, and recursively calls itself.
  • Function 0 (recursive call) starts at the beginning of time 2 and executes for 4 units of time.
  • Function 0 (initial call) resumes execution then immediately calls function 1.
  • Function 1 starts at the beginning of time 6, executes 1 units of time, and ends at the end of time 6.
  • Function 0 resumes execution at the beginning of time 6 and executes for 2 units of time.
  • So function 0 spends 2 + 4 + 1 = 7 units of total time executing, and function 1 spends 1 unit of total time executing.

Example 4:

1
2
Input: n = 2, logs = ["0:start:0","0:start:2","0:end:5","1:start:7","1:end:7","0:end:8"]
Output: [8,1]

Example 5:

1
2
Input: n = 1, logs = ["0:start:0","0:end:0"]
Output: [1]

这道题让我们函数的运行的时间。一个函数调用其他函数的时候自身也在运行,这样的话用栈stack就比较合适了,函数开启了就压入栈,结束了就出栈,不会有函数被漏掉。这样的我们可以遍历每个log,然后把三部分分开,函数idx,类型type,时间点time。如果此时栈不空,说明之前肯定有函数在跑,那么不管当前是start还是end,之前函数时间都得增加,增加的值为time - preTime,这里的preTime是上一个时间点。然后我们更新preTime为当前时间点time。然后我们判断log的类型,如果是start,我们将当前函数压入栈;如果是end,那么我们将栈顶元素取出,对其加1秒,并且preTime也要加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
class Solution {
public:
vector<int> exclusiveTime(int n, vector<string>& logs) {
vector<int> res(n, 0);
int cur = 0, pre = 0;
stack<int> ss;
for (string s : logs) {
int len = s.length();
int p = 0, id = 0, time = 0, type;
while(p < len) {
while(s[p] != ':')
id = id * 10 + s[p++] - '0';
p ++;
if (s[p] == 's')
type = 1;
else
type = 0;
while(p < len && s[p++] != ':');
while(p < len)
time = time * 10 + s[p++] - '0';
if (!ss.empty()) {
res[ss.top()] += (time - pre);
}
pre = time;
if (type == 1) {
ss.push(id);
}
else {
int i = ss.top();
ss.pop();
res[i] ++;
pre ++;
}
}
}
return res;
}
};

Leetcode637. Average of Levels in Binary Tree

Given a non-empty binary tree, return the average value of the nodes on each level in the form of an array.

Example 1:

1
2
3
4
5
6
7
8
9
Input:
3
/ \
9 20
/ \
15 7
Output: [3, 14.5, 11]
Explanation:
The average value of nodes on level 0 is 3, on level 1 is 14.5, and on level 2 is 11. Hence return [3, 14.5, 11].

层次遍历计算平均值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
vector<double> averageOfLevels(TreeNode* root) {
queue<TreeNode*> q;
vector<double> res;
q.push(root);
int counter;
while(!q.empty()) {
vector<TreeNode*> vec;
double sum = 0;
int n = q.size();
while(n--) {
TreeNode* temp = q.front();
sum += temp->val;
vec.push_back(temp);
q.pop();

if(temp->left) q.push(temp->left);
if(temp->right) q.push(temp->right);
}
sum = (double)sum / vec.size();
res.push_back(sum);
}
return res;
}
};

Leetcode638. Shopping Offers

In LeetCode Store, there are some kinds of items to sell. Each item has a price.

However, there are some special offers, and a special offer consists of one or more different kinds of items with a sale price.

You are given the each item’s price, a set of special offers, and the number we need to buy for each item. The job is to output the lowest price you have to pay for exactly certain items as given, where you could make optimal use of the special offers.

Each special offer is represented in the form of an array, the last number represents the price you need to pay for this special offer, other numbers represents how many specific items you could get if you buy this offer.

You could use any of special offers as many times as you want.

Example 1:

1
2
3
4
5
6
7
Input: [2,5], [[3,0,5],[1,2,10]], [3,2]
Output: 14
Explanation:
There are two kinds of items, A and B. Their prices are $2 and $5 respectively.
In special offer 1, you can pay $5 for 3A and 0B
In special offer 2, you can pay $10 for 1A and 2B.
You need to buy 3A and 2B, so you may pay $10 for 1A and 2B (special offer #2), and $4 for 2A.

Example 2:

1
2
3
4
5
6
7
Input: [2,3,4], [[1,1,0,4],[2,2,1,9]], [1,2,1]
Output: 11
Explanation:
The price of A is $2, and $3 for B, $4 for C.
You may pay $4 for 1A and 1B, and $9 for 2A ,2B and 1C.
You need to buy 1A ,2B and 1C, so you may pay $4 for 1A and 1B (special offer #1), and $3 for 1B, $4 for 1C.
You cannot add more items, though only $9 for 2A ,2B and 1C.

Note:

  • There are at most 6 kinds of items, 100 special offers.
  • For each item, you need to buy at most 6 of them.
  • You are not allowed to buy more items than you want, even if that would lower the overall price.

这道题说有一些商品,各自有不同的价格,然后给我们了一些优惠券,可以在优惠的价格买各种商品若干个,要求我们每个商品要买特定的个数,问我们使用优惠券能少花多少钱,注意优惠券可以重复使用,而且商品不能多买。那么我们可以先求出不使用任何商品需要花的钱数作为结果res的初始值,然后我们遍历每一个优惠券,定义一个变量isValid表示当前优惠券可以使用,然后遍历每一个商品,如果某个商品需要的个数小于优惠券中提供的个数,说明当前优惠券不可用,isValid标记为false。如果遍历完了发现isValid还为true的话,表明该优惠券可用,我们可以更新结果res,对剩余的needs调用递归并且加上使用该优惠券需要付的钱数。最后别忘了恢复needs的状态,主要是dfs。参见代码如下:

1
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 shoppingOffers(vector<int>& price, vector<vector<int>>& specials, vector<int>& needs) {
int len = price.size();
int res = 0;
for (int i = 0; i < len; i ++)
res += (price[i] * needs[i]);
for (auto special : specials) {
bool isvalid = true;
for (int i = 0; i < len; i ++) {
if (needs[i] < special[i])
isvalid = false;
needs[i] -= special[i];
}

if (isvalid)
res = min(res, shoppingOffers(price, specials, needs) + special.back());

for (int i = 0; i < len; i ++) {
needs[i] += special[i];
}
}
return res;
}
};

Leetcode639. Decode Ways II

A message containing letters from A-Z is being encoded to numbers using the following mapping way:

1
2
3
4
'A' -> 1
'B' -> 2
...
'Z' -> 26

Beyond that, now the encoded string can also contain the character ‘*’, which can be treated as one of the numbers from 1 to 9.

Given the encoded message containing digits and the character ‘*’, return the total number of ways to decode it.

Also, since the answer may be very large, you should return the output mod 109 + 7.

Example 1:

1
2
3
Input: "*"
Output: 9
Explanation: The encoded message can be decoded to the string: "A", "B", "C", "D", "E", "F", "G", "H", "I".

Example 2:

1
2
Input: "1*"
Output: 9 + 9 = 18

Note:

  • The length of the input string will fit in range [1, 105].
  • The input string will only contain the character ‘*’ and digits ‘0’ - ‘9’.

这道解码的题是之前那道Decode Ways的拓展,难度提高了不少,引入了星号,可以代表1到9之间的任意数字,是不是有点外卡匹配的感觉。有了星号以后,整个题就变得异常的复杂,所以结果才让我们对一个很大的数求余,避免溢出。这道题的难点就是要分情况种类太多,一定要全部理通顺才行。我们还是用DP来做,建立一个一维dp数组,其中dp[i]表示前i个字符的解码方法等个数,长度为字符串的长度加1。将dp[0]初始化为1,然后我们判断,如果字符串第一个字符是0,那么直接返回0,如果是*,则dp[1]初始化为9,否则初始化为1。下面就来计算一般情况下的dp[i]了,我们从i=2开始遍历,由于要分的情况种类太多,我们先选一个大分支,就是当前遍历到的字符s[i-1],只有三种情况,要么是0,要么是1到9的数字,要么是星号。我们一个一个来分析:

首先来看s[i-1]为0的情况,这种情况相对来说比较简单,因为0不能单独拆开,只能跟前面的数字一起,而且前面的数字只能是1或2,其他的直接返回0即可。那么当前面的数字是1或2的时候,dp[i]的种类数就跟dp[i-2]相等,可以参见之前那道Decode Ways的讲解,因为后两数无法单独拆分开,就无法产生新的解码方法,所以只保持住原来的拆分数量就不错了;如果前面的数是星号的时候,那么前面的数可以为1或者2,这样就相等于两倍的dp[i-2];如果前面的数也为0,直接返回0即可。

再来看s[i-1]为1到9之间的数字的情况,首先搞清楚当前数字是可以单独拆分出来的,那么dp[i]至少是等于dp[i-1]的,不会拖后腿,还要看其能不能和前面的数字组成两位数进一步增加解码方法。那么就要分情况讨论前面一个数字的种类,如果当前数字可以跟前面的数字组成一个小于等于26的两位数的话,dp[i]还需要加上dp[i-2];如果前面的数字为星号的话,那么要看当前的数字是否小于等于6,如果是小于等于6,那么前面的数字就可以是1或者2了,此时dp[i]需要加上两倍的dp[i-2],如果大于6,那么前面的数字只能是1,所以dp[i]只能加上dp[i-2]。

最后来看s[i-1]为星号的情况,如果当前数字为星号,那么就创造9种可以单独拆分的方法,所以那么dp[i]至少是等于9倍的dp[i-1],还要看其能不能和前面的数字组成两位数进一步增加解码方法。那么就要分情况讨论前面一个数字的种类,如果前面的数字是1,那么当前的9种情况都可以跟前面的数字组成两位数,所以dp[i]需要加上9倍的dp[i-2];如果前面的数字是2,那么只有小于等于6的6种情况都可以跟前面的数字组成两位数,所以dp[i]需要加上6倍的dp[i-2];如果前面的数字是星号,那么就是上面两种情况的总和,dp[i]需要加上15倍的dp[i-2]。

每次算完dp[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
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
int numDecodings(string s) {
int n = s.size(), M = 1e9 + 7;
vector<long> dp(n + 1, 0);
dp[0] = 1;
if (s[0] == '0') return 0;
dp[1] = (s[0] == '*') ? 9 : 1;
for (int i = 2; i <= n; ++i) {
if (s[i - 1] == '0') {
if (s[i - 2] == '1' || s[i - 2] == '2') {
dp[i] += dp[i - 2];
} else if (s[i - 2] == '*') {
dp[i] += 2 * dp[i - 2];
} else {
return 0;
}
} else if (s[i - 1] >= '1' && s[i - 1] <= '9') {
dp[i] += dp[i - 1];
if (s[i - 2] == '1' || (s[i - 2] == '2' && s[i - 1] <= '6')) {
dp[i] += dp[i - 2];
} else if (s[i - 2] == '*') {
dp[i] += (s[i - 1] <= '6') ? (2 * dp[i - 2]) : dp[i - 2];
}
} else { // s[i - 1] == '*'
dp[i] += 9 * dp[i - 1];
if (s[i - 2] == '1') dp[i] += 9 * dp[i - 2];
else if (s[i - 2] == '2') dp[i] += 6 * dp[i - 2];
else if (s[i - 2] == '*') dp[i] += 15 * dp[i - 2];
}
dp[i] %= M;
}
return dp[n];
}
};

下面这种解法是论坛上排名最高的解法,常数级的空间复杂度,写法非常简洁,思路也巨牛逼,博主是无论如何也想不出来的,只能继续当搬运工了。这里定义了一系列的变量e0, e1, e2, f0, f1, f2。其中:

  • e0表示当前可以获得的解码的次数,当前数字可以为任意数 (也就是上面解法中的dp[i])
  • e1表示当前可以获得的解码的次数,当前数字为1
  • e2表示当前可以获得的解码的次数,当前数字为2
  • f0, f1, f2分别为处理完当前字符c的e0, e1, e2的值

那么下面我们来进行分类讨论,当c为星号的时候,f0的值就是9e0 + 9e1 + 6*e2,这个应该不难理解了,可以参考上面解法中的讲解,这里的e0就相当于dp[i-1],e1和e2相当于两种不同情况的dp[i-2],此时f1和f2都赋值为e0,因为要和后面的数字组成两位数的话,不会增加新的解码方法,所以解码总数跟之前的一样,为e0, 即dp[i-1]。

当c不为星号的时候,如果c不为0,则f0首先应该加上e0。然后不管c为何值,e1都需要加上,总能和前面的1组成两位数;如果c小于等于6,可以和前面的2组成两位数,可以加上e2。然后我们更新f1和f2,如果c为1,则f1为e0;如果c为2,则f2为e0。

最后别忘了将f0,f1,f2赋值给e0,e1,e2,其中f0需要对超大数取余,参见代码如下:

解法二:

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 numDecodings(string s) {
long e0 = 1, e1 = 0, e2 = 0, f0, f1, f2, M = 1e9 + 7;
for (char c : s) {
if (c == '*') {
f0 = 9 * e0 + 9 * e1 + 6 * e2;
f1 = e0;
f2 = e0;
} else {
f0 = (c > '0') * e0 + e1 + (c <= '6') * e2;
f1 = (c == '1') * e0;
f2 = (c == '2') * e0;
}
e0 = f0 % M;
e1 = f1;
e2 = f2;
}
return e0;
}
};

Leetcode640. Solve the Equation

Solve a given equation and return the value of ‘x’ in the form of a string “x=#value”. The equation contains only ‘+’, ‘-‘ operation, the variable ‘x’ and its coefficient. You should return “No solution” if there is no solution for the equation, or “Infinite solutions” if there are infinite solutions for the equation.

If there is exactly one solution for the equation, we ensure that the value of ‘x’ is an integer.

Example 1:

1
2
Input: equation = "x+5-3+x=6+x-2"
Output: "x=2"

Example 2:

1
2
Input: equation = "x=x"
Output: "Infinite solutions"

Example 3:

1
2
Input: equation = "2x=x"
Output: "x=0"

Example 4:

1
2
Input: equation = "2x+3x-6x=x+2"
Output: "x=-1"

Example 5:

1
2
Input: equation = "x=x+2"
Output: "No solution"

这道题给了我们一个用字符串表示的方程式,让我们求出x的解,根据例子可知,还包括x有无穷多个解和x没有解的情况。解一元一次方程没什么难度,难点在于处理字符串,如何将x的系数合并起来,将常数合并起来,化简成ax=b的形式来求解。博主最开始的思路是先找到等号,然后左右两部分分开处理。由于要化成ax=b的格式,所以左半部分对于x的系数都是加,右半部分对于x的系数都是减。同理,左半部分对于常数是减,右半部分对于常数是加。

那么我们就开始处理字符串了,我们定义一个符号变量sign,初始化为1,数字变量num,初始化为-1,后面会提到为啥不能初始化为0。我们遍历每一个字符,如果遇到了符号位,我们看num的值,如果num是-1的话,说明是初始值,没有更新过,我们将其赋值为0;反之,如果不是-1,说明num已经更新过了,我们乘上当前的正负符号值sign。这是为了区分”-3”和”3+3”这种两种情况,遇到-3种的符号时,我们还不需要加到b中,所以num此时必须为0,而遇到3+3中的加号时,此时num已经为3了,我们要把前面的3加到b中。

遇到数字的时候,我们还是要看num的值,如果是初始值,那么就将其赋值为0,然后计算数字的时候要先给num乘10,再加上当前的数字。这样做的原因是常数不一定都是个位数字,有可能是两位数或者三位数,这样做才能正确的读入数字。我们在遇到数字的时候并不更新a或者b,我们只在遇到符号位或者x的时候才更新。这样如果最后一位是数字的话就会产生问题,所以我们要在字符串的末尾加上一个+号,这样确保了末尾数字会被处理。

遇到x的时候比较tricky,因为可能是x, 0x, -x这几种情况,我们还是首先要看num的值是否为初始值-1,如果是的话,那么就可能是x或-x这种情况,我们此时将num赋值为sign;如果num不是-1,说明num已经被更新了,可能是0x, -3x等等,所以我们要将num赋值为num*sign。这里应该就明白了为啥不能将num初始化为0了,因为一旦初始化为0了,就没法区分x和0x这两种情况了。

那么我们算完了a和b,得到了ax=b的等式,下面的步骤就很简单了,只要分情况讨论得出正确的返回结果即可,参见代码如下:

1
2
3
4
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:
string solveEquation(string equation) {
int a = 0, b = 0;
auto found = equation.find("=");
helper(equation.substr(0, found), true, a, b);
helper(equation.substr(found + 1), false, a, b);
if (a == 0 && a == b) return "Infinite solutions";
if (a == 0 && a != b) return "No solution";
return "x=" + to_string(b / a);
}
void helper(string e, bool isLeft, int& a, int& b) {
int sign = 1, num = -1;
e += "+";
for (int i = 0; i < e.size(); ++i) {
if (e[i] == '-' || e[i] == '+') {
num = (num == -1) ? 0 : (num * sign);
b += isLeft ? -num : num;
num = -1;
sign = (e[i] == '+') ? 1 : -1;
} else if (e[i] >= '0' && e[i] <= '9') {
if (num == -1) num = 0;
num = num * 10 + e[i] - '0';
} else if (e[i] == 'x') {
num = (num == -1) ? sign : (num * sign);
a += isLeft ? num : -num;
num = -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
class Solution {
public:

void helper(string e, int &i, int &x_l, int &l, bool isright) {
int len = e.length();
int num, flag;
if (isright)
i ++;
while(i < len && (isright || e[i] != '=')) {
num = 0;
flag = 1;
if (e[i] == '-') {
flag = -1;
i++;
}
else if (e[i] == '+') {
flag = 1;
i ++;
}
else if (e[i] == 'x') {
x_l ++;
i ++;
}
int or_i = i;
while(i < len && '0' <= e[i] && e[i] <= '9')
num = num * 10 + e[i++] - '0';
num *= flag;
if (num == 0 && flag == -1)
num = -1;
if (num != 0 && e[i] == 'x' || or_i != i && e[i] == 'x') {
x_l += num;
i ++;
}
else {
l += num;
}
}
}

string solveEquation(string e) {
int len = e.length(), i = 0;
int x_l = 0, x_r = 0;
int l = 0, r = 0;
int num, flag;

helper(e, i, x_l, l, false);
helper(e, i, x_r, r, true);
if (l - r == 0 && x_l - x_r == 0)
return "Infinite solutions";
else if (x_l - x_r == 0 && l - r != 0)
return "No solution";
else if (l - r == 0 && x_l - x_r != 0)
return "x=0";
else {
x_l -= x_r;
r -= l;
return "x="+to_string(r/x_l);
}
return "";

}
};

Leetcode641. Design Circular Deque

Design your implementation of the circular double-ended queue (deque).

Your implementation should support following operations:

  • MyCircularDeque(k): Constructor, set the size of the deque to be k.
  • insertFront(): Adds an item at the front of Deque. Return true if the operation is successful.
  • insertLast(): Adds an item at the rear of Deque. Return true if the operation is successful.
  • deleteFront(): Deletes an item from the front of Deque. Return true if the operation is successful.
  • deleteLast(): Deletes an item from the rear of Deque. Return true if the operation is successful.
  • getFront(): Gets the front item from the Deque. If the deque is empty, return -1.
  • getRear(): Gets the last item from Deque. If the deque is empty, return -1.
  • isEmpty(): Checks whether Deque is empty or not.
  • isFull(): Checks whether Deque is full or not.

Example:

1
2
3
4
5
6
7
8
9
10
MyCircularDeque circularDeque = new MycircularDeque(3); // set the size to be 3
circularDeque.insertLast(1); // return true
circularDeque.insertLast(2); // return true
circularDeque.insertFront(3); // return true
circularDeque.insertFront(4); // return false, the queue is full
circularDeque.getRear(); // return 2
circularDeque.isFull(); // return true
circularDeque.deleteLast(); // return true
circularDeque.insertFront(4); // return true
circularDeque.getFront(); // return 4

Note:

  • All values will be in the range of [0, 1000].
  • The number of operations will be in the range of [1, 1000].
  • Please do not use the built-in Deque library.

就像前一道题中的分析的一样,上面的解法并不是本题真正想要考察的内容,我们要用上环形Circular的性质,我们除了使用size来记录环形队列的最大长度之外,还要使用三个变量,head,tail,cnt,分别来记录队首位置,队尾位置,和当前队列中数字的个数,这里我们将head初始化为k-1,tail初始化为0。还是从简单的做起,判空就看当前个数cnt是否为0,判满就看当前个数cnt是否等于size。接下来取首尾元素,先进行判空,然后根据head和tail分别向后和向前移动一位取即可,记得使用上循环数组的性质,要对size取余。再来看删除末尾函数,先进行判空,然后tail向前移动一位,使用循环数组的操作,然后cnt自减1。同理,删除开头函数,先进行判空,队首位置head要向后移动一位,同样进行加1之后对长度取余的操作,然后cnt自减1。再来看插入末尾函数,先进行判满,然后将新的数字加到当前的tail位置,tail移动到下一位,为了避免越界,我们使用环形数组的经典操作,加1之后对长度取余,然后cnt自增1即可。同样,插入开头函数,先进行判满,然后将新的数字加到当前的head位置,head移动到前一位,然后cnt自增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
67
68
69
70
71
72
73
74
75
76
class MyCircularDeque {
public:

vector<int> v;
int size, head, tail, cnt;
/** Initialize your data structure here. Set the size of the deque to be k. */
MyCircularDeque(int k) {
size = k;
head = k-1;
tail = 0;
cnt = 0;
v.resize(k);
}

/** Adds an item at the front of Deque. Return true if the operation is successful. */
bool insertFront(int value) {
if (isFull())
return false;
v[head] = value;
head = (head-1+size) % size;
cnt ++;
return true;
}

/** Adds an item at the rear of Deque. Return true if the operation is successful. */
bool insertLast(int value) {
if (isFull())
return false;
v[tail] = value;
tail = (tail + 1) % size;
cnt ++;
return true;
}

/** Deletes an item from the front of Deque. Return true if the operation is successful. */
bool deleteFront() {
if (isEmpty())
return false;
head = (head + 1) % size;
cnt --;
return true;
}

/** Deletes an item from the rear of Deque. Return true if the operation is successful. */
bool deleteLast() {
if (isEmpty())
return false;
tail = (tail - 1 + size) % size;
cnt --;
return true;
}

/** Get the front item from the deque. */
int getFront() {
if (isEmpty())
return -1;
return v[(head+1)%size];
}

/** Get the last item from the deque. */
int getRear() {
if (isEmpty())
return -1;
return v[(tail-1+size)%size];
}

/** Checks whether the circular deque is empty or not. */
bool isEmpty() {
return cnt == 0;
}

/** Checks whether the circular deque is full or not. */
bool isFull() {
return cnt == size;
}
};

Leetcode643. Maximum Average Subarray I

Given an array consisting of n integers, find the contiguous subarray of given length k that has the maximum average value. And you need to output the maximum average value.

Example 1:

1
2
3
Input: [1,12,-5,-6,50,3], k = 4
Output: 12.75
Explanation: Maximum average is (12-5-6+50)/4 = 51/4 = 12.75

Note:

  • 1 <= k <= n <= 30,000.
  • Elements of the given array will be in the range [-10,000, 10,000].
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
double findMaxAverage(vector<int>& nums, int k) {
double sum = 0.0;
for(int i = 0; i < k; i ++)
sum += nums[i];
double res = sum;
for(int i = k; i < nums.size(); i ++) {
sum = sum + nums[i] - nums[i-k];
res = max(res, sum);
}
return res / k;
}
};

Leetcode645. Set Mismatch

The set S originally contains numbers from 1 to n. But unfortunately, due to the data error, one of the numbers in the set got duplicated to another number in the set, which results in repetition of one number and loss of another number.

Given an array nums representing the data status of this set after the error. Your task is to firstly find the number occurs twice and then find the number that is missing. Return them in the form of an array.

Example 1:

1
2
Input: nums = [1,2,2,4]
Output: [2,3]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> findErrorNums(vector<int>& nums) {
vector<int> res;
map<int, int> mp;
for(int i = 0; i < nums.size(); i ++)
mp[nums[i]] ++;
for(int j = 1; j <= nums.size(); j ++)
{
if(mp[j] == 2)
res.insert(res.begin(), j);
if(mp[j] == 0)
res.push_back(j);
}
return res;
}
};

Leetcode646. Maximum Length of Pair Chain

You are given an array of n pairs pairs where pairs[i] = [lefti, righti] and lefti < righti.

A pair p2 = [c, d] follows a pair p1 = [a, b] if b < c. A chain of pairs can be formed in this fashion.

Return the length longest chain which can be formed.

You do not need to use up all the given intervals. You can select pairs in any order.

Example 1:

1
2
3
Input: pairs = [[1,2],[2,3],[3,4]]
Output: 2
Explanation: The longest chain is [1,2] -> [3,4].

Example 2:

1
2
3
Input: pairs = [[1,2],[7,8],[4,5]]
Output: 3
Explanation: The longest chain is [1,2] -> [4,5] -> [7,8].

这道题给了我们一些链对,规定了如果后面链对的首元素大于前链对的末元素,那么这两个链对就可以链起来,问我们最大能链多少个。那么我们想,由于规定了链对的首元素一定小于尾元素,我们需要比较的是某个链表的首元素和另一个链表的尾元素之间的关系,如果整个链对数组是无序的,那么就很麻烦,所以我们需要做的是首先对链对数组进行排序,按链对的尾元素进行排序,小的放前面。这样我们就可以利用Greedy算法进行求解了。我们可以用一个栈,先将第一个链对压入栈,然后对于后面遍历到的每一个链对,我们看其首元素是否大于栈顶链对的尾元素,如果大于的话,就将当前链对压入栈,这样最后我们返回栈中元素的个数即可,用一个变量对栈进行优化。参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:

static bool comp(vector<int> &a, vector<int> &b) {
return a[1] < b[1];
}

int findLongestChain(vector<vector<int>>& pairs) {
sort(pairs.begin(), pairs.end(), comp);
stack<vector<int> > s;
int tail = INT_MIN, res = 0;
for (auto p : pairs) {
if (tail < p[0]) {
tail = p[1];
res ++;
}
}
return res;
}
};

Leetcode647. Palindromic Substrings

Given a string, your task is to count how many palindromic substrings in this string.

The substrings with different start indexes or end indexes are counted as different substrings even they consist of same characters.

Example 1:

1
2
3
Input: "abc"
Output: 3
Explanation: Three palindromic strings: "a", "b", "c".

Example 2:

1
2
3
Input: "aaa"
Output: 6
Explanation: Six palindromic strings: "a", "a", "a", "aa", "aa", "aaa".

Note:

  • The input string length won’t exceed 1000.

这道题给了一个字符串,让我们计算有多少个回文子字符串。以字符串中的每一个字符都当作回文串中间的位置,然后向两边扩散,每当成功匹配两个左右两个字符,结果 res 自增1,然后再比较下一对。注意回文字符串有奇数和偶数两种形式,如果是奇数长度,那么i位置就是中间那个字符的位置,所以左右两遍都从i开始遍历;如果是偶数长度的,那么i是最中间两个字符的左边那个,右边那个就是 i+1,这样就能 cover 所有的情况啦,而且都是不同的回文子字符串,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int countSubstrings(string s) {
if (s.empty()) return 0;
int n = s.size(), res = 0;
for (int i = 0; i < n; ++i) {
helper(s, i, i, res);
helper(s, i, i + 1, res);
}
return res;
}
void helper(string s, int i, int j, int& res) {
while (i >= 0 && j < s.size() && s[i] == s[j]) {
--i; ++j; ++res;
}
}
};

dp[i][j]定义成子字符串[i, j]是否是回文串就行了,然后i从 n-1 往0遍历,j从i往 n-1 遍历,然后看s[i]s[j]是否相等,这时候需要留意一下,有了s[i]s[j]相等这个条件后,i和j的位置关系很重要,如果i和j相等了,则dp[i][j]肯定是 true;如果i和j是相邻的,那么dp[i][j]也是 true;如果i和j中间只有一个字符,那么dp[i][j]还是 true;如果中间有多余一个字符存在,则需要看dp[i+1][j-1]是否为 true,若为 true,那么dp[i][j]就是 true。赋值dp[i][j]后,如果其为 true,结果res自增1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int countSubstrings(string s) {
int n = s.size(), res = 0;
vector<vector<bool>> dp(n, vector<bool>(n));
for (int i = n - 1; i >= 0; --i) {
for (int j = i; j < n; ++j) {
dp[i][j] = (s[i] == s[j]) && (j - i <= 2 || dp[i + 1][j - 1]);
if (dp[i][j]) ++res;
}
}
return res;
}
};

Leetcode648. Replace Words

In English, we have a concept called root, which can be followed by some other words to form another longer word - let’s call this word successor. For example, the root an, followed by other, which can form another word another.

Now, given a dictionary consisting of many roots and a sentence. You need to replace all the successor in the sentence with the root forming it. If a successor has many roots can form it, replace it with the root with the shortest length.

You need to output the sentence after the replacement.

Example 1:

1
2
Input: dictionary = ["cat","bat","rat"], sentence = "the cattle was rattled by the battery"
Output: "the cat was rat by the bat"

Example 2:

1
2
Input: dictionary = ["a","b","c"], sentence = "aadsfasf absbs bbab cadsfafs"
Output: "a a b c"

Example 3:

1
2
Input: dictionary = ["a", "aa", "aaa", "aaaa"], sentence = "a aa a aaaa aaa aaa aaa aaaaaa bbb baba ababa"
Output: "a a a a a a a a bbb baba a"

Example 4:

1
2
Input: dictionary = ["catt","cat","bat","rat"], sentence = "the cattle was rattled by the battery"
Output: "the cat was rat by the bat"

Example 5:

1
2
Input: dictionary = ["ac","ab"], sentence = "it is abnormal that this solution is accepted"
Output: "it is ab that this solution is ac"

Note:

  • The input will only have lower-case letters.
  • 1 <= dict words number <= 1000
  • 1 <= sentence words number <= 1000
  • 1 <= root length <= 100
  • 1 <= sentence words length <= 1000

这道题给了我们一个前缀字典,又给了一个句子,让我们将句子中较长的单词换成其前缀(如果在前缀字典中存在的话)。我们对于句子中的一个长单词如何找前缀呢,是不是可以根据第一个字母来快速定位呢,比如cattle这个单词的首字母是c,那么我们在前缀字典中找所有开头是c的前缀,为了方便查找,我们将首字母相同的前缀都放到同一个数组中,总共需要26个数组,所以我们可以定义一个二维数组来装这些前缀。还有,我们希望短前缀在长前缀的前面,因为题目中要求用最短的前缀来替换单词,所以我们可以先按单词的长度来给所有的前缀排序,然后再依次加入对应的数组中,这样就可以保证短的前缀在前面。

下面我们就要来遍历句子中的每一个单词了,由于C++中没有split函数,所以我们就采用字符串流来提取每一个单词,对于遍历到的单词,我们根据其首字母查找对应数组中所有以该首字母开始的前缀,然后直接用substr函数来提取单词中和前缀长度相同的子字符串来跟前缀比较,如果二者相等,说明可以用前缀来替换单词,然后break掉for循环。别忘了单词之前还要加上空格,参见代码如下:

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:
string replaceWords(vector<string>& dict, string sentence) {
string res = "", t = "";
vector<vector<string>> v(26);
istringstream is(sentence);
sort(dict.begin(), dict.end(), [](string &a, string &b) {return a.size() < b.size();});
for (string word : dict) {
v[word[0] - 'a'].push_back(word);
}
while (is >> t) {
for (string word : v[t[0] - 'a']) {
if (t.substr(0, word.size()) == word) {
t = word;
break;
}
}
res += t + " ";
}
res.pop_back();
return res;
}
};

我们要做的就是把所有的前缀都放到前缀树里面,而且在前缀的最后一个结点的地方将标示isWord设为true,表示从根节点到当前结点是一个前缀,然后我们在遍历单词中的每一个字母,我们都在前缀树查找,如果当前字母对应的结点的表示isWord是true,我们就返回这个前缀,如果当前字母对应的结点在前缀树中不存在,我们就返回原单词。

1
2
3
4
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
class Solution {
public:

class Trie {
public:
bool isword;
Trie* t[26];
Trie() : isword(false) {
for(int i = 0; i < 26; i ++)
t[i] = NULL;
}
};

string replaceWords(vector<string>& dictionary, string sentence) {
string res = "";
Trie *root = new Trie();
for (int i = 0; i < dictionary.size(); i ++)
insert(root, dictionary[i]);
int i = 0, len = sentence.length();
while (i < len) {
res += find(root, sentence, i);
while(i < len && sentence[i] != ' ')
i ++;
i ++;
if (i < len)
res += ' ';
}
return res;
}

void insert(Trie *root, string s) {
for (char c : s) {
if (root->t[c - 'a'] == NULL) {
root->t[c - 'a'] = new Trie();
}
root = root->t[c - 'a'];
}
root->isword = true;
}

string find(Trie *root, string s, int& i) {
int len = s.length();
string t = "";
while(i < len && s[i] != ' ') {
root = root->t[s[i] - 'a'];
if (root == NULL)
break;
t += s[i ++];
if (root->isword)
return t;
}
while (i < len && s[i] != ' ') {
t += s[i++];
}
return t;
}
};

Leetcode649. Dota2 Senate

In the world of Dota2, there are two parties: the Radiant and the Dire.

The Dota2 senate consists of senators coming from two parties. Now the senate wants to make a decision about a change in the Dota2 game. The voting for this change is a round-based procedure. In each round, each senator can exercise one of the two rights:

  • Ban one senator’s right: A senator can make another senator lose all his rights in this and all the following rounds.
  • Announce the victory: If this senator found the senators who still have rights to vote are all from the same party, he can announce the victory and make the decision about the change in the game.

Given a string representing each senator’s party belonging. The character ‘R’ and ‘D’ represent the Radiant party and the Dire party respectively. Then if there are n senators, the size of the given string will be n.

The round-based procedure starts from the first senator to the last senator in the given order. This procedure will last until the end of voting. All the senators who have lost their rights will be skipped during the procedure.

Suppose every senator is smart enough and will play the best strategy for his own party, you need to predict which party will finally announce the victory and make the change in the Dota2 game. The output should be Radiant or Dire.

Example 1:

1
2
3
4
5
Input: "RD"
Output: "Radiant"
Explanation: The first senator comes from Radiant and he can just ban the next senator's right in the round 1.
And the second senator can't exercise any rights any more since his right has been banned.
And in the round 2, the first senator can just announce the victory since he is the only guy in the senate who can vote.

Example 2:

1
2
3
4
5
6
7
Input: "RDD"
Output: "Dire"
Explanation:
The first senator comes from Radiant and he can just ban the next senator's right in the round 1.
And the second senator can't exercise any rights anymore since his right has been banned.
And the third senator comes from Dire and he can ban the first senator's right in the round 1.
And in the round 2, the third senator can just announce the victory since he is the only guy in the senate who can vote.

这道题模拟了刀塔类游戏开始之前的BP过程,两个阵营按顺序Ban掉对方的英雄,看最后谁剩下来了,就返回哪个阵营。我们可以用两个队列queue,把各自阵营的位置存入不同的队列里面,然后进行循环,每次从两个队列各取一个位置出来,看其大小关系,小的那个说明在前面,就可以把后面的那个Ban掉,所以我们要把小的那个位置要加回队列里面,但是不能直接加原位置,因为下一轮才能再轮到他来Ban,所以我们要加上一个n,再排入队列。这样当某个队列为空时,推出循环,我们返回不为空的那个阵营,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
string predictPartyVictory(string senate) {
int n = senate.size();
queue<int> q1, q2;
for (int i = 0; i < n; ++i) {
(senate[i] == 'R') ? q1.push(i) : q2.push(i);
}
while (!q1.empty() && !q2.empty()) {
int i = q1.front(); q1.pop();
int j = q2.front(); q2.pop();
(i < j) ? q1.push(i + n) : q2.push(j + n);
}
return (q1.size() > q2.size()) ? "Radiant" : "Dire";
}
};

Leetcode650. 2 Keys Keyboard

Initially on a notepad only one character ‘A’ is present. You can perform two operations on this notepad for each step:

  • Copy All: You can copy all the characters present on the notepad (partial copy is not allowed).
  • Paste: You can paste the characters which are copied last time.

Given a number n. You have to get exactly n ‘A’ on the notepad by performing the minimum number of steps permitted. Output the minimum number of steps to get n ‘A’.

Example 1:

1
2
3
4
5
6
7
Input: 3
Output: 3
Explanation:
Intitally, we have one character 'A'.
In step 1, we use Copy All operation.
In step 2, we use Paste operation to get 'AA'.
In step 3, we use Paste operation to get 'AAA'.

这道题就是给了复制和粘贴这两个按键,然后给了一个A,目标时利用这两个键来打印出n个A,注意复制的时候时全部复制,不能选择部分来复制,然后复制和粘贴都算操作步骤,问打印出n个A需要多少步操作。

当n = 1时,已经有一个A了,不需要其他操作,返回0

当n = 2时,需要复制一次,粘贴一次,返回2

当n = 3时,需要复制一次,粘贴两次,返回3

当n = 4时,这就有两种做法,一种是需要复制一次,粘贴三次,共4步,另一种是先复制一次,粘贴一次,得到 AA,然后再复制一次,粘贴一次,得到 AAAA,两种方法都是返回4

当n = 5时,需要复制一次,粘贴四次,返回5

当n = 6时,需要复制一次,粘贴两次,得到 AAA,再复制一次,粘贴一次,得到 AAAAAA,共5步,返回5

通过分析上面这6个简单的例子,已经可以总结出一些规律了,首先对于任意一个n(除了1以外),最差的情况就是用n步,不会再多于n步,但是有可能是会小于n步的,比如 n=6 时,就只用了5步,仔细分析一下,发现时先拼成了 AAA,再复制粘贴成了 AAAAAA。那么什么情况下可以利用这种方法来减少步骤呢,分析发现,小模块的长度必须要能整除n,这样才能拆分。对于 n=6,我们其实还可先拼出 AA,然后再复制一次,粘贴两次,得到的还是5。分析到这里,解题的思路应该比较清晰了,找出n的所有因子,然后这个因子可以当作模块的个数,再算出模块的长度 n/i,调用递归,加上模块的个数i来更新结果 res 即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int minSteps(int n) {
if (n == 1) return 0;
int res = n;
for (int i = n - 1; i > 1; --i) {
if (n % i == 0) {
res = min(res, minSteps(n / i) + i);
}
}
return res;
}
};

下面这种方法是用 DP 来做的,我们可以看出来,其实就是上面递归解法的迭代形式,思路没有任何区别,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int minSteps(int n) {
vector<int> dp(n + 1, 0);
for (int i = 2; i <= n; ++i) {
dp[i] = i;
for (int j = i - 1; j > 1; --j) {
if (i % j == 0) {
dp[i] = min(dp[i], dp[j] + i / j);
}
}
}
return dp[n];
}
};

Leetcode652. Find Duplicate Subtrees

Given a binary tree, return all duplicate subtrees. For each kind of duplicate subtrees, you only need to return the root node of any oneof them.

Two trees are duplicate if they have the same structure with same node values.

Example 1:

1
2
3
4
5
6
7
    1
/ \
2 3
/ / \
4 2 4
/
4

The following are two duplicate subtrees:

1
2
3
  2
/
4

and

1
4

Therefore, you need to return above trees’ root in the form of a list.

这道题让我们寻找重复树,建立序列化跟其出现次数的映射,这样如果我们得到某个结点的序列化字符串,而该字符串正好出现的次数为1,说明之前已经有一个重复树了,我们将当前结点存入结果res,这样保证了多个重复树只会存入一个结点,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
vector<TreeNode*> findDuplicateSubtrees(TreeNode* root) {
vector<TreeNode*> res;
unordered_map<string, int> m;
helper(root, m, res);
return res;
}

string helper(TreeNode* root, unordered_map<string, int>& m, vector<TreeNode*>& res) {
if (!root)
return "#";
string str = to_string(root->val) + ',' + helper(root->left, m, res) + ',' + helper(root->right, m, res);
if (m[str] == 1)
res.push_back(root);
m[str] ++;
return str;
}
};

Leetcode653. Two Sum IV - Input is a BST

Given a Binary Search Tree and a target number, return true if there exist two elements in the BST such that their sum is equal to the given target.

Example 1:

1
2
3
4
5
6
7
8
9
Input: 
5
/ \
3 6
/ \ \
2 4 7

Target = 9
Output: True

Example 2:
1
2
3
4
5
6
7
8
9
Input: 
5
/ \
3 6
/ \ \
2 4 7

Target = 28
Output: False

这道题又是一道2sum的变种题。只要是两数之和的题,一定要记得先尝试用HashSet来做,这道题只不过是把数组变成了一棵二叉树而已,换汤不换药,我们遍历二叉树就行,然后用一个HashSet,在递归函数函数中,如果node为空,返回false。如果k减去当前结点值在HashSet中存在,直接返回true;否则就将当前结点值加入HashSet,然后对左右子结点分别调用递归函数并且或起来返回即可。本来想用双指针的,但是不好办,要考虑的情况太多。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
bool dfs(TreeNode* root, unordered_set<int> &mp, int k) {
if(!root)
return false;
if(mp.count(k - root->val))
return true;
mp.insert(root->val);
return dfs(root->left, mp, k) || dfs(root->right, mp, k);
}
bool findTarget(TreeNode* root, int k) {
unordered_set<int> mp;
return dfs(root, mp, k);
}
};

Leetcode654. Maximum Binary Tree

Given an integer array with no duplicates. A maximum tree building on this array is defined as follow:

The root is the maximum number in the array.
The left subtree is the maximum tree constructed from left part subarray divided by the maximum number.
The right subtree is the maximum tree constructed from right part subarray divided by the maximum number.
Construct the maximum tree by the given array and output the root node of this tree.

Example 1:

1
2
3
4
5
6
7
8
9
10
Input: [3,2,1,6,0,5]
Output: return the tree root node representing the following tree:

6
/ \
3 5
\ /
2 0
\
1

Note:
The size of the given array will be in the range [1,1000].

这个题比较奇怪,其实不太懂题意,主要是给一个数组,把数组建立成一个树,找到最大的数作为root,然后递归建立,大概是这个意思。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:

int max(vector<int>& nums, int l,int r){
int biggest = l;
for(int i=l;i<r;i++){
if(nums[biggest]<nums[i])
biggest = i;
}
return biggest;
}

TreeNode* construct(vector<int>& nums, int l, int r){
if(l == r)
return NULL;
int biggest = max(nums,l,r);
TreeNode* root = new TreeNode(nums[biggest]);
root->left=construct(nums,l,biggest);
root->right=construct(nums,biggest+1,r);
return root;
}

TreeNode* constructMaximumBinaryTree(vector<int>& nums) {
return construct(nums, 0, nums.size());
}
};

Leetcode655. Print Binary Tree

Print a binary tree in an m*n 2D string array following these rules:

The row number m should be equal to the height of the given binary tree.

The column number n should always be an odd number.

The root node’s value (in string format) should be put in the exactly middle of the first row it can be put. The column and the row where the root node belongs will separate the rest space into two parts (left-bottom part and right-bottom part). You should print the left subtree in the left-bottom part and print the right subtree in the right-bottom part. The left-bottom part and the right-bottom part should have the same size. Even if one subtree is none while the other is not, you don’t need to print anything for the none subtree but still need to leave the space as large as that for the other subtree. However, if two subtrees are none, then you don’t need to leave space for both of them.

Each unused space should contain an empty string “”.

Print the subtrees following the same rules.

Example 1:

1
2
3
4
5
6
7
Input:
1
/
2
Output:
[["", "1", ""],
["2", "", ""]]

Example 2:

1
2
3
4
5
6
7
8
9
10
Input:
1
/ \
2 3
\
4
Output:
[["", "", "", "1", "", "", ""],
["", "2", "", "", "", "3", ""],
["", "", "4", "", "", "", ""]]

Example 3:

1
2
3
4
5
6
7
8
9
10
11
12
13
Input:
1
/ \
2 5
/
3
/
4
Output:
[["", "", "", "", "", "", "", "1", "", "", "", "", "", "", ""]
["", "", "", "2", "", "", "", "", "", "", "", "5", "", "", ""]
["", "3", "", "", "", "", "", "", "", "", "", "", "", "", ""]
["4", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]]

Note: The height of binary tree is in the range of [1, 10].

这道题给了我们一棵二叉树,让我们以数组的形式打印出来。数组每一行的宽度是二叉树的最底层数所能有的最多结点数,存在的结点需要填入到正确的位置上。那么这道题我们就应该首先要确定返回数组的宽度,由于宽度跟数组的深度有关,所以我们首先应该算出二叉树的最大深度,直接写一个子函数返回这个最大深度,从而计算出宽度。下面就是要遍历二叉树从而在数组中加入结点值。我们先来看第一行,由于根结点只有一个,所以第一行只需要插入一个数字,不管这一行多少个位置,我们都是在最中间的位置插入结点值。下面来看第二行,我们仔细观察可以发现,如果我们将这一行分为左右两部分,那么插入的位置还是在每一部分的中间位置,这样我们只要能确定分成的部分的左右边界位置,就知道插入结点的位置了,所以应该是使用分治法的思路。在递归函数中,如果当前node不存在或者当前深度超过了最大深度直接返回,否则就给中间位置赋值为结点值,然后对于左子结点,范围是左边界到中间位置,调用递归函数,注意当前深度加1;同理对于右子结点,范围是中间位置加1到右边界,调用递归函数,注意当前深度加1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
vector<vector<string>> printTree(TreeNode* root) {
int h = getHeight(root), w = pow(2, h) - 1;
vector<vector<string>> res(h, vector<string>(w, ""));
helper(root, 0, w - 1, 0, h, res);
return res;
}
void helper(TreeNode* node, int i, int j, int curH, int height, vector<vector<string>>& res) {
if (!node || curH == height) return;
res[curH][(i + j) / 2] = to_string(node->val);
helper(node->left, i, (i + j) / 2, curH + 1, height, res);
helper(node->right, (i + j) / 2 + 1, j, curH + 1, height, res);
}
int getHeight(TreeNode* node) {
if (!node) return 0;
return 1 + max(getHeight(node->left), getHeight(node->right));
}
};

Leetcode657. Robot Return to Origin

There is a robot starting at position (0, 0), the origin, on a 2D plane. Given a sequence of its moves, judge if this robot ends up at (0, 0) after it completes its moves.

The move sequence is represented by a string, and the character moves[i] represents its ith move. Valid moves are R (right), L (left), U (up), and D (down). If the robot returns to the origin after it finishes all of its moves, return true. Otherwise, return false.

Note: The way that the robot is “facing” is irrelevant. “R” will always make the robot move to the right once, “L” will always make it move left, etc. Also, assume that the magnitude of the robot’s movement is the same for each move.

Example 1:

1
2
3
Input: "UD"
Output: true
Explanation: The robot moves up once, and then down once. All moves have the same magnitude, so it ended up at the origin where it started. Therefore, we return true.

Example 2:

1
2
3
Input: "LL"
Output: false
Explanation: The robot moves left twice. It ends up two "moves" to the left of the origin. We return false because it is not at the origin at the end of its moves.

一个序列,判断‘L’和‘R’是不是个数相等,‘U’和‘D’是不是个数相等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool judgeCircle(string moves) {
int ud=0,lr=0;
for(int i=0;i<moves.length();i++){
if(moves[i]=='U') ud++;
else if(moves[i]=='D') ud--;
else if(moves[i]=='L') lr++;
else if(moves[i]=='R') lr--;
}
if(ud==0 && lr==0)
return true;
else
return false;
}
};

Leetcode658. Find K Closest Elements

Given a sorted array, two integers k and x, find the k closest elements to x in the array. The result should also be sorted in ascending order. If there is a tie, the smaller elements are always preferred.

Example 1:

1
2
Input: [1,2,3,4,5], k=4, x=3
Output: [1,2,3,4]

Example 2:

1
2
Input: [1,2,3,4,5], k=4, x=-1
Output: [1,2,3,4]

Note:

  • The value k is positive and will always be smaller than the length of the sorted array.
  • Length of the given array is positive and will not exceed 104
  • Absolute value of elements in the array and x will not exceed 104

这道题给我们了一个数组,还有两个变量k和x。让找数组中离x最近的k个元素,而且说明了数组是有序的,如果两个数字距离x相等的话,取较小的那个。从给定的例子可以分析出x不一定是数组中的数字,由于数组是有序的,所以最后返回的k个元素也一定是有序的,那么其实就是返回了原数组的一个长度为k的子数组,转化一下,实际上相当于在长度为n的数组中去掉 n-k 个数字,而且去掉的顺序肯定是从两头开始去,因为距离x最远的数字肯定在首尾出现。那么问题就变的明朗了,每次比较首尾两个数字跟x的距离,将距离大的那个数字删除,直到剩余的数组长度为k为止,参见代码如下:

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> findClosestElements(vector<int>& arr, int k, int x) {
int len = arr.size(), l = 0, r = len-1;
while(r - l + 1 > k) {
if (x - arr[l] < arr[r] - x)
r --;
else if (x - arr[l] > arr[r] - x)
l ++;
else {
if (arr[l] < arr[r])
r --;
else
l ++;
}
}
vector<int> res;
for (int i = l; i <= r; i ++)
res.push_back(arr[i]);
return res;
}
};

下面这种解法是论坛上的高分解法,用到了二分搜索法。其实博主最开始用的方法并不是帖子中的这两个方法,虽然也是用的二分搜索法,但博主搜的是第一个不小于x的数,然后同时向左右两个方向遍历,每次取和x距离最小的数加入结果 res 中,直到取满k个为止。但是下面这种方法更加巧妙一些,二分法的判定条件做了一些改变,就可以直接找到要返回的k的数字的子数组的起始位置,感觉非常的神奇。每次比较的是 mid 位置和x的距离跟 mid+k 跟x的距离,以这两者的大小关系来确定二分法折半的方向,最后找到最近距离子数组的起始位置,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
vector<int> findClosestElements(vector<int>& arr, int k, int x) {
int left = 0, right = arr.size() - k;
while (left < right) {
int mid = left + (right - left) / 2;
if (x - arr[mid] > arr[mid + k] - x) left = mid + 1;
else right = mid;
}
return vector<int>(arr.begin() + left, arr.begin() + left + k);
}
};

Leetcode659. Split Array into Consecutive Subsequences

You are given an integer array sorted in ascending order (may contain duplicates), you need to split them into several subsequences, where each subsequences consist of at least 3 consecutive integers. Return whether you can make such a split.

Example 1:

1
2
3
4
5
6
Input: [1,2,3,3,4,5]
Output: True
Explanation:
You can split them into two consecutive subsequences :
1, 2, 3
3, 4, 5

Example 2:

1
2
3
4
5
6
Input: [1,2,3,3,4,4,5,5]
Output: True
Explanation:
You can split them into two consecutive subsequences :
1, 2, 3, 4, 5
3, 4, 5

Example 3:

1
2
Input: [1,2,3,4,4,5]
Output: False

这道题让将数组分割成多个连续递增的子序列,注意这里可能会产生歧义,实际上应该是分割成一个或多个连续递增的子序列,因为 [1,2,3,4,5] 也是正确的解。这道题就用贪婪解法就可以了,使用两个 HashMap,第一个 HashMap 用来建立数字和其出现次数之间的映射 freq,第二个用来建立可以加在某个连续子序列后的数字与其可以出现的次数之间的映射 need。对于第二个 HashMap,举个例子来说,就是假如有个连牌,比如对于数字1,此时检测数字2和3是否存在,若存在的话,表明有连牌 [1,2,3] 存在,由于后面可以加上4,组成更长的连牌,所以不管此时牌里有没有4,都可以建立 4->1 的映射,表明此时需要一个4。这样首先遍历一遍数组,统计每个数字出现的频率,然后开始遍历数组,对于每个遍历到的数字,首先看其当前出现的次数,如果为0,则继续循环;如果 need 中存在这个数字的非0映射,那么表示当前的数字可以加到某个连的末尾,将当前数字在 need 中的映射值自减1,然后将下一个连续数字的映射值加1,因为当 [1,2,3] 连上4后变成 [1,2,3,4] 之后,就可以连上5了,说明此时还需要一个5;如果不能连到其他子序列后面,则来看其是否可以成为新的子序列的起点,可以通过看后面两个数字的映射值是否大于0,都大于0的话,说明可以组成3连儿,于是将后面两个数字的映射值都自减1,还有由于组成了3连儿,在 need 中将末尾的下一位数字的映射值自增1;如果上面情况都不满足,说明该数字是单牌,只能划单儿,直接返回 false。最后别忘了将当前数字的 freq 映射值自减1。退出 for 循环后返回 true,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool isPossible(vector<int>& nums) {
unordered_map<int, int> freq, need;
for (int num : nums) ++freq[num];
for (int num : nums) {
if (freq[num] == 0) continue;
if (need[num] > 0) {
--need[num];
++need[num + 1];
} else if (freq[num + 1] > 0 && freq[num + 2] > 0) {
--freq[num + 1];
--freq[num + 2];
++need[num + 3];
} else return false;
--freq[num];
}
return true;
}
};

Leetcode661. Image Smoother

Given a 2D integer matrix M representing the gray scale of an image, you need to design a smoother to make the gray scale of each cell becomes the average gray scale (rounding down) of all the 8 surrounding cells and itself. If a cell has less than 8 surrounding cells, then use as many as you can.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Input:
[[1,1,1],
[1,0,1],
[1,1,1]]
Output:
[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]
Explanation:
For the point (0,0), (0,2), (2,0), (2,2): floor(3/4) = floor(0.75) = 0
For the point (0,1), (1,0), (1,2), (2,1): floor(5/6) = floor(0.83333333) = 0
For the point (1,1): floor(8/9) = floor(0.88888889) = 0
Note:
The value in the given matrix is in the range of [0, 255].
The length and width of the given matrix are in the range of [1, 150].

假设一个二维整数矩阵M代表图像的灰度,你需要设计一个更平滑的方法,使每个单元格的灰度范围变成所有8个周围单元格的平均灰度(四舍五入)。如果一个单元格的周围单元格少于8个,那么就尽可能多地使用。
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 getval(vector<vector<int>>& M, int ii, int jj) {
int sum = 0;
int count = 0;
for(int i = ii-1; i <= ii+1; i ++)
for(int j = jj-1; j <= jj+1; j ++)
if(i >= 0 && j >= 0 && i < M.size() && j < M[0].size()) {
count ++;
sum += M[i][j];
}
return sum / count;
}

vector<vector<int>> imageSmoother(vector<vector<int>>& M) {
int m = M.size(), n = M[0].size();
vector<vector<int>> res(m, vector(n, 0));
for(int i = 0; i < m; i ++)
for(int j = 0; j < n; j ++)
res[i][j] = getval(M, i, j);
return res;
}
};

Leetcode662. Maximum Width of Binary Tree

Given a binary tree, write a function to get the maximum width of the given tree. The width of a tree is the maximum width among all levels. The binary tree has the same structure as a full binary tree, but some nodes are null.

The width of one level is defined as the length between the end-nodes (the leftmost and right most non-null nodes in the level, where the null nodes between the end-nodes are also counted into the length calculation.

Example 1:

1
2
3
4
5
6
7
8
9
Input: 
1
/ \
3 2
/ \ \
5 3 9

Output: 4
Explanation: The maximum width existing in the third level with the length 4 (5,3,null,9).

Example 2:

1
2
3
4
5
6
7
8
Input: 
1
/
3
/ \
5 3
Output: 2
Explanation: The maximum width existing in the third level with the length 2 (5,3).

Example 3:

1
2
3
4
5
6
7
8
9
Input: 
1
/ \
3 2
/
5

Output: 2
Explanation: The maximum width existing in the second level with the length 2 (3,2).

Example 4:

1
2
3
4
5
6
7
8
9
10
11
Input: 

1
/ \
3 2
/ \
5 9
/ \
6 7
Output: 8
Explanation:The maximum width existing in the fourth level with the length 8 (6,null,null,null,null,null,null,7).

这道题让我们求二叉树的最大宽度,根据题目中的描述可知,这里的最大宽度不是满树的时候的最大宽度,如果是那样的话,肯定是最后一层的结点数最多。这里的最大宽度应该是两个存在的结点中间可容纳的总的结点个数,中间的结点可以为空。那么其实只要我们知道了每一层中最左边和最右边的结点的位置,我们就可以算出这一层的宽度了。所以这道题的关键就是要记录每一层中最左边结点的位置,我们知道对于一棵完美二叉树,如果根结点是深度1,那么每一层的结点数就是2*n-1,那么每个结点的位置就是[1, 2*n-1]中的一个,假设某个结点的位置是i,那么其左右子结点的位置可以直接算出来,为2*i2*i+1。这里使用了队列 queue 来辅助运算,queue 里存的是一个 pair,结点和其当前位置,在进入新一层的循环时,首先要判断该层是否只有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
class Solution {
public:
int widthOfBinaryTree(TreeNode* root) {
if (!root)
return 0;
queue<pair<TreeNode*, int>> q;
q.push({root, 1});
int res = 0;
while(!q.empty()) {
int cnt = q.size();
int left = q.front().second, right = left;
int maxx = q.back().second;
for (int i = 0; i < cnt; i ++) {
TreeNode* tmp = q.front().first;
right = q.front().second;
q.pop();
if (tmp->left) q.push({tmp->left, right*2-maxx});
if (tmp->right) q.push({tmp->right, right*2+1-maxx});
}
res = max(res, right - left + 1);
}
return res;
}
};

Leetcode664. Strange Printer

There is a strange printer with the following two special requirements:

The printer can only print a sequence of the same character each time.
At each turn, the printer can print new characters starting from and ending at any places, and will cover the original existing characters.

Given a string consists of lower English letters only, your job is to count the minimum number of turns the printer needed in order to print it.

Example 1:

1
2
3
Input: "aaabbb"
Output: 2
Explanation: Print "aaa" first and then print "bbb".

Example 2:

1
2
3
Input: "aba"
Output: 2
Explanation: Print "aaa" first and then print "b" from the second place of the string, which will cover the existing character 'a'.

这道题说有一种奇怪的打印机每次只能打印一排相同的字符,然后可以在任意起点和终点位置之间打印新的字符,用来覆盖原有的字符。现在给了我们一个新的字符串,问我们需要几次可以正确的打印出来。题目中给了两个非常简单的例子,主要是帮助我们理解的。博主最开始想的方法是一种类似贪婪算法,先是找出出现次数最多的字符,然后算需要多少次变换能将所有其他字符都变成那个出现最多次的字符,结果fail了。然后又试了一种类似剥洋葱的方法,从首尾都分别找连续相同的字符,如果首尾字符相同,则两部分一起移去,否则就移去连续相同个数多的子序列。

二维dp数组中dp[i][j]表示打印出字符串[i, j]范围内字符的最小步数,难点就是找递推公式啦。遇到乍看去没啥思路的题,博主一般会先从简单的例子开始,看能不能分析出规律,从而找到解题的线索。首先如果只有一个字符,比如字符串是”a”的话,那么直接一次打印出来就行了。如果字符串是”ab”的话,那么我们要么先打印出”aa”,再改成”ab”,或者先打印出”bb”,再改成”ab”。同理,如果字符串是”abc”的话,就需要三次打印。那么一个很明显的特征是,如果没有重复的字符,打印的次数就是字符的个数。燃鹅这题的难点就是要处理有相同字符的情况,比如字符串是”aba”的时候,我们先打”aaa”的话,两步就搞定了,如果先打”bbb”的话,就需要三步。我们再来看一个字符串”abcb”,我们知道需要需要三步,我们看如果把这个字符串分成两个部分”a”和”bcb”,它们分别的步数是1和2,加起来的3是整个的步数。而对于字符串”abba”,如果分成”a”和”bba”,它们分别的步数也是1和2,但是总步数却是2。这是因为分出的”a”和”bba”中的最后一个字符相同。对于字符串”abbac”,因为位置0上的a和位置3上的a相同,那么整个字符串的步数相当于”bb”和”ac”的步数之和,为3。那么分析到这,是不是有点眉目了?我们关心的是字符相等的地方,对于[i, j]范围的字符,我们从i+1位置上的字符开始遍历到j,如果和i位置上的字符相等,我们就以此位置为界,将[i+1, j]范围内的字符拆为两个部分,将二者的dp值加起来,和原dp值相比,取较小的那个。所以我们的递推式如下:

1
dp[i][j] = min(dp[i][j], dp[i + 1][k - 1] + dp[k][j]       (s[k] == s[i] and i + 1 <= k <= j)

要注意一些初始化的值,dp[i][i]是1,因为一个字符嘛,打印1次,还是就是在遍历k之前,dp[i][j]初始化为 1 + dp[i + 1][j],为啥呢,可以看成在[i + 1, j]的范围上多加了一个s[i]字符,最坏的情况就是加上的是一个不曾出现过的字符,步数顶多加1步,注意我们的i是从后往前遍历的,当然你可以从前往后遍历,参数对应好就行了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int strangePrinter(string s) {
int n = s.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int i = n - 1; i >= 0; --i) {
for (int j = i; j < n; ++j) {
dp[i][j] = (i == j) ? 1 : (1 + dp[i + 1][j]);
for (int k = i + 1; k <= j; ++k) {
if (s[k] == s[i]) dp[i][j] = min(dp[i][j], dp[i + 1][k - 1] + dp[k][j]);
}
}
}
return (n == 0) ? 0 : dp[0][n - 1];
}
};

理解了上面的DP的方法,那么也可以用递归的形式来写,记忆数组memo就相当于dp数组,整个思路完全一样,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int strangePrinter(string s) {
int n = s.size();
vector<vector<int>> memo(n, vector<int>(n, 0));
return helper(s, 0, n - 1, memo);
}
int helper(string s, int i, int j, vector<vector<int>>& memo) {
if (i > j) return 0;
if (memo[i][j]) return memo[i][j];
memo[i][j] = helper(s, i + 1, j, memo) + 1;
for (int k = i + 1; k <= j; ++k) {
if (s[k] == s[i]) {
memo[i][j] = min(memo[i][j], helper(s, i + 1, k - 1, memo) + helper(s, k, j, memo));
}
}
return memo[i][j];
}
};

Leetcode665. Non-decreasing Array

Given an array nums with n integers, your task is to check if it could become non-decreasing by modifying at most 1 element.

We define an array is non-decreasing if nums[i] <= nums[i + 1] holds for every i (0-based) such that (0 <= i <= n - 2).

Example 1:

1
2
3
Input: nums = [4,2,3]
Output: true
Explanation: You could modify the first 4 to 1 to get a non-decreasing array.

Example 2:
1
2
3
Input: nums = [4,2,1]
Output: false
Explanation: You can't get a non-decreasing array by modify at most one element.

这道题给了我们一个数组,说我们最多有1次修改某个数字的机会,问能不能将数组变为非递减数组。题目中给的例子太少,不能覆盖所有情况,我们再来看下面三个例子:

  • 4,2,3
  • -1,4,2,3
  • 2,3,3,2,4

我们通过分析上面三个例子可以发现,当我们发现后面的数字小于前面的数字产生冲突后,有时候需要修改前面较大的数字(比如前两个例子需要修改4),有时候却要修改后面较小的那个数字(比如前第三个例子需要修改2),那么有什么内在规律吗?是有的,判断修改那个数字其实跟再前面一个数的大小有关系,首先如果再前面的数不存在,比如例子1,4前面没有数字了,我们直接修改前面的数字为当前的数字2即可。而当再前面的数字存在,并且小于当前数时,比如例子2,-1小于2,我们还是需要修改前面的数字4为当前数字2;如果再前面的数大于当前数,比如例子3,3大于2,我们需要修改当前数2为前面的数3。这是修改的情况,由于我们只有一次修改的机会,所以用一个变量cnt,初始化为1,修改数字后cnt自减1,当下次再需要修改时,如果cnt已经为0了,直接返回false。遍历结束后返回true,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool checkPossibility(vector<int>& nums) {
bool res = true;
int count = 1;
for(int i = 1; i < nums.size(); i ++)
if(nums[i-1] > nums[i]) {
if(!count) {
return false;
}

if (i == 1 || nums[i] >= nums[i - 2])
nums[i-1] = nums[i];
else
nums[i] = nums[i-1];
count --;
}
return true;
}
};

Leetcode667. Beautiful Arrangement II

Given two integers n and k, you need to construct a list which contains n different positive integers ranging from 1 to n and obeys the following requirement:

Suppose this list is [a1, a2, a3, … , an], then the list [|a1 - a2|, |a2 - a3|, |a3 - a4|, … , |an-1 - an|] has exactly k distinct integers.

If there are multiple answers, print any of them.

Example 1:

1
2
3
Input: n = 3, k = 1
Output: [1, 2, 3]
Explanation: The [1, 2, 3] has three different positive integers ranging from 1 to 3, and the [1, 1] has exactly 1 distinct integer: 1.

Example 2:

1
2
3
Input: n = 3, k = 2
Output: [1, 3, 2]
Explanation: The [1, 3, 2] has three different positive integers ranging from 1 to 3, and the [2, 1] has exactly 2 distinct integers: 1 and 2.

Note:

  • The n and k are in the range 1 <= k < n <= 104.

这道题给我们了一个数字n和一个数字k,让找出一种排列方式,使得1到n组成的数组中相邻两个数的差的绝对值正好有k种。给了k和n的关系为k<n。那么我们首先来考虑,是否这种条件关系下,是否已定存在这种优美排列呢,我们用一个例子来分析,比如说当n=8,我们有数组:

1, 2, 3, 4, 5, 6, 7, 8

当我们这样有序排列的话,相邻两数的差的绝对值为1。我们想差的绝对值最大能为多少,应该是把1和8放到一起,为7。那么为了尽可能的产生不同的差的绝对值,我们在8后面需要放一个小数字,比如2,这样会产生差的绝对值6,同理,后面再跟一个大数,比如7,产生差的绝对值5,以此类推,我们得到下列数组:

1, 8, 2, 7, 3, 6, 4, 5

其差的绝对值为:7,6,5,4,3,2,1

共有7种,所以我们知道k最大为n-1,所以这样的排列一定会存在。我们的策略是,先按照这种最小最大数相邻的方法排列,每排一个,k自减1,当k减到1的时候,后面的排列方法只要按照生序的方法排列,就不会产生不同的差的绝对值,这种算法的时间复杂度是O(n),属于比较高效的那种。我们使用两个指针,初始时分别指向1和n,然后分别从i和j取数加入结果res,每取一个数字k自减1,直到k减到1的时候,开始按升序取后面的数字,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
vector<int> constructArray(int n, int k) {
vector<int> res;
int i = 1, j = n;
while (i <= j) {
if (k > 1) res.push_back(k-- % 2 ? i++ : j--);
else res.push_back(i++);
}
return res;
}
};

Leetcode669. Trim a Binary Search Tree

Given a binary search tree and the lowest and highest boundaries as L and R, trim the tree so that all its elements lies in [L, R] (R >= L). You might need to change the root of the tree, so the result should return the new root of the trimmed binary search tree.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
Input: 
1
/ \
0 2

L = 1
R = 2

Output:
1
\
2

Example 2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Input: 
3
/ \
0 4
\
2
/
1

L = 1
R = 3

Output:
3
/
2
/
1

修剪一棵二叉搜索树,给定一个区间[L, R],剪掉value值不在该区间内的节点。由于这是一棵二次搜索树,它或者是一棵空树,或者是具有下列性质的二叉树:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉排序树。

所以会出现以下三种情况:

  • 当前节点的值 < L,当前节点的左子树的值都是小于当前节点的值,所以都要减掉,我们只需要继续修剪当前节点的右子树。
  • 当前节点的值 > R,当前节点的右子树的值都是大于当前节点的值,所以也要全部减掉,我们只需要继续修剪当前节点的左子树。
  • L <= 当前节点的值 <= R,需要修剪他的左子树和右子树。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int L, int R) {
if(!root)
return root;
if(root->val < L)
return trimBST(root->right, L, R);
if(root->val > R)
return trimBST(root->left, L, R);
root->left = trimBST(root->left, L, R);
root->right = trimBST(root->right, L, R);
return root;
}
};

Leetcode670. Maximum Swap

You are given an integer num. You can swap two digits at most once to get the maximum valued number.

Return the maximum valued number you can get.

Example 1:

1
2
3
Input: num = 2736
Output: 7236
Explanation: Swap the number 2 and the number 7.

Example 2:

1
2
3
Input: num = 9973
Output: 9973
Explanation: No swap.

这道题给了一个数字,我们有一次机会可以置换该数字中的任意两位,让返回置换后的最大值,当然如果当前数字就是最大值,也可以选择不置换,直接返回原数。由于希望置换后的数字最大,那么肯定最好的高位上的小数字和低位上的大数字进行置换,比如例子1。而如果高位上的都是大数字,像例子2那样,很有可能就不需要置换。所以需要找到每个数字右边的最大数字(包括其自身),这样再从高位像低位遍历,如果某一位上的数字小于其右边的最大数字,说明需要调换,由于最大数字可能不止出现一次,这里希望能跟较低位的数字置换,这样置换后的数字最大,所以就从低位向高位遍历来找那个最大的数字,找到后进行调换即可。比如对于 1993 这个数:

1 9 9 3

9 9 1 3

我们建立好数组后,从头遍历原数字,发现1比9小,于是从末尾往前找9,找到后一置换,就得到了 9913。

1
2
3
4
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 maximumSwap(int num) {
vector<int> r;
int o_num = num;
while(num > 0) {
r.push_back(num%10);
num /= 10;
}
int len = r.size(), i, j;
vector<int> max_num(len, -1);
for (i = len-1; i >= 0; i --) {
for (j = i; j >= 0; j --) {
max_num[i] = max(max_num[i], r[j]);
}
}

for (i = len-1; i >= 0; i --) {
if (max_num[i] > r[i])
break;
}
if (i == -1)
return o_num;
for (j = 0; j < len; j ++ )
if (r[j] == max_num[i])
break;
swap(r[i], r[j]);
int res = 0;
for (i = len-1; i >= 0; i --)
res = res * 10 + r[i];
return res;
}
};

Leetcode671. Second Minimum Node In a Binary Tree

Given a non-empty special binary tree consisting of nodes with the non-negative value, where each node in this tree has exactly two or zero sub-node. If the node has two sub-nodes, then this node’s value is the smaller value among its two sub-nodes. More formally, the property root.val = min(root.left.val, root.right.val) always holds.

Given such a binary tree, you need to output the second minimum value in the set made of all the nodes’ value in the whole tree.

If no such second minimum value exists, output -1 instead.

Example 1:

1
2
3
4
5
6
7
8
9
Input: 
2
/ \
2 5
/ \
5 7

Output: 5
Explanation: The smallest value is 2, the second smallest value is 5.

Example 2:
1
2
3
4
5
6
7
Input: 
2
/ \
2 2

Output: -1
Explanation: The smallest value is 2, but there isn't any second smallest value.

本来想用两个最小值来记录,但是很麻烦,还是先遍历再用set去重吧
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:
int findSecondMinimumValue(TreeNode* root) {
set<int> s;
queue<TreeNode*> q;
q.push(root);
while(!q.empty()) {
TreeNode* temp = q.front();
q.pop();
s.insert(temp->val);
if(temp->left)
q.push(temp->left);
if(temp->right)
q.push(temp->right);
}
auto it = s.begin();
it++;
if(it == s.end())
return -1;
return *it;
}
};

Leetcode672. Bulb Switcher II

There is a room with n lights which are turned on initially and 4 buttons on the wall. After performing exactly m unknown operations towards buttons, you need to return how many different kinds of status of the n lights could be.

Suppose n lights are labeled as number [1, 2, 3 …, n], function of these 4 buttons are given below:

  • Flip all the lights.
  • Flip lights with even numbers.
  • Flip lights with odd numbers.
  • Flip lights with (3k + 1) numbers, k = 0, 1, 2, …

Example 1:

1
2
3
Input: n = 1, m = 1.
Output: 2
Explanation: Status can be: [on], [off]

Example 2:

1
2
3
Input: n = 2, m = 1.
Output: 3
Explanation: Status can be: [on, off], [off, on], [off, off]

Example 3:

1
2
3
Input: n = 3, m = 1.
Output: 4
Explanation: Status can be: [off, on, off], [on, off, on], [off, off, off], [off, on, on].

Note: n and m both fit in range [0, 1000].

这道题是之前那道Bulb Switcher的拓展,但是关灯的方式改变了。现在有四种关灯方法,全关,关偶数灯,关奇数灯,关3k+1的灯。现在给我们n盏灯,允许m步操作,问我们总共能组成多少种不同的状态。博主开始想,题目没有让列出所有的情况,而只是让返回总个数。那么博主觉得应该不能用递归的暴力破解来做,一般都是用DP来做啊。可是想了半天也没想出递推公式,只得作罢。只好去参考大神们的做法,发现这道题的结果并不会是一个超大数,最多情况只有8种。转念一想,也是,如果结果是一个超大数,一般都会对一个超大数10e7来取余,而这道题并没有,所以是一个很大的hint,只不过博主没有get到。博主应该多列几种情况的,说不定就能找出规律。下面先来看一种暴力解法,首先我们先做一个小小的优化,我们来分析四种情况:

第一种情况: 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 14 , 15 ,…

第二种情况:1, 2 ,3, 4 ,5, 6 ,7, 8 ,9, 10 ,11, 12 ,13, 14 ,15,…

第三种情况: 1 ,2, 3 ,4, 5 ,6, 7 ,8, 9 ,10, 11 ,12, 13 ,14, 15 ,…

第四种情况: 1 ,2,3, 4 ,5,6, 7 ,8,9, 10 ,11,12, 13 ,14,15,…

通过观察上面的数组,我们可以发现以6个为1组,都是重复的pattern,那么实际上我们可以把重复的pattern去掉而且并不会影响结果。如果n大于6,我们则对其取余再加上6,新的n跟使用原来的n会得到同样的结果,但这样降低了我们的计算量。

下面我们先来生成n个1,这里1表示灯亮,0表示灯灭,然后我们需要一个set来记录已经存在的状态,用一个queue来辅助我们的BFS运算。我们需要循环m次,因为要操作m次,每次开始循环之前,先统计出此时queue中数字的个数len,然后进行len次循环,这就像二叉树中的层序遍历,必须上一层的结点全部遍历完了才能进入下一层,当然,在每一层开始前,我们都需要情况集合s,这样每个操作之间才不会互相干扰。然后在每层的数字循环中,我们取出队首状态,然后分别调用四种方法,突然感觉,这很像迷宫遍历问题,上下左右四个方向,周围四个状态算出来,我们将不再集合set中的状态加入queue和集合set。当m次操作遍历完成后,队列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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Solution {
public:
int flipLights(int n, int m) {
n = (n <= 6) ? n : (n % 6 + 6);
int start = (1 << n) - 1;
unordered_set<int> s;
queue<int> q{{start}};
for (int i = 0; i < m; ++i) {
int len = q.size();
s.clear();
for (int k = 0; k < len; ++k) {
int t = q.front(); q.pop();
vector<int> next{flipAll(t, n), flipEven(t, n), flipOdd(t, n), flip3k1(t, n)};
for (int num : next) {
if (s.count(num)) continue;
q.push(num);
s.insert(num);
}
}
}
return q.size();
}

int flipAll(int t, int n) {
int x = (1 << n) - 1;
return t ^ x;
}

int flipEven(int t, int n) {
for (int i = 0; i < n; i += 2) {
t ^= (1 << i);
}
return t;
}

int flipOdd(int t, int n) {
for (int i = 1; i < n; i += 2) {
t ^= (1 << i);
}
return t;
}

int flip3k1(int t, int n) {
for (int i = 0; i < n; i += 3) {
t ^= (1 << i);
}
return t;
}
};

上面那个方法虽然正确,但是有些复杂了,由于这道题最多只有8中情况,所以很适合分情况来讨论:

  • 当m和n其中有任意一个数是0时,返回1
  • 当n = 1时只有两种情况,0和1
  • 当n = 2时,这时候要看m的次数,如果m = 1,那么有三种状态 00,01,10
  • 当m > 1时,那么有四种状态,00,01,10,11
  • 当m = 1时,此时n至少为3,那么我们有四种状态,000,010,101,011
  • 当m = 2时,此时n至少为3,我们有七种状态:111,101,010,100,000,001,110
  • 当m > 2时,此时n至少为3,我们有八种状态:111,101,010,100,000,001,110,011
1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int flipLights(int n, int m) {
if (n == 0 || m == 0) return 1;
if (n == 1) return 2;
if (n == 2) return m == 1 ? 3 : 4;
if (m == 1) return 4;
return m == 2 ? 7 : 8;
}
};

Leetcode673. Number of Longest Increasing Subsequence

Given an unsorted array of integers, find the number of longest increasing subsequence.

Example 1:

1
2
3
Input: [1,3,5,4,7]
Output: 2
Explanation: The two longest increasing subsequence are [1, 3, 4, 7] and [1, 3, 5, 7].

Example 2:

1
2
3
Input: [2,2,2,2,2]
Output: 5
Explanation: The length of longest continuous increasing subsequence is 1, and there are 5 subsequences' length is 1, so output 5.

Note: Length of the given array will be not exceed 2000 and the answer is guaranteed to be fit in 32-bit signed int.

这道题给了我们一个数组,让求最长递增序列的个数。这里用len[i]表示以nums[i]为结尾的递推序列的长度,用cnt[i]表示以nums[i]为结尾的递推序列的个数,初始化都赋值为1,只要有数字,那么至少都是1。然后遍历数组,对于每个遍历到的数字nums[i],再遍历其之前的所有数字nums[j],当nums[i]小于等于nums[j]时,不做任何处理,因为不是递增序列。反之,则判断len[i]len[j]的关系,如果len[i]等于len[j] + 1,说明nums[i]这个数字可以加在以nums[j]结尾的递增序列后面,并且以nums[j]结尾的递增序列个数可以直接加到以nums[i]结尾的递增序列个数上。如果len[i]小于len[j] + 1,说明找到了一条长度更长的递增序列,那么此时将len[i]更新为len[j]+1,并且原本的递增序列都不能用了,直接用cnt[j]来代替。在更新完len[i]cnt[i]之后,要更新 mx 和结果 res,如果 mx 等于 len[i],则把cnt[i]加到结果 res 之上;如果 mx 小于len[i],则更新 mx 为len[i],更新结果 res 为cnt[i],参见代码如下:

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 findNumberOfLIS(vector<int>& nums) {
int res = 0, mx = 0, n = nums.size();
vector<int> len(n, 1), cnt(n, 1);
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (nums[i] <= nums[j]) continue;
if (len[i] == len[j] + 1) cnt[i] += cnt[j];
else if (len[i] < len[j] + 1) {
len[i] = len[j] + 1;
cnt[i] = cnt[j];
}
}
if (mx == len[i]) res += cnt[i];
else if (mx < len[i]) {
mx = len[i];
res = cnt[i];
}
}
return res;
}
};

Leetcode674. Longest Continuous Increasing Subsequence

Given an unsorted array of integers, find the length of longest continuous increasing subsequence (subarray).

Example 1:

1
2
3
4
Input: [1,3,5,4,7]
Output: 3
Explanation: The longest continuous increasing subsequence is [1,3,5], its length is 3.
Even though [1,3,5,7] is also an increasing subsequence, it's not a continuous one where 5 and 7 are separated by 4.

Example 2:
1
2
3
Input: [2,2,2,2,2]
Output: 1
Explanation: The longest continuous increasing subsequence is [2], its length is 1.

找一个最长递增子串
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int findLengthOfLCIS(vector<int>& nums) {
if(nums.size() == 0)
return 0;
int res = 0, cur = 1;
for(int i = 1; i < nums.size(); i ++) {
if(nums[i] > nums[i-1])
cur ++;
else {
res = max(res, cur);
cur = 1;
}
}
return max(res, cur);
}
};

Leetcode676. Implement Magic Dictionary

Implement a magic directory with buildDict, and search methods.

For the method buildDict, you’ll be given a list of non-repetitive words to build a dictionary.

For the method search, you’ll be given a word, and judge whether if you modify exactly one character into another character in this word, the modified word is in the dictionary you just built.

Example 1:

1
2
3
4
5
Input: buildDict(["hello", "leetcode"]), Output: Null
Input: search("hello"), Output: False
Input: search("hhllo"), Output: True
Input: search("hell"), Output: False
Input: search("leetcoded"), Output: False

这道题让我们设计一种神奇字典的数据结构,里面有一些单词,实现的功能是当我们搜索一个单词,只有存在和这个单词只有一个位置上的字符不相同的才能返回true,否则就返回false,注意完全相同也是返回false,必须要有一个字符不同。博主首先想到了One Edit Distance那道题,只不过这道题的两个单词之间长度必须相等。所以只需检测和要搜索单词长度一样的单词即可,所以我们用的数据结构就是根据单词的长度来分,把长度相同相同的单词放到一起,这样就可以减少搜索量。那么对于和要搜索单词进行比较的单词,由于已经保证了长度相等,我们直接进行逐个字符比较即可,用cnt表示不同字符的个数,初始化为0。如果当前遍历到的字符相等,则continue;如果当前遍历到的字符不相同,并且此时cnt已经为1了,则break,否则cnt就自增1。退出循环后,我们检测是否所有字符都比较完了且cnt为1,是的话则返回true,否则就是跟下一个词比较。如果所有词都比较完了,则返回false,参见代码如下:

1
2
3
4
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 MagicDictionary {
public:
/** Initialize your data structure here. */
MagicDictionary() {}

/** Build a dictionary through a list of words */
void buildDict(vector<string> dict) {
for (string word : dict) {
m[word.size()].push_back(word);
}
}

/** Returns if there is any word in the trie that equals to the given word after modifying exactly one character */
bool search(string word) {
for (string str : m[word.size()]) {
int cnt = 0, i = 0;
for (; i < word.size(); ++i) {
if (word[i] == str[i]) continue;
if (word[i] != str[i] && cnt == 1) break;
++cnt;
}
if (i == word.size() && cnt == 1) return true;
}
return false;
}

private:
unordered_map<int, vector<string>> m;
};

下面这种解法实际上是用到了前缀树中的search的思路,但是我们又没有整个用到prefix tree,博主感觉那样写法略复杂,其实我们只需要借鉴一下search方法就行了。我们首先将所有的单词都放到一个集合中,然后在search函数中,我们遍历要搜索的单词的每个字符,然后把每个字符都用a-z中的字符替换一下,形成一个新词,当然遇到本身要跳过。然后在集合中看是否存在,存在的话就返回true。记得换完一圈字符后要换回去,不然就不满足只改变一个字符的条件了,参见代码如下:

1
2
3
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 MagicDictionary {
public:
/** Initialize your data structure here. */
MagicDictionary() {}

/** Build a dictionary through a list of words */
void buildDict(vector<string> dict) {
for (string word : dict) s.insert(word);
}

/** Returns if there is any word in the trie that equals to the given word after modifying exactly one character */
bool search(string word) {
for (int i = 0; i < word.size(); ++i) {
char t = word[i];
for (char c = 'a'; c <= 'z'; ++c) {
if (c == t) continue;
word[i] = c;
if (s.count(word)) return true;
}
word[i] = t;
}
return false;
}

private:
unordered_set<string> s;
};

Leetcode677. Map Sum Pairs

Implement a MapSum class with insert, and sum methods.

For the method insert, you’ll be given a pair of (string, integer). The string represents the key and the integer represents the value. If the key already existed, then the original key-value pair will be overridden to the new one.

For the method sum, you’ll be given a string representing the prefix, and you need to return the sum of all the pairs’ value whose key starts with the prefix.

Example 1:

1
2
3
4
Input: insert("apple", 3), Output: Null
Input: sum("ap"), Output: 3
Input: insert("app", 2), Output: Null
Input: sum("ap"), Output: 5

这道题让我们实现一个MapSum类,里面有两个方法,insert和sum,其中inser就是插入一个键值对,而sum方法比较特别,是在找一个前缀,需要将所有有此前缀的单词的值累加起来返回。使用map来代替前缀树啦。博主开始想到的方法是建立前缀和一个pair之间的映射,这里的pair的第一个值表示该词的值,第二个值表示将该词作为前缀的所有词的累加值,那么我们的sum函数就异常的简单了,直接将pair中的两个值相加即可。关键就是要在insert中把数据结构建好,构建的方法也不难,首先我们suppose原本这个key是有值的,我们更新的时候只需要加上它的差值即可,就算key不存在默认就是0,算差值也没问题。然后我们将first值更新为val,然后就是遍历其所有的前缀了,给每个前缀的second都加上diff即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MapSum {
public:
/** Initialize your data structure here. */
MapSum() {}

void insert(string key, int val) {
int diff = val - m[key].first, n = key.size();
m[key].first = val;
for (int i = n - 1; i > 0; --i) {
m[key.substr(0, i)].second += diff;
}
}

int sum(string prefix) {
return m[prefix].first + m[prefix].second;
}

private:
unordered_map<string, pair<int, int>> m;
};

下面这种方法用的是带排序的map,insert就是把单词加入map。在map里会按照字母顺序自动排序,然后在sum函数里,我们根据prefix来用二分查找快速定位到第一个不小于prefix的位置,然后向后遍历,向后遍历的都是以prefix为前缀的单词,如果我们发现某个单词不是以prefix为前缀了,直接break;否则就累加其val值,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class MapSum {
public:
/** Initialize your data structure here. */
MapSum() {}

void insert(string key, int val) {
m[key] = val;
}

int sum(string prefix) {
int res = 0, n = prefix.size();
for (auto it = m.lower_bound(prefix); it != m.end(); ++it) {
if (it->first.substr(0, n) != prefix) break;
res += it->second;
}
return res;
}

private:
map<string, int> m;
};

自己写的基于字典树的:

1
2
3
4
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
class Trie {
public:
Trie* child[26];
bool isword;
int number;
Trie() {
for (int i = 0; i < 26; i ++)
child[i] = NULL;
isword = false;
number = 0;
}
};
class MapSum {
public:
Trie * root;
/** Initialize your data structure here. */
MapSum() {
root = new Trie();
}

void insert(string key, int val) {
Trie *cur = root;
for (char c : key) {
if (cur->child[c-'a'] == NULL)
cur->child[c-'a'] = new Trie();
cur = cur->child[c-'a'];
}
cur->isword = true;
cur->number = val;
}

int sum(string prefix) {
Trie *cur = root;
int sum = 0;
for (char c : prefix) {
cur = cur->child[c-'a'];
if (!cur)
break;
}
queue<Trie*> q;
if (cur)
q.push(cur);
while(!q.empty()) {
Trie* t = q.front();
q.pop();
if (t->number != 0)
sum += t->number;

for (int i = 0; i < 26; i ++) {
if (t->child[i])
q.push(t->child[i]);
}

}
return sum;
}
};

Leetcode678. Valid Parenthesis String

Given a string containing only three types of characters: ‘(‘, ‘)’ and ‘*’, write a function to check whether this string is valid. We define the validity of a string by these rules:

  • Any left parenthesis ‘(‘ must have a corresponding right parenthesis ‘)’.
  • Any right parenthesis ‘)’ must have a corresponding left parenthesis ‘(‘.
  • Left parenthesis ‘(‘ must go before the corresponding right parenthesis ‘)’.
  • ‘*’ could be treated as a single right parenthesis ‘)’ or a single left parenthesis ‘(‘ or an empty string.
  • An empty string is also valid.

Example 1:

1
2
Input: "()"
Output: True

Example 2:

1
2
Input: "(*)"
Output: True

Example 3:

1
2
Input: "(*))"
Output: True

Note: The string size will be in the range [1, 100].

这道题让我们验证括号字符串,由于星号的存在,这道题就变的复杂了,由于星号可以当括号用,所以当遇到右括号时,就算此时变量为0,也可以用星号来当左括号使用。那么星号什么时候都能当括号来用吗,我们来看两个例子*)(,在第一种情况下,星号可以当左括号来用,而在第二种情况下,无论星号当左括号,右括号,还是空,(都是不对的。当然这种情况只限于星号和左括号之间的位置关系,而只要星号在右括号前面,就一定可以消掉右括号。那么我们使用两个stack,分别存放左括号和星号的位置,遍历字符串,当遇到星号时,压入星号栈star,当遇到左括号时,压入左括号栈left,当遇到右括号时,此时如果left和star均为空时,直接返回false;如果left不为空,则pop一个左括号来抵消当前的右括号;否则从star中取出一个星号当作左括号来抵消右括号。当循环结束后,我们希望left中没有多余的左括号,就算有,我们可以尝试着用星号来抵消,当star和left均不为空时,进行循环,如果left的栈顶左括号的位置在star的栈顶星号的右边,那么就组成了*(模式,直接返回false;否则就说明星号可以抵消左括号,各自pop一个元素。最终退出循环后我们看left中是否还有多余的左括号,没有就返回true,否则false,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool checkValidString(string s) {
stack<int> left, star;
for (int i = 0; i < s.size(); ++i) {
if (s[i] == '*') star.push(i);
else if (s[i] == '(') left.push(i);
else {
if (left.empty() && star.empty()) return false;
if (!left.empty()) left.pop();
else star.pop();
}
}
while (!left.empty() && !star.empty()) {
if (left.top() > star.top()) return false;
left.pop(); star.pop();
}
return left.empty();
}
};

既然星号可以当左括号和右括号,那么我们就正反各遍历一次,正向遍历的时候,我们把星号都当成左括号,此时用经典的验证括号的方法,即遇左括号计数器加1,遇右括号则自减1,如果中间某个时刻计数器小于0了,直接返回false。如果最终计数器等于0了,我们直接返回true,因为此时我们把星号都当作了左括号,可以跟所有的右括号抵消。而此时就算计数器大于0了,我们暂时不能返回false,因为有可能多余的左括号是星号变的,星号也可以表示空,所以有可能不多,我们还需要反向q一下,哦不,是反向遍历一下,这是我们将所有的星号当作右括号,遇右括号计数器加1,遇左括号则自减1,如果中间某个时刻计数器小于0了,直接返回false。遍历结束后直接返回true,这是为啥呢?此时计数器有两种情况,要么为0,要么大于0。为0不用说,肯定是true,为啥大于0也是true呢?因为之前正向遍历的时候,我们的左括号多了,我们之前说过了,多余的左括号可能是星号变的,也可能是本身就多的左括号。本身就多的左括号这种情况会在反向遍历时被检测出来,如果没有检测出来,说明多余的左括号一定是星号变的。而这些星号在反向遍历时又变做了右括号,最终导致了右括号有剩余,所以当这些星号都当作空的时候,左右括号都是对应的,即是合法的。你可能会有疑问,右括号本身不会多么,其实不会的,如果多的话,会在正向遍历中被检测出来,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
bool checkValidString(string s) {
int left = 0, right = 0, n = s.size();
for (int i = 0; i < n; ++i) {
if (s[i] == '(' || s[i] == '*') ++left;
else --left;
if (left < 0) return false;
}
if (left == 0) return true;
for (int i = n - 1; i >= 0; --i) {
if (s[i] == ')' || s[i] == '*') ++right;
else --right;
if (right < 0) return false;
}
return true;
}
};

下面这种方法是用递归来写的,思路特别的简单直接,感觉应该属于暴力破解法。使用了变量cnt来记录左括号的个数,变量start表示当前开始遍历的位置,那么在递归函数中,首先判断如果cnt小于0,直接返回false。否则进行从start开始的遍历,如果当前字符为左括号,cnt自增1;如果为右括号,若cnt此时小于等于0,返回false,否则cnt自减1;如果为星号,我们同时递归三种情况,分别是当星号为空,左括号,或右括号,只要有一种情况返回true,那么就是true了。如果循环退出后,若cnt为0,返回true,否则false,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool checkValidString(string s) {
return helper(s, 0, 0);
}
bool helper(string s, int start, int cnt) {
if (cnt < 0) return false;
for (int i = start; i < s.size(); ++i) {
if (s[i] == '(') {
++cnt;
} else if (s[i] == ')') {
if (cnt <= 0) return false;
--cnt;
} else {
return helper(s, i + 1, cnt) || helper(s, i + 1, cnt + 1) || helper(s, i + 1, cnt - 1);
}
}
return cnt == 0;
}
};

Leetcode679. 24 Game

You have 4 cards each containing a number from 1 to 9. You need to judge whether they could operated through *, /, +, -, (, )to get the value of 24.

Example 1:

1
2
3
Input: [4, 1, 8, 7]
Output: True
Explanation: (8-4) * (7-1) = 24

Example 2:

1
2
Input: [1, 2, 1, 2]
Output: False

Note:

  • The division operator / represents real division, not integer division. For example, 4 / (1 - 2/3) = 12.
  • Every operation done is between two numbers. In particular, we cannot use - as a unary operator. For example, with [1, 1, 1, 1] as input, the expression -1 - 1 - 1 - 1 is not allowed.
  • You cannot concatenate numbers together. For example, if the input is [1, 2, 1, 2], we cannot write this as 12 + 12.

这道题就是经典的24点游戏了,记得小时候经常玩这个游戏,就是每个人发四张牌,看谁最快能算出24,这完全是脑力大比拼啊,不是拼的牌技。玩的多了,就会摸出一些套路来,比如尽量去凑2和12,3和8,4和6等等,但是对于一些特殊的case,比如 [1, 5, 5, 5] 这种,正确的解法是 5 * (5 - 1 / 5),一般人都会去试加减乘,和能整除的除法,而像这种带小数的确实很难想到,但是程序计算就没问题,可以遍历所有的情况,这也是这道题的实际意义所在吧。那么既然是要遍历所有的情况,我们应该隐约感觉到应该是要使用递归来做的。我们想,任意的两个数字之间都可能进行加减乘除,其中加法和乘法对于两个数字的前后顺序没有影响,但是减法和除法是有影响的,而且做除法的时候还要另外保证除数不能为零。我们要遍历任意两个数字,然后对于这两个数字,尝试各种加减乘除后得到一个新数字,将这个新数字加到原数组中,记得原来的两个数要移除掉,然后调用递归函数进行计算,我们可以发现每次调用递归函数后,数组都减少一个数字,那么当减少到只剩一个数字了,就是最后的计算结果,所以我们在递归函数开始时判断,如果数组只有一个数字,且为24,说明可以算出24,结果res赋值为true返回。这里我们的结果res是一个全局的变量,如果已经为true了,就没必要再进行运算了,所以第一行应该是判断结果res,为true就直接返回了。我们遍历任意两个数字,分别用p和q来取出,然后进行两者的各种加减乘除的运算,将结果保存进数组临时数组t,记得要判断除数不为零。然后将原数组nums中的p和q移除,遍历临时数组t中的数字,将其加入数组nums,然后调用递归函数,记得完成后要移除数字,恢复状态,这是递归解法很重要的一点。最后还要把p和q再加回原数组nums,这也是还原状态,参见代码如下:

解法一:

1
2
3
4
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:
bool judgePoint24(vector<int>& nums) {
bool res = false;
double eps = 0.001;
vector<double> arr(nums.begin(), nums.end());
helper(arr, eps, res);
return res;
}
void helper(vector<double>& nums, double eps, bool& res) {
if (res) return;
if (nums.size() == 1) {
if (abs(nums[0] - 24) < eps) res = true;
return;
}
for (int i = 0; i < nums.size(); ++i) {
for (int j = 0; j < i; ++j) {
double p = nums[i], q = nums[j];
vector<double> t{p + q, p - q, q - p, p * q};
if (p > eps) t.push_back(q / p);
if (q > eps) t.push_back(p / q);
nums.erase(nums.begin() + i);
nums.erase(nums.begin() + j);
for (double d : t) {
nums.push_back(d);
helper(nums, eps, res);
nums.pop_back();
}
nums.insert(nums.begin() + j, q);
nums.insert(nums.begin() + i, p);
}
}
}
};

来看一种很不同的递归写法,这里将加减乘除操作符放到了一个数组ops中。并且没有用全局变量res,而是让递归函数带有bool型返回值。在递归函数中,还是要先看nums数组的长度,如果为1了,说明已经计算完成,直接看结果是否为0就行了。然后遍历任意两个数字,注意这里的i和j都分别从0到了数组长度,而上面解法的j是从0到i,这是因为上面解法将p - q, q - p, q / q, q / p都分别列出来了,而这里仅仅是nums[i] - nums[j], nums[i] / nums[j],所以i和j要交换位置,但是为了避免加法和乘法的重复计算,我们可以做个判断,还有别忘记了除数不为零的判断,i和j不能相同的判断。我们建立一个临时数组t,将非i和j位置的数字都加入t,然后遍历操作符数组ops,每次取出一个操作符,然后将nums[i]和nums[j]的计算结果加入t,调用递归函数,如果递归函数返回true了,那么就直接返回true。否则移除刚加入的结果,还原t的状态,参见代码如下:

解法二:

1
2
3
4
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:
bool judgePoint24(vector<int>& nums) {
double eps = 0.001;
vector<char> ops{'+', '-', '*', '/'};
vector<double> arr(nums.begin(), nums.end());
return helper(arr, ops, eps);
}
bool helper(vector<double>& nums, vector<char>& ops, double eps) {
if (nums.size() == 1) return abs(nums[0] - 24) < eps;
for (int i = 0; i < nums.size(); ++i) {
for (int j = 0; j < nums.size(); ++j) {
if (i == j) continue;
vector<double> t;
for (int k = 0; k < nums.size(); ++k) {
if (k != i && k != j) t.push_back(nums[k]);
}
for (char op : ops) {
if ((op == '+' || op == '*') && i > j) continue;
if (op == '/' && nums[j] < eps) continue;
switch(op) {
case '+': t.push_back(nums[i] + nums[j]); break;
case '-': t.push_back(nums[i] - nums[j]); break;
case '*': t.push_back(nums[i] * nums[j]); break;
case '/': t.push_back(nums[i] / nums[j]); break;
}
if (helper(t, ops, eps)) return true;
t.pop_back();
}
}
}
return false;
}
};

Leetcode680. Valid Palindrome II

Given a non-empty string s, you may delete at most one character. Judge whether you can make it a palindrome.

Example 1:

1
2
Input: "aba"
Output: True

Example 2:
1
2
3
Input: "abca"
Output: True
Explanation: You could delete the character 'c'.

首先,对于这样的回文判断,一次遍历肯定是必不可少的。如果没有多出的字符,只需要从两端向中间依次进行判断即可。
但是现在是有多出的字符,因此,要么前面多出一个字符,要么后面多出一个字符。这两种情况是不确定的,因此需要分别来进行判断。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool validPalindrome(string s) {
int left = 0, right = s.length()-1;
while(left < right) {
if(s[left] == s[right]) {
left ++;
right --;
}
else {
int left1 = left+1, right1 = right;
int left2 = left, right2 = right-1;
while(left1 < right1 && s[left1] == s[right1]) left1++, right1--;
while(left2 < right2 && s[left2] == s[right2]) left2++, right2--;
return left1 >= right1 || left2 >= right2;
}
}
return true;
}
};

Leetcode682. Baseball Game

You’re now a baseball game point recorder.

Given a list of strings, each string can be one of the 4 following types:

  • Integer (one round’s score): Directly represents the number of points you get in this round.
  • “+” (one round’s score): Represents that the points you get in this round are the sum of the last two valid round’s points.
  • “D” (one round’s score): Represents that the points you get in this round are the doubled data of the last valid round’s points.
  • “C” (an operation, which isn’t a round’s score): Represents the last valid round’s points you get were invalid and should be removed.
    Each round’s operation is permanent and could have an impact on the round before and the round after.

You need to return the sum of the points you could get in all the rounds.

Example 1:

1
2
3
4
5
6
7
8
Input: ["5","2","C","D","+"]
Output: 30
Explanation:
Round 1: You could get 5 points. The sum is: 5.
Round 2: You could get 2 points. The sum is: 7.
Operation 1: The round 2's data was invalid. The sum is: 5.
Round 3: You could get 10 points (the round 2's data has been removed). The sum is: 15.
Round 4: You could get 5 + 10 = 15 points. The sum is: 30.

Example 2:
1
2
3
4
5
6
7
8
9
10
11
Input: ["5","-2","4","C","D","9","+","+"]
Output: 27
Explanation:
Round 1: You could get 5 points. The sum is: 5.
Round 2: You could get -2 points. The sum is: 3.
Round 3: You could get 4 points. The sum is: 7.
Operation 1: The round 3's data is invalid. The sum is: 3.
Round 4: You could get -4 points (the round 3's data has been removed). The sum is: -1.
Round 5: You could get 9 points. The sum is: 8.
Round 6: You could get -4 + 9 = 5 points. The sum is 13.
Round 7: You could get 9 + 5 = 14 points. The sum is 27.

简单模拟。
1
2
3
4
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:
int calPoints(vector<string>& ops) {
vector<int> res(ops.size());
int pointer = 0;
for(int i = 0; i < ops.size(); i ++) {
if(ops[i] == "C") {
pointer --;
}
else if(ops[i] == "D") {
res[pointer] = res[pointer-1]*2;
pointer++;
}
else if(ops[i] == "+") {
res[pointer] = res[pointer-1]+res[pointer-2];
pointer++;
}
else {
res[pointer] = stoi(ops[i]);
pointer++;
}
}
int sum = 0;
for(int i = 0;i < pointer; i ++)
sum += res[i];
return sum;
}
};

大佬做法:
1
2
3
4
5
6
7
8
9
int calPoints(char ** ops, int opsSize){
int p[1000] = { 0 }, r = 0, t = 0;
for (int i = 0 ; i < opsSize ; i++)
*ops[i] == '+' ? r ? p[r] = p[r - 1], r > 1 ? p[r] += p[r - 2] : 0, t += p[r++] : 0 :
*ops[i] == 'D' ? r ? t += p[r] = p[r - 1] * 2, r++ : 0 :
*ops[i] == 'C' ? r ? t -= p[--r] : 0 :
(t += p[r++] = atoi(ops[i]));
return t;
}

Leetcode684. Redundant Connection

In this problem, a tree is an undirected graph that is connected and has no cycles.

The given input is a graph that started as a tree with N nodes (with distinct values 1, 2, …, N), with one additional edge added. The added edge has two different vertices chosen from 1 to N, and was not an edge that already existed.

The resulting graph is given as a 2D-array of edges. Each element of edges is a pair [u, v] with u < v, that represents an undirected edge connecting nodes u and v.

Return an edge that can be removed so that the resulting graph is a tree of N nodes. If there are multiple answers, return the answer that occurs last in the given 2D-array. The answer edge [u, v] should be in the same format, with u < v.

Example 1:

1
2
3
4
5
6
Input: [[1,2], [1,3], [2,3]]
Output: [2,3]
Explanation: The given undirected graph will be like this:
1
/ \
2 - 3

Example 2:

1
2
3
4
5
6
Input: [[1,2], [2,3], [3,4], [1,4], [1,5]]
Output: [1,4]
Explanation: The given undirected graph will be like this:
5 - 1 - 2
| |
4 - 3

Note:

  • The size of the input 2D-array will be between 3 and 1000.
  • Every integer represented in the 2D-array will be between 1 and N, where N is the size of the input array.

这道题给我们了一个无向图,让删掉组成环的最后一条边用并查集做即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
vector<int> root(2001, -1);
for (auto edge : edges) {
int x = find(root, edge[0]), y = find(root, edge[1]);
if (x == y) return edge;
root[x] = y;
}
return {};
}
int find(vector<int>& root, int i) {
while (root[i] != -1) {
i = root[i];
}
return i;
}
};

Leetcode686. Repeated String Match

Given two strings A and B, find the minimum number of times A has to be repeated such that B is a substring of it. If no such solution, return -1.

For example, with A = “abcd” and B = “cdabcdab”.

Return 3, because by repeating A three times (“abcdabcdabcd”), B is a substring of it; and B is not a substring of A repeated two times (“abcdabcd”).

Note:
The length of A and B will be between 1 and 10000.

这道题题意清晰,解决题目的关键在于把可能的情况想清楚。

本题分为三种情况处理:

  • A比B短,这是最容易想到的情况,比如A为“abc”,B为“bcab”,我们要重复1次,使得A的长度大于等于B。接着猜判断B能不能在A中找到,这时我们用find函数即可。还有另一种情况,A重复完之后刚好长度等于B,但是我们有时能找到B,有时还要再重复一次才能找到B。比如A为“abc”,B为“abcabc”,这种就是重复1次,长度刚好等于B,能找到B的情况。比如A为“abc”,B为“bcabca”,这种就是重复1次,长度刚好等于B,但不能找到B的情况,要再重复1次。
  • A比B长,有两种情况,第一种就是刚好能找到,不用重复。比如A为“abcdefg”,B为“bcd”。另一种是找不到,要再重复一次,比如A为“abcdefg”,B为“efga”,要再重复一次才能找到。
  • A和B一样长,同样两种情况,第一种就是刚好能找到,A==B。另一种就是要再重复一次,比如A为“abcdefg”,B为“efgabcd”,要再重复一次才能找到。

综上所述,我们使得A的长度大于等于B,如果这个时候能找到B,那么ok,返回重复的次数。如果不能找到B,那么再重复一次A,如果重复之后能找到,那么返回新的重复的次数。如果还是找不到,我们认为当A的长度大于等于B的时候,这时候可能还会找不到B,但如果再重复一次A,重复之后还是找不到B,那么就是不可能通过重复A来找到B的,返回-1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int repeatedStringMatch(string A, string B) {
int result = 0;
string newA = "";
while(newA.size() < B.size()) {
newA += A;
result ++;
}
if(newA.find(B) != -1)
return result;
newA += A;
if(newA.find(B) != -1)
return result+1;
return -1;
}
};

Leetcode687. Longest Univalue Path

Given a binary tree, find the length of the longest path where each node in the path has the same value. This path may or may not pass through the root.

The length of path between two nodes is represented by the number of edges between them.

Example 1:

1
2
3
4
5
6
7
Input:
5
/ \
4 5
/ \ \
1 1 5
Output: 2

Example 2:
1
2
3
4
5
6
7
Input:
1
/ \
4 5
/ \ \
4 4 5
Output: 2

Note: The given binary tree has not more than 10000 nodes. The height of the tree is not more than 1000.
要求的是最长的路径,所以就不可以往回走,只能是两个节点的路径,那这个路径就可以表示为一个根结点到左边的最长路径+到右边最长路径,简单的对每一个根结点往左往右递归求解就好。
对每一个结点,以其作为根结点,对左右分别dfs求得从当前节点出发左边最长和右边最长的值,然后加起来和max对比,如果大于max则替换max。然后返回上一层,返回上一层的数要为左边或右边的最大值,而不是相加值,因为对于父节点为根结点的话只能往左或者往右寻找最长路径,而不能先去左边然后去右边然后返回。

1
2
3
4
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 Solution {
public:

int dfs(TreeNode* root, int& maxx) {
if (root->left == NULL && root->right == NULL)
return 0;
int l = 0, r = 0;
if(root->left && root->left->val == root->val)
l = 1 + dfs(root->left, maxx);
else if(root->left)
dfs(root->left, maxx);
if (root->right && root->right->val == root->val)
r = 1 + dfs(root->right, maxx);
else if (root->right)
dfs(root->right, maxx);

if(l + r > maxx)
maxx = l + r;
return l > r ? l : r;

}

int longestUnivaluePath(TreeNode* root) {
int maxx = 0;
if(!root)
return 0;
dfs(root, maxx);
return maxx;
}
};

Leetcode690. Employee Importance

You are given a data structure of employee information, which includes the employee’s unique id, his importance value and his direct subordinates’ id.

For example, employee 1 is the leader of employee 2, and employee 2 is the leader of employee 3. They have importance value 15, 10 and 5, respectively. Then employee 1 has a data structure like [1, 15, [2]], and employee 2 has [2, 10, [3]], and employee 3 has [3, 5, []]. Note that although employee 3 is also a subordinate of employee 1, the relationship is not direct.

Now given the employee information of a company, and an employee id, you need to return the total importance value of this employee and all his subordinates.

Example 1:

1
2
3
4
Input: [[1, 5, [2, 3]], [2, 3, []], [3, 3, []]], 1
Output: 11
Explanation:
Employee 1 has importance value 5, and he has two direct subordinates: employee 2 and employee 3. They both have importance value 3. So the total importance value of employee 1 is 5 + 3 + 3 = 11.

超级麻烦的一道题,就模拟呗……
1
2
3
4
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
/*
// Definition for Employee.
class Employee {
public:
int id;
int importance;
vector<int> subordinates;
};
*/
class Solution {
public:
int getImportance(vector<Employee*> employees, int id) {
int total = 0;
vector<int> subids;
for (Employee* employee : employees)
{
if (employee->id == id)
{
total += employee->importance;
subids = employee->subordinates;
break;
}
}
if (subids.size() == 0) return total;
for (int subid : subids)
total += getImportance(employees, subid);
return total;
}
};
};

Leetcode692. Top K Frequent Words

Given a non-empty list of words, return the k most frequent elements.

Your answer should be sorted by frequency from highest to lowest. If two words have the same frequency, then the word with the lower alphabetical order comes first.

Example 1:

1
2
3
4
Input: ["i", "love", "leetcode", "i", "love", "coding"], k = 2
Output: ["i", "love"]
Explanation: "i" and "love" are the two most frequent words.
Note that "i" comes before "love" due to a lower alphabetical order.

Example 2:

1
2
3
4
Input: ["the", "day", "is", "sunny", "the", "the", "the", "sunny", "is", "is"], k = 4
Output: ["the", "is", "sunny", "day"]
Explanation: "the", "is", "sunny" and "day" are the four most frequent words,
with the number of occurrence being 4, 3, 2 and 1 respectively.

这道题让我们求前K个高频词,跟之前那道题 Top K Frequent Elements 极其类似,换了个数据类型就又是一道新题。唯一的不同就是之前那道题对于出现频率相同的数字,没有顺序要求。而这道题对于出现频率相同的单词,需要按照字母顺序来排。但是解法都一样,还是用最小堆和桶排序的方法。首先来看最小堆的方法,思路是先建立每个单词和其出现次数之间的映射,然后把单词和频率的pair放进最小堆,如果没有相同频率的单词排序要求,我们完全可以让频率当作pair的第一项,这样priority_queue默认是以pair的第一项为key进行从大到小的排序,而当第一项相等时,又会以第二项由大到小进行排序,这样第一项的排序方式就与题目要求的相同频率的单词要按字母顺序排列不相符,当然我们可以在存入结果res时对相同频率的词进行重新排序处理,也可以对priority_queue的排序机制进行自定义,这里我们采用第二种方法,我们自定义排序机制,我们让a.second > b.second,让小频率的词在第一位,然后当a.second == b.second时,我们让a.first < b.first,这是让字母顺序大的排在前面(这里博主需要强调一点的是,priority_queue的排序机制的写法和vector的sort的排序机制的写法正好顺序相反,同样的写法,用在sort里面就是频率小的在前面,不信的话可以自己试一下)。定义好最小堆后,我们首先统计单词的出现频率,然后组成pair排序最小堆之中,我们只保存k个pair,超过了就把队首的pair移除队列,最后我们把单词放入结果res中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<string> topKFrequent(vector<string>& words, int k) {
vector<string> res(k);
unordered_map<string, int> freq;
auto cmp = [](pair<string, int>& a, pair<string, int>& b) {
return a.second > b.second || (a.second == b.second && a.first < b.first);
};
priority_queue<pair<string, int>, vector<pair<string, int>>, decltype(cmp) > q(cmp);
for (auto word : words) ++freq[word];
for (auto f : freq) {
q.push(f);
if (q.size() > k) q.pop();
}
for (int i = res.size() - 1; i >= 0; --i) {
res[i] = q.top().first; q.pop();
}
return res;
}
};

下面这种解法还是一种堆排序的思路,这里我们用map,来建立次数和出现该次数所有单词的集合set之间的映射,这里也利用了set能自动排序的特性,当然我们还是需要首先建立每个单词和其出现次数的映射,然后将其组成pair放入map种,map是从小到大排序的,这样我们从最后面取pair,就是次数最大的,每次取出一层中所有的单词,如果此时的k大于该层的单词个数,就将整层的单词加入结果res中,否则就取前K个就行了,取完要更更新K值,如果K小于等于0了,就break掉,返回结果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
class Solution {
public:
vector<string> topKFrequent(vector<string>& words, int k) {
vector<string> res;
unordered_map<string, int> freq;
map<int, set<string>> m;
for (string word : words) ++freq[word];
for (auto a : freq) {
m[a.second].insert(a.first);
}
for (auto it = m.rbegin(); it != m.rend(); ++it) {
if (k <= 0) break;
auto t = it->second;
vector<string> v(t.begin(), t.end());
if (k >= t.size()) {
res.insert(res.end(), v.begin(), v.end());
} else {
res.insert(res.end(), v.begin(), v.begin() + k);
}
k -= t.size();
}
return res;
}
};

Leetcode693. Binary Number with Alternating Bits

Given a positive integer, check whether it has alternating bits: namely, if two adjacent bits will always have different values.

Example 1:

1
2
3
4
Input: 5
Output: True
Explanation:
The binary representation of 5 is: 101

Example 2:
1
2
3
4
Input: 7
Output: False
Explanation:
The binary representation of 7 is: 111.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
bool hasAlternatingBits(int n) {
int flag = 0;
int prev = n & 1;
n = n >> 1;
while(n) {
int temp = n & 1;
if(prev == temp)
return false;
prev = temp;
n = n >> 1;
}
return true;
}
};

Leetcode695. Max Area of Island

Given a non-empty 2D array grid of 0’s and 1’s, an island is a group of 1‘s (representing land) connected 4-directionally (horizontal or vertical.) You may assume all four edges of the grid are surrounded by water.

Find the maximum area of an island in the given 2D array. (If there is no island, the maximum area is 0.)

Example 1:

1
2
3
4
5
6
7
8
9
10
[[0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,1,1,0,1,0,0,0,0,0,0,0,0],
[0,1,0,0,1,1,0,0,1,0,1,0,0],
[0,1,0,0,1,1,0,0,1,1,1,0,0],
[0,0,0,0,0,0,0,0,0,0,1,0,0],
[0,0,0,0,0,0,0,1,1,1,0,0,0],
[0,0,0,0,0,0,0,1,1,0,0,0,0]]

Given the above grid, return 6. Note the answer is not 11, because the island must be connected 4-directionally.

Example 2:

1
2
3
[[0,0,0,0,0,0,0,0]]

Given the above grid, return 0.

Note: The length of each dimension in the given grid does not exceed 50.

这道题需要统计出每个岛的大小,再来更新结果res。先用递归来做,遍历grid,当遇到为1的点,我们调用递归函数,在递归函数中,我们首先判断i和j是否越界,还有grid[i][j]是否为1,我们没有用visited数组,而是直接修改了grid数组,遍历过的标记为-1。如果合法,那么cnt自增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
class Solution {
public:
vector<vector<int>> dirs{{0,-1},{-1,0},{0,1},{1,0}};
int maxAreaOfIsland(vector<vector<int>>& grid) {
int m = grid.size(), n = grid[0].size(), res = 0;
for (int i = 0; i < m; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] != 1) continue;
int cnt = 0;
helper(grid, i, j, cnt, res);
}
}
return res;
}
void helper(vector<vector<int>>& grid, int i, int j, int& cnt, int& res) {
int m = grid.size(), n = grid[0].size();
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] <= 0) return;
res = max(res, ++cnt);
grid[i][j] *= -1;
for (auto dir : dirs) {
helper(grid, i + dir[0], j + dir[1], cnt, res);
}
}
};

Leetcode696. Count Binary Substrings

Give a string s, count the number of non-empty (contiguous) substrings that have the same number of 0’s and 1’s, and all the 0’s and all the 1’s in these substrings are grouped consecutively.

Substrings that occur multiple times are counted the number of times they occur.

Notice that some of these substrings repeat and are counted the number of times they occur.

Also, “00110011” is not a valid substring because all the 0’s (and 1’s) are not grouped together.

Example 1:

1
2
3
Input: "00110011"
Output: 6
Explanation: There are 6 substrings that have equal number of consecutive 1's and 0's: "0011", "01", "1100", "10", "0011", and "01".

Example 2:
1
2
3
Input: "10101"
Output: 4
Explanation: There are 4 substrings: "10", "01", "10", "01" that have equal number of consecutive 1's and 0's.

Note:

  • s.length will be between 1 and 50,000.
  • s will only consist of “0” or “1” characters.

从例子00110011来看,在第一个1出现时,preLength为2,即前面有两个相等的字符00,此时01可以满足条件,当第二个1出现时,此时0011可以满足条件,在遇到下一个0时,将currLength的次数置为1,preLength为2,此时0前面有连个连续的1,可以组成10满足条件,遇到下一个0时currLength的次数置为2,此时可以满足条件,依此类推..。用两个变量preLength(当前字符前连续的相同字符的个数),currLength(当前字符连续个数),当preLength>=preLength,则满足条件的结果加1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int countBinarySubstrings(string s) {
int res = 0, prev = 0, cur = 1;
for(int i = 1; i < s.length(); i ++) {
if(s[i-1] == s[i])
cur ++;
else {
prev = cur;
cur = 1;
}
if(prev>=cur)
res ++;
}
return res;
}
};

Leetcode697. Degree of an Array

Given a non-empty array of non-negative integers nums, the degree of this array is defined as the maximum frequency of any one of its elements.

Your task is to find the smallest possible length of a (contiguous) subarray of nums, that has the same degree as nums.

Example 1:

1
2
3
4
5
6
7
Input: [1, 2, 2, 3, 1]
Output: 2
Explanation:
The input array has a degree of 2 because both elements 1 and 2 appear twice.
Of the subarrays that have the same degree:
[1, 2, 2, 3, 1], [1, 2, 2, 3], [2, 2, 3, 1], [1, 2, 2], [2, 2, 3], [2, 2]
The shortest length is 2. So return 2.

Example 2:
1
2
Input: [1,2,2,3,1,4,2]
Output: 6

定义数组的度为某个或某些数字出现最多的次数,要我们找最短的子数组使其和原数组拥有相同的度。那么我们肯定需要统计每个数字出现的次数,就要用哈希表来建立每个数字和其出现次数之间的映射。由于我们要求包含原度的最小长度的子数组,那么最好的情况就是子数组的首位数字都是统计度的数字,即出现最多的数字。那么我们肯定要知道该数字的第一次出现的位置和最后一次出现的位置,由于我们开始不知道哪些数字会出现最多次,所以我们统计所有数字的首尾出现位置,那么我们再用一个哈希表,建立每个数字和其首尾出现的位置。我们用变量degree来表示数组的度。好,现在我们遍历原数组,累加当前数字出现的次数,当某个数字是第一次出现,那么我们用当前位置的来更新该数字出现的首尾位置,否则只更新尾位置。每遍历一个数,我们都更新一下degree。当遍历完成后,我们已经有了数组的度,还有每个数字首尾出现的位置,下面就来找出现次数为degree的数组,然后计算其首尾位置差加1就是candidate数组的长度,由于出现次数为degree的数字不一定只有一个,我们遍历所有的,找出其中最小的即可。
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 findShortestSubArray(vector<int>& nums) {
unordered_map<int, int> mp;
unordered_map<int, pair<int, int>> mpp;
int maxx = -1;
for(int i = 0; i < nums.size(); i ++) {
if(mp[nums[i]] == 0)
mpp[nums[i]] = {i, i};
else
mpp[nums[i]].second = i;
mp[nums[i]] ++;
maxx = max(maxx, mp[nums[i]]);
}
int res = 9999999;
for(auto it = mp.begin(); it != mp.end(); it ++) {
if(it->second == maxx) {
res = min(res, mpp[it->first].second - mpp[it->first].first + 1);
}
}
return res;
}
};

记录下每个元素的出现次数和出现的首尾坐标。然后根据首尾坐标计算长度。

Leetcode698. Partition to K Equal Sum Subsets

Given an array of integers nums and a positive integer k, find whether it’s possible to divide this array into knon-empty subsets whose sums are all equal.

Example 1:

1
2
3
Input: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
Output: True
Explanation: It's possible to divide it into 4 subsets (5), (1, 4), (2,3), (2,3) with equal sums.

Note:

  • 1 <= k <= len(nums) <= 16.
  • 0 < nums[i] < 10000.

这道题给了我们一个数组nums和一个数字k,问我们该数字能不能分成k个非空子集合,使得每个子集合的和相同。给了k的范围是[1,16],而且数组中的数字都是正数。这跟之前那道 Partition Equal Subset Sum 很类似,但是那道题只让分成两个子集合,所以问题可以转换为是否存在和为整个数组和的一半的子集合,可以用dp来做。但是这道题让求k个和相同的,感觉无法用dp来做,因为就算找出了一个,其余的也需要验证。这道题我们可以用递归来做,首先我们还是求出数组的所有数字之和sum,首先判断sum是否能整除k,不能整除的话直接返回false。然后需要一个visited数组来记录哪些数组已经被选中了,然后调用递归函数,我们的目标是组k个子集合,是的每个子集合之和为target = sum/k。我们还需要变量start,表示从数组的某个位置开始查找,curSum为当前子集合之和,在递归函数中,如果k=1,说明此时只需要组一个子集合,那么当前的就是了,直接返回true。如果curSum等于target了,那么我们再次调用递归,此时传入k-1,start和curSum都重置为0,因为我们当前又找到了一个和为target的子集合,要开始继续找下一个。否则的话就从start开始遍历数组,如果当前数字已经访问过了则直接跳过,否则标记为已访问。然后调用递归函数,k保持不变,因为还在累加当前的子集合,start传入i+1,curSum传入curSum+nums[i],因为要累加当前的数字,如果递归函数返回true了,则直接返回true。否则就将当前数字重置为未访问的状态继续遍历,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool canPartitionKSubsets(vector<int>& nums, int k) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if (sum % k != 0) return false;
vector<bool> visited(nums.size(), false);
return helper(nums, k, sum / k, 0, 0, visited);
}
bool helper(vector<int>& nums, int k, int target, int start, int curSum, vector<bool>& visited) {
if (k == 1) return true;
if (curSum == target) return helper(nums, k - 1, target, 0, 0, visited);
for (int i = start; i < nums.size(); ++i) {
if (visited[i]) continue;
visited[i] = true;
if (helper(nums, k, target, i + 1, curSum + nums[i], visited)) return true;
visited[i] = false;
}
return false;
}
};

我们也可以对上面的解法进行一些优化,比如先给数组按从大到小的顺序排个序,然后在递归函数中,我们可以直接判断,如果curSum大于target了,直接返回false,因为题目中限定了都是正数,并且我们也给数组排序了,后面的数字只能更大,这个剪枝操作大大的提高了运行速度。

Leetcode700. Search in a Binary Search Tree

Given the root node of a binary search tree (BST) and a value. You need to find the node in the BST that the node’s value equals the given value. Return the subtree rooted with that node. If such node doesn’t exist, you should return NULL.

For example,

Given the tree:

1
2
3
4
5
    4
/ \
2 7
/ \
1 3

And the value to search: 2
You should return this subtree:
1
2
3
  2     
/ \
1 3

In the example above, if we want to search the value 5, since there is no node with value 5, we should return NULL.

Note that an empty tree is represented by NULL, therefore you would see the expected output (serialized tree format) as [], not null.

给一棵树,查找对应value的子树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:

TreeNode* des(TreeNode* root,int val){
if(root==NULL)
return NULL;
if(root->val == val)
return root;
if(root->val > val)
return des(root->left,val);
else
return des(root->right,val);
}

TreeNode* searchBST(TreeNode* root, int val) {
if(root == NULL)
return NULL;
return des(root,val);
}
};

Leetcode901. Online Stock Span

Write a class StockSpanner which collects daily price quotes for some stock, and returns the span of that stock’s price for the current day.

The span of the stock’s price today is defined as the maximum number of consecutive days (starting from today and going backwards) for which the price of the stock was less than or equal to today’s price.

For example, if the price of a stock over the next 7 days were [100, 80, 60, 70, 60, 75, 85], then the stock spans would be [1, 1, 1, 2, 1, 4, 6].

Example 1:

1
2
3
4
5
6
7
8
9
10
11
Input: ["StockSpanner","next","next","next","next","next","next","next"], [[],[100],[80],[60],[70],[60],[75],[85]]
Output: [null,1,1,1,2,1,4,6]
Explanation:
First, S = StockSpanner() is initialized. Then:
S.next(100) is called and returns 1,
S.next(80) is called and returns 1,
S.next(60) is called and returns 1,
S.next(70) is called and returns 2,
S.next(60) is called and returns 1,
S.next(75) is called and returns 4,
S.next(85) is called and returns 6.

Note that (for example) S.next(75) returned 4, because the last 4 prices (including today’s price of 75) were less than or equal to today’s price.

Note:

  • Calls to StockSpanner.next(int price)will have 1 <= price <= 10^5.
  • There will be at most 10000 calls to StockSpanner.next per test case.
  • There will be at most 150000 calls to StockSpanner.next across all test cases.
  • The total time limit for this problem has been reduced by 75% for C++, and 50% for all other languages.

这道题定义了一个 StockSpanner 的类,有一个 next 函数,每次给当天的股价,让我们返回之前连续多少天都是小于等于当前股价。

可以找连续递增的子数组的长度么,其实也是不行的,就拿题目中的例子来说吧 [100, 80, 60, 70, 60, 75, 85],数字 75 前面有三天是比它小的,但是这三天不是有序的,是先增后减的,那怎么办呢?我们先从简单的情况分析,假如当前的股价要小于前一天的,那么连续性直接被打破了,所以此时直接返回1就行了。但是假如大于等于前一天股价的话,情况就比较 tricky 了,因为此时所有小于等于前一天股价的天数肯定也是小于等于当前的,那么我们就需要知道是哪一天的股价刚好大于前一天的股价,然后用这一天的股价跟当前的股价进行比较,若大于当前股价,说明当前的连续天数就是前一天的连续天数加1,而若小于当前股价,我们又要重复这个过程,去比较刚好大于之前那个的股价。所以我们需要知道对于每一天,往前推刚好大于当前股价的是哪一天,用一个数组 pre,其中 pre[i] 表示从第i天往前推刚好大于第i天的股价的是第 pre[i] 天。接下来看如何实现 next 函数,首先将当前股价加入 nums 数组,然后前一天在数组中的位置就是 (int)nums.size()-2。再来想想 corner case 的情况,假如当前是数组中的第0天,前面没有任何股价了,我们的 pre[0] 就赋值为 -1 就行了,怎么知道当前是否是第0天,就看 pre 数组是否为空。再有就是由于i要不断去 pre 数组中找到之前的天数,所以最终i是有可能到达 pre[0] 的,那么就要判断当i为 -1 时,也要停止循环。循环的最后一个条件就是当之前的股价小于等当前的估计 price 时,才进行循环,这个前面讲过了,循环内部就是将 pre[i] 赋值给i,这样就完成了跳到之前天的操作。while 循环结束后要将i加入到 pre 数组,因为这个i就是从当前天往前推,一个大于当前股价的那一天,有了这个i,就可以计算出连续天数了,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class StockSpanner {
public:
StockSpanner() {}

int next(int price) {
nums.push_back(price);
int i = (int)nums.size() - 2;
while (!pre.empty() && i >= 0 && nums[i] <= price) {
i = pre[i];
}
pre.push_back(i);
return (int)pre.size() - 1 - i;
}

private:
vector<int> nums, pre;
};

我们还可以使用栈来做,里面放一个 pair 对儿,分别是当前的股价和之前比其小的连续天数。在 next 函数中,使用一个 cnt 变量,初始化为1。还是要个 while 循环,其实核心的本质都是一样的,循环的条件首先是栈不能为空,并且栈顶元素的股价小于等于当前股价,那么 cnt 就加上栈顶元素的连续天数,可以感受到跟上面解法在这里的些许不同之处了吧,之前是一直找到第一个大于当前股价的天数在数组中的位置,然后相减得到连续天数,这里是在找的过程中直接累加连续天数,最终都可以得到正确的结果,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class StockSpanner {
public:
StockSpanner() {}

int next(int price) {
int cnt = 1;
while (!st.empty() && st.top().first <= price) {
cnt += st.top().second; st.pop();
}
st.push({price, cnt});
return cnt;
}

private:
stack<pair<int, int>> st;
};

Leetcode904. Fruit Into Baskets

In a row of trees, the i-th tree produces fruit with type tree[i].

You start at any tree of your choice, then repeatedly perform the following steps:

Add one piece of fruit from this tree to your baskets. If you cannot, stop.
Move to the next tree to the right of the current tree. If there is no tree to the right, stop.
Note that you do not have any choice after the initial choice of starting tree: you must perform step 1, then step 2, then back to step 1, then step 2, and so on until you stop.

You have two baskets, and each basket can carry any quantity of fruit, but you want each basket to only carry one type of fruit each.

What is the total amount of fruit you can collect with this procedure?

Example 1:

1
2
3
Input: [1,2,1]
Output: 3
Explanation: We can collect [1,2,1].

Example 2:

1
2
3
Input: [0,1,2,2]
Output: 3 Explanation: We can collect [1,2,2].
If we started at the first tree, we would only collect [0, 1].

Example 3:

1
2
3
Input: [1,2,3,2,2]
Output: 4 Explanation: We can collect [2,3,2,2].
If we started at the first tree, we would only collect [1, 2].

Example 4:

1
2
3
Input: [3,3,3,1,2,1,1,2,3,3,4]
Output: 5 Explanation: We can collect [1,2,1,1,2].
If we started at the first tree or the eighth tree, we would only collect 4 fruits.

Note:

  • 1 <= tree.length <= 40000
  • 0 <= tree[i] < tree.length

这道题说是给了我们一排树,每棵树产的水果种类是 tree[i],说是现在有两种操作,第一种是将当前树的水果加入果篮中,若不能加则停止;第二种是移动到下一个树,若没有下一棵树,则停止。现在我们有两个果篮,可以从任意一个树的位置开始,但是必须按顺序执行操作一和二,问我们最多能收集多少个水果。说实话这道题的题目描述确实不太清晰,博主看了很多遍才明白意思,论坛上也有很多吐槽的帖子,但实际上这道题的本质就是从任意位置开始,若最多只能收集两种水果,问最多能收集多少个水果。那么再进一步提取,其实就是最多有两个不同字符的最长子串的长度,跟之前那道 Longest Substring with At Most Two Distinct Characters 一模一样,只不过换了一个背景,代码基本都可以直接使用的,博主感觉这样出题有点不太好吧,完全重复了。之前那题的四种解法这里完全都可以使用,先来看第一种,使用一个 HashMap 来记录每个水果出现次数,当 HashMap 中当映射数量超过两个的时候,我们需要删掉一个映射,做法是滑动窗口的左边界 start 的水果映射值减1,若此时减到0了,则删除这个映射,否则左边界右移一位。当映射数量回到两个的时候,用当前窗口的大小来更新结果 res 即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int totalFruit(vector& tree) {
int res = 0, start = 0, n = tree.size();
unordered_map<int, int> fruitCnt;
for (int i = 0; i < n; ++i) {
++fruitCnt[tree[i]];
while (fruitCnt.size() > 2) {
if (--fruitCnt[tree[start]] == 0) {
fruitCnt.erase(tree[start]);
}
++start;
}
res = max(res, i - start + 1);
}
return res;
}
};

我们除了用 HashMap 来映射字符出现的个数,我们还可以映射每个数字最新的坐标,比如题目中的例子 [0,1,2,2],遇到第一个0,映射其坐标0,遇到1,映射其坐标1,当遇到2时,映射其坐标2,每次我们都判断当前 HashMap 中的映射数,如果大于2的时候,那么需要删掉一个映射,我们还是从 start=0 时开始向右找,看每个字符在 HashMap 中的映射值是否等于当前坐标 start,比如0,HashMap 此时映射值为0,等于 left 的0,那么我们把0删掉,start 自增1,再更新结果,以此类推直至遍历完整个数组,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int totalFruit(vector& tree) {
int res = 0, start = 0, n = tree.size();
unordered_map<int, int> fruitPos;
for (int i = 0; i < n; ++i) {
fruitPos[tree[i]] = i;
while (fruitPos.size() > 2) {
if (fruitPos[tree[start]] == start) {
fruitPos.erase(tree[start]);
}
++start;
}
res = max(res, i - start + 1);
}
return res;
}
};

后来又在网上看到了一种解法,这种解法是维护一个滑动窗口 sliding window,指针 left 指向起始位置,right 指向 window 的最后一个位置,用于定位 left 的下一个跳转位置,思路如下:

  • 若当前字符和前一个字符相同,继续循环。
  • 若不同,看当前字符和 right 指的字符是否相同:
  • 若相同,left 不变,右边跳到 i - 1。
  • 若不同,更新结果,left 变为 right+1,right 变为 i - 1。

最后需要注意在循环结束后,我们还要比较结果 res 和 n - left 的大小,返回大的,这是由于如果数组是 [5,3,5,2,1,1,1],那么当 left=3 时,i=5,6 的时候,都是继续循环,当i加到7时,跳出了循环,而此时正确答案应为 [2,1,1,1] 这4个数字,而我们的结果 res 只更新到了 [5,3,5] 这3个数字,所以我们最后要判断 n - left 和结果 res 的大小。

另外需要说明的是这种解法仅适用于于不同字符数为2个的情况,如果为k个的话,还是需要用上面两种解法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int totalFruit(vector<int>& tree) {
int res = 0, left = 0, right = -1, n = tree.size();
for (int i = 1; i < n; ++i) {
if (tree[i] == tree[i - 1]) continue;
if (right >= 0 && tree[right] != tree[i]) {
res = max(res, i - left);
left = right + 1;
}
right = i - 1;
}
return max(n - left, res);
}
};

还有一种不使用 HashMap 的解法,这里我们使用若干个变量,其中 cur 为当前最长子数组的长度,a和b为当前候选的两个不同的水果种类,cntB 为水果b的连续个数。我们遍历所有数字,假如遇到的水果种类是a和b中的任意一个,那么 cur 可以自增1,否则 cntB 自增1,因为若是新水果种类的话,默认已经将a种类淘汰了,此时候选水果由类型b和这个新类型水果构成,所以当前长度是 cntB+1。然后再来更新 cntB,假如当前水果种类是b的话,cntB 自增1,否则重置为1,因为 cntB 统计的就是水果种类b的连续个数。然后再来判断,若当前种类不是b,则此时a赋值为b, b赋值为新种类。最后不要忘了用 cur 来更新结果 res,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int totalFruit(vector<int>& tree) {
int res = 0, cur = 0, cntB = 0, a = 0, b = 0;
for (int fruit : tree) {
cur = (fruit == a || fruit == b) ? cur + 1 : cntB + 1;
cntB = (fruit == b) ? cntB + 1 : 1;
if (b != fruit) {
a = b; b = fruit;
}
res = max(res, cur);
}
return res;
}
};

Leetcode905. Sort Array By Parity

Given an array A of non-negative integers, return an array consisting of all the even elements of A, followed by all the odd elements of A.

You may return any answer array that satisfies this condition.

Example 1:

1
2
3
Input: [3,1,2,4]
Output: [2,4,3,1]
The outputs [4,2,3,1], [2,4,1,3], and [4,2,1,3] would also be accepted.

Note:

1 <= A.length <= 5000
0 <= A[i] <= 5000

将奇数和偶数分类。。。简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> sortArrayByParity(vector<int>& A) {
for(int i = 0; i < A.size(); i ++){
if(A[i] % 2){
for(int j = i + 1; j < A.size(); j++){
if(A[j] % 2 == 0){
int temp = A[j];
A[j] = A[i];
A[i] = temp;
}
}
}
}
return A;
}
};

Leetcode907. Sum of Subarray Minimums

Given an array of integers A, find the sum of min(B), where B ranges over every (contiguous) subarray of A.

Since the answer may be large, return the answer modulo 10^9 + 7.

Example 1:

1
2
3
4
Input: [3,1,2,4]
Output: 17
Explanation: Subarrays are [3], [1], [2], [4], [3,1], [1,2], [2,4], [3,1,2], [1,2,4], [3,1,2,4].
Minimums are 3, 1, 2, 4, 1, 1, 2, 1, 1, 1. Sum is 17.

Note:

  • 1 <= A.length <= 30000
  • 1 <= A[i] <= 30000

这道题给了一个数组,对于所有的子数组,找到最小值,并返回累加结果,并对一个超大数取余。由于我们只关心子数组中的最小值,所以对于数组中的任意一个数字,需要知道其是多少个子数组的最小值。就拿题目中的例子 [3,1,2,4] 来分析,开始遍历到3的时候,其本身就是一个子数组,最小值也是其本身,累加到结果 res 中,此时 res=3,然后看下个数1,是小于3的,此时新产生了两个子数组 [1] 和 [3,1],且最小值都是1,此时在结果中就累加了 2,此时 res=5。接下来的数字是2,大于之前的1,此时会新产生三个子数组,其本身单独会产生一个子数组 [2],可以先把这个2累加到结果 res 中,然后就是 [1,2] 和 [3,1,2],可以发现新产生的这两个子数组的最小值还是1,跟之前计算数字1的时候一样,可以直接将以1结尾的子数组最小值之和加起来,那么以2结尾的子数组最小值之和就是 2+2=4,此时 res=9。对于最后一个数字4,其单独产生一个子数组 [4],还会再产生三个子数组 [3,1,2,4], [1,2,4], [2,4],其并不会对子数组的最小值产生影响,所以直接加上以2结尾的子数组最小值之和,总共就是 4+4=8,最终 res=17。

分析到这里,就知道我们其实关心的是以某个数字结尾时的子数组最小值之和,可以用一个一维数组 dp,其中dp[i]表示以数字A[i]结尾的所有子数组最小值之和,将dp[0]初始化为A[0],结果res也初始化为A[0]。然后从第二个数字开始遍历,若大于等于前一个数字,则当前dp[i]赋值为dp[i-1]+A[i],前面的分析已经解释了,当前数字A[i]组成了新的子数组,同时由于A[i]不会影响最小值,所以要把之前的最小值之和再加一遍。假如小于前一个数字,就需要向前遍历,去找到第一个小于A[i]的位置j,假如j小于0,表示前面所有的数字都是小于A[i]的,那么A[i]是前面i+1个以A[i]结尾的子数组的最小值,累加和为(i+1) x A[i],若j大于等于0,则需要分成两部分累加,dp[j] + (i-j)xA[i],这个也不难理解,前面有i-j个以A[i]为结尾的子数组的最小值是A[i],而再前面的子数组的最小值就不是A[i]了,但是还是需要加上一遍其本身的最小值之和,因为每个子数组末尾都加上A[i]均可以组成一个新的子数组,最终的结果res就是将dp数组累加起来即可,别忘了对超大数取余,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int sumSubarrayMins(vector<int>& A) {
int res = A[0], n = A.size(), M = 1e9 + 7;
vector<int> dp(n);
dp[0] = A[0];
for (int i = 1; i < n; ++i) {
if (A[i] >= A[i - 1]) dp[i] = dp[i - 1] + A[i];
else {
int j = i - 1;
while (j >= 0 && A[i] < A[j]) --j;
dp[i] = (j < 0) ? (i + 1) * A[i] : (dp[j] + (i - j) * A[i]);
}
res = (res + dp[i]) % M;
}
return res;
}
};

上面的方法虽然 work,但不是很高效,原因是在向前找第一个小于当前的数字,每次都要线性遍历一遍,造成了平方级的时间复杂度。而找每个数字的前小数字或是后小数字,正是单调栈擅长的,可以参考博主之前的总结贴 LeetCode Monotonous Stack Summary 单调栈小结。这里我们用一个单调栈来保存之前一个小的数字的位置,栈里先提前放一个 -1,作用会在之后讲解。还是需要一个 dp 数组,跟上面的定义基本一样,但是为了避免数组越界,将长度初始化为 n+1,其中 dp[i] 表示以数字 A[i-1] 结尾的所有子数组最小值之和。对数组进行遍历,当栈顶元素不是 -1 且 A[i] 小于等于栈顶元素,则将栈顶元素移除。这样栈顶元素就是前面第一个比 A[i] 小的数字,此时 dp[i+1] 更新还是跟之前一样,分为两个部分,由于知道了前面第一个小于 A[i] 的数字位置,用当前位置减去栈顶元素位置再乘以 A[i],就是以 A[i] 为结尾且最小值为 A[i] 的子数组的最小值之和,而栈顶元素之前的子数组就不受 A[i] 影响了,直接将其 dp 值加上即可。将当前位置压入栈,并将 dp[i+1] 累加到结果 res,同时对超大值取余,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int sumSubarrayMins(vector<int>& A) {
int res = 0, n = A.size(), M = 1e9 + 7;
stack<int> st{{-1}};
vector<int> dp(n + 1);
for (int i = 0; i < n; ++i) {
while (st.top() != -1 && A[i] <= A[st.top()]) {
st.pop();
}
dp[i + 1] = (dp[st.top() + 1] + (i - st.top()) * A[i]) % M;
st.push(i);
res = (res + dp[i + 1]) % M;
}
return res;
}
};

再来看一种解法,由于对于每个数字,只要知道了其前面第一个小于其的数字位置,和后面第一个小于其的数字位置,就能知道当前数字是多少个子数组的最小值,直接相乘累加到结果 res 中即可。这里我们用两个单调栈 st_pre 和 st_next,栈里放一个数对儿,由数字和其在原数组的坐标组成。还需要两个一维数组 left 和 right,其中 left[i] 表示以 A[i] 为结束为止且 A[i] 是最小值的子数组的个数,right[i] 表示以 A[i] 为起点且 A[i] 是最小值的子数组的个数。对数组进行遍历,当 st_pre 不空,且栈顶元素大于 A[i],移除栈顶元素,这样剩下的栈顶元素就是 A[i] 左边第一个小于其的数字的位置,假如栈为空,说明左边的所有数字都小于 A[i],则 left[i] 赋值为 i+1,否则赋值为用i减去栈顶元素在原数组中的位置的值,然后将 A[i] 和i组成数对儿压入栈 st_pre。对于 right[i] 的处理也很类似,先将其初始化为 n-i,然后看若 st_next 不为空且栈顶元素大于 A[i],然后取出栈顶元素t,由于栈顶元素t是大于 A[i]的,所以 right[t.second] 就可以更新为 i-t.second,然后将 A[i] 和i组成数对儿压入栈 st_next,最后再遍历一遍原数组,将每个 A[i] x left[i] x right[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
class Solution {
public:
int sumSubarrayMins(vector<int>& A) {
int res = 0, n = A.size(), M = 1e9 + 7;
stack<pair<int, int>> st_pre, st_next;
vector<int> left(n), right(n);
for (int i = 0; i < n; ++i) {
while (!st_pre.empty() && st_pre.top().first > A[i]) {
st_pre.pop();
}
left[i] = st_pre.empty() ? (i + 1) : (i - st_pre.top().second);
st_pre.push({A[i], i});
right[i] = n - i;
while (!st_next.empty() && st_next.top().first > A[i]) {
auto t = st_next.top(); st_next.pop();
right[t.second] = i - t.second;
}
st_next.push({A[i], i});
}
for (int i = 0; i < n; ++i) {
res = (res + A[i] * left[i] * right[i]) % M;
}
return res;
}
};

我们也可以对上面的解法进行空间上的优化,只用一个单调栈,用来记录当前数字之前的第一个小的数字的位置,然后遍历每个数字,但是要多遍历一个数字,i从0遍历到n,当 i=n 时,cur 赋值为0,否则赋值为 A[i]。然后判断若栈不为空,且 cur 小于栈顶元素,则取出栈顶元素位置 idx,由于是单调栈,那么新的栈顶元素就是 A[idx] 前面第一个较小数的位置,由于此时栈可能为空,所以再去之前要判断一下,若为空,则返回 -1,否则返回栈顶元素,用 idx 减去栈顶元素就是以 A[idx] 为结尾且最小值为 A[idx] 的子数组的个数,然后用i减去 idx 就是以 A[idx] 为起始且最小值为 A[idx] 的子数组的个数,然后 A[idx] x left x right 就是 A[idx] 这个数字当子数组的最小值之和,累加到结果 res 中并对超大数取余即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int sumSubarrayMins(vector<int>& A) {
int res = 0, n = A.size(), M = 1e9 + 7;
stack<int> st;
for (int i = 0; i <= n; ++i) {
int cur = (i == n) ? 0 : A[i];
while (!st.empty() && cur < A[st.top()]) {
int idx = st.top(); st.pop();
int left = idx - (st.empty() ? -1 : st.top());
int right = i - idx;
res = (res + A[idx] * left * right) % M;
}
st.push(i);
}
return res;
}
};

Leetcode908. Smallest Range I

Given an array A of integers, for each integer A[i] we may choose any x with -K <= x <= K, and add x to A[i]. After this process, we have some array B. Return the smallest possible difference between the maximum value of B and the minimum value of B.

Example 1:

1
2
3
Input: A = [1], K = 0
Output: 0
Explanation: B = [1]

Example 2:
1
2
3
Input: A = [0,10], K = 2
Output: 6
Explanation: B = [2,8]

Example 3:
1
2
3
Input: A = [1,3,6], K = 3
Output: 0
Explanation: B = [3,3,3] or B = [4,4,4]

Note:

  • 1 <= A.length <= 10000
  • 0 <= A[i] <= 10000
  • 0 <= K <= 10000

给了一个非负数的数组,和一个非负数K,说是数组中的每一个数字都可以加上 [-K, K] 范围内的任意一个数字,问新数组的最大值最小值之间的差值最小是多少。这道题的难度是 Easy,理论上应该是可以无脑写代码的,但其实很容易想的特别复杂。本题的解题标签是 Math,这种类型的题目基本上就是一种脑筋急转弯的题目,有时候一根筋转不过来就怎么也做不出来。首先来想,既然是要求新数组的最大值和最小值之间的关系,那么肯定是跟原数组的最大值最小值有着某种联系,原数组的最大值最小值我们可以很容易的得到,只要找出了跟新数组之间的联系,问题就能迎刃而解了。题目中说了每个数字都可以加上 [-K, K] 范围内的数字,当然最大值最小值也可以,如何让二者之间的差值最小呢?当然是让最大值尽可能变小,最小值尽可能变大了,所以最大值 mx 要加上 -K,而最小值 mn 要加上K,然后再做减法,即 (mx-K)-(mn+K) = mx-mn+2K,这就是要求的答案啦。

只要找到数组A 最大值和最小值的差,然后和2k比较即可得到结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int smallestRangeI(vector<int>& A, int K) {
// find max and min
int maxx = INT_MIN;
int minn = INT_MAX;
for(int i=0;i<A.size();i++){
if(A[i]>maxx)
maxx = A[i];
if(A[i]<minn)
minn = A[i];
}
if(minn+K >= maxx-K)
return 0;
else
return maxx - minn - 2 * K;
}
};

Leetcode909. Snakes and Ladders

On an N x N board, the numbers from 1 to N*N are written boustrophedonically starting from the bottom left of the board, and alternating direction each row. For example, for a 6 x 6 board, the numbers are written as follows:

You start on square 1 of the board (which is always in the last row and first column). Each move, starting from square x, consists of the following:

  • You choose a destination square S with number x+1, x+2, x+3, x+4, x+5, or x+6, provided this number is <= N*N.
    (This choice simulates the result of a standard 6-sided die roll: ie., there are always at most 6 destinations, regardless of the size of the board.)
  • If S has a snake or ladder, you move to the destination of that snake or ladder. Otherwise, you move to S.

A board square on row r and column c has a “snake or ladder” if board[r][c] != -1. The destination of that snake or ladder is board[r][c].

Note that you only take a snake or ladder at most once per move: if the destination to a snake or ladder is the start of another snake or ladder, you do not continue moving. (For example, if the board is [[4,-1],[-1,3]], and on the first move your destination square is 2, then you finish your first move at 3, because you do notcontinue moving to 4.)

Return the least number of moves required to reach square N*N. If it is not possible, return -1.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Input: [
[-1,-1,-1,-1,-1,-1],
[-1,-1,-1,-1,-1,-1],
[-1,-1,-1,-1,-1,-1],
[-1,35,-1,-1,13,-1],
[-1,-1,-1,-1,-1,-1],
[-1,15,-1,-1,-1,-1]]
Output: 4
Explanation:
At the beginning, you start at square 1 [at row 5, column 0].
You decide to move to square 2, and must take the ladder to square 15.
You then decide to move to square 17 (row 3, column 5), and must take the snake to square 13.
You then decide to move to square 14, and must take the ladder to square 35.
You then decide to move to square 36, ending the game.
It can be shown that you need at least 4 moves to reach the N*N-th square, so the answer is 4.

这道题给了一个 NxN 大小的二维数组,从左下角从1开始,蛇形游走,到左上角或者右上角到数字为 NxN,中间某些位置会有梯子,就如同传送门一样,直接可以到达另外一个位置。现在就如同玩大富翁 Big Rich Man 一样,有一个骰子,可以走1到6内的任意一个数字,现在奢侈一把,有无限个遥控骰子,每次都可以走1到6以内指定的步数,问最小能用几步快速到达终点 NxN 位置。博主刚开始做这道题的时候,看是求极值,以为是一道动态规划 Dynamic Programming 的题,结果发现木有办法重现子问题,没法写出状态转移方程,只得作罢。但其实博主忽略了一点,求最小值还有一大神器,广度优先搜索 BFS,最直接的应用就是在迷宫遍历的问题中,求从起点到终点的最少步数,也可以用在更 general 的场景,只要是存在确定的状态转移的方式,可能也可以使用。这道题基本就是类似迷宫遍历的问题,可以走的1到6步可以当作六个方向,这样就可以看作是一个迷宫了,唯一要特殊处理的就是遇见梯子的情况,要跳到另一个位置。这道题还有另一个难点,就是数字标号和数组的二维坐标的转换,这里起始点是在二维数组的左下角,且是1,而代码中定义的二维数组的 (0, 0) 点是在左上角,需要转换一下,还有就是这道题的数字是蛇形环绕的,即当行号是奇数的时候,是从右往左遍历的,转换的时候要注意一下。

初始时将数字1放入,然后还需要一个 visited 数组,大小为 nxn+1。在 while 循环中进行层序遍历,取出队首数字,判断若等于 nxn 直接返回结果 res。否则就要遍历1到6内的所有数字i,则 num+i 就是下一步要走的距离,需要将其转为数组的二维坐标位置,这个操作放到一个单独的子函数中,后边再讲。有了数组的坐标,就可以看该位置上是否有梯子,有的话,需要换成梯子能到达的位置,没有的话还是用 num+i。有了下一个位置,再看 visited 中的值,若已经访问过了直接跳过,否则标记为 true,并且加入队列 queue 中即可,若 while 循环退出了,表示无法到达终点,返回 -1。将数字标号转为二维坐标位置的子函数也不算难,首先应将数字标号减1,因为这里是从1开始的,而代码中的二维坐标是从0开始的,然后除以n得到横坐标,对n取余得到纵坐标。但这里得到的横纵坐标都还不是正确的,因为前面说了数字标记是蛇形环绕的,当行号是奇数的时候,列数需要翻转一下,即用 n-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
class Solution {
public:
int snakesAndLadders(vector<vector<int>>& board) {
int len = board.size(), res = 0;
len = len*len;
queue<int> q{{1}};
vector<bool> visited(len + 1);

while (!q.empty()) {
int s = q.size();
for (int i = 0; i < s; i ++) {
int num = q.front(); q.pop();
if (num == len)
return res;
for (int i = 1; i <= 6 && num + i <= len; ++i) {
int next = getBoardValue(board, num + i);
if (next == -1) next = num + i;
if (visited[next]) continue;
visited[next] = true;
q.push(next);
}
}
res ++;
}
return -1;
}

int getBoardValue(vector<vector<int>>& board, int i) {
int len = board.size(), x = (i - 1) / len, y = (i - 1) % len;
if (x % 2 == 1) y = len - 1 - y;
x = len - 1 - x;
return board[x][y];
}
};

Leetcode911. Online Election

In an election, the i-th vote was cast for persons[i] at time times[i].

Now, we would like to implement the following query function: TopVotedCandidate.q(int t) will return the number of the person that was leading the election at time t.

Votes cast at time t will count towards our query. In the case of a tie, the most recent vote (among tied candidates) wins.

Example 1:

1
2
3
4
5
6
7
Input: ["TopVotedCandidate","q","q","q","q","q","q"], [[[0,1,1,0,0,1,0],[0,5,10,15,20,25,30]],[3],[12],[25],[15],[24],[8]]
Output: [null,0,1,1,0,0,1]
Explanation:
At time 3, the votes are [0], and 0 is leading.
At time 12, the votes are [0,1,1], and 1 is leading.
At time 25, the votes are [0,1,1,0,0,1], and 1 is leading (as ties go to the most recent vote.)
This continues for 3 more queries at time 15, 24, and 8.

Note:

  • 1 <= persons.length = times.length <= 5000
  • 0 <= persons[i] <= persons.length
  • times is a strictly increasing array with all elements in [0, 10^9].
  • TopVotedCandidate.q is called at most 10000 times per test case.
  • TopVotedCandidate.q(int t) is always called with t >= times[0].

这道题是关于线上选举的问题,这里给了两个数组 persons 和 times,表示在某个时间点times[i],i这个人把选票投给了 persons[i],现在有一个q函数,输入时间点t,让返回在时间点t时得票最多的人,当得票数相等时,返回最近得票的人。因为查询需求的时间点是任意的,在某个查询时间点可能并没有投票发生,但需要知道当前的票王,当然最傻的办法就是每次都从开头统计到当前时间点,找出票王,但这种方法大概率会超时,正确的方法实际上是要在某个投票的时间点,都统计出当前的票王,然后在查询的时候,查找刚好大于查询时间点的下一个投票时间点,返回前一个时间点的票王即可,所以这里可以使用一个 TreeMap 来建立投票时间点和当前票王之间的映射。如何统计每个投票时间点的票王呢,可以使用一个 count 数组,其中count[i]就表示当前i获得的票数,还需要一个变量 lead,表示当前的票王。现在就可以开始遍历所有的投票了,对于每个投票,将票数加到 count 中对应的人身上,然后跟 lead 比较,若当前人的票数大于等于 lead 的票数,则 lead 更换为当前人,同时建立当前时间点和票王之间的映射。在查询的时候,由于时间点是有序的,所以可以使用二分搜索法,由于使用的是 TreeMap,具有自动排序的功能,可以直接用upper_bound来查找第一个比t大的投票时间,然后再返回上一个投票时间点的票王即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class TopVotedCandidate {
public:
TopVotedCandidate(vector& persons, vector& times) {
int n = persons.size(), lead = 0;
vector<int> count(n + 1);
for (int i = 0; i < n; ++i) {
if (++count[persons[i]] >= count[lead]) {
lead = persons[i];
}
m[times[i]] = lead;
}
}
int q(int t) {
return (--m.upper_bound(t))->second;
}

private:
map<int, int> m;
};

我们也可以用 HashMap 来取代 TreeMap,但因为 HashMap 无法进行时间点的排序,不好使用二分搜索法了,所以就需要记录投票时间数组 times,保存在一个私有变量中。在查询函数中自己来写二分搜索法,查找第一个大于目标值的数。由于要返回上一个投票时间点,所以要记得减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
class TopVotedCandidate {
public:
TopVotedCandidate(vector& persons, vector& times) {
int n = persons.size(), lead = 0;
vector<int> count(n + 1);
this->times = times;
for (int i = 0; i < n; ++i) {
if (++count[persons[i]] >= count[lead]) {
lead = persons[i];
}
m[times[i]] = lead;
}
}
int q(int t) {
int left = 0, right = times.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (times[mid] <= t) left = mid + 1;
else right = mid;
}
return m[times[right - 1]];
}

private:
unordered_map<int, int> m;
vector times;
};

Leetcode912. Sort an Array

Given an array of integers nums, sort the array in ascending order.

Example 1:

1
2
Input: [5,2,3,1]
Output: [1,2,3,5]

Example 2:

1
2
Input: [5,1,1,2,0,0]
Output: [0,0,1,1,2,5]

Note:

  • 1 <= A.length <= 10000
  • -50000 <= A[i] <= 50000

这道题让我们给数组排序,常见排序方法有很多,插入排序,选择排序,堆排序,快速排序,冒泡排序,归并排序,桶排序等等。它们的时间复杂度不尽相同,这道题貌似对于平方级复杂度的排序方法会超时,所以只能使用那些速度比较快的排序方法啦。题目给定了每个数字的范围是 [-50000, 50000],并不是特别大,这里可以使用记数排序 Count Sort,建立一个大小为 100001 的数组 count,然后统计 nums 中每个数字出现的个数,然后再从0遍历到 100000,对于每个遍历到的数字i,若个数不为0,则加入 count 数组中对应个数的 i-50000 到结果数组中,这里的 50000 是 offset,因为数组下标不能为负数,在开始统计个数的时候,每个数字都加上了 50000,那么最后组成有序数组的时候就要减去,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
vector sortArray(vector& nums) {
int n = nums.size(), j = 0;
vector<int> res(n), count(100001);
for (int num : nums) ++count[num + 50000];
for (int i = 0; i < count.size(); ++i) {
while (count[i]-- > 0) {
res[j++] = i - 50000;
}
}
return res;
}
};

下面是快速排序。快排的精髓在于选一个 pivot,然后将所有小于 pivot 的数字都放在左边,大于 pivot 的数字都放在右边,等于的话放哪边都行。但是此时左右两边的数组各自都不一定是有序的,需要再各自调用相同的递归,直到细分到只有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
class Solution {
public:
vector<int> sortArray(vector<int>& nums) {
quick(nums, 0, (int)nums.size()-1);
return nums;
}

void quick(vector<int>& nums, int start, int end) {
if (start >= end)
return;
int pivot = nums[(start+end)/2], i = start, j = end;
while(i <= j) {
while(i <= j && nums[i] < pivot)
i ++;
while(i <= j && nums[j] > pivot)
j --;
if (i <= j) {
swap(nums[i], nums[j]);
i ++;
j --;
}
}
quick(nums, start, j);
quick(nums, i, end);
}
};

Leetcode914. X of a Kind in a Deck of Cards

In a deck of cards, each card has an integer written on it.

Return true if and only if you can choose X >= 2 such that it is possible to split the entire deck into 1 or more groups of cards, where:

Each group has exactly X cards.
All the cards in each group have the same integer.

Example 1:

1
2
3
Input: deck = [1,2,3,4,4,3,2,1]
Output: true
Explanation: Possible partition [1,1],[2,2],[3,3],[4,4].

Example 2:
1
2
3
Input: deck = [1,1,1,2,2,2,3,3]
Output: false´
Explanation: No possible partition.

Example 3:
1
2
3
Input: deck = [1]
Output: false
Explanation: No possible partition.

Example 4:
1
2
3
Input: deck = [1,1]
Output: true
Explanation: Possible partition [1,1].

Example 5:
1
2
3
Input: deck = [1,1,2,2,2,2]
Output: true
Explanation: Possible partition [1,1],[2,2],[2,2].

Constraints:

  • 1 <= deck.length <= 10^4
  • 0 <= deck[i] < 10^4

1、这道题给定一个vector,vector中存放着卡牌的数字,比如1、2、3、4这样子,你需要把这些卡牌分成多组。要求同一组中的卡牌数字一致,并且每一组中的卡牌张数一样。比如123321,你就可以分成[1,1],[2,2],[3,3]。如果可以这样分组,并且组中卡牌张数大于等于2,那么返回true,否则返回false。限制卡牌数字在[0,10000),vector中的卡牌张数在[1,10000]。

2、我们最开始可以用vector也可以用map,来存放各个数字的卡牌各有多少张。(笔者一开始的错误想法:这里用先排序后遍历的做法,有点傻,因为排序O(nlogn)的时间复杂度太高了,还不如直接遍历。)得到各个数字卡牌的张数之后,我们需要看一下是否可以分组。这里有个地方要注意下,比如卡牌1有4张,卡牌2有6张,是否可以分组呢?可以的,每组2张就可以了,卡牌1有2组,卡牌2有3组。也就是说,我们要求各种数字卡牌的张数的最大公约数,看一下最大公约数是否大于等于2。而不能简单地看各种数字卡牌的张数是否一致。

但是求集体的最大公约数太麻烦了,还不如直接从2开始,判断所有数字可不可以整除2。如果可以,那么返回true。如果不行,看一下是否可以整除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
class Solution {
public:
bool hasGroupsSizeX(vector<int>& deck) {
unordered_map<int, int> mapp;
for(int i = 0; i < deck.size(); i ++)
mapp[deck[i]] ++;
int minn = 9999999;
for(auto it = mapp.begin(); it != mapp.end(); it ++)
minn = min(minn, it->second);
int flag;
for(int i = 2; i <= minn; i ++) {
flag = 0;
for(auto it = mapp.begin(); it != mapp.end(); it ++) {
if(it->second % i != 0) {
flag = 1;
break;
}
}
if(flag == 0)
return true;
}
return false;
}
};

Leetcode915. Partition Array into Disjoint Intervals

Given an array A, partition it into two (contiguous) subarrays left and right so that:

  • Every element in left is less than or equal to every element in right.
  • left and right are non-empty.
  • left has the smallest possible size.

Return the length of left after such a partitioning. It is guaranteed that such a partitioning exists.

Example 1:

1
2
3
Input: [5,0,3,8,6]
Output: 3
Explanation: left = [5,0,3], right = [8,6]

Example 2:

1
2
3
Input: [1,1,1,0,6,12]
Output: 4
Explanation: left = [1,1,1,0], right = [6,12]

Note:

  • 2 <= A.length <= 30000
  • 0 <= A[i] <= 10^6
  • It is guaranteed there is at least one way to partition A as described.

这道题说是给了一个数组A,让我们分成两个相邻的子数组 left 和 right,使得 left 中的所有数字小于等于 right 中的,并限定了每个输入数组必定会有这么一个分割点,让返回数组 left 的长度。这道题并不算一道难题,当然最简单并暴力的方法就是遍历所有的分割点,然后去验证左边的数组是否都小于等于右边的数,这种写法估计会超时,这里就不去实现了。直接来想优化解法吧,由于分割成的 left 和 right 数组本身不一定是有序的,只是要求 left 中的最大值要小于等于 right 中的最小值,只要这个条件满足了,一定就是符合题意的分割。left 数组的最大值很好求,在遍历数组的过程中就可以得到,而 right 数组的最小值怎么求呢?其实可以反向遍历数组,并且使用一个数组 backMin,其中 backMin[i] 表示在范围 [i, n-1] 范围内的最小值,有了这个数组后,再正向遍历一次数组,每次更新当前最大值 curMax,这就是范围 [0, i] 内的最大值,通过 backMin 数组快速得到范围 [i+1, n-1] 内的最小值,假如 left 的最大值小于等于 right 的最小值,则 i+1 就是 left 的长度,直接返回即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int partitionDisjoint(vector& A) {
int n = A.size(), curMax = INT_MIN;
vector<int> backMin(n, A.back());
for (int i = n - 2; i >= 0; --i) {
backMin[i] = min(backMin[i + 1], A[i]);
}
for (int i = 0; i < n - 1; ++i) {
curMax = max(curMax, A[i]);
if (curMax <= backMin[i + 1]) return i + 1;
}
return 0;
}
};

下面来看论坛上的主流解法,只需要一次遍历即可,并且不需要额外的空间,这里使用三个变量,partitionIdx 表示分割点的位置,preMax 表示 left 中的最大值,curMax 表示当前的最大值。思路是遍历每个数字,更新当前最大值 curMax,并且判断若当前数字 A[i] 小于 preMax,说明这个数字也一定是属于 left 数组的,此时整个遍历到的区域应该都是属于 left 的,所以 preMax 要更新为 curMax,并且当前位置也就是潜在的分割点,所以 partitionIdx 更新为i。由于题目中限定了一定会有分割点,所以这种方法是可以得到正确结果的,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int partitionDisjoint(vector& A) {
int partitionIdx = 0, preMax = A[0], curMax = preMax;
for (int i = 1; i < A.size(); ++i) {
curMax = max(curMax, A[i]);
if (A[i] < preMax) {
preMax = curMax;
partitionIdx = i;
}
}
return partitionIdx + 1;
}
};

Leetcode916. Word Subsets

We are given two arrays A and B of words. Each word is a string of lowercase letters.

Now, say that word b is a subset of word a if every letter in b occurs in a, including multiplicity. For example, “wrr” is a subset of “warrior”, but is not a subset of “world”.

Now say a word a from A is universal if for every b in B, b is a subset of a.

Return a list of all universal words in A. You can return the words in any order.

Example 1:

1
2
Input: A = ["amazon","apple","facebook","google","leetcode"], B = ["e","o"]
Output: ["facebook","google","leetcode"]

Example 2:

1
2
Input: A = ["amazon","apple","facebook","google","leetcode"], B = ["l","e"]
Output: ["apple","google","leetcode"]

Example 3:

1
2
Input: A = ["amazon","apple","facebook","google","leetcode"], B = ["e","oo"]
Output: ["facebook","google"]

Example 4:

1
2
Input: A = ["amazon","apple","facebook","google","leetcode"], B = ["lo","eo"]
Output: ["google","leetcode"]

Example 5:

1
2
Input: A = ["amazon","apple","facebook","google","leetcode"], B = ["ec","oc","ceo"]
Output: ["facebook","leetcode"]

Note:

  • 1 <= A.length, B.length <= 10000
  • 1 <= A[i].length, B[i].length <= 10
  • A[i] and B[i] consist only of lowercase letters.
  • All words in A[i] are unique: there isn’t i != j with A[i] == A[j].

这道题定义了两个单词之间的一种子集合关系,就是说假如单词b中的每个字母都在单词a中出现了(包括重复字母),就说单词b是单词a的子集合。现在给了两个单词集合A和B,让找出集合A中的所有满足要求的单词,使得集合B中的所有单词都是其子集合。配合上题目中给的一堆例子,意思并不难理解,根据子集合的定义关系,其实就是说若单词a中的每个字母的出现次数都大于等于单词b中每个字母的出现次数,单词b就一定是a的子集合。现在由于集合B中的所有单词都必须是A中某个单词的子集合,那么其实只要对于每个字母,都统计出集合B中某个单词中出现的最大次数,比如对于这个例子,B=[“eo”,”oo”],其中e最多出现1次,而o最多出现2次,那么只要集合A中有单词的e出现不少1次,o出现不少于2次,则集合B中的所有单词一定都是其子集合。这就是本题的解题思路,这里使用一个大小为 26 的一维数组 charCnt 来统计集合B中每个字母的最大出现次数,而将统计每个单词的字母次数的操作放到一个子函数 helper 中,当 charCnt 数组更新完毕后,下面就开始检验集合A中的所有单词了。对于每个遍历到的单词,还是要先统计其每个字母的出现次数,然后跟 charCnt 中每个位置上的数字比较,只要均大于等于 charCnt 中的数字,就可以加入到结果 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
class Solution {
public:
vector<string> wordSubsets(vector<string>& words1, vector<string>& words2) {
vector<int> char_num(26, 0);
vector<string> res;
for (int i = 0; i < words2.size(); i ++) {
vector<int> t = helper(words2[i]);
for (int j = 0; j < 26; j ++)
char_num[j] = max(char_num[j], t[j]);
}

for (int i = 0; i < words1.size(); i ++) {
vector<int> t = helper(words1[i]);
int j = 0;
for (j = 0; j < 26; j ++)
if (t[j] < char_num[j])
break;
if (j == 26)
res.push_back(words1[i]);
}
return res;
}

vector<int> helper(string a) {
vector<int> res(26, 0);
for (int i = 0; i < a.length(); i ++)
res[a[i] - 'a'] ++;
return res;
}
};

Leetcode917. Reverse Only Letters

Given a string S, return the “reversed” string where all characters that are not a letter stay in the same place, and all letters reverse their positions.

Example 1:

1
2
Input: "ab-cd"
Output: "dc-ba"

Example 2:
1
2
Input: "a-bC-dEf-ghIj"
Output: "j-Ih-gfE-dCba"

Example 3:
1
2
Input: "Test1ng-Leet=code-Q!"
Output: "Qedo1ct-eeLg=ntse-T!"

Note:

  • S.length <= 100
  • 33 <= S[i].ASCIIcode <= 122
  • S doesn’t contain \ or “

给定一个字符串 S,返回 “反转后的” 字符串,其中不是字母的字符都保留在原地,而所有字母的位置发生反转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:

bool isletter(char b) {
if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z'))
return true;
else
return false;
}

string reverseOnlyLetters(string S) {
int i = 0, j = S.length();
while(i < j) {
if(!isletter(S[i]))
i ++;
else if(!isletter(S[j]))
j --;
else {
char c = S[i];
S[i] = S[j];
S[j] = c;
i ++;
j --;
}
}
return S;
}
};

Leetcode918. Maximum Sum Circular Subarray

Given a circular array C of integers represented by A, find the maximum possible sum of a non-empty subarray of C.

Here, a circular array means the end of the array connects to the beginning of the array. (Formally, C[i] = A[i]when 0 <= i < A.length, and C[i+A.length] = C[i] when i >= 0.)

Also, a subarray may only include each element of the fixed buffer A at most once. (Formally, for a subarray C[i], C[i+1], ..., C[j], there does not exist i <= k1, k2 <= j with k1 % A.length = k2 % A.length.)

Example 1:

1
2
3
Input: [1,-2,3,-2]
Output: 3
Explanation: Subarray [3] has maximum sum 3

Example 2:

1
2
Input: [5,-3,5]
Output: 10 Explanation: Subarray [5,5] has maximum sum 5 + 5 = 10

Example 3:

1
2
3
Input: [3,-1,2,-1]
Output: 4
Explanation: Subarray [2,-1,3] has maximum sum 2 + (-1) + 3 = 4

Example 4:

1
2
Input: [3,-2,2,-3]
Output: 3 Explanation: Subarray [3] and [3,-2,2] both have maximum sum 3

Example 5:

1
2
Input: [-2,-3,-1]
Output: -1 Explanation: Subarray [-1] has maximum sum -1

Note:

  • -30000 <= A[i] <= 30000
  • 1 <= A.length <= 30000

这道题让求环形子数组的最大和,既然是子数组,则意味着必须是相连的数字,而由于环形数组的存在,说明可以首尾相连,这样的话,最长子数组的范围可以有两种情况,一种是正常的,数组中的某一段子数组,另一种是分为两段的,即首尾相连的。对于第一种情况,其实就是之前那道题 Maximum Subarray 的做法,对于第二种情况,需要转换一下思路,除去两段的部分,中间剩的那段子数组其实是和最小的子数组,只要用之前的方法求出子数组的最小和,用数组总数字和一减,同样可以得到最大和。两种情况的最大和都要计算出来,取二者之间的较大值才是真正的和最大的子数组。但是这里有个 corner case 需要注意一下,假如数组中全是负数,那么和最小的子数组就是原数组本身,则求出的差值是0,而第一种情况求出的和最大的子数组也应该是负数,那么二者一比较,返回0就不对了,所以这种特殊情况需要单独处理一下,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int maxSubarraySumCircular(vector& A) {
int sum = 0, mn = INT_MAX, mx = INT_MIN, curMax = 0, curMin = 0;
for (int num : A) {
curMin = min(curMin + num, num);
mn = min(mn, curMin);
curMax = max(curMax + num, num);
mx = max(mx, curMax);
sum += num;
}
return (sum - mn == 0) ? mx : max(mx, sum - mn);
}
};

Leetcode921. Minimum Add to Make Parentheses Valid

Given a string S of ‘(‘ and ‘)’ parentheses, we add the minimum number of parentheses ( ‘(‘ or ‘)’, and in any positions ) so that the resulting parentheses string is valid.

Formally, a parentheses string is valid if and only if:

It is the empty string, or
It can be written as AB (A concatenated with B), where A and B are valid strings, or
It can be written as (A), where A is a valid string.
Given a parentheses string, return the minimum number of parentheses we must add to make the resulting string valid.

Example 1:

1
2
Input: "())"
Output: 1

Example 2:
1
2
Input: "((("
Output: 3

Example 3:
1
2
Input: "()"
Output: 0

Example 4:
1
2
Input: "()))(("
Output: 4

Note:

S.length <= 1000
S only consists of ‘(‘ and ‘)’ characters.

一道变形的括号匹配,这里注意如果res为负数的话,要及时纠正成正的且也要在最终结果加一,如上边的Example4的样子,如果只是按照栈的做法,结果是0,是错的,其实要加4个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int minAddToMakeValid(string S) {
int res=0,result=0;
for(int i=0;i<S.size();i++)
if(S[i]=='(')
res++;
else{
res--;
if(res<0){
res=0;
result++;
}
}
return result+res;
}
};

Leetcode922. Sort Array By Parity II

Given an array A of non-negative integers, half of the integers in A are odd, and half of the integers are even.

Sort the array so that whenever A[i] is odd, i is odd; and whenever A[i] is even, i is even.

You may return any answer array that satisfies this condition.

Example 1:

1
2
Input: [4,2,5,7]
Output: [4,5,2,7]

Explanation: [4,7,2,5], [2,5,4,7], [2,7,4,5] would also have been accepted.

Note:

2 <= A.length <= 20000
A.length % 2 == 0
0 <= A[i] <= 1000

首先,将所有偶数元素放在正确的位置就足够了,因为所有奇数元素也都在正确的位置。 所以我们只关注A [0],A [2],A [4],……

理想情况下,我们希望有一些分区,左边的所有内容都已经正确,右边的所有内容都是未定的。
实际上,如果我们把它分成两个切片,即偶数= A [0],A [2],A [4],……和奇数= A [1],A [3],A [5],这个想法是有效的, ….我们的不变量将是偶数切片中的所有小于i的位置都是正确的,并且奇数切片中小于j的所有位置都是正确的。

对于每个偶数,让我们使A[i]也为偶数。 为此,我们将从奇数切片中提取一个元素。 我们将j传递到奇数切片,直到找到偶数元素,然后交换。 我们的不变量得以维持,因此算法是正确的。

就是说对每一个偶数位置的奇数,在奇数位置找一个偶数,然后交换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
vector<int> sortArrayByParityII(vector<int>& A) {
int j = 1;
for(int i = 0; i < A.size(); i += 2){
if(A[i] % 2 == 1){
while(A[j] % 2 == 1)
j += 2;
int temp = A[i];
A[i] = A[j];
A[j] = temp;
}
}
return A;
}
};

Leetcode925. Long Pressed Name

Your friend is typing his name into a keyboard. Sometimes, when typing a character c, the key might get long pressed, and the character will be typed 1 or more times.

You examine the typed characters of the keyboard. Return True if it is possible that it was your friends name, with some characters (possibly none) being long pressed.

Example 1:

1
2
3
Input: name = "alex", typed = "aaleex"
Output: true
Explanation: 'a' and 'e' in 'alex' were long pressed.

Example 2:
1
2
3
Input: name = "saeed", typed = "ssaaedd"
Output: false
Explanation: 'e' must have been pressed twice, but it wasn't in the typed output.

Example 3:
1
2
Input: name = "leelee", typed = "lleeelee"
Output: true

Example 4:
1
2
3
Input: name = "laiden", typed = "laiden"
Output: true
Explanation: It's not necessary to long press any character.

Constraints:

  • 1 <= name.length <= 1000
  • 1 <= typed.length <= 1000
  • The characters of name and typed are lowercase letters.
1
2
3
4
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:
bool isLongPressedName(string name, string typed) {
if(name[0] != typed[0])
return false;
int count1, count2, i, j;
int length1 = name.length(), length2 = typed.length();
for(i = 1, j = 1; i < length1 || j < length2; i ++, j ++) {
if(name[i-1] != typed[j-1])
return false;
count1 = 1;
count2 = 1;
while(name[i] == name[i-1]) {
count1 ++;
i ++;
}
while(typed[j] == typed[j-1]) {
count2 ++;
j ++;
}
cout<< count1 << " " << count2 << endl;
if(count2 < count1)
return false;
}
if(name[length1-1] != typed[length2-1])
return false;
return true;
}
};

不简单……边界条件很多,而且很麻烦。

Leetcode926. Flip String to Monotone Increasing

A string of ‘0’s and ‘1’s is monotone increasing if it consists of some number of ‘0’s (possibly 0), followed by some number of ‘1’s (also possibly 0.)

We are given a string S of ‘0’s and ‘1’s, and we may flip any ‘0’ to a ‘1’ or a ‘1’ to a ‘0’.

Return the minimum number of flips to make S monotone increasing.

Example 1:

1
2
3
Input: "00110"
Output: 1
Explanation: We flip the last digit to get 00111.

Example 2:

1
2
3
Input: "010110"
Output: 2
Explanation: We flip to get 011111, or alternatively 000111.

Example 3:

1
2
3
Input: "00011000"
Output: 2
Explanation: We flip to get 00000000.

Note:

  • 1 <= S.length <= 20000
  • S only consists of ‘0’ and ‘1’ characters.

这道题给了我们一个只有0和1的字符串,现在说是可以将任意位置的数翻转,即0变为1,或者1变为0,让组成一个单调递增的序列,即0必须都在1的前面,博主刚开始想的策略比较直接,就是使用双指针分别指向开头和结尾,开头的指针先跳过连续的0,末尾的指针向前跳过连续的1,然后在中间的位置分别记录0和1的个数,返回其中较小的那个。这种思路乍看上去没什么问题,但是实际上是有问题的,比如对于这个例子 “10011111110010111011”,如果按这种思路的话,就应该将所有的0变为1,从而返回6,但实际上更优的解法是将第一个1变为0,将后4个0变为1即可,最终可以返回5,这说明了之前的解法是不正确的。这道题可以用动态规划 Dynamic Programming 来做,需要使用两个 dp 数组,其中 cnt1[i] 表示将范围是 [0, i-1] 的子串内最小的将1转为0的个数,从而形成单调字符串。同理,cnt0[j] 表示将范围是 [j, n-1] 的子串内最小的将0转为1的个数,从而形成单调字符串。这样最终在某个位置使得 cnt0[i]+cnt1[i] 最小的时候,就是变为单调串的最优解,这样就可以完美的解决上面的例子,子串 “100” 的最优解是将1变为0,而后面的 “11111110010111011” 的最优解是将4个0变为1,总共加起来就是5,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int minFlipsMonoIncr(string S) {
int n = S.size(), res = INT_MAX;
vector<int> cnt1(n + 1), cnt0(n + 1);
for (int i = 1, j = n - 1; j >= 0; ++i, --j) {
cnt1[i] += cnt1[i - 1] + (S[i - 1] == '0' ? 0 : 1);
cnt0[j] += cnt0[j + 1] + (S[j] == '1' ? 0 : 1);
}
for (int i = 0; i <= n; ++i) res = min(res, cnt1[i] + cnt0[i]);
return res;
}
};

我们可以进一步优化一下空间复杂度,用一个变量 cnt1 来记录当前位置时1出现的次数,同时 res 表示使到当前位置的子串变为单调串的翻转次数,用来记录0的个数,因为遇到0就翻1一定可以组成单调串,但不一定是最优解,每次都要和 cnt1 比较以下,若 cnt1 较小,就将 res 更新为 cnt1,此时保证了到当前位置的子串变为单调串的翻转次数最少,并不关心到底是把0变为1,还是1变为0了,其实核心思想跟上面的解法很相近,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int minFlipsMonoIncr(string S) {
int n = S.size(), res = 0, cnt1 = 0;
for (int i = 0; i < n; ++i) {
(S[i] == '0') ? ++res : ++cnt1;
res = min(res, cnt1);
}
return res;
}
};

Leetcode929. Unique Email Addresses

Every email consists of a local name and a domain name, separated by the @ sign. For example, in alice@leetcode.com, alice is the local name, and leetcode.com is the domain name. Besides lowercase letters, these emails may contain ‘.’s or ‘+’s.

If you add periods (‘.’) between some characters in the local name part of an email address, mail sent there will be forwarded to the same address without dots in the local name. For example, “alice.z@leetcode.com” and “alicez@leetcode.com” forward to the same email address. (Note that this rule does not apply for domain names.)

If you add a plus (‘+’) in the local name, everything after the first plus sign will be ignored. This allows certain emails to be filtered, for example m.y+name@email.com will be forwarded to my@email.com. (Again, this rule does not apply for domain names.)

It is possible to use both of these rules at the same time.

Given a list of emails, we send one email to each address in the list. How many different addresses actually receive mails?

Example 1:

1
2
3
Input: ["test.email+alex@leetcode.com","test.e.mail+bob.cathy@leetcode.com","testemail+david@lee.tcode.com"]
Output: 2
Explanation: "testemail@leetcode.com" and "testemail@lee.tcode.com" actually receive mails

Note:

  • 1 <= emails[i].length <= 100
  • 1 <= emails.length <= 100
  • Each emails[i] contains exactly one ‘@’ character.
  • All local and domain names are non-empty.
  • Local names do not start with a ‘+’ character.

字符串处理,如果一个email地址里有点(‘.’)的话,就忽略这个点,如果有加号(‘+’),忽略这个加号到(‘@’)之间的字符。判断一共有几个一样的email地址。不难,但是涉及字符串处理的话总归有些麻烦的。

1
2
3
4
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:
int numUniqueEmails(vector<string>& emails) {
char result[100][100];
memset(result,'\0',sizeof(result));
int result_len=0;
for(unsigned int i=0; i<emails.size(); i++)
{
char temp[100];
memset(temp,'\0',100);
int temp_len = 0, k=0;
unsigned int j;
for(j=0; j<emails[i].length(); j++)
{
if(emails[i][j]=='.')
continue;
else if(emails[i][j]=='+' || emails[i][j]=='@' )
break;
else
temp[temp_len++]=emails[i][j];
}
for(j=emails[i].find('@'); j<emails[i].length(); j++)
temp[temp_len++]=emails[i][j];

for(k=0; k<result_len; k++)
if(strcmp(result[k],temp)==0)
break;
if(k==result_len)
memcpy(result[result_len++], temp, sizeof(temp));
memset(temp,'\0',100);
}
return result_len;
}
};

我的另一种做法,用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
class Solution {
public:
int numUniqueEmails(vector<string>& emails) {
set<string> result;
for(int i = 0; i < emails.size(); i ++) {
string temp;
int j;
for(j = 0; j < emails[i].size(); j ++) {
if(emails[i][j] == '+' || emails[i][j] == '@')
break;
if(emails[i][j] == '.')
continue;
temp += emails[i][j];
}
while(emails[i][j] != '@')
j ++;
for(; j < emails[i].size(); j ++) {
temp += emails[i][j];
}
result.insert(temp);
}
return result.size();
}
};

解析:
For each email address, convert it to the canonical address that actually receives the mail. This involves a few steps:

  • Separate the email address into a local part and the rest of the address.
  • If the local part has a ‘+’ character, remove it and everything beyond it from the local part.
  • Remove all the zeros from the local part.
  • The canonical address is local + rest.

After, we can count the number of unique canonical addresses with a Set structure.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int numUniqueEmails(String[] emails) {
Set<String> seen = new HashSet();
for (String email: emails) {
int i = email.indexOf('@');
String local = email.substring(0, i);
String rest = email.substring(i);
if (local.contains("+")) {
local = local.substring(0, local.indexOf('+'));
}
local = local.replaceAll(".", "");
seen.add(local + rest);
}

return seen.size();
}
}

Leetcode930. Binary Subarrays With Sum

In an array A of 0s and 1s, how many non-empty subarrays have sum S?

Example 1:

1
2
3
4
5
6
7
8
Input: A = [1,0,1,0,1], S = 2
Output: 4
Explanation:
The 4 subarrays are bolded below:
[1,0,1]
[1,0,1,0]
[0,1,0,1]
[1,0,1]

Note:

  • A.length <= 30000
  • 0 <= S <= A.length
  • A[i] is either 0 or 1.

这道题给了我们一个只由0和1组成的数组A,还有一个整数S,问数组A中有多少个子数组使得其和正好为S。博主最先没看清题意,以为是按二进制数算的,但是看了例子之后才发现,其实只是单纯的求和而已。那么马上就想着应该是要建立累加和数组的,然后遍历所有的子数组之和,但是这个遍历的过程还是平方级的复杂度,这道题的 OJ 卡的比较严格,只放行线性的时间复杂度。所以这种遍历方式是不行的,但仍需要利用累加和的思路,具体的方法是在遍历的过程中使用一个变量 curSum 来记录当前的累加和,同时使用一个 HashMap,用来映射某个累加出现的次数,初始化需要放入 {0,1} 这个映射对儿,后面会讲解原因。在遍历数组的A的时候,对于每个遇到的数字 num,都加入累加和 curSum 中,然后看若 curSum-S 这个数有映射值的话,那么说明就存在 m[curSum-S] 个符合题意的子数组,应该加入到结果 res 中,假如 curSum 正好等于S,即 curSum-S=0 的时候,此时说明从开头到当前位置正好是符合题目要求的子数组,现在明白刚开始为啥要加入 {0,1} 这个映射对儿了吧,就是为了处理这种情况。然后此时 curSum 的映射值自增1即可。其实这道题的解法思路跟之前那道 Contiguous Array 是一样的,那道题是让找0和1个数相同的子数组,这里让找和为S的子数组,都可以用一个套路来解题,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
int res = 0, curSum = 0;
unordered_map<int, int> m{{0, 1}};
for (int num : A) {
curSum += num;
res += m[curSum - S];
++m[curSum];
}
return res;
}
};

我们也可以使用滑动窗口 Sliding Window 来做,也是线性的时间复杂度,其实还是利用到了累计和的思想,不过这个累加和不是从开头到当前位置之和,而是这个滑动窗口内数字之和,这 make sense 吧,因为只要这个滑动窗口内数字之和正好等于S了,即是符合题意的一个子数组。遍历数组A,将当前数字加入 sum 中,然后看假如此时 sum 大于S了,则要进行收缩窗口操作,左边界 left 右移,并且 sum 要减去这个移出窗口的数字,当循环退出后,假如此时 sum 小于S了,说明当前没有子数组之和正好等于S,若 sum 等于S了,则结果 res 自增1。此时还需要考虑一种情况,就是当窗口左边有连续0的时候,因为0并不影响 sum,但是却要算作不同的子数组,所以要统计左起连续0的个数,并且加到结果 res 中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int numSubarraysWithSum(vector<int>& A, int S) {
int res = 0, sum = 0, left = 0, n = A.size();
for (int i = 0; i < n; ++i) {
sum += A[i];
while (left < i && sum > S) sum -= A[left++];
if (sum < S) continue;
if (sum == S) ++res;
for (int j = left; j < i && A[j] == 0; ++j) {
++res;
}
}
return res;
}
};

Leetcode931. Minimum Falling Path Sum

Given a square array of integers A, we want the minimum sum of a falling path through A.

A falling path starts at any element in the first row, and chooses one element from each row. The next row’s choice must be in a column that is different from the previous row’s column by at most one.

Example 1:

1
2
3
4
5
6
7
8
9
Input: [[1,2,3],[4,5,6],[7,8,9]]
Output: 12
Explanation:
The possible falling paths are:

- `[1,4,7], [1,4,8], [1,5,7], [1,5,8], [1,5,9]`
- `[2,4,7], [2,4,8], [2,5,7], [2,5,8], [2,5,9], [2,6,8], [2,6,9]`
- `[3,5,7], [3,5,8], [3,5,9], [3,6,8], [3,6,9]`
The falling path with the smallest sum is [1,4,7], so the answer is 12.

Note:

  • 1 <= A.length == A[0].length <= 100
  • -100 <= A[i][j] <= 100

这道题给了一个长宽相等的二维数组,说是让找一个列路径,使得相邻两个位置的数的距离不超过1,可以通过观察题目中给的例子来理解题意。由于每个位置上的累加值是由上一行的三个位置中较小的那个决定的,所以这就是一道典型的动态规划 Dynamic Programming 的题,为了节省空间,直接用数组A本身当作 dp 数组,其中 A[i][j] 就表示最后一个位置在 (i, j) 的最小的下降路径,则最终只要在最后一行中找最小值就是所求。由于要看上一行的值,所以要从第二行开始遍历,那么首先判断一下数组是否只有一行,是的话直接返回那个唯一的数字即可。否则从第二行开始遍历,一定存在的是 A[i-1][j] 这个数字,而它周围的两个数字需要判断一下,存在的话才进行比较取较小值,将最终的最小值加到当前的 A[i][j] 上即可。为了避免重新开一个 for 循环,判断一下,若当前是最后一行,则更新结果 res,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int minFallingPathSum(vector<vector<int>>& A) {
if (A.size() == 1) return A[0][0];
int n = A.size(), res = INT_MAX;
for (int i = 1; i < n; ++i) {
for (int j = 0; j < n; ++j) {
int pre = A[i - 1][j];
if (j > 0) pre = min(pre, A[i - 1][j - 1]);
if (j < n - 1) pre = min(pre, A[i - 1][j + 1]);
A[i][j] += pre;
if (i == n - 1) res = min(res, A[i][j]);
}
}
return res;
}
};

Leetcode932. Beautiful Array

For some fixed N, an array A is beautiful if it is a permutation of the integers 1, 2, …, N, such that:

For every i < j, there is no k with i < k < j such that A[k] * 2 = A[i] + A[j].

Given N, return any beautiful array A. (It is guaranteed that one exists.)

Example 1:

1
2
Input: 4
Output: [2,1,4,3]

Example 2:

1
2
Input: 5
Output: [3,1,2,5,4]

Note:

  • 1 <= N <= 1000

这道题定义了一种漂亮数组,说的是在任意两个数字之间,不存在一个正好是这两个数之和的一半的数字,现在让返回长度是N的一个漂亮数组,注意这里长度是N的漂亮数组一定是由1到N之间的数字组成的,每个数字都会出现,而且一定存在这样的漂亮数组。博主刚开始时是没什么头绪的,想着总不会是要遍历所有的排列情况,然后对每个情况去验证是否是漂亮数组的吧,想想都觉得很不高效,于是就放弃挣扎,直接逛论坛了。不出意外,最高票的还是你李哥,居然提出了逆天的线性时间的解法,献上膝盖,怪不得有网友直接要 Venmo 号立马打钱,LOL~ 这道题给了提示说是要用分治法来做,但是怎么分是这道题的精髓,若只是普通的对半分,那么在 merge 的时候还是要验证是否是漂亮数组,麻烦!但若按奇偶来分的话,那就非常的叼了,因为奇数加偶数等于奇数,就不会是任何一个数字的2倍了。这就是奇偶分堆的好处,这时任意两个数字肯定不能分别从奇偶堆里取了,那可能你会问,奇数堆会不会有三个奇数打破这个规则呢?只要这个奇数堆是从一个漂亮数组按固定的规则变化而来的,就能保证一定也是漂亮数组,因为对于任意一个漂亮数组,若对每个数字都加上一个相同的数字,或者都乘上一个相同的数字,则一定还是漂亮数组,因为数字的之间的内在关系并没有改变。明白了上面这些,基本就可以解题了,假设此时已经有了一个长度为n的漂亮数组,如何将其扩大呢?可以将其中每个数字都乘以2并加1,就都会变成奇数,并且这个奇数数组还是漂亮的,然后再将每个数字都乘以2,那么都会变成偶数,并且这个偶数数组还是漂亮的,两个数组拼接起来,就会得到一个长度为 2n 的漂亮数组。就是这种思路,可以从1开始,1本身就是一个漂亮数组,然后将其扩大,注意这里要卡一个N,不能让扩大的数组长度超过N,只要在变为奇数和偶数时加个判定就行了,将不大于N的数组加入到新的数组中,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> beautifulArray(int N) {
vector<int> res{1};
while (res.size() < N) {
vector<int> t;
for (int num : res) {
if (num * 2 - 1 <= N) t.push_back(num * 2 - 1);
}
for (int num : res) {
if (num * 2 <= N) t.push_back(num * 2);
}
res = t;
}
return res;
}
};

Leetcode933. Number of Recent Calls

Write a class RecentCounter to count recent requests. It has only one method: ping(int t), where t represents some time in milliseconds. Return the number of pings that have been made from 3000 milliseconds ago until now. Any ping with time in [t - 3000, t] will count, including the current ping. It is guaranteed that every call to ping uses a strictly larger value of t than before.

Example 1:

1
2
Input: inputs = ["RecentCounter","ping","ping","ping","ping"], inputs = [[],[1],[100],[3001],[3002]]
Output: [null,1,2,3,3]

Note:

  • Each test case will have at most 10000 calls to ping.
  • Each test case will call ping with strictly increasing values of t.
  • Each call to ping will have 1 <= t <= 10^9.

行吧,我是没看懂这个题是什么意思。。。只是判断t和3000的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RecentCounter {
public:
queue<int> q;
RecentCounter() {

}

int ping(int t) {
q.push(t);
while(q.front()<t-3000)
q.pop();
return q.size();
}
};

Leetcode934. Shortest Bridge

In a given 2D binary array A, there are two islands. (An island is a 4-directionally connected group of 1s not connected to any other 1s.)

Now, we may change 0s to 1s so as to connect the two islands together to form 1 island.

Return the smallest number of 0s that must be flipped. (It is guaranteed that the answer is at least 1.)

Example 1:

1
2
Input: [[0,1],[1,0]]
Output: 1

Example 2:

1
2
Input: [[0,1,0],[0,0,0],[0,0,1]]
Output: 2

Example 3:

1
2
Input: [[1,1,1,1,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,0,0,1],[1,1,1,1,1]]
Output: 1

Note:

  • 1 <= A.length = A[0].length <= 100
  • A[i][j] == 0 or A[i][j] == 1

这道题说是有一个只有0和1的二维数组,其中连在一起的1表示岛屿,现在假定给定的数组中一定有两个岛屿,问最少需要把多少个0变成1才能使得两个岛屿相连。在 LeetCode 中关于岛屿的题目还不少,但是万变不离其宗,核心都是用 DFS 或者 BFS 来解,有些还可以用联合查找 Union Find 来做。这里要求的是最小值,首先预定了一个 BFS,这就相当于洪水扩散一样,一圈一圈的,用的就是 BFS 的层序遍历。好,现在确定了这点后,再来想,这里并不是从某个点开始扩散,而是要从一个岛屿开始扩散,那么这个岛屿的所有的点都是 BFS 的起点,都是要放入到 queue 中的,所以要先来找出一个岛屿的所有点。找的方法就是遍历数组,找到第一个1的位置,然后对其调用 DFS 或者 BFS 来找出所有相连的1,先来用 DFS 的方法,对第一个为1的点调用递归函数,将所有相连的1都放入到一个队列 queue 中,并且将该点的值改为2,然后使用 BFS 进行层序遍历,每遍历一层,结果 res 都增加1,当遇到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
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
class Solution {
public:
int shortestBridge(vector<vector>& A) {
int res = 0, n = A.size(), startX = -1, startY = -1;
queue<int> q;
vector<int> dirX{-1, 0, 1, 0}, dirY = {0, 1, 0, -1};
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (A[i][j] == 0) continue;
startX = i; startY = j;
break;
}
if (startX != -1) break;
}
helper(A, startX, startY, q);
while (!q.empty()) {
for (int i = q.size(); i > 0; --i) {
int t = q.front(); q.pop();
for (int k = 0; k < 4; ++k) {
int x = t / n + dirX[k], y = t % n + dirY[k];
if (x < 0 || x >= n || y < 0 || y >= n || A[x][y] == 2) continue;
if (A[x][y] == 1) return res;
A[x][y] = 2;
q.push(x * n + y);
}
}
++res;
}
return res;
}
void helper(vector<vector>& A, int x, int y, queue& q) {
int n = A.size();
if (x < 0 || x >= n || y < 0 || y >= n || A[x][y] == 0 || A[x][y] == 2) return;
A[x][y] = 2;
q.push(x * n + y);
helper(A, x + 1, y, q);
helper(A, x, y + 1, q);
helper(A, x - 1, y, q);
helper(A, x, y - 1, q);
}
};

我们也可以使用 BFS 来找出所有相邻的1,再加上后面的层序遍历的 BFS,总共需要两个 BFS,注意这里第一个 BFS 不需要是层序遍历的,而第二个 BFS 是必须层序遍历,可以对比一下看一下这两种写法有何不同,参见代码如下:

``C++
class Solution {
public:
int shortestBridge(vector<vector>& A) {
int res = 0, n = A.size();
queue<int> q, que;
vector<int> dirX{-1, 0, 1, 0}, dirY = {0, 1, 0, -1};
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (A[i][j] == 0) continue;
A[i][j] = 2;
que.push(i * n + j);
break;
}
if (!que.empty()) break;
}
while (!que.empty()) {
int t = que.front(); que.pop();
q.push(t);
for (int k = 0; k < 4; ++k) {
int x = t / n + dirX[k], y = t % n + dirY[k];
if (x < 0 || x >= n || y < 0 || y >= n || A[x][y] == 0 || A[x][y] == 2) continue;
A[x][y] = 2;
que.push(x * n + y);
}
}
while (!q.empty()) {
for (int i = q.size(); i > 0; --i) {
int t = q.front(); q.pop();
for (int k = 0; k < 4; ++k) {
int x = t / n + dirX[k], y = t % n + dirY[k];
if (x < 0 || x >= n || y < 0 || y >= n || A[x][y] == 2) continue;
if (A[x][y] == 1) return res;
A[x][y] = 2;
q.push(x * n + y);
}
}
++res;
}
return res;
}
};

LeetCode] 935. Knight Dialer 骑士拨号器

The chess knight has a unique movement, it may move two squares vertically and one square horizontally, or two squares horizontally and one square vertically (with both forming the shape of an L). The possible movements of chess knight are shown in this diagaram:

A chess knight can move as indicated in the chess diagram below:

We have a chess knight and a phone pad as shown below, the knight can only stand on a numeric cell (i.e. blue cell).

Given an integer n, return how many distinct phone numbers of length n we can dial.

You are allowed to place the knight on any numeric cell initially and then you should perform n - 1 jumps to dial a number of length n. All jumps should be valid knight jumps.

As the answer may be very large, return the answer modulo 109 + 7.

Example 1:

1
2
3
Input: n = 1
Output: 10
Explanation: We need to dial a number of length 1, so placing the knight over any numeric cell of the 10 cells is sufficient.

Example 2:

1
2
3
Input: n = 2
Output: 20
Explanation: All the valid number we can dial are [04, 06, 16, 18, 27, 29, 34, 38, 40, 43, 49, 60, 61, 67, 72, 76, 81, 83, 92, 94]

Example 3:

1
2
Input: n = 3
Output: 46

Example 4:

1
2
Input: n = 4
Output: 104

Example 5:

1
2
3
Input: n = 3131
Output: 136006598
Explanation: Please take care of the mod.

Constraints:

  • 1 <= n <= 5000

这道题说是有一种骑士拨号器,在一个电话拨号盘上跳跃,其跳跃方式是跟国际象棋中的一样,不会国际象棋的童鞋可以将其当作中国象棋中的马,马走日象飞田。这个骑士可以放在 10 个数字键上的任意一个,但其跳到的下一个位置却要符合其在国际象棋中的规则,也就是走日。现在给了一个整数N,说是该骑士可以跳N次,问能拨出多个不同的号码,并且提示了结果要对一个超大数字取余。这里使用一个二维数组 dp,其中 dp[i][j] 表示骑士第i次跳到数字j时组成的不同号码的个数,那么最终所求的就是将 dp[N-1][j] 累加起来,j的范围是0到9。接下来看状态转移方程怎么写,当骑士在第i次跳到数字j时,考虑其第 i-1 次是在哪个位置,可能有多种情况,先来分析拨号键盘的结构,找出从每个数字能到达的下一个位置,可得如下关系:

1
2
3
4
5
6
7
8
9
10
0 -> 4, 6
1 -> 6, 8
2 -> 7, 9
3 -> 4, 8
4 -> 3, 9, 0
5 ->
6 -> 1, 7, 0
7 -> 2, 6
8 -> 1, 3
9 -> 4, 2

可以发现,除了数字5之外,每个数字都可以跳到其他位置,其中4和6可以跳到三个不同位置,其他都只能取两个位置。反过来想,可以去的位置,就表示也可能从该位置回来,所以根据当前的位置j,就可以在数组中找到上一次骑士所在的位置,并将其的 dp 值累加上即可,这就是状态转移的方法,由于第一步是把骑士放到任意一个数字上,就要初始化 dp[0][j] 为1,然后进行状态转移就行了,记得每次累加之后要对超大数取余,最后将 dp[N-1][j] 累加起来的时候,也要对超大数取余,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int knightDialer(int N) {
int res = 0, M = 1e9 + 7;
vector<vector<int>> dp(N, vector<int>(10));
vector<vector<int>> path{{4, 6}, {6, 8}, {7, 9}, {4, 8}, {3, 9, 0}, {}, {1, 7, 0}, {2, 6}, {1, 9}, {4, 2}};
for (int i = 0; i < 10; ++i) dp[0][i] = 1;
for (int i = 1; i < N; ++i) {
for (int j = 0; j <= 9; ++j) {
for (int idx : path[j]) {
dp[i][j] = (dp[i][j] + dp[i - 1][idx]) % M;
}
}
}
for (int i = 0; i < 10; ++i) res = (res + dp.back()[i]) % M;
return res;
}
};

我们也可以用递归+记忆数组的方式来写,整体思路和迭代的方法并没有什么区别,之前类似的题目也不少,就不多解释了,可以对照上面的讲解和代码来理解,参见代码如下:

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 knightDialer(int N) {
int res = 0, M = 1e9 + 7;
vector<vector<int>> memo(N + 1, vector<int>(10));
vector<vector<int>> path{{4, 6}, {6, 8}, {7, 9}, {4, 8}, {3, 9, 0}, {}, {1, 7, 0}, {2, 6}, {1, 9}, {4, 2}};
for (int i = 0; i < 10; ++i) {
res = (res + helper(N - 1, i, path, memo)) % M;
}
return res;
}
int helper(int n, int cur, vector<vector<int>>& path, vector<vector<int>>& memo) {
if (n == 0) return 1;
if (memo[n][cur] != 0) return memo[n][cur];
int res = 0, M = 1e9 + 7;
for (int idx : path[cur]) {
res = (res + helper(n - 1, idx, path, memo)) % M;
}
return memo[n][cur] = res;
}
};

Leetcode937. Reorder Data in Log Files

You have an array of logs. Each log is a space delimited string of words.

For each log, the first word in each log is an alphanumeric identifier. Then, either:

  • Each word after the identifier will consist only of lowercase letters, or;
  • Each word after the identifier will consist only of digits.
    We will call these two varieties of logs letter-logs and digit-logs. It is guaranteed that each log has at least one word after its identifier.

Reorder the logs so that all of the letter-logs come before any digit-log. The letter-logs are ordered lexicographically ignoring identifier, with the identifier used in case of ties. The digit-logs should be put in their original order.

Return the final order of the logs.

Example 1:

1
2
Input: logs = ["dig1 8 1 5 1","let1 art can","dig2 3 6","let2 own kit dig","let3 art zero"]
Output: ["let1 art can","let3 art zero","let2 own kit dig","dig1 8 1 5 1","dig2 3 6"]

对于每条日志,其第一个字为字母数字标识符。然后,要么:标识符后面的每个字将仅由小写字母组成,或标识符后面的每个字将仅由数字组成。

将这两种日志分别称为字母日志和数字日志。保证每个日志在其标识符后面至少有一个字。将日志重新排序,使得所有字母日志都排在数字日志之前。字母日志按字母顺序排序,忽略标识符,标识符仅用于表示关系。数字日志应该按原来的顺序排列。返回日志的最终顺序。

1
2
3
4
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:

static bool cmp(string a,string b) {
int ii = 0, jj = 0;
while(a[ii ++] != ' ') ;
while(b[jj ++] != ' ') ;
return a.substr(ii, a.length() - ii + 1) <= b.substr(jj, b.length() - jj + 1);
// 这里要比较后边所有的,不能比较一个字符
}

vector<string> reorderLogFiles(vector<string>& logs) {
vector<string> res;
int len = logs.size();
vector<bool> flag(len, 0);
for(int i = 0; i < len; i ++) {
int ii = 0;
while(logs[i][ii ++] != ' ') ;
cout << logs[i][ii] << endl;
if('a' <= logs[i][ii] && logs[i][ii] <= 'z') {
res.push_back(logs[i]);
flag[i] = 1;
}
}
sort(res.begin(), res.end(), cmp);
for(string t : res)
cout << t << endl;
for(int i = 0; i < len; i ++) {
if(!flag[i])
res.push_back(logs[i]);
}
return res;
}
};

Leetcode938. Range Sum of BST

Given the root node of a binary search tree, return the sum of values of all nodes with value between L and R (inclusive).

The binary search tree is guaranteed to have unique values.
一棵树,给定了根节点,再给一个范围(L,R),求这棵二叉树中在这个范围内的数的和,太简单了。。。直接递归查找,没难度,还奇怪呢这么简单的题还标着个medium。。。

1
2
3
4
Example 1:

Input: root = [10,5,15,3,7,null,18], L = 7, R = 15
Output: 32
1
2
3
4
Example 2:

Input: root = [10,5,15,3,7,13,18,1,null,6], L = 6, R = 10
Output: 23
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int rangeSumBST(TreeNode* root, int L, int R) {
if(root){
if(root->val >= L && root->val <= R)
return root->val + rangeSumBST(root->left,L,R)+rangeSumBST(root->right,L,R);
else
return rangeSumBST(root->left,L,R)+rangeSumBST(root->right,L,R);
}
return 0;
}
};

Leetcode939. Minimum Area Rectangle

Given a set of points in the xy-plane, determine the minimum area of a rectangle formed from these points, with sides parallel to the x and y axes.

If there isn’t any rectangle, return 0.

Example 1:

1
2
Input: [[1,1],[1,3],[3,1],[3,3],[2,2]]
Output: 4

Example 2:

1
2
Input: [[1,1],[1,3],[3,1],[3,3],[4,1],[4,3]]
Output: 2

Note:

  • 1 <= points.length <= 500
  • 0 <= points[i][0] <= 40000
  • 0 <= points[i][1] <= 40000
  • All points are distinct.

这道题给了我们一堆点的坐标,问能组成的最小的矩形面积是多少,题目中限定了矩形的边一定是平行于主轴的,不会出现旋转矩形的形状。如果知道了矩形的两个对角顶点坐标,求面积就非常的简单了,但是随便取四个点并不能保证一定是矩形,不过这四个点坐标之间是有联系的,相邻的两个顶点要么横坐标,要么纵坐标,一定有一个是相等的,这个特点先记下。策略是,先找出两个对角线的顶点,一但两个对角顶点确定了,其实这个矩形的大小也就确定了,另外的两个点其实就是分别在跟这两个点具有相同的横坐标或纵坐标的点中寻找即可,为了优化查找的时间,可以事先把所有具有相同横坐标的点的纵坐标放入到一个 HashSet 中,使用一个 HashMap,建立横坐标和所有具有该横坐标的点的纵坐标的集合之间的映射。然后开始遍历任意两个点的组合,由于这两个点必须是对角顶点,所以其横纵坐标均不能相等,若有一个相等了,则跳过该组合。否则看其中任意一个点的横坐标对应的集合中是否均包含另一个点的纵坐标,均包含的话,说明另两个顶点也是存在的,就可以计算矩形的面积了,更新结果 res,若最终 res 还是初始值,说明并没有能组成矩形,返回0即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int minAreaRect(vector<vector<int>>& points) {
int res = INT_MAX, n = points.size();
unordered_map<int, unordered_set<int>> m;
for (auto point : points) {
m[point[0]].insert(point[1]);
}
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (points[i][0] == points[j][0] || points[i][1] == points[j][1]) continue;
if (m[points[i][0]].count(points[j][1]) && m[points[j][0]].count(points[i][1])) {
res = min(res, abs(points[i][0] - points[j][0]) * abs(points[i][1] - points[j][1]));
}
}
}
return res == INT_MAX ? 0 : res;
}
};

Leetcode941. Valid Mountain Array

Given an array A of integers, return true if and only if it is a valid mountain array.

Recall that A is a mountain array if and only if:

  • A.length >= 3
  • There exists some i with 0 < i < A.length - 1 such that:
    • A[0] < A[1] < … A[i-1] < A[i]
    • A[i] > A[i+1] > … > A[A.length - 1]

Example 1:

1
2
Input: [2,1]
Output: false

Example 2:
1
2
Input: [3,5,5]
Output: false

Example 3:
1
2
Input: [0,3,2,1]
Output: true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool validMountainArray(vector<int>& A) {
bool flag = false;
if(A.size() < 3)
return false;
int i = 0;
for(; i < A.size()-1; i ++)
if(A[i] >= A[i+1])
break;
if(i == 0 || i == A.size()-1)
return false;
for(; i < A.size()-1; i ++)
if(A[i] <= A[i+1])
break;
if(i != A.size() - 1)
return false;
return true;
}
};

Leetcode942. DI String Match

Given a string S that only contains “I” (increase) or “D” (decrease), letN = S.length.

Return any permutation A of [0, 1, …, N] such that for alli = 0, ..., N-1:

If S[i] == “I”, then A[i] < A[i+1]
If S[i] == “D”, then A[i] > A[i+1]

Example 1:

1
2
Input: "IDID"
Output: [0,4,1,3,2]

Example 2:
1
2
Input: "III"
Output: [0,1,2,3]

Example 3:
1
2
Input: "DDI"
Output: [3,2,0,1]

Note:

1 <= S.length <= 10000
S only contains characters “I” or “D”.

题目的意思是,将字符串与数组一一对应,因为数组多一位,不考虑这一位。剩下的位置,如果字符串写的是‘I’,那么该位置上的数应该比右边所有的数都小。而如果是‘D’,则是比右边的都大。现在需要找到其中任意一组。

其实这个题是一个贪心,并且有点dp的感觉。感觉这个题解不唯一,其实还是比较简单能够证明反例。评论有人提出了解法证明,可以看一下:

只需要证明,对于任何 > 或者 < , 算法的规则都能满足。
△N = max-min; 由于每次遇到一个符号,△N-1。
当符号为“ < < <”: max—可以保证符号的正确性。
当符号为“ > > >”: min++可以保住符号的正确性。
当符号为“ ……< > < “: 任意时刻max和min开始比较,是否满足 min < max?
答案是:YES! 由于符号的数量为N,最开始△N = N。由于至少出现一对大于号和小于号 , min(△N)= 1,仍然满足min < max;
综上,得证。

因为每一位对应的数字只有两种情况:比右边所有数都大,或者都小。那么我们可以设定两个值,初始的话:low = 0,high = N。这样,从左开始遍历字符串,碰见一个字符,如果是‘I’,那么就直接赋值low,同时low++。这样,‘I’右边所有的数,一定是都比这个位置大的。因为此时low>a[i],同时high > low。

反而言之,碰见‘D’,直接赋值hight,同时high—。这样所有的数就一定比这个小了。大概就是这样,在O(n)的时间复杂度下就能构造出答案数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
vector<int> diStringMatch(string S) {
vector<int> res = vector<int>(S.size()+1);
int low = 0,high=S.size();
for(int i=0;i<S.size();i++){
if(S[i]=='I')
res[i]=low++;
else
res[i]=high--;
}
res[S.size()]=low;
return res;
}
};

Leetcode944. Delete Columns to Make Sorted

We are given an array A of N lowercase letter strings, all of the same length.

Now, we may choose any set of deletion indices, and for each string, we delete all the characters in those indices.

For example, if we have an array A = [“abcdef”,”uvwxyz”] and deletion indices {0, 2, 3}, then the final array after deletions is [“bef”, “vyz”], and the remaining columns of A are [“b”,”v”], [“e”,”y”], and [“f”,”z”]. (Formally, the c-th column is [A[0][c], A[1][c], …, A[A.length-1][c]].)

Suppose we chose a set of deletion indices D such that after deletions, each remaining column in A is in non-decreasing sorted order.

Return the minimum possible value of D.length.

Example 1:

1
2
Input: ["cba","daf","ghi"]
Output: 1

Explanation:
After choosing D = {1}, each column [“c”,”d”,”g”] and [“a”,”f”,”i”] are in non-decreasing sorted order.
If we chose D = {}, then a column [“b”,”a”,”h”] would not be in non-decreasing sorted order.
Example 2:
1
2
Input: ["a","b"]
Output: 0

Explanation: D = {}
Example 3:
1
2
Input: ["zyx","wvu","tsr"]
Output: 3

Explanation: D = {0, 1, 2}

Note:

1 <= A.length <= 100
1 <= A[i].length <= 1000

字符串数组 A 中的每个字符串元素的长度相同,统计index个数,这个index 的要求是 A[i].charAt(index),i=0,1,2,3,4 组成的 字符序列 不是严格递增。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int minDeletionSize(vector<string>& A) {
int isize=A.size();
int jsize=A[0].size();
int ans=0;
for(int j=0;j<jsize;j++){
for(int i=0;i<isize-1;i++){
if(A[i][j]>A[i+1][j]){
ans++;
break;
}
}
}
return ans;
}
};

Leetcode945. Minimum Increment to Make Array Unique

Given an array of integers A, a move consists of choosing any A[i], and incrementing it by 1.

Return the least number of moves to make every value in A unique.

Example 1:

1
2
3
Input: [1,2,2]
Output: 1
Explanation: After 1 move, the array could be [1, 2, 3].

Example 2:

1
2
3
4
Input: [3,2,1,2,1,7]
Output: 6
Explanation: After 6 moves, the array could be [3, 4, 1, 2, 5, 7].
It can be shown with 5 or less moves that it is impossible for the array to have all unique values.

Note:

  • 0 <= A.length <= 40000
  • 0 <= A[i] < 40000

这道题给了一个数组,说是每次可以将其中一个数字增加1,问最少增加多少次可以使得数组中没有重复数字。给的两个例子可以帮助我们很好的理解题意,这里主要参考了 lee215 大神的帖子,假如数组中没有重复数字的话,则不需要增加,只有当重复数字存在的时候才需要增加。比如例子1中,有两个2,需要将其中一个2增加到3,才能各不相同。但有时候只增加一次可能并不能解决问题,比如例子2中,假如要处理两个1,增加其中一个到2并不能解决问题,因此2也是有重复的,甚至增加到3还是有重复,所以一直得增加到4才行,但此时如何知道后面是否还有1,所以需要一个统一的方法来增加,最好是能从小到大处理数据,则先给数组排个序,然后用一个变量 need 表示此时需要增加到的数字,初始化为0,由于是从小到大处理,这个 need 会一直变大,而且任何小于 need 的数要么是数组中的数,要么是某个数字增后的数,反正都是出现过了。然后开始遍历数组,对于遍历到的数字 num,假如 need 大于 num,说明此时的 num 是重复数字,必须要提高到 need,则将 need-num 加入结果 res 中,反之若 need 小于 num,说明 num 并未出现过,不用增加。然后此时更新 need 为其和 num 之间的较大值并加1,因为 need 不能有重复,所以要加1,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
int minIncrementForUnique(vector<int>& A) {
int res = 0, need = 0;
sort(A.begin(), A.end());
for (int num : A) {
res += max(need - num, 0);
need = max(num, need) + 1;
}
return res;
}
};

假如数组中有大量的重复数字的话,那么上面的方法还是需要一个一个的来处理,来看一种能同时处理大量的重复数字的方法。这里使用了一个 TreeMap 来统计每个数字和其出现的次数之间的映射。由于 TreeMap 可以对 key 自动排序,所以就没有必要对原数组进行排序了,这里还是要用变量 need,整体思路和上面的解法很类似。建立好了 TreeMap 后开始遍历,此时单个数字的增长还是 max(need - num, 0),这个已经在上面解释过了,由于可能由多个,所以还是要乘以个数 a.second,到这里还没有结束,因为 a.second 这多么多个数字都被增加到了同一个数字,而这些数字应该彼此再分开,好在现在没有比它们更大的数字,那么问题就变成了将k个相同的数字变为不同,最少的增加次数,答案是 k*(k-1)/2,这里就不详细推导了,其实就是个等差数列求和,这样就可以知道将 a.second 个数字变为不同总共需要增加的次数,下面更新 need,在 max(need, num) 的基础上,还要增加个数 a.second,从而到达一个最小的新数字,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int minIncrementForUnique(vector<int>& A) {
int res = 0, need = 0;
map<int, int> numCnt;
for (int num : A) ++numCnt[num];
for (auto &a : numCnt) {
res += a.second * max(need - a.first, 0) + a.second * (a.second - 1) / 2;
need = max(need, a.first) + a.second;
}
return res;
}
};

再来看一种联合查找 Union Find 的方法,这是一种并查集的方法,在岛屿群组类的问题上很常见,可以搜搜博主之前关于岛屿类题目的讲解,很多都使用了这种方法。但是这道题乍一看好像跟群组并没有明显的关系,但其实是有些很微妙的联系的。这里的 root 使用一个 HashMap,而不是用数组,因为数字不一定是连续的,而且可能跨度很大,使用 HashMap 会更加省空间一些。遍历原数组,对于每个遍历到的数字 num,调用 find 函数,这里实际上就是查找上面的方法中的 need,即最小的那个不重复的新数字,而 find 函数中会不停的更新 root[x],而只要x存在,则不停的自增1,直到不存在时候,则返回其本身,那么实际上从 num 到 need 中所有的数字的 root 值都标记成了 need,就跟它们是属于一个群组一样,这样做的好处在以后的查询过程中可以更快的找到 need 值,这也是为啥这种方法不用给数组排序的原因,若还是不理解的童鞋可以将例子2代入算法一步一步执行,看每一步的 root 数组的值是多少,应该不难理解,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
int minIncrementForUnique(vector<int>& A) {
int res = 0;
unordered_map<int, int> root;
for (int num : A) {
res += find(root, num) - num;
}
return res;
}
int find(unordered_map<int, int>& root, int x) {
return root[x] = root.count(x) ? find(root, root[x] + 1) : x;
}
};

LeetCode946. Validate Stack Sequences

Given two sequences pushed and popped with distinct values, return true if and only if this could have been the result of a sequence of push and pop operations on an initially empty stack.

Example 1:

1
2
3
4
5
Input: pushed = [1,2,3,4,5], popped = [4,5,3,2,1]
Output: true
Explanation: We might do the following sequence:
push(1), push(2), push(3), push(4), pop() -> 4,
push(5), pop() -> 5, pop() -> 3, pop() -> 2, pop() -> 1

Example 2:

1
2
3
Input: pushed = [1,2,3,4,5], popped = [4,3,5,1,2]
Output: false
Explanation: 1 cannot be popped before 2.

Note:

  • 0 <= pushed.length == popped.length <= 1000
  • 0 <= pushed[i], popped[i] < 1000
  • pushed is a permutation of popped.
  • pushed and popped have distinct values.

这道题给了两个序列 pushed 和 popped,让判断这两个序列是否能表示同一个栈的压入和弹出操作,由于栈是后入先出的顺序,所以并不是任意的两个序列都是满足要求的。比如例子2中,先将 1,2,3,4 按顺序压入栈,此时4和3出栈,接下来压入5,再让5出栈,接下来出栈的是2而不是1,所以例子2会返回 false。而这道题主要就是模拟这个过程,使用一个栈,和一个变量i用来记录弹出序列的当前位置,此时遍历压入序列,对遍历到的数字都压入栈,此时要看弹出序列当前的数字是否和栈顶元素相同,相同的话就需要移除栈顶元素,并且i自增1,若下一个栈顶元素还跟新位置上的数字相同,还要进行相同的操作,所以用一个 while 循环来处理。直到最终遍历完压入序列后,若此时栈为空,则说明是符合题意的,否则就是 false,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
bool validateStackSequences(vector<int>& pushed, vector<int>& popped) {
stack<int> st;
int i = 0;
for (int num : pushed) {
st.push(num);
while (!st.empty() && st.top() == popped[i]) {
st.pop();
++i;
}
}
return st.empty();
}
};

Leetcode947. Most Stones Removed with Same Row or Column

On a 2D plane, we place n stones at some integer coordinate points. Each coordinate point may have at most one stone.

A stone can be removed if it shares either the same row or the same column as another stone that has not been removed.

Given an array stones of length n where stones[i] = [xi, yi] represents the location of the ith stone, return the largest possible number of stones that can be removed.

Example 1:

1
2
3
4
5
6
7
8
9
Input: stones = [[0,0],[0,1],[1,0],[1,2],[2,1],[2,2]]
Output: 5
Explanation: One way to remove 5 stones is as follows:
Remove stone [2,2] because it shares the same row as [2,1].
Remove stone [2,1] because it shares the same column as [0,1].
Remove stone [1,2] because it shares the same row as [1,0].
Remove stone [1,0] because it shares the same column as [0,0].
Remove stone [0,1] because it shares the same row as [0,0].
Stone [0,0] cannot be removed since it does not share a row/column with another stone still on the plane.

Constraints:

  • 1 <= stones.length <= 1000
  • 0 <= xi, yi <= 104
  • No two stones are at the same coordinate point.

给一个2D数组,其中的元素代表2D的位置,同一位置只会有一个石头,同行或同列的石头能被移走(前提是存在与它同行或同列的石头,如果只有它自己就不能移走),问最多可移走多少个石头。

思路

问题转换:同行同列的石头阵可以被移走直到只剩下一个石头。那么把同行同列的石头全都归到一个group,这个group的个数就是最后剩下的石头数量。则被移走的石头数量=石头总数量 - group个数

任务就变成了把同行同列的石头归到一个group里,两种方法,DFS和Union-Find

DFS

可把 (row, col) 看作一条边,建立无向图。但是注意一个问题,就是(1,2) 和 (2,1)是两个位置,但会被认为是同一条边,这时需要把这两种边区分开,题中有这样一个限制条件0 <= xi, yi <= 104 ,所以可定义0~10000是row的区间,10001~20001是col的范围。也就是把col + 10001,但是实际上col+10000也通过了。

这样做就可以访问一个位置(row,col)时,把row行和col列的位置全都标记为访问过,再递归标记它们关联的行和列,为一个组。下一次再从未访问过的位置重新标记新的一组。这样就可找出一共有多少个组。

可能行和列的概念容易混淆,可以认为列也是要遍历的“行”,只不过列的index是从10000开始的。

这里标记访问过不是一个位置一个位置地标,而是标整行整列,表示这一行或列已经访问过(可把列理解为index从10000开始的“行”)。

1
2
3
4
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
//19ms
public int removeStones(int[][] stones) {
int groups = 0;
HashMap<Integer, List<Integer>> graph = new HashMap<>();
boolean[] visited = new boolean[20001];

for(int[] stone : stones) {
int row = stone[0];
int col = stone[1] + 10000;
if(!graph.containsKey(row)) {
graph.put(row, new ArrayList<Integer>());
}
graph.get(row).add(col);

if(!graph.containsKey(col)) {
graph.put(col, new ArrayList<Integer>());
}
graph.get(col).add(row);
}

for(Integer key : graph.keySet()) {
if(visited[key]) continue;
dfs(key, graph, visited);
groups ++;
}
return stones.length - groups;
}

void dfs(int rowCol, HashMap<Integer, List<Integer>> graph, boolean[] visited) {
if(visited[rowCol]) return;
visited[rowCol] = true;
//行的话找同一列,列的话找同一行的stone,把同行或同列的标记为访问,为同一group
for(Integer next : graph.get(rowCol)) {
if(visited[next]) continue;
dfs(next, graph, visited);
}
}

如果DFS深度过深,担心StackOverflowError,可用stack版DFS,和BFS差不多,只不过BFS用的queue储存节点,先进先访问。DFS用stack储存节点,后进的先访问,直到到达尽头。

1
2
3
4
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
public int removeStones(int[][] stones) {
int groups = 0;
HashMap<Integer, List<Integer>> graph = new HashMap<>();
boolean[] visited = new boolean[20001];

for(int[] stone : stones) {
int row = stone[0];
int col = stone[1] + 10000;
if(!graph.containsKey(row)) {
graph.put(row, new ArrayList<Integer>());
}
graph.get(row).add(col);

if(!graph.containsKey(col)) {
graph.put(col, new ArrayList<Integer>());
}
graph.get(col).add(row);
}

for(Integer key : graph.keySet()) {
if(visited[key]) continue;
Stack<Integer> stack = new Stack<>();
stack.push(key);
visited[key] = true;
while(!stack.isEmpty()) {
int rowCol = stack.pop();
for(Integer next : graph.get(rowCol)) {
if(visited[next]) continue;
visited[next] = true;
stack.push(next);
}
}
groups ++;
}
return stones.length - groups;
}

Union-Find

思路和上面一样,也是把同行同列的归为一个group,最后用石头总数-group个数。

还是把(row, col)看成一条边,也可理解为它们是关联的数字。还是row是0~10000区间,col是10001~20001区间。但是这里加了一个情况,就是一个点还没有被访问的时候,它的parent是0,访问过的点要么parent是它自己,要么是其他点。所以为了和0区别开,row的范围移到1~10001,col移到10002~20002。所以row要加1,col要加10002。

  • parent:上面还有其他parent。
  • root:parent是它自己(最上层parent)。

统一把col的root设为row的root。这样做有什么用处?row和col有同一个root,当同一列的位置来的时候,可通过col找到这个root,当同一行的位置来的时候,可通过row找到同一root,这样就可达到把同行和同列的石头都归为一个group的效果。

当row和col未访问过,即parent==0时,把row的parent标记为它自己,col的parent标记为row。一个访问过一个没访问过时,标记root为访问过的root。

当有不同的root时,把col的root,重点强调是root,而不是简单的parent,标记为row的root

1
2
3
4
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
class Solution {
public:
int removeStones(vector<vector<int>>& stones) {
vector<int> parent(20003, 0);
int res = 0;
for (auto stone : stones)
uni(parent, stone[0]+1, stone[1]+10002, res);
return stones.size() - res;
}

void uni(vector<int>& parent, int st1, int st2, int& res) {
int pr = find(parent, st1);
int pc = find(parent, st2);
if (pr == pc) {
if (pr == 0) {
res ++;
parent[st1] = st1;
parent[st2] = st1;
}
}
else {
if (pr == 0)
parent[st1] = pc;
else if (pc == 0)
parent[st2] = pr;
else {
res --;
parent[pc] = pr;
}
}
}

int find(vector<int>& parent, int st) {
int sp = parent[st];
while(sp != parent[sp])
sp = parent[st] = find(parent, sp);
return sp;
}
};

Leetcode948. Bag of Tokens

You have an initial power P, an initial score of 0 points, and a bag of tokens.

Each token can be used at most once, has a value token[i], and has potentially two ways to use it.

  • If we have at least token[i] power, we may play the token face up, losing token[i] power, and gaining 1 point.
  • If we have at least 1 point, we may play the token face down, gaining token[i] power, and losing 1 point.

Return the largest number of points we can have after playing any number of tokens.

Example 1:

1
2
Input: tokens = [100], P = 50
Output: 0

Example 2:

1
2
Input: tokens = [100,200], P = 150
Output: 1

Example 3:

1
2
Input: tokens = [100,200,300,400], P = 200
Output: 2

Note:

  • tokens.length <= 1000
  • 0 <= tokens[i] < 10000
  • 0 <= P < 10000

这道题说是给了一个初始力量值P,然后有一个 tokens 数组,有两种操作可以选择,一种是减去 tokens[i] 的力量,得到一分,但是前提是减去后剩余的力量不能为负。另一种是减去一分,得到 tokens[i] 的力量,前提是减去后的分数不能为负,问一顿操作猛如虎后可以得到的最高分数是多少。这道题其实题意不是太容易理解,而且例子也没给解释,博主也是读了好几遍题目才明白的。比如例子3,开始有 200 的力量,可以先花 100,得到1个积分,此时还剩 100 的力量,但由于剩下的 token 值都太大,没法换积分了,只能用积分来换力量,既然都是花一个1个积分,肯定是要换最多的力量,于是换来 400 力量,此时总共有 500 的力量,积分还是0,但是一顿操作后,白嫖了 400 的力量,岂不美哉?!这 500 的力量刚好可以换两个积分,所以最后返回的就是2。通过上述分析,基本上可以知道策略了,从最小的 token 开始,用力量换积分,当力量不够时,就用基本换最大的力量,如果没有积分可以换力量,就结束,或者所有的 token 都使用过了,也结束,这就是典型的贪婪算法 Greedy Algorithm,也算对得起其 Medium 的身价。这里先给 tokens 数组排个序,然后使用双指针i和j,分别指向开头和末尾,当 i<=j 进行循环,从小的 token 开始查找,只要力量够,就换成积分,不能换的时候,假如 i>j 或者此时积分为0,则退出;否则用一个积分换最大的力量,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int bagOfTokensScore(vector<int>& tokens, int P) {
int res = 0, cur = 0, n = tokens.size(), i = 0, j = n - 1;
sort(tokens.begin(), tokens.end());
while (i <= j) {
while (i <= j && tokens[i] <= P) {
P -= tokens[i++];
res = max(res, ++cur);
}
if (i > j || cur == 0) break;
--cur;
P += tokens[j--];
}
return res;
}
};

我们也可以换一种写法,不用 while 套 while,而是换成赏心悦目的 if … else 语句,其实也没差啦,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int bagOfTokensScore(vector<int>& tokens, int P) {
int res = 0, cur = 0, n = tokens.size(), i = 0, j = n - 1;
sort(tokens.begin(), tokens.end());
while (i <= j) {
if (P >= tokens[i]) {
P -= tokens[i++];
res = max(res, ++cur);
} else if (cur > 0) {
--cur;
P += tokens[j--];
} else {
break;
}
}
return res;
}
};

我们也可以使用递归来做,使用一个子函数 helper,将i和j当作参数输入,其实原理跟上的方法一摸一样,不难理解,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int bagOfTokensScore(vector<int>& tokens, int P) {
sort(tokens.begin(), tokens.end());
return helper(tokens, P, 0, (int)tokens.size() - 1, 0);
}
int helper(vector<int>& tokens, int P, int i, int j, int cur) {
if (i > j) return cur;
int res = cur;
if (tokens[i] <= P) {
res = max(res, helper(tokens, P - tokens[i], i + 1, j, cur + 1));
} else if (cur > 0) {
res = max(res, helper(tokens, P + tokens[j], i, j - 1, cur - 1));
}
return res;
}
};

Leetcode949. Largest Time for Given Digits

Given an array of 4 digits, return the largest 24 hour time that can be made. The smallest 24 hour time is 00:00, and the largest is 23:59. Starting from 00:00, a time is larger if more time has elapsed since midnight. Return the answer as a string of length 5. If no valid time can be made, return an empty string.

Example 1:

1
2
Input: [1,2,3,4]
Output: "23:41"

Example 2:
1
2
Input: [5,5,5,5]
Output: ""

使用STL中的全排列生成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
string largestTimeFromDigits(vector<int>& A) {
string res = "";
sort(A.begin(), A.end(), greater<int>());
do {
if( (A[0] <= 1 || (A[0] == 2 && A[1] < 4)) && A[2] < 6) {
stringstream ss;
ss << A[0] << A[1] << ":" << A[2] << A[3];
return ss.str();
}
} while(prev_permutation(A.begin(), A.end()));
return "";
}
};

Leetcode950. Reveal Cards In Increasing Order

In a deck of cards, every card has a unique integer. You can order the deck in any order you want.

Initially, all the cards start face down (unrevealed) in one deck.

Now, you do the following steps repeatedly, until all cards are revealed:

Take the top card of the deck, reveal it, and take it out of the deck.
If there are still cards in the deck, put the next top card of the deck at the bottom of the deck.
If there are still unrevealed cards, go back to step 1. Otherwise, stop.
Return an ordering of the deck that would reveal the cards in increasing order.

The first entry in the answer is considered to be the top of the deck.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
Input: [17,13,11,2,3,5,7]
Output: [2,13,3,11,5,17,7]
Explanation:
We get the deck in the order [17,13,11,2,3,5,7] (this order doesn't matter), and reorder it.
After reordering, the deck starts as [2,13,3,11,5,17,7], where 2 is the top of the deck.
We reveal 2, and move 13 to the bottom. The deck is now [3,11,5,17,7,13].
We reveal 3, and move 11 to the bottom. The deck is now [5,17,7,13,11].
We reveal 5, and move 17 to the bottom. The deck is now [7,13,11,17].
We reveal 7, and move 13 to the bottom. The deck is now [11,17,13].
We reveal 11, and move 17 to the bottom. The deck is now [13,17].
We reveal 13, and move 17 to the bottom. The deck is now [17].
We reveal 17.
Since all the cards revealed are in increasing order, the answer is correct.

Note:

1 <= A.length <= 1000
1 <= A[i] <= 10^6
A[i] != A[j] for all i != j

woc什么乱七八糟的题,这个确实没懂。

从牌组顶部抽一张牌,显示它,然后将其从牌组中移出。
如果牌组中仍有牌,则将下一张处于牌组顶部的牌放在牌组的底部。
如果仍有未显示的牌,那么返回步骤 1。否则,停止行动。
得到的序列要求是递增序列。

例如 1 3 2 通过上述变换,可以得到1 2 3,满足题目要求。

解法是:1 2 3 通过上述变换,可以得到 1 3 2,即这道题的解。

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<int> deckRevealedIncreasing(vector<int>& deck) {
queue<int> q;
vector<int> res(deck.size());
sort(deck.begin(),deck.end());
for(int i=0;i<deck.size();i++)
q.push(i);
for(int i=0;i<deck.size();i++)
{
int temp = q.front();
res[temp]=deck[i];
q.pop();

temp = q.front();
q.push(temp);
q.pop();
}
return res;
}
};

Leetcode951. Flip Equivalent Binary Trees

For a binary tree T, we can define a flip operation as follows: choose any node, and swap the left and right child subtrees.

A binary tree X is flip equivalent to a binary tree Y if and only if we can make X equal to Y after some number of flip operations.

Write a function that determines whether two binary trees are flip equivalent. The trees are given by root nodes root1 and root2.

Example 1:

Input: root1 = [1,2,3,4,5,6,null,null,null,7,8], root2 = [1,3,2,null,6,4,5,null,null,null,null,8,7]
Output: true
Explanation: We flipped at nodes with values 1, 3, and 5.

这种做法好复杂啊。。。有太多情况需要考虑了。

1
2
3
4
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
class Solution {
public:

bool compare(TreeNode* root1, TreeNode* root2){
if( root1->left == NULL && root1->right == NULL && root2->left == NULL && root2->right == NULL ) {
return true;
}
else if( root1->left != NULL && root1->right != NULL && root2->left != NULL && root2->right != NULL ) {
if(root1->left->val == root2->left->val && root1->right->val == root2->right->val) {
return true;
} else if(root1->right->val == root2->left->val && root1->left->val == root2->right->val) {
TreeNode* temp = root1->right;
root1->right = root1->left;
root1->left = temp;
return true;
} else {
return false;
}
} else if ( root1->left == NULL && root1->right != NULL && root2->left == NULL && root2->right != NULL ){
if(root1->right->val == root2->right->val) {
return true;
} else {
return false;
}
} else if ( root1->left != NULL && root1->right == NULL && root2->left != NULL && root2->right == NULL ) {
if(root1->left->val == root2->left->val) {
return true;
} else {
return false;
}
} else if ( root1->left == NULL && root1->right != NULL && root2->left != NULL && root2->right == NULL ){
if(root1->right->val == root2->left->val) {
root1->left = root1->right;
root1->right = NULL;
return true;
} else {
return false;
}
} else if ( root1->left != NULL && root1->right == NULL && root2->left == NULL && root2->right != NULL ) {
if(root1->left->val == root2->right->val) {
root1->right = root1->left;
root1->left = NULL;
return true;
} else {
return false;
}
}
return false;
}

bool order(TreeNode* root1, TreeNode* root2){
if (root1 == NULL && root2 == NULL) {
return true;
}
if (root1 == NULL || root2 == NULL) {
return false;
}
if( root1 != NULL && root2 != NULL ) {
if(root1->val == root2->val)
compare(root1, root2);
else
return false;
return order(root1->left, root2->left) && order(root1->right, root2->right);
}
return false;


}

bool flipEquiv(TreeNode* root1, TreeNode* root2) {
return order(root1, root2);
}
};

另一种做法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
bool flipEquiv(TreeNode* root1, TreeNode* root2) {
// Two null trees are flip equivalent
// A non-null and null tree are NOT flip equivalent
// Two non-null trees with different root values are NOT flip equivalent
// Two non-null trees are flip equivalent if
// The left subtree of tree1 is flip equivalent with the left subtree of tree2 and the right subtree of tree1 is
// flipequivalent with the right subtree of tree2 (no flip case)
// OR
// The right subtree of tree1 is flip equivalent with the left subtree of tree2 and the left subtree of tree1 is
// flipequivalent with the right subtree of tree2 (flip case)
if ( !root1 && !root2 ) return true;
if ( !root1 && root2 || root1 &&!root2 || root1->val != root2->val ) return false;
return flipEquiv( root1->left, root2->left ) && flipEquiv( root1->right, root2->right )
|| flipEquiv( root1->right, root2->left ) && flipEquiv( root1->left, root2->right );
}
};

Leetcode953. Verifying an Alien Dictionary

In an alien language, surprisingly they also use english lowercase letters, but possibly in a different order. The order of the alphabet is some permutation of lowercase letters.

Given a sequence of words written in the alien language, and the order of the alphabet, return true if and only if the given words are sorted lexicographicaly in this alien language.

Example 1:

1
2
3
Input: words = ["hello","leetcode"], order = "hlabcdefgijkmnopqrstuvwxyz"
Output: true
Explanation: As 'h' comes before 'l' in this language, then the sequence is sorted.

Example 2:
1
2
3
Input: words = ["word","world","row"], order = "worldabcefghijkmnpqstuvxyz"
Output: false
Explanation: As 'd' comes after 'l' in this language, then words[0] > words[1], hence the sequence is unsorted.

Example 3:
1
2
3
Input: words = ["apple","app"], order = "abcdefghijklmnopqrstuvwxyz"
Output: false
Explanation: The first three characters "app" match, and the second string is shorter (in size.) According to lexicographical rules "apple" > "app", because 'l' > '∅', where '∅' is defined as the blank character which is less than any other character (More info).

从一个新的字母序判断是不是有序的字符串数组。
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 isAlienSorted(vector<string>& words, string order) {
map<char, int> mp;
for(int i = 0; i < order.length(); i ++)
mp[order[i]] = i;
int size = words.size();
for(int i = 0; i < size-1; i ++) {
int j = i + 1;
int min_size = min(words[i].length(), words[j].length());
int k;
for(k = 0; k < min_size; k ++)
if(mp[words[i][k]] > mp[words[j][k]])
return false;
else if (mp[words[i][k]] < mp[words[j][k]])
break;
if(k == min_size && words[i].length() > words[j].length())
return false;
}
return true;
}
};

Leetcode955. Delete Columns to Make Sorted II

We are given an array A of N lowercase letter strings, all of the same length.

Now, we may choose any set of deletion indices, and for each string, we delete all the characters in those indices.

For example, if we have an array A = [“abcdef”,”uvwxyz”] and deletion indices {0, 2, 3}, then the final array after deletions is [“bef”,”vyz”].

Suppose we chose a set of deletion indices D such that after deletions, the final array has its elements in lexicographic order (A[0] <= A[1] <= A[2] … <= A[A.length - 1]).

Return the minimum possible value of D.length.

Example 1:

1
2
3
4
5
6
Input: ["ca","bb","ac"]
Output: 1
Explanation:
After deleting the first column, A = ["a", "b", "c"].
Now A is in lexicographic order (ie. A[0] <= A[1] <= A[2]).
We require at least 1 deletion since initially A was not in lexicographic order, so the answer is 1.

Example 2:

1
2
3
4
5
6
Input: ["xc","yb","za"]
Output: 0
Explanation:
A is already in lexicographic order, so we don't need to delete anything.
Note that the rows of A are not necessarily in lexicographic order:
ie. it is NOT necessarily true that (A[0][0] <= A[0][1] <= ...)

Example 3:

1
2
3
4
Input: ["zyx","wvu","tsr"]
Output: 3
Explanation:
We have to delete every column.

Note:

  • 1 <= A.length <= 100
  • 1 <= A[i].length <= 100

这道题说是给了一个字符串数组,里面的字符串长度均相同,这样如果将每个字符串看作一个字符数组的话,于是就可以看作的一个二维数组,题目要求数组中的字符串是按照字母顺序的,问最少需要删掉多少列。我们知道比较两个长度相等的字符串的字母顺序时,就是从开头起按照两两对应的位置比较,只要前面的字符顺序已经比出来了,后面的字符的顺序就不用管了,比如 “bx” 和 “ea”,因为 b 比 e 小,所以 “bx” 比 “ea” 小,后面的 x 和 a 的顺序无关紧要。如果看成二维数组的话,在比较A[i][j]A[i+1][j]时,假如 [0, j-1] 中的某个位置k,已经满足了A[i][k] < A[i+1][k]的话,这里就不用再比了,所以用一个数组 sorted 来标记某相邻的两个字符串之间是否已经按照字母顺序排列了。然后用两个 for 循环,外层是遍历列,内层是遍历行,然后看若sorted[i]为 false,且A[i][j] > A[i + 1][j]的话,说明当前列需要被删除,结果 res 自增1,且 break 掉内层 for 循环。当内层 for 循环 break 掉或者自己结束后,此时看 i 是否小于 m-1,是的话说明是 break 掉的,直接 continue 外层循环。若是自己退出的,则在遍历一遍所有行,更新一下 sorted 数组即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int minDeletionSize(vector<string>& A) {
int res = 0, m = A.size(), n = A[0].size(), i = 0, j = 0;
vector<int> sorted(m - 1);
for (j = 0; j < n; ++j) {
for (i = 0; i < m - 1; ++i) {
if (!sorted[i] && A[i][j] > A[i + 1][j]) {
++res;
break;
}
}
if (i < m - 1) continue;
for (i = 0; i < m - 1; ++i) {
sorted[i] |= A[i][j] < A[i + 1][j];
}
}
return res;
}
};

Leetcode957. Prison Cells After N Days

There are 8 prison cells in a row, and each cell is either occupied or vacant.

Each day, whether the cell is occupied or vacant changes according to the following rules:

If a cell has two adjacent neighbors that are both occupied or both vacant, then the cell becomes occupied.
Otherwise, it becomes vacant.
(Note that because the prison is a row, the first and the last cells in the row can’t have two adjacent neighbors.)

We describe the current state of the prison in the following way: cells[i] == 1 if the i-th cell is occupied, else cells[i] == 0.

Given the initial state of the prison, return the state of the prison after N days (and N such changes described above.)

Example 1:

1
2
3
4
5
6
7
8
9
10
11
Input: cells = [0,1,0,1,1,0,0,1], N = 7
Output: [0,0,1,1,0,0,0,0]
Explanation: The following table summarizes the state of the prison on each day:
Day 0: [0, 1, 0, 1, 1, 0, 0, 1]
Day 1: [0, 1, 1, 0, 0, 0, 0, 0]
Day 2: [0, 0, 0, 0, 1, 1, 1, 0]
Day 3: [0, 1, 1, 0, 0, 1, 0, 0]
Day 4: [0, 0, 0, 0, 0, 1, 0, 0]
Day 5: [0, 1, 1, 1, 0, 1, 0, 0]
Day 6: [0, 0, 1, 0, 1, 1, 0, 0]
Day 7: [0, 0, 1, 1, 0, 0, 0, 0]

Example 2:

1
2
Input: cells = [1,0,0,1,0,0,1,0], N = 1000000000
Output: [0,0,1,1,1,1,1,0]

Note:

  • cells.length == 8
  • cells[i] is in {0, 1}
  • 1 <= N <= 10^9

这道题给了一个只由0和1构成的数组,数组长度固定为8,现在要进行N步变换,变换的规则是若一个位置的左右两边的数字相同,则该位置的数字变为1,反之则变为0,让求N步变换后的数组的状态。需要注意的数组的开头和结尾的两个位置,由于一个没有左边,一个没有右边,默认其左右两边的数字不相等,所以不管首尾数字初始的时候是啥,在第一次变换之后一定会是0,而且一直会保持0的状态。可能是有一个周期循环的,这样就完全没有必要每次都算一遍。正确的做法的应该是建立状态和当前N值的映射,一旦当前计算出的状态在 HashMap 中出现了,说明周期找到了,这样就可以通过取余来快速的缩小N值。为了使用 HashMap 而不是 TreeMap,这里首先将数组变为字符串,然后开始循环N,将当前状态映射为 N-1,然后新建了一个长度为8,且都是0的字符串。更新的时候不用考虑首尾两个位置,因为前面说了,首尾两个位置一定会变为0。更新完成了后,便在 HashMap 查找这个状态是否出现过,是的话算出周期,然后N对周期取余。最后再把状态字符串转为数组即可,参见代码如下:

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> prisonAfterNDays(vector<int>& cells, int N) {
vector<int> res;
string str;
for (int num : cells) str += to_string(num);
unordered_map<string, int> m;
while (N > 0) {
m[str] = N--;
string cur(8, '0');
for (int i = 1; i < 7; ++i) {
cur[i] = (str[i - 1] == str[i + 1]) ? '1' : '0';
}
str = cur;
if (m.count(str)) {
N %= m[str] - N;
}
}
for (char c : str) res.push_back(c - '0');
return res;
}
};

Leetcode958. Check Completeness of a Binary Tree

Given a binary tree, determine if it is a complete binary tree.

Definition of a complete binary tree from Wikipedia:
In a complete binary tree every level, except possibly the last, is completely filled, and all nodes in the last level are as far left as possible. It can have between 1 and 2h nodes inclusive at the last level h.

Example 1:

1
2
3
Input: [1,2,3,4,5,6]
Output: true
Explanation: Every level before the last is full (ie. levels with node-values {1} and {2, 3}), and all nodes in the last level ({4, 5, 6}) are as far left as possible.

Example 2:
1
2
3
Input: [1,2,3,4,5,null,7]
Output: false
Explanation: The node with value 7 isn't as far left as possible.

用BFS遍历二叉树,当遇到空节点时,如果队列中还有未遍历的节点则该二叉树不完整。

1
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:
bool isCompleteTree(TreeNode* root) {
if(root == NULL)
return true;
queue<TreeNode*> q;
q.push(root);
TreeNode* temp;
bool res = true;
while(!q.empty()) {
int size = q.size();
temp = q.front();
q.pop();
if(temp == NULL)
res = false;
else {
if(!res)
return false;
q.push(temp->left);
q.push(temp->right);
}
}
return true;
}
};

Leetcode959. Regions Cut By Slashes

In a N x N grid composed of 1 x 1 squares, each 1 x 1 square consists of a /, \, or blank space. These characters divide the square into contiguous regions.

(Note that backslash characters are escaped, so a \ is represented as “\\”.)

Return the number of regions.

Example 1:

1
2
3
4
5
Input: [
" /",
"/ "
]
Output: 2

Explanation: The 2x2 grid is as follows:

Example 2:

1
2
3
4
5
Input: [
" /",
" "
]
Output: 1

Explanation: The 2x2 grid is as follows:

Example 3:

1
2
3
4
5
6
Input: [
"\\/",
"/\\"
]
Output: 4
Explanation: (Recall that because \ characters are escaped, "\\/" refers to \/, and "/\\" refers to /\.)

The 2x2 grid is as follows:

Example 4:

1
2
3
4
5
6
Input: [
"/\\",
"\\/"
]
Output: 5
Explanation: (Recall that because \ characters are escaped, "/\\" refers to /\, and "\\/" refers to \/.)

The 2x2 grid is as follows:

Example 5:

1
2
3
4
5
6
Input: [
"//",
"/ "
]
Output: 3
Explanation: The 2x2 grid is as follows:

Note:

  • 1 <= grid.length == grid[0].length <= 30
  • grid[i][j] is either ‘/‘, ‘\’, or ‘ ‘.

这道题说是有个 NxN 个小方块,每个小方块里可能是斜杠,反斜杠,或者是空格。然后问这些斜杠能将整个区域划分成多少个小区域。这的确是一道很有意思的题目,虽然只是 Medium 的难度,但是博主拿到题目的时候是懵逼的,这尼玛怎么做?无奈只好去论坛上看大神们的解法,结果发现大神们果然牛b,巧妙的将这道题转化为了岛屿个数问题 Number of Islands,具体的做法将每个小区间化为九个小格子,这样斜杠或者反斜杠就是对角线或者逆对角线了,是不是有点图像像素化的感觉,就是当你把某个图片尽可能的放大后,到最后你看到也就是一个个不同颜色的小格子组成了这幅图片。这样只要把斜杠的位置都标记为1,而空白的位置都标记为0,这样只要找出分隔开的0的群组的个数就可以了,就是岛屿个数的问题啦。使用一个 DFS 来遍历即可,这个并不难,这道题难就难在需要想出来这种像素化得转化,确实需要灵光一现啊,参见代码如下:

1
2
3
4
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
class Solution {
public:
int regionsBySlashes(vector<string>& grid) {
int n = grid.size(), res = 0;
vector<vector<int>> nums(3 * n, vector<int>(3 * n));
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
if (grid[i][j] == '/') {
nums[i * 3][j * 3 + 2] = 1;
nums[i * 3 + 1][j * 3 + 1] = 1;
nums[i * 3 + 2][j * 3] = 1;
} else if (grid[i][j] == '\\') {
nums[i * 3][j * 3] = 1;
nums[i * 3 + 1][j * 3 + 1] = 1;
nums[i * 3 + 2][j * 3 + 2] = 1;
}
}
}
for (int i = 0; i < nums.size(); ++i) {
for (int j = 0; j < nums.size(); ++j) {
if (nums[i][j] == 0) {
helper(nums, i, j);
++res;
}
}
}
return res;
}
void helper(vector<vector<int>>& nums, int i, int j) {
if (i >= 0 && j >= 0 && i < nums.size() && j < nums.size() && nums[i][j] == 0) {
nums[i][j] = 1;
helper(nums, i - 1, j);
helper(nums, i, j + 1);
helper(nums, i + 1, j);
helper(nums, i, j - 1);
}
}
};

Leetcode961. N-Repeated Element in Size 2N Array

In a array A of size 2N, there are N+1 unique elements, and exactly one of these elements is repeated N times.

Return the element repeated N times.

Example 1:

1
2
Input: [1,2,3,3]
Output: 3

Example 2:
1
2
Input: [2,1,2,5,3,2]
Output: 2

Example 3:
1
2
Input: [5,1,5,2,5,3,5,4]
Output: 5

Note:

4 <= A.length <= 10000
0 <= A[i] < 10000
A.length is even

一个桶排序搞定

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int repeatedNTimes(vector<int>& A) {
int counter[10000];
memset(counter, 0, sizeof(counter));
for(int i = 0; i < A.size(); i ++){
counter[A[i]] ++;
if(counter[A[i]] >= A.size() / 2)
return A[i];
}
return -1;
}
};

看到了大佬的解法,跪了,如果有两个连续一样的元素,直接返回

The intuition here is that the repeated numbers have to appear either next to each other (A[i] == A[i + 1]), or alternated (A[i] == A[i + 2]).

The only exception is sequences like [2, 1, 3, 2]. In this case, the result is the last number, so we just return it in the end. This solution has O(n) runtime.

1
2
3
4
5
int repeatedNTimes(vector<int>& A) {
for (auto i = 0; i < A.size() - 2; ++i)
if (A[i] == A[i + 1] || A[i] == A[i + 2]) return A[i];
return A[A.size() - 1];
}

Another interesting approach is to use randomization (courtesy of @lee215 ). If you pick two numbers randomly, there is a 25% chance you bump into the repeated number. So, in average, we will find the answer in 4 attempts, thus O(4) runtime.
1
2
3
4
int repeatedNTimes(vector<int>& A, int i = 0, int j = 0) {
while (A[i = rand() % A.size()] != A[j = rand() % A.size()] || i == j);
return A[i];
}

Leetcode962. Maximum Width Ramp

A ramp in an integer array nums is a pair (i, j) for which i < j and nums[i] <= nums[j]. The width of such a ramp is j - i.

Given an integer array nums, return the maximum width of a ramp in nums. If there is no ramp in nums, return 0.

Example 1:

1
2
3
Input: nums = [6,0,8,2,1,5]
Output: 4
Explanation: The maximum width ramp is achieved at (i, j) = (1, 5): nums[1] = 0 and nums[5] = 5.

Example 2:

1
2
3
Input: nums = [9,8,1,0,1,9,4,0,4,1]
Output: 7
Explanation: The maximum width ramp is achieved at (i, j) = (2, 9): nums[2] = 1 and nums[9] = 1.

Constraints:

  • 2 <= nums.length <= 5 * 104
  • 0 <= nums[i] <= 5 * 104

这道题说给了一个数组A,这里定义了一种叫做 Ramp 的范围 (i, j),满足 i < j 且 A[i] <= A[j],而 ramp 就是 j - i,这里让求最宽的 ramp,若没有,则返回0。其实就是让在数组中找一前一后的两个数字,前面的数字小于等于后面的数字,且两个数字需要相距最远,让求这个最远的距离。先想一下,什么时侯不存在这个 ramp,就是当数组是严格递减的时候,那么不存在前面的数字小于等于后面的数字的情况,于是 ramp 是0。这道题的优化解法应该是使用单调栈。这里用一个数组 idx,来记录一个单调递减数组中数字的下标,遍历原数组A,对于每个遍历到的数字 A[i],判断若此时下标数组为空,或者当前数字 A[i] 小于该下标数组中最后一个坐标在A中表示的数字时,将当前坐标i加入 idx,继续保持单调递减的顺序。反之,若 A[i] 比较大,则可以用二分搜索法来找出单调递减数组中第一个小于 A[i] 的数字的坐标,这样就可以快速得到 ramp 的大小,并用来更新结果 res 即可,这样整体的复杂度就降到了 O(nlgn)。

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 maxWidthRamp(vector<int>& nums) {
int res = 0;
vector<int> s;
for (int i = 0; i < nums.size(); i ++) {
if (s.size() == 0 || nums[i] <= nums[s.back()])
s.push_back(i);
else {
int left = 0, right = s.size()-1;
while(left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[i])
left = mid+1;
else
right = mid;
}
res = max(res, i - s[right]);
}
}
return res;
}
};

或者:

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 maxWidthRamp(vector<int>& A) {
stack<int> s;
int maxlen = 0, i;
for(i = 0; i < A.size(); ++i)
{
if(s.empty() || A[s.top()] > A[i])//单调递减栈
s.push(i);
}
for(i = A.size()-1; i >= 0; --i)
{
while(!s.empty() && A[i] >= A[s.top()])
{
maxlen = max(maxlen, i-s.top());
s.pop();
}
}
return maxlen;
}
};

Leetcode963. Minimum Area Rectangle II

Given a set of points in the xy-plane, determine the minimum area of any rectangle formed from these points, with sides not necessarily parallel to the x and y axes.

If there isn’t any rectangle, return 0.

Example 1:

1
2
3
Input: [[1,2],[2,1],[1,0],[0,1]]
Output: 2.00000
Explanation: The minimum area rectangle occurs at [1,2],[2,1],[1,0],[0,1], with an area of 2.

Example 2:

1
2
3
Input: [[0,1],[2,1],[1,1],[1,0],[2,0]]
Output: 1.00000
Explanation: The minimum area rectangle occurs at [1,0],[1,1],[2,1],[2,0], with an area of 1.

Example 3:

1
2
3
Input: [[0,3],[1,2],[3,1],[1,3],[2,1]]
Output: 0
Explanation: There is no possible rectangle to form from these points.

Example 4:

1
2
3
Input: [[3,1],[1,1],[0,1],[2,1],[3,3],[3,2],[0,2],[2,3]]
Output: 2.00000
Explanation: The minimum area rectangle occurs at [2,1],[2,3],[3,3],[3,1], with an area of 2.

Note:

  • 1 <= points.length <= 50
  • 0 <= points[i][0] <= 40000
  • 0 <= points[i][1] <= 40000
  • All points are distinct.
  • Answers within 10^-5 of the actual value will be accepted as correct.

这道题是之前那道 Minimum Area Rectangle 的拓展,虽说是拓展,但是解题思想完全不同。那道题由于矩形不能随意翻转,所以任意两个相邻的顶点一定是相同的横坐标或者纵坐标,而这道题就不一样了,矩形可以任意翻转,就不能利用之前的特点了。那该怎么办呢,这里就要利用到矩形的对角线的特点了,我们都知道矩形的两条对角线长度是相等的,而且相交于矩形的中心,这个中心可以通过两个对顶点的坐标求出来。只要找到了两组对顶点,它们的中心重合,并且表示的对角线长度相等,则一定可以组成矩形。基于这种思想,可以遍历任意两个顶点,求出它们之间的距离,和中心点的坐标,将这两个信息组成一个字符串,建立和顶点在数组中位置之间的映射,这样能组成矩形的点就被归类到一起了。接下来就是遍历这个 HashMap 了,只能取出两组顶点及更多的地方,开始遍历,分别通过顶点的坐标算出两条边的长度,然后相乘用来更新结果 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
32
33
34
class Solution {
public:
double minAreaFreeRect(vector<vector<int>>& points) {
int n = points.size();
if (n < 4) return 0.0;
double res = DBL_MAX;
unordered_map<string, vector<vector<int>>> m;
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
long dist = getLength(points[i], points[j]);
double centerX = (points[i][0] + points[j][0]) / 2.0;
double centerY = (points[i][1] + points[j][1]) / 2.0;
string key = to_string(dist) + "_" + to_string(centerX) + "_" + to_string(centerY);
m[key].push_back({i, j});
}
}
for (auto &a : m) {
vector<vector<int>> vec = a.second;
if (vec.size() < 2) continue;
for (int i = 0; i < vec.size(); ++i) {
for (int j = i + 1; j < vec.size(); ++j) {
int p1 = vec[i][0], p2 = vec[j][0], p3 = vec[j][1];
double len1 = sqrt(getLength(points[p1], points[p2]));
double len2 = sqrt(getLength(points[p1], points[p3]));
res = min(res, len1 * len2);
}
}
}
return res == DBL_MAX ? 0.0 : res;
}
long getLength(vector<int>& pt1, vector<int>& pt2) {
return (pt1[0] - pt2[0]) * (pt1[0] - pt2[0]) + (pt1[1] - pt2[1]) * (pt1[1] - pt2[1]);
}
};

Leetcode965. Univalued Binary Tree

A binary tree is univalued if every node in the tree has the same value.

Return true if and only if the given tree is univalued.

Example 1:

Input: [1,1,1,1,1,null,1]
Output: true

Example 2:

Input: [2,2,2,5,2]
Output: false

Note:

The number of nodes in the given tree will be in the range [1, 100].
Each node’s value will be an integer in the range [0, 99].

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool des(TreeNode* root,int val){
if(root==NULL)
return true;
if(root->val != val)
return false;
return des(root->left,val)&&des(root->right,val);


}
bool isUnivalTree(TreeNode* root) {
if(root==NULL)
return true;
return des(root,root->val);
}
};

Leetcode966. Vowel Spellchecker

Given a wordlist, we want to implement a spellchecker that converts a query word into a correct word.

For a given query word, the spell checker handles two categories of spelling mistakes:

  • Capitalization: If the query matches a word in the wordlist (case-insensitive), then the query word is returned with the same case as the case in the wordlist.
    • Example: wordlist = [“yellow”], query = “YellOw”: correct = “yellow”
    • Example: wordlist = [“Yellow”], query = “yellow”: correct = “Yellow”
    • Example: wordlist = [“yellow”], query = “yellow”: correct = “yellow”
  • Vowel Errors: If after replacing the vowels (‘a’, ‘e’, ‘i’, ‘o’, ‘u’) of the query word with any vowel individually, it matches a word in the wordlist - (case-insensitive), then the query word is returned with the same case as the match in the wordlist.
    • Example: wordlist = [“YellOw”], query = “yollow”: correct = “YellOw”
    • Example: wordlist = [“YellOw”], query = “yeellow”: correct = “” (no match)
    • Example: wordlist = [“YellOw”], query = “yllw”: correct = “” (no match)

In addition, the spell checker operates under the following precedence rules:

  • When the query exactly matches a word in the wordlist (case-sensitive), you should return the same word back.
  • When the query matches a word up to capitlization, you should return the first such match in the wordlist.
  • When the query matches a word up to vowel errors, you should return the first such match in the wordlist.
  • If the query has no matches in the wordlist, you should return the empty string.

Given some queries, return a list of words answer, where answer[i] is the correct word for query = queries[i].

Example 1:

1
2
Input: wordlist = ["KiTe","kite","hare","Hare"], queries = ["kite","Kite","KiTe","Hare","HARE","Hear","hear","keti","keet","keto"]
Output: ["kite","KiTe","KiTe","Hare","hare","","","KiTe","","KiTe"]

Note:

  • 1 <= wordlist.length <= 5000
  • 1 <= queries.length <= 5000
  • 1 <= wordlist[i].length <= 7
  • 1 <= queries[i].length <= 7
  • All strings in wordlist and queries consist only of english letters.

这道题给了一组单词,让实现一个拼写检查器,把查询单词转换成一个正确的单词。这个拼写检查器主要有两种功能,一种是可以忽略大小写,另一种是忽略元音的错误,所谓元音是 a,e,i,o,u,这五个字母。另外题目中还制定了一些其他规则:假如有和查询单词一模一样的单词,考虑大小写,此时应该优先返回。第二个优先级是字母及顺序都一样,但大小写可能不同的,第三个优先级是有元音错误的单词也可以返回,最后都不满足的话返回空串。首先对于第一种情况,返回和查询单词一模一样的单词,很简单,将所有单词放入一个 HashSet 中,这样就可以快速确定一个查询单词是否在原单词数组中出现过。对于第二种情况,做法是将每个单词都转为小写,然后建立小写单词和原单词之间都映射,注意对于转为小写后相同都单词,我们只映射第一个出现该小写状态的单词,后面的不用管。对于第三种情况,对于每个单词,转为小写之后,然后把所有的元音字母用特殊字符替代,比如下划线,然后也是建立这种特殊处理后的状态和原单词之间的映射。当映射都建立好了之后,就可以遍历所有的查询单词了,首先是去 HashSet 中找,若有跟该查询单词一模一样的,直接加入结果 res 中。若没有,则先将查询单词变为小写,然后去第一个 HashMap 中查找,若存在,直接加入结果 res 中。若没有,再把所有的元音变为下划线,去第二个 HashMap 中查找,存在则直接加入结果 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Solution {
public:

char tolower(char c) {
if ('A' <= c && c <= 'Z')
return (char)(c + 32);
return c;
}

vector<string> spellchecker(vector<string>& wordlist, vector<string>& queries) {
vector<string> res;
unordered_set<string> st;
unordered_map<string, string> map;
unordered_map<string, string> map_v;
for (int i = 0; i < wordlist.size(); i ++) {
string word = wordlist[i];
st.insert(word);
for (int j = 0; j < word.length(); j ++)
word[j] = tolower(word[j]);
if (!map.count(word))
map[word] = wordlist[i];
for (int j = 0; j < word.length(); j ++)
if (word[j] == 'a' || word[j] == 'e' || word[j] == 'i' || word[j] == 'o' || word[j] == 'u')
word[j] = '_';
if (!map_v.count(word))
map_v[word] = wordlist[i];
}

for (string& query : queries) {
if (st.count(query)) {
res.push_back(query);
continue;
}

for (int j = 0; j < query.length(); j ++)
query[j] = tolower(query[j]);

if (map.count(query)) {
res.push_back(map[query]);
continue;
}

for (int j = 0; j < query.length(); j ++)
if (query[j] == 'a' || query[j] == 'e' || query[j] == 'i' || query[j] == 'o' || query[j] == 'u')
query[j] = '_';
if (map_v.count(query)) {
res.push_back(map_v[query]);
continue;
}
res.push_back("");
}
return res;
}
};

Leetcode969. Pancake Sorting

Given an array of integers arr, sort the array by performing a series of pancake flips.

In one pancake flip we do the following steps:

  • Choose an integer k where 1 <= k <= arr.length.
  • Reverse the sub-array arr[1…k].

For example, if arr = [3,2,1,4] and we performed a pancake flip choosing k = 3, we reverse the sub-array [3,2,1], so arr = [1,2,3,4] after the pancake flip at k = 3.

Return the k-values corresponding to a sequence of pancake flips that sort arr. Any valid answer that sorts the array within 10 * arr.length flips will be judged as correct.

Example 1:

1
2
3
4
5
6
7
8
9
Input: arr = [3,2,4,1]
Output: [4,2,4,3]
Explanation:
We perform 4 pancake flips, with k values 4, 2, 4, and 3.
Starting state: arr = [3, 2, 4, 1]
After 1st flip (k = 4): arr = [1, 4, 2, 3]
After 2nd flip (k = 2): arr = [4, 1, 2, 3]
After 3rd flip (k = 4): arr = [3, 2, 1, 4]
After 4th flip (k = 3): arr = [1, 2, 3, 4], which is sorted.

Notice that we return an array of the chosen k values of the pancake flips.

Example 2:

1
2
3
Input: arr = [1,2,3]
Output: []
Explanation: The input is already sorted, so there is no need to flip anything.

Note that other answers, such as [3, 3], would also be accepted.

Constraints:

  • 1 <= arr.length <= 100
  • 1 <= arr[i] <= arr.length
  • All integers in arr are unique (i.e. arr is a permutation of the integers from 1 to arr.length).

这道题给了长度为n的数组,由1到n的组成,顺序是打乱的。现在说我们可以任意翻转前k个数字,k的范围是1到n,问怎么个翻转法能将数组翻成有序的。题目说并不限定具体的翻法,只要在 10*n 的次数内翻成有序的都是可以的,任你随意翻,就算有无效的步骤也无所谓。题目中给的例子1其实挺迷惑的,因为并不知道为啥要那样翻,也没有一个固定的翻法,所以可能会误导大家。必须要自己想出一个固定的翻法,这样才能应对所有的情况。每次先将数组中最大数字找出来,然后将最大数字翻转到首位置,然后翻转整个数组,这样最大数字就跑到最后去了。然后将最后面的最大数字去掉,这样又重现一样的情况,重复同样的步骤,直到数组只剩一个数字1为止,在过程中就把每次要翻转的位置都记录到结果 res 中就可以了,注意这里 C++ 的翻转函数 reverse 的结束位置是开区间,很容易出错,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
vector<int> pancakeSort(vector<int>& arr) {
vector<int> res;
for (int i = arr.size(), j; i > 0; --i) {
for (j = 0; arr[j] != i; ++j);
reverse(arr.begin(), arr.begin() + j + 1);
res.push_back(j + 1);
reverse(arr.begin(), arr.begin() + i);
res.push_back(i);
}
return res;
}
};

Leetcode970. Powerful Integers

Given two positive integers x and y, an integer is powerful if it is equal to x^i + y^j for some integers i >= 0 and j >= 0. Return a list of all powerful integers that have value less than or equal to bound.

You may return the answer in any order. In your answer, each value should occur at most once.

Example 1:

1
2
3
4
5
6
7
8
9
10
Input: x = 2, y = 3, bound = 10
Output: [2,3,4,5,7,9,10]
Explanation:
2 = 2^0 + 3^0
3 = 2^1 + 3^0
4 = 2^0 + 3^1
5 = 2^1 + 3^1
7 = 2^2 + 3^1
9 = 2^3 + 3^0
10 = 2^0 + 3^2

Example 2:
1
2
Input: x = 3, y = 5, bound = 15
Output: [2,4,6,8,10,14]

方法很简单,如果x/y等于1,那么幂值只会是1;如果x/y 大于1,由于 bound <= 10^6,幂的最大值是20(pow(2,20) > 10^6)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
vector<int> powerfulIntegers(int x, int y, int bound) {
set<int> res;
long temp;
int x_max = x > 1 ? 20 : 1;
int y_max = y > 1 ? 20 : 1;
for(int i = 0 ; i < x_max && pow(x, i) <= bound; i ++)
for(int j = 0 ; j < y_max && pow(y, j) <= bound; j ++) {
temp = pow(x, i) + pow(y, j);
if(temp <= bound)
res.insert(temp);
}
return vector<int>(res.begin(), res.end());
}
};

Leetcode971. Flip Binary Tree To Match Preorder Traversal

Given a binary tree with N nodes, each node has a different value from {1, …, N}.

A node in this binary tree can be flipped by swapping the left child and the right child of that node.

Consider the sequence of N values reported by a preorder traversal starting from the root. Call such a sequence of N values the voyage of the tree.

(Recall that a preorder traversal of a node means we report the current node’s value, then preorder-traverse the left child, then preorder-traverse the right child.)

Our goal is to flip the least number of nodes in the tree so that the voyage of the tree matches the voyage we are given.

If we can do so, then return a list of the values of all nodes flipped. You may return the answer in any order.

If we cannot do so, then return the list [-1].

Example 1:

1
2
Input: root = [1,2], voyage = [2,1]
Output: [-1]

Example 2:
1
2
Input: root = [1,2,3], voyage = [1,3,2]
Output: [1]

Example 3:
1
2
Input: root = [1,2,3], voyage = [1,2,3]
Output: []

最少翻转哪些节点,能使得二叉树的前序遍历变成voyage.

其实这个题不难,因为题目就说了是前序遍历,所以做法肯定还是前序遍历。我刚开始一直想不通的地方在于,题目又是返回[-1],又是正常返回,没想好怎么做区分。其实做法就是递归函数不仅要修改res数组,还要返回表示能不能构成题目条件的bool变量。

看到二叉树的题,很大可能就需要递归,所以直接先写出dfs函数,然后再慢慢向里面填东西。

我们定义的dfs函数意义是,我们能不能通过翻转(或者不翻转)该root节点的左右子树,得到对应v。如果能,返回true,否则返回false。

首先在递归函数中,我们对root节点进行判断,如果root不存在,这种情况不应该认为是题目输入错误,而是应该认为已经遍历到最底部了,这个时候相当于root = [], voyage = [],所以返回true;在先序遍历的时候,root节点是第一个要被遍历到的节点,如果不和voyage[0]相等,直接返回false;

这个题目的难点在于是否需要翻转一个节点的左右孩子。判断的方法其实是简单的:如果voyage第二个元素等于root的左孩子,那么说明不用翻转,直接递归调用左右孩子;否则如果voyage的第二个元素等于root的右孩子,那么还要注意一下,在左孩子存在的情况下,我们需要翻转当前的节点左右孩子。

翻转是什么概念呢?这里并没有直接交换,而是把当前遍历到的位置使用遍历i保存起来,这样voyage[i]就表示当前遍历到哪个位置了。所以dfs调用两个孩子的顺序很讲究,它体现了先序遍历先解决哪个树的问题,也就是完成了逻辑上的交换左右孩子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
int i = 0;
vector<int> ans;

bool dfs(TreeNode* root, vector<int>& voyage) {
if(!root)
return true;
if(root->val != voyage[i++])
return false;
if(root->left && root->left->val == voyage[i])
return dfs(root->left, voyage) && dfs(root->right, voyage);
else if(root->right && root->right->val == voyage[i]) {
if(root->left)
ans.push_back(root->val);
return dfs(root->right, voyage) && dfs(root->left, voyage);
}
return !root->left && !root->right;
}

vector<int> flipMatchVoyage(TreeNode* root, vector<int>& voyage) {
if(dfs(root, voyage))
return ans;
return {-1};
}
};

Leetcode973. K Closest Points to Origin

We have a list of points on the plane. Find the K closest points to the origin (0, 0).

(Here, the distance between two points on a plane is the Euclidean distance.)

You may return the answer in any order. The answer is guaranteed to be unique (except for the order that it is in.)

Example 1:

1
2
3
4
5
6
7
Input: points = [[1,3],[-2,2]], K = 1
Output: [[-2,2]]
Explanation:
The distance between (1, 3) and the origin is sqrt(10).
The distance between (-2, 2) and the origin is sqrt(8).
Since sqrt(8) < sqrt(10), (-2, 2) is closer to the origin.
We only want the closest K = 1 points from the origin, so the answer is just [[-2,2]].

Example 2:

1
2
3
Input: points = [[3,3],[5,-1],[-2,4]], K = 2
Output: [[3,3],[-2,4]]
(The answer [[-2,4],[3,3]] would also be accepted.)

Note:

  • 1 <= K <= points.length <= 10000
  • -10000 < points[i][0] < 10000
  • -10000 < points[i][1] < 10000

这道题给了平面上的一系列的点,让求最接近原点的K个点。基本上没有什么难度,无非就是要知道点与点之间的距离该如何求。一种比较直接的方法就是给这个二维数组排序,自定义排序方法,按照离原点的距离从小到大排序,注意这里我们并不需要求出具体的距离值,只要知道互相的大小关系即可,所以并不需要开方。排好序之后,返回前k个点即可,参见代码如下:

1
2
3
4
5
6
7
8
9
class Solution {
public:
vector<vector<int>> kClosest(vector<vector<int>>& points, int K) {
sort(points.begin(), points.end(), [](vector<int>& a, vector<int>& b) {
return a[0] * a[0] + a[1] * a[1] < b[0] * b[0] + b[1] * b[1];
});
return vector<vector<int>>(points.begin(), points.begin() + K);
}
};

下面这种解法是使用最大堆 Max Heap 来做的,在 C++ 中就是用优先队列来做,这里维护一个大小为k的最大堆,里面放一个 pair 对儿,由距离原点的距离,和该点在原数组中的下标组成,这样优先队列就可以按照到原点的距离排队了,距离大的就在队首。这样每当个数超过k个了之后,就将队首的元素移除即可,最后把剩下的k个点存入结果 res 中即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<vector<int>> kClosest(vector<vector<int>>& points, int K) {
vector<vector<int>> res;
priority_queue<pair<int, int>> pq;
for (int i = 0; i < points.size(); ++i) {
int t = points[i][0] * points[i][0] + points[i][1] * points[i][1];
pq.push({t, i});
if (pq.size() > K) pq.pop();
}
while (!pq.empty()) {
auto t = pq.top(); pq.pop();
res.push_back(points[t.second]);
}
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
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:

int comp(vector<int> p1, vector<int> p2) {
return p1[0] * p1[0] + p1[1] * p1[1] - p2[0] * p2[0] - p2[1] * p2[1];
}

int helper(vector<vector<int>>& points, int l, int r) {
vector<int> priot = points[l];
while(l < r) {
while(l < r && comp(points[r], priot) >= 0) r --;
points[l] = points[r];
while(l < r && comp(points[l], priot) <= 0) l ++;
points[r] = points[l];
}
points[l] = priot;
return l;
}

vector<vector<int>> kClosest(vector<vector<int>>& points, int k) {
vector<vector<int>> res;
int l = 0, r = points.size()-1;
while(l <= r) {
int mid = helper(points, l, r);
if (mid == k)
break;
else if (mid > k)
r = mid-1;
else
l = mid+1;
}
for (int i = 0; i < k; i ++)
res.push_back(points[i]);
return res;
}
};

Leetcode976. Largest Perimeter Triangle

Given an array A of positive lengths, return the largest perimeter of a triangle with non-zero area, formed from 3 of these lengths.

If it is impossible to form any triangle of non-zero area, return 0.

Example 1:

1
2
Input: [2,1,2]
Output: 5

Example 2:
1
2
Input: [1,2,1]
Output: 0

Example 3:
1
2
Input: [3,2,3,4]
Output: 10

Example 4:
1
2
Input: [3,6,2,3]
Output: 8

三角形的条件:两边之和>第三边。

若要构成最大的三角形周长,只需要对数组排序,一直取出最大的三个值作为三角形的边,符合条件即可返回。

证明:若数组A为自然顺序,A[N]>=A[N-1]+A[N-2],则A[N]>=A[N-1]+A[N-3],A[N]与后面的数字更不可能构成三角形,可以直接排除。

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int largestPerimeter(vector<int>& A) {
sort(A.begin(), A.end());
for(int i = A.size() - 1; i >= 2; i --) {
if(A[i - 2] + A[i - 1] > A[i])
return A[i - 2] + A[i - 1] + A[i];
}
return 0;
}
};

Leetcode977. Squares of a Sorted Array

Given an array of integers A sorted in non-decreasing order, return an array of the squares of each number, also in sorted non-decreasing order.

Example 1:

1
2
Input: [-4,-1,0,3,10]
Output: [0,1,9,16,100]

Example 2:
1
2
Input: [-7,-3,2,3,11]
Output: [4,9,9,49,121]

Note:

  • 1 <= A.length <= 10000
  • -10000 <= A[i] <= 10000
  • A is sorted in non-decreasing order.

给一个vector,有正有负,输出排序之后的平方数组。

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
vector<int> sortedSquares(vector<int>& A) {
vector<int> res(A.size());
int l = 0, r = A.size()-1, p = A.size() - 1;
while(l <= r) {
res[p--] = pow(A[abs(A[l]) > abs(A[r]) ? l++ : r--],2);
}

return res;
}
};

另一种方法:

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<int> sortedSquares(vector<int>& A) {
vector<int> res(A.size());
int i = 0, j = 0, k = 0;
while(i < A.size() && A[i] < 0)
i ++;
j = i - 1;
while(j >= 0 && i < A.size()){
res[k++] = pow(A[abs(A[i]) < abs(A[j]) ? i ++ : j --], 2);
}

while(j>=0)
res[k++]=pow(A[j--],2);
while(i<A.size())
res[k++]=pow(A[i++],2);

return res;
}
};

Leetcode978. Longest Turbulent Subarray

A subarray A[i], A[i+1], …, A[j] of A is said to be turbulent if and only if:

  • For i <= k < j, A[k] > A[k+1] when k is odd, and A[k] < A[k+1] when k is even;
  • OR, for i <= k < j, A[k] > A[k+1] when k is even, and A[k] < A[k+1] when k is odd.

That is, the subarray is turbulent if the comparison sign flips between each adjacent pair of elements in the subarray.

Return the length of a maximum size turbulent subarray of A.

Example 1:

1
2
3
Input: [9,4,2,10,7,8,8,1,9]
Output: 5
Explanation: (A[1] > A[2] < A[3] > A[4] < A[5])

Example 2:
1
2
Input: [4,8,12,16]
Output: 2

Example 3:
1
2
Input: [100]
Output: 1

隐藏很深的dp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int maxTurbulenceSize(vector<int>& A) {
int sizee = A.size();
int res = 1;
vector<int> up(sizee, 1);
vector<int> down(sizee, 1);

for(int i = 1; i < sizee; i ++) {
if(A[i] > A[i - 1])
up[i] = down[i - 1] + 1;
if(A[i] < A[i - 1])
down[i] = up[i - 1] + 1;
res = max(res, max(up[i], down[i]));
}
return res;
}
};

首先预处理一下,记录数组中数字变化趋势,1增加-1减少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
class Solution {
public:
int maxTurbulenceSize(vector<int>& A) {
int sizee = A.size();
int res = 0;
int count = 2;
for(int i = 1; i < sizee; i ++) {
if(A[i] > A[i - 1])
A[i-1] = 1;
else if(A[i] < A[i + 1])
A[i-1] = -1;
else
A[i-1] = 0;
}
for(int i = 1; i < sizee - 1; i ++) {
while(i < sizee - 1 && A[i] * A[i-1] < 0) {
count ++;
i ++;
}
res = max(res, count);
count = 2;
}
return res;
}
};

Leetcode979. Distribute Coins in Binary Tree

Given the root of a binary tree with N nodes, each node in the tree has node.val coins, and there are N coins total.

In one move, we may choose two adjacent nodes and move one coin from one node to another. (The move may be from parent to child, or from child to parent.)

Return the number of moves required to make every node have exactly one coin.

Example 1:

Input: [3,0,0]
Output: 2
Explanation: From the root of the tree, we move one coin to its left child, and one coin to its right child.

Example 2:

Input: [0,3,0]
Output: 3
Explanation: From the left child of the root, we move two coins to the root [taking two moves]. Then, we move one coin from the root of the tree to the right child.

Example 3:

Input: [1,0,2]
Output: 2

Example 4:

Input: [1,0,0,null,3]
Output: 4

Note:

1<= N <= 100
0 <= node.val <= N

给你一个二叉树,对于每个节点的val,每次只能往父亲或者儿子移动1,最后使得所有节点值都为1,求最小的移动次数。

思路:从叶子到根寻找,对于每个节点,只能剩下一个。多了的值肯定要全给父亲,少的值全问父亲要,统计一下就好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int ans=0;
int distributeCoins(TreeNode* root) {
dfs(root);
return ans;
}
int dfs(TreeNode* root){
if(root==NULL)
return 0;
int left = dfs(root->left);
int right = dfs(root->right);

ans += abs(left) + abs(right);
return root->val -1 + left + right ;
}
};

Leetcode980. Unique Paths III

On a 2-dimensional grid, there are 4 types of squares:

1 represents the starting square. There is exactly one starting square.
2 represents the ending square. There is exactly one ending square.
0 represents empty squares we can walk over.
-1 represents obstacles that we cannot walk over.
Return the number of 4-directional walks from the starting square to the ending square, that walk over every non-obstacle square exactly once.

Example 1:

1
2
3
4
5
Input: [[1,0,0,0],[0,0,0,0],[0,0,2,-1]]
Output: 2
Explanation: We have the following two paths:
1. (0,0),(0,1),(0,2),(0,3),(1,3),(1,2),(1,1),(1,0),(2,0),(2,1),(2,2)
2. (0,0),(1,0),(2,0),(2,1),(1,1),(0,1),(0,2),(0,3),(1,3),(1,2),(2,2)

Example 2:
1
2
3
4
5
6
7
Input: [[1,0,0,0],[0,0,0,0],[0,0,0,2]]
Output: 4
Explanation: We have the following four paths:
1. (0,0),(0,1),(0,2),(0,3),(1,3),(1,2),(1,1),(1,0),(2,0),(2,1),(2,2),(2,3)
2. (0,0),(0,1),(1,1),(1,0),(2,0),(2,1),(2,2),(1,2),(0,2),(0,3),(1,3),(2,3)
3. (0,0),(1,0),(2,0),(2,1),(2,2),(1,2),(1,1),(0,1),(0,2),(0,3),(1,3),(2,3)
4. (0,0),(1,0),(2,0),(2,1),(1,1),(0,1),(0,2),(0,3),(1,3),(1,2),(2,2),(2,3)

Example 3:
1
2
3
4
5
Input: [[0,1],[2,0]]
Output: 0
Explanation:
There is no path that walks over every empty square exactly once.
Note that the starting and ending square can be anywhere in the grid.

Note:

1 <= grid.length * grid[0].length <= 20

给了一个二维矩阵,1代表起点,2代表终点,0代表可以走的格子,-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
class Solution {
public:
vector<pair<int, int>> dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
int uniquePathsIII(vector<vector<int>>& grid) {
int M=grid.size();
int zerosize=0,res=0;
int N=grid[0].size();
for(int i=0;i<M;i++)
for(int j=0;j<N;j++)
if(grid[i][j]==0)
zerosize++;
for(int i=0;i<M;i++)
for(int j=0;j<N;j++)
if(grid[i][j]==1)
dfs(grid,i,j,0,zerosize,res);
return res;
}

void dfs(vector<vector<int>>& grid, int x, int y, int pathcount, int zerocount, int& res){
if(grid[x][y]==2 && zerocount == pathcount ){
res++;
}
int M=grid.size();
int N=grid[0].size();

int pre=grid[x][y];
if(pre==0)
pathcount++;
grid[x][y]=-1;
for (auto d : dirs) {
int nx = x + d.first;
int ny = y + d.second;
if (nx < 0 || nx >= M || ny < 0 || ny >= N || grid[nx][ny] == -1)
continue;
dfs(grid, nx, ny, pathcount, zerocount, res);
}
grid[x][y]=pre;

}

};

Leetcode981. Time Based Key-Value Store

Create a timebased key-value store class TimeMap, that supports two operations.

  • set(string key, string value, int timestamp):Stores the key and value, along with the given timestamp.
  • get(string key, int timestamp)
    • Returns a value such that set(key, value, timestamp_prev) was called previously, with timestamp_prev <= timestamp.
    • If there are multiple such values, it returns the one with the largest timestamp_prev.
    • If there are no values, it returns the empty string (“”).

Example 1:

1
2
3
4
5
6
7
8
9
10
Input: inputs = ["TimeMap","set","get","get","set","get","get"], inputs = [[],["foo","bar",1],["foo",1],["foo",3],["foo","bar2",4],["foo",4],["foo",5]]
Output: [null,null,"bar","bar",null,"bar2","bar2"]
Explanation:
TimeMap kv;
kv.set("foo", "bar", 1); // store the key "foo" and value "bar" along with timestamp = 1
kv.get("foo", 1); // output "bar"
kv.get("foo", 3); // output "bar" since there is no value corresponding to foo at timestamp 3 and timestamp 2, then the only value is at timestamp 1 ie "bar"
kv.set("foo", "bar2", 4);
kv.get("foo", 4); // output "bar2"
kv.get("foo", 5); //output "bar2"

这个题太麻烦了,没有耐心做了,只看了看,本来想用很好的方法,比如二分实现一下,但是发现这种简单粗暴的方法竟然也能过,就算了……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class TimeMap {
private:
unordered_map<string, map<int, string>> mp;
vector<int> tvec;
public:
/** Initialize your data structure here. */
TimeMap() {}

void set(string key, string value, int timestamp) {
mp[key][timestamp] = value;
}

string get(string key, int timestamp) {
if(!mp.count(key))
return "";
if(mp[key].count(timestamp))
return mp[key][timestamp];
for(auto it = mp[key].rbegin(); it != mp[key].rend(); it++) {
if(it->first > timestamp)
continue;
else
return it->second;
}
return "";
}
};

Leetcode983. Minimum Cost For Tickets

In a country popular for train travel, you have planned some train travelling one year in advance. The days of the year that you will travel is given as an array days. Each day is an integer from 1 to 365.

Train tickets are sold in 3 different ways:

  • a 1-day pass is sold for costs[0] dollars;
  • a 7-day pass is sold for costs[1] dollars;
  • a 30-day pass is sold for costs[2] dollars.

The passes allow that many days of consecutive travel. For example, if we get a 7-day pass on day 2, then we can travel for 7 days: day 2, 3, 4, 5, 6, 7, and 8.

Return the minimum number of dollars you need to travel every day in the given list of days.

Example 1:

1
2
3
4
5
6
7
8
Input: days = [1,4,6,7,8,20], costs = [2,7,15]
Output: 11
Explanation:
For example, here is one way to buy passes that lets you travel your travel plan:
On day 1, you bought a 1-day pass for costs[0] = $2, which covered day 1.
On day 3, you bought a 7-day pass for costs[1] = $7, which covered days 3, 4, ..., 9.
On day 20, you bought a 1-day pass for costs[0] = $2, which covered day 20.
In total you spent $11 and covered all the days of your travel.

Example 2:
1
2
3
4
5
6
7
Input: days = [1,2,3,4,5,6,7,8,9,10,30,31], costs = [2,7,15]
Output: 17
Explanation:
For example, here is one way to buy passes that lets you travel your travel plan:
On day 1, you bought a 30-day pass for costs[2] = $15 which covered days 1, 2, ..., 30.
On day 31, you bought a 1-day pass for costs[0] = $2 which covered day 31.
In total you spent $17 and covered all the days of your travel.

Note:

  • 1 <= days.length <= 365
  • 1 <= days[i] <= 365
  • days is in strictly increasing order.
  • costs.length == 3
  • 1 <= costs[i] <= 1000

days数组中存储的是该年中去旅游的日期(范围为1到365之间的数字),costs数组大小为3,存储的是1天,7天和30天火车票的价格。我们需要做一个方案选择合适的购票方案达到旅游days天最省钱的目的。

算法描述

采用动态规划进行解决,假设现在是第days[i]天,我们在该天出行旅游需要选择买票方案,现在我们有三种方案:第一,购买一天的通行票,当天出行,花费就是第days[i-1]天的花费加上一天的通行票价;第二,购买七天的通行票,而七天的通行票可以在连续的七天之内使用,所以花费是第days[i-7]天的花费加上七天的通行票价(即从第days[i-8]天到days[i]天的花费都包含在这七天的通行票中);第三,购买三十天的通行票,同理,花费是days[i-30]天加上三十天的通行票价。然后我们在这三种方案中选择最实惠的。最后,在实现代码中注意数组越界的问题。

使用dp[j]代表着我们旅行到i天为止需要的最少旅行价格,递推公式为:

  • dp[j] = dp[j-1] (第j天不用旅行)
  • dp[j] = min(dp[j-1] + costs[0], dp[j-7] + costs[1], dp[j-30] + costs[2]) (第j天需要旅行)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int mincostTickets(vector<int>& days, vector<int>& costs) {
if(days.size() == 0)
return 0;
int dp[366] = {0};
for(int i = 1; i < 366; i ++) {
if(find(days.begin(), days.end(), i) == days.end() )
dp[i] = dp[i - 1];
else {
dp[i] = min(dp[i-1] + costs[0], min(dp[max(0, i-7)]+costs[1], dp[max(0, i-30)]+costs[2]));
}
}
return dp[365];
}
};

Leetcode984. String Without AAA or BBB

Given two integers A and B, return any string S such that:

  • S has length A + B and contains exactly A ‘a’ letters, and exactly B ‘b’ letters;
  • The substring ‘aaa’ does not occur in S;
  • The substring ‘bbb’ does not occur in S.

Example 1:

1
2
3
Input: A = 1, B = 2
Output: "abb"
Explanation: "abb", "bab" and "bba" are all correct answers.

Example 2:
1
2
Input: A = 4, B = 1
Output: "aabaa"

使用贪心,先选较多的然后再选较少的字母。主要是看两个字母哪个比较多,较多的哪个放到A上,然后判断A和B是否大于0,或者A是否大于B。
1
2
3
4
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 Solution {
public:
string strWithout3a3b(int A, int B) {
char a = 'a', b = 'b';
int temp2;
string res = "";
if(A < B) {
a = 'b';
b = 'a';
temp2 = A;
A = B;
B = temp2;
}
while(A>0 || B>0) {
if(A > 0) {
res += a;
A --;
}
if(A > B) {
res += a;
A --;
}
if(B > 0) {
res += b;
B --;
}
}
return res;
}
};

Leetcode985. Sum of Even Numbers After Queries

We have an array A of integers, and an array queries of queries.

For the i-th query val = queries[i][0], index = queries[i][1], we add val to A[index]. Then, the answer to the i-th query is the sum of the even values of A.

(Here, the given index = queries[i][1] is a 0-based index, and each query permanently modifies the array A.)

Return the answer to all queries. Your answer array should have answer[i] as the answer to the i-th query.

Example 1:

1
2
3
4
5
6
7
8
Input: A = [1,2,3,4], queries = [[1,0],[-3,1],[-4,0],[2,3]]
Output: [8,6,2,4]
Explanation:
At the beginning, the array is [1,2,3,4].
After adding 1 to A[0], the array is [2,2,3,4], and the sum of even values is 2 + 2 + 4 = 8.
After adding -3 to A[1], the array is [2,-1,3,4], and the sum of even values is 2 + 4 = 6.
After adding -4 to A[0], the array is [-2,-1,3,4], and the sum of even values is -2 + 4 = 2.
After adding 2 to A[3], the array is [-2,-1,3,6], and the sum of even values is -2 + 6 = 4.

题意比较曲折,就是在queries中的每个pair,某个位置加上一个数,在计算A数组中所有偶数的和。下边的代码会超时:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> sumEvenAfterQueries(vector<int>& A, vector<vector<int>>& queries) {
vector<int> res;
int size = queries.size(), val, index, sum;
for(int i = 0; i < size; i ++) {
sum = 0;
val = queries[i][0];
index = queries[i][1];
A[index] += val;
for(int j = 0; j < A.size(); j ++)
sum += (A[j]%2 ? 0 : A[j]);
res.push_back(sum);
}
return res;
}
};

应首先计算出所有的偶数和,再根据运算之后的结果进行计算。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<int> sumEvenAfterQueries(vector<int>& A, vector<vector<int>>& queries) {
vector<int> res;
int size = queries.size(), val, index, sum = 0;
for(int i = 0; i < A.size(); i ++)
sum += (A[i]%2 ? 0 : A[i]);
for(int i = 0; i < size; i ++) {
val = queries[i][0];
index = queries[i][1];
if(A[index]%2 == 0)
sum -= A[index];
A[index] += val;
if(A[index]%2 == 0)
sum += A[index];
res.push_back(sum);
}
return res;
}
};

Leetcode986. Interval List Intersections

Given two lists of closed intervals, each list of intervals is pairwise disjoint and in sorted order.

Return the intersection of these two interval lists.

(Formally, a closed interval [a, b] (with a <= b) denotes the set of real numbers x with a <= x <= b. The intersection of two closed intervals is a set of real numbers that is either empty, or can be represented as a closed interval. For example, the intersection of [1, 3] and [2, 4] is [2, 3].)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<vector<int>> intervalIntersection(vector<vector<int>>& A, vector<vector<int>>& B) {
if(A.size() == 0 || B.size() == 0)
return vector<vector<int>>{};
int sizea = A.size(), sizeb = B.size();
int l, r;
vector<vector<int>> res;
for(int i = 0, j = 0; i < sizea && j < sizeb;) {
l = max(A[i][0], B[j][0]);
r = min(A[i][1], B[j][1]);
if(l <= r)
res.push_back({l, r});
if(r == A[i][1])
i ++;
else j ++;
}
return res;
}
};

贪心,由于排好序了,直接双指针扫,思路和归并排序合并比较类似,注意往后移动的条件是尾部,因为一个矩形的结束条件是尾部比完了,不能写成是头部

Leetcode987. Vertical Order Traversal of a Binary Tree

Given a binary tree, return the vertical order traversal of its nodes values.

For each node at position (X, Y), its left and right children respectively will be at positions (X-1, Y-1) and (X+1, Y-1).

Running a vertical line from X = -infinity to X = +infinity, whenever the vertical line touches some nodes, we report the values of the nodes in order from top to bottom (decreasing Y coordinates).

If two nodes have the same position, then the value of the node that is reported first is the value that is smaller.

Return an list of non-empty reports in order of X coordinate. Every report will have a list of values of nodes.

Example 1:

1
2
3
4
5
6
7
8
Input: [3,9,20,null,null,15,7]
Output: [[9],[3,15],[20],[7]]
Explanation:
Without loss of generality, we can assume the root node is at position (0, 0):
Then, the node with value 9 occurs at position (-1, -1);
The nodes with values 3 and 15 occur at positions (0, 0) and (0, -2);
The node with value 20 occurs at position (1, -1);
The node with value 7 occurs at position (2, -2).

Example 2:
1
2
3
4
5
Input: [1,2,3,4,5,6,7]
Output: [[4],[2],[1,5,6],[3],[7]]
Explanation:
The node with value 5 and the node with value 6 have the same position according to the given scheme.
However, in the report "[1,5,6]", the node value of 5 comes first since 5 is smaller than 6.

Note:

  • The tree will have between 1 and 1000 nodes.
  • Each node s value will be between 0 and 1000.

要求把相同X的节点位置放在一起,并且要求结果中节点的存放是从上到下的。如果两个节点的坐标相同,那么value小的节点排列在前面。通过维护一个队列,我们从上到下依次遍历每个节点,给每个节点设置好了坐标。这个队列存储的是个三元组(TreeNode*,int x,int y)

1
2
3
4
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
class Solution {
public:
struct dat{
TreeNode* root;
int x, y;
};

vector<vector<int>> verticalTraversal(TreeNode* root) {
queue<struct dat> q;
struct dat te = {root, 0, 0};
q.push(te);
map<int, vector<pair<int, int>>> node;
while(!q.empty()) {
struct dat temp = q.front();
q.pop();
node[temp.x].push_back(make_pair(-temp.y, temp.root->val));
if(temp.root->left)
q.push({temp.root->left, temp.x - 1, temp.y - 1});
if(temp.root->right)
q.push({temp.root->right, temp.x + 1, temp.y - 1});
}
vector<vector<int>> res;
for(auto it : node) {
sort(it.second.begin(), it.second.end());
vector<int> tempp;
for(auto i : it.second)
tempp.push_back(i.second);
res.push_back(tempp);
}
return res;
}
};

Leetcode988. Smallest String Starting From Leaf

Given the root of a binary tree, each node has a value from 0 to 25 representing the letters ‘a’ to ‘z’: a value of 0 represents ‘a’, a value of 1 represents ‘b’, and so on.

Find the lexicographically smallest string that starts at a leaf of this tree and ends at the root.

(As a reminder, any shorter prefix of a string is lexicographically smaller: for example, “ab” is lexicographically smaller than “aba”. A leaf of a node is a node that has no children.)

Example 1:

1
2
Input: [0,1,2,3,4,3,4]
Output: "dba"

Example 2:
1
2
Input: [25,1,3,1,3,0,2]
Output: "adz"

Example 3:
1
2
Input: [2,2,1,null,1,0,null,0]
Output: "abc"

这个数组代表一个树,图就不上了,从叶子节点开始找到一个最小的字符串。

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:

string result = "zzzzzzzzzz";
void bianli(TreeNode* root, string cur) {
if(root->left == NULL && root->right == NULL) {
cur = (char)((root->val) + 'a')+cur;
result = result < cur ? result : cur;
return;
}
if(root->left != NULL) {
bianli(root->left, (char)(root->val+'a')+cur);
}
if(root->right != NULL) {
bianli(root->right, (char)(root->val+'a')+cur);
}
}

string smallestFromLeaf(TreeNode* root) {
bianli(root, "");
return result;
}
};

Leetcode989. Add to Array-Form of Integer

For a non-negative integer X, the array-form of X is an array of its digits in left to right order. For example, if X = 1231, then the array form is [1,2,3,1].

Given the array-form A of a non-negative integer X, return the array-form of the integer X+K.

Example 1:

1
2
3
Input: A = [1,2,0,0], K = 34
Output: [1,2,3,4]
Explanation: 1200 + 34 = 1234

Example 2:
1
2
3
Input: A = [2,7,4], K = 181
Output: [4,5,5]
Explanation: 274 + 181 = 455

Example 3:
1
2
3
Input: A = [2,1,5], K = 806
Output: [1,0,2,1]
Explanation: 215 + 806 = 1021

Example 4:
1
2
3
Input: A = [9,9,9,9,9,9,9,9,9,9], K = 1
Output: [1,0,0,0,0,0,0,0,0,0,0]
Explanation: 9999999999 + 1 = 10000000000

Note:

  • 1 <= A.length <= 10000
  • 0 <= A[i] <= 9
  • 0 <= K <= 10000
  • If A.length > 1, then A[0] != 0

大概意思是一个数组代表一个数,再给一个整数K,返回结果的各位数组成的数组,注意进位,我的做法很麻烦,需要两次反转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
vector<int> addToArrayForm(vector<int>& A, int K) {
vector<int> result;
int L=K;
reverse(A.begin(), A.end());
for(int i = 0; i < A.size(); i++){
A[i] = A[i] + L;
L = A[i]/10;
A[i]=A[i]%10;
}
while(L>0) {
A.push_back(L%10);
L = L/10;
}
reverse(A.begin(), A.end());
return A;
}
};

从数组最右边开始,逐位相加,用carry记录进位,每一次取和的最右一位存入链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public List<Integer> addToArrayForm(int[] A, int K) {
int len = A.length;
int carry = 0;
List<Integer> list = new ArrayList<Integer>();
for(int i = len - 1; i > -1; i--){
int sum = carry + A[i] + K % 10;
list.add(sum % 10);
carry = sum / 10;
K /= 10;
}
carry += K;
while(carry != 0){
list.add(carry % 10);
carry /= 10;
}
Collections.reverse(list);
return list;
}
}

Leetcode990. Satisfiability of Equality Equations

Given an array equations of strings that represent relationships between variables, each string equations[i] has length 4 and takes one of two different forms: “a==b” or “a!=b”. Here, a and b are lowercase letters (not necessarily different) that represent one-letter variable names.

Return true if and only if it is possible to assign integers to variable names so as to satisfy all the given equations.

Example 1:

1
2
3
Input: ["a==b","b!=a"]
Output: false
Explanation: If we assign say, a = 1 and b = 1, then the first equation is satisfied, but not the second. There is no way to assign the variables to satisfy both equations.

Example 2:
1
2
3
Input: ["b==a","a==b"]
Output: true
Explanation: We could assign a = 1 and b = 1 to satisfy both equations.

Example 3:
1
2
Input: ["a==b","b==c","a==c"]
Output: true

Example 4:
1
2
Input: ["a==b","b!=c","c==a"]
Output: false

Example 5:
1
2
Input: ["c==c","b==d","x!=z"]
Output: true

Note:

  • 1 <= equations.length <= 500
  • equations[i].length == 4
  • equations[i][0] and equations[i][3] are lowercase letters
  • equations[i][1] is either ‘=’ or ‘!’
  • equations[i][2] is ‘=’

给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:”a==b”或 “a!=b”。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。

只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false。

这个问题一看就是并查集问题,所以直接使用并查集就过了。将所有相等的元素构成一个集合中,然后判断不相等的元素是不是相同根即可。

1
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 arr[26];
int find(int x) {
return arr[x]==x?x:find(arr[x]);
}
void uni(int x, int y) {
arr[find(y)]=find(x);
}
bool equationsPossible(vector<string>& equations) {
for(int i=0;i<26;i++)
arr[i]=i;
for(int i=0;i<equations.size();i++){
if(equations[i][1]=='=') {
uni(equations[i][0]-'a', equations[i][3]-'a');
}
}
for(int i=0;i<equations.size();i++){
if(equations[i][1]=='!' && find(equations[i][0]-'a')==find(equations[i][3]-'a')) {
return false;
}
}
return true;
}
};

Leetcode991. Broken Calculator

On a broken calculator that has a number showing on its display, we can perform two operations:

  • Double: Multiply the number on the display by 2, or;
  • Decrement: Subtract 1 from the number on the display.

Initially, the calculator is displaying the number X.

Return the minimum number of operations needed to display the number Y.

Example 1:

1
2
3
Input: X = 2, Y = 3
Output: 2
Explanation: Use double operation and then decrement operation {2 -> 4 -> 3}.

Example 2:

1
2
3
Input: X = 5, Y = 8
Output: 2
Explanation: Use decrement and then double {5 -> 4 -> 8}.

Example 3:

1
2
3
Input: X = 3, Y = 10
Output: 3
Explanation: Use double, decrement and double {3 -> 6 -> 5 -> 10}.

Example 4:

1
2
3
Input: X = 1024, Y = 1
Output: 1023
Explanation: Use decrement operations 1023 times.

Note:

  • 1 <= X <= 10^9
  • 1 <= Y <= 10^9

这道题说是有一个坏了的计算器,其实就显示一个数字X,现在我们有两种操作,一种乘以2操作,一种是减1操作,问最少需要多少次操作才能得到目标数字Y。好,现在来分析,由于X和Y的大小关系并不确定,最简单的当然是X和Y相等,就不需要另外的操作了。当X大于Y时,由于都是正数,肯定就不能再乘2了,所以此时直接就可以返回 X-Y。比较复杂的情况就是Y大于X的情况,此时X既可以减1,又可以乘以2,但是仔细想想,我们的最终目的应该是要减小Y,直至其小于等于X,就可以直接得到结果。这里X乘以2的效果就相当于Y除以2,操作数都一样,但是Y除以2时还要看Y的奇偶性,如果Y是偶数,那么 OK,可以直接除以2,若是奇数,需要将其变为偶数,由于X可以减1,等价过来就是Y加1,所以思路就有了,当Y大于X时进行循环,然后判断Y的奇偶性,若是偶数,直接除以2,若是奇数,则加1,当然此时结果 res 也要对应增加。循环退出后,还要加上 X-Y 的值即可,参见代码如下:

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int brokenCalc(int start, int target) {
int res = 0;
while(target > start) {
target = (target % 2) ? (target + 1) : target / 2;
res ++;
}
return res + start - target;
}
};

若用递归来写就相当的简洁了,可以两行搞定,当然若你够 geek 的话,也可以压缩到一行,参见代码如下:

1
2
3
4
5
6
7
class Solution {
public:
int brokenCalc(int X, int Y) {
if (X >= Y) return X - Y;
return (Y % 2 == 0) ? (1 + brokenCalc(X, Y / 2)) : (1 + brokenCalc(X, Y + 1));
}
};

Leetcode993. Cousins in Binary Tree

In a binary tree, the root node is at depth 0, and children of each depth k node are at depth k+1.

Two nodes of a binary tree are cousins if they have the same depth, but have different parents. We are given the root of a binary tree with unique values, and the values x and y of two different nodes in the tree. Return true if and only if the nodes corresponding to the values x and y are cousins.

Example 1:

1
2
Input: root = [1,2,3,4], x = 4, y = 3
Output: false

Example 2:
1
2
Input: root = [1,2,3,null,4,null,5], x = 5, y = 4
Output: true

Example 3:
1
2
Input: root = [1,2,3,null,4], x = 2, y = 3
Output: false

求解x,y的深度和父亲结点,如果深度一样,父亲结点不同,就是true;否则,就是false。
1
2
3
4
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:

TreeNode* dfs(TreeNode* root, int x, int depth, int &level) {
if(root == NULL)
return NULL;
if((root->left && root->left->val == x) || (root->right && root->right->val == x)) {
level = depth;
return root;
}
TreeNode *left = dfs(root->left, x, depth + 1, level);
if (left)
return left;

TreeNode *right = dfs(root->right, x, depth + 1, level);
if (right)
return right;
return NULL;
}

bool isCousins(TreeNode* root, int x, int y) {
int level_a, level_b;
TreeNode *xx = dfs(root, x, 0, level_a);
TreeNode *yy = dfs(root, y, 0, level_b);
if(xx != yy && level_a == level_b)
return true;
return false;
}
};

Leetcode994. Rotting Oranges

You are given an m x n grid where each cell can have one of three values:

  • 0 representing an empty cell,
  • 1 representing a fresh orange, or
  • 2 representing a rotten orange.

Every minute, any fresh orange that is 4-directionally adjacent to a rotten orange becomes rotten.

Return the minimum number of minutes that must elapse until no cell has a fresh orange. If this is impossible, return -1.

Example 1:

1
2
Input: grid = [[2,1,1],[1,1,0],[0,1,1]]
Output: 4

Example 2:

1
2
3
Input: grid = [[2,1,1],[0,1,1],[1,0,1]]
Output: -1
Explanation: The orange in the bottom left corner (row 2, column 0) is never rotten, because rotting only happens 4-directionally.

Example 3:

1
2
3
Input: grid = [[0,2]]
Output: 0
Explanation: Since there are already no fresh oranges at minute 0, the answer is just 0.

Constraints:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 10
  • grid[i][j] is 0, 1, or 2.

这道题说给的一个 mxn 大小的格子上有些新鲜和腐烂的橘子,每一分钟腐烂的橘子都会传染给其周围四个中的新鲜橘子,使得其也变得腐烂。现在问需要多少分钟可以使得所有的新鲜橘子都变腐烂,无法做到时返回 -1。由于这里新鲜的橘子自己不会变腐烂,只有被周围的腐烂橘子传染才会,所以当新鲜橘子周围不会出现腐烂橘子的时候,那么这个新鲜橘子就不会腐烂,这才会有返回 -1 的情况。这道题就是个典型的广度优先遍历 Breadth First Search,并没有什么太大的难度,先遍历一遍整个二维数组,统计出所有新鲜橘子的个数,并把腐烂的橘子坐标放入一个队列 queue,之后进行 while 循环,循环条件是队列不会空,且 freshLeft 大于0,使用层序遍历的方法,用个 for 循环在内部。每次取出队首元素,遍历其周围四个位置,越界或者不是新鲜橘子都跳过,否则将新鲜橘子标记为腐烂,加入队列中,并且 freshLeft 自减1。每层遍历完成之后,结果 res 自增1,最后返回的时候,若还有新鲜橘子,即 freshLeft 大于0时,返回 -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
26
27
28
29
30
31
32
33
34
class Solution {
public:
int orangesRotting(vector<vector<int>>& grid) {
int res = 0, m = grid.size(), n = grid[0].size(), freshLeft = 0;
queue<pair<int, int> > q;
vector<vector<int>> dirs{{-1, 0}, {0, 1}, {1, 0}, {0, -1}};
for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++) {
if (grid[i][j] == 1)
freshLeft ++;
else if (grid[i][j] == 2)
q.push(make_pair(i, j));
}
while(!q.empty() && freshLeft > 0) {
int size = q.size();
for (int i = 0; i < size; i ++) {
int x = q.front().first, y = q.front().second;
q.pop();

for (int j = 0; j < 4; j ++) {
int xx = x + dirs[j][0];
int yy = y + dirs[j][1];
if (0 > xx || xx >= m || 0 > yy || yy >= n || grid[xx][yy] != 1)
continue;
grid[xx][yy] = 2;
q.push(make_pair(xx, yy));
freshLeft --;
}
}
res ++;
}
return freshLeft > 0 ? -1 : res;
}
};

Leetcode997. Find the Town Judge

In a town, there are N people labelled from 1 to N. There is a rumor that one of these people is secretly the town judge.

If the town judge exists, then:

The town judge trusts nobody.
Everybody (except for the town judge) trusts the town judge.
There is exactly one person that satisfies properties 1 and 2.
You are given trust, an array of pairs trust[i] = [a, b] representing that the person labelled a trusts the person labelled b.

If the town judge exists and can be identified, return the label of the town judge. Otherwise, return -1.

Example 1:

1
2
Input: N = 2, trust = [[1,2]]
Output: 2

Example 2:
1
2
Input: N = 3, trust = [[1,3],[2,3]]
Output: 3

Example 3:
1
2
Input: N = 3, trust = [[1,3],[2,3],[3,1]]
Output: -1

Example 4:
1
2
Input: N = 3, trust = [[1,2],[2,3]]
Output: -1

Example 5:
1
2
Input: N = 4, trust = [[1,3],[1,4],[2,3],[2,4],[4,3]]
Output: 3

在一个小镇里,按从 1 到 N 标记了 N 个人。传言称,这些人中有一个是小镇上的秘密法官。如果小镇的法官真的存在,那么:小镇的法官不相信任何人。每个人(除了小镇法官外)都信任小镇的法官。只有一个人同时满足属性 1 和属性 2 。给定数组 trust,该数组由信任对 trust[i] = [a, b] 组成,表示标记为 a 的人信任标记为 b 的人。如果小镇存在秘密法官并且可以确定他的身份,请返回该法官的标记。否则,返回 -1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
int findJudge(int N, vector<vector<int>>& trust) {
int in[1005] = {0}, out[1005] = {0};
for(int i = 0; i < trust.size(); i ++) {
in[trust[i][1]] ++;
out[trust[i][0]] = -1;
}
for(int i = 1; i <= N; i ++) {
if(out[i] == 0 && in[i] == N - 1)
return i;
}
return -1;
}
};

Leetcode998. Maximum Binary Tree II

We are given the root node of a maximum tree: a tree where every node has a value greater than any other value in its subtree.
Just as in the previous problem, the given tree was constructed from an list A (root = Construct(A)) recursively with the following Construct(A) routine:

  • If A is empty, return null.
  • Otherwise, let A[i] be the largest element of A. Create a root node with value A[i].
  • The left child of root will be Construct([A[0], A[1], …, A[i-1]])
  • The right child of root will be Construct([A[i+1], A[i+2], …, A[A.length - 1]])
  • Return root.

Note that we were not given A directly, only a root node root = Construct(A).

Suppose B is a copy of A with the value val appended to it. It is guaranteed that B has unique values.

Return Construct(B).

Example 1:

1
2
3
Input: root = [4,1,3,null,null,2], val = 5
Output: [5,4,null,1,3,null,null,2]
Explanation: A = [1,4,2,3], B = [1,4,2,3,5]

Example 2:

1
2
3
Input: root = [5,2,4,null,1], val = 3
Output: [5,2,4,null,1,null,3]
Explanation: A = [2,1,5,4], B = [2,1,5,4,3]

Example 3:

1
2
3
Input: root = [5,2,3,null,1], val = 4
Output: [5,2,4,null,1,3]
Explanation: A = [2,1,5,3], B = [2,1,5,3,4]

Note:

  • 1 <= B.length <= 100

和上一题654相比,这道题没有给max binary tree的原始数组nums,而是已经从654建好的tree root。那么已知新的数组是nums后面再加一个val,要返回modify过的新max binary tree。那么分为三种情况讨论,也就是example给出的三种:

  1. val > root ->val,新数字将成为新的根节点,root被连接到左边;
  2. val < root -> val,那么要遍历寻找该插入的位置,因为顺序问题,我们不考虑向左子树插入,只向右子树方向递归寻找这个再一次使val > root -> val满足的位置。如果没有找到,需要将新节点连成最后一个max的右子树;
  3. 如果找到了这样一个节点parent,那么它的右子树将被连接到新节点的左子树,而新节点被连到parent的右子树。
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
TreeNode* insertIntoMaxTree(TreeNode* root, int val) {
TreeNode* node = new TreeNode(val);
if (!root || root -> val < val) {
node -> left = root;
return node;
}
root -> right = insertIntoMaxTree(root -> right, val);
return root;
}
};

Leetcode999. Available Captures for Rook

On an 8 x 8 chessboard, there is one white rook. There also may be empty squares, white bishops, and black pawns. These are given as characters ‘R’, ‘.’, ‘B’, and ‘p’ respectively. Uppercase characters represent white pieces, and lowercase characters represent black pieces.

The rook moves as in the rules of Chess: it chooses one of four cardinal directions (north, east, west, and south), then moves in that direction until it chooses to stop, reaches the edge of the board, or captures an opposite colored pawn by moving to the same square it occupies. Also, rooks cannot move into the same square as other friendly bishops.

Return the number of pawns the rook can capture in one move.

Example 1:

1
2
3
4
5
6
7
8
9
10
11
12
Input: 
[[".",".",".",".",".",".",".","."],
[".",".",".","p",".",".",".","."],
[".",".",".","R",".",".",".","p"],
[".",".",".",".",".",".",".","."],
[".",".",".",".",".",".",".","."],
[".",".",".","p",".",".",".","."],
[".",".",".",".",".",".",".","."],
[".",".",".",".",".",".",".","."]]
Output: 3
Explanation:
In this example the rook is able to capture all the pawns.

Example 2:

1
2
3
4
5
6
7
8
9
10
11
12
Input: [
[".",".",".",".",".",".",".","."],
[".","p","p","p","p","p",".","."],
[".","p","p","B","p","p",".","."],
[".","p","B","R","B","p",".","."],
[".","p","p","B","p","p",".","."],
[".","p","p","p","p","p",".","."],
[".",".",".",".",".",".",".","."],
[".",".",".",".",".",".",".","."]]
Output: 0
Explanation:
Bishops are blocking the rook to capture any pawn.

Example 3:

1
2
3
4
5
6
7
8
9
10
11
12
Input: [
[".",".",".",".",".",".",".","."],
[".",".",".","p",".",".",".","."],
[".",".",".","p",".",".",".","."],
["p","p",".","R",".","p","B","."],
[".",".",".",".",".",".",".","."],
[".",".",".","B",".",".",".","."],
[".",".",".","p",".",".",".","."],
[".",".",".",".",".",".",".","."]]
Output: 3
Explanation:
The rook can capture the pawns at positions b5, d6 and f5.

Note:

  1. board.length == board[i].length == 8
  2. board[i][j] is either ‘R’, ‘.’, ‘B’, or ‘p’
  3. There is exactly one cell with board[i][j] == ‘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
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
class Solution {
public:
int numRookCaptures(vector<vector<char>>& board) {
int x,y;
int res=0;
bool bBlack;
for (int row = 0; row < 8; row++)
for (int col = 0; col < 8; col++)
if (board[row][col] == 'R' ||
board[row][col] == 'r') {
x = row;
y = col;
if (board[row][col] == 'R') {
bBlack = false;
}
else {
bBlack = true;
}
break;
}

for(int j=x-1;j>=0;j--){
if(board[j][y]=='.')
continue;
if(board[j][y]=='P' && bBlack==true){
res++;
break;
}
else if(board[j][y]=='p' && bBlack==false){
res++;
break;
}
else
break;
}
for(int j=x+1;j<8;j++){
if(board[j][y]=='.')
continue;
if(board[j][y]=='P' && bBlack==true){
res++;
break;
}
else if(board[j][y]=='p' && bBlack==false){
res++;
break;
}
else
break;
}
for(int j=y-1;j>=0;j--){
if(board[x][j]=='.')
continue;
if(board[x][j]=='P' && bBlack==true){
res++;
break;
}
else if(board[x][j]=='p' && bBlack==false){
res++;
break;
}
else
break;
}
for(int j=y+1;j<8;j++){
if(board[x][j]=='.')
continue;
if(board[x][j]=='P' && bBlack==true){
res++;
break;
}
else if(board[x][j]=='p' && bBlack==false){
res++;
break;
}
else
break;
}
return res;
}
};

来源:https://blog.csdn.net/maokelong95/article/details/51989081

前言

堆内存(Heap Memory)是一个很有意思的领域。你可能和我一样,也困惑于下述问题很久了:

  • 如何从内核申请堆内存?
  • 谁管理它?内核、库函数,还是应用本身?
  • 内存管理效率怎么这么高?!
  • 堆内存的管理效率可以进一步提高吗?
    最近,我终于有时间去深入了解这些问题。下面就让我来谈谈我的调研成果。

开源社区公开了很多现成的内存分配器(Memory Allocators,以下简称为分配器):

  • dlmalloc – 第一个被广泛使用的通用动态内存分配器;
  • ptmalloc2 – glibc 内置分配器的原型;
  • jemalloc – FreeBSD & Firefox 所用分配器;
  • tcmalloc – Google 贡献的分配器;
  • libumem – Solaris 所用分配器;

每一种分配器都宣称自己快(fast)、可拓展(scalable)、效率高(memory efficient)!但是并非所有的分配器都适用于我们的应用。内存吞吐量大(memory hungry)的应用程序,其性能很大程度上取决于分配器的性能。

历史:ptmalloc2 基于 dlmalloc 开发,其引入了多线程支持,于 2006 年发布。发布之后,ptmalloc2 整合进了 glibc 源码,此后其所有修改都直接提交到了 glibc malloc 里。因此,ptmalloc2 的源码和 glibc malloc 的源码有很多不一致的地方。(译者注:1996 年出现的 dlmalloc 只有一个主分配区,该分配区为所有线程所争用,1997 年发布的 ptmalloc 在 dlmalloc 的基础上引入了非主分配区的概念。)

申请堆的系统调用

我在之前的文章中提到过,malloc 内部通过 brk 或 mmap 系统调用向内核申请堆区。

在内存管理领域,我们一般用「堆」指代用于分配动态内存的虚拟地址空间,而用「栈」指代用于分配静态内存的虚拟地址空间。具体到虚拟内存布局(Memory Layout),堆维护在通过 brk 系统调用申请的「Heap」及通过 mmap 系统调用申请的「Memory Mapping Segment」中;而栈维护在通过汇编栈指令动态调整的「Stack」中。在 Glibc 里,「Heap」用于分配较小的内存及主线程使用的内存。

下图为 Linux 内核 v2.6.7 之后,32 位模式下的虚拟内存布局方式。

多线程支持

Linux 的早期版本采用 dlmalloc 作为它的默认分配器,但是因为 ptmalloc2 提供了多线程支持,所以 后来 Linux 就转而采用 ptmalloc2 了。多线程支持可以提升分配器的性能,进而间接提升应用的性能。

在 dlmalloc 中,当两个线程同时 malloc 时,只有一个线程能够访问临界区(critical section)——这是因为所有线程共享用以缓存已释放内存的「空闲列表数据结构」(freelist data structure),所以使用 dlmalloc 的多线程应用会在 malloc 上耗费过多时间,从而导致整个应用性能的下降。

在 ptmalloc2 中,当两个线程同时调用 malloc 时,内存均会得以立即分配——每个线程都维护着单独的堆,各个堆被独立的空闲列表数据结构管理,因此各个线程可以并发地从空闲列表数据结构中申请内存。这种为每个线程维护独立堆与空闲列表数据结构的行为就「per thread arena」。

案例代码

1
2
3
4
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
/* Per thread arena example. */
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>

void* threadFunc(void* arg) {
printf("Before malloc in thread 1\n");
getchar();
char* addr = (char*) malloc(1000);
printf("After malloc and before free in thread 1\n");
getchar();
free(addr);
printf("After free in thread 1\n");
getchar();
}

int main() {
pthread_t t1;
void* s;
int ret;
char* addr;

printf("Welcome to per thread arena example::%d\n",getpid());
printf("Before malloc in main thread\n");
getchar();
addr = (char*) malloc(1000);
printf("After malloc and before free in main thread\n");
getchar();
free(addr);
printf("After free in main thread\n");
getchar();
ret = pthread_create(&t1, NULL, threadFunc, NULL);
if(ret)
{
printf("Thread creation error\n");
return -1;
}
ret = pthread_join(t1, &s);
if(ret)
{
printf("Thread join error\n");
return -1;
}
return 0;
}

案例输出

在主线程 malloc 之前

从如下的输出结果中我们可以看到,这里还没有堆段也没有每个线程的栈,因为 thread1 还没有创建!

1
2
3
4
5
6
7
8
9
10
11
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread 
Welcome to per thread arena example::6501
Before malloc in main thread
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
b7e05000-b7e07000 rw-p 00000000 00:00 0
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在主线程 malloc 之后

从如下的输出结果中我们可以看到,堆段已经产生,并且其地址区间正好在数据段(0x0804b000 - 0x0806c000)上面,这表明堆内存是移动「Program Break」的位置产生的(也即通过 brk 中断)。此外,请注意,尽管用户只申请了 1000 字节的内存,但是实际产生了 132KB 的堆。这个连续的堆区域被称为「arena」。因为这个 arena 是被主线程建立的,因此其被称为「main arena」。接下来的申请会继续分配这个 arena 的 132KB 中剩余的部分。当分配完毕时,它可以通过继续移动 Program Break 的位置扩容。扩容后,「top chunk」的大小也随之调整,以将这块新增的空间圈进去;相应地,arena 也可以在 top chunk 过大时缩小。

注意:top chunk 是一个 arena 位于最顶层的 chunk。有关 top chunk 的更多信息详见后续章节「top chunk」部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread 
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
...
sploitfun@sploitfun-VirtualBox:~/lsploits/hof/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7e05000-b7e07000 rw-p 00000000 00:00 0
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在主线程 free 之后

从如下的输出结果中我们可以看到,当分配的内存区域 free 掉时,其并不会立即归还给操作系统,而仅仅是移交给了作为库函数的分配器。这块 free 掉的内存添加在了「main arenas bin」中(在 glibc malloc 中,空闲列表数据结构被称为「bin」)。随后当用户请求内存时,分配器就不再向内核申请新堆了,而是先试着各个「bin」中查找空闲内存。只有当 bin 中不存在空闲内存时,分配器才会继续向内核申请内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread 
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
...
sploitfun@sploitfun-VirtualBox:~/lsploits/hof/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7e05000-b7e07000 rw-p 00000000 00:00 0
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在 thread1 malloc 之前

从如下的输出结果中我们可以看到,此时 thread1 的堆尚不存在,但其栈已产生。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread 
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
Before malloc in thread 1
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7604000-b7605000 ---p 00000000 00:00 0
b7605000-b7e07000 rw-p 00000000 00:00 0 [stack:6594]
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在 thread1 malloc 之后

从如下的输出结果中我们可以看到,thread1 的堆段(b7500000 - b7521000,132KB)建立在了内存映射段中,这也表明了堆内存是使用 mmap 系统调用产生的,而非同主线程一样使用 sbrk 系统调用。类似地,尽管用户只请求了 1000B,但是映射到程地址空间的堆内存足有 1MB。这 1MB 中,只有 132KB 被设置了读写权限,并成为该线程的堆内存。这段连续内存(132KB)被称为「thread arena」。

注意:当用户请求超过 128KB(比如 malloc(132*1024)) 大小并且此时 arena 中没有足够的空间来满足用户的请求时,内存将通过 mmap 系统调用(不再是 sbrk)分配,而不论请求是发自 main arena 还是 thread arena。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread 
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
Before malloc in thread 1
After malloc and before free in thread 1
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7500000-b7521000 rw-p 00000000 00:00 0
b7521000-b7600000 ---p 00000000 00:00 0
b7604000-b7605000 ---p 00000000 00:00 0
b7605000-b7e07000 rw-p 00000000 00:00 0 [stack:6594]
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

在 thread1 free 之后

从如下的输出结果中我们可以看到,free 不会把内存归还给操作系统,而是移交给分配器,然后添加在了「thread arenas bin」中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ ./mthread 
Welcome to per thread arena example::6501
Before malloc in main thread
After malloc and before free in main thread
After free in main thread
Before malloc in thread 1
After malloc and before free in thread 1
After free in thread 1
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$ cat /proc/6501/maps
08048000-08049000 r-xp 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
08049000-0804a000 r--p 00000000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804a000-0804b000 rw-p 00001000 08:01 539625 /home/sploitfun/ptmalloc.ppt/mthread/mthread
0804b000-0806c000 rw-p 00000000 00:00 0 [heap]
b7500000-b7521000 rw-p 00000000 00:00 0
b7521000-b7600000 ---p 00000000 00:00 0
b7604000-b7605000 ---p 00000000 00:00 0
b7605000-b7e07000 rw-p 00000000 00:00 0 [stack:6594]
...
sploitfun@sploitfun-VirtualBox:~/ptmalloc.ppt/mthread$

Arena

Arena 的数量

在以上的例子中我们可以看到,主线程包含 main arena 而 thread 1 包含它自己的 thread arena。所以线程和 arena 之间是否存在一一映射关系,而不论线程的数量有多大?当然不是,部分极端的应用甚至运行比处理器核数还多的线程,在这种情况下,每个线程都拥有一个 arena 开销过高且意义不大。所以,arena 数量其实是限于系统核数的。

1
2
3
4
For 32 bit systems:
Number of arena = 2 * number of cores + 1.
For 64 bit systems:
Number of arena = 8 * number of cores + 1.

Multiple Arena

举例而言:让我们来看一个运行在单核计算机上的 32 位操作系统上的多线程应用(4 线程,主线程 + 3 个线程)的例子。这里线程数量(4)> 2 * 核心数(1) + 1,所以分配器中至少有一个 Arena(也即标题所称「multiple arenas」)会被所有线程共享。那么是如何共享的呢?

  • 当主线程第一次调用 malloc 时,已经建立的 main arena 会被没有任何竞争地使用;
  • 当 thread 1 和 thread 2 第一次调用 malloc 时,一块新的 arena 将被创建,且将被没有任何竞争地使用。此时线程和 arena 之间存在一一映射关系;
  • 当 thread3 第一次调用 malloc 时,arena 的数量限制被计算出来,结果显示已超出,因此尝试复用已经存在的 arena(也即 Main arena 或 Arena 1 或 Arena 2);
  • 复用:
    一旦遍历到可用 arena,就开始自旋申请该 arena 的锁;
    如果上锁成功(比如说 main arena 上锁成功),就将该 arena 返回用户;
    如果没找到可用 arena,thread 3 的 malloc 将被阻塞,直到有可用的 arena 为止。
  • 当thread 3 调用 malloc 时(第二次了),分配器会尝试使用上一次使用的 arena(也即,main arena),从而尽量提高缓存命中率。当 main arena 可用时就用,否则 thread 3 就一直阻塞,直至 main arena 空闲。因此现在 main arena 实际上是被 main thread 和 thread 3 所共享。

    Multiple Heaps

    在「glibc malloc」中主要有 3 种数据结构:

  • heap_info ——Heap Header—— 一个 thread arena 可以维护多个堆。每个堆都有自己的堆 Header(注:也即头部元数据)。什么时候 Thread Arena 会维护多个堆呢? 一般情况下,每个 thread arena 都只维护一个堆,但是当这个堆的空间耗尽时,新的堆(而非连续内存区域)就会被 mmap 到这个 aerna 里;

  • malloc_state ——Arena header—— 一个 thread arena 可以维护多个堆,这些堆另外共享同一个 arena header。Arena header 描述的信息包括:bins、top chunk、last remainder chunk 等;
  • malloc_chunk ——Chunk header—— 根据用户请求,每个堆被分为若干 chunk。每个 chunk 都有自己的 chunk header。

注意:

  • Main arena 无需维护多个堆,因此也无需 heap_info。当空间耗尽时,与 thread arena 不同,main arena 可以通过 sbrk 拓展堆段,直至堆段「碰」到内存映射段;
  • 与 thread arena 不同,main arena 的 arena header 不是保存在通过 sbrk 申请的堆段里,而是作为一个全局变量,可以在 libc.so 的数据段中找到。

main arena 和 thread arena 的图示如下(单堆段):

thread arena 的图示如下(多堆段):

Chunk

堆段中存在的 chunk 类型如下:

  • Allocated chunk;
  • Free chunk;
  • Top chunk;
  • Last Remainder chunk.

Allocated chunk

「Allocated chunck」就是已经分配给用户的 chunk,其图示如下:

图中左方三个箭头依次表示:

  • chunk:该 Allocated chunk 的起始地址;
  • mem:该 Allocated chunk 中用户可用区域的起始地址(= chunk + sizeof(malloc_chunk));
  • next_chunk:下一个 chunck(无论类型)的起始地址。

图中结构体内部各字段的含义依次为:

  • prev_size:若上一个 chunk 可用,则此字段赋值为上一个 chunk 的大小;否则,此字段被用来存储上一个 chunk 的用户数据;
  • size:此字段赋值本 chunk 的大小,其最后三位包含标志信息:
  • PREV_INUSE § – 置「1」表示上个 chunk 被分配;
  • IS_MMAPPED (M) – 置「1」表示这个 chunk 是通过 mmap 申请的(较大的内存);
  • NON_MAIN_ARENA (N) – 置「1」表示这个 chunk 属于一个 thread arena。

注意:

malloc_chunk 中的其余结构成员,如 fd、 bk,没有使用的必要而拿来存储用户数据;
用户请求的大小被转换为内部实际大小,因为需要额外空间存储 malloc_chunk,此外还需要考虑对齐。

Free chunk

「Free chunck」就是用户已释放的 chunk,其图示如下:

图中结构体内部各字段的含义依次为:

  • prev_size: 两个相邻 free chunk 会被合并成一个,因此该字段总是保存前一个 allocated chunk 的用户数据;
  • size: 该字段保存本 free chunk 的大小;
  • fd: Forward pointer —— 本字段指向同一 bin 中的下个 free chunk(free chunk 链表的前驱指针);
  • bk: Backward pointer —— 本字段指向同一 bin 中的上个 free chunk(free chunk 链表的后继指针)。

Bins

「bins」 就是空闲列表数据结构。它们用以保存 free chunks。根据其中 chunk 的大小,bins 被分为如下几种类型:

  • Fast bin;
  • Unsorted bin;
  • Small bin;
  • Large bin.

保存这些 bins 的字段为:

  • fastbinsY: 这个数组用以保存 fast bins;
  • bins: 这个数组用于保存 unsorted bin、small bins 以及 large bins,共计可容纳 126 个,其中:
  • Bin 1: unsorted bin;
  • Bin 2 - 63: small bins;
  • Bin 64 - 126: large bins.

Fast Bin

大小为 16 ~ 80 字节的 chunk 被称为「fast chunk」。在所有的 bins 中,fast bins 路径享有最快的内存分配及释放速度。

  • 数量:10
  • 每个 fast bin 都维护着一条 free chunk 的单链表,采用单链表是因为链表中所有 chunk 的大小相等,增删 chunk 发生在链表顶端即可;—— LIFO
  • chunk 大小:8 字节递增
  • fast bins 由一系列所维护 chunk 大小以 8 字节递增的 bins 组成。也即,fast bin[0] 维护大小为 16 字节的 chunk、fast bin[1] 维护大小为 24 字节的 chunk。依此类推……
  • 指定 fast bin 中所有 chunk 大小相同;
  • 在 malloc 初始化过程中,最大的 fast bin 的大小被设置为 64 而非 80 字节。因为默认情况下只有大小 16 ~ 64 的 chunk 被归为 fast chunk 。
  • 无需合并 —— 两个相邻 chunk 不会被合并。虽然这可能会加剧内存碎片化,但也大大加速了内存释放的速度!
  • malloc(fast chunk)
  • 初始情况下 fast chunck 最大尺寸以及 fast bin 相应数据结构均未初始化,因此即使用户请求内存大小落在 fast chunk 相应区间,服务用户请求的也将是 small bin 路径而非 fast bin 路径;
  • 初始化后,将在计算 fast bin 索引后检索相应 bin;
  • 相应 bin 中被检索的第一个 chunk 将被摘除并返回给用户。
  • free(fast chunk)
  • 计算 fast bin 索引以索引相应 bin;
  • free 掉的 chunk 将被添加到上述 bin 的顶端。

Unsorted Bin

当 small chunk 和 large chunk 被 free 掉时,它们并非被添加到各自的 bin 中,而是被添加在 「unsorted bin」 中。这使得分配器可以重新使用最近 free 掉的 chunk,从而消除了寻找合适 bin 的时间开销,进而加速了内存分配及释放的效率。

  • 数量:1
  • unsorted bin 包括一个用于保存 free chunk 的双向循环链表(又名 binlist);
  • chunk 大小:无限制,任何大小的 chunk 均可添加到这里。

Small Bin

大小小于 512 字节的 chunk 被称为 「small chunk」,而保存 small chunks 的 bin 被称为 「small bin」。在内存分配回收的速度上,small bin 比 large bin 更快。

  • 数量:62
  • 每个 small bin 都维护着一条 free chunk 的双向循环链表。free 掉的 chunk 添加在链表的顶端,而 malloc 的 chunk 从链表尾端摘除。—— FIFO
  • chunk 大小:8 字节递增
  • Small bins 由一系列所维护 chunk 大小以 8 字节递增的 bins 组成。举例而言,small bin[0] (Bin 2)维护着大小为 16 字节的 chunks、small bin[1](Bin 3)维护着大小为 24 字节的 chunks ,依此类推……
  • 指定 small bin 中所有 chunk 大小均相同,因此无需排序;
  • 合并 —— 相邻的 free chunk 将被合并,这减缓了内存碎片化,但是减慢了 free 的速度;
  • malloc(small chunk)
  • 初始情况下,small bins 都是 NULL,因此尽管用户请求 small chunk ,提供服务的将是 unsorted bin 路径而不是 small bin 路径;
  • 第一次调用 malloc 时,维护在 malloc_state 中的 small bins 和 large bins 将被初始化,它们都会指向自身以表示其为空;
  • 此后当 small bin 非空,相应的 bin 会摘除其中最后一个 chunk 并返回给用户;
  • free(small chunk)
  • free chunk 的时候,检查其前后的 chunk 是否空闲,若是则合并,也即把它们从所属的链表中摘除并合并成一个新的 chunk,新 chunk 会添加在unsorted bin 的前端。

    Large Bin

    大小大于等于 512 字节的 chunk 被称为「large chunk」,而保存 large chunks 的 bin 被称为 「large bin」。在内存分配回收的速度上,large bin 比 small bin 慢。

  • 数量:63

  • 每个 large bin 都维护着一条 free chunk 的双向循环链表。free 掉的 chunk 添加在链表的顶端,而 malloc 的 chunk 从链表尾端摘除。——FIFO
  • 这 63 个 bins
  • 32 个 bins 所维护的 chunk 大小以 64B 递增,也即 large chunk[0](Bin 65) 维护着大小为 512B ~ 568B 的 chunk 、large chunk[1](Bin 66) 维护着大小为 576B ~ 632B 的 chunk,依此类推……
  • 16 个 bins 所维护的 chunk 大小以 512 字节递增;
  • 8 个 bins 所维护的 chunk 大小以 4096 字节递增;
  • 4 个 bins 所维护的 chunk 大小以 32768 字节递增;
  • 2 个 bins 所维护的 chunk 大小以 262144 字节递增;
  • 1 个 bin 维护所有剩余 chunk 大小;
  • 不像 small bin ,large bin 中所有 chunk 大小不一定相同,各 chunk 大小递减保存。最大的 chunk 保存顶端,而最小的 chunk 保存在尾端;
  • 合并 —— 两个相邻的空闲 chunk 会被合并;
  • malloc(large chunk)
  • 初始情况下,large bin 都会是 NULL,因此尽管用户请求 large chunk ,提供服务的将是 next largetst bin 路径而不是 large bin 路劲 。
  • 第一次调用 malloc 时,维护在 malloc_state 中的 small bin 和 large bin 将被初始化,它们都会指向自身以表示其为空;
  • 此后当 large bin 非空,如果相应 bin 中的最大 chunk 大小大于用户请求大小,分配器就从该 bin 顶端遍历到尾端,以找到一个大小最接近用户请求的 chunk。一旦找到,相应 chunk 就会被切分成两块:
  • User chunk(用户请求大小)—— 返回给用户;
  • Remainder chunk (剩余大小)—— 添加到 unsorted bin。
  • 如果相应 bin 中的最大 chunk 大小小于用户请求大小,分配器就会扫描 binmaps,从而查找最小非空 bin。如果找到了这样的 - bin,就从中选择合适的 chunk 并切割给用户;反之就使用 top chunk 响应用户请求。
  • free(large chunk) —— 类似于 small chunk 。

Top Chunk

一个 arena 中最顶部的 chunk 被称为「top chunk」。它不属于任何 bin 。当所有 bin 中都没有合适空闲内存时,就会使用 top chunk 来响应用户请求。

当 top chunk 的大小比用户请求的大小大的时候,top chunk 会分割为两个部分:

  • User chunk,返回给用户;
  • Remainder chunk,剩余部分,将成为新的 top chunk。
    当 top chunk 的大小比用户请求的大小小的时候,top chunk 就通过 sbrk(main arena)或 mmap( thread arena)系统调用扩容。

Last Remainder Chunk

「last remainder chunk」即最后一次 small request 中因分割而得到的剩余部分,它有利于改进引用局部性,也即后续对 small chunk 的 malloc 请求可能最终被分配得彼此靠近。

那么 arena 中的若干 chunks,哪个有资格成为 last remainder chunk 呢?

当用户请求 small chunk 而无法从 small bin 和 unsorted bin 得到服务时,分配器就会通过扫描 binmaps 找到最小非空 bin。正如前文所提及的,如果这样的 bin 找到了,其中最合适的 chunk 就会分割为两部分:返回给用户的 User chunk 、添加到 unsorted bin 中的 Remainder chunk。这一 Remainder chunk 就将成为 last remainder chunk。

那么引用局部性是如何达成的呢?

当用户的后续请求 small chunk,并且 last remainder chunk 是 unsorted bin 中唯一的 chunk,该 last remainder chunk 就将分割成两部分:返回给用户的 User chunk、添加到 unsorted bin 中的 Remainder chunk(也是 last remainder chunk)。因此后续的请求的 chunk 最终将被分配得彼此靠近。

内存管理器ptmalloc

内存布局

了解ptmalloc内存管理器,就必须得先了解操作系统的内存布局方式。通过下面这个图,我很很清晰的可以看到堆、栈等的内存分布。

X86平台LINUX进程内存布局:

上图就是linux操作系统的内存布局。内存从低到高分别展示了操作系统各个模块的内存分布。

  • Test Segment:存放程序代码,只读,编译的时候确定
  • Data Segment:存放程序运行的时候就能确定的数据,可读可写
  • BBS Segment:定义而没有初始化的全局变量和静态变量
  • Heap:堆。堆的内存地址由低到高。
  • Mmap:映射区域。
  • Stack:栈。编译器自动分配和释放。内存地址由高到低

ptmalloc内存管理器

ptmalloc是glibc默认的内存管理器。我们常用的malloc和free就是由ptmalloc内存管理器提供的基础内存分配函数。ptmalloc有点像我们自己写的内存池,当我们通过malloc或者free函数来申请和释放内存的时候,ptmalloc会将这些内存管理起来,并且通过一些策略来判断是否需要回收给操作系统。这样做的最大好处就是:让用户申请内存和释放内存的时候更加高效。

为了内存分配函数malloc的高效性,ptmalloc会预先向操作系统申请一块内存供用户使用,并且ptmalloc会将已经使用的和空闲的内存管理起来;当用户需要销毁内存free的时候,ptmalloc又会将回收的内存管理起来,根据实际情况是否回收给操作系统。

设计假设

ptmalloc在设计时折中了高效率,高空间利用率,高可用性等设计目标。所以有了下面一些设计上的假设条件:

  1. 具有长生命周期的大内存分配使用mmap。
  2. 特别大的内存分配总是使用mmap。
  3. 具有短生命周期的内存分配使用brk。
  4. 尽量只缓存临时使用的空闲小内存块,对大内存块或是长生命周期的大内存块在释放时都直接归还给操作系统。
  5. 对空闲的小内存块只会在malloc和free的时候进行合并,free时空闲内存块可能放入pool中,不一定归还给操作系统。
  6. 收缩堆的条件是当前free的块大小加上前后能合并chunk的大小大于64KB、,并且堆顶的大小达到阈值,才有可能收缩堆,把堆最顶端的空闲内存返回给操作系统。
  7. 需要保持长期存储的程序不适合用ptmalloc来管理内存。
  8. 不停的内存分配ptmalloc会对内存进行切割和合并,会导致一定的内存碎片

主分配区和非主分配区

ptmalloc的内存分配器中,为了解决多线程锁争夺问题,分为主分配区main_area和非主分配区no_main_area。

  1. 每个进程有一个主分配区,也可以允许有多个非主分配区。
  2. 主分配区可以使用brk和mmap来分配,而非主分配区只能使用mmap来映射内存块
  3. 非主分配区的数量一旦增加,则不会减少。
  4. 主分配区和非主分配区形成一个环形链表进行管理。

chunk 内存块的基本组织单元

ptmalloc通过chunk的数据结构来组织每个内存单元。当我们使用malloc分配得到一块内存的时候,这块内存就会通过chunk的形式被记录到glibc上并且管理起来。你可以把它想象成自己写内存池的时候的一个内存数据结构。chunk的结构可以分为使用中的chunk和空闲的chunk。使用中的chunk和空闲的chunk数据结构基本项同,但是会有一些设计上的小技巧,巧妙的节省了内存。

使用中的chunk:

  1. chunk指针指向chunk开始的地址;mem指针指向用户内存块开始的地址。
  2. p=0时,表示前一个chunk为空闲,prev_size才有效
  3. p=1时,表示前一个chunk正在使用,prev_size无效 p主要用于内存块的合并操作
  4. ptmalloc 分配的第一个块总是将p设为1, 以防止程序引用到不存在的区域
  5. M=1 为mmap映射区域分配;M=0为heap区域分配
  6. A=1 为非主分区分配;A=0 为主分区分配

空闲的chunk:

  1. 空闲的chunk会被放置到空闲的链表bins上。当用户申请内存malloc的时候,会先去查找空闲链表bins上是否有合适的内存。
  2. fp和bp分别指向前一个和后一个空闲链表上的chunk
  3. fp_nextsize和bp_nextsize分别指向前一个空闲chunk和后一个空闲chunk的大小,主要用于在空闲链表上快速查找合适大小的chunk。
  4. fp、bp、fp_nextsize、bp_nextsize的值都会存在原本的用户区域,这样就不需要专门为每个chunk准备单独的内存存储指针了。

空闲链表bins

当用户使用free函数释放掉的内存,ptmalloc并不会马上交还给操作系统(这边很多时候我们明明执行了free函数,但是进程内存并没有回收就是这个原因),而是被ptmalloc本身的空闲链表bins管理起来了,这样当下次进程需要malloc一块内存的时候,ptmalloc就会从空闲的bins上寻找一块合适大小的内存块分配给用户使用。这样的好处可以避免频繁的系统调用,降低内存分配的开销。

ptmalloc一共维护了128bin。每个bins都维护了大小相近的双向链表的chunk。

通过上图这个bins的列表就能看出,当用户调用malloc的时候,能很快找到用户需要分配的内存大小是否在维护的bin上,如果在某一个bin上,就可以通过双向链表去查找合适的chunk内存块给用户使用。

  1. fast bins。fast bins是bins的高速缓冲区,大约有10个定长队列。当用户释放一块不大于max_fast(默认值64)的chunk(一般小内存)的时候,会默认会被放到fast bins上。当用户下次需要申请内存的时候首先会到fast bins上寻找是否有合适的chunk,然后才会到bins上空闲的chunk。ptmalloc会遍历fast bin,看是否有合适的chunk需要合并到bins上。

  2. unsorted bin。是bins的一个缓冲区。当用户释放的内存大于max_fast或者fast bins合并后的chunk都会进入unsorted bin上。当用户malloc的时候,先会到unsorted bin上查找是否有合适的bin,如果没有合适的bin,ptmalloc会将unsorted bin上的chunk放入bins上,然后到bins上查找合适的空闲chunk。

  3. small bins和large bins。small bins和large bins是真正用来放置chunk双向链表的。每个bin之间相差8个字节,并且通过上面的这个列表,可以快速定位到合适大小的空闲chunk。前64个为small bins,定长;后64个为large bins,非定长。

  4. Top chunk。并不是所有的chunk都会被放到bins上。top chunk相当于分配区的顶部空闲内存,当bins上都不能满足内存分配要求的时候,就会来top chunk上分配。

  5. mmaped chunk。当分配的内存非常大(大于分配阀值,默认128K)的时候,需要被mmap映射,则会放到mmaped chunk上,当释放mmaped chunk上的内存的时候会直接交还给操作系统。

内存分配malloc流程

  1. 获取分配区的锁,防止多线程冲突。
  2. 计算出需要分配的内存的chunk实际大小。
  3. 判断chunk的大小,如果小于max_fast(64b),则取fast bins上去查询是否有适合的chunk,如果有则分配结束。
  4. chunk大小是否小于512B,如果是,则从small bins上去查找chunk,如果有合适的,则分配结束。
  5. 继续从 unsorted bins上查找。如果unsorted bins上只有一个chunk并且大于待分配的chunk,则进行切割,并且剩余的chunk继续扔回unsorted bins;如果unsorted bins上有大小和待分配chunk相等的,则返回,并从unsorted bins删除;如果unsorted bins中的某一chunk大小 属于small bins的范围,则放入small bins的头部;如果unsorted bins中的某一chunk大小 属于large bins的范围,则找到合适的位置放入。
  6. 从large bins中查找,找到链表头后,反向遍历此链表,直到找到第一个大小 大于待分配的chunk,然后进行切割,如果有余下的,则放入unsorted bin中去,分配则结束。
  7. 如果搜索fast bins和bins都没有找到合适的chunk,那么就需要操作top chunk来进行分配了(top chunk相当于分配区的剩余内存空间)。判断top chunk大小是否满足所需chunk的大小,如果是,则从top chunk中分出一块来。
  8. 如果top chunk也不能满足需求,则需要扩大top chunk。主分区上,如果分配的内存小于分配阀值(默认128k),则直接使用brk()分配一块内存;如果分配的内存大于分配阀值,则需要mmap来分配;非主分区上,则直接使用mmap来分配一块内存。通过mmap分配的内存,就会放入mmap chunk上,mmap chunk上的内存会直接回收给操作系统。

内存释放free流程

  1. 获取分配区的锁,保证线程安全。
  2. 如果free的是空指针,则返回,什么都不做。
  3. 判断当前chunk是否是mmap映射区域映射的内存,如果是,则直接munmap()释放这块内存。前面的已使用chunk的数据结构中,我们可以看到有M来标识是否是mmap映射的内存。
  4. 判断chunk是否与top chunk相邻,如果相邻,则直接和top chunk合并(和top chunk相邻相当于和分配区中的空闲内存块相邻)。转到步骤8
  5. 如果chunk的大小大于max_fast(64b),则放入unsorted bin,并且检查是否有合并,有合并情况并且和top chunk相邻,则转到步骤8;没有合并情况则free。
  6. 如果chunk的大小小于 max_fast(64b),则直接放入fast bin,fast bin并没有改变chunk的状态。没有合并情况,则free;有合并情况,转到步骤7
  7. 在fast bin,如果当前chunk的下一个chunk也是空闲的,则将这两个chunk合并,放入unsorted bin上面。合并后的大小如果大于64KB,会触发进行fast bins的合并操作,fast bins中的chunk将被遍历,并与相邻的空闲chunk进行合并,合并后的chunk会被放到unsorted bin中,fast bin会变为空。合并后的chunk和topchunk相邻,则会合并到topchunk中。转到步骤8
  8. 判断top chunk的大小是否大于mmap收缩阈值(默认为128KB),如果是的话,对于主分配区,则会试图归还top chunk中的一部分给操作系统。free结束。

mallopt 参数调优

  1. M_MXFAST:用于设置fast bins中保存的chunk的最大大小,默认值为64B。最大80B
  2. M_TRIM_THRESHOLD:用于设置mmap收缩阈值,默认值为128KB。
  3. M_MMAP_THRESHOLD:M_MMAP_THRESHOLD用于设置mmap分配阈值,默认值为128KB。当用户需要分配的内存大于mmap分配阈值,ptmalloc的malloc()函数其实相当于mmap()的简单封装,free函数相当于munmap()的简单封装。
  4. M_MMAP_MAX:M_MMAP_MAX用于设置进程中用mmap分配的内存块的地址段数量,默认值为65536
  5. M_TOP_PAD:该参数决定了,当libc内存管理器调用brk释放内存时,堆顶还需要保留的空闲内存数量。该值缺省为 0.

使用注意事项

为了避免Glibc内存暴增,需要注意:

  1. 后分配的内存先释放,因为ptmalloc收缩内存是从top chunk开始,如果与top chunk相邻的chunk不能释放,top chunk以下的chunk都无法释放。
  2. Ptmalloc不适合用于管理长生命周期的内存,特别是持续不定期分配和释放长生命周期的内存,这将导致ptmalloc内存暴增。
  3. 多线程分阶段执行的程序不适合用ptmalloc,这种程序的内存更适合用内存池管理
  4. 尽量减少程序的线程数量和避免频繁分配/释放内存。频繁分配,会导致锁的竞争,最终导致非主分配区增加,内存碎片增高,并且性能降低。
  5. 防止内存泄露,ptmalloc对内存泄露是相当敏感的,根据它的内存收缩机制,如果与top chunk相邻的那个chunk没有回收,将导致top chunk一下很多的空闲内存都无法返回给操作系统。
  6. 防止程序分配过多内存,或是由于Glibc内存暴增,导致系统内存耗尽,程序因OOM被系统杀掉。预估程序可以使用的最大物理内存大小,配置系统的/proc/sys/vm/overcommit_memory,/proc/sys/vm/overcommit_ratio,以及使用ulimt –v限制程序能使用虚拟内存空间大小,防止程序因OOM被杀掉。

TCMalloc内存分配与管理简述

TCMalloc给每个线程分配了一个线程局部缓存,小对象的分配是直接由线程局部缓存来完成的,这样就避免了多线程程序中的锁竞争情况。当线程局部缓存中的内存不够时,会将对象从中央数据结构移动到线程局部缓存中,同时定期的用垃圾收集器把内存从线程局部缓存迁移回中央数据结构中。

TCMalloc将尺寸小于等于256 * 1024字节的对象(“小”对象)和大对象区分开来。大对象直接使用页级分配器从中央页堆直接分配。即,一个大对象总是页对齐的并占据了整数个数的页。

小对象的分配

每个小对象的大小都会被映射到与之接近的可分配的class中的一个。例如,所有大小在833到1024字节之间的小对象时,都会归整到1024字节。60个可分配的尺寸类别这样隔开:较小的尺寸相差8字节,较大的尺寸相差16字节,再大一点的尺寸差32字节,如此等等。最大的间隔是控制的,这样刚超过上一个级别被分配到下一个级别就不会有太多的内存被浪费。

一个线程缓存包含了由各个尺寸内存的对象组成的单链表,如图所示:

当分配一个小对象时:

  1. 我们将其大小映射到对应的尺寸中。
  2. 查找当前线程的线程缓存中相应的尺寸的内存链表。
  3. 如果当前尺寸内存链表非空,那么从链表中移除的第一个对象并返回它。

当我们按照这种方式分配时,TCMalloc不需要任何锁。这就可以极大提高分配的速度,因为锁/解锁操作在一个2.8GHzXeon上大约需要100纳秒的时间。

如果当前尺寸内存链表为空:

  1. 从Central Cache中取得一系列这种尺寸的对象(CentralCache是被所有线程共享的)。
  2. 将他们放入该线程线程的缓冲区。
  3. 返回一个新获取的对象给应用程序。

如果CentralCache也为空:

  1. 我们从中央页堆中分配一系列页面。
  2. 将他们分割成该尺寸的一系列对象。
  3. 将新分配的对象放入CentralCache的链表上
  4. 像前面一样,将部分对象移入线程局部的链表中。

如果中央页堆也为空,那么就从系统中分配一系列的页面(使用sbrk、mmap或者通过在/dev/mem中进行映射),把页面给中央页堆,然后继续上面的操作

大对象的分配

一个大对象的尺寸会被中央页堆直接处理,被圆整到一个页面尺寸(4K)。中央页堆是由空闲内存列表组成的数组。对于i < 256而言,数组的第k个元素是一个由每个单元是由k个页面组成的空闲内存链表。第256个条目则是一个包含了长度>= 256个页面的空闲内存链表:

k个页面的一次分配通过在第k个空闲内存链表中查找来完成。如果该空闲内存链表为空,那么我们则在下一个空闲内存链表中查找,如此继续。最终,如果必要的话,我们将在最后空闲内存链表中查找。如果这个动作也失败了,我们将向系统获取内存(使用sbrk、mmap或者通过在/dev/mem中进行映射)

如果k个页面的分配是由连续的> k个页面的空闲内存链表完成的,剩下的连续页面将被重新插回到与之页面大小接近的空闲内存链表中去。

TCMalloc的内存分配的主要层次

第一层

线程局部分配,ThreadCache包含了一个不同对象大小的空闲链表数组,其实现采用操作系统的线程局部存储功能。分配时几乎不需要用锁,除非触发CentralCache的操作。

ThreadCache中的重要数据结构:

  • pthread_t tid_; 绑定线程,达到每个线程有个缓冲池的目的
  • FreeList list_[kNumClasses]; 这个数组就是上图中的第一列,如下图


数组中的每一个节点就是代表上图中的每一行,如下图

每个class对应多大的内存空间?这个表示每组的大小的变量在哪里?

不存在这样的变量,但是通过映射关系可以达到一个class管一类size的作用,

如下图所示,由cl得到list_[cl],这也即是一个class。

至于cl,是由class_array_得到的。

若申请的内存是13字节,但分配的却是15字节,那么便会有2个字节的内存碎片(内部碎片)。

第二层

中心分配,Centralcache

该层的分配需要锁。CentralCacheThreadCache之间的空闲链表是一一对应的,以子链表为单位(obj个数很可能为num_objects_to_movecl),见do_malloc与do_free流程图)进行互相交换。

CentralCache的内存从PageHeap里获得。从PageHeap获得的内存叫Span。一个Span在使用时只能用于同一大小的空闲链表,一但CentralCachePageHeap中获取新的Span,这个Span就是一个串好的相同大小内存的空闲链表。

Centralcache中有几个重要数据结构:

  • TCEntry tc_slots_[kMaxNumTransferEntries];
    • tc_slots_每个节点存放的是一组obj链表,这一组obj的个数为num_objects_to_move,TCEntry结构体有两指针,分别指向这个链表的头和尾。
    • tc_slots_存放的是threadCacheCentralCache归还的obj链表,并且只有当个数满足num_objects_to_move时,才会放入tc_slots_。否则归还的obj根据其所处的span,进行归还,若对应的spanempty,那么由于此时被归还内存了,所以其有空闲obj了,便把该spanempty队列清除,把其加入nonempty队列。
  • span empty
    • FetchFromSpans函数把一个objnonempty队列中的一个span中切出,准备给threadCache。当切完这个obj后,如果该span已经没有内存空间了,那么便把该spannonempty队列移除,并加入empty队列。
  • span nonempty
    • CentralCache从中央页堆申请页面,中央页堆以span的形式返回。在CentralCache中会把该span切成大小为class_to_sizeobj,并把所有的obj链接起来,链表头为span->objects。再把该链表加入nonempty队列。
    • nonempty队列另外一个被加入span的地方在内存从threadCache归还给CentralCache时,具体情况见上面tc_slots_这一数据结构的描述。

第三层

中央页堆,PageHeap

PageHeap以一定数量连续页面内存的形式提供内存。这组连续的页面由一个Span对象描述,Span对象和它描述的页面内存是独立的。Span对象保存了页面的id序列,页面id左移一个page就是内存的地址。由于页面和Span内存独立,需要用page id反向映射查找Span对象就需要单独的映射表。这个表用radix tree实现,兼顾效率和内存。PageHeap还负责合并和拆分相邻的Span。

PageHeap重要数据结构:

  • SpanList large_;
  • SpanList free_[kMaxPages];

中央页堆是由空闲内存列表组成的数组。对于i< 256而言,数组的第k个元素是一个由每个单元是由k个页面组成的空闲内存链表(这也即是free_)。第256个条目(这即是large_)则是一个包含了长度>= 256个页面的空闲内存链表:

SpanList

1
2
3
4
struct SpanList {
Span normal;
Span returned;
};

Returned代表的是已经归还给系统的span

第四层

系统页面分配,这就是调用系统函数了。

内存在各层之间的传递

ThreadCache与CentralCache内存传递

  1. ThreadCache内存不够时,要从CentralCache拿(RemoveRange),再把拿到的内存加入ThreadCachelist_[cl]链表队列。(PushRange)
  2. ThreadCacheCentralCache拿或者返还给CentralCache的内存,是一种什么逻辑?

当拿内存时,如果申请的内存大小是0.23kb,先是找到ThreadCache中对应给0.23kb的内存组的大小是多少,这里假设是0.3kb。然后根据num_objects_to_move(0.3)函数获得每次应该传递的obj的个数。ThreadCache还内存给CentralCacheReleaseToCentralCache),一次也是还num_objects_to_move(0.3)个,把该obj全部放到tc_slots,但如果实在是不满足该条件,就把内存还给SpanReleaseListToSpans)。

正常情况肯定是申请num_objects_to_move(cl)obj,除非FreeList本身能容纳的obj个数不够num_objects_to_move(cl)

当要归还的obj个数大于num_objects_to_move(cl)时,一次还Static::sizemap()->num_objects_to_move(cl)obj,归还给tc_slots_数组。最后多余的不够num_objects_to_move(cl)obj通过ReleaseListToSpans函数归还。

CentralCache与中央页堆的内存传递

FetchFromSpansSafe()首先会调用FetchFromSpans(从Span中切一个obj对应的内存片),当FetchFromSpans调用失败,也即nonempty队列中对应的span连一个obj的内存都切不出来时,便会调用populate函数从中央页堆中获取内存。倘若FetchFromSpansSafe中的FetchFromSpans能切除一个obj,就不从中央页堆申请内存,RemoveRange函数后续继续用FetchFromSpans切另外N-1obj,倘若此时nonempty中内存只够mobj的(m<N-1),那么此时就返回mobjThreadCache

中央页堆每次传递给CentralCache的内存也是固定的,每次传递class_to_pages(size_class_)个页面。这n个页面就是一个Span,该span会被切成obj链接起来,然后把该Span插入CentralCachenonempty中。

从中央页堆拿class_to_pages(size_class_)个页面利用的是New函数,该函数首先是在中央页堆的free_或者large_队列中拿内存,如果这两都不符合条件,那么就要从系统内存拿啦(GrowHeap)

中央页堆与系统内存的内存传递

系统内存每次传递给中央页堆的页面数,与populate函数中传进来的页面数n以及系统参数kMinSystemAlloc有关,如下面的语句:

1
Length ask =(n>kMinSystemAlloc) ? n : static_cast<Length>(kMinSystemAlloc)  (GrowHeap函数里)

GrowHeap中利用TCMalloc_SystemAlloc向系统申请内存(其底层会调用mmap或者是sbrk)。把系统分配来的内存弄成Span,把生成的Span的信息记录进radix tree,日后通过页面ID便可通过get函数查找到其对应的Span对象,再通过Delete函数把新生成的Span加入中央页堆的free_或者large_normal或者returned队列(在Delete函数里面,会合并相邻的Span)

TCMalloc中涉及到的几个重要的数据结构

initStaticVars()里面首先会调用SizeMap.initSizeMap是一个非常关键的数据结构,SizeMap里面涉及到几个关键的数据结构class_array_class_to_size_class_to_pages_num_objects_to_move_

其中class_array将一个size映射成为一个class num,被映射的class num一共有kNumClassesnum,而class_to_size_class_to_pages_num_objects_to_move_这三个数组都是拥有kNumClassesnum的数组。所以根据class_array映射得到的class num,也即另外3个数组的索引号,就可以使用另外3个数组。

根据这个索引号可以从class_to_size数组中得到基于这个索引(也即最开始的size)的可分配obj的最大size,假设这个大小的size叫做Asize

可以从num_objects_to_move数组中得到基于这个索引(也即最开始的size)的在ThreadCacheCentralCache之间移动的obj的数量,该obj的大小就是Asize

可以从class_to_pages_数组中得到基于这个索引(也即最开始的size)的在CentralCache和中央页堆之间移动的页面数量。

class_to_size数组,num_objects_to_move数组,class_to_pages_数组均是在SizeMap::Init()函数中被初始化。class_to_size_数组最终会被初始化为8,16,32,48(16递增,直到128字节),128字节后,是另外一直形式的递增,一直到kMaxSize。到了kMaxSize后,又换一种形式的递增

TCMalloc中内存分配流程

内存分配流程图如上图,具体流程如下:

1、Tcmalloc首先判断mallocsize是否大于kMaxSize,如果小于这个值,那么将size转换为想的obj class,然后从当前thread私有的cacheAllocate,转至第2步。如果请求的size大于kMaxSize那么跳至第10。

2、首先判断当前的threadcacheobj calss对应的freelist中是否包含有空闲的obj,如果有直接pop出来,否则从CentralCache中拿,转下一步。

3、CentralCacheThreadCache之间obj的转移采用batch方式,每次转移固定数量的obj,这个数量通过Static::sizemap()->num_objects_to_move定义,当然在决定最终转移数量时还是需要不能超过ThreadCache相应listmaxlength。然后通过CentralCache对应freelistRemoveRange函数将确定大小的obj转移出来,并通过对应listPushRange函数将这些obj插入ThreadCache对应的freelist

4、CentralCache通过RemoveRange将特定数量的obj移出,CentralCache将连续的内存看做一个SpanSpanCentralCache管理内存的一个主要数据结构。而Span又被切分成N个统一大小的obj

5、在Allocate的过程中,首先判断需要Allocateobj数量是不是正好符合num_objects_to_move,如果是而且CentralCache用来存放spanslots不为空,那么直接从slots里面拿,否则从nonempty队列中的Span拿。

6、Nonempty队列存放了所有可用的Span,那么从头开始一个个拿,如果拿光了还是不能满足要求,那么只能通过向pageheap要求一个span,这个spansizeclass_to_pages决定,然后再将这个Span切成obj返回给CentralCache。然后再次尝试从Span分配。

7、Pageheap管理整个系统page级别的allocate,他通过两个数据结构管理所有的Spanfree_数组和large_列表),free_数组存放size小于kMaxPagesSpan,而large_列表存放大于等于kMaxPagesSpanPageHeap首先判断要求的pages是否大于等于kMaxPages,如果小于那么先从free数组中找,从要求大小的位置开始往后找,先找normal队列再找return对队列。如果在normal队列中找到且找到的Span状态为Span::ON_NORMAL_FREELIST,那么直接从里面切出需要的Span返回给CentralCache。如果在return队列中找到且找到的Span状态为Span::ON_RETURNED_FREELIST那么直接从里面切出需要的Span返回给CentralCache

8、如果需要的size不符合上述要求或者在上述队列中没有找到那么将从large_队列中找。从large_队列中查找时,首先从normal队列入手,然后再从return队列找,他将找到size最符合且地址在空闲Span中最小的Span,然后切出来返回。

9、如果large_队列中都没有找到合适的Span,那么将通过GrowHeap增长Heap的方式,通过TCMalloc_SystemAlloc向系统申请内存。并包装成Span,并插入heap中,然后再次进行分配。

10、来到此处代表分配的内存是大于32k的,那么将向heap直接请求跳到第7步。

TCMalloc中内存释放流程

一些未解决的问题

线程缓冲区的大小的确定

Tcmallloc官方文档上说线程缓冲区的大小是慢启动的,在源码中找到了它的慢启动代码,但是还没有研究明白这个慢启动到底是一个什么逻辑。

程序里有三处地方与该缓冲区大小确定有关,三处地方分别是FetchFromCentralCache,ListTooLong以及Scavenge。具体怎么确定的还没有研究,先做个备忘录而已。

恰当线程缓冲区大小至关重要,如果缓冲区太小,我们需要经常去CentralHeap分配;如果线程缓冲区太大,又致使大量对象闲置而浪费内存。

注意到恰当的线程缓冲区的大小对内存的释放一样重要。如果没有线程缓冲,每次内存释放都需要把内存移回到Central Heap。同样,一些线程有不对称的内存分配和释放行为(例如:生产者和消费者线程),所以确定恰当的缓冲区大小也很棘手。

确定缓冲区大小,我们采用“慢开始”算法来确定每一个尺寸内存链表的最大长度。当某个链表使用更频繁,我们就扩大他的长度。如果我们某个链表上释放的操作比分配操作更多,它的最大长度将被增长到整个链表可以一次性有效的移动到Central Heap的长度。

下面的伪代码说明了这种慢开始算法。注意到num_objects_to_move对每一个尺寸是不同的。通过移动特定长度的对象链表,中央缓冲可以高效的将链表在线程中传递。如果线程缓冲区的需要小于num_objects_to_move,在中央缓冲区上的这种操作具有线性的时间复杂度。使用num_objects_to_move作为从中央缓冲区传递的对象数量的缺点是,它将不需要的那部分对象浪费在线程缓冲区。

TCMalloc与APR,ptmalloc的分析比较

tcmalloc与APR,ptmalloc的不同点

tcmalloc与APR初始化时都不会预先分配内存。但是tcmalloc申请一个小对象后(最开始时的申请,此时threadCache,CentralCache等结构中还没内存),便会向系统申请几个页面的内存,中央页堆,CentralCache,threadCache会分别标记属于他们的内存。然后再从threadCache中分配一个obj给申请者。

而APR不一样,申请者申请内存(最开始时的申请), 新分配的内存并不挂接到内存分配器链表中,而是在调用allocator_free进行内存释放的时候,内存才可能挂到内存分配器链表上。新分配的内存在内存池节点的active链表中。

这也即是APR的一大缺点:apr_pool的一个大缺点就是从池中申请的内存不能归还给内存池,只能等pool销毁的时候才能归还。为了弥补这个缺点,apr_pool的实际使用中,可以申请拥有不同生命周期的内存池。

Ptmalloc:在使用malloc之前,heap大小为0.若请求空间小于mmap分配阈值,主分配区会调用sbrk()增加一块大小为(128kb+chunk_size)align4KB的空间作为heap。非主分配区会调用一块大小为HEAP_MAX_SIZE(32位系统上默认为1M,64位系统上默认为64M)的空间作为sub_heap(3.2.3.4小节),这就是ptmalloc维护的分配空间。

当用户释放了heap中的chunk时,ptmalloc又会使用fastbins和bins来组织空闲chunk,以备下一次分配。

所以三者对内存的维护处理各不相同,初始化时三者都不会预分配空间。但一旦有了malloc,tcmalloc与ptmalloc便会预先分配一块大内存加入内存池(应该可以称为内存池吧),tcmalloc把内存分到各级,但是ptmalloc只是放在heap中。

tcmalloc应该是只有一个内存池的概念,而APR则是多内存池概念。

各内存池的优缺点

Ptmalloc缺点:

  1. 多线程由于锁冲突,所以慢
  2. 容易造成内存暴增:因为ptmalloc的内存收缩是从topchunk开始,如果与topchunk相邻的那个chunk没有被释放,topchunk以下的空闲内存都无法返回给系统,即是这些空闲内存有几十个G也不行。(ptmalloc文档第4节)
  3. 容易造成内存碎片(ptmalloc文档第4节第5点),ptmalloc就不会存在这种大块内存碎片的问题,由于其内存管理机制。不过ptmalloc也会引起小的内存碎片,比如我申请的是13字节,对应的size是15字节,那么便会有2个自己的内存碎片。不过

APR缺点:APR从系统申请来的内存先是放在内存池节点的active链表中链起来,并不会加入内存分配器的free链表数组。所以当内存中一直是在malloc,但是却没有free时,active链表链接了大量内存,而free链表数组一直没值,这样容易把系统内存耗尽

APR优点:可以建立子内存池,这样建立不同周期的内存池。 比如连接内存池与请求内存池

看这些个分配器的分配机制,可见这些内存管理机制都是针对小内存分配和管理。对大块内存还是直接用了系统调用。所以在程序中应该尽量避免大内存的malloc/new、free/delete操作。另外这个分配器的最小粒度都是以8字节为单位的,所以频繁分配小内存,像int啊bool啊什么的,仍然会浪费空间。经过测试无论是对bool、int、short进行new的时候,实际消耗的内存在ptmalloc和tcmalloc下64位系统地址间距都是32个字节。大量new测试的时候,ptmalloc平均每次new消耗32字节,tcmalloc消耗8字节(我想说ptmalloc弱爆啦,而且tcmalloc)。所以大量使用这些数据的时候不妨用数组自己维护一个内存池,可以减少很多的内存浪费。(原来STL的map和set一个节点要消耗近80个字节有这么多浪费在这里了啊)

而多线程下对于比较大的数据结构,为了减少分配时的锁争用,最好是自己维护内存池。单线程的话无所谓了,呵呵。不过自己维护内存池是增加代码复杂度,减少 内存管理复杂度。但是我觉得,255个分页以下(1MB)的内存话,tcmalloc的分配和管理机制已经相当nice,没太大必要自己另写一个。

内存池的一个实践

背景

首先,我们介绍下什么是内存池?

❝预先在内存中申请一定数量的内存块留作备用,当有新的内存需求时,就先从内存池中分配内存返回,在释放的时候,将内存返回给内存池而不是OS,在下次申请的时候,重新进行分配❞

那么为什么要有内存池呢?这就需要从传统内存分配的特点来进行分析,传统内存分配释放的优点无非就是通用性强,应用广泛,但是传统的内存分配、释放在某些特定的项目中,其不一定是最优、效率最高的方案。

传统内存分配、释放的缺点总结如下:

  1. 调用malloc/new,系统需要根据“最先匹配”、“最优匹配”或其他算法在内存空闲块表中查找一块空闲内存,调用free/delete,系统可能需要合并空闲内存块,这些会产生额外开销
  2. 频繁的在堆上申请和释放内存必然需要大量时间,降低了程序的运行效率。对于一个需要频繁申请和释放内存的程序来说,频繁调用new/malloc申请内存,delete/free释放内存都需要花费系统时间,频繁的调用必然会降低程序的运行效率。
  3. 经常申请小块内存,会将物理内存“切”得很碎,导致内存碎片。申请内存的顺序并不是释放内存的顺序,因此频繁申请小块内存必然会导致内存碎片,造成“有内存但是申请不到大块内存”的现象。

从上图中,可以看出,应用程序会调用glibc运行时库的malloc函数进行内存申请,而malloc函数则会根据具体申请的内存块大小,根据实际情况最终从sys_brk或者sys_mmap_pgoff系统调用申请内存,而大家都知道,跟os打交道,_性能损失_是毋庸置疑的。

其次,glibc作为通用的运行时库,malloc/free需要满足各种场景需求,比如申请的字节大小不一,多线程访问等。有没有比传统malloc/free性能更优的方案呢?答案是:有。

在程序启动的时候,我们预分配特定数量的固定大小的块,这样每次申请的时候,就从预分配的块中获取,释放的时候,将其放入预分配块中以备下次复用,这就是所谓的_内存池技术_,每个内存池对应特定场景,这样的话,较传统的传统的malloc/free少了很多复杂逻辑,性能显然会提升不少。结合传统malloc/free的缺点,我们总结下使用内存池方案的优点:

  1. 比malloc/free进行内存申请/释放的方式快
  2. 不会产生或很少产生堆碎片
  3. 可避免内存泄漏

根据分配出去的字节大小是否固定,分为固定大小内存池和可变大小内存池两类。而可变大小内存池,可分配任意大小的内存池,比如ptmalloc、jemalloc以及google的tcmalloc。固定大小内存池,顾名思义,每次申请和释放的内存大小都是固定的。每次分配出去的内存块大小都是程序预先定义的值,而在释放内存块时候,则简单的挂回内存池链表即可。

内存池

内存池,重点在”池“字上,之所以称之为内存池,是在真正使用之前,先预分配一定数量、大小预设的块,如果有新的内存需求时候,就从内存池中根据申请的内存大小,分配一个内存块,若当前内存块已经被完全分配出去,则继续申请一大块,然后进行分配。

当进行内存块释放的时候,则将其归还内存池,后面如果再有申请的话,则将其重新分配出去。

原理

  • 创建并初始化头结点MemoryPool
  • 通过MemoryPool进行内存分配,如果发现MemoryPool所指向的第一块MemoryBlock或者现有MemoryPool没有空闲内存块,则创建一个新的MemoryBlock初始化之后将其插入MemoryPool的头
  • 在内存分配的时候,遍历MemoryPool中的单链表MemoryBlock,根据地址判断所要释放的内存属于哪个MemoryBlock,然后根据偏移设置MemoryBlock的第一块空闲块索引,同时将空闲块个数+1

上述只是一个简单的逻辑讲解,比较宏观,下面我们将通过图解和代码的方式来进行讲解。


在上图中,我们画出了内存池的结构图,从图中,可以看出,有两个结构变量,分别为MemoryPool和MemoryBlock。

下面我们将从数据结构和接口两个部分出发,详细讲解内存池的设计。

数据结构

MemoryBlock

本文中所讲述的内存块的分配和释放都是通过该结构进行操作,下面是MemoryBlock的示例图:

在上图中,Header存储该MemoryBlock的内存块情况,比如可用的内存块索引、当前MemoryBlock中可用内存块的个数等等。

定义如下所示:

1
2
3
4
5
6
7
8
struct MemoryBlock {
unsigned int size;
unsigned int free_size;
unsigned int first_free;

struct MemoryBlock *next;
char a_data[0];
};

其中:

  • size为MemoryBlock下内存块的个数
  • free_size为MemoryBlock下空闲内存块的个数
  • first_free为MemoryBlock中第一个空闲块的索引
  • next指向下一个MemoryBlock
  • a_data是一个柔性数组

柔性数组即数组大小待定的数组, C语言中结构体的最后一个元素可以是大小未知的数组,也就是所谓的0长度,所以我们可以用结构体来创建柔性数组。它的主要用途是为了满足需要变长度的结构体,为了解决使用数组时内存的冗余和数组的越界问题。

MemoryPool

MemoryPool为内存池的头,里面定义了该内存池的信息,比如本内存池分配的固定对象的大小,第一个MemoryBlock等

1
2
3
4
5
6
7
struct MemoryPool {
unsigned int obj_size;
unsigned int init_size;
unsigned int grow_size;

MemoryBlock *first_block;
};

其中:

  • obj_size为内存池分配的固定内存块的大小
  • init_size初始化内存池时候创建的内存块的个数
  • grow_size当初始化内存块使用完后,再次申请内存块时候的个数
  • first_block指向第一个MemoryBlock

接口

memory_pool_create

1
2
3
MemoryPool *memory_pool_create(unsigned int init_size, 
unsigned int grow_size,
unsigned int size);

本函数用来创建一个MemoryPool,并对其进行初始化,下面是参数说明:

  • init_size 表示第一个MemoryBlock中创建块的个数
  • grow_size 表示当MemoryPool中没有空闲块可用,则创建一个新的MemoryBlock时其块的个数
  • size 为块的大小(即每次分配相同大小的固定size)

memory_alloc

1
void *memory_alloc(MemoryPool *mp);

本函数用了从mp中申请一块内存返回

  • mp 为MemoryPool类型指针,即内存池的头
  • 如果内存分配失败,则返回NULL

memory_free

1
void* memory_free(MemoryPool *mp, void *pfree);

本函数用来释放内存

  • mp 为MemoryPool类型指针,即内存池的头
  • pfree 为要释放的内存

free_memory_pool

1
void free_memory_pool(MemoryPool *mp);

本函数用来释放内存池

实现

在讲解整个实现之前,我们先看先内存池的详细结构图。

初始化内存池

MemoryPool是整个内存池的入口结构,该函数主要是用来创建MemoryPool对象,并使用参数对其内部的成员变量进行初始化。

函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MemoryPool *memory_pool_create(unsigned int init_size, unsigned int grow_size, unsigned int size)
{
MemoryPool *mp;
mp = (MemoryPool*)malloc(sizeof(MemoryPool));
mp->first_block = NULL;
mp->init_size = init_size;
mp->grow_size = grow_size;

if(size < sizeof(unsigned int))
mp->obj_size = sizeof(unsigned int);
mp->obj_size = (size + (MEMPOOL_ALIGNMENT-1)) & ~(MEMPOOL_ALIGNMENT-1);

return mp;
}

内存分配

1
2
3
4
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
void *memory_alloc(MemoryPool *mp) {

unsigned int i;
unsigned int length;

if(mp->first_block == NULL) {
MemoryBlock *mb;
length = (mp->init_size)*(mp->obj_size) + sizeof(MemoryBlock);
mb = malloc(length);
if(mb == NULL) {
perror("memory allocate failed!\n");
return NULL;
}

/* init the first block */
mb->next = NULL;
mb->free_size = mp->init_size - 1;
mb->first_free = 1;
mb->size = mp->init_size*mp->obj_size;

mp->first_block = mb;

char *data = mb->a_data;

/* set the mark */
for(i=1; i<mp->init_size; ++i) {
*(unsigned long *)data = i;
data += mp->obj_size;
}

return (void *)mb->a_data;
}

MemoryBlock *pm_block = mp->first_block;

while((pm_block != NULL) && (pm_block->free_size == 0)) {
pm_block = pm_block->next;
}

if(pm_block != NULL) {
char *pfree = pm_block->a_data + pm_block->first_free * mp->obj_size;

pm_block->first_free = *((unsigned long *)pfree);
pm_block->free_size--;

return (void *)pfree;
} else {
if(mp->grow_size == 0)
return NULL;

MemoryBlock *new_block = (MemoryBlock *)malloc((mp->grow_size)*(mp->obj_size) + sizeof(MemoryBlock));

if(new_block == NULL)
return NULL;

char *data = new_block->a_data;

for(i=1; i<mp->grow_size; ++i) {
*(unsigned long *)data = i;
data += mp->obj_size;
}

new_block->size = mp->grow_size*mp->obj_size;
new_block->free_size = mp->grow_size-1;
new_block->first_free = 1;
new_block->next = mp->first_block;
mp->first_block = new_block;

return (void *)new_block->a_data;
}
}

内存块主要在MemoryBlock结构中,也就是说申请的内存,都是从MemoryBlock中进行获取,流程如下:

  • 获取MemoryPool中的first_block指针
  • 如果该指针为空,则创建一个MemoryBlock,first_block指向新建的MemoryBlock,并返回
  • 否则,从first_block进行单链表遍历,查找第一个free_size不为0的MemoryBlock,如果找到,则对该MemoryBlock的相关参数进行设置,然后返回内存块
  • 否则,创建一个新的MemoryBlock,进行初始化分配之后,将其插入到链表的头部(这样做的目的是为了方便下次分配效率,即减小了链表的遍历)

在上述代码中,需要注意的是第30-33行或者67-70行,这两行的功能一样,都是对新申请的内存块进行初始化,这几行的意思,是要将空闲块连接起来,但是,并没有使用传统意义上的链表方式,而是通过index方式进行连接,具体如下图所示:

在上图中,第0块空闲块的下一个空闲块索引为1,而第1块空闲块的索引为2,依次类推,形成了如下链表方式

1->2->3->4->5

内存分配流程图如下所示:

1
2
3
4
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
void* memory_free(MemoryPool *mp, void *pfree) {
if(mp->first_block == NULL) {
return;
}

MemoryBlock *pm_block = mp->first_block;
MemoryBlock *pm_pre_block = mp->first_block;

/* research the MemoryBlock which the pfree in */
while(pm_block && ((unsigned long)pfree < (unsigned long)pm_block->a_data ||
(unsigned long)pfree>((unsigned long)pm_block->a_data+pm_block->size))) {
//pm_pre_block = pm_block;
pm_block = pm_block->next;

if(pm_block == NULL) {
return pfree;
}
}

unsigned int offset = pfree -(void*) pm_block->a_data;

if((offset&(mp->obj_size -1)) > 0) {
return pfree;
}

pm_block->free_size++;
*((unsigned int *)pfree) = pm_block->first_free;

pm_block->first_free=(unsigned int)(offset/mp->obj_size);

return NULL;
}

内存释放过程如下:

  • 判断当前MemoryPool的first_block指针是否为空,如果为空,则返回
  • 否则,遍历MemoryBlock链表,根据所释放的指针参数判断是否在某一个MemoryBlock中
  • 如果找到,则对MemoryBlock中的各个参数进行操作,然后返回
  • 否则,没有合适的MemoryBlock,则表明该被释放的指针不在内存池中,返回

在上述代码中,需要注意第20-29行。

  • 第20行,求出被释放的内存块在MemoryBlock中的偏移
  • 第22行,判断是否能被整除,即是否在这个内存块中,算是个double check
  • 第26行,将该MemoryBlock中的空闲块个数加1
  • 第27-29行,类似于链表的插入,将新释放的内存块的索引放入链表头,而其内部的指向下一个可用内存块

现在举个例子,以便于理解,假设在一开始有5个空闲块,其中前三个空闲块都分配出去了,那么此时,空闲块链表如下:

4->5,其中first_free = 4

然后在某一个时刻,第1块释放了,那么释放归还之后,如下:

1->4->5,其中first_free = 1

内存释放流程图如下:

释放内存池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void free_memory_pool(MemoryPool *mp) {
MemoryBlock *mb = mp->first_block;

if(mb != NULL) {
while(mb->next != NULL) {
s_memory_block *delete_block = mb;
mb = mb->next;

free(delete_block);
}
free(mb);
}
free(mp);
}

上图是一个完整的分配和释放示意图,下面,我结合代码来分析:

  • (a)步,创建了一个MemoryPool结构体
    • obj_size = 4代表本内存池分配的内存块大小为4
    • init_size = 5代表创建内存池的时候,第一块MemoryBlock的空闲内存块个数为5
    • grow_size = 5代表当申请内存的时候,如果没有空闲内存,则创建的新的MemoryBlock的空闲内存块个数为5
  • (b)步,分配出去一块内存
    • 此时,free_size即该MemoryBlock中可用空闲块个数为4
    • first_free = 1,代表将内存块分配出去之后,下一个可用的内存块的index为1
  • (c)步,分配出去一块内存
    • 此时,free_size即该MemoryBlock中可用空闲块个数为3
    • first_free = 2,代表将内存块分配出去之后,下一个可用的内存块的index为2
  • (d)步,分配出去一块内存
    • 此时,free_size即该MemoryBlock中可用空闲块个数为2
    • first_free = 3,代表将内存块分配出去之后,下一个可用的内存块的index为3
  • (e)步,分配出去一块内存
    • 此时,free_size即该MemoryBlock中可用空闲块个数为1
    • first_free = 4,代表将内存块分配出去之后,下一个可用的内存块的index为4
  • (f)步,释放第1个内存块
    • 将free_size进行+1操作
    • fire_free值为此次释放的内存块的索引,而释放的内存块的索引里面的值则为之前first_free的值(此处释放用的前差法)
  • (g)步,释放第3个内存块
    • 将free_size进行+1操作
    • fire_free值为此次释放的内存块的索引,而释放的内存块的索引里面的值则为之前first_free的值(此处释放用的前差法)
  • (h)步,释放第3个内存块
    • 将free_size进行+1操作
    • fire_free值为此次释放的内存块的索引,而释放的内存块的索引里面的值则为之前first_free的值(此处释放用的前差法)

测试

测试代码如下:

1
2
3
4
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 "memory_pool.h"
#include <sys/time.h>
#include <malloc.h>
#include <stdio.h>

int main() {
MemoryPool *mp = memory_pool_create(8);

struct timeval start;
struct timeval end;

int t[] = {20000, 40000, 80000, 100000, 120000, 140000, 160000, 180000, 200000};
int s = sizeof(t)/sizeof(int);
for (int i = 0; i < s; ++i) {
gettimeofday(&start, NULL);
for (int j = 0; j < t[i]; ++j) {

void *p = memory_alloc(mp);
memory_free(mp, p);
//void *p = malloc(8);
//free(p);
}
gettimeofday(&end, NULL);
long cost = 1000000 * (end.tv_sec - start.tv_sec) +
end.tv_usec - start.tv_usec;
printf("%ld\n",cost);
}
free_memory_pool(mp);
return 0;
}

数据对比如下:

从上图可以看出,pool的分配效率高于传统的malloc方式,性能提高接近100%

扩展

在文章前面,我们有提过本内存池是单线程、固定大小的,但是往往这种还是不能满足要求,如下几个场景

  • 单线程多固定大小
  • 多线程固定大小
  • 多线程多固定大小

多固定大小,指的是提前预支需要申请的内存大小。

单线程多固定大小: 针对此场景,由于已经预知了所申请的size,所以可以针对每个size创建一个内存池。

多线程固定大小:针对此场景,有以下两个方案

  • 使用ThreadLocalCache
  • 每个线程创建一个内存池
  • 使用加锁,操作全局唯一内存池(每次加锁解锁耗时100ns左右)

多线程多固定大小:针对此场景,可以结合上述两个方案,即

  • 使用ThreadCache,每个线程内创建多固定大小的内存池
  • 每个线程内创建一个多固定大小的内存池
  • 使用加锁,操作全局唯一内存池(每次加锁解锁耗时100ns左右)

上述几种方案,仅仅是在使用固定大小内存池基础上进行的扩展,具体的方案,需要根据具体情况来具体分析

malloc 函数详解

原文:https://www.cnblogs.com/Commence/p/5785912.html

malloc只是C标准库中提供的一个普通函数

而且很多很多人都对malloc的具体实现机制不是很了解。

  1. 关于malloc以及相关的几个函数
    1
    2
    3
    4
    5
    6
    #include <stdlib.h>(Linux下)

    void *malloc(size_t size);
    void free(void *ptr);
    void *calloc(size_t nmemb, size_t size);
    void *realloc(void *ptr, size_t size);
    也可以这样认为(window下)原型:extern void *malloc(unsigned int num_bytes);
    头文件:#include 或者#include 两者的内容是完全一样的。

如果分配成功:则返回指向被分配内存空间的指针,不然,返回空指针NULL。当内存不再使用的时候,应使用free()函数将内存块释放掉。void ,表示未确定类型的指针。C,C++规定,void 类型可以强转为任何其他类型的的指针。

malloc returns a void pointer to the allocated space, or NULL if there is insufficient memory available. To return a pointer to a type other than void, use a type cast on the return value. The storage space pointed to by the return value is guaranteed to be suitably aligned for storage of any type of object. If size is 0, malloc allocates a zero-length item in the heap and returns a valid pointer to that item. Always check the return from malloc, even if the amount of memory requested is small.

关于void *的其他说法:

1
2
3
void * p1;
int *p2;
p1 = p2;

就是说其他任意类型都可以直接赋值给它,无需进行强转,但是反过来不可以。

malloc:

  • malloc分配的内存大小至少为size参数所指定的字节数
  • malloc的返回值是一个指针,指向一段可用内存的起始地址
  • 多次调用malloc所分配的地址不能有重叠部分,除非某次malloc所分配的地址被释放掉
  • malloc应该尽快完成内存分配并返回(不能使用NP-hard的内存分配算法)
  • 实现malloc时应同时实现内存大小调整和内存释放函数(realloc和free)

malloc和free函数是配对的,如果申请后不释放就是内存泄露;如果无故释放那就是什么都没有做,释放只能释放一次,如果释放两次及两次以上会出现错误(但是释放空指针例外,释放空指针其实也等于什么都没有做,所以,释放多少次都是可以的)

  1. malloc和new

new返回指定类型的指针,并且可以自动计算所需要的大小。

1
2
3
int *p;
p = new int; //返回类型为int *类型,分配的大小为sizeof(int)
p = new int[100]; //返回类型为int *类型,分配的大小为sizeof(int) * 100

而malloc则必须由我们计算字节数,并且在返回的时候强转成实际指定类型的指针。

1
2
int *p;
p = (int *)malloc(sizeof(int));

  1. malloc的返回是void ,如果我们写成了: p = malloc(sizeof(int));间接的说明了(将void 转化给了int *,这不合理)
  2. malloc的实参是sizeof(int),用于指明一个整形数据需要的大小,如果我们写成:p = (int *)malloc(1),那么可以看出:只是申请了一个字节的空间,如果向里面存放了一个整数的话,将会占用额外的3个字节,可能会改变原有内存空间中的数据;
  3. malloc只管分配内存,并不能对其进行初始化,所以得到的一片新内存中,其值将是随机的。一般意义上:我们习惯性的将其初始化为NULL。当然,也可以用memset函数的。

简单的说:

malloc 函数其实就是在内存中:找一片指定大小的空间,然后将这个空间的首地址给一个指针变量,这里的指针变量可以是一个单独的指针,也可以是一个数组的首地址, 这要看malloc函数中参数size的具体内容。我们这里malloc分配的内存空间在逻辑上是连续的,而在物理上可以不连续。我们作为程序员,关注的 是逻辑上的连续,其它的,操作系统会帮着我们处理的。

下面我们聊聊malloc的具体实现机制:

Linux内存管理

虚拟内存地址与物理内存地址

为了简单,现代操作系统在处理内存地址时,普遍采用虚拟内存地址技术。即在汇编程序(或机器语言)层面,当涉及内存地址时, 都是使用虚拟内存地址。采用这种技术时,每个进程仿佛自己独享一片2N字节的内存,其中N是机器位数。例如在64位CPU和64位操作系统下,每个进程的 虚拟地址空间为264Byte。

这种虚拟地址空间的作用主要是简化程序的编写及方便操作系统对进程间内存的隔离管理,真实中的进程不太可能(也用不到)如此大的内存空间,实际能用到的内存取决于物理内存大小。

由于在机器语言层面都是采用虚拟地址,当实际的机器码程序涉及到内存操作时,需要根据当前进程运行的实际上下文将虚拟地址转换为物理内存地址,才能实现对真实内存数据的操作。这个转换一般由一个叫MMU(Memory Management Unit)的硬件完成。

页与地址构成

在现代操作系统中,不论是虚拟内存还是物理内存,都不是以字节为单位进行管理的,而是以页(Page)为单位。一个内存页是一段固定大小的连续内存地址的总称,具体到Linux中,典型的内存页大小为4096Byte(4K)。

所以内存地址可以分为页号和页内偏移量。下面以64位机器,4G物理内存,4K页大小为例,虚拟内存地址和物理内存地址的组成如下:

上面是虚拟内存地址,下面是物理内存地址。由于页大小都是4K,所以页内便宜都是用低12位表示,而剩下的高地址表示页号。

MMU映射单位并不是字节,而是页,这个映射通过查一个常驻内存的数据结构页表来实现。现在计算机具体的内存地址映射比较复杂,为了加快速度会引入一系列缓存和优化,例如TLB等机制。下面给出一个经过简化的内存地址翻译示意图,虽然经过了简化,但是基本原理与现代计算机真实的情况的一致的。

内存页与磁盘页

我们知道一般将内存看做磁盘的的缓存,有时MMU在工作时,会发现页表表明某个内存页不在物理内存中,此时会触发一个缺页异 常(Page Fault),此时系统会到磁盘中相应的地方将磁盘页载入到内存中,然后重新执行由于缺页而失败的机器指令。关于这部分,因为可以看做对malloc实现 是透明的,所以不再详细讲述,有兴趣的可以参考《深入理解计算机系统》相关章节。
附上一张在维基百科找到的更加符合真实地址翻译的流程供大家参考,这张图加入了TLB和缺页异常的流程。

Linux进程级内存管理

内存排布

明白了虚拟内存和物理内存的关系及相关的映射机制,下面看一下具体在一个进程内是如何排布内存的。

以Linux 64位系统为例。理论上,64bit内存地址可用空间为0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF,这是个相当庞大的空间,Linux实际上只用了其中一小部分(256T)。

根据Linux内核相关文档描述,Linux64位操作系统仅使用低47位,高17位做扩展(只能是全0或全1)。所以,实际用到的地址为空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,其中前面为用户空间(User Space),后者为内核空间(Kernel Space)。图示如下:

对用户来说,主要关注的空间是User Space。将User Space放大后,可以看到里面主要分为如下几段:

  • Code:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码)
  • Data:这里存放的是初始化过的全局变量
  • BSS:这里存放的是未初始化的全局变量
  • Heap:堆,这是我们本文重点关注的地方,堆自低地址向高地址增长,后面要讲到的brk相关的系统调用就是从这里分配内存
  • Mapping Area:这里是与mmap系统调用相关的区域。大多数实际的malloc实现会考虑通过mmap分配较大块的内存区域,本文不讨论这种情况。这个区域自高地址向低地址增长
  • Stack:这是栈区域,自高地址向低地址增长

Heap内存模型

一般来说,malloc所申请的内存主要从Heap区域分配(本文不考虑通过mmap申请大块内存的情况)。

由上文知道,进程所面对的虚拟内存地址空间,只有按页映射到物理内存地址,才能真正使用。受物理存储容量限制,整个堆虚拟内存空间不可能全部映射到实际的物理内存。Linux对堆的管理示意如下:

Linux维护一个break指针,这个指针指向堆空间的某个地址。从堆起始地址到break之间的地址空间为映射好的,可以供进程访问;而从break往上,是未映射的地址空间,如果访问这段空间则程序会报错。

brk与sbrk

由上文知道,要增加一个进程实际的可用堆大小,就需要将break指针向高地址移动。Linux通过brk和sbrk系统调用操作break指针。两个系统调用的原型如下:

1
2
int brk(void *addr);
void *sbrk(intptr_t increment);

brk将break指针直接设置为某个地址,而sbrk将break从当前位置移动increment所指定的增量。brk 在执行成功时返回0,否则返回-1并设置errno为ENOMEM;sbrk成功时返回break移动之前所指向的地址,否则返回(void *)-1。

一个小技巧是,如果将increment设置为0,则可以获得当前break的地址。

另外需要注意的是,由于Linux是按页进行内存映射的,所以如果break被设置为没有按页大小对齐,则系统实际上会在最 后映射一个完整的页,从而实际已映射的内存空间比break指向的地方要大一些。但是使用break之后的地址是很危险的(尽管也许break之后确实有 一小块可用内存地址)。

资源限制与rlimit

  系统对每一个进程所分配的资源不是无限的,包括可映射的内存空间,因此每个进程有一个rlimit表示当前进程可用的资源上限。这个限制可以通过getrlimit系统调用得到,下面代码获取当前进程虚拟内存空间的rlimit:

1
2
3
4
5
int main() {
struct rlimit *limit = (struct rlimit *)malloc(sizeof(struct rlimit));
getrlimit(RLIMIT_AS, limit);
printf("soft limit: %ld, hard limit: %ld\n", limit->rlim_cur, limit->rlim_max);
}

其中rlimit是一个结构体:
1
2
3
4
struct rlimit {
rlim_t rlim_cur; /* Soft limit */
rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */
};

每种资源有软限制和硬限制,并且可以通过setrlimit对rlimit进行有条件设置。其中硬限制作为软限制的上限,非特权进程只能设置软限制,且不能超过硬限制。

实现malloc

玩具实现

在正式开始讨论malloc的实现前,我们可以利用上述知识实现一个简单但几乎没法用于真实的玩具malloc,权当对上面知识的复习:

1
2
3
4
5
6
7
8
9
10
11
/* 一个玩具malloc */
#include <sys/types.h>
#include <unistd.h>
void *malloc(size_t size)
{
void *p;
p = sbrk(0);
if (sbrk(size) == (void *)-1)
return NULL;
return p;
}

这个malloc每次都在当前break的基础上增加size所指定的字节数,并将之前break的地址返回。这个malloc由于对所分配的内存缺乏记录,不便于内存释放,所以无法用于真实场景。

正式实现

下面严肃点讨论malloc的实现方案。

数据结构

首先我们要确定所采用的数据结构。一个简单可行方案是将堆内存空间以块(Block)的形式组织起来,每个块由meta区和 数据区组成,meta区记录数据块的元信息(数据区大小、空闲标志位、指针等等),数据区是真实分配的内存区域,并且数据区的第一个字节地址即为 malloc返回的地址。

可以用如下结构体定义一个block:

1
2
3
4
5
6
7
8
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

  由于我们只考虑64位机器,为了方便,我们在结构体最后填充一个int,使得结构体本身的长度为8的倍数,以便内存对齐。示意图如下:

寻找合适的block

现在考虑如何在block链中查找合适的block。一般来说有两种查找算法:

First fit:从头开始,使用第一个数据区大小大于要求size的块所谓此次分配的块
Best fit:从头开始,遍历所有块,使用数据区大小大于size且差值最小的块作为此次分配的块
  两种方法各有千秋,best fit具有较高的内存使用率(payload较高),而first fit具有更好的运行效率。这里我们采用first fit算法。

1
2
3
4
5
6
7
8
9
/* First fit */
t_block find_block(t_block *last, size_t size) {
t_block b = first_block;
while(b && !(b->free && b->size >= size)) {
*last = b;
b = b->next;
}
return b;
}

find_block从frist_block开始,查找第一个符合要求的block并返回block起始地址,如果找不到 这返回NULL。这里在遍历时会更新一个叫last的指针,这个指针始终指向当前遍历的block。这是为了如果找不到合适的block而开辟新 block使用的,具体会在接下来的一节用到。

开辟新的block

如果现有block都不能满足size的要求,则需要在链表最后开辟一个新的block。这里关键是如何只使用sbrk创建一个struct:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define BLOCK_SIZE 24 /* 由于存在虚拟的data字段,sizeof不能正确计算meta长度,这里手工设置 */

t_block extend_heap(t_block last, size_t s) {
t_block b;
b = sbrk(0);
if(sbrk(BLOCK_SIZE + s) == (void *)-1)
return NULL;
b->size = s;
b->next = NULL;
if(last)
last->next = b;
b->free = 0;
return b;
}

分裂block

First fit有一个比较致命的缺点,就是可能会让很小的size占据很大的一块block,此时,为了提高payload,应该在剩余数据区足够大的情况下,将其分裂为一个新的block,示意如下:

1
2
3
4
5
6
7
8
9
void split_block(t_block b, size_t s) {
t_block new;
new = b->data + s;
new->size = b->size - s - BLOCK_SIZE ;
new->next = b->next;
new->free = 1;
b->size = s;
b->next = new;
}

malloc的实现

有了上面的代码,我们可以利用它们整合成一个简单但初步可用的malloc。注意首先我们要定义个block链表的头first_block,初始化为NULL;另外,我们需要剩余空间至少有BLOCK_SIZE + 8才执行分裂操作。

由于我们希望malloc分配的数据区是按8字节对齐,所以在size不为8的倍数时,我们需要将size调整为大于size的最小的8的倍数:

1
2
3
4
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
size_t align8(size_t s) {
if(s & 0x7 == 0)
return s;
return ((s >> 3) + 1) << 3;
}

#define BLOCK_SIZE 24
void *first_block=NULL;

/* other functions... */

void *malloc(size_t size) {
t_block b, last;
size_t s;
/* 对齐地址 */
s = align8(size);
if(first_block) {
/* 查找合适的block */
last = first_block;
b = find_block(&last, s);
if(b) {
/* 如果可以,则分裂 */
if ((b->size - s) >= ( BLOCK_SIZE + 8))
split_block(b, s);
b->free = 0;
} else {
/* 没有合适的block,开辟一个新的 */
b = extend_heap(last, s);
if(!b)
return NULL;
}
} else {
b = extend_heap(NULL, s);
if(!b)
return NULL;
first_block = b;
}
return b->data;
}

calloc的实现

有了malloc,实现calloc只要两步:

  1. malloc一段内存
  2. 将数据区内容置为0
    由于我们的数据区是按8字节对齐的,所以为了提高效率,我们可以每8字节一组置0,而不是一个一个字节设置。我们可以通过新建一个size_t指针,将内存区域强制看做size_t类型来实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void *calloc(size_t number, size_t size) {
    size_t *new;
    size_t s8, i;
    new = malloc(number * size);
    if(new) {
    s8 = align8(number * size) >> 3;
    for(i = 0; i < s8; i++)
    new[i] = 0;
    }
    return new;
    }

    free的实现

    free的实现并不像看上去那么简单,这里我们要解决两个关键问题:

  3. 如何验证所传入的地址是有效地址,即确实是通过malloc方式分配的数据区首地址

  4. 如何解决碎片问题
    首先我们要保证传入free的地址是有效的,这个有效包括两方面:
  • 地址应该在之前malloc所分配的区域内,即在first_block和当前break指针范围内
  • 这个地址确实是之前通过我们自己的malloc分配的

第一个问题比较好解决,只要进行地址比较就可以了,关键是第二个问题。这里有两种解决方案:一是在结构体内埋一个magic number字段,free之前通过相对偏移检查特定位置的值是否为我们设置的magic number,另一种方法是在结构体内增加一个magic pointer,这个指针指向数据区的第一个字节(也就是在合法时free时传入的地址),我们在free前检查magic pointer是否指向参数所指地址。这里我们采用第二种方案:

首先我们在结构体中增加magic pointer(同时要修改BLOCK_SIZE):

1
2
3
4
5
6
7
8
9
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

然后我们定义检查地址合法性的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
t_block get_block(void *p) {
char *tmp;
tmp = p;
return (p = tmp -= BLOCK_SIZE);
}

int valid_addr(void *p) {
if(first_block) {
if(p > first_block && p < sbrk(0)) {
return p == (get_block(p))->ptr;
}
}
return 0;
}

当多次malloc和free后,整个内存池可能会产生很多碎片block,这些block很小,经常无法使用,甚至出现许多碎片连在一起,虽然总体能满足某此malloc要求,但是由于分割成了多个小block而无法fit,这就是碎片问题。

  一个简单的解决方式时当free某个block时,如果发现它相邻的block也是free的,则将block和相邻block合并。为了满足这个实现,需要将s_block改为双向链表。修改后的block结构如下:

1
2
3
4
5
6
7
8
9
10
typedef struct s_block *t_block;
struct s_block {
size_t size; /* 数据区大小 */
t_block prev; /* 指向上个块的指针 */
t_block next; /* 指向下个块的指针 */
int free; /* 是否是空闲块 */
int padding; /* 填充4字节,保证meta块长度为8的倍数 */
void *ptr; /* Magic pointer,指向data */
char data[1] /* 这是一个虚拟字段,表示数据块的第一个字节,长度不应计入meta */
};

合并方法如下:
1
2
3
4
5
6
7
8
9
t_block fusion(t_block b) {
if (b->next && b->next->free) {
b->size += BLOCK_SIZE + b->next->size;
b->next = b->next->next;
if(b->next)
b->next->prev = b;
}
return b;
}

有了上述方法,free的实现思路就比较清晰了:首先检查参数地址的合法性,如果不合法则不做任何事;否则,将此block 的free标为1,并且在可以的情况下与后面的block进行合并。如果当前是最后一个block,则回退break指针释放进程内存,如果当前 block是最后一个block,则回退break指针并设置first_block为NULL。实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void free(void *p) {
t_block b;
if(valid_addr(p)) {
b = get_block(p);
b->free = 1;
if(b->prev && b->prev->free)
b = fusion(b->prev);
if(b->next)
fusion(b);
else {
if(b->prev)
b->prev->prev = NULL;
else
first_block = NULL;
brk(b);
}
}
}

realloc的实现

为了实现realloc,我们首先要实现一个内存复制方法。如同calloc一样,为了效率,我们以8字节为单位进行复制:

1
2
3
4
5
6
7
8
void copy_block(t_block src, t_block dst) {
size_t *sdata, *ddata;
size_t i;
sdata = src->ptr;
ddata = dst->ptr;
for(i = 0; (i * 8) < src->size && (i * 8) < dst->size; i++)
ddata[i] = sdata[i];
}

然后我们开始实现realloc。一个简单(但是低效)的方法是malloc一段内存,然后将数据复制过去。但是我们可以做的更高效,具体可以考虑以下几个方面:

  • 如果当前block的数据区大于等于realloc所要求的size,则不做任何操作
  • 如果新的size变小了,考虑split
  • 如果当前block的数据区不能满足size,但是其后继block是free的,并且合并后可以满足,则考虑做合并

下面是realloc的实现:

1
2
3
4
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
void *realloc(void *p, size_t size) {
size_t s;
t_block b, new;
void *newp;
if (!p)
/* 根据标准库文档,当p传入NULL时,相当于调用malloc */
return malloc(size);
if(valid_addr(p)) {
s = align8(size);
b = get_block(p);
if(b->size >= s) {
if(b->size - s >= (BLOCK_SIZE + 8))
split_block(b,s);
} else {
/* 看是否可进行合并 */
if(b->next && b->next->free
&& (b->size + BLOCK_SIZE + b->next->size) >= s) {
fusion(b);
if(b->size - s >= (BLOCK_SIZE + 8))
split_block(b, s);
} else {
/* 新malloc */
newp = malloc (s);
if (!newp)
return NULL;
new = get_block(newp);
copy_block(b, new);
free(p);
return(newp);
}
}
return (p);
}
return NULL;
}

遗留问题和优化

以上是一个较为简陋,但是初步可用的malloc实现。还有很多遗留的可能优化点,例如:

  • 同时兼容32位和64位系统
  • 在分配较大快内存时,考虑使用mmap而非sbrk,这通常更高效
  • 可以考虑维护多个链表而非单个,每个链表中的block大小均为一个范围内,例如8字节链表、16字节链表、24-32字节链表等等。此时可以根据size到对应链表中做分配,可以有效减少碎片,并提高查询block的速度
  • 可以考虑链表中只存放free的block,而不存放已分配的block,可以减少查找block的次数,提高效率

Linux I/O 原理

导言

如今的网络应用早已从 CPU 密集型转向了 I/O 密集型,网络服务器大多是基于 C-S 模型,也即 客户端 - 服务端 模型,客户端需要和服务端进行大量的网络通信,这也决定了现代网络应用的性能瓶颈:I/O。

传统的 Linux 操作系统的标准 I/O 接口是基于数据拷贝操作的,即 I/O 操作会导致数据在操作系统内核地址空间的缓冲区和用户进程地址空间定义的缓冲区之间进行传输。设置缓冲区最大的好处是可以减少磁盘 I/O 的操作,如果所请求的数据已经存放在操作系统的高速缓冲存储器中,那么就不需要再进行实际的物理磁盘 I/O 操作;然而传统的 Linux I/O 在数据传输过程中的数据拷贝操作深度依赖 CPU,也就是说 I/O 过程需要 CPU 去执行数据拷贝的操作,因此导致了极大的系统开销,限制了操作系统有效进行数据传输操作的能力。

I/O 是决定网络服务器性能瓶颈的关键,而传统的 Linux I/O 机制又会导致大量的数据拷贝操作,损耗性能,所以我们亟需一种新的技术来解决数据大量拷贝的问题,这个答案就是零拷贝(Zero-copy)。

计算机存储器

既然要分析 Linux I/O,就不能不了解计算机的各类存储器。

存储器是计算机的核心部件之一,在完全理想的状态下,存储器应该要同时具备以下三种特性:

  1. 速度足够快:存储器的存取速度应当快于 CPU 执行一条指令,这样 CPU 的效率才不会受限于存储器
  2. 容量足够大:容量能够存储计算机所需的全部数据
  3. 价格足够便宜:价格低廉,所有类型的计算机都能配备

但是现实往往是残酷的,我们目前的计算机技术无法同时满足上述的三个条件,于是现代计算机的存储器设计采用了一种分层次的结构:

从顶至底,现代计算机里的存储器类型分别有:寄存器、高速缓存、主存和磁盘,这些存储器的速度逐级递减而容量逐级递增 。存取速度最快的是寄存器,因为寄存器的制作材料和 CPU 是相同的,所以速度和 CPU 一样快,CPU 访问寄存器是没有时延的,然而因为价格昂贵,因此容量也极小,一般 32 位的 CPU 配备的寄存器容量是 32✖️32 Bit,64 位的 CPU 则是 64✖️64 Bit,不管是 32 位还是 64 位,寄存器容量都小于 1 KB,且寄存器也必须通过软件自行管理。

第二层是高速缓存,也即我们平时了解的 CPU 高速缓存 L1、L2、L3,一般 L1 是每个 CPU 独享,L3 是全部 CPU 共享,而 L2 则根据不同的架构设计会被设计成独享或者共享两种模式之一,比如 Intel 的多核芯片采用的是共享 L2 模式而 AMD 的多核芯片则采用的是独享 L2 模式。

第三层则是主存,也即主内存,通常称作随机访问存储器(Random Access Memory, RAM)。是与 CPU 直接交换数据的内部存储器。它可以随时读写(刷新时除外),而且速度很快,通常作为操作系统或其他正在运行中的程序的临时资料存储介质。

最后则是磁盘,磁盘和主存相比,每个二进制位的成本低了两个数量级,因此容量比之会大得多,动辄上 GB、TB,而问题是访问速度则比主存慢了大概三个数量级。机械硬盘速度慢主要是因为机械臂需要不断在金属盘片之间移动,等待磁盘扇区旋转至磁头之下,然后才能进行读写操作,因此效率很低。

主内存是操作系统进行 I/O 操作的重中之重,绝大部分的工作都是在用户进程和内核的内存缓冲区里完成的,因此我们接下来需要提前学习一些主存的相关原理。

物理内存

我们平时一直提及的物理内存就是上文中对应的第三种计算机存储器,RAM 主存,它在计算机中以内存条的形式存在,嵌在主板的内存槽上,用来加载各式各样的程序与数据以供 CPU 直接运行和使用。

虚拟内存

在计算机领域有一句如同摩西十诫般神圣的哲言:”计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”,从内存管理、网络模型、并发调度甚至是硬件架构,都能看到这句哲言在闪烁着光芒,而虚拟内存则是这一哲言的完美实践之一。

虚拟内存是现代计算机中的一个非常重要的存储器抽象,主要是用来解决应用程序日益增长的内存使用需求:现代物理内存的容量增长已经非常快速了,然而还是跟不上应用程序对主存需求的增长速度,对于应用程序来说内存还是不够用,因此便需要一种方法来解决这两者之间的容量差矛盾。

计算机对多程序内存访问的管理经历了 静态重定位 —> 动态重定位 —> 交换(swapping)技术 —> 虚拟内存,最原始的多程序内存访问是直接访问绝对内存地址,这种方式几乎是完全不可用的方案,因为如果每一个程序都直接访问物理内存地址的话,比如两个程序并发执行以下指令的时候:

1
2
3
4
5
6
7
8
9
mov cx, 2
mov bx, 1000H
mov ds, bx
mov [0], cx

...

mov ax, [0]
add ax, ax

这一段汇编表示在地址 1000:0 处存入数值 2,然后在后面的逻辑中把该地址的值取出来乘以 2,最终存入 ax 寄存器的值就是 4,如果第二个程序存入 cx 寄存器里的值是 3,那么并发执行的时候,第一个程序最终从 ax 寄存器里得到的值就可能是 6,这就完全错误了,得到脏数据还顶多算程序结果错误,要是其他程序往特定的地址里写入一些危险的指令而被另一个程序取出来执行,还可能会导致整个系统的崩溃。所以,为了确保进程间互不干扰,每一个用户进程都需要实时知晓当前其他进程在使用哪些内存地址,这对于写程序的人来说无疑是一场噩梦。

因此,操作绝对内存地址是完全不可行的方案,那就只能用操作相对内存地址,我们知道每个进程都会有自己的进程地址,从 0 开始,可以通过相对地址来访问内存,但是这同样有问题,还是前面类似的问题,比如有两个大小为 16KB 的程序 A 和 B,现在它们都被加载进了内存,内存地址段分别是 0 ~ 16384,16384 ~ 32768。A 的第一条指令是 jmp 1024,而在地址 1024 处是一条mov指令,下一条指令是 add,基于前面的mov指令做加法运算,与此同时,B 的第一条指令是 jmp 1028,本来在 B 的相对地址 1028 处应该也是一条mov去操作自己的内存地址上的值,但是由于这两个程序共享了段寄存器,因此虽然他们使用了各自的相对地址,但是依然操作的还是绝对内存地址,于是 B 就会跳去执行 add 指令,这时候就会因为非法的内存操作而 crash。

有一种静态重定位的技术可以解决这个问题,它的工作原理非常简单粗暴:当 B 程序被加载到地址 16384 处之后,把 B 的所有相对内存地址都加上 16384,这样的话当 B 执行 jmp 1028 之时,其实执行的是jmp 1028+16384,就可以跳转到正确的内存地址处去执行正确的指令了,但是这种技术并不通用,而且还会对程序装载进内存的性能有影响。

再往后,就发展出来了存储器抽象:地址空间,就好像进程是 CPU 的抽象,地址空间则是存储器的抽象,每个进程都会分配独享的地址空间,但是独享的地址空间又带来了新的问题:如何实现不同进程的相同相对地址指向不同的物理地址?最开始是使用动态重定位技术来实现,这是用一种相对简单的地址空间到物理内存的映射方法。基本原理就是为每一个 CPU 配备两个特殊的硬件寄存器:基址寄存器和界限寄存器,用来动态保存每一个程序的起始物理内存地址和长度,比如前文中的 A,B 两个程序,当 A 运行时基址寄存器和界限寄存器就会分别存入 0 和 16384,而当 B 运行时则两个寄存器又会分别存入 16384 和 32768。然后每次访问指定的内存地址时,CPU 会在把地址发往内存总线之前自动把基址寄存器里的值加到该内存地址上,得到一个真正的物理内存地址,同时还会根据界限寄存器里的值检查该地址是否溢出,若是,则产生错误中止程序,动态重定位技术解决了静态重定位技术造成的程序装载速度慢的问题,但是也有新问题:每次访问内存都需要进行加法和比较运算,比较运算本身可以很快,但是加法运算由于进位传递时间的问题,除非使用特殊的电路,否则会比较慢。

然后就是 交换(swapping)技术,这种技术简单来说就是动态地把程序在内存和磁盘之间进行交换保存,要运行一个进程的时候就把程序的代码段和数据段调入内存,然后再把程序封存,存入磁盘,如此反复。为什么要这么麻烦?因为前面那两种重定位技术的前提条件是计算机内存足够大,能够把所有要运行的进程地址空间都加载进主存,才能够并发运行这些进程,但是现实往往不是如此,内存的大小总是有限的,所有就需要另一类方法来处理内存超载的情况,第一种便是简单的交换技术:

先把进程 A 换入内存,然后启动进程 B 和 C,也换入内存,接着 A 被从内存交换到磁盘,然后又有新的进程 D 调入内存,用了 A 退出之后空出来的内存空间,最后 A 又被重新换入内存,由于内存布局已经发生了变化,所以 A 在换入内存之时会通过软件或者在运行期间通过硬件(基址寄存器和界限寄存器)对其内存地址进行重定位,多数情况下都是通过硬件。

另一种处理内存超载的技术就是虚拟内存技术了,它比交换(swapping)技术更复杂而又更高效,是目前最新应用最广泛的存储器抽象技术:

虚拟内存的核心原理是:为每个程序设置一段”连续”的虚拟地址空间,把这个地址空间分割成多个具有连续地址范围的页 (page),并把这些页和物理内存做映射,在程序运行期间动态映射到物理内存。当程序引用到一段在物理内存的地址空间时,由硬件立刻执行必要的映射;而当程序引用到一段不在物理内存中的地址空间时,由操作系统负责将缺失的部分装入物理内存并重新执行失败的指令:

虚拟地址空间按照固定大小划分成被称为页(page)的若干单元,物理内存中对应的则是页框(page frame)。这两者一般来说是一样的大小,如上图中的是 4KB,不过实际上计算机系统中一般是 512 字节到 1 GB,这就是虚拟内存的分页技术。因为是虚拟内存空间,每个进程分配的大小是 4GB (32 位架构),而实际上当然不可能给所有在运行中的进程都分配 4GB 的物理内存,所以虚拟内存技术还需要利用到前面介绍的交换(swapping)技术,在进程运行期间只分配映射当前使用到的内存,暂时不使用的数据则写回磁盘作为副本保存,需要用的时候再读入内存,动态地在磁盘和内存之间交换数据。

其实虚拟内存技术从某种角度来看的话,很像是糅合了基址寄存器和界限寄存器之后的新技术。它使得整个进程的地址空间可以通过较小的单元映射到物理内存,而不需要为程序的代码和数据地址进行重定位。

进程在运行期间产生的内存地址都是虚拟地址,如果计算机没有引入虚拟内存这种存储器抽象技术的话,则 CPU 会把这些地址直接发送到内存地址总线上,直接访问和虚拟地址相同值的物理地址;如果使用虚拟内存技术的话,CPU 则是把这些虚拟地址通过地址总线送到内存管理单元(Memory Management Unit,MMU),MMU 将虚拟地址映射为物理地址之后再通过内存总线去访问物理内存:

虚拟地址(比如 16 位地址 8196=0010 000000000100)分为两部分:虚拟页号(高位部分)和偏移量(低位部分),虚拟地址转换成物理地址是通过页表(page table)来实现的,页表由页表项构成,页表项中保存了页框号、修改位、访问位、保护位和 “在/不在” 位等信息,从数学角度来说页表就是一个函数,入参是虚拟页号,输出是物理页框号,得到物理页框号之后复制到寄存器的高三位中,最后直接把 12 位的偏移量复制到寄存器的末 12 位构成 15 位的物理地址,即可以把该寄存器的存储的物理内存地址发送到内存总线:

在 MMU 进行地址转换时,如果页表项的 “在/不在” 位是 0,则表示该页面并没有映射到真实的物理页框,则会引发一个缺页中断,CPU 陷入操作系统内核,接着操作系统就会通过页面置换算法选择一个页面将其换出 (swap),以便为即将调入的新页面腾出位置,如果要换出的页面的页表项里的修改位已经被设置过,也就是被更新过,则这是一个脏页 (dirty page),需要写回磁盘更新改页面在磁盘上的副本,如果该页面是”干净”的,也就是没有被修改过,则直接用调入的新页面覆盖掉被换出的旧页面即可。

最后,还需要了解的一个概念是转换检测缓冲器(Translation Lookaside Buffer,TLB),也叫快表,是用来加速虚拟地址映射的,因为虚拟内存的分页机制,页表一般是保存内存中的一块固定的存储区,导致进程通过 MMU 访问内存比直接访问内存多了一次内存访问,性能至少下降一半,因此需要引入加速机制,即 TLB 快表,TLB 可以简单地理解成页表的高速缓存,保存了最高频被访问的页表项,由于一般是硬件实现的,因此速度极快,MMU 收到虚拟地址时一般会先通过硬件 TLB 查询对应的页表号,若命中且该页表项的访问操作合法,则直接从 TLB 取出对应的物理页框号返回,若不命中则穿透到内存页表里查询,并且会用这个从内存页表里查询到最新页表项替换到现有 TLB 里的其中一个,以备下次缓存命中。

至此,我们介绍完了包含虚拟内存在内的多项计算机存储器抽象技术,虚拟内存的其他内容比如针对大内存的多级页表、倒排页表,以及处理缺页中断的页面置换算法等等,以后有机会再单独写一篇文章介绍,或者各位读者也可以先行去查阅相关资料了解,这里就不再深入了。

用户态和内核态

一般来说,我们在编写程序操作 Linux I/O 之时十有八九是在用户空间和内核空间之间传输数据,因此有必要先了解一下 Linux 的用户态和内核态的概念。

首先是用户态和内核态:

从宏观上来看,Linux 操作系统的体系架构分为用户态和内核态(或者用户空间和内核)。内核从本质上看是一种软件 —— 控制计算机的硬件资源,并提供上层应用程序 (进程) 运行的环境。用户态即上层应用程序 (进程) 的运行空间,应用程序 (进程) 的执行必须依托于内核提供的资源,这其中包括但不限于 CPU 资源、存储资源、I/O 资源等等。

现代操作系统都是采用虚拟存储器,那么对 32 位操作系统而言,它的寻址空间(虚拟存储空间)为 2^32 B = 4G。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对 Linux 操作系统而言,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间。

因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的系统资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以,为了减少有限资源的访问和使用冲突,Unix/Linux 的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。Intel 的 x86 架构的 CPU 提供了 0 到 3 四个特权级,数字越小,特权越高,Linux 操作系统中主要采用了 0 和 3 两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如 C 函数库中的内存分配函数malloc(),它具体是使用sbrk()系统调用来分配内存,当malloc调用sbrk()的时候就涉及一次从用户态到内核态的切换,类似的函数还有printf(),调用的是wirte()系统调用来输出字符串,等等。

用户进程在系统中运行时,大部分时间是处在用户态空间里的,在其需要操作系统帮助完成一些用户态没有特权和能力完成的操作时就需要切换到内核态。那么用户进程如何切换到内核态去使用那些内核资源呢?答案是:1) 系统调用(trap),2) 异常(exception)和 3) 中断(interrupt)。

  • 系统调用:用户进程主动发起的操作。用户态进程发起系统调用主动要求切换到内核态,陷入内核之后,由操作系统来操作系统资源,完成之后再返回到进程。
  • 异常:被动的操作,且用户进程无法预测其发生的时机。当用户进程在运行期间发生了异常(比如某条指令出了问题),这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也即是切换到了内核态。异常包括程序运算引起的各种错误如除 0、缓冲区溢出、缺页等。
  • 中断:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令是用户态下的程序,那么转换的过程自然就会是从用户态到内核态的切换。中断包括 I/O 中断、外部信号中断、各种定时器引起的时钟中断等。中断和异常类似,都是通过中断向量表来找到相应的处理程序进行处理。区别在于,中断来自处理器外部,不是由任何一条专门的指令造成,而异常是执行当前指令的结果。

通过上面的分析,我们可以得出 Linux 的内部层级可分为三大部分:

  • 用户空间;
  • 内核空间;
  • 硬件。

Linux I/O

I/O 缓冲区


在 Linux 中,当程序调用各类文件操作函数后,用户数据(User Data)到达磁盘(Disk)的流程如上图所示。

图中描述了 Linux 中文件操作函数的层级关系和内存缓存层的存在位置,中间的黑色实线是用户态和内核态的分界线。

read(2)/write(2)是 Linux 系统中最基本的 I/O 读写系统调用,我们开发操作 I/O 的程序时必定会接触到它们,而在这两个系统调用和真实的磁盘读写之间存在一层称为 Kernel buffer cache 的缓冲区缓存。在 Linux 中 I/O 缓存其实可以细分为两个:Page Cache 和 Buffer Cache,这两个其实是一体两面,共同组成了 Linux 的内核缓冲区(Kernel Buffer Cache):

  • 读磁盘:内核会先检查 Page Cache 里是不是已经缓存了这个数据,若是,直接从这个内存缓冲区里读取返回,若否,则穿透到磁盘去读取,然后再缓存在 Page Cache 里,以备下次缓存命中;
  • 写磁盘:内核直接把数据写入 Page Cache,并把对应的页标记为 dirty,添加到 dirty list 里,然后就直接返回,内核会定期把 dirty list 的页缓存 flush 到磁盘,保证页缓存和磁盘的最终一致性。

Page Cache 会通过页面置换算法如 LRU 定期淘汰旧的页面,加载新的页面。可以看出,所谓 I/O 缓冲区缓存就是在内核和磁盘、网卡等外设之间的一层缓冲区,用来提升读写性能的。

在 Linux 还不支持虚拟内存技术之前,还没有页的概念,因此 Buffer Cache 是基于操作系统读写磁盘的最小单位 — 块(block)来进行的,所有的磁盘块操作都是通过 Buffer Cache 来加速,Linux 引入虚拟内存的机制来管理内存后,页成为虚拟内存管理的最小单位,因此也引入了 Page Cache 来缓存 Linux 文件内容,主要用来作为文件系统上的文件数据的缓存,提升读写性能,常见的是针对文件的read()/write()操作,另外也包括了通过mmap()映射之后的块设备,也就是说,事实上 Page Cache 负责了大部分的块设备文件的缓存工作。而 Buffer Cache 用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用,实际上负责所有对磁盘的 I/O 访问:

因为 Buffer Cache 是对粒度更细的设备块的缓存,而 Page Cache 是基于虚拟内存的页单元缓存,因此还是会基于 Buffer Cache,也就是说如果是缓存文件内容数据就会在内存里缓存两份相同的数据,这就会导致同一份文件保存了两份,冗余且低效。另外一个问题是,调用 write 后,有效数据是在 Buffer Cache 中,而非 Page Cache 中。这就导致 mmap 访问的文件数据可能存在不一致问题。为了规避这个问题,所有基于磁盘文件系统的 write,都需要调用 update_vm_cache() 函数,该操作会把调用 write 之后的 Buffer Cache 更新到 Page Cache 去。由于有这些设计上的弊端,因此在 Linux 2.4 版本之后,kernel 就将两者进行了统一,Buffer Cache 不再以独立的形式存在,而是以融合的方式存在于 Page Cache 中:

融合之后就可以统一操作 Page Cache 和 Buffer Cache:处理文件 I/O 缓存交给 Page Cache,而当底层 RAW device 刷新数据时以 Buffer Cache 的块单位来实际处理。

I/O 模式

在 Linux 或者其他 Unix-like 操作系统里,I/O 模式一般有三种:

  • 程序控制 I/O
  • 中断驱动 I/O
  • DMA I/O

下面我分别详细地讲解一下这三种 I/O 模式。

程序控制 I/O

这是最简单的一种 I/O 模式,也叫忙等待或者轮询:用户通过发起一个系统调用,陷入内核态,内核将系统调用翻译成一个对应设备驱动程序的过程调用,接着设备驱动程序会启动 I/O 不断循环去检查该设备,看看是否已经就绪,一般通过返回码来表示,I/O 结束之后,设备驱动程序会把数据送到指定的地方并返回,切回用户态。

比如发起系统调用read()

中断驱动 I/O

第二种 I/O 模式是利用中断来实现的:

流程如下:

  1. 用户进程发起一个read()系统调用读取磁盘文件,陷入内核态并由其所在的 CPU 通过设备驱动程序向设备寄存器写入一个通知信号,告知设备控制器 (我们这里是磁盘控制器)要读取数据;
  2. 磁盘控制器启动磁盘读取的过程,把数据从磁盘拷贝到磁盘控制器缓冲区里;
  3. 完成拷贝之后磁盘控制器会通过总线发送一个中断信号到中断控制器,如果此时中断控制器手头还有正在处理的中断或者有一个和该中断信号同时到达的更高优先级的中断,则这个中断信号将被忽略,而磁盘控制器会在后面持续发送中断信号直至中断控制器受理;
  4. 中断控制器收到磁盘控制器的中断信号之后会通过地址总线存入一个磁盘设备的编号,表示这次中断需要关注的设备是磁盘;
  5. 中断控制器向 CPU 置起一个磁盘中断信号;
  6. CPU 收到中断信号之后停止当前的工作,把当前的 PC/PSW 等寄存器压入堆栈保存现场,然后从地址总线取出设备编号,通过编号找到中断向量所包含的中断服务的入口地址,压入 PC 寄存器,开始运行磁盘中断服务,把数据从磁盘控制器的缓冲区拷贝到主存里的内核缓冲区;
  7. 最后 CPU 再把数据从内核缓冲区拷贝到用户缓冲区,完成读取操作,read()返回,切换回用户态。

DMA I/O

并发系统的性能高低究其根本,是取决于如何对 CPU 资源的高效调度和使用,而回头看前面的中断驱动 I/O 模式的流程,可以发现第 6、7 步的数据拷贝工作都是由 CPU 亲自完成的,也就是在这两次数据拷贝阶段中 CPU 是完全被占用而不能处理其他工作的,那么这里明显是有优化空间的;第 7 步的数据拷贝是从内核缓冲区到用户缓冲区,都是在主存里,所以这一步只能由 CPU 亲自完成,但是第 6 步的数据拷贝,是从磁盘控制器的缓冲区到主存,是两个设备之间的数据传输,这一步并非一定要 CPU 来完成,可以借助 DMA 来完成,减轻 CPU 的负担。

DMA 全称是 Direct Memory Access,也即直接存储器存取,是一种用来提供在外设和存储器之间或者存储器和存储器之间的高速数据传输。整个过程无须 CPU 参与,数据直接通过 DMA 控制器进行快速地移动拷贝,节省 CPU 的资源去做其他工作。

目前,大部分的计算机都配备了 DMA 控制器,而 DMA 技术也支持大部分的外设和存储器。借助于 DMA 机制,计算机的 I/O 过程就能更加高效:

DMA 控制器内部包含若干个可以被 CPU 读写的寄存器:一个主存地址寄存器 MAR(存放要交换数据的主存地址)、一个外设地址寄存器 ADR(存放 I/O 设备的设备码,或者是设备信息存储区的寻址信息)、一个字节数寄存器 WC(对传送数据的总字数进行统计)、和一个或多个控制寄存器。

  1. 用户进程发起一个read()系统调用读取磁盘文件,陷入内核态并由其所在的 CPU 通过设置 DMA 控制器的寄存器对它进行编程:把内核缓冲区和磁盘文件的地址分别写入 MAR 和 ADR 寄存器,然后把期望读取的字节数写入 WC 寄存器,启动 DMA 控制器;
  2. DMA 控制器根据 ADR 寄存器里的信息知道这次 I/O 需要读取的外设是磁盘的某个地址,便向磁盘控制器发出一个命令,通知它从磁盘读取数据到其内部的缓冲区里;
  3. 磁盘控制器启动磁盘读取的过程,把数据从磁盘拷贝到磁盘控制器缓冲区里,并对缓冲区内数据的校验和进行检验,如果数据是有效的,那么 DMA 就可以开始了;
  4. DMA 控制器通过总线向磁盘控制器发出一个读请求信号从而发起 DMA 传输,这个信号和前面的中断驱动 I/O 小节里 CPU 发给磁盘控制器的读请求是一样的,它并不知道或者并不关心这个读请求是来自 CPU 还是 DMA 控制器;
  5. 紧接着 DMA 控制器将引导磁盘控制器将数据传输到 MAR 寄存器里的地址,也就是内核缓冲区;
  6. 数据传输完成之后,返回一个 ack 给 DMA 控制器,WC 寄存器里的值会减去相应的数据长度,如果 WC 还不为 0,则重复第 4 步到第 6 步,一直到 WC 里的字节数等于 0;
  7. 收到 ack 信号的 DMA 控制器会通过总线发送一个中断信号到中断控制器,如果此时中断控制器手头还有正在处理的中断或者有一个和该中断信号同时到达的更高优先级的中断,则这个中断信号将被忽略,而 DMA 控制器会在后面持续发送中断信号直至中断控制器受理;
  8. 中断控制器收到磁盘控制器的中断信号之后会通过地址总线存入一个主存设备的编号,表示这次中断需要关注的设备是主存;
  9. 中断控制器向 CPU 置起一个 DMA 中断的信号;
  10. CPU 收到中断信号之后停止当前的工作,把当前的 PC/PSW 等寄存器压入堆栈保存现场,然后从地址总线取出设备编号,通过编号找到中断向量所包含的中断服务的入口地址,压入 PC 寄存器,开始运行 DMA 中断服务,把数据从内核缓冲区拷贝到用户缓冲区,完成读取操作,read() 返回,切换回用户态。

传统 I/O 读写模式

Linux 中传统的 I/O 读写是通过read()/write()系统调用完成的,read()把数据从存储器 (磁盘、网卡等) 读取到用户缓冲区,write()则是把数据从用户缓冲区写出到存储器:

1
2
3
4
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

一次完整的读磁盘文件然后写出到网卡的底层传输过程如下:

可以清楚看到这里一共触发了 4 次用户态和内核态的上下文切换,分别是read()/write()调用和返回时的切换,2 次 DMA 拷贝,2 次 CPU 拷贝,加起来一共 4 次拷贝操作。

通过引入 DMA,我们已经把 Linux 的 I/O 过程中的 CPU 拷贝次数从 4 次减少到了 2 次,但是 CPU 拷贝依然是代价很大的操作,对系统性能的影响还是很大,特别是那些频繁 I/O 的场景,更是会因为 CPU 拷贝而损失掉很多性能,我们需要进一步优化,降低、甚至是完全避免 CPU 拷贝。

零拷贝 (Zero-copy)

Zero-copy 是什么?

Wikipedia 的解释如下:

“Zero-copy” describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.

零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。

Zero-copy 能做什么?

  • 减少甚至完全避免操作系统内核和用户应用程序地址空间这两者之间进行数据拷贝操作,从而减少用户态 — 内核态上下文切换带来的系统开销。
  • 减少甚至完全避免操作系统内核缓冲区之间进行数据拷贝操作。
  • 帮助用户进程绕开操作系统内核空间直接访问硬件存储接口操作数据。
  • 利用 DMA 而非 CPU 来完成硬件接口和内核缓冲区之间的数据拷贝,从而解放 CPU,使之能去执行其他的任务,提升系统性能。

Zero-copy 的实现方式有哪些?

从 zero-copy 这个概念被提出以来,相关的实现技术便犹如雨后春笋,层出不穷。但是截至目前为止,并没有任何一种 zero-copy 技术能满足所有的场景需求,还是计算机领域那句无比经典的名言:”There is no silver bullet”!

而在 Linux 平台上,同样也有很多的 zero-copy 技术,新旧各不同,可能存在于不同的内核版本里,很多技术可能有了很大的改进或者被更新的实现方式所替代,这些不同的实现技术按照其核心思想可以归纳成大致的以下三类:

  • 减少甚至避免用户空间和内核空间之间的数据拷贝:在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的 Page Cache 和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是通过增加新的系统调用来完成的,比如 Linux 中的mmap()sendfile()以及splice()等。
  • 绕过内核的直接 I/O:允许在用户态进程绕过内核直接和硬件进行数据传输,内核在传输过程中只负责一些管理和辅助的工作。这种方式其实和第一种有点类似,也是试图避免用户空间和内核空间之间的数据传输,只是第一种方式是把数据传输过程放在内核态完成,而这种方式则是直接绕过内核和硬件通信,效果类似但原理完全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种方式侧重于在用户进程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种方法延续了以往那种传统的通信方式,但更灵活。

减少甚至避免用户空间和内核空间之间的数据拷贝

mmap()

1
2
3
4
#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

一种简单的实现方案是在一次读写过程中用 Linux 的另一个系统调用mmap()替换原先的read()mmap()也即是内存映射(memory map):把用户进程空间的一段内存缓冲区(user buffer)映射到文件所在的内核缓冲区(kernel buffer)上。

利用mmap()替换read(),配合write() 调用的整个流程如下:

  • 用户进程调用mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;
  • DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  • mmap()返回,上下文从内核态切换回用户态;
  • 用户进程调用write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;
  • CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;
  • DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  • write()返回,上下文从内核态切换回用户态。

通过这种方式,有两个优点:一是节省内存空间,因为用户进程上的这一段内存是虚拟的,并不真正占据物理内存,只是映射到文件所在的内核缓冲区上,因此可以节省一半的内存占用;二是省去了一次 CPU 拷贝,对比传统的 Linux I/O 读写,数据不需要再经过用户进程进行转发了,而是直接在内核里就完成了拷贝。所以使用mmap()之后的拷贝次数是 2 次 DMA 拷贝,1 次 CPU 拷贝,加起来一共 3 次拷贝操作,比传统的 I/O 方式节省了一次 CPU 拷贝以及一半的内存,不过因为mmap()也是一个系统调用,因此用户态和内核态的切换还是 4 次。

mmap()因为既节省 CPU 拷贝次数又节省内存,所以比较适合大文件传输的场景。虽然mmap()完全是符合 POSIX 标准的,但是它也不是完美的,因为它并不总是能达到理想的数据传输性能。首先是因为数据数据传输过程中依然需要一次 CPU 拷贝,其次是内存映射技术是一个开销很大的虚拟存储操作:这种操作需要修改页表以及用内核缓冲区里的文件数据汰换掉当前 TLB 里的缓存以维持虚拟内存映射的一致性。但是,因为内存映射通常针对的是相对较大的数据区域,所以对于相同大小的数据来说,内存映射所带来的开销远远低于 CPU 拷贝所带来的开销。此外,使用mmap()还可能会遇到一些需要值得关注的特殊情况,例如,在mmap()—>write()这两个系统调用的整个传输过程中,如果有其他的进程突然截断了这个文件,那么这时用户进程就会因为访问非法地址而被一个从总线传来的 SIGBUS 中断信号杀死并且产生一个 core dump。有两种解决办法:

  • 设置一个信号处理器,专门用来处理 SIGBUS 信号,这个处理器直接返回,write()就可以正常返回已写入的字节数而不会被SIGBUS中断,errno错误码也会被设置成 success。然而这实际上是一个掩耳盗铃的解决方案,因为 BIGBUS 信号的带来的信息是系统发生了一些很严重的错误,而我们却选择忽略掉它,一般不建议采用这种方式。
  • 通过内核的文件租借锁(这是 Linux 的叫法,Windows 上称之为机会锁)来解决这个问题,这种方法相对来说更好一些。我们可以通过内核对文件描述符上读/写的租借锁,当另外一个进程尝试对当前用户进程正在进行传输的文件进行截断的时候,内核会发送给用户一个实时信号:RT_SIGNAL_LEASE 信号,这个信号会告诉用户内核正在破坏你加在那个文件上的读/写租借锁,这时write()系统调用会被中断,并且当前用户进程会被 SIGBUS 信号杀死,返回值则是中断前写的字节数,errno 同样会被设置为 success。文件租借锁需要在对文件进行内存映射之前设置,最后在用户进程结束之前释放掉。

sendfile()

在 Linux 内核 2.1 版本中,引入了一个新的系统调用sendfile()

1
2
3
#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

从功能上来看,这个系统调用将mmap()+write()这两个系统调用合二为一,实现了一样效果的同时还简化了用户接口,其他的一些 Unix-like 的系统像 BSD、Solaris 和 AIX 等也有类似的实现,甚至 Windows 上也有一个功能类似的 API 函数 TransmitFile。

out_fdin_fd分别代表了写入和读出的文件描述符,in_fd必须是一个指向文件的文件描述符,且要能支持类mmap()内存映射,不能是 Socket 类型,而out_fd在 Linux 内核 2.6.33 版本之前只能是一个指向Socket的文件描述符,从 2.6.33 之后则可以是任意类型的文件描述符。off_t 是一个代表了in_fd偏移量的指针,指示sendfile()该从in_fd的哪个位置开始读取,函数返回后,这个指针会被更新成sendfile()最后读取的字节位置处,表明此次调用共读取了多少文件数据,最后的count参数则是此次调用需要传输的字节总数。

使用sendfile()完成一次数据读写的流程如下:

  • 用户进程调用sendfile()从用户态陷入内核态;
  • DMA 控制器将数据从硬盘拷贝到内核缓冲区;
  • CPU 将内核缓冲区中的数据拷贝到套接字缓冲区;
  • DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;
  • sendfile()返回,上下文从内核态切换回用户态。

基于sendfile(),整个数据传输过程中共发生 2 次 DMA 拷贝和 1 次 CPU 拷贝,这个和mmap()+write()相同,但是因为sendfile()只是一次系统调用,因此比前者少了一次用户态和内核态的上下文切换开销。读到这里,聪明的读者应该会开始提问了:”sendfile()会不会遇到和mmap()+write()相似的文件截断问题呢?”,很不幸,答案是肯定的。sendfile() 一样会有文件截断的问题,但欣慰的是,sendfile()不仅比mmap()+write()在接口使用上更加简洁,而且处理文件截断时也更加优雅:如果sendfile()过程中遭遇文件截断,则sendfile()系统调用会被中断杀死之前返回给用户进程其中断前所传输的字节数,errno 会被设置为 success,无需用户提前设置信号处理器,当然你要设置一个进行个性化处理也可以,也不需要像之前那样提前给文件描述符设置一个租借锁,因为最终结果还是一样的。

sendfile()相较于mmap()的另一个优势在于数据在传输过程中始终没有越过用户态和内核态的边界,因此极大地减少了存储管理的开销。即便如此,sendfile() 依然是一个适用性很窄的技术,最适合的场景基本也就是一个静态文件服务器了。而且根据 Linus 在 2001 年和其他内核维护者的邮件列表内容,其实当初之所以决定在 Linux 上实现sendfile()仅仅是因为在其他操作系统平台上已经率先实现了,而且有大名鼎鼎的 Apache Web 服务器已经在使用了,为了兼容 Apache Web 服务器才决定在 Linux 上也实现这个技术,而且sendfile()实现上的简洁性也和 Linux 内核的其他部分集成得很好,所以 Linus 也就同意了这个提案。

然而sendfile()本身是有很大问题的,从不同的角度来看的话主要是:

  • 首先一个是这个接口并没有进行标准化,导致sendfile()在 Linux 上的接口实现和其他类 Unix 系统的实现并不相同;
  • 其次由于网络传输的异步性,很难在接收端实现和sendfile()对接的技术,因此接收端一直没有实现对应的这种技术;
  • 最后从性能方面考量,因为sendfile()在把磁盘文件从内核缓冲区(page cache)传输到到套接字缓冲区的过程中依然需要 CPU 参与,这就很难避免 CPU 的高速缓存被传输的数据所污染。

此外,需要说明下,sendfile()的最初设计并不是用来处理大文件的,因此如果需要处理很大的文件的话,可以使用另一个系统调用sendfile64(),它支持对更大的文件内容进行寻址和偏移。

sendfile() with DMA Scatter/Gather Copy

上一小节介绍的sendfile()技术已经把一次数据读写过程中的 CPU 拷贝的降低至只有 1 次了,但是人永远是贪心和不知足的,现在如果想要把这仅有的一次 CPU 拷贝也去除掉,有没有办法呢?

当然有!通过引入一个新硬件上的支持,我们可以把这个仅剩的一次 CPU 拷贝也给抹掉:Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather — 分散/收集功能,并修改了sendfile()的代码使之和 DMA 适配。scatter 使得 DMA 拷贝可以不再需要把数据存储在一片连续的内存空间上,而是允许离散存储,gather 则能够让 DMA 控制器根据少量的元信息:一个包含了内存地址和数据大小的缓冲区描述符,收集存储在各处的数据,最终还原成一个完整的网络包,直接拷贝到网卡而非套接字缓冲区,避免了最后一次的 CPU 拷贝:

sendfile() + DMA gather的数据传输过程如下:

  • 用户进程调用sendfile(),从用户态陷入内核态;
  • DMA 控制器使用 scatter 功能把数据从硬盘拷贝到内核缓冲区进行离散存储;
  • CPU 把包含内存地址和数据长度的缓冲区描述符拷贝到套接字缓冲区,DMA 控制器能够根据这些信息生成网络包数据分组的报头和报尾
  • DMA 控制器根据缓冲区描述符里的内存地址和数据大小,使用 scatter-gather 功能开始从内核缓冲区收集离散的数据并组包,最后直接把网络包数据拷贝到网卡完成数据传输;
  • sendfile()返回,上下文从内核态切换回用户态。

基于这种方案,我们就可以把这仅剩的唯一一次 CPU 拷贝也给去除了(严格来说还是会有一次,但是因为这次 CPU 拷贝的只是那些微乎其微的元信息,开销几乎可以忽略不计),理论上,数据传输过程就再也没有 CPU 的参与了,也因此 CPU 的高速缓存再不会被污染了,也不再需要 CPU 来计算数据校验和了,CPU 可以去执行其他的业务计算任务,同时和 DMA 的 I/O 任务并行,此举能极大地提升系统性能。

splice()

sendfile() + DMA Scatter/Gather的零拷贝方案虽然高效,但是也有两个缺点:

  • 这种方案需要引入新的硬件支持;
  • 虽然sendfile()的输出文件描述符在 Linux kernel 2.6.33 版本之后已经可以支持任意类型的文件描述符,但是输入文件描述符依然只能指向文件。

这两个缺点限制了sendfile() + DMA Scatter/Gather方案的适用场景。为此,Linux 在 2.6.17 版本引入了一个新的系统调用splice(),它在功能上和sendfile()非常相似,但是能够实现在任意类型的两个文件描述符时之间传输数据;而在底层实现上,splice()又比sendfile()少了一次 CPU 拷贝,也就是等同于sendfile() + DMA Scatter/Gather,完全去除了数据传输过程中的 CPU 拷贝。

splice()系统调用函数定义如下:

1
2
3
4
5
6
7
#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

fd_infd_out也是分别代表了输入端和输出端的文件描述符,这两个文件描述符必须有一个是指向管道设备的,这也是一个不太友好的限制。

off_inoff_out则分别是fd_infd_out的偏移量指针,指示内核从哪里读取和写入数据,len则指示了此次调用希望传输的字节数,最后的flags是系统调用的标记选项位掩码,用来设置系统调用的行为属性的,由以下 0 个或者多个值通过『或』操作组合而成:

  • SPLICE_F_MOVE:指示splice()尝试仅仅是移动内存页面而不是复制,设置了这个值不代表就一定不会复制内存页面,复制还是移动取决于内核能否从管道中移动内存页面,或者管道中的内存页面是否是完整的;这个标记的初始实现有很多 bug,所以从 Linux 2.6.21 版本开始就已经无效了,但还是保留了下来,因为在未来的版本里可能会重新被实现。
  • SPLICE_F_NONBLOCK:指示splice()不要阻塞 I/O,也就是使得splice()调用成为一个非阻塞调用,可以用来实现异步数据传输,不过需要注意的是,数据传输的两个文件描述符也最好是预先通过 O_NONBLOCK 标记成非阻塞 I/O,不然splice()调用还是有可能被阻塞。
  • SPLICE_F_MORE:通知内核下一个splice()系统调用将会有更多的数据传输过来,这个标记对于输出端是 socket 的场景非常有用。

splice()是基于 Linux 的管道缓冲区 (pipe buffer) 机制实现的,所以splice()的两个入参文件描述符才要求必须有一个是管道设备,一个典型的splice()用法是:

1
2
3
4
5
6
7
8
9
int pfd[2];

pipe(pfd);

ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
assert(bytes != -1);

bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
assert(bytes != -1);

数据传输过程图:

使用splice()完成一次磁盘文件到网卡的读写过程如下:

  • 用户进程调用pipe(),从用户态陷入内核态,创建匿名单向管道,pipe() 返回,上下文从内核态切换回用户态;
  • 用户进程调用splice(),从用户态陷入内核态;
  • DMA 控制器将数据从硬盘拷贝到内核缓冲区,从管道的写入端”拷贝”进管道,splice() 返回,上下文从内核态回到用户态;
  • 用户进程再次调用splice(),从用户态陷入内核态;
  • 内核把数据从管道的读取端”拷贝”到套接字缓冲区,DMA 控制器将数据从套接字缓冲区拷贝到网卡;
  • splice()返回,上下文从内核态切换回用户态。

相信看完上面的读写流程之后,读者肯定会非常困惑:说好的splice()sendfile()的改进版呢?sendfile()好歹只需要一次系统调用,splice()居然需要三次,这也就罢了,居然中间还搞出来一个管道,而且还要在内核空间拷贝两次,这算个毛的改进啊?

我最开始了解splice()的时候,也是这个反应,但是深入学习它之后,才渐渐知晓个中奥妙,且听我细细道来:

先来了解一下 pipe buffer 管道,管道是 Linux 上用来供进程之间通信的信道,管道有两个端:写入端和读出端,从进程的视角来看,管道表现为一个 FIFO 字节流环形队列:

管道本质上是一个内存中的文件,也就是本质上还是基于 Linux 的 VFS,用户进程可以通过pipe()系统调用创建一个匿名管道,创建完成之后会有两个 VFS 的 file 结构体的 inode 分别指向其写入端和读出端,并返回对应的两个文件描述符,用户进程通过这两个文件描述符读写管道;管道的容量单位是一个虚拟内存的页,也就是 4KB,总大小一般是 16 个页,基于其环形结构,管道的页可以循环使用,提高内存利用率。 Linux 中以pipe_buffer结构体封装管道页,file 结构体里的 inode 字段里会保存一个 pipe_inode_info 结构体指代管道,其中会保存很多读写管道时所需的元信息,环形队列的头部指针页,读写时的同步机制如互斥锁、等待队列等:

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 pipe_buffer {
struct page *page; // 内存页结构
unsigned int offset, len; // 偏移量,长度
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};

struct pipe_inode_info {
struct mutex mutex;
wait_queue_head_t wait;
unsigned int nrbufs, curbuf, buffers;
unsigned int readers;
unsigned int writers;
unsigned int files;
unsigned int waiting_writers;
unsigned int r_counter;
unsigned int w_counter;
struct page *tmp_page;
struct fasync_struct *fasync_readers;
struct fasync_struct *fasync_writers;
struct pipe_buffer *bufs;
struct user_struct *user;
};

pipe_buffer中保存了数据在内存中的页、偏移量和长度,以这三个值来定位数据,注意这里的页不是虚拟内存的页,而用的是物理内存的页框,因为管道时跨进程的信道,因此不能使用虚拟内存来表示,只能使用物理内存的页框定位数据;管道的正常读写操作是通过pipe_write()/pipe_read()来完成的,通过把数据读取/写入环形队列的pipe_buffer来完成数据传输。

splice() 是基于 pipe buffer 实现的,但是它在通过管道传输数据的时候却是零拷贝,因为它在写入读出时并没有使用pipe_write()/pipe_read()真正地在管道缓冲区写入读出数据,而是通过把数据在内存缓冲区中的物理内存页框指针、偏移量和长度赋值给前文提及的pipe_buffer中对应的三个字段来完成数据的”拷贝”,也就是其实只拷贝了数据的内存地址等元信息。

splice()在 Linux 内核源码中的内部实现是do_splice()函数,而写入读出管道则分别是通过do_splice_to()do_splice_from(),这里我们重点来解析下写入管道的源码,也就是do_splice_to(),我现在手头的 Linux 内核版本是 v4.8.17,我们就基于这个版本来分析,至于读出的源码函数do_splice_from(),原理是相通的,大家举一反三即可。

splice()写入数据到管道的调用链式:do_splice() --> do_splice_to() --> splice_read()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static long do_splice(struct file *in, loff_t __user *off_in,
struct file *out, loff_t __user *off_out,
size_t len, unsigned int flags)
{
...

// 判断是写出 fd 是一个管道设备,则进入数据写入的逻辑
if (opipe) {
if (off_out)
return -ESPIPE;
if (off_in) {
if (!(in->f_mode & FMODE_PREAD))
return -EINVAL;
if (copy_from_user(&offset, off_in, sizeof(loff_t)))
return -EFAULT;
} else {
offset = in->f_pos;
}

// 调用 do_splice_to 把文件内容写入管道
ret = do_splice_to(in, &offset, opipe, len, flags);

if (!off_in)
in->f_pos = offset;
else if (copy_to_user(off_in, &offset, sizeof(loff_t)))
ret = -EFAULT;

return ret;
}

return -EINVAL;
}

进入do_splice_to()之后,再调用splice_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
static long do_splice_to(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsigned int flags)
{
ssize_t (*splice_read)(struct file *, loff_t *,
struct pipe_inode_info *, size_t, unsigned int);
int ret;

if (unlikely(!(in->f_mode & FMODE_READ)))
return -EBADF;

ret = rw_verify_area(READ, in, ppos, len);
if (unlikely(ret < 0))
return ret;

if (unlikely(len > MAX_RW_COUNT))
len = MAX_RW_COUNT;

// 判断文件的文件的 file 结构体的 f_op 中有没有可供使用的、支持 splice 的 splice_read 函数指针
// 因为是`splice()`调用,因此内核会提前给这个函数指针指派一个可用的函数
if (in->f_op->splice_read)
splice_read = in->f_op->splice_read;
else
splice_read = default_file_splice_read;

return splice_read(in, ppos, pipe, len, flags);
}

in->f_op->splice_read这个函数指针根据文件描述符的类型不同有不同的实现,比如这里的in是一个文件,因此是generic_file_splice_read(),如果是socket的话,则是sock_splice_read(),其他的类型也会有对应的实现,总之我们这里将使用的是generic_file_splice_read()函数,这个函数会继续调用内部函数__generic_file_splice_read完成以下工作:

  1. 在 page cache 页缓存里进行搜寻,看看我们要读取这个文件内容是否已经在缓存里了,如果是则直接用,否则如果不存在或者只有部分数据在缓存中,则分配一些新的内存页并进行读入数据操作,同时会增加页框的引用计数;
  2. 基于这些内存页,初始化splice_pipe_desc结构,这个结构保存会保存文件数据的地址元信息,包含有物理内存页框地址,偏移、数据长度,也就是pipe_buffer所需的三个定位数据的值;
  3. 最后,调用splice_to_pipe()splice_pipe_desc结构体实例是函数入参。
1
2
3
4
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
ssize_t splice_to_pipe(struct pipe_inode_info *pipe, struct splice_pipe_desc *spd)
{
...

for (;;) {
if (!pipe->readers) {
send_sig(SIGPIPE, current, 0);
if (!ret)
ret = -EPIPE;
break;
}

if (pipe->nrbufs < pipe->buffers) {
int newbuf = (pipe->curbuf + pipe->nrbufs) & (pipe->buffers - 1);
struct pipe_buffer *buf = pipe->bufs + newbuf;

// 写入数据到管道,没有真正拷贝数据,而是内存地址指针的移动,
// 把物理页框、偏移量和数据长度赋值给 pipe_buffer 完成数据入队操作
buf->page = spd->pages[page_nr];
buf->offset = spd->partial[page_nr].offset;
buf->len = spd->partial[page_nr].len;
buf->private = spd->partial[page_nr].private;
buf->ops = spd->ops;
if (spd->flags & SPLICE_F_GIFT)
buf->flags |= PIPE_BUF_FLAG_GIFT;

pipe->nrbufs++;
page_nr++;
ret += buf->len;

if (pipe->files)
do_wakeup = 1;

if (!--spd->nr_pages)
break;
if (pipe->nrbufs < pipe->buffers)
continue;

break;
}

...
}

这里可以清楚地看到splice()所谓的写入数据到管道其实并没有真正地拷贝数据,而是玩了个 tricky 的操作:只进行内存地址指针的拷贝而不真正去拷贝数据。所以,数据splice()在内核中并没有进行真正的数据拷贝,因此splice()系统调用也是零拷贝。

还有一点需要注意,前面说过管道的容量是 16 个内存页,也就是 16 * 4KB = 64 KB,也就是说一次往管道里写数据的时候最好不要超过 64 KB,否则的话会splice()会阻塞住,除非在创建管道的时候使用的是pipe2()并通过传入 O_NONBLOCK 属性将管道设置为非阻塞。

即使splice()通过内存地址指针避免了真正的拷贝开销,但是算起来它还要使用额外的管道来完成数据传输,也就是比sendfile()多了两次系统调用,这不是又增加了上下文切换的开销吗?为什么不直接在内核创建管道并调用那两次splice(),然后只暴露给用户一次系统调用呢?实际上因为splice()利用管道而非硬件来完成零拷贝的实现比sendfile() + DMA Scatter/Gather的门槛更低,因此后来的sendfile()的底层实现就已经替换成splice()了。

至于说splice()本身的 API 为什么还是这种使用模式,那是因为 Linux 内核开发团队一直想把基于管道的这个限制去掉,但不知道因为什么一直搁置,所以这个 API 也就一直没变化,只能等内核团队哪天想起来了这一茬,然后重构一下使之不再依赖管道,在那之前,使用splice()依然还是需要额外创建管道来作为中间缓冲,如果你的业务场景很适合使用splice(),但又是性能敏感的,不想频繁地创建销毁 pipe buffer 管道缓冲区,那么可以参考一下 HAProxy 使用splice()时采用的优化方案:预先分配一个 pipe buffer pool 缓存管道,每次调用spclie()的时候去缓存池里取一个管道,用完就放回去,循环利用,提升性能。

send() with MSG_ZEROCOPY

Linux 内核在 2017 年的 v4.14 版本接受了来自 Google 工程师 Willem de Bruijn 在 TCP 网络报文的通用发送接口send()中实现的 zero-copy 功能 (MSG_ZEROCOPY) 的 patch,通过这个新功能,用户进程就能够把用户缓冲区的数据通过零拷贝的方式经过内核空间发送到网络套接字中去,这个新技术和前文介绍的几种零拷贝方式相比更加先进,因为前面几种零拷贝技术都是要求用户进程不能处理加工数据而是直接转发到目标文件描述符中去的。Willem de Bruijn 在他的论文里给出的压测数据是:采用 netperf 大包发送测试,性能提升 39%,而线上环境的数据发送性能则提升了 5%~8%,官方文档陈述说这个特性通常只在发送 10KB 左右大包的场景下才会有显著的性能提升。一开始这个特性只支持 TCP,到内核 v5.0 版本之后才支持 UDP。

这个功能的使用模式如下:

1
2
3
4
if (setsockopt(socket_fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
error(1, errno, "setsockopt zerocopy");

ret = send(socket_fd, buffer, sizeof(buffer), MSG_ZEROCOPY);

首先第一步,先给要发送数据的socket设置一个SOCK_ZEROCOPY option,然后在调用send()发送数据时再设置一个 MSG_ZEROCOPY option,其实理论上来说只需要调用setsockopt()或者send()时传递这个 zero-copy 的 option 即可,两者选其一,但是这里却要设置同一个 option 两次,官方的说法是为了兼容send()API 以前的设计上的一个错误:send() 以前的实现会忽略掉未知的 option,为了兼容那些可能已经不小心设置了 MSG_ZEROCOPY option 的程序,故而设计成了两步设置。不过我猜还有一种可能:就是给使用者提供更灵活的使用模式,因为这个新功能只在大包场景下才可能会有显著的性能提升,但是现实场景是很复杂的,不仅仅是全部大包或者全部小包的场景,有可能是大包小包混合的场景,因此使用者可以先调用setsockopt()设置 SOCK_ZEROCOPY option,然后再根据实际业务场景中的网络包尺寸选择是否要在调用send()时使用 MSG_ZEROCOPY 进行 zero-copy 传输。

因为send()可能是异步发送数据,因此使用 MSG_ZEROCOPY 有一个需要特别注意的点是:调用send()之后不能立刻重用或释放 buffer,因为 buffer 中的数据不一定已经被内核读走了,所以还需要从 socket 关联的错误队列里读取一下通知消息,看看 buffer 中的数据是否已经被内核读走了:

1
2
3
4
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
pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
error(1, errno, "recvmsg");

read_notification(msg);


uint32_t read_notification(struct msghdr *msg)
{
struct sock_extended_err *serr;
struct cmsghdr *cm;

cm = CMSG_FIRSTHDR(msg);
if (cm->cmsg_level != SOL_IP &&
cm->cmsg_type != IP_RECVERR)
error(1, 0, "cmsg");

serr = (void *) CMSG_DATA(cm);
if (serr->ee_errno != 0 ||
serr->ee_origin != SO_EE_ORIGIN_ZEROCOPY)
error(1, 0, "serr");

return serr->ee _ data;
}

这个技术是基于 redhat 红帽在 2010 年给 Linux 内核提交的 virtio-net zero-copy 技术之上实现的,至于底层原理,简单来说就是通过send()把数据在用户缓冲区中的分段指针发送到 socket 中去,利用 page pinning 页锁定机制锁住用户缓冲区的内存页,然后利用 DMA 直接在用户缓冲区通过内存地址指针进行数据读取,实现零拷贝;具体的细节可以通过阅读 Willem de Bruijn 的论文 (PDF) 深入了解。

目前来说,这种技术的主要缺陷有:

  • 只适用于大文件 (10KB 左右) 的场景,小文件场景因为 page pinning 页锁定和等待缓冲区释放的通知消息这些机制,甚至可能比直接 CPU 拷贝更耗时;
  • 因为可能异步发送数据,需要额外调用poll()和 recvmsg() 系统调用等待 buffer 被释放的通知消息,增加代码复杂度,以及会导致多次用户态和内核态的上下文切换;
  • MSG_ZEROCOPY 目前只支持发送端,接收端暂不支持。

绕过内核的直接 I/O

可以看出,前面种种的 zero-copy 的方法,都是在想方设法地优化减少或者去掉用户态和内核态之间以及内核态和内核态之间的数据拷贝,为了实现避免这些拷贝可谓是八仙过海,各显神通,采用了各种各样的手段,那么如果我们换个思路:其实这么费劲地去消除这些拷贝不就是因为有内核在掺和吗?如果我们绕过内核直接进行 I/O 不就没有这些烦人的拷贝问题了吗?这就是绕过内核直接 I/O 技术:

这种方案有两种实现方式:

  • 用户直接访问硬件
  • 内核控制访问硬件

用户直接访问硬件

这种技术赋予用户进程直接访问硬件设备的权限,这让用户进程能有直接读写硬件设备,在数据传输过程中只需要内核做一些虚拟内存配置相关的工作。这种无需数据拷贝和内核干预的直接 I/O,理论上是最高效的数据传输技术,但是正如前面所说的那样,并不存在能解决一切问题的银弹,这种直接 I/O 技术虽然有可能非常高效,但是它的适用性也非常窄,目前只适用于诸如 MPI 高性能通信、丛集计算系统中的远程共享内存等有限的场景。

这种技术实际上破坏了现代计算机操作系统最重要的概念之一 —— 硬件抽象,我们之前提过,抽象是计算机领域最最核心的设计思路,正式由于有了抽象和分层,各个层级才能不必去关心很多底层细节从而专注于真正的工作,才使得系统的运作更加高效和快速。此外,网卡通常使用功能较弱的 CPU,例如只包含简单指令集的 MIPS 架构处理器(没有不必要的功能,如浮点数计算等),也没有太多的内存来容纳复杂的软件。因此,通常只有那些基于以太网之上的专用协议会使用这种技术,这些专用协议的设计要比远比 TCP/IP 简单得多,而且多用于局域网环境中,在这种环境中,数据包丢失和损坏很少发生,因此没有必要进行复杂的数据包确认和流量控制机制。而且这种技术还需要定制的网卡,所以它是高度依赖硬件的。

与传统的通信设计相比,直接硬件访问技术给程序设计带来了各种限制:由于设备之间的数据传输是通过 DMA 完成的,因此用户空间的数据缓冲区内存页必须进行 page pinning(页锁定),这是为了防止其物理页框地址被交换到磁盘或者被移动到新的地址而导致 DMA 去拷贝数据的时候在指定的地址找不到内存页从而引发缺页错误,而页锁定的开销并不比 CPU 拷贝小,所以为了避免频繁的页锁定系统调用,应用程序必须分配和注册一个持久的内存池,用于数据缓冲。

用户直接访问硬件的技术可以得到极高的 I/O 性能,但是其应用领域和适用场景也极其的有限,如集群或网络存储系统中的节点通信。它需要定制的硬件和专门设计的应用程序,但相应地对操作系统内核的改动比较小,可以很容易地以内核模块或设备驱动程序的形式实现出来。直接访问硬件还可能会带来严重的安全问题,因为用户进程拥有直接访问硬件的极高权限,所以如果你的程序设计没有做好的话,可能会消耗本来就有限的硬件资源或者进行非法地址访问,可能也会因此间接地影响其他正在使用同一设备的应用程序,而因为绕开了内核,所以也无法让内核替你去控制和管理。

内核控制访问硬件

相较于用户直接访问硬件技术,通过内核控制的直接访问硬件技术更加的安全,它比前者在数据传输过程中会多干预一点,但也仅仅是作为一个代理人这样的角色,不会参与到实际的数据传输过程,内核会控制 DMA 引擎去替用户进程做缓冲区的数据传输工作。同样的,这种方式也是高度依赖硬件的,比如一些集成了专有网络栈协议的网卡。这种技术的一个优势就是用户集成去 I/O 时的接口不会改变,就和普通的read()/write()系统调用那样使用即可,所有的脏活累活都在内核里完成,用户接口友好度很高,不过需要注意的是,使用这种技术的过程中如果发生了什么不可预知的意外从而导致无法使用这种技术进行数据传输的话,则内核会自动切换为最传统 I/O 模式,也就是性能最差的那种模式。

这种技术也有着和用户直接访问硬件技术一样的问题:DMA 传输数据的过程中,用户进程的缓冲区内存页必须进行 page pinning 页锁定,数据传输完成后才能解锁。CPU 高速缓存内保存的多个内存地址也会被冲刷掉以保证 DMA 传输前后的数据一致性。这些机制有可能会导致数据传输的性能变得更差,因为read()/write()系统调用的语义并不能提前通知 CPU 用户缓冲区要参与 DMA 数据传输传输,因此也就无法像内核缓冲区那样可依提前加载进高速缓存,提高性能。由于用户缓冲区的内存页可能分布在物理内存中的任意位置,因此一些实现不好的 DMA 控制器引擎可能会有寻址限制从而导致无法访问这些内存区域。一些技术比如 AMD64 架构中的 IOMMU,允许通过将 DMA 地址重新映射到内存中的物理地址来解决这些限制,但反过来又可能会导致可移植性问题,因为其他的处理器架构,甚至是 Intel 64 位 x86 架构的变种 EM64T 都不具备这样的特性单元。此外,还可能存在其他限制,比如 DMA 传输的数据对齐问题,又会导致无法访问用户进程指定的任意缓冲区内存地址。

内核缓冲区和用户缓冲区之间的传输优化

到目前为止,我们讨论的 zero-copy 技术都是基于减少甚至是避免用户空间和内核空间之间的 CPU 数据拷贝的,虽然有一些技术非常高效,但是大多都有适用性很窄的问题,比如 sendfile()、splice() 这些,效率很高,但是都只适用于那些用户进程不需要直接处理数据的场景,比如静态文件服务器或者是直接转发数据的代理服务器。

现在我们已经知道,硬件设备之间的数据可以通过 DMA 进行传输,然而却并没有这样的传输机制可以应用于用户缓冲区和内核缓冲区之间的数据传输。不过另一方面,广泛应用在现代的 CPU 架构和操作系统上的虚拟内存机制表明,通过在不同的虚拟地址上重新映射页面可以实现在用户进程和内核之间虚拟复制和共享内存,尽管一次传输的内存颗粒度相对较大:4KB 或 8KB。

因此如果要在实现在用户进程内处理数据(这种场景比直接转发数据更加常见)之后再发送出去的话,用户空间和内核空间的数据传输就是不可避免的,既然避无可避,那就只能选择优化了,因此本章节我们要介绍两种优化用户空间和内核空间数据传输的技术:

  • 动态重映射与写时拷贝 (Copy-on-Write)
  • 缓冲区共享 (Buffer Sharing)

动态重映射与写时拷贝 (Copy-on-Write)

前面我们介绍过利用内存映射技术来减少数据在用户空间和内核空间之间的复制,通常简单模式下,用户进程是对共享的缓冲区进行同步阻塞读写的,这样不会有 data race 问题,但是这种模式下效率并不高,而提升效率的一种方法就是异步地对共享缓冲区进行读写,而这样的话就必须引入保护机制来避免数据冲突问题,写时复制 (Copy on Write) 就是这样的一种技术。

写入时复制(Copy-on-write,COW)是一种计算机程序设计领域的优化策略。其核心思想是,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。

举一个例子,引入了 COW 技术之后,用户进程读取磁盘文件进行数据处理最后写到网卡,首先使用内存映射技术让用户缓冲区和内核缓冲区共享了一段内存地址并标记为只读 (read-only),避免数据拷贝,而当要把数据写到网卡的时候,用户进程选择了异步写的方式,系统调用会直接返回,数据传输就会在内核里异步进行,而用户进程就可以继续其他的工作,并且共享缓冲区的内容可以随时再进行读取,效率很高,但是如果该进程又尝试往共享缓冲区写入数据,则会产生一个 COW 事件,让试图写入数据的进程把数据复制到自己的缓冲区去修改,这里只需要复制要修改的内存页即可,无需所有数据都复制过去,而如果其他访问该共享内存的进程不需要修改数据则可以永远不需要进行数据拷贝。

COW 是一种建构在虚拟内存冲映射技术之上的技术,因此它需要 MMU 的硬件支持,MMU 会记录当前哪些内存页被标记成只读,当有进程尝试往这些内存页中写数据的时候,MMU 就会抛一个异常给操作系统内核,内核处理该异常时为该进程分配一份物理内存并复制数据到此内存地址,重新向 MMU 发出执行该进程的写操作。

COW 最大的优势是节省内存和减少数据拷贝,不过却是通过增加操作系统内核 I/O 过程复杂性作为代价的。当确定采用 COW 来复制页面时,重要的是注意空闲页面的分配位置。许多操作系统为这类请求提供了一个空闲的页面池。当进程的堆栈或堆要扩展时或有写时复制页面需要管理时,通常分配这些空闲页面。操作系统分配这些页面通常采用称为按需填零的技术。按需填零页面在需要分配之前先填零,因此会清除里面旧的内容。

局限性:

COW 这种零拷贝技术比较适用于那种多读少写从而使得 COW 事件发生较少的场景,因为 COW 事件所带来的系统开销要远远高于一次 CPU 拷贝所产生的。此外,在实际应用的过程中,为了避免频繁的内存映射,可以重复使用同一段内存缓冲区,因此,你不需要在只用过一次共享缓冲区之后就解除掉内存页的映射关系,而是重复循环使用,从而提升性能,不过这种内存页映射的持久化并不会减少由于页表往返移动和 TLB 冲刷所带来的系统开销,因为每次接收到 COW 事件之后对内存页而进行加锁或者解锁的时候,页面的只读标志 (read-ony) 都要被更改为 (write-only)。

缓冲区共享 (Buffer Sharing)

从前面的介绍可以看出,传统的 Linux I/O 接口,都是基于复制/拷贝的:数据需要在操作系统内核空间和用户空间的缓冲区之间进行拷贝。在进行 I/O 操作之前,用户进程需要预先分配好一个内存缓冲区,使用read()系统调用时,内核会将从存储器或者网卡等设备读入的数据拷贝到这个用户缓冲区里;而使用write() 系统调用时,则是把用户内存缓冲区的数据拷贝至内核缓冲区。

为了实现这种传统的 I/O 模式,Linux 必须要在每一个 I/O 操作时都进行内存虚拟映射和解除。这种内存页重映射的机制的效率严重受限于缓存体系结构、MMU 地址转换速度和 TLB 命中率。如果能够避免处理 I/O 请求的虚拟地址转换和 TLB 刷新所带来的开销,则有可能极大地提升 I/O 性能。而缓冲区共享就是用来解决上述问题的一种技术。

最早支持 Buffer Sharing 的操作系统是 Solaris。后来,Linux 也逐步支持了这种 Buffer Sharing 的技术,但时至今日依然不够完整和成熟。

操作系统内核开发者们实现了一种叫 fbufs 的缓冲区共享的框架,也即快速缓冲区( Fast Buffers ),使用一个fbuf缓冲区作为数据传输的最小单位,使用这种技术需要调用新的操作系统 API,用户区和内核区、内核区之间的数据都必须严格地在 fbufs 这个体系下进行通信。fbufs 为每一个用户进程分配一个 buffer pool,里面会储存预分配 (也可以使用的时候再分配) 好的 buffers,这些 buffers 会被同时映射到用户内存空间和内核内存空间。fbufs 只需通过一次虚拟内存映射操作即可创建缓冲区,有效地消除那些由存储一致性维护所引发的大多数性能损耗。

传统的 Linux I/O 接口是通过把数据在用户缓冲区和内核缓冲区之间进行拷贝传输来完成的,这种数据传输过程中需要进行大量的数据拷贝,同时由于虚拟内存技术的存在,I/O 过程中还需要频繁地通过 MMU 进行虚拟内存地址到物理内存地址的转换,高速缓存的汰换以及 TLB 的刷新,这些操作均会导致性能的损耗。而如果利用 fbufs 框架来实现数据传输的话,首先可以把 buffers 都缓存到 pool 里循环利用,而不需要每次都去重新分配,而且缓存下来的不止有 buffers 本身,而且还会把虚拟内存地址到物理内存地址的映射关系也缓存下来,也就可以避免每次都进行地址转换,从发送接收数据的层面来说,用户进程和 I/O 子系统比如设备驱动程序、网卡等可以直接传输整个缓冲区本身而不是其中的数据内容,也可以理解成是传输内存地址指针,这样就就避免了大量的数据内容拷贝:用户进程/ IO 子系统通过发送一个个的fbuf写出数据到内核而非直接传递数据内容,相对应的,用户进程/ IO 子系统通过接收一个个的fbuf而从内核读入数据,这样就能减少传统的read()/write()系统调用带来的数据拷贝开销:

  • 发送方用户进程调用uf_allocate从自己的 buffer pool 获取一个fbuf缓冲区,往其中填充内容之后调用uf_write向内核区发送指向fbuf的文件描述符;
  • I/O 子系统接收到fbuf之后,调用 uf_allocb 从接收方用户进程的 buffer pool 获取一个 fubf 并用接收到的数据进行填充,然后向用户区发送指向fbuf的文件描述符;
  • 接收方用户进程调用uf_get接收到fbuf,读取数据进行处理,完成之后调用uf_deallocatefbuf放回自己的 buffer pool。

fbufs 的缺陷

共享缓冲区技术的实现需要依赖于用户进程、操作系统内核、以及 I/O 子系统 (设备驱动程序,文件系统等)之间协同工作。比如,设计得不好的用户进程容易就会修改已经发送出去的fbuf从而污染数据,更要命的是这种问题很难 debug。虽然这个技术的设计方案非常精彩,但是它的门槛和限制却不比前面介绍的其他技术少:首先会对操作系统 API 造成变动,需要使用新的一些 API 调用,其次还需要设备驱动程序配合改动,还有由于是内存共享,内核需要很小心谨慎地实现对这部分共享的内存进行数据保护和同步的机制,而这种并发的同步机制是非常容易出 bug 的从而又增加了内核的代码复杂度,等等。因此这一类的技术还远远没有到发展成熟和广泛应用的阶段,目前大多数的实现都还处于实验阶段。

总结

本文中我主要讲解了 Linux I/O 底层原理,然后介绍并解析了 Linux 中的 Zero-copy 技术,并给出了 Linux 对 I/O 模块的优化和改进思路。

Linux 的 Zero-copy 技术可以归纳成以下三大类:

  • 减少甚至避免用户空间和内核空间之间的数据拷贝:在一些场景下,用户进程在数据传输过程中并不需要对数据进行访问和处理,那么数据在 Linux 的 Page Cache 和用户进程的缓冲区之间的传输就完全可以避免,让数据拷贝完全在内核里进行,甚至可以通过更巧妙的方式避免在内核里的数据拷贝。这一类实现一般是是通过增加新的系统调用来完成的,比如 Linux 中的mmap(),sendfile() 以及splice()等。
  • 绕过内核的直接 I/O:允许在用户态进程绕过内核直接和硬件进行数据传输,内核在传输过程中只负责一些管理和辅助的工作。这种方式其实和第一种有点类似,也是试图避免用户空间和内核空间之间的数据传输,只是第一种方式是把数据传输过程放在内核态完成,而这种方式则是直接绕过内核和硬件通信,效果类似但原理完全不同。
  • 内核缓冲区和用户缓冲区之间的传输优化:这种方式侧重于在用户进程的缓冲区和操作系统的页缓存之间的 CPU 拷贝的优化。这种方法延续了以往那种传统的通信方式,但更灵活。

本文从虚拟内存、I/O 缓冲区,用户态&内核态以及 I/O 模式等等知识点全面而又详尽地剖析了 Linux 系统的 I/O 底层原理,分析了 Linux 传统的 I/O 模式的弊端,进而引入 Linux Zero-copy 零拷贝技术的介绍和原理解析,通过将零拷贝技术和传统的 I/O 模式进行区分和对比,带领读者经历了 Linux I/O 的演化历史,通过帮助读者理解 Linux 内核对 I/O 模块的优化改进思路,相信不仅仅是让读者了解 Linux 底层系统的设计原理,更能对读者们在以后优化改进自己的程序设计过程中能够有所启发。

poll select epoll剖析

poll/select/epoll的实现都是基于文件提供的poll方法(f_op->poll),
该方法利用poll_table提供的_qproc方法向文件内部事件掩码_key对应的的一个或多个等待队列(wait_queue_head_t)上添加包含唤醒函数(wait_queue_t.func)的节点(wait_queue_t),并检查文件当前就绪的状态返回给poll的调用者(依赖于文件的实现)。
当文件的状态发生改变时(例如网络数据包到达),文件就会遍历事件对应的等待队列并调用回调函数(wait_queue_t.func)唤醒等待线程。

通常的file.f_ops.poll实现及相关结构体如下

1
2
3
4
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
struct file {  
const struct file_operations *f_op;
spinlock_t f_lock;
// 文件内部实现细节
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
struct list_head f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */
// 其他细节....
};

// 文件操作
struct file_operations {
// 文件提供给poll/select/epoll
// 获取文件当前状态, 以及就绪通知接口函数
unsigned int (*poll) (struct file *, struct poll_table_struct *);
// 其他方法read/write 等... ...
};

// 通常的file.f_ops.poll 方法的实现
unsigned int file_f_op_poll (struct file *filp, struct poll_table_struct *wait)
{
unsigned int mask = 0;
wait_queue_head_t * wait_queue;

//1. 根据事件掩码wait->key_和文件实现filep->private_data 取得事件掩码对应的一个或多个wait queue head
some_code();

// 2. 调用poll_wait 向获得的wait queue head 添加节点
poll_wait(filp, wait_queue, wait);

// 3. 取得当前就绪状态保存到mask
some_code();

return mask;
}

// select/poll/epoll 向文件注册就绪后回调节点的接口结构
typedef struct poll_table_struct {
// 向wait_queue_head 添加回调节点(wait_queue_t)的接口函数
poll_queue_proc _qproc;
// 关注的事件掩码, 文件的实现利用此掩码将等待队列传递给_qproc
unsigned long _key;
} poll_table;
typedef void (*poll_queue_proc)(struct file *, wait_queue_head_t *, struct poll_table_struct *);

// 通用的poll_wait 函数, 文件的f_ops->poll 通常会调用此函数
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)
{
if (p && p->_qproc && wait_address) {
// 调用_qproc 在wait_address 上添加节点和回调函数
// 调用 poll_table_struct 上的函数指针向wait_address添加节点, 并设置节点的func
// (如果是select或poll 则是 __pollwait, 如果是 epoll 则是 ep_ptable_queue_proc),
p->_qproc(filp, wait_address, p);
}
}

// wait_queue 头节点
typedef struct __wait_queue_head wait_queue_head_t;
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};

// wait_queue 节点
typedef struct __wait_queue wait_queue_t;
struct __wait_queue {
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);


// 当文件的状态发生改变时, 文件会调用此函数,此函数通过调用wait_queue_t.func通知poll的调用者
// 其中key是文件当前的事件掩码
void __wake_up(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;

spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, 0, key);
spin_unlock_irqrestore(&q->lock, flags);
}
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
// 遍历并调用func 唤醒, 通常func会唤醒调用poll的线程
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;

if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive) {
break;
}
}
}

poll 和 select

poll和select的实现基本上是一致的,只是传递参数有所不同,他们的基本流程如下:

  1. 复制用户数据到内核空间
  2. 估计超时时间
  3. 遍历每个文件并调用f_op->poll 取得文件当前就绪状态, 如果前面遍历的文件都没有就绪,向文件插入wait_queue节点
  4. 遍历完成后检查状态:
     a). 如果已经有就绪的文件转到5;
     b). 如果有信号产生,重启poll或select(转到 1或3);
     c). 否则挂起进程等待超时或唤醒,超时或被唤醒后再次遍历所有文件取得每个文件的就绪状态
    
  5. 将所有文件的就绪状态复制到用户空间
  6. 清理申请的资源

关键结构体

下面是poll/select共用的结构体及其相关功能:

poll_wqueues 是 select/poll 对poll_table接口的具体化实现,其中的table, inline_index和inline_entries都是为了管理内存。
poll_table_entry 与一个文件相关联,用于管理插入到文件的wait_queue节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// select/poll 对poll_table的具体化实现  
struct poll_wqueues {
poll_table pt;
struct poll_table_page *table; // 如果inline_entries 空间不足, 从poll_table_page 中分配
struct task_struct *polling_task; // 调用poll 或select 的进程
int triggered; // 已触发标记
int error;
int inline_index; // 下一个要分配的inline_entrie 索引
struct poll_table_entry inline_entries[N_INLINE_POLL_ENTRIES];//
};
// 帮助管理select/poll 申请的内存
struct poll_table_page {
struct poll_table_page * next; // 下一个 page
struct poll_table_entry * entry; // 指向第一个entries
struct poll_table_entry entries[0];
};
// 与一个正在poll /select 的文件相关联,
struct poll_table_entry {
struct file *filp; // 在poll/select中的文件
unsigned long key;
wait_queue_t wait; // 插入到wait_queue_head_t 的节点
wait_queue_head_t *wait_address; // 文件上的wait_queue_head_t 地址
};

公共函数

下面是poll/select公用的一些函数,这些函数实现了poll和select的核心功能。

poll_initwait 用于初始化poll_wqueues,

__pollwait 实现了向文件中添加回调节点的逻辑,

pollwake 当文件状态发生改变时,由文件调用,用来唤醒线程,

poll_get_entry,free_poll_entry,poll_freewait用来申请释放poll_table_entry 占用的内存,并负责释放文件上的wait_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
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
// poll_wqueues 的初始化:  
// 初始化 poll_wqueues , __pollwait会在文件就绪时被调用
void poll_initwait(struct poll_wqueues *pwq)
{
// 初始化poll_table, 相当于调用基类的构造函数
init_poll_funcptr(&pwq->pt, __pollwait);
/*
* static inline void init_poll_funcptr(poll_table *pt, poll_queue_proc qproc)
* {
* pt->_qproc = qproc;
* pt->_key = ~0UL;
* }
*/
pwq->polling_task = current;
pwq->triggered = 0;
pwq->error = 0;
pwq->table = NULL;
pwq->inline_index = 0;
}

// wait_queue设置函数
// poll/select 向文件wait_queue中添加节点的方法
static void __pollwait(struct file *filp, wait_queue_head_t *wait_address,
poll_table *p)
{
struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt);
struct poll_table_entry *entry = poll_get_entry(pwq);
if (!entry) {
return;
}
get_file(filp); //put_file() in free_poll_entry()
entry->filp = filp;
entry->wait_address = wait_address; // 等待队列头
entry->key = p->key;
// 设置回调为 pollwake
init_waitqueue_func_entry(&entry->wait, pollwake);
entry->wait.private = pwq;
// 添加到等待队列
add_wait_queue(wait_address, &entry->wait);
}

// 在等待队列(wait_queue_t)上回调函数(func)
// 文件就绪后被调用,唤醒调用进程,其中key是文件提供的当前状态掩码
static int pollwake(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
struct poll_table_entry *entry;
// 取得文件对应的poll_table_entry
entry = container_of(wait, struct poll_table_entry, wait);
// 过滤不关注的事件
if (key && !((unsigned long)key & entry->key)) {
return 0;
}
// 唤醒
return __pollwake(wait, mode, sync, key);
}
static int __pollwake(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
struct poll_wqueues *pwq = wait->private;
// 将调用进程 pwq->polling_task 关联到 dummy_wait
DECLARE_WAITQUEUE(dummy_wait, pwq->polling_task);
smp_wmb();
pwq->triggered = 1;// 标记为已触发
// 唤醒调用进程
return default_wake_function(&dummy_wait, mode, sync, key);
}

// 默认的唤醒函数,poll/select 设置的回调函数会调用此函数唤醒
// 直接唤醒等待队列上的线程,即将线程移到运行队列(rq)
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,
void *key)
{
// 这个函数比较复杂, 这里就不具体分析了
return try_to_wake_up(curr->private, mode, wake_flags);
}

poll,select对poll_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
41
42
43
44
45
46
47
48
49
50
51
52
53
// 分配或使用已先前申请的 poll_table_entry,  
static struct poll_table_entry *poll_get_entry(struct poll_wqueues *p) {
struct poll_table_page *table = p->table;

if (p->inline_index < N_INLINE_POLL_ENTRIES) {
return p->inline_entries + p->inline_index++;
}

if (!table || POLL_TABLE_FULL(table)) {
struct poll_table_page *new_table;
new_table = (struct poll_table_page *) __get_free_page(GFP_KERNEL);
if (!new_table) {
p->error = -ENOMEM;
return NULL;
}
new_table->entry = new_table->entries;
new_table->next = table;
p->table = new_table;
table = new_table;
}
return table->entry++;
}

// 清理poll_wqueues 占用的资源
void poll_freewait(struct poll_wqueues *pwq)
{
struct poll_table_page * p = pwq->table;
// 遍历所有已分配的inline poll_table_entry
int i;
for (i = 0; i < pwq->inline_index; i++) {
free_poll_entry(pwq->inline_entries + i);
}
// 遍历在poll_table_page上分配的inline poll_table_entry
// 并释放poll_table_page
while (p) {
struct poll_table_entry * entry;
struct poll_table_page *old;
entry = p->entry;
do {
entry--;
free_poll_entry(entry);
} while (entry > p->entries);
old = p;
p = p->next;
free_page((unsigned long) old);
}
}
static void free_poll_entry(struct poll_table_entry *entry)
{
// 从等待队列中删除, 释放文件引用计数
remove_wait_queue(entry->wait_address, &entry->wait);
fput(entry->filp);
}

poll/select核心结构关系

下图是 poll/select 实现公共部分的关系图,包含了与文件直接的关系,以及函数之间的依赖。

poll的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
// poll 使用的结构体  
struct pollfd {
int fd; // 描述符
short events; // 关注的事件掩码
short revents; // 返回的事件掩码
};
// long sys_poll(struct pollfd *ufds, unsigned int nfds, long timeout_msecs)
SYSCALL_DEFINE3(poll, struct pollfd __user *, ufds, unsigned int, nfds,
long, timeout_msecs)
{
struct timespec end_time, *to = NULL;
int ret;
if (timeout_msecs >= 0) {
to = &end_time;
// 将相对超时时间msec 转化为绝对时间
poll_select_set_timeout(to, timeout_msecs / MSEC_PER_SEC,
NSEC_PER_MSEC * (timeout_msecs % MSEC_PER_SEC));
}
// do sys poll
ret = do_sys_poll(ufds, nfds, to);
// do_sys_poll 被信号中断, 重新调用, 对使用者来说 poll 是不会被信号中断的.
if (ret == -EINTR) {
struct restart_block *restart_block;
restart_block = &current_thread_info()->restart_block;
restart_block->fn = do_restart_poll; // 设置重启的函数
restart_block->poll.ufds = ufds;
restart_block->poll.nfds = nfds;
if (timeout_msecs >= 0) {
restart_block->poll.tv_sec = end_time.tv_sec;
restart_block->poll.tv_nsec = end_time.tv_nsec;
restart_block->poll.has_timeout = 1;
} else {
restart_block->poll.has_timeout = 0;
}
// ERESTART_RESTARTBLOCK 不会返回给用户进程,
// 而是会被系统捕获, 然后调用 do_restart_poll,
ret = -ERESTART_RESTARTBLOCK;
}
return ret;
}
int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,
struct timespec *end_time)
{
struct poll_wqueues table;
int err = -EFAULT, fdcount, len, size;
/* 首先使用栈上的空间,节约内存,加速访问 */
long stack_pps[POLL_STACK_ALLOC/sizeof(long)];
struct poll_list *const head = (struct poll_list *)stack_pps;
struct poll_list *walk = head;
unsigned long todo = nfds;
if (nfds > rlimit(RLIMIT_NOFILE)) {
// 文件描述符数量超过当前进程限制
return -EINVAL;
}
// 复制用户空间数据到内核
len = min_t(unsigned int, nfds, N_STACK_PPS);
for (;;) {
walk->next = NULL;
walk->len = len;
if (!len) {
break;
}
// 复制到当前的 entries
if (copy_from_user(walk->entries, ufds + nfds-todo,
sizeof(struct pollfd) * walk->len)) {
goto out_fds;
}
todo -= walk->len;
if (!todo) {
break;
}
// 栈上空间不足,在堆上申请剩余部分
len = min(todo, POLLFD_PER_PAGE);
size = sizeof(struct poll_list) + sizeof(struct pollfd) * len;
walk = walk->next = kmalloc(size, GFP_KERNEL);
if (!walk) {
err = -ENOMEM;
goto out_fds;
}
}
// 初始化 poll_wqueues 结构, 设置函数指针_qproc 为__pollwait
poll_initwait(&table);
// poll
fdcount = do_poll(nfds, head, &table, end_time);
// 从文件wait queue 中移除对应的节点, 释放entry.
poll_freewait(&table);
// 复制结果到用户空间
for (walk = head; walk; walk = walk->next) {
struct pollfd *fds = walk->entries;
int j;
for (j = 0; j < len; j++, ufds++)
if (__put_user(fds[j].revents, &ufds->revents)) {
goto out_fds;
}
}
err = fdcount;
out_fds:
// 释放申请的内存
walk = head->next;
while (walk) {
struct poll_list *pos = walk;
walk = walk->next;
kfree(pos);
}
return err;
}
// 真正的处理函数
static int do_poll(unsigned int nfds, struct poll_list *list,
struct poll_wqueues *wait, struct timespec *end_time)
{
poll_table* pt = &wait->pt;
ktime_t expire, *to = NULL;
int timed_out = 0, count = 0;
unsigned long slack = 0;
if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
// 已经超时,直接遍历所有文件描述符, 然后返回
pt = NULL;
timed_out = 1;
}
if (end_time && !timed_out) {
// 估计进程等待时间,纳秒
slack = select_estimate_accuracy(end_time);
}
// 遍历文件,为每个文件的等待队列添加唤醒函数(pollwake)
for (;;) {
struct poll_list *walk;
for (walk = list; walk != NULL; walk = walk->next) {
struct pollfd * pfd, * pfd_end;
pfd = walk->entries;
pfd_end = pfd + walk->len;
for (; pfd != pfd_end; pfd++) {
// do_pollfd 会向文件对应的wait queue 中添加节点
// 和回调函数(如果 pt 不为空)
// 并检查当前文件状态并设置返回的掩码
if (do_pollfd(pfd, pt)) {
// 该文件已经准备好了.
// 不需要向后面文件的wait queue 中添加唤醒函数了.
count++;
pt = NULL;
}
}
}
// 下次循环的时候不需要向文件的wait queue 中添加节点,
// 因为前面的循环已经把该添加的都添加了
pt = NULL;

// 第一次遍历没有发现ready的文件
if (!count) {
count = wait->error;
// 有信号产生
if (signal_pending(current)) {
count = -EINTR;
}
}

// 有ready的文件或已经超时
if (count || timed_out) {
break;
}
// 转换为内核时间
if (end_time && !to) {
expire = timespec_to_ktime(*end_time);
to = &expire;
}
// 等待事件就绪, 如果有事件发生或超时,就再循
// 环一遍,取得事件状态掩码并计数,
// 注意此次循环中, 文件 wait queue 中的节点依然存在
if (!poll_schedule_timeout(wait, TASK_INTERRUPTIBLE, to, slack)) {
timed_out = 1;
}
}
return count;
}


static inline unsigned int do_pollfd(struct pollfd *pollfd, poll_table *pwait)
{
unsigned int mask;
int fd;
mask = 0;
fd = pollfd->fd;
if (fd >= 0) {
int fput_needed;
struct file * file;
// 取得fd对应的文件结构体
file = fget_light(fd, &fput_needed);
mask = POLLNVAL;
if (file != NULL) {
// 如果没有 f_op 或 f_op->poll 则认为文件始终处于就绪状态.
mask = DEFAULT_POLLMASK;
if (file->f_op && file->f_op->poll) {
if (pwait) {
// 设置关注的事件掩码
pwait->key = pollfd->events | POLLERR | POLLHUP;
}
// 注册回调函数,并返回当前就绪状态,就绪后会调用pollwake
mask = file->f_op->poll(file, pwait);
}
mask &= pollfd->events | POLLERR | POLLHUP; // 移除不需要的状态掩码
fput_light(file, fput_needed);// 释放文件
}
}
pollfd->revents = mask; // 更新事件状态
return mask;
}


static long do_restart_poll(struct restart_block *restart_block)
{
struct pollfd __user *ufds = restart_block->poll.ufds;
int nfds = restart_block->poll.nfds;
struct timespec *to = NULL, end_time;
int ret;
if (restart_block->poll.has_timeout) {
// 获取先前的超时时间
end_time.tv_sec = restart_block->poll.tv_sec;
end_time.tv_nsec = restart_block->poll.tv_nsec;
to = &end_time;
}
ret = do_sys_poll(ufds, nfds, to); // 重新调用 do_sys_poll
if (ret == -EINTR) {
// 又被信号中断了, 再次重启
restart_block->fn = do_restart_poll;
ret = -ERESTART_RESTARTBLOCK;
}
return ret;
}

select 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
typedef struct {  
unsigned long *in, *out, *ex;
unsigned long *res_in, *res_out, *res_ex;
} fd_set_bits;
// long sys_select(int n, fd_set *inp, fd_set *outp, fd_set *exp, struct timeval *tvp)
SYSCALL_DEFINE5(select, int, n, fd_set __user *, inp, fd_set __user *, outp,
fd_set __user *, exp, struct timeval __user *, tvp)
{
struct timespec end_time, *to = NULL;
struct timeval tv;
int ret;
if (tvp) {
if (copy_from_user(&tv, tvp, sizeof(tv))) {
return -EFAULT;
}
// 计算超时时间
to = &end_time;
if (poll_select_set_timeout(to,
tv.tv_sec + (tv.tv_usec / USEC_PER_SEC),
(tv.tv_usec % USEC_PER_SEC) * NSEC_PER_USEC)) {
return -EINVAL;
}
}
ret = core_sys_select(n, inp, outp, exp, to);
// 复制剩余时间到用户空间
ret = poll_select_copy_remaining(&end_time, tvp, 1, ret);
return ret;
}

int core_sys_select(int n, fd_set __user *inp, fd_set __user *outp,
fd_set __user *exp, struct timespec *end_time)
{
fd_set_bits fds;
void *bits;
int ret, max_fds;
unsigned int size;
struct fdtable *fdt;
//小对象使用栈上的空间,节约内存, 加快访问速度
long stack_fds[SELECT_STACK_ALLOC/sizeof(long)];

ret = -EINVAL;
if (n < 0) {
goto out_nofds;
}

rcu_read_lock();
// 取得进程对应的 fdtable
fdt = files_fdtable(current->files);
max_fds = fdt->max_fds;
rcu_read_unlock();
if (n > max_fds) {
n = max_fds;
}

size = FDS_BYTES(n);
bits = stack_fds;
if (size > sizeof(stack_fds) / 6) {
// 栈上的空间不够, 申请内存, 全部使用堆上的空间
ret = -ENOMEM;
bits = kmalloc(6 * size, GFP_KERNEL);
if (!bits) {
goto out_nofds;
}
}
fds.in = bits;
fds.out = bits + size;
fds.ex = bits + 2*size;
fds.res_in = bits + 3*size;
fds.res_out = bits + 4*size;
fds.res_ex = bits + 5*size;

// 复制用户空间到内核
if ((ret = get_fd_set(n, inp, fds.in)) ||
(ret = get_fd_set(n, outp, fds.out)) ||
(ret = get_fd_set(n, exp, fds.ex))) {
goto out;
}
// 初始化fd set
zero_fd_set(n, fds.res_in);
zero_fd_set(n, fds.res_out);
zero_fd_set(n, fds.res_ex);

ret = do_select(n, &fds, end_time);

if (ret < 0) {
goto out;
}
if (!ret) {
// 该返回值会被系统捕获, 并以同样的参数重新调用sys_select()
ret = -ERESTARTNOHAND;
if (signal_pending(current)) {
goto out;
}
ret = 0;
}

// 复制到用户空间
if (set_fd_set(n, inp, fds.res_in) ||
set_fd_set(n, outp, fds.res_out) ||
set_fd_set(n, exp, fds.res_ex)) {
ret = -EFAULT;
}

out:
if (bits != stack_fds) {
kfree(bits);
}
out_nofds:
return ret;
}

int do_select(int n, fd_set_bits *fds, struct timespec *end_time)
{
ktime_t expire, *to = NULL;
struct poll_wqueues table;
poll_table *wait;
int retval, i, timed_out = 0;
unsigned long slack = 0;

rcu_read_lock();
// 检查fds中fd的有效性, 并获取当前最大的fd
retval = max_select_fd(n, fds);
rcu_read_unlock();

if (retval < 0) {
return retval;
}
n = retval;

// 初始化 poll_wqueues 结构, 设置函数指针_qproc 为__pollwait
poll_initwait(&table);
wait = &table.pt;
if (end_time && !end_time->tv_sec && !end_time->tv_nsec) {
wait = NULL;
timed_out = 1;
}

if (end_time && !timed_out) {
// 估计需要等待的时间.
slack = select_estimate_accuracy(end_time);
}

retval = 0;
for (;;) {
unsigned long *rinp, *routp, *rexp, *inp, *outp, *exp;

inp = fds->in;
outp = fds->out;
exp = fds->ex;
rinp = fds->res_in;
routp = fds->res_out;
rexp = fds->res_ex;
// 遍历所有的描述符, i 文件描述符
for (i = 0; i < n; ++rinp, ++routp, ++rexp) {
unsigned long in, out, ex, all_bits, bit = 1, mask, j;
unsigned long res_in = 0, res_out = 0, res_ex = 0;
const struct file_operations *f_op = NULL;
struct file *file = NULL;
// 检查当前的 slot 中的描述符
in = *inp++;
out = *outp++;
ex = *exp++;
all_bits = in | out | ex;
if (all_bits == 0) { // 没有需要监听的描述符, 下一个slot
i += __NFDBITS;
continue;
}

for (j = 0; j < __NFDBITS; ++j, ++i, bit <<= 1) {
int fput_needed;
if (i >= n) {
break;
}
// 不需要监听描述符 i
if (!(bit & all_bits)) {
continue;
}
// 取得文件结构
file = fget_light(i, &fput_needed);
if (file) {
f_op = file->f_op;
// 没有 f_op 的话就认为一直处于就绪状态
mask = DEFAULT_POLLMASK;
if (f_op && f_op->poll) {
// 设置等待事件的掩码
wait_key_set(wait, in, out, bit);
/*
static inline void wait_key_set(poll_table *wait, unsigned long in,
unsigned long out, unsigned long bit)
{
wait->_key = POLLEX_SET;// (POLLPRI)
if (in & bit)
wait->_key |= POLLIN_SET;//(POLLRDNORM | POLLRDBAND | POLLIN | POLLHUP | POLLERR)
if (out & bit)
wait->_key |= POLLOUT_SET;//POLLOUT_SET (POLLWRBAND | POLLWRNORM | POLLOUT | POLLERR)
}
*/
// 获取当前的就绪状态, 并添加到文件的对应等待队列中
mask = (*f_op->poll)(file, wait);
// 和poll完全一样
}
fput_light(file, fput_needed);
// 释放文件
// 检查文件 i 是否已有事件就绪,
if ((mask & POLLIN_SET) && (in & bit)) {
res_in |= bit;
retval++;
// 如果已有就绪事件就不再向其他文件的
// 等待队列中添加回调函数
wait = NULL;
}
if ((mask & POLLOUT_SET) && (out & bit)) {
res_out |= bit;
retval++;
wait = NULL;
}
if ((mask & POLLEX_SET) && (ex & bit)) {
res_ex |= bit;
retval++;
wait = NULL;
}
}
}
if (res_in) {
*rinp = res_in;
}
if (res_out) {
*routp = res_out;
}
if (res_ex) {
*rexp = res_ex;
}
cond_resched();
}
wait = NULL; // 该添加回调函数的都已经添加了
if (retval || timed_out || signal_pending(current)) {
break; // 信号发生,监听事件就绪或超时
}
if (table.error) {
retval = table.error; // 产生错误了
break;
}
// 转换到内核时间
if (end_time && !to) {
expire = timespec_to_ktime(*end_time);
to = &expire;
}
// 等待直到超时, 或由回调函数唤醒, 超时后会再次遍历文件描述符
if (!poll_schedule_timeout(&table, TASK_INTERRUPTIBLE,
to, slack)) {
timed_out = 1;
}
}

poll_freewait(&table);

return retval;
}

epoll实现

epoll 的实现比poll/select 复杂一些,这是因为:

  1. epoll_wait, epoll_ctl 的调用完全独立开来,内核需要锁机制对这些操作进行保护,并且需要持久的维护添加到epoll的文件
  2. epoll本身也是文件,也可以被poll/select/epoll监视,这可能导致epoll之间循环唤醒的问题
  3. 单个文件的状态改变可能唤醒过多监听在其上的epoll,产生唤醒风暴

epoll各个功能的实现要非常小心面对这些问题,使得复杂度大大增加。

epoll的核心数据结构

1
2
3
4
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
// epoll的核心实现对应于一个epoll描述符  
struct eventpoll {
spinlock_t lock;
struct mutex mtx;
wait_queue_head_t wq; // sys_epoll_wait() 等待在这里
// f_op->poll() 使用的, 被其他事件通知机制利用的wait_address
wait_queue_head_t poll_wait;
/* 已就绪的需要检查的epitem 列表 */
struct list_head rdllist;
/* 保存所有加入到当前epoll的文件对应的epitem*/
struct rb_root rbr;
// 当正在向用户空间复制数据时, 产生的可用文件
struct epitem *ovflist;
/* The user that created the eventpoll descriptor */
struct user_struct *user;
struct file *file;
/*优化循环检查,避免循环检查中重复的遍历 */
int visited;
struct list_head visited_list_link;
}

// 对应于一个加入到epoll的文件
struct epitem {
// 挂载到eventpoll 的红黑树节点
struct rb_node rbn;
// 挂载到eventpoll.rdllist 的节点
struct list_head rdllink;
// 连接到ovflist 的指针
struct epitem *next;
/* 文件描述符信息fd + file, 红黑树的key */
struct epoll_filefd ffd;
/* Number of active wait queue attached to poll operations */
int nwait;
// 当前文件的等待队列(eppoll_entry)列表
// 同一个文件上可能会监视多种事件,
// 这些事件可能属于不同的wait_queue中
// (取决于对应文件类型的实现),
// 所以需要使用链表
struct list_head pwqlist;
// 当前epitem 的所有者
struct eventpoll *ep;
/* List header used to link this item to the &quot;struct file&quot; items list */
struct list_head fllink;
/* epoll_ctl 传入的用户数据 */
struct epoll_event event;
};

struct epoll_filefd {
struct file *file;
int fd;
};

// 与一个文件上的一个wait_queue_head 相关联,因为同一文件可能有多个等待的事件,这些事件可能使用不同的等待队列
struct eppoll_entry {
// List struct epitem.pwqlist
struct list_head llink;
// 所有者
struct epitem *base;
// 添加到wait_queue 中的节点
wait_queue_t wait;
// 文件wait_queue 头
wait_queue_head_t *whead;
};

// 用户使用的epoll_event
struct epoll_event {
__u32 events;
__u64 data;
} EPOLL_PACKED;

文件系统初始化和epoll_create

1
2
3
4
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
// epoll 文件系统的相关实现  
// epoll 文件系统初始化, 在系统启动时会调用

static int __init eventpoll_init(void)
{
struct sysinfo si;

si_meminfo(&si);
// 限制可添加到epoll的最多的描述符数量

max_user_watches = (((si.totalram - si.totalhigh) / 25) << PAGE_SHIFT) /
EP_ITEM_COST;
BUG_ON(max_user_watches < 0);

// 初始化递归检查队列
ep_nested_calls_init(&poll_loop_ncalls);
ep_nested_calls_init(&poll_safewake_ncalls);
ep_nested_calls_init(&poll_readywalk_ncalls);
// epoll 使用的slab分配器分别用来分配epitem和eppoll_entry
epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct epitem),
0, SLAB_HWCACHE_ALIGN | SLAB_PANIC, NULL);
pwq_cache = kmem_cache_create("eventpoll_pwq",
sizeof(struct eppoll_entry), 0, SLAB_PANIC, NULL);

return 0;
}


SYSCALL_DEFINE1(epoll_create, int, size)
{
if (size <= 0) {
return -EINVAL;
}

return sys_epoll_create1(0);
}

SYSCALL_DEFINE1(epoll_create1, int, flags)
{
int error, fd;
struct eventpoll *ep = NULL;
struct file *file;

/* Check the EPOLL_* constant for consistency. */
BUILD_BUG_ON(EPOLL_CLOEXEC != O_CLOEXEC);

if (flags & ~EPOLL_CLOEXEC) {
return -EINVAL;
}
/*
* Create the internal data structure ("struct eventpoll").
*/
error = ep_alloc(&ep);
if (error < 0) {
return error;
}
/*
* Creates all the items needed to setup an eventpoll file. That is,
* a file structure and a free file descriptor.
*/
fd = get_unused_fd_flags(O_RDWR | (flags & O_CLOEXEC));
if (fd < 0) {
error = fd;
goto out_free_ep;
}
// 设置epfd的相关操作,由于epoll也是文件也提供了poll操作
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
O_RDWR | (flags & O_CLOEXEC));
if (IS_ERR(file)) {
error = PTR_ERR(file);
goto out_free_fd;
}
fd_install(fd, file);
ep->file = file;
return fd;

out_free_fd:
put_unused_fd(fd);
out_free_ep:
ep_free(ep);
return error;
}

epoll中的递归死循环和深度检查

递归深度检测

epoll本身也是文件,也可以被poll/select/epoll监视,如果epoll之间互相监视就有可能导致死循环。epoll的实现中,所有可能产生递归调用的函数都由函函数ep_call_nested进行包裹,递归调用过程中出现死循环或递归过深就会打破死循环和递归调用直接返回。该函数的实现依赖于一个外部的全局链表nested_call_node(不同的函数调用使用不同的节点),每次调用可能发生递归的函数(nproc)就向链表中添加一个包含当前函数调用上下文ctx(进程,CPU,或epoll文件)和处理的对象标识cookie的节点,通过检测是否有相同的节点就可以知道是否发生了死循环,检查链表中同一上下文包含的节点个数就可以知道递归的深度。以下就是这一过程的源码。

1
2
3
4
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
struct nested_call_node {  
struct list_head llink;
void *cookie; // 函数运行标识, 任务标志
void *ctx; // 运行环境标识
};
struct nested_calls {
struct list_head tasks_call_list;
spinlock_t lock;
};

// 全局的不同调用使用的链表
// 死循环检查和唤醒风暴检查链表
static nested_call_node poll_loop_ncalls;
// 唤醒时使用的检查链表
static nested_call_node poll_safewake_ncalls;
// 扫描readylist 时使用的链表
static nested_call_node poll_readywalk_ncalls;


// 限制epoll 中直接或间接递归调用的深度并防止死循环
// ctx: 任务运行上下文(进程, CPU 等)
// cookie: 每个任务的标识
// priv: 任务运行需要的私有数据
// 如果用面向对象语言实现应该就会是一个wapper类
static int ep_call_nested(struct nested_calls *ncalls, int max_nests,
int (*nproc)(void *, void *, int), void *priv,
void *cookie, void *ctx)
{
int error, call_nests = 0;
unsigned long flags;
struct list_head *lsthead = &ncalls->tasks_call_list;
struct nested_call_node *tncur;
struct nested_call_node tnode;
spin_lock_irqsave(&ncalls->lock, flags);
// 检查原有的嵌套调用链表ncalls, 查看是否有深度超过限制的情况
list_for_each_entry(tncur, lsthead, llink) {
// 同一上下文中(ctx)有相同的任务(cookie)说明产生了死循环
// 同一上下文的递归深度call_nests 超过限制
if (tncur->ctx == ctx &&
(tncur->cookie == cookie || ++call_nests > max_nests)) {
error = -1;
}
goto out_unlock;
}
/* 将当前的任务请求添加到调用列表*/
tnode.ctx = ctx;
tnode.cookie = cookie;
list_add(&tnode.llink, lsthead);
spin_unlock_irqrestore(&ncalls->lock, flags);
/* nproc 可能会导致递归调用(直接或间接)ep_call_nested
* 如果发生递归调用, 那么在此函数返回之前,
* ncalls 又会被加入额外的节点,
* 这样通过前面的检测就可以知道递归调用的深度
*/
error = (*nproc)(priv, cookie, call_nests);
/* 从链表中删除当前任务*/
spin_lock_irqsave(&ncalls->lock, flags);
list_del(&tnode.llink);
out_unlock:
spin_unlock_irqrestore(&ncalls->lock, flags);
return error;
}

循环检测(ep_loop_check)

循环检查(ep_loop_check),该函数递归调用ep_loop_check_proc利用ep_call_nested来实现epoll之间相互监视的死循环。因为ep_call_nested中已经对死循环和过深的递归做了检查,实际的ep_loop_check_proc的实现只是递归调用自己。其中的visited_list和visited标记完全是为了优化处理速度,如果没有visited_list和visited标记函数也是能够工作的。该函数中得上下文就是当前的进程,cookie就是正在遍历的epoll结构。

1
2
3
4
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
static LIST_HEAD(visited_list);  
// 检查 file (epoll)和ep 之间是否有循环
static int ep_loop_check(struct eventpoll *ep, struct file *file)
{
int ret;
struct eventpoll *ep_cur, *ep_next;

ret = ep_call_nested(&poll_loop_ncalls, EP_MAX_NESTS,
ep_loop_check_proc, file, ep, current);
/* 清除链表和标志 */
list_for_each_entry_safe(ep_cur, ep_next, &visited_list,
visited_list_link) {
ep_cur->visited = 0;
list_del(&ep_cur->visited_list_link);
}
return ret;
}

static int ep_loop_check_proc(void *priv, void *cookie, int call_nests)
{
int error = 0;
struct file *file = priv;
struct eventpoll *ep = file->private_data;
struct eventpoll *ep_tovisit;
struct rb_node *rbp;
struct epitem *epi;

mutex_lock_nested(&ep->mtx, call_nests + 1);
// 标记当前为已遍历
ep->visited = 1;
list_add(&ep->visited_list_link, &visited_list);
// 遍历所有ep 监视的文件
for (rbp = rb_first(&ep->rbr); rbp; rbp = rb_next(rbp)) {
epi = rb_entry(rbp, struct epitem, rbn);
if (unlikely(is_file_epoll(epi->ffd.file))) {
ep_tovisit = epi->ffd.file->private_data;
// 跳过先前已遍历的, 避免循环检查
if (ep_tovisit->visited) {
continue;
}
// 所有ep监视的未遍历的epoll
error = ep_call_nested(&poll_loop_ncalls, EP_MAX_NESTS,
ep_loop_check_proc, epi->ffd.file,
ep_tovisit, current);
if (error != 0) {
break;
}
} else {
// 文件不在tfile_check_list 中, 添加
// 最外层的epoll 需要检查子epoll监视的文件
if (list_empty(&epi->ffd.file->f_tfile_llink))
list_add(&epi->ffd.file->f_tfile_llink,
&tfile_check_list);
}
}
mutex_unlock(&ep->mtx);

return error;
}

唤醒风暴检测

当文件状态发生改变时,会唤醒监听在其上的epoll文件,而这个epoll文件还可能唤醒其他的epoll文件,这种连续的唤醒就形成了一个唤醒路径,所有的唤醒路径就形成了一个有向图。如果文件对应的epoll唤醒有向图的节点过多,那么文件状态的改变就会唤醒所有的这些epoll(可能会唤醒很多进程,这样的开销是很大的),而实际上一个文件经过少数epoll处理以后就可能从就绪转到未就绪,剩余的epoll虽然认为文件已就绪而实际上经过某些处理后已不可用。epoll的实现中考虑到了此问题,在每次添加新文件到epoll中时,就会首先检查是否会出现这样的唤醒风暴。

该函数的实现逻辑是这样的,递归调用reverse_path_check_proc遍历监听在当前文件上的epoll文件,在reverse_pach_check_proc中统计并检查不同路径深度上epoll的个数,从而避免产生唤醒风暴。

1
2
3
4
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
#define PATH_ARR_SIZE 5  
// 在EPOLL_CTL_ADD 时, 检查是否有可能产生唤醒风暴
// epoll 允许的单个文件的唤醒深度小于5, 例如
// 一个文件最多允许唤醒1000个深度为1的epoll描述符,
//允许所有被单个文件直接唤醒的epoll描述符再次唤醒的epoll描述符总数是500
//

// 深度限制
static const int path_limits[PATH_ARR_SIZE] = { 1000, 500, 100, 50, 10 };
// 计算出来的深度
static int path_count[PATH_ARR_SIZE];

static int path_count_inc(int nests)
{
/* Allow an arbitrary number of depth 1 paths */
if (nests == 0) {
return 0;
}

if (++path_count[nests] > path_limits[nests]) {
return -1;
}
return 0;
}

static void path_count_init(void)
{
int i;

for (i = 0; i < PATH_ARR_SIZE; i++) {
path_count[i] = 0;
}
}

// 唤醒风暴检查函数
static int reverse_path_check(void)
{
int error = 0;
struct file *current_file;

/* let's call this for all tfiles */
// 遍历全局tfile_check_list 中的文件, 第一级
list_for_each_entry(current_file, &tfile_check_list, f_tfile_llink) {
// 初始化
path_count_init();
// 限制递归的深度, 并检查每个深度上唤醒的epoll 数量
error = ep_call_nested(&poll_loop_ncalls, EP_MAX_NESTS,
reverse_path_check_proc, current_file,
current_file, current);
if (error) {
break;
}
}
return error;
}
static int reverse_path_check_proc(void *priv, void *cookie, int call_nests)
{
int error = 0;
struct file *file = priv;
struct file *child_file;
struct epitem *epi;

list_for_each_entry(epi, &file->f_ep_links, fllink) {
// 遍历监视file 的epoll
child_file = epi->ep->file;
if (is_file_epoll(child_file)) {
if (list_empty(&child_file->f_ep_links)) {
// 没有其他的epoll监视当前的这个epoll,
// 已经是叶子了
if (path_count_inc(call_nests)) {
error = -1;
break;
}
} else {
// 遍历监视这个epoll 文件的epoll,
// 递归调用
error = ep_call_nested(&poll_loop_ncalls,
EP_MAX_NESTS,
reverse_path_check_proc,
child_file, child_file,
current);
}
if (error != 0) {
break;
}
} else {
// 不是epoll , 不可能吧?
printk(KERN_ERR "reverse_path_check_proc: "
"file is not an ep!\n");
}
}
return error;
}

epoll 的唤醒过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void ep_poll_safewake(wait_queue_head_t *wq)  
{
int this_cpu = get_cpu();

ep_call_nested(&poll_safewake_ncalls, EP_MAX_NESTS,
ep_poll_wakeup_proc, NULL, wq, (void *) (long) this_cpu);

put_cpu();
}

static int ep_poll_wakeup_proc(void *priv, void *cookie, int call_nests)
{
ep_wake_up_nested((wait_queue_head_t *) cookie, POLLIN,
1 + call_nests);
return 0;
}

static inline void ep_wake_up_nested(wait_queue_head_t *wqueue,
unsigned long events, int subclass)
{
// 这回唤醒所有正在等待此epfd 的select/epoll/poll 等
// 如果唤醒的是epoll 就可能唤醒其他的epoll, 产生连锁反应
// 这个很可能在中断上下文中被调用
wake_up_poll(wqueue, events);
}

epoll_ctl

1
2
3
4
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
// long epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  

SYSCALL_DEFINE4(epoll_ctl, int, epfd, int, op, int, fd,
struct epoll_event __user *, event)
{
int error;
int did_lock_epmutex = 0;
struct file *file, *tfile;
struct eventpoll *ep;
struct epitem *epi;
struct epoll_event epds;

error = -EFAULT;
if (ep_op_has_event(op) &&
// 复制用户空间数据到内核
copy_from_user(&epds, event, sizeof(struct epoll_event))) {
goto error_return;
}

// 取得 epfd 对应的文件
error = -EBADF;
file = fget(epfd);
if (!file) {
goto error_return;
}

// 取得目标文件
tfile = fget(fd);
if (!tfile) {
goto error_fput;
}

// 目标文件必须提供 poll 操作
error = -EPERM;
if (!tfile->f_op || !tfile->f_op->poll) {
goto error_tgt_fput;
}

// 添加自身或epfd 不是epoll 句柄
error = -EINVAL;
if (file == tfile || !is_file_epoll(file)) {
goto error_tgt_fput;
}

// 取得内部结构eventpoll
ep = file->private_data;

// EPOLL_CTL_MOD 不需要加全局锁 epmutex
if (op == EPOLL_CTL_ADD || op == EPOLL_CTL_DEL) {
mutex_lock(&epmutex);
did_lock_epmutex = 1;
}
if (op == EPOLL_CTL_ADD) {
if (is_file_epoll(tfile)) {
error = -ELOOP;
// 目标文件也是epoll 检测是否有循环包含的问题
if (ep_loop_check(ep, tfile) != 0) {
goto error_tgt_fput;
}
} else
{
// 将目标文件添加到 epoll 全局的tfile_check_list 中
list_add(&tfile->f_tfile_llink, &tfile_check_list);
}
}

mutex_lock_nested(&ep->mtx, 0);

// 以tfile 和fd 为key 在rbtree 中查找文件对应的epitem
epi = ep_find(ep, tfile, fd);

error = -EINVAL;
switch (op) {
case EPOLL_CTL_ADD:
if (!epi) {
// 没找到, 添加额外添加ERR HUP 事件
epds.events |= POLLERR | POLLHUP;
error = ep_insert(ep, &epds, tfile, fd);
} else {
error = -EEXIST;
}
// 清空文件检查列表
clear_tfile_check_list();
break;
case EPOLL_CTL_DEL:
if (epi) {
error = ep_remove(ep, epi);
} else {
error = -ENOENT;
}
break;
case EPOLL_CTL_MOD:
if (epi) {
epds.events |= POLLERR | POLLHUP;
error = ep_modify(ep, epi, &epds);
} else {
error = -ENOENT;
}
break;
}
mutex_unlock(&ep->mtx);

error_tgt_fput:
if (did_lock_epmutex) {
mutex_unlock(&epmutex);
}

fput(tfile);
error_fput:
fput(file);
error_return:

return error;
}

EPOLL_CTL_ADD 实现

1
2
3
4
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
// EPOLL_CTL_ADD  
static int ep_insert(struct eventpoll *ep, struct epoll_event *event,
struct file *tfile, int fd)
{
int error, revents, pwake = 0;
unsigned long flags;
long user_watches;
struct epitem *epi;
struct ep_pqueue epq;
/*
struct ep_pqueue {
poll_table pt;
struct epitem *epi;
};
*/

// 增加监视文件数
user_watches = atomic_long_read(&ep->user->epoll_watches);
if (unlikely(user_watches >= max_user_watches)) {
return -ENOSPC;
}

// 分配初始化 epi
if (!(epi = kmem_cache_alloc(epi_cache, GFP_KERNEL))) {
return -ENOMEM;
}

INIT_LIST_HEAD(&epi->rdllink);
INIT_LIST_HEAD(&epi->fllink);
INIT_LIST_HEAD(&epi->pwqlist);
epi->ep = ep;
// 初始化红黑树中的key
ep_set_ffd(&epi->ffd, tfile, fd);
// 直接复制用户结构
epi->event = *event;
epi->nwait = 0;
epi->next = EP_UNACTIVE_PTR;

// 初始化临时的 epq
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
// 设置事件掩码
epq.pt._key = event->events;
// 内部会调用ep_ptable_queue_proc, 在文件对应的wait queue head 上
// 注册回调函数, 并返回当前文件的状态
revents = tfile->f_op->poll(tfile, &epq.pt);

// 检查错误
error = -ENOMEM;
if (epi->nwait < 0) { // f_op->poll 过程出错
goto error_unregister;
}
// 添加当前的epitem 到文件的f_ep_links 链表
spin_lock(&tfile->f_lock);
list_add_tail(&epi->fllink, &tfile->f_ep_links);
spin_unlock(&tfile->f_lock);

// 插入epi 到rbtree
ep_rbtree_insert(ep, epi);

/* now check if we've created too many backpaths */
error = -EINVAL;
if (reverse_path_check()) {
goto error_remove_epi;
}

spin_lock_irqsave(&ep->lock, flags);

/* 文件已经就绪插入到就绪链表rdllist */
if ((revents & event->events) && !ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);


if (waitqueue_active(&ep->wq))
// 通知sys_epoll_wait , 调用回调函数唤醒sys_epoll_wait 进程
{
wake_up_locked(&ep->wq);
}
// 先不通知调用eventpoll_poll 的进程
if (waitqueue_active(&ep->poll_wait)) {
pwake++;
}
}

spin_unlock_irqrestore(&ep->lock, flags);

atomic_long_inc(&ep->user->epoll_watches);

if (pwake)
// 安全通知调用eventpoll_poll 的进程
{
ep_poll_safewake(&ep->poll_wait);
}

return 0;

error_remove_epi:
spin_lock(&tfile->f_lock);
// 删除文件上的 epi
if (ep_is_linked(&epi->fllink)) {
list_del_init(&epi->fllink);
}
spin_unlock(&tfile->f_lock);

// 从红黑树中删除
rb_erase(&epi->rbn, &ep->rbr);

error_unregister:
// 从文件的wait_queue 中删除, 释放epitem 关联的所有eppoll_entry
ep_unregister_pollwait(ep, epi);

/*
* We need to do this because an event could have been arrived on some
* allocated wait queue. Note that we don't care about the ep->ovflist
* list, since that is used/cleaned only inside a section bound by "mtx".
* And ep_insert() is called with "mtx" held.
*/
// TODO:
spin_lock_irqsave(&ep->lock, flags);
if (ep_is_linked(&epi->rdllink)) {
list_del_init(&epi->rdllink);
}
spin_unlock_irqrestore(&ep->lock, flags);

// 释放epi
kmem_cache_free(epi_cache, epi);

return error;
}

EPOLL_CTL_DEL

EPOLL_CTL_DEL 的实现调用的是 ep_remove 函数,函数只是清除ADD时, 添加的各种结构,EPOLL_CTL_MOD 的实现调用的是ep_modify,在ep_modify中用新的事件掩码调用f_ops->poll,检测事件是否已可用,如果可用就直接唤醒epoll,这两个的实现与EPOLL_CTL_ADD 类似,代码上比较清晰,这里就不具体分析了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static int ep_remove(struct eventpoll *ep, struct epitem *epi)  
{
unsigned long flags;
struct file *file = epi->ffd.file;

/*
* Removes poll wait queue hooks. We _have_ to do this without holding
* the "ep->lock" otherwise a deadlock might occur. This because of the
* sequence of the lock acquisition. Here we do "ep->lock" then the wait
* queue head lock when unregistering the wait queue. The wakeup callback
* will run by holding the wait queue head lock and will call our callback
* that will try to get "ep->lock".
*/
ep_unregister_pollwait(ep, epi);

/* Remove the current item from the list of epoll hooks */
spin_lock(&file->f_lock);
if (ep_is_linked(&epi->fllink))
list_del_init(&epi->fllink);
spin_unlock(&file->f_lock);

rb_erase(&epi->rbn, &ep->rbr);

spin_lock_irqsave(&ep->lock, flags);
if (ep_is_linked(&epi->rdllink))
list_del_init(&epi->rdllink);
spin_unlock_irqrestore(&ep->lock, flags);

/* At this point it is safe to free the eventpoll item */
kmem_cache_free(epi_cache, epi);

atomic_long_dec(&ep->user->epoll_watches);

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
/* 
* Modify the interest event mask by dropping an event if the new mask
* has a match in the current file status. Must be called with "mtx" held.
*/
static int ep_modify(struct eventpoll *ep, struct epitem *epi, struct epoll_event *event)
{
int pwake = 0;
unsigned int revents;
poll_table pt;

init_poll_funcptr(&pt, NULL);

/*
* Set the new event interest mask before calling f_op->poll();
* otherwise we might miss an event that happens between the
* f_op->poll() call and the new event set registering.
*/
epi->event.events = event->events;
pt._key = event->events;
epi->event.data = event->data; /* protected by mtx */

/*
* Get current event bits. We can safely use the file* here because
* its usage count has been increased by the caller of this function.
*/
revents = epi->ffd.file->f_op->poll(epi->ffd.file, &pt);

/*
* If the item is "hot" and it is not registered inside the ready
* list, push it inside.
*/
if (revents & event->events) {
spin_lock_irq(&ep->lock);
if (!ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);

/* Notify waiting tasks that events are available */
if (waitqueue_active(&ep->wq))
wake_up_locked(&ep->wq);
if (waitqueue_active(&ep->poll_wait))
pwake++;
}
spin_unlock_irq(&ep->lock);
}

/* We have to call this outside the lock */
if (pwake)
ep_poll_safewake(&ep->poll_wait);

return 0;
}

epoll_wait

1
2
3
4
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
/* 
epoll_wait实现
*/

SYSCALL_DEFINE4(epoll_wait, int, epfd, struct epoll_event __user *, events,
int, maxevents, int, timeout)
{
int error;
struct file *file;
struct eventpoll *ep;

// 检查输入数据有效性
if (maxevents <= 0 || maxevents > EP_MAX_EVENTS) {
return -EINVAL;
}

if (!access_ok(VERIFY_WRITE, events, maxevents * sizeof(struct epoll_event))) {
error = -EFAULT;
goto error_return;
}

/* Get the "struct file *" for the eventpoll file */
error = -EBADF;
file = fget(epfd);
if (!file) {
goto error_return;
}

error = -EINVAL;
if (!is_file_epoll(file)) {
goto error_fput;
}
// 取得ep 结构
ep = file->private_data;

// 等待事件
error = ep_poll(ep, events, maxevents, timeout);

error_fput:
fput(file);
error_return:

return error;
}

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, long timeout)
{
int res = 0, eavail, timed_out = 0;
unsigned long flags;
long slack = 0;
wait_queue_t wait;
ktime_t expires, *to = NULL;

if (timeout > 0) {
// 转换为内核时间
struct timespec end_time = ep_set_mstimeout(timeout);

slack = select_estimate_accuracy(&end_time);
to = &expires;
*to = timespec_to_ktime(end_time);
} else if (timeout == 0) {
// 已经超时直接检查readylist
timed_out = 1;
spin_lock_irqsave(&ep->lock, flags);
goto check_events;
}

fetch_events:
spin_lock_irqsave(&ep->lock, flags);

// 没有可用的事件,ready list 和ovflist 都为空
if (!ep_events_available(ep)) {

// 添加当前进程的唤醒函数
init_waitqueue_entry(&wait, current);
__add_wait_queue_exclusive(&ep->wq, &wait);

for (;;) {
/*
* We don't want to sleep if the ep_poll_callback() sends us
* a wakeup in between. That's why we set the task state
* to TASK_INTERRUPTIBLE before doing the checks.
*/
set_current_state(TASK_INTERRUPTIBLE);
if (ep_events_available(ep) || timed_out) {
break;
}
if (signal_pending(current)) {
res = -EINTR;
break;
}

spin_unlock_irqrestore(&ep->lock, flags);
// 挂起当前进程,等待唤醒或超时
if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS)) {
timed_out = 1;
}

spin_lock_irqsave(&ep->lock, flags);
}

__remove_wait_queue(&ep->wq, &wait);

set_current_state(TASK_RUNNING);
}
check_events:
// 再次检查是否有可用事件
eavail = ep_events_available(ep);

spin_unlock_irqrestore(&ep->lock, flags);

/*
* Try to transfer events to user space. In case we get 0 events and
* there's still timeout left over, we go trying again in search of
* more luck.
*/
if (!res && eavail
&& !(res = ep_send_events(ep, events, maxevents)) // 复制事件到用户空间
&& !timed_out) // 复制事件失败并且没有超时,重新等待。
{
goto fetch_events;
}

return res;
}


static inline int ep_events_available(struct eventpoll *ep)
{
return !list_empty(&ep->rdllist) || ep->ovflist != EP_UNACTIVE_PTR;
}

struct ep_send_events_data {
int maxevents;
struct epoll_event __user *events;
};

static int ep_send_events(struct eventpoll *ep,
struct epoll_event __user *events, int maxevents)
{
struct ep_send_events_data esed;

esed.maxevents = maxevents;
esed.events = events;

return ep_scan_ready_list(ep, ep_send_events_proc, &esed, 0);
}

static int ep_send_events_proc(struct eventpoll *ep, struct list_head *head,
void *priv)
{
struct ep_send_events_data *esed = priv;
int eventcnt;
unsigned int revents;
struct epitem *epi;
struct epoll_event __user *uevent;

// 遍历已就绪链表
for (eventcnt = 0, uevent = esed->events;
!list_empty(head) && eventcnt < esed->maxevents;) {
epi = list_first_entry(head, struct epitem, rdllink);

list_del_init(&epi->rdllink);
// 获取ready 事件掩码
revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL) &
epi->event.events;

/*
* If the event mask intersect the caller-requested one,
* deliver the event to userspace. Again, ep_scan_ready_list()
* is holding "mtx", so no operations coming from userspace
* can change the item.
*/
if (revents) {
// 事件就绪, 复制到用户空间
if (__put_user(revents, &uevent->events) ||
__put_user(epi->event.data, &uevent->data)) {
list_add(&epi->rdllink, head);
return eventcnt ? eventcnt : -EFAULT;
}
eventcnt++;
uevent++;
if (epi->event.events & EPOLLONESHOT) {
epi->event.events &= EP_PRIVATE_BITS;
} else if (!(epi->event.events & EPOLLET)) {
// 不是边缘模式, 再次添加到ready list,
// 下次epoll_wait 时直接进入此函数检查ready list是否仍然继续
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// 如果是边缘模式, 只有当文件状态发生改变时,
// 才文件会再次触发wait_address 上wait_queue的回调函数,
}
}

return eventcnt;
}

eventpoll_poll

由于epoll自身也是文件系统,其描述符也可以被poll/select/epoll监视,因此需要实现poll方法。

1
2
3
4
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
static const struct file_operations eventpoll_fops = {  
.release = ep_eventpoll_release,
.poll = ep_eventpoll_poll,
.llseek = noop_llseek,
};

static unsigned int ep_eventpoll_poll(struct file *file, poll_table *wait)
{
int pollflags;
struct eventpoll *ep = file->private_data;
// 插入到wait_queue
poll_wait(file, &ep->poll_wait, wait);
// 扫描就绪的文件列表, 调用每个文件上的poll 检测是否真的就绪,
// 然后复制到用户空间
// 文件列表中有可能有epoll文件, 调用poll的时候有可能会产生递归,
// 调用所以用ep_call_nested 包装一下, 防止死循环和过深的调用
pollflags = ep_call_nested(&poll_readywalk_ncalls, EP_MAX_NESTS,
ep_poll_readyevents_proc, ep, ep, current);
// static struct nested_calls poll_readywalk_ncalls;
return pollflags != -1 ? pollflags : 0;
}

static int ep_poll_readyevents_proc(void *priv, void *cookie, int call_nests)
{
return ep_scan_ready_list(priv, ep_read_events_proc, NULL, call_nests + 1);
}

static int ep_scan_ready_list(struct eventpoll *ep,
int (*sproc)(struct eventpoll *,
struct list_head *, void *),
void *priv,
int depth)
{
int error, pwake = 0;
unsigned long flags;
struct epitem *epi, *nepi;
LIST_HEAD(txlist);

/*
* We need to lock this because we could be hit by
* eventpoll_release_file() and epoll_ctl().
*/
mutex_lock_nested(&ep->mtx, depth);

spin_lock_irqsave(&ep->lock, flags);
// 移动rdllist 到新的链表txlist
list_splice_init(&ep->rdllist, &txlist);
// 改变ovflist 的状态, 如果ep->ovflist != EP_UNACTIVE_PTR,
// 当文件激活wait_queue时,就会将对应的epitem加入到ep->ovflist
// 否则将文件直接加入到ep->rdllist,
// 这样做的目的是避免丢失事件
// 这里不需要检查ep->ovflist 的状态,因为ep->mtx的存在保证此处的ep->ovflist
// 一定是EP_UNACTIVE_PTR
ep->ovflist = NULL;
spin_unlock_irqrestore(&ep->lock, flags);

// 调用扫描函数处理txlist
error = (*sproc)(ep, &txlist, priv);

spin_lock_irqsave(&ep->lock, flags);

// 调用 sproc 时可能有新的事件,遍历这些新的事件将其插入到ready list
for (nepi = ep->ovflist; (epi = nepi) != NULL;
nepi = epi->next, epi->next = EP_UNACTIVE_PTR) {
// #define EP_UNACTIVE_PTR (void *) -1
// epi 不在rdllist, 插入
if (!ep_is_linked(&epi->rdllink)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
// 还原ep->ovflist的状态
ep->ovflist = EP_UNACTIVE_PTR;

// 将处理后的 txlist 链接到 rdllist
list_splice(&txlist, &ep->rdllist);

if (!list_empty(&ep->rdllist)) {
// 唤醒epoll_wait
if (waitqueue_active(&ep->wq)) {
wake_up_locked(&ep->wq);
}
// 当前的ep有其他的事件通知机制监控
if (waitqueue_active(&ep->poll_wait)) {
pwake++;
}
}
spin_unlock_irqrestore(&ep->lock, flags);

mutex_unlock(&ep->mtx);

if (pwake) {
// 安全唤醒外部的事件通知机制
ep_poll_safewake(&ep->poll_wait);
}

return error;
}

static int ep_read_events_proc(struct eventpoll *ep, struct list_head *head,
void *priv)
{
struct epitem *epi, *tmp;
poll_table pt;
init_poll_funcptr(&pt, NULL);
list_for_each_entry_safe(epi, tmp, head, rdllink) {
pt._key = epi->event.events;
if (epi->ffd.file->f_op->poll(epi->ffd.file, &pt) &
epi->event.events) {
return POLLIN | POLLRDNORM;
} else {
// 这个事件虽然在就绪列表中,
// 但是实际上并没有就绪, 将他移除
// 这有可能是水平触发模式中没有将文件从就绪列表中移除
// 也可能是事件插入到就绪列表后有其他的线程对文件进行了操作
list_del_init(&epi->rdllink);
}
}
return 0;
}

epoll全景

以下是epoll使用的全部数据结构之间的关系图,采用的是一种类UML图,希望对理解epoll的内部实现有所帮助。

poll/select/epoll 对比

通过以上的分析可以看出,poll和select的实现基本是一致,只是用户到内核传递的数据格式有所不同,

select和poll即使只有一个描述符就绪,也要遍历整个集合。如果集合中活跃的描述符很少,遍历过程的开销就会变得很大,而如果集合中大部分的描述符都是活跃的,遍历过程的开销又可以忽略。

epoll的实现中每次只遍历活跃的描述符(如果是水平触发,也会遍历先前活跃的描述符),在活跃描述符较少的情况下就会很有优势,在代码的分析过程中可以看到epoll的实现过于复杂并且其实现过程中需要同步处理(锁),如果大部分描述符都是活跃的,epoll的效率可能不如select或poll。(参见epoll 和poll的性能测试 http://jacquesmattheij.com/Poll+vs+Epoll+once+again)

select能够处理的最大fd无法超出FDSETSIZE。

select会复写传入的fd_set 指针,而poll对每个fd返回一个掩码,不更改原来的掩码,从而可以对同一个集合多次调用poll,而无需调整。

select对每个文件描述符最多使用3个bit,而poll采用的pollfd需要使用64个bit,epoll采用的 epoll_event则需要96个bit

如果事件需要循环处理select, poll 每一次的处理都要将全部的数据复制到内核,而epoll的实现中,内核将持久维护加入的描述符,减少了内核和用户复制数据的开销。

虚拟存储器

进程提供给应用程序的关键抽象:

一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器。
一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统.

虚拟存储器

虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。通过一个很清晰的机制,虚拟存储器提供了三个重要的能力:

(1)它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。

(2)它为每个进程提供了一致的地址空间,从而简化了存储器管理。

(3)它保护了每个进程的地址空间不被其他进程破坏。

物理和虚拟寻址

物理寻址

计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址(Physical Address,PA)。第一个字节的地址为0,接下来的字节的地址为1,再下一个为2,依此类推。给定这种简单的结构,CPU访问存储器的最自然的方式就是使用物理地址,我们把这种方式称为物理寻址。

虚拟寻址

使用虚拟寻址时,CPU通过生成一个虚拟地址(Virtual Address,VA)来访问主存,这个虚拟地址在被送到存储器之前先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译(address translation)。就像异常处理一样,地址翻译需要CPU硬件和操作系统之间的紧密合作。CPU芯片上叫做存储器管理单元(Memory Management Unit,MMU)的专用硬件,利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容是由操作系统管理。

地址空间

地址空间(adress space)是一个非整数地址的有序集合:{0,1,2,…}

如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间(linear address space)。在一个带虚拟存储器的系统中,CPU从一个有N = 2 ^ n个地址空间中生成虚拟地址,这个地址空间称为虚拟地址空间(virtual address space):{0,1,2,3,…,N-1}

一个地址空间的大小是由表示最大地址所需要的倍数来描述的。例如,一个包含N=2^n个地址的虚拟地址空间叫做一个n位地址空间。现在系统典型地支持32位或者64位虚拟地址空间是。

一个系统还有一个物理地址空间(physical addresss space),它与系统中物理存储器的M字节相对应:{0,1,2,…M-1}

M不要求是2的幂,但是为了简化讨论,我们假设M = 2 ^ m。

地址空间的概念是很重要的,因为它清楚地区分了数据对象(字节)和它们的属性(地址)。一旦认识到了这种区别,那么我们就可以将其推广,允许每个数据对象有多个独立的地址,其中每个地址都选自一个不同的地址空间(不连续的意思吗?)。这就是虚拟存储器的基本思想。主存中每个字节都有一个选自虚拟地址空间的虚拟地址和一个选自物理地址空间的物理地址。(这段没怎么看懂~~)

虚拟存储器作为缓存的工具

概念上而言,虚拟存储器(VM)被组织为一个由存放在磁盘上N个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,这个唯一的虚拟地址是作为到数组的索引的。磁盘上的数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM系统通过将虚拟存储器分割称为虚拟页(Vitual Page,VP)的大小固定的块来处理这个问题。每个虚拟页的大小为P = 2 ^ n字节。类似地,物理存储器被分割为物理页(Physical Page,PP),大小也为P字节(物理页也称为页帧(page frame))。

在任意时刻,虚拟页面的集合都分为三个不相交的子集:

未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。(没有调用malloc或者mmap的)
缓存的:当前缓存在物理存储中的已分配页。(已经调用malloc和mmap的,在程序中正在引用的)
未缓存的:没有缓存在物理存储器中的已分配页。(已经调用malloc和mmap的,在程序中还没有被引用的)

页表

同任何缓存一样,虚拟存储器系统必须有某种方法来判定一个虚拟页是否存放在DRAM中的某个地方。如果是,系统还必须确定这个虚拟页存放在哪个物理页中。如果不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置,在物理存储器中选择一个牺牲页,并将虚拟页从磁盘拷贝到DRAM中,替换这个牺牲页。

这些功能是由许多软硬件联合提供的,包括操作系统软件,MMU(存储器管理单元)中地址翻译硬件和一个存放在物理存储器中叫做页表(page table)的数据结构,页表将虚拟页映射到物理页。页表就是一个页表条目(Page Table Entry,PTE)的数组。

Linux虚拟存储器系统

Linux为每个进程维持了一个单独的虚拟地址空间。

内核虚拟存储器包含内核中的代码和数据结构。内核虚拟存储器的某些区域被映射到所有进程共享的物理页面。例如,每个进程共享内核的代码和全局数据结构。

Linux虚拟存储器区域(Windows下也有区域的概念)

Linux将虚拟存储器组织成一些区域(也叫做段)的集合。一个区域(area)就是已经存在着的(已分配的)虚拟存储器的连续片(chunk),这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都不同的区域。每个存在的虚拟页面保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。区域的概念很重要,因为它允许虚拟地址空间有间隙。内核不用记录那些不存在的虚拟页,而这样的页也不占用存储器。磁盘或者内核本身的任何额外资源。

内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息(例如,PID,指向用户栈的指针、可执行的目标文件的名字以及程序计数器)。

task_struct中的一个条目指向mm_struct,它描述了虚拟存储器中的当前状态。其中pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm_area_struct(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域(area)。当内核运行这个进程时,它就将pgd存放在CR3控制寄存器中。

一个具体区域结构包含下面的字段:

  • vm_start:指向这个区域的起始处。
  • vm_end:指向这个区域的结束处。
  • vm_prot:描述这个区域的内包含的所有页的读写许可权限。
  • vm_flags:描述这个区域内页面是与其他进程共享的,还是这个进程私有的(还描述了其他一些信息)。
  • vm_next:指向链表中下一个区域结构。

存储器映射(Windows下也有类似的机制,名叫内存映射)

Linux(以及其他一些形式的Unix)通过将一个虚拟存储器区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟存储器区域的内容,这个过程称为存储器映射(memory mapping)。虚拟存储器区域可以映射到两种类型的对象的一种:
(1)Unix文件上的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始化内容。因为按需进行页面高度,所以这些虚拟页面没有实际进行物理存储器,直到CPU第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域文件区要大,那么就用零来填充这个区域的余下部分。

(2)匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理存储器中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在存储器中的。注意在磁盘和存储器之间没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页(demand-zero page)。
无论在哪种情况下,一旦一个虚拟页面被初始化了, 它就在一个由内核维护的专门的交换文件(swap file)之间换来换去。交换文件也叫做交换空间(swap space)或者交换区域(swap area)。需要意识到的很重要的一点,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。

再看共享对象
一个对象可以被映射到虚拟存储的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟存储器的其他进程而言也是可见的。而且,这此变化也会反映在磁盘上的原始对象中。(IPC的一种方式)
另一方面,对一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中。一个映射到共享对象的虚拟存储器区域叫做共享区域。类似地,也有私有区域。

共享对象的关键点在于即使对象被映射到了多个共享区域,物理存储器也只需要存放共享对象的一个拷贝。

一个共享对象(注意,物理页面不一定是连续的。)

私有对象是使用一种叫做写时拷贝(copy-on-write)的巧妙技术被映射到虚拟存储器中的。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时拷贝。

再看fork函数

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟存储器,它创建了当前进程的mm_struct、区域结构和页表的原样拷贝。它将两个进程中的每个页面都为标记只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝。

当fork在新进程中返回时,新进程现在的虚拟存储器刚好和调用fork时存在的虚拟存储器相同。当这两个进程中的任一个后来进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

再看execve函数

假设运行在当前进程中的程序执行了如下的调用:

execve(“a.out”,NULL,NULL) ;

execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:

删除已存在的用户区域。删除当前进程虚拟地址用户部分中的已存在的区域结构。
映射私有区域。为新程序的文本、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时拷贝的。文本和数据区域被映射为a.out文件中的文本和数据区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的。
映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

使用mmap函数的用户级存储器映射

1
2
3
4
5
#include<unistd.h>  
#include<sys/mman.h>

void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset) ;
//返回:若成功时则为指向映射区域的指针,若出错则为MAP_FAILED(-1)

mmap函数要求内核创建一个新的虚拟存储器区域是,最好是从地址start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片(chunk)映射到这个新区域。连续的对象片大小为length字节,从距文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL。

munmap函数删除虚拟存储器的区域:

1
2
3
4
5
6
#include<unistd.h>  
#include<sys/mman.h>


int munmap(void *start,size_t length);
//返回:若成功则为0,若出错则为-1

1、需要额外的虚拟存储器时,使用一种动态存储器分配器(dynamic memory allocator)。一个动态存储器分配器维护着一个进程的虚拟存储器区域,称为堆(heap)。在大多数的unix系统中,堆是一个请求二进制0的区域;对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

2、分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟存储器组块(chunk),要么是已分配的,要么是未分配的。

1)显式分配器(explicit allocator):如通过malloc,free或C++中通过new,delete来分配和释放一个块。

2)隐式分配器(implicit allocator):也叫做垃圾收集器(garbage collector)。自动释放未使用的已分配的块的过程叫做垃圾回收(garbage collection)。

3、malloc不初始化它返回的存储器,calloc是一个基于malloc的包装(wrapper)函数,它将分配的存储器初始化为0。想要改变一个以前已分配的块的大小,可以使用realloc函数。

4、分配器必须对齐块,使得它们可以保存任何类型的数据对象。在大多数系统中,以8字节边界对齐。

不修改已分配的块:分配器只能操作或者改变空闲块。一旦被分配,就不允许修改或者移动它。

5、碎片(fragmentation)

有内部碎片(internal)和外部碎片(external)。

外部碎片:在一个已分配块比有效载荷在时发生的。(如对齐要求,分配最小值限制等)

外部碎片:当空闲存储器合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。

6、隐式空间链表

放置分配的块的策略有:首次适配(first fit),下一次适配(next fit),和最佳适配(best fit)。

如果空闲块已经最大程度的合并,而仍然不能生成一个足够大的块,来满足要求的话,分配器就会向内核请求额外的堆存储器,要么是通过调用nmap,要么是通过调用sbrk函数;分配器都会将额外的(增加的)存储器转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。

7、书中对分配器的设计举了一个小例子,10.9.12节。

8、一种流行的减少分配时间的方法,称为分离存储(segregated storage),维护多个空闲链表,其中每个链表中的块有大致相等的大小。

垃圾回收

1、垃圾收集器将存储器视为一张有向可达图(reachability graph)。

2、Mark%Sweep垃圾收集器由标记(mark)阶段和清除(sweep)阶段组成。标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个被标记的已分配块。典型地,块头部中空闲的低位中的一位来表示这个块是否被标记了.

直接存储器存取DMA

DMA(Direct Memory Access)

DMA(Direct Memory Access)即直接存储器存取,是一种快速传送数据的机制。

工作原理

DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。
  要把外设的数据读入内存或把内存的数据传送到外设,一般都要通过CPU控制完成,如CPU程序查询或中断方式。利用中断进行数据传送,可以大大提高CPU的利用率。
  但是采用中断传送有它的缺点,对于一个高速I/O设备,以及批量交换数据的情况,只能采用DMA方式,才能解决效率和速度问题。DMA在外设与内存间直接进行数据交换,而不通过CPU,这样数据传送的速度就取决于存储器和外设的工作速度。
  通常系统的总线是由CPU管理的。在DMA方式时,就希望CPU把这些总线让出来,即CPU连到这些总线上的线处于第三态–高阻状态,而由DMA控制器接管,控制传送的字节数,判断DMA是否结束,以及发出DMA结束信号。DMA控制器必须有以下功能:

  1. 能向CPU发出系统保持(HOLD)信号,提出总线接管请求;
  2. 当CPU发出允许接管信号后,负责对总线的控制,进入DMA方式;
  3. 能对存储器寻址及能修改地址指针,实现对内存的读写操作;
  4. 能决定本次DMA传送的字节数,判断DMA传送是否结束
  5. 发出DMA结束信号,使CPU恢复正常工作状态。

DMA流程

计算机发展到今天,DMA已不再用于内存到内存的数据传送,因为CPU速度非常快,做这件事,比用DMA控制还要快,但要在适配卡和内存之间传送数据,仍然是非DMA莫属。要从适配卡到内存传送数据,DMA同时触发从适配卡读数据总线(即I/O读操作)和向内存写数据的总线。激活I/O读操作就是让适配卡把一个数据单位(通常是一个字节或一个字)放到PC数据总线上,因为此时内存写总线也被激活,数据就被同时从PC总线上拷贝到内存中。

DMA工作方式   

随着大规模集成电路技术的发展,DMA传送已不局限于存储器与外设间的信息交换,而可以扩展为在存储器的两个区域之间,或两种高速的外设之间进行DMA传送,如图所示。
DMAC是控制存储器和外部设备之间直接高速地传送数据的硬件电路,它应能取代CPU,用硬件完成数据传送的各项功能。
各种DMAC一般都有两种基本的DMA传送方式:

  1. 单字节方式:每次DMA请求只传送一个字节数据,每传送完一个字节,都撤除DMA请求信号,释放总线。
  2. 多字节方式:每次DMA请求连续传送一个数据块,待规定长度的数据块传送完以后,才撤除DMA请求,释放总线。

在DMA传送中,为了使源和目的间的数据传送取得同步,不同的DMAC在操作时都受到外设的请求信号或准备就绪信号–Ready信号的限制。

工作方式

DMA与CPU调度

DMA控制器可采用哪几种方式与CPU分时使用内存?
直接内存访问(DMA)方式是一种完全由硬件执行I/O交换的工作方式。DMA控制器从CPU完全接管对总线的控制。数据交换不经过CPU,而直接在内存和I/O设备之间进行。DMA控制器采用以下三种方式:

  1. 停止CPU访问内存:当外设要求传送一批数据时,由DMA控制器发一个信号给CPU。DMA控制器获得总线控制权后,开始进行数据传送。一批数据传送完毕后,DMA控制器通知CPU可以使用内存,并把总线控制权交还给CPU。
  2. 周期挪用:当I/O设备没有 DMA请求时,CPU按程序要求访问内存:一旦 I/O设备有DMA请求,则I/O设备挪用一个或几个周期。
  3. DMA与CPU交替访内:一个CPU周期可分为2个周期,一个专供DMA控制器访内,另一个专供CPU访内。不需要总线使用权的申请、建立和归还过程。

DMA概述

DMA的英文拼写是“Direct Memory Access”,汉语的意思就是直接内存访问。DMA既可以指内存和外设直接存取数据这种内存访问的计算机技术,又可以指实现该技术的硬件模块(对于通用计算机PC而言,DMA控制逻辑由CPU和DMA控制接口逻辑芯片共同组成,嵌入式系统的DMA控制器内建在处理器芯片内部,一般称为DMA控制器,DMAC)。

DMA内存访问技术

使用DMA的好处就是它不需要CPU的干预而直接服务外设,这样CPU就可以去处理别的事务,从而提高系统的效率,对于慢速设备,如UART,其作用只是降低CPU的使用率,但对于高速设备,如硬盘,它不只是降低CPU的使用率,而且能大大提高硬件设备的吞吐量。因为对于这种设备,CPU直接供应数据的速度太低。 因CPU只能一个总线周期最多存取一次总线,而且对于ARM,它不能把内存中A地址的值直接搬到B地址。它只能先把A地址的值搬到一个寄存器,然后再从这个寄存器搬到B地址。也就是说,对于ARM,要花费两个总线周期才能将A地址的值送到B地址。而DMA就不同了,一般系统中的DMA都有突发(Burst)传输的能力,在这种模式下,DMA能一次传输几个甚至几十个字节的数据,所以使用DMA能使设备的吞吐能力大为增强。

使用DMA时我们必须要注意如下事实:

  • DMA使用物理地址,程序是使用虚拟地址的,所以配置DMA时必须将虚拟地址转化成物理地址。
  • 因为程序使用虚拟地址,而且一般使用cache地址,所以Cache中的内容与其物理地址(内存)的内容不一定一致,所以在启动DMA传输前一定要将该地址的cache刷新,即写入内存。
  • OS并不能保证每次分配到的内存空间在物理上是连续的。尤其是在系统使用过一段时间而又分配了一块比较大的内存时。所以每次都需要判断地址是不是连续的,如果不连续就需要把这段内存分成几段让DMA完成传输

DMAC的基本配置

DMA用于无需CPU的介入而直接由专用控制器(DMA控制器)建立源与目的传输的应用,因此,在大量数据传输中解放了CPU。PIC32微控制器中的DMA可用于映射到内存空间中的不同外设,如从存储区到SPI,UART或I2C等设备。DMA特性详见器件参考手册,这里仅对一些基本原理与功能做一个简析。

|
—-|—-
地址寄存器|放DMA传输时存储单元地址
字节计数器|存放DMA传输的字节数
控制寄存器|存放由CPU设定的DMA传输方式,控制命令等
状态寄存器|存放DMAC当前的状态,包括有无DMA请求,是否结束等

独立DMA控制芯片

在课程《微机原理》中,会讲到X86下一片独立的DMA控制芯片8237A。8237A控制芯片各通道在PC机内的任务:

CH0:用作动态存储器的刷新控制
CH1:为用户预留
CH2:软盘驱动器数据传输用的DMA控制
CH3:硬盘驱动器数据传输用的DMA控制

嵌入式设备中的DMA

直接存储器存取(DMA)控制器是一种在系统内部转移数据的独特外设,可以将其视为一种能够通过一组专用总线将内部和外部存储器与每个具有DMA能力的外设连接起来的控制器。它之所以属于外设,是因为它是在处理器的编程控制下来执行传输的。值得注意的是,通常只有数据流量较大(kBps或者更高)的外设才需要支持DMA能力,这些应用方面典型的例子包括视频、音频和网络接口。

一般而言,DMA控制器将包括一条地址总线、一条数据总线和控制寄存器。高效率的DMA控制器将具有访问其所需要的任意资源的能力,而无须处理器本身的介入,它必须能产生中断。最后,它必须能在控制器内部计算出地址。

一个处理器可以包含多个DMA控制器。每个控制器有多个DMA通道,以及多条直接与存储器站(memory bank)和外设连接的总线,如图1所示。在很多高性能处理器中集成了两种类型的DMA控制器。第一类通常称为“系统DMA控制器”,可以实现对任何资源(外设和存储器)的访问,对于这种类型的控制器来说,信号周期数是以系统时钟(SCLK)来计数的,以ADI的Blackfin处理器为例,频率最高可达133MHz。第二类称为内部存储器DMA控制器(IMDMA),专门用于内部存储器所处位置之间的相互存取操作。因为存取都发生在内部(L1-L1、L1-L2,或者L2-L2),周期数的计数则以内核时钟(CCLK)为基准来进行,该时钟的速度可以超过600MHz。

每个DMA控制器有一组FIFO,起到DMA子系统和外设或存储器之间的缓冲器的作用。对于MemDMA(Memory DMA)来说,传输的源端和目标端都有一组FIFO存在。当资源紧张而不能完成数据传输的话,则FIFO可以提供数据的暂存区,从而提高性能。

因为通常会在代码初始化过程中对DMA控制器进行配置,内核就只需要在数据传输完成后对中断做出响应即可。你可以对DMA控制进行编程,让其与内核并行地移动数据,而同时让内核执行其基本的处理任务―那些应该让它专注完成的工作。

在一个优化的应用中,内核永远不用参与任何数据的移动,而仅仅对L1存储器中的数据进行读写。于是,内核不需要等待数据的到来,因为DMA引擎会在内核准备读取数据之前将数据准备好。图2给出了处理器和DMA控制器间的交互关系。由处理器完成的操作步骤包括:建立传输,启用中断,生成中断时执行代码。返回到处理器的中断输入可以用来指示“数据已经准备好,可进行处理”。

数据除了往来外设之外,还需要从一个存储器空间转移到另一个空间中。例如,视频源可以从一个 视频端口直接流入L3存储器,因为工作缓冲区规模太大,无法放入到存储器中。我们并不希望让处理器在每次需要执行计算时都从外部存储读取像素信息,因此为 了提高存取的效率,可以用一个存储器到存储器的DMA(MemDMA)来将像素转移到L1或者L2存储器中。

到目前为之,我们还仅专注于数据的移动,但是DMA的传送能力并不总是用来移动数据。

在最简单的MemDMA情况中,我们需要告诉DMA控制器源端地址、目标端地址和待传送的字的个数。每次传输的字的大小可以是8、16或者12位。 我们只需要改变数据传输每次的数据大小,就可以简单地增加DMA的灵活性。例如,采用非单一大小的传输方式时,我们以传输数据块的大小的倍数来作为地址增量。也就是说,若规定32位的传输和4个采样的跨度,则每次传输结束后,地址的增量为16字节(4个32位字)。

DMA的设置

目前有两类主要的DMA传输结构:寄存器模式和描述符模式。无论属于哪一类DMA,表1所描述的几类信息都会在DMA控制器中出现。当DMA以寄存器模式工作时,DMA控制器只是简单地利用寄存器中所存储的参数值。在描述符模式中,DMA控制器在存储器中查找自己的配置参数。

基于寄存器的DMA

在基于寄存器的DMA内部,处理器直接对DMA控制寄存器进行编程,来启动传输。基于寄存器的DMA提供了最佳的DMA控制器性能,因为寄存器并不需要不断地从存储器中的描述符上载入数据,而内核也不需要保持描述符。

基于寄存器的DMA由两种子模式组成:自动缓冲(Autobuffer)模式和停止模式。在自动缓冲DMA中,当一个传输块传输完毕,控制寄存器就自动重新载入其最初的设定值,同一个DMA进程重新启动,开销为零。

正如我们在图3中所看到的那样,如果将一个自动缓冲DMA设定为从外设传输一定数量的字到 L1数据存储器的缓冲器上,则DMA控制器将会在最后一个字传输完成的时刻就迅速重新载入初始的参数。这构成了一个“循环缓冲器”,因为当一个量值被写入 到缓冲器的最后一个位置上时,下一个值将被写入到缓冲器的第一个位置上。

自动缓冲DMA特别适合于对性能敏感的、存在持续数据流的应用。DMA控制器可以在独立于处理器其他活动的情况下读入数据流,然后在每次传输结束时,向内核发出中断。

停止模式的工作方式与自动缓冲DMA类似,区别在于各寄存器在DMA结束后不会重新载入,因 此整个DMA传输只发生一次。停止模式对于基于某种事件的一次性传输来说十分有用。例如,非定期地将数据块从一个位置转移到另一个位置。当你需要对事件进 行同步时,这种模式也非常有用。例如,如果一个任务必须在下一次传输前完成的话,则停止模式可以确保各事件发生的先后顺序。此外,停止模式对于缓冲器的初 始化来说非常有用。

描述符模型

基于描述符(descriptor)的DMA要求在存储器中存入一组参数,以 启动DMA的系列操作。该描述符所包含的参数与那些通常通过编程写入DMA控制寄存器组的所有参数相同。不过,描述符还可以容许多个DMA操作序列串在一 起。在基于描述符的DMA操作中,我们可以对一个DMA通道进行编程,在当前的操作序列完成后,自动设置并启动另一次DMA传输。基于描述符的方式为管理 系统中的DMA传输提供了最大的灵活性。

ADI 的Blackfin处理器上有两种主要的描述符方式―描述符阵列和描述符列表,这两种操作方式所要实现的目标是在灵活性和性能之间实现一种折中平衡。

直接内存访问(DMA)

什么是DMA

直接内存访问是一种硬件机制,它允许外围设备和主内存之间直接传输它们的I/O数据,而不需要系统处理器的参与。使用这种机制可以大大提高与设备通信的吞吐量。

DMA数据传输

有两种方式引发数据传输:
第一种情况:软件对数据的请求

  1. 当进程调用read,驱动程序函数分配一个DMA缓冲区,并让硬件将数据传输到这个缓冲区中。进程处于睡眠状态。
  2. 硬件将数据写入到DMA缓冲区中,当写入完毕,产生一个中断
  3. 中断处理程序获取输入的数据,应答中断,并唤起进程,该进程现在即可读取数据

第二种情况发生在异步使用DMA时。

  1. 硬件产生中断,宣告新数据的到来
  2. 中断处理程序分配一个缓冲区,并且告诉硬件向哪里传输数据
  3. 外围设备将数据写入数据区,完成后,产生另外一个中断
  4. 处理程序分发新数据,唤醒任何相关进程,然后执行清理工作

高效的DMA处理依赖于中断报告。

分配DMA缓冲区

使用DMA缓冲区的主要问题是:当大于一页时,它们必须占据连续的物理页,因为设备使用ISA或PCI系统总线传输数据,而这两种方式使用的都是物理地址。

使用get_free_pasges可以分配多大几M字节的内存(MAX_ORDER是11),但是对于较大数量(即使是远小于128KB)的请求,通常会失败,这是因为系统内存充满了内存碎片。

解决方法之一就是在引导时分配内存,或者为缓冲区保留顶部物理内存。

例子:在系统引导时,向内核传递参数“mem=value”的方法保留顶部的RAM。比如系统有256内存,参数“mem=255M”,使内核不能使用顶部的1M字节。随后,模块可以使用下面代码获得该内存的访问权:

dmabuf=ioremap(0XFF00000/*255M/, 0X100000/1M/*);

解决方法之二是使用GPF_NOFAIL分配标志为缓冲区分配内存,但是该方法为内存管理子系统带来了相当大的压力。

解决方法之三十设备支持分散/聚集I/O,这可以将缓冲区分配成多个小块,设备会很好地处理它们。

通用DMA层

DMA操作最终会分配缓冲区,并将总线地址传递给设备。内核提高了一个与总线——体系结构无关的DMA层。强烈建议在编写驱动程序时,为DMA操作使用该层。使用这些函数的头文件是

int dma_set_mask(struct device *dev, u64 mask);

该掩码显示该设备能寻址能力对应的位。比如说,设备受限于24位寻址,则mask应该是0x0FFFFFF。

DMA映射

IOMMU在设备可访问的地址范围内规划了物理内存,使得物理上分散的缓冲区对设备来说成连续的。对IOMMU的运用需要使用到通用DMA层,而vir_to_bus函数不能完成这个任务。但是,x86平台没有对IOMMU的支持。

解决之道就是建立回弹缓冲区,然后,必要时会将数据写入或者读出回弹缓冲区。缺点是降低系统性能。

根据DMA缓冲区期望保留的时间长短,PCI代码区分两种类型的DMA映射:

一是一致性DMA映射,存在于驱动程序生命周期中,一致性映射的缓冲区必须可同时被CPU和外围设备访问。一致性映射必须保存在一致性缓存中。建立和使用一致性映射的开销是很大的。

二是流式DMA映射,内核开发者建议尽量使用流式映射,原因:一是在支持映射寄存器的系统中,每个DMA映射使用总线上的一个或多个映射寄存器,而一致性映射生命周期很长,长时间占用这些这些寄存器,甚至在不使用他们的时候也不释放所有权;二是在一些硬件中,流式映射可以被优化,但优化的方法对一致性映射无效。

建立一致性映射

驱动程序可调用pci_alloc_consistent函数建立一致性映射:

void dma_alloc_coherent(struct device dev, size_t size, dma_addr_t *dma_handle, int falg);

该函数处理了缓冲区的分配和映射,前两个参数是device结构和所需的缓冲区的大小。函数在两处返回DMA映射的结果:函数的返回值是缓冲区的内核虚拟地址,可以被驱动程序使用;而与其相关的总线地址保存在dma_handle中。

当不再需要缓冲区时,调用下函数:

void dma_free_conherent(struct device dev, size_t size, void vaddr, dma_addr_t *dma_handle);

DMA池

DMA池是一个生成小型,一致性DMA映射的机制。调用dma_alloc_coherent函数获得的映射,可能其最小大小为单个页。如果设备需要的DMA区域比这还小,就是用DMA池。在中定义了DMA池函数:

struct dma_pool dma_pool_create(const char name, struct device *dev, size_t size, size_t align, size_t allocation);

void dma_pool_destroy(struct dma_pool *pool);

name是DMA池的名字,dev是device结构,size是从该池中分配的缓冲区的大小,align是该池分配操作所必须遵守的硬件对齐原则(用字节表示),如果allocation不为零,表示内存边界不能超越allocation。比如说传入的allocation是4K,表示从该池分配的缓冲区不能跨越4KB的界限。

在销毁之前必须向DMA池返回所有分配的内存。

void dma_pool_alloc(sturct dma_pool pool, int mem_flags, dma_addr_t *handle);

void dma_pool_free(struct dma_pool pool, void addr, dma_addr_t addr);

建立流式DMA映射

在某些体系结构中,流式映射也能够拥有多个不连续的页和多个“分散/聚集”缓冲区。建立流式映射时,必须告诉内核数据流动的方向。

DMA_TO_DEVICE

DEVICE_TO_DMA

如果数据被发送到设备,使用DMA_TO_DEVICE;而如果数据被发送到CPU,则使用DEVICE_TO_DMA。

DMA_BIDIRECTTONAL

如果数据可双向移动,则使用该值

DMA_NONE

该符号只是出于调试目的。

当只有一个缓冲区要被传输的时候,使用下函数映射它:

dma_addr_t dma_map_single(struct device dev, void buffer, size_t size, enum dma_data_direction direction);

返回值是总线地址,可以把它传递给设备;如果执行错误,返回NULL。

当传输完毕后,使用下函数删除映射:

void dma_unmap_single(struct device *dev, dma_addr_t dma_addr, size_t size, enum dma-data_direction direction);

使用流式DMA的原则:

一是缓冲区只能用于这样的传送,即其传送方向匹配与映射时给定的方向值;

二是一旦缓冲区被映射,它将属于设备,不是处理器。直到缓冲区被撤销映射前,驱动程序不能以任何方式访问其中的内容。只用当dma_unmap_single函数被调用后,显示刷新处理器缓存中的数据,驱动程序才能安全访问其中的内容。

三是在DMA出于活动期间内,不能撤销对缓冲区的映射,否则会严重破坏系统的稳定性。

如果要映射的缓冲区位于设备不能访问的内存区段(高端内存),怎么办?一些体系结构只产生一个错误,但是其他一些系统结构件创建一个回弹缓冲区。回弹缓冲区就是内存中的独立区域,它可被设备访问。如果使用DMA_TO_DEVICE标志映射缓冲区,并且需要使用回弹缓冲区,则在最初缓冲区中的内容作为映射操作的一部分被拷贝。很明显,在拷贝后,最初缓冲区内容的改变对设备不可见。同样DEVICE_TO_DMA回弹缓冲区被dma_unmap_single函数拷贝回最初的缓冲区中,也就是说,直到拷贝操作完成,来自设备的数据才可用。

有时候,驱动程序需要不经过撤销映射就访问流式DMA缓冲区的内容,为此内核提供了如下调用:

void dma_sync_single_for_cpu(struct device *dev, dma_handle_t bus_addr, size_t size, enum dma_data_directction direction);

应该在处理器访问流式DMA缓冲区前调用该函数。一旦调用了该函数,处理器将“拥有”DMA缓冲区,并可根据需要对它进行访问。然后在设备访问缓冲区前,应该调用下面的函数将所有权交还给设备:

void dma_sync_single_for_device(struct device *dev, dma_handle_t bus_addr, size_t size, enum dma_data_direction direction);

再次强调,处理器在调用该函数后,不能再访问DMA缓冲区了。

Linux session 浅谈

session的概念:
在web中的session概念,维系是基于凭证,在web中一般用session保存的是登录的信息,当客户端每次进行请求的时候,都会在请求的数据后面加上session ,这样

服务端就可以知道该用户是什么用户,以及他所具有的权限。当用户退出,或长时间没有交互,则session无效(该凭证已经失效),需要重新登录。

linux中的session跟这个有点类似,也是在一个用户登录到主机,那么就建立了一个session,但是它的维系是基于连接的,那么该对于这个会话存在两种的维持方法

  1. 本地连接:就是说用户是在主机本机上进行的登录,直接通过键盘和屏幕和主机进行交互。
  2. 远程连接:用户通过互联网进行连接,比如基于ssh,连接都是经过加密的。

session是一个或多个进程组的集合。

session的创建:
创建有两种方法:

  1. 用户登录就是一个会话的开始,登录之后,用户会得到一个与终端相关联的进程,该进程就是该会话的leader,会话的id就是该进程的id。

  2. 是在程序中调用pid_t setsid(void),如果调用此函数的进程不是一个进程组的组长,则此函数就会创建一个新的会话,它将做以下三件事:
    a. 该进程是新会话的首进程(session leader),也是该会话中唯一的进程;

b. 该进程成为一个新进程组的组长进程,新进程组id是该进程id;
c. 该进程是没有控制终端的。如果该进程原来是有一个控制中断的,但是这种联系也会被打断。因此呢,我们在新建一个session的时候就要记得对输入输出进程重定向哦。

在调用setsid()的时候呢,要注意如果caller process是进程组组长,那么函数将会返回出错哦,所以一般偶是先fork一个进程后,在调用该函数,保证了caller process不是进程组组长哦。
session的退出:
对于session的退出会进行很多的操作,且听我慢慢说来:
当session 中leader进程退出,将导致它所连接终端被hangup,这就意味着该会话结束。如果是像ssh这种远程连接,可以通过断开网络连接来使(伪)终端hangup,这将使得leader进程收到SIGHUP信号而退出。如果是pty,其本身就是随着会话建立而创建的,会话结束,那么该终端也会被销毁的。而如果是tty则不会,因为该设备是在系统初始化的时候创建的(请看前一篇博客),并不是依赖该会话建立的,所以当该会话退出,tty仍然是存在的。只是init进程在会话结束后,就会重启getty来监听该tty。

但是对于会话的结束,并不会意味着该会话的所以进程都结束。

对于daemon进程,在会话中创建,但是不依赖于会话,是常驻在后台的进程。

具体来说当终端hangup时候,内核会有如下两个动作:

  1. 想对应会话的leader进程发送SIGHUP信号,一般来说leader是一个shell,它收到SIGHUP信号后并不是马上退出,而hi想他启动的子进程都各自发送一个SIGHUP,将他们都杀死后,自己才退出,但是如果当该leader进程主动退出,而导致的终端hangup那么就不会发送SIGHUP信号给子进程了。

  2. 因为session都将消亡了,那么它将控制终端修改为不可读不可写的文件。所以呢,会话退出后没有消亡的进程是不能控制终端的。

如果又想要某个进程称为常驻后台进程,不随session退出而退出,有下面几个方法:

  1. 避免shell发送SIGHUP信号: a. 主动调用exit,而不是直接断开终端;b. 两次fork,因为shell只给子进程发送SIGHUP信号,不给孙进程发送。
  2. 忽略SIGHUP信号:进程捕捉到该信号将该信号忽略就行了。
  3. 通过上面说到的setsid()系统调用,那么该调用进程将会退出该session而建立一个新的session。

Linux性能优化:CPU篇

系统平均负载

简介

  • 系统平均负载:是处于可运行或不可中断状态的平均进程数。
  • 可运行进程:使用 CPU 或等待使用 CPU 的进程
  • 不可中断状态进程:正在等待某些 IO 访问,一般是和硬件交互,不可被打断(不可被打断的原因是为了保护系统数据一致,防止数据读取错误)

查看系统平均负载

首先top命令查看进程运行状态,如下:

1
2
3
 PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
10760 user 20 0 3061604 84832 5956 S 82.4 0.6 126:47.61 Process
29424 user 20 0 54060 2668 1360 R 17.6 0.0 0:00.03 **top**

程序状态Status进程可运行状态为R,不可中断运行为D(后续讲解 top 时会详细说明)

top查看系统平均负载:

1
2
3
4
5
6
7
top - 13:09:42 up 888 days, 21:32,  8 users,  load average: 19.95, 14.71, 14.01
Tasks: 642 total, 2 running, 640 sleeping, 0 stopped, 0 zombie
%Cpu0 : 37.5 us, 27.6 sy, 0.0 ni, 30.9 id, 0.0 wa, 0.0 hi, 3.6 si, 0.3 st
%Cpu1 : 34.1 us, 31.5 sy, 0.0 ni, 34.1 id, 0.0 wa, 0.0 hi, 0.4 si, 0.0 st
...
KiB Mem : 14108016 total, 2919496 free, 6220236 used, 4968284 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 6654506 avail Mem

这里的load average就表示系统最近 1 分钟、5 分钟、15 分钟的系统瓶颈负载。

uptime查看系统瓶颈负载

1
2
[root /home/user]# uptime
13:11:01 up 888 days, 21:33, 8 users, load average: 17.20, 14.85, 14.10

查看 CPU 核信息:系统平均负载和 CPU 核数密切相关,我们可以通过以下命令查看当前机器 CPU 信息:

lscpu查看 CPU 信息:

1
2
3
4
5
6
7
8
9
10
[root@Tencent-SNG /home/user_00]# lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 8
...
L1d cache: 32K
L1i cache: 32K
L2 cache: 4096K
NUMA node0 CPU(s): 0-7 // NUMA架构信息

cat /proc/cpuinfo查看每个 CPU 核的信息:
1
2
3
4
5
processor       : 7   // 核编号7
vendor_id : GenuineIntel
cpu family : 6
model : 6
...

系统平均负载升高的原因

一般来说,系统平均负载升高意味着 CPU 使用率上升。但是他们没有必然联系,CPU 密集型计算任务较多一般系统平均负载会上升,但是如果 IO 密集型任务较多也会导致系统平均负载升高但是此时的 CPU 使用率不一定高,可能很低因为很多进程都处于不可中断状态,等待 CPU 调度也会升高系统平均负载。

所以假如我们系统平均负载很高,但是 CPU 使用率不是很高,则需要考虑是否系统遇到了 IO 瓶颈,应该优化 IO 读写速度。

所以系统是否遇到 CPU 瓶颈需要结合 CPU 使用率,系统瓶颈负载一起查看(当然还有其他指标需要对比查看,下面继续讲解)

案例问题排查

stress是一个施加系统压力和压力测试系统的工具,我们可以使用stress工具压测试 CPU,以便方便我们定位和排查 CPU 问题。

1
2
3
4
5
6
7
yum install stress // 安装stress工具
stress 命令使用
// --cpu 8:8个进程不停的执行sqrt()计算操作
// --io 4:4个进程不同的执行sync()io操作(刷盘)
// --vm 2:2个进程不停的执行malloc()内存申请操作
// --vm-bytes 128M:限制1个执行malloc的进程申请内存大小
stress --cpu 8 --io 4 --vm 2 --vm-bytes 128M --timeout 10s

我们这里主要验证 CPU、IO、进程数过多的问题

CPU 问题排查

使用stress -c 1模拟 CPU 高负载情况,然后使用如下命令观察负载变化情况:

uptime:使用uptime查看此时系统负载:

1
2
3
# -d 参数表示高亮显示变化的区域
$ watch -d uptime
... load average: 1.00, 0.75, 0.39

mpstat:使用mpstat -P ALL 1则可以查看每一秒的 CPU 每一核变化信息,整体和top类似,好处是可以把每一秒(自定义)的数据输出方便观察数据的变化,最终输出平均数据:
1
2
3
4
13:14:53     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
13:14:58 all 12.89 0.00 0.18 0.00 0.00 0.03 0.00 0.00 0.00 86.91
13:14:58 0 100.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
13:14:58 1 0.40 0.00 0.20 0.00 0.00 0.20 0.00 0.00 0.00 99.20

由以上输出可以得出结论,当前系统负载升高,并且其中 1 个核跑满主要在执行用户态任务,此时大多数属于业务工作。所以此时需要查哪个进程导致单核 CPU 跑满,pidstat:使用pidstat -u 1则是每隔 1 秒输出当前系统进程、CPU 数据:
1
2
3
4
13:18:00      UID       PID    %usr %system  %guest    %CPU   CPU  Command
13:18:01 0 1 1.00 0.00 0.00 1.00 4 systemd
13:18:01 0 3150617 100.00 0.00 0.00 100.00 0 stress
...

top:当然最方便的还是使用top命令查看负载情况:
1
2
3
4
5
6
7
8
top - 13:19:06 up 125 days, 20:01,  3 users,  load average: 0.99, 0.63, 0.42
Tasks: 223 total, 2 running, 221 sleeping, 0 stopped, 0 zombie
%Cpu(s): 14.5 us, 0.3 sy, 0.0 ni, 85.1 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 16166056 total, 3118532 free, 9550108 used, 3497416 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 6447640 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3150617 root 20 0 10384 120 0 R 100.0 0.0 4:36.89 stress

此时可以看到是stress占用了很高的 CPU。

IO 问题排查

我们使用stress -i 1来模拟 IO 瓶颈问题,即死循环执行 sync 刷盘操作:

uptime:使用uptime查看此时系统负载:

1
2
$ watch -d uptime
..., load average: 1.06, 0.58, 0.37

mpstat:查看此时 IO 消耗,但是实际上我们发现这里 CPU 基本都消耗在了 sys 即系统消耗上。
1
2
3
4
Average:     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
Average: all 0.33 0.00 12.64 0.13 0.00 0.00 0.00 0.00 0.00 86.90
Average: 0 0.00 0.00 99.00 1.00 0.00 0.00 0.00 0.00 0.00 0.00
Average: 1 0.00 0.00 0.33 0.00 0.00 0.00 0.00 0.00 0.00 99.67

IO 无法升高的问题:

iowait 无法升高的问题,是因为案例中 stress 使用的是 sync()系统调用,它的作用是刷新缓冲区内存到磁盘中。对于新安装的虚拟机,缓冲区可能比较小,无法产生大的 IO 压力,这样大部分就都是系统调用的消耗了。所以,你会看到只有系统 CPU 使用率升高。解决方法是使用 stress 的下一代 stress-ng,它支持更丰富的选项,比如stress-ng -i 1 —hdd 1 —timeout 600(—hdd 表示读写临时文件)。

1
2
3
Average:     CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
Average: all 0.25 0.00 0.44 26.22 0.00 0.00 0.00 0.00 0.00 73.09
Average: 0 0.00 0.00 1.02 98.98 0.00 0.00 0.00 0.00 0.00 0.00

pidstat:同上(略)

可以看出 CPU 的 IO 升高导致系统平均负载升高。我们使用pidstat查找具体是哪个进程导致 IO 升高的。

top:这里使用 top 依旧是最方面的查看综合参数,可以得出stress是导致 IO 升高的元凶。

pidstat 没有 iowait 选项:可能是 CentOS 默认的sysstat太老导致,需要升级到 11.5.5 之后的版本才可用。

进程数过多问题排查

进程数过多这个问题比较特殊,如果系统运行了很多进程超出了 CPU 运行能,就会出现等待 CPU 的进程。 使用stress -c 24来模拟执行 24 个进程(我的 CPU 是 8 核) uptime:使用uptime查看此时系统负载:

1
2
$ watch -d uptime
..., load average: 18.50, 7.13, 2.84

mpstat:同上(略)

pidstat:同上(略)

可以观察到此时的系统处理严重过载的状态,平均负载高达 18.50。

top:我们还可以使用top命令查看此时Running状态的进程数,这个数量很多就表示系统正在运行、等待运行的进程过多。

总结

通过以上问题现象及解决思路可以总结出:

  1. 平均负载高有可能是 CPU 密集型进程导致的
  2. 平均负载高并不一定代表 CPU 使用率高,还有可能是 I/O 更繁忙了
  3. 当发现负载高的时候,你可以使用 mpstat、pidstat 等工具,辅助分析负载的来源

总结工具:mpstat、pidstat、top和uptime

CPU 上下文切换

CPU 上下文:CPU 执行每个任务都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(Program Counter,PC)包括 CPU 寄存器在内都被称为 CPU 上下文。

CPU 上下文切换:CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

CPU 上下文切换:分为进程上下文切换、线程上下文切换以及中断上下文切换。

进程上下文切换

从用户态切换到内核态需要通过系统调用来完成,这里就会发生进程上下文切换(特权模式切换),当切换回用户态同样发生上下文切换。

一般每次上下文切换都需要几十纳秒到数微秒的 CPU 时间,如果切换较多还是很容易导致 CPU 时间的浪费在寄存器、内核栈以及虚拟内存等资源的保存和恢复上,这里同样会导致系统平均负载升高。

Linux 为每个 CPU 维护一个就绪队列,将 R 状态进程按照优先级和等待 CPU 时间排序,选择最需要的 CPU 进程执行。这里运行进程就涉及了进程上下文切换的时机:

  • 进程时间片耗尽。
  • 进程在系统资源不足(内存不足)。
  • 进程主动sleep。
  • 有优先级更高的进程执行。
  • 硬中断发生。

线程上下文切换

线程和进程:

  • 当进程只有一个线程时,可以认为进程就等于线程。
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
  • 线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

所以线程上下文切换包括了 2 种情况:

  • 不同进程的线程,这种情况等同于进程切换。
  • 相同进程的线程切换,只需要切换线程私有数据、寄存器等不共享数据。

中断上下文切换

中断处理会打断进程的正常调度和执行,转而调用中断处理程序,响应设备事件。而在打断其他进程时,就需要将进程当前的状态保存下来,这样在中断结束后,进程仍然可以从原来的状态恢复运行。

对同一个 CPU 来说,中断处理比进程拥有更高的优先级,所以中断上下文切换并不会与进程上下文切换同时发生。由于中断会打断正常进程的调度和执行,所以大部分中断处理程序都短小精悍,以便尽可能快的执行结束。

查看系统上下文切换

vmstat:工具可以查看系统的内存、CPU 上下文切换以及中断次数:

1
2
3
4
5
6
7
// 每隔1秒输出
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
3 0 0 157256 3241604 5144444 0 0 20 0 26503 33960 18 7 75 0 0
17 0 0 159984 3241708 5144452 0 0 12 0 29560 37696 15 10 75 0 0
6 0 0 162044 3241816 5144456 0 0 8 120 30683 38861 17 10 73 0 0

  • cs:则为每秒的上下文切换次数。
  • in:则为每秒的中断次数。
  • r:就绪队列长度,正在运行或等待 CPU 的进程。
  • b:不可中断睡眠状态的进程数,例如正在和硬件交互。

pidstat:使用pidstat -w选项查看具体进程的上下文切换次数:

1
2
3
4
5
$ pidstat -w -p 3217281 1
10:19:13 UID PID cswch/s nvcswch/s Command
10:19:14 0 3217281 0.00 18.00 stress
10:19:15 0 3217281 0.00 18.00 stress
10:19:16 0 3217281 0.00 28.71 stress

其中cswch/s和nvcswch/s表示自愿上下文切换和非自愿上下文切换。

  • 自愿上下文切换:是指进程无法获取所需资源,导致的上下文切换。比如说, I/O、内存等系统资源不足时,就会发生自愿上下文切换。
  • 非自愿上下文切换:则是指进程由于时间片已到等原因,被系统强制调度,进而发生的上下文切换。比如说,大量进程都在争抢 CPU 时,就容易发生非自愿上下文切换

案例问题排查

这里我们使用sysbench工具模拟上下文切换问题。

先使用vmstat 1查看当前上下文切换信息:

1
2
3
4
5
6
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 0 514540 3364828 5323356 0 0 10 16 0 0 4 1 95 0 0
1 0 0 514316 3364932 5323408 0 0 8 0 27900 34809 17 10 73 0 0
1 0 0 507036 3365008 5323500 0 0 8 0 23750 30058 19 9 72 0 0

然后使用sysbench --threads=64 --max-time=300 threads run模拟 64 个线程执行任务,此时我们再次vmstat 1查看上下文切换信息:
1
2
3
4
5
6
7
8
9
10
11
$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
2 0 0 318792 3385728 5474272 0 0 10 16 0 0 4 1 95 0 0
1 0 0 307492 3385756 5474316 0 0 8 0 15710 20569 20 8 72 0 0
1 0 0 330032 3385824 5474376 0 0 8 16 21573 26844 19 9 72 0 0
2 0 0 321264 3385876 5474396 0 0 12 0 21218 26100 20 7 73 0 0
6 0 0 320172 3385932 5474440 0 0 12 0 19363 23969 19 8 73 0 0
14 0 0 323488 3385980 5474828 0 0 64 788 111647 3745536 24 61 15 0 0
14 0 0 323576 3386028 5474856 0 0 8 0 118383 4317546 25 64 11 0 0
16 0 0 315560 3386100 5475056 0 0 8 16 115253 4553099 22 68 9 0 0

我们可以明显的观察到:

  • 当前 cs、in 此时剧增。
  • sy+us 的 CPU 占用超过 90%。
  • r 就绪队列长度达到 16 个超过了 CPU 核心数 8 个。

分析 cs 上下文切换问题

我们使用pidstat查看当前 CPU 信息和具体的进程上下文切换信息:

1
2
3
4
5
6
7
8
9
10
11
// -w表示查看进程切换信息,-u查看CPU信息,-t查看线程切换信息
$ pidstat -w -u -t 1

10:35:01 UID PID %usr %system %guest %CPU CPU Command
10:35:02 0 3383478 67.33 100.00 0.00 100.00 1 sysbench

10:35:01 UID PID cswch/s nvcswch/s Command
10:45:39 0 3509357 - 1.00 0.00 kworker/2:2
10:45:39 0 - 3509357 1.00 0.00 |__kworker/2:2
10:45:39 0 - 3509702 38478.00 45587.00 |__sysbench
10:45:39 0 - 3509703 39913.00 41565.00 |__sysbench

所以我们可以看到大量的sysbench线程存在很多的上下文切换。

分析 in 中断问题

我们可以查看系统的watch -d cat /proc/softirqs以及watch -d cat /proc/interrupts来查看系统的软中断和硬中断(内核中断)。我们这里主要观察/proc/interrupts即可。

1
2
$ watch -d cat /proc/interrupts
RES: 900997016 912023527 904378994 902594579 899800739 897500263 895024925 895452133 Rescheduling interrupts

这里明显看出重调度中断(RES)增多,这个中断表示唤醒空闲状态 CPU 来调度新任务执行,

总结

自愿上下文切换变多了,说明进程都在等待资源,有可能发生了 I/O 等其他问题。
非自愿上下文切换变多了,说明进程都在被强制调度,也就是都在争抢 CPU,说明 CPU 的确成了瓶颈。
中断次数变多了,说明 CPU 被中断处理程序占用,还需要通过查看/proc/interrupts文件来分析具体的中断类型。

CPU 使用率

除了系统负载、上下文切换信息,最直观的 CPU 问题指标就是 CPU 使用率信息。Linux 通过/proc虚拟文件系统向用户控件提供系统内部状态信息,其中/proc/stat则是 CPU 和任务信息统计。

1
2
3
4
$ cat /proc/stat | grep cpu
cpu 6392076667 1160 3371352191 52468445328 3266914 37086 36028236 20721765 0 0
cpu0 889532957 175 493755012 6424323330 2180394 37079 17095455 3852990 0 0
...

这里每一列的含义如下:

  • user(通常缩写为 us),代表用户态 CPU 时间。注意,它不包括下面的 nice 时间,但包括了 guest 时间。
  • nice(通常缩写为 ni),代表低优先级用户态 CPU 时间,也就是进程的 nice 值被调整为 1-19 之间时的 CPU 时间。这里注意,nice 可取值范围是 -20 到 19,数值越大,优先级反而越低。
  • system(通常缩写为 sys),代表内核态 CPU 时间。
  • idle(通常缩写为 id),代表空闲时间。注意,它不包括等待 I/O 的时间(iowait)。
  • iowait(通常缩写为 wa),代表等待 I/O 的 CPU 时间。
  • irq(通常缩写为 hi),代表处理硬中断的 CPU 时间。
  • softirq(通常缩写为 si),代表处理软中断的 CPU 时间。
  • steal(通常缩写为 st),代表当系统运行在虚拟机中的时候,被其他虚拟机占用的 CPU 时间。
  • guest(通常缩写为 guest),代表通过虚拟化运行其他操作系统的时间,也就是运行虚拟机的 CPU 时间。
  • guest_nice(通常缩写为 gnice),代表以低优先级运行虚拟机的时间。

这里我们可以使用top、ps、pidstat等工具方便的查询这些数据,可以很方便的看到 CPU 使用率很高的进程,这里我们可以通过这些工具初步定为,但是具体的问题原因还需要其他方法继续查找。

这里我们可以使用perf top方便查看热点数据,也可以使用perf record可以将当前数据保存起来方便后续使用perf report查看。

CPU 使用率问题排查

这里总结一下 CPU 使用率问题及排查思路:

  • 用户 CPU 和 Nice CPU 高,说明用户态进程占用了较多的 CPU,所以应该着重排查进程的性能问题。
  • 系统 CPU 高,说明内核态占用了较多的 CPU,所以应该着重排查内核线程或者系统调用的性能问题。
  • I/O 等待 CPU 高,说明等待 I/O 的时间比较长,所以应该着重排查系统存储是不是出现了 I/O 问题。
  • 软中断和硬中断高,说明软中断或硬中断的处理程序占用了较多的 CPU,所以应该着重排查内核中的中断服务程序。

CPU 问题排查套路

CPU 使用率

CPU 使用率主要包含以下几个方面:

  • 用户 CPU 使用率,包括用户态 CPU 使用率(user)和低优先级用户态 CPU 使用率(nice),表示 CPU 在用户态运行的时间百分比。用户 CPU 使用率高,通常说明有应用程序比较繁忙。
  • 系统 CPU 使用率,表示 CPU 在内核态运行的时间百分比(不包括中断)。系统 CPU 使用率高,说明内核比较繁忙。
  • 等待 I/O 的 CPU 使用率,通常也称为 iowait,表示等待 I/O 的时间百分比。iowait 高,通常说明系统与硬件设备的 I/O 交互时间比较长。
  • 软中断和硬中断的 CPU 使用率,分别表示内核调用软中断处理程序、硬中断处理程序的时间百分比。它们的使用率高,通常说明系统发生了大量的中断。

除在虚拟化环境中会用到的窃取 CPU 使用率(steal)和客户 CPU 使用率(guest),分别表示被其他虚拟机占用的 CPU 时间百分比,和运行客户虚拟机的 CPU 时间百分比。

平均负载

反应了系统的整体负载情况,可以查看过去 1 分钟、过去 5 分钟和过去 15 分钟的平均负载。

上下文切换

上下文切换主要关注 2 项指标:

  • 无法获取资源而导致的自愿上下文切换。
  • 被系统强制调度导致的非自愿上下文切换。

CPU 缓存命中率

CPU 的访问速度远大于内存访问,这样在 CPU 访问内存时不可避免的要等待内存响应。为了协调 2 者的速度差距出现了 CPU 缓存(多级缓存)。 如果 CPU 缓存命中率越高则性能会更好,我们可以使用以下工具查看 CPU 缓存命中率,工具地址、项目地址 perf-tools

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# ./cachestat -t
Counting cache functions... Output every 1 seconds.
TIME HITS MISSES DIRTIES RATIO BUFFERS_MB CACHE_MB
08:28:57 415 0 0 100.0% 1 191
08:28:58 411 0 0 100.0% 1 191
08:28:59 362 97 0 78.9% 0 8
08:29:00 411 0 0 100.0% 0 9
08:29:01 775 20489 0 3.6% 0 89
08:29:02 411 0 0 100.0% 0 89
08:29:03 6069 0 0 100.0% 0 89
08:29:04 15249 0 0 100.0% 0 89
08:29:05 411 0 0 100.0% 0 89
08:29:06 411 0 0 100.0% 0 89
08:29:07 411 0 3 100.0% 0 89
[...]

总结

通过性能指标查工具(CPU 相关)

根据工具查性能指标(CPU 相关)

CPU 问题排查方向有了以上性能工具,在实际遇到问题时我们并不可能全部性能工具跑一遍,这样效率也太低了,所以这里可以先运行几个常用的工具 top、vmstat、pidstat 分析系统大概的运行情况然后在具体定位原因。

  • top 系统CPU => vmstat 上下文切换次数 => pidstat 非自愿上下文切换次数 => 各类进程分析工具(perf strace ps execsnoop pstack)
  • top 用户CPU => pidstat 用户CPU => 一般是CPU计算型任务
  • top 僵尸进程 => 各类进程分析工具(perf strace ps execsnoop pstack)
  • top 平均负载 => vmstat 运行状态进程数 => pidstat 用户CPU => 各类进程分析工具(perf strace ps execsnoop pstack)
  • top 等待IO CPU => vmstat 不可中断状态进程数 => IO分析工具(dstat、sar -d)
  • top 硬中断 => vmstat 中断次数 => 查看具体中断类型(/proc/interrupts)
  • top 软中断 => 查看具体中断类型(/proc/softirqs) => 网络分析工具(sar -n、tcpdump) 或者 SCHED(pidstat 非自愿上下文切换)

CPU 问题优化方向性能优化往往是多方面的,CPU、内存、网络等都是有关联的,这里暂且给出 CPU 优化的思路,以供参考。

  • 基本优化:程序逻辑的优化比如减少循环次数、减少内存分配,减少递归等等。
  • 编译器优化:开启编译器优化选项例如gcc -O2对程序代码优化。
  • 算法优化:降低算法复杂度,例如使用nlogn的排序算法,使用logn的查找算法等。
  • 异步处理:例如把轮询改为通知方式
  • 多线程代替多进程:某些场景下多线程可以代替多进程,因为上下文切换成本较低
  • 缓存:包括多级缓存的使用(略)加快数据访问

系统优化:

  • CPU 绑定:绑定到一个或多个 CPU 上,可以提高 CPU 缓存命中率,减少跨 CPU 调度带来的上下文切换问题
  • CPU 独占:跟 CPU 绑定类似,进一步将 CPU 分组,并通过 CPU 亲和性机制为其分配进程。
  • 优先级调整:使用 nice 调整进程的优先级,适当降低非核心应用的优先级,增高核心应用的优先级,可以确保核心应用得到优先处理。
  • 为进程设置资源限制:使用 Linux cgroups 来设置进程的 CPU 使用上限,可以防止由于某个应用自身的问题,而耗尽系统资源。
  • NUMA 优化:支持 NUMA 的处理器会被划分为多个 Node,每个 Node 有本地的内存空间,这样 CPU 可以直接访问本地空间内存。
  • 中断负载均衡:无论是软中断还是硬中断,它们的中断处理程序都可能会耗费大量的 CPU。开启 irqbalance 服务或者配置 smp_affinity,就可以把中断处理过程自动负载均衡到多个 CPU 上。

硬中断和软中断

概述

从本质上来讲,中断是一种电信号,当设备有某种事件发生时,它就会产生中断,通过总线把电信号发送给中断控制器。如果中断的线是激活的,中断控制器就把电信号发送给处理器的某个特定引脚。处理器于是立即停止自己正在做的事,跳到中断处理程序的入口点,进行中断处理。

  1. 硬中断

由与系统相连的外设(比如网卡、硬盘)自动产生的。主要是用来通知操作系统系统外设状态的变化。比如当网卡收到数据包的时候,就会发出一个中断。我们通常所说的中断指的是硬中断(hardirq)。

  1. 软中断

为了满足实时系统的要求,中断处理应该是越快越好。linux为了实现这个特点,当中断发生的时候,硬中断处理那些短时间就可以完成的工作,而将那些处理事件比较长的工作,放到中断之后来完成,也就是软中断(softirq)来完成。

  1. 中断嵌套

Linux下硬中断是可以嵌套的,但是没有优先级的概念,也就是说任何一个新的中断都可以打断正在执行的中断,但同种中断除外。软中断不能嵌套,但相同类型的软中断可以在不同CPU上并行执行。

  1. 软中断指令

int是软中断指令。中断向量表是中断号和中断处理函数地址的对应表。int n - 触发软中断n。相应的中断处理函数的地址为:中断向量表地址 + 4 * n。

  1. 硬中断和软中断的区别
  • 软中断是执行中断指令产生的,而硬中断是由外设引发的。
  • 硬中断的中断号是由中断控制器提供的,软中断的中断号由指令直接指出,无需使用中断控制器。
  • 硬中断是可屏蔽的,软中断不可屏蔽。
  • 硬中断处理程序要确保它能快速地完成任务,这样程序执行时才不会等待较长时间,称为上半部。
  • 软中断处理硬中断未完成的工作,是一种推后执行的机制,属于下半部。

开关

硬中断的开关

简单禁止和激活当前处理器上的本地中断:

1
2
local_irq_disable();
local_irq_enable();

保存本地中断系统状态下的禁止和激活:
1
2
3
unsigned long flags;
local_irq_save(flags);`
local_irq_restore(flags);

软中断的开关

禁止下半部,如softirq、tasklet和workqueue等:

1
2
local_bh_disable();
local_bh_enable();

需要注意的是,禁止下半部时仍然可以被硬中断抢占。

判断中断状态

1
2
3
#define in_interrupt() (irq_count()) // 是否处于中断状态(硬中断或软中断)
#define in_irq() (hardirq_count()) // 是否处于硬中断
#define in_softirq() (softirq_count()) // 是否处于软中断

硬中断

注册中断处理函数

注册中断处理函数:

1
2
3
4
5
6
7
8
9
10
11
/**
* irq: 要分配的中断号
* handler: 要注册的中断处理函数
* flags: 标志(一般为0)
* name: 设备名(dev->name)
* dev: 设备(struct net_device *dev),作为中断处理函数的参数
* 成功返回0
*/

int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags,
const char *name, void *dev);

中断处理函数本身:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef irqreturn_t (*irq_handler_t) (int, void *);

/**
* enum irqreturn
* @IRQ_NONE: interrupt was not from this device
* @IRQ_HANDLED: interrupt was handled by this device
* @IRQ_WAKE_THREAD: handler requests to wake the handler thread
*/
enum irqreturn {
IRQ_NONE,
IRQ_HANDLED,
IRQ_WAKE_THREAD,
};
typedef enum irqreturn irqreturn_t;
#define IRQ_RETVAL(x) ((x) != IRQ_NONE)

注销中断处理函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* free_irq - free an interrupt allocated with request_irq
* @irq: Interrupt line to free
* @dev_id: Device identity to free
*
* Remove an interrupt handler. The handler is removed and if the
* interrupt line is no longer in use by any driver it is disabled.
* On a shared IRQ the caller must ensure the interrupt is disabled
* on the card it drives before calling this function. The function does
* not return until any executing interrupts for this IRQ have completed.
* This function must not be called from interrupt context.
*/

void free_irq(unsigned int irq, void *dev_id);

软中断

定义

软中断是一组静态定义的下半部接口,可以在所有处理器上同时执行,即使两个类型相同也可以。但一个软中断不会抢占另一个软中断,唯一可以抢占软中断的是硬中断。

软中断由softirq_action结构体表示:

1
2
3
struct softirq_action {
void (*action) (struct softirq_action *); /* 软中断的处理函数 */
};

目前已注册的软中断有10种,定义为一个全局数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static struct softirq_action softirq_vec[NR_SOFTIRQS];

enum {
HI_SOFTIRQ = 0, /* 优先级高的tasklets */
TIMER_SOFTIRQ, /* 定时器的下半部 */
NET_TX_SOFTIRQ, /* 发送网络数据包 */
NET_RX_SOFTIRQ, /* 接收网络数据包 */
BLOCK_SOFTIRQ, /* BLOCK装置 */
BLOCK_IOPOLL_SOFTIRQ,
TASKLET_SOFTIRQ, /* 正常优先级的tasklets */
SCHED_SOFTIRQ, /* 调度程序 */
HRTIMER_SOFTIRQ, /* 高分辨率定时器 */
RCU_SOFTIRQ, /* RCU锁定 */
NR_SOFTIRQS /* 10 */
};

注册软中断处理函数

1
2
3
4
5
6
7
8
9
/**
* @nr: 软中断的索引号
* @action: 软中断的处理函数
*/

void open_softirq(int nr, void (*action) (struct softirq_action *))
{
softirq_vec[nr].action = action;
}

例如:

1
2
open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);

触发软中断

调用raise_softirq()来触发软中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void raise_softirq(unsigned int nr)
{
unsigned long flags;
local_irq_save(flags);
raise_softirq_irqoff(nr);
local_irq_restore(flags);
}

/* This function must run with irqs disabled */
inline void rasie_softirq_irqsoff(unsigned int nr)
{
__raise_softirq_irqoff(nr);

/* If we're in an interrupt or softirq, we're done
* (this also catches softirq-disabled code). We will
* actually run the softirq once we return from the irq
* or softirq.
* Otherwise we wake up ksoftirqd to make sure we
* schedule the softirq soon.
*/
if (! in_interrupt()) /* 如果不处于硬中断或软中断 */
wakeup_softirqd(void); /* 唤醒ksoftirqd/n进程 */
}

Percpu变量irq_cpustat_t中的__softirq_pending是等待处理的软中断的位图,通过设置此变量

即可告诉内核该执行哪些软中断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static inline void __rasie_softirq_irqoff(unsigned int nr)
{
trace_softirq_raise(nr);
or_softirq_pending(1UL << nr);
}

typedef struct {
unsigned int __softirq_pending;
unsigned int __nmi_count; /* arch dependent */
} irq_cpustat_t;

irq_cpustat_t irq_stat[];
#define __IRQ_STAT(cpu, member) (irq_stat[cpu].member)
#define or_softirq_pending(x) percpu_or(irq_stat.__softirq_pending, (x))
#define local_softirq_pending() percpu_read(irq_stat.__softirq_pending)

唤醒ksoftirqd内核线程处理软中断。

1
2
3
4
5
6
7
8
static void wakeup_softirqd(void)
{
/* Interrupts are disabled: no need to stop preemption */
struct task_struct *tsk = __get_cpu_var(ksoftirqd);

if (tsk && tsk->state != TASK_RUNNING)
wake_up_process(tsk);
}

在下列地方,待处理的软中断会被检查和执行:

  1. 从一个硬件中断代码处返回时

  2. 在ksoftirqd内核线程中

  3. 在那些显示检查和执行待处理的软中断的代码中,如网络子系统中

而不管是用什么方法唤起,软中断都要在do_softirq()中执行。如果有待处理的软中断,do_softirq()会循环遍历每一个,调用它们的相应的处理程序。在中断处理程序中触发软中断是最常见的形式。中断处理程序执行硬件设备的相关操作,然后触发相应的软中断,最后退出。内核在执行完中断处理程序以后,马上就会调用do_softirq(),于是软中断开始执行中断处理程序完成剩余的任务。

下面来看下do_softirq()的具体实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
asmlinkage void do_softirq(void)
{
__u32 pending;
unsigned long flags;

/* 如果当前已处于硬中断或软中断中,直接返回 */
if (in_interrupt())
return;

local_irq_save(flags);
pending = local_softirq_pending();
if (pending) /* 如果有激活的软中断 */
__do_softirq(); /* 处理函数 */
local_irq_restore(flags);
}

1
2
3
4
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
/* We restart softirq processing MAX_SOFTIRQ_RESTART times,
* and we fall back to softirqd after that.
* This number has been established via experimentation.
* The two things to balance is latency against fairness - we want
* to handle softirqs as soon as possible, but they should not be
* able to lock up the box.
*/
asmlinkage void __do_softirq(void)
{
struct softirq_action *h;
__u32 pending;
/* 本函数能重复触发执行的次数,防止占用过多的cpu时间 */
int max_restart = MAX_SOFTIRQ_RESTART;
int cpu;

pending = local_softirq_pending(); /* 激活的软中断位图 */
account_system_vtime(current);
/* 本地禁止当前的软中断 */
__local_bh_disable((unsigned long)__builtin_return_address(0), SOFTIRQ_OFFSET);
lockdep_softirq_enter(); /* current->softirq_context++ */
cpu = smp_processor_id(); /* 当前cpu编号 */

restart:
/* Reset the pending bitmask before enabling irqs */
set_softirq_pending(0); /* 重置位图 */
local_irq_enable();
h = softirq_vec;
do {
if (pending & 1) {
unsigned int vec_nr = h - softirq_vec; /* 软中断索引 */
int prev_count = preempt_count();
kstat_incr_softirqs_this_cpu(vec_nr);

trace_softirq_entry(vec_nr);
h->action(h); /* 调用软中断的处理函数 */
trace_softirq_exit(vec_nr);

if (unlikely(prev_count != preempt_count())) {
printk(KERN_ERR "huh, entered softirq %u %s %p" "with preempt_count %08x,"
"exited with %08x?\n", vec_nr, softirq_to_name[vec_nr], h->action, prev_count,
preempt_count());
}
rcu_bh_qs(cpu);
}
h++;
pending >>= 1;
} while(pending);

local_irq_disable();
pending = local_softirq_pending();
if (pending & --max_restart) /* 重复触发 */
goto restart;

/* 如果重复触发了10次了,接下来唤醒ksoftirqd/n内核线程来处理 */
if (pending)
wakeup_softirqd();

lockdep_softirq_exit();
account_system_vtime(current);
__local_bh_enable(SOFTIRQ_OFFSET);
}

ksoftirqd内核线程

内核不会立即处理重新触发的软中断。当大量软中断出现的时候,内核会唤醒一组内核线程来处理。这些线程的优先级最低(nice值为19),这能避免它们跟其它重要的任务抢夺资源。但它们最终肯定会被执行,所以这个折中的方案能够保证在软中断很多时用户程序不会因为得不到处理时间而处于饥饿状态,同时也保证过量的软中断最终会得到处理。

每个处理器都有一个这样的线程,名字为ksoftirqd/n,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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static int run_ksoftirqd(void *__bind_cpu)
{
set_current_state(TASK_INTERRUPTIBLE);
current->flags |= PF_KSOFTIRQD; /* I am ksoftirqd */

while(! kthread_should_stop()) {
preempt_disable();

if (! local_softirq_pending()) { /* 如果没有要处理的软中断 */
preempt_enable_no_resched();
schedule();
preempt_disable():
}

__set_current_state(TASK_RUNNING);

while(local_softirq_pending()) {
/* Preempt disable stops cpu going offline.
* If already offline, we'll be on wrong CPU: don't process.
*/
if (cpu_is_offline(long)__bind_cpu))/* 被要求释放cpu */
goto wait_to_die;

do_softirq(); /* 软中断的统一处理函数 */

preempt_enable_no_resched();
cond_resched();
preempt_disable();
rcu_note_context_switch((long)__bind_cpu);
}

preempt_enable();
set_current_state(TASK_INTERRUPTIBLE);
}

__set_current_state(TASK_RUNNING);
return 0;

wait_to_die:
preempt_enable();
/* Wait for kthread_stop */
set_current_state(TASK_INTERRUPTIBLE);
while(! kthread_should_stop()) {
schedule();
set_current_state(TASK_INTERRUPTIBLE);
}

__set_current_state(TASK_RUNNING);
return 0;
}

原文:https://blog.csdn.net/zhangskd/article/details/21992933

浅析CPU中断技术

原文:https://www.cnblogs.com/funeral/archive/2013/03/06/2945485.html

什么是CPU中断?

使用计算机的过程中,经常会遇到这么一种情景:

  1. 你正在看电影
  2. 你的朋友发来一条QQ信息
  3. 你一边回复朋友的信息,一边继续看电影

这个过程中,一切是那么的顺其自然。但理论上来说,播放电影的时候,CPU正在一丝不苟的执行着一条又一条的指令,它是如何在维持电影播放的情况下,及时接收并响应你的键盘输入信息呢?
这就是CPU中断技术在起作用。

CPU中断技术的定义如下:

计算机处于执行期间系统内发生了非寻常或非预期的急需处理事件CPU暂时中断当前正在执行的程序而转去执行相应的事件处理程序处理完毕后返回原来被中断处继续执行。

在这里,“非寻常或非预期的事件”指的就是你回复朋友的QQ时,用键盘键入信息。为了及时响应你键入的信息,CPU将正在执行的任务“播放电影”暂时中断,处理完你键入的信息后,继续执行“播放电影”的任务。由于这个“中断当前任务->响应键盘输入->继续当前任务”的执行周期非常短(一般都是微秒级),所以一般人感觉不出来。

CPU中断的作用

早期的CPU处理外设的事件(比如接收键盘输入),往往采用“轮询”的方式。即CPU像个查岗的一样轮番对外设顺序访问,比如它先看看键盘有没被按下,有的话就处理,没的话继续往下看鼠标有没有移动,再看看打印机……这种方式使CPU的执行效率很低,且CPU与外设不能同时工作(因为要等待CPU来“巡查”)。

中断模式时就是说CPU不主动访问这些设备,只管处理自己的任务。如果有设备要与CPU联系,或要CPU处理一些事情,它会给CPU发一个中断请求信号。这时CPU就会放下正在进行的工作而去处理这个外设的请求。处理完中断后,CPU返回去继续执行中断以前的工作。

中断模式的作用和优点在于:

  1. 可以使CPU和外设同时工作,使系统可以及时地响应外部事件。
  2. 可允许多个外设同时工作,大大提高了CPU的利用率,也提高了数据输入、输出的速度。
  3. 可以使CPU及时处理各种软硬件故障(比如计算机在运行过程中,出现了难以预料的情况或一些故障,如电源掉电、存储出错、运算溢出等等。计算机可以利用中断系统自行处理,而不必停机或报告工作人员。)

CPU中断的类型

在计算机系统中,根据中断源的不同,通常将中断分为两大类:

  1. 硬件中断
  2. 软件中断

硬件中断

硬件中断又称外部中断,主要分为两种:可屏蔽中断、非屏蔽中断。
可屏蔽中断:

  1. 常由计算机的外设或一些接口功能产生,如键盘、打印机、串行口等
  2. 这种类型的中断可以在CPU要处理其它紧急操作时,被软件屏蔽或忽略

非屏蔽中断:

  1. 由意外事件导致,如电源断电、内存校验错误等
  2. 对于这种类型的中断事件,无法通过软件进行屏蔽,CPU必须无条件响应

在x86架构的处理器中,CPU的中断控制器由两根引脚(INTR和NMI)接收外部中断请求信号。其中:

  1. INTR接收可屏蔽中断请求
  2. NMI接收非屏蔽中断请求

典型事例:

  1. 典型的可屏蔽中断的例子是打印机中断,CPU对打印机中断请求的响应可以快一些,也可以慢一些,因为让打印机稍等待一会也是完全合理的。
  2. 典型的非屏蔽中断的例子是电源断电,一旦出现此中断请求,必须立即无条件地响应,否则进行其他任何工作都是没有意义的。

软件中断

软件中断又称内部中断,是指在程序中调用INTR中断指令引起的中断。比如winAPI中,keybd_event和mouse_event两个函数,就是用来模拟键盘和鼠标的输入(这个仅为笔者本人的猜测)。

CPU中断的过程

中断请求

中断请求是由中断源向CPU发出中断请求信号。外部设备发出中断请求信号要具备以下两个条件:

  1. 外部设备的工作已经告一段落。例如输入设备只有在启动后,将要输入的数据送到接口电路的数据寄存器(即准备好要输入的数据)之后,才可以向CPU发出中断请求。
  2. 系统允许该外设发出中断请求。如果系统不允许该外设发出中断请求,可以将这个外设的请求屏蔽。当这个外设中断请求被屏蔽,虽然这个外设准备工作已经完成,也不能发出中断请求。

中断响应、处理和返回

当满足了中断的条件后,CPU就会响应中断,转入中断程序处理。具体的工作过程如下:

  1. 关闭中断信号接收器
  2. 保存现场(context)
  3. 给出中断入口,转入相应的中断服务程序
  4. 处理完成,返回并恢复现场(context)
  5. 开启中断信号接收器

中断排队和中断判优

  1. 中断申请是随机的,有时会出现多个中断源同时提出中断申请。
  2. CPU每次只能响应一个中断源的请求。
  3. CPU不可能对所有中断请求一视同仁,它会根据各中断源工作性质的轻重缓急,预先安排一个优先级顺序。当多个中断源同时申请中断时,即按此优先级顺序进行排队,等候CPU处理。

了解了CPU中断处理的过程,就不难理解下面一种常见的情景:

正在拷贝文件时,往某个文本框输入信息,这个文本框会出现短暂的假死,键盘输入的数据不能及时显示在文本框中,需要等一会儿才能逐渐显示出来。这是因为该中断操作(往文本框输入信息)在中断队列的优先级比较低,或者CPU认为正在处理的操作(拷贝文件)进行挂起的代价太大,所以只有等到CPU到了一个挂起代价较低的点,才会挂起当前操作,处理本次中断信息。

多核CPU对中断的处理

多核CPU的中断处理和单核有很大不同。多核的各处理器核心之间需要通过中断方式进行通信,所以CPU芯片内部既有各处理器核心的本地中断控制器,又有负责仲裁各核之间中断分配的全局中断控制器。

现今的多核处理器在中断处理和中断控制方面主要使用的是APIC(Advanced Programmable Interrupt Controllers),即高级编程中断控制器。它是基于中断控制器两个基础功能单元——本地单元以及I/O单元的分布式体系结构。在多核系统中,多个本地和I/O APIC单元能够作为一个整体通过APIC总线互相操作。

APIC的功能有:

  1. 接受来自处理器中断引脚的内部或外部I/O APIC的中断,然后将这些中断发送给处理器核心进行处理
  2. 在多核处理器系统中,接收和发送核内中断消息

对于外部设备发出的中断请求,由全局中断控制器接收请求并决定交给CPU的哪一个核心处理。也可针对APIC编程,让所有的中断都被一个固定的CPU处理。

Linux中断子系统

声明:本博内容均由http://blog.csdn.net/droidphone原创。

设备、中断控制器和CPU

一个完整的设备中,与中断相关的硬件可以划分为3类,它们分别是:设备、中断控制器和CPU本身,下图展示了一个smp系统中的中断硬件的组成结构:

设备:设备是发起中断的源,当设备需要请求某种服务的时候,它会发起一个硬件中断信号,通常,该信号会连接至中断控制器,由中断控制器做进一步的处理。在现代的移动设备中,发起中断的设备可以位于soc(system-on-chip)芯片的外部,也可以位于soc的内部,因为目前大多数soc都集成了大量的硬件IP,例如I2C、SPI、Display Controller等等。

中断控制器:中断控制器负责收集所有中断源发起的中断,现有的中断控制器几乎都是可编程的,通过对中断控制器的编程,我们可以控制每个中断源的优先级、中断的电器类型,还可以打开和关闭某一个中断源,在smp系统中,甚至可以控制某个中断源发往哪一个CPU进行处理。对于ARM架构的soc,使用较多的中断控制器是VIC(Vector Interrupt Controller),进入多核时代以后,GIC(General Interrupt Controller)的应用也开始逐渐变多。

CPU:CPU是最终响应中断的部件,它通过对可编程中断控制器的编程操作,控制和管理者系统中的每个中断,当中断控制器最终判定一个中断可以被处理时,他会根据事先的设定,通知其中一个或者是某几个cpu对该中断进行处理,虽然中断控制器可以同时通知数个cpu对某一个中断进行处理,实际上,最后只会有一个cpu相应这个中断请求,但具体是哪个cpu进行响应是可能是随机的,中断控制器在硬件上对这一特性进行了保证,不过这也依赖于操作系统对中断系统的软件实现。在smp系统中,cpu之间也通过IPI(inter processor interrupt)中断进行通信。

IRQ编号

系统中每一个注册的中断源,都会分配一个唯一的编号用于识别该中断,我们称之为IRQ编号。IRQ编号贯穿在整个Linux的通用中断子系统中。在移动设备中,每个中断源的IRQ编号都会在arch相关的一些头文件中,例如arch/xxx/mach-xxx/include/irqs.h。驱动程序在请求中断服务时,它会使用IRQ编号注册该中断,中断发生时,cpu通常会从中断控制器中获取相关信息,然后计算出相应的IRQ编号,然后把该IRQ编号传递到相应的驱动程序中。

在驱动程序中申请中断

Linux中断子系统向驱动程序提供了一系列的API,其中的一个用于向系统申请中断:

1
2
3
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
irq_handler_t thread_fn, unsigned long irqflags,
const char *devname, void *dev_id)

其中,

  • irq是要申请的IRQ编号,
  • handler是中断处理服务函数,该函数工作在中断上下文中,如果不需要,可以传入NULL,但是不可以和thread_fn同时为NULL;
  • thread_fn是中断线程的回调函数,工作在内核进程上下文中,如果不需要,可以传入NULL,但是不可以和handler同时为NULL;
  • irqflags是该中断的一些标志,可以指定该中断的电气类型,是否共享等信息;
  • devname指定该中断的名称;
  • dev_id用于共享中断时的cookie data,通常用于区分共享中断具体由哪个设备发起;

关于该API的详细工作机理我们后面再讨论。

通用中断子系统(Generic irq)的软件抽象

在通用中断子系统(generic irq)出现之前,内核使用do_IRQ处理所有的中断,这意味着do_IRQ中要处理各种类型的中断,这会导致软件的复杂性增加,层次不分明,而且代码的可重用性也不好。事实上,到了内核版本2.6.38,__do_IRQ这种方式已经彻底在内核的代码中消失了。通用中断子系统的原型最初出现于ARM体系中,一开始内核的开发者们把3种中断类型区分出来,他们是:

  • 电平触发中断(level type)
  • 边缘触发中断(edge type)
  • 简易的中断(simple type)

后来又针对某些需要回应eoi(end of interrupt)的中断控制器,加入了fast eoi type,针对smp加入了per cpu type。把这些不同的中断类型抽象出来后,成为了中断子系统的流控层。要使所有的体系架构都可以重用这部分的代码,中断控制器也被进一步地封装起来,形成了中断子系统中的硬件封装层。我们可以用下面的图示表示通用中断子系统的层次结构:

硬件封装层 它包含了体系架构相关的所有代码,包括中断控制器的抽象封装,arch相关的中断初始化,以及各个IRQ的相关数据结构的初始化工作,cpu的中断入口也会在arch相关的代码中实现。中断通用逻辑层通过标准的封装接口(实际上就是struct irq_chip定义的接口)访问并控制中断控制器的行为,体系相关的中断入口函数在获取IRQ编号后,通过中断通用逻辑层提供的标准函数,把中断调用传递到中断流控层中。我们看看irq_chip的部分定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct irq_chip {
const char *name;
unsigned int (*irq_startup)(struct irq_data *data);
void (*irq_shutdown)(struct irq_data *data);
void (*irq_enable)(struct irq_data *data);
void (*irq_disable)(struct irq_data *data);

void (*irq_ack)(struct irq_data *data);
void (*irq_mask)(struct irq_data *data);
void (*irq_mask_ack)(struct irq_data *data);
void (*irq_unmask)(struct irq_data *data);
void (*irq_eoi)(struct irq_data *data);

int (*irq_set_affinity)(struct irq_data *data, const struct cpumask *dest, bool force);
int (*irq_retrigger)(struct irq_data *data);
int (*irq_set_type)(struct irq_data *data, unsigned int flow_type);
int (*irq_set_wake)(struct irq_data *data, unsigned int on);
......
};

看到上面的结构定义,很明显,它实际上就是对中断控制器的接口抽象,我们只要对每个中断控制器实现以上接口(不必全部),并把它和相应的irq关联起来,上层的实现即可通过这些接口访问中断控制器。而且,同一个中断控制器的代码可以方便地被不同的平台所重用。

中断流控层:所谓中断流控是指合理并正确地处理连续发生的中断,比如一个中断在处理中,同一个中断再次到达时如何处理,何时应该屏蔽中断,何时打开中断,何时回应中断控制器等一系列的操作。该层实现了与体系和硬件无关的中断流控处理操作,它针对不同的中断电气类型(level,edge……),实现了对应的标准中断流控处理函数,在这些处理函数中,最终会把中断控制权传递到驱动程序注册中断时传入的处理函数或者是中断线程中。目前内核提供了以下几个主要的中断流控函数的实现(只列出部分):

  • handle_simple_irq();
  • handle_level_irq(); 电平中断流控处理程序
  • handle_edge_irq(); 边沿触发中断流控处理程序
  • handle_fasteoi_irq(); 需要eoi的中断处理器使用的中断流控处理程序
  • handle_percpu_irq(); 该irq只有单个cpu响应时使用的流控处理程序

中断通用逻辑层:该层实现了对中断系统几个重要数据的管理,并提供了一系列的辅助管理函数。同时,该层还实现了中断线程的实现和管理,共享中断和嵌套中断的实现和管理,另外它还提供了一些接口函数,它们将作为硬件封装层和中断流控层以及驱动程序API层之间的桥梁,例如以下API:

  • generic_handle_irq();
  • irq_to_desc();
  • irq_set_chip();
  • irq_set_chained_handler();

驱动程序API:该部分向驱动程序提供了一系列的API,用于向系统申请/释放中断,打开/关闭中断,设置中断类型和中断唤醒系统的特性等操作。驱动程序的开发者通常只会使用到这一层提供的这些API即可完成驱动程序的开发工作,其他的细节都由另外几个软件层较好地“隐藏”起来了,驱动程序开发者无需再关注底层的实现,这看起来确实是一件美妙的事情,不过我认为,要想写出好的中断代码,还是花点时间了解一下其他几层的实现吧。其中的一些API如下:

  • enable_irq();
  • disable_irq();
  • disable_irq_nosync();
  • request_threaded_irq();
  • irq_set_affinity();

irq描述结构:struct irq_desc

整个通用中断子系统几乎都是围绕着irq_desc结构进行,系统中每一个irq都对应着一个irq_desc结构,所有的irq_desc结构的组织方式有两种:

基于数组方式:平台相关板级代码事先根据系统中的IRQ数量,定义常量:NR_IRQS,在kernel/irq/irqdesc.c中使用该常量定义irq_desc结构数组:

1
2
3
4
5
6
7
struct irq_desc irq_desc[NR_IRQS] __cacheline_aligned_in_smp = {
[0 ... NR_IRQS-1] = {
.handle_irq = handle_bad_irq,
.depth = 1,
.lock = __RAW_SPIN_LOCK_UNLOCKED(irq_desc->lock),
}
};

基于基数树方式:当内核的配置项CONFIG_SPARSE_IRQ被选中时,内核使用基数树(radix tree)来管理irq_desc结构,这一方式可以动态地分配irq_desc结构,对于那些具备大量IRQ数量或者IRQ编号不连续的系统,使用该方式管理irq_desc对内存的节省有好处,而且对那些自带中断控制器管理设备自身多个中断源的外部设备,它们可以在驱动程序中动态地申请这些中断源所对应的irq_desc结构,而不必在系统的编译阶段保留irq_desc结构所需的内存。
下面我们看一看irq_desc的部分定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct irq_data {
unsigned int irq;
unsigned long hwirq;
unsigned int node;
unsigned int state_use_accessors;
struct irq_chip *chip;
struct irq_domain *domain;
void *handler_data;
void *chip_data;
struct msi_desc *msi_desc;
#ifdef CONFIG_SMP
cpumask_var_t affinity;
#endif
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct irq_desc {
struct irq_data irq_data;
unsigned int __percpu *kstat_irqs;
irq_flow_handler_t handle_irq;
#ifdef CONFIG_IRQ_PREFLOW_FASTEOI
irq_preflow_handler_t preflow_handler;
#endif
struct irqaction *action; /* IRQ action list */
unsigned int status_use_accessors;
unsigned int depth; /* nested irq disables */
unsigned int wake_depth; /* nested wake enables */
unsigned int irq_count; /* For detecting broken IRQs */

raw_spinlock_t lock;
struct cpumask *percpu_enabled;
#ifdef CONFIG_SMP
const struct cpumask *affinity_hint;
struct irq_affinity_notify *affinity_notify;
#ifdef CONFIG_GENERIC_PENDING_IRQ
cpumask_var_t pending_mask;
#endif
#endif
wait_queue_head_t wait_for_threads;

const char *name;
} ____cacheline_internodealigned_in_smp;

对于irq_desc中的主要字段做一个解释:

  • irq_data 这个内嵌结构在2.6.37版本引入,之前的内核版本的做法是直接把这个结构中的字段直接放置在irq_desc结构体中,然后在调用硬件封装层的chip->xxx()回调中传入IRQ编号作为参数,但是底层的函数经常需要访问->handler_data,->chip_data,->msi_desc等字段,这需要利用irq_to_desc(irq)来获得irq_desc结构的指针,然后才能访问上述字段,者带来了性能的降低,尤其在配置为sparse irq的系统中更是如此,因为这意味着基数树的搜索操作。为了解决这一问题,内核开发者把几个低层函数需要使用的字段单独封装为一个结构,调用时的参数则改为传入该结构的指针。实现同样的目的,那为什么不直接传入irq_desc结构指针?因为这会破坏层次的封装性,我们不希望低层代码可以看到不应该看到的部分,仅此而已。
  • kstat_irqs 用于irq的一些统计信息,这些统计信息可以从proc文件系统中查询。
  • action 中断响应链表,当一个irq被触发时,内核会遍历该链表,调用action结构中的回调handler或者激活其中的中断线程,之所以实现为一个链表,是为了实现中断的共享,多个设备共享同一个irq,这在外围设备中是普遍存在的。
  • status_use_accessors 记录该irq的状态信息,内核提供了一系列irq_settings_xxx的辅助函数访问该字段,详细请查看kernel/irq/settings.h
  • depth 用于管理enable_irq()/disable_irq()这两个API的嵌套深度管理,每次enable_irq时该值减去1,每次disable_irq时该值加1,只有depth==0时才真正向硬件封装层发出关闭irq的调用,只有depth==1时才会向硬件封装层发出打开irq的调用。disable的嵌套次数可以比enable的次数多,此时depth的值大于1,随着enable的不断调用,当depth的值为1时,在向硬件封装层发出打开irq的调用后,depth减去1后,此时depth为0,此时处于一个平衡状态,我们只能调用disable_irq,如果此时enable_irq被调用,内核会报告一个irq失衡的警告,提醒驱动程序的开发人员检查自己的代码。
  • lock 用于保护irq_desc结构本身的自旋锁。
  • affinity_hit 用于提示用户空间,作为优化irq和cpu之间的亲缘关系的依据。
  • pending_mask 用于调整irq在各个cpu之间的平衡。
  • wait_for_threads 用于synchronize_irq(),等待该irq所有线程完成。

irq_data结构中的各字段:

  • irq 该结构所对应的IRQ编号。
  • hwirq 硬件irq编号,它不同于上面的irq;
  • node 通常用于hwirq和irq之间的映射操作;
  • state_use_accessors 硬件封装层需要使用的状态信息,不要直接访问该字段,内核定义了一组函数用于访问该字段:irqd_xxxx(),参见include/linux/irq.h。
  • chip 指向该irq所属的中断控制器的irq_chip结构指针
  • handler_data 每个irq的私有数据指针,该字段由硬件封转层使用,例如用作底层硬件的多路复用中断。
  • chip_data 中断控制器的私有数据,该字段由硬件封转层使用。
  • msi_desc 用于PCIe总线的MSI或MSI-X中断机制。
  • affinity 记录该irq与cpu之间的亲缘关系,它其实是一个bit-mask,每一个bit代表一个cpu,置位后代表该cpu可能处理该irq。

这是通用中断子系统系列文章的第一篇,这里不会详细介绍各个软件层次的实现原理,但是有必要对整个架构做简要的介绍:

  • 系统启动阶段,取决于内核的配置,内核会通过数组或基数树分配好足够多的irq_desc结构;
  • 根据不同的体系结构,初始化中断相关的硬件,尤其是中断控制器;
  • 为每个必要irq的irq_desc结构填充默认的字段,例如irq编号,irq_chip指针,根据不同的中断类型配置流控handler;
  • 设备驱动程序在初始化阶段,利用request_threaded_irq() api申请中断服务,两个重要的参数是handler和thread_fn;
  • 当设备触发一个中断后,cpu会进入事先设定好的中断入口,它属于底层体系相关的代码,它通过中断控制器获得irq编号,在对irq_data结构中的某些字段进行处理后,会将控制权传递到中断流控层(通过irq_desc->handle_irq);
  • 中断流控处理代码在作出必要的流控处理后,通过irq_desc->action链表,取出驱动程序申请中断时注册的handler和thread_fn,根据它们的赋值情况,或者只是调用handler回调,或者启动一个线程执行thread_fn,又或者两者都执行;
  • 至此,中断最终由驱动程序进行了响应和处理。

中断子系统的proc文件接口

在/proc目录下面,有两个与中断子系统相关的文件和子目录,它们是:

  • /proc/interrupts:文件
  • /proc/irq:子目录

读取interrupts会依次显示irq编号,每个cpu对该irq的处理次数,中断控制器的名字,irq的名字,以及驱动程序注册该irq时使用的名字,以下是一个例子:

/proc/irq目录下面会为每个注册的irq创建一个以irq编号为名字的子目录,每个子目录下分别有以下条目:

  • smp_affinity irq和cpu之间的亲缘绑定关系;
  • smp_affinity_hint 只读条目,用于用户空间做irq平衡只用;
  • spurious 可以获得该irq被处理和未被处理的次数的统计信息;
  • handler_name 驱动程序注册该irq时传入的处理程序的名字;

根据irq的不同,以上条目不一定会全部都出现,以下是某个设备的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
## cd /proc/irq
## ls
ls
332
248
......
......
12
11
default_smp_affinity


## ls 332
bcmsdh_sdmmc
spurious
node
affinity_hint
smp_affinity


## cat 332/smp_affinity
3

可见,以上设备是一个使用双核cpu的设备,因为smp_affinity的值是3,系统默认每个中断可以由两个cpu进行处理。

Linux 中的各种栈

栈 (stack) 是一种串列形式的 数据结构。这种数据结构的特点是 后入先出 (LIFO, Last In First Out),数据只能在串列的一端 (称为:栈顶 top) 进行 推入 (push) 和 弹出 (pop) 操作。根据栈的特点,很容易的想到可以利用数组,来实现这种数据结构。但是本文要讨论的并不是软件层面的栈,而是硬件层面的栈。

大多数的处理器架构,都有实现硬件栈。有专门的栈指针寄存器,以及特定的硬件指令来完成 入栈/出栈 的操作。例如在 ARM 架构上,R13 (SP) 指针是堆栈指针寄存器,而 PUSH 是用于压栈的汇编指令,POP 则是出栈的汇编指令。

【扩展阅读】:ARM 寄存器简介

ARM 处理器拥有 37 个寄存器。 这些寄存器按部分重叠组方式加以排列。 每个处理器模式都有一个不同的寄存器组。 编组的寄存器为处理处理器异常和特权操作提供了快速的上下文切换。

提供了下列寄存器:

  • 三十个 32 位通用寄存器:
  • 存在十五个通用寄存器,它们分别是 r0-r12、sp、lr
  • sp (r13) 是堆栈指针。C/C++ 编译器始终将 sp 用作堆栈指针
  • lr (r14) 用于存储调用子例程时的返回地址。如果返回地址存储在堆栈上,则可将 lr 用作通用寄存器
  • 程序计数器 (pc):指令寄存器
  • 应用程序状态寄存器 (APSR):存放算术逻辑单元 (ALU) 状态标记的副本
  • 当前程序状态寄存器 (CPSR):存放 APSR 标记,当前处理器模式,中断禁用标记等
  • 保存的程序状态寄存器 (SPSR):当发生异常时,使用 SPSR 来存储 CPSR

上面是栈的原理和实现,下面我们来看看栈有什么作用。栈作用可以从两个方面体现:函数调用多任务支持

我们知道一个函数调用有以下三个基本过程:

  • 调用参数的传入
  • 局部变量的空间管理
  • 函数返回

函数的调用必须是高效的,而数据存放在 CPU通用寄存器 或者 RAM 内存 中无疑是最好的选择。以传递调用参数为例,我们可以选择使用 CPU通用寄存器 来存放参数。但是通用寄存器的数目都是有限的,当出现函数嵌套调用时,子函数再次使用原有的通用寄存器必然会导致冲突。因此如果想用它来传递参数,那在调用子函数前,就必须先 保存原有寄存器的值,然后当子函数退出的时候再 恢复原有寄存器的值 。

函数的调用参数数目一般都相对少,因此通用寄存器是可以满足一定需求的。但是局部变量的数目和占用空间都是比较大的,再依赖有限的通用寄存器未免强人所难,因此我们可以采用某些 RAM 内存区域来存储局部变量。但是存储在哪里合适?既不能让函数嵌套调用的时候有冲突,又要注重效率。

这种情况下,栈无疑提供很好的解决办法。一、对于通用寄存器传参的冲突,我们可以再调用子函数前,将通用寄存器临时压入栈中;在子函数调用完毕后,在将已保存的寄存器再弹出恢复回来。二、而局部变量的空间申请,也只需要向下移动下栈顶指针;将栈顶指针向回移动,即可就可完成局部变量的空间释放;三、对于函数的返回,也只需要在调用子函数前,将返回地址压入栈中,待子函数调用结束后,将函数返回地址弹出给 PC 指针,即完成了函数调用的返回;

于是上述函数调用的三个基本过程,就演变记录一个栈指针的过程。每次函数调用的时候,都配套一个栈指针。即使循环嵌套调用函数,只要对应函数栈指针是不同的,也不会出现冲突。

【扩展阅读】:函数栈帧 (Stack Frame)

函数调用经常是嵌套的,在同一时刻,栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等,函数调用时入栈的顺序为:

实参N~1 → 主调函数返回地址 → 主调函数帧基指针EBP → 被调函数局部变量1~N

栈帧的边界由 栈帧基地址指针 EBP 和 栈指针 ESP 界定,EBP 指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。函数调用栈的典型内存布局如下图所示:

然而栈的意义还不只是函数调用,有了它的存在,才能构建出操作系统的多任务模式。我们以 main 函数调用为例,main 函数包含一个无限循环体,循环体中先调用 A 函数,再调用 B 函数。

1
2
3
4
5
6
7
8
9
func B():
return;

func A():
B();

func main():
while (1)
A();

试想在单处理器情况下,程序将永远停留在此 main 函数中。即使有另外一个任务在等待状态,程序是没法从此 main 函数里面跳转到另一个任务。因为如果是函数调用关系,本质上还是属于 main 函数的任务中,不能算多任务切换。此刻的 main 函数任务本身其实和它的栈绑定在了一起,无论如何嵌套调用函数,栈指针都在本栈范围内移动。

由此可以看出一个任务可以利用以下信息来表征:

  1. main 函数体代码
  2. main 函数栈指针
  3. 当前 CPU 寄存器信息

假如我们可以保存以上信息,则完全可以强制让出 CPU 去处理其他任务。只要将来想继续执行此 main 任务的时候,把上面的信息恢复回去即可。有了这样的先决条件,多任务就有了存在的基础,也可以看出栈存在的另一个意义。在多任务模式下,当调度程序认为有必要进行任务切换的话,只需保存任务的信息(即上面说的三个内容)。恢复另一个任务的状态,然后跳转到上次运行的位置,就可以恢复运行了。

可见每个任务都有自己的栈空间,正是有了独立的栈空间,为了代码重用,不同的任务甚至可以混用任务的函数体本身,例如可以一个main函数有两个任务实例。至此之后的操作系统的框架也形成了,譬如任务在调用 sleep() 等待的时候,可以主动让出 CPU 给别的任务使用,或者分时操作系统任务在时间片用完是也会被迫的让出 CPU。不论是哪种方法,只要想办法切换任务的上下文空间,切换栈即可。

进程栈

进程栈是属于用户态栈,和进程 虚拟地址空间 (Virtual Address Space) 密切相关。那我们先了解下什么是虚拟地址空间:在 32 位机器下,虚拟地址空间大小为 4G。这些虚拟地址通过页表 (Page Table) 映射到物理内存,页表由操作系统维护,并被处理器的内存管理单元 (MMU) 硬件引用。每个进程都拥有一套属于它自己的页表,因此对于每个进程而言都好像独享了整个虚拟地址空间。

Linux 内核将这 4G 字节的空间分为两部分,将最高的 1G 字节(0xC0000000-0xFFFFFFFF)供内核使用,称为 内核空间。而将较低的3G字节(0x00000000-0xBFFFFFFF)供各个进程使用,称为 用户空间。每个进程可以通过系统调用陷入内核态,因此内核空间是由所有进程共享的。虽然说内核和用户态进程占用了这么大地址空间,但是并不意味它们使用了这么多物理内存,仅表示它可以支配这么大的地址空间。它们是根据需要,将物理内存映射到虚拟地址空间中使用。

Linux 对进程地址空间有个标准布局,地址空间中由各个不同的内存段组成 (Memory Segment),主要的内存段如下:

  • 程序段 (Text Segment):可执行文件代码的内存映射
  • 数据段 (Data Segment):可执行文件的已初始化全局变量的内存映射
  • BSS段 (BSS Segment):未初始化的全局变量或者静态变量(用零页初始化)
  • 堆区 (Heap) : 存储动态内存分配,匿名的内存映射
  • 栈区 (Stack) : 进程用户空间栈,由编译器自动分配释放,存放函数的参数值、局部变量的值等
  • 映射段(Memory Mapping Segment):任何内存映射文件

而上面进程虚拟地址空间中的栈区,正指的是我们所说的进程栈。进程栈的初始化大小是由编译器和链接器计算出来的,但是栈的实时大小并不是固定的,Linux 内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。但是并不是说栈区可以无限增长,它也有最大限制 RLIMIT_STACK (一般为 8M),我们可以通过 ulimit 来查看或更改 RLIMIT_STACK 的值。

【扩展阅读】:如何确认进程栈的大小

我们要知道栈的大小,那必须得知道栈的起始地址和结束地址。栈起始地址 获取很简单,只需要嵌入汇编指令获取栈指针 esp 地址即可。栈结束地址 的获取有点麻烦,我们需要先利用递归函数把栈搞溢出了,然后再 GDB 中把栈溢出的时候把栈指针 esp 打印出来即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* file name: stacksize.c */

void *orig_stack_pointer;

void blow_stack() {
blow_stack();
}

int main() {
__asm__("movl %esp, orig_stack_pointer");

blow_stack();
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ g++ -g stacksize.c -o ./stacksize
$ gdb ./stacksize
(gdb) r
Starting program: /home/home/misc-code/setrlimit

Program received signal SIGSEGV, Segmentation fault.
blow_stack () at setrlimit.c:4
4 blow_stack();
(gdb) print (void *)$esp
$1 = (void *) 0xffffffffff7ff000
(gdb) print (void *)orig_stack_pointer
$2 = (void *) 0xffffc800
(gdb) print 0xffffc800-0xff7ff000
$3 = 8378368 // Current Process Stack Size is 8M

上面对进程的地址空间有个比较全局的介绍,那我们看下 Linux 内核中是怎么体现上面内存布局的。内核使用内存描述符来表示进程的地址空间,该描述符表示着进程所有地址空间的信息。内存描述符由 mm_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
struct mm_struct {
struct vm_area_struct *mmap; /* 内存区域链表 */
struct rb_root mm_rb; /* VMA 形成的红黑树 */
...
struct list_head mmlist; /* 所有 mm_struct 形成的链表 */
...
unsigned long total_vm; /* 全部页面数目 */
unsigned long locked_vm; /* 上锁的页面数据 */
unsigned long pinned_vm; /* Refcount permanently increased */
unsigned long shared_vm; /* 共享页面数目 Shared pages (files) */
unsigned long exec_vm; /* 可执行页面数目 VM_EXEC & ~VM_WRITE */
unsigned long stack_vm; /* 栈区页面数目 VM_GROWSUP/DOWN */
unsigned long def_flags;
unsigned long start_code, end_code, start_data, end_data; /* 代码段、数据段 起始地址和结束地址 */
unsigned long start_brk, brk, start_stack; /* 栈区 的起始地址,堆区 起始地址和结束地址 */
unsigned long arg_start, arg_end, env_start, env_end; /* 命令行参数 和 环境变量的 起始地址和结束地址 */
...
/* Architecture-specific MM context */
mm_context_t context; /* 体系结构特殊数据 */

/* Must use atomic bitops to access the bits */
unsigned long flags; /* 状态标志位 */
...
/* Coredumping and NUMA and HugePage 相关结构体 */
};

【扩展阅读】:进程栈的动态增长实现

进程在运行的过程中,通过不断向栈区压入数据,当超出栈区容量时,就会耗尽栈所对应的内存区域,这将触发一个 缺页异常 (page fault)。通过异常陷入内核态后,异常会被内核的 expand_stack() 函数处理,进而调用 acct_stack_growth() 来检查是否还有合适的地方用于栈的增长。

如果栈的大小低于 RLIMIT_STACK(通常为8MB),那么一般情况下栈会被加长,程序继续执行,感觉不到发生了什么事情,这是一种将栈扩展到所需大小的常规机制。然而,如果达到了最大栈空间的大小,就会发生 栈溢出(stack overflow),进程将会收到内核发出的 段错误(segmentation fault) 信号。

动态栈增长是唯一一种访问未映射内存区域而被允许的情形,其他任何对未映射内存区域的访问都会触发页错误,从而导致段错误。一些被映射的区域是只读的,因此企图写这些区域也会导致段错误。

线程栈

从 Linux 内核的角度来说,其实它并没有线程的概念。Linux 把所有线程都当做进程来实现,它将线程和进程不加区分的统一到了 task_struct 中。线程仅仅被视为一个与其他进程共享某些资源的进程,而是否共享地址空间几乎是进程和 Linux 中所谓线程的唯一区别。线程创建的时候,加上了 CLONE_VM 标记,这样 线程的内存描述符 将直接指向 父进程的内存描述符。

1
2
3
4
5
6
7
if (clone_flags & CLONE_VM) {
/*
* current 是父进程而 tsk 在 fork() 执行期间是共享子进程
*/
atomic_inc(&current->mm->mm_users);
tsk->mm = current->mm;
}

虽然线程的地址空间和进程一样,但是对待其地址空间的 stack 还是有些区别的。对于 Linux 进程或者说主线程,其 stack 是在 fork 的时候生成的,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长。然而对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是事先固定下来的,使用 mmap 系统调用,它不带有 VM_STACK_FLAGS 标记。这个可以从 glibc 的nptl/allocatestack.c 中的 allocate_stack() 函数中看到:

1
2
mem = mmap (NULL, size, prot,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

由于线程的 mm->start_stack 栈地址和所属进程相同,所以线程栈的起始地址并没有存放在 task_struct 中,应该是使用 pthread_attr_t 中的 stackaddr 来初始化 task_struct->thread->sp(sp 指向 struct pt_regs 对象,该结构体用于保存用户进程或者线程的寄存器现场)。这些都不重要,重要的是,线程栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。由于线程栈是从进程的地址空间中 map 出来的一块内存区域,原则上是线程私有的。但是同一个进程的所有线程生成的时候浅拷贝生成者的 task_struct 的很多字段,其中包括所有的 vma,如果愿意,其它线程也还是可以访问到的,于是一定要注意。

进程内核栈

在每一个进程的生命周期中,必然会通过到系统调用陷入内核。在执行系统调用陷入内核之后,这些内核代码所使用的栈并不是原先进程用户空间中的栈,而是一个单独内核空间的栈,这个称作进程内核栈。进程内核栈在进程创建的时候,通过 slab 分配器从 thread_info_cache 缓存池中分配出来,其大小为 THREAD_SIZE,一般来说是一个页大小 4K;

1
2
3
4
5
6
7
8
9
10
union thread_union {                                   
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
````

thread_union 进程内核栈 和 task_struct 进程描述符有着紧密的联系。由于内核经常要访问 task_struct,高效获取当前进程的描述符是一件非常重要的事情。因此内核将进程内核栈的头部一段空间,用于存放 thread_info 结构体,而此结构体中则记录了对应进程的描述符,两者关系如下图(对应内核函数为 dup_task_struct()):
![](/img/20160901215111055.png)

有了上述关联结构后,内核可以先获取到栈顶指针 esp,然后通过 esp 来获取 thread_info。这里有一个小技巧,直接将 esp 的地址与上 ~(THREAD_SIZE - 1) 后即可直接获得 thread_info 的地址。由于 thread_union 结构体是从 thread_info_cache 的 Slab 缓存池中申请出来的,而 thread_info_cache 在 kmem_cache_create 创建的时候,保证了地址是 THREAD_SIZE 对齐的。因此只需要对栈指针进行 THREAD_SIZE 对齐,即可获得 thread_union 的地址,也就获得了 thread_union 的地址。成功获取到 thread_info 后,直接取出它的 task 成员就成功得到了 task_struct。其实上面这段描述,也就是 current 宏的实现方法:

register unsigned long current_stack_pointer asm (“sp”);

static inline struct thread_info current_thread_info(void)
{
return (struct thread_info
)
(current_stack_pointer & ~(THREAD_SIZE - 1));
}

define get_current() (current_thread_info()->task)

define current get_current()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

## 中断栈
进程陷入内核态的时候,需要内核栈来支持内核函数调用。中断也是如此,当系统收到中断事件后,进行中断处理的时候,也需要中断栈来支持函数调用。由于系统中断的时候,系统当然是处于内核态的,所以中断栈是可以和内核栈共享的。但是具体是否共享,这和具体处理架构密切相关。

X86 上中断栈就是独立于内核栈的;独立的中断栈所在内存空间的分配发生在 arch/x86/kernel/irq_32.c 的 irq_ctx_init() 函数中(如果是多处理器系统,那么每个处理器都会有一个独立的中断栈),函数使用 __alloc_pages 在低端内存区分配 2个物理页面,也就是8KB大小的空间。有趣的是,这个函数还会为 softirq 分配一个同样大小的独立堆栈。如此说来,softirq 将不会在 hardirq 的中断栈上执行,而是在自己的上下文中执行。

![](/img/20160901215126528.png)

而 ARM 上中断栈和内核栈则是共享的;中断栈和内核栈共享有一个负面因素,如果中断发生嵌套,可能会造成栈溢出,从而可能会破坏到内核栈的一些重要数据,所以栈空间有时候难免会捉襟见肘。



## Linux 为什么需要区分这些栈?
为什么需要区分这些栈,其实都是设计上的问题。这里就我看到过的一些观点进行汇总,供大家讨论:

## 为什么需要单独的进程内核栈?

所有进程运行的时候,都可能通过系统调用陷入内核态继续执行。假设第一个进程 A 陷入内核态执行的时候,需要等待读取网卡的数据,主动调用 schedule() 让出 CPU;此时调度器唤醒了另一个进程 B,碰巧进程 B 也需要系统调用进入内核态。那问题就来了,如果内核栈只有一个,那进程 B 进入内核态的时候产生的压栈操作,必然会破坏掉进程 A 已有的内核栈数据;一但进程 A 的内核栈数据被破坏,很可能导致进程 A 的内核态无法正确返回到对应的用户态了;
为什么需要单独的线程栈?

Linux 调度程序中并没有区分线程和进程,当调度程序需要唤醒”进程”的时候,必然需要恢复进程的上下文环境,也就是进程栈;但是线程和父进程完全共享一份地址空间,如果栈也用同一个那就会遇到以下问题。假如进程的栈指针初始值为 0x7ffc80000000;父进程 A 先执行,调用了一些函数后栈指针 esp 为 0x7ffc8000FF00,此时父进程主动休眠了;接着调度器唤醒子线程 A1:
此时 A1 的栈指针 esp 如果为初始值 0x7ffc80000000,则线程 A1 一但出现函数调用,必然会破坏父进程 A 已入栈的数据。
如果此时线程 A1 的栈指针和父进程最后更新的值一致,esp 为 0x7ffc8000FF00,那线程 A1 进行一些函数调用后,栈指针 esp 增加到 0x7ffc8000FFFF,然后线程 A1 休眠;调度器再次换成父进程 A 执行,那这个时候父进程的栈指针是应该为 0x7ffc8000FF00 还是 0x7ffc8000FFFF 呢?无论栈指针被设置到哪个值,都会有问题不是吗?
进程和线程是否共享一个内核栈?

No,线程和进程创建的时候都调用 dup_task_struct 来创建 task 相关结构体,而内核栈也是在此函数中 alloc_thread_info_node 出来的。因此虽然线程和进程共享一个地址空间 mm_struct,但是并不共享一个内核栈。

# Linux中的银行家算法

## 死锁避免——银行家算法的应用背景
要想说银行家,首先得说死锁问题,因为银行家算法就是为了死锁避免提出的。那么,什么是死锁?简单的举个例子:俩人吃饺子,一个人手里拿着酱油,一个人手里拿着醋,拿酱油的对拿着醋的人说:“你把醋给我,我就把酱油给你”;拿醋的对拿着酱油的人说:“不,你把酱油给我,我把醋给你。”

于是,俩人这两份调料是永远吃不上了。这就是死锁。

那么,为啥这个算法叫银行家算法?因为这个算法同样可以用于银行的贷款业务。让我们考虑下面的情况。

一个银行家共有20亿财产
第一个开发商:已贷款15亿,资金紧张还需3亿。
第二个开发商:已贷款5亿,运转良好能收回。
第三个开发商:欲贷款18亿

在这种情况下,如果你是银行家,你怎么处理这种情况?一个常规的想法就是先等着第二个开发商把钱收回来,然后手里有了5个亿,再把3个亿贷款给第一个开发商,等第一个开发商收回来18个亿,然后再把钱贷款给第三个开发商。
这里面什么值得学习呢?最重要的就是眼光放长一点,不要只看着手里有多少钱,同时要注意到别人欠自己的钱怎么能收回来。

那么正经点说这个问题,第一个例子中:醋和酱油是资源,这俩吃饺子的是进程;第二个例子中:银行家是资源,开发商是进程。在操作系统中,有内存,硬盘等等资源被众多进程渴求着,那么这些资源怎么分配给他们才能避免“银行家破产”的风险?

## 银行家算法
### 安全序列

安全序列是指对当前申请资源的进程排出一个序列,保证按照这个序列分配资源完成进程,不会发生“酱油和醋”的尴尬问题。

我们假设有进程P1,P2,.....Pn
则安全序列要求满足:Pi(1<=i<=n)需要资源<=剩余资源 + 分配给Pj(1 <= j < i)资源
为什么等号右边还有已经被分配出去的资源?想想银行家那个问题,分配出去的资源就好比第二个开发商,人家能还回来钱,咱得把这个考虑在内。

我们定义下面的数据结构

int n,m; //系统中进程总数n和资源种类总数m
int Available[1..m]; //资源当前可用总量
int Allocation[1..n,1..m]; //当前给分配给每个进程的各种资源数量
int Need[1..n,1..m];//当前每个进程还需分配的各种资源数量
int Work[1..m]; //当前可分配的资源
bool Finish[1..n]; //进程是否结束

1
2
### 安全判定算法
初始化

Work = Available(动态记录当前剩余资源)
Finish[i] = false(设定所有进程均未完成)
1
2

查找可执行进程Pi(未完成但目前剩余资源可满足其需要,这样的进程是能够完成的)

Finish[i] = false
Need[i] <= Work
1
2
3
如果没有这样的进程Pi,则跳转到第4步

(若有则)Pi一定能完成,并归还其占用的资源,即:

Finish[i] = true
Work = Work +Allocation[i]
GOTO 第2步,继续查找
1
2

如果所有进程Pi都是能完成的,即Finish[i]=ture,则系统处于安全状态,否则系统处于不安全状态。伪代码:

Boolean Found;
Work = Available; Finish[1..n] = false;
while(true){
//不断的找可执行进程
Found = false;
for(i=1; i<=n; i++){
if(Finish[i]==false && Need[i]<=Work){
Work = Work + Allocation[i];//把放出去的贷款也当做自己的资产
Finish[i] = true;
Found = true;
}
}
if(Found==false)break;
}
for(i=1;i<=n;i++)
if(Finish[i]==false)return “deadlock”; //如果有进程是完不成的,那么就是有死锁
1
2
3
4
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

## 示例
举个实际例子,假设下面的初始状态:
![](/img/20190521170000.jpg)

首先,进入算法第一步,初始化。那么Work = Available = [3 3 2]

首先看P0:P0的Need为[7 4 3],Available不能满足,于是跳过去

P1的Need为[1 2 2]可以满足,我们令Work = Allocation[P1] + Work,此时Work = [5 3 2]

再看P2,P2的Need为[6 0 0],那么现有资源不满足。跳过去。

看P3,那么看P3,Work可以满足。那么令Work = Allocation[P3] + Work,此时Work = [7 4 3]

再看P4,Work可以满足。令Work = Allocation[P4] + Work ,此时Work = [7 4 5]

到此第一轮循环完毕,由于找到了可用进程,那么进入第二轮循环。

看P0,Work此时可以满足。令Work = Allocation[P0] + Work ,此时Work = [7 5 5]

再看P2,此时Work可以满足P2。令Work = Allocation[P2] + Work , 此时Work = [10 5 7]

至此,算法运行完毕。找到安全序列 < P1,P3,P4,P0,P2 > ,证明此时没有死锁危险。(安全序列未必唯一)

## 资源请求算法
之前说完了怎么判定当前情况是否安全,下面就是说当有进程新申请资源的时候如何处理。
我们将第i个进程请求的资源数记为Requests[i]

算法流程:

1.如果Requests[i]<=Need[i],则转到第二步。否则,返回异常。这一步是控制进程申请的资源不得大于需要的资源

2.如果Requests[i]<=Available,则转到第三步,否则Pi等待资源。

3.如果满足前两步,那么做如下操作:


Available = Available -Requests[i]
Allocation = Allocation[i]+Requests[i]
Need[i]=Need[i]-Requests[i]
调用安全判定算法,检查是否安全
if(安全)
{
申请成功,资源分配
}
else
{
申请失败,资源撤回。第三步前几个操作进行逆操作
}
1
2
3
4
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

# Linux任务调度机制

## 作业调度策略
进程调度在近几个版本中都进行了重要的修改。我们先了解一下进程调度的原理:

### 进程类型
在linux调度算法中,将进程分为两种类型,即:I/O消耗型和CPU消耗型。例如文本处理程序与正在执行的Make的程序。文本处理程序大部份时间都在等待I/O设备的输入,而make程序大部份时间都在CPU的处理上。因此为了提高响应速度,I/O消耗程序应该有较高的优先级,才能提高它的交互性。相反的,Make程序相比之下就不那么重要了,只要它能处理完就行了。因此,基于这样的原理,linux有一套交互程序的判断机制。在task_struct结构中新增了一个成员:sleep_avg此值初始值为100。进程在CPU上执行时,此值减少。当进程在等待时,此值增加。最后,在调度的时候。根据sleep_avg的值重新计算优先级。

### 进程优先级
正如我们在上面所说的:交互性强的需要高优先级,交互性弱的需要低优先级。在linux系统中,有两种优先级:普通优先级和实时优先级。我们在这里主要分析的是普通优先级,实时优先级部份可自行了解。

### 运行时间片
进程的时间片是指进程在抢占前可以持续运行的时间。在linux中,时间片长短可根据优先级来调整。进程不一定要一次运行完所有的时间片。可以在运时的中途被切换出去。

### 进程抢占
当一个进程被设为TASK_RUNING状态时,它会判断它的优先级是否高于正在运行的进程,如果是,则设置调度标志位,调用schedule()执行进程的调度。当一个进程的时间片为0时,也会执行进程抢占。
调度程序运行时,要在所有可运行状态的进程中选择最值得运行的进程投入运行。选择进程的依据是什么呢?在每个进程的task_struct结构中有以下四 项:policy、priority、counter、rt_priority。这四项就是调度程序选择进程的依据.其中,policy是进程的调度策略,用来区分两种进程-实时和普通;priority是进程(实时和普通)的优先 级;counter 是进程剩余的时间片,它的大小完全由priority决定;rt_priority是实时优先级,这是实时进程所特有的,用于实时进程间的选择。

首先,Linux 根据policy从整体上区分实时进程和普通进程,因为实时进程和普通进程度调度是不同的,它们两者之间,实时进程应该先于普通进程而运行,然后,对于同一类型的不同进程,采用不同的标准来选择进程:

policy的取值会有以下可能:

- SCHED_OTHER 分时调度策略,(默认的)
- SCHED_FIFO实时调度策略,先到先服务
- SCHED_RR实时调度策略,时间片轮转 实时进程将得到优先调用,实时进程根据实时优先级决定调度权值,分时进程则通过nice和counter值决定权值,nice越小,counter越大,被调度的概率越大,也就是曾经使用了cpu最少的进程将会得到优先调度。
- SHCED_RR和SCHED_FIFO的不同:当采用SHCED_RR策略的进程的时间片用完,系统将重新分配时间片,并置于就绪队列尾。放在队列尾保证了所有具有相同优先级的RR任务的调度公平。
- SCHED_FIFO一旦占用cpu则一直运行。一直运行直到有 更高优先级任务到达或自己放弃 。
- 如果有相同优先级的实时进程(根据优先级计算的调度权值是一样的)已经准备好,FIFO时必须等待该进程主动放弃后才可以运行这个优先级相同的任务。而RR可以让每个任务都执行一段时间。

相同点:
- RR和FIFO都只用于实时任务。
- 创建时优先级大于0(1-99)。
- 按照可抢占优先级调度算法进行。
- 就绪态的实时任务立即抢占非实时任务。


对于普通进程,Linux采用动态优先调度,选择进程的依据就是进程counter的大小。进程创建时,优先级priority被赋一个初值,一般为 0~70之间的数字,这个数字同时也是计数器counter的初值,就是说进程创建时两者是相等的。字面上看,priority是"优先级"、 counter是"计数器"的意思,然而实际上,它们表达的是同一个意思-进程的"时间片"。Priority代表分配给该进程的时间片,counter 表示该进程剩余的时间片。在进程运行过程中,counter不断减少,而priority保持不变,以便在counter变为0的时候(该进程用完了所分 配的时间片)对counter重新赋值。当一个普通进程的时间片用完以后,并不马上用priority对counter进行赋值,只有所有处于可运行状态 的普通进程的时间片(p->counter==0)都用完了以后,才用priority对counter重新赋值,这个普通进程才有了再次被调度的 机会。这说明,普通进程运行过程中,counter的减小给了其它进程得以运行的机会,直至counter减为0时才完全放弃对CPU的使用,这就相对于 优先级在动态变化,所以称之为动态优先调度。至于时间片这个概念,和其他不同操作系统一样的,Linux的时间单位也是"时钟滴答",只是不同操作系统对 一个时钟滴答的定义不同而已(Linux为10ms)。进程的时间片就是指多少个时钟滴答,比如,若priority为20,则分配给该进程的时间片就为 20个时钟滴答,也就是20*10ms=200ms。Linux中某个进程的调度策略(policy)、优先级(priority)等可以作为参数由用户 自己决定,具有相当的灵活性。内核创建新进程时分配给进程的时间片缺省为200ms(更准确的,应为210ms),用户可以通过系统调用改变它。

对于实时进程,Linux采用了两种调度策略,即FIFO(先来先服务调度)和RR(时间片轮转调度)。因为实时进程具有一定程度的紧迫性,所以衡量一个 实时进程是否应该运行,Linux采用了一个比较固定的标准。实时进程的counter只是用来表示该进程的剩余时间片,并不作为衡量它是否值得运行的标 准。实时进程的counter只是用来表示该进程的剩余时间片,并不作为衡量它是否值得运行的标准,这和普通进程是有区别的。上面已经看到,每个进程有两 个优先级(动态优先级和实时优先级),实时优先级就是用来衡量实时进程是否值得运行的。

Linux根据policy的值将进程总体上分为实时进程和普通进程,提供了三种调度算法:一种传统的Unix调度程序和两个由POSIX.1b(原名为 POSIX.4)操作系统标准所规定的"实时"调度程序。但这种实时只是软实时,不满足诸如中断等待时间等硬实时要求,只是保证了当实时进程需要时一定只 把CPU分配给实时进程。


非实时进程有两种优先级,一种是静态优先级,另一种是动态优先级。实时进程又增加了第三种优先级,实时优先级。优先级是一些简单的整数,为了决定应该允许哪一个进程使用CPU的资源,用优先级代表相对权值-优先级越高,它得到CPU时间的机会也就越大。

- 静态优先级(priority)-不随时间而改变,只能由用户进行修改。它指明了在被迫和其他进程竞争CPU之前,该进程所应该被允许的时间片的最大值(但很可能的,在该时间片耗尽之前,进程就被迫交出了CPU)。
- 动态优先级(counter)-只要进程拥有CPU,它就随着时间不断减小;当它小于0时,标记进程重新调度。它指明了在这个时间片中所剩余的时间量。
- 实时优先级(rt_priority)-指明这个进程自动把CPU交给哪一个其他进程;较高权值的进程总是优先于较低权值的进程。如果一个进程不是实时进程,其优先级就是0,所以实时进程总是优先于非实时进程的(但实际上,实时进程也会主动放弃CPU)。

当所有任务都采用FIFO调度策略时(SCHED_FIFO):
1. 创建进程时指定采用FIFO,并设置实时优先级rt_priority(1-99)。
2. 如果没有等待资源,则将该任务加入到就绪队列中。
3. 调度程序遍历就绪队列,根据实时优先级计算调度权值,选择权值最高的任务使用cpu, 该FIFO任务将一直占有cpu直到有优先级更高的任务就绪(即使优先级相同也不行)或者主动放弃(等待资源)。
4. 调度程序发现有优先级更高的任务到达(高优先级任务可能被中断或定时器任务唤醒,再或被当前运行的任务唤醒,等等),则调度程序立即在当前任务堆栈中保存当前cpu寄存器的所有数据,重新从高优先级任务的堆栈中加载寄存器数据到cpu,此时高优先级的任务开始运行。重复第3步。
5. 如果当前任务因等待资源而主动放弃cpu使用权,则该任务将从就绪队列中删除,加入等待队列,此时重复第3步。


当所有任务都采用RR调度策略(SCHED_RR)时:
1. 创建任务时指定调度参数为RR, 并设置任务的实时优先级和nice值(nice值将会转换为该任务的时间片的长度)。
2. 如果没有等待资源,则将该任务加入到就绪队列中。
3. 调度程序遍历就绪队列,根据实时优先级计算调度权值,选择权值最高的任务使用cpu。
4. 如果就绪队列中的RR任务时间片为0,则会根据nice值设置该任务的时间片,同时将该任务放入就绪队列的末尾 。重复步骤3。
5. 当前任务由于等待资源而主动退出cpu,则其加入等待队列中。重复步骤3。


系统中既有分时调度,又有时间片轮转调度和先进先出调度:
1. RR调度和FIFO调度的进程属于实时进程,以分时调度的进程是非实时进程。
2. 当实时进程准备就绪后,如果当前cpu正在运行非实时进程,则实时进程立即抢占非实时进程 。
3. RR进程和FIFO进程都采

作业调度算法:
1. 先来先服务算法
2. 段作业优先调度算法
3. 优先级调度算法
4. 时间片轮转调度算法
5. 最高响应比优先调度算法
响应比=周转时间/作业执行时间=(作业执行时间+作业等待时间)/作业执行时间=1+作业等待时间/作业执行时间;
作业周转时间=作业完成时间-作业到达时间
6. 多级反馈队列调度算法
- 进程在进入待调度的队列等待时,首先进入优先级最高的Q1等待。
- 首先调度优先级高的队列中的进程。若高优先级中队列中已没有调度的进程,则调度次优先级队列中的进程。例如:Q1,Q2,Q3三个队列,只有在Q1中没有进程等待时才去调度Q2,同理,只有Q1,Q2都为空时才会去调度Q3。
- 对于同一个队列中的各个进程,按照时间片轮转法调度。比如Q1队列的时间片为N,那么Q1中的作业在经历了N个时间片后若还没有完成,则进入Q2队列等待,若Q2的时间片用完后作业还不能完成,一直进入下一级队列,直至完成。
- 在低优先级的队列中的进程在运行时,又有新到达的作业,那么在运行完这个时间片后,CPU马上分配给新到达的作业(抢占式)。
7. 实时调度算法
- 最早截止时间优先调度算法
- 最低松弛度优先调度算法

根据任务紧急的程度,来确定任务的优先级。比如说,一个任务在200ms时必须完成而它本身运行需要100ms,所以此任务就必须在100ms之前调度执行,此任务的松弛度就是100ms。在实现此算法时需要系统中有一个按松弛度排序的实时任务就绪队列,松弛度最低的任务排在最烈的最前面,调度程序总是选择就粗队列中的首任务执行!(可理解为最早额定开始)

# Linux 伙伴算法简介

它要解决的问题是频繁地请求和释放不同大小的一组连续页框,必然导致在已分配页框的块内分散了许多小块的空闲页面,由此带来的问题是,即使有足够的空闲页框可以满足请求,但要分配一个大块的连续页框可能无法满足请求。

伙伴算法(Buddy system)把所有的空闲页框分为11个块链表,每块链表中分布包含特定的连续页框地址空间,比如第0个块链表包含大小为2^0个连续的页框,第1个块链表中,每个链表元素包含2个页框大小的连续地址空间,….,第10个块链表中,每个链表元素代表4M的连续地址空间。每个链表中元素的个数在系统初始化时决定,在执行过程中,动态变化。

伙伴算法每次只能分配2的幂次页的空间,比如一次分配1页,2页,4页,8页,…,1024页(2^10)等等,每页大小一般为4K,因此,伙伴算法最多一次能够分配4M的内存空间。

## 核心概念和数据结构
两个内存块,大小相同,地址连续,同属于一个大块区域。(第0块和第1块是伙伴,第2块和第3块是伙伴,但第1块和第2块不是伙伴)

伙伴位图:用一位描述伙伴块的状态位码,称之为伙伴位码。比如,bit0为第0块和第1块的伙伴位码,如果bit0为1,表示这两块至少有一块已经分配出去,如果bit0为0,说明两块都空闲,还没分配。

Linux2.6为每个管理区使用不同的伙伴系统,内核空间分为三种区,DMA,NORMAL,HIGHMEM,对于每一种区,都有对于的伙伴算法,

1. free_area数组:
![](/img/241710171253745.png)

struct zone{
….
struct free_area free_area[MAX_ORDER];
….
}
1
2
3
4
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
struct free_area  free_area[MAX_ORDER]    #MAX_ORDER 默认值为11

2. zone_mem_map数组
![](/img/2010637_1264725236SKso.gif)

free_area数组中,第K个元素,它标识所有大小为2^k的空闲块,所有空闲快由free_list指向的双向循环链表组织起来。其中的nr_free,它指定了对应空间剩余块的个数。

整个分配图示,大概如下:
![](/img/241715520945881.png)

## 申请和回收过程
比如,我要分配4(2^2)页(16k)的内存空间,算法会先从free_area[2]中查看nr_free是否为空,如果有空闲块,则从中分配,如果没有空闲块,就从它的上一级free_area[3](每块32K)中分配出16K,并将多余的内存(16K)加入到free_area[2]中去。如果free_area[3]也没有空闲,则从更上一级申请空间,依次递推,直到free_area[max_order],如果顶级都没有空间,那么就报告分配失败。

释放是申请的逆过程,当释放一个内存块时,先在其对于的free_area链表中查找是否有伙伴存在,如果没有伙伴块,直接将释放的块插入链表头。如果有或板块的存在,则将其从链表摘下,合并成一个大块,然后继续查找合并后的块在更大一级链表中是否有伙伴的存在,直至不能合并或者已经合并至最大块2^10为止。

内核试图将大小为b的一对空闲块(一个是现有空闲链表上的,一个是待回收的),合并为一个大小为2B的单独块,如果它成功合并所释放的块,它会试图合并2b大小的块,

内核使用_rmqueue()函数来在管理区中找到一个空闲块,成功返回第一个被分配页框的页描述符,失败返回NULL。
![](/img/241710200165462.png)

## 内核使用
_free_pages_bulk()函数按照伙伴系统的策略释放页框。它使用3个基本输入参数:
page:被释放块中所包含的第一个页框描述符的地址。
zone:管理区描述符的地址。
order:块大小的对数。

## 伙伴算法的优缺点
### 优点

较好的解决外部碎片问题

当需要分配若干个内存页面时,用于DMA的内存页面必须连续,伙伴算法很好的满足了这个要求

只要请求的块不超过512个页面(2K),内核就尽量分配连续的页面。

针对大内存分配设计。

### 缺点

1. 合并的要求太过严格,只能是满足伙伴关系的块才能合并,比如第1块和第2块就不能合并。
2. 碎片问题:一个连续的内存中仅仅一个页面被占用,导致整块内存区都不具备合并的条件
3. 浪费问题:伙伴算法只能分配2的幂次方内存区,当需要8K(2页)时,好说,当需要9K时,那就需要分配16K(4页)的内存空间,但是实际只用到9K空间,多余的7K空间就被浪费掉。
4. 算法的效率问题: 伙伴算法涉及了比较多的计算还有链表和位图的操作,开销还是比较大的,如果每次2^n大小的伙伴块就会合并到2^(n+1)的链表队列中,那么2^n大小链表中的块就会因为合并操作而减少,但系统随后立即有可能又有对该大小块的需求,为此必须再从2^(n+1)大小的链表中拆分,这样的合并又立即拆分的过程是无效率的。

Linux针对大内存的物理地址分配,采用伙伴算法,如果是针对小于一个page的内存,频繁的分配和释放,有更加适宜的解决方案,如slab和kmem_cache等,这不在本文的讨论范围内。

# Linux内存描述符mm_struct实例详解

无论是内核线程还是用户进程,对于内核来说,无非都是task_struct这个数据结构的一个实例而已,task_struct被称为进程描述符(process descriptor),因为它记录了这个进程所有的context。其中有一个被称为'内存描述符‘(memory descriptor)的数据结构mm_struct,抽象并描述了Linux视角下管理进程地址空间的所有信息。

mm_struct定义在include/linux/mm_types.h中,其中的域抽象了进程的地址空间,如下图所示:
![](/img/2017090617062846.png)

```C
struct mm_struct {
struct vm_area_struct * mmap; //指向虚拟区间(VMA)的链表
struct rb_root mm_rb; //指向线性区对象红黑树的根
struct vm_area_struct * mmap_cache; //指向最近找到的虚拟区间
unsigned long(*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);//在进程地址空间中搜索有效线性地址区
unsigned long(*get_unmapped_exec_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
void(*unmap_area) (struct mm_struct *mm, unsigned long addr);//释放线性地址区间时调用的方法
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
unsigned long cached_hole_size;
unsigned long free_area_cache; //内核从这个地址开始搜索进程地址空间中线性地址的空闲区域
pgd_t * pgd; //指向页全局目录
atomic_t mm_users; //次使用计数器,使用这块空间的个数
atomic_t mm_count; //主使用计数器
int map_count; //线性的个数
struct rw_semaphore mmap_sem; //线性区的读/写信号量
spinlock_t page_table_lock; //线性区的自旋锁和页表的自旋锁
struct list_head mmlist; //指向内存描述符链表中的相邻元素
/* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
mm_counter_t _file_rss; //mm_counter_t代表的类型实际是typedef atomic_long_t
mm_counter_t _anon_rss;
mm_counter_t _swap_usage;
unsigned long hiwater_rss; //进程所拥有的最大页框数
unsigned long hiwater_vm; //进程线性区中最大页数
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
//total_vm 进程地址空间的大小(页数)
//locked_vm 锁住而不能换出的页的个数
//shared_vm 共享文件内存映射中的页数
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
//stack_vm 用户堆栈中的页数
//reserved_vm 在保留区中的页数或者在特殊线性区中的页数
//def_flags 线性区默认的访问标志
//nr_ptes 进程的页表数
unsigned long start_code, end_code, start_data, end_data;
//start_code 可执行代码的起始地址
//end_code 可执行代码的最后地址
//start_data已初始化数据的起始地址
// end_data已初始化数据的最后地址
unsigned long start_brk, brk, start_stack;
//start_stack堆的起始位置
//brk堆的当前的最后地址
//用户堆栈的起始地址
unsigned long arg_start, arg_end, env_start, env_end;
//arg_start 命令行参数的起始地址
//arg_end命令行参数的起始地址
//env_start环境变量的起始地址
//env_end环境变量的最后地址
unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
struct linux_binfmt *binfmt;
cpumask_t cpu_vm_mask; //用于惰性TLB交换的位掩码
/* Architecture-specific MM context */
mm_context_t context; //指向有关特定结构体系信息的表
unsigned int faultstamp;
unsigned int token_priority;
unsigned int last_interval;
unsigned long flags; /* Must use atomic bitops to access the bits */
struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
spinlock_t ioctx_lock; //用于保护异步I/O上下文链表的锁
struct hlist_head ioctx_list;//异步I/O上下文
#endif
#ifdef CONFIG_MM_OWNER
struct task_struct *owner;
#endif
#ifdef CONFIG_PROC_FS
unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
struct mmu_notifier_mm *mmu_notifier_mm;
#endif
#ifdef CONFIG_TRANSPARENT_HUGEPAGE
pgtable_t pmd_huge_pte; /* protected by page_table_lock */
#endif
#ifdef __GENKSYMS__
unsigned long rh_reserved[2];
#else
//有多少任务分享这个mm OOM_DISABLE
union {
unsigned long rh_reserved_aux;
atomic_t oom_disable_count;
};
/* base of lib map area (ASCII armour) */
unsigned long shlib_base;
#endif
};

Linux内存管理之mmap详解

原文:https://blog.csdn.net/caogenwangbaoqiang/article/details/80780106

mmap系统调用

mmap系统调用

mmap将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。munmap执行相反的操作,删除特定地址区域的对象映射。

当使用mmap映射文件到进程后,就可以直接操作这段虚拟地址进行文件的读写等操作,不必再调用read,write等系统调用.但需注意,直接对该段内存写时不会写入超过当前文件大小的内容.

采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。

基于文件的映射,在mmap和munmap执行过程的任何时刻,被映射文件的st_atime可能被更新。如果st_atime字段在前述的情况下没有得到更新,首次对映射区的第一个页索引时会更新该字段的值。用PROT_WRITE 和 MAP_SHARED标志建立起来的文件映射,其st_ctime 和 st_mtime在对映射区写入之后,但在msync()通过MS_SYNC 和 MS_ASYNC两个标志调用之前会被更新。

用法:

1
2
3
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);

int munmap(void *start, size_t length);

返回说明:

成功执行时,mmap()返回被映射区的指针,munmap()返回0。失败时,mmap()返回MAP_FAILED[其值为(void *)-1],munmap返回-1。errno被设为以下的某个值

1
2
3
4
5
6
7
8
9
10
11
EACCES:访问出错
EAGAIN:文件已被锁定,或者太多的内存已被锁定
EBADF:fd不是有效的文件描述词
EINVAL:一个或者多个参数无效
ENFILE:已达到系统对打开文件的限制
ENODEV:指定文件所在的文件系统不支持内存映射
ENOMEM:内存不足,或者进程已超出最大内存映射数量
EPERM:权能不足,操作不允许
ETXTBSY:已写的方式打开文件,同时指定MAP_DENYWRITE标志
SIGSEGV:试着向只读区写入
SIGBUS:试着访问不属于进程的内存区

参数:

  • start:映射区的开始地址。
  • length:映射区的长度。
  • prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起
    • PROT_EXEC //页内容可以被执行
    • PROT_READ //页内容可以被读取
    • PROT_WRITE //页可以被写入
    • PROT_NONE //页不可访问
  • flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
    • MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
    • MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
    • MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
    • MAP_DENYWRITE //这个标志被忽略。
    • MAP_EXECUTABLE //同上
    • MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
    • MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存。
    • MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。
    • MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。
    • MAP_ANON //MAP_ANONYMOUS的别称,不再被使用。
    • MAP_FILE //兼容标志,被忽略。
    • MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
    • MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
    • MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。
  • fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1。
    offset:被映射对象内容的起点。

系统调用munmap()

int munmap( void * addr, size_t len )
该调用在进程地址空间中解除一个映射关系,addr是调用mmap()时返回的地址,len是映射区的大小。当映射关系解除后,对原来映射地址的访问将导致段错误发生。

系统调用msync()

int msync ( void * addr , size_t len, int flags)
一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。

系统调用mmap()用于共享内存的两种方式

使用普通文件提供的内存映射

适用于任何进程之间;此时,需要打开或创建一个文件,然后再调用mmap();典型调用代码如下:

1
2
3
4
fd=open(name, flag, mode); 
if(fd<0)

ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0);

通过mmap()实现共享内存的通信方式有许多特点和要注意的地方

使用特殊文件提供匿名内存映射

适用于具有亲缘关系的进程之间;由于父子进程特殊的亲缘关系,在父进程中先调用mmap(),然后调用fork()。那么在调用fork()之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而mmap()返回的地址,却由父子进程共同维护。
对于具有亲缘关系的进程实现共享内存最好的方式应该是采用匿名内存映射的方式。此时,不必指定具体的文件,只要设置相应的标志即可.

mmap进行内存映射的原理

mmap系统调用的最终目的是将,设备或文件映射到用户进程的虚拟地址空间,实现用户进程对文件的直接读写,这个任务可以分为以下三步:

  1. 在用户虚拟地址空间中寻找空闲的满足要求的一段连续的虚拟地址空间,为映射做准备(由内核mmap系统调用完成)

每个进程拥有3G字节的用户虚存空间。但是,这并不意味着用户进程在这3G的范围内可以任意使用,因为虚存空间最终得映射到某个物理存储空间(内存或磁盘空间),才真正可以使用。

那么,内核怎样管理每个进程3G的虚存空间呢?概括地说,用户进程经过编译、链接后形成的映象文件有一个代码段和数据段(包括data段和bss段),其中代码段在下,数据段在上。数据段中包括了所有静态分配的数据空间,即全局变量和所有申明为static的局部变量,这些空间是进程所必需的基本要求,这些空间是在建立一个进程的运行映像时就分配好的。除此之外,堆栈使用的空间也属于基本要求,所以也是在建立进程时就分配好的.

在内核中,这样每个区域用一个结构struct vm_area_struct 来表示.它描述的是一段连续的、具有相同访问属性的虚存空间,该虚存空间的大小为物理内存页面的整数倍。可以使用 cat /proc//maps来查看一个进程的内存使用情况,pid是进程号.其中显示的每一行对应进程的一个vm_area_struct结构.

下面是struct vm_area_struct结构体的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* This struct defines a memory VMM memory area. */ 
struct vm_area_struct {
struct mm_struct * vm_mm; /* VM area parameters */
unsigned long vm_start;
unsigned long vm_end;
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot;
unsigned long vm_flags;
/* AVL tree of VM areas per task, sorted by address */
short vm_avl_height;
struct vm_area_struct * vm_avl_left;
struct vm_area_struct * vm_avl_right;
/* For areas with an address space and backing store,
vm_area_struct *vm_next_share;
struct vm_area_struct **vm_pprev_share;
struct vm_operations_struct * vm_ops;
unsigned long vm_pgoff; /* offset in PAGE_SIZE units, not PAGE_CACHE_SIZE */
struct file * vm_file;
unsigned long vm_raend;
void * vm_private_data; /* was vm_pte (shared mem) */
};

通常,进程所使用到的虚存空间不连续,且各部分虚存空间的访问属性也可能不同。所以一个进程的虚存空间需要多个vm_area_struct结构来描述。在vm_area_struct结构的数目较少的时候,各个vm_area_struct按照升序排序,以单链表的形式组织数据(通过vm_next指针指向下一个vm_area_struct结构)。但是当vm_area_struct结构的数据较多的时候,仍然采用链表组织的化,势必会影响到它的搜索速度。针对这个问题,vm_area_struct还添加了vm_avl_hight(树高)、vm_avl_left(左子节点)、vm_avl_right(右子节点)三个成员来实现AVL树,以提高vm_area_struct的搜索速度。

假如该vm_area_struct描述的是一个文件映射的虚存空间,成员vm_file便指向被映射的文件的file结构,vm_pgoff是该虚存空间起始地址在vm_file文件里面的文件偏移,单位为物理页面。

因此,mmap系统调用所完成的工作就是准备这样一段虚存空间,并建立vm_area_struct结构体,将其传给具体的设备驱动程序.

建立虚拟地址空间和文件或设备的物理地址之间的映射(设备驱动完成)

建立文件映射的第二步就是建立虚拟地址和具体的物理地址之间的映射,这是通过修改进程页表来实现的.mmap方法是file_opeartions结构的成员:

1
int (mmap)(struct file ,struct vm_area_struct *);

linux有2个方法建立页表:

(1) 使用remap_pfn_range一次建立所有页表.

1
int remap_pfn_range(struct vm_area_struct *vma, unsigned long virt_addr, unsigned long pfn, unsigned long size, pgprot_t prot);

返回值:成功返回 0, 失败返回一个负的错误值

参数说明:

  • vma 用户进程创建一个vma区域
  • virt_addr 重新映射应当开始的用户虚拟地址. 这个函数建立页表为这个虚拟地址范围从 virt_addr 到 virt_addr_size.
  • pfn 页帧号, 对应虚拟地址应当被映射的物理地址. 这个页帧号简单地是物理地址右移 PAGE_SHIFT 位. 对大部分使用, VMA 结构的 vm_paoff 成员正好包含你需要的值
  • size 正在被重新映射的区的大小, 以字节.
  • prot 给新 VMA 要求的”protection”. 驱动可(并且应当)使用在vma->vm_page_prot 中找到的值.

(2) 使用nopage VMA方法每次建立一个页表项.

1
struct page *(*nopage)(struct vm_area_struct *vma, unsigned long address, int *type);

返回值:成功则返回一个有效映射页,失败返回NULL.

参数说明:

  • address 代表从用户空间传过来的用户空间虚拟地址.

(3) 使用方面的限制:

remap_pfn_range不能映射常规内存,只存取保留页和在物理内存顶之上的物理地址。因为保留页和在物理内存顶之上的物理地址内存管理系统的各个子模块管理不到。640 KB 和 1MB 是保留页可能映射,设备I/O内存也可以映射。如果想把kmalloc()申请的内存映射到用户空间,则可以通过mem_map_reserve()把相应的内存设置为保留后就可以。

当实际访问新映射的页面时的操作(由缺页中断完成)
(1) page cache及swap cache中页面的区分:一个被访问文件的物理页面都驻留在page cache或swap cache中,一个页面的所有信息由struct page来描述。struct page中有一个域为指针mapping ,它指向一个struct address_space类型结构。page cache或swap cache中的所有页面就是根据address_space结构以及一个偏移量来区分的。

(2) 文件与 address_space结构的对应:一个具体的文件在打开后,内核会在内存中为之建立一个struct inode结构,其中的i_mapping域指向一个address_space结构。这样,一个文件就对应一个address_space结构,一个 address_space与一个偏移量能够确定一个page cache 或swap cache中的一个页面。因此,当要寻址某个数据时,很容易根据给定的文件及数据在文件内的偏移量而找到相应的页面。

(3) 进程调用mmap()时,只是在进程空间内新增了一块相应大小的缓冲区,并设置了相应的访问标识,但并没有建立进程空间到物理页面的映射。因此,第一次访问该空间时,会引发一个缺页异常。

(4) 对于共享内存映射情况,缺页异常处理程序首先在swap cache中寻找目标页(符合address_space以及偏移量的物理页),如果找到,则直接返回地址;如果没有找到,则判断该页是否在交换区 (swap area),如果在,则执行一个换入操作;如果上述两种情况都不满足,处理程序将分配新的物理页面,并把它插入到page cache中。进程最终将更新进程页表。

注:对于映射普通文件情况(非共享映射),缺页异常处理程序首先会在page cache中根据address_space以及数据偏移量寻找相应的页面。如果没有找到,则说明文件数据还没有读入内存,处理程序会从磁盘读入相应的页面,并返回相应地址,同时,进程页表也会更新.

(5) 所有进程在映射同一个共享内存区域时,情况都一样,在建立线性地址与物理地址之间的映射之后,不论进程各自的返回地址如何,实际访问的必然是同一个共享内存区域对应的物理页面。

Linux内核中cache的实现

操作系统和文件 Cache 管理

操作系统是计算机上最重要的系统软件,它负责管理各种物理资源,并向应用程序提供各种抽象接口以便其使用这些物理资源。从应用程序的角度看,操作系统提供了一个统一的虚拟机,在该虚拟机中没有各种机器的具体细节,只有进程、文件、地址空间以及进程间通信等逻辑概念。这种抽象虚拟机使得应用程序的开发变得相对容易:开发者只需与虚拟机中的各种逻辑对象交互,而不需要了解各种机器的具体细节。此外,这些抽象的逻辑对象使得操作系统能够很容易隔离并保护各个应用程序。

对于存储设备上的数据,操作系统向应用程序提供的逻辑概念就是”文件”。应用程序要存储或访问数据时,只需读或者写”文件”的一维地址空间即可,而这个地址空间与存储设备上存储块之间的对应关系则由操作系统维护。

在 Linux 操作系统中,当应用程序需要读取文件中的数据时,操作系统先分配一些内存,将数据从存储设备读入到这些内存中,然后再将数据分发给应用程序;当需要往文件中写数据时,操作系统先分配内存接收用户数据,然后再将数据从内存写到磁盘上。文件 Cache 管理指的就是对这些由操作系统分配,并用来存储文件数据的内存的管理。 Cache 管理的优劣通过两个指标衡量:一是 Cache 命中率,Cache 命中时数据可以直接从内存中获取,不再需要访问低速外设,因而可以显著提高性能;二是有效 Cache 的比率,有效 Cache 是指真正会被访问到的 Cache 项,如果有效 Cache 的比率偏低,则相当部分磁盘带宽会被浪费到读取无用 Cache 上,而且无用 Cache 会间接导致系统内存紧张,最后可能会严重影响性能。

下面分别介绍文件 Cache 管理在 Linux 操作系统中的地位和作用、Linux 中文件 Cache相关的数据结构、Linux 中文件 Cache 的预读和替换、Linux 中文件 Cache 相关 API 及其实现。

文件 Cache 的地位和作用

文件 Cache 是文件数据在内存中的副本,因此文件 Cache 管理与内存管理系统和文件系统都相关:一方面文件 Cache 作为物理内存的一部分,需要参与物理内存的分配回收过程,另一方面文件 Cache 中的数据来源于存储设备上的文件,需要通过文件系统与存储设备进行读写交互。从操作系统的角度考虑,文件 Cache 可以看做是内存管理系统与文件系统之间的联系纽带。因此,文件 Cache 管理是操作系统的一个重要组成部分,它的性能直接影响着文件系统和内存管理系统的性能。

图1描述了 Linux 操作系统中文件 Cache 管理与内存管理以及文件系统的关系示意图。从图中可以看到,在 Linux 中,具体文件系统,如 ext2/ext3、jfs、ntfs 等,负责在文件 Cache和存储设备之间交换数据,位于具体文件系统之上的虚拟文件系统VFS负责在应用程序和文件 Cache 之间通过 read/write 等接口交换数据,而内存管理系统负责文件 Cache 的分配和回收,同时虚拟内存管理系统(VMM)则允许应用程序和文件 Cache 之间通过 memory map的方式交换数据。可见,在 Linux 系统中,文件 Cache 是内存管理系统、文件系统以及应用程序之间的一个联系枢纽。

文件 Cache 相关数据结构

在 Linux 的实现中,文件 Cache 分为两个层面,一是 Page Cache,另一个 Buffer Cache,每一个 Page Cache 包含若干 Buffer Cache。内存管理系统和 VFS 只与 Page Cache 交互,内存管理系统负责维护每项 Page Cache 的分配和回收,同时在使用 memory map 方式访问时负责建立映射;VFS 负责 Page Cache 与用户空间的数据交换。而具体文件系统则一般只与 Buffer Cache 交互,它们负责在外围存储设备和 Buffer Cache 之间交换数据。Page Cache、Buffer Cache、文件以及磁盘之间的关系如图 2 所示,Page 结构和 buffer_head 数据结构的关系如图 3 所示。在上述两个图中,假定了 Page 的大小是 4K,磁盘块的大小是 1K。本文所讲述的,主要是指对 Page Cache 的管理。

在 Linux 内核中,文件的每个数据块最多只能对应一个 Page Cache 项,它通过两个数据结构来管理这些 Cache 项,一个是 radix tree,另一个是双向链表。Radix tree 是一种搜索树,Linux 内核利用这个数据结构来通过文件内偏移快速定位 Cache 项,图 4 是 radix tree的一个示意图,该 radix tree 的分叉为4(22),树高为4,用来快速定位8位文件内偏移。Linux(2.6.7) 内核中的分叉为 64(26),树高为 6(64位系统)或者 11(32位系统),用来快速定位 32 位或者 64 位偏移,radix tree 中的每一个叶子节点指向文件内相应偏移所对应的Cache项。

另一个数据结构是双向链表,Linux内核为每一片物理内存区域(zone)维护active_list和inactive_list两个双向链表,这两个list主要用来实现物理内存的回收。这两个链表上除了文件Cache之外,还包括其它匿名(Anonymous)内存,如进程堆栈等。

文件Cache的预读和替换

Linux内核中文件预读算法的具体过程是这样的:对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(不少于一个页面,通常是三个页面),这时的预读称为同步预读。对于第二次读请求,如果所读页面不在Cache中,即不在前次预读的group中,则表明文件访问不是顺序访问,系统继续采用同步预读;如果所读页面在Cache中,则表明前次预读命中,操作系统把预读group扩大一倍,并让底层文件系统读入group中剩下尚不在Cache中的文件数据块,这时的预读称为异步预读。无论第二次读请求是否命中,系统都要更新当前预读group的大小。此外,系统中定义了一个window,它包括前一次预读的group和本次预读的group。任何接下来的读请求都会处于两种情况之一:第一种情况是所请求的页面处于预读window中,这时继续进行异步预读并更新相应的window和group;第二种情况是所请求的页面处于预读window之外,这时系统就要进行同步预读并重置相应的window和group。图5是Linux内核预读机制的一个示意图,其中a是某次读操作之前的情况,b是读操作所请求页面不在window中的情况,而c是读操作所请求页面在window中的情况。

Linux内核中文件Cache替换的具体过程是这样的:刚刚分配的Cache项链入到inactive_list头部,并将其状态设置为active,当内存不够需要回收Cache时,系统首先从尾部开始反向扫描active_list并将状态不是referenced的项链入到inactive_list的头部,然后系统反向扫描inactive_list,如果所扫描的项的处于合适的状态就回收该项,直到回收了足够数目的Cache项。Cache替换算法如图6的算法描述伪码所示。

1
2
3
4
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
Mark_Accessed(b) {
if b.state==(UNACTIVE && UNREFERENCE)
b.state = REFERENCE
else if b.state == (UNACTIVE && REFERENCE) {
b.state = (ACTIVE && UNREFERENCE)
Add X to tail of active_list
} else if b.state == (ACTIVE && UNREFERENCE)
b.state = (ACTIVE && REFERENCE)
}
Reclaim() {
if active_list not empty and scan_num<MAX_SCAN1
{
X = head of active_list
if (X.state & REFERENCE) == 0
Add X to tail of inactive_list
else {
X.state &= ~REFERENCE
Move X to tail of active_list
}
scan_num++
}
scan_num = 0
if inactive_list not emptry and scan_num <
MAX_SCAN2 {
X = head of inactive_list
if (X.state & REFERENCE) == 0
return X
else {
X.state = ACTIVE | UNREFERENCE
Move X to tail of active_list
}
scan_num++
}
return NULL
}
Access(b){
if b is not in cache {
if slot X free
put b into X
else {
X=Reclaim()
put b into X
}
Add X to tail of inactive_list
}
Mark_Accessed(X)
}

文件Cache相关API及其实现

Linux内核中与文件Cache操作相关的API有很多,按其使用方式可以分成两类:一类是以拷贝方式操作的相关接口, 如read/write/sendfile等,其中sendfile在2.6系列的内核中已经不再支持;另一类是以地址映射方式操作的相关接口,如mmap等。

第一种类型的API在不同文件的Cache之间或者Cache与应用程序所提供的用户空间buffer之间拷贝数据,其实现原理如图7所示。

第二种类型的API将Cache项映射到用户空间,使得应用程序可以像使用内存指针一样访问文件,Memory map访问Cache的方式在内核中是采用请求页面机制实现的,其工作过程如图8所示。

首先,应用程序调用mmap(图中1),陷入到内核中后调用do_mmap_pgoff(图中2)。该函数从应用程序的地址空间中分配一段区域作为映射的内存地址,并使用一个VMA(vm_area_struct)结构代表该区域,之后就返回到应用程序(图中3)。当应用程序访问mmap所返回的地址指针时(图中4),由于虚实映射尚未建立,会触发缺页中断(图中5)。之后系统会调用缺页中断处理函数(图中6),在缺页中断处理函数中,内核通过相应区域的VMA结构判断出该区域属于文件映射,于是调用具体文件系统的接口读入相应的Page Cache项(图中7、8、9),并填写相应的虚实映射表。经过这些步骤之后,应用程序就可以正常访问相应的内存区域了。

小结

文件Cache管理是Linux操作系统的一个重要组成部分,同时也是研究领域一个很热门的研究方向。目前,Linux内核在这个方面的工作集中在开发更有效的Cache替换算法上,如LIRS(其变种ClockPro)、ARC等。

Linux孤儿进程与僵尸进程

基本概念

我们知道在unix/linux中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。 当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

问题及危害

unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤 儿进程的父进程设置为init,而init进程会循环地wait()它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。

任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个 子进程在结束时都要经过的阶段。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。

僵尸进程危害场景:

例如有个进程,它定期的产 生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程 退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用ps命令查看的话,就会看到很多状态为Z的进程。 严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大 量僵死进程的那个元凶枪毙掉(也就是通过kill发送SIGTERM或者SIGKILL信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被init进程接管,init进程会wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程 就能瞑目而去了。

孤儿进程和僵尸进程测试

孤儿进程测试程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>

int main()
{
pid_t pid;
//创建一个进程
pid = fork();
//创建失败
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//子进程
if (pid == 0)
{
printf("I am the child process.\n");
//输出进程ID和父进程ID
printf("pid: %d\tppid:%d\n",getpid(),getppid());
printf("I will sleep five seconds.\n");
//睡眠5s,保证父进程先退出
sleep(5);
printf("pid: %d\tppid:%d\n",getpid(),getppid());
printf("child process is exited.\n");
}
//父进程
else
{
printf("I am father process.\n");
//父进程睡眠1s,保证子进程输出进程id
sleep(1);
printf("father process is exited.\n");
}
return 0;
}

pid=3906 ppid=3905
pid=3906 ppid=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
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>

int main()
{
pid_t pid;
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
else if (pid == 0)
{
printf("I am child process.I am exiting.\n");
exit(0);
}
printf("I am father process.I will sleep two seconds\n");
//等待子进程先退出
sleep(2);
//输出进程信息
system("ps -o pid,ppid,state,tty,command");
printf("father process is exiting.\n");
return 0;
}

僵尸进程测试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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

int main()
{
pid_t pid;
//循环创建子进程
while(1)
{
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
else if (pid == 0)
{
printf("I am a child process.\nI am exiting.\n");
//子进程退出,成为僵尸进程
exit(0);
}
else
{
//父进程休眠20s继续创建子进程
sleep(20);
continue;
}
}
return 0;
}

僵尸进程解决办法

通过信号机制

子进程退出时向父进程发送SIGCHILD信号,父进程处理SIGCHILD信号。在信号处理函数中调用wait进行处理僵尸进程。测试程序如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <signal.h>

static void sig_child(int signo);

int main()
{
pid_t pid;
//创建捕捉子进程退出信号
signal(SIGCHLD,sig_child);
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
else if (pid == 0)
{
printf("I am child process,pid id %d.I am exiting.\n",getpid());
exit(0);
}
printf("I am father process.I will sleep two seconds\n");
//等待子进程先退出
sleep(2);
//输出进程信息
system("ps -o pid,ppid,state,tty,command");
printf("father process is exiting.\n");
return 0;
}

static void sig_child(int signo)
{
pid_t pid;
int stat;
//处理僵尸进程
while ((pid = waitpid(-1, &stat, WNOHANG)) >0)
printf("child %d terminated.\n", pid);
}

fork两次

《Unix 环境高级编程》8.6节说的非常详细。原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>

int main()
{
pid_t pid;
//创建第一个子进程
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//第一个子进程
else if (pid == 0)
{
//子进程再创建子进程
printf("I am the first child process.pid:%d\tppid:%d\n",getpid(),getppid());
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//第一个子进程退出
else if (pid >0)
{
printf("first procee is exited.\n");
exit(0);
}
//第二个子进程
//睡眠3s保证第一个子进程退出,这样第二个子进程的父亲就是init进程里
sleep(3);
printf("I am the second child process.pid: %d\tppid:%d\n",getpid(),getppid());
exit(0);
}
//父进程处理第一个子进程退出
if (waitpid(pid, NULL, 0) != pid)
{
perror("waitepid error:");
exit(1);
}
exit(0);
return 0;
}

Linux中的slab机制

内部碎片和外部碎片

外部碎片

什么是外部碎片呢?我们通过一个图来解释:

假设这是一段连续的页框,阴影部分表示已经被使用的页框,现在需要申请一个连续的5个页框。这个时候,在这段内存上不能找到连续的5个空闲的页框,就会去另一段内存上去寻找5个连续的页框,这样子,久而久之就形成了页框的浪费。称为外部碎片。
内核中使用伙伴算法的迁移机制很好的解决了这种外部碎片。

内部碎片

当我们申请几十个字节的时候,内核也是给我们分配一个页,这样在每个页中就形成了很大的浪费。称之为内部碎片。
内核中引入了slab机制去尽力的减少这种内部碎片。

slab分配机制

slab分配器是基于对象进行管理的,所谓的对象就是内核中的数据结构(例如:task_struct,file_struct 等)。相同类型的对象归为一类,每当要申请这样一个对象时,slab分配器就从一个slab列表中分配一个这样大小的单元出去,而当要释放时,将其重新保存在该列表中,而不是直接返回给伙伴系统,从而避免内部碎片。slab分配器并不丢弃已经分配的对象,而是释放并把它们保存在内存中。slab分配对象时,会使用最近释放的对象的内存块,因此其驻留在cpu高速缓存中的概率会大大提高。

内核中slab的主要数据结构

简要分析下这个图:kmem_cache是一个cache_chain的链表,描述了一个高速缓存,每个高速缓存包含了一个slabs的列表,这通常是一段连续的内存块。存在3种slab:slabs_full(完全分配的slab),slabs_partial(部分分配的slab),slabs_empty(空slab,或者没有对象被分配)。slab是slab分配器的最小单位,在实现上一个slab有一个货多个连续的物理页组成(通常只有一页)。单个slab可以在slab链表之间移动,例如如果一个半满slab被分配了对象后变满了,就要从slabs_partial中被删除,同时插入到slabs_full中去。
举例说明:如果有一个名叫inode_cachep的struct kmem_cache节点,它存放了一些inode对象。当内核请求分配一个新的inode对象时,slab分配器就开始工作了:

首先要查看inode_cachep的slabs_partial链表,如果slabs_partial非空,就从中选中一个slab,返回一个指向已分配但未使用的inode结构的指针。完事之后,如果这个slab满了,就把它从slabs_partial中删除,插入到slabs_full中去,结束;
如果slabs_partial为空,也就是没有半满的slab,就会到slabs_empty中寻找。如果slabs_empty非空,就选中一个slab,返回一个指向已分配但未使用的inode结构的指针,然后将这个slab从slabs_empty中删除,插入到slabs_partial(或者slab_full)中去,结束;
如果slabs_empty也为空,那么没办法,cache内存已经不足,只能新创建一个slab了。
接下来我们来分析下slab在内核中数据结构的组织,首先要从kmem_cache这个结构体说起了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
struct kmem_cache {
struct array_cache *array[NR_CPUS];//per_cpu数据,记录了本地高速缓存的信息,也是用于跟踪最近释放的对象,每次分配和释放都要直接访问它。
unsigned int batchcount;//本地高速缓存转入和转出的大批数据数量
unsigned int limit;//本地高速缓存中空闲对象的最大数目
unsigned int shared;

unsigned int buffer_size;/*buffer的大小,就是对象的大小*/
u32 reciprocal_buffer_size;

unsigned int flags; /* constant flags */
unsigned int num; /* ## of objs per slab *//*slab中有多少个对象*/

/* order of pgs per slab (2^n) */
unsigned int gfporder;/*每个slab中有多少个页*/

gfp_t gfpflags; /*与伙伴系统交互时所提供的分配标识*/

size_t colour; /* cache colouring range *//*slab中的着色*/
unsigned int colour_off; /* colour offset */着色的偏移量
struct kmem_cache *slabp_cache;
unsigned int slab_size; //slab管理区的大小
unsigned int dflags; /* dynamic flags */

/* constructor func */
void (*ctor)(void *obj); /*构造函数*/

/* 5) cache creation/removal */
const char *name;/*slab上的名字*/
struct list_head next; //用于将高速缓存连入cache chain

/* 6) statistics */ //一些用于调试用的变量
#ifdef CONFIG_DEBUG_SLAB
unsigned long num_active;
unsigned long num_allocations;
unsigned long high_mark;
unsigned long grown;
unsigned long reaped;
unsigned long errors;
unsigned long max_freeable;
unsigned long node_allocs;
unsigned long node_frees;
unsigned long node_overflow;
atomic_t allochit;
atomic_t allocmiss;
atomic_t freehit;
atomic_t freemiss;

int obj_offset;
int obj_size;
#endif /* CONFIG_DEBUG_SLAB */
//用于组织该高速缓存中的slab
struct kmem_list3 *nodelists[MAX_NUMNODES];/*最大的内存节点*/

};

/* Size description struct for general caches. */
struct cache_sizes {
size_t cs_size;
struct kmem_cache *cs_cachep;
#ifdef CONFIG_ZONE_DMA
struct kmem_cache *cs_dmacachep;
#endif
};

由上面的总图可知,一个核心的数据结构就是kmem_list3,它描述了slab描述符的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct kmem_list3 {
/*三个链表中存的是一个高速缓存slab*/
/*在这三个链表中存放的是cache*/
struct list_head slabs_partial; //包含空闲对象和已经分配对象的slab描述符
struct list_head slabs_full;//只包含非空闲的slab描述符
struct list_head slabs_free;//只包含空闲的slab描述符
unsigned long free_objects; /*高速缓存中空闲对象的个数*/
unsigned int free_limit; //空闲对象的上限
unsigned int colour_next; /* Per-node cache coloring *//*即将要着色的下一个*/
spinlock_t list_lock;
struct array_cache *shared; /* shared per node */
struct array_cache **alien; /* on other nodes */
unsigned long next_reap; /* updated without locking *//**/
int free_touched; /* updated without locking */
};

接下来介绍描述单个slab的结构struct slab
1
2
3
4
5
6
7
8
struct slab {
struct list_head list; //用于将slab连入keme_list3的链表
unsigned long colouroff; //该slab的着色偏移
void *s_mem; /* 指向slab中的第一个对象*/
unsigned int inuse; /* num of objs active in slab */已经分配出去的对象
kmem_bufctl_t free; //下一个空闲对象的下标
unsigned short nodeid; //节点标识符
};

在kmem_cache中还有一个重要的数据结构struct array_cache.这是一个指针数组,数组的元素是系统的cpu的个数。该结构用来描述每个cpu的高速缓存,它的主要作用是减少smp系统中对于自旋锁的竞争。

实际上,每次分配内存都是直接与本地cpu高速缓存进行交互,只有当其空闲内存不足时,才会从keme_list中的slab中引入一部分对象到本地高速缓存中,而keme_list中的空闲对象也不足时,那么就要从伙伴系统中引入新的页来建立新的slab了。

1
2
3
4
5
6
7
8
9
10
11
12
struct array_cache {
unsigned int avail;/*当前cpu上有多少个可用的对象*/
unsigned int limit;/*per_cpu里面最大的对象的个数,当超过这个值时,将对象返回给伙伴系统*/
unsigned int batchcount;/*一次转入和转出的对象数量*/
unsigned int touched;/*标示本地cpu最近是否被使用*/
spinlock_t lock;/*自旋锁*/
void *entry[]; /*
* Must have this definition in here for the proper
* alignment of array_cache. Also simplifies accessing
* the entries.
*/
};

对上面提到的各个数据结构做一个总结,用下图来描述:

关于slab分配器的API

下面看一下slab分配器的接口——看看slab缓存是如何创建、撤销以及如何从缓存中分配一个对象的。一个新的kmem_cache通过kmem_cache_create()函数来创建:

1
2
3
struct kmem_cache *
kmem_cache_create( const char *name, size_t size, size_t align,
unsigned long flags, void (*ctor)(void*));

*name是一个字符串,存放kmem_cache缓存的名字;size是缓存所存放的对象的大小;align是slab内第一个对象的偏移;flag是可选的配置项,用来控制缓存的行为。最后一个参数ctor是对象的构造函数,一般是不需要的,以NULL来代替。kmem_cache_create()成功执行之后会返回一个指向所创建的缓存的指针,否则返回NULL。kmem_cache_create()可能会引起阻塞(睡眠),因此不能在中断上下文中使用。

撤销一个kmem_cache则是通过kmem_cache_destroy()函数:

1
int kmem_cache_destroy( struct kmem_cache *cachep);

该函数成功则返回0,失败返回非零值。调用kmem_cache_destroy()之前应该满足下面几个条件:首先,cachep所指向的缓存中所有slab都为空闲,否则的话是不可以撤销的;其次在调用kmem_cache_destroy()过程中以及调用之后,调用者需要确保不会再访问这个缓存;最后,该函数也可能会引起阻塞,因此不能在中断上下文中使用。
可以通过下面函数来从kmem_cache中分配一个对象:

void kmem_cache_alloc(struct kmem_cache cachep, gfp_t flags);
这个函数从cachep指定的缓存中返回一个指向对象的指针。如果缓存中所有slab都是满的,那么slab分配器会通过调用kmem_getpages()创建一个新的slab。

释放一个对象的函数如下:

1
void kmem_cache_free(struct kmem_cache* cachep,  void* objp);

这个函数是将被释放的对象返还给先前的slab,其实就是将cachep中的对象objp标记为空闲而已

使用以上的API写内核模块,生成自己的slab高速缓存。

其实到了这里,应该去分析以上函数的源码,但是几次奋起分析,都被打趴在地。所以就写个内核模块,鼓励下自己吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <linux/autoconf.h>
#include <linux/module.h>
#include <linux/slab.h>

MODULE_AUTHOR("wangzhangjun");
MODULE_DESCRIPTION("slab test module");

static struct kmem_cache *test_cachep = NULL;
struct slab_test
{
int val;
};
void fun_ctor(struct slab_test *object , struct kmem_cache *cachep , unsigned long flags )
{
printk(KERN_INFO "ctor fuction ...\n");
object->val = 1;
}

static int __init slab_init(void)
{
struct slab_test *object = NULL;//slab的一个对象
printk(KERN_INFO "slab_init\n");
test_cachep = kmem_cache_create("test_cachep",sizeof(struct slab_test)*3,0,SLAB_HWCACHE_ALIGN,fun_ctor);
if(NULL == test_cachep)
return -ENOMEM ;
printk(KERN_INFO "Cache name is %s\n",kmem_cache_name(test_cachep));//获取高速缓存的名称
printk(KERN_INFO "Cache object size is %d\n",kmem_cache_size(test_cachep));//获取高速缓存的大小
object = kmem_cache_alloc(test_cachep,GFP_KERNEL);//从高速缓存中分配一个对象
if(object)
{
printk(KERN_INFO "alloc one val = %d\n",object->val);
kmem_cache_free( test_cachep, object );//归还对象到高速缓存
//这句话的意思是虽然对象归还到了高速缓存中,但是高速缓存中的值没有做修改
//只是修改了一些它的状态。
printk(KERN_INFO "alloc three val = %d\n",object->val);
object = NULL;
}else
return -ENOMEM;
return 0;
}

static void __exit slab_clean(void)
{
printk(KERN_INFO "slab_clean\n");
if(test_cachep)
kmem_cache_destroy(test_cachep);//调用这个函数时test_cachep所指向的缓存中所有的slab都要为空

}

module_init(slab_init);
module_exit(slab_clean);
MODULE_LICENSE("GPL");

我们结合结果来分析下这个内核模块:

这是dmesg的结果,可以发现我们自己创建的高速缓存的名字test_cachep,还有每个对象的大小。

还有构造函数修改了对象里面的值,至于为什么构造函数会出现这么多次,可能是因为,这个函数被注册了之后,系统的其他地方也会调用这个函数。在这里可以分析源码,当调用keme_cache_create()的时候是没有调用对象的构造函数的,调用kmem_cache_create()并没有分配slab,而是在创建对象的时候发现没有空闲对象,在分配对象的时候,会调用构造函数初始化对象。
另外结合上面的代码可以发现,alloc three val是在kmem_cache_free之后打印的,但是它的值依然可以被打印出来,这充分说明了,slab这种机制是在将某个对象使用完之后,就其缓存起来,它还是切切实实的存在于内存中。
再结合/proc/slabinfo的信息看我们自己创建的slab高速缓存

可以发现名字为test_cachep的高速缓存,每个对象的大小(objsize)是16,和上面dmesg看到的值相同,objperslab(每个slab中的对象时202),pagesperslab(每个slab中包含的页数),可以知道objsize * objperslab < pagesperslab。

总结

目前只是对slab机制的原理有了一个感性的认识,对于这部分相关的源码涉及到着色以及内存对齐等细节。看的不是很清楚,后面还需要仔细研究。

Linux的任务调度机制

Linux进程调度的目标

  1. 高效性:高效意味着在相同的时间下要完成更多的任务。调度程序会被频繁的执行,所以调度程序要尽可能的高效;
  2. 加强交互性能:在系统相当的负载下,也要保证系统的响应时间;
  3. 保证公平和避免饥渴;
  4. SMP调度:调度程序必须支持多处理系统;
  5. 软实时调度:系统必须有效的调用实时进程,但不保证一定满足其要求;

Linux进程优先级

进程提供了两种优先级,一种是普通的进程优先级,第二个是实时优先级。前者适用SCHED_NORMAL调度策略,后者可选SCHED_FIFO或SCHED_RR调度策略。任何时候,实时进程的优先级都高于普通进程,实时进程只会被更高级的实时进程抢占,同级实时进程之间是按照FIFO(一次机会做完)或者RR(多次轮转)规则调度的。

首先,说下实时进程的调度

实时进程,只有静态优先级,因为内核不会再根据休眠等因素对其静态优先级做调整,其范围在0~MAX_RT_PRIO-1间。默认MAX_RT_PRIO配置为100,也即,默认的实时优先级范围是0~99。而nice值,影响的是优先级在MAX_RT_PRIO~MAX_RT_PRIO+40范围内的进程。

不同与普通进程,系统调度时,实时优先级高的进程总是先于优先级低的进程执行。知道实时优先级高的实时进程无法执行。实时进程总是被认为处于活动状态。如果有数个 优先级相同的实时进程,那么系统就会按照进程出现在队列上的顺序选择进程。假设当前CPU运行的实时进程A的优先级为a,而此时有个优先级为b的实时进程B进入可运行状态,那么只要b < a,系统将中断A的执行,而优先执行B,直到B无法执行(无论A,B为何种实时进程)。

不同调度策略的实时进程只有在相同优先级时才有可比性:

  1. 对于FIFO的进程,意味着只有当前进程执行完毕才会轮到其他进程执行。由此可见相当霸道。
  2. 对于RR的进程。一旦时间片消耗完毕,则会将该进程置于队列的末尾,然后运行其他相同优先级的进程,如果没有其他相同优先级的进程,则该进程会继续执行。

总而言之,对于实时进程,高优先级的进程就是大爷。它执行到没法执行了,才轮到低优先级的进程执行。等级制度相当森严啊。

非实时进程调度

Linux对普通的进程,根据动态优先级进行调度。而动态优先级是由静态优先级(static_prio)调整而来。Linux下,静态优先级是用户不可见的,隐藏在内核中。而内核提供给用户一个可以影响静态优先级的接口,那就是nice值,两者关系如下:

1
static_prio=MAX_RT_PRIO +nice+ 20

nice值的范围是-20~19,因而静态优先级范围在100~139之间。nice数值越大就使得static_prio越大,最终进程优先级就越低。

ps -el 命令执行结果:NI列显示的每个进程的nice值,PRI是进程的优先级(如果是实时进程就是静态优先级,如果是非实时进程,就是动态优先级)  

而进程的时间片就是完全依赖 static_prio 定制的,见下图,摘自《深入理解linux内核》,

我们前面也说了,系统调度时,还会考虑其他因素,因而会计算出一个叫进程动态优先级的东西,根据此来实施调度。因为,不仅要考虑静态优先级,也要考虑进程的属性。例如如果进程属于交互式进程,那么可以适当的调高它的优先级,使得界面反应地更加迅速,从而使用户得到更好的体验。Linux2.6 在这方面有了较大的提高。Linux2.6认为,交互式进程可以从平均睡眠时间这样一个measurement进行判断。进程过去的睡眠时间越多,则越有可能属于交互式进程。则系统调度时,会给该进程更多的奖励(bonus),以便该进程有更多的机会能够执行。奖励(bonus)从0到10不等。

系统会严格按照动态优先级高低的顺序安排进程执行。动态优先级高的进程进入非运行状态,或者时间片消耗完毕才会轮到动态优先级较低的进程执行。动态优先级的计算主要考虑两个因素:静态优先级,进程的平均睡眠时间也即bonus。计算公式如下,

1
dynamic_prio = max (100, min (static_prio - bonus + 5, 139))

在调度时,Linux2.6 使用了一个小小的trick,就是算法中经典的空间换时间的思想[还没对照源码确认],使得计算最优进程能够在O(1)的时间内完成。

为什么根据睡眠和运行时间确定奖惩分数是合理的

睡眠和CPU耗时反应了进程IO密集和CPU密集两大瞬时特点,不同时期,一个进程可能即是CPU密集型也是IO密集型进程。对于表现为IO密集的进程,应该经常运行,但每次时间片不要太长。对于表现为CPU密集的进程,CPU不应该让其经常运行,但每次运行时间片要长。交互进程为例,假如之前其其大部分时间在于等待CPU,这时为了调高相应速度,就需要增加奖励分。另一方面,如果此进程总是耗尽每次分配给它的时间片,为了对其他进程公平,就要增加这个进程的惩罚分数。可以参考CFS的virtutime机制.

现代方法CFS

不再单纯依靠进程优先级绝对值,而是参考其绝对值,综合考虑所有进程的时间,给出当前调度时间单位内其应有的权重,也就是,每个进程的权重X单位时间=应获cpu时间,但是这个应得的cpu时间不应太小(假设阈值为1ms),否则会因为切换得不偿失。但是,当进程足够多时候,肯定有很多不同权重的进程获得相同的时间——最低阈值1ms,所以,CFS只是近似完全公平。

Linux进程状态机

进程是通过fork系列的系统调用(fork、clone、vfork)来创建的,内核(或内核模块)也可以通过kernel_thread函数创建内核进程。这些创建子进程的函数本质上都完成了相同的功能——将调用进程复制一份,得到子进程。(可以通过选项参数来决定各种资源是共享、还是私有。)
那么既然调用进程处于TASK_RUNNING状态(否则,它若不是正在运行,又怎么进行调用?),则子进程默认也处于TASK_RUNNING状态。
另外,在系统调用clone和内核函数kernel_thread也接受CLONE_STOPPED选项,从而将子进程的初始状态置为 TASK_STOPPED。

进程创建后,状态可能发生一系列的变化,直到进程退出。而尽管进程状态有好几种,但是进程状态的变迁却只有两个方向——从TASK_RUNNING状态变为非TASK_RUNNING状态、或者从非TASK_RUNNING状态变为TASK_RUNNING状态。总之,TASK_RUNNING是必经之路,不可能两个非RUN状态直接转换。

也就是说,如果给一个TASK_INTERRUPTIBLE状态的进程发送SIGKILL信号,这个进程将先被唤醒(进入TASK_RUNNING状态),然后再响应SIGKILL信号而退出(变为TASK_DEAD状态)。并不会从TASK_INTERRUPTIBLE状态直接退出。

进程从非TASK_RUNNING状态变为TASK_RUNNING状态,是由别的进程(也可能是中断处理程序)执行唤醒操作来实现的。执行唤醒的进程设置被唤醒进程的状态为TASK_RUNNING,然后将其task_struct结构加入到某个CPU的可执行队列中。于是被唤醒的进程将有机会被调度执行。

而进程从TASK_RUNNING状态变为非TASK_RUNNING状态,则有两种途径:

  1. 响应信号而进入TASK_STOPED状态、或TASK_DEAD状态;
  2. 执行系统调用主动进入TASK_INTERRUPTIBLE状态(如nanosleep系统调用)、或TASK_DEAD状态(如exit系统调用);或由于执行系统调用需要的资源得不到满     足,而进入TASK_INTERRUPTIBLE状态或TASK_UNINTERRUPTIBLE状态(如select系统调用)。

显然,这两种情况都只能发生在进程正在CPU上执行的情况下。

通过ps命令我们能够查看到系统中存在的进程,以及它们的状态:

R(TASK_RUNNING),可执行状态。

只有在该状态的进程才可能在CPU上运行。而同一时刻可能有多个进程处于可执行状态,这些进程的task_struct结构(进程控制块)被放入对应CPU的可执行队列中(一个进程最多只能出现在一个CPU的可执行队列中)。进程调度器的任务就是从各个CPU的可执行队列中分别选择一个进程在该CPU上运行。
只要可执行队列不为空,其对应的CPU就不能偷懒,就要执行其中某个进程。一般称此时的CPU“忙碌”。对应的,CPU“空闲”就是指其对应的可执行队列为空,以致于CPU无事可做。
有人问,为什么死循环程序会导致CPU占用高呢?因为死循环程序基本上总是处于TASK_RUNNING状态(进程处于可执行队列中)。除非一些非常极端情况(比如系统内存严重紧缺,导致进程的某些需要使用的页面被换出,并且在页面需要换入时又无法分配到内存……),否则这个进程不会睡眠。所以CPU的可执行队列总是不为空(至少有这么个进程存在),CPU也就不会“空闲”。

很多操作系统教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在linux下统一为 TASK_RUNNING状态。

S(TASK_INTERRUPTIBLE),可中断的睡眠状态。

处于这个状态的进程因为等待某某事件的发生(比如等待socket连接、等待信号量),而被挂起。这些进程的task_struct结构被放入对应事件的等待队列中。当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。

通过ps命令我们会看到,一般情况下,进程列表中的绝大多数进程都处于TASK_INTERRUPTIBLE状态(除非机器的负载很高)。毕竟CPU就这么一两个,进程动辄几十上百个,如果不是绝大多数进程都在睡眠,CPU又怎么响应得过来。

D(TASK_UNINTERRUPTIBLE),不可中断的睡眠状态。

与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是CPU不响应外部硬件的中断,而是指进程不响应异步信号。
绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9竟然杀不死一个正在睡眠的进程了!于是我们也很好理解,为什么ps命令看到的进程几乎不会出现TASK_UNINTERRUPTIBLE状态,而总是TASK_INTERRUPTIBLE状态。

而TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了(参见《linux异步信号handle浅析》)。
在进程对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。(比如read系统调用触发了一次磁盘到用户空间的内存的DMA,如果DMA进行过程中,进程由于响应信号而退出了,那么DMA正在访问的内存可能就要被释放了。)这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。

linux系统中也存在容易捕捉的TASK_UNINTERRUPTIBLE状态。执行vfork系统调用后,父进程将进入TASK_UNINTERRUPTIBLE状态,直到子进程调用exit或exec。
通过下面的代码就能得到处于TASK_UNINTERRUPTIBLE状态的进程:

1
2
3
4
#include <unistd.h>
void main() {
if (!vfork()) sleep(100);
}

编译运行,然后ps一下:
1
2
3
4
kouu@kouu-one:~/test$ ps -ax | grep a\.out
4371 pts/0 D+ 0:00 ./a.out
4372 pts/0 S+ 0:00 ./a.out
4374 pts/1 S+ 0:00 grep a.out

然后我们可以试验一下TASK_UNINTERRUPTIBLE状态的威力。不管kill还是kill -9,这个TASK_UNINTERRUPTIBLE状态的父进程依然屹立不倒。

T(TASK_STOPPED or TASK_TRACED),暂停状态或跟踪状态。

向进程发送一个SIGSTOP信号,它就会因响应该信号而进入TASK_STOPPED状态(除非该进程本身处于TASK_UNINTERRUPTIBLE状态而不响应信号)。(SIGSTOP与SIGKILL信号一样,是非常强制的。不允许用户进程通过signal系列的系统调用重新设置对应的信号处理函数。)
向进程发送一个SIGCONT信号,可以让其从TASK_STOPPED状态恢复到TASK_RUNNING状态。

当进程正在被跟踪时,它处于TASK_TRACED这个特殊的状态。“正在被跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于TASK_TRACED状态。而在其他时候,被跟踪的进程还是处于前面提到的那些状态。
对于进程本身来说,TASK_STOPPED和TASK_TRACED状态很类似,都是表示进程暂停下来。
而TASK_TRACED状态相当于在TASK_STOPPED之上多了一层保护,处于TASK_TRACED状态的进程不能响应SIGCONT信号而被唤醒。只能等到调试进程通过ptrace系统调用执行PTRACE_CONT、PTRACE_DETACH等操作(通过ptrace系统调用的参数指定操作),或调试进程退出,被调试的进程才能恢复TASK_RUNNING状态。

Z(TASK_DEAD - EXIT_ZOMBIE),退出状态,进程成为僵尸进程。

进程在退出的过程中,处于TASK_DEAD状态。

在这个退出过程中,进程占有的所有资源将被回收,除了task_struct结构(以及少数资源)以外。于是进程就只剩下task_struct这么个空壳,故称为僵尸。
之所以保留task_struct,是因为task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。比如在shell中,$?变量就保存了最后一个退出的前台进程的退出码,而这个退出码往往被作为if语句的判断条件。
当然,内核也可以将这些信息保存在别的地方,而将task_struct结构释放掉,以节省一些空间。但是使用task_struct结构更为方便,因为在内核中已经建立了从pid到task_struct查找关系,还有进程间的父子关系。释放掉task_struct,则需要建立一些新的数据结构,以便让父进程找到它的子进程的退出信息。

父进程可以通过wait系列的系统调用(如wait4、waitid)来等待某个或某些子进程的退出,并获取它的退出信息。然后wait系列的系统调用会顺便将子进程的尸体(task_struct)也释放掉。
子进程在退出的过程中,内核会给其父进程发送一个信号,通知父进程来“收尸”。这个信号默认是SIGCHLD,但是在通过clone系统调用创建子进程时,可以设置这个信号。

通过下面的代码能够制造一个EXIT_ZOMBIE状态的进程:

1
2
3
4
5
#include <unistd.h>
void main() {
if (fork())
while(1) sleep(100);
}

编译运行,然后ps一下:
1
2
3
4
kouu@kouu-one:~/test$ ps -ax | grep a\.out
10410 pts/0 S+ 0:00 ./a.out
10411 pts/0 Z+ 0:00 [a.out] <defunct>
10413 pts/1 S+ 0:00 grep a.out

只要父进程不退出,这个僵尸状态的子进程就一直存在。那么如果父进程退出了呢,谁又来给子进程“收尸”?
当进程退出的时候,会将它的所有子进程都托管给别的进程(使之成为别的进程的子进程)。托管给谁呢?可能是退出进程所在进程组的下一个进程(如果存在的话),或者是1号进程。所以每个进程、每时每刻都有父进程存在。除非它是1号进程。

1号进程,pid为1的进程,又称init进程。
linux系统启动后,第一个被创建的用户态进程就是init进程。它有两项使命:

  1. 执行系统初始化脚本,创建一系列的进程(它们都是init进程的子孙);
  2. 在一个死循环中等待其子进程的退出事件,并调用waitid系统调用来完成“收尸”工作;

init进程不会被暂停、也不会被杀死(这是由内核来保证的)。它在等待子进程退出的过程中处于TASK_INTERRUPTIBLE状态,“收尸”过程中则处于TASK_RUNNING状态。

X(TASK_DEAD - EXIT_DEAD),退出状态,进程即将被销毁。

而进程在退出过程中也可能不会保留它的task_struct。比如这个进程是多线程程序中被detach过的进程(进程?线程?参见《linux线程浅析》)。或者父进程通过设置SIGCHLD信号的handler为SIG_IGN,显式的忽略了SIGCHLD信号。(这是posix的规定,尽管子进程的退出信号可以被设置为SIGCHLD以外的其他信号。)
此时,进程将被置于EXIT_DEAD退出状态,这意味着接下来的代码立即就会将该进程彻底释放。所以EXIT_DEAD状态是非常短暂的,几乎不可能通过ps命令捕捉到。

一些重要的杂项

调度程序的效率

“优先级”明确了哪个进程应该被调度执行,而调度程序还必须要关心效率问题。调度程序跟内核中的很多过程一样会频繁被执行,如果效率不济就会浪费很多CPU时间,导致系统性能下降。
在linux 2.4时,可执行状态的进程被挂在一个链表中。每次调度,调度程序需要扫描整个链表,以找出最优的那个进程来运行。复杂度为O(n);
在linux 2.6早期,可执行状态的进程被挂在N(N=140)个链表中,每一个链表代表一个优先级,系统中支持多少个优先级就有多少个链表。每次调度,调度程序只需要从第一个不为空的链表中取出位于链表头的进程即可。这样就大大提高了调度程序的效率,复杂度为O(1);
在linux 2.6近期的版本中,可执行状态的进程按照优先级顺序被挂在一个红黑树(可以想象成平衡二叉树)中。每次调度,调度程序需要从树中找出优先级最高的进程。复杂度为O(logN)。

那么,为什么从linux 2.6早期到近期linux 2.6版本,调度程序选择进程时的复杂度反而增加了呢?
这是因为,与此同时,调度程序对公平性的实现从上面提到的第一种思路改变为第二种思路(通过动态调整优先级实现)。而O(1)的算法是基于一组数目不大的链表来实现的,按我的理解,这使得优先级的取值范围很小(区分度很低),不能满足公平性的需求。而使用红黑树则对优先级的取值没有限制(可以用32位、64位、或更多位来表示优先级的值),并且O(logN)的复杂度也还是很高效的。

调度触发的时机

调度的触发主要有如下几种情况:
1、当前进程(正在CPU上运行的进程)状态变为非可执行状态。
进程执行系统调用主动变为非可执行状态。比如执行nanosleep进入睡眠、执行exit退出、等等;
进程请求的资源得不到满足而被迫进入睡眠状态。比如执行read系统调用时,磁盘高速缓存里没有所需要的数据,从而睡眠等待磁盘IO;
进程响应信号而变为非可执行状态。比如响应SIGSTOP进入暂停状态、响应SIGKILL退出、等等;

2、抢占。进程运行时,非预期地被剥夺CPU的使用权。这又分两种情况:进程用完了时间片、或出现了优先级更高的进程。
优先级更高的进程受正在CPU上运行的进程的影响而被唤醒。如发送信号主动唤醒,或因为释放互斥对象(如释放锁)而被唤醒;
内核在响应时钟中断的过程中,发现当前进程的时间片用完;
内核在响应中断的过程中,发现优先级更高的进程所等待的外部资源的变为可用,从而将其唤醒。比如CPU收到网卡中断,内核处理该中断,发现某个socket可读,于是唤醒正在等待读这个socket的进程;再比如内核在处理时钟中断的过程中,触发了定时器,从而唤醒对应的正在nanosleep系统调用中睡眠的进程;

内核抢占

理想情况下,只要满足“出现了优先级更高的进程”这个条件,当前进程就应该被立刻抢占。但是,就像多线程程序需要用锁来保护临界区资源一样,内核中也存在很多这样的临界区,不大可能随时随地都能接收抢占。
linux 2.4时的设计就非常简单,内核不支持抢占。进程运行在内核态时(比如正在执行系统调用、正处于异常处理函数中),是不允许抢占的。必须等到返回用户态时才会触发调度(确切的说,是在返回用户态之前,内核会专门检查一下是否需要调度);
linux 2.6则实现了内核抢占,但是在很多地方还是为了保护临界区资源而需要临时性的禁用内核抢占。

也有一些地方是出于效率考虑而禁用抢占,比较典型的是spin_lock。spin_lock是这样一种锁,如果请求加锁得不到满足(锁已被别的进程占有),则当前进程在一个死循环中不断检测锁的状态,直到锁被释放。
为什么要这样忙等待呢?因为临界区很小,比如只保护“i+=j++;”这么一句。如果因为加锁失败而形成“睡眠-唤醒”这么个过程,就有些得不偿失了。
那么既然当前进程忙等待(不睡眠),谁又来释放锁呢?其实已得到锁的进程是运行在另一个CPU上的,并且是禁用了内核抢占的。这个进程不会被其他进程抢占,所以等待锁的进程只有可能运行在别的CPU上。(如果只有一个CPU呢?那么就不可能存在等待锁的进程了。)
而如果不禁用内核抢占呢?那么得到锁的进程将可能被抢占,于是可能很久都不会释放锁。于是,等待锁的进程可能就不知何年何月得偿所望了。

对于一些实时性要求更高的系统,则不能容忍spin_lock这样的东西。宁可改用更费劲的“睡眠-唤醒”过程,也不能因为禁用抢占而让更高优先级的进程等待。比如,嵌入式实时linux montavista就是这么干的。
由此可见,实时并不代表高效。很多时候为了实现“实时”,还是需要对性能做一定让步的。

多处理器下的负载均衡

前面我们并没有专门讨论多处理器对调度程序的影响,其实也没有什么特别的,就是在同一时刻能有多个进程并行地运行而已。那么,为什么会有“多处理器负载均衡”这个事情呢?
如果系统中只有一个可执行队列,哪个CPU空闲了就去队列中找一个最合适的进程来执行。这样不是很好很均衡吗?
的确如此,但是多处理器共用一个可执行队列会有一些问题。显然,每个CPU在执行调度程序时都需要把队列锁起来,这会使得调度程序难以并行,可能导致系统性能下降。而如果每个CPU对应一个可执行队列则不存在这样的问题。
另外,多个可执行队列还有一个好处。这使得一个进程在一段时间内总是在同一个CPU上执行,那么很可能这个CPU的各级cache中都缓存着这个进程的数据,很有利于系统性能的提升。
所以,在linux下,每个CPU都有着对应的可执行队列,而一个可执行状态的进程在同一时刻只能处于一个可执行队列中。

于是,“多处理器负载均衡”这个麻烦事情就来了。内核需要关注各个CPU可执行队列中的进程数目,在数目不均衡时做出适当调整。什么时候需要调整,以多大力度进程调整,这些都是内核需要关心的。当然,尽量不要调整最好,毕竟调整起来又要耗CPU、又要锁可执行队列,代价还是不小的。
另外,内核还得关心各个CPU的关系。两个CPU之间,可能是相互独立的、可能是共享cache的、甚至可能是由同一个物理CPU通过超线程技术虚拟出来的……CPU之间的关系也是实现负载均衡的重要依据。关系越紧密,进程在它们之间迁移的代价就越小。参见《linux内核SMP负载均衡浅析》。

优先级继承

由于互斥,一个进程(设为A)可能因为等待进入临界区而睡眠。直到正在占有相应资源的进程(设为B)退出临界区,进程A才被唤醒。
可能存在这样的情况:A的优先级非常高,B的优先级非常低。B进入了临界区,但是却被其他优先级较高的进程(设为C)抢占了,而得不到运行,也就无法退出临界区。于是A也就无法被唤醒。
A有着很高的优先级,但是现在却沦落到跟B一起,被优先级并不太高的C抢占,导致执行被推迟。这种现象就叫做优先级反转。

出现这种现象是很不合理的。较好的应对措施是:当A开始等待B退出临界区时,B临时得到A的优先级(还是假设A的优先级高于B),以便顺利完成处理过程,退出临界区。之后B的优先级恢复。这就是优先级继承的方法。

中断处理线程化

在linux下,中断处理程序运行于一个不可调度的上下文中。从CPU响应硬件中断自动跳转到内核设定的中断处理程序去执行,到中断处理程序退出,整个过程是不能被抢占的。
一个进程如果被抢占了,可以通过保存在它的进程控制块(task_struct)中的信息,在之后的某个时间恢复它的运行。而中断上下文则没有task_struct,被抢占了就没法恢复了。
中断处理程序不能被抢占,也就意味着中断处理程序的“优先级”比任何进程都高(必须等中断处理程序完成了,进程才能被执行)。但是在实际的应用场景中,可能某些实时进程应该得到比中断处理程序更高的优先级。
于是,一些实时性要求更高的系统就给中断处理程序赋予了task_struct以及优先级,使得它们在必要的时候能够被高优先级的进程抢占。但是显然,做这些工作是会给系统造成一定开销的,这也是为了实现“实时”而对性能做出的一种让步。

通用Linux系统

通用Linux系统支持实时和非实时两种进程,实时进程相对于普通进程具有绝对的优先级。对应地,实时进程采用SCHED_FIFO或者SCHED_RR调度策略,普通的进程采用SCHED_OTHER调度策略。

在调度算法的实现上,Linux中的每个任务有四个与调度相关的参数,它们是rt_priority、policy、priority(nice)、counter。调度程序根据这四个参数进行进程调度。

在SCHED_OTHER调度策略中,调度器总是选择那个priority+counter值最大的进程来调度执行。从逻辑上分析SCHED_OTHER调度策略存在着调度周期(epoch),在每一个调度周期中,一个进程的priority和counter值的大小影响了当前时刻应该调度哪一个进程来执行,其中priority是一个固定不变的值,在进程创建时就已经确定,它代表了该进程的优先级,也代表这该进程在每一个调度周期中能够得到的时间片的多少;counter是一个动态变化的值,它反映了一个进程在当前的调度周期中还剩下的时间片。在每一个调度周期的开始,priority的值被赋给counter,然后每次该进程被调度执行时,counter值都减少。当counter值为零时,该进程用完自己在本调度周期中的时间片,不再参与本调度周期的进程调度。当所有进程的时间片都用完时,一个调度周期结束,然后周而复始。另外可以看出Linux系统中的调度周期不是静态的,它是一个动态变化的量,比如处于可运行状态的进程的多少和它们priority值都可以影响一个epoch的长短。值得注意的一点是,在2.4以上的内核中,priority被nice所取代,但二者作用类似。

可见SCHED_OTHER调度策略本质上是一种比例共享的调度策略,它的这种设计方法能够保证进程调度时的公平性—一个低优先级的进程在每一个 epoch中也会得到自己应得的那些CPU执行时间,另外它也提供了不同进程的优先级区分,具有高priority值的进程能够获得更多的执行时间。对于实时进程来说,它们使用的是基于实时优先级rt_priority的优先级调度策略,但根据不同的调度策略,同一实时优先级的进程之间的调度方法有所不同:

  • SCHED_FIFO:不同的进程根据静态优先级进行排队,然后在同一优先级的队列中,谁先准备好运行就先调度谁,并且正在运行的进程不会被终止直到以下情况发生:(1).被有更高优先级的进程所强占CPU;(2).自己因为资源请求而阻塞;(3).自己主动放弃CPU(调用sched_yield)。
  • SCHED_RR:这种调度策略跟上面的SCHED_FIFO一模一样,除了它给每个进程分配一个时间片,时间片到了正在执行的进程就放弃执行;时间片的长度可以通过sched_rr_get_interval调用得到。

由于Linux系统本身是一个面向桌面的系统,所以将它应用于实时应用中时存在如下的一些问题:

  • Linux系统中的调度单位为10ms,所以它不能够提供精确的定时;
  • 当一个进程调用系统调用进入内核态运行时,它是不可被抢占的;
  • Linux内核实现中使用了大量的锁中断操作会造成中断的丢失;
  • 由于使用虚拟内存技术,当发生页出错时,需要从硬盘中读取交换数据,但硬盘读写由于存储位置的随机性会导致随机的读写时间,这在某些情况下会影响一些实时任务的截止期限;
  • 虽然Linux进程调度也支持实时优先级,但缺乏有效的实时任务的调度机制和调度算法;它的网络子系统的协议处理和其它设备的中断处理都没有与它对应的进程的调度关联起来,并且它们自身也没有明确的调度机制;

实时Linux研究

瘦内核(微内核)- Thin-Kernel

瘦内核(或微内核)方法使用了第二个内核作为硬件与Linux内核间的抽象接口。非实时Linux内核在后台运行,作为瘦内核的一项低优先级任务托管全部非实时任务。实时任务直接在瘦内核上运行。瘦内核主要用于(除了托管实时任务外)中断管理。瘦内核截取中断以确保非实时内核无法抢占瘦内核的运行。这允许瘦内核提供硬实时支持。

虽然瘦内核方法有自己的优势(硬实时支持与标准Linux内核共存),但这种方法也有缺点。实时任务和非实时任务是独立的,这造成了调试困难。而且,非实时任务并未得到Linux平台的完全支持(瘦内核之所以称为瘦的一个原因)。使用这种方法的例子有RTLinux(现在由Wind River Systems专有),实时应用程序接口(RTAI)和Xenomai。

超微内核

这里瘦内核方法依赖于包含任务管理的最小内核,而超微内核法对内核进行更进一步的缩减。通过这种方式,它不像是一个内核而更像是一个硬件抽象层(HAL)。超微内核为运行于更高级别的多个操作系统提供了硬件资源共享。因为超微内核对硬件进行了抽象,因此它可为更高级别的操作系统提供优先权,从而支持实时性。

注意,这种方法和运行多个操作系统的虚拟化方法有一些相似之处。使用这种方法的情况下,超微内核在实时和非实时内核中对硬件进行抽象。这与 hypervisor 从客户(guest)操作系统对裸机进行抽象的方式很相似。

关于超微内核的示例是操作系统的Adaptive Domain Environment for Operating Systems(ADEOS)。ADEOS支持多个并发操作系统同步运行。当发生硬件事件后,ADEOS对链中的每个操作系统进行查询以确定使用哪一个系统处理事件。

资源内核(Resource-kernel)

另一个实时架构是资源内核法。这种方法为内核增加一个模块,为各种资源提供预留(reservation)。这种机制保证了对时分复用(time- multiplexed)系统资源的访问(CPU、网络或磁盘带宽)。这些资源拥有多个预留参数,如循环周期、需要的处理时间(也就是完成处理所需的时间),以及截止时间。

资源内核提供了一组应用程序编程接口(API),允许任务请求这些预留资源。然后资源内核可以合并这些请求,使用任务定义的约束定义一个调度,从而提供确定的访问(如果无法提供确定性则返回错误)。通过调度算法,如Earliest-Deadline-First(EDF),内核可以处理动态的调度负载。

资源内核法实现的一个示例是CMU公司的Linux/RK,它把可移植的资源内核集成到Linux中作为一个可加载模块。这种实现演化成商用的 TimeSys Linux/RT 产品。

标准的Linux内核最新版本2.6中加入了实时功能

目前探讨的这些方法在架构上都很有趣,但是它们都在内核的外围运行。然而,如果对标准Linux内核进行必要的修改使其支持实时性,结果会怎么样呢?

今天,在2.6内核中,通过对内核进行简单配置使其完全可抢占,您就可以得到软实时功能。在标准2.6 Linux内核中,当用户空间的进程执行内核调用时(通过系统调用),它便不能被抢占。这意味着如果低优先级进程进行了系统调用后,高优先级进程必须等到调用结束后才能访问CPU。

新的配置选项CONFIG_PREEMPT改变了这一内核行为,在高优先级任务可用的情况下(即使此进程正在进行系统调用),它允许进程被抢占。

但这种配置选项也是一种折衷。虽然此选项实现了软实时性能并且即使在负载条件下也可使操作系统顺利地运行,但这样做也付出了代价。代价就是略微减低了吞吐量以及内核性能,原因是CONFIG_PREEMPT选项增加了开销。这种选项对桌面和嵌入式系统而言是有用的,但并不是在任何场景下都有用(例如,服务器)。

在2.6内核中另一项有用的配置选项是高精度定时器。这个新选项允许定时器以1μs的精度运行(如果底层硬件支持的话),并通过红黑树实现对定时器的高效管理。通过红黑树,可以使用大量的定时器而不会对定时器子系统(O(log n))的性能造成影响。

只需要一点额外的工作,就可以通过PREEMPT_RT补丁实现硬实时。PREEMPT_RT补丁提供了多项修改,可实现硬实时支持。其中一些修改包括重新实现一些内核锁定原语,从而实现完全可抢占,实现内核互斥的优先级继承,并把中断处理程序转换为内核线程以实现线程可抢占。

高速缓存以及TLB与虚拟内存

内存管理单元MMU

这里假设大家了解虚拟内存的由来。参考《深入理解计算机系统》讲虚拟内存的章节

实际上我们写的程序,都是面向虚拟内存的。我们在程序中写的变量的地址,实际上是虚拟内存中的地址,当CPU想要访问该地址的时候,内存管理单元MMU会将该虚拟地址翻译成真实的物理地址,然后CPU就去真实的物理地址处取得数据。

这里说的虚拟地址,是指虚拟地址空间中地址。这里我们说的虚拟地址空间,实际上是在磁盘上的一块空间(常见的是4G的进程虚拟地址空间)。具体这4G的虚拟地址空间的来龙去脉,参考《深入理解计算机系统》第九章。

MMU:内存管理单元。它是一个硬件,不是软件。它用于将虚拟地址翻译成实际的物理内存地址。同时它还可以将特定的内存块设置成不同的读写属性,进而实现内存保护。注意,MMU是硬件管理,不是软件实现内存管理。

总结来说,MMU能实现以下功能:

虚拟内存。有了虚拟内存,可以在处理器上运行比实际物理内存大的应用程序。为了使用虚拟内存,操作系统通常要设置一个交换区(通常在硬盘上),通过将内存中不活跃的数据与指令放到交换区,以腾出物理内存来为其他程序服务。
内存保护。通过这一功能,可以将特定的内存块设置为读、写或者可执行的属性。比如将不可变的数据或者代码设为只读的,这样可以防止被恶意串改。

虚拟内存

进程的概念大家都知道。

每一个进程都独立的运行在自己的虚拟地址空间。为了理解这一个概念。我们可以看一个而简单的例子:

看一下下面的代码:
main.c

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <stdlib.h>

int g_int = 1;
int main() {
printf("g_int = %d\n",g_int);
printf("&g_int = %d\n",&g_int);

system("pause");//此处程序会停止执行,不会执行到return 0
return 0;
}

如果我同时运行该程序两次。打印结果会是一样么?答案是结果肯定一样,运行结果都为:

当然,这是在我的计算机上,在你的计算机上g_int地址可能不一样,但是同时运行该程序两次,结果肯定是一样的。其实这个答案很多人都知道是一样的,初学者都知道。但是初学者说不清楚是为什么。

这个进程运行两份实例的时候。在物理内存中,实际上是以下分布情况:

进程1和进程2 位于不同的地址。但是我们程序打印的g_int全局变量的地址值,是一样的。

这里就引入了虚拟内存的概念。我们写程序,面向的是虚拟地址空间。写的程序的内容,都可以看成是在虚拟地址空间中运行(实际上最终是将虚拟地址空间映射到了物理地址空间)。如下图:

我们可以看到。main.o可执行程序,运行两份实例时,相当于两个进程。这两个进程都有自己独立的虚拟地址空间。然后将虚拟地址空间里的代码数据映射到内存中,从而被CPU执行与处理。在物理内存中,g_int这个全局变量的物理地址确实不同。但是在虚拟内存中,由于进程1与进程2的虚拟地址空间完全一样(同一个可执行程序代码),那么g_int地址,实际上就是一样的。

CPU在执行指令与数据时,获得的是虚拟内存的地址。但是CPU只能去物理内存寻址。此时,MMU就派上用场了。MMU负责,将虚拟地址,翻译成,真正运行时的物理地址。

MMU是如何将虚拟地址翻译成物理地址的,这个后面讲。现在先要了解一下交换区的概念。

交换区: 实际上就是一块磁盘空间(硬盘空间)。虚拟内存与物理内存映射的时候,是将虚拟内存的代码放到交换区中,以后在CPU想要执行相关的指令或者数据时,如果内存中没有,先去交换区将需要的指令与数据映射到物理内存,然后CPU再执行。
虚拟内存与交换取的这种概念,实现了大内存需求量的(多个)进程,能够(同时)运行在较小的物理内存中。如下图所示:

上图中,说的是进程的局部代码在物理内存中运行。是因为程序具有局部性原则,所以在某一段很小的时间段内,只有很少一部分代码会被CPU执行。具体可以参考下一篇文章。

到这里,我们应该大致明白了虚拟内存的作用与简单机制。还剩下MMU如何翻译虚拟地址为物理地址的,这放到最后讲解。现在先总结一下虚拟内存机制:

虚拟内存需要重新映射到物理内存
虚拟地址映射到物理地址中的实际地址
每次只有进程的少部分代码会在物理内存中运行
大部分代码依然位于磁盘中(存储器硬盘)

页式内存管理

上一节笼统的介绍了虚拟内存的概念。接下来学习内存管理中的一种方式:页式内存管理。

页的概念

由1.1的内容,我们知道了交换区。我们知道交换区里面存放的是大部分的可执行代码与数据。而物理内存中,执行的是少部分的可执行代码与数据。那么当物理内存中的代码与数据执行完需要执行接下来的代码,而刚好接下来的代码还在交换区中没有映射到物理内存(这称为缺页,后面会讲),那么此时就需要从交换区获取程序的代码,将它拿到物理内存执行。那么一次拿多少代码过来呢?这是一个问题!

为了CPU的高效执行以及方便的内存管理(详细原因见以后的文章),每次需要拿一个页的代码。这个页,指的是一段连续的存储空间(常见的是4Kb),也叫作块。假设页的大小为P。在虚拟内存中,叫做虚拟页(VP)。从虚拟内存拿了一个页的代码要放到物理内存,那么自然物理内存也得有一个刚好一般大小的页才能存放虚拟页的代码。物理内存中的页叫做物理页(PP)

在任何时刻,虚拟页都是以下三种状态中的一种:

  • 未分配的:VM系统还未分配的页(或者未创建)。未分配的页还没有任何数据与代码与他们相关联,因此也就不占用任何磁盘。
  • 缓存的: 当前已缓存在物理内存中的已分配页
  • 未缓存的:未缓存在物理内存中的已分配页

下图展示了一个8个虚拟页的小虚拟内存。其中:虚拟页0和3还没有被分配,因此在磁盘上还不存在。虚拟页1、4、 6被缓存在物理内存中。虚拟页2、 5、 7已经被分配,但是还没有缓存到物理内存中去执行。

页表的概念

1.21节用到了缓存这个词。这里假设大家都理解缓存的概念。

虚拟内存中的一些虚拟页是缓存在物理内存中被执行的。理所应当,应该有一种机制,来判断虚拟页,是否被缓存在了物理内存中的某个物理页上。如果不命中(需要一个页的代码,但是这个页未缓存在物理内存中),系统还必须知道这个虚拟页存放在磁盘上的哪个位置,从而在物理内存中选择一个空闲页或者替换一个牺牲页,并将需要的虚拟页从磁盘复制到物理内存中。

这些功能,是由软硬件结合完成的。 包括操作系统软件,MMU中的地址翻译硬件,和一个存放在物理内存中的页表的数据结构。

上一节说将虚拟页映射到物理页,实际上就是MMU地址翻译硬件将一个虚拟地址翻译成物理地址时,都会去读取页表的内容。操作系统负责维护页表的内容,以及在磁盘与物理内存之间来回传送页。

下图是一个页表的基本组织结构(实际上不止那些内容):

页表实际上就是一个数组。这个数组存放的是一个称为页表条目(PTE)的结构。虚拟地址空间的每一个页在页表中,都有一个对应的页表条目(PTE)。虚拟页地址(首地址)翻译的时候就是查询的各个虚拟页在页表中的PTE,从而进行地址翻译的。

现在假设每一个PTE都有一个有效位和一个n位字段的地址。其中

有效位:表示对应的虚拟页是否缓存在了物理内存中。0表示未缓存。1表示已缓存。
n位地址字段:如果未缓存(有效字段为0),n位地址字段不为空的话,这个n位地址字段就表示该虚拟页在磁盘上的起始的位置。如果这个n位字段为空,那么就说明该虚拟页未分配。如果已缓存(有效字段为1),n位地址字段肯定不为空,它表示该虚拟页在物理内存中的起始地址。
综上分析,就得知,上图中:四个虚拟页VP1 , VP2, VP4 , VP7 是被缓存在物理内存中。 两个虚拟页VP0, VP5还未被分配。但是剩下的虚拟页VP3 ,VP6已经被分配了,但是还没有缓存到物理内存中去执行。

注意:任意的物理页,都可以缓存任意的虚拟页。(因为物理内存是全相联的)

页命中

考虑下图的情形:

假设现在CPU想读取VP2页面中的某一个字节的内容。会发生什么呢?

当CPU得到一个地址vaddr想要访问它(这个addr就是上面想要访问的某一个字节的地址),通过后面会学习的MMU地址翻译硬件,将虚拟地址addr作为索引定位到页表的PTE条目中的PTE2(这里假设是PTE2),从内存中去读到PTE2的有效位为1,说明该虚拟页面已经被缓存了,所以CPU使用该PTE2条目中的物理内存地址(这个物理内存地址是PP1中的起始地址)构造出vaddr的物理地址paddr(这个地址是PP1页面起始地址或后面的某一个地址)。然后CPU就会去paddr这个物理内存地址去取数据。这种情况,就是也命中。

实际上,上面的VP2的起始地址与paddr地址,很类似于内存的分段机制(X86以前就是分段机制),CPU访问内存的地址是“段地址:偏移地址”或者叫做“CS:IP”。而我们现在学习的是分页机制,他们都是一种内存管理机制。

缺页

什么是缺页?

考虑以下图示情形:

当CPU想访问VP3页面中的某一个字节。会发生什么情况?

由1.23小节的分析知,当地址翻译硬件MMU找到了PTE3后,发现有效位为0,则说明VP3并未缓存在物理内存中,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会在物理内存中查询是否有空闲页面。如果物理内存中有空闲页面,则将VP3页面的内容从磁盘中复制到(映射)物理内存中的空闲页面。如果物理内存中没有空闲页面,则缺页异常处理程序就选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改了,那么内核就会将它复制回磁盘。

然后此时因为VP3已经在物理内存中被缓存了,就需要将页表更新,也就是更新PTE3。

随后缺页异常处理程序返回。它会重新启动导致缺页的指令,该指令会重新将刚刚导致缺页的虚拟地址发送到MMU硬件翻译,但是此时,因为VP3已经被缓存,所以会页命中。

下图是在经过了缺页后,我们的示例页表的状态:

以上有一个过程是替换页面的过程,其中包含一个页面调度算法。这个以后会学习。
1.25 分配页面
当你在程序中调用malloc或者new分配内存时,发生了什么?调用malloc后,会在虚拟内存中分配页面。(注意malloc分配的内存时虚拟内存,当CPU访问的时候,首先肯定会发生缺页,然后再将该页缓存到物理内存中)

如下图所示:
本身没有VP5这个虚拟页面,现在malloc后,新分配了一个虚拟页面VP5。

分配好VP5这个虚拟页面后,还需要更新PTE条目,使得PTE5指向VP5。

程序的局部性原则

虚拟内存这种机制会有什么问题?经常缺页会不会导致程序的执行效率低下?

实际上,虽然会产生不命中现象,但是虚拟内存机制工作的很好。这主要与程序局部性原则有关!!!什么是程序的局部性?

尽管在程序整个运行的生命周期,引用的不同的页面总数可能会超过物理内存的大小,但是局部性原则保证了在任意时刻:程序将趋向于在一个较小的活动页面集合上工作。 这个集合成为工作集或者常驻集合。在最开始,也就是将工作集页面调度到物理内存中之后,接下来对这个工作集的引用将导致页命中,而不会产生额外的磁盘流量。

上面看似很完美,但是也有可能会出现这样一种情况:工作集的大小超过了物理内存的大小!! 此时,页面会不停的换入换出。这种状态叫做抖动!!!

当然,现在的计算机的物理内存的大小都非常大,一般不会出现抖动的现象!!!

虚拟内存作为内存管理工具

虚拟内存为什么说是一种内存管理工具?

虚拟内存大大地简化了内存管理,并提供了一种自然的保护内存的方法。

到目前为止,我们都假设有一个单独的页表,将一个虚拟地址空间映射到物理地址空间。实际上,操作系统为每一个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间。如下图:

注意:多个虚拟页面,可以映射到同一个共享物理页面上。

按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理产生了深远的影响!!!如下:

  • 简化链接。
  • 简化加载
  • 简化共享
  • 简化内存分配
    具体参考CSAPP:9.4节内容。

虚拟内存作为内存保护工具

上一节学习了虚拟内存作为内存管理工具。

其实虚拟内存还可以作为内存保护工具。如何做到?

想一想,CPU在访问一个虚拟内存页面时,需要读取页表条目中的PTE条目。如果在PTE条目中加一些额外的许可位来控制对虚拟内存的访问,当CPU读到相应的许可位,就可以知道该虚拟内存是否可读或者可写,或者可执行? 这样看来我们的页表就要变化一下,就如下图所示:

上图中:

  • SUP表示进程是否必须运行在内核模式(超级用户)下才能访问该页。
  • READ表示是否可读
  • WRITE表示是否可写

如果一条指令违反了这些许可条件,那么CPU就会触发一个一般保护故障,将控制传递给一个内核中的异常处理程序。Linux shell 一般将这种异常报告为“段错误(segmentation fault)”

地址翻译

上面一直在说MMU通过读取页表的PTE将虚拟地址翻译成物理地址。到底是如何翻译的?

如下图,展示了MMU是如何翻译地址的:

看到这么复杂的图,不要害怕!!! 下面讲解很容易懂!

CPU中有一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。
n位的虚拟地址,包含两个部分:虚拟页面偏移VPO(p位)与虚拟页号VPN(n-p位)
MMU利用虚拟内存的高n-p位VPN作为索引找到页表的对应的PTE条目,然后获取PTE条目对应的物理页号PPN
然后将PPN与VPO串联连接起来,就得到了实际的物理地址。(实际上就是PPN左移p位然后加上VPO,VPO=PPO)
到这里实际上我们已经更加的将这种地址串联与X86处理器中的分段机制很像。X86-16位的分段机制 也是将段地址CS左移4位然后与偏移地址IP相加,得到最终的物理地址。这是不是与上面的分页机制的地址翻译过程很像? 实际上它们一个是实模式,一个是保护模式而已!

MMU的地址翻译过程是不是很简单?如果不理解,就反复看,就理解了!!!

总结

下面来总结一下,分页机制中,CPU获得一个虚拟地址后,有哪些步骤需要做:

当页命中时,CPU硬件执行的步骤

注释:VA:虚拟地址 PTEA:页表条目地址 PTE:页表条目 PA物理内存地址

如上图,CPU的执行步骤如下:

  • 处理器生成一个虚拟地址,并把它传送给MMU
  • MMU生成PTE地址,并从高速缓存/物理内存请求得到它
  • 高速缓存/物理内存向MMU返回PTE
  • MMU根据得到的PTE索引页表,从而构造物理地址,并把物理地址传送给高速缓存/物理内存
  • 高速缓存/物理内存返回请求的数据或者指令给CPU

当缺页时,CPU的硬件执行过程

注释:VA:虚拟地址 PTEA:页表条目地址 PTE:页表条目

如上图,CPU的执行步骤如下:

  • 处理器生成一个虚拟地址,并把它传送给MMU
  • MMU生成PTE地址,并从高速缓存/物理内存请求得到它
  • 高速缓存/物理内存向MMU返回PTE
  • PTE中的有效位是0,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序
  • 缺页异常处理程序确定出物理内存中的牺牲页,如果这个页面被修改了,就将它换出道磁盘
  • 缺页异常处理程序将需要的页面调入到高速缓存/物理内存,并更新内存中的PTE
  • 缺页异常处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的地址再次发送给MMU。因为虚拟页面现在缓存在物理内存中了,所以此次就会命中,物理内存就会将所请求的数据或者指令返回给CPU

可以看到,页命中与缺页的前三步,都是一样的。我们还可以总结出一个重要的结论:
页命中完全是由硬件来处理的,而缺页,却是由硬件和操作系统内核共同完成的。

高速缓存(Cache)的引入

看看上面分析页命中与缺页的过程中,出现了高速缓存,如果只有物理内存很好理解,现在出现高速缓存是啥意思?

学习过上一篇文章,我们应该可以理解页命中,缺页这些简单的概念以及虚拟地址的寻址过程(如果不明白,建议先学习上一篇文章)。

我们知道,CPU寻址时,从内存中获取指令与数据的时间还是相当大的(CPU的速度远远大于内存的速度)。所以高速缓存(Cache)就出现了。

  • Cache是一种小容量高速存储器
  • Cache的存取速度与CPU的运算速度几乎同量级
  • Cache在现代计算机系统中直接内置于处理器芯片中
  • 在处理器和内存之间设置cache(精确来讲是将Cache放在MMU与物理内存之间)
  • 把内存中被频繁访问的数据和指令复制到cache中
  • 页表也在内存中,将被频繁访问的PTE,复制到Cache中
  • 大多数情况下,CPU可以直接从cache中取指令与数据

如下图,我们先来看一个高速缓存与虚拟内存结合的例子,看看此时CPU的访问过程:

这个图,其实很好理解!!!当MMU要查询PTEA以及PA时,都先去高速缓存中先查一下,看看有没有,如果高速缓存中有PTEA与PA,直接从高速缓存中获取数相应的PTE与数据。

如果高速缓存中没有相应的PTEA或者PA时,就去物理内存中获取,然后从物理内存中获取之后,将获取到的PTE或者数据再缓存到高速缓存中,然后高速缓存将获取到的数据返回给CPU执行。

注意:因为Cache是放在MMU与物理内存之间的,所以高速缓存无需处理保护问题,因为访问权限的检查是MMU地址翻译过程的一部分。

利用TLB加速地址翻译

学到了这里,我们应该很清楚地址翻译的过程了。如果不清楚,就需要看上一篇文章或者深入理解计算机系统第九章。

在地址翻译的过程中,CPU每产生一个虚拟地址(VP),MMU都要去别的地方查询一个PTE。这个别的地方指:高速缓存或者物理内存。在最坏的情况下(缺页),需要访问两次物理内存。这种开销是极其昂贵的。在最好的情况下,MMU也需要去高速缓存中获取PTE对应的值。虽然高速缓存已经很快了,但是相对于CPU内部来说,还是有点慢。那么能不能MMU不去别的地方获取PTE?能不能在MMU内部也搞一个类似于高速缓存的东西,来存储一部分经常被访问的PTE?答案是可以的!!!在MMU中,有一个小的缓存,称为翻译后备缓冲器(TLB)

如下图示来看看带有TLB的 MMU,且TLB命中时,是如何执行的

  • CPU产生一个虚拟地址
  • 第二部和第三部是MMU从TLB中取出相应的PTE
  • MMU将这个虚拟地址翻译成一个物理地址,并将它发送到高速缓存/物理内存。
  • 高速缓存/物理内存将所请求的数据字返回给CPU

我们可以看到,TLB是虚拟寻址的缓存。

下面再来看看TLB不命中时,是如何执行的

当TLB不命中时,关键点在于,MMU必须从L1高速缓存中获取到相应的PTE,新取出的PTE再放到TLB中,此时可能会覆盖一个已经存在的条目。那么当TLB中有了相应的PTE,MMU再去TLB中查找…

Cache与物理内存是如何映射的

这里我们只学习一下直接映射法:

直接映射法:

  • 将cache和物理内存分成固定大小的块(如512byte/块)
  • 物理内存中的每一块在cache中都有固定的映射位置
  • 对应的映射公式为:
    • Pos(cache) = 内存块号 % cache总块数

如图:

注意:任意一个物理内存块都可以映射到唯一固定的cache块(物理内存不同的块,可以映射到同一个cache块)。

直接映射原理

比如我们想要访问某一个物理地址,我们如何知道这个地址是否在cache中?或者如何知道它在cache中的位置?

首先,现在只有一个物理地址,需要根据这个物理地址进行判断。

看下面,对物理地址有一个划分:

以上的物理地址分为3部分,都是什么意思呢?

我们利用以下规则来判断;

  • 根据物理地址的中间的c位,找到cache中对应的块
  • 比较物理地址的高t位,让它与cache中的flag比较,看是否相同
    • 如果相同:说明数据在高速缓存中有缓存,那么此时根据物理内存的b位找到cache对应的块中的偏移
    • 如果不同:说明数据在缓存中没有缓存,此时就将物理内存中对应的数据复制到cache中

比如下面这个例子:

直接映射法的特点

我们已经知道,直接映射法,很有可能不同的物理内存块映射到相同的cache块。所以直接映射法这样会导致缓存失效。但是直接映射法过程简单,所需耗时短!!

总结

下面笼统的用流程图概括一下处理器的数据访问过程:

关于按字寻址和按字节寻址的理解

原文:https://blog.csdn.net/lishuhuakai/article/details/8934540

我们先从一道简单的问题说起!

设有一个1MB容量的存储器,字长32位,问:按字节编址,字编址的寻址范围以及各自的寻址范围大小?

如果按字节编址,则 1MB = 2^20B , 1字节=1B=8bit, 2^20B/1B = 2^20 ,地址范围为0~(2^20)-1,也就是说需要二十根地址线才能完成对1MB空间的编码,所以地址寄存器为20位,寻址范围大小为2^20=1M。

如果按字编址,则1MB=2^20B,1字=32bit=4B,2^20B/4B = 2^18

地址范围为0~2^18-1,也就是说我们至少要用18根地址线才能完成对1MB空间的编码。因此按字编址的寻址范围是2^18

以上题目注意几点:

  1. 区分寻址空间与寻址范围两个不同的概念,寻址范围仅仅是一个数字范围,不带有单位。而寻址范围的大小很明显是一个数,指寻址区间的大小;而寻址空间指能够寻址最大容量,单位一般用MB、B来表示;本题中寻址范围为0~(2^20)-1,寻址空间为1MB。

  2. 按字节寻址,指的是存储空间的最小编址单位是字节,按字编址,是指存储空间的最小编址单位是字,以上题为例,总的存储器容量是一定的,按字编址和按字节编址所需要的编码数量是不同的,按字编址由于编址单位比较大(1字=32bit=4B),从而编码较少,而按字节编址由于编码单位较小(1字节=1B=8bit),从而编码较多。

  3. 区别M和MB。M为数量单位。1024=1K,1024K=1M,MB指容量大小。1024B=1KB,1024KB=1MB.

  4. 想要搞清按字寻址和按字节寻址就要先搞清位、字节、字长、字的定义 :

  • 位:数据存储的最小单位。计算机中最小的数据单位,一个位的取值只能是0或1;
  • 字节:由八位二进制数组成,是计算机中最基本的计量单位,也是最重要的计量单位(个人理解)。
  • 字长:计算机中对CPU在单位时间内能处理的最大二进制数的位数叫做字长。
  • 字:字是不同计算机系统中占据一个单独的地址(内存单元的编号)并作为一个单元(由一个或多个字节组合而成)处理的一组二进制数。

下面是我对于按字寻址和按字节寻址的理解:

  • 按字节寻址:最通俗的理解就是一组地址线的每个不同状态对应一个字节的地址。比如说有24根地址线,按字节寻址,而且每根线有两个状态,那么24根地址线组成的地址信号就有2^24个不同状态,每个状态对应一个字节的地址空间的话,24根地址线的可寻址空间2^24B,即16MB。
  • 按字寻址:最通俗的理解就是一组地址线的每个不同状态对应一个字的地址。因为字节是计算机中最基本的计量单位且一个字由若干字节构成,所以计算机在寻址过程中会区分字里面的字节,即会给字里面的字节编址,这样就会占用部分地址线。比如说有24根地址线,按字寻址,字长16位,16位即两个字节,这样就会占用一根地址线用来字内寻址,这样就剩下23根地址线,所以寻址范围是2^23W,即8MW,这里W是字长的意思。

理解 CPU Cache

下列两个循环哪个快?

1
2
3
4
5
6
7
8
9
10
int array[1024][1024]

// Loop 1
for(int i = 0; i < 1024; i ++)
for(int j = 0; j < 1024; j ++)
array[i][j] ++;
// Loop 2
for(int i = 0; i < 1024; i ++)
for(int j = 0; j < 1024; j ++)
array[j][i] ++;

Loop 1 的 CPU cache 命中率高,所以它比 Loop 2 约快八倍!

Gallery of Processor Cache Effects 用 7 个源码示例生动的介绍 cache 原理,深入浅出!但是可能因操作系统的差异、编译器是否优化,以及近些年 cache 性能的提升,第 3 个样例在 Mac 的效果与原文相差较大。另外 Berkeley 公开课 CS162 图文并茂,非常推荐。本文充当搬运工的角色,集二者之精华科普 CPU cache 知识。

What is Cache

维基百科定义为:在计算机系统中,CPU cache(中文简称缓存)是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU 寄存器。其容量远小于内存,但速度却可以接近处理器的频率。

原图出处(CS162)。Note:早期的L2 cache 位于主板,现在L2和L3 cache均封装于 CPU 芯片。

CPU 访问内存时,首先查询 cache 是否已缓存该数据。如果有,则返回数据,无需访问内存;如果不存在,则需把数据从内存中载入 cache,最后返回给理器。在处理器看来,缓存是一个透明部件,旨在提高处理器访问内存的速率,所以从逻辑的角度而言,编程时无需关注它,但是从性能的角度而言,理解其原理和机制有助于写出性能更好的程序。Cache 之所以有效,是因为程序对内存的访问存在一种概率上的局部特征:

  • Spatial Locality:对于刚被访问的数据,其相邻的数据在将来被访问的概率高。
  • Temporal Locality:对于刚被访问的数据,其本身在将来被访问的概率高。

从广义的角度而言,cache 可以分为两类:

  • 数据(指令) cache: 缓存内存数据,根据层级又可分为 L1、L2 和 L3,如果 miss,CPU 需访内存获取数据(指令)。
  • TLB(Translation lookaside buffer): 寻址 cache,缓存进程的虚拟机地址和物理地址之间的映射关系,如果 miss,MMU 需多次访问内存获取多级 page table 才能计算出物理地址。

比 mac OS 为例,可用 sysctl 查询 cache 信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ sysctl -a

hw.cachelinesize: 64
hw.l1icachesize: 32768
hw.l1dcachesize: 32768
hw.l2cachesize: 262144
hw.l3cachesize: 3145728
machdep.cpu.cache.L2_associativity: 8
machdep.cpu.core_count: 2
machdep.cpu.thread_count: 4
machdep.cpu.tlb.inst.large: 8
machdep.cpu.tlb.data.small: 64
machdep.cpu.tlb.data.small_level1: 64
machdep.cpu.tlb.shared: 1024

如下图:

Why Cache

早期的 CPU 并没有 cache,以起于 1978 年的 intel x86 芯片为例,它从 1992 年开始才开始引入 cache:

  • 1992: 386 platform 引入 L1 cache
  • 1995: Pentium Pro 引入 L2 cache
  • 2008: Core i3 引入 L3 cache

CPU 和 RAM 主频的增长速率的巨大差距是 cache 引入的直接原因。从 1980 年到 2010 年二者的发展状况,CPU 性能的年增长速度约为 60%,而 RAM 仅有约 9%,巨大的差异导致数十年后,CPU 的速度约比 RAM 快数百倍。

有人问,为什么不提高 RAM 的速度,因为成本太高!成本因素也是 cache 分为多级的原因。越快的越贵,所以容量小;越慢越廉,容量可很大,它是成本和性能之间的折中方案。CS162 如下两句原话很好的概括了 cache 的作用。

  • Present as much memory as in the cheapest technology
  • Provide access at speed offered by the fastest technology

Cache line size

Cache line 是 cache 和 RAM 交换数据的最小单位,通常为 64 Byte。当 CPU 把内存的数据载入 cache 时,会把临近的共 64 Byte 的数据也一同载入,因为临近的数据在将来被访问的可能性大,这为 spatial locality 奠定了基础。本文开头的例子中,因为 loop 1 依次访问的数据在地址空间上是相邻的,故 cache 命中率高,耗时少。下列展示了如何测试 cache line 的 size:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
double diff, total_time = 0;

struct timeval t1, t2;
char *array, *clear_array;
array = malloc(ARRAY_SIZE * sizeof(char));
clear_array = malloc(L3_SIZE * sizeof(char));for(int t = 0; t < TIMES; t++) //loop for many times
{
gettimeofday(&t1, NULL);
for(int i = 0; i < ARRAY_SIZE; i += stride){
array[i]++;
}
gettimeofday(&t2, NULL);
diff = time_diff(t1, t2);
total_time += diff;

//clear array data in L1,L2,L3 cache
for(int i = 0; i < L3_SIZE; i ++){
clear_array[i] ++;
}
}

经归一化处理后,本人所测array[i] ++的平均时间与 stride 的关系如下(如果关闭 hardware prefetch,效果可能会更好):

L1、L2 and L3 cache size

L1, L2 和 L3 cache size 的测试方法如下,在循环内每隔 64 Byte(cache line) 访问 array 一次:

1
2
3
4
5
6
7
8
int steps = 64 * 1024 * 1024; // Arbitrary number of steps

int length_mod = ARRAY_SIZE - 1;

for (int i = 0; i < steps; i += 64)
{
array[i & length_mod]++; // (x & length_mod) is equal to (i % length)
}

所得结果为:

原图出处 Gallery of Processor Cache Effects 注:本例在本人 Mac 的效果远远差于原著的效果,故采用原图。
对于当前个人计算机的 CPU,L1 cache 通常为数十 KB,L2 cache 为数百 KB,L3 cache 可达数 MB,但是 TLB 相对较小,一般只有几百个 entry。

Instruction-level parallelism

下列循环哪个快?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Loop 1
gettimeofday(&t1, NULL);
for(int i = 0; i < ARRAY_SIZE - 1; i++){
array[0] ++;
array[0] ++;
}

# Loop 2
gettimeofday(&t2, NULL);
for(int i = 0; i < ARRAY_SIZE - 1; i++){
array[0] ++;
array[1] ++;
}gettimeofday(&t3, NULL);

loop1_time = time_diff(t1, t2);
loop2_time = time_diff(t2, t3);

Loop 2 的速度比 Loop 1 接近快一倍。
目前 CPU 可以实现一定程度上的并行,比如在同一个时间点访问 L1 Cache 上的两处数据,也可以在同一个时间点执行两条算术运算指令。对于 Loop 2,它并行的运行 array[0] ++array[1] ++

Loop 1

原图出处 Gallery of Processor Cache Effects

Loop 2

Cache associativity

本节主要介绍内存中的数据在 cache 的存放规则,即对于给定地址的数据 A,它该存放在 cache 的何处?要回答此问题,首先需介绍三种不同的存放规则:

  • Direct mapped cache: 数据 A 在 cache 的存放位置只有固定一处。
  • N-way set associative cache: 数据 A 在 cache 的存放位置可以有 N 处。
  • Full associative cache: 数据 A 可存放在 cache 的任意位置。

从硬件的角度出发,direct mapped cache 设计简单,full associative cache 设计复杂,特别当 cache size 很大时,硬件成本非常之高。但是在 direct mapped cache 下数据的存放地址是固定唯一的,所以容易产生碰撞,最终降低 cache 的命中率,影响性能。在成本和性能的权衡下,当前的 CPU 都是 N-way set associative cache,N 通常为 4,8 或 16。

以大小为 32 KB,cache line 的大小为 64 Byte 的某 cache 为例,对于不同存放规则,其硬件设计也不同,下列图片依次展示其原理。

Direct mapped cache

2-way set associative

Full associative cache

本人的 L2 cache 大小为 256 KB,8-way set associative,cache line 为 64 Byte,所以共有 512 个 set (256 K / 64 / 8),所以地址间隔 32768 (512 * 64) 个 Byte 的数据都会落在 cache 的同一个 set 中。

1
2
3
4
5
6
7
8
9
#define ARRAY_SIZE  64 * 1024 * 1024
#define STEPS 1024 * 1024 * 1024

for(int i = 0; i < STEPS; i++){
array[p] ++;
p += STRIDE;
if(p >= ARRAY_SIZE)
p = 0;
}

当STRIDE为32768时,array[p]总是访问相同cache set,造成大量的冲突和置换,所用时间为18s,当STRIDE为1时,所用时间为3.5s。

False cache line sharing

以本计算机的CPU(Intel(R) Core(TM) i5-4258U CPU @ 2.40GHz)为例,同一个core里的两个cpu thread共享L1和L2 cache,L3 cache则是由2个core共享。但一个cpu thread修改cache的某处时,该处所在的整个cache line都会被置为 invalid,其它的cpu thread不能使用该cache line,直到数据被同步到RAM中。

1
2
3
4
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
int counter[1024]; // global variable
void *update_counter(int position)
{
for(int i = 0; i < 1000000000; i ++ ){
counter[position] ++;
}
}int main()
{
double diff;
struct timeval t1, t2;
pthread_t tid1, tid2, tid3, tid4;

// Sharing
gettimeofday(&t1, NULL);
pthread_create(&tid1, NULL, update_counter, (void *)1);
pthread_create(&tid2, NULL, update_counter, (void *)2);
pthread_create(&tid3, NULL, update_counter, (void *)3);
pthread_create(&tid4, NULL, update_counter, (void *)4);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
pthread_join(tid4, NULL);
gettimeofday(&t2, NULL);
diff = time_diff(t1, t2);
printf("%f\n", diff);

// False Sharing
gettimeofday(&t1, NULL);
pthread_create(&tid1, NULL, update_counter, (void *)16);
pthread_create(&tid2, NULL, update_counter, (void *)32);
pthread_create(&tid3, NULL, update_counter, (void *)48);
pthread_create(&tid4, NULL, update_counter, (void *)64);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_join(tid3, NULL);
pthread_join(tid4, NULL);

gettimeofday(&t2, NULL);
diff = time_diff(t1, t2);
printf("%f\n", diff);
}

并行创建 4 个线程执行上述函数,当 position 值分别为 1,2,3,4 时,所用总时间为 13.1 s,当值为 16,32,48,64 时,所用总时间为 3.4 s。

Hardware complexities

上述例子为我们介绍了 Cache的基本原理,但是CPU还是非常复杂多样。例如:

1
2
3
4
5
int A, B, C, D, E, F, G;
for (int i = 0; i < 200000000; i++)
{
<something> // do something
}

结果为:
1
2
3
4
5
<something> Time
A++; B++; C++; D++; 719 ms
A++; C++; E++; G++; 448 ms
A++; C++; 518 ms
One more question

下列循环哪个快?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int array_512[512][512]
int array_513[513][513]

// Loop 1
for(int i = 0; i < 512; i++){
for(int j = 0; j < 512; j ++){
tmp = array_512[i][j];
array_512[i][j] = array_512[j][i];
array_512[j][i] = tmp;
}
}// Loop 2
for(int i = 0; i < 513; i++){
for(int j = 0; j < 513; j ++){
tmp = array_513[i][j];
array_513[i][j] = array_513[j][i];
array_513[j][i] = tmp;
}
}

Linux程序加载过程

原文链接:https://blog.csdn.net/hnzziafyz/article/details/52200265

一个进程在内存中主要占用了以下几个部分,分别是代码段、数据段、BSS,栈,堆,等参数。其中,代码、数据、BSS的内容是可执行文件中对应的内容,加载程序并不是把它们的内容从可执行程序中填充到内存中,而是将它们的信息(基地址、长度等)更新到进程控制块(task_struct)中,当CPU第 一次实际寻址执行的时候,就会引起缺页中断,操作系统再将实际的内容从可执行文件中复制内容到物理内存中。

堆的内容是程序执行中动态分配的,所以加载程序 只是将它的起始地址更新到进程控制块中,执行过程中遇到动态分配内存的操作的时候再在物理内存分配实际的页。参数区在新进程加载的时候要存入环境变量和命令行参数列表。栈在程序加载时候存入的内容就是环境参数列表和命令行参数列表的指针和命令行参数的个数。

1)在shell界面输入./可执行文件名。经shell分析,该参数非shell内建命令,则认为是加载可执行文件。于是调用fork函数开始创建新进程,产生0x80中断,映射到函数sys_fork()中,调用find_empty_process()函数,为新进程申请一个可用的进程号。

2)为可执行程序的管理结构找到存储空间。为了实现对进程的保护,系统为每个进程的管理专门设计了一个结构,即task_struct。内核通过调用get_free_page函数获得用于保存task_struct和内核栈的页面只能在内核的线性地址空间。

3)shell进程为新进程复制task_struct结构。行程序复制了task_struct后,新进程便继承了shell的全部管理信息。但由于每个进程呢的task_struct结构中的信息是不一样的,所以还要对该结构进行个性化设置(为防止在设置的过程中被切换到该进程,应先设置为不可中断状态)。个性化设置主要包括进程号、父进程、时间片、TSS段(为进程间切换而设计的,进程的切换时建立在对进程的保护的基础上的,在进程切换时TSS用来保存或恢复该进程的现场所用到的寄存器的值)。这些都是通过函数copy_process来完成的。

4)复制新进程页表并设置其对应的页目录项。现在调用函数copy_mem为进程分段(LDT),更新代码段和数据段的基地址,即确定线性地址空间(关键在于确定段基址和限长)。接着就是分页,分页是建立在分段的基础上的。

5)建立新进程与全局描述符(GDT)的关联,将新进程的TSS和LDT挂接在GDT的指定位置处。(注:TSS和LDT对进程的保护至关重要)

6)将新进程设置为就绪状态

7)加载可执行文件。进入do_execve函数之后,将可执行文件的头表加载到内存中并检测相关信息。加载执行程序(讲程序按需加载到内存)。

Linux系统调用的实现机制分析

转载自:http://blog.csdn.net/sailor_8318/archive/2008/09/10/2906968.aspx

系统调用意义

linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于核心态,而普通的函数调用由函数库或用户自己提供,运行于用户态。

一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作”保护模式”)。为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。

系统调用在用户空间进程和硬件设备之间添加了一个中间层。该层主要作用有三个:

  1. 它为用户空间提供了一种统一的硬件的抽象接口。比如当需要读些文件的时候,应用程序就可以不去管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型。
  2. 系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限和其他一些规则对需要进行的访问进行裁决。举例来说,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。
  3. 每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核又对此一无所知的话,几乎就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。在Linux中,系统调用是用户空间访问内核的惟一手段;除异常和中断外,它们是内核惟一的合法入口。

API/POSIX/C库的关系

一般情况下,应用程序通过应用编程接口(API)而不是直接通过系统调用来编程。这点很重要,因为应用程序使用的这种编程接口实际上并不需要和内核提供的系统调用一一对应。一个API定义了一组应用程序使用的编程接口。它们可以实现成一个系统调用,也可以通过调用多个系统调用来实现,而完全不使用任何系统调用也不存在问题。实际上,API可以在各种不同的操作系统上实现,给应用程序提供完全相同的接口,而它们本身在这些系统上的实现却可能迥异。

在Unix世界中,最流行的应用编程接口是基于POSIX标准的,其目标是提供一套大体上基于Unix的可移植操作系统标准。POSIX是说明API和系统调用之间关系的一个极好例子。在大多数Unix系统上,根据POSIX而定义的API函数和系统调用之间有着直接关系。

Linux的系统调用像大多数Unix系统一样,作为C库的一部分提供如下图所示。C库实现了 Unix系统的主要API,包括标准C库函数和系统调用。所有的C程序都可以使用C库,而由于C语言本身的特点,其他语言也可以很方便地把它们封装起来使用。

从程序员的角度看,系统调用无关紧要,他们只需要跟API打交道就可以了。相反,内核只跟系统调用打交道;库函数及应用程序是怎么使用系统调用不是内核所关心的。

关于Unix的界面设计有一句通用的格言“提供机制而不是策略”。换句话说,Unix的系统调用抽象出了用于完成某种确定目的的函数。至干这些函数怎么用完全不需要内核去关心。区别对待机制(mechanism)和策略(policy)是Unix设计中的一大亮点。大部分的编程问题都可以被切割成两个部分:“需要提供什么功能”(机制)和“怎样实现这些功能”(策略)。

系统调用的实现

系统调用处理程序

“当我输入 cat /proc/cpuinfo 时,cpuinfo() 函数是如何被调用的?”内核完成引导后,控制流就从相对直观的“接下来调用哪个函数?”改变为取决于系统调用、异常和中断。

用户空间的程序无法直接执行内核代码。它们不能直接调用内核空间中的函数,因为内核驻留在受保护的地址空间上。如果进程可以直接在内核的地址空间上读写的话,系统安全就会失去控制。所以,应用程序应该以某种方式通知系统,告诉内核自己需要执行一个系统调用,希望系统切换到内核态,这样内核就可以代表应用程序来执行该系统调用了。

通知内核的机制是靠软件中断实现的。首先,用户程序为系统调用设置参数。其中一个参数是系统调用编号。参数设置完成后,程序执行“系统调用”指令。x86系统上的软中断由int产生。这个指令会导致一个异常:产生一个事件,这个事件会致使处理器切换到内核态并跳转到一个新的地址,并开始执行那里的异常处理程序。此时的异常处理程序实际上就是系统调用处理程序。它与硬件体系结构紧密相关。

新地址的指令会保存程序的状态,计算出应该调用哪个系统调用,调用内核中实现那个系统调用的函数,恢复用户程序状态,然后将控制权返还给用户程序。系统调用是设备驱动程序中定义的函数最终被调用的一种方式。

系统调用号

在Linux中,每个系统调用被赋予一个系统调用号。这样,通过这个独一无二的号就可以关联系统调用。当用户空间的进程执行一个系统调用的时候,这个系统调用号就被用来指明到底是要执行哪个系统调用。进程不会提及系统调用的名称。

系统调用号相当关键,一旦分配就不能再有任何变更,否则编译好的应用程序就会崩溃。Linux有一个“未实现”系统调用sys_ni_syscall(),它除了返回一ENOSYS外不做任何其他工作,这个错误号就是专门针对无效的系统调用而设的。

因为所有的系统调用陷入内核的方式都一样,所以仅仅是陷入内核空间是不够的。因此必须把系统调用号一并传给内核。在x86上,系统调用号是通过eax寄存器传递给内核的。在陷人内核之前,用户空间就把相应系统调用所对应的号放入eax中了。这样系统调用处理程序一旦运行,就可以从eax中得到数据。其他体系结构上的实现也都类似。

内核记录了系统调用表中的所有已注册过的系统调用的列表,存储在sys_call_table中。它与体系结构有关,一般在entry.s中定义。这个表中为每一个有效的系统调用指定了惟一的系统调用号。sys_call_table是一张由指向实现各种系统调用的内核函数的函数指针组成的表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ENTRY(sys_call_table)
.long SYMBOL_NAME(sys_ni_syscall) /* 0 - old "setup()" system call*/
.long SYMBOL_NAME(sys_exit)
.long SYMBOL_NAME(sys_fork)
.long SYMBOL_NAME(sys_read)
.long SYMBOL_NAME(sys_write)
.long SYMBOL_NAME(sys_open) /* 5 */
.long SYMBOL_NAME(sys_close)
.long SYMBOL_NAME(sys_waitpid)
.long SYMBOL_NAME(sys_capget)
.long SYMBOL_NAME(sys_capset) /* 185 */
.long SYMBOL_NAME(sys_sigaltstack)
.long SYMBOL_NAME(sys_sendfile)
.long SYMBOL_NAME(sys_ni_syscall) /* streams1 */
.long SYMBOL_NAME(sys_ni_syscall) /* streams2 */
.long SYMBOL_NAME(sys_vfork) /* 190 */

system_call()函数通过将给定的系统调用号与NR_syscalls做比较来检查其有效性。如果它大于或者等于NR syscalls,该函数就返回一ENOSYS。否则,就执行相应的系统调用。

1
call *sys_ call-table(,%eax, 4)

由于系统调用表中的表项是以32位(4字节)类型存放的,所以内核需要将给定的系统调用号乘以4,然后用所得的结果在该表中查询其位置

参数传递

除了系统调用号以外,大部分系统调用都还需要一些外部的参数输人。所以,在发生异常的时候,应该把这些参数从用户空间传给内核。最简单的办法就是像传递系统调用号一样把这些参数也存放在寄存器里。在x86系统上,ebx, ecx, edx, esi和edi按照顺序存放前五个参数。需要六个或六个以上参数的情况不多见,此时,应该用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针。

给用户空间的返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。接下来许多关于系统调用处理程序的描述都是针对x86版本的。但不用担心,所有体系结构的实现都很类似。

参数验证

系统调用必须仔细检查它们所有的参数是否合法有效。举例来说,与文件I/O相关的系统调用必须检查文件描述符是否有效。与进程相关的函数必须检查提供的PID是否有效。必须检查每个参数,保证它们不但合法有效,而且正确。

最重要的一种检查就是检查用户提供的指针是否有效。试想,如果一个进程可以给内核传递指针而又无须被检查,那么它就可以给出一个它根本就没有访问权限的指针,哄骗内核去为它拷贝本不允许它访问的数据,如原本属于其他进程的数据。在接收一个用户空间的指针之前,内核必须保证:

  1. 指针指向的内存区域属于用户空间。进程决不能哄骗内核去读内核空间的数据。
  2. 指针指向的内存区域在进程的地址空间里。进程决不能哄骗内核去读其他进程的数据。
  3. 如果是读,该内存应被标记为可读。如果是写,该内存应被标记为可写。进程决不能绕过内存访问限制。

内核提供了两个方法来完成必须的检查和内核空间与用户空间之间数据的来回拷贝。注意,内核无论何时都不能轻率地接受来自用户空间的指针!这两个方法中必须有一个被调用。为了向用户空间写入数据,内核提供了copy_to_user(),它需要三个参数。第一个参数是进程空间中的目的内存地址。第二个是内核空间内的源地址。最后一个参数是需要拷贝的数据长度(字节数)。

为了从用户空间读取数据,内核提供了copy_from_ user(),它和copy-to-User()相似。该函数把第二个参数指定的位置上的数据拷贝到第一个参数指定的位置上,拷贝的数据长度由第三个参数决定。

如果执行失败,这两个函数返回的都是没能完成拷贝的数据的字节数。如果成功,返回0。当出现上述错误时,系统调用返回标准-EFAULT。

注意copy_to_user()和copy_from_user()都有可能引起阻塞。当包含用户数据的页被换出到硬盘上而不是在物理内存上的时候,这种情况就会发生。此时,进程就会休眠,直到缺页处理程序将该页从硬盘重新换回物理内存。

系统调用的返回值

系统调用(在Linux中常称作syscalls)通常通过函数进行调用。它们通常都需要定义一个或几个参数(输入)而且可能产生一些副作用,例如写某个文件或向给定的指针拷贝数据等等。为防止和正常的返回值混淆,系统调用并不直接返回错误码,而是将错误码放入一个名为errno的全局变量中。通常用一个负的返回值来表明错误。返回一个0值通常表明成功。如果一个系统调用失败,你可以读出errno的值来确定问题所在。通过调用perror()库函数,可以把该变量翻译成用户可以理解的错误字符串。

errno不同数值所代表的错误消息定义在errno.h中,你也可以通过命令”man 3 errno”来察看它们。需要注意的是,errno的值只在函数发生错误时设置,如果函数不发生错误,errno的值就无定义,并不会被置为0。另外,在处理errno前最好先把它的值存入另一个变量,因为在错误处理过程中,即使像printf()这样的函数出错时也会改变errno的值。

当然,系统调用最终具有一种明确的操作。举例来说,如getpid()系统调用,根据定义它会返回当前进程的PID。内核中它的实现非常简单:

1
2
3
4
asmlinkage long sys_ getpid(void)
{
return current-> tgid;
}

上述的系统调用尽管非常简单,但我们还是可以从中发现两个特别之处。首先,注意函数声明中的asmlinkage限定词,这是一个小戏法,用于通知编译器仅从栈中提取该函数的参数。所有的系统调用都需要这个限定词。其次,注意系统调用get_pid()在内核中被定义成sys_ getpid。这是Linux中所有系统调用都应该遵守的命名规则

添加新系统调用

给Linux添加一个新的系统调用是件相对容易的工作。怎样设计和实现一个系统调用是难题所在,而把它加到内核里却无须太多周折。让我们关注一下实现一个新的Linux系统调用所需的步骤。

实现一个新的系统调用的第一步是决定它的用途。它要做些什么?每个系统调用都应该有一个明确的用途。在Linux中不提倡采用多用途的系统调用(一个系统调用通过传递不同的参数值来选择完成不同的工作)。ioctl()就应该被视为一个反例。

新系统调用的参数、返回值和错误码又该是什么呢?系统调用的接口应该力求简洁,参数尽可能少。设计接口的时候要尽量为将来多做考虑。你是不是对函数做了不必要的限制?系统调用设计得越通用越好。不要假设这个系统调用现在怎么用将来也一定就是这么用。系统调用的目的可能不变,但它的用法却可能改变。这个系统调用可移植吗?别对机器的字节长度和字节序做假设。当你写一个系统调用的时候,要时刻注意可移植性和健壮性,不但要考虑当前,还要为将来做打算。

当编写完一个系统调用后,把它注册成一个正式的系统调用是件琐碎的工作:

在系统调用表的最后加入一个表项。每种支持该系统调用的硬件体系都必须做这样的工作。从0开始算起,系统调用在该表中的位置就是它的系统调用号。

对于所支持的各种体系结构,系统调用号都必须定义于中。

系统调用必须被编译进内核映象(不能被编译成模块)。这只要把它放进kernel/下的一个相关文件中就可以。

让我们通过一个虚构的系统调用f00()来仔细观察一下这些步骤。首先,我们要把sys_foo加入到系统调用表中去。对于大多数体系结构来说,该表位干entry.s文件中,形式如下:

1
2
3
4
5
6
ENTRY(sys_ call_ table)
.long sys_ restart_ syscall/*0*/
.long sys_ exit
.long sys_ fork
.long sys_ read
.long sys_write

我们把新的系统调用加到这个表的末尾:
1
.long sys_foo

虽然没有明确地指定编号,但我们加入的这个系统调用被按照次序分配给了283这个系统调用号。对于每种需要支持的体系结构,我们都必须将自己的系统调用加人到其系统调用表中去。每种体系结构不需要对应相同的系统调用号。

接下来,我们把系统调用号加入到<asm/unistd.h>中,它的格式如下:

1
2
3
4
5
6
7
/*本文件包含系统调用号*/
#define_ NR_ restart_ syscall
#define NR exit
#define NR fork
#define NR read
#define NR write
#define NR- mq getsetattr 282

然后,我们在该列表中加入下面这行:
1
#define_ NR_ foo 283

最后,我们来实现f00()系统调用。无论何种配置,该系统调用都必须编译到核心的内核映象中去,所以我们把它放进kernel/sys.c文件中。你也可以将其放到与其功能联系最紧密的代码中去

1
2
3
4
asmlinkage long sys-foo(void)
{
return THREAD SIZE
)

就是这样!严格说来,现在就可以在用户空间调用f00()系统调用了。

建立一个新的系统调用非常容易,但却绝不提倡这么做。通常模块可以更好的代替新建一个系统调用。

访问系统调用

系统调用上下文

内核在执行系统调用的时候处于进程上下文。current指针指向当前任务,即引发系统调用的那个进程。

在进程上下文中,内核可以休眠并且可以被抢占。这两点都很重要。首先,能够休眠说明系统调用可以使用内核提供的绝大部分功能。休眠的能力会给内核编程带来极大便利。在进程上下文中能够被抢占,其实表明,像用户空间内的进程一样,当前的进程同样可以被其他进程抢占。因为新的进程可以使用相同的系统调用,所以必须小心,保证该系统调用是可重人的。当然,这也是在对称多处理中必须同样关心的问题。

当系统调用返回的时候,控制权仍然在system_call()中,它最终会负责切换到用户空间并让用户进程继续执行下去。

系统调用访问示例

操作系统使用系统调用表将系统调用编号翻译为特定的系统调用。系统调用表包含有实现每个系统调用的函数的地址。例如,read() 系统调用函数名为 sys_read。read() 系统调用编号是 3,所以 sys_read()位于系统调用表的第四个条目中(因为系统调用起始编号为0)。从地址 sys_call_table + (3 * word_size) 读取数据,得到 sys_read()的地址。

找到正确的系统调用地址后,它将控制权转交给那个系统调用。我们来看定义 sys_read() 的位置,即fs/read_write.c 文件。这个函数会找到关联到 fd 编号(传递给 read() 函数的)的文件结构体。那个结构体包含指向用来读取特定类型文件数据的函数的指针。进行一些检查后,它调用与文件相关的 read() 函数,来真正从文件中读取数据并返回。与文件相关的函数是在其他地方定义的 —— 比如套接字代码、文件系统代码,或者设备驱动程序代码。这是特定内核子系统最终与内核其他部分协作的一个方面。

读取函数结束后,从sys_read()返回,它将控制权切换给ret_from_sys。它会去检查那些在切换回用户空间之前需要完成的任务。如果没有需要做的事情,那么就恢复用户进程的状态,并将控制权交还给用户程序。

从用户空间直接访问系统调用

通常,系统调用靠C库支持。用户程序通过包含标准头文件并和C库链接,就可以使用系统调用(或者调用库函数,再由库函数实际调用)。但如果你仅仅写出系统调用,glibc库恐怕并不提供支持。值得庆幸的是,Linux本身提供了一组宏,用于直接对系统调用进行访问。它会设置好寄存器并调用陷人指令。这些宏是_syscalln(),其中n的范围从0到6。代表需要传递给系统调用的参数个数,这是由于该宏必须了解到底有多少参数按照什么次序压入寄存器。举个例子,open()系统调用的定义是:

1
long open(const char *filename, int flags, int mode)

而不靠库支持,直接调用此系统调用的宏的形式为:

1
2
#define NR_ open 5
syscall3(long, open, const char*,filename, int, flags, int, mode)

这样,应用程序就可以直接使用open()

对于每个宏来说,都有2+ n个参数。第一个参数对应着系统调用的返回值类型。第二个参数是系统调用的名称。再以后是按照系统调用参数的顺序排列的每个参数的类型和名称。_NR_ open在中定义,是系统调用号。该宏会被扩展成为内嵌汇编的C函数。由汇编语言执行前一节所讨论的步骤,将系统调用号和参数压入寄存器并触发软中断来陷入内核。调用open()系统调用直接把上面的宏放置在应用程序中就可以了。

让我们写一个宏来使用前面编写的foo()系统调用,然后再写出测试代码炫耀一下我们所做的努力。

1
2
3
4
5
6
7
8
9
#define NR foo 283
_sysca110(long, foo)
int main()
{
long stack size;
stack_ size=foo();
printf("The kernel stack size is 81d/n",stack_ size);
return;
}

系统调用表

以下是Linux系统调用的一个列表,包含了大部分常用系统调用和由系统调用派生出的的函数。其中有一些函数的作用完全相同,只是参数不同。可能很多熟悉C++朋友马上就能联想起函数重载,但是别忘了Linux核心是用C语言写的,所以只能取成不同的函数名。

进程控制

函数名 功能
fork 创建一个新进程
clone 按指定条件创建子进程
execve 运行可执行文件
exit 中止进程
_exit 立即中止当前进程
getdtablesize 进程所能打开的最大文件数
getpgid 获取指定进程组标识号
setpgid 设置指定进程组标志号
getpgrp 获取当前进程组标识号
setpgrp 设置当前进程组标志号
getpid 获取进程标识号
getppid 获取父进程标识号
getpriority 获取调度优先级
setpriority 设置调度优先级
modify_ldt 读写进程的本地描述表
nanosleep 使进程睡眠指定的时间
nice 改变分时进程的优先级
pause 挂起进程,等待信号
personality 设置进程运行域
prctl 对进程进行特定操作
ptrace 进程跟踪
sched_get_priority_max 取得静态优先级的上限
sched_get_priority_min 取得静态优先级的下限
sched_getparam 取得进程的调度参数
sched_getscheduler 取得指定进程的调度策略
sched_rr_get_interval 取得按RR算法调度的实时进程的时间片长度
sched_setparam 设置进程的调度参数
sched_setscheduler 设置指定进程的调度策略和参数
sched_yield 进程主动让出处理器,并将自己等候调度队列队尾
vfork 创建一个子进程,以供执行新程序,常与execve等同时使用
wait 等待子进程终止
wait3 参见wait
waitpid 等待指定子进程终止
wait4 参见waitpid
capget 获取进程权限
capset 设置进程权限
getsid 获取会晤标识号
setsid 设置会晤标识号

文件系统控制

文件读写操作

函数名 功能
fcntl 文件控制
open 打开文件
creat 创建新文件
lose 关闭文件描述字
read 读文件
write 写文件
readv 从文件读入数据到缓冲数组中
writev 将缓冲数组里的数据写入文件
pread 对文件随机读
pwrite 对文件随机写
lseek 移动文件指针
_llseek 在64位地址空间里移动文件指针
dup 复制已打开的文件描述字
dup2 按指定条件复制文件描述字
flock 文件加/解锁
poll I/O多路转换
truncate 截断文件
ftruncate 参见truncate
umask 设置文件权限掩码
fsync 把文件在内存中的部分写回磁盘

文件系统操作

函数名 功能
access 确定文件的可存取性
chdir 改变当前工作目录
fchdir 参见chdir
chmod 改变文件方式
fchmod 参见chmod
chown 改变文件的属主或用户组
fchown 参见chown
lchown 参见chown
chroot 改变根目录
stat 取文件状态信息
lstat 参见stat
fstat 参见stat
statfs 取文件系统信息
fstatfs 参见statfs
readdir 读取目录项
getdents 读取目录项
mkdir 创建目录
mknod 创建索引节点
rmdir 删除目录
rename 文件改名
link 创建链接
symlink 创建符号链接
unlink 删除链接
readlink 读符号链接的值
mount 安装文件系统
umount 卸下文件系统
ustat 取文件系统信息
utime 改变文件的访问修改时间
utimes 参见utime
quotactl 控制磁盘配额

系统控制

函数名 功能
ioctl I/O总控制函数
_sysctl 读/写系统参数
acct 启用或禁止进程记账
getrlimit 获取系统资源上限
setrlimit 设置系统资源上限
getrusage 获取系统资源使用情况
uselib 选择要使用的二进制函数库
ioperm 设置端口I/O权限
iopl 改变进程I/O权限级别
outb 低级端口操作
reboot 重新启动
swapon 打开交换文件和设备
swapoff 关闭交换文件和设备
bdflush 控制bdflush守护进程
sysfs 取核心支持的文件系统类型
sysinfo 取得系统信息
adjtimex 调整系统时钟
alarm 设置进程的闹钟
getitimer 获取计时器值
setitimer 设置计时器值
gettimeofday 取时间和时区
settimeofday 设置时间和时区
stime 设置系统日期和时间
time 取得系统时间
times 取进程运行时间
uname 获取当前UNIX系统的名称、版本和主机等信息
vhangup 挂起当前终端
nfsservctl 对NFS守护进程进行控制
vm86 进入模拟8086模式
create_module 创建可装载的模块项
delete_module 删除可装载的模块项
init_module 初始化模块
query_module 查询模块信息
*get_kernel_syms 取得核心符号,已被query_module代替

内存管理

函数名 功能
brk 改变数据段空间的分配
sbrk 参见brk
mlock 内存页面加锁
munlock 内存页面解锁
mlockall 调用进程所有内存页面加锁
munlockall 调用进程所有内存页面解锁
mmap 映射虚拟内存页
munmap 去除内存页映射
mremap 重新映射虚拟内存地址
msync 将映射内存中的数据写回磁盘
mprotect 设置内存映像保护
getpagesize 获取页面大小
sync 将内存缓冲区数据写回硬盘
cacheflush 将指定缓冲区中的内容写回磁盘

网络管理

函数名 功能
getdomainname 取域名
setdomainname 设置域名
gethostid 获取主机标识号
sethostid 设置主机标识号
gethostname 获取本主机名称
sethostname 设置主机名称

socket控制

函数名 功能
socketcall socket系统调用
socket 建立socket
bind 绑定socket到端口
connect 连接远程主机
accept 响应socket连接请求
send 通过socket发送信息
sendto 发送UDP信息
sendmsg 参见send
recv 通过socket接收信息
recvfrom 接收UDP信息
recvmsg 参见recv
listen 监听socket端口
select 对多路同步I/O进行轮询
shutdown 关闭socket上的连接
getsockname 取得本地socket名字
getpeername 获取通信对方的socket名字
getsockopt 取端口设置
setsockopt 设置端口参数
sendfile 在文件或端口间传输数据
socketpair 创建一对已联接的无名socket

用户管理

函数名 功能
getuid 获取用户标识号
setuid 设置用户标志号
getgid 获取组标识号
setgid 设置组标志号
getegid 获取有效组标识号
setegid 设置有效组标识号
geteuid 获取有效用户标识号
seteuid 设置有效用户标识号
setregid 分别设置真实和有效的的组标识号
setreuid 分别设置真实和有效的用户标识号
getresgid 分别获取真实的,有效的和保存过的组标识号
setresgid 分别设置真实的,有效的和保存过的组标识号
getresuid 分别获取真实的,有效的和保存过的用户标识号
setresuid 分别设置真实的,有效的和保存过的用户标识号
setfsgid 设置文件系统检查时使用的组标识号
setfsuid 设置文件系统检查时使用的用户标识号
getgroups 获取后补组标志清单
setgroups 设置后补组标志清单

进程间通信

函数名 功能
ipc 进程间通信总控制调用

信号

函数名 功能
sigaction 设置对指定信号的处理方法
sigprocmask 根据参数对信号集中的信号执行阻塞/解除阻塞等操作
sigpending 为指定的被阻塞信号设置队列
sigsuspend 挂起进程等待特定信号
signal 参见signal
kill 向进程或进程组发信号
*sigblock 向被阻塞信号掩码中添加信号,已被sigprocmask代替
*siggetmask 取得现有阻塞信号掩码,已被sigprocmask代替
*sigsetmask 用给定信号掩码替换现有阻塞信号掩码,已被sigprocmask代替
*sigmask 将给定的信号转化为掩码,已被sigprocmask代替
*sigpause 作用同sigsuspend,已被sigsuspend代替
sigvec 为兼容BSD而设的信号处理函数,作用类似sigaction
ssetmask ANSI C的信号处理函数,作用类似sigaction

消息

函数名 功能
msgctl 消息控制操作
msgget 获取消息队列
msgsnd 发消息
msgrcv 取消息

管道

函数名 功能
pipe 建管道

信号量

函数名 功能
semctl 信号量控制
semget 获取一组信号量
semop 信号量操作

共享内存

函数名 功能
shmctl 控制共享内存
shmget 获取共享内存
shmat 连接共享内存
shmdt 拆卸共享内存

中断和中断处理程序

原文:http://www.cnblogs.com/hanyan225/archive/2011/07/17/2108609.html

中断还是中断,我讲了很多次的中断了,今天还是要讲中断,为啥呢?因为在操作系统中,中断是必须要讲的..

那么什么叫中断呢, 中断还是打断,这样一说你就不明白了。唉,中断还真是有点像打断。我们知道linux管理所有的硬件设备,要做的第一件事先是通信。然后,我们天天在说一句话:处理器的速度跟外围硬件设备的速度往往不在一个数量级上,甚至几个数量级的差别,这时咋办,你总不能让处理器在那里傻等着你硬件做好了告诉我一声吧。这很容易就和日常生活联系起来了,这样效率太低,不如我处理器做别的事情,你硬件设备准备好了,告诉我一声就得了。这个告诉,咱们说的轻松,做起来还是挺费劲啊!怎么着,简单一点,轮训(polling)可能就是一种解决方法,缺点是操作系统要做太多的无用功,在那里傻傻的做着不重要而要重复的工作,这里有更好的办法—-中断,这个中断不要紧,关键在于从硬件设备的角度上看,已经实现了从被动为主动的历史性突破。

中断的例子我就不说了,这个很显然啊。分析中断,本质上是一种特殊的电信号,由硬件设备发向处理器,处理器接收到中断后,会马上向操作系统反应此信号的带来,然后就由OS负责处理这些新到来的数据,中断可以随时发生,才不用操心与处理器的时间同步问题。不同的设备对应的中断不同,他们之间的不同从操作系统级来看,差别就在于一个数字标识——-中断号。专业一点就叫中断请求(IRQ)线,通常IRQ都是一些数值量。有些体系结构上,中断好是固定的,有的是动态分配的,这不是问题所在,问题在于特定的中断总是与特定的设备相关联,并且内核要知道这些信息,这才是最关键的,不是么?哈哈.

用书上一句话说:讨论中断就不得不提及异常,异常和中断不一样,它在产生时必须要考虑与处理器的时钟同步,实际上,异常也常常称为同步中断,在处理器执行到由于编程失误而导致的错误指令的时候,或者是在执行期间出现特殊情况,必须要靠内核来处理的时候,处理器就会产生一个异常。因为许多处理器体系结构处理异常以及处理中断的方式类似,因此,内核对它们的处理也很类似。这里的讨论,大部分都是适合异常,这时可以看成是处理器本身产生的中断。

中断产生告诉中断控制器,继续告诉操作系统内核,内核总是要处理的,是不?这里内核会执行一个叫做中断处理程序或中断处理例程的函数。这里特别要说明,中断处理程序是和特定中断相关联的,而不是和设备相关联,如果一个设备可以产生很多中断,这时该设备的驱动程序也就需要准备多个这样的函数。一个中断处理程序是设备驱动程序的一部分,这个我们在linux设备驱动中已经说过,就不说了,后面我也会提到一些。前边说过一个问题:中断是可能随时发生的,因此必须要保证中断处理程序也能随时执行,中断处理程序也要尽可能的快速执行,只有这样才能保证尽可能快地恢复中断代码的执行。

但是,不想说但是,大学第一节逃课的情形现在仍记忆犹新:又想马儿跑,又想马儿不吃草,怎么可能!但现实问题或者不像想象那样悲观,我们的中断说不定还真有奇迹发生。这个奇迹就是将中断处理切为两个部分或两半。中断处理程序上半部(top half)—-接收到一个中断,它就立即开始开始执行,但只做严格时限的工作,这些工作都是在所有中断被禁止的情况下完成的。同时,能够被允许稍后完成的工作推迟到下半部(bottom half)去,此后,下半部会被执行,通常情况下,下半部都会在中断处理程序返回时立即执行。我会在后面谈论linux所提供的是实现下半部的各种机制。

说了那么多,现在开始第一个问题:如何注册一个中断处理程序。我们在linux驱动程序理论里讲过,通过一下函数可注册一个中断处理程序:

1
int request_irq(unsigned int irq,irqreturn_t (*handler)(int, void *,struct pt_regs *),unsigned long irqflags,const char * devname,void *dev_id)

有关这个中断的一些参数说明,我就不说了,一旦注册了一个中断处理程序,就肯定会有释放中断处理,这是调用下列函数:

1
void free_irq(unsigned int irq, void *dev_id)

这里需要说明的就是要必须要从进程上下文调用free_irq().好了,现在给出一个例子来说明这个过程,首先声明一个中断处理程序:

1
static irqreturn_t intr_handler(int irq, void *dev_id, struct pt_regs *regs)

注意:这里的类型和前边说到的request_irq()所要求的参数类型是匹配的,参数不说了。对于返回值,中断处理程序的返回值是一个特殊类型,irqrequest_t,可能返回两个特殊的值:IRQ_NONE和IRQ_HANDLED.当中断处理程序检测到一个中断时,但该中断对应的设备并不是在注册处理函数期间指定的产生源时,返回IRQ_NONE;当中断处理程序被正确调用,且确实是它所对应的设备产生了中断时,返回IRQ_HANDLED.C此外,也可以使用宏IRQ_RETVAL(x),如果x非0值,那么该宏返回IRQ_HANDLED,否则,返回IRQ_NONE.利用这个特殊的值,内核可以知道设备发出的是否是一种虚假的(未请求)中断。如果给定中断线上所有中断处理程序返回的都是IRQ_NONE,那么,内核就可以检测到出了问题。最后,需要说明的就是那个static了,中断处理程序通常会标记为static,因为它从来不会被别的文件中的代码直接调用。另外,中断处理程序是无需重入的,当一个给定的中断处理程序正在执行时,相应的中断线在所有处理器上都会被屏蔽掉,以防止在同一个中断上接收另外一个新的中断。通常情况下,所有其他的中断都是打开的,所以这些不同中断线上的其他中断都能被处理,但当前中断总是被禁止的。由此可见,同一个中断处理程序绝对不会被同时调用以处理嵌套的中断。

下面要说到的一个问题是和共享的中断处理程序相关的。共享和非共享在注册和运行方式上比较相似的。差异主要有以下几点:

  1. request_irq()的参数flags必须设置为SA_SHIRQ标志。
  2. 对每个注册的中断处理来说,dev_id参数必须唯一。指向任一设备结构的指针就可以满足这一要求。通常会选择设备结构,因为它是唯一的,而且中断处理程序可能会用到它,不能给共享的处理程序传递NULL值。
  3. 中断处理程序必须能够区分它的设备是否真的产生了中断。这既需要硬件的支持,也需要处理程序有相关的处理逻辑。如果硬件不支持这一功能,那中断处理程序肯定会束手无策,它根本没法知道到底是否与它对应的设备发生了中断,还是共享这条中断线的其他设备发出了中断。

在指定SA_SHIRQ标志以调用request_irq()时,只有在以下两种情况下才能成功:中断当前未被注册或者在该线上的所有已注册处理程序都指定了SA_SHIRQ.A。注意,在这一点上2.6与以前的内核是不同的,共享的处理程序可以混用SA_INTERRUPT. 一旦内核接收到一个中断后,它将依次调用在该中断线上注册的每一个处理程序。因此一个处理程序必须知道它是否应该为这个中断负责。如果与它相关的设备并没有产生中断,那么中断处理程序应该立即退出,这需要硬件设备提供状态寄存器(或类似机制),以便中断处理程序进行检查。毫无疑问,大多数设备都提这种功能。

当执行一个中断处理程序或下半部时,内核处于中断上下文(interrupt context)中。对比进程上下文,进程上下文是一种内核所处的操作模式,此时内核代表进程执行,可以通过current宏关联当前进程。此外,因为进程是进程上下文的形式连接到内核中,因此,在进程上下文可以随时休眠,也可以调度程序。但中断上下文却完全不是这样,它可以休眠,因为我们不能从中断上下文中调用函数。如果一个函数睡眠,就不能在中断处理程序中使用它,这也是对什么样的函数能在中断处理程序中使用的限制。还需要说明一点的是,中断处理程序没有自己的栈,相反,它共享被中断进程的内核栈,如果没有正在运行的进程,它就使用idle进程的栈。因为中断程序共享别人的堆栈,所以它们在栈中获取空间时必须非常节省。内核栈在32位体系结构上是8KB,在64位体系结构上是16KB.执行的进程上下文和产生的所有中断都共享内核栈。

下面给出中断从硬件到内核的路由过程(截图选自liuux内核分析与设计p61),然后做出总结:

上面的图内部说明已经很明确了,我这里就不在详谈。在内核中,中断的旅程开始于预定义入口点,这类似于系统调用。对于每条中断线,处理器都会跳到对应的一个唯一的位置。这样,内核就可以知道所接收中断的IRQ号了。初始入口点只是在栈中保存这个号,并存放当前寄存器的值(这些值属于被中断的任务);然后,内核调用函数do_IRQ().从这里开始,大多数中断处理代码是用C写的。do_IRQ()的声明如下:

1
unsigned int do_IRQ(struct pt_regs regs)

因为C的调用惯例是要把函数参数放在栈的顶部,因此pt_regs结构包含原始寄存器的值,这些值是以前在汇编入口例程中保存在栈上的。中断的值也会得以保存,所以,do_IRQ()可以将它提取出来,X86的代码为:

1
int irq = regs.orig_eax & 0xff

计算出中断号后,do_IRQ()对所接收的中断进行应答,禁止这条线上的中断传递。在普通的PC机器上,这些操作是由mask_and_ack_8259A()来完成的,该函数由do_IRQ()调用。接下来,do_IRQ()需要确保在这条中断线上有一个有效的处理程序,而且这个程序已经启动但是当前没有执行。如果这样的话, do_IRQ()就调用handle_IRQ_event()来运行为这条中断线所安装的中断处理程序,有关处理例子,可以参考linux内核设计分析一书,我这里就不细讲了。在handle_IRQ_event()中,首先是打开处理器中断,因为前面已经说过处理器上所有中断这时是禁止中断(因为我们说过指定SA_INTERRUPT)。接下来,每个潜在的处理程序在循环中依次执行。如果这条线不是共享的,第一次执行后就退出循环,否则,所有的处理程序都要被执行。之后,如果在注册期间指定了SA_SAMPLE_RANDOM标志,则还要调用函数add_interrupt_randomness(),这个函数使用中断间隔时间为随机数产生熵。最后,再将中断禁止(do_IRQ()期望中断一直是禁止的),函数返回。该函数做清理工作并返回到初始入口点,然后再从这个入口点跳到函数ret_from_intr().该函数类似初始入口代码,以汇编编写,它会检查重新调度是否正在挂起,如果重新调度正在挂起,而且内核正在返回用户空间(也就是说,中断了用户进程),那么schedule()被调用。如果内核正在返回内核空间(也就是中断了内核本身),只有在preempt_count为0时,schedule()才会被调用(否则,抢占内核是不安全的)。在schedule()返回之前,或者如果没有挂起的工作,那么,原来的寄存器被恢复,内核恢复到曾经中断的点。在x86上,初始化的汇编例程位于arch/i386/kernel/entry.S,C方法位于arch/i386/kernel/irq.c其它支持的结构类似。

下边给出PC机上位于/proc/interrupts文件的输出结果,这个文件存放的是系统中与中断相关的统计信息,这里就解释一下这个表:

上面是这个文件的输入,第一列是中断线(中断号),第二列是一个接收中断数目的计数器,第三列是处理这个中断的中断控制器,最后一列是与这个中断有关的设备名字,这个名字是通过参数devname提供给函数request_irq()的。最后,如果中断是共享的,则这条中断线上注册的所有设备都会列出来,如4号中断。

Linux内核给我们提供了一组接口能够让我们控制机器上的中断状态,这些接口可以在中找到。一般来说,控制中断系统的原因在于需要提供同步,通过禁止中断,可以确保某个中断处理程序不会抢占当前的代码。此外,禁止中断还可以禁止内核抢占。然而,不管是禁止中断还是禁止内核抢占,都没有提供任何保护机制来防止来自其他处理器的并发访问。Linux支持多处理器,因此,内核代码一般都需要获取某种锁,防止来自其他处理器对共享数据的并发访问,获取这些锁的同时也伴随着禁止本地中断。锁提供保护机制,防止来自其他处理器的并发访问,而禁止中断提供保护机制,则是防止来自其他中断处理程序的并发访问。

在linux设备驱动理论帖里详细介绍过linux的中断操作接口,这里就大致过一下,禁止/使能本地中断(仅仅是当前处理器)用:

1
2
local_irq_disable();
local_irq_enable();

如果在调用local_irq_disable()之前已经禁止了中断,那么该函数往往会带来潜在的危险,同样的local_irq_enable()也存在潜在的危险,因为它将无条件的激活中断,尽管中断可能在开始时就是关闭的。所以我们需要一种机制把中断恢复到以前的状态而不是简单地禁止或激活,内核普遍关心这点,是因为内核中一个给定的代码路径可以在中断激活饿情况下达到,也可以在中断禁止的情况下达到,这取决于具体的调用链。面对这种情况,在禁止中断之前保存中断系统的状态会更加安全一些。相反,在准备激活中断时,只需把中断恢复到它们原来的状态:

1
2
3
unsigned long flags;
local_irq_save(flags);
local_irq_restore(flags);

参数包含具体体系结构的数据,也就是包含中断系统的状态。至少有一种体系结构把栈信息与值相结合(SPARC),因此flags不能传递给另一个函数(换句话说,它必须驻留在同一个栈帧中),基于这个原因,对local_irq_save()的调用和local_irq_restore()的调用必须在同一个函数中进行。前面的所有的函数既可以在中断中调用,也可以在进程上下文使用。

前面我提到过禁止整个CPU上所有中断的函数。但有时候,好奇的我就想,我干么没要禁止掉所有的中断,有时,我只需要禁止系统中一条特定的中断就可以了(屏蔽掉一条中断线),这就有了我下面给出的接口:

1
2
3
4
void disable_irq(unsigned int irq);
void disable_irq_nosync(unsigned int irq);
void enable_irq(unsigned int irq);
void synchronise_irq(unsigned int irq);

对有关函数的说明和注意,我前边已经说的很清楚了,这里飘过。另外,禁止多个中断处理程序共享的中断线是不合适的。禁止中断线也就禁止了这条线上所有设备的中断传递,因此,用于新设备的驱动程序应该倾向于不使用这些接口。另外,我们也可以通过宏定义在<asm/system.h>中的宏irqs_disable()来获取中断的状态,如果中断系统被禁止,则它返回非0,否则,返回0;用定义在<asm/hardirq.h>中的两个宏in_interrupt()in_irq()来检查内核的当前上下文的接口。由于代码有时要做一些像睡眠这样只能从进程上下文做的事,这时这两个函数的价值就体现出来了。

信号中断与慢系统调用

慢系统调用(Slow system call)

该术语适用于那些可能永远阻塞的系统调用。永远阻塞的系统调用是指调用永远无法返回,多数网络支持函数都属于这一类。如:若没有客户连接到服务器上,那么服务器的accept调用就会一直阻塞。

慢系统调用可以被永久阻塞,包括以下几个类别:

(1)读写‘慢’设备(包括pipe,终端设备,网络连接等)。读时,数据不存在,需要等待;写时,缓冲区满或其他原因,需要等待。读写磁盘文件一般不会阻塞。

(2)当打开某些特殊文件时,需要等待某些条件,才能打开。例如:打开中断设备时,需要等到连接设备的modem响应才能完成。

(3)pause和wait函数。pause函数使调用进程睡眠,直到捕获到一个信号。wait等待子进程终止。

(4)某些ioctl操作。

(5)某些IPC操作。

EINTR错误产生的原因

早期的Unix系统,如果进程在一个慢系统调用(slow system call)中阻塞时,当捕获到某个信号且相应信号处理函数返回时,这个系统调用被中断,调用返回错误,设置errno为EINTR(相应的错误描述为“Interrupted system call”)。

怎么看哪些系统条用会产生EINTR错误呢?用man啊!

如下表所示的系统调用就会产生EINTR错误,当然不同的函数意义也不同。

系统调用函数 errno为EINTR表征的意义
write 由于信号中断,没写成功任何数据。
open 由于信号中断,没读到任何数据。
recv 由于信号中断返回,没有任何数据可用。
sem_wait 函数调用被信号处理函数中断。

如何处理被中断的系统调用

既然系统调用会被中断,那么别忘了要处理被中断的系统调用。有三种处理方式:

  • 人为重启被中断的系统调用
  • 安装信号时设置 SA_RESTART属性(该方法对有的系统调用无效)
  • 忽略信号(让系统不产生信号中断)

人为重启被中断的系统调用

人为当碰到EINTR错误的时候,有一些可以重启的系统调用要进行重启,而对于有一些系统调用是不能够重启的。例如:accept、read、write、select、和open之类的函数来说,是可以进行重启的。不过对于套接字编程中的connect函数我们是不能重启的,若connect函数返回一个EINTR错误的时候,我们不能再次调用它,否则将立即返回一个错误。针对connect不能重启的处理方法是,必须调用select来等待连接完成。

这里的“重启”怎么理解?

一些IO系统调用执行时,如 read 等待输入期间,如果收到一个信号,系统将中断read, 转而执行信号处理函数. 当信号处理返回后, 系统遇到了一个问题: 是重新开始这个系统调用, 还是让系统调用失败?早期UNIX系统的做法是, 中断系统调用,并让系统调用失败, 比如read返回 -1, 同时设置 errno 为EINTR中断了的系统调用是没有完成的调用,它的失败是临时性的,如果再次调用则可能成功,这并不是真正的失败,所以要对这种情况进行处理, 典型的方式为:

1
2
3
4
5
6
again:
if ((n = read(fd, buf, BUFFSIZE)) < 0) {
if (errno == EINTR)
goto again; /* just an interrupted system call */
/* handle other errors */
}

可以去github上看看别人怎么处理EINTR错误的。在github上搜索“==EINTR”关键字就有一大堆了。摘取几个看看:
1
2
while ((r = read (fd, buf, len)) < 0 && errno == EINTR) /*do
nothing*/ ;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ssize_t Read(int fd, void *ptr, size_t nbytes)
{

ssize_t n;

again:
if((n = read(fd, ptr, nbytes)) == -1){
if(errno == EINTR)
goto again;
else
return -1;
}
return n;
}

安装信号时设置 SA_RESTART属性

我们还可以从信号的角度来解决这个问题, 安装信号的时候, 设置 SA_RESTART属性,那么当信号处理函数返回后, 不会让系统调用返回失败,而是让被该信号中断的系统调用将自动恢复。

1
2
3
4
5
6
7
8
9
struct sigaction action;

action.sa_handler = handler_func;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
/* 设置SA_RESTART属性 */
action.sa_flags |= SA_RESTART;

sigaction(SIGALRM, &action, NULL);

但注意,并不是所有的系统调用都可以自动恢复。如msgsnd喝msgrcv就是典型的例子,msgsnd/msgrcv以block方式发送/接收消息时,会因为进程收到了信号而中断。此时msgsnd/msgrcv将返回-1,errno被设置为EINTR。且即使在插入信号时设置了SA_RESTART,也无效。在man msgrcv中就有提到这点:

msgsnd and msgrcv are never automatically restarted after being interrupted by a signal handler, regardless of the setting of the SA_RESTART flag when establishing a signal handler.

忽略信号

当然最简单的方法是忽略信号,在安装信号时,明确告诉系统不会产生该信号的中断。

1
2
3
4
5
6
struct sigaction action;

action.sa_handler = SIG_IGN;
sigemptyset(&action.sa_mask);

sigaction(SIGALRM, &action, NULL);

测试代码一

闹钟信号SIGALRM中断read系统调用。安装SIGALRM信号时如果不设置SA_RESTART属性,信号会中断read系统过调用。如果设置了SA_RESTART属性,read就能够自己恢复系统调用,不会产生EINTR错误。

1
2
3
4
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
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <string.h>
#include <unistd.h>

void sig_handler(int signum)
{
printf("in handler\n");
sleep(1);
printf("handler return\n");
}

int main(int argc, char **argv)
{
char buf[100];
int ret;
struct sigaction action, old_action;

action.sa_handler = sig_handler;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
/* 版本1:不设置SA_RESTART属性
* 版本2:设置SA_RESTART属性 */
//action.sa_flags |= SA_RESTART;

sigaction(SIGALRM, NULL, &old_action);
if (old_action.sa_handler != SIG_IGN) {
sigaction(SIGALRM, &action, NULL);
}
alarm(3);

bzero(buf, 100);

ret = read(0, buf, 100);
if (ret == -1) {
perror("read");
}

printf("read %d bytes:\n", ret);
printf("%s\n", buf);

return 0;
}

测试代码二

闹钟信号SIGALRM中断msgrcv系统调用。即使在插入信号时设置了SA_RESTART,也无效。

1
2
3
4
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
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

void ding(int sig)
{
printf("Ding!\n");
}

struct msgst
{
long int msg_type;
char buf[1];
};

int main()
{
int nMsgID = -1;

// 捕捉闹钟信息号
struct sigaction action;
action.sa_handler = ding;
sigemptyset(&action.sa_mask);
action.sa_flags = 0;
// 版本1:不设置SA_RESTART属性
// 版本2:设置SA_RESTART属性
action.sa_flags |= SA_RESTART;
sigaction(SIGALRM, &action, NULL);

alarm(3);
printf("waiting for alarm to go off\n");

// 新建消息队列
nMsgID = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
if( nMsgID < 0 )
{
perror("msgget fail" );
return;
}
printf("msgget success.\n");

// 阻塞 等待消息队列
//
// msgrcv会因为进程收到了信号而中断。返回-1,errno被设置为EINTR。
// 即使在插入信号时设置了SA_RESTART,也无效。man msgrcv就有说明。
//
struct msgst msg_st;
if( -1 == msgrcv( nMsgID, (void*)&msg_st, 1, 0, 0 ) )
{
perror("msgrcv fail");
}

printf("done\n");

exit(0);
}

总结

慢系统调用(slow system call)会被信号中断,系统调用函数返回失败,并且errno被置为EINTR(错误描述为“Interrupted system call”)。

处理方法有以下三种:

  1. 人为重启被中断的系统调用;
  2. 安装信号时设置 SA_RESTART属性;
  3. 忽略信号(让系统不产生信号中断)。

有时我们需要捕获信号,但又考虑到第2种方法的局限性(设置 SA_RESTART属性对有的系统无效,如msgrcv),所以在编写代码时,一定要“人为重启被中断的系统调用”。

Linux虚拟地址空间布局

在多任务操作系统中,每个进程都运行在属于自己的内存沙盘中。这个沙盘就是虚拟地址空间(Virtual Address Space),在32位模式下它是一个4GB的内存地址块。在Linux系统中, 内核进程和用户进程所占的虚拟内存比例是1:3,而Windows系统为2:2(通过设置Large-Address-Aware Executables标志也可为1:3)。这并不意味着内核使用那么多物理内存,仅表示它可支配这部分地址空间,根据需要将其映射到物理内存。

虚拟地址通过页表(Page Table)映射到物理内存,页表由操作系统维护并被处理器引用。内核空间在页表中拥有较高特权级,因此用户态程序试图访问这些页时会导致一个页错误(page fault)。在Linux中,内核空间是持续存在的,并且在所有进程中都映射到同样的物理内存。内核代码和数据总是可寻址,随时准备处理中断和系统调用。与此相反,用户模式地址空间的映射随进程切换的发生而不断变化。

Linux进程在虚拟内存中的标准内存段布局如下图所示:

其中,用户地址空间中的蓝色条带对应于映射到物理内存的不同内存段,灰白区域表示未映射的部分。这些段只是简单的内存地址范围,与Intel处理器的段没有关系。

上图中Random stack offset和Random mmap offset等随机值意在防止恶意程序。Linux通过对栈、内存映射段、堆的起始地址加上随机偏移量来打乱布局,以免恶意程序通过计算访问栈、库函数等地址。execve(2)负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2)还会将BSS段清零。

用户进程部分分段存储内容如下表所示(按地址递减顺序):

名称 存储内容
局部变量、函数参数、返回地址等
动态分配的内存
BSS段 未初始化或初值为0的全局变量和静态局部变量
数据段 已初始化且初值非0的全局变量和静态局部变量
代码段 可执行代码、字符串字面值、只读变量

在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载,并在内存中为这些段分配空间。栈也由操作系统分配和管理;堆由程序员自己管理,即显式地申请和释放空间。

BSS段、数据段和代码段是可执行程序编译时的分段,运行时还需要栈和堆。

以下详细介绍各个分段的含义。

内核空间

内核总是驻留在内存中,是操作系统的一部分。内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。

栈(stack)

栈又称堆栈,由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。堆栈主要有三个用途:

  • 为函数内部声明的非静态局部变量(C语言中称“自动变量”)提供存储空间。
  • 记录函数调用过程相关的维护性信息,称为栈帧(Stack Frame)或过程活动记录(Procedure Activation Record)。它包括函数返回地址,不适合装入寄存器的函数参数及一些寄存器值的保存。除递归调用外,堆栈并非必需。因为编译时可获知局部变量,参数和返回地址所需空间,并将其分配于BSS段。
  • 临时存储区,用于暂存长算术表达式部分计算结果或alloca()函数分配的栈内内存。

持续地重用栈空间有助于使活跃的栈内存保持在CPU缓存中,从而加速访问。进程中的每个线程都有属于自己的栈。向栈中不断压入数据时,若超出其容量就会耗尽栈对应的内存区域,从而触发一个页错误。此时若栈的大小低于堆栈最大值RLIMIT_STACK(通常是8M),则栈会动态增长,程序继续运行。映射的栈区扩展到所需大小后,不再收缩。

Linux中ulimit -s命令可查看和设置堆栈最大值,当程序使用的堆栈超过该值时, 发生栈溢出(Stack Overflow),程序收到一个段错误(Segmentation Fault)。注意,调高堆栈容量可能会增加内存开销和启动时间。

堆栈既可向下增长(向内存低地址)也可向上增长, 这依赖于具体的实现。本文所述堆栈向下增长。

堆栈的大小在运行时由内核动态调整。

内存映射段(mmap)

此处,内核将硬盘文件的内容直接映射到内存, 任何应用程序都可通过Linux的mmap()系统调用或Windows的CreateFileMapping()/MapViewOfFile()请求这种映射。内存映射是一种方便高效的文件I/O方式, 因而被用于装载动态共享库。用户也可创建匿名内存映射,该映射没有对应的文件, 可用于存放程序数据。在 Linux中,若通过malloc()请求一大块内存,C运行库将创建一个匿名内存映射,而不使用堆内存。”大块” 意味着比阈值 MMAP_THRESHOLD还大,缺省为128KB,可通过mallopt()调整。

该区域用于映射可执行文件用到的动态链接库。在Linux 2.4版本中,若可执行文件依赖共享库,则系统会为这些动态库在从0x40000000开始的地址分配相应空间,并在程序装载时将其载入到该空间。在Linux 2.6内核中,共享库的起始地址被往上移动至更靠近栈区的位置。

从进程地址空间的布局可以看到,在有共享库的情况下,留给堆的可用空间还有两处:一处是从.bss段到0x40000000,约不到1GB的空间;另一处是从共享库到栈之间的空间,约不到2GB。这两块空间大小取决于栈、共享库的大小和数量。这样来看,是否应用程序可申请的最大堆空间只有2GB?事实上,这与Linux内核版本有关。在上面给出的进程地址空间经典布局图中,共享库的装载地址为0x40000000,这实际上是Linux kernel 2.6版本之前的情况了,在2.6版本里,共享库的装载地址已经被挪到靠近栈的位置,即位于0xBFxxxxxx附近,因此,此时的堆范围就不会被共享库分割成2个“碎片”,故kernel 2.6的32位Linux系统中,malloc申请的最大内存理论值在2.9GB左右。

堆(heap)

堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。当进程调用malloc(C)/new(C++)等函数分配内存时,新分配的内存动态添加到堆上(扩张);当调用free(C)/delete(C++)等函数释放内存时,被释放的内存从堆中剔除(缩减) 。

分配的堆内存是经过字节对齐的空间,以适合原子操作。堆管理器通过链表管理每个申请的内存,由于堆申请和释放是无序的,最终会产生内存碎片。堆内存一般由应用程序分配释放,回收的内存可供重新使用。若程序员不释放,程序结束时操作系统可能会自动回收。

堆的末端由break指针标识,当堆管理器需要更多内存时,可通过系统调用brk()和sbrk()来移动break指针以扩张堆,一般由系统自动调用。

使用堆时经常出现两种问题:1) 释放或改写仍在使用的内存(“内存破坏”);2)未释放不再使用的内存(“内存泄漏”)。当释放次数少于申请次数时,可能已造成内存泄漏。泄漏的内存往往比忘记释放的数据结构更大,因为所分配的内存通常会圆整为下个大于申请数量的2的幂次(如申请212B,会圆整为256B)。

堆不同于数据结构中的”堆”,其行为类似链表。

【扩展阅读】栈和堆的区别

①管理方式:栈由编译器自动管理;堆由程序员控制,使用方便,但易产生内存泄露。

②生长方向:栈向低地址扩展(即”向下生长”),是连续的内存区域;堆向高地址扩展(即”向上生长”),是不连续的内存区域。这是由于系统用链表来存储空闲内存地址,自然不连续,而链表从低地址向高地址遍历。

③空间大小:栈顶地址和栈的最大容量由系统预先规定(通常默认2M或10M);堆的大小则受限于计算机系统中有效的虚拟内存,32位Linux系统中堆内存可达2.9G空间。

④存储内容:栈在函数调用时,首先压入主调函数中下条指令(函数调用语句的下条可执行语句)的地址,然后是函数实参,然后是被调函数的局部变量。本次调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的指令地址,程序由该点继续运行下条可执行语句。堆通常在头部用一个字节存放其大小,堆用于存储生存期与函数调用无关的数据,具体内容由程序员安排。

⑤分配方式:栈可静态分配或动态分配。静态分配由编译器完成,如局部变量的分配。动态分配由alloca函数在栈上申请空间,用完后自动释放。堆只能动态分配且手工释放。

⑥分配效率:栈由计算机底层提供支持:分配专门的寄存器存放栈地址,压栈出栈由专门的指令执行,因此效率较高。堆由函数库提供,机制复杂,效率比栈低得多。Windows系统中VirtualAlloc可直接在进程地址空间中分配一块内存,快速且灵活。

⑦分配后系统响应:只要栈剩余空间大于所申请空间,系统将为程序提供内存,否则报告异常提示栈溢出。

操作系统为堆维护一个记录空闲内存地址的链表。当系统收到程序的内存分配申请时,会遍历该链表寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点空间分配给程序。若无足够大小的空间(可能由于内存碎片太多),有可能调用系统功能去增加程序数据段的内存空间,以便有机会分到足够大小的内存,然后进行返回。,大多数系统会在该内存空间首地址处记录本次分配的内存大小,供后续的释放函数(如free/delete)正确释放本内存空间。

此外,由于找到的堆结点大小不一定正好等于申请的大小,系统会自动将多余的部分重新放入空闲链表中。

⑧碎片问题:栈不会存在碎片问题,因为栈是先进后出的队列,内存块弹出栈之前,在其上面的后进的栈内容已弹出。而频繁申请释放操作会造成堆内存空间的不连续,从而造成大量碎片,使程序效率降低。

可见,堆容易造成内存碎片;由于没有专门的系统支持,效率很低;由于可能引发用户态和内核态切换,内存申请的代价更为昂贵。所以栈在程序中应用最广泛,函数调用也利用栈来完成,调用过程中的参数、返回地址、栈基指针和局部变量等都采用栈的方式存放。所以,建议尽量使用栈,仅在分配大量或大块内存空间时使用堆。

使用栈和堆时应避免越界发生,否则可能程序崩溃或破坏程序堆、栈结构,产生意想不到的后果。

BSS段

BSS(Block Started by Symbol)段中通常存放程序中以下符号:

  • 未初始化的全局变量和静态局部变量
  • 初始值为0的全局变量和静态局部变量(依赖于编译器实现)
  • 未定义且初值不为0的符号(该初值即common block的大小)

C语言中,未显式初始化的静态分配变量被初始化为0(算术类型)或空指针(指针类型)。由于程序加载时,BSS会被操作系统清零,所以未赋初值或初值为0的全局变量都在BSS中。BSS段仅为未初始化的静态分配变量预留位置,在目标文件中并不占据空间,这样可减少目标文件体积。但程序运行时需为变量分配内存空间,故目标文件必须记录所有未初始化的静态分配变量大小总和(通过start_bss和end_bss地址写入机器代码)。当加载器(loader)加载程序时,将为BSS段分配的内存初始化为0。在嵌入式软件中,进入main()函数之前BSS段被C运行时系统映射到初始化为全零的内存(效率较高)。

注意,尽管均放置于BSS段,但初值为0的全局变量是强符号,而未初始化的全局变量是弱符号。若其他地方已定义同名的强符号(初值可能非0),则弱符号与之链接时不会引起重定义错误,但运行时的初值可能并非期望值(会被强符号覆盖)。因此,定义全局变量时,若只有本文件使用,则尽量使用static关键字修饰;否则需要为全局变量定义赋初值(哪怕0值),保证该变量为强符号,以便链接时发现变量名冲突,而不是被未知值覆盖。

某些编译器将未初始化的全局变量保存在common段,链接时再将其放入BSS段。在编译阶段可通过-fno-common选项来禁止将未初始化的全局变量放入common段。

此外,由于目标文件不含BSS段,故程序烧入存储器(Flash)后BSS段地址空间内容未知。U-Boot启动过程中,将U-Boot的Stage2代码(通常位于lib_xxxx/board.c文件)搬迁(拷贝)到SDRAM空间后必须人为添加清零BSS段的代码,而不可依赖于Stage2代码中变量定义时赋0值。

【扩展阅读】BSS历史

BSS(Block Started by Symbol,以符号开始的块)一词最初是UA-SAP汇编器(United Aircraft Symbolic Assembly Program)中的伪指令,用于为符号预留一块内存空间。该汇编器由美国联合航空公司于20世纪50年代中期为IBM 704大型机所开发。

后来该词被作为关键字引入到了IBM 709和7090/94机型上的标准汇编器FAP(Fortran Assembly Program),用于定义符号并且为该符号预留指定字数的未初始化空间块。

在采用段式内存管理的架构中(如Intel 80x86系统),BSS段通常指用来存放程序中未初始化全局变量的一块内存区域,该段变量只有名称和大小却没有值。程序开始时由系统初始化清零。

BSS段不包含数据,仅维护开始和结束地址,以便内存能在运行时被有效地清零。BSS所需的运行时空间由目标文件记录,但BSS并不占用目标文件内的实际空间,即BSS节段应用程序的二进制映象文件中并不存在。

数据段(Data)

数据段通常用于存放程序中已初始化且初值不为0的全局变量和静态局部变量。数据段属于静态内存分配(静态存储区),可读可写。

数据段保存在目标文件中(在嵌入式系统里一般固化在镜像文件中),其内容由程序初始化。例如,对于全局变量int gVar = 10,必须在目标文件数据段中保存10这个数据,然后在程序加载时复制到相应的内存。

数据段与BSS段的区别如下:

  1. BSS段不占用物理文件尺寸,但占用内存空间;数据段占用物理文件,也占用内存空间。
    对于大型数组如int ar0[10000] = {1, 2, 3, …}和int ar1[10000],ar1放在BSS段,只记录共有10000*4个字节需要初始化为0,而不是像ar0那样记录每个数据1、2、3…,此时BSS为目标文件所节省的磁盘空间相当可观。
  2. 当程序读取数据段的数据时,系统会出发缺页故障,从而分配相应的物理内存;当程序读取BSS段的数据时,内核会将其转到一个全零页面,不会发生缺页故障,也不会为其分配相应的物理内存。

运行时数据段和BSS段的整个区段通常称为数据区。某些资料中“数据段”指代数据段 + BSS段 + 堆。

代码段(text)

代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。一般C语言执行语句都编译成机器代码保存在代码段。通常代码段是可共享的,因此频繁执行的程序只需要在内存中拥有一份拷贝即可。代码段通常属于只读,以防止其他程序意外地修改其指令(对该段的写操作将导致段错误)。某些架构也允许代码段为可写,即允许修改程序。

代码段指令根据程序设计流程依次执行,对于顺序指令,只会执行一次(每个进程);若有反复,则需使用跳转指令;若进行递归,则需要借助栈来实现。

代码段指令中包括操作码和操作对象(或对象地址引用)。若操作对象是立即数(具体数值),将直接包含在代码中;若是局部数据,将在栈区分配空间,然后引用该数据地址;若位于BSS段和数据段,同样引用该数据地址。

代码段最容易受优化措施影响。

保留区

位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。

它并不是一个单一的内存区域,而是对地址空间中受到操作系统保护而禁止用户进程访问的地址区域的总称。大多数操作系统中,极小的地址通常都是不允许访问的,如NULL。C语言将无效指针赋值为0也是出于这种考虑,因为0地址上正常情况下不会存放有效的可访问数据。

在32位X86架构的Linux系统中,用户进程可执行程序一般从虚拟地址空间0x08048000开始加载。该加载地址由ELF文件头决定,可通过自定义链接器脚本覆盖链接器默认配置,进而修改加载地址。0x08048000以下的地址空间通常由C动态链接库、动态加载器ld.so和内核VDSO(内核提供的虚拟共享库)等占用。通过使用mmap系统调用,可访问0x08048000以下的地址空间。

通过cat /proc/self/maps命令查看加载表如下:

【扩展阅读】分段的好处

进程运行过程中,代码指令根据流程依次执行,只需访问一次(当然跳转和递归可能使代码执行多次);而数据(数据段和BSS段)通常需要访问多次,因此单独开辟空间以方便访问和节约空间。具体解释如下:

当程序被装载后,数据和指令分别映射到两个虚存区域。数据区对于进程而言可读写,而指令区对于进程只读。两区的权限可分别设置为可读写和只读。以防止程序指令被有意或无意地改写。

现代CPU具有极为强大的缓存(Cache)体系,程序必须尽量提高缓存命中率。指令区和数据区的分离有利于提高程序的局部性。现代CPU一般数据缓存和指令缓存分离,故程序的指令和数据分开存放有利于提高CPU缓存命中率。

当系统中运行多个该程序的副本时,其指令相同,故内存中只须保存一份该程序的指令部分。若系统中运行数百进程,通过共享指令将节省大量空间(尤其对于有动态链接的系统)。其他只读数据如程序里的图标、图片、文本等资源也可共享。而每个副本进程的数据区域不同,它们是进程私有的。

此外,临时数据及需要再次使用的代码在运行时放入栈区中,生命周期短。全局数据和静态数据可能在整个程序执行过程中都需要访问,因此单独存储管理。堆区由用户自由分配,以便管理。

Linux文件系统详解

原文:https://www.cnblogs.com/alantu2018/p/8461749.html

概述

在LINUX系统中有一个重要的概念:一切都是文件。 其实这是UNIX哲学的一个体现,而Linux是重写UNIX而来,所以这个概念也就传承了下来。在UNIX系统中,把一切资源都看作是文件,包括硬件设备。UNIX系统把每个硬件都看成是一个文件,通常称为设备文件,这样用户就可以用读写文件的方式实现对硬件的访问。这样带来优势也是显而易见的:

UNIX 权限模型也是围绕文件的概念来建立的,所以对设备也就可以同样处理了。

硬盘驱动

常见的硬盘类型有PATA, SATA和AHCI等,在Linux系统中,对不同硬盘所提供的驱动模块一般都存放在内核目录树drivers/ata中,而对于一般通用的硬盘驱动,也许会直接被编译到内核中,而不会以模块的方式出现,可以通过查看/boot/config-xxx.xxx文件来确认:

CONFIG_SATA_AHCI=y

General Block Device Layer

这一层的作用,正是解答了上面提出的第一个问题,不同的硬盘驱动,会提供不同的IO接口,内核认为这种杂乱的接口,不利于管理,需要把这些接口抽象一下,形成一个统一的对外接口,这样,不管你是什么硬盘,什么驱动,对外而言,它们所提供的IO接口没什么区别,都一视同仁的被看作块设备来处理。

所以,如果在一层做的任何修改,将会直接影响到所有文件系统,不管是ext3,ext4还是其它文件系统,只要在这一层次做了某种修改,对它们都会产生影响。

文件系统

文件系统这一层相信大家都再熟悉不过了,目前大多Linux发行版本默认使用的文件系统一般是ext4,另外,新一代的btrfs也呼之欲出,不管什么样的文件系统,都是由一系列的mkfs.xxx命令来创建,如:

1
2
mkfs.ext4 /dev/sda
mkfs.btrfs /dev/sdb

内核所支持的文件系统类型,可以通过内核目录树 fs 目录中的内容来查看。

虚拟文件系统(VFS)

Virtual File System这一层,正是用来解决上面提出的第二个问题,试想,当我们通过mkfs.xxx系列命令创建了很多不同的文件系统,但这些文件系统都有各自的API接口,而用户想要的是,不管你是什么API,他们只关心mount/umount,或open/close等操作。

所以,VFS就把这些不同的文件系统做一个抽象,提供统一的API访问接口,这样,用户空间就不用关心不同文件系统中不一样的API了。VFS所提供的这些统一的API,再经过System Call包装一下,用户空间就可以经过SCI的系统调用来操作不同的文件系统。
VFS所提供的常用API有:

1
2
3
mount(), umount() …
open(),close() …
mkdir() …

和文件系统关系最密切的就是存储介质,存储介质大致有RAM,ROM,磁盘磁带,闪存等。

闪存(Flash Memory)是一种长寿命的非易失性(在断电情况下仍能保持所存储的数据信息)的存储器,数据删除不是以单个的字节为单位而是以固定的区块为单位(注意:NOR Flash 为字节存储。),区块大小一般为256KB到20MB。闪存是电子可擦除只读存储器(EEPROM)的变种,EEPROM与闪存不同的是,它能在字节水平上进行删除和重写而不是整个芯片擦写,这样闪存就比EEPROM的更新速度快。由于其断电时仍能保存数据,闪存通常被用来保存设置信息,如在电脑的BIOS(基本输入输出程序)、PDA(个人数字助理)、数码相机中保存资料等。

外存通常是磁性介质或光盘,像硬盘,软盘,磁带,CD等,能长期保存信息,并且不依赖于电来保存信息,但是由机械部件带动,速度与CPU相比就显得慢的多。内存指的就是主板上的存储部件,是CPU直接与之沟通,并用其存储数据的部件,存放当前正在使用的(即执行中)的数据和程序,它的物理实质就是一组或多组具备数据输入输出和数据存储功能的集成电路,内存只用于暂时存放程序和数据,一旦关闭电源或发生断电,其中的程序和数据就会丢失。
RAM又分为动态的和静态。。静态被用作cache,动态的常用作内存。。网上说闪存不能代替DRAM是因为闪存不像RAM(随机存取存储器)一样以字节为单位改写数据,因此不能取代RAM。这个以后可以了解下硬件的知识再来辨别.

Linux下的文件系统结构如下:

Linux启动时,第一个必须挂载的是根文件系统;若系统不能从指定设备上挂载根文件系统,则系统会出错而退出启动。之后可以自动或手动挂载其他的文件系统。因此,一个系统中可以同时存在不同的文件系统。

不同的文件系统类型有不同的特点,因而根据存储设备的硬件特性、系统需求等有不同的应用场合。在嵌入式Linux应用中,主要的存储设备为RAM(DRAM, SDRAM)和ROM(常采用FLASH存储器),常用的基于存储设备的文件系统类型包括:jffs2, yaffs, cramfs, romfs, ramdisk, ramfs/tmpfs等。

网络文件系统NFS (Network File System)

NFS是由Sun开发并发展起来的一项在不同机器、不同操作系统之间通过网络共享文件的技术。在嵌入式Linux系统的开发调试阶段,可以利用该技术在主机上建立基于NFS的根文件系统,挂载到嵌入式设备,可以很方便地修改根文件系统的内容。

以上讨论的都是基于存储设备的文件系统(memory-based file system),它们都可用作Linux的根文件系统。实际上,Linux还支持逻辑的或伪文件系统(logical or pseudo file system),例如procfs(proc文件系统),用于获取系统信息,以及devfs(设备文件系统)和sysfs,用于维护设备文件。

文件存储结构

介绍文件存储结构前先来看看文件系统如何划分磁盘,创建一个文件、目录、链接的过程。

物理磁盘到文件系统

我们知道文件最终是保存在硬盘上的。硬盘最基本的组成部分是由坚硬金属材料制成的涂以磁性介质的盘片,不同容量硬盘的盘片数不等。每个盘片有两面,都可记录信息。盘片被分成许多扇形的区域,每个区域叫一个扇区,每个扇区可存储128×2的N次方(N=0.1.2.3)字节信息。在DOS中每扇区是128×2的2次方=512字节,盘片表面上以盘片中心为圆心,不同半径的同心圆称为磁道。硬盘中,不同盘片相同半径的磁道所组成的圆柱称为柱面。磁道与柱面都是表示不同半径的圆,在许多场合,磁道和柱面可以互换使用,我们知道,每个磁盘有两个面,每个面都有一个磁头,习惯用磁头号来区分。扇区,磁道(或柱面)和磁头数构成了硬盘结构的基本参数,帮这些参数可以得到硬盘的容量,基计算公式为:
存储容量=磁头数×磁道(柱面)数×每道扇区数×每扇区字节数
要点:

  1. 硬盘有数个盘片,每盘片两个面,每个面一个磁头
  2. 盘片被划分为多个扇形区域即扇区
  3. 同一盘片不同半径的同心圆为磁道
  4. 不同盘片相同半径构成的圆柱面即柱面
  5. 公式: 存储容量=磁头数×磁道(柱面)数×每道扇区数×每扇区字节数
  6. 信息记录可表示为:××磁道(柱面),××磁头,××扇区

那么这些空间又是怎么管理起来的呢?unix/linux使用了一个简单的方法。
它将磁盘块分为以下三个部分:

  1. 超级块,文件系统中第一个块被称为超级块。这个块存放文件系统本身的结构信息。比如,超级块记录了每个区域的大小,超级块也存放未被使用的磁盘块的信息。
  2. I-切点表。超级块的下一个部分就是i-节点表。每个i-节点就是一个对应一个文件/目录的结构,这个结构它包含了一个文件的长度、创建及修改时间、权限、所属关系、磁盘中的位置等信息。一个文件系统维护了一个索引节点的数组,每个文件或目录都与索引节点数组中的唯一一个元素对应。系统给每个索引节点分配了一个号码,也就是该节点在数组中的索引号,称为索引节点号
  3. 数据区。文件系统的第3个部分是数据区。文件的内容保存在这个区域。磁盘上所有块的大小都一样。如果文件包含了超过一个块的内容,则文件内容会存放在多个磁盘块中。一个较大的文件很容易分布上千个独产的磁盘块中。

Linux正统的文件系统(如ext2、ext3)一个文件由目录项、inode和数据块组成。

  • 目录项:包括文件名和inode节点号。
  • Inode:又称文件索引节点,是文件基本信息的存放地和数据块指针存放地。
  • 数据块:文件的具体内容存放地。

Linux正统的文件系统(如ext2、3等)将硬盘分区时会划分出目录块、inode Table区块和data block数据区域。一个文件由一个目录项、inode和数据区域块组成。Inode包含文件的属性(如读写属性、owner等,以及指向数据块的指针),数据区域块则是文件内容。当查看某个文件时,会先从inode table中查出文件属性及数据存放点,再从数据块中读取数据。

文件存储结构大概如下:

其中目录项的结构如下(每个文件的目录项存储在改文件所属目录的文件内容里):

其中文件的inode结构如下(inode里所包含的文件信息可以通过stat filename查看得到):

以上只反映大体的结构,linux文件系统本身在不断发展。但是以上概念基本是不变的。且如ext2、ext3、ext4文件系统也存在很大差别,如果要了解可以查看专门的文件系统介绍。

创建一个文件的过程

我们从前面可以知道文件的内容和属性是分开存放的,那么又是如何管理它们的呢?现在我们以创建一个文件为例来讲解。
在命令行输入命令:

1
$ who > userlist

当完成这个命令时。文件系统中增加了一个存放命令who输出内容的新文件userlist,那么这整个过程到底是怎么回事呢?
文件主要有属性、内容以及文件名三项。内核将文件内容存放在数据区,文件属性存放在i-节点,文件名存放在目录中。
创建成功一个文件主要有以下四个步骤:

  1. 存储属性 也就是文件属性的存储,内核先找到一块空的i-节点。例如,内核找到i-节点号921130。内核把文件的信息记录其中。如文件的大小、文件所有者、和创建时间等。
  2. 存储数据 即文件内容的存储,由于该文件需要3个数据块。因此内核从自由块的列表中找到3个自由块。如600、200、992,内核缓冲区的第一块数据复制到块600,第二和第三分别复制到922和600.
  3. 记录分配情况,数据保存到了三个数据块中。所以必须要记录起来,以后再找到正确的数据。分配情况记录在文件的i-节点中的磁盘序号列表里。这3个编号分别放在最开始的3个位置。
  4. 添加文件名到目录,新文件的名字是userlist 内核将文件的入口(47,userlist)添加到目录文件里。文件名和i-节点号之间的对应关系将文件名和文件和文件的内容属性连接起来,找到文件名就找到文件的i-节点号,通过i-节点号就能找到文件的属性和内容。
    代码具体实现过程参考:
    http://blog.csdn.net/kai_ding/article/details/9206057

创建一个目录的过程

前面说了创建一个文件的大概过程,也了解文件内容、属性以及入口的保存方式,那么创建一个目录时又是怎么回事呢?
我现在test目录使用命令mkdir 新增一个子目录child:

从用户的角度看,目录child是目录test的一个子目录,那么在系统中这层关系是怎么实现的呢?实际上test目录包含一个指向子目录child的i-节点的链接,原理跟普通文件一样,因为目录也是文件。

目录其实也是文件,只是它的内容比较特殊。所以它的创建过程和文件创建过程一样,只是第二步写的内容不同。

  1. 系统找到空闲的i-节点号887220,写入目录的属性
  2. 找到空闲的数据块1002来存储目录的内容,只是目录的内容比较特殊,包含文件名字列表,列表一般包含两个部分:i-节点号和文件名,这个列表其实也就是文件的入口,新建的目录至少包含三个目录”.”和”..”其中”.”指向自己,”..”指向上级目录,我们可以通过比较对应的i-节点号来验证,887270 对应着上级目录中的child对应的i-节点号
  3. 记录分配情况。这个和创建文件完全一样
  4. 添加目录的入口到父目录,即在父目录中的child入口。

一般都说文件存放在某个目录中,其实目录中存入的只是文件在i-节点表的入口,而文件的内容则存储在数据区。我们一般会说“文件userlist在目录test中”,其实这意味着目录test中有一个指向i-节点921130的链接,这个链接所附加的文件名为userlist,这也可以这样理解:目录包含的是文件的引用,每个引用被称为链接。文件的内容存储在数据块。文件的属性被记录在一个被称为i-节点的结构中。I-节点的编号和文件名关联起来存在目录中。

发现“.”和“..”都指向i-节点2。实际上当我们用mkfs创建一个文件系统时,mkfs都会将根目录的父目录指向自己。所以根目录下.和..指向同一个i-节点也不奇怪了。

理解链接

我们知道文件都有文件名与数据,这在 Linux 上被分成两个部分:用户数据 (user data) 与元数据 (metadata)。用户数据,即文件数据块 (data block),数据块是记录文件真实内容的地方;而元数据则是文件的附加属性,如文件大小、创建时间、所有者等信息。在 Linux 中,元数据中的 inode 号(inode 是文件元数据的一部分但其并不包含文件名,inode 号即索引节点号)才是文件的唯一标识而非文件名。文件名仅是为了方便人们的记忆和使用,系统或程序通过 inode 号寻找正确的文件数据块。图展示了程序通过文件名获取文件内容的过程。

移动或重命名文件

1
2
3
4
5
6
7
8
9
10
# stat /home/harris/source/glibc-2.16.0.tar.xz
File: `/home/harris/source/glibc-2.16.0.tar.xz'
Size: 9990512 Blocks: 19520 IO Block: 4096 regular file
Device: 807h/2055d Inode: 2485677 Links: 1
Access: (0600/-rw-------) Uid: ( 1000/ harris) Gid: ( 1000/ harris)
...
...
# mv /home/harris/source/glibc-2.16.0.tar.xz /home/harris/Desktop/glibc.tar.xz
# ls -i -F /home/harris/Desktop/glibc.tar.xz
2485677 /home/harris/Desktop/glibc.tar.xz

在 Linux 系统中查看 inode 号可使用命令 stat 或 ls -i(若是 AIX 系统,则使用命令 istat)。清单 3.中使用命令 mv 移动并重命名文件 glibc-2.16.0.tar.xz,其结果不影响文件的用户数据及 inode 号,文件移动前后 inode 号均为:2485677。
为解决文件的共享使用,Linux 系统引入了两种链接:硬链接 (hard link) 与软链接(又称符号链接,即 soft link 或 symbolic link)。

为 Linux 系统解决了文件的共享使用,还带来了隐藏文件路径、增加权限安全及节省存储等好处。若一个inode号对应多个文件名,则称这些文件为硬链接。换言之,硬链接就是同一个文件使用了多个别名。硬链接可由命令 link 或 ln 创建。如下是对文件 oldfile 创建硬链接。

1
2
link oldfile newfile
ln oldfile newfile

由于硬链接是有着相同 inode 号仅文件名不同的文件,因此硬链接存在以下几点特性:

  • 文件有相同的 inode 及 data block;
  • 只能对已存在的文件进行创建;
  • 不能交叉文件系统进行硬链接的创建;
  • 不能对目录进行创建,只可对文件创建;
  • 删除一个硬链接文件并不影响其他有相同 inode 号的文件。

创建一个链接的步骤大概如下:

  1. 通过原文件的文件名找到文件的i-节点号
  2. 添加文件名关联到目录,新文件的名字是mylink 内核将文件的入口(921130,mylink)添加到目录文件里。

和创建文件的过程比较发现,链接少了写文件内容的步骤,完全相同的是把文件名关联到目录这一步
现在.i- 节点号921130对应了两个文件名。链接数也会变成2个,文件的内容并不会发生任何变化。前面我们已经讲了:目录包含的是文件的引用,每个引用被称为链接。所以链接文件和原始文件本质上是一样的,因为它们都是指向同一个i-节点。由于此原因也就可以理解链接的下列特性:你改变其中任何一个文件的内容,别的链接文件也一样是变化;另外如果你删除某一个文件,系统只会在所指向的i-节点上把链接数减1,只有当链接数减为零时才会真正释放i-节点。
硬链接有两个特点:

  1. 不能跨文件系统
  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
# ls -li 
total 0

// 只能对已存在的文件创建硬连接
# link old.file hard.link
link: cannot create link `hard.link' to `old.file': No such file or directory

# echo "This is an original file" > old.file
# cat old.file
This is an original file
# stat old.file
File: `old.file'
Size: 25 Blocks: 8 IO Block: 4096 regular file
Device: 807h/2055d Inode: 660650 Links: 2
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
...
// 文件有相同的 inode 号以及 data block
# link old.file hard.link | ls -li
total 8
660650 -rw-r--r-- 2 root root 25 Sep 1 17:44 hard.link
660650 -rw-r--r-- 2 root root 25 Sep 1 17:44 old.file

// 不能交叉文件系统
# ln /dev/input/event5 /root/bfile.txt
ln: failed to create hard link `/root/bfile.txt' => `/dev/input/event5':
Invalid cross-device link

// 不能对目录进行创建硬连接
# mkdir -p old.dir/test
# ln old.dir/ hardlink.dir
ln: `old.dir/': hard link not allowed for directory
# ls -iF
660650 hard.link 657948 old.dir/ 660650 old.file

软链接与硬链接不同,若文件用户数据块中存放的内容是另一文件的路径名的指向,则该文件就是软连接。软链接就是一个普通文件,只是数据块内容有点特殊。软链接有着自己的 inode 号以及用户数据块(见 图 2.)。因此软链接的创建与使用没有类似硬链接的诸多限制:

  • 软链接有自己的文件属性及权限等;
  • 可对不存在的文件或目录创建软链接;
  • 软链接可交叉文件系统;
  • 软链接可对文件或目录创建;
  • 创建软链接时,链接计数 i_nlink 不会增加;
  • 删除软链接并不影响被指向的文件,但若被指向的原文件被删除,则相关软连接被称为死链接(即 dangling link,若被指向路径文件被重新创建,死链接可恢复为正常的软链接)。

软链接实际上只是一段文字,里面包含着它所指向的文件的名字,系统看到软链接后自动跳到对应的文件位置处进行处理;相反,硬链接为文件开设一个新的目录项,硬链接与文件原有的名字是平权的,在Linux看来它们是等价的。由于这个原因,硬链接不能连接两个不同文件系统上的文件。

至于硬连接,举个例子说吧,你把dir1/file1硬连接到dir2/file2, 就是在dir2下建立一个dir1/file1的镜像文件file2,它与file1是占用一样大的空间的,并且改动两者中的一个,另一个也会发生同样的改动.
软连接和硬连接可以这样理解:

  • 硬连接就像一个文件有多个文件名,
  • 软连接就是产生一个新文件(这个文件内容,实际上就是记当要链接原文件路径的信息),这个文件指向另一个文件的位置,
  • 硬连接必须在同一文件系统中,而软连接可以跨文件系统

硬连接 :源文件名和链接文件名都指向相同的物理地址,目录不能够有硬连接,文件在磁盘中只有一个复制,可以节省硬盘空间,由于删除文件要在同一个索引节点属于唯一的连接时才能成功,因此可以防止不必要的误删除软连接(符号连接)用ln -s命令创建文件的符号连接,符号连接是linux特殊文件的一种,作为一个文件,它的资料是它所连接的文件的路径名,类似于硬件方式,**可以删除原始文件 而连接文件仍然存在。**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# ls -li 
total 0

// 可对不存在的文件创建软链接
# ln -s old.file soft.link
# ls -liF
total 0
789467 lrwxrwxrwx 1 root root 8 Sep 1 18:00 soft.link -> old.file

// 由于被指向的文件不存在,此时的软链接 soft.link 就是死链接
# cat soft.link
cat: soft.link: No such file or directory

// 创建被指向的文件 old.file,soft.link 恢复成正常的软链接
# echo "This is an original file_A" >> old.file
# cat soft.link
This is an original file_A

// 对不存在的目录创建软链接
# ln -s old.dir soft.link.dir
# mkdir -p old.dir/test
# tree . -F --inodes
.
├ [ 789497] old.dir/
│ └ [ 789498] test/
├ [ 789495] old.file
├ [ 789495] soft.link -> old.file
└ [ 789497] soft.link.dir -> old.dir/

文件节点inode

inode是什么

理解inode,要从文件储存说起。

扇区(sector):硬件(磁盘)上的最小的操作单位,是操作系统和块设备(硬件、磁盘)之间传送数据的单位。

block由一个或多个sector组成,文件系统中最小的操作单位;OS的虚拟文件系统从硬件设备上读取一个block,实际为从硬件设备读取一个或多个sector。对于文件管理来说,每个文件对应的多个block可能是不连续的;

block最终要映射到sector上,所以block的大小一般是sector的整数倍。不同的文件系统block可使用不同的大小,操作系统会在内存中开辟内存,存放block到所谓的block buffer中。在Ext2中,物理块的大小是可变化的,这取决于在创建文件系统时的选择,之所以不限制大小,也正体现了Ext2的灵活性和可扩充性。通常,Ext2的物理块占一个或几个连续的扇区,显然,物理块的数目是由磁盘容量等硬件因素决定的。具体文件系统所操作的基本单位是逻辑块,只在需要进行I/O操作时才进行逻辑块到物理块的映射,这显然避免了大量的I/O操作,因而文件系统能够变得高效。逻辑块作为一个抽象的概念,它必然要映射到具体的物理块上去,因此,逻辑块的大小必须是物理块大小的整数倍,一般说来,两者是一样大的。

通常,一个文件占用的多个物理块在磁盘上是不连续存储的,因为如果连续存储,则经过频繁的删除、建立、移动文件等操作,最后磁盘上将形成大量的空洞,很快磁盘上将无空间可供使用。因此,必须提供一种方法将一个文件占用的多个逻辑块映射到对应的非连续存储的物理块上去,Ext2等类文件系统是用索引节点解决这个问题的。


文件数据都储存在”块”中,那么很显然,我们还必须找到一个地方储存文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等。这种储存文件元信息的区域就叫做inode,中文译名为”索引节点”。
在Unix/Linux上,一个文件由一个inode 表示。inode在系统管理员看来是每一个文件的唯一标识,在系统里面,inode是一个结构,存储了关于这个文件的大部分信息。

inode内容

inode包含文件的元信息,具体来说有以下内容:

  • 文件的字节数
  • 文件拥有者的UserID,文件的GroupID
  • 文件的读、写、执行权限
  • 文件的时间戳,共有三个:ctime指inode上一次变动的时间,mtime指文件内容上一次变动的时间,atime指文件上一次打开的时间。
  • 链接数,即有多少文件名指向这个inode,文件数据block的位置可以用stat命令,查看某个文件的inode信息:statexample.txt
    总之,除了文件名以外的所有文件信息,都存在inode之中。至于为什么没有文件名,下文会有详细解释。

inode中存储了一个文件的以下信息:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct inode {
struct hlist_node i_hash; /* 哈希表 */
struct list_head i_list; /* 索引节点链表 */
struct list_head i_dentry; /* 目录项链表 */
unsigned long i_ino; /* 节点号 */
atomic_t i_count; /* 引用记数 */
umode_t i_mode; /* 访问权限控制 */
unsigned int i_nlink; /* 硬链接数 */
uid_t i_uid; /* 使用者id */
gid_t i_gid; /* 使用者id组 */
kdev_t i_rdev; /* 实设备标识符 */
loff_t i_size; /* 以字节为单位的文件大小 */
struct timespec i_atime; /* 最后访问时间 */
struct timespec i_mtime; /* 最后修改(modify)时间 */
struct timespec i_ctime; /* 最后改变(change)时间 */
unsigned int i_blkbits; /* 以位为单位的块大小 */
unsigned long i_blksize; /* 以字节为单位的块大小 */
unsigned long i_version; /* 版本号 */
unsigned long i_blocks; /* 文件的块数 */
unsigned short i_bytes; /* 使用的字节数 */
spinlock_t i_lock; /* 自旋锁 */
struct rw_semaphore i_alloc_sem; /* 索引节点信号量 */
struct inode_operations *i_op; /* 索引节点操作表 */
struct file_operations *i_fop; /* 默认的索引节点操作 */
struct super_block *i_sb; /* 相关的超级块 */
struct file_lock *i_flock; /* 文件锁链表 */
struct address_space *i_mapping; /* 相关的地址映射 */
struct address_space i_data; /* 设备地址映射 */
struct dquot *i_dquot[MAXQUOTAS]; /* 节点的磁盘限额 */
struct list_head i_devices; /* 块设备链表 */
struct pipe_inode_info *i_pipe; /* 管道信息 */
struct block_device *i_bdev; /* 块设备驱动 */
unsigned long i_dnotify_mask; /* 目录通知掩码 */
struct dnotify_struct *i_dnotify; /* 目录通知 */
unsigned long i_state; /* 状态标志 */
unsigned long dirtied_when; /* 首次修改时间 */
unsigned int i_flags; /* 文件系统标志 */
unsigned char i_sock; /* 可能是个套接字吧 */
atomic_t i_writecount; /* 写者记数 */
void *i_security; /* 安全模块 */
__u32 i_generation; /* 索引节点版本号 */
union {
void *generic_ip; /* 文件特殊信息 */
} u;
};

inode就是一个文件的一部分描述,不是全部,在内核中,inode对应了这样一个实际存在的结构。

纵观整个inode的C语言描述,没有发现关于文件名的东西,也就是说文件名不由inode保存,实际上系统是不关心文件名的,对于系统中任何的操作,大部分情况下你都是通过文件名来做的,但系统最终都要通过找到文件对应的inode来操作文件,由inode结构中*i_op指向的接口来操作。

文件系统如何存取文件的:

  1. 根据文件名,通过Directory里的对应关系,找到文件对应的Inodenumber
  2. 再根据Inodenumber读取到文件的Inodetable
  3. 再根据Inodetable中的Pointer读取到相应的Blocks

这里有一个重要的内容,就是Directory,他不是我们通常说的目录,而是一个列表,记录了一个文件/目录名称对应的Inodenumber。

ELF文件格式

来源:https://www.cnblogs.com/jiqingwu/p/elf_format_research_01.html

Segment和Section

ELF 是Executable and Linking Format的缩写,即可执行和可链接的格式,是Unix/Linux系统ABI (Application Binary Interface)规范的一部分。

Unix/Linux下的可执行二进制文件、目标代码文件、共享库文件和core dump文件都属于ELF文件。下面的图来自于文档 Executable and Linkable Format (ELF),描述了ELF文件的大致布局。

左边是ELF的链接视图,可以理解为是目标代码文件的内容布局。右边是ELF的执行视图,可以理解为可执行文件的内容布局。
注意目标代码文件的内容是由section组成的,而可执行文件的内容是由segment组成的。

要注意区分段(segment)和节(section)的概念,这两个概念在后面会经常提到。
我们写汇编程序时,用.text,.bss,.data这些指示,都指的是section,比如.text,告诉汇编器后面的代码放入.text section中。
目标代码文件中的section和section header table中的条目是一一对应的。section的信息用于链接器对代码重定位。

而文件载入内存执行时,是以segment组织的,每个segment对应ELF文件中program header table中的一个条目,用来建立可执行文件的进程映像。
比如我们通常说的,代码段、数据段是segment,目标代码中的section会被链接器组织到可执行文件的各个segment中。
.text section的内容会组装到代码段中,.data, .bss等节的内容会包含在数据段中。

在目标文件中,program header不是必须的,我们用gcc生成的目标文件也不包含program header。
一个好用的解析ELF文件的工具是readelf。对我本机上的一个目标代码文件sleep.o执行readelf -S sleep.o,输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
There are 12 section headers, starting at offset 0x270:

Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000015 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 000001e0
0000000000000018 0000000000000018 I 9 1 8
[ 3] .data PROGBITS 0000000000000000 00000055
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000055
0000000000000000 0000000000000000 WA 0 0 1
... ... ... ...
[11] .shstrtab STRTAB 0000000000000000 00000210
0000000000000059 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)

readelf -S是显示文件中的Section信息,sleep.o中共有12个section, 我们省略了其中一些Section的信息。
可以看到,除了我们熟悉的.text, .data, .bss,还有其它Section,这等我们以后展开讲Section的时候还会专门讲到。
看每个Section的Flags我们也可以得到一些信息,比如.text section的Flags是AX,表示要分配内存,并且是可执行的,这一节是代码无疑了。
.data 和 .bss的Flags的Flags都是WA,表示可写,需分配内存,这都是数据段的特征。

使用readelf -l可以显示文件的program header信息。我们对sleep.o执行readelf -l sleep.o,会输出There are no program headers in this file.
program header和文件中的segment一一对应,因为目标代码文件中没有segment,program header也就没有必要了。

可执行文件的内容组织成segment,因此program header table是必须的。
section header不是必须的,但没有strip过的二进制文件中都含有此信息。
对本地可执行文件sleep执行readelf -l sleep,输出如下:

1
2
3
4
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
Elf file type is DYN (Shared object file)
Entry point 0x1040
There are 11 program headers, starting at offset 64

Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040
0x0000000000000268 0x0000000000000268 R 0x8
INTERP 0x00000000000002a8 0x00000000000002a8 0x00000000000002a8
0x000000000000001c 0x000000000000001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000560 0x0000000000000560 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x00000000000001d5 0x00000000000001d5 R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x0000000000000110 0x0000000000000110 R 0x1000
LOAD 0x0000000000002de8 0x0000000000003de8 0x0000000000003de8
0x0000000000000248 0x0000000000000250 RW 0x1000
DYNAMIC 0x0000000000002df8 0x0000000000003df8 0x0000000000003df8
0x00000000000001e0 0x00000000000001e0 RW 0x8
NOTE 0x00000000000002c4 0x00000000000002c4 0x00000000000002c4
0x0000000000000044 0x0000000000000044 R 0x4
GNU_EH_FRAME 0x0000000000002004 0x0000000000002004 0x0000000000002004
0x0000000000000034 0x0000000000000034 R 0x4
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x0000000000002de8 0x0000000000003de8 0x0000000000003de8
0x0000000000000218 0x0000000000000218 R 0x1

Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .got.plt .data .bss
06 .dynamic
07 .note.ABI-tag .note.gnu.build-id
08 .eh_frame_hdr
09
10 .init_array .fini_array .dynamic .got

如输出所示,文件中共有11个segment。只有类型为LOAD的段是运行时真正需要的。
除了段信息,还输出了每个段包含了哪些section。比如第二个LOAD段标志为R(只读)E(可执行)的,它的编号是03,表示它包含哪些section的那一行内容为:
03 .init .plt .text .fini
可以发现.text包含在其中,这一段就是代码段。
再比如第三个LOAD段,索引是04,标志为R(只读),但没有可执行的属性,它包含的section有.rodata .eh_frame_hdr .eh_frame,其中rodata表示只读的数据,也就是程序中用到的字符串常量等。
最后一个LOAD段,索引05,标志RW(可读写),它包含的节是.init_array .fini_array .dynamic .got .got.plt .data .bss,可以看到.data和.bss都包含其中,这段是数据段无疑。

ELF header详解

ELF header的定义可以在/usr/include/elf.h中找到。Elf32_Ehdr是32位 ELF header的结构体。Elf64_Ehdr是64位ELF header的结构体。

1
2
3
4
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
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number和其它信息 */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;

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;

64位和32位只是个别字段长度不同,比如 Elf64_Addr 和 Elf64_Off 都是64位无符号整数。而Elf32_Addr 和 Elf32_Off是32位无符号整数。这导致ELF header的所占的字节数不同。32位的ELF header占52个字节,64位的ELF header占64个字节。

ELF header详解

  1. e_ident占16个字节。前四个字节被称作ELF的Magic Number。后面的字节描述了ELF文件内容如何解码等信息。等一下详细讲。

  2. e_type,2字节,描述了ELF文件的类型。以下取值有意义:

    1
    2
    3
    4
    5
    6
    7
    8
    ET_NONE, 0, No file type
    ET_REL, 1, Relocatable file(可重定位文件,通常是文件名以.o结尾,目标文件)
    ET_EXEC, 2, Executable file (可执行文件)
    ET_DYN, 3, Shared object file (动态库文件,你用gcc编译出的二进制往往也属于这种类型,惊讶吗?)
    ET_CORE, 4, Core file (core文件,是core dump生成的吧?)
    ET_NUM, 5,表示已经定义了5种文件类型
    ET_LOPROC, 0xff00, Processor-specific
    ET_HIPROC, 0xffff, Processor-specific

    ET_LOPROCET_HIPROC的值,包含特定于处理器的语义。

  3. e_machine,2字节。描述了文件面向的架构,可取值如下(因为文档较老,现在有更多取值,参见/usr/include/elf.h中的EM_开头的宏定义):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    EM_NONE, 0, No machine
    EM_M32, 1, AT&T WE 32100
    EM_SPARC, 2, SPARC
    EM_386, 3, Intel 80386
    EM_68K, 4, Motorola 68000
    EM_88K, 5, Motorola 88000
    EM_860, 7, Intel 80860
    EM_MIPS, 8, MIPS RS3000
    ... ...
  4. e_version,2字节,描述了ELF文件的版本号,合法取值如下:
    1
    2
    3
    EV_NONE, 0, Invalid version
    EV_CURRENT, 1, Current version,通常都是这个取值。
    EV_NUM, 2, 表示已经定义了2种版本号
  5. e_entry,(32位4字节,64位8字节),执行入口点,如果文件没有入口点,这个域保持0。
  6. e_phoff, (32位4字节,64位8字节),program header table的offset,如果文件没有PH,这个值是0。
  7. e_shoff, (32位4字节,64位8字节), section header table 的offset,如果文件没有SH,这个值是0。
  8. e_flags, 4字节,特定于处理器的标志,32位和64位Intel架构都没有定义标志,因此eflags的值是0。
  9. e_ehsize, 2字节,ELF header的大小,32位ELF是52字节,64位是64字节。
  10. e_phentsize,2字节。program header table中每个入口的大小。
  11. e_phnum, 2字节。如果文件没有program header table, e_phnum的值为0。e_phentsize乘以e_phnum就得到了整个program header table的大小。
  12. e_shentsize, 2字节,section header table中entry的大小,即每个section header占多少字节。
  13. e_shnum, 2字节,section header table中header的数目。如果文件没有section header table, e_shnum的值为0。e_shentsize乘以e_shnum,就得到了整个section header table的大小。
  14. e_shstrndx, 2字节。section header string table index. 包含了section header table中section name string table。如果没有section name string table, e_shstrndx的值是SHN_UNDEF.

回过头来,我们仔细看看文件前16个字节,也是e_ident。

如图,前4个字节是ELF的Magic Number,固定为7f 45 4c 46。第5个字节指明ELF文件是32位还是64位的。第6个字节指明了数据的编码方式,即我们通常说的little endian或是big endian。little endian我喜欢称作小头在前,低位字节在前,或者直接说低位字节在低位地址,比如0x7f454c46,存储顺序就是46 4c 45 7f。big endian就是大头在前,高位字节在前,直接说就是高位字节在低位地址,比如0x7f454c46,在文件中的存储顺序是7f 45 4c 46
第7个字节指明了ELF header的版本号,目前值都是1。第8-16个字节,都填充为0。

readelf读取ELF header

我们使用readelf -h <elffile>可以读取文件的ELF header信息。
比如我本地有执行文件hello,我执行reaelf -h hello,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1050
Start of program headers: 64 (bytes into file)
Start of section headers: 14768 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 11
Size of section headers: 64 (bytes)
Number of section headers: 29
Section header string table index: 28

这是我用gcc生成的执行文件,但注意它的Type是DYN (Shared object file),这大概是因为,这个文件不能直接执行,是依赖于解释器和c库才能运行。真正的可执行文件是解释器,而hello相对于解释器来说也是个共享库文件。这是我的推断,需要后面深入学习后验证。

ELF sections

我们在讲ELF Header的时候,讲到了section header table。它是一个section header的集合,每个section header是一个描述section的结构体。在同一个ELF文件中,每个section header大小是相同的。(其实看了源码就知道,32位ELF文件中的section header都是一样的大小,64位ELF文件中的section header也是一样的大小)

每个section都有一个section header描述它,但是一个section header可能在文件中没有对应的section,因为有的section是不占用文件空间的。每个section在文件中是连续的字节序列。section之间不会有重叠。

一个目标文件中可能有未覆盖到的空间,比如各种header和section都没有覆盖到。这部分字节的内容是未指定的,也是没有意义的。

section header定义

section header结构体的定义可以在 /usr/include/elf.h中找到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* Section header.  */

typedef struct
{
Elf32_Word sh_name; /* Section name (string tbl index) */
Elf32_Word sh_type; /* Section type */
Elf32_Word sh_flags; /* Section flags */
Elf32_Addr sh_addr; /* Section virtual addr at execution */
Elf32_Off sh_offset; /* Section file offset */
Elf32_Word sh_size; /* Section size in bytes */
Elf32_Word sh_link; /* Link to another section */
Elf32_Word sh_info; /* Additional section information */
Elf32_Word sh_addralign; /* Section alignment */
Elf32_Word sh_entsize; /* Entry size if section holds table */
} Elf32_Shdr;

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;

下面我们依次讲解结构体各个字段:

sh_name,4字节,是一个索引值,在shstrtable(section header string table,包含section name的字符串表,也是一个section)中的索引。第二讲介绍ELF文件头时,里面专门有一个字段e_shstrndx,其含义就是shstrtable对应的section header在section header table中的索引。

sh_type,4字节,描述了section的类型,常见的取值如下:

  • SHT_NULL 0,表明section header无效,没有关联的section。
  • SHT_PROGBITS 1,section包含了程序需要的数据,格式和含义由程序解释。
  • SHT_SYMTAB 2, 包含了一个符号表。当前,一个ELF文件中只有一个符号表。SHT_SYMTAB提供了用于(link editor)链接编辑的符号,当然这些符号也可能用于动态链接。这是一个完全的符号表,它包含许多符号。
  • SHT_STRTAB 3,包含一个字符串表。一个对象文件包含多个字符串表,比如.strtab(包含符号的名字)和.shstrtab(包含section的名称)。
  • SHT_RELA 4,重定位节,包含relocation入口,参见Elf32_Rela。一个文件可能有多个Relocation Section。比如.rela.text,.rela.dyn。
  • SHT_HASH 5,这样的section包含一个符号hash表,参与动态连接的目标代码文件必须有一个hash表。目前一个ELF文件中只包含一个hash表。讲链接的时候再细讲。
  • SHT_DYNAMIC 6,包含动态链接的信息。目前一个ELF文件只有一个DYNAMIC section。
  • SHT_NOTE 7,note section, 以某种方式标记文件的信息,以后细讲。
  • SHT_NOBITS 8,这种section不含字节,也不占用文件空间,section header中的sh_offset字段只是概念上的偏移。
  • SHT_REL 9, 重定位节,包含重定位条目。和SHT_RELA基本相同,两者的区别在后面讲重定位的时候再细讲。
  • SHT_SHLIB 10,保留,语义未指定,包含这种类型的section的elf文件不符合ABI。
  • SHT_DYNSYM 11, 用于动态连接的符号表,推测是symbol table的子集。
  • SHT_LOPROC 0x70000000 到 SHT_HIPROC 0x7fffffff,为特定于处理器的语义保留。
  • SHT_LOUSER 0x80000000 and SHT_HIUSER 0xffffffff,指定了为应用程序保留的索引的下界和上界,这个范围内的索引可以被应用程序使用。

sh_flags, 32位占4字节, 64位占8字节。包含位标志,用readelf -S <elf>可以看到很多标志。常用的有:

  • SHF_WRITE 0x1,进程执行的时候,section内的数据可写。
  • SHF_ALLOC 0x2,进程执行的时候,section需要占据内存。
  • SHF_EXECINSTR 0x4,节内包含可以执行的机器指令。
  • SHF_STRINGS 0x20,包含0结尾的字符串。
  • SHF_MASKOS 0x0ff00000,这个mask为OS特定的语义保留8位。
  • SHF_MASKPROC 0xf0000000,这个mask包含的所有位保留(也就是最高字节的高4位),为处理器相关的语义使用。

sh_addr, 对32位来说是4字节,64位是8字节。如果section会出现在进程的内存映像中,给出了section第一字节的虚拟地址。

sh_offset,对于32位来说是4字节,64位是8字节。section相对于文件头的字节偏移。对于不占文件空间的section(比如SHT_NOBITS),它的sh_offset只是给出了section逻辑上的位置。

sh_size,section占多少字节,对于SHT_NOBITS类型的section,sh_size没用,其值可能不为0,但它也不占文件空间。

sh_link,含有一个section header的index,该值的解释依赖于section type。

  • 如果是SHT_DYNAMIC,sh_link是string table的section header index,也就是说指向字符串表。
  • 如果是SHT_HASH,sh_link指向symbol table的section header index,hash table应用于symbol table。
  • 如果是重定位节SHT_REL或SHT_RELA,sh_link指向相应符号表的section header index。
  • 如果是SHT_SYMTAB或SHT_DYNSYM,sh_link指向相关联的符号表,暂时不解。
  • 对于其它的section type,sh_link的值是SHN_UNDEF

sh_info,存放额外的信息,值的解释依赖于section type。

  • 如果是SHT_REL和SHT_RELA类型的重定位节,sh_info是应用relocation的节的节头索引。
  • 如果是SHT_SYMTAB和SHT_DYNSYM,sh_info是第一个non-local符号在符号表中的索引。推测local symbol在前面,non-local symbols紧跟在后面,所以文档中也说,sh_info是最后一个本地符号的在符号表中的索引加1。
  • 对于其它类型的section,sh_info是0。

sh_addralign,地址对齐,如果一个section有一个doubleword字段,系统在载入section时的内存地址必须是doubleword对齐。也就是说sh_addr必须是sh_addralign的整数倍。只有2的正整数幂是有效的。0和1说明没有对齐约束。

sh_entsize,有些section包含固定大小的记录,比如符号表。这个值给出了每个记录大小。对于不包含固定大小记录的section,这个值是0。

系统预定义的section name

系统预定义了一些节名(以.开头),这些节有其特定的类型和含义。

  1. .bss:包含程序运行时未初始化的数据(全局变量和静态变量)。当程序运行时,这些数据初始化为0。 其类型为SHT_NOBITS,表示不占文件空间。SHF_ALLOC + SHF_WRITE,运行时要占用内存的。
  2. .comment包含版本控制信息(是否包含程序的注释信息?不包含,注释在预处理时已经被删除了)。类型为SHT_PROGBITS。
  3. .data.data1,包含初始化的全局变量和静态变量。 类型为SHT_PROGBITS,标志为SHF_ALLOC + SHF_WRITE(占用内存,可写)。
  4. .debug,包含了符号调试用的信息,我们要想用gdb等工具调试程序,需要该类型信息,类型为SHT_PROGBITS。
  5. .dynamic,类型SHT_DYNAMIC,包含了动态链接的信息。标志SHF_ALLOC,是否包含SHF_WRITE和处理器有关。
  6. .dynstr,SHT_STRTAB,包含了动态链接用的字符串,通常是和符号表中的符号关联的字符串。标志 SHF_ALLOC
  7. .dynsym,类型SHT_DYNSYM,包含动态链接符号表, 标志SHF_ALLOC。
  8. .fini,类型SHT_PROGBITS,程序正常结束时,要执行该section中的指令。标志SHF_ALLOC + SHF_EXECINSTR(占用内存可执行)。现在ELF还包含.fini_array section。
  9. .got,类型SHT_PROGBITS,全局偏移表(global offset table),以后会重点讲。
  10. .hash,类型SHT_HASH,包含符号hash表,以后细讲。标志SHF_ALLOC。
  11. .init,SHT_PROGBITS,程序运行时,先执行该节中的代码。SHF_ALLOC + SHF_EXECINSTR,和.fini对应。现在ELF还包含.init_array section。
  12. .interp,SHT_PROGBITS,该节内容是一个字符串,指定了程序解释器的路径名。如果文件中有一个可加载的segment包含该节,属性就包含SHF_ALLOC,否则不包含。
  13. .line,SHT_PROGBITS,包含符号调试的行号信息,描述了源程序和机器代码的对应关系。gdb等调试器需要此信息。
  14. .note Note Section, 类型SHT_NOTE,以后单独讲。
  15. .plt 过程链接表(Procedure Linkage Table),类型SHT_PROGBITS,以后重点讲。
  16. .relNAME,类型SHT_REL, 包含重定位信息。如果文件有一个可加载的segment包含该section,section属性将包含SHF_ALLOC,否则不包含。NAME,是应用重定位的节的名字,比如.text的重定位信息存储在.rel.text中。
  17. .relaname类型SHT_RELA,和.rel相同。SHT_RELA和SHT_REL的区别,会在讲重定位的时候说明。
  18. .rodata.rodata1。类型SHT_PROGBITS, 包含只读数据,组成不可写的段。标志SHF_ALLOC。
  19. .shstrtab,类型SHT_STRTAB,包含section的名字。有读者可能会问:section header中不是已经包含名字了吗,为什么把名字集中存放在这里? sh_name 包含的是.shstrtab 中的索引,真正的字符串存储在.shstrtab中。那么section names为什么要集中存储?我想是这样:如果有相同的字符串,就可以共用一块存储空间。如果字符串存在包含关系,也可以共用一块存储空间。
  20. .strtab SHT_STRTAB,包含字符串,通常是符号表中符号对应的变量名字。如果文件有一个可加载的segment包含该section,属性将包含SHF_ALLOC。字符串以\0结束, section以\0开始,也以\0结束。一个.strtab可以是空的,它的sh_size将是0。针对空字符串表的非0索引是允许的。
  21. symtab,类型SHT_SYMTAB,Symbol Table,符号表。包含了定位、重定位符号定义和引用时需要的信息。符号表是一个数组,Index 0 第一个入口,它的含义是undefined symbol index, STN_UNDEF。如果文件有一个可加载的segment包含该section,属性将包含SHF_ALLOC。

练习:读取section names

从这一讲开始,都会有练习,方便我们把前面的理论知识综合运用。

下面这个练习的目标是:从一个ELF文件中读取存储section name的字符串表。前面讲过,该字符串表也是一个section,section header table中有其对应的section header,并且ELF文件头中给出了节名字符串表对应的section header的索引,e_shstrndx。

我们的思路是这样:

  1. 从ELF header中读取section header table的起始位置,每个section header的大小,以及节名字符串表对应section header的索引。
  2. 计算section_header_table_offset + section_header_size * e_shstrndx就是节名字符串表对应section header的偏移。
  3. 读取section header,可以从中得到节名字符串表在文件中的偏移和大小。
  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
/* 64位ELF文件读取section name string table */
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

int main(int argc, char *argv[])
{
/* 打开本地的ELF可执行文件hello */
FILE *fp = fopen("./hello", "rb");
if(!fp) {
perror("open ELF file");
exit(1);
}

/* 1. 通过读取ELF header得到section header table的偏移 */
/* for 64 bit ELF,
e_ident(16) + e_type(2) + e_machine(2) +
e_version(4) + e_entry(8) + e_phoff(8) = 40 */
fseek(fp, 40, SEEK_SET);
uint64_t sh_off;
int r = fread(&sh_off, 1, 8, fp);
if (r != 8) {
perror("read section header offset");
exit(2);
}
/* 得到的这个偏移值,可以用`reaelf -h hello`来验证是否正确 */
printf("section header offset in file: %ld (0x%lx)\n", sh_off, sh_off);

/* 2. 读取每个section header的大小e_shentsize,
section header的数量e_shnum,
以及对应section name字符串表的section header的索引e_shstrndx
得到这些值后,都可以用`readelf -h hello`来验证是否正确 */
/* e_flags(4) + e_ehsize(2) + e_phentsize(2) + e_phnum(2) = 10 */
fseek(fp, 10, SEEK_CUR);
uint16_t sh_ent_size; /* 每个section header的大小 */
r = fread(&sh_ent_size, 1, 2, fp);
if (r != 2) {
perror("read section header entry size");
exit(2);
}
printf("section header entry size: %d\n", sh_ent_size);

uint16_t sh_num; /* section header的数量 */
r = fread(&sh_num, 1, 2, fp);
if (r != 2) {
perror("read section header number");
exit(2);
}
printf("section header number: %d\n", sh_num);

uint16_t sh_strtab_index; /* 节名字符串表对应的节头的索引 */
r = fread(&sh_strtab_index, 1, 2, fp);
if (r != 2) {
perror("read section header string table index");
exit(2);
}
printf("section header string table index: %d\n", sh_strtab_index);

/* 3. read section name string table offset, size */
/* 先找到节头字符串表对应的section header的偏移位置 */
fseek(fp, sh_off + sh_strtab_index * sh_ent_size, SEEK_SET);
/* 再从section header中找到节头字符串表的偏移 */
/* sh_name(4) + sh_type(4) + sh_flags(8) + sh_addr(8) = 24 */
fseek(fp, 24, SEEK_CUR);
uint64_t str_table_off;
r = fread(&str_table_off, 1, 8, fp);
if (r != 8) {
perror("read section name string table offset");
exit(2);
}
printf("section name string table offset: %ld\n", str_table_off);

/* 从section header中找到节头字符串表的大小 */
uint64_t str_table_size;
r = fread(&str_table_size, 1, 8, fp);
if (r != 8) {
perror("read section name string table size");
exit(2);
}
printf("section name string table size: %ld\n", str_table_size);

/* 动态分配内存,把节头字符串表读到内存中 */
char *buf = (char *)malloc(str_table_size);
if(!buf) {
perror("allocate memory for section name string table");
exit(3);
}
fseek(fp, str_table_off, SEEK_SET);
r = fread(buf, 1, str_table_size, fp);
if(r != str_table_size) {
perror("read section name string table");
free(buf);
exit(2);
}
uint16_t i;
for(i = 0; i < str_table_size; ++i) {
/* 如果节头字符串表中的字节是0,就打印`\0` */
if (buf[i] == 0)
printf("\\0");
else
printf("%c", buf[i]);
}
printf("\n");
free(buf);
fclose(fp);
return 0;
}

把以上代码存为chap3_read_section_names.c,执行gcc -Wall -o secnames chap3_read_section_names.c进行编译,输出的执行文件名叫secnames。执行secnames,输出如下:

1
2
3
4
5
6
7
8
./secnames
section header offset in file: 14768 (0x39b0)
section header entry size: 64
section header number: 29
section header string table index: 28
section name string table offset: 14502
section name string table size: 259
\0.symtab\0.strtab\0.shstrtab\0.interp\0.note.ABI-tag\0.note.gnu.build-id\0.gnu.hash\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_r\0.rela.dyn\0.rela.plt\0.init\0.text\0.fini\0.rodata\0.eh_frame_hdr\0.eh_frame\0.init_array\0.fini_array\0.dynamic\0.got\0.got.plt\0.data\0.bss\0.comment\0

可以发现,节头字符串表以\0开始,以\0结束。如果一个section的name字段指向0,则他指向的字节值是0,则它没有名称,或名称是空。

本文的目的:大家对于Hello World程序应该非常熟悉,随便使用哪一种语言,即使还不熟悉的语言,写出一个Hello World程序应该毫不费力,但是如果让大家详细的说明这个程序加载和链接的过程,以及后续的符号动态解析过程,可能还会有点困难。本文就是以一个最基本的C语言版本Hello World程序为基础,了解Linux下ELF文件的格式,分析并验证ELF文件和加载和动态链接的具有实现。

1
2
3
4
5
6
7
8
9
/* hello.c */  
#include <stdio.h>

int main()
{
printf(“hello world!\n”);
return 0;
}
$ gcc –o hello hello.c

ELF文件格式

概述

Executable and Linking Format(ELF)文件是x86 Linux系统下的一种常用目标文件(object file)格式,有三种主要类型:

  • 适于连接的可重定位文件(relocatable file),可与其它目标文件一起创建可执行文件和共享目标文件。
  • 适于执行的可执行文件(executable file),用于提供程序的进程映像,加载的内存执行。
  • 共享目标文件(shared object file),连接器可将它与其它可重定位文件和共享目标文件连接成其它的目标文件,动态连接器又可将它与可执行文件和其它共享目标文件结合起来创建一个进程映像。

ELF文件格式比较复杂,本文只是简要介绍它的结构,希望能给想了解ELF文件结构的读者以帮助。具体详尽的资料请参阅专门的ELF文档。

文件格式

为了方便和高效,ELF文件内容有两个平行的视角:一个是程序连接角度,另一个是程序运行角度,如图所示。

ELF header在文件开始处描述了整个文件的组织,Section提供了目标文件的各项信息(如指令、数据、符号表、重定位信息等),Program header table指出怎样创建进程映像,含有每个program header的入口,section header table包含每一个section的入口,给出名字、大小等信息。

数据表示

ELF数据编码顺序与机器相关,数据类型有六种,见下表:

ELF文件头

像bmp、exe等文件一样,ELF的文件头包含整个文件的控制结构。它的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define EI_NIDENT       16  
typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type; /* file type */
Elf32_Half e_machine; /* architecture */
Elf32_Word e_version;
Elf32_Addr e_entry; /* entry point */
Elf32_Off e_phoff; /* PH table offset */
Elf32_Off e_shoff; /* SH table offset */
Elf32_Word e_flags;
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* PH size */
Elf32_Half e_phnum; /* PH number */
Elf32_Half e_shentsize; /* SH size */
Elf32_Half e_shnum; /* SH number */
Elf32_Half e_shstrndx; /* SH name string table index */
} Elf32_Ehdr;

其中E_ident的16个字节标明是个ELF文件(7F+’E’+’L’+’F’)。e_type表示文件类型,2表示可执行文件。e_machine说明机器类别,3表示386机器,8表示MIPS机器。e_entry给出进程开始的虚地址,即系统将控制转移的位置。e_phoff指出program header table的文件偏移,e_phentsize表示一个program header表中的入口的长度(字节数表示),e_phnum给出program header表中的入口数目。类似的,e_shoff,e_shentsize,e_shnum 分别表示section header表的文件偏移,表中每个入口的的字节数和入口数目。e_flags给出与处理器相关的标志,e_ehsize给出ELF文件头的长度(字节数表示)。e_shstrndx表示section名表的位置,指出在section header表中的索引。

Section Header

目标文件的section header table可以定位所有的section,它是一个Elf32_Shdr结构的数组,Section头表的索引是这个数组的下标。有些索引号是保留的,目标文件不能使用这些特殊的索引。
Section包含目标文件除了ELF文件头、程序头表、section头表的所有信息,而且目标文件section满足几个条件:

目标文件中的每个section都只有一个section头项描述,可以存在不指示任何section的section头项。
每个section在文件中占据一块连续的空间。
Section之间不可重叠。
目标文件可以有非活动空间,各种headers和sections没有覆盖目标文件的每一个字节,这些非活动空间是没有定义的。
Section header结构定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {  
Elf32_Word sh_name; /* name of section, index */
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr; /* memory address, if any */
Elf32_Off sh_offset;
Elf32_Word sh_size; /* section size in file */
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize; /* fixed entry size, if have */
} Elf32_Shdr;

其中sh_name指出section的名字,它的值是后面将会讲到的section header string table中的索引,指出一个以null结尾的字符串。sh_type是类别,sh_flags指示该section在进程执行时的特性。sh_addr指出若此section在进程的内存映像中出现,则给出开始的虚地址。sh_offset给出此section在文件中的偏移。其它字段的意义不太常用,在此不细述。

文件的section含有程序和控制信息,系统使用一些特定的section,并有其固定的类型和属性(由sh_type和sh_info指出)。下面介绍几个常用到的section:“.bss”段含有占据程序内存映像的未初始化数据,当程序开始运行时系统对这段数据初始为零,但这个section并不占文件空间。“.data.”和“.data1”段包含占据内存映像的初始化数据。“.rodata”和“.rodata1”段含程序映像中的只读数据。“.shstrtab”段含有每个section的名字,由section入口结构中的sh_name索引。“.strtab”段含有表示符号表(symbol table)名字的字符串。“.symtab”段含有文件的符号表,在后文专门介绍。“.text”段包含程序的可执行指令。

当然一个实际的ELF文件中,会包含很多的section,如.got,.plt等等,我们这里就不一一细述了,需要时再详细的说明。

Program Header

目标文件或者共享文件的program header table描述了系统执行一个程序所需要的段或者其它信息。目标文件的一个段(segment)包含一个或者多个section。Program header只对可执行文件和共享目标文件有意义,对于程序的链接没有任何意义。结构定义如下:

1
2
3
4
5
6
7
8
9
10
typedef struct elf32_phdr{  
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr; /* virtual address */
Elf32_Addr p_paddr; /* ignore */
Elf32_Word p_filesz; /* segment size in file */
Elf32_Word p_memsz; /* size in memory */
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;

其中p_type描述段的类型;p_offset给出该段相对于文件开关的偏移量;p_vaddr给出该段所在的虚拟地址;p_paddr给出该段的物理地址,在Linux x86内核中,这项并没有被使用;p_filesz给出该段的大小,在字节为单元,可能为0;p_memsz给出该段在内存中所占的大小,可能为0;p_filesze与p_memsz的值可能会不相等。

Symbol Table

目标文件的符号表包含定位或重定位程序符号定义和引用时所需要的信息。符号表入口结构定义如下:

1
2
3
4
5
6
7
8
typedef struct elf32_sym{  
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
unsigned char st_info;
unsigned char st_other;
Elf32_Half st_shndx;
} Elf32_Sym;

其中st_name包含指向符号表字符串表(strtab)中的索引,从而可以获得符号名。st_value指出符号的值,可能是一个绝对值、地址等。st_size指出符号相关的内存大小,比如一个数据结构包含的字节数等。st_info规定了符号的类型和绑定属性,指出这个符号是一个数据名、函数名、section名还是源文件名;并且指出该符号的绑定属性是local、global还是weak。

Section和Segment的区别和联系

可执行文件中,一个program header描述的内容称为一个段(segment)。Segment包含一个或者多个section,我们以Hello World程序为例,看一下section与segment的映射关系:

如上图红色区域所示,就是我们经常提到的文本段和数据段,由图中绿色部分的映射关系可知,文本段并不仅仅包含.text节,数据段也不仅仅包含.data节,而是都包含了多个section。

ELF文件的加载过程

加载和动态链接的简要介绍

从编译/链接和运行的角度看,应用程序和库程序的连接有两种方式。一种是固定的、静态的连接,就是把需要用到的库函数的目标代码(二进制)代码从程序库中抽取出来,链接进应用软件的目标映像中;另一种是动态链接,是指库函数的代码并不进入应用软件的目标映像,应用软件在编译/链接阶段并不完成跟库函数的链接,而是把函数库的映像也交给用户,到启动应用软件目标映像运行时才把程序库的映像也装入用户空间(并加以定位),再完成应用软件与库函数的连接。

这样,就有了两种不同的ELF格式映像。一种是静态链接的,在装入/启动其运行时无需装入函数库映像、也无需进行动态连接。另一种是动态连接,需要在装入/启动其运行时同时装入函数库映像并进行动态链接。Linux内核既支持静态链接的ELF映像,也支持动态链接的ELF映像,而且装入/启动ELF映像必需由内核完成,而动态连接的实现则既可以在内核中完成,也可在用户空间完成。因此,GNU把对于动态链接ELF映像的支持作了分工:把ELF映像的装入/启动入在Linux内核中;而把动态链接的实现放在用户空间(glibc),并为此提供一个称为“解释器”(ld-linux.so.2)的工具软件,而解释器的装入/启动也由内核负责,这在后面我们分析ELF文件的加载时就可以看到。

这部分主要说明ELF文件在内核空间的加载过程,下一部分对用户空间符号的动态解析过程进行说明。

Linux可执行文件类型的注册机制

在说明ELF文件的加载过程以前,我们先回答一个问题,就是:为什么Linux可以运行ELF文件?

回答:内核对所支持的每种可执行的程序类型都有个struct linux_binfmt的数据结构,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 
* This structure defines the functions that are used to load the binary formats that
* linux accepts.
*/
struct linux_binfmt {
struct linux_binfmt * next;
struct module *module;
int (*load_binary)(struct linux_binprm *, struct pt_regs * regs);
int (*load_shlib)(struct file *)
int (*core_dump)(long signr, struct pt_regs * regs, struct file * file);
unsigned long min_coredump; /* minimal dump size */
int hasvdso;
};

其中的load_binary函数指针指向的就是一个可执行程序的处理函数。而我们研究的ELF文件格式的定义如下:
1
2
3
4
5
6
7
8
static struct linux_binfmt elf_format = {  
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
.hasvdso = 1
};

要支持ELF文件的运行,则必须向内核登记这个数据结构,加入到内核支持的可执行程序的队列中。内核提供两个函数来完成这个功能,一个注册,一个注销,即:

1
2
int register_binfmt(struct linux_binfmt * fmt)  
int unregister_binfmt(struct linux_binfmt * fmt)

当需要运行一个程序时,则扫描这个队列,让各个数据结构所提供的处理程序,ELF中即为load_elf_binary,逐一前来认领,如果某个格式的处理程序发现相符后,便执行该格式映像的装入和启动。

内核空间的加载过程

内核中实际执行execv()或execve()系统调用的程序是do_execve(),这个函数先打开目标映像文件,并从目标文件的头部(第一个字节开始)读入若干(当前Linux内核中是128)字节(实际上就是填充ELF文件头,下面的分析可以看到),然后调用另一个函数search_binary_handler(),在此函数里面,它会搜索我们上面提到的Linux支持的可执行文件类型队列,让各种可执行程序的处理程序前来认领和处理。如果类型匹配,则调用load_binary函数指针所指向的处理函数来处理目标映像文件。在ELF文件格式中,处理函数是load_elf_binary函数,下面主要就是分析load_elf_binary函数的执行过程(说明:因为内核中实际的加载需要涉及到很多东西,这里只关注跟ELF文件的处理相关的代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct {  
struct elfhdr elf_ex;
struct elfhdr interp_elf_ex;
struct exec interp_ex;
} *loc;
loc = kmalloc(sizeof(*loc), GFP_KERNEL);
/* Get the exec-header */
loc->elf_ex = *((struct elfhdr *)bprm->buf);
……
/* First of all, some simple consistency checks */
if (memcmp(loc->elf_ex.e_ident, ELFMAG, SELFMAG) != 0)
goto out;
if (loc->elf_ex.e_type != ET_EXEC && loc->elf_ex.e_type != ET_DYN)
goto out;

在load_elf_binary之前,内核已经使用映像文件的前128个字节对bprm->buf进行了填充,563行就是使用这此信息填充映像的文件头(具体数据结构定义见第一部分,ELF文件头节),然后567行就是比较文件头的前四个字节,查看是否是ELF文件类型定义的“\177ELF”。除这4个字符以外,还要看映像的类型是否ET_EXEC和ET_DYN之一;前者表示可执行映像,后者表示共享库。

1
2
3
4
5
6
7
8
9
/* Now read in all of the header information */  
if (loc->elf_ex.e_phnum < 1 || loc->elf_ex.e_phnum > 65536U / sizeof(struct elf_phdr))
goto out;
size = loc->elf_ex.e_phnum * sizeof(struct elf_phdr);
……
elf_phdata = kmalloc(size, GFP_KERNEL);
……
retval = kernel_read(bprm->file, loc->elf_ex.e_phoff,
(char *)elf_phdata, size);

这块就是通过kernel_read读入整个program header table。从代码中可以看到,一个可执行程序必须至少有一个段(segment),而所有段的大小之和不能超过64K。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
elf_ppnt = elf_phdata;  
……
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
if (elf_ppnt->p_type == PT_INTERP) {
……
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
……
retval = kernel_read(bprm->file, elf_ppnt->p_offset,
elf_interpreter,
elf_ppnt->p_filesz);
……
interpreter = open_exec(elf_interpreter);
……
retval = kernel_read(interpreter, 0, bprm->buf,
BINPRM_BUF_SIZE);
……
/* Get the exec headers */
……
loc->interp_elf_ex = *((struct elfhdr *)bprm->buf);
break;
}
elf_ppnt++;
}

这个for循环的目的在于寻找和处理目标映像的“解释器”段。“解释器”段的类型为PT_INTERP,找到后就根据其位置的p_offset和大小p_filesz把整个“解释器”段的内容读入缓冲区(640~640)。事个“解释器”段实际上只是一个字符串,即解释器的文件名,如“/lib/ld-linux.so.2”。有了解释器的文件名以后,就通过open_exec()打开这个文件,再通过kernel_read()读入其开关128个字节(695~696),即解释器映像的头部。我们以Hello World程序为例,看一下这段中具体的内容:

其实从readelf程序的输出中,我们就可以看到需要解释器/lib/ld-linux.so.2,为了进一步的验证,我们用hd命令以16进制格式查看下类型为INTERP的段所在位置的内容,在上面的各个域可以看到,它位于偏移量为0x000114的位置,文件内占19个字节:

从上面红色部分可以看到,这个段中实际保存的就是“/lib/ld-linux.so.2”这个字符串。

1
2
3
4
5
6
7
8
9
for(i = 0, elf_ppnt = elf_phdata; i < loc->elf_ex.e_phnum; i++, elf_ppnt++) {  
……
if (elf_ppnt->p_type != PT_LOAD)
continue;
……
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
elf_prot, elf_flags);
……
}

这段代码从目标映像的程序头中搜索类型为PT_LOAD的段(Segment)。在二进制映像中,只有类型为PT_LOAD的段才是需要装入的。当然在装入之前,需要确定装入的地址,只要考虑的就是页面对齐,还有该段的p_vaddr域的值(上面省略这部分内容)。确定了装入地址后,就通过elf_map()建立用户空间虚拟地址空间与目标映像文件中某个连续区间之间的映射,其返回值就是实际映射的起始地址。

1
2
3
4
5
6
7
8
9
10
if (elf_interpreter) {  
……
elf_entry = load_elf_interp(&loc->interp_elf_ex,
interpreter,
&interp_load_addr);
……
} else {
elf_entry = loc->elf_ex.e_entry;
……
}

这段程序的逻辑非常简单:如果需要装入解释器,就通过load_elf_interp装入其映像(951~953),并把将来进入用户空间的入口地址设置成load_elf_interp()的返回值,即解释器映像的入口地址。而若不装入解释器,那么这个入口地址就是目标映像本身的入口地址。

1
2
3
4
5
create_elf_tables(bprm, &loc->elf_ex,  
(interpreter_type == INTERPRETER_AOUT),
load_addr, interp_load_addr);
……
start_thread(regs, elf_entry, bprm->p);

在完成装入,启动用户空间的映像运行之前,还需要为目标映像和解释器准备好一些有关的信息,这些信息包括常规的argc、envc等等,还有一些“辅助向量(Auxiliary Vector)”。这些信息需要复制到用户空间,使它们在CPU进入解释器或目标映像的程序入口时出现在用户空间堆栈上。这里的create_elf_tables()就起着这个作用。

最后,start_thread()这个宏操作会将eip和esp改成新的地址,就使得CPU在返回用户空间时就进入新的程序入口。如果存在解释器映像,那么这就是解释器映像的程序入口,否则就是目标映像的程序入口。那么什么情况下有解释器映像存在,什么情况下没有呢?如果目标映像与各种库的链接是静态链接,因而无需依靠共享库、即动态链接库,那就不需要解释器映像;否则就一定要有解释器映像存在。

以我们的Hello World为例,gcc在编译时,除非显示的使用static标签,否则所有程序的链接都是动态链接的,也就是说需要解释器。由此可见,我们的Hello World程序在被内核加载到内存,内核跳到用户空间后并不是执行Hello World的,而是先把控制权交到用户空间的解释器,由解释器加载运行用户程序所需要的动态库(Hello World需要libc),然后控制权才会转移到用户程序。

ELF文件中符号的动态解析过程

上面一节提到,控制权是先交到解释器,由解释器加载动态库,然后控制权才会到用户程序。因为时间原因,动态库的具体加载过程,并没有进行深入分析。大致的过程就是将每一个依赖的动态库都加载到内存,并形成一个链表,后面的符号解析过程主要就是在这个链表中搜索符号的定义。

我们后面主要就是以Hello World为例,分析程序是如何调用printf的:

查看一下gcc编译生成的Hello World程序的汇编代码(main函数部分):

1
2
3
4
5
6
08048374 <main>:  
8048374: 8d 4c 24 04 lea 0x4(%esp),%ecx
……
8048385: c7 04 24 6c 84 04 08 movl $0x804846c,(%esp)
804838c: e8 2b ff ff ff call 80482bc <puts@plt>
8048391: b8 00 00 00 00 mov $0x0,%eax

从上面的代码可以看出,经过编译后,printf函数的调用已经换成了puts函数(原因读者可以想一下)。其中的call指令就是调用puts函数。但从上面的代码可以看出,它调用的是puts@plt这个标号,它代表什么意思呢?在进一步说明符号的动态解析过程以前,需要先了解两个概念,一个是global offset table,一个是procedure linkage table。

Global Offset Table(GOT)

在位置无关代码中,一般不能包含绝对虚拟地址(如共享库)。当在程序中引用某个共享库中的符号时,编译链接阶段并不知道这个符号的具体位置,只有等到动态链接器将所需要的共享库加载时进内存后,也就是在运行阶段,符号的地址才会最终确定。因此,需要有一个数据结构来保存符号的绝对地址,这就是GOT表的作用,GOT表中每项保存程序中引用其它符号的绝对地址。这样,程序就可以通过引用GOT表来获得某个符号的地址。

在x86结构中,GOT表的前三项保留,用于保存特殊的数据结构地址,其它的各项保存符号的绝对地址。对于符号的动态解析过程,我们只需要了解的就是第二项和第三项,即GOT[1]和GOT[2]:GOT[1]保存的是一个地址,指向已经加载的共享库的链表地址(前面提到加载的共享库会形成一个链表);GOT[2]保存的是一个函数的地址,定义如下:GOT[2] = &_dl_runtime_resolve,这个函数的主要作用就是找到某个符号的地址,并把它写到与此符号相关的GOT项中,然后将控制转移到目标函数,后面我们会详细分析。

Procedure Linkage Table(PLT)

过程链接表(PLT)的作用就是将位置无关的函数调用转移到绝对地址。在编译链接时,链接器并不能控制执行从一个可执行文件或者共享文件中转移到另一个中(如前所说,这时候函数的地址还不能确定),因此,链接器将控制转移到PLT中的某一项。而PLT通过引用GOT表中的函数的绝对地址,来把控制转移到实际的函数。

在实际的可执行程序或者共享目标文件中,GOT表在名称为.got.plt的section中,PLT表在名称为.plt的section中。

大致的了解了GOT和PLT的内容后,我们查看一下puts@plt中到底是什么内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Disassembly of section .plt:  

0804828c <__gmon_start__@plt-0x10>:
804828c: ff 35 68 95 04 08 pushl 0x8049568
8048292: ff 25 6c 95 04 08 jmp *0x804956c
8048298: 00 00
......
0804829c <__gmon_start__@plt>:
804829c: ff 25 70 95 04 08 jmp *0x8049570
80482a2: 68 00 00 00 00 push $0x0
80482a7: e9 e0 ff ff ff jmp 804828c <_init+0x18>

080482ac <__libc_start_main@plt>:
80482ac: ff 25 74 95 04 08 jmp *0x8049574
80482b2: 68 08 00 00 00 push $0x8
80482b7: e9 d0 ff ff ff jmp 804828c <_init+0x18>
080482bc <puts@plt>:
80482bc: ff 25 78 95 04 08 jmp *0x8049578
80482c2: 68 10 00 00 00 push $0x10
80482c7: e9 c0 ff ff ff jmp 804828c <_init+0x18>

可以看到puts@plt包含三条指令,程序中所有对有puts函数的调用都要先来到这里(Hello World里只有一次)。可以看出,除PLT0以外(就是gmon_start@plt-0x10所标记的内容),其它的所有PLT项的形式都是一样的,而且最后的jmp指令都是0x804828c,即PLT0为目标的。所不同的只是第一条jmp指令的目标和push指令中的数据。PLT0则与之不同,但是包括PLT0在内的每个表项都占16个字节,所以整个PLT就像个数组(实际是代码段)。另外,每个PLT表项中的第一条jmp指令是间接寻址的。比如我们的puts函数是以地址0x8049578处的内容为目标地址进行中跳转的。

顺着这个地址,我们进一步查看此处的内容:

1
2
(gdb) x/w  0x8049578  
0x8049578 <_GLOBAL_OFFSET_TABLE_+20>: 0x080482c2

从上面可以看出,这个地址就是GOT表中的一项。它里面的内容是0x80482c2,即puts@plt中的第二条指令。前面我们不是提到过,GOT中这里本应该是puts函数的地址才对,那为什么会这样呢?原来链接器在把所需要的共享库加载进内存后,并没有把共享库中的函数的地址写到GOT表项中,而是延迟到函数的第一次调用时,才会对函数的地址进行定位。

puts@plt的第二条指令是pushl $0x10,那这个0x10代表什么呢?

1
2
3
4
5
Relocation section '.rel.plt' at offset 0x25c contains 3 entries:  
Offset Info Type Sym.Value Sym. Name
08049570 00000107 R_386_JUMP_SLOT 00000000 __gmon_start__
08049574 00000207 R_386_JUMP_SLOT 00000000 __libc_start_main
08049578 00000307 R_386_JUMP_SLOT 00000000 puts

其中的第三项就是puts函数的重定向信息,0x10即代表相对于.rel.plt这个section的偏移位置(每一项占8个字节)。其中的Offset这个域就代表的是puts函数地址在GOT表项中的位置,从上面puts@plt的第一条指令也可以验证这一点。向堆栈中压入这个偏移量的主要作用就是为了找到puts函数的符号名(即上面的Sym.Name域的“puts”这个字符串)以及puts函数地址在GOT表项中所占的位置,以便在函数定位完成后将函数的实际地址写到这个位置。

puts@plt的第三条指令就跳到了PLT0的位置。这条指令只是将0x8049568这个数值压入堆栈,它实际上是GOT表项的第二个元素,即GOT[1](共享库链表的地址)。

随即PLT0的第二条指令即跳到了GOT[2]中所保存的地址(间接寻址),即_dl_runtime_resolve这个函数的入口。

_dl_runtime_resolve的定义如下:

1
2
3
4
5
6
7
8
9
10
11
_dl_runtime_resolve:  
pushl %eax # Preserve registers otherwise clobbered.
pushl %ecx
pushl %edx
movl 16(%esp), %edx # Copy args pushed by PLT in register. Note
movl 12(%esp), %eax # that `fixup' takes its parameters in regs.
call _dl_fixup # Call resolver.
popl %edx # Get register content back.
popl %ecx
xchgl %eax, (%esp) # Get %eax contents end store function address.
ret $8 # Jump to function address.

从调用puts函数到现在,总共有两次压栈操作,一次是压入puts函数的重定向信息的偏移量,一次是GOT[1](共享库链表的地址)。上面的两次movl操作就是将这两个数据分别取到edx和eax,然后调用_dl_fixup(从寄存器取参数),此函数完成的功能就是找到puts函数的实际加载地址,并将它写到GOT中,然后通过eax将此值返回给_dl_runtime_resolve。xchagl这条指令,不仅将eax的值恢复,而且将puts函数的值压到栈顶,这样当执行ret指令后,控制就转移到puts函数内部。ret指令同时也完成了清栈动作,使栈顶为puts函数的返回地址(main函数中call指令的下一条指令),这样,当puts函数返回时,就返回到正确的位置。

当然,如果是第二次调用puts函数,那么就不需要这么复杂的过程,而只要通过GOT表中已经确定的函数地址直接进行跳转即可。下图是前面过程的一个示意图,红色为第一次函数调用的顺序,蓝色为后续函数调用的顺序(第1步都要执行)。

ELF文件加载和链接的简要总结

用户通过shell执行程序,shell通过exceve进入系统调用。(User-Mode)

sys_execve经过一系列过程,并最终通过ELF文件的处理函数load_elf_binary将用户程序和ELF解释器加载进内存,并将控制权交给解释器。(Kernel-Mode)

ELF解释器进行相关库的加载,并最终把控制权交给用户程序。由解释器处理用户程序运行过程中符号的动态解析。(User-Mode)

Glibc 安装指南(2.6.1 → 2.9)

安装信息的来源

http://www.gnu.org/software/libc/manual/html_node/System-Configuration.html
http://www.gnu.org/software/libc/manual/html_node/Installation.html
http://www.gnu.org/software/libc/manual/html_node/Name-Service-Switch.html

要点提示

编译Glibc的时候应该尽可能使用最新的内核头文件,至少要使用 2.6.16 以上版本的内核,先前的版本有一些缺陷会导致”make check”时一些与pthreads测试相关的项目失败。使用高版本内核头文件编译的Glibc二进制文件完全可以运行在较低版本的内核上,并且当你升级内核后新内核的特性仍然可以得到充分发挥而无需重新编译Glibc。但是如果编译时使用的头文件的版本较低,那么运行在更高版本的内核上时,新内核的特性就不能得到充分发挥。更多细节可以查看[八卦故事]内核头文件传奇的跟帖部分。

推荐使用GCC-4.1以上的版本编译,老版本的GCC可能会生成有缺陷的代码。

不要在运行中的系统上安装 Glibc,否则将会导致系统崩溃,至少应当将新 Glibc 安装到其他的单独目录,以保证不覆盖当前正在使用的 Glibc 。

Glibc 不能在源码目录中编译,它必须在一个额外分开的目录中编译。这样在编译发生错误的时候,就可以删除整个编译目录重新开始。

源码树下的Makeconfig文件中有许多用于特定目的的变量,你可以在编译目录下创建一个configparms文件来改写这些变量。执行make命令的时候configparms文件中的内容将会按照Makefile规则进行解析。比如可以通过在其中设置 CFLAGS LDFLAGS 环境变量来优化编译,设置 CC BUILD_CC AR RANLIB 来指定交叉编译环境。

需要注意的是有些测试项目假定是以非 root 身份执行的,因此我们强烈建议你使用非 root 身份编译和测试 Glibc 。

配置选项
下列选项皆为非默认值[特别说明的除外]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
--help
--version
--quiet
--config-cache
--no-create
--srcdir=DIR
--exec-prefix=EPREFIX
--bindir=DIR
--sbindir=DIR
--libexecdir=DIR
--sysconfdir=DIR
--sharedstatedir=DIR
--localstatedir=DIR
--libdir=DIR
--includedir=DIR
--oldincludedir=DIR
--datarootdir=DIR
--datadir=DIR
--infodir=DIR
--localedir=DIR
--mandir=DIR
--docdir=DIR
--htmldir=DIR
--dvidir=DIR
--pdfdir=DIR
--psdir=DIR
--build=BUILD
--host=HOST

这些选项的含义基本上通用于所有软件包,这里就不特别讲解了。需要注意的是:没有—target=TARGET选项。

—prefix=PREFIX
安装目录,默认为 /usr/local。Linux文件系统标准要求基本库必须位于 /lib 目录并且必须与根目录在同一个分区上,但是 /usr 可以在其他分区甚至是其他磁盘上。因此,如果在Linux平台上指定 —prefix=/usr ,那么基本库部分将自动安装到 /lib 目录下,而非基本库部分则会自动安装到 /usr/lib 目录中,同时将使用 /etc 作为配置目录,也就是等价于”slibdir=/lib sysconfdir=/etc”。但是如果保持默认值或指定其他目录,那么所有组件都间被安装到PREFIX目录下。

—disable-sanity-checks
真正禁用线程(仅在特殊环境下使用该选项)。

—enable-check-abi
在”make check”时执行”make check-abi”。[提示]在我的机器上始终导致check-abi-libm测试失败。

—disable-shared
不编译共享库(即使平台支持)。在支持 ELF 并且使用 GNU 连接器的系统上默认为enable 。[提示] —disable-static 选项实际上是不存在的,静态库总是被无条件的编译和安装。

—enable-profile
启用 profiling 信息相关的库文件编译。主要用于调试目的。

—enable-omitfp
编译时忽略帧指示器(使用 -fomit-frame-pointer 编译),并采取一些其他优化措施。忽略帧指示器可以提高运行效率,但是调试将变得不可用,并且可能生成含有 bug 的代码。使用这个选项还将导致额外编译带有调试信息的非优化版本的静态库(库名称以”_g”结尾)。

—enable-bounded
启用运行时边界检查(比如数组越界),这样会降低运行效率,但能防止某些溢出漏洞。

—disable-versioning
不在共享库对象中包含符号的版本信息。这样可以减小库的体积,但是将不兼容依赖于老版本 C 库的二进制程序。[提示]在我的机器上使用此选项总是导致编译失败。

—enable-oldest-abi=ABI
启用老版本的应用程序二进制接口支持。ABI是Glibc的版本号,只有明确指定版本号时此选项才有效。

—enable-stackguard-randomization
在程序启动时使用一个随机数初始化 __stack_chk_guard ,主要用来抵抗恶意攻击。

—enable-add-ons[=DIRS…]
为了减小软件包的复杂性,一些可选的libc特性被以单独的软件包发布,比如’linuxthreads’(现在已经被废弃了),他们被称为’add-ons’。要使用这些额外的包,可以将他们解压到Glibc的源码树根目录下,然后使用此选项将DIR1,DIR2,…中的附加软件包包含进来。其中的”DIR”是附加软件包的目录名。默认值”yes”表示编译所有源码树根目录下找到的附加软件包。

—disable-hidden-plt
默认情况下隐藏仅供内部调用的函数,以避免这些函数被加入到过程链接表(PLT,Procedure Linkage Table)中,这样可以减小 PLT 的体积并将仅供内部使用的函数隐藏起来。而使用该选项将把这些函数暴露给外部用户。

—enable-bind-now
禁用”lazy binding”,也就是动态连接器在载入 DSO 时就解析所有符号(不管应用程序是否用得到),默认行为是”lazy binding”,也就是仅在应用程序首次使用到的时候才对符号进行解析。因为在大多数情况下,应用程序并不需要使用动态库中的所有符号,所以默认的 “lazy binding”可以提高应用程序的加载性能并节约内存用量。然而,在两种情况下,”lazy binding”是不利的:①因为第一次调用DSO中的函数时,动态连接器要先拦截该调用来解析符号,所以初次引用DSO中的函数所花的时间比再次调用要花的时间长,但是某些应用程序不能容忍这种不可预知性。②如果一个错误发生并且动态连接器不能解析该符号,动态连接器将终止整个程序。在”lazy binding”方式下,这种情况可能发生在程序运行过程中的某个时候。某些应用程序也是不能容忍这种不可预知性的。通过关掉”lazy binding”方式,在应用程序接受控制权之前,让动态连接器在处理进程初始化期间就发现这些错误,而不要到运行时才出乱子。

—enable-static-nss
编译静态版本的NSS(Name Service Switch)库。仅在/etc/nsswitch.conf中只使用dns和files的情况下,NSS才能编译成静态库,并且你还需要在静态编译应用程序的时候明确的连接所有与NSS库相关的库才行[比如:gcc -static test.c -o test -Wl,-lc,-lnss_files,-lnss_dns,-lresolv]。不推荐使用此选项,因为连接到静态NSS库的程序不能动态配置以使用不同的名字数据库。

—disable-force-install
不强制安装当前新编译的版本(即使已存在的文件版本更新)。

—enable-kernel=VERSION
VERSION 的格式是 X.Y.Z,表示编译出来的 Glibc 支持的最低内核版本。VERSION 的值越高(不能超过内核头文件的版本),加入的兼容性代码就越少,库的运行速度就越快。

—enable-all-warnings
在编译时显示所有编译器警告,也就是使用 -Wall 选项编译。

—with-gd
—with-gd-include

—with-gd-lib
指定libgd的安装目录(DIR/include和DIR/lib)。后两个选项分别指定包含文件和库目录。

—without-fp
仅在硬件没有浮点运算单元并且操作系统没有模拟的情况下使用。x86 与 x86_64 的 CPU 都有专门的浮点运算单元。而且 Linux 有 FPU 模拟。简单的说,不要 without 这个选项!因为它会导致许多问题!

—with-binutils=DIR
明确指定编译时使用的Binutils(as,ld)所在目录。

—with-elf
指定使用 ELF 对象格式,默认不使用。建议在支持 ELF 的 Linux 平台上明确指定此选项。

—with-selinux
—without-selinux
启用/禁用 SELinux 支持,默认值自动检测。

—with-xcoff
使用XCOFF对象格式(主要用于windows)。

—without-cvs
不访问CVS服务器。推荐使用该选项,特别对于从CVS下载的的版本。

—with-headers=DIR
指定内核头文件的所在目录,在Linux平台上默认是’/usr/include’。

—without-tls
禁止编译支持线程本地存储(TLS)的库。使用这个选项将导致兼容性问题。

—without-__thread
即使平台支持也不使用TSL特性。建议不要使用该选项。

—with-cpu=CPU
在 gcc 命令行中加入”-mcpu=CPU”。鉴于”-mcpu”已经被反对使用,所以建议不要设置该选项,或者设为 —without-cpu 。

编译与测试

使用 make 命令编译,使用 make check 测试。如果 make check 没有完全成功,就千万不要使用这个编译结果。需要注意的是有些测试项目假定是以非 root 身份执行的,因此我们强烈建议你使用非 root 身份编译和测试。

测试中需要使用一些已经存在的文件(包括随后的安装过程),比如 /etc/passwd, /etc/nsswitch.conf 之类。请确保这些文件中包含正确的内容。

安装与配置
使用 make install 命令安装。比如:make install LC_ALL=C

如果你打算将此 Glibc 安装为主 C 库,那么我们强烈建议你关闭系统,重新引导到单用户模式下安装。这样可以将可能的损害减小到最低。

安装后需要配置 GCC 以使其使用新安装的 C 库。最简单的办法是使用恰当 GCC 的编译选项(比如 -Wl,—dynamic-linker=/lib/ld-linux.so.2 )重新编译 GCC 。然后还需要修改 specs 文件(通常位于 /usr/lib/gcc-lib/TARGET/VERSION/specs ),这个工作有点像巫术,调整实例请参考 LFS 中的两次工具链调整。

可以在 make install 命令行使用’install_root’变量指定安装实际的安装目录(不同于 —prefix 指定的值)。这个在 chroot 环境下或者制作二进制包的时候通常很有用。’install_root’必须使用绝对路径。

被’grantpt’函数调用的辅助程序’/usr/libexec/pt_chown’以 setuid ‘root’ 安装。这个可能成为安全隐患。如果你的 Linux 内核支持’devptsfs’或’devfs’文件系统提供的 pty slave ,那么就不需要使用 pt_chown 程序。

安装完毕之后你还需要配置时区和 locale 。使用 localedef 来配置locale 。比如使用’localedef -i de_DE -f ISO-8859-1 de_DE’将 locale 设置为’de_DE.ISO-8859-1’。可以在编译目录中使用’make localedata/install-locales’命令配置所有可用的 locale ,但是一般不需要这么做。

时区使用’TZ’环境变量设置。tzselect 脚本可以帮助你选择正确的值。设置系统全局范围内的时区可以将 /etc/localtime 文件连接到 /usr/share/zoneinfo 目录下的正确文件上。比如对于中国人可以’ln -s /usr/share/zoneinfo/PRC /etc/localtime’。

Binutils 安装指南(2.18 → 2.19.1)

安装信息的来源

源码包内的下列文件:各级目录下的configure脚本  README  {bfd,binutils,gas,gold,libiberty}/README
要点提示
如果想与GCC联合编译,那么可以将binutils包的内容解压到GCC的源码目录中(tar -xvf binutils-2.19.1.tar.bz2 —strip-components=1 -C gcc-4.3.3),然后按照正常编译GCC的方法编译即可。这样做的好处之一是可以完整的将 GCC 与 Binutils 进行一次bootstrap。

推荐用一个新建的目录来编译,而不是在源码目录中。编译完毕后可以使用”make check”运行测试套件。这个测试套件依赖于DejaGnu软件包,而DejaGnu又依赖于expect,expect依赖于tcl。

如果只想编译 ld 可以使用”make all-ld”,如果只想编译 as 可以使用”make all-gas”。类似的还有 clean-ld clean-as distclean-ld distclean-as check-ld check-as 等。

配置选项
下列选项皆为非默认值[特别说明的除外]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
--help
--version
--quiet
--config-cache
--no-create
--srcdir=DIR
--prefix=PREFIX
--exec-prefix=EPREFIX
--bindir=DIR
--sbindir=DIR
--libexecdir=DIR
--datadir=DIR
--sysconfdir=DIR
--sharedstatedir=DIR
--localstatedir=DIR
--libdir=DIR
--includedir=DIR
--oldincludedir=DIR
--infodir=DIR
--mandir=DIR
--program-prefix=PREFIX
--program-suffix=SUFFIX
--program-transform-name=PROGRAM
--build=BUILD
--host=HOST
--target=TARGET

这些选项的含义基本上通用于所有软件包,这里就不特别讲解了。

—disable-nls
禁用本地语言支持(它允许按照非英语的本地语言显示警告和错误消息)。编译时出现”undefined reference to ‘libintl_gettext’”错误则必须禁用。

—disable-rpath
不在二进制文件中硬编码库文件的路径。

—disable-multilib
禁止编译适用于多重目标体系的库。例如,在x86_64平台上,默认既可以生成64位代码,也可以生成32位代码,若使用此选项,那么将只能生成64位代码。

—enable-cgen-maint=CGENDIR
编译cgen相关的文件[主要用于GDB调试]。

  • —enable-shared[=PKG[,…]]
  • —disable-shared
  • —enable-static[=PKG[,…]]
  • —disable-static
    允许/禁止编译共享或静态版本的库和可执行程序,全部可识别的PKG如下:binutils,gas,gprof,ld,bfd,opcodes,libiberty(仅支持作为静态库)。static在所有目录下的默认值都是”yes”;而shared在不同子目录下默认值不同,有些为”yes”(binutils,gas,gprof,ld)有些为”no”(bfd,opcodes,libiberty)。

—enable-install-libbfd
—disable-install-libbfd
允许或禁止安装 libbfd 以及相关的头文件( libbfd 是二进制文件描述库,用于读写目标文件”.o”,被GDB/ld/as等程序使用)。本地编译或指定—enable-shared的情况下默认值为”yes”,否则默认值为”no”。

—enable-64-bit-bfd
让BFD支持64位目标,如果希望在32位平台上编译64程序就需要使用这个选项。如果指定的目标(TARGET)是64位则此选项默认打开,否则默认关闭(即使 —enable-targets=all 也是如此)。

—enable-elf-stt-common
允许BFD生成STT_COMMON类型的ELF符号。[2.19版本新增选项]

—enable-checking
—disable-checking
允许 as 执行运行时检查。正式发布版本默认禁用,快照版本默认启用。

—disable-werror
禁止将所有编译器警告当作错误看待(因为当编译器为GCC时默认使用-Werror)。

—enable-got=target|single|negative|multigot
指定GOT的处理模式。默认值是”target”。[2.19版本新增选项]

—enable-gold
使用gold代替GNU ld。gold是Google开发的连接器,2008年捐赠给FSF,目的是取代现有的GNU ld,但目前两者还不能完兼容。[2.19版本新增选项]

—enable-plugins
启用gold连接器的插件支持。[2.19版本新增选项]

—enable-threads
编译多线程版本的gold连接器。[2.19版本新增选项]

—with-lib-path=dir1:dir2…
指定编译出来的binutils工具(比如:ld)将来默认的库搜索路径,在绝大多数时候其默认值是”/lib:/usr/lib”。这个工作也可以通过设置 Makefile 中的 LIB_PATH 变量值完成。

—with-libiconv-prefix[=DIR]
—without-libiconv-prefix
在 DIR/include 目录中搜索 libiconv 头文件,在 DIR/lib 目录中搜索 libiconv 库文件。或者根本不使用 libiconv 库。

—with-libintl-prefix[=DIR]
—without-libintl-prefix
在 DIR/include 目录中搜索 libintl 头文件,在 DIR/lib 目录中搜索 libintl 库文件。或者根本不使用 libintl 库。

—with-mmap
使用mmap访问BFD输入文件。某些平台上速度较快,某些平台上速度较慢,某些平台上无法正常工作。

—with-pic
—without-pic
试图仅使用 PIC 或 non-PIC 对象,默认两者都使用。

以下选项仅在与GCC联合编译时才有意义,其含义与GCC相应选项的含义完全一样,默认值也相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
--enable-bootstrap
--disable-bootstrap
--enable-languages=lang1,lang2,...
--enable-stage1-checking
--enable-stage1-languages
--disable-libada
--disable-libgcj
--disable-libgomp
--disable-libmudflap
--disable-libssp
--enable-objc-gc
--disable-cloog-version-check
--disable-ppl-version-check
--with-gnu-as
--with-gnu-ld
--with-gmp=GMPDIR
--with-gmp-include=GMPINCDIR
--with-gmp-lib=GMPLIBDIR
--with-mpfr=MPFRDIR
--with-mpfr-include=MPFRINCDIR
--with-mpfr-lib=MPFRLIBDIR
--with-cloog=CLOOGDIR
--with-cloog_include=CLOOGINCDIR
--with-cloog_lib=CLOOGLIBDIR
--with-ppl=PPLDIR
--with-ppl_include=PPLINCDIR
--with-ppl_lib=PPLLIBDIR
--with-stabs

以下选项仅用于交叉编译环境

—enable-serial-[{host,target,build}-]configure
强制为 host, target, build 顺序配置子包,如果使用”all”则表示所有子包。

—with-sysroot=dir
将 dir 看作目标系统的根目录。目标系统的头文件、库文件、运行时对象都将被限定在其中。

—with-target-subdir=SUBDIR
为 target 在 SUBDIR 子目录中进行配置。

—with-newlib
将’newlib’(另一种标准C库,主要用于嵌入式环境)指定为目标系统的C库进行使用。

—with-build-sysroot=sysroot
在编译时将’sysroot’当作指定 build 平台的根目录看待。仅在已经使用了—with-sysroot选项的时候,该选项才有意义。

—with-build-subdir=SUBDIR
为 build 在 SUBDIR 子目录中进行配置。

—with-build-libsubdir=DIR
指定 build 平台的库文件目录。默认值是SUBDIR。

—with-build-time-tools=path
在给定的path中寻找用于编译Binutils自身的目标工具。该目录中必须包含 ar, as, ld, nm, ranlib, strip 程序,有时还需要包含 objdump 程序。例如,当编译Binutils的系统的文件布局和将来部署Binutils的目标系统不一致时就需要使用此选项。

—with-cross-host=HOST
这个选项已经被反对使用,应该使用—with-sysroot来代替其功能。
以下选项意义不大,一般不用考虑它们

—disable-dependency-tracking
禁止对Makefile规则的依赖性追踪。

—disable-largefile
禁止支持大文件。[2.19版本新增选项]

—disable-libtool-lock
禁止 libtool 锁定以加快编译速度(可能会导致并行编译的失败)

—disable-build-warnings
禁止显示编译时的编译器警告,也就是使用”-w”编译器选项进行编译。

—disable-fast-install
禁止为快速安装而进行优化。

—enable-maintainer-mode
启用无用的 make 规则和依赖性(它们有时会导致混淆)

—enable-commonbfdlib
—disable-commonbfdlib
允许或禁止编译共享版本的 BFD/opcodes/libiberty 库。分析configure脚本后发现这个选项事实上没有任何实际效果。

—enable-install-libiberty
安装 libiberty 的头文件(libiberty.h),许多程序都会用到这个库中的函数(getopt,strerror,strtol,strtoul)。这个选项经过实验,没有实际效果(相当于disable)。

—enable-secureplt
使得binutils默认创建只读的 plt 项。相当于将来调用 gcc 时默认使用 -msecure-plt 选项。仅对 powerpc-linux 平台有意义。

—enable-targets=TARGET,TARGET,TARGET…
使BFD在默认格式之外再支持多种其它平台的二进制文件格式,”all”表示所有已知平台。在32位系统上,即使使用”all”也只能支持所有32位目标,除非同时使用 —enable-64-bit-bfd 选项。由于目前 gas 并不能使用内置的默认平台之外的其它目标,因此这个选项没什么实际意义。此选项在所有目录下都没有默认值。但对于2.19版本,此选项在gold子目录下的默认值是”all”。

—with-bugurl=URL
—without-bugurl
指定发送bug报告的URL/禁止发送bug报告。默认值是”http://www.sourceware.org/bugzilla/"。

—with-datarootdir=DATADIR
将 DATADIR 用作数据根目录,默认值是[PREFIX/share]

—with-docdir=DOCDIR
—with-htmldir=HTMLDIR
—with-pdfdir=PDFDIR
指定各种文档的安装目录。DOCDIR默认值的默认值是DATADIR,HTMLDIR和PDFDIR的默认值是DOCDIR。

—with-included-gettext
使用软件包中自带的 GNU gettext 库。如果你已经使用了Glibc-2.0以上的版本,或者系统中已经安装了GNU gettext软件包,那么就没有必要使用这个选项。默认不使用。

—with-pkgversion=PKG
在 bfd 库中使用”PKG”代替默认的”GNU Binutils”作为版本字符串。比如你可以在其中嵌入编译时间或第多少次编译之类的信息。

—with-separate-debug-dir=DIR
在DIR中查找额外的全局debug信息,默认值:${libdir}/debug

—with-debug-prefix-map=’A=B C=D …’
在调试信息中建立 A-B,C-D, … 这样的映射关系。默认为空。[2.19版本新增选项]

GCC 安装指南(4.3 → 4.4)

要点提示

从GCC-4.3起,安装GCC将依赖于GMP-4.1以上版本和MPFR-2.3.2以上版本。如果将这两个软件包分别解压到GCC源码树的根目录下,并分别命名为”gmp”和”mpfr”,那么GCC的编译程序将自动将两者与GCC一起编译。建议尽可能使用最新的GMP和MPFR版本。

推荐用一个新建的目录来编译GCC,而不是在源码目录中,这一点玩过LFS的兄弟都很熟悉了。另外,如果先前在编译中出现了错误,推荐使用 make distclean 命令进行清理,然后重新运行 configure 脚本进行配置,再在另外一个空目录中进行编译。

配置选项

[注意]这里仅包含适用于 C/C++ 语言编译器、十进制数字扩展库(libdecnumber)、在多处理机上编写并行程序的应用编程接口GOMP库(libgomp)、大杂烩的libiberty库、执行运行时边界检查的库(libmudflap)、保护堆栈溢出的库(libssp)、标准C++库(libstdc++) 相关的选项。也就是相当于 gcc-core 与 gcc-g++ 两个子包的选项。并不包括仅仅适用于其他语言的选项。

每一个 —enable 选项都有一个对应的 —disable 选项,同样,每一个 —with 选项也都用一个对应的 —without 选项。每一对选项中必有一个是默认值(依赖平台的不同而不同)。下面所列选项若未特别说明皆为非默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
--help
--version
--quiet
--config-cache
--no-create
--srcdir=DIR
--prefix=PREFIX
--exec-prefix=EPREFIX
--bindir=DIR
--sbindir=DIR
--libexecdir=DIR
--datadir=DIR
--sysconfdir=DIR
--sharedstatedir=DIR
--localstatedir=DIR
--libdir=DIR
--includedir=DIR
--oldincludedir=DIR
--infodir=DIR
--mandir=DIR
--program-prefix=PREFIX
--program-suffix=SUFFIX
--program-transform-name=PROGRAM
--build=BUILD
--host=HOST
--target=TARGET

这些选项的含义基本上通用于所有软件包,这里就不特别讲解了。

—disable-nls
禁用本地语言支持(它允许按照非英语的本地语言显示警告和错误消息)。编译时出现”undefined reference to ‘libintl_gettext’”错误则必须禁用。

—disable-rpath
不在二进制文件中硬编码库文件的路径。

—enable-bootstrap
—disable-bootstrap
“bootstrap”的意思是用第一次编译生成的程序来第二次编译自己,然后又用第二次编译生成的程序来第三次编译自己,最后比较第二次和第三次编译的结果,以确保编译器能毫无差错的编译自身,这通常表明编译是正确的。非交叉编译的情况下enable是默认值;交叉编译的情况下,disable是默认值。提示:stage2出来的结果是”最终结果”。

—enable-checking[=LIST]
该选项会在编译器内部生成一致性检查的代码,它并不改变编译器生成的二进制结果。这样导致编译时间增加,并且仅在使用GCC作为编译器的时候才有效,但是对输出结果没有影响。在”gcc”子目录下,对从CVS下载的版本默认值是”yes”(=assert,misc,tree,gc,rtlflag,runtime),对于正式发布的版本则是”release”(=assert,runtime),在”libgcc”子目录下,默认值始终是”no”。可以从 “assert,df,fold,gc,gcac,misc,rtlflag,rtl,runtime,tree,valgrind”中选择你想要检查的项目(逗号隔开的列表,”all”表示全部),其中rtl,gcac,valgrind非常耗时。使用 —disable-checking 完全禁止这种检查会增加未能检测内部错误的风险,所以不建议这样做。

—enable-languages=lang1,lang2,…
只安装指定语言的编译器及其运行时库,可以使用的语言是:ada, c, c++, fortran, java, objc, obj-c++ ,若不指定则安装所有默认可用的语言(ada和obj-c++为非默认语言)。

—disable-multilib
禁止编译适用于多重目标体系的库。例如,在x86_64平台上,编译器默认既可以生成64位代码,也可以生成32位代码,若使用此选项,那么将只能生成64位代码。

—enable-shared[=PKG[,…]]
—disable-shared
—enable-static[=PKG[,…]]
—disable-static
允许/禁止编译共享或静态版本的库,全部可识别的库如下:libgcc,libstdc++,libffi,zlib,boehm-gc,ada,libada,libjava,libobjc,libiberty(仅支持作为静态库)。static在所有目录下的默认值都是”yes”;shared除了在libiberty目录下的默认值是”no”外,在其它目录下的默认值也都是”yes”。

—enable-decimal-float[=bid|dpd]
—disable-decimal-float
启用或禁用 libdecnumber 库符合 IEEE 754-2008 标准的 C 语言十进制浮点扩展,还可以进一步选择浮点格式(bid是i386与x86_64的默认值;dpd是PowerPC的默认值)。在 PowerPC/i386/x86_64 GNU/Linux 系统默认启用,在其他系统上默认禁用。

—disable-libgomp
不编译在多处理机上编写并行程序的应用编程接口GOMP库(libgomp)。

—disable-libmudflap
不编译执行运行时边界检查的库(libmudflap)。

—disable-libssp
不编译保护缓冲区溢出的运行时库。

—disable-symvers
禁用共享库对象中符号包含的版本信息。使用这个选项将导致 ABI 发生改变。禁用版本信息可以减小库的体积,但是将不兼容依赖于老版本库的二进制程序。它还会导致 libstdc++ 的 abi_check 测试失败,但你可以忽略这个失败。

—enable-threads=posix|aix|dce|gnat|mach|rtems|solaris|vxworks|win32|nks
—disable-threads
启用或禁用线程支持,若启用,则必须同时明确指定线程模型(不同平台支持的线程库并不相同,Linux现在一般使用posix)。这将对Objective-C编译器、运行时库,以及C++/Java等面向对象语言的异常处理产生影响。

—enable-version-specific-runtime-libs
将运行时库安装在编译器特定的子目录中(${libdir}/gcc-lib/${target_alias}/${gcc_version}),而不是默认的${libdir}目录中。另外,’libstdc++’的头文件将被安装在 ${libdir}/gcc-lib/${target_alias}/${gcc_version}/include/g++ 目录中(除非同时又指定了 —with-gxx-include-dir)。如果你打算同时安装几个不同版本的 GCC ,这个选项就很有用处了。当前,libgfortran,libjava,libmudflap,libstdc++,libobjc都支持该选项。

—enable-werror
—disable-werror
是否将所有编译器警告当作错误看待(使用-Werror来编译)。对于开发中的版本和快照默认为”yes”,对于正式发布的版本则默认为”no”。

—with-as=pathname
—with-ld=pathname
指定将来GCC使用的汇编器/连接器的位置,必须使用绝对路径。如果configure的默认查找过程找不到汇编器/连接器,就会需要该选项。或者系统中有多个汇编器/连接器,也需要它来指定使用哪一个。如果使用GNU的汇编器,那么你必须同时使用GNU连接器。

—with-datarootdir=DATADIR
将 DATADIR 用作数据根目录,默认值是[PREFIX/share]

—with-docdir=DOCDIR
—with-htmldir=HTMLDIR
—with-pdfdir=PDFDIR
指定各种文档的安装目录。DOCDIR默认值的默认值是DATADIR,HTMLDIR和PDFDIR的默认值是DOCDIR。

—with-gmp=GMPDIR
—with-gmp-include=GMPINCDIR
—with-gmp-lib=GMPLIBDIR
指定 GMP 库的安装目录/头文件目录/库目录。指定GMPDIR相当于同时指定了:GMPINCDIR=GMPDIR/include,GMPLIBDIR=GMPDIR/lib 。

—with-mpfr=MPFRDIR
—with-mpfr-include=MPFRINCDIR
—with-mpfr-lib=MPFRLIBDIR
指定 MPFR 库的安装目录/头文件目录/库目录。指定MPFRDIR相当于同时指定了:MPFRINCDIR=MPFRDIR/include,MPFRLIBDIR=MPFRDIR/lib 。

—with-cloog=CLOOGDIR
—with-cloog_include=CLOOGINCDIR
—with-cloog_lib=CLOOGLIBDIR
指定CLooG(Chunky Loop Generator)的安装目录/头文件目录/库目录。指定CLOOGDIR相当于同时指定了:CLOOGINCDIR=CLOOGDIR/include,CLOOGLIBDIR=CLOOGDIR/lib 。[GCC-4.4新增选项]

—with-ppl=PPLDIR
—with-ppl_include=PPLINCDIR
—with-ppl_lib=PPLLIBDIR
指定PPL(Parma Polyhedra Library)的安装目录/头文件目录/库目录。指定PPLDIR相当于同时指定了:PPLINCDIR=PPLDIR/include,PPLLIBDIR=PPLDIR/lib 。[GCC-4.4新增选项]

—with-gxx-include-dir=DIR
G++头文件的安装目录,默认为”prefix/include/c++/版本”。

—with-libiconv-prefix[=DIR]
—without-libiconv-prefix
在 DIR/include 目录中搜索 libiconv 头文件,在 DIR/lib 目录中搜索 libiconv 库文件。或者根本不使用 libiconv 库。

—with-libintl-prefix[=DIR]
—without-libintl-prefix
在 DIR/include 目录中搜索 libintl 头文件,在 DIR/lib 目录中搜索 libintl 库文件。或者根本不使用 libintl 库。

—with-local-prefix=DIR
指定本地包含文件的安装目录,不管如何设置—prefix,其默认值都为 /usr/local 。只有在系统已经建立了某些特定的目录规则,而不再是在 /usr/local/include 中查找本地安装的头文件的时候,该选项才使必须的。不能指定为 /usr ,也不能指定为安装GCC自身头文件的目录(默认为$libdir/gcc/$target/$version/include),因为安装的头文件会和系统的头文件混合,从而造成冲突,导致不能编译某些程序。

—with-long-double-128
—without-long-double-128
指定long double类型为 128-bit 或 64-bit(等于double) 。基于 Glibc 2.4 或以上版本编译时默认为 128-bit ,其他情况默认为 64-bit ;但是可以使用这个选项强制指定。

—with-pic
—without-pic
试图仅使用 PIC 或 non-PIC 对象,默认两者都使用。

—with-slibdir=DIR
共享库(libgcc)的安装目录,默认等于 —libdir 的值。

—with-system-libunwind
使用系统中已经安装的libunwind库,默认自动检测。

—with-system-zlib
使用系统中的libz库,默认使用GCC自带的库。

以下选项仅适用于 C++ 语言:

—disable-c99
禁止支持 C99 标准。该选项将导致 ABI 接口发生改变。

—enable-cheaders=c|c_std|c_global
为 g++ 创建C语言兼容的头文件,默认为”c_global”。

—enable-clocale[=gnu|ieee_1003.1-2001|generic]
指定目标系统的 locale 模块,默认值为自动检测。建议明确设为”gnu”,否则可能会编译出 ABI 不兼容的 C++ 库。

—enable-clock-gettime[=yes|no|rt]
指明如何获取C++0x草案里面time.clock中clock_gettime()函数:”yes”表示在libc和libposix4库中检查(而libposix4在需要的时候还可能会链接到libstdc++)。”rt”表示还额外在librt库中查找,这一般并不是一个很好的选择,因为librt经常还会连接到libpthread上,从而使得单线程的程序产生不必要的锁定开销。默认值”no”则完全跳过这个检查。[GCC-4.4新增选项]

—enable-concept-checks
打开额外的实例化库模板编译时检查(以特定的模板形式),这可以帮助用户在他们的程序运行之前就发现这些程序在何处违反了STL规则。

—enable-cstdio=PACKAGE
使用目标平台特定的 I/O 包,PACKAGE的默认值是”stdio”,也是唯一可用的值。使用这个选项将导致 ABI 接口发生改变。

—enable-cxx-flags=FLAGS
编译 libstdc++ 库文件时传递给编译器的编译标志,是一个引号界定的字符串。默认为空,表示使用环境变量 CXXFLAGS 的值。

—enable-fully-dynamic-string
该选项启用了一个特殊版本的 basic_string 来禁止在预处理的静态存储区域中放置空字符串的优化手段。参见 PR libstdc++/16612 获取更多细节。

—disable-hosted-libstdcxx
默认编译特定于主机环境的C++库。使用该选项将仅编译独立于主机环境的C++运行时库(前者的子集)。

—enable-libstdcxx-allocator[=new|malloc|mt|bitmap|pool]
指定目标平台特定的底层 std::allocator ,默认自动检测。使用这个选项将导致 ABI 接口发生改变。

—enable-libstdcxx-debug
额外编译调试版本的 libstdc++ 库文件,并默认安装在 ${libdir}/debug 目录中。

—enable-libstdcxx-debug-flags=FLAGS
编译调试版本的 libstdc++ 库文件时使用的编译器标志,默认为”-g3 -O0”

—disable-libstdcxx-pch
禁止创建预编译的 libstdc++ 头文件(stdc++.h.gch),这个文件包含了所有标准 C++ 的头文件。该选项的默认值等于hosted-libstdcxx的值。

—disable-long-long
禁止使用模板支持’long long’类型。’long long’是 C99 新引进的类型,也是 GNU 对 C++98 标准的一个扩展。该选项将导致 ABI 接口发生改变。

—enable-sjlj-exceptions
强制使用旧式的 setjmp/longjmp 异常处理模型,使用这个选项将导致 ABI 接口发生改变。默认使用可以大幅降低二进制文件尺寸和内存占用的新式的 libunwind 库进行异常处理。建议不要使用此选项。

—disable-visibility
禁止 -fvisibility 编译器选项的使用(使其失效)。

—disable-wchar_t
禁止使用模板支持多字节字符类型’wchar_t’。该选项将导致 ABI 接口发生改变。

以下选项仅用于交叉编译:

—enable-serial-[{host,target,build}-]configure
强制为 host, target, build 顺序配置子包,如果使用”all”则表示所有子包。

—with-sysroot=DIR
将DIR看作目标系统的根目录。目标系统的头文件、库文件、运行时对象都将被限定在其中。其默认值是 ${gcc_tooldir}/sys-root 。

—with-target-subdir=SUBDIR
为 target 在 SUBDIR 子目录中进行配置。

—with-newlib
将’newlib’指定为目标系统的C库进行使用。这将导致 libgcc.a 中的 __eprintf 被忽略,因为它被假定为由’newlib’提供。

—with-build-subdir=SUBDIR
为 build 在 SUBDIR 子目录中进行配置。

—with-build-libsubdir=DIR
指定 build 平台的库文件目录。默认值是SUBDIR。

—with-build-sysroot=sysroot
在编译时将’sysroot’当作指定 build 平台的根目录看待。仅在已经使用了—with-sysroot选项的时候,该选项才有意义。

—with-build-time-tools=path
在给定的path中寻找用于编译GCC自身的目标工具。该目录中必须包含 ar, as, ld, nm, ranlib, strip 程序,有时还需要包含 objdump 程序。例如,当编译GCC的系统的文件布局和将来部署GCC的目标系统不一致时就需要使用此选项。

—with-cross-host=HOST
这个选项已经被反对使用,应该使用—with-sysroot来代替其功能。

编译、测试、安装

除了使用 CFLAGS,LDFLAGS 之外,还可以使用 LIBCFLAGS,LIBCXXFLAGS 控制库文件(由stage3编译)的编译器选项。可以在 make 命令行上使用 BOOT_CFLAGS,BOOT_LDFLAGS 来控制 stage2,stage3 的编译。可以使用 make bootstrap4 来增加步骤以避免 stage1 可能被错误编译所导致的错误。可以使用 make profiledbootstrap 在编译stage1时收集一些有用的统计信息,然后使用这些信息编译最终的二进制文件,这样可以提升编译器和相应库文件的执行效率。

编译完毕后可以使用”make check”运行测试套件,然后可以和http://gcc.gnu.org/buildstat.html里面列出来的结果进行对比,只要"unexpected failures”不要太多就好说。这个测试套件依赖于DejaGnu软件包,而DejaGnu又依赖于expect,expect依赖于tcl。如果只想运行C++测试,可以使用”make check-g++”命令;如果只想运行C编译器测试,可以使用”make check-gcc”。还可以制定只运行某些单项测试:比如使用 make check RUNTESTFLAGS=”compile.exp -v” 运行编译测试。另一方面,GCC并不支持使用”make uninstall”进行卸载,建议你将GCC安装在一个特别的目录中,然后在不需要的时候直接删除这个目录。

因为GCC的安装依赖于GMP和MPFR,所以下面附上GMP和MPFR的安装信息,主要是configure选项。

优化基本原理

编译原理出于代码编译的模块化组装考虑,一般会在语义分析的阶段生成平台无关的中间代码,经过中间代码级的代码优化,而后作为输入进入代码生成阶段,产生最终运行机器平台上的目标代码,再经过一次目标代码级别的代码优化(一般和具体机器的硬件结构高度耦合,复杂且不通用)。故而出于理解编译原理的角度考虑,代码优化一般都是以中间代码级代码优化手段作为研究对象。

代码优化按照优化的代码块尺度分为:局部优化、循环优化和全局优化。即

  1. 局部优化:只有一个控制流入口、一个控制流出口的基本程序块上进行的优化;
  2. 循环优化:对循环中的代码进行的优化;
  3. 全局优化:在整个程序范围内进行的优化。

常见的代码优化手段

常见的代码优化技术有:删除多余运算、合并已知量和复写传播,删除无用赋值等。采用转载自《编译原理》教材中关于这些优化技术的图例快速地展示下各优化技术的具体内容。
针对目标代码:

1
2
3
P := 0
for I := 1 to 20 do
P := P + A[I]*B[I]

假设其翻译所得的中间代码如下

删除多余运算:分析上图的中间代码,可以发现 (3)和式 (6)属于重复计算( 因为I并没有发生变化),故而式 (6)是多余的,完全可以采用 T4∶=T1代替。

代码外提:减少循环中代码总数的一个重要办法是循环中不变的代码段外提。这种变换把循环不变运算,即结果独立于循环执行次数的表达式,提到循环的前面,使之只在循环外计算一次。针对改定的例子,显然数组A和 B的首地址在计算过程中并不改变,则作出的改动如下

强度削弱:强度削弱的本质是把强度大的运算换算成强度小的运算,例如将乘法换成加法运算。针对上面的循环过程,每循环一次,I的值增加1,T1的值与I保持线性关系,每次总是增加4。因此,可以把循环中计算T1值的乘法运算变换成在循环前进行一次乘法运算,而在循环中将其变换成加法运算。

变换循环控制条件:I和T1始终保持T1=4*I的线性关系,因此可以把四元式(12)的循环控制条件I≤20变换成T1≤80,这样整个程序的运行结果不变。这种变换称为变换循环控制条件。经过这一变换后,循环中I的值在循环后不会被引用,四元式(11)成为多余运算,可以从循环中删除。变换循环控制条件可以达到代码优化的目的。

合并已知量和复写传播:四元式(3)计算4*I时,I必为1。即4*I的两个运算对象都是编码时的已知量,可在编译时计算出它的值,即四元式(3)可变为T1=4,这种变换称为合并已知量。

四元式(6)把T1的值复写到T4中,四元式(8)要引用T4的值,而从四元式(6)到四元式(8)之间未改变T4和T1的值,则将四元式(8)改为T6∶=T5[T1],这种变换称为复写传播。

删除无用赋值:式(6)对T4赋值,但T4未被引用;另外,(2)和(11)对I赋值,但只有(11)引用I。所以,只要程序中其它地方不需要引用T4和I,则(6),(2)和(11)对程序的运行结果无任何作用。我们称之为无用赋值,无用赋值可以从程序中删除。至此,我们可以得到删减后简洁的代码

基本块内的局部优化

基本块的划分

入口语句的定义如下:

  • 程序的第一个语句;或者,
  • 条件转移语句或无条件转移语句的转移目标语句;
  • 紧跟在条件转移语句后面的语句。

有了入口语句的概念之后,就可以给出划分中间代码(四元式程序)为基本块的算法,其步骤如下:

  • 求出四元式程序中各个基本块的入口语句。
  • 对每一入口语句,构造其所属的基本块。它是由该入口语句到下一入口语句(不包括下一入口语句),或到一转移语句(包括该转移语句),或到一停语句(包括该停语句)之间的语句序列组成的。
  • 凡未被纳入某一基本块的语句、都是程序中控制流程无法到达的语句,因而也是不会被执行到的语句,可以把它们删除。

基本块的优化手段

由于基本块内的逻辑清晰,故而要做的优化手段都是较为直接浅层次的。目前基本块内的常见的块内优化手段有:

  1. 删除公共子表达式
  2. 删除无用代码
  3. 重新命名临时变量 (一般是用来应对创建过多临时变量的,如t2 := t1 + 3如果后续并没有对t1的引用,则可以t1 := t1 + 3来节省一个临时变量的创建)
  4. 交换语句顺序
  5. 在结果不变的前提下,更换代数操作(如x∶=y2是需要根据运算符重载指数函数的,这是挺耗时的操作,故而可以用强度更低的x∶=y*y来代替)
    根据以上原则,对如下代码进行优化
    1
    2
    3
    4
    5
    6
    t1 := 4 - 2
    t2 := t1 / 2
    t3 := a * t2
    t4 := t3 * t1
    t5 := b + t4
    c := t5 * t5
    给出优化的终版代码
    1
    2
    3
    t1 := a + a
    t1 := b + t1
    c := t1 * t1
    显然代码优化的工作不能像上面那样的人工一步步确认和遍历,显然必然要将这些优化工作公理化。而一般到涉及到数据流和控制流简化的这种阶段,都是到了图论一展身手的时候。

DAG(无环路有向图)应用于基本块的优化工作

在DAG图中,通过节点间的连线和层次关系来表示表示式或运算的归属关系:

  • 图的叶结点,即无后继的结点,以一标识符(变量名)或常数作为标记,表示这个结点代表该变量或常数的值。如果叶结点用来代表某变量A的地址,则用addr(A)作为这个结点的标记。
  • 图的内部结点,即有后继的结点,以一运算符作为标记,表示这个结点代表应用该运算符对其后继结点所代表的值进行运算的结果。
    (注:该部分内容转载自教材《编译原理》第11章DAG无环路有向图应用于代码优化)

DAG构建的流程如下

  • 对基本块的每一四元式,依次执行:
    • 1如果NODE(B)无定义,则构造一标记为B的叶结点并定义NODE(B)为这个结点;
      • 如果当前四元式是0型,则记NODE(B)的值为n,转4。
      • 如果当前四元式是1型,则转2.(1)。
      • 如果当前四元式是2型,则:(Ⅰ)如果NODE(C)无定义,则构造一标记为C的叶结点并定义NODE(C)为这个结点,(Ⅱ)转2.(2)。
    • 2
      • 如果NODE(B)是标记为常数的叶结点,则转2.(3),否则转3.(1)。
      • 如果NODE(B)和NODE(C)都是标记为常数的叶结点,则转2.(4),否则转3.(2)。
      • 执行op B(即合并已知量),令得到的新常数为P。如果NODE(B)是处理当前四元式时 新构造出来的结点,则删除它。如果NODE(P)无定义,则构造一用P做标记的叶结点n。置NODE(P)=n,转4.。
      • 执行B op C(即合并已知量),令得到的新常数为P。如果NODE(B)或NODE(C)是处理当前四元式时新构造出来的结点,则删除它。如果NODE(P)无定义,则构造一用P做标记的叶结点n。置NODE(P)=n,转4.。
    • 3.
      • 检查DAG中是否已有一结点,其唯一后继为NODE(B),且标记为op(即找公共子表达式)。如果没有,则构造该结点n,否则就把已有的结点作为它的结点并设该结点为n,转4.。
      • 检查DAG中是否已有一结点,其左后继为NODE(B),右后继为NODE(C),且标记为op(即找公共子表达式)。如果没有,则构造该结点n,否则就把已有的结点作为它的结点并设该结点为n。转4.。
    • 4.
      • 如果NODE(A)无定义,则把A附加在结点n上并令NODE(A)=n;否则先把A从NODE(A)结点上的附加标识符集中删除(注意,如果NODE(A)是叶结点,则其标记A不删除),把A附加到新结点n上并令NODE(A)=n。转处理下一四元式。

说着很复杂,下面看一个案例

1
2
3
4
5
6
7
8
9
10
(1) T0∶=3.14
(2) T1∶=2 * T0
(3) T2∶=R + r
(4) A∶=T1 * T2
(5) B∶=A
(6) T3∶=2 * T0
(7) T4∶=R + r
(8) T5∶=T3 * T4
(9) T6∶=R - r
(10) B∶=T5 * T6

其DAG图的构建过程如下

通过DAG图可以发现诸多的优化信息,如重复定义、无用定义等,则根据上图的DAG图可以构建最后的优化代码序列

1
2
3
4
  (1) S1∶=R+r
  (2) A∶=6.28*S1
  (3) S2∶=R-r
  (4) B∶=A *S2

循环优化

根据上面基本块的定义,我们将诸多基本块组装在一起,构建成程序循环图,如针对下面这个例子

1
2
3
4
5
6
7
8
9
  (1) read x
  (2) read y
  (3) r∶=x mod y
  (4) if r=0 goto (8)
  (5) x∶=y
  (6) y∶=r
  (7) goto (3)
  (8) write y
  (9) halt

则按照上面基本块的划分,可以分成四个部分,四个部分的控制流分析可知可以得到一个循环图

循环块最主要的特点是只有一个数据流和控制流入口,而出口可能有多个。循环优化的主要手段有:循环次数无关性代码外提、删除归纳变量和运算强度削弱。关于这三种手段的理解可以借助此前的描述进行类比,基本并无太多差异。

编译时的数学库问题

前言

链接是代码生成可执行文件中一个非常重要的过程。我们在使用一些库函数时,有时候需要链接库,有时候又不需要,这是为什么呢?了解一些链接的基本过程,能够帮助我们在编译时解决一些疑难问题。比如,下面就有一种奇怪的现象。

一个奇怪的链接问题

程序功能很简单,计算e的n次方。程序清单如下(代码一):

1
2
3
4
5
6
7
8
#include<stdio.h>
#include<math.h>
int main(int argc,char *argv[])
{
double a = exp(2);
printf("%lf\n",a);
return 0;
}

编译运行:
1
2
3
gcc -o expTest expTest.c
./expTest
7.389056

一切似乎顺理成章,我们再来看下面这种情况,将变量b=2传入exp函数(代码二):
1
2
3
4
5
6
7
8
9
#include<stdio.h>
#include<math.h>
int main(int argc,char *argv[])
{
int b = 2;
double a = exp(b);
printf("%lf\n",a);
return 0;
}

编译:
1
2
3
4
gcc -o expTest expTest.c
/tmp/ccx5lXbS.o:在函数‘main’中:
expTest.c:(.text+0x20):对‘exp’未定义的引用
collect2: error: ld returned 1 exit status

我们发现,同样的编译方法编译不过了,提示对‘exp’未定义的引用,并且抛出链接出错。

我们通过man命令查看exp函数:

1
2
3
4
5
6
7
8
9
10
11
12
man 3 exp
NAME
exp, expf, expl - base-e exponential function

SYNOPSIS
#include <math.h>

double exp(double x);
float expf(float x);
long double expl(long double x);

Link with -lm.

发现它除了需要包含头文件math.h外,编译时还需要使用-lm链接。
再次编译运行:
1
2
3
4
gcc -lm -o  expTest expTest.c 
/tmp/ccYT3E65.o:在函数‘main’中:
expTest.c:(.text+0x20):对‘exp’未定义的引用
collect2: error: ld returned 1 exit status

为什么还是不行呢?我们已经按照帮助手册的只是加了-lm了啊?难道是位置不对?我们换个位置试试:
1
2
3
gcc -o  expTest expTest.c -lm 
./expTest
7.389056

现在终于成功编译并运行。

分析

虽然最后终于成功编译运行,但是不免产生了几个疑问:

两段代码同样都调用了exp函数,为什么一个需要链接,一个不需要链接呢?

到底什么时候需要链接呢?

为什么链接的时候放在前面就不行呢?

我们一一解答。
1.为什么一个需要链接,一个不需要?
我们可以观察到,代码一调用exp传入的参数是常量2,代码二调用exp传入的参数是变量b,那么对于代码一会不会在运行之前就计算好了呢?
我们来看一下它们的汇编代码。
代码一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.LC1:
.string "%lf\n"
main:
push rbp
mov rbp, rsp
sub rsp, 32
mov DWORD PTR [rbp-20], edi
mov QWORD PTR [rbp-32], rsi
movsd xmm0, QWORD PTR .LC0[rip]
movsd QWORD PTR [rbp-8], xmm0
movsd xmm0, QWORD PTR [rbp-8]
mov edi, OFFSET FLAT:.LC1
mov eax, 1
call printf
mov eax, 0
leave
ret
.LC0:
.long 3100958126
.long 1075678820

代码二:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.LC0:
.string "%lf\n"
main:
push rbp
mov rbp, rsp
sub rsp, 32
mov DWORD PTR [rbp-20], edi
mov QWORD PTR [rbp-32], rsi
mov DWORD PTR [rbp-4], 2
cvtsi2sd xmm0, DWORD PTR [rbp-4]
call exp
movq rax, xmm0
mov QWORD PTR [rbp-16], rax
movsd xmm0, QWORD PTR [rbp-16]
mov edi, OFFSET FLAT:.LC0
mov eax, 1
call printf
mov eax, 0
leave
ret

汇编的具体细节我们无需尽知,但是我们可以很明显地看到,第二段代码调用了exp函数(call exp指令),而第一段代码没有看到调用exp的身影。
实际上,通过汇编代码可以看到,当传入参数为常量时,就已经计算好了值(emm0寄存器为浮点运算相关寄存器),最后根本不需要调用exp函数。而对于变量型的参数,其值在运行时确定,因此需要调用。我们还可以通过ldd命令来看它们链接的库有什么不同。
对于代码一:
1
2
3
4
ldd expTest
linux-vdso.so.1 => (0x00007ffec079d000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd327744000)
/lib64/ld-linux-x86-64.so.2 (0x00007fd327b0e000)

对于代码二:
1
2
3
4
5
ldd expTest
linux-vdso.so.1 => (0x00007ffefdfc9000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f9afcccb000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9afc901000)
/lib64/ld-linux-x86-64.so.2 (0x00007f9afcfd4000)

可以看到,第二段代码编译出来的可执行文件,多依赖了libm.so.6,也就是exp函数所在的库。

2.什么时候需要链接?
事实上,C编译器总是主动传送libc.a或libc.so给链接器,也就是说,对于使用包含在libc.a或libc.so库中的函数,是不需要在编译时手动链接的。而调用函数是否需要链接,可以使用命令“man 3 函数名“查看,如果需要链接库,最后都有说明。

3.为什么链接的时候放在前面就不行呢?
这个就涉及到链接器的工作原理了,在此只简单说明一下:链接过程中,需要进行符号解析,并且是按照顺序解析;如果库链接在前,就可能出现库中的符号不会被需要,链接器不会把它加到未解析的符号集合中,那么后面引用这个符号的目标文件就不能解析该引用,导致最后链接失败。因此链接库的一般准则是将它们放在命令行的结尾。

总结

通过前面的实例和分析,我们总结出以下几点:

调用包含于libc库中的函数不需要链接。

对于传参为常量的数学函数调用,生成可执行文件过程中可能将其优化,而无需调用该函数。

库链接一般放在命令行结尾。

通过man命令查看在调用某个函数时是否需要链接。

头文件遮挡

在编译过程中最诡异的问题莫过于头文件遮挡,如下代码中main.cpp包含头文件common.h,真正想用的头文件是图中最右边那个包含name

成员的文件(所在目录为./include),但在编译过程中中间的common.h(所在目录为./include1)抢先被发现,导致编译器报错:Test结构没有name成员,对程序员来讲,自己明明定义了name成员,居然说没有name这个成员,如果第一次碰到这种情况可能会怀疑人生。应对这种诡异的问题,我们可以用-E参数看下编译器预处理后的输出,如下图。

预处理文件格式如下:# linenum filename flag,表示之后的内容是从文件名为filaname的文件中第linenum行展开的,flag的取值可以是1,2,3,4,可以是用空格分开的多值,1表示接下来要展开一个新文件;2表示一个文件展开完毕;3表示接下来内容来自一个系统头文件;4表示接下来的内容应该看做是extern C形式引入的。

从展开后的输出我们可以清楚地看到Test结构确实没有定义name这个成员,并且Test这个结构是在./include1中的common.h中定义的,到此真相大白,编译器压根就没用我们定义的Test结构,而是被别的同名头文件截胡了。我们可以通过调整-I或者在头文件中带上部分路径更详细制定头文件位置来解决。

目标文件:

编译链接最终会生成各种目标文件,Linux下目标文件格式为ELF(Executable Linkable Format),详细定义见/usr/include/elf.h头文件,常见的目标文件有:可重定位目标文件,也即.o结尾的目标文件,当然静态库也归为此类;可执行文件,比如默认编译出的a.out文件;共享目标文件.so;核心转储文件,也就是core dump后产出的文件。Linux文件格式可以通过file命令查看。

一个典型的ELF文件格式如下图所示,文件有两种视角:编译视角,以section头部表为核心组织程序;运行视角,程序头部表以segment为核心组织程序。这么做主要是为了节约存储,很多细碎的section在运行时由于对齐要求会导致很大的内存浪费,运行时通常会将权限类似的section组织成segment一起加载。

通过命令objdump和readelf可以查看ELF文件的内容。

对可重定位目标文件常见的section有:

符号解析:

链接器会为对外部符号的引用修改为正确的被引用符号的地址,当无法为引用的外部符号找到对应的定义时,链接器会报undefined reference to XXXX的错误。另外一种情况是,找到了多个符号的定义,这种情况链接器有一套规则。在描述规则前需要了解强符号和弱符号的概念,简单讲函数和已初始化的全局变量是强符号,未初始化的全局变量是弱符号。

针对符号的多重定义链接器处理规则如下(作者在gcc 7.3.0上貌似规则2,3都按1处理):

  1. 不允许多个强符号定义,链接器会报告重复定义貌似的错误
  2. 如果一个强符号和多个弱符号同名,则选择强符号
  3. 如果符号在所有目标文件中都为弱符号,那么选择占用空间最大的一个

有了这些基础,我们先来看一下静态链接过程:

  1. 链接器从左到右按照命令行出现顺序扫描目标文件和静态库
  2. 链接器维护一个目标文件的集合E,一个未解析符号集合U,以及E中已定义的符号集合D,初始状态E、U、D都为空
  3. 对命令行上每个文件f,链接器会判断f是否是一个目标文件还是静态库,如果是目标文件,则f加入到E,f中未定义的符号加入到U中,已定义符号加入到D中,继续下一文件
  4. 如果是静态库,链接器尝试到静态库目标文件中匹配U中未定义的符号,如果m中匹配U中的一个符号,那么m就和上步中文件f一样处理,对每个成员文件都依次处理,直到U、D无变化,不包含在E中的成员文件简单丢弃
  5. 所有输入文件处理完后,如果U中还有符号,则出错,否则链接正常,输出可执行文件

静态库顺序

如下图所示,main.cpp依赖liba.a,liba.a又依赖libb.a,根据静态链接算法,如果用g++ main.cpp liba.a libb.a的顺序能正常链接,因为解析liba.a时未定义符号FunB会加入到上述算法的U中,然后在libb.a中找到定义,如果用g++ main.cpp libb.a liba.a的顺序编译,则无法找到FunB的定义,因为根据静态链接算法,在解析libb.a的时候U为空,所以不需要做任何解析,简单抛弃libb.a,但在解析liba.a的时候又发现FunB没有定义,导致U最终不为空,链接错误,因此在做静态链接时,需要特别注意库的顺序安排,引用别的库的静态库需要放在前面,碰到链接很多库的时候,可能需要做一些库的调整,从而使依赖关系更清晰。

动态链接:

之前大部分内容都是静态链接相关,但静态链接有很多不足:不利于更新,只要有一个库有变动,都需要重新编译;不利于共享,每个可执行程序都单独保留一份,对内存和磁盘是极大的浪费。

要生成动态链接库需要用到参数“-shared -fPIC”表示要生成位置无关PIC(Position Independent Code)的共享目标文件。对静态链接,在生成可执行目标文件时整个链接过程就完成了,但要想实现动态链接的效果,就需要把程序按照模块拆分成相对独立的部分,在程序运行时将他们链接成一个完整的程序,同时为了实现代码在不同程序间共享要保证代码是和位置无关的(因为共享目标文件在每个程序中被加载的虚拟地址都不一样,要保证它不管被加载在哪都能工作),而为了实现位置无关又依赖一个前提:数据段和代码段的距离总是保持不变。

由于不管在内存中如何加载一个目标模块,数据段和代码段间的距离是不变的,编译器在数据段前面引入了一个全局偏移表GOT(Global Offset Table),被引用的全局变量或者函数在GOT中都有一条记录,同时编译器为GOT中每个条目生成一个重定位记录,因为数据段是可以修改的,动态链接器在加载时会重定位GOT中的每个条目,这样就实现了PIC。

大体原理基本就这样,但具体实现时,对函数的处理和全局变量有所不同。由于大型程序函数成千上万,而程序很可能只会用到其中的一小部分,因此没必要加载的时候把所有的函数都做重定位,只有在用到的时候才对地址做修订,为此编译器引入了过程链接表PLT(Procedure Linkage Table)来实现延时绑定。PLT在代码段中,它指向了GOT中函数对应的地址,第一次调用时候,GOT存放的不是函数的实际地址,而是PLT跳转到GOT代码的后一条指令地址,这样第一次通过PLT跳转到GOT,然后通过GOT又调回到PLT的下一条指令,相当于什么也没做,紧接着PLT后面的代码会将动态链接需要的参数入栈,然后调用动态链接器修正GOT中的地址,从这以后,PLT中代码跳转到GOT的地址就是函数真正的地址,从而实现了所谓的延时绑定。

对共享目标文件而言,有几个需要关注的section:

有了以上基础后,我们看一下动态链接的过程:

  1. 装载过程中程序执行会跳转到动态链接器
  2. 动态链接器自举通过GOT、.dynamic信息完成自身的重定位工作
  3. 装载共享目标文件:将可执行文件和链接器本身符号合并入全局符号表,依次广度优先遍历共享目标文件,它们的符号表会不断合并到全局符号表中,如果多个共享对象有相同的符号,则优先载入的共享目标文件会屏蔽掉后面的符号
  4. 重定位和初始化

全局符号介入

动态链接过程中最关键的第3步可以看到,当多个共享目标文件中包含一个相同的符号,那么会导致先被加载的符号占住全局符号表,后续共享目标文件中相同符号被忽略。当我们代码中没有很好的处理命名的话,会导致非常奇怪的错误,幸运的话立刻core dump,不幸的话直到程序运行很久以后才莫名其妙的core dump,甚至永远不会core dump但是结果不正确。

如下图所示,main.cpp中会用到两个动态库libadd.so,libadd1.so的符号,我们把重点

放在Add函数的处理上,当我们以g++ main.cpp libadd.so libadd1.so编译时,程序输出“Add in add lib”说明Add是用的libadd.so中的符号(add.cpp),当我们以g++ main.cpp libadd1.so libadd.so编译时,程序输出“Add in add1 lib”说明Add是用的libadd1.so中的符号,这时候问题就大了,调用方main.cpp中认为Add只有两个参数,而add1.cpp中认为Add有三个参数,程序中如果有这样的代码,可以预见很可能造成巨大的混乱。具体符号解析我们可以通过LD_DEBUG=all ./a.out来观察Add的解析过程,如下图所示:左边是对应libadd.so在编译时放在前面的情况,Add绑定在libadd.so中,右边对应libadd1.so放前面的情况,Add绑定在libadd1.so中。

运行时加载动态库:

有了动态链接和共享目标文件的加持,Linux提供了一种更加灵活的模块加载方式:通过提供dlopen,dlsym,dlclose,dlerror几个API,可以实现在运行的时候动态加载模块,从而实现插件的功能。

如下代码演示了动态加载Add函数的过程,add.cpp按照正常编译“g++ -fPIC –shared –o libadd.so add.cpp”成libadd.so,main.cpp通过“g++ main.cpp -ldl”编译为a.out。main.cpp中首先通过dlopen接口取得一个句柄void *handle,然后通过dlsym从句柄中查找符号Add,找到后将其转化为Add函数,然后就可以按照正常的函数使用,最后dlclose关闭句柄,期间有任何错误可以通过dlerror来获取。

静态全局变量与动态库导致double free

在全面了解了动态链接相关知识后,我们来看一个静态全局变量和动态库纠结在一起引发的问题,代码如下,foo.cpp中有一个静态全局对象foo_,foo.cpp会编译成一个libfoo.a,bar.cpp依赖libfoo.a库,它本身会编译成libbar.so,main.cpp既依赖于libfoo.a又依赖libbar.so。

编译的makefile如下:

运行a.out会导致double free的错误。这是由于在一个位置上调用了两次析构函数造成的。之所以会这样是因为链接的时候先链接的静态库,将foo_的符号解析为静态库中的全局变量,当动态链接libbar.so时,由于全局已经有符号foo_,因此根据全局符号介入,动态库中对foo_的引用会指向静态库中版本,导致最后在同一个对象上析构了两次。

解决办法如下:

  1. 不使用全局对象
  2. 编译时候调换库的顺序,动态库放在前面,这样全局只会有一个foo_对象
  3. 全部使用动态库
  4. 通过编译器参数来控制符号的可见性。

库打桩机制

前言

假如由于调试需要,你希望原先代码中的malloc函数更换为你自己写好的malloc函数,该怎么办呢?如何对程序进行”偷梁换柱“?

打桩机制

LInux链接器有强大的库打桩机制,它允许你对共享库的代码进行截取,从而执行自己的代码。而为了调试,你通常可以在自己的代码中加入一些调试信息,例如,调用次数,打印信息,调用时间等等。本文将介绍三种打桩机制,分别在编译的不同阶段。

编译时打桩

编译时打桩在源代码级别进行替换。我们很容易通过#define指令来完成这件事情。首先我们定义自己的头文件mymalloc.h:

1
2
#define malloc(size) mymalloc(size)
void *mymalloc(size_t size)

由于在这里使用了#define指令,我们后面需要malloc的地方都会被mymalloc替代。
而mymalloc.c代码如下:

1
2
3
4
5
6
7
8
9
10
11
#ifdef MYMOCK //只有MYMOC
#include<stdio.h>
#include<stdlib.h>
/*打桩函数*/
void *mymalloc(size_t size)
{
void *ptr = malloc(size);
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

注意第一行,我们需要在gcc中传入编译选项MYMOCK(自定义,代码与传入的一致即可)。

我们在main.c中调用它:

1
2
3
4
5
6
7
8
#include<stdio.h>
#include"malloc.h"
int main()
{
char *p = malloc(64);
free(p);
return 0;
}

编译运行:

1
2
3
4
$ gcc -DMYMOCK -c mymalloc.c 
$ gcc -I . -o main main.c mymalloc.o
$ ./main
ptr is 0xdbd010

编译时还使用-I参数,告诉编译器从当前目录下寻找头文件malloc.h,因此,main函数中的malloc调用将会被替换成mymalloc。而在mymalloc.c中的则使用原始的malloc函数,最终达到“偷梁换柱”的效果。

实际上你也可以通过仅仅预编译来很清楚的看到其中的变化:

1
$ gcc -I . -E -o main.i main.c

查看main.i,你会发现,使用malloc的地方,都被替换成了mymalloc。

小结一下前面的步骤:

  • 打桩函数内部不要打桩,即mymalloc.c中要使用原始的malloc函数,不然会造成循环调用
  • 通过#define指令,将外部调用malloc的地方都替换为mymalloc
  • 分开编译mymalloc.c和外部调用代码,最终链接

这种方式打桩需要能够访问源代码才能完成。

链接时打桩

顾名思义,链接时打桩是在链接时替换需要的函数。Linux链接器支持用—wrap,f的方式来进行打桩,链接时符号f解析成wrap_f,还会把real_f解析成f。什么意思呢?我们修改前面mymalloc.c的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#include<stdio.h>
#include<stdlib.h>
void *__real_malloc(size_t size);//注意声明
/*打桩函数*/
void *__wrap_malloc(size_t size)
{
void *ptr = __real_malloc(size);//最后会被解析成malloc
printf("ptr is %p\n",ptr);
return ptr;
}
#endif

注意将main.c中包含的malloc.h那一行去掉。

编译运行:

1
2
3
4
5
$ gcc -DMYMOCK mymalloc.c
$ gcc -c main.c
$ gcc -Wl,--wrap,malloc -o main main.o mymalloc.o
$ ./main
ptr is 0x95f010

我们特别关注mymalloc.c中的代码,利用链接器的打桩机制,最后在main函数中调用malloc,将会去调用wrap_malloc,而real_malloc将会被解析成真正的malloc,从而达到“偷梁换柱”的效果。

可以看到的是,这种打桩方式至少需要能够访问可重定位文件。

运行时打桩

前面两种打桩方式,一种需要访问源代码,另外一种至少要访问可重定位文件。可运行时打桩没有这么多要求。运行时打桩可以通过设置LD_PRELOAD环境变量,达到在你加载一个动态库或者解析一个符号时,先从LD_PRELOAD指定的目录下的库去寻找需要的符号,然后再去其他库中寻找。
同样我们修改mymalloc.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#define _GNU_SOURCE //这行特别注意加上
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>
extern FILE *stdout;
/*打桩的malloc函数*/
void *malloc(size_t size)
{
static int calltimes;
calltimes++;
/*函数指针*/
void *(*realMalloc)(size_t size) = NULL;
char *error;

realMalloc = dlsym(RTLD_NEXT,"malloc");//RTLD_NEXT
if(NULL == realMalloc)
{
error = dlerror();
fputs(error,stdout);
return NULL;
}

void *ptr = realMalloc(size);
if(1 == calltimes)
{
printf("ptr is %p\n",ptr);
}
calltimes = 0;
return ptr;
}
#endif

在mymalloc.c的代码中,由于我们自己的打桩函数也叫malloc,因此我们通过运行时链接调用malloc函数,以便获取malloc的地址,而不是直接调用。并且是以RTLD_NEXT方式。

将mymalloc.c制作成动态库

1
2
3
4
5
$ gcc -DMYMOCK -shared -fPIC -o libmymalloc.so mymalloc.c -ldl
$ gcc -o main main.c //重新编译main
$ LD_PRELOAD="./libmymalloc.so"
$ ./main
Segmentation fault (core dumped)

然而非常不幸的是,最后core dumped了,我们用gdb(参考《Linux常用命令-开发调试篇》)查看调用栈:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(gdb)bt
#0 0x00007fe0ca83518e in _IO_vfprintf_internal (
s=0x7fe0cabad620 <_IO_2_1_stdout_>, format=0x7fe0cabb26dd "ptr is %p\n",
ap=ap@entry=0x7ffcbd652058) at vfprintf.c:1267
#1 0x00007fe0ca83d899 in __printf (format=<optimised out>) at printf.c:33
#2 0x00007fe0cabb26cc in malloc () from ./mymalloc.so
#3 0x00007fe0ca8551d5 in __GI__IO_file_doallocate (
fp=0x7fe0cabad620 <_IO_2_1_stdout_>) at filedoalloc.c:127
#4 0x00007fe0ca863594 in __GI__IO_doallocbuf (
fp=fp@entry=0x7fe0cabad620 <_IO_2_1_stdout_>) at genops.c:398
#5 0x00007fe0ca8628f8 in _IO_new_file_overflow (
f=0x7fe0cabad620 <_IO_2_1_stdout_>, ch=-1) at fileops.c:820
#6 0x00007fe0ca86128d in _IO_new_file_xsputn (
f=0x7fe0cabad620 <_IO_2_1_stdout_>, data=0x7fe0cabb26dd, n=7)
at fileops.c:1331
#7 0x00007fe0ca835241 in _IO_vfprintf_internal (

我们从调用栈基本可以推断,其中有反复调用,那就是说在mymalloc.c中的malloc函数中,有的语句也调用了malloc,导致了最终的反复调用。解决这种问题有两个方法:

  • 避免反复调用
  • 使用不调用打桩函数的函数,即不调用其中的printf

我们采用下面这种方式来避免反复调用,开始调用时,置调用次数为1,最后置0,如果发现调用次数不为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
#ifdef MYMOCK //只有MYMOCK编译选项是,这段代码才会编译进去
#define _GNU_SOURCE //这行特别注意加上
#include<stdio.h>
#include<stdlib.h>
#include<dlfcn.h>
extern FILE *stdout;
/*打桩的malloc函数*/
void *malloc(size_t size)
{
/*调用次数+1*/
static int calltimes;
calltimes++;
/*函数指针*/
void *(*realMalloc)(size_t size) = NULL;
char *error;

realMalloc = dlsym(RTLD_NEXT,"malloc");//RTLD_NEXT
if(NULL == realMalloc)
{
error = dlerror();
fputs(error,stdout);
return NULL;
}

void *ptr = realMalloc(size);
/*如果是第一次调用,则调用printf,否则不调用*/
if(1 == calltimes)
{
printf("ptr is %p\n",ptr);
}
calltimes = 0;
return ptr;
}
#endif

当然这样的写法在多线程中也是有问题的,如何改进?

至此,就达到了我们需要的结果:

1
2
./main
ptr is 0x245c010

实际上,你会发现,在设置了这个环境变量的终端下,这个打桩的动作对所有程序都生效:
1
2
3
4
5
6
7
$ ls
ptr is 0x1f1a040
ptr is 0x1f1a680
ptr is 0x1f1a700
ptr is 0x1f1a040
ptr is 0x1f1a060
ptr is 0x1f1a040

那么怎么取消呢:
1
$ unset LD_PRELOAD

在这里也可以看到,这个机制虽然强大,同样也非常危险,因为不怀好意者可以通过这种方式恶意攻击你的程序。比如说,有个程序中checkPass的接口用来校验密码,如果这个时候使用另外一个动态库,实现自己的checkPass函数,并且设置LD_PRELOAD环境变量,就可以达到跳过密码检查的目的。

总结

怎么样,是不是觉得很神奇?尤其是最后一种方式,可以达到对任何程序进行”偷梁换柱“,对于问题的定位和程序的调试非常有帮助。但是,需要特别注意的是,采用最后一种方式打桩时,最好避免打桩函数内部还调用了打桩函数,这样会导致难以预料的后果,另外由于这种打桩机制对所有程序都有效,因此也非常危险,需要特别注意。

编译器常用的一些优化方法

常量传播

常量传播,就是说在编译期时,能够直接计算出结果(这个结果往往是常量)的变量,将被编译器由直接计算出的结果常量来替换这个变量。

例:

1
2
3
4
5
int main(int argc,char **argv){
int x = 1;
std::cout<<x<<std::endl;
return 0;
}

上例编译器会直接用常量1替换变量x,优化成:

1
2
3
4
int main(int argc,char **argv){
std::cout<<1<<std::endl;
return 0;
}

常量折叠

常量折叠,就是说在编译期间,如果有可能,多个变量的计算可以最终替换为一个变量的计算,通常是多个变量的多级冗余计算被替换为一个变量的一级计算

例:

1
2
3
4
5
6
7
int main(int argc,char **argv){
int a = 1;
int b = 2;
int x = a + b;
std::cout<<x<<std::endl;
return 0;
}

常量折叠优化后:

1
2
3
4
5
int main(int argc,char **argv){
int x = 1 + 2;
std::cout<<x<<std::endl;
return 0;
}

当然,可以再进行进一步的常量替换优化:

1
2
3
4
int main(int argc,char **argv){
std::cout<<3<<std::endl;
return 0;
}

通常,编译优化是一件综合且连贯一致的复杂事情,下文就不再赘述了。

复写传播

复写传播,就是编译器用一个变量替换两个或多个相同的变量。

例:

1
2
3
4
5
6
int main(int argc,char **argv){
int y = 1;
int x = y;
std::cout<<x<<std::endl;
return 0;
}

优化后:

1
2
3
4
5
int main(int argc,char **argv){
int x = 1;
std::cout<<x<<std::endl;
return 0;
}

上例有两个变量y和x,但是其实是两个相同的变量,并且其它地方并未区分它们两个,所以它们是重复的,可称为“复写”,编译器可以将其优化,将x“传播”给y,只剩下一个变量x,当然,反过来优化掉x只剩下一个y也是可以的。

公共子表式消除

公共子表达式消除是说,如果一个表达式E已经计算过了,并且从先前的计算到现在的E中的变量都没有发生变化,那么E的此次出现就成为了公共子表达式,因此,编译器可判断其不需要再次进行计算浪费性能。

例:

1
2
3
4
5
6
7
int main(int argc,char **argv){
int a = 1;
int b = 2;
int x = (a+b) * 2 + (b+a) * 6;
std::cout<<x<<std::endl;
return 0;
}

优化后:

1
2
3
4
5
6
7
8
int main(int argc,char **argv){
int a = 1;
int b = 2;
int E = a + b;
int x = E * 2 + E * 6;
std::cout<<x<<std::endl;
return 0;
}

当然,也有可能会直接变成:

1
2
3
4
5
6
7
8
int main(int argc,char **argv){
int a = 1;
int b = 2;
int E = a + b;
int x = E * 8;
std::cout<<x<<std::endl;
return 0;
}

无用代码消除

无用代码消除指的是永远不能被执行到的代码或者没有任何意义的代码会被清除掉,比如return之后的语句,变量自己给自己赋值等等。

例:

1
2
3
4
5
6
int main(int argc,char **argv){
int x = 1;
int x = x;
std::cout<<x<<std::endl;
return 0;
}

上例中,x变量自我赋值显然是无用代码,将会被优化掉:

1
2
3
4
5
int main(int argc,char **argv){
int x = 1;
std::cout<<x<<std::endl;
return 0;
}

数组范围检查消除

如果开发语言是Java这种动态类型安全型的,那在访问数组时比如array[ ]时,Java不会像C/C++那样只是纯粹的裸指针访问,而是会在运行时访问数组元素前进行一次是否越界检查,这将会带来许多开销,如果即时编译器能根据数据流分析出变量的取值范围在[0,array.length]之间,那么在循环期间就可以把数组的上下边界检查消除,以减少不必要的性能损耗。

方法内联

这种优化方法是将比较简短的函数或者方法代码直接粘贴到其调用者中,以减少函数调用时的开销,比较重要且常用,很容易理解,就比如C++的inline关键字一样,只不过inline是开发者的手动方法内联,而编译器在分析代码和数据流之后,也有可能做出自动inline的优化。

逃逸分析

一个对象如果被其声明的方法之外的一个或多个函数所引用,那就被称为逃逸,可以通俗理解为,该对象逃逸了其原本的命名空间或者作用域,使得声明(或者定义)该对象的方法结束时,该对象不能被销毁。

通常,一个函数里的局部变量其内存空间是在栈上分配的,而对象则是在堆上分配的内存空间,在函数调用结束时,局部变量会随着栈空间销毁而自动销毁,但堆上的空间要么是依赖类似JVM的垃圾内存自动回收机制(GC),要么就得像C/C++那样的依赖开发者本身的记忆力,因此,堆上的内存分配与销毁一般开销会比栈上的大得多。

逃逸分析的基本原理就是分析对象动态作用域。如果确定一个方法不会逃逸出方法之外,那让整个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧而销毁。在一般应用中,不会逃逸的局部对象所占用的比例很大,如果能在编译器优化时,为其在栈上分配内存空间,那大量的对象就会随着方法结束而自动销毁了,不用依赖前面讲的GC或者记忆力,系统的压力将会小很多。

一个演示简单编译器循环优化的例子

演示用的代码例子

先来看用于演示的C代码例子:

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

uint32_t gLastI;

uint32_t foo(uint32_t lo, uint32_t hi) {
uint32_t sum = 0; // (1)
for (uint32_t i = lo; i < hi; i++) { // (2)
uint32_t y = 2 * i; // (3)
if ((hi & 1) == 0) { // (4)
sum += i; // (5)
gLastI = i; // (6)
} else {
sum += y; // (7)
}
}
return sum; // (8)
}

挺简单的函数。有啥好优化的呢?——对于不熟悉编译原理的同学来说,最可能让他们意外的可能就是优化后代码的顺序与原程序的巨大差异。

ICC 17在Linux/x86-64上在-O3优化级别会把这个例子优化为等价于下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
uint32_t foo(uint32_t lo, uint32_t hi) {
uint32_t sum = 0;
if (lo < hi) {
uint32_t n = hi - lo;
if ((hi & 1) != 0) {
for (uint32_t i = 0; i < n; i++) {
sum += lo * 2; // folded into lea
sum += i * 2; // folded into lea
}
} else {
uint32_t last_i;
for (uint32_t i = 0; i < n; i++) {
sum += lo;
last_i = lo;
lo++;
}
gLastI = last_i;
}
}
return sum;
}

实际生成的汇编长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
foo:
mov edx, esi #5.35
xor eax, eax #6.11
cmp edi, edx #7.29
jae ..B1.9 # Prob 50% #7.29
mov esi, edx #5.35
mov ecx, eax #7.3
sub esi, edi #5.35
test dl, 1 #9.15
je ..B1.7 # Prob 50% #9.21
..B1.4: # Preds ..B1.2 ..B1.4
lea eax, DWORD PTR [rax+rdi*2] #7.3
lea eax, DWORD PTR [rax+rcx*2] #8.17
inc ecx #7.3
cmp ecx, esi #7.3
jb ..B1.4 # Prob 82% #7.3
jmp ..B1.9 # Prob 100% #7.3
..B1.7: # Preds ..B1.2 ..B1.7
inc ecx #7.3
add eax, edi #10.7
mov edx, edi #11.7
inc edi #7.3
cmp ecx, esi #7.3
jb ..B1.7 # Prob 82% #7.3
mov DWORD PTR gLastI[rip], edx #11.7
..B1.9: # Preds ..B1.4 ..B1.8 ..B1.1
ret #16.10

它为什么可以这样做?下面就让我简单科普一下。

编译器在优化代码的时候,只要保证最终的结果满足程序中各种依赖关系就可以了,而不必总是维持跟输入的源码相同的顺序(“program order”)。不过这个传送门中涉及的例子非常简单,只有纯直线代码,没有跳转 / 条件跳转,也没有对内存的读写,所以只要用“数据依赖”(data dependence)就足以讲解了。

而本文所用的例子则稍微复杂一点,可以涉及稍微多一些的优化的讲解。

首先在(2)开始有一个典型的for循环,在(4)有一个条件分支;这两个都是控制流操作,使这个例子涉及“控制依赖”(control dependence)。然后在(6)有一个对全局变量gLastI的写操作,这是一个内存写操作,使这个例子涉及“内存依赖”(memory dependence)——或者说正好演示了冗余写操作的情况。

副作用?

对编译器中的优化器来说,所谓“副作用”就是在当前编译单元中无法做足够分析的运算结果。这跟上层的源语言中所说的“副作用”并不总是一回事。所以当看到对程序中的副作用的讨论时,要注意清楚讨论的上下文是什么,免得误解了对方的意思。

例如说,对编译器中端的优化器来说,C语言的一个标量类型的局部变量,如果它在整个函数中都没有被取过地址,那么所有对它自身的读写运算都可以认为是“无副作用”的。这是因为这些变量是活动记录(activation record,或者说栈帧)的一部分,而一个函数被调用一次的活动记录里的内容都是这次调用独享访问的,除非程序主动通过取局部变量地址的方式来暴露出机会让别的代码能操作这些局部变量。这样编译器的优化器就可以对其做足够分析,将它们涉及的副作用都分析出来,并转换为没有副作用的形式。

而对原本的C语言来说,一般会把对局部变量的赋值(写)运算叫做“有副作用”的。

这个差异主要是来自编译器各部分的分工,以及优化器对程序的分析能力。

回顾一下一个典型的带优化的编译器的工作流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    源代码
-> [ 词法分析 ]
-> 单词流
-> [ 语法分析 ]
-> 语法树 / 抽象语法树 编译器前端
-> [ 语义分析 ]
-> 带标注的语法树
-> [ 中间代码生成 ]
-> 中间代码 -------------------------
-> [ 平台无关优化 ]
-> 优化的中间代码 编译器中端
-> [ 平台相关lowering ]
-> 平台相关中间代码 --------------------------
-> [ 平台相关优化 ]
-> 优化的平台相关中间代码 编译器后端
-> [ 代码生成 ]
-> 目标代码

在这个流程中,编译器前端更关心源语言的语义,后端更关心目标平台的特性,而位于中间的中端则主要关心相对不那么语言相关、也不那么平台相关的优化。

当我们讨论源语言层面上的“副作用”,编译器前端的“语义分析”部分是必须要能正确理解这些副作用的含义(并在副作用不合理时给出警告)。然后在“中间代码生成”的部分,这些“副作用”会在中间表示中用更显式的方式表现出来,于是到编译器中端拿到中间表示的时候,就不用关心这些源语言层面的副作用了。

例如说,一个经典的不好的C代码:

1
2
3
4
5
int foo() {
int i = 0;
int j = i + i++;
return j;
}

i + i++的地方有一个纯粹的对局部变量i的读操作,以及一个带有副作用(自增)的对局部变量i的读写操作,而这两个操作之间没有sequence point所以它们俩的求值顺序是未定义的。

在Clang中,语义分析的部分会对这个情况给出警告:

1
2
3
foo.c:3:16: warning: unsequenced modification and access to 'i' [-Wunsequenced]
int j = i + i++;
~ ^

然后Clang在生成中间代码(LLVM IR)时,会根据自己的理解选择一种求值顺序——后做i++,生成出每个操作都简单明确的中间代码,然后编译器中端(LLVM)在拿到LLVM IR之后就能根据代码的顺序准确地理解前端所做的选择:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
; Function Attrs: nounwind
define i32 @foo() #0 {
%i = alloca i32, align 4 ; int i
%j = alloca i32, align 4 ; int j
store i32 0, i32* %i, align 4 ; i = 0
%3 = load i32, i32* %i, align 4 ; tmp3 = i
%4 = load i32, i32* %i, align 4 ; tmp4 = i
%5 = add nsw i32 %4, 1 ; tmp5 = tmp4 + 1
store i32 %5, i32* %i, align 4 ; i = tmp5
%6 = add nsw i32 %3, %4 ; tmp6 = tmp3 + tmp4
store i32 %6, i32* %j, align 4 ; j = tmp6
%7 = load i32, i32* %j, align 4 ; tmp7 = j
ret i32 %7 ; return tmp7
}

也就是Clang选择拆解副作用的方式,对应这样的C代码:

1
2
3
4
5
6
int foo() {
int i = 0;
int j = i + i;
i = i + 1; // side-effect of i++
return j;
}

可以看到这里生成的LLVM IR还是“有副作用”的——那3条store指令就是“副作用”。但是LLVM可以对所有没有被取地址的标量类型的局部变量都可以做完全的分析——可以找到所有对这些局部变量的读写操作并分析其中的副作用的效果——然后将IR转换到对这些局部变量来说没有副作用的形式。

例如说对上述LLVM IR跑一次mem2reg pass(或者包含mem2reg的sroa pass),会得到:

1
2
3
4
5
6
; Function Attrs: nounwind
define i32 @foo() #0 {
%1 = add nsw i32 0, 1 ; tmp1 = 0 + 1
%2 = add nsw i32 0, 0 ; tmp2 = 0 + 0
ret i32 %2 ; return tmp2
}

这里就没有任何副作用了,只有对局部值的简单运算。进一步做常量折叠和无用代码消除之后,就只剩下:

1
2
3
4
; Function Attrs: nounwind
define i32 @foo() #0 {
ret i32 0 ; return 0
}

了。

同一个例子用GCC 4.9.2来看编译器前端的理解(生成的GIMPLE):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
foo ()
{
int i.0;
int D.1748;
int i;
int j;

i = 0;
i.0 = i;
i = i.0 + 1; // side-effect of i++
j = i.0 + i;
D.1748 = j;
return D.1748;
}

这GCC选择的求值顺序就跟Clang正好相反,先做了i++。

然后中端在分析完局部变量涉及的副作用之后,所生成的无副作用的中间代码(Tree SSA形式的GIMPLE):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
foo ()
{
int j;
int i;
int D.1748;
int i.0;
int i.0_2;
int _5;

<bb 2>:
i_1 = 0;
i.0_2 = i_1;
i_3 = i.0_2 + 1;
j_4 = i.0_2 + i_3;
_5 = j_4;

<L0>:
return _5;
}

每个局部变量最多被赋值一次,从赋值到使用直接不用考虑别的副作用影响该变量的值,所以说“没有副作用”。

副作用与控制依赖

先说结论:没有副作用的运算可以无视控制依赖,只要满足数据依赖即可执行。

什么是控制依赖?控制依赖是说,某个运算Y的执行与否,依赖于某个带有控制流语义的运算X的结果。

例如说,

1
2
3
4
5
6
7
8
int foo(int a, int b, int cond) {
int c = b + 1;
int x = 0;
if (cond) {
x = a + c;
}
return x;
}

这个例子里,x = a + c就控制依赖于”if (cond)”的运算结果,只有当cond为真值的时候,x = a + c才执行。

但是”a + c”是一个没有副作用的运算,它其实放在foo()中的什么位置执行都可以——只要它所依赖的数据输入a和c都已经求好值了即可——而不必依赖于”if (cond)”的结果。这跟本文开头提到的传送门里“数据依赖”的例子一样。

所以把上述代码的a + c提取到if外面,转换成下面这样也是一样的:

1
2
3
4
5
6
7
8
9
int foo(int a, int b, int cond) {
int c = b + 1;
int x = 0;
int tmp = a + c;
if (cond) {
x = tmp;
}
return x;
}

又或者再向前挪一点:

1
2
3
4
5
6
7
8
9
int foo(int a, int b, int cond) {
int c = b + 1;
int tmp = a + c;
int x = 0;
if (cond) {
x = tmp;
}
return x;
}

也可以。

那么”x = “的部分呢?这个赋值会根据”if (cond)”的结果而影响局部变量x的值,所以要先看作有控制依赖的有副作用的操作,分析清楚之后再转换到无副作用的形式。

但是所谓“无副作用”的形式要如何表达一个变量可能经由不同的分支执行后得到不同的值呢?一种办法是SSA形式的“phi”伪函数。让我们把这个例子转成SSA形式来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int foo(int a, int b, int cond) {
int c = b + 1;
int x0 = 0;
if (cond) goto condtrue; else goto condfalse;

condtrue:
int x1 = a + c;
goto aftercond;

condfalse:
goto aftercond;

aftercond:
int x2 = phi(condfalse x0, condtrue x1);
return x2;
}

这个“phi”伪函数会显式指明“如果控制来自某个分支,则选用某个值”。这就把副作用与控制依赖显式结合在一起表达出来了。

回到本文开头的例子,位于(3)的”2 * i”是一个无副作用的运算,所以它的运算位置可以被移动。例如说它可以被向下移动(sink),到真正使用它的地方,变成:

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

uint32_t gLastI;

uint32_t foo(uint32_t lo, uint32_t hi) {
uint32_t sum = 0;
for (uint32_t i = lo; i < hi; i++) {
if ((hi & 1) == 0) {
sum += i;
gLastI = i;
} else {
uint32_t y = 2 * i;
sum += y;
}
}
return sum;
}

循环不变量与循环不变量外提(LICM)

就跟上一节提到的思路一样,如果通过分析可以发现在循环中有运算的值不受循环的影响,那么就可以把它提升到循环的外面。这种优化叫做循环不变量外提(LICM,loop-invariant code motion)。

以本文开头的例子来说,通过分析可以发现从(2)开始的for循环,在循环体中没有对变量hi赋过值,所以hi的值在循环内不会改变。递推出去,hi & 1是一个无副作用的运算,它的值在循环中也不会改变。同理(hi & 1) == 0的值在循环中也不会改变。

所以这个例子就可以把(4)的条件运算提取到循环外面,变成(在上一节的基础上):

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

uint32_t gLastI;

uint32_t foo(uint32_t lo, uint32_t hi) {
uint32_t sum = 0;
_Bool tmpcond = (hi & 1) == 0;
for (uint32_t i = lo; i < hi; i++) {
if (tmpcond) {
sum += i;
gLastI = i;
} else {
uint32_t y = 2 * i;
sum += y;
}
}
return sum;
}

循环判断外提(loop unswitching)

作为LICM的一种扩展,如果我们发现循环里有条件是对循环不变量来做判断的,那么就可以选择把这个判断提升到循环的外面 ,并且把原循环拆分为两个特化的版本,分别对应条件为真以及为假的情况。

这样每个版本的循环都会比原本的更简单,而假定循环是耗时的操作,是我们要有针对性优化的目标,把循环拆分成特化的版本后就可以减小循环的开销。

还是回到本文开头的例子,在上一节版本的基础上,可以进一步变换为:

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

uint32_t gLastI;

uint32_t foo(uint32_t lo, uint32_t hi) {
uint32_t sum = 0;
if ((hi & 1) == 0) {
for (uint32_t i = lo; i < hi; i++) {
sum += i;
gLastI = i;
}
} else {
for (uint32_t i = lo; i < hi; i++) {
uint32_t y = 2 * i;
sum += y;
}
}
return sum;
}

跟开头演示的优化后的结果是不是越来越相似了?

内存写的下沉(store sinking)

嗯这个读起来有点怪。简单来说就是如果有连续多次对同一个位置的内存写操作,那么只有最后一个才是有意义的,前面那些只要没被用到都是无意义的,可以消除。所以这种优化也叫做“冗余内存写消除”(redundant store elimination)。

应用到循环中,如果我们在循环体中不断对某个位于内存中的变量做赋值,但却没有在循环中使用过这个赋值的结果,那么这个赋值就没有意义,可以被消除。

例如说:

1
2
3
for (int i = 0; i < 3; i++) {
globalVariable = i;
}

全局变量globalVariable的实体必须要被分配在内存中,所以对它的赋值是一个内存写操作(memory store)。如果我们分析一下循环的执行过程 ,就会发现这个例子实际上会执行3次对globalVariable的赋值:

1
2
3
globalVariable = 0
globalVariable = 1
globalVariable = 2

但在这个循环中其实并没有用到这些赋值的结果,而在循环结束时需要给外界留下的副作用只需要是globalVariable = 2。所以我们可以把这个内存写操作“下沉”(sink)到循环的后面去,变成:

1
2
3
4
for (int i = 0; i < 3; i++) {
/* empty loop body */
}
globalVariable = 2; // constant-folded condition: if (0 < 3)

或者稍微没那么优化的版本:

1
2
3
4
5
int i;
for (i = 0; i < 3; i++) {
/* empty loop body */
}
globalVariable = i - 1; // constant-folded condition: if (0 < 3)

但要注意的是:一个for循环其实是有可能一次也不执行的,所以在循环体里的赋值如果被下沉到循环后面的话,要保证该循环至少执行过一次才正确。

回到本文开头的例子,在上一节版本的基础上,把(6)对全局变量gLastI的赋值下沉到循环后面,可以变换成:

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

uint32_t gLastI;

uint32_t foo(uint32_t lo, uint32_t hi) {
uint32_t sum = 0;
if (lo < hi) {
if ((hi & 1) == 0) {
for (uint32_t i = lo; i < hi; i++) {
sum += i;
}
gLastI = hi - 1;
} else {
for (uint32_t i = lo; i < hi; i++) {
uint32_t y = 2 * i;
sum += y;
}
}
}
return sum;
}

具体到ICC所选用的优化形式,它没能彻底优化掉循环中的运算,不过至少在循环中用一个局部变量来替代了全局变量作为赋值的目标,然后在循环之后才做最终的内存写操作:

1
2
3
4
5
6
uint32_t last_i;
for (uint32_t i = lo; i < hi; i++) {
sum += i;
last_i = i;
}
gLastI = last_i;

这仍然算是store sinking——局部变量可以被分配到寄存器里,对局部变量的赋值就不会内存写了,所以还是比对全局变量的赋值更快。

经过store sinking优化后,代码的形式已经跟ICC优化的结果非常相似了。

循环归纳变量优化(loop induction variable optimizations)

本文开头所给出的ICC优化后的版本,剩下的一些优化是跟循环归纳变量相关的。所谓“循环归纳变量”,就是值与循环轮次成线性关系的变量。

例如说最典型的for循环:

1
2
3
4
for (int i = 0; i < max; i++) {
int x = arr[i + 2];
/* ... */
}

局部变量i就是一个循环归纳变量,它的值跟循环轮次正好相等。我们可以分析出这个变量i的性质为:

1
2
3
4
init = 0
limit = max
cmp = <
step = 1

而表达式i + 2的值也是跟循环轮次成线性关系的,关系为1 * i + 2。于是这个表达式的性质就可以从变量i推算出来。

GCC与Clang对循环归纳变量的分析与优化叫做“Scalar evolutions”(简称SCEV)。

事实上,既然这是一个等差数列求和的例子,比例子中ICC编译结果更简短的形式应该是这样的:

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

uint32_t gLastI;

uint32_t foo(uint32_t lo, uint32_t hi) {
if (lo < hi) {
uint32_t n = hi - lo;
if ((hi & 1) == 0) {
gLastI = hi - 1;
return (lo & 1) == 0 ? (n >> 1) * (lo + hi - 1)
: ((lo + hi - 1) >> 1) * n;
} else {
return (lo + hi - 1) * n;
}
} else {
return 0;
}
}

直接连循环都不要了。

把非常量的循环加法变换为非循环的乘法形式是实际编译器实现中比较少见的做法。更常见的反过来的优化:“强度削减”(strength reduction),把本来是乘法的运算变成加法,之类。

英特尔多核平台编程优化大赛报告

代码优化前所需时间:4.765秒

代码优化后所需时间:0.25秒(保留小数点后7位精度)

前言

本次优化使用的CPU是Intel Xeon 5130,主频为2.0GHz,同Intel酷睿2一样是基于Core Microarchitecture 的双核处理器。本次优化在Intel的工具帮助下主要针对Core Microarchitecture 系列处理器进行优化。但是由于未知原因,Intel VTune Analyzers并不能在该系统下正常工作。所以,所有使用Intel VTune Analyzers的测试均使用另外一个奔腾D 820的系统测试。

第一章主要介绍了程序的串行优化。其中有关于Intel编译器使用,以及Intel Math Kernel Library使用,Intel VTune Analyzers使用的介绍。在借助Intel工具的帮助下,结合Intel Core Microarchitectured的特性。设计出了针对L1 Cache进行优化的,高效率的串行代码。程序的执行时间从优化前的4.765秒达到了优化后的0.765秒。

第二章主要介绍了程序的并行化。首先讨论了2种并行算法的优缺点。然后选择了适合本程序的并行算法进行优化。并且在最后分析了并行化时的性能瓶颈。通过并行化,程序达到了0.437秒。

第三章主要介绍了程序的汇编优化。首先介绍了计算的数学理论。然后介绍了汇编代码的编写。最后进行了性能分析。通过该步优化程序在保留小数点后7位精度的前提下达到了0.312秒的好成绩。并且在Intel酷睿2 E6600 上测试达到了0.25秒。

串行优化

代码的基本修改和优化

首先根据主办方的要求把代码的输出精度改为小数点后7位。

1
if (i%10 == 0) printf("%5d: Potential: %20.7f\n", i, pot);

在进行任何优化前代码的执行时间是4.765秒。

接着把项目转换成使用Intel C++ Compiler,代码的执行时间是4.531秒。

然后执行最基本的优化,把代码中的pow函数优化成乘法。代码如下:

1
2
3
distx = (r[0][j] - r[0][i])*(r[0][j] - r[0][i]);
disty = (r[1][j] - r[1][i])*(r[1][j] - r[1][i]);
distz = (r[2][j] - r[2][i])*(r[2][j] - r[2][i]);

执行时间依然为4.531秒。说明Intel编译器已经将pow函数优化掉了。

基于Intel编译器的优化

这里介绍本程序中基于Intel编译器优化技术。其中有些优化参数是可以确定的,有些优化参数需要在程序的不同阶段反复调试以确定最优方案,而有些优化技术是在后面的优化中使用的。

编译器优化级别

Intel的编译器共有如下一些主要的优化级别:

  • /O1:实现最基本的优化
  • /O2:基于代码速度实现常规优化,这个也是默认的优化级别
  • /O3:在/O2的基础上实现进一步的优化,包括Cache预读,标量转换等等,但是在某些情况下反而会减慢代码的执行速度。
  • /Ox:实现最大化的优化,包括自动内联函数的确定,全局优化,使用EBP作为通用寄存器等。
  • /fast:等同于/O3, /Qipo, /Qprec-div-, and /QxP

通过测试,目前选用/O3,但是随着代码的更改,需要重新测试,选择合适的优化级别。

针对特定处理器进行优化

Intel的编译器一共支持如下3种针对特定处理器的优化:

  • /G:使用这个优化选项,Intel将针对特定的CPU进行优化,但是其代码依然可以在所有的CPU上执行。
  • /Qx:使用这个优化选项,Intel将针对特定的CPU进行优化,并且产生的代码无法使用在不兼容的CPU上。
  • /Qax:使用这个优化选项,Intel将针对特定的CPU进行优化,并且产生多份代码,在运行时根据CPU类型自动选择最优的代码。

由于本程序只需要运行在基于Core Microarchitecture 的处理器上,而无需考虑兼容性。所以本程序选择/Qx选项。并且针对运行时的酷睿2处理器,选择/QxT。但是在进行VTune测试时,由于测试平台为奔腾D 820,所以暂时使用/QxP的参数。

使用IPO

使用/Qipo可以启用Intel编译器的过程间优化(Interprocedural Optimizations)。通过过程间优化,编译器可以通过使用寄存器优化函数调用、内联函数展开、过程间常数传递、跨多文件优化等方式进一步优化程序。

此外,Intel编译器支持多文件的过程间优化,而由于本程序只有一个文件,所以并不需要使用。

但是IPO优化却会对本程序的调试带来极大的麻烦。所以本程序开发时不使用IPO优化,只有在最后的版本中才尝试使用IPO优化能否提高效率。

使用GPO

Intel编译器支持GPO(Profile-Guided Optimization)。GPO由一下三步组成。

  • 第一步:使用/Qprof-gen编译程序,产生能记录运行细节的特殊程序。
  • 第二步:运行第一步产生的程序,生成动态信息文件(.dyn)。
  • 第三步,使用/Qprof-use,结合动态信息文件重新编译程序,产生更优化的程序。

通过使用GPO,Intel编译器可以更详细得了解程序的运行情况,从而根据实际情况产生更优化的代码。比如优化条件跳转,使得CPU分支预测的能力更准确,又如决定哪些函数需要内联,哪些不要内联等。

此外,基于GPO还有很多的工具方便用户开发程序。比如Code-Coverage Tool可以进行代码覆盖测试。

由于GPO收集的信息和特定的程序有关,而本程序一直在修改。所以本程序只在每个版本的最后部分使用GPO进行优化。

循环展开

循环展开(Loop Unrolling)通过在把循环语句中的内容展开从而使执行的代码速度更快。循环展开可以提高代码的并行程度,减少条件转移次数从而提高速度。另外,对于Pentium 4处理器,其分支预测功能可以精确得预测出16次迭代以内的循环,所以,如果能把循环展开到迭代次数在16次以内,对于特定的CPU可以提高分支预测准确度。

但是循环展开必须有一个度,并不是展开层数越多越好,展开层数多了,可能反而影响代码的执行速度。所以通常的做法是让编译器自己决定循环展开的层数。

Intel编译器对于循环展开有如下选项:

  • /Qunrolln:执行循环展开n层。
  • /Qunroll:让Intel编译器自己决定循环展开的层数。

此外Intel编译器还提供在了程序中使用编译制导语句规定某个特定循环的展开次数。如下例指示for循环展开n层。

1
2
#pragma unroll(n)
for(i=0;i<10000;i++){……}

所以本程序使用/Qunroll参数,让Intel编译器自己决定使用循环展开的层数。但是在程序的最终优化时,如果发现Intel编译器的循环展开并不是最优的,则通过在特定循环前加上编译制导语句,使用最佳的循环展开层数。

浮点计算优化

Intel编译器提供了很多基于浮点数的优化参数,有提供精度的,也有提高速度的。对于本程序,主要使用如下优化参数。

  • /fp: fast/fp: fast=1:这两个参数的等价的,同时也是默认的参数。他告诉编译器进行快速浮点计算优化。
  • /fp: fast=2:这个参数比/fp: fast=1提供更高的优化级别,同时也可能带来更大的精度损失。

本程序使用/fp: fast=2优化,但是如果发生精度问题,可以考虑使用/fp: fast=1

自动并行化

Intel的编译器支持自动并行化(Auto-parallelization)。通过/Qparallel可以打开编译器的自动并行化,编译器会在分析了用户的串行程序后,自动选择可以并行的部分进行并行化。自动并行化的有点是方便,不需要用户懂得专业知识,不需要更改原来的串行程序。但是缺点也是显而易见的,由于编译器并不知道用户的程序逻辑,所以无法很好得进行并行化。

使用OpenMP并行化

OpenMP是一种通用的并行程序设计语言,其通过在源代码中添加编译制导语句,提示编译器如何进行程序的并行化。OpenMP具有书写方便,不需要改变源代码结构等多种优点。Intel的编译器支持OpenMP。本次程序并不打算使用OpenMP进行并行化,而打算使用Windows Thread。但是由于本程序需要使用到Intel Math Kernel Library,而Intel Math Kernel Library中的代码支持OpenMP并行化。所以有必要使用一些基本的OpenMP设置函数。

需要使用OpenMP,需要在编译时加上/Qopenmp选项。并且在源代码中包含” omp.h”文件。

OpenMP提供了函数omp_set_num_threads(nthreads)设置OpenMP使用的线程数,由于其设置会影响到Intel Math Kernel Library,所以将其设置成1,禁止Intel Math Kernel Library的自动并行化。

向量化

Intel的编译器支持向量化(Vectorization)。可以把循环计算部分使用MMX,SSE,SSE2,SSE3,SSSE3等指令进行向量化,从而大大提高计算速度。这也是本程序串行化时的主要优化点。前面提到的针对处理器的/QaxT优化选项已经打开了向量化。将代码向量化还有许多需要注意的地方,具体的注意点和方法将在后面具体的代码中说明。这里先给出一些对向量化有用的编译制导语句以及选项。

/Qrestrict选项:当Intel编译器遇到循环中使用指针时,由于多个指针可能指向同一个地址,所以其无法保证指针指向内容的唯一性。故Intel编译器无法确定循环内数据是否存在依赖性。这是可以通过使用/Qrestrict选项与restrict关键字,指示某个指针指向内容的唯一性。从而能解决数据依赖性不确定的问题。

#pragma vector编译制导语句:该编译制导语句一共包含3个。#pragma vector always用于指示编译器忽略其他因素,进行向量化。#pragma vector aligned用于指示编译器进行向量化时使用对齐的数据读写方式。#pragma vector unaligned用于指示编译器进行向量化时使用不对齐的数据读写方式。由于在使用SSE类指令进行向量化时,需要同时处理多个数据,所以每次读写的数据长度很长,可以达到128bit。所以将要处理的数据按照128bit(16byte)对齐,使用对齐的读写指令是可以提高程序运行速度的。但是需要注意的是对于实际没有对齐的数据使用#pragma vector aligned会造成程序运行错误。

使用变量对齐指示

Intel编译器提供了__declspec(align(n))用于在定义变量时指定其需要进行n字节对齐。变量对齐对于向量化计算的读取速度有很大关系。对于向量化计算一般使用__declspec(align(16))进行对齐。另外也可以使用__declspec(align(64))指定变量对齐到Cache的行首。关于Cache的行对齐的详细讨论请见后文的分析。

数据预读

通常数据是放在内存中,当要计算时才读入CPU进行计算。由于内存到CPU的传输需要很长时间,所以CPU中有多级Cache机制。Intel编译器支持数据预读优化选项。通过/Qprefetch打开数据预读优化,编译器会在使用数据前先插入预读指令,让CPU先把数据预读到Cache中,从而加快数据的访问速度。该选项默认情况下是打开的。此外Intel还提供了数据预读的编译制导语句,通过使用#pragma prefetch语句,用户可以人为得在程序中增加数据预读指令。但是需要注意的是,数据预读指令并不是越多越好的。不恰当的数据预读指令会占用内存带宽,把有用的数据从Cache中挤出去,反而影响速度。并且Core Microarchitecture体系结构已经支持给予硬件的数据预读指令。所以本程序倾向于使用给予硬件的数据预读机制。而由于/Qprefetch默认的打开的,也没有必要特意关闭该选项,Intel编译器有能力判断哪些地方可以通过合适的数据访问模式激活硬件数据预读机制,哪些地方需要额外添加数据预读指令。

产生调试信息

通过使用/Zi选项产生调试信息以帮助调试。默认为关闭。在本程序的开发阶段,打开此选项。在开发完成后关闭此选项。

使用全局优化

通过使用/Og选项打开编译器的全局优化功能。改选项需要在本程序不同的开发阶段分别尝试是否打开以确定最优优化选项。

针对Windows程序优化

通过使用/GA选项可以打开Intel编译器的针对Windows程序优化的功能。其实通过打开/GA选项,Intel可以提高访问Windows下thread-local storage(TLS)变量的速度。TLS变量通过__declspec(thread)来定义。在本程序中,并不打算使用TLS变量。但还是打开/GA选项。

内联函数扩展

Intel编译器可以通过/Obn来定义内联函数的扩展级别。当n为0禁止用户定义的内核函数的扩展。当n为1时,根据用户定义的inline关键字进行扩展。当n为2时,根据Intel编译器的自动判断进行扩展。本次程序使用/Ob2选项。

FTZ与DAZ

在计算机内浮点数是由尾数和指数组成的。尾数通常被规范化成[1,2)之间。但是当数字接近0时,由于其指数已经无法将尾数规范成[1,2)之间,所以需要在尾数表示成0.0000xx的形式。这种表示形式称为不规范的形式。其会影响CPU的浮点计算速度。并且由于这种数非常接近0,所有有时将其表示成0并不会影响计算的结果。所以CPU的浮点控制器有2个用于控制对于不规范数处理的选项。FTZ用于将计算结果中的不规范数表示成0,DAZ用于在读入不规范数时将其表示成0。Intel编译器提供了内置的宏来方便用户设置这两个模式。这两个宏分别是_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON)_MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON)。用户在程序中设置了这两个模式将有助于提高浮点计算速度。但是实际上对于本程序,由于已经使用了/O3以及SSE指令集优化。所以Intel编译器已经设置好了FTZ模式,用户不必另外设置FTZ。并且由于本程序中所有的数都是计算得来的,所以只要计算时使用了FTZ,那读取数据时就不会碰到不规范的数据,所以用户也没必要设置DAZ。

编译器报告

编译器报告虽然不能直接提供优化,但是却可以让用户了解编译器处理程序的信息,给用户更改源代码提供了很多有用的信息。对于本程序,向量化是非常重要的一步,而编译器报告可以指出某个地方是由于什么原因造成没有向量化。所以本使用使用/Qvec-report3参数对向量优化进行报告。

使用Intel编译器函数进行精确时间测量

Intel编译器提供了许多特殊的函数。这类函数一般都对应一条或者几条汇编语言。其可以让用户以比汇编语言方便的方式写出性能接近汇编语言的代码。其中最主要的是对SIMD类指令的支持。当然其中还有很多其他功能的函数。比如_rdtsc()函数。

需要注意的是要使用这些函数必需打开/Oi选项。这个选项默认是打开的。

当程序需要进行精确时间测量,比如优化后需要知道某段特定的代码到底快了多少毫米时,使用Windows的时间函数已经无法满足精度要求。这是用户可以使用Intel VTune Analyzers进行测量(具体使用方法将在后面介绍)。其实CPU已经提供了一个特殊的机器指令rdtsc,使用这条指令可以读出CPU自从启动以来的时钟周期数。由于现在的CPU主频已经是上GHz了。所以,其计时精度可以达到纳秒级。Intel提供的_rdtsc()函数使得用户不必再使用汇编语言,可以像调用函数一样得到CPU的时钟周期数。例子代码如下:

注:以下代码摘自“Intel C++ Compiler Documentation”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>
int main()
{
__int64 start, stop, elaspe;
int i;
int arr[10000];
start= _rdtsc();
for(i=0; i<10000; i++)
{
arr[i]=i;
}
stop= _rdtsc();
elaspe = stop -start;
printf("Processor cycles\n %I64u\n", elaspe);
return 0;
}

经过以上编译器选项的调整,程序的运行速度已经达到了2.25秒。

使用Intel VTune Analyzers进行性能分析

Intel VTune Analyzers概述

Intel VTune Analyzers用于监视程序或者系统的各种性能,从而为用户优化程序提供有价值的数据。同时Intel VTune Analyzers也能分析其收集的信息,给出用户优化程序的建议。Intel VTune Analyzers即支持本地的数据收集,也支持远程的数据收集。在本程序中,我们只需使用其本地数据收集功能。Intel VTune Analyzers共支持3种数据收集机制。每种机制都有其自己的适用范围,详细介绍如下:

  • SAMPLING:其通过使用CPU内部的监视功能来检测系统底层的各种性能事件。使用这个功能无需在执行代码中插入特定的指令,因此其几乎没有探针效应。其无法给出函数间的调用关系。但是可以把相应的事件关联到程序中某行源代码或者汇编代码上。该方法通常适用于对某段程序的微调或者针对特定性能事件的调整上。
  • CALL GRAPH:其通过在程序中插入特殊的指令,来记录每个函数执行的时间。函数间的调用关系等。其有一定的探针效应。该方法通常用于对于整个比较庞大的程序,进行分析,找出其中具有性能瓶颈的函数。
  • COUNTER MONITOR:其无需在程序内部插入特殊的指令,因此其几乎没有探针效应。该方法即无法显示函数间的调用关系,也没法把事件定位到具体的某行代码中。该方式是用于测试整个系统的某些性能,比如CPU占用率,内存带宽等。通常用于系统级的调试。

对于本程序。由于程序结构简单。无需进行函数间调用的分析。而主要需要进行基于特定代码的分析。特别是后期需要针对CPU内部的事件特性进行源代码级甚至是汇编级的调试。所以本次优化主要采用SAMPLING方式。

基于SAMPLING方式的分析

原理:Intel的CPU有一组性能检测寄存器,由于记录各种影响性能的事件。程序首先通过编程设定需要检测的事件,并且设定触发中断的计数值。当CPU中被检测的事件达到预设的值后触发相应的中断。Intel VTune Analyzers中的SAMPLING就是使用CPU的性能检测功能帮助用户分析程序的性能。其中有关于内存访问的事件,分支预测的事件,指令执行的事件等等。由于不同的CPU支持不同的性能事件,所以在不同的CPU上使用VTune时,所能监视的事件并不相同。

使用注意事项:SAMPLING一共支持2种统计。一种是Event,其是直接测量得到的值。另外一种是Event Ratio,其是基于多个Event计算得到的,有时更有实际意义,更直观。需要注意的是,每个Event都有一个预设的值,当这个预设的值到了以后,CPU引起中断,VTune进行统计。而这个值的设置不能太大,否则统计到的事件不够多,无法分析。也不能太小,否则频繁引起中断,会加大探针效应。用户可以在每个Event上手工设置合适的Sample After值,也可以通过选项卡上的选项,让VTune先运行一遍程序,然后根据实际的事件数量来校准触发值。对于本程序,这点尤其需要引起注意。因为本程序优化到后面时间非常短,如果不校准触发值,分析的效果会不理想。需要注意的是Clockticks和Instructions Retired这两个最基本的事件,默认是不校准触发值的,我们需要把他们调整成自动校准。此外对于某个Event的发生,大部分的中断点并不是精确的。即真正发生该事件的指令在所记录事件指令的前几条。但是有一部分属于精确事件,引起这类事件的指令正好是发生中断的前一条。

优化computePot函数

在对computePot函数向量化前,我们可以注意到distxdistydistz三个变量都是临时变量。先将这3个变量去掉,从而可以使得Intel编译器能够更灵活得进行中间结果优化。另外最完成循环的i虽然是从0开始的,但是实际0和1并不进行计算,所以把外层循环的i设置层从2开始。代码如下:

1
2
3
4
5
6
for( i=2; i<NPARTS; i++ ) {
for( j=0; j<i-1; j++ ) {
dist = sqrt( (r[0][j] - r[0][i])*(r[0][j] - r[0][i]) + (r[1][j] - r[1][i])*(r[1][j] - r[1][i]) + (r[2][j] - r[2][i])*(r[2][j] - r[2][i]) );
pot += 1.0 / dist;
}
}

此时编译器显示内层循环已经向量化了。但是这个绝非我们的目标。为了提高计算开根号倒数的速度,为了使用Intel Math Kernel Library,我们需要把开根号倒数的计算先存在一组向量中,再一同计算。既将dist变量变成,dist数组,然后再对dist数组统一计算,再求和。代码如下:

1
2
3
4
5
6
7
8
9
10
11
for( i=2; i<NPARTS; i++ ) {
for( j=0; j<i-1; j++ ) {
dist[j] = (r[0][j] - r[0][i])*(r[0][j] - r[0][i]) + (r[1][j] - r[1][i])*(r[1][j] - r[1][i]) + (r[2][j] - r[2][i])*(r[2][j] - r[2][i]);
}
for( j=0; j<i-1; j++ ) {
dist[j] = 1.0 / sqrt(dist[j]);
}
for( j=0; j<i-1; j++ ) {
pot += dist[j];
}
}

Intel编译器提示,内部的3个循环都进行了向量化。此时出现了令人惊喜的成绩。程序的执行时间突然降到了1.453秒。使用VTune进行分析,发现Intel编译器对于开根号倒数的计算自动调用了内部的向量化代码库。注意此时,还没有使用Intel Math Kernel Library,所以这个向量代码库是Intel编译器内置的,虽然效率没有使用Intel Math Kernel Library高,但是速度已经提高了很多。

使用Intel Math Kernel Library

Intel Math Kernel Library中提供了一部分的向量函数(Vector Mathematical Functions)。这类函数提供了对于普通数学计算函数的快速的向量化计算。VML中有一个向量函数就是计算开根号倒数的。

Intel的VML库中提供了如下函数来计算整个向量中各个数的开根号倒数:

1
vdInvSqrt( n, a, y )

其中n表示计算的元素个数。a是指向输入计算数据数组的头指针。y是指向输出计算数据数组的头指针。其中a和y可以相同。

要使用该函数,首先需要在头文件中包含”mkl.h”,并且链接mkl_c.lib文件和libguide40.lib文件。

除了基本计算功能外,VML还提供了一个设置模式的函数,用于设置特定的计算模式:

1
vmlSetMode ( mode )

其中的mode是一个预定义宏。在我们的程序中,需要设置如下模式:

  • VML_LA:VML的所有向量函数都提供了2个精度的版本。精度低的版本计算速度也相对比较快。本程序只需要保留小数点后7位精度。低精度的版本符合要求,所以设定VML使用低精度的版本。
  • VML_DOUBLE_CONSISTENT:该选项用于控制FPU的计算精度为double,其实由于我们这次使用的函数基本上是使用SSE2指令集进行计算的,和FPU没什么关系。但是也可能存在使用FPU的可能,所以设定VML使FPU的精度为double。
  • VML_ERRMODE_IGNORE:该选项用于关闭VML的错误处理功能,本程序不需要进行错误处理。
  • VML_NUM_THREADS_OMP_FIXED:VML函数都能使用OpenMP,根据特定的硬件环境进行并行化。而我们并不需要其进行并行化。所以使用该选项和前面提到的omp_set_num_threads(1)结合。关闭VML的自动并行化功能。

具体的代码如下:

1
2
3
4
5
6
7
8
9
for ( i = 2; i < NPARTS; i ++ ) {
for ( j = 0; j < i - 1; j ++ ) {
dist[j] = (r[0][j] - r[0][i]) * (r[0][j] - r[0][i]) + (r[1][j] - r[1][i]) * (r[1][j] - r[1][i]) + (r[2][j] - r[2][i]) * (r[2][j] - r[2][i]);
}
vdInvSqrt(i-1, dist, dist);
for ( j = 0; j < i - 1; j ++ ) {
pot += dist[j];
}
}

优化后出现了令人可惜可贺的成绩:0.796秒。

根据Cache大小优化Intel Math Kernel Library调用

在上面的程序中对于MKL函数的调用是每次内部循环都执行一次调用,我们知道每次执行函数的调用都是需要开销的,那是否有更优化的调用MKL方法那?下面这段话摘自Intel Math Kernel Library的说明文档上:

There are two extreme cases: so-called “short” and “long” vectors (logarithmic scale is used to show both cases). For short vectors there are cycle organization and initialization overheads. The cost of such overheads is amortized with increasing vector length, and for vectors longer than a few dozens of elements the performance remains quite flat until the L2 cache size is exceeded with the length of the vector.

从这段文字中,我们了解到对于MKL函数的调用时,所处理的向量不能太短,否则函数的建立时间开销将是非常大的,也不能太长,操作了L2 Cache,否则函数执行时访问内存的开销是很大的。不合适的长度对于函数的性能将产生指数级影响。

根据理论计算:每次执行computePot函数,总共需要执行的计算量为(1+998)*998/2=498501个。每个double类型占用8个字节,所有总共需要占用的空间为498501*8=3988008byte=3894KB。而这次进行竞赛的测试平台的CPU的L2 Cache大小为2M,由于有2个线程同时计算,平均每个线程分到的L2 Cache为1M。由于L2 Cache可能还被其他数据占据。所以为了保证所计算的数据在L2 Cache中,最好每次计算的向量长度在512KB左右。故把整个computePot函数的计算量分成8份。每份计算量的中间结果向量长度为3894KB/8=486KB。

但是实际情况并非如此,进行这种优化后,程序的执行速度反而降低了。通过分析发现原来CPU中的L1 Cache大小为32KB。数组r有3000个元素,如果每次迭代都进行vdInvSqrt调用。那dist的长度为1000个元素左右。加起来正好可以全部在L1 Cache中。而如果合并起来调用vdInvSqrt,则由于vdInvSqrt过长。其L1 Cache中存放不下,需要存放在L2 Cache中,从而反而影响了速度。看来,对于本程序,不应该根据L2 Cache进行优化,而应该根据L1 Cache进行优化。但是对于只有几个或者几十个数据就调用MKL函数,其开销还是很大的。因此本程序使用了折中的方法,对于前面非常小的几十个数据,凑足1000个放在一起进行计算,而后面的数据还是按照原来的方式计算。具体实现的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
for( i = 2, k = 0; i < 47; i ++ ) {
for( j = 0; j < i - 1; j ++, k ++ ) {
dist[k] = (r[0][j] - r[0][i]) * (r[0][j] - r[0][i]) + (r[1][j] - r[1][i]) * (r[1][j] - r[1][i]) + (r[2][j] - r[2][i]) * (r[2][j] - r[2][i]);
}
}

vdInvSqrt(k, dist, dist);

for( j = 0; j < k; j ++ ) {
pot += dist[j];
}

for( i = 47; i < NPARTS; i ++ ) {
for( j = 0; j < i - 1; j ++ ) {
dist[j] = (r[0][j] - r[0][i]) * (r[0][j] - r[0][i]) + (r[1][j] - r[1][i]) * (r[1][j] - r[1][i]) + (r[2][j] - r[2][i]) * (r[2][j] - r[2][i]);
}
vdInvSqrt(i - 1, dist, dist);
for( j = 0; j < i - 1; j ++ ) {
pot += dist[j];
}
}

通过该优化,程序的性能略微有所提高,达到了0.781秒。

优化updatePositions函数

虽然updatePositions函数执行的时间非常短。但还是值得优化的。

首先进行的是基于数学的优化。我们发现在updatePositionsinitPositions中,都有加0.5的计算。但是从后面的computePot的相减计算中发现,这个0.5是被抵消的,既不加0.5对结果没有影响。故去掉该加0.5的计算。另外updatePositionsinitPositions中都有除以RAND_MAX的计算。而通过提取公因子的变换发现,如果此处不除以RAND_MAX而将最后的pot乘以RAND_MAX,则最后结果相同。故去掉该处的除以RAND_MAX的计算,而以在pot上一次乘以RAND_MAX为替换。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
void initPositions() {
int i, j;
for( i = 0; i < DIMS; i ++ )
for( j = 0; j < NPARTS; j ++ )
r[i][j] = (double) rand();
}

void updatePositions() {
int i, j;
for( i = 0; i < DIMS; i ++ )
for( j = 0; j < NPARTS; j ++ )
r[i][j] -= (double) rand();
}

在main函数中:

1
2
3
4
pot = 0.0;
computePot();
pot*=(double)RAND_MAX;
if (i%10 == 0) printf("%5d: Potential: %20.7f\n", i, pot);

其次需要进行updatePositionsrand函数的优化。虽然rand函数本身的执行时间非常短,但是其频繁得进行调用却影响了性能。通过查找Microsoft Visual Studio .NET 2005中提供的源代码。将其中的rand函数提取出来,进行必要的修改,并且加上inline属性。从而加快程序的调用速度。具体代码如下:

1
2
3
4
5
int holdrand=1;

inline int myrand (){
return( ((holdrand = holdrand * 214013L+ 2531011L) >> 16) & 0x7fff );
}

经过上述优化,代码的执行速度已经达到了0.765秒。

其他优化以及性能分析

至此,该程序串行优化部分已经一本完成。但是还有一点细小的地方需要优化。

变量对齐对于数据读取速度是非常重要的。尤其是使用SIMD指令集进行优化后,对于对齐的变量,可以使用对齐的读写指令提高速度。一般对于SIMD指令需要进行16字节对齐。但是对于本程序,由于后面要进行多线程优化,而多线程执行时基于Cache Line的共享冲突会对读写造成很大的损失。故本程序使用64字节对齐。代码如下:

1
2
3
4
__declspec(align(64)) int holdrand=1;
__declspec(align(64)) double r[DIMS][NPARTS];
__declspec(align(64)) double pot;
__declspec(align(64)) double dist[1048];

computePot函数的第一次迭代中。有一处进行pot累加的地方,使用了k变量作为循环条件。但是其实该变量的确切值是可以计算出来的。通过计算出该变量的确切值,可以让Intel编译器在编译时就知道循环的次数,从而有助于优化。具体代码如下(注意1035这个值):

1
2
3
4
5
6
7
8
9
10
11
for( i = 2, k = 0; i < 47; i ++ ) {
for( j = 0; j < i - 1; j ++, k ++ ) {
dist[k] = (r[0][j] - r[0][i]) * (r[0][j] - r[0][i]) + (r[1][j] - r[1][i]) * (r[1][j] - r[1][i]) + (r[2][j] - r[2][i]) * (r[2][j] - r[2][i]);
}
}

vdInvSqrt(k,dist,dist);

for( j=0; j<1035; j++ ) {
pot += dist[j];
}

此外再调整以下编译器的某些优化参数,选择合适的使用。比如使用哪个编译级别,是否打开全局优化,使用IPO,使用GPO等。

并行优化

并行优化概述

在进行本程序的并行优化前先谈谈并行优化需要注意的问题。在并行优化中经常用到数据重复和计算重复的方法。所谓数据重复,就是为了保证多个线程能同时进行计算,就把数据复制多份来提高并行度。所谓计算重复,就是有时使用计算换通信的方法,提高并行度。

在对本程序进行优化前需要注意的是。测试平台使用的是基于Core Microarchitecture结构的。这个结构的双核CPU是共享L2 Cache的。但是当数据在一个核中进行修改,另外一个核去读他时,需要消耗几十个时钟周期的延迟。其代价的非常高的。这里需要注意的是,数据在Cache中是按行进行存放的,也就是说,CPU看待数据有没有被修改过是根据Cache Line的。所以2个分别被不同的核修改的数据如果存在于同一行Cache中,访问时的效率就会非常低。也就是发生了共享冲突。所以在分配变量时要尽量把不同性质的变量分配到不同的Cache Line中。我们的测试平台的L1 Cache和L2 Cache都是每行64byte的。所以前一章中的变量对齐都使用了64byte对齐。同样,在程序并行化时也需要考虑这种情况。

优化方案一

此方案使用数据重复的方法。程序可以定义2个r数组。以及2个pot数组。通过定义2个r数组,使得主线程可以在从线程使用一个r数组计算时同时更新第二个r数组。即主线程先更新r数组,然后主线程和从线程同时开始计算。但是从线程的计算量比主线程大一点。这样当主线程计算完后,可以继续更新第二个r数组,而此时从线程还在计算原来r数组的内容。当主线程更新完第二个r数组时,从线程正好完成前面的计算,并和主线程一同计算第二个r数组,依次类推。同时2个pot数组,一个给主线程计算每步的中间结果,另一个给从线程计算每步的中间结果。等计算结束后,再将其结果相加,打印。

优点:使用该方法的优点是显而易见的,理论上线程可以做到完全同步。

缺点:使用该方法的缺点是,从线程每次计算需要从主线程计算好的r数组中读取内容,由于是2个核,所以其访问延迟非常大。此外使用2个数值,每次迭代都需要将指针指向使用的数组,增加了程序的设计难度。同时计算任务分配的调优也是非常繁琐的。

由于在前一章中,我们发现updatePositions函数所花费的时间非常短。所以做到线程间的完全平衡意义并不大。

优化方案二

在前一个方案中,我们提到了线程的完全平衡的算法。同时我们发现完全平衡的意义不大。因此我们设计适合本程序的更优的方案。既然updatePositions函数所花费的时间非常短。那2个线程同时执行updatePositions造成的额外开销也是可以忽略的。本方案使用了数据重复和计算重复的方法。同样使用2个r数组,但是2个线程同时进行重复计算,并且2个线程分区完成不同的迭代步骤的computePot计算。即主线程完成整个r数组的更新,但是只计算其中的奇数次迭代。从线程同样完成整个r数组的更新,但是只进行偶数次迭代。并且同样使用了一个pot数组,2个线程分别将自己的计算结果先存储到pot数组中。等最后同步的时候再打印。

优点:使用该方案,程序的设计相对来说比较简单,负载均衡的调整也很容易。程序只需要很少的同步操作(在本程序中,只使用了2次同步)。并且重要的是。由于2个线程都在各自的CPU上使用各自的数据进行计算,所以最大化得避免了共享冲突的发生。同时也保留了前一章优化中针对L1 Cache的命中率。

缺点:该方案的缺点是存在重复计算。但是通过前面VTune的测试,已经发现其重复计算量非常小,可以忽略。

并行实现

本程序使用方案二进行并行化。首先将所有需要计算的数据和函数都复制2份,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int computePot1(void);
void initPositions1(void);
void updatePositions1(void);
int computePot2(void);
void initPositions2(void);
void updatePositions2(void);
__declspec(align(64)) int holdrand1=1;
__declspec(align(64)) double r1[DIMS][NPARTS];
__declspec(align(64)) double pot1;
__declspec(align(64)) double dist1[1048];
__declspec(align(64)) int holdrand2=1;
__declspec(align(64)) double r2[DIMS][NPARTS];
__declspec(align(64)) double pot2;
__declspec(align(64)) double dist2[1048];
__declspec(align(64)) double potfinal[264];

其中的potfinal数组记录每次迭代的计算结果,用于最后的数组。

在主函数的并行中。我们发现由于偶数次迭代比奇数次迭代需要多算一次。故本程序的偶数次迭代在进行到快完成前先释放一个同步锁。使得主线程可以先输出一部分数据。而从线程在执行完所有的偶数次迭代后再释放一个同步锁,使主线程输出剩余的数据。由于输出数据也有一点的耗时。所以使用这种方法可以提高一点并行度。另外在本代码中使用了SetThreadAffinityMask分别设置不同的线程对应各自的CPU,以防止线程在不同的CPU中切换从而影响L1 Cache命中率。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
DWORD WINAPI mythread( void *myarg ){
int i;
SetThreadAffinityMask(GetCurrentThread(), 2);
initPositions2();
updatePositions2();

for(i=0;i<=190;i+=2){
pot2 = 0.0;
computePot2();
pot2*=(double)RAND_MAX;
potfinal[i]=pot2;
updatePositions2();
updatePositions2();
}
ReleaseSemaphore(semmiddle, 1, NULL);

for(i=192;i<=NITER;i+=2){
pot2 = 0.0;
computePot2();
pot2*=(double)RAND_MAX;
potfinal[i]=pot2;
updatePositions2();
updatePositions2();
}

ReleaseSemaphore(semafter, 1, NULL);
return 0;
}//从线程

int main() {
int i;
int myarg=0;

clock_t start, stop;
omp_set_num_threads(1);
vmlSetMode(VML_LA);
vmlSetMode(VML_DOUBLE_CONSISTENT);
vmlSetMode(VML_ERRMODE_IGNORE);
vmlSetMode(VML_NUM_THREADS_OMP_FIXED);
semmiddle = CreateSemaphore(NULL, 0, 1, NULL);
semafter = CreateSemaphore(NULL, 0, 1, NULL);
CreateThread(0, 8*1024, mythread, (void *)&myarg, 0, NULL);
SetThreadAffinityMask(GetCurrentThread(), 1);
initPositions1();
start=clock();

for(i=1;i<NITER;i+=2){
pot1 = 0.0;
updatePositions1();
updatePositions1();
computePot1();
pot1*=(double)RAND_MAX;
potfinal[i]=pot1;
}
WaitForSingleObject(semmiddle, INFINITE);

for(i=0;i<=190;i+=10)
printf("%5d: Potential: %20.7f\n", i, potfinal[i]);

WaitForSingleObject(semafter , INFINITE);

i=200;
printf("%5d: Potential: %20.7f\n", i, potfinal[i]);
stop=clock();
printf ("Seconds = %10.9f\n",(double)(stop-start)/ CLOCKS_PER_SEC);
}//主线程

性能分析

并行化后的性能并不没有像理论中这么高只有0.437秒。于是我们开始查找原因。通过使用Intel Threading Checker我们发现,VML库中存在着访问冲突。

当然这个错误有可能是Intel Threading Checker的误报。因为程序每次执行都没有发现不正确的结果,并且VML函数的文档上说明是线程安全性的。

由于兼容性原因,本系统无法使用Intel VTune Analyzers进行每个函数的耗时分析。于是使用Intel编译器提供的内置函数_rdtsc()记录不同部分所花费的CPU时钟周期。结果发现VML函数的总执行时间大概增加了0.088秒左右。说明VML函数在用户使用Windows Thread函数并行化访问时,其同步开销可能有一定的影响。

汇编级优化

优化目标

本程序主要的执行时间在computePot函数与VML库中。对于computePot函数,通过查看Intel编译器产生的汇编码发现其已经很优了。而对于VML函数由于其需要满足通用性,所以本程序应该可以设计出最适合本程序的计算函数来。

数学理论

Intel的CPU支持的SSE2指令中,有2条是用于计算双精度浮点的开根号倒数的。sqrtpd指令可以同时计算2个double型的开根号,其吞吐率为28个时钟周期。divpd指令用于计算2个数的除法,即用于计算倒数,其吞出率为17个时钟周期。由此可以计算出,如果当当使用这2条指令计算双精度数的开根号倒数,那即使使用汇编语言,忽略其他开销。计算每个元素的时钟周期也有(17+28)/2=22.5。而Intel的VML库计算每个元素的只需要10多个时钟周期,说明其肯定是通过其他快速的数学计算方法得到的。所以要优化vdInvSqrt函数,关键是找到更快速的数学计算方法。在Quake 3在源代码中有如下一段具有传奇色彩的代码:

1
2
3
4
5
6
7
8
float InvSqrt(float x){
float xhalf = 0.5f*x;
int i = *(int*)&x; // get bits for floating value
i = 0x5f3759df - (i>>1); // gives initial guess y0
x = *(float*)&i; // convert bits back to float
x = x*(1.5f-xhalf*x*x); // Newton step, repeating increases accuracy
return x;
}

在上面的代码中最后一条是典型的牛顿迭代,可以根据精度要求进行多次迭代。这段代码神奇的地方在于初始值的估算上,只用了减法和移位2个简单的操作,达到了非常接近的估算值。我们称0x5f3759df为幻数(magic number)。CHRIS LOMONT在他的《FAST INVERSE SQUARE ROOT》文章中给出了对于这个幻数的解释和计算方法。并且计算出了理论上最优的适用于double类型的幻数为0x5fe6ec85e7de30da。说们我们的代码中可以使用该方法进行计算,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
double myinvsqrt (double x)
{
double xhalf = 0.5*x;
__int64 i = *(__int64*)&x;
i = 0x5fe6ec85e7de30da - (i>>1);
x = *(double*)&i;
x = x*(1.5-xhalf*x*x);
x = x*(1.5-xhalf*x*x);
x = x*(1.5-xhalf*x*x);
x = x*(1.5-xhalf*x*x);
return x;
}

但是不幸的是,根据调试,需要达到比赛要求的小数点后7位精度,必需进行4次牛顿迭代也行。而4次牛顿迭代的计算量使得这个方法对于Intel的VML函数来说毫无优势可言。那能否降低牛顿迭代的次数那?

我们发现如果以上代码只进行3次牛顿迭代,那误差只有小数点最后的1,2位。CHRIS LOMONT在他的文中提到他说计算出来的理论最优值,而这个幻数只是在线性估计时是最优的。在多次牛顿迭代中,这个值并不是最优的。CHRIS LOMONT并没有给出对于多次牛顿迭代最优幻数的计算方法,他在文章中对于float类型的实际最优值也是穷举得到的。我们同样在理论最优值0x5fe6ec85e7de30da的基础上进行了一定的穷举操作,发现的确有更优的幻数。但是即使使用了更优的幻数,还是无法在3次牛顿迭代基础上达到精度要求。但是我们发现所有的数值都偏小。于是我们可以在三次牛顿迭代后再乘一个比1大一点点的偏移量。从而能做到3次牛顿迭代就能达到精度要求。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
double myinvsqrt (double x)
{
double xhalf = 0.5*x;
__int64 i = *(__int64*)&x;
i = newmagicnum - (i>>1);
x = *(double*)&i;
x = x*(1.5-xhalf*x*x);
x = x*(1.5-xhalf*x*x);
x = x*(1.5-xhalf*x*x);
x = x*offset
return x;
}

由于时间原因,这里并没有对newmagicnum和offset进行详细的计算与统计。只给出一个对于本程序相对较优的newmagicnum值0x5fe6d250b0000000。

在上面的代码中只进行了3次牛顿迭代。对于Intel的VML来说也没有什么优势可言。那能不能再减少一次牛顿迭代,只进行2次迭代就达到精度要求那?

我们知道要进行2次牛顿迭代就达到精度要求就必须对其初始值的估计更加准确。而使用上面的方法估计的初始值已经无法满足该准确性。这是通过查找《Intel 64 and IA-32 Architectures Optimization Reference Manual》,我们发现SSE指令集中有一条RSQRTPS的指令用于同时计算四个单精度浮点数的开根号倒数,而其在Core Microarchitecture上的延迟为3个周期,吞吐率为2个周期。也就是说我们可以在极短的时间内就算出单精度类型的开根号倒数值(看来在现在的CPU上,当初Quake 3那段具有传奇色彩的代码已经没有用了)。于是我们想到了先使用单精度类型精度初值估算,然后再使用牛顿迭代。实验结果表明该方法只需要进行2次牛顿迭代就能满足小数点后7位的精度要求。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
double myinvsqrt (double x)
{
double xhalf = 0.5*x;
float xf=(float)x;
__asm{
movss xmm1,xf;
rsqrtss xmm1,xmm1;
movss xf,xmm1;
}
x=(double)xf;
x = x*(1.5-xhalf*x*x);
x = x*(1.5-xhalf*x*x);
return x;
}

不幸的是由于该代码涉及到了复杂的算法以及类型转换,Intel的编译器并无法将其很好的并行化。所以只有依靠手工使用汇编语言将其优化。

汇编码实现

在实现汇编码前先要将原来的代码进行优化,将牛顿迭代中的减法变成加法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
double myinvsqrt (double x)
{
double xhalf = -0.5*x;
float xf=(float)x;
__asm{
movss xmm1,xf;
rsqrtss xmm1,xmm1;
movss xf,xmm1;
}

x=(double)xf;
x = x*(1.5+xhalf*x*x);
x = x*(1.5+xhalf*x*x);
return x;
}

进行这种转变是一点都不影响计算结果的。但是确可以提高计算速度。这是因为,如果执行的是减法,汇编语言的减法指令会将结果存在原来存放被减数(即1.5)的寄存器中。从而覆盖掉了原来的常数1.5,使得每次计算必须重新读入该参数。而优化成加法后则没有这个问题。

在进行优化前,还有一点需要注意的是。rsqrtps函数是4个元素一算的,所以本程序使用4个元素作为一次计算单元来向量化。而用户输入的数据并不可能是正好4个元素。对于Intel编译器以及VML函数库来所,其使用的解决方法称为” Strip-mining and Cleanup”。即先按照4个数据一组进行计算。对于剩下的个别数据再进行单独计算。这对于通用化的程序来说是必须的。但是在我们的程序中,多计算几个并不会影响结果。而对于单独几个的数据如果另外处理不但会增加程序设计的复杂性,而且性能也可能会降低。所以本程序使用过渡计算的方法。即对于需要计算的数据中不足4个的,补满4个将其后面的数据计算掉。但是此时需要注意,由于dist变量是全局变量,默认的值为全0。如果过渡计算遇到0的值,速度可能会受到影响。所以本程序需要在一开始,将会被过渡计算使用到,但是从来不会被初始化的存储单元,初始化成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
void myinvsqrt (double *start,double *end)
{
__asm{
mov esi,start;
mov edi,end;
test edi,0x0000001f;
jz myalign;
and edi,0xffffffe0;
add edi,32;
myalign:

myagain:
movapd xmm0,[esi];
movapd xmm3,[esi+16];
cvtpd2ps xmm6,xmm0;
cvtpd2ps xmm7,xmm3;
shufps xmm6,xmm7,01000100b;
rsqrtps xmm6,xmm6;
cvtps2pd xmm1,xmm6;
shufps xmm6,xmm6,01001110b;
cvtps2pd xmm4,xmm6;
mulpd xmm0,mulcc;
mulpd xmm3,mulcc;
movapd xmm2,xmm1;
movapd xmm5,xmm4;
mulpd xmm1,xmm1;
mulpd xmm4,xmm4;
mulpd xmm1,xmm0;
mulpd xmm4,xmm3;
addpd xmm1,addcc;
addpd xmm4,addcc;
mulpd xmm1,xmm2;
mulpd xmm4,xmm5;//前半段
movapd xmm2,xmm1;
movapd xmm5,xmm4;
mulpd xmm1,xmm1;
mulpd xmm4,xmm4;
mulpd xmm1,xmm0;
mulpd xmm4,xmm3;
addpd xmm1,addcc;
addpd xmm4,addcc;
mulpd xmm1,xmm2;
mulpd xmm4,xmm5;
movapd [esi],xmm1;
movapd [esi+16],xmm4;
add esi,32;
cmp esi,edi;
jne myagain;
}
}

//后半段

myinvsqrt(dist1,dist1+k); //调用方法

对于本函数的调用方法为分别传入其需要计算数据的头指针和尾指针。

性能分析

使用汇编语言优化后,程序跑出了惊人的0.312秒的好成绩。并且所有的输出数据全部都满足小数点后7位的精度要求。在使用Intel Threading Checker和Intel Threading Profiler分析程序时也得到了相对比较好的结果。

在Intel Threading Checker的检测中,没有发现程序有任何冲突。在使用Intel Threading Profiler的分析中,表现出了程序良好的并行性。

最后,在另外一台Intel酷睿2 E6600的机器上测试时,程序达到了0.25秒的好成绩,并且所有数据输出精度都达到了小数点后7位。

LLVM 内存依赖分析实现及其在后端优化中的应用

内存依赖分析简介

提高程序并行度是提高代码执行效率的重要途经。在寄存器压力允许的条件下,编译器总是并行调度尽可能多的指令。并行指令执行需要满足的另一个条件是指令之间互相独立,即编译器必须先明确指令之间的相关性,才能决定是否并行执行。如果一条指令必须依赖另一条指令的执行,例如,计算操作数必须先由load指令从内存中加载,然后才能使用,这样的指令就不能并行执行。所以依赖性会抑制并行性。与别名分析类似,编译器对于指令之间依赖性的分析总是偏向保守。当编译器无法确定两条指令的依赖关系时,一般假定指令间存在依赖性,并顺序调度这两条指令。只有在编译器可以完全确定两条指令是相互独立时,才能并行调度执行。

内存依赖的隐蔽性为编译器确定访问内存的指令依赖关系带来了一定困难。下面的例子很好地解释内存依赖:

1
2
3
4
5
void VectorAdd (short *sum, short *input1, short *input2) {
int i;
for(i = 0; i < 100; i++)
sum[i] = input1[i] + input2[i];
}

由于sum、input1和input2指针关系的不确定,将求和结果写入sum数组可能会影响input1或input2所指向的内存。例如,以如下参数调用VectorAdd()

1
VectorAdd (arr0, arr0, arr1);

这时,从内存中读取input[i]的操作就依赖于sum[i-1]的写入,以及input1[i-1]input2[i-1]的求和操作。因此,编译器会默认for循环中的加法指令不能并行执行。显然,这会大大影响VectorAdd()的性能。

为了帮助编译器分析内存依赖,大部分C / C ++编译器提供了标识指针别名信息的方法。 C99标准包括关键字strict。虽然C ++中没有标准关键字,但是大多数编译器允许使用关键字__restrict__。通过给指针增加strict属性,程序员可以向编译器保证,通过该指针写入的任何数据都不会被任何其他带有strict属性的指针读取,strict指针指向的内存对象只能被该指针访问,编译器也不必担心写入strict指针指向的内存会导致从另一个strict指针读取的值发生变化。对于VectorAdd(),如果事先知道sum[i]input1[i]input2[i]不会在内存中出现重叠,就可以给这些参数增加__restrict__修饰符:

1
2
3
4
5
6
7
void VectorAdd (short * __restrict__ sum, short * __restrict__ input1, 
short * __restrict__ input2) {
int i;
for(i = 0; i < 100; i++) {
sum[i] = input1[i] + input2[i];
}
}

这时,编译器知道,循环的每次迭代均引用不同的数组元素,对sum[i-1]的写入不会影响input1[i]input2[i]的读取。因此,循环的不同迭代可以按任意顺序执行。由于不同迭代的两个数据元素不可能相互干扰,编译器可以做更多的并行化优化。

LLVM中的内存分析实现

LLVM中的内存依赖分析主要通过Wrapper pass MemoryDependenceWrapperPass实现(MemoryDependenceAnalysis.cpp.)。MemoryDependenceWrapperPass以别名分析信息为基础,确定给定内存操作所依赖的前导内存操作,最终对客户端暴露MemoryDepnedenceResults实例:

1
2
MemoryDependenceResults *MDA = nullptr;
MDA = &getAnalysis<MemoryDependenceWrapperPass>().getMemDep();

MemoryDependenceResults的定义位于MemoryDependenceAnalysis.h,是用于进行公用内存别名信息查询的缓存接口。如果被查询的storecall指令可能会修改内存,该接口将返回可能从该内存加载或存储数据到其中的指令;如果被查询的loadcall指令不会修改内存,该接口将返回可能会修改指针的callstore指令,但通常不返回load指令,除非load指令是易失性的(volatile),或者load指令是从must-aliased指针中加载。

MemoryDependenceResults中定义的内存以来分析接口主要有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MemDepResult 
getDependency (Instruction *QueryInst, OrderedBasicBlock *OBB=nullptr)

const NonLocalDepInfo &
getNonLocalCallDependency (CallBase *QueryCall)

MemDepResult
getPointerDependencyFrom (const MemoryLocation &Loc, bool isLoad, BasicBlock::iterator ScanIt,
BasicBlock *BB, Instruction *QueryInst=nullptr,
unsigned *Limit=nullptr, OrderedBasicBlock *OBB=nullptr)

MemDepResult
getSimplePointerDependencyFrom (const MemoryLocation &MemLoc, bool isLoad,
BasicBlock::iterator ScanIt, BasicBlock *BB,
Instruction *QueryInst, unsigned *Limit, OrderedBasicBlock *OBB)

这些接口中有的用于返回内存操作依赖的指令,有的用于返回内存位置依赖的指令,但返回的类型大多是MemDepResult,其中定义了内存依赖查询的四种结果:Invalid、Clobber、Def和Other,以及依赖的指令(getInst())。

1
2
3
4
5
6
enum DepType {
Invalid = 0,
Clobber,
Def,
Other
};

Invalid:当从MemDep中删除指令时,LocalDeps map或NonLocalDeps map中与该指令对应的条目将被标记为Invalid标记。LocalDeps是指令与其依赖关系之间的映射结构,NonLocalDeps是指令与其non-local依赖关系之间的映射结构。这里的local是指当前块,non-local指当前块的前驱块。LocalDeps和NonLocalDeps的定义如下:

1
2
3
4
5
using LocalDepMapType = DenseMap<Instruction *, MemDepResult>;
LocalDepMapType LocalDeps;

using NonLocalDepMapType = DenseMap<Instruction *, PerInstNLInfo>;
NonLocalDepMapType NonLocalDeps;

无论LocalDeps映射或NonLocalDeps映射,条目中都包括指令指针,指针指向的是扫描块中的指令。在默认构造的MemDepResult对象中,依赖类型设为Invalid,指令指针将为null。

Clobber:Clobber是对篡改了内存中期望值的特定指令的依赖。 当内存依赖查询的结果为“Clobber”时,MemDepResult对的指针成员保存了篡改内存的指令。例如,当may-aliased的store指令向某个内存位置写时,有可能意外修改内存,导致随后load指令加载的数据被篡改。

Def:当内存依赖查询的结果为“Def”时,表明内存位置与指令之间有依赖关系。此时,MemDepResult对中的指针成员保存了定义内存的指令。在本例中,getPointerDependencyFrom()对指针参数%r指向内存位置的查询结果就是Def,定义内存的指令为“store i32 %2, i32 addrspace(1)* %r, align 1”。

Other:Other表示查询在指定的块中没有已知的依赖性。

1
2
3
4
5
6
7
8
9
10
11
12
13
Instruction *getInst() const {
switch (Value.getTag()) {
case Invalid:
Value.cast<Invalid>();
case Clobber:
return Value.cast<Clobber>();
case Def:
return Value.cast<Def>();
case Other:
return nullptr;
}
llvm_unreachable("Unknown discriminant!");
}

内存分析在LLVM AMDGPU后端优化中的应用

LLVM AMDGPU后端实现中两处用到MemoryDependenceWrapperPass,其中之一就是AMDGPURewriteOutArgumentsPass。本节以AMDGPURewriteOutArgumentsPass为例,阐述内存依赖分析的用法。AMDGPURewriteOutArgumentsPass优化的目的是用的返回结构替换指针参数,将方法实现由:

1
2
3
4
 int foo(int a, int b, int* out) {
*out = bar();
return a + b;
}

转化为形式:

1
2
3
std::pair<int, int> foo(int a, int b) {
return std::make_pair(a + b, bar());
}

上述第一个foo()方法实现中,除了返回a+b的结果外,还通过指针参数out返回ba()的执行结果。第一个foo()方法返回值类型是std::pair,可将两个数据组合成一个数据,pair实质上是一个结构体,通过调用std::make_pair函数初始化,两个主要成员变量firstsecond在这里分别是a+bba()的执行结果。

一般方法执行结束后,可以直接通过寄存器中返回多个值。但是C代码通常使用指针参数返回第二个值,而不是按值返回结构。 GPU堆栈访问代价较高,因此应尽可能避免使用指针参数传递返回值。将堆栈对象指针传递给函数还需要附加的地址扩展代码序列,以将指针转换为kernel关联的scratch wave offset寄存器,因为被调用函数不知道传入指针和哪个栈帧关联。

通常,传入的指针是指向由API调用为临时变量分配的内存,当创建的stub函数是内联函数时,如果将传入的指针替换为结构返回,传入的指针很可能被SROA(聚合标量替换)优化删除。

AMDGPURewriteOutArgumentsPass引入了结构返回,但是保留了未使用的指针参数,并引入了一个新的stub函数来调用struct返回主体。之后应运行DeadArgumentElimination将其清除。

本文用到的IR示例文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
define dso_local i32 @test_mem_dep(<32 x i8> addrspace(4)* %in, i32 addrspace(1)* %r) #0 {
entry:
%0 = load <32 x i8>, <32 x i8> addrspace(4)* %in
%1 = bitcast <32 x i8> %0 to <8 x i32>
%2 = extractelement <8 x i32> %1, i32 1
store i32 %2, i32 addrspace(1)* %r, align 1
%3 = icmp ne i32 %2, 0
%4 = select i1 %3, i32 0, i32 1
%5 = bitcast <32 x i8> addrspace(4)* %in to <8 x i32> addrspace(4)*
%6 = load <8 x i32>, <8 x i32> addrspace(4)* %5
%7 = extractelement <8 x i32> %6, i32 1
%8 = load <8 x i32>, <8 x i32> addrspace(4)* %5
%9 = extractelement <8 x i32> %8, i32 1
%10 = add nsw i32 %7, %4
%add = add nsw i32 %10, %9
ret i32 %add
}

AMDGPURewriteOutArgumentsPassrunOnFunction()方法遍历IR方法的输入参数,如本例中的%in%r,调用isOutArgumentCandidate()方法判断:

  • 判断参数是否为指针类型。因为这个pass用于优化指针参数返回,所以如果参数不是指针,则返回false;
  • 判断参数的指针类型地址空间(getAddressSpace)是否和分配地址空间(getAllocaAddrSpace)相同。如果不相同,则返回false;
  • 判断传入的指针参数是否有byval属性(hasByValAttr)。如果有byval属性,表明指针参数按值传递给函数。如果有byval属性,则返回false;
    • byval属性表示在调用者和被调用者之间已创建了pointee的隐藏副本,因此被调用者无法修改调用者中的值。该属性仅对LLVM指针参数有效,通常用于按值传递结构和数组,但对标量指针也有效。副本属于调用者而不是被调用者(例如,只读函数不应写入byval参数)。byval属性对返回值无效。byval属性还支持可选的类型实参,该实参必须与对应的pointee类型相同。byval属性还支持使用align属性指定对齐方式,向调用方指明stack slot的对齐方式和指针的对齐方式。如果未指定对齐方式,代码生成器将针对不同目标机器做不同的假设。
  • 判断传入的指针参数是否有sret属性(hasStructRetAttr)。sret属性表明指针参数指向结构的地址,并将该结构作为源程序方法的返回值。如果有sret属性,则返回false;
  • 判断传入指针参数的类型的字节大小(getTypeStoreSize)是否超过指定值。如果超过,则返回false。比如,在本例中,对第一个输入向量指针参数<32 x i8> addrspace(4)* %ingetTypeID()返回PointerTyIDgetPointerElementType()返回指向VectorTyID类型的指针,getTypeStoreSize()返回类型的保存大小,这个值由向量中元素的数量(getNumElements=32)和每个向量元素大小(getTypeSizeInBits=8bits)的乘积决定。乘积单位为比特,需要转为字节。因此,针对<32 x i8> addrspace(4)* %ingetTypeStoreSize返回值为32字节。

如果上述5个条件任意一条不满足,则不能将该指针参数转化到返回结构中。isOutArgumentCandidate()代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool AMDGPURewriteOutArguments::isOutArgumentCandidate(Argument &Arg) const {
const unsigned MaxOutArgSizeBytes = 4 * MaxNumRetRegs;
PointerType *ArgTy = dyn_cast<PointerType>(Arg.getType());

// TODO: It might be useful for any out arguments, not just privates.
if (!ArgTy || (ArgTy->getAddressSpace() != DL->getAllocaAddrSpace() &&
!AnyAddressSpace) ||
Arg.hasByValAttr() || Arg.hasStructRetAttr() ||
DL->getTypeStoreSize(ArgTy->getPointerElementType()) > MaxOutArgSizeBytes) {
return false;
}
return checkArgumentUses(Arg);
}

如果以上条件都满足,则继续执行checkArgumentUses(Arg),代码如下:

1
2
3
4
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
bool AMDGPURewriteOutArguments::checkArgumentUses(Value &Arg) const {
const int MaxUses = 10;
int UseCount = 0;

for (Use &U : Arg.uses()) {
StoreInst *SI = dyn_cast<StoreInst>(U.getUser());
if (UseCount > MaxUses)
return false;

if (!SI) {
auto *BCI = dyn_cast<BitCastInst>(U.getUser());
if (!BCI || !BCI->hasOneUse())
return false;

Type *DestEltTy = BCI->getType()->getPointerElementType();
if (DestEltTy->isAggregateType())
return false;

Type *SrcEltTy = Arg.getType()->getPointerElementType();
if (SrcEltTy->isArrayTy())
return false;

if ((SrcEltTy->isStructTy() && (SrcEltTy->getNumContainedTypes() != 1)))
return false;

if (DL->getTypeAllocSize(SrcEltTy) != DL->getTypeAllocSize(DestEltTy))
return false;

return checkArgumentUses(*BCI);
}

if (!SI->isSimple() ||
U.getOperandNo() != StoreInst::getPointerOperandIndex())
return false;

++UseCount;
}

该方法遍历指针参数的use,根据参数的use判断是否可以对参数做结构返回优化。例如,%inuse%0 = load <32 x i8>, <32 x i8> addrspace(4)* %in%rusestore i32 %2, i32 addrspace(1)* %r, align 1。对指针参数的use依次做如下检查:

  • 判断指针参数是否被用作store指令的目的操作数,即判断是否向指针参数写入值,即写入返回值。只有在写入返回值时,对参数优化才有意义。
    • 如果store不是atomicstore的内存位置也不是volatile,即isSimple()为真,则UseCount递增。这表明为指针参数找到合格的use
  • 如果没有store指令将指针参数作为目的操作数,则进一步判断是否有将指针参数作为操作数的bitcast指令,因为源程序中其它地方有可能通过bitcast指令将指针参数做类型转换,然后向其中写入数据。但优化无法处理对同一指针参数做多次bitcast操作的情况,因此要求bitcast结果只有一个use(hasOneUse()),否则,视为不合格的use。接下来判断bitcast操作后的类型是否为聚合类型(isAggregateType())。聚合类型的数据可以作为insertvalue或extractvalue指令的第一个操作数,结构和数组类型都是聚合类型,但向量不是聚合类型。对指针参数做bitcast操作后的结果仍是指针参数,后续还会作为store的目的操作数。目前的优化实现不支持store到聚合类型目的操作数的情况。所以,如果bitcast操作后的类型是聚合类型,则视为不合格的use。类似地,目前的优化实现也不支持指针参数的单元类型为数组的情况(isArrayTy()),以及指针参数的单元类型为结构体(isStructTy())且结构体中有多于一种数据类型的情况(getNumContainedTypes()),这些都被视为不合格的use。

如果找到指针参数的合格use(如本例中的指针参数%r),则该指针参数有可能被优化为结构返回,将其保存在OutArgs向量中。在本例中,OutArgs向量中保存的是%r,其类型(getType())为llvm::Type::PointerTyID,其指针元素类型(getPointerElementType())为llvm::Type::IntegerTyID

接下来遍历IR中的ReturnInst(本例中为“ret i32 %add”),为每个ReturnInst所属的基本块调用内存依赖查询方法getPointerDependencyFrom()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (ReturnInst *RI : Returns) {
BasicBlock *BB = RI->getParent();

MemDepResult Q = MDA->getPointerDependencyFrom(MemoryLocation(OutArg),
true, BB->end(), BB, RI);
StoreInst *SI = nullptr;
if (Q.isDef())
SI = dyn_cast<StoreInst>(Q.getInst());

if (SI) {
LLVM_DEBUG(dbgs() << "Found out argument store: " << *SI << '\n');
ReplaceableStores.emplace_back(RI, SI);
} else {
ThisReplaceable = false;
break;
}
}

getPointerDependencyFrom()方法原型如下:

1
2
3
4
5
6
7
8
9
MemDepResult MemoryDependenceResults::getPointerDependencyFrom (
const MemoryLocation & Loc,
bool isLoad,
BasicBlock::iterator ScanIt,
BasicBlock * BB,
Instruction * QueryInst = nullptr,
unsigned * Limit = nullptr,
OrderedBasicBlock * OBB = nullptr
)

getPointerDependencyFrom()方法返回内存位置依赖的指令,该内存位置在参数中指定,如例子中的MemoryLocation(OutArg)表示OutArg指向的内存位置。getPointerDependencyFrom()方法的参数说明如下:

  • isLoad:如果isLoad为true,则getPointerDependencyFrom()方法忽略只读操作的may-alias别名。如果isLoad为false,则getPointerDependencyFrom()方法忽略只读位置读操作的may-alias别名;
  • ScanIt:遍历基本块时结束循环的条件,本例为BB->end(),即在基本块的最后指令处结束遍历;
  • BB:需检查依赖关系的基本块;
  • QueryInstQueryInst参数可帮助getPointerDependencyFrom()方法利用QueryInst的元数据来完善依赖分析结果;
  • LimitLimit参数用于设定需检查指针依赖的最大指令数。getPointerDependencyFrom()方法返回时,Limit又作为返回参数,设置为需要检查而未检查的指令数;
  • OBB:经过排序的基本块(Ordered Basic Block),OBB可快速查询基本块中两个指令之间的相对位置,AliasAnalysis :: callCapturesBefore()方法也会用到OBB。

getPointerDependencyFrom()方法的主要功能实现在MemoryDependenceResults::getSimplePointerDependencyFrom()中。

getPointerDependencyFrom()返回对象的类是MemDepResult

内存依赖查询方法的客户端可通过API获得查询结果。例如本例通过Q.isDef()判断查询结果是否为Def。本例中,OutArg%rQueryInst(即RI)为ret i32 %addgetPointerDependencyFrom()方法返回内存位置依赖的指令Q.getInst()store i32 %2, i32 addrspace(1)* %r, align 1。这条store指令的确与OutArg %r相关,而且这条store指令可被结构返回替换,因此将其连同QueryInst一起保存在ReplaceableStores向量中。

1
ReplaceableStores.emplace_back(RI, SI);

遍历基本块并将可替换store指令收集完毕,保存ReplaceableStores到后,接下来遍历ReplaceableStores

1
2
3
4
5
6
7
8
9
for (std::pair<ReturnInst *, StoreInst *> Store : ReplaceableStores) {
Value *ReplVal = Store.second->getValueOperand();

auto &ValVec = Replacements[Store.first]
......

ValVec.emplace_back(OutArg, ReplVal);
Store.second->eraseFromParent();
}

其中,ReplValstore指令的操作数(本例中为%r),ValVec是以ReturnInst为索引从Replacements中取得的ReplacementVec向量,该向量的单元是一对参数值<Argument *, Value *>。遍历ReplaceableStores的目的就是向ValVec中写入参数和值,即:

1
ValVec.emplace_back(OutArg, ReplVal);

在本例中OutArgi32 addrspace(1)* %rReplVal%2 = extractelement <8 x i32> %1, i32 1

然后将store指令从当前当前基本块中删除(eraseFromParent()),并将输出参数的类型ArgTy(本例中为IntegerTyID)保存在ReturnTypes向量中:

1
2
3
if (ThisReplaceable) {
ReturnTypes.push_back(ArgTy);

新生成的返回类型NewRetTy{ i32, i32 }

1
2
StructType *NewRetTy = StructType::create(Ctx, ReturnTypes, F.getName());

因为要将原IR方法的输出参数优化进返回结构,原方法发生变化,因此调用:

1
2
Function *NewFunc = Function::Create(NewFuncTy, Function::PrivateLinkage,
F.getName() + ".body");

生成新方法并添加到模块中,但要剥离所有返回属性。此时的IR方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
define private %test_mem_dep @test_mem_dep.body(<32 x i8> addrspace(4)* %in, 
i32 addrspace(1)* %r) #0 {
entry:
%0 = load <32 x i8>, <32 x i8> addrspace(4)* %in
%1 = bitcast <32 x i8> %0 to <8 x i32>
%2 = extractelement <8 x i32> %1, i32 1
%3 = icmp ne i32 %2, 0
%4 = select i1 %3, i32 0, i32 1
%5 = bitcast <32 x i8> addrspace(4)* %in to <8 x i32> addrspace(4)*
%6 = load <8 x i32>, <8 x i32> addrspace(4)* %5
%7 = extractelement <8 x i32> %6, i32 1
%8 = load <8 x i32>, <8 x i32> addrspace(4)* %5
%9 = extractelement <8 x i32> %8, i32 1
%10 = add nsw i32 %7, %4
%add = add nsw i32 %10, %9
ret i32 %add
}

从上述IR方法中可以看到,原IR方法中的store指令“store i32 %2, i32 addrspace(1)* %r, align 1”已经被删除。

经过insertvalue指令处理:

1
NewRetVal = B.CreateInsertValue(NewRetVal, RetVal, RetIdx++);

新的IR方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
; Function Attrs: nounwind
define private %test_mem_dep @test_mem_dep.body(<32 x i8> addrspace(4)* %in,
i32 addrspace(1)* %r) #0 {
entry:
%0 = load <32 x i8>, <32 x i8> addrspace(4)* %in
%1 = bitcast <32 x i8> %0 to <8 x i32>
%2 = extractelement <8 x i32> %1, i32 1
%3 = icmp ne i32 %2, 0
%4 = select i1 %3, i32 0, i32 1
%5 = bitcast <32 x i8> addrspace(4)* %in to <8 x i32> addrspace(4)*
%6 = load <8 x i32>, <8 x i32> addrspace(4)* %5
%7 = extractelement <8 x i32> %6, i32 1
%8 = load <8 x i32>, <8 x i32> addrspace(4)* %5
%9 = extractelement <8 x i32> %8, i32 1
%10 = add nsw i32 %7, %4
%add = add nsw i32 %10, %9
%11 = insertvalue %test_mem_dep undef, i32 %add, 0
%12 = insertvalue %test_mem_dep %11, i32 %2, 1
ret %test_mem_dep %12
}

在经过返回结构优化后的IR方法中,有两条用到insertvalue指令的语句:

1
2
%11 = insertvalue %test_mem_dep undef, i32 %add, 0
%12 = insertvalue %test_mem_dep %11, i32 %2, 1

第一条语句的目的是向undef的结构%test_mem_dep的第一个成员字段中插入i32类型的值%add。第一条语句继续向结构%test_mem_dep的第二个成员字段中插入i32类型的值%2。最后得到:

1
%test_mem_dep %12 = {i32 %add, i32 %2}

%test_mem_dep %12就是优化后的返回结构。

insertvalue指令语法:

1
<result> = insertvalue <aggregate type> <val>, <ty> <elt>, <idx>{, <idx>}* ; yields <aggregate type>

insertvalue指令将某个值插入到另一个聚合值(aggregate value)的成员字段中。insertvalue指令的第一个操作数是一个结构或数组,第二个操作数是要插入的值。接下来的操作数是常量索引,表示插入值的位置,要插入的值必须与索引所标识的值具有相同的类型。

例如:

1
%agg1 = insertvalue {i32, float} undef, i32 1, 0

其中,结构{i32, float}是指令的第一个操作数。第二个操作数“i32 1”表示是要插入的值为1,类型为i32。第三个操作数0表示将“i32 1”插入结构的第一个成员字段,成员字段的类型与要插入的类型都为i32。操作完成后的结果为{i32 1, float undef}。

再例如:

1
%agg2 = insertvalue {i32, float} %agg1, float %val, 1

上述语句的目的是将loat %val插入结构{i32, float} %agg1的第二个成员字段,结果为{i32 1, float %val}

使用objdump分析core堆栈

使用c++编程的同学,经常会遇到诸如内存越界、重复释放等内存问题,大家比较习惯的追查这类问题的方式是,打开core文件的limit,生成core文件,用gdb进行分析; 但是,在实际的生产环境中。由于程序本省占用内存非常大,比如搜索的索引服务,进行core的dump不太现实,所以一般采用,在程序中捕获信号,之后打印进程的堆栈信息,再进行追查。 下面本文,就按照这种方式进行追查,首先,分析没有so的程序如何使用objdump与汇编进行分析程序的问题所在;接着分析有so的程序,如何使用objdump进行分析,希望对大家能有所帮助。

普通程序的core分析

1
2
3
4
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
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <execinfo.h>

static void print_stack_fs(int sig, FILE * output)
{
fprintf(output, "--------------------------------------\n");

char pTime[256];
//getSafeNow(pTime, 256);
fprintf(output, "[%s] received signal=%d, thread_id=%ld\n",
"now", sig, getpid());

void *array[128]; // 128 stacks at most
size_t size = backtrace(array, sizeof(array) / sizeof(array[0]));
if (size > 0 && size < 128) {
char ** stackLog = backtrace_symbols(array, size);
if(stackLog) {
for (size_t i = 0; i < size; i++) {
fprintf(output,"%s\n", stackLog[i]);
}
fflush(output);
free(stackLog);
}
}
}

static void sig_handler(int signo)
{
if (signo == SIGSEGV ||
signo == SIGBUS ||
signo == SIGABRT ||
signo == SIGFPE) {

print_stack_fs(signo, stderr);

exit(-1);
}
else if (signo == SIGTERM || signo == SIGINT) {
exit(-1);
}
}

static void sig_register()
{
struct sigaction sigac;
sigemptyset(&sigac.sa_mask);
sigac.sa_handler = sig_handler;
sigac.sa_flags = 0;

sigaction(SIGTERM, &sigac, 0);
sigaction(SIGINT , &sigac, 0);
sigaction(SIGQUIT, &sigac, 0);
sigaction(SIGPIPE, &sigac, 0);
sigaction(SIGBUS , &sigac, 0);
sigaction(SIGABRT, &sigac, 0);
sigaction(SIGFPE , &sigac, 0);
sigaction(SIGSEGV, &sigac, 0);
}



int main(int argc, char *argv[])
{
sig_register();
int a = 10, b = -2, c = 100;
char * pstr = 0x00;
int d = 100;
*pstr = 0x00;
return 0;
}

执行程序

关键地址:0x400add,指向出错的代码的具体的虚拟空间地址

1
2
3
4
5
6
7
[now] received signal=11, thread_id=1852
./a.out() [0x4008ab]
./a.out() [0x400985]
/lib64/libc.so.6(+0x362f0) [0x7fbc41a3d2f0]
./a.out() [0x400add]
/lib64/libc.so.6(__libc_start_main+0xf5) [0x7fbc41a29445]
./a.out() [0x400769]

使用objdump分析

objdump -d a.out,分析-0x18(%rbp)的地址是变量pstr的地址,之后将pstr的放置到寄存器rax赋值,之后没有申请内存的空指针进行赋值出core,具体请看下面的汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
321 0000000000400aa1 <main>:
322 400aa1: 55 push %rbp
323 400aa2: 48 89 e5 mov %rsp,%rbp
324 400aa5: 48 83 ec 30 sub $0x30,%rsp
325 400aa9: 89 7d dc mov %edi,-0x24(%rbp)
326 400aac: 48 89 75 d0 mov %rsi,-0x30(%rbp)
327 400ab0: e8 f2 fe ff ff callq 4009a7 <_ZL12sig_registerv>
328 400ab5: c7 45 fc 0a 00 00 00 movl $0xa,-0x4(%rbp) // 变量a
329 400abc: c7 45 f8 fe ff ff ff movl $0xfffffffe,-0x8(%rbp) // 变量b
330 400ac3: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp) // 变量c
331 400aca: 48 c7 45 e8 00 00 00 movq $0x0,-0x18(%rbp) // 变量 pstr
332 400ad1: 00
333 400ad2: c7 45 e4 64 00 00 00 movl $0x64,-0x1c(%rbp) // 变量d
334 400ad9: 48 8b 45 e8 mov -0x18(%rbp),%rax // 将变量pstr放到rax寄存器
335 400add: c6 00 00 movb $0x0,(%rax) // 对pstr赋值,也就是对空指针赋值,找到问题
336 400ae0: b8 00 00 00 00 mov $0x0,%eax
337 400ae5: c9 leaveq
338 400ae6: c3 retq
339 400ae7: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1)

core在so里面的objdump分析

max.h

1
2
3
4
5
6
#ifndef __MAX_H__
#define __MAX_H__

int max(int n1, int n2, int n3);

#endif

max.cpp

1
2
3
4
5
6
7
8
9
10
11
#include "max.h"

int max(int n1, int n2, int n3)
{
int max_num = n1;
max_num = max_num < n2? n2: max_num;
max_num = max_num < n3? n3: max_num;
char * pstr = 0x00;
*pstr = 0x00;
return max_num;
}

test.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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <execinfo.h>
#include "max.h"

static void print_stack_fs(int sig, FILE * output)
{
fprintf(output, "--------------------------------------\n");

char pTime[256];
//getSafeNow(pTime, 256);
fprintf(output, "[%s] received signal=%d, thread_id=%ld\n",
"now", sig, getpid());

void *array[128]; // 128 stacks at most
size_t size = backtrace(array, sizeof(array) / sizeof(array[0]));
if (size > 0 && size < 128) {
char ** stackLog = backtrace_symbols(array, size);
if(stackLog) {
for (size_t i = 0; i < size; i++) {
fprintf(output,"%s\n", stackLog[i]);
}
fflush(output);
free(stackLog);
}
}
}

static void sig_handler(int signo)
{
if (signo == SIGSEGV ||
signo == SIGBUS ||
signo == SIGABRT ||
signo == SIGFPE) {

print_stack_fs(signo, stderr);

exit(-1);
}
else if (signo == SIGTERM || signo == SIGINT) {
exit(-1);
}
}

static void sig_register()
{
struct sigaction sigac;
sigemptyset(&sigac.sa_mask);
sigac.sa_handler = sig_handler;
sigac.sa_flags = 0;

sigaction(SIGTERM, &sigac, 0);
sigaction(SIGINT , &sigac, 0);
sigaction(SIGQUIT, &sigac, 0);
sigaction(SIGPIPE, &sigac, 0);
sigaction(SIGBUS , &sigac, 0);
sigaction(SIGABRT, &sigac, 0);
sigaction(SIGFPE , &sigac, 0);
sigaction(SIGSEGV, &sigac, 0);
}



int main(int argc, char *argv[])
{
sig_register();
int a = 10, b = -2, c = 100;
int d = 100;
printf("max among 10, -2 and 100 is %d.\n", max(a, b, c));
return 0;
}

运行程序

关键地址:./libmax.so(_Z3maxiii+0x45) [0x7fb914d6868a]

1
2
3
4
5
6
7
8
[now] received signal=11, thread_id=1893
./a.out() [0x4009fb]
./a.out() [0x400ad5]
/lib64/libc.so.6(+0x362f0) [0x7fb9141b12f0]
./libmax.so(_Z3maxiii+0x45) [0x7fb914d6868a]
./a.out() [0x400c33]
/lib64/libc.so.6(__libc_start_main+0xf5) [0x7fb91419d445]
./a.out() [0x4008b9]

objdump

针对so进行反编译,运行 objdump -d libmax.so,然后找搭配_Z3maxiii,地址是645,然后+上0x45,得到地址 68A 汇编代码:movq $0x0,-0x10(%rbp) 定义pstr,68A的地址同样是对未申请内存的地址进行赋值出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
106 0000000000000645 <_Z3maxiii>:
107 645: 55 push %rbp
108 646: 48 89 e5 mov %rsp,%rbp
109 649: 89 7d ec mov %edi,-0x14(%rbp) // 参数1
110 64c: 89 75 e8 mov %esi,-0x18(%rbp) // 参数2
111 64f: 89 55 e4 mov %edx,-0x1c(%rbp) // 参数3
112 652: 8b 45 ec mov -0x14(%rbp),%eax
113 655: 89 45 fc mov %eax,-0x4(%rbp)
114 658: 8b 45 fc mov -0x4(%rbp),%eax
115 65b: 3b 45 e8 cmp -0x18(%rbp),%eax
116 65e: 7d 05 jge 665 <_Z3maxiii+0x20>
117 660: 8b 45 e8 mov -0x18(%rbp),%eax
118 663: eb 03 jmp 668 <_Z3maxiii+0x23>
119 665: 8b 45 fc mov -0x4(%rbp),%eax
120 668: 89 45 fc mov %eax,-0x4(%rbp)
121 66b: 8b 45 fc mov -0x4(%rbp),%eax
122 66e: 3b 45 e4 cmp -0x1c(%rbp),%eax
123 671: 7d 05 jge 678 <_Z3maxiii+0x33>
124 673: 8b 45 e4 mov -0x1c(%rbp),%eax
125 676: eb 03 jmp 67b <_Z3maxiii+0x36>
126 678: 8b 45 fc mov -0x4(%rbp),%eax
127 67b: 89 45 fc mov %eax,-0x4(%rbp)
128 67e: 48 c7 45 f0 00 00 00 movq $0x0,-0x10(%rbp) // pstr
129 685: 00
130 686: 48 8b 45 f0 mov -0x10(%rbp),%rax
131 68a: c6 00 00 movb $0x0,(%rax) // 对pstr赋值0,这个就是问题所在了
132 68d: 8b 45 fc mov -0x4(%rbp),%eax
133 690: 5d pop %rbp

使用addr2line定位问题的行数

1
2
3
[dubaokun@localhost so]$ addr2line -e libmax.so -ifC 68a
max(int, int, int)
/home/dubaokun/github/code/engine_code/compile/objdump/so/max.cpp:9 (discriminator 3)

总结

以上的程序较为简单,实际工作中的程序较为复杂,但是复杂都是由基础而来的,大家可以认真思考、仔细研究,对于汇编代码要有一定的理解。

编译工具的选择

对于编译工具自身的选择,在假定使用 Binutils 和 GCC 以及 Make 的前提下,没什么好说的,基本上新版本都能带来性能提升,同时比老版本对新硬件的支持更好,所以应当尽量选用新版本。不过追新也可能带来系统的不稳定,这就要针对实际情况进行权衡了。本文以 Binutils-2.18 和 GCC-4.2.2/GCC-4.3.0 以及 Make-3.81 为例进行说明。

configure 选项

这里我们只讲解通用的”体系结构选项”,由于”特性选项”在每个软件包之间千差万别,所以不可能在此处进行讲解。

这部分内容很简单,并且其含义也是不言而喻的,下面只列出常用的值:

  • i586-pc-linux-gnu
  • i686-pc-linux-gnu
  • x86_64-pc-linux-gnu
  • powerpc-unknown-linux-gnu
  • powerpc64-unknown-linux-gnu

如果你实在不知道应当使用哪一个,那么就干脆不使用这几个选项,让 config.guess 脚本自己去猜吧,反正也挺准的。

编译选项

让我们先看看 Makefile 规则中的编译命令通常是怎么写的。大多数软件包遵守如下约定俗成的规范:

  1. 首先从源代码生成目标文件(预处理,编译,汇编),”-c”选项表示不执行链接步骤。

    1
    $(CC) $(CPPFLAGS) $(CFLAGS) example.c   -c   -o example.o
  2. 然后将目标文件连接为最终的结果(连接),”-o”选项用于指定输出文件的名字。

    1
    $(CC) $(LDFLAGS) example.o   -o example

有一些软件包一次完成四个步骤:

1
$(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) example.c   -o example

当然也有少数软件包不遵守这些约定俗成的规范,比如:

  1. 有些在命令行中漏掉应有的Makefile变量(注意:有些遗漏是故意的)

    1
    2
    3
    4
    $(CC) $(CFLAGS) example.c    -c   -o example.o
    $(CC) $(CPPFLAGS) example.c -c -o example.o
    $(CC) example.o -o example
    $(CC) example.c -o example
  2. 有些在命令行中增加了不必要的Makefile变量

    1
    2
    $(CC) $(CFLAGS) $(LDFLAGS) example.o   -o example
    $(CC) $(CPPFLAGS) $(CFLAGS) $(LDFLAGS) example.c -c -o example.o

尽管将源代码编译为二进制文件的四个步骤由不同的程序(cpp,gcc/g++,as,ld)完成,但是事实上 cpp, as, ld 都是由 gcc/g++ 进行间接调用的。换句话说,控制了 gcc/g++ 就等于控制了所有四个步骤。从 Makefile 规则中的编译命令可以看出,编译工具的行为全靠 CC/CXX CPPFLAGS CFLAGS/CXXFLAGS LDFLAGS 这几个变量在控制。当然理论上控制编译工具行为的还应当有 AS ASFLAGS ARFLAGS 等变量,但是实践中基本上没有软件包使用它们。

那么我们如何控制这些变量呢?一种简易的做法是首先设置与这些 Makefile 变量同名的环境变量并将它们 export 为全局,然后运行 configure 脚本,大多数 configure 脚本会使用这同名的环境变量代替 Makefile 中的值。但是少数 configure 脚本并不这样做(比如GCC-3.4.6和Binutils-2.16.1的脚本就不传递LDFLAGS),你必须手动编辑生成的 Makefile 文件,在其中寻找这些变量并修改它们的值,许多源码包在每个子文件夹中都有 Makefile 文件,真是一件很累人的事!

CC 与 CXX

这是 C 与 C++ 编译器命令。默认值一般是 “gcc” 与 “g++”。这个变量本来与优化没有关系,但是有些人因为担心软件包不遵守那些约定俗成的规范,害怕自己苦心设置的 CFLAGS/CXXFLAGS/LDFLAGS 之类的变量被忽略了,而索性将原本应当放置在其它变量中的选项一股老儿塞到 CC 或 CXX 中,比如:CC=”gcc -march=k8 -O2 -s”。这是一种怪异的用法,本文不提倡这种做法,而是提倡按照变量本来的含义使用变量。

CPPFLAGS

这是用于预处理阶段的选项。不过能够用于此变量的选项,看不出有哪个与优化相关。如果你实在想设一个,那就使用下面这两个吧:

  • -DNDEBUG:”NDEBUG”是一个标准的 ANSI 宏,表示不进行调试编译。
  • -D_FILE_OFFSET_BITS=64:大多数包使用这个来提供大文件(>2G)支持。

CFLAGS 与 CXXFLAGS

CFLAGS 表示用于 C 编译器的选项,CXXFLAGS 表示用于 C++ 编译器的选项。这两个变量实际上涵盖了编译和汇编两个步骤。大多数程序和库在编译时默认的优化级别是”2”(使用”-O2”选项)并且带有调试符号来编译,也就是 CFLAGS=”-O2 -g”, CXXFLAGS=$CFLAGS 。事实上,”-O2”已经启用绝大多数安全的优化选项了。另一方面,由于大部分选项可以同时用于这两个变量,所以仅在最后讲述只能用于其中一个变量的选项。

先说说”-O3”在”-O2”基础上增加的几项:

  • -finline-functions:允许编译器选择某些简单的函数在其被调用处展开,比较安全的选项,特别是在CPU二级缓存较大时建议使用。
  • -funswitch-loops:将循环体中不改变值的变量移动到循环体之外。
  • -fgcse-after-reload:为了清除多余的溢出,在重载之后执行一个额外的载入消除步骤。

另外:

  • -fomit-frame-pointer:对于不需要栈指针的函数就不在寄存器中保存指针,因此可以忽略存储和检索地址的代码,同时对许多函数提供一个额外的寄存器。所有”-O”级别都打开它,但仅在调试器可以不依靠栈指针运行时才有效。在AMD64平台上此选项默认打开,但是在x86平台上则默认关闭。建议显式的设置它。
  • -falign-functions=N
  • -falign-jumps=N
  • -falign-loops=N
  • -falign-labels=N:这四个对齐选项在”-O2”中打开,其中的根据不同的平台N使用不同的默认值。如果你想指定不同于默认值的N,也可以单独指定。比如,对于L2-cache>=1M的cpu而言,指定 -falign-functions=64 可能会获得更好的性能。建议在指定了 -march 的时候不明确指定这里的值。

调试选项:

-fprofile-arcs
在使用这一选项编译程序并运行它以创建包含每个代码块的执行次数的文件后,程序可以再次使用 -fbranch-probabilities 编译,文件中的信息可以用来优化那些经常选取的分支。如果没有这些信息,gcc将猜测哪个分支将被经常运行以进行优化。这类优化信息将会存放在一个以源文件为名字的并以”.da”为后缀的文件中。
全局选项:

-pipe
在编译过程的不同阶段之间使用管道而非临时文件进行通信,可以加快编译速度。建议使用。
目录选项:

—sysroot=dir
将dir作为逻辑根目录。比如编译器通常会在 /usr/include 和 /usr/lib 中搜索头文件和库,使用这个选项后将在 dir/usr/include 和 dir/usr/lib 目录中搜索。如果使用这个选项的同时又使用了 -isysroot 选项,则此选项仅作用于库文件的搜索路径,而 -isysroot 选项将作用于头文件的搜索路径。这个选项与优化无关,但是在 CLFS 中有着神奇的作用。
代码生成选项:

-fno-bounds-check
关闭所有对数组访问的边界检查。该选项将提高数组索引的性能,但当超出数组边界时,可能会造成不可接受的行为。

-freg-struct-return
如果struct和union足够小就通过寄存器返回,这将提高较小结构的效率。如果不够小,无法容纳在一个寄存器中,将使用内存返回。建议仅在完全使用GCC编译的系统上才使用。

-fpic
生成可用于共享库的位置独立代码。所有的内部寻址均通过全局偏移表完成。要确定一个地址,需要将代码自身的内存位置作为表中一项插入。该选项产生可以在共享库中存放并从中加载的目标模块。

-fstack-check
为防止程序栈溢出而进行必要的检测,仅在多线程环境中运行时才可能需要它。

-fvisibility=hidden
设置默认的ELF镜像中符号的可见性为隐藏。使用这个特性可以非常充分的提高连接和加载共享库的性能,生成更加优化的代码,提供近乎完美的API输出和防止符号碰撞。我们强烈建议你在编译任何共享库的时候使用该选项。参见 -fvisibility-inlines-hidden 选项。
硬件体系结构相关选项[仅仅针对x86与x86_64]:

-march=cpu-type
为特定的cpu-type编译二进制代码(不能在更低级别的cpu上运行)。Intel可以用:pentium2, pentium3(=pentium3m), pentium4(=pentium4m), pentium-m, prescott, nocona, core2(GCC-4.3新增) 。AMD可以用:k6-2(=k6-3), athlon(=athlon-tbird), athlon-xp(=athlon-mp), k8(=opteron=athlon64=athlon-fx)

-mfpmath=sse
P3和athlon-xp级别及以上的cpu支持”sse”标量浮点指令。仅建议在P4和K8以上级别的处理器上使用该选项。

-malign-double
将double, long double, long long对齐于双字节边界上;有助于生成更高速的代码,但是程序的尺寸会变大,并且不能与未使用该选项编译的程序一起工作。

-m128bit-long-double
指定long double为128位,pentium以上的cpu更喜欢这种标准,并且符合x86-64的ABI标准,但是却不附合i386的ABI标准。

-mregparm=N
指定用于传递整数参数的寄存器数目(默认不使用寄存器)。0<=N<=3 ;注意:当N>0时你必须使用同一参数重新构建所有的模块,包括所有的库。

-msseregparm
使用SSE寄存器传递float和double参数和返回值。注意:当你使用了这个选项以后,你必须使用同一参数重新构建所有的模块,包括所有的库。

  • -mmmx
  • -msse
  • -msse2
  • -msse3
  • -m3dnow
  • -mssse3(没写错!GCC-4.3新增)
  • -msse4.1(GCC-4.3新增)
  • -msse4.2(GCC-4.3新增)
  • -msse4(含4.1和4.2,GCC-4.3新增)
    是否使用相应的扩展指令集以及内置函数,按照自己的cpu选择吧!

-maccumulate-outgoing-args
指定在函数引导段中计算输出参数所需最大空间,这在大部分现代cpu中是较快的方法;缺点是会明显增加二进制文件尺寸。

-mthreads
支持Mingw32的线程安全异常处理。对于依赖于线程安全异常处理的程序,必须启用这个选项。使用这个选项时会定义”-D_MT”,它将包含使用选项”-lmingwthrd”连接的一个特殊的线程辅助库,用于为每个线程清理异常处理数据。

-minline-all-stringops
默认时GCC只将确定目的地会被对齐在至少4字节边界的字符串操作内联进程序代码。该选项启用更多的内联并且增加二进制文件的体积,但是可以提升依赖于高速 memcpy, strlen, memset 操作的程序的性能。

-minline-stringops-dynamically
GCC-4.3新增。对未知尺寸字符串的小块操作使用内联代码,而对大块操作仍然调用库函数,这是比”-minline-all-stringops”更聪明的策略。决定策略的算法可以通过”-mstringop-strategy”控制。

-momit-leaf-frame-pointer
不为叶子函数在寄存器中保存栈指针,这样可以节省寄存器,但是将会使调试变的困难。注意:不要与 -fomit-frame-pointer 同时使用,因为会造成代码效率低下。

-m64
生成专门运行于64位环境的代码,不能运行于32位环境,仅用于x86_64[含EMT64]环境。

-mcmodel=small
[默认值]程序和它的符号必须位于2GB以下的地址空间。指针仍然是64位。程序可以静态连接也可以动态连接。仅用于x86_64[含EMT64]环境。

-mcmodel=kernel
内核运行于2GB地址空间之外。在编译linux内核时必须使用该选项!仅用于x86_64[含EMT64]环境。

-mcmodel=medium
程序必须位于2GB以下的地址空间,但是它的符号可以位于任何地址空间。程序可以静态连接也可以动态连接。注意:共享库不能使用这个选项编译!仅用于x86_64[含EMT64]环境。

-fforce-addr
必须将地址复制到寄存器中才能对他们进行运算。由于所需地址通常在前面已经加载到寄存器中了,所以这个选项可以改进代码。

-finline-limit=n
对伪指令数超过n的函数,编译程序将不进行内联展开,默认为600。增大此值将增加编译时间和编译内存用量并且生成的二进制文件体积也会变大,此值不宜太大。

-fmerge-all-constants
试图将跨编译单元的所有常量值和数组合并在一个副本中。但是标准C/C++要求每个变量都必须有不同的存储位置,所以该选项可能会导致某些不兼容的行为。

-fgcse-sm
在全局公共子表达式消除之后运行存储移动,以试图将存储移出循环。gcc-3.4中曾属于”-O2”级别的选项。

-fgcse-las
在全局公共子表达式消除之后消除多余的在存储到同一存储区域之后的加载操作。gcc-3.4中曾属于”-O2”级别的选项。

-floop-optimize
已废除(GCC-4.1曾包含在”-O1”中)。

-floop-optimize2
使用改进版本的循环优化器代替原来”-floop-optimize”。该优化器将使用不同的选项(-funroll-loops, -fpeel-loops, -funswitch-loops, -ftree-loop-im)分别控制循环优化的不同方面。目前这个新版本的优化器尚在开发中,并且生成的代码质量并不比以前的版本高。已废除,仅存在于GCC-4.1之前的版本中。

-funsafe-loop-optimizations
假定循环不会溢出,并且循环的退出条件不是无穷。这将可以在一个比较广的范围内进行循环优化,即使优化器自己也不能断定这样做是否正确。

-fsched-spec-load
允许一些装载指令执行一些投机性的动作。

-ftree-loop-linear
在trees上进行线型循环转换。它能够改进缓冲性能并且允许进行更进一步的循环优化。

-fivopts
在trees上执行归纳变量优化。

-ftree-vectorize
在trees上执行循环向量化。

-ftracer
执行尾部复制以扩大超级块的尺寸,它简化了函数控制流,从而允许其它的优化措施做的更好。据说挺有效。

-funroll-loops
仅对循环次数能够在编译时或运行时确定的循环进行展开,生成的代码尺寸将变大,执行速度可能变快也可能变慢。

-fprefetch-loop-arrays
生成数组预读取指令,对于使用巨大数组的程序可以加快代码执行速度,适合数据库相关的大型软件等。具体效果如何取决于代码。

-fweb
建立经常使用的缓存器网络,提供更佳的缓存器使用率。gcc-3.4中曾属于”-O3”级别的选项。

-ffast-math
违反IEEE/ANSI标准以提高浮点数计算速度,是个危险的选项,仅在编译不需要严格遵守IEEE规范且浮点计算密集的程序考虑采用。

-fsingle-precision-constant
将浮点常量作为单精度常量对待,而不是隐式地将其转换为双精度。

-fbranch-probabilities
在使用 -fprofile-arcs 选项编译程序并执行它来创建包含每个代码块执行次数的文件之后,程序可以利用这一选项再次编译,文件中所产生的信息将被用来优化那些经常发生的分支代码。如果没有这些信息,gcc将猜测那一分支可能经常发生并进行优化。这类优化信息将会存放在一个以源文件为名字的并以”.da”为后缀的文件中。

-frename-registers
试图驱除代码中的假依赖关系,这个选项对具有大量寄存器的机器很有效。gcc-3.4中曾属于”-O3”级别的选项。

  • -fbranch-target-load-optimize
  • -fbranch-target-load-optimize2
    在执行序启动以及结尾之前执行分支目标缓存器加载最佳化。

-fstack-protector
在关键函数的堆栈中设置保护值。在返回地址和返回值之前,都将验证这个保护值。如果出现了缓冲区溢出,保护值不再匹配,程序就会退出。程序每次运行,保护值都是随机的,因此不会被远程猜出。

-fstack-protector-all
同上,但是在所有函数的堆栈中设置保护值。

—param max-gcse-memory=xxM
执行GCSE优化使用的最大内存量(xxM),太小将使该优化无法进行,默认为50M。

—param max-gcse-passes=n
执行GCSE优化的最大迭代次数,默认为 1。
传递给汇编器的选项:

-Wa,options
options是一个或多个由逗号分隔的可以传递给汇编器的选项列表。其中的每一个均可作为命令行选项传递给汇编器。

-Wa,—strip-local-absolute
从输出符号表中移除局部绝对符号。

-Wa,-R
合并数据段和正文段,因为不必在数据段和代码段之间转移,所以它可能会产生更短的地址移动。

-Wa,—64
设置字长为64bit,仅用于x86_64,并且仅对ELF格式的目标文件有效。此外,还需要使用”—enable-64-bit-bfd”选项编译的BFD支持。

-Wa,-march=CPU
按照特定的CPU进行优化:pentiumiii, pentium4, prescott, nocona, core, core2; athlon, sledgehammer, opteron, k8 。
仅可用于 CFLAGS 的选项:

-fhosted
按宿主环境编译,其中需要有完整的标准库,入口必须是main()函数且具有int型的返回值。内核以外几乎所有的程序都是如此。该选项隐含设置了 -fbuiltin,且与 -fno-freestanding 等价。

-ffreestanding
按独立环境编译,该环境可以没有标准库,且对main()函数没有要求。最典型的例子就是操作系统内核。该选项隐含设置了 -fno-builtin,且与 -fno-hosted 等价。
仅可用于 CXXFLAGS 的选项:

-fno-enforce-eh-specs
C++标准要求强制检查异常违例,但是该选项可以关闭违例检查,从而减小生成代码的体积。该选项类似于定义了”NDEBUG”宏。

-fno-rtti
如果没有使用’dynamic_cast’和’typeid’,可以使用这个选项禁止为包含虚方法的类生成运行时表示代码,从而节约空间。此选项对于异常处理无效(仍然按需生成rtti代码)。

-ftemplate-depth-n
将最大模版实例化深度设为’n’,符合标准的程序不能超过17,默认值为500。

-fno-optional-diags
禁止输出诊断消息,C++标准并不需要这些消息。

-fno-threadsafe-statics
GCC自动在访问C++局部静态变量的代码上加锁,以保证线程安全。如果你不需要线程安全,可以使用这个选项。

-fvisibility-inlines-hidden
默认隐藏所有内联函数,从而减小导出符号表的大小,既能缩减文件的大小,还能提高运行性能,我们强烈建议你在编译任何共享库的时候使用该选项。参见 -fvisibility=hidden 选项。

LDFLAGS

LDFLAGS 是传递给连接器的选项。这是一个常被忽视的变量,事实上它对优化的影响也是很明显的。

-s
删除可执行程序中的所有符号表和所有重定位信息。其结果与运行命令 strip 所达到的效果相同,这个选项是比较安全的。

-Wl,options
options是由一个或多个逗号分隔的传递给链接器的选项列表。其中的每一个选项均会作为命令行选项提供给链接器。

-Wl,-On
当n>0时将会优化输出,但是会明显增加连接操作的时间,这个选项是比较安全的。

-Wl,—exclude-libs=ALL
不自动导出库中的符号,也就是默认将库中的符号隐藏。

-Wl,-m
仿真连接器,当前ld所有可用的仿真可以通过”ld -V”命令获取。默认值取决于ld的编译时配置。

-Wl,—sort-common
把全局公共符号按照大小排序后放到适当的输出节,以防止符号间因为排布限制而出现间隙。

-Wl,-x
删除所有的本地符号。

-Wl,-X
删除所有的临时本地符号。对于大多数目标平台,就是所有的名字以’L’开头的本地符号。

-Wl,-zcomberloc
组合多个重定位节并重新排布它们,以便让动态符号可以被缓存。

-Wl,—enable-new-dtags
在ELF中创建新式的”dynamic tags”,但在老式的ELF系统上无法识别。

-Wl,—as-needed
移除不必要的符号引用,仅在实际需要的时候才连接,可以生成更高效的代码。

-Wl,—no-define-common
限制对普通符号的地址分配。该选项允许那些从共享库中引用的普通符号只在主程序中被分配地址。这会消除在共享库中的无用的副本的空间,同时也防止了在有多个指定了搜索路径的动态模块在进行运行时符号解析时引起的混乱。

-Wl,—hash-style=gnu
使用gnu风格的符号散列表格式。它的动态链接性能比传统的sysv风格(默认)有较大提升,但是它生成的可执行程序和库与旧的Glibc以及动态链接器不兼容。
最后说两个与优化无关的系统环境变量,因为会影响GCC编译程序的方式,下面两个是咱中国人比较关心的:

LANG

指定编译程序使用的字符集,可用于创建宽字符文件、串文字、注释;默认为英文。[目前只支持日文”C-JIS,C-SJIS,C-EUCJP”,不支持中文]

LC_ALL
指定多字节字符的字符分类,主要用于确定字符串的字符边界以及编译程序使用何种语言发出诊断消息;默认设置与LANG相同。中文相关的几项:”zh_CN.GB2312 , zh_CN.GB18030 , zh_CN.GBK , zh_CN.UTF-8 , zh_TW.BIG5”。


title: GCC编译参数
date: 2019-04-23 22:41:39
tags:

- Linux

GNU CC(简称gcc)是GNU项目中符合ANSI C标准的编译系统,能够编译用C、C++、Object C、Jave等多种语言编写的程序。gcc又可以作为交叉编译工具,它能够在当前CPU平台上为多种不同体系结构的硬件平台开发软件,非常适合在嵌入式领域的开发编译,如常用的arm-linux-gcc交叉编译工具

通常后跟一些选项和文件名来使用 GCC 编译器。gcc 命令的基本用法如下:

gcc [options] [filenames]

选项指定编译器怎样进行编译。

gcc 编译流程

预处理-Pre-Processing

gcc -E test.c -o test.i //.i文件

编译-Compiling

gcc -S test.i -o test.s //.s文件

汇编-Assembling //.o文件

gcc -c test.s -o test.o

链接-Linking //bin文件

gcc test.o -o test

gcc工程惯用

编译

gcc -c test.c //.o文件,汇编

gcc -o test test.c //bin可执行文件

gcc test.c //a.out可执行文件

如果是c++ 直接将gcc改为g++即可。

常用参数

  1. -E参数 选项指示编译器仅对输入文件进行预处理。当这个选项被使用时, 预处理器的输出被送到标准输出而不是储存在文件里.
  2. -S参数 编译选项告诉 GCC 在为 C 代码产生了汇编语言文件后停止编译。 GCC 产生的汇编语言文件的缺省扩展名是 .s 。
  3. -c参数 选项告诉 GCC 仅把源代码编译为目标代码。缺省时 GCC 建立的目标代码文件有一个 .o 的扩展名。
  4. -o参数 编译选项来为将产生的可执行文件用指定的文件名。
  5. -O参数 选项告诉 GCC 对源代码进行基本优化。这些优化在大多数情况下都会使程序执行的更快。 -O2 选项告诉GCC 产生尽可能小和尽可能快的代码。 如-O2,-O3,-On(n 常为0—3);-O 主要进行跳转和延迟退栈两种优化;-O2 除了完成-O1的优化之外,还进行一些额外的调整工作,如指令调整等。-O3 则包括循环展开和其他一些与处理特性相关的优化工作。选项将使编译的速度比使用 -O 时慢, 但通常产生的代码执行速度会更快。
  6. 调试选项-g和-pg。-g 选项告诉 GCC 产生能被 GNU 调试器使用的调试信息以便调试你的程序。GCC 提供了一个很多其他 C 编译器里没有的特性, 在 GCC 里你能使-g 和 -O (产生优化代码)联用。-pg 选项告诉 GCC 在编译好的程序里加入额外的代码。运行程序时, 产生 gprof 用的剖析信息以显示你的程序的耗时情况。
  7. -l参数和-L参数。-l参数就是用来指定程序要链接的库,-l参数紧接着就是库名,那么库名跟真正的库文件名有什么关系呢?就拿数学库来说,他的库名是m,他的库文件名是libm.so,很容易看出,把库文件名的头lib和尾.so去掉就是库名了。如:gcc xxx.c -lm( 动态数学库) -lpthread

好了现在我们知道怎么得到库名了,比如我们自已要用到一个第三方提供的库名字叫libtest.so,那么我们只要把libtest.so拷贝到 /usr/lib里,编译时加上-ltest参数,我们就能用上libtest.so库了(当然要用libtest.so库里的函数,我们还需要与 libtest.so配套的头文件)。放在/lib和/usr/lib和/usr/local/lib里的库直接用-l参数就能链接了,但如果库文件没放在这三个目录里,而是放在其他目录里, 这时我们只用-l参数的话,链接还是会出错,出错信息大概是:“/usr/bin/ld: cannot find-lxxx”,也就是链接 程序ld在那3个目录里找不到libxxx.so,这时另外一个参数-L就派上用场了,比如常用的X11的库,它放在/usr/X11R6/lib目录 下,我们编译时就要用-L/usr/X11R6/lib -lX11参数,-L参数跟着的是库文件所在的目录名。再比如我们把libtest.so放在/aaa/bbb/ccc目录下,那链接参数就是-L/aaa/bbb/ccc -ltest

另外,大部分libxxxx.so只是一个链接,以RH9为例,比如libm.so它链接到/lib/libm.so.x,/lib/libm.so.6 又链接到/lib/libm-2.3.2.so,如果没有这样的链接,还是会出错,因为ld只会找libxxxx.so,所以如果你要用到xxxx库,而只有libxxxx.so.x或者libxxxx-x.x.x.so,做一个链接就可以了ln -s libxxxx-x.x.x.so libxxxx.so手工来写链接参数总是很麻烦的,还好很多库开发包提供了生成链接参数的程序,名字一般叫xxxx-config,一般放在/usr/bin目录下,比如 gtk1.2的链接参数生成程序是gtk-config,执行gtk-config —libs就能得到以下输出”-L/usr/lib -L/usr/X11R6/lib -lgtk -lgdk -rdynamic -lgmodule -lglib -ldl -lXi -lXext -lX11 -lm”,这就是编译一个gtk1.2程序所需的gtk链接参数,xxx-config除了—libs参数外还有一个参数是—cflags用来生成头文件包含目录的,也就是-I参数,在下面我们将会讲到。你可以试试执行gtk-config —libs —cflags,看看输出结果。

现在的问题就是怎样用这些输出结果了,最笨的方法就是复制粘贴或者照抄,聪明的办法是在编译命令行里加入这个xxxx-config --libs --cflags,比如编译一个gtk程序:gcc gtktest.c gtk-config --libs --cflags这样差不多了。注意`不是单引号,而是1键左边那个键。

除了xxx-config以外,现在新的开发包一般都用pkg-config来生成链接参数,使用方法跟xxx-config类似,但xxx-config是针对特定的开发包,但pkg-config包含很多开发包的链接参数的生成,用pkg-config —list-all命令可以列出所支持的所有开发包,pkg-config的用法就是pkg-config pagName —libs —cflags,其中pagName是包名,是pkg-config—list-all里列出名单中的一个,比如gtk1.2的名字就是gtk+, pkg-config gtk+ —libs —cflags的作用跟gtk-config —libs —cflags是一样的。比如:

gcc gtktest.c pkg-config gtk+ --libs --cflags

8) -include和-I参数

-include用来包含头文件,但一般情况下包含头文件都在源码里用#i nclude xxxxxx实现,-include参数很少用。-I参数是用来指定头文件目录,/usr/include目录一般是不用指定的,gcc知道去那里找,但 是如果头文件不在/usr/icnclude里我们就要用-I参数指定了,比如头文件放在/myinclude目录里,那编译命令行就要加上-I/myinclude参数了,如果不加你会得到一个”xxxx.h: No such file or directory”的错误。-I参数可以用相对路径,比如头文件在当前目录,可以用-I.来指定。上面我们提到的—cflags参数就是用来生成-I参数的。

9)-Wall、-w 和 -v参数

  • -Wall 打印出gcc提供的警告信息
  • -w 关闭所有警告信息
  • -v 列出所有编译步骤

10) -m64 64位

11) -shared 将-fPIC生成的位置无关的代码作为动态库,一般情况下,-fPIC和-shared都是一起使用的。生成SO文件,共享库
-static 此选项将禁止使用动态库,所以,编译出来的东西,一般都很大,也不需要什么动态连接库,就可以运行

几个相关的环境变量

PKG_CONFIG_PATH:用来指定pkg-config用到的pc文件的路径,默认是/usr/lib/pkgconfig,pc文件是文本文件,扩展名是.pc,里面定义开发包的安装路径,Libs参数和Cflags参数等等。

  • CC:用来指定c编译器。
  • CXX:用来指定cxx编译器。
  • LIBS:跟上面的—libs作用差不多。
  • CFLAGS:跟上面的—cflags作用差不多。
  • CC,CXX,LIBS,CFLAGS手动编译时一般用不上,在做configure时有时用到,一般情况下不用管。

环境变量设定方法:export ENV_NAME=xxxxxxxxxxxxxxxxx

关于交叉编译

交叉编译通俗地讲就是在一种平台上编译出能运行在体系结构不同的另一种平台上,比如在我们地PC平台(X86 CPU)上编译出能运行在arm CPU平台上的程序,编译得到的程序在X86 CPU平台上是不能运行的,必须放到arm CPU 平台上才能运行。当然两个平台用的都是linux。这种方法在异平台移植和嵌入式开发时用得非常普遍。相对与交叉编译,我们平常做的编译就叫本地编译,也 就是在当前平台编译,编译得到的程序也是在本地执行。用来编译这种程序的编译器就叫交叉编译器,相对来说,用来做本地编译的就叫本地编译器,一般用的都是gcc,但这种gcc跟本地的gcc编译器是不一样的,需要在编译gcc时用特定的configure参数才能得到支持交叉编译的gcc。为了不跟本地编译器混淆,交叉编译器的名字一般都有前缀,比如armc-xxxx-linux-gnu-gcc,arm-xxxx-linux-gnu- g++ 等等

交叉编译器的使用方法

使用方法跟本地的gcc差不多,但有一点特殊的是:必须用-L和-I参数指定编译器用arm系统的库和头文件,不能用本地(X86)的库(头文件有时可以用本地的)。

例子:

arm-xxxx-linux-gnu-gcc test.c -L/path/to/sparcLib -I/path/to/armInclude

深入理解软件包的配置、编译与安装

从源代码安装过软件的朋友一定对 ./configure && make && make install 安装三步曲非常熟悉了。然而究竟这个过程中的每一步幕后都发生了些什么呢?本文将带领你一探究竟。深入理解这个过程将有助于你在LFS的基础上玩出自己的花样来。不过需要说明的是本文对 Makefile 和 make 的讲解是相当近视和粗浅的,但是对于理解安装过程来说足够了。

概述

用一句话来解释这个过程就是:

根据源码包中 Makefile.in 文件的指示,configure 脚本检查当前的系统环境和配置选项,在当前目录中生成 Makefile 文件(还有其它本文无需关心的文件),然后 make 程序就按照当前目录中的 Makefile 文件的指示将源代码编译为二进制文件,最后将这些二进制文件移动(即安装)到指定的地方(仍然按照 Makefile 文件的指示)。

由此可见 Makefile 文件是幕后的核心。要深入理解安装过程,必须首先对 Makefile 文件有充分的了解。本文将首先讲述 Makefile 与 make ,然后再讲述 configure 脚本。并且在讲述这两部分内容时,提供了尽可能详细的、可以运用于实践的参考资料。

Makefile 与 make

用一句话来概括Makefile 与 make 的关系就是:
Makefile 包含了所有的规则和目标,而 make 则是为了完成目标而去解释 Makefile 规则的工具。

make 语法

首先看看 make 的命令行语法:

make [options] [targets] [VAR=VALUE]...
[options]是命令行选项,可以用 make —help 命令查看全部,[VAR=VALUE]是在命令行上指定环境变量,这两个大家都很熟悉,将在稍后详细讲解。而[targets]是什么呢?字面的意思是”目标”,也就是希望本次 make 命令所完成的任务。凭经验猜测,这个[targets]大概可以用”check”,”install”之类(也就是常见的测试和安装命令)。但是它到底是个啥玩意儿?不带任何”目标”的 make 命令是什么意思?为什么在安装 LFS 工具链中的 Perl-5.8.8 软件包时会出现”make perl utilities”这样怪异的命令?要回答这些问题必须首先理解 Makefile 文件中的”规则”。

Makefile 规则

Makefile 规则包含了文件之间的依赖关系和更新此规则目标所需要的命令。

一个简单的 Makefile 规则是这样写的:

1
2
TARGET : PREREQUISITES
COMMAND

TARGET
规则的目标。也就是可以被 make 使用的”目标”。有些目标可以没有依赖而只有动作(命令行),比如”clean”,通常仅仅定义一系列删除中间文件的命令。同样,有些目标可以没有动作而只有依赖,比如”all”,通常仅仅用作”终极目标”。

PREREQUISITES
规则的依赖。通常一个目标依赖于一个或者多个文件。

COMMAND
规则的命令行。一个规则可以有零个或多个命令行。
OK! 现在你明白[targets]是什么了,原来它们来自于 Makefile 文件中一条条规则的目标(TARGET)。另外,Makefile文件中第一条规则的目标被称为”终极目标”,也就是你省略[targets]参数时的目标(通常为”all”)。

当你查看一个实际的 Makefile 文件时,你会发现有些规则非常复杂,但是它都符合规则的基本格式。此外,Makefile 文件中通常还包含了除规则以外的其它很多东西,不过本文只关心其中的变量。

Makefile 变量

Makefile 中的”变量”更像是 C 语言中的宏,代表一个文本字符串(变量的值),可以用于规则的任何部分。变量的定义很简单:VAR=VALUE;变量的引用也很简单:$(VAR) 或者 ${VAR}。变量引用的展开过程是严格的文本替换过程,就是说变量值的字符串被精确的展开在变量被引用的地方。比如,若定义:VAR=c,那么,”$(VAR) $(VAR)-$(VAR) VAR.$(VAR)”将被展开为”c c-c VAR.c”。

虽然在 Makefile 中可以直接使用系统的环境变量,但是也可以通过在 Makefile 中定义同名变量来”遮盖”系统的环境变量。另一方面,我们可以在调用 make 时使用 -e 参数强制使系统中的环境变量覆盖 Makefile 中的同名变量,除此之外,在调用 make 的命令行上使用 VAR=VALUE 格式指定的环境变量也可以覆盖 Makefile 中的同名变量。

Makefile 实例

下面看一个简单的、实际的Makefile文件:

1
2
3
4
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
CC=gcc
CPPFLAGS=
CFLAGS=-O2 -pipe
LDFLAGS=-s
PREFIX=/usr

all : prog1 prog2

prog1 : prog1.o
$(CC) $(LDFLAGS) -o prog1 prog1.o

prog1.o : prog1.c
$(CC) -c $(CFLAGS) prog1.c

prog2 : prog2.o
$(CC) $(CFLAGS) $(LDFLAGS) -o prog2 prog2.o

prog2.o : prog2.c
$(CC) -c $(CPPFLAGS) $(CFLAGS) prog2.c

clean :
rm -f *.{o,a} prog{1,2}

install : prog1 prog2
if ( test ! -d $(PREFIX)/bin ) ; then mkdir -p $(PREFIX)/bin ; fi
cp -f prog1 $(PREFIX)/bin/prog1
cp -f prog2 $(PREFIX)/bin/prog2

check test : prog1 prog2
prog1 < sample1.ref > sample1.rz
prog1 < sample2.ref > sample3.rz
cmp sample1.ok sample1.rz
cmp sample2.ok sample2.rz

从中可以看出,make 与 make all 以及 make prog1 prog2 三条命令其实是等价的。而常用的 make check 和 make install 也找到了归属。同时我们也看到了 Makefile 中的各种变量是如何影响编译的。针对这个特定的 Makefile ,你甚至可以省略安装三步曲中的 make 命令而直接使用 make install 进行安装。

同样,为了使用自定义的编译参数编译 prog2 ,我们可以使用 make prog2 CFLAGS=”-O3 -march=athlon64” 或 CFLAGS=”-O3 -march=athlon64” && make -e prog2 命令达到此目的。

Makefile 惯例

下面是Makefile中一些约定俗成的目标名称及其含义:

all
编译整个软件包,但不重建任何文档。一般此目标作为默认的终极目标。此目标一般对所有源程序的编译和连接使用”-g”选项,以使最终的可执行程序中包含调试信息。可使用 strip 程序去掉这些调试符号。

clean
清除当前目录下在 make 过程中产生的文件。它不能删除软件包的配置文件,也不能删除 build 时创建的那些文件。

distclean
类似于”clean”,但增加删除当前目录下的的配置文件、build 过程产生的文件。

info
产生必要的 Info 文档。

check 或 test
完成所有的自检功能。在执行检查之前,应确保所有程序已经被创建(但可以尚未安装)。为了进行测试,需要实现在程序没有安装的情况下被执行的测试命令。

install
完成程序的编译并将最终的可执行程序、库文件等拷贝到指定的目录。此种安装一般不对可执行程序进行 strip 操作。

install-strip
和”install”类似,但是会对复制到安装目录下的可执行文件进行 strip 操作。

uninstall
删除所有由”install”安装的文件。

installcheck
执行安装检查。在执行安装检查之前,需要确保所有程序已经被创建并且被安装。

installdirs
创建安装目录及其子目录。它不能更改软件的编译目录,而仅仅是创建程序的安装目录。
下面是 Makefile 中一些约定俗成的变量名称及其含义:

这些约定俗成的变量分为三类。第一类代表可执行程序的名字,例如 CC 代表编译器这个可执行程序;第二类代表程序使用的参数(多个参数使用空格分开),例如 CFLAGS 代表编译器执行时使用的参数(一种怪异的做法是直接在 CC 中包含参数);第三类代表安装目录,例如 prefix 等等,含义简单,下面只列出它们的默认值。

1
2
3
4
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
AR   函数库打包程序,可创建静态库.a文档。默认是"ar"。
AS 汇编程序。默认是"as"。
CC C编译程序。默认是"cc"。
CXX C++编译程序。默认是"g++"。
CPP C/C++预处理器。默认是"$(CC) -E"。
FC Fortran编译器。默认是"f77"。
PC Pascal语言编译器。默认是"pc"。
YACC Yacc文法分析器。默认是"yacc"。

ARFLAGS 函数库打包程序的命令行参数。默认值是"rv"。
ASFLAGS 汇编程序的命令行参数。
CFLAGS C编译程序的命令行参数。
CXXFLAGS C++编译程序的命令行参数。
CPPFLAGS C/C++预处理器的命令行参数。
FFLAGS Fortran编译器的命令行参数。
PFLAGS Pascal编译器的命令行参数。
YFLAGS Yacc文法分析器的命令行参数。
LDFLAGS 链接器的命令行参数。

prefix /usr/local
exec_prefix $(prefix)
bindir $(exec_prefix)/bin
sbindir $(exec_prefix)/sbin
libexecdir $(exec_prefix)/libexec
datadir $(prefix)/share
sysconfdir $(prefix)/etc
sharedstatedir $(prefix)/com
localstatedir $(prefix)/var
libdir $(exec_prefix)/lib
infodir $(prefix)/info
includedir $(prefix)/include
oldincludedir $(prefix)/include
mandir $(prefix)/man
srcdir 需要编译的源文件所在的目录,无默认值

make 选项

最后说说 make 的命令行选项(以Make-3.81版本为准):

-B, —always-make
无条件的重建所有规则的目标,而不是根据规则的依赖关系决定是否重建某些目标文件。

-C DIR, —directory=DIR
在做任何动作之前先切换工作目录到 DIR ,然后再执行 make 程序。

-d
在 make 执行过程中打印出所有的调试信息。包括:make 认为那些文件需要重建;那些文件需要比较它们的最后修改时间、比较的结果;重建目标所要执行的命令;使用的隐含规则等。使用该选项我们可以看到 make 构造依赖关系链、重建目标过程的所有信息,它等效于”-debug=a”。

—debug=FLAGS
在 make 执行过程中打印出调试信息。FLAGS 用于控制调试信息级别:

a
输出所有类型的调试信息

b
输出基本调试信息。包括:那些目标过期、是否重建成功过期目标文件。

v
除 b 级别以外还包括:解析的 makefile 文件名,不需要重建文件等。

i
除 b 级别以外还包括:所有使用到的隐含规则描述。

j
输出所有执行命令的子进程,包括命令执行的 PID 等。

m
输出 make 读取、更新、执行 makefile 的信息。

-e, —environment-overrides
使用系统环境变量的定义覆盖 Makefile 中的同名变量定义。

-f FILE, —file=FILE, —makefile=FILE
将 FILE 指定为 Makefile 文件。

-h, —help
打印帮助信息。

-i, —ignore-errors
忽略规则命令执行过程中的错误。

-I DIR, —include-dir=DIR
指定包含 Makefile 文件的搜索目录。使用多个”-I”指定目录时,搜索目录按照指定顺序进行。

-j [N], —jobs[=N]
指定并行执行的命令数目。在没有指定”-j”参数的情况下,执行的命令数目将是系统允许的最大可能数目。

-k, —keep-going
遇见命令执行错误时不终止 make 的执行,也就是尽可能执行所有的命令,直到出现致命错误才终止。

-l [N], —load-average[=N], —max-load[=N]
如果系统负荷超过 LOAD(浮点数),不再启动新任务。

-L, —check-symlink-times
同时考察符号连接的时间戳和它所指向的目标文件的时间戳,以两者中较晚的时间戳为准。

-n, —just-print, —dry-run, —recon
只打印出所要执行的命令,但并不实际执行命令。

-o FILE, —old-file=FILE, —assume-old=FILE
即使相对于它的依赖已经过期也不重建 FILE 文件;同时也不重建依赖于此文件任何文件。

-p, —print-data-base
命令执行之前,打印出 make 读取的 Makefile 的所有数据(包括规则和变量的值),同时打印出 make 的版本信息。如果只需要打印这些数据信息,可以使用 make -qp 命令。查看 make 执行前的预设规则和变量,可使用命令 make -p -f /dev/null 。

-q, —question
“询问模式”。不运行任何命令,并且无输出,只是返回一个查询状态。返回状态为 0 表示没有目标需要重建,1 表示存在需要重建的目标,2 表示有错误发生。

-r, —no-builtin-rules
取消所有内嵌的隐含规则,不过你可以在 Makefile 中使用模式规则来定义规则。同时还会取消所有支持后追规则的隐含后缀列表,同样我们也可以在 Makefile 中使用”.SUFFIXES”定义我们自己的后缀规则。此选项不会取消 make 内嵌的隐含变量。

-R, —no-builtin-variables
取消 make 内嵌的隐含变量,不过我们可以在 Makefile 中明确定义某些变量。注意,此选项同时打开了”-r”选项。因为隐含规则是以内嵌的隐含变量为基础的。

-s, —silent, —quiet
不显示所执行的命令。

-S, —no-keep-going, —stop
取消”-k”选项。在递归的 make 过程中子 make 通过 MAKEFLAGS 变量继承了上层的命令行选项。我们可以在子 make 中使用”-S”选项取消上层传递的”-k”选项,或者取消系统环境变量 MAKEFLAGS 中的”-k”选项。

-t, —touch
更新所有目标文件的时间戳到当前系统时间。防止 make 对所有过时目标文件的重建。

-v, —version
打印版本信息。

-w, —print-directory
在 make 进入一个目录之前打印工作目录。使用”-C”选项时默认打开这个选项。

—no-print-directory
取消”-w”选项。可以是用在递归的 make 调用过程中,取消”-C”参数将默认打开”-w”。

-W FILE, —what-if=FILE, —new-file=FILE, —assume-new=FILE
设定 FILE 文件的时间戳为当前时间,但不改变文件实际的最后修改时间。此选项主要是为实现了对所有依赖于 FILE 文件的目标的强制重建。

—warn-undefined-variables
在发现 Makefile 中存在对未定义的变量进行引用时给出告警信息。此功能可以帮助我们调试一个存在多级套嵌变量引用的复杂 Makefile 。但是:我们建议在书写 Makefile 时尽量避免超过三级以上的变量套嵌引用。

configure

此阶段的主要目的是生成 Makefile 文件,是最关键的运筹帷幄阶段,基本上所有可以对安装过程进行的个性化调整都集中在这一步。

configure 脚本能够对 Makefile 中的哪些内容产生影响呢?基本上可以这么说:所有内容,包括本文最关心的 Makefile 规则与 Makefile 变量。那么又是哪些因素影响着最终生成的 Makefile 文件呢?答曰:系统环境和配置选项。

配置选项的影响是显而易见的。但是”系统环境”的概念却很宽泛,包含很多方面内容,不过我们这里只关心环境变量,具体说来就是将来会在 Makefile 中使用到的环境变量以及与 Makefile 中的变量同名的环境变量。

通用 configure 语法

在进一步讲述之前,先看看 configure 脚本的语法,一般有两种:

1
2
configure [OPTIONS] [VAR=VALUE]...
configure [OPTIONS] [HOST]

不管是哪种语法,我们都可以用 configure —help 查看所有可用的[OPTIONS],并且通常在结尾部分还能看到这个脚本所关心的环境变量有哪些。在本文中将对这两种语法进行合并,使用下面这种简化的语法:
1
configure [OPTIONS]

这种语法能够被所有的 configure 脚本所识别,同时也能通过设置环境变量和使用特定的[OPTIONS]完成上述两种语法的一切功能。

通用 configure 选项

虽然每个软件包的 configure 脚本千差万别,但是它们却都有一些共同的选项,也基本上都遵守相同的选项语法。

—help
显示帮助信息。

—version
显示版本信息。

—cache-file=FILE
在FILE文件中缓存测试结果(默认禁用)。

—no-create
configure脚本运行结束后不输出结果文件,常用于正式编译前的测试。

—quiet, —silent
不显示脚本工作期间输出的”checking …”消息。

—srcdir=DIR
源代码文件所在目录,默认为configure脚本所在目录或其父目录。

—prefix=PREFIX
体系无关文件的顶级安装目录PREFIX ,默认值一般是 /usr/local 或 /usr/local/pkgName

—exec-prefix=EPREFIX
体系相关文件的顶级安装目录EPREFIX ,默认值一般是 PREFIX

—bindir=DIR
用户可执行文件的存放目录DIR ,默认值一般是 EPREFIX/bin

—sbindir=DIR
系统管理员可执行目录DIR ,默认值一般是 EPREFIX/sbin

—libexecdir=DIR
程序可执行目录DIR ,默认值一般是 EPREFIX/libexec

—datadir=DIR
通用数据文件的安装目录DIR ,默认值一般是 PREFIX/share

—sysconfdir=DIR
只读的单一机器数据目录DIR ,默认值一般是 PREFIX/etc

—sharedstatedir=DIR
可写的体系无关数据目录DIR ,默认值一般是 PREFIX/com

—localstatedir=DIR
可写的单一机器数据目录DIR ,默认值一般是 PREFIX/var

—libdir=DIR
库文件的安装目录DIR ,默认值一般是 EPREFIX/lib

—includedir=DIR
C头文件目录DIR ,默认值一般是 PREFIX/include

—oldincludedir=DIR
非gcc的C头文件目录DIR ,默认值一般是 /usr/include

—infodir=DIR
Info文档的安装目录DIR ,默认值一般是 PREFIX/info

—mandir=DIR
Man文档的安装目录DIR ,默认值一般是 PREFIX/man

—build=BUILD
工具链当前的运行环境,默认是 config.guess 脚本的输出结果。

—host=HOST
编译出的二进制代码将要运行在HOST上,默认值是BUILD。

—target=TARGET
编译出的工具链所将来生成的二进制代码要在TARGET上运行,这个选项仅对工具链(也就是GCC和
Binutils两者)有意义。

—enable-FEATURE
启用FEATURE特性

—disable-FEATURE
禁用FEATURE特性

—with-PACKAGE[=DIR]
启用附加软件包PACKAGE,亦可同时指定PACKAGE所在目录DIR

—without-PACKAGE
禁用附加软件包PACKAGE

CPP
C预处理器命令

CXXCPP
C++预处理器命令

CPPFLAGS
C/C++预处理器命令行参数

CC
C编译器命令

CFLAGS
C编译器命令行参数

CXX
C++编译器命令

CXXFLAGS
C++编译器命令行参数

LDFLAGS
连接器命令行参数

至于设置这些环境变量的方法,你可以将它们 export 为全局变量在全局范围内使用,也可以在命令行上使用 [VAR=VALUE]… configure [OPTIONS] 的语法局部使用。此处就不详细描述了。

看完上述内容以后,不用多说你应当自然而然的明白该进行如何对自己的软件包进行定制安装了。祝你好运!

链接器和加载器

链接和加载

链接器和加载器做什么?

任何一个链接器和加载器的基本工作都非常简单:将更抽象的名字与更底层的名字绑定起来,好让程序员使用更抽象的名字编写代码。也就是说,它可以将程序员写的一个诸如getline 的名字绑定到“iosys模块内可执行代码的 612 字节处”或者可以采用诸如“这个模块的静态数据开始的第 450 个字节处”这样更抽象的数字地址然后将其绑定到数字地址上。

地址绑定:从历史的角度

随着操作系统的出现,有必要将可重定位的加载器从链接器和库中分离出来。在操作系统将程序加载到内存之前是无法确定程序运行的确切地址的,并将最终的地址绑定从链接时推延到了加载时。现在链接器和加载器已经将这个工作划分开了,链接器对每一个程序的部分地址进行绑定并分配相对地址,加载器完成最后的重定位步骤并赋予的实际地址。

随着计算机系统变得越来愈复杂,链接器被用来做了更多、更复杂的名字管理和地址绑定的工作。Fortran 程序使用了多个子程序和公共块(被多个子程序共享的数据区域),而它是由链接器来为这些子程序和公共数据块进行存储布局和地址分配的。逐渐地链接器还需要处理目标代码库。包括用 Fortran 或其它语言编写的应用程序库,并且编译器也支持那些可以从被编译好的处理 I/O 或其它高级操作的代码中隐含调用的库。

由于程序很快就变得比可用的内存大了,因此链接器提供了覆盖技术,它可以让程序员安排程序的不同部分来分享相同的内存,当程序的某一部分被其它部分调用时可以按需加载。

随着硬件重定位和虚拟内存的出现,每一个程序可以再次拥有整个地址空间,因此链接器和加载器变得不那么复杂了。由于硬件(而不是软件)重定位可以对任何加载时重定位进行处理,程序可以按照被加载到固定地址的方式来链接。编译器和汇编器可以被修改为在多个段内创建目标代码,为只读代码分配一个段,为别的可写数据分配其它段。链接器必须能够将相同类型的所有段都合并在一起,以使得被链接程序的所有代码都放置在一个地方,而所有的数据放在另一个地方。由于地址仍然是在链接时被分配的,因此和之前相比并不能延迟地址绑定的时机,但更多的工作被延迟到了链接器为所有段分配地址的时候。

在较简单的静态共享库中,每个库在创建时会被绑定到特定的地址,链接器在链接时将程序中引用的库例程绑定到这些特定的地址。由于当静态库中的任何部分变化时程序都需要被重新链接,而且创建静态链接库的细节也是非常冗长乏味的,因此静态链接库实际上很麻烦死板。故又出现了动态链接库,使用动态链接库的程序在开始运行之前不会将所用库中的段和符号绑定到确切的地址上。有时这种绑定还会更为延迟:在完全的动态链接中,被调用例程的地址在第一次调用前都不会被绑定。此外在程序运行过程中也可以加载库并进行绑定。这提供了一种强大且高性能的扩展程序功能的方法。

链接与加载

链接器和加载器完成几个相关但概念上不同的动作。

  • 程序加载:将程序从辅助存储设备拷贝到主内存中准备运行。在某些情况下,加载仅仅是将数据从磁盘拷入内存;在其他情况下,还包括分配存储空间,设置保护位或通过虚拟内存将虚拟地址映射到磁盘内存页上。
  • 重定位:编译器和汇编器通常为每个文件创建程序地址从 0 开始的目标代码,但是几乎没有计算机会允许从地址 0 加载你的程序。如果一个程序是由多个子程序组成的,那么所有的子程序必须被加载到互不重叠的地址上。重定位就是为程序不同部分分配加载地址,调整程序中的数据和代码以反映所分配地址的过程。在很多系统中,重定位不止进行一次。
  • 符号解析:当通过多个子程序来构建一个程序时,子程序间的相互引用是通过符号进行的;主程序可能会调用一个名为 sqrt 的计算平方根例程,并且数学库中定义了sqrt 例程。链接器通过标明分配给 sqrt 的地址在库中来解析这个符号,并通过修改目标代码使得 call 指令引用该地址。

尽管有相当一部分功能在链接器和加载器之间重叠,定义一个仅完成程序加载的程序为加载器,一个仅完成符号解析的程序为链接器是合理的。他们任何一个都可以进行重定位,而且曾经也出现过集三种功能为一体的链接加载器。

重定位和符号解析的划分界线是模糊的。由于链接器已经可以解析符号的引用,一种处理代码重定位的方法就是为程序的每一部分分配一个指向基址的符号,然后将重定位地址认为是对该基址符号的引用。

链接器和加载器共有的一个重要特性就是他们都会修改目标代码,他们也许是唯一比调试程序在这方面应用更为广泛的程序。这是一个独特而强大的特性,而且细节非常依赖于机器的规格,如果做错的话就会引发令人困惑的 bug。

两遍链接

链接基本上也是一个两遍的过程。链接器将一系列的目标文件、库、及可能的命令文件作为它的输入,然后将输出的目标文件作为产品结果,此外也可能有诸如加载映射信息或调试器符号文件的副产品。

每个输入文件都包含一系列的段(segments),即会被连续存放在输出文件中的代码或数据块。每一个输入文件至少还包含一个符号表(symbol table)。有一些符号会作为导出符号,他们在当前文件中定义并在其他文件中使用,通常都是可以在其它地方被调用的当前文件内例程的名字。其它符号会作为导入符号,在当前文件中使用但不在当前文件中定义,通常都是在该文件中调用但不存在于该文件中的例程的名字。

当链接器运行时,会首先对输入文件进行扫描,得到各个段的大小,并收集对所有符号的定义和引用。它会创建一个列出输入文件中定义的所有段的段表,和包含所有导出、导入符号的符号表。

利用第一遍扫描得到的数据,链接器可以为符号分配数字地址,决定各个段在输出地址空间中的大小和位置,并确定每一部分在输出文件中的布局。

第二遍扫描会利用第一遍扫描中收集的信息来控制实际的链接过程。它会读取并重定位目标代码,为符号引用替换数字地址,调整代码和数据的内存地址以反映重定位的段地址,并将重定位后的代码写入到输出文件中。通常还会再向输出文件中写入文件头部信息,重定位的段和符号表信息。如果程序使用了动态链接,那么符号表中还要包含运行时链接器解析动态符号时所需的信息。在很多情况下,链接器自己将会在输出文件中生成少量代码或数据,例如用来调用覆盖中或动态链接库中的例程的“胶水代码”,或在程序启动时需要被调用的指向各初始化例程的函数指针数组。

有些目标代码的格式是可以重链接的,也就是一次链接器运行的输出文件可以作为下次链接器运行的输入。这要求输出文件要包含一个像输入文件中那样的符号表,以及其它会出现在输入文件中的辅助信息。

几乎所有的目标代码格式都预备有调试符号,这样当程序在调试器控制下运行时,调试器可以使用这些符号让程序员通过源代码中的行号或名字来控制程序。

目标代码库

所有的链接器都会以这样或那样的形式来支持目标代码库,同时它们中的大多数还会支持各种各样的共享库。目标代码库的基本原则是很非常简单的。一个库不比一些目标代码文件的集合复杂多少。当链接器处理完所有规则的输入文件后,如果还存在未解析的导入名称(imported name),它就会查找一个或多个库,然后将输出这些未解析名字的库中的任何文件链接进来。

由于链接器将部分工作从链接时推迟到了加载时,使这项任务稍微复杂了一些。在链接器运行时,链接器会识别出解析未定义符号所需的共享库,但是链接器会在输出文件中标明用来解析这些符号的库名称,而不是在链接时将他们链入程序,这样可以在程序被加载时进行共享库绑定。

重定位和代码修改

链接器和加载器的核心动作是重定位代码修改。当编译器或汇编器产生一个目标代码文件时,它使用文件中定义的未重定位代码地址和数据地址来生成代码,对于其它地方定义的数据或代码通常就是 0。作为链接过程的一部分,链接器会修改目标代码以反映实际分配的地址。例如,考虑如下这段将变量 a 中的内容通过寄存器 eax 移动到变量 b 的 x86 代码片段。

1
2
mov a,%eax
mov %eax,b

如果 a 定义在同一文件的位置 0x1234,而 b 是从其它地方导入的,那么生成的代码将会是:

1
2
A1 34 12 00 00 mov a,%eax
A3 00 00 00 00 mov %eax,b

每条指令包含了一个字节的操作码和其后 4 个字节的地址。第一个指令有对地址 1234的引用(由于 x86 使用从右向左的字节序,因此这里是序),而第二个指令由于 b 的位置是未知的因此引用位置为 0。

现在想象链接器将这段代码进行链接,a 所属段被重定位到了 0x10000,b 最终位于地址 0x9A12。则链接器会将代码修改为:

1
2
A1 34 12 01 00 mov a,%eax
A3 12 9A 00 00 mov %eax,b

也就是说,链接器将第一条指令中的地址加上 0x10000,现在它所标识的 a 的重定位地址就是 0x11234,并且也补上了 b 的地址。虽然这些调整影响的是指令,但是目标文件中数据部分任何相关的指针也必须修改。

有些系统需要无论加载到什么位置都可以正常工作的位置无关代码。链接器需要提供额外的技巧来支持位置无关代码,与程序中无法做到位置无关的部分隔离开来,并设法使这两部分可以互相通讯。

编译器驱动

很多情况下,链接器所进行的操作对程序员是几乎或完全不可见的,因为它会做为编译过程的一部分自动进行。多数编译系统都有一个可以按需自动执行编译器各个阶段的编译器驱动。例如,若一个程序员有两个 C 源程序文件(简称 A,B),那么在 UNIX 系统上编译器驱动将会运行如下一系列的程序:

  • C 语言预处理器处理 A,生成预处理的 A
  • C 语言编译预处理的 A,生成汇编文件 A
  • 汇编器处理汇编文件 A,生成目标文件 A
  • C 语言预处理器处理 B,生成预处理的 B
  • C 语言编译预处理的 B,生成汇编文件 B
  • 汇编器处理汇编文件 B,生成目标文件 B
  • 链接器将目标文件 A、B 和系统 C 库链接在一起

也就是说,编译器驱动首先会将每个源文件编译为汇编语言,然后转换为目标代码,接着链接器会将目标代码链接器一起,并包含任何需要的系统 C 库例程。

编译器驱动通常要比这聪明的多,他们会比较源文件和目标代码文件的时间,仅编译那些被修改过的源文件(UNIX make 程序就是典型的例子)。

链接器命令语言

每个链接器都有某种形式的命令语言来控制链接过程。最起码链接器需要记录所链接的目标代码和库的列表。通常都会有一大长串可能的选项:在哪里放置调试符号,在哪里使用共享或非共享库,使用哪些可能的输出格式等。多数链接器都允许某些方法来指定被链接代码将要绑定的地址,这在链接一个系统内核或其它没有操作系统控制的程序时就会用到。在支持多个代码和数据段的链接器中,链接器命令语言可以对链接各个段的顺序、需要特殊处理的段和某些应用程序相关的选项进行指定。

有四种常见技术向链接器传送指令:

  • 命令行:多数系统都会有命令行(或相似功能的其它程序),通过它可以输入各种文件名和开关选项。这对于 UNIX 和 Windows 链接器是很常用的方法。
  • 与目标文件混在一起:有些链接器从一个单个输入文件中接受替换的目标文件及链接器命令。
  • 嵌入在目标文件中:有一些目标代码格式,允许将链接器命令嵌入到目标文件中。这就允许编译器将链接一个目标文件时所需要的任何选项通过文件自身来传递。例如 C 编译器将搜索标准 C 库的命令嵌入到文件中(来传递给链接过程)。
  • 单独的配置语言:极少有链接器拥有完备的配置语言来控制链接过程。可以处理众多目标文件类型、机器体系架构和地址空间规定的 GNU 链接器,拥有可以让程序员指定段链接顺序、合并相近段规则、段地址和大量其它选项的一套复杂的控制语言。

链接:一个真实的例子

我们通过一个简小的链接实例来结束对链接过程的介绍。图 3 所示为一对 C 语言源代码文件,m.c 中的主程序调用了一个名为 a 的例程,而调用了库例程 strlen 和 write 的 a 例程在 a.c 中。

1
2
3
4
5
extern void a(char *);
int main(int ac, char **av) {
static char string[] = "Hello, world!\n";
a(string);
}

源程序 a.c

1
2
3
4
5
#include <unistd.h>
#include <string.h>
void a(char *s) {
write(1, s, strlen(s));
}

主程序 m.c 用 gcc 编译成一个典型 a.out 目标代码格式长度为 165 字节的目标文件。该目标文件包含一个固定长度的头部,16 个字节的“文本”段,包含只读的程序代码,16 个字节的数据段,包含字符串。其后是两个重定位项,其中一个标明 pushl 指令将字符串 string 的地址放置在栈上为调用例程 a 作准备,另一个标明 call 指令将控制转移到例程 a。符号表分别导出和导入了 _main 与_a 的定义,以及调试器需要的其它一系列符号。注意由于和字符串 string 在同一个文件中,pushl 指令引用了 string 的临时地址 0x10,而由于_a 的地址是未知的所以 call 指令引用的地址为 0x0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000010 00000000 00000000 00000020 2**3
1 .data 00000010 00000010 00000010 00000030 2**3
Disassembly of section .text:

00000000 <_main>:
0:55 pushl %ebp
1:89 e5 movl %esp,%ebp
3:68 10 00 00 00 pushl $0x10
4:32 .data
8:e8 f3 ff ff ff call 0
9:DISP32 _a
d:c9 leave
e:c3 ret
...

子程序文件 a.c 编译成一个长度为 160 字节的目标文件,包括头部, 28字节的文本段,无数据段。两个重定位项标记了对 strlen 和 write 的 call 指令,符号表中导出_a 并导入了_strlen 和_write。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000001c 00000000 00000000 00000020 2**2
CONTENTS, ALLOC, LOAD, RELOC, CODE
1 .data 00000000 0000001c 0000001c 0000003c 2**2
CONTENTS, ALLOC, LOAD, DATA
Disassembly of section .text:

00000000 <_a>:
0:55 pushl %ebp
1:89 e5 movl %esp,%ebp
3:53 pushl %ebx
4:8b 5d 08 movl 0x8(%ebp),%ebx
7:53 pushl %ebx
8:e8 f3 ff ff ff call 0
9:DISP32 _strlen
d:50 pushl %eax
e:53 pushl %ebx
f:6a 01 pushl $0x1
11:e8 ea ff ff ff call 0
12:DISP32 _write
16:8d 65 fc leal -4(%ebp),%esp
19:5b popl %ebx
1a:c9 leave
1b:c3 ret

为了产生一个可执行程序,链接器将这两个目标文件,以及一个标准的 C 程序启动初始化例程,和必要的 C 库例程整合到一起,产生一个部分如下所示的可执行文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000fe0 00001020 00001020 00000020 2**3
1 .data 00001000 00002000 00002000 00001000 2**3
2 .bss 00000000 00003000 00003000 00000000 2**3
Disassembly of section .text:

00001020 <start-c>:
...
1092:e8 0d 00 00 00 call 10a4 <_main>
...

000010a4 <_main>:
10a4:55 pushl %ebp
10a5:89 e5 movl %esp,%ebp
10a7:68 24 20 00 00 pushl $0x2024
10ac:e8 03 00 00 00 call 10b4 <_a>
10b1:c9 leave
10b2:c3 ret
...

000010b4 <_a>:
10b4:55 pushl %ebp
10b5:89 e5 movl %esp,%ebp
10b7:53 pushl %ebx
10b8:8b 5d 08 movl 0x8(%ebp),%ebx
10bb:53 pushl %ebx
10bc:e8 37 00 00 00 call 10f8 <_strlen>
10c1:50 pushl %eax
10c2:53 pushl %ebx
10c3:6a 01 pushl $0x1
10c5:e8 a2 00 00 00 call 116c <_write>
10ca:8d 65 fc leal -4(%ebp),%esp
10cd:5b popl %ebx
10ce:c9 leave
10cf:c3 ret
...

000010f8 <_strlen>:
...

0000116c <_write>:
...

链接器将每个输入文件中相应的段合并在一起,故只存在一个合并后的文本段,一个合并后的数据段和一个 bss 段。由于每个段都会被填充为 4K 对齐以满足 x86 的页尺寸,因此文本段为 4K(减去文件中 20 字节长度的 a.out 头部,逻辑上它并不属于该段),数据段和 bss 段每个同样也是 4K 字节。

合并后的文本段包含名为 start-c 的库启动代码,由 m.o 重定位到 0x10a4 的代码,重定位到 0x10b4 的 a.o,以及被重定位到文本段更高地址从 C 库中链接来的例程。数据段,没有显示在这里,按照和文本段相同的顺序包含了合并后的数据段。由于_main 的代码被重定位到地址 0x10a4,所以这个代码要被修改到 start-c 代码的 call 指令中。在 main 例程内部,对字符串 string 的引用被重定位到 0x2024,这是 string 在数据段最终的位置,并且 call指令中地址修改为 0x10b4,这是_a 最终确定的地址。在_a 内部,对_strlen 和_write 的 call 指令也要修改为这两个例程的最终地址。

可执行程序中仍然有很多其它的 C 库例程,没有显示在这里,它们由启动代码和_write直接或间接的调用。由于可执行程序的文件格式不是可以重链接的,且操作系统从已知的固定位置加载它,因此它不包含重定位数据。它带有一个有助于调试器(debugger)工作的符号表,尽管这个程序没有使用这个符号表并且可以将其删除以节省空间。

体系结构的问题

硬件体系结构的两个方面影响到链接器:程序寻址和指令格式。链接器需要做的事情之一就是对数据和指令中的地址及偏移量都要进行修改。两种情况下链接器都必须确保所做的修改符合计算机使用的寻址方式;当修改指令时还需要进一步确保修改结果不能是无效指令。

应用程序二进制接口

每个操作系统都会为运行在该系统下的应用程序提供应用程序二进制接口(Application Binary Interface)。ABI 包含了应用程序在这个系统下运行时必须遵守的编程约定。ABI总是包含一系列的系统调用和使用这些系统调用的方法,以及关于程序可以使用的内存地址和使用机器寄存器的规定。从一个应用程序的角度看,ABI 既是系统架构的一部分也是硬件体系结构的重点,因此只要违反二者之一的条件约束就会导致程序出现严重错误。

内存地址

计算机系统都有主存储器。主存总是表现为一块连续的存储空间,每一个存储位置都有一个数字地址。这个地址从 0 开始,并逐渐增长为某个较大的数字(由地址中的位数决定)。

字节顺序和对齐

由于计算机处理的大多数数据,尤其是程序地址,都是大于 8 位的,所以通过将相邻的字节合为一组,计算机同样可以很好的处理 16 位、32 位、64 位或 128 位的数据。在某些计算机上,尤其是 IBM 和 Motorola,多字节数据的第一个字节(数字地址最低)是高位字节(most significant byte),在其它诸如 DEC 和 Intel 的机器上,第一个字节是低位字节(least significant byte)。

多字节数据通常会被对齐到一些“天生”的边界上。就是说,4 字节的数据必须对齐到4 字节的边界上,2 字节要对齐到 2 字节的边界上,并以此类推。另一种想法就是任何 N 字节数据的地址至少要有 log2(N)个低位为 0。即使在那些引用未对齐数据不会导致故障的系统上,性能的损失也是非常大的,以至于值得我们花费精力来尽可能保持地址的对齐。

很多处理器同样要求程序指令的对齐。多数 RISC 芯片要求指令必须对齐在 4 字节的边界上。

每种体系结构都定义了一系列的寄存器,这是可以由程序指令直接引用的数量很少的固定长度高速存储区域。各种体系结构的寄存器数量是变化的,从 x86 架构的 8 个到某些 RISC 设计的 32 个,寄存器的容量几乎都是和程序地址的大小相同,就是说在一个 32 位地址的系统中寄存器是 32 位的,而在具有 64 位地址的系统上,寄存器就是 64 位的了。

地址构成

当计算机程序执行时,会根据程序中的指令来读写内存。程序的指令本身也存储在内存中,但通常和程序的数据位于内存中不同的部分。

指令在逻辑上是按照存储的顺序被执行的,但通过指定程序中新的地址来执行的跳转指令是例外。每个指令中引用的数据内存地址,每个跳转指令引用的地址,要被加载或存储的数据的地址,或指令要跳转到的地址等,计算机们具有一系列的指令格式和地址构成需要链接器在重定位指令中的地址时予以处理。

指令格式

每条指令都包含一个操作码,它决定了指令做什么,此外还有一个操作数。操作数可以被编码到指令本身(立即操作数),或者放置在内存中。内存中每个操作数的地址总要经过一些计算。有时地址包含在指令中(直接寻址)。更经常的是地址存储在某一个寄存器中(寄存器间接寻址),或通过将指令中的一个常量加上寄存器中的内容计算得来。如果寄存器中的值是一个存储区域的地址,而指令中的常量是存储区域中想要访问的数据的偏移量,这种策略称为基址寻址。如果二者调换过来,并且寄存器中保存的是偏移量,那这种策略就是索引寻址。基址寻址与索引寻址之间的区别不那么好定义,而且很多体系结构都将他们混在一起了。

还有其它更为复杂的地址计算方法也仍在使用中,但是由于它们不包含链接器需要调整的域,因此链接器的多数组成部分都不需为此担心。一些体系结构使用固定长度的指令,而另一些使用变长指令。所有的 SPARC 指令都是 4字节长,并对齐到 4 字节边界。IBM 370 的指令可以是 2、4 或 6 个字节长,指令的头一个字节的头 2 位确定了指令的长度和格式。Intel x86 的指令格式随时都可以是 1 到 14 个字节长。

过程调用和可寻址性

计算机的架构师们在地址引用指令中部分或彻底的放弃了直接寻址,使用索引和基址寄存器来提供寻址所需的大部分或全部地址位。这可以让指令短一些,但与之而来的代价是编程更复杂了。

在没有采用直接寻址的体系结构中,程序在进行数据寻址时存在一个“自举”的问题:一个例程要使用寄存器中的基地址来计算数据地址,但是将基址从内存中加载到寄存器中的标准方法是从存有另一个基址的寄存器中寻址。自举问题就是如何在程序开始时将第一个基地址载入到寄存器中,随后再确保每一个例程都拥有它需要的基地址来寻址它要使用的数据。

过程调用

每种 ABI 都通过将硬件定义的调用指令与内存、寄存器的使用约定组合起来定义了一个标准的过程调用序列。硬件的调用指令保存了返回地址(调用执行后的指令地址)并跳转到目标过程。在诸如 x86 这样具有硬件栈的体系结构中返回地址被压入栈中,而在其它体系结构中它会被保存在一个寄存器里,如果必要软件要负责将寄存器中的值保存在内存中。具有栈的体系结构通常都会有一个硬件的返回指令将返回地址推出栈并跳转到该地址,而其它体系结构则使用一个“跳转到寄存器中地址”的指令来返回。

在一个过程的内部,数据寻址可分为 4 类:

  • 调用者可以向过程传递参数。
  • 本地变量在过程中分配,并在过程返回前释放。
  • 本地静态数据保存在内存的固定位置中,并为该过程私有。
  • 全局静态数据保存在内存的固定位置中,并可被很多不同过程引用。

为每个过程调用分配的一块栈内存称为“栈框架(stack frame)”。

参数和本地变量通常在栈中分配空间,某一个寄存器可以作为栈指针,它可以基址寄存器来使用。SPARC 和 x86 中使用了该策略的一种比较普遍的变体,在一个过程开始的时候,会从栈指针中加载专门的框架指针或基址指针寄存器。这样就可以在栈中压入可变大小的对象,将栈指针寄存器中的值改变为难以预定的值。如果假定栈是从高地址向低地址生长的,而框架指针指向返回地址保存在内存中的位置,那么参数就位于框架指针较小的正偏移量处,本地变量在负偏移量处。由于操作系统通常会在程序启动前为其初始化栈指针,所以程序只需要在将输入压栈或推栈时更新寄存器即可。

对于局部和全局静态数据,编译器可以为一个例程引用的所有静态变量创建一个指针表。如果某个寄存器存有指向这个表的指针,那么例程可以通过使用表指针寄存器将对象在表中的指针读取出来,加载到另一个使用表指针寄存器作为基址的寄存器中,并将第二个寄存器做为基址寄存器来寻址任何想要访问的静态目标。因此,关键技巧是表的地址存入到第一个寄存器中。一个解决方法是将提取表指针的工作交给例程的调用者,因为调用者已经加载了自己的表指针,并可以从自己的表中获取被调用例程的表的指针。

很多情况下,在一个模块中的所有例程会共享一个指针表,这时模块内的调用不需要改变表指针。SPARC 的约定是整个模块共享一个由链接器创建的表,这样表指针寄存器可以在模块内调用时保持不变。同一模块内的调用可以通过一个将被调用例程的偏移量编码到指令中的调用指令实现,这就不需要再将被调用例程的地址加载到寄存器中了。在所有这些优化中,同一模块中对某个例程的调用序列缩减为一个单独的调用指令。又回到地址自举的问题了,这个表指针的链最初是怎么开始的呢?主例程的表可能存储在一个固定的位置,或初始指针值被标注在可执行文件中这样操作系统可以在程序开始前加载它。无论使用的是什么技术,都是需要链接器的帮助的。

分页和虚拟内存

在多数现代计算机系统中,每个程序都可以寻址数量巨大的内存,在一个典型的 32 位系统中这通常是 4GB。很少有机器有那么大的内存,即使有它也需要将其在多个程序之间共享。分页硬件将一个程序的地址空间划分为大小固定的页,典型的大小是 2K 或 4K,同时将计算机的物理内存划分为同样大小的页框。硬件包含了由地址空间中各个页对应的页表项组成的多个页表。

一个页表项可以包含针对某个页的实际内存页框,或通过标志位标注该页“不存在”。当应用程序尝试使用一个不存在的页时,硬件会产生一个由操作系统处理的“页失效”错误。操作系统可以将页的内容从磁盘上复制到一个空闲的内存页框中,并让应用程序继续运行。通过按需将页在内存和磁盘之间移动,操作系统可以提供“虚拟内存”的功能,这样从应用程序看来使用的是比实际大的多的内存。

如果页可以被标注为只读,那么也会提升性能。由于只读页可以重新加载因此它们不需要调出页的操作。如果某个页逻辑上出现在多个地址空间中,一个单独的物理页就可以满足所有的地址空间。

对于 32 位寻址和使用 4K 页的 x86,需要一个具有 2^20个项的页表来覆盖整个地址空间。由于每个页表项通常为 4 字节,这会使页表的大小变成不切实际的 4MB。因此,可分页的架构会通过将高层次页表指向那些最终映射到虚拟地址所对应的物理页框的低层次页表来实现对页表的再次分页。在 370 上,高层次页表(被称为段表)的每一项映射 1MB 的地址空间,这样段表在 31 位地址模式时可以包含 2048 项。如果整个段都不存在的话,那么段表中的每一项都可以是空,否则就会指向将页映射到那个段上的低层次页表。每一个低层次页表共有256 个页表项,每一个对应段中 4K 的内存块。虽然对齐的边界略有差别,但 x86 使用类似的方式划分它的页表。每一个高层次页表(称为页目录)映射 4MB 的地址空间,这样高层次页表共有 1024 项。每一个低层次的页表同样包含 1024 项去映射和该页表对应的 4MB 地址空间中的 1024 个 4K 页。

程序地址空间

每个程序都运行在一个由计算机硬件和操作系统共同定义的地址空间中。链接器和加载器需要生成与这个地址空间匹配的可运行程序。

最简单的地址空间是由 PDP-11 版本的 UNIX 提供的。该地址空间为从 0 开始的 64K 字节。程序的只读代码从位置 0 加载,可读写的数据跟在代码的后面。PDP-11 具有 8K 的页,所以数据从代码后 8K 对齐的地方开始。栈向下生长,从 64K-1 的地方开始,随着栈和数据的增长,对应的区域会变大:当它们相遇时程序就没有可用的地址空间了。

接着 PDP-11 出现的 VAX 版本的 UNIX,使用了相似的策略。每一个 VAX 的 UNIX 程序的头两个字节都是 0(这是一个表明不保存任何东西的寄存器保存掩码)。因此,一个全 0 的空指针总是有效的,并且如果一个 C 程序将空值作为一个字串指针,那么位置 0 的零字节将会当作空字串对待。由于这个原因,上世纪 80 年代的 UNIX 由于空指针的原因包含有很多难以发现的 bug。

Unix 系统将每个程序都放置在单独的地址空间中,而操作系统运行在与应用程序在逻辑上隔离的地址空间中。那些将多个程序放在相同地址空间的操作系统,由于程序的实际加载地址只有在程序运行时才能确定,因此就使得链接器和加载器(尤其是加载器)的工作更为复杂。x86 上的 MS-DOS 系统不使用硬件保护,所以系统和应用程序共享同一个地址空间。当系统运行一个程序的时,它会查找最大的空闲内存块(可能会位于地址空间的任何位置),将程序加载到其中,然后运行它。

MS Windows 采用了一种特殊的加载策略。每个程序按照被加载到一个标准开始地址的方式来链接,但是在可执行程序中带有重定位信息。当 Windows 加载这个程序时,如果可能的话它就将程序放置在这个起始地址处,但如果这个地不可用那就会将它加载到其它地方。

映射文件

虚拟内存系统在真实内存和硬盘之间来回移动数据,当数据无法保存在内存中时就会将它交换到磁盘上。最初,交换出来的页面都是保存在独立于文件系统名字空间的单独匿名磁盘空间上的。换页发明之后不久,设计者们发现通过让换页系统读写命名的磁盘文件可以将换页系统和文件系统统一起来。当一个应用程序将一个文件映射到程序的部分地址空间时,操作系统将那部分地址空间对应的页设置为“不存在”,然后将该文件像这部分地址空间对应的页交换磁盘那样来使用。

处理对映射文件的写操作有三种不同的方法。最简单的办法是将文件以只读方式(RO)映射,任何对映射文件存储数据的操作都会失败,这通常会导致程序终止。第二种方法是将文件以可读写方式(RW)映射,这样对映射文件在内存中副本的修改会在取消映射的时候写回磁盘上。第三种方法是将文件以写时复制方式(COW)映射。这种情况下操作系统会对该页面做一个副本,这个副本会被当作没有映射的私有页来对待。在应用程序看来,由于本程序所做的修改仅对自己可见而对其它程序不可见,因此以 COW 的方式映射文件与分配一块匿名的新内存并将文件内容读入其中很类似。

共享库和程序

在几乎所有能够同时运行多个程序的系统中,每个程序都有一套独立的页面,使各自都有一个逻辑上独立的地址空间。如果单一的程序或单一的程序库在多于一个的地址空间中被使用,若能够在多个地址空间中共享这个程序或程序库的单一副本,那将节省大量的内存。对于操作系统实现这个功能是相当简捷的——只需要将可执行程序文件映射到每一个程序的地址空间即可。不可重定位的代码和只读的数据以 RO 方式映射,可写的数据以 COW 方式映射。操作系统还可以让所有映射到该文件的进程之间共享 RO 和尚未被写的 COW 数据对应的物理页框。

要完成这种共享工作需要链接器予以相当多的支持。在可执行程序中,链接器需要将所有的可执行代码聚集起来形成文件中可以被映射为 RO 的部分,而数据是可以被映射为 COW 的另一部分。每一个段的开始地址都需要以页边界对齐,这既针对逻辑上的地址空间也包括实际的被映射文件。当多个不同程序使用一个共享库时,链接器需要做标记,好让程序启动时共享库可以被映射到它们各自的地址空间中。

位置无关代码

当一个程序在多个不同的地址空间运行时,操作系统通常可以将程序加载到各地址空间的相同位置。这样可以让链接器将程序中所有的地址绑定到固定的位置且在程序加载时不需要进行重定位,因此链接器的工作简单了很多。

共享库使用了位置无关代码(PIC:Position Independnet Code),这是无论被加载到内存中的任何位置都可以正常工作的代码。共享库中的代码通常都是位置无关代码,这样代码可以以 RO 方式映射。数据页仍然带有需要被重定位的指针,但由于数据页将以 COW 方式映射,因此这里对共享不会有什么损失。

嵌入式体系结构

嵌入式系统中的链接会遇到多种在其它环境中很少遇到的问题。在尽可能小的内存容量下让程序跑的尽可能快是非常重要的。

怪异的地址空间

嵌入式系统具有很小且分布怪异的地址空间。一个 64K 的地址空间可能会包括高速的片内 ROM 和 RAM,低速的片外 ROM 和 RAM,片内外围设备,或片外外围设备。也可能会存在多个不连续的 ROM 或 RAM 区域。嵌入式系统的链接器需要有办法来指明被链接程序在内存布局上的大量细节,分配特定类型的代码和数据,甚至将例程和变量分开放入特定的地址。

非一致性内存

对片上内存的引用要比片外内存快很多,因此在同时具有两类内存的系统中,对时间要求最严格的程序需要放在快的内存中。有时候,在链接时将程序的所有对时间敏感的代码放入快速内存是可能的。但此外将数据或代码从慢速内存复制到快速内存也是很有用的,这样多个例程可以在不同时间中共享快速内存。对于这种技巧,如果能够告诉链接器“将这段代码放在位置 XXXX 但将它像在位置 YYYY 那样链接”那将是非常有用的,这样就可以在将代码从低速内存的 XXXX 位置复制到高速内存的 YYYY 位置后程序不会出错了。

内存对齐

DSP 对某些的数据结构有非常严格的内存对齐要求。

目标文件

目标文件中都有什么?

一个目标文件包含五类信息。

  • 头信息:关于文件的整体信息,诸如代码大小,翻译成该目标文件的源文件名称,和创建日期等。
  • 目标代码:由编译器或汇编器产生的二进制指令和数据。
  • 重定位信息:目标代码中的一个位置列表,链接器在修改目标代码的地址时会对它进行调整。
  • 符号:该模块中定义的全局符号,以及从其它模块导入的或者由链接器定义的符号。
  • 调试信息:目标代码中与链接无关但会被调试器使用到的其它信息。包括源代码文件和行号信息,本地符号,被目标代码使用的数据结构描述信息。

设计一个目标文件格式

一个可链接文件包含链接器处理目标代码时所需的扩展符号和重定位信息。目标代码经常被划分为多个会被链接器区别对待的小逻辑段。一个可执行程序中会包含目标代码,但是可以不需要任何符号以及重定位信息。目标代码可以是一个单独的大段,或反映了硬件执行环境的一组小段。根据系统运行时环境细节的不同,一个可加载文件可以仅包含目标代码,或为了进行运行时链接还包含了完整的符号和重定位信息。

代码区段:Unix a.out 文件

具有硬件内存重定位部件的计算机系统(今天几乎所有的计算机都有)通常都会为新运行的程序创建一个具有空地址空间的新进程,这种情况下程序就可以按照从某个固定地址开始的方式被链接,而不需要加载时的重定位。UNIX 的 a.out 目标文件格式就是针对这种情况的。

最简单的情况下,一个 a.out 文件包含一个小文件头,后面接着是可执行代码,然后是静态数据的初始值。后续型号为代码(称为指令空间 I)和数据(称为数据空间 D)提供了独立的地址空间,这样一个程序可以拥有 64K 的代码空间和 64K 的数据空间。为了支持这个特性,编译器、汇编器、链接器都被修改为可以创建两个段的目标文件(代码放入第一个段中,数据放入第二个段中,程序加载时先将第一个段载入进程的 I 空间,再将第二个段载入进程的 D 空间)。

独立的 I 和 D 空间还有另一个性能上的优势:由于一个程序不能修改自己的 I 空间,因此一个程序的多个实体可以共享一份程序代码的副本。在诸如 UNIX 这样的分时系统上,shell(命令解释器)和网络服务进程具有多个副本是很普遍的,共享程序代码可以节省相当可观的内存空间。

a.out 头部

a.out 的头部根据 UNIX 版本的不同而略有变化。

1
2
3
4
5
6
7
8
int a_magic; // 幻数
int a_text; // 文本段大小
int a_data; // 初始化的数据段大小
int a_bss; // 未初始化的数据段大小
int a_syms; // 符号表大小
int a_entry; // 入口点
int a_trsize; // 文本重定位段大小
int a_drsize; // 数据重定位段大小

幻数 a_magic 说明了当前可执行文件的类型。不同的幻数告诉操作系统的程序加载器以不同的方式将文件加载到内存中;我们将在下面讨论这些区别。文本和数据段大小 a_text 和 a_data 以字节为单位标识了头部后面的只读代码段和可读写数据段的大小。由于 UNIX 会自动将新分配的内存清零,因此初值无关紧要或者为 0 的数据不必在 a.out 文件中存储。未初始化数据大小 a_bss 说明了在 a.out 文件中的可读写数据段后面逻辑上存在多少未初始化的数据(实际上是被初始化为 0)。

a_entry 域指明了程序的起始地址,同时 a_syms,a_trsize 和 a_drsize 说明了在文件数据段后的符号表与重定位信息的大小。已经被链接好可以运行的程序中既不需要符号表也不需要重定位信息,所以除非链接器为了调试器加入符号信息,否则在可运行文件中这些域都是0。

与虚拟内存的交互

操作系统加载和启动一个简单的双段文件的过程非常简单

  • 读取 a.out 的头部获取段的大小。
  • 检查是否已存在该文件的可共享代码段。如果是的话,将那个段映射到该进程的地址空间。如果不是,创建一个并将它映射到地址空间中,然后从文件中读取文本段放入这个新的内存区域。
  • 创建一个足够容纳数据段和 BSS 的私有数据段,将它映射到进程的地址空间中,然后从文件中读取数据段放入内存中的数据段并将 BSS 段对应的内存空间清零。
  • 创建一个栈的段并将其映射到进程的地址空间(由于数据堆和栈的增长方向不同,因此栈段通常是独立于数据段的)。将命令行或者调用程序传递的参数放入栈中。
  • 适当的设置各种寄存器并跳转到起始地址。

这种策略相当有效。当 UNIX 系统采用虚拟内存后,对这种简单策略的些许改进还进一步加速了程序加载的速度并节省了相当可观的内存。

在一个分页系统中,上述的简单机制会为每一个文本段和数据段分配新的虚拟内存。由于 a.out 文件已经存储在磁盘中了,所以目标文件本身可以被映射到进程的地址空间中。虚拟内存只需要为程序写入的那些页分配新的磁盘空间,这样可以节省磁盘空间。并且由于虚拟内存系统只需要将程序确实需要的那些页从磁盘加载到内存中(而不是整个文件),这样也加快了程序启动的速度。

对 a.out 文件格式进行少许修改就可以做到这一点,这就够成了被称为 ZMAGIC 的格式。这些变化将目标文件中的段对齐到页的边界。在页大小为 4K 的系统上,a.out 头部扩展为 4K,文本段的大小也要对齐到下一个 4K 的边界。由于 BSS 段逻辑上跟在数据段的后面并在程序加载时被清零,所以没有必要对数据段进行页边界对齐的填充。

ZMAGIC 格式的文件减少了不必要的换页,对应付出的代价是浪费了大量的磁盘空间。a.out 的头部仅有 32 字节长,但是仍需要分配 4K 磁盘空间给它。文本和数据段之间的空隙平均浪费了 2K 空间,即半个 4K 的页。上述这些问题都在被称为 ZMAGIC 的压缩可分页格式中被修正了。

由于并没有什么特别的原因要求文本段的代码必须从地址 0 处开始运行,因此压缩可分页文件将 a.out 头部当成是文本段的一部分(实际上由于未初始化的指针变量经常为 0,位置 0 绝对不是一个程序入口的好地方)。代码紧跟在头部的后面,并将整个页映射为进程的第二个页,而不映射进程地址空间的第一个页,这样对位置 0 的指针引用就会失败。它也产生了一个无害的副作用就是将头部映射到进程的地址空间中了。

QMAGIC 格式的可执行文件中文本和数据段都各自扩充到一个整页,这样系统就可以很容易的将文件中的页映射到地址空间中的页。数据段的最后一页由值为零的 BSS 数据填充补齐;如果 BSS 数据大于可以填充补齐的空间,那么 a.out 的头部中会保存剩余需要分配的 BSS 空间大小。尽管 BSD UNIX 将程序加载到位置 0(或 QMAGIC 格式的 0x1000)处,其它版本的 UNIX 会将程序加载到不同的位置。例如 Motorola 68K 系列上的系统 5(System V)会将程序加载到0x80000000 处,在 386 上会加载到 0x8048000 处。只要地址是页对齐的,并且能够与链接器和加载器达成一致,加载到哪里都没有关系。

重定位:MS-DOS EXE 文件

有一些系统会将所有的程序加载到相同的地址空间。还有一些系统虽然会为程序分配自己的地址空间,但是并不总是将程序加载到相同的地址。在这些情况下,可执行程序会包含多个(通常被称为 fixups 的)重定位项,它们指明了程序中需要在被加载时进行修改的地址位置。具有 fixups 的最简单的格式之一就是 MS-DOS EXE 格式。

DOS 将程序载入到一块连续的可用实模式内存中。如果一个 64K 的段无法容纳整个程序,就需要使用明确的段基址对程序和数据进行寻址,并在程序加载时必须调整程序中的段基址以匹配程序实际加载的位置。文件中的段基址是按照程序将被加载到位置 0 来存储的,所以修正的动作就是将程序实际被加载到的段基地址与存储的段基址相加。就是说,如果程序实际被加载到位置 0x5000,即段基址为 0x500,那么文件中对段基址 0x12 的引用将会重定位为 0x512。由于程序是作为一个整体被重定位的,段内偏移量不会改变,所以加载器不需要修正除段基址之外的其它内容。

每个.EXE 文件都是以所示的头部结构开始的。跟在头部后面的是变量长度相关的额外信息和一个 segment:offset 格式的 32 位修正地址列表。修正地址是程序基地址的相对地址,所以这些修正地址本身也需要被重定位以寻找那些程序中需要被修改的地址。在修正地址列表后的是程序代码。在代码的后面,也许还有会被程序加载器忽略的额外信息。(在下面的例子中,far 类型指针为 32 位,其中 16 位段基址和 16 位段内偏移量)

1
2
3
4
5
6
7
8
9
10
11
12
13
char signature[2] = "MZ";//幻数
short lastsize; //最后一个块使用的字节数
short nblocks; //512 字节块的个数
short nreloc; //重定位项个数
short hdrsize; //以 16 字节段为单位的文件头部尺寸
short minalloc; //需额外分配的最小内存量
short maxalloc; //需额外分配的最大内存量
void far *sp; //初始栈指针
short checksum; //文件校验和
void far *ip; //初始指令指针short relocpos; //重定位修正表位置
short noverlay; //重叠的个数,程序为 0
char extra[]; //重叠所需的额外信息等
void far *relocs[]; //重定位项,从 relocpos 开始

加载.EXE 文件只比加载.COM 文件复杂一点点。

  • 读入文件头部,验证幻数是否有效。
  • 找一块大小合适的内存区域。minalloc 和 maxalloc 域说明了在被加载程序末尾后需额外分配的内存块的最大和最小尺寸(链接器总是缺省的将最小尺寸设置为程序中类似 BSS 的未初始化数据的大小,将最大尺寸设置为 0xFFFF)。
  • 创建一个程序段前缀(Program Segment Prefix),即位于程序开头的控制区域。
  • 在 PSP 之后读入程序的代码。nblocks 和 lastsize 域定义了代码的长度。
  • 从 relocpos 处开始读取 nreloc 个修正地址项。对每一个修正地址,将其中的基地址与程序代码加载的基地址相加,然后将这个重定位后的修正地址作为指针,将程序代码的实际基地址与这个指针指向的程序代码中的地址相加。
  • 将栈指针设置为重定位后的 sp,然后跳转到重定位后的 ip 处开始执行程序。

在少数情况下,程序的不同片段可以用不同的方式重定位。在 286 保护模式下(EXE 文件不支持),虽然可执行文件中的代码和数据段被加载到系统中各自独立的段,但是由于体系结构的原因段基址是不连续的。每一个保护模式的可执行程序在靠近文件开头的位置有一个表列出来程序需要的所有段。系统会创建一个表将可执行程序中的每个段与系统中实际的段址对应起来。在进行地址调整时,系统会在这个表中查找逻辑段址,并将其替换为实际的段址,相比于重定位这更类似一个符号绑定的过程。

符号和重定位

多数目标文件并不是可加载的,但相当一部分是由编译器或汇编器生成传递给链接器或库管理器的中间文件。这些可链接文件比起那些可运行文件来说,要复杂的多。原则上,一个支持链接的加载器可以在程序被加载时完成所有链接器必须完成的功能,但由于效率原因加载器通常都尽可能的简单,以提高程序启动的速度。

可重定位的 a.out 格式

UNIX 系统对于可运行文件和可链接文件都使用相同的一种目标文件格式,其中可运行文件省略掉了那些仅用于链接器的段。文本和数据段的重定位表的大小保存在a_trsizea_drsize中,符号表的尺寸保存在a_syms中。这三个段跟在文本和数据段后。

重定位项有两个功能。当一个代码段被重定位到另一个不同的段基址时,重定位项标注出代码中需要被修改的地方。在一个可链接文件中,同样也有用来标注对未定义符号引用的重定位项,这样链接器就知道在最终解析符号时应当向何处补写符号的值。每一个重定位项包含了在文本或数据段中需被重定位的地址,以及定义了要做什么的信息。该地址是一个需要进行重定位的项目到文本段或数据段起始位置的偏移量。长度域说明了该重定位项目的长度。pcrel 标志表示这是一个“PC(程序计数器,即指令寄存器)相对的”重定位项目,如果是的话,它会在指令中被作为相对地址使用。

外部标志域控制对 index 域的解释,确定该重定位项目是对某个段或符号的引用。如果外部标志为 off,那这是一个简单的重定位项目,index 就指明了该项目是基于哪个段(文本、数据或 BSS)寻址的。如果外部标志为 on,那么这是一个对外部符号的引用,则 index 是该文件符号表中的符号序号。

UNIX 编译器允许任意长度的标识符,所以名字字串全部都在符号表后面的字串表中。符号表项的第一个域是该符号以空字符结尾的名字字串在字串表中的偏移量。在类型字节中,若低位被置位则该符号是全局符号。非外部符号对于链接是没有必要的,但是会被调试器用到。其余的位是符号类型。最重要的类型包括:

  • 文本、数据或 BSS:模块内定义的符号。外部标志位可能设置或没有设置。值为与该符号对应的模块内可重定位地址。
  • abs:绝对非可重定位符号。很少在调试信息以外的地方使用。外部标志位可能设置或没有设置。值为该符号的绝对地址。
  • undefined:在该模块中未定义的符号。外部标志位必须被设置。值通常为 0。

作为一种特例,编译器可以使用一个未定义的符号来要求链接器为该符号的名字预留一块存储空间。如果一个外部符号的值不为零,则该值是提示链接器程序希望该符号寻址存储空间的大小。在链接时,若该符号的定义不存在,则链接器根据其名字在 BSS 中创建一块存储空间,大小为所有被链接模块中该符号提示尺寸中的最大值。如果该符号在某个模块中被定义了,则链接器使用该定义而忽略提示的空间大小。

Unix ELF 格式

ELF 格式即可执行和链接格式(Executable and Linking Format)。ELF 格式有三个略有不同的类型:可重定位的,可执行的,和共享目标(shared objects)。可重定位文件由编译器和汇编器创建,但在运行前需要被链接器处理。可执行文件完成了所有的重定位工作和符号解析(除了那些可能需要在运行时被解析的共享库符号),共享目标就是共享库,即包括链接器所需的符号信息,也包括运行时可以直接执行的代码。
ELF 格式具有不寻常的双重特性。编译器、汇编器和链接器将这个文件看作是被区段(section)头部表描述的一系列逻辑区段的集合,而系统加载器将文件看成是由程序头部表描述的一系列段(segment)的集合。一个段(segment)通常会由多个区段(section)组成。可重定位文件具有区段表,可执行程序具有程序头部表,而共享目标文件两者都有。区段(section)是用于链接器后续处理的,而段(segment)会被映射到内存中。

ELF 文件都是以 ELF 头部起始的。头部被设计为即使在那些字节顺序与文件的目标架构不同的机器上也可以被正确的解码。头 4 个字节是用来标识 ELF 文件的幻数,接下来的 3 个字节描述了头部其余部分的格式。当程序读取了 class 和 byteorder 标志后,它就知道了文件的字节序和字宽度,就可以进行相应的字节顺序和数据宽度的转换。其它的域描述了区段头部或程序头部的大小和位置(如果它们存在的话)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char magic[4] = "\177ELF"; //幻数
char class; //地址宽度, 1 = 32 位, 2 = 64 位
char byteorder; //字节序, 1 = little-endian,2 = big-endian
char hversion; //头部版本,总是 1
char pad[9]; //填充字节
short filetype; //文件类型:1 = 可重定位,2 = 可执行,3 = 共享目标,4 = 转储镜像(core image)
short archtype; //架构类型,2 = SPARC,3 = x86,4 = 68K,等等.
int fversion; //文件版本,总是 1
int entry; //入口地址(若为可执行文件)
int phdrpos; //程序头部在文件中的位置(不存在则为 0)int shdrpos; //区段头部在文件中的位置(不存在则为 0)
int flags; //体系结构相关的标志,总是 0
short hdrsize; //该 ELF 头部的大小
short phdrent; //程序头部表项的大小
short phdrcnt; //程序头部表项个数(不存在则为 0)
short shdrent; //区段头部表项的大小
short phdrcnt; //区段头部表项的个数(不存在则为 0)
short strsec; //保存有区段名称字串的区段的序号

可重定位文件

一个可重定位或共享目标文件可以看成是一系列在区段头部表中被定义的区段的集合。每个区段只包含一种类型的信息,可以是程序代码、只读数据或可读写数据,重定位项,或符号。在模块中定义的符号都是以段的相对地址定义的,因此一个过程(procedure)的入口点也是由包含该过程代码的程序代码区段的相对地址来定义的。此外还存在两个伪段,SHN_ABS(数字 0xfff1)逻辑上包含了绝对不可重定位符号(absolute non-relocatable symbols),SHN_COMMON(数字 0xfff2)包含未初始化的数据块。

1
2
3
4
5
6
7
8
9
10
int sh_name; //名称,可在字串表中索引到
int sh_type; //区段类型
int sh_flags; //标志位,见下
int sh_addr; //若可加载则为内存基址,否则为 0
int sh_offset; //区段起始点在文件中的位置
int sh_size; //区段大小(字节为单位)
int sh_link; //相关信息对应的区段号,若没有则为 0
int sh_info; //区段相关的更多信息
int sh_align; //移动区段时的对齐粒度
int sh_entsize;//若该区段为一个表时其中表项的大小

区段类型包括:

  • PROGBITS:程序内容,包括代码,数据和调试器信息。
  • NOBITS:类似于 PROGBITS,但在文件本身中并没有分配空间。用于 BSS 数据,在程序加载时分配空间。
  • SYMTAB 和 DYNSYM:符号表,后面会有更加详细的描述。SYMTAB 包含所有的符号并用于普通的链接器,DYNSYM 包含那些用于动态链接的符号(后一个表需要在运行时被加载到内存中,因此要让它尽可能的小)。
  • STRTAB:字串表,与 a.out 文件中的字串表类似。与 a.out 文件不同的是,ELF 文件能够而且经常为不同的用途创建不同的字串表,例如全段名称、普通符号名称和动态链接符号名称。
  • REL 和 RELA:重定位信息。REL 项将其中的重定位值加到存储在代码和数据中的基地址值,而 RELA 将重定位需要的基地址也保存在重定位项自身中。
  • DYNAMIC 和 HASH:动态链接信息和运行时符号 hash 表。这里用到了 3 个标志位:ALLOC,意味着在程序加载时该区段要占用内存空间;WRITE 意味着该区段被加载后是可写的;EXECINSTR 即表示该区段包含可执行的机器代码。

一个典型的可重定位可执行程序会有十多个区段。很多区段的名称对于链接器在根据它所支持的区段类型来进行特定的处理(同时根据标志位将不支持的区段忽略或原封不动的传递下去)时,都是有意义的。区段的类型包括:

  • .text 是具有 ALLOC 和 EXECINSTR 属性的 PROGBITS 类型区段。相当于 a.out 的文本段。
  • .data 是具有 ALLOC 和 WRITE 属性的 PROGBITS 类型区段。对应于 a.out 的数据段。
  • .rodata 是具有 ALLOC 属性的 PROGBITS 类型区段。由于是只读数据,因此没有 WRITE 属性。
  • .bss 是具有 ALLOC 和 WRITE 属性的 NOBITS 类型区段。BSS 区段在文件中没有分配空间,因此是 NOBITS 类型,但由于会在运行时分配空间,所以具有 ALLOC 属性。
  • .rel.txt,.rel.data 和.rel.rodata 每个都是 REL 或 RELA 类型区段。是对应文本或数据区段的重定位信息。
  • .init 和.fini,都是具有 ALLOC 和 EXECINSTR 属性的 PROGBITS 类型区段。与.text区段相似,但分别为程序启动和终结时执行的代码。
  • .symtab 和.dynsym 分别是 STMTAB 和 DNYSYM 类型的区段,对应为普通的和动态链接器的符号表。动态链接器符号表具有 ALLOC 属性,因为它需要在运行时被加载。
  • .strtab 和.dynstr 都是 STRTAB 类型的区段,这是名称字串的表,要么是符号表,要么是段表的段名称字串。.synstr 区段保存动态链接器符号表字串,由于需要在运行时被加载所以具有 ALLOC 属性。
  • 此外还有一些特殊的区段诸如.got 和.plt,分别是全局偏移量表(Global Offset Table)和动态链接时使用的过程链接表(Procedure Linkage Table)。
  • .debug 区段包含调试器所需的符号,.line 区段也是用于调试器的,它保存了从源代码的行号到目标代码位置的映射关系。
  • .comment 区段包含着文档字串,通常是版本控制中的版本序号。

还有一个特殊的区段类型.interp,它包含解释器程序的名字。如果这个区段存在,系统不会直接运行这个程序,而是会运行对应的解释器程序并将该 ELF 文件作为参数传递给解释器。例如 UNIX 上多年以来都有可以解释型的自运行文本文件,只需要在文件的第一行加上:#!/path/to/interpreter

ELF 符号表与 a.out 符号表相似,包含一个由表项组成的数组。

1
2
3
4
5
6
7
8
int name; //名称字串在字串表中的位置
int value; //符号值,在可重定位文件中是段相对地址,
//在可执行文件中是绝对地址
int size; //目标或函数的大小
char type:4; //符号类型:数据目标,函数,区段,或特殊文件
char bind:4; //符号绑定类型:局部,全局,或弱符号
char other; //空闲
short sect; //段基址,ABS,COMMON 或 UNDEF

ELF 符号表增加了少许新的域。size 域指明了数据目标(尤其是未定义的 BSS,又使用了公共块技巧)的大小,一个符号的绑定可以是局部的(仅模块内可见),全局的(所有地方均可见),或者弱符号。

弱符号是半个全局符号:如果存在一个对未定义的弱符号的有效定义,则链接器采用该值,否则符号值缺省为 0。

符号的类型通常是数据或者函数。对每一个区段都会有一个区段符号,通常都是使用该区段本身的名字,这对重定位项是有用的(ELF 重定位项的符号都是相对地址,因此就需要一个段符号来指明某一个重定位项目是相对于文件中的哪一个区段)。文件入口点是一个包含源代码文件名称的伪符号。

区段号(即段基址)是相对于该符号的定义所在的那个段的,例如函数入口点都是相对于.text 段定义的。这里还可以看到三个特殊的伪区段,UNDEF 用于未定义符号,ABS 用于不可重定位绝对符号,COMMON 用于尚未分配的公共块(COMMON 符号中的 value 域提供了所需的对齐粒度,size 域提供了尺寸最小值。一旦被链接器分配空间后,COMMON 符号就会被转移到.bss 区段中)。

下面是一个典型的完整的 ELF 文件,包含代码、数据、重定位信息、链接器符号、和调试器符号等若干区段。如果该文件是一个 C++程序,那可能还包含.init、.fini、.rel.init 和.rel.fini 等区段。

1
2
3
4
5
6
7
8
9
10
11
12
ELF 文件头部
.text
.data
.rodata
.bss.sym
.rel.text
.rel.data
.rel.rodata
.line
.debug
.strtab
(区段表,但不将其作为一个区段来考虑)

ELF 可执行文件

一个 ELF 可执行文件具有与可重定位 ELF 文件相同的通用格式,但对数据部分进行了调整以使得文件可以被映射到内存中并运行。文件中会在 ELF 头部后面存在程序头部。程序头部定义了要被映射的段。所示为程序头部,是一个由段描述符组成的数组。

1
2
3
4
5
6
7
8
int type; //类型:可加载代码或数据,动态链接信息,等
int offset; //段在文件中的偏移量
int virtaddr; //映射段的虚拟地址
int physaddr; //物理地址,未使用
int filesize; //文件中的段大小
int memsize; //内存中的段大小(如果包含 BSS 的话会更大些)
int flags; //读,写,执行标志位
int align; //对齐要求,根据硬件页尺大小不同有变动

一个可执行程序通常只有少数几种段,如代码和数据的只读段,可读写数据的可读写段。所有的可加载区段都归并到适当类型的段中以便系统可以通过少数的一两个操作就可以完成文件映射。

ELF 格式文件进一步扩展了 QMAGIC 格式的 a.out 文件中使用的“头部放入地址空间”的技巧,以使得可执行文件尽可能的紧凑,相应付出的代价就是地址空间显得凌乱了些。一个段可以开始和结束于文件中的任何偏移量处,但是段的虚拟起始必须和文件中起始偏移量具有低位地址模对齐的关系,例如,必须起始于一页的相同偏移量处。系统必须将段起始所在页到段结束所在页之间整个的范围都映射进来,哪怕在逻辑上该段只占用了被映射的第一页和最后一页的一部分。

被映射的文本段包括 ELF 头部,程序头部,和只读文本,这样 ELF 头部和程序头部都会在文本段开头的同一页中。文件中仅有的可读写数据段紧跟在文本段的后面。文件中的这一页会同时被映射为内存中文本段的最后一页和数据段的第一页(以 copy-on-write 的方式)。如果计算机具有 4K 的页,并在可执行文件中文本段结束于 0x80045ff,然后数据段起始于 0x8005600。文件中的这一页(即同时存有文本和数据段的页)在内存 0x8004000 处被映射为文本段的最后一页(头 0x600 个字节包含文本段中 0x8004000 到 0x80045ff 之间的内容),并在 0x8005000 处被映射为数据段(0x600 以后的部分包含数据段从 0x8005600 到 0x80056ff
的内容)。

BSS 段也是在逻辑上也是跟在数据段的可读写区段后,在本例中长度为 0x1300 字节,即文件中尺寸与内存中尺寸的差值。数据段的最后一页会从文件中映射进来,但是在随后操作系统将 BSS 段清零时,copy-on-write 系统会该段做一个私有的副本。如果文件中包含.init 或.fini 区段,这些区段会成为只读文本段的一部分,并且链接器会在程序入口点处插入代码,使得在调用主程序之前会调用.init 段的代码,并在主程序返回后调用.fini 区段的代码。

ELF 共享目标包含了可重定位和可执行文件的所有东西。它在文件的开头具有程序头部表,随后是可加载段的各区段,包括动态链接信息。在构成可加载段的各区段之后的,是重定位符号表和链接器在根据共享目标创建可执行程序时需要的其它信息,最后是区段表。

ELF 格式小结

ELF 是一种较为复杂的格式,但它的表项和预期的一样好。它既是一个足够灵活的格式,又是一种高效的可执行格式,同时也可以很方便的将可执行程序的页直接映射到程序的地址空间。它还允许从一个平台到另一个平台的交叉编译和交叉链接,并在 ELF 文件内包含了足以识别目标体系结构和字节序的信息。

存储空间分配

链接器或加载器的首要任务是存储分配。一旦分配了存储空间后,链接器就可以继续进行符号绑定和代码调整。在一个可链接目标文件中定义的多数符号都是相对于文件内的存储区域定义的,所以只有存储区域确定了才能够进行符号解析。与链接的其它方面情况相似,存储分配的基本问题是很简单的,但处理计算机体系结构和编程语言语义特性的细节让问题复杂起来。存储分配的大多数工作都可以通过优雅和相对架构无关的方法来处理,但总有一些细节需要特定机器的专门技巧来解决。

段和地址

每个目标或可执行文件都会采用目标地址空间的某种模式。通常这里的目标是目标计算机的应用程序地址空间,但某些情况下(例如共享库)也会是其它东西。在一个重定位链接器或加载器中的基本问题是要确保程序中的所有段都被定义并具有地址,并且这些地址不能发生重叠(除非有意这样)。

每一个链接器输入文件都包含一系列各种类型的段。不同类型的段以不同的方式来处理。通常,所有相同类型的段,诸如可执行代码段,会在输出文件中被合并为一个段。有时候段是在其它段的基础上合并得到的(如 Fortran 的公共块),以及在越来越多的情况下,链接器本身会创建一些段并将其放置在输出中。存储布局是一个“两遍”的过程,这是因为每个段的地址在所有其它段的大小未确定前是无法分配的。

简单的存储布局

在一种简单而不现实的情形下,链接器的输入文件包含一系列的模块,将它们称为 M1,M2, … Mn,每一个模块都包含一个单独的段,从位置 0 开始长度依次为 L1, L2, … Ln,并且目标地址空间也是从 0 开始。

链接器或加载器依次检查各个模块,按顺序分配存储空间。模块 Mi的起始地址为从 L1到 Li-1相加的总和,链接得到的程序长度为从 L1到 Ln相加的总和。多数体系结构要求数据必须对齐于字边界,或至少在对齐时运行速度会更快些。因此链接器通常会将 Li扩充到目标体系结构最严格的对齐边界(通常是 4 或 8 个字节)的倍数。

多种段类型

除最简单格式外所有的目标格式,都具有多种段的类型,链接器需要将所有输入模块中相应的段组合在一起。在具有文本和数据段的 UNIX 系统上,被链接的文件需要将所有的文本段都集中在一起,然后跟着的是所有的数据,在后面是逻辑上的 BSS(即使 BSS 在输出文件中不占空间,它仍然需要分配空间来解析 BSS 符号,并指明当输出文件被加载时要分配的 BSS 空间尺寸)。这就需要两级存储分配策略。

在读入每个输入模块时,链接器为每个 Ti,Di,Bi按照(就像是)每个段都各自从位置0 处开始的方式分配空间。在读入了所有的输入文件后,链接器就可以知道这三种段各自总的大小 Ttot,Dtot和 Btot。由于数据段跟在文本段之后,链接器将 Ttot加到每一个数据段所分配的地址上,接着,由于 BSS 跟在文本和数据段之后,所以链接器会将 Ttot、Dtot的和加到每一个 BSS 段分配的地址上。同样,链接器通常会将分配的大小按照对齐要求扩充补齐。

段与页面的对齐

如果文本和数据被加载到独立的内存页中,这也是通常的情况,文本段的大小必须扩充为一个整页,相应的数据和 BSS 段的位置也要进行调整。很多 UNIX 系统都使用一种技巧来节省文件空间,即在目标文件中数据紧跟在文本的后面,并将那个(文本和数据共存的)页在虚拟内存中映射两次,一次是只读的文本段,一次是写时复制(copy-on-write)的数据段。这种情况下,数据段在逻辑上起始于文本段末尾紧接着的下一页,这样就不需扩充文本段,数据段也可对齐于紧接着文本段后的 4K(或者其它的页尺寸)页边界。

公共块和其它特殊段

公共块

在最初的 Fortran 系统中,每一个子程序(主程序、函数或者子例程)都有各自局部声明和分配的标量和数组变量。同时还有一个各例程都可以使用的存储标量和数组的公共区域。公共块存储被证明是非常有用的,并且在后续 Fortran 中单一的公共块已经普及为多个可命名的公共块,每一个子程序都可以声明它们所用的公共块。在最初的 40 年中,Fortran 不支持动态存储分配,公共块是 Fortran 程序用来绕开这个限制的首要工具。标准 Fortran 允许在不同例程中声明不同大小的空白公共块,其中最大的尺寸最终生效。Fortran 系统们无一例外的都将它扩展为允许以不同的大小来声明所有类型的公共块,同样还是最大的尺寸最终生效。

在处理公共块时,链接器会将输入文件中声明的每个公共块当作一个段来处理,但并不会将这些段串联起来,而是将相同名称的公共块重叠在一起。这里会将声明的最大的尺寸作为段的大小,除非在某一个输入文件中存在该段的已初始化的版本。在某些系统上,已初始化的公共块是一个单独的段类型,而在另一些系统上它可能只是数据段的一部分。UNIX 链接器总是一贯支持公共块,甚至从最早版本的 UNIX 都具有一个 Fortran 子集的编译器,并且 UNIX 版本的 C 语言传统上会将未初始化的全局变量作为公共块对待。但在 ELF之前的 UNIX 目标文件只有文本、数据和 BSS 段,没有办法直接声明一个公共块。作为一个特殊技巧,链接器将未定义但具有非零初值的符号当作是公共块,而该值就是公共块的尺寸。

C++重复代码消除

在某些编译系统中,C++编译器会由于虚函数表、模板和外部 inline 函数而产生大量的重复代码。这些特性的设计是隐含的期望那种程序所有部分都可以被运行的环境。一个虚函数表(通常简称为 vtbl)包含一个类的所有虚函数(可以被子类覆盖的例程)的地址。每个带有任何虚函数的类都需要一个 vtbl。模板本质上就是以数据类型为参数的宏,并能够根据特定的类型参数集可以扩展为特定的例程。确保是否存在一个对普通例程的引用可供调用是程序员的责任,就是说对如 hash(int)和 hash(char *)每一类 hash 函数都有确定的定义,hash(T)模板可以根据程序中使用 hash 函数时不同的参数数据类型创建对应的 hash 函数。

在每个源代码文件都被单独编译的环境中,最简单的方法就是将所有的 vtbl 都放入到每一个目标文件中,扩展所有该文件用到的模板例程和外部 inline 函数,这样做的结果就是产生大量的冗余代码。

在那些使用简单链接器的系统上,某些 C++系统使用了一种迭代链接的方法,并采用独立的数据库来管理将哪些函数扩展到哪些地方,或者添加 progma(向编译器提供信息的程序源代码)向编译器反馈足够的信息以仅仅产生必须的代码。链接器的方法是让编译器在每个目标文件中生成所有可能的重复代码,然后让链接器来识别和消除重复的代码。

GNU 链接器是通过定义一个“link once”类型的区段(与公共块很相似)来解决这个模板的问题的。如果链接器看到诸如.gnu.linkonce.name 之类的区段名称,它会将第一个明确命名的此类区段保留下来并忽略其它冗余区段。同样编译器会将模板扩展到一个采用简化模板名称的.gnu.linkonce 区段中。

这种策略工作的相当不错,但它并不是万能的。例如,它不能保护功能上并不完全相同的 vtbl 和扩展模板。一些链接器尝试去检查被忽略的和保留的区段是否是每个字节都相同。这种方法是很保守的,但是如果两个文件采用了不同的优化选项,或编译器的版本不同,就会产生报错信息。另外,它也不能尽可能多的忽略冗余代码。在多数 C++系统中,所有的指针都具有相同的内部表示,这意味着一个模板的具有指向 int 类型指针参数的实例和指向float 类型指针参数的实例会产生相同的代码(即使它们的 C++数据类型不同)。某些链接器也尝试忽略那些和其它区段每个字节都相同的 link-once 区段,哪怕它们的名字并不是完全的相同,但这个问题仍然没有得到满意的解决。

符号管理

符号管理是链接器的关键功能。如果没有某种方法来进行模块之间的引用,那么链接器的其它功能也就没有什么太大的用处了。

绑定和名字解析

链接器要处理各种类型的符号。所有的链接器都要处理各模块之间符号化的引用。每个输入模块都有一个符号表。其中的符号包括:

  • 当前模块中被定义(和可能被引用)全局符号。
  • 在被模块中被引用但未被定义的全局符号(通常成为外部符号)。
  • 段名称,通常被当作定义在段起始位置的全局符号。
  • 非全局符号,调试器或崩溃转储(crash dump)分析通常会用到它们。这些符号几乎不会被链接过程用到,但有时候它们经常会和全局符号混在一起,所以链接器至少要能够跳过它们。在另一些情况中它们会在文件中一个单独的表中,或在一个单独的调试信息文件中。

链接器读入输入文件中所有的符号表,并提取出有用的信息,有时就是输入的信息,通常都是关于需要链接哪些东西的。然后它会建立链接时符号表并使用该表来指导链接过程。根据输出文件格式的不同,链接器会将部分或全部的符号信息放置在输出文件中。某些格式会在一个文件中存在多个符号表。例如 ELF 共享库会有一个动态链接所需信息的符号表,和一个单独的更大的用来调试和重链接的符号表。这个设计不见得糟糕。动态链接器所需的表比全部的表通常要小得多,将它独立出来可以加快动态链接的速度,毕竟调试或重链接一个库的机会(相比运行这个库)还是很少的。

符号表格式

链接器中的符号表与编译器中的相近,由于链接器中用到的符号一般没有编译器中的那么复杂,所以符号表通常也更简单一些。在链接器内,有一个列出输入文件和库模块的符号表,保留了每一个文件的信息。第二个符号表处理全局符号,即链接器需要在输入文件中进行解析的符号。第三个表可以处理模块内调试符号,尽管少数情况下链接器也会为调试符号建立完整的符号表,但通常都只需将输入的调试符号传递到输出文件。

在链接器本身内部,符号表通常以表项组成的数组形式来保存,并通过一个 hash 函数来定位表项,或者是由指针组成的数组,并通过 hash 函数来索引,相同 hash 的表项以链表的形式来组织。当需要在表中定位一个符号时,链接器根据符号名计算 hash 值,将该值用桶的个数来取模,以定位某一个 hash 桶,然后遍历其中的符号链表来查找符号。

模块表

链接器需要跟踪整个链接过程中出现的每一个输入模块,即包括明确链接的模块,也包括从库中提取出来的模块。图 2 所示可以产生 a.out 目标文件的 GNU 链接器的简化版模块表结构。由于每个 a.out 文件的关键信息大部分都在文件头部中,该表仅仅是将文件头部复制过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* 该文件名称 */
char *filename;
/* 符号名字串起始地址 */
char *local_sym_name;/* 描述文件内容的布局 */
/* 文件的 a.out 头部 */
struct exec header;
/* 调试符号段在文件内的偏移量,如果没有则为 0 */
int symseg_offset;
/* 描述从文件中加载到内核的数据 */
/* 文件的符号表 */
struct nlist *symbols;
/* 字串表大小,以字节为单位 */
int string_size;
/* 指向字串表的指针 */
char *strings;
/* 下面两个只在 relocatable_output 为真,或输出未定义引用的行号时使用 */
/* 文本和数据的重定位信息 */
struct relocation_info *textrel;
struct relocation_info *datarel;
/* 该文件的段与输出文件的关系 */
/* 该文件中文本段在输出文件核心镜像中的起始地址 */
int text_start_address;
/* 该文件中数据段在输出文件核心镜像中的起始地址 */
int data_start_address;
/* 该文件中 BSS 段在输出文件核心镜像中的起始地址 */
int bss_start_address;
/* 该文件中第一个本地符号在输出文件中符号表中的偏移量,以字节为单位 */
int local_syms_offset;

该表中还包含了指向符号表、字串表(在一个 a.out 文件中,符号名称字串是在符号表外另一个单独的表中)和重定位表在内存中副本的指针,同时还有计算好的文本、数据和 BSS 段在输出中的偏移量。如果该文件是一个库,每一个被链接的库成员还有它自己的模块表表项。

第一遍扫描中,链接器从每一个输入文件中读入符号表,通常是将它们一字不差的复制到内存中。在将符号名放入单独的字串表的符号格式中,链接器还要将符号表读入,并且为了后续处理更容易一些,还要遍历符号表将每一个的名称字串偏移量转换为指向内存中名称字串的指针。

全局符号表

链接器会保存一个全局符号表,在任何输入文件中被引用或者定义的符号都会有一个表项,如图 3 所示。每次链接器读入一个输入文件,它会将该文件中所有的全局符号加入到这个符号表中,并将定义或引用每个符号的位置用链表组织起来。当第一遍扫描完成后,每一个全局符号应当仅有一个定义,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
/* 摘自 GNU ld a.out */
struct glosym
{
/* 指向该符号所在 hash 桶中下一个符号的指针 */
struct glosym *link;
/* 该符号的名称 */
char *name;
/* 作为全局符号的符号值 */
long value;
/* 该符号在文件中的外部 nlist 链表,包括定义和引用 */
struct nlist *refs;
/* 非零值则意味该符号被定义为公共块,该数值即各公共块中的最大尺寸 */
int max_common_size;
/* 非零意味着该全局符号是存在的。库程序不能根据该数值加载 */
char defined;
/* 非零则意味着一个确信被加载的文件中引用了该全局符号。大于 1 的数值是该
符号定义的 n_type 编码
*/
char referenced;
/* 1 表示该符号具有多个定义
2 表示该符号具有多个定义,其中一些是集合元素,并且有一个已经被打印出
来了
*/
unsigned char multiply_defined;
}

由于每个输入文件中的全局符号都被加入到全局符号表中,链接器会将文件中每一个项链接到它们在全局符号表中对应的表项中。重定位项一般通过索引模块自己的符号表来指向符号,因此对于每一个外部引用,链接器必须要对此很清楚,例如模块 A 中的符号 15 名为 fruit,模块 B 中的符号 12 同样名为 fruit,也就是说,它们是同一个符号。每一个模块都有自己的索引集,相应也要用自己的指针向量。

符号解析

在链接的第二遍扫描过程中,链接器在创建输出文件时会解析符号引用。解析的细节与重定位是有相互影响的,这是因为在多数目标格式中,重定位项标识了程序中对符号的引用。在最简单的情况下,即链接器使用绝对地址来创建输出文件(如 UNIX 链接器中的数据引用),解析仅仅是用符号地址来替换符号的引用。如果符号被解析到地址 20486 处,则链接器会将相应的引用替换为 20486。

实际情况要复杂得多。诸如,引用一个符号就有很多种方法,通过数据指针,嵌入到指令中,甚至通过多条指令组合而成。此外,链接器生成的输出文件本身经常还是可以再次链接的。这就是说,如果一个符号被解析为数据区段中的偏移量 426,那么在输出中引用该符号的地方要被替换为可重定位引用的[数据段基址+426]。输出文件通常也拥有自己的符号表,因此链接器还要新创建一个在输出文件中符号的索引向量,然后将输出重定位项中的符号编号映射到这些新的索引中。

特殊符号

很多系统还会使用少量链接器自己定义的特殊符号。所有的 UNIX 系统都要求链接器定义 etext、edata 和 end 符号依次作为文本、数据和 BSS 段的结尾。系统调用sbrk()将 end 的地址作为运行时内存堆的起始地址,所以堆可以连续的分配在已经存在的数据和 BSS 的后面。对于具有构造和析构例程的程序,很多链接器会为每一个输入文件创建指向这些例程的指针表,并通过链接器创建的诸如__CTOR_LIST__这样的符号让该语言的启动代码可以找到这个表并依次调用其中所有的例程。

名称修改

在目标文件符号表和链接中使用的名称,与编译目标文件的源代码程序中使用的名称往往是有差别的。主要原因有 3:避免名称冲突,名称超载,和类型检查。将源代码中的名称转换为目标文件中的名称的过程称为名称修改(name mangling)。

简单的 C 和 Fortran 名称修改

预留名称的问题一直存在。在混合语言的程序中,情况甚至更糟,因为所有语言的代码都要避免使用任何其它语言运行时库中已经用到的名称。解决预留名称问题的方法之一是用其它东西(而不是过程调用)来调用运行时库。UNIX 系统采取的办法是修改 C 和 Fortran 过程的名称这样就不会因为疏忽而与库和其它例程中的名称冲突了。C 过程的名称通过在前面增加下划线来修饰,所以 main 就变成了_main。Fortran 的名称进一步被修改首尾各有一个下划线,所以 calc 就成了_calc_(这种独特的方法使得从 Fortran 中可以调用 C 中名字末尾带有下划线的例程,这样就可以用 C 编写 Fortran 的库)。

在其它系统上,编译器设计者们采取了截然相反的方法。多数汇编器和链接器允许在符号中使用 C 和 C++标识符中禁用的字符,如.或者$。运行库会使用带有禁用字符的名称来避免与应用程序的名称冲突,而不再是修改 C 或 fortran 程序中的名称。

C++类型编码:类型和范围

在一个 C++程序中,程序员可以定义很多具有相同名称但范围不同的函数和变量,对于函数,还有参数类型。一个单独的程序可以具有一个名为 V 的全局变量和一个类中的静态成员 C::V。C++允许函数名重载,即一些具有相同名称不同参数的函数,例如 f(int x)和 f(float x)。类的定义可以括入函数,括入重载名称,甚至括入重新定义了内嵌操作的函数,即一个类可以包含一个函数,它的名字实际上可以是>>或其它内建操作符。

C++类之外的数据变量名称不会进行任何的修改。一个名为 foo 的数组修改后的名称仍为 foo。与类无关的函数名称修改后增加了参数类型的编码,通过前缀__F后面跟表示参数类型的字母串来实现。下面列出了各种可能的类型表示。例如,函数func(float, int, unsigned char)变成了func__FfiUc。类的名称会被当作是各种类型来对待,编码为类名称长度数字后面跟类的名称,例如 4Pair。类还可以包含内部多级子类的名称,这种限定性(qualified)名称被编码为 Q,还有一个数字标明该成员的级别,然后是编码后的类名称。因此 First::Second::Third就变成了Q35First6Second5Third。这意味着采用两个类做为参数的函数f(Pair, First::Second::Third)就变成了f__F4PairQ35First6Second5Third

类型 字母
void v
char c
short s
int i
long l
float f
double d
long double r
varargs e
unsigned U
const C
volatile V
signed S
pointer P
reference R
array of length n An_
function F
pointer to nth member MnS

类的成员函数编码为:先是函数名,然后是两个下划线,接着是编码后的类名称,然后是F和参数,所以cl::fn(void)就变成了fn__2clFv。所有的操作符都具有 4 到 5 个字符的编码后名称,诸如*对应__ml|=对应__aor。包括构造、析构、new 和 delete 在内的特殊函数编码为__ct__dt__nw__dl。因此具有两个字符指针参数的类Pair的构造函数Pair(char *, char*)的名称就变成了__ct__4PairFPcPc。最后,由于修改后的名称会变得很长,因此对具有多个相同类型参数的函数有两种简捷编码。代码Tn表示“与第 n 个参数类型相同”,Nnm表示“n 个参数与第 m 个参数的类型相同”。因此函数segment(Pair, Pair)的名称就成了segment__F4PairT1,而函数trapezoid(Pair, Pair, Pair, Pair)的名称就是trapezoid__F4PairN31。名称修改可以为每一个可能的 C++类提供唯一的名称,相应的代价就是在错误信息和列表中会出现惊人长度和(在没有链接器和调试器支持下)难以理解的名称。尽管如此,C++还有一个本质上的问题就是名字空间相当巨大。任何表示 C++对象名称的策略都会具有和名称修改相近的冗余,而名称修改的优势在于至少还有一些人可以读懂它。

名称修改的早期用户经常会发现虽然链接器在理论上支持长名称,但实际上长名称效果并不很好,尤其针对具有大量仅最后几个字符不同的名称的程序,性能非常糟糕。幸运的是,符号表算法是一个很好理解的方法,我们可以期望链接器通过它顺利的处理长名称。

链接时类型检查

链接器类型检查的想法非常简单。多数语言都有声明了参数类型的过程,如果调用者没有将被调用过程期望的参数个数或类型传递给被调用者,那就是错误,如果调用者和被调用者在不同的文件中被编译,那这种错误是非常难以察觉的。对于链接器类型检查,每一个定义和未定义的全局符号都会有一个用字串表示的参数和返回值类型,与名称修改中的 C++参数类型相近。在链接器解析一个符号时,它将引用处的类型串与符号定义处的类型串进行比较,如果不匹配则报错。这个策略的好处之一就是链接器根本不需要理解类型编码的含义,仅仅比较字串是否相同就可以了。

即使在一个支持 C++名称修改的环境中,由于并不是所有的 C++类型信息都会被编码到修改的名称中,因此这种类型检查仍然非常有用。通过与此类似的策略来进行函数返回值类型、全局数据类型的检查也是非常有益的。

维护调试信息

编译器通过将调试信息插入目标文件来实现的,调试信息包括源代码行号到目标代码地址的映射,并描述了程序中用到的所有函数、变量、类型和数据结构。

行号信息

所有基于符号的调试器都必须将程序地址和源代码行号对应起来。这样就可以通过调试器将断点放入代码的适当位置来实现用户基于源代码行号的断点设置,并可以让调试器将调用堆栈中的程序地址和错误报告中的源代码行号关联起来。除优化编译代码外,行号信息是很简单的。优化编译的代码中会去除一些代码,导致目标文件中的代码序列与源代码行号的序列不匹配。

对于编译器生成代码所对应源代码文件中的每一行语句,编译器会产生一个行号项(包括行号和代码开始位置)。如果一个程序地址跨越了两个行号项,调试器会将两个行号中较小的报告出来。行号还需要被文件名称(包括源文件名称和头文件名称)限定。有一些格式会通过创建一个文件列表并将文件索引放入每一个行号项中来实现这一点,行号列表中的“begin include”和“end include”项,内在的维护了有行号成员组成的栈。

当编译器优化根据语句生成不连续的代码时,一些目标格式(DWARF)让编译器将每一个字节都映射回源代码中的一行,这会占用进程的大量空间,而其它格式则仅仅产生一个大概的位置。

符号和变量信息

编译器还要为每一个程序变量生成名称、类型和位置。调试符号信息某种程度上要比名称修改更为复杂,因为它不仅要对类型名称编码,还有定义类型时的数据结构类型,这样才能保证调试器能够正确处理一个数据结构中的所有子域的格式。

符号信息可以是一个隐式或显式的树结构。每个文件的最顶层是在最顶层定义的类型、变量和函数的列表,每一个内部是数据结构的子域,或函数内部定义的变量,诸如此类。在函数内部,包含“begin block”和“end block”的树标识了对行号的引用,这样调试器就可以指出程序中每一个变量的范围了。

符号信息中最有趣的部分是位置信息。静态变量的位置不会改变,但一个例程中的局部变量可能是静态的,可能在栈里、在寄存器里、在优化后的代码里,在例程的不同部分可能会从一个地方移动到另一个地方。在多数体系结构上,标准的例程调用序列会为每一个嵌套的例程维护保存堆栈和框指针(frame pointer)的链,每个例程中的局部栈变量存放在相对于框指针的已知偏移量处。在叶子例程或者没有分配局部栈变量的例程中,有一个通常使用的优化就是跳过对框指针的设置。为了正确解释栈的调用轨迹并在没有框指针的例程中寻找局部变量,调试器就必须清楚这些。

实际的问题

多数情况下,链接器仅仅传递调试信息而不对其进行解释,也可能在这个过程中会重定位和段相关的地址。链接器开始做的一件事情就是探测和去除重复调试信息。在 C 和某些特定的 C++中,程序通常都会有一系列定义类型和声明函数的头文件,每一个源文件会将定义了该文件可能使用的类型和函数的头文件都包括进来。

编译器会为每一个源代码文件包括的所有头文件中的所有内容都扫描生成调试信息。这意味着如果某个特定的头文件被 20 个会编译和链接到一起的源文件所包括的话,那链接器将会收到该文件的 20 份调试信息副本。虽然保留这些冗余信息调试器工作起来不会有任何麻烦,但头文件,尤其是在 C++中会有大量的头文件,这意味着重复的头文件信息是相当巨大的。链接器可以放心的忽略掉重复的部分,这样既可以加快链接器和调试器的速度,也可以节省空间。某些情况下,编译器会将调试信息直接放到文件或数据库中供调试器读取,而绕过了链接器。这样链接器就只需要添加和升级与分布在源文件中的各个段相对位置有关的信息即可,而诸如跳转表之类的数据会由链接器自己来创建。

当调试信息存储在目标文件中时,有时候调试信息会和链接器符号表混杂在一个大的符号表中,而有时,它们是独立的。很多年来,UNIX 系统一点一点增加了编译器中的调试信息,最后就变成了现在这个巨大的符号表。包括微软 ECOFF 在内的其它一些格式趋向于将链接器符号、调试符号和行号信息分开处理。

有时调试信息结果会存储到输出文件中,有时会输出到单独的调试文件,有时两者都会有。在构建过程中将所有调试信息都放到输出文件中的做法有一个显而易见的好处,就是调试程序所需要的信息都存放在一个地方。明显的缺点就是这将导致可执行程序体积非常庞大。

库的目的

从本质上说,库文件就是由多个目标文件聚合而成的,通常还会加入一些有助于快速查找的目录信息。

库的格式

最简单的库格式就是仅仅将目标模块顺序排列。在诸如磁带和纸带这样的顺序访问介质上,对于增加目录要注意的是,由于链接器不得不将整个库读入,因此跳过库成员和将他们读入的速度差不多。但在磁盘上,目录可以相当显著的提高库搜索速度,现在已经成为了标准组件。

UNIX 链接器库使用一种称为“archive”的格式,它实际上可以用于任何类型文件的聚合,但实践中很少用于其它地方。库的组成,首先是一个 archive 头部,然后交替着是文件头部和目标文件。最早的 archive 没有符号目录,只有一系列的目标文件,但后续版本就出现了多种类型的目录。

所有的现代 UNIX 系统都采用大同小异的 archive 格式,如下所示。该格式在 archive头部中只使用文本字符,这意味着文本文件的 archive 文件本身就是文本的。archive 文件都是以 8 字符的标志串!<arch>\n开头,其中\n是换行符。在每一个 archive 成员之前是一个 60 字节的头部,包含有:

  • 该成员名称,补齐到 16 个字符(下面会讲到)。
  • 修改时间,由从 1970 年到当时的十进制秒数表示。
  • 十进制数字表示的用户和组 ID。
  • 一个八进制数表示的 UNIX 文件模式。
  • 以字节为单位的十进制数表示的文件尺寸。如果该尺寸为奇数,那么文件的内容中会补齐一个换行符使得总长度为偶数,但这个补齐的字符不会计算在文件尺寸域中。
  • 保留的两个字节,为引号和换行符。这样就可以让头部成为一行文本,并可用来简单的验证当前头部的有效性。

每一个成员头部都会包含修改时间、用户和组 ID、文件模式,尽管链接器会将它们忽略。

1
2
3
4
5
6
7
8
9
10
File header:
!<arch>\n
Member header:
char name[16]; /* 成员名称 */
char modtime[12]; /* 修改时间 */
char uid[6]; /* 用户 ID */
char gid[6]; /* 组 ID */
char mode[8]; /* 8 进制文件模式 */
char size[10]; /* 成员大小 */
char eol[2]; /* 保留空间,一对引号/换行符 */

成员名称是 15 个字符或更少,紧随其后的空格将它补齐为 16 个字符,或者在 COFF 或 ELF 的 archive 格式中,会在斜杠后面跟随足够多的空格将总数补齐为 16 个字符。

搜索库文件

一个库文件在创建后,链接器还要能够对它进行搜索。库的搜索通常发生在链接器的第一遍扫描时,在所有单独的输入文件都被读入之后。如果一个或多个库具有符号目录,那么链接器就将目录读入,然后根据链接器的符号表依次检查每个符号。如果该符号被使用但是未定义,链接器就会将符号所属文件从库中包含进来。仅将文件标识为稍后加载是不够的,链接器必须像处理那些在显式被链接的文件中的符号那样,来处理库里各个段中的符号。段会记入段表,而符号,包括定义的和未定义的,都会记入全局符号表。一个库例程引用了另一个库中例程的符号是相当普遍的现象,譬如诸如 printf 这样的高级 I/O 例程会引用像 putc 或 write 这样的低级例程。

库符号解析是一个迭代的过程,在链接器对目录中的符号完成一遍扫描后,如果在这遍扫描中它又从该库中包括进来了任何文件,那么就还需要再进行一次扫描来解析新包括进来的文件所需的符号,直到对整个目录彻底扫描后不再需要括入新的文件为止。并不是所有的链接器都这么做的,很多链接器只是对目录进行一次连续的扫描,并忽略在库中一个文件对另一个更早扫描的文件的向后依赖。像诸如 tsort 和 lorder 这样的程序可以尽量减少由于一遍扫描给链接器带来的困难,不过并不推荐程序员通过显式的将相同名称的库在链接器命令行中列出多次来强制进行多次扫描并解析所有符号。

UNIX 链接器和很多 Windows 链接器在命令行或者控制文件中会使用一种目标文件和库混合在一起的列表,然后依次处理,这样程序员就可以控制加载目标代码和搜索库的顺序了。虽然原则上这可以提供相当大的弹性并可以通过将同名私有例程列在库例程之前而在库例程中插入自己的私有同名例程,在实际中这种排序的搜索还可以提供一些额外的用处。程序员总是可以先列出所有他们自己的目标文件,然后是任何应用程序特定的库,然后是和数学、网络等相关的系统库,最后是标准系统库。

当程序员们使用多个库的时候,如果库之间存在循环依赖的时候经常需要将库列出多次。就是说,如果一个库 A 中的例程依赖一个库 B 中的例程,但是另一个库 B 中的例程又依赖了库 A 中的另一个例程,那么从 A 扫描到 B 或从 B 扫描到 A 都无法找到所有需要的例程。当这种循环依赖发生在三个或更多的库之间时情况会更加糟糕。告诉链接器去搜索 A B A 或者 B A B,甚至有时为 A B C D A B C D,这种方法看上去很丑陋,但是确实可以解决这个问题。

性能问题

和库相关的主要性能问题是花费在顺序扫描上的时间。一旦符号目录成为标准之后,从一个库中读取输入文件的速度就和读取单独的输入文件没有什么明显差别了,而且只要库是拓扑排序的,那链接器在基于符号目录进行扫描时很少会超过一遍。如果一个库有很多小尺寸成员的话,库搜索的速度也会很慢。一个典型的 UNIX 系统库有超过 600 个成员。尤其是现在很普遍的一种情况就是库的所有成员会在运行时合并为一个单一的共享库,因此如果创建一个单一的目标文件包定义库中所有的符号,而在链接时使用这个目标文件而不进行库的搜索,那么这种方法的速度似乎可以更快一点。

弱外部符号

符号解析和库成员选择中所采用的简单的定义引用模式对很多应用而言显得灵活有余效率不足。例如,大多数 C 程序会调用 printf 函数族中的例程来格式化输出数据。printf可以格式化各种类型的数据,包括浮点类型。这就意味着任何使用 printf 的程序都会将浮点库链接进来,即便它根本不使用浮点数。

C库的布局见下,它利用了链接器顺序搜索库的特点。如果程序使用了浮点,那么对 fltused 的引用将会导致链接真正的浮点例程,包括真正的 fcvt(浮点输出例程)。然后当 I/O 模块被链接进来以定义 printf 时,就已经有一个可以满足 I/O 模块引用的 fcvt 在那里了。在那些不使用浮点的程序中,由于不会有任何未解析的符号,在 I/O 模块中引用的 fcvt 将会
解析为库中跟在 I/O 例程后面的伪2浮点例程,因此真正的浮点例程将不会被加载。

1
2
3
真正的浮点模块,定义 fltused 和 fcvt
I/O 模块,定义调用 fcvt 的 printf 函数
伪浮点例程,定义了伪 fcvt

虽然这个技巧可以工作,但用它处理多于一个或两个以上的符号时就会变得很难处理,而且它的正确性严重依赖于库中模块的顺序,尤其在重新构建库之后很容易产生问题。解决这个困境的方法就是弱外部符号,就是不会导致加载库成员的外部符号。如果该符号存在一个有效的定义,无论是从一个显式链接的文件还是普通的外部引用而被链接进来的库成员中,一个弱外部符号会被解析为一个普通的外部引用。但是如果不存在有效的定义,弱外部符号就不被定义而实际上解析为 0,这样就不会被认为是一个错误。在上面这个例子中,I/O 模块将会产生一个对 fcvt 的弱引用,真正的浮点模块在库中跟在 I/O 模块后面,并且不再需要伪例程。现在如果有一个对 fltused 的引用,则链接浮点例程并定义 fcvt。否则,对 fcvt 的引用保持未定义。这将不再依赖于库的顺序,即使对于对库进行多次扫描解析也没有问题。

ELF 还添加了另一种弱符号,和弱引用(weak reference)等价的弱定义(weak definition)。“弱定义”定义了一个没有有效的普通定义的全局符号。如果存在有效的普通定义,那么就忽略弱定义。弱定义并不经常使用,但在定义错误伪函数而无须将其分散在独立的模块中的时候,是很有用的。

重定位

为了决定段的大小、符号定义、符号引用,并指出包含那些库模块、将这些段放置在输出地址空间的什么地方,链接器会将所有的输入文件进行扫描。扫描完成后的下一步就是链接过程的核心,重定位。由于重定位过程的两个步骤,判断程序地址计算最初的非空段,和解析外部符号的引用,是依次、共同处理的,所以我们讲重定位即同时涉及这两个过程。

链接器的第一次扫描会列出各个段的位置,并收集程序中全局符号与段相关的值。一旦链接器确定了每一个段的位置,它需要修改所有的相关存储地址以反映这个段的新位置。在大多数体系结构中,数据中的地址是绝对的,那些嵌入到指令中的地址可能是绝对或者相对的。

硬件和软件重定位

硬件重定位允许操作系统为每个进程从一个固定共知的位置开始分配独立的地址空间,这就使程序容易加载,并且可以避免在一个地址空间中的程序错误破坏其它地址空间中的程序。软件链接器或加载器重定位将输入文件合并为一个大文件以加载到硬件重定位提供的地址空间中,然后就根本不需要任何加载时的地址修改了。

在诸如 286 或 386 那样有几千个段的机器上,实际上有可能做到为每一个例程或全局数据分配一个段,独立的进行软件重定位。每一个例程或数据可以从各自段的 0 位置开始,所有的全局引用通过查找系统段表中的段间引用来处理并在程序运行时绑定。不幸的是,x86段查找非常的慢,而且如果程序对每一个段间模块调用或全局数据引用都要进行段查找的话那速度要比传统程序慢的多。由于可信的理由,程序文件最好绑定在一起并且在链接时确定地址,这样它们在调试时静止不变而出货后仍能保持一致性。

链接时重定位和加载时重定位

很多系统即执行链接时重定位,也执行加载时重定位。链接器将一系列的输入文件合并成一个准备加载到特定地址的单一输出文件。当这个程序被加载后,所存储的那个地址是无效的,加载器必须重新定位被加载得程序以反应实际的加载地址。实际的地址是根据有效的存储空间而定的,这个程序在被加载时总是会被重定位的。

加载时重定位和链接时重定位比起来就颇为简单了。在链接时,不同的地址需要根据段的大小和位置重定位为不同的位置。在加载时,整个程序在重定位过程中会被认为是大的单一段,加载器只需要判断名义上的加载地址和实际加载地址的差异即可。

符号和段重定位

链接器的第一遍扫描将各个段的位置列出,并收集程序中所有全局符号和段相关的值。一旦链接器决定了每一个段的位置,它就需要调整存储地址。

  • 数据地址和段内绝对程序地址引用需要进行调整。例如,如果一个指针指向位置 1000,但是段基址被重定位为 1000,那么这个指针就需要被调整到位置 1000。
  • 程序中的段间引用也需要被调整。绝对地址引用要调整为可以反映目标地址段的新位置,同样相对地址需要调整为可以同时反映目标段和引用所在段的新位置。
  • 对全局符号的引用需要进行解析。如果一个指令调用了例程 detonate,并且 detonate 位于起始地址为 1000 的段的偏移地址 500,在这个指令中涉及到的地址要调整为 1500。

重定位和符号解析所要求的条件有些许不同。对于重定位,基址的数量相当小,也就是一个输入文件中的段的个数,不过目标文件格式允许对任何段中任何地址的引用进行重定位。对于符号解析,符号的数量远远大的多,但是大多数情况下链接器只需要对符号做一件事,即将符号的值插入到程序的一个字大小的空间中。

很多链接器将段重定位和符号重定位统一对待,这是因为它们将段当作是一种值为段基址的“伪符号”。这使得和段相关的重定位就成了和符号相关的重定位的特例。即使在将两种重定位统一对待的链接器中,此二者仍有一个重要区别:一个符号引用包括两个加数,即符号所在段的基值和符号在段内的偏移地址。有一些链接器在开始进入重定位阶段之前就会预先计算所有的符号地址,将段基址加到符号表中符号的值中。当每一项被重定位时会查找到段基址并相加。大多数情况下,并没有强制的理由要以这种或那种方法来进行这种操作。在少数链接器,尤其是那些针对实模式 x86 代码的链接器中,一个地址可以被重定位到和若干不同段相关的多个地址上,因此链接器只需要确定在上下文中一个特定引用的符号在特定段中的地址。

符号查找

目标代码格式总是将每个文件中的符号当作数组对待,并在内部使用一个小整数指代符号,即数组的索引。这对链接器带来了一些小麻烦,每一个输入文件均有不同的索引,如果输出文件是可以重链接的话那它们也会有不同的索引。最直截了当的解决办法是为每个输入文件保留一个指针数组,指向全局符号表中的表项。

基本的重定位技术

每一个可重定位的目标文件都含有一个重定位表,其中是在文件中各个段里需要被重定位的一系列地址。链接器读入段的内容,处理重定位项,然后再解决整个段,通常就是将它写入到输出文件中。通常而不总是,重定位是一次操作,处理后的结果文件不能被重定位第二次。但一些目标文件格式,是可以重定位的并在输出文件中包含所有重定位信息。对于 UNIX 链接器,有一个选项能产生可再次链接的输出文件,在某些情况下,尤其是共享库,由于它在加载时需要被重新定位因此总是带有重定位信息。

在最简单的情况中,一个段的重定位信息仅是段中需要被重定位的位置列表。在链接器处理段时,它将段基址加上由重定位项标识的每个位置的地址。这就处理了直接寻址和内存中指向某个段的指针数值。

1
address | address | address | ...

由于支持多个段和寻址模式的原因,在现代计算机上实际的程序会比这更复杂一些。经典的 UNIX a.out 格式,可能是解决这些问题的最简单的实例。

1
2
3
4
5
int address /* 文本或数据段中的偏移量 */
unsigned int r_symbolnum :24, /* 加到符号上的序数号 */
r_pcrel :1, /* 如果是指令相关的则为 1 */
r_length :2, /* 数值宽度的以 2 为底的 log 数 */
r_extern :1, /* 如果需要将符号加到数值上则为 1 */

每个目标文件都有两个重定位项集合,一个是文本段的,一个是数据段的(bss 段被定义为全 0,因此没有什么需要重定位的)。每一个重定位项都有标志位 r_extern 指明它是段相关或者符号相关的项。如果该位为空,它是段相关的并且 r_symbolnum 实际上是段的一个代码,可能是 N_TEXT(4), N_DATA(6),或者 N_BBS(8)。pc_relative 位指明该引用针对当前位置是绝对还是相对的。

每一个重定位项的其它多余信息是和它的类型及对应的段相关的。在下面的讨论中,TR,DR 和 BR 依次分别是文本段、数据段、BSS 段的重定位后基址。

对同一个段中的指针或直接地址,链接器将地址 TR 或 DR 加到段中已经保存的数值上。对于从一个段到另一个段的指针或直接地址,链接器将目标段的重定位基址,TR,DR或 BR,加到存储的数值上。由于 a.out 格式的输入文件中已经带有每一个重定位到新文件的段中的目标地址,这就是所有必须的了。例如,假定在输入文件中,文本从地址 0 开始,数据从地址 2000 开始,并且在文本段中的一个指针指向数据段中偏移量为 200 的位置。在输入文件中,被存储的指针的值为 2200。如果最后在输出文件中数据段的重定位位置为 15000,那么 DR 将为 13000,链接器将会把 13000 加入到已存在的 2200 产生最后的数值 15200。

可重链接和重定位的输出格式

有一小部分格式是可以重链接的,即输出文件带有符号表和重定位信息,这样可以作为下一次链接的输入文件来使用。很多格式是可以重定位的,这意味着输出文件保存有供加载时重定位使用的重定位信息。

对于可重链接文件,链接器需要从输入文件的重定位项中建立输出文件的重定位项。有一些重定位项被原样传递给输出了,有一些被修改了,还有一些被忽略了。对于那些不在相连段中且段相对地址固定的重定位项,通常会直接传递给输出而不需要对段索引进行修改,这是因为最终链接器还会对其进行链接。而在那些段相连格式中的重定位项,每一项的偏移量需要修改。例如,在一个被链接的 a.out 格式文件中,有一个位于某个文本段中偏移量为400 的段相对地址重定位向,如果另一个段与它所在的段相连且重定位在地址 3500 处,那么这个重定位项就要被修改为 3900 而不是 400。

符号解析项可以不加修改的传递,或因为段重定位而被修改,或被忽略。如果一个外部符号仍未被定义,那么链接器会传递这个重定位项给输出,可能会为了反映链接的段而修改偏移量和符号索引,以及输出文件符号表中的符号顺序。若这个符号被链接器根据符号引用的细节而解析。如果这个引用是同一个段中的程序计数器相对地址,鉴于引用的相对地址和目标不会移动,故链接器可以忽略掉它的重定位项。如果这个引用是绝对引用或段间引用,那重定位项就是相对于段的。

对于可以重定位但不能重链接的输出格式,链接器忽略掉除相对段地址固定的以外所有的重定位项。

其它重定位格式

虽然多数重定位项的普遍格式是数组,但也有别的可能,包括链表和位图。多数格式也具有需要被链接器特殊对待的段。

以链表形式组织的引用

对于外部符号引用,一种意料之外的有效格式是在目标文件自身中包含的引用链表。符号表项指向一个引用,对应位置的一个字(译者注:即 2 个字节)宽的数据指向后面的另一个引用,一直延伸下去直到遇到诸如空或者-1 这样的截止符。这种方法在那些地址引用是完全一个字宽的体系结构上有效,或者至少引用地址宽度足以表示目标文件中段的最大尺寸。

但这个技巧不能解决带偏移量的符号引用,对于代码引用这个限制通常是可以接受的,但是对于数据引用就有问题了。例如在 C 语言中,可以写一个指向数组中间的被初始化的静态指针:

1
2
extern int a[];
static int *ap = &a[3];

在 32 位的机器上,ap 的内容是 a 加上 12。和此问题差不多的还有对数据指针使用这种方法,或对无偏移量引用的普通情况使用了链表,或对带偏移量引用其它处理方式。

特殊情况的重定位

很多目标文件格式都有“弱”外部符号:如果输入文件碰巧定义了它的话,那么它就会被当作是普通的全局符号,否则就为空。无论是哪种方式,都会像其它符号那样进行引用解析。

加载和重叠

加载是将一个程序放到主存里使其能运行的过程。链接加载器和单纯的加载器没有太大的区别,主要和最明显的区别在于前者的输出放在内存重而不是在文件中。

基本加载

依赖于程序是通过虚拟内存系统被映射到进程地址空间,还是通过普通的 I/O 调用读入,加载会有一点小小的差别。在多数现代系统中,每一个程序被加载到一个新的地址空间,这就意味着所有的程序都被加载到一个已知的固定地址,并可以从这个地址被链接。这种情况下,加载是颇为简单的:

  • 从目标文件中读取足够的头部信息,找出需要多少地址空间。
  • 分配地址空间,如果目标代码的格式具有独立的段,那么就将地址空间按独立的段划分。
  • 将程序读入地址空间的段中。
  • 将程序末尾的 bss 段空间填充为 0,如果虚拟内存系统不自动这么做得话。
  • 如果体系结构需要的话,创建一个堆栈段(stack segment)。
  • 设置诸如程序参数和环境变量的其他运行时信息。
  • 开始运行程序。

如果程序不是通过虚拟内存系统映射的,读取目标文件就意味着通过普通的 read 系统调用读取文件。在支持共享只读代码段的系统上,系统检查是否在内存中已经加载了该代码段的一个拷贝,而不是生成另外一份拷贝。在进行内存映射的系统上,这个过程会稍稍复杂一些。系统加载器需要创建段,然后以页对齐的方式将文件页映射到段中,并赋予适当的权限,只读(RO)或写时复制(COW)。在某些情况下,相同的页会被映射两次,一个在一个段的末尾,另一个在下一个段的开头,分别被赋予 RO 和 COW 权限,格式上类似于紧凑的 UNIX a.out。由于数据段通常是和 bss 段是紧挨着的,所以加载器会将数据段所占最后一页中数据段结尾以后的部分填充为 0,然后在数据分配足够的空页面覆盖 bss 段。

带重定位的基本加载

仅有一小部分系统还仍然为执行程序在加载时进行重定位,大多数都是为共享库在加载时进行重定位。

加载时重定位要比链接时重定位简单的多,因为整个程序作为一个单元进行重定位。例如,如果一个程序被链接为从位置 0 开始,但是实际上被加载到位置 15000,那么需要所有程序中的空间都要被修正为“加上 15000”。在将程序读入主存后,加载器根据目标文件中的重定位项,并将重定位项指向的内存位置进行修改。加载时重定位会表现出性能的问题,由于在每一个地址空间内的修正值均不同,所以被加载到不同虚拟地址的代码通常不能在地址空间之间共享。

位置无关代码

对于将相同程序加载到普通地址的问题的一个常用的解决方案就是位置无关代码(position independent code, PIC)。他的思想很简单,就是将数据和普通代码中那些不会因为被加载的地址改变而变化的代码分离出来。这种方法中代码可以在所有进程间共享,只有数据页为各进程自己私有。

在现代体系结构中,生成 PIC 可执行代码并不困难。跳转和分支代码通常是位置相关的,或者与某一个运行时设置的基址寄存器相关,所以需要对他们进行非运行时的重定位。问题在于数据的寻址,代码无法获取任何的直接数据地址。由于代码是可重定位的,而数据不是位置无关的。普通的解决方案是在数据页中建立一个数据地址的表格,并在一个寄存器中保存这个表的地址,这样代码可以使用相对于寄存器中地址的被索引地址来获取数据。这种方式的成本在于对每一个数据引用需要进行一次额外的重定位,但是还存在一个问题就是如何获取保存到寄存器中去的初始地址。

例程指针表

在许多 UNIX 系统中采用的一种简单修改是将一个过程的数据地址假当作这个过程的地址,并在这个地址上放置一个指向该过程代码的指针。如要调用一个过程,调用者就将该例程的数据地址加载到约定好的数据指针寄存器,然后从数据指针指向的位置中加载代码地址到一个寄存器,然后调用这个历程。这很容易实现,而且性能还算不错。

目录表

IBM AIX 使用了这种方案的改良版本。AIX 程序将多个例程组成模块,模块就是使用单独的或一组相关的 C/C++源代码文件生成的目标代码。每个模块的数据段保存着一个目录表(Table Of Content, TOC),该表是由模块中所有例程和这些例程的小的静态数据的指针组成的。寄存器 2 通常用来保存当前模块的 TOC 地址,在 TOC 中允许直接访问静态数据,并可通过 TOC 中保存的指针间接访问代码和数据。由于调用者和被调用者共享相同的 TOC,因此在一个模块内的调用就是一个简单的 call 指令。模块之间的调用必须在调用之前切换 TOC,调用后再切换回去。

编译器将所有的调用都生成为 call 指令,其后还紧跟一个占位控操作指令 no-op,对于模块内调用这是正确的。当链接器遇到一个模块间调用时,他会在模块文本段的末尾生成一个称为 global linkage 或 glink 的例程。Glink 将调用者的 TOC 保存在栈中,然后从调用者的 TOC 中指针中加载被调用者的 TOC 和各种地址,然后跳转到要调用的例程。链接器将每一个模块间调用都重定向为针对被调用历程的 glink,并将其后的空操作指令修改为从栈中恢复 TOC 的加载指令。过程的指针都变为 TOC/代码配对(TOC/code pair)的指针,所有通过指针的 call 都会借助一个使用了该指针指向的 TOC 和代码地址的普通 glink 例程。这种方案使得模块内调用尽可能的快。模块间调用由于借助了 glink 所以会稍微慢一些,但是比起我们接下来要看到的其它替代方案来,这种速度的降低是很小的。

ELF 位置无关代码

UNIX SVR4 为它的 ELF 共享库引入了一个类似于 TOC 的位置无关代码(PIC)方案。SVR4方案现在被使用 ELF 可执行程序的系统广泛支持。它的优势在于将过程调用恢复为普通方式,即一个过程的地址就是这个过程的代码地址,不管它是存在于 ELF 库中的 PIC 代码,或存在于普通 ELF 可执行文件中的非 PIC 代码,付出的代价就是这种方案比 TOC 的开销稍多一些。

ELF 的设计者注意到一个 ELF 可执行程序中的代码页组跟在数据页组后面,不论程序被加载到地址空间的什么位置,代码到数据的偏移量是不变的。所以如果代码可以将他自己的地址加载到一个寄存器中,数据将位于相对于代码地址确定的位置,并且程序可以通过相对于某一个固定偏移量的基址寻址方式有效的引用自己数据段的数据。链接器将可执行文件中寻址的所有全局变量的指针保存在它创建的全局偏移量表(Global Offset Table, GOT)中(每一个共享库拥有自己的 GOT,如果主程序和 PIC 代码一起编译,它也会有一个 GOT,虽然通常不这么做)。鉴于链接器创建了 GOT,所以对于每个 ELF可执行程序的数据只有一个地址,而不论在该可执行程序中有多少个例程引用了它。

如果一个过程需要引用全局或静态数据,那就需要过程自己加载 GOT 的地址。虽然具体细节随体系结构不同而有所变化,但 386 的代码是比较典型的:

1
2
3
4
call .L2 ;; push PC in on the stack
.L2:
popl %ebx ;; PC into register EBX
addl $_GLOBAL_OFFSET_TABLE_+[.-.L2],%ebx;; adjust ebx to GOT address

它存在一个对后面紧接着位置的call指令,这可以将 PC压入栈中而不用跳转,然后用pop指令将保存的 PC 加载到一个寄存器中并立刻加上call的目标地址和GOT地址之间的差。在一个由编译器生成的目标文件中,专门有一个针对addl指令操作数的R_386_GOTPC重定位项。它告诉链接器替换从当前指令到GOT基地址的偏移量,同时也是告诉链接器在输出文件中建立GOT的一个标记。在输出文件中,由于addlGOT之间的距离是固定的,所以就不再需要重定位了。

上面这段代码是比较典型的,主要目的是获取GOT的地址,保存在ebx中,为以后访问程序的全局/局部变量作准备。_GLOBAL_OFFSET_TABLE是链接器可以理解的一个量,在链接的时候链接器会将它替换为当前指令地址到GOT基地址之间的距离差值。由于在引用这个量的时候,ebx中的地址是call指令行的地址,不是addl指令行的地址,所以ebx在加上_BLOBAL_OFFSET_TABLE之后,还要加上addl指令行到call指令行的距离[.-.L2],才能够调整为GOT的基地址。

GOT寄存器被加载之后,程序数据段中的静态数据与GOT直接的距离在链接时被固定了,所以代码就可以将GOT寄存器作为一个基址寄存器来引用局部静态数据。全局数据的地址只有在程序被加载后才被确定,所以为了引用全局数据,代码必须从GOT中加载数据的指针,然后引用这个指针。这个多余的内存引用使得程序稍微慢了一些,尽管大多数程序员为了方面的使用动态链接库愿意付出这个代价。对速度要求较高的代码可以使用静态共享库或者根本不使用共享库。

为了支持位置无关代码(PIC),ELF 还定义了R_386_GOTPC(或与之等价的标识)之外的一些特殊重定位类型代码。这些类型是体系结构相关的,但是 x86 下的是比较典型的:

  • R_386_GOT32:GOT 中槽位(slot)的相对位置,链接器在这里存放了对于给定符号的指针。用来标识被引用的全局变量。
  • R_386_GOTOFF:给定符号或地址相对于 GOT 基地址的距离。用来相对于 GOT 对静态数据进行寻址。
  • R_386_RELATIVE:用来标记那些在 PIC 共享库中并在加载时需要重定位的数据地址。

例如,参看下列 C 代码片断:

1
2
3
4
static int a; /* static variable */
extern int b; /* global variable */
...
a = 1; b= 2;

变量a被分配在目标文件的 bss 段,这意味着它与 GOT 之间的距离是固定可知的。目标代码可以用ebx作为基址寄存器并结合一个与 GOT 的相对偏移量直接引用这个变量:

1
movl $1,a@GOTOFF(%ebx);; R_386_GOTOFF reference to variable "a"

变量b是全局的,如果他在不同的 ELF 库或可执行文件中,那么它的位置只有在运行时才能知道。这种情况下,目标代码引用一个链接器在 GOT 中创建的指向 b 的指针:

1
2
movl b@GOT(%ebx),%eax;; R_386_GOT32 ref to address of variable "b"
movl $2,(%eax)

注意编译器仅创建一个R_386_GOT32引用,需要链接器收集所有类似的引用并为他们在GOT中创建槽位(slot)。

最终,ELF 共享库保存了若干供运行时加载器进行运行时重定位的R_386_RELATIVE重定位项。由于共享库中的文本总是位置无关代码,所以对于代码没有重定位项,但数据不是位置无关的,所以对于数据段的每一个指针都有一个重定位项。

位置无关代码的开销和得益

PIC 的得益是明显的:它使得不需加载时重定位即可加载代码成为可能;可以在进程间共享代码的内存页面,即使它们没有被分配到相同的地址空间中。可能的不利之处就是在加载时、在过程调用中以及在函数开始和结束时会降低速度,并使全部代码变得更慢。在加载时,虽然一个位置无关代码文件的代码段不需要被重定位,但是数据段需要。在一个大的库中,TOC 或 GOT 可能会非常大以至于要花费很长的时间去解析其中的所有项。

处理同一个可执行文件中的R_386_RELATIVE(或等价符号)来重定位 GOT 中的数据指针是相当快的,但是问题是很多 GOT项中的指针指向别的可执行文件并需要查找符号表来解析。在 ELF 可执行文件中的调用通常都是动态链接的,甚至于在相同库内部的调用,这就增加了明显的开销。

在 ELF 文件中函数的开始和结束是相当慢的。他们必须保存和恢复 GOT 寄存器,在 x86中就是 ebx,并且通过 call 和 pop 将程序计数器保存到一个寄存器中也是很慢的。从性能的观点来看,AIX 使用的 TOC 方法更好,因为每一个过程可以假定它的 TOC 寄存器已经在过程项中设置了。

最后,PIC 代码要比非 PIC 代码更大、更慢。到底会有多慢很大程度上依赖于体系结构。对于拥有大量寄存器且无法直接寻址的 RISC 系统来说,少一个用作 TOC 或 GOT 指针的寄存器影响并不明显,并且缺少直接寻址而需要的一些排序时间是不变的。最坏的情况是在 x86 下。它只有 6 个寄存器,所以用一个寄存器当作 GOT 指针对代码的影响非常大。由于 x86 可以直接寻址,一个对外部数据的引用在非 PIC 代码下可以是一个简单的 MOV 或 ADD,但在 PIC 代码下就要变成加载紧跟在 MOV 或 ADD 后面的地址,这既增加了额外的内存引用又占用了宝贵的寄存器作为临时指针。

特别在 x86 系统上,对于速度要求严格的任务,PIC 代码的性能降低是明显的,以至于某些系统对于共享库退而采用一种类似 PIC 的方法。

自举加载

在现代计算机中,计算机在硬件复位后运行的第一个程序总是存储在称为 bootstrap ROM 的随机只读存储器中。就像自己启动自己一样。当处理器上电或者复位后,它将寄存器复位为一致的状态。例如在 x86 系统中,复位序列跳转到系统地址空间顶部下面的 16 字节处。Bootstrap ROM 占用了地址空间顶端的 64K,然后这里的 ROM 代码就来启动计算机。在 IBM 兼容的 x86 系统上,引导 ROM 代码读取软盘上的第一个块,如果失败的话就读取硬盘上的第一个块,将它放置在内存位置 0,然后再跳转到位置 0。在第 0 块上的程序然后从磁盘上一个已知位置上加载另一个稍微大一些的操作系统引导程序到内存中,然后在跳转到这个程序,加载并运行操作系统。

为什么不直接加载操作系统?因为你无法将一个操作系统的引导程序放置在 512 个字节内。第一级引导程序只能从被引导磁盘的顶级目录中加载一个名字固定且大小不超过一个段的程序。操作系统引导程序具有更多的复杂代码如读取和解释配置文件,解压缩一个压缩的操作系统内核,寻址大量内存(在 x86 系统上的引导程序通常运行在实模式下,这意味着寻址 1MB 以上地址是比较复杂的)。完全的操作系统还要运行在虚拟内存系统上,可以加载需要的驱动程序,并运行用户级程序。很多 UNIX 系统使用一个近似的自举进程来运行用户台程序。内核创建一个进程,在其中装填一个只有几十个字节长度的小程序。然后这个小程序调用一个系统调用运行/etc/init 程序,这个用户模式的初始化程序然后依次运行系统所需要的各种配置文件,启动服务进程和登录程序。

这些对于应用级程序员没有什么影响,但是如果你想编写运行在机器裸设备上的程序时就变得有趣多了,因为你需要截取自举过程并运行自己的程序,而不是像通常那样依靠操作系统。一些系统很容易实现这一点,另外一些系统则几乎是不可能的。它同样也给定制系统提供了机会。例如可以通过将应用程序的名字改为/etc/init 基于 UNIX 内核构建单应用程序系统。

共享库

程序库的产生可以追溯到计算技术的最早期,因为程序员很快就意识到通过重用程序的代码片段可以节省大量的时间和精力。随着如 Fortran and COBOL 等语言编译器的发展,程序库成为编程的一部分。当程序调用一个标准过程时,如sqrt(),编译过的语言显式地使用库,而且它们也隐式地使用用于 I/O、转换、排序及很多其它复杂得不能用内联代码解释的函数库。随着语言变得更为复杂,库也相应地变复杂了。当我在 20 年前写一个 Fortran 77 编译器时,运行库就已经比编译器本身的工作要多了,而一个 Fortran 77 库远比一个 C++库要来得简单。

语言库的增加意味着:不但所有的程序包含库代码,而且大部分程序包含许多相同的库代码。例如,每个 C 程序都要使用系统调用库,几乎所有的 C 程序都使用标准 I/O 库例程,如 printf,而且很多使用了别的通用库,如 math,networking,及其它通用函数。这就意味着在一个有一千个编译过的程序的 UNIX 系统中,就有将近一千份 printf 的拷贝。如果所有那些程序能共享一份它们用到的库例程的拷贝,对磁盘空间的节省是可观的。更重要的是,运行中的程序如能共享单个在内存中的库的拷贝,这对主存的节省是相当可观的,不但节省内存,也提高页交换。

所有共享库基本上以相同的方式工作。在链接时,链接器搜索整个库以找到用于解决那些未定义的外部符号的模块。但链接器不把模块内容拷贝到输出文件中,而是标记模块来自的库名,同时在可执行文件中放一个库的列表。当程序被装载时,启动代码找到那些库,并在程序开始前把它们映射到程序的地址空间。标准操作系统的文件映射机制自动共享那些以只读或写时拷贝的映射页。负责映射的启动代码可能是在操作系统中,或在可执行体,或在已经映射到进程地址空间的特定动态链接器中,或是这三者的某种并集。

绑定时间

共享库提出的绑定时间问题,是常规链接的程序不会遇到的。一个用到了共享库的程序在运行时依赖于这些库的有效性。当所需的库不存在时,就会发生错误。在这情况下,除了打印出一个晦涩的错误信息并退出外,不会有更多的事情要做。当库已经存在,但是自从程序链接以来库已经改变了时,一个更有趣的问题就会发生。在一个常规链接的程序中,在链接时符号就被绑定到地址上而库代码就已经绑定到可执行体中了,所以程序所链接的库是那个忽略了随后变更的库。对于静态共享库,符号在链接时被绑定到地址上,而库代码要直到运行时才被绑定到可执行体上。

一个静态链接共享库不能改变太多,以防破坏它所绑定到的程序。因为例程的地址和库中的数据都已经绑定到程序中了,任何对这些地址的改变都将导致灾难。如果不改变程序所依赖的静态库中的任何地址,那么有时一个共享库就可以在不影响程序对它调用的前提下进行升级。这就是通常用于小 bug 修复的”小更新版”。更大的改变不可避免地要改变程序地址,这就意味着一个系统要么需要多个版本的库,要么迫使程序员在每次改变库时都重新链接它们所有的程序。实际中,永远不变的解决办法就是多版本,因为磁盘空间便宜,而要找到每个会用到共享库可执行体几乎是不可能的。

地址空间管理

共享库中最困难的就是地址空间管理。每一个共享库在使用它的程序里都占用一段固定的地址空间。不同的库,如果能够被使用在同一个程序中,它们还必须使用互不重叠的地址空间。虽然机械的检查库的地址空间是否重叠是可能的,但是给不同的库赋予相应的地址空间仍然是一种“魔法”。一方面,你还想在它们之间留一些余地,这样当其中某个新版本的库增长了一些时,它不会延伸到下一个库的空间而发生冲突。另一方面,你还想将你最常用的库尽可能紧密的放在一起以节省需要的页表数量(要知道在 x86 上,进程地址空间的每一个 4MB 的块都有一个对应的二级表)。

每个系统的共享库地址空间都必然有一个主表,库从离应用程序很远的地址空间开始。Linux 从十六进制的 60000000 开始,BSD/OS 从 A0000000 开始。商业厂家将会为厂家提供的库、用户和第三方库进一步细分地址空间,比如对 BSD/OS,用户和第三方库开始于地址 A0800000。

通常库的代码和数据地址都会被明确的定义,其中数据区域从代码区域结束地址后的一个或两个页对齐的地方开始。由于一般都不会更新数据区域的布局,而只是增加或者更改代码区域,所以这样就使小更新版本成为可能。每一个共享库都会输出符号,包括代码和数据,而且如果这个库依赖于别的库,那么通常也会引入符号。虽然以某种偶然的顺序将例程链接为一个共享库也能使用,但是真正的库使用一些分配地址的原则而使得链接更容易,或者至少使在更新库的时候不必修改输出符号的地址成为可能。对于代码地址,库中有一个可以跳转到所有例程的跳转指令表,并将这些跳转的地址作为相应例程的地址输出,而不是输出这些例程的实际地址。所有跳转指令的大小都是相同的,所以跳转表的地址很容易计算,并且只要表中不在库更新时加入或删除表项,那么这些地址将不会随版本而改变。每一个例程多出一条跳转指令不会明显的降低速度,由于实际的例程地址是不可见的,所以即使新版本与旧版本的例程大小和地址都不一样,库的新旧版本仍然是可兼容的。

对于输出数据,情况就要复杂一些,因为没有一种像对代码地址那样的简单方法来增加一个间接层。实际中的输出数据一般是很少变动的、尺寸已知的表,例如 C 标准 I/O 库中的 FILE 结构,或者像 errno 那样的单字数值(最近一次系统调用返回的错误代码),或者是 tzname(指向当前时区名称的两个字符串的指针)。建立共享库的程序员可以收集到这些输出数据并放置在数据段的开头,使它们位于每个例程中所使用的匿名数据的前面,这样使得这些输出地址在库更新时不太可能会有变化。

共享库的结构

共享库是一个包含所有准备被映射的库代码和数据的可执行格式文件。

1
2
3
4
5
6
文件头,a.out, COFF 或 ELF 头
(初始化例程,不总存在)
跳转表
代码
全局数据
私有数据

一些共享库从一个小的自举例程开始,来映射库的剩余部分。之后是跳转表,如果它不是库的第一个内容,那么就把它对齐到下一个页的位置。库中每一个输出的公共例程的地址就是跳转表的表项;跟在跳转表后面的是文本段的剩余部分(由于跳转表是可执行代码,所以它被认为是文本),然后是输出数据和私有数据。在逻辑上 bss 段应跟在数据的后面,但是就像在任何别的可执行文件中那样,它并不在于这个文件中。

创建共享库

一个 UNIX 共享库实际上包含两个相关文件,即共享库本身和给链接器用的空占位库(stub library)。库创建工具将一个档案格式的普通库和一些包含控制信息的文件作为输入生成了这两个文件。空占位库根本不包含任何的代码和数据(可能会包含一个小的自举例程),但是它包含程序链接该库时需要使用的符号定义。

创建一个共享库需要以下几步,我们将在后面更多的讨论它们:

  • 确定库的代码和数据将被定位到什么地址。
  • 彻底扫描输入的库寻找所有输出的代码符号(如果某些符号是用来在库内通信的,那么就会有一个控制文件是这些不对外输出的符号的列表)。
  • 创建一个跳转表,表中的每一项分别对应每个输出的代码符号。
  • 如果在库的开头有一个初始化或加载例程,那么就编译或者汇编它。
  • 创建共享库。运行链接器把所有内容都链接为一个大的可执行格式文件。
  • 创建空占位库:从刚刚建立的共享库中提取出需要的符号,针对输入库的符号调整这些符号。为每一个库例程创建一个空占位例程。在 COFF 库中,也会有一个小的初始化代码放在占位库里并被链接到每一个可执行体中。

创建跳转表

最简单的创建一个跳转表的方法就是编写一个全是跳转指令的汇编源代码文件,并汇编它。这些跳转指令需要使用一种系统的方法来标记,这样以后空占位库就能够把这些地址提出取来。

对于像 x86 这样具有多种长度的跳转指令的平台,可能稍微复杂一点。对于含有小于 64K 代码的库,3 个字节的短跳转指令就足够了。对于较大的库,需要使用更长的 5 字节的跳转指令。将不同长度的跳转指令混在一起是不能让人满意的,因为它使得表地址的计算更加困难,同时也更难在以后重建库时确保兼容性。最简单的解决方法就是都采用最长的跳转指令;或者全部都使用短跳转,对于那些使用短跳转太远的例程,则用一个短跳转指令跳转到放在表尾的匿名长跳转指令。(通常由此带来的麻烦比它的好处更多,因为第一跳转表很少会有好几百项。)

创建共享库

一旦跳转表和加载例程(如果需要的话)建立好之后,创建共享库就很容易了。只需要使用合适的参数运行链接器,让代码和数据从正确的地址空间开始,并将自引导例程、跳转表和输入库中的所有例程都链接在一起。它同时完成了给库中每项分配地址和创建共享库文件两件事。

库之间的引用会稍微复杂一些。如果你正在创建,例如一个使用标准 C 库例程的共享数学库,那就要确保引用的正确。假定当链接器建立新库时需要用到的共享库中的例程已经建好,那么它只需要搜索该共享库的空占位库,就像普通的可执行程序引用共享库那样。这将让所有的引用都正确。只留下一个问题,就是需要有某种方法确保任何使用新库的程序也能够链接到旧库上。对新库的空占位库的适当设计可以确保这一点。

创建空占位库

创建空占位库是创建共享库过程中诡秘的部分之一。对于库中的每一个例程,空占位库中都要包含一个同时定义了输出和输入的全局符号的对应项。

数据全局符号会被链接器放在共享库中任何地方,获取它们的数值的最合理的办法就是创建一个带有符号表的共享库,并从符号表中提取符号。对代码全局符号,入口指针都在跳转表中,所以同样很简单,只需要从共享库中提取符号表或者根据跳转表的基地址和每一个符号在表中的位置来计算符号地址。

不同于普通库模块,空占位库模块既不包含代码也不包含数据,只包含符号定义。这些符号必须定义成绝对数而不是相对,因为共享库已经完成了所有的重定位。库创建程序从输入库中提取出每一个例程,并从这些例程中得到定义和未定义的全局变量,以及每一个全局变量的类型(文本或数据)。然后它创建空占位例程,通常都是一个很小的汇编程序,以跳转表中每一项的地址的形式定义每个文本全局变量,以共享库中实际地址的形式定义每个数据或 bss 全局变量,并以“未定义”的形式定义没有定义的全局变量。当它完成所有空占位后,就对其进行汇编并将它们合并到一个普通的库档案文件中。

COFF 空占位库使用了一种不同的、更简单的设计。它们是具有两个命名段的单一目标文件。“.lib”段包含了指向共享库的所有重定位信息,“.init”段包含了将会链接到每一个客户程序去的初始化代码,一般是来初始化库中的变量。Linux 共享库更简单,a.out文件中包含了带有设置向量(“set vector”) 的符号定义。

共享库的名称一般是原先的库名加上版本号。如果原先的库称为/lib/libc.a,这通常是 C 库的名字,当前的库版本是 4.0,空占位库可能是/lib/libc_s.4.0.0.a,共享库就是/shlib/libc_s.4.0.0(多出来的 0 可以允许小版本的升级)。一旦库被放置到合适的目录下面,它们就可以被使用了。

版本命名

任何共享库系统都需要有一种办法处理库的多个版本。当一个库被更新后,新版本相对于之前版本而言在地址和调用上都有可能兼容或不兼容。UNIX 系统使用前面提到的版本命名序号来解决这个问题。

第一个数字在每次发布一个不兼容的全新的库的时候才被改变。一个和 4.x.x 的库链接的程序不能使用 3.x.x 或 5.x.x 的库。第二个数是小版本。在 Sun 系统上,每一个可执行程序所链接的库都至少需要一个尽可能大的小版本号。例如,如果它链接的是 4.2.x,那么它就可以和 4.3.x 一起运行而 4.1.x 则不行。另一些系统将第二个数字当作第一个数字的扩展,这样的话使用一个 4.2.x 的库链接的程序就只能和 4.2.x 的库一起运行。第三个数字通常都被当作补丁级别。虽然任何的补丁级别都是可用的,可执行程序最好还是使用最高的有效补丁级别。

不同的系统在运行时查找对应库的方法会略有不同。Sun 系统有一个相当复杂的运行时加载器,在库目录中查看所有的文件名并挑选出最好的那个。Linux 系统使用符号链接而避免了搜索过程。如果库 libc.so 的最新版本是 4.2.2,库的名字是 libc_s.4.2.2,但是这个库也已经被链接到 libc_s.4.2,那么加载器将仅需打开名字较短的文件,就选好了正确的版本。

多数系统都允许共享库存在于多个目录中。类似于LD_LIBRARY_PATH的环境变量可以覆盖可执行程序中的路径,以允许开发者使用它们自己的库替代原先的库进行调试或性能测试。

使用共享库链接

使用静态共享库来链接,比创建库要简单得多,因为几乎所有的确保链接器正确解析库中程序地址的困难工作,都在创建空占位库时完成了。唯一困难的部分就是在程序开始运行时将需要的共享库映射进来。

每一种格式都会提供一个小窍门让链接器创建一个库的列表,以便启动代码把库映射进来。COFF 库使用一种残忍的强制方法;链接器中的特殊代码在 COFF 文件中创建了一个以库名命名的段。Linux 链接器使用一种不那么残忍的方法,即创建一个称为设置向量的特殊符号类型。设置向量象普通的全局符号一样,但如果它有多个定义,这些定义会被放进一个以该符号命名的数组中。每个共享库定义一个设置向量符号__SHARED_LIBRARIES__,它是由库名、版本、加载地址等构成的一个数据结构的地址。 链接器创建一个指向每个这种数据结构的指针的数组,并称之为__SHARED_LIBRARIES__,好让启动代码可以使用它。BSD/OS 共享库没有使用任何的此类链接器窍门。它使用 shell 脚本建立一个共享的可执行程序,用来搜索作为参数或隐式传入的库列表,提取出这些文件的名字并根据系统文件中的列表来加载这些库的地址,然后编写一个小汇编源文件创建一个带有库名字和加载地址的结构数组,并汇编这个文件,把得到的目标文件加入到链接器的参数列表中。

在每一种情况中,从程序代码到库地址的引用都是通过空占位库中的地址自动解析的。

使用共享库运行

启动一个使用共享库的程序需要三步:加载可执行程序,映射库,进行库特定的初始化操作。在每一种情况下,可执行程序都被系统按照通常的方法加载到内存中。之后,处理方法会有差别。系统 V.3 内核具有了处理链接 COFF 共享库的可执行程序的扩展性能,其内核会查看库列表并在程序运行之前将它们映射进来。这种方法的不利之处在于 “内核肿胀”,会给不可分页的内核增加更多的代码;并且由于这种方法不允许在未来版本中有灵活性和可升级性,所以它是不灵活的。

Linux 增加了一个单独的uselib()系统调用,以获取一个库的文件名字和地址,并将它映射到程序的地址空间中。绑定到可执行体中的启动例程搜索库列表,并对每一项执行uselib()

BSD/OS 的方法是使用标准的mmap()系统调用将一个文件的多个页映射进地址空间,该方法还使用一个链接到每个共享库起始处的自举例程。可执行程序中的启动例程遍历共享库表,打开每个对应的文件,将文件的第一页映射到加载地址中,然后调用各自的自举例程,该例程位于可执行文件头之后的起始页附近的某个固定位置。然后自举例程再映射余下的文本段、数据段,然后为 bss 段映射新的地址空间,然后自举例程就返回了。

所有的段被映射了之后,通常还有一些库特定的初始化工作要做,例如,将一个指针指向 C 标准库中指定的系统环境全局变量 environ。COFF 的实现是从程序文件的.init段收集初始化代码,然后在程序启动代码中运行它。根据库的不同,它有时会调用共享库中的例程,有时不会。Linux 的实现中没有进行任何的库初始化,并且指出了在程序和库中定义相同的变量将不能很好工作的问题。

在 BSD/OS 实现中,C 库的自举例程会接收到一个指向共享库表的指针,并将所有其它的库都映射进来,减小了需要链接到单独的可执行体中的代码量。最近版本的 BSD 使用 ELF格式的可执行体。ELF 头有一个 interp 段,其中包含一个运行该文件时需要使用的解释器程序的名字。BSD 使用共享的 C 库作为解释器,这意味着在程序启动之前内核会将共享 C 库先映射进来,这就节省了一些系统调用的开销。库自举例程进行的是相同的初始化工作,将库的剩余部分映射进来,并且,通过一个指针,调用程序的 main 例程。

malloc hack 和其它共享库问题

在一个静态库中,所有的库内调用都被永久绑定了,所以不可能将某个程序中所使用的库例程通过重新定义替换为私有版本的例程。多数情况下,由于很少有程序会对标准库中例如read()strcmp()等例程进行重新定义,所以永久绑定不是什么大问题;并且如果它们自己的程序使用私有版本的strcmp(),但库例程仍调用库中标准版本,那么也没有什么大问题。

但是很多程序定义了它们自己的malloc()free()版本,这是分配堆存储的例程;如果在一个程序中存在这些例程的多个版本,那么程序将不能正常工作。例如,标准strdup()例程,返回一个指向用 malloc 分配的字符串指针,当程序不再使用它时可以释放它。如果库使用 malloc 的某个版本来分配字符串的空间,但是应用程序使用另一个版本的 free 来释放这个字符串的空间,那么就会发生混乱。

为了能够允许应用程序提供它们自己版本的mallocfree,System V.3 的共享 C 库使用了一种“丑陋”的技术。系统的维护者将mallocfree重新定义为间接调用,这是通过绑定到共享库的数据部分的函数指针实现的,我们将称它们为malloc_ptrfree_ptr

1
2
3
4
extern void *(*malloc_ptr)(size_t);
extern void (*free_ptr)(void *);
#define malloc(s) (*malloc_ptr)(s)
#define free(s) (*free_ptr)(s)

然后它们重新编译了整个 C 库,并将下面的几行内容(或汇编同类内容)加入到占位库的.init段,这样它们就被加入到每个使用该共享库的程序中了。

1
2
3
4
#undef malloc
#undef free
malloc_ptr = &malloc;
free_ptr = &free;

由于占位库将被绑定到应用程序中的,而不是共享库,所以它对mallocfree的引用是在链接时解析的。如果存在一个私有版本的mallocfree,它将指向私有版本函数的指针(译者注:指malloc_ptrfree_ptr),否则它将使用标准库的版本。不管哪种方法,库和应用程序使用的都是相同版本的mallocfree

动态链接和加载

动态链接将很多链接过程推迟到了程序启动的时候。它提供了一系列其它方法无法获得的优点:

  • 动态链接的共享库要比静态链接的共享库更容易创建。
  • 动态链接的共享库要比静态链接的共享库更容易升级。
  • 动态链接的共享库的语义更接近于那些非共享库。
  • 动态链接允许程序在运行时加载和卸载例程,这是其它途径所难以提供的功能。

当然这也有少许不利。由于每次程序启动的时候都要进行大量的链接过程,动态链接的运行时性能要比静态链接的低不少,这是付出的代价。程序中所使用的每一个动态链接的符号都必须在符号表中进行查找和解析。由于动态链接库还要包括符号表,所以它比静态库要大。

在调用的兼容性问题之上,一个顽固的麻烦根源是库语义的变化。和非共享或静态共享库而言,变更动态链接库要容易很多。所以很容易就可以改变已存在程序正在使用的动态链接库。这意味着即使程序没有任何改变,程序的行为也会改变。多数程序在出货时都带有它们所需库的副本,而安装程序经常会不假思索的将安装包中的旧版本共享库覆盖已存在的新版本库,这就破坏了那些依赖新版本库特性的程序。考虑周全的安装程序会在使用旧版本库覆盖新版本库的时候弹出告警框提示,但这样的话,依赖新版本库特性的那些应用程序又会发生旧版本库替换新版本库时发生的类似问题。

ELF 文件内容

一个 ELF 文件可以看成是由链接器解释的一系列区段(section),或由加载器解释的一系列段(segment)。ELF 程序和共享库的通用结构相同,但具体的段(segment)或者区段(section)有所区别。

ELF 共享库可被加载到任何地址,因此它们总是使用位置无关代码(PIC)的形式,这样文件的代码页无须重定位即可在多个进程之间共享。ELF 链接器通过全局偏移量表(GOT)支持 PIC 代码,每个共享库中都有 GOT,包含着程序所引用的静态数据的指针。动态链接器会解析和重定位 GOT 中的所有指针。这会引起性能的问题,但是在实际中除了非常巨大的库之外,GOT 都不大。通常使用的标准 C 库中超过 350K的代码的 GOT 也只有 180 个表项。

由于 GOT 位于代码所引用的可加载 ELF 文件中,因此无论被加载到何处,位于文件中的相对地址都不会发生变化。代码可以通过相对地址来定位 GOT,将 GOT 的地址加载到一个寄存器中,然后在需要寻址静态数据的时候从 GOT 中加载相应的指针。如果一个库没有引用任何的静态数据那么它可以不需 GOT,但实际中所有的库都有 GOT。

为了支持动态链接,每个 ELF 共享库和每个使用了共享库的可执行程序都有一个过程链接表(Procedure Linkage Table, PLT)。PLT 就像 GOT 对数据引用那样,对函数调用增添了一层间接途径。PLT 还允许进行“懒惰计算法”,即只有在第一次被调用时,才解析过程的地址。由于 PLT 表项要比 GOT 多很多(在上面提到的 C 库中会有超过 600 项),并且大多数例程在任何给定的程序中都不会被调用,因此“懒惰计算法”既可以提高程序启动的速度,也可以整体上节省相当可观的时间。

一个被动态链接的 ELF 文件包含了运行时链接器在重定位文件和解析任意未定义符号时所需的所有链接器信息。动态符号表,即.dynsym区段,包含了文件中所有的输入和输出符号。而.dynstr.hash区段包含了符号的名称字串,以及有助于加快运行时链接器查找速度的散列表。最后一个 ELF 动态链接文件的额外部分是DYNAMIC段(也被标识为.dynamic区段),动态链接器使用它来寻找和该文件相关的信息。它作为数据段的一部分被加载,但由 ELF 文件头部的指针指向它,这样运行时动态链接器就可以找到它了。DYNAMIC区段是一个由被标记的数值和指针组成的列表。一些表项类型只会出现在程序中,一些表项类型只会出现在库中,还有一些类型在两者中都会出现。

  • NEEDED:该文件所需的库的名称。(通常在程序中,如果一个库依赖其它库时有时也会在这个库中,这种情况可以嵌套发生)
  • SONAME:共享的对象名称。链接器所需要的文件的名称。(在库中)
  • SYMTABSTRTABHASHSYMENTSTRSZ:指向符号表,相关联的字串表和散列表,符号表项大小,字串表大小。(程序和库中都有)
  • PLTGOT:指向GOT,或者在某些架构下指向PLT。(程序和库中都有)
  • RELRELSZRELENT,或者RELARELASZRELAENT:重定位表的指针、大小和表项大小。重定位表中不包含加数,加数表中才包含它们。根据名字也能猜到,RELARELASIZERELAENT是加数表指针、加数表大小和加数表项的大小。(程序和库中都有)
  • JMPRELPLTRELSZPLTREL:由 PLT 引用的数据的重定位表的指针、大小和格式(REL或 RELA)。(程序和库中都有)
  • INITFINI:初始化和终止例程的指针,在程序启动和终止的时候调用。(可选的,但是通常在库和程序中都有)

加载一个动态链接的程序

加载一个动态链接的程序,这个过程冗长但简单。

启动动态链接器

在操作系统运行程序时,它会像通常那样将文件的页映射进来,但注意在可执行程序中存在一个INTERPRETER区段。这里特定的解释器是动态链接器,即ld.so,它自己也是ELF共享库的格式。操作系统并非直接启动程序,而是将动态链接器映射到地址空间的一个合适的位置,然后从ld.so处开始,并在栈中放入链接器所需要的辅助向量(auxiliary vector)信息。向量包括:

  • AT_PHDRAT_PHENT,和AT_PHNUM:程序头部在程序文件中的地址,头部中每个表项的大小,和表项的个数。头部结构描述了被加载文件中的各个段。如果系统没有将程序映射到内存中,就会有一个AT_EXECFD项作为替换,它包含被打开程序文件的文件描述符。
  • AT_ENTRY:程序的起始地址,当动态链接器完成了初始化工作之后,就会跳转到这个地址去。
  • AT_BASE:动态链接器被加载到的地址。

此时,位于ld.so起始处的自举代码找到它自己的 GOT,其中的第一项指向了ld.so文件中的DYNAMIC段。通过dynamic段,链接器在它自己的数据段中找到自己的重定位项表和重定位指针,然后解析例程需要加载的其它东西的代码引用(Linux ld.so 将所有的基础例程都命名为由字串_dt_起头,并使用专门代码在符号表中搜索以此字串开头的符号并解析它们)。

链接器然后通过指向程序符号表和链接器自己的符号表的若干指针来初始化一个符号表链。从概念上讲,程序文件和所有加载到进程中的库会共享一个符号表。但实际中链接器并不是在运行时创建一个合并后的符号表,而是将个个文件中的符号表组成一个符号表链。每个文件中都有一个散列表(一系列的散列头部,每个头部引领一个散列队列)以加速符号查找的速度。链接器可以通过计算符号的散列值,然后访问相应的散列队列进行查找以加速符号搜索的速度。

库的查找

链接器自身的初始化完成之后,它就会去寻找程序所需要的各个库。程序的程序头部有一个指针,指向dynamic段(包含有动态链接相关信息)在文件中的位置。在这个段中包含一个指针DT_STRTAB,指向文件的字串表,和一个偏移量表DT_NEEDED,其中每一个表项包含了一个所需库的名称在字串表中的偏移量。

对于每一个库,链接器会查找对应的 ELF 共享库文件,这本身也是一个颇为复杂的过程。在DT_NEEDED表项中的库名称看起来与 libXt.so.6(Xt 工具包,版本 6)类似。库文件可能会在若干库目录的任意一个之中,甚至可能文件的名称都不相同。在我的系统上,这个库的实际名称是/usr/X11R6/lib/libXt.so.6。末尾的“.0”是次版本号。链接器在以下位置搜索库:

  • 是否dynamic段有一个称为DT_RPATH的表项,它是由分号分隔开的可以搜索库的目录列表。它可以通过一个命令行参数或者在程序链接时常规(非动态)链接器的环境变量来添加。它经常会被诸如数据库类这样需要加载一系列程序并可将库放在单一目录的子系统使用,
  • 是否有一个环境符号LD_LIBRARY_PATH,它可以是由分号分隔开的可供链接器搜索库的目录列表。这就可以让开发者创建一个新版本的库并将它放置在LD_LIBRARY_PATH的路径中,这样既可以通过已存在的程序来测试新的库,或用来监测程序的行为。(因为安全原因,如果程序设置了 set-uid,那么这一步会被跳过)
  • 链接器查看库缓冲文件/etc/ld.so.conf,其中包含了库文件名和路径的列表。如果要查找的库名称存在于其中,则采用文件中相应的路径。大多数库都通过这种方法被找到。
  • 如果所有的都失败了,就查找缺省目录/usr/lib,如果在这个目录中仍没有找到,就打印错误信息,并退出执行。

一旦找到包含该库的文件,动态链接器会打开该文件,读取 ELF 头部寻找程序头部,它指向包括 dynamic 段在内的众多段。链接器为库的文本和数据段分配空间,并将它们映射进来,对于 BSS 分配初始化为 0 的页。从库的 dynamic 段中,它将库的符号表加入到符号表链中,如果该库还进一步需要其它尚未加载的库,则将那些新库置入将要加载的库链表中。在该过程结束时,所有的库都被映射进来了,加载器拥有了一个由程序和所有映射进来的库的符号表联合而成的逻辑上的全局符号表。

共享库的初始化

现在加载器再次查看每个库并处理库的重定位项,填充库的 GOT,并进行库的数据段所需的任何重定位。在 x86 平台上,加载时的重定位包括:

  • R_386_GLOB_DAT:初始化一个 GOT 项,该项是在另一个库中定义的符号的地址。
  • R_386_32:对在另一个库中定义的符号的非 GOT 引用,通常是静态数据区中的指针。
  • R_386_RELATIVE:对可重定位数据的引用,典型的是指向字串(或其它局部定义静态数据)的指针。
  • R_386_JMP_SLOT:用来初始化 PLT 的 GOT 项,稍后描述。

如果一个库具有.init 区段,加载器会调用它来进行库特定的初始化工作,诸如 C++的静态构造函数。库中的.fini 区段会在程序退出的时候被执行。它不会对主程序进行初始化,因为主程序的初始化是有自己的启动代码完成的。当这个过程完成后,所有的库就都被完全加载并可以被执行了,此时加载器调用程序的入口点开始执行程序。

高级技术

C++的技术

C++对链接器来说存在三个明显的挑战。一个是它复杂的命名规则,主要在于如果多个函数具有不同的参数类型则可以拥有相同的名称。name mangling 可以对他们进行很好的地址分配,所有的链接器都使用这种技术的不同形式。

第二个是全局的构造和析构代码,他们需要在 main 例程运行前运行和 main 例程退出后运行。这需要链接器将构造代码和析构代码片段(或者至少是指向它们的指针)都收集起来放在一个地方,以便在启动和退出时将他们一并执行。

第三,也是目前最复杂的问题即模板和“extern inline”过程。一个 C++模板定义了一个无穷的过程的家族,每一个家族成员都是由某个类型特定的模板。例如,一个模板可能定义了一个通用的 hash 表,则就有整数类型的 hash 表家族成员,浮点数类型的 hash 表家族成员,字符串类型的,或指向各种数据结构的指针的类型的。由于计算机的存储器容量是无穷的,被编译好的程序需要包含程序中用到的这个家族中实际用到的所有成员,并且不能包含其它的。如果 C++编译器采用传统方法单独处理每一个源代码文件,他不能确定是否所编译的源代码文件中用到的模板是否在其它源代码文件中还存在被使用的其它家族成员。如果编译器采用保守的方法为每一个文件中使用到的每一个家族成员都产生相应的代码,那么最后将可能对某些家族成员产生了多份代码,这就浪费了空间。如果它不产生那些代码,它就有漏掉某一个需要的家族成员的可能性存在。

inline 函数存在一个相似的问题。通常,inline 函数被像宏那样扩展开,但是在某些情况下编译器会产生该函数相反的 out of line 版本。如果若干个不同的文件使用某个包含一个 inline 函数的单一头文件,并且某些文件需要一个 out of line 的版本,就会产生代码重复的相同问题。

一些编译器采用改变源代码语言的方法以帮助产生可以被“哑”链接器(dump linkers)链接的目标代码。很多最近的 C++系统都把这个问题放到了首位,或者让链接器更聪明些,或者将程序开发系统的其它部分和链接器整合在一起,以解决这个问题。下面我们概要的看看后一种途径。

试验链接

对于使用“头脑简单”的链接器构建起来的系统,C++系统使用了多种技巧来使得 C++程序得以被链接。一种方法是先用传统的 C 前端实现来进行通常都会失败的试验链接,然后让编译器驱动(运行各种编译器、汇编器、链接器代码片段的程序)从链接结果中提取信息,再重新编译和链接以完成任务。

在 UNIX 系统上,如果 linker 在一次链接任务中不能够解析所有的未定义符号引用,他可以选择仍然输出一个作为后续链接任务的输入文件的输出文件。在链接过程中链接器使用普通的库查找规则,使得输出文件包含所需的库,这也是再次作为输入文件所包含的信息。试验链接解决了上面所有的 C++问题,虽然很慢,但却是有效的方法。

对于全局的构造和析构代码,C++编译器在每一个输入文件中建立了完成构造和析构功能的例程。这些例程在逻辑上是匿名的,但是编译器给他们分配了可识别的名称。例如,GNU C++编译器会对名为junk的类中的变量创建名为_GLOBAL_.I.__4junk_GLOBAL_.D.__4junk的构造例程及析构例程。在试验链接结束后,链接器驱动程序会检测输出文件的符号表并为全局构造和析构例程建立一个链表,这是通过编写一个由数组构成的队列的源代码文件来实现的(通过 C 或者汇编语言)。然后在再次链接中,C++的启动和退出代码使用这个数组中的内容去调用所有对应的例程。这和那些针对 C++的链接器的功能基本相同,区别仅仅是它是在链接器之外实现的。

对于模板和 extern inline 来说,编译器最初不会为他们生成任何代码。试验链接会获得程序中实际使用到的所有模板和 extern inline 的未定义符号,编译器驱动程序会利用这些符号重新运行编译器并为之生成代码,然后再次进行链接。这里会有一个小问题是为模板寻找对应的源代码,因为所要找寻的目标可能潜伏在非常大量的源代码文件中。C 前端程序使用了一种简单而特别的技术:扫描头文件,然后猜测一个在 foo.h 中声明的模板会定义在 foo.cc 中。新近版本的 GCC 会使用一种在编译过程中生成,以注明模板定义代码的位置的小文件,称之为“仓库”(repository)。在试验链接后,编译器驱动程序仅需要扫描这些小文件就可以找到模板对应的源代码。

消除重复代码

试验链接的方法会产生尽可能小的代码,在试验链接之后会再为第一次处理遗留下的任何源代码继续产生代码。之所以采用这种前后颠倒的方法是为了生成所有可能的代码,然后让链接器将那些重复的丢掉。编译器为每一个源文件都生成了他们各自所需的每一个扩展模板和 extern line 代码。每一个可能冗余的代码块都被放到他们各自的段中并用唯一的名字来标识它是什么。例如,GCC 将每一个代码块放置在一个命名为.gnu.linkonce.d.mangledname的 ELF 或 COFF 段中,这里“缺损名称”(mangled name)是指增加了类型信息的函数名称。有一些格式可以仅仅通过名字就识别出可能的冗余段,如微软的 COFF 格式使用带有精确类型标志的 COMDAT 段来表示可能的冗余代码段。如果存在同一个名字的段的多个副本,那么链接器就会在链接时将多余的副本忽略掉。

这种方法非常好的做到了为每一个例程在可执行程序中仅仅生成一个副本,作为代价,会产生非常大的包含一个模板的多个副本的目标文件。但这种方法至少提供了可以产生比其它方法更小的最终代码的可能性。在很多情况下,当一个模板扩展为多个类型时所产生的代码是一样的。例如,鉴于 C++的指针都具有相同的表示方法,因此一个实现了类型为<TYPE>可进行边界检查的数组的模板,通常对所有指针类型所扩展的代码都是一样的。所以,那个已经删除了冗余段的链接器还可以检查内容一样的段,并将多个内容一样的段消除为只剩一个。

借助于数据库的方法

GCC 所用的“仓库”实际上就是一个小的数据库。最终,工具开发者都会转而使用数据库来存储源代码和目标代码,就像 IBM 的 Viaual Age C++的 Montana 开发环境一样。数据库跟踪每一个声明和定义的位置,这样就可以在源代码改变后精确的指出哪些例程会对此修改具有依赖关系,并仅仅重新编译和链接那些修改了的地方。

链接时的垃圾收集

有一些链接器也提供从目标文件中去除无用的代码的功能。大多数程序的源代码文件和目标文件都包含有多于一个的例程。如果编译器在每个例程之间划分边界,那么链接器就能确定每一个例程都定义了哪些符号,哪些例程都引用了哪些符号。根本没有被引用的任何例程都可以被安全的忽略掉。每次当一个例程被忽略掉时,由于这个例程可能还引用了一些唯一被该历程引用的其它例程,而那些例程也会随后被忽略掉,因此链接器需要重新计算“定义/引用”表。

缺省情况下,所有的未引用例程都会被忽略掉,但是程序员可以通过链接器的开关参数告诉它不要进行任何的垃圾收集,或对特定的文件或段不进行垃圾收集。链接器查找那些没有被引用的段,并删除它们。在大多数情况下,链接器会同时查找相同内容的多个例程(通常从我们上面提到的模板的扩展而来)并将多于的副本清除。对可收集垃圾的链接器的一个替代方案就是更广泛的使用库。程序员可以将被链接到程序中的库转换为每个库成员只有一个例程的库,然后从这些库中进行链接,这样链接器可以挑选需要的例程而跳过那些没有被引用的例程。这种方法中最难的部分是重新处理源代码以将含有多个例程的源代码文件分割为很多只有单一例程的小文件,并为每一个都替换掉相应的数据声明及从头文件中包含过来的代码,并在内部重新对各个例程命名以防止名字冲突

原先属于多个源代码文件中的本地例程,在划分为每个库成员一个例程的库的时候,这些本地例程名字在对外公开后很有可能存在名字相同的若干个例程,因此需要为避免名字冲突进行一些处理。这样的结果是可以产生尺寸最小的可执行程序,相应的代价是编译和链接的速度非常之慢。

链接时优化

在大多数系统上,链接器是在软件建立过程中唯一会同时检查程序所有部分的程序。这就意味着他可以做一些别的部件无法进行的全局优化,特别是当程序由多个使用不同语言和编译器编写的多个模块组成的时候。例如,在一个带有类继承的语言中,一个类的方法可能会在子类中被覆盖,因此对它的调用通常都是间接的。但是如果没有任何的子类,或者存在子类但是没有一个覆盖了这个方法,那就可以直接调用这个方法。链接器可以对这种情况进行特殊优化以避免面向对象语言在继承时的低效率。

一种更激进的方法是对整个程序在链接时进行标准的全局优化。Srivastava 和 Wall 编写过一个优化链接器,可以将 RISC 体系结构的目标代码反编译为一种中间格式的数据,并对之实施诸如 inline 这样的高层次优化或诸如将一个更快但限制更多的指令替换为一个稍慢但常用的指令的低层次优化,然后再重新生成目标代码。特别是在 64 位体系结构的 Alpha体系结构中,对静态或者全局数据,以及任意例程的寻址方法,是将指向地址池中某一项的地址指针从内存中加载到寄存器里,然后把这个寄存器作为基址寄存器使用(地址持通过一个全局的指针寄存器来寻址)。他们的 OM 优化链接器会寻找多个连续指令引用一系列地址足够紧接的全局变量或静态变量的情况(这些全局变量和静态变量的彼此位置接近到足够可以通过同一个指针即可对他们寻址),然后重写目标代码以去除多余的从全局地址池中加载地址的指针。它也寻找那些通过分支跳转指令在 32 位地址范围内的过程调用,并将他们替换为需加载一个地址的间接调用。它也可以重新排列普通块的位置,使得较小的块排列在一起,这样以增加同一个指针被引用的次数。

其它链接器都会对进行一些别的体系结构相关的优化。如多流的 VLIW 机器具有大量的寄存器,并且寄存器内容的保存和回复是一个主要的瓶颈。有一个测试工具会使用统计数据指出哪些例程会频繁的调用其它哪里例程。它修改了代码中所使用的寄存器以尽量减少例程调用者和被调用者之间重叠使用的寄存器数量,进而尽量减少了保存和恢复的次数。

链接时代码生成

很多链接器会生成少量的输出目标代码,例如 UNIX ELF 文件的 PLT(译者注:procedure linkage table)中的跳转项。但是一些实验链接器会产生比那更多的代码。Srivastava 和 Wall 的优化链接器首先将目标文件反编译为一种中间格式的代码。多数情况下,如果链接器想要中间格式代码的话,他可以很容易的告诉编译器跳过代码生成,而创建中间格式的目标文件,让链接器去完成代码生成工作。上面这些确实是 Fernandez 优化器所描述的。链接器可以使用所有的中间格式代码,对其进行大量的优化工作,然后再为输出文件产生目标代码。

对于商业链接器有很多理由说明为什么它们根据中间格式代码进行代码生成。理由之一是中间格式代码的语言趋向于和编译器的目标语言相关。设计一种中间格式代码的语言以处理包括 C 和 C++在内的类 Fortran 语言并不是很难的事情,但是要设计既能处理那些语言又能处理诸如 Cobol 和 Lisp 这样鲜有共性的语言,那是一件相当难的事情。链接器通常都是链接从任何编译器和汇编器生成的目标代码,因此使其和特定语言关联起来是会有问题的。链接时统计和工具有一些小组曾编写过链接时统计和优化的工具。

在链接传统二进制目标代码和链接中间格式语言之间有一个有趣的妥协就是将汇编语言的源程序作为中间格式的目标语言。链接器同时将整个程序汇编以生成输出文件。作为 Linux 灵感来源的 MINIX(一种类 UNIX 的小操作系统)就是这么做的。汇编语言足够接近于机器语言因此任何编译器都可以生成它,并且它也足够高级到可以进行一些有用的优化,包括无用代码消除、代码重组、一些有力的代码缩减,以及诸如对某一操作在确保足够操作位数的前提下选择最小版本指令的标准汇编优化。

由于汇编的执行速度很快,因此这样的系统可以很快的执行,尤其是当目标语言是一种被进行了标识的汇编语言而不是完全的汇编源代码时(这是因为像在其它编译器中一样,在汇编中最初添加标识的过程是整个处理过程中最慢的部分)。

45个寄存器

什么是寄存器?

寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果以及一些CPU运行需要的信息。

x86架构CPU走的是复杂指令集(CISC) 路线,提供了丰富的指令来实现强大的功能,与此同时也提供了大量寄存器来辅助功能实现。这篇文章将覆盖下面这些寄存器:

  • 通用寄存器
  • 标志寄存器
  • 指令寄存器
  • 段寄存器
  • 控制寄存器
  • 调试寄存器
  • 描述符寄存器
  • 任务寄存器
  • MSR寄存器

通用寄存器

首当其冲的是通用寄存器,这些的寄存器是程序执行代码最最常用,也最最基础的寄存器,程序执行过程中,绝大部分时间都是在操作这些寄存器来实现指令功能。

所谓通用,即这些寄存器CPU没有特殊的用途,交给应用程序“随意”使用。注意,这个随意,我打了引号,对于有些寄存器,CPU有一些潜规则,用的时候要注意。

  • eax: 通常用来执行加法,函数调用的返回值一般也放在这里面
  • ebx: 数据存取
  • ecx: 通常用来作为计数器,比如for循环
  • edx: 读写I/O端口时,edx用来存放端口号
  • esp: 栈顶指针,指向栈的顶部
  • ebp: 栈底指针,指向栈的底部,通常用ebp+偏移量的形式来定位函数存放在栈中的局部变量
  • esi: 字符串操作时,用于存放数据源的地址
  • edi: 字符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作

在x64架构中,上面的通用寄存器都扩展成为64位版本,名字也进行了升级。当然,为了兼容32位模式程序,使用上面的名字仍然是可以访问的,相当于访问64位寄存器的低32位。

1
rax rbx rcx rdx rsp rbp rsi rdi

除了扩展原来存在的通用寄存器,x64架构还引入了8个新的通用寄存器:
1
r8-r15

在原来32位时代,函数调用时,那个时候通用寄存器少,参数绝大多数时候是通过线程的栈来进行传递(当然也有使用寄存器传递的,比如著名的C++ this指针使用ecx寄存器传递,不过能用的寄存器毕竟不多)。

进入x64时代,寄存器资源富裕了,参数传递绝大多数都是用寄存器来传了。寄存器传参的好处是速度快,减少了对内存的读写次数。

当然,具体使用栈还是用寄存器传参数,这个不是编程语言决定的,而是编译器在编译生成CPU指令时决定的,如果编译器非要在x64架构CPU上使用线程栈来传参那也不是不行,这个对高级语言是无感知的。

标志寄存器

标志寄存器,里面有众多标记位,记录了CPU执行指令过程中的一系列状态,这些标志大都由CPU自动设置和修改:

  • CF 进位标志
  • PF 奇偶标志
  • ZF 零标志
  • SF 符号标志
  • OF 补码溢出标志
  • TF 跟踪标志
  • IF 中断标志
    ······

在x64架构下,原来的eflags寄存器升级为64位的rflags,不过其高32位并没有新增什么功能,保留为将来使用。

指令寄存器

eip: 指令寄存器可以说是CPU中最最重要的寄存器了,它指向了下一条要执行的指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复,这就是CPU工作的基本日常。

而在漏洞攻击中,黑客想尽办法费尽心机都想要修改指令寄存器的地址,从而能够执行恶意代码。

同样的,在x64架构下,32位的eip升级为64位的rip寄存器。

段寄存器

段寄存器与CPU的内存寻址技术紧密相关。

早在16位的8086CPU时代,内存资源宝贵,CPU使用分段式内存寻址技术:

16位的寄存器能寻址的范围是64KB,通过引入段的概念,将内存空间划分为不同的区域:分段,通过段基址+段内偏移段方式来寻址。

这样一来,段的基地址保存在哪里呢?8086CPU专门设置了几个段寄存器用来保存段的基地址,这就是段寄存器段的由来。

段寄存器也是16位的。

段寄存器有下面6个,前面4个是早期16位模式就引入了,到了32位时代,又新增了fs和gs两个段寄存器。

  • cs: 代码段
  • ds: 数据段
  • ss: 栈段
  • es: 扩展段
  • fs: 数据段
  • gs: 数据段
    段寄存器里面存储的内容与CPU当前工作的内存寻址模式紧密相关。

当CPU处于16位实地址模式下时,段寄存器存储段的基地址,寻址时,将段寄存器内容左移4位(乘以16)得到段基地址+段内偏移得到最终的地址。

当CPU工作于保护模式下,段寄存器存储的内容不再是段基址了,此时的段寄存器中存放的是段选择子,用来指示当前这个段寄存器“指向”的是哪个分段。

注意我这里的指向打了引号,段寄存器中存储的并不是内存段的直接地址,而是段选择子,它的结构如下:

16个bit长度的段寄存器内容划分了三个字段:

  • PRL: 特权请求级,就是我们常说的ring0-ring3四个特权级。
  • TI: 0表示用的是全局描述符表GDT,1表示使用的是局部描述符表LDT。
  • Index: 这是一个表格中表项的索引值,这个表格叫内存描述符表,它的每一个表项都描述了一个内存分段。

这里提到了两个表,全局描述符表GDT和局部描述符表LDT,关于这两个表的介绍,下面介绍描述符寄存器时再详述,这里只需要知道,这是CPU支持分段式内存管理需要的表格,放在内存中,表格中的每一项都是一个描述符,记录了一个内存分段的信息。

保护模式下的段寄存器和段描述符到最后的内存分段,通过下图的方式联系在一起:

通用寄存器、段寄存器、标志寄存器、指令寄存器,这四组寄存器共同构成了一个基本的指令执行环境,一个线程的上下文也基本上就是这些寄存器,在执行线程切换的时候,就是修改它们的内容。

控制寄存器

控制寄存器是CPU中一组相当重要的寄存器,我们知道eflags寄存器记录了当前运行线程的一系列关键信息。

那CPU运行过程中自身的一些关键信息保存在哪里呢?答案是控制寄存器!

32位CPU总共有cr0-cr4共5个控制寄存器,64位增加了cr8。他们各自有不同的功能,但都存储了CPU工作时的重要信息:

  • cr0: 存储了CPU控制标记和工作状态
  • cr1: 保留未使用
  • cr2: 页错误出现时保存导致出错的地址
  • cr3: 存储了当前进程的虚拟地址空间的重要信息——页目录地址
  • cr4: 也存储了CPU工作相关以及当前人任务的一些信息
  • cr8: 64位新增扩展使用

其中,CR0尤其重要,它包含了太多重要的CPU信息,值得单独关注一下:

一些重要的标记位含义如下:

  • PG: 是否启用内存分页
  • AM: 是否启用内存对齐自动检查
  • WP: 是否开启内存写保护,若开启,对只读页面尝试写入时将触发异常,这一机制常常被用来实现写时复制功能
  • PE: 是否开启保护模式

除了CR0,另一个值得关注的寄存器是CR3,它保存了当前进程所使用的虚拟地址空间的页目录地址,可以说是整个虚拟地址翻译中的顶级指挥棒,在进程空间切换的时候,CR3也将同步切换。

调试寄存器

在x86/x64CPU内部,还有一组用于支持软件调试的寄存器。

调试,对于我们程序员是家常便饭,必备技能。但你想过你的程序能够被调试背后的原理吗?

程序能够被调试,关键在于能够被中断执行和恢复执行,被中断的地方就是我们设置的断点。那程序是如何能在遇到断点的时候停下来呢?

对于一些解释执行(PHP、Python、JavaScript)或虚拟机执行(Java)的高级语言,这很容易办到,因为它们的执行都在解释器/虚拟机的掌控之中。

而对于像C、C++这样的“底层”编程语言,程序代码是直接编译成CPU的机器指令来执行的,这就需要CPU来提供对于调试的支持了。

对于通常的断点,也就是程序执行到某个位置下就停下来,这种断点实现的方式,在x86/x64上,是利用了一条软中断指令:int 3来进行实现的。

注意,这里的int不是指高级语言里面的整数,而是表示interrupt中断的意思,是一条汇编指令,int 3则表示中断向量号为3的中断。

在我们使用调试器下断点时,调试器将会把对应位置的原来的指令替换为一个int 3指令,机器码为0xCC。这个动作对我们是透明的,我们在调试器中看到的依然是原来的指令,但实际上内存中已经不是原来的指令了。

顺便提一句,两个0xCC是汉字【烫】的编码,在一些编译器里,会给线程的栈中填充大量的0xCC,如果程序出错的时候,我们经常会看到很多烫烫烫出现,就是这个原因。

言归正传,CPU在执行这条int 3指令时,将自动触发中断处理流程(虽然这实际上不是一个真正的中断),CPU将取出IDTR寄存器指向的中断描述符表IDT的第3项,执行里面的中断处理函数。

而这个中断描述符表,早在操作系统启动之初,就已经提前安排好了,所以执行这条指令后,操作系统的中断处理函数将介入,来处理这一事件。

后面的过程就多了,简单来说,操作系统会把触发这一事件的进程冻结起来,随后将这一事件发送到调试器,调试器拿到之后就知道目标进程触发断点了。这个时候,咱们程序员就能通过调试器的UI交互界面或者命令行调试接口来调试目标进程,查看堆栈、查看内存、变量都随你。

如果我们要继续运行,调试器将会把之前修改的int 3指令给恢复回去,然后告知操作系统:我处理完了,把目标进程解冻吧!

上面简单描述了一下普通断点的实现原理。现在思考一个场景:我们发现一个bug,某个全局整数型变量的值老是莫名其妙被修改,但你发现有很多线程,很多函数都有可能会去修改这个变量,你想找出到底谁干的,怎么办?

这个时候上面的普通断点就没办法了,你需要一种新的断点:硬件断点。

这时候就该本小节的主人公调试寄存器登场表演了。

在x86架构CPU内部,提供了8个调试寄存器DR0~DR7。

  • DR0~DR3:这是四个用于存储地址的寄存器
  • DR4~DR5:这两个有点特殊,受前面提到的CR4寄存器中的标志位DE位控制,如果CR4的DE位是1,则DR4、DR5是不可访问的,访问将触发异常。如果CR4的DE位是0,则DR4和DR5将会变成DR6和DR7的别名,相当于做了一个软链接。这样做是为了将DR4、DR5保留,以便将来扩展调试功能时使用。
  • DR6:这个寄存器中存储了硬件断点触发后的一些状态信息
  • DR7:调试控制寄存器,这里面记录了对DR0-DR3这四个寄存器中存储地址的中断方式(是对地址的读,还是写,还是执行)、数据长度(1/2/4个字节)以及作用范围等信息

通过调试器的接口设置硬件断点后,CPU在执行代码的过程中,如果满足条件,将自动中断下来。

回答前面提出的问题,想要找出是谁偷偷修改了全局整形变量,只需要通过调试器设置一个硬件写入断点即可。

描述符寄存器

所谓描述符,其实就是一个数据结构,用来记录一些信息,‘描述’一个东西。把很多个描述符排列在一起,组成一个表,就成了描述符表。再使用一个寄存器来指向这个表,这个寄存器就是描述符寄存器。

在x86/x64系列CPU中,有三个非常重要的描述符寄存器,它们分别存储了三个地址,指向了三个非常重要的描述符表。

  • gdtr: 全局描述符表寄存器,前面提到,CPU现在使用的是段+分页结合的内存管理方式,那系统总共有那些分段呢?这就存储在一个叫全局描述符表(GDT)的表格中,并用gdtr寄存器指向这个表。这个表中的每一项都描述了一个内存段的信息。
  • ldtr: 局部描述符表寄存器,这个寄存器和上面的gdtr一样,同样指向的是一个段描述符表(LDT)。不同的是,GDT是全局唯一,LDT是局部使用的,可以创建多个,随着任务段切换而切换(下文介绍任务寄存器会提到)。

GDT和LDT中的表项,就是段描述符,描述了一个内存分段的信息,其结构如下:

一个表项占据8个字节(32位CPU),里面存储了一个内存分段的诸多信息:基地址、大小、权限、类型等信息。

除了这两个段描述符寄存器,还有一个非常重要的描述符寄存器:

idtr: 中断描述符表寄存器,指向了中断描述符表IDT,这个表的每一项都是一个中断处理描述符,当CPU执行过程中发生了硬中断、异常、软中断时,将自动从这个表中定位对应的表项,里面记录了发生中断、异常时该去哪里执行处理函数。

IDT中的表项称为Gate,中文意思为门,因为这是应用程序进入内核的主要入口。虽然表的名字叫中断描述符表,但表中存储的不全是中断描述符,IDT中的表项存在三种类型,对应三种类型的门:

  • 任务门
  • 陷阱门
  • 中断门

三种描述符中都存储了处理这个中断/异常/任务时该去哪里处理的地址。三种门用途不一,其中中断门是真正意义上的中断,而像前面提到的调试指令int 3以及老式的系统调用指令int 2e/int 80都属于陷阱门。任务门则用的较少,要了解任务门,先了解下任务寄存器。

任务寄存器

现代操作系统,都是支持多任务并发运行的,x86架构CPU为了顺应时代潮流,在硬件层面上提供了专门的机制用来支持多任务的切换,这体现在两个方面:

CPU内部设置了一个专用的寄存器——任务寄存器TR,它指向当前运行的任务。

定义了描述任务的数据结构TSS,里面存储了一个任务的上下文(一系列寄存器的值),下图是一个32位CPU的TSS结构图:

x86CPU的构想是每一个任务对应一个TSS,然后由TR寄存器指向当前的任务,执行任务切换时,修改TR寄存器的指向即可,这是硬件层面的多任务切换机制。

这个构想其实还是很不错的,然而现实却打了脸,包括Linux和Windows在内的主流操作系统都没有使用这个机制来进行线程切换,而是自己使用软件来实现多线程切换。

所以,绝大多数情况下,TR寄存器都是指向固定的,即便线程切换了,TR寄存器仍然不会变化。

注意,我这里说的的是绝大多数情况,而没有说死。虽然操作系统不依靠TSS来实现多任务切换,但这并不意味着CPU提供的TSS操作系统一点也没有使用。还是存在一些特殊情况,如一些异常处理会使用到TSS来执行处理。

下面这张图,展示了控制寄存器、描述符寄存器、任务寄存器构成的全貌:

模型特定寄存器

从80486之后的x86架构CPU,内部增加了一组新的寄存器,统称为MSR寄存器,中文直译是模型特定寄存器,意思是这些寄存器不像上面列出的寄存器是固定的,这些寄存器可能随着不同的版本有所变化。这些寄存器主要用来支持一些新的功能。

随着x86CPU不断更新换代,MSR寄存器变的越来越多,但与此同时,有一部分MSR寄存器随着版本迭代,慢慢固化下来,成为了变化中那部分不变的,这部分MSR寄存器,Intel将其称为Architected MSR,这部分MSR寄存器,在命名上,统一加上了IA32的前缀。

这里选取三个代表性的MSR简单介绍一下:

  • IA32_SYSENTER_CS
  • IA32_SYSENTER_ESP
  • IA32_SYSENTER_EIP

这三个MSR寄存器是用来实现快速系统调用。

在早期的x86架构CPU上,系统调用依赖于软中断实现,类似于前面调试用到的int 3指令,在Windows上,系统调用用到的是int 2e,在Linux上,用的是int 80。

软中断毕竟还是比较慢的,因为执行软中断就需要内存查表,通过IDTR定位到IDT,再取出函数进行执行。

系统调用是一个频繁触发的动作,如此这般势必对性能有所影响。在进入奔腾时代后,就加上了上面的三个MSR寄存器,分别存储了执行系统调用后,内核系统调用入口函数所需要的段寄存器、堆栈栈顶、函数地址,不再需要内存查表。快速系统调用还提供了专门的CPU指令sysenter/sysexit用来发起系统调用和退出系统调用。

在64位上,这一对指令升级为syscall/sysret。

总结

以上就是全部要介绍的寄存器了,需要说明一下的是,这并不是x86CPU全部所有的寄存器,除了这些,还存在XMM、MMX、FPU浮点数运算等其他寄存器。

管理处理器的亲和性

简单地说,CPU 亲和性(affinity) 就是进程要在某个给定的 CPU 上尽量长时间地运行而不被迁移到其他处理器的倾向性。Linux 内核进程调度器天生就具有被称为 软 CPU 亲和性(affinity) 的特性,这意味着进程通常不会在处理器之间频繁迁移。这种状态正是我们希望的,因为进程迁移的频率小就意味着产生的负载小。

2.6 版本的 Linux 内核还包含了一种机制,它让开发人员可以编程实现 硬 CPU 亲和性(affinity)。这意味着应用程序可以显式地指定进程在哪个(或哪些)处理器上运行。

什么是 Linux 内核硬亲和性(affinity)?

在 Linux 内核中,所有的进程都有一个相关的数据结构,称为 task_struct。这个结构非常重要,原因有很多;其中与 亲和性(affinity)相关度最高的是 cpus_allowed 位掩码。这个位掩码由 n 位组成,与系统中的 n 个逻辑处理器一一对应。 具有 4 个物理 CPU 的系统可以有 4 位。如果这些 CPU 都启用了超线程,那么这个系统就有一个 8 位的位掩码。

如果为给定的进程设置了给定的位,那么这个进程就可以在相关的 CPU 上运行。因此,如果一个进程可以在任何 CPU 上运行,并且能够根据需要在处理器之间进行迁移,那么位掩码就全是 1。实际上,这就是 Linux 中进程的缺省状态。

Linux 内核 API 提供了一些方法,让用户可以修改位掩码或查看当前的位掩码:

  • sched_set_affinity() (用来修改位掩码)
  • sched_get_affinity() (用来查看当前的位掩码)

注意,cpu_affinity 会被传递给子线程,因此应该适当地调用 sched_set_affinity。

为什么应该使用硬亲和性(affinity)?

通常 Linux 内核都可以很好地对进程进行调度,在应该运行的地方运行进程(这就是说,在可用的处理器上运行并获得很好的整体性能)。内核包含了一些用来检测 CPU 之间任务负载迁移的算法,可以启用进程迁移来降低繁忙的处理器的压力。

一般情况下,在应用程序中只需使用缺省的调度器行为。然而,您可能会希望修改这些缺省行为以实现性能的优化。让我们来看一下使用硬亲和性(affinity) 的 3 个原因。

原因 1. 有大量计算要做
基于大量计算的情形通常出现在科学和理论计算中,但是通用领域的计算也可能出现这种情况。一个常见的标志是您发现自己的应用程序要在多处理器的机器上花费大量的计算时间。

原因 2. 您在测试复杂的应用程序
测试复杂软件是我们对内核的亲和性(affinity)技术感兴趣的另外一个原因。考虑一个需要进行线性可伸缩性测试的应用程序。有些产品声明可以在 使用更多硬件 时执行得更好。

我们不用购买多台机器(为每种处理器配置都购买一台机器),而是可以:

  • 购买一台多处理器的机器
  • 不断增加分配的处理器
  • 测量每秒的事务数
  • 评估结果的可伸缩性

如果应用程序随着 CPU 的增加可以线性地伸缩,那么每秒事务数和 CPU 个数之间应该会是线性的关系(例如斜线图 —— 请参阅下一节的内容)。这样建模可以确定应用程序是否可以有效地使用底层硬件。

Amdahl 法则

Amdahl 法则是有关使用并行处理器来解决问题相对于只使用一个串行处理器来解决问题的加速比的法则。加速比(Speedup) 等于串行执行(只使用一个处理器)的时间除以程序并行执行(使用多个处理器)的时间:

其中 T(j) 是在使用 j 个处理器执行程序时所花费的时间。

Amdahl 法则说明这种加速比在现实中可能并不会发生,但是可以非常接近于该值。对于通常情况来说,我们可以推论出每个程序都有一些串行的组件。随着问题集不断变大,串行组件最终会在优化解决方案时间方面达到一个上限。

Amdahl 法则在希望保持高 CPU 缓存命中率时尤其重要。如果一个给定的进程迁移到其他地方去了,那么它就失去了利用 CPU 缓存的优势。实际上,如果正在使用的 CPU 需要为自己缓存一些特殊的数据,那么所有其他 CPU 都会使这些数据在自己的缓存中失效。

因此,如果有多个线程都需要相同的数据,那么将这些线程绑定到一个特定的 CPU 上是非常有意义的,这样就确保它们可以访问相同的缓存数据(或者至少可以提高缓存的命中率)。否则,这些线程可能会在不同的 CPU 上执行,这样会频繁地使其他缓存项失效。

原因 3. 您正在运行时间敏感的、决定性的进程
我们对 CPU 亲和性(affinity)感兴趣的最后一个原因是实时(对时间敏感的)进程。例如,您可能会希望使用硬亲和性(affinity)来指定一个 8 路主机上的某个处理器,而同时允许其他 7 个处理器处理所有普通的系统调度。这种做法确保长时间运行、对时间敏感的应用程序可以得到运行,同时可以允许其他应用程序独占其余的计算资源。

下面的样例应用程序显示了这是如何工作的。

如何利用硬亲和性(affinity)

现在让我们来设计一个程序,它可以让 Linux 系统非常繁忙。可以使用前面介绍的系统调用和另外一些用来说明系统中有多少处理器的 API 来构建这个应用程序。实际上,我们的目标是编写这样一个程序:它可以让系统中的每个处理器都繁忙几秒钟。

清单 1. 让处理器繁忙

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* This method will create threads, then bind each to its own cpu. */
bool do_cpu_stress(int numthreads)
{
int ret = TRUE;
int created_thread = 0;
/* We need a thread for each cpu we have... */
while ( created_thread < numthreads - 1 )
{
int mypid = fork();
if (mypid == 0) /* Child process */
{
printf("\tCreating Child Thread: #%i\n", created_thread);
break;
}
else /* Only parent executes this */
{
/* Continue looping until we spawned enough threads! */ ;
created_thread++;
}
}
/* NOTE: All threads execute code from here down! */

正如您可以看到的一样,这段代码只是通过 fork 调用简单地创建一组线程。每个线程都执行这个方法中后面的代码。现在我们让每个线程都将亲和性(affinity)设置为自己的 CPU。

清单 2. 为每个线程设置 CPU 亲和性(affinity)

1
2
3
4
5
6
7
8
9
10
cpu_set_t mask;
/* CPU_ZERO initializes all the bits in the mask to zero. */
CPU_ZERO( &mask );
/* CPU_SET sets only the bit corresponding to cpu. */
CPU_SET( created_thread, &mask );
/* sched_setaffinity returns 0 in success */
if( sched_setaffinity( 0, sizeof(mask), &mask ) == -1 )
{
printf("WARNING: Could not set CPU Affinity, continuing...\n");
}

如果程序可以执行到这儿,那么我们的线程就已经设置了自己的亲和性(affinity)。调用 sched_setaffinity 会设置由 pid 所引用的进程的 CPU 亲和性(affinity)掩码。如果 pid 为 0,那么就使用当前进程。

亲和性(affinity)掩码是使用在 mask 中存储的位掩码来表示的。最低位对应于系统中的第一个逻辑处理器,而最高位则对应于系统中最后一个逻辑处理器。

每个设置的位都对应一个可以合法调度的 CPU,而未设置的位则对应一个不可调度的 CPU。换而言之,进程都被绑定了,只能在那些对应位被设置了的处理器上运行。通常,掩码中的所有位都被置位了。这些线程的亲和性(affinity)都会传递给从它们派生的子进程中。

注意不应该直接修改位掩码。应该使用下面的宏。虽然在我们的例子中并没有全部使用这些宏,但是在本文中还是详细列出了这些宏,您在自己的程序中可能需要这些宏。

清单 3. 间接修改位掩码的宏

1
2
3
4
5
6
7
8
void CPU_ZERO (cpu_set_t *set)
这个宏对 CPU 集 set 进行初始化,将其设置为空集。
void CPU_SET (int cpu, cpu_set_t *set)
这个宏将 cpu 加入 CPU 集 set 中。
void CPU_CLR (int cpu, cpu_set_t *set)
这个宏将 cpu 从 CPU 集 set 中删除。
int CPU_ISSET (int cpu, const cpu_set_t *set)
如果 cpu 是 CPU 集 set 的一员,这个宏就返回一个非零值(true),否则就返回零(false)。

对于本文来说,样例代码会继续让每个线程都执行某些计算量较大的操作。

清单 4. 每个线程都执行一个计算敏感的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Now we have a single thread bound to each cpu on the system */
int computation_res = do_cpu_expensive_op(41);
cpu_set_t mycpuid;
sched_getaffinity(0, sizeof(mycpuid), &mycpuid);
if ( check_cpu_expensive_op(computation_res) )
{
printf("SUCCESS: Thread completed, and PASSED integrity check!\n",
mycpuid);
ret = TRUE;
}
else
{
printf("FAILURE: Thread failed integrity check!\n",
mycpuid);
ret = FALSE;
}
return ret;
}

我们使用一个 main 程序来封装这些方法,它使用一个用户指定的参数来说明要让多少个 CPU 繁忙。我们可以使用另外一个方法来确定系统中有多少个处理器:

int NUM_PROCS = sysconf(_SC_NPROCESSORS_CONF);

这个方法让程序能够自己确定要让多少个处理器保持繁忙,例如缺省让所有的处理器都处于繁忙状态,并允许用户指定系统中实际处理器范围的一个子集。

调试工具ltrace strace ftrace的使用

最近学习了一些调试工具,包括ltrace strace ftrace。这些都属于不同层级的调试工具。 下面是我画的简易的层次关系图。

1
2
3
4
5
6
7
8
  App
|
| <--------ltrace
|
libc ld < -------strace
|
| <----------systemtap
kernel <---------ftrace

systemtap是当下功能强大的内核函数追踪工具,我们编写特定的script就可以调试内核函数,由于这个篇幅有限,我将在其他文章中进行介绍。

先从最简单的说起ltrace起。 拿最简单的hello world程序来说,printf调用的lic里面的库函数说白了就是put(),put()函数返回值就是打印字符的个数,包括转移字符\n。

1
2
3
4
5
6
7
8
[root@localhost day3]# ltrace -f ./hello
[pid 15439] __libc_start_main(0x40052c, 1, 0x7fff226b6888, 0x400560
[pid 15439] puts("Hello world"Hello world
) = 12
[pid 15439] puts("Hello world"Hello world
) = 12
[pid 15439] +++ exited (status 0) +++
[root@localhost day3]#

下面我来说一下strace的功能,追踪system call 与 signal。所谓系统调用,就是内核提供的、功能十分强大的一系列的函数。这些系统调用是在内核中实现的,比如linux中的POSIX标准就是指的这一些。再通过一定的方式把系统调用给用户,一般都通过门(gate)陷入(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
29
30
[root@localhost day3]# strace -f ./hello
execve("./hello", ["./hello"], [/* 51 vars */]) = 0
brk(0) = 0x184e000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ff8cecb5000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=114694, ...}) = 0
mmap(NULL, 114694, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7ff8cec98000
close(3) = 0
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\20\33\242\361<\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2071376, ...}) = 0
mmap(0x3cf1a00000, 3896312, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x3cf1a00000
mprotect(0x3cf1bad000, 2097152, PROT_NONE) = 0
mmap(0x3cf1dad000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1ad000) = 0x3cf1dad000 mmap(0x3cf1db3000, 17400, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x3cf1db3000
close(3) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ff8cec97000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ff8cec95000
arch_prctl(ARCH_SET_FS, 0x7ff8cec95740) = 0
mprotect(0x3cf1dad000, 16384, PROT_READ) = 0
mprotect(0x600000, 4096, PROT_READ) = 0
mprotect(0x3cf1820000, 4096, PROT_READ) = 0
munmap(0x7ff8cec98000, 114694) = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ff8cecb4000
write(1, "Hello world\n", 12Hello world ) = 12
write(1, "Hello world\n", 12Hello world ) = 12
exit_group(0) = ? +++
exited with 0 +++
[root@localhost day3]#

通过查看上面的system call,我们就会对elf文件载入流程有一个清晰的认识。

流程:

1.调用execve()函数执行载入

2.brk() allocate new space to load the infomation of programmer

3.mmap()把elf头载入virtual address

4.先链接ld.so与ld.so.cache中是否存在之前调用过库函数的绝对地址

5.查看file 状态的fstat(),包括r w x 等

6.读取ELF头,并映射到虚拟地址,进行内存保护mprotect()

7.载入libc.so库函数

8.arch_prctl()设置运行环境的体系结构

9.write()就是内核中写函数,包括发消息给其他的用户,写入设备等。 10.完成调用,退出。

Ftrace 是一个内核中的追踪器,用于帮助系统开发者或设计者查看内核运行情况,它可以被用来调试或者分析延迟/性能问题。最早 ftrace 是一个 function tracer,仅能够记录内核的函数调用流程。如今 ftrace 已经成为一个 framework,采用 plugin 的方式支持开发人员添加更多种类的 trace 功能。

Ftrace需要kernel支持 CONFIG_FUNCTION_TRACER CONFIG_FUNCTION_GRAPH_TRACER CONFIG_CONTEXT_SWITCH_TRACER CONFIG_NOP_TRACER CONFIG_SCHED_TRACER Debugfs 勾选,这样才可以使用ftrace中的一些特定功能。

编译内核完成后,重新开机载入新内核。 ftrace不同于其他的调试工具,他需要debugfs的辅助。debugfs是一种特殊的文件系统,本身无法进行编辑,任何写入信息都要靠echo载入。另外由于是kernel debug,所以需要最高的root权限。 我们要先挂载这个文件系统到特殊的文件目录。这个/mnt/与/sys/kernel/debug/tracing是等同的。

1
2
3
4
5
6
7
[root@localhost /]# mount -t debugfs debugfs /mnt/ 
[root@localhost /]# cd /mnt/
[root@localhost mnt]# ls
acpi cleancache dri hid iwlwifi mei sched_features usb x86 bdi cxgb4 dynamic_debug ideapad kernel_page_tables mmc0 sleep_time vgaswitcheroo xen bluetooth debug_objects extfrag ieee80211 kprobes rcu suspend_stats virtio-ports boot_params dma_buf frontswap ips mce regmap tracing wakeup_sources
[root@localhost mnt]# cd tracing/
[root@localhost tracing]# ls
available_events enabled_functions max_graph_depth set_ftrace_filter stack_trace_filter tracing_cpumask available_filter_functions events options set_ftrace_notrace trace tracing_max_latency available_tracers free_buffer per_cpu set_ftrace_pid trace_clock tracing_on buffer_size_kb function_profile_enabled printk_formats set_graph_function trace_marker tracing_thresh buffer_total_size_kb instances README snapshot trace_options uprobe_events current_tracer kprobe_events saved_cmdlines stack_max_size trace_pipe uprobe_profile dyn_ftrace_total_info kprobe_profile set_event stack_trace trace_stat

Ftrace 的普通使用步骤如下:

  1. 挂载Debugfs: Ftrace 通过 debugfs 向用户态提供访问接口。配置内核时激活 debugfs 后会创建目录 /sys/kernel/debug ,debugfs 文件系统就是挂载 到该目录。 1.1 运行时挂载: Officially mount method :
1
2
3
4
5
6
7
8
# mount -t debugfs nodev /sys/kernel/debug
OR:
# mkdir /debug
# mount -t debugfs nodev /debug
# cd /debug/tracing

OR: # mount -t debugfs nodev /sys/kernel/debug
# ln -s /sys/kernel/debug /debug

1.2 系统启动自动挂载: 要在系统启动自动挂载debugfs,需要将如下内容添加到 /etc/fstab 文件: debugfs /sys/kernel/debug debugfs defaults 0 0

  1. 选择一种 tracer:
1
2
3
4
5
6
7
8
9
# cat current_tracer

nop // no option

# cat available_tracers

blk function_graph function nop

# echo function_graph > current_tracer
  1. 打开关闭追踪(在老的内核上有tracing_enabled,需要给tracing_enabled和tracing_on同时赋1 才能打开追踪,而在新的内核上去掉tracing_enabled只需要控制tracing_on 即可打开关闭追踪)
    1
    # echo 1 > tracing_on; run_test; echo 0 > tracing_on

注:其实ftrace_enabled并不是去掉了,而是从 tracing目录中去掉,我们还是可以在 /proc/sys/kernel/ftrace_enabled 目录下看到他的身影,而且默认已经被设置为1,所以现在我们只需要echo 1到tracing_on 中即可打开追踪。 $ cat /proc/sys/kernel/ftrace_enabled 1

  1. 查看追踪结果:

ftrace 的输出信息主要保存在 3 个文件中。

  • Trace,该文件保存 ftrace 的输出信息,其内容可以直接阅读。
  • latency_trace,保存与 trace 相同的信息,不过组织方式略有不同。主要为了用户能方便地分析系统中有关延迟的信息。
  • trace_pipe 是一个管道文件,主要为了方便应用程序读取 trace 内容。算是扩展接口吧。

所以可以直接查看 trace 追踪文件,也可以在追踪之前使用trace_pipe 将追踪结果直接导向其他的文件。 比如:# cat trace_pipe > /tmp/log &

1
2
3
# cat /tmp/log

OR # cat trace

Ftrace 的进阶使用

  1. 使用 echo pid > set_ftrace_pid 来追踪特定的进程!

  2. 追踪事件:

2.1 首先查看事件文件夹下面有哪些选项:

1
2
3
4
# ls events/
block ext4 header_event jbd2 napi raw_syscalls …… enable
# ls events/sched/
enable sched_kthread_stop_ret sched_process_exit sched_process_wait ……

2.2 追踪一个/若干事件:

1
2
3
4
5
6
7
# echo 1 > events/sched/sched_wakeup/enable
# cat trace | head -10
# tracer: nop
#TASK-PID CPU# TIMESTAMP FUNCTION
# | | | |
bash-2613 [001] 425.078164: sched_wakeup: task bash:2613 [120] success=0 [001]
bash-2613 [001] 425.078184: sched_wakeup: task bash:2613 [120] success=0 [001]

2.3 追踪一类事件:

1
2
3
4
5
6
7
# echo 1 > events/sched/enable
# cat trace | head -10
# tracer: nop
#TASK-PID CPU# TIMESTAMP FUNCTION
# | | | |
events/0-9 [000] 638.042792: sched_switch: task events/0:9 [120] (S) ==> kondemand/0:1305 [120]
ondemand/0-1305 [000] 638.042796: sched_stat_wait: task: restorecond:1395 wait: 15023 [ns]

2.4 追踪所有事件:

1
2
3
4
5
6
7
8
9
# echo 1 > events/enable
# cat trace | head -10
# tracer: nop
#TASK-PID CPU# TIMESTAMP FUNCTION
# | | | |
cpid-1470 [001] 794.947181: kfree: call_site=ffffffff810c996d ptr=(null)
acpid-1470 [001] 794.947182: sys_read -> 0x1
acpid-1470 [001] 794.947183: sys_exit: NR 0 = 1
...

  1. stack_trace

    1
    2
    3
    # echo 1 > /proc/sys/kernel/stack_tracer_enabled
    OR # kernel command line “stacktrace”
    查看: # cat stack_trace
  2. 将要跟踪的函数写入文件 set_ftrace_filter ,将不希望跟踪的函数写入文件 set_ftrace_notrace。通常直接操作文件 set_ftrace_filter 就可以了.

============= Ftrace 提供的函数使用=============

内核头文件 include/linux/kernel.h 中描述了 ftrace 提供的工具函数的原型,这些函数包括 trace_printk、tracing_on/tracing_off 等。

  1. 使用 trace_printk 打印跟踪信息

ftrace 提供了一个用于向 ftrace 跟踪缓冲区输出跟踪信息的工具函数,叫做 trace_printk(),它的使用方式与 printk() 类似。可以通过 trace 文件读取该函数的输出。从头文件 include/linux/kernel.h 中可以看到,在激活配置 CONFIG_TRACING 后,trace_printk() 定义为宏: #define trace_printk(fmt, args…) \ … 所以在使用时:(例子是在一个内核模块中添加打印信息)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <linux/init.h> 
#include <linux/module.h>
#include <linux/kernel.h>
MODULE_LICENSE("GPL");

static int ftrace_demo_init(void)
{
trace_printk("Can not see this in trace unless loaded for the second time\n");
return 0;
}

static void ftrace_demo_exit(void)
{
trace_printk("Module unloading\n");
}

module_init(ftrace_demo_init);
module_exit(ftrace_demo_exit);
  1. 使用 tracing_on/tracing_off 控制跟踪信息的记录

在跟踪过程中,有时候在检测到某些事件发生时,想要停止跟踪信息的记录,这样,跟踪缓冲区中较新的数据是与该事件有关的。在用户态,可以通过向文件 tracing_on 写入 0 来停止记录跟踪信息,写入 1 会继续记录跟踪信息。而在内核代码中,可以通过函数 tracing_on() 和 tracing_off() 来做到这一点,它们的行为类似于对 /sys/kernel/debug/tracing 下的文件 tracing_on 分别执行写 1 和 写 0 的操作。 使用这两个函数,会对跟踪信息的记录控制地更准确一些,这是因为在用户态写文件 tracing_on 到实际暂停跟踪,中间由于上下文切换、系统调度控制等可能已经经过较长的时间,这样会积累大量的跟踪信息,而感兴趣的那部分可能会被覆盖掉了。

实际代码中,可以通过特定条件(比如检测到某种异常状况,等等)来控制跟踪信息的记录,函数的使用方式类似如下的形式:

1
2
if (condition)
tracing_on() or tracing_off()

跟踪模块运行状况时,使用 ftrace 命令操作序列在用户态进行必要的设置,而在代码中则可以通过 traceing_on() 控制在进入特定代码区域时开启跟踪信息,并在遇到某些条件时通过 tracing_off() 暂停;读者可以在查看完感兴趣的信息后,将 1 写入 tracing_on 文件以继续记录跟踪信息。实践中,可以通过宏来控制是否将对这些函数的调用编译进内核模块,这样可以在调试时将其开启,在最终发布时将其关闭。 用户态的应用程序可以通过直接读写文件 tracing_on 来控制记录跟踪信息的暂停状态,以便了解应用程序运行期间内核中发生的活动。

如果我们要开启追踪功能。echo 1 > tracing_on echo function_graph >current_tracer 另外我们也可以设置要追踪的pid值 event buffer等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# tracer: function_graph
#
# CPU DURATION FUNCTION CALLS
# | | | | | | |
2) | update_curr() {
2) 0.147 us | update_min_vruntime();
2) | cpuacct_charge() {
2) 0.097 us | debug_lockdep_rcu_enabled();
2) 0.082 us | rcu_is_cpu_idle();
2) 0.120 us | debug_lockdep_rcu_enabled();
2) 0.098 us | debug_lockdep_rcu_enabled();
2) 0.094 us | rcu_is_cpu_idle();
2) 0.099 us | rcu_lockdep_current_cpu_online();
2) 0.072 us | debug_lockdep_rcu_enabled();
2) 0.071 us | rcu_is_cpu_idle();
2) 6.935 us | }
2) 8.757 us | }
2) 0.269 us | __compute_runnable_contrib();
2) 0.093 us | __update_entity_load_avg_contrib();
2) 0.188 us | update_cfs_rq_blocked_load();
2) 0.113 us | update_cfs_shares();
2) | sched_slice.isra.38() {
2) 0.206 us | calc_delta_mine();
2) 0.962 us | }

ftrace 不仅可以追踪内核中的函数,也可以追踪用户态下的函数是如何trap in kernel 然后ret的。 比如我们写一个fork的demo

1
2
3
4
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
#include <sys/types.h>
#include <sys/stat.h>

int main(int argc, char **argv)
{
int ret;
int fd;
int i = 0;
char pidbuf[20];
pid_t id;

id = fork();
if (id < 0) {
fprintf(stderr, "Error in fork");
exit(-1);
} else if (id == 0) {
scanf("%d", &i);

ret = execv("hello", NULL);
if (ret == -1) {
fprintf(stderr, "Error in execv");
exit(-1);
}
} else {
sprintf(pidbuf, "%ld", (long)id);
fd = open("/sys/kernel/debug/tracing/set_ftrace_pid", O_CREAT | O_RDWR, 0660);
if (fd < 0) {
fprintf(stderr, "Error in open");
exit(-1);
}
write(fd, pidbuf, strlen(pidbuf));
close(fd);
fd = open("/sys/kernel/debug/tracing/tracing_on", O_CREAT | O_RDWR, 0660);
write(fd, "1", 2);
close(fd);
printf("!!!!\n");
sleep(5);
}
return 0;
}

然后使用ftrace进行追踪,可以得到一个system call的完整的结果。

Linux性能分析工具汇总

首先来看一张图:

图片

上图是Brendan Gregg 的一次性能分析的分享,这里面的所有工具都可以通过 man 来获得它的帮助文档,下面简单介绍介绍一下常规的用法:

vmstat—虚拟内存统计

vmstat(VirtualMeomoryStatistics,虚拟内存统计)是 Linux 中监控内存的常用工具,可对操作系统的虚拟内存、进程、CPU 等的整体情况进行监视。vmstat 的常规用法:vmstat interval times 即每隔 interval 秒采样一次,共采样 times 次,如果省略 times,则一直采集数据,直到用户手动停止为止。简单举个例子:

图片

可以使用 ctrl+c 停止 vmstat 采集数据。

第一行显示了系统自启动以来的平均值,第二行开始显示现在正在发生的情况,接下来的行会显示每5秒间隔发生了什么,每一列的含义在头部,如下所示:

  • procs:r 这一列显示了多少进程在等待cpu,b列显示多少进程正在不可中断的休眠(等待IO)。
  • memory:swapd 列显示了多少块被换出了磁盘(页面交换),剩下的列显示了多少块是空闲的(未被使用),多少块正在被用作缓冲区,以及多少正在被用作操作系统的缓存。
  • swap:显示交换活动:每秒有多少块正在被换入(从磁盘)和换出(到磁盘)。
  • io:显示了多少块从块设备读取(bi)和写出(bo),通常反映了硬盘I/O。
  • system:显示每秒中断(in)和上下文切换(cs)的数量。
  • cpu:显示所有的cpu时间花费在各类操作的百分比,包括执行用户代码(非内核),执行系统代码(内核),空闲以及等待IO。

内存不足的表现:free memory 急剧减少,回收 buffer 和 cache 也无济于事,大量使用交换分区(swpd),页面交换(swap)频繁,读写磁盘数量(io)增多,缺页中断(in)增多,上下文切换(cs)次数增多,等待IO的进程数(b)增多,大量CPU时间用于等待IO(wa)

iostat—用于报告中央处理器统计信息

iostat 用于报告中央处理器(CPU)统计信息和整个系统、适配器、tty 设备、磁盘和 CD-ROM 的输入/输出统计信息,默认显示了与 vmstat 相同的 cpu 使用信息,使用以下命令显示扩展的设备统计:

图片

第一行显示的是自系统启动以来的平均值,然后显示增量的平均值,每个设备一行。

常见 linux 的磁盘 IO 指标的缩写习惯:rq 是 request,r 是 read,w 是 write,qu 是 queue,sz 是 size,a 是verage,tm 是 time,svc 是 service。

  • rrqm/s 和 wrqm/s:每秒合并的读和写请求,“合并的”意味着操作系统从队列中拿出多个逻辑请求合并为一个请求到实际磁盘。
  • r/s和w/s:每秒发送到设备的读和写请求数。
  • rsec/s和wsec/s:每秒读和写的扇区数。
  • avgrq –sz:请求的扇区数。
  • avgqu –sz:在设备队列中等待的请求数。
  • await:每个IO请求花费的时间。
  • svctm:实际请求(服务)时间。
  • %util:至少有一个活跃请求所占时间的百分比。

dstat—系统监控工具

dstat 显示了 cpu 使用情况,磁盘 io 情况,网络发包情况和换页情况,输出是彩色的,可读性较强,相对于 vmstat 和iostat 的输入更加详细且较为直观。在使用时,直接输入命令即可,当然也可以使用特定参数。

如下:dstat –cdlmnpsy

图片

img

iotop—LINUX进程实时监控工具

iotop命令是专门显示硬盘IO的命令,界面风格类似top命令,可以显示IO负载具体是由哪个进程产生的。是一个用来监视磁盘I/O使用状况的top类工具,具有与top相似的UI,其中包括PID、用户、I/O、进程等相关信息。

可以以非交互的方式使用:

1
iotop –bod interval

查看每个进程的 I/O,可以使用

1
pidstat,pidstat –d instat

pidstat—监控系统资源情况

pidstat 主要用于监控全部或指定进程占用系统资源的情况,如 CPU,内存、设备 IO、任务切换、线程等。

使用方法:

1
pidstat –d interval

pidstat 还可以用以统计CPU使用信息:

1
pidstat –u interval

统计内存信息:

1
pidstat –r interval

top

  • top 命令的汇总区域显示了五个方面的系统性能信息:
  • 负载:时间,登陆用户数,系统平均负载;
  • 进程:运行,睡眠,停止,僵尸;
  • cpu:用户态,核心态,NICE,空闲,等待IO,中断等;
  • 内存:总量,已用,空闲(系统角度),缓冲,缓存;
  • 交换分区:总量,已用,空闲

任务区域默认显示:进程 ID,有效用户,进程优先级,NICE 值,进程使用的虚拟内存,物理内存和共享内存,进程状态,CPU 占用率,内存占用率,累计 CPU 时间,进程命令行信息。

htop

htop 是 Linux 系统中的一个互动的进程查看器,一个文本模式的应用程序(在控制台或者X终端中),需要 ncurses。

图片

img

Htop 可让用户交互式操作,支持颜色主题,可横向或纵向滚动浏览进程列表,并支持鼠标操作。

与 top 相比,htop 有以下优点:

  • 可以横向或者纵向滚动浏览进程列表,以便看到所有的进程和完整的命令行。
  • 在启动上,比top更快。
  • 杀进程时不需要输入进程号。
  • htop支持鼠标操作。

mpstat

mpstat 是 Multiprocessor Statistics的缩写,是实时系统监控工具。其报告CPU的一些统计信息,这些信息存放在 /proc/stat 文件中。在多 CPUs 系统里,其不但能查看所有 CPU 的平均状况信息,而且能够查看特定 CPU 的信息。常见用法:

1
mpstat –P ALL interval times

netstat

netstat 用于显示与 IP、TCP、UDP和 ICMP 协议相关的统计数据,一般用于检验本机各端口的网络连接情况。

常见用法:

1
2
3
4
5
netstat –npl   # 可以查看你要打开的端口是否已经打开。

netstat –rn # 打印路由表信息。

netstat –in # 提供系统上的接口信息,打印每个接口的MTU,输入分组数,输入错误,输出分组数,输出错误,冲突以及当前的输出队列的长度。

ps—显示当前进程的状态

ps 参数太多,具体使用方法可以参考 man ps

常用的方法:

1
2
3
ps  aux       #hsserver

ps –ef |grep #hundsun

杀掉某一程序的方法:

1
ps  aux | grep mysqld | grep –v grep | awk ‘{print $2 }’ xargs kill -9

杀掉僵尸进程:

1
ps –eal | awk ‘{if ($2 == “Z”){print $4}}’ | xargs kill -9

strace

跟踪程序执行过程中产生的系统调用及接收到的信号,帮助分析程序或命令执行中遇到的异常情况。

举例:查看 mysqld 在 linux 上加载哪种配置文件,可以通过运行下面的命令:

1
strace –e stat64 mysqld –print –defaults > /dev/null

uptime

能够打印系统总共运行了多长时间和系统的平均负载,uptime 命令最后输出的三个数字的含义分别是 1分钟,5分钟,15分钟内系统的平均负荷。

lsof

lsof(list open files)是一个列出当前系统打开文件的工具。通过 lsof 工具能够查看这个列表对系统检测及排错,常见的用法:

查看文件系统阻塞

1
lsof /boot

查看端口号被哪个进程占用

1
lsof  -i : 3306

查看用户打开哪些文件

1
lsof –u username

查看进程打开哪些文件

1
lsof –p  4838

查看远程已打开的网络链接

1
lsof –i @192.168.34.128

perf

perf 是 Linux kernel 自带的系统性能优化工具。优势在于与 Linux Kernel 的紧密结合,它可以最先应用到加入 Kernel 的new feature,用于查看热点函数,查看 cashe miss 的比率,从而帮助开发者来优化程序性能。

性能调优工具如 perf,Oprofile 等的基本原理都是对被监测对象进行采样,最简单的情形是根据 tick 中断进行采样,即在 tick 中断内触发采样点,在采样点里判断程序当时的上下文。假如一个程序 90% 的时间都花费在函数 foo() 上,那么 90% 的采样点都应该落在函数 foo() 的上下文中。运气不可捉摸,但我想只要采样频率足够高,采样时间足够长,那么以上推论就比较可靠。因此,通过 tick 触发采样,我们便可以了解程序中哪些地方最耗时间,从而重点分析。

汇总

结合以上常用的性能测试命令并联系文初的性能分析工具的图,就可以初步了解到性能分析过程中哪个方面的性能使用哪方面的工具(命令)。

常用的性能测试工具

熟练并精通了第二部分的性能分析命令工具,引入几个性能测试的工具,介绍之前先简单了解几个性能测试工具:

  • perf_events:一款随 Linux 内核代码一同发布和维护的性能诊断工具,由内核社区维护和发展。Perf 不仅可以用于应用程序的性能统计分析,也可以应用于内核代码的性能统计和分析。
  • eBPF tools:一款使用 bcc 进行的性能追踪的工具,eBPF map可以使用定制的 eBPF 程序被广泛应用于内核调优方面,也可以读取用户级的异步代码。重要的是这个外部的数据可以在用户空间管理。这个 k-v 格式的 map 数据体是通过在用户空间调用 bpf 系统调用创建、添加、删除等操作管理的。
  • perf-tools:一款基于 perf_events (perf) 和 ftrace 的Linux性能分析调优工具集。Perf-Tools 依赖库少,使用简单。支持Linux 3.2 及以上内核版本。
  • bcc(BPF Compiler Collection)::一款使用 eBP F的 perf 性能分析工具。一个用于创建高效的内核跟踪和操作程序的工具包,包括几个有用的工具和示例。利用扩展的BPF(伯克利数据包过滤器),正式称为eBPF,一个新的功能,首先被添加到Linux 3.15。多用途需要Linux 4.1以上BCC。
  • ktap:一种新型的linux脚本动态性能跟踪工具。允许用户跟踪Linux内核动态。ktap是设计给具有互操作性,允许用户调整操作的见解,排除故障和延长内核和应用程序。它类似于Linux和Solaris DTrace SystemTap。
  • Flame Graphs:是一款使用 perf,system tap,ktap 可视化的图形软件,允许最频繁的代码路径快速准确地识别,可以是使用 github.com/brendangregg/flamegraph 中的开发源代码的程序生成。

Linux observability tools | Linux 性能观测工具

图片

img

  • 首先学习的Basic Tool有如下:uptime、top(htop)、mpstat、isstat、vmstat、free、ping、nicstat、dstat。
  • 高级的命令如下:sar、netstat、pidstat、strace、tcpdump、blktrace、iotop、slabtop、sysctl、/proc。

Linux benchmarking tools | Linux 性能测评工具

图片

img

是一款性能测评工具,对于不同模块的性能测试可以使用相应的工具,想要深入了解,可以参考最下文的附件文档。

Linux tuning tools | Linux 性能调优工具

图片

img

是一款性能调优工具,主要是从linux内核源码层进行的调优,想要深入了解,可以参考下文附件文档。

Linux observability sar | linux性能观测工具

图片

img

sar(System Activity Reporter系统活动情况报告)是目前LINUX上最为全面的系统性能分析工具之一,可以从多方面对系统的活动进行报告,包括:文件的读写情况、系统调用的使用情况、磁盘I/O、CPU效率、内存使用状况、进程活动及IPC 有关的活动等方面。sar 的常规使用方式:

1
sar  [options] [-A] [-o file]  t [n]

其中:t 为采样间隔,n 为采样次数,默认值是1;-o file 表示将命令结果以二进制格式存放在文件中,file 是文件名。options 为命令行选项

Linux命令

文件和目录

cd命令

(它用于切换当前目录,它的参数是要切换到的目录的路径,可以是绝对路径,也可以是相对路径)

  • cd /home 进入 ‘/ home’ 目录
  • cd .. 返回上一级目录
  • cd ../.. 返回上两级目录
  • cd 进入个人的主目录
  • cd ~user1 进入个人的主目录
  • cd - 返回上次所在的目录

pwd命令

pwd 显示工作路径

ls命令

(查看文件与目录的命令,list之意)

  • ls 查看目录中的文件
  • ls -l 显示文件和目录的详细资料
  • ls -a 列出全部文件,包含隐藏文件
  • ls -R 连同子目录的内容一起列出(递归列出),等于该目录下的所有文件都会显示出来
  • ls [0-9] 显示包含数字的文件名和目录名
  • ls -al 长格式显示当前目录下所有文件
  • ls -h 文件大小显示为常见大小单位 B KB MB …
  • ls -d 显示目录本身,而不是里面的子文件

长格式显示项

1
2
-rw-------    1   root    root    1190    08-10 23:37     anaconda-ks.cfg
① ② ③ ④ ⑤ ⑥ ⑦

  • 第①项:权限位
  • 第②项:引用计数
  • 第③项:属主(所有者)
  • 第④项:属组
  • 第⑤项:大小
  • 第⑥项:最后一次修改时间
  • 第⑦项:文件名

cp 命令

(用于复制文件,copy之意,它还可以把多个文件一次性地复制到一个目录下)

  • -a :将文件的特性一起复制
  • -p :连同文件的属性一起复制,而非使用默认方式,与-a相似,常用于备份
  • -i :若目标文件已经存在时,在覆盖时会先询问操作的进行
  • -r :递归持续复制,用于目录的复制行为
  • -u :目标文件与源文件有差异时才会复制

mv命令

(用于移动文件、目录或更名,move之意)

  • -f :force强制的意思,如果目标文件已经存在,不会询问而直接覆盖
  • -i :若目标文件已经存在,就会询问是否覆盖
  • -u :若目标文件已经存在,且比目标文件新,才会更新

rm 命令

(用于删除文件或目录,remove之意)

  • -f :就是force的意思,忽略不存在的文件,不会出现警告消息
  • -i :互动模式,在删除前会询问用户是否操作
  • -r :递归删除,最常用于目录删除,它是一个非常危险的参数

mkdir

  • mkdir test 创建名为test的目录
  • mkdir -p test1/test2/test3 递归创建

rmdir

删除目录 (只能删除空目录)

查看文件内容

touch

  • 命令名称:touch
  • 命令所在路径:/bin/touch
  • 权限:所有用户
  • 能描述:创建空文件 或 修改文件时间

touch test.py 创建空文件,如果文件存在,则修改文件创建时间

more

  • 命令所在路径:/bin/more
  • 执行权限:所有用户
  • 功能描述:分屏显示文件内容

more 文件名 分屏显示文件内容

  • 向上翻页 空格键
  • 向下翻页 b键
  • 退出查看 q键
  • 命令所在路径:/usr/bin/head
  • 执行权限:所有用户
  • 功能描述:显示文件头
1
2
3
4
5
head 文件名           显示文件头几行(默认显示10行)
head -n 20 文件名 显示文件前20行
head -n -20 文件名 显示文件最后20行
ctrl + c 强制终止查看模式
ctrl + l 清屏

ln

  • 命令所在路径:/bin/ln
  • 执行权限:所有用户
  • 功能描述:链接文件

等同于Windows中的快捷方式,新建的链接,占用不同的硬盘位置,修改一个文件,两边都会改变,删除源文件,软连接文件打不开

ln -s 源文件 目标文件 创建链接文件(文件名都必须写绝对路径)

cat命令

(用于查看文本文件的内容,后接要查看的文件名,通常可用管道与more和less一起使用)

  • cat file1 从第一个字节开始正向查看文件的内容
  • tac file1 从最后一行开始反向查看一个文件的内容
  • cat -n file1 标示文件的行数
  • more file1 查看一个长文件的内容
  • head -n 2 file1 查看一个文件的前两行
  • tail -n 2 file1 查看一个文件的最后两行
  • tail -n +1000 file1 从1000行开始显示,显示1000行以后的
  • cat filename | head -n 3000 | tail -n +1000 显示1000行到3000行
  • cat filename | tail -n +3000 | head -n 1000 从第3000行开始,显示1000(即显示3000~3999行)

文件搜索

find命令

  • find / -name file1 从 ‘/‘ 开始进入根文件系统搜索文件和目录
  • find / -user user1 搜索属于用户 ‘user1’ 的文件和目录
  • find /usr/bin -type f -atime +100 搜索在过去100天内未被使用过的执行文件
  • find /usr/bin -type f -mtime -10 搜索在10天内被创建或者修改过的文件
  • whereis halt 显示一个二进制文件、源码或man的位置
  • which halt 显示一个二进制文件或可执行文件的完整路径

删除大于50M的文件:
find /var/mail/ -size +50M -exec rm {} \;

文件的权限

-rw-r—r—. 1 root root 44736 7月 18 00:38 install.log

权限位是十位
第一位:代表文件类型

  • - 普通文件
  • d 目录文件
  • l 链接文件

其他九位:代表各用户的权限(前三位=属主权限u 中间三位=属组权限g 其他人权限o)
-r 读 4
-w 写 2
-x 执行 1

权限对文件的含义:

  • r:读取文件内容 如:cat、more、head、tail
  • w:编辑、新增、修改文件内容 如:vi、echo 但是不包含删除文件
  • x:可执行 /tmp/11/22/abc ————-

权限对目录的含义:

  • r:可以查询目录下文件名 如:ls
  • w:具有修改目录结构的权限 如:touch、rm、mv、cp
  • x:可以进入目录 如:cd

chmod 命令

ls -lh 显示权限

  • chmod ugo+rwx directory1 设置目录的所有人(u)、群组(g)以及其他人(o)以读(r,4 )、写(w,2)和执行(x,1)的权限
  • chmod go-rwx directory1 删除群组(g)与其他人(o)对目录的读写执行权限

chown 命令

(改变文件的所有者)

  • chown user1 file1 改变一个文件的所有人属性
  • chown -R user1 directory1 改变一个目录的所有人属性并同时改变改目录下所有文件的属性
  • chown user1:group1 file1 改变一个文件的所有人和群组属性

chgrp 命令

(改变文件所属用户组)
chgrp group1 file1 改变文件的群组

文本处理

grep

grep是一款强大的文本搜索工具,支持正则表达式。全称( global search regular expression(RE) and print out the line)

语法:grep [option]… PATTERN [FILE]…

常用:

1
2
3
4
usage: grep [-abcDEFGHhIiJLlmnOoqRSsUVvwxZ] [-A num] [-B num] [-C[num]]
[-e pattern] [-f file] [--binary-files=value] [--color=when]
[--context[=num]] [--directories=action] [--label] [--line-buffered]
[--null] [pattern] [file ...]

常用参数:

  • -v 取反
  • -i 忽略大小写
  • -c 符合条件的行数
  • -n 输出的同时打印行号
  • ^开头
  • $ 以结尾
  • ^$ 空行

实际使用

准备好一个小故事txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[root@iz2ze76ybn73dvwmdij06zz ~]# cat monkey
One day,a little monkey is playing by the well.一天,有只小猴子在井边玩儿.
He looks in the well and shouts :它往井里一瞧,高喊道:
“Oh!My god!The moon has fallen into the well!” “噢!我的天!月亮掉到井里头啦!”
An older monkeys runs over,takes a look,and says,一只大猴子跑来一看,说,
“Goodness me!The moon is really in the water!” “糟啦!月亮掉在井里头啦!”
And olderly monkey comes over.老猴子也跑过来.
He is very surprised as well and cries out:他也非常惊奇,喊道:
“The moon is in the well.” “糟了,月亮掉在井里头了!”
A group of monkeys run over to the well .一群猴子跑到井边来,
They look at the moon in the well and shout:他们看到井里的月亮,喊道:
“The moon did fall into the well!Come on!Let’get it out!”
“月亮掉在井里头啦!快来!让我们把它捞起来!”
Then,the oldest monkey hangs on the tree up side down ,with his feet on the branch .
然后,老猴子倒挂在大树上,
And he pulls the next monkey’s feet with his hands.拉住大猴子的脚,
All the other monkeys follow his suit,其他的猴子一个个跟着,
And they join each other one by one down to the moon in the well.
它们一只连着一只直到井里.
Just before they reach the moon,the oldest monkey raises his head and happens to see the moon in the sky,正好他们摸到月亮的时候,老猴子抬头发现月亮挂在天上呢
He yells excitedly “Don’t be so foolish!The moon is still in the sky!”
它兴奋地大叫:“别蠢了!月亮还好好地挂在天上呢!

直接查找符合条件的行

1
2
3
4
5
6
7
8
9
[root@iz2ze76ybn73dvwmdij06zz ~]# grep moon monkey
“Oh!My god!The moon has fallen into the well!” “噢!我的天!月亮掉到井里头啦!”
“Goodness me!The moon is really in the water!” “糟啦!月亮掉在井里头啦!”
“The moon is in the well.” “糟了,月亮掉在井里头了!”
They look at the moon in the well and shout:他们看到井里的月亮,喊道:
“The moon did fall into the well!Come on!Let’get it out!”
And they join each other one by one down to the moon in the well.
Just before they reach the moon,the oldest monkey raises his head and happens to see the moon in the sky,正好他们摸到月亮的时候,老猴子抬头发现月亮挂在天上呢
He yells excitedly “Don’t be so foolish!The moon is still in the sky!”

查找反向符合条件的行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[root@iz2ze76ybn73dvwmdij06zz ~]# grep -v  moon monkey
One day,a little monkey is playing by the well.一天,有只小猴子在井边玩儿.
He looks in the well and shouts :它往井里一瞧,高喊道:
An older monkeys runs over,takes a look,and says,一只大猴子跑来一看,说,
And olderly monkey comes over.老猴子也跑过来.
He is very surprised as well and cries out:他也非常惊奇,喊道:
A group of monkeys run over to the well .一群猴子跑到井边来,
“月亮掉在井里头啦!快来!让我们把它捞起来!”
Then,the oldest monkey hangs on the tree up side down ,with his feet on the branch .
然后,老猴子倒挂在大树上,
And he pulls the next monkey’s feet with his hands.拉住大猴子的脚,
All the other monkeys follow his suit,其他的猴子一个个跟着,
它们一只连着一只直到井里.
它兴奋地大叫:“别蠢了!月亮还好好地挂在天上呢!”

直接查找符合条件的行数

1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# grep -c  moon monkey
8

忽略大小写查找符合条件的行数,先来看一下直接查找的结果

1
[root@iz2ze76ybn73dvwmdij06zz ~]# grep my monkey

忽略大小写查看

1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# grep -i my monkey
“Oh!My god!The moon has fallen into the well!” “噢!我的天!月亮掉到井里头啦!”

查找符合条件的行并输出行号

1
2
3
4
5
6
7
8
9
[root@iz2ze76ybn73dvwmdij06zz ~]# grep -n monkey monkey
1:One day,a little monkey is playing by the well.一天,有只小猴子在井边玩儿.
4:An older monkeys runs over,takes a look,and says,一只大猴子跑来一看,说,
6:And olderly monkey comes over.老猴子也跑过来.
9:A group of monkeys run over to the well .一群猴子跑到井边来,
13:Then,the oldest monkey hangs on the tree up side down ,with his feet on the branch .
15:And he pulls the next monkey’s feet with his hands.拉住大猴子的脚,
16:All the other monkeys follow his suit,其他的猴子一个个跟着,
19:Just before they reach the moon,the oldest monkey raises his head and happens to see the moon in the sky,正好他们摸到月亮的时候,老猴子抬头发现月亮挂在天上呢

查找开头是J的行

1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# grep '^J' monkey
Just before they reach the moon,the oldest monkey raises his head and happens to see the moon in the sky,正好他们摸到月亮的时候,老猴子抬头发现月亮挂在天上呢

查找结尾是呢的行

1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# grep "呢$" monkey
Just before they reach the moon,the oldest monkey raises his head and happens to see the moon in the sky,正好他们摸到月亮的时候,老猴子抬头发现月亮挂在天上呢

sed

sed是一种流编辑器,是一款处理文本比较优秀的工具,可以结合正则表达式一起使用。

sed执行过程:

  • sed命令: sed
  • 语法 : sed [选项]… {命令集} [输入文件]…

常用命令:

  • d 删除选择的行
  • s 查找
  • y 替换
  • i 当前行前面插入一行
  • a 当前行后面插入一行
  • p 打印行
  • q 退出

替换符:

  • 数字 :替换第几处
  • g : 全局替换
  • \1: 子串匹配标记,前面搜索可以用元字符集(..)
  • &: 保留搜索刀的字符用来替换其他字符

查看文件:

1
2
3
4
5
6
➜ cat word
Twinkle, twinkle, little star
How I wonder what you are
Up above the world so high
Like a diamond in the sky
When the blazing sun is gone

替换:
1
2
3
4
5
6
➜ sed 's/little/big/' word
Twinkle, twinkle, big star
How I wonder what you are
Up above the world so high
Like a diamond in the sky
When the blazing sun is gone

查看文本:

1
2
3
4
5
6
7
➜ cat word1
Oh if there's one thing to be taught
it's dreams are made to be caught
and friends can never be bought
Doesn't matter how long it's been
I know you'll always jump in
'Cause we don't know how to quit

全局替换:

1
2
3
4
5
6
7
➜ sed 's/to/can/g' word1
Oh if there's one thing can be taught
it's dreams are made can be caught
and friends can never be bought
Doesn't matter how long it's been
I know you'll always jump in
'Cause we don't know how can quit

按行替换(替换2到最后一行)

1
2
3
4
5
6
7
➜ sed '2,$s/to/can/' word1
Oh if there's one thing to be taught
it's dreams are made can be caught
and friends can never be bought
Doesn't matter how long it's been
I know you'll always jump in
'Cause we don't know how can quit

删除:

1
2
3
4
5
➜ sed '2d' word
Twinkle, twinkle, little star
Up above the world so high
Like a diamond in the sky
When the blazing sun is gone

显示行号:
1
2
3
4
5
6
7
8
9
10
➜ sed '=;2d' word
1
Twinkle, twinkle, little star
2
3
Up above the world so high
4
Like a diamond in the sky
5
When the blazing sun is gone

删除第2行到第四行:

1
2
3
4
5
6
7
8
➜ sed '=;2,4d' word
1
Twinkle, twinkle, little star
2
3
4
5
When the blazing sun is gone

向前插入:

1
2
3
➜ echo "hello" | sed 'i\kitty'
kitty
hello

向后插入:
1
2
3
➜ echo "kitty" | sed 'i\hello'
hello
kitty

替换第二行为hello kitty

1
2
3
4
5
6
➜ sed '2c\hello kitty' word
Twinkle, twinkle, little star
hello kitty
Up above the world so high
Like a diamond in the sky
When the blazing sun is gone

替换第二行到最后一行为hello kitty
1
2
3
➜ sed '2,$c\hello kitty' word
Twinkle, twinkle, little star
hello kitty

写入行,把带star的行写入c文件中,c提前创建
1
2
3
➜ sed -n '/star/w c' word
➜ cat c
Twinkle, twinkle, little star

退出:打印3行后,退出sed
1
2
3
4
➜ sed '3q' word
Twinkle, twinkle, little star
How I wonder what you are
Up above the world so high

awk

比起sed和grep,awk不仅仅是一个小工具,也可以算得上一种小型的编程语言了,支持if判断分支和while循环语句还有它的内置函数等,是一个要比grep和sed更强大的文本处理工具,但也就意味着要学习的东西更多了。

下面来说一下awk的一些基础概念以及实际操作。

语法:

  • Usage: awk [POSIX or GNU style options] -f progfile [--] file ...
  • Usage: awk [POSIX or GNU style options] [--] 'program' file ...

域:类似数据库列的概念,但它是按照序号来指定的,比如我要第一个列就是1,第二列就是2,依此类推。$0就是输出整个文本的内容。默认用空格作为分隔符,当然你可以自己通过-F设置适合自己情况的分隔符。

提前自己编了一段数据,学生以及学生成绩数据表。

列数 名称 描述
1 Name 姓名
2 Math 数学
3 Chinese 语文
4 English 英语
5 History 历史
6 Sport 体育
8 Grade 班级

“Name Math Chinese English History Sport grade 输出整个文本

1
2
3
4
5
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '{print $0}' students_store
Xiaoka 60 80 40 90 77 class-1
Yizhihua 70 66 50 80 90 class-1
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2

输出第一列(姓名列)
1
2
3
4
5
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '{print $1}' students_store
Xiaoka
Yizhihua
kerwin
Fengzheng

模式&动作
1
awk '{[pattern] action}' {filenames}  

模式pattern 可以是

  • 条件语句
  • 正则

模式的两个特殊字段 BEGIN 和 END (不指定时匹配或打印行数)

  • BEGIN :一般用来打印列名称。
  • END : 一般用来打印总结性质的字符。

动作:action 在{}内指定,一般用来打印,也可以是一个代码段。

示例

给上面的文本加入标题头:

1
2
3
4
5
6
7
8
[root@iz2ze76ybn73dvwmdij06zz ~]##  awk 'BEGIN {print "Name     Math  Chinese  English History  Sport grade\n----------------------------------------------"} {print $0}' students_store

Name Math Chinese English History Sport grade
----------------------------------------------------------
Xiaoka 60 80 40 90 77 class-1
Yizhihua 70 66 50 80 90 class-1
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2

仅打印姓名、数学成绩、班级信息,再加一个文尾(再接再厉):
1
2
3
4
5
6
7
8
9
[root@iz2ze76ybn73dvwmdij06zz ~]## awk 'BEGIN {print "Name   Math  grade\n---------------------"} {print $1 2 "\t" $7} END {print "continue to exert oneself"}' students_store

Name Math grade
---------------------
Xiaoka 60 class-1
Yizhihua 70 class-1
kerwin 80 class-2
Fengzheng 90 class-2
continue to exert oneself

结合正则

使用方法:

1
符号 ~  后接正则表达式

此时我们再加入一条后来的新同学,并且没有分班。先来看下现在的数据
1
2
3
4
5
6
[root@iz2ze76ybn73dvwmdij06zz ~]## cat students_store
Xiaoka 60 80 40 90 77 class-1
Yizhihua 70 66 50 80 90 class-1
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2
xman - - - - - -

模糊匹配|查询已经分班的学生
1
2
3
4
5
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '$0 ~/class/' students_store
Xiaoka 60 80 40 90 77 class-1
Yizhihua 70 66 50 80 90 class-1
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2

精准匹配|查询1班的学生
1
2
3
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '$7=="class-1" {print $0}'  students_store
Xiaoka 60 80 40 90 77 class-1
Yizhihua 70 66 50 80 90 class-1

反向匹配|查询不是1班的学生
1
2
3
4
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '$7!="class-1" {print $0}'  students_store
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2
xman - - - - - -

比较操作|查询数学大于80的
1
2
3
4
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '$2>60 {print $0}'  students_store
Yizhihua 70 66 50 80 90 class-1
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2

查询数学大于英语成绩的
1
2
3
4
5
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '$2 > $4  {print $0}'  students_store
Xiaoka 60 80 40 90 77 class-1
Yizhihua 70 66 50 80 90 class-1
kerwin 80 90 60 70 60 class-2
Fengzheng 90 78 62 40 62 class-2

匹配指定字符中的任意字符,在加一列专业,让我们来看看憨憨们的专业,顺便给最后一个新来的同学分个班吧。然后再来看下此时的数据。
1
2
3
4
5
6
[root@iz2ze76ybn73dvwmdij06zz ~]## cat students_store
Xiaoka 60 80 40 90 77 class-1 Java
Yizhihua 70 66 50 80 90 class-1 java
kerwin 80 90 60 70 60 class-2 Java
Fengzheng 90 78 62 40 62 class-2 java
xman - - - - - class-3 php

或关系匹配|查询1班和3班的学生
1
2
3
4
root@iz2ze76ybn73dvwmdij06zz ~]## awk '$0 ~/(class-1|class-3)/' students_store
Xiaoka 60 80 40 90 77 class-1 Java
Yizhihua 70 66 50 80 90 class-1 java
xman - - - - - class-3 php

任意字符匹配|名字第二个字母是

字符解释:

  • ^ : 字段或记录的开头。
  • . : 任意字符。
1
2
3
4
root@iz2ze76ybn73dvwmdij06zz ~]## awk '$0 ~/(class-1|class-3)/' students_store
Xiaoka 60 80 40 90 77 class-1 Java
Yizhihua 70 66 50 80 90 class-1 java
xman - - - - - class-3 php

复合表达式:&& AND的关系,必同时满足才行哦~

查询数学成绩大于60并且语文成绩也大于60的童鞋。

1
2
3
4
[root@iz2ze76ybn73dvwmdij06zz ~]## awk '{ if ($2 > 60 && $3 > 60) print $0}' students_store
Yizhihua 70 66 50 80 90 class-1 java
kerwin 80 90 60 70 60 class-2 Java
Fengzheng 90 78 62 40 62 class-2 java

|| OR:查询数学大于80或者语文大于80的童鞋。
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]##  awk '{ if ($2 > 80 || $4 > 80) print $0}' students_store
Fengzheng 90 78 62 40 62 class-2 java

printf 格式化输出

除了能达到功能以外,一个好看的格式也是必不可少的,因此格式化的输出看起来会更舒服哦~

语法:

  • printf ([格式],参数)
  • printf %x(格式) 具体参数 x代表具体格式
符号 说明
- 左对齐
Width 域的步长
.prec 最大字符串长度或小数点右边位数

格式转化符常用格式

符号 描述
%c ASCII
%d 整数
%o 八进制
%x 十六进制数
%f 浮点数
%e 浮点数(科学记数法)
%s 字符串
%g 决定使用浮点转化e/f

具体操作示例

ASCII码🐎

1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# echo "66" | awk '{printf "%c\n",$0}'
B

浮点数
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {printf "%f\n",100}'
100.000000

16进制数
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {printf "%x",996}'
3e4

内置变量

频率较高常用内置变量

  • NF :记录浏览域的个数,在记录被读后设置。
  • NR :已读的记录数。
  • FS : 设置输入域分隔符
  • ARGC :命令行参数个数,支持命令行传入。
  • RS : 控制记录分隔符
  • FIlENAME : awk当前读文件的名称

操作

输出学生成绩表和域个数以及已读记录数。

1
2
3
4
5
6
[root@iz2ze76ybn73dvwmdij06zz ~]# awk '{print $0, NF , NR}' students_store
Xiaoka 60 80 40 90 77 class-1 Java 8 1
Yizhihua 70 66 50 80 90 class-1 java 8 2
kerwin 80 90 60 70 60 class-2 Java 8 3
Fengzheng 90 78 62 40 62 class-2 java 8 4
xman - - - - - class-3 php 8 5

内置函数

常用函数:

  • length(s) 返回s长度
  • index(s,t) 返回s中字符串t第一次出现的位置
  • match (s,r) s中是否包含r字符串
  • split(s,a,fs) 在fs上将s分成序列a
  • gsub(r,s) 用s代替r,范围全文本
  • gsub(r,s,t) 范围t中,s代替r
  • substr(s,p) 返回字符串s从第p个位置开始后面的部分(下标是从1 开始算的,大家可以自己试试)
  • substr(s,p,n) 返回字符串s从第p个位置开始后面n个字符串的部分

操作

length

1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {print length(" hello,im xiaoka")}'
16

index
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {print index("xiaoka","ok")}'
4

match
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {print match("Java小咖秀","va小")}'
3

gsub
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'gsub("Xiaoka","xk") {print $0}' students_store
xk 60 80 40 90 77 class-1 Java

substr(s,p)
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {print substr("xiaoka",3)}'
aoka

substr(s,p,n)
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN {print substr("xiaoka",3,2)}'
ao

split
1
2
3
4
5
6
[root@iz2ze76ybn73dvwmdij06zz ~]# str="java,xiao,ka,xiu"
[root@iz2ze76ybn73dvwmdij06zz ~]# awk 'BEGIN{split('"\"$str\""',ary,","); for(i in ary) {if(ary[i]>1) print ary[i]}}'
xiu
java
xiao
ka

awk脚本

前面说过awk是可以说是一个小型编程语言。如果命令比较短我们可以直接在命令行执行,当命令行比较长的时候,可以使用脚本来处理,比命令行的可读性更高,还可以加上注释。

写一个完整的awk脚本并执行步骤

1.先创建一个awk文件

1
[root@iz2ze76ybn73dvwmdij06zz ~]# vim printname.awk

2.脚本第一行要指定解释器
1
#!/usr/bin/awk -f

3.编写脚本内容,打印一下名称
1
2
3
4
[root@iz2ze76ybn73dvwmdij06zz ~]# cat printname.awk
#!/usr/bin/awk -f
#可以加注释了,哈哈
BEGIN { print "my name is Java小咖秀"}

4.既然是脚本,必不可少的可执行权限安排上~
1
2
3
[root@iz2ze76ybn73dvwmdij06zz ~]# chmod +x printname.awk
[root@iz2ze76ybn73dvwmdij06zz ~]# ll printname.awk
-rwxr-xr-x 1 root root 60 7月 1 15:23 printname.awk

5.有了可执行权限,我们来执行下看结果
1
2
[root@iz2ze76ybn73dvwmdij06zz ~]# ./printname.awk
my name is Java小咖秀

paste 命令

  • paste file1 file2 合并两个文件或两栏的内容
  • paste -d ‘+’ file1 file2 合并两个文件或两栏的内容,中间用”+”区分

sort 命令

  • sort file1 file2 排序两个文件的内容
  • sort file1 file2 | uniq 取出两个文件的并集(重复的行只保留一份)
  • sort file1 file2 | uniq -u 删除交集,留下其他的行
  • sort file1 file2 | uniq -d 取出两个文件的交集(只留下同时存在于两个文件中的文件)

comm 命令

  • comm -1 file1 file2 比较两个文件的内容只删除 ‘file1’ 所包含的内容
  • comm -2 file1 file2 比较两个文件的内容只删除 ‘file2’ 所包含的内容
  • comm -3 file1 file2 比较两个文件的内容只删除两个文件共有的部分

打包和压缩文件

tar 命令

(对文件进行打包,默认情况并不会压缩,如果指定了相应的参数,它还会调用相应的压缩程序(如gzip和bzip等)进行压缩和解压)

  • -c :新建打包文件
  • -t :查看打包文件的内容含有哪些文件名
  • -x :解打包或解压缩的功能,可以搭配-C(大写)指定解压的目录,注意-c,-t,-x不能同时出现在同一条命令中
  • -j :通过bzip2的支持进行压缩/解压缩
  • -z :通过gzip的支持进行压缩/解压缩
  • -v :在压缩/解压缩过程中,将正在处理的文件名显示出来
  • -f filename :filename为要处理的文件
  • -C dir :指定压缩/解压缩的目录dir

  • 压缩:tar -jcv -f filename.tar.bz2 要被处理的文件或目录名称

  • 查询:tar -jtv -f filename.tar.bz2
  • 解压:tar -jxv -f filename.tar.bz2 -C 欲解压缩的目录

  • bunzip2 file1.bz2 解压一个叫做 ‘file1.bz2’的文件

  • bzip2 file1 压缩一个叫做 ‘file1’ 的文件
  • gunzip file1.gz 解压一个叫做 ‘file1.gz’的文件
  • gzip file1 压缩一个叫做 ‘file1’的文件
  • gzip -9 file1 最大程度压缩
  • rar a file1.rar test_file 创建一个叫做 ‘file1.rar’ 的包
  • rar a file1.rar file1 file2 dir1 同时压缩 ‘file1’, ‘file2’ 以及目录 ‘dir1’
  • rar x file1.rar 解压rar包

  • zip file1.zip file1 创建一个zip格式的压缩包

  • unzip file1.zip 解压一个zip格式压缩包
  • zip -r file1.zip file1 file2 dir1 将几个文件和目录同时压缩成一个zip格式的压缩包

系统和关机

  • shutdown -h now 关闭系统(1)
  • init 0 关闭系统(2)
  • telinit 0 关闭系统(3)
  • shutdown -h hours:minutes & 按预定时间关闭系统
  • shutdown -c 取消按预定时间关闭系统
  • shutdown -r now 重启(1)
  • reboot 重启(2)
  • logout 注销
  • time 测算一个命令(即程序)的执行时间

进程相关的命令

jps命令

(显示当前系统的java进程情况,及其id号)

  • jps(Java Virtual Machine Process Status Tool)是JDK 1.5提供的一个显示当前所有java进程pid的命令,简单实用,非常适合在linux/unix平台上简单察看当前java进程的一些简单情况。

ps命令

(用于将某个时间点的进程运行情况选取下来并输出,process之意)

  • -A :所有的进程均显示出来
  • -a :不与terminal有关的所有进程
  • -u :有效用户的相关进程
  • -x :一般与a参数一起使用,可列出较完整的信息
  • -l :较长,较详细地将PID的信息列出

  • ps aux # 查看系统所有的进程数据

  • ps ax # 查看不与terminal有关的所有进程
  • ps -lA # 查看系统所有的进程数据
  • ps axjf # 查看连同一部分进程树状态

kill命令

(用于向某个工作(%jobnumber)或者是某个PID(数字)传送一个信号,它通常与ps和jobs命令一起使用)

killall命令

(向一个命令启动的进程发送一个信号)

top命令

是Linux下常用的性能分析工具,能够实时显示系统中各个进程的资源占用状况,类似于Windows的任务管理器。

如何杀死进程:

  1. 图形化界面的方式
  2. kill -9 pid (-9表示强制关闭)
  3. killall -9 程序的名字
  4. pkill 程序的名字

查看进程端口号:
netstat -tunlp|grep 端口号

普通文件和目录文件的区别

普通文件和目录文件

  • 普通文件:存储普通数据,一般就是字符串。
  • 目录文件:存储了一张表,该表就是该目录文件下,所有文件名和inode的映射关系。

权限的区别

对于普通文件来说,rwx的意义是:

  • r:可以获得这个普通文件的名字和内容。
  • w:可以修改这个文件的内容和文件名。可以删除该文件。
  • x:该文件是否具有被执行的权限。

对于目录文件来说,rwx的意义是:

  • r:表示具有读取目录结构列表的权限,所以当你具有读取(r)一个目录的权限时,表示你可以查询该目录下的文件名。 就可以利用 ls 这个命令将该目录的内容列表显示出来, 必须这个目录有x的权限,才可以进入这个目录。
  • w:移动该目录结构列表的权限(建立新的文件与目录、删除已经存在的文件与目录、更名、移动位置)。
  • x:目录不可以被执行,目录的x代表的是用户能否进入该目录成为工作目录。

Linux 常见目录

  • / 根目录
  • /bin 命令保存目录(普通用户就可以读取的命令)
  • /boot 启动目录,启动相关文件
  • /dev 设备文件保存目录
  • /etc 配置文件保存目录
  • /home 普通用户的家目录
  • /lib 系统库保存目录
  • /mnt 系统挂载目录
  • /media 挂载目录
  • /root 超级用户的家目录
  • /tmp 临时目录
  • /sbin 命令保存目录(超级用户才能使用的目录)
  • /proc 直接写入内存的
  • /sys 将内核的一些信息映射,可供应用程序所用
  • /usr 系统软件资源目录
  • /usr/bin/ 系统命令(普通用户)
  • /usr/sbin/ 系统命令(超级用户)
  • /var 系统相关文档内容
  • /var/log/ 系统日志位置
  • /var/spool/mail/ 系统默认邮箱位置
  • /var/lib/ 默认安装的库文件目录

nohup

在应用Unix/Linux时,我们一般想让某个程序在后台运行,于是我们将常会用 & 在程序结尾来让程序自动运行。比如我们要运行mysql在后台: /usr/local/mysql/bin/mysqld_safe –user=mysql &。可是有很多程序并不想mysqld一样,这样我们就需要nohup命令,怎样使用nohup命令呢?这里讲解nohup命令的一些用法。

1
nohup /root/start.sh &

在shell中回车后提示:

1
[~]$ appending output to nohup.out

原程序的的标准输出被自动改向到当前目录下的nohup.out文件,起到了log的作用。

但是有时候在这一步会有问题,当把终端关闭后,进程会自动被关闭,察看nohup.out可以看到在关闭终端瞬间服务自动关闭。

咨询红旗Linux工程师后,他也不得其解,在我的终端上执行后,他启动的进程竟然在关闭终端后依然运行。

在第二遍给我演示时,我才发现我和他操作终端时的一个细节不同:他是在当shell中提示了nohup成功后还需要按终端上键盘任意键退回到shell输入命令窗口,然后通过在shell中输入exit来退出终端;而我是每次在nohup执行成功后直接点关闭程序按钮关闭终端.。所以这时候会断掉该命令所对应的session,导致nohup对应的进程被通知需要一起shutdown。

这个细节有人和我一样没注意到,所以在这儿记录一下了。

nohup 命令

用途:不挂断地运行命令。

语法:nohup Command [ Arg … ] [ & ]

描述:nohup 命令运行由 Command 参数和任何相关的 Arg 参数指定的命令,忽略所有挂断(SIGHUP)信号。在注销后使用 nohup 命令运行后台中的程序。要运行后台中的 nohup 命令,添加 & ( 表示”and”的符号)到命令的尾部。

无论是否将 nohup 命令的输出重定向到终端,输出都将附加到当前目录的 nohup.out 文件中。如果当前目录的 nohup.out 文件不可写,输出重定向到 $HOME/nohup.out 文件中。如果没有文件能创建或打开以用于追加,那么 Command 参数指定的命令不可调用。如果标准错误是一个终端,那么把指定的命令写给标准错误的所有输出作为标准输出重定向到相同的文件描述符。

退出状态:该命令返回下列出口值:

  • 126 可以查找但不能调用 Command 参数指定的命令。
  • 127 nohup 命令发生错误或不能查找由 Command 参数指定的命令。

否则,nohup 命令的退出状态是 Command 参数指定命令的退出状态。

nohup命令及其输出文件

nohup命令:如果你正在运行一个进程,而且你觉得在退出帐户时该进程还不会结束,那么可以使用nohup命令。该命令可以在你退出帐户/关闭终端之后继续运行相应的进程。nohup就是不挂起的意思( n ohang up)。

该命令的一般形式为:nohup command &

使用nohup命令提交作业

如果使用nohup命令提交作业,那么在缺省情况下该作业的所有输出都被重定向到一个名为nohup.out的文件中,除非另外指定了输出文件:

nohup command > myout.file 2>&1 &

在上面的例子中,输出被重定向到myout.file文件中。

使用 jobs 查看任务。

使用 fg %n 关闭。

环境变量设置

简介

环境变量是在操作系统中一个具有特定名字的对象,它包含了一个或多个应用程序将使用到的信息。Linux是一个多用户的操作系统,每个用户登录系统时都会有一个专用的运行环境,通常情况下每个用户的默认的环境都是相同的。这个默认环境就是一组环境变量的定义。每个用户都可以通过修改环境变量的方式对自己的运行环境进行配置。

分类

根据环境变量的生命周期我们可以将其分为永久性变量和临时性变量,根据用户等级的不同又可以将其分为系统级变量和用户级变量。怎么分都无所谓,主要是对它的理解。

对所有用户生效的永久性变量(系统级)

这类变量对系统内的所有用户都生效,所有用户都可以使用这类变量。作用范围是整个系统。
设置方式: 用vim在/etc/profile文件中添加我们想要的环境变量。
当然,这个文件只有在root(超级用户)下才能修改。我们可以在etc目录下使用ls -l查看这个文件的用户及权限。

利用vim打开/etc/ profile文件,用export指令添加环境变量。

【注意】:添加完成后新的环境变量不会立即生效,除非你调用source /etc/profile 该文件才会生效。否则只能在下次重进此用户时才能生效。

对单一用户生效的永久性变量(用户级)

该类环境变量只对当前的用户永久生效。也就是说假如用户A设置了此类环境变量,这个环境变量只有A可以使用。而对于其他的B,C,D,E….用户等等,这个变量是不存在的。

设置方法:在用户主目录”~”下的隐藏文件 “.bash_profile”中添加自己想要的环境变量。
查看隐藏文件: ls -a或ls -al

利用vim打开文件,利用export添加环境变量。与上相同。同样注意,添加完成后新的环境变量不会立即生效,除非你调用source ./.bash_profile 该文件才会生效。否则只能在下次重进此用户时才能生效。

可以看到我在上图中用红框框住了两个文件,.bashrc和.bash_profile。原则上来说设置此类环境变量时在这两个文件任意一个里面添加都是可以的。

~/.bash_profile是交互式login方式进入bash shell运行。
~/ .bashrc是交互式non-login方式进入bash shell运行。

二者设置大致相同。通俗点说,就是.bash_profile文件只会在用户登录的时候读取一次,而.bashrc在每次打开终端进行一次新的会话时都会读取。

临时有效的环境变量(只对当前shell有效)

此类环境变量只对当前的shell有效。当我们退出登录或者关闭终端再重新打开时,这个环境变量就会消失。是临时的。

设置方法:直接使用export指令添加。

设置环境变量常用的几个指令

echo

查看显示环境变量,使用时要加上符号“”例:echo”例:echoPATH

export

设置新的环境变量
export 新环境变量名=内容
例:export MYNAME=”LLZZ”

修改环境变量

修改环境变量没有指令,可以直接使用环境变量名进行修改。
例:MYNAME=”ZZLL”

env

查看所有环境变量

set

查看本地定义的所有shell变量

unset

删除一个环境变量
例 unset MYNAME

readonly

设置只读环境变量。
例:readonly MYNAME

常用的几个环境变量

PATH

指定命令的搜索路径。通过设置环境变量PATH可以让我们运行程序或指令更加方便。
echo $PATH 查看环境变量PATH。

每一个冒号都是一个路径,这些搜索路径都是一些可以找到可执行程序的目录列表。当我们输入一个指令时,shell会先检查命令是否是内部命令,不是的话会再检查这个命令是否是一个应用程序。然后shell会试着从这些搜索路径,即PATH(上图中路径)中寻找这些应用程序。如果shell在这些路径目录里没有找到可执行文件。则会报错。若找到,shell内部命令或应用程序将被分解为系统调用并传给Linux内核。

举个例子:
现在有一个c程序test.c通过gcc编译生成的可执行文件a.out(功能:输出helloworld)。我们平常执行这个a.out的时候是使用
①相对路径调用方式: ./a.out (”.”代表当前目录,”/”分隔符)。
②还可以使用绝对路径调用方式:将其全部路径写出:/home/lzk/test/a.out(此路径是我的工作目录路径,只是个例子,仅供参考)

③通过设置PATH环境变量,直接用文件名调用:
在没设置PATH前,我们直接使用a.out调用程序会报错,因为shell并没有从PATH已拥有的搜索路径目录中找到a.out这个可执行程序。

使用export指令,将a.out的路径添加到搜索路径当中,export PATH=$PATH:路径
我们就可以使用a.out直接执行程序。

HOME

指定用户的主工作目录,即为用户登录到Linux系统中时的默认目录,即“~”。

LOGNAME

指当前用户的登录名

HOSTNAME

指主机的名称。

SHELL

指当前用户用的是哪种shell

LANG/LANGUGE

和语言相关的环境变量,使用多种语言的用户可以修改此环境变量。

MAIL

指当前用户的邮件存放目录

PS1

命令提示符,root用户是#,普通用户是$

PS2

附属提示符,默认是“>”

SECONDS

从当前shell开始运行所流逝的秒数

总结

环境变量是和shell紧密相关的,用户登录系统后就启动了一个shell,对于Linux来说一般是bash(Bourne Again shell,Bourne shell(sh)的扩展),也可以切换到其他版本的shell。bash有两个基本的系统级配置文件:/etc/bashrc和/etc/profile。这些配置文件包含了两组不同的变量:shell变量和环境变量。shell变量是局部的,而环境变量是全局的。环境变量是通过shell命令来设置。设置好的环境变量又可以被所以当前用户的程序使用。

Linux检测工具

  • 静态代码检测工具:cppcheck、Clang-Tidy、PC-lint、SonarQube+sonar-cxx、Facebook的infer、Clang Static Analyzer。
  • 内存泄漏检测工具:valgrind、ASan、mtrace、ccmalloc、debug_new。
  • profiling工具:gperftools、perf、intel VTune、AMD CodeAnalyst、gnu prof、Quantify

https://www.brendangregg.com/linuxperf.html









Linux shell脚本

入参和默认变量

对于shell脚本而言,有些内容是专门用于处理参数的,它们都有特定的含义,例如:

  • /home/shouwang/test.sh para1 para2 para3
  • $0 $1 $2 $3
  • 脚本名 第一个参数 第三个参数

除此之外,还有一些其他的默认变量,例如:

  • $# 代表脚本后面跟的参数个数,前面的例子中有3个参数
  • $@ 代表了所有参数,并且可以被遍历
  • $* 代表了所有参数,且作为整体,和$@很像,但是有区别
  • $$$$ 代表了当前脚本的进程ID
  • $? 代表了上一条命令的退出状态

变量

给变量赋值,使用等号即可,但是等号两边千万不要有空格,等号右边有空格的字符串也必须用引号引起来:para1="hello world"表示字符串直接赋给变量para1

unset用于取消变量。例如:
unset para1

如何使用变量呢?使用变量时,需要在变量前加$.

例如要打印前面para1的内容:echo "para1 is $para1",将会输出para1 is hello world。或者变量名两边添加大括号:echo "para1 is ${para1}!",将会输出para1 is hello world!

命令执行在shell中执行命令通常只需要像在终端一样执行命令即可,不过,如果想要命令结果打印出来的时候,这样的方式就行不通了。因此,shell的命令方式常有:

1
a=`ls` 

“`“是左上角~键,不是单引号。

或者使用$,后面括号内是执行的命令:echo "current path is $(pwd)"

另外,前面两种方式对于计算表达式也是行不通的,而要采取下面的方式:echo "1+1=$((1+1))",打印:1+1=2,即$后面用两重括号将要计算的表达式包裹起来。那如果要执行的命令存储在变量中呢?前面的方法都不可行了,当然括号内的内容被当成命令执行还是成立的。要使用下面的方式,例如:

  • a="ls"
  • echo "$($a)"

但是如果字符串时多条命令的时候,上面的方式又不可行了,而要采用下面的方式:

  • a="ls;pwd"
  • echo "$(eval $a)"

这是使用了eval,将a的内容都作为命令来执行。

条件分支

一般说明,如果命令执行成功,则其返回值为0,否则为非0,因此,可以通过下面的方式判断上条命令的执行结果:

1
2
3
4
5
6
if [ $? -eq 0 ]then
echo "success"
elif [ $? -eq 1 ]then
echo "failed,code is 1"
else
echo "other code"fi

case语句使用方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name="aa"
case $name in
"aa")
echo "name is $name"
;;
"")
echo "name is empty"
;;
"bb")
echo "name is $name"
;;
*)
echo "other name"
;;
esac

初学者特别需要注意以下几点:

  • []前面要有空格,它里面是逻辑表达式
  • if elif后面要跟then,然后才是要执行的语句
  • 如果想打印上一条命令的执行结果,最好的做法是将$?赋给一个变量,因为一旦执行了一条命令$?的值就可能会变。
  • case每个分支最后以两个分号结尾,最后是case反过来写,即esac。

多个条件如何使用呢,两种方式,方式一:

1
2
3
if [ 10 -gt 5 -o 10 -gt 4 ];then
echo "10>5 or 10 >4"
fi

方式二:
1
2
3
if [ 10 -gt 5 ] || [ 10 -gt 4 ];then
echo "10>5 or 10 >4"
fi

其中-o或者||表示或。这里也有一些常见的条件判定。总结如下:

  • -o or或者,同||
  • -a and 与,同&&
  • !

整数判断:

  • -eq 两数是否相等
  • -ne 两数是否不等
  • -gt 前者是否大于后者(greater then)
  • -lt 前面是否小于后者(less than)
  • -ge 前者是否大于等于后者(greater then or equal)
  • -le 前者是否小于等于后者(less than or equal)

字符串判断str1 exp str2:

  • -z "$str1" str1是否为空字符串
  • -n "$str1" str1是否不是空字符串
  • "$str1" == "$str2" str1是否与str2相等
  • "$str1" != "$str2" str1是否与str2不等
  • "$str1" =~ "str2" str1是否包含str2

特别注意,字符串变量最好用引号引起来,因为一旦字符串中有空格,这个表达式就错了,有兴趣的可以尝试当str1=”hello world”,而str2=”hello”的时候进行比较。

文件目录判断:filename

  • -f $filename 是否为文件
  • -e $filename 是否存在
  • -d $filename 是否为目录
  • -s $filename 文件存在且不为空
  • ! -s $filename 文件是否为空

循环

循环形式一,和Python的for in很像:

1
2
3
4
5
#遍历输出脚本的参数
for i in $@;
do
echo $i
done

循环形式二,和C语言风格很像:
1
2
3
4
5
for ((i = 0 ; i < 10 ; i++)); 
do
echo $i
done
#循环打印0到9。

循环形式三:
1
2
3
4
5
for i in {1..5}; 
do
echo "Welcome $i"
done
#循环打印1到5。

循环方式四:
1
2
3
4
while [ "$ans" != "yes" ]
do
read -p "please input yes to exit loop:" ans
done

只有当输入yes时,循环才会退出。即条件满足时,就进行循环。

循环方式五:

1
2
3
4
5
ans=yes
until [ $ans != "yes" ]
do
read -p "please input yes to exit loop:" ans
done

这里表示,只有当ans不是yes时,循环就终止。

循环方式六:

1
2
3
4
for i in {5..15..3}; 
do
echo "number is $i"
done

每隔5打印一次,即打印5,8,11,14。

函数

定义函数方式如下:

1
2
3
4
myfunc() 
{
echo "hello world $1"
}

或者:
1
2
3
4
function myfunc() 
{
echo "hello world $1"
}

函数调用:
1
2
para1="shouwang"
myfunc $para1

返回值

通常函数的return返回值只支持0-255,因此想要获得返回值,可以通过下面的方式。

1
2
3
4
5
6
7
function myfunc() 
{
local myresult='some value'
echo $myresult
}
val=$(myfunc)
#val的值为some value

通过return的方式适用于判断函数的执行是否成功:
1
2
3
4
5
6
7
8
9
10
11
function myfunc() 
{
#do something
return 0
}

if myfunc;then
echo "success"
else
echo "failed"
fi

日志保存

脚本执行后免不了要记录日志,最常用的方法就是重定向。以下面的脚本为例:

1
2
3
4
#!/bin/bash
#test.sh
lll #这个命令是没有的,因此会报错
date

方式一,将标准输出保存到文件中,打印标准错误:
1
./test.sh > log.dat

这种情况下,如果命令执行出错,错误将会打印到控制台。所以如果你在程序中调用,这样将不会讲错误信息保存在日志中。

方式二,标准输出和标准错误都保存到日志文件中:

1
./test.sh > log.dat 2>&1

2>&1的含义可以参考《如何理解linuxshell中的2>&1》

方式三,保存日志文件的同时,也输出到控制台:

1
./test.sh |tee log.dat

脚本执行

最常见的执行方式前面已经看到了:

1
./test.sh

其它执行方式:

  • sh test.sh,在子进程中执行
  • sh -x test.sh,会在终端打印执行到命令,适合调试
  • source test.sh,test.sh在父进程中执行
  • . test.sh,不需要赋予执行权限,临时执行脚本

退出码

很多时候我们需要获取脚本的执行结果,即退出状态,通常0表示执行成功,而非0表示失败。为了获得退出码,我们需要使用exit。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
function myfun()
{
if [ $# -lt 2 ]then
echo "para num error"
exit 1
fi
echo "ok"
exit 2
}
if [ $# -lt 1 ]then
echo "para num error"
exit 1
fi
returnVal=`myfun aa`
echo "end shell"
exit 0

这里需要特别注意的一点是,使用
1
returnVal=`myfun aa`

这样的句子执行函数,即便函数里面有exit,它也不会退出脚本执行,而只是会退出该函数,这是因为exit是退出当前进程,而这种方式执行函数,相当于fork了一个子进程,因此不会退出当前脚本。最终结果就会看到,无论你的函数参数是什么最后end shell都会打印。
1
./test.sh;echo $?

1
0

这里的0就是脚本的执行结果。

bash命令逻辑

  • cmd1&&cmd2:如果cmd1成功则执行cmd2
  • cmd1||cmd2:如果cmd1不成功则执行cmd2
  • cmd1|cmd2 :这个是管道,把cmd1的输出作为cmd2的输入
  • cmd1;cmd2 :连续执行两条命令,先cmd1,然后cmd2

逻辑运算符

-f 常用!侦测『档案』是否存在 eg: if [ -f filename ]

-d 常用!侦测『目录』是否存在

-b 侦测是否为一个『 block 档案』

-c 侦测是否为一个『 character 档案』

-S 侦测是否为一个『 socket 标签档案』

-L 侦测是否为一个『 symbolic link 的档案』

-e 侦测『某个东西』是否存在!

关于程序的逻辑卷标

-G 侦测是否由 GID 所执行的程序所拥有

-O 侦测是否由 UID 所执行的程序所拥有

-p 侦测是否为程序间传送信息的 name pipe 或是 FIFO (老实说,这个不太懂!)

关于档案的属性侦测

-r 侦测是否为可读的属性

-w 侦测是否为可以写入的属性

-x 侦测是否为可执行的属性

-s 侦测是否为『非空白档案』

-u 侦测是否具有『 SUID 』的属性

-g 侦测是否具有『 SGID 』的属性

-k 侦测是否具有『 sticky bit 』的属性

两个档案之间的判断与比较 ;例如[ test file1 -nt file2 ]

-nt 第一个档案比第二个档案新

-ot 第一个档案比第二个档案旧

-ef 第一个档案与第二个档案为同一个档案( link 之类的档案)

逻辑的『和(and)』『或(or)』

&& 逻辑的 AND 的意思

|| 逻辑的 OR 的意思

运算符号 代表意义

= 等于 应用于:整型或字符串比较 如果在[] 中,只能是字符串

!=不等于 应用于:整型或字符串比较 如果在[] 中,只能是字符串

< 小于 应用于:整型比较 在[] 中,不能使用 表示字符串

> 大于 应用于:整型比较 在[] 中,不能使用 表示字符串

-eq 等于 应用于:整型比较

-ne 不等于 应用于:整型比较

-lt 小于 应用于:整型比较

-gt 大于 应用于:整型比较

-le 小于或等于 应用于:整型比较

-ge 大于或等于 应用于:整型比较

-a 双方都成立(and) 逻辑表达式 –a 逻辑表达式

-o 单方成立(or) 逻辑表达式 –o 逻辑表达式

-z 空字符串

-n 非空字符串

逻辑表达式

test 命令

使用方法:test EXPRESSION

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[root@localhost ~]# test 1 = 1 && echo 'ok'

ok

[root@localhost ~]# test -d /etc/ && echo 'ok'

ok

[root@localhost ~]# test 1 -eq 1 && echo 'ok'

ok

[root@localhost ~]# if test 1 = 1 ; then echo 'ok'; fi

ok

注意:所有字符 与逻辑运算符直接用“空格”分开,不能连到一起。

精简表达式

[] 表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[root@localhost ~]# [ 1 -eq 1 ] && echo 'ok'

ok

[root@localhost ~]# [ 2 < 1 ] && echo 'ok'

-bash: 2: No such file or directory

[root@localhost ~]# [ 2 < 1 ] && echo 'ok'

[root@localhost ~]# [ 2 -gt 1 -a 3 -lt 4 ] && echo 'ok'

ok

[root@localhost ~]# [ 2 -gt 1 && 3 -lt 4 ] && echo 'ok'

-bash: [: missing `]'

注意:在[] 表达式中,常见的>,<需要加转义字符,表示字符串大小比较,以acill码 位置作为比较。 不直接支持<>运算符,还有逻辑运算符|| && 它需要用-a[and] –o[or]表示

[[]] 表达式

1
2
3
4
5
6
7
8
9
10
11
[root@localhost ~]# [ 1 -eq 1 ] && echo 'ok'

ok

[root@localhost ~]$ [[ 2 < 3 ]] && echo 'ok'

ok

[root@localhost ~]$ [[ 2 < 3 && 4 > 5 ]] && echo 'ok'

ok

注意:[[]] 运算符只是[]运算符的扩充。能够支持<,>符号运算不需要转义符,它还是以字符串比较大小。里面支持逻辑运算符:|| &&

性能比较

bash的条件表达式中有三个几乎等效的符号和命令:test,[]和[[]]。通常,大家习惯用if [];then这样的形式。而[[]]的出现,根据ABS所说,是为了兼容><之类的运算符。以下是比较它们性能,发现[[]]是最快的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ time (for m in {1..100000}; do test -d .;done;)

real 0m0.658s

user 0m0.558s

sys 0m0.100s

$ time (for m in {1..100000}; do [ -d . ];done;)

real 0m0.609s

user 0m0.524s

sys 0m0.085s

$ time (for m in {1..100000}; do [[ -d . ]];done;)

real 0m0.311s

user 0m0.275s

sys 0m0.036s

不考虑对低版本bash和对sh的兼容的情况下,用[[]]是兼容性强,而且性能比较快,在做条件运算时候,可以使用该运算符。