ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

游戏大厂C/C++校招笔试核心考点与备考策略

游戏大厂C/C++校招笔试核心考点与备考策略 每年的秋招补录季总有一批同学被各种笔试题“教做人”尤其是游戏大厂的C/C岗位。搜狐畅游的这套2019校招笔试题虽然年份早了点但考题风格和难度放在今天依然很有参考价值——游戏开发工程师的笔试考什么、重点看什么几乎都能从这套题里摸出脉络。我当时补录阶段也做过这套题说实话难度不算变态但覆盖面很广语言细节、内存模型、算法基础都有涉及。而且它有一个很典型的特点偏爱考“C/C的差异点”和“底层内存行为”。如果只是把C当“带类的C”来写很多题会做得很别扭。这篇就把这套题背后真正想考察的点拆开揉碎帮准备校招的你划一划重点。这套题适合谁主要两类人一是正在准备游戏公司C/C方向笔试的应届生二是想系统查漏补缺的在校生。哪怕你暂时不投畅游把里面的知识点吃透对其他游戏大厂的笔试也有很大帮助。下面我按考点模块一个个拆解。1. 语言基本功C/C差异点才是真正的考点先说一个大方向游戏公司笔试里C/C题目很少考“背语法”而是喜欢考两个语言在底层行为上的差异以及这些差异在实际工程里带来的影响。畅游这套题也不例外不少题目表面上看是语法题实际上是在考察你对“C在C的基础上做了什么、为什么这么做”的理解。1.1 为什么游戏开发如此执着于C/C聊考点之前得先搞清楚一个底层逻辑为什么游戏公司校招笔试要考C/C而且是深扣底层的那种考法。游戏引擎Unreal、Unity底层、自研引擎基本都是C写的性能要求极高。渲染、物理、动画、粒子这些模块每一帧要在几十毫秒内完成大量计算编译型语言手动内存管理带来的性能优势是Java、Python这类语言替代不了的。尤其是移动端游戏CPU和内存资源有限一个内存泄漏、一次不必要的拷贝都可能直接导致掉帧或者闪退。所以游戏公司对C/C岗位的候选人有个隐性的要求必须理解代码在内存里长什么样。栈上还是堆上生命周期多长拷贝了几次这些如果心里没数写出来的代码到项目里大概率会埋雷。笔试就是在用题目筛掉那些“只会调API、不懂底层”的简历党。1.2 高频差异考点速查表结合这套题和多家游戏公司的笔试风格我整理了下面几个“必考差异点”每一组都要能说出个一二三来对比维度CC游戏开发中的实际影响内存管理malloc/freenew/delete、RAII手动管理易泄漏C用RAII降低风险函数调用函数指针函数重载、虚函数、lambda多态让引擎架构可扩展但虚函数有性能成本类型转换强转static_cast/dynamic_cast等更安全dynamic_cast有运行时开销热路径慎用默认参数不支持支持提高接口灵活性标准库无STL容器/算法高效开发但要理解底层数据结构和迭代器失效问题范式支持面向过程面向对象 泛型 函数式游戏ECS架构中常用组合优于继承、数据驱动设计这张表不是让你背而是笔试里的选择题、改错题很多都是围绕这些差异设计的。比如它可能会给你一段“看起来像C实际上是C风格”的代码让你判断哪里有问题。1.3 对“c转c”群体的特殊建议热词里有个“c转c”这个背景在准备笔试时很有代表性。很多同学C语言底子不错指针玩得溜但一接触C就习惯性地用它写C风格的代码——结构体满天飞、全局函数到处是、手动new/delete。这在笔试里很容易吃亏。为什么因为面试官从你的代码风格就能看出你“有没有C思维”。比如**RAII资源获取即初始化**这个概念C语言里没有对应的东西但C面试里反反复复会问。它的核心逻辑是资源内存、文件句柄、锁在构造函数里获取在析构函数里释放利用栈对象的生命周期来自动管理资源异常发生时也能保证析构函数执行。我在实际项目中见过太多“C风格C”代码导致的线上问题。最典型的就是忘记delete、异常路径上资源不释放、裸指针到处传。C11之后有了智能指针unique_ptr/shared_ptr/weak_ptrRAII的落地成本大大降低笔试里手写一个简单的智能指针类也几乎是标配题。如果你还在用C的思维写C这一关过不去。2. 笔试核心考点拆解内存、多态与STL这套题的主干考点大概可以归纳成三块内存模型、面向对象核心机制、STL的底层行为。每块都值得展开讲因为它们对应的不只是笔试分数更是后续技术面和实际开发的基石。2.1 内存布局栈、堆、全局区、常量区C/C程序的内存分区是必考内容选择题、简答题都可能出现。标准划分是这样的栈区由编译器自动分配释放存放局部变量、函数参数。栈默认大小在Windows下通常是1MB左右Linux下一般是8MB。递归过深、在栈上申请大数组很容易栈溢出。堆区由程序员手动分配释放new/malloc对应delete/free。堆空间受限于系统可用内存远大于栈。全局区/静态区存储全局变量和static变量程序结束才释放。常量区存储字符串常量等只读修改会导致崩溃。代码区存放函数体的二进制代码。笔试里常见的坑有哪些我列几个真实案例第一栈上放了大对象。比如一个包含int[1024*1024]的结构体直接定义成局部变量在某些平台直接爆栈。正确做法是分配在堆上或者用std::vector管理。第二返回栈对象地址。函数返回局部变量的指针或引用出了函数作用域这块栈内存就被回收了后续使用是未定义行为。编译器通常会给Warning但笔试的代码题不一定开-Wall自己得能看出来。第三内存重叠的memcpy问题。memcpy对重叠内存区域是未定义行为应该用memmove。笔试里偶尔会埋伏这个点考察你对库函数细节的熟悉程度。2.2 指针和引用的本质区别这是C面试题里的“入门必问”畅游的题里也出现过。如果要我一句话总结指针是保存地址的变量引用是变量的别名指针可以为空引用必须在定义时初始化且之后不能改绑定。但笔试不会只考概念它可能给你一段代码让你判断输出或者指出编译错误。比如int a 10; int ref a; int *ptr a; ref 20; // a变成20 ptr nullptr; // 只是指针变量不再指向aa没变这里要清楚对引用赋值实际上是给被引用的变量赋值对指针变量赋值只是改变指针的指向。还有一个高频题函数参数传值、传引用、传指针的区别。传值会拷贝一份传引用和传指针不会拷贝但指针本身有4/8字节拷贝修改行为不同。在游戏开发里大量对象传递时用const引用是性能关键——避免一次无谓的拷贝可能省下几毫秒这在每帧逻辑里就是天壤之别。2.3 多态的实现原理虚函数与虚表“C多态怎么实现的”几乎每家游戏公司技术面都会问笔试里也会用代码题来考。核心就三点虚函数、虚表vtable、虚指针vptr。当你定义了一个含虚函数的类编译器会为这个类生成一张虚函数表表里存放的是虚函数的地址。每个对象内部会有一个隐藏的虚指针vptr指向所属类的虚表。调用虚函数时通过vptr找到虚表再从虚表里取出对应的函数指针来调用。这个过程叫动态绑定运行时才确定调用哪个版本。代价是什么呢一次间接寻址以及虚函数通常无法内联——对性能敏感的游戏代码这是要尽量避免在每帧逻辑里大量使用虚函数的原因之一。笔试题目常见形式基类指针指向派生类对象调用虚函数/非虚函数判断输出。我给你出一道典型的class Base { public: virtual void show() { cout Base endl; } void print() { cout Base print endl; } }; class Derived : public Base { public: virtual void show() override { cout Derived endl; } void print() { cout Derived print endl; } }; int main() { Base* p new Derived(); p-show(); // 虚函数动态绑定输出 Derived p-print(); // 非虚函数按指针类型绑定输出 Base print delete p; }关键在于非虚函数是编译期静态绑定的编译器看的是指针的静态类型。而虚函数是运行期动态绑定看的是对象的真实类型。这个题如果答错基本等于告诉面试官“你C多态还没入门”。2.4 内存对齐没听过真的会吃亏内存对齐是C/C笔试里很喜欢考的一个细节因为它直接关系到结构体占多大内存。游戏里经常需要把数据打包进Buffer内存对齐问题如果处理不好轻则性能下降重则直接崩溃。规则看起来很简单但实际容易踩坑每个成员按其对齐数对齐结构体的总大小是最大对齐数的整数倍。对齐数一般是编译器默认值如#pragma pack默认8字节和成员自身大小的较小值。举个例子struct Test { char a; // 1字节 int b; // 4字节 char c; // 1字节 };直觉上大小是6字节实际在默认对齐下是12字节。为什么a占1字节后为了对齐b4字节要跳过3个字节填充然后c占1字节结构体总大小要对齐到最大对齐数4的整数倍又得填充3个字节。所以2字节的数据占用了12字节的存储空间浪费了6字节。怎么优化按成员大小降序排列成员声明顺序或者用#pragma pack调整。这在游戏引擎的序列化、网络同步数据包设计里特别关键——一个结构体多占的字节乘以成千上万的实例内存占用差距就出来了。如果你笔试时能主动提一嘴“这个结构体有内存对齐sizeof不等于成员之和”面试官的好感度会明显上升因为它说明你真有过底层开发经验。3. 常见笔试题型与解题思路从读懂题目到交付满分代码光掌握知识点还不够你还得知道这些知识点在笔试题里是怎么被“包装”成题目的以及用什么思路去解最快最稳。畅游这套题型的分布很有代表性我按出现频率排个序代码阅读与输出、改错题、算法题、设计/开放题。3.1 代码阅读题输出就是照妖镜这类题最喜欢用继承、指针、生命周期组合拳。比如定义了三个类基类构造时调用虚函数派生类里又有一个成员对象问你main里new一个派生类后程序输出什么。很多同学一看到“构造函数里调用虚函数”就迷糊。这里有个非常关键的坑在构造函数或析构函数中调用虚函数不会发生动态绑定。因为构造时对象的vptr是指向当前正在构造的这个类对应的虚表子类部分还没构造完成不能调用尚不存在的派生类版本。所以基类构造函数里调用的虚函数调用的一定是基类自己的版本。这种题目怎么练我建议把《Effective C》的条款09“绝不在构造和析构过程中调用虚函数”找来精读一下笔试里这道题基本就稳了。还有另一类常见题对象生命周期与析构顺序。记住一个规律构造顺序是“基类先于派生类、成员按声明顺序初始化、构造体执行”析构顺序完全反过来“派生类析构函数体先执行、成员按声明顺序逆序析构、基类最后析构”。这个顺序题也经常和虚析构函数一起考——如果基类析构函数不是虚函数delete基类指针指向的派生类对象时只会调用基类析构派生类部分资源泄漏。这是一道经典的安全隐患题。3.2 改错题考的是项目里的真实代码味道改错题比阅读题更贴近工程。给你一小段代码里面埋着内存泄漏、空指针解引用、迭代器失效、混用new[]和delete等错误要你找出来并改正。以迭代器失效为例这是STL题里的“钉子户”。看这段std::vectorint v {1, 2, 3, 4, 5}; for (auto it v.begin(); it ! v.end(); it) { if (*it % 2 0) { v.erase(it); // 错误erase后it失效再是未定义行为 } }正确写法是for (auto it v.begin(); it ! v.end(); ) { if (*it % 2 0) { it v.erase(it); // erase返回下一个有效的迭代器 } else { it; } }为什么vector的erase会导致迭代器失效因为vector在删除元素后需要把后续元素向前移动整个存储空间不变但被删位置后面的迭代器指向的元素已经变了而且如果erase导致容量重分配那就全废了。改这题的时候最好顺手写一句注释说明“erase返回下一个有效迭代器”体现你对容器底层实现的理解。这种题目考察的就是你有没有写过多线程、写过项目里真实业务逻辑代码。平时自己写代码时多开- Wall -Wextra 编译选项编译器报的Warning就是改错题的现成素材遇到一个查一个积累多了笔试题自然不在话下。3.3 算法题时间限制下的策略性取舍游戏公司笔试的算法题一般不会像LeetCode Hard那么变态但也不会让你轻松AC。难度集中在Medium偏下到Medium之间。题目类型上数组/字符串操作、链表反转/合并、二叉树的遍历与深度、DFS/BFS搜索、动态规划基础题出现频率最高。这里我重点提醒一点笔试的算法题时间复杂度是最重要的评分标准。C/C的运行速度比脚本语言快得多但服务器判题机对时间限制依然很严格。热词里“时间限制: c/c 1000ms其他语言 2000ms”就是个典型配置。C/C只给1秒时限意味着你的算法必须接近最优复杂度O(n^2)的算法如果n到了10^51秒内基本跑不完。举个例子求最长不重复子串。朴素解法两重循环O(n^2)n10^5时代入n^210^10即使每秒执行10^9次操作也要10秒直接超时。用滑动窗口哈希表O(n)稳过。所以笔试答题前一定要先估算数据范围不要看见字符串题就双循环那是埋自己。顺带说一嘴代码风格。笔试代码一般要手写完整个输入输出不习惯的同学会在这里浪费大量时间。建议提前把常用的IO模式背下来比如C风格的#include iostream #include string #include vector using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin T; while (T--) { string s; cin s; // 处理逻辑... } return 0; }关了流同步之后cin/cout的效率能接近scanf/printf的水平笔试时能省下不少时间。另外如果题目对性能和内存很敏感也可以用纯C风格的输入输出更稳妥。3.4 设计/开放题用工程经验拿下附加分除了传统选择题和算法题畅游这类游戏公司的笔试偶尔会加一两道设计类或开放类题目比如“如何设计一个伤害计算系统”“多人在线游戏中的背包系统怎么设计”。这部分没有标准答案但可以通过答案看出候选人有没有工程思维。如果遇到这类题我的建议是抓住三个关键词数据驱动、组件化、可扩展。比如要设计一个技能系统不要一上来就说“定义一个大Skill类里面放伤害、冷却、特效、音效一堆字段”——那是最糟糕的答案。试着从数据驱动的角度回答技能配置表存放所有技能的数值和表现参数代码里用基类Skill 不同行为组件组成新技能只需新增配置和少量组件不需要改动核心逻辑。这种答题思路不是临时抱佛脚能整出来的靠的是平时写项目时多琢磨“如果产品需求变了我这份代码要改哪里”。平时写课程设计或Demo时养成这个思考习惯笔试的开放题就能从容不少。4. 从准备笔试到搭建环境本地动手实践才是硬道理知识点看得再多不动手都是纸上谈兵。热词里频繁出现的vscode配置C/C环境、MinGW-w64安装这些问题看似基础实际上把很多初次参加笔试的同学拦在了“想写代码但环境起不来”这一步。笔试前把本地环境彻底打通绝对值得拿出半天时间专门搞定。4.1 本地环境搭建从MinGW-w64到VS CodeWindows下最通用的C/C开发环境组合就是VS Code MinGW-w64 GCC/G编译器 GDB调试器。VS Code本身只是个编辑器所有编译和调试能力都依赖后边这些工具链这一点务必理解。MinGW-w64的安装有两条路。一是离线安装包需要去SourceForge上找二是在MSYS2里通过pacman安装。我推荐MSYS2这条路因为后续安装其他库CMake、Ninja、SDL等也方便。装完后重点是把MSYS2安装路径\mingw64\bin加入系统PATH环境变量否则VS Code怎么都找不到编译器。验证是否装好就在终端敲gcc --version g --version gdb --version三个都能输出版本号说明工具链没问题。然后是VS Code这边装两个必备插件C/CMicrosoft官方出品提供IntelliSense和调试支持、Code Runner方便快速编译运行单文件。项目根目录下需要建.vscode/tasks.json编译任务和.vscode/launch.json调试配置。核心配置我就直接贴出来{ version: 2.0.0, tasks: [ { label: C Build, type: cppbuild, command: g, args: [ -g, -stdc17, -Wall, -Wextra, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe ], group: build, problemMatcher: [$gcc] } ] }-stdc17指定C标准如果笔试要求C11就把数字改成11。-Wall -Wextra打开所有常见警告这对帮你发现笔试题里埋的坑特别有效。写代码时看到Warning就顺手修复时间长了写出来的代码别人挑不出刺。调试配置launch.json也要准备好笔试现场如果允许本地调试有的在线编程环境不行能单步看变量、看内存很多逻辑错误当场就能抓出来。4.2 调试工具到底有多重要GDB和Visual Studio的选择刚才提到的GDB是命令行调试器通过VS Code的图形界面来用对新手友好。但我想额外说一句如果你准备长期走游戏开发Visual StudioWindows下还是值得装的尤其是它的“内存窗口”和“反汇编视图”。看C对象在内存里的真实布局比在脑子里空想vptr、内存对齐要直观得多。怎么用很简单写一段有多态继承的代码在调试器里给对象加监视展开对象的成员你会清清楚楚看到那个隐藏的vptr指针再按照vptr去虚表里看函数地址。这一步看完多态的抽象理解瞬间落地。游戏公司里用Visual Studio的团队占绝对多数调试器用得顺不顺直接影响开发效率。Unity的C#脚本栈和C原生插件的崩溃转储最后都是扔到Visual Studio里分析。笔试阶段先把VS Code练熟入职后自然会切换过去。4.3 笔试中的代码验证技巧很多同学笔试时有个致命习惯代码写完了不输入任何测试用例就提交。这等于把到手的分数拱手让人。我建议至少留10分钟来跑自测用例。本地跑代码时怎么设计测试用例三个维度正常输入确保主流程正确、边界输入空数组、n1、最大值、极端输入数据量大的情况用来验证会不会超时。比如一道数组题如果你用C写的可以顺手在main里构造一个100000个元素的用例用chrono库计时跑了超过1秒就要考虑优化#include chrono auto start std::chrono::high_resolution_clock::now(); // 调用要测试的函数 auto end std::chrono::high_resolution_clock::now(); auto duration std::chrono::duration_caststd::chrono::milliseconds(end - start); cout 耗时: duration.count() ms endl;这比什么“我猜应该能过”可靠得多。4.4 笔试前两周的冲刺准备清单最后整理一份冲刺清单这是我带过不少学弟学妹后总结出来的版本两周内按这个顺序过一遍笔试当天的状态会完全不一样第一周基础巩固把C Primer的“类、继承、虚函数、模板”四章再翻一遍LeetCode按标签刷题重点刷数组、字符串、链表、二叉树、DFS/BFS一共刷30道左右每题控制在30分钟内。第二周模拟演练找3~4套不同游戏公司往年的C/C笔试题严格按考试时间90分钟模拟重点训练“先易后难跳过卡壳题”的节奏整理错题本把反复出现的考点内存对齐、虚函数、迭代器失效一一记录。考前1~2天不再刷新题只看错题本和速查表检查本地环境和在线IDE账号都能正常登录确认考场允许使用本地编译器还是必须在网页上写代码。如果时间更紧至少把STL里vector、map、unordered_map的底层实现原理和复杂度背清楚它们是游戏开发中使用频率最高的容器。5. 进阶视角校招笔试题背后游戏开发真实场景的映射笔试不只是敲门砖很多题目本质上就是游戏开发日常的缩影。能看透这层你答题的深度会很不一样。5.1 ECS架构与虚函数的博弈前面提到虚函数有性能成本。那游戏行业到底怎么处理“多态”和“性能”的矛盾现代游戏引擎越来越推崇ECSEntity-Component-System架构核心思想是“组合优于继承”把数据Component和行为System分离而不是用深重的类继承树表达所有实体。在ECS里玩家、怪物、子弹都是一个Entity本质上是一个ID它们的数据由各种Component组合而成逻辑则放在System里统一处理。这种架构下虚函数的用武之地大大减少取而代之的是函数指针、std::function、或者直接模板化。为什么要这么做因为虚函数的间接跳转破坏了CPU分支预测而且对象内存不连续cache locality差在成千上万个实体上做遍历更新时性能差距非常明显。笔试里可能不会直接问“什么是ECS”但如果你在开放题里能提到“避免大量虚函数调用可以采用数据驱动设计”面试官会立刻意识到你平时是关注引擎架构的。这比堆砌名词有用得多。5.2 对象池为什么游戏引擎总是自己管内存笔试里如果你能答出“频繁new/delete会产生内存碎片”那只是及格。更进一步游戏引擎通常会为特定类型维护对象池Object Pool预先分配一大块内存切成固定大小的槽位对象创建时从池里取销毁时还给池子。子弹、粒子、飘字这些高频创建销毁的对象都是对象池的典型应用。这样做的核心原因是避免频繁向操作系统申请和释放内存。系统调用是有代价的而且大量小内存块反复分配释放堆碎片化会越来越严重最终可能导致“明明内存够用但malloc找不到足够大的连续块”。笔试题偶尔会让你实现一个简单的对象池或者从内存碎片角度分析某段代码的隐患。答这类题时除了说“使用对象池”最好能画一两笔不会有洁癖的面试官不喜欢画图的说明块分配策略、空闲链表、内存对齐对齐处理。这就把单纯的概念题变成了加分题。5.3 笔试答题的工程素养从变量命名到代码注释最后聊一个所有笔试题的通用加分项代码风格。同一道题目两个人答案的分数可能完全一样但代码一眼看上去的观感差异巨大。我的建议是变量命名必须语义清晰cnt、cur、nxt这种缩写可以但a、b、c这种就别用了。核心算法块加上两三行注释说清楚思路。笔试阅卷不一定是真人逐行看的但算法题的关键步骤有注释很大概率会被判卷人识别为“高分段答案”。不要写大函数一段逻辑抽成一个子函数每行不要超过100个字符。避免使用奇技淫巧。比如三目运算符嵌套或者在for循环里写连续赋值这些写法在工程里都是可读性毒药笔试时更没必要炫技。我自己参加笔试时就有个切身体会同样是AC的代码有人只用35分钟写完并且注释清晰有人卡在最后5分钟提交了一个没注释的版本面试官拿到的参考完全不同。笔试不只是跟判题机对话更是跟后来审你卷子的人对话。写在最后很多同学觉得校招笔试就是靠刷题刷得越多分越高。我的体会是刷题是必要条件但不是充分条件。真正能让分数产生质变的是你对“为什么这么考”的理解——为什么游戏公司要考虚函数、内存对齐、迭代器失效因为这些都是你入职后每天都会遇到并影响线上产品质量的问题。回到搜狐畅游这套2019年的题它让我印象最深的地方在于没有一道题是纯粹为了难为人。每一道题背后都能找到游戏引擎或游戏逻辑里真实的映射。所以准备这类笔试的时候不要只盯着“答案”多问一句“这题在游戏里对应什么场景”你的复习效率和答题深度都会完全不一样。如果你正准备即将到来的校招这套思路可以立刻用起来。先把环境搭好然后把基础考点过一遍最后用几套往年题做模拟。考场上遇到不会的题也别慌把能想到的步骤写出来能拿一分是一分——说到底笔试只是第一步更重要的是让它成为你技术积累的一个起点。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进