读书笔记(十二) 《汇编语言》

两个月前看完王爽著的《汇编语言》经过一段时间的休整,今天再次翻开来又有另一番滋味,趁着最近对底层原理这股热情,对书本的内容进行一些整理。

我的初衷是希望更多的理解程序集在计算机中执行的原理,我中意《深度探索C++对象模型》里作者说的一句话,他说“我的经验告诉我,如果一个程序员了解底层实现模型,他就能够写出效率较高的代码,自信心也比较高。一个人不应该用猜的方式,或是等待某大师的宣判,才确定”何时提供一个copy constructor 而何时不需要“,这类问题的解答应该来自于我们自身对对象模型的了解”。

我们很多时候由于不了解底层的执行原来,导致我们在写代码的时候基本都靠猜,我们自认为的程序在计算机中的执行方式是这样的或是那样的。以前我猜过很长一段时间,但现在我不想猜了,我想了解所有关于计算机底层的执行原理,包括计算机设备的体系结构,计算机机器码的执行原理,面向对象模型的布局和执行原理。

以下是我对书本内容的记录和理解

我把这本书的理解分为,基础知识、总线、寄存器、寻址、跳转、中断,6个部分。书中写了很多案例,总53万字,我把这些知识精简一下,并说出我自己的理解。

基础知识

我们知道机器指令是一列0和1构成二进制数字,为什么是0和1呢,因为它可以在设备中用高低电平表示。高低电平就是一个晶体管的两个状态,即有电流和没电流。

一列010101二进制数字如果用数字电路来表示的话,就是一排晶体管。这排晶体管假如要做逻辑判断或计算的话,就需要涉及到逻辑门(即电流的走向逻辑),我们常接触到的逻辑门有,与(and)、非(not)、或(or)这三种逻辑门,除了这三种还有,与非门、或非门、反相器、异或门,通过这六种基础的逻辑门的组合,我们可以制造出很多很多复杂的逻辑门电路,比如加法数字电路、乘法数字电路等,计算器就是由众多逻辑门组合而成的,输入一排电流后得到一排电流的结果。

由于0101形式的机器码,太难记忆,所以人们发明了汇编,它其实就是机器码的助记符,即某个10101的指令用一个英文符号来表示。因此汇编指令与机器指令的差别在于表示方式上,汇编是机器指令便于记忆的一种书写格式。我们程序员用汇编写出程序后,用汇编编译器将其翻译成机器码,再交由计算机执行。反过来也是一样,翻译完成的机器码(即一个可执行文件)也可以反过来翻译成汇编语言符号。

我们常用的cpu处理器的发展过程,从8080到奔腾4的过程是,8080、8086/8088、80186、80286、80386、80486、奔腾、奔腾2、奔腾3、奔腾4.

其中8086/8088开始被使用在了微机上,即我们现在的个人电脑。80386开始在微机上可以做多任务操作,一台个人电脑中的操作系统可以同时处理多项任务。虽然80286也具备了对多任务系统的支持,但它对8086/8088的兼容性支持比较差,所以过度非常困难,于是就有了80386,它既有多任务系统的功能也兼容了8086/8088。

总线

CPU是计算机的核心,要让CPU工作就必须向它提供指令和数据,指令和数据存放在内存,因此内存的作用仅次于CPU。而磁盘则不同与内存,磁盘上的数据或程序集如果不读到内存中就无法被CPU使用,所以对CPU来说,磁盘其实是外部设备。计算机设备中除了CPU芯片外,其他设备中也有属于自己的芯片,比如内存芯片、磁盘芯片、键盘芯片、显卡芯片、网络芯片等等,这些芯片都有自己的寄存器,CPU可以通过这些寄存器与其他芯片进行交流。

设备与CPU之间的交流都是数据交流(也就是不同高低平的电流),CPU在读写数据时要指明,它要对哪一个器件进行操作,进行哪种操作,是从中读出数据还是写入数据。

那么CPU是通过什么将数据传到其他设备的芯片中去的呢?在计算机中有专门连接CPU和其他芯片的导线(电线或电流线),我们称它为总线。总线又分为3类,地址总线、控制总线、数据总线。

这3根总线的分类职责不同,地址总线用于指定内存单元地址,控制总线用于传输指令,数据总线用于传输数据。

