Hcc的Blog

嵌入式 · AI · 折腾不止

0%

一些 C++ 知识

一些没见过的头文件

array

  array 提供了如下写法:

1
std::array<int,6>

  类似于 C 数组:int a[6];,但是 array 更加安全,例如

1
std::array<uint8_t,6> mf{};

  就创建了长度为 6 的 mf 数组,存放六个电机故障码。

atomic

  atomic 为原子变量,用于多线程,普通变量在两个线程中同时调用可能会出现数据竞争,用以下方法定义原子变量:

1
std::atomic<bool> flag;

  保证一个线程修改时,另一个线程看到的是完整结果,如下为线程安全修改:

1
g_exit_flag.store(true);

chrono

  chrono 是时间相关的包,如下代码表示 100 ms,常用于 sleep_for()

1
std::chrono::milliseconds(100)

csignal

  此头文件用于信号处理,例如终端输入 Ctrl+C 就会产生一个 SIGINT,程序收到后就会执行 SignalHandler() 函数。

memory

  这是智能指针的头文件,会在下面详细讲解。

智能指针

  智能指针的本质是将 对象的生命周期和所有权 管理起来。传统裸指针最麻烦的问题并不是调用,而是这个对象谁负责释放,什么时候释放,会不会重复释放。
  我们先来看传统指针的问题,如下为传统的 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
class Dog
{
public:
Dog()
{
std::cout << "Dog created\n";
}

~Dog()
{
std::cout << "Dog destroyed\n";
}
};

int main()
{
Dog* dog = new Dog();

dog->xxx();

delete dog;

return 0;
}

  执行流程如下:

1
2
3
4
5
6
7
8
9
10
11
new Dog()
↓
堆上创建 Dog 对象
↓
dog 保存对象地址
↓
使用
↓
delete dog
↓
销毁 Dog

  如果我们后续没有 delete,那么在函数结束以后,dog 这个指针变量没有了,但是 Dog 对象仍然存在于堆内存中,这样会导致内存泄漏(即这块内存再也没办法正常释放了)。写代码时需要注意 delete,有时候一个函数中直接 return 了,根本没有执行 delete。智能指针就解决了这个问题,它可以将 delete 这件事自动化,示例代码如下:

1
2
3
4
5
{
std::unique_ptr<Dog> dog = std::make_unique<Dog>();

dog->run();
}

  当函数结束时,dog 变量的生命周期结束了,unique_ptr 析构,自动 delete dog,Dog 的析构函数执行,不需要手动写 delete dog;。这背后的核心思想就是 RAII,即让资源跟着对象的生命周期自动获得、自动释放。

  C++ 主要有三种智能指针,如下:

1
2
3
std::unique_ptr
std::shared_ptr
std::weak_ptr

  他们的区别在于所有权模型不同

类型 含义
unique_ptr 这个对象只有一个主人
shared_ptr 这个对象可以有多个主人
weak_ptr 我知道这个对象,但我不是它的主人

unique_ptr:唯一所有权

  示例代码:

1
auto dog = std::make_unique<Dog>();

  此时的 dog 就是一个 unique_ptr,指向 Dog 对象,dog 是 Dog 对象的唯一主人,只要 dog 活着,Dog 就活着,dog 释放,Dog 就析构。在使用方法上,智能指针和传统指针很像,都是通过 -> 访问成员。

  如果需要转移指针,使用的是 std::move 函数,我们不能直接

1
auto dog2 = dog1;   // ❌

  但是可以

1
auto dog2 = std::move(dog1);

  这里将 dog1 的生命周期管理转交给了 dog2,转移完成后 dog1 为空,dog2 指向 Dog。

  .get() 函数表示把里面保存的裸指针借我用一下,但所有权不变,并不是像 move 一样的转交,示例代码如下:

1
2
3
auto dog = std::make_unique<Dog>();

Dog* raw = dog.get();

  执行完成后 dog 和 raw 都指向内存中的 Dog,但是 raw 没有所有权,所以不能对其进行 delete 操作。

  .release() 是高危操作,其代码如下:

1
Dog* p = dog.release();

  执行完成后,dog 变为空,p 成为裸指针指向 Dog,于是我们需要自己负责 delete,否则就会泄露。

  .reset() 表示把当前管理的对象销毁,于是就变成了空指针。

1
2
auto dog = std::make_unique<Dog>();
dog.reset();

shared_ptr:共享所有权

1
auto dog = std::make_shared<Dog>();

  这里的 shared_ptr 允许复制,例如:

1
2
3
auto p1 = std::make_shared<Dog>();
auto p2 = p1;
auto p3 = p1;

  此时 p1、p2、p3 都是 Dog 的主人。如果这样的话,shared_ptr 应该如何判断什么时候被销毁?这里靠的是 reference count,引用计数。例如:

1
auto p1 = std::make_shared<Dog>();

  那么应用计数就为 1,当执行如下代码后,引用计数就为 3。

1
2
auto p2 = p1;
auto p3 = p1;

  当三个指针全部 reset 后,引用计数就变成了 0,那么 Dog 就会被 delete。
  在多线程中,我们常常使用共享指针来实现多个线程中调用同一个内存,shared_ptr 可以把对象寿命延长到:最后一个使用者也不用它为止。不过也不是看到多线程就使用 shared_ptr,如果一块内存只有一个明确的主人,那么我们应该优先使用 unique_ptr,权限管理更加清晰。

  需要注意的是,shared_ptr 的引用计数管理本身可以安全地跨线程复制和释放,但是 shared_ptr 指向的 Dog 对象并不会因此自动线程安全。我们应该对 Dog 对象内的成员也进行原子操作。

weak_ptr:观察

  单纯使用 shared_ptr 的话可能会存在循环指针的问题:

1
2
3
4
5
6
7
8
9
10
11
class Person
{
public:
std::shared_ptr<Person> friend_;
};

auto a = std::make_shared<Person>();
auto b = std::make_shared<Person>();

a->friend_ = b;
b->friend_ = a;

  在如上代码中,外部的 a 指向 A,A 内部的指针又指向 b,引用计数为 2,同理,b 指针的引用计数也为 2。因此,当我们只释放外部的 a、b 指针时,其引用计数不是 0,因此不会自动释放,造成内存泄漏。

1
std::weak_ptr<T>

  weak_ptr 可以观察一个 shared_ptr 管理的对象,但是不增加引用计数,如下:

1
2
3
4
5
6
7
8
class Person
{
public:
std::weak_ptr<Person> friend_;
};

a->friend_ = b;
b->friend_ = a;

  这样写,a、b 的引用计数都为 1,可以在外部直接释放。需要注意的是,weak_ptr 不可以直接 -> ,因为其指向的对象可能已经销毁,需要先检查其指向存在性再进行调用。

并发与并行,C++中的多线程

并发与并行的区别

  假设机器人程序有三个任务:500Hz SPI 通信、100Hz 控制算法、1Hz 健康状态上报。并发强调多个任务在同一段时间范围内都在推进,单核 CPU 也可以并发处理任务,在不同任务之间快速切换,看起来三个任务都在运行。

  并行强调这些任务真的在同一时刻运行,例如多核 CPU,每一个核运行不同的任务,所以并发不等于并行,C++ 中的 std::thread 创建的是线程,至于最终是否在不同的 CPU 核心运行由操作系统调度。

进程与线程的关系

  当我们运行 ./hal_bridge 时,创建一个进程,进程里可以存在多个线程,如主线程、控制线程等等。同一个进程内的线程共享内存。

在 C++ 中使用多线程

  C++11 中加入了 #include <thread>,用于创建线程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <thread>

void worker()
{
std::cout << "worker running\n";
}

int main()
{
std::thread t(worker);

t.join();
}

  执行流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
main线程
│
│ 创建 thread
├────────────────→ worker线程
│ │
│ │ worker()
│ │
│ join()等待 │
│ │
│←────────────────────┘
│
↓
结束

  给线程传参示例代码如下:

1
2
3
4
5
void worker(int id)
{
std::cout << id << '\n';
}
std::thread t(worker, 10);

  当std::thread t(worker, 10);运行后,线程开始独立运行,t.join 表示当前线程等 t 执行完成之后再继续。t.death()表示让线程脱离,之后 t 不再管理这个线程。std::this_death表示当前正在执行代码的线程,常见用法:

1
2
std::this_thread::sleep_for(
std::chrono::milliseconds(10));

  这里表示当前线程睡 10ms,而非整个线程。

std::mutex 互斥锁

  假设:

1
2
3
4
5
6
7
struct RobotState
{
double pitch;
double velocity;
};

RobotState state;

  线程 A 和线程 B 同时对 state 进行操作,会出现数据竞争现象。这时应该使用 mutex,其示意图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
线程A                线程B

lock()
拿到钥匙
↓
counter++
↓
unlock()
归还钥匙
lock()
拿到钥匙
↓
counter++
↓
unlock()

  所以同一时刻只有一个线程可以修改 counter。但是在实际情况下,手动写 lock() 和 unlock() 会导致死锁的情况发生。现代 C++ 推荐 RAII 管理锁,如 std::lock_guard,示例代码如下:

1
2
3
4
5
6
7
8
std::mutex mtx;

void func()
{
std::lock_guard<std::mutex> lock(mtx);

counter++;
}

  过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
进入 func

lock_guard 构造
↓
mtx.lock()
↓
counter++
↓
离开作用域
↓
lock_guard 析构
↓
mtx.unlock()

  我们还会使用 {} 来缩小作用范围,如下:

1
2
3
4
5
6
7
8
9
10
void func()
{
{
std::lock_guard<std::mutex> lock(mtx);

updateSharedState();
}

doHeavyCalculation();
}

  这里结束锁后立即释放,然后再 doHeavyCalculation(),这是非常重要的并发优化原则:锁住的范围越小越好。

  std::unique_lock 比 lock_guard 更加灵活,基本用法如下:

1
2
3
4
5
6
7
8
9
10
11
std::unique_lock<std::mutex> lock(mtx);

readSharedData();

lock.unlock();

heavyCalculation();

lock.lock();

writeSharedData();

  这可以做到中途解锁、再上锁。

  defer_lock 可以做到先创建锁管理对象,但暂时不要 lock。等 lock.lock() 再真正上锁。

1
2
3
4
5
std::unique_lock<std::mutex> lock(
mtx,
std::defer_lock);

lock.lock(); // 真正上锁

  std::scoped_lock 可以一次锁多个 mutex,例如

1
2
3
4
5
6
std::mutex m1;
std::mutex m2;

{
std::scoped_lock lock(m1, m2);
}

  还有更多锁的使用需要在代码实战中注意,我会在日后进一步学习。

lambda

  Lambda 在现代 C++ 中十分重要,这是一种现场临时写的没有名字的小函数,他很适合用在线程、回调、STL 算法中。

最简单的 Lambda

  普通函数:

1
2
3
4
int add(int a, int b)
{
return a + b;
}

  Lambda 可以直接写成

1
2
3
4
5
auto add = [](int a, int b) {
return a + b;
};

int result = add(1, 2);

  所以这也是一个函数,只是没有传统函数名。

Lambda 的完整结构

  其标准形式如下:

1
2
3
4
[捕获列表](参数列表) -> 返回类型
{
函数体
}

  捕获列表是 Lambda 最重要的部分,它决定了 Lambda 能不能使用外面的局部变量,以及如何使用,例如:

1
2
3
4
5
6
7
8
9
10
11
int x = 10;

auto f = []()
{
std::cout << x; // 报错!未捕获 x 就使用!
};

auto f = [x]()
{
std::cout << x; // 正确做法
};

  如下代码输出的值为 10,因为在创建 Lambda 的时候已经把 x = 10 复制进了函数中,之后外面改成 20 对内部值没有影响。

1
2
3
4
5
6
7
8
9
10
int x = 10;

auto f = [x]()
{
std::cout << x;
};

x = 20;

f();

  我们还可以直接按引用捕获,如 [&x],这表示了 Lambda 直接引用外面的 x,所以下面的代码会输出 20。

1
2
3
4
5
6
7
8
9
10
int x = 10;

auto f = [&x]()
{
std::cout << x;
};

x = 20;

f();

  [=] 默认按值捕获,代码如下,Lambda 中使用到的外部局部变量默认都按值捕获:

1
2
3
4
5
6
7
8
int a = 10;
int b = 20;
int c = 30;

auto f = [=]()
{
std::cout << a + b + c;
};

  同样的,还有 [&],表示 Lambda 中用到的外部局部变量默认按引用捕获。

1
2
3
4
5
6
7
8
int a = 10;
int b = 20;

auto f = [&]()
{
a++;
b++;
};

  我们也可以同时使用这两种方式捕获,如下:

1
2
3
4
5
6
7
8
9
int a = 10;
int b = 20;

auto f = [a, &b]()
{
std::cout << a;

b++;
};

Lambda 高级用法

  如下代码将函数先保存,等关机时再调用这个函数。

1
2
3
4
ShutdownHandler(
[platform_driver]() {
platform_driver->latchVelocityZero();
});

  还可以这样,可以减少另外定义函数的代码。

1
2
3
std::thread monitor_thread([platform_driver] {
...
});

  Lambda 本质上是编译器创建了一个类,大概长这样

1
2
3
4
5
6
7
8
9
10
11
class Lambda
{
private:
int x_;

public:
void operator()() const
{
std::cout << x_;
}
};

  因此我们没法直接修改 按值捕获的副本,通过 mutable 允许 Lambda 修改按值捕获进来的副本,示例如下:

1
2
3
4
5
6
int x = 10;

auto f = [x]() mutable {
x++;
std::cout << x << std::endl;
};