ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux课程设计实战:C语言socket实现斗地主网络编程

Linux课程设计实战:C语言socket实现斗地主网络编程 简介一份基于C语言和Socket通信实现的斗地主完整课程设计项目适合计算机相关专业学生用于课程设计、作业演示或网络编程入门进阶。源码已在实际环境中编译运行通过包含客户端与服务端完整代码、接口与配置头文件、Makefile构建脚本及部署文档便于快速搭建联机对局环境并理解TCP通信流程。压缩包共19个文件核心为C源码、目标文件、Makefile及Markdown文档整体仅49KB结构紧凑、内容精炼适合作为Linux网络编程或并发服务器的实践参考。该项目在CSDN平台已获得173人关注学习经高分答辩验证具备较高的参考价值。资料内附系统部署说明与README文档可帮助使用者理清编译、配置和启动流程同时保留模块化设计可在原有斗地主逻辑、玩家交互或网络协议基础上继续扩展实现其他玩法或功能是一份兼具教学与二次开发意义的实操资源。1. 这份课设源码包里真正值得你动手的东西期末前一周打开这份 Linux 课程设计压缩包里面是 C 语言写的斗地主源码、课程设计资料和部署文档。“高分项目”四个字听起来像噱头但拆开看核心内容其实是一次完整的 Linux 下 socket 网络编程练习服务器管牌局、三个客户端进场、自定义协议把“发牌—叫地主—出牌—结算”串起来。这篇文章把我自己跑这类课设的完整路径讲清楚源码为什么这么组织、怎么编译部署、牌型校验在哪里容易翻车、以及答辩时评委盯哪几个点。适合正在做 Linux 课设、想拿高分、又不想只会贴代码的人读。下面直接从选型逻辑开始拆。2. 用 C 语言和 socket 做斗地主为什么这个选型课设会拿高分2.1 在 Linux 上写 socketC 语言就是本家接口先回答一个很多人会问的问题现在有 Python、Java为什么还选 C 语言加 socket 做斗地主因为 Linux 的 socket 本身就是一套 C 语言接口。socket、bind、listen、accept、send、recv 这些函数直接暴露在 libc 里参数是指针、长度、错误码。Python 写 socket 底层也走同一套系统调用只是被解释器包了一层。在 Linux 课程设计里用 C 写等于直接对着操作系统内核接口编程编译产物就是可执行文件不依赖 Java 虚拟机这类运行时。课设评分看的是这个你有没有真正建立连接、有没有处理 accept 返回的文件描述符、有没有在 bind 前检查端口冲突、有没有处理 recv 返回 0 的情况。C 语言把这些底层细节全部暴露出来代码写没写到点子上评委一眼就能看到。Python 写个 socket 可能就三五行Linux 下的系统调用、文件描述符表、本地回环地址这些东西反而被藏住了不适合作为 Linux 课程设计的选题。另外一个实际好处是便于静态审查。gcc 编译时加 -Wall -Wextra能干净通过这两个警告选项本身就是加分项。源码包里如果能做到零警告说明作者的工程习惯是到位的你答辩时也可以主动提这一点。2.2 斗地主不是游戏是四个模块把“斗地主”当成整体去写很容易写出一个上千行的 main 函数最后自己都改不动。我在做这类项目时会先拆模块。第一是牌的表示。一副牌 54 张推荐用 0~53 的整数编号。每个整数对应一张牌点数和花色单独用查表得到。这样发牌就是洗牌算法比较牌型就是整数比较不需要处理中文字符串。第二是牌局状态机。一局斗地主的生命周期非常清晰等待三名玩家、发牌、叫地主、出牌、结算。其中“出牌”环节又包含出牌合法性、压牌判断、一轮没人压就要“过”。状态机用枚举和 switch 写出比一堆 if 嵌套可读性好得多。第三是规则判定。这是整个项目里最容易翻车的部分。判单张、对子、三条容易判顺子、连对、飞机的时候边界条件很多比如 2 和王不能进顺子比如四带二到底算不算炸弹。源码包里如果只实现了部分牌型不要慌答辩时把边界说清楚比实现完整更重要。第四是网络层。三名玩家是三个独立进程、各自持有自己的套接字。服务端维护一张玩家表每收到一条消息就做广播或转发。课设只要求三个人玩完全不用做成 P2P服务端星型结构最简单、最好讲。2.3 连接模型一个服务端挂三个客户端网络层的具体形态我采用“一拖三”一台服务器进程监听某个端口三台客户端进程连上来。连接建立完后服务端把三个 socket 文件描述符放进同一个 fd_set用 select 轮询。为什么不用多线程因为房间内只有三个连接select 在三个 fd 上做轮询的开销几乎可以忽略。多线程虽然也能做但要引入 mutex 保护“当前出牌玩家”和“已出牌列表”这些共享状态对课设来说复杂度往上跳了一档。轮到谁出牌服务端向对应的客户端发一条消息客户端收到后输出当前手牌等待输入。用户敲完牌编号之后客户端把牌面列表发回服务端服务端完成合法性校验和大小比较后决定是“出牌成功”还是“管不上”再广播更新状态。整体消息格式一般定义成简单的行文本动作码加分隔符加参数。后面一章会把这套协议拆开看这里先记住一个结论越简单的协议越不容易在课设现场出 bug。3. 用 C 把斗地主网络核心写稳协议、状态机、牌型比较3.1 先定协议每条消息一行文本我在这里写的是一套我自己常用、也适合课设的报文格式。每条消息以换行符结尾字段用竖线分隔动作码|参数1|参数2 典型消息 READY|Alice 客户端声明准备 DEAL|17|23|... 服务端下发手牌 BID|1 0不叫 1叫地主 2抢地主 PLAY|1|0|12|36 打出编号为 0 12 36 的三张牌 PASS| 不出 SETTLE|win|Alice 本局结束在 C 语言里解析这种协议不费劲。用 fgets 或者按字节累积读入缓冲区直到出现 \n然后用 strsep 或 strtok_r 把竖线切开。注意一定要用 strtok_r 而不是 strtok因为 strtok 内部有静态状态在多客户端、多缓冲区场景下会互相覆盖。服务端用三个独立的缓冲区分开保存每个客户端的半包状态谁的数据读完整了谁的消息才进入解析。参数说明READY 用来关联玩家名与 socket 文件描述符DEAL 后面跟的是一串牌编号服务端发牌时必须保证同一张牌只发给一个人PLAY 里第一个参数是打出张数后面跟具体牌号。把张数放在前面是为了服务端可以一次性读完参数再处理不必每次猜测要读几个字段。3.2 服务端连接骨架select 三路复用先看服务端建立监听的代码这一段也是源码里最常见的开头#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/select.h #include sys/socket.h #define PORT 8080 #define MAX_CLIENTS 3 int main(void) { int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, MAX_CLIENTS) 0) { perror(listen); exit(1); } int clients[MAX_CLIENTS]; memset(clients, -1, sizeof(clients)); int connected 0; while (connected MAX_CLIENTS) { struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(listen_fd, (struct sockaddr *)cli, len); if (cfd 0) { perror(accept); continue; } clients[connected] cfd; printf(player %d connected from %s:%d\n, connected, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port)); } // 三个客户端都连上后进入选牌逻辑 for (int i 0; i MAX_CLIENTS; i) { fd_set rset; FD_ZERO(rset); FD_SET(clients[i], rset); struct timeval tv {1, 0}; int ret select(clients[i] 1, rset, NULL, NULL, tv); if (ret 0 FD_ISSET(clients[i], rset)) { char buf[128] {0}; int n recv(clients[i], buf, sizeof(buf) - 1, 0); if (n 0) { printf(from %d: %s\n, i, buf); } } } return 0; }这段代码做的是最底层的连接准备。逻辑说明socket 创建的是 IPv4 TCP 流式套接字setsockopt 里的 SO_REUSEADDR 是必须的否则服务端上次 CtrlC 退出后端口会进入 TIME_WAIT 状态紧接着重启就会报 bind: Address already in use这是典型的课设现场翻车点。accept 返回的是新的已连接套接字listen_fd 本身不负责收发数据。clients 数组用来存三个连接的 fd课设场景里最多三名玩家用固定数组比链表更直观。参数说明PORT 是服务端端口本地实验用 8080 没问题和别的程序冲突就换 8081 到 8099 之间。MAX_CLIENTS3 是斗地主的人数上限。注意 select 的第一个参数是“最大文件描述符加一”所以写成 clients[i] 1很多第一次写的人容易漏掉这个加一导致 select 永远不生效。3.3 发牌与叫地主状态怎么流转三个客户端都连接成功之后服务端要做两件事洗牌发牌然后进入叫地主流程。洗牌用 Fisher-Yates 算法这也是源码里最常见的一段#include stdlib.h void shuffle(int *deck, int n) { for (int i n - 1; i 0; i--) { int j rand() % (i 1); int tmp deck[i]; deck[i] deck[j]; deck[j] tmp; } }逻辑说明反向遍历把当前牌与随机位置交换最后得到的序列是等概率的。有一种常见的错法是“每次生成随机位置然后交换 deck[i] 和 deck[j]”那样确实也能打乱但等概率性证明更麻烦对课设展示这个函数足以说明问题。洗完之后把 54 张牌分成 17、17、17剩下 3 张底牌先不发给任何人等服务端把叫地主的结果算出来再追加到地主手上并把地主标记写入局状态。叫地主的规则是三名玩家轮流叫分或者按“先叫一分、再叫三分”的循环轮询。源码里一般用整型字段记录当前叫分最大值与出价玩家编号最后叫分最高的人当地主。为了保证不死锁服务端每发出一条询问消息都要启动超时计时器在 select 模型里最朴素的超时就是给 select 的 timeval 赋值比如 30 秒超时则默认当前玩家不叫。状态机里用枚举 GAME_WAIT、GAME_BID、GAME_PLAY、GAME_OVER 保存当前阶段收到消息时先校验当前阶段是否符合动作码不匹配就回一条 ERROR 而不是直接崩溃。这样做的好处是客户端误操作不会破坏服务端内部状态。3.4 牌型比较最容易血泪的 C 函数牌型比较是整个项目里最容易写翻车的地方。我先把“牌面”抽出来用 int 数组保存点数3 对应 310 对应 10J、Q、K、A 分别是 11、12、13、142 是 15小王是 16大王是 17。这样处理之后比较逻辑就完全落在整数上。判断一组牌是什么牌型一般这样实现// cards: 已按点数升序排好的牌点数数组n: 张数 // 返回类型字符串single pair triple straight bomb rocket ... const char *check_type(int *cards, int n) { if (n 1) return single; if (n 2) { if (cards[0] 16 cards[1] 17) return rocket; if (cards[0] cards[1]) return pair; return invalid; } if (n 3) { return (cards[0] cards[1] cards[1] cards[2]) ? triple : invalid; } if (n 4) { if (cards[0] cards[1] cards[1] cards[2] cards[2] cards[3]) return bomb; // 三带一 if ((cards[0] cards[1] cards[1] cards[2]) || (cards[1] cards[2] cards[2] cards[3])) return triple_one; return invalid; } if (n 5) { // 顺子连续且不含2和王 int ok 1; for (int i 1; i n; i) { if (cards[i] ! cards[i - 1] 1) { ok 0; break; } } if (ok cards[n - 1] 14) return straight; } return invalid; }逻辑说明先按张数分类再统计牌点连续性。斗地主的判定就是一层一层剥开的轮到 5 张及以上时先检查是否为连续数字顺子的终点被限制为 A14是因为 2 和王不能进顺子。这段代码没有处理连对、飞机和四带二源码一般也就只做到“支持常见牌型”所以你拿到源码后先看 check_type 里有没有处理 n6 的连对如果没有补齐它会是一个很好的答辩加分扩展点。参数说明cards 必须是升序排列调用前用 qsort 排一次即可这里不能对 cards 做原地乱序。如果出现非法张数如 n0返回字符串 invalid上层在比较大小之前必须对这个返回值做分支。这个函数最重要的边界是炸弹与普通牌型之间不能互相比较大小炸弹可以压任意普通牌型但两个炸弹之间要比点数王炸压一切。很多翻车案例是“我用一对 2 去压炸弹”结果服务端放行了原因就是比较函数只比了点数最大值没有先比牌型级别。比较大小的函数往往是分层逻辑双方类型相同就比较关键点数如果一方是炸弹或者王炸再单独升级处理除此之外都叫“管不上”。注意三带一里带的那张单牌不参与比较只比三张相同的那个点数。课堂答辩时把这个细节讲清楚比背十页介绍有用得多。4. 从 zip 到可运行在 Linux 上编译部署这份源码的五个步骤4.1 先确认环境Linux、gcc、make、端口都能用动手前先确认系统里有编译工具链。Debian/Ubuntu 系一般执行sudo apt update sudo apt install -y build-essentialbuild-essential 会带上 gcc、make 以及 libc 头文件对课程设计来说足够了。如果你用的是 Red Hat 系把命令换成 yum install gcc make。还有一种做法是用 Docker 拉一个 ubuntu 镜像来编译但我不太推荐在课设阶段引入 Docker因为挂载目录、容器网络这些概念会分散调试主线的精力。如果你在 WSL 环境跑先在 WSL 里执行 uname -r 查看内核版本版本太旧时安装新版发行版会报 “WSL needs updating your version of Windows subsystem for Linux” 之类提示解决方法是打开 Windows 更新或者用 wsl --update 更新一次再重新进入发行版。结论能一次跑通原生 Linux就不要叠虚拟机这层复杂度。检查端口也是容易忽略的点。如果服务端要监听 8080先执行ss -lntp | grep 8080看到 LISTEN 状态的进程就把 PORT 换一个或者用 pkill 干掉旧的测试进程。课设现场最尴尬的事是启动服务端时报 bind: Address already in use很多时候不是端口被占只是上一次程序没退出。4.2 解压源码包并看懂部署文档的目录结构拿到 zip 之后别急着 make。很多选手直接跳到编译结果缺了某个头文件路径再回头查更浪费时间。我一般先把压缩包放在一个单独的目录里再解压mkdir -p ~/doudizhu cd ~/doudizhu unzip /path/to/doudizhu.zip find . -maxdepth 2 -type f | sort解压完立刻做两件事第一看是否有 README 或部署文档第二列出顶层目录。部署文档里通常包含环境要求、端口号、启动顺序。没有文档时判断源码入口其实很简单先找 main 函数再找 socket 和 bind 调用最后看是否有 Makefile 或 CMakeLists。一般源码包结构是根目录下的 .c 与 .h 文件docs/ 或 doc/ 里的课程设计报告与部署文档Makefile 或 CMakeLists.txt常见的坑是解压出来的文件带中文名或空格。在 Linux 的 Shell 里输入中文文件名容易出编码问题我的习惯是解压后马上把顶层目录改成简单英文名mv Linux课程设计* doudizhu改完名之后后面所有命令都不再碰中文路径少很多麻烦。4.3 用 Makefile 一键编译注意 -Wall 和依赖很多源码包会带 Makefile但有的写得比较随意。一个适合课设的 Makefile 大概长这样CC gcc CFLAGS -Wall -Wextra -g -O0 LDFLAGS -lpthread all: server client server: server.o card.o $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS) client: client.o $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS) %.o: %.c card.h $(CC) $(CFLAGS) -c $ -o $ clean: rm -f *.o server client注意这里把 card.h 放进了 %.o 规则的依赖列表头文件一旦修改所有包含它的源文件都会重新编译。很多课设翻车点是开发到一半改了 card.h 里的牌型定义结果只重编了其中一个 .c另外几个 .o 还在用旧布局出现“服务端和客户端对不上”的诡异现象。把依赖关系写进 Makefile 是最省事的后悔药。编译时如果源码本身有警告先不要急着改业务逻辑优先看警告来自哪个文件。unused variable、implicit declaration of function 这类警告通常指向少包含了头文件。改成make clean make得到 server 和 client 两个可执行文件。如果 make 报错说找不到某个头文件用 grep -r include 查一下是自定义头文件还是系统头文件。自定义头文件路径不对时在 CFLAGS 里加 -I./src 重新编译即可。4.4 启动服务器再按顺序启动三个客户端部署文档里最常见的启动顺序是先启动服务端再启动三个客户端。./server 8080在另一个终端窗口执行./client 127.0.0.1 8080再把这条路复制到另外两个终端窗口执行同样的命令只是客户端里要输入不同的玩家名。这里我特别强调一下端口号和 IP 的选择。服务端监听地址用 INADDR_ANY也就是 0.0.0.0意味着本机所有网卡都可以连客户端在本地回环测试时地址写 127.0.0.1如果客户端在另一台真实机器上就要写服务端那块网卡的 IP可以用 ip addr 查看。启动后如果所有客户端都停在“等待其他玩家”状态说明服务端的连接计数或准备阶段校验没跑过去。最常见的原因是服务端只 accept 了三个连接就立即进入发牌逻辑如果有一个客户端提前断线connected 变量没有递减后面就一直等不够三个人。此时三个终端全部 CtrlC重新启动服务端再依次连客户端。4.5 按评分点自测连接、发牌、叫地主、出牌、结算Linux 课程设计围绕“基于 C 语言和 socket”的评分点通常集中在以下五处网络层是否真的用了 socket、bind、listen、accept。是否处理了客户端连接数达到三人后才开局。发牌是否保证 54 张牌不重不漏。牌型校验和大小比较是否符合斗地主规则。部署文档能不能让评委照着复现。先跑一遍 happy path然后主动测试“过牌”“炸弹压牌”“玩家中途退出”这三个场景。中途退出尤其重要因为很多课设源码根本没处理。一个可行的做法是在服务端的 recv 返回值小于等于 0 时标记该玩家掉线并把其他客户端都断开保证整个房间不会卡在等待出牌。源码里不一定做了但你可以把这个补丁写进答辩说明里。5. 运行与部署常见问题这五个坑让你在演示现场翻车5.1 recv 返回 0 被当成正常消息现象某个客户端关掉窗口后服务端还在向它发送出牌提醒程序没有崩溃但另外两个玩家一直等不到响应。原因TCP 连接关闭时对端会先收到一个 EOFrecv 返回 0。不少初学者在 recv 返回 0 时没有特殊处理反而把这个返回值当成空消息然后继续向已经关闭的 socket 写数据。第一次写可能触发 SIGPIPE进程直接退出Linux 上默认行为就是这样如果忽略了 SIGPIPEsend 会返回 EPIPE但消息永远送不到。解决在 recv 返回值 n0 的统一出口处把该玩家标记为掉线关闭这个 fd并从连接数组中移除。在 send 之前检查对方是否还在线对 SIGPIPE 用 signal(SIGPIPE, SIG_IGN) 抑制掉改用 send 的返回值判断错误。把这三件事都做了玩家中途退出的场景就不至于卡死整局。5.2 出牌校验时数组越界炸弹和飞机一起翻车现象手牌还剩 8 张打出飞机时程序突然 Segmentation fault演示现场进程结束。原因很多牌型判断函数是按固定长度数组设计的比如 char type[16]结果在解析一手超过 16 张的牌时越界写入。或者qsort 之后在牌型识别里访问 cards[n1] 这类“越界读”。越界读不会立刻崩溃但会得到随机数据导致把非法牌型判成合法。解决先给判断函数加一个入口保护n0 或 n20 直接返回 invalid绝不去访问 cards[i1]把类型字符串改成用静态字符串指针返回避免 char 数组拷贝越界。然后用 AddressSanitizer 跑一轮gcc -fsanitizeaddress -g -O1 -o server server.c card.c ./server 8080测试过程中出现 heap-buffer-overflow 或 stack-buffer-overflowgcc 会直接打印出错的源码行这是定位越界最快的手段。5.3 fgets 留下的换行符导致命令解析失败现象客户端输入出牌序号后服务端总提示“未知动作”但输入单张却可以输入多张时经常最后一个数字解析不出来。原因客户端常用 fgets(buf, sizeof(buf), stdin) 读用户输入fgets 会把末尾的 \n 也读进缓冲区。如果用 strtok 按空格切分\n 会附在最后一个 token 后面协议里又约定消息以换行结束服务端拿到“12\n”和“12”两种格式一旦用 strcmp 比较就失败。解决在客户端读入后先手动去掉换行char *p strchr(buf, \n); if (p) *p \0;或者用 strcspn 也是一样的效果。然后发送时用 snprintf 拼成带 \n 的报文服务端解析时用 strsep 或 strtok_r 按分隔符切完之后再对每个字段做 trim。这类问题排查起来很浪费时间所以源码包里如果用了 fgets先看它有没有处理换行符。5.4 头文件改动后没有触发重编译现象改了 card.h 里的一张牌表示常量重新 make 之后程序行为没有任何变化旧逻辑还在跑。原因Makefile 里没有写头文件依赖。make 依赖文件时间戳来决定是否重新编译但默认规则里不会自动跟踪每个 .c 文件 include 了哪些 .h除非用 gcc -MM 生成依赖文件或者像上面那样在规则里写明 card.h。解决在 Makefile 的 %.o 规则依赖里加对应头文件更省心的是在根目录执行make clean make每次改动头文件后强制作一次全量重编。演示前养成这个习惯能避免“改了没变”这种现场灵异事件。5.5 虚拟机里客户端连不上服务端现象在虚拟机里启动服务端物理机上的客户端 connect 超时但在虚拟机内部用 127.0.0.1 可以连上。原因虚拟机网络模式限制了跨主机访问。如果虚拟机用的是 NAT 模式外部机器没法直接访问虚拟机里的端口如果用的是桥接模式虚拟机和主机又不在同一网段或者宿主机防火墙没有放行对应端口。解决课设演示一般都在同一台机器上开四个终端所以最简单的方法是把所有客户端都跑在虚拟机本机地址统一写 127.0.0.1不要写宿主机 IP。如果一定要跨机器把虚拟机网络改成桥接模式然后保证服务端 listen 的端口在宿主机防火墙里放行再确认虚拟机 IP 和客户端能互相 ping 通。顺序是先 ping 通链路再测试端口通不通最后才查业务代码。很多连带问题都出在“链路都不通就开始 debug 逻辑”。6. 把服务端做扎实两个值得加进去的进阶功能基础功能跑通只是及格要让这份课设从“能玩”变成“不容易被问倒”我会再加两组功能。第一个是服务端日志。在服务端每个关键节点打一行结构化日志而不是只打印到控制台void log_msg(int level, const char *fmt, ...) { char buf[512]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); fprintf(stderr, [%s] %s\n, level ? WARN : INFO, buf); }用 fprintf(stderr, ...) 而不是 printf是为了避免日志缓冲逻辑干扰标准输出的协议内容也方便重定向到日志文件。这个函数对答辩极有用评委问“你怎么定位玩家掉线问题”时你能直接指日志里的 recv ret-1。第二个是超时踢出。没有人愿意在“等待玩家”界面干等。给每个客户端维护一个 last_active 时间戳在 select 的 timeval 里算出最早的剩余超时时间超时就发送 QUIT 并断开。课设里最简单的实现是 select 的 tv 固定为 30 秒每次事件到来后重新设置玩家长时间不出牌就自动判负。这也能实现服务器核心价值——死等。作为习惯我会在改完一个点之后跑三个用例单张压单张、炸弹压单张、普通牌型互不压。然后连续开 30 局自动模拟不崩溃算过。这个验证习惯比写一堆测试框架更符合课程设计的时间投入。最后说一句我自己的教训这份课设真正决定分数高低的不是把斗地主规则实现得多完整而是别人连不上、连上了玩不了、玩到一半崩溃的时候你能不能拿出日志和补丁。把上面这两个功能加上再把部署文档里的启动步骤亲自从头到尾走两遍已经能超过大部分只交源码的作业了。希望帮到你。本文还有配套的精品资源点击获取
返回列表