面灵AI→

龙旗科技嵌入式一面:const/static/volatile 与内存分段

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

《面试题目》

  1. 在 C 中,怎样让某个对象只能被读取、不能经当前名字写入?当这种限定与指针结合时,如何区分「地址不可改」「目标不可改」和「两者都不可改」?
  2. 若希望一个局部对象在函数多次调用之间保留上次的值,同时又不被外部作用域访问,应使用哪种存储类说明符?它的初始化时机和生存期如何变化?
  3. 怎样让一个全局对象或函数只在本编译单元内参与链接,而不被其他源文件引用?这对命名冲突和模块封装有什么影响?
  4. 哪些情况下编译器对内存访问的合并、缓存或删除会违背程序真实意图?用什么限定符可以抑制这类优化?它能替代互斥或原子操作吗?
  5. 中断上下文与普通执行流共享标志时,声明上应注意什么?访问固定地址的外设寄存器时,又该如何避免被优化?这两类场景的共同点是什么?
  6. 数组名和保存首地址的指针变量,在对象大小、可重新赋值性、类型退化以及 sizeof 运算结果上有哪些差异?
  7. 当形参写成带长度的数组形式时,编译器实际把它视为什么?为什么函数体内部无法恢复调用方数组的元素个数?调用方通常还需要额外提供什么信息?
  8. 一个 C 程序加载后,通常会被划分成哪些逻辑段或内存区域?指令、只读数据、已初始化数据、未初始化数据、栈和堆分别大致位于哪里?

《参考解析》

const 与指针:三种「不可改」怎么区分

const 修饰谁,谁就只读,读法是「从变量名往外、先近后远」。三种组合:

const int *p;        // 指向常量的指针:*p 不能写,p 本身可以改(可指向别处)
int * const p = &a;  // 常量指针:p 不能改(地址锁定),*p 可以写
const int * const p = &a; // 两者都不可改

判定技巧是先看 const 在 * 的左边还是右边:在 * 左侧修饰的是「指向的对象」,在 * 右侧修饰的是「指针本身」。工程上的意义是接口契约:函数参数写 const T * 表示我只读你的数据,写 T * const 基本只用于本文件内的固定指向(对调用者几乎无意义,所以常见于驱动里指向固定寄存器块的指针)。

static 的三种语义

① 局部变量:static int n; 只初始化一次(程序加载时就在 .data/.bss 里备好),函数返回不销毁,值跨调用保留,但作用域仍限于函数内,外部看不见名字——这正是题目第 2 问要的答案,适合做计数器、状态机缓存。② 全局变量或函数:加上 static 后链接属性变成内部链接(internal linkage),只在本编译单元可见,别的 .c 即使 extern 也链不到,好处是把符号藏起来(nm 里是小写 t/d/b)、避免多文件同名符号冲突、也让编译器有机会内联和做更激进的优化;代价是无法被单元测试直接链接,调试只能通过暴露的接口。③ 数组形参里的 static(如 void f(int a[static 10]))是给编译器的长度提示,属于另一类用法。前两种是面试要答的重点。

volatile:抑制优化,但不等于线程安全

会被优化「优化坏」的典型情形:循环里反复读同一个变量被提到寄存器(while (!flag); 变成死循环)、两次相邻的相同写被合并、看似无用的读被删除、读写在编译器层面被重排。加 volatile 后每次访问都真的访存,顺序相对其他 volatile 访问也被约束,这些优化就不会发生。

但它不能替代互斥或原子操作:volatile uint32_t cnt; cnt++; 仍然是「读-改-写」三步,多核或中断并发下照样丢更新,volatile 也不提供内存屏障语义(Cortex-M 上常需 __DMB()/__DSB()),在多核上还涉及 cache 一致性(该用原子类型或关中断/加锁)。一句话:volatile 解决的是「编译器不知道值会变」,不解决「两条执行流同时改」。

中断共享标志与外设寄存器:共同点是「程序之外会变」

中断与主流程共享标志时,标志本身要 volatile(否则主循环可能永远读到寄存器里的旧值),若是多字节数据或被多处读改写,还需要关中断/临界区保护,或用 atomic_*;在 Cortex-M 上要注意读写顺序与屏障。访问固定地址的外设寄存器时,用 volatile 限定指针(#define REG (*(volatile uint32_t *)0x40000000),或用厂商头文件里的类型),保证每次访问都真实产生 load/store。

两类场景的共同点就是:变量的值可能在本线程/本编译单元的可见逻辑之外被改变,编译器无法推断。区别在于 volatile 只能防优化,多核/中断并发下的原子性与可见性还得靠关中断、锁、原子操作或屏障。

数组名 vs 指针、形参退化

数组名不是指针变量:sizeof(arr) 得到整个数组的字节数(指针得到指针大小 4/8 字节)、数组名不能重新赋值(不是左值)、&arr 的类型是「指向数组的指针」(int (*)[N],加 1 跳过整个数组)。相同点是绝大多数表达式里数组名会退化成指向首元素的指针,所以能传给 int * 形参、能做指针运算。

形参写成 void f(int a[10]) 或 void f(int a[]) 时,编译器一律当成 int *a 处理,长度信息被丢弃,因此函数体内 sizeof(a) 是指针大小,无法恢复调用方数组的元素个数。调用方必须额外把长度传进来(f(a, n)),或者约定哨兵值(如字符串的 \0)、把长度包进结构体(struct { int n; int *data; }),C11 之后还可以用 f(int n, int a[static n]) 表达契约(但这只是给编译器的提示,不改变形参类型)。

程序的内存分段布局

典型布局自上而下(地址从高到低):栈(局部变量、函数参数、返回地址,向下增长)→ 堆(malloc/new,向上增长)→ .bss(未初始化或零初始化的全局/静态变量,加载时不占文件空间,启动时清零)→ .data(已初始化的全局/静态变量,初值存在 Flash/可执行文件里,启动时拷到 RAM)→ .rodata(只读数据:常量、字符串字面量)→ .text(指令)。.data/.bss/.rodata/.text 都是编译链接期确定的段,栈和堆是运行时才划分的动态区域,两者相向增长,撞上就是栈溢出或 malloc 失败。嵌入式里还要注意:const 全局量进 Flash(.rodata)而不是 RAM,所以能省 RAM;.data 的拷贝和 .bss 的清零都由启动文件在 main 之前完成,这也是启动时间里可控的一段。