前言

很多C/C++方面的书中都介绍到“当变量由const修饰时,这个变量就成为了常量,其值不能再修改”。果真如此吗?在之前的一篇文章C/C++中已初始化/未初始化全局/静态/局部变量/常量在内存中的位置const修饰的局部变量是否是常量就提出过怀疑,这里利用一些例子来确认一下const修饰的变量究竟是不是常量。

还是首先上结论:const修饰的全局变量、静态局部变量以及数组(包括字符串)的值都不能再更改,const修饰的基本类型局部变量的值都可以更改。

注意:对const修饰的变量重新赋值属未定义行为,其影响因编译器而异。

开发环境

  • OS X El Captian (10.11.6)
  • Apple LLVM 7.3.0 (clang-703.0.31)
  • gcc 6.1.0

全局变量、静态变量与数组

这三种类型的变量,其值在编译时就放在只读的__const段或__cstring段内,运行时重新赋值会出现“段错误”。

const int global_int = 1;

int main(int argc, const char *argv[]) {
  const static char static_char = 'a';
  const int local_int_array[] = {1, 2, 3};
  const char local_str1[] = "Hello";
  char *local_str2 = "World";

  int *global_int_ptr = &global_int;
  *global_int_ptr = 2;

  char *static_char_ptr = &static_char;
  *static_char_ptr = 'b';

  int *local_int_array_ptr = &local_int_array[0];
  *local_int_array_ptr = 3;

  char *local_str1_ptr = &local_str1[0];
  *local_str1_ptr = 'W';

  char *local_str2_ptr = local_str2;
  *local_str2_ptr = 'H';

  return 0;
}

如上代码使用C编译器编译,运行时无一例外都会提示出错(自己试试注释掉部分语句?),如clang提示

Thread 1: EXC_BAD_ACCESS (code=2, address=0x100000fa0)

我们来看看这些变量所在的内存位置(这里就简单点直接列出来,具体的方法请参考前面的文章):

变量名内存地址所在段
global_int0x100000f90__const
static_char0x100000f94__const
local_int_array0x100000f98__const
local_str10x100000fa4__const
*local_str20x100000faa__cstring

无一例外,由于const的修饰,这些变量在编译时全都放在了只读的__const段或__cstring段内,在运行时由操作系统保护这些段为只读,对其重新赋值会提示出错

有没有发现上面例子中的local_str2有点不一样,没有const修饰?这种情况在上一篇C中字符串在内存中的位置及读写等分析中已经讨论过,采用指针方式初始化的字符串,其字符串常量本身被放在__cstring段中,而指针则被放在栈中,所以对此字符串重新赋值也会提示出错。

基本类型局部变量

基本类型的局部变量加不加const修饰都是放在栈中的,这里不再验证。这里主要关注的是:在栈中的const修饰的局部变量,是否有如上述全局变量等的操作系统提供的写保护?即这些const修饰的局部变量是只读的吗?

验证的方法很简单啦,找个const修饰的局部变量仍然可以重新赋值的例子就好了。由于C和C++的处理方法不一样,这里分开讨论。

C

我们看这段代码:

#include <stdio.h>

int main(int argc, const char *argv[]) {
  const int local_const_a = 10;
  int *ptr = &local_const_a;

  *ptr = 20;

  printf("local_const_a=%d\n", local_const_a);
  printf("*ptr=%d\n", *ptr);

  return 0;
}

编译输出:

local_const_a=10
*ptr=20

同一个变量有两个不同的值?为谨慎起见我们还是看看内存里local_const_a的值:local_const_a所在内存地址为0x7fff5fbff93c,此地址下内存值为14 00 00 00,即20。

首先我们能确认的是这个const修饰的变量的值已经被改变了。接下来我们再来分析为什么local_const_a仍然会输出10。

看看反汇编代码:

0x100000f00 <+0>:   pushq  %rbp
0x100000f01 <+1>:   movq   %rsp, %rbp
0x100000f04 <+4>:   subq   $0x30, %rsp
0x100000f08 <+8>:   leaq   0x7f(%rip), %rax          ; "local_const_a=%d\n"
0x100000f0f <+15>:  movl   $0xa, %ecx
0x100000f14 <+20>:  leaq   -0x14(%rbp), %rdx
0x100000f18 <+24>:  movl   $0x0, -0x4(%rbp)
0x100000f1f <+31>:  movl   %edi, -0x8(%rbp)
0x100000f22 <+34>:  movq   %rsi, -0x10(%rbp)
0x100000f26 <+38>:  movl   $0xa, -0x14(%rbp)
0x100000f2d <+45>:  movq   %rdx, -0x20(%rbp)
0x100000f31 <+49>:  movq   -0x20(%rbp), %rdx
0x100000f35 <+53>:  movl   $0x14, (%rdx)
0x100000f3b <+59>:  movq   %rax, %rdi
0x100000f3e <+62>:  movl   %ecx, %esi
0x100000f40 <+64>:  movb   $0x0, %al
0x100000f42 <+66>:  callq  0x100000f6c               ; symbol stub for: printf
0x100000f47 <+71>:  leaq   0x52(%rip), %rdi          ; "*ptr=%d\n"
0x100000f4e <+78>:  movq   -0x20(%rbp), %rdx
0x100000f52 <+82>:  movl   (%rdx), %esi
0x100000f54 <+84>:  movl   %eax, -0x24(%rbp)
0x100000f57 <+87>:  movb   $0x0, %al
0x100000f59 <+89>:  callq  0x100000f6c               ; symbol stub for: printf
0x100000f5e <+94>:  xorl   %ecx, %ecx
0x100000f60 <+96>:  movl   %eax, -0x28(%rbp)
0x100000f63 <+99>:  movl   %ecx, %eax
0x100000f65 <+101>: addq   $0x30, %rsp
0x100000f69 <+105>: popq   %rbp
0x100000f6a <+106>: retq   

