为了将抽象概念落到实处,我们不妨先来看一段具体的代码:
#include<stdio.h>intmain(){inta=10;printf("%p\n",&a);int*p=&a;*p=20;printf("%d\n",a);return0;}这段简单代码包含了指针变量并能体现其基础用法,其运行过程更是分析易混淆概念的极佳范例。下面我会先引入核心概念,再结合该代码进行分析,最后梳理每个语句的作用及其运行过程,从而建立指针的初步分析框架。
【指针(值):进程地址空间内的地址编号】
在讨论指针前,明晰内存地址的概念十分必要:存储在内存中的变量会占据一定字节的连续存储空间,该空间的首字节编号称为该变量的内存地址(通常用十六进制数表示)。该地址在当前进程内是唯一的。
—>补充:并非一切变量都有与之对应的内存地址——例如被优化或存放在寄存器中的变量。
回归正题(本段仅讨论指针的数值部分),指针(值)特指内存中某个存储单元的编号,也就是上文所提到的内存地址值。
【指针类型:编译器的解析规则】
指针类型是一种由基本数据类型(被指向的类型,如int、char等)派生而来的派生数据类型。它本身并不存储具体数据,但为内存中的地址值附加了解析规则:
- 确定视域长度:编译器确定该指针指向类型的大小,即解引用时访问的字节数;
- 推导运算步长:当指向完整对象类型的指针进行加减整数运算(如p+1)时,编译器会以指向类型的大小sizeof(*p)作为缩放因子,计算地址偏移的字节数(即编译器按上述视域长度跳过相应字节数);
- 绑定解析视图:将目标字节序列依照编码规则(如补码、IEEE 754)解释为对应的类型值,即绑定解引用表达式的类型语义(决定编译器如何理解这些字节的数学意义)。
—>补充:void*是特殊的“无类型”指针,指针变量自身的字节长度在既定架构下是确定的。但其指向类型的大小(视域长度)未定,编译器无法确定缩放因子,因此不可对该类型指针直接进行算术运算。
【指针变量:存放地址的存储容器】
指针变量是一个具体的存储容器,由系统分配内存空间,用以存放地址值。作为一种变量,如未被register存储类关键字修饰,指针变量本身也拥有可通过取地址符&获取的独立内存地址。
—>补充:指针变量具体存放在栈区、静态区还是堆区,取决于它的定义方式和存储类别,而非指针类型本身。如示例代码中的局部指针变量p被分配在栈上;但若指针变量定义在函数外部或带有static关键字,则分配在静态区;若指针变量本身是动态分配内存块的一部分(如结构体成员),则可能驻留于堆上。
有了以上铺垫,我们回到本文开头的代码,逐条拆解其中涉及指针的声明、赋值、解引用等具体写法,并指出易混淆的细节。
【目标对象的声明与初始化】
代码行int a = 10;定义了一个整型变量a,为后续指针操作提供了一个可寻址的目标对象,但其本身不涉及指针。
该语句在内存(通常为运行时栈)中为标识符a分配了固定大小的存储空间(int类型通常占4字节),并用初值10填充该空间。单次程序运行期间这片空间拥有唯一的内存首地址,该地址可通过取地址运算符获取。
【取地址运算】
代码行printf("%p\n",&a);使用了取地址运算符&,该运算的结果是一个指针常量(不可修改的地址值,属于右值)。
单目运算符&作用于左值(如变量名),返回该左值对应内存单元的首地址。其结果的类型为指向该变量类型的指针,如&a的类型是int*(指向int的指针)。
【指针类型声明与指针初始化赋值】
int* p声明了一个指针变量p,语法形式为类型名* 变量名;。
注意区分:此处的是指针声明符(类型修饰符),表示p是一个指针变量;类型名int决定了该指针的指向类型(即后续解引用时可读写的数据宽度及指针算术运算的步长);指针变量本身是p,int则是其完整类型。
代码行int* p = &a;将指针变量p初始化为a的地址。
赋值号右侧必须是一个合法的地址值(此处为&a),其类型必须与左侧指针类型严格匹配(均为int*)。p的存储内容在初始化后变为a的内存地址,此时称p指向a。
【指针解引用】*p = 20;的作用为通过指针间接修改目标变量的值。
单目运算符是解引用运算符(间接访问运算符),作用于指针变量p,返回该指针所指向内存单元的左值(即可读可写的内存位置),因此p完全等价于它所指向的变量a本身(p=20等价于a=20)。
其中p出现在赋值号左侧时,执行的是写操作(向该单元写入新值);若出现在右侧或表达式中,执行的则是读操作(获取该单元的值)。
真实的代码运行场景总能带给我们对知识更加深刻的理解,而亲手在调试器中观察内存中的变量正是让理论落实的最佳方式,通过调试器正确查找变量的内存地址也无疑是落实这些理解的第一步。由此,我想到了一种可能被踩中的误区:
【如何查找内存地址】
不知你是否曾试图在调试器的内存窗口地址栏里直接输入变量名“a”来跳转,这样尝试后,我并未达到目的,只得到了铺满窗口的“??”——这个窗口不支持变量名解析。不像监视窗口可以计算&a这类表达式,VS的内存窗口地址栏不支持表达式求值。当我们在内存窗口的地址栏里输入“a”时,调试器无法将其关联到源代码中的符号表,而是将a按照十六进制字面量的规则解析为数字0xA。
至于图中显示的地址0x0000000A,它在Windows中属于NULL指针保护区(接近0地址),操作系统禁止任何程序访问这片区域,因而调试器无法读取数据,只能显示“??”。
下面提供三种手动输入地址以外的常见查找方法:
- 在监视窗口或自动窗口中找到变量a,右键点击它并选择“转到内存位置”。内存窗口会自动跳转;
- 在监视窗口中输入“&a”,复制其显示的纯数字地址后粘贴到内存窗口的地址栏;
- 在代码编辑器选中a并拖到内存窗口的地址栏。部分VS版本支持这个操作但并不会尝试解析表达式,而是传递变量对应的地址值。
相信你还记得前文内存地址概念处所述的“该地址在当前进程内是唯一的。”与【目标对象的声明与初始化】处的“单次程序运行期间分配给标识符a的固定大小的存储空间拥有唯一内存首地址。”,为何“当前进程”和“单次程序运行”值得强调?难道存储空间中还藏有一片世外桃源?接下来的内容便会探究我多次运行同一段代码时的一个小发现……
【“唯一”的内存地址为何会变化?】
在反复运行本文的示例代码时,我注意到一个有趣的现象:每次打印出变量a的地址都不相同——一切困惑的肇因。
#include<stdio.h>
int main()
{
int a = 10;
printf(“%p\n”,&a);
int* p = &a;
*p = 20;
printf(“%d\n”,a);
return 0;
}
为了观察这一现象,我在代码行int* p = &a;设置断点,并连续启动程序两次(不修改任何代码),通过调试器查看a的地址:
从两次运行的截图中我们可以看到:因尚未执行*p = 20,变量a的值始终为10,但打印出的内存地址却不相同。这是因为现代操作系统出于安全性考虑,默认启用了地址空间布局随机化(ASLR)。该机制使得每次程序启动时,栈的起始地址都随机偏移,因此局部变量(a)的地址每次运行都会变化。
正因如此,我们不可在代码中强行编码具体的地址值,因为该地址在每次运行时都可能不同。必须始终通过取地址运算符&或指针变量来动态获取当前运行时的真实地址。
通过调试器查看到的地址“0x00AFF7D0”和“0x00F8F93C”同样让我十分好奇——前缀0x的含义是什么?输出格式规范究竟是什么样的?我的查询结果如下:
我们可以将这样的地址看作两个部分:
- 在C语言中,0x为十六进制基数前缀,是一个不可分割的整体词法单元。编译器会按十六进制(基数为16)来解读0x后跟随的数字。
—>0:在C语言语法规则里,以数字0开头,表示这是一个数字常量。
—>x(或X):是十六进制hexadecimal的缩写标记,用以声明其后的数字按照16进制来解读。 - 十六进制数码(如00AFF7D0和00F8F93C):地址的实际有效数值。其中的字母a-f分别代表十进制的10-15且不区分大小写,即0x00AFF7D0和0x00aff7d0在数值上完全等价。
—>补充:0x前缀在代码中写十六进制的整型常量时必须遵守,而在 printf(“%p”) 打印地址时由标准库的实现自动添加,目的是方便我们在调试信息中快速识别其十六进制地址的身份。
【始终如一的main函数起始地址】
仍以这两次运行为例,但这次的截图中我们观察到的内存地址相同,其原因如下:
【代码段地址的固定性】
内存窗口当前显示的是main函数在代码区(.text 段)中的虚拟内存起始地址。与每次运行时地址都会变化的栈变量(如a)不同:现代操作系统的ASLR机制通常不对主可执行文件的代码段(.text)进行随机化,故main函数的地址在连续调试中是相同的,其入口地址在链接时就已经确定。
main函数地址在代码段,存放指令;变量a的地址在栈段,存放数据。
【如何确定图中所示的正是main函数的地址?】
内存窗口中的前几个字节“55 8b ec”是回答这个问题的关键:
在32位(x86)汇编指令中,55是push ebp指令,用于保存旧的栈底指针(ebp);8b ec是mov ebp, esp指令,用于设置新的栈帧。它们组成了C/C++函数(尤其是main函数)开头的标准序言机器码。因此这行数据正是CPU即将执行的指令。
最后,让我们对整段代码的运行过程进行再度梳理:
int a = 10;
在栈上分配一块内存(大小通常为4字节),将其命名为a并存入初始值10。
printf(“%p\n”, &a);
取a的地址并将其以十六进制格式输出。
int* p = &a;
定义指针变量p并将a的地址存入p,p与a建立指向关系。
*p = 20;
解引用p,找到a所在的内存位置并写入20,a的值被覆盖。
printf(“%d\n”, a);
直接读取a的值(20)并打印。
return 0;
程序正常结束。
以上便是本篇博客的全部内容,如有错漏还望您不吝赐教,我会努力提升自己的写作质量并持续分享所学所获,共勉!