面灵AI→

华为嵌入式软件一面二面凉经:C 与网络基础过关,并发与协议栈实现层卡死

轮次
一面+二面
base
东莞
结果
二面挂
时间
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. 说说你知道的进程间通信方式,各自优缺点。

二面(一小时十分钟)

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

《参考解析》

这场的分界线很清楚:一面考「知不知道」,二面考「懂不懂实现」。 一面的题基本在知识面上,能答上来就过;二面面试官直接说「聊点深的」,问的全是「不会用不要紧、底层怎么回事」。作者自己复盘得也准:平时 pthread_mutex_lock 一把梭,从没想过锁不上的线程被扔哪去了。这类问题的判据不是背过答案,而是能不能对一个知识点讲出三件事——它解决什么现象、它在内核或硬件里怎么实现、它为什么这么设计以及代价是什么。刷面经时觉得「这个我会」就翻页,涨的是第一层;二面要的是后两层。

互斥锁在内核里怎么实现、加锁失败的线程去哪了。 Linux 上的 pthread mutex 无竞争时是用户态的一条 CAS 就拿下(fast path,不进内核);一旦发现有竞争,才走 futex(FUTEX_WAIT) 系统调用,内核按这个锁的地址找到对应的等待队列,把线程状态置成就绪以外的可中断睡眠态、挂进队列并调度出去——所以答案是「线程睡在这个 futex 地址的等待队列上,不占 CPU」。解锁时先在用户态尝试直接把锁还掉,如果还有等待者,再 FUTEX_WAKE 唤醒。顺着这个链路能接出好几个常见追问:为什么会有「先自旋几次再挂起」的自适应策略(避免短临界区白白做一次上下文切换)、为什么要 futex 而不是直接系统调用(无竞争时零系统调用开销)、为什么低优先级线程持锁会导致优先级反转(需要优先级继承的 PI mutex)、以及为什么锁的内存地址要按字对齐。

自旋锁和互斥锁的区别,答在「能不能睡眠」上。 自旋锁忙等、不睡眠,只适合临界区极短且不允许睡眠的上下文(中断处理、调度器内部);单核上自旋锁基本退化为关抢占,只有在多核上才有意义。互斥锁会睡眠,持锁线程可被抢占和调度,代价是可能两次上下文切换。选择的判据是临界区长度与上下文切换开销的对比,加上调用方能不能睡眠——在中断里只能自旋,在进程上下文里持锁做 IO 就该用互斥锁。

原子操作的实现和开销。 x86 上靠带 lock 前缀的指令(lock cmpxchg、lock xadd),实现方式是在缓存层取得该 cacheline 的独占权(旧实现是锁总线,现代多在缓存一致性协议里做);ARM 上是 ldrex / strex 的 LL/SC 重试循环。和加锁比,无竞争时原子指令只是几条指令、没有系统调用和上下文切换,便宜得多;有竞争时 CAS 循环可能反复失败空转,开销反而可能超过互斥锁的挂起唤醒。另一个必须主动说的边界:单个原子变量只能保证它自己的一致性,涉及多个变量或「检查和修改」的复合逻辑,要么用锁,要么用序列锁之类专门机制。

volatile 不能保证线程安全,理由要拆成三条。 它只保证这次访存不被编译器优化掉,不保证原子性(i++ 仍是读-改-写)、不提供内存序(周围的访存照样可重排)、也不能在核间建立先后关系。C++ 里该用 std::atomic 配内存序,内核里用 READ_ONCE / WRITE_ONCE 加显式屏障。把这层和「驱动里 volatile 用来访问寄存器」的用途分开讲,说明你知道它到底解决什么问题。

内存屏障什么时候需要。 编译屏障(barrier())只挡编译器重排;CPU 屏障(smp_mb / smp_rmb / smp_wmb,ARM 上落 dmb / dsb / isb)才管核间可见性顺序,且必须成对使用才有效。最典型的场景就是无锁的单生产者单消费者队列:生产者在写完数据之后、更新索引之前插写屏障,保证消费者看到新索引时数据一定已经可见;消费者读到索引后插读屏障。三面里常见的追问还有「单核要不要 CPU 屏障」(不要,但要编译屏障)和「smp_ 前缀在单核配置下会退化成编译屏障」。

死锁四条件与工程上的避免手段。 四个必要条件是互斥、持有并等待、不可剥夺、循环等待,破坏任意一个就行。工程里最有效的三条:给所有锁定一个全局固定的获取顺序(破坏循环等待)、一次性把需要的锁全拿到或拿不到就全放掉重试(破坏持有并等待)、以及给等待加超时兜底。检测上,线上可以看 /proc/locks、gdb 或 pstack 把多个线程的栈打出来找互相等待的环,或者用带死锁检测的运行时工具。回答「你项目里怎么避免的」时给一个具体约定(比如「所有涉及 A 和 B 的地方都先拿 A 再拿 B,临界区里不做 IO 和回调」)比背四条定义可信得多。

