计算机指令

上世纪 60 年代晚期或 70 年代初期,程序需要先写在纸上,然后转成二进制机器码,再打到打孔卡上(0 表示不打孔,1 表示打孔),送入特殊的计算机中执行。

从硬件的角度来看,CPU 就是一个超大规模集成电路,通过电路实现了加法、乘法乃至各种各样的处理逻辑。

从软件的角度来看,CPU 就是一个执行各种计算机指令(Instruction Code)的逻辑机器。这里的计算机指令,就好比一门 CPU 能够听得懂的语言,我们也可以把它叫作机器语言(Machine Language)。

不同的 CPU 能够听懂的语言不太一样。也就是 CPU 支持的语言不一样,这里的语言叫指令集(Instruction Set)。

一个计算机程序由成千上万条指令组成的。但是 CPU 里不能一直放着所有指令,所以计算机程序平时是存储在存储器中的。这种程序指令存储在存储器里面的计算机,我们就叫作存储程序型计算机(Stored-program Computer)。

了解了计算机指令和计算机指令集,接下来我们来看看,平时编写的代码,到底是怎么变成一条条计算机指令,最后被 CPU 执行的呢?我们拿一小段真实的 C 语言程序来看看。

// test.c
int main()
{
  int a = 1; 
  int b = 2;
  a = a + b;
}

通过编译器,可以将上述代码编译成汇编代码,再通过汇编器,将汇编代码编译成机器码,最后通过 CPU 执行。

在一个 Linux 操作系统上,我们可以简单地使用 gcc 和 objdump 这样两条命令,把对应的汇编代码和机器码都打印出来。

$ gcc -g -c test.c
$ objdump -d -M intel -S test.o

test.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
int main()
{
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
  int a = 1; 
   4:   c7 45 fc 01 00 00 00    mov    DWORD PTR [rbp-0x4],0x1
  int b = 2;
   b:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
  a = a + b;
  12:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  15:   01 45 fc                add    DWORD PTR [rbp-0x4],eax
}
  18:   5d                      pop    rbp
  19:   c3                      ret    

左侧有一堆数字,这些就是一条条机器码;右边有一系列的pushmovaddpop 等,这些就是对应的汇编代码。

了解了这个过程,下面我们放大局部,来看看这一行行的汇编代码和机器指令,到底是什么意思。

常见的指令可以分成五大类。

第一类是算术类指令。我们的加减乘除,在 CPU 层面,都会变成一条条算术类指令。

第二类是数据传输类指令。给变量赋值、在内存里读写数据,用的都是数据传输类指令。

第三类是逻辑类指令。逻辑上的与或非,都是这一类指令。

第四类是条件分支类指令。日常我们写的“if/else”,其实都是条件分支类指令。

最后一类是无条件跳转指令。写一些大一点的程序,我们常常需要写一些函数或者方法。在调用函数的时候,其实就是发起了一个无条件跳转指令。

指令跳转

CPU 如何执行指令的

代码经过软件层面的编译后就是一条条二进制指令,指令交由 CPU 中几百亿个晶体管去执行。我们先不管几百亿的晶体管的背后是怎么通过电路运转起来的,逻辑上,我们可以认为,CPU 其实就是由一堆寄存器组成的。而寄存器就是 CPU 内部,由多个触发器(Flip-Flop)或者锁存器(Latches)组成的简单电路。触发器和锁存器,其实就是两种不同原理的数字电路组成的逻辑门。

N 个触发器或者锁存器,就可以组成一个N位(Bit)的寄存器,能够保存 N位的数据。比方说,我们用的64Intel服务器,寄存器就是64 位的。

一个 CPU 里面会有很多种不同功能的寄存器。这里介绍三种比较特殊的。

  • PC 寄存器(Program Counter Register),我们也叫指令地址寄存器(Instruction Address Register)。顾名思义,它就是用来存放下一条需要执行的计算机指令的内存地址。

  • 指令寄存器(Instruction Register),用来存放当前正在执行的指令。

  • 条件码寄存器(Status Register),用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果。

