Table of Contents:

## 内存

程序编译后的text段、data段和bss段

左边的是UNIX/LINUX系统的执行文件,右边是对应进程逻辑地址空间的划分情况。


一般情况,一个程序本质上都是由 bss段、data段、text段三个段组成——这是计算机程序设计中重要的基本概念。

关于data和bss段区别

程序1:

int ar[30000];
void main()
{
......
}

程序2:

int ar[300000] = {1, 2, 3, 4, 5, 6 };
void main()
{
......
}

程序2编译之后所得的可执行文件比程序1大得多
程序1的变量位于bss段,程序2位于data段,两者的区别在于:
全局的未初始化变量存在于bss段中,具体体现为一个占位符,全局的已初始化变量存于data段中,而函数内的自动变量都在栈上分配空间。
bss不占用可执行文件空间,其内容由操作系统初始化(清零),裸机程序需要自行手动清零。
而data段则需要占用可执行文件空间,其内容由程序初始化,因此造成了上述情况。

一个程序在计算机中到底是如何运行的?

程序是保存在硬盘中的,要载入内存才能运行,CPU也被设计为只能从内存中读取数据和指令
我们经常听说多少位的CPU,指的就是寄存器的的位数
缓存的容量是有限的,CPU只能从缓存中读取到部分数据,对于使用不是很频繁的数据,会绕过缓存,直接到内存中读取。所以不是每次都能从缓存中得到数据,这就是缓存的命中率,能够从缓存中读取就命中,否则就没命中。关于缓存的命中率又是一门学问,哪些数据保留在缓存,哪些数据不保留,都有复杂的算法。

虚拟内存到底是什么?为什么我们在C语言中看到的地址是假的?

现在程序员都习以为常,好像就应该是这样子似的。但是在最初可不是这样子的。
虚拟地址:虚拟地址的整个想法是这样的:把程序给出的地址看做是一种虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。
让我们回到程序的运行本质上来。用户程序在运行时不希望介入到这些复杂的内存管理过程中,作为普通的程序,它需要的是一个简单的执行环境,有自己的内存,有自己的CPU,好像整个程序独占整个计算机而不用关心其他的程序。
使不同程序的地址空间相互隔离
如果所有程序都直接使用物理内存,那么程序所使用的地址空间不是相互隔离的。恶意程序可以很容易改写其他程序的内存数据,以达到破坏的目的;有些非恶意、但是有 Bug 的程序也可能会不小心修改其他程序的数据,导致其他程序崩溃。

虚拟地址空间以及编译模式

int a = 1, b = 255;
int main(){
    int c = 1;
    printf("&a = %p, &b = %p &c = %p\n",&a, &b, &c);   // a, b的地址始终是相同的,c的地址会随机的变。
    pause();
    return 0;
}

全局去的变量在编译的时候就确定了,栈区的变量为保护程序安全会进行随机的栈地址。
所谓虚拟地址空间,就是程序可以使用的虚拟地址的有效范围。虚拟地址和物理地址的映射关系由操作系统决定,相应地,虚拟地址空间的大小也由操作系统决定,但还会受到编译模式的影响。
CPU是计算机的核心,决定了计算机的数据处理能力寻址能力,也即决定了计算机的性能。CPU一次(一个时钟内)能处理的数据的大小由寄存器的位数和能支持的数据总线的宽度(也即有多少根数据总线)决定,我们通常所说的多少位的CPU,除了可以理解为寄存器的位数,也可以理解数据总线的宽度,通常情况下它们是相等的。

数据总线主频都是CPU的重要指标:数据总线决定了CPU单次的数据处理能力,主频决定了CPU单位时间内的数据处理次数,它们的乘积就是CPU单位时间内的数据处理量。
需要注意的是,数据总线地址总线不是一回事,数据总线用于在CPU和内存之间传输数据,地址总线用于在内存上定位数据,它们之间没有必然的联系,宽度并不一定相等。实际情况是,地址总线的宽度往往随着数据总线的宽度而增长,以访问更大的内存。

系统实际支持的物理内存

CPU支持的物理内存只是理论上的数据,实际应用中还会受到操作系统的限制,例如,Win7 64位家庭版最大仅支持8GB或16GB的物理内存,Win7 64位专业版或企业版能够支持到192GB的物理内存。
Windows Server 2003 数据中心版专为大型企业或国家机构而设计,可以处理海量数据,分为32位版和64位版,32位版最高支持512GB的物理内存,这显然超出了32位CPU的寻址能力,可以通过两次寻址来实现

编译模式

需要注意的是:
32位的操作系统只能运行32位的程序(也即以32位模式编译的程序),64位操作系统可以同时运行32位的程序(为了向前兼容,保留已有的大量的32位应用程序)和64位的程序(也即以64位模式编译的程序)。
64位的CPU运行64位的程序才能发挥它的最大性能,运行32位的程序会白白浪费一部分资源。

