5. 指针与多维数组:二维数组的内存布局、行指针与列指针
多维数组,尤其是二维数组,是很多C语言学习者的一道坎。说实话,我见过不少工作两三年的嵌入式工程师,写代码时碰到二维数组传参还会懵一下。嗯,这其实不丢人,因为二维数组在内存里的布局和指针的对应关系,确实需要一点空间想象力。
今天我们就把它彻底讲透。你想想看,二维数组说白了就是一维数组的数组。这句话听起来像废话,但如果你真能理解它,后面所有关于行指针、列指针的问题,都会迎刃而解。
5.1 二维数组的内存布局
先看一个最简单的例子:
int arr[3][4]; // 3行4列的二维数组
这个数组在内存中是怎么存的?按行优先存储。也就是说,先存第0行的4个元素,再存第1行的4个元素,最后存第2行的4个元素。整个数组占用连续的一块内存空间,总共 3 × 4 × sizeof(int) 个字节。
我画了一张图,帮你直观理解这个布局:
看到这张图了吗?整个数组在内存中是线性排列的,没有行与行之间的空隙。这一点非常重要——很多新手以为二维数组在内存里是「网格状」的,其实不是,它就是一条长线,只不过我们通过两个下标把它逻辑上分成了行和列。
核心结论:二维数组 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