面灵AI→

瑞芯微嵌入式一面 关键字、内存对齐与设备树九连问

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

《面试题目》

  1. volatile 关键字的作用是什么?驱动读取寄存器为什么必须加 volatile?
  2. static、const、volatile 三个关键字在嵌入式中的核心用法分别是什么?
  3. 指针数组与数组指针、函数指针与指针函数有什么区别?
  4. 结构体的内存对齐规则是什么?如何计算大小、如何优化内存占用?
  5. 中断服务函数有哪些限制?为什么不能做耗时操作、不能调用 printf?
  6. UART、I2C、SPI 通信有什么区别?串口乱码、丢包怎么排查?
  7. 简述瑞芯微 BootROM、SPL、U-Boot、Kernel 四级启动流程。
  8. 设备树的作用是什么?DTS 如何和驱动的 probe 函数完成匹配?
  9. kmalloc、kzalloc、vmalloc 有什么区别?各自适用什么场景?

《参考解析》

volatile 与寄存器读取:volatile 告诉编译器「这个对象随时可能被本题之外的东西改掉」,于是它不再把值缓存进寄存器、不合并相邻的多次读、也不删掉看似没用的写,每次访问都老老实实生成一条访存指令。寄存器映射必须写成 volatile 指针,原因就在这里:轮询状态位写成 while (REG->STAT & BUSY); 时,如果寄存器没被 volatile 修饰,编译器会认为循环体里没人改它,直接优化成读一次、甚至把整个循环删掉。要补一句边界——volatile 只解决编译器优化问题,不提供原子性、也不构成内存屏障;多核之间的可见性与顺序要靠 barrier 和原子操作,驱动里常见写完寄存器再回读一次的用法,就是为了把写操作推下去、确保生效。

static、const、volatile 在嵌入式里的分工:三者管的是完全不同的三件事。static 管链接属性与生存期——修饰文件作用域的变量或函数时把符号变成内部链接,别的编译单元看不到,用于避免驱动之间重名;修饰函数内变量时放进静态存储区、只初始化一次。const 是「只读」的契约,让编译器把数据放进只读段,并给调用方一个不会改的承诺,常见于查找表和配置结构。volatile 是「会变」。三者可以叠加,比如 static uint32_t volatile * const REG——指针本身只读(不能重新指向),但指向的寄存器内容随时会变,读法要从标识符出发逐层往外看。

指针数组、数组指针与函数指针的读法:int *p[10] 是数组,10 个元素都是 int *;int (*p)[10] 是指针,指向一个含 10 个 int 的数组,p + 1 一次跳过一整个数组(32 位 int 就是 40 字节)。同理 int *f() 是返回指针的函数,int (*f)() 是函数指针。判断技巧是从标识符出发、先右后左结合:右边先遇到 [] 就是数组、先遇到 () 就是函数,左边再看元素或返回值类型——数组指针之所以要加括号,就是因为它强行改变了结合顺序。嵌入式里函数指针大量用于中断向量表和驱动 ops 结构,数组指针则常见于把二维缓冲区整体传参。

结构体内存对齐与内存优化:规则两条:每个成员相对于结构体首地址的偏移必须是该成员对齐要求的整数倍,不足就在前一个成员后面补 padding;结构体总大小必须是最大对齐成员的整数倍,尾部也要补齐,这样才能保证结构体数组里每个元素都天然对齐。计算时用 offsetof 逐个验证,别凭感觉数。优化手段:把成员按对齐要求从大到小排列,减少中间空洞;多个小标志位合并成位域或一个整型;确实要与外部协议逐字节对应时才用 __attribute__((packed)) 或 #pragma pack,代价是非对齐访问在部分架构上更慢、甚至由内核陷入处理,某些平台直接触发异常。

中断服务函数的限制:本质是它运行在中断上下文,没有进程上下文、不能睡眠。凡是可能阻塞的调用都不能用:kmalloc(GFP_KERNEL)、mutex、信号量、copy_to_user,以及任何会等待 IO 或主动让出 CPU 的函数,只能用 GFP_ATOMIC,加锁要用自旋锁并配 spin_lock_irqsave 关中断防死锁。时间上要尽量短,因为关中断期间同级中断被推迟、直接影响实时性,长活儿应该丢给下半部(tasklet、workqueue、threaded IRQ)。printf 是典型反例:它慢、内部加锁、不可重入,还依赖串口驱动的发送完成,在中断里调用可能死锁或阻塞;正确做法是在中断里写缓冲、打时间戳,退出中断后由任务上下文打印。

UART、I2C、SPI 的差异与串口乱码排查:UART 是异步、点对点、没有时钟线,靠双方约定的波特率采样,帧里有起始位、数据位、校验位、停止位,适合板间通信与调试口。I2C 同步、两根线(SCL/SDA)、开漏加外部上拉、多主多从、靠 7 位或 10 位地址寻址、每字节有 ACK,速率通常 100k/400k/1M,适合挂一堆慢速外设。SPI 同步、四根线(SCLK/MOSI/MISO/CS)、全双工、没有地址靠片选选设备、没有 ACK、速率可以到几十 MHz,适合高速外设与屏幕 Flash。串口乱码几乎先怀疑波特率与时钟源(分频算错、外部晶振频率与实际不符),再查数据位/校验/停止位配置、是否共地、电平是否匹配(TTL 与 RS232/RS485 不能直连)、线缆长度与干扰;丢包则看 FIFO 是否溢出、有没有开流控、中断或 DMA 处理是否及时、接收缓冲是否够大。

四级启动流程与设备树匹配:上电先跑芯片内固化的 BootROM,它按熔丝或引脚配置的顺序(SPI Flash、eMMC、SD 卡、USB 下载)尝试读入下一级。SPL 的主要任务是把 DDR 初始化起来——BootROM 时代只有片内 SRAM,放不下完整引导程序——然后把 U-Boot 搬到 DRAM。U-Boot 初始化外设、读环境变量、加载内核镜像与设备树并传 bootargs,最后跳转执行。内核自解压后进 start_kernel,初始化内存管理、调度与驱动模型,挂载根文件系统,启动用户态 init。设备树的作用是把「这块板子上有什么硬件、挂在哪个地址、用哪个中断」从内核代码里挪出来:内核把 dtb 展开成 device_node 树并注册成 platform_device,驱动在 of_match_table 里用 compatible 字符串与之匹配,匹配成功回调 probe,于是一个内核镜像配不同 dtb 就能支持不同板子;除 compatible 外,驱动还可以用 of 接口读 reg、interrupts、clocks、gpios 拿资源。

kmalloc、kzalloc 与 vmalloc:kmalloc 从 slab 分配物理连续的内存,适合小块和需要 DMA 的场景(很多 DMA 控制器只能搬物理连续内存),速度快,但有大小上限、也依赖连续物理页,碎片化后大块容易失败。kzalloc 就是 kmalloc 加清零,驱动里更常用,避免把未初始化内容交给硬件或泄露给用户态。vmalloc 分配虚拟地址连续、物理可以不连续的内存,适合几十 KB 以上的大缓冲区,代价是要建立页表、分配更慢,而且不能用于 DMA。此外还有 kvmalloc(先试 kmalloc,失败退回 vmalloc)和 devm_kzalloc(随设备释放,省掉错误路径上的手工 free)。分配标志要看上下文:进程上下文用 GFP_KERNEL,中断里只能 GFP_ATOMIC。