面灵AI→

大华嵌入式软件一面二面面经(含 Linux 内核追问)

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

《面试题目》

一面(约 50 分钟):

  1. Linux 下 volatile 的作用,什么场景必须加?
  2. static 修饰全局变量、局部变量、函数分别是什么效果?
  3. 字节对齐的规则,为什么要对齐,不对齐会怎样?
  4. 进程和线程的区别,顺带问各自的通信方式。
  5. 堆和栈的区别,程序运行时内存怎么分配的?
  6. const 和指针结合的几种写法都什么意思?
  7. I²C 的通信过程,几根线,怎么寻址,上拉电阻为什么要有?
  8. DMA 是干什么的,用它有什么好处?
  9. GDB 常用哪些命令,段错误怎么定位?
  10. FreeRTOS 的任务调度是怎么调的,抢占式怎么理解?
  11. 互斥量和信号量的区别,分别用在什么场景?

二面(约 1 小时,Linux 内核与调度原理):

  1. Linux 的进程调度是怎么工作的,schedule 函数什么时候被调用?
  2. 页表是几级的,分页有什么缺点?
  3. 时间片轮转和多级反馈队列,nice 值怎么影响优先级?
  4. 共享内存到底依不依赖内核,读写为什么快?
  5. 实时操作系统的调度器怎么设计的,上下文怎么保存?
  6. 信号量释放之后,内核怎么决定下一个唤醒哪个线程?
  7. 怎么评估一个系统的实时性,中断响应时间怎么测?
  8. 上下文切换的时候,系统具体做了哪些事?
  9. MMU 怎么把虚拟地址翻译成物理地址的?
  10. u-boot 启动为什么要分两个阶段,第一阶段为什么要尽量小?

《参考解析》

