cgo调用c动态库实战
这篇从一个需求开始说起。这个需求也很简单,就是需要为某个硬件加密算法封装一个接口,接口的逻辑是传入唯一标识UID,能生成加密后的加密串。
这篇从一个需求开始说起。这个需求也很简单,就是需要为某个硬件加密算法封装一个接口,接口的逻辑是传入唯一标识UID,能生成加密后的加密串。
但是麻烦的是,这个加密算法是另外一个部门使用C开发的。而Web是Golang开发的。所以这个情况,自然就想到了用上cgo。
“硬件部门提供一个c编译的动态库,web服务方通过cgo来加载动态库,调用动态库中的接口,生成加密串。”
这样通过cgo连接的好处:
1 web开发方无需了解任何加密算法逻辑,保证了加密算法的安全。
2 各司其职,硬件部门负责算法开发,web部门负责封装实现。
那怎么使用cgo来加载动态库呢?这里记录一下。
假设硬件部门提供的接口如下:
int Producer(int in_encrypt_method,
const unsigned char* in_uid,
unsigned int in_uid_len,
unsigned char** out_chip_key,
unsigned int* out_chip_key_len);
还有一个动态库so文件 ChipSdk.so,和头文件 chip_key_producer.h。
而要用上这个动态库的接口,我们弄明白两个事情就可以了:如何加载动态库, 和如何转换数据结构。
加载动态库
cgo是go和c的桥梁,它的功能其实很强大,比如,在c中调用go写的函数,在go中调用c写的函数。我们这里就只是用了一种:在go中调用c写的动态库。
据我了解,go中调用c写的动态库至少有两种办法。
第一种,将动态库放在lib目录中,使用C编译器选项让go程序能找到这个lib库和头文件。
其中C编译器选项CFLAGS和LDFLAGS两个选项。其中CFLAGS标识头文件的路径。即
// #cgo CFLAGS: -I/home/jianfengye/chipkey/
// #include "chip_key_producer.h"
表示去/home/jianfengye/chipkey/这个目录加载"chip_key_producer.h"的头文件。
而LDFLAGS标识去哪里读取动态库,读取哪个动态库,比如
// #cgo LDFLAGS: -L/home/jianfengye/chipkey -lChipSdk
表示读取 /home/jianfengye/chipkey/ChipSdk.so
// #cgo CFLAGS: -I/home/jianfengye/chipkey/
// #cgo LDFLAGS: -L/home/jianfengye/chipkey -lChipSdk
// #include "chip_key_producer.h"
import "C"
import (
"encoding/base64"
"errors"
"fmt"
...
)
// EncryptChipKey 获取密钥
func EncryptChipKey(uid string, typ int) ([]byte, error) {
...
result, err := C.Producer(C.int(typ), ucharPtr, ulenPtr, outKeyPtr, outKeyLenPtr)
if result != 0 || err != nil{
return nil, errors.New(fmt.Sprintf("ProducerCall return %v, err: %v", result, err))
}
...
}
上面的代码例子就表示了加载ChipSdk.so动态库,header头文件未chip_key_producer.h。使用的时候调用其中的Producer函数。
第二种,使用动态dlopen的方式来加载动态库。
dlopen 能把一个动态库加载到内存中,返回一个handler,这个handler可以通过dlsym来加载动态库中的函数符号。代码如如
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
typedef int (*Producer)(int,
const unsigned char*,
unsigned int,
unsigned char**,
unsigned int*); // function pointer type
int ProducerCall(void* f, int in_encrypt_method,
const unsigned char* in_uid,
unsigned int in_uid_len,
unsigned char** out_chip_key,
unsigned int* out_chip_key_len) { // wrapper function
return ((Producer) f)(in_encrypt_method, in_uid, in_uid_len, out_chip_key, out_chip_key_len);
}
*/
import "C"
import (
"encoding/base64"
"encoding/hex"
"errors"
...
)
func EncryptKey(uid string, typ int) ([]byte, error) {
...
handle := C.dlopen(C.CString("/home/jianfengye/chipkey/ChipSdk.so"), C.RTLD_LAZY)
funcPtr := C.dlsym(handle, C.CString("Producer"))
...
result, err := C.ProducerCall(funcPtr, C.int(typ), ucharPtr, ulenPtr, outKeyPtr, outKeyLenPtr)
if result != 0 || err != nil {
return nil, errors.New(fmt.Sprintf("ProducerCall return %v, err: %v", result, err))
}
...
}
我们可以看到,实际上我们用注释的方式自己写了一个函数ProducerCall,它的第一个参数是void*f 表示的是函数符号,Producer,就是C.dlsym和C.dlopen获取出来的函数符号。
在go代码中,我们实际上是调用了C.ProducerCall这个函数。
类型转换
加载动态库的方式使用上面两种方式任意一种都是可以的,但是类型转换,就是统一的了。我们都要先把go中的某些类型转换为c中的某些类型,同时要把函数调用返回的c中的某些类型再变化成go中的类型。
我们先分析下Producer这个接口的参数,这是标准的c接口,其中的参数中输入有3个,一个是encrypt_method标识加密类型,另外两个in_uid 和 in_uid_len 表示的实际是一个字符串,表示的是输入的设备的唯一标识。而输出的两个参数,out_chip_key 和 out_chip_key_len 也是表示一个字符串,分别为这个字符串的首地址指针和长度指针。
这里主要参考柴大的 https://chai2010.cn/advanced-go-programming-book/ch2-cgo/ch2-03-cgo-types.html 类型转换一篇。
输入转换
先看输入参数,在go中,我们有的是一个string,如何把string变成unsigned char*和unsigned int呢?
首先和unsigned char* 对应的结构是 uint8数组
所以我们先把string变成uint8数组。
uidUint8 := make([]uint8, 0, len(uid))
for i := 0; i < len(hexUid); i++ {
uidUint8 = append(uidUint8, uint8(uid[i]))
}
然后,我们从uint8的slice结构中可以获取到指针信息和lenth信息。
var arr0Hdr = (*reflect.SliceHeader)(unsafe.Pointer(&uidUint8))
ucharPtr := (*C.uchar)(unsafe.Pointer(arr0Hdr.Data))
ulenPtr := C.uint(uint(arr0Hdr.Len))
这段就有点绕,首先将uidUint8这个slice变化成reflect.SliceHeader结构,然后从这个结构中获取到Data和Len字段。这两个字段是slice结构的内部字段。
再接着,将Data结构通过unsafe.Pointer,转换为*C.uchar,也就是c中的uchar*。
同样,把Len结构转换为C.uint,也就是c中的uint结构。
这样ucharPtr和ulenPtr就是C中可以直接使用的数据结构了。
输出转换
接着看输出转换,我们从c函数中获取到的是unsigned char** out_chip_key
和 unsigned int* out_chip_key_len
实际上我们想要的是返回[]byte,那么我们怎么做呢?
同样我们也想到,slice数组都是(*reflect.SliceHeader)结构,那么我们创建一个[]byte,转化为SliceHeader结构,然后将这个sliceHeader结构的Data和Len的指针传递到c函数中,c函数就会修改这两个指针指向的地址,从而达到将[]byte赋值的效果。
outKey := make([]uint8, 0, 0)
var outKeyHdr = (*reflect.SliceHeader)(unsafe.Pointer(&outKey))
outKeyPtr := (**C.uchar)(unsafe.Pointer(outKeyHdr.Data))
outKeyLenPtr := (*C.uint)(unsafe.Pointer(&outKeyHdr.Len))
result, err := C.ProducerCall(funcPtr, C.int(typ), ucharPtr, ulenPtr, outKeyPtr, outKeyLenPtr)
if result != 0 || err != nil {
return nil, errors.New(fmt.Sprintf("ProducerCall return %v, err: %v", result, err))
}
outKeyLen := uint32(*outKeyLenPtr)
outKeyHdr.Data = uintptr(unsafe.Pointer(*outKeyPtr))
outKeyHdr.Len = int(outKeyLen)
outKeyHdr.Cap = int(outKeyLen)
var ret []byte
for i := 0; i < int(outKeyLen); i++ {
ret = append(ret, byte(outKey[i]))
}
这里我们创建了一个[]uint8的数组,然后将对应的Data,Len的指针通过unsafe.Pointer的方式转换成对应的C的指针结构。
然后传递进入C的函数,当c函数对指针赋值后,我们再将 (**C.uchar) 指向的 *uchar的地址转换为uintptr赋值给Data。
同时把(*C.uint)指向的C.uint赋值给Len和Cap。
这样C函数返回的uchar* 就指向给了[]uint8了。
最后我们再把[]uint8变成[]byte就行了。
总而言之,不管是输出转换还是输入转换,这里就是两个原则:
1 基本类型通过 C.xxx来进行互相转换
2 指针类型通过unsafe.Pointer进行互相转换
总结
在生产过程中,还要记得cgo也有可能panic,最好在调用cgo的前后记得recover。
基本上弄懂了cgo的加载机制和类型转换两个逻辑和原理,cgo就算入门了。网上对cgo的使用介绍文章确实不多,不过go编译器集成cgo已经集成很不错了,一旦你的类型转换不符合要求,编译的时候都会有很明确的错误指示了。
cgo就像是粘合剂,将golang和c连接起来了。
Hi,我是轩脉刃,一个名不见经传码农,体制内的小愤青,躁动的骚年,2022年想坚持写一些学习/工作/思考笔记,谓之倒逼学习。欢迎关注个人公众号:轩脉刃的刀光剑影。
推荐阅读