读写锁什么场景反而更慢。 读写锁的优势在读多写少的共享读;它变慢有几种情况:临界区极短(几条指令)时,读写锁自身的引用计数和原子操作开销比一把互斥锁还大;写者频繁时读者会把写者饿死,需要额外策略;多核上大量读者抢同一把锁的同一根 cacheline,会造成缓存行争用,反而不如分片加锁(sharded lock)或者干脆用无锁结构。

TCP 滑动窗口与拥塞控制的分工,要能一句说清。 发送窗口 = min(接收窗口 rwnd, 拥塞窗口 cwnd)。rwnd 由接收方通告,是流量控制,防的是把接收方的缓冲灌爆;cwnd 由发送方根据网络反馈自己调整,是拥塞控制,防的是把中间链路打爆。两者取小值,所以「配合」就是每时每刻取更严格的那个限制。拥塞控制四个阶段:慢启动时 cwnd 从 1 个 MSS 起指数增长到 ssthresh(一开始完全不知道路径容量,只能快速探测,叫「慢」是相对于一上来就发满窗口);进入拥塞避免后改为线性增长;发生超时说明拥塞严重,ssthresh 减半、cwnd 归 1 重新慢启动;收到三个重复 ACK 说明只是个别丢包,走快重传加快速恢复,ssthresh 减半、cwnd 降到 ssthresh,不回到起点。

TIME_WAIT 与 2MSL。 TIME_WAIT 由主动关闭方进入,存在的理由有两条:保证最后那个 ACK 有重传机会(如果对端没收到,会重发 FIN,此时连接还在,能应答);让本次连接的迟到报文在网络里消亡,避免被后续复用同一四元组的新连接误收。2MSL 就是一个报文在网络中最大存活时间的往返,等满这么久才能保证两个方向上的残留报文都消失。服务器上大量 TIME_WAIT 通常意味着是服务端主动关连接(短连接场景下很常见),处理方向是改用长连接和连接池减少频繁关闭、让客户端先关,SO_REUSEADDR 解决的是重启时 bind 失败,tcp_fin_timeout 之类参数的收益有限——根子还是连接管理策略。

「进程内存一直涨」要给完整排查流程,而不是只报工具名。 第一步先判性质:RSS 持续上涨且不回落才像泄漏;/proc/<pid>/smaps_rollup 里看涨的是 [heap] 还是 mmap 区域——堆涨多半是分配没释放或者分配器不归还,mmap 涨往往是线程栈、文件映射或大块分配(glibc 的大分配走 mmap)。第二步看分配器统计(mallinfo / malloc_stats)拿到净分配量的趋势,区分「真泄漏」和「碎片导致的 RSS 上涨」(glibc 释放后不一定把内存还给 OS,arena 会留着)。第三步用工具定位:开发期用 valgrind / massif 或 ASan、LSan 跑复现路径,线上用 tcmalloc / jemalloc 的 heap profile 按分配点排序,或者 gdb attach 加 malloc_trim 观察回落。第四步回到业务:按可疑路径做二分(哪类请求触发后才涨),确认是持有引用没放、缓存无上限、还是每个请求都申请一处小对象。回答里要体现「先证明是泄漏、再定位到分配点」这个顺序,直接说「用 valgrind 查」是拿不到分的。

select、poll、epoll 的差别与 epoll 的高效来源。 select 用位图存 fd 集合、有 FD_SETSIZE 上限(常见 1024),每次调用都要把整个集合从用户态拷进内核、内核线性扫描全部 fd,返回后还要重新设置集合;poll 换成数组、去掉了数量上限,但仍然是每次 O(n) 扫描。epoll 把两件事分开:epoll_ctl 时把关心的 fd 挂进内核的红黑树(增删查 O(log n)),同时给每个 fd 注册回调;设备就绪时回调把它挂进就绪链表,epoll_wait 只取就绪链表上的项——所以复杂度与实际就绪的 fd 数相关,而与监听的 fd 总数无关。还要能接住两个追问:水平触发(LT,默认,不处理会反复通知)和边沿触发(ET,只通知一次,必须循环读到 EAGAIN,且 fd 要设成非阻塞,否则会漏事件或阻塞在最后一次读上);以及 epoll 的局限——它不适合普通文件(普通文件永远就绪),fork 之后父子共享实例的语义要小心。

最后那道「最难 bug 怎么定位」是方法论题。 它考的是排查过程能不能复述成一条有逻辑的链:现象(触发条件、可复现性、影响面)→ 缩小范围(按模块、输入、并发条件二分)→ 加观测(日志、ftrace / perf、断点、core dump)→ 形成假设并验证(能解释所有已知现象才算通过)→ 修复并回归(原本能复现的路径不再复现、没有引入新问题)。要能交代「当时为什么会往这个方向怀疑」,以及哪里还没查到底——比起把一个没查清的问题讲得很圆,说明验证到了哪一步更稳。

这篇凉经最值得带走的是那条复盘:面经是检索线索,不是掌握程度。 正确用法是每看完一道题,合上答案自己讲三层——它解决什么问题、底层怎么实现、代价是什么,讲不出来的就打回重学;华为一面还考了两道手撕,说明嵌入式也躲不开算法题,手感不能丢。原帖没有写后续,从「三天没消息」来看这一轮到此为止。