C语言指针详解(上):指针变量、函数指针

C语言指针详解(上):指针变量、函数指针

本篇讲解指针的核心概念与使用方法。关于指针安全(空指针、野指针)和嵌入式实战用法,请看《C语言指针详解(下)》

一、指针基础

1.1 定义

指针变量是一个特殊的变量,它存储的值是另一个变量的内存地址(即地址编号),而不是数据本身。我们平时说的"指针",通常指的就是这个地址值本身。

ℹ️ 指针 vs 指针变量
  • 指针:一个内存地址(一个数值),代表某块内存的编号
  • 指针变量:一个变量,专门用于存放内存地址

类比:门牌号是"指针",写着门牌号的纸条是"指针变量"。

💡 核心理解

变量存数据,指针存地址。指针变量的值有特殊意义——它代表一个地址编号,通过这个地址可以间接访问和操作对应内存中的数据。

语法格式:

数据类型 *指针变量名;
  • 数据类型:指针所指向的变量的类型
  • *:声明这是一个指针变量
  • 指针变量名:指针本身的名字
ℹ️ 关键运算符
  • &(取地址):获取变量的内存地址
  • *(解引用):获取指针指向地址上的值

指针变量的大小是固定的,不取决于它指向什么类型,而取决于系统的地址总线宽度:

系统 指针大小 原因
32 位(STM32、普通 PC) 4 字节 地址总线 32 位,可寻址 2³² = 4GB
64 位(现代 PC) 8 字节 地址总线 64 位,可寻址 2⁶⁴
// 无论指向什么类型,指针大小都一样(32位系统下均为4)
sizeof(int *)    // 4
sizeof(char *)   // 4
sizeof(double *) // 4
sizeof(void *)   // 4

Visual Studio 2022 x86/x64 系统运行截图:

指针大小对比

1.2 基本用法

#include <stdio.h>
#include <stdlib.h>

int b = 20;                          // 全局变量,存在全局/静态区

void main(void)
{
    int a = 10;                      // 局部变量,存在栈区
    int *p1 = &a;                    // p1 → 栈上的 a
    int *p2 = &b;                    // p2 → 全局区的 b
    int *p3 = (int*)malloc(sizeof(int));  // p3 → 堆上动态分配的内存
    if (!p3) {
        return;
    }
    *p3 = 30;

    int *p;
    p = (int *)100;                  // ⚠️ 语法合法,但地址 100 不可读写!

    printf("*p1 = %d, *p2 = %d, *p3 = %d\n", *p1, *(&b), p3[0]);
    printf("p = %p\n", p);
    printf("*p = %d\n", *p);        // ❌ 崩溃!访问了非法地址

    free(p3);
}

这段代码展示了指针的几种典型场景:

指针 指向哪里 内存区域 结果
p1 = &a 局部变量 a 栈区 ✅ 正常读写
p2 = &b 全局变量 b 全局/静态区 ✅ 正常读写
p3 = malloc(...) 动态分配 堆区 ✅ 正常读写(用完要 free
p = (int*)100 随意指定的地址 未知 程序崩溃(野指针)
ℹ️ 几个细节
  • *(&b) 等价于 b — 先取 b 的地址,再解引用拿回值
  • p3[0] 等价于 *p3 — 指针可以用数组下标语法访问
  • p = (int*)100 赋值语法合法(整数强转为指针),但地址 100 不属于你的程序,读写必崩
⚠️ 野指针预警

最后一行 *p = ... 对地址 100 解引用,这就是典型的野指针——指向一个不确定/非法的地址。关于野指针的成因与防御,详见下篇。

1.3 指针访问方式与算术

指针本质就是一个内存地址,通过地址直接读写内存。C 语言提供了三种访问方式,它们的底层都依赖于指针算术

三种访问方式

方式 语法 等价写法 适用场景
解引用 *p 访问单个变量
下标 p[n] *(p + n) 访问数组/连续内存
箭头 p->m (*p).m 访问结构体成员

方式一:解引用 *p

最基础的访问方式,直接读写指针指向的内存:

int a = 10;
int *p = &a;
*p = 20;       // 写入:a 变为 20
int b = *p;    // 读取:b = 20

方式二:下标 p[n]

p[n] 等价于 *(p + n),从指针地址偏移 n 个元素后解引用。这就引出了指针算术的核心概念。

方式三:箭头 p->member

用于结构体指针,等价于 (*p).member

struct Student {
    char name[20];
    int age;
};

struct Student s = {"Tom", 18};
struct Student *p = &s;
p->age = 20;           // 等价于 (*p).age = 20
printf("%s", p->name); // 等价于 (*p).name
💡 为什么需要箭头运算符?

(*p).member 需要先解引用再访问成员,括号不能省略(因为 . 优先级高于 *)。p->member 是更简洁的写法,在嵌入式开发中大量使用。

指针算术:+1 不是地址 +1

ptr + 1 的核心是「元素个数加 1」,而非「内存地址数值直接加 1 字节」。

公式: 指针偏移后的地址 = 原地址 + n × sizeof(指针指向的类型)

char   *pc = (char *)0x1000;    // pc + 1 → 0x1001  (+1 字节)
int    *pi = (int *)0x1000;     // pi + 1 → 0x1004  (+4 字节)
double *pd = (double *)0x1000;  // pd + 1 → 0x1008  (+8 字节)
💡 设计原理

这样设计是为了适配连续内存的元素访问,让 p[n]*(p + n) 自动定位到第 n 个元素,不用手动计算字节数。

实例:数组遍历中的三种访问方式

下面的代码展示了如何用三种等价方式访问数组元素:

int arr[] = {10, 20, 30, 40};
int *p = arr;          // p 指向 arr[0],假设地址 0x1000

// 指针算术的内存布局:
// p + 0 → 0x1000 → arr[0] = 10
// p + 1 → 0x1004 → arr[1] = 20
// p + 2 → 0x1008 → arr[2] = 30

// 三种等价的访问方式:
printf("%d\n", *(p + 0));  // 方式一:指针算术 + 解引用 → 10
printf("%d\n", p[1]);      // 方式二:下标访问 → 20
printf("%d\n", *(arr + 2));// 方式三:数组名 + 算术 → 30
ℹ️ 为什么指针可以用下标?

因为 p[n] 只是 *(p + n) 的语法糖。编译器会自动将下标转换为指针算术运算。这也是为什么数组和指针在很多场景下可以互换使用的原因。
⚠️ 越界风险

指针算术不会检查边界!p + 100 语法合法,但如果超出数组范围,访问时会导致未定义行为(崩溃或读到垃圾数据)。

1.4 指针与数组

#include <stdio.h>

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

    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d, *(p+%d) = %d\n", i, arr[i], i, *(p + i));
    }
    // arr[i] 与 *(p + i) 完全等价

    return 0;
}

数组退化(Array Decay)

上面代码中 int *p = arr 能直接赋值,是因为 C 语言的一条重要规则:数组名在大多数表达式中会自动"退化"为指向首元素的指针

int arr[5] = {1, 2, 3, 4, 5};

int *p = arr;       // arr 退化成 int*,等价于 &arr[0]

void foo(int *p, int len);
foo(arr, 5);        // 传参时 arr 也会退化成 int*

不退化的三个例外:

场景 行为 示例
sizeof(arr) 返回整个数组的大小 sizeof(arr)20(5×4 字节)
&arr 得到数组指针(类型是 int(*)[5] 不是 int*
字符串字面量初始化 char[] 拷贝内容,不退化 char s[] = "hello";
⚠️ 数组传参后 sizeof 就"不对了"
void foo(int arr[]) {       // 本质上是 int *arr,已经退化了
    sizeof(arr);            // ⚠️ 得到的是指针大小(4或8),不是数组大小!
}

这就是为什么数组长度必须作为额外参数传入函数的原因。

1.5 指针作为函数参数(传址调用)

#include <stdio.h>

// 通过指针交换两个变量的值
void swap(int *x, int *y) {
    int temp = *x;
    *x = *y;
    *y = temp;
}

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

    swap(&a, &b);  // 传入地址
    printf("交换后: a=%d, b=%d\n", a, b);  // a=7, b=3

    return 0;
}

二、函数指针

💡 一句话理解

函数指针就是指向函数的指针——变量指针存的是数据地址,函数指针存的是代码(函数入口)地址

2.1 定义与内存视角

在 C 语言中,函数名本质上就是一个地址常量,指向该函数在内存中机器码的入口地址。函数指针是一个变量,它存储了某个函数的入口地址,通过它可以间接调用该函数。

函数指针内存示意

2.2 声明格式

返回值类型 (*指针变量名)(参数类型列表);
⚠️ 注意括号

(*指针变量名) 的括号不可省略
  • int (*p)(int, int); → ✅ 这是函数指针,p 指向一个 int(int,int) 型函数
  • int *p(int, int); → ❌ 这是返回 int* 的函数声明,不是函数指针!

2.3 常见声明示例

// 指向 "无参数、无返回值" 函数的指针
void (*p1)(void);

// 指向 "两个int参数、返回int" 函数的指针
int (*p2)(int, int);

// 指向 "一个char*参数、返回void" 函数的指针
void (*p3)(char *);

// 指向 "无参数、返回 uint8_t*" 函数的指针
uint8_t* (*p4)(void);

2.4 使用 typedef 简化声明

// 定义函数指针类型
typedef int (*MathFunc_t)(int, int);

// 用类型名声明变量,简洁清晰
MathFunc_t pAdd = add;
MathFunc_t pSub = sub;
💡 最佳实践

在嵌入式项目中,强烈建议使用 typedef 定义函数指针类型:
  • 命名加 _t 后缀或 Callback 后缀,语义更清晰
  • 多处使用时避免重复书写冗长的声明
  • HAL 库、RTOS 中大量采用此方式

2.5 赋值与调用

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

int main(void) {
    int (*p)(int, int);  // 声明函数指针

    p = add;             // 赋值:函数名自动退化为函数指针,不需要 &
    printf("10 + 20 = %d\n", p(10, 20));   // 输出: 30

    p = sub;             // 重新指向另一个函数
    printf("10 - 20 = %d\n", p(10, 20));   // 输出: -10

    return 0;
}
ℹ️ 函数名退化(Function Decay)

与数组名退化为指针类似,函数名在表达式中也会自动退化为函数指针
p = add;    // add 退化成函数指针 ✅
p = &add;   // 显式取地址,效果一样 ✅

这就是为什么 p = add 不需要加 & 的原因。

ℹ️ 两种等价的调用方式
p(10, 20);       // 直接调用(推荐,简洁)
(*p)(10, 20);    // 解引用调用(更显式)

2.6 回调函数(函数指针作为参数)

回调函数是函数指针最核心的应用——将"行为"作为参数传递给另一个函数。

#include <stdio.h>

typedef int (*Operation_t)(int, int);

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

// 计算器函数,接受一个函数指针作为参数
int calculate(int a, int b, Operation_t op) {
    return op(a, b);  // 通过函数指针调用传入的函数
}

int main(void) {
    printf("加法: %d\n", calculate(10, 5, add));  // 15
    printf("减法: %d\n", calculate(10, 5, sub));  // 5
    printf("乘法: %d\n", calculate(10, 5, mul));  // 50
    return 0;
}
💡 回调的本质

"你不直接调用我,你把我的地址给别人,让别人在合适的时候来调用我。"

这就是回调——控制反转的思想。

2.7 函数指针数组

将多个同类型函数组织成数组,用索引来选择调用哪一个:

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int divide(int a, int b) { return b != 0 ? a / b : 0; }

int main(void) {
    // 函数指针数组
    int (*ops[])(int, int) = { add, sub, mul, divide };
    const char *names[] = { "加", "减", "乘", "除" };

    int a = 20, b = 5;
    for (int i = 0; i < 4; i++) {
        printf("%s法: %d\n", names[i], ops[i](a, b));
    }
    return 0;
}
ℹ️ 应用场景

函数指针数组在嵌入式中常用于:
  • 命令解析器:根据命令 ID 索引对应的处理函数
  • 状态机:根据状态索引对应的状态处理函数
  • 菜单系统:根据菜单项索引对应的操作

三、* 星号辨析:定义指针 vs 解引用 vs 乘法

* 在 C 语言中有 三种完全不同的含义,仅靠上下文区分:

场景 含义 示例 怎么读
类型声明 定义指针变量 int *p; "p 是一个 int 指针"
表达式中(单目) 解引用(取值) *p = 10; "取 p 指向的值"
表达式中(双目) 乘法 a * b "a 乘以 b"

判断方法:看 * 左边是什么

        ┌─ 左边是"类型名" → 定义指针
        │     int *p;       // int 是类型 → 定义指针
  * 号 ─┤
        └─ 左边不是类型名 → 表达式中使用
              *p = 10;      // 左边没有类型 → 解引用
              a * b;        // 两边都是变量 → 乘法

最容易混淆的场景

// 场景一:定义时的初始化 —— * 是声明的一部分,不是解引用
int *p = &a;    // 不是 "把 &a 赋给 *p",而是 "声明 int* 类型的 p,初始化为 &a"

// 场景二:一行定义多个变量 —— * 只跟紧挨着的变量
int *x, y;      // x 是 int*(指针),y 只是 int(不是指针!)
int *x, *y;     // x 和 y 都是 int*(每个都要加 *)

// 场景三:函数参数中
void foo(int *p) { ... }   // 声明:p 是指针参数
foo(&a);                    // 调用时传地址,这里的 & 是取地址
📋 总结速查
int *p = &a;     →  * 靠着类型(int)→ 声明指针
*p = 10;         →  * 单独跟变量     → 解引用(写入)
x = *p;          →  * 单独跟变量     → 解引用(读取)
c = a * b;       →  * 两边都是值     → 乘法

记不住就看一点:* 前面有没有类型名(int/char/float...)

  • ✅ 有 → 定义指针
  • ❌ 没有 → 解引用或乘法

四、总结

指针分类速查

指针类型 说明 关键点
普通指针 指向变量数据 * &,传址调用、数组遍历
函数指针 指向函数入口地址 回调、状态机、驱动抽象
📋 核心要点
  • 取地址用 &,解引用用 *
  • * 前有类型 → 声明;无类型 → 运算
  • 数组名、函数名在表达式中会自动退化为指针
  • 函数指针调用前必须判空if (cb) cb();

五、复习题

选择题

1. 在 32 位系统下,以下哪个表达式的值不是 4?

A. sizeof(int *)
B. sizeof(char *)
C. sizeof(int [5])
D. sizeof(void *)

查看答案

**C**。sizeof(int [5]) = 5 × 4 = 20 字节,得到的是整个数组的大小。其他选项都是指针大小,在 32 位系统下均为 4 字节。

2. 下面代码的输出是什么?

int a = 5;
int *p = &a;
*p = 20;
printf("%d\n", a);

A. 5
B. 20
C. 地址值
D. 编译错误

查看答案

**B**。p 指向 a*p = 20 通过解引用修改了 a 的值,所以输出 20

3. 以下声明中,哪一个是函数指针?

A. int *p(int, int);
B. int (*p)(int, int);
C. int *(p)(int, int);
D. int (p*)(int, int);

查看答案

**B**。int (*p)(int, int) 是函数指针,p 指向一个接收两个 int 参数、返回 int 的函数。A 是返回 int* 的函数声明,C/D 语法错误或含义不同。

4. int *x, y; 这行代码声明了什么?

A. 两个 int 指针
B. 两个 int 变量
C. xint 指针,yint 变量
D. 编译错误

查看答案

**C**。* 只作用于紧跟的变量名 x,所以 xint* 类型,y 只是普通 int。要让两者都是指针,需要写 int *x, *y;

判断题

5. 数组作为函数参数传递后,在函数内部使用 sizeof 仍能得到数组的总大小。( )

查看答案

**✗ 错误**。数组传参时会退化为指针,sizeof 得到的是指针大小(4 或 8 字节),而非数组总大小。这就是为什么数组长度需要作为额外参数传入。

6. p = add;p = &add; 效果完全相同,因为函数名会自动退化为函数指针。( )

查看答案

**✓ 正确**。与数组名退化为指针类似,函数名在表达式中也会自动退化为函数指针,所以 add&add 等价。

编程题

7. 请写出以下代码的运行结果:

int arr[] = {10, 20, 30, 40, 50};
int *p = arr;
printf("%d %d %d\n", *p, *(p+2), p[4]);
查看答案

输出:10 30 50

- *p → 首元素 arr[0] = 10
- *(p+2)arr[2] = 30
- p[4]arr[4] = 50(指针下标访问等价于 *(p+4)

8. 使用函数指针和回调函数实现一个 apply 函数,接收一个整型数组、数组长度和一个函数指针,对数组的每个元素执行该函数并打印结果。

查看参考答案

```c
#include <stdio.h>

typedef int (*Transform_t)(int);

int doubleIt(int x) { return x * 2; }
int square(int x) { return x * x; }

void apply(int arr[], int len, Transform_t func) {
for (int i = 0; i < len; i++) {
printf("%d ", func(arr[i]));
}
printf("\n");
}

int main(void) {
int data[] = {1, 2, 3, 4, 5};
apply(data, 5, doubleIt); // 输出: 2 4 6 8 10
apply(data, 5, square); // 输出: 1 4 9 16 25
return 0;
}
```

关键点:使用 typedef 定义函数指针类型,将"行为"作为参数传入,体现回调思想。


延伸阅读

  • C语言指针详解(下) — 空指针、野指针、嵌入式实战用法
  • 嵌入式开发函数指针使用 — 函数指针的完整实战应用(状态机、命令解析器、驱动抽象)
  • C语言指针和结构体 — 指针与结构体的综合使用
  • C语言变量 — 变量的存储与生命周期