2024年4月

论文直接将纯Trasnformer应用于图像识别,是Trasnformer在图像领域正式挑战CNN的开山之作。这种简单的可扩展结构在与大型数据集的预训练相结合时,效果出奇的好。在许多图像分类数据集上都符合或超过了SOTA,同时预训练的成本也相对较低

来源:晓飞的算法工程笔记 公众号

论文: An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale

Introduction


基于自注意力的架构,尤其是Transformers,已成为NLP任务的首选模型。通常的用法是先在大型文本语料库上进行预训练,然后在较小的特定任务数据集上fine-tuning。得益于Transformers的计算效率和可扩展性,训练超过100B参数的模型成为了可能。而且随着模型和数据集的继续增长,模型仍然没有性能饱和的迹象。
在计算机视觉中,卷积网络仍然占主导地位。受NLP的启发,多项工作尝试将CNN的结构与self-attention进行结合(比如
DETR:Facebook提出基于Transformer的目标检测新范式 | ECCV 2020 Oral
),其中一些则尝试完全替换卷积(比如
实战级Stand-Alone Self-Attention in CV,快加入到你的trick包吧 | NeurIPS 2019
)。 完全替换卷积的模型虽然理论上有效,但由于使用了特殊的注意力结构,尚未能在现代硬件加速器上有效地使用。因此,在大规模图像识别中,经典的ResNet类型仍然是最主流的。
为此,论文打算不绕弯子,直接将标准Transformer应用于图像。先将图像拆分为图像块,块等同于NLP中的token,然后将图像块映射为embedding序列作为Transformer的输入,最后以有监督的方式训练模型进行图像分类。
但论文通过实验发现,不加强正则化策略在ImageNet等中型数据集上进行训练时,这些模型的准确率比同等大小的ResNet低几个百分点。这个结果是意料之中的,因为Transformers缺乏CNN固有的归纳偏置能力,例如平移不变性和局部性。在数据量不足的情况下,训练难以很好地泛化。但如果模型在更大的数据集(14M-300M图像)上训练时,情况则发生了反转,大规模训练要好于归纳偏置。为此,论文将在规模足够的数据集上预训练的Vision Transformer(ViT)迁移到数据较少的任务,得到很不错的结果。
在公开的ImageNet-21k数据集或内部的JFT-300M数据集上进行预训练后,ViT在多个图像识别任务上接近或超过了SOTA。其中,最好的模型在ImageNet上达到88.55%,在ImageNet-ReaL上达到90.72%,在CIFAR-100上达到94.55%,在包含19个视觉任务的VTAB标准上达到77.63%。

Method


在模型设计中,论文尽可能地遵循原生的Transformer结构。这样做的好处在于原生的Transformer结构已经被高效地实现,可以开箱即用的。

Vision Transformer(ViT)

模型的整体结构如图1所示,计算流程如公式1-4所示,主要有以下几个要点:

  1. 输入处理:标准Transformer接收一维embedding序列作为输入,为了处理二维图像,先将图像
    \(x\in R^{H\times W\times C}\)
    重排为二维块序列
    \(x_p\in R^{N\times (P^2\times C)}\)
    ,其中
    \((H, W)\)
    为原图像的分辨率,
    \(C\)
    是通道数,
    \((P, P)\)
    是每个图像块的分辨率,
    \(N=HW/P^2\)
    是生成的块数量,也是Transformer的有效输入序列长度。Transformer所有层使用向量的维度均为
    \(D\)
    ,需要先使用可训练的公式1将二维图像块线性映射到
    \(D\)
    维,映射的输出称为图像块embedding。
  2. class token:类似于BERT在输入序列开头插入[class]token,论文同样在图像块embedding序列中预先添加一个可学习的class token(
    \(z^0_0=x_{class}\)
    ),并将其在Transformer encoder中的对应输出(
    \(z^0_L\)
    )经公式4转换为图像特征
    \(y\)
    。在预训练和fine-tuning期间,分类head都接到
    \(z^0_L\)
    上。分类head在预训练时由仅有单隐藏层的MLP实现,而在fine-tuning时由单线性层实现。
  3. position embedding:添加position embedding到图像块embedding中可以增加位置信息,用合并的embedding序列用作encoder的输入。论文使用标准的可学习1D position embedding,使用更复杂的2D-aware position embedding并没有带来的显着性能提升。
  4. Transformer encoder:Transformer encoder是主要的特征提取模块,由multiheaded self-attention模块和MLP模块组成,每个模块前面都添加Layernorm(LN)层以及应用残差连接。MLP包含两个具有GELU非线性激活的全连接层,这是point-wise的,不是对整个token输出。self-attention的介绍可以看看附录A或公众号的
    实战级Stand-Alone Self-Attention in CV,快加入到你的trick包吧 | NeurIPS 2019
    )文章。

Inductive bias

论文注意到,在Vision Transformer中,图像特定的归纳偏置比CNN要少得多。在CNN中,局部特性、二维邻域结构信息(位置信息)和平移不变性贯彻了模型的每一层。而在ViT中,自注意力层是全局的,只有MLP层是局部和平移不变的。
ViT使用的二维邻域结构信息非常少,只有在模型开头将图像切割成图像块序列时以及在fine-tuning时根据图像的分辨率调整对应的position embedding有涉及。此外,初始的position embedding仅有图像块的一维顺序信息,不包含二维空间信息,所有图像块间的空间关系必须从头开始学习。

Hybrid Architecture

作为图像块的替代方案,输入序列可以由CNN的特征图映射产生,构成混合模型中。将公式1中映射得到图像块embedding
\(E\)
替换为从CNN提取的特征图中映射得到的特征块embedding,然后跟前面一样添加插入[class] token和position embedding进行后续计算。
有一种特殊情况,特征块为
\(1\times 1\)
的空间大小。这意味着输入embedding序列通过简单地将特征图按空间维度展开,然后映射到Transformer维度得到。

Fine-Tuning and Higher Resolution

通常,ViT需要先在大型数据集上预训练,然后在(较小的)下游任务fine-tuning。为此,在fine-tuning时需要将预训练的预测头替换为零初始化的
\(D\times K\)
前向层,
\(K\)
为下游任务的类数量。
根据已有的研究,fine-tuning时使用比预训练高的分辨率通常可以有更好的效果。但使用更高分辨率的图像时,如果保持图像块大小相同,产生的embedding序列会更长。虽然Vision Transformer可以处理任意长度的序列,但预训练得到的position embedding将会失去意义。因此,论文提出根据原始图像中的位置对预训练的position embedding进行2D插值,然后进行fine-tuning训练。
值得注意的是,这种分辨率相关的调整以及模型开头的图像块的提取是Vision Transformer中少有的手动引入图像二维结构相关的归纳偏置的点。

Experiment


论文设计了三种不同大小的ViT,结构参数如上。

分类性能对比,不同模型、不同预训练数据集在不同分类训练集上的表现。

将VTAB任务拆分与SOTA模型进行对比,其中VIVI是在ImageNet和Youtube数据集上训练的ResNet类模型。

