找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2876

积分

0

好友

407

主题
发表于 15 小时前 | 查看: 0| 回复: 0

💭 开篇:指针恐惧症的由来

说到C语言的指针,很多初学者都会皱起眉头。我记得当年学习的时候,老师在黑板上写下这样的代码:

int *p;
int **pp;
void (*func_ptr)(int);

然后说:“这是指针,这是指向指针的指针,这是函数指针。”

一张科技感十足的图片,展示了一个悬浮在电路板上方的复古风格设备,设备上标有“POINTER”字样,并发射蓝色激光照射在电路板上。电路板上有复杂的金色线路和芯片,右下角显示虚拟文本框“ADDR: 0x04F2”。背景是模糊的实验室环境,可见电子仪器、显示器和工具。

我当时的内心OS:“什么鬼?这都是什么天书?”

为什么指针让人害怕?

  • 太抽象:在PC上学指针,你看不到它实际指向哪里,只能想象  
  • 易出错:野指针、空指针、内存泄漏,一不小心程序就崩了  
  • 难调试:出问题了只有一个"段错误",连哪里错了都不知道  
  • 语法复杂*&**(*func)(),各种符号看得眼花  

但是,当你接触到嵌入式编程,特别是单片机开发的时候,你会惊讶地发现:指针突然变得直观了!

在单片机里,指针就是"遥控器"

想象一下你家里的各种电器:

  • 电视的遥控器 → 控制电视  
  • 空调的遥控器 → 控制空调  
  • LED灯的遥控器 → 控制灯光  

在单片机里,指针就是硬件的遥控器

  • 指向GPIO寄存器的指针 → 控制引脚电平  
  • 指向UART寄存器的指针 → 控制串口通信  
  • 指向定时器寄存器的指针 → 控制定时器工作  
// 这不是什么高深的魔法,就是拿着"遥控器"(指针)
// 去操作"电器"(硬件寄存器)
uint32_t *gpio_control = (uint32_t *)0x40020000;  // 拿到GPIO的遥控器
*gpio_control = 0x00000020;                        // 按下"开关"按钮

看到了吗?在单片机里,指针不再抽象——它就是内存地址,而内存地址对应着真实的硬件!


🏠 指针的本质:内存地址的另一个名字

在深入单片机应用之前,我们先用最简单的方式理解指针。

内存是什么?一排有门牌号的房间

把计算机的内存想象成一条长长的街道,街道两旁是一排排房间(每个房间就是一个字节):

地址(门牌号)   内容(房间里的东西)
0x20000000  →  [  0x42  ]  ← 房间1
0x20000001  →  [  0x13  ]  ← 房间2
0x20000002  →  [  0x00  ]  ← 房间3
0x20000003  →  [  0xFF  ]  ← 房间4
...

指针是什么?门牌号(地址)

// 普通变量:直接在房间里存东西
int value = 42;           // 在某个房间里存了数字42

// 指针变量:存的是门牌号
int *ptr = &value;        // ptr存的是value所在房间的门牌号

// 解引用(*ptr):拿着门牌号去房间里拿东西
int x = *ptr;             // 根据ptr里的门牌号,去房间里读取内容(42)

用图示来看更清楚:

// 假设value变量分配在地址0x20000010
int value = 42;

内存布局:
地址          内容
0x20000010 → [ 42 ]  ← value变量在这里

// 指针ptr存的就是这个地址
int *ptr = &value;  // & 是"取地址",得到门牌号

ptr的值是:0x20000010(这就是门牌号)

// 解引用*ptr就是"根据门牌号找到房间,看里面的东西"
int x = *ptr;  // 去0x20000010这个地址读取内容 → 得到42

核心概念速记

操作 含义 类比
int *ptr 定义指针 准备一个用来存门牌号的本子
&value 取地址 查询value的门牌号
ptr = &value 赋值地址 把门牌号写在本子上
*ptr 解引用 拿着本子上的门牌号去房间里看
*ptr = 10 通过指针修改 拿着门牌号去房间里改东西

🔧 单片机里的指针:直击硬件的利器

现在我们进入正题:为什么嵌入式代码到处都是指针?

寄存器 = 特殊的内存地址

在单片机中,除了普通的RAM,还有一片特殊的"内存"——硬件寄存器

这些寄存器的地址是固定的、硬件规定的

// STM32F103的GPIO寄存器地址(来自芯片手册)
#define GPIOA_BASE   0x40010800   // GPIOA的基地址
#define GPIOB_BASE   0x40010C00   // GPIOB的基地址

// 具体寄存器的偏移
#define ODR_OFFSET   0x0C         // 输出数据寄存器偏移

// 完整地址
#define GPIOA_ODR    (GPIOA_BASE + ODR_OFFSET)  // 0x4001080C

关键点:这些地址不是变量,是硬件控制器的"控制面板"!

示例:点亮LED的两种方式

方式1:使用HAL库(封装了指针操作)

// HAL库帮你隐藏了指针
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);

// 看起来很简单,但它底层就是用指针操作寄存器!

方式2:直接用指针操作寄存器(本质)

// 方法A:最原始的方式
*((uint32_t *)0x4001080C) |= (1 << 5);

// 方法B:用宏定义增加可读性
#define GPIOA_ODR  *((volatile uint32_t *)0x4001080C)
GPIOA_ODR |= (1 << 5);

// 方法C:用指针变量
volatile uint32_t *gpio_odr = (volatile uint32_t *)0x4001080C;
*gpio_odr |= (1 << 5);

对比

方式 可读性 效率 灵活性 学习曲线
HAL库 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐
直接指针 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

结论:HAL库是把指针操作包装成了函数,但底层必须用指针才能操作硬件!


🎯 指针操作寄存器的三板斧

在单片机编程中,99%的指针操作都遵循这三个步骤。掌握它们,你就掌握了嵌入式开发的核心技能。

第一板斧:强制类型转换 (type *)address

为什么要强制转换?

硬件地址本质上是一个数字(比如0x40020000),但C语言不允许直接对数字解引用:

// ❌ 错误:编译器会报错
*0x40020000 = 0x12345678;  // 错误:0x40020000是个整数,不是指针!

// ✅ 正确:先把整数转换成指针
*((uint32_t *)0x40020000) = 0x12345678;

详细拆解

// 一步步分解
uint32_t address = 0x40020000;           // 1. 这是一个整数
uint32_t *ptr = (uint32_t *)address;     // 2. 把整数强制转换成指针
                         └─────────┘
                         类型转换,告诉编译器
                         "把这个数字当成32位指针"
*ptr = 0x12345678;                       // 3. 现在可以解引用了!

类型选择规则

寄存器位宽 指针类型 示例
8位 uint8_t * 字符相关寄存器
16位 uint16_t * 部分定时器寄存器
32位 uint32_t * ARM Cortex-M的大多数寄存器
// 示例:不同位宽的寄存器
uint8_t *byte_reg = (uint8_t *)0x40020000;    // 8位寄存器
*byte_reg = 0xFF;                             // 写入1个字节

uint32_t *word_reg = (uint32_t *)0x40020004;  // 32位寄存器
*word_reg = 0x12345678;                       // 写入4个字节

第二板斧:解引用读写 *ptr = valuevalue = *ptr

写操作:把值写入寄存器

volatile uint32_t *gpio_odr = (volatile uint32_t *)0x4001080C;

// 写入操作
*gpio_odr = 0x00000020;  // 把值0x00000020写入地址0x4001080C

// 等价于:去0x4001080C这个"房间",把里面的内容改成0x00000020

读操作:从寄存器读取值

volatile uint32_t *gpio_idr = (volatile uint32_t *)0x40010808;

// 读取操作
uint32_t pin_state = *gpio_idr;  // 从0x40010808读取当前GPIO输入状态

// 等价于:去0x40010808这个"房间",看看里面存的是什么

