C/C++
{Back to Index}  

Table of Contents

1 运行

1.1 Tool Chain

Tool Chain 是三种元素的集合,按顺序执行,最终将代码转换成可执行程序:

  • 预处理器
  • 编译器
  • 链接器

compile-process.png

1.1.1 预处理

在 GCC 中,可以通过下面的命令生成.i文件:

gcc -E demo.c -o demo.i -nostdinc -P

-E 表示只进行预编译, -nostdinc will bypass standard #include files 。

1.1.2 编译

可以使用下面的命令生成 .s 文件

gcc -S demo.c -o demo.s

1.1.3 汇编

汇编的结果是产生目标文件,在 GCC 下的后缀为.o

1.1.4 链接

目标文件已经是二进制文件,与可执行文件的组织形式类似,只是有些函数和全局变量的地址还未找到,程序不能执行。链接的作用就是找到这些目标地址,将所有的目标文件组织成一个可以执行的二进制文件。

1.2 动态链接库 1

生成动态链接库是直接使用gcc命令并且需要添加-fPIC(-fpic) 以及-shared 参数
  • fPIC 或 -fpic 参数的作用是使得 gcc 生成的代码是与位置无关的,也就是使用相对位置。
  • shared参数的作用是告诉编译器生成一个动态链接库。

dll-0.png

2 类型

2.1 整数类型

int-types.png

Figure 3: Integer Types, Sizes, and Format Specifiers

2.1.1 size_t

通常用 unsigned long long 实作,并用 %zd (十进制) 或 %zx(十六进制) 作为格式描述符。

2.2 枚举类型

enum class Race {
    Dinan,
    Teklan,
    Ivyn,
    Moiran,
    Camite,
    Julian,
    Aidan
};

Race langobard_race = Race::Aidan;

2.3 POD

尽量在 POD 中以 从大到小 排列成员。

2.4 初始化

2.4.1 基本类型初始化为零

int a = 0;
int b{};
int c = {};
int d = { 0 };
int e; // 不要使用这种方式

2.4.2 基本类型初始化为任意值

int e = 42;
int f{ 42 };
int g = { 42 };
int h( 42 );

2.4.3 数组初始化

int array_1[]{ 1, 2, 3 };       // array of 3 integers
int array_2[5]{};               // array of 5 integers, all elements are initialized to 0
int array_3[5]{ 1, 2, 3 };      // array of 5 integers, first 3 elements are 1, 2, 3, the rest are 0
int array_4[5];                 // array of 5 uninitialized integers (dangerous!)

2.4.4 POD 初始化

#include <cstdint>

struct Pod {
    uint64_t a;
    char b[256];
    bool c;
};

int main() {
    Pod pod1{};                 // all fields are zeroed
    Pod pod2 = {};              // all fields are zeroed
    Pod pod3{ 42, "hello" };    // a = 42, b = "hello", c = 0
    Pod pod4{ 42, "hello", true };    // a = 42, b = "hello", c = true
    Pod pod5 = { 42, "hello", true }; // same as above
    Pod pod6 = { .a = 42, .b = "hello", .c = true }; // same as above
}

2.4.5 类初始化

struct Taxonomist {
    Taxonomist() {
        printf("(no argument)\n");
    }

    Taxonomist(int x) {
        printf("int: %d\n", x);
    }

    Taxonomist(char x) {
        printf("char: %c\n", x);
    }

    Taxonomist(float x) {
        printf("float: %f\n", x);
    }
};

int main() {
    Taxonomist t1;              // (no argument)
    Taxonomist t2{ 'c' };       // char: c
    Taxonomist t3{ 65537 };     // int: 65537
    Taxonomist t4 { 6.02e23f }; // float: 602000000000000000000000
    Taxonomist t5('g');         // char: g
    Taxonomist t6 = { 'l' };    // char: l
    Taxonomist t7{};            // (no argument)
    Taxonomist t8();            // WRONG, 这是个函数声明,这就是要引入大括号初始化语法的一个主要原因,另一个主要原因是 Narrowing conversion
}

2.4.6 类成员初始化

不能使用小括号来初始化成员变量。

struct JohanVanDerSmut {
    bool gold = true;           // equal sign
    int year_of_smelting_accident { 1970 }; // curly braces
    char key_location[8] = { "x-rated" };   // equal sign with curly braces
};

2.4.7 Narrowing Conversion

每当发生 Implicit Narrowing Conversions 时,大括号初始化会产生告警, 这可以帮助减少 bug 。

