← 返回文章列表

C 语言(六)|指针基础:地址、解引用与指针运算

详细介绍C语言指针的核心概念,从内存地址到数组指针的底层逻辑,包含assert断言、排序算法指针实现、二级指针和指针数组等内容。

版权声明:本文为作者原创,版权归作者所有。如需转载或引用,请联系作者获得授权,并在使用时注明出处。

前言

指针是C语言的灵魂,也是其魅力所在。掌握指针意味着你真正理解了计算机内存管理机制。指针概念抽象、内容繁多、绕人烧脑是初学者的共同感受——概念抽象是因为理解指针需要"脑子转得过弯",内容繁多是因为涉及内存、地址、变量类型、数组等多知识点联动,绕人烧脑则是因为一不小心就写出bug。

指针从入门到精通需要持续学习、反复实践、不断深化。初学时的"懂了但没完全懂"是正常现象,关键是在后续学习中持续回顾、以理论指导实践、用实践验证理论,最终达到融会贯通。

一、内存与地址

1.1 理解内存

内存是计算机中用于存储数据的硬件设备,程序运行时所有数据都存储在内存中。以酒店为例理解内存:整栋大楼是内存,每个楼层是内存的一个单元,每个楼层有楼层号(类似内存地址),每个房间住着数据。

内存的基本单位是字节(byte)。每个字节都有唯一的地址,就像每个房间都有门牌号一样。

1.2 地址的概念

地址是内存中每个字节的编号,从0开始递增。例如:

  • 地址0x0010:第一个字节
  • 地址0x0011:第二个字节
  • 地址0x0012:第三个字节

重要结论:地址是内存的"门牌号",通过地址可以找到对应的内存空间。

1.3 &操作符:获取地址

&(取地址操作符)用于获取变量的内存地址。

#include <stdio.h>

int main() {
    int a = 10;
    printf("a的地址:%p\n", &a);
    printf("a的值:%d\n", a);
    return 0;
}

运行结果(地址值因计算机而异):

a的地址:0x0061FE44
a的值:10

二、指针变量的定义与使用

2.1 什么是指针?

指针是一个变量,其值为另一个变量的地址。换句话说,指针"指向"另一个变量。

通俗理解:指针是地址的"收藏者",通过指针可以找到变量的位置(地址),进而访问变量。

2.2 指针变量的定义

语法格式:type *pointer_name;

  • type:指针指向的数据类型
  • *:表明这是一个指针变量
  • pointer_name:指针变量的名字

示例:

int *p;      // 指向int类型变量的指针
char *pc;    // 指向char类型变量的指针
float *pf;   // 指向float类型变量的指针
double *pd;  // 指向double类型变量的指针

注意:指针变量的类型决定了它能指向什么类型的变量,也决定了解引用时读取几个字节。

2.3 指针的初始化

指针必须在使用前初始化,否则可能指向未知地址,导致程序崩溃。

#include <stdio.h>
#include <assert.h>

int main() {
    int a = 10;
    int *p = &a;  // 初始化指针p,指向变量a的地址
    
    // 断言:确保指针非空
    assert(p != NULL);

    printf("a的地址:%p\n", &a);
    printf("p的值:%p\n", p);
    printf("p指向的值:%d\n", *p);
    return 0;
}

2.4 解引用操作符(*)

*(解引用操作符)用于访问指针指向的变量。

#include <stdio.h>
#include <assert.h>

int main() {
    int a = 10;
    int *p = &a;

    printf("a的值:%d\n", a);    // 直接访问a
    printf("*p的值:%d\n", *p); // 通过指针访问a

    *p = 20;  // 通过指针修改a的值
    printf("修改后a的值:%d\n", a);
    return 0;
}

运行结果:

a的值:10
*p的值:10
修改后a的值:20

2.5 指针变量的大小

指针变量的大小由系统地址位数决定,与指向的数据类型无关。

#include <stdio.h>

int main() {
    int *p1;
    char *p2;
    float *p3;
    double *p4;

    printf("int* 的尺寸:%zu\n", sizeof(p1));
    printf("char* 的尺寸:%zu\n", sizeof(p2));
    printf("float* 的尺寸:%zu\n", sizeof(p3));
    printf("double* 的尺寸:%zu\n", sizeof(p4));

    printf("所有指针尺寸相同:%zu\n", sizeof(p1) == sizeof(p2));
    return 0;
}

结论:32位系统指针占4字节,64位系统指针占8字节。

三、指针类型的意义

指针类型决定了指针的两个核心行为:

  1. 解引用权限:解引用时能访问的内存字节数
  2. 指针运算步长:指针±整数时地址偏移的字节数
#include <stdio.h>

