华为嵌入式软件一面二面凉经:C 与网络基础过关,并发与协议栈实现层卡死
- 轮次
- 一面+二面
- base
- 东莞
- 结果
- 二面挂
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
一面(约 55 分钟)
- 自我介绍,然后讲一下简历里那个 Linux 项目,数据是怎么流的。
- 指针和数组的区别?函数传参传数组的时候到底传了什么?
- 二级指针什么时候用?举个实际场景。
- 内存泄漏是怎么产生的?你平时怎么发现的?
malloc申请的内存free之后能不能继续访问?会发生什么?- 结构体内存对齐:给一个结构体算大小,然后问怎么优化排布省内存。
strcpy和memcpy有什么区别?strcpy有什么隐患?- 手撕:反转链表。
- 手撕:判断字符串是不是回文,要求不申请额外空间。
- TCP 和 UDP 的区别?分别用在什么场景?
- 三次握手为什么是三次?两次行不行?
- TCP 怎么保证可靠传输的?
- 说说你知道的进程间通信方式,各自优缺点。
二面(一小时十分钟)
- 多线程里为什么需要锁?不加锁具体会出什么问题?从 CPU 层面说。
- 互斥锁在内核里怎么实现的?加锁失败的线程去哪了?
- 自旋锁和互斥锁区别在哪?
- 死锁的四个必要条件?你项目里怎么避免的?怎么检测?
- 读写锁和互斥锁的区别?什么场景下读写锁反而更慢?
- 原子操作是怎么实现的?和加锁比开销差在哪?
volatile能不能保证线程安全?为什么?- 内存屏障是干嘛的?什么时候需要?
- TCP 的滑动窗口和拥塞控制具体怎么配合工作的?
- 拥塞控制那几个阶段分别在干什么?为什么要慢启动?
- TIME_WAIT 状态为什么要存在?为什么是 2MSL?
- 服务器上出现大量 TIME_WAIT 是什么原因?怎么处理?
- 一个进程内存一直涨,你怎么定位是哪儿泄漏了?说完整流程。
- select、poll、epoll 的区别?epoll 为什么高效?底层数据结构是什么?
- 讲一个自己遇到过的最难的 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)→ 形成假设并验证(能解释所有已知现象才算通过)→ 修复并回归(原本能复现的路径不再复现、没有引入新问题)。要能交代「当时为什么会往这个方向怀疑」,以及哪里还没查到底——比起把一个没查清的问题讲得很圆,说明验证到了哪一步更稳。
这篇凉经最值得带走的是那条复盘:面经是检索线索,不是掌握程度。 正确用法是每看完一道题,合上答案自己讲三层——它解决什么问题、底层怎么实现、代价是什么,讲不出来的就打回重学;华为一面还考了两道手撕,说明嵌入式也躲不开算法题,手感不能丢。原帖没有写后续,从「三天没消息」来看这一轮到此为止。