
我当年刚学C语言的时候看到const这个关键字第一反应是“哦就是定义常量的嘛”跟#define差不多。直到后来在一个项目里因为没搞清const和指针的排列组合把一个字符串数组当参数传进函数后不小心改了内容导致整个模块的状态错乱排查了两天才发现是const限定符加错位置以及在函数接口处丢掉const导致的。那次之后我才认真把const的用法、底层逻辑和实际项目里的规范彻底捋了一遍。所以这篇博文我想把C语言关键字const的作用从头到尾说透包括它的编译期机制、不同修饰位置的语义差异、与#define的区别、在实际工程里的应用套路以及我踩过的坑和排错经验。这个内容特别适合刚学C语言的学生、写嵌入式固件的工程师以及从脚本语言转过来、对“只读”概念理解得不够深的开发者。相信你把这篇文章读完后再看项目里带const的代码会顺眼很多自己也敢在接口设计里用const来明确语义。1. 为什么会有const先搞清楚它在解决什么问题1.1 变量的“可变”与“不可变”C语言里的一把锁C语言里普通变量默认就是可读可写的。你想什么时候改它的值就什么时候改编译器不会拦你除非你主动加一个限定符告诉它这个变量只准读不准写。const就是这个“告诉编译器”的动作。但很多人会问既然我想让一个值不被修改那我写代码的时候小心一点别去改它不就行了现实是人的注意力是不可靠的尤其是当项目到了几千几万行、参与的人有好几个的时候。你很难保证每个接手代码的人都知道“这个值后面会被用到千万别不小心改了它”。一旦有人在某处写了个buf[pos] \0;之类的操作而buf其实是需要保持不变的配置数据轻则数据错乱重则程序崩溃或者逻辑静默出错。const解决的就是这个问题它在编译层面给变量加了一把锁任何写入操作都会在编译期直接报错从机制上杜绝误改。类比一下就是普通变量像是公共笔记本每个人都能在上面写字const变量像是贴了“只读”标签的文件谁想改都会收到系统提示“没有权限”。这种错误如果等编译期报出来你修一下就完事要是编译期不报、运行期才炸那排查成本就翻了好几倍。从工程管理的角度看const最大的价值其实不在于“定义常量”这个表面功能而在于它把“这个数据允许被谁以什么方式使用”这个约定从口头文档落到了编译器能检查的代码层面。代码审查的时候看到函数参数带const接手的人立刻就知道这个函数承诺不改动传入的数据看到不带const就知道这个函数可能会修改使用的时候就得留个心眼。1.2 从编译器视角看const它到底做了什么如果你在编译层面去理解const会发现它的工作机制很有意思。当你写const int max_len 100;时编译器会做两件事。第一它会把max_len这个变量在符号表里标记为“只读”。之后你在同一作用域里写max_len 200;编译器会直接给出类似“assignment of read-only variable”的错误这是它在编译期执行的检查动作。第二如果max_len是局部变量编译器通常会把它当作一个普通的只读变量处理也就是说它的值可能存放在栈上有自己独立的地址。但如果它是全局的、并且你在代码里没有对它取地址那么编译器有很大概率把它优化成编译期常量也就是说所有使用max_len的地方会直接替换成100这个字面量根本不会在数据段里为它分配空间。这里有个特别容易让人误解的点const限定的是“C语言语法层面不能作为左值被赋值”它并你不保证这个变量在运行时所在的地址一定处于只读存储区。你可以通过强制类型转换或者另存一个指针来绕过编译器检查但那是未定义行为程序可能照常运行也可能直接段错误。这取决于编译器的优化策略、变量存储位置以及你运行的操作系统对内存的保护策略。所以工程上老老实实别去改const变量不仅是为了编译通过也是为了运行时安全。2. const的几种用法一张表理清楚2.1 修饰普通变量给常量一个“合法身份”最简单的用法就是const int kCount 10; int const kMaxSize 1024;这两种写法是等价的const放在类型前面还是类型后面都表示“这个变量本身是只读的”。我个人的习惯是const int kCount因为读起来自然“常量整型kCount”。不过你完全可以用int const只要团队里统一风格就行。使用const修饰普通变量需要注意几点它必须在定义时就初始化因为一旦定义完成你就没法再往它里面写值了。如果你在头文件里声明一个全局const变量需要注意它的链接属性。C语言里全局const变量默认是外部链接的也就是说如果多个源文件都包含同一个头文件可能会造成重复定义。所以工程上一般有两种做法放在.c文件里然后用extern int const g_cnt;在头文件里声明或者用static const限制作用域。如果你用const int len 10;定义数组大小这在C89里是编译不过的因为C89要求数组长度必须是编译期整型常量表达式而const变量在C89里不算编译期常量。但C99之后变长数组VLA允许使用变量作为数组长度所以很多新代码就无所谓了。不过为了安全和可移植性定义数组大小我建议还是用宏或枚举。2.2 修饰指针const放在*左边还是右边效果完全不同这是const最容易让人翻车的地方。总有初学者搞不清const int *p和int *const p有什么区别甚至有的人写了几年代码还是似懂非懂。我给你的口诀就一句话const修饰的是它左边紧挨着的类型关键字但如果const右边紧挨着那它修饰的是指针变量本身*。更直白的记忆方式是const int *p读作“指向const int的指针”。重点是*p不能改p可以改。也就是说你可以让指针指向别的地方但不能通过这个指针修改它指向的数据。int *const p读作“指向int的const指针”。重点是p本身不能改*p可以改。也就是说指针一旦指向某个地址以后就不能再改指向了但你可以通过这个指针修改那个地址上的数据。const int *const p指针本身和它指向的数据都不能改。举个例子你就明白了int a 1, b 2; const int *p1 a; *p1 10; // 编译错误p1指向的数据是const p1 b; // 合法指针变量本身可以改 int *const p2 a; *p2 20; // 合法可以修改a的值 p2 b; // 编译错误p2本身是const const int *const p3 a; *p3 30; // 编译错误 p3 b; // 编译错误我整理了一张表方便对照记忆写法指针本身指向的数据典型用途const int *p/int const *p可改不可改读取字符串、数组防止误写int *const p不可改可改固定缓冲区首地址但内容可变const int *const p不可改不可改硬件寄存器映射、固件配置指针int *p可改可改默认普通指针没必要加const很多新手搞混其实是把const int *p和int *const p弄反了。我建议你每次写的时候心里默读一遍“const int *p是‘指针指向const整型’int *const p是‘指针本身是const整型的指针’”。等读顺了就不容易错了。2.3 const与#define同样是常量为什么我更推荐const很多C语言的早期教材都会教学生用#define定义常量比如#define MAX_SIZE 100。但实际上在大多数场景下const是比#define更合适的选择。#define是预处理器层面的文本替换它在编译之前就把代码里的MAX_SIZE全部替换成100这带来几个问题它没有类型信息不会做类型检查。比如你把100这个整型字面量替换到一个需要float的地方可能产生隐式转换警告甚至精度问题而编译器不会帮你指出这个警告是因为宏替换导致的。它不占存储、没有地址。你没法对宏定义的常量取地址也没法把它传给一个需要const int *参数的函数。这在某些场景下非常别扭。宏的调试体验比较差。你写MAX_SIZE调试器里看到的是100但你想看这个宏是怎么来的还得去翻头文件。const变量的优势是它有一个真正的类型编译器会做类型检查它有作用域规则可以定义在函数内部或全局它占用存储或者被优化成编译期常量可以取地址传参调试器里能直接看到这个变量名和值。但要注意const和#define并不是完全互斥的。在C89编译器仍然广泛使用的嵌入式领域变长数组不受支持的情况下定义数组大小还是经常用#define或enum。因为这些场合需要的是“编译期整型常量”而const int在C89里不是。我的建议是定义数组大小或case标签等需要编译期常量的地方用#define或enum。定义有类型、需要传址、需要作用域约束的对象优先用const变量。如果一个常量在你的代码里只会作为字面量出现、不需要取地址#define其实也没什么问题但项目规范最好统一。3. const在真实项目中的高频应用场景3.1 函数形参加const只读保护和代码自文档化写函数的时候形参要不要加const是很多C语言初学者不会主动去想的。但实际上这是const在工程上最值得使用的场景。假设你有一个函数用来计算某个数组的元素和int sum_array(int arr[], int n);看到这个声明使用者只知道它接收一个数组和长度并不知道函数内部会不会修改数组内容。如果函数内部不小心写了arr[0] 0;不会报错但这个副作用可能让调用者一脸懵。如果改成int sum_array(const int arr[], int n);这个声明是在告诉所有人这个函数承诺不会修改arr指向的数据。编译器也会帮忙盯着如果你在函数体内写了arr[0] 0;立刻编译报错。这就是“自文档化”的含义。你不用写注释说“本函数不会修改数组”看签名就够了。还有一个实际好处当调用者的数据本来就以const的方式存在时只有当你的函数形参也是const编译器才允许你传入。如果你写的是int sum_array(int arr[], int n);然后试图传一个const int arr[]进去编译器会报类型不匹配的警告或错误因为函数有可能修改const数据。所以在接口设计阶段就加上const能让你的函数和调用方之间的约束更清晰减少“类型传递时const丢失”的问题。不过也要提醒一句不是所有地方都该加const。如果函数本身就要修改传入的数据比如初始化函数、排序函数那形参就不该加const否则你在函数内部还得强转不如一开始就别加。3.2 全局常量与配置文件避免硬编码魔法数字很多C项目都有配置文件或全局参数比如通信协议里的最大包长、超时时间、缓存区数量、传感器量程等。如果你直接在代码里写if (len 1024)这个1024就是所谓的“魔法数字”阅读代码的人不知道它干嘛的将来想改也麻烦。正面的做法是用有名字的常量const uint32_t kMaxPacketSize 1024; const uint32_t kDefaultTimeoutMs 500; const uint16_t kSensorCount 6;在嵌入式项目里这些配置常量我通常会集中放在一个头文件或一个专门的config.c里方便统一管理。注意如果放在头文件里记得用static const来限定作用域避免多个源文件包含后出现链接问题。或者你可以用extern const的方式在.c文件里定义在.h文件里声明。有人可能会说这些常量用#define不是更节省空间吗在绝大多数情况下const全局变量会被编译器优化掉直接用常量替换所有使用它的地方。就算它真的占了存储相对于“程序可读性和可维护性”的提升这点开销是完全可以接受的。只有在极端的资源受限单片机上才需要严格考虑存储布局问题那也是特殊项目单独优化的事情。3.3 常量数组、字符串和查表结构典型嵌入式/底层场景在嵌入式开发和底层驱动里查表lookup table是一种非常常见的编程方式。比如你有一个温度传感器要根据ADC采集值查对应的温度那么这张对应表是固定的不可能在运行过程中被修改。这时候就应该用const修饰static const uint16_t s_adc_to_temp_table[256] { 0, 25, 52, 80, // ... 默认数据 };把这个表放在static const里好处非常多明确告诉阅读代码的人这是一份只读数据别动它。对编译器来说它可能被放在只读数据段如Flash或只读内存区域运行时不占用宝贵的RAM。在单片机RAM以KB为单位的时代这个优化尤其重要。从安全角度说如果嵌入式系统支持MPU或MMU这块区域可以被设置为只读即使代码里出现缓冲区溢出或者野指针也改不了这块数据能在一定程度上减小漏洞影响范围。同样字符串字面量在C语言里本身就是以数组形式存放在只读存储区的。你用char *p hello;试图修改p[0]在很多平台上会段错误就是因为这个字符串实际在只读区域。正确的做法是用const char *p hello;这样不仅语义正确如果代码里不小心写了修改语句编译器也会直接报错而不是等到运行时崩溃。4. 实战一个完整的const改造案例4.1 改造前的代码看看哪里容易出问题我前面提到过以前我在一个模块协议解析的代码里因为const位置没放对出了个隐蔽的bug。我把这个案例简化后拿来当实操演示。假设你要写一个函数处理一帧协议数据。协议数据是以字节数组形式传入的包含头部、长度字段、负载和校验。你希望这个函数只读取数据不修改原始帧同时通过参数返回解析结果。改造之前很多人会这么写#include stdio.h #include stdint.h #define FRAME_HEADER 0xAA // 解析协议帧返回0成功-1失败 int parse_frame(uint8_t *frame, int len, uint16_t *payload_len, uint8_t *payload) { if (frame NULL || payload NULL) { return -1; } if (len 4) { return -1; } if (frame[0] ! FRAME_HEADER) { return -1; } *payload_len (frame[2] 8) | frame[3]; if (*payload_len len - 4) { return -1; } for (int i 0; i *payload_len; i) { payload[i] frame[4 i]; } return 0; }这段代码表面上看没大问题。但问题是frame形参是uint8_t *它允许函数内部修改帧数据。假设哪天你在这个函数里增加一段逻辑比如某种调试模式下要在帧里标记一下写了个frame[1] 0x55;那么调用方收到的原始帧就被篡改了。如果这个帧是多个模块共享的这就是一个定时炸弹。更麻烦的是调用方如果本身有一份const的帧数据它没法直接调用这个函数。比如你的帧数据可能是从Flash里读取出来的const数组那parse_frame(cfg_frame, sizeof(cfg_frame), ...)就会编译警告或者报错因为类型不匹配函数可能修改它。这时候你只能在调用处强转或者复制一份到RAM里纯属给自己添堵。4.2 改造过程和关键步骤改造的核心思路是把不需要修改的输入参数全部加const明确函数的只读约定。第一步分析函数参数。frame是只读的应该改成const uint8_t *frame。len是个值参数怎么改都不会影响实参不需要加const。payload_len和payload是输出参数需要函数往里面写数据不能加const。第二步检查函数体内有没有误修改frame的代码。我这段代码里没有那直接改形参声明就行。改造后// 解析协议帧返回0成功-1失败 // frame: 输入参数只读不会被修改 int parse_frame(const uint8_t *frame, int len, uint16_t *payload_len, uint8_t *payload) { if (frame NULL || payload NULL) { return -1; } if (len 4) { return -1; } if (frame[0] ! FRAME_HEADER) { return -1; } *payload_len (frame[2] 8) | frame[3]; if (*payload_len len - 4) { return -1; } for (int i 0; i *payload_len; i) { payload[i] frame[4 i]; } return 0; }第三步反过来测试一下const的保护作用。你在函数体内加一行frame[1] 0x55;编译器会立刻报错error: assignment of read-only location *(frame 1u)这说明编译器已经开始帮你做事了。改完这个函数后我还会把输入参数命名为in_frame或frame_in输出参数命名为payload_out之类的后缀在命名上进一步强调方向。不过这属于团队规范问题不强求。第四步如果你的项目里所有解析类函数都遵循这个模式那调用方看到const uint8_t *就知道“这个函数不会改我的数据放心传”。这是一个从接口层面降低bug概率的做法成本几乎为零但收益很实在。4.3 验证和调试const到底拦住了什么改造完成后我在本地写了一个简单的测试来验证static const uint8_t frame[] {0xAA, 0x00, 0x00, 0x02, 0x01, 0x02}; int main(void) { uint16_t payload_len 0; uint8_t payload[8] {0}; if (parse_frame(frame, sizeof(frame), payload_len, payload) 0) { printf(payload_len%u\n, payload_len); for (int i 0; i payload_len; i) { printf(payload[%d]0x%02X\n, i, payload[i]); } } return 0; }注意frame数组在测试代码里本身就是static const uint8_t[]。改造前调用parse_frame(frame, ...)会有编译警告因为const uint8_t*不能安全地转换成uint8_t*改造后就直接编译通过了这就是const传递性带来的好处。我还故意测试了把frame[1] 0x55;写进函数的情况GCC报错很明确直接定位到行号。调试这种编译期错误比定位运行时的“为什么数据被改了”要轻松一个数量级。这个案例总结下来就一句话函数形参声明不要偷懒输入参数尽量加const输出参数保持可变。这句话值得写在你工位的便利贴上。5. 容易踩的坑和排查经验5.1 指针混用带来的风险const丢失与非法修改有些技巧能绕过const检查但几乎都是危险操作。最常见的两种是通过强转把const int*转成int*然后修改原数据。在函数声明里省略const却在函数内部偷偷信任“不会改”结果传入了const数据还被当作可变指针使用。在C语言里通过强制类型转换去掉const然后修改数据属于未定义行为。标准里没有规定会发生什么但实践中轻则被优化器忽略修改、重则触发段错误。我遇到过一种很难查的bug代码里定义了一个const字符串然后在某个函数里通过强转修改了它。因为在某些嵌入式平台上这段字符串被放在Flash里写操作不会立刻崩溃但也不会生效数据看起来“没变”。而同样的代码在PC上跑换个编译器或者开启某些优化选项后程序直接segfault。最让人头疼的是这个bug在不同工具链上的行为还不一样最后翻代码才发现是有人为了“省一个临时缓冲区”强转改了const数据。所以请把“强转const就能改数据”这条路彻底封死。如果确实需要修改一份数据那就老老实实复制一份到可变缓冲区再对副本操作。另外在C语言里函数指针的参数也有const匹配问题。比如你有一个void (*handler)(int *)想把它当成void (*handler)(const int *)传给某个接口这是不允许的。原因很简单如果允许函数内部就可能通过这个指针修改原本不允许修改的数据。所以一旦const被放进接口设计它会沿着调用链一路传播这是好事因为接口的约束变得更严格了。5.2 volatile与const同时出现别自己吓自己很多嵌入式C程序员看到const volatile的组合会愣一下心想“既是常量又是易变的这不矛盾吗”答案是不矛盾。const告诉编译器“程序不能通过这个标识符主动修改数据”volatile告诉编译器“这个数据的值可能在程序之外被改变别把它优化掉”。最典型的场景是硬件寄存器映射。比如读取一个表示设备状态的寄存器它映射到内存地址0x40001000#define STATUS_REG (*(const volatile uint32_t *)0x40001000U)这里volatile保证每次读取都真的去访问该内存地址而不是用缓存值优化掉const保证程序不会往这个寄存器地址写入因为写入一个状态寄存器可能引起不可预知的硬件行为。在代码里写STATUS_REG 0x01;会编译失败这在很多场景下正是我们想要的——硬件寄存器如果不用加const一个误写可能导致整个系统挂掉。同样的道理也适用于某些只读传感器数据寄存器和共享内存的只读视图。你要是有天在代码里看到const volatile不要觉得奇怪它其实是同时施加了两道约束。还有一个容易误会的问题static关键字和const的组合。static控制的是变量的作用域和存储期const控制的是可写性。两者可以共存比如static const int kNum 3;表示这个常量只在当前文件或函数内可见并且不可修改。这在模块化编程里非常常用有效避免了全局命名冲突。5.3 初学者最容易问的问题这里一并答了问题1const int *p和int const *p有什么区别没有区别两种写法语义完全相同都表示“指针指向的值是const”。这只是个人习惯问题。真正需要区分的是它们与int *const p的区别前者指针本身可变、指向数据不可变后者相反。问题2在一个函数里形参写成const char *和char *调用时有区别吗有。如果你有一个const char *str的字符串传给形参是char *的函数编译会警告因为函数可能会修改它。只有形参也是const char *const字符串才能直接传进去。反过来把一个char *传给const char *形参倒是可以的因为函数更严格地承诺“不修改”这属于安全的收缩。问题3const变量能用指针取地址然后绕过修改吗能但这是未定义行为纯属自己给自己挖坑。如果你非要在某个几乎没有别的办法的场合修改它比如接收旧接口的强制要求想清楚后果并用一段注释说明为什么这么做。但大多数情况下重构都比这种操作更值得。问题4全局const变量一定要初始化吗是的。const变量一旦定义编译期和运行期都不能再写入。如果不初始化它就会是一个没法赋值的变量完全没用。所以要么定义时就给值要么用extern声明引用其他地方定义好的常量。问题5C里的const和C语言里的const有什么不同差别还挺大的。C里const变量默认具有内部链接属性不用担心头文件里定义const导致重定义C里const int n 10;通常可以直接作为编译期常量使用不会为它分配存储除非你用n取地址C还有constexpr关键字进一步强化编译期计算能力。但我这篇文章主要是在讲C语言C的const语义更复杂以后再单独开一篇说。结尾我的一些经验体会最后分享一点我自己的体会。刚开始学C语言的时候const在我眼里就是个不起眼的小修饰符甚至觉得“加了const反而多打字麻烦”。后来写了几万行C代码、调过几个因为数据被意外修改而头疼的问题之后才慢慢意识到const其实是C语言里最被低估的关键字之一。它能在编译期就把一大批“忘记只读约定”的bug扼杀在摇篮里同时让接口变得更清晰让代码更适合团队协作。我在实际项目里的做法是所有函数输入参数能加const一定加const所有全局配置数据优先用static const所有查表数据必须加const。这样坚持一段时间之后你会发现代码里因为误修改数据导致的bug明显变少而且代码review的时候大家讨论的重点也从“这里会不会被改”变成了“这个接口为什么不能是const”这种更加本质的问题。这个改变我觉得是值得每一个C语言学习者去尝试的。