I recently wrote a blog post giving an introduction to reverse engineering and assembly language on the Purism blog. Considering that my last blog post on my own website is from 3 years ago and this post is useful beyond the needs of just Purism, I thought it might have a nice home in my own personal blog as well, so here’s a copy paste of the entire blog post, as is.我最近在Purism博客上写了一篇博客文章,介绍了逆向工程和汇编语言。考虑到我在自己网站上的最后一篇博客文章是 3 年前的,这篇文章的用处超出了 Purism 的需求,我认为它可能在我自己的个人博客中也有一个很好的家,所以这里是整个博客文章的复制粘贴,原样。
Recently, I’ve finished reverse engineering the Intel FSP-S “entry” code, that is from the entry point (FspSiliconInit) all the way to the end of the function and all the subfunctions that it calls. This is only some initial foray into reverse engineering the FSP as a whole, but reverse engineering is something that takes a lot of time and effort. Today’s blog post is here to illustrate that, and to lay the foundations for understanding what I’ve done with the FSP code (in a future blog post).最近,我完成了对英特尔 FSP-S“入口”代码的逆向工程,即从入口点 (FspSiliconInit) 一直到函数的末端及其调用的所有子函数。这只是对 FSP 作为一个整体进行逆向工程的一些初步尝试,但逆向工程需要大量的时间和精力。今天的博客文章是为了说明这一点,并为理解我使用 FSP 代码所做的工作奠定基础(在以后的博客文章中)。
Over the years, many people asked me to teach them what I do, or to explain to them how to reverse engineer assembly code in general. Sometimes I hear the infamous “How hard can it be?” catchphrase. Last week someone I was discussing with thought that the assembly language is just like a regular programming language, but in binary form—it’s easy to make that mistake if you’ve never seen what assembly is or looks like. Historically, I’ve always said that reverse engineering and ASM is “too complicated to explain” or that “If you need help to get started, then you won’t be able to finish it on your own” and various other vague responses—I often wanted to explain to others why I said things like that but I never found a way to do it. You see, when something is complex, it’s easy to say that it’s complex, but it’s much harder to explain to people why it’s complex.多年来,许多人要求我教他们我是做什么的,或者向他们解释如何对汇编代码进行一般的逆向工程。有时我会听到臭名昭著的“这能有多难?”的口号。上周,有人在我讨论时认为,汇编语言就像一种常规的编程语言,但采用二进制形式——如果你从未见过汇编是什么或看起来是什么样子,就很容易犯这个错误。从历史上看,我总是说逆向工程和 ASM “太复杂了,无法解释”,或者“如果你需要帮助才能开始,那么你将无法自己完成它”以及其他各种含糊不清的回应——我经常想向别人解释为什么我会说这样的话,但我从来没有找到方法去做。你看,当某件事很复杂时,说它很复杂很容易,但向人们解释它为什么复杂要困难得多。
I was lucky to recently stumble onto a little function while reverse engineering the Intel FSP, a function that was both simple and complex, where figuring out what it does was an interesting challenge that I can easily walk you through. This function wasn’t a difficult thing to understand, and by far, it’s not one of the hard or complex things to reverse engineer, but this one is “small and complex enough” that it’s a perfect example to explain, without writing an entire book or getting into the more complex aspects of reverse engineering. So today’s post serves as a “primer” guide to reverse engineering for all of those interested in the subject. It is a required read in order to understand the next blog posts I would be writing about the Intel FSP. Ready? Strap on your geek helmet and let’s get started!我很幸运,最近在对英特尔 FSP 进行逆向工程时偶然发现了一个小功能,这个功能既简单又复杂,弄清楚它的作用是一个有趣的挑战,我可以轻松引导您完成。这个功能并不是一件难理解的事情,到目前为止,它不是逆向工程难或复杂的事情之一,但这个功能“足够小而复杂”,它是一个完美的解释例子,无需写一整本书或深入到逆向工程的更复杂方面。因此,今天的帖子为所有对该主题感兴趣的人提供了逆向工程的“入门”指南。这是一本必读书目,以便理解我将要撰写的有关英特尔 FSP 的下一篇博客文章。准备?戴上你的极客头盔,让我们开始吧!
DISCLAIMER: I might make false statements in the blog post below, some by mistake, some intentionally for the purpose of vulgarizing the explanations. For example, when I say below that there are 9 registers in X86, I know there are more (SSE, FPU, or even just the DS or EFLAGS registers, or purposefully not mentioning EAX instead of RAX, etc.), but I just don’t want to complicate matters by going too wide in my explanations.免责声明:我可能会在下面的博客文章中做出虚假陈述,有些是错误的,有些是故意的,目的是使解释庸俗化。例如,当我在下面说 X86 中有 9 个寄存器时,我知道还有更多(SSE、FPU,甚至只是 DS 或 EFLAGS 寄存器,或者故意不提及 EAX 而不是 RAX 等),但我只是不想在我的解释中过于宽泛地使事情复杂化。
First things first, you need to understand some basic concepts, such as “what is ASM exactly”. I will explain some basic concepts but not all the basic concepts you might need. I will assume that you know at least what a programming language is and know how to write a simple “hello world” in at least one language, otherwise you’ll be completely lost.首先,您需要了解一些基本概念,例如“ASM 到底是什么”。我将解释一些基本概念,但不是您可能需要的所有基本概念。我假设你至少知道什么是编程语言,并且知道如何用至少一种语言编写一个简单的“你好世界”,否则你会完全迷失方向。
So, ASM is the Assembly language, but it’s not the actual binary code that executes on the machine. It is however, very similar to it. To be more exact, the assembly language is a textual representation of the binary instructions given to the microprocessor. You see, when you compile your regular C program into an executable, the compiler will transform all your code into some very, very, very basic instructions. Those instructions are what the CPU will understand and execute. By combining a lot of small, simple and specific instructions, you can do more complex things. That’s the basis of any programming language, of course, but with assembly, the building blocks that you get are very limited. Before I’ll talk about instructions, I want to explain two concepts first which you’ll need to follow the rest of the story.因此,ASM 是汇编语言,但它不是在机器上执行的实际二进制代码。然而,它与它非常相似。更准确地说,汇编语言是提供给微处理器的二进制指令的文本表示。你看,当你把常规的C程序编译成一个可执行文件时,编译器会把你所有的代码转换成一些非常、非常、非常基本的指令。这些指令是 CPU 将理解和执行的指令。通过结合许多小的、简单的和具体的指令,你可以做更复杂的事情。当然,这是任何编程语言的基础,但是使用汇编语言,你得到的构建块非常有限。在我讨论说明之前,我想先解释两个概念,您需要遵循故事的其余部分。
The stack 堆栈
First I’ll explain what “the stack” is. You may have heard of it before, or maybe you didn’t, but the important thing to know is that when you write code, you have two types of memory:首先,我将解释什么是“堆栈”。你可能以前听说过它,或者你可能没有听说过,但重要的是要知道,当你编写代码时,你有两种类型的内存:
- The first one is your “dynamic memory”, that’s when you call ‘malloc’ or ‘new’ to allocate new memory, this goes from your RAM upward (or left-to-right), in the sense that if you allocate 10 bytes, you’ll first get address 0x1000 for example, then when you allocate another 30 bytes, you’ll get address 0x100A, then if you allocate another 16 bytes, you’ll get 0x1028, etc.
第一个是你的“动态内存”,即当你调用“malloc”或“new”来分配新的内存时,这是从你的RAM向上(或从左到右)进行的,从某种意义上说,如果你分配10个字节,你将首先得到地址0x1000例如,然后当你分配另外30个字节时,你将得到地址0x100A,然后如果你分配另外16个字节, 你会得到0x1028,等等。
- The second type of memory that you have access to is the stack, which is different, instead it grows downward (or right-to-left), and it’s used to store local variables in a function. So if you start with the stack at address 0x8000, then when you enter a function with 16 bytes worth of local variables, your stack now points to address 0x7FF0, then you enter another function with 64 bytes worth of local variables, and your stack now points to address 0x7FB0, etc. The way the stack works is by “stacking” data into it, you “push” data in the stack, which puts the variable/data into the stack and moves the stack pointer down, you can’t remove an item from anywhere in the stack, you can always only remove (pop) the last item you added (pushed). A stack is actually an abstract type of data, like a list, an array, a dictionary, etc. You can read more about what a stack is on wikipedia and it shows you how you can add and remove items on a stack with this image:
您可以访问的第二种类型的内存是堆栈,它是不同的,而是向下(或从右到左)增长,用于在函数中存储局部变量。因此,如果您从地址 0x8000 的堆栈开始,那么当您输入一个具有 16 个字节的局部变量的函数时,您的堆栈现在指向地址 0x7FF0,然后您输入另一个具有 64 个字节的局部变量的函数,并且您的堆栈现在指向地址 0x7FB0,等等。堆栈的工作方式是将数据“堆叠”到堆栈中,您将数据“推送”到堆栈中,这会将变量/数据放入堆栈中并向下移动堆栈指针,您不能从堆栈中的任何地方删除项目,您始终只能删除(弹出)您添加(推送)的最后一个项目。堆栈实际上是一种抽象类型的数据,如列表、数组、字典等。您可以在维基百科上阅读有关堆栈的更多信息,它向您展示了如何使用此图像在堆栈上添加和删除项目:

