在MacOS App代码中定位架构差异
修复源自Apple Silicon和Intel电脑架构差异导致的问题
概览
在Intel Mac上写的代码并不是总能在Apple Silicon平台使用。两个系统之间是存在的差异会导致代码运行出错或者程序崩溃。在开始前使用下面的提示来定位可能出现问题的地方。
动态获取系统和硬件细节
依赖特定系统细节或硬件配置的代码在Apple Silicon平台可能会崩溃或者出现预期之外的行为。Apple Silicon和Intel Mac之间有很多的硬件差异,系统特性也会有一些不同。如果为一个特性硬编码了一个数据,在其它系统上如果这个数据变了代码可能就无法如预期运行。
如果可能的话动态的从系统全局变量中获取数据而不是基于底层系统硬编码。例如从全局变量vm_page_size中获取虚拟内存页的大小。如果某个全局变量无法获取,那么,使用sysctl或者sysctlbyname函数来获取需要的信息。
Apple Silicon的一些特性确实和Intel Mac电脑不一样,如果不动态获取这些特性的话可能会影响代码的稳定性。这些特性包括:
- 虚拟内存页大小是不同的。请从全局变量vm_page_size获取具体数值。
- 缓存行大小是不同的。请使用sysctl从hw.cachelinesize获取。
- CPU的一些特性。使用sysctl和形式为hw.optional.的字符串来确定这些特性是否可用,是你想使用的特性。比如使用hw.optional.avx512f字符串来确定AVX512指令集是否可用。
在命令行中使用命令sysctl hw来查看可用的硬件特性列表。至于其它系统特性,在sysctl后面使用不同的字符串如kern、user或machdep。
同步访问内存中的共享数据
总是使用macOS中的锁、内存屏障和其它同步原语来保护共享内存。像Intel架构的Mac电脑这类的强内存序模型会显式的添加内存屏障,防止处理器在加载和存储指令时重排序导致的条件竞争。而Apple Silicon架构类似的弱内存序模型会给处理器在内存指令排序上更多灵活性和更好的性能,但不会显式添加内存屏障。为了确保代码在所有平台都正确无误,请在代码中显式的添加同步原语。
macOS包括很多的内存原语:
- Grand Central Dispatch(GCD)提供了串行队列和其它方式来同步任务,请查看 Dispath
- @synchronized 指令为Objective-C代码创建了一个互斥锁。
- Foundation框架中定义了标准的内存锁、条件锁和其它类型的锁。
- os框架提供了“不公平”的同步锁。
- pthreads库定义了标准的互斥锁和条件变量。
- C11/C++11在stdatomic.h中的原始指令支持在原子操作中自定义内存顺序。
如果还在使用无锁的算法或者是自定义的同步技术,请考虑使用系统提供的技术替代。如果无法适配系统同步原语,那么,请在部署程序之前验证自定义代码在Apple Silicon平台的正确性。
注意 使用线程检测工具(thread sanitizer)检查数据竞争并定位代码中需要同步的地方。更多信息查看<在早期诊断内存,线程和崩溃问题>
不要重复声明一个有变长参数的函数
x86_64和arm64架构对于变长函数(可以传入不同数量参数的函数)有的不同的调用协议。在x86_64上编译器会将定长函数和变长函数等同,参数优先放在寄存器上直到寄存器用尽才会使用堆栈。在arm64上无论是否有寄存器可用,编译器都会将变长函数的参数放在堆栈上。如果定义了一个定长函数,再定义一个同名的变长函数在运行时会因为无法匹配导致预期之外的效果。
为了更好的理解问题,先看下面的只有固定参数的函数:
int foo(const char *mystr, BOOL mybool, char mychar, int myint, long mylong)
{
NSLog(@"foo(%s, %x, %x, %x, %lx)", mystr, mybool, mychar, myint, mylong);
return 42;
}
如果是在x86_64平台上,可以在代码任意地方重新声明一个如下的同名函数并成功执行:
extern int foo(const char *mystr, ...);
void printTestValues() {
BOOL mybool = YES;
char mychar = 42;
int myint = 0xfeedface;
long mylong = 0x0123456789abcdef;
foo("hello", mybool, mychar, myint, mylong);
}
同样的代码会因为arm64平台函数调用者和函数自身编排参数的方式不同而出错。函数自身的预期是所有的参数都在寄存器中。然而调用者只是将第一个参数传入寄存器,其它的参数都传入了堆栈。结果,函数的内部实现会去错误的地址寻找参数,导致预期之外的结果。
即使没有显式地声明一个重构函数,类似objc_msgSend的函数也会隐式声明重构函数和方法。更多信息请查看<为动态方法分派启用强制严格类型>
为动态方法分派启用强制严格类型
为适配x86_64和arm64架构之间的方法调用差异,请更新动态分派代码让参数在不同的平台可以正确传递。像objc_msgSend这样的方法会调用对象中的方法,并将提供的参数传递给这个方法。因为objc_msgSend要支持调用任意方法,所以它是接收可变的参数列表而不是固定的参数。这种可变参数的使用改变了objc_msgSend如何调用函数,有效的将方法重构成了变长函数。
为了演示这个问题,参考下面示例中想要用objc_msgSend调用的方法:
- (void)document:(NSDocument*)doc
didSave:(BOOL)didSave
contextInfo:(void*)contextInfo;
由于objc_msgSend将方法声明为变长类型,按照arm64的调用方式,会将参数放在堆栈上。然而,原本的方法声明是固定参数的,而不是可变参数。因此,方法的实现会去寄存器寻找参数,那是arm64上编译器寻找定长方法参数的地方。这样的结果就是方法调用会出现未知错误。
要修复代码中的动态分派问题,需要定义一个类型安全的函数指针来代替直接调用objc_msgSend。在x86_64和Apple Silicon平台都可以用类型安全指针。类型安全指针指定了确定数量的参数,并将每个参数的类型信息合并到objc_msgSend调用,使得编译器生成这个方法期望的调用方式。例如,方法document:didSave:contextInfo:的类型安全的函数指针如下:
// 声明类型安全的函数指针
void (* didSaveDispatcher)(id,SEL,NSDocument *,BOOL,void *) =
(void(*)(id,SEL,NSDocument *,BOOL,void *))objc_msgSend;
初始化动态分派操作需要传入目标对象,选择器和方法参数给函数指针,代码如下:
// 通用分派给objc_msgSend调用函数
didSaveDispatcher(myDelegate, mySelector, myDocument, NO, myPtr);
为了定位在调用objc_msgSend时没有使用类型安全,在构建设定中启用objc_msgSend调用严格检查(strict Checking of objc_msgSend calls)。当这个设定的值为YES时,编译器会在代码中标记哪些调用objc_msgSend时没有使用类型安全函数指针。
定位框架中的数字差异
一些框架有一些细微变更在移植代码到Apple Silicon时可能会引发问题。
比如:
- NSTextAlignment中的某些枚举值在arm64和x86_64平台使用了不同的数值。如果使用数值来引用这些常量,在每个平台验证使用的值是正确的。
- NSImage.ResizingMode和UIImage.ResizingMode中的某些枚举值在arm64和x86_64平台使用了不同的数值。如果使用数值来引用这些常量,在每个平台验证使用的值是正确的。
- 视频工具箱框架(Video Toolbox framework)中的编码ID在arm64和x86_64平台甚至不同版本的macOS中可能会不同。例如,kVTVideoEncoderSpecification_EncoderID在不同平台会不一样。
更多关于框架差异的信息,请查阅对应的框架文档。
使用内置intrinsic函数替代原始汇编代码
如果App中针对某些任务使用了汇编代码或者是特定处理器的内置函数(__builtin functions),请使用编译器的内置intrinsic函数代替。编译器内置的intrinsic可以在支持跨平台的基础上实现跟汇编代码一样的效果。在编译的时候,编译器会根据当前平台将内置intrinsic函数调用替换成合适的汇编指令。
为了展示intrinsic内置intrinsic函数的好处,参考下面计算一个数字的前置0个数的函数实现的示例。示例演示了使用内置intrinsic函数CLZ指令来计算一个integer类型的数字中前置0的个数。
int GOOD_count_leading_zeroes(int x){
int count;
if(x == 0){
return (sizeof(x) * CHAR_BIT);
}
#if __has_builtin(__builtin_clz)
count = __builtin_clz(x);
#else
int index = 1;
for(; x != 1; ++index){
x = (unsigned)x >> 1;
}
count = ((sizeof(x) * CHAR_BIT) - index);
#endif
如下面代码所示,如果不使用内置intrinsic函数来实现同样的功能需要更多的代码。并且因为要为每一种处理器架构都提供一个自定义的实现,所以需要的代码也更复杂。
int BAD_count_leading_zeroes(int x){
int count;
if(x == 0){
return (sizeof(x) * CHAR_BIT);
}
#if defined(__x86_64__)
__asm__ (
"bsrl %1, %0\n\t"
"xorl $0x1f, %1"
: "=r" (count)
: "r" (x)
);
#elif defined(__aarch64__)
__asm__ (
"clz %w1, %w0"
: "=r" (count)
: "r" (x)
);
#else
int index = 1;
for(; x != 1; ++index){
x = (unsigned)x >> 1;
}
count = ((sizeof(x) * CHAR_BIT) - index);
#endif
return count;
}
如果需要了解clang编译器提供的内置intrinsic函数列表,请查看clang文档https://llvm.org。
更新特定处理器的向量指令
如果代码中包含了针对Intel处理器的SSE,AVX,AVX2或者是AVX512单元的指令,更新到支持Apple Silicon的版本。Accelerate框架中有大量为全Mac平台优化后的向量操作库,可以作为针对特定处理器的向量操作的替代。Accelerate框架使用当前系统可用的硬件来实现:
- 向量和矩阵计算
- 图像操作
- 数字信号处理
- 线性代数计算
- 压缩
- 神经网络操作
关于Accelerate框架的更多信息,请查看<Accelerate文档>
将时间基本信息应用到Mach Absolute Time的值中
不要假设mach_absolute_time返回的是设备启动后经历的纳秒数并将时间基本信息应用到返回值中。mach_absolute_time的返回值在本地进程和转换模式进程中是不同的,并且也没有必要和设备启动后经历的时间关联。将时间基本信息应用之后确保了可以在不同进程和不同电脑之间传递这个数据。
下面的代码演示了如何将时间基本信息应用到mach_absolute_time的返回值中:
uint64_t MachTimeToNanoseconds(uint64_t machTime) {
uint64_t nanoseconds = 0;
static mach_timebase_info_data_t sTimebase;
if (sTimebase.denom == 0)
(void)mach_timebase_info(&sTimebase);
nanoseconds = ((machTime * sTimebase.numer) / sTimebase.denom);
return nanoseconds;
}
如果想要直接获取纳秒数而不是转换mach_absolute_time的返回值,可以直接调用这个函数。
审查包含Float类型到Int类型转换的代码
C语言家族中,Apple Silicon和Intel Mac电脑应对float到int的类型转换是不同的。为了展示其中一个不同,先看下面将float类型的表示无穷大的数转换成uint32_t和int32_t类型。
uint32_t a = (uint32_t)INFINITY;
int32_t b = (int32_t)INFINITY;
在arm64架构中,会转换成最近的整型数值:
a = 0xffffffff = 4294967295 // 最大的无符号整型
b = 0x7fffffff = 2147483647 // 最大的有符号整型
对于无符号类型的转换,x86_64架构会将值转换为0,对于有符号的类型转换,会转换成整型中最小的数。
a = 0x00000000 = 0 // 转换成0
b = 0x80000000 = -21474836548 // 最小的整型数值
注意 在Swift语言中,float到int类型的转换在所有的CPU架构中都保持一致。
如果代码中使用了float类型到int类型的转换,审查这部分代码确保边界条件正确。检测无效转换的一个方法是使用UBSan工具配合float-cast-overflow选项。如果想让编译器检测出隐式的转换。在构建的时候启用-Wconversion编译标记。
关于如何使用UBSan工具的信息,请查看<在早期诊断内存,线程和崩溃问题>
将BOOL类型看作是二值类型
Objective-C中,规定BOOL类型只有两个值:YES或NO。在Apple Silicon平台上,编译器将BOOL类型定义为本地化类型bool,但是在Intel Mac上BOOL类型是一个有符号的char类型。在通用程序中为了避免问题:
- 不要在BOOL类型的变量上执行数学运算。
- 不要增加或减少BOOL变量的值。
- 不要假设BOOL类型的数字值是0或1以外的值。
为了展示问题,参考下面的示例:
int nBytes = 1024;
BOOL receivedBytes = nBytes;
if (receivedBytes) {
printf("Success!\n");
} else {
printf("Failure...\n");
}
在x86_64架构中,receiveBytes的值是0或者说false。而在arm64架构中,receiveBytes的值是true。一个修复这种问题的办法是在 nBytes变量赋值给receiveBytes 前在前面加上两个否定运算符,如下所示:
int nBytes = 1024;
BOOL receivedBytes = !!nBytes;
注意 在编译的时候启用-Wobjc-signed-char-bool-implicit-int-conversion编译选项会为隐式的BOOL类型转换生成警告。
更新即时编译器(Just-In-Time)
由于Apple Silicon平台阻止内存页同时写入和执行,即时编译器的工作流程必须更新以支持这个特性。在Intel Mac电脑上只有适配了Hardened Runtime的app才需要这步。
更多信息请查看<迁移即时编译器到Apple Silicon>
更新C++代码
Apple Silicon平台的C++ ABI(应用二进制接口)匹配的是iOS设备的ABI,而不是Intel Mac电脑的ABI。关于ABI的信息,请查看<iOS ABI 函数调用指南>
本文暂时没有评论,来添加一个吧(●'◡'●)