除此之外还有整数寄存器浮点数寄存器向量寄存器地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

实际上,一个程序执行的时候,CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存里面是连续保存的,也会一条条顺序加载。

而有些特殊指令,比如上一讲我们讲到 J 类指令,也就是跳转指令,会修改 PC 寄存器里面的地址值。

从 if…else 来看程序的执行和跳转

首先看如下的例程,

// test.c
#include <time.h>
#include <stdlib.h>
 
int main()
{
  srand(time(NULL));
  int r = rand() % 2;
  int a = 10;
  if (r == 0)
  {
    a = 1;
  } else {
    a = 2;
  } 
gcc -g -c test.c
objdump -d -M intel -S test.o 

编译后打印出汇编代码如下:

 if (r == 0)
  3b:   83 7d fc 00             cmp    DWORD PTR [rbp-0x4],0x0
  3f:   75 09                   jne    4a <main+0x4a>
    {
        a = 1;
  41:   c7 45 f8 01 00 00 00    mov    DWORD PTR [rbp-0x8],0x1
  48:   eb 07                   jmp    51 <main+0x51>
    }
    else
    {
        a = 2;
  4a:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
  51:   b8 00 00 00 00          mov    eax,0x0
    } 

可以看到,这里对于 r == 0的条件判断,被编译成了 cmp 和 jne 这两条指令。

cmp 指令比较了前后两个操作数的值,这里的DWORD PTR 代表操作的数据类型是 32位的整数,而 [rbp-0x4] 则是一个寄存器的地址。所以,第一个操作数就是从寄存器里拿到的变量 r 的值。第二个操作数 0x0 就是我们设定的常量016 进制表示。cmp 指令的比较结果,会存入到条件码寄存器当中去。

在这里,如果比较的结果是 True,也就是 r == 0,就把零标志条件码(对应的条件码是 ZFZero Flag)设置为 1。除了零标志之外,IntelCPU 下还有进位标志(CFCarry Flag)、符号标志(SFSign Flag)以及溢出标志(OFOverflow Flag),用在不同的判断条件下。

cmp 指令执行完成之后,PC 寄存器会自动自增,开始执行下一条 jne 的指令。

跟着的 jne 指令,是 jump if not equal 的意思,它会查看对应的零标志位。如果为0,会跳转到后面跟着的操作数 4a 的位置。这个 4a,对应这里汇编代码的行号,也就是上面设置的 else 条件里的第一条指令。当跳转发生的时候,PC 寄存器就不再是自增变成下一条指令的地址,而是被直接设置成这里的 4a 这个地址。这个时候,CPU 再把 4a 地址里的指令加载到指令寄存器中来执行。

跳转到执行地址为 4a 的指令,实际是一条 mov 指令,第一个操作数和前面的 cmp 指令一样,是另一个 32 位整型的寄存器地址,以及对应的 216 进制值 0x2mov 指令把 2 设置到对应的寄存器里去,相当于一个赋值操作。然后,PC寄存器里的值继续自增,执行下一条 mov 指令。

这条 mov 指令的第一个操作数eax,代表累加寄存器,第二个操作数0x0则是 16 进制的0的表示。这条指令其实没有实际的作用,它的作用是一个占位符。我们回过头去看前面的 if 条件,如果满足的话,在赋值的 mov 指令执行完成之后,有一个 jmp 的无条件跳转指令。跳转的地址就是这一行的地址51。我们的 main 函数没有设定返回值,而mov eax, 0x0 其实就是给 main 函数生成了一个默认的为 0 的返回值到累加器里面。if 条件里面的内容执行完成之后也会跳转到这里,和 else 里的内容结束之后的位置是一样的。

函数调用:为什么会发生 Stack Overflow

静态链接

既然我们的程序最终都被变成了一条条机器码去执行,那为什么同一个程序,在同一台计算机上,在 Linux 下可以运行,而在 Windows 下却不行呢?反过来,Windows 上的程序在 Linux 上也是一样不能执行的。可是我们的 CPU 并没有换掉,它应该可以识别同样的指令呀?

将以下两个例程编译,然后通过 objdump 命令看看它们的汇编代码。

// add_lib.c
int add(int a, int b)
{
    return a+b;
}

// link_example.c
#include <stdio.h>
int main()
{
    int a = 10;
    int b = 5;
    int c = add(a, b);
    printf("c = %d\n", c);
}
gcc -g -c add_lib.c link_example.c
objdump -d -M intel -S add_lib.o
objdump -d -M intel -S link_example.o
add_lib.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi
   a:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
   d:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  10:   01 d0                   add    eax,edx
  12:   5d                      pop    rbp
  13:   c3                      ret    
link_example.o:     file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   48 83 ec 10             sub    rsp,0x10
   8:   c7 45 fc 0a 00 00 00    mov    DWORD PTR [rbp-0x4],0xa
   f:   c7 45 f8 05 00 00 00    mov    DWORD PTR [rbp-0x8],0x5
  16:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  19:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  1c:   89 d6                   mov    esi,edx
  1e:   89 c7                   mov    edi,eax
  20:   b8 00 00 00 00          mov    eax,0x0
  25:   e8 00 00 00 00          call   2a <main+0x2a>
  2a:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax
  2d:   8b 45 f4                mov    eax,DWORD PTR [rbp-0xc]
  30:   89 c6                   mov    esi,eax
  32:   48 8d 3d 00 00 00 00    lea    rdi,[rip+0x0]        # 39 <main+0x39>
  39:   b8 00 00 00 00          mov    eax,0x0
  3e:   e8 00 00 00 00          call   43 <main+0x43>
  43:   b8 00 00 00 00          mov    eax,0x0
  48:   c9                      leave  
  49:   c3                      ret    

既然代码已经被我们“编译”成了指令,我们不妨尝试运行一下 ./link_example.o

然而我们并不能成功运行,会得到一条cannot execute binary file: Exec format error 的错误。

我们再仔细看一下 objdump 出来的两个文件的代码,会发现两个程序的地址都是从 0 开始的。如果地址是一样的,程序如果需要通过 call 指令调用函数的话,它怎么知道应该跳转到哪一个文件里呢?

无论是这里的运行报错,还是 objdump 出来的汇编代码里面的重复地址,都是因为add_lib.o以及 link_example.o 并不是一个可执行文件(Executable Program),而是目标文件(Object File)。只有通过链接器(Linker)把多个目标文件以及调用的各种函数库链接起来,我们才能得到一个可执行文件。

通过 gcc-o参数,可以生成对应的可执行文件。

$ gcc -o link-example add_lib.o link_example.o
$ ./link_example
c = 15

ELF(Execuatable and Linkable File Format)的文件格式,中文名字叫可执行与可链接文件格式,这里面不仅存放了编译成的汇编指令,还保留了很多别的数据。

Responsive Image

链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,变成了最终的可执行代码。这也是为什么,可执行文件里面的函数调用的地址都是正确的。

Responsive Image

在链接器把程序变成可执行文件之后,要装载器去执行程序就容易多了。装载器不再需要考虑地址跳转的问题,只需要解析 ELF 文件,把对应的指令和数据,加载到内存里面供 CPU 执行就可以了。

为什么同样一个程序,在 Linux 下可以执行而在 Windows 下不能执行了。其中一个非常重要的原因就是,两个操作系统下可执行文件的格式不一样

我们今天讲的是 Linux 下的 ELF 文件格式,而 Windows 的可执行文件格式是一种叫作 PE(Portable Executable Format)的文件格式。Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。

如果我们有一个可以能够解析 PE 格式的装载器,我们就有可能在 Linux 下运行 Windows 程序了。这样的程序真的存在吗?没错,Linux 下著名的开源项目 Wine,就是通过兼容 PE 格式的装载器,使得我们能直接在 Linux 下运行 Windows 程序的。而现在微软的 Windows 里面也提供了 WSL,也就是 Windows Subsystem for Linux,可以解析和加载 ELF 格式的文件。

程序装载

程序装载面临的挑战

  • 可执行程序加载后占用的内存空间应该是连续的。 这点很好理解,指令正常都是顺序执行的。
  • 我们需要同时加载很多个程序,并且不能让程序自己规定在内存中加载的位置。 计算机通常会同时运行很多个程序,可能你想要的内存地址已经被其他加载了的程序占用了。

我们把指令里用到的内存地址叫作虚拟内存地址(Virtual Memory Address),实际在内存硬件里面的空间地址,我们叫物理内存地址(Physical Memory Address)。

程序里有指令和各种内存地址,我们只需要关心虚拟内存地址就行了。对于任何一个程序来说,它看到的都是同样的内存地址。我们维护一个虚拟内存到物理内存的映射表,这样实际程序指令执行的时候,会通过虚拟内存地址,找到对应的物理内存地址,然后执行。

内存分段

这种找出一段连续的物理内存和虚拟内存地址进行映射的方法,我们叫分段(Segmentation)。这里的段,就是指系统分配出来的那个连续的内存空间。

分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处,第一个就是内存碎片(Memory Fragmentation)的问题。

Responsive Image

我们可以通过内存交换(Memory Swapping)技术解决。

我们可以把 Python 程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里面。不过读回来的时候,我们不再把它加载到原来的位置,而是紧紧跟在那已经被占用了的 512MB 内存后面。这样,我们就有了连续的 256MB 内存空间,就可以去加载一个新的 200MB 的程序。

如果你自己安装过 Linux 操作系统,你应该遇到过分配一个 swap 硬盘分区的问题。这块分出来的磁盘空间,其实就是专门给 Linux 操作系统进行内存交换用的。

硬盘的访问速度要比内存慢很多,而每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个很占内存空间的程序,这样整个机器都会显得卡顿。

内存分页

既然问题出在内存碎片和内存交换的空间太大上,那么解决问题的办法就是,少出现一些内存碎片,并且让需要交换写入或者从磁盘装载的数据更少一点。于是就有了内存分页(Paging)。

和分段这样分配一整段连续的空间给到程序相比,分页是把整个物理内存空间切成一段段固定尺寸的大小。而对应的程序所需要占用的虚拟内存空间,也会同样切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。

从虚拟内存到物理内存的映射,不再是拿整段连续的内存的物理地址,而是按照一个一个页来的。页的尺寸一般远远小于整个程序的大小。在 Linux 下,我们通常只设置成 4KB。

由于内存空间都是预先划分好的,也就没有了不能使用的碎片,而只有被释放出来的很多 4KB 的页。即使内存空间不够,需要让现有的、正在运行的其他程序,通过内存交换释放出一些内存的页出来,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,让整个机器被内存交换的过程给卡住。

分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

实际上,我们的操作系统,的确是这么做的。当要读取特定的页,却发现数据并没有加载到物理内存里的时候,就会触发一个来自于 CPU 的缺页错误(Page Fault)。我们的操作系统会捕捉到这个错误,然后将对应的页,从存放在硬盘上的虚拟内存里读取出来,加载到物理内存里。

动态链接

如果我们有很多个程序都要通过装载器装载到内存里面,那里面链接好的同样的功能代码,也都需要再装载一遍,再占一遍内存空间。在一个运行上百个进程的系统中,这将会造成极大的资源浪费。

共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamic linking)是由一个叫做动态链接器(dynamic linker)的程序来执行的。Linux 中为.so文件,Windows 中为.dll文件。

