1. 从一个“看着别扭”的写法说起
如果你写过一阵子嵌入式C代码,或者接手过一些老项目,大概率会在某个头文件或者源文件里看到类似这样的数组定义:
const unsigned char font_table[] = { 0x00, 0x0F, 0x3F, 0x7F, /* ... */ }; const unsigned char logo_data[] __attribute__((section(".code"))) = { 0xAA, 0x55, /* ... */ };或者更直白一点,在某个老编译器(比如一些8051、部分RISC架构的专用工具链)里,你会看到有人写:
code unsigned char table[] = {1, 2, 3, 4};同一个工程里,有的数组前面加了code,有的没加,有的用const,有的用__attribute__((section(...))),还有的干脆什么都不写。新手看到这种混搭,第一反应往往是:这俩到底有啥区别?是不是加code更省内存?我能不能全加上?全加上会不会出问题?
这个问题看起来是个语法细节,实际上它牵扯到C语言里一个非常核心但经常被忽视的话题:数组到底被放在哪个存储区,以及编译器如何决定这件事。code这个词本身并不是标准C的关键字,它是某些编译器(尤其是面向哈佛架构或带独立程序存储空间的MCU)提供的扩展关键字,用来告诉编译器“把这个数据放到程序存储区(通常是Flash/ROM)里,而不是放到RAM里”。而“不加code”的数组,默认落在RAM的读写数据段里。
我见过太多项目因为这个问题踩坑:有人把大数组不加code直接塞进RAM,结果链接时RAM爆掉;有人给本该可写的缓冲区加了code,运行时写操作直接触发硬件异常;还有人换了编译器之后发现code关键字不认了,整个工程编译不过。这些坑的共同点是:大家只知道“加code省RAM”,却不知道背后的存储模型和访问约束。
这篇内容就是想把这件事彻底讲透。我会从存储区的划分讲起,说清楚code这类修饰到底改变了什么,然后给出可复现的实操对比、链接脚本层面的验证方法、常见编译器的差异对照,最后把我自己踩过的坑和排查技巧整理成速查表。不管你是刚接触嵌入式C的新手,还是写了几年但一直“照着别人写”的老手,看完应该都能对“数组放哪儿”这件事有一个清晰的判断依据。
2. 先搞清楚:数组到底被放在了哪里
2.1 标准C的视角:只分“静态存储期”和“自动存储期”
在纯标准C的语境里,编译器并不关心你的数据具体落在Flash还是RAM,它只关心存储期(storage duration)。定义在函数外面的数组、或者函数内用static修饰的数组,具有静态存储期,生命周期贯穿整个程序运行;函数内不加static的局部数组具有自动存储期,随栈帧创建和销毁。
标准C能表达的“放哪儿”的意图非常有限,主要靠两个东西:
const:表示这个对象不可修改。注意,const只是语义上的只读约束,它并不保证数据一定被放进ROM。在很多桌面平台上,const全局数组确实会被放进只读段(.rodata),但在一些嵌入式工具链里,如果不配合特定关键字或链接配置,const数组照样可能被安排在RAM里,只是运行时写它会出问题。static:改变链接属性(内部链接)和存储期,但不直接指定物理存储区。
所以标准C给不了你“把这个数组放到程序存储区”的精确控制。这就是为什么各家编译器要发明自己的扩展关键字。
2.2 哈佛架构与统一编址:为什么有的芯片特别在意这件事
要理解code的存在意义,得先明白芯片的存储架构。常见的有两类:
统一编址(冯·诺依曼架构的变体):程序存储空间和数据存储空间在同一个地址空间里,比如很多ARM Cortex-M芯片,Flash和SRAM挂在同一条总线上,用不同地址段区分。这种架构下,编译器通过链接脚本把只读数据放到Flash对应的段(比如.rodata),把可写数据放到SRAM对应的段(.data、.bss)。你写const数组,编译器通常会自动帮你放到Flash段,不太需要额外的关键字。
哈佛架构(或带独立程序/数据总线的变体):程序存储空间和数据存储空间是两套独立的地址空间,访问它们需要不同的指令。典型的比如一些8051内核、部分PIC、AVR,以及某些DSP。在这种架构下,一个数据放在程序区还是数据区,访问方式完全不同。编译器必须知道这个数组在哪个区,才能生成正确的取数指令。code这类关键字就是用来做这个声明的。
我用一个生活化的类比来解释:统一编址就像一栋楼里不同楼层,你知道房间号就能找到,只是有的楼层是档案室(只读)、有的是办公室(可写);哈佛架构则像两栋独立的楼,一栋叫“程序楼”、一栋叫“数据楼”,同样的门牌号在两栋楼里指的不是同一个地方,你必须先说清楚去哪个楼。
2.3code关键字的真实身份:编译器扩展,不是标准
必须强调一点:code不是C标准的一部分。你在标准文档里搜不到它。它是特定编译器(尤其是Keil C51、SDCC、部分IAR和专用工具链)提供的存储类扩展或类型修饰扩展。不同编译器对它的拼写、位置、语义都有差异:
| 编译器/工具链 | 典型写法 | 语义 |
|---|---|---|
| Keil C51 | code unsigned char x[] | 放入程序存储区(CODE区) |
| SDCC(8051) | __code unsigned char x[]或code | 放入代码区 |
| 部分ARM工具链 | __attribute__((section(".rodata"))) | 指定段,间接控制位置 |
| IAR | __flash或#pragma location | 放入Flash |
| GCC通用 | __attribute__((section("...")))+ 链接脚本 | 手动指定段 |
所以当你看到“有的加code有的不加”,首先要问的是:这个工程用的是哪套工具链?因为同一个词在不同编译器下含义可能不同,甚至有的编译器根本不认这个词,加了直接报错。
2.4 不加code的数组默认去哪儿了
在不加任何修饰的情况下,一个全局数组的归属由编译器的默认规则和链接脚本共同决定。通常的规律是:
- 如果数组带
const且编译器认为可以放到只读段,它可能进.rodata(Flash)。 - 如果数组不带
const,它是可写的,必须进.data(初始化值存在Flash,运行时拷贝到RAM)或.bss(未初始化,运行时清零)。 - 局部数组进栈。
关键点在于:“不加code”不等于“一定在RAM”,但“不加code且不带const”基本可以确定在RAM。而“加了code”则明确告诉编译器“这是只读的、放程序区”。这个区别在RAM紧张的芯片上就是生死线。
3. 加与不加的核心差异拆解
3.1 存储位置差异:RAM占用与Flash占用的此消彼长
这是最直接、也是大家最关心的差异。假设你有一个1024字节的常量表:
/* 写法A:不加code,不带const */ unsigned char table_a[1024] = { /* 初始化数据 */ }; /* 写法B:加code */ code unsigned char table_b[1024] = { /* 初始化数据 */ };写法A的后果是:初始化数据先存在Flash里,启动时由启动代码拷贝到RAM的.data段,运行时这1024字节实打实占用RAM。写法B则把这1024字节直接留在程序存储区,运行时不占RAM,只占Flash。
在RAM只有几KB的芯片上,这一个选择就可能决定你的工程能不能链接通过。我见过一个项目,界面上几十张点阵图标的数组全都没加code,链接时RAM直接溢出,报错信息指向.data段超限。把图标数组统一加上code之后,RAM占用立刻降下来一大截,问题当场解决。
但这里有个反直觉的点:加code不一定省总存储。因为数据还是要在Flash里存一份,只是不再往RAM里拷。所以它省的是RAM,Flash占用不变甚至略增(因为可能涉及对齐和段管理开销)。如果你的瓶颈是Flash而不是RAM,加code帮不上忙。
3.2 访问方式差异:为什么加了code的数组不能随便写
code区在硬件上通常是只读的(Flash/ROM的物理特性决定)。所以加了code的数组,你在代码里对它做写操作,后果分几种情况:
- 编译器在编译期就报错(如果它把
code理解为只读限定)。 - 编译通过,但运行时写操作无效(写入被硬件忽略)。
- 编译通过,运行时触发硬件异常或复位。
第三种最坑,因为它不一定每次都触发,可能在你测试时没事,量产之后在特定条件下崩。所以我的习惯是:凡是加了code的数组,一律配合const使用,让编译器在编译期就帮我拦住写操作。code const双保险,语义也最清晰。
访问效率方面,哈佛架构下从程序区取数和从数据区取数用的是不同指令,程序区取数往往需要专门的指令(比如查表指令),速度可能略慢于直接读RAM,但通常可以接受。统一编址架构下,从Flash读和从RAM读的差别主要是总线等待周期,Flash通常有等待状态,高频运行时略慢,但影响有限。
3.3 初始化行为差异:启动拷贝的隐性成本
不加code的可写全局数组,如果带初始化值,启动代码需要做一次“从Flash拷贝到RAM”的动作。这个拷贝发生在main之前,由启动文件(startup)完成。数组越大,启动时间越长。
/* 这个数组会导致启动时拷贝 4096 字节 */ unsigned char big_buffer[4096] = {1, 2, 3, /* ... */};加了code的数组没有这个拷贝过程,因为它本来就在程序区,不需要搬。对于启动时间敏感的场景(比如要求快速响应的设备),这也是一个考量点。
另外,未初始化的全局数组(.bss段)会在启动时被清零,同样有成本。而code数组必须带初始化值(因为程序区不能“运行时清零”),所以不存在.bss的问题。
3.4 可移植性差异:换编译器就翻车
这是code关键字最大的隐患。因为它不是标准C,一旦你换了工具链,或者把代码从嵌入式平台挪到桌面平台做单元测试,code这个词很可能不被识别,直接编译失败。
我处理过的做法是加一层宏封装:
/* storage.h */ #if defined(__C51__) || defined(SDCC) #define CODE_ARRAY code #elif defined(__GNUC__) #define CODE_ARRAY __attribute__((section(".rodata"))) #else #define CODE_ARRAY const #endif这样在源码里统一写CODE_ARRAY unsigned char table[] = {...};,换平台时只改一个头文件。虽然多了一层间接,但可移植性大大提升。这个技巧我在多个跨平台项目里用过,实测很稳。
4. 实操验证:亲手看看数组到底去了哪儿
光讲理论不够,这一节我带你做几个可复现的实验,用编译产物和链接信息来验证“加与不加”的真实差异。实验环境用通用的GCC工具链(桌面或ARM交叉编译均可),思路对其它工具链同样适用。
4.1 实验一:用size和nm看段归属
准备两个源文件,分别定义加与不加的数组:
/* test_no_code.c */ unsigned char table_no_code[256] = {1}; /* test_code.c */ const unsigned char table_code[256] = {1};编译成目标文件后,用nm查看符号所在段:
gcc -c test_no_code.c -o test_no_code.o gcc -c test_code.c -o test_code.o nm test_no_code.o nm test_code.o你会看到table_no_code落在D(.data 段,可写数据),而table_code落在R(.rodata 段,只读数据)。这就是最直观的证据:加不加只读修饰,符号归属的段不同。
再用size看各段大小:
size test_no_code.o size test_code.otest_no_code.o的data段是256字节,test_code.o的rodata段是256字节,data段为0。这直接对应了“占RAM还是占Flash”的差异。
4.2 实验二:链接后看最终内存布局
把两个目标文件链接成一个可执行文件,用objdump看段分布:
gcc test_no_code.o test_code.o -o test_bin objdump -h test_bin | grep -E "data|rodata"输出里.data段和.rodata段的大小会清楚显示。如果你在嵌入式交叉编译环境里做这个实验,配合arm-none-eabi-size和链接脚本(.ld文件),能看到.data的加载地址(LMA,在Flash)和运行地址(VMA,在RAM)不同,而.rodata的LMA和VMA都在Flash。这个LMA/VMA的概念是理解“启动拷贝”的关键。
4.3 实验三:故意写code数组,观察编译期拦截
const unsigned char table_code[4] = {1, 2, 3, 4}; void try_write(void) { table_code[0] = 9; /* 故意写只读数组 */ }用GCC编译,会得到类似assignment of read-only location的错误。这就是const配合只读段的价值:把运行时才暴露的问题提前到编译期。如果你的工具链里code本身不带只读语义,务必手动加const。
4.4 实验四:在链接脚本里确认段的落点
如果你用的是自定义链接脚本(嵌入式项目几乎都会自定义),打开.ld文件,找MEMORY和SECTIONS部分。典型结构长这样:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K } SECTIONS { .text : { *(.text*) } > FLASH .rodata : { *(.rodata*) } > FLASH .data : { *(.data*) } > RAM AT > FLASH .bss : { *(.bss*) } > RAM }看.data那行:> RAM AT > FLASH的意思是运行地址在RAM,加载地址在Flash,启动代码负责搬运。.rodata直接> FLASH,不占RAM。这就是“加code/加const”在链接层面的最终体现。理解了这段脚本,你就再也不会对“数组放哪儿”感到模糊了。
提示:不同工具链的段名可能不同(
.rodata、.const、.text混用都有),但“只读数据进Flash段、可写数据进RAM段”这个原则是通用的。看链接脚本时先找MEMORY定义,再看各段归属,比死记段名靠谱。
5. 常见编译器与平台的处理差异对照
5.1 Keil C51:code是刚需
在8051这类哈佛架构芯片上,code几乎是必用的。因为程序区和数据区地址空间独立,不加code的数组默认进内部RAM或外部RAM(取决于small/compact/large存储模式),而8051的RAM非常小(内部往往只有128或256字节)。一个几百字节的常量表不加code,RAM立刻不够用。
/* Keil C51 典型写法 */ code const unsigned char font[] = { /* 字模数据 */ };注意这里code和const一起用。code管位置,const管只读语义,两者配合最稳妥。
5.2 SDCC:__code双下划线形式
SDCC对8051的支持里,关键字通常带双下划线前缀,避免和用户标识符冲突:
__code const unsigned char table[] = {1, 2, 3};SDCC也接受code(在特定编译选项下),但为了可移植和避免命名冲突,我建议统一用__code。
5.3 ARM GCC:没有code,用段属性
ARM Cortex-M是统一编址,Flash和SRAM在同一地址空间,所以不需要code这种关键字。控制位置靠const加链接脚本,或者显式指定段:
const unsigned char table[] __attribute__((section(".rodata"))) = {1, 2, 3};大多数情况下,只要加了const,GCC会自动把它放进.rodata,链接脚本再把.rodata放到Flash,你什么都不用额外做。这也是为什么很多从8051转过来的开发者会疑惑“为什么这里不用code”——因为架构不同,机制不同。
5.4 跨平台统一封装的实践
我在跨平台项目里用的封装策略是分三层:
/* storage_attr.h */ #ifndef STORAGE_ATTR_H #define STORAGE_ATTR_H #if defined(__C51__) #define STORAGE_CODE code #elif defined(SDCC) #define STORAGE_CODE __code #elif defined(__GNUC__) #define STORAGE_CODE #else #define STORAGE_CODE #endif #define CONST_CODE_ARRAY STORAGE_CODE const #endif使用侧统一写:
CONST_CODE_ARRAY unsigned char font_table[] = { /* ... */ };这样在8051上展开成code const,在ARM GCC上展开成const,语义一致,源码不用改。这个封装看起来简单,但省掉了大量条件编译的麻烦,我在三个不同架构的项目里复用同一套头文件,实测下来维护成本很低。
6. 踩坑实录与排查速查表
6.1 坑一:大数组不加code导致RAM溢出
现象:链接报错,提示.data段或RAM区域超出容量,或者程序能烧进去但一运行就异常(栈被挤爆)。
排查思路:先用size或 map 文件看.data和.bss的大小,找出占用最大的符号。map 文件里会列出每个符号的地址和大小,按大小排序一眼就能看到“元凶”。如果确认是常量表,加code/const解决。
我的经验是:凡是初始化后不再修改的数组,一律加只读修饰。这个习惯能避免绝大多数RAM溢出问题。
6.2 坑二:给可写缓冲区误加code
现象:程序运行到某处写数组时卡死、复位,或者数据写不进去。
排查思路:检查所有加了code的数组,确认代码里有没有对它赋值。特别注意通过指针间接写入的情况——编译器不一定能拦住指针写只读区。用const修饰能拦住大部分直接写,但指针强转绕过的写操作只能靠代码审查。
注意:
code数组通过指针访问时,指针类型必须匹配。在哈佛架构上,指向程序区的指针和指向数据区的指针可能是不同宽度或不同指令,混用会导致取到错误数据。这是8051开发里非常经典的坑。
6.3 坑三:换编译器后code不认
现象:代码从一个工具链迁到另一个,编译报code未定义。
解决:用第5节的宏封装,把平台相关的关键字隔离到头文件里。迁移时只改头文件,业务代码不动。
6.4 坑四:以为加了const就一定进Flash
现象:加了const但RAM占用没降。
原因:某些工具链的默认链接脚本没有把.rodata放到Flash,或者编译器因为某些选项把const数据仍安排在RAM。这时候要去看链接脚本和编译选项,必要时显式指定段属性。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 链接报RAM超限 | 大常量数组未加只读修饰 | 看map文件找大符号 | 加code/const |
| 运行时写数组卡死 | 对code数组做写操作 | 搜索该数组的赋值点 | 去掉code或改为可写区 |
| 换工具链编译失败 | code非标准关键字 | 看报错行 | 宏封装隔离 |
| 加const后RAM没降 | 链接脚本未把rodata放Flash | 查.ld文件段归属 | 调整链接脚本 |
| 指针读code数组数据错乱 | 指针类型/地址空间不匹配 | 检查指针声明 | 用匹配的存储区指针类型 |
| 启动变慢 | 大.data数组拷贝 | 看启动代码和.data大小 | 常量改code,减少拷贝 |
6.6 一个容易被忽略的细节:对齐与填充
code数组和普通数组在段内的对齐规则可能不同。某些架构要求程序区数据按特定边界对齐(比如2字节或4字节),如果数组长度是奇数,编译器可能插入填充字节。这在计算Flash占用时要留意,尤其是大量小数组连续定义时,填充开销会累积。我的做法是:对性能或空间敏感的表,显式用__attribute__((aligned(N)))控制对齐,避免编译器“自作主张”。
7. 我个人的几条实操建议
写到这里,关于“加不加code”的技术面基本讲完了。最后分享几条我在实际项目里沉淀下来的判断习惯,都是踩过坑之后总结的,供你参考。
第一条,先看架构再决定用不用关键字。统一编址的芯片(大多数ARM)不需要code,用const加链接脚本就够了;哈佛架构的芯片(8051、部分PIC/AVR)才需要专门的存储区关键字。上来就无脑加code,在ARM上可能直接编译不过。
第二条,常量表默认加只读修饰,这是肌肉记忆。我现在的习惯是,只要一个数组初始化之后不再改,第一反应就是加const,在需要区分程序区的平台上再加code。这个习惯帮我躲过了至少三次RAM溢出的线上隐患。
第三条,用map文件而不是猜。数组到底去了哪儿,不要靠“我觉得”,打开map文件看符号地址和段归属,一目了然。map文件是嵌入式调试里被低估的工具,很多人只在链接报错时才看它,其实平时也应该定期扫一眼,看看内存分布是否健康。
第四条,跨平台代码把存储属性收进宏。这一条前面讲过,但值得再强调:平台相关的关键字散落在业务代码里,是维护灾难的开始。一个头文件、几个宏,成本极低,收益极高。
第五条,写只读区一定要用const兜底。code管位置,const管语义,两者职责不同。只加code不加const,等于把“不能写”这件事完全交给硬件和运气,编译期一点保护都没有。双保险才是稳妥做法。
这个内容其实还可以往下延展,比如讨论code数组在函数指针表、中断向量表里的特殊用法,或者不同优化等级下编译器对只读数据的合并策略(相同内容的常量数组可能被合并以省Flash)。这些话题偏进阶,等以后有机会再单独展开。眼下把“加与不加的区别”这件事吃透,已经能帮你避开嵌入式开发里一大类内存相关的坑了。