背景
在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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
/*! @typedef IOUSBDeviceQualifierDescriptor @discussion USB Device Qualifier Descriptor. See the USB Specification at <a href="http://www.usb.org"TARGET="_blank">http://www.usb.org</a>. */ #pragma pack(1) struct IOUSBDeviceQualifierDescriptor { UInt8 bLength; UInt8 bDescriptorType; UInt16 bcdUSB; UInt8 bDeviceClass; UInt8 bDeviceSubClass; UInt8 bDeviceProtocol; UInt8 bMaxPacketSize0; UInt8 bNumConfigurations; UInt8 bReserved; }; typedef struct IOUSBDeviceQualifierDescriptor IOUSBDeviceQualifierDescriptor; typedef IOUSBDeviceQualifierDescriptor * IOUSBDeviceQualifierDescriptorPtr; #pragma options align=reset |
通过代码可以看到,它手工做了按1字节对齐,也就是紧密排列结构体,在出了作用域之后,再手工重置对齐操作。
所以不管是跨越二进制边界的哪一方,现在都有了同样的对齐操作,是可以保证针对相同的偏移量来取到同一个字段的。
但这就完全没问题了么? 当然不是了!
这里还存在未对齐访问的问题。考虑一个场景,如果客户这么搞
1 2 3 4 5 6 7 |
#pragma pack(1) struct ZhaoShiStruct { UInt8 danteng; IOUSBDeviceQualifierDescriptor desc; }; #pragma options align=reset |
然后在栈上或堆上创建一个ZhaoShiStruct
的实例zhaoshi
,再把&zhaoshi.desc
作为指针传给库来使用,会发生什么?
如果这个体系结构不支持非对齐访问,那针对desc->bcdUSB
就出现了未对齐的访问,如果平台对于非对齐访问没做额外处理,轻则数据错误,重则直接异常崩掉。
所以如果是使用结构体的话,首选是让库创建结构体实例,传给客户使用。如果一定要客户创建结构体实例,那一定要想办法在API层面做检查并报异常。可能有人有疑问了,这里做兼容行不行? 我说是不行的。因为如果客户创建了一个非对齐的结构体,他自己存入和读取数据的时候都有可能不对,那兼容还有什么意义呢。所以一步到位直接报异常,让客户意识到问题提前改过来才是正道。说实话拿这个约束客户还是有点过分了,我宁愿让客户直接传简单类型,退而求其次给他提供一个获取结构体指针的方法,让他拿到后填入。如果他敢给我传他自己创建的结构体,我检查出来之后直接报异常就行了。
另外还有就是,这种结构体,一定要注意生命周期问题,出了同步阻塞边界后一定不能再使用了。这个约束用来约束库的开发者来说还好,但是对于客户,鬼知道他会怎么用!所以说还要注意,结构体的字段尽量都是简单的值类型。如果一定有引用类型,那最佳的场景是由客户填入引用类型的值(如字符数组的指针),而在库里使用。一定要避免库通过结构体暴露出引用类型给客户。
然后还有一个问题,就是引起这个思考所带来的问题,就是这种对齐控制的代码,要把跨平台做好才行。gcc,clang,vc,新老版本,你的API兼容到什么,你就得提供多少。像这次遇到的问题就是在Mac OS X 10.5.8上macports编译的gcc7.5实际上不认识#pragma options align=reset
,所以如果把库改成这样就行了
1 2 3 4 5 6 |
#ifdef __clang__ #pragma options align=reset #else #pragma pack() #endif |
你看看,就一个llvm的clang和gcc就不一样。那回头如果你做跨平台的库,人家用vc怎么办,用mingw怎么办,你是不是都得考虑。所以光一个对齐的处理估计你考虑全了就不知道多少行。
~另外一个事情,如果人家有客户就是手残,认为自己手工配置的对齐声明可以一劳永逸,没想到他引入了你的头文件后却导致了对齐被重置了,这怎么整呢! 客户看到的就是反正引入了你的头文件,凭什么却破坏了我的配置! 虽然问题看似很简单,但会给客户接入带来不小的成本。~
21/09/17补:目前在我们自己项目中引入了一个新的做法,可以规避掉对于已有字节对齐设置的影响,即pack的push和pop操作。
1 2 3 4 5 6 7 8 9 10 11 12 |
# pragma pack(push, 8) struct LoganConfig{ bool encrypt = false; bool upload = true; bool deleteAfterUpload = true; uint32_t uploadInternal = 1000 * 60; char* uploadAddress = ""; char* fileDir = "xxxxx"; uint32_t fileMaxSize = 5242880; } #pragma pack(pop) |
暂时想到的就这些了。可能还有些遗漏的。
总结
进行下总结吧。经过思考,结论如下。
- 原则上能不用就不用
- 如果不得不用,请注意
- 自身对齐
- 避免对客户的对齐配置产生影响
注意事项
- 首先注意统一字节对齐处理
- 做好字节对齐的跨平台处理
- 避免对客户的对齐配置产生影响
- 如果用于由库给客户提供数据
- 直接传结构体的值,禁止传递结构体的引用(指针)
- 结构体的字段一定使用简单值类型
- 禁止出现引用类型
- 如果用于由客户给库提供数据
- 结构体避免应由库的方法进行创建
- 做好结构体的对齐检查工作,并尽早崩溃
总之注意事项实在太多了,如果是我的话我宁愿选择不用。
做跨平台客户端SDK到现在,看见「二进制边界」和「复杂类型」,还有「链接」「符号」这些东西,仿佛都有说不完的话题。因为这些东西的背后,是一个个不眠之夜,是看一个个似简单但又深刻的教训。
替代方案其实也是有的。要么就是妥协多传几个参数,再一个就是在二进制边界使用序列化的方案来处理,比如用json字符数组来传递大量的参数。
如果是客户给库不频繁的传递参数用途,目前看起来json序列化反序列化感觉还是挺实用的,后续可能在生产中我会采取这样的方案。