iOS 引用 C/C++ 项目:交叉编译与 Objective-C++

最近终于有幸参与公司的 iOS 项目,其中有个 C/C++ 写的库需要调用;

之前对 Android JNI/NDK 调用 C/C++ 还算熟悉,但 iOS 混编 C/C++ 却是初次接触,各种被虐..

上个周末基本都在解决库的编译问题,爱人 Amble 也耐心帮我查资料、作分析,感动之余,决定把中途遇到的问题记录下来。

静态库 or 动态库?

iOS 不支持调用第三方动态库(.dylib),应该是出于安全考虑,比如各种越狱工具就大量使用动态库;

一开始在这里耽误很久,.dylib 库编译没问题,但是 Xcode 项目引用后一直报 ‘library not load’、’symbols not found for architexture’ 之类的错误..

C++ 头文件去掉动态链接代码

C++ 动态库接口对应的头文件,需要将 __declspec(dllexport)__stdcall 等关键字去掉;

CMake 脚本改为静态链接

add_library(${PROJECT_NAME}) SHARED ${SOURCE_FILES})

这里 SHARED 改为 STATIC 即可。

交叉编译

Mac 上直接跑 CMake 编译出来的是 X86 架构的,iOS 上无法运行,需要一个调用 xcodebuild 的 CMake 脚本做交叉编译;

Google Code 上的这个 iOS-CMake 项目年久失修,需要自己做一些改动。

修改 Xcode 路径

exec_program(/usr/bin/xcode-select ARGS -print-path OUTPUT_VARIABLE CMAKE_XCODE_DEVELOPER_DIR)
set (XCODE_POST_43_ROOT "${CMAKE_XCODE_DEVELOPER_DIR}/Platforms/${IOS_PLATFORM_LOCATION}/Developer")

这里自动检测安装路径并设置,不需要写死,也不需要编译时指定编译选项;

编译器设置和检查

CMakeForceCompiler 已经过时,直接指定编译器为 Clang 就好:

set (CMAKE_C_COMPILER /usr/bin/clang clang)
set (CMAKE_CXX_COMPILER /usr/bin/clang++ clang)

后面需要跳过编译器检查,否则编译会报错中断:

set (CMAKE_CXX_COMPILER_WORKS TRUE)
set (CMAKE_C_COMPILER_WORKS TRUE)

修改 Architecture

if (IOS_PLATFORM STREQUAL "OS")
    set (IOS_ARCH "armv7;armv7s;arm64")
elseif (IOS_PLATFORM STREQUAL "SIMULATOR")
    set (IOS_ARCH "i386;x86_64")
elseif (IOS_PLATFORM STREQUAL "WATCHOS")
    set (IOS_ARCH "armv7k")
endif ()

这里可以根据实际情况做取舍,比如最新 iOS 11 就宣布不支持非 64 位架构,真机和模拟器可以分别只保留 arm64、x86_64;

另外,还新增了 WatchOS 的支持。

禁用 CMake 可执行编译选项

一般 C/C++ 项目每个模块下面都会添加包含 main() 的测试代码,需要在 CMakeLists.txt 文件中注释掉 #add_executable 相关代码,否则后面编译会报以下错误:

target specifies product type ‘com.apple.product-type.tool’, but there’s no such product type for the ‘iphoneos’ platform

调用 CMake 创建并编译 Xcode 项目

CMake 添加 -DCMAKE_TOOLCHAIN_FILE 选项指定 iOS 交叉编译脚本,-GXcode 选项指定生成项目类型为 Xcode:

cmake -DCMAKE_TOOLCHAIN_FILE=./ios.cmake -DIOS_PLATFORM=SIM -H. -Bbuild.sim -GXcode
cmake --build build.sim/ --config Release

上面是模拟器的库编译,真机类似。

使用 xcodebuild 自动编译项目

后面发现,如果存在库的依赖关系,即 CMakeLists.txt 中设置了 target_link_libraries,上面生成的 Xcode 工程并不会自动添加库依赖,需要自己手动设在 Build Phases 的 Link Binary with Libraries 中设置;

设置完后,只要不增删 C/C++ 代码,就不用重新生成 Xcode 项目,可以通过 xcodebuild 自动编译:

xcodebuild clean
xcodebuild -target xxx

这个工具在自动打包时经常用到。

符号表相关编译设置

