C语言const深度解析:从指针组合到嵌入式实战避坑指南

发布时间:2026/10/1 3:37:52
C语言const深度解析:从指针组合到嵌入式实战避坑指南 C语言里有个关键词面试几乎必问实际工程里也天天见但很多人写了两三年C代码一遇到const和指针组合还是容易懵——我说的就是const。每次代码评审我都能看到有人把const当装饰品乱挂或者干脆不用然后代码里到处是魔法数字和意外修改。这篇文章我打算把const的作用一次讲透从基础语法到指针组合从函数参数到结构体再到实际开发中那些坑全部过一遍。适合刚学C语言的学生也适合写了段时间但没系统梳理过const的嵌入式、后端开发。先给个最直白的结论const就是告诉编译器“这个变量我只读不写”让编译器帮你守着这条红线。它能让代码意图更清晰能让编译器帮你抓出“意外改值”的bug还能让优化器放胆优化。但const的语法细节特别多尤其是和指针、函数参数、类型限定符混在一起的时候很容易写出“字面合法、语义错误”的代码。下面一个一个拆。1. const的本质不是“常量”是“只读变量”1.1 const修饰的到底是什么很多初学者会把const理解成“定义常量”这个说法不准确。#define宏定义的才是真正的常量const修饰的是“只读变量”。它有变量的全部特征有地址、占用存储空间、类型明确只是语法上不允许通过这个名字去修改它。const int a 10; // a 11; // 编译报错不能通过a修改 int *p (int *)a; *p 11; // 不推荐UB但实际能改这里有个关键点const int a 10;和int a 10;在编译后如果没被取地址const的变量有可能被优化成立即数直接被替换到使用位置。一旦取了地址它就会真的存在于内存中const只是编译器层面的约束不是硬件写保护。那为什么要说“只读变量”而不是“常量”因为const修饰的变量不能用作数组长度在C89/C99标准中也不能用作case标签这在编译期需要确定值的场景里是行不通的。比如const int n 5; int arr[n]; // C99变长数组可以但C89不行且它确实是运行时才确定的 // switch(n) { case n: ... } // 报错case要求编译期常量1.2 与#define宏的本质区别这里值得花点篇幅。很多教材喜欢把const和#define放在一起对比我们要知道它们应用在不同场景对比项const#define类型检查有编译器检查类型没有纯文本替换存储空间分情况可能占用内存不占内存预处理阶段替换作用域遵循块作用域规则从定义处到文件末尾调试体验可在调试器中查看值调试时看不到宏名是否可被取地址可以如const_var不能实际开发中我的习惯是整型、浮点型、字符型的常量优先用const尤其是需要类型安全的场景纯文本替换的宏保留给复杂的表达式、函数式宏、条件编译等场景。比如#define MAX(a, b) ((a) (b) ? (a) : (b))这种只能用宏而const int MAX_SIZE 1024;这种就应该用const。1.3 const在编译期的两个作用编译器拿到const修饰的变量后做两件事。第一语法检查但凡发现通过这个标识符直接修改值的代码直接报错。这是const最直观的作用。第二优化依据编译器认为const修饰的变量不会变就可以做常量传播、常量折叠、缓存优化减少内存访问。这个优化在-O2以上尤其明显。但要注意编译器默认const变量在本文件内不会变可如果它是个外部全局变量别的文件通过非const指针传进来修改它编译器可能感知不到就会产生“优化出来的值和实际值不一致”的问题。这也是const全局变量在跨文件使用时要注意的地方后面会专门说。2. const和指针的组合最绕也最容易错2.1 两种基本形态指向常量的指针 vs 常量指针这里我直接把规则讲透。const和指针组合时核心就是看const在*左边还是右边。const int *p; // p是一个指向const int的指针p本身可变*p不可变 int const *p; // 同上一行const在*左边效果一样 int *const p; // p本身是const指针p指向的int可变但p不能再指向别处 const int *const p; // 两个都不可变记忆方法从右往左读。const int *p读作“p是pointer to const int”int *const p读作“p是const pointer to int”。看const修饰的是谁——在*左边修饰的是指向的对象在*右边修饰的是指针本身。2.2 组合规则全解析先看一段完整示例int a 10; int b 20; const int *p1 a; // 不能通过p1修改a // *p1 30; // 报错表达式必须是可修改的左值 p1 b; // 合法p1本身可变 int *const p2 a; // p2永远指向a *p2 30; // 合法可以修改a // p2 b; // 报错不能给常量指针重新赋值 const int *const p3 a; // 都不能改这个表帮你理清楚声明指针本身指向的对象const int *p可变不可变int const *p可变不可变int *const p不可变可变const int *const p不可变不可变面试官最爱问的一个坑是const int *p和int const *p是否等价以及const int *p和int *const p的区别。答案前面已经写了——前两者等价后面两个天差地别。2.3 指针常量与数组名int *const p有一个天然的朋友——数组名。int arr[5]里的arr本质上就是个int *const类型的指针严格说是指向首元素的常量指针所以数组名不能做arr操作因为数组名本身不可变。这也是为什么你写arr编译不通过的原因。不过要注意区分数组名不完全是int *const它在很多表达式里会退化成指针但sizeof(arr)返回的是整个数组的大小arr的类型是int (*)[5]指向整个数组的指针这些和普通int *const指针不一样。2.4 const指针的强制类型转换警告如果代码里出现const int a 10; int *p a;在C语言里编译器通常会给出警告而不是报错。但在C里这直接是编译错误。C语言的警告意味着“这一段没有语法问题但行为不确定”用(int *)a强转是可以编译过去的但修改一个原本声明为const的对象是未定义行为程序可能正常工作、崩溃或者修改后不生效因为编译器优化。我自己在嵌入式开发中见过好几次有人强转const变量然后想改它结果因为编译器已经把它优化成立即数内存改了但读到的一直是旧值排查半天。3. 函数里的const参、返回值一个都不能少3.1 const修饰函数参数函数参数用const修饰是为了承诺“我不会修改你传进来的数据”。这在传指针时尤其重要。看一个典型场景size_t my_strlen(const char *s) { size_t len 0; while (s[len] ! \0) len; return len; }s声明为const char *函数体内任何对s[i]的赋值都会编译失败。这样做的好处有三层第一文档化意图调用者一看就知道这个函数不会改动数据第二编译器帮你做检查误写了立刻报错第三允许传入const char *类型的数据因为非const指针可以隐式转换为const指针反过来不行。注意第三点背后的规则C语言里char *可以自动转为const char *但const char *不能自动转为char *。所以如果一个函数声明为void func(char *s)你传const char *进去会警告甚至报错。这提醒我们一个函数如果不会修改传入的缓冲区参数就要写成const指针。3.2 传值和传指针的区别值传递不需要const吗也不一定。看这两种写法void func(int a) { ... } // 普通传值 void func(const int a) { ... } // const传值const int a在函数体内不能被赋值但这在实际中意义不大——反正a是实参的拷贝改不改都不影响调用者。但有一种场景有用函数内部确实不应该修改某个入参用const强制约束防止手滑。不过我个人觉得在传值场景用const更多是“心理安慰”真正的价值在传指针和引用C时。结构体传参则是另一回事。结构体太大的时候一般用指针传。如果不希望函数修改结构体内容必须写const struct Student *stu这样既避免了拷贝开销又保证了只读。3.3 const修饰函数返回值返回值带const有两种情况返回指针和返回值。const char *get_message(void) { return hello; } const int get_number(void) { return 42; }返回指针时const char *表示返回的指针指向的内容是只读的。这通常用在返回内部字符串常量、返回只读缓冲区、返回字符串字面量等场景。调用者拿到这个指针只能读不能写。但const int get_number()这种其实没什么实际作用。因为返回值是右值本身就是不可修改的const加不加都一样反而会影响类型一致性。C里返回const值类型甚至会影响移动语义不推荐。所以经验是值类型返回值不用加const指针/引用返回值根据是否需要只读来定。3.4 const放在函数名后的特殊情况这里多说一句有些教材会把const放在函数声明的最后比如void func() const;那是C里表示“这个成员函数不修改对象状态”的语法。C语言里没有这种用法。遇到这种写法说明你在看的是C代码别混着理解。C的const成员函数和C的const参数虽然都叫const底层逻辑也有千丝万缕的关系但语法位置和用途区别很大。4. 全局、数组、结构体const的高级应用4.1 const全局变量的跨文件使用全局变量用const修饰能有效防止其他地方意外修改但跨文件使用时有个坑。看这段// file.c const int MAX_SIZE 1024; // main.c extern const int MAX_SIZE;在C语言里const全局变量默认是内部链接的也就是说它的作用域仅限于定义它的源文件。extern声明一个const int变量时编译器不会自动把它当作外部链接。解决方案有两个一是在头文件里加上extern const int MAX_SIZE;然后在定义处显式声明const int MAX_SIZE 1024;编译时确保两个文件都包含这个头文件二是在定义处用extern覆盖默认的内部链接// file.c extern const int MAX_SIZE 1024;这样声明之后MAX_SIZE就具备外部链接属性其他文件用extern声明就能访问。注意C里const全局变量默认是内部链接感化行为有所不同。C和C混编时要注意这个区别避免出现“明明定义了却链接不到”的问题。4.2 const数组查表利器const数组在工程中太常用了。查表、配置文件、错误码表、字模、CRC表全是const数组。声明方式const int lookup_table[8] {3, 5, 1, 7, 8, 4, 2, 6}; const char *weekday_names[] {Sun, Mon, Tue, Wed, Thu, Fri, Sat};为什么表格要用const第一防止程序运行中把表改坏。比如查表代码里万一写了个越界赋值const直接在编译期拦截。第二可以放在只读段如Flash、ROM在嵌入式设备里节省宝贵的RAM。第三编译器可以放心优化甚至把整个表内联成立即数。4.3 const与结构体只读对象结构体变量加const和普通变量类似但多了指针成员的细节。考虑下面这个typedef struct { int *data; int length; } Buffer; void init_buffer(const Buffer *buf) { // (*buf).length 10; // 报错不能修改buf指向的结构体成员 // buf-data NULL; // 报错 // *buf-data 42; // 合法buf-data是指针指针指向的对象可修改 }理解这个的关键在于const Buffer *buf约束的是buf指向的那个结构体变量本身即结构体中的每个成员都不能通过buf来修改。但因为data成员是一个int *类型的指针它的值也就是指针本身被const约束了不能改而它指向的那块内存并没有被const约束所以*buf-data 42是合法的。这个细节写出来有点绕但非常重要const是浅层约束不会自动传递到指针指向的深层数据。要深层也只读必须手动声明const int *data。4.4 字符串字面量与const char*字符串字面量如hello在C语言标准里本来就不应该修改规范写法是char *p hello; // 旧式写法应该避免 const char *p hello; // 推荐hello的类型是char[6]但标准规定修改它是未定义行为。许多编译器把字符串字面量放在只读区写操作会导致段错误。现代编译器对char *p hello;有deprecated警告保留是为了兼容老代码。所以规范做法就是const char *。顺带一提在嵌入式面试或笔试中如果数组定义成char str[] hello;那str是可修改的和char *str hello在内存分布上完全不同。这个和const的关联在于想知道字符串能不能改先看它是字符数组还是指针——字符数组可以原地改指向字面量的指针不能改。5. const、static、volatile、typedef的联合作战5.1 const与static全局常量的标准组合static const组合非常常见static const int CONFIG_PIN 5;static限定了作用域只在当前文件const限定了只读。两者一组合既能防止外部文件访问又能防止本文件内被意外修改。在头文件里定义static const变量每个包含这个头文件的源文件都会有一份独立的拷贝适合轻量级配置常量的场景。但如果常量比较大例如大数组放头文件里static const会每个文件都复制一份浪费内存。这时候应当用extern.c文件定义的方式共享只存一份。5.2 const与volatile读硬件寄存器的正确姿势嵌入式开发里有一个经典问题const和volatile能共存吗答案是可以而且必须共存。考虑一个状态寄存器const volatile uint32_t *status_reg (uint32_t *)0x40001000;这个声明同时表达了两层意思const表示程序员想读它不打算通过这个指针写它volatile表示这个地址的值可能随时被硬件改变编译器每次使用前必须重新读内存不能优化成缓存。反过来如果写成volatile const顺序无所谓都是同时具备两种语义。但是不能省略任何一个没有volatile编译器可能把读取优化掉读不到最新硬件状态没有const可能不小心写操作对只读寄存器来说就是灾难。5.3 const与typedeftypedef和const组合有时会有歧义。看typedef int *int_ptr; const int_ptr p; // p是什么类型很多人以为const int_ptr p等于const int *p其实不是。typedef是类型别名const是限定符const int_ptr表示“该类型的一个const限定变量”即一个int *const p。所以p是不能改的指针但指向的对象可以改。要得到const int *必须写const int *p; // 或者 typedef const int *const_int_ptr; const_int_ptr p;这是一个经典考点也是实际代码里容易犯的错。尤其是写接口头文件时typedef了指针类型再加上const语义往往和第一反应不一致。5.4 const与函数指针函数指针也能加const。比如void (* const handler)(void) my_handler;这表示handler本身是个不可修改的函数指针变量只能指向my_handler不能指向别的函数。调用没问题handler()。值传递中const对这种函数指针的作用和普通指针一样重点是本体不可改。5.5 const修饰全局函数返回值与extern接口如果在接口设计里你要暴露一个只读字符串给外部模块写在头文件里就是// public.h extern const char *get_version(void);这个声明表示返回一个指向只读字符数据的指针调用者拿到version字符串只能读。内部实现可以返回一个静态字符串const char *get_version(void) { return v1.2.3; }这样相比于直接暴露全局变量接口更清晰外部模块知道不能修改版本号字符串。6. 常见误区和排查技巧6.1 踩坑记录const修饰的变量不等于编译期常量有个朋友写代码来问我为什么这样编译不过const int size 10; int array[size];在C99之前这段代码是有问题的因为数组长度必须是编译期常量表达式。C99引入了变长数组VLA允许使用运行时值作为数组长度但VLA不能是全局的而且C11里VLA变为了可选特性。在C里const int size 10;如果是一个有初始化器的局部const int它可以被用作编译期常量这是C的特殊规则但const int size get_size();就不行。所以遇到编译错误先确认标准版本。6.2 谨防const陷阱通过指针绕过const修改以前排查过一个bug现象很诡异一个设备配置参数初始化时可以正常设置运行中某次改配置后显示值没变但实际功能变了。后来定位到代码如下const int timeout 5000; int *p (int *)timeout; *p 6000;因为timeout被声明为const编译器把所有用到timeout的地方都直接替换成5000了。*p 6000改到的内存并不是函数后续读timeout时访问的地方。这个bug如果不开优化可能一切正常开了-O2就全部乱套。所以遇到“改了const变量的值但读出来还是旧的”这种情况第一嫌疑就是编译器优化。6.3 排查速查表现象原因解决方案对const int *p执行*p 10报错p指向的对象被const约束确保不需要修改或改用非const指针对int *const p执行p other报错指针本体被const约束确认是否需要可变的指针变量改了const变量但读取值不变编译器优化把值替换为立即数不要修改const变量或者用volatile程序直接崩溃/段错误写字符串字面量或const只读区改成char str[] ...跨文件访问const全局变量报错/链接失败默认内部链接定义处显式加extern结构体const指针却又能修改数据const是浅层约束深层数据也要const指针成员函数需要传入const char*却报类型不兼容传了char*到const char**场景理解多级指针不允许隐式转换6.4 多级指针的const不兼容这个坑很多人没注意到char **不能隐式转换为const char **。看void func(const char **argv) {} int main(void) { char *args[] {a, b, NULL}; func(args); // 编译警告 }原因在于如果允许char **转为const char **那在func里执行argv[0] new会让原数组元素指向字符串字面量这个操作本身没毛病。但如果同时有另一个变量是char *p而你执行*argv p就相当于把一个char*赋值给了一个const char*变量本来没问题。真正的危险在两层转换中可能绕过类型安全C标准干脆禁止了这种隐式转换只允许char **转成char * const *。这个细节在命令行解析、main(int argc, char **argv)相关的代码里比较常见。如果一个接口声明的是const char **传char **进去时要注意匹配类型。7. 实际开发中的使用建议我在做嵌入式、服务端、以及写工具链的时候const的使用习惯基本稳定了分享几个经验。第一指针参数一律按“能否修改”来决策。只读就加const函数接口一目了然。如果你自己设计API别的同事拿到头文件一看const char *就知道这函数不会改字符串一看char *就知道这个缓冲区是输出参数调用前得分配好。这是最廉价的文档。第二常量定义优先const不优先宏。尤其是在写头文件给多个模块共用时const int能保证类型检查宏就是无脑替换。唯一要用宏的场景是条件编译、表达式型宏、以及需要编译期优化掉的常量case标签、数组长度、位域宽度。第三外部只读接口全面加const。比如extern const全局变量、返回只读内存的函数都在声明处写清楚。这样不仅防止使用者误改也提醒实现者不要返回内部可变缓冲区。第四不要为了编译通过就强制转换去掉const。如果在代码里写(char *)强转干掉const你要反问自己“我真的需要修改一个只读对象吗” 大概率是你把对象的属性设错了或者设计上这个数据本来就是可变的。多数情况下的正确做法是源头就不加const而不是使用处强转。第五注意const和volatile、static的搭配。访问硬件寄存器时const volatile不能少模块内部全局状态static const能防同事乱改跨模块共享的常量extern const定义一次。第六数组作为函数参数退化成指针后const的行为要盯紧。void func(const int arr[])完全等价于void func(const int *arr)只是写法上的提示不是真正的“数组受const保护”。如果想实现“数组不可修改”在函数参数层面做不到因为退化了。第七代码规范和编译器告警要一起上。-Wall -Wextra必开-Wwrite-strings这种针对字符串字面量的告警也要开。头文件里多写一点const编译期就能拦住一大波运行期才会暴露的bug。第八用const声明的变量也可以参与sizeof计算、printf参数、循环控制等它在运行期依然是一个变量只是不能写入。不要因为看到const就默认它一定是编译期常量。我在实际项目中还养成了一个习惯代码评审时看到有函数代码超过20行我第一件事就是看它的指针参数有没有const。没有加const的只读参数写的人大概率修改过它或者想修改它这种代码往往是隐藏bug的重灾区。const看起来只是多写几个字母但它是C语言里最容易被人低估的“安全网”。把它用好代码的健壮性上一个台阶。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询