C语言陷阱II

qc1iu published this page · Last modified:

相信以后还会有III,IIII,V……

类型转换是有代价的

最近在写虚拟机解释引擎的时候遇到的这个问题。Java虚拟机中虽然有多种数据类型,但是实际上在Java栈上,只有两种类型,占用一个栈槽位的和占用两个栈槽位的,也就是4字节与8字节。所以一开始设计Java栈的API的时候使用了两套接口:

unsinged int Stack_pop4(T);
void Stack_push4(T, unsinged int);
unsigned long long Stack_pop8(T);
void Stack_push8(T, unsinged long long);

这里的问题是,将float类型push之后,当再次pop出来时,结果就不正确了。

C语言中float占4个字节,无符号整形数也是4个字节,这样如果有4个字节的数据放在内存中,具体它是什么值取决于我们把它当成什么类型去解释。这一点非常类似数据与结构的关系,数据往往只有一份,但是可以用多种结构来表示,结构只是数据的不同视图罢了。内存中的数据当然也是这样,放在那里都是没有类型的字节,就看CPU如何去解释它们。

但是将同样的数据用不同的类型来解释,在C语言中不能用类型转换来实现,原因在于类型转换不是免费的!

float x = 3.14;
unsigned int y = (unsigned int)x;
float z = (float)y;  // z已经不是3.14了

之所以说类型转换不是免费的,因为这里可以将它理解为一种运算:unsigned int trans_f2i(float).并且这种运算并不是拿着原始数据生成一个新的数据的视图,而是复制一份数据并对数据做修改。显然y与x在内存中的地址是不一样的。

使用指针可以将任意合法内存当做任意类型的读写。

float x = 3.14;
unsigned int *p = &x;
unsigned int y = *p;
float *f = &y;
float z = *f;  // z = 3.14

灵活使用union也可以获得同样的效果。

union {
       unsigned int u;
       float f;
}u;
float x = 3.14;
u.f = x;
unsigned int y = u.u;
float z = u.f; // z = 3.14

将任意合法内存作为参数传给已定义的函数

需求看上去有点奇怪,不过如果真的写起Runtime类的程序,这种需求恐怕还是挺多的。可以理解为函数都已经定义好了,但是函数的参数可能是通过网络传来的,或者通过其他过程计算得到的一块内存区域。比如通过dlopen,dlsym可以找到函数指针,不过如何用一个通用模块给找到的函数传递参数呢?

合理的利用程序调用栈的布局似乎是一个好主意。这篇文章里面详细介绍了栈相关的细节(貌似链接挂了,以后我会自己写一篇放上)。比如如下这段程序:

int foo(int x, int y, int z);
struct Args {
   unsigned data[MAX];
};
int main()
{
   int(*f)();
   f = foo;
   struct Args args;
   int *body = (int*)args.data;
   body[0] = 1;
   body[1] = 2;
   body[2] = 3;
   f(args);
}

利用结构体值传递的特点,可以将一块内存作为参数传给接受任意个任意类型参数的函数。当然这里的方法很多,用不定长数组总是分配在栈的低地址的特点也可以达到同样的效果,就不在举例子了。

让我掉进陷阱的是这套方案在64bit的系统上不能用了……最后发现其实就是ABI的问题。在64bit上gcc应该是默认用寄存器传参,而32bit都是默认用栈传参。看到也有人有同样的需求x86-64-forcing-gcc-to-pass-arguments-on-the-stack。我没有查相关的ABI文档,不过做了点实验,反编译了一些代码,64bit上应该前6个参数都是通过寄存器传递的,多于6个的参数通过栈传递。当然这个不是很准确,权威答案还需要查看相应的文档。我只是用这个例子测了一下:

struct args {
  long long data[64];
};

int foo(int x1, int x2, int x3
            , int x4, int x5, int x6
            , int x7, int x8, int x9){
  printf("hello, world %d %d %d\n", x7, x8, x9);

  return 1;
}

typedef int(*fTy)();
int main()
{
  fTy f;
  f = foo;

  struct args Args;
  Args.data[0] = 100;
  Args.data[1] = 200;
  Args.data[2] = 300;
  f(1,2,3,4,5,6,Args);  // print hello, world 100 200 300
  return 0;
}

当然这种方法本身就是一种hack,是破坏ABI的一种做法。即使在32bit上也是不能保证正确性的,因为32bit上可以通过优化利用2个寄存器传参,只是默认关闭而已。