深入理解C指针

​#C语言 #​ #C指针 #​

1 认识指针

指针:一个存放内存地址的变量

1.1 指针和内存

image

阅读指针声明时候,可以选择倒过来读,会更容易理解。

指针被赋值为NULL时候,会被解释为二进制0.

void指针
具有和char指针相同的形式和内存对齐方式。
只能用作数据指针,不能用作函数指针。

全局指针和静态指针在程序启动时候被初始化为NULL。

1.2 指针的类型和长度

size_t类型是无符号整数,经常用于循环计数器、数组索引等。

在部分for循环中 如果中间的判断条件为 size_t a >= 0​,则可能会出错,该循环不会停止

例如 for (szie_t i = n; i >= 0; i--) {...}​, 当i为零时,由于是无符号整数,再减1还为整数,所以一直循环。(如果希望使用size_t 中间判断可以改为 ​i != SIZE_MAX​)

指针的长度可以通过sizeof​操作符判断

1.3 指针操作符

image

1.3.1 指针算数运算

  • 指针加上/减去整数
    给指针加上⼀个整数实际上加的数是这个整数和指针数据类型对应字节数的乘积
  • 两个指针相减
    通常是判断数组中的元素顺序
  • 比较指针
    判断数组元素的相对顺序

数组的名字,返回的只是数组地址,也就是数组第⼀个元素的地址。

1.4 指针的常见用法

1.4.1 多次间接引用

例如传统的argv 和argc 参数来给main 函数传递程序参数

俗称指针的指针

1.4.2 常量与指针

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

const int *p​; 不可以修改p指向的值,但是可以更改p的指向,让它指向其他值

int const *p​和const int *p​ 是等价的

  • pci 可以被修改为指向不同的整数常量;

  • pci 可以被修改为指向不同的⾮整数常量;

  • 可以解引pci 以读取数据;

  • 不能解引pci 从⽽修改它指向的数据。

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

意味着指针不可以变,但是指向的数据可以变(因此如果指向const定义的一些非指针变量会出错)

例如int *const cpi = …

  • cpi 必须被初始化为指向 常量变量;

  • cpi 不能被修改;

  • cpi 指向的数据可以被修改。

3、指向常量常量指针

不可以修改指针、不可以修改指针指向的数据

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

下面第二行为指向”指向常量常量指针“的指针

const int * const cpci = &limit;
const int * const * pcpci;

2 C的动态内存管理

2.2 动态内存分配函数

stdlib.h​image

malloc

void* malloc(size_t);

如果内存不足,返回NULL,此外新分配的内存会包含垃圾数据。

calloc
分配内存同时清空内存,也即设置为二进制0

void *calloc(size_t numElements, size_t elementSize);

realloc

image

alloca

函数返回后会自动释放内存,但是要求系统运行是基于栈

c99中引入了可变长数组

char* buf[size];

2.3 free

void free(void *ptr);

如果传入空指针,则什么都不做

2.5 动态内存分配技术

资源获取即初始化

GNU的扩展要⽤到RAII_VARIABLE 宏,它声明⼀个变量,然后给变量关联如

下属性:
⼀个类型;
创建变量时执⾏的函数;
变量超出作⽤域时执⾏的函数

#define RAII_VARIABLE(vartype,varname,initval,dtor) \

void *dtor* ## varname (vartype * v) { dtor(*v); } \

