C语言关键字—static/extern/volatile/const 笔记(上)
- C语言进阶
- 9小时前
- 20热度
- 0评论
C语言进阶 — 关键字详解(上)
C 语言共有 32 个关键字(C89)/ 44 个(C11)。本篇聚焦嵌入式开发中与存储、链接、优化直接相关的四个核心关键字:static、extern、volatile、const。
下篇:C语言关键字(下)—— typedef、sizeof、register、inline、enum、struct、union。
1 static 关键字
static 有两个作用:① 将变量分配到静态全局数据区;② 限定变量或函数的作用域。
1.1 修饰局部变量
普通局部变量在栈上分配,函数返回即销毁。static 修饰后,变量存放在静态全局数据区(已初始化 → .data 段,未初始化 → .bss 段),生命周期与整个程序相同,但作用域仍限于函数内部。
#include <stdio.h>
int func(void)
{
static int count = 0; // 只在第一次调用时初始化
return ++count;
}
int main(void)
{
printf("%d\n", func()); // 输出 1
printf("%d\n", func()); // 输出 2
printf("%d\n", func()); // 输出 3
return 0;
}
static 局部变量只初始化一次。后续函数调用不会重新赋值,而是保留上一次的值。
1.2 修饰全局变量
全局变量本身就在静态区分配,static 的作用是限制其作用域为当前文件,其他 .c 文件即使使用 extern 也无法访问。
// file_a.c
static int secret = 42; // 仅 file_a.c 内部可见
// file_b.c
extern int secret; // ❌ 链接报错:undefined reference
在多文件项目中,用
static 修饰全局变量可以实现模块化封装,避免命名冲突——类似于面向对象中的 private。
1.3 修饰函数
与修饰全局变量类似,static 函数仅在当前文件内可见,其他文件无法调用。
// driver_uart.c
static void uart_set_baudrate(uint32_t baud)
{
// 内部辅助函数,外部不可见
}
void uart_init(uint32_t baud)
{
uart_set_baudrate(baud); // ✅ 同文件内可调用
}
// main.c
extern void uart_set_baudrate(uint32_t baud);
uart_set_baudrate(9600); // ❌ 链接报错
uart_init(9600); // ✅ 正确调用公开接口
1.4 static 总结
| 修饰对象 | 存储位置 | 生命周期 | 作用域 |
|---|---|---|---|
| 局部变量 | 静态区(.data / .bss) |
程序运行期间 | 函数内部 |
| 全局变量 | 静态区 | 程序运行期间 | 仅当前文件 |
| 函数 | 代码段(.text) |
程序运行期间 | 仅当前文件 |
2 extern 关键字
extern 用来声明一个在其他文件中已经定义且没有被 static 修饰的全局变量或函数,使当前文件可以访问它。
2.1 声明外部变量
// config.c —— 定义
int system_clock = 72000000; // 72 MHz
// main.c —— 声明并使用
extern int system_clock; // 告诉编译器:这个变量在别处定义
printf("Clock: %d Hz\n", system_clock);
- 定义(Definition):分配内存,如
int a = 10; - 声明(Declaration):告知编译器类型和名字,不分配内存,如
extern int a;
一个变量只能定义一次,但可以声明多次。
2.2 声明外部函数
函数默认具有外部链接属性(除非被 static 修饰),因此函数声明中 extern 可省略:
// math_utils.c —— 定义(以下两种写法等价,extern 可省略)
int add(int a, int b) // ✅ 推荐写法,简洁
{
return a + b;
}
extern int add(int a, int b) // 加 extern 也行,但多余
{
return a + b;
}
// 因为编译器通过有无 {} 就能区分声明与定义,函数定义天生就是外部链接
// main.c —— 声明(两种写法等价)
extern int add(int a, int b); // 显式 extern
int add(int a, int b); // 省略 extern,效果一样
// 函数声明也默认 extern,所以两种写法等价
2.3 最佳实践:头文件
实际工程中,通常将 extern 声明放在头文件中,避免重复声明:
// config.h
#ifndef CONFIG_H
#define CONFIG_H
extern int system_clock;
extern void system_init(void);
#endif
// main.c
#include "config.h"
int main(void)
{
system_init();
printf("Clock: %d\n", system_clock);
return 0;
}
2.4 架构建议:用访问器函数替代 extern 变量
在实际架构设计中,尽量避免用
extern 直接暴露全局变量。它会导致模块间耦合度极高,任何文件都能随意读写,出了 Bug 难以追踪。
推荐方案:static 变量 + 访问器函数(Getter / Setter),这是嵌入式工程中最常用的封装模式——思路类似 C# 属性的 get / set 访问器,用函数控制对内部数据的读写。
// 2.h —— 只暴露接口,不暴露变量
void set_a(int value);
int read_a(void);
// 2.c —— 变量藏在模块内部
static int a = 10;
void set_a(int value)
{
mutex_lock(); // 可以在入口统一加锁
a = value;
mutex_unlock();
}
int read_a(void)
{
return a;
}
// 1.c —— 通过函数访问,无需关心变量细节
#include <stdio.h>
#include "2.h"
int main(void)
{
set_a(20);
printf("a = %d\n", read_a());
return 0;
}
static int a— 变量作用域限制在2.c内部,外部无法直接碰到set_a()/read_a()— 唯一访问入口,可以在函数内统一加锁、校验、日志- 外部文件只依赖
2.h的函数声明,不关心变量名、类型、存储方式,修改内部实现不影响调用方
两种方案对比:
| 方案 | 耦合度 | 线程安全 | 可维护性 |
|---|---|---|---|
extern int a; 直接暴露 |
高 — 谁都能改 | ❌ 无保护 | 差 — 改名/改类型要改所有文件 |
static + 访问器函数 |
低 — 只依赖接口 | ✅ 可在函数内加锁 | 好 — 内部随便改,接口不变就行 |
extern 用于声明函数没问题(头文件天天在用);但共享变量时,优先用 static + 访问器函数封装,把 extern 变量当作最后手段。
// C# —— 语言级访问器
private int _a;
public int A {
get { return _a; }
set { _a = value; }
}
2.5 extern 与 static 的对比
| 特性 | extern |
static |
|---|---|---|
| 作用 | 引用外部符号 | 限制作用域 |
| 链接属性 | 外部链接(跨文件可见) | 内部链接(仅当前文件) |
| 典型用途 | 跨文件共享变量/函数 | 模块内部封装 |
3 volatile 关键字
volatile 是嵌入式开发中最重要的关键字之一。不正确使用会导致程序在优化后出现"诡异"Bug。
3.1 作用
volatile 告诉编译器:这个变量的值可能在程序控制流之外被改变,每次访问都必须从内存重新读取,禁止优化。
3.2 三大使用场景
场景一:硬件寄存器
// 外设状态寄存器,硬件会随时修改其值
volatile uint32_t *status_reg = (volatile uint32_t *)0x40021000;
// 轮询等待硬件就绪
while ((*status_reg & 0x01) == 0) {
// 若不加 volatile,编译器可能只读一次就"认为"值不变
// 导致死循环
}
场景二:中断服务程序(ISR)修改的变量
volatile int flag = 0;
// 中断服务函数
void TIM2_IRQHandler(void)
{
flag = 1; // 在中断中被修改
}
// 主循环
int main(void)
{
while (!flag) {
// 若不加 volatile,编译器认为 flag 在循环中不会变
// 优化后可能变成 while(1)
}
printf("中断触发!\n");
return 0;
}
场景三:多线程 / RTOS 共享变量
volatile int shared_data = 0;
// 任务 A
void task_a(void *arg)
{
while (shared_data == 0) {
// 等待任务 B 修改
}
}
// 任务 B
void task_b(void *arg)
{
shared_data = 1; // 通知任务 A
}
3.3 volatile 不能保证原子性
volatile 只防止编译器优化,不能防止多线程竞争。对于需要原子操作的场景,还需要配合关中断或互斥锁。
volatile int counter = 0;
// ❌ 这不是原子操作!即使加了 volatile
void ISR(void)
{
counter++; // 实际是 读→加→写 三步操作
}
// ✅ 正确做法:关中断保护
void safe_increment(void)
{
__disable_irq();
counter++;
__enable_irq();
}
3.4 const volatile 组合
看似矛盾,实际很有用:程序不能修改(const),但硬件会改变(volatile)。
// 只读状态寄存器:程序不应写入,但硬件会更新
const volatile uint32_t *chip_id = (const volatile uint32_t *)0x1FFFF7E8;
printf("Chip ID: 0x%08X\n", *chip_id);
4 const 关键字
const 表示只读——被修饰的变量不可通过该标识符修改。
4.1 修饰普通变量
const int MAX_SIZE = 100;
MAX_SIZE = 200; // ❌ 编译报错
| 特性 | const | #define |
| :--- | :--- | :--- |
| 类型检查 | ✅ 有 | ❌ 无(纯文本替换) |
| 调试可见 | ✅ 有符号名 | ❌ 已被替换 |
| 作用域 | 遵循 C 作用域规则 | 从定义处到文件末尾 |
| 内存 | 可能占用(编译器可优化掉) | 不占用 |
嵌入式中常量推荐优先使用 #define 或 enum(不占 RAM),但涉及类型安全时用 const。
4.2 const 与指针
这是笔试/面试的高频考点。记忆口诀:const 靠近谁,谁就不能变。
int a = 10, b = 20;
const int *p1 = &a; // 指向 const int 的指针 → *p1 不能改,p1 能改
int const *p2 = &a; // 同上(const int 与 int const 等价)
int *const p3 = &a; // const 指针 → p3 不能改,*p3 能改
const int *const p4 = &a; // 双重 const → *p4 和 p4 都不能改
| 声明 | 指针可变 | 指向的值可变 |
|---|---|---|
const int *p |
✅ | ❌ |
int *const p |
❌ | ✅ |
const int *const p |
❌ | ❌ |
4.3 修饰函数参数
用于告诉调用者:函数不会修改你传入的数据。
// 承诺不修改 src 指向的内容
void uart_send(const uint8_t *src, uint16_t len)
{
for (uint16_t i = 0; i < len; i++) {
UART_TX_REG = src[i];
// src[i] = 0; // ❌ 编译报错,const 保护
}
}
4.4 修饰函数返回值
const char *get_version(void)
{
return "v1.0.0"; // 返回的字符串字面量不可修改
}
char *str = get_version(); // ⚠️ 编译器警告
const char *str = get_version(); // ✅ 正确
5 自测题
用以下题目检验对 static、extern、volatile、const 的理解。
题 1:static 局部变量
以下代码输出什么?
#include <stdio.h>
void counter(void)
{
static int n = 0;
int m = 0;
n++;
m++;
printf("n=%d, m=%d\n", n, m);
}
int main(void)
{
counter();
counter();
counter();
return 0;
}
点击查看答案
```
n=1, m=1
n=2, m=1
n=3, m=1
```
n 是 static 局部变量,只初始化一次,每次调用累加保留;m 是普通局部变量,每次调用都重新初始化为 0。
题 2:static 全局变量的作用域
以下代码能否编译通过?为什么?
// sensor.c
static int raw_value = 0;
void sensor_read(void) {
raw_value = ADC_Read();
}
// main.c
extern int raw_value;
int main(void) {
printf("%d\n", raw_value);
return 0;
}
点击查看答案
**编译能通过,但链接会报错**:undefined reference to 'raw_value'。
static 将 raw_value 限制为 sensor.c 内部链接,其他文件用 extern 也无法访问。正确做法是提供一个公开的访问函数:
```c
// sensor.c
static int raw_value = 0;
int sensor_get_value(void) { return raw_value; }
```
题 3:extern 声明 vs 定义
以下两行代码,哪个是声明,哪个是定义?
int count;
extern int count;
点击查看答案
- int count; — 定义(分配内存,默认初始化为 0)
- extern int count; — 声明(不分配内存,引用别处的定义)
如果两行在同一个文件中,不会冲突——声明和定义可以共存。如果在不同文件中各写 int count;,则会重复定义,链接报错。
题 4:extern 与函数
以下两种写法有区别吗?
extern void init(void);
void init(void);
点击查看答案
**没有区别。** 函数声明默认就是 extern(外部链接),编译器通过有无 {} 区分声明与定义,所以 extern 在函数声明中是多余的,两种写法完全等价。
题 5:volatile 必要性判断
以下代码在 GCC -O2 优化下可能出现什么问题?如何修复?
int ready = 0;
void USART1_IRQHandler(void)
{
ready = 1;
}
int main(void)
{
while (!ready) {
// 等待中断
}
printf("Data received!\n");
return 0;
}
点击查看答案
**问题**:编译器优化后,main 中的 while (!ready) 可能只读一次 ready 的值。由于在 main 的控制流中 ready 没有被修改,编译器会认为它永远为 0,优化成 while(1) 死循环。
**修复**:加 volatile:
```c
volatile int ready = 0;
```
volatile 强制编译器每次循环都从内存重新读取 ready。
题 6:volatile 与原子性
以下代码是否线程安全?为什么?
volatile int counter = 0;
void TIM2_IRQHandler(void) { counter++; }
void TIM3_IRQHandler(void) { counter++; }
点击查看答案
**不安全。** counter++ 不是原子操作,实际是"读→加→写"三步。如果 TIM2 中断执行到"读"之后被 TIM3 打断(嵌套中断),两次自增可能只生效一次。
volatile 只保证每次都从内存读写,不保证操作的原子性。修复方法:
```c
void safe_increment(void)
{
__disable_irq();
counter++;
__enable_irq();
}
```
题 7:const 与指针
以下代码哪行会编译报错?
int a = 10, b = 20;
const int *p1 = &a;
int *const p2 = &a;
*p1 = 100; // A
p1 = &b; // B
*p2 = 100; // C
p2 = &b; // D
点击查看答案
- **A 报错** ❌ — p1 指向 const int,不能通过 *p1 修改值
- **B 正确** ✅ — p1 本身可变,可以指向别处
- **C 正确** ✅ — p2 是 const 指针,但指向的值可以修改
- **D 报错** ❌ — p2 本身是 const,不能重新指向
口诀:const 靠近谁,谁就不能变。const int *p → 值不能变;int *const p → 指针不能变。
题 8:const volatile 组合
const volatile uint32_t *reg 是什么意思?什么场景会用到?
点击查看答案
- const → 程序代码不允许写入
- volatile → 值可能被硬件改变,每次必须重新读取
典型场景:只读硬件寄存器,例如芯片 ID 寄存器、ADC 数据寄存器(只读模式)。程序不应该写这些寄存器,但每次读取的值可能不同。
```c
const volatile uint32_t *chip_id = (const volatile uint32_t *)0x1FFFF7E8;
```
题 9:综合题
找出以下代码中的所有错误:
// module.c
static int mode = 0;
const int MAX_RETRY = 3;
// main.c
extern int mode;
extern const int MAX_RETRY;
int flag = 0;
void EXTI0_IRQHandler(void) {
flag = 1;
}
int main(void)
{
while (!flag) {}
for (int i = 0; i < MAX_RETRY; i++) {
printf("mode = %d\n", mode);
}
return 0;
}
点击查看答案
共 2 个错误:
**错误 1**:extern int mode; — mode 在 module.c 中被 static 修饰,是内部链接,其他文件无法 extern 引用,链接报错。
**错误 2**:int flag = 0; — flag 在中断中被修改,但没有 volatile 修饰。-O2 优化下 while (!flag) 会变成死循环。应改为 volatile int flag = 0;。
extern const int MAX_RETRY; 是正确的——MAX_RETRY 没有 static 修饰,可以跨文件访问。