位操作(最常用):

// 设置某一位为1(不影响其他位)
*gpio_odr |= (1 << 5);   // 将第5位设为1,其他位保持不变

// 清除某一位为0(不影响其他位)
*gpio_odr &= ~(1 << 5);  // 将第5位清为0,其他位保持不变

// 切换某一位(0→1或1→0)
*gpio_odr ^= (1 << 5);   // 第5位翻转

第三板斧:配合volatile volatile uint32_t *ptr

为什么需要volatile?

看这个例子:

// 场景:等待硬件设置一个标志位
uint32_t *status_reg = (uint32_t *)0x40013804;

// ❌ 错误:编译器可能"优化"掉循环
while ((*status_reg & 0x01) == 0) {
    // 等待第0位变成1
}

// 编译器的想法:"这个循环里status_reg没变化,
// 它一直是0,那这就是个死循环,我'优化'一下吧!"
// 结果:编译器生成的代码可能一直读缓存的旧值,永远等不到硬件更新

正确做法:加上volatile

// ✅ 正确:告诉编译器"这个值可能被硬件改变,别优化!"
volatile uint32_t *status_reg = (volatile uint32_t *)0x40013804;

while ((*status_reg & 0x01) == 0) {
    // 每次循环都真实读取硬件寄存器
}

volatile的含义

“这个变量是’易变的’(volatile),可能被外部因素(硬件、中断等)修改,不要用缓存值,每次都去真实地址读取!”

什么时候必须用volatile?

  • ✅ 硬件寄存器  
  • ✅ 中断服务函数中访问的全局变量  
  • ✅ 多线程共享的变量  
  • ✅ DMA访问的缓冲区  
// 示例:中断标志变量
volatile uint8_t interrupt_flag = 0;

void TIM2_IRQHandler(void) {
    // 中断中设置标志
    interrupt_flag = 1;
}

void main_loop(void) {
    while (1) {
        if (interrupt_flag) {  // 必须声明为volatile,否则可能读不到更新
            interrupt_flag = 0;
            // 处理中断事件
        }
    }
}

💡 实战案例1:点亮LED(从指针角度剖析)

现在我们用一个完整的例子,逐行分析指针是如何操控硬件的。

硬件背景

  • 芯片:STM32F103C8T6  
  • LED连接:PA5引脚  
  • 目标:让LED亮灭闪烁  

完整代码(纯指针实现)

#include <stdint.h>

// ========== 第一步:定义寄存器地址 ==========
// 这些地址来自《STM32F10x参考手册》
#define RCC_BASE      0x40021000   // 时钟控制器基地址
#define GPIOA_BASE    0x40010800   // GPIOA基地址

// RCC寄存器偏移
#define RCC_APB2ENR   (RCC_BASE + 0x18)     // APB2外设时钟使能寄存器

// GPIO寄存器偏移
#define GPIOA_CRL     (GPIOA_BASE + 0x00)   // 端口配置低寄存器(PA0-PA7)
#define GPIOA_ODR     (GPIOA_BASE + 0x0C)   // 端口输出数据寄存器

// ========== 第二步:把地址转换成指针 ==========
volatile uint32_t *rcc_apb2enr = (volatile uint32_t *)RCC_APB2ENR;
volatile uint32_t *gpioa_crl   = (volatile uint32_t *)GPIOA_CRL;
volatile uint32_t *gpioa_odr   = (volatile uint32_t *)GPIOA_ODR;

// 简单延时函数
void delay(uint32_t count) {
    while (count--) {
        __asm("nop");  // 空操作
    }
}

int main(void) {
    // ========== 第三步:使能GPIOA时钟 ==========
    // STM32的外设默认关闭时钟,必须先开启
    // 读取当前值
    uint32_t temp = *rcc_apb2enr;
                └─────────┘
                解引用,读取0x40021018地址的内容
    // 设置bit 2为1(使能GPIOA时钟)
        temp |= (1 << 2);
    // 写回寄存器
        *rcc_apb2enr = temp;
    └─────────┘
    解引用,向0x40021018地址写入新值
    // 更简洁的写法(一步到位):
    // *rcc_apb2enr |= (1 << 2);

    // ========== 第四步:配置PA5为推挽输出模式 ==========
    // GPIOA_CRL控制PA0-PA7,每个引脚占4位
    // PA5对应bit 20-23
    // 先清除PA5的配置位
        temp = *gpioa_crl;
        temp &= ~(0xF << 20);     // 清除bit 20-23(0xF = 0b1111)
    // 设置为输出模式,50MHz,推挽输出(0b0011)
        temp |= (0x3 << 20);
    // 写回寄存器
        *gpioa_crl = temp;

    // ========== 第五步:控制LED闪烁 ==========
    while (1) {
        // 点亮LED(PA5输出高电平)
            *gpioa_odr |= (1 << 5);   // 将bit 5设为1
        └────────┘
        去0x4001080C,把bit 5改成1
            delay(500000);             // 延时

        // 熄灭LED(PA5输出低电平)
            *gpioa_odr &= ~(1 << 5);  // 将bit 5清为0
        └────────┘
        去0x4001080C,把bit 5改成0
            delay(500000);             // 延时
    }
    return 0;
}

逐行解析关键操作

1. 地址到指针的转换

volatile uint32_t *gpioa_odr = (volatile uint32_t *)0x4001080C;
       └────┘    └───────┘    └───────────────┘  └─────────┘
       修饰      指针类型       类型转换           硬件地址

这一行在做什么?

  • 0x4001080C:这是芯片手册规定的GPIO输出寄存器地址  
  • (volatile uint32_t *):把这个整数强制转换成"指向32位变量的指针"  
  • volatile:告诉编译器"这个地址的内容可能被硬件改变"  
  • gpioa_odr:给这个指针起个名字,方便使用  

2. 通过指针修改硬件

*gpioa_odr |= (1 << 5);

CPU执行流程:

  1. 读取地址0x4001080C的当前值(假设是0x00000000)  
  2. 计算(1 << 5)0x00000020  
  3. 执行或运算:0x00000000 | 0x000000200x00000020  
  4. 把结果0x00000020写回地址0x4001080C  
  5. 硬件控制器检测到bit 5变成1,立即让PA5引脚输出高电平  

3. 如果不用指针会怎样?

根本做不了!硬件寄存器是固定地址,你必须通过指针才能访问它们。

// ❌ 无法实现:你无法"定义一个变量正好在0x4001080C"
uint32_t gpioa_odr;  // 这只是在RAM里分配的普通变量
gpioa_odr = 0x20;    // 这改的是RAM,硬件看不到!

📡 实战案例2:串口发送字符串(指针遍历)

串口通信是单片机最常用的功能,这里我们用指针来发送字符串。

任务描述

通过USART1发送字符串"Hello, STM32!"

代码实现

#include <stdint.h>

// USART1寄存器地址
#define USART1_BASE   0x40013800
#define USART1_SR     (USART1_BASE + 0x00)  // 状态寄存器
#define USART1_DR     (USART1_BASE + 0x04)  // 数据寄存器

// 转换成指针
volatile uint32_t *usart1_sr = (volatile uint32_t *)USART1_SR;
volatile uint32_t *usart1_dr = (volatile uint32_t *)USART1_DR;

// 状态位定义
#define USART_SR_TXE  (1 << 7)  // 发送数据寄存器空标志

// ========== 方法1:使用指针遍历 ==========
void uart_send_string_ptr(const char *str) {
//                         └────────┘
//                         const char *:指向字符的指针
// 当指针指向的字符不是'\0'(字符串结束符)时,继续循环
    while (*str != '\0') {
    └──┘
    解引用:读取指针指向的字符
        // 等待发送缓冲区空闲
        while ((*usart1_sr & USART_SR_TXE) == 0);

        // 发送当前字符
        *usart1_dr = *str;
        └──┘
        读取字符

        // 指针自增,指向下一个字符
        str++;
        // str现在指向下一个字符的地址
    }
}