The image shows you what we call a LIFO (Last-In-First-Out) and that’s what a stack is. In the case of the computer’s stack, it grows downward in the RAM (as opposed to upward in the above image) and is used to store local variables as well as the return address for your function (the instruction that comes after the call to your function in the parent function). So when you look at a stack, you will see multiple “frames”, you’ll see your current function’s stack with all its variables, then the return address of the function that called it, and above it, you’ll see the previous function’s frame with its own variables and the address of the function that called it, and above, etc. all the way to the main function which resides at the top of the stack.该图显示了我们所说的 LIFO(后进先出),这就是堆栈。在计算机堆栈的情况下,它在 RAM 中向下增长(与上图中向上增长相反),并用于存储局部变量以及函数的返回地址(在父函数中调用函数后的指令)。因此,当你查看一个堆栈时,你会看到多个“帧”,你会看到你当前函数的堆栈及其所有变量,然后是调用它的函数的返回地址,在它上面,你会看到前一个函数的框架,它有自己的变量和调用它的函数的地址,以及上面, 等等,一直到位于堆栈顶部的 main 函数。
Here is another image that exemplifies this:这是另一个示例图像:

The registers 寄存器
The second thing I want you to understand is that the processor has multiple “registers”. You can think of a register as a variable, but there are only 9 total registers on x86, with only 7 of them usable. So, on the x86 processor, the various registers are: EAX, EBX, ECX, EDX, EDI, ESI, EBP, ESP, EIP.我希望您了解的第二件事是处理器有多个“寄存器”。您可以将寄存器视为变量,但 x86 上总共只有 9 个寄存器,其中只有 7 个可用。因此,在 x86 处理器上,各种寄存器是:EAX、EBX、ECX、EDX、EDI、ESI、EBP、ESP、EIP。
There are two registers in there that are special:其中有两个特殊的寄存器:
- The EIP (Instruction Pointer) contains the address of the current instruction being executed.
EIP(指令指针)包含当前正在执行的指令的地址。
- The ESP (Stack Pointer) contains the address of the stack.
ESP(堆栈指针)包含堆栈的地址。
Access to the registers is extremely fast when compared to accessing the data in the RAM (the stack also resides on the RAM, but towards the end of it) and most operations (instructions) have to happen on registers. You’ll understand more when you read below about instructions, but basically, you can’t use an instruction to say “add value A to value B and store it into address C”, you’d need to say “move value A into register EAX, then move value B into register EBX, then add register EAX to register EBX and store the result in register ECX, then store the value of register ECX into the address C”.与访问RAM中的数据相比,对寄存器的访问速度非常快(堆栈也位于RAM上,但位于RAM的末端),并且大多数操作(指令)都必须在寄存器上进行。当您阅读下面的说明时,您会理解更多,但基本上,您不能使用指令说“将值 A 添加到值 B 并将其存储到地址 C”,您需要说“将值 A 移动到寄存器 EAX,然后将值 B 移动到寄存器 EBX,然后添加寄存器 EAX 以注册 EBX 并将结果存储在寄存器 ECX 中, 然后将寄存器 ECX 的值存储到地址 C”。
The instructions 说明
Let’s go back to explaining instructions now. As I explained before, the instructions are the basic building blocks of the programs, and they are very simple, they take the form of:现在让我们回过头来解释说明。正如我之前解释的,指令是程序的基本构建块,它们非常简单,它们采用以下形式:
Where “INS” is the instruction”, and OP1, OP2, OP3 is what we call the “operand”, most instructions will only take 2 operands, some will take no operands, some will take one operand and others will take 3 operands. The operands are usually registers. Sometimes, the operand can be an actual value (what we call an “immediate value”) like “1”, “2” or “3”, etc. and sometimes, the operand is a relative position from a register, like for example “[%eax + 4]” meaning the address pointed to by the %eax register + 4 bytes. We’ll see more of that shortly. For now, let’s give you the list of the most common and used instructions:其中“INS”是指令“,OP1、OP2、OP3是我们所说的”操作数“,大多数指令只接受2个操作数,有些不接受操作数,有些需要一个操作数,有些则需要3个操作数。操作数通常是寄存器。有时,操作数可以是实际值(我们称之为“即时值”),如“1”、“2”或“3”等,有时,操作数是寄存器的相对位置,例如“[%eax + 4]”,表示 %eax 寄存器指向的地址 + 4 个字节。我们很快就会看到更多这样的内容。现在,让我们为您提供最常见和最常用的说明列表:
- “ADD/SUB/MUL/DIV“: Add, Substract, Multiply, Divide one operand with another and store the result in a register
- “JMP/JZ/JNZ/JB/JS/etc.”: Jump to another instruction (Jump unconditionally, Jump if Zero, Jump if Not Zero, Jump if Below, Jump if Sign, etc.)
- “CALL“: Call a function. This is the equivalent of doing a “PUSH %EIP+4” + “JMP”. I’ll get into calling conventions later..
“CALL”:调用函数。这相当于执行 “PUSH %EIP+4” + “JMP”。我稍后会介绍调用约定。
- “RET“: Return from a function. This is the equivalent of doing a “POP %EIP”
“RET”:从函数返回。这相当于执行“POP %EIP”
That’s about it, that’s what most programs are doing. Of course, there’s a lot more instructions, you can see a full list here, but you’ll see that most of the other instructions are very obscure or very specific or variations on the above instructions, so really, this represents most of the instructions you’ll ever encounter.就是这样,这就是大多数程序正在做的事情。当然,还有更多的说明,你可以在这里看到一个完整的列表,但你会看到大多数其他说明都非常晦涩或非常具体或对上述说明有不同,所以实际上,这代表了你会遇到的大部分说明。
I want to explain one thing before we go further down: there is an additional register I didn’t mention before called the FLAGS register, which is basically just a status register that contains “flags” that indicate when some arithmetic condition happened on the last arithmetic operation. For example, if you add 1 to 0xFFFFFFFF, it will give you ‘0’ but the “Overflow flag” will be set in the FLAGS register. If you substract 5 from 0, it will give you 0xFFFFFFFB and the “Sign flag” will be set because the result is negative, and if you substract 3 from 3, the result will be zero and the “Zero flag” will be set.在我们进一步讨论之前,我想解释一件事:有一个我之前没有提到的附加寄存器,称为 FLAGS 寄存器,它基本上只是一个状态寄存器,其中包含“标志”,指示在最后一次算术运算中发生某些算术条件的时间。例如,如果将 1 加到 0xFFFFFFFF,则将得到“0”,但将在 FLAGS 寄存器中设置“溢出标志”。如果你从 0 中减去 5,它将得到你0xFFFFFFFB并且将设置“符号标志”,因为结果是负的,如果你从 3 中减去 3,结果将为零并且将设置“零标志”。
I’ve shown you the “CMP” instruction which is used to compare a register with an operand, but you might be wondering, “What does it mean exactly to ‘compare’?” Well, it’s simple, the CMP instruction is the same thing as the SUB instruction, in that, it substracts one operand from another, but the difference is that it doesn’t store the result anywhere. However, it does get your flags updated in the FLAGS register. For example, if I wanted to compare %EAX register with the value ‘2’, and %EAX contains the value 3, this is what’s going to happen: you will substract 2 from the value, the result will be 1, but you don’t care about that, what you care about is that the ZF (Zero flag) is not set, and the SF (Sign flag is not set), which means that %eax and ‘2’ are not equal (otherwise, ZF would be set), and that the value in %eax is superior to 2 (because SF is not set), so you know that “%eax > 2” and that’s what the CMP does.我已经向您展示了用于比较寄存器和操作数的“CMP”指令,但您可能想知道,“'比较'到底是什么意思?嗯,这很简单,CMP 指令与 SUB 指令是一回事,因为它从一个操作数中减去另一个操作数,但区别在于它不会将结果存储在任何地方。但是,它确实会在 FLAGS 寄存器中更新您的标志。例如,如果我想比较值为“2”的 %EAX 寄存器,而 %EAX 包含值 3,则将要发生的情况是:您将从值中减去 2,结果将是 1,但您不关心这一点,您关心的是未设置 ZF(零标志), 和 SF(未设置符号标志),这意味着 %eax 和“2”不相等(否则,将设置 ZF),并且 %eax 中的值优于 2(因为未设置 SF),因此您知道“%eax > 2”,这就是 CMP 的作用。
The TEST instruction is very similar but it does a logical AND on the two operands for testing, so it’s used for comparing logical values instead of arithmetic values (“TEST %eax, 1” can be used to check if %eax contains an odd or even number for example).TEST 指令非常相似,但它对两个操作数执行逻辑 AND 以进行测试,因此它用于比较逻辑值而不是算术值(例如,“TEST %eax, 1”可用于检查 %eax 是否包含奇数或偶数)。
This is useful because the next bunch of instructions I explained in the list above is conditional Jump instructions, like “JZ” (jump if zero) or “JB” (jump if below), or “JS” (jump if sign), etc. This is what is used to implement “if, for, while, switch/case, etc.” it’s as simple as doing a “CMP” followed by a “JZ” or “JNZ” or “JB”, “JA”, “JS”, etc.这很有用,因为我在上面的列表中解释的下一组指令是条件跳转指令,如“JZ”(如果为零则跳转)或“JB”(如果在下面跳转),或者“JS”(如果符号跳转)等。这就是用于实现“if、for、while、switch/case 等”的方法,就像执行“CMP”后跟“JZ”或“JNZ”或“JB”、“JA”、“JS”等一样简单。
And if you’re wondering what’s the difference between a “Jump if below” and “Jump if sign” and “Jump if lower”, since they all mean that the comparison gave a negative result, right? Well, the “jump if below” is used for unsigned integers, while “jump if lower” is used for signed integers, while “jump if sign” can be misleading. An unsigned 3 – 4 would give us a very high positive result… something like that, in practice, JB checks the Carry Flag, while JS checks the Sign Flag and JL checks if the Sign Flag is equal to the Overflow flag. See the Conditional Jump page for more details.而且,如果您想知道“如果低于则跳跃”和“如果低于则跳跃”和“如果较低则跳跃”之间有什么区别,因为它们都意味着比较给出了负面结果,对吧?好吧,“如果低于则跳”用于无符号整数,而“如果低于则跳转”用于有符号整数,而“如果符号则跳转”可能会产生误导。一个无符号的 3 – 4 会给我们一个非常高的积极结果......类似这样的事情,在实践中,JB 检查 Carry Flag,而 JS 检查 Sign Flag,JL 检查 Sign Flag 是否等于 Overflow 标志。有关详细信息,请参阅条件跳转页面。
A practical example 一个实际的例子
Here’s a very small and simple practical example, if you have a simple C program like this:这里有一个非常小而简单的实际示例,如果你有一个简单的 C 程序,如下所示:
It would compile into something like this:它将编译成如下内容:
Yep, something as simple as that, can be quite complicated in assembly. Well, it’s not really that complicated actually, but a couple of things can be confusing.是的,像这样简单的事情,在组装中可能相当复杂。嗯,实际上并没有那么复杂,但有几件事可能会令人困惑。
You have only 7 usable registers, and one stack. Every function gets its arguments passed through the stack, and can return its return value through the %eax register. If every function modified every register, then your code will break, so every function has to ensure that the other registers are unmodified when it returns (other than %eax). You pass the arguments on the stack and your return value through %eax, so what should you do if need to use a register in your function? Easy: you keep a copy on the stack of any registers you’re going to modify so you can restore them at the end of your function. In the _add_a_and_b function, I did that for the %ebx register as you can see. For more complex function, it can get a lot more complicated than that, but let’s not get into that for now (for the curious: compilers will create what we call a “prologue” and an “epilogue” in each function. In the prologue, you store the registers you’re going to modify, set up the %ebp (base pointer) register to point to the base of the stack when your function was entered, which allows you to access things without keeping track of the pushes/pops you do throughout the function, then in the epilogue, you pop the registers back, restore %esp to the value that was saved in %ebp, before you return).您只有 7 个可用寄存器和一个堆栈。每个函数的参数都通过堆栈传递,并且可以通过 %eax 寄存器返回其返回值。如果每个函数都修改了每个寄存器,那么您的代码就会中断,因此每个函数都必须确保其他寄存器在返回时未被修改(%eax 除外)。您将参数传递到堆栈上,并通过%eax传递返回值,那么如果需要在函数中使用寄存器,应该怎么做?简单:在要修改的任何寄存器的堆栈上保留一份副本,以便在函数结束时恢复它们。在 _add_a_and_b 函数中,我为 %ebx 寄存器执行此操作,如您所见。对于更复杂的函数,它可能会变得比这复杂得多,但我们暂时不要深入讨论(对于好奇的人:编译器将在每个函数中创建我们所说的“序言”和“尾声”。在序言中,你存储了你要修改的寄存器,设置了%ebp(基指针)寄存器,在进入你的函数时指向堆栈的基层,这使你能够访问事物,而无需跟踪你在整个函数中所做的推送/弹出,然后在尾声中,你把寄存器弹出回来, 在返回之前,将 %esp 还原为在 %ebp 中保存的值)。
The second thing you might be wondering about is with these lines:您可能想知道的第二件事是以下几行:
And to explain it, I will simply show you this drawing of the stack’s contents when we call those two instructions above:为了解释它,我将简单地向您展示这张堆栈内容的图,当我们调用上面的那两条指令时:

For the purposes of this exercise, we’re going to assume that the _main function is located in memory at the address 0xFFFF0000, and that each instructoin is 4 bytes long (the size of each instruction can vary depending on the instruction and on its operands). So you can see, we first pushed 3 into the stack, %esp was lowered, then we pushed 2 into the stack, %esp was lowered, then we did a ‘call _add_a_and_b’, which stored the address of the next instruction (4 instructions into the main, so ‘_main+16’) into the stack and esp was lowered, then we pushed %ebx, which I assumed here contained a value of 0, and the %esp was lowered again. If we now wanted to access the first argument to the function (2), we need to access %esp+8, which will let us skip the saved %ebx and the ‘Return address’ that are in the stack (since we’re working with 32 bits, each value is 4 bytes). And in order to access the second argument (3), we need to access %esp+12.出于本练习的目的,我们将假设 _main 函数位于内存中的地址 0xFFFF0000,并且每个指令长为 4 个字节(每条指令的大小可能因指令及其操作数而异)。所以你可以看到,我们首先将 3 推入堆栈,%esp 降低,然后我们将 2 推入堆栈,%esp 降低,然后我们进行“调用 _add_a_and_b”,将下一条指令的地址(4 条指令进入主指令,所以 '_main+16')放入堆栈中,ESP 降低, 然后我们按下 %ebx,我假设它在这里包含的值为 0,并且 %esp 再次降低。如果我们现在想访问函数 (2) 的第一个参数,我们需要访问 %esp+8,这将让我们跳过堆栈中保存的 %ebx 和“返回地址”(因为我们使用的是 32 位,每个值是 4 个字节)。为了访问第二个参数 (3),我们需要访问 %esp+12。
Binary or assembly? 二进制还是汇编?
One question that may (or may not) be popping into your mind now is “wait, isn’t this supposed to be the ‘computer language’, so why isn’t this binary?” Well, it is… in a way. As I explained earlier, “the assembly language is a textual representation of the binary instructions given to the microprocessor”, what it means is that those instructions are given to the processor as is, there is no transformation of the instructions or operands or anything like that. However, the instructions are given to the microprocessor in binary form, and the text you see above is just the textual representation of it.. kind of like how “68 65 6c 6c 6f” is the hexadecimal representation of the ASCII text “hello”. What this means is that each instruction in assembly language, which we call a ‘mnemonic’ represents a binary instruction, which we call an ‘opcode’, and you can see the opcodes and mnemonics in the list of x86 instructions I gave you above. Let’s take the CALL instruction for example. The opcode/mnemonic list is shown as:现在你可能会(也可能不会)想到一个问题:“等等,这不应该是'计算机语言'吗,那么为什么它不是二进制的呢?嗯,它是......在某种程度上。正如我之前解释的,“汇编语言是提供给微处理器的二进制指令的文本表示”,这意味着这些指令按原样提供给处理器,没有指令或操作数或类似的东西的转换。但是,指令是以二进制形式提供给微处理器的,您上面看到的文本只是它的文本表示。有点像“68 65 6c 6c 6f”是 ASCII 文本“你好”的十六进制表示。这意味着汇编语言中的每条指令,我们称之为“助记符”,代表一个二进制指令,我们称之为“操作码”,你可以在我上面给你的 x86 指令列表中看到操作码和助记符。让我们以 CALL 指令为例。操作码/助记符列表显示为:
Opcode 操作码 | Mnemonic 记忆 | Description 描述 |
E8 cw E8 顺频 | CALL rel16 致电 REL16 | Call near, relative, displacement relative to next instruction呼叫近、相对、相对于下一条指令的位移 |
E8 cd E8 光盘 | CALL rel32 致电 REL32 | Call near, relative, displacement relative to next instruction呼叫近、相对、相对于下一条指令的位移 |
FF /2 FF/2 | CALL r/m16 呼叫 r/m16 | Call near, absolute indirect, address given in r/m16呼叫附近,绝对间接,地址在 r/m16 中给出 |
FF /2 FF/2 | CALL r/m32 呼叫 r/m32 | Call near, absolute indirect, address given in r/m32呼叫邻近,绝对间接,地址在 r/m32 中给出 |
9A cd 9A 光盘 | CALL ptr16:16 致电 ptr16:16 | Call far, absolute, address given in operand调用操作数中给出的远、绝对、地址 |
9A cp 9安培 cp | CALL ptr16:32 致电 ptr16:32 | Call far, absolute, address given in operand调用操作数中给出的远、绝对、地址 |
FF /3 FF/3 | CALL m16:16 呼叫 m16:16 | Call far, absolute indirect, address given in m16:16远呼叫,绝对间接,m16:16 中给出的地址 |
FF /3 FF/3 | CALL m16:32 呼叫 m16:32 | Call far, absolute indirect, address given in m16:32远呼叫,绝对间接,在m16:32中给出的地址 |
This means that this same “CALL” mnemonic can have multiple addresses to call. Actually, there are four different possitiblities, each having a 16 bits and a 32 bits variant. The first possibility is to call a function with a relative displacement (Call the function 100 bytes below this current position), or an absolute address given in a register (Call the function whose address is stored in %eax) or an absolute address given as a pointer (Call the function at address 0xFFFF0100), or an absolute address given as an offset to a segment (I won’t explain segments now). In our example above, the “call _add_a_and_b” was probably stored as a call relative to the current position with 12 bytes below the current instruction (4 bytes per instruction, and we have the CALL, ADD, RET instructions to skip). This means that the instruction in the binary file was encoded as “E8 00 00 00 0C” (The E8 opcode to mean a “CALL near, relative”, and the “00 00 00 0C” to mean 12 bytes relative to the current instruction). Now, the most observant of you have probably noticed that this CALL instruction takes 5 bytes total, not 4, but as I said above, we will assume it’s 4 bytes per instruction just for the sake of keeping things simple, but yes, the CALL (in this case) is 5 bytes, and other instructions will sometimes have more or less bytes as well.这意味着同一个“CALL”助记词可以有多个地址可以调用。实际上,有四种不同的可能性,每种都有 16 位和 32 位的变体。第一种可能性是调用具有相对位移的函数(在此当前位置下方 100 个字节处调用函数),或寄存器中给出的绝对地址(调用地址存储在 %eax 中的函数)或作为指针给出的绝对地址(在地址 0xFFFF0100 处调用函数),或作为段的偏移量给出的绝对地址(我现在不会解释段)。在上面的示例中,“调用_add_a_and_b”可能被存储为相对于当前位置的调用,在当前指令下方 12 个字节(每条指令 4 个字节,我们要跳过 CALL、ADD、RET 指令)。这意味着二进制文件中的指令被编码为“E8 00 00 00 0C”(E8 操作码表示“CALL near, relative”,而“00 00 00 0C”表示相对于当前指令的 12 个字节)。现在,最细心的人可能已经注意到,这个 CALL 指令总共需要 5 个字节,而不是 4 个字节,但正如我上面所说,为了简单起见,我们假设每条指令是 4 个字节,但是是的,CALL(在本例中)是 5 个字节,其他指令有时也会有或多或少的字节。
I chose the CALL function above for example, because I think it’s the least complicated to explain.. other instructions have even more complicated opcodes and operands (See the ADD and ADC (Add with Cary) instructions for example, you’ll notice the same opcodes shared between them even, so they are the same instruction, but it’s easy to give them separate mnemonics to differentiate their behaviors).例如,我选择了上面的 CALL 函数,因为我认为它解释起来最不复杂。其他指令具有更复杂的操作码和操作数(例如,请参阅 ADD 和 ADC(使用 Cary 添加)指令,您会注意到它们之间甚至共享相同的操作码,因此它们是相同的指令,但很容易给它们提供单独的助记符来区分它们的行为)。
Here’s a screenshot showing a side by side view of the Assembly of a function with the hexadecimal view of the binary:下面是一个屏幕截图,显示了函数程序集的并排视图和二进制文件的十六进制视图:

As you can see, I have my cursor on address 0xFFF6E1D6 on the assembly view on the left, which is also highlighted on the hex view on the right. That address is a CALL instruction, and you can see the equivalent hex of “E8 B4 00 00 00”, which means it’s a CALL near, relative (E8 being the opcode for it) and the function is 0xB4 (180) bytes below our current position of 0xFFF6E1D6.如您所见,我将光标放在左侧装配视图的地址0xFFF6E1D6上,该视图也在右侧的十六进制视图上突出显示。该地址是一个 CALL 指令,您可以看到“E8 B4 00 00 00”的等效十六进制,这意味着它是近邻、相对的 CALL(E8 是它的操作码),并且该函数比我们当前位置的 0xFFF6E1D6 低 0xB4 (180) 个字节。
If you open the file with a hexadecimal editor, you’ll only see the hex view on the right, but you need to put the file into a Disassembler (such as the IDA disassembler which I’m using here, but there are cheaper alternatives as well, the list can be long), and the disassembler will interpret those binary opcodes to show you the textual assembly representation which is much much easier to read.如果使用十六进制编辑器打开文件,则只能看到右侧的十六进制视图,但需要将文件放入反汇编器中(例如我在这里使用的 IDA 反汇编器,但也有更便宜的替代方案,列表可能很长),反汇编器将解释这些二进制操作码以向您展示文本汇编表示形式,这更容易阅读。
Now that you have the basics, let’s do a quick reverse engineering exercise… This is a very simple function that I’ve reversed recently, it comes from the SiliconInit part of the FSP, and it’s used to validated the UPD configuration structure (used to tell it what to do).现在您已经掌握了基础知识,让我们做一个快速的逆向工程练习......这是一个非常简单的函数,我最近将其反转,它来自 FSP 的 SiliconInit 部分,用于验证 UPD 配置结构(用于告诉它该做什么)。
Here is the Assembly code for that function:以下是该函数的程序集代码:

