
简介这是一个基于STM32微控制器的双人五子棋嵌入式课程设计项目面向嵌入式、自动化、电子信息、通信工程等专业的课程设计、毕业设计及入门进阶开发场景。工程采用Keil MDK集成环境开发代码中包含LCD液晶显示、定时器、Flash存储、RCC时钟等标准外设库驱动完整实现了从底层硬件初始化到双人对弈逻辑的整个流程。压缩包共259个文件以71个C语言源文件和74个头文件为主体同时附有Keil工程配置文件、编译生成的hex/axf烧录文件以及README说明文档整体体积约6.42MB便于直接导入工程查看或烧录至开发板运行验证。当前已有1089人学习访问代码经作者完整测试并成功运行答辩评审平均分达94.5分可直接参考其界面绘制、落子判定和胜负判断的设计思路。也可在现有框架上继续修改扩展更多游戏功能或用于项目初期演示答辩。1. 嵌入式课程设计的选题逻辑为什么双人五子棋比点灯更适合上手双人五子棋在 PC 上用几百行代码就能写完但放到 STM32 上难点瞬间从算法转向工程一块小尺寸 LCD 怎么在裸机上刷出棋盘和棋子、按键怎么消抖并转成光标移动、15x15 的棋盘状态怎么保存以及胜负判定函数在 72 MHz 主频下会不会拖垮主循环。这些恰好是嵌入式课程设计最常见的考查点也是面试时“嵌入式八股文”里 GPIO、SPI、定时器、状态机的综合应用。这个选题不依赖操作系统不需要网络协议栈一份 HAL 或标准库工程就能完整跑起来适合作为嵌入式学习路线上一个能自闭环的里程碑也常被拿来改造成毕业设计或蓝桥杯嵌入式类真题的训练载体。接下来按硬件、驱动、逻辑、交互、验收五条线展开每一条都会给可以直接落地的代码和参数。2. 基于 STM32 的硬件拓扑与引脚分配先定外设再写驱动2.1 最小硬件清单控制器、显示屏、输入设备怎么搭做双人五子棋核心外设是“一块能画点和线的屏”和“至少四个可独立识别的按键”。最常见也最稳的组合是 STM32F103C8T6 或 STM32F103RCT6 加一块 SPI 接口的 TFT LCDILI9341 或 ST7789再配五个轻触按键分别承担上、下、左、右、确认。如果手头有 SSD1306 OLED 也可以做只是 128x64 分辨率下 15x15 的棋盘网格太密通常会把棋盘缩成 9x9 或 7x7代码结构不变但演示效果会打折扣。选择屏幕时优先看接口。SPI 屏只占 5 根线比并口屏少得多而且用硬件 SPI 加 DMA 后刷新一帧也足够快I2C 屏接线最少但像素写入慢画棋盘和棋子时能感到明显逐行扫描不建议在课程设计里选。输入设备方面独立按键的可靠性比矩阵键盘高消抖逻辑也简单后续换摇杆只是把方向键事件改成 ADC 采样判断的问题状态机不用动。提示两块屏幕都能跑通同样的逻辑层区别只在lcd_set_pixel(x, y, color)这一层。想省事就把屏幕接口抽象成绘制基元后面换屏只改驱动文件。2.2 引脚分配表与接线注意以 SPI2 接口的 ILI9341 为例一组常用引脚分配如下功能引脚模式说明LCD_SCKPB13AF_PPSPI2_SCK时钟频率按 30 MHz 以下设LCD_MOSIPB15AF_PP写数据用ILI9341 不需要读回LCD_CSPB12GPIO_PP片选低电平有效软件控制LCD_DCRSPB0GPIO_PP命令/数据选择LCD_RSTPB1GPIO_PP复位脚初始化时先低后高LCD_BLPA8GPIO_PP背光可接定时器通道做 PWM 调光KEY0~KEY3PE2~PE5INPUT_PULLUP方向键按下为低电平KEY_CONFIRMPE6INPUT_PULLUP落子确认键接线时注意三点一是 DC 和 RST 不要选在只有 SWD 或 JTAG 复用的引脚上否则调试器连接会受干扰二是所有按键必须外部上拉或使能内部上拉按下读 0 在裸机代码里处理起来最直接三是 LCD 的电源引脚旁边放一个 100nF 去耦电容屏幕刷新时电流抖动会造成 SPI 数据错误这是硬排错时常被忽略的一环。如果拿到的是 STM32F103C8T6 最小系统板引脚会少一些可以把按键挪到 PA0~PA4。务必在 CubeMX 的 GPIO 配置里把速度设为 Low外设引脚速度设为 High两者分开调这是新人常踩的坑。2.3 软件架构分层与状态枚举软件层面建议分三层设备驱动层管理 LCD 和按键输入逻辑层管理棋盘数据、落子合法性、胜负判定状态机层管理菜单、对局、结算三个主流程。这样划分后驱动和逻辑可以分别单独测试状态机里不出现任何HAL_GPIO_WritePin的散落调用。状态枚举在移植到任何屏幕上都通用typedef enum { ST_MENU, // 初始菜单等待开始 ST_PLAYING, // 对局中轮到玩家 1 或玩家 2 ST_END // 胜负已出或棋盘下满 } game_state_t; game_state_t state ST_MENU;三个状态之间的转移由按键事件触发菜单状态下按确认进入对局对局中满足胜负条件进入结算结算后按确认复位棋盘回到菜单。这套转移关系先画成表再写代码比在 while 循环里堆if要清晰得多答辩时也更好说明。3. 驱动层搭建LCD 绘制、按键扫描与坐标输入3.1 CubeMX 配置要点与芯片支持包安装工程初始化用 STM32CubeMX 生成选好芯片型号后首先要确认本地已经安装对应系列的固件包。CubeMX 首次创建工程会联网下载STM32Cube_FW_F1如果网络不稳定可以在 Help 菜单下通过“Manage embedded software packages”从本地离线包导入这一步对应很多教程里提的“STM32 芯片包安装”。生成工程时只勾选SPI2、GPIO、USART1用于日志输出不要勾选多余中间件裸机工程越干净越容易排查问题。SPI2 参数按如下设置Baud Rate Prescaler选 3 分频或 4 分频Clock Polarity为 LowClock Phase为 1 EdgeData Size为 8 Bit。ILI9341 支持 SPI Mode 0 和 Mode 3绝大多数驱动库默认 Mode 0保持默认数据格式即可。3.2 LCD 绘制基元的实现驱动代码不需要完整重写屏幕控制器协议通常的做法是移植一份针对 ILI9341 的底层初始化然后封装三个函数供上层使用void lcd_init(void); // 发送初始化序列 void lcd_fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color); void lcd_set_pixel(uint16_t x, uint16_t y, uint16_t color);基于lcd_set_pixel再扩展画线、画矩形、画圆和 ASCII 字符。这里最关键的优化是所有绘制函数都只操作一块与屏幕分辨率对应的LCD_BUF内存数组最后一次性把整块缓冲刷到屏幕上。TFT 屏直接逐像素写会很慢整块缓冲提交后才能让双人五子棋的棋盘刷新看起来流畅。#define LCD_WIDTH 240 #define LCD_HEIGHT 320 uint16_t lcd_buf[LCD_WIDTH * LCD_HEIGHT]; // 颜色为 RGB565 void lcd_refresh(void) { // 按 ILI9341 的行列顺序把 lcd_buf 写到 GRAM for (int y 0; y LCD_HEIGHT; y) { lcd_set_window(0, y, LCD_WIDTH - 1, y); lcd_write_buf(lcd_buf[y * LCD_WIDTH], LCD_WIDTH); } }这段代码里lcd_set_window指定 GRAM 写入区间一次写入一行能减少 8-bit/16-bit 模式切换的开销。RGB565 在 240x320 分辨率下占 150 KB而 STM32F103RCT6 有 48 KB 内存放不下整块缓冲F103RCT6 类大内存芯片可以用双缓冲C8T6 最小系统板建议改用局部缓冲加行扫描的方式或直接选 128x160 分辨率的屏幕降低内存压力。3.3 按键扫描把机械抖动变成稳定事件按键处理不能直接在中断里读电平常见做法是定时器每 10 ms 进一次中断扫描或者更好用 SysTick 置一个“该扫描了”标志主循环里调用扫描函数。去抖采用连续计数法连续 3 次读到相同电平才确认状态变化同时只对“按下”边沿产生一次事件。#define KEY_NUM 5 #define KEY_UP 0x01 #define KEY_DOWN 0x02 #define KEY_LEFT 0x04 #define KEY_RIGHT 0x08 #define KEY_CONFIRM 0x10 uint8_t key_state[KEY_NUM]; uint8_t key_tick[KEY_NUM]; uint8_t scan_key(void) { uint8_t evt 0; uint8_t lvl read_key_gpio_all(); for (int i 0; i KEY_NUM; i) { uint8_t pressed (lvl (1 i)) ? 1 : 0; if (pressed) { if (key_tick[i] 3) key_tick[i]; if (key_tick[i] 3 key_state[i] 0) { key_state[i] 1; evt | (1 i); // 只有“按下”边沿触发 } } else { key_tick[i] 0; key_state[i] 0; } } return evt; }逻辑说明key_tick记录连续扫描到同一电平的次数达到 3 次才更新key_state天然去掉了 20~30 ms 的机械抖动key_state只在这个函数中切换用于抑制长按期间重复触发。返回值和事件标志兼容后续状态机里的位运算判断。4. 核心对弈逻辑棋盘数据结构、坐标转换与五连判定4.1 棋盘数据结构选型双人五子棋的状态主体是 15x15 的棋盘每个交叉点只有空、黑、白三种状态用uint8_t数组最直接#define BOARD_SIZE 15 #define CELL_PX 20 // 每格像素宽度 #define GRID_X0 18 // 棋盘左上角起始屏幕坐标 #define GRID_Y0 40 uint8_t board[BOARD_SIZE][BOARD_SIZE]; // 0空 1黑子 2白子 int8_t cursor_x, cursor_y; // 当前光标逻辑坐标 uint8_t current_player 1; // 当前执子方为什么不用一个整数位域压缩课设规模下省不了多少内存但每次读写要多次移位代码可读性差。这个数据结构已经能支持落子、判断重复落子、悔棋和重新开始。cursor_x使用int8_t足够覆盖 -128~127但做边界判断时必须先检查再操作数组下标避免数组越界。4.2 坐标映射把按键移动转成屏幕落点逻辑坐标和屏幕坐标是两套系统光标移动只改变逻辑坐标绘制时才换算成屏幕坐标void cursor_move(int8_t dx, int8_t dy) { int8_t nx cursor_x dx; int8_t ny cursor_y dy; if (nx 0 nx BOARD_SIZE ny 0 ny BOARD_SIZE) { cursor_x nx; cursor_y ny; } } uint16_t cursor_screen_x(void) { return GRID_X0 cursor_x * CELL_PX; } uint16_t cursor_screen_y(void) { return GRID_Y0 cursor_y * CELL_PX; }绘制光标时先在原位置擦除旧光标再在新位置用反色矩形框标记避免整屏重绘。屏幕刷新频率由主循环里的刷新标志控制标志由定时器置位一般 20 Hz 就足够肉眼看到光标是“一个闪烁的方框”不会产生残影。4.3 从最后落子向外数四种方向一次命中五连判定五连判定的常见错误写法是每下完一子就全盘扫描每个交叉点时间复杂度是 O(n²)在 15x15 上虽然也能跑但嵌入式中不值得浪费 CPU 时间。正确做法是只从最后落下的那一子出发沿水平、垂直、主对角线、副对角线四个方向的正向和反向各数一次相同棋子的连续数量。static const int8_t dirs[4][2] { { 1, 0}, // 水平 { 0, 1}, // 垂直 { 1, 1}, // 主对角线 { 1, -1} // 副对角线 }; uint8_t check_win(uint8_t px, uint8_t py, uint8_t player) { for (int8_t d 0; d 4; d) { int8_t cnt 1; for (int8_t s 1; s 5; s) { int8_t x px dirs[d][0] * s; int8_t y py dirs[d][1] * s; if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE || board[x][y] ! player) { break; } cnt; } for (int8_t s 1; s 5; s) { int8_t x px - dirs[d][0] * s; int8_t y py - dirs[d][1] * s; if (x 0 || x BOARD_SIZE || y 0 || y BOARD_SIZE || board[x][y] ! player) { break; } cnt; } if (cnt 5) { return 1; } } return 0; }判定从第 2 个循环开始前先检查x、y是否越过棋盘边界再读取board[x][y]顺序一定不能反否则 C 语言判定短路失效后可能访问到非法地址。两个方向的循环限制在 4 次内因为五连意味着只需往两边最多数 4 个位置就足够覆盖正负方向用 cnt 5 而不是 cnt 5是为了兼容“活四”之后的第六子等延长线属于同一方依然成立。这个函数放在主循环里调用一次事件触发时才执行不要放进按键中断或高优先级中断。课程验收时老师会问你冲四和活四怎么判断可以在check_win基础上再扩展一个count_empty_at_both_sides函数专门统计两端空位这是从“能下”走向“会分析棋形”的入门分水岭。5. 状态机落地双人回合切换、胜负显示与重开流程5.1 主循环结构一个事件一次处理主循环不要写成顺序执行一大串函数的裸循环而是每轮只做四件事查按键事件、查定时器刷新标志、更新光标、执行一次当前状态下的处理。整个游戏状态被锁在state变量里不同状态下同样的按键事件会走到完全不同的分支这就是状态机带来的可维护性。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI2_Init(); lcd_init(); game_reset(); while (1) { uint8_t evt scan_key(); if (evt) { game_handle_key(evt); } if (ui_refresh_flag) { refresh_cursor(); ui_refresh_flag 0; } } }ui_refresh_flag由 SysTick 中断每 20 ms 置 1表示“这一轮可以刷新光标了”实际刷新放主循环是防止在中断里调用耗时过长的 LCD 缓冲提交函数。5.2 对局中的按键处理与回合切换对局状态下按键处理的核心逻辑如下void game_handle_key(uint8_t evt) { if (state ST_MENU) { if (evt KEY_CONFIRM) { state ST_PLAYING; game_reset(); } return; } if (state ST_PLAYING) { if (evt KEY_UP) cursor_move(0, -1); if (evt KEY_DOWN) cursor_move(0, 1); if (evt KEY_LEFT) cursor_move(-1, 0); if (evt KEY_RIGHT) cursor_move(1, 0); if (evt KEY_CONFIRM) { if (board[cursor_x][cursor_y] ! 0) { play_warning_tone(); // 非空位置落子提示 return; } board[cursor_x][cursor_y] current_player; draw_stone(cursor_x, cursor_y, current_player); if (check_win(cursor_x, cursor_y, current_player)) { state ST_END; show_result(current_player); } else if (is_board_full()) { state ST_END; show_result(0); // 0 表示平局 } else { current_player 3 - current_player; // 1 - 2 update_player_indicator(); } } } }回合切换用3 - current_player而不是增减运算是因为玩家编号只有 1 和 2这样写最简短且不会出现中间态。落子合法性检查放在确认分支内部未确认的悬浮光标不写进棋盘。draw_stone只负责画当前玩家颜色的实心圆圆半径按CELL_PX / 2 - 2计算代表棋盘线条被棋子覆盖时仍有黑线露出的视觉细节。5.3 结算界面与重新开始流程进入ST_END后屏幕中央显示“黑方胜”或“白方胜”如果是平局显示“棋盘已满”。此时方向键不再响应只有确认键触发一次game_resetvoid game_reset(void) { for (int i 0; i BOARD_SIZE; i) for (int j 0; j BOARD_SIZE; j) board[i][j] 0; current_player 1; cursor_x 7; cursor_y 7; state ST_MENU; draw_menu(); }复位时把光标放在棋盘正中符合大多数人对五子棋第一手“天元”的直觉。保存输赢次数可另开两个全局变量在show_result里自增再把数值用lcd_show_num画到屏幕上可以让课设的完整度提升不少。6. 联调排查与课程验收技巧从编译报错到自动化验证6.1 烧录阶段最常见的“No STM32 Target Found”排查顺序在 Keil 或 ST-LINK 命令行烧录时报Error: No STM32 Target Found! If your product embeds debug authentication please...时不要先怀疑芯片坏了。按顺序检查CubeMX 工程里是否在 SYS 配置中打开了 Serial Wire 调试口目标板 SWDIO 和 SWCLK 是否没接但 CubeMX 仍默认它们为 GPIO板子是否处于低功耗停机状态最后再尝试 STM32 ST-LINK Utility 里勾选 Connect Under Reset 模式。用命令行方式快速验证连接STM32CubeProgrammer -c portSWD modeUNDERRESET -w build/main.bin 0x08000000命令中modeUNDERRESET强制控制复位引脚后连接能绕开程序内把调试口改做普通 GPIO 导致失去调试器连接的常见问题。连接成功后立刻烧录一个点灯程序确认硬件链路没问题再回来调工程代码。6.2 用串口日志验证走子逻辑不靠眼睛看屏幕屏幕画面卡住时不一定是死机更可能是刷新逻辑把整块缓冲反复清零。建议程序中保留 USART1 重定向把关键路点打印出来int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }在check_win返回真时打印一条[%d,%d] player%d won在进入ST_END时打印stateEND。串口助手里看到序列正常但屏没反应问题就在 LCD 驱动层如果序列中断则是对弈逻辑死循环或数组越界。注意 Windows 下 Virtual COM Port 的驱动偶尔带黄色叹号重新安装 CP210x 或 CH340 对应驱动就能恢复。6.3 最后验收前值得加入的验证手段推荐留一个“自测模式”在菜单里同时按住两个按键进入主循环自动生成固定坐标序列每步落子都走一遍check_win把结果回传串口。配合一个几行写的 PC 端脚本模拟 5 连坐标就能在答辩前跑完 15x15 全盘越界、首行首列、四角连成五子这些边界用例这比现场手点要稳得多。免费且无压力的这种闭环验证思路也是把嵌入式开源项目经验写进简历时最好的素材。本文还有配套的精品资源点击获取