我们来举个例子,CPU要读取内存中3号单元的数据,CPU先通过地址线将地址信息3发给内存芯片,再通过控制总线发出读取内存的指令,指令中选中存储芯片,并通知它将要从中读取数据,最后内存芯片将3号单元中的数据通过数据线送入CPU。反过来,写的步骤也是类似,先通过地址总线将地址信息3发给内存芯片,再通过控制总线向内存芯片发出写入命令,最后通过数据总线将数据传入内心芯片。

我们现在知道了,地址总线能传输多少个二进制信息,CPU就可以对多少个单元地址进行寻址。数据总线也是一样,有多少根决定了一次可传二进制数据的大小。控制总线是个总称,是一些不同控制线的集合,有多少根控制总线就意味着CPU提供了对外部器件的多少种控制。

现在我们一台机子上有很多存储器,包括随机存储器(RAM)、只读存储器(ROM)、显存RAM、网卡ROM等,这些存储器在物理上是独立的器件,它们都与CPU总线相连。

CPU在地址传输的时候是怎么去识别到底传给哪个存储器的呢?CPU在操控它们的时候,把它们总的当作一个整体的内存空间来看待,相当于若干个存储器排列组成了一个逻辑存储器,每个存储器的存储地址与前一个是紧密连接的,我们称这整个存储空间为,内存地址空间。

举个例子,假如所有存储器地址加起来为从64KB的空间,即总共0~FFFF的地址,那么主随机存储器的地址空间可能为32KB即 0~7FFF,显存地址空间可能为8KB即 8000~9FFF,其他各个ROM的地址空间为24KB 即 A000~FFFF。如果CPU向内存地址为1000的地址发送数据,就是向主随机存储器发送数据,如果向9000地址写入数据,则为显存空间。

这里所说的都是外部总线,是CPU连接外部器件的导线集合。其实CPU内部也是有总线把各个部件连接起来的,一个CPU由运算器、控制器、寄存器等器件构成,这些器件就是靠内部总线相连,它们之间相互进行数据传输。

寄存器

现代的CPU寄存器由很多种,我们拿最常用的一些来讲,即8086的14个寄存器,AX、BX、CX、DX、SI、DI、SP、BP、IP、CS、SS、DS、ES、PSW。

通用寄存器有4个,AX、BX、CX、DX,它们可以存放一般性数据,可以看作是平时程序中的变量。它们中又有高低位寄存器来指向它们的高位和低位,即AH和AL为AX的高8位和低8位,BH和BL为BX的高8位和低8位,CH和CL为CX的高8位和低8位,DH和DL为DX的高8位和低8位。

8086中可以一次性处理8位和16位数据,即字节(byte)和字(word)。即我们可以向AX中传数据也可以向AH和AL上传数据。

我们来举个例子:

	mov ax,18 将18送入AX寄存器

	mov ah,78 将78送入AH寄存器

	add ax,8 将AX中的值加上8后覆盖AX

	现在ax中的值为86

在汇编代码中寄存器的用法有习惯性用法的讲究,比如AX寄存器就是存放临时数据的,BX则存放偏移地址数据,CX存放的是循环计数数据,DX则通常用来存放要访问的数据段地址。而SI、DI与BX的作用一样,存放的是内存偏移地址,只是它们不能拆分为高低位。

除了通用寄存器外,还有段寄存器,标志寄存器,也是汇编中重要的寄存器。

段寄存器用于存放,段内存的起始地址和偏移地址。它们包括,CS、IP、SS、SP、BP、DS。

其中CS和IP就是代码段寄存器,CS指向代码段在内存中的起点,IP则指向当前执行指令在内存起点中的偏移位置,IP指向的地址就是程序要执行的指令位置。

SS、SP和BP是内存栈的段寄存器,其中SS指向栈内存段起点地址,SP则指向当前栈顶偏移地址,BP有点特殊,它和SP联合使用作为SP校准使用,因为SP要指向栈顶不能乱动,所以SP把地址传给BP,由BP来做栈内的寻址。