Responsive Image

PIC (Position-Independent Code)

要想要在程序运行的时候共享代码,也有一定的要求,就是这些机器码必须是“地址无关”的。也就是说,我们编译出来的共享库文件的指令代码,放在哪个内存地址都能正常运行。

Responsive Image

对于所有动态链接共享库的程序来讲,虽然我们的共享库用的都是同一段物理内存地址,但是在不同的应用程序里,它所在的虚拟内存地址是不同的。我们没办法、也不应该要求动态链接同一个共享库的不同程序,必须把这个共享库所使用的虚拟内存地址变成一致。如果这样的话,我们写的程序就必须明确地知道内部的内存地址分配。

如何才能实现地址无关?

动态代码库内部的变量和函数调用都很容易解决,我们只需要使用相对地址(Relative Address)就好了。各种指令中使用到的内存地址,给出的不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址。因为整个共享库是放在一段连续的虚拟内存地址中的,无论装载到哪一段地址,不同指令之间的相对地址都是不变的。

PLT 和 GOT

// lib.h
#ifndef LIB_H
#define LIB_H
 
void show_me_the_money(int money);
#endif

// lib.c
#include <stdio.h>
 
void show_me_the_money(int money)
{
    printf("Show me USD %d from lib.c \n", money);
}
// show_me_poor.c
#include "lib.h"
int main()
{
    int money = 5;
    show_me_the_money(money);
}
gcc lib.c -fPIC -shared -o lib.so
gcc -o show_me_poor show_me_poor.c ./lib.so

