置零:=0?&=0?^=?

2023-01-08

“中级语言”

C语言虽然具备完整的高级语言特性,但更多特性的设计是针对CPU和硬件设计的,因为设计C语言的原始目的是为了代替汇编语言开发操作系统。

在设计C语言的那个年代,RISC并不流行,为了提升低主频CPU的性能,增强单条指令的功能,提供更多的指令和指令寻址方式,是更为常用的方法,也就是CISC的思路,从8086/8088开始的x86就是典型的例子。实际上,CISC和RISC孰优孰劣直到今天也并不是有绝对定论的事情。

很多C语言特性,看似复杂,如果对应到CISC CPU指令一级上,例如x86指令一级上,就会发现实在是理所当然的事情。

例如,“++”、“–”这类运算符,本来就是对应着INC、DEC这类指令;而令C语言初学者十分迷惑的“j=i++”与“j=++i”之间的区别,实际只是编译时,生成INC指令和MOV指令的先后次序问题。

“+=”、“-=”、“&=”、“|=”、“^=”这类运算符本来就是直接对应着ADD、SUB、AND、OR、XOR这类指令的,一条指令就能实现,反倒是“+”、“-”、“&”、“|”、“^”这类运算符一条指令往往还不能完全实现,还需要附加MOV等指令。

另外,“[]”运算符为何不检查下标越界?因为“[]”运算符的实质是一种变址运算符,并非取数组下标元素运算符。x86本来就支持相对寻址(变址寻址)、基址—变址寻址、基址—变址—相对寻址等多种寻址方式,“[]”运算符可直接用这些寻址方式实现,例如,在x86汇编语言中,变址寻址的下列写法都是可以接受的,寻址结果也一样:

mov al,data[si]
mov al,si[data]
mov al,[data+si]
mov al,[si+data]

有趣的是,C语言中,“取数组a下标为6的(第7个)元素”的写法,写作a[6]或者6[a]实际都是正确的,当然写成*(a+6)或者*(6+a)也是完全等价的,对照上面变址寻址的几种写法不难理解。

三种运算

在x86汇编语言中,假设需要将寄存器AX的值清零,可以有好几种写法,例如以下三种写法都可以:

mov ax,0
and ax,0
xor ax,ax

但第一种与后两种写法的本质不同,mov ax,0指令是一种将立即数传输到寄存器的操作,除了影响AX寄存器的值之外不会有任何附加作用,但and ax,0和xor ax,ax指令则是一种运算,会动用到CPU的ALU,运算结果一方面将AX寄存器清零,另一方面由于运算结果为零,因此同时将标志寄存器PSW(或者FLAGS)中的ZF、CF、OF、PF标志位也清零了,等于一条指令完成了多个工作。而ZF、CF、OF、PF标志位的清零,可能影响后续指令的执行,例如JZ/JNZ这样的条件跳转指令;同时,使用and ax,0或xor ax,ax的写法清零标志位,在使用汇编语言开发时就有可能简化程序的编写。

另外,三种写法中,一般来说xor ax,ax的写法指令执行速度要快一些,因为只涉及到AX寄存器操作数,不涉及到立即数操作数,指令执行过程中省去了对指令进行进一步译码,以及从指令中获得立即数操作数的步骤(或者更为准确地说,实现了断开数据依赖)。

那么,在C语言中,假设变量a是使用寄存器直接实现的(很多时候,C编译器的优化都能做到),那么一般来说,a=0就相当于mov ax,0的形式,a&=0就相当于and ax,0的形式,a^=a就相当于xor ax,ax的形式,尽管在现代主流CPU和主流C编译器上,三者可能都会被编译器最终优化为xor ax,ax的形式,但在一些嵌入式CPU和较老的C编译器上,优化能力有限,三种写法最终生成的指令就有可能不同,进而就可能影响到指令在CPU上的执行速度,以及编译器在其它方面的优化,毕竟一条指令能完成两个工作,则有可能有利于后续指令的优化。

还有,值得注意的是,在底层开发,例如嵌入式系统和硬件固件(Firmware)的开发中,直接与硬件底层,例如外设寄存器打交道时,如果要将外设寄存器中的某位置位(设置为1)或者清零(设置为0),一般不用“=”直接赋值,而应该用“|=”或者“&=”进行,也就是相当于使用OR指令或者AND指令,防止影响寄存器中除了需要置位或者清零的位之外的位,否则影响了不应该影响的位,就可能导致不可预知的结果。