预训练数据集与迁移数据集上的性能关系对比,预训练数据集小更适合使用ResNet类模型。

预训练数据集与few-shot性能对比,直接取输出特征进行逻辑回归。

预训练消耗与迁移后性能的对比。

可视化ViT:

  • 公式1的前28个线性映射参数的权值主成分分析,主成分差异代表提取的特征较丰富。
  • position embedding之间的相关性,约近的一般相关性越高。
  • 每层的self-attention中每个head的平均注意力距离(类似于卷积的感受域大小),越靠前的关注的距离更远,往后则越近。

Conclusion


论文直接将纯Trasnformer应用于图像识别,是Trasnformer在图像领域正式挑战CNN的开山之作。这种简单的可扩展结构在与大型数据集的预训练相结合时,效果出奇的好。在许多图像分类数据集上都符合或超过了SOTA,同时预训练的成本也相对较低。



如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

大家好,我是呼噜噜,在上一篇文章
聊聊x86计算机启动发生的事?
我们了解了x86计算机启动过程,MBR、0x7c00是什么?其中当bios引导结束后,操作系统接过计算机的控制权后,发生了哪些事?本文将揭开迷雾的序章-
Bootsect.S

回顾计算机启动过程

我们先来回顾一下,上古时期计算机按下电源键的启动过程,这里以8086架构为例:

8086、80x86是什么意思?

有许多人不知道 经常遇到的8086、80x86是什么意思?我们简单科普一下:

  1. 8086是Intel公司推出的最早,也是最流行的面向个人电脑的CPU型号
  2. x86泛指一系列基于Intel 8086且向后兼容的中央处理器指令集架构,由于以“86”作为结尾,因此其架构被称为"x86"
  3. 80x86也就是在8086基础上的增强版,包括80286,80386,80486,其后面就是我们所熟悉的奔腾、酷睿、i5、i7等等

寄存器初始化CS:IP

相比于上一篇文章
聊聊x86计算机启动发生的事
,我们这里再讲细致点,当计算机一按下电源后,8086CPU就处于
实模式
的状态,此时会将CPU的寄存器初始化为
CS=0xFFFF;IP=0x0000
,也就是实际物理地址
0xFFFF0
(
CS左移4位+IP
)

CS : 代码段寄存器;IP : 指令指针寄存器。
CS:IP指向的内容 会被CPU当做计算机指令去执行

那么从地址
0xFFFF0
中取出来的指令是什么?我们知道当电路通电后,内存是一片空白的,内存断电后 数据是无法保存的,所以BIOS程序需要事先被刷入
只读存储器ROM
中。物理地址
0xFFFF0
就是指向这样一段BIOS ROM

CPU是如何和ROM相连的?

那么问题又来了,CPU是如何和ROM相连的?CPU 不仅和ROM相连,还和RAM(俗称内存),IO接口等设备相连,他们是通过
总线
相连。还好当时笔者将计算机组成原理好好复习了一遍,不然这部分真挺难理解的。

总线
是贯穿整个系统的是一组电子管道,是连接各个部件的信息传输线,是各个部件共享的传输介质,称作总线,它
携带信息字节并负责在各个计算机部件间传递

总线按系统总线传输信息内容的不同,又可以分为3 种:
数据总线、地址总线和控制总线
。我们这里用到的就是地址总线,把 0xFFFF0 作为 CPU 的地址总线信号传输出去,去这个地址总线对应的位置处找

由于计算机有多个设备,必然会存在多个设备
同时竞争总线控制权
的问题,这时候就需要
总线仲裁,
让某个设备优先获得总线控制权,获得了总线控制权的设备,才能开始传送数据。未获胜的设备只能等待获胜的设备处理完成后才能执行。

我们简单总结一下:当总线仲裁器仲裁通过后,CPU可以依靠地址总线寻址,找到对应设备ROM上地址
0xFFFF0
处的内容。

拓展可见:
什么是计算机中的高速公路-总线?

加载MBR到内存中

当BIOS自检完成,设置启动顺序后,利用 BIOS 的输入功能将启动磁盘的
启动扇区MBR
(也叫第一扇区,主引导记录)的内容原封不动地搬到内存的
0x7C00
地址处,并设置CPU寄存器
CS=0x07C0,IP=0x0000
。到这一步,计算机的控制权将交到操作系统手中!

为什么是0x7C00这个地址?如何得出?别再问了,本文不再解释了,具体看笔者的上一篇文章
聊聊x86计算机启动发生的事

对于Linux0.12来说,第一个程序
Bootsect.S
编译成二进制后,需要事先放到
主引导记录MBR
中,MBR大小就是一个扇区的大小512字节,如果这512字节的最后两个字节是
0x55AA
,表明这个设备可以用于启动。只有这样我们BIOS才能识别它,才能把bootsect.S加载到内存中。

如果不是0x55和0xAA,表明设备不能用于启动,控制权于是被转交给"启动顺序"中的下一个设备。如果到最后还是没找到符合条件的,直接报出一个无启动区的error。

下面我们看下操作系统编译后,存放在储存设备(硬盘)的模块分布:


先简单介绍一下,不必深究,后续文章会娓娓道来:

  1. bootsect.s的主要作用就是加载操作系统,把操作系统从硬盘中,加载到内存里去
  2. setup.s的主要作用:首先获得光标,内存,显卡,磁盘等硬件参数存放在内存空间中,方便后续程序使用;临时建立gdt、idt表,并且从实模式进入到了保护模式
  3. 在linux0.12源码,boot目录下还有一个head.s,在上图中被
    归于system模块
    ,属于操作系统主体文件,主要是进行进入保护模式之后的初始化工作
  4. system模块:就是操作系统的主体,比如文件系统,IO,进程等模块。 Linux0.12 内核 system 模块大约占随后的 260 个扇区。

更多精彩文章在公众号「
小牛呼噜噜

bootsect.S具体干了什么?

bootsect的主要作用就是加载操作系统,把操作系统从硬盘中,加载到内存里去,我们下面结合bootsect.s的源码一起来看看bootsect.S具体干了什么?

呼噜噜这里整个过程先汇成了图,大家配合图去阅读下文,对照起来,更容易理解

设置段基址 & 内存分段机制

要想bootsect启动,需要让BIOS将bootsect.s 从硬盘的MBR中搬到 内存位置
0x7c00
处,大小512个字节。当bootsect被BIOS加载到内存后,计算机的控制权就到操作系统bootsect的手上了。

entry start        ! 告知链接程序,程序入口是从start 标号开始执行的
start:
	mov	ax,#BOOTSEG  !BOOTSEG=0x7c0 , 将 ds 段寄存器置为 0x7C0
	mov	ds,ax        !再将 ax 段寄存器里的值复制到 ds 段寄存器里
	mov	ax,#INITSEG  !SETUPSEG=0x9000,将 es 段寄存器置为 0x9000
	mov	es,ax        !再将 ax 段寄存器里的值复制到 es 段寄存器里

	mov	cx,#256
	sub	si,si
	sub	di,di
	rep
	movw     
	jmpi	go,INITSEG

我们可以看到CPU实际执行第一句的代码
mov ax,#BOOTSEG !BOOTSEG=0x7c0
,这是汇编写的,其实这里的
0x7c0
对应的就是我们上文的地址
0x7C00

0x7c0
是段地址,
0x7C00
是其实际的物理地址,
0x7c0
左移四位就是
0x7c00
,这就是
内存寻址-分段机制

那么大家一定会有疑问内存为什么分段?

计算机内存究竟是什么?其实它就像数组一样,咦有人不懂数组是什么,那么我们可以再头脑风暴一下,内存其实就像
纸带
一样,我们来看下上古时期的计算机:

穿孔纸带,图片来源于网络

纸带上有一个个孔,这样大家可能还看不明白,我们再来看一张图:

这些孔排列组合其实就是二进制数,纸带其实就是储存数据的介质,那么
内存
就是足够长的“纸带”

在现代计算机中,内存它使用的是
DRAM
芯片,也叫
动态随机存取存储器
,即只需给出地址,就能直接访问指定地址的数据,这一点特别像数组,所以许多材料都是用数组来画内存图

那么CPU访问内存明明可以直接通过地址访问内存,为什么还要分段?其实这又是一个历史因素导致的,让我们回到"分段"首次出现的时候:"分段"是从Intel 8086芯片开始的,8086又是你......

由于8086那个时代CPU、内存都很昂贵, CPU 和寄存器等宽度都是 16 位的,其可寻址2的16次方字节,也就是64kb,然而8086有20根地址线,可寻址的最大内存空间是1MB。CPU和寄存器的寻址能力远远不能满足使用,于是机智的祖师爷们,采用了
分段技术

分段
,为解决这个问题,8086引入段寄存器,如
CS、DS、ES、SS
。通过
段基址+段内偏移地址
的方式生成20位的地址,扩大寻址能力,从而实现对1MB内存空间的寻址。由于这样程序中指令了只用到16位地址,缩短了指令长度,也变相地提高了程序执行速度。

  • CS:代码段寄存器,存放代码段的段基址
  • DS是数据段寄存器,存放数据段的段基址
  • ES是扩展段寄存器,存放当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段
  • SS是堆栈段寄存器,存放堆栈段的段基址
  • 80836还新增2个寄存器,FS标志段寄存器、GS全局段寄存器。

使用段地址还有一个好处是 程序可以
重定位
,那个时候的计算机可
没有虚拟地址
之说,
只有物理地址

访问任何存储单元都直接给出物理地址
。这就带来一个问题: 如果此时计算机多道程序并发运行,程序中的地址都是实际物理地址,这些程序编译出来的程序运行地址是相同的,计算机只能运行一个程序。

重定向
: 将程序中指令的地址改成另一个地址,但该地址处的内容还是原内存地址处的内容。这样程序指令虽然还是物理地址,但程序能够并发运行了。

1982年处理器
80286
,首次提出
保护模式
概念,为了保持兼容性,所以同样支持内存分段管理,将8086这种称为
实模式
,最大的区别是
物理内存地址不能直接被程序访问
,这块非常重要,篇幅也较长,笔者先挖坑,后续系列文章再单独出一篇。

咳咳,拓展的有点多了,赶紧让我们回到bootsect源码处

mov ds,ax
这句话代码的意思就是:将 ax 段寄存器里的值复制到 ds 段寄存器里。ds在上文我们提到,8086特地为采用内存分段机制,引入的段寄存器。ds具体表示
数据段寄存器

存放数据段的段基址

换句话说,就是将段基址设为
0x07c0
,那么后续
数据段程序
中只需写
段内偏移地址
,就能访问实际物理地址了。比如后续程序中出现
mov ax,0x01

0x01
其实是
[ds:0x01]
,那么
ax的实际物理地址= 0x07c0 <<4 + 0x01
。将ds寄存器段基址设置好后,其实就是方便之后程序访问内存,访问的数据的内存地址都先默认加上
0x7c00
,然后再去内存中寻址。

如果实际编程时,代码段的起始地址一般放到 CS寄存器,虽然CPU没有强制规定代码段、数据段等分离。

mov ax,#INITSEG

mov es,ax
将 ax 段寄存器里的值
0x9000
复制到 es 段寄存器里,和ds赋值同理,不再赘述。需要注意的是
8086无法直接给段寄存器进行赋值,需要使用通用寄存器来当中介
(一般使用ax)

bootsect的"再次搬家"到0x90000

接着bootsect自己把自己从内存位置
0x7c00
处,搬到
0x90000
处,这次可没BIOS帮忙了,得自食其力

          
start:
	mov	ax,#BOOTSEG  
	mov	ds,ax        
	mov	ax,#INITSEG  
	mov	es,ax        

	mov	cx,#256       ! 设置移动计数值=256 字(512 字节);
	sub	si,si         ! si寄存器 清零
	sub	di,di         ! di寄存器 清零
	rep               ! 重复执行并递减 cx 的值,直到 cx = 0 为止。
	movw              ! 即 movs 指令。从内存[si]处移动 cx 个字到[di]处。//一次移动两个字节,256B*2=512B

mov cx,#256
将cx 寄存器的值赋值为 256,单位是字(Word), 1 word=2Byte

sub si,si
是si寄存器 清零操作,
sub
是汇编语言中的一种运算指令,它用来执行减法运算,并将结果存储到被减数(前者)上去。比如
sub a,b
就是
a = a-b
。再结合前面的ds,es,那么此时si的段地址
ds:si = 0x07C0:0x0000
,同理di的段地址
es:di = 0x9000:0x0000

rep
就是重复执行后一条指令,
movw
就是复制的意思。
rep movw
就是重复多次搬运

我们可以知道这段的总体意思就是:循环256次,反复将段地址
0x07C0:0x0000
的内容一个字一个字的复制到
段地址0x9000:0x0000
处,直到寄存器cx为0。这样就实现了bootsect的"自我搬运",把
实际物理内存地址0x7c00处
512个字节的内容全部复制到
实际物理内存地址0x90000处

那为啥bootsect还要"多此一举" 将自己从
0x7c00
,搬到
0x90000
处?

  • 操作系统system后续最终是要从物理内存起始位置处
    地址0
    开始存放,好处是让system代码中的地址对应上实际的物理地址。
  • 一般要留
    512KB
    的内存空间放操作系统system,
    会覆盖0x7c00地址的内容
    ,所以需要把bootsect代码搬到内存更高处。

加载setup.s到内存0x90200

当上面bootsect完成自我搬运后,紧接着执行
jmpi go,INITSEG
,jmpi有段间跳转的作用。这里 INITSEG 指出跳转到的
段地址0x9000
,标号 go 是段内偏移地址。

其实就是执行完
jmpi go,INITSEG
后,CPU已经移动到内存
0x90000+go
位置处的代码中 执行。为啥要加go?其实此时bootsect编译后的二进制内容,已经搬运到内存
0x90000
处,但是我们不能再从头执行
start: mov ax,#BOOTSEG
操作,而是从
go: mov ax,cs
处代码继续执行下去。

	jmpi	go,INITSEG  ! 段间跳转。这里 INITSEG 指出跳转到的段地址,标号 go 是段内偏移地址。