// ========== 方法2:使用数组下标(对比) ==========
void uart_send_string_index(const char *str) {
    int i = 0;
    while (str[i] != '\0') {
    //   └───┘
    //   str[i] 等价于 *(str + i)
        while ((*usart1_sr & USART_SR_TXE) == 0);
        *usart1_dr = str[i];
        i++;
    }
}

int main(void) {
    // ... 初始化串口(省略)
    // 测试
    uart_send_string_ptr("Hello, STM32!\r\n");
    return 0;
}

指针遍历的内存视图

假设字符串"Hello"存储在地址0x20000100

地址           内容       *str的变化
0x20000100 → [ 'H' ]  ← str初始指向这里,*str = 'H'
0x20000101 → [ 'e' ]  ← str++后指向这里,*str = 'e'
0x20000102 → [ 'l' ]  ← str++后指向这里,*str = 'l'
0x20000103 → [ 'l' ]  ← str++后指向这里,*str = 'l'
0x20000104 → [ 'o' ]  ← str++后指向这里,*str = 'o'
0x20000105 → [ '\0' ] ← str++后指向这里,*str = '\0',循环结束

指针自增的含义

char *str = "Hello";

str++;  // 指针增加1,但实际地址增加sizeof(char) = 1字节
// str从0x20000100变成0x20000101

性能对比:指针 vs 数组下标

// 指针方式生成的汇编(ARM Thumb)
// 假设str在寄存器r0
ldrb r1, [r0]       // 加载*str到r1        (1条指令)
add  r0, r0, #1     // str++                (1条指令)

// 数组下标方式生成的汇编
// 假设str在r0,i在r1
add  r2, r0, r1     // 计算str + i          (1条指令)
ldrb r3, [r2]       // 加载str[i]到r3       (1条指令)
add  r1, r1, #1     // i++                  (1条指令)

结论:指针方式稍微高效一点(少一条加法指令),但差别不大。更重要的是代码简洁性


🚀 实战案例3:DMA配置(指针告诉硬件"数据在哪")

DMA(Direct Memory Access,直接内存访问)是一个独立的硬件模块,它可以不通过CPU直接在内存和外设之间传输数据。

为什么DMA需要指针?

因为DMA需要知道:

  • 源地址:数据从哪里来?(指针)  
  • 目标地址:数据要去哪里?(指针)  
  • 传输多少:数据大小(整数)  
// DMA配置寄存器(简化示例)
#define DMA1_BASE        0x40020000
#define DMA1_Channel1    (DMA1_BASE + 0x08)

// DMA通道寄存器偏移
#define DMA_CCR_OFFSET   0x00   // 配置寄存器
#define DMA_CNDTR_OFFSET 0x04   // 数据量寄存器
#define DMA_CPAR_OFFSET  0x08   // 外设地址寄存器
#define DMA_CMAR_OFFSET  0x0C   // 内存地址寄存器

// 转换成指针
volatile uint32_t *dma_ccr   = (volatile uint32_t *)(DMA1_Channel1 + DMA_CCR_OFFSET);
volatile uint32_t *dma_cndtr = (volatile uint32_t *)(DMA1_Channel1 + DMA_CNDTR_OFFSET);
volatile uint32_t *dma_cpar  = (volatile uint32_t *)(DMA1_Channel1 + DMA_CPAR_OFFSET);
volatile uint32_t *dma_cmar  = (volatile uint32_t *)(DMA1_Channel1 + DMA_CMAR_OFFSET);

// 示例:用DMA把数组数据发送到串口
void dma_uart_send(const uint8_t *data, uint16_t size) {
//                 └──────────┘
//                 指针:数据的起始地址
    // 1. 配置DMA传输数量
        *dma_cndtr = size;

    // 2. 配置外设地址(USART1数据寄存器)
        *dma_cpar = (uint32_t)USART1_DR;  // USART1_DR是个地址

    // 3. 配置内存地址(我们的数组)
        *dma_cmar = (uint32_t)data;       // 把指针转换成地址值
        └────────┘
        data本身就是地址,但要转换成uint32_t类型

    // 4. 配置传输方向、优先级等
        *dma_ccr = 0x00000091;  // 启用DMA,内存到外设,8位数据

    // 现在DMA开始工作:
    // - 从地址data开始,连续读取size个字节
    // - 每读一个字节,就写到USART1_DR
    // - CPU完全不参与,可以去做其他事情!
}

int main(void) {
    // ... 初始化DMA和UART
    uint8_t buffer[] = "DMA Test!\r\n";

    // 启动DMA传输
        dma_uart_send(buffer, sizeof(buffer) - 1);
        └────┘
        buffer是数组名,退化为指针(指向第一个元素)

    // DMA正在后台传输数据,CPU可以做其他事情
    while (1) {
        // 主循环可以处理其他任务
    }
    return 0;
}

关键理解点

1. 指针类型转换

const uint8_t *data = buffer;   // data是uint8_t*类型(指向字节)
*dma_cmar = (uint32_t)data;    // DMA寄存器是32位,要转换成uint32_t

// 为什么这样做安全?
// 因为指针的本质就是地址(一个数字)
// 只要地址值正确,硬件就能找到数据

2. 数组名退化为指针

uint8_t buffer[11] = "DMA Test!\r\n";

// buffer可以当指针用
dma_uart_send(buffer, ...);  // ✅ 正确

// 实际上编译器把它转换成了
dma_uart_send(&buffer[0], ...);  // 等价

3. DMA看到的"世界"

从DMA的视角看内存:

假设buffer数组在地址0x20000200:

地址           内容
0x20000200 → [ 'D' ]  ← DMA第1次读取这里
0x20000201 → [ 'M' ]  ← DMA第2次读取这里
0x20000202 → [ 'A' ]  ← DMA第3次读取这里
0x20000203 → [ ' ' ]  ← DMA第4次读取这里
...

DMA不关心这是什么数据类型,它只知道:
"从0x20000200开始,连续读11个字节,依次发送"

🎭 实战案例4:函数指针在中断向量表中的应用

中断向量表是单片机中函数指针最经典的应用。

什么是中断向量表?

中断向量表是一个函数指针数组,存储在Flash的起始位置(通常是0x08000000)。当中断发生时,CPU根据中断号查表,跳转到对应的函数执行。

中断号     向量表地址       存储的内容(函数指针)
 0      0x08000000   →  初始栈指针值
 1      0x08000004   →  Reset_Handler的地址
 2      0x08000008   →  NMI_Handler的地址
 3      0x0800000C   →  HardFault_Handler的地址
 ...
 28     0x08000070   →  TIM2_IRQHandler的地址

代码示例:定义中断向量表

#include <stdint.h>

// ========== 第一步:声明中断处理函数 ==========
void Reset_Handler(void);       // 复位处理
void NMI_Handler(void);        // 不可屏蔽中断
void HardFault_Handler(void);  // 硬件错误
void TIM2_IRQHandler(void);      // 定时器2中断

// ========== 第二步:定义中断向量表 ==========
// 这是一个函数指针数组
typedef void (*vector_table_entry_t)(void);
//            └──────────────────┘
//            这是函数指针类型:返回void,无参数

// 使用GCC的属性,把这个数组放到.isr_vector段(Flash起始处)
__attribute__((section(".isr_vector")))
const vector_table_entry_t vector_table[] = {
//└────────────────────┘
// 数组元素类型是"函数指针"

    (vector_table_entry_t)0x20005000,  // [0] 初始栈顶指针(特殊)
    Reset_Handler,                      // [1] 复位向量
    NMI_Handler,                        // [2] NMI
    HardFault_Handler,                  // [3] 硬件错误
    // ... 省略中间的向量
    TIM2_IRQHandler,                    // [28] 定时器2中断
};

