博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
《深入理解C指针》-第1章 认识指针-阅读所得
阅读量:4098 次
发布时间:2019-05-25

本文共 9883 字,大约阅读时间需要 32 分钟。

           第1章 认识指针

 

 

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

理解指针的工作方式:通过理解组织和管理内存的方式。

本章简要介绍指针、指针操作符以及指针如何与内存相互作用。

1.1节研究如何声明指针、基本的指针操作符和null的概念。C支持好几种不同类型的null,所以仔细研究null会对我们有所启发。

1.2节将细致地介绍几种不同的内存模型。毫无疑问,我们在使用C的过程中肯定会遇到各种内存模型。特定编译器和操作系统下的内存模型会影响指针的使用方式。我们也将仔细研究跟指针和内存模型有关的几种预定义类型。

1.3节会深入探讨指针操作符,包括指针的算术运算和比较。

1.4节探究常量和指针。众多的声明组合提供了有趣通常也很有用的方法。无论你是C程序员新手还是老手,本书都能帮助你深入理解指针,填补你知识结构中的空白。老手可以挑选感兴趣的主题,新手还是按部就班为好。

 

1.1 指针和内存

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

自动内存

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

动态内存

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

 

理解这些内存类型可以更好地理解指针。大部分指针用来操作内存中的数据,因此理解内存的分区和组织方式有助于我们弄清楚指针如何操作内存。

指针变量包含内存中别的变量、对象或函数的地址。对象就是内存分配函数(比如malloc)分配的内存。指针通常根据所指的数据类型来声明。对象可以是任何C数据类型,如整数、字符、字符串或结构体。然而,指针本身并没有包含所引用数据的类型信息,指针只包含地址。

c语言指针的存储大小

指针是C语言中的精华。指针其实就是一个变量,和其他类型的变量一样。在32位机器上,它是一个占用四字节的变量。在64位机器上,他是一个8字节,它与其他变量的不同就在于它的值是一个内存地址,指向内存的某一个地方。即指针是一种存放另一个变量的地址的变量。

Linux下c语言程序的内存布局

内核空间(0xc0000000-0xffffffff) 1G 和 用户空间(0--0xc000000) 3G

操作系统会默认将高地址的1G或2G空间分配给内核,剩下的内存空间是用户空间 

Linux下32位环境的用户空间内存分布情况:

 

程序代码区:主要存放二进制代码。不可修改,有执行权限

常量区:存放一般的常量,有读取权限,没有修改权限。所以数据不能修改

全局数据区:存放全局变量,静态变量等,有读写权限

堆区:由程序员分配和释放,malloc(),calloc()等函数操作的内存

栈区:存放函数的参数,局部变量值

一个程序的代码区,常量区,全局数据区在程序加载到内存的时候就分配好了,大小固定。

函数被调用时,会将参数、局部变量、返回地址等与函数相关的信息压入栈中,函数执行结束后,这些信息都将被销毁。所以局部变量、参数只在当前函数中有效,不能传递到函数外部,因为它们的内存不在了。

常量区、全局数据区、栈上的内存由系统自动分配和释放,不能由程序员控制。程序员唯一能控制的内存区域就是堆(Heap):它是一块巨大的内存空间,常常占据整个虚拟空间的绝大部分,在这片空间中,程序可以申请一块内存,并自由地使用(放入任何数据)。堆内存在程序主动释放之前会一直存在,不随函数的结束而失效。在函数内部产生的数据只要放到堆中,就可以在函数外部使用。

 

1.1.1 为什么要精通指针

指针有几种用途,包括:
1.写出快速高效的代码;
2.为解决很多类问题提供方便的途径;
3.支持动态内存分配;
4.使表达式变得紧凑和简洁;
5.提供用指针传递数据结构的能力而不会带来庞大的开销;(只是传递的指针)
6.保护作为参数传递给函数的数据。

 

用指针可以写出快速高效的代码是因为指针更接近硬件。也就是说,编译器可以更容易地把操作翻译成机器码。指针附带的开销一般不像别的操作符那样大。

很多数据结构用指针更容易实现,比如链表可以用数组实现,也可以用指针实现。然而,指针更容易使用,也能直接映射到下一个或上一个链接。用数组实现需要用到数组下标,不直观,也没有指针灵活。

图1-1比较形象地展示了用数组和指针实现员工链表时的情形。图中左边用了数组,head变量表明链表的第一个元素在数组下标10的位置,每一个数组元素都包含表示员工的数据结构。结构的next字段存放下一个员工在数组中的下标。灰底的元素表示未使用。

 

 

