第 7 讲 · 指针、内存与动态空间

一句话区分变量和指针:普通变量保存某种类型的值,指针变量保存某种类型对象的地址。 指针并不神秘,但它把“访问哪块内存”的责任交给了程序员。

1. 变量的五个属性

int count = 18;

可以从五个角度看它:

  • 名字:count;
  • 类型:int;
  • 大小:sizeof count;
  • 值:18;
  • 地址:&count。

变量名让我们直接访问对象,地址让我们间接访问对象。

变量 a 在按字节编址的连续内存单元中占据多个地址

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 前问:

  1. p 已初始化吗?
  2. p 当前不是 NULL 吗?
  3. 目标对象还活着吗?
  4. 地址满足类型的对齐要求吗?
  5. 读写范围仍在对象内部吗?
  6. 当前操作允许修改目标吗?

指针错误看似千变万化,本质上都在违反其中一条。只要把“地址从哪里来、指向谁、能用多久、能访问多大”说清,指针就会从玄学变成可验证的内存关系。

评论