// ========== 第三步:实现中断处理函数 ==========
void TIM2_IRQHandler(void) {
    // 这是定时器2的中断处理函数
    // 当TIM2中断发生时,CPU会调用这个函数

    // 清除中断标志
    volatile uint32_t *tim2_sr = (volatile uint32_t *)0x40000010;
        *tim2_sr &= ~(1 << 0);

    // 用户代码:比如翻转LED
    static volatile uint32_t *gpioa_odr = (volatile uint32_t *)0x4001080C;
        *gpioa_odr ^= (1 << 5);  // LED闪烁
}

void HardFault_Handler(void) {
    // 硬件错误处理(通常是指针错误导致的)
    while (1) {
        // 死循环,等待调试
    }
}

中断发生时的流程

假设定时器2中断发生(中断号28):

  1. 硬件自动保存现场:CPU把当前的寄存器压栈  
  2. 查找中断向量:CPU读取地址0x08000000 + 28*4 = 0x08000070的内容  
  3. 获取函数地址:假设读到的是0x080001A0(TIM2_IRQHandler的地址)  
  4. 跳转执行:CPU跳转到0x080001A0,开始执行TIM2_IRQHandler  
  5. 返回:中断函数执行完毕,CPU恢复现场,继续执行被中断的代码  

函数指针的作用:让CPU能够"间接跳转"到正确的函数。

函数指针的语法详解

// 最基本的函数指针定义
void (*func_ptr)(int);
   └───────┘
   func_ptr是一个指针,指向"返回void、参数是int"的函数

// 赋值
void my_function(int x) {
    // ...
}
func_ptr = my_function;  // 函数名就是函数的地址

// 调用
func_ptr(42);            // 通过指针调用函数
// 等价于
my_function(42);         // 直接调用

// 在中断向量表中
void TIM2_IRQHandler(void);         // 声明函数
vector_table[28] = TIM2_IRQHandler; // 存储函数地址

// 当中断发生时,硬件相当于执行:
vector_table[28]();  // 调用TIM2_IRQHandler

⚠️ 指针的"危险"与防护

指针强大,但也容易出错。这里列举常见的陷阱和防护方法。

1. 野指针(最危险)

什么是野指针? 未初始化或已释放的指针,指向不确定的内存。

// ❌ 危险:野指针
void bad_example(void) {
    int *ptr;           // 声明但未初始化,ptr的值是随机的
        *ptr = 42;      // 写入随机地址,可能崩溃!
}

// ✅ 安全:初始化指针
void good_example(void) {
    int value;
    int *ptr = &value;  // 指针指向明确的变量
        *ptr = 42;      // 安全
}

// ❌ 危险:使用已释放的指针
void bad_free(void) {
    int *ptr = (int *)malloc(sizeof(int));
    free(ptr);
        *ptr = 42;      // ptr已经free,再用就是野指针!
}

// ✅ 安全:free后置NULL
void good_free(void) {
    int *ptr = (int *)malloc(sizeof(int));
    free(ptr);
        ptr = NULL;      // 标记指针无效

    if (ptr != NULL) {  // 使用前检查
        *ptr = 42;
    }
}

单片机中的特殊情况

// 嵌入式中常见的野指针场景
void uart_init(void) {
    volatile uint32_t *usart_cr1 = (volatile uint32_t *)0x4001380C;
        *usart_cr1 |= 0x2000;  // 使能USART
}

// 如果地址写错(手误或芯片型号不对),就是野指针!
volatile uint32_t *wrong_ptr = (volatile uint32_t *)0x4001380D;  // 地址错误!
*wrong_ptr = 0x2000;  // 可能触发HardFault

防护措施

  • ✅ 声明时立即初始化  
  • ✅ 使用前检查是否为NULL  
  • ✅ 硬件地址仔细核对datasheet  
  • ✅ 使用宏定义而不是直接写数字地址  

2. 空指针(NULL)

什么是空指针? 指针值为0(NULL),表示"不指向任何有效地址"。

// ❌ 危险:不检查NULL
void process_data(uint8_t *data) {
    *data = 0xFF;  // 如果data是NULL,这里会崩溃!
}

// ✅ 安全:先检查
void process_data_safe(uint8_t *data) {
    if (data == NULL) {
        // 处理错误情况
        return;
    }
    *data = 0xFF;  // 现在安全了
}

// ❌ 危险:malloc失败后不检查
void bad_malloc(void) {
    uint8_t *buffer = (uint8_t *)malloc(1024);
        buffer[0] = 0x42;  // 如果malloc返回NULL,这里会崩溃
}

// ✅ 安全:检查malloc返回值
void good_malloc(void) {
    uint8_t *buffer = (uint8_t *)malloc(1024);
    if (buffer == NULL) {
        // 内存分配失败,处理错误
        return;
    }
    buffer[0] = 0x42;  // 安全
    free(buffer);
}

在嵌入式中的应用

// 检查硬件初始化是否成功
volatile uint32_t *check_peripheral(uint32_t base_addr) {
    // 读取外设的ID寄存器(假设偏移0x00)
    volatile uint32_t *id_reg = (volatile uint32_t *)base_addr;

    if (*id_reg == 0xFFFFFFFF) {
        // ID全1,说明外设不存在或未上电
        return NULL;
    }

    return id_reg;
}

void use_peripheral(void) {
    volatile uint32_t *periph = check_peripheral(0x40020000);

    if (periph != NULL) {
        // 外设存在,可以使用
            *periph = 0x1234;
    } else {
        // 外设不存在,报错处理
            uart_send_string("Peripheral not found!\r\n");
    }
}

3. 指针越界

什么是指针越界? 指针访问超出了分配的内存范围。

// ❌ 危险:数组越界
void bad_array_access(void) {
    uint8_t buffer[10];
    uint8_t *ptr = buffer;

    for (int i = 0; i < 20; i++) {  // 数组只有10个元素!
        ptr[i] = i;  // i>=10时越界,破坏其他变量的内存
    }
}

// ✅ 安全:检查边界
void good_array_access(void) {
    uint8_t buffer[10];
    uint8_t *ptr = buffer;

    for (int i = 0; i < 10; i++) {  // 限制在数组大小内
        ptr[i] = i;
    }
}

// ❌ 危险:指针自增过头
void bad_pointer_increment(void) {
    uint8_t buffer[10];
    uint8_t *ptr = buffer;

    while (ptr < buffer + 100) {  // 错误!超出数组范围
        *ptr++ = 0xFF;
    }
}

// ✅ 安全:正确的循环条件
void good_pointer_increment(void) {
    uint8_t buffer[10];
    uint8_t *ptr = buffer;
    uint8_t *end = buffer + 10;  // 计算结束位置

    while (ptr < end) {
        *ptr++ = 0xFF;
    }
}

实际案例:缓冲区溢出

// 危险的字符串复制
void bad_strcpy(char *dest, const char *src) {
    while (*src != '\0') {
        *dest++ = *src++;  // 没有检查dest的大小!
    }
    *dest = '\0';
}

// 调用
char small_buffer[10];
bad_strcpy(small_buffer, "This is a very long string!");  // 溢出!

// 安全的字符串复制
void safe_strncpy(char *dest, const char *src, size_t max_len) {
    size_t i;
    for (i = 0; i < max_len - 1 && src[i] != '\0'; i++) {
        dest[i] = src[i];
    }
    dest[i] = '\0';  // 确保以null结尾
}

// 调用
char small_buffer[10];
safe_strncpy(small_buffer, "This is a very long string!", sizeof(small_buffer));
// 结果:small_buffer = "This is a" (被截断,但安全)

4. 类型不匹配(对齐问题)

什么是对齐? ARM Cortex-M要求访问多字节数据时,地址必须是数据大小的倍数。

