做了几个单片机小项目后,我一直觉得纯流水灯、温度采集这类题目学不到太多交互逻辑,想找一个既有“算法含量”又能在仿真里完整跑起来的东西练手。最后选了五子棋游戏机:51单片机做核心控制,Proteus搭仿真,Keil写程序,再加一份设计报告和原理图,整套流程走下来,从硬件电路到软件状态机基本都覆盖了。这篇文章就把我整个过程中的设计思路、代码结构、仿真踩坑和排查方法整理出来,适合正在做课程设计或想从基础外设进阶到游戏逻辑的朋友参考。
1. 项目整体设计与需求拆解
1.1 这个项目到底要解决什么问题
先想清楚一个事:五子棋游戏机不是“电脑下棋”的人工智能项目,而是“两人对弈的电子棋盘”。MCU要做的事情比AI下棋简单,但对逻辑完整性的要求很高。简单拆一下需求,其实就三块:
- 棋盘状态管理:落子位置要记录,不能覆盖已有棋子。
- 轮流落子:黑棋白棋交替,每步之后切换状态。
- 胜负判定:任意一方在横、竖、两条斜线方向先连成五子,游戏结束。
在51单片机上做这三件事,难点不在“计算”而在“资源”。51的RAM通常只有128字节或256字节,程序空间也就4K到8K字节。一个15×15的棋盘需要225个格子,如果每个格子用一个字节存储,就占掉225字节RAM,对标准51来说非常紧张。所以棋盘规模、存储方式、显示方案都要围绕资源做取舍。
我在设计时直接决定棋盘用8×8简化版,八个按键输入行列坐标,LCD1602显示棋盘和状态信息。8×8只需要64字节存储,51标准RAM完全撑得住。显示方面,LCD1602是两行16列字符屏,画不下15×15棋盘,但显示8×8棋盘加状态提示正好合适。这是“资源约束倒逼设计决策”的典型例子。
1.2 选型决策:为什么是51、Proteus、Keil的组合
有人会问:现在ARM、树莓派这么流行,为什么还要用51做五子棋?我的回答是:正是因为51资源少,你才会去认真设计数据结构、抠RAM占用、优化程序流程。这些能力放在任何高端芯片上都通用。而且51是绝大多数高校嵌入式课程的入门芯片,周围同学都在用,资料多、踩坑记录多,遇到问题好解决。
Proteus选它做仿真,核心原因是它的硬件调试可视化程度高。你可以直接看到LCD1602刷新过程、矩阵键盘扫描时序、蜂鸣器声音告警,波形异常一眼就能发现。对比硬件的优势是:改代码不用刷芯片,点运行就能看结果,调逻辑效率高很多。对于课程设计这种需要反复演示、频繁改功能的场景,Proteus是最好的选择。
Keil C51则是行业标准编译器,工程配置简单,调试器支持在线仿真,配合Proteus的联合调试还能做硬件级仿真。最终交付的Proteus仿真文件、Keil工程源码、设计报告和原理图四件套,正好对应“验证—实现—文档—电路”的完整闭环。这套组合对课程设计和毕设答辩非常有利,评审老师看到仿真能跑、报告有分析、原理图规范,基本不需要再多问。
2. 硬件架构与原理图设计
2.1 核心电路模块拆解
整个游戏机的硬件分成四个模块:主控、输入、输出、辅助电路。原理图看起来不复杂,但每个模块的选型和连接都有讲究。
主控模块选的是AT89C52。这颗芯片有8K程序存储器和256字节RAM,I/O口有4个8位并行口,完全够用。而且Proteus里自带这个芯片模型,仿真时直接搜“AT89C52”就能调出来。晶振电路用12MHz晶振加两个30pF电容,复位电路用10μF电容加10kΩ电阻组成上电自动复位。注意复位电路的电容极性不能画反,否则上电瞬间复位引脚电平不对,单片机可能起不来。
输入模块是4×4矩阵键盘,占用P1口的8个引脚。矩阵键盘比独立按键省I/O,8个引脚就能识别16个按键,这是51系统里非常经典的外设接法。矩阵键盘的工作原理是行列扫描:先把所有行线输出高电平,然后逐行拉低,再读取列线电平,根据哪一行拉低、哪一列读到低电平,就能唯一确定按键位置。
输出模块的核心是LCD1602液晶屏,数据线接P0口(注意P0口是开漏输出,必须外接上拉电阻,否则高电平拉不上去),控制线RS、RW、E分别接P2.5、P2.6、P2.7。LCD1602是5V供电,与51无缝兼容,在Proteus里选LM016L型号即可。另外加一个蜂鸣器接P2.4,用于落子和胜局的声音提示。
辅助模块包括电源指示LED和排针扩展接口。电源指示LED串一个1kΩ限流电阻接到VCC,这个电阻阻值不能太小,否则电流过大LED寿命缩短,也不能太大,否则亮度太低看不清楚。
2.2 显示与交互方案的设计取舍
五子棋游戏的显示方案,折腾过几个方向,最后才确定LCD1602。先说说备选方案:
- LCD12864:显示面积大,可以画真正的15×15棋盘,但接口复杂,Proteus仿真时字符和图形混合显示容易出问题,51的P0口要驱动大量数据线,程序也重。
- LED点阵:用8×8双色点阵非常直观,落子后点亮一个点,有明显“棋盘感”。但点阵需要动态扫描刷新,程序要做行扫描和列数据移位,CPU占用高,显示亮度还会受刷新频率影响,Proteus里的动态扫描效果比实物差。
- LCD1602:字符型液晶,一行显示16个字符,两行共32个字符。8×8棋盘正好可以用8列字符加坐标标识,录入状态提示完全够用。
我最终选LCD1602,核心原因是字符屏的数据更新方式简单:写一个字符就是一次I/O写操作,不需要维护扫描缓冲区,显示稳定,调试方便。8×8棋盘虽然尺寸小了点,但作为课程设计级别完全说得过去。
交互方案选了矩阵键盘加“坐标输入”模式。玩家通过按键输入行列号,再按确认键落子。矩阵键盘的按键布局我设计成:
- 3×3区域按键对应棋子坐标,数字键映射行号和列号
- 第4行放“确认”“悔棋”“开始”“复位”功能键
这种交互模式比方向键移动光标快很多:直接输入坐标,两步就能落子,方向键模式需要多次按键才能移动到目标位置,体验差得多。不过需要做一个约束,只有当前空闲的坐标能接收对应按键输入。
2.3 原理图绘制中的关键细节
原理图绘制看起来是体力活,但几个细节直接影响仿真和实物能否正常工作。
第一是P0口上拉电阻。这是51头号经典坑。P0口内部没有上拉电阻,输出高电平时驱动能力很弱,直接接LCD数据线会导致高电平电压不足,显示乱码。原理图中必须在P0口每组8根数据线上,统一接一个排阻到VCC,阻值选10kΩ比较合适。
第二是晶振电路的负载电容。Proteus仿真里晶振参数直接填12M就行,但实物的负载电容要根据芯片手册选。AT89C52的负载电容典型值是30pF,两个电容一端接晶振两端,另一端分别接地。
第三是排针扩展接口。原理图里建议加一个4针排针,引出VCC、GND、TXD、RXD,方便后续烧录固件或连接外部设备。这个接口在实物阶段非常有用,我之前就因为原理图没预留下载口,做实物时临时飞线,走了不少弯路。
绘制完原理图之后,还要做电气规则检查(ERC),确认没有悬空引脚、电源网络命名错误、总线连接不匹配等问题。Proteus的ERC功能在菜单中直接触发,报出的警告项不要忽略,逐条确认,很多仿真跑不起来的根源就在原理图连接错误。
3. 软件程序设计与核心逻辑实现
3.1 棋盘数据结构与按键扫描机制
程序的核心是棋盘数据结构和按键扫描逻辑。棋盘状态我用一个二维数组存储,定义如下:
#define SIZE 8 // 棋盘状态:0空位,1黑棋,2白棋 uchar board[SIZE][SIZE] = {0}; // 当前玩家:1黑棋先手,2白棋 uchar currentPlayer = 1; // 游戏状态:0运行中,1黑胜,2白胜,3平局 uchar gameStatus = 0;这个数据结构非常直观,64字节的数组直接塞进内部RAM,数据结构访问速度最快。为什么要用uchar不用bit?因为每个格子需要表示三种状态,一个bit只能表示两种状态,不够用。如果把两种棋子和空格拆成多个bit数组,代码复杂度会增加,读起来也不直观。64字节对51来说完全可以接受,就选最简单的方案。
按键扫描机制用的是“行列反转法”。P1口8位引脚中,高4位接行线,低4位接列线。扫描步骤:
- 输出行线高电平,列线设为输入。
- 检测列线电平,如果有列线被拉低,说明有按键按下。
- 读取低电平的列线位置,结合当前扫描的行号,得到按键编码。
- 每行依次拉低再检测,循环一遍就能确定所有按键。
关键细节是消抖。按下按键时,触点会有约10到20毫秒的机械抖动,程序必须延时消抖,否则一次按键可能被识别成多次输入。我在扫描函数里加了检测到按下后延时20ms再确认一次电平的操作,只有两次都检测到低电平才判定有效。
uchar key_scan() { uchar row, col; for (row = 0; row < 4; row++) { P1 = ~(0x01 << row); // 拉低当前行 delay_ms(5); // 等待信号稳定 for (col = 0; col < 4; col++) { if ((P1 & (0x10 << col)) == 0) { // 对应列被拉低 delay_ms(20); // 消抖 // 再次确认按键仍然按下 if ((P1 & (0x10 << col)) == 0) { return row * 4 + col + 1; } } } } return 0; // 无按键 }这里有一个很容易踩的坑:矩阵键盘与LCD1602共用引脚。我在原理图里把键盘接P1口,LCD数据线接P0口,两者互不干扰。如果你把键盘也接到P0,LCD写数据时键盘会被干扰,按键识别就会出现随机跳变。这个教训我是在调试时发现的,现象是按键输入偶尔变成别的数字,排查了大半天才发现。
3.2 五子棋胜负判定算法
胜负判定是整个程序的核心亮点,也是最容易被忽视的部分。网上很多代码落子后暴力全盘扫描,算法简单但效率低。我写的版本是“以待落子点为中心,沿四个方向向外延伸检查”,代码逻辑清晰且计算量小。
四种方向定义:
- 水平:行不变,列递增递减
- 垂直:列不变,行递增递减
- 主对角线:行和列同时递增递减
- 副对角线:行递增列递减(或反过来)
判定思路:落子后,以落子点为起点,沿某个方向的正反两侧分别数连续同色棋子数,如果总数大于等于5,则判定该玩家获胜。
// 判断是否有五子连珠 bit check_win(uchar row, uchar col) { uchar i, count; uchar player = board[row][col]; char dirs[4][2] = {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; for (i = 0; i < 4; i++) { count = 1; count += count_dir(row, col, dirs[i][0], dirs[i][1], player); count += count_dir(row, col, -dirs[i][0], -dirs[i][1], player); if (count >= 5) { return 1; } } return 0; } // 沿指定方向统计连续同色棋子数 uchar count_dir(uchar row, uchar col, char dx, char dy, uchar player) { uchar count = 0; uchar r = row + dx, c = col + dy; while (r < SIZE && c < SIZE && board[r][c] == player) { count++; r += dx; c += dy; } return count; }这里为什么两个方向都要检查?因为五子棋连珠的位置不一定在落子点的同一侧。比如你下在中间,棋子向左右两边各延伸2个,总共5个连成一线,如果只查一侧会漏判。两侧计数相加正好解决这个问题。注意副对角线的方向向量(1,-1),检查时列坐标减到负数要防止越界,代码里用c >= 0作为循环条件,但要注意C语言里uchar类型不判断负数,所以我在主函数里用它之前其实把board当uchar存取,这个细节在调试时容易翻车,正确写法是在循环条件里显式判断c < SIZE和c >= 0。
还有一个边界情况:棋盘64格全下满但无人连成五子,算平局。这个在游戏状态机里单独判断:落子数达到64且check_win为假,gameStatus置为3。
3.3 Keil工程搭建与代码结构
Keil工程的搭建有固定套路。新建工程后,选Atmel AT89C52芯片,然后添加源文件,配置输出选项生成HEX文件。有一个关键设置必须注意:在Options for Target的Output选项卡里勾选“Create HEX File”,否则Proteus仿真是没法加载程序的。
代码结构上,我按功能分了几个模块。
// main.c - 主程序与状态机 void main() { init_lcd1602(); init_board(); while (1) { switch (gameStatus) { case 0: play_game(); // 对弈流程 break; case 1: display_winner(1); // 黑胜 break; case 2: display_winner(2); // 白胜 break; case 3: display_winner(3); // 平局 break; } } }主循环是典型的状态机结构。对弈状态里又分为“等待行输入”“等待列输入”“等待确认落子”三个子状态。为什么分这么细?因为LCD1602显示要有提示信息,提醒当前轮到谁、该输入什么。不分状态的话,程序判断条件会非常混乱,按键输入的错误率也会变高。
显示模块单独一个文件lcd1602.c,里面封装了写命令、写数据、设置光标位置、显示字符串等基础函数。基础函数尽量复用:比如显示“Black”就是先设置光标到指定行列,再循环发送字符串。不要在main里直接拼凑一堆LCD写指令,否则代码又长又难维护。
另外建议加上一个简单的音效模块。落子时蜂鸣器响一声短音,胜局时响三声长音,给玩家明确的听觉反馈。Proteus仿真里蜂鸣器也能响,音效验证很方便。音效用延时函数产生方波驱动蜂鸣器,频率大约1000Hz,响200ms即可:
void buzz(uchar times, uint duration_ms) { uchar i; uint j; for (i = 0; i < times; i++) { for (j = 0; j < duration_ms * 10; j++) { buzzer = !buzzer; delay_us(500); // 约1kHz方波 } delay_ms(100); } }4. Proteus仿真搭建与联调
4.1 仿真电路搭建步骤
Proteus鉴真环节从新建工程开始。注意选择“New Project”时,在“Create schematic from template”里选默认模板,后续再手动添加元件,方便自己控制布局。
元件清单整理一下:
- 主控:AT89C52
- 晶振:12MHz
- 复位电路:10μF电解电容 + 10kΩ电阻
- LCD:LM016L
- 矩阵键盘:KEYPAD-SMALL(4×4)
- 蜂鸣器:BUZZER
- 上拉排阻:RESPACK-8,10kΩ
放到图纸上之后,关键布局建议:主控放中央,左侧放晶振电路和复位电路,右侧放LCD,下方放键盘矩阵,电源LED放顶部。这个布局模拟了实物的信号流向:程序从主控输出到LCD,输入从键盘到主控,一目了然。仿真时调试也方便,因为走线短,视觉上不容易看乱。
连线要注意一个点:AT89C52的EA引脚要接高电平,否则单片机默认访问外部程序存储器,仿真时程序加载不进去。这个在数据手册里默认是总线复用外部存储器模式,实际项目中必须把EA拉高。Proteus仿真里最容易忽略。
还有供电和复位:Proteus元件的VCC不是显式连接的,直接在放置元件时配置为5V,GND接VSS脚即可。复位电路在仿真时需要用“按钮+RC电路”模拟上电复位和手动复位两种场景。RC复位电路参数的选择参考实际主板设计,10μF电容加10kΩ电阻,可以保证上电时RST平高电平超过2个机器周期。
4.2 仿真调试技巧与问题排查
Proteus仿真最大的价值是能实时观察单片机的运行时序,利用这个特性可以高效调程序。
我第一次烧完程序加载到Proteus里,LCD显示全是黑块,屏幕一个字符都刷新不出来。初步怀疑LCD初始化时序问题。后来用Proteus的“AVR/8051”调试模式配合虚拟终端逐步排查,发现是LCD的E使能信号时序不对:数据写好后E引脚的高电平持续时间太短,两条命令间隔又太快,LCD没有正确锁存数据。解决办法是在写命令和写数据函数里加入足够的延时,E引脚保持高电平至少2ms,之后拉低。改完之后LCD显示立刻正常。
矩阵键盘调试的坑也很典型。按键按下后,在Proteus里看到的现象是:按下一次,程序变成了进入连续输入状态。原因是我在扫描函数里键盘hold住时间过长,松手检测没写,导致一次按键被重复识别。解决方法是加一个松手检测:确认按键有效后,直到检测到按键释放(列线恢复高电平)才返回。这个逻辑在实物调试里同样适用,不然你会碰到“按下一次落两个子”的灵异现象。
仿真过程中我还发现,程序里延时过长会导致“按键没反应”的错觉。原因不是芯片卡死,而是CPU在忙延时时没有去扫描按键。比如按键扫描在主循环里,但主循环先做了LCD刷新,刷新时间过长,按键扫描频率太低。我把LCD刷新放在落子后执行,而不是每帧都刷新,避免按键扫描被大量延时挤占。这个问题用Proteus里“信号调试窗口”能看到程序执行时间,确实延时长到几十毫秒。
提一个高效的联调方法:Proteus支持与Keil的联合仿真调试。在Keil里生成HEX文件后,在Proteus里双击芯片加载HEX,运行仿真,然后在Keil里按F5进入调试模式,可以看到C源码和汇编的对应关系,时时查看变量值变化。判断胜负时,直接用Watch窗口监视board数组的值,谁能连成五子一看便知,比靠脑筋推理快得多。
5. 常见问题排查与避坑实录
5.1 LCD屏幕显示异常的几种情况
LCD显示异常是最常见的仿真问题,我整理一个速查表:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 屏幕黑块花屏 | LCD初始化时序不对,E信号高电平时间太短 | 将写命令和写数据函数中延时加长到毫秒级 |
| 有背景光无字符 | 对比度引脚VEE电压不当 | 在VEE和GND之间加一个10kΩ电位器分压 |
| 显示乱码 | 数据线接错或P0口上拉电阻缺失 | 检查数据线连接,补上10kΩ排阻 |
| 显示内容闪烁 | LCD刷新频率太低 | 落子后才刷新,不要频繁全屏刷新 |
| 光标闪烁不消失 | 没有关闭光标显示 | 在初始化时写入0x0C命令关闭光标 |
特别说一下VEE电压问题。LCD1602的VEE引脚用于调节液晶对比度,正常范围是0到5V,接法是用电位器分压。Proteus仿真里如果直接接GND,对比度可能过高,字符都凝成黑块;如果悬空,可能看不到字符。最佳做法是接一个10kΩ电位器,中间抽头接VEE,调节时能看到从白底到黑底的变化。实物电路上这个细节直接决定成败,我见过几个同学实物显示不出字符,快把数据线都要断了,最后才发现是对比度引脚没接好。
5.2 按键识别出错和处理技巧
矩阵键盘的误识别也是重灾区。我在第3章提到过消抖和松手检测,但实际工程里还会碰到更棘手的情况:
第一,多键同时按下,扫描结果不确定。常见于玩家操作习惯,同时按了确认键和坐标键。解决办法是加“互斥保护”:一次落子流程里,只接收当前状态允许的按键类型。比如已经输入了行号,那下一个只接收列号键;确认键按下后,整轮输入锁定,直到程序完成本轮落子。这样即便玩家手速快,也不会造成棋盘状态错乱。
第二,Proteus仿真中键盘按下一瞬间状态不稳定。把扫描频率降低是一种办法,但我建议更稳健的做法是“状态标志+延时库”。按键确认时用一个全局变量记下键码,同时置位“有按键”标志。主循环只在检测到标志后才处理键码,处理完清标志。这个机制相当于给输入加了“缓冲队列”,有效防止重复进入。
第三,坐标输入越界。键盘区域3×3之外的键如果误按,程序要拦截。我设计了范围检查函数:
// 输入有效性检查 bit is_valid_position(uchar row, uchar col) { if (row >= SIZE || col >= SIZE) { return 0; } if (board[row][col] != 0) { return 0; // 该位置已有棋子 } return 1; }如果输入无效,LCD提示“Invalid! Retry”,并要求重新输入。这属于基本的输入防御编程,虽然麻烦,但实际使用体验完全不一样。没有这个检查,游戏可能在下几次落子时莫名其妙崩溃。
5.3 仿真无法运行的高频原因
排除了所有正常情况后,如果Proteus还是不工作,基本逃不开下面几个点:
- HEX文件没有加载进去:这是最常见的“跑不起来”。确认在AT89C52属性对话框里指定了HEX文件路径,路径不能有中文和空格,Proteus对路径中文的支持非常差。
- EA引脚悬空:EA必须接VCC,这步骤遗漏会导致仿真提示不加载程序。
- 晶振参数配置错误:双击晶振元件,频率填12M,不要把频率填成1200或者12K这样离谱的值。
- 电源没有配置:Proteus 8以上版本,元件默认不接VCC端子,需要用电源工具放置VCC和GND,并命名好网络。
- 重复定义I/O端口:检查P1口是否同时连接了按键矩阵和别的外设,如果是就会导致端口电平冲突。
我遇到最诡异的一次仿真问题是程序在本地能编译,加载到Proteus后完全不运行,连LCD背景光都不亮。后来用Proteus的调试器单步执行才发现,程序卡在了一个死循环里。原因是延时函数里的变量类型错了,uint被定义成char,导致延时时间溢出为0,程序一直在等一个永远无法满足的条件。这种坑查起来非常费劲,最后我养成了一个习惯:每个模块单独跑一个最小测试,不要等到全部写完再整体仿真。
6. 实操心得与后续扩展思路
做这个项目的最大体会是:嵌入式项目的复杂度不在“写代码”,而在“状态管理”。五子棋看起来简单,但把轮流身份、坐标输入、棋盘更新、胜负判断、显示刷新、声音反馈这些状态串起来,很容易出现互相干扰。一个小建议:把每个模块的接口想清楚再动笔,特别是主循环的流程控制,用状态机的思路写,后面调试会轻松很多。
我再分享一个调试技巧:在LCD上增加调试页面。比如程序启动时先显示版本号和自检信息,按键测试模式下能回显按键编码。这个功能看似多余,但排查输入输出问题时价值巨大。我把键盘扫描和LCD显示集成到一个测试模式下,每次硬件改动后先跑自检,确认每个按键都能对应到正确的键码,然后再切换到游戏模式。整个调试时间至少缩短三分之一。
这个项目后续还可以扩展不少方向。比如加入单机人机对战,用简单的“专家策略”实现电脑落子,算法可以用“攻防评分法”,把棋盘每个空位周围的黑白棋子分布按权重打分,选最高分落子。也可以改成更大的LCD12864屏,画真实15×15棋盘,配合51的外部RAM放置更大的棋盘数组。声音模块可以升级成播放16位旋律的蜂鸣器音乐。甚至可以用51的串口把棋局数据实时发送到上位机,用串口助手观察复盘,这种扩展对学习通信协议也很有帮助。
做项目不用追求一步到位,先把基础版本跑通,再逐步加功能。我最初版本的代码只有200多行,加完音效、输入校验、平局判断之后到了400多行。回头再看,每一步扩展都是在原有状态机上加一个分支,改动很自然,这就是结构清晰的好处。希望这些踩坑记录和代码思路,能帮你少走一些弯路,尽快把这个五子棋游戏机跑起来。