Background and Viewpoint

要理解 assembly code,就必须知道它工作所在的体系结构。至少,你需要弄清楚 instruction 是如何在 CPU、register 和 memory 中流动的,instruction 又是如何指挥它们运转的。特别是,对于 newbie 来讲很容易忽略 register 的重要作用,从而导致自己对 instruction 在整个体系中的行为产生误解。

一个高度概括 CPU、register 和 memory 的宏观体系架构「经典图例」来自于 CSAPP(Computer Systems A Programmers Perspective)的 Figure 1.4:

image.png

如图可知可以看到

  • CPU 的一个核心是 PC(program counter),它是一个仅有 word-sized 大小的 storage device。
    • 凡是 word-sized 大小的存储设备都可以叫 register,所以 PC 是一个特殊的 register,它的名称叫做 PC。
    • register file 中也包含了多个 register,为了区分彼此,每一个 register 都有一个特定的名称。
    • 一般使用 register 时,泛指 register file 中的 register;指定 PC 这个 register 时通常不用 register 来简称,而是直接用 PC 称呼。
  • 这个 register 仅能存放 word-sized 大小的数据,即:memory address。
  • 从计算机开机到关机,每时每刻 CPU 都在执行 PC 所指向的、存放于 memory 的 instruction:
    • 从 PC 所指向的 memory address 读取到相应的 instruction 。
    • 按照这个 instruction 在 ALU 中做相应的计算。
    • 更新 PC 的值为下一条 instruction 的 memory address(不一定同刚才那条 instruction 连续)。
  • register file(上图中 PC 旁边的、多个小长方形组成的矩形):由多个 word-sized 大小的 register 组成的 small storage device。
    • 每一个小的长方形,即是一个 register,且它的大小为 word-sized。
    • 显然,register file 中的「每个小长方形」跟 PC 是一样大小的。且它们都被称之为 register。由此可见,register 可被当做是一个 word-sized 的、在 CPU 中的存储单位。
    • register file 中的每个小长方形,各自都有一个 unique name。
  • ALU 可计算出 new data 或者 new address value。
  • CPU 支持的 instruction:
    • Load:将 memory 中的一个 word-sized 大小的内容,复写到某个 register。
    • Store:将 register 中的 word-sized 大小的内容,复写到 memory 中的某个位置。
    • Operate:将两个 register 中的内容拷贝到 ALU,对这两个 word-sized 的值做算数运算,将结果复写到一个 register 中。
    • Jump:从 instruction 中提取出一个 word-sized 大小的内容,将其复写到 PC。

register file 中的 register 有多个,它们分别有不同的作用,如:

  • general purpose register:用于 data movement、arithmetic instructions。
  • index register:充当 pointer
  • base pointer:data pointer
  • stack pointer
  • segment register:用于存储 program 的 不同部分,如 code segment、data segment、stack segment、extra segment。

每一条 machine instruction,其实就是一个 number:

  • 例如 03 C3 表示:将 EAX 和 EBX 这两个 register 中的内容相加,并将结果放到 EAX 中。
  • 但这样的 machine instruction 太难记忆,于是引入 assemble language:
    • 每一个条 assemble statement 都对应一条 machine instruction;
    • 例如上面的 03 C3 machine instruction 对应的 assemble statement 为 add eax, ebx

Intel x86 Key Processors

  • 8088, 8086:仅支持 real mode。在 real mode 下,任何一根 process 可以访问任意的 memory address,甚至可以访问其他 process 的 memory address!
  • 80286:开始支持 16-bit protected mode
  • 80386:开始支持 32-bit protected mode

OS Conceptual Model

image.png
容易将 OS 当做客观的存在(左图)。而是事实是,OS 也是 program,它也是必须依赖于 memory 和 CPU、以 instruction 在两者之间传递的形式而存在(右图)。


x86 Assembler Language

相同的 x86 ISA(instruction set architecture)可以由不同的 assembler 来解析。而不同的 assembler 则会使用不同的 assembly language。