-fpic 选项指示编译器生成与位置无关的代码。-shared选项指示链接器创建一个共享的目标文件。

然后,我们再通过 gcc 编译 show_me_poor 动态链接了 lib.so 的可执行文件。在这些操作都完成了之后,我们把 show_me_poor 这个文件通过 objdump 出来看一下。

$ objdump -d -M intel -S show_me_poor

……
0000000000400540 <show_me_the_money@plt-0x10>:
  400540:       ff 35 12 05 20 00       push   QWORD PTR [rip+0x200512]        # 600a58 <_GLOBAL_OFFSET_TABLE_+0x8>
  400546:       ff 25 14 05 20 00       jmp    QWORD PTR [rip+0x200514]        # 600a60 <_GLOBAL_OFFSET_TABLE_+0x10>
  40054c:       0f 1f 40 00             nop    DWORD PTR [rax+0x0]
 
0000000000400550 <show_me_the_money@plt>:
  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>
  400556:       68 00 00 00 00          push   0x0
  40055b:       e9 e0 ff ff ff          jmp    400540 <_init+0x28>
……
0000000000400676 <main>:
  400676:       55                      push   rbp
  400677:       48 89 e5                mov    rbp,rsp
  40067a:       48 83 ec 10             sub    rsp,0x10
  40067e:       c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
  400685:       8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  400688:       89 c7                   mov    edi,eax
  40068a:       e8 c1 fe ff ff          call   400550 <show_me_the_money@plt>
  40068f:       c9                      leave  
  400690:       c3                      ret    
  400691:       66 2e 0f 1f 84 00 00    nop    WORD PTR cs:[rax+rax*1+0x0]
  400698:       00 00 00 
  40069b:       0f 1f 44 00 00          nop    DWORD PTR [rax+rax*1+0x0]