对于栈操作,计算机有push和pop指令可以使用,即我们先定义号SS和SP好地址后用push和pop操作:

	mov ax, 123H ;给一个地址

	mov ss, ax ;段寄存器必须用寄存器操作数值

	mov sp, 100H ;设置栈顶,就是设置栈总大小

	push bp ;保存bp指针

	mov bp, sp ;把栈顶置针给bp,这时候如果函数有参数,则[bp + 2*4]就是第一个参数位置,[bp + 3*4]就是第二个参数位置,以此类推。

	mov ax, 22 ;把22给ax

	push ax ;ax中的值推入栈

	pop bx ;把栈中的值推出给bx

	现在bx中数据为22

最后是DS,DS则是静态数据段的寄存器,指向静态数据段的起始地址,所有在程序中的常量、静态变量都被放在静态数据段中,有DS数据段地址指向。

有了指令内存地址,栈内存地址,静态数据内存地址,我们就可以根据数据执行指令,控制栈顶保存函数临时变量。

标志寄存器则对各种标志位存储,它包括ZF(零标志)、OF(溢出标志)、DF(方向标志)、CF(进位标志)、PF(奇偶标志)、SF(符号标志)、TF(中断标志)、IF(屏蔽标志)等。

	ZF 结果是否为0

	OF 加法是否溢出

	DF si、di的增减方向

	CF 进行无符号运算时记录最高有效位的进位值。

	PF 结果是否为偶数

	SF 结果是否为负

	TF 是否有中断程序

	IF 是否屏蔽中断

这些标志位都是被动被计算机赋值的,只有当我们结束某个操作指令后才会需要去查看它们。

寻址

绝大部分机器指令都是进行数据处理的指令,处理大致可分为3类:读取、写入、运算。在机器指令这一层来讲,并不关心数据的值是多少,而关心指令执行前一刻,它将要处理的数据所在的位置。指令在执行前,所要处理的数据可以在3个地方,分别是:CPU内部、内存、端口。

我们知道CPU通过内存来获得数据,在访问内存时要给出内存的地址,通过地址总线送入存储器芯片必须是一个内存单元的物理地址,于是CPU可访问的内存大小就受到地址总线的限制。

8086CPU是16位结构的CPU,也就是说8086内部职能一次性处理和传输总长16位的信息,如果将地址从内部简单发出,那么它只能送出16位地址,即只能访问0~FF之间的地址,表现出的寻址能力时64K。

这样的寻址能力太少,于是CPU采用了一种内部用两个地址合成一个地址的方法来增加寻址能力,即“段地址+偏移地址 = 物理地址”,这样就一下子增加了1倍的寻址能力,如果地址总线够宽的话。可惜8086只有20根总线,因此最大寻址能力只能提高到1MB。

我们现代计算机中也运用了同样的寻址方式,但是现代计算机中地址总线只有32条和40条,即32位CPU处理器原本只能处理4G内存,64位CPU处理器原本只能处理1T内存(而且还要受到其他地址总线的限制,比如内部总线)。

段地址是重要的标记位置,是一段内存的起点,因此在程序中有专门的寄存器来存储它们,就是我们前面说的,CS、IP、SS、SP、BP、DS、ES。

寻址方式分,直接寻址、间接寻址、基址变址寻址,即如下:

例子1,数据段寻址:

	mov bx, 1000H ;将1000数据送入bx

	mov ds, bx ;将bx里的数据送入ds

	mov al, [0] ;将ds段地址加上0得到的1000H:0,在该内存地址单元中的数据送入al寄存器

	mov [0], bx ;将bx的数据送入1000H:0内存地址单元中

例子2,寄存器寻址:

	mov bx, 1000H ;将1000H送入bx

	mov ax, [bx] ;根据bx中的值去内存中的单元地址中的值,传送给ax

	mov ax, [bx + 123] ;根据bx+123得到的值去内存中的单元地址中取值,传送给ax

	mov ax, [bx + si] ;根据bx + si的到的值去内存中取值,传送给ax

	mov ax, [bx + si + 123] ;根据bx + si + 123得到的值去内存中的单元地址中取值,传送给ax

例子3,段地址寻址:

	mov bx, 1000H ;将1000H送入bx

	mov cs, bx ;将bx的值送入cs代码段寄存器

	mov ax, 0 ;将0送入ax

	add ax, cs:[bx] ;用段地址+偏移地址的方式获得cs+bx地址,并从该地址上取得数值送入ax