go:	mov	ax,cs		
	mov	dx,#0xfef4	! arbitrary value >>512 - disk parm size

	mov	ds,ax
	mov	es,ax
	push	ax        ! 临时保存段值(0x9000)

	mov	ss,ax		    ! put stack at 0x9ff00 - 12.
	mov	sp,dx

	push	#0        ! 置段寄存器 fs = 0。
	pop	fs          ! fs:bx 指向存有软驱参数表地址处(指针的指针)
	mov	bx,#0x78		! fs:bx is parameter table address
	seg fs
	lgs	si,(bx)			! gs:si is source

	mov	di,dx			! es:di is destination
	mov	cx,#6			! copy 12 bytes
	cld

	rep           ! 复制 12 字节的软驱参数表到 0x9000:0xfef4 处。
	seg gs
	movw

	mov	di,dx
	movb	4(di),*18		! patch sector count

	seg fs         ! 让中断向量 0x1E 的值指向新表。
	mov	(bx),di
	seg fs
	mov	2(bx),es

	pop	ax
	mov	fs,ax
	mov	gs,ax
	
	xor	ah,ah			! reset FDC 让中断向量 0x1E 的值指向新表。
	xor	dl,dl
	int 	0x13	

上述主要是将 寄存器DS、ES 和SS 重新设置为CPU移动后,代码所在的段处
0×9000
,设置SP栈寄存器
0xfef4
栈指针要远大于512字节偏移(即 0x90200 )处都可以,一般setup程序大概占用4个扇区,这样栈顶段地址
ss:sp
和现有的代码足够远 ,防止后续栈操作覆盖掉已有的代码。

还有BIOS 设置的中断 0x1e 的中断向量值等操作。这边和主干操作不太相干,简略过一下,主要就是把这些寄存器重新设置好值,方便后续使用。

更多精彩文章在公众号「
小牛呼噜噜

接下来紧接着将setup.s 加载到内存
0x90200

load_setup:
	xor	dx, dx			         ! 驱动器drive 0, 磁头head 0
	mov	cx,#0x0002		    	 ! 扇区sector 2, 磁道号track 0,从第二个扇区开始读
	mov	bx,#0x0200					 ! 偏移address = 512, in INITSEG ,表示读到0x90200
	mov	ax,#0x0200+SETUPLEN	 ! service 2, nr of sectors ,SETUPLEN是 4个扇区
	int	0x13								 ! read it


	jnc	ok_load_setup				 ! ok,就跳到ok_load_setup

	push	ax								 ! dump error code
	call	print_nl           ! 屏幕光标回车
	mov	bp, sp
	call	print_hex          ! 显示十六进制值
	pop	ax	
  	
	xor	dl, dl							 ! reset FDC
	xor	ah, ah
	int	0x13
	j	load_setup             ! j 即 jmp 指令,失败就再跳转到load_setup,重复执行

那怎么简单高效将磁盘里的内容加载到内存中呢?linus这里用的是bios的中断程序,因为此时bios还在内存中,可以为我们所用,
0x13
号中断 在BIOS中是可以访问软盘、IDE、ROM、远程磁盘服务的作用。

这里0x13 和C语言中的函数调用是很像的,不过需要注意的是它的参数只能通过
寄存器去传参
,而C语言函数调用不仅可以寄存器传参,还可以
栈传参
。所以0x13的参数就是其前面的dx,cx,bx,ax寄存器的值,另外磁盘只认磁头磁道扇区,如果给个地址,磁盘是不识别的,磁盘一副不太聪明的样子。

另外
xor
对两个操作数进行逻辑(按位)异或操作,并将结果存放在目标操作数,
xor dx,dx
也是一个置零操作,指定驱动和磁头

那么我们连起来,这段主要是让bios 0x13号中断处理程序 从磁盘的第2扇区开始读,接连读4个扇区的内容到内存
0x90200
处中。成功就跳转到
ok_load_setup
,没成功就回到
load_setup
,重复执行上述操作。

加载system到内存0x10000

当bootsect成功将setup.s搬到内存
0x90200
处后,CPU从
ok_load_setup
处继续执行指令。接下来就是需要将整个操作系统system(head.s+其他文件,大约260个扇区)的内容加载到内存
0x10000
处,下面我们就具体看下代码是如何实现的:

ok_load_setup:

! Get disk drive parameters, specifically nr of sectors/track  
!提示这面段代码功能是:利用BIOSINT 0x13 中断,来来取磁盘的一些参数,比如是取每磁道扇区数,并保存在
位置 sectors 处

	xor	dl,dl
	mov	ah,#0x08		! AH=8 is get drive parameters
	int	0x13
	xor	ch,ch
	seg cs          !表示下一条语句的操作数在 cs 段寄存器所指的段中。它只影响其下一条语句
	mov	sectors,cx
	mov	ax,#INITSEG
	mov	es,ax       !取磁盘参数中断改了es寄存器的值,这里重置es的值


! Print some inane message 提示下面这段功能是:打印一些消息

	mov	ah,#0x03		  ! read cursor pos 读取当前光标的地址
	xor	bh,bh
	int	0x10          ! bios 0x10中断,其作用:在屏幕上显示字符和字符串
	
	mov	cx,#9
	mov	bx,#0x0007		! page 0, attribute 7 (normal)
	mov	bp,#msg1      ! msg1的内容是:  .byte 13,10(换行+回车)  .ascii "Loading"
	mov	ax,#0x1301		! write string, move cursor
	int	0x10


! ok, we've written the message, now
! we want to load the system (at 0x10000)   加载system到内存0x10000

	mov	ax,#SYSSEG
	mov	es,ax		     ! segment of 0x010000
	call	read_it    ! 读磁盘上 system 模块
	call	kill_motor ! 关闭驱动器马达
	call	print_nl   ! 光标回车换行

	... 省略非主干代码...

! after that (everyting loaded), we jump to
! the setup-routine loaded directly after
! the bootblock:

	jmpi	0,SETUPSEG   !bootsect程序到这里就结束了,跳转到0x9020,同时setup获得控制权

这里
int 0x10
号中断,其作用是 在屏幕上显示字符和字符串,由于操作系统比较大,加载需要时间,这时在屏幕上显示提示信息"Loading"

这里将操作系统加载到内存中,是通过子程序
read_it
来实现的,read_it就不具体展开了,比较复杂。我们需要知道由于操作系统比较大,一个磁道是远远放不下的,另外磁盘是不认地址的,在搬运过程中,需要进行磁道、扇区和磁头的计算,特别是一个段的大小是64k,如果放不下,需要更换段地址。如果不更换段地址,会从该段地址0字节开始重新写,这样会覆盖之前的内容。

那为什么一个段的大小是64KB呢?
我们知道在8086CPU中,其内存地址是表示为
段基址+段内偏移地址,其中
偏移地址使用一个16位的二进制数表示,表示范围
0000~FFFF
,所以总共有2^16(2的16次方)=64K个不同的地址,一个内存最小单元是字节Byte,所以一个段大小为64KB

jmpi 0,SETUPSEG
,bootsect程序到这里就结束了,跳转到内存地址
0x90200
,同时setup获得控制权

为了帮助大家理解,呼噜噜这里又把本篇文章全部串起来,大家可以根据下面这张图重新回顾一下bootsect整个工作流程:

额外补充一下:

boot_flag: .word 0xAA55
最后2个字节是
0xAA55
,由于bootsect是采用AT&T汇编,
小端
显示的,实际上就是
0x55AA
与前文MBR那边前后呼应

这也说明了操作系统在开始加载到内存的程序中,得与内存地址一一对应, 不能多一个字节,也不能少一个字节!!!

尾语

本文主要讲解了bootsect.S的主要工作流程,Linux0.12虽然和如今的Linux6.x内核相比显得过于简陋,但麻雀虽小五脏俱全,它是我们打开操作系统大门的钥匙,后面让我们看看setup.s获得计算机的控制权后,会发生什么?

最近实在太忙了,后面随缘更新,留言可催更(bushi)~~


参考资料:

《Linux内核完全注释5.0》
《操作系统真象还原》
https://elixir.bootlin.com/linux/0.12/source/boot/bootsect.S
https://files.embeddedts.com//old/saved-downloads-manuals/EBIOS-UM.PDF


本篇文章到这里就结束啦,如果我的文章对你有所帮助的话,还请点个免费的

,你的支持会激励我输出更高质量的文章,感谢!


作者:小牛呼噜噜 ,首发于公众号
小牛呼噜噜

,系列文章还有:

  1. 聊聊x86计算机启动发生的事?
  2. Linux0.12内核源码解读(2)-Bootsect.S
  3. Linux0.12内核源码解读(3)-Setup.S
  4. 图解CPU的实模式与保护模式
  5. Linux0.12内核源码解读(7)-陷阱门初始化
  6. 图解计算机中断
  7. 什么是系统调用机制?结合Linux0.12源码图解

前面的文章我们都初步学习了Source Generators的使用方式以及做了一些简单的代码生成工具。
但是Source Generators除了做自动代码生成之外,还能有别的应用场景,本文来了解一下Source Generators的应用场景。

介绍

Source Generators(源代码生成器)是.NET生态系统中的一项强大工具,它为开发人员提供了在编译时生成额外代码的能力,从而实现了自动化、性能优化以及更多应用场景。本文将探讨.NET Source Generators 的应用场景,包括自动代码生成、元数据处理、性能优化、DSL 支持、代码分析和跨语言交互。

自动代码生成

Source Generators 可以用于自动生成大量样板代码,如数据访问层、序列化代码和DTO等。通过在编译时生成这些代码,可以减少手动编写重复代码的工作量,提高开发效率,并确保代码的一致性和可维护性。例如,可以生成数据库实体类、数据访问方法以及与数据库表对应的映射代码,从而简化数据访问层的开发过程。
前面的学习文章主要就是使用Source Generators来做自动生成代码。

元数据处理

Source Generators 可以在编译时分析源代码,并生成元数据或附加信息,用于代码的进一步处理或其他用途。例如,可以生成用于路由、依赖注入、序列化等方面的元数据,从而提供更灵活的编程体验。通过在编译时生成这些元数据,可以避免在运行时进行反射或其他开销较大的操作,提高应用程序的性能和可靠性。

性能优化

通过在编译时生成高效的代码,Source Generators 可以提高应用程序的性能。例如,可以生成高效的序列化/反序列化代码,避免在运行时使用反射或动态代码生成带来的性能损失。此外,Source Generators 还可以用于生成优化后的数据访问层代码,从而减少数据库访问的开销,并提高数据访问的效率。

领域特定语言(DSL)支持

Source Generators 可以用于创建领域特定语言(DSL)的支持库。通过在编译时生成相应的代码,可以将 DSL 转换为标准的 .NET 代码,从而实现更高的性能和更好的集成。这使得开发人员可以使用更加灵活和表达力强的 DSL 来描述领域模型,而无需牺牲性能或可维护性。

代码分析和验证

Source Generators 可以用于分析源代码并进行静态分析或验证。例如,可以生成代码来检查代码中的潜在错误或执行代码度量分析。通过在编译时生成这些分析代码,可以及早发现和修复潜在的问题,提高代码的质量和可靠性。

跨语言交互

Source Generators 可以用于生成与其他语言的交互所需的代码。例如,可以生成与非 .NET 语言(如 C、C++、Python 等)交互所需的 P/Invoke 或跨语言桥接代码。这使得.NET应用程序可以与其他平台和语言无缝集成,从而实现更广泛的应用场景和更好的可移植性。

结语

总的来说,.NET Source Generators 提供了一种强大的机制,可以在编译时根据源代码生成额外的代码,从而实现自动化、性能优化、DSL 支持等各种应用场景。开发人员可以利用 Source Generators 提高开发效率、提高应用程序的性能,并实现更加灵活和可维护的代码结构。

补充:感谢评论区提醒,推荐这个仓库学习:
https://github.com/amis92/csharp-source-generators

前言

在Canvas2D中实现圆形的绘制比较简单,只要调用
arc
指令就能在Canvas画布上绘制出一个圆形,类似的,在SVG中我们也只需要一个
<circle>
标签就能在页面上绘制一个圆形。那么在WebGL中我们要怎么去绘制呢?WebGL只能绘制三种形状:点、线段和三角形,它没有提供直接绘制圆形的功能,当然也无法像SVG一样使用标签,所以我们是无法直接绘制圆形曲线的,这个时候我们可以借助相关的数学知识,来实现圆形的绘制。

参数方程

相信数学基础好的小伙伴一定能很快想到,我们可以使用参数方程去获取圆形曲线上的点的坐标,只要我们收集足够多的点,再通过绘制线段的方式将这些点连接起来,就能得到接近圆的图形,从视觉上看就是一个圆形了。其实圆形就是曲线中的一个特例,所以也就是说我们还可以通过参数方程绘制其他常见的曲线,比如圆、椭圆、抛物线、正余弦曲线等等。

以下是圆的参数方程:

\[\begin{cases}
x = x0 + r * cos(θ)\\
y = y0 + r * sin(θ)\\
\end{cases}
\]

在圆的参数方程中,可以使用圆心坐标、半径和夹角的正余弦值来表示横纵坐标的值。

具体实现

按照这个思路,我们就可以编写代码来绘制圆形曲线了。

在正式实现之前,在HTML中准备一个Canvas:

<canvas ref="webglRef" width="256" height="256"></canvas>

