计算机基本功:内存地址空间

101 阅读13分钟

我们在平时开发的时候往往容易忽视内存的存在。由于内存不是直接可以打交道的,程序员往往对其了解很少。但计算机的硬核基础,内存是必修关卡,希望这篇文章可以帮你把内存的本质、内存系统的来龙去脉都弄明白。这篇文章首先帮我们先搞清楚CPU怎么访问内存,然后再来分析内存地址从何而来,最终让大家建立对内存地址空间的理解。

CPU如何访问内存

C语言把变量名和函数名都转换成了汇编语言里的标号,而汇编语言的标号,就是机器更好理解的符号。符号具体包括符号类型、符号名称和符号地址这几个属性。其中,符号地址是由一个叫链接器的东西生成的。

汇编语言的标号,就表示为一段内存地址的开始。进一步分析看看:加载字指令 lw 指令,它会从一 个地址指向的内存单元中,把数据加载到寄存器上;储存字指令 sw 指令则是跟 lw 指令相反,把寄存器里的数据存到特定内存单元当中。这些指令里源寄存器和立即数组成的数据,其实就叫内存地址。

结合这些信息,我们就能推出这个结论:CPU 正是通过内存地址来访问内存的。这个地址本质上是一个整数数据。而这个整数代表了一个内存单元的索引号,CPU 访问内存的时候,硬件的地址译码器会负责把索引号,转换成相应的地址信号和片选信号,帮助 CPU“寻路”,找到特定的内存单元位置。

截屏2024-06-27 19.22.45.png 从上图中得知,内存最小编址单位为一个字节,一个字节能储存 8 个二进制位,即给出一个地址,就能够精确地定位到某个内存字节单元。两个连续的字节为半字,储存 16 个二进制位,四个连续的字节为一个字,也就是储存 32 个二进制位。

对照上图看一下,看起来 0~0xFFFFFFFF 这之间任意整数形成的地址,都能索引并访问到对应的内存单元。不过这只是理想状态,现实里并非如此。真正的实现方案中,一些地址上对应的不一定是内存单元,还可能是系统寄存器,设备寄存器、设备内存、主内(即我们经常说内存),情况如下图所示:

截屏2024-06-27 19.24.21.png 示意图里描述的更接近真实情况,在一台现代的物理计算机上,各种设备和内存都是统一编址的,不同的地址段能访问到不同的设备。

比如上图中,CPU 发出了 0x00000004 地址,这时经过地址译码,访问的就不是某个内存单元了,而是系统寄存器;如果 CPU 发出的地址在 0xC0000000 到 0xE0000000 之间,就会访问到设备上的内存,而 CPU 发出的地址是 0x60000000 到 0xBFFFFFFF 之间和0x100000000 到 0x19FFFFFFF 之间,才能访问到主存,也就是内存。

还有一些地址并没有对应到具体的设备,即为无效地址,如果 CPU 访问了无效地址,就会得到无效数据或者收到硬件错误的反馈。

现在,我们已经清楚地知道了,CPU 把一个整数数据当成地址,放在地址总线上,由地址译码器选择该地址正确索引的设备或者内存进行访问。

从另一个角度看,数据在物理电路上,是由不同的电子信号的组合来表示的。正是有了这些电子信号组合,才能做到索引相应的设备和内存。CPU 通过什么访问内存,以及地址的本质是什么,我们已经找到答案了,用一句话概括就是:CPU 通过地址访问内存,地址的本质是整数数据,而整数数据的本质是电子信号的组合。

内存地址从何而来

现在已经知道了 CPU 要通过地址访问内存。但是如果我问你,这个地址从何而来?你是不是有些惊讶,发现自己一下子可能回答不上来,或者只知道个大概。比较容易想到的思路是,访问内存的是相应的程序,那么自然内存地址是从程序代码中来。只是我们没有认真思考过,程序代码的地址是怎么产生的?

下面,我们就通过几行代码来一步步探索这个问题,代码如下所示:

//ls.S文件
.text
.globl sw_ins
#a0内存地址
#a1储存的值
sw_ins:
sw a1, 0(a0)      #储存a1到a0+0地址处
jr ra             #返回
//main.c文件
unsigned int word = 0xffffffff;

int main(){
    sw_ins((unsigned int)&word, 0);
    return 0;
}

