第4天:数据到底存在哪里?——理解内存的本质

📅 2026/7/19 17:30:42 👁️ 阅读次数 📝 编程学习
第4天:数据到底存在哪里?——理解内存的本质

将爱赋给建筑,便成为家;

将教学赋给建筑,便成为学校;

将劳作赋给建筑,便成为公司。

将指令与数据分区赋给存储器,便成为‘内存’。

——《嵌入式开发之道》

第3天我们成功用C语言重写了点灯程序:写了代码,执行了编译命令,生成了一个led.bin,烧录到开发板,灯亮了。

但在这个过程中,有一个关键步骤你可能没太在意:

链接器(ld)到底做了什么?为什么一定要指定-Ttext=0x00000000?为什么startup.s必须放在第一个?

今天我们就来回答这些问题。

一、CPU分不清指令和数据

CPU从内存里读出一个数,它怎么知道这个数是指令还是数据?

答案是:CPU分不清。

在CPU眼里,0x20000xE7FE没有区别——都是16位的二进制数。指令和数据在内存中都是0和1,CPU只负责“读出来”,不区分类型。

如果CPU把数据当成指令去执行,程序就会崩溃。解决方案很简单:把指令和数据分开放。

1.1指令区——.text段

CPU上电后,第一件事不是执行你的代码,而是去地址0x000000000x00000004读两个数:

地址

存放内容

作用

0x00000000

堆栈指针初始值

告诉CPU栈从哪开始(见1.3节"栈"的详细解释)

0x00000004

复位向量

告诉CPU“程序从哪开始执行”

这两个地址合起来叫向量表。你可以把它理解为一本书的目录:

  • 第一个地址告诉CPU"栈的起点在哪"
  • 第二个地址告诉CPU"程序从哪开始执行"

为什么叫"向量"?因为"向量"在数学里表示"有方向的量"——这里的地址就是用来确定CPU跳转方向的,所以叫向量表。

向量表不仅包含启动信息,还包含所有中断的入口。具体结构如下:

0x00000000 堆栈指针(SP)初始值 0x00000004 复位向量(Reset) 0x00000008 NMI(不可屏蔽中断)向量 0x0000000C HardFault(硬错误)向量 ... 0x00000040 EXTI0(外部中断0)向量

向量表不仅包含复位向量,还包含其他中断入口。第6天讲中断时我们再展开。

向量表后面就是你的程序指令。指令按顺序排放,CPU按顺序一条条往下执行。

你写程序时,.text段的末尾就是你最后一条指令。但问题是,Flash里你写的程序后面还有别的内容——比如其他段的数据、甚至Flash中未使用的区域(里面是随机值)。CPU不会因为"你写完了"就停下来,它只会一根筋地继续取指、译码、执行。

如果最后一条指令不是跳转,CPU就会继续往后面的地址取数,把那里的数据当指令执行——这就叫程序跑飞

解决方案:在最后放一条跳转指令,让CPU永远跳回自己,不往"后面"走。

loop: b loop ; 跳转到自己,永远循环

这就是为什么所有嵌入式程序最后都有死循环——就是防止程序跑飞。

掉电恢复后系统仍需继续正常运行,因此指令区要安排在Flash等非易失性存储器里。指令区取名为.text

1.2数据区

指令区解决了“指令存哪里”的问题。现在来回答“数据存哪里”。

从原理上看,CPU可以读写任何地方的数据,能不能读写成功、有没有后果是另一回事——取决于该地址的硬件特性。

有初值的数据:.data段

假设你定义了一个全局变量int g_count = 100;。这个100是初值,需要保存下来——所以它被放在Flash里(掉电不丢)。

但问题是:程序运行过程中,g_count的值可能被改写(比如g_count++)。Flash不能像内存那样随时改写,所以运行时必须把这个变量拷贝到SRAM里

这就是.data段的特殊之处:

  • 在Flash中:保存着初值(100)
  • 在SRAM中:保存着变量的副本(启动时从Flash拷贝过来,之后可以随意改写)

启动代码是上电后最先运行的一小段汇编程序,它负责在调用main函数之前,把Flash中的初值拷贝到SRAM中。

无初值的数据:.bss段

有的数据没有初始值,运行时才直接分配在可写存储器里,这部分数据叫.bss段。需要在系统上电后、正式程序跑起来之前统一清零,以保持程序运行的确定性。

BSSBlock Started by Symbol,最早是IBM 704汇编伪指令,意为“从此开始保留一块未初始化存储区”。

只读数据:.rodata段

有的数据不仅有初值,而且程序运行过程中不需要修改——比如字符串常量"Hello"、用关键字const修饰的全局变量。这类数据直接放在Flash中(.rodata段,read only data),不需要拷贝到SRAM,运行时直接从Flash读取,既省SRAM空间,又保证数据不会被意外修改。

const char *s = "Hello"; // "Hello"字符串放在.rodata段 const int version = 0x0100; // version放在.rodata段

.text.rodata是两个独立的段,它们共同存放在Flash区域中。向量表(0x00000000开始)本身也属于.text段的一部分。.text段包含三部分:向量表 + 程序指令 + 其他只读数据。而.rodata段(字符串常量、const全局变量)紧跟在.text后面。