This was disassembled using IDA 7.0 (The Interactive DisAssembler) which is an incredible (but expensive) piece of software. There are other disassemblers which can do similar jobs, but I prefer IDA personally. Let’s first explain what you see on the screen.这是使用 IDA 7.0(交互式反汇编器)拆解的,这是一个令人难以置信(但昂贵)的软件。还有其他反汇编器可以做类似的工作,但我个人更喜欢 IDA。让我们首先解释一下您在屏幕上看到的内容。
On the left side, you see “seg000:FFF40xxx” this means that we are in the segment “seg000” at the address 0xFFF40xxx. I won’t explain what a segment is, because you don’t need to know it. The validate_upd_config function starts at address 0xFFF40311 in the RAM, and there’s not much else to understand. You can see how the address increases from one instruction to the next, it can help you calculate the size in bytes that each instruction takes in RAM for example, if you’re curious of course… (the XOR is 2 bytes, the CMP is 2 bytes, etc.).在左侧,您可以看到“seg000:FFF40xxx”,这意味着我们位于地址 0xFFF40xxx 的段“seg000”中。我不会解释什么是段,因为你不需要知道它。validate_upd_config函数从RAM中的地址0xFFF40311开始,没有太多其他需要理解的地方。您可以看到地址如何从一条指令增加到下一条指令,它可以帮助您计算每条指令在 RAM 中占用的字节大小,例如,如果您好奇,当然......(XOR 为 2 个字节,CMP 为 2 个字节,以此类推)。
As you’ve seen in my previous example, anything after a semicolon (“;”) is considered a comment and can be ignored. The “CODE XREF” comments are added by IDA to tell us that this code has a cross-references (is being called by) some other code. So when you see “CODE XREF: validate_upd_config+9” (at 0xFF40363, the RETN instruction), it means this instruction is being called (referenced by) from the function validate_upd_config and the “+9” means 9 bytes into the function (so since the function starts at 0xFFF40311, it means it’s being called from the instruction at offset 0xFFF4031A. The little “up” arrow next to it means that it comes from above the current position in the code, and if you follow the grey lines on the left side of the screen, you can follow that call up to the address 0xFFF4031A which contains the instruction “jnz short locret_FFF40363”. I assume the “j” letter right after the up arrow is to tell us that the reference comes from a “jump” instruction.正如您在我之前的示例中看到的,分号 (“;”) 后面的任何内容都被视为注释,可以忽略。“CODE XREF”注释是由 IDA 添加的,目的是告诉我们此代码具有交叉引用(正在被调用)其他一些代码。因此,当您看到“CODE XREF: validate_upd_config+9”(在 0xFF40363处,RETN 指令),这意味着该指令正在从函数validate_upd_config调用(引用),而“+9”表示函数中有 9 个字节(因此,由于函数从 0xFFF40311 开始,这意味着它正在从偏移量 0xFFF4031A的指令中调用。它旁边的小“向上”箭头表示它来自代码中当前位置的上方,如果您按照屏幕左侧的灰线进行操作,则可以按照该调用向上移动到包含指令“jnz short locret_FFF40363”的地址0xFFF4031A。我假设向上箭头后面的“j”字母是告诉我们引用来自“跳跃”指令。
As you can see in the left side of the screen, there are a lot of arrows, that means that there’s a lot of jumping around in the code, even though it’s not immediatly obvious. The awesome IDA software has a “layout view” which gives us a much nicer view of the code, and it looks like this:正如您在屏幕左侧看到的,有很多箭头,这意味着代码中有很多跳跃,即使它不是立即显而易见的。很棒的 IDA 软件有一个“布局视图”,它为我们提供了更好的代码视图,它看起来像这样:

Now you can see each block of code separately in their own little boxes, with arrows linking all of the boxes together whenever a jump happens. The green arrows mean that it’s a conditional jump when the condition is successful, while the red arrows means the condition was not successful. This means that a “JZ” will show a green arrow towards the code it would jump to if the result is indeed zero, and a red arrow towards the block where the result is not zero. A blue arrow means that it’s an unconditional jump.现在,您可以在它们自己的小框中分别查看每个代码块,每当发生跳转时,箭头都会将所有框连接在一起。绿色箭头表示当条件成功时它是有条件的跳跃,而红色箭头表示条件不成功。这意味着,“JZ”将显示一个绿色箭头,指向如果结果确实为零,它将跳转到的代码,以及一个红色箭头,指向结果不为零的块。蓝色箭头表示这是无条件跳跃。
I usually always do my reverse engineering using the layout view, I find it much easier to read/follow, but for the purpose of this exercise, I will use the regular linear view instead, so I think it will be easier for you to follow with that instead. The reason is mostly because the layout view doesn’t display the address of each instruction, and it’s easier to have you follow along if I can point out exactly which instruction I’m looking it by mentioning its address.我通常总是使用布局视图进行逆向工程,我发现它更容易阅读/遵循,但出于本练习的目的,我将使用常规线性视图,所以我认为你会更容易遵循它。原因主要是因为布局视图不显示每条指令的地址,如果我可以通过提及其地址准确地指出我正在查看的指令,那么让您更容易跟随。
Now that you know how to read the assembly code, you understand the various instructions, I feel you should be ready to reverse engineering this very simple assembly code (even though it might seem complex at first). I just need to give you the following hints first:现在您知道如何阅读汇编代码,您理解了各种指令,我觉得您应该准备好对这个非常简单的汇编代码进行逆向工程(即使一开始它可能看起来很复杂)。我只需要先给你以下提示:
- Because I’ve already reversed engineering it, you get the beautiful name “validate_upd_config” for the function, but technically, it was simply called “sub_FFF40311”
因为我已经对它进行了逆向工程,所以你得到了这个函数的美丽名字“validate_upd_config”,但从技术上讲,它只是被称为“sub_FFF40311”
- I had already reverse engineered the function that called it so I know that this function is receiving its arguments in an unusual way. The arguments aren’t pushed to the stack, instead, the first argument is stored in %ecx, and the second argument is stored in %edx
我已经对调用它的函数进行了逆向工程,因此我知道该函数正在以一种不寻常的方式接收其参数。参数不会推送到堆栈,而是将第一个参数存储在 %ecx 中,第二个参数存储在 %edx 中
- The first argument (%ecx, remember?) is an enum to indicate what type of UPD structure to validate, let me help you out and say that type ‘3’ is the FSPM_UPD (The configuration structure for the FSPM, the MemoryInit function), and that type ‘5’ is the FSPS_UPD (The configuration structure for the FSPS, the SiliconInit function).
第一个参数(%ecx,还记得吗?)是一个枚举,用于指示要验证的 UPD 结构类型,让我帮助您说类型“3”是FSPM_UPD(FSPM 的配置结构,MemoryInit 函数),类型“5”是FSPS_UPD(FSPS 的配置结构,SiliconInit 函数)。
- Reverse engineering is really about reading one line at a time, in a sequential manner, keep track of which blocks you reversed and be patient. You can’t look at it and expect to understand the function by viewing the big picture.
逆向工程实际上是以顺序方式一次读取一行,跟踪您反转了哪些块并要有耐心。你不能看着它,并期望通过观察大局来理解这个功能。
- It is very very useful in this case to have a dual monitor, so you can have one monitor for the assembly, and the other monitor for your C code editor. In my case, I actually recently bought an ultra-wide monitor and I split screen between my IDA window and my emacs window and it’s great. It’s hard otherwise to keep going back and forth between the assembly and the C code. That being said, I would suggest you do the same thing here and have a window on the side showing you the assembly image above (not the layout view) while you read the explanation on how to reverse engineer it below.
在这种情况下,拥有一个双显示器是非常非常有用的,因此你可以有一个显示器用于程序集,另一个显示器用于你的C代码编辑器。就我而言,我实际上最近买了一台超宽显示器,我在 IDA 窗口和 emacs 窗口之间分屏,这很棒。否则,很难在汇编和 C 代码之间来回切换。话虽如此,我建议您在这里做同样的事情,并在侧面设置一个窗口,向您展示上面的装配图像(而不是布局视图),同时您阅读下面有关如何对其进行逆向工程的说明。
Got it? All done? No? Stop sweating and hyperventilating… I’ll explain exactly how to reverse engineer this function in the next paragraph, and you will see how simple it turns out to be!明白了?全部完成?不?停止出汗和过度换气......我将在下一段中准确解释如何对此功能进行逆向工程,您将看到结果是多么简单!
Let’s get started! 让我们开始吧!
The first thing I do is write the function in C. Since I know the name and its arguments already, I’ll do that:我做的第一件事是用 C 编写函数。由于我已经知道名称及其参数,因此我将这样做:
Yeah, there’s not much to it yet, and I set it to return “void” because I don’t know if it returns anything else, and I gave the first argument “action” as a “uint8_t” because in the parent function it’s used a single byte register (I won’t explain for now how to differentiate 1-byte, 2-bytes, 4-bytes and 8-bytes registers). The second argument is a pointer, but I don’t know it’s a pointer to what kind of structure exactly, so I just set it as a void *.是的,它还没有太多内容,我将其设置为返回“void”,因为我不知道它是否返回其他任何东西,并且我将第一个参数“action”作为“uint8_t”,因为在父函数中它使用了单字节寄存器(我现在不会解释如何区分 1 字节、2 个字节、 4 字节和 8 字节寄存器)。第二个参数是一个指针,但我不知道它是指向到底是哪种结构的指针,所以我只是将其设置为空洞*。
The first instruction is a “xor eax, eax”. What does this do? It XORs the eax register with the eax register and stores the result in the eax register itself, which is the same thing as “mov eax, 0”, because 1 XOR 1= 0 and 0 XOR 0 = 0, so if every bit in the eax register is logically XORed with itself, it will give 0 for the result. If you’re asking yourself “Why did the compiler decide to do ‘xor eax, eax’ instead of ‘mov eax, 0’ ?” then the answer is simple: “Because it takes less CPU clock cycles to do a XOR, than to do a move”, which means it’s more optimized and it will run faster. Besides, the XOR takes 2 bytes as you can see above (the address of the instructions jumped from FFF40311 to FFF40313), while a “mov eax, 0” would have taken 5 bytes. So it also helps keep the code smaller.第一条指令是“xor eax, eax”。这有什么作用?它将 eax 寄存器与 eax 寄存器进行 XOR 运算,并将结果存储在 eax 寄存器本身中,这与“mov eax, 0”相同,因为 1 XOR 1= 0 和 0 XOR 0 = 0,因此,如果 eax 寄存器中的每个位都与自身逻辑 XOR 运算,它将给出 0 的结果。如果你问自己“为什么编译器决定执行'xor eax, eax'而不是'mov eax, 0'?”,那么答案很简单:“因为执行XOR比执行移动所需的CPU时钟周期更少”,这意味着它更优化,运行得更快。此外,如上所述,XOR 需要 2 个字节(指令的地址从 FFF40311 跳到 FFF40313),而“mov eax, 0”需要 5 个字节。因此,它还有助于保持代码更小。
Alright, so now we know that eax is equal to 0, let’s keep that in mind and move along (I like to keep track of things like that as comments in my C code). Next instruction does a “cmp ecx, 3”, so it’s comparing ecx, which we already know is our first argument (uint8_t action), ok, it’s a comparison, not much to do here, again let’s keep that in mind and continue… the next instruction does a “jnz short loc_FFF40344”, which is more interesting, so if the previous comparison is NOT ZERO, then jump to the label loc_FFF40344 (for now ignore the “short”, it just helps us differentiate between the various mnemonics, and it means that the jump is a relative offset that fits in a “short word” which means 2 bytes, and you can confirm that the jnz instruction does indeed take only 2 bytes of code). Great, so there’s a jump if the result is NOT ZERO, which means that if the result is zero, the code will just continue, which means if the ecx register (action variable) is EQUAL (substraction is zero) to 3, the code will continue down to the next instruction instead of jumping… let’s do that, and in the meantime we’ll update our C code:好了,现在我们知道 eax 等于 0,让我们记住这一点并继续前进(我喜欢在我的 C 代码中将这样的事情作为注释进行跟踪)。下一条指令做一个“cmp ecx,3”,所以它是比较 ecx,我们已经知道这是我们的第一个参数(uint8_t动作),好吧,这是一个比较,这里没什么可做的,再次让我们记住这一点并继续......下一条指令做一个“jnz short loc_FFF40344”,这个比较比较比较不为零,那就跳到标签loc_FFF40344(暂时忽略“short”,它只是帮助我们区分各种助记词,这意味着跳转是适合“短词”的相对偏移量,这意味着 2 个字节, 您可以确认 JNZ 指令确实只需要 2 个字节的代码)。太好了,所以如果结果不为零,就会发生跳跃,这意味着如果结果为零,代码将继续,这意味着如果 ecx 寄存器(动作变量)等于(减法为零)到 3,代码将继续向下到下一条指令而不是跳跃......让我们这样做,与此同时,我们将更新我们的 C 代码:
The next instruction is “test edx, edx”. We know that the edx register is our second argument which is the pointer to the configuration structure. As I explained above, the “test” is just like a comparison, but it does an AND instead of a substraction, so basically, you AND edx with itself.. well, of course, that has no consequence, 1 AND 1 = 1, and 0 AND 0 = 0, so why is it useful to test a register against itself? Simply because the TEST will update our FLAGS register… so when the next instruction is “JZ” it basically means “Jump if the edx register was zero”… And yes, doing a “TEST edx, edx” is more optimized than doing a “CMP edx, 0”, you’re starting to catch on, yeay!下一条指令是“test edx, edx”。我们知道 edx 寄存器是我们的第二个参数,它是指向配置结构的指针。正如我上面解释的,“测试”就像一个比较,但它做一个 AND 而不是减法,所以基本上,你 AND edx 与自身。好吧,当然,这没有后果,1 和 1 = 1,以及 0 和 0 = 0,那么为什么针对自身测试寄存器有用呢?仅仅因为 TEST 将更新我们的 FLAGS 寄存器......所以当下一条指令是“JZ”时,它基本上意味着“如果edx寄存器为零,则跳转”......是的,做一个“TEST edx,edx”比做一个“CMP edx,0”更优化,你开始流行起来,是的!
And indeed, the next instruction is “jz locret_FFF40363”, so if the edx register is ZERO, then jump to locret_FFF40363, and if we look at that locret_FFF40363, it’s a very simple “retn” instruction. So our code becomes:事实上,下一条指令是“jz locret_FFF40363”,所以如果 edx 寄存器为 ZERO,则跳到 locret_FFF40363,如果我们看一下那个locret_FFF40363,这是一个非常简单的“retn”指令。所以我们的代码变成:
Next! Now it gets slightly more complicated… the instruction is: “cmp dword ptr [edx], 554C424Bh”, which means we do a comparison of a dword (4 bytes), of the data pointed to by the pointer edx, with no offset (“[edx]” is the same as saying “edx[0]” if it was a C array for example), and we compare it to the value 554C424Bh… the “h” at the end means it’s a hexadecimal value, and with experience you can quickly notice that the hexadecimal value is all within the ASCII range, so using a Hex to ASCII converter, we realize that those 4 bytes represent the ASCII letters “KBLU” (which is why I manually added them as a comment to that instruction, so I won’t forget). So basically the instruction compares the first 4 bytes of the structure (the content pointed to by the edx pointer) to the string “KBLU”. The next instruction does a “jnz loc_FFF4035E” which means that if the comparison result is NOT ZERO (so, if they are not equal) we jump to loc_FFF4035E.下一个!现在它变得稍微复杂一些......指令为:“cmp dword ptr [edx], 554C424Bh”,这意味着我们对指针 edx 指向的数据(4 个字节)进行比较,没有偏移量(例如,如果它是 C 数组,“[edx]”与说“edx[0]”相同),并将其与值 554C424Bh 进行比较......末尾的“h”表示它是一个十六进制值,根据经验,您可以很快注意到十六进制值都在 ASCII 范围内,因此使用十六进制到 ASCII 转换器,我们意识到这 4 个字节代表 ASCII 字母“KBLU”(这就是为什么我手动将它们添加为该指令的注释, 所以我不会忘记)。因此,基本上该指令将结构的前 4 个字节(edx 指针指向的内容)与字符串“KBLU”进行比较。下一条指令执行“jnz loc_FFF4035E”,这意味着如果比较结果不为零(因此,如果它们不相等),我们将跳到loc_FFF4035E。
Instead of continuing sequentially, I will see what that loc_FFF4035E contains (of course, I did the same thing in all the previous jumps, and had to decide if I wanted to continue reverse engineering the jump or the next instruction, in this case, it seems better for me to jump, you’ll see why soon). The loc_FFF4035E label contains the following instruction: “mov, eax, 80000002h”, which means it stores the value 0x80000002 into the eax register, and then it jumps to (not really, it just naturally flows to the next instruction which happens to be the label) locret_FFF40363, which is just a “retn”. This makes our code into this:我不会按顺序继续,而是看看那个loc_FFF4035E包含什么(当然,我在之前的所有跳跃中都做了同样的事情,并且必须决定我是想继续对跳跃进行逆向工程还是下一个指令,在这种情况下,对我来说似乎更好跳跃,你很快就会明白为什么)。loc_FFF4035E标签包含以下指令:“mov, eax, 80000002h”,这意味着它将值 0x80000002 存储到 eax 寄存器中,然后跳到(不是真的,它只是自然而然地流向恰好是标签的下一条指令)locret_FFF40363,这只是一个“retn”。这使我们的代码变成了这样:
The observant here will notice that I’ve changed the function prototype to return a uint32_t instead of “void” and my previous “return” has become “return 0” and the new code has a “return 0x80000002”. That’s because I realized at this point that the “eax” register is used to return a uint32_t value. And since the first instruction was “xor eax, eax”, and we kept in the back of our mind that “eax is initialized to 0”, it means that the use case with the (config == NULL) will return 0. That’s why I made all these changes…这里的观察者会注意到,我已经更改了函数原型以返回一个 uint32_t而不是“void”,而我之前的“return”变成了“return 0”,新代码有一个“return 0x80000002”。这是因为此时我意识到“eax”寄存器用于返回uint32_t值。而且由于第一条指令是“xor eax, eax”,并且我们始终牢记“eax 被初始化为 0”,这意味着 (config == NULL) 的用例将返回 0。这就是我做出所有这些更改的原因......
Very well, let’s go back to where we were, since we’ve exhausted this jump, we’ll jump back in reverse to go back to the address FFF40322 and continue from there to the next instruction. It’s a “cmp dword ptr [edx+4], 4D5F4450h”, which compares the dword at edx+4 to 0x4D5F4450, which I know to be the ASCII for “PD_M”; this means that the last 3 instructions are used to compare the first 8 bytes of our pointer to “KBLUPD_M”… ohhh, light bulb above our heads, it’s comparing the pointer to the Signature of the FSPM_UPD structure (don’t forget, you weren’t supposed to know that the function is called validate_upd_config, or that the argument is a config pointer… just that it’s a pointer)! OK, now it makes sense, and while we’re at it—and since we are, of course, reading the FSP integration guide PDF, we then also realize what the 0x80000002 actually means. At this point, our code now becomes:很好,让我们回到原来的位置,既然我们已经用尽了这个跳跃,我们将反向跳回地址FFF40322,并从那里继续到下一条指令。这是一个“cmp dword ptr [edx+4], 4D5F4450h”,它将 edx+4 处的 dword 与 0x4D5F4450 进行比较,我知道这是“PD_M”的 ASCII;这意味着最后 3 条指令用于将我们的指针的前 8 个字节与“KBLUPD_M”进行比较......哦,我们头顶上的灯泡,它正在将指针与 FSPM_UPD 结构的 Signature 进行比较(别忘了,你不应该知道该函数被称为 validate_upd_config,或者参数是一个配置指针......只是一个指针)!好了,现在它就说得通了,当我们在做这件事时,当然,由于我们正在阅读 FSP 集成指南 PDF,那么我们也意识到0x80000002的实际含义。此时,我们的代码现在变为:
Yay, this is starting to look like something… Now you probably got the hang of it, so let’s do things a little faster now.是的,这开始看起来像什么......现在您可能已经掌握了窍门,所以现在让我们快一点做事。
- The next line “cmp [edx+28h], eax” compares edx+0x28 to eax. Thankfully, we know now that edx points to the FSPM_UPD structure, and we can calculate that at offset 0x28 inside that structure, it’s the field StackBase within the FspmArchUpd field…
下一行“cmp [edx+28h], eax”将 edx+0x28 与 eax 进行比较。值得庆幸的是,我们现在知道 edx 指向FSPM_UPD结构,我们可以计算出,在该结构内部的偏移0x28,它是 FspmArchUpd 字段中的字段 StackBase......
- and also, we still have in the back of our minds that ‘eax’ is initialized to zero, so, we know that the next 2 instructions are just checking if upd->FspmArchUpd.StackBase is == NULL.
而且,我们仍然在脑海中记住“eax”被初始化为零,因此,我们知道接下来的 2 条指令只是检查 upd->FspmArchUpd.StackBase 是否为 == NULL。
- Then we compare the StackSize with 0x26000, but the comparison is using “jb” for the jump, which is “jump if below”, so it checks if StackSize < 0x26000,
然后我们将 StackSize 与 0x26000进行比较,但比较是使用 “jb” 进行跳转,即 “jump if below”,因此它检查 StackSize 是否< 0x26000,
- finally it does a “test” with “edx+30h” (which is the BootloaderTolumSize field) and 0xFFF, then it does an unconditional jump to loc_FFF4035C, which itself does a “jz” to the return..
最后,它使用“edx+30h”(即 BootloaderTolumSize 字段)并0xFFF执行“测试”,然后无条件跳转到 loc_FFF4035C,它本身对返回值执行“jz”。
- which means if (BootloaderTolumSize & 0xFFF == 0) it will return whatever EAX contained (which is zero),
这意味着如果 (BootloaderTolumSize & 0xFFF == 0) 它将返回包含的任何 EAX(为零),
- but if it doesn’t, then it will continue to the next instruction which is the “mov eax, 80000002h”.
但如果没有,那么它将继续到下一条指令,即“MOV EAX,80000002H”。
So, we end up with this code:因此,我们最终得到以下代码:
Great, we just solved half of our code! Don’t forget, we jumped one way instead of another at the start of the function, now we need to go back up and explore the second branch of the code (at offset 0xFFF40344). The code is very similar, but it checks for “KBLUPD_S” Signature, and nothing else. Now we can also remove any comment/notes we have (such as the note that eax is initialized to 0) and clean up, and simplify the code if there is a need.太好了,我们刚刚解决了一半的代码!别忘了,我们在函数开始时跳过了一种方式而不是另一种方式,现在我们需要返回并探索代码的第二个分支(在偏移量 0xFFF40344)。代码非常相似,但它检查“KBLUPD_S”签名,而不检查其他任何内容。现在我们还可以删除我们拥有的任何注释/注释(例如 eax 初始化为 0 的注释)并清理,并在需要时简化代码。
So our function ends up being (this is the final version of the function):所以我们的函数最终是(这是函数的最终版本):
Now this wasn’t so bad, was it? I mean, it’s time consuming, sure, it can be a little disorienting if you’re not used to it, and you have to keep track of which branches (which blocks in the layout view) you’ve already gone through, etc. but the function turned out to be quite small and simple. After all, it was mostly only doing CMP/TEST and JZ/JNZ.现在这还不是那么糟糕,不是吗?我的意思是,这很耗时,当然,如果你不习惯它,它可能会有点迷失方向,而且你必须跟踪你已经经历过哪些分支(布局视图中的哪些块),等等。 但事实证明,这个功能非常小而简单。毕竟,它主要只做CMP/TEST和JZ/JNZ。
That’s pretty much all I do when I do my reverse engineering, I go line by line, I understand what it does, I try to figure out how it fits into the bigger picture, I write equivalent C code to keep track of what I’m doing and to be able to understand what happens, so that I can later figure out what the function does exactly… Now try to imagine doing that for hundreds of functions, some of them that look like this (random function taken from the FSPM module):这几乎是我做逆向工程时所做的全部,我一行一行地了解它的作用,我试图弄清楚它如何适应更大的画面,我编写等效的 C 代码来跟踪我正在做什么并能够理解发生了什么,这样我以后就可以弄清楚这个函数到底是做什么的......现在试着想象一下对数百个函数这样做,其中一些看起来像这样(取自 FSPM 模块的随机函数):

You can see on the right, the graph overview which shows the entirety of the function layout diagram. The part on the left (the assembly) is represented by the dotted square on the graph overview (near the middle). You will notice some arrows that are thicker than the others, that’s used in IDA to represent loops. On the left side, you can notice one such thick green line coming from the bottom and the arrow pointing to a block inside our view. This means that there’s a jump condition below that can jump back to a block that is above the current block and this is basically how you do a for/while loop with assembly, it’s just a normal jump that points backwards instead of forwards.您可以在右侧看到图形概述,它显示了整个函数布局图。左侧的部分(组件)由图形概览上的虚线方块表示(靠近中间)。您会注意到一些箭头比其他箭头更粗,这些箭头在 IDA 中用于表示循环。在左侧,您可以注意到一条如此粗的绿线从底部伸出,箭头指向我们视图中的一个块。这意味着下面有一个跳跃条件,可以跳回当前块上方的块,这基本上就是你如何用组装进行 for/while 循环,它只是一个正常的跳跃,指向后方而不是向前。
At the beginning of this post, I mentioned a challenging function to reverse engineer. It’s not extremely challenging—it’s complex enough that you can understand the kind of things I have to deal with sometimes, but it’s simple enough that anyone who was able to follow up until now should be able to understand it (and maybe even be able to reverse engineer it on their own).在这篇文章的开头,我提到了一个具有挑战性的逆向工程函数。这并不是非常具有挑战性——它足够复杂,你可以理解我有时必须处理的那种事情,但它足够简单,任何能够跟进到现在的人都应该能够理解它(甚至可能能够自己进行逆向工程)。
So, without further ado, here’s this very simple function:所以,事不宜迟,这里有一个非常简单的函数:

Since I’m a very nice person, I renamed the function so you won’t know what it does, and I removed my comments so it’s as virgin as it was when I first saw it. Try to reverse engineer it. Take your time, I’ll wait:由于我是一个非常好的人,所以我重命名了这个函数,这样你就不知道它的作用了,我删除了我的评论,所以它就像我第一次看到它时一样原始。尝试对其进行逆向工程。慢慢来,我等着:

Alright, so, the first instruction is a “call $+5”, what does that even mean?好吧,所以,第一条指令是“调用 $+5”,这到底是什么意思?
- When I looked at the hex dump, the instruction was simply “E8 00 00 00 00” which according to our previous CALL opcode table means “Call near, relative, displacement relative to next instruction”, so it wants to call the instruction 0 bytes from the next instruction. Since the call opcode itself is taking 5 bytes, that means it’s doing a call to its own function but skipping the call itself, so it’s basically jumping to the “pop eax”, right? Yes… but it’s not actually jumping to it, it’s “calling it”, which means that it just pushed into the stack the return address of the function… which means that our stack contains the address 0xFFF40244 and our next instruction to be executed is the one at the address 0xFFF40244. That’s because, if you remember, when we do a “ret”, it will pop the return address from the stack into the EIP (instruction pointer) register, that’s how it knows where to go back when the function finishes.
当我查看十六进制转储时,该指令只是“E8 00 00 00 00”,根据我们之前的 CALL 操作码表,它的意思是“调用接近、相对、相对于下一条指令的位移”,因此它希望从下一条指令开始调用指令 0 个字节。由于调用操作码本身占用 5 个字节,这意味着它正在调用自己的函数,但跳过了调用本身,因此它基本上是跳转到“pop eax”,对吧?是的。。。但它实际上并没有跳到它,而是在“调用它”,这意味着它只是将函数的返回地址推入堆栈......这意味着我们的堆栈包含地址 0xFFF40244,我们要执行的下一个指令是地址0xFFF40244的指令。这是因为,如果你还记得,当我们执行“ret”时,它会将堆栈中的返回地址弹出到 EIP(指令指针)寄存器中,这就是它知道函数结束时返回何处的方式。
- So, then the instruction does a “pop eax” which will pop that return address into EAX, thus removing it from the stack and making the call above into a regular jump (since there is no return address in the stack anymore).
因此,然后该指令执行一个“pop eax”,它将该返回地址弹出到 EAX 中,从而将其从堆栈中删除并使上述调用成为常规跳转(因为堆栈中不再有返回地址)。
- Then it does a “sub eax, 0FFF40244h”, which means it’s substracting 0xFFF40244 from eax (which should contain 0xFFF40244), so eax now contains the value “0”, right? You bet!
然后它执行“sub eax, 0FFF40244h”,这意味着它正在从 eax(应包含 0xFFF40244)中减去 0xFFF40244,因此 eax 现在包含值“0”,对吧?当然!
- Then it adds to eax, the value “0xFFF4023F”, which is the address of our function itself. So, eax now contains the value 0xFFF4023F.
然后,它向 eax 添加值“0xFFF4023F”,即我们函数本身的地址。因此,eax 现在包含值 0xFFF4023F。
- It will then substract from EAX, the value pointed to by [eax-15], which means the dword (4 bytes) value at the offset 0xFFF4023F – 0xF, so the value at 0xFFF40230, right… that value is 0x1AB (yep, I know, you didn’t have this information)… so, 0xFFF4023F – 0x1AB = 0xFFF40094!
然后,它将从 EAX 中减去 [eax-15] 指向的值,这意味着偏移量 0xFFF4023F – 0xF处的 dword(4 个字节)值,因此是 0xFFF40230 处的值,对吧......该值为 0x1AB(是的,我知道,你没有这些信息)......所以,0xFFF4023F – 0x1AB = 0xFFF40094!
- And then the function returns.. with the value 0xFFF40094 in EAX, so it returns 0xFFF40094, which happens to be the pointer to the FSP_INFO_HEADER structure in the binary.
然后函数返回.的值在 EAX 中0xFFF40094,因此它返回 0xFFF40094,这恰好是指向二进制文件中FSP_INFO_HEADER结构的指针。
So, the function just returns 0xFFF40094, but why did it do it in such a convoluted way? The reason is simple: because the FSP-S code is technically meant to be loaded in RAM at the address 0xFFF40000, but it can actually reside anywhere in the RAM when it gets executed. Coreboot for example doesn’t load it in the right memory address when it executes it, so instead of returning the wrong address for the structure and crashing (remember, most of the jumps and calls use relative addresses, so the code should work regardless of where you put it in memory, but in this case returning the wrong address for a structure in memory wouldn’t work), the code tries to dynamically verify if it has been relocated and if it is, it will calculate how far away it is from where it’s supposed to be, and calculate where in memory the FSP_INFO_HEADER structure ended up being.那么,该函数只是返回0xFFF40094,但为什么它以如此复杂的方式执行此操作呢?原因很简单:因为 FSP-S 代码在技术上是为了在地址0xFFF40000加载到 RAM 中,但当它被执行时,它实际上可以驻留在 RAM 中的任何位置。例如,Coreboot 在执行它时不会将其加载到正确的内存地址中,因此不会返回错误的结构地址并崩溃(请记住,大多数跳转和调用都使用相对地址,因此无论您将其放在内存中的哪个位置,代码都应该可以工作,但在这种情况下,为内存中的结构返回错误的地址是行不通的), 该代码尝试动态验证它是否已被重新定位,如果已被重新定位,它将计算它与它应该在的位置相距多远,并计算FSP_INFO_HEADER结构最终在内存中的位置。
Here’s the explanation why:原因如下:
- If the FSP was loaded into a different memory address, then the “call $+5” would put the exact memory address of the next instruction into the stack, so when you pop it into eax then substract from it the expected address 0xFFF40244, this means that eax will contain the offset from where it was supposed to be.
如果 FSP 被加载到不同的内存地址,那么 “call $+5” 会将下一条指令的确切内存地址放入堆栈中,因此当您将其弹出到 eax 中,然后从中减去预期的地址0xFFF40244,这意味着 eax 将包含与它应该在的位置的偏移量。
- Above, we said eax would be equal to zero, yes, that’s true, but only in the usecase where the FSP is in the right memory address, as expected, otherwise, eax would simply contain the offset. Then you add to it 0xFFFF4023F which is the address of our function, and with the offset, that means eax now contains the exact memory address of the current function, wherever it was actually placed in RAM!
上面,我们说过 eax 等于零,是的,这是真的,但只有在 FSP 位于正确的内存地址的用例中,正如预期的那样,否则,eax 将仅包含偏移量。然后你向它添加0xFFFF4023F这是我们函数的地址,加上偏移量,这意味着 eax 现在包含当前函数的确切内存地址,无论它实际放置在 RAM 中的哪个位置!
- Then when it grabs the value 0x1AB (because that value is stored in RAM 15 bytes before the start of the function, that will work just fine) and substracts it from our current position, it gives us the address in RAM of the FSP_INFO_HEADER (because the compiler knows that the structure is located exactly 0x1AB bytes before the current function). This just makes everything be relative.
然后,当它获取值 0x1AB(因为该值在函数开始前 15 个字节存储在 RAM 中,这将正常工作)并从我们当前的位置减去它时,它会为我们提供 FSP_INFO_HEADER 的 RAM 地址(因为编译器知道结构位于当前函数之前 0x1AB 个字节)。这只是让一切都是相对的。
Isn’t that great!? 😉 It’s so simple, but it does require some thinking to figure out what it does and some thinking to understand why it does it that way… but then you end up with the problem of “How do I write this in C”? Honestly, I don’t know how, I just wrote this in my C file:这不是很好吗!?😉 这很简单,但它确实需要一些思考来弄清楚它的作用,并需要一些思考来理解它为什么这样做......但是你最终会遇到“我如何用 C 语言编写这个”的问题?老实说,我不知道怎么做,我只是在我的 C 文件中写了这个:
I think the compiler takes care of doing all that magic on its own when you use the -fPIC compiler option (for gcc), which means “Position-Independent Code”.我认为当您使用 -fPIC 编译器选项(用于 gcc)时,编译器会自行完成所有这些魔术,这意味着“位置无关的代码”。
On my side, I’ve finished reverse engineering the FSP-S entry code—from the entry point (FspSiliconInit) all the way to the end of the function and all the subfunctions that it calls.就我而言,我已经完成了对 FSP-S 入口代码的逆向工程 - 从入口点 (FspSiliconInit) 一直到函数的末尾及其调用的所有子函数。
This only represents 9 functions however, and about 115 lines of C code; I haven’t yet fully figured out where exactly it’s going in order to execute the rest of the code. What happens is that the last function it calls (it actually jumps into it) grabs a variable from some area in memory, and within that variable, it will copy a value into the ESP, thus replacing our stack pointer, and then it does a “RETN”… which means that it’s not actually returning to the function that called it (coreboot), it’s returning… “somewhere”, depending on what the new stack contains, but I don’t know where (or how) this new stack is created, so I need to track it down in order to find what the return address is, find where the “retn” is returning us into, so I can unlock plenty of new functions and continue reverse engineering this.然而,这只代表 9 个函数和大约 115 行 C 代码;我还没有完全弄清楚它到底要去哪里,以便执行其余的代码。发生的情况是,它调用的最后一个函数(它实际上跳入其中)从内存中的某个区域抓取一个变量,并且在该变量中,它会将一个值复制到 ESP 中,从而替换我们的堆栈指针,然后它执行“RETN”......这意味着它实际上并没有返回到调用它的函数(coreboot),而是返回...“某处”,取决于新堆栈包含的内容,但我不知道这个新堆栈是在哪里(或如何)创建的,所以我需要追踪它,以便找到返回地址,找到“retn”将我们返回到哪里,这样我就可以解锁大量新功能并继续对此进行逆向工程。
I’ve already made some progress on that front (I know where the new stack tells us to return into) but you will have to wait until my next blog post before I can explain it all to you. It’s long and complicated enough that it needs its own post, and this one is long enough already.我已经在这方面取得了一些进展(我知道新的堆栈告诉我们要回到哪里),但你必须等到我的下一篇文章,我才能向你解释这一切。它足够长和复杂,以至于它需要自己的帖子,而这个已经足够长了。
You never really know what to expect when you start reverse engineering assembly. Here are some other stories from my past experiences.当你开始对装配进行逆向工程时,你永远不知道会发生什么。以下是我过去经历的其他一些故事。
- I once spent a few days reverse engineering a function until about 30% of it when I finally realized that the function was… the C++ “+ operator” of the std::string class (which by the way, with the use of C++ templates made it excruciatingly hard to understand)!
我曾经花了几天时间对一个函数进行逆向工程,直到大约 30% 时我终于意识到该函数是......std::string 类的 C++ “+ 运算符”(顺便说一下,使用 C++ 模板使其非常难以理解)!
- I once had to reverse engineer over 5000 lines of assembly code that all resolved into… 7 lines of C code. The code was for creating a hash and it was doing a lot of manipulation on data with different values on every iteration. There was a LOT of xor, or, and, shifting left and right of data, etc., which took maybe a hundred or so lines of assembly and it was all inside a loop, which the compiler decided that—to optimize it—it would unravel the loop (this means that instead of doing a jmp, it will just copy-paste the same code again), so instead of having to reverse engineer the code once and then see that it’s a loop that runs 64 times, I had to reverse engineer the same code 64 times because it was basically getting copy-pasted by the compiler in a single block but the compiler was “nice” enough that it was using completely different registers for every repetition of the loop, and the data was getting shifted in a weird way and using different constants and different variables at every iteration, and—as if that wasn’t enough— every 1/4th of the loop, changing the algorithm and making it very difficult to predict the pattern, forcing me to completely reverse engineer the 5000+ assembly lines into C, then slowly refactor and optimize the C code until it became that loop with 7 lines of code inside it… If you’re curious you can see the code here at line 39, where there is some operation common to all iterations, then 4 different operations depending on which iteration we are doing, and the variables used for each operation changes after each iteration (P, PP, PPP and PPPP get swapped every time), and the constant values and the indices used are different for each iteration as well (see constants.h). It was complicated and took a long while to reverse engineer.
我曾经不得不对 5000 多行汇编代码进行逆向工程,这些代码都解析为......7 行 C 代码。该代码用于创建哈希,并且在每次迭代中都对具有不同值的数据进行大量操作。有很多 xor、or、and、数据左右移动等,这可能需要一百行左右的组装行,而且它们都在一个循环内,编译器决定——为了优化它——它将解开循环(这意味着它不会执行 jmp,而是再次复制粘贴相同的代码), 因此,我不必对代码进行一次逆向工程,然后看到它是一个运行 64 次的循环,而是不得不对相同的代码进行 64 次逆向工程,因为它基本上是被编译器在一个块中复制粘贴的,但编译器足够“好”,以至于它每次重复循环都使用完全不同的寄存器, 数据以一种奇怪的方式移动,在每次迭代时都使用不同的常量和不同的变量,而且——好像这还不够——每 1/4 个循环,改变算法并使其难以预测模式,迫使我将 5000+ 条装配线完全逆向工程为 C,然后慢慢重构和优化 C 代码,直到它变成那个里面有 7 行代码的循环......如果你好奇的话,可以在第 39 行看到代码,其中所有迭代都有一些共同的操作,然后是 4 个不同的操作,具体取决于我们正在执行的迭代,并且每次迭代后用于每个操作的变量都会发生变化(P、PP、PPP 和 PPPP 每次都会交换),并且每次迭代使用的常量值和索引也不同(参见 constants.h)。 这很复杂,需要很长时间才能进行逆向工程。
- Below is the calling graph of the PS3 firmware I worked on some years ago. All of these functions have been entirely reverse engineered (each black rectangle is actually an entire function, and the arrows show which function calls which other function), and the result was the ps3xport tool. As you can see, sometimes a function can be challenging to reverse, and sometimes a single function can call so many nested functions that it can get pretty complicated to keep track of what is doing what and how everything fits together. That function at the top of the graph was probably very simple, but it brought with it so much complexity because of a single “call”:
以下是我几年前使用的 PS3 固件的调用图。所有这些函数都完全经过了逆向工程(每个黑色矩形实际上是一个完整的函数,箭头显示哪个函数调用哪个其他函数),结果就是ps3xport工具。正如你所看到的,有时一个函数可能很难逆转,有时一个函数可以调用如此多的嵌套函数,以至于跟踪什么在做什么以及所有东西如何组合在一起可能会变得相当复杂。图顶部的那个函数可能非常简单,但由于一个“调用”,它带来了如此多的复杂性:

In conclusion: 结论:
- Reverse engineering isn’t just about learning a new language, it’s a very different experience from “learning Java/Python/Rust after you’ve mastered C”, because of the way it works; it can sometimes be very easy and boring, sometimes it will be very challenging for a very simple piece of code.
逆向工程不仅仅是学习一门新语言,它与“掌握了 C 语言后再学习 Java/Python/Rust”的体验截然不同,因为它的工作方式;它有时可能非常简单和无聊,有时对于一段非常简单的代码来说会非常具有挑战性。
- It’s all about perseverance, being very careful (it’s easy to get lost or make a mistake, and very hard to track down and fix a mistake/typo if you make one), and being very patient. We’re talking days, weeks, months. That’s why reverse engineering is something that very few people do (compared to the number of people who do general software development). Remember also that our first example was 82 bytes of code, and the second one was only 19 bytes long, and most of the time, when you need to reverse engineer something, it’s many hundreds of KBs of code.
这一切都是关于毅力,非常小心(很容易迷路或犯错误,如果你犯了错误/错别字,很难追踪和纠正错误/错别字),并且非常有耐心。我们谈论的是几天、几周、几个月。这就是为什么逆向工程是很少有人做的事情(与从事一般软件开发的人数相比)。还要记住,我们的第一个示例是 82 字节的代码,第二个示例只有 19 个字节长,大多数时候,当您需要对某些内容进行逆向工程时,它是数百 KB 的代码。
All that being said, the satisfaction you get when you finish reverse engineering some piece of code, when you finally understand how it works and can reproduce its functionality with open source software of your own, cannot be described with words. The feeling of achievement that you get makes all the efforts worth it!话虽如此,当你完成对一段代码的逆向工程时,当你最终理解它是如何工作的,并可以使用你自己的开源软件重现其功能时,你所获得的满足感是无法用言语来形容的。你得到的成就感让所有的努力都是值得的!
I hope this write-up helps everyone get a fresh perspective on what it means to “reverse engineer the code”, why it takes so long, and why it’s rare to find someone with the skills, experience and patience to do this kind of stuff for months—as it can be frustrating, and we sometimes need to take a break from it and do something else in order to renew our brain cells.我希望这篇文章能帮助每个人重新理解“逆向工程代码”意味着什么,为什么需要这么长时间,以及为什么很难找到一个有技能、经验和耐心的人来做几个月的这种事情——因为这可能会令人沮丧,我们有时需要休息一下,做点别的事情来更新我们的脑细胞。