常用的 assembly language 有:MASMNASMGASGNU Assembler)、as86、TASM、a86、Terse 等(assembly code 通常保存于 .s file 中。另:如果是 **.S** file,则其中可以容纳 preprocess 的语句,如 **include**)。其中:

如同 assembler 是各式各样的,x86 的 object file 的类型也是各式各样的,例如:

  • OMF: used in DOS but has 32-bit extensions for Windows. Old.
  • AOUT: used in early Linux and BSD variants
  • COFF: “Common object file format”
  • Win, Win32: Microsoft’s version of COFF, not exactly the same! Replaces OMF.
  • Win64: Microsoft’s format for Win64.
  • ELF, ELF32: Used in modern 32-bit Linux and elsewhere
  • ELF64: Used in 64-bit Linux and elsewhere
  • macho32: NeXTstep/OpenStep/Rhapsody/Darwin/OS X 32-bit
  • macho64: NeXTstep/OpenStep/Rhapsody/Darwin/OS X 64-bit

广义上讲,「目标文件」与「可执行文件」的格式几乎是一样的,可以粗略地看作是一种类型的文件。「可执行文件」格式主要是 Windows 的 PE(portable executable)和 Linux 的 ELF(executable linkable format),它们都是 COFF(common file format)格式的变种。于是,我们可以对其做如下分类(参考《程序员的自我修养—链接、装载与库》):

  • 可执行文件:
    • executable:Windows
    • ELF(executable linkable format):Linux
  • 动态链接库(DLL,dynamic linking library):
    • .dll :Windows
    • .so (shared object file):Linux
  • 静态链接库(static linking library):
    • .lib :Windows
    • .a :Linux | ELF文件类型 | 说明 | 实例 | | —- | —- | —- | | 可重定位文件(Relocatable File) | 包含了「代码」和「数据」,可被用来链接成「可执行文件」或「目标文件」。

      静态链接库也可以归为这一类。 |
      - Linux的 .o
      - Windows的 .obj
      | | 可执行文件(Executable file) | 包含了可以直接执行的程序,如ELF可行性文件。(一般没有扩展名) |
      - /bin/bash 文件
      - Windows的.exe
      | | 共享目标文件(shared object file) | 包含了「代码」和「数据」,可以在下面两种情况下使用:
      - “连接器”可以使用这种文件跟其它「可重定向文件」和「共享目标文件」,产生新的目标文件。
      - “动态链接器”可以将这几种共享目标文件与「可执行文件」结合,作为进程映像的一部分来执行。
      |
      - Linux的 .so ,如 /lib/glibc-2.5.so
      - Windows的 dll
      | | 核心转储文件(core dump file) | 当进程意外终止时,系统可以将该进程的地址空间内容即终止时的其它信息,转储到这类文件。 | Linux下的 core dump |

整个 assembly code 都是围绕着 CPU、register、memory 打转,特别是 register 值得格外重视,它是整个 instruction 的枢纽中心,是 hardware provided variable(不像 memory variable 可以随时在某一个 physical position 消失、再从另一个 physical position 重新分配)。

如果按照 instruction 操作来分类,assembly code 的结构其实很简单,仅 6 类:

  1. access data(register、memory access)
  2. arithmetic、logical operation(+-*/、and/or/xor)
  3. control(if, loop)
  4. procedure(function call)
  5. array(allocation、access)
  6. heterogeneous data structure(struct、union)

所谓掌握 assembly code,无非就是掌握这 6 类 instruction 对应的 assembly code。

Assembly code 中最常使用的便是对 memory 或 register 的访问(即上述「1」)。掌握了对 data source access 的含义,配合上基本的 action name(如:add, mov, sub),就能将大部分的 assembly code 读懂。