例子4,不同类型的数据放入不同的段:

	assume cs:code, ds:data, ss:stack

	data segment ; 定义data内容

		dw 0123h, 0456h, 0789h, 0abch, 0defh, 0fedh ;dw为字内容即2个byte

	data ends

	stack segment ; 定义stack内容

		dw 0,0,0,0,0,0

	stack ends

	code segment ; 定义code内容

		mov ax, statck ; 将statck地址送入ax

		mov ss, ax ; 将ax内容送入ss栈段寄存器

		mov sp, 20h ; 将栈顶定义为20h大小

		mov ax, data ; 将data定义为

		mov ds, ax ; 将ax数据传入ds数据段寄存器

		mov bx, 0 ; 将0传入bx

	code ends

	end start

跳转

计算机执行机器指令时是顺序的并且根据代码段指针在没有跳转的情况下依次执行下去,这个指针就是IP寄存器。因此跳转就是将原本IP寄存器指向的地址改为我们指定的地址,这样计算机根据IP指针指向的机器指令可以随意操控了。

最常用的跳转就是loop。计算机在执行loop指令时每次都会在先将cx中的值减1,再判断cx是否为0,如果不为零则跳转到指定地址:

	mov cx, 11 ; 将11传送给cx

	mov ax, 1 ; 将1传送给ax

	s: add ax, ax ; 将ax加上ax的值传入ax

	   loop s  ; 先做cx=cx-1操作,再判断cx是否为零,如果不是零则跳转到 s位置,如果是零则继续下一条

除了修改IP来实现跳转外,我们也可以通过就该CS代码段地址来实现跳转。

只修改IP实现的跳转,我们称为段内跳转,也称短跳转,因为跳转范围在一个寄存器大小范围内。而同时修改CS和IP实现的跳转,我们称为段间跳转,也称为长跳转。

常用的跳转指令有:

	无条件跳转 jmp

	有条件跳转 je(相等就跳转),jcxz(判断cx为0就跳转)

	循环跳转   loop

	调用跳转 call,ret(使用栈中数据修改IP内容实现近跳转),retf(用栈中数据修改CS和IP实现远跳转)

	中断跳转   int

前面有些一般形式的跳转我们都会理解的比较快,比如jmp,je,jcxz,和loop,都是通过寻址地址跳转,或先做某个判断再跳转。和我们平时写的代码中的,if…else,goto,for循环,switch有很多相似之处,因此理解起来相对比较容易,最多也只是多了一个远近的跳转,也好理解,近跳转只修改IP,远跳转修改了CS和IP。

call、ret、retf和我们平时编写的函数有紧密的联系。

当call被调用时,它先将当前IP或CS和IP推入到栈中,再进行转移。执行call时相当于:

	push IP

	jmp 某寄存器

而ret和retf在执行时,会先从栈中推出一个数据传送给IP,或推出2个数据传送给CS与IP。

ret相当于:

	pop IP

retf相当于

	pop IP

	pop CS

这两个指令很像我们代码中的函数调用function call 和 return时的作用。我们来看看这两个指令配合起来是如何运作的:

	mov ax, 1 ;将1传送给ax

	mov cx, 3 ;将3传送给cx

	call s ;先push IP 并且 jump s,指令跳到s段地址上

	mov bx, ax ;将ax的值传送给bx

	mov ax, 4c00h ;将4c00h传送给ax

	int 21h ;中断调用,结束

	s: add ax, ax ;将ax值相加传送给ax

	   loop s ;先cx减去1传送给cx,再判断cx是否为0,如果不为0条转到s,如果为0则继续下一个

	   ret ;pop IP,从栈中取的前面call时推入的地址给IP,实现跳转到 call s 那句指令下

	最后结束前,bx为8,loop了跳转了3次,ax从1自增到了8,call跳转到了s做了循环,做完循环ret又回到了call s下。

