feat(spi): 增强 SPI master:性能剖析与执行耗时跟踪

- 新增 `lastWorkerExecUs()`:获取最近一次 worker 任务执行耗时(µs)。
- 引入 `SpiProfile` 结构体:事务级详细剖析,记录 SPI 事务各阶段的时间戳。
- 实现 `lastProfile()`:诊断用,获取最近一次事务剖析快照。
- SPI 时钟配置提升至 13.3 MHz,改善性能。
- 修改前端 JavaScript 轮询逻辑:新增 uptime 检查,更好地处理 ESP32 重启状态。
- SPI 测速 HTML 的计时单位由毫秒改为微秒显示。
- 新建 `spi_ring_cache.h`:共享 SPI ring push 缓存工具。
- 新增 `http_util.h`:共享 HTTP 处理器工具,含 JSON 拼接与查询参数解析。
- 引入 `json_parse.h`:轻量级 JSON 解析器,针对扁平 JSON 对象设计。
- 新增 Windows 环境下的项目构建批处理脚本。
- 重构 CAN 反馈处理:从推送式改为请求驱动架构,降低内存占用、提升效率。
This commit is contained in:
小龙虾
2026-08-02 17:37:19 +08:00
parent 04a118219f
commit f9cbcc396b
42 changed files with 1957 additions and 4537 deletions
@@ -0,0 +1,81 @@
## 方案:/can/feedback 改为请求驱动架构
### 目标
- ESP32 不再被动缓存 GD32 的 0xC1 推送,改为前端请求时主动向 GD32 查询
- GD32 停止主动推送 0xC1(降低 SPI 资源占用)
- 删除 `s_latest[3]`/`s_work[3]` 常驻 BSS(省 ~7.8KB)+ handler 栈上 `results[3]`(省 ~3.9KB 栈)
### 依据
- GD32 `handleFeedback` 已支持 0xC1 请求查询(`[channel, page]` → ACK + 页数据),无需新增协议
- ESP32 `transact(0xC1, ...)` 是现成请求/响应机制,`canFdSend`/`canFdSetRxMode` 已在用
- `canFdParseFeedbackPage` 可直接复用(只解析 payload 字节,不关心来源)
- 0xC4/0xC5/0xC6/0xD2 推送独立,不受影响
### 改动清单
#### 1. ESP32 侧 — `main/transport/canfd_bridge.cpp` / `.h`
新增 `canFdQueryFeedback(channel, page, can_feedback_result_t *out, uint8_t *entryIdx)`:
```cpp
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
```
- 发 `transact(0xC1, {channel, page}, ...)`,超时 200ms
- 成功(resp_type==ACK)→ 调 `canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx)`
- 返回 0=成功,-1=SPI/参数失败,-2=GD32 拒绝(ERROR)
#### 2. ESP32 侧 — `main/server/http_handlers.cpp` `can_feedback_handler`
- 删除 `results[CAN_CHANNEL_MAX]` 栈数组 + `getLatestCanFeedback` 循环
- 改为:对每个使能通道,调 `canFdQueryFeedback(ch, 0, &result, &entryIdx)` + `canFdQueryFeedback(ch, 1, &result, &entryIdx)`,在栈上用单个 `can_feedback_result_t result`(1.3KB,用完即释放)
- lite 路径:只需 page 0(统计字段在 page0 header),1 次 SPI 查询即可
- full 路径:page 0 + page 1,2 次查询,然后序列化
- 查询失败 → 该通道 `valid=false`(与原来无快照时行为一致)
- `pauseCanFeedbackTask` 调用保留(send_batch 仍需独占 SPI)
#### 3. ESP32 侧 — `can_mode_handler` GET
- 原来 `getLatestCanFeedback(0, &fb)` 读缓存 mode → 改为 `canFdQueryFeedback(0, 0, &fb, &entryIdx)` 取 page0 的 mode 字段
- 原来的 fallback `canFdSetRxMode` 保留(查询也失败时兜底)
#### 4. ESP32 侧 — `main/monitor/can_feedback_task.cpp`
- **删除** `s_latest[3]`、`s_valid[3]`、`s_work[3]`、`s_workIdx[3]`、`s_lastLoggedError[3]`、`publish()`、`getLatestCanFeedback()`
- **删除** taskLoop 里的 0xC1 分支(`canFdParseFeedbackPage` + `publish`)
- **保留** 0xC4/0xC5/0xC6/0xD2 事件分发(这些仍走推送)
- **保留** `pauseCanFeedbackTask`(send_batch / OTA 仍需暂停事件消费)
- `s_mutex` 删除(仅保护 s_latest/s_valid,不再需要)
#### 5. ESP32 侧 — `main/monitor/can_feedback_task.h`
- 删除 `getLatestCanFeedback` 声明
- 保留 `startCanFeedbackTask`、`pauseCanFeedbackTask`
#### 6. GD32 侧 — `app_spi_cmd_can_feedback.cpp`
- `canFeedbackStartRound()`:改为空函数(或加 `s_pushEnabled=false` 永久禁用推送)
- `canFeedbackPushStep()`:开头加 `if (!s_pushEnabled) return;` 或直接 return
- **保留** `handleFeedback`(请求查询处理程序不动)
- **保留** `buildFeedbackPayload`(查询和推送共用)
#### 7. GD32 侧 — `task_runner.cpp`
- `canFeedbackStartRound()` 调用可保留(变成空函数无副作用)或删除
### 内存收益
| 项 | 改前 | 改后 | 省 |
|---|---|---|---|
| `s_latest[3]` BSS | 3.9KB | 0 | 3.9KB |
| `s_work[3]` BSS | 3.9KB | 0 | 3.9KB |
| handler 栈 `results[3]` | 3.9KB | 1.3KB(单通道) | 2.6KB 栈 |
| **合计常驻 BSS** | | | **~7.8KB** |
### 性能影响
- 原来读缓存:<1ms
- 改后查询:lite 路径 1 次 SPI 往返 ~2-4ms;full 路径 6 次往返(3ch×2page)~12-24ms
- 前端 1Hz 轮询,24ms 完全可接受(httpd 单线程最坏阻塞 <50ms)
- GD32 不再 1Hz 推送 6 帧,省下 SPI 带宽和 PB6 中断
### 风险与缓解
- **SPI 查询失败**:GD32 不响应时 `transact` 超时返回,handler 返回 valid=false,前端显示"--"(与原无快照行为一致)
- **OTA 期间查询**:send_batch/OTA 已 `pauseCanFeedbackTask`,且 SPI worker 串行化,查询不会与 OTA 数据流冲突
- **can_monitor.html full 路径延迟**:6 次 SPI 查询 ~24ms + JSON 序列化,总 <50ms,远小于 1s 轮询间隔
### 不改动的部分
- 0xC4 ADC、0xC5 motor state、0xC6 motor version、0xD2 motor OTA 推送机制不变
- `canFdParseFeedbackPage` 解析逻辑不变
- GD32 `handleFeedback` 查询处理程序不变
- 前端 JS 不变(API 响应格式不变)
-8
View File
@@ -1,8 +0,0 @@
idf_component_register(
SRCS "cJSON.c"
INCLUDE_DIRS "."
REQUIRES
)
# ESP-IDF uses no DLL, export all symbols
target_compile_definitions(${COMPONENT_LIB} PUBLIC CJSON_HIDE_SYMBOLS=0)
File diff suppressed because it is too large Load Diff
-306
View File
@@ -1,306 +0,0 @@
/*
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
#ifndef cJSON__h
#define cJSON__h
#ifdef __cplusplus
extern "C"
{
#endif
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
#define __WINDOWS__
#endif
#ifdef __WINDOWS__
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
For *nix builds that support visibility attribute, you can define similar behavior by
setting default visibility to hidden by adding
-fvisibility=hidden (for gcc)
or
-xldscope=hidden (for sun cc)
to CFLAGS
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
*/
#define CJSON_CDECL __cdecl
#define CJSON_STDCALL __stdcall
/* export symbols by default, this is necessary for copy pasting the C and header file */
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_EXPORT_SYMBOLS
#endif
#if defined(CJSON_HIDE_SYMBOLS)
#define CJSON_PUBLIC(type) type CJSON_STDCALL
#elif defined(CJSON_EXPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
#elif defined(CJSON_IMPORT_SYMBOLS)
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
#endif
#else /* !__WINDOWS__ */
#define CJSON_CDECL
#define CJSON_STDCALL
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
#else
#define CJSON_PUBLIC(type) type
#endif
#endif
/* project version */
#define CJSON_VERSION_MAJOR 1
#define CJSON_VERSION_MINOR 7
#define CJSON_VERSION_PATCH 19
#include <stddef.h>
/* cJSON Types: */
#define cJSON_Invalid (0)
#define cJSON_False (1 << 0)
#define cJSON_True (1 << 1)
#define cJSON_NULL (1 << 2)
#define cJSON_Number (1 << 3)
#define cJSON_String (1 << 4)
#define cJSON_Array (1 << 5)
#define cJSON_Object (1 << 6)
#define cJSON_Raw (1 << 7) /* raw json */
#define cJSON_IsReference 256
#define cJSON_StringIsConst 512
/* The cJSON structure: */
typedef struct cJSON
{
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
struct cJSON *next;
struct cJSON *prev;
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
struct cJSON *child;
/* The type of the item, as above. */
int type;
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
char *valuestring;
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
int valueint;
/* The item's number, if type==cJSON_Number */
double valuedouble;
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
char *string;
} cJSON;
typedef struct cJSON_Hooks
{
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
void *(CJSON_CDECL *malloc_fn)(size_t sz);
void (CJSON_CDECL *free_fn)(void *ptr);
} cJSON_Hooks;
typedef int cJSON_bool;
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_NESTING_LIMIT
#define CJSON_NESTING_LIMIT 1000
#endif
/* Limits the length of circular references can be before cJSON rejects to parse them.
* This is to prevent stack overflows. */
#ifndef CJSON_CIRCULAR_LIMIT
#define CJSON_CIRCULAR_LIMIT 10000
#endif
/* returns the version of cJSON as a string */
CJSON_PUBLIC(const char*) cJSON_Version(void);
/* Supply malloc, realloc and free functions to cJSON */
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
/* Render a cJSON entity to text for transfer/storage. */
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
/* Render a cJSON entity to text for transfer/storage without any formatting. */
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
/* Delete a cJSON entity and all subentities. */
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
/* Returns the number of items in an array (or object). */
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
/* Get item "string" from object. Case insensitive. */
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
/* Check item type and return its value */
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
/* These functions check the type of an item */
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
/* These calls create a cJSON item of the appropriate type. */
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
/* raw json */
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
/* Create a string where valuestring references a string so
* it will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
/* Create an object/array that only references it's elements so
* they will not be freed by cJSON_Delete */
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
/* These utilities create an Array of count items.
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
/* Append item to the specified array/object. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
* writing to `item->string` */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
/* Remove/Detach items from Arrays/Objects. */
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
/* Update array items. */
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
/* Duplicate a cJSON item */
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
* The item->next and ->prev pointers are always zero on return from Duplicate. */
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
* The input pointer json cannot point to a read-only address area, such as a string constant,
* but should point to a readable and writable address area. */
CJSON_PUBLIC(void) cJSON_Minify(char *json);
/* Helper functions for creating and adding items to an object at the same time.
* They return the added item or NULL on failure. */
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
/* helper for the cJSON_SetNumberValue macro */
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
#define cJSON_SetBoolValue(object, boolValue) ( \
(object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
(object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
cJSON_Invalid\
)
/* Macro for iterating over an array or object */
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
CJSON_PUBLIC(void) cJSON_free(void *object);
#ifdef __cplusplus
}
#endif
#endif
-1
View File
@@ -35,7 +35,6 @@ idf_component_register(
REQUIRES
transport # M5: 传输层组件(SPI/CANFD/协议)
app_update # ESP32 OTA API
cjson # JSON 解析/生成
esp_driver_tsens # 温度传感器驱动
esp_driver_uart # UART 驱动(调试日志)
esp_wifi # WiFi STA/AP 模式
+3 -1
View File
@@ -132,7 +132,9 @@ extern "C" void app_main(void)
SystemBoot::mark(BOOT_OTA_VALID);
}
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s, heap=%lu, wifi_early=%s",
/* ★ banner 文案必须包含「启动」,前端 polling.js 靠
* /OTA Relay .*启动/ 匹配本行判定 ESP32 重启完成(阶段 6)。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay %s 启动, heap=%lu, wifi_early=%s",
FW_VERSION,
(unsigned long)esp_get_free_heap_size(),
wifi_early_started_sta ? "STA" : "AP/other");
+2 -2
View File
@@ -14,8 +14,8 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 74
#define FW_VERSION_STRING "0.3.74" // can batch: 帧间隔 5→1ms;前端 BATCH_CHUNK=64 + fetch 超时加固 + finally 兜底 renderHistory
#define FW_VERSION_PATCH 76
#define FW_VERSION_STRING "0.3.76" // 重启恢复提速2:httpd就绪后再打 MAIN「启动」banner(防环缩容裁掉);前端用 uptime<120s 判重启完成,不再干等10s
// Git 信息(手动填写)
#define GIT_HASH "770ae14"
+19 -96
View File
@@ -1,14 +1,11 @@
/*
* can_feedback_task.cpp - 被动监听 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.cpp - 被动监听 GD32 主动推送的事件
*
* 架构(GD32 主动推送,ESP32 被动接收):
* GD32 APP 在主循环中调用 canFeedbackPushStep(),循环遍历 3 通道 × 2 页,
* 通过 requestEsp32Read(0xC1) 下拉 DATA_READY 主动按铃,将每页反馈 payload
* 推送给 ESP32。ESP32 本任务 SpiMaster::takeEvent() 阻塞等待按铃/事件,
* 读到一帧后用 canFdParseFeedbackPage() 解析并累积到 s_work[channel],再
* publish() 到 s_latest[channel] 供 Web 读取。
*
* 替代了原先 ESP32 1Hz 主动发 0xC1 轮询的逻辑,避免占用主线 SPI 带宽。
* 架构(2026-08 改造后):
* 0xC1 CAN 反馈已改为请求驱动(前端请求时 ESP32 主动向 GD32 查询),
* 不再被动缓存。本任务仅保留 0xC4/0xC5/0xC6/0xD2 事件分发:
* 0xC4 ADC 调试反馈、0xC5 多电机状态、0xC6 多电机版本、0xD2 电机 OTA 进度
* 这些仍由 GD32 主动推送,ESP32 被动接收。
*
* OTA 期间通过 pauseCanFeedbackTask(true) 暂停本任务,避免与 OTA 抢占 SPI。
*/
@@ -17,7 +14,6 @@
#include "freertos/semphr.h"
#include "app_config.h"
#include "transport/canfd_bridge.h"
#include "transport/spi_master.h"
#include "monitor/debug_log.h"
#include "monitor/can_feedback_task.h"
@@ -25,7 +21,6 @@
#include "monitor/motor_state.h"
#include "monitor/motor_version.h"
#include "monitor/motor_ota_relay.h"
#include "app_config.h"
#include "esp_system.h"
#include <string.h>
@@ -34,7 +29,6 @@ namespace {
constexpr uint32_t kWaitRingMs = 500U; ///< 单次等待 GD32 按铃超时
constexpr uint32_t kPauseDelayMs = 100U; ///< 暂停时的轮询间隔
constexpr uint8_t kSpiCanFeedbackType = 0xC1U;
constexpr uint8_t kSpiAdcFeedbackType = 0xC4U; ///< GD32 ADC 调试反馈
constexpr uint8_t kSpiMotorStateType = 0xC5U; ///< GD32 多电机状态推送
constexpr uint8_t kSpiMotorVersionType = 0xC6U; ///< GD32 多电机版本推送(事件驱动)
@@ -42,44 +36,22 @@ constexpr uint8_t kSpiMotorOtaStatusType = 0xD2U; ///< GD32 电机 OTA relay
constexpr size_t kRingPayloadMax = SPI_OTA_FRAME_MAX; ///< 与变长读一致,避免截断
constexpr const char *kTag = "CAN_FB";
static can_feedback_result_t s_latest[CAN_CHANNEL_MAX];
static bool s_valid [CAN_CHANNEL_MAX];
static SemaphoreHandle_t s_mutex = nullptr;
static volatile bool s_paused = false;
/* 累积工作缓冲:跨 page 0/1 累积每通道的完整反馈 */
static can_feedback_result_t s_work [CAN_CHANNEL_MAX];
static uint8_t s_workIdx[CAN_CHANNEL_MAX];
static uint32_t s_lastLoggedError[CAN_CHANNEL_MAX];
/**
* @brief 将某通道完整反馈发布到 s_latest(线程安全)。
* @param ch 通道 0..2
* @param fb 解析后的快照
*/
static void publish(uint8_t ch, const can_feedback_result_t &fb)
{
if (s_mutex == nullptr) return;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_latest[ch] = fb;
s_valid[ch] = true;
xSemaphoreGive(s_mutex);
}
}
/**
* @brief can_fb 任务主循环:takeEvent 分发 0xC1 CAN / 0xC4 ADC。
* @brief can_fb 任务主循环:takeEvent 分发 0xC4/0xC5/0xC6/0xD2 推送事件。
* 0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),不再在此处理。
*/
static void taskLoop(void *)
{
DEBUG_INFO(kTag, "CAN 反馈被动监听任务启动 (GD32 主动推送, 0xC1)");
DEBUG_INFO(kTag, "事件监听任务启动 (0xC4 ADC / 0xC5 motor / 0xC6 ver / 0xD2 ota)");
/* 冷启动延后开始监听,错开上电初始化窗口 */
{
esp_reset_reason_t rr = esp_reset_reason();
if (rr == ESP_RST_POWERON || rr == ESP_RST_BROWNOUT) {
constexpr uint32_t kCanFbDeferMs = 1500U;
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始 CAN 反馈监听",
DEBUG_INFO(kTag, "冷启动: %lu ms 后开始事件监听",
(unsigned long)kCanFbDeferMs);
vTaskDelay(pdMS_TO_TICKS(kCanFbDeferMs));
}
@@ -126,39 +98,14 @@ static void taskLoop(void *)
continue;
}
if (ring_type != kSpiCanFeedbackType || ring_len < 28U) {
continue;
}
const uint8_t ch = ring_payload[0];
if (ch >= CAN_CHANNEL_MAX) {
continue;
}
/* 解析本页并累积到工作缓冲,page=0 会自动重置 */
if (canFdParseFeedbackPage(ring_payload, ring_len,
&s_work[ch], &s_workIdx[ch]) == 0) {
publish(ch, s_work[ch]);
/* 累计错误发生变化时才告警一次;P0/P1 携带相同统计,不能重复打印。 */
if (s_work[ch].stats.error_count != 0U
&& s_work[ch].stats.error_count != s_lastLoggedError[ch]) {
DEBUG_WARN(kTag,
"CH%u err=%lu TEC=%u REC=%u",
static_cast<unsigned>(ch),
static_cast<unsigned long>(s_work[ch].stats.error_count),
static_cast<unsigned>(s_work[ch].stats.tec),
static_cast<unsigned>(s_work[ch].stats.rec));
s_lastLoggedError[ch] = s_work[ch].stats.error_count;
}
}
/* 0xC1 已改为请求驱动,不再在此处理;未知类型静默跳过 */
}
}
} // namespace
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask()
@@ -166,19 +113,6 @@ bool startCanFeedbackTask()
static bool s_started = false;
if (s_started) return true;
if (s_mutex == nullptr) {
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == nullptr) {
DEBUG_ERR(kTag, "互斥量创建失败");
return false;
}
}
memset(s_latest, 0, sizeof(s_latest));
memset(s_lastLoggedError, 0, sizeof(s_lastLoggedError));
memset(s_valid, 0, sizeof(s_valid));
memset(s_work, 0, sizeof(s_work));
memset(s_workIdx, 0, sizeof(s_workIdx));
BaseType_t xRet = xTaskCreatePinnedToCore(
taskLoop,
"can_fb",
@@ -196,7 +130,7 @@ bool startCanFeedbackTask()
}
/**
* @brief 暂停/恢复 CAN(及同任务分发的 ADC)事件监听。
* @brief 暂停/恢复事件监听。
* @param pause true=OTA 期间暂停;false=恢复
*/
void pauseCanFeedbackTask(bool pause)
@@ -204,28 +138,17 @@ void pauseCanFeedbackTask(bool pause)
if (s_paused == pause) return; // 幂等:状态未变时不重复日志
s_paused = pause;
if (pause) {
DEBUG_DBG(kTag, "CAN 监听已暂停(OTA 进行中)");
DEBUG_DBG(kTag, "事件监听已暂停(OTA 进行中)");
} else {
DEBUG_DBG(kTag, "CAN 监听已恢复");
DEBUG_DBG(kTag, "事件监听已恢复");
}
}
/**
* @brief 拷贝某通道的最新快照(线程安全)。
* @return true 表示拿到一份有效数据。
* @brief 查询是否处于暂停(OTA / send_batch 期间为 true)。
* 暂停期间 HTTP handler 应跳过 0xC1 查询,避免与 OTA 抢占 SPI。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out)
bool canFeedbackPaused()
{
if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) {
return false;
}
bool ok = false;
if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
if (s_valid[channel]) {
*out = s_latest[channel];
ok = true;
}
xSemaphoreGive(s_mutex);
}
return ok;
return s_paused;
}
+9 -15
View File
@@ -1,35 +1,29 @@
/*
* can_feedback_task.h - 被动接收 GD32 主动推送的 CAN 反馈快照
* can_feedback_task.h - 事件监听任务(0xC4/0xC5/0xC6/0xD2 推送)
*
* ESP32 侧不主动 1Hz 轮询 0xC1;由 GD32 按铃推送,本任务 takeEvent 接收。
* 2026-08 架构改造:0xC1 CAN 反馈已改为请求驱动(canFdQueryFeedback),
* 不再被动缓存。本任务仅分发 GD32 主动推送的 ADC/电机状态/版本/OTA 进度事件。
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
#include <stdint.h>
#include "transport/canfd_bridge.h"
/** @brief 暂停/恢复 CAN 反馈监听(OTA 期间调用 pause=true)。 */
/** @brief 暂停/恢复事件监听(OTA 期间调用 pause=true)。 */
void pauseCanFeedbackTask(bool pause);
/** @brief 查询监听是否处于暂停(OTA / send_batch 期间为 true)。
* 请求驱动的 SPI 查询(canFdQueryFeedback)在暂停期间应跳过。 */
bool canFeedbackPaused();
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief 启动 CAN 反馈被动监听任务(幂等)。
* @brief 启动事件监听任务(幂等)。
* @return true 表示任务已存在或创建成功。
*/
bool startCanFeedbackTask();
/**
* @brief 拷贝某通道的最新反馈快照(线程安全)。
* @param channel 0/1/2。
* @param out 输出缓冲。
* @return true 表示拿到一份有效数据,false 表示通道无效或尚未拿到第一份。
*/
bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out);
#ifdef __cplusplus
}
#endif
+6 -2
View File
@@ -11,8 +11,12 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
/** 单次快照最多跟踪的任务数 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 32;
/** 单次快照最多跟踪的任务数。
* 系统常驻任务 ~14(app)+ ~5(IDF: tiT/wifi/nimble/hci/ble)= ~19,
* 按需短暂任务(ble_finish/restart/cancel/gd32_wait/ota_feed)不常驻。
* 20 覆盖常驻集 + 余量 1;偶发按需任务多出时 uxTaskGetSystemState 截断,
* 仅影响该拍 CPU 统计显示,不影响功能。原 32 浪费 ~1KB BSS。 */
static constexpr size_t CPU_TASK_SNAPSHOT_MAX = 20;
/** 采样周期(ms)— 窗口 sample_ms 应对齐此值 */
static constexpr uint32_t CPU_STATS_SAMPLE_MS = 1000;
+10 -2
View File
@@ -509,7 +509,12 @@ esp_err_t DebugLog::uartSend(const uint8_t *data, size_t len)
return ESP_ERR_INVALID_STATE;
}
const int ret = uart_write_bytes(UART_NUM_0, data, len);
/* 非阻塞直写 FIFO:满了丢弃(日志必进 ring,web /logs 仍完整)。
* 旧实现 uart_write_bytes 在 TX buffer=0 时同步阻塞到整行发完
* (115200 下 ~5-8ms/条),OTA 期间日志密集会把 ota_relay(prio 23)
* / spi_worker(prio 6) 阻塞数 ms~数十 ms——50ms ACK 窗口被拖爆,
* 表现为改短重试后超时重发频繁(线上 12/777)。 */
const int ret = uart_tx_chars(UART_NUM_0, (const char *)data, (int)len);
if (ret < 0) {
return ESP_FAIL;
}
@@ -646,7 +651,10 @@ esp_err_t DebugLog::send(const uint8_t *data, size_t len)
if (sock >= 0) {
const size_t send_len = len > 1024 ? 1024 : len;
const int ret = ::sendto(sock, data, send_len, 0,
/* MSG_DONTWAIT:非阻塞发送。lwIP 在 ARP 未解析/路由未就绪时
* sendto 会排队等待(可达 SO_SNDTIMEO 200ms),会阻塞打日志的
* 业务任务(OTA relay/worker)——日志转发必须尽力而为。 */
const int ret = ::sendto(sock, data, send_len, MSG_DONTWAIT,
(struct sockaddr *)&m_debugAddr, sizeof(m_debugAddr));
if (ret >= 0) {
udp_ret = ESP_OK;
+5 -41
View File
@@ -2,9 +2,7 @@
* gd32_adc_feedback.cpp - GD32 ADC 调试反馈解析与缓存
*/
#include "monitor/gd32_adc_feedback.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -17,29 +15,6 @@ static SemaphoreHandle_t s_mux = nullptr;
static gd32_adc_feedback_t s_latest = {};
static bool s_have = false;
/** @brief 读小端 u16。 */
static uint16_t rdU16Le(const uint8_t *p)
{
return static_cast<uint16_t>(p[0] | (static_cast<uint16_t>(p[1]) << 8));
}
/** @brief 读小端 u32。 */
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
/** @brief 懒创建快照互斥量。 */
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
/**
@@ -80,7 +55,7 @@ bool onGd32AdcFeedbackEvent(const uint8_t *payload, size_t len)
fb.uptime_sec = uptime;
fb.recv_ms = esp_timer_get_time() / 1000;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
@@ -103,7 +78,7 @@ bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
if (out == nullptr) {
return false;
}
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return false;
}
@@ -124,7 +99,7 @@ bool getLatestGd32AdcFeedback(gd32_adc_feedback_t *out)
*/
uint32_t gd32AdcFeedbackAgeMs(void)
{
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) {
return UINT32_MAX;
}
@@ -135,16 +110,5 @@ uint32_t gd32AdcFeedbackAgeMs(void)
recv = s_latest.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) {
return UINT32_MAX;
}
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) {
return 0U;
}
const int64_t age = now - recv;
if (age > static_cast<int64_t>(UINT32_MAX)) {
return UINT32_MAX;
}
return static_cast<uint32_t>(age);
return ringCacheAgeMs(have, recv);
}
+27 -31
View File
@@ -29,6 +29,7 @@
#include "spi_master.h"
#include "esp_event.h"
#include "esp_netif_types.h"
#include "server/http_util.h"
// ============================================================
// heap hook:捕获"破新低时的最后一次 alloc"(最后一击,未必是主因)
@@ -399,8 +400,13 @@ void HealthMonitor::taskEntry(void *pvParameters)
* 必须在 maybeRecord 之前,否则 est 长期偏低会误触发 hook 快照。 */
hookResyncEstFree(free_heap);
// 任务栈指标:本周期只采集一次,堆低水位快照和栈水位巡检共用。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 堆低水位快照:仅在"最低空闲"创新低时记录一条上下文(5s 检测足够)
self.maybeRecordHeapLowWater();
// 传入已采集的 metrics,避免 maybeRecordHeapLowWater 内部二次遍历任务表。
self.maybeRecordHeapLowWater(metrics, count);
if (free_heap < HEAP_WARNING_MIN) {
/* 堆告警节流:OTA 期间阈值更低、间隔更长 */
@@ -426,10 +432,8 @@ void HealthMonitor::taskEntry(void *pvParameters)
DEBUG_ERR("MON", "健康状态危险: %d", cur);
}
// 栈水位主动巡检:复用 /system/status 的任务表,低水位时 DEBUG_ERR 主动告警。
// 栈水位主动巡检:复用已采集的任务表,低水位时 DEBUG_ERR 主动告警。
// health_mon 任务不被 OTA 暂停,告警经 UDP/UART 直发,穿透 OTA 门控。
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
for (size_t i = 0; i < count && i < STACK_TASK_MAX; ++i) {
if (metrics[i].handle == NULL || metrics[i].stack_size_bytes == 0) {
continue;
@@ -604,23 +608,6 @@ health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi)
// ============================================================
// 堆低水位快照("最低空闲"创新低时记录上下文)
// ============================================================
namespace {
/** @brief snprintf 追加到 buf 的安全封装:截断但不越界,返回新 offset。 */
int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) {
// 被截断:vsnprintf 返回的是"本应写入"的长度,按截断后修正 offset
return (int)cap - 1;
}
return offset + n;
}
} // namespace
/**
* @brief 检测"内部堆最低空闲"是否创新低,是则覆盖写入唯一快照槽。
@@ -630,7 +617,7 @@ int appendJsonfSafe(char *buf, size_t cap, int offset, const char *fmt, ...)
* 所有外部读取(OtaCoordinator::getSnapshot / collectTaskStackMetrics / rssi / temp)
* 都先在锁外取,避免持锁期间嵌套调用其它模块的锁;持锁后只做字段拷贝。
*/
void HealthMonitor::maybeRecordHeapLowWater()
void HealthMonitor::maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics, size_t pre_count)
{
// 与 http_handlers.cpp 同口径:内部 DRAM 数据堆
static constexpr uint32_t CAPS = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT;
@@ -691,8 +678,17 @@ void HealthMonitor::maybeRecordHeapLowWater()
HeapLowTaskStack stacks[8];
uint8_t stack_count = 0;
{
TaskStackMetric metrics[STACK_TASK_MAX];
size_t count = collectTaskStackMetrics(metrics, STACK_TASK_MAX);
// 优先复用巡检循环已采集的指标,避免二次遍历
TaskStackMetric local_metrics[STACK_TASK_MAX];
const TaskStackMetric *metrics;
size_t count;
if (pre_metrics && pre_count > 0) {
metrics = pre_metrics;
count = pre_count;
} else {
count = collectTaskStackMetrics(local_metrics, STACK_TASK_MAX);
metrics = local_metrics;
}
// 先全部采集
struct Item { char name[16]; uint32_t rem; };
Item items[STACK_TASK_MAX];
@@ -816,7 +812,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
LockGuard lock(m_mutex);
if (!m_heapLowWaterValid) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
const HeapLowWaterRecord &r = m_heapLowWater;
@@ -824,7 +820,7 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
* hook_confidence 告知前端置信度:hook 命中=est_low_watermark,未命中=none。
* 前端应据此区分 estimated 与 measured(free_heap/min_free_heap 为实测)。 */
const char *hook_confidence = r.hook_fired ? "est_low_watermark" : "none";
int pos = appendJsonfSafe(buf, cap, offset,
int pos = http_json_append(buf, cap, offset,
"{\"count\":1,\"records\":[{"
"\"ts_ms\":%llu,\"free_heap\":%lu,\"min_free_heap\":%lu,"
"\"largest_free_block\":%lu,\"sys_state\":%u,\"ota_channel\":%u,"
@@ -863,24 +859,24 @@ int HealthMonitor::dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const
(unsigned long)r.hook_fired,
(unsigned long)r.ip_event_count);
if (pos == offset) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
for (uint8_t k = 0; k < r.task_stack_count && k < 8; ++k) {
int after = appendJsonfSafe(buf, cap, pos,
int after = http_json_append(buf, cap, pos,
"%s{\"name\":\"%s\",\"remaining\":%lu}",
k == 0 ? "" : ",",
r.task_stacks[k].name,
(unsigned long)r.task_stacks[k].remaining);
if (after == pos) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
pos = after;
}
int closed = appendJsonfSafe(buf, cap, pos, "]}]}");
int closed = http_json_append(buf, cap, pos, "]}]}");
if (closed == pos) {
return appendJsonfSafe(buf, cap, offset, "{\"count\":0,\"records\":[]}");
return http_json_append(buf, cap, offset, "{\"count\":0,\"records\":[]}");
}
return closed;
}
+6 -1
View File
@@ -12,6 +12,7 @@
#include "driver/temperature_sensor.h"
#include "esp_timer.h"
#include "app_config.h"
#include "task_stack.h" // TaskStackMetric(maybeRecordHeapLowWater 可选参数)
// T9.3: 健康状态等级
typedef enum {
@@ -133,7 +134,11 @@ public:
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。 */
void maybeRecordHeapLowWater();
/** @brief 检测并可能覆盖写入唯一快照槽(线程安全)。
* 由 health_mon 5s 巡检调用,仅当 internal_min_free 出现新低时写入。
* 设备只保留最新 1 条;多条历史由网页 IndexedDB 累积。
* @param pre_metrics 巡检循环已采集的任务栈指标(避免二次遍历);null 时内部自采 */
void maybeRecordHeapLowWater(const TaskStackMetric *pre_metrics = nullptr, size_t pre_count = 0);
/** @brief 将唯一快照序列化为 JSON:{"count":0|1,"records":[...]}。线程安全。 */
int dumpHeapLowWaterJson(char *buf, size_t cap, int offset) const;
+72 -74
View File
@@ -35,6 +35,59 @@ static int64_t nowMs() {
return esp_timer_get_time() / 1000;
}
/* ── 共享辅助:终态判断 + QUERY SPI 事务 ── */
/** @brief GD32 侧已进终态(IDLE/FINISHED/ERROR),喂数据应停止。 */
static inline bool isTerminalState(motor_ota_state_t s)
{
return s == MOTOR_OTA_IDLE || s == MOTOR_OTA_FINISHED || s == MOTOR_OTA_ERROR;
}
/**
* @brief 发 SPI 0xD0 QUERY 查询 GD32 真实状态。
* @param out_state 非 null 时写入 GD32 返回的 state
* @param out_err 非 null 时写入 error_code
* @param update_status true 时把结果同步写回 s_status(持锁)
* @return true=QUERY 成功且 ACK 有有效数据;false=SPI 失败或无 ACK
*
* 消除 feedTask 中 4 处重复的 qcmd[1]={2} + transact + parse qp[0..5] 块。
*/
static bool queryMotorOtaStatus(motor_ota_state_t *out_state = nullptr,
uint8_t *out_err = nullptr,
bool update_status = false)
{
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) != ESP_OK
|| qt != kRspAck || ql < 1U) {
return false;
}
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (out_state) *out_state = qst;
if (out_err) *out_err = (ql >= 2U) ? qp[1] : 0U;
if (update_status && s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = (ql >= 2U) ? qp[1] : 0U;
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
if (ql >= 10U) {
s_status.fw_size = static_cast<uint32_t>(qp[6])
| (static_cast<uint32_t>(qp[7]) << 8)
| (static_cast<uint32_t>(qp[8]) << 16)
| (static_cast<uint32_t>(qp[9]) << 24);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
return true;
}
/* ═══════════════════ 喂数据 task ═══════════════════ */
static void feedTask(void *arg)
@@ -87,8 +140,7 @@ static void feedTask(void *arg)
* (0xD2 ring 延迟到达) 覆写 s_status 导致 feedTask 误判退出发 CANCEL。 */
const motor_ota_status_t st = motorOtaGetStatus();
if (nowMs() >= grace_end_ms) {
if (st.state == MOTOR_OTA_IDLE || st.state == MOTOR_OTA_FINISHED ||
st.state == MOTOR_OTA_ERROR) {
if (isTerminalState(st.state)) {
ESP_LOGW(TAG, "GD32 终态 0x%02X,停止喂数据 offset=%u/%u",
(int)st.state, (unsigned)s_fw_offset, (unsigned)s_fw_size);
break;
@@ -129,34 +181,15 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
* 避免 GD32 已进 ERROR 终态但 feedTask 不知道、盲重试空转到 120s 超时。 */
if (++consec_fail >= kQueryAfterFails) {
consec_fail = 0U;
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 1U) {
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
qst == MOTOR_OTA_ERROR) {
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true) && isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY 检测到 GD32 终态 0x%02X err=0x%02X,停止喂数据 offset=%u/%u",
(int)qst, (unsigned)qp[1],
(int)qst, (unsigned)qerr,
(unsigned)s_fw_offset, (unsigned)s_fw_size);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qp[1];
if (ql >= 6U) {
s_status.packets_acked = static_cast<uint16_t>(qp[2])
| (static_cast<uint16_t>(qp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qp[4])
| (static_cast<uint16_t>(qp[5]) << 8);
}
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
break;
}
}
}
continue; /* 不推进 offset,同 offset 重发 */
}
consec_fail = 0U;
@@ -177,13 +210,8 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
/* 握手未确认时定期 QUERY:确认 GD32 已过握手阶段才标 TRANSFER */
if (!handshake_confirmed && (ack_n & 7U) == 0U) {
uint8_t qcmd[1] = {2U};
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 1U) {
motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
motor_ota_state_t qst;
if (queryMotorOtaStatus(&qst, nullptr, false)) {
if (qst == MOTOR_OTA_ERASE || qst == MOTOR_OTA_TRANSFER ||
qst == MOTOR_OTA_FINISH_OTA || qst == MOTOR_OTA_WAIT_APP_RUN) {
handshake_confirmed = true;
@@ -218,14 +246,9 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
* 当前 offset 被 gap 拒收(永远不会变 ACK)。QUERY 确认真实状态。 */
if (++consec_wait >= kQueryAfterWaits) {
consec_wait = 0U;
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qt = 0, qp[16] = {0};
size_t ql = 0;
if (SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1, &qt, qp, sizeof(qp), &ql, 300U) == ESP_OK
&& qt == kRspAck && ql >= 2U) {
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qp[0]);
const uint8_t qerr = qp[1];
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, false)) {
if (qst != MOTOR_OTA_TRANSFER) {
/* GD32 不在传输态(auto-retry 回了 kPreCancel/握手):
* 重置 offset 从头喂,配合 GD32 重新擦除+握手。 */
@@ -238,8 +261,7 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
handshake_confirmed = true; /* GD32 在 transfer,确认握手完成 */
}
/* 终态仍 break */
if (qst == MOTOR_OTA_IDLE || qst == MOTOR_OTA_FINISHED ||
qst == MOTOR_OTA_ERROR) {
if (isTerminalState(qst)) {
ESP_LOGW(TAG, "QUERY(WAIT) 终态 0x%02X err=0x%02X,停止",
(unsigned)qst, (unsigned)qerr);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
@@ -276,8 +298,7 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
/* 优先吃 ring 推送(已恢复 suppress) */
{
const motor_ota_status_t st = motorOtaGetStatus();
if (st.state == MOTOR_OTA_FINISHED || st.state == MOTOR_OTA_ERROR ||
st.state == MOTOR_OTA_IDLE) {
if (isTerminalState(st.state)) {
ESP_LOGI(TAG, "feedTask 退出,ring 终态: state=%u err=%u pkt=%u/%u",
(unsigned)st.state, (unsigned)st.error_code,
(unsigned)st.packets_acked, (unsigned)st.total_packets);
@@ -285,37 +306,14 @@ const uint32_t remaining = s_fw_size - s_fw_offset;
break;
}
}
uint8_t qcmd[1] = {2U}; /* cmd=QUERY */
uint8_t qresp_type = 0;
uint8_t qresp[16] = {0};
size_t qresp_len = 0;
esp_err_t qr = SpiMaster::instance().transact(
kSpiMotorOtaCmd, qcmd, 1,
&qresp_type, qresp, sizeof(qresp), &qresp_len, 300U);
if (qr == ESP_OK && qresp_type == kRspAck && qresp_len >= 10U) {
const motor_ota_state_t qst = static_cast<motor_ota_state_t>(qresp[0]);
if (s_mutex && xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
s_status.state = qst;
s_status.error_code = qresp[1];
s_status.packets_acked = static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8);
s_status.total_packets = static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8);
s_status.fw_size = static_cast<uint32_t>(qresp[6])
| (static_cast<uint32_t>(qresp[7]) << 8)
| (static_cast<uint32_t>(qresp[8]) << 16)
| (static_cast<uint32_t>(qresp[9]) << 24);
s_status.last_update_ms = nowMs();
xSemaphoreGive(s_mutex);
}
if (qst == MOTOR_OTA_FINISHED || qst == MOTOR_OTA_ERROR ||
qst == MOTOR_OTA_IDLE) {
motor_ota_state_t qst;
uint8_t qerr;
if (queryMotorOtaStatus(&qst, &qerr, true)) {
if (isTerminalState(qst)) {
const motor_ota_status_t snap = motorOtaGetStatus();
ESP_LOGI(TAG, "feedTask 退出,QUERY 终态: state=%u err=%u pkt=%u/%u",
(unsigned)qst, (unsigned)qresp[1],
(unsigned)(static_cast<uint16_t>(qresp[2])
| (static_cast<uint16_t>(qresp[3]) << 8)),
(unsigned)(static_cast<uint16_t>(qresp[4])
| (static_cast<uint16_t>(qresp[5]) << 8)));
(unsigned)qst, (unsigned)qerr,
(unsigned)snap.packets_acked, (unsigned)snap.total_packets);
got_terminal = true;
break;
}
+5 -28
View File
@@ -2,9 +2,7 @@
* motor_state.cpp - GD32 多电机状态推送解析与缓存 (SPI TYPE 0xC5)
*/
#include "monitor/motor_state.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -13,21 +11,6 @@ namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_state_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorStateEvent(const uint8_t *payload, size_t len)
@@ -51,7 +34,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
return false;
}
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
@@ -102,7 +85,7 @@ bool onMotorStateEvent(const uint8_t *payload, size_t len)
bool getLatestMotorState(motor_state_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
@@ -118,7 +101,7 @@ bool getLatestMotorState(motor_state_table_t *out)
uint32_t motorStateAgeMs(void)
{
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return UINT32_MAX;
int64_t recv = 0;
@@ -128,11 +111,5 @@ uint32_t motorStateAgeMs(void)
recv = s_table.recv_ms;
xSemaphoreGive(s_mux);
}
if (!have || recv <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv) return 0U;
const int64_t age = now - recv;
return (age > static_cast<int64_t>(UINT32_MAX)) ? UINT32_MAX
: static_cast<uint32_t>(age);
return ringCacheAgeMs(have, recv);
}
+3 -20
View File
@@ -4,9 +4,7 @@
* 镜像 motor_state.cpp 的互斥保护缓存模型。
*/
#include "monitor/motor_version.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "monitor/spi_ring_cache.h"
#include <string.h>
@@ -15,21 +13,6 @@ namespace {
static SemaphoreHandle_t s_mux = nullptr;
static motor_version_table_t s_table = {};
static uint32_t rdU32Le(const uint8_t *p)
{
return static_cast<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(p[3]) << 24);
}
static void ensureMux()
{
if (s_mux == nullptr) {
s_mux = xSemaphoreCreateMutex();
}
}
} // namespace
bool onMotorVersionEvent(const uint8_t *payload, size_t len)
@@ -40,7 +23,7 @@ bool onMotorVersionEvent(const uint8_t *payload, size_t len)
const uint8_t count = payload[0];
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
if (xSemaphoreTake(s_mux, pdMS_TO_TICKS(20)) != pdTRUE) return false;
@@ -69,7 +52,7 @@ bool onMotorVersionEvent(const uint8_t *payload, size_t len)
bool getLatestMotorVersion(motor_version_table_t *out)
{
if (out == nullptr) return false;
ensureMux();
ringCacheEnsureMux(&s_mux);
if (s_mux == nullptr) return false;
bool ok = false;
+62
View File
@@ -0,0 +1,62 @@
/*
* spi_ring_cache.h - SPI ring push 缓存共享辅助
*
* gd32_adc_feedback / motor_state / motor_version 三个模块共用相同的
* 互斥懒创建 + 小端读 + ageMs 计算样板,提取到此处消除三份拷贝。
*/
#ifndef SPI_RING_CACHE_H
#define SPI_RING_CACHE_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── 小端读 ── */
static inline uint16_t rdU16Le(const uint8_t *p)
{
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
}
static inline uint32_t rdU32Le(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
/* ── 互斥懒创建 ── */
static inline void ringCacheEnsureMux(SemaphoreHandle_t *mux)
{
if (*mux == nullptr) {
*mux = xSemaphoreCreateMutex();
}
}
/* ── 通用 ageMs ──
* 传入缓存的 have 标志 + recv_ms 时间戳,返回距 now 的毫秒数。
* 无数据或 recv_ms<=0 返回 UINT32_MAX。
*/
static inline uint32_t ringCacheAgeMs(bool have, int64_t recv_ms)
{
if (!have || recv_ms <= 0) return UINT32_MAX;
const int64_t now = esp_timer_get_time() / 1000;
if (now < recv_ms) return 0U;
const int64_t age = now - recv_ms;
return (age > (int64_t)UINT32_MAX) ? UINT32_MAX : (uint32_t)age;
}
#ifdef __cplusplus
}
#endif
#endif // SPI_RING_CACHE_H
+68 -33
View File
@@ -26,6 +26,7 @@
#include "esp_timer.h"
#include "spi_master.h"
#include "http_handlers.h" /* http_set_cors_headers */
#include "server/http_util.h"
#include "monitor/debug_log.h"
namespace {
@@ -37,30 +38,14 @@ constexpr size_t kPayloadDefault = 236U; /* 满帧:等效 0xD1 电机 OTA
constexpr size_t kPayloadMax = 236U; /* APP kTxBufSize=256 - V2_OVERHEAD */
constexpr uint32_t kFrameTimeoutMs = 500U;
/** @brief 从 query 里取无符号整型参数,缺失/非法时返回默认值。 */
uint32_t queryU32(httpd_req_t *req, const char *key, uint32_t defval)
{
char qbuf[64];
char vbuf[16];
if (httpd_req_get_url_query_str(req, qbuf, sizeof(qbuf)) != ESP_OK) {
return defval;
}
if (httpd_query_key_value(qbuf, key, vbuf, sizeof(vbuf)) != ESP_OK) {
return defval;
}
char *end = nullptr;
const unsigned long v = strtoul(vbuf, &end, 10);
return (end && *end == '\0') ? (uint32_t)v : defval;
}
} // namespace
esp_err_t spi_speedtest_handler(httpd_req_t *req)
{
uint32_t frames = queryU32(req, "frames", kFramesDefault);
uint32_t frames = http_query_u32(req, "frames", kFramesDefault);
if (frames < 1U) { frames = 1U; }
if (frames > kFramesMax) { frames = kFramesMax; }
size_t payload_len = (size_t)queryU32(req, "payload", (uint32_t)kPayloadDefault);
size_t payload_len = (size_t)http_query_u32(req, "payload", (uint32_t)kPayloadDefault);
if (payload_len > kPayloadMax) { payload_len = kPayloadMax; }
/* 探针 payload:递增填充(CRC32 覆盖,顺带校验数据路径完整性) */
@@ -74,9 +59,18 @@ esp_err_t spi_speedtest_handler(httpd_req_t *req)
uint64_t rtt_sum_us = 0U;
uint64_t rtt_min_us = UINT64_MAX;
uint64_t rtt_max_us = 0U;
/* RTT 分布直方图:<2 / 2-3 / 3-4 / 4-5 / >=5 ms,用于判断抖动形态
* (双峰=离散 +1ms 碰撞;均匀=tick 相位量化) */
uint32_t histo[5] = {0U, 0U, 0U, 0U, 0U};
/* RTT 分布直方图(µs 计时,<2ms 区间细化为 0.5ms 粒度):
* <0.5 / 0.5-1 / 1-1.5 / 1.5-2 / 2-3 / 3-5 / >=5 ms,
* 用于判断抖动形态(<2ms 内的尖峰/双峰 vs 离散 +1ms 碰撞) */
uint32_t histo[7] = {0U, 0U, 0U, 0U, 0U, 0U, 0U};
/* 事务耗时(worker 内执行段,SpiMaster::lastWorkerExecUs)与
* 调度/唤醒开销(RTT − 事务)拆解:事务慢=GD32 应答/SPI 时序;
* 调度慢=ESP32 任务抢占(WiFi/esp_timer 等)。 */
uint64_t w_sum_us = 0U, w_min_us = UINT64_MAX, w_max_us = 0U;
uint64_t s_sum_us = 0U, s_min_us = UINT64_MAX, s_max_us = 0U;
/* 5 段细分(SpiMaster::SpiProfile):提交/调度、发送帧、等应答、读响应、唤醒 */
uint64_t seg_sum[5] = {0U, 0U, 0U, 0U, 0U};
uint64_t seg_max[5] = {0U, 0U, 0U, 0U, 0U};
DEBUG_INFO("SPI", "[SPEEDTEST] start frames=%u payload=%u",
(unsigned)frames, (unsigned)payload_len);
@@ -100,12 +94,34 @@ esp_err_t spi_speedtest_handler(httpd_req_t *req)
rtt_sum_us += rtt;
if (rtt < rtt_min_us) { rtt_min_us = rtt; }
if (rtt > rtt_max_us) { rtt_max_us = rtt; }
const uint32_t ms_bucket = (uint32_t)(rtt / 1000U);
if (ms_bucket < 2U) { ++histo[0]; }
else if (ms_bucket < 3U) { ++histo[1]; }
else if (ms_bucket < 4U) { ++histo[2]; }
else if (ms_bucket < 5U) { ++histo[3]; }
else { ++histo[4]; }
const uint64_t wus = SpiMaster::instance().lastWorkerExecUs();
const uint64_t sus = (rtt > wus) ? (rtt - wus) : 0U;
w_sum_us += wus;
if (wus < w_min_us) { w_min_us = wus; }
if (wus > w_max_us) { w_max_us = wus; }
s_sum_us += sus;
if (sus < s_min_us) { s_min_us = sus; }
if (sus > s_max_us) { s_max_us = sus; }
/* 5 段细分(防回绕钳 0) */
const SpiMaster::SpiProfile &pf = SpiMaster::instance().lastProfile();
const uint64_t seg[5] = {
(pf.t_w0 > pf.t_submit0) ? (pf.t_w0 - pf.t_submit0) : 0U,
(pf.t_send1 > pf.t_send0) ? (pf.t_send1 - pf.t_send0) : 0U,
(pf.t_wait1 > pf.t_wait0) ? (pf.t_wait1 - pf.t_wait0) : 0U,
(pf.t_read1 > pf.t_wait1) ? (pf.t_read1 - pf.t_wait1) : 0U,
(pf.t_submit1 > pf.t_w1) ? (pf.t_submit1 - pf.t_w1) : 0U,
};
for (int k = 0; k < 5; ++k) {
seg_sum[k] += seg[k];
if (seg[k] > seg_max[k]) { seg_max[k] = seg[k]; }
}
if (rtt < 500U) { ++histo[0]; }
else if (rtt < 1000U) { ++histo[1]; }
else if (rtt < 1500U) { ++histo[2]; }
else if (rtt < 2000U) { ++histo[3]; }
else if (rtt < 3000U) { ++histo[4]; }
else if (rtt < 5000U) { ++histo[5]; }
else { ++histo[6]; }
} else {
++n_fail;
}
@@ -120,25 +136,44 @@ esp_err_t spi_speedtest_handler(httpd_req_t *req)
tput_kbps = ((double)n_ok * (double)payload_len) / ((double)elapsed_us / 1000000.0) / 1024.0;
}
if (rtt_min_us == UINT64_MAX) { rtt_min_us = 0U; }
if (w_min_us == UINT64_MAX) { w_min_us = 0U; }
if (s_min_us == UINT64_MAX) { s_min_us = 0U; }
DEBUG_INFO("SPI", "[SPEEDTEST] done ok=%u fail=%u avg=%uus tput=%.1fKB/s",
(unsigned)n_ok, (unsigned)n_fail, (unsigned)rtt_avg_us, tput_kbps);
char text[480];
char text[640];
const int len = snprintf(text, sizeof(text),
"SPI 测速结果\n"
"帧数: %u 成功: %u 失败: %u%s\n"
"载荷: %u B/帧 总耗时: %u ms\n"
"往返延迟: 平均 %.1f ms 最快 %.1f ms 最慢 %.1f ms\n"
"延迟分布: <2ms:%u 2-3ms:%u 3-4ms:%u 4-5ms:%u >=5ms:%u\n"
"往返延迟: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"SPI 事务: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"调度开销: 平均 %llu µs 最快 %llu µs 最慢 %llu µs\n"
"单帧分段 平均: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"单帧分段 最慢: 提交/调度 %llu | 发送帧 %llu | 等应答 %llu | 读响应 %llu | 唤醒 %llu µs\n"
"延迟分布: <0.5ms:%u 0.5-1ms:%u 1-1.5ms:%u 1.5-2ms:%u 2-3ms:%u 3-5ms:%u >=5ms:%u\n"
"等效吞吐: %.1f KB/s\n",
(unsigned)frames, (unsigned)n_ok, (unsigned)n_fail,
(n_fail > 0U) ? " <-- 存在失败帧, 链路异常!" : "",
(unsigned)payload_len, (unsigned)elapsed_ms,
(double)rtt_avg_us / 1000.0, (double)rtt_min_us / 1000.0,
(double)rtt_max_us / 1000.0,
(unsigned long long)rtt_avg_us, (unsigned long long)rtt_min_us,
(unsigned long long)rtt_max_us,
(unsigned long long)(w_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)w_min_us, (unsigned long long)w_max_us,
(unsigned long long)(s_sum_us / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)s_min_us, (unsigned long long)s_max_us,
(unsigned long long)(seg_sum[0] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[1] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[2] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[3] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)(seg_sum[4] / (n_ok > 0U ? n_ok : 1U)),
(unsigned long long)seg_max[0], (unsigned long long)seg_max[1],
(unsigned long long)seg_max[2], (unsigned long long)seg_max[3],
(unsigned long long)seg_max[4],
(unsigned)histo[0], (unsigned)histo[1], (unsigned)histo[2],
(unsigned)histo[3], (unsigned)histo[4], tput_kbps);
(unsigned)histo[3], (unsigned)histo[4],
(unsigned)histo[5], (unsigned)histo[6], tput_kbps);
http_set_cors_headers(req);
httpd_resp_set_type(req, "text/plain; charset=utf-8");
+11
View File
@@ -19,6 +19,7 @@
#include "esp32_ota_core.h"
#include "app_config.h"
#include "monitor/debug_log.h"
#include "spi_master.h" /* setSpiPaused:OTA 期间暂停 SPI,避免 flash 写与 SPI 读并发 */
#define TAG "OTA_CORE"
@@ -110,6 +111,7 @@ esp_err_t Esp32OtaCore::finalizeCleanupAndReturn(esp_err_t code)
}
m_ended = false;
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 结束(失败也恢复 SPI) */
resetState();
return code;
}
@@ -201,6 +203,12 @@ esp_err_t Esp32OtaCore::begin(size_t total_size)
m_ended = false;
memset(m_sha256, 0, sizeof(m_sha256));
/* ESP32 自我 OTA 期间暂停 SPI(GD32 按铃读 + 业务 transact 全部停摆):
* esp_ota_write 写 flash 时 cache 反复失效,SPI worker 的 flash 驻留代码
* 访问会被拖慢甚至触发 Cache error,且此时 GD32 数据无意义。
* finalize()/abort() 恢复。 */
SpiMaster::instance().setSpiPaused(true);
DEBUG_DBG(TAG, "ESP32 OTA begin part=%s size=%u (erase-on-write)",
m_partition->label, (unsigned)total_size);
return ESP_OK;
@@ -382,11 +390,13 @@ esp_err_t Esp32OtaCore::finalize(const char *frontend_sha_hex, bool reboot,
if (err != ESP_OK) {
DEBUG_ERR(TAG, "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err));
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 失败也恢复 SPI */
resetState();
return err;
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 收尾完成,恢复 SPI 业务 */
DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,共 %u 字节", (unsigned)m_received);
// 保持 m_active 直到重启或下次 begin;resetState 由 begin/abort 处理
@@ -413,6 +423,7 @@ void Esp32OtaCore::abort()
esp_ota_abort(m_handle);
}
releaseMutex();
SpiMaster::instance().setSpiPaused(false); /* OTA 中止,恢复 SPI 业务 */
resetState();
DEBUG_INFO(TAG, "ESP32 OTA 已中止并清理");
}
+38 -47
View File
@@ -100,6 +100,24 @@ void OtaCoordinator::watchdogCheckLocked()
doAbortLocked();
}
esp_err_t OtaCoordinator::beginLockedWithChecks(const char *tag, bool &out_lock)
{
out_lock = lock();
if (!out_lock) return ESP_ERR_INVALID_STATE;
// ★ 卡死自愈检查
watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数期间禁止新的 GD32/ESP32 OTA
if (motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 %s", tag);
unlock(true);
out_lock = false;
return ESP_ERR_INVALID_STATE;
}
return ESP_OK;
}
// ============================================================
// 初始化
// ============================================================
@@ -138,24 +156,15 @@ void OtaCoordinator::init()
// ============================================================
esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 先做卡死自愈检查:若之前忙状态卡死,自动转 ERROR 继续后续流程
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:电机喂数任务与 GD32 固件共用 gd32_ota 分区,
// 电机喂数期间禁止新的 GD32 存储,避免分区被并发覆写。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 GD32 存储");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
bool h;
esp_err_t pre = beginLockedWithChecks("GD32 存储", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 GD32 存储仍在进行中(STORING)且来自同一通道,
// 自动中止旧存储并重新开始。覆盖"上传过程中刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
// RELAYING 是中继阶段,不可中断,也拒绝。
if (h && m_sysState == OTA_SYS_GD32_STORING) {
if (m_sysState == OTA_SYS_GD32_STORING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "GD32存中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
@@ -169,23 +178,20 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
// 系统忙检查:仅拒绝真正"在传输"的活跃状态
// STORED / COMPLETE 是"待中继/已中继"的非活跃状态 → 允许新存储覆盖
if (h && (m_sysState == OTA_SYS_ESP32_RECEIVING ||
if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING ||
m_sysState == OTA_SYS_ESP32_REBOOTING ||
m_sysState == OTA_SYS_GD32_RELAYING)) {
m_sysState == OTA_SYS_GD32_RELAYING) {
DEBUG_WARN(TAG, "系统忙 (state=%d),拒绝 GD32 存储请求", (int)m_sysState);
unlock(h);
return ESP_ERR_INVALID_STATE;
}
// 如果之前已有存储但未中继(STORED)或刚中继完(COMPLETE),允许新存储覆盖。
// 注意:清理 NVS meta 不在此处做,避免与紧跟的 prepareGd32Storage 内
// clearGd32Meta + saveGd32Meta 重复一次写入,仅做状态注释说明。
if (h && (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE)) {
if (m_sysState == OTA_SYS_GD32_STORED || m_sysState == OTA_SYS_GD32_COMPLETE) {
DEBUG_INFO(TAG, "覆盖旧 GD32 存储状态,开始新存储 (size=%" PRIu32 ")", total_size);
}
if (h) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch
m_otaChannel = channel; // ★ 记录发起通道
m_sysState = OTA_SYS_GD32_STORING;
@@ -193,9 +199,10 @@ esp_err_t OtaCoordinator::beginGd32Storage(uint32_t total_size, ota_channel_t ch
m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻
DEBUG_DBG(TAG, "GD32存开始 %" PRIu32 "B →STORING ep=%u ch=%d",
total_size, m_otaEpoch, (int)channel);
}
// ★ 预分配 flashWriteChunk 对齐缓冲(OTA 期消除逐块 malloc;非 OTA 期零占用)
Gd32FlashStore::acquireAlignBufForOta();
unlock(h);
return h ? ESP_OK : ESP_ERR_INVALID_STATE;
return ESP_OK;
}
esp_err_t OtaCoordinator::prepareGd32Storage(uint32_t total_size, ota_channel_t channel)
@@ -416,23 +423,14 @@ void OtaCoordinator::notifyBleCoexEnd()
// ============================================================
esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t channel)
{
bool h = lock();
// ★ 卡死自愈检查
if (h) watchdogCheckLocked();
// ★ 电机 OTA 互斥:ESP32 自身 OTA 会重启设备,中断正在进行的电机喂数,
// 电机 OTA 期间拒绝 ESP32 OTA。
if (h && motorOtaIsActive()) {
DEBUG_WARN(TAG, "电机 OTA 进行中,拒绝 ESP32 OTA");
unlock(h);
return ESP_ERR_INVALID_STATE;
}
bool h;
esp_err_t pre = beginLockedWithChecks("ESP32 OTA", h);
if (pre != ESP_OK) return pre;
// ★ 同类型重启:若上一次 ESP32 OTA 仍在接收中(RECEIVING)且来自同一通道,
// 自动中止旧会话并重新开始。覆盖"上传完成后刷新网页再上传"场景。
// 跨通道(BLE→WiFi 或 WiFi→BLE)仍拒绝,保持互斥。
if (h && m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_sysState == OTA_SYS_ESP32_RECEIVING) {
if (m_otaChannel != channel) {
DEBUG_WARN(TAG, "ESP32收中 ch=%d 拒跨通道 ch=%d",
(int)m_otaChannel, (int)channel);
@@ -447,7 +445,7 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
// ★ FINALIZING 残留自愈:SHA 不匹配时 core 已 abort,但旧固件可能未把协调器
// 复位到 IDLE。core 已空闲 ⇒ 协调器状态是假占用,允许清掉后开新会话。
// core 仍 active ⇒ 真正在收尾,拒绝(不可 forceCleanup,防并发写分区)。
if (h && m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (m_sysState == OTA_SYS_ESP32_FINALIZING) {
if (!Esp32OtaCore::instance().isActive()) {
DEBUG_WARN(TAG, "FINALIZING残留(core空闲),强制复位");
doAbortLocked();
@@ -459,19 +457,17 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
}
// 系统忙检查:GD32 传输/中继进行中,或 ESP32 重启中,均拒绝。
if (h && (m_sysState == OTA_SYS_GD32_STORING ||
if (m_sysState == OTA_SYS_GD32_STORING ||
m_sysState == OTA_SYS_GD32_RELAYING ||
m_sysState == OTA_SYS_ESP32_REBOOTING)) {
m_sysState == OTA_SYS_ESP32_REBOOTING) {
DEBUG_WARN(TAG, "忙 state=%d(%s) 拒ESP32 OTA",
(int)m_sysState, stateToString(m_sysState));
unlock(h);
return ESP_ERR_INVALID_STATE;
}
esp_err_t err = ESP_ERR_INVALID_STATE;
if (h) {
// 委托 Esp32OtaCore 处理分区/互斥逻辑
err = Esp32OtaCore::instance().begin(total_size);
esp_err_t err = Esp32OtaCore::instance().begin(total_size);
if (err == ESP_OK) {
m_otaEpoch++; // ★ 新会话开始,递增 epoch(使旧 handler 的 abortCurrentIfEpoch 失效)
m_otaChannel = channel; // ★ 记录发起通道
@@ -481,7 +477,6 @@ esp_err_t OtaCoordinator::beginEsp32Ota(uint32_t total_size, ota_channel_t chann
} else {
DEBUG_WARN(TAG, "ESP32 OTA begin 失败: %s", esp_err_to_name(err));
}
}
unlock(h);
return err;
@@ -622,9 +617,11 @@ void OtaCoordinator::doAbortLocked()
if (m_sysState == OTA_SYS_GD32_STORING) {
Gd32FlashStore::instance().metaClear();
Gd32FlashStore::releaseAlignBufForOta(); // 释放 OTA 期对齐缓冲
} else if (m_sysState == OTA_SYS_GD32_RELAYING) {
/* 中继中止必须 CANCEL 远端,否则 GD32 停在 WAITING_* 半会话 */
OtaRelay::instance().abortRemoteAndReset(true);
Gd32FlashStore::releaseAlignBufForOta(); // 中继阶段也可能写过 flash
} else if (m_sysState == OTA_SYS_ESP32_RECEIVING ||
m_sysState == OTA_SYS_ESP32_FINALIZING) {
Esp32OtaCore::instance().abort();
@@ -761,12 +758,6 @@ const char* OtaCoordinator::stateToString(ota_system_state_t s)
}
}
/** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */
bool OtaCoordinator::getGd32AppAlive() const
{
return OtaRelay::getGd32AppAlive();
}
// ============================================================
// OTA 状态快照
// ============================================================
+7 -6
View File
@@ -420,12 +420,6 @@ public:
bool isIdle() const;
// ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)──
/**
* @brief 获取 GD32 APP 存活状态。
* @return true 表示 GD32 APP 存活。
*/
bool getGd32AppAlive() const;
// ── 完全复位 ──
/**
* @brief 完全复位协调器和所有子系统状态。
@@ -479,6 +473,13 @@ private:
// 已持锁中止:清理子系统并将状态复位为 IDLE
void doAbortLocked();
/** @brief 统一 "lock + watchdog 自愈 + 电机 OTA 互斥拒绝" 前缀检查。
* beginGd32Storage / beginEsp32Ota 共用此逻辑。
* @param tag 日志标签(如 "GD32 存储" / "ESP32 OTA")
* @param out_lock 成功时输出锁句柄(调用方负责 unlock)
* @return ESP_OK=前缀通过可继续;ESP_ERR_INVALID_STATE=被拒绝(已 unlock) */
esp_err_t beginLockedWithChecks(const char *tag, bool &out_lock);
// ★ 已持锁版本的 ESP32 状态切换 helper:设置 m_sysState 并打印一条 INFO 日志。
// 供 notifyEsp32*Complete / notifyEsp32Rebooting 等锁内路径复用,避免重复样板代码。
void setEsp32StateLocked(ota_system_state_t s, const char *dbg_msg);
+75 -71
View File
@@ -349,10 +349,14 @@ ota_result_t OtaRelay::handleTransferring(relay_event_t event, const uint8_t *pa
DEBUG_INFO("OTA", "=== 阶段3: 数据包传输 (%" PRIu32 " 包) ===", m_transferTotalPackets);
/* 转发预算:自本阶段起算。正常 ~15ms/包,预算 40ms/包 + 20s 底(上限 120s)。 */
constexpr uint32_t kXferMinMs = 20U * 1000U;
/* 转发预算:自本阶段起算。正常 ~2ms/包(12MHz+ 读预算收紧后实测),
* 单包失败重发最坏 ~95ms/轮 × 3 轮 ≈ 285ms(底层 3 次尝试 + relay 50ms 窗口)。
* 预算按"正常 + 重发余量":25ms/包 ≈ 正常 2ms + 约 10% 包重发 1 次的平均,
* 底 10s 覆盖慢启动/握手余量(777 包正常仅 ~1.6s);上限 120s 兜极端慢速。
* 跨包卡死(10s 无成功帧)由 kStallTimeoutMs 单独检测,总预算不重复兜底。 */
constexpr uint32_t kXferMinMs = 10U * 1000U;
constexpr uint32_t kXferMaxMs = 120U * 1000U;
constexpr uint32_t kXferPerPktMs = 40U;
constexpr uint32_t kXferPerPktMs = 25U;
uint32_t xfer_ms = kXferMinMs + m_transferTotalPackets * kXferPerPktMs;
if (xfer_ms > kXferMaxMs) {
xfer_ms = kXferMaxMs;
@@ -448,9 +452,6 @@ ota_result_t OtaRelay::forwardFirmware()
DEBUG_INFO("OTA", "开始固件转发: %" PRIu32 " 字节", meta.total_size);
/* 中继全程禁止 worker 把 OTA ACK/WAIT 当 EVENT 偷读 */
SpiMaster::instance().setSuppressRingService(true);
/* 完整性门控(避免与 /ota/relay·BLE 触发前的 verifyGd32Firmware 二次全盘 SHA):
* - meta 必须完整 stored≥total
* - checksum 非全零:触发路径已 finalize+比对,此处信任 meta,跳过再扫 Flash
@@ -500,10 +501,8 @@ ota_result_t OtaRelay::forwardFirmware()
DEBUG_ERR("OTA", "中继失败: result=%d, state=%d", (int)res, (int)getState());
/* 必须通知 GD32 CANCEL,否则远端停在 WAITING_* 半会话 */
abortRemoteAndReset(true);
} else {
/* 成功路径:解除按铃抑制(reset 也会做,成功时状态机可能停在 DONE) */
SpiMaster::instance().setSuppressRingService(false);
}
/* 成功/失败统一由 reset() 解除 ring 抑制(relay task 侧持有 ref) */
return res;
}
@@ -1174,7 +1173,10 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
* - 等 ACK 期间可预组下一包(与从机处理重叠)。
*/
constexpr uint32_t kMaxDataSends = 3U;
constexpr uint32_t kAckWaitMs = 6000U; /* 单次发送后等 ACK 上限(覆盖 GD32 增量 drain) */
/* 单次发送后等 ACK 上限:GD32 正常 ms 级回 ACK(ISR 回 WAIT、主循环回 ACK),
* 50ms 未到即重发(DUP-SEQ 回放捞回),不再干等 6s(用户指定 50ms)。
* credit==0 的排空积压由 transact 内部 credit 背压消化,此处无需长等。 */
constexpr uint32_t kAckWaitMs = 50U;
constexpr uint32_t kPollSliceMs = 50U; /* 略紧:DATA_READY 驱动,缩短空切片 */
TickType_t pkt_start = xTaskGetTickCount();
@@ -1204,9 +1206,11 @@ bool OtaRelay::sendRelayPacketWithRetry(uint32_t seq, const uint8_t tx_buf[64],
{
resp_len = 0;
resp_type = 0;
/* 等首响应上限 10ms:一次 SPI 事务最多 5ms(用户指定),
* 超时立即进入下方 50ms poll 窗口,不再干等 200ms。 */
esp_err_t err = SpiMaster::instance().transact(
SPI_OTA_CMD_DATA, tx_buf, 64,
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 1200);
&resp_type, resp_payload, sizeof(resp_payload), &resp_len, 10);
logRespPayload("DATA_PACKET", resp_type, resp_payload, resp_len);
if (err == ESP_OK && resp_type == SPI_OTA_RSP_WAIT) {
saw_wait = true;
@@ -1412,6 +1416,58 @@ bool OtaRelay::probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime)
return false;
}
/**
* @brief 统一 "HELLO(role=APP) → 可选 PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。
*
* 两处原循环差异仅:taskEntry 在 HELLO 已带身份(gd32IdKnown)时跳过 PING 直接成功。
* 用 allow_hello_shortcut 参数统一:true 时启用 HELLO 身份快路径。
*
* @param timeout_ms PING 超时
* @param ping_interval_ms 每轮间隔
* @param max_attempts 最大轮数
* @param allow_hello_shortcut true=HELLO 带身份即成功(taskEntry 场景)
* @return true=APP 存活已确认
*/
bool OtaRelay::helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut)
{
bool session_ok = false;
for (uint32_t attempt = 0; attempt < max_attempts; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(ping_interval_ms));
if (!session_ok) {
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true;
/* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */
if (allow_hello_shortcut && SpiMaster::instance().gd32IdKnown()) {
StateLockGuard lock;
gd32Version = SpiMaster::instance().gd32VersionCached();
gd32AppAlive = true;
DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d",
gd32Version.app_major, gd32Version.app_minor,
gd32Version.app_patch, gd32Version.app_build);
DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d",
gd32Version.boot_major, gd32Version.boot_minor,
gd32Version.boot_patch, gd32Version.boot_build);
return true;
}
} else {
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/%u)", attempt + 1U, (unsigned)max_attempts);
continue;
}
}
/* HELLO 成功但无身份(旧固件),或 doWaitForAppAlive 路径:回退 APP PING */
if (probeAndCacheAppAlive(timeout_ms)) {
return true;
}
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
session_ok = false;
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/%u)", attempt + 1U, (unsigned)max_attempts);
}
return false;
}
/** @brief 协议子步骤:发送 FINISH_OTA 指令并等待 GD32 ACK(10 次重试)。 */
ota_result_t OtaRelay::doSendFinishOta()
{
@@ -1485,30 +1541,8 @@ ota_result_t OtaRelay::doWaitForAppAlive(uint32_t timeout_ms)
/* FINISH 后短暂安静,避免 jump/APP SPI arm 窗口被 HELLO 打乱(对齐既有 300ms 节拍)。 */
vTaskDelay(pdMS_TO_TICKS(500));
/* APP 起机约数秒;HELLO 须 role=APP 再建 session,再 appPing。 */
bool session_established = false;
for (uint32_t attempt = 0; attempt < 20; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(300));
if (!session_established) {
esp_err_t hr = SpiMaster::instance().hello(1500U, V2_ROLE_APP);
if (hr == ESP_OK && SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_established = true;
} else {
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
continue;
}
}
if (probeAndCacheAppAlive(timeout_ms)) {
break;
}
/* PING 失败:清 session,下轮重 HELLO(避免 sticky 假会话) */
session_established = false;
DEBUG_INFO("OTA", "等待 GD32 APP 启动... (%" PRIu32 "/20)", attempt + 1);
}
if (!getGd32AppAlive()) {
/* doWaitForAppAlive 不启用 HELLO 身份快路径:OTA 完成后必须 PING 确认 APP 真正运行 */
if (!helloAndProbeApp(timeout_ms, 300U, 20U, /*allow_hello_shortcut=*/false)) {
DEBUG_ERR("OTA", "GD32 APP 启动确认超时,OTA 失败");
/* APP 未起来时 CANCEL 可能无效,仍尽力清理 BOOT 半会话 */
abortRemoteAndReset(true);
@@ -1562,42 +1596,7 @@ void OtaRelay::taskEntry(void *pvParameters)
/* 启动 GD32 探测:先 hello 建 v2 session,再 appPing。 */
{
DEBUG_INFO("OTA", "启动初始 GD32 探测 (hello+probe)...");
bool session_ok = false;
bool alive = false;
for (uint32_t attempt = 0; attempt < 20U; ++attempt) {
vTaskDelay(pdMS_TO_TICKS(300));
if (!session_ok) {
if (SpiMaster::instance().hello(1500U, V2_ROLE_APP) == ESP_OK &&
SpiMaster::instance().gd32Role() == V2_ROLE_APP) {
session_ok = true;
/* HELLO 已带身份时直接缓存版本/开关,无需再等 PING 才亮版本 */
if (SpiMaster::instance().gd32IdKnown()) {
StateLockGuard lock;
gd32Version = SpiMaster::instance().gd32VersionCached();
gd32AppAlive = true;
alive = true;
DEBUG_INFO("OTA", "HELLO就绪 APP=v%d.%d.%d.%d",
gd32Version.app_major, gd32Version.app_minor,
gd32Version.app_patch, gd32Version.app_build);
DEBUG_INFO("OTA", "HELLO就绪 BOOT=v%d.%d.%d.%d",
gd32Version.boot_major, gd32Version.boot_minor,
gd32Version.boot_patch, gd32Version.boot_build);
break;
}
} else {
DEBUG_INFO("OTA", "等待 GD32 APP... (%" PRIu32 "/20)", attempt + 1U);
continue;
}
}
/* 旧固件无 HELLO 身份:回退 APP PING */
if (probeAndCacheAppAlive(2000)) {
alive = true;
break;
}
session_ok = false;
DEBUG_INFO("OTA", "等待 GD32 APP 存活... (%" PRIu32 "/20)", attempt + 1U);
}
if (!alive) {
if (!helloAndProbeApp(2000U, 300U, 20U, /*allow_hello_shortcut=*/true)) {
DEBUG_WARN("OTA", "启动 GD32 探测未成功(中继前仍可再 hello)");
}
}
@@ -1610,6 +1609,11 @@ void OtaRelay::taskEntry(void *pvParameters)
}
DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg);
/* 尽早抑制 ring service:onRelayStarted 可能因协调器锁(/system/status
* 等争锁)阻塞,此窗口内 worker 会把上一阶段残留 RESPONSE 当 EVENT 偷读
* (线上 log#1:TRANSFERRING 起始瞬间 serviceRing 读到 type=0x80)。
* forwardFirmware 不再重复设置;释放统一走 reset()。 */
SpiMaster::instance().setSuppressRingService(true);
// 通过 IRelayListener 接口回调通知协调器
IRelayListener *listener = getRelayListener();
if (listener) listener->onRelayStarted();
+13 -40
View File
@@ -118,16 +118,6 @@ public:
*/
ota_result_t forwardFirmware();
// ── 事件驱动接口 ──
/**
* @brief 向状态机投递事件,驱动状态转换.
* @param event 事件类型.
* @param payload 关联数据(响应载荷等),可为nullptr.
* @param len 数据长度.
* @return 当前操作结果码(OTA_RESULT_OK 表示事件已处理且可继续).
*/
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 状态查询(线程安全)──
/**
* @brief 获取当前中继状态.
@@ -145,31 +135,6 @@ public:
* @return 中继进度快照。
*/
static RelaySnapshot getSnapshot();
/**
* @brief 设置中继状态.
* @param s 目标状态。
*/
static void setState(relay_state_t s);
/**
* @brief 设置中继当前序号.
* @param seq 序号值。
*/
static void setRelaySeq(uint32_t seq);
/**
* @brief 设置中继总包数.
* @param total 总包数。
*/
static void setRelayTotalPackets(uint32_t total);
/**
* @brief 设置GD32 APP存活标志位.
* @param alive true 表示存活。
*/
static void setGd32AppAlive(bool alive);
/**
* @brief 设置GD32固件版本.
* @param ver 版本信息结构体引用。
*/
static void setGd32Version(const gd32_version_t &ver);
/**
* @brief 获取 OTA 中继任务句柄。
* @return FreeRTOS 任务句柄。
@@ -195,11 +160,6 @@ public:
* @param listener 监听器指针,传 nullptr 取消注册。
*/
static void setRelayListener(IRelayListener *listener);
/**
* @brief 获取当前注册的监听器。
* @return 监听器指针,未注册时为 nullptr。
*/
static IRelayListener* getRelayListener();
// ── 队列访问器 ──
/**
@@ -223,6 +183,19 @@ public:
*/
static bool probeAndCacheAppAlive(uint32_t timeout_ms, uint32_t *out_uptime = nullptr);
/** @brief 统一 "HELLO(role=APP) → PING 探测" 循环(doWaitForAppAlive + taskEntry 共用)。 */
static bool helloAndProbeApp(uint32_t timeout_ms, uint32_t ping_interval_ms,
uint32_t max_attempts, bool allow_hello_shortcut);
// ── 内部状态写入(仅 ota_relay.cpp 使用,无外部调用)──
static void setState(relay_state_t s);
static void setRelaySeq(uint32_t seq);
static void setRelayTotalPackets(uint32_t total);
static void setGd32AppAlive(bool alive);
static void setGd32Version(const gd32_version_t &ver);
static IRelayListener* getRelayListener();
ota_result_t processEvent(relay_event_t event, const uint8_t *payload, size_t len);
// ── 线程安全锁 ──
static void initMutex(); ///< 初始化状态互斥锁(taskEntry 首行调用)
static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); ///< 锁获取超时时间
+5 -1
View File
@@ -11,7 +11,11 @@ void drainOtaDataQueue()
{
BleOtaDataBatch stale;
while (s_otaDataQueue && xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
if (stale.data) free(stale.data);
if (stale.pool_idx >= 0) {
ble_ota_pool_release(stale.pool_idx); // 静态池槽:归还
} else if (stale.data) {
free(stale.data); // heap 回退路径:释放
}
}
}
+157 -8
View File
@@ -54,6 +54,132 @@ ota_meta_t s_ble_gd32_meta = {};
bool s_ble_gd32_meta_active = false;
bool s_conn_fallback_requested = false;
// ============================================================
// DATA_IN 缓冲池(2026-08 内存优化)
//
// 背景:原 DATA_IN 路径每包 malloc(recv_len) + worker free(item.data),
// BLE 高速下发时高频堆抖动制造碎片,挤掉 lwip pbuf 连续块导致网络断连。
//
// 策略:OTA begin 时一次性 calloc 池(连续大块,比高频 per-pack malloc
// 碎片小得多),DATA_IN 借槽拷贝 → 入队 → worker 处理后归还;
// OTA end/abort/断连时 free 整个池。
// - 非 OTA 期间:池指针为 NULL,零占用,堆基线不受影响。
// - OTA 期间:借/还 O(1) 标志位操作,无堆抖动。
// - 池满/包过大回退 malloc + 负 ACK(保留原语义,避免功能回退)。
//
// 池容量 8 槽 × 512B = 4KB(2026-08-01 修正):
// 原 8×4096B=32KB 使 OTA 全程多占 28KB 堆,刷写 1MB 固件收尾时
// (0xF8 后需创建 finish task + image verify)直接 ESP_ERR_NO_MEM。
// 单次 ATT 写入上限 = MTU 512B(BLE_MTU_SIZE),常态包 ≤512B 全走池;
// NimBLE 合并的大 batch(≤4096B,经 BLE_OTA_BUF_SIZE 截断)回退
// malloc,与无池时代行为一致。4KB 固定占用对收尾任务足够安全。
// ============================================================
struct BleDataPool {
static constexpr size_t kSlots = 8;
static constexpr size_t kSlotSize = 512; // 单次 ATT 写入上限(MTU);大 batch 回退 malloc
uint8_t *buf[kSlots]; // kSlots 个独立槽(calloc 一次分配 kSlots*kSlotSize 后切分)
volatile bool in_use[kSlots];
};
static BleDataPool *s_data_pool = nullptr;
// 前向声明(poolAlloc 与 poolFree 互相引用)
static void poolFree();
/** @brief OTA begin 时分配池。失败返回 false(调用方回退纯 malloc 路径)。
* 安全性:上次会话遗留的池仅当全部槽空闲才复用。若 worker 仍持有在途
* batch(in_use=true),强制复位复用会让新会话 DATA_IN 覆写旧 worker
* 正在解析的内存 → flash 写坏 → 收尾 SHA/verify 失败。此时本会话回退
* malloc,旧池留待 worker 归还后由下次 poolFree 释放。 */
static bool poolAlloc()
{
if (s_data_pool) {
// 上次会话遗留池:检查是否全部空闲(in_use 仅由 worker/队列消费侧复位)
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (s_data_pool->in_use[i]) {
DEBUG_WARN(TAG, "DATA_IN 池槽仍被占用,本会话回退 malloc");
return false;
}
}
return true; // 全部空闲:安全复用
}
s_data_pool = (BleDataPool *)calloc(1, sizeof(BleDataPool));
if (!s_data_pool) return false;
// 一次性分配所有槽的负载数据(连续大块,碎片友好)
uint8_t *blob = (uint8_t *)calloc(BleDataPool::kSlots, BleDataPool::kSlotSize);
if (!blob) {
free(s_data_pool);
s_data_pool = nullptr;
return false;
}
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
s_data_pool->buf[i] = blob + i * BleDataPool::kSlotSize;
s_data_pool->in_use[i] = false;
}
return true;
}
/** @brief OTA end/abort/断连时释放整个池。
* 安全性:仅当所有槽均空闲时才释放;若 worker 仍有 in-flight batch
* 占用槽(in_use=true),跳过本次释放,留待下次 poolFree 全部空闲时
* 再释放。这避免了 worker 访问已释放内存的竞态(单核抢占:
* session_reset 运行在 NimBLE host task,worker 是独立任务,二者仅
* 通过队列交接,无显式同步)。 */
static void poolFree()
{
if (!s_data_pool) return;
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (s_data_pool->in_use[i]) {
// 有槽仍被 worker 占用,跳过释放(下次 poolFree 再试)
DEBUG_DBG(TAG, "DATA_IN 池延迟释放: 槽%u in_use", (unsigned)i);
return;
}
}
// buf[0] 是连续 blob 的起始(一次 calloc 分配)
if (s_data_pool->buf[0]) {
free(s_data_pool->buf[0]);
}
free(s_data_pool);
s_data_pool = nullptr;
}
/** @brief 从池借一个槽,返回索引;无空闲或池未分配返回 -1。仅 DATA_IN(host task)调用。 */
static int poolAcquire()
{
if (!s_data_pool) return -1;
for (size_t i = 0; i < BleDataPool::kSlots; ++i) {
if (!s_data_pool->in_use[i]) {
s_data_pool->in_use[i] = true;
return (int)i;
}
}
return -1;
}
/** @brief 归还槽到池。仅 worker / drain(队列消费侧)调用。 */
static void poolRelease(int idx)
{
if (!s_data_pool) return;
if (idx >= 0 && idx < (int)BleDataPool::kSlots) {
s_data_pool->in_use[idx] = false;
}
}
/** @brief 借槽拷贝数据;返回槽索引或 -1(池满/未分配/包过大)。 */
static int poolCopyIn(const uint8_t *src, size_t len)
{
if (!s_data_pool || len > BleDataPool::kSlotSize) return -1;
int idx = poolAcquire();
if (idx < 0) return -1;
memcpy(s_data_pool->buf[idx], src, len);
return idx;
}
/** @brief 归还池槽(跨 TU 调用入口:drainOtaDataQueue / 会话清理)。 */
void ble_ota_pool_release(int idx)
{
poolRelease(idx);
}
// ============================================================
// DATA_IN 丢包负反馈:把 malloc 失败 / 队列满从"静默丢包"变成
// "主动发缺口 ACK 让前端按 completed_bytes 续传退避"。
@@ -394,6 +520,11 @@ esp_err_t ble_esp32_ota_begin(size_t total_size)
BleOta::instance().m_esp32LastSeq = 0xFFFFU;
// ★ 分配 reorder buffer(失败则自动回退丢弃策略)
esp32_reorder_alloc();
// ★ 分配 DATA_IN 缓冲池(失败则 DATA_IN 回退 per-pack malloc 路径)
if (!poolAlloc()) {
DEBUG_WARN(TAG, "DATA_IN 池分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
// ★ BLE OTA 开始:降低 WiFi 射频占用
ble_ota_boost_wifi_ps();
/* 刷写前再要一次高速连接参数(连接时若未谈妥,此处补刀) */
@@ -439,6 +570,8 @@ void ble_ota_session_reset()
BleOta::instance().m_esp32RebootPending = false;
// 释放 ESP32 reorder buffer(无 buffer 时为空操作)
esp32_reorder_free();
// 释放 DATA_IN 缓冲池(非 OTA 期间零占用,恢复堆基线)
poolFree();
// 复位 GD32 路径乱序包计数器(节流 ACK 计数),保证下次 GD32 上传从干净状态开始
s_gd32_ooo_count = 0;
s_ble_gd32_meta = {};
@@ -697,21 +830,33 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;
}
// 将数据拷贝到堆上并送入工作队列,避免在 NimBLE host task 中执行
// 将数据拷贝到队列缓冲并送入工作线程,避免在 NimBLE host task 中执行
// 耗时的 flash 操作(esp_ota_begin / esp_ota_write)导致 BLE 连接断开。
// ★ 优先走静态池(消除高频 malloc/free 堆抖动);池满回退 malloc + 负 ACK。
if (s_otaDataQueue) {
// 解析 cmd 字节供负 ACK 区分 ESP32/GD32 续传分支(与 worker 解析一致)
uint8_t cmd = parse_cmd_from_batch(buf, (size_t)recv_len);
int pool_idx = poolCopyIn(buf, (size_t)recv_len);
if (pool_idx >= 0) {
BleOtaDataBatch item = { s_data_pool->buf[pool_idx], (size_t)recv_len, pool_idx };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
// 队列满:归还池槽 + 发缺口 ACK 让前端按 completed_bytes 续传退避,
// 而非等满 8s 窗口超时后整窗口重发 → 重传风暴叠加低堆二次失败
s_ota_drop_queue_full += 1;
poolRelease(pool_idx);
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
sendNegativeAckIfDue(cmd);
}
} else {
// 池满或包过大:回退 malloc(保留原负 ACK 语义,避免静默丢包)
uint8_t *heap_buf = (uint8_t *)malloc((size_t)recv_len);
if (heap_buf) {
memcpy(heap_buf, buf, (size_t)recv_len);
BleOtaDataBatch item = { heap_buf, (size_t)recv_len };
BleOtaDataBatch item = { heap_buf, (size_t)recv_len, -1 };
if (xQueueSend(s_otaDataQueue, &item, 0) != pdTRUE) {
// 队列满:发缺口 ACK 让前端按 completed_bytes 续传退避,
// 而非等满 8s 窗口超时后整窗口重发 → 重传风暴叠加低堆二次失败
s_ota_drop_queue_full += 1;
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
free(heap_buf);
DEBUG_WARN(TAG, "DATA_IN满 丢%dB 等重传", recv_len);
sendNegativeAckIfDue(cmd);
}
} else {
@@ -721,6 +866,7 @@ int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle,
sendNegativeAckIfDue(cmd);
}
}
}
return 0;
}
@@ -1096,7 +1242,8 @@ void ble_ota_data_worker_task(void *pvParameters)
BleOtaDataBatch item;
if (xQueueReceive(s_otaDataQueue, &item, portMAX_DELAY) == pdTRUE) {
if (!item.data || item.len == 0) {
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
continue;
}
@@ -1104,12 +1251,14 @@ void ble_ota_data_worker_task(void *pvParameters)
if (!BleOta::instance().isConnected() ||
BleOta::instance().isSuspendedForWifiOta()) {
DEBUG_DBG(TAG, "BLE不可用 丢%zuB队列", item.len);
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
continue;
}
ble_ota_process_data_batch(item.data, item.len);
free(item.data);
if (item.pool_idx >= 0) poolRelease(item.pool_idx);
else free(item.data);
}
}
}
+2 -1
View File
@@ -227,7 +227,8 @@ int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle,
BleOtaDataBatch stale;
size_t dropped = 0;
while (xQueueReceive(s_otaDataQueue, &stale, 0) == pdTRUE) {
free(stale.data);
if (stale.pool_idx >= 0) ble_ota_pool_release(stale.pool_idx);
else free(stale.data);
dropped++;
}
if (dropped > 0) {
+3
View File
@@ -61,6 +61,7 @@
struct BleOtaDataBatch {
uint8_t *data;
size_t len;
int pool_idx; ///< 静态池槽索引(>=0 为池内,<0 为 heap malloc,需 free)
};
struct BleGd32RelayWaitCtx {
@@ -87,6 +88,8 @@ extern bool s_wifi_ps_boosted;
extern bool s_conn_fallback_requested;
void drainOtaDataQueue();
/** @brief 归还 DATA_IN 静态池槽(drainOtaDataQueue / 会话清理跨 TU 调用)。 */
void ble_ota_pool_release(int idx);
void ble_ota_start_advertising(void);
void sendWindowAck(uint8_t cmd, uint16_t seq, uint32_t completed_bytes);
void sendFinishEvent(uint8_t stage, uint32_t value);
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
/*
* http_util.h - HTTP handler 共享工具(JSON 追加、query 解析、hex 编解码)
*
* 消除 http_handlers.cpp / health_monitor.cpp / spi_speed_test.cpp 中的重复样板。
*/
#ifndef HTTP_UTIL_H
#define HTTP_UTIL_H
#include <stddef.h>
#include <stdint.h>
#include <stdarg.h>
#include <stdbool.h>
#include "esp_http_server.h"
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/* ── JSON buffer 追加 ──────────────────────────────────────────
* 统一替代 feedback_append / append_jsonf / appendJsonfSafe 三份拷贝。
* 截断时不越界,返回新 offset;出错返回 -1 或原 offset(视调用方约定)。
* 返回 (int)cap-1 表示已截断填满。 */
static inline int http_json_append(char *buf, size_t cap, int offset, const char *fmt, ...)
{
if (!buf || cap == 0 || offset < 0 || (size_t)offset >= cap) return offset;
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + offset, cap - (size_t)offset, fmt, ap);
va_end(ap);
if (n < 0) return offset;
if ((size_t)n >= cap - (size_t)offset) return (int)cap - 1; /* 截断 */
return offset + n;
}
/* ── Query 参数解析 ────────────────────────────────────────────
* 统一替代 6 处内联 ?lite=1 / ?bin=1 / ?ch=N 解析块。 */
/** 查询布尔型参数:值为 "1"/"y"/"t"/"T" 时返回 true,缺失或其它返回 false。 */
static inline bool http_query_bool(httpd_req_t *req, const char *key)
{
char query[48];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return false;
char val[8] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return false;
return val[0] == '1' || val[0] == 'y' || val[0] == 'Y' ||
val[0] == 't' || val[0] == 'T';
}
/** 查询无符号整型参数:缺失/非法时返回 defval。 */
static inline uint32_t http_query_u32(httpd_req_t *req, const char *key, uint32_t defval)
{
char query[64];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK) return defval;
char val[16] = {};
if (httpd_query_key_value(query, key, val, sizeof(val)) != ESP_OK) return defval;
return (uint32_t)strtoul(val, NULL, 10);
}
/* ── Hex 编解码 ────────────────────────────────────────────────
* 统一替代内联 strtoul 循环和 snprintf("%02X") 循环。 */
/** hex 字符串 → 字节数组,返回写入字节数。 */
static inline size_t http_hex_to_bytes(const char *hex, uint8_t *out, size_t max_out)
{
size_t hex_len = strlen(hex);
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** hex 字符串(带长度)→ 字节数组,返回写入字节数。
* 供 json_parse.h 的非 NUL 终止字符串值使用。 */
static inline size_t http_hex_to_bytes_n(const char *hex, size_t hex_len, uint8_t *out, size_t max_out)
{
size_t n = 0;
for (size_t i = 0; i + 1 < hex_len && n < max_out; i += 2) {
char byte_str[3] = { hex[i], hex[i + 1], 0 };
out[n++] = (uint8_t)strtol(byte_str, NULL, 16);
}
return n;
}
/** 字节数组 → 大写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_upper(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02X", in[i]);
}
out[n * 2] = '\0';
}
/** 字节数组 → 小写 hex 字符串(out 需至少 2*n+1 字节)。 */
static inline void http_bytes_to_hex_lower(const uint8_t *in, size_t n, char *out)
{
for (size_t i = 0; i < n; ++i) {
snprintf(out + i * 2, 3, "%02x", in[i]);
}
out[n * 2] = '\0';
}
#ifdef __cplusplus
}
#endif
#endif // HTTP_UTIL_H
+243
View File
@@ -0,0 +1,243 @@
/*
* json_parse.h - 极简 JSON 扁平对象解析器(cJSON 零堆分配替代)
*
* 项目中对 JSON 的使用全部是"解析前端发来的扁平 JSON 对象":
* { "sha256": "...", "verify_only": true, "total_size": 12345, "type": "esp32" }
* 无嵌套对象、无数组(can_send_batch 已自研流式解析)。
* 本解析器直接在调用方提供的可写字符串缓冲上做扫描,
* 不分配堆内存,字符串值通过就地 NUL 终止返回指针。
*
* 用法:
* JsonView root = jsonParse(buf); // buf 须为可写 char[]
* if (!root.valid) return error;
* JsonView sha = jsonGet(&root, "sha256");
* if (sha.type == JSON_STRING) { use(sha.str); }
* JsonView sz = jsonGet(&root, "total_size");
* if (sz.type == JSON_NUMBER) { uint32_t n = (uint32_t)sz.num; }
* // 无需 free —— 指针指向 buf,buf 由调用方管理
*/
#ifndef JSON_PARSE_H
#define JSON_PARSE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
JSON_INVALID = 0,
JSON_NULL,
JSON_BOOL,
JSON_NUMBER,
JSON_STRING,
JSON_OBJECT,
} JsonType;
typedef struct {
JsonType type;
bool valid;
char *str; /* JSON_STRING:指向 buf 内字符串(不一定 NUL 终止!) */
size_t str_len; /* JSON_STRING:字符串长度(配合 str 使用,避免就地写 NUL) */
double num; /* JSON_NUMBER / JSON_BOOL */
char *obj_start; /* JSON_OBJECT:'{' 后的内容区 */
size_t obj_len;
} JsonView;
/**
* @brief 解析 JSON 字符串为根对象视图(就地扫描,可能写入 NUL 终止符)。
* @param json 可写 NUL 终止 JSON 字符串(须为顶层 { ... } 扁平对象)
* @return type=JSON_OBJECT 且 valid=true,或 valid=false 解析失败
*/
static inline JsonView jsonParse(char *json)
{
JsonView v = {};
if (!json) return v;
char *p = json;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '{') return v;
p++;
char *content = p;
int depth = 1;
while (*p) {
if (*p == '"') {
p++;
while (*p && *p != '"') {
if (*p == '\\' && p[1]) p++;
p++;
}
if (*p) p++;
} else if (*p == '{') {
depth++; p++;
} else if (*p == '}') {
depth--;
if (depth == 0) break;
p++;
} else {
p++;
}
}
if (depth != 0) return v;
v.type = JSON_OBJECT;
v.valid = true;
v.obj_start = content;
v.obj_len = (size_t)(p - content);
return v;
}
/**
* @brief 在扁平 JSON 对象中查找指定 key 的值。
* @return valid=true 表示找到;字符串值经就地 NUL 终止后通过 .str 返回
*/
static inline JsonView jsonGet(const JsonView *obj, const char *key)
{
JsonView result = {};
if (!obj || !obj->valid || obj->type != JSON_OBJECT || !key) return result;
char *p = obj->obj_start;
char *end = obj->obj_start + obj->obj_len;
while (p < end) {
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
if (p >= end) break;
/* "key" */
if (*p != '"') break;
p++;
char *key_start = p;
while (p < end && *p != '"') p++;
if (p >= end) break;
size_t klen = (size_t)(p - key_start);
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end || *p != ':') break;
p++;
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) p++;
if (p >= end) break;
bool match = (strlen(key) == klen && strncmp(key_start, key, klen) == 0);
if (*p == '"') {
p++;
char *val_start = p;
while (p < end && *p != '"') {
if (*p == '\\' && p + 1 < end) p++;
p++;
}
if (p >= end) break;
if (match) {
/* 不就地写 NUL(会破坏后续 jsonGet 扫描);用 str_len 返回长度 */
result.type = JSON_STRING;
result.valid = true;
result.str = val_start;
result.str_len = (size_t)(p - val_start);
return result;
}
p++;
} else if (*p == 't' || *p == 'f') {
if (match) {
result.type = JSON_BOOL;
result.valid = true;
result.num = (*p == 't') ? 1.0 : 0.0;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else if (*p == 'n') {
if (match) {
result.type = JSON_NULL;
result.valid = true;
return result;
}
while (p < end && *p != ',' && *p != '}' && *p != ' ' && *p != '\n' && *p != '\r') p++;
} else {
char *val_start = p;
if (*p == '-') p++;
while (p < end && ((*p >= '0' && *p <= '9') || *p == '.' || *p == 'e' || *p == 'E' || *p == '+' || *p == '-')) p++;
if (match) {
/* 不就地写 NUL;strtod 可在非 NUL 终止串上工作(指定长度)。
* 但 strtod 需要终止符——用临时方式:记录起止,用 sscanf 或手动解析。
* 更简方案:buf 本身是 NUL 终止的(jsonParse 要求 NUL 终止输入),
* 而 *p 此处是数字后的分隔符(',' 或 '}' 或空白),不是 NUL。
* 我们临时保存 *p、写 NUL、解析、恢复 *p。 */
char saved = *p;
*p = '\0';
result.type = JSON_NUMBER;
result.valid = true;
result.num = strtod(val_start, nullptr);
*p = saved; /* 恢复,不影响后续 jsonGet 扫描 */
return result;
}
}
}
return result;
}
/* ── 便捷谓词(兼容 cJSON 调用风格)── */
static inline bool jsonIsString(const JsonView *item) {
return item && item->valid && item->type == JSON_STRING;
}
static inline bool jsonIsNumber(const JsonView *item) {
return item && item->valid && item->type == JSON_NUMBER;
}
static inline bool jsonIsBool(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL;
}
static inline bool jsonIsTrue(const JsonView *item) {
return item && item->valid && item->type == JSON_BOOL && item->num != 0.0;
}
/**
* @brief 获取字符串值指针和长度(字符串不一定 NUL 终止,须配合 str_len 使用)。
* @param item JSON 值视图
* @param out_len 可选,输出字符串长度
* @return 字符串指针(指向 buf 内部),或 nullptr
*/
static inline const char *jsonGetStringValue(const JsonView *item, size_t *out_len = nullptr) {
if (item && item->valid && item->type == JSON_STRING) {
if (out_len) *out_len = item->str_len;
return item->str;
}
if (out_len) *out_len = 0;
return nullptr;
}
/**
* @brief 比较字符串值是否等于指定 C 字符串。
* 因 jsonGet 不写 NUL 终止符,不能用 strcmp,须用此函数。
*/
static inline bool jsonStringEquals(const JsonView *item, const char *cstr) {
if (!jsonIsString(item) || !cstr) return false;
size_t clen = strlen(cstr);
return clen == item->str_len && strncmp(item->str, cstr, clen) == 0;
}
/**
* @brief 将字符串值拷贝到 NUL 终止的输出缓冲(安全截断)。
* @return 实际拷贝长度(不含 NUL)
*/
static inline size_t jsonGetStringCopy(const JsonView *item, char *out, size_t out_max) {
if (!jsonIsString(item) || !out || out_max == 0) return 0;
size_t n = (item->str_len < out_max - 1) ? item->str_len : out_max - 1;
memcpy(out, item->str, n);
out[n] = '\0';
return n;
}
static inline double jsonGetNumberValue(const JsonView *item) {
return (item && item->valid && item->type == JSON_NUMBER) ? item->num : 0.0;
}
#ifdef __cplusplus
}
#endif
#endif // JSON_PARSE_H
+10 -1
View File
@@ -299,7 +299,10 @@ esp_err_t WifiOta::initSta()
country.policy = WIFI_COUNTRY_POLICY_MANUAL;
wifi_config_t wifi_config = {};
wifi_config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
/* FAST_SCAN:扫到目标 SSID 即停并连接(多 AP 同名时取先扫到的,可能非最强)。
* 原 ALL_CHANNEL_SCAN 要把 13 信道全扫完才连,OTA 重启后页面恢复要多等 3~8s。
* esp_restart 不清 nvs.net80211 缓存,驱动可复用上次 AP 信息,恢复更快。 */
wifi_config.sta.scan_method = WIFI_FAST_SCAN;
wifi_config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
wifi_config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
wifi_config.sta.pmf_cfg.capable = true;
@@ -822,6 +825,12 @@ void WifiOta::taskEntry(void *pvParameters)
/* 启动日志 burst 已过:环 64→16 槽,释放大约 7.5KB 给业务堆 */
DebugLog::instance().shrinkRingAfterBoot();
/* ★ 重启完成判定信号(前端 polling.js 匹配 /OTA Relay .*启动/ + tag=MAIN):
* 必须在本函数(httpd 就绪、shrinkRingAfterBoot 之后)打印——main.cpp 的
* 早期 banner 在环缩容时会被裁掉,前端首轮 /logs 拉不到,只能干等 10s
* 安全阈值才恢复页面。此处是环内最新日志,前端一定能拉到。 */
DEBUG_INFO("MAIN", "ESP32-C3 OTA Relay v%s 启动", FW_VERSION);
// WiFi 状态监控
// 每秒一次循环:每 10 秒检查 RSSI
uint32_t tick = 0;
+57 -5
View File
@@ -14,6 +14,7 @@
#include "mbedtls/private/sha256.h"
#include "gd32_flash_store.h"
#include "app_config.h" // OTA_BLOCK_SIZE(flashWriteChunk 静态对齐缓冲容量)
#include "monitor/debug_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -147,6 +148,54 @@ esp_err_t Gd32FlashStore::ensureErasedForWrite(uint32_t offset, size_t len)
return ESP_OK;
}
// ============================================================
// OTA 期对齐缓冲池(2026-08 内存优化)
//
// flashWriteChunk 在非 4 字节对齐写入时需要临时缓冲读回 padding。
// 原实现每次 malloc/free,OTA 收流时与非对齐块叠加产生堆抖动。
// 现改为 OTA begin 时一次性 calloc,end/abort 时 free,消除抖动
// 又不增加常驻 BSS(非 OTA 期零占用)。
// ============================================================
static uint8_t *s_align_buf = nullptr;
static constexpr size_t kAlignBufCap = OTA_BLOCK_SIZE + 8;
void Gd32FlashStore::acquireAlignBufForOta()
{
if (!s_align_buf) {
s_align_buf = (uint8_t *)calloc(1, kAlignBufCap);
if (!s_align_buf) {
DEBUG_WARN("OTA", "对齐缓冲分配失败,回退 malloc free=%u",
(unsigned)esp_get_free_heap_size());
}
}
}
void Gd32FlashStore::releaseAlignBufForOta()
{
if (s_align_buf) {
free(s_align_buf);
s_align_buf = nullptr;
}
}
/** @brief 取对齐缓冲。优先用 OTA 期预分配;未分配或太小则临时 malloc。 */
static uint8_t *acquireAlignBuf(size_t needed)
{
if (s_align_buf && needed <= kAlignBufCap) {
return s_align_buf;
}
// OTA 未预分配(begin 失败)或包过大:回退临时 malloc
return (uint8_t *)malloc(needed);
}
/** @brief 归还对齐缓冲。OTA 池的不释放;临时 malloc 的 free。 */
static void releaseAlignBuf(uint8_t *buf)
{
if (buf && buf != s_align_buf) {
free(buf);
}
}
/** @brief 向 gd32_ota 分区写入数据块,自动处理非 4 字节对齐的读写。 */
esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len)
{
@@ -188,9 +237,12 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
offset, len, head_pad);
}
uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len);
/* OTA 期按需对齐缓冲(OTA begin 时 calloc,end/abort 时 free):
* 消除每非对齐块的 malloc/free 抖动,又不增加常驻 BSS。
* 容量 = OTA_BLOCK_SIZE(4KB) + 最多 3 字节 tail pad。 */
uint8_t *aligned_buf = acquireAlignBuf(aligned_len);
if (!aligned_buf) {
DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len);
DEBUG_ERR("OTA", "flashWriteChunk 对齐缓冲不足/未分配: %zu", aligned_len);
return ESP_ERR_NO_MEM;
}
@@ -200,7 +252,7 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回head失败: offset=%" PRIu32 " head=%zu err=%s",
aligned_offset, head_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
@@ -215,13 +267,13 @@ esp_err_t Gd32FlashStore::flashWriteChunk(uint32_t offset, const uint8_t *data,
if (err != ESP_OK) {
DEBUG_ERR("OTA", "flashWriteChunk读回tail失败: offset=%" PRIu32 " tail=%zu err=%s",
aligned_offset + total, tail_pad, esp_err_to_name(err));
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
}
esp_err_t err = esp_partition_write(part, aligned_offset, aligned_buf, aligned_len);
free(aligned_buf);
releaseAlignBuf(aligned_buf);
return err;
}
+6
View File
@@ -145,6 +145,12 @@ public:
*/
const esp_partition_t* getPartition();
// ── OTA 期对齐缓冲生命周期 ──
/** @brief GD32 OTA begin 时调用:预分配对齐缓冲(消除 flashWriteChunk 逐块 malloc)。 */
static void acquireAlignBufForOta();
/** @brief GD32 OTA end/abort 时调用:释放对齐缓冲(非 OTA 期零占用)。 */
static void releaseAlignBufForOta();
private:
Gd32FlashStore();
~Gd32FlashStore() = default;
+61 -8
View File
@@ -61,11 +61,13 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
uint8_t resp_type = 0U;
size_t resp_len = 0U;
esp_err_t ret = ESP_OK;
/* CAN 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
/* 0xC0 发送只会收到 ACK(0x80) 或 ERROR(0x82)。若读到 WAIT(0x81),必是上一轮电机 OTA
* 喂数(0xD1→WAIT)残留在 SPI 响应管道里的陈旧帧(0xC0 命令本身永不产生 WAIT)。
* 陈旧 WAIT 会被下方误判为 ERROR → resp_len=0 → gd32_err=0xFF → 无声拒绝发送(
* 表现为“失败一次就再也发不出去”)。此处最多重试几次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。 */
* 表现为“失败一次就再也发不出去”)。此处最多重试 4 次排空陈旧帧,
* 直到读到本命令真正的 ACK/ERROR。
* SPI 传输失败/无响应的重发已由 SpiMaster::otaSendAndPoll 统一处理
* (同 TX_SEQ,最多 3 次尝试),此处不再各自重试;GD32 拒绝(ERROR)同样不重试。 */
for (uint8_t attempt = 0U; attempt < 4U; ++attempt) {
resp_type = 0U;
resp_len = 0U;
@@ -73,8 +75,8 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
kSpiCanFrameType,
tx_buf, tx_len,
&resp_type, spi_rx, spi_rx_max, &resp_len, timeout_ms);
if (ret != ESP_OK || resp_type != SPI_OTA_RSP_WAIT) {
break; /* SPI 失败或已拿到真正响应(ACK/ERROR) */
if (resp_type != SPI_OTA_RSP_WAIT) {
break; /* 已拿到真正响应(ACK/ERROR);SPI 失败(ret!=OK)也退出交上层处理 */
}
vTaskDelay(pdMS_TO_TICKS(3)); /* 让 GD32 更新响应,再读一次排空陈旧 WAIT */
}
@@ -241,8 +243,10 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX)
? e.dlc : static_cast<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
const uint16_t entrySize = static_cast<uint16_t>(8U + dataLen);
if (offset + entrySize > len) {
// 线协议条目实际大小按原始 dlc 计算(CANFD 帧可能 64B),offset 必须按
// 实际条目推进,否则后续条目错位。data 拷贝按 dataLen 截断。
const uint16_t wireEntrySize = static_cast<uint16_t>(8U + e.dlc);
if (offset + wireEntrySize > len) {
break;
}
@@ -251,7 +255,7 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
e.data[d] = 0U;
}
offset += entrySize;
offset += wireEntrySize;
(*entryIdx)++;
}
@@ -264,6 +268,55 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
return 0;
}
/* ── 0xC1 请求/响应查询(2026-08 架构改造:替代被动推送缓存)── */
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx)
{
if (out == nullptr || entryIdx == nullptr) {
return -1;
}
if (channel >= CAN_CHANNEL_MAX || page > 1U) {
return -1;
}
uint8_t req[2] = { channel, page };
uint8_t resp_type = 0;
/* SPI v2 payload 上限 236B(256 帧 - 20 开销),与 GD32 kFeedbackPayloadLen 一致 */
uint8_t resp_buf[SPI_OTA_FRAME_MAX];
size_t resp_len = 0;
esp_err_t ret = SpiMaster::instance().transact(
0xC1, req, sizeof(req),
&resp_type, resp_buf, sizeof(resp_buf), &resp_len, 200U);
if (ret != ESP_OK) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u SPI失败: %s",
(unsigned)channel, (unsigned)page, esp_err_to_name(ret));
return -1;
}
if (resp_type == SPI_OTA_RSP_ERROR) {
uint8_t gd32_err = (resp_len > 0) ? resp_buf[0] : 0;
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u GD32拒绝 err=0x%02X",
(unsigned)channel, (unsigned)page, (unsigned)gd32_err);
return -2;
}
if (resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 非ACK type=0x%02X len=%zu",
(unsigned)channel, (unsigned)page, (unsigned)resp_type, resp_len);
return -1;
}
/* 解析复用现有 canFdParseFeedbackPage(payload 格式与推送一致) */
int parse_rc = canFdParseFeedbackPage(resp_buf, resp_len, out, entryIdx);
if (parse_rc != 0) {
DEBUG_WARN("CAN", "queryFeedback ch=%u p=%u 解析失败 rc=%d len=%zu",
(unsigned)channel, (unsigned)page, parse_rc, resp_len);
return -1;
}
return 0;
}
/**
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
* @return 0 成功;-1 参数非法或 SPI/应答失败。
+17 -1
View File
@@ -57,7 +57,7 @@ int canFdSend(can_channel_t channel, uint32_t ext_id,
// 不重复 ID 快照表(SPI 0xC3 协议配套)
// ============================================================
#define CANFD_ID_SNAPSHOT_MAX 16 ///< 每通道最大不重复 ID 条目数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数
#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数(CANFD 最大 64B,保持完整不截断)
/**
* @brief 单个不重复 ID 的最新帧快照条目。
@@ -131,6 +131,22 @@ typedef struct {
int canFdParseFeedbackPage(const uint8_t *payload, size_t len,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @brief 主动向 GD32 查询一页 CAN 反馈(SPI 0xC1 请求/响应)。
*
* 2026-08 架构改造:ESP32 不再被动缓存 GD32 推送,改为前端请求时按需查询。
* GD32 handleFeedback 接收 [channel, page] 返回 ACK + 页数据(与推送 payload 格式一致)。
* 查询成功后调 canFdParseFeedbackPage 解析,复用现有累积逻辑。
*
* @param channel 目标通道 0/1/2。
* @param page 页号 0 或 1(page 0 重置累积 + 填统计,page 1 追加 entries)。
* @param out 累积结果(跨 page 0/1 累积,调用方按通道维护)。
* @param entryIdx 当前已填充条目数(page 0 时被重置为 0)。
* @return 0 成功;-1 参数/SPI 失败;-2 GD32 返回 ERROR。
*/
int canFdQueryFeedback(uint8_t channel, uint8_t page,
can_feedback_result_t *out, uint8_t *entryIdx);
/**
* @brief 切换 GD32 三路 CAN 的接收过滤模式(SPI 0xC2)。
* @param mode 目标模式(0=全收,1=按 ID 过滤,2=CAN0 桥接到 1/2)。
+176 -42
View File
@@ -21,6 +21,7 @@
#include "esp_log.h"
#include "esp_random.h" // esp_random() for HELLO session_id
#include "esp_intr_alloc.h" // ESP_INTR_FLAG_IRAM
#include "esp_timer.h" // esp_timer_get_time():worker job 执行耗时采样
#include "spi_master.h"
#include "spi_link_v2.h"
#include "app_config.h"
@@ -67,8 +68,27 @@ static size_t spiRespReadBudget(void)
return SPI_APP_TX_BUF_SIZE;
}
/**
* @brief 已知"短响应"的主动请求类型:应答帧 ≤ ~70B(实测 0xA1 PING ~60-70B、
* DATA/REQ/FINISH ACK ~30-40B、0xB5/0xC0/0xC2/0xD1 ACK 均短)。
* 对这些类型的主动 poll 可把读预算从 256 收紧到 96B,省 ~160B 0xFF 填充时钟
* (@13.3MHz ≈ 96µs/帧)。排除 0xC3(ID 快照响应可能很长)与被动 0xC1 事件读。
*/
static bool isShortRespType(uint8_t type)
{
switch (type) {
case 0xA1U: case 0x02U: case 0x01U: case 0xF7U:
case 0xB0U: case 0xC0U: case 0xC2U: case 0xD1U:
return true;
default:
return false;
}
}
/** 短响应读预算(覆盖最长 ~70B 应答 + 余量)。 */
static constexpr size_t kShortRespReadBudget = 96U;
/** APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type)
bool SpiMaster::isAppOnlySpiType(uint8_t type)
{
switch (type) {
case 0xC1U: /* CAN feedback */
@@ -85,6 +105,34 @@ static bool isAppOnlySpiType(uint8_t type)
}
}
/* ── 去重辅助实现 ── */
void SpiMaster::copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len)
{
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
} else if (copy_len > 0) {
memset(resp_payload, 0, copy_len);
}
}
if (resp_len) *resp_len = found_len;
}
void SpiMaster::correctRoleFromType(uint8_t found_type, const char *src)
{
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X, %s)",
(unsigned)m_gd32Role, found_type, src);
m_gd32Role = V2_ROLE_APP;
}
}
/** @brief ISR-safe GPIO 电平读取(不调用 Flash 中的 gpio_get_level)。
* 用于 SPIready 边沿中断,避免在 cache 被禁用期间访问 Flash 代码。
*/
@@ -189,18 +237,52 @@ bool SpiMaster::popEvent(uint8_t *type, uint8_t *payload, size_t max_len, size_t
}
/**
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理。
* @param suppress true=OTA 等关键路径禁止偷读 PB6;false=恢复
* @brief 暂停/恢复对 GD32 主动按铃的 serviceRing 处理(引用计数)。
* @param suppress true=+1(OTA 等关键路径禁止偷读 PB6);false=-1(钳位到 0)。
*
* GD32 中继(forwardFirmware)与电机 OTA feedTask 都可能持有抑制;任一
* 持有者退出时若直接清位,会把另一方的保护误清(电机 OTA 收尾曾把进行中
* 的 GD32 中继抑制清掉 → worker 偷读 OTA ACK/WAIT → poll 空等 6s)。
*/
void SpiMaster::setSuppressRingService(bool suppress)
{
m_suppressRingService = suppress;
taskENTER_CRITICAL(&s_spiJobMux);
uint32_t refs = m_ringSuppressRefs;
if (suppress) {
++refs;
} else if (refs > 0U) {
--refs;
}
m_ringSuppressRefs = refs;
const bool now_suppressed = (refs > 0U);
taskEXIT_CRITICAL(&s_spiJobMux);
m_suppressRingService = now_suppressed;
if (now_suppressed) {
/* 丢弃已锁存的按铃标志,避免解除后立刻误读残留边沿 */
m_gd32RequestPending = false;
}
}
/**
* @brief ESP32 自我 OTA 期间暂停/恢复全部 SPI 活动。
*
* 暂停:worker 空转不读 SPI(GD32 按铃丢弃),submitJob 快速拒绝
* (业务调用立即返回 ESP_ERR_INVALID_STATE,不排队不阻塞)。
* 恢复:唤醒 worker 恢复正常。幂等,可多次调用。
*/
void SpiMaster::setSpiPaused(bool paused)
{
m_spiPaused = paused;
if (paused) {
/* 丢弃已锁存的按铃标志:暂停期间不读 GD32,解除后勿误读残留边沿 */
m_gd32RequestPending = false;
}
if (m_workerTask != nullptr) {
xTaskNotify(m_workerTask, 0x10U, eSetBits); /* 唤醒 worker 处理状态变化 */
}
}
/**
* @brief 业务侧确认本端 TX 序号已被对端接受,推进 m_txSeq。
* @note 在收到明确业务 ACK 后调用,避免重发与序号失配。
@@ -278,11 +360,7 @@ esp_err_t SpiMaster::serviceRingOnce()
{
LockGuard lock(m_mutex);
/* APP 独有事件 → 纠正粘滞的 boot role(OTA 后 HELLO 未刷新时常见) */
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
correctRoleFromType(found_type, "ring");
(void)pushEvent(found_type, found_payload, found_len);
}
return ESP_OK;
@@ -428,6 +506,10 @@ void SpiMaster::finishPoolJob(Job *job)
*/
esp_err_t SpiMaster::submitJob(Job &job)
{
/* ESP32 自我 OTA 暂停:快速拒绝,不排队不等待(worker 空转中)。 */
if (m_spiPaused) {
return ESP_ERR_INVALID_STATE;
}
if (m_jobQueue == nullptr || m_workerTask == nullptr || onWorker()) {
executeJob(job);
return job.result;
@@ -529,6 +611,15 @@ void SpiMaster::workerLoop()
DEBUG_INFO("SPI", "SPI worker started, handle=%p", (void *)m_workerTask);
for (;;) {
/* ESP32 自我 OTA 暂停:空转不读 SPI(flash 写期间 cache 失效会拖慢
* flash 驻留代码,且此时 GD32 数据无意义)。恢复由 setSpiPaused(false)
* 通知唤醒。 */
if (m_spiPaused) {
m_gd32RequestPending = false;
uint32_t bits = 0U;
(void)xTaskNotifyWait(0U, 0xFFFFFFFFU, &bits, pdMS_TO_TICKS(50));
continue;
}
/* ★ 只在 ISR 置位 m_gd32RequestPending 时读 EVENT。
* 禁止见 DATA_READY 低就 serviceRing:命令 HELLO_ACK/OTA 响应也会拉低
* PB6,会被偷走当事件 → 业务 poll 全 0xFF,OTA/ENTER_BOOT 全挂。
@@ -541,7 +632,16 @@ void SpiMaster::workerLoop()
Job *job = nullptr;
if (xQueueReceive(m_jobQueue, &job, pdMS_TO_TICKS(1)) == pdTRUE && job != nullptr) {
/* 计时 worker 实际执行段(取帧→发→读响应→完成):
* 测速页 RTT − 本值 = 提交/调度/唤醒开销,用于区分
* "ESP32 侧任务抢占" vs "GD32 应答慢" 的抖动归属。 */
const int64_t w0 = esp_timer_get_time();
m_lastProfile.t_w0 = (uint64_t)w0;
executeJob(*job);
const int64_t w1 = esp_timer_get_time();
m_lastProfile.t_w1 = (uint64_t)w1;
const int64_t wus = w1 - w0;
m_lastWorkerExecUs = (wus > 0) ? (uint32_t)wus : 0U;
finishPoolJob(job);
continue;
}
@@ -698,7 +798,9 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
t.tx_buffer = tx_ptr;
t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL;
/* SPI 事务等待使用有界超时 */
/* 中断模式(queue+result 信号量):2026-08-02 实测 polling 模式在 C3 单核上
* 整体更慢——传输段虽略快(-14~-17µs),但 worker 忙等占死 CPU,提交/调度
* 13→140µs、唤醒 18→76µs、2-5ms 尾巴 23→129 帧,RTT 658→822µs。回退。 */
const uint32_t effective_ms = (timeout_ms == 0U) ? 500U
: (timeout_ms < 50U ? 50U : timeout_ms);
const TickType_t queue_wait = pdMS_TO_TICKS(effective_ms);
@@ -755,7 +857,7 @@ esp_err_t SpiMaster::transferClockedFlags(const uint8_t *tx_buf, size_t tx_len,
* 时钟预算见 spiRespReadBudget():BOOT=128,APP=256。
*/
esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms)
uint32_t timeout_ms, size_t budget_hint)
{
if (out_frame == nullptr || out_len == nullptr || out_max < V2_HEADER_LEN) {
return ESP_ERR_INVALID_ARG;
@@ -767,6 +869,9 @@ esp_err_t SpiMaster::readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_
*out_len = 0U;
size_t budget = spiRespReadBudget();
if (budget_hint > 0U && budget_hint < budget) {
budget = budget_hint; /* 主动 poll 按请求类型收紧,省 0xFF 填充时钟 */
}
if (budget > out_max) {
budget = out_max;
}
@@ -1069,7 +1174,11 @@ esp_err_t SpiMaster::init()
}
}
if (m_workerTask == nullptr) {
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 6, &m_workerTask);
/* 优先级 8(2026-08-02 测速实验):原 6 会被 ota_feed(7) 抢占,
* SPI 完成 ISR 唤醒 worker 后还要等 feed 让出,拖长 RTT 唤醒链。
* 8 仍低于 health_mon(21)/ota_relay(23);worker 阻塞循环为主,
* 仅 job 执行时运行,不会饿死 ota_feed/httpd。 */
BaseType_t ok = xTaskCreate(workerEntry, "spi_worker", TASK_STACK_SPI_WORKER, this, 8, &m_workerTask);
if (ok != pdPASS || m_workerTask == nullptr) {
DEBUG_ERR("SPI", "SPI worker task create failed");
m_workerTask = nullptr;
@@ -1124,6 +1233,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) {
return ESP_ERR_INVALID_ARG;
}
/* 记录最近一次主动请求类型:poll 读预算按它收紧(isShortRespType)。 */
m_lastReqType = type;
DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u", type, (unsigned)tx_len);
@@ -1194,17 +1305,8 @@ esp_err_t SpiMaster::otaTransferUnlocked(uint8_t type, const uint8_t *payload, s
/* 与 Git 稳定路径一致:step1 陈旧帧原样解析即可(otaSendAndPoll 传 nullptr 丢弃)。
* 勿在此处过滤 LINK/EVENT,否则改变 delayed-by-one 语义。 */
if (otaFindFrame(rx_raw, xfer_len, &found_type, &found_payload, &found_len, &frame_offset)) {
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
}
}
if (resp_len) *resp_len = found_len;
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
m_gd32Role = V2_ROLE_APP;
}
copyFoundFrame(resp_type, found_type, resp_payload, resp_max, found_payload, found_len, resp_len);
correctRoleFromType(found_type, "send");
/* expect_stale(两步法 step1)读到的是延迟一拍陈旧帧,序号由 poll 成功时推进。
* 此处若自增会导致 job 间隙/超时后 resend 使用错误 TX_SEQ。 */
if (!expect_stale) {
@@ -1286,7 +1388,12 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
? ((timeout_ms >= 80U) ? 40U : timeout_ms)
: 15U;
const uint32_t wait_ms = (ready_slice < remain_ms) ? ready_slice : remain_ms;
/* 分段剖析:等 READY 段 = GD32 收到帧→拉低 PB6 的处理时间 */
m_lastProfile.t_wait0 = (uint64_t)esp_timer_get_time();
const bool ready = (waitSpiDataReady(wait_ms) == ESP_OK);
if (ready) {
m_lastProfile.t_wait1 = (uint64_t)esp_timer_get_time();
}
now = xTaskGetTickCount();
remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U;
@@ -1303,7 +1410,12 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
uint8_t rx_raw[SPI_OTA_FRAME_MAX];
size_t rx_len = 0U;
esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout);
/* 读预算按最近请求类型收紧(短响应类型 96B,其余按角色默认) */
const size_t budget_hint = isShortRespType(m_lastReqType) ? kShortRespReadBudget : 0U;
esp_err_t ret = readV2FrameVarlen(rx_raw, sizeof(rx_raw), &rx_len, spi_timeout, budget_hint);
if (ret == ESP_OK) {
m_lastProfile.t_read1 = (uint64_t)esp_timer_get_time();
}
did_spi_read = true;
if (ret != ESP_OK) {
if (ret == ESP_ERR_INVALID_RESPONSE) {
@@ -1347,11 +1459,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
}
if ((found_flags & V2_FLAG_EVENT) != 0U) {
if (isAppOnlySpiType(found_type) && m_gd32Role != V2_ROLE_APP) {
DEBUG_INFO("SPI", "role %u→APP (poll saw type=0x%02X)",
(unsigned)m_gd32Role, found_type);
m_gd32Role = V2_ROLE_APP;
}
correctRoleFromType(found_type, "poll");
DEBUG_DBG("SPI", "poll skip EVENT type=0x%02X", found_type);
vTaskDelay(pdMS_TO_TICKS(1));
continue;
@@ -1365,16 +1473,7 @@ esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_p
continue;
}
if (resp_type) *resp_type = found_type;
if (resp_payload && resp_max > 0) {
size_t copy_len = (found_len < resp_max) ? found_len : resp_max;
if (found_payload && copy_len > 0) {
memcpy(resp_payload, found_payload, copy_len);
} else if (copy_len > 0) {
memset(resp_payload, 0, copy_len);
}
}
if (resp_len) *resp_len = found_len;
copyFoundFrame(resp_type, found_type, resp_payload, resp_max, found_payload, found_len, resp_len);
/* WAIT/ACK/ERROR:只要 ack_seq 匹配,GD32 已消费本 TX_SEQ,必须推进。
* ★ 电机 OTA:feedData 缓冲满回 WAIT 时若不清 seq,ESP32 同 seq 重发
@@ -1429,7 +1528,11 @@ esp_err_t SpiMaster::transact(uint8_t type, const uint8_t *payload, size_t paylo
job.out_payload = resp_payload;
job.out_max = resp_max;
job.out_len = resp_len;
return submitJob(job);
/* 分段剖析:记录调用方侧两个时间戳(worker 侧在 workerLoop/otaSendAndPoll 内写) */
m_lastProfile.t_submit0 = (uint64_t)esp_timer_get_time();
const esp_err_t p_ret = submitJob(job);
m_lastProfile.t_submit1 = (uint64_t)esp_timer_get_time();
return p_ret;
}
return otaSendAndPoll(type, payload, payload_len,
resp_type, resp_payload, resp_max, resp_len, timeout_ms);
@@ -1512,10 +1615,28 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
poll_timeout = 1U;
}
/* 统一重发机制(嵌入 worker,所有业务共用,调用方无需各自重试):
* 仅当"没读到任何有效响应"时同 TX_SEQ 重发(失败路径 seq 不推进):
* - 发送级失败(transferClocked 错误/credit 拒绝):帧大概率未送达 GD32;
* - poll 超时(无响应):帧可能未送达或响应丢失,重发靠 GD32 DUP-SEQ 去重兜底。
* 读到任何帧(WAIT/ACK/ERROR/陈旧)即成功返回,不重发:
* - 保证 GD32 明确拒绝(ERROR 0x82)天然不重试(如 CAN 负载高/BUS_OFF);
* - OTA relay 的 WAIT 语义、旧帧排空语义均不受影响。 */
constexpr uint32_t kTxRetryMax = 3U; /* 总尝试次数(1 次首发 + 2 次重发) */
for (uint32_t attempt = 0U; attempt < kTxRetryMax; ++attempt) {
m_lastProfile.t_send0 = (uint64_t)esp_timer_get_time();
esp_err_t send_ret = otaTransferUnlocked(type, payload, payload_len,
nullptr, nullptr, 0U, nullptr, send_timeout,
/*expect_stale=*/true);
m_lastProfile.t_send1 = (uint64_t)esp_timer_get_time();
if (send_ret != ESP_OK && send_ret != ESP_ERR_INVALID_RESPONSE) {
if (attempt + 1U < kTxRetryMax) {
DEBUG_DBG("SPI", "TX retry(%u/%u) send fail type=0x%02X %s",
(unsigned)(attempt + 1U), (unsigned)kTxRetryMax,
type, esp_err_to_name(send_ret));
vTaskDelay(pdMS_TO_TICKS(3));
continue;
}
return send_ret;
}
@@ -1531,8 +1652,21 @@ esp_err_t SpiMaster::otaSendAndPoll(uint8_t type, const uint8_t *payload, size_t
}
/* 步骤 2: 轮询读取真实响应(变长读,不重发命令) */
return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, poll_timeout,
/*expected_type=*/0xFFU);
esp_err_t poll_ret = otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len,
poll_timeout, /*expected_type=*/0xFFU);
if (poll_ret == ESP_OK) {
return ESP_OK; /* 已读到有效响应帧(ACK/ERROR/陈旧) */
}
if (attempt + 1U < kTxRetryMax) {
DEBUG_DBG("SPI", "TX retry(%u/%u) no resp type=0x%02X %s",
(unsigned)(attempt + 1U), (unsigned)kTxRetryMax,
type, esp_err_to_name(poll_ret));
vTaskDelay(pdMS_TO_TICKS(3));
continue;
}
return poll_ret;
}
return ESP_ERR_TIMEOUT; /* 不可达:循环必有返回 */
}
/**
+60 -1
View File
@@ -100,6 +100,31 @@ public:
/** @brief SPI worker 任务句柄(栈水位 /system/status 与 health 巡检用)。 */
TaskHandle_t getWorkerTaskHandle() const { return m_workerTask; }
/** @brief 最近一次 worker job 实际执行耗时(µs,worker 内计时)。
* 测速页用 RTT − 本值拆出"提交/调度/唤醒"开销,定位抖动归属。 */
uint32_t lastWorkerExecUs() const { return m_lastWorkerExecUs; }
/**
* @brief 单次 transact 的分段耗时剖析(测速页诊断用)。
* 5 段:提交/调度(t_submit0→t_w0)、发送帧(t_send0→t_send1)、
* 等应答(t_wait0→t_wait1)、读响应(t_wait1→t_read1)、唤醒(t_w1→t_submit1)。
* 写入顺序由 done 信号量保证 happens-before(worker 先写全再 give,调用方 take 后读)。
*/
struct SpiProfile {
uint64_t t_submit0 = 0; ///< 调用方 transact 入口
uint64_t t_w0 = 0; ///< worker executeJob 开始
uint64_t t_send0 = 0; ///< 发送帧开始
uint64_t t_send1 = 0; ///< 发送帧结束
uint64_t t_wait0 = 0; ///< 进入 poll(等 READY)
uint64_t t_wait1 = 0; ///< READY 就绪(wait 段结束)
uint64_t t_read1 = 0; ///< 响应帧读取完成
uint64_t t_w1 = 0; ///< worker executeJob 结束
uint64_t t_submit1 = 0; ///< 调用方 submitJob 返回
};
/** @brief 最近一次 transact 的剖析快照(仅测速/诊断用)。 */
SpiProfile lastProfile() const { return m_lastProfile; }
/** @brief SPI 任务队列当前待处理 job 数(固定 Job pool 槽,无堆分配)。
* 供 /system/heap_low_log 堆低水位快照采样,定位"低谷是不是 SPI 队列堆积引起"。 */
UBaseType_t getPendingJobCount() const { return m_jobQueue ? uxQueueMessagesWaiting(m_jobQueue) : 0; }
@@ -107,9 +132,18 @@ public:
/**
* @brief OTA 中继期间禁止 worker 将 DATA_READY 当 EVENT 读取。
* 中继开始设 true,结束/复位设 false。
* 实现为引用计数:GD32 中继与电机 OTA feed 可并发持有,任一持有者
* 退出都不会提前解除抑制(避免电机 OTA 收尾把 GD32 中继的抑制误清)。
*/
void setSuppressRingService(bool suppress);
/**
* @brief ESP32 自我 OTA 期间暂停全部 SPI 活动(GD32 按铃读 + 业务 transact)。
* 暂停后 worker 空转不读 SPI,submitJob 快速返回 ESP_ERR_INVALID_STATE
* (业务立即失败,不阻塞排队);恢复时唤醒 worker 恢复正常。幂等。
*/
void setSpiPaused(bool paused);
/**
* @brief 业务层确认当前请求已成功时推进/对齐 m_txSeq。
*/
@@ -282,12 +316,22 @@ private:
SpiLinkState m_linkState;
/** GD32 主动按铃标志:GPIO 下降沿 ISR 置位,仅 SPI worker 消费。 */
volatile bool m_gd32RequestPending = false;
/** ring service 抑制引用计数(setSuppressRingService 内部使用,临界区保护)。 */
uint32_t m_ringSuppressRefs = 0;
/** OTA 中继时为 true:worker 不 serviceRing,避免偷读 OTA ACK/WAIT。 */
volatile bool m_suppressRingService = false;
/** ESP32 自我 OTA 期间为 true:worker 空转、submitJob 快速拒绝。 */
volatile bool m_spiPaused = false;
/** 最近一次主动请求类型(otaTransferUnlocked 发送时记录;poll 读预算按它收紧)。 */
volatile uint8_t m_lastReqType = 0xFFU;
/** SPI worker 任务句柄(ISR 唯一通知目标)。 */
TaskHandle_t m_spiTaskHandle = NULL;
TaskHandle_t m_workerTask = NULL;
QueueHandle_t m_jobQueue = NULL;
/** 最近一次 job 执行耗时(µs,worker 写入、业务只读;单核 32 位读原子)。 */
uint32_t m_lastWorkerExecUs = 0;
/** 最近一次 transact 分段剖析(worker 与调用方交叉写入,done 信号量同步)。 */
SpiProfile m_lastProfile;
/** 事件计数信号量(worker push / takeEvent pop)。 */
SemaphoreHandle_t m_eventSem = NULL;
@@ -405,6 +449,19 @@ private:
/** @brief GPIO 边沿中断回调(注册到 ESP-IDF GPIO ISR 服务)。 */
static void IRAM_ATTR onReadyEdgeISR(void *arg);
/** @brief APP 独有业务 TYPE:见到即可纠正 m_gd32Role(BOOT 不会发这些)。 */
static bool isAppOnlySpiType(uint8_t type);
// ── 去重辅助 ──
/** @brief 把 otaFindFrame 找到的帧拷贝到调用方输出缓冲(安全截断)。 */
static void copyFoundFrame(uint8_t *resp_type, uint8_t found_type,
uint8_t *resp_payload, size_t resp_max,
const uint8_t *found_payload, size_t found_len,
size_t *resp_len);
/** @brief 见到 APP 独有 TYPE 时纠正粘滞的 boot role(3 处调用合并)。 */
void correctRoleFromType(uint8_t found_type, const char *src);
// 内部 SPI 传输
/**
* @brief 执行底层 SPI 时钟同步传输。
@@ -434,9 +491,11 @@ private:
* @param out_max 输出缓冲容量
* @param out_len 实际帧长
* @param timeout_ms 单次 SPI 超时
* @param budget_hint 读预算提示(0=按角色默认;>0 时取 min(hint, 角色预算),
* 主动 poll 按最近请求类型收紧,省 0xFF 填充时钟)
*/
esp_err_t readV2FrameVarlen(uint8_t *out_frame, size_t out_max, size_t *out_len,
uint32_t timeout_ms);
uint32_t timeout_ms, size_t budget_hint = 0U);
// 无锁内部版本(供已持锁的公共方法调用,避免递归死锁)
/**
+4 -4
View File
@@ -12,10 +12,10 @@ CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
# 不要再开 QIO,除非确认 flash 型号与硬件连接均兼容。
CONFIG_ESPTOOLPY_FLASHMODE_DIO=y
# DCU SPI clock -- 8 MHz(2026-07-31 提速:事件驱动握手后时钟占周期 ~78%,
# 8MHz 实测测速页/relay/PING 零错误后启用;半周期 62.5ns,GD32 从机 MISO
# 输出延迟 + ESP32 GPIO 矩阵输入延迟 ~45ns 仍有余量;≥16MHz 全双工不可行)
CONFIG_DCU_SPI_CLOCK_HZ=8000000
# DCU SPI clock -- 13.3 MHz(2026-08-02:80MHz/6 整除,比 12M 再省 ~8%;
# 半周期 37.5ns,MISO 输出延迟 + GPIO 矩阵延迟 ~45ns 预算已近上限,需实测;
# ≥16MHz 全双工不可行。写 13333333:若写 13000000 驱动取整 80/7=11.4M 反而更慢)
CONFIG_DCU_SPI_CLOCK_HZ=13333333
# GD32 OTA 包间节奏:不用固定 PACKET_INTERVAL;依赖 SpiMaster CREDIT 背压
# (flash 槽满时 CREDIT=0,等 CREDIT_UPDATE / 帧头 credit 恢复后再发)
+23
View File
@@ -0,0 +1,23 @@
@echo off
REM Clear MSYS/MINGW env vars to bypass IDF MSys detection when called from Git Bash
set MSYSTEM=
set MSYSTEM_CHOST=
set MSYSTEM_PREFIX=
set MINGW_CHOST=
set MINGW_PREFIX=
set MINGW_PACKAGE_PREFIX=
set CHERE_INVOKING=
set MSYS2_PATH_TYPE=
REM Toolchain installed at C:\Espressif (see .vscode/settings.json idf.toolsPath)
set IDF_TOOLS_PATH=C:\Espressif
call "F:\software\.espressif\v6.0\esp-idf\export.bat"
if errorlevel 1 (
echo EXPORT_FAILED
exit /b 1
)
cd /d "G:\Robot_v2\DCU\ESP32_COMM"
idf.py build
exit /b %errorlevel%
+13 -5
View File
@@ -216,11 +216,19 @@ export async function pollStatus() {
if (window.isEsp32Rebooting ||
window.otaSessionState === window.OtaSessionState.REBOOTING) {
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询,
// 保持"重启中"状态,不清除标志,不推进阶段。
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner 触发;
// 这里只在已观察到下线(rebootObservedDown)或超 10s 安全阈值时接受恢复。
if (window.rebootObservedDown || rbElapsed >= 10) {
/* 重启完成的证据(任一即可):
* 1) rebootObservedDown:已观察到设备下线后恢复。
* 2) freshBoot:设备回报 uptime 很小(<120s,与 resolveEsp32VerifyAmbiguous
* 的 freshBoot 阈值一致)。★ 关键:设备掉线仅 3~5s 时(FAST_SCAN 生效后
* 常见),在途轮询的 TCP SYN 重传会在设备起来后完成握手、全部成功,
* rebootObservedDown 永远不置位,旧逻辑只能干等 10s 兜底——
* 实测设备 3s 就绪、页面却 10s 才恢复。uptime 是新固件启动的权威信号。
* 3) rbElapsed >= 10:安全兜底(兼容 uptime 缺失 / 设备未真重启)。
* pre-restart 窗口(finalize 响应后约 1s 设备仍在跑旧固件)uptime 很大,
* 不会误判为 freshBoot。 */
var upS = Number(data && data.uptime_sec);
var freshBoot = Number.isFinite(upS) && upS >= 0 && upS < 120;
if (window.rebootObservedDown || freshBoot || rbElapsed >= 10) {
markEsp32RebootComplete();
}
}
+3 -3
View File
@@ -113,15 +113,15 @@
function addHistory(frames, payload, text) {
/* 从设备中文文本里提取关键数字做历史行 */
var fail = (text.match(/失败:\s*(\d+)/) || [])[1] || "?";
var avg = (text.match(/平均\s*([\d.]+)\s*ms/) || [])[1] || "?";
var min = (text.match(/最快\s*([\d.]+)\s*ms/) || [])[1] || "?";
var avg = (text.match(/平均\s*([\d.]+)\s*µs/) || [])[1] || "?";
var min = (text.match(/最快\s*([\d.]+)\s*µs/) || [])[1] || "?";
var tput = (text.match(/等效吞吐:\s*([\d.]+)\s*KB\/s/) || [])[1] || "-";
var d = new Date();
var tr = document.createElement("tr");
var cells = [
pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()),
String(frames), String(payload) + "B", fail,
avg + "ms", min + "ms", tput === "-" ? "-" : tput + "KB/s"
avg + "µs", min + "µs", tput === "-" ? "-" : tput + "KB/s"
];
for (var i = 0; i < cells.length; i++) {
var td = document.createElement("td");