Variable
定义: C中的变量 = 一个有名字的,可寻址的存储区域 + 一组由类型定义的解释规则
拆开来看
| 维度 | 含义 |
|---|---|
| 名字 | 符号(symbol),供程序员与编译器使用 |
| 存储区域 | 实实在在的内存(或寄存器) |
| 类型 | 决定:大小,对齐,读写方式,解释方式 |
变量不是值,值只是变量在某一刻内存中的比特状态
| 概念 | 含义 |
|---|---|
| 标识符(identifier) | 名字本身,比如x |
| 对象(object) | 一块存储区域 |
| 变量(variable) | 具名对象(有名字的object) |
int x = 10;
x:标识符int x:定义了一个对象x:这个对象是变量(具名对象)
变量的本质分类维度
C中变量不是靠一种方式分类的,而是多维度叠加的
按存储期(Storage Duration)分类(核心)
这是理解C的第一关键点
自动存储期(automatic)
void f() {
int x = 10;
}
| 特性 | 说明 |
|---|---|
| 分配时间 | 进入作用域时 |
| 释放时间 | 离开作用域时 |
| 典型位置 | 栈 |
| 默认初始化 | 未初始化(垃圾值) |
静态存储期(static)
static int x;
int y;
| 特性 | 说明 |
|---|---|
| 生命周期 | 整个程序运行期间 |
| 分配位置 | .data/.bss |
| 默认初始化 | 初始化 0 |
| 作用域 | 看声明位置 |
包括:全局变量,static局部变量,文件级static
动态存储期(dynamic)
int* p = malloc(sizeof(int));
| 特性 | 说明 |
| 分配 | 运行时 |
| 释放 | free |
| 管理者 | 程序员 |
| 存储位置 | 堆 |
p是自动变量,*p指向的对象是动态存储期
按作用域(Scope)分类
块作用域(block scope)
{
int x;
}
只在{}内可见
文件作用域(file scope)
int g;
从声明点到文件末尾
函数原型作用域
void f(int x);
x只在参数列表中有效
按链接属性(Linkage)分类
这是编译/链接阶段的核心概念
| 链接属性 | 说明 |
|---|---|
| 无链接 | 仅在当前作用域 |
| 内部链接 | 当前翻译单元 |
| 外部链接 | 跨翻译单元 |
static int x;
int y;
链接属性决定:符号是否能被Id看见
按类型(Type)分类(值的解释规则)
int x;
float y;
struct S s;
int* p;
类型决定
- 占用字节数
- 对齐方式
- 运算规则
- 是否可修改
- 是否可别名(alias)
类型是“解释规则”,不是内存本身
定义 vs 声明
定义(Definition)
分配存储空间
int x;
声明(Declaration)
告诉编译器“它存在”
extern int x;
一个变量只能定义一次,可以声明多次
初始化的本质
自动变量
int x; // 未初始化
// 读取未初始化的自动对象的值是未定义行为
静态变量
static int x; // 自动初始化为0
原因不是“语法规则”,而是:静态存储期变量在程序加载时由运行时系统清零
变量与内存
int x = 10;
本质
地址 A:
0000000A 00 00 00
x-> 符号 -> 地址A10-> 比特模式int-> 如何解释这4个字节
C中的变量是:由编译器管理的符号 + 存储期 + 作用域 + 链接属性 + 类型解释规则,本质是对一段内存的而语言级约束
Constant
C语言中没有“真正不可变的变量”这个概念
常量 != 只读内存,常量 != const
在C中,“常量”是一个语义概念,而不是一个统一的实体
C中的常量分类
字面量(Literal Constant)
42
3.14
'a'
"hello"
本质:编译期就确定的值
分类
| 类型 | 示例 | 存储 |
|---|---|---|
| 整数字面量 | 42, 0xFF | 通常直接嵌入指令 |
| 浮点字面量 | 3.14 | 只读数据段 |
| 字符字面量 | 'a' | 整型常量 |
| 字符串字面量 | "hello" | 静态存储区(只读) |
char *p = "hello";
p[0] = 'H'; // 未定义行为
字符串字面量不是数组变量,而是静态只读对象
#define宏常量
#define N 100
本质:预处理阶段的纯文本替换
int a[N]; // 等价于 int a[100];
特点:
- 不占用存储
- 无类型
- 无作用域(作用于文本范围)
- 不是调试器友好
编译器根本不知道它“是常量”
const修饰的对象
const int x = 10;
const不是“常量”,而是“通过该名字不可修改”
也就是说
const int x = 10;
x是一个变量- 有存储
- 可能被放在只读区,也可能不
反例
int y = 20;
const int *p = &y;
*p = 30; // 编译错误
y = 30; // 合法
const约束的是访问路径(lvalue),不是对象本身
enum枚举常量
enum { A = 1, B = 2 };
A,B是编译期整型常量- 不占内存
- 可用于数组长度、case标签
int arr[A]; // 合法
在很多底层代码中,enum被当作类型安全的宏常量
常量和变量在内存中的真实样子
典型内存布局
.text 代码
.rodata 字符串字面量 / const 对象(可能)
.data 已初始化全局变量
.bss 未初始化全局变量
.stack 自动变量
.heap 动态分配
C标准并不规定const一定放在只读区,这是编译器行为,不是语言保证
初始化 vs 赋值
int x = 10; // 初始化
x = 20; // 赋值
const int n = 10;
int arr[n]; // 在C中不一定合法,C90/C99不允许,C99VLA或C11开启VLA允许
原因
n不是编译期常量- 即使
n的值不变,它仍是运行期对象
GCC扩展可能允许,但这不是标准C
编译期 vs 运行期
- 编译期:程序还没有变成可执行文件之前,所有“由编译器完成的计算与决策”
- 运行期:程序已经开始执行,由CPU + 操作系统参与的所有行为
编译期行为
严格说,编译期 != 只有compiler,它是一个阶段链
1. 预处理期(Preprocessing)
#define N 10
#include <stdio.h>
发生的事情:
- 宏替换
- 条件编译
- 头文件展开
这一阶段
- 没有类型
- 没有变量
- 没有作用域
- 只是文本
2. 编译期(狭义的compiler)
编译器做的事
- 词法/语法分析
- 语义分析(类型检查)
- 常量折叠(const folding)
- 生成中间表示(IR)
- 优化
- 生成目标代码(.o)
int x = 3 * 4 + 5; // 编译期算成17
enum { N = 16 };
int a[N]; // OK
但
const int n = 16;
int a[n]; // 标准C不行,n是运行期对象,不是编译期常量
3. 链接期(Linking)
- 符号解析
- 地址重定位
- 合并段
extern int g;
此时
- 才知道
g的最终地址 - 才能确定跨文件符号
运行期
运行期开始于
程序被 OS 加载
↓
main() 开始执行
运行期发生的事情
- 栈帧建立/销毁
- 变量分配
- 函数调用
- 分支跳转
- 内存读写
- 系统调用
- 线程调度
总结
永远问三个问题
- 这东西有没有对象
- 它的值是不是在编译时就确定
- 是否需要运行时指令来获得
只要第3个是YES,那就是运行期
编译期决定“形状”,运行期决定“状态”
- 编译期:代码长什么样
- 运行期:代码怎么跑
| 问题 | 编译期 | 运行期 |
|---|---|---|
| 是否有 CPU 指令在跑 | ❌ | ✅ |
| 是否有内存地址 | 部分(符号) | 全部 |
| 是否能访问变量值 | ❌ | ✅ |
| 是否能调用函数 | ❌ | ✅ |
| 是否能分配栈 | ❌ | ✅ |
| 是否能决定数组大小 | ✅(需常量表达式) | ❌ |
| 是否能优化代码 | ✅ | ❌ |
从汇编角度的一句判断法:只要需要生成“运行时指令去算”的,就是运行期
int f(int n) {
int a[n]; // 运行期分配
}
编译器必须生成
- 计算
n - 调整
rsp - 恢复栈
编译器、编辑器、语言语义、程序分析边界
编辑器/IDE可以在编辑期发现一部分编译器必然错误,但原则上无法在编辑期可靠地发现依赖运行期状态的错误\
为什么编辑器能报编译期错误
编译器并不是猜,而是在调用编译器能力
现代编辑器(VS Code, CLion, Vim + clangd)做的事情是
- 实时分析源码
- 运行语法分析 + 语义分析
- 使用与编译器相同或相似的前端
例如
int x = "abc"; // 类型错误
这是一个不依赖输入,不依赖环境,对所有执行路径都成立的错误
这种错误在任何运行期都会失败 -> 可在编辑期100%确定
这类错误的共同特征
- 不需要执行程序
- 不依赖输入
- 不依赖分支
- 对所有路径成立
因此,编辑器可以提前报错,本质是静态分析
为什么编辑器不能报出运行期错误
典型例子
int f(int x) {
return 10 / x;
}
问题
- 当
x == 0-> 崩溃 - 当
x != 0-> 正常
编辑器在编辑器无法知道
x是来自哪里- 用户输入是什么
- 走哪条分支
这是一个理论上的不可能性,这里不是“技术不够好”,而是计算理论极限
核心问题:停机问题(Halting Problem):不存在一个程序,能够对任意程序,判断它是否在运行时发生错误
运行期错误本质上是
- 程序行为
- 状态演化
- 动态路径
这些都属于不可完全静态判定的问题
为什么有时编辑器好像能发现运行期问题
编辑器有时候会提示空指针、越界等行为,这是因为,编辑器做的是保守静态分析
例如
int *p = NULL;
*p = 10;
这是100%必然的运行期错误,不需要输入,不存在分支,所有路径必崩,因此可以在编辑期报错
但一旦引入条件,就不再是”必然“
if (p != NULL)
*p = 10;
是否安全取决于p的来源,取决于执行路径;编辑器此时只能给warning或保持沉默
编辑器/编译器的”能力边界“
能做到的(静态、确定性)
- 语法错误
- 类型错误
- 必然的未定义行为
- 明显越界(常量索引)
- 明显空指针解引用
做不到的(动态、路径相关)
- 输入相关崩溃
- 数据竞争(大多数情况)
- 逻辑错误
- 时序问题
- 资源泄漏(完全精确)
这也就是为什么需要”运行期工具“,既然编辑期/编译期有理论极限,工程上怎么解决
运行期检测(动态分析)
- AddressSanitizer
- Valgrind
- ThreadSanitizer
它们的特点:需要执行,基于真实路径,有性能开销(插桩)
类型系统作为”前移防线“ 例如
const,restrict,不可空指针(其他语言),能在编译期消灭一类运行期错误