• 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 62浏览
  • 用状态机在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 128浏览
  • 真心不粗!适合练手的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 152浏览
  • FPGA中计算角度正余弦值的两种方法

    一. FOC之使用Cordic算法求解sin/cos 在进行坐标变换的时候,需要计算角度的正余弦值,而在FPGA中是不能直接进行求解的,需要采用其它的方式进行求解。最常使用的方法有如下两种: 基于ROM的查找表方式:  首先在PC上使用python等高级语言将一个周期内的正余弦值全部计算出来,角度的分辨率根据实际需求来确定,分辨率越精细,那么需要存储ROM的深度就越深,反之约小,然后将计算出来的正余弦值进行一个扩大取整保留数据精度,最后按照角度顺序依次存入ROM中。很明显,通过这种方式计算正余弦值所需要的时钟周期特别短,消耗FPGA的存储资源大。 基于Cordic算法计算: Cordic算法并不直接求解正余弦值,而且通过旋转逼近的思想来进行拟合正余弦函数。该算法拟合的精度非常高,因而被广泛应用于计算机图形学、数字信号处理等领域。 Cordic算法运算过程中,只设计到移位和加减运算,这种运算是非常适合于FPGA的,从面积和计算速度两方面进行综合考虑,最终选择占用面积较小、计算速度略低的Cordic算法来求解sin/cos函数值。 首先如下图所示,假设单位圆上有任意两点Q和P,它们之间的角度关系已知,则它们的XY轴坐标可以表示如下: 将Q点的坐标公式进行展开,然后再将P点的坐标公式代入其中可得: 为了统一变量类型,将cos函数作为公共相提取出来,可以得到如下形式: 可以看出,由P点旋转至Q点后,Q点的最终表达式如上所示,这种形式便是Cordic算法旋转的基本公式了。如果将旋转初始点P设置为一个特殊位置:X轴上,那么很明显Q点的坐标值就是对应旋转角度的正余弦值。 有了上述基本推论,就可以开始真正的进行旋转拟合了。P点直接一步旋转到Q点,肯定是不可取的。如果将P点经过多次旋转,每一次旋转的角度均为特殊角度,tan函数对应的角度值如下,这样就将乘法运算巧妙的转换成了左移运算。 每一次旋转迭代的公式如下,每一次旋转的公式里面还包括了cos函数,这也是不方便在FPGA内计算的,观察表达式可以知道,cos函数在这里起到的作用是对坐标值起到等比例缩放的作用,并不会影响旋转的点对应向量的方向。 所以可以将每一次旋转过程中的cos函数提取出来,最后进行运算,这样就不用参与到每次的旋转计算中去,由于旋转的角度是已知的,所以当确定好旋转次数后,可以将这部分运算提取计算出来,作为一个系数K,K的表达式如下图所示。 接下来就是需要研究每次旋转对应的角度值了,角度对应的tan函数值是已知的,可以通过Python直接求解出对应的角度,然后汇总成如下表格: 通过上表可以看出,当旋转到16次的时候,角度的误差只有千分之一了,而cosβ和K的值均趋近于一个定值,故Cordic旋转拟合是收敛的。在旋转的过程中,可能会出现旋转角度大于目标角度的情况,所以在旋转的过程中还需要增加一个变量d来控制旋转的方向,另外用z来表示旋转到的角度值,最终的旋转迭代公式如下: 最终目标角度的正余弦值如下: FPGA内部实现的过程中,需要对旋转角度值以及K值扩大2^16次方,然后取整,为的是在保持计算精度的情况下,免去数据的小数部分,这些都是固定值,不会根据目标角度的变化而变化,可以在程序中直接定义出来,如下图所示。 另外还要一个关键点需要注意的是迭代公式中使用的是tan函数,需要对目标角度限制在-90°到90°范围内,所以在目标角度输入模块之后,需要先对角度进行一个象限变换,为了处理的方便,本设计将目标角度变换到第一象限内,也就是0°到90°,如下图所示,象限变换不会影响正余弦数组的大小,只会影响其数值的符号,所以在迭代完成后,根据需要对坐标点进行取反运行即可。   

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

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

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

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

    01-08 57浏览
  • 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浏览
  • 为什么建议你用表驱动法?嵌入式C语言代码开发技巧

    数据压倒一切。如果选择了正确的数据结构并把一切组织的井井有条,正确的算法就不言自明。编程的核心是数据结构,而不是算法。——Rob Pike 说明 本文基于这样的认识:数据是易变的,逻辑是稳定的。本文例举的编程实现多为代码片段,但不影响描述的完整性。本文例举的编程虽然基于C语言,但其编程思想也适用于其他语言。此外,本文不涉及语言相关的运行效率讨论。 概念提出 所谓表驱动法(Table-Driven Approach)简而言之就是用查表的方法获取数据。此处的“表”通常为数组,但可视为数据库的一种体现。 根据字典中的部首检字表查找读音未知的汉字就是典型的表驱动法,即以每个字的字形为依据,计算出一个索引值,并映射到对应的页数。相比一页一页地顺序翻字典查字,部首检字法效率极高。 具体到编程方面,在数据不多时可用逻辑判断语句(if…else或switch…case)来获取值;但随着数据的增多,逻辑语句会越来越长,此时表驱动法的优势就开始显现。 例如,用36进制(A表示10,B表示11,…)表示更大的数字,逻辑判断语句如下: if(ucNum < 10) {     ucNumChar = ConvertToChar(ucNum); } else if(ucNum == 10) {     ucNumChar = 'A'; } else if(ucNum == 11) {     ucNumChar = 'B'; } else if(ucNum == 12) {     ucNumChar = 'C'; } //... ... else if(ucNum == 35) {     ucNumChar = 'Z'; } 当然也可以用 switch…case 结构,但实现都很冗长。而用表驱动法(将numChar 存入数组)则非常直观和简洁。如: CHAR aNumChars[] = {'0', '1', '2', /*3~9*/'A', 'B', 'C', /*D~Y*/'Z'}; CHAR ucNumChar = aNumChars[ucNum % sizeof(aNumChars)]; 像这样直接将变量当作下数组下标来读取数值的方法就是直接查表法。 注意,如果熟悉字符串操作,则上述写法可以更简洁: CHAR ucNumChar = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[ucNum]; 使用表驱动法时需要关注两个问题:一是如何查表,从表中读取正确的数据;二是表里存放什么,如数值或函数指针。前者参见1.1节“查表方式”内容,后者参见1.2节“实战示例”内容。 查表方式 常用的查表方式有直接查找、索引查找和分段查找等。 直接查找 即直接通过数组下标获取到数据。如果熟悉哈希表的话,可以很容易看出这种查表方式就是哈希表的直接访问法。 如获取星期名称,逻辑判断语句如下: if(0 == ucDay) {     pszDayName = "Sunday"; } else if(1 == ucDay) {     pszDayName = "Monday"; } //... ... else if(6 == ucDay) {     pszDayName = "Saturday"; } 而实现同样的功能,可将这些数据存储到一个表里: CHAR *paNumChars[] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; CHAR *pszDayName = paNumChars[ucDay]; 类似哈希表特性,表驱动法适用于无需有序遍历数据,且数据量大小可提前预测的情况。 对于过于复杂和庞大的判断,可将数据存为文件,需要时加载文件初始化数组,从而在不修改程序的情况下调整里面的数值。 有时,访问之前需要先进行一次键值转换。如表驱动法表示端口忙闲时,需将槽位端口号映射为全局编号。所生成的端口数目大小的数组,其下标对应全局端口编号,元素值表示相应端口的忙闲状态。 索引查找 有时通过一次键值转换,依然无法把数据(如英文单词等)转为键值。此时可将转换的对应关系写到一个索引表里,即索引访问。 如现有100件商品,4位编号,范围从0000到9999。此时只需要申请一个长度为100的数组,且对应2位键值。但将4位的编号转换为2位的键值,可能过于复杂或没有规律,最合适的方法是建立一个保存该转换关系的索引表。采用索引访问既节省内存,又方便维护。比如索引A表示通过名称访问,索引B表示通过编号访问。 分段查找 通过确定数据所处的范围确定分类(下标)。有的数据可分成若干区间,即具有阶梯性,如分数等级。此时可将每个区间的上限(或下限)存到一个表中,将对应的值存到另一表中,通过第一个表确定所处的区段,再由区段下标在第二个表里读取相应数值。注意要留意端点,可用二分法查找,另外可考虑通过索引方法来代替。 如根据分数查绩效等级: #define MAX_GRADE_LEVEL (INT8U)5 DOUBLE aRangeLimit[MAX_GRADE_LEVEL] = {50.0, 60.0, 70.0, 80.0, 100.0}; CHAR *paGrades[MAX_GRADE_LEVEL] = {"Fail", "Pass", "Credit", "Distinction", "High Distinction"}; static CHAR* EvaluateGrade(DOUBLE dScore) {     INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < aRangeLimit[ucLevel]) return paGrades[ucLevel];     } return paGrades[0]; } 上述两张表(数组)也可合并为一张表(结构体数组),如下所示: typedef struct{     DOUBLE  aRangeLimit;     CHAR    *pszGrade; }T_GRADE_MAP; T_GRADE_MAP gGradeMap[MAX_GRADE_LEVEL] = {     {50.0, "Fail"},     {60.0, "Pass"},     {70.0, "Credit"},     {80.0, "Distinction"},     {100.0, "High Distinction"} }; static CHAR* EvaluateGrade(DOUBLE dScore) {     INT8U ucLevel = 0; for(; ucLevel < MAX_GRADE_LEVEL; ucLevel++) { if(dScore < gGradeMap[ucLevel].aRangeLimit) return gGradeMap[ucLevel].pszGrade;     } return gGradeMap[0].pszGrade; } 该表结构已具备的数据库的雏形,并可扩展支持更为复杂的数据。其查表方式通常为索引查找,偶尔也为分段查找;当索引具有规律性(如连续整数)时,退化为直接查找。 使用分段查找法时应注意边界,将每一分段范围的上界值都考虑在内。 找出所有不在最高一级范围内的值,然后把剩下的值全部归入最高一级中。有时需要人为地为最高一级范围添加一个上界。 同时应小心不要错误地用“<”来代替“<=”。要保证循环在找出属于最高一级范围内的值后恰当地结束,同时也要保证恰当处理范围边界。 实战示例 本节多数示例取自实际项目。表形式为一维数组、二维数组和结构体数组;表内容有数据、字符串和函数指针。基于表驱动的思想,表形式和表内容可衍生出丰富的组合。 字符统计 问题:统计用户输入的一串数字中每个数字出现的次数。 普通解法主体代码如下: INT32U aDigitCharNum[10] = {0}; /* 输入字符串中各数字字符出现的次数 */ INT32U dwStrLen = strlen(szDigits); INT32U dwStrIdx = 0; for(; dwStrIdx < dwStrLen; dwStrIdx++) { switch(szDigits[dwStrIdx])     { case '1':             aDigitCharNum[0]++; break; case '2':             aDigitCharNum[1]++; break; //... ... case '9':             aDigitCharNum[8]++; break;     } } 这种解法的缺点显而易见,既不美观也不灵活。其问题关键在于未将数字字符与数组aDigitCharNum下标直接关联起来。 以下示出更简洁的实现方式: for(; dwStrIdx < dwStrLen; dwStrIdx++) { aDigitCharNum[szDigits[dwStrIdx] - '0']++; } 上述实现考虑到0也为数字字符。该解法也可扩展至统计所有ASCII可见字符。 月天校验 问题:对给定年份和月份的天数进行校验(需区分平年和闰年)。 普通解法主体代码如下: switch(OnuTime.Month) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: if(OnuTime.Day>31 || OnuTime.Day<1)         {             CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~31)!!!\n", OnuTime.Day);             retcode = S_ERROR;         } break; case 2: if(((OnuTime.Year%4 == 0) && (OnuTime.Year%100 != 0)) || (OnuTime.Year%400 == 0))         { if(OnuTime.Day>29 || OnuTime.Day<1)             {                 CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~29)!!!\n", OnuTime.Day);                 retcode = S_ERROR;             }         } else { if(OnuTime.Day>28 || OnuTime.Day<1)             {                 CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~28)!!!\n", OnuTime.Day);                 retcode = S_ERROR;             }         } break; case 4: case 6: case 9: case 11: if(OnuTime.Day>30 || OnuTime.Day<1)         {             CtcOamLog(FUNCTION_Pon,"Don't support this Day: %d(1~30)!!!\n", OnuTime.Day);             retcode = S_ERROR;         } break; default:         CtcOamLog(FUNCTION_Pon,"Don't support this Month: %d(1~12)!!!\n", OnuTime.Month);         retcode = S_ERROR; break; } 以下示出更简洁的实现方式: #define MONTH_OF_YEAR 12 /* 一年中的月份数 */ /* 闰年:能被4整除且不能被100整除,或能被400整除 */ #define IS_LEAP_YEAR(year) ((((year) % 4 == 0) && ((year) % 100 != 0)) || ((year) % 400 == 0)) /* 平年中的各月天数,下标对应月份 */ INT8U aDayOfCommonMonth[MONTH_OF_YEAR] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; INT8U ucMaxDay = 0; if((OnuTime.Month == 2) && (IS_LEAP_YEAR(OnuTime.Year)))     ucMaxDay = aDayOfCommonMonth[1] + 1; else ucMaxDay = aDayOfCommonMonth[OnuTime.Month-1]; if((OnuTime.Day < 1) || (OnuTime.Day > ucMaxDay) {     CtcOamLog(FUNCTION_Pon,"Month %d doesn't have this Day: %d(1~%d)!!!\n",               OnuTime.Month, OnuTime.Day, ucMaxDay);     retcode = S_ERROR; } 名称构造 问题:根据WAN接口承载的业务类型(Bitmap)构造业务类型名称字符串。 普通解法主体代码如下: void Sub_SetServerType(INT8U *ServerType, INT16U wan_servertype) { if ((wan_servertype & 0x0001) == 0x0001)     {         strcat(ServerType, "_INTERNET");     } if ((wan_servertype & 0x0002) == 0x0002)     {         strcat(ServerType, "_TR069");     } if ((wan_servertype & 0x0004) == 0x0004)     {         strcat(ServerType, "_VOIP");     } if ((wan_servertype & 0x0008) == 0x0008)     {         strcat(ServerType, "_OTHER");     } } 以下示出C语言中更简洁的实现方式: /* 获取var变量第bit位,编号从右至左 */ #define GET_BIT(var, bit) (((var) >> (bit)) & 0x1) const CHAR* paSvrNames[] = {"_INTERNET", "_TR069", "_VOIP", "_OTHER"}; const INT8U ucSvrNameNum = sizeof(paSvrNames) / sizeof(paSvrNames[0]); VOID SetServerType(CHAR *pszSvrType, INT16U wSvrType) {     INT8U ucIdx = 0; for(; ucIdx < ucSvrNameNum; ucIdx++) { if(1 == GET_BIT(wSvrType, ucIdx))             strcat(pszSvrType, paSvrNames[ucIdx]);     } } 新的实现将数据和逻辑分离,维护起来非常方便。只要逻辑(规则)不变,则唯一可能的改动就是数据(paSvrNames)。 值名解析 问题:根据枚举变量取值输出其对应的字符串,如PORT_FE(1)输出“Fe”。 //值名映射表结构体定义,用于数值解析器 typedef struct{     INT32U dwElem; //待解析数值,通常为枚举变量 CHAR*  pszName; //指向数值所对应解析名字符串的指针 }T_NAME_PARSER; /****************************************************************************** * 函数名称:  NameParser * 功能说明:  数值解析器,将给定数值转换为对应的具名字符串 * 输入参数:  VOID *pvMap       :值名映射表数组,含T_NAME_PARSER结构体类型元素                                 VOID指针允许用户在保持成员数目和类型不变的前提下,                                 定制更有意义的结构体名和/或成员名。              INT32U dwEntryNum :值名映射表数组条目数              INT32U dwElem     :待解析数值,通常为枚举变量              INT8U* pszDefName :缺省具名字符串指针,可为空 * 输出参数:  NA * 返回值  :  INT8U *: 数值所对应的具名字符串              当无法解析给定数值时,若pszDefName为空,则返回数值对应的16进制格式              字符串;否则返回pszDefName。 ******************************************************************************/ INT8U *NameParser(VOID *pvMap, INT32U dwEntryNum, INT32U dwElem, INT8U* pszDefName) {     CHECK_SINGLE_POINTER(pvMap, "NullPoniter");     INT32U dwEntryIdx = 0; for(dwEntryIdx = 0; dwEntryIdx < dwEntryNum; dwEntryIdx++) { T_NAME_PARSER *ptNameParser = (T_NAME_PARSER *)pvMap; if(dwElem == ptNameParser->dwElem)         { return ptNameParser->pszName;         } //ANSI标准禁止对void指针进行算法操作;GNU标准则指定void*算法操作与char*一致。 //若考虑移植性,可将pvMap类型改为INT8U*,或定义INT8U*局部变量指向pvMap。 pvMap += sizeof(T_NAME_PARSER);     } if(NULL != pszDefName)     { return pszDefName;     } else { static INT8U szName[12] = {0}; //Max:"0xFFFFFFFF" sprintf(szName, "0x%X", dwElem); return szName;     } } 以下给出NameParser的简单应用示例: //UNI端口类型值名映射表结构体定义 typedef struct{     INT32U dwPortType;     INT8U* pszPortName; }T_PORT_NAME; //UNI端口类型解析器 T_PORT_NAME gUniNameMap[] = {     {1, "Fe"},     {3, "Pots"},     {99, "Vuni"} }; const INT32U UNI_NAM_MAP_NUM = (INT32U)(sizeof(gUniNameMap)/sizeof(T_PORT_NAME)); VOID NameParserTest(VOID) {     INT8U ucTestIndex = 1;     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("DefName", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0, "DefName")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Fe", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 1, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Pots", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 3, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Vuni", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 99, NULL)) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("Unknown", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 255, "Unknown")) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("0xABCD", NameParser(gUniNameMap, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK");     printf("[%s] Result: %s!\n", __FUNCTION__, ucTestIndex++,            strcmp("NullPoniter", NameParser(NULL, UNI_NAM_MAP_NUM, 0xABCD, NULL)) ? "ERROR" : "OK"); } gUniNameMap在实际项目中有十余个条目,若采用逻辑链实现将非常冗长。 取值映射 问题:不同模块间同一参数枚举值取值可能有所差异,需要适配。 此处不再给出普通的switch…case或if…else if…else结构,而直接示出以下表驱动实现: typedef struct{     PORTSTATE loopMEState;     PORTSTATE loopMIBState; }LOOPMAPSTRUCT; static LOOPMAPSTRUCT s_CesLoop[] = {     {NO_LOOP,                  e_ds1_looptype_noloop},     {PAYLOAD_LOOP,             e_ds1_looptype_PayloadLoop},     {LINE_LOOP,                e_ds1_looptype_LineLoop},     {PON_LOOP,                 e_ds1_looptype_OtherLoop},     {CES_LOOP,                 e_ds1_looptype_InwardLoop}}; PORTSTATE ConvertLoopMEStateToMIBState(PORTSTATE vPortState) {     INT32U num = 0, ii;          num = ARRAY_NUM(s_CesLoop); for(ii = 0; ii < num; ii++) { if(vPortState == s_CesLoop[ii].loopMEState) return s_CesLoop[ii].loopMIBState;     } return e_ds1_looptype_noloop; } 相应地,从loopMIBState映射到loopMEState需要定义一个ConvertLoopMIBStateToMEState函数。更进一步,所有类似的一对一映射关系都必须如上的映射(转换)函数,相当繁琐。事实上,从抽象层面看,该映射关系非常简单。提取共性后定义带参数宏,如下所示: /********************************************************** * 功能描述:进行二维数组映射表的一对一映射,用于参数适配 * 参数说明:map        -- 二维数组映射表             elemSrc    -- 映射源,即待映射的元素值             elemDest   -- 映射源对应的映射结果             direction  -- 映射方向字节,表示从数组哪列映射至哪列。                           高4位对应映射源列,低4位对应映射结果列。             defaultVal -- 映射失败时置映射结果为缺省值 * 示例:ARRAY_MAPPER(gCesLoopMap, 3, ucLoop, 0x10, NO_LOOP);             则ucLoop = 2(LINE_LOOP) **********************************************************/ #define ARRAY_MAPPER(map, elemSrc, elemDest, direction, defaultVal) do{\     INT8U ucMapIdx = 0, ucMapNum = 0; \     ucMapNum = sizeof(map)/sizeof(map[0]); \ for(ucMapIdx = 0; ucMapIdx < ucMapNum; ucMapIdx++) \ { \ if((elemSrc) == map[ucMapIdx][((direction)&0xF0)>>4]) \         { \             elemDest = map[ucMapIdx][(direction)&0x0F]; \ break; \         } \     } \ if(ucMapIdx == ucMapNum) \     { \         elemDest = (defaultVal); \     } \ }while(0) 参数取值转换时直接调用统一的映射器宏,如下: static INT8U gCesLoopMap[][2] = {     {NO_LOOP,                  e_ds1_looptype_noloop},     {PAYLOAD_LOOP,             e_ds1_looptype_PayloadLoop},     {LINE_LOOP,                e_ds1_looptype_LineLoop},     {PON_LOOP,                 e_ds1_looptype_OtherLoop},     {CES_LOOP,                 e_ds1_looptype_InwardLoop}}; ARRAY_MAPPER(gCesLoopMap, tPara.dwParaVal[0], dwLoopConf, 0x01, e_ds1_looptype_noloop); 另举一例: #define CES_DEFAULT_JITTERBUF (INT32U)2000 /* 默认jitterbuf为2000us,而1帧=125us */ #define CES_JITTERBUF_STEP (INT32U)125 /* jitterbuf步长为125us,即1帧 */ #define CES_DEFAULT_QUEUESIZE (INT32U)5 #define CES_DEFAULT_MAX_QUEUESIZE (INT32U)7 #define ARRAY_NUM(array) (sizeof(array) / sizeof((array)[0])) /* 数组元素个数 */ typedef struct{     INT32U  dwJitterBuffer;     INT32U  dwFramePerPkt;     INT32U  dwQueueSize; }QUEUE_SIZE_MAP; /* gCesQueueSizeMap也可以(JitterBuffer / FramePerPkt)值为索引,更加紧凑 */ static QUEUE_SIZE_MAP gCesQueueSizeMap[]= {        {1,1,1},  {1,2,1},  {2,1,2},  {2,2,1},        {3,1,3},  {3,2,1},  {4,1,3},  {4,2,1},        {5,1,4},  {5,2,3},  {6,1,4},  {6,2,3},        {7,1,4},  {7,2,3},  {8,1,4},  {8,2,3},        {9,1,5},  {9,2,4},  {10,1,5}, {10,2,4},        {11,1,5}, {11,2,4}, {12,1,5}, {12,2,4},        {13,1,5}, {13,2,4}, {14,1,5}, {14,2,4},        {15,1,5}, {15,2,4}, {16,1,5}, {16,2,4},        {17,1,6}, {17,2,5}, {18,1,6}, {18,2,5},        {19,1,6}, {19,2,5}, {20,1,6}, {20,2,5},        {21,1,6}, {21,2,5}, {22,1,6}, {22,2,5},        {23,1,6}, {23,2,5}, {24,1,6}, {24,2,5},        {25,1,6}, {25,2,5}, {26,1,6}, {26,2,5},        {27,1,6}, {27,2,5}, {28,1,6}, {28,2,5},        {29,1,6}, {29,2,5}, {30,1,6}, {30,2,5},        {31,1,6}, {31,2,5}, {32,1,6}, {32,2,5}}; /********************************************************** * 函数名称:CalcQueueSize * 功能描述:根据JitterBuffer和FramePerPkt计算QueueSize * 注意事项:配置的最大缓存深度 *            = 2 * JitterBuffer / FramePerPkt *            = 2 * N Packet = 2 ^ QueueSize *            JitterBuffer为125us帧速率的倍数, *            FramePerPkt为每个分组的帧数, *            QueueSize向上取整,最大为7。 **********************************************************/ INT32U CalcQueueSize(INT32U dwJitterBuffer, INT32U dwFramePerPkt) {     INT8U ucIdx = 0, ucNum = 0; //本函数暂时仅考虑E1 ucNum = ARRAY_NUM(gCesQueueSizeMap); for(ucIdx = 0; ucIdx < ucNum; ucIdx++) { if((dwJitterBuffer == gCesQueueSizeMap[ucIdx].dwJitterBuffer) &&           (dwFramePerPkt == gCesQueueSizeMap[ucIdx].dwFramePerPkt))        { return gCesQueueSizeMap[ucIdx].dwQueueSize;        }     } return CES_DEFAULT_MAX_QUEUESIZE; } 版本控制 问题:控制OLT与ONU之间的版本协商。ONU本地设置三比特控制字,其中bit2(MSB)~bit0(LSB)分别对应0x21、0x30和0xAA版本号;且bitX为0表示上报对应版本号,bitX为1表示不上报对应版本号。其他版本号如0x20、0x13和0x1必须上报,即不受控制。 最初的实现采用if…else if…else结构,代码非常冗长,如下: pstSendTlv->ucLength = 0x1f; if (gOamCtrlCode == 0) {     vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3);     pstSendTlv->aucVersionList[3] = 0x30;     vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3);     pstSendTlv->aucVersionList[7] = 0x21;     vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3);     pstSendTlv->aucVersionList[11] = 0x20;     vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3);     pstSendTlv->aucVersionList[15] = 0x13;     vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3);     pstSendTlv->aucVersionList[19] = 0x01;     vosMemCpy(&(pstSendTlv->aucVersionList[20]), ctc_oui, 3);     pstSendTlv->aucVersionList[23] = 0xaa; } else if (gOamCtrlCode == 1) {     vosMemCpy(pstSendTlv->aucVersionList, ctc_oui, 3);     pstSendTlv->aucVersionList[3] = 0x30;     vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3);     pstSendTlv->aucVersionList[7] = 0x21;     vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3);     pstSendTlv->aucVersionList[11] = 0x20;     vosMemCpy(&(pstSendTlv->aucVersionList[12]), ctc_oui, 3);     pstSendTlv->aucVersionList[15] = 0x13;     vosMemCpy(&(pstSendTlv->aucVersionList[16]), ctc_oui, 3);     pstSendTlv->aucVersionList[19] = 0x01; } //此处省略gOamCtrlCode == 2~6的处理代码 else if (gOamCtrlCode == 7) {     vosMemCpy(&(pstSendTlv->aucVersionList), ctc_oui, 3);     pstSendTlv->aucVersionList[3] = 0x20;     vosMemCpy(&(pstSendTlv->aucVersionList[4]), ctc_oui, 3);     pstSendTlv->aucVersionList[7] = 0x13;     vosMemCpy(&(pstSendTlv->aucVersionList[8]), ctc_oui, 3);     pstSendTlv->aucVersionList[11] = 0x01; } 以下示出C语言中更简洁的实现方式(基于二维数组): /********************************************************************** * 版本控制字数组定义 * gOamCtrlCode:   Bitmap控制字。Bit-X为0时上报对应版本,Bit-X为1时屏蔽对应版本。 * CTRL_VERS_NUM:  可控版本个数。 * CTRL_CODE_NUM:  控制字个数。与CTRL_VERS_NUM有关。 * gOamVerCtrlMap: 版本控制字数组。行对应控制字,列对应可控版本。                   元素值为0时不上报对应版本,元素值非0时上报该元素值。 * Note: 该数组旨在实现“数据与控制隔离”。后续若要新增可控版本,只需修改                   -- CTRL_VERS_NUM                   -- gOamVerCtrlMap新增行(控制字)                   -- gOamVerCtrlMap新增列(可控版本) **********************************************************************/ #define CTRL_VERS_NUM 3 #define CTRL_CODE_NUM (1< u8_t gOamVerCtrlMap[CTRL_CODE_NUM][CTRL_VERS_NUM]

    01-03 79浏览
  • 8个问题搞懂linux系统

    背景: 我们学习Linux的系统调用经常会遇到一个概念:“内核态和用户态的切换”,一般人只会告诉你说这个切换代价很大,具体是什么情况?为什么需要切换?一定需要切换吗?怎么就会触发切换?内核怎么知道要切换了?内核态是一个程序吗?是谁来切换的,CPU吗?等等具体细节很多人都不知道。我花时间整理了这些问题的答案,希望能帮到更多人。 要理解以上问题,需要有计算机的基础知识,计算机的本质是开关门三极管控制的高低电平切换,这些高低电平就是二进制的0、1。本质上也是数据的计算和保存。寄存器、缓存器、运存、内存等是数据,CPU负责计算转换这些数据。外设负责显示和传输数据,使用的“人”可以看到这些数据。 现在的操作系统其实就是把以前的“汇编版单程序”做了封装,本质还没变,一个CPU同时只能运行一个一个程序,不过是速度快了,让程序之间来回切换,让我们人感知不到任何切换感和卡顿感(电脑的卡顿不是CPU卡顿是程序的阻塞)。基于这一点:内核就是一个个程序,一上电内核肯定早于用户程序运行在CPU中。内核调度用户程序,把用户程序的上下文加载到CPU 并让出CPU,让出前定好定时器(硬件定时器--可以打断CPU运行),让用户程序时间片到了后可以切换回内核程序。 问题1:linux 系统调用 read/write这种 执行的时候 用户态和内核态的切换过程怎样,有什么缺点? 在 Linux 系统中,read 和 write 系统调用是典型的用户态和内核态之间的切换过程。下面是它们执行时的用户态和内核态切换过程以及潜在的缺点: 1. 执行过程 用户态到内核态切换: 用户程序调用 read() 或 write() 时,首先是从用户态发起系统调用。 由于 read 和 write 是系统调用,它们会触发一个软件中断或调用 syscall 指令,这将导致 CPU 从用户态切换到内核态。 内核在内核态中执行实际的 I/O 操作(例如,磁盘读写、网络操作等)。内核会访问设备驱动程序,可能会通过设备管理层与硬件进行交互。 内核态到用户态切换: 当 I/O 操作完成后,内核会将结果(例如读取的数据,写入操作的字节数)返回给用户程序。 结果返回后,系统会通过中断或返回指令将 CPU 状态从内核态切换回用户态,允许用户程序继续执行。 2. 内核态和用户态切换的缺点 内核态和用户态的切换是开销较大的操作,尤其是在频繁调用 read 和 write 系统调用时。具体的缺点包括: 1. 上下文切换的开销: 每次从用户态切换到内核态(或者反过来),都会发生上下文切换。上下文切换不仅涉及保存和恢复 CPU 寄存器的状态,还包括更新内存中的相关数据结构(如进程控制块),这些操作会带来额外的性能开销。 如果 read 或 write 调用是同步的(阻塞模式),程序会等待 I/O 完成,这时 CPU 可能空闲,浪费了大量的时间。 2. I/O 阻塞与同步: 在传统的阻塞 I/O 模式下,read 和 write 系统调用可能会使调用线程处于阻塞状态,直到 I/O 操作完成。这种情况下的上下文切换会导致 CPU 浪费时间等待 I/O 完成。 如果应用程序需要频繁进行阻塞 I/O 操作(例如,每秒钟执行大量的 read/write 系统调用),会导致频繁的用户态和内核态切换,从而增加系统开销。 3. 缓存一致性和内存访问: read 和 write 系统调用有时会涉及大量的数据拷贝。例如,read 操作可能会将数据从内核空间拷贝到用户空间,这需要额外的内存访问,尤其在大量数据传输的情况下,这种开销非常显著。 现代操作系统通常使用缓冲区或零拷贝技术(如 mmap()、sendfile() 等)来减少这种开销,但仍然需要避免频繁的内核态与用户态之间的数据拷贝。 4. 线程切换: 在多线程环境中,频繁调用 read 和 write 可能会导致频繁的线程切换(尤其是在多个线程共享同一文件描述符时),增加了额外的调度开销。 3. 优化方法 为了减少用户态和内核态切换带来的性能损耗,可以采取以下几种优化方法: 异步 I/O(AIO):通过异步 I/O,应用程序不需要在 read 或 write 调用时阻塞线程,内核会在 I/O 操作完成时通知应用程序,减少了上下文切换的次数。 零拷贝 I/O(Zero-copy I/O):通过减少内核与用户空间之间的数据拷贝来提高性能,例如使用 mmap() 或 sendfile()。 非阻塞 I/O:通过设置文件描述符为非阻塞模式,避免阻塞等待,结合 select() 或 poll() 等 I/O 多路复用技术,提高并发性能。 总结来说,read 和 write 系统调用的用户态和内核态切换过程在多次调用时可能带来显著的性能开销,尤其是上下文切换、阻塞等待和数据拷贝等问题。通过使用异步 I/O、零拷贝技术等优化方法,可以有效减少这些开销。 问题2:fread/fwrite/fopen/fclose/getchar/gets/puts/putchar是系统调用吗,异步IO怎么使用? 1. **fread/fwrite/fopen/fclose/getchar/gets/puts/putchar 是否是系统调用? 这些函数是 C 标准库提供的高级 I/O 函数,它们并不是直接的系统调用,而是通过系统调用实现的。系统调用是内核提供的接口,用于用户程序与操作系统之间的交互,而这些库函数是建立在操作系统提供的系统调用之上的,它们负责提供更高层次的功能,如缓冲、格式化等。 具体来说: fread/fwrite/fopen/fclose:这些是标准 C 库提供的文件 I/O 操作函数,通常会通过 read 和 write 系统调用来实际进行文件操作。 fopen 会调用系统调用(如 open)来打开文件。 fread 和 fwrite 在执行数据读取或写入时,最终会调用系统调用(如 read 和 write),并且会使用缓冲区进行缓存管理。 fclose 会关闭文件,并释放与文件相关的资源,通常会调用系统调用(如 close)。 getchar/puts/putchar: getchar 是标准输入函数,会调用 read 系统调用读取数据。 putchar 和 puts 是输出函数,底层通过 write 系统调用将字符输出到标准输出。 总结: 这些 C 库函数本身并不是系统调用,但它们内部通过对系统调用(如 read, write, open, close 等)的调用来实现实际的 I/O 操作。它们提供了额外的功能,如缓冲区管理、格式化输入输出等。 2. 异步 I/O (AIO) 怎么使用? 异步 I/O(AIO)允许程序在发起 I/O 操作后,不需要阻塞等待操作完成,而是可以继续执行其他任务,直到 I/O 操作完成并通知程序。这对于提高性能、避免线程阻塞非常有效,尤其在高并发的场景下。 在 Linux 上,异步 I/O 主要通过以下几种方式实现: 1. aio_read 和 aio_write: 这些是 POSIX 标准定义的异步 I/O 系统调用。与传统的 read 和 write 系统调用不同,aio_read 和 aio_write 不会阻塞进程,调用者可以在等待 I/O 操作完成时继续执行其他代码。 示例: #include #include #include #include #include #include #include int main() { struct aiocb cb; // 异步 I/O 控制块 int fd = open("example.txt", O_RDONLY); if (fd == -1) { perror("open"); return -1; } // 设置异步读取的参数 memset(&cb, 0, sizeof(struct aiocb)); cb.aio_fildes = fd; cb.aio_buf = malloc(1024); cb.aio_nbytes = 1024; cb.aio_offset = 0; // 发起异步读取 if (aio_read(&cb) == -1) { perror("aio_read"); close(fd); return -1; } // 检查 I/O 操作是否完成 while (aio_error(&cb) == EINPROGRESS) { // 可以继续执行其他操作 printf("I/O in progress...\n"); sleep(1); } // 获取操作结果 int ret = aio_return(&cb); if (ret == -1) { perror("aio_return"); close(fd); return -1; } printf("Read %d bytes: %s\n", ret, (char*)cb.aio_buf); free((void*)cb.aio_buf); close(fd); return 0;} 关键步骤: aio_read 发起异步读操作,aio_write 用于异步写操作。 aio_error 用于检查 I/O 操作是否仍在进行中。 aio_return 用于获取操作结果,如果操作已完成。 2. epoll 和 select: epoll 和 select 是 I/O 多路复用机制,允许一个线程同时监控多个文件描述符的事件,例如可读、可写等。它们本身不是完全异步 I/O,但能够以非阻塞方式进行 I/O 操作,帮助高效地管理多个并发连接。 示例(使用epoll监控文件描述符的可读事件): #include #include #include #include #include int main() { int epoll_fd = epoll_create1(0); if (epoll_fd == -1) { perror("epoll_create1"); exit(EXIT_FAILURE); } int fd = open("example.txt", O_RDONLY | O_NONBLOCK); if (fd == -1) { perror("open"); exit(EXIT_FAILURE); } struct epoll_event event; event.events = EPOLLIN; // 监控可读事件 event.data.fd = fd; if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) { perror("epoll_ctl"); exit(EXIT_FAILURE); } struct epoll_event events[10]; int nfds = epoll_wait(epoll_fd, events, 10, -1); if (nfds == -1) { perror("epoll_wait"); exit(EXIT_FAILURE); } for (int i = 0; i < nfds; i++) { if (events[i].events & EPOLLIN) { // 处理可读事件 char buffer[1024]; int bytes_read = read(events[i].data.fd, buffer, sizeof(buffer)); if (bytes_read > 0) { printf("Read %d bytes: %s\n", bytes_read, buffer); } else { printf("End of file or error\n"); } } } close(fd); close(epoll_fd); return 0;} 3. 使用 mmap 和 msync 进行内存映射 I/O: 内存映射 I/O 是一种通过内存操作文件的方式,这种方法可以实现高效的异步 I/O,尤其在处理大文件时可以提高性能。 总结: 异步 I/O 可以通过使用 aio_read/aio_write 等接口来实现,它们允许在 I/O 操作未完成时不阻塞当前线程。 epoll/select 提供了一种更灵活的方式来处理多个并发 I/O 操作,虽然它们并不直接提供异步 I/O,但通过非阻塞 I/O 和事件通知机制,也能达到类似的效果。 问题3:既然系统调用引发了上下文切换,那么其实就是进程间的切换,是用户进程和内核进程间的切换吗,内核进程是每次系统调用时才开始运行 还是一直在运行,对应哪个程序? 系统调用确实会导致上下文切换,但它并不是指进程间的完全切换。系统调用引发的上下文切换主要是用户进程和内核之间的切换,而不是两个不同的用户进程或两个内核进程之间的切换。 1. 用户进程与内核进程的切换 用户进程 在用户态运行,它在执行过程中可以发起 系统调用(如 read、write、open 等),此时发生 上下文切换,CPU 会从用户态切换到内核态。 内核态 由操作系统内核代码控制,内核执行的部分是操作系统的一部分,负责管理硬件资源、内存管理、I/O 操作等。内核态下执行的是内核代码,这时会执行具体的系统调用逻辑,如硬件驱动、内存管理等。 内核进程并不代表是一个普通的进程,它是由操作系统内核管理的,通常不依赖于特定的程序。内核会执行很多任务,并且可以同时管理多个用户进程。内核中有一些特定的执行上下文(如中断处理、系统调用的执行),这些都属于内核层面的“活动”。 2. 内核进程和内核态执行的区别 内核进程:通常是指内核中执行的那些特定任务(如调度、进程管理、网络处理等),这些任务由操作系统内核自己管理。 内核态执行:是指当前正在执行的内核代码,无论是否有内核进程在运行。实际上,内核并不像用户进程那样“常驻”或“独立运行”。当用户进程发起系统调用时,内核会执行对应的内核代码(如 I/O 操作),完成后再返回用户进程。 3. 系统调用时内核的执行 内核态的执行:内核并不是每次系统调用时才开始执行,它始终在内存中并处于休眠或等待状态。当用户进程触发系统调用时,CPU 切换到内核态,执行与该系统调用相关的内核代码(例如磁盘 I/O、网络操作、内存分配等)。系统调用结束后,CPU 再切换回用户态,继续执行用户进程。 在内核态,操作系统会管理多种资源,如中断、设备驱动程序、内存管理等,它通过各种机制(如中断、定时器、系统调用等)来调度内核态代码的执行。 例子:当用户程序发起read()系统调用时,内核需要通过磁盘 I/O 或网络设备获取数据,并把数据从内核空间传递到用户空间。内核执行这些操作时并不依赖于某个单独的“内核进程”,而是由内核中相应的代码块来完成。 4. 内核进程不是每次系统调用才执行 内核进程通常是指内核内部用于特定任务的程序(如内核线程)。但是,在 Linux 等操作系统中,内核是一个整体的操作系统内核,它会管理和调度所有进程(包括用户进程和内核线程)。内核进程并不是系统调用时才运行,而是内核代码在响应系统调用时会启动内核的相应部分。 例如,操作系统内核会创建和管理各种“内核线程”(如 kworker,ksoftirqd 等),这些线程用于执行后台任务,如处理硬件中断、执行调度等。这些内核线程在系统启动时就会存在,并且会一直运行,独立于用户进程。 5. 进程的执行与内核的关系 用户进程:通常会在用户态执行,除非它发起系统调用或者发生中断等需要内核介入的事件。在系统调用时,操作系统的内核代码会被调度执行。 内核进程:并不是像用户进程一样是由用户创建的。内核本身有一些“内核线程”,它们在内核模式下执行,通常由操作系统自动创建和管理。它们执行特定任务(如硬件中断处理、网络协议栈、内存管理等)。 6. 上下文切换的两种类型 用户态到内核态的切换:例如当用户进程发起系统调用时,操作系统会从用户进程切换到内核空间。 内核态到用户态的切换:当系统调用完成时,操作系统会将执行从内核空间切换回用户空间,继续执行用户进程。 上下文切换在这两种情况下都可能发生,但 内核进程并不代表是特定的独立程序,它只是内核的一部分,始终处于内核模式下,并且会响应系统调用、硬件中断等事件。 总结: 系统调用引发的上下文切换,是用户进程从用户态切换到内核态,执行内核代码。并不是进程间的切换,而是用户态和内核态之间的切换。 内核进程 并不是每次系统调用时才开始运行,内核始终在内存中,可以理解为操作系统的一部分,负责管理硬件、内存、进程调度等任务。 内核本身并不像用户进程一样是一个独立的程序,而是操作系统的一部分,内核的不同部分(如内核线程)始终在内核态下执行。 问题4:不管是内核还是用户程序 对于CPU来说就是 一段待执行的指令合集,不过是操作系统内核的指令集很大,正常处于休眠状态,等待中断来触发执行。本质上来说,用户程序是系统调用的,本质上来说也是运行的负责调度的内核程序,这个内核程序负责读取和切换待调度的每个程序的上下文。是这样吗? 1. 用户程序和内核程序的关系 用户程序:是由用户编写的应用程序,运行在 用户态。它通过系统调用与操作系统进行交互,例如使用 read 或 write 执行 I/O 操作、通过 fork 创建子进程等。当用户程序发起系统调用时,会触发用户态到内核态的上下文切换,此时操作系统内核开始执行对应的内核代码(如进程调度、文件系统操作、内存管理等)。 内核程序(内核态代码):是操作系统的核心,运行在 内核态。它负责管理硬件资源、调度进程、提供系统调用接口、处理中断等。内核代码并不是单独的“程序”,而是操作系统的一部分,它的职责是响应系统调用、管理系统资源并调度用户进程。 你说得对,内核负责调度进程并在需要时切换它们的上下文。 2. 内核程序的执行: 是操作系统内核代码的执行 内核 是一个 大指令集,包含很多功能,例如管理 CPU 调度、内存管理、I/O 操作、硬件中断处理等。它通常处于“休眠”状态,即它的代码并不会主动执行,只有在某些事件发生时(例如,系统调用、硬件中断、进程调度等),内核才会被唤醒并开始执行相应的任务。 你可以把内核想象成一个负责协调、管理和调度硬件资源的程序。当 用户程序 发起一个 系统调用 时,内核会响应这个调用,切换到内核态,执行内核代码,执行完毕后再切换回用户态。 3. 上下文切换: 上下文切换 是指 CPU 从一个执行上下文切换到另一个执行上下文。上下文包括程序计数器(PC)、寄存器值、栈指针等信息。每次从 用户程序切换到内核程序(或反之)时,都会进行上下文切换。 内核负责调度进程,并根据各种条件(如时间片、I/O 等待)决定哪个进程应该运行。内核会保存当前进程的上下文,并恢复下一个待执行进程的上下文。这个过程叫做 进程调度,它是操作系统管理多个程序并合理分配 CPU 时间的关键。 内核在执行过程中,会根据不同的事件(比如硬件中断、系统调用、时间片耗尽等)来触发上下文切换。比如,如果一个进程执行了 read 系统调用,CPU 会切换到内核态,由内核代码处理 I/O 操作,处理完毕后再切换回用户进程。 4. 用户程序的调度 用户程序 的调度并不是由用户控制的,而是由操作系统内核负责。内核通过调度算法(如时间片轮转、优先级调度等)来决定哪个进程可以占用 CPU 执行。每当进程的时间片用完,或者进程进行 I/O 等待,或者发生某些调度事件时,内核会触发上下文切换。 所以,你可以理解为 用户程序本质上是由内核调度的。虽然用户程序在用户态下运行,但它的执行是受操作系统内核控制的。 5. 内核程序的角色: 内核程序的职责不仅仅是“调度”用户程序,还包括其他很多核心任务: 硬件管理:内核通过驱动程序与硬件交互,管理 I/O 设备、网络设备等。 内存管理:管理物理内存和虚拟内存,进行地址映射、分页等操作。 进程调度:决定哪个进程应该运行,并在多个进程之间切换。 文件系统管理:管理文件系统和磁盘存储。 中断处理:响应硬件中断,处理外设请求。 6. 总结 用户程序 是由用户编写的程序,运行在 用户态,通过系统调用与操作系统进行交互。 内核程序 是操作系统的一部分,运行在 内核态,负责管理硬件资源、调度进程、提供系统调用接口等。内核并不是“单独的程序”,而是操作系统的核心部分。 操作系统内核的主要任务 包括调度用户进程、处理中断、提供系统调用等,它负责在多进程环境中协调多个程序的执行,并确保它们能有效利用 CPU 和其他资源。 内核通过 上下文切换 进行进程调度,决定哪个进程能够占用 CPU 执行,并在多个进程之间进行切换。 问题5:那用户程序时间片到了是怎么让出CPU的呢(此时内核程序并未在运行,因为CPU一次只能运行一个程序),难道内核程序执行了一个定时中断,来切换用户程序? 是的,用户程序时间片到了时,操作系统会通过 定时中断(Timer Interrupt) 来触发 内核程序 的执行,从而进行 进程调度。具体来说,CPU 在执行用户程序时,操作系统内核并没有持续运行,而是通过中断机制来控制进程的切换。 1. 时间片与定时器中断 每个进程在操作系统中都会分配一个固定的 时间片(Time Slice),这是它在 CPU 上连续执行的时间。当时间片耗尽时,操作系统需要 暂停 当前进程的执行,并将 CPU 切换到另一个进程。 关键步骤: 定时器中断:操作系统会使用硬件定时器(通常是 PIT,可编程间隔定时器)来生成定时中断。定时器中断是一个周期性事件,它在设定的时间间隔内触发一次。例如,操作系统可能每 10 毫秒或 1 毫秒触发一次定时器中断。 定时器中断触发:当定时器中断触发时,CPU 会 暂停当前执行的指令,并跳转到内核代码来处理中断。这个过程涉及 上下文切换,操作系统内核会保存当前用户程序的状态(即上下文),并恢复操作系统的中断处理程序。 进程调度:内核的调度程序会检查当前运行的进程是否已经消耗完时间片。如果时间片已用完,调度程序会选择另一个进程来运行。此时,内核会将当前进程的状态保存在其进程控制块(PCB)中,然后选择下一个进程并恢复它的上下文,切换到该进程。 恢复进程:当调度程序完成切换后,CPU 会从内核态切换回用户态,恢复到新选定进程的执行。 2. 操作系统如何使用定时中断进行调度 操作系统的调度是通过响应定时器中断来进行的。在这个过程中,操作系统内核负责: 在中断发生时 保存当前进程的状态(即上下文),包括程序计数器、寄存器等。 调度 下一个进程并恢复其上下文。 让 CPU 切换到新的进程。 这种机制确保了操作系统可以 公平地分配 CPU 时间 给多个进程,使得每个进程都有机会在 CPU 上运行,并且防止某个进程长期占用 CPU。 3. 上下文切换的具体过程 定时中断触发时,CPU 会进入 内核态,并执行操作系统的 中断处理程序。操作系统会检查是否需要切换进程。 具体的处理步骤: 保存当前进程的上下文:操作系统保存当前进程的寄存器、程序计数器等信息,以便在该进程再次运行时能够恢复它的执行状态。 更新进程调度信息:操作系统会标记当前进程为“已用完时间片”或“等待 I/O”等状态,并选择下一个进程来运行。调度器根据调度算法(如时间片轮转、优先级调度等)选择下一个候选进程。 恢复新进程的上下文:操作系统恢复新选定进程的上下文,恢复其寄存器、程序计数器等,使得新的进程能够继续执行。 返回用户态:CPU 从内核态切换回用户态,继续执行新选定进程。 4. 定时器中断是如何控制时间片的 定时器中断通常由 硬件定时器(如 PIT)生成,操作系统会设置一个时间片的长度,假设时间片为 10 毫秒,那么操作系统每 10 毫秒就会通过定时器中断检查是否需要切换进程。 在每次定时器中断发生时,操作系统内核会检查当前进程是否已经用完了它的时间片。如果用完了,内核会选择另一个进程并进行上下文切换。 5. 举个例子 假设操作系统使用的是 时间片轮转(Round-Robin)调度算法,并且每个进程的时间片为 10 毫秒: 1.进程 A 被调度执行,开始运行。 2.10 毫秒后,定时器中断触发。 内核从用户态切换到内核态,处理定时器中断。 检查进程 A 是否已用完时间片,发现时间片已用完。 保存进程 A 的上下文。 选择下一个进程(比如进程 B)。 恢复进程 B 的上下文。 3.进程 B 开始执行,运行 10 毫秒后再次触发定时器中断。 4.继续这个过程,操作系统通过定时器中断来实现进程的轮换执行。 6. 总结 时间片到时,操作系统会通过定时器中断来 切换进程。定时器中断是由硬件定时器定期触发的,操作系统内核利用定时器中断来响应并进行进程调度。 内核程序 在定时器中断触发时被执行,它负责检查当前进程是否用完时间片,并调度下一个进程运行。 通过这种机制,操作系统能够公平地分配 CPU 时间给多个进程,确保系统的 响应性 和 并发性。 问题6:top命令能看到内核的程序吗? top 命令显示的是当前系统中 用户进程 和 内核线程 的运行状态,但它并不直接显示“内核程序”本身,因为内核本身并不是一个用户进程,而是操作系统的一部分,通常运行在 内核态。 1. top 命令显示的内容 top 命令的输出显示的是系统中 所有进程 的资源使用情况(如 CPU、内存、I/O 等),并且默认情况下列出的是 用户进程。这些进程的状态和资源占用可以帮助你了解系统的负载情况。 对于 内核线程,top 命令也会显示它们的状态,尤其是在 Linux 2.6 及其之后的版本,内核线程会被列出作为进程的一部分。内核线程是内核空间中的执行单元,它们负责执行系统的内部任务,如调度、硬件管理、I/O 操作等。 2. 内核线程的显示 在 top 命令的输出中,内核线程 会以特定的名称显示,这些线程通常以 k 开头(如 kworker、ksoftirqd、kthreadd 等)。 这些内核线程的名字表明它们是由内核创建并在内核态执行的。例如: kworker 线程负责处理内核的工作队列任务。 ksoftirqd 线程用于软中断的处理。 kthreadd 线程是内核线程的创建者,负责启动和管理其他内核线程。 你可以通过 top 的 -H 选项查看线程信息(包括内核线程和用户线程): top -H 这将显示每个进程的 线程,其中包括内核线程和用户线程。 3. 如何识别内核线程 在 top 命令的输出中,内核线程 和 用户进程 是区分开的,主要通过 进程的 UID 和 进程名称 来区分: 内核线程通常会显示为 root 用户(因为大多数内核线程是由 root 权限启动的),且它们的 PID 一般较小。 内核线程的名称通常以 k 开头,例如 kworker, ksoftirqd, kthreadd 等。 4. 查看内核进程 虽然 top 显示了内核线程,但你并不能通过 top 查看完整的 内核程序。因为 内核程序 本身是操作系统的一部分,它并不像用户进程那样是一个独立的程序。内核的执行是在操作系统启动时加载的,并且与用户空间的程序是分离的。操作系统内核代码总是处于内存中,并且由 CPU 在不同的时间片和事件中执行。 5. 示例输出 下面是一个 top 命令输出的例子,包含了内核线程和用户进程的区别: top - 10:21:09 up 1 day, 1:02, 2 users, load average: 0.02, 0.01, 0.00Tasks: 146 total, 1 running, 145 sleeping, 0 stopped, 0 zombie%Cpu(s): 2.0 us, 1.0 sy, 0.0 ni, 97.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 stMiB Mem : 4000.0 total, 900.0 free, 1500.0 used, 1600.0 buff/cacheMiB Swap: 512.0 total, 400.0 free, 112.0 used. 800.0 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 123 root 20 0 8200 1200 1000 S 1.0 0.0 0:01.32 kworker/0:1H 124 root 20 0 8200 1200 1000 S 0.9 0.0 0:01.30 kworker/1:2 125 root 20 0 8200 1200 1000 S 0.8 0.0 0:01.40 ksoftirqd/0 1001 user1 20 0 212304 14288 1044 S 0.5 0.4 0:00.72 firefox 1002 user2 20 0 145236 8544 2052 S 0.3 0.2 0:00.60 gnome-shell 在这个输出中,进程如 kworker/0:1H 和 ksoftirqd/0 是 内核线程,它们在内核空间执行,负责系统的底层任务。 firefox 和 gnome-shell 是 用户进程,它们在用户空间执行,并显示为其他用户的程序。 6. 内核的管理 内核本身不作为单独的进程出现在 top 命令中,因为它并不是一个“用户可见”的进程。内核代码始终在内存中并执行,但它的管理不依赖于用户进程。 内核线程 负责处理系统的底层任务,如硬件管理、任务调度、内存管理、I/O 操作等。它们是由内核启动的,并且通常在 top 中显示为 root 用户,前缀为 k。 7. 总结 top 命令显示了内核线程和用户进程,它们的区别通常在于进程名称(内核线程以 k 开头)和用户 ID(通常是 root 用户)。 内核程序 本身并不作为独立的进程出现,它始终在操作系统内核中执行。top 命令能够显示 内核线程,但并不直接显示“内核程序”这一概念。 问题7:所以内核驱动加载 只是做了注册,不是直接运行,任何驱动程序都有触发条件,否则就是没用的驱动。这个触发条件可以是用户的系统调用,可以是定时中断、硬件中断等? 1. 驱动程序的加载与注册 驱动程序加载:当一个内核驱动被加载时,操作系统会将其编译为内核模块(在 Linux 中通常是 .ko 文件)。这个模块可以在系统启动时自动加载,也可以在运行时由用户手动加载(例如通过 modprobe 或 insmod 命令)。 注册过程:加载的驱动程序需要通过注册来向内核报告它将管理某些硬件或提供某些服务。例如: 字符设备驱动 会通过 register_chrdev 函数注册一个字符设备。 块设备驱动 会通过 register_blkdev 注册块设备。 网络驱动 会通过 register_netdev 注册网络设备。 注册的过程确保内核知道驱动程序的存在,并为它分配合适的资源。 2. 驱动程序的触发条件 驱动程序并不会“持续”运行,而是会根据特定的 事件或触发条件 来执行。驱动程序的执行通常是由内核和硬件事件引起的,以下是几种常见的触发条件: 2.1 用户空间的系统调用 有些驱动程序是通过用户程序发起的 系统调用 来触发的。用户程序可以通过系统调用与内核空间的驱动程序进行交互: 例如,一个字符设备驱动可能等待用户空间程序通过 open(), read(), write() 等系统调用来访问设备。用户程序的这些操作会触发内核中对应驱动的相关函数,进而执行驱动程序的操作。 2.2 硬件中断 许多硬件设备(如网卡、磁盘、USB 设备等)需要在某些事件发生时通知内核,例如数据传输完成、设备状态变化等。硬件中断是一种常见的触发条件: 硬件中断触发:硬件设备会发出中断信号,通知 CPU 该设备需要处理。内核的中断处理程序会触发相应设备驱动程序中的 中断处理函数 来处理中断事件。 例如,网卡驱动在接收到数据包时会触发一个硬件中断,内核会调用网卡驱动中的 中断处理程序,并处理网络数据。 2.3 定时中断 内核中的某些驱动程序可能依赖于 定时中断 来执行周期性任务。这类驱动程序通常是负责一些 定时操作 的,如定时刷盘(写入数据到磁盘)或周期性地检查设备状态: 例如,某些设备的驱动程序可能需要定期轮询设备状态,这种操作会通过内核定时器触发。 2.4 内核事件和工作队列 有些驱动程序会将任务放入 内核的工作队列 中,这些任务会在适当的时候由内核线程执行。工作队列可以由多种事件触发: 内核工作队列:内核使用工作队列(如 kworker 线程)来处理异步任务。例如,磁盘驱动可以将数据写入硬盘的任务放入工作队列中,等待 kworker 线程异步执行。 事件触发:内核会基于某些条件(如 I/O 完成、设备状态变化)来触发工作队列的执行。 2.5 内存或缓冲区管理 有些驱动程序依赖于 内存管理系统,比如内存的分配或释放、缓冲区的满载等。内核的内存管理子系统会触发驱动程序的某些操作: 例如,网络设备的驱动程序会等待内存缓冲区的空闲,当数据包需要发送时,内核会分配内存,并触发驱动程序的相关操作。 3. 驱动程序的生命周期 加载:驱动程序在内核启动时或通过命令行加载。驱动的注册使得内核能够识别并管理设备。 等待事件:驱动程序在注册之后会等待某些事件的发生。这些事件可以是来自用户空间的请求、硬件中断、定时器中断等。 响应事件:当触发条件满足时,驱动程序会执行相应的操作。例如,用户通过 read() 请求数据,驱动程序会读取硬件设备的数据并返回给用户空间;或者硬件中断发生,驱动程序会执行中断处理程序。 卸载:当驱动程序不再需要时(例如设备被移除,或不再使用),它会通过卸载过程从内核中注销并释放相关资源。 4. 总结 驱动程序的加载 只是一个注册过程,它将设备与驱动程序关联,并通知内核可以处理该设备的操作。 驱动程序 并不会持续运行,而是根据 触发条件(如用户程序发起的系统调用、硬件中断、定时中断等)来执行。例如,硬件中断、定时任务、用户发起的 I/O 操作等都可以触发内核中的驱动程序执行。 触发条件 是驱动程序执行的核心,通常这些条件是由外部事件(如硬件操作或用户请求)引发的。 因此,驱动程序的作用是 响应特定的事件,并在合适的时机执行相关操作,而不是持续占用 CPU 执行。 问题8:内核调度程序加载的CPU运行的代码是汇编写的吧,内核还有哪些代码是基于汇编实现的? 是的,内核调度程序以及其他一些关键部分的代码通常会使用 汇编语言 来实现。由于汇编语言能够直接与硬件进行交互,它可以提供更高效和低级的控制,尤其在操作系统内核的某些关键部分。以下是一些内核中的关键代码,它们通常是基于汇编语言实现的: 1. 内核调度程序的汇编实现 内核调度程序负责在不同进程之间进行上下文切换,这是一项至关重要的任务,涉及到保存和恢复 CPU 的上下文(如寄存器、程序计数器等)。其中,部分调度程序的实现确实使用了汇编语言来实现高效的上下文切换,特别是在保存和恢复进程状态时。 上下文切换的实现:上下文切换通常需要直接与硬件寄存器打交道,存储当前进程的寄存器值并恢复另一个进程的寄存器值。这一过程通常用汇编语言来实现,确保对 CPU 状态的精确控制。 汇编与 C 代码结合:虽然大部分调度算法和高层逻辑使用 C 语言实现,但具体的上下文切换过程会涉及到汇编指令,因为这些操作需要访问和修改底层的 CPU 寄存器。通常,汇编代码会嵌入到 C 代码中,或者由单独的汇编文件处理。 2. 中断处理和系统调用的汇编实现 中断处理:当硬件中断发生时,内核需要快速响应并切换到适当的中断处理程序。中断处理程序需要保存当前进程的上下文,处理硬件中断,然后根据需要进行调度。中断的进入和退出通常是通过汇编语言实现的,因为它涉及到修改CPU 状态(如修改程序计数器、堆栈指针等),这些操作必须直接通过汇编指令来完成。 系统调用:系统调用是用户空间与内核空间交互的桥梁,通常通过中断触发。对于 x86 架构,系统调用通过int 0x80(对于 x86)或syscall(对于 x86_64)指令来发起。系统调用的进入和退出也会涉及一些汇编代码来切换上下文(从用户态切换到内核态),并进行参数传递。 3. 内核引导(Bootstrapping) 内核引导过程是操作系统启动时非常重要的一个步骤。通常,内核的启动过程需要做很多低级操作,包括硬件初始化、内存管理的设置等。这些操作通常需要使用汇编语言来直接与硬件交互。 引导加载程序(如 GRUB、LILO 等)负责将内核加载到内存中,并启动内核的初步执行。内核的引导部分通常会使用汇编语言来初始化 CPU、设置堆栈、关闭中断等。 在现代操作系统中,启动过程通常会使用一部分汇编语言来进行硬件初始化(如设置分页、保护模式、GDT/IDT 等),然后再切换到 C 语言编写的内核代码。 4. 内核的低级硬件操作 内核中需要直接与硬件打交道的部分,如 内存管理、I/O 操作 和 硬件抽象层,通常会用汇编语言来实现一部分关键操作,特别是在一些操作必须直接与硬件交互时。 内存管理:内存管理是内核中的一个关键部分,涉及到分页、内存分配等。在某些平台上,分页表的创建和管理需要通过汇编语言进行直接控制,因为它涉及到 CPU 特性(如 CR3 寄存器的设置)和硬件地址的管理。 I/O 操作:操作系统与硬件设备的交互(如磁盘、网络设备、显示器等)通常需要使用汇编语言来执行低级 I/O 操作。例如,通过端口访问、DMA 操作等通常依赖于汇编语言实现。 5. 内核的同步机制(原子操作) 一些内核的同步机制需要通过汇编语言来实现,尤其是在多核处理器环境下,内核需要确保操作的原子性。这些操作包括原子加法、减法、比较和交换等,它们常常需要通过 CPU 提供的特殊指令来实现,譬如lock前缀指令(lock cmpxchg)来保证原子性。 例如,自旋锁 和 信号量 等机制的实现,可能需要使用汇编语言来确保在多处理器系统中对共享资源的安全访问。 6. 汇编在内核中的应用场景总结 可以总结为以下几个关键场景: 上下文切换:实现进程间的上下文切换,保存和恢复寄存器、程序计数器等。 中断处理:处理中断时切换到内核态,保存/恢复状态。 系统调用处理:用户态程序通过系统调用进入内核态的机制,涉及到上下文切换。 引导加载程序和初始化:内核引导过程中的硬件初始化和内存设置。 直接硬件交互:直接与硬件打交道时,汇编提供更精细的控制(如内存管理、I/O 操作等)。 原子操作和同步:提供多核环境下的原子操作支持,确保数据的安全性。 7. C 语言与汇编语言的结合 在现代操作系统中,内核的主要逻辑部分(如调度算法、内存管理、设备驱动等)通常使用 C 语言实现,因为 C 语言具有更好的可读性和维护性。汇编语言主要用于一些需要直接控制硬件、优化性能或实现特殊功能的低级操作。 汇编与 C 的结合:内核中的很多汇编代码通常会被嵌入到 C 语言代码中(例如,通过内联汇编或函数调用),以便 C 代码能够调用汇编实现的功能,同时也能确保代码的跨平台兼容性。 总结: 汇编语言 在操作系统内核中的作用非常重要,尤其是在 上下文切换、硬件中断处理、系统调用、内存管理、I/O 操作、同步机制 等关键部分。 内核中的 高层逻辑(如调度算法、进程管理等)通常由 C 语言 编写,而低级硬件交互、性能优化和中断处理等关键部分常常需要使用汇编语言来实现,以确保对硬件的精确控制和高效执行。

    01-02 107浏览
  • 常用的嵌入式软件调试方法与技巧

    在软件开发过程中,一般来说,花在测试比花在编码的时间要多很多,通常为3:1(甚至更多)。 这个比例随着你的编程和测试水平的提高而不断下降,但不论怎样,软件测试对一般人来讲很重要。 今天以嵌入式开发为例,给大家分享一下常见软件的调试方法有哪些? 很多年前,一位开发人员为了在对嵌入式有更深层次的理解,询问了这样的一个问题:我怎么才能知道并懂得我的系统到底在干些什么呢? 面对这个问题有些吃惊,因为在当时没有人这么问过,而同时代的嵌入式开发人员问的最多的大都围绕“我怎么才能使程序跑得更快”、“什么编译器最好”等问题。 面对这个不同寻常却异乎成熟的问题,可能很多人都不知道怎么办,下面就来讲讲软件测试找bug常见方法和秘诀。 1 懂得使用工具 通常嵌入式系统对可靠性的要求比较高。嵌入式系统安全性的失效可能会导致灾难性的后果,即使是非安全性系统,由于大批量生产也会导致严重的经济损失。 这就要求对嵌入式系统,包括嵌入式软件进行严格的测试、确认和验证。随着越来越多的领域使用软件和微处理器控制各种嵌入式设备,对日益复杂的嵌入式软件进行快速有效的测试愈加显得重要。 就像修车需要工具一样,好的程序员应该能够熟练运用各种软件工具。 不同的工具,有不同的使用范围,有不同的功能。使用这些工具,你可以看到你的系统在干些什么,它又占用什么资源,它到底和哪些外界的东西打交道。 让你郁闷好几天的问题可能通过某个工具就能轻松搞定,可惜你就是不知道。 那么为什么那么多的人总是在折腾个半死之后才想到要用测试工具呢?原因很多,主要有两个: 一个是害怕; 另一个是惰性; 害怕是因为加入测试工具或测试模块到代码需要技巧同时有可能引入新的错误,所以他们总喜欢寄希望于通过不断地修改重编译代码来消除bug,结果却无济于事。 懒惰是因为他们习惯了使用printf之类的简单测试手段。 下面来介绍一些嵌入式常用的测试工具(1)、源码级调试器????[Source-levelDebugger] 这种调试器一般提供单步或多步调试、断点设置、内存检测、变量查看等功能,是嵌入式调试最根本有效的调试方法。比如VxWorksTornadoII提供的gdb就属于这一种。 (2)、简单实用的打印显示工具???? [printf] printf或其它类似的打印显示工具估计是最灵活最简单的调试工具。 打印代码执行过程中的各种变量可以让你知道代码执行的情况。但是,printf对正常的代码执行干扰比较大(一般printf占用CPU比较长的时间),需要慎重使用,最好设置打印开关来控制打印。 (3)、ICE或JTAG调试器????[In- circuitEmulator] ICE是用来仿真CPU核心的设备,它可以在不干扰运算器的正常运行情况下,实时的检测CPU的内部工作情况。 像桌面调试软件所提供的:复杂的条件断点、先进的实时跟踪、性能分析和端口分析这些功能,它也都能提供。ICE一般都有一个比较特殊的CPU,称为外合(bond-out)CPU. 这是一种被打开了封装的CPU,并且通过特殊的连接,可以访问到CPU的内部信号,而这些信号,在CPU被封装时,是没法 “看到”的。 当和工作站上强大的调试软件联合使用时,ICE就能提供你所能找到的最全面的调试功能。 但ICE同样有一些缺点:昂贵;不能全速工作;同样,并不是所有的CPU都可以作为外合CPU的,从另一个角度说,这些外合CPU也不大可能及时的被新出的CPU所更换。 JTAG(JointTestActionGroup)虽然它最初开发出来是为了监测IC和电路连接,但是这种串行接口扩展了用途,包括对调试的支持。 (4)、ROM监视器???? [ROMMonitor] ROM监控器是一小程序,驻留在嵌入系统ROM中,通过串行的或网络的连接和运行在工作站上的调试软件通信。 这是一种便宜的方式,当然也是最低端的技术。它除了要求一个通信端口和少量的内存空间外,不需要其它任何专门的硬件。 提供了如下功能:下载代码、运行控制、断点、单步步进、以及观察、修改寄存器和内存。 因为ROM监控器是操作软件的一部分,只有当你的应用程序运行时,它才会工作。 如果你想检查CPU和应用程序的状态,你就必须停下应用程序,再次进入ROM监控器。 (5)、Data监视器???? [DataMonitor] 这种监视器在不停止CPU运行的情况下不仅可以显示指定变量内容,还可以收集并以图形形式显示各个变量的变化过程。 (6)、OS监视器???? [OperatingSystemMonitor] 操作系统监视器可以显示诸如任务切换、信号量收发、中断等事件。 一方面,这些监视器能够为你呈现事件之间的关系和时间联系;另一方面,还可以提供对信号量优先级反转、死锁和中断延时等问题的诊断。 (7)、性能分析工具???? [Profiler] 可以用来测试CPU到底耗在哪里。profiler工具可以让你知道系统的瓶颈在哪里、CPU的使用率以及需要优化的地方。 (8)、内存测试工具???? [MemoryTeseter] 可以找到内存使用的问题所在,比如内存泄露、内存碎片、内存崩溃等问题。如果发现系统出现一些不可预知的或间歇性的问题,就应该使用内存测试工具测测看。 (8)、运行跟踪器???? [ExecutionTracer] 可以显示CPU执行了哪些函数、谁在调用、参数是什么、何时调用等情况。这种工具主要用于测试代码逻辑,可以在大量的事件中发现异常。 (9)、覆盖工具[CoverageTester] 主要显示CPU具体执行了哪些代码,并让你知道那些代码分支没有被执行到哪里。这样有助于提高代码质量并消除无用代码。 (10)、GUI测试工具???? [GUITester] 很多嵌入式应用带有某种形式的图形用户界面进行交互,有些系统性能测试是根据用户输入响应时间进行的。 GUI测试工具可以作为脚本工具有开发环境中运行测试用例,其功能包括对操作的记录和回放、抓取屏幕显示供以后分析和比较、设置和管理测试过程(Rational 公司的robot和Mercury的Loadrunner工具是杰出的代表)。 很多嵌入式设备没有GUI,但常常可以对嵌入式设备进行插装来运行GUI测试脚本,虽然这种方式可能要求对被测代码进行更改,但是节省了功能测试和回归测试的时间。 (11)、自制工具???? [Home-madetester] 在嵌入式应用中,有时候为了特定的目的,需要自行编写一些工具来达到某种测试目的。 本人曾经编写的视频流录显工具在测试视频会议数据流向和变化上帮了大忙,帮公司找到了几个隐藏很深的bug。 2 尽早发现内存问题 内存问题危害很大,不容易排查,主要有三种类型:内存泄露、内存碎片和内存崩溃。 对于内存问题态度必须要明确,那就是早发现早“治疗”。在软件设计中,内存泄露的“名气”最大,主要由于不断分配的内存无法及时地被释放,久而久之,系统的内存耗尽。 即使细心的编程老手有时后也会遭遇内存泄露问题。有测试过内存泄露的朋友估计都有深刻地体验,那就是内存泄露问题一般隐藏很深,很难通过代码阅读来发现。有些内存泄露甚至可能出现在库当中。 有可能这本身是库中的bug,也有可能是因为程序员没有正确理解它们的接口说明文档造成错用。 在很多时候,大多数的内存泄露问题无法探测,但可能表现为随机的故障。程序员们往往会把这种现象怪罪于硬件问题。 如果用户对系统稳定性不是很高,那么重启系统问题也不大;但,如果用户对系统稳定很高,那么这种故障就有可能使用户对产品失去信心,同时也意味着你的项目是个失败的项目。 由于内存泄露危害巨大,现在已经有许多工具来解决这个问题。 这些工具通过查找没有引用或重复使用的代码块、垃圾内存收集、库跟踪等技术来发现内存泄露的问题。 每个工具都有利有弊,不过总的来说,用要比不用好。总之,负责的开发人员应该去测试内存泄露的问题,做到防患于未然。 内存碎片比内存泄露隐藏还要深。随着内存的不断分配并释放,大块内存不断分解为小块内存,从而形成碎片,久而久之,当需要申请大块内存是,有可能就会失败。如果系统内存够大,那么坚持的时间会长一些,但最终还是逃不出分配失败的厄运。在使用动态分配的系统中,内存碎片经常发生。 目前,解决这个问题最效的方法就是使用工具通过显示系统中内存的使用情况来发现谁是导致内存碎片的罪魁祸首,然后改进相应的部分。 由于动态内存管理的种种问题,在嵌入式应用中,很多公司干脆就禁用malloc/free的以绝后患。 内存崩溃是内存使用最严重的结果,主要原因有数组访问越界、写已经释放的内存、指针计算错误、访问堆栈地址越界等等。 这种内存崩溃造成系统故障是随机的,而且很难查找,目前提供用于排查的工具也很少。 总之,如果要使用内存管理单元的话,必须要小心,并严格遵守它们的使用规则,比如谁分配谁释放。 3 深入理解代码优化 讲到系统稳定性,人们更多地会想到实时性和速度,因为代码效率对嵌入式系统来说太重要了。 知道怎么优化代码是每个嵌入式软件开发人员必须具备的技能。就像女孩子减肥一样,起码知道她哪个地方最需要减,才能去购买减肥药或器材来减掉它。 可见,代码优化的前提是找到真正需要优化的地方,然后对症下药,优化相应部分的代码。 前面提到的profile(性能分析工具,一些功能齐全IDE都提供这种内置的工具)能够记录各种情况比如各个任务的CPU占用率、各个任务的优先级是否分配妥当、某个数据被拷贝了多少次、访问磁盘多少次、是否调用了网络收发的程序、测试代码是否已经关闭等等。 但是,profile工具在分析实时系统性能方面还是有不够的地方。 一方面,人们使用profile工具往往是在系统出现问题即CPU耗尽之后,而 profile工具本身对CPU占用较大,所以profile对这种情况很可能不起作用。 根据Heisenberg效应,任何测试手段或多或少都会改变系统运行,这个对profiler同样适用! 总之,提高运行效率的前提是你必须要知道CPU到底干了些什么干的怎么样。 4 不要让自己大海捞针 大海捞针只是对调试的一种生动比喻。经常听到组里有人对自己正在调试的代码说shit! 可以理解,因为代码不是他写的,他有足够的理由去 shitbug百出的代码,只要他自己不要写出这种代码,否则有一天同组的其它人可能同样会shit他写的代码。 为何会有大海捞针呢?肯定是有人把针掉到海里咯;那针为何会掉在海里呢?肯定是有人不小心或草率呗。 所以当你在抱怨针那么难找的时候,你是否想过是你自己草率地丢掉的。 同样,当你调试个半死的时候,你是否想过你要好好反省一下当初为了寻求捷径可能没有严格地遵守好的编码设计规范、没有检测一些假设条件或算法的正确性、没有将一些可能存在问题的代码打上记号呢? 关于如何写高质量请参考林锐的《高质量c++/c编程指南》或《关于C的0x8本“经书》。 如果你确实已经把针掉在海里是,为了防止在找到之前刺到自己,你必须要做一些防范工作,比如戴上安全手套。 同样,为了尽能地暴露和捕捉问题根源,我们可以设计比较全面的错误跟踪代码。怎么来做呢? 尽可能对每个函数调用失败作出处理,尽可能检测每个参数输入输出的有效性,包括指针以及检测是否过多或过少地调用某个过程。错误跟踪能够让你知道你大概把针掉在哪个位置。 5 重现并隔离问题 如果你不是把针掉在大海了,而是掉在草堆里,那要好办些。因为至少我们可以把草堆分成很多块,一块一块的找。 对于模块独立的大型项目,使用隔离方法往往是对付那些隐藏极深bug的最后方法。 如果问题的出现是间歇性的,我们有必要设法去重现它并记录使其重现的整个过程以备在下一次可以利用这些条件去重现问题。 如果你确信可以使用记录的那些条件去重现问题,那么我们就可以着手去隔离问题。怎么隔离呢? 我们可以用#ifdef把一些可能和问题无关的代码关闭,把系统最小化到仍能够重现问题的地步。 如果还是无法定位问题所在,那么有必要打开“工具箱”了。可以试着用ICE或数据监视器去查看某个可疑变量的变化;可以使用跟踪工具获得函数调用的情况包括参数的传递;检查内存是否崩溃以及堆栈溢出的问题。 6 以退为进 猎人为了不使自己在森林里迷路,他常常会在树木上流下一些标记,以备自己将来有一天迷路时可以根据这些标记找到出路。对过去代码的修改进行跟踪记录对将来出现问题之后的调试很有帮助。 假如有一天,你最近一次修改的程序跑了很久之后忽然死掉了,那么你这时的第一反映就是我到底改动了些什么呢,因为上次修改之前是好的。 那么如何检测这次相对于上次的修改呢?没错,代码控制系统SCS或称版本控制系统 VCS可以很好地解决这个问题。 将上个版本checkin下来后和当前测试版本比较。比较的工 具可以是SCS/VCS/CVS自带的diff工具或其它功能更强的比较工具,比如BeyondCompare和 ExamDiff。通过比较,记录所有改动的代码,分析所有可能导致问题的可疑代码。 7 确定测试的完整性 你怎么知道你的测试有多全面呢?覆盖测试(coveragetesting)可以回答这个问题。覆盖测试工具可以告诉你CPU到底执行了哪些代码。 好的覆盖工具通常可以告诉你大概20%到40% 代码没有问题,而其余的可能存在bug.覆盖工具有不同的测试级别,用户可以根据自己的需要选择某个级别。 即使你很确信你的单元测试已经很全面并且没有 deadcode,覆盖工具还是可以为你指出一些潜在的问题。 看下面的代码: if(i>=0&& (almostAlwaysZero==0||(last=i))) 如果almostAlwaysZero为非0,那么last=i赋值语句就被跳过,这可能不是你所期望的。 这种问题通过覆盖工具的条件测试功能可以轻松得被发现。总之,覆盖测试对于提高代码质量很有帮助。 8 提高代码质量意味着节省时间 有研究表明软件开发的时间超过80%被用在下面几个方面:调试自己的代码(单元测试)。 调试自己和其他相关的代码(模块间测试)。调试整个系统(系统测试),更糟糕的是你可能需要花费10-200倍的时间来找一个 bug,而这个bug在开始的时候可能很容易就能找到。 一个小bug可能让你付出巨大的代价,即使这个bug对整个系统的性能没有太大的影响,但很可能会影响让那些你可以看得到的部分。 所以我们必须要养成良好的编码和测试手段以求更高的代码质量,以便缩短调试的代码。 9 发现它、分析它、解决它 这世界没有万能的膏药。profile再强大也有力不从心的时候;内存监视器再好,也有无法发现的时候;覆盖工具再好用,也有不能覆盖的地方。 一些隐藏很深的问题即使用尽所有工具也有可能无法查到其根源,这时我们能做的就是通过这些问题所表现出来的外在现象或一些数据输出来发现其中的规律或异常。 一旦发现任何异常,一定要深入地理解并回溯其根源,直到解决为止。 10 请利用初学者思维 有人这样说过:“有些事情在初学者的脑子里可能有各种各样的情况,可在专家的头脑里可能就很单一”。 有时候,有些简单的问题会被想得很复杂,有些简单的系统被设计得很复杂,就是由于你的“专家思维”。 当你被问题难住时,关掉电脑,出去走走,把你的问题和你的朋友甚至你的小狗说说,或许他们可以给你意想不到的启发。 11 总结 嵌入式调试也是一门艺术。就想其它的艺术一样,如果你想取得成功,你必须具备智慧、经验并懂得使用工具。

    01-02 167浏览
正在努力加载更多...
广告