右边显示了用指针实现的等价形式。head变量存放指向第一个员工节点的指针。每个节点存放员工数据和指向链表中下一个节点的指针。

指针形式不仅更清晰,也更灵活。通常创建数组时需要知道数组的长度,这样就会限制链表所能容纳的元素数量。使用指针没有这个限制,因为新节点可以根据需要动态分配。

指针是创建和加强应用的强大工具,不利之处则是使用指针过程中可能会发生很多问题,比如:

1.访问数组和其他数据结构时越界;
2.自动变量消失后被引用;
3.堆上分配的内存释放后被引用;
4.内存分配之前解引指针

 

指针的语法和语义在C规范,但还是有一些情况下规范没有明确定义指针的行为。这类情况下,指针的行为定义为如下之一。

实现定义:有具体的实现,并且有文档描述。实现定义行为的一个例子就是当整数做右移操作时如何补充符号位。

未确定:有某种实现,但是没有文档描述。未确定行为的一个例子是当malloc函数的参数为0时所分配的内存大小。在CERT

Secure Coding Appendix DD有一个未确定行为的列表。

未定义:没有规定,任何事情都有可能发生。这种行为的一个例子是被free函数释放的指针的值。

 

1.1.2 声明指针

通过在数据类型后面跟星号,再加上指针变量的名字可以声明指针。一般情况下,指针指向什么数据类型的变量,指针就声明成什么类型。

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

注意:指针申明的时候最好初始化。int *pi = NULL; 初始化以后的指针在一些操作下野会变成野指针,比如:free释放内存以后。指向被释放的内存的指针也是野指针,这个时候需要把它置为NULL。我理解的野指针就是不可控的,不确定指向的指针都是野指针。

int *pi = NULL;

pi = malloc(sizeof(int) *100);

free(pi);

pi = NULL;

 

对于以上声明,图1-2说明了典型的内存分配是什么样的。三个方框表示三个内存单元,每个方框左边的数字是地址,地址旁边的名字是持有这个地址的变量,这里的地址100只是为了说明原理。就这个问题来说,指针或者其他变量的实际地址通常是未知的(局部变量,函数执行的时候申请栈内存,入栈),而且在大部分的应用程序中,这个值也没什么用。三个点表示未初始化的内存。

 

指向未初始化的内存的指针可能会产生问题。如果将这种指针解引,指针的内容可能并不是一个合法的地址,就算是合法地址,那个地址也可能没有包含合法的数据。程序没有权限访问不合法地址,否则在大部分平台上会造成程序终止,这是很严重的,会造成一系列问题,
变量num和pi分别位于地址100和104。假设这两个变量都占据4字节空间,就像1.2节中所说,实际的长度取决于系统配置。除非特别指明,我们所有的例子都使用4字节长的整数。

注意 本书用100这样的地址来解释指针如何工作,这样会简化例子。当你运行示例代码时会得到不同的地址,而且这些地址甚至

在同一个程序运行几次的时候都可能变化。
记住这几点:
(1)pi的内容最终应该赋值为一个整数变量的地址;
(2)这些变量没有被初始化,所以包含的是垃圾数据;
(3)指针的实现中没有内部信息表明自己指向的是什么类型的数据或者内容是否合法;不过,指针有类型,而且如果没有正确使用,编译器会频繁抱怨。

注意 说到垃圾,我们是指分配的内存中可能包含任何数据。当内存刚分配时不会被清理,之前的内容可能是任何东西。如果之
前的内容是一个浮点数,那把它当成一个整数就没什么用。就算确实包含了整数,也不大可能是正确的整数。所以我们说内容是
垃圾。尽管不经过初始化就可以使用指针,但只有初始化后,指针才会正常工作。

 

内化于心,践行之:

指针按所指向的数据类型进行声明,并且进行初始化。malloc/free进行配套使用,避免内存泄露,free掉内存后,把指针赋值为NULL,避免出现野指针。

 

1.1.3 如何阅读声明

现在介绍一种阅读指针声明的方法,这个方法会让指针更容易理解,那就是:倒过来读。尽管我们还没讲到指向常量的指针,但可以先看看它的声明:

图1-3:倒过来的声明很多程序员都发现倒过来读声明就没那么复杂了。

注意 遇到复杂的指针表达式时,画一张图,我们在很多例子中就是这样做的。

 

1.1.4 地址操作符

地址操作符&会返回操作数的地址。我们可以用这个操作符来初始化pi指针,如下所示:在声明变量pi的同时把它初始化为num的地址。最好写成: int *pi = NULL;

内存赋值:

函数入参可以使用地址操作符:

int a = 4; int b = 5;

int swap(int *a,int b)

