《C语言指针结构体与寄存器操作入门实战》

第四节:指针与数组——数组名与指针的关系、指针算术、数组遍历与传参

数组与指针是C语言中联系最紧密、也最容易混淆的一对概念。很多初学者认为“数组名就是指针”,这种说法并不完全准确。数组名在表达式中会“退化”为指向首元素的指针,但数组名本身并不是一个指针变量,它没有独立的存储空间,也不能被赋值。理解数组名与指针的微妙关系、掌握指针算术的规则,是后续进行寄存器地址映射、DMA缓冲区操作、结构体数组遍历等嵌入式开发的基础。本节将从核心原理出发,逐步拆解数组与指针的等价性、指针移动的字节计算、以及数组作为函数参数时的真实传递机制。

数组名与指针关系、指针算术、数组遍历与传参 int arr[5] 数组名 arr 类型: int[5] arr[0] arr[1] arr[2] arr[3] arr[4] 连续内存,每个元素4字节 int *p = arr; 指针变量 p 类型: int* p 变量本身 存储的是arr[0]的地址 赋值/指向 指针算术规则 p+1 → 地址+1*sizeof(int) p+i → 地址+i*sizeof(int) *(p+i) ≡ arr[i] p++ 移动一个元素,非一个字节 注意:p+1 与 (char*)p+1 不同 数组作为函数参数时的退化 void func(int arr[]) → 实际等价于 void func(int *arr) sizeof(arr) 在函数内 = 8(64位系统指针大小),不是数组总字节数 必须额外传递数组长度参数,例如 void func(int *arr, int len) 二维数组传参:void func(int (*mat)[4], int rows) 或 void func(int mat[][4], int rows)

核心原理:数组名是常量地址,指针是变量地址

在C语言中,数组名代表数组首元素的地址,且这个地址是一个常量,不能修改。例如 int arr[5],arr 的类型是 int[5],但在大多数表达式中,arr 会“退化”为 int* 类型的指针值,指向 arr[0]。关键区别在于:arr 不是一个变量,它没有自己的存储空间,不能执行 arr++ 或 arr = p 这样的操作。而指针变量 p 有独立的存储空间,可以重新指向其他地址。

从编译器的角度看,arr 在符号表中被解析为一个地址常量,而 p 是一个需要从内存中读取其值的变量。这种差异在嵌入式开发中尤为重要:当你操作寄存器映射地址时,通常使用 #define REG_BASE ((volatile uint32_t*)0x40000000) 这样的宏定义,它本质上是一个常量指针,而不是指针变量,这样可以避免指针被意外修改。

核心结论:

  • 数组名是地址常量,不是指针变量,不能自增自减或赋值。
  • 指针变量可以改变指向,但必须确保指向合法的内存区域。
  • 在表达式中,数组名自动转换为指向首元素的指针,但 sizeof(arr) 返回整个数组的字节数,而 sizeof(p) 返回指针本身的大小(64位系统为8字节)。

指针算术:移动的单位是元素,不是字节

指针算术是C语言中高效遍历数组的核心机制。指针加1,并不是地址值加1,而是加上指针所指类型的大小。例如 int *p 指向 int 类型,p+1 的地址值增加 4(假设 int 为4字节);char *cp 指向 char 类型,cp+1 的地址值增加 1。这种设计使得指针算术与数组下标天然对应:*(p+i) 等价于 arr[i],且编译器生成的机器码效率相同。

在实际开发中,指针算术常用于遍历数组、操作缓冲区、解析数据包。例如,在串口接收中断中,我们可能用指针遍历接收缓冲区:

uint8_t rx_buf[128];
uint8_t *p = rx_buf;
for (int i = 0; i < 128; i++) {
    process_byte(*p);  // 读取当前字节
    p++;               // 移动到下一个字节,地址+1
}

注意,如果指针类型是 uint32_t*,则 p++ 地址增加4字节。这种差异在内存对齐和硬件寄存器操作中非常关键。例如,操作一个32位寄存器数组时,使用 volatile uint32_t* 指针遍历,每次移动4字节,正好对应一个寄存器地址。

操作 int *p (int为4字节) char *cp (char为1字节) uint32_t *rp (uint32_t为4字节)
p+1 / cp+1 / rp+1 地址 +4 地址 +1 地址 +4
p++ / cp++ / rp++ 地址 +4 地址 +1 地址 +4
p - q (两指针相减) 返回元素个数,即 (地址差)/4 返回字节数 返回元素个数
比较 p < q 按地址值大小比较 按地址值大小比较 按地址值大小比较

风险提示:指针算术不会检查边界。如果 p 指向数组末尾之后,再执行 p++ 或访问 *p,属于未定义行为,可能导致硬件错误(如 HardFault)。在嵌入式开发中,务必在循环中显式判断索引或指针是否越界。

