ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言指针详解:从内存地址到数组与函数指针的底层原理

C语言指针详解:从内存地址到数组与函数指针的底层原理 很多学C语言的朋友都会在指针这里卡住我当年也一样。第一次看int *p a;这行代码只觉得「又是星号又是取地址符号到底谁指向谁」后来写了几年C回头再看指针其实一点都不玄——它不过是一个装着地址的变量而已。但就是这么一个简单的概念牵涉到内存布局、类型系统、函数调用、数据结构几乎贯穿了C语言的所有核心场景。这篇内容我把指针的定义和基础操作从底层讲透配合可直接上机的例子和踩坑经验。适合刚学完分支循环、正在和指针搏斗的新手也想给那些「会用但说不清原理」的同学补上缺失的一环。读完你至少能回答三个问题指针变量里到底存了什么*出现在不同位置是什么意思为什么数组名和指针总被拿来说事1. 指针到底是个什么东西从内存地址开始说起1.1 现代计算机的「房间号」机制要理解指针先得理解内存。你可以把内存想象成一栋巨大的楼房每个房间都有唯一的门牌号。这个门牌号就是内存地址。CPU要读写数据必须知道数据在哪个房间——也就是地址。我们平时写int a 10;编译器会分配一个房间给a房间号假设是0x7ffe2a1b4c房间里放的数值是10。你写a这个名字编译器就帮你翻译成「去房间0x7ffe2a1b4c读写」的操作。这是变量名存在的意义——它是对某个内存地址的人类可读别名。但有些场景你不想直接依赖变量名。比如你想写一个函数它能修改调用者的变量。C语言默认是值传递函数内部改形参不影响实参。你想让函数能「找到」实参的内存地址然后直接朝那个地址写入。这时候你需要一个变量专门来保存地址。这个变量的名字不重要重要的是它里头的值——一个地址。这个变量就是指针变量。顺带一提地址到底是多大在64位系统上地址通常是64个bit也就是8个字节。所以指针变量在绝大多数64位Linux/Windows/macOS上自身占8字节。这和它指向的数据类型无关——char *和double *的指针变量大小一样大。1.2 为什么不能直接拿变量名干这事有人会问a不就能取地址吗为什么还要定义指针变量去存它因为地址是一个「值」你要保存值就得有容器。你可以把a临时用掉比如传给某个函数函数执行完就没了。如果你想长期保存这个地址比如让一个全局结构体记录某个变量的地址或者把一个地址从A函数传递到B函数再传回来你就需要变量来保存它。这个变量就是指针。所以一句话总结指针变量是一种特殊的变量它的值是另一个变量的内存地址。2. 指针的定义和初始化星号的位置与类型的作用2.1 定义语法把星号读成「指向」C语言里定义一个指针变量的形式是int *p; // p 是一个指向 int 的指针 char *cp; // cp 是一个指向 char 的指针 double *dp; // dp 是一个指向 double 的指针这里最容易误解的是int *p;到底是在定义int还是*p正确理解是p的类型是「指向int的指针」也就是int *。*只是语法标记告诉编译器这个变量是个指针。星号放哪里并不影响语义——下面三种写法完全等价int* p1; int *p2; int * p3;但我个人强烈建议写int *p;星号靠变量名。为什么因为如果你写int* p1, p2;新手会以为p1和p2都是指针实际上只有p1是指针p2是普通int变量。这个坑太经典了。把星号贴近变量名至少视觉上会提醒你「星号是跟着变量名走的不是跟着类型走的」。要同时定义两个指针必须写成int *p1, *p2;2.2 指针类型的作用决定「步长」和「解引用宽度」很多教材强调指针类型但说得不透。指针变量里存的是地址地址在内存里就是一个数字。那为什么需要类型因为当你对这个指针做操作时编译器需要知道两件事从该地址出发一次读写多少个字节地址加1实际加的字节数是多少比如int *p解引用*p时编译器从p存的地址开始读取4个字节假设int占4字节并把这些字节解释成一个int。而char *cp解引用*cp只读取1个字节。再看加减运算p 1实际地址不是数字加1而是加上sizeof(int)也就是4。cp 1则加1。这就是「指针运算的步长」由类型决定。你在纸上画内存格子图的时候会发现int指针移到下一个格子必须跳过4个格。所以定义指针时一定要写明类型类型就是指针的「尺子」。2.3 初始化的正确姿势区分初始化和赋值定义指针后它不会自动变成NULL或者任何安全值。如果未初始化它里面是一个随机地址也就是传说中的「野指针」。访问它后果不可预测——运气好是段错误运气不好是悄悄改乱内存bug极难查。推荐的初始化方式int a 10; int *p a; // 定义时直接用另一个变量的地址初始化最安全 int *p2 NULL; // 暂时不知道该指向啥先置空 int *p3 malloc(sizeof(int)); // 指向自己申请的堆空间还有一种常见写法是「先定义后赋值」int *p; p a;注意这里的p前面没有*。很多新手会写*p a;然后报编译错误或者警告——那是给p指向的内存赋值不是给p赋值。规则很简单定义语句里带类型的是定义指针单独使用时不带星号的指针变量名代表地址本身带星号代表它指向的内存。3. 指针的简单操作取地址、解引用、算术3.1 取地址 运算符是一元运算符作用在变量上得到该变量的内存地址。比如int a 5; int *p a;这时候p的值就是a的地址。也可以在函数调用里直接取地址scanf(%d, a)就是把a的地址传给scanfscanf拿到地址后往那个内存地址写入从键盘读到的值。实在这里用取地址的原因就是C语言值传递无法让被调函数直接修改调用者局部变量必须传地址。注意只能用在左值上也就是有明确内存位置的实体。你不能写(a 1)因为表达式a 1的结果没有独立的内存地址。3.2 解引用* 运算符*是一元取内容运算符dereference作用在指针上表示「访问指针所指向的那个变量」。它有两个方向读或写。int a 5; int *p a; printf(%d\n, *p); // 读输出 5 *p 100; // 写把 a 的值改成 100*p 100;等价于a 100;。这里*p出现时编译器帮你从p中取出地址再访问该地址。解引用时最怕的是p不是有效地址。如果你对一个NULL指针做*p 100;程序立即崩溃segmentation fault这其实算好事——至少它崩得明显。真正恐怖的是悬空指针指向的内存已经被释放解引用时可能读到脏数据或者写坏别的变量。3.3 指针算术移动地址的刻度指针可以做、-、、--运算。但前面已经说过移动的字节数由指针类型决定。int arr[] {10, 20, 30, 40}; int *p arr; // p 指向 arr[0] p; // 现在指向 arr[1] printf(%d\n, *p); // 输出 20 printf(%ld\n, p - arr); // 输出 1两个地址的差值除以元素大小注意p不是把地址加1而是加sizeof(int)。这样设计的目的就是让指针运算和数组元素一一对应——你移动的「格子」是元素格子不是字节。两个同类型指针相减结果是它们之间间隔的元素个数比如p2 - p1。这个操作经常用来计算数组区间长度。但指针相加没有意义两个地址加一起是啥毫无意义C标准明确禁止。3.4 指针比较判断指向关系指针可以比较大小前提是它们指向同一个数组或者同一个地址空间的合法内存。常见用法if (p arr p arr 4) { // p 在数组范围内 }这在检查越界时有用。注意比较不同类型的指针或者指向不同内存块不是同一个数组的元素的指针行为未定义别这么干。4. 指针和数组、字符串、函数的关系经典三连4.1 数组名会「退化」成指针C语言里数组名绝大多数情况下会隐式转换为指向第一个元素的指针。所以int *p arr;是合法的和int *p arr[0];等价。这里有个经典考题「数组名和指针有什么区别」答案是数组名不是指针变量它没有自己的内存来保存地址——数组名相当于一个「地址常量」。你用sizeof(arr)能得到整个数组的大小比如int arr[10]是40字节但sizeof(p)只是一个指针变量的大小8字节。另外arr是不允许的因为数组名是常量不能自增而指针变量可以。在函数传参时数组会完全退化为指针void foo(int arr[]) { // 本质上是 int *arr printf(%ld\n, sizeof(arr)); // 永远是指针大小不是数组大小 }所以想在函数里知道数组长度必须额外传一个长度参数。这就是为什么你会经常看到void foo(int arr[], int n)这种签名。4.2 字符串常量与字符指针字符串字面量如hello在C语言中是一个字符数组长度6含结尾的\0。表达式hello的类型是char *确切说是const char *。所以你可以写char *s hello;s指向这个字符串的首字符。打印用%sprintf(%s\n, s);。注意这里的字符串内容通常存放在只读数据区不能通过s修改它。做实验时千万别写s[0] H;——很可能直接崩溃。如果你想修改字符串内容就得用可写数组char buf[] hello; buf[0] H; // 合法为什么能改因为buf是一块在栈上的数组字符串字面量的内容被拷贝进了这个数组。而char *s hello里的s直接指向字面量本身字面量在只读区。4.3 指针数组和数组指针别搞混热搜词里提到「指针数组存放字符串」。这两个表达差一个词含义完全不同指针数组是一个数组每个元素是指针。定义char *names[3];意思是names是一个由3个char *组成的数组。可以这样存字符串指针const char *names[] {Alice, Bob, Charlie};数组指针是指向数组的指针。定义int (*ptr)[5];意思是ptr指向一个内含5个int的数组。它和int *ptr[5]指针数组只差一个括号但语义天差地别。记忆技巧看运算符优先级。[]优先级高于*。没有括号时int *ptr[5]先结合[]所以是数组加了括号int (*ptr)[5]先结合*所以是指针这个指针指向整个数组。4.4 函数指针把代码也当成地址函数在内存中也有地址函数名同样会退化为函数指针。定义方式int add(int a, int b) { return a b; } int (*fp)(int, int) add; int result fp(3, 4); // 调用 add(3,4)fp是一个指针指向一个「接收两个int参数、返回int」的函数。调用时可以直接fp(3,4)也可以(*fp)(3,4)两种写法都合法。函数指针最常见的用途是回调函数比如排序时的比较器int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return x - y; } int arr[] {4, 1, 3, 2}; qsort(arr, 4, sizeof(int), cmp_int);这里cmp_int就是被传进去的回调函数qsort在内部反复调用它来决定排序顺序。没有函数指针这种通用库函数几乎没法写。4.5 指针的指针为什么char **常见int **pp表示指向指针的指针。它的应用场景很明确你想在函数里修改调用者的指针变量本身。比如你写一个分配内存的初始化函数void init_ptr(int **pp) { *pp malloc(sizeof(int)); // 把 malloc 的结果写到调用者传进来的指针变量里 **pp 42; }调用int *p NULL; init_ptr(p); printf(%d\n, *p); // 输出 42如果不传p只是传int *p进去那么在函数里修改p的指向根本影响不了外面的p。这就是二级指针存在的意义想要修改指针本身就得用指向指针的指针。5. 常见指针错误与调试心得5.1 空指针和野指针两种脾气的坑空指针值为NULL解引用它会立刻段错误这是最好对付的。处理方式很简单解引用前判断if (p ! NULL) { *p 1; }不少系统库函数如果参数为NULL也会返回错误这些在写代码时要注意防御。野指针定义后未赋值就拿来解引用。它的地址是随机的可能是一个恰好可读的区域读出来是垃圾值也可能越权访问导致段错误。最麻烦的是它不一定会立刻崩而是在数十万次运行后才出现诡异的数据错乱。排除这种bug极其花时间因为出错位置往往不在写野指针的地方而是在碰巧被它影响到的某个变量。悬空指针指向的内存已经free或者函数返回后失效但你仍然持有指针并解引用。比如int *f(void) { int a 10; return a; // 返回局部变量的地址函数结束后该内存无效 }或者int *p malloc(sizeof(int)); free(p); *p 10; // 释放后仍在写入这两个都是典型的未定义行为。修复悬空指针的根治方法是「谁分配谁释放」以及释放后立即将指针置为NULLfree(p); p NULL;这样即使之后误用了p也只会是空指针错误而不是悬空访问至少好排查。5.2 排查指针问题的实用招式说几个我用得最顺手的排查方法第一招地址打印。不要只猜直接打印指针的值和解引用后的值printf(p %p, a %p\n, (void*)p, (void*)a); printf(*p %d\n, *p);%p必须传void*所以打印时强转一下。看到两个地址是否一致立刻知道 p 有没有指向 a。第二招缩小范围注释法。当大段代码里指针操作复杂时把可疑的代码逐段注释或者写一个最小复现的例子从几百行缩到几十行确认问题是不是在指针操作上。这种方法虽然笨但对指针这种「一旦错就波及全局」的问题最有效。第三招编译器警告别无视。开启-Wall -Wextra编译很多指针类型不匹配、未初始化的问题编译器都会警告。比如int *p a;如果类型不一致编译器会提醒你存在不兼容的指针类型。第四招使用ValgrindLinux。遇到内存错误valgrind能定位到第几行、哪个指针非法访问它给出错误行号通常一查一个准。云服务器上跑不了图形环境但命令行用同样方便。5.3 一个完整的综合小实验把前面内容串起来写一个小程序验证各种指针操作。建议你亲手敲一遍然后改动各种符号用printf看输出#include stdio.h int main(void) { int a 10; int *p a; printf(a 的值: %d\n, a); printf(a 的地址: %p\n, (void*)a); printf(p 的值: %p\n, (void*)p); printf(p 的地址: %p\n, (void*)p); printf(*p 的值: %d\n, *p); *p 20; printf(修改后 a: %d\n, a); int arr[] {1, 2, 3, 4}; int *pa arr; printf(*(pa2) %d\n, *(pa 2)); printf(arr[2] %d\n, arr[2]); printf(pa[2] %d\n, pa[2]); return 0; }pa[2]和arr[2]完全等价——下标运算符[]本质上是*(pa 2)的语法糖。这也是为什么很多人说「指针传递数组」时函数里可以用arr[i]的形式访问因为编译器都翻译成指针运算了。6. 再聊几句指针学习的进阶路线指针的内容肯定不止上面这些但基础定义的路线走扎实后面的东西都是在这之上加码。你接着会碰到指针和结构体结合struct Node *next是链表的基本形态理解了「一个结构体里存着下一个结构体的地址」这个概念就理解了链表。多级指针继续套娃int ***ppp完全有可能出现尤其在一些框架代码里。原则一样每一层*就是一层间接引用。复杂声明解读像void (*signal(int sig, void (*func)(int)))(int);这种「左右互搏」的声明你只要掌握「从内往外读、从变量名往两边拆」的方法也不怕。如果非要我总结一句其实不是总结是教训学指针不要背语法要在纸上画内存格子。每个变量占几格指针变量里存了个地址数字这条箭头从哪个格子指到哪个格子。画上几十道题你自然就通了。我见过几乎所有能快速学会指针的人都是靠画图先想清楚再上机验证。最后分享一个小技巧上机的时候每改一个变量就打印一次它三个属性——值、地址、如果是指针就打印解引用结果。把这些对应关系看熟了指针在你的脑子里就不再是抽象概念了。
返回列表