简介:这套基于STM32的双人五子棋课程设计资源,面向嵌入式、自动化、电子信息等专业的课程设计与毕设场景,提供可运行并通过测试的完整工程。压缩包共259个文件,大小6.42MB,主要包含STM32标准外设库的h/c源码、Keil工程配置文件(uvprojx)、编译产物(hex/axf)以及README说明文档。工程基于标准外设库实现了LCD棋盘绘制、触摸输入与胜负判定,双人对战流程完整,代码结构清晰,可直接打开工程烧录验证,也便于二次修改实现计分、悔棋、人机对战等扩展功能。资源代码经过运行成功后才上传,质量相对可靠,可作为入门STM32图形界面与触摸交互开发的参考案例。目前已有1089人学习/下载,适合需要快速完成嵌入式课设或初期立项演示的开发者学习使用。
1. 先把“双人五子棋”当成一块完整的嵌入式课程设计拼图
STM32 双人五子棋这个题目,看起来只是把五子棋的逻辑跑在开发板上,但实际做起来会发现它横跨了嵌入式开发里最常考的几条主线:GPIO 与中断、定时器与 PWM、液晶屏驱动、触摸输入解析、状态机设计,以及最基本的博弈逻辑。课程设计给的是“双人”,意味着你不需要去写 AI 估值函数和搜索树,真正的工作量都压在“人机交互”和“对局流程的稳定性”上。多数人做这个题目,方案是 STM32F103 系列最小系统板、一块 SPI 接口的 TFT 液晶屏(常用 2.4 寸或 2.8 寸,分辨率 320×240)、电阻触摸或四线电阻屏,再用几个 GPIO 扩展按键做辅助操作。整体难度不高,但要把触摸坐标换算、落子命中检测、胜利判定、悔棋和重开这些功能整理清楚,代码量通常在一千五百行到两千五百行之间,足够撑起一篇课程设计报告,也适合作为蓝桥杯嵌入式赛项之外的练手项目。
这篇文章不打算讲怎么把整个工程从零搭出来——那会很长,而且 Keil 工程的配置属于基础能力。我打算把重点放在“你真正需要自己想清楚的那几个环节”:触摸屏坐标怎么校正和映射、棋子绘制与胜负判定怎么写才不容易出 bug、对局状态机怎么设计才能让“双人轮流落子”这个核心流程不出乱子,以及如何把代码拆成 UI 层和逻辑层方便测试。最后给出一个串口调试和 Modbus 扩展的思路,方便你把它从课程设计升级成可演示的作品。整个过程用到的代码片段都按“能直接抄进工程”的标准来写,但会标注清楚哪些参数需要根据你的屏幕和引脚重新调整。
2. 硬件选型与接线:从“能亮屏”到“能落子”的底层准备
做 STM32 双人五子棋,先要在硬件上决定三件事:主控选谁、屏幕怎么接、触摸和按键怎么分配。这三件事直接决定了后面代码怎么写,也决定了你焊板子的时候会不会跳线跳到头大。
2.1 主控与屏幕驱动的选择原则:SPI 屏优先,FSMC 是加分项
对于课程设计级别,主控我推荐 STM32F103C8T6 或者 STM32F103RCT6。前者便宜、资料多,适合最小系统板 + 独立 TFT 屏;后者多出来的引脚和 Flash 容量能让你从容地加功能(比如存棋谱、加音效)。如果你用的是正点原子或野火的开发板,板上已经带了 TFT-LCD 接口,那直接用 FSMC 方式驱动会非常流畅,因为 FSMC 写屏幕像写内存一样,不需要软件模拟时序。如果是自己买的最小系统板 + 独立模块,SPI 接口的屏更现实,因为引脚占用少——完整驱动一块 2.4 寸 SPI 屏只需要 SCK、MOSI、CS、DC(数据/命令选择)、RST、背光,一共 6 根信号线,加上电源和地,8 根就够。
比较稳妥的组合是:STM32F103C8T6 + 2.4 寸 SPI TFT(ILI9341 或 ST7789 控制器)+ XPT2046 电阻触摸芯片。ILI9341 的驱动代码网上能找到很多版本,但我的建议是你自己把初始化序列精简一遍,去掉不用的 Gamma 校正和电源序列,只保留必要的 sleep out、display on 和像素格式设置,这样屏幕唤醒更快,代码也更清爽。SPI 速率先设到 18MHz 左右,如果显示有花屏再往下调。不要一上来就开 36MHz,有些杜邦线质量扛不住那么高的速率。
2.2 触摸屏接线与 GPIO 规划表:这 8 个引脚别接错
以 SPI 屏 + XPT2046 触摸为例,标准四线电阻触摸的 T_IRQ、T_DO、T_DIN、T_CS、T_CLK 要各占一个 GPIO。如果你买的屏模块是“SPI 接口 + 触摸合体”的那种,触摸控制器的引脚通常和屏幕的 SPI 共用时钟和数据线,只是 CS 分开。这种接法省引脚,但驱动时要小心:写完屏幕数据之后不能立刻去读触摸,需要把片选切到触摸芯片,中间加一点延时,否则两个外设会互相干扰。
下面是我在课程设计里常用的一组引脚规划,你可以直接用,也可以按自己板子的丝印调整:
| 功能 | 引脚 | 说明 |
|---|---|---|
| SPI1_SCK | PA5 | 接屏幕与触摸共用时钟 |
| SPI1_MOSI | PA7 | 共用数据线,屏幕写数据 |
| SPI1_MISO | PA6 | 触摸读数据,屏幕不用 |
| TFT_CS | PA4 | 屏幕片选,低有效 |
| TFT_DC | PA3 | 数据/命令选择 |
| TFT_RST | PA2 | 复位,低有效 |
| TCH_CS | PA1 | 触摸片选,低有效 |
| TCH_IRQ | PA0 | 触摸中断,按下时低电平 |
按键方面,如果屏幕触摸够灵敏,可以只保留一个“确认/落子”按键和一个“悔棋”按键,分别接 PB0 和 PB1,外部加上拉电阻,按下为低电平,用 EXTI 外部中断读取。如果触摸屏在课程设计答辩现场偶尔抽风,这两个物理按键就是你的保底方案——至少能让评委看到棋能下下去。
提示:PA0 和 PA1 在 F103 上可以复用为 ADC 和 TIM2 的通道,但在这个项目里优先给触摸用。编引脚表的时候先把所有功能列出来再分配,别等焊完线才发现某个引脚已经被占用。
2.3 触摸校正的最小算法:四点校准比单点准得多
电阻触摸屏的痛点在于坐标漂移:你点屏幕左上角,读回来的可能是 (120, 80) 而不是 (0, 0)。原因是触摸屏的 AD 值范围和液晶屏的分辨率之间是线性映射关系,但这两个坐标系的原点、比例尺、甚至旋转方向都不一致。如果不做校正,直接拿触摸 AD 值去和屏幕像素坐标比对,落子位置会有半个格子的偏差,对五子棋这种需要精确点格的场景来说完全不可用。
最常用的做法是四点校正。在屏幕的四个角落附近显示四个十字标记,让用户依次点上去,拿到四组 (触摸原始坐标, 屏幕目标坐标),然后套用仿射变换公式:
X_screen = A * X_touch + B * Y_touch + C Y_screen = D * X_touch + E * Y_touch + F六个未知数,四个点列出八个方程,用最小二乘求近似解。实际做的时候不用真的去解矩阵,取左上和右下两个点先算缩放比例和偏移就够了,四点校准是为了把屏幕旋转和轻微梯形失真也修正掉。完整的最小二乘解算代码比较长,课程设计里我通常用简化的两段式映射——先根据触摸坐标落在屏幕的哪个象限判断方向,再线性映射——效果也很稳定。
下面这段代码示意了简化映射的核心逻辑,实际使用时要替换成你自己读到的触摸 AD 上下限:
/* 触摸坐标到屏幕像素坐标的线性映射 */ uint16_t touch_to_screen_x(uint16_t touch_x, uint16_t touch_y) { /* 假设触摸原始坐标范围: x 0-4000, y 0-4000 */ /* 屏幕分辨率: 240 宽 (x), 320 高 (y) */ /* 注意: 电阻触摸屏的 y 方向和屏幕的 y 方向通常相反 */ uint16_t screen_x = (touch_y - TOUCH_X_MIN) * 240 / (TOUCH_X_MAX - TOUCH_X_MIN); uint16_t screen_y = 320 - (touch_x - TOUCH_Y_MIN) * 320 / (TOUCH_Y_MAX - TOUCH_Y_MIN); if (screen_x >= 240) screen_x = 239; if (screen_y >= 320) screen_y = 319; return screen_x; }这段代码的核心在于交叉映射——读取的 touch_y 映射到屏幕的 x 方向,touch_x 映射到屏幕的 y 方向,同时取反。这是四线电阻屏的典型特性:X 轴和 Y 轴的物理方向和像素坐标轴的对应关系取决于你的屏幕安装方向。如果你的屏幕读出来方向不对,先把这一步调对再往下做,否则后面的所有命中检测都是错的。
提示:触摸校正参数最好存入 STM32 的 Flash 内部,比如占据最后两个扇区,避免每次开机都要重新校准。掉电保存只需要调用一次 HAL_FLASH_Program 和 HAL_FLASH_Unlock 相关的接口,代码量不大但非常实用。
3. 棋盘建模与绘制:用 15×15 的二维数组撑起全部对局逻辑
五子棋的核心数据结构特别简单:一个 15×15 的二维数组。每个元素的值表示该位置的状态——0 表示空,1 表示黑子,2 表示白子。但简单不意味着可以随便写,因为围绕这个数组的所有操作——绘制、落子、判定胜负、悔棋——如果不在设计阶段统一约定好,后面去耦合会非常痛苦。
3.1 棋盘点位到像素坐标的换算公式:一格 20 像素是安全值
五子棋棋盘画在 320×240 的横屏还是 240×320 的竖屏,直接决定格子大小。我建议你用横屏 320×240,棋盘预留左侧 10 像素边距,顶部留出 20 像素显示当前轮到谁,剩下的区域画 15×15 的网格。这样算下来,水平方向 300 像素分 14 个间隔,每个格子大约 21 像素;垂直方向 220 像素分 14 个间隔,约 15.7 像素。
为了画棋子方便,格子大小最好取整数。我常用的方案是:格子大小 20 像素,棋盘起点 (10, 30),这样水平方向 15 个点从 10 到 290,垂直方向从 30 到 310——超过 320×240 了,所以这个方案只适合竖屏。如果你用横屏,就把棋盘旋转 90 度重新计算。这里给出一组在竖屏 240×320 下的可靠参数:
棋盘起点: (20, 40) 格子大小: 20 像素 15 条横线 y 坐标: 40, 60, 80, ..., 320 (共 15 条,最后一条在 320 处) 15 条竖线 x 坐标: 20, 40, 60, ..., 300 (共 15 条,最后一条在 300 处) 棋子直径: 18 像素 (要比格子略小,视觉上留出间隙)对应的坐标换算公式为:
#define GRID_SIZE 20 #define BOARD_X0 20 #define BOARD_Y0 40 /* 棋盘下标(row, col) -> 像素坐标 */ uint16_t get_pixel_x(uint8_t col) { return BOARD_X0 + col * GRID_SIZE; } uint16_t get_pixel_y(uint8_t row) { return BOARD_Y0 + row * GRID_SIZE; } /* 像素坐标 -> 棋盘下标 */ /* 注意先做边界判断再转换,防止越界写数组 */ uint8_t get_board_col(uint16_t px) { if (px < BOARD_X0 - GRID_SIZE/2) return 0xFF; /* 无效值 */ if (px > BOARD_X0 + 14 * GRID_SIZE + GRID_SIZE/2) return 0xFF; return (px - BOARD_X0 + GRID_SIZE/2) / GRID_SIZE; } uint8_t get_board_row(uint16_t py) { if (py < BOARD_Y0 - GRID_SIZE/2) return 0xFF; if (py > BOARD_Y0 + 14 * GRID_SIZE + GRID_SIZE/2) return 0xFF; return (py - BOARD_Y0 + GRID_SIZE/2) / GRID_SIZE; }这套换算的核心逻辑是“无条件的四舍五入太危险,应该先判断范围再取整”。get_board_col 函数里先检查触摸坐标是否落在棋盘区域向外扩大半个格子的范围内,如果不在就直接返回 0xFF 表示无效,这样触摸到边框外时不会算出负数下标。
提示:很多人在这一步直接写
(px - 20) / 20取整,导致点在两个格子正中间时容易落到错误的格子上。加一个GRID_SIZE/2的偏移再做整数除法,就相当于四舍五入了,这是最常见的修正手段。
3.2 棋子绘制的两种方式:全屏重绘和增量绘制
绘制棋子最简单粗暴的方式是每次落子后全屏重绘整个棋盘——代码只有几行,但刷新一次 320×240 的 SPI 屏大约需要 80 到 150 毫秒,如果还叠加了触摸读取和胜负判定,体感就是“卡一下”。更合理的做法是增量绘制:只画刚落下的那一个棋子。
增量绘制很简单:根据棋盘下标的行列号算出像素坐标,在那个 18×18 的圆形区域内先填充背景色,再画圆形。但重复落子或悔棋后,原先位置的棋子被擦除时,底下如果残留之前棋子的边缘,会出现“鬼影”。我的解决办法是维护一个 dirty 标志位:每次改变棋盘状态后,记录受影响的格子坐标,在下一次刷新回调里统一重绘这些格子。
typedef struct { uint8_t row; uint8_t col; uint8_t dirty; /* 1 表示需要重绘 */ } dirty_cell_t; dirty_cell_t dirty_cells[32]; uint8_t dirty_count = 0; void mark_dirty(uint8_t row, uint8_t col) { if (dirty_count < 32) { dirty_cells[dirty_count].row = row; dirty_cells[dirty_count].col = col; dirty_cells[dirty_count].dirty = 1; dirty_count++; } } void redraw_dirty_cells(void) { for (uint8_t i = 0; i < dirty_count; i++) { uint8_t row = dirty_cells[i].row; uint8_t col = dirty_cells[i].col; draw_single_grid(row, col); /* 重绘底色和十字交叉点 */ if (board[row][col] == 1) { draw_piece(row, col, BLACK_PIECE); } else if (board[row][col] == 2) { draw_piece(row, col, WHITE_PIECE); } } dirty_count = 0; }draw_single_grid 函数负责在指定格子坐标上先画底色矩形,再画网格线,最后调用 draw_piece 画棋子。这样悔棋时,只需要把对应格子的状态改成 0 然后标记 dirty,重绘后旧棋子就会被底色覆盖。
如果屏幕刷新速度特别慢,还可以把背景色、网格、棋子的绘制全部改为 DMA 传输——但 STM32F103 的 SPI DMA 需要处理 NSS 片选和 DC 引脚的时序配合,复杂度提升明显,课程设计不推荐,除非你是为了答辩加分。
3.3 落子命中检测:先判断棋子是否已存在,再落子
触摸输入最容易被忽视的问题是“落子时手指还压在屏幕上”。电阻屏的物理特性决定了手指没有离开时,触摸坐标读出来会持续抖动,如果你不判断触摸释放,一次按压可能触发两三次落子。所以落子的完整流程是:检测到触摸按下,读取并校正坐标,换算成棋盘行列,先检查该位置是否已有棋子,如果没有就落子、切换玩家、等待触摸释放,最后才进入下一次触摸检测。
void handle_touch_input(void) { uint16_t tx, ty; uint8_t row, col; if (touch_pressed() == 0) return; /* 无触摸按下 */ read_touch_raw(&tx, &ty); /* 读 AD 原始值 */ uint16_t px = touch_to_screen_x(tx, ty); /* 校正 + 映射 */ uint16_t py = touch_to_screen_y(tx, ty); row = get_board_row(py); col = get_board_col(px); if (row == 0xFF || col == 0xFF) return; /* 点击在棋盘外 */ if (board[row][col] != 0) return; /* 已有棋子,拒绝落子 */ board[row][col] = (current_player == PLAYER_BLACK) ? 1 : 2; mark_dirty(row, col); redraw_dirty_cells(); if (check_win(row, col)) { show_winner(current_player); game_state = GAME_OVER; return; } current_player = (current_player == PLAYER_BLACK) ? PLAYER_WHITE : PLAYER_BLACK; update_status_bar(); /* 显示当前轮到谁 */ while (touch_pressed()); /* 等待手指释放 */ }这个函数的调用时机放在主循环里,配合触摸中断置位的一个全局标志位来触发。要注意while (touch_pressed())这一句——如果是纯轮询模式,这里卡住会导致整个系统短暂阻塞,但考虑到后面本来就要等待用户操作,这个阻塞是可以接受的。如果你要在等待释放期间刷新屏幕动画,就把这个 while 改成状态机的一个状态,效果更好。
4. 胜利判定与状态机:双人轮流对局里最容易被测试击穿的逻辑
五子棋的胜利判定算法本身不复杂,但放在嵌入式环境里,因为要考虑资源占用和代码可维护性,写法上还是有讲究的。
4.1 四方向遍历的胜利判定:从落子点扩散而不是全盘扫描
最简单也最不容易出错的胜利判定方法是:从最后一颗落子的位置出发,沿着水平、垂直、主对角线、副对角线四个方向,分别向两个方向延伸统计连续同色棋子的数量,如果某个方向连续数达到 5 就判胜。这个方法比全盘扫描 15×15 的数组要高效得多——最坏情况只需要检查 4 个方向各 4 个格子,计算量几乎可以忽略。
uint8_t check_win(uint8_t row, uint8_t col) { int8_t dirs[4][2] = {{1,0},{0,1},{1,1},{1,-1}}; uint8_t player = board[row][col]; if (player == 0) return 0; for (uint8_t d = 0; d < 4; d++) { uint8_t count = 1; int8_t dr = dirs[d][0]; int8_t dc = dirs[d][1]; /* 正方向延伸 */ for (int8_t step = 1; step <= 4; step++) { int8_t r = row + dr * step; int8_t c = col + dc * step; if (r < 0 || r >= 15 || c < 0 || c >= 15) break; if (board[r][c] == player) count++; else break; } /* 反方向延伸 */ for (int8_t step = 1; step <= 4; step++) { int8_t r = row - dr * step; int8_t c = col - dc * step; if (r < 0 || r >= 15 || c < 0 || c >= 15) break; if (board[r][c] == player) count++; else break; } if (count >= 5) return 1; } return 0; }这里的边界检查用r < 0 || r >= 15 || c < 0 || c >= 15完成,四个方向分别向正负延伸最多 4 步,避免数组越界。注意 dirs 数组里第三组元素是 {1,1},代表主对角线;第四组是 {1,-1},代表副对角线,方向已经包含了正负两个方向的情况。
一个常见误判是把胜利判定写成“遍历整个棋盘检查是否有五连”,这在 15×15 的小棋盘上功能没问题,但无法复用到一个更通用的接口上——比如将来你给 AI 博弈写搜索函数时,需要的是“某个点落子后是否获胜”而不是“全局是否有人获胜”。从这个角度讲,从落子点扩散的写法是更合理的基础设施。
4.2 游戏状态机:空闲、对局、结束、悔棋四个状态的流转
双人五子棋不能只有一个“下棋”状态,如果不在代码里区分当前处于什么阶段,悔棋、重开、回合切换这些功能都会出 bug。我常用的状态定义如下:
typedef enum { GAME_IDLE = 0, /* 初始状态,显示主菜单或等待开始 */ GAME_PLAYING, /* 对局中,允许落子 */ GAME_OVER, /* 分出胜负,等待确认 */ GAME_UNDO_CONFIRM, /* 悔棋确认弹窗 */ } game_state_t; game_state_t game_state = GAME_IDLE;整个状态流转的逻辑是:开机进入 IDLE,按“开始”按键进入 PLAYING,对局中每次成功落子后检查是否胜利——胜利则进入 OVER 状态,显示胜者并停止落子;如果没胜利,玩家可以选择悔棋、也可以继续落子。悔棋流程走一个 CONFIRM 状态,弹出“是否悔棋”,确认后弹出棋盘上最后两步(黑方和白方各退一步),回到 PLAYING 并切换回正确玩家。
状态机实现对课程设计来说不需要引入复杂框架,直接在 main loop 里用 switch-case 就行:
void game_loop(void) { switch (game_state) { case GAME_IDLE: if (button_start_pressed()) { reset_board(); game_state = GAME_PLAYING; update_status_bar(); } break; case GAME_PLAYING: handle_touch_input(); handle_button_input(); break; case GAME_OVER: /* 触摸或按键进入重新开始确认 */ if (button_restart_pressed()) { reset_board(); game_state = GAME_PLAYING; } break; case GAME_UNDO_CONFIRM: handle_undo_confirm(); break; default: break; } }每个 case 内部的处理函数要保持简短,不要在 case 里写大段逻辑。特别是 GAME_PLAYING 里的 handle_touch_input,前面已经演示过,它的职责非常单一——只处理“触摸落在合法位置”这件事。所有 UI 绘制和状态迁移都通过调用独立的函数完成,这样后面测试时把触摸和按键的输入源替换成串口命令也能跑通同样的流程。
提示:悔棋功能有一个隐蔽的坑——如果一方已经获胜进入 GAME_OVER,悔棋应该被禁止,因为胜负已定,回退到最后一手棋会让胜负状态失效。处理方式是在进入 GAME_OVER 的时候把悔棋按钮置灰或直接屏蔽。
4.3 回合控制:不是简单的 current_player 翻转
双人轮流落子的回合控制,表面上只是黑棋下完切白棋、白棋下完切黑棋,但实际要考虑几个问题:第一,如果触碰的是棋盘外的区域,不能切换玩家;第二,如果落子的位置已经有棋子,不能切换玩家;第三,悔棋之后回到的回合是什么?要回退到“被悔棋那一步的玩家”而不是简单翻转一次。
处理第一和第二种情况,只需要在 handle_touch_input 里把玩家切换的代码放在所有合法性检查之后。这个顺序很多人会写错——先切换了 current_player 再检查棋盘状态,结果就是空点落子变成了白棋落子。处理悔棋的回合切换,需要在悔棋函数里维护一个“最后一步棋的玩家记录”:
uint8_t last_move_player = 0; /* 0: 无记录, 1: 黑, 2: 白 */ void undo_last_move(void) { if (move_history_count == 0) return; move_record_t *last = &move_history[move_history_count - 1]; board[last->row][last->col] = 0; mark_dirty(last->row, last->col); last_move_player = last->player; move_history_count--; /* 回退后轮到上一步落子的玩家再次落子 */ current_player = last_move_player; redraw_dirty_cells(); } void record_move(uint8_t row, uint8_t col, uint8_t player) { if (move_history_count < MAX_MOVES) { move_history[move_history_count].row = row; move_history[move_history_count].col = col; move_history[move_history_count].player = player; move_history_count++; } }move_history 是一个数组,每次落子时记录坐标和玩家,悔棋时弹栈。这个设计比“记录完所有棋盘状态再整体回滚”要轻量得多,而且天然支持连续悔棋——只要循环调用 undo_last_move 就能回退任意多步。对于课程设计的答辩场景,能在评委面前连续悔两步棋并且界面不花,属于非常加分的演示。
5. UI 设计与人机交互:减少误触的三种细节处理
五子棋的 UI 不需要炫酷,但必须让人一眼看清“现在轮到谁”“我上次点到哪了”“刚才那步棋在哪”。这三个信息对应三个 UI 元素:顶部状态栏、最近落子高亮、棋盘网格交叉点。
5.1 状态栏信息布局:轮次、步数、胜负提示在同一行
竖屏 240×320 下,顶部 40 像素已经留给了状态栏。布局建议是:左侧 8 像素字体显示“黑方/白方”,中间显示步数(阿拉伯数字即可),右侧留空给特殊状态提示,比如“黑胜”或者“悔棋?”。状态栏底色用深灰色,文字用白色,这样在阳光直射下也还能辨认。
刷新状态栏时不要全屏重绘,只刷新变了的部分。可以画一个矩形覆盖旧文字区域然后重新绘制文本,或者更高效地写一个 draw_text_at 函数,支持直接覆盖。清空区域内残留像素最稳妥的方法是先 fillRect 再 drawString,两步操作 20 毫秒内完成。
5.2 最近落子高亮与坐标指示:小方框提示当前位置
落子后,在刚落下的棋子上画一个小方框,用于标识“上一步棋在哪”。这个看似简单,但必须处理“上一步棋被悔棋后,高亮应该消失或移动”的情况。实现上,维护一个全局变量:
uint8_t last_move_row = 0xFF; uint8_t last_move_col = 0xFF; void clear_last_move_highlight(void) { if (last_move_row != 0xFF && last_move_col != 0xFF) { draw_single_grid(last_move_row, last_move_col); if (board[last_move_row][last_move_col] != 0) { draw_piece(last_move_row, last_move_col, board[last_move_row][last_move_col]); } } last_move_row = 0xFF; last_move_col = 0xFF; }在每次落子后,先调用 clear_last_move_highlight 清掉旧的高亮,再给新落子标记高亮。绘制高亮的方法是在棋子外圈画一个 2 像素宽的方框,颜色用亮绿色或黄色,和黑子白子都区分得开。
5.3 触摸消抖与误触过滤:连续读取、阈值判定、区域屏蔽
电阻触摸的抖动问题比电容屏严重得多,尤其是在快速点击时。常用的消抖手段有三种:
- 连续读取 3 次触摸坐标,如果最大差值小于阈值(比如 10 个 AD 值),才认为是有效点击。
- 从触摸按下到触摸释放之间,忽略所有新触发的落子逻辑,也就是前面提到的“等待释放”逻辑。
- 为按钮区域设置最小有效点击间隔,比如两次落子间隔必须在 200 毫秒以上,防止快速双击导致意外落子。
第二种手段效果最好,因为它从物理层面杜绝了一次按压触发多次落子的情况。第三种手段适合在触摸屏反应迟钝导致用户“用力多点几次”的场景下使用,能防止积压的触摸事件在释放后连续触发。实际代码可以在 handle_touch_input 里加一个时间戳判断:
uint32_t last_move_time = 0; if (HAL_GetTick() - last_move_time < 200) return; last_move_time = HAL_GetTick();这个判断放在触摸读数和坐标换算之后,确保落子是有效的才更新时间戳。如果点在棋盘外或已有棋子的位置,不更新时间戳,这样用户快速重试不会受到间隔限制。
6. 串口调试与 Modbus 扩展:把课程设计从“能玩”做到“能讲”
课程设计答辩时,真正能把分数拉开的是演示流程和功能完整性,而不是代码里某个算法写得有多漂亮。我的建议是至少加一个“串口调试”能力,让评委能通过 PC 端串口助手直接看到当前棋盘状态和胜负判定结果,而不是只看屏幕。进一步,如果你还有余力,把对局扩展成 Modbus 暂存指令控制,在“嵌入式”这个标签上加分明显。
6.1 串口协议设计:三行命令覆盖调试全场景
一个简单的文本协议就够了,不需要二进制帧。我用过一套很轻的协议,规则是:
- 串口发送
show,回显当前棋盘,用数字矩阵打印 15×15,0/1/2 分别表示空/黑/白。 - 串口发送
move r c,模拟在 (r, c) 落子,走和触摸落子完全相同的逻辑入口。 - 串口发送
undo,触发悔棋。 - 串口发送
reset,重置棋盘。
void uart_command_handler(char *cmd) { if (strncmp(cmd, "move", 4) == 0) { uint8_t row, col; sscanf(cmd + 4, "%hhu %hhu", &row, &col); if (row < 15 && col < 15 && board[row][col] == 0) { board[row][col] = (current_player == PLAYER_BLACK) ? 1 : 2; record_move(row, col, board[row][col]); mark_dirty(row, col); redraw_dirty_cells(); if (check_win(row, col)) { show_winner(current_player); game_state = GAME_OVER; } else { current_player = (current_player == PLAYER_BLACK) ? PLAYER_WHITE : PLAYER_BLACK; update_status_bar(); } } } else if (strncmp(cmd, "undo", 4) == 0) { undo_last_move(); } else if (strncmp(cmd, "reset", 5) == 0) { reset_board(); } }串口命令直接复用线程状态机里的逻辑,而不是重新写一套落子代码,这样保证“屏幕上看到的”和“串口打印的”永远一致。调试时如果发现触摸坐标换算有偏差,可以直接用move命令精确落子,排除触摸硬件干扰,单独验证逻辑层。
6.2 Modbus 扩展:一张映射表解释全部寄存器
如果你拿的是 485 接口的开发板,或者想展示自己在通信协议上的能力,可以把双人五子棋改造成“上位机通过 Modbus 控制落子”的演示。Modbus 寄存器映射可以这样设计:
| 寄存器地址 | 读写属性 | 含义 |
|---|---|---|
| 0x0000 | 只读 | 当前棋盘状态,位 0-14 表示第 0 行,位 15 恒 0 |
| 0x0010 | 只写 | 落子命令,低字节为列号,高字节为行号 |
| 0x0011 | 只写 | 0x01 表示悔棋,0x02 表示重置 |
| 0x0020 | 只读 | 当前玩家:1=黑,2=白 |
| 0x0021 | 只读 | 步数计数 |
映射表的意义不只是给上位机用——它还倒逼你把代码拆成“协议层-逻辑层-驱动层”。协议层只负责解析 Modbus 帧,逻辑层暴露函数接口,驱动层管屏幕和触摸。这个架构在面试时能直接回答“你如何保证代码可测试”这个问题。
6.3 验证技巧:用串口脚本做回归测试
课程设计不需要完整的自动化测试,但你可以写一个简单的 Python 脚本,通过串口向 STM32 循环发送固定棋谱,验证胜负判定是否正确。这个脚本本质上是把你的 move 命令依次发给板子,每次发完等待 100 毫秒,读取回显确认落子成功。如果某个棋谱该判胜但没判,说明 check_win 逻辑有边界问题。
我最常用的验证棋谱有两组:一组是横连五子,模拟赢棋;一组是斜连五子但只连到四,确认不会误判。前者验证正向功能,后者验证边界条件。如果这两组都过了,胜负判定基本不会出大问题。棋谱本身很简单,比如 “move 7 7” 到 “move 7 11” 五步就能把黑棋横连。最后一手落完,回显里应该出现玩家胜负标记。
本文还有配套的精品资源,点击获取