C语言指针详解(下):空指针、野指针、悬空指针与嵌入式实战
- C语言进阶
- 8小时前
- 19热度
- 0评论
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;
}
在 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 的内存,导致不可预测的行为 |
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;
- 全局/静态指针未初始化时自动为
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,编译器可能优化掉循环中的重复读取
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 是局部变量,存储在栈上,函数返回后栈帧被回收,返回的指针指向已失效的内存。正确做法是使用 static、malloc 或通过参数传出。
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语言变量》— 变量的存储与生命周期