C语言指针详解(下):空指针、野指针、悬空指针与嵌入式实战

C语言指针详解(下)— 安全与嵌入式实战

ℹ️ 导读:本篇讲解指针的安全使用和嵌入式实战。指针基础概念与语法请看《C语言指针详解(上)》。

一、空指针(NULL Pointer)

1.1 定义

空指针是一个不指向任何有效内存地址的指针,其值为 NULL(即 0)。它是 C 语言中表示"指针当前没有指向任何东西"的标准方式。

#include <stdio.h>
#include <stddef.h>  // NULL 定义在这里(也在 stdio.h、stdlib.h 中)

int main() {
    int *p = NULL;  // 声明一个空指针

    printf("p 的值: %p\n", p);  // 0x0 或 (nil)

    return 0;
}
ℹ️ NULL 的本质

在 C 标准库中,NULL 通常定义为 ((void *)0)0

本质上就是地址 0,这个地址在绝大多数系统中是不可访问的保留区域

1.2 为什么需要空指针?

场景 说明
初始化指针 声明指针时赋 NULL,避免成为野指针
表示"无效"或"空" 函数返回 NULL 表示失败(如 malloc 分配失败)
安全检查 调用前判断是否为 NULL,防止非法访问
释放后置空 free(p); p = NULL; 防止悬空指针被误用

1.3 空指针的使用规范

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

int main() {
    // ✅ 规范一:声明时初始化为 NULL
    int *p = NULL;

    // ✅ 规范二:使用前检查是否为 NULL
    if (p != NULL) {
        printf("值 = %d\n", *p);
    } else {
        printf("指针为空,不能解引用!\n");
    }

    // ✅ 规范三:动态分配后检查
    p = (int *)malloc(sizeof(int));
    if (p == NULL) {
        printf("内存分配失败!\n");
        return 1;
    }
    *p = 42;
    printf("值 = %d\n", *p);  // 42

    // ✅ 规范四:释放后立即置空
    free(p);
    p = NULL;   // 防止悬空指针

    return 0;
}

1.4 解引用空指针的后果

int *p = NULL;
*p = 100;   // ❌ 未定义行为!
平台 后果
桌面系统(Linux/Windows) 触发段错误(Segmentation Fault),程序被操作系统终止
嵌入式系统(STM32 等) 触发HardFault 异常,系统死机或复位
裸机无 MMU 可能静默写入地址 0 的内存,导致不可预测的行为
⚠️ 嵌入式特别注意:在 ARM Cortex-M 中,地址 0x00000000 通常映射到 Flash 的起始位置(存放中断向量表)。对空指针解引用可能破坏向量表,导致系统完全崩溃!

1.5 空指针在函数指针中的检查

typedef void (*Callback_t)(void);

Callback_t cb = NULL;

// ❌ 直接调用空函数指针 → HardFault
cb();

// ✅ 安全调用
if (cb != NULL) {
    cb();
}

// ✅ 简写形式(推荐)
if (cb) {
    cb();
}

二、野指针(Dangling / Wild Pointer)

2.1 定义

野指针是指指向不确定地址的指针。它可能指向已释放的内存、未初始化的随机地址,或已超出作用域的局部变量。

野指针 vs 空指针:

对比 空指针(NULL) 野指针(Wild/Dangling)
指向 确定的无效地址(0 不确定的随机地址
可检测 if (p == NULL) ❌ 无法检测
危害 解引用会立即崩溃(可预测) 可能"正常运行"但数据损坏(不可预测)
本质 程序员主动标记"无效" 程序员疏忽导致的错误

2.2 野指针的三种成因

① 未初始化的指针

int *p;        // 局部变量未初始化,p 的值是栈上的随机数据
*p = 100;      // ❌ 写入未知地址,未定义行为!

// ✅ 正确做法:声明时初始化
int *p = NULL;
💡 全局 vs 局部
  • 全局/静态指针未初始化时自动为 NULL(零初始化)
  • 局部指针未初始化时值是栈上的垃圾数据,是最常见的野指针来源

② 释放后未置空(悬空指针 / Dangling Pointer)

int *p = (int *)malloc(sizeof(int));
*p = 42;
free(p);       // 内存已归还给系统

// ⚠️ p 的值没变,仍指向原地址,但那块内存已不属于你了!
printf("%d\n", *p);   // ❌ 未定义行为,可能输出 42,也可能崩溃
*p = 100;              // ❌ 写入已释放的内存

// ✅ 正确做法:free 后立即置空
free(p);
p = NULL;
⚠️ 悬空指针比空指针更危险:因为它"看起来正常"——地址值不是 0,无法通过 if (p != NULL) 检测,可能在某些时候"恰好能读到正确值",但随时可能出错。这种 Bug 极难定位。

③ 指向已超出作用域的局部变量

int *getPointer() {
    int local = 42;
    return &local;     // ❌ 返回局部变量的地址!
}

int main() {
    int *p = getPointer();
    // local 已经销毁,p 指向的栈内存已被回收
    printf("%d\n", *p);   // ❌ 未定义行为
    return 0;
}
⚠️ 编译器通常会警告warning: function returns address of local variable

看到这个警告一定要修复!正确做法是使用 static、动态分配、或通过参数传出。

三种修复方法:

// ✅ 修复方法一:使用 static(延长生命周期)
int *getPointer() {
    static int local = 42;   // static 变量在程序结束前不会销毁
    return &local;
}

// ✅ 修复方法二:动态分配
int *getPointer() {
    int *p = (int *)malloc(sizeof(int));
    *p = 42;
    return p;   // 调用者负责 free
}

// ✅ 修复方法三:通过参数传出
void getValue(int *out) {
    *out = 42;
}

2.3 防御野指针的最佳实践

防御野指针四原则
│
├── 1. 声明即初始化 ——— int *p = NULL;
│
├── 2. 释放即置空   ——— free(p); p = NULL;
│
├── 3. 使用前检查   ——— if (p != NULL) { *p = ... }
│
└── 4. 不返回局部变量地址 ——— 用 static / malloc / 参数传出

2.4 嵌入式场景中的野指针陷阱

// 陷阱一:中断中使用未初始化的指针
volatile uint8_t *g_rxPtr;   // 全局指针,好在全局默认 NULL

void USART1_IRQHandler(void) {
    if (g_rxPtr != NULL) {       // ✅ 必须检查
        *g_rxPtr++ = USART1->DR;
    }
}

// 陷阱二:DMA 传输完成后缓冲区已被覆盖
uint8_t buf[64];
uint8_t *snapshot = buf;      // snapshot 指向 buf
HAL_UART_Receive_DMA(&huart1, buf, 64);
// ... DMA 可能随时覆盖 buf 中的内容
// snapshot 指向的数据可能已经变了 → 逻辑上的"野指针"

// 陷阱三:回调函数指针在模块反初始化后未清除
void Module_DeInit(void) {
    // ... 清理硬件 ...
    g_callback = NULL;   // ✅ 别忘了清除回调指针
}

三、嵌入式开发中指针的常见用法

在嵌入式开发中,指针不只是"指向变量",更是直接操控硬件的核心工具。

3.1 访问寄存器(最常见)

单片机的外设(GPIO、UART、定时器……)都是通过固定内存地址的寄存器来控制的。指针就是访问这些地址的手段。

// STM32 中 GPIOA 的输出数据寄存器地址为 0x4001080C
// 直接通过指针操作这个地址

// 方法一:强制类型转换为指针
volatile uint32_t *GPIOA_ODR = (volatile uint32_t *)0x4001080C;

*GPIOA_ODR |= (1 << 5);    // 将 PA5 设为高电平(点亮 LED)
*GPIOA_ODR &= ~(1 << 5);   // 将 PA5 设为低电平(熄灭 LED)

// 方法二:用宏定义(STM32 标准库就是这么干的)
#define GPIOA_ODR   (*(volatile uint32_t *)0x4001080C)

GPIOA_ODR |= (1 << 5);     // 用起来像普通变量一样
ℹ️ volatile 关键字:嵌入式中操作硬件寄存器必须加 volatile!它告诉编译器:"这个地址的值可能随时被硬件改变,不要优化掉对它的读写。"
// ❌ 没有 volatile,编译器可能优化掉循环中的重复读取
uint32_t *reg = (uint32_t *)0x40010000;
while (*reg & 0x01) { }   // 编译器可能只读一次就不再读了

// ✅ 加了 volatile,每次循环都会真正去读硬件
volatile uint32_t *reg = (volatile uint32_t *)0x40010000;
while (*reg & 0x01) { }   // 每次都从地址重新读取

3.2 结构体指针映射寄存器组

一个外设有很多寄存器,地址是连续的。用结构体 + 指针一次性映射整组寄存器:

// 定义 GPIO 寄存器组结构体(和硬件手册对应)
typedef struct {
    volatile uint32_t CRL;    // 偏移 0x00 - 配置低寄存器
    volatile uint32_t CRH;    // 偏移 0x04 - 配置高寄存器
    volatile uint32_t IDR;    // 偏移 0x08 - 输入数据寄存器
    volatile uint32_t ODR;    // 偏移 0x0C - 输出数据寄存器
    volatile uint32_t BSRR;   // 偏移 0x10 - 置位/复位寄存器
    volatile uint32_t BRR;    // 偏移 0x14 - 复位寄存器
    volatile uint32_t LCKR;   // 偏移 0x18 - 锁定寄存器
} GPIO_TypeDef;

// 将结构体指针指向 GPIOA 的基地址
#define GPIOA  ((GPIO_TypeDef *)0x40010800)
#define GPIOB  ((GPIO_TypeDef *)0x40010C00)

// 使用:像访问结构体成员一样操作寄存器
GPIOA->ODR |= (1 << 5);      // PA5 输出高
GPIOA->ODR &= ~(1 << 5);     // PA5 输出低
uint32_t input = GPIOB->IDR;  // 读取 GPIOB 所有引脚状态

3.3 函数指针(回调函数)

嵌入式中经常用函数指针实现中断回调、事件处理、状态机

#include <stdio.h>

// 定义函数指针类型
typedef void (*EventHandler)(void);

// 各种事件处理函数
void onButtonPress(void)  { printf("按钮被按下!\n"); }
void onTimeout(void)      { printf("定时器超时!\n"); }
void onDataReceived(void) { printf("收到串口数据!\n"); }

// 回调表(用函数指针数组管理)
EventHandler handlers[3] = {
    onButtonPress,
    onTimeout,
    onDataReceived
};

// 模拟中断触发
void triggerEvent(int event_id) {
    if (event_id >= 0 && event_id < 3) {
        handlers[event_id]();  // 通过函数指针调用
    }
}

int main() {
    triggerEvent(0);  // 按钮被按下!
    triggerEvent(1);  // 定时器超时!
    triggerEvent(2);  // 收到串口数据!
    return 0;
}

3.4 指针操作缓冲区(通信协议解析)

嵌入式通信(UART、SPI、I2C)中收到的数据是一串字节流,用指针逐字节/逐字段解析:

#include <stdio.h>
#include <stdint.h>

// 假设收到一个数据包:[帧头 0xAA] [命令 1字节] [数据长度 1字节] [数据...] [校验]
uint8_t rx_buf[] = {0xAA, 0x01, 0x03, 0x10, 0x20, 0x30, 0x8E};

void parsePacket(uint8_t *buf, int len) {
    uint8_t *p = buf;  // 指针指向缓冲区头部

    if (*p != 0xAA) { printf("帧头错误!\n"); return; }
    p++;

    uint8_t cmd = *p++;         // 读命令,指针后移
    uint8_t data_len = *p++;    // 读数据长度,指针后移

    printf("命令: 0x%02X, 数据长度: %d\n", cmd, data_len);
    printf("数据: ");
    for (int i = 0; i < data_len; i++) {
        printf("0x%02X ", *p++);  // 逐字节读数据
    }
    printf("\n校验: 0x%02X\n", *p);
}

int main() {
    parsePacket(rx_buf, sizeof(rx_buf));
    return 0;
}

3.5 内存映射 I/O 总结

┌─────────────────────────────────────────────┐
│              嵌入式中的内存地图               │
├─────────────┬───────────────────────────────┤
│ 0x0000 0000 │ Flash(存放代码)              │
│ 0x2000 0000 │ SRAM(存放变量、栈、堆)       │
│ 0x4000 0000 │ 外设寄存器区                   │  ← 指针直接操作这里!
│ 0xE000 0000 │ Cortex-M 内核寄存器            │
└─────────────┴───────────────────────────────┘

指针的角色:
  int *p = (int *)0x20000000;   // 指向 SRAM 中的某个变量
  volatile uint32_t *reg = ...;  // 指向外设寄存器
  void (*isr)(void) = ...;      // 指向中断服务函数

四、总结

指针安全

问题 危害 防御
空指针 解引用崩溃(可预测) if (p != NULL) 检查
野指针 数据损坏(不可预测 声明即初始化、释放即置空
悬空指针 访问已释放内存 free(p); p = NULL;
返回局部地址 栈回收后地址失效 static / malloc / 参数传出

安全使用口诀

声明即初始化 ——→  int *p = NULL;
使用前要检查 ——→  if (p != NULL) { ... }
释放后要置空 ——→  free(p); p = NULL;
不返回局部址 ——→  用 static / malloc / 参数传出
函数指针判空 ——→  if (cb) cb();
操作寄存器加 ——→  volatile
📋 速记
  • 空指针可检测(== NULL),野指针不可检测 → 预防为主
  • 函数指针调用前必须判空
  • 操作寄存器别忘 volatile
  • 中断回调在反初始化时必须清 NULL

五、复习题

选择题

1. 以下哪个操作能让指针变成"野指针"?

A. int *p = NULL;
B. free(p); p = NULL;
C. free(p);(没有置空)
D. int *p = &a;

点击查看答案

**C**。free(p) 释放了内存但没有将 p 置为 NULL,此时 p 仍保留原地址值,成为悬空指针(野指针的一种)。A 是空指针,B 是正确的释放流程,D 是正常指针。

2. 在 STM32 中,操作硬件寄存器时必须加哪个关键字?

A. const
B. static
C. volatile
D. extern

点击查看答案

**C**。volatile 告诉编译器该地址的值可能被硬件随时修改,禁止编译器对读写操作进行优化。不加 volatile 可能导致编译器只读一次寄存器就缓存结果,读不到硬件的最新状态。

3. 以下代码的问题是什么?

int *getPointer() {
    int local = 42;
    return &local;
}

A. 语法错误,无法编译
B. 返回了局部变量的地址,函数返回后该地址失效
C. 缺少 malloc,内存不足
D. 没有问题,可以正常使用

点击查看答案

**B**。local 是局部变量,存储在栈上,函数返回后栈帧被回收,返回的指针指向已失效的内存。正确做法是使用 staticmalloc 或通过参数传出。

4. 以下哪种方式不能正确检测空指针?

A. if (p == NULL)
B. if (!p)
C. if (p == 0)
D. if (p == -1)

点击查看答案

**D**。空指针的值是 0(即 NULL),而 -1 不是空指针的标准表示。A、B、C 三种写法都能正确检测空指针。

判断题

5. 全局指针变量如果没有显式初始化,其默认值是 NULL。( )

点击查看答案

**✓ 正确**。全局变量和静态变量遵循零初始化规则,未显式初始化的全局指针默认为 NULL(即 0)。但局部指针不会自动初始化,值是栈上的垃圾数据。

6. 解引用空指针一定会导致程序崩溃。( )

点击查看答案

**✗ 错误**。在有 MMU 的桌面系统(Linux/Windows)中通常会触发段错误而崩溃,但在某些裸机嵌入式系统(无 MMU)中,可能静默写入地址 0 的内存,导致不可预测的行为而不是立即崩溃。

编程题

7. 以下代码有几处指针安全问题?请逐一指出并修复。

#include <stdlib.h>

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

    int *q = (int *)malloc(sizeof(int));
    *q = 20;
    free(q);
    printf("%d\n", *q);

    return 0;
}
点击查看答案

共 **3 处**问题:

| 行 | 问题 | 修复 |
| :--- | :--- | :--- |
| int *p;*p = 10; | 未初始化的野指针,解引用导致未定义行为 | int *p = NULL; 然后分配内存后再使用 |
| free(q);*q | 悬空指针,访问已释放的内存 | free(q); q = NULL; |
| 缺少 #include <stdio.h> | 使用了 printf 但未包含头文件 | 添加 #include <stdio.h> |

修复后:

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

int main() {
int *p = NULL; // ✅ 声明即初始化
p = (int *)malloc(sizeof(int));
if (p == NULL) return 1;
*p = 10;

int *q = (int *)malloc(sizeof(int));
if (q == NULL) { free(p); return 1; }
*q = 20;
printf("%d\n", *q); // ✅ 在 free 之前使用
free(q);
q = NULL; // ✅ 释放即置空

free(p);
p = NULL;
return 0;
}
```

8. 补全以下嵌入式代码,使用结构体指针安全地读取 GPIOB 引脚 3 的电平状态:

typedef struct {
    volatile uint32_t CRL;
    volatile uint32_t CRH;
    volatile uint32_t IDR;
    volatile uint32_t ODR;
} GPIO_TypeDef;

#define GPIOB  ((__________)0x40010C00)

uint8_t read_PB3(void) {
    // 补全:读取 PB3 的电平状态,返回 0 或 1
}
点击查看答案

```c
#define GPIOB ((GPIO_TypeDef *)0x40010C00)

uint8_t read_PB3(void) {
if (GPIOB->IDR & (1 << 3)) {
return 1; // PB3 为高电平
}
return 0; // PB3 为低电平
}
```

关键点:
- 宏定义将地址强转为 GPIO_TypeDef * 结构体指针
- 通过 ->IDR 访问输入数据寄存器
- 用位与 & (1 << 3) 提取第 3 位的状态


延伸阅读

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