float a{ 1 };
float b{ 2 };
int narrowed(a / b); // narrowing conversion
printf("narrowed: %d\n", narrowed);
int result { a / b }; // Compiler generated warning
printf("result: %d\n", result);

3 对象

3.1 拷贝语义

3.1.1 拷贝构造

DerivedClass(const DerivedClass& other) : BaseClass(other) {
    // Copy constructor code
}

3.1.2 拷贝赋值

DerivedClass& operator=(const DerivedClass& other) {
    if (this != &other) {
        // Copy the base class part
        BaseClass::operator=(other);
        // Copy the derived class part
        derivedMember = other.derivedMember;
    }
    return *this;
}

拷贝赋值和拷贝构造的主要区别在于,在拷贝赋值中, 旧值可能已经有了一个值,必须在赋新值前,清理旧值的资源。

3.2 移动语义

3.2.1 移动构造

DerivedClass(DerivedClass&& other) noexcept : BaseClass(std::move(other)) {
    // move other's resources to this
}

移动构造和拷贝构造类似 ,区别在于前者采用了右值引用而非左值引用。如果不加上 noexcept ,编译期会退而使用拷贝构造函数。

3.2.2 移动赋值

DerivedClass& operator=(DerivedClass&& other) noexcept {
    if (this == &other) {
        return *this;
    }
    BaseClass::operator=(std::move(other));
    // move other's members to this
    return *this;
}

除了自引用检查和旧值清理逻辑之外,移动赋值的逻辑和移动构造的逻辑是相同的。

3.3 编译器生成的方法

有五种方法与对象的内存控制相关:

  • 析构函数
  • 拷贝构造
  • 移动构造
  • 拷贝赋值
  • 移动赋值
  1. 如果什么方法都不定制,编译器为这五个方法自动生成默认实现
  2. 如果定义了析构函数,拷贝构造,或拷贝复制这三个方法的任何一个,就会得到所有这三个函数的实现
  3. 如果只定义移动语义相关的方法,编译器会自动提供默认析构函数

Sorry, your browser does not support SVG.

Figure 4: A chart illustrating which methods the compiler generates when given various inputs

4 多态

4.1 接口 (运行时多态)

4.2 Template (编译期多态)

4.2.1 类型转换函数

4.2.1.1 const_cast
const int& const_val = 10;
auto& val = const_cast<int&>(const_val);
val = 20;
log_info("const_val: %d, val: %d", const_val, val); // const_val: 20, val: 20

也可以使用 const_cast 从对象中去除 volatile 修饰符。

4.2.1.2 static_cast
short s = 600;
void *target = &s;
auto as_short = static_cast<short *>(target);
log_info("as_short: %d", *as_short); // 600

float f = 3.14;
auto as_int = static_cast<int *>(&f); // static_cast from 'float *' to 'int *' is not allowed
4.2.1.3 reinterpret_cast

通常用于 cast 两个完全不搭界的类型,使用者必须自行负责转换的正确性。

auto timer = reinterpret_cast<const uint64_t*>(0x7e00);
printf("Time: %llu\n", *timer); // Segment fault

4.2.2 非类型模版参数

template <size_t Index, typename T, size_t Length>
T& get(T (&arr)[Length]) {
    static_assert(Index < Length, "Index out of bounds");
    return arr[Index];
}

int arr[] = {1, 2, 3, 4, 5};
std::cout << get<2>(arr) << std::endl; // 3

5 语句

5.1 命名空间

5.1.1 using

using 会 ① 将符号导入块中,② 如果在命名空间中使用 using ,则会将符号导入当前命名空间。

namespace BroopKidron13::Shaltanc {
    enum class Color {
        Mauve, Pink, Russet
    }
}

int main() {
    using namespace BroopKidron13::Shaltanc::Color;
    const auto shaltanac_grass = Color::Russet;
    if (shaltanac_grass == Color::Russet) {
        printf("The grass is russet\n");
    }
}

5.1.2 类型别名 (using =)

类型别名可以出现在任何作用域:块,类,或者命名空间。

该技法可以用于减少 模版参数 ,为模版定义类型别名:

#include <cstdio>
#include <stdexcept>

template <typename To, typename From>
struct NarrowCaster {
  To cast(From value) const {
    const auto converted = static_cast<To>(value);
    const auto backwards = static_cast<From>(converted);
    if(value != backwards)
      throw std::runtime_error{ "Narrowed!" };
    return converted;
  }
};

template <typename From>
using short_caster = NarrowCaster<short, From>;

