C语言指针详解

前言

在学习C语言的时候,我们经常会遇到指针。也是在入门C语言的难点,不像Java无论怎么写,顶多就是会报NullPointerException 空指针异常,也十分好对程序进行排查。

而在学习C语言的时候会经常看到下面的代码:

int *p;
//  或者
int **p;
// 甚至
int ***p;

变量存储过程

如:

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

结果 a = 10

以上程序在计算机内部做了些什么呢?

(1)int a;

在栈中定义了一个变量a,这个a变量会在内存中开辟一个int类型大小的空间,即4个字节,32位二进制位(但是会根据不同的系统会有一些差别)。

存储示意图

(2)int a 先 &a拿到a的地址值(假如为:0x622fe09)

在a的自己的那片空间里存放数值10,由于计算机中所有的数据都是二进制存储的,所以是把10换算成二进制后,再放到自己的空间中。

变量声明过程

具体在内存的存储空间如下:

内存示意图

(3)变量名实际上是以一个名字代表存储地址,在对程序编译连接时由编译系统给每一个变量名分配对应的内存地址,所以从变量中取值,实际上是通过变量名找到对应的内存地址,从该存储单元中读取数据。

所以printf的时候就是通过变量名a获取地址值,然后通过地址值获取该存储单元中的值,并输出。

指针类型

了解了变量的的存储过程,再看指针变量的存储过程。

指针本质

上面知道变量向计算机申请一块内存在来存放变量的值,然后我们可以通过&符(即取地址值符)来获取变量的实际地址,这个值就是变量所占内存块的起始地址(这个值是虚拟地址,并不是物理内存上的地址,只是起到通过这个值去内存找到内存的值。这个和Java中的HashCode有点类似)

如需打印地址值的话:

int a = 10;
printf("%#X\n", &a);

一般都会得到类似这样的一个值 0X62FE04

所以变量只是符号化,变量只是为了让我们编程的时候更加方便,对人友好,可数计算机并不知道变量a,b什么之类的,计算机只认识二进制(也就是010101之类的)。这一点可以通过GCC编译一个C源码查看编译后的代码得到印证。

所以可以认为C会维护一个映射,将程序的变量转换为地址,然后对这个地址进行读写。

规范

(1)和变量一样,一定是先定义后赋值

int a = 10;
int *p;
p = &a;
printf("%#X\n", p);

(2) 定义方式

int* p;
// 或者
int *p;

这两种都是可以的,都是指针类型,尽管第二种int *p 中的*p是连着写的,但是依然是变量名为p,变量类型为int类型的指针(int*)

但是不能像下面这样定义

int a = 10;
int *p;
p = a; 

因为不能把一个具体的值赋值个指针(类型不匹配)

指针存储过程

如:

int a = 10;
int *p;
p  = &b;

(1) 在栈中定义一个指针变量 p, 并在内存中开辟和int一样的内存空间,指针变量也是变量。

(2) &a拿到a的起始地址值0x622fe09,然后把0x622fe09放到p自己的内存空间中

指针存储示意图

指针的作用

刚开始我们可能会想,既然有变量,为什么还需要指针呢?直接用变量名不行吗?

其实这个答案当然是可以的,在JavaScript和Java等语言中就是传值的,如: 我们需要一个功能对一个数字 乘二我们一般会这样做:

Java:

public static void main(String[] args) {
    int a = 10;
    a = doubleHandler(a);
    System.out.println(a);
}
public static int doubleHandler(int a) {
    return a * 2;
}

但这样其实也是适用于C语言的(通过传值的方式)。

int doubleHandler(int a) {
    return a * 2;
}
int main() {
    int a = 10;
    a = doubleHandler(a);
    printf("a的值是: %d\n", a);
    return 0;
}

但是C语言却可以通过传址(变量地址值)的方式来改变变量。

int doubleHandler(int *pa) {
    *pa = (*pa) * 2;
}
//
int main() {
    int a = 10;
    doubleHandler(&a);
    printf("此时a的值是:%d\n", a);
    return 0;
}

解引用

通过上面的:

*pa = (*pa) * 2;
  • doubleHandler方法传入的是一个地址值是怎样拿到地址值对应的值的?

pa中存储的是a的地址值,然后通过运算法*(即*pa) 即可拿到指针所指的地址内容了,所以(*pa) 就拿到了a的值。

  • 为什么指针也需要类型?

因为指针变量存储的是变量内存的首地址,至于要从首地址去多少字节,就需要用指针类型了。如果int类型的指针,就会从首地址开始提取4个字节,char类型的指针则会提取一个字节其余依次类推, 如下图:

image-20210405115229648

p指针也是一个变量,本身村粗也需要占据一块内存,这块内存存储的是a变量的首地址。

当(*p)的时候,就会从这个首地址连续去除4个byte,然后通过int类型的编码方式读取出来。

*p = *p 左右的区别

依然使用上面的例子:

*pa = (*pa) * 2;

可以看到赋值符号两边都有 *p,但是他们的区别是什么呢?