有了function和ret的概念,我们就可以传递更多参数到栈中,在function里去取出栈中的值进行计算,在汇编里会变得如何呢,我们来看看:

	mov ax 1000 ;将1000传入ax

	mov ds ax ;通过ax设置数据内存单元

	mov [101] 10 ;将10传送到数据内存101地址单元上

	mov [102] 20 ;将20传送到数据内存102地址单元上

	push [101] ;将101数据内存地址单元上的数据推入栈中

	push [102] ;将102数据内存地址单元上的数据推入栈中

	mov ax, 1 ;将1传入ax

	call s ;先push IP,再将IP设置为 s段地址上

	mov bx, ax ;将ax中的数值传送给bx

	mov ax 4c00h ;将4c00h传送给ax

	int 21h ;中断调用,结束

	s: pop bx ;从栈中推出数据给bx,其实就是IP值

	   pop dx ;从栈中推出数据给dx,其实就是[102]的值

	   add ax dx ;将ax和dx相加传送给ax

	   pop dx ;从栈中推出数据给dx,其实就是[101]的值

	   add ax dx ;将ax和dx相加传送给ax

	   jmp bx ;直接跳转到call s的位置

上述程序利用了栈传递数据,在调用 call s之前,在栈中推入了内存地址101和102的值,到s中执行计算时分别推出了IP和两个数据,做完加法后跳转到call s的原位置下,继续执行直到结束。

中断

中断分内部中断和外部中断,中断的意思是CPU不再接着向下执行,而是转去处理特殊的信息。

CPU具备一种能力,可以在执行完当前正在执行的指令后检测到从CPU外部发过来的或者内部产生的一种特殊信息,并立即处理接收到的信息,这就是中断信息。

内部中断比如CPU在除法时溢出就会触发内部的中断程序,外部中断比如收到键盘的输入数据时会触发中断程序。

用来处理中断信息的程序被称中断处理程序,一般来说,需要对不同的中断信息编写不同的处理程序。

每个中断程序都有一个中断码,通过中断码我们可以找到中断程序。在计算机内存中有一块内存是专门存放中断码和对应的中断程序地址的,我们称为中断向量表:

	0号 -> 中断程序入口地址

	1号 -> 中断程序入口地址

	...

当程序引发中断时过程如下,我们假设发生了除法溢出错误,产生了0号中断信息:

	1.除法溢出,产生中断信息,取得中断码

	2.标志寄存器的值入栈(因为在中断过程中要改变标志寄存器值)

	3.设置标志寄存器TF和IF的值为0

	4.CS和IP入栈

	5.根据中断码从中断向量表中取出中断处理程序的入口地址

中断程序编写的方法和子程序相似:

	1.保存用到的寄存器

	2.处理中断

	3.恢复用到的寄存器

	4.调用iret指令返回

iret指令功能可描述为:

	pop IP ;推出地址给指令指针IP

	pop CS ;推出地址给指令段地址给CS

	popf ;推出标志寄存器

除了系统自带的中断程序外,我们也可以自己安装中断程序,例如我们常用的Try cache 和 throw 就是利用安装中断程序来做抓异常的功能的。

我们来看看,安装中断程序时如何做的:

	mov ax, cs ;将cs地址传入ax

	mov ds, ax ;将ax地址传入ds,为后面拷贝做准备

	mov si, offset ss ;设置ds:si指向地址

	mov ax, 0 ;将0传入ax

	mov es, ax ;将ax传入es

	mov di, 200h ;设置es:di指向目的地址

	mov cx, offset sqrend-offset ss ;设置ss中断程序长度

	cld ;设置传输方向为正

	rep movsb ; 拷贝ds:si到es:di

	mov ax, 0 ; 设置ax为0

	mov es, ax ; 将ax的0传入es

	mov word ptr es:[7ch*4], 200h ; 修改中断编码7ch的中断程序入口地址

	mov word ptr es:[7ch*4 + 2], 0 ; 修改中断编码7ch的中断程序入口地址

	mov ax, 4c00h ;设置ax

	int 21h ; 结束中断

	ss: mul ax ;中断程序,ax乘以ax传入ax

	    iret ;恢复中断

我们将ss这段程序放入了中断向量表7ch中去,当我们执行 int 7ch 时就执行了ss段程序,虽然这个程序执行完毕后就没有在内存了,但ss这段指令已经被拷贝到了内存中。

我们来看看BIOS和DOS在启动时时如何安装它们的中断程序的。

1.开机后,CPU一加电,初始化(CS)=0FFFFh,(IP)=0,自动从FFFF:0单元开始执行程序。FFFF:0出油一条跳转指令,CPU执行该指令后转去执行BIOS中的硬件系统检测和初始化程序。

2.初始化程序将建立BIOS所支持的中断向量表,将BIOS提供的中断程序的入口地址登记在中断向量中。