64位的操作系统 为什么能运行32位的程序
主要的原因如下:
1. 64位操作系统的兼容模式:64位操作系统通常会提供兼容模式来运行32位的应用程序。在兼容模式下,操作系统会将32位的应用程序加载到一个特定的运行环境中,使其可以正常运行。
2. 共享库支持:许多操作系统提供了共享库(也称为动态链接库),这些共享库包含32位和64位版本的函数和功能。当运行32位应用程序时,操作系统会加载32位版本的共享库,并使其在运行时提供所需的功能。
3. 指令集兼容性:64位处理器通常支持指令集的兼容性。这意味着它们可以执行32位的指令集,以便运行32位的应用程序。虽然64位处理器支持更多的寄存器和更大的内存寻址能力,但它们仍然可以执行32位指令。
4. 内存管理:64位操作系统可以使用一些技术来模拟32位应用程序的内存管理。例如,可以使用内存分段来模拟32位应用程序的地址空间。

C语言内存对齐,提高寻址效率

CPU 通过地址总线来访问内存,一次能处理几个字节的数据,就命令地址总线读取几个字节的数据。32 位的 CPU 一次可以处理4个字节的数据,那么每次就从内存读取4个字节的数据;少了浪费主频,多了没有用。64位的处理器也是这个道理,每次读取8个字节
以32位的CPU为例,实际寻址的步长为4个字节,也就是只对编号为 4 的倍数的内存寻址,例如 0、4、8、12、1000 等,而不会对编号为 1、3、11、1001 的内存寻址。

将一个数据尽量放在一个步长之内,避免跨步长存储,这称为内存对齐。在32位编译模式下,默认以4字节对齐;在64位编译模式下,默认以8字节对齐。
为了提高存取效率,编译器会自动进行内存对齐
经过笔者测试,对于全局变量,GCC在 Debug 和 Release 模式下都会进行内存对齐,而VS只有在 Release 模式下才会进行对齐。而对于局部变量,GCC和VS都不会进行对齐,不管是Debug模式还是Release模式。

改变对齐方式

内存对齐虽然和硬件有关,但是决定对齐方式的是编译器,如果你的硬件是64位的,却以32位的方式编译,那么还是会按照4个字节对齐。
对齐方式可以通过编译器参数修改,以VS2010为例,更改对齐方式的步骤为:项目 --> 属性 --> C/C++ --> 代码生成 --> 结构成员对齐
最后需要说明的是:内存对齐不是C语言的特性,它属于计算机的运行原理,C++、Java、Python等其他编程语言同样也会有内存对齐的问题。

内存分页机制,完成虚拟地址的映射

现代计算机都使用分页(Paging)的方式对虚拟地址空间和物理地址空间进行分割和映射,以减小换入换出的粒度,提高程序运行效率。

页的大小是固定的,由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。目前几乎所有PC上的操作系统都是用 4KB 大小的页。

分页机制究竟是如何实现的?

MMU部件以及对内存权限的控制

通过页表完成虚拟地址和物理地址的映射时,要经过多次转换,还要进行计算,如果由操作系统来完成这项工作,那将会成倍降低程序的性能(比如cpu上下文的频繁切换),得不偿失,所以这种方式是不现实的。
在CPU内部,有一个部件叫做MMU(Memory Management Unit,内存管理单元),由它来负责将虚拟地址映射为物理地址

在页映射模式下,CPU 发出的是虚拟地址,也就是我们在程序中看到的地址,这个地址会先交给 MMU,经过 MMU 转换以后才能变成了物理地址。

即便是这样,MMU也要访问好几次内存,性能依然堪忧,所以在MMU内部又增加了一个缓存,专门用来存储页目录和页表,这个便是快表。MMU内部的缓存有限,当页表过大时,也只能将部分常用页表加载到缓存,但这已经足够了,因为经过算法的巧妙设计,可以将缓存的命中率提高到 90%,剩下的10%的情况无法命中,再去物理内存中加载页表。

MMU 只是通过页表来完成虚拟地址到物理地址的映射,但不会构建页表,构建页表是操作系统的任务。在程序加载到内存以及程序运行过程中,操作系统会不断更新程序对应的页表,并将页目录的物理地址保存到 CR3 寄存器。MMU 向缓存中加载页表时,会根据 CR3 寄存器找到页目录,再找到页表,最终通过软件和硬件的结合来完成内存映射。
CR3 是CPU内部的一个寄存器,专门用来保存页目录的物理地址。
每个程序在运行时都有自己的一套页表,切换程序时,只要改变 CR3 寄存器的值就能够切换到对应的页表。

Linux下C语言程序的内存布局(内存模型)