ret = swap(&a,&b); //传变量的地址

 

1.1.5 打印指针的值

我们实际使用的变量几乎不可能有100或104这样的地址。不过,变量的地址可以通过打印来确定,如下所示:

pi是指针变量,里面保存的是变量num的地址,所以打印pi就是打印&num的地址

printf函数还有其他几种格式说明符在打印指针的值时比较有用,如表1-2所示。

%p和%x的不同之处在于:%p一般会把数字显示为十六进制大写。如果没有特别说明,我们用%p作为地址的说明符。

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

虚拟内存和指针

让打印地址变得更为复杂的是,在虚拟操作系统上显示的指针地址一般不是真实的物理内存地址。虚拟操作系统允许程序分布在机器的物理地址空间上。应用程序分为页(或帧),这些页表示内存中的区域。应用程序的页被分配在不同的(可能是不相邻的)内存区域上,而且可能不是同时处于内存中。如果操作系统需要占用被某一页占据的内存,可以将这些内存交换到二级存储器中,待将来需要时再装载进内存中(内存地址一般都会与之前的不同)。这种能力为虚拟操作系统管理内存提供了相当大的灵活性。

每个程序都假定自己能够访问机器的整个物理内存空间,实际上却不是。程序使用的地址是虚拟地址。操作系统会在需要时把虚拟地址映射为物理内存地址。
这意味着页中的代码和数据在程序运行时可能位于不同的物理位置。应用程序的虚拟地址不会变,就是我们在查看指针内容时看到的地址。操作系统会帮我们将虚拟地址映射为真实地址。操作系统处理一切事务,程序员无法控制也不需要关心。理解这些问
题就能解释在虚拟操作系统中运行的程序所返回的地址。

Linux中虚拟内存的管理是特别重要的知识点,需要花大量时间去理解实际,用"结硬寨,打呆仗"的方法,啃下来。我们应用程序操作的地址都是虚拟地址,访问这个虚拟地址,实际是访问的这个虚拟地址映射的物理地址。

// 假设0x560012300 为寄存器地址,(volatile unsigned int *)0x560012300将地址强制转化为 unsigned int型指针,然后在取值*,实际是取寄存器地址0x560012300  里面的值。

reg = *(volatile unsigned int *)0x560012300  这样我们就可以通过操作reg变量从而操作寄存器的值。

 

1.1.6 用间接引用操作符解引指针

间接引用操作符(*)返回指针变量指向的值,一般称为解引指针。

下面的例子声明和初始化了num和pi:

然后下面的语句就用间接引用操作符来显示5,也就是num的值:

我们也可以把解引操作符的结果用做左值。术语“左值”是指赋值操作符左边的操作数,所有的左值都必须可以修改,因为它们会被赋值。

下面的代码把200赋给pi指向的整数。因为它指向num变量,200会被赋值给num。图1-5说明了这个操作如何影响内存

 

1.1.7 指向函数的指针

指针可以声明为指向函数,声明的写法有点难记。下面的代码说明如何声明一个指向函数的指针。函数没有参数也没有返回值。指针的名字是foo:

内化于心,践行之:

函数指针使用,两个库之间进行数据交互。比如,

A库注册一个回调函数AB_RegisterCb(A_Processdata),并实现它接口int A_Processdata(int a,int b)

B库调用这个回调函数AB_RegisterCb( int (* fn)(int a,int b)  ),函数指针fn指向A_Processdata函数地址入口。B库调用fn,把B库处理的数据入参,A库调用接口A_Processdata处理这个参数。

 

1.1.8 null的概念

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

null概念;
null指针常量;
NULL宏;

ASCII字符NUL;

null字符串;
null语句。

NULL被赋值给指针就意味着指针不指向任何东西。null概念是指指针包含了一个特殊的值,和别的指针不一样,它没有指向任何内存区域。两个null指针总是相等的。尽管不常见,但每一种指针类型(如字符指针和整数指针)都可以有对应的null指针类型。

null概念是通过null指针常量来支持的一种抽象。这个常量可能是也可能不是常量0,C程序员不需要关心实际的内部表示。

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

这就是我们通常理解为null指针的东西。这个定义一般可以在多种头文件中找到,包括stddef.h、stdblib.h和stdio.h。

如果编译器用一个非零的位串来表示null,那么编译器就有责任在指针上下文中把NULL或0当做null指针,实际的null内部表示由实现定义。使用NULL或0是在语言层面表示null指针的符号。
ASCII字符NUL定义为全0的字节。然而,这跟null指针不一样。C的字符串表示为以0值结尾的字符序列。null字符串是空字符串,不包含任何字符。最后,null语句就是只有一个分号的语句。

