
1. 为什么“目标代码生成”是最容易让人懵掉的一章说到编译原理大多数人印象最深的是词法分析、语法分析那几关毕竟正则表达式和递归下降是能亲手写出点东西的。等到第十一章“目标代码生成”很多人开始犯迷糊前面的分析阶段是“读懂”程序这章突然要“生成”程序了输出的不再是抽象语法树或者中间代码而是实实在在的汇编或者机器指令。这种身份切换是这章劝退不少人的第一个坎。我自己当年学这章时也踩过同样的坑前几章靠着画DFA、手写递归下降还能自我感觉良好一到目标代码生成就开始发虚因为它的输入是中间代码比如三地址码输出是目标机器的指令序列中间夹着大量看起来像“工程细节”但实际直接影响程序性能的东西——寄存器分配、寻址方式选择、控制流指令的回填、临时变量的生命周期管理。说实话如果不是后来实际动手写过一个小型后端我很难真正理解这章讲的东西为什么值得单独拿一整章来讲。这章到底解决什么问题核心就一句话把与机器无关的中间表示翻译成与具体机器相关的目标代码并且尽量做到“又快又小”。之所以强调“尽量”是因为目标代码生成本质上是一个优化问题同一个中间表示可以翻译成无数条指令序列有的跑得快有的占空间小有的编译时间短你要在这几个维度之间做权衡。适合谁来学如果你正在上编译原理课、准备考研复试、或者准备面试时被问到“编译器后端是怎么工作的”这章的内容都是绕不开的硬通货。如果你只是想写写解释器或者做点前端工具目标代码生成可以暂时跳过但如果你想深入理解程序真正跑起来之前发生了什么以及“高级语言为什么经过编译会变快变慢”这章值得认真啃完。2. 目标代码生成的输入、输出与核心挑战2.1 它到底接收什么又要产出一份什么样的代码目标代码生成的输入一般是前一阶段中间代码生成输出的中间表示。教材里面最常见的是三地址码Three-Address Code也就是每条指令最多包含三个地址两个操作数和一个结果例如t1 a b、t2 t1 * c这类形式。为什么是“三地址”而不是直接一步到位生成汇编因为中间表示需要足够抽象才能够在不同目标机器之间复用前端的分析代码同时又要足够接近机器结构方便后续做优化和翻译。输出则取决于目标机器类型。在编译原理课程里最常见的设定是生成一段简单RISC风格的汇编代码理由是RISC指令集每条指令格式规整、寻址方式简单、寄存器数量固定非常适合作为教学模型。等你真正进入工业界编译器后端面对的是x86、ARM、RISC-V这些真实指令集寻址方式复杂得多寄存器数量和调用约定也有各种限制但底层原理是一致的。举个最简单的例子。给定三地址码t1 a b t2 t1 * c d t2如果目标机器是“类MIPS”的简单模型它可能被翻译成lw r1, a lw r2, b add r3, r1, r2 lw r4, c mul r5, r3, r4 sw r5, d这里涉及两个核心问题一是如何决定把哪个变量放到哪个寄存器寄存器分配二是如何处理“临时变量存放地址”和“最终变量地址”之间的映射关系地址绑定。后面我会展开讲。2.2 为什么寄存器分配是“拦路虎”而不是“锦上添花”很多初学者会把寄存器分配看成一种可选优化心里想的是反正内存够用变量全放内存里不就行了话是这么说但代价非常直观如果每次运算都要从内存读操作数、算完再写回内存生成的代码不仅指令数量翻倍程序运行速度也会成倍下降。现代CPU寄存器访问速度比内存快一个数量级L1缓存也要几个周期能放寄存器的变量绝不应该放内存这是后端设计的基本共识。寄存器分配的难点在于寄存器是稀缺资源。指令集的通用寄存器数量是固定的比如x86-64有16个通用寄存器ARM有31个但活跃变量数量可能远超这个数。当“供不应求”时就必须把一部分变量溢出spill到内存并且选择合适的时机加载/保存。怎么选、何时溢出、溢出哪些变量直接决定了生成代码的质量。以经典教材里的一个简单策略为例在基本块basic block内部做寄存器分配时可以采用“为每个变量单独分配寄存器用完立即释放”的朴素策略保证寄存器不冲突。但这显然很浪费因为有些变量在后续代码中已经不再活跃即不再被读取这时候占用寄存器纯属浪费会让后面的变量被迫溢出。所以更合理的做法是先做活跃变量分析liveness analysis确定每个变量在每条指令处的存活状态然后优先把寄存器给活跃范围重叠的变量组使用。2.3 临时变量和名字的地址绑定比想象中更讲究目标代码生成里有一个细节特别容易被忽略中间代码用符号名字比如t1、b描述操作数但目标代码里操作数要么在寄存器里要么在某个内存地址。所以在生成指令之前得先把“名字”翻译成“位置”。这里要区分两类名字。一类是源程序里的变量比如a、b、d它们在最终程序里有固定位置静态数据区、栈上或者寄存器中。另一类是编译器生成的临时变量比如t1、t2它们用来暂存中间计算结果生命周期极短。对于临时变量最常用的两个策略是直接分配一个寄存器在寄存器空闲时让临时变量全程待在寄存器里需要时就取用。优点是快缺点是如果临时变量数量过多还是得溢出。复用临时单元临时变量池同类临时变量可以复用同一块存储只要它们的活跃范围不重叠。这个策略的好处是能显著减少内存占用量代价是需要活跃性分析支持。实操心得我实际写过一个小后端之后才意识到临时变量复用看上去很简单但极容易出错因为“活跃范围不重叠”的判断如果错了生成代码在特定数据下就会算出错误结果而且很难复现。所以我在自己的项目里宁可先让每个临时变量单独占一个寄存器或栈槽逻辑跑通了再一步步做复用优化。这个“先正确再高效”的顺序值得所有学习者遵守。3. 指令选择与寻址方式决定代码“长什么样”3.1 从三地址码到指令模板中间的映射规则是什么指令选择Instruction Selection解决的是“用哪条目标指令实现一个操作”的问题。理想情况下一条三地址码对应一条目标指令比如a b c对应add。但现实是目标机器的指令集往往比中间代码的操作类型更丰富也受各种约束限制。一个典型的例子是如果三地址码里有t1 a[0]读取数组元素这种操作目标机器如果没有“寄存器加偏移量的内存寻址”模式就得先生成计算地址的指令再生成加载指令但如果有类似x86的mov eax, [ebx offset]那一条指令就能搞定。所以指令选择的本质是在指令模板库中寻找匹配的片段尽量用最少指令、最短延迟来完成操作。教材里常用的方法是“树重写”tree rewriting把中间代码表示成树结构然后使用类似“tile”的规则集去覆盖树目标是覆盖方式的总代价最小。实际工程中LLVM后端使用的是SelectionDAG做法类似但是面向真实指令集做了海量模式匹配。虽然课程不要求你手写一个完整的指令选择器但理解“模板匹配 代价最小化”这个思路对你理解编译器为什么能生成高质量指令非常有帮助。3.2 寻址方式对生成代码长度的影响非常大在目标代码生成中寻址方式addressing mode决定了如何去取一个操作数。很多教材在这个地方花了不少篇幅因为同一个操作数选用不同的寻址方式生成的代码可能截然不同。常见的寻址方式包括以简化的类RISC模型为例直接寻址操作数地址在指令中直接给出适合全局变量和静态变量。寄存器寻址操作数在寄存器中指令里只需给出寄存器编号。立即数寻址操作数本身是个常量直接嵌在指令里。间接寻址操作数地址存放在寄存器或内存单元中适合指针访问。变址寻址寄存器加上偏移量得到有效地址适合数组和结构体访问。选择哪种寻址方式一方面看源语言语义比如x 42里的42显然适合立即数寻址另一方面看能否减少指令数量。比如a[i]这种访问如果支持变址寻址可能一条指令搞定如果不支持就得先把a的基址加载到寄存器再把i乘以元素大小加到基址上然后才能加载指令数可能会多出两三条。实操中一个常见的坑是为了生成更短的代码盲目选择“看起来更复杂”的寻址方式结果导致指令的访存次数增加反而拖慢执行速度。因为寻址方式复杂往往意味着更多访存操作而访存在现代CPU里是最贵的操作之一。所以指令数量和指令代价要一起看不能光看条数。3.3 一个小例子从三地址码到汇编的完整映射下面我用一个很小的例子说明指令选择和寻址方式对最终代码的影响。假设三地址码是t a b c t * 2采用“每个变量独占内存地址 寄存器中转”的朴素翻译策略代码可能长这样lw r1, a # 从内存加载 a lw r2, b # 从内存加载 b add r3, r1, r2 # a b - r3 sw r3, t # 暂存 t lw r4, t # 重新加载 t实际上可以直接用 r3 mul r5, r4, r4 # 错误演示占位后面再展开这里有一处明显的浪费t刚刚算出来存在寄存器r3里马上又把它存到内存再从内存加载回来来回折腾。正确做法是在t依然活跃时保持它在寄存器里lw r1, a lw r2, b add r3, r1, r2 mul r3, r3, 2 # 如果目标机器支持立即数乘法 sw r3, c # 最终结果写回内存这样指令数量从8条降到5条原因就是利用了活跃变量分析知道t用完后不再需要以及立即数寻址让常量乘法不依赖额外的寄存器加载。从这里你应该能看出目标代码生成并不是机械翻译那么简单它处处是权衡。而理解权衡的基础是搞清楚每条指令的真正代价。这也是为什么我建议初学者在学习时先拿一个简单的教学指令集把每条指令的周期数和访存次数写在旁边再回去看生成的代码很多“为什么教材这么写”的问题就迎刃而解了。4. 寄存器分配、活跃变量分析与临时变量的生命周期4.1 活跃变量分析目标代码优化里的“地基”如果说寄存器分配是目标代码生成的核心工程那活跃变量分析就是支撑它的地基。活跃性liveness的定义不复杂在一个程序点p如果变量x的值会在未来被读取那么我们就说x在p点是活跃的。反过来如果x的值在当前点之后永远不被读取那它就是不活跃的可以放心地让出寄存器或内存。计算活跃变量可以基于控制流图CFG做数据流分析从基本块的出口往入口反向传播。具体来说对于一个基本块我们要计算两个集合入口活跃变量集合live-in和出口活跃变量集合live-out。基本块内部按指令逆序迭代遇到形如x y z的指令时y、z是“被使用”的它们需要在执行该指令前已经是活的x是“被定义”的它在执行该指令后不再需要旧值所以要从活跃集合中删掉。公式化表达就是live-in use(B) ∪ (live-out - def(B))其中use(B)是基本块内“先使用后定义”的变量集合def(B)是基本块内被定义的变量集合。多个基本块之间根据控制流关系反复迭代直到所有集合不再变化。这个算法本身并不难难的是理解它为什么要存在。我在面试别人时就常问如果临时变量t1的生命周期很短编译器怎么知道它可以在第3条指令之后就把寄存器让给别的新变量答案就是活跃变量分析。没有这个分析寄存器分配只能按“最保守”的策略把所有变量从头到尾锁在内存里性能直接垮掉。4.2 基本块内的寄存器分配从“朴素法”到“引用计数法”在编译原理课程的范围内最常见的寄存器分配方法是把基本块视为一个独立单元在基本块内部进行局部寄存器分配。教材通常介绍两种策略朴素法为每个变量分配一个寄存器直到所有寄存器耗尽然后开始溢出内存。引用计数法统计每个变量在基本块剩余代码中的引用次数指作为操作数被读取的次数把寄存器优先分配给引用次数多的变量这样能最大限度减少访存。举个例子。假设某基本块中有变量a后续有5次读取变量b后续只有1次读取。如果寄存器紧张显然应该优先给a保留寄存器让b在需要时从内存加载。这种“朝三暮四”式的选择看起来只是在做优先级排序但它实打实地决定了生成的代码会少掉多少次加载操作。引用计数法的另一变体是当变量在某条指令后不再活跃时立即释放其占用的寄存器也称作“死亡变量释放”这样后续指令可以更早获得空闲寄存器减少溢出几率。4.3 还有一种很实用的策略把寄存器看成“缓存”其实把寄存器分配理解为“用寄存器给内存变量做缓存”是特别准确的。你写程序时定义一个变量它在源语言语义上是“存放在某块内存”的但在运行时只要这个变量活跃编译器就会尽量把它“缓存”在某个寄存器里从而避免每次访问都跑一遍内存。有了这个类比之后很多问题就通了为什么函数调用点上寄存器会变得紧张因为调用者和被调者都要用寄存器调用约定规定了哪些寄存器调用者保存、哪些被调者保存本质就是在管理寄存器“缓存”的换入换出。为什么循环里的变量比循环外的变量更渴望寄存器因为循环体执行次数多cache命中带来的收益更大。为什么溢出spill之后代码变慢因为缓存容量不够时每次使用变量都要访问内存相当于每次cache miss。这个思路不仅能帮你做题还能帮你在真实工程里理解为何编译器开了O2优化后同样的C代码生成的汇编会有那么大的差别。很多情况下主要差距就来自于寄存器的使用是否充分。4.4 实操建议自己动手写局部寄存器分配器如果你正在学这章我强烈建议你用Python或其他语言写一个“玩具寄存器分配器”流程如下输入一段三地址码可以直接手工编写测试样例。为它划分基本块生成控制流图。对每个基本块做活跃变量分析搞清楚每条指令前后的活跃集合。按“引用计数 死亡释放”的策略逐条指令生成目标汇编先不管真实机器指令就输出类似R1,R2的结果形式。打印生成的指令序列和“朴素法”生成的代码做对比看看寄存器分配优化节省了多少条指令。这个练习看起来有点繁琐但它能把本章80%的核心概念串起来而且写完之后你对“编译器后端到底在忙什么”会有真正切身的理解不是只会背概念。我在带学生做类似练习时发现一个常见的误区有人直接用全局的寄存器分配算法比如图着色搞得代码非常复杂结果在小例子里反而看不到收益。我的建议是先搞定局部再扩展到全局。局部寄存器分配虽然“简单”但它足够让你理解寄存器分配的本质矛盾——数量不够用选择最值得留的变量留下。5. 控制流指令的生成跳转目标的回填技术5.1 为什么控制流翻译比表达式翻译更“麻烦”前面讲的都是表达式和赋值语句的翻译看起来还算顺理成章。但目标代码生成里有一块内容特别容易让人抓狂if、while、for这类控制流的翻译。难点在于生成跳转指令时跳转目标往往还没有确定下来。举个例子。把下面的三地址码翻译成汇编if a b goto L1 t a b goto L2 L1: t a - b L2: c t生成if a b goto L1时L1的地址还没生成它要在后面才出现。这时候怎么办最直接的办法是“回填”backpatching先给跳转指令留一个空位等目标地址确定之后再把这个空位补上。实际处理中大家通常用“符号标签”来避免先算绝对地址先把L1、L2当作符号在所有指令都生成之后再解析标签的具体位置。但真实编译器中为了让跳转指令的机器码正确编码尤其是条件跳转通常有距离限制往往需要“两遍扫描”或者“指令延迟槽/松弛”机制来调整最终跳转位移。5.2 回填的启动条件它不是一个独立优化而是基本生成步骤回填最精髓的地方在于它不仅适用于标签地址也适用于布尔表达式短路求值short-circuit evaluation的翻译。比如C语言中的if (a 0 b 0)如果a 0不成立就不需要再判断b 0直接跳转到假出口。为了实现这个效果编译器在翻译第一个比较语句时就需要预留一个“当条件为假时跳转”的空槽等布尔表达式的整体结构翻译完才知道槽位应该填向哪里。这就是经典的“拉链回填”zipper backpatching技术把需要回填的跳转指令地址放在一个链表中当目标确定时沿着链表挨个去填。用中文教材的话说这叫“回填真链”和“回填假链”分别对应布尔表达式为真和为假时需要跳转到的地址集合。如果你感兴趣可以查阅“编译原理 第三版”的相关习题里面有大量布尔表达式控制流的四元式翻译练习。做会这些题你对回填的理解就不会只停留在概念层面了。5.3 条件跳转的一个实操坑别把“真跳”和“假跳”搞反在写递归下降或者中间代码生成程序时我踩过最经典的坑就是真跳、假跳和“反转条件”之间的关系。假设源语句是if (a 0) { x 1; } else { x 2; }。一种翻译策略是“条件为假跳到else”那编写的跳转指令应当是if a 0 goto L_else // then分支 x 1 goto L_end L_else: // else分支 x 2 L_end:注意原代码的条件是a 0但生成的条件跳转指令变成了a 0才跳转。因为我们要的是“条件不满足时跳到别处”。如果你直接把a 0翻译成beq/blt跳转到then分支就会出现逻辑反转错误。实操心得我在调试自己的编译器时会特意用一组覆盖“条件真”、“条件假”、“边界值”的极小测试用例逐条检查跳转指令的目标。发现错误时先别急着改代码而是先把“源程序的执行路径”和“生成汇编的执行路径”都手工走一遍往往能快速定位是跳转条件反了还是回填目标错了。这个问题在手工设计中间代码格式时尤其容易出现因为“跳转条件”和“输出分支结构”之间存在两层抽象。6. 目标代码的优化窥孔优化与指令松弛6.1 什么是窥孔优化为什么名字这么奇怪窥孔优化Peephole Optimization是一种非常实用、也是教材必提的目标代码优化技术。它的思路特别直白把生成的指令看成一条很长的序列用一个“小窗口”peephole在序列上滑动窗口内如果出现可以替换的局部指令模式就做一个局部等价变换。为什么叫“窥孔”想象你透过一个极小的窗口看代码每次只能看到寥寥几条指令在这个范围内做优化。它不像全局优化那样需要把握整个程序的运行过程只做完全安全的局部变换。常见的窥孔优化模式包括冗余跳转消除goto L1后面紧跟L1:即跳转到下一条指令这个跳转可以直接删掉。不可达代码删除goto L2后面的指令如果没有其他入口可以删除。相邻运算合并add r1, r1, 0可以替换成一条mov甚至什么都不做如果r1不需要置标志位。加载后立即存储消除lw r1, a; sw r1, a如果中间没有其他指令修改内存这条加载/存储对可以删除。这些优化看起来非常“没技术含量”但实际工程中特别有用。因为很多时候前面阶段尤其是语法翻译或者中间代码生成会产生大量“模板化”指令这些指令单看没问题合在一起有大量空转。窥孔优化用最小代价把“模板化”导致的冗余去掉性价比极高。6.2 指令松弛跳转距离不够时的“补救”技术在真实指令集中条件跳转和无条件跳转往往有不同的编码范围。比如一个短跳转只能跳 ±128 字节长跳转可以覆盖整个地址空间但指令更长。如果最初生成的是短跳转最后因为代码膨胀导致目标超出范围就需要“指令松弛”instruction relaxation机制把超出距离的短跳转替换成“反向条件跳转 长跳转”的组合然后重新扫描是否有新的越界问题。很多初学者不理解为什么编译器的汇编阶段会有“两遍”或“多遍”这种设计其实指令松弛就是其中一个重要原因。因为指令长度不固定第一条指令的地址会影响到后面指令的地址进而影响跳转目标距离反过来又可能改变某些指令是否选择长编码所以需要迭代到稳定状态。虽然课程里一般不会让你真正去实现指令松弛但请在看到“两遍扫描”这个词时不要觉得是老师故意搞复杂。只要你在真实机器上做过一层汇编器或者链接器就一定知道“长度不固定到底有多麻烦”。6.3 生成“看着没问题”的代码和“真的能跑”的代码是两码事最后说一个我在实际项目中反复遇到的事很多人会把验证重点放在“指令对不对”上而忽略了“寄存器别被覆盖”。比如生成了两条指令lw r1, a lw r2, b add r3, r1, r2 sw r3, a看起来没问题。但如果你在前面生成了lw r1, a后面一个指令又需要把a重新加载进r1而中间r1被别的计算覆盖了那就出事了。寄存器分配的核心职责之一就是保证一条指令使用的寄存器在它使用的那个时间点上内容确实是操作数想要的值。实操中我建议你给生成的汇编加一个“寄存器活跃性快照”每生成一条指令就把每个寄存器的“当前含义”打出来例如r1 - 变量ar3 - 空。只要某个寄存器的“当前含义”和指令期望的操作数不匹配就说明生成逻辑有问题。这种调试法比肉眼盯汇编高效得多。7. 从课程习题到真实编译器目标代码生成的延伸应用7.1 课程学习时怎么把“做对题”变成“做懂题”如果你正在准备考试目标代码生成的题型大概围绕这几个方向根据三地址码生成汇编考指令选择和寻址方式、给定寄存器数量求溢出次数考寄存器分配策略、计算活跃变量集合考数据流分析、补全回填后的跳转目标考控制流翻译。这些题看上去是机械操作但如果只是套公式分数可能不错理解却很虚。我的建议是每做完一道题都追问自己三个问题。第一如果寄存器数量增加一倍我的分配策略会变吗第二如果目标机器没有立即数寻址指令序列会怎么变第三如果条件跳转指令只支持比较大小为0if (a b)要怎么翻译这三个问题能把一道“翻译题”扩展成“设计题”帮你从“会做”走向“会想”。7.2 面试中被问到“编译器后端了解多少”时怎么展示深度编译原理面试题中目标代码生成是后端方向的高频考点。面试官如果问“讲讲目标代码生成的流程”很多人的回答会是“把中间代码翻译成机器代码”这种回答等于没说。更好的回答方式是先讲输入输出输入是三地址码/LLVM IR输出是目标汇编或机器码。再讲核心子任务指令选择、寄存器分配、指令调度如果涉及性能、窥孔优化。然后讲一个关键难点以寄存器分配为例说明寄存器数量有限而活跃变量可能很多需要用活跃变量分析确定哪些变量该驻留寄存器必要时溢出到内存。最后举一个具体例子比如三地址码t a b; c t * 2通过寄存器复用来减少访存从“朴素翻译”变为“带优化的翻译”代码条数能省多少。这种回答方式既展示了你对整体流程的把握又体现你的实操意识。如果你还有过写小型编译器的经历哪怕只是几千行面试时都能成为加分项。因为后端这个东西“看着懂”和“写出来能跑”之间隔着一道巨大的鸿沟能跑通任何一段代码都说明你真的跨过去了。7.3 现代化编译器里的目标代码生成LLVM和GCC是怎么做的说实话课程里的目标代码生成和工业级编译器中的目标代码生成差异比想象中大得多。工业级编译器不会一棵树上吊死它们常用的结构是“前端生成IR中端做优化后端进行指令选择、寄存器分配和指令调度”。LLVM的Instruction Selection基于SelectionDAG寄存器分配默认使用“贪心分配器Greedy Allocator”它是从经典图着色算法演化来的GCC的RTL阶段也有自己的指令模板匹配和寄存器分配层。如果你学过这些经典方法后再回头看教材会有一种“原来教材是减配版LLVM”的感觉。教材刻意减少复杂度把指令集换成RISC风格把寄存器分配简化成“基本块内引用计数”是为了让你抓住本质而不是被真实机器的细节淹没。所以不必觉得教材内容“过时”恰恰相反它就是工业界做法的理论骨架。8. 常见问题速查与避坑清单下面是我踩过的一些坑和能直接用的经验整理成一个速查表典型问题错误表现排查思路与正确做法临时变量反复加载/保存生成代码里同一变量在相邻指令间存取多次做活跃变量分析临时变量活跃期间保持寄存器占用不要提前保存跳转目标回填错误程序跳转后执行了错误分支手工走查执行路径确定真链/假链地址建议用最小测试集覆盖边界寄存器被意外覆盖前一条指令的值在后续指令里丢失为每个寄存器维护“当前含义”快照生成指令前检查寄存器是否正被占用立即数和内存地址混淆常数值被当成地址加载/存储检查寻址方式立即数直接用立即数指令避免先加载到寄存器再参与运算控制流块未按标签分布好生成代码跳转到错误的基本块给每个基本块独立标签用控制流图核对跳转目标边界先优化后正确本来能跑的代码被“优化”出bug先保证朴素生成结果正确再一步步添加优化每步用测试跑通在动手写或者调试代码时我的习惯是把生成的目标代码拆成三类分别验证。第一类是纯表达式计算用几组常量即可第二类是含控制流的代码用分支条件交替验证第三类是含循环和数组访问的代码用连续几轮迭代看结果。每一类都能通过之后再合在一起跑综合用例。这种分层验证法看起来慢但能最快定位问题出现在前后端的哪一部分。最后再分享一个小技巧如果你刚开始学这章千万不要一上来就用“教科书里的高深术语”去硬套代码。先写一个最简单的流程比如“输入三地址码 → 轴翻译成汇编 → 人工对比执行结果”把它跑通之后再把寄存器分配、寻址方式、回填这些优化逐项加进去。每一层优化都保持“能回退”的状态这样出了问题你能明确知道是哪一层引入的。目标代码生成本身就是在多条约束之间找平衡做好了你对编译器整个后半段会有一种通透感做不好就只是背了一堆名词这一点我体会特别深。