5. 指针与多维数组:二维数组的内存布局、行指针与列指针

多维数组,尤其是二维数组,是很多C语言学习者的一道坎。说实话,我见过不少工作两三年的嵌入式工程师,写代码时碰到二维数组传参还会懵一下。嗯,这其实不丢人,因为二维数组在内存里的布局和指针的对应关系,确实需要一点空间想象力。

今天我们就把它彻底讲透。你想想看,二维数组说白了就是一维数组的数组。这句话听起来像废话,但如果你真能理解它,后面所有关于行指针、列指针的问题,都会迎刃而解。

5.1 二维数组的内存布局

先看一个最简单的例子:

int arr[3][4];  // 3行4列的二维数组

这个数组在内存中是怎么存的?按行优先存储。也就是说,先存第0行的4个元素,再存第1行的4个元素,最后存第2行的4个元素。整个数组占用连续的一块内存空间,总共 3 × 4 × sizeof(int) 个字节。

我画了一张图,帮你直观理解这个布局:

二维数组 int arr[3][4] 内存布局 arr[0][0] arr[0][1] arr[0][2] arr[0][3] arr[1][0] arr[1][1] arr[1][2] arr[1][3] arr[2][0] arr[2][1] arr[2][2] arr[2][3] 地址: 0x1000 0x1004 0x1008 0x100C 0x1010 0x1014 0x1018 0x101C 行0 行1 行2 连续内存

看到这张图了吗?整个数组在内存中是线性排列的,没有行与行之间的空隙。这一点非常重要——很多新手以为二维数组在内存里是「网格状」的,其实不是,它就是一条长线,只不过我们通过两个下标把它逻辑上分成了行和列。

核心结论:二维数组 arr[M][N] 在内存中占用 M × N × sizeof(元素类型) 个连续字节。arr[i][j] 的地址 = 起始地址 + (i × N + j) × sizeof(元素类型)。

5.2 行指针与列指针

好,现在我们来聊聊指针。二维数组的指针问题,说白了就是搞清楚「指向一维数组的指针」和「指向普通元素的指针」的区别。

先看几个关键表达式:

int arr[3][4];
int (*p)[4];  // 行指针:指向包含4个int的一维数组
int *q;       // 列指针:指向int类型

这里 p 是行指针,q 是列指针。它们有什么区别?

  • 行指针 p:p = arr,p 指向第0行(一个长度为4的int数组)。p+1 跳过一整行(4个int)。
  • 列指针 q:q = &arr[0][0],q 指向一个int元素。q+1 跳过1个int。

我个人习惯把行指针想象成「指向行的指针」,它把二维数组的每一行当作一个整体。而列指针就是普通的int指针,它只关心单个元素。

记忆技巧:声明行指针时,int (*p)[4],括号是必须的。如果写成 int *p[4],那就变成了指针数组——4个int指针,完全不是一回事。我当年刚学的时候也搞混过,后来每次写都默念一遍「括号优先,行指针;没括号,指针数组」。

5.3 通过指针访问二维数组元素

有了行指针和列指针,我们就可以用多种方式访问二维数组的元素了。看代码:

#include <stdio.h>

int main() {
    int arr[3][4] = {
        {1, 2, 3, 4},
        {5, 6, 7, 8},
        {9, 10, 11, 12}
    };
    
    // 方式1:直接用下标
    printf("arr[1][2] = %d\n", arr[1][2]);  // 7
    
    // 方式2:用行指针 + 列下标
    int (*row_ptr)[4] = arr;
    printf("row_ptr[1][2] = %d\n", row_ptr[1][2]);  // 7
    
    // 方式3:用列指针 + 偏移量
    int *col_ptr = &arr[0][0];
    printf("*(col_ptr + 1*4 + 2) = %d\n", *(col_ptr + 1*4 + 2));  // 7
    
    // 方式4:用行指针解引用 + 列指针
    printf("*(*(row_ptr + 1) + 2) = %d\n", *(*(row_ptr + 1) + 2));  // 7
    
    return 0;
}

这四种方式都能访问到 arr[1][2]。你可能会问,为什么要有这么多写法?

嗯,在实际项目中,情况往往比较复杂。比如你要写一个函数,处理一个行数和列数都不固定的二维数组。这时候,用行指针传参就非常方便:

void print_matrix(int rows, int cols, int (*matrix)[cols]) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%d ", matrix[i][j]);
        }
        printf("\n");
    }
}

int main() {
    int arr[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};
    print_matrix(3, 4, arr);
    return 0;
}

注意看,这里的函数参数 int (*matrix)[cols] 是一个行指针,它要求传入的数组每行必须有 cols 个元素。C99 之后支持变长数组(VLA),所以 cols 可以是变量。这在嵌入式开发中非常实用——比如你要处理一个传感器数据矩阵,行数和列数由运行时参数决定。

避坑指南:我曾经在一个项目中,需要把二维数组传给一个函数。我一开始写的是 void func(int **matrix),结果编译通过,运行时却崩溃了。为什么?因为 int ** 是指向指针的指针,而二维数组名 arr 的类型是 int (*)[4],两者完全不兼容。正确的做法是用行指针,或者把二维数组「降维」成一维指针处理。

5.4 行指针与列指针的算术运算对比

为了让你更清楚地看到区别,我整理了一个表格:

操作 行指针 p (int (*p)[4]) 列指针 q (int *q)
p / q 的值 指向第0行(地址 = &arr[0]) 指向 arr[0][0](地址 = &arr[0][0])
p+1 / q+1 跳过 4×sizeof(int) 字节,指向第1行 跳过 sizeof(int) 字节,指向 arr[0][1]
解引用 *p / *q 得到第0行的首地址(相当于 &arr[0][0]) 得到 arr[0][0] 的值
访问元素 p[i][j] 或 *(*(p+i)+j) *(q + i*4 + j)

看到区别了吗?行指针的步长是一整行,列指针的步长是一个元素。这就是为什么行指针也叫「行地址」,列指针也叫「元素地址」。

5.5 实际项目中的经验

我在做嵌入式图像处理时,经常需要操作像素矩阵。比如一个 320×240 的灰度图像,就是一个 unsigned char img[240][320]。处理这种数据时,我通常会用行指针来遍历:

void process_image(unsigned char (*img)[320], int rows) {
    for (int i = 0; i < rows; i++) {
        unsigned char *row = img[i];  // 拿到第i行的列指针
        for (int j = 0; j < 320; j++) {
            row[j] = row[j] > 128 ? 255 : 0;  // 二值化处理
        }
    }
}

这种写法既清晰又高效。你想想看,如果我用 unsigned char **img 来传参,不仅类型不匹配,而且每次访问都要做两次间接寻址,性能上也有损失。

总结一下:

  • 二维数组在内存中是连续线性存储的,按行优先排列。
  • 行指针 int (*p)[N] 指向一维数组,步长为一整行。
  • 列指针 int *p 指向普通元素,步长为单个元素。
  • 函数传参时,用行指针比用 int ** 更安全、更符合类型系统。
  • 访问元素时,arr[i][j] 等价于 *(*(arr+i)+j),也等价于 *(col_ptr + i*N + j)

嗯,二维数组和指针的关系,说白了就是「用一维的视角理解二维的布局」。一旦你接受了这个设定,后面再遇到三维数组、动态分配的二维数组,思路都是一样的。记住:内存是线性的,多维只是我们赋予它的逻辑结构


公众号:蓝海资料掘金营,微信deep3321