理论上讲,register 就是 CPU 中的 physical variable 的位置,可以存放任何数据。但它们都有各自的习惯用法,因而可以按照「习惯用法」做一些初步的分类。例如,可以考察 CSAPP 的 Figure 3.2:
image.png
这些 32 位寄存器有多种用途,但每一个都有 “专长”,有各自的特别之处(前缀 e 是因为从 16-bit 扩大到了 32-bit 来标明 extend):

  1. arithmetic operation**:**
    1. eax(accumulator):是 “累加器”,它是很多加法乘法指令的 default 寄存器。
    2. ecx(counter): 是计数器,是重复(rep)前缀指令和 loop 指令的内定计数器。
    3. edx(divider): 则总是被用来放整数除法产生的余数。
    4. ebx(base): 是 “基地址” 寄存器,在内存寻址时存放基地址。
  2. source/destination**:**
    1. esi(source index):分别叫做 “源索引寄存器”(source index),因为在很多字符串操作指令中,DS:ESI 指向源串,而 ES:EDI 指向目标串.
    2. edi(destination index):目标索引寄存器(destination index)
  3. pointer**:**
    1. esp(stack pointer):栈顶(stack top)指针。
    2. ebp(base pointer):是 “基址指针”(base pointer),它最经常被用作高级语言「函数调用」(function call)的 “框架指针”(frame pointer)
  4. PC(program counter)**:**
    1. eip:寄存器存放下一个 CPU 指令存放的内存地址,当 CPU 执行完当前的指令后,从 EIP 寄存器中读取下一条指令的内存地址,然后继续执行。

知晓了每个 register 的习惯用法后,我们便可以探讨 assembly code 对 data source(register, memory)的引用了。
image.png
可以总结的规律是

  • 有括号」的表达式,都会被映射为 M [] 操作
    • 最 general 的形态: Imm(Eb, Ei, s) = M[ Imm + R[Eb] + R[Ei] * s ] .
  • 没有括号」时,分三种情况:
    • 以 % 开头的,映射为 R [] 操作
    • 以 $ 开头的,映射为常数值
    • 单纯的常数值,映射为 M [] 操作

作为一个快速应用,我们根据上述规则来尝试解答 CSAPP 的习题:
image.png

  • %eax = R[%eax] = 0x100
  • 0x104 = M[0x104] = 0xAB
  • $0x108 = 0x108
  • (%eax) = M[ R[%eax] ] = M[0x100] = 0xFF
  • 4(%eax) = M[4 + R[%eax]] = M[4 + 0x100] = M[0x104] = 0xAB
  • 9(%eax, %edx) = M[9 + R[%eax] + R[%edx]] = M[9 + 0x100 + 0x3] = M[0x10C] = 0x11
  • 260(%ecx, %edx) = M[260 + R[%ecx] + R[%edx]] = M[260 + 0x1 + 0x3] = M[0x104 + 0x4] = M[0x108] = 0x13
  • 0xFC(, %ecx, 4) = M[0xFC + R[%ecx] 4] = M[0xFC + 0x1 4] = M[0x100] = 0xFF
  • (%eax, %edx, 4) = M[R[%eax] + R[%edx] 4] = M[0x100 + 0x3 4] = M[0x100 + 0xC] = M[0x10C] = 0x11

再来是 mov 操作:
image.png
可以看到

  • mov 操作的后缀:
    • 仅有数量单位 b, w, l
    • 后缀 s(sign-extended),紧接数量单位转换
    • 后缀 z(zero-extended),紧接数量单位转换
  • pushl 操作,实际是由两步构成的:
    • 将 register % esp 的值,修改为 R [% esp] - 4;(即 memory 中的地址值减 4)
    • 之后,再将 S 的值复写到 register % esp 的值所指向的 memory 区域;(即将 S 复写到 M [R [% esp] ] .)
  • popl 操作,也是由两步构成:
    • 将当前 register % esp 所指向的 memory 区域的值,复写到 D;(即将 M [R [% esp] ] 的值写到 D)
    • 再将 register % esp 保存的地址值,更新,即自增 4。

x86 register 分类

https://wiki.osdev.org/CPU_Registers_x86


Control Register

(可参考 xv6 中 entry.S 中的代码)