// ❌ 危险:未对齐的访问
void bad_alignment(void) {
    uint8_t buffer[8];

    // 尝试在奇数地址访问32位数据
    uint32_t *ptr = (uint32_t *)(&buffer[1]);  // 地址可能是奇数
        *ptr = 0x12345678;  // 可能触发HardFault!
}

// ✅ 安全:确保对齐
void good_alignment(void) {
    // 方法1:使用__attribute__((aligned(4)))
    __attribute__((aligned(4))) uint8_t buffer[8];

    uint32_t *ptr = (uint32_t *)buffer;  // 现在buffer保证4字节对齐
        *ptr = 0x12345678;  // 安全

    // 方法2:使用memcpy(自动处理对齐)
    uint8_t unaligned_buffer[8];
    uint32_t value = 0x12345678;
    memcpy(&unaligned_buffer[1], &value, sizeof(value));  // 安全
}

实际案例:解析网络数据包

// 假设收到的数据包格式:
// [1字节命令] [4字节数据]
uint8_t packet[5] = {0x01, 0x12, 0x34, 0x56, 0x78};

// ❌ 危险:直接类型转换
void bad_parse(void) {
    uint32_t *data_ptr = (uint32_t *)(&packet[1]);  // packet[1]可能未对齐
    uint32_t value = *data_ptr;  // 可能触发HardFault
}

// ✅ 安全:逐字节拼接
void good_parse(void) {
    uint32_t value = 0;
        value |= ((uint32_t)packet[1] << 0);
        value |= ((uint32_t)packet[2] << 8);
        value |= ((uint32_t)packet[3] << 16);
        value |= ((uint32_t)packet[4] << 24);
    // 结果:value = 0x78563412(注意字节序)
}

// ✅ 更安全:使用memcpy
void better_parse(void) {
    uint32_t value;
    memcpy(&value, &packet[1], sizeof(value));  // 编译器会处理对齐
}

5. 多线程/中断竞争条件

问题:主程序和中断同时访问同一个指针指向的数据。

// 全局缓冲区
volatile uint8_t rx_buffer[256];
volatile uint8_t *rx_write_ptr = rx_buffer;  // 写指针(中断用)
volatile uint8_t *rx_read_ptr = rx_buffer;   // 读指针(主程序用)

// ❌ 危险:没有保护的多指针操作
void UART_IRQHandler(void) {
    *rx_write_ptr++ = UART->DR;  // 中断中写入数据

    if (rx_write_ptr >= rx_buffer + 256) {
        rx_write_ptr = rx_buffer;  // 循环
    }
}

void main_loop(void) {
    while (1) {
        if (rx_read_ptr != rx_write_ptr) {  // 有数据
            uint8_t data = *rx_read_ptr++;  // ⚠️ 如果此时中断发生...

            if (rx_read_ptr >= rx_buffer + 256) {
                rx_read_ptr = rx_buffer;
            }

            process_data(data);
        }
    }
}

// ✅ 安全:使用临界区保护
void safe_main_loop(void) {
    while (1) {
        uint8_t data;
        int has_data = 0;

        __disable_irq();  // 关中断
        if (rx_read_ptr != rx_write_ptr) {
            data = *rx_read_ptr++;
            if (rx_read_ptr >= rx_buffer + 256) {
                rx_read_ptr = rx_buffer;
            }
            has_data = 1;
        }
        __enable_irq();  // 开中断

        if (has_data) {
            process_data(data);
        }
    }
}

🛠️ 常见指针操作技巧与模式

1. 结构体指针访问寄存器组

当一组相关的寄存器连续排列时,可以用结构体指针来访问,代码更清晰。

// 方法A:分别定义每个寄存器
#define GPIOA_CRL   *((volatile uint32_t *)0x40010800)
#define GPIOA_CRH   *((volatile uint32_t *)0x40010804)
#define GPIOA_IDR   *((volatile uint32_t *)0x40010808)
#define GPIOA_ODR   *((volatile uint32_t *)0x4001080C)
// 使用
GPIOA_ODR |= (1 << 5);

// 方法B:使用结构体(更优雅)
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)

// 使用(更直观)
GPIOA->ODR |= (1 << 5);      // 通过指针访问结构体成员
GPIOA->BSRR = (1 << 5);      // 设置bit 5
GPIOA->BRR = (1 << 5);       // 复位bit 5

// 这就是STM32 HAL库的做法!

优点

  • 代码可读性强  
  • 编译器能做边界检查  
  • 自动计算偏移量  

原理

// 当你写 GPIOA->ODR 时,编译器做了什么?
// 1. GPIOA是指针,值为0x40010800
// 2. ODR是结构体中的第4个成员,偏移0x0C
// 3. 编译器生成:*(0x40010800 + 0x0C) = *(0x4001080C)

2. 指针数组 vs 数组指针

这是个经典的容易混淆的概念。

// ========== 指针数组 ==========
// "数组的元素是指针"
int *ptr_array[5];
//   └────────┘
//   ptr_array是一个数组,有5个元素,每个元素是int*类型

// 示例:多个LED的GPIO地址
volatile uint32_t *led_gpio[3] = {
    (volatile uint32_t *)0x4001080C,  // LED1 - GPIOA_ODR
    (volatile uint32_t *)0x40010C0C,  // LED2 - GPIOB_ODR
    (volatile uint32_t *)0x4001100C,  // LED3 - GPIOC_ODR
};

// 使用
void control_led(int led_id, int state) {
    if (state) {
        *led_gpio[led_id] |= (1 << 5);   // 点亮
    } else {
        *led_gpio[led_id] &= ~(1 << 5);  // 熄灭
    }
}

// ========== 数组指针 ==========
// "指针指向数组"
int (*array_ptr)[5];
//    └────────┘
//    array_ptr是一个指针,指向"有5个int元素的数组"

// 示例:指向GPIO端口的配置数组
uint32_t gpioa_config[5] = {0x44444444, 0x44444444, 0x00000000, 0x00000000, 0x00000000};
uint32_t (*config_ptr)[5] = &gpioa_config;

// 使用
uint32_t first_value = (*config_ptr)[0];  // 访问第一个元素

记忆技巧

  • int *ptr[5][]优先级高,先是数组,数组的元素是int *  
  • int (*ptr)[5]()优先级高,先是指针,指针指向int [5]  

3. 二级指针的实际用途

场景1:修改指针本身

// 函数需要修改指针的值(让指针指向新地址)
void advance_pointer(uint8_t **ptr, int offset) {
//                 └────┘
//                 指向指针的指针(二级指针)

    *ptr = *ptr + offset;  // 修改指针的值
}

// 使用
uint8_t buffer[100];
uint8_t *current = buffer;

advance_pointer(¤t, 10);  // 传入current的地址
// 现在current指向buffer[10]

场景2:动态创建指针数组

// 创建一个指针数组(每个元素指向一个缓冲区)
uint8_t **create_buffer_array(int count, int size) {
    uint8_t **array = (uint8_t **)malloc(count * sizeof(uint8_t *));

    for (int i = 0; i < count; i++) {
        array[i] = (uint8_t *)malloc(size);
    }

    return array;
}

// 使用
uint8_t **buffers = create_buffer_array(5, 256);
buffers[0][0] = 0x42;  // 访问第1个缓冲区的第1个字节

场景3:链表

// 链表节点
typedef struct Node {
    int data;
    struct Node *next;  // 指针指向下一个节点
} Node;

// 在链表头插入节点
void insert_head(Node **head, int value) {
//             └────┘
//             需要修改head指针本身

    Node *new_node = (Node *)malloc(sizeof(Node));
    new_node->data = value;
    new_node->next = *head;

    *head = new_node;  // 修改head,让它指向新节点
}

4. const指针的三种用法

// ========== 用法1:指向常量的指针 ==========
const int *ptr1;
//└───┘
// ptr1指向的数据是常量,不能通过ptr1修改

