# 云端抽签触发系统事件任务栈溢出:问题与修复说明 ## 1. 对应提交 ```text d386de2 fix(multimodal): prepare session for cloud fortune draw ``` 本文说明首次抽签能够自动创建多模态会话后,按下 SW2 导致 `cp_system_event` 任务栈溢出的问题,以及最终采用的异步 worker 方案。 ## 2. 问题现象 修复“首次抽签缺少会话”后,设备按下 SW2 可以进入 `Start` 发送,但随即触发 FreeRTOS 栈保护: ```text control_plane_mm_rt: Cloud chouqian request queued cp_mm_session: Start sent (...) ***ERROR*** A stack overflow in task cp_system_event has been detected. ``` 回溯显示: ```text panic_abort esp_system_abort vApplicationStackOverflowHook vTaskSwitchContext ``` 栈已经被破坏,因此回溯无法继续还原完整的业务调用链,但任务名明确指向 `cp_system_event`。 ## 3. 问题原因 ### 3.1 系统事件任务栈较小 系统事件任务配置为 4096 字节: ```c #ifndef CONFIG_TQ_SYSTEM_EVENT_TASK_STACK #define CONFIG_TQ_SYSTEM_EVENT_TASK_STACK 4096 #endif xTaskCreatePinnedToCore(control_plane_system_event_task, "cp_system_event", CONFIG_TQ_SYSTEM_EVENT_TASK_STACK, NULL, 4, &s_system_event_task, CONTROL_PLANE_SYSTEM_EVENT_TASK_CORE); ``` 该任务原本只负责读取事件队列、识别按键类型并分发轻量控制命令,不适合承担协议报文构造和同步网络发送。 ### 3.2 首版修复在按键调用栈内执行了 Start 首版会话准备逻辑在登记请求后立即调用推进函数: ```c ESP_LOGI(TAG, "Cloud chouqian request queued"); control_plane_try_cloud_chouqian_request(); return ESP_OK; ``` 实际调用链变成: ```text cp_system_event -> control_plane_dispatch_key_event -> control_plane_request_cloud_chouqian -> control_plane_try_cloud_chouqian_request -> control_plane_multimodal_send_start -> 创建 cJSON 协议对象 -> 序列化 Start JSON -> WebSocket 文本发送与日志格式化 ``` `Start` 包含音频、下行、voice、client info、workspace 和 app 等参数。虽然 JSON 节点和序列化文本主要使用 heap,但多层函数调用、局部结构体、格式化和网络发送仍会消耗任务栈,最终超过 `cp_system_event` 的 4096 字节限制。 ### 3.3 单纯扩大系统事件栈不是合适的职责划分 直接扩大 `cp_system_event` 栈可以缓解当前崩溃,但仍有以下问题: - 所有按键和系统事件都被迫为少数网络操作长期预留大栈; - WebSocket 发送可能等待锁或网络超时,阻塞其他系统事件; - 以后 Start 协议字段增加后仍可能再次逼近栈上限; - 事件分发任务承担了不属于它的会话编排职责。 因此最终方案是迁移执行上下文,而不是只增加系统事件任务的栈。 ## 4. 解决方式 ### 4.1 按键任务只登记状态并通知 worker 最终实现不再从 `control_plane_request_cloud_chouqian()` 直接执行 Start 或 Prompt: ```c esp_err_t control_plane_request_cloud_chouqian(void) { uint32_t requested_ms = control_plane_now_ms(); portENTER_CRITICAL(&s_multimodal_runtime_lock); if (s_cloud_chouqian_pending) { portEXIT_CRITICAL(&s_multimodal_runtime_lock); return ESP_ERR_INVALID_STATE; } s_cloud_chouqian_pending = true; s_cloud_chouqian_action_in_progress = false; s_cloud_chouqian_prompt_sent = false; s_cloud_chouqian_requested_ms = requested_ms; s_model_command_dispatched = false; portEXIT_CRITICAL(&s_multimodal_runtime_lock); ESP_LOGI(TAG, "Cloud chouqian request queued"); control_plane_wake_multimodal_watchdog(); return ESP_OK; } ``` 此时 `cp_system_event` 只执行: ```text 写入少量受锁保护的状态 -> 打印 queued 日志 -> 发送 FreeRTOS task notification -> 返回事件循环 ``` ### 4.2 使用 FreeRTOS task notification 唤醒 watchdog ```c static void control_plane_wake_multimodal_watchdog(void) { TaskHandle_t task = get_multimodal_task_handle(&s_multimodal_watchdog_task); if (task != NULL) { xTaskNotifyGive(task); } } ``` 任务通知不携带大对象,也不需要新建队列。抽签请求的真实状态保存在受 `s_multimodal_runtime_lock` 保护的静态状态中,通知只表示“需要立即检查一次”。 ### 4.3 watchdog 同时支持通知唤醒和周期检查 原 watchdog 每秒固定延时: ```c vTaskDelay(tick_interval); ``` 修改后使用带超时的通知等待: ```c while (!get_multimodal_stop_flag(&s_multimodal_watchdog_stop_requested)) { (void)ulTaskNotifyTake(pdTRUE, tick_interval); control_plane_multimodal_runtime_state_t state = {0}; if (control_plane_multimodal_get_runtime_state(&state) != ESP_OK) { idle_transport_started_ms = 0U; continue; } uint32_t now_ms = control_plane_now_ms(); control_plane_try_cloud_chouqian_request(); control_plane_try_server_success_prompt(&state, now_ms); /* 原有 heartbeat、stall 和 idle transport 检查继续执行。 */ } ``` 其语义为: - 收到抽签或会话事件通知时立即运行; - 没有通知时最多等待 1 秒,继续执行原有周期 watchdog 工作; - 通知在 worker 忙碌时会被记录,下一轮循环不会丢失唤醒; - `pending` 状态是业务事实来源,不依赖通知次数表达请求数量。 ### 4.4 会话回调也不直接发送网络请求 `TASK_STARTED` 和 `SERVER_LISTENING` 可能发生在 WebSocket 消息处理上下文。最终实现让它们同样只唤醒 watchdog: ```c case CONTROL_PLANE_MULTIMODAL_EVENT_TASK_STARTED: control_plane_wake_multimodal_watchdog(); break; case CONTROL_PLANE_MULTIMODAL_EVENT_SERVER_LISTENING: control_plane_wake_multimodal_watchdog(); break; ``` 这样 `Start` 和 `RequestToRespond` 始终由同一个 control-plane worker 执行,不会把同步 WebSocket 发送压力转移到另一个事件回调任务。 ### 4.5 增加 watchdog 栈余量 watchdog 现在负责构造和发送 Start、RequestToRespond,因此栈从 6144 字节增加到 8192 字节: ```c #define CONTROL_PLANE_WATCHDOG_TASK_STACK_SIZE 8192 ``` 增加的 2048 字节用于覆盖多模态协议 JSON 构造、日志格式化和 WebSocket 发送调用链。系统事件任务仍保持原来的 4096 字节,不为网络工作长期扩容。 ## 5. 修复后的执行上下文 ```text cp_system_event(4096 字节) -> 识别 SW2 SHORT_PRESS -> 设置抽签 pending -> xTaskNotifyGive(mm_watchdog) -> 立即返回 mm_watchdog(8192 字节) -> 被通知唤醒 -> 查询多模态会话状态 -> 必要时构造并发送 Start -> 等待服务端 Listening 通知 -> 构造并发送 RequestToRespond ``` 系统事件任务与网络发送调用栈已经完全分离。 ## 6. 解决成果 - `cp_system_event` 不再执行 Start JSON 构造和 WebSocket 发送; - SW2 按键分发能够快速返回,不会阻塞其他系统事件; - task-started 和 Listening 回调不再直接执行抽签网络请求; - watchdog 通过任务通知即时响应,不引入固定 1 秒操作延迟; - watchdog 栈增加到 8192 字节,为新增职责提供明确余量; - 原有 heartbeat、下行 stall 恢复和空闲 transport 管理仍按 1 秒周期运行; - ESP-IDF v5.5.4 完整构建通过; - 生成的应用固件大小为 `0x20f450` 字节,最小应用分区剩余约 41%。 ## 7. 后续影响与边界 ### 7.1 内存影响 watchdog 栈增加 2048 字节,通常来自 FreeRTOS 可用内部内存。这是本修复最明确的常驻内存成本。相比扩大通用系统事件任务栈,额外内存被分配给实际执行网络协议工作的 owner task,职责更匹配。 ### 7.2 watchdog 唤醒频率 每次抽签排队、task started 和 Listening 事件都会通知 watchdog。通知只增加一次轻量调度,不创建新任务,不分配业务消息内存,也不会改变原有任务优先级。 ### 7.3 原有周期任务行为 `ulTaskNotifyTake(pdTRUE, tick_interval)` 在无通知时仍以 1 秒超时返回,所以 heartbeat、无响应保护、stall 恢复和 idle transport 关闭逻辑的最大检查间隔没有变长。有通知时这些检查可能提前执行,这是安全的。 ### 7.4 请求可靠性 task notification 只负责唤醒,不负责保存业务数据。即使多次通知合并,`s_cloud_chouqian_pending` 仍保持为 true,watchdog 的周期检查也会继续推进请求,因此不会因为通知合并而丢失抽签状态。 ### 7.5 仍需关注的验证项 - 冷启动后首次按 SW2,确认不再出现 `cp_system_event` 栈溢出; - 通过运行时 stack high-water mark 检查 `mm_watchdog` 的实际余量; - 弱网和 WebSocket 发送超时场景下,确认系统事件队列仍能持续处理其他按键; - 高频 SW2 和 PTT 交错操作下,确认不会发生重复 Prompt 或异常会话抢占。