面灵AI→

龙旗科技 嵌入式面经 C 语言限定符与内存布局

时间
2026-09
来源
牛客网

《面试题目》

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

《参考解析》

const 与指针的三种组合从右往左读:const int *p(等价 int const *p)是「指向常量的指针」,*p 不能写、p 可以改;int * const p 是「常量指针」,p 不能改、*p 可以写;const int * const p 两者都不能改。记忆口诀是从标识符出发由内向外、从右向左读:先看 p 右边有没有 const(有就是 p 本身不可改),再看左边。回答时最好各写一行例子,并指出 const 修饰的是「经这个名字的访问路径」,对象如果在别处有非 const 别名仍然可以被改,所以它不是并发保护。

static 用在局部变量上是「静态存储期 + 块作用域」:写成 static int n; 后,变量放在 .data(有初值)或 .bss(无初值)里,程序启动时(准确说是加载时由启动代码)只初始化一次,函数返回不销毁,下次调用保留上次的值,但名字仍只在函数体内可见,其他函数访问不到。要注意两点:它让函数不再可重入、也不再线程安全,多任务环境下共享这个变量必须加锁;初值必须是编译期常量,不能是运行时计算的表达式。

文件作用域的封装靠 static 或匿名命名空间:在全局变量或函数前加 static,它的链接属性从 external 变成 internal,只在本编译单元内可见,其他 .c 文件即使声明 extern 也链接不到。好处是彻底消除重名冲突——两个模块可以各有一个 static void init() 互不干扰,同时把实现细节藏起来,只经头文件暴露必要接口,这也让链接器能做更好的优化(比如内联和删除未使用函数)。C++ 里更推荐用匿名命名空间,语义更清晰。

volatile 管的是「编译器不许假设内存没被别人改」:典型场景有四种——中断服务程序与主流程共享的标志变量、内存映射的外设寄存器、被 DMA 直接写入的缓冲区、多线程共享但没用同步原语的变量(这条最容易出事)。不开 volatile 时编译器可能把「循环里反复读同一个变量」优化成只读一次(死循环出不来),或者把多次写合并、把看似无用的读删掉。但 volatile 只保证「每次访问都真的去读/写内存」,不保证原子性、不保证顺序、不提供内存屏障,所以 volatile 绝不能替代互斥量或原子操作:像 counter++ 这种读改写,volatile 照样会丢更新;多核下还有可见性和重排序问题,得用 atomic 或加锁 + 屏障。

中断共享标志和外设寄存器的共同点是「值随时可能被外部改」:共享标志要声明成 volatile sig_atomic_t(C 标准保证对它的读写是原子的),并且在中断和主流程两侧都带 volatile,否则优化等级一开就可能永远读到旧值;外设寄存器要用 volatile 修饰的指针访问(内核里的 readl/writel 就是这一套,还额外带了屏障),不要用普通指针强转。两者的共同点是编译器无法从代码本身推断「外部会改这块内存」,必须由程序员显式告诉它——区别在于外设寄存器还要考虑访问宽度和顺序(读清中断标志、写 1 清位这类硬件语义)。

数组名不是指针变量:数组名代表一块连续对象的整体,sizeof(arr) 得到整个数组的字节数,&arr 的类型是「指向数组的指针」;指针变量是一个独立对象,sizeof(p) 是指针本身的宽度(32 位 4 字节、64 位 8 字节)。数组名不可赋值(arr = p 编译报错),指针可以重新指向。在大多数表达式里数组名会「退化」成指向首元素的指针(arr 等价于 &arr[0]),所以能传给指针形参、能做指针运算,但退化不等于它是指针——退化不发生在 sizeof、& 和字符串字面量初始化数组这三种场合。

数组形参只是「长得像数组的指针」:void f(int a[10]) 和 void f(int a[]) 和 void f(int *a) 在编译器眼里完全一样,长度信息被直接丢弃,函数体内 sizeof(a) 是指针大小而不是数组大小,这也是为什么不能在里面用 sizeof(a)/sizeof(a[0]) 求元素个数。要让函数知道长度,调用方必须额外提供:要么多传一个 size_t n 参数,要么约定哨兵值(如字符串的 \0),要么把长度和指针包进一个结构体一起传。用 static 修饰数组形参(int a[static 10])只是给编译器一个「至少这么长」的提示用于优化,运行时并没有边界检查。

加载后的段布局:.text 放机器指令,只读可执行,通常在 Flash(嵌入式)或代码段(Linux 进程);.rodata 放字符串常量和 const 数据,只读;.data 放已初始化的全局/静态变量,初值保存在 Flash 或可执行文件里,启动时拷进可写 RAM;.bss 放未初始化或初值为 0 的全局/静态变量,只记大小不占文件空间,启动时清零;栈从高地址向下生长、存放函数调用帧和局部变量,堆从 .bss 之后向上生长、由 malloc 管理。嵌入式上还要算上中断向量表和启动代码。栈和堆相向生长、中间就是「还没用的空闲 RAM」,两者撞上就是栈溢出——这也是为什么不建议在 MCU 上频繁用堆。