……

main 函数调用 show_me_the_money 的函数的时候,对应的代码是这样的:

call   400550 <show_me_the_money@plt>

这里后面有一个@plt 的关键字,代表了我们需要从PLT,也就是过程链接表(Procedure Link Table)里面找要调用的函数。对应的地址呢,则是 400550 这个地址。

那当我们把目光挪到上面的 400550 这个地址,你又会看到里面进行了一次跳转,这个跳转指定的跳转地址,你可以在后面的注释里面可以看到,GLOBAL_OFFSET_TABLE+0x18。这里的 GLOBAL_OFFSET_TABLE,就是我接下来要说的全局偏移表。

  400550:       ff 25 12 05 20 00       jmp    QWORD PTR [rip+0x200512]        # 600a68 <_GLOBAL_OFFSET_TABLE_+0x18>

在动态链接对应的共享库,我们在共享库的 data section 里面,保存了一张全局偏移表(GOT,Global Offset Table)。虽然共享库的代码部分的物理内存是共享的,但是数据部分是各个动态链接它的应用程序里面各加载一份的。所有需要引用当前共享库外部的地址的指令,都会查询 GOT,来找到当前运行程序的虚拟内存里的对应位置。而 GOT 表里的数据,则是在我们加载一个个共享库的时候写进去的。