在之后的代码中会用到我自己之前简单封装的一个WebGL的类,只是封装了一些繁琐的创建着色器程序的步骤,封装的比较粗糙。下面就开始具体的实现。

  • 首先,定义函数获取圆形曲线的顶点集合。

    const TAU_SEGMENTS = 60;
    const TAU = Math.PI * 2;
    // 获得圆形曲线顶点集合
    function arc(x0, y0, radius, startAng = 0, endAng = Math.PI * 2) {
      const ang = Math.min(TAU, endAng - startAng);
      const ret = ang === TAU ? []: [[x0, y0]];
      const segments = Math.round(TAU_SEGMENTS * ang / TAU);
      for (let i = 0; i <= segments; i ++) {
        const x = x0 + radius * Math.cos(startAng + ang * i / segments);
        const y = y0 + radius * Math.sin(startAng + ang * i / segments);
        ret.push([x, y]);
      }
      return ret;
    }
    

    x
    0
    和y
    0
    是圆心坐标,radius是半径,startAng和endAng表示圆弧的起始角度和结束角度,对于整个圆来说,就是从0到2π,这些参数都比较好理解。

    再来看arc这个函数的内部变量,ang好理解,就是结束角度和起始角度的差值;segments表示要在圆弧上取的点的总数,如果是整个圆就取60个点。

    接着就是遍历,获取segments数量的点的坐标,并存储在ret数组中。

  • 这样,我们就可以调用
    arc
    函数来获取顶点集合了。

    const vertices = arc(0, 0, 0.8);
    

    因为在WebGL中坐标系在视口的坐标范围默认是-1到1,要在视口中看到整个圆,这个圆的半径不能超过1,所以这里半径我取0.8,圆心为
    (0, 0)
    ,然后获取到顶点集合。

  • 创建WebGL程序并绘制。

    WebGL部分的代码就比较简单了,首先是两段GLSL代码,和常见的实现三角形的GLSL代码没什么太大区别:

    const vertex = `
      attribute vec2 position;
    
      void main() {
        gl_PointSize = 1.0;
        gl_Position = vec4(position, 1, 1);
      }
    `;
    const fragment = `
      precision mediump float;
    
      void main() {
        gl_FragColor = vec4(0, 0, 0, 1);
      }
    `;
    

    因为通过参数方程获取到的是连续的点,所以我们可以通过
    gl.LINE_LOOP
    的绘图模式,将所有的点串联起来,这样就得到了一个视觉上的圆形曲线。

    const gl = webglRef.value.getContext('webgl');
    const webgl = new WebGL(gl, vertex, fragment);
    webgl.drawSimple(vertices.flat(), 2, gl.LINE_LOOP);
    

    具体在封装的
    drawSimple
    方法中我调用了
    gl.drawArrays
    来绘制图形。

    gl.drawArrays(gl.LINE_LOOP, 0, points.length / size);
    

实际操作下来能发现,其实绘制圆形曲线还比较简单,所以我们还可以尝试去实现色盘。

色盘是一个实心的圆,就不能通过线条的方式去绘制了,之前在
《利用向量判断多边形边界》
中我们有提到过,对于多边形我们可以把它们看做是由多个三角形组合而成的图形,因此我们可以对多边形进行三角剖分,也就是使用多个三角形的组合来表示一个多边形,把这些三角形都绘制到画布上就组成了多边形,而圆形我们就可以把它看做是一种特殊的多边形。

因为三角剖分算法比较复杂,我们可以直接调用现有的库来完成这个操作,之前使用的是
earcut
这个库,现在我们换一个叫
TESS2
的库,更详细的介绍可以查看它的
github仓库
,下面我们就调用TESS2的API来完成三角剖分操作。

webgl.drawPolygonTess2(vertices);
// ↓↓ 
drawPolygonTess2(points, {
    color,
    rule = WINDING_ODD/*WINDING_NONZERO*/
} = {}) {
    const triangles = tess2Triangulation(points, rule);
    triangles.forEach(t => this.drawTriangle(t, {color}));
}
// ↓↓
function tess2Triangulation(points, rule = WINDING_ODD) {
    const res = tesselate({
        contours: [points.flat()],
        windingRule: rule,
        elementType: POLYGONS,
        polySize: 3,
        vertexSize: 2,
        strict: false
    });
    const triangles = [];
    for (let i = 0; i < res.elements.length; i += 3) {
        const a = res.elements[i];
        const b = res.elements[i + 1];
        const c = res.elements[i + 2];
        triangles.push([
            [res.vertices[a * 2], res.vertices[a * 2 + 1]],
            [res.vertices[b * 2], res.vertices[b * 2 + 1]],
            [res.vertices[c * 2], res.vertices[c * 2 + 1]],
        ])
    }
    return triangles;
}

这样我们就绘制了一个黑色的实心圆。

要实现色盘,我们需要使用HSV或者HSL的颜色表示形式,因为色相Hue的取值范围是0到360度,所以这两种颜色表示形式可以让我们直接把色值和角度关联起来,因此我们可以通过varying变量将坐标信息传递给片元着色器,然后在片元着色器中使用坐标信息计算hsv形式的像素色值。

// vertex
attribute vec2 position;
varying vec2 vP;

void main() {
  gl_PointSize = 1.0;
  gl_Position = vec4(position, 1, 1);
  vP = position;
}

但是WebGL中还无法直接处理HSV的颜色表示形式,所以我们需要使用
hsv2rgb
函数来完成颜色向量的转换,这其中具体的转换算法我也并不是很懂,感兴趣的小伙伴可以自行研究。

// fragment
#define PI 3.1415926535897932384626433832795
precision mediump float;

varying vec2 vP;