程序内存在地址空间中的分布情况称为内存模型(Memory Model)
内存模型由操作系统构建,在Linux和Windows下有所差异,并且会受到编译模式的影响
Windows 在默认情况下会将高地址的 2GB 空间分配给内核(也可以配置为1GB),而 Linux 默认情况下会将高地址的 1GB 空间分配给内核。也就是说,应用程序只能使用剩下的 2GB 或 3GB 的地址空间,称为用户空间(User Space)。

程序代码区、常量区、全局数据区在程序加载到内存后就分配好了,并且在程序运行期间一直存在,不能销毁也不能增加(大小已被固定),只能等到程序运行结束后由操作系统收回,所以全局变量、字符串常量等在程序的任何地方都能访问,因为它们的内存一直都在。
常量区全局数据区有时也被合称为静态数据区,意思是这段内存专门用来保存数据,在程序运行期间一直存在。

#include <stdio.h>

char *str1 = "c.biancheng.net";  //字符串在常量区,str1在全局数据区
int n;  //全局数据区

char* func(){
    char *str = "C语言中文网";  //字符串在常量区,str在栈区
    return str;
}

int main(){
    int a;  //栈区
    char *str2 = "01234";  //字符串在常量区,str2在栈区
    char  arr[20] = "56789";  //字符串和arr都在栈区
    char *pstr = func();  //栈区
    int b;  //栈区

    return 0;
}

注意:
* 1) 全局变量的内存在编译时就已经分配好了,它的默认初始值是 0(它所占用的每一个字节都是0值),局部变量的内存在函数调用时分配,它默认初始值是不确定的,由编译器决定,一般是垃圾值,。
* 2) 函数 func() 中的局部字符串常量"C语言中文网"也被存储到常量区,不会随着 func() 的运行结束而销毁,所以最后依然能够输出。
* 3) 字符数组 arr[20] 在栈区分配内存,字符串"56789"就保存在这块内存中,而不是在常量区,大家要注意区分。

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

Windows下C语言程序的内存布局(内存模型)

不像 Linux,Windows 是闭源的,有版权保护,资料较少,不好深入研究每一个细节,至今仍有一些内部原理不被大家知晓。

用户模式和内核模式

前面一直说“程序的地址空间”,这其实是不严谨的,应该说“进程的地址空间”。
内核空间存放的是操作系统内核代码和数据,是被所有程序共享的
要想访问内核空间,必须借助操作系统提供的 API 函数,执行内核提供的代码,让内核自己来访问,这样才能保证内核空间的数据不会被随意修改,才能保证操作系统本身和其他程序的稳定性。
用户程序调用系统 API 函数称为系统调用(System Call);发生系统调用时会暂停用户程序,转而执行内核代码(内核也是程序),访问内核空间,这称为内核模式(Kernel Mode)。
用户空间保存的是应用程序的代码和数据,是程序私有的,其他程序一般无法访问。当执行应用程序自己的代码时,称为用户模式(User Mode)。

计算机会经常在内核模式和用户模式之间切换:
* 当运行在用户模式的应用程序需要输入输出、申请内存等比较底层的操作时,就必须调用操作系统提供的 API 函数,从而进入内核模式;
* 操作完成后,继续执行应用程序的代码,就又回到了用户模式。

内核代码为什么不独占独立的地址空间?
既然内核也是一个应用程序,为何不让它拥有独立的4GB地址空间,而是要和用户程序共享、占用有限的内存呢?
让内核拥有完全独立的地址空间,就是让内核处于一个独立的进程中,这样每次进行系统调用都需要切换进程
而让内核和用户程序共享地址空间,发生系统调用时进行的是模式切换,模式切换仅仅需要寄存器进栈出栈,不会导致缓存失效;现代CPU也都提供了快速进出内核模式的指令,与进程切换比起来,效率大大提高了

栈(Stack)是什么?栈溢出又是怎么回事?

从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。
通常使用ebp(Extend Base Pointer )寄存器指向栈底,而使用esp(Extend Stack Pointer)寄存器指向栈顶。
extend是因为从16位扩展到32位的原因,64的叫做:rbp,rsp
对每个程序来说,栈能使用的内存是有限的,一般是 1M~8M,这在编译时就已经决定了,程序运行期间不能再改变。如果程序使用的栈内存超出最大值,就会发生栈溢出(Stack Overflow)错误。
在 Linux GCC 下,默认是 8M。
一个程序可以包含多个线程,每个线程都有自己的栈,严格来说,栈的最大值是针对线程来说的,而不是针对程序。

//这个就会造成Stack Overflow
int main(){
    char str[1024*1024*10] = {0};
    return 0;
}

一个函数在栈上到底是怎样的?

