初级指针

本篇主要介绍:指针和变量的关系、指针类型、指针的运算符、空指针和野指针、指针和数组指针和字符串、const 和指针、以及gdb 调试段错误

基础概念

int num = 1; 会申请4个字节的内存来存放数字1,每次访问 num 就是访问这4个字节。

访问内存中的这4个字节,不仅可以通过名称(例如 num),还可以通过地址

Tip& 不仅是位运算符,还是取地址操作符。例如 int* ptr = #,就是取变量 num 的地址并将其保存到指针变量 ptr 中

请看示例:

#include <stdio.h>

int main() {
    int num = 10;

    // num 的地址:0x7fff4dbf01d8
    printf("num 的地址:%p\n", &num);
    // num 的地址加1 :0x7fff4dbf01dc。
    printf("num 的地址加1 :%p\n", &num + 1);

    // j 存放连续内存的第一个字节地址
    int *j = &num;

    // 10。通过地址访问
    printf("%d", *j);

    return 0;
}

&num&num + 1 相差4个字节,说明 &num 表示整数。

普通变量存放值,而指针用于存放地址。

通过 int *j = &num 将变量num的首地址给到指针 j(j的类型是 int *),最后通过地址(*j) 访问整数1。

int *j 是一个int类型的指针,还有 char、float等指针类型。指针类型必须匹配,比如将 j 的指针类型换成 char,则会警告。就像这样:

- int *j = &num;
+ char *j = &num;

运行:

/workspace/CProject-test/main.c:12:11: warning: incompatible pointer types initializing 'char *' with an expression of type 'int *' [-Wincompatible-pointer-types]
    char *j = &num;
          ^   ~~~~
1 warning generated.
num 的地址:0x7ffddcfe5328
num 的地址加1 :0x7ffddcfe532c
10

Tip: 指针 j 也有地址,也就是指针的指针。现在不研究

练习

题目:请问输出什么?

#include <stdio.h>

int main() {
    int num = 10;
    int *p = &num;
    printf("用指针访问数据 num :%d\n", *p);

    *p = 11;
    printf("用过指针修改 num 数据:%d\n", num);

    return 0;
}

提示:数据可以通过变量访问,也能使用地址(指针)访问。就像通知同学去嵌入式实验室上课,或者是 303 上课。其中*p = 11; 等价于 num = 11;

输出:

用指针访问数据 num :10
用过指针修改 num 数据:11

星号的作用

指针 * 有两个主要作用(根据* 前面有无类型做区分):

  • 指针类型声明
  • 取值(又称解引用操作符)。例如,*ptr 表示获取指针变量 ptr 所指向内存地址上的值。

请看示例:

#include <stdio.h>

int main() {
    int num = 10;
    // 指针类型声明
    int *p = &num;

    // 取值
    printf("%d\n", *p); // 10

    // 取值
    *p = 11;
    printf("%d\n", num); // 11

    return 0;
}

指针类型

所占字节

在32位系统上,指针通常占用4个字节;而在64位系统上,指针通常占用8个字节。请看示例:

#include <stdio.h>

int main() {
    printf("char类型指针所占字节数为:%zu\n", sizeof(char*));
    printf("short类型指针所占字节数为:%zu\n", sizeof(short*));
    printf("int类型指针所占字节数为:%zu\n", sizeof(int*));
    printf("long类型指针所占字节数为:%zu\n", sizeof(long*));
    printf("float类型指针所占字节数为:%zu\n", sizeof(float*));
    printf("double类型指针所占字节数为:%zu\n", sizeof(double*));
    printf("long long类型指针所占字节数为:%zu\n", sizeof(long long*));
    return 0;
}

输出:

char类型指针所占字节数为:8
short类型指针所占字节数为:8
int类型指针所占字节数为:8
long类型指针所占字节数为:8
float类型指针所占字节数为:8
double类型指针所占字节数为:8
long long类型指针所占字节数为:8

练习

题目:请问整数类型的指针和字符类型的指针加1分别是几个字节?

#include <stdio.h>

int main() {
    int num = 10;

    printf("num 的地址:%p\n", &num);
    printf("num 的地址加1 :%p\n", &num + 1);

    char ch = 'a';

    printf("ch 的地址:%p\n", &ch);
    printf("ch 的地址加1 :%p\n", &ch + 1);
    return 0;
}

输出:

num 的地址:0x7fffe8244288
num 的地址加1 :0x7fffe824428c
ch 的地址:0x7fffe8244287
ch 的地址加1 :0x7fffe8244288

