大家好,我是良许。
今天我们来聊一聊 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 = # // 一级指针
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 语言的指针,在嵌入式开发的道路上走得更远。