函数的调用和栈是分不开的,没有栈就没有函数调用
当发生函数调用时,会将函数运行需要的信息全部压入栈中,这常常被称为栈帧(Stack Frame)或活动记录(Activate Record)。
* 1) 函数的返回地址
* 2) 参数和局部变量。
* 3) 编译器自动生成的临时数据。例如,当函数返回值的长度较大(比如占用40个字节)时,会先将返回值压入栈中,然后再交给函数调用者。当返回值的长度较小(char、int、long 等)时,不会被压入栈中,而是先将返回值放入寄存器,再传递给函数调用者。
* 4) 一些需要保存的寄存器,例如 ebp、ebx、esi、edi 等。之所以要保存寄存器的值,是为了在函数退出时能够恢复到函数调用之前的场景,继续执行上层函数。
理论上 ebp 寄存器应该指向栈底,但在实际应用中,它却指向了old ebp。

关于数据的定位

由于 esp 的值会随着数据的入栈而不断变化,要想根据 esp 找到参数、局部变量等数据是比较困难的,所以在实现上是根据 ebp 来定位栈内数据的。ebp 的值是固定的,数据相对 ebp 的偏移也是固定的,ebp 的值加上偏移量就是数据的地址

函数调用惯例(Calling Convention)

我们知道,一个C程序由若干个函数组成,C程序的执行实际上就是函数之间的相互调用。
函数的参数(实参)由调用方压入栈中供被调用方使用,它们之间要有一致的约定。例如,参数是从左到右入栈还是从右到左入栈,如果双方理解不一致,被调用方使用参数时就会出错。所以,函数调用方和被调用方必须遵守同样的约定,理解要一致,这称为调用惯例(Calling Convention)
一个调用惯例一般规定以下两方面的内容:
* 1) 函数参数的传递方式,是通过栈传递还是通过寄存器传递(64位用寄存器)。
* 2) 函数参数的传递顺序,是从左到右入栈还是从右到左入栈。
* 3) 参数弹出方式。函数调用结束后需要将压入栈中的参数全部弹出,以使得栈在函数调用前后保持一致。这个弹出的工作可以由调用方来完成,也可以由被调用方来完成。
* 4) 函数名修饰方式。函数名在编译时会被修改,调用惯例可以决定如何修改函数名。

在C语言中,存在多种调用惯例,可以在函数声明或函数定义时指定。
在函数声明处是为调用方指定调用惯例,而在函数定义处是为被调用方(也就是函数本身)指定调用惯例。
例如:

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

__cdecl是C语言默认的调用惯例,在平时编程中,我们其实很少去指定调用惯例,这个时候就使用默认的__cdecl。除了 cdecl,还有其他调用惯例:cdecl、stdcall、fastcall等

__cdecl 并不是标准关键字,上面的写法在 VC/VS 下有效,但是在 GCC 下,要使用 attribute((cdecl))。

用一个实例来深入剖析函数进栈出栈的过程

函数出栈只是在增加 esp 寄存器的值,使它指向上一个数据,并没有销毁之前的数据。栈上的数据只有在后续函数继续入栈时才能被覆盖掉,这就意味着,只要时机合适,在函数外部依然能够取得局部变量的值。

栈溢出攻击的原理是什么?

int main(){
    char str[10] = {0};
    gets(str);
    printf("str: %s\n", str);
    return 0;
}


局部数组也是在栈上分配内存,当输入"12345678901234567890" 时,会发生数组溢出,占用“4字节空白内存”、“old ebp”和“返回地址”所在的内存,并将原有的数据覆盖掉,这样当 main() 函数执行完成后,会取得一个错误的返回地址,该地址上的指令是不确定的,或者根本就没有指令,所以程序在返回时出错。
C语言不会对数组溢出做检测,这是一个典型的由于数组溢出导致覆盖了函数返回地址的例子,我们将这样的错误称为“栈溢出错误”。
这里的栈指的是当前的函数栈帧。
除了上面讲到的 gets() 函数,strcpy()、scanf() 等能够向数组写入数据的函数都有导致栈溢出的风险。
栈溢出一般不会产生严重的后果,但是如果有用户精心构造栈溢出,让返回地址指向恶意代码,那就比较危险了,这就是常说的栈溢出攻击。

C语言动态内存分配

静态内存分配,在进程的地址空间中,代码区、常量区、全局数据区的内存在程序启动时就已经分配好了,它们大小固定,不能由程序员分配和释放,只能等到程序运行结束由操作系统回收。
动态内存分配栈区堆区的内存在程序运行期间可以根据实际需求来分配和释放,不用在程序刚启动时就备足所有内存。
使用静态内存的优点是速度快,省去了向操作系统申请内存的时间,缺点就是不灵活,缺乏表现力,例如不能控制数据的作用范围,不能使用较大的内存。而使用动态内存可以让程序对内存的管理更加灵活和高效,需要内存就立即分配,而且需要多少就分配多少,从几个字节到几个GB不等;不需要时就立即回收,再分配给其他程序使用。