int value = 42;
const int *ptr1 = &value;

*ptr1 = 10;   // ❌ 错误:不能修改
ptr1 = &other; // ✅ 正确:可以改变指针指向

// ========== 用法2:常量指针 ==========
int *const ptr2 = &value;
//   └───┘
// ptr2本身是常量,不能改变指向

*ptr2 = 10;   // ✅ 正确:可以修改指向的数据
ptr2 = &other; // ❌ 错误:不能改变指针本身

// ========== 用法3:指向常量的常量指针 ==========
const int *const ptr3 = &value;
//└───┘    └───┘
// 两者都是常量

*ptr3 = 10;   // ❌ 错误:不能修改数据
ptr3 = &other; // ❌ 错误:不能改变指针

// 在嵌入式中的应用
void process_sensor_data(const uint16_t *data, int count) {
//                     └─────────┘
//                     函数承诺不会修改data指向的数据

    for (int i = 0; i < count; i++) {
        // data[i] = 0;  // ❌ 编译错误,保护原始数据
        int value = data[i];  // ✅ 可以读取
        // 处理value...
    }
}

记忆技巧:const修饰它右边最近的内容

  • const int *:const修饰int,数据是常量  
  • int *const:const修饰指针,指针是常量  

🔍 调试指针问题的实战技巧

1. 在调试器中查看指针指向的内存

Keil MDK示例

uint32_t *gpio_odr = (uint32_t *)0x4001080C;
*gpio_odr = 0x00000020;

调试步骤:

  1. 在这行代码设断点  
  2. 运行到断点  
  3. 在Watch窗口添加:  
    • gpio_odr:查看指针的值(应该是0x4001080C)  
    • *gpio_odr:查看指针指向的内容  
  4. 在Memory窗口输入地址0x4001080C,直接查看内存内容  

GDB调试示例

(gdb) break main.c:42
(gdb) run
(gdb) print gpio_odr          # 查看指针值
$1 = (uint32_t *) 0x4001080c
(gdb) print *gpio_odr         # 查看指针指向的内容
$2 = 32
(gdb) x/4xw 0x4001080C        # 查看从0x4001080C开始的4个字(32位)
0x4001080c:  0x00000020  0x00000000  0x00000000  0x00000000

2. 用串口打印指针地址

当没有调试器时,串口是最好的调试工具。

void debug_pointer(void *ptr, const char *name) {
    // 打印指针地址
        uart_send_string(name);
        uart_send_string(" = 0x");

    // 把指针转换成整数,方便打印
        uint32_t addr = (uint32_t)ptr;

    // 打印16进制地址
        for (int i = 28; i >= 0; i -= 4) {
            uint8_t nibble = (addr >> i) & 0xF;
            if (nibble < 10) {
                uart_send_char('0' + nibble);
            } else {
                uart_send_char('A' + nibble - 10);
            }
        }
        uart_send_string("\r\n");
}

// 使用
int value = 42;
int *ptr = &value;

debug_pointer(ptr, "ptr");           // 输出:ptr = 0x20000100
debug_pointer(&value, "&value");     // 输出:&value = 0x20000100

volatile uint32_t *gpio = (volatile uint32_t *)0x4001080C;
debug_pointer((void *)gpio, "gpio"); // 输出:gpio = 0x4001080C

更高级的调试函数

void debug_memory_dump(void *addr, int size) {
    uint8_t *ptr = (uint8_t *)addr;

        uart_send_string("Memory dump from 0x");
        print_hex((uint32_t)addr);
        uart_send_string(":\r\n");

    for (int i = 0; i < size; i++) {
        if (i % 16 == 0) {
            // 打印地址
                print_hex((uint32_t)(ptr + i));
                uart_send_string(": ");
        }

        // 打印内容
            print_hex_byte(ptr[i]);
            uart_send_char(' ');

        if (i % 16 == 15) {
                uart_send_string("\r\n");
        }
    }
}

// 使用
uint8_t buffer[32] = {0x12, 0x34, 0x56, 0x78, /* ... */};
debug_memory_dump(buffer, 32);

// 输出示例:
// Memory dump from 0x20000200:
// 20000200: 12 34 56 78 9A BC DE F0 11 22 33 44 55 66 77 88
// 20000210: 99 AA BB CC DD EE FF 00 01 02 03 04 05 06 07 08

3. HardFault调试(指针错误的常见后果)

当指针出错时,最常见的结果是HardFault异常(硬件错误)。

典型的HardFault原因

  • 访问不存在的内存地址  
  • 未对齐的访问  
  • 野指针  
  • 栈溢出  

调试HardFault的方法

// 实现HardFault处理函数,打印寄存器信息
void HardFault_Handler(void) {
    // 保存关键寄存器(这些寄存器在异常发生时自动压栈)
    // 栈中的布局(从低地址到高地址):
    // R0, R1, R2, R3, R12, LR, PC, xPSR

    __asm volatile(
    "TST LR, #4          \n\t"
    "ITE EQ              \n\t"
    "MRSEQ R0, MSP       \n\t" // 主栈
    "MRSNE R0, PSP       \n\t" // 进程栈
    "B hardfault_handler \n\t" // 跳转到C函数
    );
}

void hardfault_handler(uint32_t *stack) {
    // stack指向异常发生时的栈帧
        uint32_t r0  = stack[0];
        uint32_t r1  = stack[1];
        uint32_t r2  = stack[2];
        uint32_t r3  = stack[3];
        uint32_t r12 = stack[4];
        uint32_t lr  = stack[5];  // 返回地址
        uint32_t pc  = stack[6];  // 异常发生时的PC(最关键!)
        uint32_t psr = stack[7];

    // 通过串口打印信息
        uart_send_string("\r\n===== HardFault =====\r\n");
        uart_send_string("PC  = 0x"); print_hex(pc);  uart_send_string("\r\n");
        uart_send_string("LR  = 0x"); print_hex(lr);  uart_send_string("\r\n");
        uart_send_string("R0  = 0x"); print_hex(r0);  uart_send_string("\r\n");
        uart_send_string("R1  = 0x"); print_hex(r1);  uart_send_string("\r\n");
        uart_send_string("R2  = 0x"); print_hex(r2);  uart_send_string("\r\n");
        uart_send_string("R3  = 0x"); print_hex(r3);  uart_send_string("\r\n");

    // 读取故障状态寄存器
        volatile uint32_t *cfsr = (volatile uint32_t *)0xE000ED28;
        uart_send_string("CFSR = 0x"); print_hex(*cfsr); uart_send_string("\r\n");

    // 如果是地址访问错误,读取错误地址
        if (*cfsr & 0x80) {  // MMARVALID
            volatile uint32_t *mmfar = (volatile uint32_t *)0xE000ED34;
            uart_send_string("Bad address = 0x"); print_hex(*mmfar); uart_send_string("\r\n");
        }

    // 死循环,等待复位或调试器介入
        while (1) {
            // 可以闪烁LED指示错误
        }
}

根据PC定位错误代码

# 使用arm-none-eabi-addr2line工具
arm-none-eabi-addr2line -e your_program.elf -f -C 0x080001A4

# 输出:
# main.c:42
# bad_pointer_access

4. 使用内存观察窗口

在IDE中,可以实时观察特定内存区域的变化。

示例:监控GPIO寄存器

  1. 添加内存观察:地址0x4001080C,长度32字节  
  2. 运行程序  
  3. 实时观察寄存器值的变化  

技巧

  • 可以同时观察多个地址范围  
  • 可以查看不同格式(HEX、BIN、DEC)  
  • 可以修改内存值进行测试  

⚡ 指针性能优化

1. 为什么指针比数组索引快?

看这个简单的例子:

// 方法A:数组索引
uint8_t sum_array_index(uint8_t arr[], int size) {
    uint8_t sum = 0;
    for (int i = 0; i < size; i++) {
        sum += arr[i];
    }
    return sum;
}

// 方法B:指针
uint8_t sum_array_pointer(uint8_t *arr, int size) {
    uint8_t sum = 0;
    uint8_t *end = arr + size;
    while (arr < end) {
        sum += *arr++;
    }
    return sum;
}

生成的ARM汇编对比(简化):

; ========== 方法A:数组索引 ==========
; 假设arr在r0,size在r1,sum在r2,i在r3
sum_array_index:
    movs r2, #0       ; sum = 0
    movs r3, #0       ; i = 0
loop:
    cmp  r3, r1       ; 比较i和size
    bge  done         ; if i >= size, 跳转到done

    add  r4, r0, r3   ; 计算arr + i的地址(关键:额外的加法)
    ldrb r5, [r4]     ; 加载arr[i]
    add  r2, r2, r5   ; sum += arr[i]

    adds r3, r3, #1   ; i++
    b    loop
done:
    mov  r0, r2       ; 返回sum
    bx   lr

; ========== 方法B:指针 ==========
; 假设arr在r0,end在r1,sum在r2
sum_array_pointer:
    add  r1, r0, r1   ; end = arr + size(循环外计算一次)
    movs r2, #0       ; sum = 0
loop:
    cmp  r0, r1       ; 比较arr和end
    bge  done

    ldrb r3, [r0]     ; 加载*arr(直接用r0,无需计算)
    add  r2, r2, r3   ; sum += *arr

    adds r0, r0, #1   ; arr++(指针自增)
    b    loop
done:
    mov  r0, r2
    bx   lr

性能差异

  • 数组索引:每次循环需要计算arr + i(一次加法)  
  • 指针方式:指针直接指向当前元素,自增即可  

实测性能(STM32F103 @ 72MHz,数组大小1000):

  • 方法A(数组索引):约14μs  
  • 方法B(指针):约12μs  
  • 提升约14%  

2. 寄存器优化:让指针留在寄存器里

编译器通常会把频繁使用的指针保存在寄存器中,避免反复从内存加载。

// ❌ 低效:全局指针(每次访问都要从内存加载)
volatile uint32_t *global_gpio_odr = (volatile uint32_t *)0x4001080C;

void toggle_led(void) {
    *global_gpio_odr ^= (1 << 5);  // 需要从内存加载global_gpio_odr
}

// ✅ 高效:局部指针(编译器可以把它放在寄存器)
void toggle_led_fast(void) {
    volatile uint32_t *gpio_odr = (volatile uint32_t *)0x4001080C;

    for (int i = 0; i < 1000; i++) {
        *gpio_odr ^= (1 << 5);  // gpio_odr在寄存器中,访问更快
    }
}

// ✅ 更高效:使用register关键字(提示编译器)
void toggle_led_faster(void) {
    register volatile uint32_t *gpio_odr = (volatile uint32_t *)0x4001080C;

    for (int i = 0; i < 1000; i++) {
        *gpio_odr ^= (1 << 5);
    }
}

性能对比

  • 全局指针:需要3-4个时钟周期(加载地址、加载指针、解引用)  
  • 局部指针:需要1-2个时钟周期(指针已在寄存器)  
  • 差距在高频操作时明显  

3. 循环展开 + 指针

对于已知大小的数组,可以展开循环减少开销。

// 普通循环
void clear_buffer_normal(uint32_t *buffer, int size) {
    for (int i = 0; i < size; i++) {
        buffer[i] = 0;
    }
}

// 循环展开(每次处理4个元素)
void clear_buffer_unrolled(uint32_t *buffer, int size) {
    int i;
    for (i = 0; i < size - 3; i += 4) {
        buffer[i]   = 0;
        buffer[i+1] = 0;
        buffer[i+2] = 0;
        buffer[i+3] = 0;
    }

    // 处理剩余的元素
    for (; i < size; i++) {
        buffer[i] = 0;
    }
}

// 更激进的展开 + 指针
void clear_buffer_fastest(uint32_t *buffer, int size) {
    uint32_t *end = buffer + size;
    uint32_t *p = buffer;

    // 每次处理8个元素
    while (p + 8 <= end) {
        p[0] = 0; p[1] = 0; p[2] = 0; p[3] = 0;
        p[4] = 0; p[5] = 0; p[6] = 0; p[7] = 0;
        p += 8;
    }

    // 处理剩余
    while (p < end) {
        *p++ = 0;
    }
}

性能对比(清零1000个uint32_t):

  • 普通循环:约8μs  
  • 展开4次:约6μs(提升25%)  
  • 展开8次:约5μs(提升37%)  

注意:过度展开会增加代码大小,要在速度和空间之间权衡。


🗺️ 从指针到地址映射:理解单片机内存布局

STM32F103的内存映射

地址范围                用途                     访问方式
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
0x00000000-0x1FFFFFFF  代码区
├─ 0x08000000-0x0801FFFF  Flash (128KB)        只读(可编程)
└─ 0x1FFFF000-0x1FFFF7FF  系统内存(Bootloader) 只读

0x20000000-0x3FFFFFFF  SRAM区
└─ 0x20000000-0x20004FFF  SRAM (20KB)           读写

0x40000000-0x5FFFFFFF  外设区
├─ 0x40000000-0x40007FFF  APB1外设
│  ├─ 0x40000000          TIM2
│  ├─ 0x40000400          TIM3
│  └─ 0x40013800          USART1
├─ 0x40010000-0x40017FFF  APB2外设
│  ├─ 0x40010800          GPIOA
│  ├─ 0x40010C00          GPIOB
│  └─ 0x40011000          GPIOC
└─ 0x40020000-0x40023FFF  AHB外设
   └─ 0x40021000          RCC

0xE0000000-0xE00FFFFF  Cortex-M3私有外设
├─ 0xE000E000-0xE000EFFF  NVIC(中断控制器)
├─ 0xE000ED00-0xE000ED8F  SCB(系统控制块)
└─ 0xE000EDF0           SysTick定时器

为什么GPIO在0x40000000开头?

这是ARM Cortex-M架构规定的内存映射标准

  • 0x00000000-0x1FFFFFFF:代码空间(Code Region)  

    • 执行效率最高  
    • Flash映射到这里  
  • 0x20000000-0x3FFFFFFF:SRAM空间(SRAM Region)  

    • 变量、堆、栈都在这里  
    • 访问速度快  
  • 0x40000000-0x5FFFFFFF:外设空间(Peripheral Region)  

    • 所有硬件寄存器在这里  
    • 使用强顺序访问(Strongly-ordered)  
  • 0xE0000000-0xE00FFFFF:系统空间(System Region)  

    • CPU内部的配置寄存器  
    • NVIC、SysTick等  

为什么要这样分区?

  • 性能优化:不同区域可以配置不同的缓存策略  
  • 安全隔离:代码和数据分离,防止错误访问  
  • 标准化:所有Cortex-M芯片遵循相同布局,代码可移植  

地址是如何分配的?

看一个实际的程序:

// 全局变量(在SRAM中)
uint32_t global_var = 42;           // 假设分配到0x20000100

// const全局变量(在Flash中)
const uint32_t const_var = 42;      // 假设分配到0x08001000

// 函数代码(在Flash中)
void my_function(void) {            // 函数入口假设在0x08000200
    // 局部变量(在栈中,栈在SRAM)
    int local_var = 10;             // 假设在0x20004FFC(栈从高地址向下生长)

    // 操作GPIO寄存器(固定地址)
    volatile uint32_t *gpio = (volatile uint32_t *)0x4001080C;
        *gpio = 0x20;
}

内存布局图

Flash (0x08000000开始):
0x08000000 │ 中断向量表
0x08000100 │ ...
0x08000200 │ my_function的代码 ◄─┐
0x08000210 │ ...                  │
0x08001000 │ const_var = 42       │ 这些在Flash中
          │ ...                  │
                                 │
