• 为什么要基于UDS搞Bootloader

    01 为什么要基于UDS搞Bootloader 假如你的控制器有外壳,却没有设计bootloader的话,每次更新ECU的程序,你都需要把外壳拆开,用烧写器来更新程序。有了bootloader,你就可以通过CAN线来更新程序了。更方便些的话,甚至可以通过OTA进行远程升级。 那为什么使用UDS呢?主要是为了规范bootloader的全过程。比如烧写小明牌ECU时,我们肯定希望其他牌子的ECU处于一个静默的状态,都歇一歇,这就需要一个大家共同执行的标准来进行规范,什么时候停发数据,什么时候不能再储存DTC了等等。 又比如在调试时,大家肯定希望你的控制器经由CAN烧写的过程是大家都能看得懂的,是满足于某种规范的。由此,UDS在设计时考虑了bootloader的需求,专门为bootloader设计了几个服务,供大家使用。主机厂在发需求时自然就要求大家要在UDS规范的基础上完成bootloader功能了。 02 Bootloader应支持的UDS服务 显然bootloader不需要支持19/14等故障类服务。 在boot程序中,10/27/11/3E这样的基础诊断服务需要支持,22/2E读写DID的服务需要支持,31/34/36/37这4个bootloader主打服务需要支持,共10个。 在app段程序中,85和28服务需要支持,保证暂停CAN正常通信,暂停记录DTC,让被升级设备专心升级。 10种boot段服务+2种app段服务 03 测试设备在线Bootloader——三段式 (1)预编程阶段 1. 3E TP报文。 2. 10服务切换到03扩展模式。 3. 85服务和28服务,关DTC和非诊断报文。使整个CAN网络处于安静的状态。这是对整车网络进行操作的,一般都是以功能寻址的方式来发送。注意先用85服务关闭DTC,再使用28服务关报文。 (2)主编程阶段 1. 10服务切换到编程模式,这里要注意,正确的方式是App段程序回复0x78 NRC,接下来跳转到boot段程序,最后由Boot段程序来回复10 02的肯定响应。错误的方式是由App段回复10 02的肯定响应,再进行跳转。 2. 读取一个DID,tester要判断一下返回值。返回值里面可能包含密钥的一部分信息。 3. 27服务,解锁,通过安全验证。 注意10 02服务不应直接进行肯定响应,存在风险 4. 写DID指纹,标记写软件人的身份,ECU回复写指纹成功。(根据OEM要求来执行) 5. 31服务-擦除Flash。ECU肯定响应,擦除成功。 6. 34服务,请求数据下载,ECU回复确认最大块大小。 7. 36服务,开始传输数据。每个块传输完成后,ECU肯定响应。判断是否还有更多块需要下载。最多可以支持255个块。 8. 37服务,请求退出传输。ECU肯定响应。 9. 31服务-校验APP段程序,检查编程一致性/完整性。ECU肯定响应。校验成功。 10. 若有更多块需要下载,重新执行31(擦除Flash区域)-34-36-37-31(校验)服务。若无,往下执行。 11. 11服务,ECU复位。之后应直接跳转到新下载的APP段程序中。 31(擦Flash)-34-36 36-37-31(校验) (3)后编程状态 1. 10服务切换到03扩展会话。 2. 执行28服务和85服务,使能非诊断报文和DTC。这是对整车网络进行操作的,一般都是以功能寻址的方式来发送。注意先执行28,后执行85,避免DTC误报。 3. 27服务,安全校验,准备写入数据。 4. 2E服务,将编程信息写入到ECU中。 5. 10服务,退回01默认会话。结束。 04 BootLoader的启动顺序与转换流程 1. ECU上电或复位后,先进入Boot段。从Flash/EEPROM中读取 App有效标志,运行boot标志 。 2.判断 运行boot标志 ,若为1,则进入Boot段的编程会话(安全等级为上锁),之后写Flash/EEPROM(不安全操作), 运行boot标志 清零。若S3定时器超时则退回Boot段默认会话。 3. 经过安全访问进入Level2解锁状态,开始执行App内存擦除,擦除后 App有效标志 清零(不安全操作)。 4. 开始烧写。烧写成功后 运行boot标志 写0,App有效标志 写1。 2*. 判断 运行boot标志 ,若为0,则进入Boot段的默认会话。 3*. 50ms后判断 App有效标志 ,若为1,则 跳转到 App段默认会话。实现时使用汇编指令执行APP段程序;若为0,退回Boot段默认会话,且不再判断 App有效标志,不会再尝试进入App段。 4*. App段程序若收到了编程会话请求, 运行boot标志写1 ,随即执行ECU复位,这样会重新进入boot段程序。 注:从BOOT跳入APP前需要判断APP的数据完整性,例如进行CRC校验。

    04-09 88浏览
  • 通讯模块故障,你中招了吗?

    在工业自动化的繁忙现场,PLC(可编程逻辑控制器)如同大脑般指挥着各类设备的运作。然而,一旦其通讯模块出现故障,整个生产线可能会瞬间陷入瘫痪,带来不可估量的损失。你是否也曾遭遇过这样的困境,却束手无策?别担心,今天我们就来一场实战演练,教你如何快速判断PLC通讯模块的故障,并掌握更换技巧,让你的工业自动化系统畅通无阻! 通讯模块故障,你中招了吗? 想象一下,当PLC通讯模块突然“罢工”,生产线上的设备开始各自为政,数据无法上传、指令无法下达,整个系统陷入一片混乱。这样的场景,是否让你不寒而栗?别担心,我们这就为你揭开通讯模块故障的神秘面纱,助你轻松应对! 一、通讯模块的重要性与故障后果 PLC通讯模块,作为PLC与外界设备沟通的桥梁,其重要性不言而喻。一旦出现故障,可能会带来数据无法传输、生产停滞、误报漏报等严重后果。因此,掌握通讯模块的故障诊断与更换技能,对于确保工业自动化系统的稳定运行至关重要。 你的工厂是否也出现过PLC通讯模块故障的情况?你是如何处理的?欢迎在评论区分享你的经验和教训! 二、故障诊断前的准备 在进行故障诊断前,我们需要准备以下工具和设备:PLC主机及通讯模块(用于故障测试和替换)、编程软件及编程电缆、备用通讯模块、万用表、电脑或hmi设备等。这些工具将助我们快速定位故障,确保更换过程的顺利进行。 三、故障诊断的逻辑步骤 要快速判断PLC通讯模块是否故障,我们可以按照以下逻辑步骤进行: 排除非硬件问题:先确认通讯模块的供电、连接线缆、网络设置等是否正确。 检查硬件状态:通过观察通讯模块上的指示灯状态,判断是否正常工作。 替换法验证:将故障模块替换为备用模块,观察系统是否恢复正常。 记录和分析日志:利用PLC编程软件查看通讯错误代码或系统日志,进一步确认故障原因。 现在,请你试着根据以上步骤,对你工厂中的PLC通讯模块进行一次初步检查。你是否发现了潜在的问题?如果有,请记录下来,并在评论区分享你的发现! 四、具体操作步骤与细节 接下来,我们将详细介绍故障诊断与更换的具体操作步骤: 观察指示灯状态:根据指示灯的状态,我们可以初步判断通讯模块的工作状态。例如,电源灯不亮可能是模块未供电或电源故障;通讯灯不闪可能是通讯中断或模块未正常工作。 练习题:请查阅你所使用的PLC通讯模块的指示灯说明,记住各状态灯的含义。 检查供电和线缆:使用万用表检查模块的供电电压是否符合要求,线缆连接是否牢固。对于以太网通讯模块,可以使用网络测试工具检查网线是否通畅。 小提示:在振动较大的设备旁,线缆容易磨损或松动,务必加强检查。 使用编程软件诊断:通过PLC编程软件查看通讯模块的状态和错误代码。常见的错误包括通讯超时、地址冲突、参数错误等。 常见错误提醒:初学者容易忽略通讯协议和波特率的匹配问题,务必重点检查。 替换法验证:如果通过以上步骤仍然无法确认故障,可以直接将通讯模块替换为备用模块。替换后,观察系统是否恢复正常。 练习题:尝试从你的PLC中拔下通讯模块,记录其安装方式和固定方法。 记录和分析日志:许多PLC系统会记录故障日志或报警信息,这些信息有助于定位问题。例如,“模块脱离”或“通讯失败”等提示。 学习技巧:养成查看系统日志的习惯,这将让你更快地找到问题所在。 五、功能扩展与调试方法 除了基本的故障诊断和更换操作外,我们还可以对系统进行以下改进: 冗余设计:为关键通讯模块配置备份模块,当主模块故障时自动切换。 远程监控:通过将PLC接入云平台,实时监控通讯状态,提前预警故障。 优化布线:采用屏蔽线缆,减少工业环境中的电磁干扰对通讯的影响。 完成模块更换后,务必按照以下步骤进行调试: 检查模块安装是否牢固。 重新上电,观察通讯模块的状态指示灯是否恢复正常。 测试通讯功能,确保数据能够正常传输。 模拟故障场景,测试系统的故障响应能力。 六、注意事项与应用场景 在更换PLC通讯模块时,我们需要注意以下几点: 备件管理:现场应常备通讯模块,以便快速更换。 数据备份:更换模块前,确保PLC程序和参数已备份。 防静电操作:更换模块时应佩戴防静电手环,避免损坏硬件。 标记模块:为模块做好标识,避免误用或安装错误。 PLC通讯模块的故障诊断和更换方法不仅适用于工业生产线,还可以应用于智能楼宇控制、仓储物流系统、能源管理系统等多个场景。 七、常见问题及其解决方法 以下是一些常见问题及其解决方法: 通讯模块电源灯不亮:检查电源接线是否牢固,必要时更换模块。 通讯数据丢失或中断:更换线缆,优化布线,减少干扰。 模块通讯指示灯不闪:检查通讯协议和波特率是否匹配,必要时更换模块。 更换模块后仍无法通讯:更新PLC程序中的模块参数,确保配置正确。 八、总结与行动建议 PLC通讯模块是工业自动化系统中的重要组成部分,其故障可能会严重影响设备运行。通过本次学习,相信你已经掌握了判断通讯模块故障的基本方法以及更换模块的具体步骤。然而,知识只有付诸实践才能真正转化为技能。因此,我们建议你回到工作现场,尝试观察PLC通讯模块的状态灯并熟悉模块的拆装方法。相信你会发现,动手实践是最好的学习方式!

    03-14 177浏览
  • 三菱PLC的MC通信协议报文解析

    大家好!我是付工。西门子、三菱、欧姆龙是我们自动化行业使用最多的三个PLC品牌。今天跟大家分享一下三菱PLC的MC通信协议分析。大家在学习MC通信协议时,建议先熟练掌握Modbus通信协议。三菱PLC的MC协议是一种数据通信协议,它用于在计算机和三菱PLC之间传输数据。MC协议是三菱公司独有的一种协议,主要用于控制三菱PLC。MC协议是Melsec协议的简称。 一、通信帧类型 很多人在学习三菱PLC通信协议时,很头疼的是三菱PLC有很多种通信帧类型,每种通信帧又有ASCII和二进制两种编码格式。在我们实际开发中,以太网通信主要使用的是QnA兼容3E帧(FX5U系列/Q系列/Qna系列/L系列/R系列),如果是FX3U通过加装Fx3U-ENET-ADP,会使用到A兼容1E帧。对于串口设备,一般会使用QnA兼容2C帧或QnA兼容4C帧。通信编码格式有ASCII和二进制两种方式,通过二进制编码数据进行的通信与通过ASCII编码数据进行的通信相比,前者的通信数据量约为后者的二分之一,因此二进制编码的方式通信效率更高。 二、PLC配置 三菱PLC与西门子PLC有所不同。 西门子PLC是固定端口102,一个端口支持多个连接。 三菱PLC需要手动添加端口,一个端口只支持一个连接。 因此三菱PLC需要手动配置,这里以三菱R系列为例: 1、在导航中,通过参数>>R08ENCPU>> 模块参数,双击在打开的界面中设置好PLC的IP地址信息,将通信数据代码改成二进制,然后找到【对象设备连接配置设置】: 2、点击设置,可以拖入多个SLMP连接设备,端口号根据自己需求设置,然后反应设置并关闭设置结束,设置完成后,重新下载PLC程序,断电重启PLC。 SLMP(Seamless Message Protocol)是在以太网中使用的协议。MC协议则包含了串口以及以太网的通信协议,范围更广。 三、读取协议帧 接下来我们来分析一下三菱的通信报文,以QnA兼容3E帧为例,其他通信帧大同小异。 协议帧一般分为请求帧、响应帧及异常帧。 请求帧:表示发送请求的报文。 响应帧:如果请求正确,PLC会以响应帧进行返回。 异常帧:如果请求错误,CPU会以异常帧返回。 读取请求帧报文格式: 读取响应帧报文格式: 读取异常帧报文格式: 说明:以上三种协议帧是参考三菱官方文档总结而成,其中头部指的是TCP头部,我们可以不用管。 四、写入协议帧 写入请求帧报文格式: 写入响应帧报文格式: 写入异常帧报文格式: 说明:如果我们学过Modbus,可以看到,协议都是相通的,MC只是比Modbus报文结构更复杂而已。 五、通信测试 我们以读取D0开始的5个寄存器为例,结合协议文档,来进行报文拼接。 发送报文如下: 副头部:0x50 0x00 网络编号:0x00 PLC编号:0xFF 请求目标模块I/O编号:0xFF 0x03 请求目标模块站号:0x00 请求数据长度:0x0C 0x00 CPU监视定时器:0x0A 0x00 指令:0x01 0x04 子指令:0x00 0x00 起始软元件:0x00 0x00 0x00 软元件代码:0xA8 软元件点数:0x05 0x00 我们通过网络调试助手发送这个报文,观察一下返回的报文: 响应报文如下: 副头部:0xD0 0x00 网络编号:0x00 PLC编号:0xFF 请求目标模块I/O编号:0xFF 0x03 请求目标模块站号:0x00 响应数据长度:0x0C 0x00 结束代码:0x00 0x00 软元件数据:0x0A 0x00 0x14 0x00 0x1E 0x00 0x28 0x00 0x32 0x00 其中0x0A 0x00 0x14 0x00 0x1E 0x00 0x28 0x00 0x32 0x00即表示D0-D4的值,进行数据解析处理后的值分别为10、20、30、40、50,与PLC数据一致。 六、代码实现 我们也可以编写C#程序来实现整个过程。 这里测试为主,代码相对简单,实际应用时可进一步封装,代码如下: static void Main(string[] args){ // 连接 Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); socket.Connect("192.168.2.144", 4096); byte[] bytes = new byte[] { 0x50,0x00,//副头部,固定50 00 0x00,// 网络编号 0xFF,//PLC编号 0xFF,0x03,//目标模块IO编号,固定FF 03 0x00,// 目标模块站号 0x0C,0x00, // 字节长度,当前字节往后 0x0A,0x00,//PLC响应超时时间,以250ms为单位计算 0x01,0x04,// 成批读出,主命令 0x00,0x00,// 字操作,子命令 0x00,0x00,0x00,// 起始地址 0xA8,// 区域代码 0x05,0x00 //读取长度 }; socket.Send(bytes); byte[] respBytes = new byte[1024]; int count = socket.Receive(respBytes); if (count == 21) { for (int i = 11; i < count; i+=2) { // 小端处理,每2个字节作为一个数据 byte[] dataBytes = new byte[2]; dataBytes[0] = respBytes[i]; dataBytes[1] = respBytes[i+1]; Console.WriteLine(BitConverter.ToInt16(dataBytes, 0)); } }} 输出结果如下:

    03-14 162浏览
  • C++多线程内存模型(MemoryOrder)详解

    在多线程编程中, 有两个需要注意的问题, 一个是数据竞争, 另一个是内存执行顺序. 什么是数据竞争(Data Racing) 我们先来看什么是数据竞争(Data Racing), 数据竞争会导致什么问题. #include #include int counter = 0;void increment() { for (int i = 0; i < 100000; ++i) { // ++counter实际上3条指令 // 1. int tmp = counter; // 2. tmp = tmp + 1; // 3. counter = tmp; ++counter; }}int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Counter = " << counter << "\n"; return 0;} 在这个例子中, 我们有一个全局变量counter, 两个线程同时对它进行增加操作. 理论上, counter的最终值应该是200000. 然而, 由于数据竞争的存在, counter的实际值可能会小于200000. 这是因为, ++counter并不是一个原子操作, CPU会将++counter分成3条指令来执行, 先读取counter的值, 增加它, 然后再将新值写回counter. 示意图如下: Thread 1 Thread 2---------                 ---------int tmp = counter; int tmp = counter;tmp = tmp + 1;            tmp = tmp + 1;counter = tmp;            counter = tmp; 两个线程可能会读取到相同的counter值, 然后都将它增加1, 然后将新值写回counter. 这样, 两个线程实际上只完成了一次+操作, 这就是数据竞争. 如何解决数据竞争 c++11引入了std::atomic, 将某个变量声明为std::atomic后, 通过std::atomic的相关接口即可实现原子性的读写操作. 现在我们用std::atomic来修改上面的counter例子, 以解决数据竞争的问题. #include #include #include // 只能用bruce-initialization的方式来初始化std::atomic.// std::atomiccounter{0} is ok, // std::atomiccounter(0) is NOT ok.std::atomic<int> counter{0}; void increment() { for (int i = 0; i < 100000; ++i) { ++counter; }}int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Counter = " << counter << "\n"; return 0;} 我们将int counter改成了std::atomiccounter, 使用std::atomic的++操作符来实现原子性的自增操作. std::atomic提供了以下几个常用接口来实现原子性的读写操作, memory_order用于指定内存顺序, 我们稍后再讲. // 原子性的写入值std::atomic::store(T val, memory_order sync = memory_order_seq_cst);// 原子性的读取值std::atomic::load(memory_order sync = memory_order_seq_cst);// 原子性的增加// counter.fetch_add(1)等价于++counterstd::atomic::fetch_add(T val, memory_order sync = memory_order_seq_cst);// 原子性的减少// counter.fetch_sub(1)等价于--counterstd::atomic::fetch_sub(T val, memory_order sync = memory_order_seq_cst);// 原子性的按位与// counter.fetch_and(1)等价于counter &= 1std::atomic::fetch_and(T val, memory_order sync = memory_order_seq_cst);// 原子性的按位或// counter.fetch_or(1)等价于counter |= 1std::atomic::fetch_or(T val, memory_order sync = memory_order_seq_cst);// 原子性的按位异或// counter.fetch_xor(1)等价于counter ^= 1std::atomic::fetch_xor(T val, memory_order sync = memory_order_seq_cst); 什么是内存顺序(Memory Order) 内存顺序是指在并发编程中, 对内存读写操作的执行顺序. 这个顺序可以被编译器和处理器进行优化, 可能会与代码中的顺序不同, 这被称为指令重排. 我们先举例说明什么是指令重排, 假设有以下代码: int a = 0, b = 0, c = 0, d = 0;void func() { a = b + 1; c = d + 1;} 在这个例子中, a = b + 1和c = d + 1这两条语句是独立的, 它们没有数据依赖关系. 如果处理器按照代码的顺序执行这两条语句, 那么它必须等待a = b + 1执行完毕后, 才能开始执行c = d + 1. 但是, 如果处理器可以重排这两条语句的执行顺序, 那么它就可以同时执行这两条语句, 从而提高程序的执行效率. 例如, 处理器有两个执行单元, 那么它就可以将a = b + 1分配给第一个执行单元, 将c = d + 1分配给第二个执行单元, 这样就可以同时执行这两条语句. 这就是指令重排的好处:它可以充分利用处理器的执行流水线, 提高程序的执行效率. 但是在多线程的场景下, 指令重排可能会引起一些问题, 我们看个下面的例子: #include #include #include std::atomic<bool> ready{false};std::atomic<int> data{0};void producer() { data.store(42, std::memory_order_relaxed); // 原子性的更新data的值, 但是不保证内存顺序 ready.store(true, std::memory_order_relaxed); // 原子性的更新ready的值, 但是不保证内存顺序}void consumer() { // 原子性的读取ready的值, 但是不保证内存顺序 while (!ready.load(memory_order_relaxed)) { std::this_thread::yield(); // 啥也不做, 只是让出CPU时间片 } // 当ready为true时, 再原子性的读取data的值 std::cout << data.load(memory_order_relaxed); // 4. 消费者线程使用数据}int main() { // launch一个生产者线程 std::thread t1(producer); // launch一个消费者线程 std::thread t2(consumer); t1.join(); t2.join(); return 0;} 在这个例子中, 我们有两个线程:一个生产者(producer)和一个消费者(consumer). 生产者先将data的值修改为一个有效值, 比如42, 然后再将ready的值设置为true, 通知消费者可以读取data的值了. 我们预期的效果应该是当消费者看到ready为true时, 此时再去读取data的值, 应该是个有效值了, 对于本例来说即为42. 但实际的情况是, 消费者看到ready为true后, 读取到的data值可能仍然是0. 为什么呢? 一方面可能是指令重排引起的. 在producer线程里, data和store是两个不相干的变量, 所以编译器或者处理器可能会将data.store(42, std::memory_order_relaxed);重排到ready.store(true, std::memory_order_relaxed);之后执行, 这样consumer线程就会先读取到ready为true, 但是data仍然是0. 另一方面可能是内存顺序不一致引起的. 即使producer线程中的指令没有被重排, 但CPU的多级缓存会导致consumer线程看到的data值仍然是0. 我们通过下面这张示意图来说明这和CPU多级缓存有毛关系. 每个CPU核心都有自己的L1 Cache与L2 Cache. producer线程修改了data和ready的值, 但修改的是L1 Cache中的值, producer线程和consumer线程的L1 Cache并不是共享的, 所以consumer线程不一定能及时的看到producer线程修改的值. CPU Cache的同步是件很复杂的事情, 生产者更新了data和ready后, 还需要根据MESI协议将值写回内存,并且同步更新其他CPU核心Cache里data和ready的值, 这样才能确保每个CPU核心看到的data和ready的值是一致的. 而data和ready同步到其他CPU Cache的顺序也是不固定的, 可能先同步ready, 再同步data, 这样的话consumer线程就会先看到ready为true, 但data还没来得及同步, 所以看到的仍然是0. 这就是我们所说的内存顺序不一致问题. 为了避免这个问题, 我们需要在producer线程中, 在data和ready的更新操作之间插入一个内存屏障(Memory Barrier), 保证data和ready的更新操作不会被重排, 并且保证data的更新操作先于ready的更新操作. 需要C/C++ Linux服务器架构师学习资料加qun579733396获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享 现在我们来修改上述的例子, 来解决内存顺序不一致的问题. #include #include #include std::atomic<bool> ready{false};std::atomic<int> data{0};void producer() { data.store(42, std::memory_order_relaxed); // 原子性的更新data的值, 但是不保证内存顺序 ready.store(true, std::memory_order_released); // 保证data的更新操作先于ready的更新操作}void consumer() { // 保证先读取ready的值, 再读取data的值 while (!ready.load(memory_order_acquire)) { std::this_thread::yield(); // 啥也不做, 只是让出CPU时间片 } // 当ready为true时, 再原子性的读取data的值 std::cout << data.load(memory_order_relaxed); // 4. 消费者线程使用数据}int main() { // launch一个生产者线程 std::thread t1(producer); // launch一个消费者线程 std::thread t2(consumer); t1.join(); t2.join(); return 0;} 我们只是做了两处改动, 将producer线程里的ready.store(true, std::memory_order_relaxed);改为ready.store(true, std::memory_order_released);, 一方面限制ready之前的所有操作不得重排到ready之后, 以保证先完成data的写操作, 再完成ready的写操作. 另一方面保证先完成data的内存同步, 再完成ready的内存同步, 以保证consumer线程看到ready新值的时候, 一定也能看到data的新值. 将consumer线程里的while (!ready.load(memory_order_relaxed))改为while (!ready.load(memory_order_acquire)), 限制ready之后的所有操作不得重排到ready之前, 以保证先完成读ready操作, 再完成data的读操作; 现在我们再来看看memory_order的所有取值与作用: memory_order_relaxed: 只确保操作是原子性的, 不对内存顺序做任何保证, 会带来上述produer-consumer例子中的问题. memory_order_release: 用于写操作, 比如std::atomic::store(T, memory_order_release), 会在写操作之前插入一个StoreStore屏障, 确保屏障之前的所有操作不会重排到屏障之后, 如下图所示 + | | | No Moving Down |+-----v---------------------+| StoreStore Barrier |+---------------------------+ memory_order_acquire: 用于读操作, 比如std::atomic::load(memory_order_acquire), 会在读操作之后插入一个LoadLoad屏障, 确保屏障之后的所有操作不会重排到屏障之前, 如下图所示: +---------------------------+| LoadLoad Barrier |+-----^---------------------+ | | | No Moving Up | | + memory_order_acq_rel: 等效于memory_order_acquire和memory_order_release的组合, 同时插入一个StoreStore屏障与LoadLoad屏障. 用于读写操作, 比如std::atomic::fetch_add(T, memory_order_acq_rel). memory_order_seq_cst: 最严格的内存顺序, 在memory_order_acq_rel的基础上, 保证所有线程看到的内存操作顺序是一致的. 这个可能不太好理解, 我们看个例子, 什么情况下需要用到memory_order_seq_cst: #include#include#include std::atomic<bool> x = {false};std::atomic<bool> y = {false};std::atomic<int> z = {0}; void write_x(){ x.store(true, std::memory_order_seq_cst);} void write_y(){ y.store(true, std::memory_order_seq_cst);} void read_x_then_y(){ while (!x.load(std::memory_order_seq_cst)) ; if (y.load(std::memory_order_seq_cst)) { ++z; }} void read_y_then_x(){ while (!y.load(std::memory_order_seq_cst)) ; if (x.load(std::memory_order_seq_cst)) { ++z; }} int main(){ std::thread a(write_x); std::thread b(write_y); std::thread c(read_x_then_y); std::thread d(read_y_then_x); a.join(); b.join(); c.join(); d.join(); assert(z.load() != 0); // will never happen} 以上这个例子中, a, c, c, d四个线程运行完毕后, 我们期望z的值一定是不等于0的, 也就是read_x_then_y和read_y_then_x两个线程至少有一个会执行++z. 要保证这个期望成立, 我们必须对x和y的读写操作都使用memory_order_seq_cst, 以保证所有线程看到的内存操作顺序是一致的. 如何理解呢,我们假设将所有的 memory_order_seq_cst 替换为 memory_order_relaxed,则可能的执行顺序如下: 线程 A:write_x()执行时,x 被设为 true。 线程 B:write_y()执行时,y 被设为 true。 线程 C:read_x_then_y() 里面读取到 x 是 true,但由于 memory_order_relaxed 不保证全局可见性,y.load()可能仍然读取到 false。 线程 D:read_y_then_x() 里面读取到 y 是 true,但同样,x.load()可能仍然读取到 false。 如果我们使用的是memory_order_seq_cst,就不会遇到这种情况。线程C和线程D的看到的x和y的值就会是一致的。 这样说可能还是会比较抽象, 我们可以直接理解下memory_order_seq_cst是怎么实现的. 被memory_order_seq_cst标记的写操作, 会立马将新值写回内存, 而不仅仅只是写到Cache里就结束了; 被memory_order_seq_cst标记的读操作, 会立马从内存中读取新值, 而不是直接从Cache里读取. 这样相当于write_x, write_y, read_x_then_y, read_y_then_x四个线程都是在同一个内存中读写x和y, 也就不存在Cache同步的顺序不一致问题了. 理解memory_order_seq_cst的实现后,我们再回过头来就比较好理解为什么使用memory_order_relaxed 时线程C和线程D里看到的x和y的值会不一样了。 我们来看下面这张图,如果我们使用的是memory_order_relaxed ,则: write_x()与read_y_then_x()放到 Core1 执行 write_y()与read_x_then_y()放到 Core2 上执行。 结果就会是: write_x只会把自己 Core 里 Cache 的 x 更新为 true,也就只有 Core1 能看到 x为 true,而 Core2 里的 x 还是 false。 write_y只会把自己 Core 里 Cache 的 y 更新为 true,也就只有 Core2 能看到 y为 true,而 Core1 里的 y 还是 false。 而如果我们用的是memory_order_seq_cst,那么write_x()和write_y()都会把 Cache 与 Memory 里的值一起更新,read_x_then_y()与read_y_then_x()都会从 Memory 里读值,而不是从自己 Core 里的 Cache 里读,这样它们看到的 x和y的值就是一样的了。 所以我们也就能理解为什么memory_order_seq_cst会带来最大的性能开销了, 相比其他的memory_order来说, 因为相当于禁用了CPU Cache. memory_order_seq_cst常用于multi producer - multi consumer的场景, 比如上述例子里的write_x和write_y都是producer, 会同时修改x和y, read_x_then_y和read_y_then_x两个线程, 都是consumer, 会同时读取x和y, 并且这两个consumer需要按照相同的内存顺序来同步x和y.

    03-06 174浏览
  • PLC程序中急停为什么要写成常开?

    在PLC编程中,一般把停止按钮物理接点接成常闭,程序中写成常开,这样的停止按钮在这套设备中只起到信号作用。 为什么PLC编程中急停要和普通急停按钮相反呢?其实写成常开的不仅仅是急停,还有热继、温控等等带有保护功能的接点,具体原因见下文: 首先急停按钮接入PLC的DI点中也是以常闭点接入: 上图就是急停按钮接入PLC中的接线图的一部分,可以看到是常闭触点的状态接入!以常闭触点接入的好处是,当急停按钮所在的线路断路了,程序中也能立马反映出来,或者说相当于急停按钮被按下去,常闭触点变成了常开触点,实现了对断线状态的监控! 然后在PLC程序中看一下: 这段程序,启动条件1(I0.2)和启动条件2(I0.3)都是以常开状态接入PLC的两个按钮,而急停按钮(E_StopPB)则是以常闭状态接入PLC的按钮!---从图片上的圆圈①可以看到,3个按钮都没有按下的时候,只有E_StopPB是接通的,当三个按钮都按下时即圆圈②处,启动条件1和2的两个状态接通了,而E_StopPB的状态则是显示断开!这两种状态的不同就解释了,为何急停按钮在PLC硬件处接线要用常闭,而PLC程序中要用常开!---是因为PLC模块的数字量DI点,是外围电路接通程序内部就显示接通,而外围电路断开则程序内部就显示断开,也就是说PLC的DI点硬件电路设计造成的!另外,因为急停信号是很重要的点,所以人们利用PLC数字量DI点的硬件特性,人为的规定急停按钮接线应该是常闭点接入!---因为常闭点造成电路一直接通,所以程序中就要用常开点,这样才能保证不急停的时候,程序逻辑能接通!

    03-05 155浏览
  • 多路开关状态指示设计:掌控电路的智能之眼

    1. 如图4.3.1所示,AT89S51单片机的P1.0-P1.3接四个发光二极管L1-L4,P1.4-P1.7接了四个开关K1-K4,编程将开关的状态反映到发光二极管上。(开关闭合,对应的灯亮,开关断开,对应的灯灭)。 2. 电路原理图   图4.3.1...

    02-27 98浏览
  • 嵌入式软件为何要用状态机架构

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

    01-14 119浏览
  • 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 116浏览
  • 用状态机在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 196浏览
  • 真心不粗!适合练手的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 262浏览
正在努力加载更多...
广告