基于Linux帧缓冲区的简易五子棋游戏——C语言工程源码深度解析
写在前面:本项目是一个运行于 Linux 帧缓冲区(Framebuffer)环境下的简易五子棋游戏,使用纯 C 语言编写,不依赖任何图形库(如 SDL、GTK),直接操作/dev/fb0设备进行像素级绘图。本文将从整体架构到核心算法逐层剖析,适合嵌入式 Linux 图形编程初学者参考。
一、项目概述
1.1 项目简介
本项目实现了一个简易的人机交替五子棋游戏,主要特点如下:
直接操作 Framebuffer:通过
mmap将显存映射到用户空间,实现像素级绘图键盘交互:使用
WASD移动光标,空格键落子胜负判定:四方向(横、竖、正斜、反斜)连五检测
BMP 图片显示:胜利时加载 BMP 图片作为提示
无第三方图形库依赖:仅使用 Linux 系统调用和 C 标准库
1.2 开发环境
| 项目 | 说明 |
|---|---|
| 操作系统 | Linux(需支持/dev/fb0) |
| 编译器 | GCC |
| 依赖库 | libm(数学库,用于draw_circle) |
| 显示设备 | Framebuffer 设备(800×600 或更高) |
| 输入方式 | 标准终端键盘输入(getchar) |
1.3 文件结构
wuziqi/ ├── main.c # 程序入口 ├── game.h # 游戏逻辑头文件(棋盘参数、全局变量声明) ├── game.c # 游戏核心逻辑(棋盘绘制、输入处理、落子、BMP加载) ├── framebuffer.h # 帧缓冲区头文件(绘图函数声明) ├── framebuffer.c # 帧缓冲区实现(初始化、画点/线/圆/矩形、清屏) ├── panding.h # 胜负判定头文件 ├── panding.c # 胜负判定实现(四方向连五检测) ├── makefile # 编译脚本 ├── baiqi.bmp # 白棋胜利提示图片 └── heiqi.bmp # 黑棋胜利提示图片
二、整体架构设计
2.1 三层架构
本项目采用清晰的分层设计:
┌─────────────────────────────────────────┐ │ main.c(入口层) │ │ 初始化 → 显示棋盘 → 开始游戏 │ ├─────────────────────────────────────────┤ │ game.c(游戏逻辑层) │ │ 棋盘绘制 │ 输入处理 │ 落子逻辑 │ BMP显示 │ ├─────────────────────────────────────────┤ │ panding.c(胜负判定层) │ │ 四方向连五检测 │ 胜利图片显示 │ ├─────────────────────────────────────────┤ │ framebuffer.c(图形驱动层) │ │ 设备初始化 │ 画点 │ 画线 │ 画圆 │ 画矩形 │ │ 清屏 │ 内存映射(mmap) │ ├─────────────────────────────────────────┤ │ /dev/fb0(Linux内核层) │ └─────────────────────────────────────────┘
2.2 模块依赖关系
main.c ├── framebuffer.h → framebuffer.c └── game.h → game.c ├── framebuffer.h → framebuffer.c └── panding.h → panding.c └── game.h(访问棋盘参数)
各模块之间通过头文件中的extern声明共享全局变量,数据流向清晰:main.c启动后调用game.c的函数,game.c调用framebuffer.c的绘图原语和panding.c的判定逻辑。
三、图形驱动层——framebuffer.c / framebuffer.h
3.1 头文件定义
// framebuffer.h #define RGB_FMT_888 32 #define RGB_FMT_565 16 extern int fbx_g; // 横向分辨率(如 800) extern int fby_g; // 纵向分辨率(如 600) extern int init_fb(char *devname); extern void draw_point(int x, int y, unsigned int col); extern void draw_h_line(int x, int y, int len, unsigned int col); extern void draw_s_line(int x, int y, int len, unsigned int col); extern void draw_clear(unsigned int col); extern void draw_full_rect(int x, int y, int w, int h, unsigned int col); extern void draw_circle(int x0, int y0, int r, unsigned int col);
头文件定义了两种像素格式宏(RGB_FMT_888对应 32 位色深,RGB_FMT_565对应 16 位色深),并声明了所有绘图原语函数。fbx_g和fby_g保存当前屏幕分辨率,供其他模块使用。
3.2 设备初始化——init_fb()
void *pmem = NULL; // 显存映射指针 int fb; // 设备文件描述符 struct fb_var_screeninfo vinfo; // 可变屏幕信息 int init_fb(char *devname) { // 1. 打开显示设备 fb = open(devname, O_RDWR); if (-1 == fb) { perror("open fb error"); return -1; } // 2. 获取显示设备参数(分辨率、像素格式) int ret = ioctl(fb, FBIOGET_VSCREENINFO, &vinfo); if (ret < 0) { perror("ioctl error"); return -1; } fbx_g = vinfo.xres; fby_g = vinfo.yres; // 3. 建立显存空间与用户空间的内存映射 size_t len = vinfo.xres_virtual * vinfo.yres_virtual * vinfo.bits_per_pixel / 8; pmem = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fb, 0); if (pmem == MAP_FAILED) { perror("mmap error"); return -1; } return 0; }核心解析:
初始化分三步走,这也是 Linux Framebuffer 编程的标准流程:
打开设备:
open("/dev/fb0", O_RDWR)打开帧缓冲设备文件,获得文件描述符fb。获取参数:通过
ioctl(fb, FBIOGET_VSCREENINFO, &vinfo)读取屏幕的可变信息,包括分辨率(xres、yres)、色深(bits_per_pixel)等。FBIOGET_VSCREENINFO是 Linux Framebuffer 子系统定义的 ioctl 命令,fb_var_screeninfo结构体保存了所有可变参数。内存映射:使用
mmap将整个虚拟显存空间映射到用户空间的pmem指针。映射大小计算公式为:
$$
\text{len} = \text{xres\_virtual} \times \text{yres\_virtual} \times \frac{\text{bits\_per\_pixel}}{8}
$$
注意:这里使用的是
xres_virtual和yres_virtual(虚拟分辨率)而非物理分辨率,因为虚拟分辨率可能大于物理分辨率,映射整个虚拟空间才能覆盖所有可见区域。MAP_SHARED标志确保写入操作会同步到实际硬件。
3.3 画点——draw_point()
void draw_point(int x, int y, unsigned int col) { if (x >= vinfo.xres || y >= vinfo.yres) return; // 边界检查,防止越界写入 if (vinfo.bits_per_pixel == RGB_FMT_888) // 32位色深 { unsigned int *p = pmem; *(p + vinfo.xres_virtual * y + x) = col; } else if (vinfo.bits_per_pixel == RGB_FMT_565) // 16位色深 { unsigned short *p = pmem; *(p + vinfo.xres_virtual * y + x) = col; } }核心解析:
画点是所有图形绘制的基础。关键在于地址计算:
像素地址 = 基址(pmem) + y × 虚拟宽度 × 每像素字节数 + x × 每像素字节数
对于32 位色深(RGB888):每个像素占 4 字节,将
pmem强转为unsigned int*,偏移量为xres_virtual * y + x对于16 位色深(RGB565):每个像素占 2 字节,将
pmem强转为unsigned short*,偏移量相同
函数首先进行边界检查,防止坐标超出物理屏幕范围导致段错误。这里使用vinfo.xres(物理分辨率)而非虚拟分辨率做判断,是正确的做法。
3.4 画线——draw_h_line()/draw_s_line()
// 画水平线 void draw_h_line(int x, int y, int len, unsigned int col) { for (int i = x; i < x + len; ++i) draw_point(i, y, col); } // 画竖直线 void draw_s_line(int x, int y, int len, unsigned int col) { for (int i = y; i < y + len; ++i) draw_point(x, i, col); }核心解析:
水平线在固定 y 坐标上沿 x 方向逐点绘制;竖直线在固定 x 坐标上沿 y 方向逐点绘制。实现简单直接,逐点调用draw_point。
优化提示:当前实现逐像素调用函数,存在大量函数调用开销。对于帧缓冲区编程,可以优化为直接指针写入整行数据,或使用
memset/memcpy批量写入,显著提升性能。
3.5 清屏与填充矩形
// 清屏(用指定颜色填充整个屏幕) void draw_clear(unsigned int col) { for (int i = 0; i < fby_g; ++i) draw_h_line(0, i, fbx_g, col); } // 填充矩形 void draw_full_rect(int x, int y, int w, int h, unsigned int col) { for (int i = y; i < y + h; ++i) draw_h_line(x, i, w, col); }核心解析:
draw_clear:逐行调用draw_h_line填充整个屏幕,使用fbx_g和fby_g作为循环范围。draw_full_rect:在指定矩形区域内逐行填充,本质是连续绘制多条水平线。
3.6 画圆——draw_circle()
void draw_circle(int x0, int y0, int r, unsigned int col) { int x = 0, y = 0; int r0 = 0; for (r0 = 0; r0 <= r; ++r0) // 从圆心向外逐层填充 { for (float si = 0; si <= 360; si += 0.01) // 角度从0扫到360度 { x = x0 + r0 * cos(2 * 3.1416 / 360 * si); y = y0 + r0 * sin(2 * 3.1416 / 360 * si); draw_point(x, y, col); } } }核心解析:
这是一个实心圆绘制算法,采用极坐标参数方程:
$$
x = x_0 + r \cdot \cos(\theta), \quad y = y_0 + r \cdot \sin(\theta)
$$
外层循环r0从 0 递增到r,实现从圆心向外的逐层填充;内层循环角度si从 0° 到 360°,步长 0.01°,扫描完整圆周。
性能分析:这个实现虽然正确,但效率极低。一个半径为 10 的圆需要计算
10 × 36000 = 360000次draw_point调用,每次调用都涉及浮点运算和函数调用。更高效的方案是使用Bresenham 画圆算法(仅用整数运算)或Midpoint Circle Algorithm,仅需O(r)次迭代即可完成圆轮廓绘制,再配合扫描线填充实现实心圆。
四、游戏逻辑层——game.c / game.h
4.1 头文件与关键宏定义
// game.h #define BROAD_X_START 100 // 棋盘左上角 X 坐标 #define BROAD_Y_START 100 // 棋盘左上角 Y 坐标 #define BROAD_X_CNT 20 // 棋盘横向交叉点数 #define BROAD_Y_CNT 20 // 棋盘纵向交叉点数 #define BROAD_W_ONE 25 // 每个格子边长(像素) extern int posx_g; // 当前光标 X 坐标 extern int posy_g; // 当前光标 Y 坐标 extern int chess_g[BROAD_Y_CNT + 4*2][BROAD_X_CNT + 4*2]; // 棋盘状态数组
在game.c中还定义了棋子颜色值和颜色常量:
#define WHITE_CHESS 1 // 白棋标记 #define BLACK_CHESS -1 // 黑棋标记 #define RED 0x00FF0000 #define GREEN 0x0000FF00 #define BLUE 0x000000FF #define BLACK 0x00000000 #define WHITE 0x00FFFFFF #define YELLOW 0x00FFFF00
颜色格式说明:颜色值采用
0x00RRGGBB格式(32 位 RGB888),高 8 位为 0(Alpha 通道不使用),中间 8 位为红色,接着 8 位为绿色,低 8 位为蓝色。
4.2 全局变量
int posx_g = BROAD_X_START; // 光标初始位置 int posy_g = BROAD_Y_START; int col_ = 0; // 回合计数器(偶数=白方,奇数=黑方) int chess_g[BROAD_Y_CNT + 4*2][BROAD_X_CNT + 4*2] = {0}; // 28×28 棋盘数组棋盘数组设计——关键的边界填充技巧:
int chess_g[BROAD_Y_CNT + 4*2][BROAD_X_CNT + 4*2]; // 20 + 8 = 28 20 + 8 = 28
这里数组大小为28×28,而实际棋盘为 20×20。两侧各多出4 行/列的填充区域(共 8 行/列),全部初始化为 0。这是一个非常巧妙的设计:
实际棋盘区域(20×20,下标 4~23) ┌──────────────────────────────────┐ │ 填充区(4列)│ 实际棋盘(20列)│ 填充区(4列)│ │ 全部为0 │ │ 全部为0 │ ├──────────────┼──────────────────┼──────────────┤ │ 填充区(4行)│ │ 填充区 │ ← 填充区全为0 │ 全部为0 │ 实际棋盘(20行)│ 全部为0 │ ├──────────────┼──────────────────┼──────────────┤ │ 填充区 │ │ 填充区 │ └──────────────┴──────────────────┴──────────────┘
为什么需要这个填充?在胜负判定时,需要从当前位置向四个方向各检查 4 个棋子。如果棋子位于棋盘边缘,直接访问a[m-4][n]等会越界。而有了填充区域(值为 0,既不是白棋也不是黑棋),即使棋子在边缘位置,访问也不会越界,且 0 值不会影响连五判断(因为5 × 1 = 5或5 × (-1) = -5才判定胜利,0 会打破连子)。这是一种用空间换安全的经典做法。
4.3 棋盘绘制——show_chess_broad()
void show_chess_broad() { // 1. 绘制背景 draw_clear(0xCDB79E); // 棋盘木色背景 // 2. 绘制棋盘网格线(横向) for (int i = 0; i < BROAD_Y_CNT; ++i) { draw_h_line(BROAD_X_START, BROAD_Y_START + i * BROAD_W_ONE, (BROAD_X_CNT - 1) * BROAD_W_ONE, BLACK); } // 3. 绘制棋盘网格线(纵向) for (int i = 0; i < BROAD_X_CNT; ++i) { draw_s_line(BROAD_X_START + i * BROAD_W_ONE, BROAD_Y_START, (BROAD_Y_CNT - 1) * BROAD_W_ONE, BLACK); } // 4. 绘制光标(红色小方块) draw_full_rect(posx_g - 5, posy_g - 5, 10, 10, RED); // 5. 绘制所有已落棋子 for (int i = 0; i < BROAD_Y_CNT + 8; ++i) { for (int j = 0; j < BROAD_X_CNT + 8; ++j) { if (WHITE_CHESS == chess_g[i][j]) { draw_circle((i-4)*BROAD_W_ONE + BROAD_Y_START, (j-4)*BROAD_W_ONE + BROAD_X_START, 10, WHITE); } else if (BLACK_CHESS == chess_g[i][j]) { draw_circle((i-4)*BROAD_W_ONE + BROAD_Y_START, (j-4)*BROAD_W_ONE + BROAD_X_START, 10, BLACK); } } } }核心解析:
棋盘绘制分为五步:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | draw_clear(0xCDB79E) | 清屏并填充木色背景 |
| 2 | 横线循环 | 绘制 20 条水平线,间距 25 像素 |
| 3 | 竖线循环 | 绘制 20 条竖直线,间距 25 像素 |
| 4 | draw_full_rect | 在光标位置绘制 10×10 红色方块 |
| 5 | 双重循环遍历数组 | 根据数组值绘制白棋/黑棋圆 |
坐标映射:棋盘数组下标
(i, j)到屏幕坐标的映射公式为screen = (index - 4) * BROAD_W_ONE + BROAD_START。其中-4是因为数组有 4 行/列的填充偏移,需要减去才能对应到实际棋盘坐标。
注意:这里的坐标映射代码中,X 和 Y 坐标似乎有交叉使用的情况(
i对应BROAD_Y_START,j对应BROAD_X_START),这在实际运行中需要结合具体的屏幕方向来理解,可能在某些设备上表现为坐标转置。
4.4 游戏主循环——begin_game()
void begin_game() { char choose = 0; while (1) { choose = getchar(); // 阻塞等待键盘输入 if ('w' == choose) // 上移 { if (posy_g > BROAD_Y_START) posy_g -= BROAD_W_ONE; } else if ('a' == choose) // 左移 { if (posx_g > BROAD_X_START) posx_g -= BROAD_W_ONE; } else if ('s' == choose) // 下移 { if (posy_g < BROAD_Y_START + (BROAD_Y_CNT - 1) * BROAD_W_ONE) posy_g += BROAD_W_ONE; } else if ('d' == choose) // 右移 { if (posx_g < BROAD_X_START + (BROAD_X_CNT - 1) * BROAD_W_ONE) posx_g += BROAD_W_ONE; } else if (' ' == choose) // 空格键落子 { if (col_ % 2 == 0) { chess_g[(posx_g-BROAD_X_START)/BROAD_W_ONE + 4] [(posy_g-BROAD_Y_START)/BROAD_W_ONE + 4] = 1; // 白棋 } else { chess_g[(posx_g-BROAD_X_START)/BROAD_W_ONE + 4] [(posy_g-BROAD_Y_START)/BROAD_W_ONE + 4] = -1; // 黑棋 } col_++; show_chess_broad(); // 刷新棋盘 panding(chess_g); // 胜负判定 if (game_over == 1) return; } } }核心解析:
这是整个游戏的核心控制流,采用经典的事件循环(Event Loop)模式:
输入处理:通过
getchar()函数阻塞等待用户输入,每次读取一个字符。移动控制:使用
WASD键分别控制光标向上、下、左、右移动,每次移动距离为一个棋盘格子的宽度(25 像素)。边界限制:每次移动前都会检查光标位置是否超出棋盘边界,确保光标始终停留在棋盘范围内。
落子逻辑:按下空格键时触发落子操作,根据
col_变量的奇偶性决定当前落子方:偶数回合为白棋(值为 1),奇数回合为黑棋(值为 -1)。坐标转换:将屏幕坐标转换为棋盘数组下标的计算公式如下:
$$ \text{index} = \frac{\text{pos} - \text{BROAD\_START}}{\text{BROAD\_W\_ONE}} + 4 $$
刷新与判定:每次落子后刷新棋盘显示,并调用
panding()检查胜负。
设计要点:使用
col_变量记录回合数,偶数回合白方落子,奇数回合黑方落子,实现了简单的交替机制。注意这里没有处理同一位置重复落子的情况——后续落子会覆盖已有棋子。
4.5 BMP 图片显示——draw_bmp()
#pragma pack(1) // 取消结构体字节对齐,确保与BMP文件格式一致 typedef struct tagBITMAPFILEHEADER { short bfType; // 文件类型标志("BM") int bfSize; // 文件大小 short bfReserved1; // 保留字段 short bfReserved2; // 保留字段 int bfOffBits; // 图像数据偏移量 } Bmp_file_head_t; typedef struct tagBITMAPINFOHEADER { int biSize; // 信息头大小 int biWidth; // 位图宽度 int biHeight; // 位图高度 short biPlanes; // 位平面数(必须为1) short biBitCount; // 每像素位数 int biCompression; // 压缩类型 int biSizeImage; // 图像数据大小 int biXPelsPerMeter; // 水平分辨率 int biYPelsPerMeter; // 垂直分辨率 int biClrUsed; // 使用颜色数 int biClrImportant; // 重要颜色数 } Bmp_info_t; #pragma pack() void draw_bmp(int x, int y, char *bmpname) { Bmp_file_head_t headinfo; Bmp_info_t bmpinfo; // 1. 打开BMP文件 int fd = open(bmpname, O_RDONLY); if (-1 == fd) { perror("open bmp error"); return; } // 2. 读取文件头和信息头 read(fd, &headinfo, sizeof(headinfo)); read(fd, &bmpinfo, sizeof(bmpinfo)); // 3. 读取像素数据 unsigned char *buff = malloc(bmpinfo.biHeight * bmpinfo.biWidth * bmpinfo.biBitCount / 8); read(fd, buff, bmpinfo.biHeight * bmpinfo.biWidth * bmpinfo.biBitCount / 8); close(fd); // 4. 逐像素绘制(BMP数据从下到上存储,需翻转Y轴) unsigned char *p = buff; unsigned char r, g, b; for (int j = bmpinfo.biHeight - 1; j >= 0; j--) // 从最后一行开始 { for (int i = 0; i < bmpinfo.biWidth; i++) { b = *p; ++p; // BMP格式为BGR顺序 g = *p; ++p; r = *p; ++p; unsigned int col = (r << 16) | (g << 8) | (b << 0); // 转为RGB888 draw_point(i + x, j + y, col); } } free(buff); }核心解析:
这是一个完整的 BMP 24 位真彩色图片解析器,处理流程如下:
打开文件 → 读取文件头 → 读取信息头 → 读取像素数据 → 逐像素绘制 → 释放内存
关键技术点:
#pragma pack(1):取消结构体的默认字节对齐。BMP 文件格式要求结构体成员紧密排列,不加填充字节。如果不使用#pragma pack(1),编译器可能会在成员间插入填充字节,导致读取的数据错位。BGR → RGB 转换:BMP 文件中像素数据的排列顺序是 B-G-R(蓝-绿-红),而 Framebuffer 使用的是 R-G-B 顺序,需要手动转换:
unsigned int col = (r << 16) | (g << 8) | (b << 0);
Y 轴翻转:BMP 文件的像素数据是从下到上存储的(第一行对应图像底部),因此外层循环从
biHeight - 1递减到 0,实现 Y 轴翻转。
局限性:当前实现仅支持 24 位(
biBitCount = 24)无压缩 BMP。对于其他色深(如 8 位索引色)或压缩格式(如 RLE),需要额外处理调色板或解压逻辑。
五、胜负判定层——panding.c / panding.h
5.1 头文件
// panding.h extern int game_over; // 游戏结束标志 extern int panding(int a[][28]); // 胜负判定主函数 extern int panding_2(int num); // 胜负结果处理
5.2 胜负判定主函数——panding()
int game_over = 0; int panding(int a[][28]) { int sum[4] = {0}; // 四个方向的连子之和 int m, n, step; // 计算最后落子在数组中的坐标 m = (posx_g - BROAD_X_START) / BROAD_W_ONE + 4; n = (posy_g - BROAD_Y_START) / BROAD_W_ONE + 4; for (step = 0; step < 5; step++) { // 方向0:水平方向(左右各检查,滑动窗口) for (int i = n - 4 + step; i <= n + step; i++) sum[0] += a[m][i]; // 方向1:垂直方向 for (int i = m - 4 + step; i <= m + step; i++) sum[1] += a[i][n]; // 方向2:主对角线(左上→右下) for (int i = m - 4 + step, j = n - 4 + step; i <= m + step; i++, j++) sum[2] += a[i][j]; // 方向3:副对角线(右上→左下) for (int i = m - 4 + step, j = n + 4 + step; i <= m + step; i++, j--) sum[3] += a[i][j]; // 检查四个方向是否有连五 for (int i = 0; i < 4; i++) { if (panding_2(sum[i]) == 1) { game_over = 1; return 1; } } // 重置sum数组,准备下一轮滑动 for (int j = 0; j < 4; j++) sum[j] = 0; } return 0; }核心解析——这是整个项目中最精巧的算法:
5.2.1 算法思路
五子棋的胜负判定核心是:检查最后落子的位置,在四个方向(横、竖、正斜、反斜)上是否存在连续 5 个同色棋子。
本项目采用的是滑动窗口求和法:
白棋标记为
+1,黑棋标记为-1,空位为0在某个方向上取连续 5 个位置求和
若和为
+5→ 白棋五连 → 白方胜若和为
-5→ 黑棋五连 → 黑方胜若和的绝对值小于 5 → 该窗口内未形成五连
5.2.2 滑动窗口机制
step变量从 0 到 4 循环,实现窗口滑动。以水平方向为例:
假设最后落子位置为 n(数组下标),step 从 0 到 4: step=0: 检查 [n-4, n-3, n-2, n-1, n] ← 窗口最左 step=1: 检查 [n-3, n-2, n-1, n, n+1] step=2: 检查 [n-2, n-1, n, n+1, n+2] ← 落子位于窗口中央 step=3: 检查 [n-1, n, n+1, n+2, n+3] step=4: 检查 [n, n+1, n+2, n+3, n+4] ← 窗口最右
这 5 个窗口覆盖了所有包含落子位置n的连续 5 格组合。只要其中任一窗口的 5 个格子全为同色棋子,即可判定胜利。
5.2.3 四个方向的检查
方向0(水平 →): 固定行 m,列从 n-4+step 到 n+step 方向1(垂直 ↓): 固定列 n,行从 m-4+step 到 m+step 方向2(主对角 ↘):行和列同时递增,从 (m-4+step, n-4+step) 到 (m+step, n+step) 方向3(副对角 ↙):行递增列递减,从 (m-4+step, n+4+step) 到 (m+step, n-step)
用图示表示四个方向:
方向2 ↘ / / ─────●───── 方向0 → /| / | / ↓ 方向3 ↙ 方向1 ↓
5.2.4 边界安全
回到之前提到的数组填充技巧:由于数组大小为 28×28(实际棋盘 20×20 居中,四周各填充 4 行/列),当落子在棋盘边缘时,a[m-4][n]这样的访问也不会越界。填充区域的值为 0,不会影响求和结果(因为0既不是+1也不是-1,会打破连续性)。
5.3 胜负结果处理——panding_2()
int panding_2(int num) { if (num == 5) // 白棋五连 { draw_bmp(100, 100, "./baiqi.bmp"); // 显示白棋胜利图片 return 1; } else if (num == -5) // 黑棋五连 { draw_bmp(100, 100, "./heiqi.bmp"); // 显示黑棋胜利图片 return 1; } else return 0; // 未分胜负 }核心解析:
根据求和结果判断胜负:
sum == 5:5 个白棋(+1 × 5 = 5)→ 白方胜利,显示baiqi.bmpsum == -5:5 个黑棋(-1 × 5 = -5)→ 黑方胜利,显示heiqi.bmp其他值:未形成五连,继续游戏
设计亮点:利用
+1和-1的对称性,使得判断逻辑极为简洁——只需比较求和结果是否等于±5,无需额外记录棋子颜色。
六、程序入口——main.c
#include <stdio.h> #include "framebuffer.h" #include "game.h" int main(void) { init_fb("/dev/fb0"); // 初始化帧缓冲设备 #if 0 // 以下是绘图函数的测试代码(已禁用) draw_clear(0xCDB79E); draw_point(400, 300, RED); draw_h_line(200, 200, 100, YELLOW); draw_circle(400, 300, 100, RED); #endif show_chess_broad(); // 显示初始棋盘 begin_game(); // 开始游戏主循环 return 0; }核心解析:
main函数极其简洁,体现了良好的分层设计:
init_fb("/dev/fb0")—— 初始化图形硬件show_chess_broad()—— 绘制初始棋盘begin_game()—— 进入游戏主循环(阻塞在此处,直到游戏结束)
#if 0 ... #endif之间是开发阶段的测试代码,用于验证各个绘图函数的正确性。使用条件编译而非注释,便于随时启用调试。
七、编译构建——makefile
TARGET = a.out CC = gcc SRC = framebuffer.c main.c game.c panding.c $(TARGET): $(SRC) $(CC) $(SRC) -lm -o $(TARGET) clean: rm -f $(TARGET)
核心解析:
SRC:列出所有源文件,编译器一次性编译链接-lm:链接数学库(libm),因为framebuffer.c中使用了cos()和sin()-o $(TARGET):输出可执行文件a.outclean:清理目标,删除编译产物
编译命令展开:
gcc framebuffer.c main.c game.c panding.c -lm -o a.out
注意事项:此程序需要在有
/dev/fb0设备的 Linux 系统上运行(如树莓派、开发板或配置了 Framebuffer 的桌面 Linux),且通常需要 root 权限。在虚拟终端(非图形界面)下运行效果最佳。
八、核心设计总结与技术要点
8.1 全局数据流图
用户键盘输入 (WASD/Space) │ ▼ begin_game() ──更新──→ posx_g, posy_g (光标位置) │ ──更新──→ chess_g[][] (棋盘状态) │ ├──→ show_chess_broad() ──调用──→ draw_clear / draw_h_line / draw_s_line │ draw_full_rect / draw_circle │ │ │ ▼ │ draw_point() → pmem (mmap显存) │ └──→ panding(chess_g) ──计算──→ sum[4] (四方向连子和) │ ──判断──→ panding_2(sum) │ │ └──更新──→ game_over ├──→ draw_bmp() (胜利图片) └──→ 返回1 (游戏结束)
8.2 关键技术点汇总
| 技术点 | 所在文件 | 说明 |
|---|---|---|
| Framebuffer mmap 内存映射 | framebuffer.c | 将显存映射到用户空间,实现零拷贝像素写入 |
| ioctl 获取屏幕参数 | framebuffer.c | FBIOGET_VSCREENINFO获取分辨率和色深 |
| 双色深兼容 | framebuffer.c | 同时支持 32 位 RGB888 和 16 位 RGB565 |
| 棋盘数组边界填充 | game.h/c | 28×28 数组(实际 20×20),避免判定越界 |
| 滑动窗口求和判定 | panding.c | 四方向 × 五窗口,利用 ±1 对称性简化判断 |
| BMP 文件解析 | game.c | #pragma pack(1)+ BGR→RGB + Y轴翻转 |
| 条件编译调试 | main.c | #if 0控制测试代码的启停 |
| 回合制控制 | game.c | col_变量奇偶性决定黑白方 |
8.3 设计亮点
数组填充避免边界检查:通过在棋盘数组四周填充 4 行/列的 0 值,使得胜负判定的四方向扫描永远不会越界,省去了大量边界判断代码,简洁且安全。
±1 编码简化胜负判断:白棋用
+1、黑棋用-1,连续 5 子求和结果为±5,判断逻辑只需一个if比较,极为优雅。分层清晰:图形驱动、游戏逻辑、胜负判定三层分离,各层通过头文件和全局变量通信,便于维护和扩展。
零依赖图形编程:不依赖任何图形库,直接操作 Framebuffer,是嵌入式 Linux 图形编程的经典入门范式。
九、可改进之处
9.1 重复落子检测
当前实现中,空格键落子时没有检查目标位置是否已有棋子,会导致覆盖已有棋子。改进方案:
else if (' ' == choose) { int idx_m = (posx_g - BROAD_X_START) / BROAD_W_ONE + 4; int idx_n = (posy_g - BROAD_Y_START) / BROAD_W_ONE + 4; if (chess_g[idx_m][idx_n] != 0) // 该位置已有棋子 { // 可以提示"此处已有棋子"或直接忽略 continue; } chess_g[idx_m][idx_n] = (col_ % 2 == 0) ? WHITE_CHESS : BLACK_CHESS; col_++; // ... }9.2 画圆性能优化
当前draw_circle使用浮点三角函数 + 逐点绘制,性能极差。建议改用Bresenham 中点画圆算法:
void draw_circle(int x0, int y0, int r, unsigned int col) { int x = 0, y = r; int d = 3 - 2 * r; // 初始决策参数 while (x <= y) { // 利用八对称性绘制8个点 draw_point(x0 + x, y0 + y, col); draw_point(x0 - x, y0 + y, col); draw_point(x0 + x, y0 - y, col); draw_point(x0 - x, y0 - y, col); draw_point(x0 + y, y0 + x, col); draw_point(x0 - y, y0 + x, col); draw_point(x0 + y, y0 - x, col); draw_point(x0 - y, y0 - x, col); if (d < 0) d += 4 * x + 6; else { d += 4 * (x - y) + 10; y--; } x++; } }该算法仅使用整数运算,复杂度从O(r² × 36000)降至O(r)。
9.3 输入方式改进
当前使用getchar()获取终端输入,需要按回车确认。在嵌入式设备上可改用非阻塞键盘读取(如termios设置原始模式或读取/dev/input/event*),实现即时响应:
#include <termios.h> struct termios oldt, newt; tcgetattr(STDIN_FILENO, &oldt); newt = oldt; newt.c_lflag &= ~(ICANON | ECHO); // 原始模式,无回显 tcsetattr(STDIN_FILENO, TCSANOW, &newt); // ... 游戏循环 ... tcsetattr(STDIN_FILENO, TCSANOW, &oldt); // 恢复终端设置
9.4 资源释放
程序结束时没有调用munmap释放映射内存和close关闭设备文件。虽然进程退出时操作系统会自动回收,但在长期运行的场景下应显式释放:
void fini_fb(void) { if (pmem != MAP_FAILED && pmem != NULL) munmap(pmem, vinfo.xres_virtual * vinfo.yres_virtual * vinfo.bits_per_pixel / 8); if (fb > 0) close(fb); }9.5 头文件重复包含与宏重复定义
game.c中#include "game.h"出现了两次(第 1 行和第 9 行),虽有#ifndef保护不会出错,但属于冗余代码。BROAD_X_START等宏在game.h和game.c中重复定义,应统一在头文件中定义一次。
十、完整调用关系图
main() │ ├── init_fb("/dev/fb0") │ ├── open() │ ├── ioctl(FBIOGET_VSCREENINFO) │ └── mmap() │ ├── show_chess_broad() │ ├── draw_clear() ──→ draw_h_line() ──→ draw_point() │ ├── draw_h_line() × 20 │ ├── draw_s_line() × 20 │ ├── draw_full_rect() ──→ draw_h_line() ──→ draw_point() │ └── draw_circle() × N ──→ draw_point() │ └── begin_game() ├── getchar() [循环] ├── [落子时] show_chess_broad() [同上] └── [落子时] panding(chess_g) ├── 4方向 × 5窗口 求和 └── panding_2(sum) ├── [白胜] draw_bmp("baiqi.bmp") │ ├── open() → read(文件头) → read(信息头) │ ├── malloc() → read(像素数据) │ └── draw_point() × W×H → free() ├── [黑胜] draw_bmp("heiqi.bmp") [同上] └── [继续] return 0十一、总结
本项目虽然代码量不大(约 300 行 C 代码),但涵盖了嵌入式 Linux 图形编程的多个核心知识点:
Linux Framebuffer 编程:设备打开、参数获取、内存映射、像素写入
基础图形算法:画点、画线、画圆、填充矩形、清屏
BMP 文件解析:文件头解析、像素数据读取、颜色格式转换、Y 轴翻转
游戏逻辑设计:回合制控制、键盘输入处理、棋盘状态管理
算法设计:滑动窗口求和的胜负判定、数组边界填充技巧
工程组织:分层架构、头文件接口设计、Makefile 构建
这是一个非常适合嵌入式 Linux 初学者的练手项目——从硬件底层到游戏逻辑,每一层都可以深入学习。在此基础上,可以进一步扩展为:支持触摸屏输入、添加 AI 对手、实现悔棋功能、增加音效等,逐步构建一个完整的五子棋游戏。
声明:本文基于项目实际源码进行分析,所有代码片段均来自工程文件,解析为作者原创。如有疏漏或不当之处,欢迎指正交流。
