程序编码

代码示例

mstore.c

1
2
3
4
5
6
long mult2(long, long); 

void multstore(long x, long y, long *dest){
	long t = mult2(x,y);
	*dest = t;
}

main.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stdio.h>

void multstore(long, long, long *);

int main(){
	long d;
	multstore(2,3,&d);
	printf("2 * 3 --> %ld\n",d);
	return 0;
}

long mult2(long a, long b){
	long s = a * b;
	return s; 
}

使用gcc编译

1
gcc -Og -o p mstore.c main.c

首先,C预处理器扩展源代码,插入所有用#include 命令指定的文件,并扩展所有用#define 声明指定的宏。其次,编译器产生两个源文件的汇编代码,名字分别为 mstore.s 和 main.s。接下来,汇编器会将汇编代码转化成二进制目标代码文件 p1.o 和 p2.o。目标代码是机器代码的一种形式,它包含所有指令的二进制表示,但是还没有填人全局值的地址。最后,链接器将两个目标代码文件与实现库两数(例如 printf)的代码合并,并产生最终的可执行代码文件(由命令行指示符 -o p 指定的)。可执行代码是我们要考虑的机器代码的第二种形式,也就是处理器执行的代码格式。会在第 7章更详细地介绍这些不同形式的机器代码之间的关系以及链接的过程。

1
gcc -Og -S mstore.c

产生一个汇编文件mstore.s,但是不做其他进一步的工作。

mstore.s完整内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
	.file	"mstore.c"
	.text
	.globl	multstore
	.type	multstore, @function
multstore:
.LFB0:
	.cfi_startproc
	endbr64
	pushq	%rbx
	.cfi_def_cfa_offset 16
	.cfi_offset 3, -16
	movq	%rdx, %rbx
	call	mult2@PLT
	movq	%rax, (%rbx)
	popq	%rbx
	.cfi_def_cfa_offset 8
	ret
	.cfi_endproc
.LFE0:
	.size	multstore, .-multstore
	.ident	"GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0"
	.section	.note.GNU-stack,"",@progbits
	.section	.note.gnu.property,"a"
	.align 8
	.long	 1f - 0f
	.long	 4f - 1f
	.long	 5
0:
	.string	 "GNU"
1:
	.align 8
	.long	 0xc0000002
	.long	 3f - 2f
2:
	.long	 0x3
3:
	.align 8
4:

上面代码中每个缩进去的行都对应于一条机器指令。比如,pushq 指令表示应该将寄存器 %rbx 的内容压人程序栈中。

如果使用 “-c” 命令行选项,GCC会编译并汇编该代码:

1
gcc -Og -c mstore.c

这就会产生目标代码文件 mstore.o,它是二进制格式的。

从中得到一个重要的信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。

查看机器代码文件的内容,反汇编器(disassembler)很有用

1
objdump -d mstore.o

结果如下:

1
2
3
4
5
6
7
8
0000000000000000 <multstore>:
   0:	f3 0f 1e fa          	endbr64 
   4:	53                   	push   %rbx
   5:	48 89 d3             	mov    %rdx,%rbx
   8:	e8 00 00 00 00       	callq  d <multstore+0xd>
   d:	48 89 03             	mov    %rax,(%rbx)
  10:	5b                   	pop    %rbx
  11:	c3                   	retq

生成实际可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数

用如下方法生成可执行文件prog

1
gcc -Og -o p mstore.c main.c

ATT与Intel

image-20211125142907321

数据格式

image-20211104230312430

大多数 GCC 生成的汇编代码指令都有一个宇符的后缀,表明操作数的大小,例如,数据传送指令有四个变种:movb(传送字节)、movw(传送宇)、movI(传送双字)和movq(传送四字)。后级‘l’用来表示双字,因为32位数被看成是“长字(1ong word)”。注意,汇编代码也使用后级‘l’来表示 4 字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。

访问信息

寄存器的名字都是以%r开头,不过后面还跟着一些不同的命名规则的名字。

image-20211104230347242

操作数格式

image-20211104230416998

image-20211015222330674

数据传送指令

image-20211104230455303

源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,要么是一个奇存器,要么是一个内存地址。x86-64 加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从一个内存位置复制到另一个内存位置需要两条指令——第一条指令将源值加载到寄存器中,第二条将该奇存器值写入目的位置。这些指令的寄存器操作数可以是 16个奇存器有标号部分中的任意一个,寄存器部分的大小必须与指令最后一个字符(b、w、l、q)指定的大小匹配。大多数情况中,MOV 指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movI 指令以寄存器作为目的时,它会把该寄存器的高位4字节设置为0。造成这个例外的原因是 x86-64 采用的惯例,即任何为奇存器生成 32 位值的指令都会把该寄存器的高位部分置成 0。