我们知道,符号表(symbols) 包含 C/C++ 程序中的变量和函数信息,虽然可方便调试,但是会显著增加包大小;

Xcode 的 Build Settings 包含一些符号表相关设置,这里介绍两个常用的、和 Clang 编译选项有关的:

  • Generate Debug Symbols: 开启后,编译每个源文件时会自动带上编译选项 -g-gmodules,生成完整的调试信息,如果 C/C++ 层代码 crash 后会自动跳转到源代码行数;关闭后,crash 时会直接跳到汇编代码;一般建议至少调试阶段应该开启;

  • Debug Info Level: 如果设置为 Line Tables Only,会自动带上编译选项 -gline-tables-only,调试信息就只会包含函数名、文件名、行号,不包含变量;

也可以先不管这些设置,最后直接通过 strip -x -S old.a -o new.a 命令去掉符号表信息;

更多内容可参考:

库的合并与检查

macOS 自带的 lipo 命令可用于将真机和模拟器的库文件合并:

lipo -create "lib/ios/libXXX.a" "lib/sim/libXXX.a" -output "lib/generic/libXXX.a"

最后,再介绍两个有用的命令行工具:

  • file xxx.a:可以查看库文件的架构信息;

  • lipo -info xxx.a:也可以查看库文件的架构信息;

  • nm -a xxx.a:可以查看库的符号表信息;

详细编译脚本参考这里

Objective-C 混编 C/C++

C++ 头文件编译问题

C++ 工程的 .h 文件直接拷进 Xcode 是可能编译不过的,如果在头文件使用了 C++ 语法的话;

因为 Xcode 默认当做 C 头文件编译,解决方式有两个:

  1. 扩展名改为 .hpp 或 .hxx,让编译器知道这是 C++ 头文件;

  2. 在使用 C++ 语法的代码块前后分别添加 #ifdef __cplusplus#endif 宏;

除了上面的问题,如果头文件定义了 const 常量,并且被多次 include 的话,Xcode 编译会报 ‘duplicate symbol’ 的异常;

原因是被认为重复定义,解决方式是在前面添加 static 关键字。

Objective-C 和 C/C++ 数据类型转换

主要是 NSString/NSDatastd::stringchar* 这三者之前的相互转换,这里我定义了一些宏方便调用:

#define STDStr2Int(str) std::atoi(str.c_str())
#define STDStr2Float(str) std::atof(str.c_str())
#define STDStr2Long(str) std::atol(str.c_str())
#define STDStr2LongLong(str) std::atoll(str.c_str())
#define NSStr2CharP(nsStr) (char*)[nsStr cStringUsingEncoding:NSUTF8StringEncoding]
#define NSStr2STDStr(nsStr) std::string([nsStr cStringUsingEncoding:NSUTF8StringEncoding])
#define STDStr2NSStr(stdStr) [NSString stringWithCString:stdStr.c_str() encoding:NSUTF8StringEncoding]
#define STDStr2NSData(stdStr) [NSData dataWithBytes:stdStr.data() length:stdStr.size()]]
#define charP2NSStr(charP) [NSString stringWithCString:charP encoding:NSUTF8StringEncoding]
#define charP2NSData(charP) [[NSData alloc] initWithBytes:charP length:strlen(charP)]
#define charV2STDStr(charV) std::string(charV.begin(), charV.end())
#define NSStr2NSData(nsStr) [nsStr dataUsingEncoding:NSUTF8StringEncoding]
#define NSData2NSStr(nsData) [[NSString alloc] initWithData:nsData encoding:NSUTF8StringEncoding]

还有一点要注意,对于包含汉字等宽字节的 NSString,如果要传字节长度到 C/C++ 层,不能直接调 [nsString length],因为它返回的是字符数而不是字节数;

可以先将 NSString* 转为 char*,然后调用 C/C++ 的 std::strlen() 得到字节数:

char *charP = NSString2CharP(nsStr);
int charLength = std::strlen(charP);

内存管理

我们知道,Objective-C 的 initdealloc 方法分别对应 C++ 的构造函数和析构函数,混编时他们的对象内存也是各自管理的;

Objective-C 中如果创建了全局 C/C++ 对象,需要在 dealloc 方法中释放;

同样,如果 C++ 创建了 Objective-C 对象,也需要在析构函数中释放;

二进制数据传递问题

