华为嵌入式二面凉经:C 基础能过,并发与协议栈实现全卡住
- 轮次
- 一二面
- 结果
- 二面挂
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
一面(约 55 分钟)
- 讲一下简历里那个 Linux 项目,数据是怎么流的?
- 指针和数组有什么区别?函数传参传数组的时候到底传了什么?
- 二级指针什么时候用?举个实际场景。
- 内存泄漏是怎么产生的?你平时怎么发现?
- malloc 申请的内存 free 之后能不能继续访问?会发生什么?
- 结构体内存对齐怎么算?给你一个结构体算大小,再问怎么优化排布省内存。
- strcpy 和 memcpy 有什么区别?strcpy 有什么隐患?
- 手撕:反转链表。
- 手撕:判断字符串是不是回文,要求不申请额外空间。
- TCP 和 UDP 的区别是什么?分别用在什么场景?
- 三次握手为什么是三次?两次行不行?
- TCP 怎么保证可靠传输?
- 说说你知道的进程间通信方式,各自优缺点。
二面(约 70 分钟)
- 多线程里为什么需要锁?不加锁具体会出什么问题?从 CPU 层面说。
- 互斥锁在内核里怎么实现的?加锁失败的线程去哪了?
- 死锁的四个必要条件是什么?你项目里怎么避免的?怎么检测?
- 读写锁和互斥锁有什么区别?什么场景下读写锁反而更慢?
- 原子操作是怎么实现的?和加锁比开销差在哪?
- volatile 能不能保证线程安全?为什么?
- 内存屏障是干嘛的?什么时候需要?
- TCP 的滑动窗口和拥塞控制具体怎么配合工作?
- 拥塞控制那几个阶段分别在干什么?为什么要慢启动?
- TIME_WAIT 状态为什么要存在?为什么是 2MSL?
- 服务器上出现大量 TIME_WAIT 是什么原因?怎么处理?
- 一个进程内存一直涨,你怎么定位是哪儿泄漏了?说完整流程。
- select、poll、epoll 的区别是什么?epoll 为什么高效?底层数据结构是什么?
- 讲一个你自己遇到过的最难的 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 这题要挑一个有完整排查链的:现象 → 缩小范围的手段(二分、加日志、对比环境)→ 定位到的根因 → 修复和验证。避免讲成「查了很久最后发现是个拼写错误」,那体现不出方法。