资讯动态

从D触发器到RAM64:手把手教你构建计算机内存

发布时间:2026/10/14 4:18:07 来源:尧图企业网站定制
如果你刚开始做《计算机系统要素》实验到Pro3的时候多半会愣一下前面几关都是拼逻辑门、多路选择器怎么突然要做出一个RAM我第一次看到这个题目也是这样想的RAM不是内存芯片吗不是该去查芯片手册、看引脚定义吗怎么在实验里从零手搓出来等我把Pro3做完才明白这个实验真正想教的不是“怎么用RAM”而是“RAM到底由什么组成”。它会把存储拆成Bit、Register、RAM8、RAM64一层层往上搭每一次封装都让容量翻倍直到你建立起“内存不过是大规模重复单元”的直觉。这篇文章我就用自己做Pro3时的思路从D触发器讲到RAM64把地址译码、读写路径、时序控制和最常见的调试坑一次性说清楚。适合正在做这个实验、以及想理解CPU存储结构的同学已经能写完整代码的也可以看看最后的排查速查表说不定能帮你省半小时。1. 先理解RAM到底要解决哪三个问题1.1 项目3的目标拆开看其实并不玄《计算机系统要素》项目3通常要求你按顺序实现Bit、Register、RAM8、RAM64、RAM512和RAM4K有些版本还会加一个PC计数器。很多人在做之前会觉得RAM是一个特别复杂的设备但拆开实验描述就会发现它只干三件事按地址存数据、按地址取数据、在写入信号有效时更新数据。所以实验里的每个芯片本质上都是在完成这三件事中的一个环节。Bit负责“记住1个位”Register负责“记住16个位”RAM8负责“从8个Register里按地址挑一个”RAM64则是“从64个Register里按地址挑一个”。每上一层只是在前面模块的基础上多复制、多接线并没有引入新的魔法。我在做Pro3之前有个误区以为内存是“一整块能读写的黑盒”结果看完硬件描述语言HDL里那些DMux、Mux、Register的连线才意识到存储器和普通组合逻辑一样是可以用基本门搭出来的。区别只在于多了时钟和“反馈”这个动作——反馈让电路拥有了状态而状态就是存储器存在的意义。1.2 随机访问到底是什么意思“RAM”这个缩写里的“Random Access”指的不是“随机乱来”而是“任意时刻都可以直接访问任意地址”。对比一下磁带你想读第100段必须从开头一路卷过去而RAM只需要把地址线给定组合逻辑立刻就能把对应位置的数据选出来不需要按顺序查找。这个特性从电路结构上体现得非常直接读数据用多路选择器写数据用译码器。地址线同时控制读路径和写路径选中哪个寄存器哪个寄存器就响应操作。也就是说RAM的“快”并不是靠什么特殊材料而是靠把地址译码器和数据选择器并行铺开形成一个只要信号传输延迟足够短、就能立刻到达目标位置的电路结构。所以Pro3的核心并不在于你记住了多少芯片型号而在于你能不能理解一条控制流load信号决定“本次要不要写入”地址信号决定“写到哪个寄存器 / 从哪个寄存器读”时钟信号决定“到底哪一刻才真正把数据锁存进去”。这三个信号分开看都不难但合在一起就是整个CPU存储系统的雏形。2. 从D触发器到寄存器先把最小存储单元做出来2.1 D触发器为什么能“记住”数据RAM虽然叫“内存”但真正能记住一个比特的不是RAM芯片本身而是电路里的触发器。在Pro3里我们不需要自己搭建D触发器内部的晶体管结构实验环境已经提供了DFF部件但你必须理解它的行为否则后面做RAM时很容易踩时序的坑。D触发器最核心的一点是在时钟边沿瞬间它把输入in的值锁存到内部并稳定输出到out其他时间不管in怎么变out都保持上次锁存的值。换句话说它就像一个带快门的相机只有按下快门时钟边沿那一瞬间画面才会被记录下来之后哪怕眼前景色变了照片依然是刚才那帧。“保持”这个特性看起来很简单但在数字电路里意义重大它让信号不再是“流过去就消失”的瞬间值而是能够跨时钟周期存在。D触发器由NAND门搭成时内部其实利用了“输出反馈到输入”的回路——正是这个反馈让电路在没有新写入动作时不断维持自身状态。2.2 用HDL写一个Bit再拼出Register实验里的Bit就是对DFF做一个小封装让它支持“加载”控制。因为原始DFF是不管你有没有写入意图都会在时钟边沿把in锁存进去。但我们想要的寄存器是有load信号的load1才更新load0就保持。怎么实现思路是给DFF之前加一个选择器当load1时选择外部输入in当load0时选择DFF自己的旧输出out重新锁存回去。这就是下面这段HDL做的事情CHIP Bit { IN in, load, clock; OUT out; PARTS: // 旧输出回送到Mux用于保持原值 Mux(afeedback, bin, selload, outmuxOut); DFF(inmuxOut, clockclock, outout, outfeedback); }我当初看完这段代码的第一反应是这也能算存储是的存储的本质就是“自己喂自己”。feedback这个连线把DFF的输出绕回到输入选择器load0时DFF每个时钟周期都重新锁存同一个值相当于一遍遍“刷新”记忆但数字不变load1时才让新数据进入。这里有一个容易困惑的点DFF是边沿触发Mux是组合逻辑所以load信号不需要严格同步只要在时钟边沿到来前稳定就行。有了BitRegister就非常直接了只是把16个Bit并行摆开共用一个load和clock每个Bit处理in的一位输出拼成16位。HDL里就是把16个Bit都连起来第0个处理in[0]第1个处理in[1]依此类推。你可以写一个循环也可以直接列出16个子部件哪一个都行。CHIP Register { IN in[16], load, clock; OUT out[16]; PARTS: Bit(inin[0], loadload, clockclock, outout[0]); Bit(inin[1], loadload, clockclock, outout[1]); // ... 一直到 in[15]/out[15] }到这一步你已经拥有了一个“16位寄存器”它整体保存一个16位数。接下来的RAM就是在这个基础上复制出很多个寄存器再决定怎么让它们协同工作。3. 地址译码和多路选择把寄存器阵列变成真正的RAM3.1 写路径DMux 如何把load信号精准投递到目标寄存器RAM为什么能按地址找到数据关键不是“找”而是把数据送到唯一被选中的寄存器其余寄存器保持不动。对于RAM8我们有8个Register但只有一个外部load信号不能直接全部接上否则写一个地址所有寄存器都会更新。正确的做法是用DMux8Way来做“地址译码”。DMux8Way的输入是load选择端是3位地址当地址为某个值时它把load原样送到对应的输出端其他7个输出全部为0。于是在同一个时刻只有地址对应的那个寄存器的load是1其他寄存器收到的load都是0。这就是“写入选通”。我第一遍做的时候曾经觉得可以不用DMux而是用Mux产生一个“该寄存器是否被选中”的布尔值再喂给Register。这么做逻辑上没错但会很绕。标准做法就是把DMux看成一条“地址管道”让load信号顺着管道流到正确的寄存器门口。这里的对应关系是地址0对应a地址1对应b地址2对应c依此类推。别小看这个顺序经常有同学因为把address的位顺序接反导致0和3、2和6这类地址看起来“错乱”。3.2 读路径Mux 如何从一堆寄存器中选出目标数据写路径解决“喂给谁”读路径解决“从哪个取”。当你想读某个寄存器的值时不能把所有寄存器的输出都直接接到out总线上因为多个输出信号同时驱动一根线会造成冲突。必须用Mux8Way16将这些16位输出汇聚起来选择端同样是那条3位地址。Mux8Way16的输入a到h来自8个Register的输出选择端地址为几就输出哪一个寄存器的内容。注意这个选择端和写路径DMux的选择端是同一条地址总线。所以一个地址值既决定了写的时候哪个寄存器被激活也决定了读的时候哪个寄存器的数据被输出。这就是惯性思维里“地址指向某个存储位置”的硬件表现。读路径和写路径还有个关键差别读是纯组合逻辑只要地址稳定out立刻就能反映对应寄存器的值写却要等一个时钟边沿真正锁存。所以仿真测试里你经常会看到“先给地址和in再让时钟翻转然后检查out”这种节奏。3.3 RAM8与RAM64的完整HDL实现细节把DMux和Mux组合进RAM8代码反而比Register更规整。我给出一个可以直接过的写法和注释CHIP RAM8 { IN in[16], load, address[3], clock; OUT out[16]; PARTS: // 写路径把load拆成8路地址选中哪路哪路才有load DMux8Way(inload, seladdress, aload0, bload1, cload2, dload3, eload4, fload5, gload6, hload7); Register(inin, loadload0, clockclock, outr0); Register(inin, loadload1, clockclock, outr1); Register(inin, loadload2, clockclock, outr2); Register(inin, loadload3, clockclock, outr3); Register(inin, loadload4, clockclock, outr4); Register(inin, loadload5, clockclock, outr5); Register(inin, loadload6, clockclock, outr6); Register(inin, loadload7, clockclock, outr7); // 读路径用同一地址从8个寄存器输出中挑一个 Mux8Way16(ar0, br1, cr2, dr3, er4, fr5, gr6, hr7, seladdress, outout); }这里所有寄存器的in都连同一个外部输入但不用担心只有被选中的那个load1其他都是0。读的时候未选中的寄存器输出照样存在只是Mux把它们挡在门外。RAM64不是直接调用64个Register而是调用8个RAM8这样代码结构清晰得多。地址位一扩成6位我们需要决定哪几位控制“哪个RAM8”哪几位控制“RAM8内部的哪个寄存器”。我的做法是低3位address[0..2]给每个RAM8做内部寻址高3位address[3..5]用来选择外层RAM8。于是CHIP RAM64 { IN in[16], load, address[6], clock; OUT out[16]; PARTS: // 先用高3位把load分发到8个RAM8 DMux8Way(inload, seladdress[3..5], aload0, bload1, cload2, dload3, eload4, fload5, gload6, hload7); RAM8(inin, loadload0, addressaddress[0..2], clockclock, outr0); RAM8(inin, loadload1, addressaddress[0..2], clockclock, outr1); RAM8(inin, loadload2, addressaddress[0..2], clockclock, outr2); RAM8(inin, loadload3, addressaddress[0..2], clockclock, outr3); RAM8(inin, loadload4, addressaddress[0..2], clockclock, outr4); RAM8(inin, loadload5, addressaddress[0..2], clockclock, outr5); RAM8(inin, loadload6, addressaddress[0..2], clockclock, outr6); RAM8(inin, loadload7, addressaddress[0..2], clockclock, outr7); // 输出侧再用高3位从8个RAM8中选一个 Mux8Way16(ar0, br1, cr2, dr3, er4, fr5, gr6, hr7, seladdress[3..5], outout); }这种“先通过DMux定位到子模块再通过子模块内部地址定位到寄存器”的方式就是计算机存储体系中经典的层次化寻址。每一层只管自己的那几位地址RAM512、RAM4K都只是把RAM64继续当作积木往上再叠一层罢了。4. 调试实录Pro3最容易翻车的5个问题4.1 多个输出直接相连引发总线冲突Pro3最常见的编译错误不是逻辑错误而是“多驱动”。比如你可能会想“RAM8有8个寄存器直接把它们的out并到一起不就行了”HDL编译器会立刻报错因为数字电路里多个输出脚接在同一根线上会互相打架。现实中的三态门、总线仲裁芯片也都是在解决同一个问题但实验环境让我们用Mux来做“逻辑选通”而不是靠电气特性断电。所以记住一个铁律任何一根out线最终只能由唯一一个部件驱动。RAM的输出必须从Mux8Way16出来不能从任何Register直连。4.2 load和地址位顺序接反这个坑很隐蔽。RAM8的DMux8Way选择端address[3]是一个3位总线从高到低排列对应数值0到7。如果你在RAM64里把address[0..2]和address[3..5]写反测试结果会非常奇怪写地址0和3可能看起来正常但写地址1和4会互相影响。其实不是RAM坏了而是你把“高位/低位”的切片搞反了。排查方法很简单写一个向地址0、1、2、3依次写入不同值再依次读回的小测试哪个地址序列错位就说明对应位段接反了。4.3 对时钟边沿的理解不到位有同学写test脚本时把load1、in某值、address某值然后立刻检查out发现没有变化以为是RAM没写进去。实际上RAM8的写入动作发生在时钟上升沿你必须在信号稳定之后给一个从0到1的时钟跳变才能触发DFF更新。在硬件仿真里这个节奏叫“先建立输入再打一拍时钟”。如果只是把输入恒定为1DFF只会在第一次边沿采样之后不再变化。这也是为什么实验提供的.tst文件里总是反复用tick、tock这些步骤制造时钟脉冲。4.4 高低位扩展时只改了容量没改Mux选择端从RAM64扩展到RAM512时很多人会把内部RAM64的address接成address[0..5]但外层Mux8Way16的sel还留在address[3..5]。这样容量虽然是8个RAM64但每个RAM64会被多个高层地址选到数据互相覆盖。正确写法是子模块内部用低6位address[0..5]外层选择用高3位address[6..8]。层级越高地址的“分片”越要清楚。4.5 常见问题排查速查表现象可能原因快速排查方法RAM8编译报“multiple drivers”多个Register输出直接连接out改用Mux8Way16统一收口写地址0后读地址1也变地址位接反或DMux输出顺序写错检查a~h与address数值对应关系写入后立即读out没变化没给时钟上升沿测试脚本里加tick/tockRAM64读写相同地址正常其他地址错高低位切片写反核对address[0..2]和address[3..5]读永远输出0但写入好像成功Mux8Way16输入a~h与寄存器输出未对齐逐个检查Mux输入顺序扩展RAM512后容量混乱外层Mux/DMux sel位写错高层只用高位地址低位留给子模块这张表是我实际跑Pro3时反复对照的清单。前三个问题我全踩过最后一个是我帮同组同学查出来的。只要你按着“先查编译错误、再查地址切片、最后查时钟节奏”的顺序排查基本十分钟内能找到问题。5. 从RAM8到CPU内存容量扩展的思维模型5.1 地址位宽和容量的换算关系RAM的容量和地址位宽有一个简单到容易被忽略的公式寄存器数量 2^地址位数总存储位数 寄存器数量 × 16。比如RAM8用3位地址RAM64用6位地址RAM512用9位地址RAM4K用12位地址。每增加3位地址容量就乘以8因为每一层都是8选1结构。这个对数关系解释了为什么内存地址总线位数决定最大寻址空间。你在Pro3里辛苦搭的每一个RAM8实际上就是CPU眼里“可寻址存储单元”的微观缩影。当你从RAM64扩展到RAM4K时你已经亲手实践了经典的分层存储设计——高位地址选存储体低位地址选体内部单元。现代内存条上的Bank、Row、Column结构本质上也是同一套思想。5.2 用子模块搭子模块的工程习惯Pro3最值得培养的其实不是“会写HDL”而是“敢把大问题拆成小问题”。RAM64如果直接写64个Register连线和命名会让你疯掉但先写好RAM8再让RAM64由8个RAM8组成代码量反而没增加多少而且每一个部件都可以单独测试。你可以先用test命令验证RAM8读写正确然后假设RAM8是绝对可靠的积木专心检查RAM64的地址分配是否合理。这种做法放到真实工程里就是模块化设计。数字芯片里动辄上亿个晶体管没有人能逐个调试只能一层层抽象上层只关心接口行为不关心内部实现。Pro3在这一点上非常仁慈它让你用两天时间亲自体验一次“层次化构建”的甜头。我自己的体会是做完这个实验再去看CPU的Register File、Cache模型甚至操作系统的内存管理会有一种“原来底层的坑我都踩过”的踏实感。后续如果你想继续深入可以试着把PC计数器也接到RAM上做一个“地址自动递增”的取指过程模拟或者研究一下哈佛结构和冯诺依曼结构在物理连线上的区别那时候你会发现Pro3给你的不是一块RAM而是一整套构建数字系统的思维脚手架。最后分享一个实际操作中的小技巧写RAM64之前先手动画一张地址位分配表把地址0、1、63分别拆成高3位和低3位再看它们分别选中哪个RAM8、哪个内部地址。这张表会让你在接Mux和DMux时彻底告别混乱。把这个习惯保留到后面的CPU实验里你会回来感谢自己。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