3.硬件系统检测和初始化完成后,调用 int 19h 进行操作系统的引导。从此将计算机交由操作系统控制,DOS开始初始化程序。

4.DOS启动后,除完成其他工作外,还将它所提供的中断程序装入内存,并添加到相应的中断向量表中。

外部中断

前面说CPU总线连接了很多芯片,这些芯片都有一组可以由CPU读写的寄存器,虽然这些寄存器都在不同的芯片中,但它们都与总线相连,CPU可以通过控制总线对它们进行读写。

从CPU的角度看,将这些寄存器都当作端口,对它们进行统一编址,从而建立了一个统一的端口地址空间。每个端口在地址空间中都有一个地址。

在访问端口时,CPU通过端口地址来定位端口,端口地址范围为0~65535。

访问端口与访问内存略有不同,访问内存使用mov ax, ds:[8],将内存地址数据段中的8单元地址上的数据传送到ax,而端口则是 in al, 60h,从60h号端口读入1个字节的数据送入al,out 20h, al 将al上的数据传入到20h端口。

CPU有了与外部芯片通信的机制,就可以接受外部中断信息。比如,外设的输入到达时,相关芯片就会向CPU发出相应的中断信息。CPU在执行完当前指令后,检测到发过来的中断信息,引发了中断过程,跳转到中断程序处理外设的输入数据。

我们来看看计算机时如何处理键盘输入中断的:

1.键盘上每个键都相当于一个开关,键盘中有一个芯片对键盘上的每个键的开关状态进行扫描。

2.按下一个键时,开关接通,该芯片就产生了一个扫描码,扫描码被送入主板上的接口芯片(usb接口)的寄存器中,该寄存器的端口地址通常为60h。

3.松开按下的键时,也会产生一个扫描码,也同样被送入主板上的接口芯片(usb接口)的寄存器中。

4.当键盘的输入到达60h端口时,接口芯片就会向CPU发送中断码。CPU检测到该中断信息后,如果IF=1则认为是不可屏蔽中断,接着响应中断引发中断过程,转去执行int 9的中断程序。

5.BIOS提供了int 9的中断程序,读出60h端口中的扫描码,并送入到内存的键盘缓存中,如果是Ctrl之类的控制键则写入内存中存储状态字节单元,接收完成后向接口芯片发出应答信息。

6.CPU继续根据内存中键盘缓存中的数据执行相关程序,响应键盘的输入。

除了键盘外,磁盘也是外部设备的一种,也同样需要通过端口和中断的形式处理相关程序。

BIOS和操作系统都提供了访问磁盘的中断程序。当需要向磁盘访问数据时,会先在寄存器和内存上设置访问的地址和大小,再发起中断,中断程序会根据设置在寄存器和内存上的信息从磁盘上传输到内存,传输完毕后结束中断。

下面这个写入数据到3.5英寸软盘为例:

	mov ax, 0

	mov es, ax

	mov bx, 200h ; ex:bx指向写入磁盘的数据

	mov al, 1 ; 读取的扇区数

	mov ch, 0 ; 磁道号

	mov cl, 1 ; 扇区号

	mov dl, 0 ; 驱动器号

	mov dh, 0 ; 磁头号

	mov ah, 3 ; 表示写入

	int 13h ; 执行磁盘写入中断

CPU首先需要告诉磁盘设备,写入还是读取,接着要告诉磁盘写入的内容,以及写入的地址,中断程序在执行完毕写入操作后,结束中断并返回原地址。

总结,汇编其实就是机器码的助记符,它有很多个寄存器,每个寄存器都有不同的用途,内存分为几个段,不同的内存段有不同的用途,通常包括有静态数据段、代码段、栈空间段,我们通过指令可以在内存中寻址,也可以在不同的段之间跳转。CPU和设备之间用总线连接在一起,总线又分为地址总线,数据总线,控制总线,每个设备又都有自己的芯片,它们与CPU的通信方式就是通过自己的寄存器,CPU统一称它们为端口并为它们做了统一的编号。除内存外,设备与CPU之间的通信通常使用中断来进行,中断程序执行完毕后会跳转到原位置继续执行下面的程序指令。

· 读书笔记

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    读书笔记(十二) 《汇编语言》

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)