两者合在一起,就是最终烧录到Flash里的完整程序内容——在嵌入式开发中,这份内容常被称为程序镜像(可以理解为“程序在Flash中的完整副本”)。

1.3两种特殊情况

按道理,上述两种方式能适用所有情况。但有两种情况需要特别考虑:

  1. 事先不知道有多少数据要处理——程序执行时才知道,无法提前安排,怎么办?
  2. 要处理的数据太多,存储器空间不够——怎么办?

针对第一种情况:堆(.heap)

在可读写的存储空间划出一段连续的地址空间。需要数据空间时,就到这个地方占一些地方;用完后再释放掉,以备下一次重用。取名为堆(.heap),英文原意是"杂乱的一堆、堆积物"。

堆通常用于运行时才知道大小的数据缓冲区(如网络数据包、用户输入)。在C语言中,通过malloc函数来申请堆空间,用free函数来释放。

针对第二种情况:栈

设想一下:我们要调用一个函数,需要传给这个函数一些输入,函数执行完后还要传回一个返回值,同时函数本身还需要额外的数据空间来"倒腾"。这些都需要存储空间。

比方说下面这个函数:

int MyFunction_A(int A, int B) { int C; if (A > B) { C = A + B; } else { C = A - B; } return C; }

处理输入A和B之外,还需要一个额外的变量C来临时保留结果。这个变量除了本函数关心外,还有谁关心?没人关心。所以我们叫它局部变量。函数执行完后,A、B、C都没用了。

如果程序有成百上千个函数,每个函数都有自己的局部变量和参数,全部安排在.data或.bss段里,内存远远不够。更好的方案是:用完了就释放,让同一块空间被反复使用。

栈的设计方案:

划一块连续空间K,调用函数之前,把参数和局部变量按顺序往里放:

0: A 1: B 2: C

函数执行完后,这块空间就释放了。下次再调用函数时,又从0开始往里放——空间得到重复利用。

嵌套调用的情况更复杂一些:

main调用MyFunction_A,MyFunction_A又调用MyFunction_B:

0: A ← MyFunction_A的参数 1: B ← MyFunction_A的参数 2: C ← MyFunction_A的局部变量 3: D ← MyFunction_B的参数 4: E ← MyFunction_B的参数 5: F ← MyFunction_B的参数 6: G ← MyFunction_B的局部变量

MyFunction_B执行完后,3~6释放,但0~2还要继续用到MyFunction_A结束。所以我们需要一个指针,指向"当前正在用的空间的边界"——这个指针叫SP(Stack Pointer,栈指针)。

stack英文原意是"一摞盘子"——先放的压在底下,后放的摞在上面,用的时候从上面一个个拿,后进先出。

  • 往K空间放数 →压栈/进栈,SP减小

  • 从K空间移除数据 →弹栈/出栈,SP增大

后调用的函数先返回,后进先出。

上述栈的工作方式示意图如下:

CPU专门设计了栈操作指令PUSH和POP:

PUSH {r1-r10} ; 将多个寄存器压入栈 POP {r1-r10} ; 从栈弹出到多个寄存器

这款单片机用寄存器R13充当SP。上电后CPU从向量表地址0处读取SP的初始值——这也是我们前两天在地址0放0x20001000的原因。

为什么是0x20001000?这款芯片的SRAM范围是0x20000000~0x20001FFF(8KB),栈顶设在0x20001000,留出0x20000000~0x20000FFF给.data、.bss和堆使用。

栈之所以能工作的根本原因:CPU在执行程序时在时间上是串行的——每一件事都有确定的先后顺序,进和退的顺序是确定的,栈才能工作。

如果栈空间太小,或者局部变量太多,超过边界就会非法改写其他数据空间——这叫栈溢出。程序会异常、跑飞。

// 场景一:无限递归(每次调用都消耗栈空间) void func(void) { func(); // 永不返回,栈逐渐耗尽 } // 场景二:过大的局部数组(多个同类型变量打包成一组,叫数组) void test(void) { char buf[10000]; // 定义一个能存10000个char的数组,全部放在栈上,栈只有8000字节 → 直接溢出 }

数组就是连续存放的多个相同类型数据,通过下标(从0开始)访问每一个元素。

例如下面定义了一个char类型的数组,一共有10000个char类型元素,编号从0到9999。

char buf[10000];

buf[0] = 3;

buf[9999] = 1;

1.4内存划分总览

常规的内存划分方案如下图所示:

将代码区.text和只读数据区.rodata放在Flash中(本教程的芯片地址0x000000000x00007FFF),将数据区(.data/.bss/堆/栈)放入SRAM中(地址0x200000000x20001FFF)。这是一个例子,但其基本原则适用于所有嵌入式系统。

二、C语言各要素存放在哪里?

还有一种特殊的变量——用static修饰的局部变量,它的生命周期是整个程序,存放位置取决于是否有初值。