*pa出现在左边即是左值,表示的pa指向int类型变量的内存空间,可以将赋值符号右边的的值赋值给这一块空间。

*pa出现在右边即是右值,表示的是pa指向int类型的变量的值。

例子

int main() {
    int a = 10;
    int b;
    int *p = &b;
    *p = a;
    int c = *p + 1;
    printf("b的值是:%d\n", b);
    printf("c的值是:%d\n", c);
    return 0;
}

结果为:

b的值是:10
c的值是:11

示意图如下:


变量赋值

传值和传址

传值过程中,被调函数的形参作为被调函数的局部变量处理,即在内存中堆栈中开辟空间以存放有主调函数放进来的实参的值,从而成为了实参的一个拷贝。传值的特点就是对形参的任何操作不会影响主调函数实参的值。

而在传址的过程,被调函数的形参虽然作为局部变量在堆栈中开辟了内存空间,但是这时存放的是主调函数放进来的实参变量地址。被调函数对形参的任何操作处理都会间接寻址,即通过堆栈中的存放的地址值访问主调函数的实参的值,进而相互影响。

例子

传值:

#include <stdio.h>
void swap(int a, int b){
    int temp;
    temp = a;
    a = b;
    b = temp;
}
int main() {
    int a = 10, b = 20;
    swap(a, b);
    printf("此时a的值是:%d, b的值是: %d\n", a, b);
    return 0;
}

结果:

此时a的值是:10, b的值是: 20

传址:

#include <stdio.h>
void swap(int *a, int *b){
    int temp = *a;
    *a = *b;
    *b = temp;
}
int main() {
    int a = 10, b = 20;
    swap(&a, &b);
    printf("此时a的值是:%d, b的值是: %d\n", a, b);
    return 0;
}

结果:

此时a的值是:20, b的值是: 10

多级指针

多级指针一般会有二级指针(**p),三级指针(**P),尽管还有 四五六但是很少见也很少用的到。

例子

int a = 10;
int *b = &a;
int **c = &b;
int ***d = &c;

上面的 *b就是一级指针,**b就是二级指针。

内存示意:

多级指针示意图

数组指针

数组是C自带的基本数据结构,其实数组和指针也是有着十分紧密的联系的。

例子

int arr[5] = {10, 20, 8, 7};
printf("%d\n", *arr); // 10
printf("%d\n", arr[0]); // 10
//
printf("%d\n", *(arr + 1)); // 20;
printf("%d\n", arr[1]); // 20

亦或者

int arr[5] = {10, 20, 8, 7};
int *pa = arr; //
printf("%d\n", *pa); // 10
printf("%d\n", pa[0]); // 10

第0个元素的地址称为数组的首地址,数组名实际是指向数组的首地址的,当我们通过下标arr[0]或者*(arr + 1) 去访问数组的元素的时候。

实际上可以看做address[offset], address为首地址即地址起始值,offset为偏移量,这里的偏移量不是直接和address 相加,而是乘以数组类型所在字节数

address + sizeof(int) * offset;

注意

尽管数组名有时可以用来当做指针使用,但是数组名不是指针。

例子:

printf("%u\n", sizeof(arr));
printf("%u\n", sizeof(pa));

结果是:

20 
8

第一个输出20,是因为arr包含了5个int类型的元素,(5 * 4);

第二个输出8,这个也是根据不同的系统而定的,在32位的机器上是4, 在64位的机器上位8,其实代表了系统的寻址能力,也就是指针长度

printf("%u\n", sizeof(pa));
// 等于下面的代码
printf("%d\n", sizeof(int *));

二维数组

例子

int arr[3][2] = {{10, 20}, {30, 40}, {50, 60}};

注意

二维数组和一维数组是一样的,没有本质区别,都是按照线性排列的

10 20 30 40 50 60

并不是想象中的二维矩阵

10 20
30 40 
50 60

当我们向arr[1][1]这样去访问的时候,编译器是如何去计算他们的地址的呢。

如:

int arr[n][m]

那么访问访问arr[a][b]元素地址的计算方式如下:

arr + (m * a + b);

*p++

一维数组

#include <stdio.h>
int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    int len = sizeof(arr) / sizeof(int);
    int *p = arr;
    int i;
    for(; i < len; i++){
        printf("arr[i] = %d\n", *p++);
    }
    return 0;
}

结果:

arr[i] = 10
arr[i] = 20
arr[i] = 30
arr[i] = 40
arr[i] = 50

亦或者

#include <stdio.h>
int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    int len = sizeof(arr) / sizeof(int);
    int i;
    for(; i < len; i++){
        printf("arr[i] = %d\n", *(arr + i));
    }
    return 0;
}

二维数组

#include <stdio.h>
int main() {
    int arr[3][2] = {{10, 20}, {30, 40}, {50, 60}};
    int *p = arr;
    int len = sizeof (arr) / sizeof (int);
    int i;
    for(; i < len; i++){
        printf("%d ",*p++);
    }
    return 0;
}

