2023年奇安信春招C方向的那套试卷在朋友圈里被讨论过好几轮。作为当时也在筹备C岗位面试的人我特意把这套卷子里里外外拆了一遍今天把一些核心考点和做题思路整理出来。这套卷子表面上看是常规的C八股加算法题但仔细研究会发现它其实在考察一个安全厂商C开发者的底层基本功内存管理是否扎实、并发思维是否严密、对新特性的掌握是否跟得上工程节奏。无论你是准备投奇安信还是想检验自己的C水平都值得对着这套题做一次完整复盘。1. 从试卷定位看奇安信要什么样的C开发1.1 安全厂商笔试和互联网大厂的区别先聊一个很多人忽略的问题安全公司的C岗位和普通业务开发岗笔试侧重点是完全不同的。互联网大厂喜欢考高并发架构、海量数据处理因为他们的系统面对的是海量用户访问而安全厂商的C代码通常跑在终端、网关、检测引擎这类环境里更看重你写的代码在资源受限的情况下是否稳定、是否高效、是否存在内存破损或逻辑漏洞。这套卷子从头到尾都在贯彻这个思路。它不是简单地问你“constexpr是哪个C版本引入的”这种背诵题而是会把这个知识点放到具体场景里让你判断一段代码能不能在编译期求值、如果不行应该怎么改。这背后考的是你对C语言演进方向的理解现代C越来越强调编译期计算、类型安全、资源管理而不是靠程序员小心翼翼地去查边界。1.2 2023春招C试卷的题型分布与时间分配我根据记忆和当时收集到的信息把试卷的题型分布整理成了下面这张表。具体细节可能和原卷略有出入但整体结构足以说明问题。模块题型题量建议用时考察重点C基础语法选择改错15题左右25分钟初始化、类型转换、重载、移动语义内存与资源管理选择编程10题左右30分钟智能指针、内存布局、new/delete并发与多线程选择问答8题左右25分钟线程安全、ABA问题、锁、原子操作算法与数据结构编程3题左右50分钟排序、快速幂、单调栈、模拟设计模式与工程问答代码5题左右20分钟回调函数、观察者模式、构建配置整套卷子时间大约两个半小时题量不算小。我见过太多人死在时间分配上前面选择题纠结太久后面算法题只能草草写个开头。所以我的建议是——遇到拿不准的选择题先凭第一感觉标记好把时间留给分值更高的算法题。毕竟算法题一道可能就是15到20分顶得上五道选择题。2. 基础语法与内存管理从字符串初始化到constexpr的演进2.1 字符串数组初始化的三个坑你踩过几个这套试卷在基础题部分就给人一个下马威。有一道题直接让你判断下面几种字符串初始化方式的对错const char* str1 hello; char str2[] hello; char* str3 (char*)hello; // C风格强转 char str4[] {h, e, l, l, o}; // 没有结尾\0很多人一看就觉得都差不多但这四行每一行都有文章。第一行是合法的字符串字面量存在只读区const char* 指向它没问题。第二行也是合法的它会在栈上复制一个字符数组并自动补上结尾的\0sizeof(str2) 是6。第三行在C里已经不行了从C11开始字符串字面量类型是 const char[N]强转成 char* 属于未定义行为虽然很多老编译器只是警告但严格按标准来说这是个错误。第四行是最隐蔽的坑数组长度是5但只初始化了5个字符没有容纳结尾空字符的空间这个字符串根本没有正确终止。如果你拿它去调 strlen 或 std::cout大概率会读越界行为不可预测。这种题目考察的不是你会不会背“字符串要以\0结尾”这句话而是你会不会在写代码的时候意识到边界。安全厂商最怕的就是这种不可预测的越界行为它们往往是漏洞的源头。我自己的习惯是新代码一律用 std::string完全避开这个问题只有在做协议解析、自定义内存池这类高性能模块时才手动处理而且必须用显式的长度限制函数。2.2 constexpr的版本演进背后藏着现代C的设计哲学热搜词里的“constexpr哪个C版本引入的”正是这份试卷里的一道典型选择题。constexpr 从 C11 首次引入C14 放宽了函数内可以出现多条 return、允许局部变量声明C17 支持了 if constexprC20 之后甚至可以定义 constexpr 的虚函数、在编译期使用 vector 和 string。试卷不会只考一个版本号它会把版本演进的点作为区分度。比如它可能会给你一段代码constexpr int square(int x) { int sum 0; for (int i 0; i x; i) { sum x; } return sum; }让你判断这段代码在哪个C标准下可以编译通过。C11 的 constexpr 函数限制很死函数体只能由一条 return 语句组成带循环直接报错到 C14 才允许这种写法。如果你只记得“constexpr 是 C11 引入的”而不知道后续版本的能力变化这道题就答不上来。这里我多说一句。很多人觉得背版本号很无聊但工程上这个知识非常实用。比如你在写一个跨平台项目编译标准必须兼容 C14那你就不能使用 if constexpr只能用模板特化或 SFINAE 绕过。这套卷子考版本演进其实是在考察你有没有实际做过需要兼容不同标准的项目。2.3 new/delete、malloc/free混用的致命细节内存管理几乎是必考项。试卷里有一道题问为什么 C 里不推荐把 malloc/free 和 new/delete 混用标准答案是“因为 new 会调用构造函数delete 会调用析构函数而 malloc/free 只申请和释放内存”。但真正的坑在于如果你对一个 malloc 出来的内存执行 delete就意味着对一块没有构造对象的内存调用了析构函数这是未定义行为反过来如果你对 new 出来的对象用 free那对象内部的资源没法被析构函数释放直接造成资源泄漏。我在实际开发中还真遇到过这种问题。当时维护一个老模块有人图省事用 realloc 扩容一个结构体数组结果结构体里有 std::string 成员。浅拷贝了新数据之后旧内存被 free成员指针直接悬空程序在随机时刻崩溃。排查了一整天才定位到问题用 C 的方式管理 C 对象就是灾难。所以我的朴素原则是在 C 代码里非极端场景不用裸指针管理生命周期优先 unique_ptr、shared_ptr如果必须和 C 库打交道就用 malloc/free 处理纯 POD 数据并且做好注释禁止对非 POD 类型使用。3. 多线程与并发考点ABA问题、线程安全与锁的取舍3.1 ABA问题一个比死锁更容易被忽略的并发陷阱这套试卷里明确出现了“ABA问题c”相关的知识点。ABA问题最早在无锁数据结构中被重视起来核心场景是这样的线程 P 读到了共享变量的值 A然后被调度挂起另一个线程 Q 将值从 A 改成 B再改回 A。等线程 P 恢复执行后它用 CAS 比较发现值还是 A以为没人动过于是执行交换——但事实上数据已经被改过两轮了基于“值没变”的假设做的操作很可能是错的。试卷里的问法往往不给完整代码而是让你结合无锁栈或无锁队列描述 ABA 问题如何发生以及怎么解决。最常见的解决手段是给每个数据加上版本号或标签CAS 的对象从“值”变成“值和版本号”的复合结构。每次修改值的同时递增版本号即使值变回原来的样子版本号也已经变了CAS 就能识别出中间发生了变更。struct Node { int value; uint64_t version; }; std::atomicNode atomicNode;在 x86 平台上如果 Node 的大小不超过 16 字节可以用双字 CAS 指令来原子更新。如果结构更大就得用指针计数器的组合这也是哈特博士经典的无锁队列中常用的做法。3.2 这道卷子里出现的多线程题考的是工程思维有一道题让我印象很深给出一段使用 std::thread 去更新共享 map 的代码要求指出线程安全问题并改进。代码里大概是这样std::mapint, int data; std::vectorint keys {1, 2, 3}; std::thread t1([]() { data[keys[0]] 10; }); std::thread t2([]() { data[keys[1]] 20; }); t1.join(); t2.join();这里有两个线程同时写同一个 map即使写的是不同 key也不行。std::map 的内部结构是红黑树插入元素可能引发节点左旋右旋多个线程同时修改树结构就会破坏结点的父子关系导致崩溃或死循环。必须加锁或者使用并发安全的容器比如 concurrent_map第三方库或 std::mutex 保护。很多人会陷入“只要操作不同 key 就安全”的误区。我在实际项目里见过类似情况两个线程一个只做读一个只做写其中读线程一直正常运行写线程偶尔被调用看起来没问题。后来有一天写线程频率陡增读线程开始读到半更新状态的数据程序直接卡死。这就是没有加锁引起的脏读。这道题的正解是引入 std::shared_mutex让多线程并发读、写线程独占。这样做既保护了数据又把并发性能损失降到最低。试卷的判分点大概率就在这你是不是只会用 std::mutex还是能说出 shared_mutex 这种更精细的同步工具。3.3 原子操作、内存序和锁之间怎么选并发部分还有一道关于 std::atomic 的题问 atomic 的 fetch_add 和 mutex 保护下的 i 有什么区别。最直观的回答是fetch_add 是无锁的编译器会映射到 CPU 的原子指令比如 x86 的 lock xadd而 mutex 保护下的 i 是一次加锁、修改、解锁的完整流程开销要重得多且可能发生线程切换。更深一层试卷考的是内存序的理解。std::atomic 支持 memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。默认是 seq_cst提供最强的顺序保证但性能也有损耗。如果你熟悉 lock-free 编程会知道在某些场景下用 acquire/release 就能保证同一线程的修改对其他线程可见。需要注意的一点原子操作并不等于线程安全。如果两个线程都在对同一个原子变量做 fetch_add那么累加是原子的但如果你的业务逻辑是“先读旧值再根据旧值计算新状态最后写回”这整个过程就不是原子的必须用 CAS 循环或锁。这一点在无锁队列实现中尤其容易出错。4. 算法题实战排序、快速幂、单调栈与校招的底层逻辑4.1 冒泡排序不只是“两两交换”它还有优化空间搜索热词里“冒泡排序算法c”排得很靠前确实是校招笔试的常客。这套卷子并没有直接让你背冒泡排序代码而是给出了一个排了一半的数组让你补全内层循环。很多人觉得这种题简单但越简单越容易马虎。冒泡排序有两个常见的优化点。一个是设置标志位如果某一轮扫描中没有发生任何交换说明序列已经有序可以提前退出template typename T void bubbleSort(std::vectorT arr) { for (size_t i 0; i arr.size() - 1; i) { bool swapped false; for (size_t j 0; j arr.size() - i - 1; j) { if (arr[j] arr[j 1]) { std::swap(arr[j], arr[j 1]); swapped true; } } if (!swapped) { break; } } }另一个优化是记录最后一次交换的位置这样后面的部分说明已经有序下轮扫描不用再走到数组末尾。试卷里这种题考的不是你能不能写出 O(n^2) 的排序而是你会不会在熟悉的代码里增加细节、提高效率。面试官看的是代码习惯不是看能不能跑通。4.2 快速幂算法从“求x的n次方”到“模幂运算”算法题里有一道快速幂原题大概是求 x 的 n 次方并对某个质数取模。这几乎是和“斐波那契数列”同级的高频题。快速幂的核心思想是把指数拆成二进制利用分治减少乘法次数。比如 x^13 x^8 * x^4 * x^1其中 13 的二进制是 1101。long long fastPow(long long base, long long exp, long long mod) { long long result 1; base % mod; while (exp 0) { if (exp 1) { result (result * base) % mod; } base (base * base) % mod; exp 1; } return result; }这里有两个容易写错的地方。第一base 要先对 mod 取模防止前面几次乘法就溢出。第二result 和 base 的乘法要用 long long 或更大的类型承接否则中间结果可能超出 int 范围。如果题目要求的 mod 是 10^97两个数相乘可能接近 10^18必须用 long long如果 modulus 更大还可能需要“快速乘”或 __int128这也是进阶的考法。我记得这道题的变体里加入了矩阵快速幂。让你用矩阵快速幂求斐波那契数列的第 n 项。这类题其实不存在什么新意核心就是先定义矩阵乘法再用同样的二进制拆解思路。写矩阵乘法时要注意为每一层循环单独开一份临时结果避免污染当前矩阵。4.3 单调栈解决“下一个更大元素”的经典框架如果你刷过 LeetCode对“单调栈”一定不陌生。《物流网络》那道题我在别的文章里详细讲过这里单说单调栈本身。这套卷子里的题目场景是给你一个数组要你求出每个元素右侧第一个比它大的元素的下标。暴力解法是两重循环 O(n^2)单调栈能做到 O(n)。单调栈的核心是维护一个栈让栈内元素保持单调递减或递增。遍历数组时每来一个新元素就循环把栈内比当前元素小的元素弹掉并记录答案。这样每个元素最多进栈一次、出栈一次总复杂度 O(n)。std::vectorint nextGreaterElement(const std::vectorint nums) { int n nums.size(); std::vectorint res(n, -1); std::stackint stk; for (int i 0; i n; i) { while (!stk.empty() nums[stk.top()] nums[i]) { res[stk.top()] i; stk.pop(); } stk.push(i); } return res; }单调栈的难点从来不是模板本身而是能否识别出题目可以转化为单调栈。这套卷子里的题把场景包装成了“每日温度”的变体本质没变。我建议把所有单调栈题型的套路背熟找右边第一个更大元素用单调递增栈存下标找右边第一个更小元素用单调递减栈存下标。每次循环先清栈再压栈思路非常固定。4.4 排序、搜索、模拟题之外还要注意复杂度推导这套卷子里的算法题并不难但最后一道“求 n 个整数的最小公倍数”反而刷掉了一批人。单求两个数的 LCM 很简单a * b / gcd(a, b)。但 n 个数的 LCM 需要小心溢出。正确的做法是边遍历边约分每次用当前结果和下一个数求 gcd然后乘除这样才能保证中间结果尽量小。我见过很多人一上来就把所有数乘起来再除 gcd结果中间乘积直接溢出。正确的写法是long long lcm 1; for (int x : nums) { lcm lcm / std::gcd(lcm, x) * x; }先除后乘这是基本技巧却很容易被忽略。这套卷子考的不只是算法模板还有对数值范围、溢出风险的敏感度。安全方向的开发对数据边界、溢出问题的敏感度是基本素养。5. 设计模式与工程化能力回调、观察者与C项目构建5.1 回调函数在C里的几种实现方式奇安信这套卷子考了回调函数的例子。回调函数本质上是一种解耦机制你定义一个函数并把函数指针或可调用对象传给另一个模块让它在特定时机调用你定义的函数。C 里实现回调的方式至少有五种方式场景注意点C 风格函数指针兼容 C 库接口只能捕获自由函数或静态函数无法捕获 this函数对象仿函数需要保存状态通过重载 operator()灵活但代码量大std::function万能可调用对象封装开销较大注意生命周期Lambda 表达式现代C首选捕获列表要小心悬空引用模板回调性能要求高的场景编译期多态增加代码体积写异步网络库时我习惯用 std::function 加回调参数。比如一个异步读取操作用户传入一个 lambda 作为完成回调。但这里有个大坑如果异步任务比回调对象的生命周期还长而 lambda 捕获了对象成员引用就会出现悬空引用。正确的做法是捕获 shared_ptr 的副本或者保证回调生命周期被统一管理。试卷里那道回调题还顺带问了一句“std::function 和函数指针有什么区别”。答案在于 std::function 是一个类模板它可以封装任意可调用对象代价是通常会有一次或多次隐式分配。如果你在一个高频回调路径上建议使用模板或函数对象来避免 std::function 的内存分配开销。5.2 观察者模式从发布订阅到信号槽作为信号槽机制的前身观察者模式在客户端开发中特别常见。试卷里要求你设计一个简单的事件系统某个事件发生时多个监听者都能收到通知。最直观的实现是维护一个 list of std::function 回调每次触发事件就遍历调用。class EventBus { public: using Handler std::functionvoid(const Event); void subscribe(const Handler handler) { handlers_.push_back(handler); } void publish(const Event event) { for (const auto handler : handlers_) { handler(event); } } private: std::vectorHandler handlers_; };但是如果我在发布事件的同时订阅新回调或者某个回调内部把自己从列表中移除就会导致迭代器失效。这种细节在笔试现场很难被察觉然而它就是工程里最常见的 bug。所以我会选择先拷贝一份 handler 列表再遍历或者在回调中使用索引延迟更新。我在实际项目里写了太多观察者模式核心教训是不要在回调中直接操作订阅列表。如果必须支持就加入一个 pending 队列在事件循环之外更新。5.3 Visual C Redistributable 和构建环境笔试外的隐形考点热搜词里有不少是关于 visual c redistributable 的这套试卷虽然没有直接考它但在简历和面试里很可能会被问到“你如何在 Windows 上部署一个依赖了 MSVC 运行时的程序”。这涉及 C 工程化的基础知识64位版本的 Visual C Redistributable 只解决运行时 DLL 缺失问题比如 VCRUNTIME140.dll、MSVCP140.dll。如果你静态链接了运行时就不需要装 Redistributable如果你使用了 /MD 动态链接目标机器上必须装有对应版本的运行库。这套卷子最后一题问的是项目构建工具的选择并让你对比 vscode 配置 C/C 环境时用 tasks.json 和 CMake 的区别。很多人只会用 IDE 一键构建说不出底层原理。我一般推荐在 VSCode 里使用 CMake Tools 插件因为 CMake 能把编译器、链接器、依赖库、安装规则全都声明清楚跨平台也能用。而 tasks.json 相当于写死命令脚本一旦编译器路径变化就得改。笔试里写出“我会使用 CMake 管理项目用 CMakeLists.txt 指定 C 标准、源文件、目标、依赖”会显得比较专业。另外“c怎么只能加代码的情况下减少运行时间”这个热搜词很有意思。回答的核心其实无非是把函数参数用 const 引用传递、避免不必要的拷贝、开启编译器优化 O2、使用 noexcept 帮助优化器。笔试如果想要在算法题上跑出更快的程序应该先关注大 O 复杂度其次才是常数优化。顺序不能反过来。5.4 从C八股到真正能落地的心法这套试卷里还有一部分是“C八股文”比如移动语义、完美转发、RAII、虚函数表、RTTI 等。这些题目本身不复杂但想要答好却不容易。就拿移动语义来说很多人能背出“std::move 是一个 cast”但遇到下面这个写法就开始困惑class Foo { public: Foo(std::vectorint data) : data_(std::move(data)) {} private: std::vectorint data_; };这里按值接收参数再移动和直接用一个 const ref 接收参数再拷贝性能差异很大。按值接收允许调用者传左值时拷贝一次、传右值时零拷贝直接移动是一种常见的现代C写法。但这种写法对空实参也会分配内存吗不会移动构造只是转移指针所有权没有为底层数据重新分配空间。类似的点太多了但我觉得最重要的不是背概念而是理解 C 设计的底层动机。C 之所以在安全领域仍是主力语言是因为它可以精细控制内存布局、直接操作硬件、接近零成本抽象、并通过模板在编译期完成大量逻辑。这套试卷正是从这些角度去筛选人的。如果你只是背题也许能过笔试但面试官稍一追问就会露馅。建议每一步都问自己“为什么这么设计”“换成另一种方式会有什么代价”这样的复习方式才撑得起一个真正可用的 C 技能树。最后再分享一个小技巧这套卷子的算法题做完之后一定要回过头来检查边界。n 等于 0 怎么办数组为空怎么办输入达到上限会不会溢出我当年就因为少考虑了一个 n0 的场景丢掉了整套题里最不该丢的分。安全方向尤其看重边界所谓漏洞几乎都是边界条件没处理好的产物。这一点无论你考不考奇安信都值得记在心里。