// hsv -> rgb
// 参数的取值范围都是 (0, 1)
vec3 hsv2rgb(vec3 c) {
  vec3 rgb = clamp(abs(mod(c.x * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

void main() {
  float x0 = 0.0;
  float y0 = 0.0;
  float h = atan(vP.y - y0, vP.x - x0);
  h = h / (PI * 2.0); // 归一化处理
  vec3 hsv_color = vec3(h, 1.0, 1.0);
  vec3 rgb_color = hsv2rgb(hsv_color);
  gl_FragColor = vec4(rgb_color, 1.0);
}

在上述代码中,我们调用atan函数计算得到以
(0,0)
为圆心的弧度值,再除以

得到一个归一化的值,然后将这个归一化的值通过
hsv2rgb
函数转化RGB颜色向量。

这样我们就使用WebGL实现了一个色盘。如果我们想要颜色的过渡显得更自然,还可以设置使饱和度随着半径增大而增大。

void main() {
  // ...
  float r = sqrt((vP.x - x0) * (vP.x - x0) + (vP.y - y0) * (vP.y - y0)); // 计算半径

  vec3 hsv_color = vec3(h, r * 1.2, 1.0);
  // ...
}

好啦,那看到这里的小伙伴应该都知道如何绘制圆形,如何实现色盘了吧,可以自己动手实践一下。

前言:

经过前面几篇的学习,我们了解到指令的大概分类,如:

参数加载指令,该加载指令以 Ld 开头,将参数加载到栈中,以便于后续执行操作命令。

参数存储指令,其指令以 St 开头,将栈中的数据,存储到指定的变量中,以方便后续使用。

创建实例指令,其指令以 New 开头,用于在运行时动态生成并初始化对象。

方法调用指令,该指令以 Call 开头,用于在运行时调用其它方法。

本篇介绍分支条件指令,该指令通常以 Br、或 B、C 开头,用于在运行分支条件时跳转指令。

分支条件指令介绍:

分支条件指令是在.NET Emit编程中关键的控制流程工具,用于在IL代码中实现条件判断和控制转移。

ILGenerator 类提供了一系列方法,用于生成这些分支条件指令,包括条件分支、无条件分支和Switch分支等。

条件分支指令(如brtrue和brfalse)根据栈顶的布尔值决定是否跳转到目标标签,而无条件分支指令(如br)则总是进行跳转。

Switch分支指令则用于在多个目标中选择一个跳转。

通过比较指令(如ceq、cgt和clt),还可以进行数值比较并根据比较结果执行相应的跳转操作。

这些指令的灵活运用可以实现复杂的控制逻辑,例如条件判断、循环和异常处理等。

深入理解分支条件指令将帮助开发者更好地掌握.NET Emit编程,提高代码生成的效率和灵活性。

常用分支条件指令:

条件跳转指令:

  1. beq
    :如果两个值相等,则跳转到指定的标签。
  2. bge
    :如果第一个值大于或等于第二个值,则跳转到指定的标签。
  3. bgt
    :如果第一个值大于第二个值,则跳转到指定的标签。
  4. ble
    :如果第一个值小于或等于第二个值,则跳转到指定的标签。
  5. blt
    :如果第一个值小于第二个值,则跳转到指定的标签.
  6. bne.un
    :如果两个无符号整数值不相等,则跳转到指定的标签。
  7. brtrue
    :如果值为 true,则跳转到指定的标签。
  8. brfalse
    :如果值为 false,则跳转到指定的标签。
  9. brtrue.s
    :如果值为 true,则跳转到指定的标签(短格式)。
  10. brfalse.s
    :如果值为 false,则跳转到指定的标签(短格式).

无条件跳转指令:

  1. br
    :无条件跳转到指定的标签。
  2. br.s
    :短格式的无条件跳转到指定的标签。
  3. leave
    :无条件跳转到 try、filter 或 finally 块的末尾。
  4. leave.s
    :短格式的无条件跳转到 try、filter 或 finally 块的末尾.

比较跳转指令:

  1. bgt.un
    :如果第一个无符号整数值大于第二个值,则跳转到指定的标签。
  2. bge.un
    :如果第一个无符号整数值大于或等于第二个值,则跳转到指定的标签。
  3. blt.un
    :如果第一个无符号整数值小于第二个值,则跳转到指定的标签。
  4. ble.un
    :如果第一个无符号整数值小于或等于第二个值,则跳转到指定的标签.

其他跳转指令:

  1. switch
    :根据给定的索引值跳转到不同的标签。
  2. brnull
    :如果值为 null,则跳转到指定的标签。
  3. brinst
    :如果对象是类的实例,则跳转到指定的标签。

这些指令可以帮助控制流程,在特定条件下跳转到指定的标签位置执行相应的代码。

从以上分类说明可以看出,该指令需要配置标签使用,对于标签的用法,

如有遗忘,可以回去补一下文章:
.NET Emit 入门教程:第六部分:IL 指令:2:详解 ILGenerator 辅助方法

上面指令按使用方式,只分两种:

1、条件跳转指令:根据栈顶的数据,及指令的判断条件,来跳转标签。2、Switch 分支跳转指令:根据给定的索引值,来跳转标签。

1、条件跳转指令:

条件分支指令是在IL代码中用于根据条件来执行跳转操作的指令。

它们可以根据
栈顶的(布尔)值
来决定是否跳转到目标标签:

示例指令:Brtrue

示例指令:Brfalse

该 true、false 指令,除了 bool 值,还兼容判断了空(引用)和数字(零),这个小细节要注意。

示例指令:Br

示例代码:

var dynamicMethod = new DynamicMethod("WriteAOrB", typeof(void), new Type[] { typeof(bool) }, typeof(AssMethodIL_Condition));

ILGenerator il
=dynamicMethod.GetILGenerator();var labelEnd =il.DefineLabel();var labelFalse =il.DefineLabel();

il.Emit(OpCodes.Ldarg_0);

il.Emit(OpCodes.Brfalse_S, labelFalse);

il.EmitWriteLine(
"true.");
il.Emit(OpCodes.Br_S, labelEnd);


il.MarkLabel(labelFalse);
il.EmitWriteLine(
"false");


il.MarkLabel(labelEnd);
il.Emit(OpCodes.Ret);
//返回该值

运行结果:

说明:

1、在 truefalse指令中,通常只会使用其中一个。2、在分支条件中,很多时候需要配合 Br 无条件指令跳出分支。3、在定义标签时,除了定义分支标签,还要定义结束标签,以便 Br 无条件指令的跳出。

4、其它条件指令的使用,和bool条件指令的使用是一样的,只需要理解指令的含义即可。

2、Switch 分支条件指令:

Switch 分支指令用于在多个目标中选择一个跳转,类似于在高级编程语言中的 switch 或者 case 语句。

在IL代码中,Switch 指令可以实现根据一个整数值来决定跳转到不同的目标标签。Switch 分支指令的主要指令是 switch,其作用如下:

  • switch: 该指令从标签数组中选择一个目标标签进行跳转。在 IL 代码中,标签数组通常在 switch 指令之前被定义,并且 switch 指令的操作数是标签数组的引用。switch 指令会从操作数指定的标签数组中根据整数值索引来选择目标标签,然后执行跳转操作。

Switch 分支指令的作用是根据一个整数值来决定跳转到不同的目标标签,这在处理具有多个选择的情况下非常有用,可以使得代码更加简洁和高效。

注意事项:

1、该 Switch 指令只是类似 switch 编程,但不等同

2、该 Switch 指令只能根据索引进行指令跳转。

同时,Switch 指令在IL代码编写起来,会相对复杂一点,特别是 case 一多,写起来可会要你命3000.

为了区分 Switch 指令和我们编写代码时的指令 Switch case 区别,我们来看以下示例:

这一次我们反过来,先写 C# 代码,再看它生成的 IL 代码。

下面给一个示例代码1:

static void TestString(stringc)
{
switch(c)
{
case "a":
Console.WriteLine(
"A");break;case "b":
Console.WriteLine(
"B");break;default:
Console.WriteLine(
"C");break;
}
}

反编绎,看生成的 IL 代码:

.method private hidebysig static 
        voidTestString (stringc
) cil managed
{
//Method begins at RVA 0x3808//Code size 73 (0x49) .maxstack 2.locals init (
[
0] string,
[
1] string)

IL_0000: nop
IL_0001: ldarg.
0IL_0002: stloc.1IL_0003: ldloc.1IL_0004: stloc.0IL_0005: ldloc.0IL_0006: ldstr"a"IL_000b: callbool [mscorlib]System.String::op_Equality(string, string)
IL_0010: brtrue.s IL_0021

IL_0012: ldloc.
0IL_0013: ldstr"b"IL_0018: callbool [mscorlib]System.String::op_Equality(string, string)
IL_001d: brtrue.s IL_002e

IL_001f: br.s IL_003b

IL_0021: ldstr
"A"IL_0026: callvoid [mscorlib]System.Console::WriteLine(string)
IL_002b: nop
IL_002c: br.s IL_0048

IL_002e: ldstr
"B"IL_0033: callvoid [mscorlib]System.Console::WriteLine(string)
IL_0038: nop
IL_0039: br.s IL_0048

IL_003b: ldstr
"C"IL_0040: callvoid [mscorlib]System.Console::WriteLine(string)
IL_0045: nop
IL_0046: br.s IL_0048

IL_0048: ret
}
//end of method Program::TestString

从该生成的 IL 代码中,可以看出并没有 Switch 指令,而是常规调用字符串比较后,用 bool 条件指令进行跳转。

那是不是用数字类型就会得到 Switch 指令呢?

再用数字型来一次:

        static void TestInt(intc)
{
switch(c)
{
case 1:
Console.WriteLine(
"AAA"); break;case 2:
Console.WriteLine(
"BBB"); break;default:
Console.WriteLine(
"CCC"); break;
}
}

得到的 IL 代码如下:

.method private hidebysig static 
        voidTestInt (
int32 c
) cil managed
{
//Method begins at RVA 0x3860//Code size 57 (0x39) .maxstack 2.locals init (
[
0] int32,
[
1] int32
)

IL_0000: nop
IL_0001: ldarg.
0IL_0002: stloc.1IL_0003: ldloc.1IL_0004: stloc.0IL_0005: ldloc.0IL_0006: ldc.i4.1IL_0007: beq.s IL_0011

IL_0009: br.s IL_000b

IL_000b: ldloc.
0IL_000c: ldc.i4.2IL_000d: beq.s IL_001e

IL_000f: br.s IL_002b

IL_0011: ldstr
"AAA"IL_0016: callvoid [mscorlib]System.Console::WriteLine(string)
IL_001b: nop
IL_001c: br.s IL_0038

IL_001e: ldstr
"BBB"IL_0023: callvoid [mscorlib]System.Console::WriteLine(string)
IL_0028: nop
IL_0029: br.s IL_0038

IL_002b: ldstr
"CCC"IL_0030: callvoid [mscorlib]System.Console::WriteLine(string)
IL_0035: nop
IL_0036: br.s IL_0038

IL_0038: ret
}
//end of method Program::TestInt

依旧是 br 跳转指令。

再试一下用枚举呢?

        static voidTestEnum(DataAccessKind c)
{
switch(c)
{
caseDataAccessKind.Read:
Console.WriteLine(
"AAA"); break;caseDataAccessKind.None:
Console.WriteLine(
"BBB"); break;default:
Console.WriteLine(
"CCC"); break;
}
}

得到 IL 代码如下:

.method private hidebysig static 
        voidTestEnum (
valuetype [System.Data]Microsoft.SqlServer.Server.DataAccessKind c
) cil managed
{
//Method begins at RVA 0x38a8//Code size 56 (0x38) .maxstack 2.locals init (
[
0] valuetype [System.Data]Microsoft.SqlServer.Server.DataAccessKind,
[
1] valuetype [System.Data]Microsoft.SqlServer.Server.DataAccessKind
)

IL_0000: nop
IL_0001: ldarg.
0IL_0002: stloc.1IL_0003: ldloc.1IL_0004: stloc.0IL_0005: ldloc.0IL_0006: brfalse.s IL_001d

IL_0008: br.s IL_000a

IL_000a: ldloc.
0IL_000b: ldc.i4.1IL_000c: beq.s IL_0010

IL_000e: br.s IL_002a

IL_0010: ldstr
"AAA"IL_0015: callvoid [mscorlib]System.Console::WriteLine(string)
IL_001a: nop
IL_001b: br.s IL_0037

IL_001d: ldstr
"BBB"IL_0022: callvoid [mscorlib]System.Console::WriteLine(string)
IL_0027: nop
IL_0028: br.s IL_0037

IL_002a: ldstr
"CCC"IL_002f: callvoid [mscorlib]System.Console::WriteLine(string)
IL_0034: nop
IL_0035: br.s IL_0037

IL_0037: ret
}
//end of method Program::TestEnum

还是没有见 Switch 指令。

可见,编程中的Switch,和 IL 的 Switch 指令是不同的。

所以很容易陷入一个误区,以为代码用 Switch 写分支,对应的IL就得用 Switch 指令。

下面演示一个用 Switch 指令的示例:

 var dynamicMethod = new DynamicMethod("WriteAOrB", typeof(void), new Type[] { typeof(int) }, typeof(AssMethodIL_Condition));

ILGenerator il
=dynamicMethod.GetILGenerator();var labelEnd =il.DefineLabel();

Label labelIndex_0
=il.DefineLabel();
Label labelIndex_1
=il.DefineLabel();
Label labelIndex_2
=il.DefineLabel();//BreakOp None=-1,Null=0,Empty=1,NullOrEmpty=2 var lables = new Label[] { labelIndex_0, labelIndex_1, labelIndex_2 };//0、1、2 il.Emit(OpCodes.Ldarg_0);

il.Emit(OpCodes.Switch, lables);

il.MarkLabel(labelIndex_0);
il.EmitWriteLine(
"0.");
il.Emit(OpCodes.Br_S, labelEnd);

il.MarkLabel(labelIndex_1);
il.EmitWriteLine(
"1.");
il.Emit(OpCodes.Br_S, labelEnd);

il.MarkLabel(labelIndex_2);
il.EmitWriteLine(
"2.");
il.Emit(OpCodes.Br_S, labelEnd);

il.MarkLabel(labelEnd);
il.Emit(OpCodes.Ret);
//返回该值 dynamicMethod.Invoke(null, new object[] { 1});


Console.Read();

运行结果:

从上面的示例可以看出,使用 Switch 指令,其实是在 IL 中编写类似于 Switch 语法的条件分支,而不是和C# 语法的 Switch 对应。

总结:

本篇介绍了在IL(Intermediate Language)代码中常见的两种指令类型:条件跳转指令和Switch 分支跳转指令。

条件跳转指令则用于执行数值比较操作,根据比较结果执行相应的跳转操作或将比较结果压入栈中。

由于其使用方式是一致,因此示例仅展示bool条件指令的使用,没有对其它指令展开示例,但其它条件指令的含义,也是需要仔细了解一下的。

Switch 分支跳转指令用于根据一个整数值选择不同的目标标签进行跳转,类似于高级编程语言中的 switch 或者 case 语句。

通过 Switch 指令,可以使代码更加简洁、高效,并提高可读性和可维护性。在处理具有多个选择的情况下特别有用,例如枚举类型或者状态机的处理。

它们在条件分支指令和循环控制中起着关键作用,通过灵活运用比较指令,可以实现各种复杂的算法和逻辑。

综上所述,Switch 分支跳转指令和条件跳转指令是IL代码中常用的两种控制流指令,它们在编写和优化IL代码时起着重要作用,能够使代码更加简洁、高效和易于理解。