虽然不同的程序调用的同样的动态库,各自的内存地址是独立的,调用的又都是同一个动态库,但是不需要去修改动态库里面的代码所使用的地址,而是各个程序各自维护好自己的 GOT,能够找到对应的动态库就好了。

二进制编码

原码表示法,左侧第一位是符号位,符号位为 1 时表示负数,为 0 时表示正数。 补码表示法,我们不再把这一位当成单独的符号位,在剩下几位计算出的十进制前加上正负号,而是在计算整个二进制值的时候,在左侧最高位前面加个负号。

比如,一个 4 位的二进制补码数值 1011,转换成十进制,就是 $-1 \times 2^3 + 0 \times 2^2 + 1 \times 2^1 + 1 \times 2^0 = -5$

一个 4 位的二进制数,可以表示从 -8 到 7 这 16 个整数,不会白白浪费一位。

字符集:表示的可以是字符的一个集合。比如说“第一版《新华字典》里面出现的所有汉字”,这是一个中文字符集。比如,我们日常说的Unicode,其实就是一个字符集,包含了 150 种语言的 14 万个不同的字符。

字符编码:对于字符集里的这些字符,怎么一一用二进制表示出来的一个字典。我们上面说的 Unicode,就可以用UTF-8UTF-16,乃至 UTF-32 来进行编码,存储成二进制。

同样的文本,采用不同的编码存储下来。如果另外一个程序,用一种不同的编码方式来进行解码和展示,就会出现乱码。

锟斤拷 烫烫烫

Unicode 编码一直持续在收录各种字元,这就可能会出现各种作业系统支援的 Unicode 字元不一样。这也就会导致 A 上的一个用 Unicode 编码的字元,在 B 上就会出现无法显示的情况。为了避免这种情况,在 Unicode 中定义了一个特殊字元�,它的 Unicode 编码为 0xFFFD。

假如 A 支援特殊字元⬆,但是 B 并不支援这个⬆,那么在 B 中将会用�来代替。

这个字元用 UTF-8 编码后,十六进位表示为0xEF 0XBF 0XBD。如果连续出现两个⬆符号,那么用 UTF-8 编码后的十六进位则表示为0xEF 0XBF 0XBD 0xEF 0XBF 0XBD,这时候再转码成 GBK,因为 GBK 中用两个位元组表示一个字元,那么上述的字元就成了锟(0xEFBF),斤(0xBDEF),拷(0xBFBD)。出现锟斤拷的原因就是 UTF-8 转码 GBK 的过程中出现了问题。当然如果想要出现锟斤拷,则至少需要两个字元出现乱码。

而“烫烫烫”,则是因为如果你用了 Visual Studio 的调试器,默认使用 MBCS 字符集。“烫”在里面是由 0xCCCC 来表示的,而 0xCC 又恰好是未初始化的内存的赋值。于是,在读到没有赋值的内存地址或者变量的时候,电脑就开始大叫“烫烫烫”了。

理解电路

从以上的学习可以知道,最终执行的程序都是二进制的指令。那为何计算机最终选择了二进制呢?接下来看看计算机在硬件层面究竟是怎么表示二进制的,以此你就会明白,为什么计算机会选择二进制。

加法器

乘法器

浮点数和定点数