第6章 C语言基础-指针
在 C 语言中,指针(Pointer)是最具特色、最强大,也最令初学者困惑的核心特性。它直接映射计算机内存模型,让开发者能精准操作内存地址,赋予程序员对硬件近乎底层的控制能力。但指针的灵活性也带来了风险 —— 野指针、内存泄漏等问题。掌握指针,是真正掌握C语言的关键一步。
6.1、指针概述
6.1.1、指针的定义
C标准(C99/C11 §6.2.5)中关于指针的描述: "A pointer type may be derived from a function type or an object type, called the referenced type. A pointer type describes an object whose value provides a reference to an entity of the referenced type."
翻译:指针类型可派生自函数类型或对象类型(称为被引用类型),指针类型描述一个对象,其值提供对被引用类型实体的引用。
也就是说:地址+类型解读合在一起才构成 pointer
- 指针的类型:取决于被引用类型,它决定了指针的“步长”(决定了解引用时如何解释内存)
- 指针本身也是一个对象:指针变量(一个存储内存地址的变量)本身在内存中占据一定的空间,有自己的地址和值;
- 指针的值: 某个变量或对象在内存中的(起始)位置。
- 提供一个引用: 指针的值(地址)充当了一个“引用”或“链接”,通过它可以访问(读取或修改)位于该地址处的、属于“被引用类型”的那个实体。可以通过解引用操作符
*来使用这个引用。
在C语言中,指针类型是一种派生自对象类型或函数类型的引用机制。它通过“地址值 + 类型解读”的双重结构,实现对内存实体的显式引用。
通俗的理解:指针 = 地址 + 类型信息;指针变量 = 存储内存地址的容器;指针设计的核心思想是直接访问内存位置,而不是通过变量名来访问数据。指针和指针变量是两个不同的概念,一个变量的地址我们称之为变量的“指针(引用)”,一个用来存放指针的变量,称之为指针变量。
6.1.2、指针的历史起源
- 汇编时代:只有地址
在汇编语言中,内存地址是直接暴露的,程序员需要手动管理内存。地址只是一个数字,没有类型信息。这个时候的程序员必须自己记住每个地址存储的数据类型,并使用正确的指令来操作。
MOV AX, [0x1000] ; //将内存地址0x1000处的值移动到AX寄存器
- 1960年:首次出现“引用”概念
Algol 60是第一个引入“引用”概念的高级语言。它允许变量“引用”另一个变量,但语言层面不暴露地址。
- 1965年:PL/I的POINTER类型
PL/I(Programming Language One)是由IBM开发的语言,它首次引入了POINTER类型,PL/I的指针可以指向数据对象,并且提供了显式的指针操作。
- 1970年:B语言的指针简化
B语言是C语言的前身,由贝尔实验室的Ken Thompson开发。B语言简化了PL/I的指针概念,引入了&和*运算符,使得指针操作更加直接和简洁:B语言的指针直接映射到机器地址,没有类型信息。
auto x, y;
auto p;
p = &x; // 获取x的地址
*p = 3; // 将3赋值给p指向的变量(即x)
y = *p; // 将p指向的变量的值赋给y
- C语言:类型化的指针
C语言在B语言的基础上,引入了类型化的指针。每个指针都有类型,表示它指向的数据类型。这是因为 1972 年,Dennis Ritchie 为 PDP-11 重写 B,当时的PDP-11 支持 字节级寻址(需区分 char 和 int),所以指针开始携带类型。
char c; // 1字节(PDP-11 字节指令)
int i; // 早期只有2字节,后来才出现4字节
char *pc = &c; // 指针携带类型信息
int *pi = &i; // 解引用时按 sizeof(int) 缩放
6.1.3、指针变量的声明与初始化
指针变量的声明与初始化语法:
type *pointer_name; //声明指针
type *pointer_name = value; //声明指针并初始化
-
type是目标对象的类型。 -
*表示这是一个指针声明/变量。 -
pointer_name是指针变量名。 -
C标准规定,每种数据类型都可以有对应的指针类型。
- 示例1-声明语法格式:
int *p; // 常见风格:强调 * 作用于 p,C标准明确将 * 视为声明符的一部分,采用 int *p 风格 //另外两种风格也支持,但是推荐使用上面的 int* p; // 常见风格:强调 p 是 int* 类型 int * p; // 注:合法但不常用 -
示例2:
int *p1; // 未初始化指针(危险未初始化的指针是“野指针”,其值是未定义的,解引用会导致未定义行为)
int *p2 = NULL; // 空指针(C11推荐使用NULL而非0)
int *p3 = &var; //声明指针并初始化(var为已声明的变量)
6.1.4、取地址操作符与取值操作符
在 C 语言中,指针是一种特殊的变量类型,用于存储内存地址。取地址操作符 (&) 和取值操作符 (*) 是与指针配合使用的两个重要运算符,它们分别用于获取变量地址和访问指针所指向的内存内容。
-
取地址操作符 (
&)- 作用:获取变量在内存中的存储地址
- 语法:
&变量名 - 返回值:变量所占据内存空间的起始地址(指针类型)
-
取值操作符 (
*)- 作用:访问指针所指向的内存地址中存储的值
- 语法:
*指针变量名 - 返回值:指针所指向地址中存储的实际值
-
示例:
#include <stdio.h>
int main() {
int a = 10; // 定义int类型变量a,赋值为10,假设其地址为:0x3000
int *p = &a; // 定义int类型指针p,存储a的地址,假设其地址为:0x2000
int **q = &p; // 定义int二级指针q,存储p的地址,由于栈遵循先进后出,最后声明的变量会首先分配到栈中,假设其地址为:0x1000
return 0;
}
- 在内存中的示意图:
- 当main函数执行时,操作系统为
main函数分配了一块栈空间,用来存储函数内部的局部变量;- 在
main函数中,int a = 10;会在栈上分配一块内存存储a的值 10,假设该地址是0x3000;(在大多数现代计算机架构中栈是从高地址向低地址生长的);- 在
main函数中,int *p = &a;会在栈上分配一块内存存储p的值,假设该地址是0x2000,其值是变量a的地址值0x3000;这里的int *p的意思是p是一个指针变量,类型是int*,表明p是“pointer to int”(指向整数的指针),在声明中*是一个派生符号,用来声明p是指针类型;&a是取地址操作,意思是取a的地址,并将这个地址值赋值给p;- 在
main函数中,int **q = &p;会在栈上分配一块内存存储q的值,假设该地址是0x1000,其值是变量p的地址值0x2000;这里的int **q的意思是q是一个指针变量,类型是int*,表明q是“pointer to int*”(指向指针类型的指针,也叫二级指针);
6.1.5、指针的类型系统
C 标准把“指针”归为派生类型,对任意type T,可派生出T *(指向 T 的指针),因此“指针”本身不是单一类型,而是一族类型的统称。
- 按指向的目标类型分类,可以分为:
| 类别 | 描述 | 示例 |
|---|---|---|
| 对象类型指针 | 指向数据对象的指针,包括基本类型、结构体、联合体、数组、枚举、也包括多级指针等 | int *ip; struct Point *sp; int (*ap)[5]; int **pp; |
| 函数类型指针 | 指向函数的指针,存储函数入口地址 | int (*fp)(int, int); void (*vp)(void); |
| void类型指针 | 通用指针,可指向任何对象类型,但不能直接解引用 | void *vp; |
- 限定符修饰的对象不同,可分为:
| 限定符 | 修饰位置 | 描述 | 示例 |
|---|---|---|---|
| const | 指向的类型 | 表示指向的数据是常量,不可通过该指针修改 | const int *p; int const *p; |
| const | 指针本身 | 表示指针本身是常量,不可修改指针的指向 | int *const p; |
| volatile | 指向的类型 | 表示指向的数据可能被外部因素改变,访问时不要优化 | volatile int *p; |
| volatile | 指针本身 | 表示指针本身可能被外部因素改变 | int *volatile p; |
| restrict | 指针本身 | 表示该指针是访问其指向对象的唯一方式(C99) | int *restrict p; |
| _Atomic | 指向的类型 | 表示通过该指针访问对象是原子操作(C11) | _Atomic int *p; |
| _Atomic | 指针本身 | 表示指针本身的操作是原子操作(C11) | int *_Atomic p; |
注:C标准允许用 “类型限定符”(const、volatile等)修饰指针,形成 “限定符修饰的指针”,这类指针的核心作用是 “通过类型约束,限制对指针指向对象的修改权限” 或 “提示编译器内存可能被外部修改”。
6.1.6、指针的大小
无论指针指向 char、int、double 还是复杂结构体,所有数据指针的大小在同一个平台上都是相同的。
- 示例1:
#include <stdio.h>
int main(){
char *cp; // 指向char
int *ip; // 指向int
double *dp; // 指向double
struct BigStruct *sp; // 指向大结构体
printf("%zu %zu %zu %zu",
sizeof(cp), sizeof(ip), sizeof(dp), sizeof(sp));// 输出:8 8 8 8(64位系统) 输出:4 4 4 4(32位系统)
}
指针的大小与机器架构有关:
- 指针变量存储的是内存地址,地址是硬件层面的概念。
- 地址的位数由CPU地址总线宽度决定
| 架构 | 指针大小 | 地址空间 | 典型平台 |
|---|---|---|---|
| 32位 | 4字节 | 2³² = 4GB | x86, ARM32 |
| 64位 | 8字节 | 2⁶⁴ = 16EB | x86-64, ARM64 |
- 示例2:
#include <stdio.h>
int main() {
int a = 10; // 定义一个int类型的变量a
int *p = &a; // 定义一个int类型的指针p,并使其指向变量a的内存地址
printf("a = %d\n", a); // 输出a的值
printf("*p = %d\n", *p); // 通过指针p解引用,输出p指向的值,即a的值
printf("Address of a = %p\n", (void*)&a); // 输出变量a的内存地址
printf("Address stored in p = %p\n", (void*)p); // 输出p存储的地址,和a的地址一样
printf("size_a=%zu size_p=%zu ", sizeof(a), sizeof(p)); //size_a=4 size_p=8
return 0;
}
| 地址(虚拟地址) | 变量/数据 | 占用大小 |
|---|---|---|
| 0x7fffd23a0d00 | a = 10 | 4字节 |
| 0x7fffd23a0d08 | p (指针) = 0x7fffd23a0d00 | 8字节 |
| 0x7fffd23a0d10 | 堆或栈其他数据 | 其他内存 |
注:在64位系统上,如果你使用指针来存储一个简单的 int 类型变量,确实会导致内存的占用变大。这是因为指针本身在64位系统上通常需要 8字节,而 int 类型变量本身仅仅需要 4字节。
6.1.7、指针变量的运算
在 C 语言中,指针变量存储的是内存地址(本质是一个数值),但它的运算逻辑却和普通数值(如int、long)截然不同 —— 你不能对指针做乘法、除法,加法和减法也有严格限制。
前面我们说到“指针 = 地址 + 类型信息“,指针运算的本质不是 “地址值的数值运算”,而是 “基于数据类型的内存偏移运算” 。
比如:
- int *p:存储的地址值指向int类型数据,编译器会记住 “p关联的类型是int”;
- char *q:存储的地址值指向char类型数据,编译器会记住 “q关联的类型是char”。
在 C 语言中,指针仅支持加法、减法、指针与指针的减法三种运算。且每种运算都有明确的场景限制。这些运算之所以被允许,本质是为了满足 “连续内存块的高效访问” 需求,最典型的场景就是数组遍历。
注: 函数指针和void指针均不支持运算,函数在内存中是 “指令序列”,不是 “连续的数据块”。
6.1.7.1、基本规则
对于指针 p 指向类型 T:
p + n:地址偏移n * sizeof(T)字节。p - n:地址偏移-n * sizeof(T)字节。p++,p--:分别指向下一个/上一个T类型对象。- 示例:
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; // p 指向 arr[0],即数组内存地址的首地址
printf("%d\n", *p); // 10
printf("%d\n", *(p+1)); // 20
printf("%d\n", *(p+2)); // 30 (等价于 p[2)
char c = 'a';
char *pc = &c;
//输出原始地址
printf("p=%#X, c=%#X\n", p, pc); //p=0X61FE84, c=0X61FE83
//输出每次地址的偏移量
p++,pc++; //int的偏移量是4 char的偏移量是1
printf("p=%#X, c=%#X\n", p, pc); //p=0X61FE88, c=0X61FE84
return 0;
}
6.1.7.2、指针相减
两个指向同一数组(或其末尾后一个位置)的指针可以相减,表示它们之间的元素个数。
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;
int *q = &arr[4];
printf("q - p = %ld\n", q - p); // 输出 q - p = 4,表示 q 是 p 后面的第 4 个元素
return 0;
}
注意:指向不同数组或无关对象的指针相减是未定义行为。
6.2、对象指针
对象指针是指向对象类型的指针。对象类型包括:基本类型、复合类型(数组类型,结构体类型、联合体类型、枚举类型)、多级指针;
6.2.1、基本类型指针
-
整型指针(
int *)- 示例:
//这是最常见的指针类型,指向一个整数类型的数据 int a = 10; int *p = &a; // p 是一个指向 int 类型的指针,存储 a 的地址 -
字符型指针 (
char *)- 示例:
//字符型指针指向一个字符型变量。在字符串操作、字符数组和处理文本时经常使用。 char c = 'A'; char *p = &c; // p 是一个指向 char 类型的指针,存储 c 的地址 -
浮点型指针 (
float *)- 示例:
//浮点型指针指向一个浮点数类型的数据 float f = 3.14; float *p = &f; // p 是一个指向 float 类型的指针,存储 f 的地址 -
双精度浮点型指针 (
double *)- 示例:
//双精度浮点型指针用于指向 double 类型的数据 double d = 5.67; double *p = &d; // p 是一个指向 double 类型的指针,存储 d 的地址
6.2.2、复合类型指针-数组指针
数组(的)指针是指向整个数组的指针,并不是单纯的指向数组首元素的指针。数组指针具有双重特性:
- 地址值:与数组首元素的地址相同;
- 类型信息:携带了完整的数组类型和大小信息(而不仅仅是首元素指针)
6.2.2.1、数组指针声明
其声明形式为:
type (*pointer_name)[size];
type:数组元素的类型pointer_name:指针变量名size:数组的大小(必须为常量表达式)- 示例1:
#include <stdio.h>
int main(void) {
int arr[3];
int (*arr_ptr)[3] = &arr; // 指向包含5个int的数组的指针
//地址值 = 数组的首地址 但是size = 数组的总大小
printf("arr first address: %p\n", &arr); //数组的首地址
printf("arr_ptr points to array: %p, sizeof(*pa): %zu\n", (void*)arr_ptr, sizeof(*arr_ptr)); //arr_ptr points to array=arr first address sizeof:12 (3*4)
return 0;
}
- 数组指针与普通指针内存布局区别
内存地址: 0x1000 0x1004 0x1008 0x100C
+--------+--------+--------+--------+
| 10 | 20 | 30 | ?? |
+--------+--------+--------+--------+
a[0] a[1] a[2] arr_ptr+1指向的位置
arr: 0x1000 (int *) → 指向单个int
arr_ptr:0x1000 (int (*)[3]) → 指向整个数组
6.2.2.1、数组指针与指针数组的区别
- 数组指针:它是“指针”,指向一个数组对象。
- 指针数组:它是“数组”,元素类型是指针。
- 示例1:从定义格式上区分
//数组指针
//1) 一个“指向含 3 个 int 的数组”的指针,类型是 int (*)[3]
//2)从书写格式上区分,pa 先被 () 括住,与 * 结合 → “pa 是指针”;再与 [3] 结合 → “指向‘长度为 3 的 int 数组’”(()的优先级高于[])
int (*pa)[3];
//指针数组
//1)一个有 3 个元素的数组,每个元素都是 int *
//2)ap 先与 [3] 结合 → “ap 是长度为 3 的数组”;再看 * → “数组元素是 int *([]的优先级高于*)
int *ap[3];
- 示例2:
#include <stdio.h>
int main(void) {
int a[3] = {10, 20, 30}; // 一个数组对象
// 1. 数组名 a 在表达式中衰变为 int*(指向首地址)
int *p_first = a; // 普通指针类型: int*,指向 a[0] 的地址
printf("p_first points to first: %p, value: %d\n", (void*)p_first, *p_first); // 10
// 2. &a 是指向整个数组的指针(不衰变)
int (*pa)[3] = &a; // 类型: int (*)[3],指向整个 a
printf("pa points to array: %p, sizeof(*pa): %zu\n", (void*)pa, sizeof(*pa)); // sizeof=12 (3*4)
// 双重性演示:
// - "首地址"面:解引用后访问元素(行为类似首元素指针)
printf("(*pa)[1] via deref: %d\n", (*pa)[1]); // 20
printf("address of (*pa)[1] = %p\n", (void *)&(*pa)[1]);
printf("p_first[1] = %d, address = %p\n", p_first[1], (void *)&p_first[1]);
// 说明:(*pa)[1] 和 p_first[1] 访问的是同一个元素 a[1]
// 原因:*pa 得到整个数组,但数组在表达式中衰变为指向首元素的指针(与 p_first 相同)
// - "整个数组"面:算术跳过整个数组
printf("pa + 1 addr: %p (skips 12 bytes)\n", (void*)(pa + 1)); // pa + 3*sizeof(int)
printf("p_first + 1 addr: %p (skips 4 bytes)\n", (void*)(p_first + 1)); // 对比:只跳过一个int
// 说明:pa 是数组指针,+1 移动整个数组大小;p_first 是元素指针,+1 只移动一个元素大小
// 指针数组示例(对比)
int x=1, y=2;
int *ap[2] = {&x, &y}; // 数组,存指针
printf("ap decays to int **: %p, sizeof(ap): %zu\n", (void*)ap, sizeof(ap)); // sizeof=16 (2*8)
printf("ap[0] points to: %p, value: %d\n", (void*)ap[0], *ap[0]); // 访问第一个指针
// 错误混用演示:
// 不能把数组直接赋给指针数组(类型不匹配)
//int **bad = a; // 错误:int * vs int (*)[3](编译器会警告或报错:[Warning] initialization from incompatible pointer type [enabled by default])
return 0;
}
-
解释:这里会涉及C标准的中两个规则
-
规则1:C标准中关于数组到指针的隐式转换规则:
Except when it is the operand of the sizeof operator, the _Alignof operator, or the unary & operator, or is a string literal used to initialize an array, an expression that has type “array of type” is converted to an expression with type “pointer to type” that points to the initial element of the array object and is not an lvalue.
除了
sizeof运算符、_Alignof运算符或一元&运算符的操作数,或者是用于初始化数组的字符串字面量,否则,类型为“type类型的数组”的表达式会被转换为一个类型为“指向type的指针”的表达式,该指针指向数组对象的首元素,并且这个结果不是一个左值。C 从 B 语言继承而来,B 语言里“数组”本质上就是指针,这样做的目的是实现简单(把数组名变成首地址,函数调用时只要传一个机器字(指针),不必整段复制数组内容)。
-
规则2:C标准中关于下标运算符的规范
A postfix expression followed by an expression in square brackets
[]is a subscript operator.One of the expressions shall have type “pointer to complete object type”, the other shall have integer type.The definition of the subscript operator is that E1[E2] is identical to (*((E1)+(E2))).The commutative requirement also holds: E1[E2] == E2[E1].后缀表达式后跟方括号“[]”中的表达式是一个下标运算符。其中一个表达式应具有“指向完整对象类型的指针”类型,另一个应具有整数类型。下标运算符的定义是E1[E2]等于(*(E1)+(E2))。交换率要求也成立:E1[E2]==E2[E1]
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; // p 指向 arr[0] // 以下所有写法都等价,结果都是 30 printf("%d\n", arr[2]); // 直观写法,方便我们使用小标访问数组元素 printf("%d\n", *(arr + 2)); // 编译器会按规范,根据定义展开:arr[2] -> (*((arr) + (2))) printf("%d\n", *(p + 2)); // p + 2 指向 arr[2],解引用得 30 printf("%d\n", 2[arr]); // 非常规但合法的写法!因为 2[arr] -> (*((2) + (arr))) -> 与 arr[2] 相同 -
-
验证1:数组遇到
sizeof运算符不退化
int main(){
int a[3] = {10, 20, 30}; // 一个数组对象
// 1. 数组名 a 在表达式中衰变为 int*(指向首地址)
int *p_first = a;
//数组在表达式中,遇到 sizeof不退化
printf("%数组表达式遇到sizeof: zu\n", sizeof(a)); // 输出:12 是整个数组的大小 (3 * sizeof(int))
// 如果arr退化成了指针,sizeof(a)应该输出指针的大小(如8字节),但它没有退化!
}
- 验证2:遇到一元
&运算符的操作数,不退化
#include <stdio.h>
int main(){
int a[3] = {10, 20, 30}; // 一个数组对象
// 1. 数组名 a 在表达式中衰变为 int*(指向首地址)
int *p_first = a;
//一元 & 作用于对象 x 时,值是该对象所占内存的首字节地址;类型是“指向 x 类型”的指针
//这样写会出现警告,对谁取地址,类型就是指向谁的指针,类型不同,存在隐患
//int *p_first2 = &a; [Warning] initialization from incompatible pointer type [enabled by default] 使用不兼容的指针类型初始化[默认启用]
// 2. &a 是指向整个数组的指针(不衰变),其类型是 int (*)[3] —— 指向整个数组的指针
int (*pa)[3] = &a;
// 双重性演示:
//p_first 的地址 = &a的地址值 = 数组a的首地址
printf("p_first 的地址 (数值): %p\n", p_first);
printf("&a 的地址 (数值): %p\n", (void*)&a);
printf("数组a的首地址 (数值): %p\n", (void*)&a[0]);
//p_first 是首地址值 &a的地址值也是首地址值 =)&a[0] 我们直接取数组的第一个元素再取地址
// - "首地址"面:解引用后访问元素(行为类似首元素指针)
printf("(*pa)[1] via deref: %d\n", (*pa)[1]); // 20
printf("address of (*pa)[1] = %p\n", (void *)&(*pa)[1]);
printf("p_first[1] = %d, address = %p\n", p_first[1], (void *)&p_first[1]);
// 说明:(*pa)[1] 和 p_first[1] 访问的是同一个元素 a[1]
// 原因:*pa 得到整个数组,但数组在表达式中衰变为指向首元素的指针(与 p_first 相同)
// - "整个数组"面:算术跳过整个数组
printf("pa + 1 = %p (首地址+12)\n", (void*)(pa + 1)); // pa + 1 = pa + 3*sizeof(int)
printf("p_first + 1 = %p (首地址+4)\n", (void*)(p_first + 1)); //p_first + 1 = p_first + sizeof(int) 对比:只跳过一个int
}
6.2.2、复合类型指针-字符串指针
在C语言中,字符串是以空字符 '\0' 结尾的字符数组。C语言本身没有内置的“字符串类型”,而是通过字符指针(char *)或字符数组(char[])来操作字符串。
6.2.2.1、字符串指针的声明与初始化
字符串指针是指向字符串首字符的指针变量,定义方式如下:
//1)字符串字面量
char *str1; // 声明未初始化的字符串指针
char *str2 = "hello world"; // 指向字符串字面量的指针
//2)字符数组
char arr[] = "hello"; // 字符数组,可修改
char *str3 = arr; // 指向字符数组的指针,arr在表达式中会隐式转换为char*(指向首字符)
//如果只涉及到对字符串的读取,那么字符数组和字符串常量都能够满足要求;
//如果有修改操作,那么只能使用字符数组,不能使用字符串常量。
C标准虽未规定字符串字面量是否可修改,但强烈建议视为只读。尝试修改会导致未定义行为:
- 示例:
#include <stdio.h>
int main(){
const char *str = "Hello"; // 推荐:字面量字符串,显式声明为 const
char arr[] = "Hello"; // 可修改:复制到栈上数组
char *str1 = "Hello";
str1[0] = 'h'; // UB!可能导致程序崩溃或静默失败
printf("%s\n",str); //直接不输出了...
arr[0] = 'h'; // 合法,修改数组元素
}
6.2.2.1、字符串指针的运算
#include <stdio.h>
#include <stddef.h> /* ptrdiff_t */
int main(void) {
char *str = "pointer arithmetic";
char *p = str;
while (*p != '\0') /* 移到字符串末尾 */
p++; // 指针向后移动一个字符位置
ptrdiff_t len = p - str; /* 字符个数(不含 '\0') */
printf("length = %td\n", len); /* 正确输出 ptrdiff_t */
return 0;
}
6.2.3、复合类型指针-结构体和联合体指针
结构体指针:是指向结构体类型对象的指针;联合体指针:是指向联合体类型对象的指针;这个后面再详细介绍。
- 结构体指针的声明与初始化
struct Point {
int x;
int y;
};
struct Point p = {10, 20};
struct Point *p_ptr = &p; // 结构体指针初始化
- 联合体指针的声明
union Data {
int i;
float f;
char str[20];
};
union Data data;
union Data *data_ptr = &data;
6.2.4、复合类型指针-枚举类型指针
6.2.4.1、枚举类型概述
枚举类型是C语言中的一种用户定义的整型类型,用于表示一组命名的整型常量。其基本语法如下:
//早期风格
enum enum-tag { enumerator-list } enum-variable-list;
//现代风格
typedef enum [enum-tag] { enumerator-list } enum-alias-variable;
-
enum-tag:枚举标签(可选) -
enumerator-list:枚举常量列表- 类型:每个枚举常量具有
int类型 - 默认值:第一个枚举常量默认为0,后续依次递增
- 显式赋值:可以显式指定枚举常量的值
- 类型:每个枚举常量具有
-
enum-variable-list:枚举变量列表(可选) -
typedef 可给枚举类型取别名
-
示例:
//早期风格
enum Color {
RED = 1, // 显式赋值为1
GREEN, // 自动为2
BLUE = 4, // 显式赋值为4
YELLOW // 自动为5
};
enum Color c; //c是枚举变量
// typedef 的典型写法
typedef enum color {RED, GREEN, BLUE} Color;
Color c = RED; // 可以省掉 enum 关键字
//现代风格
typedef enum {RED, GREEN, BLUE} Color;
6.2.4.2、枚举类型指针的声明与使用
- 基本语法:
// 定义枚举类型
enum Color { RED, GREEN, BLUE };
// 声明枚举指针
enum Color *pColor;
// 使用typedef定义枚举类型别名
typedef enum { RED, GREEN, BLUE } Color;
// 声明枚举指针
Color *pColor;
- 示例:
#include <stdio.h>
// 使用typedef定义枚举类型别名
typedef enum { RED, GREEN, BLUE } Color;
int main() {
// 声明枚举变量
Color myColor = RED;
// 声明枚举指针(使用typedef简化)
Color *pColor;
// 初始化指针
pColor = &myColor;
// 通过指针访问
printf("Original color: %d\n", *pColor); // 输出: Original color: 0
// 通过指针修改
*pColor = BLUE;
printf("Modified color: %d\n", *pColor); // 输出: Modified color: 2
// 指针与数组
Color colors[3] = { RED, GREEN, BLUE };
pColor = colors;
// 遍历数组
for (int i = 0; i < 3; i++) {
printf("Color %d: %d\n", i, pColor[i]); // 使用数组语法
}
// 输出:
// Color 0: 0
// Color 1: 1
// Color 2: 2
return 0;
}
6.2.5、多级指针
多级指针是指向指针的指针,而指针本身也是一种对象类型,因此多级指针属于对象指针的范畴。每增加一级间接寻址,就多一个 *。
- 一级指针 (
int *p) : 存储普通变量地址的指针。 - 二级指针 (
int **pp) : 存储一级指针地址的指针。 - 三级指针 (
int ***ppp) : 存储二级指针地址的指针。 - ...(可以一直套娃)
- 多级指针的声明与初始化
声明多级指针时,* 的数量表明了指针的级数:
int value = 100; // 一个整型变量
int *p = &value; // 一级指针,指向 value
int **pp = &p; // 二级指针,指向指针 p
int ***ppp = &pp; // 三级指针,指向指针 pp
- 示例:
#include <stdio.h>
// 使用typedef定义枚举类型别名
typedef enum { RED, GREEN, BLUE } Color;
int main() {
int value = 100;
int *p = &value;
int **pp = &p;
int ***ppp = &pp;
// 以下所有表达式的值都是 100
printf("%d\n", value); // 直接访问
printf("%d\n", *p); // 一次解引用:*p -> value
printf("%d\n", **pp); // 两次解引用:**pp -> *p -> value
printf("%d\n", ***ppp);// 三次解引用:***ppp -> **pp -> *p -> value
// 获取各级指针本身的地址
printf("Address of p: %p\n", (void*)&p); //指针p的地址,p本身的值是value的地址值
printf("Value stored in pp: %p (which is the address of p)\n", (void*)pp); //指针pp的地址
return 0;
//地址 内容 说明
//0xFF00.. 0xEF00.. pp 变量自身
//0xEF00.. 0xDE00.. p 变量自身
//0xDE00.. 100 value 变量
}
6.3、函数指针
函数指针也是一种指针变量,但它存储的不是数据的内存地址,而是一个函数代码在内存中的起始地址(函数的入口地址) 。通过函数指针,我们可以间接地调用它所指向的函数。
6.3.1、为什么需要函数指针
C99 /C11 §6.3.2.3/8:
A pointer to a function may be converted to a pointer to another function and back again;the result shall compare equal to the original pointer.
指向函数的指针可以转换为指向另一个函数的指针,然后再转换回来;结果应与原始指针相等。
也就是说:函数指针可以转换为其他函数指针类型,但转换后调用必须与原始类型兼容。
我们知道C语言的核心是过程式编程,但函数本身不是值类型(如整数或结构体,不能像他们一样放在变量中传递)。函数指针桥接了这一差距,使函数可以被动态处理。主要的应用场景包括:
- 实现回调函数
允许一个函数接受另一个函数作为参数,并在适当时候调用它。例如,在排序算法中传递比较函数(如qsort()标准库函数),或在事件驱动编程中注册处理函数。
- 函数表/跳转表
用数组或结构体封装多个函数指针,用于状态机、命令解析器等。
- 实现多态/策略模式
使用函数指针数组来存储不同行为函数,在运行时动态选择要执行的算法或行为。
- 动态库加载
使用函数指针来指向动态加载的函数。
6.3.2、函数指针的声明与初始化
6.3.2.1、函数指针的声明
函数声明符包括返回类型、函数名(对于指针,用*替换)和参数列表,基本语法如下:
//基本形式
return_type (*pointer_name)(parameter_list);
//typedef简化
typedef return_type (*func_ptr_type)(parameter_list);
func_ptr_type ptr_name;
- return_type:函数返回的类型(如int、void、double)。
- pointer_name:指针变量名。这里的括号是必需的,没有将被解释为返回return_type*的函数声明
- parameter_types:逗号分隔的参数类型列表。如果无参数,用(void)表示空列表。
- 示例:
// 指向返回int、无参数函数的指针,即func_ptr的类型为:int (*)(void)
int (*func_ptr)(void);
// 指向返回void、接受两个int参数函数的指针
void (*func_ptr2)(int, int);
// 指向返回double、接受char*和int函数的指针
double (*func_ptr3)(const char*, int);
// 指向返回类型为void,接受一个int参数的指针,并给它取了简短名字func_ptr4,见到它等价于void (*)(int)
typedef void (*func_ptr4)(int);
func_ptr4 fp4_1, fp4_2; // fp4_1、fp4_2 的类型同样是 “void (*)(int)”
6.3.2.1、函数指针的初始化
// 方法1:直接赋值
int add(int a, int b) { return a + b; }
int (*ptr)(int, int) = add; //隐式转换
// 方法2:取地址运算符(可选)
int (*ptr)(int, int) = &add; // 显示取地址,与上面等价
// 方法3:通过typedef
typedef int (*math_func)(int, int);
math_func ptr = add;
6.3.3、函数指针的调用
int add(int a, int b) { return a + b; }
int (*ptr)(int, int) = add;
// 方法1:显式解引用
int result = (*ptr)(3, 4); // 与上面等价
// 方法2:直接调用,标准规定函数指针调用时自动解引用(推荐)
int result = ptr(3, 4); // 等价于add(3,4)
6.3.4、核心应用场景
6.3.4.1、回调函数
#include <stdio.h>
// 回调函数类型,callback_t 是 void (*)(int)类型
typedef void (*callback_t)(int);
// 接受回调的函数
void process_data(int data, callback_t cb) {
printf("Processing %d...\n", data);
if (cb) cb(data); // 调用回调, cb(data) 等价 (*cb)(data)
}
// 具体回调实现
void print_square(int x) {
printf("Square: %d\n", x * x);
}
void print_cube(int x) {
printf("Cube: %d\n", x * x * x);
}
int main(void) {
process_data(5, print_square); // 传入函数名(自动转指针)
process_data(3, print_cube);
process_data(2, NULL); // 可选回调
return 0;
}
6.3.4.2、函数表/跳转表
- 跳转表 → 编译器替你做的底层“地址跳转数组”,通过索引直接“跳转”到对应处理逻辑(通常配合switch实现函数调用);
- 函数表(调度表) → 你自己写的函数指针数组,通过索引访问函数指针并调用对应函数;
#include <stdio.h>
void action_a(void) { puts("Action A"); }
void action_b(void) { puts("Action B"); }
void action_c(void) { puts("Action C"); }
// 函数指针数组
typedef void (*action_func_t)(void);
//函数表
action_func_t actions[] = {
action_a, //隐士转换为执行void(*)(void)类型,等价于&action_a
action_b,
action_c
};
int main(void) {
int choice = 1;
if (choice >= 0 && choice < 3) {
//actions[choice]() 同一调用语法可以绑定到不同的实现,这更偏向于一种行为上的多态的,当结合结构体时,多态可以扩展到 "数据 + 行为" 的整体封装
actions[choice](); // 调用对应函数
}
return 0;
}
6.3.4.3、库函数
- 编写dll源代码
__declspec(dllexport) int add(int a, int b) {
return a + b;
}
__declspec(dllexport) int multiply(int a, int b) {
return a * b;
}
运行:gcc -shared -o mathlib.dll mathlib.c ,生成mathlib.dll
- 编写调用程序
#include <windows.h>
#include <stdio.h>
typedef int (*MathFunc)(int, int);
int main() {
HMODULE hDll;
MathFunc addFunc, multiplyFunc;
int result;
/* 1. 运行时把 DLL 装进进程 */
hDll = LoadLibraryA("mathlib.dll"); // 使用ANSI版本
if (!hDll) {
printf("加载DLL失败: %lu\n", GetLastError());
return 1;
}
/* 2. 取函数地址 */
addFunc = (MathFunc)GetProcAddress(hDll, "add");
multiplyFunc = (MathFunc)GetProcAddress(hDll, "multiply");
if (!addFunc || !multiplyFunc) {
printf("获取函数地址失败: %lu\n", GetLastError());
FreeLibrary(hDll);
return 1;
}
/* 3. 调用 */
result = addFunc(5, 3);
printf("5 + 3 = %d\n", result);
result = multiplyFunc(4, 6);
printf("4 * 6 = %d\n", result);
// 4. 卸载
FreeLibrary(hDll);
return 0;
}
6.4、void类型指针
在C语言中,指针是一种强大而灵活的工具,它允许我们直接操作内存地址。而void类型指针(void*)则是一种特殊的指针类型(通常称为 "通用指针"),它提供了无类型指向的能力。
void:表示“无类型”。不能用于声明变量(如void a;是错误的),通常用于指示函数无返回值;void*:表示“指向无类型数据的指针”。它是一个纯粹的内存地址,丢失了关于所指向数据的类型和长度信息,但正因如此,它可以容纳任何类型的数据地址。
6.4.1、void类型指针的声明与初始化
void *ptr; // 声明一个void类型指针
int a = 10;
ptr = &a; // 可以指向int类型变量
char c = 'A';
ptr = &c; // 也可以指向char类型变量
6.4.2、void 指针的核心特性
void 指针只知道它指向某个内存位置,但不知道该位置存储的数据类型是什么。这种 "无类型" 特性既是它的优势,也带来了特殊的使用规则。
6.4.2.1、可以指向任何数据类型的对象
void*可以指向任何类型的数据,这是它最大的特点。
int num = 42;
float pi = 3.14f;
char c = 'A';
struct MyStruct s;
void *vptr;
vptr = # // 合法:指向int
vptr = π // 合法:指向float
vptr = &c; // 合法:指向char
vptr = &s; // 合法:指向结构体
6.4.2.2、不能直接解引用
C 标准明确禁止直接解引用 void 指针。因为编译器不知道它指向的数据类型,无法确定应该读取多少字节的数据:
#include <stdio.h>
int main(){
void *vptr;
int x = 10;
vptr = &x;
// *vptr = 20; // 错误:不能直接解引用void指针
// int y = *vptr; // 错误:同样不允许
}
要访问 void 指针指向的数据,必须先进行显式类型转换:
#include <stdio.h>
int main(){
void *vptr;
int x = 10;
vptr = &x;
int *iptr = (int *)vptr; // 显式转换为int指针
*iptr = 20; // 现在可以解引用了
printf("%d\n",*iptr); //20
}
6.4.2.3、不能进行指针算术运算
与其他指针类型不同,void 指针不能直接进行算术运算(如增减操作):
#include <stdio.h>
int main(){
void *vptr;
int x = 10;
vptr = &x;
//vptr++; //错误:不能对void指针进行算术运算
int *iptr = (int *)vptr; // 显式转换为int指针
*iptr = 20; // 现在可以解引用了
printf("%d\n",*iptr); //20
}
6.4.3、void 指针的使用场景
6.4.3.1、内存分配函数
标准库中的内存分配函数(如malloc、calloc、realloc)返回void*,因为它们分配的是未指定类型的内存。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void){
/* 1. 只圈地,不决定用途 */
void *raw = malloc(16); // 16 字节
/* 2. 决定当 int[4] 用 */
int *i = (int *)raw; // 准备存放int类型数据
for(int k = 0; k < 4; ++k) i[k] = k * 10;
/* 3. 同一块地,改当 double[2] 用 */
double *d = (double *)raw; // 擦掉,准备存放 double 类型的数据
d[0] = 3.14;
d[1] = 2.71;
/* 4. 再当字节数组看内部布局 */
unsigned char *byte = (unsigned char *)raw;
for(size_t j = 0; j < 16; ++j)
printf("%02hhx ", byte[j]);
free(raw);
}
6.4.3.2、泛型编程
在C语言中,由于没有模板,void*常用于实现泛型函数。例如,qsort函数:
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int id;
float score;
} Student;
/* 按 score 升序 */
int cmp_score(const void *a, const void *b){
float fa = ((const Student*)a)->score;
float fb = ((const Student*)b)->score;
return (fa > fb) - (fa < fb); // 安全返回 -1/0/1
}
int main(void){
Student s[] = { {1, 90}, {2, 75}, {3, 95} };
qsort(s, 3, sizeof(Student), cmp_score);
for(int i = 0; i < 3; ++i)
printf("%d: %.1f\n", s[i].id, s[i].score);
}
- 解释:
void qsort(void *base,
size_t nmemb,
size_t size,
int (*compar)(const void *, const void *));
//第 1 个参数只要求给出待排数组的首地址,类型被抹成 void *;
//第 3 个参数 size 告诉它每个元素占多少字节;
//第 4 个参数是一个“回调”比较函数,同样只接受 const void * 类型的形参。
int cmp_score(const void *a, const void *b){
//cmp_score 里之所以要把 void * 先转成 const Student *,就是为了配合 qsort 的泛型接口,如果没有这一步强制类型转换,qsort 就无从知道数组里到底存的是什么
float fa = ((const Student*)a)->score;
float fb = ((const Student*)b)->score;
return (fa > fb) - (fa < fb); // 安全返回 -1/0/1
}
6.5、修饰限定符与指针
C 标准定义了4个类型限定符,用于修饰变量的属性:
const:表示对象的值不能被修改(只读)volatile:表示对象的值可能被程序之外的因素修改(防止编译器优化,每次访问都必须从内存读取)restrict:(C99 新增):表示指针是访问其指向对象的唯一方式(用于编译器优化)- _Atomic:(C11 新增,需包含 <stdatomic.h>):为多线程编程提供了标准化的原子操作支持
这些限定符可以单独使用,也可以与指针结合使用,产生各种不同的语义。(注:这里只介绍前面3种)
6.5.1、const限定符与指针
6.5.1.1、限定指针指向内容
使用方式:const int *p; (p是指向const int的指针,与int const *p;等价)
- 指针
p本身可以修改(可以指向其他对象) - 指针所指向的对象(
*p)不能通过p修改 - 示例:
#include <stdio.h>
int main() {
int x = 10, y = 20;
const int *p = &x; //const 修饰 *p(以*为界),限制了对指针解引用值的修改
//*p = 15; // 编译错误:不能通过p修改指向的对象:[Error] assignment of read-only location '* p'
p = &y; // 合法:可以改变p的指向
printf("*p: %d\n", *p); // 输出:*p: 20
return 0;
}
6.5.1.2、限定指针本身
-
使用方式:int * const p(p是const 的指针,指向 int)
- 指针
p本身不能修改(初始化后不能改变指向) - 指针所指向的对象(
*p)可以修改
- 指针
-
示例:
#include <stdio.h>
int main() {
int x = 10, y = 20;
int * const p = &x; // 必须初始化
*p = 15; // 合法:可以修改指向的对象
//p = &y; // 编译错误:不能改变p的指向
printf("*p: %d\n", *p); // 输出:*p: 15
return 0;
}
6.5.1.3、限定内容和指针
使用方式:const int *const p (p是const指针,指向const int)
- 指针
p本身不能修改 - 指针所指向的对象(
*p)也不能通过p修改 - 示例:
#include <stdio.h>
int main() {
int x = 10, y = 20;
const int * const p = &x;
//*p = 15; // 编译错误:不能修改指向的对象
//p = &y; // 编译错误:不能改变p的指向
return 0;
}
6.5.2、volatile限定符与指针
volatile关键字告诉编译器,对象的值可能会以编译器不可预知的方式改变(如硬件、其他线程),因此编译器不应优化对该变量的访问。典型应用场景:
- 内存映射硬件寄存器
- 多线程共享变量
- 信号处理程序修改的变量
6.5.2.1、 volatile指针的基本用法
volatile int *ptr; // 或 int volatile *ptr;
- 指针指向一个
volatile int对象 - 每次访问该对象都必须从内存读取,不能使用缓存值
- 指针本身的值可以修改
注:volatile只保证内存可见性,不保证原子性或操作顺序,因此不能保证线程安全;volatile只禁止特定类型的优化。
6.5.3、restrict限定符与指针
restrict是C99引入的限定符,只能用于指针类型。它告诉编译器,该指针是访问其所指向对象的唯一方式,以便进行优化(指令重排、预取、向量化)。
6.5.3.1、 restrict的基本用法
void *memcpy(void * restrict s1, const void * restrict s2, size_t n);
void *memmove(void *s1, const void *s2, size_t n);
memcpy源与目标区域 不得重叠,否则行为未定义(效率更高)。memmove允许重叠,保证“先拷完再覆盖”(保守复制),结果可预期。
更多内容:请关注公众号:java穆瑾轩