面灵AI→

大疆嵌入式软件一面二面:从 static 到环形缓冲区

轮次
一面+二面
时间
2026-09
来源
牛客网

《面试题目》

一面(约 70 分钟)

  1. static 的作用是什么?修饰局部变量和全局变量分别有什么区别?
  2. volatile 用过没有?项目里什么地方用了?它能保证操作的原子性吗?
  3. 指针和数组的区别是什么?数组作为函数参数传进去以后,sizeof 算出来是什么?
  4. 结构体内存对齐:现场给几个成员算大小,调整顺序后再算一遍。
  5. 堆和栈的区别是什么?局部变量的地址能不能返回?
  6. memcpy 和 memmove 有什么区别?内存区域重叠时怎么处理?
  7. 中断里哪些事情不适合做?耗时操作一般怎么交给任务处理?
  8. 中断和任务之间传数据,用过什么方式?
  9. 互斥锁、二值信号量有什么区别?
  10. 优先级反转是什么?优先级继承怎么缓解?
  11. 任务什么时候会切换?高优先级任务一直处于就绪状态会怎么样?
  12. 任务栈大小怎么定?有没有看过栈的使用情况?
  13. 手撕代码:链表反转,写完自己检查边界(空链表、只有一个节点)。
  14. 自我介绍之后先讲项目:现在主要做什么,用什么芯片和系统,自己负责哪部分?

二面(约 80 分钟)

  1. 把数据从进来到出去的整个过程讲清楚:中间经过哪些中断、哪些任务、哪些缓冲区?
  2. 采样频率是多少?一次产生多少数据?
  3. 接收缓冲区为什么开这么大?有没有算过最坏情况下要存多少?
  4. 如果消费数据的任务被延迟了,缓冲区能撑多久?
  5. 缓冲区满了怎么办?丢新数据还是覆盖旧数据,依据是什么?
  6. 怎么判断系统已经开始丢数据?有没有计数或者其他检测方式?
  7. 项目里几个任务的优先级怎么排?哪个任务必须及时响应?
  8. 从数据采集完成到处理结束,允许的最大延迟是多少?
  9. 怎么测这段延迟?只看平均值够不够?
  10. 两个任务共用一个缓冲区,怎么避免读到写了一半的数据?
  11. 如果 DMA 还在往缓冲区写,任务能不能直接读?怎么划分可读的部分?
  12. 临界区里处理的数据比较多,会有什么影响?
  13. 讲一个最难定位的 bug:最开始怎么发现丢数据的?凭什么确定不是发送端少发了?
  14. 排查过程中排除了哪些可能?每一步依据是什么?
  15. 有没有留下出问题时的现场数据?
  16. 加日志以后,问题出现的频率有没有变化?
  17. 最后怎么验证修改有效?如果跑几天没复现,能不能说明解决了?
  18. 设备偶发进 HardFault,你会先保留哪些信息?
  19. 怀疑任务栈溢出,怎么确认?还可能是什么原因?
  20. 开了编译优化以后才出现异常,会从哪些地方查?
  21. 看门狗由谁来喂?如果有个业务任务卡死,其他任务还在正常运行,能不能发现?
  22. 手撕代码:实现环形缓冲区,先按单线程情况写;再问如果一个任务写、另一个任务读,需要额外考虑什么?

《参考解析》

缓冲区容量、满时策略与丢数据判定

容量不能拍脑袋,要按最坏情况算:容量 ≥ 峰值生产速率 × 消费者最长阻塞时间,再留 1.5~2 倍余量。这里的关键是「最长阻塞时间」怎么来——不是平均值,而是高优先级任务抢占、Flash 擦写、DMA 中断风暴叠加后的最坏值,能算出来才有说服力。满了之后丢谁取决于数据语义:控制环只关心最新值,用覆盖旧数据;采样日志要保完整性,用丢新数据 + 计数上报。最忌讳「满了就悄悄丢」——必须有可观测的计数器(写指针超圈次数、序列号跳变、DMA 的 NDTR 异常、硬件 overrun 标志),并把丢包率暴露成可读的统计量甚至告警。判断「是否已经丢数据」的靠谱手段就是序号连续性:每帧带一个递增序号,接收侧一旦发现跳号立刻计数,比事后猜可靠得多。

端到端延迟怎么测

只看平均值没有意义,偶发的长尾才是真问题。做法是:在数据进入(中断入口)时打一个时间戳,处理结束(任务出口)再打一次,把差值做成直方图,同时关注 P50/P95/P99 和最大值。时间戳要用硬件定时器的高位计数器(比如 DWT 的 CYCCNT 或 TIM 的自由运行计数),别用毫秒级的 tick。更硬核的做法是给关键路径配两个 GPIO:进中断拉高、处理完拉低,用示波器看脉宽,既不影响代码逻辑又能看到最坏情况;测的时候还要注意观测者效应——加日志本身会改变时序,尤其别在中断里做阻塞的串口输出。最后别忘了交代「延迟是在什么负载下测的」,空载数据没有参考价值。

单生产者单消费者环形缓冲区

单线程版本很简单:head/tail 指针取模(容量取 2 的幂就能用 & (N-1) 代替取模),判空是 head == tail,判满留一个空槽或额外维护一个 count,写之前判满、读之前判空。