int main() {
    int a = 0x12345678;  // 4字节整数
    int *pa = &a;
    char *pc = (char *)&a;  // 强制转换为char*

    printf("*pa = 0x%x\n", *pa);    // 读取4字节:0x12345678
    printf("*pc = 0x%x\n", *pc);   // 读取1字节:0x78(取决于小端/大端)

    printf("pa + 1 = %p\n", pa + 1);  // 移动4字节
    printf("pc + 1 = %p\n", pc + 1);  // 移动1字节
    return 0;
}

核心结论:指针的类型决定了指针"怎么走"(运算步长)和"看多少"(解引用权限)。

void*指针

指针类型中有一个特殊类型void *,可理解为无具体类型的指针(泛型指针):

  • 优点:可以接收任意类型的地址
  • 局限性:不能直接进行指针±整数和解引用运算

四、指针运算

4.1 指针与整数的运算

指针与整数相加减时,指针会移动"n个元素大小"的距离,而非简单的内存字节数。

#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;  // 数组名就是数组首元素的地址

    printf("p = %p, *p = %d\n", p, *p);
    printf("p+1 = %p, *(p+1) = %d\n", p+1, *(p+1));
    printf("p+2 = %p, *(p+2) = %d\n", p+2, *(p+2));
    return 0;
}

重要结论:

  • p + n:指针向后移动n个"元素"的位置
  • p - n:指针向前移动n个"元素"的位置

4.2 指针之间的减法

两个指针相减,结果是它们之间"元素的个数",而非地址的数值差。

#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *p1 = &arr[0];
    int *p2 = &arr[3];

    printf("p2 - p1 = %td\n", p2 - p1);  // 输出3
    printf("p1 - p2 = %td\n", p1 - p2);  // 输出-3
    return 0;
}

4.3 指针之间的关系比较

指针可以进行比较运算,比较的是地址的先后顺序。

#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int *p = arr;
    int *end = &arr[5];

    while (p < end) {
        printf("%d ", *p);
        p++;
    }
    printf("\n");
    return 0;
}

运行结果:10 20 30 40 50

五、数组与指针

5.1 数组名的理解

关键结论:数组名是数组首元素的地址(有两个例外)。

#include <stdio.h>

int main() {
    int arr[] = {10, 20, 30, 40, 50};

    printf("arr = %p\n", arr);
    printf("&arr[0] = %p\n", &arr[0]);
    printf("&arr = %p\n", &arr);

    printf("arr+1 = %p\n", arr + 1);
    printf("&arr[0]+1 = %p\n", &arr[0] + 1);
    printf("&arr+1 = %p\n", &arr + 1);  // 跳过整个数组
    return 0;
}

重要区分:

  • arr + 1:跳过一个整型元素(4字节)
  • &arr + 1:跳过整个数组(5个整型 = 20字节)

两个例外:

  1. sizeof(数组名):计算整个数组的内存大小
  2. &数组名:取整个数组的地址

5.2 数组的访问方式

数组元素可以通过下标和指针两种方式访问。

#include <stdio.h>
#include <assert.h>

void Print(int *ptr, int n) {
    // 断言:确保指针非空 + 数组长度合法
    assert(ptr != NULL);
    assert(n > 0);
    
    int* q = ptr;
    while (ptr < q + n) {
        printf("%d ", *ptr);
        ptr++;
    }
    printf("\n");
}

int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int len = sizeof(arr) / sizeof(arr[0]);
    
    printf("下标访问:arr[2] = %d\n", arr[2]);
    printf("指针访问:*(arr+2) = %d\n", *(arr + 2));
    
    Print(arr, len);  // 使用指针遍历打印
    return 0;
}

结论:arr[i]、(arr+i)、(p+i)、p[i]四种方式完全等价。

5.3 模拟strlen(指针实现)

#include <stdio.h>
#include <assert.h>

size_t my_strlen(char *p) {
    // 断言:确保传入的字符串指针非空
    assert(p != NULL);
    
    char* q = p;
    while (*p) {
        p++;
    }
    return (p - q);  // 指针相减,得到元素个数
}

int main() {
    char arr[] = "abcdef";
    size_t len = my_strlen(arr);
    printf("%zu\n", len);  // 输出6
    return 0;
}

六、const修饰指针

6.1 const修饰指针的三种情况

#include <stdio.h>

int main() {
    int a = 10, b = 20;

    // 情况1:const修饰指针指向的内容
    const int *p1 = &a;
    // *p1 = 30;  // 错误:不能通过p1修改a的值
    p1 = &b;     // 正确:p1可以指向其他地址

    // 情况2:const修饰指针本身
    int *const p2 = &a;
    *p2 = 30;    // 正确:可以通过p2修改a的值
    // p2 = &b;   // 错误:p2不能指向其他地址

    // 情况3:const同时修饰指针和指针指向的内容
    const int *const p3 = &a;
    // *p3 = 30;  // 错误
    // p3 = &b;   // 错误

    return 0;
}

