C/C++ 中有时会遇到一些位级操作,通常是一些隐式的类型转换,它们往往很难凭借高级语言层面的直觉来理解或记忆。本文旨在分析这些操作对应的汇编代码,从机器指令的角度来理解这类操作。

补码数转换为更长的无符号数

int main() {
	short a = -12345;
	unsigned b = a;
	cout << a << " " << b << endl; // -12345 4294954951
}

首先看以上这个示例,一个短整型数据(2 字节)强制类型转换为无符号整型数据(4 字节)之后,得到的值却是一个看似毫不相关的结果。

void showBytes(unsigned char* ptr, size_t sz) {
	for (int i = 0; i < sz; ++i) {
		printf("%.2x ", ptr[i]);
	}
	printf("\n");
}

首先,为了更好地分析这类位级操作,这里编写了一个简单的字节打印函数,通过将指向变量的指针强制类型转换为 unsigned char * ,便可以很方便地通过增加数组下标来实现对每个字节的访问。

int main() {
	short a = -12345;
	showBytes((unsigned char*)&a, sizeof(a)); // c7 cf
	unsigned b = a;
	showBytes((unsigned char*)&b, sizeof(b)); // c7 cf ff ff
}

通过打印变量 a 和 b 的位级表示,发现 a 的位级表示为:c7 ff,而 b 的位级表示为 c7 ff ff ff,这表明 b 在位级层面实际上进行了符号扩展(注意此处字节序为小端表示,即字节地址由高到低为 ff ff ff c7),再将其解释为无符号类型,用表达式表示就是:unsigned b = (unsigned)(int)a;.

在 MSVC 编译器下对前面的代码进行编译,得到以下代码:

mov eax, 0ffffcfc7h
mov word ptr [a], ax

movsx eax, word ptr [a]
mov dword ptr [b], eax

将 a 赋值给 b 的指令为 movsx,该指令的作用是将源数据经过符号扩展后存入目的地址,相关的指令还有 movzx,作用是将源数据经过零扩展后存入目的地址,因此程序实际上是将 a 的比特位符号扩展后再存入 b 中。事实上,要对一个变量进行何种扩展,决定因素是源数据的类型,而与目标类型无关,这是 C 语言标准所规定的。

截断补码数

int main() {
	int a = INT32_MIN;
	short b = a;
	cout << a << " " << b << endl; // -2147483648 0
}

4 字节的整型转换为 2 字节的短整型,同样产生了令人意想不到的结果。

int main() {
	int a = INT32_MIN;
	showBytes((unsigned char*)&a, sizeof(a)); // 00 00 00 80
	short b = a;
	showBytes((unsigned char*)&b, sizeof(b)); // 00 00
}

由于整型的字节长度大于短整型,因此在类型转换过程中,必然要进行数位的截断,关键在于截断策略的选择。对于无符号数来说,很容易想到直接将高位字节部分截断,因为这样才能保证当整型数值 a 不是太大(小于短整型所能表示的最大数值)时,类型转换后数值保持不变。而根据上述字节打印结果,可以看到补码数值的截断策略与无符号数一致,以下汇编代码清楚地表明了这一点:

mov dword ptr [a], 80000000h

mov ax, word ptr [a]
mov word ptr [b], ax

在进行类型转换时,程序只是简单地将变量 a 的一个字(word),即两字节存入 b 中。因此补码数的截断,其本质上还是位级层面的截断,与该补码所表示的数值并无关系,不涉及到任何的算术运算,这就使得在对负数进行截断时,往往产生出乎意料的结果。