上述代码分别来源于工程目录中的 ls.S 文件和 main.c 文件,代码功能逻辑很简单,就是 C 语言的 main 函数调用汇编代码 sw_ins,对 word 变量做修改,把它从 0xffffffff 修改为 0。

请你注意,我们现在不是研究代码本身,而是研究代码编译后的链接过程,通过这个线索来分析程序代码地址如何产生。

为此,我帮你写了一个链接脚本来控制链接过程和传递相关信息。同时我们还要修改 Makefile文件的内容,让链接脚本生效,Makefile 内容如下所示:

截屏2024-06-27 19.32.10.png

上图中红色框中是修改内容,尤其是第 7 行你要仔细看看,其中 -T ld.lds 表示使用 ld.lds 文件作为链接脚本文件,-Map main.map 表示链接器将链接后的内存 map 信息,输出到main.map 文件里。

接下来,我们重点研究一下 ld.lds,代码如下所示:

//输出格式

OUTPUT_FORMAT(elf32-littleriscv)
//配置内存空间,起始地址为0x10000,长度为0x50000
MEMORY
{
    RAM (xrw) : ORIGIN = 0x10000 , LENGTH = 0x50000
}
//定义输出节
SECTIONS
{
    //定义text节包含链接文件的所有以.text开头的节
    .text :
    {
        *(.text) *(.text.*)
    } > RAM
    //定义data节包含链接文件的所有以.data、.sdata、.sdata2、.rodata开头的节
    .data :
    {
        *(.data .data.*) *(.sdata .sdata.*) *(.sdata2.*) *(.rodata) *(.rodata*)
    } > RAM
    //定义bss节包含链接文件的所有以.bss、.sbss、.common开头的节
    .bss :
    {
        *(.sbss*) *(.bss*) *(COMMON*)
     } > RAM
}

从链接脚本中我们看到,Id.lds 文件首先配置了一个内存空间,这个空间从 0x10000 地址开始,一共有 0x50000 个字节。然后,链接器把所有参与链接文件里,以.text、.data、.sdata、.bss、.sbss、.COMMON 开头的节,按照上述链接脚本的顺序,合并成可执行程序文件,这个文件的地址从 0x10000 地址开始,到 0x60000 结束。

这个合并过程中,需要对符号进行绑定和地址重定位,我特意为你画了一幅图,展示这个过 程。

截屏2024-06-27 19.37.08.png

看了图片,你是不是对链接器生成地址的过程更加清楚了呢?如上图所示,ls.o、main.o 文件是可链接的目标文件,格式也是 ELF 的,其中有.text 节、.data 节、.bss 节等,不同的数据会放到不同的节里,如下表所示: 截屏2024-06-27 19.37.53.png 链接器所做的工作就是根据 lds 文件中的定义,完成“合并同类项”的整理工作,也就是把相同的节合并成一个更大的节。比如 ls.o 的.text 节与 main.o 的.text 节合并成 main.elf 的.text节,而.data、.bss 节也是类似的合并过程,合并之后就要执行更重要的工作。程序重定位也叫分配内存地址。我也举个例子帮助你理解,比如 main.elf 程序要从内存地址0x10000 开始,并且这个地址开始存放的是.text 节,即指令部分,.data 节放在.text 节之后。

链接器根据.text 节的大小,就能算出.data 节的开始地址。比如在上面的例子里,就是0x10030。.data 节中有一个变量 word 是一个字大小,所以 word 变量地址会从 0x10030 开始存放,占用 4 字节,下一个变量地址将从 0x10034 开始。

既然 word 变量存放内存地址是 0x10030,那么链接器就需要修改指令,具体就是修改指令中表示 word 变量地址的数据,让地址数据变成 0x10030,或者通过一种计算方式得到0x10030,这样程序中的相关指令才能最终访问到 word 变量。这也是在 main.o 中的 main 函数里,一些指令数据与 main.elf 中的 main 函数指令数据不一样的原因。

还有一个关键的地方我再讲讲,main 函数中调用了 sw_ins 函数,链接器也要进行处理,确保jalr 指令能跳转到 sw_ins 函数的地址上,即 0x10000 地址。

链接器产生地址的过程我们讲完了,概括说就是链接多个程序模块,并且分配程序在运行过程中的地址。