数组遍历与传参:退化的本质与长度传递

当数组作为函数参数时,C语言会将其退化为指针。这意味着 void func(int arr[]) 和 void func(int *arr) 在编译层面完全等价。函数内部无法通过 sizeof(arr) 获取数组长度,因为 arr 已经是一个指针变量。因此,必须额外传递数组长度参数,或者使用结束标志(如字符串的 '\0')。

对于二维数组,传参规则更为严格。例如 int mat[3][4],作为参数时退化为 int (*mat)[4],即指向包含4个int元素的数组的指针。此时必须指定第二维的大小,否则编译器无法计算行偏移。在嵌入式开发中,操作图像矩阵或传感器数据帧时,这种传参方式非常常见。

// 正确的一维数组传参
void sum_array(int *arr, int len) {
    int sum = 0;
    for (int i = 0; i < len; i++) {
        sum += arr[i];
    }
}

// 正确的二维数组传参(列数必须指定)
void process_matrix(int (*mat)[4], int rows) {
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < 4; c++) {
            mat[r][c] *= 2;
        }
    }
}

案例拆解:用指针算术解析ADC采样缓冲区

假设一个嵌入式系统使用ADC连续采样,DMA将采样结果存入一个16位数组 uint16_t adc_buf[256]。我们需要计算所有采样值的平均值,并找出最大值和最小值。这个案例综合运用了数组名退化、指针算术和数组遍历。

背景与约束:系统时钟频率为72MHz,ADC采样率为1kHz,DMA每次传输256个采样点后触发中断。中断处理时间必须小于1ms,否则会丢失下一次DMA传输。因此,遍历和计算必须高效。

初始现象:初版代码使用数组下标遍历,功能正确但耗时约0.8ms,接近中断处理时限。我们希望优化到0.5ms以内。

关键输入数据:adc_buf 数组首地址为 0x20000000,每个元素为16位(2字节)。

分析判断过程:使用指针遍历可以减少每次循环的索引计算和乘法操作。编译器对 arr[i] 的访问本质上是 *(arr + i),但使用指针自增可以避免每次计算偏移量。此外,我们可以使用 uint16_t *p 直接遍历,每次移动2字节。

实际操作步骤:

  1. 定义指针 uint16_t *p = adc_buf;,定义结束指针 uint16_t *end = adc_buf + 256;。
  2. 使用 while (p < end) 循环,在循环体内读取 *p,然后 p++。
  3. 累加求和,同时比较更新最大值和最小值。
uint32_t sum = 0;
uint16_t max_val = 0, min_val = 0xFFFF;
uint16_t *p = adc_buf;
uint16_t *end = adc_buf + 256;

while (p < end) {
    uint16_t val = *p;
    sum += val;
    if (val > max_val) max_val = val;
    if (val < min_val) min_val = val;
    p++;
}
uint16_t avg = (uint16_t)(sum / 256);

结果对比:优化后循环耗时约0.4ms,满足中断处理时限。指针版本比下标版本快约50%,因为减少了每次循环的索引计算和地址偏移计算。

风险与代价:指针遍历需要小心结束条件,如果 end 指针计算错误,可能越界访问。此外,如果数组元素类型改变(如改为32位),必须同步修改指针类型,否则指针算术会出错。

复盘经验:在嵌入式开发中,指针算术是性能优化的有效手段,但必须确保类型匹配和边界正确。对于DMA缓冲区这类连续内存,使用指针遍历是标准做法。同时,建议使用 volatile 关键字修饰指向硬件寄存器的指针,防止编译器优化掉必要的读取操作。

经验提醒:当数组作为函数参数时,尽量同时传递长度参数,避免在函数内部使用 sizeof 计算长度。对于硬件寄存器映射,建议使用 volatile 指针,并确保指针类型与寄存器宽度一致(如 volatile uint32_t* 对应32位寄存器)。

常见误区与总结复盘

第一个常见误区是认为 sizeof(arr) 在函数内也能返回数组长度。实际上,数组作为参数退化为指针后,sizeof 返回的是指针大小。第二个误区是混淆 arr++ 与 p++,前者非法,后者合法。第三个误区是在指针算术中忽略了类型大小,例如将 int* 指针加1误认为地址加1字节,导致访问错误的内存位置。

总结本节要点:数组名是地址常量,指针是地址变量;指针算术以元素大小为单位移动;数组传参时退化为指针,必须显式传递长度;在嵌入式开发中,指针算术是操作缓冲区、寄存器映射和DMA数据的核心技能。掌握这些规则,可以为后续学习结构体指针、函数指针和复杂数据结构打下坚实基础。