动态内存分配函数

  1. realloc失败的时候,返回NULL,原来的内存不改变,不会释放也不会移动
  2. 假如原来的内存后面还有足够多剩余内存的话,realloc的内存=原来的内存+剩余内存,realloc还是返回原来内存的地址; 假如原来的内存后面没有足够多剩余内存的话,realloc将申请新的内存,然后把原来的内存数据拷贝到新内存里,原来的内存将被free掉,realloc返回新内存的地址
  3. 如果size为0,效果等同于free。
  4. 传递给realloc的指针必须是先前通过malloc(), calloc(), 或realloc()分配的
  5. 传递给realloc的指针可以为空,等同于malloc。

malloc函数背后的实现原理——内存池

一种做法是把 malloc() 的内存管理交给系统内核去做,既然内核管理着进程的地址空间,那么如果它提供一个系统调用,可以让 malloc() 使用这个系统调用去申请内存,不就可以了吗?当然这是一种理论上的做法,但实际上这样做的性能比较差,因为每次程序申请或者释放堆空间都要进行系统调用
malloc() 实现是向操作系统申请一块适当大小的堆空间,然后由 malloc() 自己管理这块空间。如果用完了就再向系统申请空间。

链表式内存管理

在程序运行过程中,堆内存从低地址向高地址连续分配,随着内存的释放,会出现不连续的空闲区域,
带阴影的方框是已被分配的内存,白色方框是空闲内存或已被释放的内存。程序需要内存时,malloc() 首先遍历空闲区域,看是否有大小合适的内存块,如果有,就分配,如果没有,就向操作系统申请(发生系统调用)。
内存块(包括已分配和空闲的)的结构类似于链表,它们之间通过指针连接在一起。


malloc() 和 free() 所做的工作主要是对已有内存块的分拆和合并,并没有频繁地向操作系统申请内存,这大大提高了内存分配的效率。

链表式内存管理缺点:

内存池

不管具体的分配算法是怎样的,为了减少系统调用,减少物理内存碎片,malloc() 的整体思想是先向操作系统申请一块大小适当的内存,然后自己管理,这就是内存池(Memory Pool)。
内存池的研究重点不是向操作系统申请内存,而是对已申请到的内存的管理
* 池化技术
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。

空指针和野指针(悬挂指针)

指针指向非法的内存地址,那么这个指针就是悬挂指针,也叫野指针。意为无法正常使用的指针。

#define NULL 0 # 实际上就是让指针=0,就是让指针受控制

解决野指针的方案:
1. 定义指针时 把指针变量赋值成null
2. 释放内存时,先判断指针变量是否为null
3. 释放内存完毕后,把指针变量重新赋值成null

 if (p != NULL)
 {
  free(p);
  p = NULL;
 }

1.使用未初始化的指针

void main()  
{  
    char *p,*p1="hello first!";  
    while((*(p++) = *(p1++)) != 0);  
}  

错处:
p定义时没有初始化,p是指向不定,是一个野指针。
p++可能引用得空间为非法的。编译时不会出错,但运行时会造成程序崩溃。

2.操作NULL指针

#include   
void main()  
{  
    char *p=NULL,*p1="hello first!";  
    while((*(p++) = *(p1++)) != 0);  
}

NULL表示没有任何指向。p++没有任何意义,运行时会造成程序崩溃。这里就应该想到不能对NULL的指针进行直接操作。

3.使用指向释放掉的内存

void function( void )  
{  
    char* str = new char[100];  
    delete[] str;  
    // Do something  
    strcpy( str, "Dangerous!!" );  
}      

之所以说其危险,是因为这是一段完全合乎语法的代码,编译的时候完美得一点错误也不会有,
然而当运行到strcpy一句的时候,问题就会出现,因为在这之前,str的空间已经被delete掉了,所以strcpy当然不会成功。

4.指针所指的对象已经消亡

比如函数中的局部变量在函数调用结束消失

char * getstring3()
{
    char buf[30];
    strcpy(buf, "abcde");
    return buf;          //因为其在栈空间内,掉用此函数返回的是错的
}     
int main()
{
    char * ch = getstring3()
} 

5.指针指向常量区且对其写访问

 {
    char *p = "abcdefg";
    *p = 'z'; //会报错,字符串是在全局区-->常量区
 }

6.realloc函数使用不当

#include<malloc.h>
void main()
{
    char*p,*q;
    p=(char*)malloc(10);
    q=p;   // q之后会成悬空指针
    p=(char*)realloc(q,20);
    //…………………………
}

