注意:
– 此游戏未适配移动端设备游玩,仅支持键盘操作;
– 游戏目前未对玩家进行区分,即所有在线玩家皆可同时操作;
– 进行游戏前需手动点击下方绿色游戏区域,确保焦点在游戏窗口上,即可通过按键控制移动。
最近在学习 Linux 设备驱动,学习的过程总是枯燥的,并且我一直认为动手去做一个完整的项目才是最快速的学习方法。因此当学习到字符设备驱动了解到 scull 时,就一直想将这个 “无用” 的设备用起来,联想到 Linux 中一切皆文件的思想,以读写文件的方式实现一个游戏的想法就由此而来,于是就动手开始编写这个以字符设备驱动为基础,通过读写的方式游玩的字符贪吃蛇游戏。
游戏介绍
游戏玩法与传统贪吃蛇游戏相同,通过控制贪吃蛇在地图中上下左右移动,吃到食物便可延长身体直到占满整个地图游戏成功;贪吃蛇无法后退,只能按照设定方向一直移动,撞到地图边界或自身游戏结束。
游戏逻辑全部封装为 Linux 字符设备驱动,可以通过对游戏的读写操作进行控制,因此可以通过简单地以读写文本的方式呈现在网页上在线游玩。网页端仅负责简单美化界面,读取并显示文本信息以呈现游戏地图,响应按键动作并写入驱动,通过不断刷新页面动态显示当前游戏状态。
游戏不仅可以在网页端在线游玩,还可以在命令行中使用 Linux 的基本命令进行控制,如使用 cat 命令打印游戏地图,echo 命令控制游戏状态等。
实现这样一个简单贪吃蛇的方式数不胜数,以 Linux 驱动为基础属实是舍近求远,简单的 HTML+JavaScript 的组合甚至可以实现的更好
(才不是我不会所以不用),此项目仅仅是为了以一种更加实际且愉快的方式去学习,就是为了图一乐
实现方式
游戏的实现方式可以看做是贪吃蛇的逻辑控制加上 scull (Simple Character Utility for Loading Localities) 的简单实现以及 Web 页面的呈现与控制,实际上就是以文件的方式通过驱动显示或修改内存中的一片区域的内容,整体结构如下图所示。
可见为了实现 Linux 驱动功能以及网页端在线游玩的功能,整体逻辑还是比较复杂的(大佬轻喷)下面就对游戏架构从底层开始依次进行说明。
贪吃蛇游戏逻辑
为了实现生成、移动、显示等游戏基本逻辑,需要编写逻辑代码并提供对外接口,这部分实现起来相对较为简单就不详细描述了,只着重讲一下主要接口、蛇身遍历以及地图绘制这几部分。
在学习 Linux 驱动时了解到为了抽象出统一的接口,使用 C 语言实现了大量的面向对象设计,因此我在实现游戏逻辑时也参照使用面向对象的思想进行设计。游戏的主要接口以及说明如下:
snake_t
:储存游戏数据的结构体,用于创建游戏对象;snake_init
:用于初始化游戏对象,需给出地图边长大小;snake_deinit
:销毁游戏对象;snake_map_refresh
:更新地图,每调用一次就会根据设定的方向移动贪吃蛇并完成状态判定;snake_get_state
:获取游戏状态,包括游戏中、游戏成功、游戏失败、游戏暂停几个状态;snake_get_map_size
:获取游戏地图大小,返回值为地图的面积;snake_draw_map
:给出绘制后的游戏地图,与原始地图数据的区别会在下面说明;snake_set_dir
:设置贪吃蛇的移动方向,特别指出可以通过这个接口暂停游戏;
地图以及地图中的元素都是通过数字表示,每个数字代表的含义通过下面的枚举描述,需要特别指出的是枚举中定义数字以外的所有数字均代表蛇身。
#define SNAKE_MAP_DATA_MAX (0x7F) // 地图最大数据量 /** * 地图原始数据,用于记录地图中各元素的位置 * 蛇身会在蛇尾的基础上依次递增 */ typedef enum { SNAKE_RAW_MAP = 0, // 地图 SNAKE_RAW_TAIL, // 蛇尾 SNAKE_RAW_FOOD = SNAKE_MAP_DATA_MAX,// 食物 SNAKE_RAW_MAX = SNAKE_RAW_FOOD, } map_raw_t;
蛇身会在蛇尾的基础上依次递增,除食物以外的最大数字便代表蛇头,并且 snake_t
里面保存了当前蛇头的所在位置,这样如果蛇头位置发生了改变就可以通过递归查找的方式遍历更新蛇身。
/** * snake_refresh - 遍历蛇身,实现蛇的移动 * @snake: 需要遍历蛇身的snake_t类型指针 * @x: 蛇头的x轴坐标 * @y: 蛇头的y轴坐标 * @last_data: 蛇的长度 * * 此函数为递归调用,递归遍历当前坐标的上、下、左、右四个方向, * 判断蛇身是否需要移动,直到遇到地图边界或者无蛇身 */ static void snake_refresh(snake_t *snake, int x, int y, char last_data) { char data; if (is_beyond_border(snake, x, y)) { return; // 超出地图 } data = get_map_raw(snake, x, y); if ((data == SNAKE_RAW_MAP ) || (data == SNAKE_RAW_FOOD) ) { return; // 非蛇身 } if (data - 1 == last_data) { // 蛇身移动 data -= 1; set_map_raw(snake, x, y, data); } if (data == last_data) { // 确保正向遍历 snake_refresh(snake, x, y - 1, data - 1); snake_refresh(snake, x, y + 1, data - 1); snake_refresh(snake, x - 1, y, data - 1); snake_refresh(snake, x + 1, y, data - 1); } }
具体的递归方式为:
- 若当前所在位置的数字比上、下、左、右四个方向的数字大 1,说明贪吃蛇未移动或吃到了食物,则继续以符合条件的位置为中心继续判断;
- 若当前位置的数字与上、下、左、右四个方向的数字相等,说明贪吃蛇蛇发生了移动,则将符合条件位置的数字减 1 并依次为中心继续判断;
- 否则终止判断。
由于地图数据通过每一格的数字大小保存,并不方便查看,因此需要对其进行转化,这样就是 snake_draw_map
函数的作用,它将通过 map_draw_t
中定义的规则根据下面定义的字符对地图进行重新绘制。
/** * 地图绘制数据,根据原始数据用以下内容绘制各元素 */ const char SNAKE_DRAW_HEAD = '@'; // 蛇头 const char SNAKE_DRAW_FAIL = 'X'; // 失败 const char SNAKE_DRAW_SUCC = 'O'; // 成功 const char SNAKE_DRAW_BODY = '#'; // 蛇身 const char SNAKE_DRAW_TAIL = '*'; // 蛇尾 const char SNAKE_DRAW_FOOD = '$'; // 食物 const char SNAKE_DRAW_MAP = '.'; // 地图 const char SNAKE_DRAW_LF = '\n'; // 换行符
需要特别指出的是若游戏正常运行时蛇头显示为 @,游戏失败时蛇头显示为 X,游戏成功时显示为 O。
参考链接:
驱动接口封装
接口实现
游戏逻辑接口完成后便可以将其与一般的字符设备驱动一样进行封装,使得可以通过 read
、write
、open
、close
等 Linux 的系统调用统一对游戏进行控制,当然也包括底层使用了这些接口的命令。
下面主要对 read
、write
、llseek
对应的几个函数进行说明:
.read = snake_read
:用于更新并获取游戏地图,在每次调用时都会先对游戏地图数据进行刷新,之后直接获取绘制好的地图并返还给用户空间(之后会考虑将地图刷新用定时器单独处理);.write = snake_write
:用于对游戏进行控制,仅获取从用户空间传入数据的最后一个有效数据(实际为倒数第二个数据,因为使用 echo 命令写入时倒数第一个为换行符),再将数据转化为命令控制游戏;.llseek = snake_seek
:用于使驱动可以被网页以文本方式呈现。
其中比较特殊的是 llseek
接口,最初实现驱动时并为提供此接口的实例,但在测试时发现设备无法被当做文本正常显示,直到在网页上出现 seek error
的提示并按照标准的方式实现 llseek
接口后才可以在网页中以文本方式将游戏呈现。根据在函数中添加的打印信息,在每次读取驱动数据前调用了两次这个函数,其中 whence
参数分别传入的是 SEEK_CUR
与 SEEK_END
,所以推测在这里 llseek
接口的作用是分别定为到文本开头和文本末尾,通过返回的位置信息获取文本的大小(纯属猜测,如有错误,欢迎指正)。
因此在这个函数中只是对 SEEK_END
命令返回了地图的大小,其他命令都未做处理,具体实现如下:
static loff_t snake_seek(struct file *fp, loff_t offset, int whence) { snake_dev_t *sdev = fp->private_data; loff_t pos; switch (whence) { case 0: // SEEK_SET pos = offset; break; case 1: // SEEK_CUR pos += fp->f_pos; break; case 2: // SEEK_END pos += snake_get_map_size(sdev->snake); break; default: pos = -EINVAL; goto fail; } if (pos < 0) { pos = -EINVAL; goto fail; } fp->f_pos = pos; fail: return pos; }
Makefile
当驱动接口都实现完成后便可编写 Makefile 完成编译等工作,Makefile 的编写与普通驱动的 Makefile 并无不同,需要特别指出的是为了使用 gdb 进行调试使用了 EXTRA_CFLAGS += -g
选项,并且由于存在多个源文件,使用了如下方式将其编译为一个模块。
obj-m := snake.o snake-objs := snake_device.o snake_interface.o
安装与卸载
由于在编写驱动时使用了动态的方式创建设备号,因此生成设备节点时需要在 /proc/devices
中获取设备号,为了方便每次安装卸载设备方便编写如下脚本(片段),完整内容请前往我的 GitHub 仓库查看:
Install() { /sbin/insmod ${Pwd}/mod/${Module}.ko $* || exit 1 rm -f /dev/${Device} major=$(awk "\$2==\"${Device}\" {print \$1}" /proc/devices) mknod /dev/${Device} c ${major} 0 group="staff" grep -q '^staff:' /etc/group || group="wheel" chgrp ${group} /dev/${Device} chmod ${Mod} /dev/${Device} ln -s /dev/${Device} ${WebDir}/${Link} && echo -e "${Info} 安装成功" } Uninstall() { /sbin/rmmod ${Module} $* || exit 1 rm -f /dev/${Device} && echo -e "${Info} 卸载成功" }
安装卸载过程与一般的驱动相似,只不过为了方便非 root 用户使用此驱动进行游戏,在修改权限时为所有用户开放了读写权限。
关于调试
在编写代码时难免出现一些疏漏,导致程序没有办法按照预期运行并给出想要的结果,这时如何调试就非常重要。除了使用常用的 printk
进行打印,通过 dmesg
查看打印信息调试外,掌握若程序发生了崩溃等不适合使用打印时的调试方法也很重要。
在我编写驱动时出现空指针导致的驱动程序崩溃后,寻找调试方法时发现了 @CHENG Jian 大佬的博文 Linux Kernel PANIC (三)–Soft Panic/Oops 调试及实例分析受益匪浅,这里也推荐感兴趣可以去学习一下,另外 Linux 强制卸载内核模块 (由于驱动异常导致 rmmod 不能卸载) 中给出的方法也十分实用。
参考链接:
游戏控制与显示
命令 (Shell Command)
由于我们使用字符驱动的方式,通过读写对游戏进行控制,因此我们可以直接通过使用 Linux 命令游玩,如使用 cat 命令打印游戏地图,echo 命令控制游戏状态。
控制命令如下:
W
A
S
D
:控制贪吃蛇上、下、左、右移动;P
:暂停游戏;R
:重新开始。
$ cat /dev/char_snake # 打印地图 ........... .........$. ........... ........... ........... .....@..... ........... ........... ........... ........... ........... $ echo W > /dev/char_snake # 向上移动 $ cat /dev/char_snake # 打印地图 ........... .........$. ........... ........... .....@..... ........... ........... ........... ........... ........... ...........
网页 (Web Server)
该项目的目的是通过封装游戏逻辑为 Linux 字符设备驱动,实现以文本方式进行游玩。为了更好地展现文本,并且方便游玩,使用网页通过服务器软件呈现游戏,使得该游戏可以在网页端在线游玩。
前端
为了实现在线游玩的功能,需要编写 HTML 代码提供界面,并且编写 JavaScript 提供地图数据的请求以及对按键动作的访问。HTML 代码相对较为简单,主要是通过一个 id 为 map
的空<pre>
标签作为容器,以供在 JavaScript 中获取到地图数据后填充在里面。
<pre id="map"></pre>
之后便可以编写 JavaScript 代码响应按键动作以及获取地图数据,响应按键动作主要通过监听键盘按键事件,并根据不同的按键使用 fetch API 访问不同的地址以通知服务器进行动作。
// 监听键盘按键事件 document.addEventListener("keydown", function(event) { let moveDir; // 处理按键事件 switch (event.keyCode) { case 87: // w 键 moveDir = "UP"; break; case 65: // a 键 moveDir = "LEFT"; break; case 83: // s 键 moveDir = "DOWN"; break; case 68: // d 键 moveDir = "RIGHT"; break; case 32: // 空格键 moveDir = "PAUSE"; break; case 82: // r 键 moveDir = "RESTART"; break; default: // 未定义按键 return; } fetch(moveDir); });
获取地图数据由于需要实时刷新,因此定义了一个定时器不断执行,并且同样使用 fetch API 向服务器请求地图数据,并将请求到的内容更新到 map 容器中。
// 获取包含文本内容的 <pre> 元素 const mapElement = document.getElementById("map"); // 设置初始访问路径和定时器间隔时间 let path = "/snake_device"; const interval = setInterval(updateMap, 1000); // 定义更新地图内容的函数 function updateMap() { // 加载指定路径的地图文件,并将其显示在 <pre> 元素中 fetch(path, { headers: { "Acceppt": "text/pline", "Content-Type": "text/plain" // 文本信息 }, mode: "same-origin", // 不允许跨域请求 cache: "reload", // 忽略本地缓存 }) .then(response => { if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.text(); }) .then(mapData => mapElement.innerText = mapData) .catch(err => console.error(`Fecth problem: ${err.message}`)); }
后端
由于我对服务器软件 Caddy 相对较为熟悉,因此该项目使用 Caddy 作为 Web 服务器响应客户端发来的请求。首先需要使用 Caddy 的静态文件服务器 (file_server) 模块,响应客户端对页面以及驱动中地图数据的请求,并且为了可以响应客户端不同按键发出不同的地址的请求,这里用到了 Caddy 的执行 (exec) 模块,通过设置匹配器对对应地址请求执行响应命令以实现对游戏的控制,Caddyfile 配置如下。
http://127.0.0.1:2019 { route { exec /UP { command ./snake.sh args move UP } exec /DOWN { command ./snake.sh args move DOWN } exec /LEFT { command ./snake.sh args move LEFT } exec /RIGHT { command ./snake.sh args move RIGHT } exec /PAUSE { command ./snake.sh args move PAUSE } exec /RESTART { command ./snake.sh args move RESTART } } file_server { root src/web/ index snake.html } }
由于 exec 模块会对命令参数统一作为字符串处理,所以无法直接使用 echo 命令与 >
将数据写入驱动,因此这里使用脚本作为缓冲将数据写入。
注意:exec 并非官方模块,因此可以在我的 GitHub 仓库下载使用我编译好的 Caddy (Linux amd64)。如果有其他的需求可以自行编译或者使用官方网站下载。
Move() { case $* in "UP") echo "W" > ${WebDir}/${Link} ;; "DOWN") echo "S" > ${WebDir}/${Link} ;; "LEFT") echo "A" > ${WebDir}/${Link} ;; "RIGHT") echo "D" > ${WebDir}/${Link} ;; "PAUSE") echo "P" > ${WebDir}/${Link} ;; "RESTART") echo "R" > ${WebDir}/${Link} ;; *) echo -e "${Error} 请使用UP、DOWN、LEFT、RIGHT控制移动,使用PAUSE暂停,RESTART重新开始" ;; esac }
其中 ${WebDir}/${Link}
为 /dev/char_snake
的软连接路径。
参考链接:
总结
在学习 Linux 设备驱动的过程中,我尝试通过编写一个基于字符设备驱动的贪吃蛇游戏项目来加深对驱动的理解。该项目实现了在命令行和网页上进行游戏控制和游玩的功能,前端使用 HTML 和 JavaScript 实现,后端使用 Caddy 作为 Web 服务器响应客户端请求,底层使用 Linux 字符设备驱动实现游戏逻辑。本文详细介绍了游戏架构和接口实现,并分享了调试方法和参考文章等内容。另外,在驱动实现时还尝试运用面向对象设计思想,使得代码更加清晰易懂。
最后,希望看到这里的各位大佬能够玩得开心,同时非常欢迎各位能够留下宝贵的意见与我交流,相互学习!