面灵AI→

华为嵌入式二面凉经:C 基础能过,并发与协议栈实现全卡住

轮次
一二面
结果
二面挂
时间
2026-10
来源
牛客网

《面试题目》

一面(约 55 分钟)

  1. 讲一下简历里那个 Linux 项目,数据是怎么流的?
  2. 指针和数组有什么区别?函数传参传数组的时候到底传了什么?
  3. 二级指针什么时候用?举个实际场景。
  4. 内存泄漏是怎么产生的?你平时怎么发现?
  5. malloc 申请的内存 free 之后能不能继续访问?会发生什么?
  6. 结构体内存对齐怎么算?给你一个结构体算大小,再问怎么优化排布省内存。
  7. strcpy 和 memcpy 有什么区别?strcpy 有什么隐患?
  8. 手撕:反转链表。
  9. 手撕:判断字符串是不是回文,要求不申请额外空间。
  10. TCP 和 UDP 的区别是什么?分别用在什么场景?
  11. 三次握手为什么是三次?两次行不行?
  12. TCP 怎么保证可靠传输?
  13. 说说你知道的进程间通信方式,各自优缺点。

二面(约 70 分钟)

  1. 多线程里为什么需要锁?不加锁具体会出什么问题?从 CPU 层面说。
  2. 互斥锁在内核里怎么实现的?加锁失败的线程去哪了?
  3. 死锁的四个必要条件是什么?你项目里怎么避免的?怎么检测?
  4. 读写锁和互斥锁有什么区别?什么场景下读写锁反而更慢?
  5. 原子操作是怎么实现的?和加锁比开销差在哪?
  6. volatile 能不能保证线程安全?为什么?
  7. 内存屏障是干嘛的?什么时候需要?
  8. TCP 的滑动窗口和拥塞控制具体怎么配合工作?
  9. 拥塞控制那几个阶段分别在干什么?为什么要慢启动?
  10. TIME_WAIT 状态为什么要存在?为什么是 2MSL?
  11. 服务器上出现大量 TIME_WAIT 是什么原因?怎么处理?
  12. 一个进程内存一直涨,你怎么定位是哪儿泄漏了?说完整流程。
  13. select、poll、epoll 的区别是什么?epoll 为什么高效?底层数据结构是什么?
  14. 讲一个你自己遇到过的最难的 bug,怎么定位的?

《参考解析》

不加锁会出问题,根因在 CPU 的乱序与可见性。一条 i++ 编译成 load、add、store 三步,两个线程可能都读到旧值、各自加一、再写回,丢一次更新。更深一层是每个核有自己的 store buffer 和缓存,写入未必立刻对其他核可见;同时编译器和 CPU 都可能重排指令。锁之所以能解决,是因为它同时提供了互斥(同一时刻只有一个持有者)和内存语义(acquire/release 屏障,进入临界区前的写对退出者可见)。

互斥锁在内核里的实现:futex(fast userspace mutex)是主流做法——无竞争时只在用户态做一次 CAS(0 → 1)就拿到了锁,完全不进内核;有竞争时才通过 futex(FUTEX_WAIT) 系统调用把线程挂到内核的等待队列上,状态置为不可中断睡眠或可中断睡眠。加锁失败的线程不是自旋,而是被移出运行队列,等释放方 FUTEX_WAKE 唤醒后重新参与调度。自旋锁则不同,它原地循环检查,适合临界区极短、且不能睡眠的场景(内核中断上下文)。

死锁四个必要条件:互斥、持有并等待、不可剥夺、循环等待。破坏任一条即可避免——工程上最实用的是破坏循环等待:给所有锁定义全局顺序,永远按同一顺序加锁。检测则是把「线程等待锁」建成有向图,跑环检测(DFS 或拓扑排序),这也是「设计死锁监控」这题的答案。

读写锁在写多或临界区极短时反而更慢:它的读者计数本身需要一个原子操作或内部锁来维护,临界区如果只有几行代码,维护计数的开销就超过了并行带来的收益;写者饥饿也是常见问题。判断标准是读操作的耗时是否远大于加锁开销、以及读写比例。