int main() {
  try {
    const short_caster<int> caster;
    const auto cyclic_short = caster.cast(142857);
    printf("cyclic_short: %d\n", cyclic_short);
  } catch(const std::runtime_error& e) {
    printf("Exception: %s\n", e.what());
  }
}

5.1.3 constexpr if

constexpr if 主要用途是根据类型参数的某些属性在函数模版中提供自定义行为。 在运行期间,constexpr if 语句消失。

这个语句可以取代预处理器宏。

#include <cstdio>
#include <stdexcept>
#include <type_traits>

template <typename T>
auto value_of(T x) {
  if constexpr(std::is_pointer<T>::value) {
    if(!x)
      throw std::runtime_error{ "Null pointer dereference." };
    return *x;
  } else {
    return x;
  }
}

int main() {
  unsigned long level{ 8998 };
  auto level_ptr = &level;
  auto& level_ref = level;
  printf("Power level = %lu\n", value_of(level_ptr));
  ++*level_ptr;
  printf("Power level = %lu\n", value_of(level_ref));
  ++level_ref;
  printf("It's over %lu!\n", value_of(level++));
  try {
    level_ptr = nullptr;
    value_of(level_ptr);
  } catch(const std::exception& e) {
    printf("Exception: %s\n", e.what());
  }
}

6 函数

6.1 volatile

不能在 volatile 对象上调用非 volatile 方法。

6.2 decltype

template <typename X, typename Y>
auto add(X x, Y y) -> decltype(x + y) {
  return x + y;
}

7 智能指针

smart.png

Figure 5: Smart Pointers in stdlib and Boost

8 迭代器

itr.png

Figure 6: Iterator categories and their nested relationships

9 Memory Order 2 , 3 , 4

Memory Order 控制了执行结果在多核中的可见顺序,这个可见顺序与代码序不一定一致:

  • 原因一是 汇编指令优化重排
  • 原因二是 CPU 实际执行时乱序执行 以及 部分 CPU 架构上没有做到内存强一致性 (内存强一致性:执行结果出现的顺序应该和指令顺序一样,不存在重排乱序) ,导致后面的代码执行完成的时候,前面的代码修改的内存却还没改变

在可能出现问题的场景下,需要手动干预以避免问题,汇编(软件)和 CPU(硬件) 都提供了相应的指令取进行干预控制,C++ 的 atomic中的 Memory Order 可以看成是这些控制的 封装 ,隐藏了底层,之所以有六种是因为这种控制是有代价,从松散到严格开销越来越高,在某些场景下,是允许部分重排的,只是对于小部分重排会导致问题的才需要加以控制,那么只需要衉一些低开销的控制即可。

Memory Order 的作用是:

  • 干预汇编重排
  • 干预硬件乱序执行
  • 控制执行结果在多核间的可见性

可以说,Memory Order 是用来限制编译器以及 CPU 对单线程当中的指令执行顺序进行重排的 程度 (此外还包括对 cache 的控制方法)。
这种限制,决定了以 atomic 操作为基准点(边界),对其之前后的内存访问命令,能够在多大的范围内自由重排, 也被称为栅栏

六种模型参数本质上是限制单线程内部的指令重排顺序, 并不是同步不同线程之间的指令顺序
通过不同的参数选择,来控制带有模型参数的变量前后的指令被重排顺序的程度。

9.1 硬件内存模型

随着 CPU 的不断发展,CPU 的计算能力远超过从主存 (DRAM) 中读写速度。为了提升数据读写速度,慢慢的加入了 Cache, Store Buffer, Invalidate Queue 等硬件,并允许 CPU 的乱序执行:
实际的 CPU 在运行指令过程中并非表现得一条执行完了才执行下一条指令,比如一个 MOV 指令会导致 CPU 的 Load Unit 忙而 ALU (逻辑计算单元) 空闲,因此在等取值的同时,预先做下一个能够做的计算指令,这样的乱序执行提升了CPU 的使用率。

Store Buffer, Invalidate Queue 的出现带来了不同的内存一致性模型,从而导致执行结果在多核下的可见顺序不同,所以在编程时考虑这点,以下总结了常见的四种内存一致性模型以及它们对执行结果的在多核中可见顺序的影响。

9.1.1 顺序存储模型(SC: Sequential Consistency)

sc.png

Figure 7: 顺序存储模型

多核 cache 间使用 MESI 协议进行数据同步,不存在数据一致性问题,因此在这种模型下,多线程程序的运行与所期望的执行情况是一致的, 不会出现内存访问乱序的情况。

顺序存储模型是最简单的存储模型,也称为 强定序模型 。CPU 会按照代码来执行所有的 load 与 store 动作,即按照它们在程序的顺序流中出现的次序来执行。从主存储器和 CPU 的角度来看,load 和 store 是顺序地对主存储器进行访问。

