第 7 讲 · 指针、内存与动态空间
一句话区分变量和指针:普通变量保存某种类型的值,指针变量保存某种类型对象的地址。 指针并不神秘,但它把“访问哪块内存”的责任交给了程序员。
1. 变量的五个属性
int count = 18;
可以从五个角度看它:
- 名字:
count; - 类型:
int; - 大小:
sizeof count; - 值:18;
- 地址:
&count。
变量名让我们直接访问对象,地址让我们间接访问对象。

int *count_ptr = &count;
printf("%p\n", (void *)count_ptr);
printf("%d\n", *count_ptr);
& 取地址,* 对指针解引用。*count_ptr 与 count 指向同一个 int 对象:
count_ptr ───────→ count
[一个地址] [18]
地址输出使用 %p,参数按约定转换成 void *。不要把地址硬塞给 %d 或假设它一定能装进普通整数。
2. 指针也有类型
char *pc;
int *pi;
double *pd;
类型决定:
- 解引用时把目标字节解释成什么;
- 读写多少字节;
- 指针加一跨过多少字节;
- 哪些赋值和比较是类型兼容的。
同一个内存字节模式,用 signed char 与 unsigned char 解释可能分别得到 -1 和 255。指针类型不是编译器的“多余限制”,而是正确解释内存的规则。
3. 定义、初始化与空指针
int value = 42;
int *p = &value;
int *none = NULL;
未初始化指针含有不确定值,称为野指针:
int *bad;
*bad = 1; /* 未定义行为 */
NULL 表示空指针常量,意为“当前不指向对象”。可以比较,但不能解引用:
if (p != NULL) {
printf("%d\n", *p);
}
把整数地址随意强制转换成指针,或把不兼容指针互相赋值,可能造成对齐、有效范围和类型别名问题。不要靠强转掩盖编译器警告。
4. 取地址与解引用
int a = 5;
int *p = &a;
*p = 8; /* a 也变成 8 */
printf("%d\n", a);
对合法对象,*&a 等价于 a,&*p 等价于 p。但这个等价关系的前提是 p 本身是可用的合法指针。
指针变量也有自己的地址:
int **pp = &p;
pp 指向 p,*pp 是 p,**pp 是 a。多重指针将在下一讲系统展开。
5. const 放在哪里
从变量名向外读声明:
const int *p1 = &a; /* 指向只读 int 的指针 */
int *const p2 = &a; /* 值固定的指针 */
const int *const p3 = &a; /* 两者都不能通过该名字修改 */
p1可以改为指向别处,但不能通过*p1修改目标;p2不能改地址,但可以修改*p2;p3两者都不能改。
const 限制的是通过当前表达式进行的操作,不保证底层对象没有其他可写别名。
把 const int * 赋给 int * 会丢掉只读约束,编译器应警告。不要用强制转换绕过它。
6. 字符串、字符数组与字符指针
char array[] = "hello";
const char *literal = "hello";
array 拥有一份可修改的字符数组:
array[0] = 'H';
literal 指向字符串常量,字符串常量通常位于只读静态区域,不能修改:
/* literal[0] = 'H'; 未定义行为 */
课件强调:字符串常量表达式的值是首字符地址。用 const char * 接收,才能把只读事实写进类型。
7. 指针作为函数参数
C 只有值传递。把地址值作为参数传入,就能让函数找到调用者对象。
7.1 输出多个结果
int divide_with_remainder(int a, int b, int *quotient, int *remainder)
{
if (b == 0 || quotient == NULL || remainder == NULL) {
return 0;
}
*quotient = a / b;
*remainder = a % b;
return 1;
}
返回值报告是否成功,两个输出参数承载结果。
7.2 只读与可写接口
double average(const double *values, int n);
void fill(double *values, int n, double value);
第一个函数承诺不通过 values 修改数组,第二个明确会写入。
7.3 数组参数就是指针参数
函数参数中的下列声明等价:
void fill(int a[], int n);
void fill(int *a, int n);
a 只是指向第一个元素,原数组长度不会自动传入。因此 n 是接口不可缺少的一部分。
8. 指针作为返回值
标准函数 strchr 返回字符串中某字符第一次出现的位置,找不到返回 NULL:
char *found = strchr(text, 'x');
if (found != NULL) {
printf("offset=%td\n", found - text);
}
返回指针时必须保证目标在函数返回后仍存在。下面是错误的:
int *bad(void)
{
int local = 42;
return &local; /* local 的生命周期随函数结束 */
}
可以安全返回:
- 调用者传入对象内部的位置;
- 静态存储期对象地址;
- 动态申请且把释放责任交给调用者的空间。
设计接口时要说清谁拥有这块内存、谁负责释放。
9. 指针运算
假设 p 指向数组中的某个元素:
int a[5] = {10, 20, 30, 40, 50};
int *p = &a[1];
9.1 加减整数
*(p + 2) == 40;
p[-1] == 10;
p + 1 移到下一个 int,实际地址增加 sizeof(int) 字节。负下标语法合法,但只有结果仍落在同一数组中才可访问;a[-1] 仍然越界。
9.2 *p++ 到底是什么
后置 ++ 的结合使:
*p++
等价于:
*(p++)
先取得原指针所指的值,再让指针移动。若要给目标值加一,应写:
(*p)++;
不确定时加括号,不要让表达式的意图依赖默认优先级。
9.3 指针相减
两个指向同一数组元素或末尾后一位置的指针可以相减,结果是相隔的元素数,类型为 ptrdiff_t:
ptrdiff_t distance = &a[4] - &a[1]; /* 3 */
不同数组之间相减没有定义。
9.4 比较
相等比较可判断是否指向同一地址。大小比较只有在同一数组范围内才有规定的先后意义,常用于遍历:
for (int *it = a; it < a + 5; ++it) {
printf("%d\n", *it);
}
指针不能相加、相乘,不能与浮点数做算术,也不应把地址当普通整数随意移位。
10. 指针强制转换与字节序
字符类型指针可以观察任何对象的字节表示:
unsigned int x = 0x12345678u;
unsigned char *bytes = (unsigned char *)&x;
for (size_t i = 0; i < sizeof x; ++i) {
printf("%02X ", bytes[i]);
}
在小端机器上通常先看到 78,大端机器先看到 12。
但把任意 char * 强转为 int * 后解引用,可能违反对齐和对象有效类型规则。类型转换只改变编译器怎样解释地址,不会凭空创建合法的目标对象。
11. void * 与 memcpy
void * 是通用对象指针,可以保存不同对象地址,但不能直接解引用,也不能按标准 C 做算术,因为不知道目标大小。
void *raw = &value;
int *typed = raw;
memcpy 复制原始字节:
#include <string.h>
int src[4] = {1, 2, 3, 4};
int dst[4];
memcpy(dst, src, sizeof src);
它不理解元素语义,只按给定字节数复制。源和目标重叠时应使用 memmove;若结构中含有指针,字节复制只会复制地址,不能自动复制指针所指的数据。
12. 静态区、栈与堆
程序常见内存区域可这样理解:
代码与只读区域:机器指令、通常还有字符串常量
静态存储区:全局变量、static 对象
调用栈:函数参数、返回信息、自动局部变量
堆:程序运行时显式申请和释放的动态空间
这是帮助建立生命周期直觉的常见布局,具体可执行文件和操作系统如何分段由平台决定,不是 C 语言强制的固定地址图。
动态申请:
#include <stdlib.h>
size_t n = 1000;
double *data = malloc(n * sizeof *data);
if (data == NULL) {
return 1;
}
/* 使用 data[0] 到 data[n - 1] */
free(data);
data = NULL;
malloc 返回的字节内容未初始化。需要全 0 可用 calloc。调整大小可用 realloc,但应先用临时指针接收,避免失败时丢失原地址。
12.1 动态内存的四类错误
- 内存泄漏:失去地址前没有
free; - 悬空指针:
free后继续访问; - 重复释放:同一块空间
free两次; - 越界:申请
n个元素,却访问第n个。
free(NULL) 是安全的,但它不会自动把原变量设为 NULL。
13. 一份指针安全清单
每次写 *p 前问:
p已初始化吗?p当前不是NULL吗?- 目标对象还活着吗?
- 地址满足类型的对齐要求吗?
- 读写范围仍在对象内部吗?
- 当前操作允许修改目标吗?
指针错误看似千变万化,本质上都在违反其中一条。只要把“地址从哪里来、指向谁、能用多久、能访问多大”说清,指针就会从玄学变成可验证的内存关系。