简介:一套面向嵌入式初学者的STM32F103手写识别课程设计资源,适合单片机课程设计、毕业设计或嵌入式底层学习参考。该项目将触摸屏输入与识别算法结合,覆盖LCD显示、触摸屏校准、串口打印、按键切换模式等功能,可直接用于理解手写输入与字符识别的整体实现流程。资源共168个文件,以C源码(72个)和头文件(63个)为主,另含hex固件、lib库及LCD相关图片素材,并附带工程配置与清理脚本,整体约1.85MB,结构紧凑、便于直接查看代码或烧录验证。已有2358人学习,包内完整实现了开机初始化、字库检测、手写区等待输入、自动识别并输出结果等流程,可帮助开发者快速掌握STM32外设驱动与识别逻辑的配合写法,也便于在此基础上扩展更多实验功能。
1. 在 stm32f103 最小系统上做手写识别,先忘掉神经网络
拆这套 STM32f103 手写识别课程设计时,我最强烈的感受是:真正卡住大家的不是识别算法本身,而是「触摸轨迹怎么进来、识别结果怎么出去」。Cortex-M3 只有 72MHz,指望在 MCU 上跑出一套 MNIST 卷积明显不现实。工程给出的方案很务实:LCD 做交互,触摸屏采集笔迹,SD 卡里放字库,外部识别器或内置识别核负责匹配,STM32 只做调度。课件里同时出现 stm32f10x_tim.c、lcd.c、sdio_sdcard.c、stm32f10x_flash.c,说明这是一条完整链路,不是单独跑个 demo。这套实验适合正在做嵌入式课设、蓝桥杯备赛、想从外设驱动跨到应用层的人,也适合把标准库重新捡起来的老工程师。
2. FatFs 字库加载层:为什么工程文件里躺着 cc936.c 和 sdio_sdcard.c
2.1 识别前为什么要先检测字库
手写识别器初始化之后会先检测字库,这一步不是走过场。识别器内部完成笔画匹配后,返回的通常不是可以直接在 LCD 上绘制的点阵,而是一个字符编码或字库索引。STM32 拿到这个编码,还需要到字库里取对应点阵,才能在屏幕的显示区画出结果。如果字库缺失或路径不对,识别功能本身正常,但输出端会变成空白或乱码,现象很像「识别器坏了」。
常见做法是字库文件以二进制文件形式放到 SD 卡,例如 HZK16 存放 GBK 编码的 16×16 点阵,ASC16 存放 ASCII 字符。工程里用 FatFs 的ff.c来读写文件系统,SD 卡则通过sdio_sdcard.c驱动。手写识别器初始化时挂在 FatFs 之后,就是因为它要依赖文件系统读取字库配置;如果把初始化顺序反过来,识别器启动时会因为找不到字库而回退到出厂默认状态,后续识别结果全部不可靠。
2.2 挂载 SD 卡与字库检测的 C 代码
在初始化阶段,字库检测通常这样写:
#include "ff.h" #include "diskio.h" FATFS fs; FIL fp; FRESULT res; FILINFO fno; res = f_mount(&fs, "0:", 1); if (res != FR_OK) { printf("mount fail: %d\n", res); return res; } res = f_open(&fp, "0:/HZK16", FA_READ); if (res == FR_OK) { f_close(&fp); printf("font library OK\n"); } else { printf("font missing, error: %d\n", res); }这段代码里,f_mount第一参数传入全局FATFS对象,第二参数"0:"是卷标,绑定了底层 SD 卡的驱动号;第三个参数1表示立即挂载,如果传0,则会在第一次读写文件时才挂载,排查问题时容易混淆。f_open返回FR_OK说明字库文件存在,返回FR_NO_FILE说明路径不对,返回FR_NOT_READY则多半是 SD 卡初始化失败,要往回查SD_Init()的状态返回值。检测完立刻f_close,避免长期占用文件句柄,否则后面识别器读字库时可能出现FR_TOO_MANY_OPEN_FILES。
2.3 SDIO 时钟分频与接线,别一上来就追求高速
sdio_sdcard.c走的是 SDIO 外设。很多课程设计里 SDIO 初始化没问题,卡也插了,可f_mount就是报FR_NOT_READY。此时优先检查SDIO_ClockDiv。
SDIO_InitStructure.SDIO_ClockDiv = 0x04; SDIO_InitStructure.SDIO_ClockEdge = SDIO_ClockEdge_Rising; SDIO_InitStructure.SDIO_BusWide = SDIO_BusWide_1b;在 72MHz 主频下,SDIO 输出时钟约等于SDIOCLK / (2 + SDIO_ClockDiv)。SDIO_ClockDiv设为 0 时接近 36MHz,但很多 SD 卡初始化阶段不支持这么高,导致SD_Init()卡在SD_CMD8_SEND。常见做法是初始化阶段先用较大的分频系数,比如 0x04,确认卡 ID 和容量后,再切到 1 位或 4 位高速模式。另外,SDIO 的 D0-D3、CLK、CMD 要比 SPI 模式多出四根数据线,这六条线在 PCB 或杜邦线方案里都要加 10k 左右上拉电阻,否则在高阻态时数据线悬空,容易偶发读写错误。
2.4 cc936.c 的中文编码角色,以及 keilkilll.bat 的意义
工程里同时出现cc936.c、cc949.c、cc950.c、cc932.c四个文件,看到它们第一反应是:这事与 FatFs 的_CODE_PAGE配置有关。完整列表如下:
| 文件 | 代码页 | 适用区域 |
|---|---|---|
| cc936.c | CP936/GBK | 简体中文 |
| cc932.c | CP932 | 日文 Shift-JIS |
| cc949.c | CP949 | 韩文 |
| cc950.c | CP950 | 繁体中文 Big5 |
FatFs 在ffconf.h中通过_CODE_PAGE指定编码页。如果使用中文文件名或读中文路径,_CODE_PAGE要设成 936,并保证 cc936.c 参与编译。若是把_CODE_PAGE设成 950 而工程里只留了 cc936.c,链接时会出现ff_convert或ff_wtoupper未定义,这类错误和 CPU 主频无关,纯粹是文件系统配置不匹配。组件里其他三个编码文件可以删掉,保留它们不会报错,但会造成维护误解。keilkilll.bat是 clean 脚本,删除工程编译产生的中间 obj、lst 文件,对最终发布包非常有用;唯一要注意的是脚本默认删除范围,别把字库 bin 文件也列入删除名单。
3. 触摸轨迹采集与等距重采样:识别器的输入不能是原始点
3.1 坐标来源与按下判定
手写区在 LCD 上既负责显示轨迹,也负责接收触摸坐标。触摸驱动会提供一个结构体,典型成员是tp_dev.sta、tp_dev.x、tp_dev.y。在主循环里轮询扫描,检测到按下后记录坐标:
#define TRACE_MAX 256 int16_t trace_buf[2][TRACE_MAX]; uint16_t trace_cnt = 0; tp_dev.scan(); if (tp_dev.sta & TP_PRES_DOWN) { if (trace_cnt < TRACE_MAX) { trace_buf[0][trace_cnt] = tp_dev.x; trace_buf[1][trace_cnt] = tp_dev.y; trace_cnt++; } LCD_DrawPoint(tp_dev.x, tp_dev.y, WHITE); } else { if (trace_cnt > 0) { HandWrite_Recognize(trace_buf, trace_cnt); trace_cnt = 0; } }TP_PRES_DOWN位被置 1,表示当前有笔尖按下;清零表示抬起。每次扫描间隔 5ms 左右,按下期间持续记录坐标。有个容易忽略的问题:用户写一个字时,手会短暂停顿,但触摸仍处于按下状态,此时坐标保持不变,如果直接把坐标全量记录下来,后续识别会把一个长时间停顿的点当成连续压感。常见做法是先判TP_PRES_DOWN,再结合本次坐标与上次坐标的距离差,若距离小于一个像素阈值,则跳过该点,避免同一位置塞进大量重复点。
3.2 滑点滤波与抬笔抖动
电阻触摸屏在按下瞬间容易出现一段飞点,表现为轨迹开头突然跳到屏幕边缘,又跳回原处。最简单的方法是滑窗滤波,取相邻三点平均值:
int16_t sx, sy; for (i = 1; i < trace_cnt - 1; i++) { sx = (trace_buf[0][i-1] + trace_buf[0][i] + trace_buf[0][i+1]) / 3; sy = (trace_buf[1][i-1] + trace_buf[1][i] + trace_buf[1][i+1]) / 3; filter_buf[0][i] = sx; filter_buf[1][i] = sy; }注意,这段代码没有对端点做处理,所以filter_buf首尾两个点需要直接复制原始值。3 点均值对单点飞刺足够,对连续三点偏移则无能为力。更严格的判断是检查相邻点欧氏距离,如果某一点与前后两点的距离都超过整段轨迹平均距离的 3 倍,就把它当作坏点剔除。抬笔抖动是另一个坑:笔尖离屏时触摸屏会残留一次低压力接触,导致轨迹末尾多出一根短尾。我的习惯是丢弃最后 5 个点,再检查最后一段向量长度,若小于总长度的 2% 则直接截断。
3.3 等距重采样与归一化网格
原始点受书写速度影响,同一个字用慢写和快写产生的时间间隔不同,导致采样点密度不同。识别器需要的是一组空间上均匀分布的点列,因此要做等距重采样。先累加相邻点距离,然后按目标点数等分弧长:
#define RESAMPLE_N 128 double dist_sum[TRACE_MAX]; double total_len = 0; dist_sum[0] = 0; for (i = 1; i < trace_cnt; i++) { total_len += hypot( filter_buf[0][i] - filter_buf[0][i-1], filter_buf[1][i] - filter_buf[1][i-1]); dist_sum[i] = total_len; } double step = total_len / (RESAMPLE_N - 1); uint16_t ptr = 0; int16_t out[2][RESAMPLE_N]; for (i = 0; i < RESAMPLE_N; i++) { double target = step * i; while (ptr < trace_cnt - 2 && dist_sum[ptr+1] < target) ptr++; double alpha = (target - dist_sum[ptr]) / (dist_sum[ptr+1] - dist_sum[ptr]); out[0][i] = (int16_t)(filter_buf[0][ptr] + alpha * (filter_buf[0][ptr+1] - filter_buf[0][ptr])); out[1][i] = (int16_t)(filter_buf[1][ptr] + alpha * (filter_buf[1][ptr+1] - filter_buf[1][ptr])); }step是目标弧长,ptr在原始累积长度表中前进,alpha是线性插值权重。这样做的好处是慢写和快写会得到相同点数的轨迹,识别器不会因为笔画速度不同而给出不同结果。重采样后还要归一化,把轨迹坐标映射到固定区域。一般先计算包围盒宽高,再保持宽高比缩放到 200×200 网格内:
double scale = 200.0 / max(width, height); px = (int16_t)((x - min_x) * scale + margin); py = (int16_t)((y - min_y) * scale + margin);不推荐直接把宽高分别拉伸到 200×200,否则一个扁平的「一」会被拉成一个方框,降低识别可信度。
3.4 识别器输入包的组织方式
识别器拿到的不应该是二维数组,而是一段带包头和点数的二进制流。常见格式是固定包头,后面跟坐标数组:
uint8_t recv_buf[3 + RESAMPLE_N * 2]; recv_buf[0] = 0xAA; recv_buf[1] = (uint8_t)mode; // 模式:数字/大写/小写/混合 recv_buf[2] = RESAMPLE_N; for (i = 0; i < RESAMPLE_N; i++) { recv_buf[3 + i * 2] = out[0][i] & 0xFF; recv_buf[3 + i * 2 + 1] = out[0][i] >> 8; recv_buf[3 + RESAMPLE_N * 2] = ...; // 实际以模块手册为准 }这里坐标采用小端排列。如果是整数坐标,两个字节刚好;如果触摸分辨率超过 16 位,就要扩到 4 字节。发送后等待识别器返回类别编码。返回编码再经过模式映射,数字模式直接拼成 ASCII 字符,字符模式查表转换成对应的字母。
4. 识别状态机与四种模式切换:KEY0、串口回显和主循环设计
4.1 上电初始化顺序,识别器必须排在字库之后
初始化顺序直接决定实验成败。我这里用的顺序是:
SystemInit(); LCD_Init(); KEY_Init(); USART1_Init(115200); TP_Init(); SD_Init(); f_mount(&fs, "0:", 1); FontLib_Check(); HandWrite_Init(); LCD_Clear(WHITE);LCD_Init要在TP_Init之前,因为触摸芯片的采样触发引脚需要正确引脚映射。SD_Init放在f_mount之前,FatFs 只是文件系统层,底层设备没有起来,挂载必然失败。HandWrite_Init放在字库检测后,是为了让识别器启动时能直接读取字库或识别配置文件。若把HandWrite_Init提前,主控侧无法判断它是否拿到字库,调试时会出现「识别结果乱跳」的假象。
4.2 主循环状态机:等待笔迹、画线、识别
直接在主循环里判断TP_PRES_DOWN也能工作,但用户按住屏幕时,循环会反复执行识别逻辑,频繁刷新 LCD。更稳的做法是维护一个状态机:
typedef enum { ST_WAIT_INPUT, ST_TRACE, ST_RECOGNIZE } op_state_t; op_state_t state = ST_WAIT_INPUT; while (1) { tp_dev.scan(); if (state == ST_WAIT_INPUT) { if (tp_dev.sta & TP_PRES_DOWN) state = ST_TRACE; } else if (state == ST_TRACE) { if (tp_dev.sta & TP_PRES_DOWN) { RecordTracePoint(); } else { state = ST_RECOGNIZE; } } else if (state == ST_RECOGNIZE) { uint16_t code = HandWrite_Recognize(mode); USART1_Printf("result: 0x%04X\n", code); LCD_ShowRecognitionResult(code); state = ST_WAIT_INPUT; } DS0_Toggle(); delay_ms(5); }ST_TRACE状态下只有检测到从按下到抬起,才进入识别。抬起后即使手指再次接触屏幕,状态已经切到ST_RECOGNIZE,不会重复识别。DS0_Toggle()放在主循环末尾,正常情况下灯会闪烁;如果识别卡死,灯停在高电平或低电平,判断非常直观。5ms 延时是给整个循环一个固定节奏,避免触摸扫描频率过快导致采样点重复。
4.3 KEY0 模式切换与串口打印
模式切换是 KEY0 的一个低频分支,不需要占用额外中断:
static rec_mode_t mode = MODE_DIGIT; if (KEY_Scan(0) == KEY0_PRES) { mode = (rec_mode_t)((mode + 1) % 4); printf("mode change => %d\n", mode); LCD_ShowMode(mode); }四种模式对应数字、大写字母、小写字母、混合符号。数字模式下识别器只返回 0-9,大写模式返回 A-Z,混合模式会返回数字和大小写字母。模式设置会在轨迹送入识别器前写入recv_buf[1],让识别器缩小候选范围,提高正确率。串口打印先要重定向fputc:
int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }检查USART_FLAG_TXE表示发送数据寄存器为空;如果写USART_FLAG_TC,程序会等到数据完全发送完毕才返回,差别不大,但 TXE 更适合逐个字节打印。串口助手的编码要设成 GBK,因为 MCU 端打印中文提示时,字符串常量默认是按本地编码存放的。识别结果本身是十六进制编码,不需要转码,直接看串口即可。
4.4 标准库 v3.5 的编译注意点
这套工程大概率基于标准外设库 v3.5,在 Keil 里经常见到如下几类错误:
| 编译错误 | 常见原因 |
|---|---|
L6218E: Undefined symbol FLASH_Unlock | 没有把 stm32f10x_flash.c 加入工程 |
L6218E: Undefined symbol SD_Init | SDIO 相关库文件缺失,或宏定义不对 |
L6218E: Undefined symbol DiskioInitialize | diskio.c 与平台驱动未绑定 |
遇到undefined symbol,优先检查工程文件里是否包含对应的_it.c或_dev.c文件。标准库 v3.5 的启动文件选择也要和芯片容量对应:HD对应 512KB 以上 Flash 的型号,定义STM32F10X_HD和USE_STDPERIPH_DRIVER后,外设库的stm32f10x.h才能正确打开外设寄存器映射。stm32f10x_flash.c在工程里还有一个实际用途是保存触摸校准参数,校准结果写入 Flash 后,下次开机无需重新校准;如果直接烧库文件而不写应用逻辑,校准参数仍然只是内存变量,断电即失。
5. KEY_UP 触摸校准和调试验证:别让识别率死在坐标偏移上
5.1 校准参数怎么落到 Flash
触摸屏不准时,按 KEY_UP 进入校准。LCD 会依次在四个角和中心显示十字标记,用户点到十字位置后,控制器记录该位置触摸芯片的原始 ADC 值,再计算 LCD 坐标到触摸坐标的线性映射。简化的缩放参数如下:
scale_x = (LCD_X2 - LCD_X1) / (adc_x2 - adc_x1); offset_x = LCD_X2 - scale_x * adc_x2;LCD_X1和LCD_X2是校准点的屏幕坐标,adc_x1和adc_x2是触摸芯片上报的原始值。得到scale_x和offset_x后,对 Y 轴同理。这套线性映射适合标准四点校准;如果屏幕旋转 90 度,还需要交换 X/Y 轴和正负号,否则手写区会出现镜像。校准参数建议通过FLASH_Unlock、FLASH_ErasePage、FLASH_ProgramHalfWord写入内部 Flash,这样下次上电直接读取,不必每次校准。注意擦除时不要按页覆盖启动代码,选 Flash 末尾的扇区更安全。
5.2 验证识别率的小技巧
校准完成后不要直接写长句子,先做单字统计。循环写 0 到 9 各 5 次,每次识别后用printf把期望值和实际值一起打出,最后统计正确率。如果某个数字频繁错成另一个,先看轨迹回显,确认有没有断笔。比如数字 5 容易识别成 6,通常因为抬笔过早,末尾少了一段弧线。把超时阈值从 200ms 调到 120ms,让用户写快点,现象会有明显改善。串口输出的单字识别时间也能做参考:如果识别耗时超过 300ms,大概率是重采样点数太多或识别器串口波特率太低,可以先把RESAMPLE_N从 128 降到 64 尝试。
5.3 DS0 状态灯与主循环的调试关系
DS0 在主循环里翻转,不要把它放到定时器中断里。如果识别逻辑阻塞在HandWrite_Recognize,主循环停住,DS0 就不闪,这能直接区分是触摸采集异常还是识别器通信异常。若 DS0 闪但 LCD 不更新,问题在显示刷新部分;若 DS0 不闪但串口持续打印识别结果,说明主循环被某个while等待拖死。在校准模式下,我把 DS0 改成校准坐标更新时点亮,十字点采集完成就熄灭,可以快速确认触摸扫描是否在按预期工作。
本文还有配套的精品资源,点击获取