image-20211104230517920

补充:当寄存器的名称放在括号中时,使用这个寄存器,不管里面存的是什么,将寄存器中的值当作地址去访问

图 3-4 中记录的最后一条指令是处理64位立即数数据的。常规的 mova 指令只能以表示为 32 位补码数字的立即数作为源操作数,然后把这个值符号扩展得到 64位的值,放到目的位置。movabsq 指令能够以任意 64 位立即数值作为源操作数,并且只能以寄存器作为目的。

图3-5和图3-6记录的是两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在奇存器或内存中)复制到目的寄存器。MOVZ 类中的指令把目的中剩余的字节填充为 0,而MOVS 类中的指令通过符号扩展来填充,把源操作的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个字符指定源的大小,而第二个指明目的的大小。

image-20211104230543401

movzbl 指令不仅会把 %eax 的高3个字节清零,还会把整个寄存器 %rax 的高4个字节都清零。

补充:x86-64中的内存引用总是用四字长寄存器给出,例如%rax,哪怕操作数只是一个字节、一个字或是一个双字

movb $0xF,(%ebx) 错误:Cannot use %ebx as address register

补充:何时使用零扩展,何时使用符号扩展: 当窄数据类型为:有符号数据类型时,扩展为宽数据类型时,使用符号扩展。 当窄数据类型为:无符号数据类型时,扩展为宽数据类型时,使用零扩展。

image-20211015221825774 image-20211015222101234 image-20211015222224916 image-20211015222235611 image-20211015222243835 image-20211015222251056

压入和弹出栈数据(仅适用于8字节的)

image-20211104230628170

将四字压入栈

1
2
subq $8,%rsp  #Decrement stack pointer
movq %rbp, (%rsp)  #Store %rbp on stack

将四字弹出栈

1
2
movq (%rsp), %rax  #Read %rax from stack
addq $8,%rsp  #Increment stack pointer

算数和逻辑操作

image-20211104230649538

加载有效地址(允许的伸缩因子1、2、4和8覆盖了所有基本简单数据类型的大小)

加载有效地址(load effective address)指令 leaq 实际上是 mova 指令的变形。它的指令形式是从内存读数据到寄存器,但实际上它根本就没有引用内存。它的第一个操作数看上去是一个内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。在图3-10 中我们用 C语言的地址操作符 &s 说明这种计算。这条指令可以为后面的内存引用产生指针。另外,它还可以简洁地描述普通的算术操作。例如,如果寄存器 %rdx 的值为 x,那么指令 leaq 7(%rdx,%rdx,4),%rax 将设置寄存器 %rax 的值为 5x+7。编译器经常发现 leaq 的一些灵活用法,根本就与有效地址计算无关。目的操作数必须是一个寄存器。

image-20211015205237211

一元和二元操作

二元操作的第二个操作数既是源又是目的

注意:当第二个操作数为内存地址时,处理器必领从内存读出值,执行操作,再把结果写回内存。

移位操作

移位操作,先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器 %cl 中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数。)原则上来说,1个字节的移位量使得移位量的编码范围可以达到 2^8^-1=255。 x86-64 中,移位操作对w位长的数据值进行操作,移位量是由 %cl 奇存器的低m位决定的,这里2^m^=w。高位会被忽略。所以,例如当奇存器 %cl 的十六进制值为 OxFF 时,指令 salb 会移7位,salw 会移15位,sall 会移31位,而 salq 会移63位。

特殊的算数操作

image-20211104230845340

乘法指令:

x86-64 指令集提供了两条不同的 “单操作数” 乘法指令,以计算两个64位值的全128 位乘积——一个是无符号数乘法(mulq),而另一个是补码乘法(imulq)。这两条指令都要求一个参数必须在奇存器%rax中,而另一个作为指令的源操作数给出。然后乘积存放在奇存器%rdx(高 64 位)和%rax(低 64 位)中。虽然 imulq 这个名字可以用于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辨出想用哪条指令。

image-20211104231118512

除法指令:

前面的算术运算表(图3-10)没有列出除法或取模操作。这些操作是由单操作数除法指令来提供的,类似于单操作数乘法指令。有符号除法指令idivq 将寄存器 %rdx(高 64位)和%rax(低 64 位)中的 128 位数作为被除数,而除数作为指令的操作数给出。指令将商存储在奇存器 %rax 中,将余数存储在寄存器 %rdx 中。

对于大多数 64 位除法应用来说,被除数也常常是一个64位的值。这个值应该存放在%rax中,%rdx 的位应该设置为全0(无符号运算)或者%rax 的符号位(有符号运算)。后面这个操作可以用指令 cqto来完成。这条指令不需要操作数——它隐含读出 %rax 的符号位,并将它复制到 %rdx 的所有位。

image-20211104231141755

image-20211104231151403

控制

条件码

除了整数奇存器,CPU 还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些奇存器来执行条件分支指令。最常用的条件码有:

CF(carry flag):进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。

ZF(zero flag):零标志。最近的操作得出的结果为 0。

SF(sign flag):符号称志,最近的操作得到的结果为负数。(运算结果最高有效位为1,说明结果是负数,SF会被置为1)

OF(overflow flag):溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。

leaq指令不改变任何条件码,因为它是用来进行地址计算的。

CMP指令和TEST指令只设置条件码而不改变任何其他奇存器。(想要的结果最终在%rax中)

CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP 指令与 SUB 指令的行为是一样的。

TEST指令的行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。典型的用法是,两个操作数是一样的(例如,testq %rax,%rax 用来检查 %rax 是负数、零,还是正数),或其中的一个操作数是一个掩码,用来指示哪些位应该被测试。 (想要的结果最终在%rax中)

image-20211104231214479

访问条件码

一条 SET指令的目的操作数是低位单字节寄存器元素(图 3-2)之一,或是一个字节的内存位置,指令会将这个字节设置成0或者1。为了得到一个 32 位或 64 位结果,我们必须对高位清零。

image-20211104231301954

跳转指令

图 3-15 列举了不同的跳转指令。jmp 指令是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如标号 “.L1”。间接跳转的写法是’*‘ ,后面跟一个操作数指示符,举个例子,指令

jmp *%rax

用寄存器%rax中的值作为跳转目标,而指令

jmp *(%rax)

以%rax 中的值作为读地址,从内存中读出跳转目标

image-20211104231348001

跳转指令的编码

跳转指令有几种不同的编码,但是最常用都是PC相对的(PC-relative)。也就是,它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为1、2或4个字节。第二种编码方法是出“绝对”地址,用4个字节直接指定目标。汇编器和链接器会选择适当的跳转目的编码。

用条件控制来实现条件分支

image-20211104231926287

image-20211019103913698

用条件传送来实现条件分支

image-20211104231819599 image-20211104231724271

image-20211104232032562

循环

1.do-while
image-20211104232105463
2.while

**①跳转到中间(jump to middle):**它执行了一个无条件跳转跳转到循环结尾处的测试,以此来执行初始的测试。

image-20211104232134834

image-20211104232159882

**②guarded-do:**首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while

image-20211104232223343

3.for循环

image-20211104232244150

switch语句

switch(开关)语句可以根据一个整数索引值进行多重分支(multiway branching)。在处理具有多种可能结果的测试时,这种语句特别有用。它们不仅提高了 C 代码的可读性,而且通过使用跳转表(jump table)这种数据结构使得实现更加高效。跳转表是一个数组, 表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。和使用一组很长的 if-else 语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC 根据开关情况的数量和开关情况值的稀疏程度来翻译开关语句。当开关情况数量比较多(例如4个以上),并且值的范围跨度比较小时,就会使用跳转表。

image-20211019112335735

&&:创建一个指向代码位置的指针。

image-20211104232330056

image-20211104232340370

image-20211104232706674

过程

mov和lea的区别

lea是“load effective address”的缩写,简单的说,lea指令可以用来将一个内存地址直接赋给目的操作数,例如: lea [ebx+8],eax 就是将ebx+8这个值直接赋给eax,而不是把ebx+8处的内存地址里的数据赋给eax。

而mov指令则恰恰相反,例如: mov [ebx+8],eax 则是把内存地址为ebx+8处的数据赋给eax。

栈帧

栈上用于特定call的每个内存块称为栈帧

每一次函数的调用,都会在调用栈(call stack)上维护一个独立的栈帧(stack frame)

