ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

为什么学了C语言还要硬啃计算机组成原理?从一条指令看懂CPU与存储体系

为什么学了C语言还要硬啃计算机组成原理?从一条指令看懂CPU与存储体系 1. 为什么学完C语言还是要硬啃《计算机组成原理》大学的时候我写了两年代码C语言、数据结构都能应付但一看到计算机组成原理这几个字就头皮发麻。直到有一次面试被问到一个int a 1;从内存里取出来到CPU寄存器里执行中间到底发生了什么我发现自己根本说不清楚才开始老老实实补这门课。后来带团队、做性能优化才发现当年的这笔投资回报率实在太高了。很多人有个误区觉得写应用层代码不需要懂底层。但实际情况恰恰相反不懂计算机组成原理你只是在使用计算机而不是在设计程序。当你处理一个线上接口延迟过高的问题时如果脑子里没有Cache miss、分支预测失败、内存带宽瓶颈这些概念排查方向会很盲目。而这些都是《计算机组成原理》这门课的核心内容。这篇文章想做的事情很简单就是把这门课里计算机系统是怎么工作的这条主线彻底讲透包括指令是怎么被执行的、存储体系为什么分那么多层、总线协议到底在干什么、CPU的流水线为什么会断流。全程以实际场景切入补充教材里不会写明白的为什么和所以呢。这门课适合的人群也很广泛正在学计算机组成原理、被课本折磨的本科生准备考研408的同学以及工作几年后想回头补基础、做性能优化和底层排障的开发者。如果你是零基础也能看懂我会尽量用大白话解释每一条原理后面真实的硬件行为。2. 从算盘到CPU冯·诺依曼体系结构到底在模仿谁2.1 计算机五大部件是怎么协同起来的冯·诺依曼体系结构的核心思想说穿了就是三个词存储程序、指令顺序执行、数据和指令统一编码。这句话听着抽象其实本质是把人打算盘的过程机械化。你手动算一道计算题的时候需要什么一张草稿纸存储器、一支笔输入设备、你的脑子运算器控制器算完还得把结果写出来输出设备。计算机把这套流程拆成了五个部件运算器、控制器、存储器、输入设备、输出设备。运算器负责算控制器负责指挥存储器负责记录输入输出则接管人与机器之间的信息交换。让这五个部件真正协同起来的关键是那条放在存储器里的程序。程序不是抽象的逻辑而是一连串已经编码好的指令。所以为什么叫存储程序意思是你先把指令和数据存进内存然后CPU才开始逐条取出来执行。这里有一个隐藏的设计决策指令和数据放在同一个存储器空间里共用一套地址体系。优点是灵活性极高——程序可以当数据一样被修改、被覆盖操作系统因此可以实现进程换入换出代价是安全性风险缓冲区溢出攻击正是钻了这个指令即数据的空子。教材上通常附一张经典的结构图把五个部件用实线连起来虚线表示控制信号。很多人背完图就过了但我建议大家离开课本用一条真实指令走一遍完整链路比默背十遍图都有效。2.2 一条指令的一生取指、译码、执行、访存、写回以int c a b;为例在C语言里这只是一行赋值语句但翻译成MIPS或RISC-V汇编后CPU的工作流程被拆成了明确的五个阶段第一步取指IF。程序计数器PC保存着下一条指令的地址。CPU先把PC的值送到地址总线从内存中取出指令同时PC自动加4在32位体系里以指向下一条指令。这里有个容易忽略的细节PC的增量操作与取指同时进行目的是不让流水线在每个周期里额外等一条加法。第二步译码ID。取来的指令是一串二进制数比如32位的000000 00001 00010 00001 00000 100000。控制器在这里严格说是译码器要把它拆开哪几位是操作码比如加法哪几位是源寄存器编号哪几位是目标寄存器编号。译码的同时寄存器堆开始工作——根据源寄存器编号读出对应的值。为什么能那么快因为寄存器堆是纯组合逻辑加上边缘触发的时序控制在设计上就是为单周期并行读取而优化的。第三步执行EX。运算器ALU接到操作数根据操作码完成加法运算。如果是加法指令这里得到的就是求和结果如果是比较跳转指令这里还要算出目标地址。第四步访存MEM。只有load、store指令真正访问内存其他指令在这个阶段是空闲的。这也是为什么现代CPU在流水线设计上会把访存阶段单独设一级——数据通路和内存控制器的延迟差异太大必须用缓冲区隔开。第五步写回WB。把ALU的计算结果写回目标寄存器更新寄存器堆的状态。写回完成后这条指令的生命周期结束下一条指令在流水线里已经走到译码或执行阶段了。我在学习时最大的顿悟点是CPU本身并不理解加法它只是在一个时钟周期内让一组逻辑门按照预期的控制信号完成电平翻转。所谓执行程序就是无数个时钟周期里有规律地重复这套五步流程。把这一条链路走通后面学流水线、中断、Cache都顺了。3. 运算器不只是算数从加法器到状态标志3.1 全加器、串行进位与超前进位运算器最核心的部件是ALU。ALU最基本的计算单元是加法器而加法器的地基是那一堆用门电路搭起来的全加器Full Adder。我记得刚学数字电路时老师先教半加器只有两个输入端A、B输出Sum和Carry。但它没法处理低位进位所以进到ALU里的每一个bit都必须用全加器——它有A、B和Cin三个输入只要三个输入里有奇数个1Sum就为1有两处及以上为1时Cout为1。听起来很简单对吧但问题来了一个64位的加法器要把64个全加器连起来。如果每一位的进位都要等低一位的Cout算出来那就是一条长达64级的进位链。每级门延迟哪怕只有几皮秒64级累计下来时钟周期就得拉长。这和我们在教科书里看到串行进位加法器的性能瓶颈是直接对应的。解决思路是用超前进位Carry Lookahead。它的核心技巧是每一位的溢出其实可以由输入的A、B直接预测出来。具体分两种情况如果当前位A1且B1那么这一位必然产生进位记作GenerateG如果A、B中恰好一个为1那么它会把低位的进位原封不动传上去记作PropagateP。于是第i位的进位可以写成Ci Gi (Pi C(i-1))。虽然形式上仍然是递推的但只要把式子展开每一位的进位都能直接写成G、P和C0的组合也就是通过一个深层的组合逻辑电路把进位一次性算出来而不需要等待前面若干级的输出。细心的读者已经发现了网上的热词列表里有计算机组成原理组间串行进位。这正是超前进位的实际形式64位的加法器不可能全部做一次性展开那样门的扇入扇出会爆炸常见的工程方案是4位一组做超前进位组与组之间采用串行进位。这样的设计折中了速度与芯片面积是局部最优、全局可用思路的典型例子。后来408的真题里也考过这道题如果没有理解G和P的含义会很难下笔。3.2 标志位、移位与状态寄存器加法器算完之后除了得到结果还会产生一组重要的状态标志进位标志CF、零标志ZF、符号标志SF、溢出标志OF。这四位几乎构成了所有高级语言里比较运算的底层基础。举个例子怎么判断两个整数a、b谁更大CPU并不认识大于它只是做一次a - b的减法运算在加法器里就是加上b的补码然后根据结果的状态标志来判断如果结果是0ZF1说明相等如果最高位是1SF1说明结果是负数如果有进位变号OF1说明溢出了。条件跳转指令比如x86的jg、jl读到的就是这些标志位的组合关系。所以哪怕你的程序里写的是if (a b)到了硬件层面它真的只是做一次减法、看状态位、决定是否跳转这三个动作。教材里还会讲移位器但实际ALU的移位功能往往由桶形移位器Barrel Shifter实现。它可以在一个时钟周期内把操作数移位任意位数用大量的并行选择器来换时间这在ARM处理器里非常常见。理解这一点再去读一些内存对齐的性能优化文章会觉得轻松很多。4. 存储体系是一个精密的分权系统不是一块大硬盘4.1 为什么不能只用一种存储器从直观的角度想如果内存能做得又快又大还便宜那还需要寄存器、Cache、外存之分吗遗憾的是当前技术条件下速度、容量、成本构成了不可能三角。CPU的寄存器访问延迟在1ns以内但容量极小一个寄存器堆可能只有几十字节DRAM内存能到128GB了但访问延迟是几十纳秒SSD容量做到几TB很容易但一次随机访问要几十微秒比内存慢了三个数量级。所以现代计算机的存储体系必然是一个金字塔结构寄存器、多级Cache、主存、磁盘/SSD。上层速度更快、价格更贵下层容量更大、价格更便宜。程序自身的行为特征时间局部性、空间局部性让这个金字塔得以运转你反复用一个变量它就能留在寄存器你顺序读取数组它就会被自动预取到Cache。读到这里应该能理解为什么408真题里计算机组成原理存储器一直是必考部分了。存储层次的设计直接影响CPI每指令周期数如果Cache miss率每提升1%整机性能掉的可不是1%而是可能超过20%——因为DRAM的等待时间太长CPU只能干等。4.2 动态存储器和静态存储器的本质差别很多初学者分不清DRAM和SRAM。其实名字已经提示了一切。DRAM动态随机存取存储器是依靠电容上的电荷来存储信息的。电容会漏电所以必须周期性地刷新——这就是动态的含义。刷新不仅消耗能量还会阻塞访问这是DRAM延迟高的一个结构性原因。SRAM静态随机存取存储器则是由触发器电路构成的。只要供电持续数据状态就会保持不需要刷新读写速度快得多。但一个SRAM单元需要6个晶体管DRAM一个单元只需要1个晶体管加1个电容所以SRAM单位面积的容量远低于DRAM。这就是为什么Cache只能用SRAM做但容量只能做到几MB而内存用DRAM能轻松做到几十GB的原因。有些同学容易混淆概念见到ROM也分不清。ROM只读存储器属于非易失性存储断电不丢数据用于固件存放而RAM断电就丢。这里提醒一句现在手机宣传的8GB256GB前面是LPDDR内存RAM后面是闪存Flash很多人买之前不搞清楚优化存储时会把方向搞错。4.3 把内存当成仓库Cache的替换策略与写策略我给新手讲课的时候常打一个比喻寄存器是你桌上的笔Cache是你伸手能拿到的柜子里的文件内存是旁边资料室的档案架外存是楼下档案库。好的程序员一定会在桌上只放最常用的笔也会及时把用过的文件归档——这其实就是Cache替换策略的思想。Cache运行在一套极小的硬件状态机之上核心算法有几种直接映射每个内存地址只能进Cache中唯一的cache line。结构简单但容易冲突。全相联每个地址可以放在任何一个cache line灵活性最高但要全量比较地址成本高。组相联前两者的折中分成若干组地址限定在某一组但组内任意映射。现代CPU几乎都使用组相联L1通常8路或16路。Cache的写策略同样关键。如果CPU命中Cache并写入新值内存里的旧值怎么处理两种方案写直达Write Through是同步写回内存效率低但一致性容易保证写回Write Back是先写Cache并标记脏位等cache line被替换出去时才一次性写回内存效率高但增加了一致性的复杂度。多核环境下还有缓存一致性问题为什么需要MESI协议这是组建多核系统时绕不开的坎。我在实际做性能优化时遇到的典型坑就是伪共享False Sharing。两个线程修改的是不同的变量但这两个变量恰好落在同一个cache line里一旦任意一方写入整个cache line被标记失效另一个线程被迫重新加载——看起来双方各自独立的操作被硬件层面强行互锁了。理解了Cache一致性协议才知道为什么多线程代码里变量要用缓存行填充对齐。5. 总线、中断与I/O数据是怎么出门的5.1 三根总线各干各的活总线是连接CPU、存储器和I/O设备的公共通路。教材上把总线分成三类数据总线、地址总线和控制总线。说穿了就像一条马路被分成车道数据总线上跑具体的数字地址总线上指名道姓说这是给谁的数据控制总线决定现在是读还是写。地址总线的宽度决定了CPU能访问的地址空间上限。比如32位地址总线最多寻址4GB空间。这个结论直接解释了为什么老的32位系统最多支持4GB内存。64位系统地址总线宽了地址空间直接在理论上飙到2^64足够用很长远。总线的时序和仲裁也很关键。多个设备同时想占用总线怎么办要么用优先级要么用公平轮询。PCIe总线的仲裁在硬件引擎里完成而对程序员可见的部分是配置空间和DMA机制——现在的NVMe SSD能达到那么高的吞吐靠的就是PCIe总线的DMA能力CPU只负责发起请求数据搬运的活全部交给总线上的DMA引擎。5.2 中断打断CPU的礼貌方式I/O设备要通知CPU我有数据了或我忙完了有几种方式轮询、中断和DMA。轮询是CPU没事就反复问设备好了没简单但浪费CPU中断是设备主动举手CPU响应后转去执行特定的中断服务程序。中断机制的细节值得敲黑板。中断请求来了CPU不是立刻就去响应而是要等当前指令执行完、在指令边界响应响应前要保存现场PC、状态寄存器等然后跳转到中断向量表里的对应表项处理完中断服务程序还要恢复现场接着执行被打断的程序。这里最不那么直观的点是中断嵌套与优先级如果在处理一个低级中断时来了一个更高优先级的中断CPU可能被二次打断。硬件上可以通过中断屏蔽字来临时关掉部分优先级的响应软件上则通过设置中断状态位来管理临界区。所以操作系统里关中断、开中断这些原语都能在组成原理的中断控制器里找到对应。5.3 DMA到底省了什么时间I/O操作最耗时的部分是数据搬运磁盘数据先到内存缓冲区再从内存到用户空间动辄拷贝好几次。如果每一次都靠CPU逐字节搬运CPU基本上就废了。DMA直接存储器访问设备存在的意义就是在CPU初始化好源地址、目标地址、传输长度之后由DMA控制器自动把数据从设备搬到内存传输完成后向CPU发中断通知。我见过一个性能敏感的网卡驱动项目把收包路径中的CPU拷贝改成了DMA环形缓冲区吞吐直接翻倍。这就凸显了组成原理对系统软件设计的指导作用理解DMA的角色才能在写驱动或做IO优化时找到真正的瓶颈。软考操作系统知识考点里也经常把中断、DMA和缓冲技术放在一起考因为它们本来就是配套使用的一组概念。6. 指令流水线CPU的工厂流水线也会堵车和返工6.1 流水线为什么能提高吞吐却没有降低延迟如果把一条指令的五个阶段看成工厂的五道工序那么流水线就是把各道工序并行化当第1条指令进入执行阶段时第2条指令已经在访存阶段第3条正在译码……理想情况下每个时钟周期都有一条指令完成执行。这里有个概念必须分清流水线并没有让单条指令的延迟变短它让单条指令从取指到写回依然要走五个阶段但是整个CPU的吞吐量提升了五倍理想时。所以处理器的频率没有变化但单位时间完成的指令数增加了。不过理想流水线只存在于教科书。现实中有三类结构性问题会让流水线断流。6.2 数据冒险邻桌正在算的数下一道工序就要用第1条指令算出R1 R2 R3紧接着第2条指令要R4 R1 R5。问题是第1条指令的R1要等它执行阶段结束才产生而第2条在译码阶段就要读R1。在五级流水线里这两条指令相隔两个周期如果不做处理R1的旧值会被读到。硬件上最常见的解决办法是转发Forwarding在流水线寄存器之间加旁路把第1条指令执行阶段的输出直接送到第2条指令执行阶段的输入不需要真正等写回。转发不是万能的遇上load-use冒险load的数据还没到但下一条指令马上要用转发也无能为力只能插入气泡流水线停顿一个周期。我刚学这部分时对气泡完全无感后来在模拟器上调性能才发现每插入一个气泡这个流水线吞吐就少一条指令。指令调度优化师干的工作本质上就是通过重新排序指令尽量减少插入气泡的数目。6.3 控制冒险分支预测错了就要返工比数据冒险更麻烦的是分支指令。当CPU取到一条if...else...跳转指令时它还不知道是否跳转因为分支条件的计算结果要等执行阶段才出来。如果CPU傻等流水线就得空转好几个周期如果CPU先赌一把预测跳转然后执行完发现预测错了后面投机执行的指令全部作废流水线要被冲刷Flush重新从正确目标地址取指——这就是控制冒险的代价。现代CPU用分支历史表做预测单一的静态预测总是跳转/总是不跳转已经远远不够了。教科书上提到的两位饱和计数器是我们理解起步的基础把每个分支的历史状态编码成4个状态再根据结果跳转或不跳转在置信度高、置信度低之间切换。现代CPU在此基础上发展出了更复杂的TAGE预测器预测准确率能到95%以上。但作为程序员更需要记住的是不可预测的分支是性能杀手。在高性能代码里应该把最可能走的分支放前面或者干脆用算术运算替代分支。我记得看到过一个排序算法优化案例把内层循环里的分支改写成CMOV条件传送指令之后耗时降了30%——因为Intel CPU面对不可预测分支时代价是惊人的。6.4 从流水线视角看整机性能性能是流水线、Cache命中率、分支预测准确率、内存带宽这些因素的加权合力。只看CPU主频在新世纪早就不是评价机器性能的可靠指标了。两个相同主频的CPUIPC每周期指令数可能差出一倍。这也是为什么你去对比服务器整机性能时要在实际负载上跑benchmark而不是看宣传页上的4.0GHz。理解了流水线你还会明白另一个职场里常见的玄学为什么同样的代码在我的电脑上跑得快在同事的笔记本上却慢很多除了频率差异L1 Cache大小、分支预测策略、预取器的行为都会影响结果。这些恰恰是《计算机组成原理》里实实在在的技术细节而不是运气。7. 写给正在学组成原理的人几条实在的学习建议这门课的知识点密集、抽象但绝不是靠背就能过的。我踩过的坑和一些经验分享出来希望能帮你少走弯路第一把指令执行过程画成时间轴。不要只背五个阶段的名字而是选一条真实汇编指令自己动手画一条五级流水线的时空图标注出每个阶段的数据来自哪里、控制信号怎么产生。这一步做完数据冒险、控制冒险、转发、气泡全部能串起来。第二用模拟器验证理论。网上有很多开源的处理器模拟器项目比如RARSRISC-V汇编模拟器和Logisim。Logisim里能直接搭一个简单CPU每个门电路都有延迟每条线都必须手动接——当你亲手把取指、译码、执行、访存、写回五个阶段连起来好多模棱两可的概念会自动变得清晰。第三408真题的分量不止在考试。好好把历年真题里关于存储器和流水线的题目做几遍。很多题设计得非常有水平比如Cache映射与地址划分的题、DMA与中断结合的题——这些不只是考试里的送分题它们的推理过程在真实系统中排查性能问题时也经常出现。我在工作后做离线分析引擎的IO优化时用的DMA环形缓冲区设计就是当年做408题时打下的底子。第四先有树再有叶。很多人学这门课时陷入只见树木不见森林的状态盯着加法器的门电路结构死磕反而搞不懂它在整个CPU里的位置。我的建议是先建立取指-译码-执行-访存-写回和寄存器-Cache-内存-外存两条主干框架再把ALU、中断、DMA、流水线冒险这些细节挂到对应树干上。做到拿到一个硬件问题马上能定位它发生在哪一级、和哪些部件有关就已经比大多数人强了。第五一定要动手写汇编。哪怕是x86汇编写一段冒泡排序都行。你会发现高级语言里一个循环一行的操作底层竟然涉及访存、比较、跳转、规整对齐这么多细节。带着这些体验再回来看教材很多公式和图表都有生命了。《计算机组成原理》是一门越琢磨越有意思的课。它不只是一堆逻辑门和时序图更是理解现代计算机系统如何吞吐数据、如何管理资源、如何权衡性能与代价的钥匙。可能你暂时还没感受到它的用处但相信我等你某一天线上系统出现性能问题拿到一个火焰图却看不懂CPU到底卡在哪里时你会回头感谢自己今天学过的每一个流水线气泡和每一次Cache替换决策。
返回列表