6.2 记忆技巧

  • **const在*左边**:修饰指向的内容,内容不能改
  • **const在*右边**:修饰指针本身,指针不能改

七、野指针与assert断言

7.1 野指针的产生原因

  1. 指针变量未初始化:int p; p = 10;(p为随机地址)
  2. 指针越界访问:数组遍历超出范围
  3. 指针指向的内存被释放:内存释放后未将指针置空

7.2 野指针的规避方法

  1. 指针定义时立即初始化(赋值合法地址或NULL)
  2. 指针越界检查:遍历内存时严格限制范围
  3. 内存释放后立即置空:p = NULL;
  4. 解引用前判空:if(p != NULL) { *p = 20; }

7.3 assert断言

assert是调试阶段的断言检查,用于快速定位指针空值、条件非法等问题,是C语言调试的"利器"。

核心特性:

  • 自动标识文件名和出错行号,快速定位问题
  • 无需修改代码,通过宏定义即可开启/关闭所有assert()语句
  • 仅在调试模式生效,不影响发布版本的性能

基础使用:

#include <stdio.h>
#include <assert.h>

void Swap(int *x, int *y) {
    // 断言:确保传入的指针非空
    assert(x != NULL);
    assert(y != NULL);
    
    int temp = *x;
    *x = *y;
    *y = temp;
}

int main() {
    int a = 10, b = 20;
    int *p = NULL;
    Swap(p, &b);  // 触发assert断言
    printf("a=%d, b=%d", a, b);
    return 0;
}

运行结果(触发断言):

Assertion failed: (x != NULL), function Swap, file test.c, line 6.

开启/关闭assert:

  • 启用:直接引入<assert.h>(默认启用)
  • 禁用:在<assert.h>前定义宏NDEBUG

禁用示例:

#include <stdio.h>
#define NDEBUG  // 定义NDEBUG,禁用所有assert
#include <assert.h>

void Swap(int *x, int *y) {
    // 断言被禁用,不会执行检查
    assert(x != NULL);
    assert(y != NULL);
    
    int temp = *x;
    *x = *y;
    *y = temp;
}

最佳实践:

  • 解引用指针前,必加assert(p != NULL)
  • 数组传参时,断言数组长度assert(n > 0)
  • 仅用于"调试阶段的非法条件检查",不替代正常的业务逻辑判断(如if)

八、指针作为函数参数

8.1 传值调用 vs 传址调用

#include <stdio.h>
#include <assert.h>

// 传值调用:交换失败
void swap_fail(int x, int y) {
    int temp = x;
    x = y;
    y = temp;
}

// 传址调用:交换成功
void swap_success(int *px, int *py) {
    // 断言:确保指针非空
    assert(px != NULL);
    assert(py != NULL);
    
    int temp = *px;
    *px = *py;
    *py = temp;
}

int main() {
    int a = 10, b = 20;
    printf("交换前:a=%d, b=%d\n", a, b);

    swap_fail(a, b);
    printf("传值交换后:a=%d, b=%d\n", a, b);  // a和b不变

    swap_success(&a, &b);
    printf("传址交换后:a=%d, b=%d\n", a, b);  // a和b交换成功
    return 0;
}

8.2 指针参数的使用场景

  1. 修改外部变量:函数需要修改外部变量的值
  2. 减少复制开销:大型结构体直接传地址而非传值复制
  3. 返回多个值:函数需要返回多个结果

九、排序算法的指针实现

9.1 冒泡排序

#include <stdio.h>
#include <assert.h>

void BubbleSort(int *arr, int n) {
    assert(arr != NULL);
    assert(n > 0);
    
    for(int i=0; i<n-1; i++) {
        int flag = 1;  // 优化:标记是否已有序
        for(int j=0; j<n-1-i; j++) {
            if(*(arr+j) > *(arr+j+1)) {
                int temp = *(arr+j);
                *(arr+j) = *(arr+j+1);
                *(arr+j+1) = temp;
                flag = 0;
            }
        }
        if(flag == 1) break;
    }
}

int main() {
    int arr[6] = {5,3,4,1,2,6};
    int n = sizeof(arr)/sizeof(arr[0]);
    BubbleSort(arr, n);
    for(int i=0; i<n; i++) {
        printf("%d ", arr[i]);  // 输出1 2 3 4 5 6
    }
    return 0;
}

9.2 选择排序

#include <stdio.h>
#include <assert.h>