realloc时会把原来内存空间中的内容复制到新开辟的内存中,返回新的内存首地址。此时q就成了悬空指针

内存泄露

0.函数中调用malloc()返回分配的地址,但是调用函数没用free()

1.malloc两次都指向同一个指针变量

int main(){
    char *p = (char*)malloc(100 * sizeof(char));
    p = (char*)malloc(50 * sizeof(char));
    free(p);
    p = NULL;
    return 0;
}

2.一个指针变量赋给了另一个

int *pOld = (int*) malloc( sizeof(int) );
int *pNew = (int*) malloc( sizeof(int) );
pOld=pNew;

原因都是把之前的指针给覆盖,整没了,然后没用办法用free释放了。

## 指针

C语言指针是什么?1分钟彻底理解C语言指针的概念

一切都是地址
数据和代码都以二进制的形式存储在内存中,计算机无法从格式上区分某块内存到底存储的是数据还是代码。当程序被加载到内存后,操作系统会给不同的内存块指定不同的权限,拥有读取和执行权限的内存块就是代码,而拥有读取和写入权限(也可能只有读取权限)的内存块就是数据。
CPU 访问内存时需要的是地址,而不是变量名和函数名!变量名和函数名只是地址的一种助记符,当源文件被编译和链接成可执行程序后,它们都会被替换成地址。编译和链接过程的一项重要任务就是找到这些名称所对应的地址。

C语言指针变量的定义和使用(精华)

int *a, *b, *c; //a、b、c 的类型都是 int*
int *a, b, c;   //只有a是指针

使用指针是间接获取数据,使用变量名是直接获取数据,前者比后者的代价要高。

对星号*的总结
- 表示定义一个指针变量,以和普通变量区分开,例如int a = 100; int *p = &a;
- 表示获取指针指向的数据,是一种间接操作,例如int a, b, *p = &a; *p = 100; b = *p;

指针变量的运算(加法、减法和比较运算)

数组指针(指向数组的指针)详解

在C语言中,我们将第 0 个元素的地址称为数组的首地址
arr、p、&arr[0] 这三种写法都是等价的

int arr[] = { 99, 15, 100, 888, 252 };
int *p = arr;
int *p = &arr[0];

arr本身就是一个指针”这种表述并不准确,严格来说应该是“arr 被转换成了一个指针”
访问数组方式:
* 1) 使用下标
也就是采用 arr[i] 的形式访问数组元素。本质是编译器帮我们翻译成*(p+i)
* 2) 使用指针
也就是使用 *(p+i) 的形式访问数组元素。

不同的是,数组名是常量,它的值不能改变。数组名只能指向数组的开头,而数组指针可以先指向数组开头,再指向其他元素。

字符串指针(指向字符串的指针)详解

两种方式:

char str[] = "http://c.biancheng.net";
char *str = "http://c.biancheng.net";    //我们将第二种形式的字符串称为**字符串常量**

它们最根本的区别是在内存中的存储区域不一样,字符数组存储在全局数据区或栈区,第二种形式的字符串存储在常量区。
全局数据区和栈区的字符串(也包括其他数据)有读取和写入的权限,
而常量区的字符串(也包括其他数据)只有读取权限,没有写入权限。

#include <stdio.h>
int main(){
    char *str = "Hello World!";
    str = "I love C!"; //正确,可以把指针指向其他地方
    str[3] = 'P'; //错误
    return 0;
}

到底使用字符数组还是字符串常量?
获取用户输入的字符串就是一个典型的写入操作,只能使用字符数组,不能使用字符串常量。

指针变量作为函数参数

int max(int intArr[6], int len){ ... }

不管是int intArr[6]还是int intArr[]都不会创建一个数组出来,编译器也不会为它们分配内存,它们最终还是会转换为int *intArr这样的指针。
int intArr[6]这种形式只能说明函数期望用户传递的数组有 6 个元素,并不意味着数组只能有 6 个元素,真正传递的数组可以有少于或多于 6 个的元素。

// 二维数组的情况
void f(int a[5])-----> void f(int a[]); ----->  void f(int* a);
void g(int a[3][5])-----> void g(int a[][5]); ----->  void g(int (*a)[5]);  
Int printArray(char *p[3])----->Int printArray(char *p[])----->Int printArray(char **p)
一维数组 char a[30]               指针 char*
指针的数组 char *a[30]            指针的指针 char **a
二维数组 char a[10][30]           指向数组的指针 char(*a)[30]

关于形参:
写在函数上形参变量,还是写在函数里面的变量,从CC++编译的角度来讲,是没有任何区别的(分配4字节内存);只不过是写在函数上形参变量 ,具有对外的属性而已

指针和函数

  1. 一级指针做输入
Int showbuf(char *p);   
int showArray(int *array, int iNum)
  1. 一级指针做输出