一旦变成「一个任务写、另一个任务读」,要额外处理三件事:

  • 指针的可见性与顺序。读写指针要用 volatile 或 std::atomic 修饰,并且保证「先写数据、再更新指针」的顺序:生产者写完数据后需要一次 release 语义(atomic_store_release 或 __DMB())再更新 head,消费者先 acquire 读 head 再读数据。Cortex-M 单核场景下,简单粗暴地关中断/关调度进入临界区反而比无锁更省心、更不容易出错。
  • 不引入额外阻塞。SPSC 的正确写法本来就不需要锁:生产者只改 head,消费者只改 tail,天然不会互相踩,这也是它比队列+互斥量更适合中断/高频路径的原因。
  • 容量与覆盖语义。如果满了要覆盖旧数据,消费者侧的 tail 必须跟着前移,这时「写了多少 / 丢了多少」就得靠计数器而不是指针差来算。

HardFault 现场保留、栈溢出确认与优化引入的异常

设备偶发进 HardFault 时,第一优先级是别让它复位后什么都不剩。要保留的信息:CFSR(能分出 MemManage / BusFault / UsageFault,进一步看 MMFAR、BFAR 拿到出错地址)、HFSR、以及异常栈帧里的 R0~R3、R12、LR、PC、xPSR——判断用的是 MSP 还是 PSP 就看 LR 的 bit2。把 PC 丢进 arm-none-eabi-addr2line -e xxx.elf 或 objdump -d 就能定位到源码行,这一步能省掉几天排查。现场要存到复位不丢的地方:noinit 段、备份寄存器(有 VBAT 时)或直接写进 Flash 的日志区,复位后在启动阶段读出来上报。生产设备上还应配一个「异常计数器」,把偶发故障变成可统计的指标。

怀疑任务栈溢出时,先用 uxTaskGetStackHighWaterMark() 看历史最小余量,或者在栈底填魔数定期校验,再往上是 MPU 保护栈底下一页。但要注意进 HardFault 的原因不止栈溢出:空指针/野指针解引用、访问未使能时钟的外设寄存器、数组越界写坏返回地址、对齐错误(非对齐访问在部分内核上直接 fault)、除零、以及栈上的大结构体导致越界,都会表现成同一种现象。排查时按「PC 指向的指令类型 + CFSR 分类」先分区,再逐步二分。

「开了编译优化才出现的异常」几乎都有固定嫌疑名单:共享变量漏加 volatile(编译器把它缓存在寄存器里,循环里再也不去内存读)、未定义行为被优化暴露(越界、有符号整型溢出、严格别名 -fstrict-aliasing、读未初始化变量、空指针解引用被当作不可达而删掉分支)、时序假设被删掉(空的延时循环、靠语句顺序实现的小延时、本意是内存屏障的 volatile 写)、以及内联和展开导致栈占用变大。定位方法是逐级降优化等级(-O3 → -O2 → -Os → -O0)二分,同时用 -S 或 objdump -d 对着生成的汇编看哪条被优化掉了;临时用 -fno-strict-aliasing、-fno-delete-null-pointer-checks 验证假设可以,但根因还是要把 UB 改掉。

看门狗与「任务卡死」的检测

喂狗必须放在最低优先级任务或主循环里,且喂狗条件是「所有关键任务都上报过心跳」——常见实现是每个任务维护一个计数器或时间戳,监督逻辑遍历检查全部任务在最近一个周期内都跑过,才放行喂狗。如果只在某个任务里喂狗,这个任务卡死了系统当然会复位,但别的任务卡死它照样喂,等于没保护。另外不要在中断里喂狗、调试时记得先冻结看门狗、长耗时操作(擦写 Flash)要分段喂或者临时关狗并记日志。

一面基础题:static/volatile、memcpy/memmove 与优先级反转

static 修饰局部变量改变的是存储期:变量从栈上挪到静态区,函数退出不销毁,只在程序启动时(或首次执行到声明处,C++11 起线程安全)初始化一次,作用域仍限于本函数。修饰全局变量或函数改变的是链接属性:变成内部链接,只在本编译单元可见,用来避免多文件重名冲突。volatile 告诉编译器每次访问都必须真的去内存读写,禁止把它缓存到寄存器、禁止删除「看似无用」的访问;但它不保证原子性——i++ 依然是读-改-写三步,多核或中断共享时必须用原子操作、临界区或关中断,而且 volatile 也不构成内存屏障。

memcpy 假定源和目的不重叠,重叠时行为未定义(实现可能用了向量化整块拷贝,正序拷贝会把源数据覆盖掉)。memmove 会先判断区间是否重叠,重叠时改成从后往前拷或先拷到临时缓冲,代价只是多一次比较,所以不确定的时候一律用 memmove。嵌入式里环形缓冲整理、协议组包时缓冲区前后挪动,都是典型的重叠拷贝场景。

优先级反转是「高优先级任务被低优先级任务间接阻塞」:低优先级任务持有互斥量,高优先级任务等锁,此时一个中优先级任务抢占 CPU 并长时间运行,导致高优先级任务等得比低优先级还久。经典案例是火星探路者的系统复位。缓解手段是优先级继承(持有锁的低优先级任务临时被提升到等待者的优先级,FreeRTOS 的 mutex 支持,二值信号量不支持)或优先级天花板协议;更根本的做法是缩短临界区、避免在高优先级路径上争用同一把锁。顺带回答「互斥锁与二值信号量的区别」:互斥量有所有者概念、支持优先级继承、支持递归获取,常用于保护共享资源;二值信号量没有所有者,更适合做任务间同步(中断给信号、任务等信号),用错了就会出现「谁都能给、谁都能拿」的混乱。