想必没几个人会耐着性子看汇编,我们把注意力集中在两个printf上。因为涉及到函数调用,这里说明下64位系统下函数调用参数传递的方式,callq函数调用前,第一个参数放在rdi寄存器中,第二个参数放在rsi寄存器中,其中rsi的低32位称为esi

第一个printf

printf("local_const_a=%d\n", local_const_a);

其相关的汇编代码为:

0x100000f08 <+8>:   leaq   0x7f(%rip), %rax          ; "local_const_a=%d\n"
0x100000f0f <+15>:  movl   $0xa, %ecx

0x100000f3b <+59>:  movq   %rax, %rdi
0x100000f3e <+62>:  movl   %ecx, %esi

0x100000f42 <+66>:  callq  0x100000f6c               ; symbol stub for: printf

第一个参数rdi为指向"local_const_a=%d\n"的指针,第二个参数esi0xa,也就是说调用printf时并没有从local_const_a的内存地址获取最新的值20,而是直接拿const声明时的值10,即在编译时相当于对local_const_a作了宏替换

也就是说,因为local_const_aconst修饰且其值确定,所以编译时将所有出现local_const_a的地方直接替换为其值,而不考虑运行时值会不会改变。即可以理解为执行的是

printf("local_const_a=%d\n", 10);

第二个printf

printf("*ptr=%d\n", *ptr);

其相关的汇编代码为:

0x100000f26 <+38>:  movl   $0xa, -0x14(%rbp)

0x100000f14 <+20>:  leaq   -0x14(%rbp), %rdx

0x100000f2d <+45>:  movq   %rdx, -0x20(%rbp)

0x100000f47 <+71>:  leaq   0x52(%rip), %rdi          ; "*ptr=%d\n"
0x100000f4e <+78>:  movq   -0x20(%rbp), %rdx
0x100000f52 <+82>:  movl   (%rdx), %esi

0x100000f59 <+89>:  callq  0x100000f6c               ; symbol stub for: printf

第一个参数rdi为指向"*ptr=%d\n"的指针,第二个参数esi为以ptr为地址的内存的值,即local_const_a的最新值

也就是说,由于是通过指针间接访问,因此编译也是按照这个步骤从指针获取内存地址,然后再从内存地址获取内存值,从而得到了local_const_a的真实值。

C++

C++不同于C是因为C++封堵了常量指针向指针的转换,所以上述C代码用C++编译器直接编译不过去。不过还是有办法通过指针改变const修饰的变量的值得,下面介绍两种,第一种使用C++提供的const_cast转换符来去除变量的const限定;第二种则采用间接的方式来“制造”一个指向const修饰的变量的指针。

方法一

#include <stdio.h>

int main(int argc, const char *argv[]) {
  const int local_const_a = 10;
  int *ptr = const_cast<int *>(&local_const_a);

  *ptr = 20;

  printf("local_const_a=%d\n", local_const_a);
  printf("*ptr=%d\n", *ptr);

  return 0;
}

通过const_cast将常量指针转换为指针,与上面的C版本实际是一样的,输出

local_const_a=10
*ptr=20

方法二

#include <stdio.h>

int main(int argc, const char *argv[]) {
  const int local_const_a = 10;

  int temp = 1;
  int *ptr = &temp;
  ptr += 1;

  *ptr = 20;

  printf("local_const_a=%d\n", local_const_a);
  printf("*ptr=%d\n", *ptr);

  const int *const_ptr = &local_const_a;
  printf("*const_ptr=%d\n", *const_ptr);

  return 0;
}

这种方法实际是利用到了栈,local_const_a进栈后,随机将占位的temp进栈,此时这两个变量在内存中紧邻;然后创造一个指向temp的指针,给这个指针一个int长度的偏移,这个指针就实际指向local_const_a啦!这时通过这个指针进行赋值,修改的就是local_const_a的值。输出

local_const_a=10
*ptr=20
*const_ptr=20

上述这两种方法了C版本一样,直接输出local_const_a为声明时的值,而通过指针输出的是实际值,但都做到了对const变量的重新赋值。

总结

const修饰的全局变量、静态局部变量以及数组(包括字符串)由于保存在只读的段中,其值受操作系统保护,所以这些const修饰的变量就是实际意义上的常量。

const修饰的基本类型局部变量则保存在栈中,运行时并没有任何形式的写保护,其值可以自由修改。这些const修饰只在编译时起作用,编译器会将这些变量当做类似于宏处理;或者说,编译器对这些变量只作“道义上”的保护,但并不能“防小人”。需要注意的一点是,任何对const修饰的变量重新赋值,都是未定义行为(Undefined Behavior),不同编译器可以有不同的处理方法

One More Thing

宏替换是需要值已经确定是吧?如果这个const变量的值只能在运行时才能确定呢?

这里就不做实验了,结果就是不会出现上述一个变量两种值的问题,因为哪种情况下运行时都得先得到变量的内存地址,然后再获取最新的值。

参考

  • 俞甲子, 潘爱民. 程序员的自我修养: 链接, 装载与库[M]. 电子工业出版社, 2009.