认识指针

指针和内存

理解指针的关键在于理解C程序如何管理内存。归根到底,指针包含的就是内存地址

C程序在编译后,会以三种形式使用内存:

  1. 静态/全局内存

    静态声明的变量分配在这里,全局变量也使用这部分内存。这些变量在程序开始运行时分配,直到程序终止才消失。所有函数都能访问全局变量,静态变量的作用域则局限在定义它们的函数内部。

  2. 自动内存

    这些变量在函数内部声明,并且在函数被调用时才创建。它们的作用域局限在函数内部,而且生命周期限制在函数执行时间内。

  3. 动态内存

    内存分配在堆上,可以根据需要释放,而且直到释放才消失。指针引用分配的内存,作用域局限于引用内存的指针。(这是第二章的重点)

指针通常根据所指的数据类型来声明,然而,指针本身并没有包含所引用数据的类型信息,指针只包含地址。

*可以将变量声明为指针,这是个重载过的符号,因为它也用于乘法和解引用上。

如何阅读指针的声明:倒过来读!(能够很清楚地区分常指针还是指向常量的指针)

例如:const int *pci;

1
2
3
4
1,pci              pci是一个变量
2,*pci pci是一个指针变量
3,int *pci pci是一个指向int的指针变量
4,const int *pci pci是一个指向int常量的指针变量

在不同平台上用一致的方式显示指针的值比较困难。一种方法是把指针转换为void指针,然后用%p格式说明符来显示,如下(%p是十六进制大写形式):

1
printf("Value of pi: %p\n",(void*)pi);

间接引用操作符()返回指针变量指向的值,一般称为解引指针。也可以把解引用操作符的结果用作*左值**,即可以对所引用的值进行修改。

指向函数的指针,例如,函数没有参数也没有返回值,指针的名字为foo:

1
void (*foo)();