接下来我们会看到,null指针对于很多数据结构的实现来说都是很有用的特性,比如链表经常用null指针来表示链表结尾。如果要把null值赋给pi,就像下面那样用NULL

注意 null指针和未初始化的指针不同。未初始化的指针可能包含任何值,而包含NULL的指针则不会引用内存中的任何地址。

有趣的是,我们可以给指针赋0,但是不能赋任何别的整数值。看一下下面的赋值操作:

指针可以作为逻辑表达式的唯一操作数。比如说,我们可以用下面的代码来测试指针是否设置成了NULL。

下面两个表达式都有效,但是有冗余。这样可能更清晰,但是没必要显式地跟NULL做比较。

如果这里pi被赋了NULL值,那就会被解释为二进制0。在C中这表示假,那么倘若pi包含NULL的话,else分支就会执行。

注意 任何时候都不应该对null指针进行解引,因为它并不包含合法地址。执行这样的代码会导致程序终止。

 

那到底用不用NULL?

0的含义随着上下文的变化而变化,有时候可能是整数0,有时候又可能是null指针。看一下这个例子:

我们习惯了重载的操作符,比如星号可以用来声明指针、解引指针或者做乘法。0也被重载了。我们可能觉得不舒服,因为还没习惯重载操作数。

最好使用NULL对指针进行初始化而不是使用0,因为这样会提醒自己是在用指针。

 

内化于心,践行之:

(1)null指针和未初始化的指针不同。未初始化的指针可能包含任何值,而包含NULL的指针则不会引用内存中的任何地址。每次申明指针后必须初始化为NULL,养成好习惯。

(2)任何时候都不应该对null指针进行解引,因为它并不包含合法地址。执行这样的代码会导致程序终止。

(3)最好使用NULL对指针进行初始化而不是使用0,因为这样会提醒自己是在用指针。

 

1.1.9 void指针

void指针是通用指针,用来存放任何数据类型的引用。下面的例子就是一个void指针:

它有两个有趣的性质:

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

任何指针都可以被赋给void指针,它可以被转换回原来的指针类型,这样的话指针的值和原指针的值是相等的。在下面的代码中,int指针被赋给void指针然后又被赋给int指针:

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

注意 用void指针的时候要小心。如果把任意指针转换为void指针,那就没有什么能阻止你再把它转换成不同的指针类型了。

sizeof操作符可以用在void指针上,不过我们无法把这个操作符用在void上,如下所示:

size_t是用来表示长度的数据类型。

 

1.1.10 全局和静态指针

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

图说明了内存布局,栈帧被推入栈中,堆用来动态分配内存,堆上面的区域用来存放全局/静态变量。这只是原理图,静态和全局变量一般放在与栈和堆所处的数据段不同的数据段中。栈和堆将在3.1节讨论。

全局和静态指针的内存分配,下图地址是从上往下,上面的地址较高(全局变量和静态变量/全局指针和静态指针这些变量都存储在静态区数据段(bss/data))

 

1.2 指针的长度和类型

如果考虑应用程序的兼容性和可移植性,指针长度就是一个问题。在大部分现代平台上,数据指针的长度通常是一样的,与指针类型无关,char指针和结构体指针长度相同。不过,函数指针长度可能与数据指针长度不同。

指针长度取决于使用的机器和编译器。比如,在现代Windows上,指针是32位或64位长。对于DOS和Windows 3.1来说,指针则是16位或32位长

 

1.2.1 内存模型

机器内存模型

Linux

32位 char(8)  short(16)  int(32) long(32)  pointer(32)

64位 char(8)  short(16)  int(32) long(64)  pointer(64)

 

1.2.2 指针相关的预定义类型

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

1. size_t 用于安全地表示长度。
2. ptrdiff_t 用于处理指针算术运算。
3. intptr_t和uintptr_t 用于存储指针地址。

 

1.2.3 理解size_t

size_t的声明是实现相关的。它出现在一个或多个标准头文件中,比如stdio.h和stblib.h,典型的定义如下:

size_t用做sizeof操作符的返回值类型,同时也是很多函数的参数类型,包括malloc和strlen.

size_t类型表示C中任何对象所能达到的最大长度。它是无符号整数,因为负数在这里没有意义。它的目的是提供一种可移植的方法来声明与系统中可寻址的内存区域一致的长度。

 

2. 对指针使用sizeof操作符

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

 

3. 使用intptr_t和uintptr_t

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

uintptr_t是intptr_t的无符号版本。对于大部分操作,用intptr_t比较好。uintptr_t不像intptr_t那样灵活。下面的例子说明如何使