C语言要素存放位置何时确定生命周期
函数代码.text(Flash)编译时整个程序
字符串常量、const全局变量.rodata(Flash)编译时整个程序
已初始化的全局变量.data(SRAM),初值在Flash编译时整个程序
已初始化的静态局部变量.data(SRAM),初值在Flash编译时整个程序
未初始化的全局变量.bss(SRAM)编译时整个程序
未初始化的静态局部变量.bss(SRAM)编译时整个程序
局部变量栈(SRAM)编译时(大小)函数执行期间
静态局部变量.data或.bss(SRAM)编译时整个程序
malloc分配的内存堆(SRAM)运行时malloc到free

示例代码与去向对照:

下面这段示例代码涵盖了上述所有情况,对照着看会更直观:

// 函数代码 → .text段 int g_init = 520; // 全局变量,有初值 → .data段 int g_uninit; // 全局变量,无初值 → .bss段 const int version = 100; // const全局变量 → .rodata段 void func(void) { int a; // 局部变量 → 栈 static int b = 10; // static局部变量,有初值 → .data段 static int c; // static局部变量,无初值 → .bss段 const char *s = "Hello"; // 指针s指向字符串常量"Hello"的首地址,字符串本身在.rodata段 }

三、这些数据怎么安排到内存里?

3.1编译时——分类打包

编译器编译时,根据代码中不同的元素,将它们放入对应的“段”中:

C代码中的内容

编译器放入的段

函数代码

.text

字符串常量、const变量

.rodata

已初始化的全局变量

.data(记录初值和变量所占的字节数)

未初始化的全局变量

.bss(只记录变量所占的字节数,不占文件空间)

注:编译器只负责“分类打包”——把代码和数据按类型放入不同的段。至于这些段最终放在内存的哪个地址,由链接器决定。

3.2链接时——分配地址

链接器拿到多个编译器生成的目标文件(.o)后,按照链接脚本(Linker Script)的指示,将所有段分配到具体的内存地址。

链接脚本(.ld)就是用来描述“所有东西怎么放”的地图

MEMORY { FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 32K SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 8K } SECTIONS { .text : { *(.text) } > FLASH .rodata : { *(.rodata) } > FLASH .data : { *(.data) } > SRAM AT > FLASH .bss : { *(.bss) } > SRAM }

链接脚本语法逐行解释:

语法含义

MEMORY { ... }

定义芯片的内存区域

FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 32K

FLASH(只读可执行),起始地址0x00000000,长度32KB

SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 8K

SRAM(可读写可执行),起始地址0x20000000,长度8KB

SECTIONS{ ... }

定义芯片的各个Section

.text : { *(.text) } > FLASH

所有.text段合并,放入FLASH

.rodata : { *(.rodata) } > FLASH

所有.rodata段合并,放入FLASH

.data : { *(.data) } > SRAM AT > FLASH

运行时在SRAM,初值存在FLASH,启动时拷贝

.bss : { *(.bss) } > SRAM

所有.bss段合并,放入SRAM,启动时清零

第3天我们用的链接命令是ld -Ttext=0x00000000,这只是一个简化版——只指定了.text的起始地址。正式项目中,链接脚本会描述所有段的完整布局。

3.3运行时处理

程序运行时,启动代码(startup.s)在调用main之前完成数据区的初始化:

  1. 设置堆栈指针(SP):从向量表的0x00000000读取初始值
  2. 拷贝.data:将Flash中的初值拷贝到SRAM中的.data区域
  3. 清零.bss:将SRAM中的.bss区域全部清零

这就是为什么C语言的全局变量在程序开始时就有正确的初值(.data),而未初始化的全局变量自动为0(.bss)。

四、为什么这些分区方法今天仍然重要?

这套“指令区+数据区”的分离设计,最早源于UNIX系统的进程内存模型。ARM公司将其引入嵌入式领域后,形成了今天通用的.text/.data/.bss分区标准。

今天的计算机虽然内存大了很多,但底层思想依然没变:

  • PC/服务器:程序在硬盘(指令区),运行时加载到内存(数据区)
  • 智能手机:App在Flash(指令区),运行时加载到RAM(数据区)
  • 嵌入式系统:固件在Flash(指令区),变量在SRAM(数据区)

你正在学习的这套分区方法,不是“过时的古董”,而是今天仍在使用的工业标准。理解这些分区,也是理解操作系统、动态链接等高级话题的基础。

五、今日核心收获

概念

一句话解释

向量表

程序启动的“目录”,告诉CPU堆栈和程序在哪

程序跑飞

CPU执行到非法指令,程序崩溃

死循环

程序末尾放跳转指令,防止跑飞

.rodata

只读数据(字符串常量、const变量),存放在Flash中

.data

有初值的全局变量,初值在Flash,运行时在SRAM

.bss

无初值的全局变量,启动时清零

局部变量,函数执行时分配,返回时释放(LIFO)

动态分配的内存,运行时才确定大小,malloc/free管理

静态局部变量

函数内用static修饰,生命周期为整个程序,放在.data或.bss

链接脚本

描述各段在内存中的布局地图

明天预告:站在巨人的肩膀上——使用标准库开发,用更简洁的方式操作硬件。