C语言关键字—static/extern/volatile/const 笔记(上)

C语言进阶 — 关键字详解(上)

C 语言共有 32 个关键字(C89)/ 44 个(C11)。本篇聚焦嵌入式开发中与存储、链接、优化直接相关的四个核心关键字:staticexternvolatileconst

下篇:C语言关键字(下)—— typedefsizeofregisterinlineenumstructunion


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 vs #define

| 特性 | const | #define |
| :--- | :--- | :--- |
| 类型检查 | ✅ 有 | ❌ 无(纯文本替换) |
| 调试可见 | ✅ 有符号名 | ❌ 已被替换 |
| 作用域 | 遵循 C 作用域规则 | 从定义处到文件末尾 |
| 内存 | 可能占用(编译器可优化掉) | 不占用 |

嵌入式中常量推荐优先使用 #defineenum(不占 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 自测题

用以下题目检验对 staticexternvolatileconst 的理解。


题 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
```

nstatic 局部变量,只初始化一次,每次调用累加保留;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'

staticraw_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 正确** ✅ — p2const 指针,但指向的值可以修改
- **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;modemodule.c 中被 static 修饰,是内部链接,其他文件无法 extern 引用,链接报错。

**错误 2**:int flag = 0;flag 在中断中被修改,但没有 volatile 修饰。-O2 优化下 while (!flag) 会变成死循环。应改为 volatile int flag = 0;

extern const int MAX_RETRY; 是正确的——MAX_RETRY 没有 static 修饰,可以跨文件访问。