结果

10 20 30 40 50 60

亦或者:

int main() {
    int arr[3][2] = {{10, 20}, {30, 40}, {50, 60}};
    int i, j;
    for(i = 0; i < 3; i++){
        for(j = 0; j < 2; j++){
            printf("arr[%d][%d] = %d\n", i, j, *(*(arr + i) + j));
        }
    }
    return 0;
}

结果:

arr[0][0] = 10
arr[0][1] = 20
arr[1][0] = 30
arr[1][1] = 40
arr[2][0] = 50
arr[2][1] = 60

函数指针

指针函数

#include <stdio.h>
int max(int a, int b) {
    return a > b ?  a : b;
}
int main() {
    int a = 10, b = 20, maxVal;
    int (*pmax)(int, int) = max;
    maxVal = pmax(a, b);
    printf("maxVal = %d\n", maxVal);
    return 0;
}

解释:

int (*pmax)(int, int) = max;

pmax是指针的名称, int表示该函数值指针返回的是int类型数据,(int,int) 表示该函数指针指向的函数形参是接收两个int。

回调函数

我们在JavaScript或者其他语言中会经常用到回调函数。

通过指针函数我们也可以实现回调函数。

例子

#include <stdio.h> 
int callBack01() {
    printf("callback01 handler\n");
    return  0;
}
int callBack02() {
    printf("callback02 handler\n");
    return  0;
}
int callBack03() {
    printf("callback03 handler\n");
    return  0;
}
int runCallBack(int (*callback)()) {
    printf("进入了 runCallBack function!\n");
    callback();
    printf("离开了 runCallBack function!\n\n");
}
int main() {
//
    runCallBack(callBack01); 
    runCallBack(callBack02);
    runCallBack(callBack03);
//
    return 0;
}

结果:

进入了 runCallBack function!
callback01 handler
离开了 runCallBack function!
-
进入了 runCallBack function!
callback02 handler
离开了 runCallBack function!
-
进入了 runCallBack function!
callback03 handler
离开了 runCallBack function!

0地址

  • 1.当然你的内存中有0地址, 但是0地址通常是个不能随便碰的地址
  • 2.所以你的指针不应该具有0值
  • 3.因此可以用0地址来表示特殊的事情
  • 4.返回的指针是无效的
  • 5.指针没有被真正初始化(先初始化为0)
  • NULL是一个预定定义的符号, 表示0地址
  • 有的编译器不愿意你用0来表示0地址.

void指针

对于void指针表示的是通用指针,可以用来存放任何数据类型引用。

void *ptr; // 定义一个void类型指针

void指针的用处就是在C语言中实现了泛型编程(或者动态内存分配),因此任何指针都可以赋值给void指针,void指针也可以被转换为原来的指针类型,并且这个过程指针的实际所指向的地址不会发生变化。

例子1:

int num;
int *pi = &num;
printf("address of pi: %#X\n", pi);
void* pv = pi;
pi = (int *) pv;
printf("address of pi: %#X\n", pi);

结果:

address of pi: 0X62FE0C
address of pi: 0X62FE0C

例子2

#include <stdio.h>
int main () {
    int a = 3;            // 定义a为整型变量
    int *p1 = &a;         // p1指向int型变量
    char *p2;             // p2指向char型变量
    void *p3;             // p3为无类型指针变量
    p3 = (void *)p1;      // 将p1的值转换为 void*类型, 然后赋值给p3
    p2 = (char *)p3;
    printf("%d", *p2);
    printf("%d", *p2);
//    printf("%d", *p3);    // 错误的
    return 0;
}

结构体指针

结构体可以包含多个成员,这些成员也是和数组一样的排列着的。

例子

struct person {
    int age;
    int height;
    int weight;
};
struct person p1;
p1.age = 18;
p1.height = 180;
p1.weight = 60;
printf("%#X\n", &p1.age);
printf("%#X\n", &p1.height);
printf("%#X\n", &p1.weight);
printf("p的体积%d\n", sizeof (p1));
结构体存储

使用指针注意事项

  • (1) 用指针作为函数返回值需要注意, 函数运行结束后会销毁内部定义的所有局部数据, 包括局部变量, 局部数据和形式参数, 函数返回的指针不能指向这些数据。

  • (2) 函数运行结束后会销毁该函数所有的局部数据. 这里所谓的销毁并不是将局部数据所占用的内存全部清零, 而是程序放弃对它的使用权限, 后面的代码可以使用这块内存.

  • (3) c语言不支持调用函数时返回局部变量的地址, 如果确实有这样的需求, 需要定义局部变量未static变量.

所以返回本地变量地址是危险的,返回全局变量或者静态变量的地址是安全的,

返回在函数内的 malloc 的内存是安全的, 但是容易造成问题,

最好的做法是返回传入的指针

Tips

  • 1.不要使用全局变量来在函数间传递参数和结果
  • 2.尽量避免使用全局变量
  • 3.使用全局变量和静态本地变量的函数是线程不安全的
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容