https://en.wikipedia.org/wiki/Control_register

CR 开头的 register 都是 control register,用于控制 CPU behavior,如:

  • interrupt control
  • switching addressing mode
  • paging control
  • coprocessor control

%CRO
image.png

  • %CR1 :reserved
  • %CR2 (Page Fault Linear Address):当发生 page default 时,program 需要去访问的 address 就存储于 %CR2

%CR3 :当 virtual address 被打开时(即:%CR0 设置了 PG 字段时),%CR3 允许 CPU 做 paging 的地址转换(linear virtual address —> page directory —> page table —> physical address)。

image.png

如上图可知,CR3 存储了 page directory 的地址。当遇到从 memory 取出来的 virtual linear address 时,就根据 %CR3 中的值找到 page directory 的地址,在按照上图的映射流程找到 page table,进而找到 physical address。

%CR4 :在 protected mode 时使用,用于:

  • enabling I/O breakpoints
  • page size extension(PSE
  • machine-check exceptions

image.png


MMU: memory management unit

https://en.wikipedia.org/wiki/Memory_management_unit


Machine Code & Assembly Code

最真实的 machine code 无疑是:

  1. 1000101101001100001001000000010010001011110000011001100100110011
  2. 1100001001001011110000101000001111100000000000010011001111000010
  3. 0010101111000010100011010100010001001001000000010111010000000111
  4. 1000110100000100100011011111110111111111111111000011

将其使用 16 进制的数字描述为:

  1. 8b 4c 24 04 8b c1 99 33 c2 2b c2 83 e0 01 33 c2
  2. 2b c2 8d 44 49 01 74 07 8d 04 8d fd ff ff ff c3

这份 machine code 使用 NASM assembler syntax 来描述(参考:Assembly and Disassembly)为:
image.png
使用 GAS assembler syntax(GAS is the GNU Assembler, that GCC relies upon. Because GAS was invented to support a 32-bit unix compiler, it uses standard AT&T syntax, which resembles a lot the syntax for standard m68k assemblers, and is standard in the UNIX world.)的描述为:
image.png
由此可知,machine code 和 assembly code 是一一对应的,每一条 assembly code 只不过是一条 instruction 的翻译而已。

通常,assembly code 存储于 .S 文件中。我们可以通过 gcc 来将 source code 先转换为 .S 文件的 assembly code,然后再将其转换为 binary code。

例如,根据 x86 Assembly/GAS Syntax 的示例可知,对于 hello.c 文件来讲,我们可以做如下操作:

  1. # 直接从 source code 生成 binary code
  2. gcc -o hello_c hello.c
  3. ./hello_c
  4. # --------------------------------------------
  5. # 从 source code 到 assembly code:
  6. # - 使用 32-bit x86 assembly language
  7. # - 生成的是 hello.s 文件
  8. gcc -S -m32 hello.c
  9. # 从 assembly code 到 binary code
  10. gcc -o hello_asm -m32 hello.s
  11. ./hello_asm

我们可以一撇 hello.s 文件:

  1. .file "hello.c"
  2. .def ___main; .scl 2; .type 32; .endef
  3. .text
  4. LC0:
  5. .ascii "Hello, world!\12\0"
  6. .globl _main
  7. .def _main; .scl 2; .type 32; .endef
  8. _main:
  9. pushl %ebp
  10. movl %esp, %ebp
  11. subl $8, %esp
  12. andl $-16, %esp
  13. movl $0, %eax
  14. movl %eax, -4(%ebp)
  15. movl -4(%ebp), %eax
  16. call __alloca
  17. call ___main
  18. movl $LC0, (%esp)
  19. call _printf
  20. movl $0, %eax
  21. leave
  22. ret
  23. .def _printf; .scl 2; .type 32; .endef

Remarks

  • .file.def.ascii 开头的行,是 assembler directive 行,用于告知 assembler 如何 assemble the file。
  • 类似于 _main: 这样以「text 和 : 」作为组合形式的行,表示 label,用于标识 file location。

其中 .file.def 行用于 debug,因而可将其去掉,进而得到:

  1. .text
  2. LC0:
  3. .ascii "Hello, world!\12\0"
  4. .globl _main
  5. _main:
  6. pushl %ebp
  7. movl %esp, %ebp
  8. subl $8, %esp
  9. andl $-16, %esp
  10. movl $0, %eax
  11. movl %eax, -4(%ebp)
  12. movl -4(%ebp), %eax
  13. call __alloca
  14. call ___main
  15. movl $LC0, (%esp)
  16. call _printf
  17. movl $0, %eax
  18. leave
  19. ret

Remarks

  • .text 行(「line 1」)用于标识 code 的起始位置。
  • 「line 2-3」声明了一个名为 LC0 的 label,「line 3」将一些 raw ASCII 放于 program。
  • 「line 4」告知 assembler _main 为一个 global label,可以被 program 的其它部分看见。
    • _main label 需要被 linker 看见,因为 startup code 会调用 _main 作为 subroutine。
  • 「line 5」是 _main label 的定义。
  • 「line 6-8」做了 3 部分的工作(start of main function):
    • 先将 %ebp 的 value 保存于 stack 上(做一个备份,因为之后要使用 %ebp 来做临时变量)。
    • %esp 的 value 赋值到 %ebp 中。
    • %ebp 上的值减去 8。
  • 「line 9」 将 %epb 的值同 0xFFFFFFF0 取 AND 操作。
  • 「line 10-12」一些 mov 操作。
  • 「line 13-14」:通过 call 语句来调用 C library 的 function。
  • 「line 15-16」:
    • $LC0 label 所代表的 raw ASCII string 移动至 stack top(由 (%esp) 表示,%esp 是 stack pointer,根据它的值取到对应的 memory 即是取到 stack top)。
    • 调用 C library 的 _printf subroutine 将 message 打印到 console。
  • 「line 17」:将整个 program 的返回值:0,保存于 %eax 中。
  • 「line 18」:leave 通常是 subroutine 的最后一行,用于 free space(将「line 6」保存于 stack 的值重新赋值给 %ebp
  • 「line 19」:将 control 交还给 calling procedure。

当然,我们可以尝试不通过 C 语言的编译,而是直接通过撰写 assembly code 来调用 system call 将「hello word」打印出来(参考:x86 Assembly Language ProgrammingGNU Assembler Examples):
image.png
这个 hello.s 文件的运行方式如图中注释所示:

  1. gcc -c hello.s && ld hello.o && ./a.out

根据上述 hello.chello.s 文件的转换过程,我们可以稍微梳理一下从 source code 到 executable code 的变化过程:

  • source code(.c file) ==> assembly code(.s file, by compiling:gcc -S -m32 hello.c
  • assembly code(.s file) ==> machine code of object file(.o file, by assembler processing:gcc -c hello.s
  • object file(.o file) ==> executable file(.out file, by linking:ld hello.o

如果再将 preprocess 等过程完整地考虑进来,我们便能得到整个从 source code 到 executable code 的过程(参考 CSAPP Figure 1.3):
image.png
image.png

  • Preprocessing:
    • 按照「预处理」命令(如 #include )来修改 source code,相当于通过命令来执行 editor 的一些工作。
    • 纯粹是「编辑器」修改 source code 的工作,只不过使用「预处理」命令来工程化。
    • 文件类型修改: hello.c —> hello.i
    • 单独此步使用的命令: gcc -E hello.c -o hello.i
  • Compiling:
    • 将 source code 编译为「汇编语言」。
    • 之所以不是直接编译为 machine code,是因为这一步之后,还可以同其它编译为 assembly code 的代码结合,例如同一个由 Fortran 编写编译为 assembly code 的代码。
    • 文件类型修改: hello.i —> hello.s
    • 单独此步使用的命令: gcc -S hello.c -o hello.s
  • Assembly:
    • 将 assembly code 编译为 machine code,即 object code。
    • 此时虽然是 machine code,还不可以被执行。因为此时只引入了头文件,头文件中的函数「声明」对应的到底是「定义」并不清楚
    • 文件类型修改: hello.s —> hello.o
    • 单独此步使用的命令: as hello.s -o hello.o 或者 gcc -c hello.s -o hello.ogcc -c hello.c -o hello.o
  • Linking:
    • 将头文件中涉及到的「其它代码」连接起来,生成 executable file。例如, printf() 函数并未在 hello.c 中定义,linking phase 这一步便可以将 printf() 的定义代码连接过来。
    • 这里连接过来的并不是 printf() 的 source code,而是 printf() 的 object code: printf.o
    • printf.o 是一个单独放置的「precompiled」object file
    • 之所以需要 linking phase 这一步,不过是为了将「引入的代码」真正合并过来。compiling/assembly 阶段只引入了声明,具体做了什么事其实是不知道的。
    • 文件类型: hello.o —> hello
    • 单独此步使用的命令: ld -static /usr/lib/crt1.o /usr/lb/crti.o /usr/lib/gcc/i486-linux-gnu/4.1.3/crtbeginT.o -L/usr/lib/gcc/i486-linux-gnu/4.1.3 -L/usr/lib -L/lib hello.o --start-group-lgcc -lgcc_eh -lc --end-group /usr/lib/gcc/i486-linux-gnu/4.1.3/crtend.o /usr/lib/crtn.o

这里最微妙的地方,或许在于将「引入外部代码」这个过程拆为了两步:1、引入声明;2、引入定义(可执行代码)。

最常见的库就是运行时库(runtime library),它是支持程序运行的基本函数的集合。「库」其实就是一组「目标文件」的包,就是一些最正常用的代码编译成目标文件后,被打包存放。
image.pngimage.png
Remarks(假设在 main.c 中调用了另一个模块 func.c 中的 foo() 函数):

  • 这时,在 main.c 中的每一处调用了 foo() 的时候都必须确切知道 foo 这函数的地址(找到函数地址,就能加载函数的执行代码)。
  • 由于每个模块是单独编译的,在 compiling 阶段 main.c 并不知道 foo 的地址,于是只能暂时把 foo 的目标地址搁置,等待在 linking 阶段被修正。
  • func.c 模块被重新编译时, foo 函数的地址有可能被改变。此时,在 main.c 中所有使用 foo 的地址的指令都需要被重新调整。
  • 连接器能自动地在 linking 阶段重新调整所有变动的地址。

另:x86 Assembly Language Programming 以另一种视角来整理了上述的过程,我们可以姑且一看以作参考:

image.png

以上讨论,均为 hello world 的 toy program,如要进一步检验自己对 GNU assembler language 的理解,则需尝试阅读 xv6bootasm.S 的代码:
image.png
可以看到在 bootasm.s 文件中有如下 assembly directive

  • .code16
  • .global.global makes the symbol visible to ld . If you define symbol in your partial program, its value is made available to other partial programs that are linked with it. Otherwise,symbol takes its attributes from a symbol of the same name from another file linked into the same program.
  • .code32
  • .p2align 2
  • .word
  • .long :same as .int , expect zero or more expressions, of any section, separated by commas. For each expression, emit a number that, at run time, is the value of that expression. The byte order and bit size of the number depends on what kind of target the assembly is for.

而 assembly label 有:

  • start
  • seta20.1
  • seta20.2
  • start32
  • spin
  • gdt
  • gdtdesc

在各种 assembly operation 中,不容易辨识的一些操作为(参考:The Linux Kernel/Syscalls):

  • cli:表示 clear interrupt flag(if),用于关闭 interrupt
  • testb:test byte, s1 & s2
  • jnz(同 jne):not equal / not zero
  • lgdt:load processor’s global descriptor table
  • ljmp:long jump
  • inb:port IO
  • outb:port IO
  • outw:port IO
  • SEG_NULLASM:
  • SEG_ASM: