很多人在学习 C/C++ 时都纠结过一个基础问题:定义常量到底用#define还是const?我见过不少刚入行的同学把#define当成“定义常量的宏”,又把const当成“更安全的宏”,结果在实际工程里混用得乱七八糟,最后要么被编译错误折磨,要么被调试器搞得怀疑人生。
这篇文章就围绕这两个关键词展开,从头到尾把它们的底层逻辑、生效时机、类型安全、内存分配、作用域、调试表现逐层剥开。顺手还会把指针常量和常量指针、constexpr、JavaScript 里的const、Python 里人为约定的常量这些连带的坑一起理清楚。内容适合三类人:刚学 C 语言的学生、做嵌入式开发的一线工程师、从脚本语言转 C/C++ 的开发者。前面基础部分每个人都能看懂,后面实战部分会用到一些 STM32 寄存器和 C++ 特性,有基础的同学可以直接跳到对应章节。
1. 先理解两者的“生效时机”和本质
1.1 #define:预处理阶段的文本替换
#define是预处理指令,它在编译器真正开始编译之前就干活了。换句话说,#define MAX 100做的事情非常朴素:在预处理阶段,把源代码里所有出现MAX的文本原封不动地替换成100。这个替换发生在任何类型检查、语法分析之前。
举个例子:
#define MAX 100 int main(void) { int a = MAX; return 0; }预处理之后,实际交给编译器的代码变成了:
int main(void) { int a = 100; return 0; }所以MAX根本不存在于编译器的视野里。它不是一个变量、不是一个对象、没有类型,也谈不上内存地址。这就是为什么有人说宏是“文本层面的把戏”,因为它的生命周期只在预处理阶段。
很多人写代码时会用#define定义一些看起来像常量的值,比如:
#define GPIO_PIN_6 6 #define UART_BAUD_115200 115200这些宏在展开之后就是裸的数字,不需要额外分配内存,也不会出现在调试器的符号表里。这在嵌入式开发中很常见,因为它可以直接参与寄存器赋值、数组大小定义等编译期就能确定的场景。
1.2 const:编译阶段由编译器处理的“变量”
const是 C/C++ 的关键字,它修饰的是一个真正的对象或变量。const int MAX = 100;的意思是:声明一个名为MAX的、类型是int的变量,并且规定这个变量在初始化之后不能被修改。
这里要尤其注意两个字:“变量”。const并没有把一个变量变成“不是变量”,它只是给变量加了一条“只读”约束。在编译器的眼里,const int MAX依然有类型、有作用域、有可能占用内存、可以被取地址。一旦你在代码里写了类似MAX = 200;这样的语句,编译器会直接报错,因为这个操作违反了对只读对象的约束。
const int MAX = 100; int main(void) { // MAX = 200; // 编译错误 printf("%d\n", MAX); return 0; }const的工作发生在编译和编译优化阶段。编译器会利用“这个值不会变”的信息做一些优化,比如把对应的表达式直接替换成立即数,但这和#define的强制文本替换是两码事。
1.3 一张表把核心差异看清楚
| 对比维度 | #define 定义的宏 | const 修饰的常量 |
|---|---|---|
| 本质 | 预处理指令,文本替换 | 语言关键字,修饰只读变量 |
| 生效阶段 | 编译之前的预处理阶段 | 编译阶段 |
| 是否有类型 | 没有,纯粹是文本 | 有,例如const int、const char* |
| 是否分配内存 | 通常不分配,无符号 | 可能分配,取决于是否取地址、编译优化 |
| 作用域 | 定义后全局可见,直到文件结束或被#undef | 遵循块作用域,可以封装在函数、类中 |
| 能否被调试器查看 | 预处理后看不到宏本身 | 可以看到符号 |
| 参数副作用 | 参数被多次求值时可能出问题 | 不存在参数展开,不会因替换产生副作用 |
| 编译期常量 | 可以,只要是常量表达式 | 在 C++ 中const int也可作为编译期常量,在 C 中要看具体上下文 |
这张表相当于全文的一个索引。后面的内容会把表格里的每一行展开讲透,尤其是“编译期常量”“作用域”和“调试表现”这几行,几乎就是面试和日常开发里高频踩坑的重灾区。
2. 类型安全与编译期纠错
2.1 #define 没有类型,const 有类型
宏没有类型,带来的直接后果就是“错误发现得比较晚”。比如:
#define PI 3.14 #define COUNT 5 double area = PI * r * r; int arr[COUNT];这里PI被展开成3.14,因为是浮点字面量,所以参与浮点运算没问题。但如果写的是:
#define RATIO 3 double result = RATIO / 2;RATIO展开成整数3,3 / 2得到整数1,浮点数精度直接丢光。这不是宏本身的错,而是“宏没有类型”导致你把一个整数当成浮点数用的时候,编译器完全不会提醒你,因为它在编译前就被替换成裸的3了。
换成const就不一样:
const double RATIO = 3.0; double result = RATIO / 2; // 结果是 1.5由于RATIO有double类型,编译器会按浮点规则来运算。更重要的是,const int传参给需要int*的函数会报错,const char*和char*在函数重载和类型匹配上也有明确区别。类型系统会帮你把很多错误挡在编译期,而不是等程序跑起来之后给你一记莫名其妙的逻辑 bug。
2.2 典型编译错误“表达式必须含有常量值”的真相
搜索热词里有一个出现频率非常高的报错:表达式必须含有常量值。这句话在 MSVC 编译 C/C++ 代码时经常能看到,尤其在定义数组大小时。
我见过很多同学的迷惑操作:
const int SIZE = 5; int arr[SIZE]; // 在某些 C 编译器里没问题,在某些环境里报错为什么会出现不一致?因为 C 标准里,数组长度要求是“整数常量表达式”,而const int SIZE是不是整数常量表达式,取决于你的 C 标准、编译器是否支持 VLA(变长数组),以及SIZE是否被编译器做常量折叠。
如果你的SIZE来自一个函数返回值:
int getSize(void) { return 5; } const int SIZE = getSize(); int arr[SIZE]; // 编译错误:表达式必须含有常量值这段代码在 C++ 里也会报错,因为SIZE虽然被const修饰,但它不是编译期可以确定的常量表达式。它的值要在运行期才知道,而数组大小在编译时必须固定。
此时#define反而显得“能解决问题”:
#define SIZE 5 int arr[SIZE]; // 编译通过,因为预处理后就是 int arr[5];所以不要天真地以为“const 比宏高级,所以什么场景都能替代宏”。在需要绝对编译期常量的场合,比如数组大小、位域宽度、case标签、C++ 模板参数,宏依然是一种直接有效的写法。C++ 中更推荐用constexpr来获得真正的编译期常量,这一点在第 5 章我会详细说。
2.3 作用域差异:宏是全局的,const 可以封装
宏一旦定义,从定义处到文件结束(或者被#undef之前)都是可见的,而且不区分函数块。如果你在一个头文件里写了:
#define BUFFER_SIZE 1024那么只要任何文件#include了这个头文件,BUFFER_SIZE就污染了整个编译单元的命名空间。你不会知道它在哪里被误用,也容易和别的库里的同名宏冲突。
const则遵循正常的 C/C++ 作用域规则。你可以把它定义在文件内:
static const int BUFFER_SIZE = 1024;也可以把它定义在函数内部:
void func(void) { const int local_max = 10; }还可以把它封装在结构体、类、命名空间里。这一点对大型项目的可维护性极其重要。比如 C++ 中的异常捕获常见写法:
try { // ... } catch (const std::exception &e) { // e.what() 可以调用 }这里的const std::exception &e既保证不会拷贝异常对象,也保证不会意外修改它。如果用一个宏去模拟类似的类型约束,根本无法做干净。
3. 内存、占用与运行期行为
3.1 const 变量真的不占内存吗
很多人听到“const 是只读变量”,就会问:它到底占不占内存?答案是:不一定,取决于你怎么用它。
如果只是定义一个普通的const int,并且从不对它取地址,编译器很可能在编译期把它折叠成立即数,最终不产生任何内存占用。比如:
const int LIMIT = 100; int a = LIMIT;这里LIMIT很可能直接被优化成100,不会在数据段里留出一个 int 的位置。
但如果你对const变量取地址:
const int LIMIT = 100; const int *p = &LIMIT;那么LIMIT就必须在内存里有一个实际的对象,否则p指向谁?此时它通常被放在只读数据段。在嵌入式系统里,这意味着它和字符串字面量一样,会被烧录到 flash 或 ROM 区,而不是占用 RAM。
宏则不同。#define LIMIT 100在预处理后就是一片文本,根本谈不上“内存中的对象”。你写&LIMIT也通不过编译,因为展开后是&100,取字面量的地址,这没有意义。
这带来一个实用结论:在资源受限的 MCU 上,如果只是想定义一个编译期使用的阈值,#define不会占据 RAM。但如果希望用const表达“运行期不会被修改的变量”,并借助编译器优化把它放到只读段,也可以做到低开销。两者不是“一个有内存一个没内存”这么简单的对立,而是取决于编译器优化。
3.2 字符串常量和指针组合
热词里提到了“转换为字符串常量”,这里值得单独讲一下。在 C/C++ 中,字符串字面量本身就是一种“写死在代码里的常量”。常见的写法:
const char *msg = "hello";msg是一个指针变量,指向的字符串内容存放在只读区域。你无法通过msg[0] = 'H'修改它,这是属于“指向常量的指针”。
如果把整段字符串定义成宏:
#define MSG "hello" printf("%s\n", MSG);预处理后被替换成printf("%s\n", "hello");。宏只是让代码简洁,并没有给你一个可以被传递、被取地址的“字符串对象”。如果你的代码需要把字符串传入接口:
void print(const char *s); print(MSG);宏展开后依然能工作,因为"hello"本身就是const char*兼容的字面量。但如果需要对字符串做数组初始化,const char arr[] = MSG;也能通过,因为替换后是标准数组初始化。真正的问题在于,宏没有类型检查,你没办法对它做sizeof那种针对变量对象的操作,一旦需要统一管理一组字符串,宏就力不从心了。
3.3 指针常量和常量指针别再搞混
搜索热词里另一个高频问题就是“指针常量和常量指针”。这两者中文听起来像绕口令,英文更直白:
const int *p:pointer to const int,指向常量的指针,也就是“常量指针”。p本身可以被修改,但你无法通过p修改它所指向的值。int *const p:const pointer to int,常量指针,也就是“指针常量”。p本身一旦初始化就不能再指向别处,但你可以通过p修改它指向的 int。
可以用一个口诀记:const在*左边,修饰的是指向的对象;const在*右边,修饰的是指针本身。
int a = 10; int b = 20; const int *p1 = &a; // *p1 = 30; // 错误:不能通过 p1 修改 a p1 = &b; // 可以:p1 本身不是常量 int *const p2 = &a; *p2 = 30; // 可以:通过 p2 修改 a // p2 = &b; // 错误:p2 本身是常量指针这个区别和#define没有直接关系,但经常出现在 const 相关的笔试和工程 code review 里。很多时候你定义了一个宏来封装寄存器地址,比如#define REG_BASE (0x4000),然后想用指针访问:
#define REG_BASE (0x4000) volatile unsigned int *const reg = (volatile unsigned int *)REG_BASE;这里的*const表示“这个指针本身不会指向别的地方”,非常符合寄存器基地址的语义——基地址固定不变。如果用const unsigned int *,那意味的是“指向的内容不可变”,对寄存器来说就错了,因为寄存器值会变。所以理解const的不同位置,在实际硬件编程里是能救命的。
4. 实战选型:什么时候用 #define,什么时候用 const
4.1 优先用 const 的场景
我在实际项目里的默认原则很简单:只要能表达成“有类型的只读变量”,就优先用const。
典型场景包括:
- 函数参数修饰:
void func(const char *str),明确告诉调用者这个接口不会修改字符串。 - 局部只读配置:
const int retry_count = 3;,避免后面误赋值。 - 全局只读配置:配合
static限制作用域,防止全局命名空间污染。 - 类成员:
const int MAX_SIZE = 100;在 C++ 里可以作为类的只读属性。 - 需要调试器查看值的时候:
const变量可以在调试器里直接看到,宏不行。
选const的根本理由是“类型安全 + 作用域可控 + 可调试”。它把常量的信息留在编译器的语义分析里,能帮你拦截大量低级错误。
4.2 必须用 #define 的场景:寄存器宏、条件编译、宏函数
但const不是万能的,有些场景里它确实替代不了#define。
第一是条件编译。#ifdef、#ifndef这类预处理指令只能配合宏使用,因为它们在const变量出现之前就必须决定要保留哪部分代码:
#ifdef DEBUG_LOG_ENABLE #define LOG_DEBUG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__) #else #define LOG_DEBUG(fmt, ...) ((void)0) #endif如果你用const bool debug_enable = true;,代码依然会被编译进二进制,只是运行时不走打印分支,不能实现“编译期裁剪代码”的效果。
第二是宏函数。比如常见的热词#define TX_BUF_BASE(sn) (0x4000 + (sn * 0x1000))。这是一个典型的“文本展开公式”,意思是每个发送通道sn对应的缓冲区基地址按0x1000间隔排列:通道 0 是0x4000,通道 1 是0x5000,通道 2 是0x6000,以此类推。
#define TX_BUF_BASE(sn) (0x4000 + ((sn) * 0x1000)) void setup_channel(int ch) { uint32_t base = TX_BUF_BASE(ch); // 把 base 写入 DMA 的外设地址寄存器 }这种宏的好处是调用起来像函数,但实际在预处理阶段就展开成固定地址运算,没有函数调用开销。当然代价是参数sn如果写成复杂表达式,可能会被求值两次,进而带来副作用问题,这个我后面会专门讲。
第三是寄存器地址映射。热词里那条#define SCL_L GPIOC->BRR = GPIO_BRR_BR6很典型,它的本质是“把给 GPIO 寄存器某个位赋值这件事定义成一个宏”,用来拉低 SCL 引脚。在 STM32 代码里,GPIOB 的 BRR 寄存器是位复位寄存器,写入GPIO_BRR_BR6表示把第 6 位输出低电平。所以这行宏展开后就是:
GPIOC->BRR = GPIO_BRR_BR6;为什么用宏而不是 const?因为你根本不是在定义一个值,而是在定义一段“操作”。宏可以封装寄存器的读写动作,const没法表达“寄存器写入”这种带有赋值语义的代码片段。
第四是某些编译期必须的常量。比如数组大小、位域、汇编层面的嵌入,以及 C++ 模板参数,这些场景要求值必须在编译期确定,宏天然满足。C++ 里可以用constexpr更好地替代,但在 C 语言中,宏依然是标准答案之一。
4.3 推荐写法:const + inline / constexpr 替代部分宏
宏虽然好用,但副作用问题突出。一个经常被引用的负面例子:
#define SQUARE(x) ((x) * (x)) int i = 3; int result = SQUARE(i++);展开后是((i++) * (i++)),i被加了两次,结果完全不可控。正确写法是:
static inline int square(int x) { return x * x; }用static inline函数替代函数型宏,既保留性能优势,又避免参数多次求值。C++ 里则可以更激进一点:
constexpr int square(int x) { return x * x; }constexpr保证在参数是编译期常量时,函数也能在编译期计算出结果,同时不会带来宏的文本替换副作用。这是现代 C++ 中“宏替代者”的主力方案。
我在项目中通常的准则是:能写成函数的写成函数,能写成const的写成const,只有面对条件编译、寄存器映射、链接脚本相关的地址计算、或者兼容 C 语言的老代码时,才保留#define。
5. 其他语言里的 const(给多语言开发者的延伸)
5.1 C++ 里的 const、constexpr 与只读语义
在 C++ 里,const的含义比 C 更丰富。它可以修饰变量、指针、成员函数、引用,比如const std::string&作为函数参数几乎成了“只读参数”的标准写法。在 C++11 之后还有constexpr,它表示“不仅是只读,而且在编译期就能求值”。
constexpr int ARRAY_SIZE = 8; int buffer[ARRAY_SIZE]; // 编译期确定大小constexpr比#define强在它有类型、有作用域、能通过调试器查看、还能参与重载决议。比旧 C++ 里的const int也强在更明确地表达了“编译期常量”的意图。如果代码要求 C++11 以上,能constexpr的地方我都不会用宏。
另外在异常处理里,catch (const std::exception &e)这种写法涉及两个点:第一,用const引用避免异常对象被复制;第二,保证异常处理函数不能修改异常对象,保持了只读语义。这条对我的日常代码审查影响很大,因为你很容易在catch块里不小心写出修改e的代码,加上const后编译器会立刻拦住。
5.2 JavaScript 的 const 与解构
前端热词里有一条const game = document.getElementById("game"); const suntext = document.ge...,这反映了 JS 开发者对const的高频使用。在 JavaScript 中,const表示“绑定不能被重新赋值”,而不是“值不可变”。
const game = document.getElementById("game"); const suntext = document.getElementById("suntext");这段代码的意思是两个变量game和suntext不能被换成别的 DOM 对象,但你可以修改game对象的属性和内容。比如:
game.style.display = "block"; // 允许 game = document.getElementById("other"); // 报错再比如热词里的const { proxy } = getCurrentInstance(),这是 Vue 3 组合式 API 里的解构写法。const修饰的是解构出来的proxy这个绑定,而不是proxy对象内部的浅层响应式属性。理解这一点后,前端 debug 时就会发现:很多“const 为什么还能改”的疑问,其实是因为把const的语义理解成“深度不可变”了。
5.3 Python 的“变量和常量”约定
Python 里没有语言级的const关键字,大家通常用全大写变量名表示约定层面的常量,比如:
MAX_RETRY = 3 PI = 3.1415926这只是命名约定,实际上仍然可以赋值修改。如果你真想让一个名字不被重新绑定,可以用typing.Final做静态检查提示:
from typing import Final MAX_RETRY: Final = 3 MAX_RETRY = 5 # mypy 静态检查会报错,但解释器不拦Python 这种设计说明了一个有意思的对比:#define是彻底地让某个名字“消失”,const是让某个名字“只读”,而 Python 是全靠程序员自觉。在跨语言切换时,不要默认所有语言的const行为都一样。
6. 常见问题与排查技巧实录
6.1 为什么数组大小报错:宏有值但 const 不一定能用
热词里的“表达式必须含有常量值”是我每次做 C 语言 Code Review 大概率会遇到的问题。如果你的场景是“需要编译期数组大小”,有几个可靠做法:
- 如果值是字面量:直接用
#define ARR_SIZE 64,或者 C++ 里用constexpr int ARR_SIZE = 64;。 - 如果值来自函数:不要指望
const int size = getSize();,因为这不是编译期常量。改为#define固定值,或使用动态内存malloc,或使用变长数组(C99 且编译器支持)。 - 在嵌入式项目里,数组大小通常要固定,所以优先用宏或
constexpr,避免 VLA 带来的栈空间不确定性。
这个问题的根源不是const比宏差,而是很多人不理解“常量表达式”的要求。平时写小程序 demo 感觉不到,一旦进入量产代码,编译期常量的要求就会被严格暴露出来。
6.2 宏参数副作用:i++ 被重复执行
这是宏最经典的一个坑。哪怕你写得很小心:
#define MIN(a, b) ((a) < (b) ? (a) : (b))调用MIN(x, y++)时,y++会因为宏展开而出现在代码里两次,最终自增两次,结果完全偏离预期。排查这类问题有个技巧:如果你发现一个“函数”在多次调用之间表现不一致,先把它所在位置用gcc -E展开看预处理结果。
gcc -E test.c -o test.i直接观察test.i里宏展开成的实际代码,一目了然。这比在脑海里猜文本替换要高效得多。
6.3 头文件重复定义与宏污染
宏还有一个隐蔽问题:头文件 A 定义了一个通用宏BUFFER_SIZE,头文件 B 里也有一个BUFFER_SIZE,但含义完全不同。两个头文件一旦同时被包含,后定义的宏会覆盖前面的定义,编译器不一定报错,甚至只给一个 warning。结果就是某个模块拿到一个被二次定义的文件,编译出来的行为完全不符合预期。
const变量不会有这个问题,因为重名符号在链接阶段会报错,或者可以用static const限制在文件内。所以我在项目里有一条硬性规则:项目级对外常量优先用const/constexpr,只有“局部的、不愿暴露在符号表里的编译期开关和地址计算”才用宏。
6.4 调试技巧:宏在断点里看不到
我在实际调试时经常看到新人盯着MAX变量,疯狂问“为什么 watch 窗口里没有这个变量”,因为他把#define MAX 100当成了普通变量去调试。宏在预处理阶段已经消失了,调试器只能在可执行文件和源码之间做映射,无法还原宏的名字。
如果你需要调试某个由宏产生的值,一个方便的办法是在代码里临时把宏的值赋给一个普通变量:
int debug_max = MAX;然后在断点处 watchdebug_max。这看似笨拙,但比空想高效。另一个做法是用编译器选项展开宏,比如 ARM GCC、Keil、IAR 都支持列出预处理结果,可以对照检查宏展开后的代码。
6.5 解读“#define SCL_L GPIOB->BRR = GPIO_BRR_BR6”这类外设宏
最后回应一下热词里那条 STM32 风格的宏:#define SCL_L GPIOC->BRR = GPIO_BRR_BR6。很多人第一次看见它,会觉得“宏怎么能定义一个赋值语句”,但它的确就是一段文本替换。在 I2C 模拟时序里,经常把 SCL 拉低这一动作定义为宏:
#define SCL_L GPIOB->BRR = GPIO_BRR_BR6 #define SCL_H GPIOB->BSRR = GPIO_BSRR_BR6在代码里写SCL_L;时,预处理后变成GPIOB->BRR = GPIO_BRR_BR6;,等效于执行一次寄存器写入,从而把 PB6 引脚电平拉低。这就是宏的“代码生成”能力。const完全做不到这一点,因为它不能替你做赋值动作。
这类宏最大的问题是缺少do { } while (0)包裹,如果在if后面接宏,分号会造成语法问题。正确的写法是:
#define SCL_L() do { GPIOB->BRR = GPIO_BRR_BR6; } while (0)调用时写成SCL_L();就能安全放在任何语句位置。类似的还有宏定义寄存器地址#define REG_BASE_ADDR (0x4000),配合指针使用,就可以完成内存映射寄存器的访问。了解这些背景后,你再看别人的驱动代码,就不会觉得那些长长得像天书的宏是玄学了。
我个人在实际工程里的习惯是先写const,确认确实需求“编译期裁剪、文本替换、操作代码片段”时再改成#define。遇到函数型宏的场合,能改 inline 函数就改 inline 函数,尤其在做代码评审的时候,我会重点盯那些参数带副作用的宏,因为它们往往是隐蔽 bug 的源头。最后再分享一个小技巧:如果一时分不清该用哪个,就在心里问自己一句“常量是给编译器看的,还是给预处理器看的”,一旦你能准确判断一个值需要在哪个阶段存在,#define和const的边界就彻底清楚了。