简介:本资源是一个基于STM32F103微控制器实现的嵌入式五子棋游戏项目,面向嵌入式初学者与STM32进阶开发者,聚焦触摸交互、SD卡数据存储与实时游戏逻辑开发等典型应用场景。项目完整包含401个文件,涵盖50个C源码(核心驱动与游戏逻辑)、43个头文件(外设与GUI接口定义)、45个编译中间文件(.o/.d)及98个HTML文档(可能为配套说明或GUI资源),另有PNG图像、HEX/AXF可执行镜像及Keil工程文件(.uvprojx/.uvoptx),整体压缩包7.64MB,结构清晰,便于分模块学习与调试。已有611人下载学习,适合通过实操掌握Cortex-M3底层驱动、SPI-SD卡读写、触摸屏坐标解析、二维棋盘状态管理及简易AI落子判断等关键技术点,是理解嵌入式人机交互系统设计的典型范例。
1. 一块STM32F103最小系统板,如何跑起一个可触摸、能存档的五子棋?
你手头那块带SPI Flash和4.3寸电阻式触摸屏的STM32F103C8T6开发板,不是只能点灯或串口打印——它真能下五子棋。这个项目不依赖PC端渲染,所有逻辑在片上运行:棋盘坐标映射由ADC采样+软件滤波完成,胜负判定用四方向线性扫描而非递归回溯,存档数据写入SD卡采用FAT16精简实现(非FatFS全量移植),连AI落子都只用固定深度的启发式评估(无Alpha-Beta剪枝)。它面向的是嵌入式初学者:代码结构清晰,中断服务例程(ISR)与主循环解耦,GPIO初始化全部显式配置(未用HAL库自动生成功能),甚至保留了Keil uVision 5.30工程中.uvprojx.bak和.uvguix.bak等备份文件——说明作者经历过真实调试崩溃。如果你正用标准库V3.5开发,又想验证SPI驱动SD卡、触摸校准、二维数组状态机这三个硬核模块,这个项目比“点灯流水灯”更贴近真实产品逻辑。
2. 触摸屏坐标映射与SD卡FAT16存储的底层实现
2.1 电阻式触摸屏的ADC采样与坐标校准
STM32F103的ADC1通道10(PA0)和通道11(PA1)分别连接X+、Y+电极,通过软件切换PB0/PB1为推挽输出控制X-、Y-接地。每次采样执行四步操作:先拉低X-使X+电极输出电压,读取PA0获取X坐标;再拉低Y-读取PA1得Y坐标;两次采样后延时10μs消除寄生电容影响;最后对连续5次采样值取中位数滤波。关键代码如下:
// touch.c 中核心采样函数 uint16_t Read_X(void) { GPIO_ResetBits(GPIOB, GPIO_Pin_0); // X- = 0 GPIO_SetBits(GPIOB, GPIO_Pin_1); // Y- = 1 (高阻) ADC_RegularChannelConfig(ADC1, ADC_Channel_10, 1, ADC_SampleTime_55Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); return ADC_GetConversionValue(ADC1); } // 校准参数存储在Flash第0页(0x08000000) typedef struct { int16_t x1, y1; // 左上角物理坐标 int16_t x2, y2; // 右下角物理坐标 } TouchCalib_t; TouchCalib_t calib_param = {120, 85, 3720, 2610}; // 实测值,非理论最大值注意:校准参数必须实测!直接使用理论ADC满幅值(0~4095)会导致触摸偏移。本项目在
main.c中预留CALIBRATE_MODE宏,短按KEY_UP进入校准界面,依次点击屏幕四角生成calib_param。物理坐标需满足x2-x1 > 3000且y2-y1 > 2000,否则线性映射失真。
2.2 SD卡SPI通信与FAT16扇区读写
SD卡通过SPI2(PB13-SCK, PB14-MISO, PB15-MOSI, PB12-NSS)连接,初始化流程严格遵循SD 2.0协议:先发74个CLK空闲脉冲,再发送CMD0复位,CMD1查询就绪状态,CMD16设置块长度为512字节。关键在于扇区地址计算——FAT16中LBA地址=数据区首扇区+簇号×每簇扇区数,而本项目将游戏存档固定存于LBA 100(即第100个512字节扇区):
// sdio.c 中写入存档的简化逻辑 uint8_t SaveGameRecord(uint8_t player, uint16_t step_count) { uint8_t buffer[512]; buffer[0] = player; // 当前玩家(1黑/2白) buffer[1] = step_count & 0xFF; // 步数低字节 buffer[2] = (step_count >> 8) & 0xFF; // 步数高字节 buffer[3] = CheckWinState(); // 胜负标志(0平局/1黑胜/2白胜) // 计算LBA地址:跳过MBR(1扇区)+FAT1(2扇区)+FAT2(2扇区)+RootDir(16扇区)=21扇区 // 数据区从22扇区开始,存档固定用第100扇区(LBA=100) if(SD_WriteBlock(buffer, 100, 1) == SD_OK) return 1; return 0; }2.2.1 FAT16关键参数解析表
| 参数名 | 值 | 说明 |
|---|---|---|
BPB_BytsPerSec | 512 | 每扇区字节数,SD卡固定值 |
BPB_SecPerClus | 1 | 每簇扇区数,本项目设为1以简化寻址 |
BPB_RsvdSecCnt | 1 | 保留扇区数(MBR所在扇区) |
BPB_NumFATs | 2 | FAT表份数(冗余备份) |
BPB_RootEntCnt | 512 | 根目录项数(FAT16限制) |
BPB_TotSec16 | 10240 | 总扇区数(5MB卡对应值) |
提示:若更换SD卡需重算
BPB_TotSec16。用diskpart在Windows下查看"磁盘容量(字节)÷512"即得该值,写入fat16.h中对应宏定义。未修改此值会导致SD_ReadBlock读取超界。
3. 五子棋状态机与触摸事件驱动的落子逻辑
3.1 棋盘状态二维数组与合法性检查
棋盘用uint8_t board[15][15]表示,初始全0(空位),1代表黑子,2代表白子。落子前必须验证三重条件:坐标在15×15范围内、目标位置为空、当前玩家符合回合制规则(current_player全局变量)。关键检查函数:
// game_logic.c uint8_t IsLegalMove(uint8_t x, uint8_t y) { if(x >= 15 || y >= 15) return 0; // 超出棋盘 if(board[x][y] != 0) return 0; // 位置已被占 if(current_player != (step_count % 2 ? 2 : 1)) return 0; // 非当前玩家回合 return 1; }3.1.1 四方向连珠检测算法详解
胜负判定不采用DFS遍历,而是对落子点(x,y)向四个方向(横、竖、斜、反斜)各延伸4格,统计同色棋子数量。例如横向检测:
uint8_t CheckHorizontal(uint8_t x, uint8_t y) { uint8_t count = 1; // 自身计1 // 向右检测 for(uint8_t i = 1; i < 5 && x+i < 15; i++) { if(board[x+i][y] == board[x][y]) count++; else break; } // 向左检测 for(uint8_t i = 1; i < 5 && x-i >= 0; i++) { if(board[x-i][y] == board[x][y]) count++; else break; } return (count >= 5) ? board[x][y] : 0; // 返回获胜方ID }注意:该算法时间复杂度O(1),比全盘扫描快100倍。但需确保
board数组索引不越界——x+i<15和x-i>=0判断必不可少,否则触发HardFault。
3.2 触摸中断与主循环协同机制
触摸事件通过EXTI9(对应PA9)触发,但实际处理在主循环中完成,避免在ISR中执行耗时操作。具体流程:
- EXTI9_IRQHandler中仅置位
touch_flag = 1; - 主循环检测到flag后调用
GetTouchPoint(&x,&y)获取坐标; - 坐标经
MapToBoard(x,y,&board_x,&board_y)转换为棋盘索引; - 执行
IsLegalMove(board_x,board_y)验证; - 合法则
board[board_x][board_y] = current_player并刷新LCD。
// main.c 主循环片段 while(1) { if(touch_flag) { touch_flag = 0; if(GetTouchPoint(&raw_x, &raw_y)) { if(MapToBoard(raw_x, raw_y, &board_x, &board_y)) { if(IsLegalMove(board_x, board_y)) { board[board_x][board_y] = current_player; step_count++; current_player = (current_player == 1) ? 2 : 1; LCD_Refresh(); // 刷新棋盘图像 SaveGameRecord(current_player, step_count); } } } } Delay_ms(10); // 防抖延时 }4. Keil工程配置与标准库V3.5下的编译排错
4.1 STM32F103标准库V3.5工程结构解析
本项目使用ST官方标准外设库V3.50(非HAL库),工程目录结构严格遵循规范:
CMSIS/:包含core_cm3.h及启动文件startup_stm32f10x_md.sFWLIB/:标准库源码(stm32f10x_gpio.c等),需在Keil中添加对应.c文件到工程USER/:用户代码(main.c、game_logic.c、touch.c等)OUTPUT/:编译输出目录(.axf、.hex)
关键配置项在Options for Target → C/C++中:
- Define:
USE_STDPERIPH_DRIVER, STM32F10X_MD - Include Paths:
.\CMSIS\,.\FWLIB\inc\,.\USER\ - Optimization:
-O1(避免内联函数导致调试困难)
4.1.1 常见编译错误与修复方案
| 错误信息 | 根本原因 | 解决方法 |
|---|---|---|
error: no stm32 target found! | Keil未安装STM32设备支持包 | 运行Keil\UV4\STM32F1xx_DFP.exe安装最新Device Family Pack |
undefined reference to 'SystemInit' | 启动文件未正确关联 | 检查Options → Target → Startup中Startup file是否为startup_stm32f10x_md.s |
ADC_GetConversionValue undefined | 未添加stm32f10x_adc.c到工程 | 在Project窗口右键→"Add Group"→添加FWLIB/src/下对应文件 |
__use_no_semihosting was requested, but _sys_exit was not defined | 未实现半主机系统调用 | 在main.c中添加__attribute__((used)) int _sys_exit(int x) { while(1); } |
4.2 ST-Link V2下载失败的硬件级排查
当Keil提示Cannot access Memory或No STM32 target found时,按顺序检查:
- SWD接线:确认SWDIO(PA13)、SWCLK(PA14)、GND、3.3V四线连接牢固,无虚焊;
- BOOT0状态:下载时BOOT0必须接GND(运行用户程序模式),BOOT1悬空;
- 电源纹波:用示波器测VDDA引脚,纹波应<50mV,否则ADC采样异常;
- 复位电路:NRST引脚外接10kΩ上拉+100nF电容,按下复位键时电压应从3.3V跌至0V再回升。
提示:若ST-Link Utility识别到设备但Keil无法下载,尝试在
Options → Debug → Settings → SWD中勾选Connect under reset,强制复位后连接。
5. 游戏AI的启发式评估与性能优化技巧
5.1 简化版AI落子策略实现
本项目AI不采用蒙特卡洛树搜索(MCTS),而是基于三类权重的启发式评估:
- 威胁分:检测对手是否已形成“活四”(两端空),立即拦截;
- 机会分:己方形成“活三”时优先扩展为“活四”;
- 中心分:棋盘中心区域(6≤x≤8且6≤y≤8)落子权重×2。
核心评估函数:
// ai.c int8_t EvaluatePosition(uint8_t x, uint8_t y) { int8_t score = 0; if(IsThreateningFour(x, y, 1)) score += 1000; // 对手活四,必须拦截 if(IsMyThree(x, y, 2)) score += 500; // 我方活三,优先扩展 if(x >= 6 && x <= 8 && y >= 6 && y <= 8) score += 200; // 中心加权 return score; } // 扫描所有空位,选择最高分位置 void AIThink(void) { uint8_t best_x = 0, best_y = 0; int8_t max_score = -128; for(uint8_t x = 0; x < 15; x++) { for(uint8_t y = 0; y < 15; y++) { if(board[x][y] == 0) { int8_t s = EvaluatePosition(x, y); if(s > max_score) { max_score = s; best_x = x; best_y = y; } } } } board[best_x][best_y] = 2; // AI执白子 }5.2 LCD刷新效率优化:局部重绘与双缓冲
原始版本每次落子重绘整个15×15棋盘(耗时约120ms),优化后仅刷新变化区域:
- 记录上一次落子坐标
last_x,last_y; - 新落子时只重绘以
(last_x,last_y)和(new_x,new_y)为中心的3×3区域; - 使用
LCD_SetCursor()定位后逐像素写入,避免全屏清屏。
// lcd_driver.c 局部刷新关键代码 void LCD_RefreshPartial(uint8_t old_x, uint8_t old_y, uint8_t new_x, uint8_t new_y) { // 计算需刷新的矩形区域(扩展1格防边缘裁剪) uint8_t min_x = (old_x > 0) ? old_x-1 : 0; uint8_t max_x = (old_x < 14) ? old_x+1 : 14; uint8_t min_y = (old_y > 0) ? old_y-1 : 0; uint8_t max_y = (old_y < 14) ? old_y+1 : 14; for(uint8_t x = min_x; x <= max_x; x++) { for(uint8_t y = min_y; y <= max_y; y++) { LCD_DrawStone(x, y, board[x][y]); // 仅重绘该坐标棋子 } } }5.2.1 双缓冲机制实现表
| 缓冲区类型 | 存储位置 | 更新时机 | 优势 |
|---|---|---|---|
| 前台缓冲区 | LCDGRAM(显存) | 每次LCD_RefreshPartial调用 | 直接显示,无延迟 |
| 后台缓冲区 | SRAM(0x20000000起) | board[][]更新后异步拷贝 | 避免刷新时数据被修改 |
启用双缓冲需在LCD_Init()中分配2KB内存,并在LCD_RefreshPartial末尾添加memcpy(LCDGRAM, back_buffer, 2048)。实测将单次刷新耗时从120ms降至35ms,触控响应延迟低于80ms,符合人眼感知流畅阈值。
技巧:若发现AI思考时屏幕卡顿,可在
AIThink()函数开头插入LCD_Fill(0,0,320,240,WHITE)快速清屏,再调用LCD_RefreshPartial局部重绘,视觉上更连贯。
本文还有配套的精品资源,点击获取