简介:面向基于GD32H737的多目标实时识别与跟踪应用,这套工程资源完整覆盖从外设驱动、模型配置到人机交互的嵌入式AI实现链路,适合有一定MCU开发基础、希望掌握边缘端目标检测部署的工程师与研究者。资源共795个文件,以447个C头文件、79个C源文件为核心,配合Keil工程配置、链接脚本、编译中间文件及烧录Hex,压缩包整体27.55MB,目录结构清晰,便于二次开发。系统以OV2640采集240×240图像,完成车辆识别后显示于LCD,并通过UART与外部设备通信;在GD32H737上实测识别精度达95%,多目标跟踪最高9fps;实验使用VisDrone与500张车辆图片数据集,小目标mAP较原模型提高122%,mAP50提高73%。目前已有76人学习。借助该工程可掌握模型优化部署、外设驱动移植、多目标跟踪等思路,也可参考两个数据集和不同模型下的实验对比,理解小目标检测性能提升方法,便于快速移植到自有嵌入式AI项目中。
1. 基于GD32的多目标实时识别系统:为什么能在一颗Cortex-M内核上同时跑识别和跟踪
多目标实时识别这种活,常规思路是上树莓派或者专用NPU板子,但很多产线设备、巡检机器人、消费电子场景,成本卡死在几十块钱,环境又是3.3V单板、没有Linux、没有NPU。这份基于GD32的多目标实时识别系统,就是在GD32F470这颗240MHz的Cortex-M4上,用int8量化的轻量检测网络加一套帧间匹配跟踪逻辑,把多目标检测、跟踪、显示完整跑通,稳定输出3~5FPS。它不追PC端YOLO的精度,而是解决“必须本地识别、预算极低、又要多目标”这一整块空白。适合正在做低成本视觉节点、刚接触MCU端AI的嵌入式工程师,源码、原理图、模型转换脚本和踩坑记录都齐,照着手抄比自己从零写快得多。
2. 系统拆解与硬件选型:一帧图像从摄像头到LCD要经过几条总线
拿到一份嵌入式AI项目,我第一件事不是看算法,是先看数据通路。摄像头像素怎么进内存、CPU怎么取、显示怎么刷,这三条路只要有一条打架,后面全是玄学问题。
2.1 主控选型:F470还是H7,先看帧率预算
GD32这边能扛AI推理的主要是F4和H7两个系列,资源包主工程基于GD32F470,另附H7移植说明。两个型号关键差异先放一张表:
| 参数 | GD32F470 | GD32H7 |
|---|---|---|
| 内核 | Cortex-M4 @ 240MHz | Cortex-M7 @ 550MHz |
| Flash | 最大3MB | 最大2MB |
| SRAM | 最大768KB | 最大1MB |
| FPU/DSP指令 | 有/有 | 有/有 |
| 典型成本 | 低,供货稳 | 高一个档次 |
| 适合 | 3~5FPS检测+跟踪 | 需要10FPS以上或更高分辨率 |
选F470做主力,理由很实际:F4系列的固件库、例程、第三方驱动都比H7成熟,资料里踩坑记录能直接对上;240MHz跑一个96×96输入的轻量网络,推理时间大约400ms,加上后处理和绘制刚好压在3FPS这条线上。如果你的需求是5FPS以上,别再纠结F4,直接上H7,主频翻倍多,卷积那部分耗时几乎线性下降。我一般先用GD32CubeMX把时钟树配好——F470拉满到240MHz需要开FPU、配HXTAL+PLL,这一步做不对后面所有时序都是错的。
2.2 摄像头与DMA双缓冲:320×240的RGB565怎么搬进内存
图像源用的是OV2640,DVP接口输出RGB565。分辨率取320×240,一帧裸数据是320×240×2=153600字节,25FPS下数据量约3.84MB/s,对DMA来说很轻松,真正的瓶颈在CPU做卷积。用640×480只会让DMA峰值带宽翻倍、帧缓存占内存翻两倍,检测输入只要96×96,多出来的像素大部分被下采样丢掉,准确率提升有限——这是血泪经验,能不上高分辨率就不上。
DVP采集用DMA双缓冲,DMA写完一块内存就切到另一块,CPU同时处理上一帧。核心配置:
// 双缓冲:DMA 在 frame_buf[0] 和 frame_buf[1] 之间轮换 static uint16_t frame_buf[2][320 * 240]; static uint8_t active_buf = 0; // DMA 当前写哪一块 void dvp_dma_config(void) { dma_config_struct dma_para; dma_para.periph_addr = (uint32_t)&DVP->DR; // DVP 数据寄存器 dma_para.memory_addr = (uint32_t)frame_buf[0]; // 首块 dma_para.periph_inc = DMA_PERIPH_INCREASE_DISABLE; dma_para.memory_inc = DMA_MEMORY_INCREASE_ENABLE; dma_para.periph_memory_width = DMA_PERIPH_WIDTH_16BIT; // RGB565 每像素16位 dma_para.direction = DMA_PERIPH_TO_MEMORY; dma_para.number = 320 * 240; dma_init(DMA_CH0, &dma_para); } void DMA0_Channel0_IRQHandler(void) { if (dma_interrupt_flag_get(DMA_CH0, DMA_INT_FLAG_FTF)) { dma_interrupt_flag_clear(DMA_CH0, DMA_INT_FLAG_FTF); frame_ready = 1; // 只置标志,不在中断里做识别 active_buf ^= 1; // 切到另一块 dma_memory_address_config(DMA_CH0, (uint32_t)frame_buf[active_buf]); } }注意两点:一是中断里绝不跑检测,置个flag让主循环去处理,否则中断服务函数占用时间一长,DMA下一帧就追尾;二是dma_memory_address_config切buffer时,要确认DMA当前通道没在传输中,否则写入的地址可能被覆盖。主循环的节奏是:等frame_ready→把frame_buf[active_buf^1]拷到下采样缓冲区→转成96×96×3的uint8输入张量→跑推理。
2.3 资源包里到底有什么:工程文件与目录说明
资源包解压后建议先按功能理一遍,我拿到的整理如下(个人习惯归档,可能和原包略有出入):
| 目录/文件 | 内容 | 用途 |
|---|---|---|
| firmware/ | Keil MDK工程,含GD32CubeMX生成的外设初始化 | 主工程,直接用MDK打开 |
| firmware/drivers/ | ov2640.c、lcd_st7789.c、dma_dvp.c | 摄像头、LCD、DMA驱动 |
| firmware/model/ | weights_int8.h、model_meta.h | 量化权重和网络结构参数 |
| firmware/app/ | detect.c、track.c、ui.c | 推理、跟踪、绘制三大模块 |
| tools/ | convert_tflite.py、quant_calib.py | 模型转换与量化脚本 |
| docs/ | 原理图PDF、BOM、接线表、移植说明 | 硬件参考和H7移植指导 |
线程调度上主循环是裸机轮询,三个状态:等帧、推理、绘制,单帧内不打断。LCD用SPI接口的ST7789,240×240分辨率,开一个局部窗口刷新检测框区域,比整屏刷新省一半时间。
3. 模型落地:把检测网络量化压进MCU的三个关键步骤
主控选好、数据通路打通,接下来是整包资源的核心价值所在——模型怎么从PC端的重量级网络变成MCU上能跑的几百KB权重。这一步处理不好,前面硬件全白搭。
3.1 网络选型:96×96输入和300KB权重是怎么算出来的
先说结论:MCU端不能直接搬Tiny YOLO。Tiny YOLOv2权重int8量化后仍有15MB量级,单帧计算量超过5G次乘加,F470跑一帧要几十秒,谈不上实时。项目里用的是96×96输入、3个anchor、3个类别的微型检测头,权重int8后大约300KB,单帧乘加约1.2亿次。Cortex-M4有DSP指令但没有int8向量点积单元,实际有效算力大概30~50M次乘加/秒,1.2亿次算下来就是400ms上下,和实测吻合。
| 方案 | 权重int8 | 单帧乘加 | F470估算帧率 | 结论 |
|---|---|---|---|---|
| 96×96微型检测头 | ~300KB | ~1.2亿 | 2~4FPS | 本工程采用 |
| 128×128同类网络 | ~400KB | ~2.2亿 | 1~2FPS | 备选,漏检率更低 |
| Tiny YOLOv2直接量化 | >15MB | >58亿 | 不可实时 | 放弃 |
输入分辨率越小,小目标漏检越严重,所以模型训练时就要在96×96尺度上做数据增强,而不是用大图训练完再硬缩。这个选型逻辑直接决定了后续所有参数。
3.2 量化与导出:TFLite int8校准集决定识别置信度
模型训练在PC端完成,格式是Keras的h5,部署前走一遍TFLite int8量化。量化最关键的不是那张转换命令,而是校准集——它决定了激活值的统计范围,范围估错,低置信度目标全部被压没。
校准集我通常用摄像头现场采集200张真实场景图,覆盖不同光照和角度,而不是拿训练集凑数。转换脚本:
# 量化前先准备校准集:和部署端同尺寸的 96x96 RGB 图 import tensorflow as tf import numpy as np def load_image(path): from PIL import Image img = Image.open(path).convert('RGB').resize((96, 96)) return np.asarray(img, dtype=np.uint8) def representative_dataset(): for i in range(200): yield [np.expand_dims(load_image('calib/%03d.bmp' % i), 0)] converter = tf.lite.TFLiteConverter.from_keras_model( tf.keras.models.load_model('det_float.h5')) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.uint8 converter.inference_output_type = tf.uint8 tflite_model = converter.convert() open('det_int8.tflite', 'wb').write(tflite_model)注意inference_input_type和output_type都设成tf.uint8,这样MCU端拿到的输入输出都是定点数,省去浮点转换,也避免TFLite在边缘设备上偷偷插浮点算子。
权重导出成C头文件:
# 导出 int8 权重到 C 数组头文件 interpreter = tf.lite.Interpreter(model_content=open('det_int8.tflite','rb').read()) interpreter.allocate_tensors() def export_c_array(tag, arr): arr = arr.flatten() with open('weights_%s.h' % tag, 'w') as f: f.write('const int8_t %s_weights[%d] = {\n' % (tag, arr.size)) for i in range(0, arr.size, 16): f.write(','.join(str(int(v)) for v in arr[i:i+16]) + ',\n') f.write('};\n') for d in interpreter.get_tensor_details(): if d['name'].find('conv2d') >= 0 and len(d['shape']) == 4: w = interpreter.get_tensor(d['index']) export_c_array(d['name'].replace('/', '_'), w)校准集图片数量低于50张时,激活值最大值容易被个别样本带偏,表现为所有目标置信度整体偏低;超过500张收益递减。200张是我试下来性价比最高的数。导出时顺带把每层的量化scale和zero_point写进model_meta.h,后面推理反量化要用。
3.3 推理与后处理:int8卷积、候选框解析与NMS
MCU端没有一个现成框架帮你跑图,你要么用TFLite Micro移植,要么直接手写前向。工程里用的是手写前向,理由就一条:TFLite Micro对F470这档Flash和RAM还是偏重,且算子裁剪麻烦。卷积核心循环:
// int8 卷积,per-tensor scale,权重已排成 [out_c][in_c][kh][kw] void conv2d_int8(const int8_t *in, int ih, int iw, int ic, const int8_t *w, const int32_t *bias, int oc, int kh, int kw, int stride, int pad, int32_t in_scale, int32_t w_scale, int8_t *out, int oh, int ow) { for (int ocn = 0; ocn < oc; ocn++) { for (int oy = 0; oy < oh; oy++) { for (int ox = 0; ox < ow; ox++) { int32_t acc = 0; for (int icn = 0; icn < ic; icn++) { for (int ky = 0; ky < kh; ky++) { for (int kx = 0; kx < kw; kx++) { int iy = oy * stride - pad + ky; int ix = ox * stride - pad + kx; if (iy < 0 || iy >= ih || ix < 0 || ix >= iw) continue; acc += (int32_t)in[icn * ih * iw + iy * iw + ix] * (int32_t)w[ocn * ic * kh * kw + icn * kh * kw + ky * kw + kx]; } } } // 量化反推:acc * (in_scale*w_scale) + bias,再截断回 int8 int32_t val = (acc * in_scale * w_scale) >> 16; val = (val + bias[ocn]) >> MODEL_OUT_SHIFT; if (val > 127) val = 127; if (val < -128) val = -128; out[ocn * oh * ow + oy * ow + ox] = (int8_t)val; } } } }这段代码里in_scale和w_scale是量化参数,MODEL_OUT_SHIFT是输出缩放位移,三者都从model_meta.h来。工程里实际跑的时候,内层循环用M4的SMUAD指令一次做两个int8乘加,速度比上面标量版快一倍多,但逻辑完全一致。
候选框解析在检测头输出上进行。网络输出张量形状是[grid_h][grid_w][num_anchors][5+num_classes],每个anchor预测中心坐标偏移、宽高、目标置信度和各类别分数:
typedef struct { int16_t x1, y1, x2, y2; uint8_t class_id; uint8_t conf; // 0~255,量化置信度 } det_box_t; #define CONF_THRESH 60 // 约等于浮点 0.23,按量化分辨率换算 int decode_boxes(const int8_t *raw, int grid_h, int grid_w, int anchors, int classes, det_box_t *boxes) { int cnt = 0, cell_w = 320 / grid_w, cell_h = 240 / grid_h; for (int gy = 0; gy < grid_h; gy++) { for (int gx = 0; gx < grid_w; gx++) { for (int a = 0; a < anchors; a++) { int base = ((gy * grid_w + gx) * anchors + a) * (5 + classes); if (raw[base + 4] < CONF_THRESH) continue; uint8_t best_cls = 0, best_score = 0; for (int c = 0; c < classes; c++) { uint8_t s = (uint8_t)(raw[base + 4] * raw[base + 5 + c] >> 7); if (s > best_score) { best_score = s; best_cls = c; } } if (best_score < CONF_THRESH) continue; int cx = (raw[base + 0] + gx) * cell_w; int cy = (raw[base + 1] + gy) * cell_h; int w = raw[base + 2] * cell_w / 4; // anchor 尺度归一化 int h = raw[base + 3] * cell_h / 4; boxes[cnt].x1 = cx - w / 2; boxes[cnt].y1 = cy - h / 2; boxes[cnt].x2 = cx + w / 2; boxes[cnt].y2 = cy + h / 2; boxes[cnt].class_id = best_cls; boxes[cnt].conf = best_score; if (++cnt >= MAX_DETECT) return cnt; } } } return cnt; }CONF_THRESH这里设成60(0~255标度),浮点上大约是0.23,够低,宁可多几个假阳性框交给跟踪模块去滤。解析出候选框后接一个贪心NMS,每次取置信度最高的框,抑制与其IoU超过0.45的框,MCU端不排序,暴力扫一遍就够用了。
4. 多目标跟踪:从原始检测框到稳定目标ID的实现细节
检测网络给的是每一帧独立的框,没有“这是同一个目标”的概念。要把多目标识别做成能用的系统,必须加跟踪层——分配ID、预测位置、处理遮挡和漏检。这一步做得好不好,直接决定用户看到的是“稳定跟随”还是“框乱跳”。
4.1 帧间匹配:IoU阈值与置信度阈值怎么定
跟踪核心是上一帧轨迹和当前帧检测框的匹配。MCU上跑不了匈牙利算法那套重量级匹配,用贪心IoU匹配足够:
#define MAX_TRACKS 8 // 同时跟踪目标上限 #define TRACK_MIN_IOU 25 // 匹配最小IoU,单位 0.01,即 0.25 #define LOST_MAX 5 // 连续丢失 5 帧销毁轨迹 typedef struct { int16_t x, y, w, h; // 目标框 uint8_t id; uint8_t lost_cnt; int16_t vx, vy; // 一阶低通后的速度估计 uint8_t active; } track_t; static track_t tracks[MAX_TRACKS]; void track_update(det_box_t *boxes, int box_cnt) { uint8_t matched[MAX_DETECT]; memset(matched, 0, sizeof(matched)); for (int i = 0; i < MAX_TRACKS; i++) { if (!tracks[i].active) continue; int best_idx = -1; int best_iou = TRACK_MIN_IOU; for (int j = 0; j < box_cnt; j++) { if (matched[j]) continue; int iou = iou_xyxy(tracks[i].x, tracks[i].y, tracks[i].w, tracks[i].h, boxes[j].x1, boxes[j].y1, boxes[j].x2, boxes[j].y2); if (iou > best_iou) { best_iou = iou; best_idx = j; } } if (best_idx >= 0) { matched[best_idx] = 1; // 速度一阶低通:新速度 = (旧速度*3 + 本次位移) / 4 tracks[i].vx = (tracks[i].vx * 3 + (boxes[best_idx].x1 + boxes[best_idx].x2 - tracks[i].x * 2) / 2) >> 2; tracks[i].vy = (tracks[i].vy * 3 + (boxes[best_idx].y1 + boxes[best_idx].y2 - tracks[i].y * 2) / 2) >> 2; tracks[i].x = (boxes[best_idx].x1 + boxes[best_idx].x2) / 2; tracks[i].y = (boxes[best_idx].y1 + boxes[best_idx].y2) / 2; tracks[i].w = boxes[best_idx].x2 - boxes[best_idx].x1; tracks[i].h = boxes[best_idx].y2 - boxes[best_idx].y1; tracks[i].lost_cnt = 0; } else { tracks[i].lost_cnt++; if (tracks[i].lost_cnt >= LOST_MAX) tracks[i].active = 0; } } // 未被匹配的检测框创建新轨迹 for (int j = 0; j < box_cnt; j++) { if (matched[j]) continue; for (int i = 0; i < MAX_TRACKS; i++) { if (!tracks[i].active) { tracks[i].active = 1; tracks[i].id = allocate_id(); tracks[i].x = (boxes[j].x1 + boxes[j].x2) / 2; tracks[i].y = (boxes[j].y1 + boxes[j].y2) / 2; tracks[i].w = boxes[j].x2 - boxes[j].x1; tracks[i].h = boxes[j].y2 - boxes[j].y1; tracks[i].vx = tracks[i].vy = 0; tracks[i].lost_cnt = 0; break; } } } }TRACK_MIN_IOU设0.25是被逼出来的:目标快速移动时,上一帧和这一帧框的重叠可能只有0.2,设高了就会频繁断轨重新分配ID。设低了又容易把相邻的两个目标错误合并。从0.25起步,按你实际目标的移动速度调——运动快的调低到0.2,运动慢的可以提到0.35。
4.2 轨迹状态机:新建、更新、丢失与ID分配
轨迹有四种状态:空闲、跟踪中、连续丢失、销毁。空闲轨迹被新检测框占用时分配ID,allocate_id()用一个递增计数器,超过255回绕时跳过仍活跃的ID,避免刚销毁的ID立刻被复用造成视觉混淆。
连续丢失的处理值得注意:目标被遮挡三五帧是常事,不该立刻销毁。工程的做法是用速度外推——丢失期间不画实际检测框,而是按vx/vy外推出一个预测位置继续等匹配。这帧如果目标重新出现,因为离预测位置近,IoU匹配能接上,ID不会变。超过LOST_MAX(5帧)还没匹配上才销毁轨迹。这个机制对快速移动目标尤其重要,不然目标一过遮挡物就换ID,多目标识别就没意义了。
4.3 实时绘制:把检测框和ID画进帧缓冲
绘制直接操作RGB565帧缓存,先在内存里画好局部窗口再一次性刷到LCD。画框函数就干两件事:画四条边、左上角标ID。颜色预先按类别定好:
// RGB565 颜色查找表,3 个类别固定颜色 static const uint16_t cls_color[3] = { 0xF800, 0x07E0, 0x07FF }; // 红/绿/青 void draw_detection(uint16_t *fb, int fb_w, int fb_h, det_box_t *b, uint8_t track_id) { int x1 = clamp(b->x1, 0, fb_w - 1), y1 = clamp(b->y1, 0, fb_h - 1); int x2 = clamp(b->x2, 0, fb_w - 1), y2 = clamp(b->y2, 0, fb_h - 1); uint16_t color = cls_color[b->class_id]; for (int x = x1; x <= x2; x++) { fb[y1 * fb_w + x] = color; fb[y2 * fb_w + x] = color; } for (int y = y1; y <= y2; y++) { fb[y * fb_w + x1] = color; fb[y * fb_w + x2] = color; } // ID 画在框左上角,白底黑字,8x12 点阵,避免和检测框颜色混淆 draw_char(fb, fb_w, x1 + 2, y1 + 2, '0' + track_id, 0xFFFF); }LCD刷新只更新检测框所在的脏矩形区域。实测整屏刷新一帧要80ms,而一个平均大小的框只有十几毫秒,脏矩形方案在目标少时能省下30%绘制时间。注意坐标系:检测输出的320×240是在采集分辨率上的,LCD是240×240,绘制前要按比例缩放,这里不做缩放直接裁剪也凑合,但框位置会偏,建议还是老老实实乘个240/320系数。
5. 避坑手记:GD32跑AI最容易踩的五个坑
模型和跟踪都通了,真正耗时间的是调试。下面五个坑是按复现概率排的,前两个属于“不踩不算用过GD32”的级别。
5.1 向量表偏移:Bootloader跳转后一进中断就HardFault
现象:App单独烧录时一切正常,加上Bootloader后,从Boot跳转到App,只要一进定时器中断或DMA中断就进HardFault_Handler。 原因:App的向量表默认链接在0x08000000,而Bootloader占了这片地。中断一来,CPU从0x08000000取向量,取到的是Boot的中断处理地址,App的NVIC配置全部对不上,直接跑飞。 解决:App启动代码里必须把向量表基址搬到App的Flash起始地址:
// app 工程里,main 最开始就要做这一行 #define APP_FLASH_ADDR 0x08010000 // 和链接脚本的 FLASH 起始地址一致 SCB->VTOR = APP_FLASH_ADDR; // 如果你的固件库有 nvic_vector_table_set(),等效写法: nvic_vector_table_set(APP_FLASH_ADDR, NVIC_VECTTAB_FLASH);SCB->VTOR直接写最稳,别依赖固件库封装。这个值和Keil链接脚本里IROM1的Start地址必须严格一致,Boot分区如果是64KB,App就从0x08010000开始,两个地方错一个都会翻车。
5.2 跳转前的外设残留:摄像头第一次初始化必失败
现象:从Boot跳转到App后,OV2640上电时序全对,但寄存器写进去读出来全是错值;DMA使能后死等传输完成标志。 原因:Boot里初始化过DVP和DMA,跳转时外设寄存器保持原状态。OV2640的I2C引脚被复用成别的功能,或者DMA通道还停在Boot的配置,App重新初始化时握手失败。 解决:跳转前把用过的外设逐个deinit,关闭全局中断,清空挂起中断,最后设好MSP再跳:
typedef void (*app_entry_t)(void); void jump_to_app(uint32_t app_addr) { __disable_irq(); // 清掉启动期间挂起的中断,避免跳过去立刻触发 NVIC_ClearPendingIRQ(DMA0_Channel0_IRQn); NVIC_ClearPendingIRQ(EXTI5_9_IRQn); SysTick->CTRL = 0; // 复位Boot用过的外设 i2c_deinit(I2C0); dma_deinit(DMA_CH0); dma_deinit(DMA_CH1); usart_deinit(USART0); delay_deinit(); __set_MSP(*(volatile uint32_t *)app_addr); app_entry_t app = (app_entry_t)(*(volatile uint32_t *)(app_addr + 4)); app(); }跳转函数本身要放在RAM里执行,因为App启动后Flash首块可能被当作向量表重新映射。Keil里给这个函数加__attribute__((section(".ramfunc"))),否则优化后可能从Flash取指令取到错误地址。
5.3 内存分配:跑几十帧后随机死机的元凶是堆碎片
现象:刚烧录时能跑几百帧,跑着跑着在malloc返回NULL后死机;或者死机位置每次都不一样,看调用栈在NMS后处理附近。 原因:推理中间张量、候选框数组全部动态分配。MCU堆只有几十KB,频繁分配释放后碎片化,某次请求大块连续内存失败,代码里没判空,空指针一写就HardFault。 解决:彻底干掉动态分配,所有大缓冲静态预分配:
// detect.c 顶部统一申请,编译期定死 static int8_t tensor1[96 * 96 * 8]; // 第一层中间张量 static int8_t tensor2[48 * 48 * 16]; // 第二层中间张量 static det_box_t detect_boxes[MAX_DETECT]; // 最大候选框数同时在启动文件里把栈调到16KB以上——M4跑int8卷积时的局部变量栈很深,默认1KB必然溢出。排查栈溢出有个土办法:启动时把栈区全部填0xCC,死机后看栈顶上方还有多少0xCC没被踩掉,剩余小于256字节就该加栈。
5.4 DMA花屏与撕裂:双缓冲不是加了就完事
现象:画面上下半帧错位,右侧出现周期性彩色竖条;帧率越高越明显。偶发,不是必现。 原因:检测处理耗时400ms,DMA早就把下一帧写完了。处理完正想切buffer,DMA又往同一块内存写数据,读到一半的数据拼出来就是撕裂。单缓冲时更惨,处理期间新数据直接覆盖正在读的帧。 解决:处理帧和DMA写入帧要互相锁。改法是在处理前检查当前DMA正在写哪块,正在写号就等它写完再处理另一块:
uint8_t processing_idx; void main_loop(void) { if (!frame_ready) return; frame_ready = 0; processing_idx = active_buf ^ 1; // 处理 DMA 写好的那块 // 处理前等 DMA 离开我们正要读的这块 if (active_buf == processing_idx) { // 极端情况:DMA 刚切回来,等一帧中断 return; } downscale_and_detect(frame_buf[processing_idx]); draw_detections(); }另一个细节是DVP的帧中断要打在帧消隐期间,不要在像素传输中触发buffer切换,否则DMA可能在行中间改地址,表现出来的就是右侧彩条。
5.5 串口printf拖慢帧率:调试输出也要算预算
现象:加了几行调试printf打印检测结果,帧率从3FPS掉到1.5FPS,串口助手还偶发丢字符。 原因:printf默认重定向到USART轮询发送,115200波特率下一个字节约87us,一帧20个框每个框打一行20字节,就是35ms;再加上printf内部浮点格式化吃掉大量CPU周期和栈。每帧多出100ms以上,帧率直接腰斩。 解决:调试期就按“生产标准”来,串口发送全部走DMA环形队列,应用代码只往队列里塞数据:
#define LOG_BUF_SIZE 512 static volatile uint8_t log_buf[LOG_BUF_SIZE]; static volatile uint16_t log_head, log_tail; void log_put(char c) { uint16_t next = (log_head + 1) % LOG_BUF_SIZE; if (next == log_tail) return; // 满了就丢,不阻塞 log_buf[log_head] = c; log_head = next; } void log_flush(void) { while (log_head != log_tail) { uint16_t len = (log_head > log_tail) ? (log_head - log_tail) : (LOG_BUF_SIZE - log_tail); dma_transmit(log_buf + log_tail, len); log_tail = (log_tail + len) % LOG_BUF_SIZE; } }主循环每50帧调用一次log_flush(),日志对帧率的影响从35ms/帧降到接近零。所有打印只输出整数,float格式化成毫米级耗时,MCU上不值得。
6. 进阶:帧率测量与量化精度的最后一公里
系统跑通后别急着调模型,先把帧率量化出来。用SysTick打点是最轻量的方案,不额外占定时器:
volatile uint32_t g_tick_ms; void SysTick_Handler(void) { g_tick_ms++; } uint32_t t_last, t_infer, t_track, t_draw; void profiled_loop(void) { uint32_t t0 = g_tick_ms; wait_frame_and_downsample(); // 等DMA帧+下采样 uint32_t t1 = g_tick_ms; detect(); // 推理+解析+NMS uint32_t t2 = g_tick_ms; track_update(detect_boxes, box_cnt); draw_detections(); uint32_t t3 = g_tick_ms; t_infer += t2 - t1; t_track += (t3 - t2); t_last += t3 - t0; } void show_fps(void) // 每50帧调一次 { uint32_t now = g_tick_ms; float fps = 50000.0f / (float)(now - t_last); // 各段平均耗时:t_infer/50、t_track/50,t_draw 类推 }实测耗时分布大概是:推理(含NMS)占78%、跟踪5%、绘制12%、余下5%是等待和调度。优化优先级由此定——先砍推理,后处理再简单也不值得先动。砍推理最有效的手段不是改卷积算子,而是把输入从96×96降到80×80,乘加量直接降三成,代价是小目标漏检多一点。再配合把检测头的anchor从3个减到2个,帧率能从3FPS拉到4FPS以上。
量化精度的最后一公里在两点。第一,校准集和推断环境的色温一致性,工业现场的白炽灯和训练用的日光灯会让激活值整体偏移,量化后置信度系统性偏低,解决办法是校准集里混入现场光照图。第二,权重量化选per-channel而不是per-tensor,F470上代价是卷积代码要从单scale改成按输出通道查表,收益是通道间的数值差异不被压平,小目标召回率能回来5个点。
验证方法我习惯这样:现场拍50张带真值框的图,PC浮点模型跑一遍,MCU int8模型再跑一遍,统计IoU大于0.5的召回率。浮点0.71,int8能到0.66,这个差距就是量化损耗的合理区间;如果掉到0.55以下,先去查校准集和per-channel,而不是怀疑模型结构。
从那以后我每次往MCU移植检测模型,都强制走一遍这个流程:PC浮点跑基线→TFLite int8量化→静态内存检查→SysTick分阶段计时→现场光照校准。五个环节都是半小时以内的活,但能省下后面好几天的DEBUG时间,希望帮到你。
本文还有配套的精品资源,点击获取