volatile、static、字节对齐。volatile 告诉编译器「这个变量可能在意料之外被改变」,因此禁止把它缓存到寄存器、禁止优化掉看似多余的读写、禁止重排它与其他 volatile 访问的相对顺序。必须加的场景:硬件寄存器映射(#define REG (*(volatile uint32_t*)0x...))、中断服务程序与主循环共享的变量、多线程共享的标志位、setjmp/longjmp 相关的变量。它不提供原子性与内存屏障,所以共享计数器仍需临界区或原子指令。static 的三种用法前面已展开:局部静态改变存储期(移到静态区、只初始化一次、函数间保持值)、全局静态与函数静态改变链接属性(仅本编译单元可见,避免符号冲突)。字节对齐是要求变量的地址是其对齐数的整数倍(通常等于自身大小,指针与 long long 在 64 位上对齐 8);对齐的好处是 CPU 能一次性读完整个字(非对齐可能需要两次访存或触发硬件异常)、也便于 DMA 与 SIMD 操作;不对齐的代价是性能下降,某些架构(早期 ARM、部分 MIPS)直接抛异常。规则是结构体成员按自身对齐依次摆放、必要时填充,整体大小为最大对齐数的整数倍,末尾补齐;调整成员顺序可减少填充。

进程与线程、堆与栈、const 与指针。进程是资源分配单位(独立地址空间、文件描述符、信号处理),线程是调度单位(共享进程的地址空间与资源,只有自己的栈与寄存器);通信方式上进程用管道、消息队列、共享内存、信号、socket(共享内存最快但要自己同步),线程因为共享地址空间直接读写即可,但必须加锁或用同步原语。堆与栈的区别:栈由编译器管理、随函数调用自动分配释放、大小固定(通常几 MB,嵌入式里更小)、分配极快、向下增长,溢出会破坏相邻区域;堆由程序员管理(malloc/free 或 new/delete)、大小受限于可用内存与地址空间、分配有碎片与元数据开销、向上增长;嵌入式里长期运行的系统往往避免频繁堆分配,改用静态分配或内存池。const 与指针的组合按「从右往左读」记:const int *p(指向常量的指针,值不可改、指针可改)、int * const p(常量指针,指针不可改、值可改)、const int * const p(都不可改)。面试时最好把每种写法的实际含义与用途说一句。

I²C、DMA、GDB。I²C 要点前面已展开(两根开漏线 + 上拉电阻、起始/地址/ACK/停止、时钟拉伸、仲裁、7 位或 10 位寻址);上拉电阻的作用是开漏输出只能主动拉低、无法主动拉高,所以要靠外部上拉把线拉回高电平,阻值要与总线电容和速率匹配(典型 4.7k)。DMA 让外设与内存之间直接搬数据、不占用 CPU 逐字节搬运,好处是释放 CPU 去做计算、提高吞吐与实时性(ADC 连续采样、串口与 SPI 大批量收发、显示刷新);代价是要处理缓存一致性(有 D-Cache 的处理器上,DMA 写入内存后 CPU 可能读到缓存里的旧值,需要 invalidate/clean 或把缓冲区放在非缓存区)、传输完成中断、以及描述符链表与 DMA 通道的分配冲突。GDB 常用命令:break/tbreak、run/continue/next/step、bt、frame、info locals/info args、print/x 看内存、watch 看变量变化、core-file 加载 core dump、disassemble 看汇编、thread apply all bt 看所有线程栈。段错误定位就是拿到 core 后 bt 看崩溃栈、结合 info registers 与反汇编定位到具体指令,再回到源码。

FreeRTOS 调度与互斥量/信号量。调度基础前面已展开(优先级 + 同优先级时间片 + 抢占,就绪位图找最高优先级,阻塞与挂起触发切换)。互斥量与信号量的区别是高频题:二值信号量用于「同步/通知」(ISR 通知任务、任务间事件),不解决优先级反转;互斥量用于「互斥访问共享资源」,带优先级继承(持锁的低优先级任务临时被提到等待者的优先级,避免中优先级任务把高优先级任务无限期推迟),并且有所有者概念、支持递归(xSemaphoreCreateRecursiveMutex),不能在 ISR 中获取。计数信号量用于「资源计数」(缓冲区空位数、连接池),二值信号量可以看作计数为 1 的特例。选择标准一句话:保护资源用互斥量,传递事件用信号量,中断里通知任务用二值信号量或任务通知(xTaskNotifyFromISR,更快更省内存)。

Linux 进程调度与 schedule 的调用时机。Linux 用 CFS(完全公平调度器):按虚拟运行时间(vruntime)排序,用红黑树取最左节点运行,权重由 nice 值决定(nice 每降 1 大约多拿 10% 的 CPU),目标是公平而非固定时间片。schedule() 被调用的时机分几类:任务主动让出(阻塞在 I/O、sleep、等锁、sched_yield)、被唤醒时触发抢占检查(try_to_wake_up 里判断是否需要抢占当前任务)、时间片/调度周期到期(scheduler_tick → task_tick_fair 设置重调度标志)、以及中断或系统调用返回用户态时检查 TIF_NEED_RESCHED 标志然后调用。要能说出「抢占不是任意点发生」:内核里非抢占区(持自旋锁、关中断)不会切换,用户态才有任意点被抢占的语义。多级反馈队列(MLFQ)是另一类设计:按优先级分多个队列,时间片用完降级,长期等待升回,目的是在不掌握任务特征时用历史行为推断它该拿多少 CPU(现代 Linux 桌面场景已被 CFS 取代,实时进程用 SCHED_FIFO/SCHED_RR)。

分页、MMU 与地址翻译。MMU 通过页表把虚拟地址翻译成物理地址:地址被拆成「页号 + 页内偏移」,页号逐级查表(x86-64 是 4 级:PGD→PUD→PMD→PTE,ARM64 通常 4 级、可配 3 级),每一级页表项给出下一级表的物理基址与权限位,最后一级给出物理页框号,加上偏移得到物理地址;找不到或权限不符就触发缺页异常或段错误。硬件靠 TLB 缓存最近用过的映射,进程切换时要换页表基址寄存器(x86 的 CR3)并刷新相关 TLB(用 ASID/PCID 可以减少刷新)。分页的缺点:多级查表带来访存开销(TLB 未命中时可能多次访存)、页表本身占内存、大页与碎片问题(页越大内部碎片越多)、以及缺页处理带来的延迟抖动,对实时性是干扰。嵌入式里如果对确定性要求高,常常关掉 MMU 或用无 MMU 的处理器。

共享内存为什么快、依不依赖内核。共享内存的核心是把同一块物理页映射到多个进程的虚拟地址空间里,进程读写的是同一片物理内存,数据不经过内核的缓冲区拷贝,所以比管道与消息队列(要走内核内存、有两次拷贝)快得多,是进程间通信里带宽最高的方式。它依然依赖内核:创建与分配(shmget/shm_open)、映射与解除映射(shmat/mmap)都要系统调用,内核负责记账与权限检查,/proc、ipcs 里能看到;只是数据面的读写完全在用户态完成。代价是共享内存不自带同步与消息边界,必须自己配信号量、互斥量或 futex 做互斥,否则会出现读到半写状态的问题;还要处理进程异常退出后的清理(shmctl 删除)与生命周期管理。

实时性评估、上下文切换与信号量唤醒。评估实时性要量三个指标:中断延迟(从硬件中断发生到 ISR 第一条指令)、调度延迟(从事件发生到目标任务开始运行,含 ISR 处理与调度器开销)、以及最坏情况下的抖动(jitter)。测量手段:用 GPIO 在中断入口与任务入口翻转电平,逻辑分析仪或示波器量时间差;用高精度计时器(clock_gettime 的 monotonic 时钟、DWT 周期计数器)打时间戳;用 cyclictest 这类工具做长跑统计最大延迟。注意要测「最坏值」而不是平均值,并跑到真实负载下(内存压力、中断风暴、DMA 竞争总线)。上下文切换做的事:保存当前任务的现场(通用寄存器、PC、PSR、浮点/SIMD 状态)到它的栈或 TCB,切换栈指针与地址空间(进程切换要换页表、刷新 TLB/换 ASID,还会影响缓存局部性),选择下一个任务并恢复它的现场,更新调度统计。开销主要来自寄存器保存、TLB 与缓存污染,所以「减少切换次数」本身就是优化目标。信号量释放后唤醒哪个线程:Linux 的 futex 走的是等待队列,唤醒顺序默认近似 FIFO(wake_up 会遍历链表),但最终谁先跑还取决于优先级与调度类(实时线程抢占普通线程、CFS 里比较 vruntime)、以及是否被放到不同 CPU 上并行唤醒;wake_up 还有排他唤醒(只唤醒一个)与公平唤醒的区别。所以「先等先得」在 Linux 上不是严格保证,需要严格顺序就得用 FIFO 队列或实时优先级。

u-boot 为什么分两阶段。第一阶段(SPL/MLO)运行在 SoC 内部的 SRAM 上,此时 DDR 还没初始化、可用内存极小(几十到几百 KB),所以它的任务只有最必要的几件:配置时钟与串口、初始化 DDR 控制器、把完整 U-Boot 从启动介质搬到 DDR;正因为空间极度受限,它必须尽量小、尽量简单。第二阶段(完整 U-Boot)在 DDR 里运行,才有了足够空间做驱动初始化、环境变量、文件系统读取、网络下载(TFTP)、脚本与命令交互,最后加载内核与设备树。好处是既满足硬件启动约束,又保留完整引导能力;这也是「第一阶段出问题连串口都看不到」的原因,调试 SPL 往往要靠示波器与备份启动介质。

这场面试给出的准备方法。作者的复盘很到位:一面题只准备到「知道它是什么」是不够的,二面会顺着往下追问「怎么实现的、为什么这么设计」。可行的做法是把每个知识点都往下准备两层(例如 volatile → 为什么不能同步 → 那什么能同步、cache 一致性怎么办;nice → CFS 怎么算权重 → 抢占时机在哪),并且优先补 OS 与 RTOS 的调度、上下文切换、锁与内存管理这几块,因为它们最容易在二面被深挖。