可以把顺序存储模型想象成单核系统。

9.1.2 存储定序模型

9.1.2.1 完全存储定序(TSO: Total Store Order)【引发 store-load 乱序】

tso.png

Figure 8: Total Store Order (TSO)

为了提高 CPU 的性能,芯片设计人员在 CPU 中包含了一个存储缓存区(store buffer),它的作用是为 store 指令提供缓冲,使得CPU不用等待存储器的响应。所以对于写而言,只要 store buffer 里还有空间,写就只需要1个时钟周期,所以 store buffer 的存在可以很好的减少写开销。

Store Buffer 必须严格按照 FIFO 的次序将数据发送到主存(所谓的FIFO表示先进入store buffer的指令数据必须先于后面的指令数据写到存储器中),CPU 必须要严格保证 store buffer 的顺序执行,这种内存模型就叫做完全存储定序(TSO)。x86 CPU 就是这种内存模型。

相比于以前的内存模型而言,store 的时候数据会先被放到store buffer里面,然后再被写到L1 cache里。 因此这引入了访问乱序的根源 :因为 store 操作会放入 SB ,而 load 操作直接被 CPU 执行,产生了两条执行路径,因这种原因产生的乱序称之为 store-load 乱序

9.1.2.2 部分存储定序(PSO: Part Store Order)【引发 store-store 乱序】

TSO 在有 store buffer 的情况下已经带来了不小的性能提升,但是芯片设计人员并不满足于这一点,于是在 TSO 模型的基础上继续放宽内存访问限制:允许 CPU 以非 FIFO 来处理 store buffer 缓冲区中的指令。 CPU 只需保证地址相关指令在store buffer中才会以FIFO的形式进行处理 ,即对 同一个相同的地址做store,才会有严格执行顺序制约 ,而其他的则可以乱序处理,这被称为部分存储定序(PSO) ,产生的乱序称为 store-store乱序

9.1.3 宽松存储模型(RMO: Relax Memory Order) 【引入 load-load 和 load-store 乱序】

为了榨取更多的性能,在 PSO 的模型的基础上,更进一步的放宽了内存一致性模型,不仅允许 store-load,store-store 乱序,还进一步允许 load-load ,load-store 乱序, 只要是地址无关的指令 ,在读写访问的时候都可以打乱所有顺序,这就是宽松内存模型(RMO)。

这是一种乱序随可见的内存一致性模型,ARM 的很多微架构就是使用 RMO 模型。

9.1.4 干预工具

9.1.4.1 内存屏障(memory barrier)

内存屏障的最根本的作用就是提供一个机制,要求CPU在这个时候必须以顺序存储一致性模型的方式来处理load与store指令,这样才不会出现内存访问不一致的情况。

内存屏障可以细分为:

  1. write memory barrier 【也称 sfence (sotre fence) ,解决 store-load/store-store 问题】

    将 store buffer 中的数据全部刷进 cache 。刷到 cache 这部分的数据被更新了,就会触发 cache 的 mesi 进行同步,发送 cacheline 的 invalidate message 告知其它 cache 持有的数据失效了,赶紧标注一下然后同步,然后其他的 core 会返回 invalidate ack 之后这时才会继续向下执行。

  2. read memory barrier 【也称 lfence (load fence) ,解决 load-store/load-load 问题】

    flush Invalidate Queue

  3. full memory barrier

    read memory barrier + write memory barrier

9.1.4.2 Invalidate Message Queue (针对 TSO)

使用 memory barrier 方式需要在 cacheline 上发送 message,一来一回需要等待时间,这对于 CPU 设计者来说同样是不可接受的。因此又引入了Invalidate Queue 。

invalidate-queue.png

有了 Invalidate Queue 之后,发送的 Invalidate Message 只需要 push 到对应 core 的 Invalidate Queue 即可,然后这个core就会返回继续执行,中间不需要等待。这样cache之间的沟通就不会有很大的阻塞了。

在被通知的CPU上运行的线程其实也需要内存屏障(load fence) ,因为如果不及时处理 Invalidate Queue,那就仍然持有旧数据。

9.1.4.3 Lock 指令 【X86 平台】

Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。它会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁,实现了以下作用:

  • 先对总线/缓存加锁,然后执行后面的指令。
  • Lock 后的写操作会通过 cache 间的 MESI 协议让其他 CPU 相关的 cache line 失效。
  • 最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。
  • 在锁住总线的时候,其他 CPU 的读写请求都会被阻塞,直到锁释放。