char * changeNum(int *num)
{
    *num = 10;
}
int a = 1;
changeNum(&a);
  1. 返回一级指针, 注意:返回的指针不能指向局部变量。
char * getBuffer()
{
 char *p = (char *)malloc(100*sizeof(char));
 return p;
}
  1. 二级指针做输入(通常是字符串数组)
int main(int arc ,char *arg[]);          //指针数组
int shouMatrix(int [3][4], int iLine);  //二维字符串数组
  1. 二级指针做输出(改变指针的值)
void getBuffer(char ** buff)
{
 char *p = (char *)malloc(100*sizeof(char));
 *buff = p;
 return;
}

一般都有对应的释放函数

int freeBuffer(char *p)
{
    if (p ==NULL)
    {
        return -1;
    }
    if (p != NULL)
    {
        free(p);
    }
    return 0;
}

二级指针(指向指针的指针)详解

如果一个指针指向的是另外一个指针,我们就称它为二级指针。其实还是指针,只是用来表明不同的含义用

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

C语言不限制指针的级数,每增加一级指针,在定义指针变量时就得增加一个星号*,实际开发中会经常使用一级指针和二级指针,几乎用不到高级指针

空指针NULL以及void指针

在进程的虚拟地址空间中,最低地址处有一段内存区域被称为保留区,这个区域不存储有效数据,也不能被用户程序访问,将 NULL 指向这块区域很容易检测到违规指针。
强烈建议对没有初始化的指针赋值为 NULL。
很多库函数都对传入的指针做了判断,如果是空指针就不做任何操作
NULL 是在stdio.h中定义的一个宏,它的具体内容为:
#define NULL ((void *)0)
C语言没有规定 NULL 的指向,只是大部分标准库约定成俗地将 NULL 指向 0,所以不要将 NULL 和 0 等同起来,例如下面的写法是不专业的:
int *p = 0;
而应该坚持写为:
int *p = NULL;

void *表示一个有效指针,它确实指向实实在在的数据,只是数据的类型尚未确定,在后续使用过程中一般要进行强制类型转换。
void *p = &a; //空类型的指针可以接受任何地址,但是不可以直接使用,需要转换成特定类型
C语言动态内存分配函数 malloc() 的返回值就是void *类型

数组和指针绝不等价,数组是另外一种类型

数组名表示数组的首地址,不幸的是,这是一种非常危险的想法
数组和指针不等价的一个典型案例就是求数组的长度,这个时候只能使用数组名,不能使用数组指针
站在编译器的角度讲,变量名、数组名都是一种符号,它们最终都要和数据绑定起来。
变量名用来指代一份数据,数组名用来指代一组数据(数据集合),它们都是有类型的,以便推断出所指代的数据的长度。
对,数组也有类型,这是很多读者没有意识到的,大部分C语言书籍对这一点也含糊其辞!我们可以将 int、float、char 等理解为基本类型,将数组理解为由基本类型派生得到的稍微复杂一些的类型。sizeof 就是根据符号的类型来计算长度的。
int a[6] = {0, 1, 2, 3, 4, 5}; 它的类型是int [6],表示这是一个拥有 6 个 int 数据的集合int [3]表示3个 int 数据的集合
整数、小数、数组、指针等不同类型的数据都是对内存的抽象,它们的名字用来指代不同的内存块,程序员在编码过程中不需要直接面对内存,使用这些名字将更加方便。
编译器在编译过程中会创建一张专门的表格(符号表) 用来保存名字以及名字对应的数据类型、长度、地址、作用域等信息,sizeof 是一个操作符,不是函数,使用 sizeof 时可以从这张表格中查询到符号的长度
与普通变量名相比,数组名既有一般性也有特殊性:
- 一般性表现在数组名也用来指代特定的内存块,也有类型和长度;
- 特殊性表现在数组名有时候会转换为一个指针,而不是它所指代的数据本身的值。

数组到底在什么时候会转换为指针

数组作函数参数
* 1) 用 a[i] 这样的形式对数组进行访问总是会被编译器改写成(或者说解释为)像 *(a+i) 这样的指针形式。
* 2) 指针始终是指针,它绝不可以改写成数组。你可以用下标形式访问指针,一般都是指针作为函数参数时,而且你知道实际传递给函数的是一个数组。
* 3) 在特定的环境中,也就是数组作为函数形参,也只有这种情况,一个数组可以看做是一个指针。作为函数形参的数组始终会被编译器修改成指向数组第一个元素的指针。

指针数组(数组每个元素都是指针)详解

指针数组(数组的每个元素都是指针)的定义形式一般为:
dataType *arrayName[length];
[ ]的优先级高于* 解读的话可以按由近到远的原则判断
指针数组还可以和字符串数组结合使用

