由mac系统IOKit处理头文件中结构体字节对齐问题引起的思考

背景

在iBook G4机器的Mac OS X 10.5.8系统上使用macports安装libusb时发生了错误,显示是通过gcc7编译到#pragma options align=reset这行时,编译器不认识了,于是报了错。这个问题本身处理并不复杂,但却引起了我的思考,作为一个API的提供者,在头文件中使用是否应当使用结构体,如果使用的话如何使用才正确呢?

思考

按照我先前的想法,原则上不允许用。原因是为何呢?

因为跨越二进制边界会带来许多问题。

作为API提供方,往往是通过提供动态库+头文件这种形式来为客户提供服务。但是一旦提供的是动态库,那必然和客户的调用代码之间是存在二进制边界的。二进制边界会带来什么问题呢?

首先是复杂引用类型问题。当跨越二进制边界时,是不能使用复杂类型的。举个例子,如果你提供了C++的API,此时是不能传std::string作为参数的。原因在于如果调用方和被调用方使用了不同的编译器(如gcc和llvm)或者不同版本的编译器(如gcc的新版本和老版本),此时std::string的实现会不一样。所以你在库里面用gcc创建的std::string,传递给客户调用方的llvm那边,尝试进行解释,要么行为不对,要么破坏了堆或栈直接崩掉或后面不一定什么时候崩掉,这个后果是非常严重的。

所以基于上面的说明,我们跨越二进制边界时,提供的API使用简单类型才能保证访问的安全性,如整形,浮点型,字符数组指针,简单类型的指针,简单类型的指针的指针等。

那这是不是意味着传结构体或结构体的指针就一定不行呢? 倒也不见得。这里面唯一的问题就是对齐的问题。不同的编译器的默认对齐规则可能不同,手工配置也会会导致对齐规则发生变化,所以就算是同一个结构体的指针,在不同二进制边界的解释方法也是会产生差异的,这个差异轻则造成数据错误,重则造成系统崩溃。即使是个很小的数据错误,其后续影响也是无法预计的。

但这个对齐是不是能够控制的呢? 肯定是可以的,比如我们看下面这个Mac OS X的IOKit库中的头文件代码 /System/Library/Frameworks/IOKit.framework/Headers/usb/USB.h

通过代码可以看到,它手工做了按1字节对齐,也就是紧密排列结构体,在出了作用域之后,再手工重置对齐操作。

所以不管是跨越二进制边界的哪一方,现在都有了同样的对齐操作,是可以保证针对相同的偏移量来取到同一个字段的。

但这就完全没问题了么? 当然不是了!
这里还存在未对齐访问的问题。考虑一个场景,如果客户这么搞

然后在栈上或堆上创建一个ZhaoShiStruct的实例zhaoshi,再把&zhaoshi.desc作为指针传给库来使用,会发生什么?
如果这个体系结构不支持非对齐访问,那针对desc->bcdUSB就出现了未对齐的访问,如果平台对于非对齐访问没做额外处理,轻则数据错误,重则直接异常崩掉。

所以如果是使用结构体的话,首选是让库创建结构体实例,传给客户使用。如果一定要客户创建结构体实例,那一定要想办法在API层面做检查并报异常。可能有人有疑问了,这里做兼容行不行? 我说是不行的。因为如果客户创建了一个非对齐的结构体,他自己存入和读取数据的时候都有可能不对,那兼容还有什么意义呢。所以一步到位直接报异常,让客户意识到问题提前改过来才是正道。说实话拿这个约束客户还是有点过分了,我宁愿让客户直接传简单类型,退而求其次给他提供一个获取结构体指针的方法,让他拿到后填入。如果他敢给我传他自己创建的结构体,我检查出来之后直接报异常就行了。

另外还有就是,这种结构体,一定要注意生命周期问题,出了同步阻塞边界后一定不能再使用了。这个约束用来约束库的开发者来说还好,但是对于客户,鬼知道他会怎么用!所以说还要注意,结构体的字段尽量都是简单的值类型。如果一定有引用类型,那最佳的场景是由客户填入引用类型的值(如字符数组的指针),而在库里使用。一定要避免库通过结构体暴露出引用类型给客户。

然后还有一个问题,就是引起这个思考所带来的问题,就是这种对齐控制的代码,要把跨平台做好才行。gcc,clang,vc,新老版本,你的API兼容到什么,你就得提供多少。像这次遇到的问题就是在Mac OS X 10.5.8上macports编译的gcc7.5实际上不认识#pragma options align=reset,所以如果把库改成这样就行了

你看看,就一个llvm的clang和gcc就不一样。那回头如果你做跨平台的库,人家用vc怎么办,用mingw怎么办,你是不是都得考虑。所以光一个对齐的处理估计你考虑全了就不知道多少行。

~另外一个事情,如果人家有客户就是手残,认为自己手工配置的对齐声明可以一劳永逸,没想到他引入了你的头文件后却导致了对齐被重置了,这怎么整呢! 客户看到的就是反正引入了你的头文件,凭什么却破坏了我的配置! 虽然问题看似很简单,但会给客户接入带来不小的成本。~

21/09/17补:目前在我们自己项目中引入了一个新的做法,可以规避掉对于已有字节对齐设置的影响,即pack的push和pop操作。

暂时想到的就这些了。可能还有些遗漏的。

总结

进行下总结吧。经过思考,结论如下。

  • 原则上能不用就不用
  • 如果不得不用,请注意
    • 自身对齐
    • 避免对客户的对齐配置产生影响

注意事项

  • 首先注意统一字节对齐处理
  • 做好字节对齐的跨平台处理
  • 避免对客户的对齐配置产生影响
  • 如果用于由库给客户提供数据
    • 直接传结构体的值,禁止传递结构体的引用(指针)
    • 结构体的字段一定使用简单值类型
    • 禁止出现引用类型
  • 如果用于由客户给库提供数据
    • 结构体避免应由库的方法进行创建
    • 做好结构体的对齐检查工作,并尽早崩溃

总之注意事项实在太多了,如果是我的话我宁愿选择不用。

做跨平台客户端SDK到现在,看见「二进制边界」和「复杂类型」,还有「链接」「符号」这些东西,仿佛都有说不完的话题。因为这些东西的背后,是一个个不眠之夜,是看一个个似简单但又深刻的教训。

替代方案其实也是有的。要么就是妥协多传几个参数,再一个就是在二进制边界使用序列化的方案来处理,比如用json字符数组来传递大量的参数。

如果是客户给库不频繁的传递参数用途,目前看起来json序列化反序列化感觉还是挺实用的,后续可能在生产中我会采取这样的方案。

发表评论

为防机器,验证码请直接输入4个数字1

*