• 嵌入式软件为何要用状态机架构

    一、提高CPU使用效率 话说我只要见到满篇都是delay_ms()的程序就会头疼,动辄十几个ms几十个ms的软件延时是对CPU资源的巨大浪费,宝贵的CPU时间都浪费在了NOP指令上。 那种为了等待一个管脚电平跳变或者一个串口数据,让整个程序都不动的情况也让我非常纠结,如果事件一直不发生电平跳变,你要等到世界末日么? 如果应用状态机编程思想,程序只需要用全局变量记录下工作状态,就可以转头去干别的工作了,当然忙完那些活儿之后要再看看工作状态有没有变化。 只要目标事件(定时未到、电平没跳变、串口数据没收完)还没发生,工作状态就不会改变,程序就一直重复着“查询—干别的—查询—干别的”这样的循环,这样CPU就闲不下来了。 这种处理方法的实质就是在程序等待事件的过程中间隔性地插入一些有意义的工作,好让CPU不是一直无谓地等待。 二、逻辑完备性 逻辑完备性是状态机编程最大的优点。 不知道大家有没有用C语言写过计算器的小程序,我很早以前写过,写出来一测试,那个惨不忍睹啊! 当我规规矩矩的输入算式的时候,程序可以得到正确的计算结果,但要是故意输入数字和运算符号的随意组合,程序总是得出莫名其妙的结果。 后来我试着思维模拟一下程序的工作过程,正确的算式思路清晰,流程顺畅,可要碰上了不规矩的式子,走着走着我就晕菜了,那么多的标志位,那么多的变量,变来变去,最后直接分析不下去了。 很久之后我认识了状态机,才恍然明白,当时的程序是有逻辑漏洞的。 如果把这个计算器程序当做是一个反应式系统,那么一个数字或者运算符就可以看做一个事件,一个算式就是一组事件组合。 对于一个逻辑完备的反应式系统,不管什么样的事件组合,系统都能正确处理事件,而且系统自身的工作状态也一直处在可知可控的状态中。 反过来,如果一个系统的逻辑功能不完备,在某些特定事件组合的驱动下,系统就会进入一个不可知不可控的状态,与设计者的意图相悖。 状态机就能解决逻辑完备性的问题。 状态机是一种以系统状态为中心,以事件为变量的设计方法,它专注于各个状态的特点以及状态之间相互转换的关系。 状态的转换恰恰是事件引起的,那么在研究某个具体状态的时候,我们自然而然地会考虑任何一个事件对这个状态有什么样的影响。 这样,每一个状态中发生的每一个事件都会在我们的考虑之中,也就不会留下逻辑漏洞。 这样说也许大家会觉得太空洞,实践出真知,某天如果你真的要设计一个逻辑复杂的程序,会觉得状态机真香! 三、程序结构清晰 用状态机写出来的程序的结构是非常清晰的。 程序员最痛苦的事儿莫过于读别人写的代码,如果代码不是很规范,而且手里还没有流程图,读代码会让人晕了又晕,只有顺着程序一遍又一遍的看,很多遍之后才能隐约地明白程序大体的工作过程。 有流程图会好一点,但是如果程序比较大,流程图也不会画得多详细,很多细节上的过程还是要从代码中理解。 相比之下,用状态机写的程序要好很多,拿一张标准的UML状态转换图,再配上一些简明的文字说明,程序中的各个要素一览无余。 程序中有哪些状态,会发生哪些事件,状态机如何响应,响应之后跳转到哪个状态,这些都十分明朗,甚至许多动作细节都能从状态转换图中找到。 可以毫不夸张的说,有了UML状态转换图,程序流程图写都不用写。

    01-14 58浏览
  • C++并发编程:什么是无锁数据结构?

    1. 什么是无锁数据结构? 锁的本质是阻止其他线程进入锁住的临界区,当一个线程在临界区中休眠,其他线程的操作也会被卡在临界区外(锁的根本意图就是杜绝并发功能,是阻塞型数据结构)。而无锁数据结构要求总有一个线程能够真正推进事情的进展,而不是空转,也就是说即使一些线程在任意位置休眠,其他线程也能完成操作并返回,这也说明任何时候都不存在锁住的临界区。 无锁数据结构不一定更快,因为常常需要很多原子操作,每个原子操作都有额外开销并可能涉及 CPU 和缓存的竞争。 1.无锁数据结构的优点: 最大限度地实现并发: 还是那句话,锁的根本意图就是杜绝并发功能,而无锁数据结构总存在某个线程能执行下一步操作(不存在锁的临界区导致其他线程被堵塞的问题) 代码的健壮性: 假设数据结构的写操作受锁保护,如果某一线程在持锁期间终止,那么该数据结构只完成了部分改动,且此后没办法修补。因为持锁期间,线程会对共享数据结构执行一系列被锁保护的操作,其他线程无法访问数据结构或观察到其部分修改状态,如果线程在操作完成之前终止(例如异常退出),锁会释放,但数据结构可能处于不一致或部分修改的状态,而剩下的部分操作没有其他线程可以接管和恢复操作,因为锁没有记录操作的上下文。 但是在无锁数据结构中,即使某线程操作无锁数据时意外终结,但丢失的数据仅限于它本身持有的部分,其他的数据仍然完好,能被其他线程正常处理(因为原子操作不能被分割,要么成功修改数据,要么失败保持原状态不变,所以即使线程终止,也不会留下半完成的修改)。 2.无锁数据结构的缺点: 难度大: 对无锁数据结构执行写操作的难度高于带锁的数据结构,主要因为无锁数据结构需要在没有锁的情况下依靠复杂的算法和原子操作(如CAS,就是compare_exchange_strong)来保证线程安全。写操作必须确保全局一致性,处理并发冲突,并设计有效的重试机制,同时解决诸如ABA问题等细节。而带锁数据结构只需通过互斥锁避免并发,逻辑相对简单,因此无锁写操作的实现通常更加复杂且易出错。 活锁 由于无锁数据结构完全不含锁,因此不存在死锁问题,但活锁(live lock)反而有可能出现。假设两个线程同时修改同一份数据结构,若他们所做的改动都导致对方从头开始操作,那双方就会反复循环,不断重试,这种现象即为活锁。与死锁不同,活锁中的线程不会被阻塞,它们会持续执行某些操作,但由于逻辑错误或相互之间的干扰,始终无法达到预期的目标。 2. 环形队列 环形队列是多线程无锁并发执行时用到的,一次往队列中写入一个事件,队列只记录事件相关数据的指针,另外使用原子操作来记录读取这个指针,迅速、安全。因为指针占空间小而且一致,所以可以直接使用数组来保存它们。 而环形队列有以下两个好处: 成环的队列大小是固定的,可以循环复用 通过移动头和尾就能实现数据的插入和取出 一个环形结构示意图如下所示: 环形队列是队列的一种数据结构,在队头出队, 队尾入队; 只是环形队列的大小是确定的, 不能进行一个长度的增加,当你把一个环形队列创建好之后,它能存放的元素个数是确定的; 虽然环形队列在逻辑上是环形的,但在物理上是一个定长的数组; 一般我们实现这个环形队列是通过一个连续的结构来实现的; 环形队列在逻辑上形成一个环形的变化,主要是当头尾指针当走到连续空间的末尾的时候,它会做一个重置的操作。 如上图所示,当队列为空的时候,头指针和尾指针指向同一个区域; 当插入一个数据之后,队列size变为1,尾指针Q.rear + 1向前移动到下一个扇区,头指针Q.front存储队列的第一个数据,并始终指向该区域(如果不pop数据的话); 当pop出一个数据后,头指针Q.front + 1 向前移动到下一个扇区,如果 front == rear 表示队列为空。注意:当数据被pop出队列后,仅仅只是头指针变化,而数据其实仍然留在内存原处不用处理,当插入新数据时会将这个内存原本的数据覆盖掉; 当尾指针 rear + 1 % 队列长度 == front 时,表示队列为满。 3. 实现线程安全的环形队列 在本节中,我们通过互斥量和原子操作分别实现有锁环形队列和无锁环形队列。 3.1 实现有锁环形队列 代码如下: #include #include #include template<typename T, size_t Cap>class CircularQueLk :private std::allocator{public: CircularQueLk() :_max_size(Cap + 1),_data(std::allocator::allocate(_max_size)), _head(0), _tail(0) {} CircularQueLk(const CircularQueLk&) = delete; CircularQueLk& operator = (const CircularQueLk&) volatile = delete; CircularQueLk& operator = (const CircularQueLk&) = delete; ~CircularQueLk() { //循环销毁 std::lock_guard<std::mutex> lock(_mtx); //调用内部元素的析构函数 while (_head != _tail) { std::allocator::destroy(_data + _head); _head = (_head+1)%_max_size; } //调用回收操作 std::allocator::deallocate(_data, _max_size); } //先实现一个可变参数列表版本的插入函数最为基准函数 template <typename ...Args> bool emplace(Args && ... args) { std::lock_guard<std::mutex> lock(_mtx); //判断队列是否满了 if ((_tail + 1) % _max_size == _head) { std::cout << "circular que full ! " << std::endl; return false; } //在尾部位置构造一个T类型的对象,构造参数为args... std::allocator::construct(_data + _tail, std::forward(args)...); //更新尾部元素位置 _tail = (_tail + 1) % _max_size; return true; } //push 实现两个版本,一个接受左值引用,一个接受右值引用 //接受左值引用版本 bool push(const T& val) { std::cout << "called push const T& version" << std::endl; return emplace(val); } //接受右值引用版本,当然也可以接受左值引用,T&&为万能引用 // 但是因为我们实现了const T& bool push(T&& val) { std::cout << "called push T&& version" << std::endl; return emplace(std::move(val)); } //出队函数 bool pop(T& val) { std::lock_guard<std::mutex> lock(_mtx); //判断头部和尾部指针是否重合,如果重合则队列为空 if (_head == _tail) { std::cout << "circular que empty ! " << std::endl; return false; } //取出头部指针指向的数据 // 因为右值引用可以隐式转换为左值引用,所以可以将一个右值引用赋值给左值引用 val = std::move(_data[_head]); //更新头部指针 _head = (_head + 1) % _max_size; return true; }private: size_t _max_size; T* _data; std::mutex _mtx; size_t _head = 0; size_t _tail = 0;}; 默认构造函数中,_data(std::allocator::allocate(_max_size))用于为 _data 指针分配一块内存,这块内存可以存储 _max_size 个 T 类型的对象,而_data也是T类型的指针,这是内存分配器类模板std::allocator实现的。 我们在创建环形队列设置的最大长度为Cap,但是在构造函数中,分配给 _data 指针的内存其实是Cap + 1,这是为了区分队列为空和队列满的状态,设计中通常会保留一个额外的空间: 空队列:当 head == tail 时,表示队列为空。 满队列:当 (tail + 1) % max_size == head 时,表示队列已满。 如果不预留额外空间,那么当 head == tail 时,可能既表示队列为空,也可能表示队列已满,这会导致无法区分这两种状态。举例说明: 假设 Cap = 5,那么数组大小为 max_size = Cap + 1 = 6。状态如下: 初始状态(空队列) bash [_, _, _, _, _, _] head = 0 tail = 0 队列添加 1 个元素(满队列) mathematica [A, _, _, _, _, _] head = 0 tail = 1 队列添加 5 个元素(满队列) mathematica [A, B, C, D, E, _] head = 0 tail = 5 此时,(tail + 1) % max_size == head,表示队列已满。 队列删除 1 个元素 mathematica [_, B, C, D, E, _] head = 1 tail = 5 此时,head != tail,队列不为空。 若尾指针在队尾(5),当删除一个元素再加入一个元素时,尾指针会重置来到 0,此时(0 + 1)% 6 == 1,满队列。 此外,需要说的是析构函数: ~CircularQueLk() { //循环销毁 std::lock_guard<std::mutex> lock(_mtx); //调用内部元素的析构函数 while (_head != _tail) { std::allocator::destroy(_data + _head); _head = (_head+1)%_max_size; } //调用回收操作 std::allocator::deallocate(_data, _max_size);} std::allocator的destroy方法用于调用指向的元素的析构函数,这里通过while函数调用队列中所有元素的析构函数(如果T是基本类型比如 int,那么销毁操作不会有实际效果); std::allocator的deallocate方法用于释放通过std::allocator::allocate分配的内存块。这仅回收内存,不会调用元素的析构函数,因此需要先在循环中显式销毁每个元素。 最后需要注意的一点是,再pop函数中,有这么一行代码:val = std::move(_data[_head]),其中,val 是一个T&类型的变量,而std::move返回的类型其实是一个右值引用,我们可以将右值引用赋值给一个左值引用,因为右值引用可以隐式转换为左值引用。但我们不能将一个右值赋值给一个左值引用,那是不合法的。 3.2 实现无锁环形队列(有缺陷) 接下来我们通过原子类型以及内存次序取代其他同步方法实现线程安全的环形队列,该队列是无锁并发的。代码如下: template<typename T, size_t Cap>class CircularQueSeq :private std::allocator{public: // 默认构造函数,为 _data 指针分配能容纳 _max_size 个 _data 类型的连续内存块 CircularQueSeq() :_max_size(Cap + 1), _data(std::allocator::allocate(_max_size)), _atomic_using(false),_head(0), _tail(0) {} CircularQueSeq(const CircularQueSeq&) = delete; CircularQueSeq& operator = (const CircularQueSeq&) volatile = delete; CircularQueSeq& operator = (const CircularQueSeq&) = delete; ~CircularQueSeq() { //循环销毁 bool use_expected = false; bool use_desired = true; do { use_expected = false; use_desired = true; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); //调用内部元素的析构函数 while (_head != _tail) { std::allocator::destroy(_data + _head); _head = (_head+1)% _max_size; } //调用回收操作 std::allocator::deallocate(_data, _max_size); do { use_expected = true; use_desired = false; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); } //先实现一个可变参数列表版本的插入函数最为基准函数 template <typename ...Args> bool emplace(Args && ... args) { bool use_expected = false; bool use_desired = true; do { use_expected = false; use_desired = true; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); //判断队列是否满了 if ((_tail + 1) % _max_size == _head) { std::cout << "circular que full ! " << std::endl; do { use_expected = true; use_desired = false; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); return false; } //在尾部位置构造一个T类型的对象,构造参数为args... std::allocator::construct(_data + _tail, std::forward(args)...); //更新尾部元素位置 _tail = (_tail + 1) % _max_size; do { use_expected = true; use_desired = false; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); return true; } //push 实现两个版本,一个接受左值引用,一个接受右值引用 //接受左值引用版本 bool push(const T& val) { std::cout << "called push const T& version" << std::endl; return emplace(val); } //接受右值引用版本,当然也可以接受左值引用,T&&为万能引用 // 但是因为我们实现了const T& bool push(T&& val) { std::cout << "called push T&& version" << std::endl; return emplace(std::move(val)); } //出队函数 bool pop(T& val) { bool use_expected = false; bool use_desired = true; do { use_desired = true; use_expected = false; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); //判断头部和尾部指针是否重合,如果重合则队列为空 if (_head == _tail) { std::cout << "circular que empty ! " << std::endl; do { use_expected = true; use_desired = false; } while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); return false; } //取出头部指针指向的数据 val = std::move(_data[_head]); //更新头部指针 _head = (_head + 1) % _max_size; do { use_expected = true; use_desired = false; }while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); return true; }private: size_t _max_size; T* _data; std::atomic<bool> _atomic_using; // 使用原子变量代替互斥 size_t _head = 0; size_t _tail = 0;}; 实现过程其实大差不差,只不过使用原子操作将使用锁的部分代替,而且相比锁的实现,无锁代码更加复杂一些。在这里,我们使用类型为std::atomic<bool>的变量代替了有锁版本的的成员变量std::mutex,这是为了使用自旋锁的思路将锁替换为原子变量循环检测的方式,接下来分析一下需要关注的成员函数。 a. 析构函数 bool use_expected = false;bool use_desired = true;do{ use_expected = false; use_desired = true;}while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); 第一个循环通过将标志位 _atomic_using 置为true确保当前线程独占,防止多个线程同时销毁资源。 _atomic_using 在构造时被初始化为false,所以使用第一个do-while时,会将_atomic_using 置为true,表示当前线程独占,只有当前线程可以销毁资源。 第一个循环执行完后,开始销毁资源,步骤和有锁环形队列相同,就不再过多叙述。 do{ use_expected = true; use_desired = false;}while (!_atomic_using.compare_exchange_strong(use_expected, use_desired)); 当执行完资源销毁步骤后,执行第二个do-while循环,将_atomic_using置为false,表示当前线程释放对 _atomic_using 的独占访问权,将其设置为未使用状态。 b. 其他成员函数 其他成员函数中,也使用第一个循环和第二个循环代替锁,实现同步机制,就不继续说明了。只需记住,第一个do-while循环相当于加锁,第二个do-while循环相当于解锁,可以理解为是一个没有RAII回收机制的unique_ptr。 3.3 实现无锁环形队列(无缺陷) 虽然通过单个原子变量实现了一个线程安全的环形队列,但是也有弊端: 因为仅有一个线程能独占atomic_using,所有多个线程执行相同的操作时,比如pop,有且仅有一个线程可以获得atomic_using的独占权从而执行,而其他线程会陷入终而复始的等待中。而循环无疑是对CPU资源的浪费,可能会造成其他线程的“受饿”情况,即某个线程被执行无锁操作的线程抢占CPU资源(频繁的自旋重试会造成CPU资源的浪费),自身只分配到极少的执行时间,甚至完全没有,运行几乎停滞或完全停滞。 所以我们可以考虑使用多个原子变量将上述操作优化: 在环形队列的多线程使用中,写入数据的关键在于指针的移动,而不是数据本身的写入。由于不同线程写入的数据位置由指针决定,只要指针的更新是安全的,各线程写入的内存区域就不会冲突。因此,写入操作可以并发进行,无需额外保护。我们只需通过原子操作确保指针的加减是安全的,避免多线程竞争导致状态不一致。这样,数据写入过程是独立的,而指针的原子更新则保证了队列操作的整体正确性和线程安全性。 CircularQueLight():_max_size(Cap + 1), _data(std::allocator::allocate(_max_size)), _head(0), _tail(0) {}private: size_t _max_size; T* _data; std::atomic<size_t> _head; std::atomic<size_t> _tail; 将无锁版本的私有成员变量修改为上述四个,无需使用_atomic_using来模仿自旋锁的操作,直接将头指针和尾指针的类型换为原子类型,我们只需原子操作确保指针的加减是安全的即可。 3.3.1 pop函数 我们先实现简单的pop: // 线程安全的pop实现bool pop(T& val) { size_t h; do { h = _head.load(); //1 处 //判断头部和尾部指针是否重合,如果重合则队列为空 if(h == _tail.load()) { return false; } val = _data[h]; // 2处 } while (!_head.compare_exchange_strong(h, (h+1)% _max_size)); //3 处 return true;} 在pop函数中,我们在 1 处load获取头部head的值,在 2 处采用了复制的方式将头部元素取出赋值给val,而不是通过std::move,因为多个线程同时pop最后只有一个线程成功执行 3 处代码退出,而失败的则需要继续循环,从更新后的head处pop元素。所以不能用std::move,否则会破坏原有的队列数据。最后,判断当前线程持有的h值和头指针是否相同,如果相同则+1,反之重新循环pop。可能不好理解,我这里详细解释一下: 为什么不能使用 std::move? 在 pop 函数中,多个线程可能同时尝试从队列中弹出元素(而在锁或者自旋锁的保护下,仅有一个线程pop),但最终只有一个线程能够成功更新_head指针。对于未成功更新指针的线程,它们需要重新获取最新的_head值,并从新的位置继续尝试弹出。 如果在2 处使用std::move,会将队列中当前_head指针指向位置的数据转移(move)到val中,这会破坏队列中该位置的数据。结果是,当其他线程在失败后重新尝试弹出时,该位置的数据可能已经被破坏(变为空的、无效的状态),导致数据丢失或逻辑错误。 为什么最终只有一个线程成功? 弹出操作依赖于 compare_exchange_strong 来更新 _head 指针,而这是一个原子操作: 只有当 _head 的当前值等于期望值(即线程读取的 h)时,才能成功将 _head 更新为新值。 如果某个线程在尝试更新 _head 时,发现 _head 已经被其他线程更新,则说明该线程失败,必须重新尝试。 这意味着,在并发环境下,尽管多个线程可以同时尝试 pop,最终只有一个线程能成功更新 _head 并退出循环,其他线程必须重新获取新的 _head 并继续尝试。 3.3.2 push函数 // 存在线程安全的 push 实现bool push(T& val){ size_t t; do { t = _tail.load(); //1 //判断队列是否满 if( (t+1)%_max_size == _head.load()) { return false; } _data[t] = val; //2 } while (!_tail.compare_exchange_strong(t, (t + 1) % _max_size)); //3 return true;} 在 push 函数中,逻辑和pop函数差不多,都是多个线程可能同时push数据,但最终只有一个线程能push进入,而其他线程重新循环重新push。过程虽然差不多,但是push的实现其实存在线程安全问题: 比如线程1 push(1) 而线程2 push(2),很有可能的顺序是,线程1走到了 2 处将data[t]成功写入了1,线程2晚一点走到了 2 处将data[t]修改为了2, 因为两个线程是同时执行的,所以此时尾指针的值还未被修改,如果线程1先一步修改尾指针,虽然能成功修改,但是内存中的值并不是线程1想要的1,而是2。流程为:1.1 -> 1.2 -> 2.1 -> 2.2 -> 1.3 这样我们看到的效果就是_data[t]被存储为2了,而实际情况应该是被存储为1,因为线程1的原子变量生效,而线程2的原子变量不满足需继续循环。我们需要想办法把_data[t]修改为1,重新优化push函数: bool push(T& val){ size_t t; do { t = _tail.load(); //1 //判断队列是否满 if( (t+1)%_max_size == _head.load()) { return false; } } while (!_tail.compare_exchange_strong(t, (t + 1) % _max_size)); //3 _data[t] = val; //2 return true;} 在该版本push函数中,我们先更新指针然后再修改内容,这样能保证多个线程push,仅有一个线程生效时,它写入的数据一定是本线程要写入到tail的数据,而此时tail被缓存在t里,那是一个线程本地变量,所以在这种情况下我们能确定即使多个线程运行到2处,他们的t值也是不同的,并不会产生上面所说的线程安全问题。 但是这种push操作仍然会有其他安全问题: 因为我们是先修改指针,后修改内存的内容,但如果我们更新完指针,在执行 2 处写操作未完成的时候,其他线程调用了pop函数,那么此时读到的值并不是更新后的值(写操作还未完成),而是该片内存原本的值。 我们理解中的同步应该是读操作能读到写操作更新后的值,而不是更新前的值,我们可以增加一个原子变量_tail_update来标记尾部数据是否修改完毕,如果没有修改完毕,此时其他线程pop获取的数据是不安全的,pop返回false。 3.3.3 优化后的pop和push函数 bool push(const T& val){ size_t t; do { t = _tail.load(); //1 //判断队列是否满 if( (t+1)%_max_size == _head.load()) { return false; } } while (!_tail.compare_exchange_strong(t, (t + 1) % _max_size)); //3 _data[t] = val; //2 // 数据成功写入之后更新tailup的值 size_t tailup; do { tailup = t; } while (_tail_update.compare_exchange_strong(tailup, (tailup + 1) % _max_size)); return true;} bool pop(T& val) { size_t h; do { h = _head.load(); //1 处 //判断头部和尾部指针是否重合,如果重合则队列为空 if(h == _tail.load()) { return false; } //判断如果此时要读取的数据和tail_update是否一致,如果一致说明尾部数据未更新完 if(h == _tail_update.load()) { return false; } val = _data[h]; // 2处 } while (!_head.compare_exchange_strong(h, (h+1)% _max_size)); //3 处 return true;} 因为当前线程执行pop和push获得的h和t都是一个固定值不会改变,改变的只是head指针和tail指针,所以当数据成功写入后,我们可以在push函数中增加一个do-while循环更新tail_update的值(将tail_update指向tail更新后的位置),表示指向已完成写入的最新位置。 而在pop函数中,如果 pop 发现 _head 与 _tail_update 相同_tail_update仍然指向tail指针的上一个位置(数据刚开始存储时,首尾指针均为0),还没有更新,说明此位置的数据尚未写入完成,因此数据是不安全的,pop 应返回 false。 我们模拟一下二者的执行流程: 在 push 中: _tail 先移动,表示分配位置。 数据写入完成后,再更新 _tail_update,标记此位置的数据可用。 在 pop 中: 检查 _tail_update,如果 _head == _tail_update,说明当前位置的数据尚未写入完成,pop 返回 false。 只有 _tail_update 超过 _head 时,才能安全读取队列数据。 我们学习了内存序之后知道,原子操作的默认内存序是先后一致次序memory_order_seq_cst,它能保证所有线程对变量操作的顺序观察一致,但是性能消耗过大,我们可以将先后一致内存模型替换为其他内存序,pop函数的实现如下: bool pop(T& val) { size_t h; do { h = _head.load(std::memory_order_relaxed); //1 处 //判断头部和尾部指针是否重合,如果重合则队列为空 if (h == _tail.load(std::memory_order_acquire)) //2处 { std::cout << "circular que empty ! " << std::endl; return false; } //判断如果此时要读取的数据和tail_update是否一致,如果一致说明尾部数据未更新完 if (h == _tail_update.load(std::memory_order_acquire)) //3处 { return false; } val = _data[h]; } while (!_head.compare_exchange_strong(h, (h + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //4 处 std::cout << "pop data success, data is " << val << std::endl; return true;} 1 处,使用了 memory_order_relaxed,这是因为对于 head 指针的加载,我们并不关心线程之间是否有同步需求,除了需要读取最新的 head 值。这里的目的是获取队列头部的索引,以便判断队列是否为空以及获取数据。由于 memory_order_relaxed 不强制同步,所以多个线程并不会相互等待,也不需要保证加载的 head 值和其他操作的顺序关系。这里使用 relaxed 只是为了提高效率,因为队列中有可能会多次重试。 2 处,当从队列中取数据时,需要保证 head 和 tail 指针的同步性。为了确保在读取队列头部元素之前,tail 指针已经正确更新,我们需要使用 memory_order_acquire。这个内存顺序会使得当前线程等待之前的操作完成,从而确保 tail 指针在当前线程读取之前是最新的。 3 处,再次使用 memory_order_acquire 来确保尾部数据的更新已经完成。通过检查 tail_update,你可以确保队列的尾部元素已完全更新并可供当前线程读取。这里的同步逻辑与 _tail` 相同,确保队列的状态对其他线程是正确同步的。如果尾部尚未更新,当前线程将继续重试,确保不会读取到不一致的状态。 4 处, 使用了两个内存顺序:memory_order_release 和memory_order_relaxed。这是因为 compare_exchange_strong 涉及到读改写,可以使用两种内存序: memory_order_release 用于确保在更新 head 指针之前,所有对队列的写操作(如 val = _data[h])对其他线程可见。这保证了在 head 更新之后,其他线程会看到正确的数据。 memory_order_relaxed 用于在比较失败时,提升效率,因为在期望条件不匹配时无需进行同步。此时,当前线程会重试,依然不需要等待其他线程完成工作,因此使用 relaxed 来减少同步开销。 push 函数的实现如下: bool push(const T& val){ size_t t; do { t = _tail.load(std::memory_order_relaxed); //1 //判断队列是否满 if ((t + 1) % _max_size == _head.load(std::memory_order_acquire)) // 2 { std::cout << "circular que full ! " << std::endl; return false; } } while (!_tail.compare_exchange_strong(t, (t + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //3 _data[t] = val; size_t tailup; do { tailup = t; } while (_tail_update.compare_exchange_strong(tailup, (tailup + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //4 std::cout << "called push data success " << val << std::endl; return true;} 1 处,读取该数据时不需要进行线程的同步,所以使用最节省资源的memory_order_relaxed内存序。 2 处,使用 memory_order_acquire 加载 head 指针,确保在进行满队列检查时,头部指针已经同步更新。 3处,使用compare_exchange_strong来尝试更新尾部指针tail。如果tail指针未被其他线程修改,当前线程会成功更新tail指针并进入push操作。如果tail指针已经被其他线程修改,当前线程会重新读取新的tail值,并继续尝试更新。 memory_order_release: 这个内存顺序保证了在更新 tail 之前,当前线程对队列的修改对其他线程是可见的。 memory_order_relaxed: 如果 compare_exchange_strong 操作失败,即尾部指针的预期值与实际值不符,那么当前线程会重试。这时,使用relaxed可以避免同步操作的开销,减少不必要的内存屏障。 4 处, _tail_update的更新同样使用了memory_order_release和memory_order_relaxed内存序,理由同上。 3.3.4 完整代码 #pragma once#include #include template<typename T, size_t Cap>class CircularQueSync : private std::allocator{public: CircularQueSync() :_max_size(Cap + 1), _data(std::allocator::allocate(_max_size)) , _head(0), _tail(0), _tail_update(0) {} CircularQueSync(const CircularQueSync&) = delete; CircularQueSync& operator = (const CircularQueSync&) volatile = delete; CircularQueSync& operator = (const CircularQueSync&) = delete; ~CircularQueSync() { //调用内部元素的析构函数 while (_head != _tail) { std::allocator::destroy(_data + _head); _head = (++_head)%_max_size; } //调用回收操作 std::allocator::deallocate(_data, _max_size); } //出队函数 bool pop(T& val) { size_t h; do { h = _head.load(std::memory_order_relaxed); //1 处 //判断头部和尾部指针是否重合,如果重合则队列为空 if (h == _tail.load(std::memory_order_acquire)) //2处 { std::cout << "circular que empty ! " << std::endl; return false; } //判断如果此时要读取的数据和tail_update是否一致,如果一致说明尾部数据未更新完 if (h == _tail_update.load(std::memory_order_acquire)) //3处 { return false; } val = _data[h]; } while (!_head.compare_exchange_strong(h, (h + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //4 处 std::cout << "pop data success, data is " << val << std::endl; return true; } bool push(const T& val){ size_t t; do { t = _tail.load(std::memory_order_relaxed); //1 //判断队列是否满 if ((t + 1) % _max_size == _head.load(std::memory_order_acquire)) // 2 { std::cout << "circular que full ! " << std::endl; return false; } } while (!_tail.compare_exchange_strong(t, (t + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //3 _data[t] = val; size_t tailup; do { tailup = t; } while (_tail_update.compare_exchange_strong(tailup, (tailup + 1) % _max_size, std::memory_order_release, std::memory_order_relaxed)); //4 std::cout << "called push data success " << val << std::endl; return true; } private: size_t _max_size; T* _data; std::atomic<size_t> _head; std::atomic<size_t> _tail; std::atomic<size_t> _tail_update;}; 4.无锁环形并发队列的优缺点 优点: 由于使用了原子操作和自旋重试机制,这种设计避免了传统的锁机制,因此能够实现高并发。每个线程在修改队列指针时(如 push 或 pop)不会进行阻塞等待,而是通过原子操作保证数据一致性。 自旋重试:在 push 或 pop 操作中,如果指针未能成功更新(例如,因为另一个线程修改了指针),线程会重试直到成功。这种方式在并发较低的情况下非常高效,但对于高并发的场景可能会带来额外的开销。 操作独立性:push 和 pop 操作是独立的,它们之间没有冲突。因此,push 与 pop 操作可以并发执行,互不干扰。只有当多个线程同时进行 push 或 pop 时,才可能导致自旋重试。 与传统的锁机制相比(如互斥锁),无锁机制通过原子操作和内存模型的控制来保证并发访问时的线程安全,而不需要通过上下文切换或阻塞来管理线程。这样可以避免锁竞争带来的性能下降。 缺点: 当队列存储的是类对象时,多个push线程可能只有一个线程会成功插入数据,而其他线程则会因为重试而浪费时间。这是因为每次重试时,push线程仍然会尝试拷贝类对象到队列中,而拷贝构造函数的调用会增加开销。尤其是当类对象比较复杂时,这种重复的拷贝开销可能会对性能造成显著影响。所以我们一般使用该方式存储标量而不应该存储类对象。 如果多个线程频繁并发进行 push 操作,重试机制可能导致每个线程都反复读取、判断和更新队列指针,这样虽然能够保证数据一致性,但会消耗大量 CPU 资源。尤其在高并发情况下,如果队列的插入操作频繁失败并重试,这种开销可能会成为瓶颈。所以我们应该尽量让push和pop并发,而不是多线程并发push。 为什么当任务执行时间比较长的时候,不适合用无锁队列? 无锁队列通常通过原子操作来保证线程安全,在并发环境中保证数据的一致性。但是,原子操作通常是在忙等待(自旋)模式下执行的。当任务执行时间较长时,如果线程长时间占用 CPU 资源进行无锁操作,它可能会导致其他线程的性能下降,甚至引发资源争用。尤其是在任务复杂且需要较多计算的场景下,长时间自旋会导致系统负载过重,影响整个系统的响应性。 因为原子操作相当于自旋重试,如果无锁操作执行时间过长,有可能会导致某一个线程处于“受饿”状态,即某个线程被执行无锁操作的线程抢占CPU资源(频繁的自旋重试会造成CPU资源的浪费),自身只分配到极少的执行时间,甚至完全没有,运行几乎停滞或完全停滞。 无锁队列在短时间、高并发、低延迟的任务场景下表现优秀,但在任务执行时间较长的情况下,使用无锁队列会导致 CPU 资源浪费、过度的自旋等待以及频繁的上下文切换。对于长时间执行的任务,使用带锁的队列是更合适的选择,因为它能有效避免这些问题。 在无锁队列中,当线程在等待队列操作完成时,如果操作需要较长时间处理,线程可能会一直进行自旋等待(即循环尝试获取队列操作的锁)。如果任务执行时间较长,线程就会频繁地进行自旋,导致 CPU 资源的浪费。相反,如果使用带锁或者条件变量的队列,线程可以在等待时挂起进入阻塞状态,释放 CPU 资源,其他线程可以继续运行。

    01-14 61浏览
  • 用状态机在STM32不同按键方式的应用

    常见的按键判定程序,如正点原子按键例程,只能判定单击事件,对于双击、长按等的判定逻辑较复杂,且使用main函数循环扫描的方式,容易被阻塞,或按键扫描函数会阻塞其他程序的执行。 使用定时器设计状态机可以规避这一问题。 功能介绍 本程序功能: 使用定时器状态机实现按键单击、双击、长按、连按功能。 消抖时间可调,长按时间可调,双击判定时间可调,连按单击间隔可调,可选择使能长按、连按、双击功能,无延时不阻塞,稳定触发。 移植只需修改读IO函数,结构体初始化和宏定义时间参数即可。 注: 在定时器状态机判定产生事件标志,在主函数处理并清除事件标志。 单击是最基本事件,除以下情况外,经过消抖后,在按键释放时触发单击事件。 使能长按后,若按键按下时间大于长按判定时间,则释放时触发长按事件,若不使能,释放时触发单击事件。 使能连按后,按住按键时持续触发连按事件,可自定义等效为单击事件。 无论是否使能长按,按键长按不释放,先经过长按判定时间触发第一次连按事件,然后循环进行连按计时,每次计时结束后都会触发一次连按事件,直到按键释放,触发长按事件(使能长按),或单击事件(不使能长按)。 使能双击后,若两次单击行为之间,由释放到按下的时间小于双击判定时间,则第一次单击行为释放时不触发单击事件,第二次单击行为在释放时触发双击事件。 一次单击行为在双击判定时间内无按键按下动作,之后才触发单击事件。 无论是否使能长按,若上述第二次行为是长按,则第二次释放时不会触发双击事件,而是到达长按判定时间后先触发属于第一次的单击事件,然后在第二次释放按键时触发长按事件(使能长按),或单击事件(不使能长按)。 代码 头文件 my_key.h #ifndef ___MY_KEY_H__#define ___MY_KEY_H__#include "main.h"#define ARR_LEN(arr) ((sizeof(arr)) / (sizeof(arr[0]))) //数组大小宏函数 #define KEY_DEBOUNCE_TIME 10 //消抖时间#define KEY_LONG_PRESS_TIME 500 //长按判定时间#define KEY_QUICK_CLICK_TIME 100 //连按时间间隔#define KEY_DOUBLE_CLICK_TIME 200 //双击判定时间#define KEY_PRESSED_LEVEL 0 //按键被按下时的电平 //按键动作typedef enum{ KEY_Action_Press, //按住 KEY_Action_Release, //松开} KEY_Action_TypeDef; //按键状态typedef enum{ KEY_Status_Idle, //空闲 KEY_Status_Debounce, //消抖 KEY_Status_ConfirmPress, //确认按下 KEY_Status_ConfirmPressLong, //确认长按 KEY_Status_WaitSecondPress, //等待再次按下 KEY_Status_SecondDebounce, //再次消抖 KEY_Status_SecondPress, //再次按下} KEY_Status_TypeDef; //按键事件typedef enum{ KEY_Event_Null, //空事件 KEY_Event_SingleClick, //单击 KEY_Event_LongPress, //长按 KEY_Event_QuickClick, //连击 KEY_Event_DoubleClick, //双击} KEY_Event_TypeDef; //按键模式使能选择typedef enum{ KEY_Mode_OnlySinge = 0x00, //只有单击 KEY_Mode_Long = 0x01, //单击长按 KEY_Mode_Quick = 0x02, //单击连按 KEY_Mode_Long_Quick = 0x03, //单击长按连按 KEY_Mode_Double = 0x04, //单击双击 KEY_Mode_Long_Double = 0x05, //单击长按双击 KEY_Mode_Quick_Double = 0x06, //单击连按双击 KEY_Mode_Long_Quick_Double = 0x07, //单击长按连按双击} KEY_Mode_TypeDef; //按键配置typedef struct{ uint8_t KEY_Label; //按键标号 KEY_Mode_TypeDef KEY_Mode; //按键模式 uint16_t KEY_Count; //按键按下计时 KEY_Action_TypeDef KEY_Action; //按键动作,按下或释放 KEY_Status_TypeDef KEY_Status; //按键状态 KEY_Event_TypeDef KEY_Event; //按键事件} KEY_Configure_TypeDef; extern KEY_Configure_TypeDef KeyConfig[];extern KEY_Event_TypeDef key_event[]; void KEY_ReadStateMachine(KEY_Configure_TypeDef *KeyCfg); #endif ‍源文件 my_key.c #include "my_key.h" static uint8_t KEY_ReadPin(uint8_t key_label){ switch (key_label) { case 0: return (uint8_t)HAL_GPIO_ReadPin(K0_GPIO_Port, K0_Pin); case 1: return (uint8_t)HAL_GPIO_ReadPin(K1_GPIO_Port, K1_Pin); case 2: return (uint8_t)HAL_GPIO_ReadPin(K2_GPIO_Port, K2_Pin); case 3: return (uint8_t)HAL_GPIO_ReadPin(K3_GPIO_Port, K3_Pin); case 4: return (uint8_t)HAL_GPIO_ReadPin(K4_GPIO_Port, K4_Pin); // case X: // return (uint8_t)HAL_GPIO_ReadPin(KX_GPIO_Port, KX_Pin); } return 0;} KEY_Configure_TypeDef KeyConfig[] = { {0, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {1, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {2, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {3, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, {4, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null}, // {X, KEY_Mode_Long_Quick_Double, 0, KEY_Action_Release, KEY_Status_Idle, KEY_Event_Null},}; KEY_Event_TypeDef key_event[ARR_LEN(KeyConfig)] = {KEY_Event_Null}; //按键事件//按键状态处理void KEY_ReadStateMachine(KEY_Configure_TypeDef *KeyCfg){ static uint16_t tmpcnt[ARR_LEN(KeyConfig)] = {0}; //按键动作读取 if (KEY_ReadPin(KeyCfg->KEY_Label) == KEY_PRESSED_LEVEL) KeyCfg->KEY_Action = KEY_Action_Press; else KeyCfg->KEY_Action = KEY_Action_Release; //状态机 switch (KeyCfg->KEY_Status) { //状态:空闲 case KEY_Status_Idle: if (KeyCfg->KEY_Action == KEY_Action_Press) //动作:按下 { KeyCfg->KEY_Status = KEY_Status_Debounce; //状态->消抖 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:默认动作,释放 { KeyCfg->KEY_Status = KEY_Status_Idle; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:消抖 case KEY_Status_Debounce: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPress; //状态->确认按下 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间未到 { KeyCfg->KEY_Count++; //消抖计数 KeyCfg->KEY_Status = KEY_Status_Debounce; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:释放,消抖时间未到,判定为抖动 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:确认按下 case KEY_Status_ConfirmPress: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间已到 { KeyCfg->KEY_Count = KEY_QUICK_CLICK_TIME; //计数置数,生成第一次连按事件 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->确认长按 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间未到 { KeyCfg->KEY_Count++; //长按计数 KeyCfg->KEY_Status = KEY_Status_ConfirmPress; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:长按时间未到,释放 { if ((uint8_t)(KeyCfg->KEY_Mode) & 0x04) //双击模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_WaitSecondPress; //状态->等待再按 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //非双击模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->单击**** } } break; //状态:确认长按 case KEY_Status_ConfirmPressLong: if (KeyCfg->KEY_Action == KEY_Action_Press) //动作:保持按下 { if ((uint8_t)KeyCfg->KEY_Mode & 0x02) //连按模式 { if (KeyCfg->KEY_Count >= KEY_QUICK_CLICK_TIME) //连按间隔时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->维持 KeyCfg->KEY_Event = KEY_Event_QuickClick; //事件->连按**** } else //连按间隔时间未到 { KeyCfg->KEY_Count++; //连按计数 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } } else //非连按模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } } else //动作:长按下后释放 { if ((uint8_t)KeyCfg->KEY_Mode & 0x01) //长按模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_LongPress; //事件->长按**** } else //非长按模式 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->单击**** } } break; //状态:等待是否再次按下 case KEY_Status_WaitSecondPress: if ((KeyCfg->KEY_Action != KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_DOUBLE_CLICK_TIME)) //动作:保持释放,双击等待时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->单击**** } else if ((KeyCfg->KEY_Action != KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_DOUBLE_CLICK_TIME)) //动作:保持释放,双击等待时间未到 { KeyCfg->KEY_Count++; //双击等待计数 KeyCfg->KEY_Status = KEY_Status_WaitSecondPress; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:双击等待时间内,再次按下 { tmpcnt[KeyCfg->KEY_Label] = KeyCfg->KEY_Count; //计数保存 KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_SecondDebounce; //状态->再次消抖 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:再次消抖 case KEY_Status_SecondDebounce: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_SecondPress; //状态->确认再次按下 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_DEBOUNCE_TIME)) //动作:保持按下,消抖时间未到 { KeyCfg->KEY_Count++; //消抖计数 KeyCfg->KEY_Status = KEY_Status_SecondDebounce; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:释放,消抖时间未到,判定为抖动 { KeyCfg->KEY_Count = KeyCfg->KEY_Count + tmpcnt[KeyCfg->KEY_Label]; //计数置数 KeyCfg->KEY_Status = KEY_Status_WaitSecondPress; //状态->等待再按 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } break; //状态:再次按下 case KEY_Status_SecondPress: if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count >= KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间已到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_ConfirmPressLong; //状态->确认长按 KeyCfg->KEY_Event = KEY_Event_SingleClick; //事件->先响应单击 } else if ((KeyCfg->KEY_Action == KEY_Action_Press) && (KeyCfg->KEY_Count < KEY_LONG_PRESS_TIME)) //动作:保持按下,长按时间未到 { KeyCfg->KEY_Count++; //计数 KeyCfg->KEY_Status = KEY_Status_SecondPress; //状态->维持 KeyCfg->KEY_Event = KEY_Event_Null; //事件->无 } else //动作:释放,长按时间未到 { KeyCfg->KEY_Count = 0; //计数清零 KeyCfg->KEY_Status = KEY_Status_Idle; //状态->空闲 KeyCfg->KEY_Event = KEY_Event_DoubleClick; //事件->双击 } break; } if (KeyCfg->KEY_Event != KEY_Event_Null) //事件记录 key_event[KeyCfg->KEY_Label] = KeyCfg->KEY_Event;} 定时器中断调用和主函数使用 中断周期为1ms //调用uint32_t tim_cnt = 0;void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){ if (htim->Instance == htim1.Instance) { tim_cnt++; if (tim_cnt % 1 == 0) // 1ms { KEY_ReadStateMachine(&KeyConfig[0]); KEY_ReadStateMachine(&KeyConfig[1]); KEY_ReadStateMachine(&KeyConfig[2]); KEY_ReadStateMachine(&KeyConfig[3]); KEY_ReadStateMachine(&KeyConfig[4]); } }} int main(void){ while (1) { if (key_event[1] == KEY_Event_SingleClick) //单击 { something1(); } if (key_event[2] == KEY_Event_LongPress) //长按 { something2(); } if ((key_event[3] == KEY_Event_QuickClick) || (key_event[3] == KEY_Event_SingleClick)) //连按 { something3(); } if (key_event[4] == KEY_Event_DoubleClick) //双击 { something4(); } memset(key_event, KEY_Event_Null, sizeof(key_event)); //清除事件 }}

    01-09 127浏览
  • 真心不粗!适合练手的13个C++开源项目

    1 C++ 那些事 这是一个适合初学者从入门到进阶的仓库,解决了面试者与学习者想要深入 C++及如何入坑 C++的问题。 除此之外,本仓库拓展了更加深入的源码分析,多线程并发等的知识,是一个比较全面的      C++ 学习从入门到进阶提升的仓库。 项目地址:https://github.com/Light-City/CPlusPlusThings 2 C++实现的各种算法的开源实现的集合 这个存储库是C++实现的各种算法的开源实现的集合,算法涵盖了计算机科学、数学和统计学、数据科学、机器学习、工程等领域的各种主题。 这些实现和相关文档旨在为教育者和学生提供学习资源。因此,对于同一个目标,可以找到多个实现,但使用不同的算法策略和优化。 开源地址:https://github.com/TheAlgorithms/C-Plus-Plus 3 C++ 实现的截图软件 Demo 仿 QQ 截图,C++ 实现的截图软件 Demo。 项目地址:https://github.com/wanttobeno/Screenshot 4 基于 C++ 实现的 HTTP 服务器 一款可运行的基于 C++ 实现的 HTTP 服务器,基于《TCPIP网络编程》和《Linux高性能服务器编程》实现的服务器项目。 项目地址:https://github.com/forthespada/MyPoorWebServer 5 WebFileServer文件服务器 不少同学学完C++和Linux后不知道做什么项目,所以很多同学都去做webserver,其实大家可以改进下webserver项目,比如实现一个文件服务器支持文件上传下载,后续可以再添加注册/登录/个人文件管理/文件分享等等功能,这样就可以写到简历里。 项目地址:https://www.bilibili.com/video/BV1bGkPYzExW/ 6 用于 C++ 的图形用户界面库 Dear ImGui 是一个用于 C++ 的无膨胀图形用户界面库,它输出优化的顶点缓冲区,你可以在启用的 3D 应用程序中随时渲染这些缓冲区,特别适合集成到游戏引擎(用于工具)、实时 3D 应用程序、全屏应用程序、嵌入式应用程序或操作系统功能非标准控制台上的任何应用程序中。 项目地址:https://github.com/ocornut/imgui Dear ImGui 的核心是独立的,不需要特定的构建过程,你可以将 .cpp 文件添加到现有项目中。 ImGui::Text("Hello, world %d", 123); if (ImGui::Button("Save")) MySaveFunction(); ImGui::InputText("string", buf, IM_ARRAYSIZE(buf)); ImGui::SliderFloat("float", &f, 0.0f, 1.0f); Result:深色风格(左),浅色风格(右)/字体:Roboto-Medium,16px 调用 ImGui::ShowDemoWindow() 函数将创建一个展示各种功能和示例的演示窗口 7  仿微信聊天软件--QT客户端+Linux C++后端 这个项目类似微信一样,可以加好友,可以一对一聊天,也可以群聊,并且还支持Linux C++后端程序。 项目地址:https://www.bilibili.com/video/BV1XukbYmEY5/ 8 手撸STL STL是C++的重要组件,C++开发几乎没有不使用STL的,然而光会用是不够的,还需要明白它的实现原理。 智能指针 vector array stack queue deque map set string 这些常用的数据结构最好自己都实现一遍。 水平高的可以直接参考gcc源码(https://github.com/gcc-mirror/gcc) 刚入门的朋友不建议看源码,费时费力又不能提升开发能力,这里推荐大家看看这份C++ STL面试题,包含STL中不同容器的实现原理。 地址:https://www.bilibili.com/video/BV1Yoz2YZEgV/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 9 手撸Json Json是特别常用的序列化数据结构(https://tech.meituan.com/2015/02/26/serialization-vs-deserialization.html) 很多人面试的时候被问到过如何实现一个Json。大家可以通过手撸一个Json来提高自己的C++水平哈。 水平高的可以直接参考这个C++Linux项目-Web多人聊天,可以通过该项目掌握MySQL+Redis+Websocket+Json等知识的运用,这个项目还可以根据自己的技术栈进行进一步扩展,形成自己独一无二的项目。 项目地址:https://www.bilibili.com/video/BV1iYtrezEkA/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 10 C++音视频项目--屏幕录制软件 想往音视频开发方向发展的同学可以看看这个项目,这个屏幕录制的项目支持区域录制、全屏录制,支持缩放录制的视频分辨率等 项目地址:https://www.bilibili.com/video/BV1CHChY3EMb/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 11 操作系统 这个在网上有专门的课程,推荐大家看看MIT6.S081课程。课程主要是操作系统的设计与实现,以及它们作为系统编程基础的应用。主要内容包括虚拟内存、文件系统、线程、上下文切换、内核、中断、系统调用、进程间通信、软件与硬件之间的协调与交互等。使用适用于RISC-V架构的多处理器操作系统xv6来说明这些主题。个人实验任务包括扩展xv6操作系统,例如支持复杂的虚拟内存特性和网络功能。 MIT6.S081课程资料:https://www.bilibili.com/video/BV1sUrWYXEJg/?vd_source=c059eb5a3b0ff5a8b664287bf79885e4 12 聊天服务器 smallchat(C实现) 项目简介:smallchat 是一个简单的基于 C 语言实现的聊天服务器和客户端项目。通过这个项目,开发者可以学习和掌握基本的网络编程技术,理解聊天应用程序的核心实现原理。smallchat 项目代码量小,结构清晰,非常适合初学者学习和实践网络编程。 **涉及技术:**C 语言、Socket 编程、多线程编程、网络协议设计与实现、终端控制、非阻塞 I/O 项目亮点: Socket 编程:通过 Socket 编程实现服务器与客户端之间的通信,展示了如何使用 C 语言进行网络编程。 多线程处理:使用多线程技术处理多个客户端连接,展示了并发编程的能力。 基本聊天功能:实现了一个简单的聊天服务器和客户端,包括消息的发送和接收。 简单命令处理:实现基本的命令处理功能,如设置昵称等,展示了如何在聊天应用中处理用户命令。 终端控制:通过设置终端为原始模式,展示了如何控制和处理终端输入。 模块化设计:代码结构清晰,模块化设计,使得项目易于理解和扩展。 源码下载链接:https://github.com/antirez/smallchat 13 RPC 框架 项目简介:实现一个远程过程调用(RPC)框架,使不同主机上的程序能够通过网络调用彼此的函数。这个项目将帮助你掌握网络通信、序列化、多线程编程和协议设计的核心概念,展示你在设计和实现高性能分布式系统方面的能力。 涉及技术:C++、网络编程、序列化/反序列化、多线程编程、协议设计、数据一致性等。 项目亮点: 并发处理:使用多线程技术处理多个客户端请求,展示你在并发编程方面的掌握。 序列化/反序列化:实现高效的数据序列化和反序列化,确保数据在网络传输中的完整性和效率。 协议设计:设计并实现高效的通信协议,确保数据在客户端和服务器之间的高效传输。 数据一致性:确保远程调用的请求和响应在分布式环境下的一致性和可靠性。 分布式架构设计:实现跨主机的远程过程调用,展示你对分布式系统架构的理解和应用能力。 高可用性:通过实现连接池和重试机制,确保RPC服务在网络波动或节点故障时的高可用性。 高性能:优化网络通信和数据处理效率,展示你在高性能系统设计方面的能力。 源码下载链接:https://github.com/Gooddbird/tinyrpc tinyrpc 项目总览: tinyrpc RPC调用执行示意图: 14 分享一些做项目的心得 1. 在Linux环境编写项目: 企业级的项目大多部署在Linux服务器上,所以你得熟悉Linux环境。我推荐使用Ubuntu,并且需要熟练掌握编译工具链如gcc/g++、make和makefile等,这样在编译和部署项目时能游刃有余。 2. 利用已有项目: 不一定要从0到1实现一个项目,这样难度太大(大佬除外)。你可以先把别人优秀的项目下载下来,自己把代码跑起来,配置环境、跑代码、看结果,然后研究别人的代码实现了什么功能、如何实现的,是否可以优化一下,加一些自己的独特思考。这样你就有了丰富的内容可以和面试官聊。 3. 项目实战经验: 举个例子,我曾在简历上展示过一个项目,是在实现HTTP服务器的基础上加了在线大整数运算功能。当时我是从0到1实现了一个MiniMuduo作为服务器框架,并在其基础上实现了HTTP服务器,还参考了Tinyhttpd项目,加入了CGI技术,支持万位以上数字的四则运算。 4. 项目中的思考和优化 在做项目时,一定要有自己的思考。比如,做一个HTTP服务器项目,一定要使用wrk等压测工具进行性能测试,优化其QPS(每秒查询率)。面试官肯定会问很多关于项目的细节问题,比如项目难点、HTTP服务器的性能如何、QPS多少、如何优化提升QPS、性能瓶颈在哪、为什么使用CGI技术、CGI是什么、解决了什么问题等等。

    01-09 151浏览
  • 我彻底服了,大牛讲解信号与系统(通俗易懂)

    我彻底服了,大牛讲解信号与系统(通俗易懂) (2015-10-13 21:22:36) 转载▼ 分类: 电力电子技术 第一课什么是卷积卷积有什么用什么是傅利叶变换什么是拉普拉斯变换 引子 很多朋友和我一样,工科电子类专业,学了一堆...

    01-08 66浏览
  • matlab是什么编程语言

    R 语言 R 是一种用于统计计算和图的语言及环境。它是一个 GNU 项目,与贝尔实验室的 John Chambers 及其同事开发的 S 语言及环境类似。R 可以视为 S 的一种不同实现。二者存在一些重要差异,但使用 S 写的很多代码...

    01-08 57浏览
  • 发送和接收 RTCP 包的规则

    RTCP Packet Send and Receive Rules: 发送和接收 RTCP 包的规则在此列出。允许在多播环境或多点单播环境中运行的实现必须满足第 6.2 节中的要求。这样的实现可以使用本节定义的算法来满足这些要求,或者可以使用其他算法,只要其性能等同或更优即可。在受限于两方单播操作的实现中,仍然应使用 RTCP 传输间隔的随机化,以避免在相同环境中运行的多个实例产生意外的同步,但可以省略第 6.3.3、6.3.6 和 6.3.7 节中的“计时器再考虑(timer reconsideration)”和“反向再考虑(reverse reconsideration)”算法。 为了执行这些规则,会话参与者必须维护几项状态: tp:上一次发送 RTCP 包的时间; tc:当前时间; tn:计划发送下一个 RTCP 包的时间; pmembers:上次重新计算 tn 时估算的会话成员数量; members:当前估算的会话成员数量; senders:当前估算的会话发送者数量; rtcp_bw:RTCP 目标带宽,即该会话所有成员发送 RTCP 包所使用的总带宽,单位为每秒字节数。这个值将是应用启动时提供的“会话带宽”参数的一个指定比例; we_sent:一个标志,如果应用自前两次 RTCP 报告后已发送数据,则该标志为真; avg_rtcp_size:该参与者发送和接收的 RTCP 包的平均复合大小(以字节为单位)。该大小包括较低层传输和网络协议头(如 UDP 和 IP),具体见第 6.2 节的说明; initial:一个标志,如果应用尚未发送任何 RTCP 包,则该标志为真。 这些规则中的许多使用了“计算出的包传输间隔”。此间隔将在接下来的部分中进行描述。 Computing the RTCP Transmission Interval: 为了保持可扩展性,会话参与者发送包的平均间隔应随着组的大小而变化。这个间隔称为“计算出的间隔(calculated interval)”。它是通过结合上面描述的多个状态来获得的。计算出的间隔 TTT 的确定方法如下: 如果发送者的数量小于或等于成员(members)数量的 25%,则间隔取决于该参与者是否为发送者(依据 we_sent 的值)。若参与者为发送者(we_sent 为真),则常数 C 设为平均 RTCP 包大小(avg_rtcp_size)除以 RTCP 带宽(rtcp_bw)的 25%,常数 n 设为发送者的数量。如果 we_sent 为假,常数 C 设为平均 RTCP 包大小除以 RTCP 带宽的 75%,常数 n 设为接收者的数量(members - senders)。如果发送者数量超过 25%,则发送者和接收者一起处理。常数 C 设为平均 RTCP 包大小除以总的 RTCP 带宽,n 设为成员总数。正如第 6.2 节所述,一个 RTP 配置文件可以规定 RTCP 带宽由两个独立的参数明确定义(分别称为 S 和 R),分别用于发送者和非发送者。在这种情况下,25% 的比例为 S/(S+R),75% 的比例为 R/(S+R)。注意,如果 R 为零,则发送者的比例永远不会超过 S/(S+R),实现时必须避免除零的情况。 如果参与者尚未发送任何 RTCP 包(变量 initial 为真),则常数 Tmin 设为 2.5 秒,否则设为 5 秒。 确定性的计算间隔 Td 设为 max(Tmin, n*C)。 计算出的间隔 T 设为 0.5 到 1.5 倍的确定性计算间隔之间的一个均匀分布的数值。 为了补偿计时器再考虑算法使 RTCP 带宽收敛于低于预期平均值的情况,最终得到的 T 值除以 e^-3/2 = 1.21828。 这个过程得出的间隔是随机的,但平均来看,至少 25% 的 RTCP 带宽分配给发送者,其余分配给接收者。如果发送者数量超过成员的四分之一,则该过程平均上在所有参与者之间平分带宽 Initialization: 加入会话后,参与者将 tp 初始化为 0,tc 初始化为 0,senders 初始化为 0,pmembers 初始化为 1,members 初始化为 1,we_sent 初始化为 false,rtcp_bw 初始化为会话带宽的指定比例,initial 初始化为 true,avg_rtcp_size 初始化为应用程序稍后将构建的第一个 RTCP 包的预计大小。随后计算出间隔 T,并将第一个包的计划发送时间设定为 tn = T。这意味着设置一个传输计时器,使其在时间 T 到期。请注意,应用程序可以使用任何所需的方法来实现该计时器。参与者将其自身的 SSRC 添加到成员表中。 Receiving an RTP or Non-BYE RTCP Packet: 当从一个 SSRC 不在成员表中的参与者接收到 RTP 或 RTCP 包时,该 SSRC 会被添加到表中,并且一旦该参与者按第 6.2.1 节所述验证通过,members 的值将被更新。对于验证通过的 RTP 包中的每个 CSRC,也会进行相同的处理。当从一个 SSRC 不在发送者表中的参与者接收到 RTP 包时,该 SSRC 会被添加到表中,senders 的值将被更新。对于每个接收到的复合 RTCP 包,avg_rtcp_size 的值会被更新: 其中,packet_size 是刚刚接收到的 RTCP 包的大小。 Receiving an RTCP BYE Packet: 除非在第 6.3.7 节中描述的发送 RTCP BYE 的情况外,如果接收到的包是 RTCP BYE 包,则会将其 SSRC 与成员表进行检查。如果该 SSRC 存在于成员表中,则从表中删除该条目,并更新 members 的值。然后,SSRC 将与发送者表进行检查。如果存在,则从表中删除该条目,并更新 senders 的值。 此外,为使 RTCP 包的传输速率能够更适应组成员数的变化,当接收到的 BYE 包使 members 值小于 pmembers 时,应执行以下“反向再考虑”算法: 根据以下公式更新 tn 的值: 根据以下公式更新 tp 的值: 将下一个 RTCP 包的计划发送时间重新设定为 tn,此时该时间比原计划更早。 将 pmembers 的值设为与 members 相等。 该算法并不能完全防止在大型会话中的大部分参与者突然离开但部分仍在的情况下,由于过早超时而导致组大小估算短时间内错误地降至零。然而,该算法可以使估算值更快速地恢复到正确值。这种情况非常少见,且后果较为无害,因此被视为次要问题。 Timing Out an SSRC: 参与者必须定期检查其他参与者是否超时。为此,参与者计算接收者的确定性(不含随机因子)计算间隔 TdTdTd,即 we_sent 为 false 的情况。任何自时间 tc−MTdtc - MTdtc−MTd(其中 MMM 为超时倍数,默认值为 5)起未发送 RTP 或 RTCP 包的会话成员将被视为超时。这意味着其 SSRC 会从成员列表中删除,且 members 的值会更新。发送者列表也需进行类似的检查。任何自时间 tc−2Ttc - 2Ttc−2T(即在最近两个 RTCP 报告间隔内)未发送 RTP 包的发送者会从发送者列表中删除,并更新 senders 的值。如果有任何成员超时,应执行第 6.3.4 节中描述的反向再考虑算法。参与者必须至少在每个 RTCP 传输间隔内执行一次此检查。 Expiration of Transmission Timer: 当数据包传输计时器到期时,参与者执行以下操作: 按第 6.3.1 节所述计算传输间隔 TTT,包括随机因子。 如果 tp+T≤tctp + T \leq tctp+T≤tc,则发送一个 RTCP 包。将 tptptp 设为 tctctc,然后按前一步计算另一个 TTT 值,并将 tntntn 设为 tc+Ttc + Ttc+T。将传输计时器设置为在时间 tntntn 到期。如果 tp+T>tctp + T > tctp+T>tc,则将 tntntn 设为 tp+Ttp + Ttp+T。不发送 RTCP 包,传输计时器设置在时间 tntntn 到期。 将 pmembers 设为 members。 如果发送了 RTCP 包,则将 initial 值设为 FALSE。此外,更新 avg_rtcp_size 的值: 其中,packet_size 是刚刚发送的 RTCP 包的大小. Transmitting a BYE Packet: 当参与者希望离开会话时,会发送一个 BYE 包,以告知其他参与者该事件。为避免当许多参与者离开系统时出现 BYE 包的洪流,如果在参与者选择离开时成员数超过 50,则必须执行以下算法。该算法会改变成员变量的正常作用,将其用于统计 BYE 包的数量: 当参与者决定离开系统时,将 tp 重置为 tc(当前时间),并将 members 和 pmembers 初始化为 1,initial 设为 1,we_sent 设为 false,senders 设为 0,avg_rtcp_size 设为复合 BYE 包的大小。随后计算出的间隔 TTT 会被计算出。BYE 包则计划在 tn=tc+Ttn = tc + Ttn=tc+T 的时间发送。 每当接收到来自其他参与者的 BYE 包时,不论该参与者是否存在于成员表中,以及在使用 SSRC 采样时 BYE 的 SSRC 是否会包含在样本中,members 的值都会增加 1。接收到其他 RTCP 或 RTP 包时,members 不会增加,且仅会对接收到的 BYE 包更新 avg_rtcp_size。当 RTP 包到达时,senders 的值不会更新,保持为 0。 然后,BYE 包的传输遵循与上述常规 RTCP 包传输相同的规则。 这允许立即发送 BYE 包,同时控制其总带宽使用量。在最坏的情况下,RTCP 控制包的带宽使用量可能是正常情况的两倍(10%):非 BYE RTCP 包占 5%,BYE 包占 5%。如果参与者不想等待上述机制允许 BYE 包的传输,则可以选择离开组而不发送 BYE 包。此参与者最终会被其他组成员超时清除。如果成员数估计 members 小于 50 时该参与者决定离开,则可立即发送 BYE 包。或者,参与者也可以选择执行上述 BYE 回退算法。 无论哪种情况,若一个参与者从未发送过 RTP 或 RTCP 包,则在离开组时不得发送 BYE 包。 Updating we_sent: 变量 we_sent 在参与者最近发送了 RTP 包时为 true,否则为 false。该状态的确定机制与管理发送者表中其他参与者的机制相同。如果当 we_sent 为 false 时参与者发送了一个 RTP 包,它会将自己添加到发送者表中,并将 we_sent 设置为 true。应执行第 6.3.4 节中描述的反向再考虑算法,以可能减少发送 SR 包之前的延迟。每次发送另一个 RTP 包时,该包的发送时间都会保存在表中。然后对该参与者应用正常的发送者超时算法 —— 如果自 tc−2Ttc - 2Ttc−2T 以来没有发送过 RTP 包,则该参与者会将自己从发送者表中移除,减少发送者计数,并将 we_sent 设置为 false。 Allocation of Source Description Bandwidth: 本规范定义了几种源描述(SDES)项,除了必需的 CNAME 项之外,还包括 NAME(个人姓名)和 EMAIL(电子邮件地址)。它还提供了定义新的应用程序专用 RTCP 包类型的方法。应用程序在为这些附加信息分配控制带宽时应谨慎,因为这会减慢接收报告和 CNAME 的发送速率,从而影响协议的性能。建议不超过 20% 的单个参与者的 RTCP 带宽用于携带附加信息。此外,并非所有 SDES 项都要包含在每个应用程序中。包含的项应根据其用途分配带宽比例。建议不动态估算这些比例,而是基于项的典型长度,将百分比静态转换为报告间隔计数。 例如,一个应用程序可能仅设计为发送 CNAME、NAME 和 EMAIL,而不发送其他项。由于 NAME 将在应用程序的用户界面中连续显示,可能会比 EMAIL 优先级更高,因为 EMAIL 仅在请求时显示。在每个 RTCP 间隔,都会发送一个 RR 包和一个带有 CNAME 项的 SDES 包。对于在最小间隔运行的小会话,平均每 5 秒会发送一次。每隔第三个间隔(15 秒),SDES 包会包含一项额外的项。在这其中,七分之八的时间是 NAME 项,而每八次(2 分钟)会包含 EMAIL 项。当多个应用程序协同工作,通过每个参与者的公共 CNAME 实现跨应用绑定时,例如在多媒体会议中为每个媒体建立一个 RTP 会话时,附加的 SDES 信息可以仅在一个 RTP 会话中发送。其他会话将仅携带 CNAME 项。特别是,这种方法应适用于分层编码方案的多个会话(见第 2.4 节)。 Sender and Receiver Reports: RTP 接收者使用 RTCP 报告包提供接收质量反馈,具体形式取决于接收者是否也是发送者。这两种报告形式——发送者报告 (SR) 和接收者报告 (RR) 的唯一区别是,除了包类型代码外,发送者报告包含一个 20 字节的发送者信息部分,供活跃的发送者使用。如果在上一次或前一个报告之后的间隔期间内站点发送了任何数据包,则会发送 SR,否则发送 RR。SR 和 RR 都包含零个或多个接收报告块,每个报告块对应一个自上次报告以来该接收者接收到 RTP 数据包的同步源 (SSRC)。不为 CSRC 列表中的贡献源生成报告。每个接收报告块提供有关从该块指示的特定源接收的数据的统计信息。由于 SR 或 RR 包中最多可以包含 31 个接收报告块,因此在必要时,附加的 RR 包应在初始 SR 或 RR 包之后叠加,以包含自上次报告以来接收到的所有源的接收报告。如果有太多的源以至于无法将所有必要的 RR 包包含在一个复合 RTCP 包中而不超出网络路径的 MTU,则每个间隔应仅包含符合 MTU 限制的子集。这些子集应在多个间隔内以轮询方式选择,以便所有源都能被报告。 接下来的章节定义了这两种报告的格式,如何在特定配置中进行扩展(如果应用程序需要额外的反馈信息),以及如何使用这些报告。第 7 节提供了有关翻译器和混合器的接收报告的详细信息。 SR: Sender Report RTCP Packet: 发送者报告 (SR) 包包含三个部分,如果定义了特定配置的扩展部分,则可能跟随一个第四部分。第一部分是 8 字节长的头部。各字段含义如下: 版本 (V):2 位 ○ 标识 RTP 版本,与 RTP 数据包中的版本相同。本规范定义的版本为 2。 填充 (P):1 位 ○ 如果填充位设置为 1,则此 RTCP 包在末尾包含一些附加的填充字节,这些字节不属于控制信息,但包含在长度字段中。填充字节的最后一个字节表示需要忽略的填充字节数,包括自身(将是 4 的倍数)。某些固定块大小的加密算法可能需要填充。在复合 RTCP 包中,仅需在一个 RTCP 包中填充,因为整个复合包会被加密(第 9.1 节方法)。因此,填充仅需添加到最后一个 RTCP 包中,且该包的填充位必须设置为 1。这种方式有助于执行附录 A.2 中描述的头部有效性检查,并允许检测某些早期实现中错误地在第一个 RTCP 包中设置填充位,并在最后一个 RTCP 包中添加填充的情况。 接收报告计数 (RC):5 位 ○ 表示此包中包含的接收报告块数量。0 也是有效值。 包类型 (PT):8 位 ○ 包含常数 200,以标识这是 RTCP SR 包。 长度:16 位 ○ 此 RTCP 包的长度(以 32 位字为单位)减 1,包括头部和填充。(偏移量 1 使得 0 成为有效长度,避免扫描复合 RTCP 包时的无限循环问题,并通过以 32 位字为单位进行计数,避免了 4 的倍数的有效性检查。) SSRC:32 位 ○ 此 SR 包发起者的同步源标识符。 第二部分为发送者信息,长 20 字节,存在于每个发送者报告包中,概述了此发送者的数据传输。各字段含义如下: NTP 时间戳:64 位 ○ 表示发送报告时的挂钟时间(见第 4 节),可与其他接收者返回的接收报告中的时间戳结合,用于测量到这些接收者的往返传播时间。接收者应注意,时间戳的测量精度可能远低于 NTP 时间戳的分辨率。时间戳的不确定性未指明,因为可能未知。在不具备挂钟时间概念但具备特定系统时钟(如系统启动时间)的系统上,发送者可以使用该时钟作为参考来计算相对 NTP 时间戳。重要的是选择一个常用时钟,以便在使用不同实现生成多媒体会话的各个流时,所有实现都使用相同的时钟。在 2036 年之前,相对和绝对时间戳会在高位有所不同,因此(无效的)比较将显示出较大的差异;到那时,希望不再需要相对时间戳。没有挂钟或经过时间概念的发送者可以将 NTP 时间戳设为 0。 RTP 时间戳:32 位 ○ 与 NTP 时间戳(上文)对应,但使用与数据包中的 RTP 时间戳相同的单位和随机偏移。对于 NTP 时间戳已同步的源,该对应关系可用于媒体内部和媒体之间的同步,并可用于媒体无关接收者估算标准 RTP 时钟频率。注意,此时间戳在大多数情况下不会等于任何相邻数据包中的 RTP 时间戳,而是必须根据对应的 NTP 时间戳、通过在采样时刻周期性检查挂钟时间来维护的 RTP 时间戳计数器和实际时间的关系进行计算。 发送者的数据包计数:32 位 ○ 从开始传输至生成此 SR 包时,发送者发送的 RTP 数据包总数。如果发送者更改其 SSRC 标识符,则应重置此计数。 发送者的字节数:32 位 ○ 从开始传输至生成此 SR 包时,发送者在 RTP 数据包中传输的有效负载字节总数(不包括头部或填充)。如果发送者更改其 SSRC 标识符,则应重置此计数。此字段可用于估算平均有效负载数据速率。 第三部分根据自上次报告以来发送者接收到的其他源数量,包含零个或多个接收报告块。每个接收报告块传达从一个同步源接收的 RTP 数据包的统计信息。当源由于冲突更改其 SSRC 标识符时,接收者不应继续使用之前的统计信息。这些统计信息包括: SSRC_n(源标识符):32 位 ○ 与此接收报告块中的信息相关的源的 SSRC 标识符。 丢包率:8 位 ○ 表示自上次发送 SR 或 RR 包以来,从源 SSRC_n 丢失的 RTP 数据包的比例,按左边缘为二进制点的定点数表示(即丢失比例乘以 256 的整数部分) RR: Receiver Report RTCP Packet: 接收者报告 (RR) 包的格式与发送者报告 (SR) 包的格式相同,但包类型字段包含常数 201,且省略了五个字的信息(即 NTP 和 RTP 时间戳以及发送者的数据包和字节计数)。其余字段的含义与 SR 包相同。 当没有数据传输或接收需要报告时,必须将一个空的 RR 包(RC = 0)放在复合 RTCP 包的开头。 Extending the Sender and Receiver Reports: 如果有需要定期报告的发送者或接收者的额外信息,配置文件应定义发送者报告和接收者报告的特定扩展。此方法应优先于定义另一种 RTCP 包类型,因为它所需的开销较少: 包中所需的字节更少(没有 RTCP 头部或 SSRC 字段); 解析更简单快捷,因为运行在该配置文件下的应用程序会编程为始终在接收报告后直接访问扩展字段。 该扩展是发送者报告或接收者报告包的第四部分,在接收报告块(如果有的话)之后的结尾处。如果需要额外的发送者信息,则在发送者报告中会首先包含在扩展部分中,但在接收者报告中则不会出现此信息。如果需要包含接收者的信息,数据应当按照与现有接收报告块数组平行的块数组结构组织;即,块的数量将由 RC 字段指示。 Analyzing Sender and Receiver Reports: 接收质量反馈预计不仅对发送者有用,也对其他接收者和第三方监视器有用。发送者可以根据反馈调整传输;接收者可以确定问题是本地的、区域性的还是全局的;网络管理人员可以使用仅接收 RTCP 包而不接收对应 RTP 数据包的独立配置监视器来评估其网络在多播分发中的性能。 发送者信息和接收者报告块都使用累积计数,这样可以计算任意两个报告之间的差异,以便在长短时间段内进行测量,并提供对报告丢失的韧性保护。最近两次接收到的报告之差可用于估算近期的分发质量。NTP 时间戳的加入,使得可以通过这些差异计算两个报告之间间隔的速率。由于该时间戳独立于数据编码的时钟速率,因此可以实现编码和配置无关的质量监视。一个示例计算是两个接收报告之间的包丢失率。累计丢失包数的差值给出了该间隔内的丢失包数。接收的扩展最高序列号的差值给出了该间隔内期望的包数。两者之比是该间隔的包丢失比例。对于连续的两个报告,这一比例应等于丢失比例字段,否则可能不一致。丢失率每秒可通过将丢失比例除以 NTP 时间戳差值(以秒为单位)获得。接收到的包数是期望包数减去丢失包数。期望包数还可用于评估丢失估算的统计有效性。例如,丢失 5 个包中的 1 个的统计显著性低于丢失 1000 个包中的 200 个。 从发送者信息中,第三方监视器可以在不接收数据的情况下计算间隔内的平均有效负载数据速率和平均包速率。两者的比值给出平均有效负载大小。如果假设包丢失与包大小无关,则特定接收者接收的包数乘以平均有效负载大小(或相应的包大小)给出该接收者的可见吞吐量。 除了允许通过报告间差值进行长期丢包测量的累积计数,丢失比例字段还提供了单个报告中的短期测量。这在会话规模扩大到足以使无法保留所有接收者的接收状态信息,或报告间隔长到仅收到一个特定接收者的报告时显得尤为重要。 到达间抖动 (jitter) 字段提供了第二个短期的网络拥塞测量。丢包跟踪持续性拥塞,而抖动测量跟踪瞬时拥塞。在丢包之前,抖动测量可能预示着拥塞。到达间抖动字段仅是报告时抖动的快照,非量化值。相反,它用于对单个接收者随时间或多个接收者(例如同一网络内的接收者)间的多次报告进行比较。为了便于接收者间比较,所有接收者必须按照相同的公式计算抖动。 由于抖动计算基于 RTP 时间戳,该时间戳表示包中数据首次采样的时刻,因此从采样到包传输的延迟变化会影响计算的抖动值。此延迟变化会出现在不同长度的音频包中,视频编码中也会出现这种情况,因为帧的所有包具有相同的时间戳,但传输时间不同。传输前的延迟变化确实降低了抖动计算作为独立网络行为测量的准确性,但考虑到接收者缓冲区必须适应这一点,将其包括在内是合适的。当抖动计算作为比较测量使用时,传输延迟变化的(恒定)分量会相互抵消,从而可以观察到网络抖动分量的变化,除非该变化非常小。如果变化很小,则可能无关紧要。

    01-03 141浏览
  • Bootloader的见鬼故障

    【前面的话】在近几年的嵌入式社区中,流传着不少关于面相Cortex-M的Bootloader科普文章,借助这些文章,一些较为经典的代码片断和技巧得到了广泛的传播。  在从Bootloader跳转到用户APP的过程中,使用函数指针而非传统的汇编代码则成了一个家喻户晓的小技巧。相信类似下面 JumpToApp() 函数,你一定不会感到陌生: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; __IO uint32_t StackAddr; __IO uint32_t ResetVector; __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 为了读懂这段代码,需要一些从事Cortex-M开发所需的“热知识”: 向量表是一个由 32bit 数据构成的数组 数组的第一个元素是 uintptr_t 类型的指针,保存着复位后主栈顶指针(MSP)的初始值。 从数组第二个元素开始,保存的是 (void (*)(void)) 类型的异常处理程序地址(BIT0固定为1,表示异常处理程序使用Thumb指令集进行编码) 数组的第二个元素保存的是复位异常处理程序的地址(Reset_Handler) 从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。 总结来说,Bootloader在跳转到App之前需要做两件事: 1. 清理房间——仿佛Bootloader从未执行过一样 2. 模拟处理器的硬件的一些复位行为——假装芯片从复位开始就直接从APP开始执行 一般来说,做到上述两点,就可以实现App将Bootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。 如何在App中加入“跳床(trumpline)”值得专门再写一篇独立的文章,不是本文所需关注的重点,请允许我暂且略过。 这里,“清理房间”的步骤与Bootloader具体“弄脏了什么”(或者说使用了什么资源)有关;而“模拟处理器硬件的一些复位行为”就较为简单和具体:即,从Bootloader跳转到App前的最后两个步骤为: 从APP的向量表中读取MSP的初始值并以此来初始化MSP寄存器; 从APP的向量表中读取Reset_Handler的值,并跳转到其中去执行——完成从Bootloader到APP的权利交接。 结合前面的例子代码,值得我们关注的部分是: 1. 使用自定义的函数指针类型 pFunction 定义一个局部变量: pFunction Jump_To_Application; 2. 根据向量表的首地址 addr 读取第一个元素——作为MSP的初始值暂时保存在局部变量 StackAddr 中: StackAddr = *(__IO uint32_t*)addr; 3. 根据向量表的首地址 addr 读取第二个元素——将Reset_Handler的首地址保存到局部变量 ResetVector 中: ResetVector = *(__IO uint32_t *)(addr + 4); 4. 设置栈顶指针MSP寄存器: __set_MSP(StackAddr); 5. 通过函数指针完成从Bootloader到App的跳转: Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 其实,无论具体的代码如何,只要实现步骤与上述类似,就存在一个隐藏较深的漏洞,而漏洞的“触发与否”则完全“看脸”——简单来说: 只要你是按照上述方法来实现从Bootloader到App的跳转的,那么就一定存在问题——而“似乎可以正常工作”就只是你运气较好,或者“由此引发的问题暂时未能引发注意”罢了。 在你试图争辩“老子代码已经量产了也没有什么不妥”之前,我们先来看看漏洞的原理是什么——在知其所以然后,如何评估风险就是你们自己的事情了。 【C语言基础设施是什么】 嵌入式系统的信息安全(Security)建立在基础设施安全(Safety)的基础之上。 由于“确保信息安全的很多机制”本质上是一套建立在“基础设施能够正常工作”这一前提之上的规则和逻辑,因此很多针对信息安全的攻击往往会绕开信息安全的“马奇诺防线”,转而攻击基础设施。芯片数字逻辑的基础设施是时钟源、供电、总线时序、复位时序等等,因此,针对硬件基础设施的攻击通常也就是针对时钟源、电源、总线时序和复位时序的攻击。此时,好奇的小伙伴会产生疑问:固件一般由C语言进行编写,那么C语言所依赖的基础设施又是什么呢? 对C语言编译器来说,栈的作用是无可替代的: 函数调用 函数间的参数传递 分配局部变量 暂时保存通用寄存器中的内容 …… 可以说,离开了栈C语言寸步难行。因此对很多芯片来说,复位后为了执行用户使用C语言编译的代码,第一个步骤就是要实现栈的初始化。 作为一个有趣的“冷知识”,Cortex-M在宣传中一直强调自己“支持完全使用C语言进行开发”,这让很多人“丈二和尚摸不着头脑”甚至觉得“非常可笑”——因为这年月连51都支持用户使用C语言进行开发了,你这里说的“Cortex-M支持使用C语言进行开发”有什么意义呢? 其实门道就在这里: 由于Cortex-M处理器会在复位时由硬件完成对C语言基础设施(也就是栈顶指针MSP)的初始化,因此无论是理论上还是实践中,从复位异常处理程序Reset_Handler开始用户就可以完全可以使用C语言进行开发了,而整个启动代码(startup)也可以全然不涉及任何汇编; 由于Cortex-M的向量表是一个完全由 32位整数(uintptr_t)构成的数组——保存的都是地址而非具体代码,可以使用C语言的数据结构直接进行描述——因此也完全不需要汇编语言的介入。 这种从复位一开始就完全不需要汇编介入的友好环境才是Cortex-M声称自己“支持完全使用C语言进行开发”的真实意义和底气。从这一角度出发,只要某个芯片架构复位后必须要通过软件来初始化栈顶指针,就不符合“从出生的那一刻就可以使用C语言”的基本要求。 【C语言编译器的约定】 栈对C语言来说如此重要,以至于编译器一直有一条默认的约定,即: 栈必须完全交由C语言编译器进行管理(或者用户对栈的操作必须符合对应平台所提供的调用规约,比如Arm的AAPCS规约)。 简而言之,如果你“偷偷摸摸”的修改了栈顶指针,C语言编译器是会“假装”完全不知道的,而此时所产生的后果C语言编译器会默认自己完全不用负责。 回头再看这段代码: StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 虽然我们觉得自己“正大光明”的使用了 __set_MSP() 来修改了栈顶指针,但它实际上是一段C语言编译器并不理解其具体功能的在线汇编——在编译器看来,无论是谁提供的 __set_MSP(),只要是在线汇编,这就算是用户代码——是编译器管不到的地带。 /** \brief Set Priority Mask \details Assigns the given value to the Priority Mask Register. \param [in] priMask Priority Mask */__STATIC_FORCEINLINE void __set_PRIMASK(uint32_t priMask){ __ASM volatile ("MSR primask, %0" : : "r" (priMask) : "memory");} 或者说:C语言编译器一般情况下会默认你“无论如何都不会修改栈顶指针”——它不仅管不着,也不想管。 从这点来看,上述代码的确打破了这份约定。即便如此,很多小伙伴会心理倔强的认为:我就这么改了,怎么DE了吧?! 【问题的分析】 从原理上说,开篇那个典型的Bootloader跳转代码所存在的问题已经昭然若揭: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; __IO uint32_t StackAddr; __IO uint32_t ResetVector; __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 我们不妨结合上述代码反汇编的结果进行深入解析: AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2 JumpToApp PROC000000 b082 SUB sp,sp,#8000002 4909 LDR r1,|L2.40|000004 9100 STR r1,[sp,#0]000006 6802 LDR r2,[r0,#0]000008 400a ANDS r2,r2,r100000a 2101 MOVS r1,#100000c 0749 LSLS r1,r1,#2900000e 428a CMP r2,r1000010 d107 BNE |L2.34|000012  6801              LDR      r1,[r0,#0]000014 9100 STR r1,[sp,#0]000016 6840 LDR r0,[r0,#4]000018 f3818808 MSR MSP,r100001c 9001 STR r0,[sp,#4]00001e b002 ADD sp,sp,#8000020 4700 BX r0 |L2.34|000022 b002 ADD sp,sp,#8000024 4770 BX lr ENDP 000026 0000 DCW 0x0000 |L2.40| DCD 0x2fff0000 注意这里,StackAddr、ResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将SP指针向栈底挪动8个字节就是这个意思: 000000 b082 SUB sp,sp,#8 虽然 JumpMask 也是局部变量,但编译器根据自己判断认为它“命不久矣”,因此直接将它分配到了通用寄存器r2中,并配合r1和sp完成了后续运算。这里: __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { ... } 对应: 000002 4909 LDR r1,|L2.40|000004 9100 STR r1,[sp,#0]000006 6802 LDR r2,[r0,#0]000008 400a ANDS r2,r2,r100000a 2101 MOVS r1,#100000c 0749 LSLS r1,r1,#2900000e 428a CMP r2,r1000010 d107 BNE |L2.34|...|L2.34|000022 b002 ADD sp,sp,#8000024 4770 BX lrENDP 000026 0000 DCW 0x0000|L2.40|DCD 0x2fff0000 考虑到JumpMask的内容与本文无关,不妨暂且跳过。 接下来就是重头戏了: 编译器按照用户的指示读取栈顶指针MSP的初始值,并保存在StackAddr中: StackAddr = *(__IO uint32_t*)addr; 对应的汇编是: 000012 6801 LDR r1,[r0,#0]000014 9100 STR r1,[sp,#0] 根据Arm的AAPCS调用规约,编译器在调用函数时会使用R0~R3来传递前4个符合条件的参数(这里的条件可以简单理解为每个参数的宽度要小于等于32bit)。根据函数原型 void JumpToApp(uint32_t addr); 可知,r0 中保存的就是形参 addr 的值。所以第一句汇编的意思就是:根据 (addr + 0)作为地址读取一个uint32_t型的数据保存到r1中。 第二句汇编中,栈顶指针sp此时实际上指向局部变量 StackAddr,因此其含义就是将通用寄存器r1中的值保存到局部变量 StackAddr 中。 对于局部变量 ResetVector 的读取操作,编译器的处理如出一辙: ResetVector = *(__IO uint32_t *)(addr + 4); 对应: 000016  6840              LDR      r0,[r0,#4]00001c 9001 STR r0,[sp,#4] 其实就是从 (addr + 4) 的位置读取 32bit 整数,然后保存到r0里,并随即保存到sp所指向的局部变量 ResetVector 中。到这里,细心地小伙伴会立即跳起来说“不对啊,原文不是这样的!”。是的,这也是最有趣的地方。实际的汇编原文如下: 000016 6840 LDR r0,[r0,#4]000018 f3818808 MSR MSP,r100001c 9001 STR r0,[sp,#4] 作为提醒,它对应的C代码如下: ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); 后面的 __set_MSP(StackAddr) 所对应的汇编代码 MSR MSR,r1 居然插入到了ResetVector赋值语句的中间?! “C语言编译器这么自由的么?” “在我使用sp之前把栈顶指针更新了?!” 先别激动,还记得我们和C语言编译器之间的约定么?C语言编译器默认我们在任何时候都不应该修改栈顶指针。因此在他看来,“你 MSR 指令操作的是r1,关我sp和r0啥事”?“我就算随意更改顺序应该对你一毛钱影响都没有!(因为我不关心、也没法知道用户线汇编语句的具体效果,因此我只关心涉事的通用寄存器是否存在冲突)” 上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果: 由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+4)实际上已经超出栈的合法范围了。 这一操作与其说是会覆盖栈后续的存储空间,倒不如说风险主要体现在BusFault上——因为相当一部分人习惯将栈放到SRAM的最末尾,而MSP+4直接超出SRAM的有效范围。 我们以为的ResetVector其实已经不在原本C编译器所安排的地址上了。 精彩的还在后面: Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 对应的翻译是: 00001e b002 ADD sp,sp,#8000020 4700 BX r0 通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从Bootloader到APP的跳转——也许你会松一口气——好像局部变量ResetVector的错位也没引起严重的后果嘛。 看似如此,但真正吓人的是C语言编译器随后对局部变量的释放: 00001e b002 ADD sp,sp,#8 它与一开始局部变量的分配形成呼应: 000000 b082 SUB sp,sp,#8...00001e b002 ADD sp,sp,#8 好借好还,再借不难。但此sp非彼sp了呀! 这里由于JumpToApp没有加上__NO_RETURN的修饰,因此C编译器并不知道这个函数是有去无回的,因此仍然会像往常一样在函数退出时释放局部变量。 就像刚才分析的那样:由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+8)实际上已经超出栈存储空间的合法范围了。 考虑到相当一部分人习惯将栈放到SRAM的最末尾,而MSP+8直接超出SRAM的有效范围,即便刚跳转到APP的时候还不会有事,但凡APP用了任何压栈操作,(无论是BusFault还是地址空间绕回)就很有可能产生灾难性的后果。 【宏观分析】 就事论事的讲,单从汇编分析来看,上述代码所产生的风险似乎是可控的,甚至某些人会觉得可以“忽略不计”。但最可怕的也就在这里,原因如下: 从原理上说,将关键信息保存在依赖栈的局部变量中,然后在编译器不知情的情况下替换了栈所在的位置,此后只要产生对相关局部变量的访问就有可能出现“刻舟求剑”的数据错误。这种问题是“系统性的”、“原理性的”。 (此图由GorgonMeducer借助GPT4进行一系列关键词调校、配上台词后获得) 不同编译器、同一编译器的不同版本、同一版本的不同优化选项都有可能对同一段C语言代码产生不同的编译结果,因此哪怕我们经过上述分析得出某一段汇编代码似乎不会产生特别严重的后果,在严谨的工程实践上,这也只能算做是“侥幸”,是埋下了一颗不知道什么时候以什么方式引爆的定时炸弹。 根据用户Bootloader代码在修改 MSP 前后对局部变量的使用情况不同、考虑到用户APP行为的不确定性、由上述缺陷代码所产生的Bootloader与APP之间配合问题的组合多种多样、由于涉及到用户栈顶指针位置的不确定性以及新的栈存储器空间中内容的随机性,最终体现出来的现象也是完全随机的。用人话说就是,经常性的“活见鬼” 【解决方案】 既然我们知道不能对上述缺陷代码抱有侥幸心理,该如何妥善解决呢?第一个思路:既然问题是由栈导致的,那么直接让编译器用通用寄存器来保存关键局部变量不就行了?修改代码为: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; register uint32_t StackAddr; register uint32_t ResetVector; register uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 相同编译环境下得出的结果为: AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2 JumpToApp PROC 000002 6801 LDR r1,[r0,#0]000004 4011 ANDS r1,r1,r2000006 2201 MOVS r2,#1000008 0752 LSLS r2,r2,#2900000a 4291 CMP r1,r200000c  d104              BNE |L2.24| 00000e 6801 LDR r1,[r0,#0]000010 6840 LDR r0,[r0,#4]000012 f3818808          MSR      MSP,r1 000016 4700 BX r0 |L2.24|000018 4770 BX       lr ENDP 00001a 0000 DCW 0x0000 |L2.28| DCD 0x2fff0000 可见,上述汇编中半个 sp 的影子都没看到,问题算是得到了解决。 然而,需要注意的是 register 关键字对编译器来说只是一个“建议”,它听不听你的还不一定。加之上述例子代码本身相当简单,涉及到的局部变量数量有限,因此问题似乎得到了解决。 倘若编译器发现你大量使用 register 关键字导致实际可用的通用寄存器数量入不敷出,大概率还是会用栈来进行过渡的——此时,哪些局部变量用栈,哪些用通用寄存器就完全看编译器的心情了。 进一步的,不同编译器、不同版本、不同优化选项又会带来大量不可控的变数。 因此就算使用 register 修饰关键局部变量的方法可以救一时之疾(“只怪老板催我催得紧,莫怪我走后洪水滔天”),也算不得妥当。 第二个思路:既然问题出在局部变量上,我用静态(或者全局)变量不就可以了?修改源代码为: #include "cmsis_compiler.h" typedef void (*pFunction)(void); __NO_RETURNvoid JumpToApp(uint32_t addr){ pFunction Jump_To_Application; static uint32_t StackAddr; static uint32_t ResetVector; register uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 这种方法看似稳如老狗,实际效果可能也不差,但还是存在隐患,因为它“没有完全杜绝编译器会使用栈的情况”,只要我们还会通过 __set_MSP() 在C语言编译器不知道的情况下更新栈顶指针,风险自始至终都是存在的。 对某些连warning都要全数消灭的团队来说,上述方案多半也是不可容忍的。 第三个思路:完全用汇编来处理从Bootloader到App的最后步骤。对此我只想说:稳定可靠,正解。 只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。 原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。 本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。 【说在后面的话】 在使用栈的情况下,on-fly 的修改栈顶指针就好比在飞行途中更换引擎——不是不行,只是要求有亿点点高。 我在微信群中帮读者分析各类Bootloader的见鬼故障时,经常在大费周章的一通分析和调试后,发现问题的罪魁祸首就是跳转代码。可怕的是,几乎每个故障的具体现象都各不相同,表现出的随机性也常常让人怀疑是不是硬件本身存在问题,亦或是产品工作现场的电磁环境较为恶劣。最要命的当数那种“偶尔出现”而复现条件颇为玄学的情形,甚至在办公室环境下完全无法重现的也大有人在。同样的问题出的多了,我几乎在每次帮人调试Bootloader时都会习惯性的先要求检查跳转代码——虽然不会每次都能猜个正着,但也有个恐怖的十之七八。这也许是某种幸存者偏差吧——毕竟大部分普通问题大家自己总能解决,到我这里的多半就是“驱鬼”了。见得多了,我突然发现,出问题的代码大多使用函数指针来实现跳转——而用局部变量来保存函数指针又成了大家自然而然的选择。加之此前很多文章都曾大规模科普上述技巧,甚至是直接包含一些存在缺陷的Bootloader范例代码,实际受影响的范围真是“细思恐极”。特此撰文,为您解惑。 

    01-03 52浏览
  • timescale指令的用法

    1、什么是`timescale指令?\x26amp;nbsp; \x26amp;nbsp; \x26amp;nbsp;`timescale指令我相信大

    01-03 43浏览
  • Linux C 中的内存屏障是什么?

    一、内存屏障 在 Linux C 语言编程 中,内存屏障(Memory Barrier) 是一种用于控制内存访问顺序的技术。它主要用于多处理器系统中,确保某些操作按预期顺序执行,避免 CPU 和编译器对内存访问进行优化,从而影响程序的正确性。内存屏障的功能在多线程和并发编程中尤为重要。 什么是内存屏障? 内存屏障的障中文意思是保护和隔离的,也有阻止的意思,阻止的是CPU对变量的继续访问,停下来更新下变量,从而保护变量的一致性。 内存屏障是针对线程所有共享变量的,而原子操作仅针对当前原子变量。 内存屏障是一种指令,它的作用是禁止 CPU 重新排序特定的内存操作。它确保在屏障之前的所有读/写操作在屏障之后的操作之前完成。内存屏障一般被用来控制多处理器环境中的内存可见性问题,尤其是在进行原子操作、锁和同步时。 在多核处理器上,每个处理器都有自己的缓存,CPU 会将内存操作缓存到自己的本地缓存中。在不同的 CPU 之间,内存的可见性并非立刻同步,这就可能导致不同线程看到不同的内存值。通过内存屏障,可以确保特定的操作顺序,以避免此类问题。 内存屏障的类型 Linux C 中,内存屏障通常有以下几种类型,主要通过内核提供的原子操作或者内存屏障函数来实现。 1.全屏障(Full Barrier 或者 LFENCE、SFENCE): 作用:以下两种相加。 用途:确保所有的内存操作都在内存屏障前完成,通常用于同步和锁定操作。 内核函数:mb() (Memory Barrier) 2.读屏障(Read Barrier 或者 LFENCE): 作用:保证屏障之前的所有读操作在屏障之后的读操作之前完成。---》翻译过来的有歧义,难以理解,那个“完成”是缓存同步主内存的意思。 本质:作用是强制将 CPU核心 中的 L1/L2 缓存 中的共享变量值写回到 主内存。 用途:在执行并行读操作时确保读顺序。 内核函数:rmb() (Read Memory Barrier) 3.写屏障(Write Barrier 或者 SFENCE): 作用:保证屏障之前的所有写操作在屏障之后的写操作之前完成。--》翻译过来有歧义,难以理解,那个“完成”是主内存同步到缓存的意思。 本质:作用是强制使数据从主内存加载,而不是直接使用可能已经过时的缓存数据。 用途:用于确保写操作顺序。 内核函数:wmb() (Write Memory Barrier) 4.无序屏障(No-op Barrier): 作用:没有实际影响,仅确保 CPU 不会重排序特定的指令。 用途:常用于确保指令的顺序性而不做其他强制性的内存同步。 读写屏障的作用域 读写屏障的作用域并不局限于当前函数或者某个函数调用的局部作用域,而是影响整个 当前线程的内存访问顺序。也就是说,只要在当前线程中,任何在屏障前后的内存操作都会受到屏障影响,而不管这些操作发生在同一个函数里还是不同的函数中。 线程之间的隔离 读写屏障是 线程级别的,因此它们只影响执行这些屏障操作的线程。也就是说,如果线程 1 执行了写屏障,它只会影响线程 1 后续的内存操作,而不会直接影响其他线程。---》翻译过来的,其实就是这个线程的读写屏障只会引发自己线程变量与主内存的同步,管不到其他线程的同步。但是写屏障触发后 会 通知其他线程,如果有现代 CPU 使用缓存一致性协议(如 MESI)的话,其他线程会把主内存中的最新值更新到自己缓存中。 读屏障不会触发其他线程去把自己的缓存同步到主内存中。 如果想让多个线程之间的共享变量同步并保持一致性,通常需要在多线程间使用某些同步机制(如锁、原子操作等),而不仅仅是依赖于单个线程的屏障。 具体来说: 写屏障(Write Barrier):会影响所有在屏障之前执行的写操作,无论这些写操作发生在当前函数内还是其他函数中。它确保屏障前的所有写操作都能同步到主内存,任何与此线程共享的缓存都能看到这些值。 读屏障(Read Barrier):会影响所有在屏障之后执行的读操作,确保这些读操作从主内存读取最新的值,而不是从 CPU 核心的缓存中读取过时的值。读屏障会影响当前线程的所有后续读取操作,无论这些读取发生在哪个函数中。 内存屏障的使用 在 Linux 中,内存屏障主要通过一组原子操作宏来提供。这些操作用于确保不同 CPU 或线程之间的内存同步。常见的内存屏障宏包括: mb():全屏障,防止 CPU 重排序所有内存操作。 rmb():读屏障,确保屏障之前的所有读操作完成。 wmb():写屏障,确保屏障之前的所有写操作完成。 示例代码 #include #include #define wmb() __asm__ __volatile__("sfence" ::: "memory") // 写屏障#define rmb() __asm__ __volatile__("lfence" ::: "memory") // 读屏障#define mb() __asm__ __volatile__("mfence" ::: "memory") // 全屏障 void example_memory_barrier() { int shared_variable = 0; // 写入数据 shared_variable = 42; // 在这里使用写屏障,确保共享变量的写操作 // 在执行屏障之后才会完成 wmb(); // 读取共享数据 printf("Shared Variable: %d\n", shared_variable); // 使用读屏障,确保屏障前的所有读取操作完成 rmb(); // 这里是确保顺序执行的一部分 printf("Shared Variable read again: %d\n", shared_variable);} int main() { example_memory_barrier(); return 0;} 为什么需要内存屏障? 避免重排序:编译器和 CPU 会对内存访问进行优化,尤其是在多处理器系统中,这可能导致指令执行顺序与预期不一致,进而导致错误的程序行为。 保证内存一致性:当一个线程或 CPU 修改共享变量时,其他线程或 CPU 可能会看到不同的内存值,内存屏障可以保证修改操作在其他线程中是可见的。 同步操作:在多线程或多处理器环境中,内存屏障确保执行顺序和同步的正确性,尤其是在没有锁或原子操作的情况下。 缓存一致性协议(例如 MESI) 为了保证多核处理器之间缓存的数据一致性,现代 CPU 会使用缓存一致性协议(如MESI 协议,即 Modified、Exclusive、Shared、Invalid)。这个协议的作用是确保一个核心的缓存修改在其他核心的缓存中得到更新,避免出现“脏数据”。 但即便如此,MESI 协议的具体实现仍然依赖于硬件,缓存之间的同步可能不会在每一次内存访问时都发生。尤其是在没有任何同步机制的情况下,一个线程修改的值可能会暂时不被另一个线程看到,直到某些缓存刷新或同步操作发生。 Linux 内核中的内存屏障 在 Linux 内核中,内存屏障主要是通过原子操作来实现的。例如,atomic_set、atomic_add 等原子操作通常会隐式地使用内存屏障来保证内存操作顺序。而直接的内存屏障通常通过 mb()、wmb() 和 rmb() 函数来实现。 总结 内存屏障在多核处理器和并发程序中非常重要,用于控制内存操作顺序,避免由于硬件优化或编译器优化引起的内存同步问题。Linux 提供了多种类型的内存屏障函数,程序员可以根据需要使用它们来确保内存操作的顺序性。 需要C/C++ Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享 二、变量存贮与内存屏障 你提到的问题涉及 程序执行过程中变量的存储位置、内存可见性和线程切换 的多个方面。为了更清晰地解释,我们需要从操作系统的内存管理和多线程模型入手。 1. 程序执行前变量存在哪里? 在程序执行之前,变量的存储位置主要依赖于变量的类型和生命周期。变量可以存储在以下几个区域: 栈区(Stack):局部变量通常会被分配到栈中。栈是线程私有的,每个线程都有一个独立的栈空间。 堆区(Heap):动态分配的内存(通过 malloc()、free() 等)会被存储在堆中。堆是共享的,不同线程可以访问堆中的数据。 全局区/静态区(Data Segment):全局变量和静态变量通常存储在数据段中。在程序启动时,数据段会被分配并初始化。 代码区(Text Segment):存储程序的代码(即机器指令),线程不直接操作。 2. 线程对变量的修改,为什么对其他线程不可见? 当一个线程修改了它的某个变量的值,这个变量的值并不一定立即对其他线程可见,主要是因为现代处理器通常会有缓存(Cache),并且每个线程可能在自己的寄存器或局部缓存中执行操作。具体原因如下: CPU 缓存:每个 CPU 核心都有自己的缓存(例如 L1、L2 Cache),当一个线程运行时,它可能会先将某个变量加载到本地缓存中进行修改,而不是直接操作主内存。这样,修改后的值可能不会立刻反映到主内存中。 内存可见性问题:因为不同的线程可能运行在不同的 CPU 核心上,并且每个核心有自己的缓存系统,其他线程(在不同的 CPU 核心上)可能无法直接看到修改后的变量值。除非通过某种同步机制(如内存屏障、锁、原子操作等)确保所有 CPU 核心的缓存一致性,否则修改的值不会立刻对其他线程可见。 3. 线程被切换时,变量的值存储到哪里? 当一个线程被 调度器切换(例如,从运行状态切换到阻塞状态或就绪状态)时,操作系统会保存该线程的 上下文(即该线程当前的执行状态)。这个过程称为 上下文切换。 CPU 寄存器:线程的 寄存器状态(包括程序计数器、栈指针、CPU 寄存器中的数据等)会被保存在操作系统为该线程分配的 线程控制块(TCB) 中,或者在内核中由特定的机制(如进程控制块、线程栈)保存。 内存:栈中的局部变量会被存储在 栈区,这些数据在线程切换时保持不变,直到线程恢复时。 CPU 缓存:在某些情况下,线程切换后,CPU 的缓存中的数据可能会被清除或更新,以保证在切换后的线程恢复时能正确访问内存。 4. 缓存与主内存 在现代多核处理器上,每个 CPU 核心(CPU Core)通常有自己的 本地缓存(例如 L1 缓存、L2 缓存,甚至是更高层的缓存)。这些缓存的作用是加速内存访问,避免每次访问内存时都直接访问主内存(RAM)。因此,当线程对某个变量进行修改时,这个变量的值首先会被写入到该线程所在 CPU 核心的 缓存 中,而不一定立即写回到主内存。 5. 另一个线程接着运行,变量值从哪里拿? 当一个线程被切换出去后,另一个线程会接管 CPU 的执行,并且继续执行自己的代码。另一个线程获取变量的值依赖于以下几个因素: 内存一致性:如果没有任何内存屏障或同步机制,另一个线程可能会读取到一个过时的缓存值。原因是线程 1 在修改变量时,可能只是修改了本地缓存,而没有把新值写回到主内存中;而线程 2 可能仍然读取到线程 1 的旧值。 线程间同步:为了确保变量的最新值在多个线程之间可见,通常需要使用 同步原语(如互斥锁 mutex、条件变量 condvar、原子操作等)。如果使用了如 mutex 锁定共享资源,或者使用了 volatile 关键字或原子操作,线程间对变量的修改才会更可靠地同步和可见。 缓存一致性协议:现代 CPU 通常使用 缓存一致性协议(如 MESI 协议)来确保不同 CPU 核心之间缓存的一致性。当一个线程修改某个变量时,其他线程会通过协议获取这个变量的最新值,避免缓存中的脏数据。 6. CPU 缓存与线程切换 这里有一个关键点:线程切换并不意味着缓存被清除。如果线程 1 在 CPU 核心 A 上运行,修改了全局变量,并且这个修改存储在核心 A 的缓存中,线程 1 被切换出去,CPU 核心 A 的缓存不会因为线程切换而清空。即使切换到其他线程,CPU 仍然会保留 核心 A 的缓存内容。 当线程 2 在另一个 CPU 核心 B 上开始运行时,如果它访问相同的全局变量,它会根据 自己核心的缓存 来读取这个变量。如果线程 2 看到的是旧值,那么它就是从 核心 B 的缓存 中拿到的旧值,而不是从主内存 中读取的。 7. 内存一致性问题 内存一致性问题通常出现在 多核处理器 中,特别是当多个线程运行在不同的 CPU 核心上时。在这种情况下: 线程 1 可能修改了一个全局变量的值,线程 1 所在的 CPU 核心会将该变量的新值写入到 该核心的缓存(例如 L1 或 L2 缓存)中。 如果 线程 2 运行在 另一个 CPU 核心 上,它可能会直接从 自己本地的缓存 中读取这个变量,而不是从主内存读取。假如线程 2 在缓存中读取到的是 线程 1 之前的旧值(而不是修改后的新值),那么线程 2 就读取到过时的值。 8. 为什么会有内存屏障? 由于多核处理器中的 CPU 会对内存操作进行缓存优化,内存屏障(mb()、wmb()、rmb())的作用是 强制同步 内存操作,确保某些操作的顺序性和内存可见性。通过内存屏障,操作系统或程序员可以确保在特定的内存操作之前或之后的操作按顺序执行,避免缓存带来的不一致性。 9. 为什么一个线程拿到的值可能会是旧的? 这个问题的核心在于 缓存一致性 和 内存可见性: 每个 CPU 核心都有独立的缓存,这意味着它们的缓存可能保存着不同版本的内存数据。 当线程 1 修改变量时,虽然它的 本地缓存 中的数据会被更新,但主内存的更新可能并没有立刻发生,或者其他 CPU 核心的缓存并没有得到通知。 如果没有同步机制(如内存屏障、锁、原子操作等),线程 2 可能会继续读取到旧的缓存值,而不是线程 1 修改后的新值。 10. 综述:内存模型和线程切换 在现代操作系统和多核处理器中,内存模型非常复杂。通过以下几个概念可以理解线程的内存操作: 每个线程有自己的栈,但共享堆和全局变量。 缓存一致性问题:线程修改的变量不会立刻对其他线程可见。 上下文切换:线程切换时,寄存器、栈等状态会保存,并由操作系统恢复。 内存屏障和同步机制:确保变量在多个线程之间同步和可见。 示例 假设有两个线程 A 和 B,它们都操作同一个共享变量 x。假设线程 A 修改了 x 的值,但由于没有同步机制,线程 B 可能不会立刻看到修改后的值。 int x = 0; // 共享变量 void thread_a() { x = 1; // 修改共享变量 wmb(); // 写屏障,确保修改的值会被其他线程看到} void thread_b() { rmb(); // 读屏障,确保读取的是线程 A 修改后的值 printf("%d\n", x); // 打印 x 的值} 总结 线程修改变量时,该变量可能存在于不同的缓存中,其他线程可能无法看到更新的值。 上下文切换时,线程的寄存器和栈会被保存在操作系统的上下文中,恢复时会读取这些数据。 内存屏障 用于控制内存操作的顺序性,确保修改的值能在不同线程间同步。 三、编译器屏障 编译器屏障(Compiler Barrier)是一种用于控制编译器优化的机制,用来确保编译器在生成代码时不会对某些操作进行重排或优化,尤其是在多线程编程和硬件相关编程中非常重要。编译器屏障并不直接控制内存访问,而是用来防止编译器对指令的顺序进行不合适的重新排序,确保代码按照预期的顺序执行。 编译器屏障的作用 编译器屏障主要用于控制编译器如何处理代码中的指令顺序,尤其是: 防止指令重排序:编译器优化时可能会改变指令的顺序,重新排序内存访问或者其他指令。这可能导致多线程程序中某些预期的同步行为失败。编译器屏障防止这种行为。 确保指令的执行顺序:在多核处理器或并发编程中,编译器屏障确保某些关键操作(比如内存访问)在正确的顺序中执行,避免因编译器优化导致的不一致性。 编译器屏障与内存屏障的区别 编译器屏障(Compiler Barrier): 它的目的是确保编译器不会对代码进行不合理的优化或重排序,特别是在涉及并发或多核时。 编译器屏障不能强制 CPU 层面执行同步操作,仅仅是防止编译器重排代码。 内存屏障(Memory Barrier): 内存屏障则是用于确保在多核或多线程环境中,内存访问按照特定的顺序发生,它直接控制内存操作和硬件级别的缓存同步。 内存屏障不仅防止编译器重排,也会影响 CPU 对内存的读取和写入顺序。 编译器屏障的实际应用 编译器屏障通常用于那些需要确保内存或操作顺序的场景,尤其是在处理低级硬件和并发编程时。下面是一些编译器屏障的常见应用场景: 原子操作:在使用原子操作(比如 atomic_add)时,编译器屏障可用于确保原子操作的顺序性。 多线程同步:当线程间存在共享数据时,编译器屏障可以防止编译器重排序线程的操作,以保证正确的同步。 硬件访问:在直接操作硬件时,编译器屏障可以确保 I/O 操作按预期顺序执行,而不被编译器优化掉。 编译器屏障的实现 不同的编译器提供不同的方式来实现编译器屏障。常见的做法包括: volatile 关键字:在 C/C++ 中,volatile 可以告诉编译器不要优化对变量的访问,通常用于内存映射 I/O 或多线程共享变量。虽然它可以防止编译器优化,但它并不能防止 CPU 缓存重排序。 内联汇编:编译器屏障还可以通过内联汇编来实现。许多编译器(如 GCC)支持特定的内联汇编指令,用于告诉编译器不要重排某些指令。例如,GCC 提供了 asm volatile 来控制编译器优化。 编译器内置指令:某些编译器提供内置指令来实现编译器屏障。例如,GCC 中有 __asm__ __volatile__ ("": : : "memory"),这是一个编译器屏障,它不会生成任何机器指令,但会告知编译器不要重排此位置的内存操作。 示例:GCC 中的编译器屏障 在 GCC 中,可以使用 __asm__ __volatile__ ("": : : "memory") 来插入一个编译器屏障。它告诉编译器不要重新排序此点前后的内存操作。 示例代码: #include volatile int shared_var = 0; void func1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("" : : : "memory"); // 插入编译器屏障} void func2() { int local = shared_var; // 读取共享变量 printf("shared_var = %d\n", local);} int main() { func1(); func2(); return 0;} 在这个示例中,__asm__ __volatile__ ("" : : : "memory")强制插入一个编译器屏障,确保编译器在执行shared_var = 1和shared_var之间的读写操作时不会对它们进行优化或重排序。 编译器屏障的局限性 虽然编译器屏障可以阻止编译器对代码的优化,但它并不能保证在多核处理器上,缓存之间的同步或内存访问的正确顺序。要确保内存的一致性,尤其是跨多个 CPU 核心的同步,还需要使用 内存屏障 或 锁 等同步机制。 总结 编译器屏障 主要用于控制编译器优化,防止编译器对代码执行顺序进行重排序。 它不能控制内存访问的实际顺序,但可以防止编译器错误地优化掉重要的内存操作。 它通常与 内存屏障 一起使用,以确保在多线程或并发环境下的正确性。 四、CPU屏障 CPU 屏障,也常称为 处理器屏障,是一个硬件层面的同步机制,主要用于确保 CPU 在执行指令时按特定的顺序访问内存。它是为了处理 CPU 的 指令重排、内存缓存一致性 和 多核 CPU 系统中的缓存同步 等问题。 在多核系统中,每个 CPU 核心都有自己的缓存(L1, L2, L3 缓存),这些缓存可能存储过时的内存值,导致不同核心之间的数据不一致。CPU 屏障通过硬件指令,确保 CPU 按照特定的顺序执行内存操作,从而解决缓存一致性和内存重排序的问题。 CPU 屏障的作用 防止指令重排:现代 CPU 在执行指令时,通常会对指令进行重排序(指令乱序执行)以提高性能。CPU 屏障确保特定的指令顺序不被改变,避免并发编程中的数据不一致性。 确保内存操作顺序:CPU 屏障通过禁止指令重排和缓存同步,确保内存操作按预期的顺序发生。这对多核处理器尤其重要,避免不同核心之间的数据不一致问题。 控制缓存一致性:当一个核心修改内存中的某个值时,CPU 屏障可以确保这个修改值被写回主内存,并通知其他核心从主内存读取最新的值。 与内存屏障的区别 内存屏障(Memory Barrier) 是一种在软件层面控制 CPU 内存操作顺序的机制。它可以是一个指令,告诉 CPU 按照特定顺序访问内存,避免乱序执行或缓存不一致。 CPU 屏障 则是硬件级别的机制,它通过处理器的硬件指令实现类似的同步操作。CPU 屏障直接控制 CPU 内部的缓存管理和指令流水线,从而确保内存操作的顺序。 CPU 屏障的类型 不同的 CPU 和架构(如 x86、ARM、PowerPC)提供不同的屏障指令,以下是一些常见的屏障类型: 全屏障(Full Barrier):也叫作 全内存屏障,会确保指令完全按顺序执行,通常会阻止所有的加载(Load)和存储(Store)操作的重排序。全屏障适用于需要完全同步内存访问的场景。 加载屏障(Load Barrier):用于控制加载指令(读取内存)的顺序,保证在屏障前的加载操作完成后,才能执行屏障后的加载操作。 存储屏障(Store Barrier):用于控制存储指令(写入内存)的顺序,确保在屏障前的写操作完成后,才能执行屏障后的写操作。 轻量级屏障(Light Barrier):有些现代 CPU 提供更细粒度的屏障,能够针对特定类型的指令(如仅仅是缓存一致性)进行同步。 典型的 CPU 屏障指令 1. x86 架构: MFENCE:这是 x86 架构中的一个全屏障指令,它确保所有的加载和存储指令在屏障前后都按顺序执行。 LFENCE:加载屏障,用于确保加载操作的顺序。 SFENCE:存储屏障,用于确保存储操作的顺序。 2. ARM 架构: DMB(Data Memory Barrier):用于确保数据内存操作的顺序。DMB 会阻止内存操作的重排序。 DSB(Data Synchronization Barrier):一个更强的同步屏障,通常会确保所有的内存操作完成,才会继续执行后续操作。 ISB(Instruction Synchronization Barrier):强制 CPU 刷新指令流水线,确保指令同步。 3. PowerPC 架构: sync:PowerPC 中的同步指令,强制执行内存访问的顺序。 CPU 屏障的应用场景 多线程编程和并发控制: 在多线程程序中,多个线程可能会同时访问共享内存。使用 CPU 屏障可以确保线程之间的内存操作顺序,从而避免出现数据不一致的情况。 内存模型: 在某些硬件平台上,特别是在不同架构(如 x86 和 ARM)之间进行程序移植时,CPU 屏障能够确保程序按照预期的内存顺序执行。 硬件编程: 对于直接操作硬件的低级编程(例如内存映射 I/O、嵌入式系统开发等),CPU 屏障能够确保 I/O 操作按顺序完成,避免因为缓存一致性问题导致硬件异常。 例子:在 x86 架构中的应用 假设有一个共享变量 shared_var,多个线程可能会修改它。如果不使用 CPU 屏障,线程 1 修改 shared_var 后,可能没有立即刷新到主内存,线程 2 可能会看到过时的缓存数据。 以下是一个简单的 C 代码示例: #include #include volatile int shared_var = 0; void thread1() { shared_var = 1; // 修改共享变量 __asm__ __volatile__ ("mfence" ::: "memory"); // 使用 MFENCE 全屏障,确保写操作完成} void thread2() { while (shared_var == 0) { // 等待 thread1 更新 shared_var } printf("shared_var updated to 1\n");} int main() { // 模拟两个线程 thread1(); thread2(); return 0;} 在这个示例中,thread1 在修改 shared_var 后使用了 mfence 指令来确保修改后的值及时写入主内存,并且被其他线程看到。thread2 会等待 shared_var 更新后继续执行。 总结 CPU 屏障 是硬件层面的机制,用来控制 CPU 内部指令执行顺序和缓存同步,确保内存操作按照特定顺序发生。 它防止 CPU 对指令的重排序,从而避免多核环境中的缓存一致性问题。 不同的 CPU 架构(如 x86、ARM)提供了不同类型的 CPU 屏障指令,如 MFENCE(x86)、DMB(ARM)等。 小结图示 +-------------------------------+ | 主内存(RAM) | | | | +-------------------------+ | | | 共享变量(shared_var) | | | +-------------------------+ | +-------------------------------+ | | v v +----------------+ +----------------+ | 核心 1 | | 核心 2 | | L1 缓存 | | L1 缓存 | | 存储变量值 | | 存储变量值 | +----------------+ +----------------+ | | v v CPU 屏障(如 MFENCE) 强制同步缓存与主内存 通过 CPU 屏障的使用,确保 核心 1 对 shared_var 的修改能正确地同步到主内存,核心 2 可以看到最新的值。 五、使用内存屏障还需要使用CPU屏障吗? 对于大多数开发人员来说,内存屏障(Memory Barriers)通常是足够的,因为它们是软件级别的同步机制,而CPU 屏障(CPU Barriers)是硬件级别的机制,两者的目标都是确保内存操作按预期顺序执行。内存屏障通过插入特定的指令来控制 CPU 的缓存一致性和内存操作顺序,通常通过编译器提供的原语(如 __sync_synchronize、atomic_thread_fence、mfence 等)来实现。 1. 内存屏障 vs CPU 屏障 内存屏障: 由 程序员显式插入,通常作为一条特殊的汇编指令或者编译器指令。 它确保了程序中的某些内存操作顺序不会被优化或乱序执行。 对于 程序员来说,内存屏障是一个高层的工具,在需要同步共享数据时,它是最常用的同步机制。 CPU 屏障: 是 硬件层面的同步机制,直接控制 CPU 内部的缓存、指令流水线和内存访问顺序。 这些机制通常是针对 处理器架构(如 x86、ARM)的具体硬件指令,用于确保内存操作按顺序发生。 程序员通常 不直接操作 CPU 屏障,而是通过内存屏障指令间接地影响硬件行为。 2. 内存屏障满足开发者需求 对于程序员而言,使用 内存屏障 就足够了,因为: 内存屏障指令是跨平台的,开发人员不需要针对特定的 CPU 指令来编写代码。它们会依赖 编译器 来生成适合特定平台的机器代码。 编译器和操作系统已经为我们处理了硬件的差异。程序员插入的内存屏障会触发适当的 CPU 屏障或其他低层次同步机制,具体取决于目标平台的 CPU 和架构。 在 多核处理器 上,内存屏障确保一个线程对共享数据的修改能够及时写回主内存,并且通知其他线程访问这些数据时能看到最新的值。 3. 实际应用中的差异 3.1. 使用内存屏障 内存屏障(如__sync_synchronize、atomic_thread_fence、mfence)通过插入到代码中来显式指定内存操作顺序,从而控制并发操作时的内存一致性问题。 例如,在多线程编程中,你希望线程 A 在修改共享变量之后,线程 B 能立即看到该修改,可以在 A 中插入内存屏障: #include std::atomic<int> shared_var = 0; void thread_a() { shared_var.store(1, std::memory_order_release); // 写屏障} void thread_b() { while (shared_var.load(std::memory_order_acquire) == 0) { // 等待线程 A 更新 shared_var } // 现在可以安全地使用 shared_var} 在此例中,std::memory_order_release 和 std::memory_order_acquire 就是内存屏障的类型,它们保证了内存操作的顺序。 3.2. CPU 屏障 CPU 屏障是硬件级别的机制。程序员一般不会显式写入 CPU 屏障指令,除非进行非常低级的硬件编程或直接操作硬件。 例如,在嵌入式系统开发或驱动开发中,可能会看到 mfence 或其他处理器指令: __asm__ __volatile__("mfence" ::: "memory"); 在这种情况下,程序员直接插入了硬件指令,显式控制 CPU 执行顺序。但是在 一般应用程序开发 中,通常并不需要手动插入这些指令,而是依赖编译器和标准库中的内存屏障。 4. 总结 对于大多数应用程序开发人员,内存屏障已经足够,不需要直接使用 CPU 屏障指令。编译器会根据目标硬件生成合适的机器代码,确保内存操作的顺序性和一致性。 内存屏障:适用于大多数开发者,提供了一个高层次的同步工具,确保不同线程之间的内存操作顺序。 CPU 屏障:属于硬件层面的操作,通常程序员不需要直接处理,除非进行底层的硬件开发。 5. 应用场景 多线程同步:确保线程对共享数据的修改能够及时生效,避免缓存不一致导致的数据不一致问题。 优化:在多核处理器中,通过内存屏障确保正确的内存操作顺序,避免因指令重排和缓存不一致带来的问题。 6. 建议 a. 如果你在编写并发程序,内存屏障是足够的,尝试使用标准库中的原子操作和内存顺序。 b. 如果你进行低级别硬件编程(例如驱动开发、嵌入式系统),可能需要直接使用 CPU 屏障。 六、“原子操作/互斥锁” 与 “内存屏障” 1. 原子操作与内存屏障的对比 原子操作(Atomic Operation)是对某个特定变量进行原子级别的访问(通常是加、减、交换等操作),它确保操作对这个变量的修改是不可分割的,即操作要么完全执行,要么完全不执行。 原子操作的作用范围:原子操作是针对特定变量的,只会影响该变量的状态,它在进行操作时,也会隐含地执行内存屏障,确保对该变量的修改对其他线程可见。 内存屏障的作用范围:内存屏障则更加广泛,它会影响到线程的所有共享变量,确保整个内存访问顺序符合预期。 简而言之,原子操作是对某个特定变量的一种操作,保证它的正确性;而内存屏障控制的是线程的所有共享变量的访问顺序,确保不同线程之间的同步和一致性。 2. 互斥锁的读写屏障 互斥锁(Mutex)作为线程同步的一种机制,通常会涉及到内存屏障。因为互斥锁的使用会影响多个线程对共享数据的访问顺序,因此通常在加锁和解锁时,操作系统会插入内存屏障,确保在锁被持有时,所有操作会遵循正确的顺序。 加锁(线程持锁)时,需要确保该线程读取的共享数据是 最新的,因此需要通过 读屏障 来保证锁保护的变量被同步到主内存,确保读到的是最新的数据。 解锁(释放锁)时,需要确保该线程修改的数据被 同步到主内存,并通知其他线程,这通常通过 写屏障 来实现,以保证其他线程能看到更新后的数据。 加锁/解锁时的内存屏障作用示意: 加锁:会确保在加锁前,当前线程对共享数据的所有操作(尤其是写入)已经完成,并且数据对其他线程可见。这通常通过插入写屏障来实现。 解锁:会确保在解锁后,其他线程能看到当前线程对共享数据的最新修改。这通常通过插入读屏障来实现。 3. 总结 内存屏障: 内存屏障的作用是保护共享数据的一致性,确保线程对共享变量的操作顺序正确。它影响的是所有共享变量,而不仅仅是某个特定的变量。 它防止 CPU 对内存访问的乱序执行,确保内存操作按预期的顺序执行。 原子操作: 原子操作通常针对单个变量,确保操作是原子性的,即不可中断的,并且会隐式地执行内存屏障,保证操作对其他线程可见。 互斥锁和内存屏障: 互斥锁在加锁和解锁时会执行内存屏障。加锁时插入写屏障,确保当前线程的写操作完成;解锁时插入读屏障,确保其他线程能看到当前线程的修改。 4. 建议 a. 如果你编写多线程程序并使用互斥锁,确保你理解锁的加锁和解锁时的内存屏障作用,以避免数据不一致问题。 b. 了解并使用原子操作和内存屏障来控制线程间共享数据的顺序,特别是在性能要求较高的场合。 七、配合内存屏障来真正理解volatile volatile 关键字在 C 和 C++ 中用于修饰变量,告诉编译器该变量的值可能会在程序的任何时刻发生变化,通常是由外部因素(如硬件、操作系统或其他线程)引起的。使用 volatile 可以防止编译器对该变量进行优化(什么样的优化?),从而确保每次访问时都从内存中获取变量的最新值,而不是从寄存器或缓存中读取过时的值。 优化?变量的优化就是CPU指令对此变量的操作一直在缓存中执行,增加运行速度。 防止优化?其实是防止读优化,即每次读操作都从主内存取,但是写操作不负责优化,可能写完大概率还在缓存中,其他同时在其他CPU核心运行的线程依然拿不到最新值。 等价于每次使用此变量之前都会触发一次“只针对这个变量的读屏障”(这是个比喻,读屏障不会只针对单独一个变量) 主要作用: 1.防止编译器优化: 编译器为了提高性能,通常会将变量存储在寄存器中,并减少对内存的访问。但是,如果一个变量是由外部因素改变的(比如硬件寄存器或其他线程修改),编译器可能不会及时从内存中读取该变量的最新值。 使用 volatile 告诉编译器:“不要优化对这个变量的访问,每次都直接从内存中读取它。” 2.确保变量访问的正确性: 当一个变量的值可能被多个线程、信号处理程序或硬件设备(例如,I/O端口、硬件寄存器)所修改时,volatile 关键字确保每次读取时都会从内存中获取最新的值,而不是使用缓存或寄存器中的值。 3.不保证写操作同步到主内存:如果你修改了一个 volatile 变量,编译器会直接在内存中修改这个变量的值,但这并不意味着其他核心的 CPU 或线程会立即看到这个修改。它不会自动确保 这个修改会立即同步到其他线程或 CPU 核心的缓存中。 4.volatile的作用范围:它确保你每次访问时都能获取到变量的最新值,但并不会同步其他线程或 CPU 中的缓存。例如,在多核处理器环境下,CPU 核心 A 可能会缓存某个变量,而 CPU 核心 B 可能并不知道核心 A 修改了这个变量,volatile 不能解决这种缓存一致性问题。 适用场景: 硬件寄存器:在嵌入式系统中,直接映射到硬件的内存地址经常会用 volatile,以确保每次访问时都读取硬件的最新值,而不是使用缓存。 多线程共享变量:当多个线程共享一个变量时,某个线程对该变量的修改可能会被其他线程及时看到,volatile 可以确保每次访问该变量时,都能获取到最新的值。 信号处理函数中的变量:在多线程或多进程的环境中,如果信号处理程序修改了某个变量,程序中的其他部分在访问这个变量时,需要确保获取到的是最新的值,因此也需要用 volatile。 使用示例: #include volatile int flag = 0; // 声明为volatile,表示它的值可能被外部因素改变 void signal_handler() { flag = 1; // 外部信号处理程序改变flag的值} void wait_for_flag() { while (flag == 0) { // 这里每次访问flag时,都会从内存中读取,而不是使用寄存器或缓存中的值 } printf("Flag is set!\n");} int main() { wait_for_flag(); // 等待flag被信号处理程序修改 return 0;} 在这个例子中: flag 变量声明为 volatile,表示它可能在程序的其他地方(如信号处理函数 signal_handler)被修改。 wait_for_flag 函数会在 flag 被修改之前一直等待。当 flag 被设置为 1 时,程序才会继续执行。 volatile 确保每次检查 flag 时都从内存读取,而不是从寄存器缓存中读取过时的值。 注意事项: 1.volatile 不等同于原子性或线程同步: volatile 只确保了 每次都从内存中读取变量的值,它并不会提供线程安全或原子性。例如,如果多个线程同时修改一个 volatile 变量,它仍然会有竞态条件(race condition)问题,因此在这种情况下,还需要使用互斥锁或原子操作来保证同步。 2.volatile不会防止缓存一致性问题: 在多核 CPU 系统中,volatile 并不能解决不同核心之间的缓存一致性问题。缓存一致性通常是由硬件(如 MESI 协议)或内存屏障来处理的。 3.volatile主要是为了防止编译器优化: 在大多数情况下,volatile 只是告诉编译器不要优化对变量的访问,它并不改变该变量的实际行为或内存模型。 结论: volatile 主要用于防止编译器优化,确保程序每次访问变量时都从内存中读取它的最新值,适用于那些可能被外部因素(硬件、其他线程、信号处理程序)改变的变量。 它 不会 处理多线程之间的同步问题,如果需要同步,则应该配合 互斥锁(mutex)或其他线程同步机制。 相关建议 a. 在多线程编程中,除了 volatile,你还应该了解如何使用 原子操作、互斥锁 或 条件变量 来确保共享数据的一致性。 b. 如果涉及到硬件寄存器的访问,理解 volatile 的使用是非常重要的,同时要注意与内存屏障和同步机制配合使用。

    01-03 43浏览
正在努力加载更多...
广告