ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式自学习模式超时退出与串口状态联动设计

嵌入式自学习模式超时退出与串口状态联动设计 前一阵调一个带自学习功能的设备测试工程师跑过来问了我一句用户进了自学习页面然后放着不动会不会一直卡在里面我第一反应是加个超时退出不就完了。但真动手写需求的时候才意识到放着不动自动退出这句话背后牵扯的东西比想象中多得多什么时候开始计时、什么操作算动了、退出前要不要给提示、退出之后上位机怎么知道、学到一半的数据怎么处理——这些统称起来就是超时退出的交互兜底以及退出条件与串口状态联动设计。这篇文章把我在这个需求上的完整思考、状态机实现和联调过程梳理一遍给遇到类似模式超时管理问题的朋友一个参考。这个需求适用于所有带配置/学习类模式的嵌入式设备学习型遥控器、家电控制器的对码模式、工业参数配置工具、带上位机的调试设备。不管具体行业是什么核心问题是一样的一个非常驻页面用户可能中途离开设备必须自己判断何时退出并且在退出时不能让用户和上位机蒙在鼓里。1. 先把这个需求看透自学习模式为什么要管退出1.1 真实场景还原用户进了自学习页然后走开了场景假设是设备有一个自学习模式。用户从主菜单进入设备进入监听状态等待用户通过按键或上位机下发方式来录入一组控制指令或者码值。看上去很简单但实际使用中一定会出现这样的情况用户进入学习页之后扭头去拿遥控器或者对着说明书找某个参数又或者中途接了个电话——设备就一个人孤零零地停在学习中界面。如果没有超时退出机制这个状态会一直保持到用户回来或者断电。看似没毛病但有几个现实问题第一用户可能压根忘了自己进过这个模式。过几天再看到设备停在学习页一脸懵。第二如果设备平台上有上位机在等待它的应答上位机那边会一直等到自己的超时才算完两边状态就错位了。第三有些学习过程还会让设备进入一个特殊的收发状态长时间停在那里可能干扰正常的业务处理比如无法响应新的指令。所以自学习放着不动会自动退出吗这个问题的答案是必须要会而且要设计得合理。不加超时的学习模式本质上就是一个无限期占用资源的异常状态。1.2 放着不动为什么不直接退出而要设计交互兜底最粗暴的做法是在进入学习模式那一刻起一个计数器超过60秒没有任何操作就立刻退出回到主界面。代码三行就写完但实际部署后会挨骂。为什么因为60秒没操作其实是分情况的。有一种情况是用户真的不在现场什么都不会按另一种情况是用户还在准备只是还没到操作那一步。如果设备到了60秒直接啪地退出等用户拿起遥控器开始按发现界面已经回到主界面了这次学习就白整了。这就引出交互兜底这个概念。所谓兜底就是当系统要因为超时做一件可能打断用户的事情时要给一个缓冲、一个解释、一个善后而不是生硬地跳走。具体到自学习模式我理解的兜底至少包括三件事退出前有倒计时预告用户只要按任意键就能取消退出重新留在学习模式。退出时把已经学到的部分数据保存下来不让用户白干。退出后把结果包括原因通过串口上报给上位机让上位机的界面状态和设备状态对齐。后面两项还要再加上退出后显示一个原因页让操作者知道发生了什么。这四件事加在一起超时退出才不是踢人而是带着上下文退场。这里有个设计原则值得多说一句任何超时退出的设计都要站在最倒霉的用户角度去检查。最倒霉的情况就是用户准备了好几分钟刚准备操作设备退出了。倒计时预告加一键取消就是专门给这个用户留的一扇门。2. 超时退出机制设计从计时器到状态机的完整链路2.1 超时策略怎么定统一超时还是分段管理超时策略这里不同产品差异很大。如果只是临时进来看一眼状态比如一个详情页10到15秒没操作退出都算正常。但自学习模式不一样它是一个完整的任务流程用户可能在中途换设备、翻资料所以我把它拆成两段管理。第一段是学习会话超时我定为60秒。也就是从进入学习模式开始如果在60秒内没有任何有效的用户操作、也没有收到上位机发来的任何有效帧系统就进入退出预告阶段。第二段是退出预告阶段我给10秒。在这10秒里屏幕显示倒计时同时接收所有可取消操作按键、触摸、有效串口帧一旦有任意活动就取消退出回到学习模式。这样设计的好处是把判断用户是否还在和提醒用户我马上要走了拆成两个独立的阶段各管各的逻辑。判断阶段只需要安静地计数不打扰用户预告阶段才开始占用屏幕和交互给用户最后的反悔机会。什么算活动这点一定要定义清楚不然后面全是坑。我整理的判定规则如下活动源是否刷新超时计时说明学习模式自己的按键是用户在执行学习动作显然还在串口有效协议帧是上位机正在交互必须续命串口裸数据/噪声否要校验帧头帧尾没通过不算无关中断如定时器中断否只是内核事件不代表用户在场传感器误触发否没有经过业务确认的事件都不算这个表看着简单实际是踩过坑才总结出来的。最初我把所有串口接收中断都拿来刷新计时结果一个带干扰的环境里设备进了学习模式就永远不超时因为噪声一直在续命。后来改成只有通过协议校验的有效帧才能刷新问题立刻消失。2.2 计时实现的核心1秒tick加标志位而不是阻塞式延时实现上我用的最不起眼但最稳妥的方式一个1秒的系统tick配合状态标志位。整个工程跑的是裸机没有操作系统所以实际的超时检查放在主循环里做。为什么不用HAL_Delay或者中断里死等两个原因。第一主流程还要处理按键扫描、显示刷新、串口接收阻塞式延时一进来其他事情全卡死。第二超时退出不是精确到毫秒的实时任务1秒级别的精度完全够用没必要给系统增加实时性负担。代码结构大概是这个思路typedef enum { LS_IDLE 0, LS_LEARNING, LS_EXIT_PREVIEW, } learn_state_t; #define LEARN_SESSION_TIMEOUT_S 60U #define EXIT_PREVIEW_TIMEOUT_S 10U typedef struct { learn_state_t state; uint16_t idle_counter; uint16_t preview_counter; } learn_ctx_t; static learn_ctx_t g_learn; void learn_enter(void) { g_learn.state LS_LEARNING; g_learn.idle_counter 0; g_learn.preview_counter 0; ui_show(PAGE_LEARN); uart_send_status(FRM_ENTER_LEARN); } void learn_tick_1s(void) { if (g_learn.state LS_LEARNING) { if (g_learn.idle_counter LEARN_SESSION_TIMEOUT_S) { g_learn.state LS_EXIT_PREVIEW; g_learn.preview_counter EXIT_PREVIEW_TIMEOUT_S; ui_show(PAGE_EXIT_PREVIEW); } } else if (g_learn.state LS_EXIT_PREVIEW) { if (--g_learn.preview_counter 0) { learn_exit_timeout(); } } }简单的核心就是这个。idle_counter在每次有效事件里清零一旦累计到60秒状态就从LS_LEARNING跳到LS_EXIT_PREVIEW同时把预告倒计时设置为10秒。预告阶段每过1秒减1减到0就真正退出。如果中途有活动事件直接回到LS_LEARNINGidle_counter清零重来。这里有几个地方要特别注意。第一tick函数里只做状态迁移和计数不做UI绘制、不做串口发送更不做Flash读写。这些操作耗时不确定会拉长中断或主循环周期。第二进入预告阶段后如果收到有效事件不是简单刷新一下idle_counter而是要把整个状态切回学习模式并且重置idle_counter否则会出现预告已经显示到第9秒活动一下又切回学习但idle其实已经59秒了下一秒又进预告的反复横跳。2.3 计时过程容易踩的坑看门狗、消抖和无效事件这一节专门讲我在联调中真正踩过的三个坑。坑一是硬件看门狗。如果设备开了IWDG而学习模式下主循环长时间停在某个等待操作的地方没有喂狗设备会突然复位表现出来就是学习模式自己退了。排查这个问题时我一度怀疑是状态机bug后来在代码里加了一个学习模式下周期性喂狗的动作才稳定。记住有看门狗时任何长时间停留的状态都要安排喂狗路径但喂狗也要做在正常的业务循环里别放在中断里否则死循环都喂不死看门狗就失去意义了。坑二是按键消抖导致的虚假活动。机械按键按下到稳定抖动时间通常有几十毫秒如果消抖做得不好一次按键会被识别成多次按下。这会在学习模式下反复刷新idle_counter间接造成两个后果一是用户明明只按了一下系统却记了多组学习数据二是超时一直被续命退不出去。所以按键事件要在消抖、去重、确认电平稳定之后再交给状态机。坑三是串口噪声被当成有效活动。前面已经提到接收中断里只要有数据就刷新计时会带来灾难性的永不超时。这里再补一个更隐蔽的情况即使上位机没有主动发数据有些USB转串口模块在上电瞬间或者驱动复位时会向总线吐出一个0x00字节如果设备把所有字节都当作有效事件这个0x00就足够让计时清零一次。所以有效帧的判断必须包含帧头、长度、校验三重校验缺一不可。3. 退出条件与串口状态联动UI状态和通信状态互相感知3.1 为什么要让退出条件和串口状态联动现在回到标题里那个关键短语退出条件挂串口的状态联动设计。翻译成大白话就是设备什么时候退出学习模式不能只由本机的按键/触摸决定还要把串口那边有没有上位机在等这个因素考虑进来并且在退出时把状态变化主动告诉串口对端。为什么需要这样想象一个真实的使用流程用户打开上位机软件通过串口让设备进入自学习模式然后人离开工位去拿测试样机。此时设备屏幕可能放在角落里用户根本看不见唯一的沟通渠道是串口。如果设备在用户离开期间超时退出了而上位机还停留在学习中的界面上干等用户回来看到上位机界面上没有任何提示第一反应是设备死机了。换句话说自学习模式里的退出不是一个纯UI事件而是一个会影响多个通信参与方的状态变化。如果这个状态变化不上报上位机就活在错误的认知里。反过来如果上位机还在持续下发配置帧说明上位机那边业务没有结束设备端的超时退出就应该让路——这就是状态联动。这里的实现思路可以概括为事件驱动、双向感知设备端状态变化时主动发帧通知上位机上位机有持续流量时设备端刷新超时计时。3.2 串口协议设计状态帧、保活帧、数据帧既然要联动串口协议里就得有明确的帧类型。这块我用的是一种很常见的自定义帧格式帧头固定两个字节接着是帧类型、长度、负载和校验。实际工程里可以按项目情况调整但核心的几类帧必须有。帧类型方向触发条件用途FRM_ENTER_LEARN设备 - 上位机设备进入学习模式上位机界面切到学习中状态FRM_LEARN_ACTIVE上位机 - 设备上位机有交互操作刷新设备端超时计时保持会话FRM_LEARN_DATA双向学习到一条数据/下发一条配置传输具体学习内容FRM_EXIT_PREVIEW设备 - 上位机设备进入退出预告阶段提前告诉上位机马上要退出了FRM_TIMEOUT_EXIT设备 - 上位机设备因超时退出上位机可提示用户并保存状态FRM_MANUAL_EXIT设备 - 上位机用户手动退出学习上位机同步状态需要特别强调FRM_EXIT_PREVIEW这帧。它是交互兜底在通信侧的落地设备进入倒计时的瞬间就发出去上位机收到后可以弹一个设备即将退出是否保持连接的提示或者主动回发一个FRM_LEARN_ACTIVE把设备重新拉回学习状态。这一帧的存在让兜底不止发生在本地屏幕也发生在远程对端。保活帧的方向也值得琢磨。上位机下发的FRM_LEARN_ACTIVE不一定要单独定时发完全可以在上位机每次正常交互时顺带发出。比如上位机每下发一条配置帧设备接收到合法帧就顺带刷新计时。这样最自然不需要额外的保活线程。3.3 代码层面的联动实现协议处理上我在串口接收中断里只负责把字节放入环形缓冲区DMA收了整帧后再做协议解析解析完成后回调到业务层。业务层里有一个专门处理联动事件的函数void learn_on_uart_frame(uint8_t frame_type, uint8_t *payload, uint16_t len) { if (g_learn.state ! LS_LEARNING g_learn.state ! LS_EXIT_PREVIEW) { return; } switch (frame_type) { case FRM_LEARN_ACTIVE: case FRM_LEARN_DATA: /* 上位机还在交互会话续期 */ learn_keepalive(); break; case FRM_EXIT_PREVIEW_ACK: /* 上位机确认退出预告可以减少预告时长 */ if (g_learn.state LS_EXIT_PREVIEW) { g_learn.preview_counter 2; } break; default: break; } } static void learn_keepalive(void) { if (g_learn.state LS_EXIT_PREVIEW) { /* 从预告阶段拉回学习模式重置计时 */ g_learn.state LS_LEARNING; g_learn.idle_counter 0; ui_show(PAGE_LEARN); } else { g_learn.idle_counter 0; } }退出时上报状态帧的部分放在learn_exit_timeout里static void learn_exit_timeout(void) { /* 保存部分学习结果 */ nvm_save_partial_learn_data(g_learn_data, g_learn_len); /* 上报超时退出状态帧附带已学习数据长度作为摘要 */ uint8_t body[2]; body[0] (uint8_t)(g_learn_len 8); body[1] (uint8_t)(g_learn_len 0xFF); uart_send_frame(FRM_TIMEOUT_EXIT, body, sizeof(body)); g_learn.state LS_IDLE; ui_show(PAGE_EXIT_REASON); }这里有个细节退出页面我用的是PAGE_EXIT_REASON而不是直接跳回主界面。原因还是兜底——用户回来看到的不应该是我为什么被踢出来了的疑问而是一个明确的原因页上面写着自学习超时退出已保存部分数据按OK重新进入。这个页面保留几秒后自动跳回主界面也可以按OK键直接重新进入学习模式省得用户再翻菜单。4. 实操记录一次完整的自学习超时退出联调4.1 硬件环境和工程结构这次联调用的硬件是STM32F103C8T6最小系统板外接一块0.96寸OLED、一个CH340 USB转串口模块、一个红外接收头。工程是裸机代码没有操作系统核心模块分别是按键扫描、OLED显示、串口DMA收发、红外学习以及本文重点的状态机管理。串口这块用的是UART1波特率1152008N1DMA接收加空闲中断。为什么要用DMA加空闲中断在自学习模式下上位机可能连续下发多条配置帧如果逐字节中断接收高波特率下很容易丢字节。DMA配合IDLE中断可以做到收满一帧才打断CPU一次减少丢失。这块后面在常见问题里还会展开。工程结构上我习惯把状态机相关代码单独放一个文件不跟UI和驱动混在一起。learn_state.c管状态迁移ui_page.c只管页面显示uart_proto.c管协议解析。这样的好处是以后换屏、换通信方式状态机逻辑基本不用动。4.2 核心代码走查状态管理、超时检查、串口处理把完整流程串起来看一遍。系统上电后用户在主界面按自学习进入。learn_enter被调用设备发FRM_ENTER_LEARN给上位机屏幕显示学习中。主循环每轮做三件事扫描按键、刷新显示、检查tick标志。tick标志由定时器中断每1秒置一次。主循环里检查到标志后调用learn_tick_1s完成超时计数和状态迁移。串口侧DMA接收空闲中断在收到一帧数据后置一个帧就绪标志主循环检测到标志后调用uart_proto_parse解析成功再回调learn_on_uart_frame根据帧类型决定是刷新计时还是处理学习数据。这里贴一下主循环的骨架int main(void) { systick_init(); uart_dma_init(); lcd_init(); key_init(); ir_learn_init(); while (1) { uint32_t tick get_tick_flag(); if (tick) { clear_tick_flag(); learn_tick_1s(); } if (uart_frame_ready()) { uart_proto_parse(); } if (key_event_ready()) { handle_key_event(key_get_event()); } lcd_refresh(); } }看起来很简单但正是这种所有事情都在主循环里排队的结构保证了每个事件的处理都是非阻塞的。按键按下、串口来帧、超时计数三者互不阻塞学习模式下无论哪一路事件发生系统都能及时响应。4.3 联调日志与现象分析我用串口调试助手分别扮演上位机抓了下面这一段典型的超时退出日志[12:00:01.123] RX: AA 55 01 00 04 10 20 30 40 [12:00:01.126] TX: AA 55 0A 02 04 10 20 30 40 --- 设备回应学习数据 [12:01:00.000] TX: AA 55 0C 01 5A --- 进入退出预告原因超时(0x5A) [12:01:00.003] RX: AA 55 0B 01 E8 --- 上位机回读保活/取消退出 [12:01:00.005] TX: AA 55 0A 01 00 --- 设备确认回到学习模式 [12:02:00.000] TX: AA 55 0C 01 5A --- 再次进入退出预告 [12:02:10.000] TX: AA 55 0D 01 01 02 --- 超时退出摘要学习到0x0102字节留意12:01:00那几行。设备在第60秒进入预告阶段后上位机立刻收到预告帧并按了一下保持连接设备就重新回到了学习模式idle归零。到12:02时又过了60秒没有活动这次上位机没有再干预于是10秒预告结束后设备发出超时退出帧携带了已学习数据的长度摘要。这个日志说明了整个联动设计的价值假如没有预告帧12:01:00设备就悄悄退出了上位机根本不知道假如没有保活帧上位机想挽留也没有手段。现在已经能做到设备有意见先通报上位机有想法能续命。5. 常见问题与排查技巧实录5.1 串口收不到状态帧或数据乱码这类问题在串口联调里出现频率最高而且大部分不是代码逻辑问题是物理层和配置层的低级问题。我按排查顺序整理成实战清单第一先确认波特率。设备端写得是115200调试助手必须也是115200差一点都不行。乱码的绝大多数原因就是波特率不匹配特别是两个设备都写着9600但在不同平台上取用的时钟源不同实际波特率可能差得挺远。第二确认共地。USB转串口模块和设备之间除了TX、RX两根数据线GND必须共地。不共地的表现很奇怪有时能收到数据有时全是乱码有时收不到。我在工位上试过GND接不接数据通道的表现完全不一样。第三如果是TTL电平转232/485的场合还要确认电平类型匹配。TTL输出直接接RS232口是收不到的反过来一样。模块选型时就要分清楚。第四用回环测试排除模块问题。把USB转串口模块的TX和RX短接在调试助手里发什么收什么如果回环正常说明模块没问题问题在设备端或者线缆。如果回环正常但设备端收不到再看接线是否接反。TX接RX、RX接TX这是新手最容易翻车的点没有之一。5.2 超时退出不生效或误退出超时退出逻辑本身不复杂但如果出现永远不退出或者刚进去就退出多半是下面几个原因。永远不退出最常见的原因是无效事件刷新计时。前面说过串口噪声、按键抖动、甚至定时器中断都可能被错误地当成用户活动。检查思路是在learn_keepalive里加一个临时计数器每次刷新计时就在串口打一个调试字符然后把设备静置看调试字符会不会一直打出来。如果会说明某个事件源在持续产生虚假活动逐个排查事件源即可。另一个原因是idle_counter根本没有递增。这通常不是状态机的问题而是tick没跑起来。检查定时器中断是否注册、中断优先级是否被其他中断抢占、SysTick的HAL配置是否在某种低功耗模式下被停掉。我遇到过一例代码里开启了STOP模式一旦进入STOPSysTick停摆整个超时系统瘫痪。刚进去就退出则要看是不是上电残留状态。如果上次关机时g_learn.state没有清回LS_IDLE而且idle_counter是非零值下次进入学习模式时会直接从旧值开始计数表现为进入后立刻退出。解决办法是在learn_enter里无条件把state、idle_counter、preview_counter全部初始化不要相信任何保留值。5.3 USB转串口驱动与烧写失败这类基础问题最后再说几个我经常在群里被问到的入门级但致命问题因为这些会直接卡住联调进度。USB转串口模块识别不到或者设备管理器里面芯片型号不对大概率是驱动问题。CH340、FTDI、WCH的CP210x系列驱动各有各的驱动包不要混用。Windows下CH340偶尔会和系统自带的usbser驱动冲突表现为设备管理器里出现感叹号这时把设备卸载后手动安装CH340官方驱动插拔一次通常能解决。STM32烧写失败是另一个高频问题。常见的现象是能识别到COM口但下载时提示连接不上或者超时。第一步检查BOOT0引脚用串口ISP下载时要保证BOOT0拉高、BOOT1拉低第二步降低下载波特率9600、57600比921600稳得多第三步看是不是有上位机软件占用了COM口把串口调试助手、日志工具全部关掉再下载。还有在Ubuntu下用CH340的老问题插上之后没有ttyUSB节点。大部分是没有权限或者内核模块没加载sudo modprobe ch341后重新插拔并把当前用户加入dialout组就能解决。这些基础问题看起来不高级但我在真实项目里几乎每一个都遇到过一遍。把它们放在这个项目的文章里是因为自学习模式和串口的状态联动设计再好一旦驱动没装好、波特率对不上联调根本走不到看状态帧那一步。最后再分享一个我从这个需求里沉淀下来的心得。设计任何带模式切换的嵌入式交互时别只盯着功能怎么实现要先想清楚用户离开之后系统怎么办。超时退出、交互兜底、串口状态联动这三个词其实是同一个问题的三种解法设备在没有人在场的时候如何体面地结束一件事并且让所有相关方都知道发生了什么。我把这套状态机结构抽出来之后后面好几个项目包括对码界面、固件升级等待确认界面、上位机批量配置界面都直接复用了改改超时时间和帧类型就行。如果你也在做类似的自学习或者配置类模式建议先把退出条件列表写在需求文档第一页它比如何进入更值得花时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进