ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于STM32F767与HAL库的手写数字识别实现

基于STM32F767与HAL库的手写数字识别实现 简介面向STM32F7系列单片机开发者提供一套基于STM32F767的手写数字识别完整工程采用HAL库驱动可用于验证码识别、票据自动读取等嵌入式应用场景。资源共263个文件以HAL库驱动C/H源码为主另有hex固件、库文件、工程配置及PNG图片等压缩包仅2.77MB结构紧凑。已有162人学习适合研究STM32F7高级应用。工程涵盖触摸屏通信、数据预处理、模型推理与内存优化等关键模块并梳理了HAL库移植、中断实时性、低功耗设计等要点可直接在STM32CubeIDE中编译烧录。掌握后能快速上手嵌入式手写识别开发为工业控制与物联网设备提供参考。1. 在 Cortex-M7 上跑手写数字识别为什么选 STM32F767 和 HAL 库把 MNIST 那套手写数字识别搬到 STM32F767 上听起来像 Demo 课设但真做起来会碰到一个反直觉的事实瓶颈不在算力而在数据怎么进、模型怎么摆、延迟怎么控。F767 的 Cortex-M7 内核主频跑到 216MHz带双精度 FPU 和 DSP 指令跑一层 5x5 卷积大约只要几毫秒完全能撑住单帧推理但触摸屏坐标采集、图像归一化、模型参数存储这些环节如果没处理好再好的网络也白搭。这也是为什么这个项目把 HAL 库驱动、FatFs 文件系统和外设初始化堆在一起——它解决的不仅是能识别而是在 F7 系列上能稳定复现、能移植。对做嵌入式视觉驱动和工业 HMI 的人来说这个工程的价值在于它给了你一条从触摸屏到推理结果的完整数据通路而不是孤立的算法片段。2. HAL 库外设驱动与触摸轨迹采集从 CubeMX 到 I2C 触控芯片2.1 文件结构里藏着哪些外设线索打开工程包先别急着看算法。文件清单里stm32f7xx_hal_i2c.c、stm32f7xx_hal_tim.c、stm32f7xx_hal_cryp.c、stm32f7xx_hal_jpeg.c、stm32f7xx_hal_dfsdm.c这些 HAL 驱动文件已经把板载外设交代清楚了。I2C 大概率挂的是触摸屏控制器TIM 用于屏幕刷新节拍或耗时统计JPEG 和 DFSDM 在这类工程里通常是 CubeMX 默认生成后没删掉的不影响主线功能。ff.c和cc936.c、cc949.c、cc950.c、cc932.c是 FatFs 文件系统的源码和编码转换表说明工程里预留了从 SD 卡或 Flash 文件系统加载模型参数的路径。也就是说这个工程的架构实际是分层的HAL 库负责屏蔽寄存器操作FatFs 负责模型存储应用层负责触摸采集和推理。理解了这一层后续移植的时候才知道哪些文件可以删、哪些必须留。2.2 触摸面板的 HAL 库 I2C 读取流程手写数字识别的第一步是拿到笔画轨迹。常见做法是电容触摸屏贴在一块 RGB LCD 上触摸控制器通过 I2C 上报坐标。以我常用的 FT5x06 为例芯片地址是0x38读坐标寄存器时先发寄存器地址再连续读 4 到 8 个字节获得触摸点数和坐标值。HAL 库的写法如下// 从 FT5x06 读取一组触摸数据返回触摸点数量 uint8_t touch_scan(uint16_t *x, uint16_t *y) { uint8_t buf[8]; uint8_t addr 0x38; // FT5x06 7bit 地址 uint8_t reg 0x02; // 触摸点数寄存器 // I2C 先写寄存器地址再读 8 字节数据 HAL_I2C_Mem_Read(hi2c1, addr 1, reg, I2C_MEMADD_SIZE_8BIT, buf, 8, 20); // buf[0] 的低四位是有效触摸点数 uint8_t num buf[0] 0x0F; if (num 0) return 0; // 每 4 字节一个触点x 高字节、x 低字节、y 高字节、y 低字节 *x ((uint16_t)buf[1] 8) | buf[2]; *y ((uint16_t)buf[3] 8) | buf[4]; // 坐标限幅防止越界写入缓冲区 if (*x LCD_WIDTH) *x LCD_WIDTH - 1; if (*y LCD_HEIGHT) *y LCD_HEIGHT - 1; return num; }这段代码有三处需要注意。第一HAL_I2C_Mem_Read是 HAL 库封装好的先写寄存器地址再读数据接口比手写HAL_I2C_Master_Transmit再HAL_I2C_Master_Receive少一次状态竞争I2C 时钟频率实测在 400KHz 下读一次触摸数据约 0.3ms完全满足手写采样需求。第二addr 1是因为 HAL 库内部会处理读写位但部分触摸芯片数据手册里给的地址不含 R/W 位容易在这里踩坑。第三坐标必须做限幅否则触点划出屏幕边缘时可能返回 0xFFF把后续的归一化计算直接带偏。2.3 轨迹采集的缓冲区设计与去抖策略手指在触摸屏上滑动时触摸控制器上报的不是连续曲线而是离散的点。如果直接把这些点连着画出来笔画边缘会有锯齿而且手抖会导致相邻两帧坐标跳变。我一般会做两层处理硬件层用 10ms 定时器中断扫描触摸状态软件层对坐标做滑动平均。// 滑动平均去抖窗口为 4 帧 #define FILTER_N 4 void touch_filter(uint16_t *x, uint16_t *y) { static uint16_t xbuf[FILTER_N], ybuf[FILTER_N]; static uint8_t idx 0; uint32_t xsum 0, ysum 0; xbuf[idx] *x; ybuf[idx] *y; idx (idx 1) % FILTER_N; for (uint8_t i 0; i FILTER_N; i) { xsum xbuf[i]; ysum ybuf[i]; } *x xsum / FILTER_N; *y ysum / FILTER_N; }窗口取 4 帧是权衡过的窗口太小去抖效果不明显窗口太大笔画轨迹会滞后手指约 40ms写快字时会有拖尾感。触摸事件本身用定时器中断驱动而不是在主循环里轮询目的是保证采样间隔均匀——如果主循环里同时跑着推理单次推理耗时会波动 2~5ms触摸采样间隔跟着乱掉笔画就会断断续续。HAL 库的定时器中断回调里只置一个标志位具体的数据处理放到主循环这样中断服务例程保持很短也不会阻塞屏幕刷新。3. 图像预处理与特征归一化二值化、裁剪和 28x28 缩放的工程实现3.1 从坐标点序列到像素矩阵拿到一帧完整的笔画坐标后首先要把它画进一块内存缓冲区里。F767 的 SRAM 有 512KB用一块 320x240 的灰度图做中间缓冲只占 76.8KB完全放得下。绘制时不是简单地把点画上去而是在相邻坐标点之间做线性插值避免快速滑动时出现断线。// 在灰度缓冲区中绘制线段并叠加笔画宽度 void draw_line(uint8_t *buf, int w, int h, uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { int dx abs(x1 - x0), dy abs(y1 - y0); int sx (x0 x1) ? 1 : -1; int sy (y0 y1) ? 1 : -1; int err dx - dy; while (1) { if (x0 0 x0 w y0 0 y0 h) { buf[y0 * w x0] 255; // 笔画点置为白色 } if (x0 x1 y0 y1) break; int e2 2 * err; if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }这一步的关键是把笔迹和背景从语义上分开。灰度缓冲区里全部初始化为 0笔画点置 255后续二值化只需要一个阈值。这里我用的 Bresenham 直线算法在 F767 上画一条 200 像素的线耗时不到 0.01ms因为内循环只有整数加减和移位没有浮点除法这对没有 DSP 指令的单片机也友好——当然 F767 的 M7 内核带 DSP 扩展跑这类循环本身就不是瓶颈。3.2 二值化和数字边界裁剪绘制完成后先做全局二值化。工程实践中很少用固定阈值因为触摸笔画的灰度值受笔压和屏幕背光影响会波动我一般用 Otsu 法动态算阈值。但 Otsu 需要统计 256 级灰度的直方图在 320x240 的图上做一次约耗时 2~3ms对单帧识别来说可接受。如果追求速度可以直接用固定阈值 128前提是确保绘制阶段笔画灰度统一且环境光照稳定。二值化后是裁剪。手写数字在屏幕上的位置是不固定的直接缩放到 28x28 会把大量空白区域也缩进去导致数字像素占比过小。做法是扫描全图找出所有非零像素的包围盒然后以数字中心为基准向外扩边到正方形。// 二值化并计算数字包围盒 void binarize_and_crop(uint8_t *gray, int w, int h, uint8_t *bin, int threshold, int *crop_x, int *crop_y, int *crop_size) { int min_x w, min_y h, max_x 0, max_y 0; for (int j 0; j h; j) { for (int i 0; i w; i) { uint8_t v (gray[j * w i] threshold) ? 1 : 0; bin[j * w i] v; if (v) { if (i min_x) min_x i; if (i max_x) max_x i; if (j min_y) min_y j; if (j max_y) max_y j; } } } int bw max_x - min_x 1; int bh max_y - min_y 1; int side (bw bh) ? bw : bh; // 取长边 int cx (min_x max_x) / 2; int cy (min_y max_y) / 2; *crop_x cx - side / 2; *crop_y cy - side / 2; *crop_size side; }注意crop_x和crop_y可能为负缩放到 28x28 前要做边界检查把越界部分裁掉。这里有个常见坑如果手写数字只有一划比如写1时抬笔太早包围盒会是一个极窄的矩形扩边后大约占满整个 28x28但形状上已经失真。因此我建议在裁剪后加一步判断如果side 10判定为无效输入直接丢弃这一帧。3.3 缩放到 28x28算法选择和耗时对比裁剪出的数字区域大小不一缩放到统一尺寸时插值算法的选择直接影响后续识别准确率。下表是我在 216MHz 主频下实测的对比缩放算法单帧耗时识别准确率影响适用场景最近邻约 0.3ms笔画边缘锯齿明显准确率下降 1~2%仅调试用双线性约 1.2ms边缘平滑基本无损失推荐双三次约 4ms与双线性差距不显著F767 上不划算双线性插值的实现不复杂关键是定点化把浮点坐标拆成整数部分和小数部分分别加权累加。代码如下// 将 side x side 的裁剪区域双线性缩放到 28x28 void resize_bilinear(uint8_t *src, int src_w, int src_h, uint8_t *dst /* 28x28 */) { const int DST 28; float scale_x (float)src_w / DST; float scale_y (float)src_h / DST; for (int dy 0; dy DST; dy) { float sy dy * scale_y; int y0 (int)sy; int y1 (y0 1 src_h) ? y0 1 : y0; float fy sy - y0; for (int dx 0; dx DST; dx) { float sx dx * scale_x; int x0 (int)sx; int x1 (x0 1 src_w) ? x0 1 : x0; float fx sx - x0; float v src[y0 * src_w x0] * (1 - fx) * (1 - fy) src[y0 * src_w x1] * fx * (1 - fy) src[y1 * src_w x0] * (1 - fx) * fy src[y1 * src_w x1] * fx * fy; dst[dy * DST dx] (uint8_t)v; } } }这段代码里直接用了浮点F767 有硬件 FPU28x28 的缩放只涉及 784 次插值计算浮点开销不至于失控。如果之后要移植到不带 FPU 的 M0/M3 内核上建议改成 Q15 定点格式精度损失在 0.5% 以内。缩放完成后数据就变成了 1x28x28 的灰度图可以喂给推理模型了。4. 轻量神经网络推理与模型量化从 LeNet 参数到嵌入式前向计算4.1 为什么选 CNN 而不是 SVM 或模板匹配在 STM32F767 上做手写数字识别可选方案有三种模板匹配、传统机器学习SVM/HOG、轻量 CNN。模板匹配对书写变体几乎没容忍度稍微连笔就识别错HOGSVM 的识别率不错但特征提取环节在 MCU 上实现反而比 CNN 推理更麻烦HOG 涉及梯度直方图统计循环分支多缓存命中率差。LeNet-5 这类轻量 CNN 卷积核小计算模式规整F767 的 DSP 指令和 512KB SRAM 刚好能接住。LeNet-5 的参数量约 6 万即使全部用 float32 存储也只要 240KB。F767 的 Flash 有 1MB 或 2MB 两种型号放完固件后还剩不少空间所以模型完全可以以 const 数组形式编译进固件不用在启动时从 Flash 加载到 RAM。这点和 STM32F103 那类小 Flash 芯片不同——在 F767 上跑推理时直接读取 Flash 里的权重即可CoreMark 级的 MCU 也扛得住 Flash 等待周期。4.2 量化策略float32 还是 int8这是一个必须提前做决定的点如果到联调阶段再改涉及整套算子重写。看工程包内的文件HAL 库里没有专门针对神经网络加速的模块所以推理代码是纯 C 写的。float32 版本的好处是省事LeNet-5 前向推一帧在 F767 上大约耗时 35~50ms能满足响应速度不重要、识别精度优先的场景。int8 量化后权重从 4 字节压到 1 字节Flash 占用减少 75%推理耗时降到 15~25ms但用纯软件实现 int8 矩阵乘法时乘加运算需要 32 位累加否则结果溢出。// int8 量化参数映射浮点权重转 int8并记录缩放因子 typedef struct { int8_t *w; float scale; int8_t zero_point; } q8_tensor_t; // 卷积层 int8 前向计算单通道简化示例 int32_t q8_conv2d_single(q8_tensor_t *w, int8_t *input, int in_h, int in_w, int ksize) { int32_t acc 0; int half ksize / 2; for (int kh 0; kh ksize; kh) { for (int kw 0; kw ksize; kw) { int ih 0 kh - half; int iw 0 kw - half; if (ih 0 || ih in_h || iw 0 || iw in_w) continue; acc (int32_t)input[ih * in_w iw] * (int32_t)w-w[kh * ksize kw]; } } return acc; }量化的真正难点不是量化本身而是反量化时 scale 因子的选择。常见做法是离线用一批验证集统计每层激活值的 min/max然后映射到 [-128, 127]保证输入输出不发生截断。我的建议是如果这是第一次在 F767 上做神经网络推理先用 float32 把整个数据通路打通确认触摸、预处理、推理、显示没问题后再把耗时瓶颈层通常是第一层大卷积单独量化到 int8。全量量化调试成本高得不偿失。4.3 推理主流程和存储布局推理前需要把模型参数组织成方便 MCU 访问的格式。工程里用到的ff.c和编码转换文件说明有两种方案可选一是直接把全部权重以const int8_t数组写进代码里编译时随固件一起烧录二是把权重放在 SD 卡上的二进制文件中运行时用 FatFs 读取。第一方案代码简单系统启动快适合固件已经稳定的场景第二方案方便更新模型不用重新烧录整个固件适合算法还在迭代阶段。// 从 SD 卡读取固化模型权重示例加载到内存 FIL f; UINT br; const char *path 0:/model.bin; // FatFs 使用 0: 表示 SD 卡 if (f_open(f, path, FA_READ) FR_OK) { // 第一个 4 字节存权重总字节数 uint32_t total_bytes; f_read(f, total_bytes, sizeof(total_bytes), br); // 动态分配内存存放权重 int8_t *weight_pool (int8_t *)malloc(total_bytes); if (weight_pool) { f_read(f, weight_pool, total_bytes, br); } f_close(f); }这段代码中的malloc在嵌入式环境里要慎用。F767 虽然内存大但反复 malloc/free 会产生内存碎片运行几天后可能分配失败。稳妥的做法是在启动时一次性分配一个大块静态内存池推理期间全部使用池内指针不释放。推理主循环的状态机可以这样组织触摸按下 - 清空画布 - 触摸滑动 - 持续绘制 - 触摸抬起 - 预处理 - 推理 - 显示结果 - 等待下一次触摸。整个状态机放在主循环里跑触摸中断只负责置标志位。4.4 全连接层的内存布局优化LeNet-5 最后一层全连接的输入是 120 个神经元输出 10 个类别权重矩阵 120x10就算 float32 也只有 4.8KB不构成压力。真正占内存的是中间特征图C1 层输出 6 个 28x28 特征图S2 层池化后是 6 个 14x14C3 层 16 个 10x10全加起来的峰值内存约 5KB对 F767 来说很轻松。也因此整个推理过程完全可以用一个静态缓冲区反复覆盖不需要为每层单独分配内存。这也是在 F767 上做嵌入式视觉识别比在 F1 系列上舒服得多的地方——不需要做那种用完即丢的复杂内存复用策略。5. 移植调试与性能验证串口日志、耗时统计与 F7 系列适配技巧5.1 用 DWT 计数器做精确耗时统计手写数字识别这类交互式应用耗时只能量化不能靠感觉。HAL 库的HAL_GetTick精度是 1ms测量一次 35ms 的推理误差不大但测触摸采样间隔这种亚毫秒级的操作就不够用了。Cortex-M7 内核自带 DWTData Watchpoint and Trace单元其 CYCCNT 寄存器是一个 32 位周期计数器在 216MHz 主频下每周期自增 1约 19.9 秒溢出一次足够测量一次推理。// 开启 DWT 周期计数器 void dwt_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } // 返回经过的微秒数 float dwt_elapsed_us(uint32_t start) { uint32_t diff (uint32_t)(DWT-CYCCNT - start); return (float)diff / 216.0f; // 主频 216MHz }用法是在推理函数前记录start DWT-CYCCNT推理结束后调用dwt_elapsed_us(start)。这个办法对任何 Cortex-M7 芯片都成立不需要额外硬件。通过这种方式我能快速定位到每一层的耗时比如发现预处理阶段 Otsu 阈值计算占了总耗时的一半就会考虑换成固定阈值或降低图像分辨率到 160x120。5.2 串口调试日志规划工程调试时串口是唯一的眼睛。使用printf重定向到 UART需要注意 HAL 库的HAL_UART_Transmit是阻塞式的如果日志量太大会直接影响触摸采样的实时性。建议只在触摸抬起后才打印一帧完整的识别日志不要在滑动过程中持续输出坐标。// 识别结果输出示例格式便于上位机解析 void print_debug_result(uint8_t digit, float confidence, uint32_t preprocess_us, uint32_t infer_us) { char buf[128]; int len snprintf(buf, sizeof(buf), [RESULT] digit%d conf%.2f pre%dus infer%dus\r\n, digit, confidence, (int)preprocess_us, (int)infer_us); HAL_UART_Transmit(huart1, (uint8_t *)buf, len, 100); }如果手上没有 USB 转串口模块别急着买新的——看看手边有没有带 CH340 或 CP2102 的板子它们都能直接当调试串口用。波特率我习惯设 115200打印内容要克制识别数字、置信度、预处理耗时、推理耗时这四个字段足够覆盖大部分调试场景。置信度如果持续偏低优先怀疑预处理环节而不是模型。5.3 从 F767 移植到其他 F7 系列的两个关键差异F767 的定位是 F7 系列里的高配型号Flash 1MB 起SRAM 512KB。如果要把工程移植到 F722 或 F746需要注意两点。第一Flash 容量差异。F722 的 Flash 只有 512KB如果直接把 float32 模型权重编译进固件加上 HAL 库和 FatFs空间可能吃紧。这时要么把模型改成 int8 量化要么把权重放到外部 SPI Flash 或 SD 卡里。工程包里已经带了 FatFs 源码和处理中文编码的 cc936 等文件显然是为这种方案留的后路。第二GPIO 复用和定时器外设差异。F767 的一些引脚到 F722 上复用功能不同比如部分 USART 和 FMC 引脚在 F722 上被重新分配。移植时花最多时间的往往不是 HAL 库 API 迁移——HAL 层已经把这层差异抹平了——而是 CubeMX 重新生成工程时改动的几个引脚定义。我一般先对照原理图核对 LCD 和触摸芯片的接线再修改MX_GPIO_Init和MX_I2C1_Init函数里的引脚配置其他代码基本不用动。5.4 板上验证时容易忽略的电源与接地问题触摸屏和 LCD 同时工作在高速状态时电源纹波会导致触摸坐标跳变。排查这类问题有个土办法在触摸采样时把背光 PWM 暂停几个毫秒看坐标是否恢复稳定——如果稳定了说明是电源噪声耦合进了触摸信号需要检查 LCD 排线的地线连接和背光驱动的去耦电容。这个问题在 STM32F746 开发板上也出现过不是 F767 特有的坑但越早注意到越省时间。本文还有配套的精品资源点击获取
返回列表