前情提要:
C++ 关键字const
[!NOTE]
昨天下班和实习生同事一起去吃饭,回出租屋就睡了,结果2点醒了到现在睡不着,就起来把这篇文章写了。刚写完,又困了,先睡了,早上起来再发表吧
2024年4月12日04点54分
三个层次
我们知道const,本质上是修饰一个变量的可写属性的。const 变量从某种角度上来说,就是限制了某个变量的可写属性。这又可以从三个层面来说,分别是编译器层面,操作系统层面和物理层面。也就是说,决定一个数据类型是否是常量有这三个维度的限制。每个维度的限制等级依次递增,首先来看编译器层面;
编译器层面
当我们定义一个const变量之后,编译器就会限定该变量是只读的,如果对其进行改动,编译就会不通过,如:
const int a = 1;
void func1() {
a = 1;//虽然变量的值没变,但本质上是对变量进行写操作,但该变量是只读的,编译器不允许
}
操作系统层面
但其实编译器层面的语法检查可以通过指针的方式绕过去。我们知道,在CPU眼中,所谓变量常量不过都是一个地址的别名,而地址对于CPU来说基本都是可读可写的。所以通过指针的方式,我们可以绕过编译器的检查,如:
const int a = 1;
void func2() {
*(long*)(&a) = 2;
}
通过这种方式,编译器不会在报错,但是运行程序时依然会发生段错误:
这是因为,虽然绕过了编译器。但是操作系统在为const分配内存时,是按照页为单位进行内存分配的,但一个变量是const时,操作系统MMU单元会为内存中这个页打上只读标签,这时如果再对该变量进行写操作,就会导致段错误。
破坏只读标签
我们说操作系统通过给页打上只读标签,那么这种标签可不可以破坏呢?答案是:可以。我们来看下面这个例子
int func3() {
volatile const int c = 1;
cout << c << endl;
*(long*)(&c) = 2;
cout << c << endl;
return c;
}
在这例子中,我们通过在栈区创建了一个const变量c,再通过指针的方式修改它;再进行输出,可以看到,虽然程序最后发现了我们修改了const变量并通过terminated Aborted退出了;但是它依然无法阻止我们修改const类型的 c。
这是为什么呢?下面我们从物理层面分析这个问题;
物理设备层面
我们知道,内存有一种类型叫做ROM;即read-only Memmory。只读内存的写入方式和普通内存相比比较特俗,它是通过配合特定的设备和总线操作来进行的。而我们在全局区定义的const int a
在程序初始化时就被程序放在了C++内存模型的数据段的常量区。这时MMU就会将该虚拟的常量区内存映射到真实的ROM内存上,这时这个变量就算天王老子来了,从软件层面上也无法进行写操作。何以证明呢?
比如
const int a = 1;
void func4() {
*(long*)&a = 1;//不报错,但是段错误
*(long*)(&a + 4) = 1;//不报错,但是段错误
*(long*)(&a - 4) = 1;//不报错,但是段错误
}
上面这3部分内容每个都会触发段错误,就算因为全局变量const int a是放在数据段的常量区,而内存分配又是以页为单位的,所以那个页周围都是无法修改的变量。
但是在上文的func3()里,我们是在栈区开辟的空间,而栈区就不会被MMU映射到ROM上,而只是单纯的给该页打标签,没了物理上的限制,当我们通过指针的方式继续修改a时,操作系统是无法阻止我们的,最多事后发现错误来个Abort。
这是因为栈区不是一个ROM内存区域,所以是可以修改的。同理堆区也可以通过类似的方法验证,本文就不再赘述,留给读者自己探索吧,这里留下我在堆区验证的代码:
int func5() {
const int* ptr = new int(5);
cout << *ptr << endl;
*(long*)(&ptr + 4) = 20;
cout << *(long*)(&ptr + 4) << endl;
*(long*)&ptr = 100;
cout << *(long*)&ptr << endl;
*(long*)(&ptr) = 20;
cout << *ptr << endl;
delete ptr;
return 0;
}