SRAM (0x20000000开始):           │
0x20000000 │ .data段起始          │
0x20000100 │ global_var = 42      │ 这些在RAM中
0x20001000 │ ...                  │
0x20004000 │ 堆(Heap)区           │
0x20004FFC │ local_var = 10 ◄─────┘ (栈向下生长)
0x20005000 │ 栈顶(Stack Top)

链接脚本如何分配地址

链接脚本(.ld文件)告诉链接器如何分配地址:

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 128K
    RAM (rwx)   : ORIGIN = 0x20000000, LENGTH = 20K
}

SECTIONS
{
    /* 代码段:放在Flash */
    .text :
    {
        KEEP(*(.isr_vector))     /* 中断向量表放最前面 */
        *(.text)                 /* 所有函数代码 */
        *(.rodata)               /* 只读数据(const变量) */
    } >FLASH

    /* 初始化数据:存储在Flash,运行时复制到RAM */
    .data :
    {
        _sdata = .;              /* 标记RAM中.data段起始 */
        *(.data)                 /* 有初始值的全局变量 */
        _edata = .;              /* 标记RAM中.data段结束 */
    } >RAM AT>FLASH

    /* 未初始化数据:只在RAM中分配空间 */
    .bss :
    {
        _sbss = .;
        *(.bss)                  /* 无初始值的全局变量 */
        *(COMMON)
        _ebss = .;
    } >RAM
}

启动代码会完成初始化

// startup.c(简化版)
void Reset_Handler(void) {
    // 1. 复制.data段从Flash到RAM
        uint32_t *src = &_sidata;   // Flash中的数据
        uint32_t *dst = &_sdata;    // RAM中的目标位置

        while (dst < &_edata) {
            *dst++ = *src++;
        }

    // 2. 清零.bss段
        dst = &_sbss;
        while (dst < &_ebss) {
            *dst++ = 0;
        }

    // 3. 调用main
        main();
}

🚁 进阶话题:指针与DMA、指针与Cache一致性

这里简要介绍两个高级主题,为后续深入学习埋下伏笔。

1. DMA与指针

DMA(Direct Memory Access)可以在后台传输数据,但需要注意:

问题:DMA和CPU同时访问同一块内存时,可能产生竞争。

// ❌ 危险:DMA正在传输时修改数据
uint8_t tx_buffer[256] = "Data to send";

// 启动DMA传输
DMA_Start(tx_buffer, 256);

// DMA正在传输时,CPU修改buffer
tx_buffer[0] = 'X';  // ⚠️ 竞争条件!DMA可能正在读取这个位置

// 等待传输完成
while (!DMA_Done());

解决方案

  • 使用标志位同步  
  • 使用双缓冲  
  • 使用volatile确保可见性  
// ✅ 安全:等待DMA完成后再修改
volatile int dma_busy = 0;

void start_dma_transfer(uint8_t *data, int size) {
    dma_busy = 1;
    DMA_Start(data, size);
}

void DMA_IRQHandler(void) {
    // DMA完成中断
        dma_busy = 0;
}

void main_loop(void) {
    uint8_t tx_buffer[256];

        strcpy(tx_buffer, "Data");
        start_dma_transfer(tx_buffer, strlen(tx_buffer));

    // 等待DMA完成
        while (dma_busy);

    // 现在可以安全地修改buffer了
        strcpy(tx_buffer, "New data");
}

2. Cache一致性问题

在高端MCU(如STM32F7、H7系列)中,有数据缓存(D-Cache)。如果DMA和Cache同时访问内存,可能导致数据不一致。

问题场景

// STM32H7(有D-Cache)
uint8_t rx_buffer[1024] __attribute__((aligned(32)));

// CPU写入数据到cache
memset(rx_buffer, 0, sizeof(rx_buffer));  // 数据在cache中,还未写回RAM

// 启动DMA(DMA直接访问RAM,看不到cache中的数据)
DMA_Receive(rx_buffer, 1024);  // DMA可能读到旧数据!

解决方案:手动管理Cache

// 在DMA传输前,清理(clean)cache(把cache数据写回RAM)
SCB_CleanDCache_by_Addr((uint32_t *)rx_buffer, sizeof(rx_buffer));

// 启动DMA
DMA_Receive(rx_buffer, 1024);

// 等待DMA完成
while (!DMA_Done());

// DMA完成后,使cache无效(invalidate)(丢弃cache,强制从RAM重新读取)
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buffer, sizeof(rx_buffer));

// 现在可以安全读取rx_buffer了
uint8_t first_byte = rx_buffer[0];  // 从RAM读取的最新数据

更多细节请参考云栈社区的教程:计算机基础


🔑 总结:指针是嵌入式开发的"钥匙"

回顾这篇文章的核心内容:

指针的本质

  • 指针 = 内存地址  
    • 就是一个数字(门牌号)  
    • 解引用 = 根据门牌号找到房间  
    • 在嵌入式中更直观,因为地址对应真实硬件  
  • 寄存器 = 特殊的内存地址  
    • 读写寄存器 = 控制硬件  
    • 指针是访问寄存器的唯一方式  
    • HAL库的底层也是指针操作  

三板斧操作

  • 类型转换(uint32_t *)0x40020000  
  • 解引用读写*ptr = value  
  • volatile修饰volatile uint32_t *ptr  

实战应用

  • GPIO控制:点亮LED  
  • 串口通信:指针遍历字符串  
  • DMA配置:指针告诉硬件数据位置  
  • 中断向量表:函数指针数组  

安全防护

  • 野指针 → 初始化和NULL检查  
  • 指针越界 → 边界检查  
  • 类型不匹配 → 对齐和memcpy  
  • 多线程竞争 → 临界区保护  

性能优化

  • 指针 > 数组索引  
  • 局部指针 > 全局指针  
  • 循环展开 + 指针  
  • 寄存器优化  

内存布局

  • Flash:代码和只读数据(0x08000000)  
  • RAM:变量、堆、栈(0x20000000)  
  • 外设:寄存器(0x40000000)  
  • 系统:CPU配置(0xE0000000)  

💪 结语:从恐惧到掌控

如果你在阅读这篇文章之前对指针感到害怕,我希望现在你能改变这个想法。

指针不是魔法,它只是内存地址的另一个名字。在PC编程中,指针看起来抽象,因为它们被操作系统和抽象层隐藏了。但在嵌入式系统中,指针恰恰是最直接、最清晰的硬件控制方式。

当你写下这样的代码:

volatile uint32_t *gpio_odr = (volatile uint32_t *)0x4001080C;
*gpio_odr |= (1 << 5);

你在做的不是什么高深的魔术,你只是:

  1. 拿到GPIO寄存器的"遥控器"(地址0x4001080C)  
  2. 按下"开关"按钮(设置bit 5)  
  3. LED就亮了!  

这就是嵌入式编程的魅力——你的代码直接控制真实的硬件,没有中间商,没有抽象层。而指针,就是你手中的"万能遥控器"。

接下来的学习建议

  • 动手实践:在开发板上尝试本文的所有例子  
  • 阅读datasheet:查阅芯片手册,了解更多寄存器  
  • 调试实验:故意制造指针错误,学会调试HardFault  
  • 深入学习:阅读云栈社区的其他进阶文章  

记住:掌握指针,就掌握了直接操控硬件的能力。这是成为嵌入式高手的必经之路。

从今天开始,不要再怕指针。它是你的朋友,是你的工具,是你在嵌入式世界中的"钥匙"。




上一篇:MT798x路由器刷机指南:编译与刷入支持Web刷机的多功能U-Boot
下一篇:从ChatGPT到AI代理:我们离“就它了”的体验还有多远?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-28 18:12 , Processed in 0.482504 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表