vartype varname _*attribute__((cleanup(_dtor* ## varname))) =

(initval)


void raiiExample() {
RAII_VARIABLE(char*, name, (char*)malloc(32), free);
strcpy(name,"RAII Example");
printf("%s\n",name);
}

3 指针和函数

3.1 程序的栈和堆

程序栈是支持函数执行的内存区域,通常和堆共享。
程序栈在这个区域的下部,堆是上部。

程序栈存放栈帧 (stack frame) ,栈帧存放函数参数和局部变量。

栈帧: 组成

  • 返回地址:函数完成后要返回的程序内部地址。

  • 局部数据存储:为局部变量分配的内存。

  • 参数存储:为函数参数分配的内存。

  • 栈指针和基指针:运⾏时系统⽤来管理栈的指针。

栈指针通常指向栈顶部。基指针(帧指针)通常存在并指向栈帧内部的地

址,⽐如返回地址,⽤来协助访问栈帧内部的元素。这两个指针都不是C指

针,它们是运⾏时系统管理程序栈的地址。

样例

float average(int *arr, int size) {
int sum;
printf("arr: %p\n",&arr);
printf("size: %p\n",&size);
printf("sum: %p\n",&sum);
for(int i=0; i<size; i++) {
sum += arr[i];
}
return (sum * 1.0f) / size;
}

arr: 0x500
size: 0x504
sum: 0x480

参数地址和局部变量地址之间的空档,保存的是运⾏时系统管理栈所需要的
其他栈帧元素。

系统在创建栈帧时,将参数以跟声明时相反的顺序推到帧上,最后推⼊局部变量,如图所⽰。在这个例⼦中,arr 在size之后被推⼊。通常,接下来会推⼊函数调⽤的返回地址,然后是局部变量。推⼊它们的顺序跟其在代码中列出的顺序相反。

image

3.2 通过指针传递和返回数据

传递指向常量的指针是C中常⽤的技术,效率很⾼,因为我们只传了数据的地址,能避免某些情况下复制⼤量内存。不过,如果只是传递指针,数据就能被修改。如果不希望数据被修改,就要传递指向常量的指针。

实现自己的free函数

#define safeFree(p) saferFree((void**)&(p))

void saferFree(void **pp) {
if (pp != NULL && *pp != NULL) {
free(*pp);
*pp = NULL;
}
}

用法:

int main() {
int *pi;
pi = (int*) malloc(sizeof(int));
*pi = 5;
printf("Before: %p\n",pi);
safeFree(pi);
printf("After: %p\n",pi);
safeFree(pi);
return (EXIT_SUCCESS);
}

第⼆次调⽤safeFree 宏给它传递NULL 值不会导致程序终⽌,因为saferFree 函数检测到这种情况并忽略了这个操作。

3.3 函数指针

顾虑:处理器可能无法配置流水线作分支预测

3.3.1 声明函数指针

image

3.3.2 使用函数指针

#include <stdio.h>
#include <malloc.h>

int (*fptr1)(int);
int square(int num) {
return num * num;
}

int main() {
int n = 5;
fptr1 = square; // fptr1 = &square
printf("Hello, World! %d\n", fptr1(n));
return 0;
}

输出

Hello, World! 25

image

有时候可以为函数指针声明一个类型定义
typedef int (*funcptr)(int);

#include <stdio.h>
#include <malloc.h>

typedef int (*funcptr)(int);
int square(int num) {
return num * num;
}

int main() {
int n = 5;
funcptr fptr1;
fptr1 = &square;
printf("Hello, World! %d\n", fptr1(n));
return 0;
}

3.3.3 传递函数指针

简单例子

int add(int num1, int num2) {
return num1 + num2;
}

int sub(int num1, int num2) {
return num1 - num2;
}

typedef int (*fptrOperator)(int, int);

int compute(fptrOperator operator, int num1, int num2) {
return operator(num1, num2);
}

void test_compute() {
printf("%d\n",compute(add, 5, 6));
printf("%d\n",compute(sub, 5, 6));

}

输出
11
-1

3.3.4 返回函数指针

使用一个函数,基于输入的字符返回相应的函数指针。

fptrOperation select(char opcode) {
switch(opcode) {
case '+': return add;
case '-': return subtract;
}
}

int evaluate(char opcode, int num1, int num2) {
fptrOperation operation = select(opcode);
return operation(num1, num2);
}

printf("%d\n",evaluate('+', 5, 6));
printf("%d\n",evaluate('-', 5, 6));

3.3.5 使用函数指针数组

函数指针数组可以基于某些条件选择要执⾏的函数

typedef int (*operation)(int, int);
operation operations[128] = {NULL};
// 或
int (*operations[128])(int, int) = {NULL};

// 数组赋值
void initializeOperationsArray() {
operations['+'] = add;
operations['-'] = subtract;
}

int evaluateArray(char opcode, int num1, int num2) {
fptrOperation operation;
operation = operations[opcode]; // 数组函数指针选择
return operation(num1, num2);
}

3.3.6 比较函数指针

add 函数被赋给fptr1 函数指针,然后和add 函数的地址做⽐较

fptrOperation fptr1 = add;
if(fptr1 == add) {
printf("fptr1 points to add function\n");
} else {
printf("fptr1 does not point to add function\n");
}

主要是可以方便部分情况下动态修改操作

3.3.7 转换函数指针

可以将指向某个函数的指针转换为其他类型的指针

4 指针和数组

4.1 数组概述

4.1.1 一维数组

一维数组是线性结构,用索引访问成员,由于c语言没有强制规定边界,无效索引会造成不可预期的行为。
int vector[5]

数组的内部表⽰不包含其元素数量的信息,数组名字只是引⽤了⼀块内存。对数组做sizeof 操作会得到为该数组分配的字节数,要知道元素的数量,只需将数组长度除以元素长度,如下所⽰,打印结果是5:​printf("%d\n", sizeof(vector)/sizeof(int));

4.1.2 二维数组

需要程序把二位数组映射到一维,也即先把数组的第一行放进内存,接着是第二行。。

4.2 指针表示法和数组

pv[i] 等价于 *(pv + i)

⽅括号表⽰法会取出pv 中包含的地址,⽤指针算术运算把索引i 加上,然后解引新地址返回其内容。

数组和指针的差别

int vector[5] = {1, 2, 3, 4, 5};
int *pv = vector;

vector[i] ⽣成的代码和*(vector+i) ⽣成的不⼀样,vector[i] 表⽰法⽣成的机器码从位置vector 开始,移动i 个位置,取出内容。⽽*(vector+i) 表⽰法⽣成的机器码则是从vector 开始,在地址上增加i ,然后取出这个地址中的内容。尽管结果是⼀样的,⽣成的机器码却不⼀样

sizeof 操作符对数组和同⼀个数组的指针操作也是不同的。对vector 调⽤sizeof 操作符会返回20,就是这个数组分配的字节数。

4.3 malloc创建一维数组

int *pv = (int*) malloc(5 * sizeof(int));
for(int i=0; i<5; i++) {
pv[i] = i+1;
}
//或者
for(int i=0; i<5; i++) {
*(pv+i) = i+1;
}

警告 在上个例⼦中我们⽤的是 (pv+i) ⽽不是pv+i ,因为解引操作符的优先级⽐加操作符⾼,先解引第⼆个表达式的指针,得到指针所引⽤的值,然后再给这个整数加上i 。这不是我们要的效果,⽽且,如果我们把这个表达式作为左值,编译器会抱怨。所以,为了让代码正确⼯作,我们需要强制先做加法,然后才是解引操作。

4.5 传递一维数组

当传递一维数组时候需要传递数组长度。

4.8 传递多维数组

传递多维数组时候有多种情况,

1、可以直接传递行和列

printf 语句通过给arr 加上前⾯⾏的元素数(i*cols) 以及表⽰当前列的j来计算每个元素的地址。

void display2DArrayUnknownSize(int *arr, int rows, int cols) {
for(int i=0; i<rows; i++) {
for(int j=0; j<cols; j++) {
printf("%d ", *(arr + (i*cols) + j));
}
printf("\n");
}
}
display2DArrayUnknownSize(&matrix[0][0], 2, 5);
// 这种情况在函数内无法使用数组下标arr[i][j]这样,因为没有将指针声明为二维数组

2、传递一个列固定的二维数组指针,再加一个行数

void display2DArray(int arr[][5], int rows) {
for (int i = 0; i<rows; i++) {
for (int j = 0; j<5; j++) {
printf("%d", arr[i][j]);
}
printf("\n");
}
}
void main() {
int matrix[2][5] = {
{1, 2, 3, 4, 5},
{6, 7, 8, 9, 10}
};
display2DArray(matrix, 2);
}4.

4.9 动态分配二维数组

4.9.1 分配可能不连续的内存

首先分配外层“数组”,然后对于每行用分别用malloc分配

int rows = 2;
int columns = 5;
int <span style="font-weight: bold;" data-type="strong">matrix = (int </span>) malloc(rows * sizeof(int *));
for (int i = 0; i < rows; i++) {
matrix[i] = (int *) malloc(columns * sizeof(int));
}

image

4.9.2 分配连续内存

第⼀种⾸先分配“外层”数组,然后是各⾏所需的所有内存。

第⼀个malloc 分配了⼀个整数指针的数组,⼀个元素⽤来存储⼀⾏的指针,这就是图4-16中在地址500处分配的内存块。第⼆个malloc 在地址600处为所有的元素分配内存。

int rows = 2;
int columns = 5;
int <span style="font-weight: bold;" data-type="strong">matrix = (int </span>) malloc(rows * sizeof(int *));
matrix[0] = (int *) malloc(rows * columns * sizeof(int));
for (int i = 1; i < rows; i++)
matrix[i] = matrix[0] + i * columns;

image

第⼆种⼀次性分配所有内存​

后⾯的代码⽤到这个数组时不能使⽤下标,必须⼿动计算索引,如下代码⽚段所⽰。

int *matrix = (int *)malloc(rows * columns * sizeof(int));
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
*(matrix + (i*columns) + j) = i*j;
}
}

不能使⽤数组下标是因为我们丢失了允许编译器使⽤下标所需的“形态”信息。实际项目中用这种方法较少。

5 指针和字符串

5.1 字符串基础

字符串是以ASCII字符NUL结尾的字符序列。

NUL和NULL不同,NUL定义为\0 ,NULL定义为((void*)0)

C中有两种类型的字符串。

单字节字符串由char 数据类型组成的序列。

宽字符串由wchar_t 数据类型组成的序列。

字符串的长度是字符串中除了NUL 字符之外的字符数。为字符串分配内存时,要记得为所有的字符再加上NUL 字符分配⾜够的空间

字符常量是单引号引起来的字符序列,通常是一个字符组成,在c中,他们的类型的int

printf("%d\n",sizeof(char));  // 1
printf("%d\n",sizeof('a')); // 4

5.1.1 字符串声明

字面量:字符串字面量是用双引号引起来的字符序列,他们位于字符串字面量池中。

字符数组:char header[30]

字符指针:char *header

5.1.2 字符串字面量池

定义字⾯量时通常会将其分配在字⾯量池中,这个内存区域保存了组成字符 串的字符序列。多次⽤到同⼀个字⾯量时,字⾯量池中通常只有⼀份副本。 这样会减少应⽤程序占⽤的内存。通常认为字⾯量是不可变的,因此只有⼀ 份副本不会有什么问题。不过,认定只有⼀份副本或者字⾯量不可变不是⼀ 种好做法, ⼤部分编译器有关闭字⾯量池的选项,⼀旦关闭,字⾯量可能⽣ 成多个副本,每个副本拥有⾃⼰的地址。

gcc中用 -fwritable-strings​来关闭

大部分情况字符串字面量是不可修改的,但是在gcc中,可以通过指针的方式修改,如下所示,因此如果声明字符串字面量时尽量声明为const

char *tabHeader = "Sound";
*tabHeader = 'L';
printf("%s\n",tabHeader); // 打印"Lound"

5.1.3 字符串初始化

1 初始化char数组

// 方式1
char header[] = "Media Player";

// 方式2
char header[13];
strcpy(header, "Media Player");

// 方式3
header[0] = 'M';
...



// 下面的是非法的,不能把字符串字面量的地址赋给数组名字
char header2[];
header2 = "Media Player";

2 初始化char指针

malloc分配,传入的长度=实际的字符串长度+1

char *header = (char*) malloc(strlen("Media Player") + 1);

strcpy(header, "Media Player");

//或者

*(header + 0) = 'M';
...

//也可以,将字符串字面量的地址直接赋值给字符指针。
char *header = "Media Player";

字符字面量和字符串字面量是两个东西

3 从标准输入初始化字符串

5.1.4 小结

下面字符串的存储位置如下图所示

char* globalHeader = "Chapter";
char globalArrayHeader[] = "Chapter";
void displayHeader() {
static char* staticHeader = "Chapter";
char* localHeader = "Chapter";
static char staticArrayHeader[] = "Chapter";
char localArrayHeader[] = "Chapter";
char* heapHeader = (char*)malloc(strlen("Chapter")+1);
strcpy(heapHeader,"Chapter");
}

image

分配在全局内存的字符串会⼀直存在,也可以被多个函数访问;静态字符串也⼀直存在,不过只有定义它们的函数才能访问,分配在堆上的内存在释放之前会⼀直存在,也可以被多个函数访问。理解这些东西能让你作出更好的选择

5.2 标准字符串操作

5.2.1 比较字符串

一般通用的是strcmp

int strcmp(const char *s1, const char *s2);
返回:
负数 - 按字典序(字母序)s1比s2小

0 - 相等

正数 - 按字典序(字母序)s1比s2大

5.2.2 复制字符串

char strcpy(char *s1, char *s2);
把s2的内容复制到s1

5.2.3 拼接字符串

char strcat(char *s1, char *s2);
将s2拼接到s1的结尾

注:第一个字符串需要足够长,否则可能会越界写入,导致不可预期的行为

例如下面这个不正确的拼接操作

char* error = "ERROR: ";
char* errorMessage = "Not enough memory";
strcat(error, errorMessage);
printf("%s\n", error);
printf("%s\n", errorMessage);

输出:
ERROR: Not enough memory
ot enough memory

errorMessage 字符串会左移⼀个字符,原因是拼接后的结果覆写了errorMessage 。字⾯量”Not enough memory” 紧跟在第⼀个字⾯量之后,因此覆写了第⼆个字⾯量。下图解释了这⼀点,字⾯量池的状态显⽰在左边,右边是复制操作后的状态。

image

拼接字符串时容易犯错的另⼀个地⽅是使⽤字符字⾯量⽽不是字符串字⾯量。在下例中,我们将⼀个字符串拼接到⼀个路径字符串后,这样是能如期⼯作的:

char* path = "C:";
char* currentPath = (char*) malloc(strlen(path)+2);
currentPath = strcat(currentPath,"\\");

因为额外的字符和NUL 字符需要空间,我们在malloc 调⽤中给字符串长度加了2。因为在字符串字⾯量中⽤了转义序列,所以这⾥拼接的是⼀个反斜杠字符。

不过,如果使⽤字符字⾯量,如下所⽰,那么就会得到⼀个运⾏时错误,原因是第⼆个参数被错误地解释为char 类型变量的地址1 :

currentPath = strcat(path,'\\');

此处其实是个整数,⽽参数是char* ,所以整数被当成了地址

5.3 传递字符串

5.3.4 给应用程序传递参数

int main(int argc, cahr **argv) {
}

int main(int argc, cahr *argv[]) {
}

5.4 返回字符串

不要返回局部变量的字符串

5.5 函数指针和字符串

6 指针和结构体

6.3 避免malloc/free开销

一般来说使用一些带有指针的结构体时候,如果重复分配释放内存,会带来较大的开销,可能会导致巨大的性能瓶颈。

解决这个问题的⼀种办法是为分配的结构体单独维护⼀个表。当⽤户不再需要某个结构体实例时,将其返回结构体池中。当我们需要某个实例时,从结构体池中获取⼀个对象。如果池中没有可⽤的元素,我们就动态分配⼀个实例。这种⽅法⾼效地维护⼀个结构体池,能按需使⽤和重复使⽤内存。

7 安全问题和指针误用

地址空间布局随机化 (Address Space Layout Randomization,ASLR)过程会把应⽤程序的数据区域随机放置在内存中,这些数据区域包括代码、栈和堆。随机放置这些区域导致攻击者更难预测内存的位置,从⽽更难利⽤它们。

7.1 指针的声明和初始化

  • 不恰当的指针声明
  • 使用前未初始化,野指针

7.2 指针的使用问题

缓冲区溢出

下⾯⼏种情况可能导致缓冲区溢出:

  • 访问数组元素时没有检查索引值;
  • 对数组指针做指针算术运算时不够小心;
  • ⽤gets 这样的函数从标准输⼊读取字符串;
  • 误⽤strcpy 和strcat 这样的函数。

strcpy允许缓冲区溢出,replace不允许。

replace可以传入长度,避免溢出。

7.3.2 清除敏感数据

在一些时候,可以在释放某个内存前,使用mmset情况这段内存,防止其他程序申请后拿到该内存,从而获取有用的信息。

8 其他重要内容

8.1 转换指针

  • 访问有特殊目的的地址
  • 分配一个地址表示端口
  • 判断机器的字节序

8.1.1 访问特殊的地址

比如,在一些底层操作系统内核中,pc的显存地址是0xB8000

还有比如访问0地址内存

8.1.2 访问端口

机器用十六进制地址表示端口,将数据作为无符号整数处理。

#define PORT 0xB0000000
unsigned int volatile * const port = (unsigned int *) PORT;

8.1.3 用DMA访问内存

直接内存访问(Direct Memory Access,DMA)是⼀种辅助系统在内存和某些设备间传输数据的底层操作,它不属于ANSI C规范,但是操作系统通常提供对这种操作的⽀持。DMA操作⼀般与CPU并⾏进⾏,这样可以将CPU解放出来执⾏其他任务,从⽽得到更好的性能。程序员先调⽤DMA函数,然后等待操作完成。通常,程序员会提供⼀个回调函数,当操作完成后,操作系统会调⽤回调函数,回调函数由函数指针指定,8.3.2节中会深⼊讨论。

8.1.4 判断字节序

8.2 别名、强别名和restrict关键字

如果两个指针引用同一个内存地址,可能会遇到一些问题。

当编译器为指针⽣成代码时,除⾮特别指定,它必须假设可能会存在别名。使⽤别名会对编译器⽣成代码有所限制,如果两个指针引⽤同⼀位置,那么任何⼀个都可能修改这个位置。当编译器⽣成读写这个位置的代码时,它就不能通过把值放⼊寄存器来优化性能。对于每次引⽤,它只能执⾏机器级别的加载和保存操作。频繁的加载/保存会很低效,在某些情况下,编译器还必须关⼼操作执⾏的顺序。

强别名

不允许⼀种类型的指针成为另⼀种类型的指针的别名。下⾯的代码中,⼀个整数指针是⼀个浮点数指针的别名,这破坏了强别名的规则。这段代码判断⼀个数是否为负数,相⽐将它的参数跟0⽐较来判断正负,这种⽅法执⾏速度更快:

float number = 3.25f;
unsigned int *ptrValue = (unsigned int *)&number;
unsigned int result = (*ptrValue & 0x80000000) == 0;

两种数据类型的联合体可以避开强别名的问题

如果编译器有禁⽤强别名的选项,就可以关闭它。GCC编译器有如下的编译

  • fno-strict-aliasing 可以关闭强别名;
  • fstrict-aliasing 可以打开强别名;
  • Wstrict-aliasing 可以打开跟强别名相关的警告信息。

8.2.1 用联合体以多种方式表示值

8.2.3 使用restrict关键字

C编译器默认假设指针有别名,⽤restrict 关键字可以在声明指针时告诉编译器这个指针没有别名,这样就允许编译器产⽣更⾼效的代码。很多情况下这是通过缓存指针实现的,不过要记住这只是个建议,编译器也可以选择不优化代码。

double * restrict arr1

⼀些标准C函数⽤了restrict 关键字,包括:
void *memcpy(void * restrict s1, const void * restrict
s2, size_t n);
char *strcpy(char * restrict s1, const char * restricts2);
char *strncpy(char * restrict s1, const char * restricts2, size_t n);
int printf(const char * restrict format, ... );
int sprintf(char * restrict s, const char * restrictformat, ... );
int snprintf(char * restrict s, size_t n, const char *restrict format, ... );
int scanf(const char * restrict format, ...);
restrict 关键字隐含了两层含义:
1. 对于编译器来说,这意味着它可以执⾏某些代码优化;
2. 对于程序员来说,这意味着这些指针不能有别名,否则操作的结果将是未定义 的。

8.3 线程和指针

线程之间共享数据会引发一些问题。

线程写入时候,可能会时不时的被挂起,这个时候数据还没写完,另一个线程去读,导致对象处于不一致的状态。

这个时候通常使用互斥锁。

8.3.2 用函数指针支持回调

8.4 面向对象技术

借助不透明指针,我们也可以使⽤C封装数据 以及⽀持某种程度的多态⾏为

8.4.1 创建和使用不透明指针

我们不允许⽤户看到链表内部结构以及使⽤链表内部结构,并且会对⽤户隐藏结构体的任何变化。

只有四个⽀持函数的签名对⽤户是可见的,否则,⽤户就⽆法利⽤或修改实现细节。我们封装了链表结构及其⽀持函数,从⽽减轻了⽤户的负担。