每个独立的栈帧一般包括:

  • 函数的返回地址和参数

  • 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量

  • 函数调用的上下文

    栈是从高地址向低地址延伸,一个函数的栈帧用 %rbp 和 %rsp 这两个寄存器来划定范围。%rbp 指向当前的栈帧的底部,%rsp 始终指向栈帧的顶部;

    %rbp 寄存器又被称为帧指针(Frame Pointer);

    %rspesp 寄存器又被称为栈指针(Stack Pointer);

image-20211030155445019
ret

执行ret,ret指令将始终采用栈指针指向的地址并将它作为返回地址。所以%rsp在你执行ret之前就恢复到它原来的位置是非常重要的。

1
2
3
4
subq $16, %rsp
...
addq $16, %rsp
ret
在不同的函数里寄存器是共享的,而内存是隔离的

运行时栈

image-20211101112656874

image-20211105104136424

转移控制

将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。在x86-64 机器中,这个信息是用指令 call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。**压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。**对应的指令ret会从栈中弹出地址A,并把PC设置为 A。下表给出的是call和ret指令的一般形式:

image-20211105104156053

数据传送

image-20211105104213611

如果一个函数有大于6个整型参数,超出 6个的部分就要通过栈来传递:把参数 1~6 复制到对应的奇存器,把参数7~n放到栈上,而参数7位于栈顶。

栈上的局部存储

到目前为止我们看到的大多数过程示例都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须在放在内在中,常见的情况包括:

  • 寄存器不足够存放所有的本地数据。

  • 对一个局部变量使用地址运算符‘&’,因此必须能够为它产生一个地址。

  • 某些局部变量是数组或结构,因此必领能够通过数组或结构引用被访问到。

    一般来说,过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为 “局部变量”

寄存器中的局部存储空间

根据惯例,寄存器%rbx、%rbp 和%r12~%r15 被划分为被调用者保存寄存器。当过程 P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压人栈中,改变奇存器的值,然后在返回前从栈中弹出旧值。压人寄存器的值会在栈帧中创建标号为“被保存的寄存器”的一部分,如图 3-25 中所示。有了这条惯例,P的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),调用Q,然后继续使用寄存器中的值,不用担心值被破坏。

数组分配和访问

基本原则

image-20211105105642247

X86-64 的内存引用指令可以用来简化数组访问。例如,假设 E 是一个 int 型的数组,而我们想计算 E[i],在此,E的地址存放在寄存器%rdx 中,而 i 存放在寄存器%rcx 中。然后,指令

1
movl (%rdx,%rcx,4),%eax

会执行地址计算 x~e~ + 4i 读这个内存位置的值,并将结果存放到寄存器 %eax 中。允许的伸缩因子 1、2、4 和 8 覆盖了所有基本简单数据类型的大小。

指针运算

image-20211105110019760

嵌套的数组

image-20211105110153743

这里,L 是数据类型 T 以字节为单位的大小。

image-20211105110301768

正如可以看到的那样,这段代码计算元素的地址为 x~A~ + 12i + 4j = x~A~ + 4(3i +j),使用了X86-64地址运算的伸缩和加法特性。

定长数组

image-20211105110629686

image-20211105110527377

image-20211105110541773

变长数组

image-20211105110805423

image-20211105110820190

image-20211105110836848

image-20211105110903401

image-20211105110920237

异质的数据结构

结构

image-20211105111217803

image-20211105111233423

联合

不怎么用

数据对齐

许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值 K(通常是 2、4 或 8)的倍数。

image-20211105111500778

image-20211103151633257

image-20211105111650221

image-20211105111700625

image-20211105111717325

如果一个结构体的成员按照不同顺序声明,可能会得到不同大小的alignment(为了内存对齐)

如果只是把最大的东西放在开头,再依次放更小的元素,能够最大限度地减少任何浪费的空间

对于结构体来说:

每个结构都有对齐要求 K (K=任何元素的最大对齐)

初始地址和结构长度必须是 K 的倍数

(结构体中的)双精度数,它应该位于一个边界上,这样浮点数的起始地址是8的倍数

内存、缓冲区

内存越界引用和缓冲区溢出

内存结构
image-20211103213517518

00007FFFFFFFFFFF = 111 1111( * 11) = 2^47^ = 128TB

image-20211103213629248 image-20211103213640727
缓冲区溢出

