Shooting Battle:Linux系统下的网络编程究极产物
服务器端
一、关键技术要点总结
1. 网络编程基础
- Socket API 使用:socket() → bind() → listen() → accept() 完整流程
- TCP 字节流特性:无消息边界,需自定义协议(帧结束标记
<<<FRAME_END>>>) - 地址结构:sockaddr_in,网络字节序转换(htons/htonl)
- SO_REUSEADDR:避免 TIME_WAIT 状态导致端口占用
2. 多线程编程
- 线程创建与分离:pthread_create + pthread_detach
- 互斥锁机制:
clients_mtx:保护玩家列表game_mtx:保护游戏世界(地图、子弹、状态)- 锁顺序约定:先 game_mtx 后 clients_mtx(防止死锁)
- 线程安全的全局状态管理
3. 游戏逻辑架构
- 三类线程:主线程(accept)、客户端线程(处理输入)、游戏线程(100ms 定时更新)
- 帧同步机制:服务器主导推进游戏状态,广播画面
- 物理模拟:速度衰减(摩擦力)、碰撞检测
- 模式系统:测试模式(单人+假人)、对抗模式(双人)
4. 协议设计
- 帧协议:
\033[2J\033[H+ 内容 +\n<<<FRAME_END>>>\n - 输入协议:WSAD(移动)、空格/回车(射击)、ESC 序列(方向键)
- ESC 序列解析:状态机设计(0→1→2)
5. 系统编程技巧
- 信号处理:SIGINT 优雅退出
- 网络接口查询:getifaddrs 获取局域网 IP
- 日志系统:带时间戳的文件日志
二、程序核心流程图
┌─────────────────────────────────────────────────┐
│ main() 主线程 │
│ 1. 初始化全局数据(clients、bullets、game_map) │
│ 2. 创建 TCP Socket (socket/bind/listen) │
│ 3. 启动 game_loop 线程(分离) │
│ 4. 循环 accept 新连接 │
└──────────────────┬──────────────────────────────┘
│
┌──────────┴──────────┐
│ accept 新客户端 │
│ - 分配玩家槽位 │
│ - 创建客户端线程 │
└──────────┬──────────┘
│
┌──────────────┴──────────────┐
│ client_thread (每个连接) │
│ 1. 读取昵称 │
│ 2. 模式选择(仅 P1) │
│ - 测试模式:生成假人 │
│ - 对抗模式:等待 P2 │
│ 3. 循环读取按键 │
│ 4. 调用 handle_input │
│ 5. 断线清理 │
└──────────────┬──────────────┘
│
┌──────────┴──────────┐
│ handle_input() │
│ - 更新速度/朝向 │
│ - 发射子弹 │
└─────────────────────┘
┌─────────────────────────────────────────────────┐
│ game_loop() 游戏线程(独立运行) │
│ 每 100ms 一帧: │
│ 1. 更新玩家物理(位置 + 速度衰减) │
│ 2. 更新子弹飞行 │
│ 3. 碰撞检测(子弹命中、扣血、胜负判定) │
│ 4. 调用 send_game_state() 广播画面 │
└─────────────────────────────────────────────────┘
三、模拟答辩 Q&A
Q1:为什么需要两把锁?如何避免死锁?
A:
-
两把锁的职责划分:
game_mtx:保护游戏世界数据(地图、子弹、游戏状态标志)clients_mtx:保护玩家列表(连接信息、昵称、HP 等)
-
避免死锁的策略:
- 统一加锁顺序:需要同时持有两把锁时,严格按"先 game_mtx,后 clients_mtx"的顺序
- 代码示例:
// ✅ 正确顺序 pthread_mutex_lock(&game_mtx); pthread_mutex_lock(&clients_mtx); // ... 操作 pthread_mutex_unlock(&clients_mtx); pthread_mutex_unlock(&game_mtx); - 原理:所有线程按相同顺序申请资源,打破"循环等待"条件
Q2:TCP 是字节流协议,如何保证接收到完整的"帧"?
A:
-
问题:TCP 无消息边界,可能出现粘包/拆包
-
解决方案:
- 定义帧结束标记:
<<<FRAME_END>>> - 客户端缓冲区累积:逐字节读取直到遇到标记
- 服务端保证原子性:每次发送"清屏序列 + 内容 + 标记"作为完整单元
- 定义帧结束标记:
-
代码示例(客户端逻辑):
char buffer[4096]; int pos = 0; while (recv(...)) { if (strstr(buffer, "<<<FRAME_END>>>")) { // 显示完整一帧 pos = 0; // 重置 } }
Q3:游戏线程的帧率是如何保证的?
A:
-
目标帧率:100ms/帧(10 FPS)
-
实现方式:
while (g_running) { usleep(100000); // 休眠 100ms // 更新逻辑... } -
局限性:
usleep不是高精度定时器,实际帧率会有波动- 更新逻辑耗时会累积到下一帧
-
改进方向:
- 使用
clock_nanosleep(CLOCK_MONOTONIC, ...)实现固定周期 - 记录时间戳,按实际耗时动态调整休眠时间
- 使用
Q4:如何处理 ESC 方向键序列?
A:
- 问题:方向键在终端发送为
ESC[A(上)、ESC[B(下)等 - 状态机设计:
状态 0(普通)→ 收到 27(ESC) → 状态 1 状态 1 → 收到 '[' → 状态 2 状态 2 → 收到 A/B/C/D → 解析完成,回到状态 0 - 代码实现:
if (esc_state == 0 && c == 27) esc_state = 1; else if (esc_state == 1 && c == '[') esc_state = 2; else if (esc_state == 2) { if (c == 'A') handle_input('U'); // 上 // ... esc_state = 0; }
Q5:如何实现"测试模式"的假人玩家?
A:
-
核心思路:在玩家2槽位创建一个
fd = -1的特殊项 -
关键步骤:
- 玩家1选择测试模式后,标记
clients[1].active = true - 设置
clients[1].fd = -1(无网络连接) - 初始化其位置、HP 等属性(使用玩家2默认出生点)
- 游戏逻辑照常处理(碰撞检测时不区分真假玩家)
- 广播画面时跳过
fd < 0的项(无需发送)
- 玩家1选择测试模式后,标记
-
清理时机:游戏结束后移除所有
fd < 0的活跃项
Q6:如何保证优雅退出?
A:
- 信号处理:
signal(SIGINT, on_sigint); void on_sigint(int sig) { g_running = 0; // volatile 保证可见性 } - 退出流程:
- 主线程 accept 循环检查
g_running - 游戏线程 100ms 一次检查
g_running - 客户端线程 recv 返回后检查
g_running - 主线程关闭所有连接和监听 socket
- 主线程 accept 循环检查
Q7:速度衰减机制的物理意义是什么?
A:
- 模拟摩擦力:玩家松开按键后不会"惯性滑行"
- 实现方式:
if (vx > 0) vx--; // 向右减速 else if (vx < 0) vx++; // 向左减速 - 效果:玩家需持续按键保持移动,提升操作手感
Q8:如果两个子弹同时击中同一玩家会怎样?
A:
- 当前实现:在同一帧内只会处理第一颗命中的子弹
- 代码逻辑:
for (子弹 i) { for (玩家 j) { if (命中) { j.hp--; bullets[i].active = false; break; // 退出玩家循环 } } } - 结果:第二颗子弹因第一颗已失效,不会重复扣血
Q9:为什么要用 snprintf 而不是 sprintf?
A:
- 安全性:
snprintf限制写入长度,防止缓冲区溢出 - 示例:
char buf[100]; snprintf(buf, sizeof(buf), "%s", long_string); // 最多写 99 字符 + '\0' - 实际意义:避免网络攻击者发送超长昵称导致栈溢出
Q10:如何测试这个服务器?
A:
- 编译:
gcc -pthread -o server server.c - 运行服务器:
./server - 客户端连接(使用 telnet/netcat):
telnet <服务器IP> 8888 - 测试流程:
- 第一个连接自动成为 P1,选择模式(1 或 2)
- 测试模式:立即开始,WSAD 移动,空格射击
- 对抗模式:等待第二个连接,然后双方操作
四、可能的扩展问题
Q11:如何改进使其支持更多玩家?
- 增加
MAX_CLIENTS常量 - 修改碰撞检测逻辑(多人混战)
- 重新设计出生点位置
- 考虑阵营/队伍系统
Q12:如何优化网络延迟?
- 客户端预测(本地先移动,收到确认后校正)
- 减少广播频率(仅在状态变化时发送)
- 使用 UDP 协议(牺牲可靠性换取低延迟)
Q13:如何防止作弊?
- 服务端权威:所有逻辑判定在服务器完成
- 输入合法性校验:检查移动速度、射击频率
- 加入反调试/完整性检查
五、答辩建议
- 准备演示环境:确保服务器能稳定运行,准备好两个客户端窗口
- 熟悉代码流程:能快速定位关键函数(init_game、handle_input、send_game_state)
- 理解并发原理:能清晰解释锁的作用和死锁预防
- 准备故障排查:如遇到问题(如客户端卡住),能迅速定位是网络/线程/逻辑问题
- 展示日志文件:
game_log.txt能证明系统运行记录
客户端
一、关键技术要点总结
1. 终端编程(Terminal I/O)
-
Raw 模式切换:
tcgetattr/tcsetattr:读取和设置终端属性ECHO:控制回显(禁用后输入不显示)ICANON:规范模式(禁用后逐字符读取,不等待换行)VMIN=0, VTIME=1:非阻塞读取,1/10 秒超时
-
终端控制序列(ANSI Escape Codes):
\033[2J:清屏\033[H:光标移到左上角ESC [ A/B/C/D:方向键(上/下/右/左)
2. 网络通信
- TCP 客户端流程:socket() → connect() → send()/recv()
- 字节流处理:使用帧标记
<<<FRAME_END>>>分割完整画面 - 异步接收设计:独立线程处理接收,避免阻塞主输入循环
3. 多线程编程
- 线程分离:
pthread_create(&th, NULL, recv_thread, &fd); pthread_detach(th); // 线程结束后自动回收资源 - 线程间通信:通过
volatile int g_running协调退出 - 线程安全问题:recv 线程与主线程通过 socket fd 通信,无共享内存竞态
4. 信号处理
- SIGINT 捕获:
signal(SIGINT, on_sigint); // 在处理函数中设置标志 + 恢复终端 - 注意事项:信号处理函数中调用
tcsetattr非异步信号安全(实际应仅设标志)
5. 内存管理
- 动态缓冲区扩展:
if (len + n + 1 > cap) { cap *= 2; agg = realloc(agg, cap); } - 内存保护:超过 256KB 时裁剪缓冲区,防止无限增长
6. 输入解析
- ESC 序列识别:
ESC(0x1b) → 读取'[' → 读取最终字符(A/B/C/D) 完整序列:\x1b[A (上箭头) - 回车归一化:
\r→\n(统一换行符)
二、程序核心流程图
┌─────────────────────────────────────────────────┐
│ main() 主流程 │
│ │
│ 1. 注册 SIGINT 处理函数 │
│ 2. 询问服务器 IP 和端口(支持默认值) │
│ 3. 建立 TCP 连接 │
│ 4. 发送昵称(行模式,避免被清屏打断) │
│ 5. 启动接收线程(recv_thread) │
│ 6. 切换终端到 raw 模式 │
│ 7. 主循环:读取按键 → 发送到服务器 │
│ 8. 退出清理:恢复终端 + 关闭 socket │
└──────────────────┬──────────────────────────────┘
│
┌──────────┴──────────┐
│ enable_raw_mode() │
│ - 保存原始终端属性 │
│ - 禁用 ECHO/ICANON │
│ - 设置 VMIN/VTIME │
│ - 注册 atexit 恢复 │
└──────────────────────┘
┌─────────────────────────────────────────────────┐
│ recv_thread() 接收线程 │
│ │
│ 循环: │
│ 1. recv() 接收数据到缓冲区 │
│ 2. 追加到聚合缓冲区(agg) │
│ 3. 查找 <<<FRAME_END>>> │
│ 4. 找到最后一个清屏序列位置 │
│ 5. 输出完整帧到终端 │
│ 6. 消费已处理数据 │
│ 7. 缓冲区保护(超过 256KB 裁剪) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ 主循环按键处理逻辑 │
│ │
│ read() 逐字符读取: │
│ - 普通字符:直接 send() │
│ - '\r':转换为 '\n' 后发送 │
│ - ESC(0x1b): │
│ ├─ 尝试读取后续 2 字节 │
│ ├─ 若为 '[' + <A/B/C/D>:发送完整序列 │
│ └─ 否则:分别发送各字节 │
└─────────────────────────────────────────────────┘
三、模拟答辩 Q&A
Q1:为什么需要切换终端到 raw 模式?
A:
-
问题背景:
- 默认终端是规范模式(Canonical Mode):
- 必须等用户按回车才能读取一整行
- 自动回显输入字符
- 支持行编辑(Backspace、Ctrl+C 等)
- 默认终端是规范模式(Canonical Mode):
-
游戏需求:
- 需要实时响应单个按键(如 WSAD 移动)
- 不需要回显(避免干扰画面显示)
- 不能等待回车(降低延迟)
-
解决方案:
raw.c_lflag &= ~(ECHO | ICANON); // 禁用回显和规范模式 raw.c_cc[VMIN] = 0; // 非阻塞读取 raw.c_cc[VTIME] = 1; // 100ms 超时
Q2:为什么用独立线程接收数据而不是主线程?
A:
-
问题:
recv()是阻塞调用,会卡住当前线程- 如果主线程 recv,就无法同时读取键盘输入
-
架构设计:
主线程:read(stdin) → 发送按键到服务器 接收线程:recv(socket) → 解析帧并输出到终端 -
优点:
- 并发处理:输入和输出互不干扰
- 响应及时:按键立即发送,不等待网络数据
Q3:如何处理 TCP 粘包和拆包问题?
A:
-
TCP 特性:字节流协议,无消息边界
-
解决方案:
- 服务端:每帧末尾添加
<<<FRAME_END>>> - 客户端:
- 使用动态增长缓冲区(agg)累积接收的字节
- 查找最后一个
<<<FRAME_END>>> - 提取从最后一个
\033[2J到标记之间的内容 - 输出完整帧并消费已处理数据
- 服务端:每帧末尾添加
-
代码示例:
// 接收数据追加到缓冲区 memcpy(agg + len, buf, n); len += n; // 查找帧标记 char *last_end = strstr(agg, "<<<FRAME_END>>>"); if (last_end) { // 输出并消费 fwrite(agg, 1, frame_len, stdout); memmove(agg, agg + consume, len - consume); }
Q4:为什么要找"最后一个清屏序列"?
A:
-
问题场景:网络延迟可能导致一次 recv 读到多帧数据
-
优化目标:
- 只显示最新一帧(避免终端闪烁)
- 跳过中间过时的帧
-
实现逻辑:
// 在缓冲区中找到最后一个 \033[2J for (char *q = agg; q < last_end; ++q) { if (memcmp(q, "\033[2J", 4) == 0) { last_clear = q; // 更新最后清屏位置 } } // 输出从 last_clear 到 last_end 的内容
Q5:如何处理方向键(箭头键)?
A:
-
方向键编码:
上:ESC[A (0x1b 0x5b 0x41) 下:ESC[B (0x1b 0x5b 0x42) 右:ESC[C (0x1b 0x5b 0x43) 左:ESC[D (0x1b 0x5b 0x44) -
解析流程:
if (c == 0x1b) { // 收到 ESC read(STDIN_FILENO, &seq[0], 1); // 读 '[' read(STDIN_FILENO, &seq[1], 1); // 读 'A'/'B'/'C'/'D' if (seq[0] == '[') { // 完整序列:发送 3 字节 send(fd, "\x1b[A", 3, 0); } else { // 不完整:分别发送 send(fd, "\x1b", 1, 0); send(fd, &seq[0], 1, 0); } }
Q6:为什么要先发送昵称再切换 raw 模式?
A:
-
时序问题:
- 如果先切换 raw 模式,服务器可能立即发送清屏帧
- 清屏会打断用户输入昵称
- 用户看不到自己输入的字符(ECHO 被禁用)
-
解决方案:
// 1. 使用规范模式输入昵称(可以正常回显和编辑) fgets(line, sizeof(line), stdin); send(fd, line, strlen(line), 0); // 2. 启动接收线程 pthread_create(&th, NULL, recv_thread, &fd); // 3. 切换到 raw 模式(用于游戏控制) enable_raw_mode();
Q7:动态缓冲区如何防止内存泄漏?
A:
-
增长策略:容量不足时翻倍扩展
if (len + n + 1 > cap) { size_t newcap = cap * 2; char *tmp = realloc(agg, newcap); if (tmp) { agg = tmp; cap = newcap; } } -
保护机制:
// 超过 256KB 时裁剪,只保留尾部 8KB if (len > 262144) { memmove(agg, agg + len - 8192, 8192); len = 8192; } -
释放时机:
- 线程退出时自动释放(栈变量)
- 若需显式释放:
free(agg);
Q8:为什么 g_running 要用 volatile 修饰?
A:
-
编译器优化问题:
// 没有 volatile,编译器可能优化为: if (g_running) { // 第一次读取 while (1) { // 循环内不再读取 // ... } } -
volatile 作用:
- 强制每次都从内存读取最新值
- 防止编译器缓存到寄存器中
-
使用场景:
- 信号处理函数修改的变量
- 多线程共享的标志位
Q9:如何保证终端能被正确恢复?
A:
-
多重保护机制:
-
atexit 注册:
atexit(disable_raw_mode); // 正常退出时调用 -
SIGINT 处理:
void on_sigint(int sig) { g_running = 0; disable_raw_mode(); // Ctrl+C 时恢复 } -
主循环退出:
while (g_running) { ... } disable_raw_mode(); // 循环结束后恢复
-
-
注意:信号处理中调用
tcsetattr不是异步信号安全函数,更严谨的做法是只设标志。
Q10:如何测试这个客户端?
A:
-
编译:
gcc -pthread -o client cli_client.c -
启动服务器:
./server -
运行客户端:
./client -
输入测试:
- IP:直接回车(使用 127.0.0.1)
- 端口:直接回车(使用 8888)
- 昵称:输入任意名字
-
游戏测试:
- 第一个连接:按 '1' 选测试模式
- WSAD 移动,空格射击
- Ctrl+C 退出
四、关键数据结构说明
1. 终端属性结构(struct termios)
struct termios {
tcflag_t c_iflag; // 输入模式标志
tcflag_t c_oflag; // 输出模式标志
tcflag_t c_cflag; // 控制模式标志
tcflag_t c_lflag; // 本地模式标志(包含 ECHO/ICANON)
cc_t c_cc[NCCS]; // 控制字符数组(VMIN/VTIME)
};
2. 关键标志位说明
| 标志位 | 含义 | 禁用后效果 |
|---|---|---|
ECHO |
回显输入 | 输入不显示在终端 |
ICANON |
规范模式 | 逐字符读取,不等待换行 |
3. 控制字符配置
| 字段 | 含义 | 设置值 | 效果 |
|---|---|---|---|
VMIN |
最小字符数 | 0 | 非阻塞读取 |
VTIME |
超时时间(1/10秒) | 1 | 100ms 超时返回 |
五、可能的扩展问题
Q11:如何支持 Windows 平台?
- 终端控制:使用 Windows Console API
#ifdef _WIN32 #include <conio.h> #include <windows.h> // 使用 _getch() 代替 read() // 使用 SetConsoleMode() 代替 tcsetattr() #endif
Q12:如何优化网络延迟显示?
- 添加本地预测:
- 本地立即更新玩家位置
- 收到服务器确认后校正
- 减少"输入延迟感"
Q13:如何处理中文输入?
- UTF-8 编码支持:
- 中文字符占 3 字节
- 需要完整读取多字节序列
- 使用
mblen()判断字符长度
Q14:如何实现彩色文本?
- ANSI 颜色代码:
"\033[31m红色\033[0m" // 红色文本 "\033[42m绿色背景\033[0m" - 在帧内容中嵌入颜色代码
六、常见问题调试
问题1:终端无法恢复正常
- 原因:程序异常退出未调用
disable_raw_mode() - 解决:手动输入
reset命令重置终端
问题2:方向键无响应
- 检查:确认 ESC 序列完整接收
- 调试:打印接收到的字节序列
问题3:画面闪烁
- 原因:输出旧帧导致
- 优化:确保只输出最后一个完整帧
问题4:内存持续增长
- 检查:缓冲区裁剪逻辑是否生效
- 监控:
top命令查看内存使用
七、答辩建议
-
准备演示:
- 启动服务器和两个客户端窗口
- 展示完整游戏流程(连接→选模式→对战)
- 展示 Ctrl+C 优雅退出
-
代码定位能力:
- 熟悉
enable_raw_mode()实现 - 熟悉
recv_thread()帧解析逻辑 - 熟悉 ESC 序列处理代码
- 熟悉
-
原理讲解:
- 能清晰解释 raw 模式与规范模式的区别
- 能说明帧协议的设计理由
- 能解释多线程架构的优势
-
对比分析:
- 与服务端的协议对应关系
- 客户端-服务器交互时序图
-
故障排查:
- 如何调试"卡住"问题(检查 g_running)
- 如何排查"乱码"问题(检查帧标记)

浙公网安备 33010602011771号