9.2 软件内存模型语义

9.2.1 memory_order_seq_cst

sequentially-consistency.png

要求底层提供顺序一致性模型,如果程序的运行底层架构是非内存强一致模型,则使用cpu提供的内存屏障等操作保证强一致,同时要求代码进行编译的时候不能够做任何指令重排。

该模型可以解决一切问题。

9.2.2 memory_order_release/memory_order_acquire

release-acquire.png

允许cpu或者编译器做一定的指令乱序重排,但是由于TSO,PSO的存在,可能产生的store-load/store-store乱序从而导致问题。那么涉及到多核交互的时候,就需要手动使用release, acquire去避免这样的问题。
与memory_order_seq_cst最大的不同的是,它是对具体代码可能出现的乱序做具体解决而不是要求全部都不能重排。

  • load(acquire) 之后的所有写操作(包含非依赖关系),不允许被移动到这个 load(acquire) 的前面,一定在 load 之后执行。
  • store(release) 之前的所有读写操作(包含非依赖关系),不允许被重排到这个 store(release) 的后面,一定在 store 之前执行。
  • 如果 store(release) 在 load(acquire) 之前执行了,那么 store(release) 之前的写操作对 load(acquire) 之后的读写操作可见。
  • 绿色区域的代码依然可以允许编译器或 CPU 为了优化目的重排序,但不能超越屏障。

9.2.3 memory_order_relaxed

relaxed-order.png

完全放开,让编译器和cpu自由搞,如果cpu是SC的话,cpu层不会出现乱序,但是编译层可能会做重排,结果也是无法保证的。
可用在代码上没有乱序要求的场景或者没有多核交互的情况下,以提升性能。

9.2.4 memory_order_acq_rel

acq_rel.png

  • 前面无法被重排到后面,后面无法被重排到前面。
  • 可以看见其他线程施加 release 之前的所有写入,同时自己之前所有写入对其他施加 acquire 语义的线程可见。

10 C++11

10.1 auto

10.1.1 限制

  1. 不能作为函数参数使用

    int func(auto a, auto b)    // error
    {
        cout << "a: " << a <<", b: " << b << endl;
    }
    
  2. 不能用于类的非静态成员变量的初始化

    class Test
    {
        auto v1 = 0;                    // error
        static auto v2 = 0;             // error,类的静态非常量成员不允许在类内部直接初始化
        static const auto v3 = 10;      // ok
    };
    
  3. 不能使用auto关键字定义数组
  4. 不能使用auto推导出模板参数

    template <typename T>
    struct Test{}
    
    int func()
    {
        Test<double> t;
        Test<auto> t1 = t;           // error, 无法推导出模板类型
        return 0;
    }
    

10.1.2 常见应用

  1. STL的容器遍历
  2. 泛型编程

    #include <iostream>
    #include <string>
    using namespace std;
    
    class T1
    {
    public:
        static int get()
        {
            return 10;
        }
    };
    
    class T2
    {
    public:
        static string get()
        {
            return "hello, world";
        }
    };
    
    template <class A>
    void func(void)
    {
        auto val = A::get();
        cout << "val: " << val << endl;
    }
    
    int main()
    {
        func<T1>();
        func<T2>();
        return 0;
    }
    

10.2 decltype

它的作用是在编译器编译的时候推导出一个表达式的类型。

10.3 constexpr

在使用中建议将 const 和 constexpr 的功能区分开,即凡是表达"只读"语义的场景都使用 const,表达"常量"语义的场景都使用 constexpr。

10.3.1 修饰普通函数/类成员函数

使用前提条件:

  • 函数必须要有返回值,并且return 返回的表达式必须是常量表达式
  • 函数在使用之前,必须有对应的定义语句,不能只看见声明
  • 函数体中,不能出现非常量表达式之外的语句,但可以有 using 、typedef、static_assert 和 return

10.3.2 修饰模板函数

由于模板中类型的不确定性, 如果 constexpr 修饰的模板函数实例化结果不满足常量表达式函数的要求(主要看对应类型的参数是否是一个右值),则 constexpr 会被自动忽略,相当于一个普通函数。

10.3.3 修饰构造函数

常量构造函数有一个要求: 构造函数的函数体必须为空,并且必须采用初始化列表的方式为各个成员赋值。

Footnotes:

Author: Hao Ruan (ruanhao1116@gmail.com)

Created: 2023-10-09 Mon 15:16

Updated: 2024-06-26 Wed 09:48

Emacs 27.2 (Org mode 9.4.4)