cgo调用c动态库实战

共 8022字,需浏览 17分钟

 ·

2022-07-13 00:40

这篇从一个需求开始说起。这个需求也很简单,就是需要为某个硬件加密算法封装一个接口,接口的逻辑是传入唯一标识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_keyunsigned 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年想坚持写一些学习/工作/思考笔记,谓之倒逼学习。欢迎关注个人公众号:轩脉刃的刀光剑影。



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

浏览 47
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报