缓冲区是一块连续的计算机内存区域,可保存相同数据类型的多个实例。缓冲区可以是堆栈(自动变量)、堆(动态内存)和静态数据区(全局或静态)。在C/C++语言中,通常使用字符数组和malloc/new之类内存分配函数实现缓冲区。溢出指数据被添加到分配给该缓冲区的内存块之外。缓冲区溢出是最常见的程序缺陷。

栈帧结构的引入为高级语言中实现函数或过程调用提供直接的硬件支持,但由于将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来隐患。若将函数返回地址修改为指向一段精心安排的恶意代码,则可达到危害系统安全的目的。此外,堆栈的正确恢复依赖于压栈的EBP值的正确性,但EBP域邻近局部变量,若编程中有意无意地通过局部变量的地址偏移窜改EBP值,则程序的行为将变得非常危险。

由于C/C++语言没有数组越界检查机制,当向局部数组缓冲区里写入的数据超过为其分配的大小时,就会发生缓冲区溢出。攻击者可利用缓冲区溢出来窜改进程运行时栈,从而改变程序正常流向,轻则导致程序崩溃,重则系统特权被窃取。

例如,对于下图的栈结构:

img

若将长度为16字节的字符串赋给acArrBuf数组,则系统会从acArrBuf[0]开始向高地址填充栈空间,导致覆盖EBP值和函数返回地址。若攻击者用一个有意义的地址(否则会出现段错误)覆盖返回地址的内容,函数返回时就会去执行该地址处事先安排好的攻击代码。最常见的手段是通过制造缓冲区溢出使程序运行一个用户shell,再通过shell执行其它命令。若该程序有root或suid执行权限,则攻击者就获得一个有root权限的shell,进而可对系统进行任意操作。

除通过使堆栈缓冲区溢出而更改返回地址外,还可改写局部变量(尤其函数指针)以利用缓冲区溢出缺陷。

注意,本文描述的堆栈缓冲区溢出不同于广义的“堆栈溢出(Stack OverFlow)”,后者除局部数组越界和内存覆盖外,还可能由于调用层次太多(尤其应注意递归函数)或过大的局部变量所导致。

image-20211105112322760

image-20211105112333320

image-20211105112352009

数据读写

数据从内存要写入磁盘中时,数据会被先写入到磁盘缓冲区,磁盘缓冲区满了再把数据写入磁盘。

磁盘缓冲区是为了平滑不同I/O设备的速度差。

是的,磁盘是分区分块存储的。如果是机械硬盘,是分磁道和扇区的。当磁头转到一个扇区的某磁道时,开始读取数据,如果只读取了 100KB 的数据,这时操作系统就想,磁头转到这儿看不容易啊,反正来都来了,顺带多读点数据吧,万一用的着呢。

所以,读取数据的时候也是通过缓冲区的。

题外话:如果应用的数据存放在不同的磁道,不同的扇区,那么读取的效率是很低的,这被称为磁盘碎片,所以 windows 有个操作叫“整理磁盘碎片”。

对抗缓冲区溢出攻击

1.栈随机化

2.栈破坏检测

image-20211105113030543

3.限制可执行代码区域

许多系统允许控制三种访问形式:读(从内存读数据),写(存储数据到内存)和执行(将内存的内容看作机器级代码)。以前,x86 体系结构将读和执行访问控制合并成一个1位的标志,这样任何被标记为可读的页也都是可执行的。栈必须是既可读又可写的,因而栈上的字节也都是可执行的。已经实现的很多机制,能够限制一些页是可读但是不可执行的,然而这些机制通常会带来严重的性能损失。

最近,AMD 为它的 64 位处理器的内存保护引入了”NX“(No-Execute,不执行)位,将读和执行访问模式分开,Intel 也跟进了。有了这个特性,栈可以被标记为可读和可写,但是不可执行,而检査页是否可执行由硬件来完成,效率上没有损失。

面向返回的编程攻击

image-20211104100517514 image-20211104100527269 image-20211104100534826 image-20211104100540521

浮点代码

image-20211105113304392

image-20211105113325312

浮点传送和转换操作

image-20211105113455334

image-20211105113533219

image-20211105113556497

image-20211105113703428

image-20211105113735601

注:(v)cvttss2si : Convert with Truncation Scalar Single-Precision Floating-Point Value to Integer

image-20211105113808708

浮点运算操作

image-20211105153036238

image-20211105153427412

image-20211105153449405

在浮点代码中使用位级操作

image-20211105153519030

浮点比较操作

image-20211105153741106

image-20211105153754310

image-20211105153809638

image-20211105153820538

image-20211105153836589