答案int * 加1是4个字节;char * 加1是1个字节。&num 和 &ch 分别代表该变量的全部字节。

指针交换数据

比如这段代码是不能实现 a、b 两数交换。请看示例:

#include <stdio.h>

void swap(x, y){
    int tmp = x;
    x = y;
    y = tmp;
}
int main() {
    int a = 1;
    int b = 2;
    swap(a, b);
    printf("a:%d\n", a);
    printf("b:%d\n", b);
    return 0;
}
a:1
b:2

分析:调用 swap(a, b) 这里是一个值传递,找到函数入口地址,对参数 x、y 申请空间和赋值,通过 tmp 变量完成了 x和y的交换,最后回收局部变量 x、y和tmp,释放空间。而 a,b数据没有变化。

可以通过指针来实现两数的交换。请看示例:

#include <stdio.h>

void swap(int* x, int* y){
    int tmp = *x;
    *x = *y;
    *y = tmp;
}
int main() {
    int a = 1;
    int b = 2;
    swap(&a, &b);
    printf("a:%d\n", a);
    printf("b:%d\n", b);
    return 0;
}
a:2
b:1

分析:通过 swap(&a, &b) 将 a b 的地址传给 x 和 y,通过 x 和 y 指针对 a 和 b 进行交换,虽然最后会销毁swap中的局部变量,但 a 和 b的值已经完成了交换。

指针的运算符

指针和变量的关系

练习1

题目:输出什么?

#include <stdio.h>

int main() {
    int a = 10, *pa = &a, *pb;
    printf("%d\n", *pa);
    pb = pa;
    printf("%d\n", *pb);
    return 0;
}

输出:10 10

分析:


int a = 10, 
// pa 指向变量 a
*pa = &a, 
// 定义一个整数型的指针 pb
*pb;
printf("%d\n", *pa);

// pb 也指向变量 a
pb = pa;
printf("%d\n", *pb);
return 0;

练习2

题目:输出什么?

#include <stdio.h>

int main() {
    int x = 3, y = 0, *px = &x;

    y = *px + 5;
    printf("%d\n", y);

    y= ++*px;
    printf("%d\n", y);

    printf("%p\n", px);

    y = *px++; 
    printf("%p\n", px);
    printf("%d\n", y);

    return 0;
}

输出:

8
4
0x7ffc48b9be38
0x7ffc48b9be3c
4

分析:

  • y= ++*px; 等效 ++(*px)。如果是 ++* 是不对的
类似 y = ++i,等于先执行 ++,在执行 y = i,
这里先对 (*px) 执行 ++,在返回  *px 的值
  • y = *px++;
先执行 y = *px,然后是 px++。px是整数类型的地址,加1就是加4个字节。

练习3

题目:输出什么?

#include <stdio.h>

int main() {
    int x = 3, y = 0, *px = &x;
    printf("%p\n", px);
    y = (*px)++; 
    printf("%p\n", px);
    printf("%d\n", x);

    return 0;
}

输出:

0x7ffef1dc4d58
0x7ffef1dc4d58
4

分析:*px++ 表示指针加1,(*px)++ 表示值加1。

指针初始化

指针初始化有两种方法:已经存在的空间和自己申请空间。

已经存在的空间,例如:

#include <stdio.h>
#include <stdlib.h>
int main() {
    int num;
    int* p = &num;
    *p = 10;

    char *str = "abc";
    printf("%s\n", str); // abc。把字符串的地址赋值给指针变量
    return 0;
}

自己申请空间可以使用 malloc 函数。申请的是 void 类型指针,也称为通用类型指针。请看示例:

#include <stdio.h>
// malloc 需要引入 <stdlib.h>
#include <stdlib.h>
int main() {
    // 申请16个字节
    int* q = malloc(sizeof(int) * 4); // 在堆里申请了16个字节
    // int* q = (int *)malloc(sizeof(int) * 4); // 推荐
    
    *q = 10;
    // 释放申请的16个字节
    free(q);
    return 0;
}

申请空间,使用完需要使用 free() 释放。

Tip:根据 C99 标准以及更高版本的标准,显式的类型转换是建议的做法,以确保类型的安全性和可读性。

空指针和野指针

下面这段代码 p 就是一个野指针,运行报错:段错误 (核心已转储)

#include <stdio.h>

int main() {
    int* p;
    *p = 1;
    return 0;
}

这里声明一个指针 p,里面是一个随机数,例如 0x7ffe71df3f40,接着往指向的内存放1,由于这块内存不知道是否存在,即使存在也不能访问,于是报段错误

直接手写一个地址也不可以。就像这样:

#include <stdio.h>

int main() {
    
    // warning: incompatible integer to pointer conversion initializing 'int *' with an expression of type 'long' [-Wint-conversion]
    // 这个警告是因为你正在将一个 long 类型的表达式赋值给一个 int* 类型的指针变量,导致类型不匹配。
    // int* p = 0x7ffe71df3f40;
    int* p = (int *)0x7ffe71df3f40;
    *p = 100;
    return 0;
}
// 分段错误 (核心已转储)"
Segmentation fault (core dumped)

空指针也不能使用:

int* p = NULL;
*p = 100;

// 输出:`Segmentation fault (core dumped)`

但空指针会让你可控。就像这样:

int* p = NULL;

if (p != NULL) {
    printf("p is not NULL\n");
}else{
    printf("p is NULL\n");
}

// 输出:p is NULL

指针和数组

遍历一个数组,可以这样:

#include <stdio.h>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int length = sizeof(arr) / sizeof(arr[0]);  // 计算数组的长度
    // 1 2 3 4 5 
    for (int i = 0; i < length; ++i) {
        printf("%d ", arr[i]);
    }

    return 0;
}

使用指针遍历数组有两种方式(效果相同)。请看示例:

#include <stdio.h>

int main() {
    int arr[] = {1, 2, 3, 4, 5};
    int length = sizeof(arr) / sizeof(arr[0]);  // 计算数组的长度

    // 指针遍历方式1
    /*
    int* pArr = arr;
    for (int i = 0; i < length; ++i) {
        printf("%d ", *(pArr + i));
    }
    */

    // 指针遍历方式2
    int* pArr = arr;
    for (int i = 0; i < length; ++i) {
        printf("%d ", pArr[i]);
    }

    return 0;
}

Tip:在数组一文中我们知道数组名表示首元素地址,这里*(pArr + i)会依次遍历数组或许是因为指针是int类型吧!

总结pArr[i] 等于 *(pArr + i)。在这里[]不再是取某个索引,而是表示取值。

指针和字符数组

题目:分析 char a[] = "Hello";char *b = "World";

  • 都可以用for遍历元素。例如:
#include <stdio.h>

int main() {
    char a[] = "Hello";
    char *b = "World";

    // Iterating over 'a'
    printf("Characters in 'a':\n");
    for (int i = 0; a[i] != '\0'; i++) {
        printf("%c\n", a[i]);
    }

    // Iterating over 'b'
    printf("\nCharacters in 'b':\n");
    for (int i = 0; b[i] != '\0'; i++) {
        printf("%c\n", b[i]);
    }

    return 0;
}

输出:

开始运行...

Characters in 'a':
H
e
l
l
o

Characters in 'b':
W
o
r
l
d

运行结束。
  • 为什么指针也可以通过索引访问特定字符?
    比如 char *b = "World";,可以将字符串视为字符数组,使用指针来指向该数组的首地址,指针可以通过偏移来访问特定位置的元素,包括字符串中的字符。

练习

题目:下面代码中 p1[0]p2[0]p3[0]的值分别是多少?

// 申请4*4个字节,每个字节地址假如是:0x100(存放1) 0x104(存放2) 0x108 0x10c
int a[] = {1,2,3,4};

int *p1 = (int*)(&a + 1);

int *p2 = (int*)((int)a + 1);

int *p3 = (int*)(a + 1);

分析:

  • (int*)(&a + 1) - &a 表示整个数组,加1则到下一个数组,然后将数组指针强转成整数指针,指向第5个元素,其实已经越界了。
  • (int*)((int)a + 1) - a 表示数组首元素地址,(int)a 将地址转为整数,以前是加1个元素,现在就是加1,然后又将整数转为整数指针,乱了(就好比访问 0x101 0x102 0x103 0x104
  • (int*)(a + 1) - a 表示数组首元素地址,加1则是第二个元素地址 0x104,不强转也可以。

结论:只有p3[0](等价于 *(p3 + 0))是一个正常的元素,也就是2.

指针和字符串

题目:用数组和指针定义字符串有什么区别?

#include <stdio.h>

int main() {
    char str[] = "HelloWorld";
    // HelloWorld
    printf("%s\n", str);

    char* s = "HelloWorld";
    // HelloWorld
    printf("%s\n", s);

    return 0;
}

Tip: 字符串的输出都是首地址,比如这里的 str 是数组的首地址,s 指针指向的也是首地址。

分析:
char str[] = "HelloWorld"; 在栈中定义一个数组,用11个字节存储HelloWorld(还有一个 \0)。请看示例:

#include <stdio.h>

int main() {
    char str[] = "HelloWorld";
    str[0]++; 
    // IelloWorld
    printf("%s\n", str); 
    // error: cannot increment value of type 'char[11]'
    str++;
    // printf("%s\n", str);
    return 0;
}

数组名(str++)不可以修改,str 就是数组首元素地址,已经固定了,可认为它是常量。但数组内容可以修改。

char* s = "helloWorld";helloWorld 放在只读数据区,s 是局部变量,放在栈中,占8个字节。请看示例:

#include <stdio.h>

int main() {
    char* s = "helloWorld";
    
    s++;
    // elloWorld
    printf("%s\n", s);

    // 报错:Segmentation fault (core dumped)
    s[0]++;

    return 0;
}

指针可以加加,但指针指向的内容不能修改。

str 只是个名字,不占空间,如果一定要说占多少,那就是它执行的数组占11个字节。而 s 是8个字节,指向一个只读区,占 11 个字节。

练习

题目:分析以下示例。

#include <stdio.h>

int main() {
    char str[20];
    str = "HelloWorld";

    char* s;
    s = "HelloWorld";
    // HelloWorld
    printf("%s\n", s);

    return 0;
}

分析:

// 分配20个字节的内存,并把首地址给 str
char str[20];
// str 是只读的,不能再赋值。报错:`error: array type 'char[20]' is not assignable`
str = "HelloWorld";

// 定义一个指针 s
char* s;
// 将 HelloWorld 的首地址给 s
s = "HelloWorld";

扩展

自定义strcpy()函数

题目:实现原生字符串拷贝方法strcpy。strcpy 其用法如下:

#include <stdio.h>
#include <string.h>

int main() {
    char source[] = "Hello";
    char destination[10]; // 目标字符串需要足够的空间来容纳 source 字符串

    strcpy(destination, source);

    printf("Source string: %s\n", source);
    printf("Destination string: %s\n", destination);

    return 0;
}

实现:

#include <stdio.h>

char* strcpy_custom(char* destination, const char* source) {
    // 字符串数组末尾有一个特殊的空字符 '\0' 来表示字符串的结束。逐个复制字符,直到遇到源字符串的结束标志 '\0'
    while (*source != '\0') {
        *destination = *source;
        destination++;
        source++;
    }

    *destination = '\0'; // 在目标字符串末尾添加结束标志 '\0'

    return destination;
}

int main() {
    // 定义两个字符数组
    char source[] = "Hello";
    char destination[10]; // 目标字符串需要足够的空间来容纳 source 字符串

    // 数组名。表示首元素的地址,加 1 是加一个元素(比如这里1个字节)
    strcpy_custom(destination, source);

    printf("Source string: %s\n", source);
    printf("Destination string: %s\n", destination);

    return 0;
}

Tipconst char* source 中 const 的作用请看const 和指针

输出:

开始运行...

Source string: Hello
Destination string: Hello

运行结束。

将 while 替换成下面一行代码效果也相同:

char* strcpy_custom(char* destination, const char* source) {
    /*
    while (*source != '\0') {
        *destination = *source;
        destination++;
        source++;
    }

    *destination = '\0'; 
    */

    // 替换成
    while((*destination++ = *source++) != '\0');
    return destination;
}

分析:(*destination++ = *source++) != '\0':

之前的是首先判断,在赋值。`*source != '\0'`、`*destination = '\0';`,这里是先赋值

后置++会放在表达式最后,所以等于:

(*destination = *source) != '\0';
destination++;
source++;

const 和指针

首先补充下(int*)的作用。之前说到 const 定义的变量可以被修改,我们写了如下代码:

#include <stdio.h>

int main() {
    const int val =5;

    int *ptr= (int*)&val;
    *ptr=10;

    printf("val = %d\n",val);
    printf("*ptr = %d\n", *ptr);

    return 0;
}

其中 int *ptr= (int*)&val; 是将一个 const int 类型的变量 val 地址强制转换为 int* 类型的指针,并将指针存储在 ptr 中。这种类型转换是不安全的,因为它丢失了 val 的常量性质。

const char* source 声明一个常量指针,以下代码仅做示意:

#include <stdio.h>

int main() {
    const char* source = "Hello";
    char* mutableSource = "World";

    printf("%c\n", source[0]);
    printf("%c\n", mutableSource[0]);

    // 以下操作是非法的,会导致编译错误
    // source[0] = 'h'; // 不能修改字符数据

    // 合法
    // 尽管mutableSource是一个非常量指针,看起来可以进行修改,但修改字符串常量是不被允许的,并且这可能导致未定义行为。
    mutableSource[4] = 'w'; // 可以修改字符数据
    return 0;
}

运行:

开始运行...

H
W
Segmentation fault (core dumped)

运行结束。
就近原则

const 有个就近原则

  • 比如:const int* p1 = &num;,const 修饰的是 *,所以 *p1 不能修改, p1 可以修改
  • 比如:int* const p2 = &num;,const 修饰 p2,所以 p2 不能修改,*p2 可以修改

请看示例:

#include <stdio.h>

int main() {
    int num = 1;
    const int* p1 = &num; // const 修饰的是 *,所以 *p1 不能修改, p1 可以修改

    p1++;
    // (*p1)++;

    int* const p2 = &num; // const 修饰 p2,所以 p2 不能修改,*p2 可以修改

    // p2++;
    (*p2)++;

    const int* const p3 = &num; // 两个都不能修改
    // p3++;
    // (*p3)++;

    return 0;
}

gdb 调试段错误

GDB(GNU Debugger)是一款强大的调试器,用于帮助开发者查找和解决程序中的错误。通过与源代码交互,并提供诸如断点设置、变量观察、内存检查等功能,GDB允许开发者逐行执行程序并分析其运行状态。
除了上文使用的 run,还有如下操作

  • run:运行程序。
  • break <line_number>:在指定行设置断点。
  • break <function_name>:在指定函数设置断点。
  • continue:继续执行程序直到下一个断点或程序结束。
  • next:逐过程地执行程序。
  • step:逐语句地执行程序。
  • print <variable>:打印变量的值。
  • backtrace:显示函数调用的堆栈跟踪信息。
  • quit:退出GDB调试会话。

使用 gdb 调试段错误的过程如下:

编写代码:

pjl@pjl-pc:~/pjl$ cat demo-3.c
#include <stdio.h>

int main() {
    int* p;
    *p = 1;

    return 0;
}

编译运行发现段错误:

pjl@pjl-pc:~/pjl$ gcc demo-3.c -o demo-3
pjl@pjl-pc:~/pjl$ ./demo-3
段错误 (核心已转储)

将代码编译为可调试的可执行文件。在gcc或g++编译时,添加"-g"选项可以生成包含调试信息的可执行文件。

// 增加 -g
pjl@pjl-pc:~/pjl$ gcc demo-3.c -o demo-3 -g

// 启动GDB并加载可执行文件
pjl@pjl-pc:~/pjl$ gdb demo-3
GNU gdb (Ubuntu 9.1-0kylin1) 9.1
Copyright (C) 2020 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from demo-3...

(gdb) 

输入 run(还有其他操作) 找到是第5行代码报错:

...
// run:运行程序。
(gdb) run
Starting program: /home/pjl/pjl/demo-3

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555135 in main () at demo-3.c:5
5           *p = 1;
(gdb)

高级指针

题目:以下代码输出什么?

#include <stdio.h>

int main() {
    char * string[] = {"Hello", "World" };
    printf("%s\n", string);
    return 0;
}

分析:
我们知道定义字符串有以下两种方法:

char str[] = "HelloWorld";
char* s = "HelloWorld";

Tip: string 在 C 中不是关键字,也不是保留字,就是一个普通变量名。

[] 的优先级是非常高的,这里首先是定义一个数组(string[]),其次就是指针,合起来就是一个指针数组。

首先在只读区分配两块内存分别存放 Hello(地址比如是 0x100) 和 World(地址比如是 0x200),指针数组是16个字节,本质就是数组,只不过里面放的是指针,比如前8个字节的地址是0x1000,那么 string 就是 0x1000,因为数组名就是数组首元素地址。

所以要输出这两个字符串,可以这么写:

#include <stdio.h>

int main() {
    char * string[] = {"Hello", "World" };

    // Hello
    printf("%s\n", string[0]);
    // World
    printf("%s\n", string[1]);
    return 0;
}
07-03 04:34