[开发工具] uint8_t arr[3][4]——这行你写了无数次的代码,真的懂了吗
写给在单片机上写代码、但还没彻底搞清楚二维数组在内存里到底长什么样的朋友。全文从最底层的存储原理出发,一步步推导到实际工程中你会遇到的每一个坑。
从一个最常见的场景说起
做嵌入式的人,几乎每天都在跟二维数组打交道。字模数据、LCD 帧缓冲、按键状态矩阵、ADC 采样历史……随便翻一个项目出来,总能找到类似这样的声明:
const uint8_t font_8x16[96][16] = { /* ... */ };
uint8_t lcd_buffer[64][128];
uint8_t key_state[4][4];
uint16_t adc_history[8][16];
语法层面谁都会写,但如果我问你下面这几个问题,你能不能不假思索地答出来?
lcd_buffer[30][100]这个元素,在 SRAM 里距离数组起始地址偏移了多少字节?编译器是怎么算出来的?font_8x16加了const,它到底放在 Flash 还是 RAM?如果不加const呢?- 我用两层
for循环遍历lcd_buffer,先遍历行和先遍历列,性能上有区别吗? - 把
uint8_t arr[4][4]传给一个uint8_t **p形参的函数,为什么编译器会报警告?运行时为什么大概率会 HardFault?
如果你对上述任何一个问题有过犹豫,这篇文章就是为你写的。我们一个一个来,从最底层讲起,每一步都讲清楚"为什么是这样"。
第一部分:二维数组在内存里到底长什么样
1.1 硬件不认"二维"
先说一个最根本的事实:MCU 的地址空间是一条线性的字节流。从 0x00000000 到 0xFFFFFFFF(理论上),每一个地址对应一个字节,排成一条长长的队列。硬件层面不存在"第几行第几列"这种概念,它只认识"从基地址偏移多少个字节"。
所以,当你在 C 语言里写下 uint8_t arr[3][4] 的时候,编译器要做的第一件事,就是把你脑子里那个 3 行 4 列的"表格",拍扁成一条直线,塞进 12 个连续的字节里。
1.2 行优先:C 语言的唯一选择
拍扁的方式不是随意的。C 语言标准规定,二维数组必须按**行优先(row-major order)**存储。什么意思?就是把第一行的所有元素先放完,再放第二行的,再放第三行的……依次类推。
拿一个具体的例子来看:
uint8_t arr[3][4] = {
{0xA0, 0xA1, 0xA2, 0xA3}, // 第 0 行
{0xB0, 0xB1, 0xB2, 0xB3}, // 第 1 行
{0xC0, 0xC1, 0xC2, 0xC3} // 第 2 行
};
它在内存里的实际排布是这样的:

内存中的实际排布(连续 12 字节)
绿色 = 第 0 行,蓝色 = 第 1 行,橙色 = 第 2 行。
12 个字节首尾相接,中间没有任何"行分隔符"、没有元数据、没有行索引表。所谓"行"和"列",完全是编译器和程序员脑子里的逻辑划分,硬件只看到一长条字节流。
1.3 寻址公式:一切的核心
既然内存是一条线,那 arr[i][j] 这个表达式是怎么定位到具体某个字节的?答案是一个简单的数学公式。
对于 T arr[M][N],访问 arr[i][j] 时,编译器生成的地址计算是:
addr(arr[i][j]) = base + (i * N + j) * sizeof(T)
其中 base 是数组的起始地址,N 是列数,sizeof(T) 是单个元素的字节大小。
拿上面的 uint8_t arr[3][4] 来验证一下:
arr[0][0]→base + (0*4 + 0) * 1 = base + 0→ 偏移 0,内容0xA0✓arr[1][2]→base + (1*4 + 2) * 1 = base + 6→ 偏移 6,内容0xB2✓arr[2][3]→base + (2*4 + 3) * 1 = base + 11→ 偏移 11,内容0xC3✓
完全对得上。
这个公式里出现的是列数 N,而不是行数 M。 这一点极其重要,后面你会看到它直接决定了函数传参时为什么必须指定列数、为什么行优先遍历比列优先遍历快。记住它,后面所有的"奇怪现象"都能用它来解释。
1.4 一个有意思的推论:越界访问为什么不报错
C 语言不做数组越界检查,这是常识。但结合行优先存储的原理,你会发现越界访问的行为其实是可以精确预测的。
比如 arr[0][5],编译器不会报错(它只管算地址),实际算出来的偏移是 0*4 + 5 = 5,对应的是第 1 行的 0xB1。从逻辑上说,你访问的是"第 0 行第 5 列",但第 0 行只有 4 列(下标 0~3),所以这个访问越界了。但因为内存是连续的,偏移 5 处确实有一个合法的字节,程序照常运行——只是你拿到的数据不是你以为的那个。
更极端一点,arr[0][12] 算出来偏移 12,已经超出了整个数组的 12 个字节(偏移 0~11),这时候访问的就是数组后面的内存了。如果那块内存恰好属于别的变量,你就悄悄改了别人的数据;如果那块内存根本不可访问,就 HardFault。
第二部分:这些字节到底放在哪里
知道了二维数组在内存里是"一条直线"之后,下一个问题是:这条直线放在 MCU 的哪个存储区域?Flash 还是 RAM?这取决于你怎么声明它。
2.1 const 数组 → Flash(ROM)
const uint8_t font_8x16[96][16] = { /* 字模数据 */ };
加了 const 修饰,编译器会把这个数组放进 .rodata 段(只读数据段),链接器最终把它映射到 Flash 地址空间。程序运行期间,这些数据直接从 Flash 读取,不占用 RAM。
这在嵌入式里非常常见。字模、查找表、协议常量表、配置参数……所有"初始化之后就不会再改"的数据,都应该加 const,让它们在 Flash 里安家。MCU 的 RAM 通常只有几 KB 到几十 KB,能省一点是一点。
2.2 非 const、有初始值 → RAM 的 .data 段
uint8_t config_table[4][8] = {
{1, 2, 3, 4, 5, 6, 7, 8},
/* ... */
};
没有 const,但有初始值。这种数组放在 RAM 的 .data 段。它的工作方式是:初始值在编译时被存到 Flash 里(作为加载映像的一部分),芯片上电后,启动代码(通常是 startup.s 里的 __data_init 之类的函数)负责把这些初始值从 Flash 拷贝到 RAM 的对应位置。
所以,一个有初始值的非 const 二维数组,实际上同时占用了 Flash(存初始值)和 RAM(存运行时副本)两份空间。如果你不关心初始值,或者可以在运行时初始化,那直接不写初始值会更省 Flash。
2.3 非 const、无初始值 → RAM 的 .bss 段
uint8_t lcd_buffer[64][128]; // 8192 字节
没有 const,也没有初始值。这种数组放在 RAM 的 .bss 段。启动代码会把 .bss 段整体清零(这是 C 标准的要求:未初始化的全局/静态变量,值为零)。
注意,.bss 段不占用 Flash 空间——它只在链接时记录一个大小,上电后由启动代码在 RAM 里划出这么一大块并清零。所以,对于大的缓冲区(比如 LCD 帧缓冲),不加初始值是更合理的做法,既省 Flash,又符合语义。
2.4 局部变量(函数内的二维数组)→ 栈
void some_function(void) {
uint8_t temp[16][16]; // 256 字节,在栈上
// ...
}
函数内部声明的二维数组,分配在栈上。函数返回时自动释放。这个后面专门有一节来讲栈溢出的问题,这里先记住这个结论。
2.5 小结

第三部分:遍历顺序为什么重要
这一节要讲的事情,很多写了好几年单片机代码的人都没注意过,但它对性能的影响是实实在在的。
3.1 两种遍历方式
假设你要把 lcd_buffer[64][128] 全部清零,你可以写两种循环:
// 方式 A:行优先遍历(外层遍历行,内层遍历列)
for (int i = 0; i < 64; i++) {
for (int j = 0; j < 128; j++) {
lcd_buffer[i][j] = 0;
}
}
// 方式 B:列优先遍历(外层遍历列,内层遍历行)
for (int j = 0; j < 128; j++) {
for (int i = 0; i < 64; i++) {
lcd_buffer[i][j] = 0;
}
}
两种方式最终的结果完全一样——整个数组都被清零了。但它们的访问模式截然不同。
3.2 用寻址公式看本质