关于函数指针,函数指针数组之类的阅读方式或者区分方法见我的博客(https://blog.csdn.net/Alieon/article/details/114598505)

null的概念:null很有趣,但是有时候会被误解。之所以会造成迷惑,是因为我们会遇到几种类似但是又不一样的概念,包括:

  • null概念
  • null指针常量
  • NULL宏
  • ASCII字符NUL
  • null字符串
  • null语句

在C语言中,NULL宏是强制类型转换为void指针的整数常量0,在很多库中定义如下:

1
#define NULL ((void *)0)

而null概念是通过null指针常量来支持的一种抽象。这个常量可能是0,也可能不是0(可以是’\0’)。特别需要注意的是,不能在需要ASCII空字符(NUL)的地方用NULL,如果确实需要可以自定义为:#define NUL ‘\0’

由此可见,在C中,NULL可以确保是0,但是空指针不一定是0(参考:http://c.biancheng.net/view/364.html)。

还需要注意的是,这一部分与C++的区别。在C++中,NULL被定义为0,而不是(void*)0,同时C++使用nullptr表示空指针,可以转换成任何类型的指针或者bool类型,但是不能转换成整数。这么做的主要原因是,C++中不能将void*类型的指针隐式转换成其他指针类型,所以将NULL定义为(void*)0的话并不能起到空指针的作用。

参考:https://blog.csdn.net/u012707739/article/details/77915483

注意

  1. null指针和未初始化的指针不同。未初始化的指针可能包含任何值,而包含NULL的指针则不会引用内存中的任何地址。有趣的是,我们可以给指针赋0,但是不能赋任何别的整数值。
  2. 任何时候都不应该对null指针进行解引用,因为它们并不包含合法地址。执行这样的代码会导致程序终止。

void指针

void指针是通用指针,用来存放任何数据类型的引用,它有两个有趣的性质:

  • void指针具有与char指针相同的形式和内存对齐方式
  • void指针与别的指针永远不会相等,不过,两个赋值为NULL的void指针是相等的

void指针只用做数据指针,而不能用做函数指针。在8.4.2节中,我们将再次研究如何用void指针来解决多态的问题。

sizeof操作符可以作用在void指针上,但是无法作用在void上,如下所示:

1
2
size_t size = sizeof(void*);     // 合法
size_t size = sizeof(void); // 不合法

全局和静态指针

指针被声明为全局或静态,就会在程序启动时被初始化为NULL。下面是全局和静态指针的例子:

1
2
3
4
5
int *globalPi;
void foo(){
static int *staticPi;
...
}

指针的长度和类型

如果考虑应用程序的兼容性和可移植性,指针长度就是一个问题。32位系统中指针长度为4字节,而64位系统中指针的长度为8字节。

使用指针时经常会用到以下四种预定义类型:

  • size_t: 用于安全地表示长度
  • ptrdiff_t: 用于处理指针算术运算
  • intptr_t和uintptr_t: 用于存储指针地址

(1)理解size_t

size_t类型表示C中任何对象所能达到的最大长度。它是无符号整数,因为负数在这里没有意义。

它的目的是提供一种可移植的方法来声明与系统中可寻址的内存区域一致的长度。size_t用做sizeof操作符的返回值类型,同时也是很多函数的参数类型,包括malloc和strlen。

打印size_t类型的值时要小心,这是无符号值,不要选错了格式说明符,建议使用%zu,但有时不能用这个,也可以考虑%u或%lu。

(2)对指针使用sizeof操作符

当需要用指针长度时,一定要用sizeof操作符。

(3)使用intptr_t和uintptr_t

intptr_t和uintptr_t类型用来存放指针地址。它们提供了一种可移植且安全的方法声明指针,而且和系统中使用的指针长度相同,对于把指针转化为整数形式来说很有用。uintptr_t是intptr_t的无符号版本。

1
2
int num;
intptr_t *pi = #

参考博客:https://blog.csdn.net/justlinux2010/article/details/7490420

可以将intptr_t理解成一种void* 类型的指针。

指针操作符

操作符 名称 含义
* 声明指针
* 解引用 解引指针
-> 指向 访问指针引用的结构的字段
+ 对指针做加法
- 对指针做减法
== != 相等、不等 比较两个指针
> >= < <= 关系运算 比较两个指针
(数据类型) 转换 改变指针的类型

(1)给指针加上/减去整数

这种操作很普遍且有用。给指针加上一个整数实际上加的数是这个整数和指针数据类型对应字节数的乘积,减也同理。(这句话就能解决所有指针偏移的题目了,重点就是看指针是什么类型的)例如:

1
2
3
4
short s;
short *ps = &s;
char c;
char *pc = &c;

对ps指针加1,ps指向的地址加2;而对pc指针加1,pc指向的地址加1。

(2)void指针和加法

作为扩展,大部分编译器都允许给void指针做算术运算,这里我们假设void指针的长度是4。不过,试图为void指针加1可能导致语法错误。在下面的代码中,我们声明指针并试图给它加1:

1
2
3
4
int num = 5;
void *pv = &num;
printf("%p\n",pv);
pv = pv+1; // 语法警告

警告内容:warning: pointer of type 'void *' used in arithmetic [-Wpointerarith]

这不是标准C允许的行为,所以编译器发出了警告。不过,pv包含的地址增加了4字节。(实际测试Ubuntu18.04 64bits的时候,void指针+1地址只加了1,而int类型指针+1,地址加了4)

(3)指针相减

一个指针减去另一个指针会得到两个地址的差值。这个差值通常没什么用,但是可以判断数组中的元素顺序。

指针之间的差值是它们之间相差的“单位”数,也就是差的元素个数,而并不是地址之间差的值。结果的符号取决于操作数的顺序,可正可负。

1
2
3
4
5
6
int vector[] = {28,41,7};
int *p0 = vector;
int *p2 = vector+2;

printf("p2-p0: %d\n",p2-p0); // 2
printf("p0-p2: %d\n",p0-p2); // -2

指针的常见用法

指针的用处很多,本节主要探讨多层间接引用常量指针

(1)多层间接引用

也就是指针的指针。可以为代码提供更多的灵活性,但是层数过多会让人迷惑

(2)常量指针

  • 指向常量的指针

    将指针定义为指向常量,这意味着不能通过指针修改它所引用的值。

    1
    2
    3
    4
    5
    6
    int num = 5;
    const int limit = 500;
    int *pi;
    const int *pci;

    pci = &limit;

    把pci声明为指向整数常量的指针意味着:

    • pci可以被修改为指向不同的int或const int
    • 可以解引pci以读取数据
    • 不能解引pci从而修改它指向的数据
  • 指向非常量的常量指针

    声明一个指向非常量的常量指针,这么做意味着指针不可变,但是它指向的数据可变。

    1
    2
    int num;
    int *const cpi = &num;

    这个声明表示:

    • cpi必须被初始化指向非常量
    • cpi不可被修改
    • cpi指向的数据可以被修改
  • 指向常量的常量指针

    这种指针很少使用。指针本身不能修改,它指向的数据也不能通过它来修改。

    注意:并不代表着它指向的数据不能修改,而是不能通过它来修改,因为它也可以指向非常量。

    1
    const int * const cpci = &limit;

C的动态内存管理

指针的强大很大程度上源于它们能追踪动态分配的内存。通过指针来管理这部分内存是很多操作的基础,包括一些用来处理复杂数据结构的操作。

由于可以先分配内存然后释放,因而应用程序可以更灵活高效地管理内存。在C99之前数组是固定长度的,而C99引入了变长数组(柔性数组),数组长度在运行时确定,而不是编译时。

堆上分配的内存,使用分配和释放函数手动实现的,这个过程被称为动态内存管理

动态内存分配

C中动态分配内存的基本步骤有:

  1. 用malloc类的函数分配内存
  2. 用这些内存支持应用程序
  3. 用free函数释放内存

例如:

1
2
3
4
int *pi = (int*)malloc(sizeof(int));
*pi = 5;
printf("*pi:\n",*pi);
free(pi);

sizeof操作符使应用程序更容易移植,还能确定在宿主系统中应该分配的正确字节数。如果写成(int*)malloc(4),根据不同的系统,int长度可能有差别,不具备移植性。

注意:每次调用malloc,程序结束时必须有对应的free函数调用,以防止内存泄漏。

一旦内存释放,就不应该访问它了,但也可能有意外发生,最好的做法是总是把被释放的指针赋值为NULL。

分配内存时,堆管理器维护的数据结构中会保存额外的信息。这些信息包括块大小和其他一些东西,通常放在紧挨着分配块的位置。如果应用程序的写入操作超出了这块内存,数据结构可能会被破坏,还可能造成程序奇怪的行为或者堆损坏。

内存泄漏

如果不再使用已分配的内存却没有将其释放就会发生泄漏,导致内存泄漏的情况可能如下:

  • 丢失内存地址
  • 应该调用free函数却没有调用(有的称为隐式泄漏)

内存泄漏的一个问题就是无法回收内存并重复利用,堆管理器可用的内存将变少。如果内存不断被分配并丢失,那么当需要更多内存而malloc又不能分配时程序可能会终止,用为它用光了内存。在极端情况下,操作系统可能崩溃。

内存泄漏的方式:

  1. 丢失地址

    下面代码说明了当pi被赋值为一个新地址是丢失内存地址的例子。当pi又指向第二次分配的内存时,第一次分配的内存的地址就会丢失。

    1
    2
    3
    4
    int *pi = (int*)malloc(sizeof(int));
    *pi = 5;
    ...
    pi = (int*)malloc(sizeof(int));
  2. 隐式内存泄漏

    如果程序应该释放内存而实际却没有释放,也会发生内存泄漏。

    如果我们不再需要某个对象但它仍然保存在堆上,就会发生隐式内存泄漏。

    注意:在释放用struct关键字创建的结构体时也可能发生内存泄漏。如果结构体包含指向动态分配的内存的指针,那么可能需要在释放结构体之前先释放这些指针。

动态内存分配函数

有一些内存分配函数可以用来管理动态内存,虽然具体可用的函数还是取决于系统,但大部分系统的stdlib.h头文件中都有如下函数:

函数 描述
malloc 从堆上分配内存
realloc 在之前分配的内存块的基础上,
将内存重新分配为更大或者更小的部分
calloc 从堆上分配内存,并清零
free 将内存块返回堆

动态内存从堆上分配,但对于一连串内存分配调用,系统不保证内存的顺序和所分配内存的连续性。不过,分配的内存会根据指针的数据类型对齐。堆管理器返回的地址是最低字节的地址。

使用malloc函数

malloc函数从堆上分配一块内存,所分配的字节数由该函数唯一的参数指定,返回值为void指针。如果内存不足,就会返回NULL。

此函数不会清空或者修改内存,所以我们认为新分配的内存包含垃圾数据。函数原型如下:

void* malloc(size_t);

需要注意的是,如果参数是负数就会引发问题,在有的系统中,参数为负会返回NULL。

以下是malloc函数的典型用法:

1
2
3
4
5
6
int *pi = (int*)malloc(sizeof(int));
if(NULL != pi){
// 指针没问题
}else{
// 无效的指针
}

malloc有可能返回NULL,所以在使用它返回的指针前需要先检查NULL。

两个问题:

1,要不要强制类型转换

C在引入void指针之前,在两种互不兼容的指针之间赋值需要对malloc使用显式转换类型以避免产生警告。但是引入void指针后,可以将void指针赋值给其他任何指针类型,所以就不再需要显式类型转换了。

但是有些开发者认为显式类型转换也是不错的做法,因为:

  • 这样可以说明malloc函数的用意
  • 代码可以和C++(或早期的C编译器)兼容,后两者需要显式类型转换

2,静态、全局指针和malloc

初始化静态和全局变量时不能调用函数。下面代码声明一个静态变量,并试图用malloc来初始化:

1
static int *pi = malloc(sizeof(int));

这样做会产生一个编译时错误消息,全局变量也一样。

对于静态变量,可以通过在后面用一个单独的语句给变量分配内存来避免这个问题。

注意:在编译器看来,作为初始化操作符的 = 和作为赋值操作符的 = 不一样。

1
2
3
4
5
void fun(){
static int *pi;
pi = malloc(sizeof(int));
...
}

而对于全局变量,不能用单独的赋值语句,因为全局变量是在函数和可执行代码外部声明的。

使用calloc函数

calloc函数会在分配的同时清空内存(将内容置为二进制0)。该函数原型如下:

void* calloc(size_t numElements, size_t elementSize);

calloc函数会根据numElementselementSize两个参数的乘积来分配内存,并返回一个指向内存的第一个字节的指针。如果不能分配内存,则会返回NULL。

如果numElements或者elementSize为0,那么calloc可能返回空指针。若calloc无法分配内存就会返回空指针,而且全局变量errno会设置成ENOMEM(内存不足),这是POSIX错误码,有的系统上可能没有。

错误码可以参考:https://www-numi.fnal.gov/offline_software/srt_public_context/WebDocs/Errors/unix_system_errors.html

下例为pi分配了20字节,并全部都为0:

1
2
3
4
5
int *pi = calloc(5, sizeof(int));

//不用calloc的话,用malloc和memset也可以得到一样的结果
int *pi = malloc(5* sizeof(int));
memset(pi, 0, 5*sizeof(int));

如果内存需要清零可以使用calloc,不过执行calloc可能比执行malloc慢。

使用realloc函数

我们可能需要时不时地增加或减少为指针分配的内存,如果需要一个变长数组这种做法尤其有用。而realloc函数会重新分配内存,下面是它的原型:

void* realloc(void* ptr, size_t size);

realloc函数返回指向内存块的指针。接受两个参数,第一个是指向内存块的指针,第二个请求的大小。

重新分配的块大小与第一个参数引用的块大小不同,或大或小。如果重新分配的空间更小,则多余的内存会还给堆,但是内容并不会被清空;如果第二个参数为0,而指针非空,那么就会释放内存。如果无法分配空间,那么原来的内存块就保持不变,但是返回的是空指针,而且errno会设置为ENOMEM

该函数的行为可以概括为:

第一个参数 第二个参数 行为
/ 同malloc
非空 0 原内存块被释放
非空 比原内存小 利用当前块分配更小的块
非空 比原内存大 要么在当前位置要么在其他位置分配更大的块

变长数组

C99引入了变长数组(VLA),允许函数内部声明和创建其长度由变量决定的数组。在下例中,我们分配了一个在函数内使用的char数组:

1
2
3
4
void compute(int size){
char buffer[size];
...
}

这意味着内存分配在运行时完成,且将内存作为栈帧的一部分来分配。另外,如果数组用到sizeof操作符,也是在运行时而不是编译时执行。

VLA的长度不能改变,一经分配其长度就固定了。如果需要一个长度能够实际变化的数组,那么需要使用类似realloc的函数。

用free函数释放内存

动态内存的释放通常用free函数来实现,它的原型是:

void free(void *ptr);

指针参数ptr应该指向由malloc类函数分配的内存地址,这块内存会被返还给。下面是一个简单的例子:

1
2
3
int *pi = (int*)malloc(sizeof(int));
...
free(pi);

如果传递给free函数的参数是空指针,通常它什么都不做。如果传入的指针所指向的内存并不是由malloc类的函数分配,那么该函数的行为将是未定义的。在下面的例子中,分配给pi的是num的地址,不过这不是一个合法的堆地址,而是栈地址:

1
2
3
4
5
void fun(){
int num;
int *pi = &num;
free(pi); // 未定义行为!
}

将已释放的指针赋值为NULL

已释放的指针仍然可能造成问题。如果我们试图解引一个已释放的指针,其行为将是未定义的。所以有些程序员会显式地给指针赋值NULL来表示该指针无效,后续再使用这种指针将会造成运行时异常。

重复释放

重复释放是指两次释放同一块内存。下面是一个简单的例子(也是经常可能会犯的错误):

1
2
3
4
5
int *p1 = (int*)malloc(sizeof(int));
int *p2 = p1;
free(p1);
...
free(p2);

调用第二个free函数会导致运行时异常。

堆管理器很难判断一个块是否已经被释放,因此它们不会试图去检测是否两次释放了同一块内存。有人建议free函数应该在返回时应该将NULL或者其他某个特殊值赋给自身的参数。但是指针是传值的,因此free函数无法显式地给它赋值NULL。

关于指针是传值的,如果不理解可以去做做牛客的这道题:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
void fun ( double *pl,double *p2,double *s)
{
s = ( double*) calloc ( 1,sizeof(double));
*s = *pl + *(p2+1);

main( )
{
double a [2] = {1.1,2.2},b [2] = {10.0,20.0}, *s = a;
fun (a,b,s);
printf ( "%5.2f\n",* s) ;

程序的输出是?(答案选D)

A, 21.10 B, 11.10 C, 12.10 D, 1.10

形参指针s创建实参s的副本,具有相同的地址值(图就懒得画了,可以理解为形参_s,实参s都指向a数组起始位置),但是对形参calloc将分配一段堆内存并将起始地址返回形参s,此时相当于形参已经丢失实参的地址了,所以后续对形参s解引修改都不对实参造成任何改变。

迷途指针

如果内存已经释放,而指针还在引用原始内存,这样的指针就称为迷途指针。迷途指针没有指向有效的对象。

使用迷途指针会造成一系列问题,包括:

(1)如果访问内存,则行为不可预期

(2)如果内存不可访问,则是段错误

(3)潜在的安全隐患

导致这几类问题的情况可能如下:

(1)访问已释放的内存

(2)返回的指针指向的是上次函数调用中的自动变量(在3.2.5节中会讨论)

迷途指针示例

  • 访问已释放的内存

    我们用malloc函数为一个整数分配内存,然后用free函数释放内存

    1
    2
    3
    4
    int *pi = (int*)malloc(sizeof(int));
    *pi = 5;
    printf("*pi: %d\n", *pi);
    free(pi);

    此时pi仍然持有整数的地址,但是这块内存已经被释放了,堆管理器可以重复使用这块内存,且后续存放的可能是非整数数据,这都是不可预期的。

    还有一种迷途指针的情况更难察觉:一个以上的指针引用同一块内存区域而其中一个指针被释放。

  • 指向栈中的内存,但是数据离开作用域,出栈之后地址无效

    块语句,函数返回局部变量的地址,这些情况也会造成迷途指针。

    1
    2
    3
    4
    5
    6
    int *pi;
    {
    int tmp = 5;
    pi = &tmp;
    }
    // 这里pi就变成了迷途指针

    在这个块语句之后,pi就成了迷途指针。因为大部分编译器都把块语句当作一个栈帧tmp变量分配在栈帧上,之后在块语句退出时会出栈。

处理迷途指针

有时候调试指针诱发的问题会很难解决,以下方法可以用来对付迷途指针:

  • 释放指针后置为NULL,后续使用这个指针会终止应用。
  • 写一个特殊的函数代替free函数。
  • 有些系统会在释放后覆写数据(比如 0xDEADBEEF)。在不抛出异常的情况下,如果程序员在预期之外的地方看到这些值,可以认为程序可能在访问已释放的内存。
  • 使用第三方工具检测迷途指针和其他问题。

指针和函数

指针对函数功能的贡献极大。它们能够将数据传递给函数,并允许函数对数据进行修改。可以将复杂数据用结构体指针的形式传递给函数和从函数返回。如果指针持有函数的地址,那么就能动态控制程序的执行流。

要理解函数及其和指针的结合使用,需要理解程序栈。大部分现代的块结构语言,都用到了程序栈来支持函数执行。调用函数时,会创建函数的栈帧并将其推到程序栈上。函数返回时,其栈帧从程序栈上弹出。

使用函数时,有两种情况指针很有用:

  1. 将指针传递给函数:函数可以修改指针所引用的数据,并且可以更高效地传递大块信息
  2. 声明函数指针:可以控制程序的执行流

程序的栈

程序栈

程序栈是支持函数执行的内存区域,通常和堆共享。也就是说,它们共享同一块内存区域,程序栈通常占据这块区域的下部,而堆用的则是上部。

stack.png

程序栈存放栈帧,栈帧有时候也成为活跃记录。栈帧存放函数参数和局部变量;堆管理动态内存。

调用函数时,函数的堆栈被推到栈上,栈向上“长出”一个栈帧。当函数终止时,其栈帧从程序栈上弹出。栈帧所使用的内存不会被清理,但最终可能会被推到程序栈上的另一个栈帧覆盖。

动态分配的内存来自堆,堆向下“生长”。随着内存的分配和释放,堆中会布满碎片。尽管堆是向下生长的,但这只是个大体方向,实际上内存可能在堆上任意位置分配。

栈帧的组织

(2021.4.12 未完待续…)