当然了,除了这种方式,你可以在程序代码中直接给出一个地址,代码如下:

int main()
{
    //把整数0x20000强制转换为int类型的指针
    int *p = (int*)0x20000;
    *p = 0;
    //动态分配一个int类型大小的内存空间,其首地址返回给addr指针
    void* addr = malloc(sizeof(int));
    return 0;
}

这段代码就是让 p 直接指向 0x20000 地址,然后向这个地址上写入 0。不过这个操作极其危险,除非你确切地知道自己在干什么,因为 0x20000 可能是其它重要数据,也可能不是真正的内存单元,而是设备寄存器,更可能什么也没有,即这个地址没有连接任何有效设备。

代码中的第三种情况是程序在运行过程中动态分配的内存,返回该内存的首地址,这相对于第一种方式更加安全可靠。

现在我们已经搞清楚了程序中的地址是怎么产生的:第一种方式是链接器第二种方式是直接定义第三种方式是动态分配内存

物理地址空间和虚拟地址空间

我们已经搞清楚了,地址从何而来,但一个地址肯定身处某一个地址空间中,我们下一个探讨话题正是地址空间。

首先,地址不过是一个整数而已,一旦这个整数被编码到 CPU 相应访存指令中的相关位段里,CPU 就会把它放到地址总线上。这样 CPU 访问内存的时候,就会通过地址译码器获得这段整数信息,从而索引到具体的设备单元上。这个设备单元可以是设备寄存器,可以是内存单元。

那么地址空间其实就是一个这样的整数所表示的范围。具体落实到 CPU 电路上,就是地址总线位数所表示的数据范围。

比方说,CPU 有 8 根地址线,它能编码 2 的 8 次方,即 256 个数据,地址 0 到地址 255 这个地址数据的范围,其实就是这个 8 位地址总线的 CPU 的地址空间;如果是 32 位地址总线的 CPU,那么它地址的空间范围就是 0~0xFFFFFFFF。从 0 到 0xFFFFFFFF,这之间的每个整数编码就是一个地址,合起来就是地址空间。

那什么是内存地址空间呢?当然就是能索引到内存单元的地址合集。我们再稍微扩展一下,你知道 CPU 的物理地址空间吗?其实它就是 CPU 地址总线位数所表示的数据范围,由于不同的 CPU,甚至同一体系 CPU 的不同版本,其地址总线数设计实现不同,物理地址空间也是不同的。

聊完了物理地址空间,咱们当然还得说说虚拟地址空间。现在的计算机系统中,我们写的程序链接时的地址和运行时的地址,都使用了虚拟地址。

虚拟地址空间的大小和 CPU 中的一个设备 MMU(内存管理单元)有关。虚拟地址之所以称为虚拟地址,是因为这种地址是假的,它不能真正索引到具体的设备单元,无论该单元属于设备寄存器还是内存,自然也就无法访问内存。还需要一个转换机构,把虚拟地址转换成真正的物理地址才能访问相应的设备。这个转换机构就是 CPU 的 MMU.

地址空间和我们所在的自然空间的寓意不是一样的,它们仅仅是为了表示某一位宽下的二进制数所有的编码合集。所谓内存地址空间,自然也就是内存地址编码的合集。有了这个概念,我们就知道,程序指令在内存中是如何组织的,一旦我们的程序出现了问题我们就能精准地分析定位问题所在。同时,我们也明白了 CPU 如何通过地址访问内存,读取其中指令和数据,也就是 CPU 运行程序的基本逻辑机理。

总结回顾

首先我们分析了 CPU 如何访问内存。一个整数数据就是一个地址,CPU 会把该数据放在地址总线上,由地址译码器选择该地址正确索引的设备或者内存进行访问。

访问内存要先知道“地址”,那内存地址是从何而来的,怎么产生的呢?我们结合例子,了解到内存地址有几种产生方式:一种是链接器对程序重定位后执行地址绑定,这地址是静态的;第二种是在代码中直接定义地址;第三种是动态分配内存,返回内存空间的首地址。

明白了 CPU 访问内存的方式,也知道了内存地址如何产生,我们再理解内存地址空间也不是难事儿了。所谓内存地址空间,本质就是内存地址位宽下地址编码的合集。