C语言的指针

0 阅读9分钟

大家好,我是良许。

今天我们来聊一聊 C 语言中最让初学者头疼,却又最强大的特性——指针。

作为一名从事嵌入式开发多年的程序员,我深知指针在底层编程中的重要性。

无论是操作硬件寄存器、管理动态内存,还是实现高效的数据结构,指针都扮演着不可或缺的角色。

1. 什么是指针

1.1 指针的本质

指针其实就是一个变量,只不过这个变量存储的不是普通的数值,而是内存地址。

我们可以把内存想象成一排排的房间,每个房间都有一个门牌号(地址),而指针就是记录这个门牌号的本子。

通过这个门牌号,我们可以找到对应的房间,进而访问或修改房间里的内容。

在嵌入式开发中,这个概念尤为重要。比如 STM32 的 GPIO 端口,其实就是通过固定的内存地址来访问的。

当我们要点亮一个 LED 灯时,本质上就是通过指针操作特定地址的寄存器。

1.2 为什么需要指针

指针的存在主要解决了以下几个问题:

第一,高效传递数据。

当我们需要在函数之间传递大型数据结构时,如果直接传递整个结构体,会产生大量的复制开销。

而使用指针,只需要传递一个地址(通常是 4 字节或 8 字节),效率大大提升。

第二,动态内存管理。

在嵌入式系统中,内存资源往往非常有限。

通过指针和动态内存分配,我们可以在程序运行时根据实际需要申请和释放内存,提高内存利用率。

第三,直接操作硬件。

在嵌入式开发中,我们经常需要直接访问硬件寄存器。

这些寄存器都有固定的物理地址,必须通过指针来访问。

2. 指针的基本使用

2.1 指针的声明和初始化

声明一个指针变量的语法是在类型名后面加上星号(*)。例如:

int *p;        // 声明一个指向整型的指针
char *str;     // 声明一个指向字符的指针
float *fp;     // 声明一个指向浮点数的指针

需要注意的是,刚声明的指针是野指针,它指向一个不确定的地址,使用前必须初始化。

我们可以用取地址符(&)来获取变量的地址:

int num = 100;
int *p = #  // p指向num的地址
​
printf("num的值: %d\n", num);
printf("num的地址: %p\n", &num);
printf("p存储的地址: %p\n", p);
printf("p指向的值: %d\n", *p);

这段代码会输出 num 的值、num 的地址、指针 p 存储的地址(与 num 的地址相同),以及通过指针 p 访问到的值(也是 100)。

2.2 指针的解引用

解引用就是通过指针访问它所指向的内存中的值。

使用星号(*)操作符可以实现解引用:

int a = 50;
int *ptr = &a;
​
printf("a的值: %d\n", a);        // 输出50
printf("*ptr的值: %d\n", *ptr);  // 输出50
​
*ptr = 80;  // 通过指针修改a的值
printf("修改后a的值: %d\n", a);  // 输出80

在这个例子中,我们通过指针 ptr 修改了变量 a 的值。

这在函数参数传递中非常有用,可以实现真正的"传址调用"。

2.3 指针与函数

在 C 语言中,函数参数默认是值传递,也就是说函数内部对参数的修改不会影响外部变量。

但通过指针,我们可以实现传址调用:

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}
​
int main(void) {
    int x = 10, y = 20;
    printf("交换前: x=%d, y=%d\n", x, y);
    
    swap(&x, &y);
    printf("交换后: x=%d, y=%d\n", x, y);
    
    return 0;
}

这个经典的交换函数例子展示了指针的威力。

通过传递变量的地址,函数内部可以直接修改外部变量的值。

3. 指针的进阶应用

3.1 指针与数组

数组名本身就是一个指针常量,指向数组的首元素。

这是 C 语言中一个非常重要的概念:

int arr[5] = {1, 2, 3, 4, 5};
int *p = arr;  // 等价于 int *p = &arr[0];
​
printf("arr[0] = %d\n", arr[0]);    // 输出1
printf("*p = %d\n", *p);            // 输出1
printf("*(p+1) = %d\n", *(p+1));    // 输出2
printf("p[2] = %d\n", p[2]);        // 输出3

指针可以进行算术运算。

当指针加 1 时,实际上是移动了一个所指类型的大小。

比如 int 类型占 4 字节,那么 p+1 实际上是地址增加 4。

在嵌入式开发中,这个特性经常用于遍历数据缓冲区:

uint8_t buffer[256];
uint8_t *ptr = buffer;
​
// 通过指针遍历整个缓冲区
for(int i = 0; i < 256; i++) {
    *ptr = i;  // 写入数据
    ptr++;     // 指针移动到下一个位置
}

3.2 指针与字符串

在 C 语言中,字符串实际上就是字符数组,而字符串的操作大量使用指针:

char str[] = "Hello";
char *p = str;
​
while(*p != '\0') {
    printf("%c", *p);
    p++;
}
printf("\n");

这段代码通过指针遍历字符串并逐个打印字符。

在实际开发中,我们经常需要处理字符串,比如解析串口接收到的 AT 指令:

void parse_at_command(char *cmd) {
    if(strncmp(cmd, "AT+", 3) == 0) {
        char *param = cmd + 3;  // 指针偏移到参数部分
        printf("收到AT指令,参数: %s\n", param);
    }
}

3.3 多级指针

指针本身也是变量,也有自己的地址,因此可以有指向指针的指针,称为多级指针:

int num = 100;
int *p = &num;      // 一级指针
int **pp = &p;      // 二级指针

printf("num = %d\n", num);
printf("*p = %d\n", *p);
printf("**pp = %d\n", **pp);

**pp = 200;  // 通过二级指针修改num的值
printf("修改后num = %d\n", num);

多级指针在动态二维数组、函数指针数组等场景中很常见。

在嵌入式开发中,有时需要动态管理设备列表,就会用到二级指针。

4. 指针在嵌入式中的实战应用

4.1 操作硬件寄存器

在 STM32 开发中,我们经常需要直接操作寄存器。

这些寄存器都有固定的物理地址,必须通过指针访问:

// 定义GPIO端口的基地址
#define GPIOA_BASE    0x40020000U
#define GPIOA_MODER   (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_ODR     (*(volatile uint32_t *)(GPIOA_BASE + 0x14))

// 配置PA5为输出模式
void led_init(void) {
    // 使能GPIOA时钟
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;

    // 配置PA5为输出模式
    GPIOA_MODER &= ~(3U << (5 * 2));  // 清除原配置
    GPIOA_MODER |= (1U << (5 * 2));   // 设置为输出
}

// 点亮LED
void led_on(void) {
    GPIOA_ODR |= (1U << 5);
}

// 熄灭LED
void led_off(void) {
    GPIOA_ODR &= ~(1U << 5);
}

这里的 volatile 关键字非常重要,它告诉编译器这个变量可能被外部因素改变,不要对其进行优化。

在访问硬件寄存器时必须使用 volatile 修饰。

4.2 DMA 数据传输

在使用 STM32 的 DMA 功能时,我们需要指定源地址和目标地址,这都是通过指针实现的:

uint8_t tx_buffer[128];
uint8_t rx_buffer[128];

void dma_uart_init(void) {
    // 配置DMA
    hdma_usart1_tx.Instance = DMA2_Stream7;
    hdma_usart1_tx.Init.Channel = DMA_CHANNEL_4;
    hdma_usart1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma_usart1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_usart1_tx.Init.MemInc = DMA_MINC_ENABLE;

    HAL_DMA_Init(&hdma_usart1_tx);
}

void send_data_via_dma(void) {
    // 通过DMA发送数据,传递缓冲区指针
    HAL_UART_Transmit_DMA(&huart1, tx_buffer, sizeof(tx_buffer));
}

4.3 动态内存管理

在嵌入式系统中,虽然要谨慎使用动态内存,但在某些场景下确实需要:

#include <stdlib.h>

typedef struct {
    uint8_t id;
    uint16_t data;
    uint32_t timestamp;
} sensor_data_t;

sensor_data_t* create_sensor_data(uint8_t id) {
    sensor_data_t *data = (sensor_data_t*)malloc(sizeof(sensor_data_t));
    if(data != NULL) {
        data->id = id;
        data->data = 0;
        data->timestamp = HAL_GetTick();
    }
    return data;
}

void process_sensor(void) {
    sensor_data_t *sensor = create_sensor_data(1);
    if(sensor != NULL) {
        // 处理传感器数据
        sensor->data = read_sensor();

        // 使用完毕后释放内存
        free(sensor);
    }
}

需要注意的是,在嵌入式系统中使用动态内存要特别小心,因为频繁的 malloc 和 free 可能导致内存碎片,影响系统稳定性。

5. 指针使用的注意事项

5.1 野指针问题

野指针是指向未知内存区域的指针,使用野指针会导致程序崩溃或产生不可预测的行为:

int *p;  // 野指针,未初始化
*p = 10; // 危险!可能导致程序崩溃

// 正确做法
int *p = NULL;  // 初始化为NULL
if(p != NULL) {
    *p = 10;
}

在使用指针前,一定要确保它已经被正确初始化。

养成将指针初始化为 NULL 的习惯,并在使用前检查是否为 NULL。

5.2 内存泄漏

动态分配的内存如果忘记释放,就会造成内存泄漏:

void memory_leak_example(void) {
    int *p = (int*)malloc(sizeof(int) * 100);
    // 使用p
    // 忘记调用free(p),造成内存泄漏
}

// 正确做法
void correct_example(void) {
    int *p = (int*)malloc(sizeof(int) * 100);
    if(p != NULL) {
        // 使用p
        free(p);
        p = NULL;  // 释放后置为NULL
    }
}

5.3 悬空指针

当指针指向的内存被释放后,如果继续使用该指针,就会产生悬空指针问题:

int *p = (int*)malloc(sizeof(int));
*p = 100;
free(p);
// p现在是悬空指针
*p = 200;  // 危险!访问已释放的内存

// 正确做法
free(p);
p = NULL;  // 释放后立即置为NULL

6. 总结

指针是 C 语言的精髓,也是嵌入式开发的基石。

虽然初学时可能觉得难以理解,但只要多加练习,理解其本质(就是内存地址),就能逐渐掌握。

在我多年的嵌入式开发经验中,指针无处不在:从操作硬件寄存器到管理数据结构,从函数参数传递到实现复杂算法,都离不开指针。

掌握指针不仅能让你写出更高效的代码,还能帮助你深入理解计算机的工作原理。

特别是在嵌入式领域,对指针的熟练运用直接关系到能否写出高质量的底层代码。

希望这篇文章能帮助大家更好地理解和使用 C 语言的指针,在嵌入式开发的道路上走得更远。