原子操作的实现分层:单核上关中断即可;多核靠 CPU 提供的原子指令(x86 的 LOCK CMPXCHG、ARM 的 LDREX/STREX 或 LSE 的 CAS),LOCK 前缀会锁住缓存行并触发缓存一致性协议。和加锁比,原子操作没有上下文切换、没有系统调用,开销小一到两个数量级;但它只能保护单个变量的单次操作,保护不了多变量之间的一致性。

volatile 不保证线程安全,它只保证可见性(每次都去内存读写,不被优化进寄存器)和一定程度的顺序性(编译器不重排 volatile 之间的访问),但不保证原子性,也不阻止 CPU 层面的重排。Java 的 volatile 语义更强(有内存屏障),C/C++ 的 volatile 基本只用于硬件寄存器和信号处理。

内存屏障解决的是重排问题:编译器和 CPU 为了性能会重排指令,单线程下结果不变,多线程下就可能让「先写数据再置标志」变成「先置标志再写数据」,读者看到标志为真但数据还没写。屏障分编译器屏障(asm volatile("" ::: "memory"))和 CPU 屏障(x86 的 mfence/lfence/sfence,ARM 的 dmb/isb)。实践上优先用语言提供的 acquire/release 语义(C11 的 atomic_thread_fence、Rust 的 Ordering),而不是手写屏障。

滑动窗口与拥塞控制的配合:滑动窗口是接收方通告的「我能收多少」(rwnd),拥塞控制是发送方估算的「网络能承受多少」(cwnd),实际发送窗口取两者较小值。所以「窗口」这个词在两个语境下含义不同,回答时要点明这一点。

拥塞控制四阶段:慢启动(cwnd 从 1 个 MSS 起指数增长到 ssthresh)、拥塞避免(超过阈值后线性增长,每个 RTT 加一个 MSS)、快重传(收到三个重复 ACK 立即重传丢失的段,不等超时)、快恢复(把 ssthresh 减半、cwnd 设为新阈值后直接进入拥塞避免,而不是回到慢启动)。慢启动之所以叫「慢」是因为起点小,但它的增长是指数的——目的是快速探到可用带宽,同时避免一上来就压垮网络。

TIME_WAIT 存在两个理由:一是保证最后一个 ACK 能到达对端(如果 ACK 丢了,对端会重发 FIN,此时还能回 ACK);二是让本次连接的老报文在网络中彻底消亡,避免它们被后续同四元组的新连接误收。2MSL 就是一个来回的最大生存时间,等这么久能保证双向的残留报文都过期。

大量 TIME_WAIT 出现在主动关闭方,常见于短连接密集的服务器或压测客户端。处理手段:开启 tcp_tw_reuse(仅对出向连接生效,且需要时间戳支持)、扩大 ip_local_port_range、改用长连接或连接池。tcp_tw_recycle 在有 NAT 的环境下会造成丢包,已被内核移除,不要再提。

内存泄漏的定位流程:先确认是真泄漏还是缓存增长(看是否有上限、是否随负载回落);然后按进程维度看 RSS 与堆统计(/proc/<pid>/status、pmap);对 C/C++ 用 valgrind、AddressSanitizer 的 LeakSanitizer,或自己 hook malloc/free 记录调用栈;对长跑服务用定期抓堆快照对比增量(gperftools、jemalloc 的 profiling、或语言自带的 heap dump)。关键是能拿到「哪段代码分配了没释放」的调用栈,光看总量涨没有用。

select/poll/epoll 的演进:select 用位图、有 1024 的 fd 上限、每次调用都要把整个集合从用户态拷到内核态、返回后还要线性扫描;poll 用数组去掉了数量上限,但拷贝和扫描的问题仍在;epoll 用内核里的红黑树存关注的 fd、用就绪链表存已触发的事件,epoll_wait 只返回就绪的那几个,避免了 O(n) 扫描。它还区分水平触发(LT,只要还有数据就一直通知)和边沿触发(ET,只在状态变化时通知一次,必须一次读到 EAGAIN)。epoll 高效的本质是「内核主动告知就绪」,而不是靠用户态轮询。

最难 bug 这题要挑一个有完整排查链的:现象 → 缩小范围的手段(二分、加日志、对比环境)→ 定位到的根因 → 修复和验证。避免讲成「查了很久最后发现是个拼写错误」,那体现不出方法。