在 C 语言里,const 是一个类型限定符,用来告诉编译器某个对象在初始化之后不能被修改。它并不是用来定义传统意义上的常量的宏,而是一种只读约束。比如 const int max = 100; 声明了一个只读变量 max,程序运行期间不能再通过 max 这个名字进行赋值。任何试图修改 const 对象的表达式都会在编译阶段报错,但这种保护主要发生在编译期,并不代表数据一定存放在只读内存区域。

如果把 const 和宏常量混为一谈,就很容易在数组大小、条件编译、链接属性等场景下产生误解。要准确理解它,需要从声明语法、指针组合、函数接口以及底层存储几个角度分别分析。
一、const 的核心语义:只读变量而非编译期常量
const 修饰变量后,变量的值仍然需要在声明时或初始化时确定,但之后就不能再通过这个变量名赋值。这种只读约束是编译期检查,编译器看到向 const 变量赋值的代码会直接报错。比如下面的声明,注释掉的赋值语句会被编译器拒绝。
#include <stdio.h>
int main(void) {
const int max = 100;
/* max = 200; 编译错误:不能给只读变量赋值 */
printf("max = %d, address = %p\n", max, (void *)&max);
return 0;
}
需要注意,C 语言中的 const 变量并不是编译期常量。它有自己的存储空间,可以取地址,也可以通过指针间接访问。这与 C++ 中的 const 对象有细微差异:在 C++ 中某些 const 对象可以参与编译期常量表达式,但 C 语言通常不把 const int 当作整数常量表达式使用。因此像 case 标签、全局数组长度等需要编译期常量的位置,不能直接使用 const 变量,这时需要用 enum 或 #define 代替。
由于 const 变量有地址,所以可以通过强制类型转换去掉 const 限定,再通过指针修改。不过 C 标准规定修改一个声明为 const 的对象属于未定义行为,实际程序中不应依赖这种做法。下面的例子只作为说明,不应在实际代码里出现。
#include <stdio.h>
int main(void) {
const int val = 10;
int *p = (int *)&val; /* 强制去掉 const 限定,不建议 */
/* *p = 20; 可能触发未定义行为,不要这样写 */
printf("val = %d\n", val);
return 0;
}
二、const 与指针的组合:分清指向常量和常量指针
const 与指针组合时最容易混淆,因为 const 放的位置不同,限制的对象也不同。常见的有三种写法:const int *p 表示指针 p 可以改变,但它指向的整数不能通过 p 修改;int * const p 表示指针 p 本身不能改变,但指向的整数可以修改;const int * const p 表示指针和指向内容都不可修改。
#include <stdio.h>
int main(void) {
int a = 10;
int b = 20;
const int *p1 = &a; /* 指向只读 int 的指针 */
/* *p1 = 30; 错误:不能修改所指内容 */
p1 = &b; /* 允许:指针本身可变 */
int *const p2 = &a; /* 只读指针,指向 int */
*p2 = 40; /* 允许:所指内容可变 */
/* p2 = &b; 错误:不能修改指针本身 */
const int *const p3 = &a; /* 指针和所指内容都只读 */
/* *p3 = 50; 错误 */
/* p3 = &b; 错误 */
printf("%d %d %d\n", *p1, *p2, *p3);
return 0;
}
阅读这类声明时,可以从变量名开始向两侧读。比如 int * const p 从 p 开始,先看到 const,说明 p 本身是常量,再看到 *,说明它是一个常量指针,指向 int。而 const int *p 从 p 开始,先看到 *,说明 p 是指针,再看到 const int,说明指向的是只读整数。这种读法能帮助快速判断哪些部分可以变。
实际开发中,函数参数常用 const int * 或 const char *,目的就是让函数内部不能修改调用者传入的数据,但指针本身可以改变。如果希望指针变量本身的指向也不能改,可以在形参中写成 int * const p,不过这种用法相对少一些。
三、const 在函数接口与结构体中的应用
把 const 用在函数参数上,是提升接口安全性的常见做法。比如标准库字符串函数 strlen 的原型可以理解为 size_t strlen(const char *s),它通过 const 明确表示函数不会修改字符串内容。调用方看到 const 参数,也能知道传入的数据不会被改写,这对于使用只读缓冲区或多线程共享数据尤其重要。
当指针参数既不想修改数据,也不想让函数保存该指针的副本时,可以使用 const 配合指针本身,不过通常 const char * 已经足够表明意图。更复杂的安全约束可以通过 const 多层指针来表达,例如 const int **pp 表示二级指针最终指向只读整数。
结构体成员也可以声明为 const,但它会带来一个约束:结构体变量在创建时必须给该成员提供初始值,之后整个结构体都不能再通过赋值修改这个成员。如果结构体变量本身不是 const,其他非 const 成员仍然可以修改。下面的例子展示了一个带 const 成员的结构体和只读指针参数。
#include <stdio.h>
struct Device {
const int id;
char name[20];
};
void show(const struct Device *dev) {
/* dev->id = 999; 错误:成员是只读 */
/* dev->name[0] = 'X'; 错误:通过 const 指针不能修改 */
printf("id=%d, name=%s\n", dev->id, dev->name);
}
int main(void) {
struct Device d = {101, "sensor"};
show(&d);
return 0;
}
需要注意,如果结构体包含 const 成员,编译器通常不会为结构体生成默认的赋值操作,因为赋值操作需要修改该成员,这与只读约束矛盾。所以这种结构体更适合在创建后保持固定标识信息,比如设备 ID、版本号等。使用只读成员可以让标识信息在对象的生命周期内保持不变。
四、常见误区与实用建议
第一种常见误区是把 const 当成 #define 的替代品。宏在预处理阶段进行文本替换,没有类型,也没有作用域;而 const 变量有明确的类型、作用域和存储期,编译器可以做类型检查。正因如此,如果只是需要一个简单的编译期常量,enum 或 #define 更合适;如果需要一个带类型的只读变量,const 更合适。
第二种误区是认为用 const 声明的全局变量一定放在只读内存。在大多数桌面系统中,const 全局变量可能被放入只读数据段,但标准并不强制。如果代码使用强制类型转换修改这类对象,可能触发段错误,也可能悄悄修改成功,这取决于平台和优化级别。因此,从可移植性角度出发,不要试图绕过 const。
还有一种情况是 const 与 volatile 组合使用。嵌入式开发中常见 volatile const int *status_reg,它表示程序不能通过这个指针修改寄存器值,但寄存器的值可能被硬件改变,所以每次访问都要从内存重新读取。这说明 const 限制的是代码的写权限,而 volatile 控制的是读取优化,两者并不冲突。
#include <stdio.h>
#define MAX 100
enum { SIZE = 100 };
int main(void) {
const int limit = 100;
int a[MAX];
int b[SIZE];
/* int c[limit]; 在需要编译期常量的场合不要使用 const 变量 */
printf("%zu %zu\n", sizeof(a), sizeof(b));
return 0;
}
综合来看,使用 const 的最大价值不在于提高运行速度,而在于让接口意图更清晰、减少意外修改。凡是函数参数指向的数据不需要在函数内改变,就应当加上 const;凡是局部变量初始化后就不再变化,也可以用 const 标记。这样代码在编译阶段就能暴露更多错误,长期维护成本也会降低。