方式 B:列优先遍历 ❌
方式 A:行优先遍历 ✅
方式 A 每次访问地址只偏移 1 字节,完全连续,总线可以 burst 传输,Cache 命中率极高。方式 B 每次跳 128 字节,大跨步跳跃,总线每次都要重新发起地址周期,Cache 加载的 32 字节里只有 1 字节马上用到。
3.3 对 MCU 的影响
在 PC 上,CPU 有 L1/L2/L3 多级缓存,行优先遍历和列优先遍历的性能差异可能只有百分之几。但在 MCU 上,情况完全不同:
第一,总线效率。 很多 MCU 的 SRAM 通过 AHB/APB 总线访问,总线有 burst 传输模式。当访问地址连续时,总线可以一次申请一整块数据(比如一次读 4 个或 8 个字节),效率很高。当地址跳跃时,每次都要重新发起一次完整的地址传输周期,开销成倍增加。
第二,Cache 行(如果有的话)。 一些性能稍高的 MCU(比如 Cortex-M7 内核的芯片)带有 D-Cache。Cache 以"行"为单位从 SRAM 加载数据,常见的 Cache 行大小是 32 字节。行优先遍历时,每次 Cache miss 加载进来的 32 字节,后续 31 次访问都能命中;列优先遍历时,加载进来的 32 字节里只有 1 个字节是你马上要用的,其余 31 个字节都浪费了,很快又被换出去。
第三,编译器优化。 行优先的连续访问模式,编译器更容易识别出来并用 memset、DMA 搬运或者 SIMD 指令来替代。列优先的跳跃模式,编译器很难做这种优化。
所以结论很明确:遍历二维数组时,***让最内层循环沿着列方向走(也就是让最内层循环变量作为最后一个下标),这样访问地址是连续的,性能最好。在 MCU 这种资源紧张的环境下,这个习惯值得养成。
第四部分:数组退化为指针——一个绑不开的话题
4.1 什么是"退化"
C 语言有一条基本规则:当数组名出现在表达式中(而不是 sizeof、&、字符串字面量初始化这三种特殊情况里),它会自动"退化"为指向首元素的指针。
对于一维数组 uint8_t buf[16],buf 退化为 uint8_t *,指向 buf[0]。这个大多数人都知道。
对于二维数组 uint8_t arr[3][4],情况稍微复杂一点。arr 退化后,指向的是"第一行",而"第一行"本身是一个 uint8_t [4] 类型的数组。所以 arr 退化后的类型是指向含有 4 个 uint8_t 的数组的指针,写成 C 语言就是:
uint8_t (*p)[4] = arr;
注意括号的位置。uint8_t (*p)[4] 和 uint8_t *p[4] 是完全不同的东西。前者是"指向数组的指针",后者是"指针数组"(一个包含 4 个指针的数组)。
4.2 退化之后怎么用
有了 uint8_t (*p)[4] = arr;,你可以用 p[i][j] 来访问元素,效果和 arr[i][j] 完全一样。因为 p[i] 等价于 *(p + i),它从 p 指向的位置(也就是第 0 行的起始地址)往后跳 i 个"行",每个"行"的大小是 4 * sizeof(uint8_t) = 4 字节。然后 [j] 再在这个行的起始地址上偏移 j 个元素。最终算出来的地址和直接用 arr[i][j] 的寻址公式一模一样。
这也解释了为什么 p 的类型里必须包含列数 4。如果编译器不知道每行有多少个元素,它就没法算 p + i 应该跳多远。
第五部分:二维数组和指针的指针,不是一回事
这是嵌入式面试的高频题,也是实际工程中容易踩坑的地方。
5.1 内存布局的根本差异
// 方式 1:真正的二维数组
uint8_t arr[3][4];
// 方式 2:指针的指针
uint8_t **p;
p = (uint8_t **)malloc(3 * sizeof(uint8_t *));
for (int i = 0; i < 3; i++) {
p[i] = (uint8_t *)malloc(4 * sizeof(uint8_t));
}
方式 1 的内存布局:12 个字节连续存放,一块完整的内存。
方式 2 的内存布局:先分配一个包含 3 个指针的数组(在 32 位系统上占 12 字节),这 3 个指针分别指向 3 块独立的、各含 4 个字节的内存。这 3 块内存彼此之间不保证连续,因为它们是通过 3 次独立的 malloc 分配的。
用图来表示:

方式 2:uint8_t **p — 分散内存
方式 1:uint8_t arr[3][4] — 连续内存
方式 1 的 12 个字节首尾相接,一次 DMA 就能全部搬走。方式 2 的 3 块数据散落在堆的不同位置,必须分 3 次读取,而且指针数组本身还要额外占 12 字节。
5.2 为什么不能互换
因为内存布局不同,所以 uint8_t arr[3][4] 和 uint8_t **p 在类型系统里是完全不同的东西。
当你写 p[i][j] 时,编译器生成的代码是:先从 p 指向的地址读出第 i 个指针(一次内存读取),然后再用这个指针偏移 j 个字节去读数据(第二次内存读取)。两次间接寻址。
当你写 arr[i][j] 时,编译器直接用公式 base + (i * 4 + j) 算出地址,一次寻址就到了。
如果你把 arr 强行转型为 uint8_t ** 传给函数,函数内部用 p[i][j] 的方式访问,它会把 arr[0][0] 到 arr[0][3] 这 4 个字节的数据当作一个指针来解读。在 32 位系统上,一个指针要 4 个字节,所以它会把 A0 A1 A2 A3 拼成一个 32 位的"地址",然后去那个地址读数据。这个地址几乎肯定是一个非法地址,结果就是 HardFault。
这就是为什么编译器在遇到 uint8_t **p = arr; 时会报警告——它知道这两个类型不兼容,强行转换会出问题。
5.3 实际工程中的选择
在嵌入式系统里,几乎不应该用 malloc 两层的方式来模拟二维数组。原因有三:
- 内存碎片。 MCU 的堆通常很小(几 KB),多次
malloc容易产生碎片,而且malloc本身有管理开销(每个分配块前面要存大小信息等元数据)。 - 不确定性。
malloc的分配地址不可预测,不利于调试和内存分析。嵌入式系统追求确定性,动态分配是大忌。 - 性能差。 两次间接寻址 vs 一次直接计算,在 MCU 上差距明显。而且连续内存可以用 DMA 搬运,非连续内存不行。
如果确实需要动态大小的二维数组(比如运行时才能确定行列数),更好的做法是分配一块连续内存,然后手动计算索引:
uint8_t *buf = malloc(rows * cols * sizeof(uint8_t));
// 访问 [i][j] 等价于:
buf[i * cols + j] = value;
这样既保持了内存连续性,又避免了两次间接寻址。
第六部分:函数传参的正确姿势
把二维数组传给函数,是很多人容易搞混的地方。我们从头推导,看看有哪些写法、各自有什么限制。
6.1 为什么不能直接传 uint8_t **
前面已经讲过了,uint8_t arr[3][4] 退化后的类型是 uint8_t (*)[4],不是 uint8_t **。所以函数形参如果写成 uint8_t **p,实参传 arr 进去,类型不匹配。
6.2 正确的写法:指定列数
void process(uint8_t arr[][4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
// arr[i][j] 可以正常访问
}
}
}
// 调用
uint8_t data[3][4] = { /* ... */ };
process(data, 3);
注意,行数可以省略(写成 []),但列数必须写死(这里是 4)。原因前面讲过了:编译器需要列数来计算 arr[i][j] 的偏移量。行数不影响偏移计算(它只决定循环跑几轮),所以可以省略,由参数 rows 传入。
6.3 用指向数组的指针
和上面等价,只是写法更显式:
void process(uint8_t (*arr)[4], int rows) {
// 内部用法完全一样
}
uint8_t arr[][4] 和 uint8_t (*arr)[4] 作为函数参数是完全等价的,编译器会把前者自动调整为后者。
6.4 C99 变长数组(VLA)
如果你的编译器支持 C99(很多嵌入式编译器支持,比如 GCC、ARM Compiler 6),可以用变长数组让列数也在运行时确定:
void process(int rows, int cols, uint8_t arr[rows][cols]) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
// arr[i][j] 正常工作
}
}
}
// 调用
uint8_t data[3][4] = { /* ... */ };
process(3, 4, data);
这里 rows 和 cols 必须出现在 arr 参数之前,因为编译器在解析 arr 的类型时需要知道 cols 的值。VLA 作为参数传递时,实际上传的仍然是指针,不会拷贝整个数组,所以没有性能损失。
不过要注意,不是所有嵌入式编译器都支持 VLA。Keil ARM Compiler 5(ARMCC)就不支持。如果你的项目用的是比较老的工具链,这个写法用不了。
6.5 最通用的做法:展平为一维指针
如果你需要最大的兼容性,或者行列数都是运行时才能确定的,最稳妥的做法是把二维数组当作一维来传:
void process(uint8_t *buf, int rows, int cols) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
buf[i * cols + j] = 0; // 手动计算索引
}
}
}
// 调用
uint8_t data[3][4] = { /* ... */ };
process((uint8_t *)data, 3, 4);
这种写法不依赖任何编译器特性,任何 C 编译器都能通过。代价是你失去了 arr[i][j] 的语法糖,需要自己写 i * cols + j。但对于需要兼容多种工具链的嵌入式项目来说,这是最安全的选择。
第七部分:栈上的二维数组与栈溢出
7.1 栈有多大
在嵌入式系统里,栈的大小是有限的,而且通常比你想象的小得多。
- 裸机程序(无 RTOS):很多链接脚本默认给栈分配 1KB~4KB。
- RTOS 任务:每个任务有独立的栈,典型大小 256 字节~2KB,取决于任务复杂度。
7.2 一个危险的例子
void lcd_clear(void) {
uint8_t temp[64][128]; // 8192 字节 = 8 KB
for (int i = 0; i < 64; i++)
for (int j = 0; j < 128; j++)
temp[i][j] = 0;
// ...
}
这个函数一调用,就在栈上分配了 8KB。如果栈总共只有 4KB,函数入口处的 PUSH 指令就已经把栈指针推到了栈底以下,覆盖了堆区或者其他变量的内存。但程序不会立刻崩溃——它会继续执行,直到某次函数调用读到了被覆盖的返回地址,跳转到一个莫名其妙的地方,触发 HardFault。
这种 bug 极其难调试,因为崩溃现场和真正出错的位置可能差了十万八千里。
7.3 怎么避免
原则一:大的数组不要放在栈上。 如果数组超过几百字节,就应该声明为全局变量(放在 .bss 段)或者用 static 修饰(放在 .bss 或 .data 段,但作用域限制在文件/函数内)。
原则二:了解你的栈大小。 查看链接脚本(.ld 或 .sct 文件),找到栈的定义,知道它有多大。很多链接脚本里会有类似 __stack_size = 0x400; 的定义,那就是 1KB。
原则三:使用栈使用量分析工具。 GCC 可以用 -fstack-usage 编译选项,生成每个函数的栈使用量报告。Keil 可以在 Map 文件里看到各函数的栈深度。定期跑一下,心里有数。
原则四:RTOS 任务栈要留余量。 很多 RTOS 支持栈溢出检测(比如 FreeRTOS 的 configCHECK_FOR_STACK_OVERFLOW),打开它,至少在开发阶段能帮你提前发现问题。
第八部分:对齐问题——一个容易被忽视的细节
8.1 什么是内存对齐
MCU 访问内存时,往往对地址有对齐要求。比如访问一个 uint16_t(2 字节),最好地址是 2 的倍数;访问一个 uint32_t(4 字节),最好地址是 4 的倍数。不对齐的访问在某些 MCU 上会导致 HardFault(比如 Cortex-M0/M0+),在另一些上只是性能下降(比如 Cortex-M3/M4 允许非对齐访问但速度更慢)。
8.2 二维数组里的对齐陷阱
对于 uint8_t 类型的二维数组,每个元素只有 1 字节,不存在对齐问题。但如果元素类型变大了,情况就不一样了。
uint32_t data[4][3]; // 每个元素 4 字节
每行有 3 个 uint32_t,占 12 字节。第 0 行从地址 base 开始,第 1 行从 base + 12 开始。因为 12 是 4 的倍数,所以第 1 行的第一个 uint32_t 也是 4 字节对齐的。没问题。
但如果列数是奇数呢?
uint32_t data[4][5]; // 每行 5 个 uint32_t = 20 字节
每行 20 字节,20 是 4 的倍数,也没问题。实际上,因为 uint32_t 是 4 字节,任何整数乘以 4 都是 4 的倍数,所以 uint32_t 的二维数组天然不存在行首对齐问题。
真正容易出问题的是结构体数组:
typedef struct {
uint8_t a;
uint8_t b;
uint8_t c;
} SensorData; // 3 字节,但编译器可能对齐到 4 字节
SensorData log[8][16];
这里 SensorData 的大小取决于编译器的对齐策略。如果编译器在末尾补了 1 字节的 padding,那 sizeof(SensorData) = 4,每行 16 * 4 = 64 字节,没问题。如果编译器不补 padding(比如用了 __packed),sizeof(SensorData) = 3,每行 16 * 3 = 48 字节,48 也是 4 的倍数,行首仍然对齐。
但如果列数不是 4 的倍数:
SensorData log[8][5]; // 如果 sizeof(SensorData) == 3,每行 15 字节
第 0 行从 base 开始,第 1 行从 base + 15 开始。15 不是 4 的倍数,第 1 行的第一个结构体的 uint32_t 成员(如果有的话)就会非对齐访问。
8.3 实际建议
在嵌入式项目里,如果你的二维数组元素是结构体类型,务必确认 sizeof(结构体) 是目标 MCU 要求的对齐粒度的整数倍。可以用 static_assert 在编译期检查:
_Static_assert(sizeof(SensorData) % 4 == 0, "SensorData size must be multiple of 4");
如果检查不通过,要么手动补 padding 字段,要么调整结构体成员的排列顺序(把大的类型放前面,小的放后面),让编译器自然对齐。
第九部分:几个实用技巧
9.1 用 memset 替代循环清零
// 不推荐
for (int i = 0; i < 64; i++)
for (int j = 0; j < 128; j++)
lcd_buffer[i][j] = 0;
// 推荐
memset(lcd_buffer, 0, sizeof(lcd_buffer));
sizeof(lcd_buffer) 在编译期就能算出来(64 * 128 * 1 = 8192),memset 内部通常用优化的汇编实现,比两层 for 循环快得多。而且因为二维数组内存是连续的,memset 可以一次性处理整个数组,不需要关心"行"和"列"的概念。
9.2 用 memcpy 整体拷贝
同理,两个同类型的二维数组之间拷贝,直接用 memcpy:
uint8_t src[4][4] = { /* ... */ };
uint8_t dst[4][4];
memcpy(dst, src, sizeof(dst));
9.3 用 sizeof 获取行列数
uint8_t arr[3][4];
int rows = sizeof(arr) / sizeof(arr[0]); // 3
int cols = sizeof(arr[0]) / sizeof(arr[0][0]); // 4
这个技巧在编译期有效(arr 必须是真正的数组,不能是指针)。sizeof(arr) 是整个数组的字节数,sizeof(arr[0]) 是一行的字节数,相除得到行数。sizeof(arr[0]) 除以一个元素的字节数得到列数。
注意,这个技巧只对真正的数组有效。如果 arr 已经退化为指针(比如作为函数参数传进来),sizeof(arr) 返回的是指针的大小(4 或 8),不是数组的大小。
9.4 初始化:部分初始化的行为
uint8_t arr[3][4] = {
{1, 2},
{3, 4, 5}
};
C 语言规定,未显式初始化的元素自动补零。所以:

深色格子是你显式写的值,灰色格子是编译器自动补的零。
这个特性在嵌入式里很有用。比如你有一个大的查找表,只有少数几个位置有非零值,其他都是零,你只需要写出非零的部分,剩下的编译器帮你补零,而且零值部分放在 .bss 段(不占 Flash)。
总结
把全文的核心要点浓缩成几句话:
- 二维数组在内存里是一条连续的直线,按行优先排列,寻址公式是
base + (i * N + j) * sizeof(T)。 - 放在 Flash 还是 RAM,取决于有没有
const。const数组在 Flash,非const在 RAM(.data或.bss段)。 - 遍历顺序影响性能。行优先遍历地址连续,对总线和 Cache 友好;列优先遍历地址跳跃,性能差。
- 数组名退化为指针时,类型是"指向一行的指针"
T (*)[N],不是T **。 - 二维数组和指针的指针内存布局完全不同,不能互换使用。
- 函数传参必须指定列数(VLA 除外),因为寻址公式需要列数。
- 大的二维数组不要放在栈上,否则容易栈溢出。
- 结构体类型的二维数组要注意对齐,确保行首地址满足对齐要求。
这些知识点不是孤立的,它们都从同一个事实出发:MCU 的内存是线性的,二维数组只是编译器提供的一种抽象。理解了这个本质,上面所有的规则、限制、陷阱,都不需要死记硬背,自己就能推导出来。
更多推荐



所有评论(0)