#include <stdio.h>
int main(){
    char *str[3] = {
        "c.biancheng.net",
        "C语言中文网",
        "C Language"
    };
    printf("%s\n%s\n%s\n", str[0], str[1], str[2]);
    return 0;
}

二维数组指针(指向二维数组的指针)详解

二维数组的三种形式:

int *(p1[5]); //指针数组,可以去掉括号直接写作 int *p1[5];    还可以用由近到远的原则判断
int (*p2)[5]; //二维数组指针,不能去掉括号,  它是一个指针,指向5个int数组的指针。

[ ]的优先级高于*( )是必须要加的,如果赤裸裸地写作int *p[4],那么应该理解为int *(p[4]),p 就成了一个指针数组,而不是二维数组指针

二维数组指针的含义:
* 1) p指向数组 a 的开头,也即第 0 行;p+1前进一行,指向第 1 行。
* 2) *(p+1)表示取地址上的数据,也就是整个第 1 行数据。注意是一行数据,是多个数据,不是第 1 行中的第 0 个元素
* 3) *(p+1)+1表示第 1 行第 1 个元素的地址
*(p+1)单独使用时表示的是第 1 行数据,放在表达式中会被转换为第 1 行数据的首地址,也就是第 1 行第 0 个元素的地址,因为使用整行数据没有实际的含义,编译器遇到这种情况都会转换为指向该行第 0 个元素的指针;就像一维数组的名字,在定义时或者和 sizeof、& 一起使用时才表示整个数组,出现在表达式中就会被转换为指向数组第 0 个元素的指针
* 4) *(*(p+1)+1)表示第 1 行第 1 个元素的值

int num[5] = {1,2,3,4,5}
num代表数组首元素地址 &num代表数组的地址 他们的步长不一样

sizeof(*num) //4
sizeof(*&num) 20

int num[3][4] = {1,2,3,4,5,6,7,8,9,10,11,12}
这是一个包含3个元素,每个元素又包含4个int的数组

指针的步长,根据所指内存空间类型来定
num代表第一个4个int的东西 步长为16
*num 步长为4
&num步长为48

函数指针(指向函数的指针)详解

函数指针的定义形式为:
returnType (*pointerName)(param list);
注意( )的优先级高于*,第一个括号不能省略,如果写作returnType *pointerName(param list);就成了函数原型,它表明函数的返回值类型为returnType *
例子:

#include <stdio.h>
//返回两个数中较大的一个
int max(int a, int b){
    return a>b ? a : b;
}
int main(){
    int x, y, maxval;
    //定义函数指针
    int (*pmax)(int, int) = max; //也可以写作int (*pmax)(int a, int b)
    printf("Input two numbers:");
    scanf("%d %d", &x, &y);
    maxval = (*pmax)(x, y);
    printf("Max value: %d\n", maxval);
    return 0;
}

只需一招,彻底攻克C语言指针,再复杂的指针都不怕

C语言标准规定,对于一个符号的定义,编译器总是从它的名字开始读取,然后按照优先级顺序依次解析。对,从名字开始,不是从开头也不是从末尾,这是理解复杂指针的关键!就近原则

main()函数的高级用法:接收用户输入的数据

main() 是C语言程序的入口函数,有且只能有一个,它实际上有两种标准的原型:

int main();
int main(int argc, char *argv[]);

对C语言指针的总结

定 义        含 义
int *p;       p可以指向 int 类型的数据,也可以指向类似 int arr[n] 的数组。
int **p;      p为二级指针,指向 int * 类型的数据。
int *p[n];    p为指针数组。[ ] 的优先级高于 *,所以应该理解为 int *(p[n]);
int (*p)[n];  p为二维数组指针。
int *p();     p是一个函数声明,它的返回值类型为 int *。
int (*p)();   p是一个函数指针,指向原型为 int func() 的函数。

深copy和浅copy

结构体复制的时候,如果有指针的话,只会复制其指针

typedef struct _AdvTeacher
{
 char *name;
 char buf[100];
 int age;
}Teacher ;


Teacher * creatT()
{
 Teacher *tmp = NULL;
 tmp = (Teacher *)malloc(sizeof(Teacher));
 tmp->name = (char *)malloc(100);
 return tmp;
}
void FreeT(Teacher *t)
{
 if (t == NULL)
 {
  return ;
 }
 if (t->name != NULL)
 {
  free(t->name);
 }
}
//copy函数
int copyObj(Teacher *to, Teacher *from)
{
 //*to = *from;//copy;
 memcpy(to, from, sizeof(Teacher));
 to->name = (char *)malloc(100);
 strcpy(to->name, from->name);
}

段错误

段错误是指访问的内存超出了系统给这个程序所设定的内存空间,例如访问了不存在的内存地址、访问了系统保护的内存地址、访问了只读的内存地址等等情况