void SelectSort(int *arr, int n) {
    assert(arr != NULL);
    assert(n > 0);
    
    for (int i = 0; i < n - 1; i++) {
        int *min_ptr = arr + i;
        for (int j = i + 1; j < n; j++) {
            if (*(arr + j) < *min_ptr) {
                min_ptr = arr + j;
            }
        }
        if (min_ptr != arr + i) {
            int temp = *(arr + i);
            *(arr + i) = *min_ptr;
            *min_ptr = temp;
        }
    }
}

int main() {
    int arr[6] = {5, 3, 4, 1, 2, 6};
    int n = sizeof(arr) / sizeof(arr[0]);
    SelectSort(arr, n);
    for(int i=0; i<n; i++) {
        printf("%d ", arr[i]);  // 输出1 2 3 4 5 6
    }
    return 0;
}

9.3 插入排序

#include <stdio.h>
#include <assert.h>

void InsertSort(int *arr, int n) {
    assert(arr != NULL);
    assert(n > 0);
    
    for (int i = 1; i < n; i++) {
        int insert_val = *(arr + i);
        int *pre_ptr = arr + i - 1;
        
        while (pre_ptr >= arr && *pre_ptr > insert_val) {
            *(pre_ptr + 1) = *pre_ptr;
            pre_ptr--;
        }
        *(pre_ptr + 1) = insert_val;
    }
}

int main() {
    int arr[6] = {5, 3, 4, 1, 2, 6};
    int n = sizeof(arr) / sizeof(arr[0]);
    InsertSort(arr, n);
    for(int i=0; i<n; i++) {
        printf("%d ", arr[i]);  // 输出1 2 3 4 5 6
    }
    return 0;
}

十、二级指针

10.1 二级指针的定义

二级指针是指向指针变量的指针。

#include <stdio.h>
#include <assert.h>

int main() {
    int a = 10, b = 20;
    int *p = &a;
    int **pp = &p;  // 二级指针
    
    // 断言:确保指针非空
    assert(p != NULL);
    assert(pp != NULL);
    
    *pp = &b;   // 等价于p = &b,修改一级指针指向
    **pp = 30;  // 等价于*p = 30,修改b的值
    printf("%d", b);  // 输出30
    return 0;
}

核心符号使用:

  • *pp:解引用二级指针,得到一级指针p的值
  • **pp:二次解引用,得到a的值

10.2 应用场景

  • 修改一级指针的指向
  • 遍历指针数组

十一、指针数组

11.1 指针数组的定义

指针数组是存放指针的数组(本质是数组,元素为指针)。

#include <stdio.h>
#include <assert.h>

int main() {
    int a = 1, b = 2, c = 3;
    int *pArr[3] = {&a, &b, &c};  // 指针数组
    
    for(int i=0; i<3; i++) {
        assert(pArr[i] != NULL);
        printf("%d ", *(pArr[i]));  // 输出1 2 3
    }
    return 0;
}

11.2 常用场景

存放多个字符串的首地址(比二维字符数组节省内存):

#include <stdio.h>

int main() {
    char *strArr[3] = {"abc", "def", "ghi"};
    for(int i=0; i<3; i++) {
        printf("%s\n", strArr[i]);
    }
    return 0;
}

11.3 指针数组模拟二维数组

#include <stdio.h>
#include <assert.h>

int main() {
    int row1[3] = {1,2,3};
    int row2[3] = {4,5,6};
    int row3[3] = {7,8,9};
    
    int *pArr[3] = {row1, row2, row3};  // 指针数组
    
    for(int i=0; i<3; i++) {
        assert(pArr[i] != NULL);
        for(int j=0; j<3; j++) {
            printf("%d ", pArr[i][j]);
        }
        printf("\n");
    }
    return 0;
}

核心区别:

  • 真正二维数组:内存连续,所有行紧密排列
  • 指针数组模拟:内存不连续,每行是独立一维数组

十二、总结

本篇我们学习了C语言指针的核心知识:

  1. 内存与地址:内存是存储数据的空间,地址是内存的"门牌号"
  2. 指针变量:存储地址的变量,通过解引用访问指向的数据
  3. 指针类型:决定解引用读取的字节数和指针运算的单位
  4. 指针运算:与整数加减、指针相减、指针比较
  5. 数组与指针:数组名是首元素地址,指针可以遍历数组
  6. const修饰指针:限制指针或指向内容的可修改性
  7. assert断言:调试阶段的非法条件检查利器
  8. 指针作为函数参数:实现传址调用,修改外部变量
  9. 排序算法的指针实现:冒泡、选择、插入排序
  10. 二级指针与指针数组:指针的进阶应用

指针是C语言的精髓,掌握指针意味着你真正理解了计算机的内存管理机制。继续学习指针的高级用法,如函数指针、动态内存管理等,你将开启C语言编程的新境界!