用intptr_t:

# ifndef __intptr_t_defined

typedef int                    intptr_t;
#  define __intptr_t_defined
# endif
typedef unsigned int        uintptr_t;
#endif
 

 

1.3 指针操作符

指针操作符

 

 

1.3.1 指针算术运算

数据指针可以执行以下几种算术运算:

1. 给指针加上整数;
2. 从指针减去整数;
3. 两个指针相减;
4. 比较指针。

 

1. 给指针加上整数

给指针加上一个整数实际上加的数是这个整数和指针数据类型对应字节数的乘积。

在下面的代码中,我们给指针加3,pi变量会包含地址112,就是pi本身的地址:

注意防止访问了越界的数组:指针指向了自己,这样没什么用,但是说明了在做指针算术运算时要小心。访问超出数组范围的内存很危险,应该避免。没有什么能保证被访问的内存是有效变量,存取无效或无用地址的情况很容易发生。

void指针和加法作为扩展,大部分编译器都允许给void指针做算术运算

 

 2.从指针减去整数

就像整数可以和指针相加一样,也能从指针减去整数。减去整数时,地址值会减去数据类型的长度和整数值的乘积.

 

3. 指针相减

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

 

1.3.2 比较指针

指针可以用标准的比较操作符来比较。通常,比较指针没什么用。然而,当把指针和数组元素相比时,比较结果可以用来判断数组元素的相对顺序

 

1.4 指针的常见用法

1.4.1 多层间接引用(二级指针的使用)

指针可以用不同的间接引用层级。把变量声明为指针的指针并不少见,有时候称它们为双重指针。一个很好的例子就是用传统

的argv和argc参数来给main函数传递程序参数。

下例使用了三个数组。第一个数组是用来存储书名列表的字符串数组:

 

int a =100;

int *p1 = &a;
int **p2 = &p1;

如果一个指针指向的是另外一个指针,我们就称它为二级指针,或者指向指针的指针。

假设有一个 int 类型的变量 a,p1是指向 a 的指针变量,p2 又是指向 p1 的指针变量,它们的关系如下图所示:
C语言二级指针(指向指针的指针)演示图

p2  = &p1  

*p2 = p1 = &a

**p2 = *p1 = a

 

 

1.4.2 常量与指针

1. 指向常量的指针

2. 指向非常量的常量指针

3. 指向常量的常量指针

4. 指向“指向常量的常量指针”的指针

 

常量指针

常量指针:如果在定义指针变量的时候,数据类型前用const修饰,被定义的指针变量就是指向常量的指针变量,指向常量的指针变量称为常量指针,格式如下

const int *p = &a; //常量指针

在这个例子下定义以下代码:
int a,b;
 const int *p=&a //常量指针
//那么分为一下两种操作
*p=9;//操作错误
p=&b;//操作成功

因为常量指针本质是指针,并且这个指针是一个指向常量的指针,指针指向的变量的值不可通过该指针修改,但是指针指向的值可以改变。

 

指针常量

指针常量:顾名思义它就是一个常量,但是是指针修饰的。 
格式为:
int * const p //指针常量
在这个例子下定义以下代码:

int a,b;

int * const p=&a //指针常量
//那么分为一下两种操作
*p=9;//操作成功
p=&b;//操作错误

因为声明了指针常量,说明指针变量不允许修改。如同次指针指向一个地址该地址不能被修改,但是该地址里的内容可以被修改.
 

 

 

 

 

 

 

 

转载地址:http://lrrii.baihongyu.com/

你可能感兴趣的文章
[C++基础]034_C++模板编程里的主版本模板类、全特化、偏特化(C++ Type Traits)
查看>>
C语言内存检测
查看>>
Linux epoll模型
查看>>
Linux select TCP并发服务器与客户端编程
查看>>
Linux系统编程——线程池
查看>>
基于Visual C++2013拆解世界五百强面试题--题5-自己实现strstr
查看>>
Linux 线程信号量同步
查看>>
C++静态成员函数访问非静态成员的几种方法
查看>>
类中的静态成员函数访问非静态成员变量
查看>>
C++学习之普通函数指针与成员函数指针
查看>>
C++的静态成员函数指针
查看>>
Linux系统编程——线程池
查看>>
yfan.qiu linux硬链接与软链接
查看>>
Linux C++线程池实例
查看>>
shared_ptr简介以及常见问题
查看>>
c++11 你需要知道这些就够了
查看>>
c++11 你需要知道这些就够了
查看>>
shared_ptr的一些尴尬
查看>>
C++总结8——shared_ptr和weak_ptr智能指针
查看>>
c++写时拷贝1
查看>>