通常 Android/iOS 调用底层 C/C++ 库最多的场景就是图像和音视频处理了,这就涉及到二进制数据的传递;

具体到我参与的项目,传递的数据是一个 std::map<std::string,std::string> 类型参数;

一开始看到 std::string 很自然联想到 Objective-C 中对应的 NSString,所以做了个“画蛇添足”的转换:先把 NSData 转换为 NSString,再将 NSString 转为 std::string

为什么要先转换为 NSString 呢?因为一开始打算在 Objective-C++ 的 wrapper 类屏蔽掉 C++ 的类型,外部调用全部传入 Objective-C 的类型;

但既然要转为 NSString 就涉及到 encoding 的问题,但很快发现无论 NSUTF8StringEncoding 还是 NSASCIIStringEncoding 都有问题;

最后改为直接 NSDatastd::string 之间转换,而且考虑到上面提到的内存管理问题,避免 EXC_BAD_ACCESS,每次传递时都做下数据拷贝:

template<typename T>
inline T MALLOC(long length) {
    return reinterpret_cast<T>(std::malloc(length * sizeof(T)));
}

template<typename T>
inline T COPY(T src, long length) {
    T data = MALLOC<T>(length);
    std::memcpy(data, src, length);
    return data;
}

inline std::string copySTDStrFromNSData(NSData* data) {
    long size = [data length];
    Byte* bytes = COPY<Byte*>((Byte*)[data bytes], size);
    std::string str(reinterpret_cast<char const*>(bytes), size);
    return str;
}

inline NSData* copyNSDataFromSTDStr(std::string str) {
    long size = str.length();
    Byte* bytes = COPY<Byte*>((Byte*)str.c_str(), size);
    NSData *data = [NSData dataWithBytes:bytes length:size];
    return data;
}

注意 std::string 构造函数要带上 length 参数,否则二进制数据很可能被截断。

C/C++ 回调 Objective-C

Objective-C 中的 C 回调函数是无法直接访问类的成员和方法的,甚至会报 EXC_BAD_ACCESS 错误;

那么如何解决这个问题呢?

熟悉 JNI 的都知道,它的每个 JNI 函数第一个参数都是 JNIEnv*,是访问 JVM 相关接口的桥梁;

同样,这里我们也可以在回调函数中增加 Objective-C 对象的指针参数,用来间接访问类成员和方法;

但是,这个 Objective-C 对象指针在 C/C++ 环境下是无法直接使用的,还得借助一个双方都能识别的桥梁 — void*

而 Objective-C 对象指针和 void* 的相互转换,则需要依赖 Bridged Cast:

  • __bridge 可用于类型互转,但是不改变对象的所有权(ownership);

  • __bridge_retained 用于将 Objective-C 对象指针转换为 C/C++ 对象指针,同时获得所有权;

  • __beidge_transfer 用户将 C/C++ 对象指针转换为 Objective-C 对象指针,同时放弃所有权;

其实系统的 Core Foundation 很多地方 也使用到这种类型转换。

最后看个 C++ 调用 Objective-C 的示例:

首先定义接口函数指针类型:

typedef void (*Callback)(void* obj, int x);

Objective-C 类的回调函数中,通过 __bridge_transfervoid* 还原为对象指针:

void funcObjc(void* obj, int x) {
    [(__bridge_transfer Objc*)obj doInObjc:x];
}

@implementation Objc

-(id)init {
    if (self = [super init]) {
        self.callback = funcObjc;
    }
    return self;
}

-(void)doInObjc:(int)x {
    NSLog(@"doInObjc: %d", x);
}

@end

C++ 构造函数接收 Objective-C 传过来的 void* 和函数指针,用于执行该回调函数:

Cxx::Cxx(void* objc, Callback callback) {
    this->objc = objc;
    this->callback = callback;
}

void Cxx::doInCxx(int x) {
    std::cout << "doInCxx: " << x << std::endl;
    this->callback(this->objc, x);
}

最终调用时,将 Objective-C 对象通过 __bridge_retained 转为 void* 传给 C++ 对象:

Objc* objc = [[Objc alloc]init];
void* vp = (__bridge_retained void*)objc;
Cxx* cxx = new Cxx(vp, objc.callback);
cxx->doInCxx(9);

也可以将上面的 __bridge_transfer__bridge_retained 全部改为 __bridge

[完整代码]