找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 2940|回复: 5

【.NET】C#接收C、C++函数直接返回的字符串

[复制链接]

1111

主题

1651

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
244
威望
743 点
宅币
24239 个
贡献
46222 次
宅之契约
0 份
在线时间
2297 小时
注册时间
2014-1-26
发表于 2020-10-6 12:38:46 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

×
翻译原文:https://limbioliong.wordpress.co ... rings-from-a-c-api/
翻译者:0xAA55

1. 介绍

1.1 直接返回字符串的API是很常见的。然而对于这些API的内部实现,以及如何在托管代码环境下调用这些API,有很多不同的情况需要额外注意。

1.2 我将列举多种不同的情况,并介绍其内部的实现原理。

2. 现像之后的原理

2.1 首先我们想要声明和使用一个C++写的API,它具有以下的C++声明:
char* __stdcall StringReturnAPI01();

这个API只是单纯地返回一个空字符结尾的字符串指针(C字符串)。

2.2 需要注意的是在托管代码里面一个C字符串是没有直接的表示方法的。也就是说我们不能直接使用一个“C字符串变量”去接收这个API的返回值并期待CLR有能力把它转换为托管代码。

2.3 托管代码里的字符串并不是简单的字符串,在非托管环境下它可以有很多种形式,比如C字符串(包括ANSI编码和Unicode编码(也包括UTF-8、UTF-16)等)或者BSTR。也就是说,你需要在托管代码里声明这个API的时候指明字符串的形式信息,比如这样:
[DllImport("某DLL.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.LPStr)]
public static extern string StringReturnAPI01();

写成VB.NET则是这样:
<DllImport("某DLL.dll", CharSet:=CharSet.Ansi, CallingConvention:=CallingConvention.StdCall)>
Public Shared Function StringReturnAPI01() As <MarshalAs(UnmanagedType.LPStr)> String
End Function

注意上述声明中的“MarshalAs(UnmanagedType.LPStr)”即表示API返回的字符串类型是一个C字符串。

2.4 经过这样的声明后,这个非托管的C字符串返回值就会被CLR创建一个托管字符串对象。目测它通过使用Marshal.PtrToStringAnsi()方法来接收一个IntPtr并将其转换为String来实现这个效果。

2.5 此处有一个非常重要的概念,内存所有者。这个概念非常重要,它决定了谁负责释放用于存储字符串的内存。现在,StringReturnAPI01()这个API要返回一个字符串,这个字符串此时也被当作是一个“out”的参数,它此时由接收这个字符串的C#(VB.NET,或者严格来说是CLR)接受。

2.6 因为接受了这个返回的字符串,成为其内存所有者,Interop Marshaler有义务释放由这个字符串占用的这块内存。也就是说,这个API返回的C字符串会被释放。

2.7 此处需要注意一个通用的协议:非托管代码分配内存容纳字符串,返回给托管代码,然后托管代码要释放这个字符串占用的内存。对于所有“out”参数,这一条都通用。

2.8 进一步来说:对于非托管代码(C、C++那边)有两种基本的分配内存的方式,可以由C#这边(CLR这边的Interop Marshaler)进行自动的释放。

·CoTaskMemAlloc() 对应 Marshal.FreeCoTaskMem().
·SysAllocString() 对应 Marshal.FreeBSTR().

也就是说,如果非托管代码使用CoTaskMemAlloc()分配的字符串内存返回给了C#,那么CLR会使用Marshal.FreeCoTaskMem()来释放这个内存。

只有当字符串是BSTR的时候,分配释放才是SysAllocString() 对应 Marshal.FreeBSTR()的这一条。这虽然与2.1提到的C字符串无关,但我会介绍。

2.9 注意:非托管方必须不能使用 new 或者 malloc()来分配内存,否则CLR无法释放这样的内存。这是因为 new 的行为取决于编译器,而 malloc()的行为取决于C标准库。CoTaskMemAlloc()和SysAllocString()因为是Windows的API,所以在这里是标准。

另一个需要注意的是虽然有作为Windows的标准API之一的GlobalAlloc()和它在CLR对应的释放内存的方法Marshal.FreeHGlobal(),但Interop Marshaler只会使用Marshal.FreeCoTaskMem()来自动释放由非托管代码创建的C字符串所占用的内存。你不能使用GlobalAlloc()分配内存然后在声明API的地方使用String返回类型来接收。

3. 示例代码

3.1 在这个章节会有各种类型的C++分配字符串和C#接收字符串的情况的例子代码,如何从非托管API接收字符串返回值。

3.2 下例代码是一个C++函数使用CoTaskMemAlloc()分配内存:
extern "C" __declspec(dllexport) char*  __stdcall StringReturnAPI01()
{
        char szSampleString[] = "Hello World";
        ULONG ulSize = strlen(szSampleString) + sizeof(char);
        char* pszReturn = NULL;

        pszReturn = (char*)::CoTaskMemAlloc(ulSize);
        // 把szSampleString里面的字符串拷贝到分配的内存上
        strcpy(pszReturn, szSampleString);
        // 将其返回。传递给CLR托管代码。
        return pszReturn;
}


3.3 C#的声明和调用示范:
[DllImport("某DLL.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.LPStr)]
public static extern string StringReturnAPI01();

static void CallUsingStringAsReturnValue()
{
        string strReturn01 = StringReturnAPI01();
        Console.WriteLine("Returned string : " + strReturn01);
}


3.4 注意参数用的是MarshalAsAttribute : UnmanagedType.LPStr 指示Interop Marshaler将该API返回的字符串视为空字符结尾ANSI编码字符串(C字符串)

3.5 在这背后发生的情况是Interop Marshaler使用该API返回的指针(char*类型)来创建一个托管字符串。目测它使用Marshal.PtrToStringAnsi()来进行这种创建,并使用Marshal.FreeCoTaskMem()来释放上述的非托管的字符串的指针。

4. 使用BSTR

4.1 在这个章节,我会演示如何在非托管代码里分配一个BSTR字符串然后将其返回给托管代码。

4.2 C++代码如下:
extern "C" __declspec(dllexport) BSTR  __stdcall StringReturnAPI02()
{
        return ::SysAllocString((const OLECHAR*)L"Hello World");
}


4.3 C#的声明和调用:
[DllImport("某DLL.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.BStr)]
public static extern string StringReturnAPI02();

static void CallUsingBSTRAsReturnValue()
{
        string strReturn = StringReturnAPI02();
        Console.WriteLine("Returned string : " + strReturn);
}


注意在这个地方参数使用了MarshalAsAttribute : UnmanagedType.BStr 这个告诉Interop Marshaler该API返回的字符串是一个BSTR字符串。

4.4 此时Interop Marshaler会使用这个BSTR来创建字符串,类似于上面目测的使用Marshal.PtrToStringBSTR()进行,并使用Marshal.FreeBSTR()释放该BSTR。

5. Unicode字符串

5.1 返回Unicode字符串就像下面的代码一样其实也很简单。

5.2 C++代码:
extern "C" __declspec(dllexport) wchar_t*  __stdcall StringReturnAPI03()
{
        // 一个常量宽字符串
        wchar_t  wszSampleString[] = L"Hello World";
        ULONG  ulSize = (wcslen(wszSampleString) * sizeof(wchar_t)) + sizeof(wchar_t);
        wchar_t* pwszReturn = NULL;

        pwszReturn = (wchar_t*)::CoTaskMemAlloc(ulSize);
        // 使用wcscpy复制宽字符串到分配的内存里
        wcscpy(pwszReturn, wszSampleString);
        // 将其返回,传递给托管代码
        return pwszReturn;
}


5.3 C#的声明和调用:
[DllImport("某DLL.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.LPWStr)]
public static extern string StringReturnAPI03();

static void CallUsingWideStringAsReturnValue()
{
        string strReturn = StringReturnAPI03();
        Console.WriteLine("Returned string : " + strReturn);
}

其实宽字符串的区别就是你需要使用UnmanagedType.LPWStr的方式来指示。

5.4 Interop Marshaler使用返回的宽字符串指针建立托管字符串。就像上面的使用Marshal.PtrToStringUni()来进行,并使用Marshal.FreeCoTaskMem()释放非托管代码分配的内存。

6. 底层处理例子 1.

6.1 在这一节,我会展示一些代码应该能帮助你把概念都联系起来理解第2章我们提到的底层操作的细节。

6.2 对比之前让Interop Marshaler自动进行非托管代码到托管代码之间的对接和内存的自动释放,我会给出一些代码不让它进行这样的自动化处理。

6.3 此处我会写一个新的API来返回一个C字符串:
extern "C" __declspec(dllexport) char*  __stdcall PtrReturnAPI01()
{
        char   szSampleString[] = "Hello World";
        ULONG  ulSize = strlen(szSampleString) + sizeof(char);
        char*  pszReturn = NULL;

        pszReturn = (char*)::GlobalAlloc(GMEM_FIXED, ulSize);
        // 复制字符串到分配的内存里
        strcpy(pszReturn, szSampleString);
        // 返回给托管代码
        return pszReturn;
}


6.4 以及C#声明:
[DllImport("某DLL.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
public static extern IntPtr PtrReturnAPI01();


注意这一次我使用IntPtr作为返回值,所以并没有“[return : …]”这样的声明,这样Interop Marshaler就管不着了。

6.5 C#的底层调用:
static void CallUsingLowLevelStringManagement()
{
        // 从非托管代码接收字符串指针
        IntPtr pStr = PtrReturnAPI01();
        // 使用指针建立托管的字符串
        string str = Marshal.PtrToStringAnsi(pStr);
        // 释放指针指向的内存
        Marshal.FreeHGlobal(pStr);
        pStr = IntPtr.Zero;
        // 显示字符串
        Console.WriteLine("Returned string : " + str);
}


上述代码模拟了Interop Marshaler接收非托管C风格字符串的过程。PtrReturnAPI01()返回的指针被用于建立托管字符串,然后指针被释放。托管代码部分的字符串变量str存储了复制过来的字符串。

对比Interop Marshaler的做法,上述唯一区别是我们使用了GlobalAlloc()和Marshal.FreeHGlobal()来分配和释放内存。Interop Marshaler自己只会使用Marshal.FreeCoTaskMem()来释放内存。它认为你的C代码传给它的string是用CoTaskMemAlloc()分配的。

7. 底层处理例子 2.

7.1 在这个最终章节,我会演示更多的底层处理字符串的方法,类似于第6章展示的那样。

7.2 我们依然不使用Interop Marshaler来进行内存的释放。事实上,我们并不进行字符串内存的动态分配。

7.3 此处我写个新API它只是简单地返回一个非托管内存里的全局常量字符串
wchar_t gwszSampleString[] = L"Global Hello World";

extern "C" __declspec(dllexport) wchar_t*  __stdcall PtrReturnAPI02()
{
        return gwszSampleString;
}


这个API返回指向DLL全局变量或者常量里的Unicode字符串“gwszSampleString”。因为这是全局内存并且可能会被DLL内的多个函数使用,这块内存是不应被删除掉的。

7.4 C#声明如下:
[DllImport("某DLL.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
public static extern IntPtr PtrReturnAPI02();


同样没有“[return : …]”这样的声明来避免被Interop Marshaler瞎管。返回的就是一个IntPtr。

7.5 然后是示例的C#代码用于托管返回的IntPtr:
static void CallUsingLowLevelStringManagement02()
{
        // 从API接收返回的指针
        IntPtr pStr = PtrReturnAPI02();
        // 使用指针建立字符串
        string str = Marshal.PtrToStringUni(pStr);
        // 显示字符串
        Console.WriteLine("Returned string : " + str);
}


在这里,返回的IntPtr被用于创建托管内存里的字符串,而非托管内存的Unicode字符串则被留下而没有删除。

注意因为返回的是一个IntPtr,你不能通过这个IntPtr直接去判断它是一个ANSI字符串还是Unicode字符串(译者:谁他娘的告诉你的……Notepad++就能做到自动识别文本编码)。事实上,你甚至都完全不能用这个IntPtr去判断这个字符串是不是一个空字符结尾的字符串。你应该知道API那边具体返回的是什么字符串,而不是用代码去检测。

7.6 补充说明,API返回的IntPtr必须不能指向一些临时的字符串存储区域(比如栈上)。如果它存储在栈上,只要这个API返回了,它就被删除了。例子如下:
extern "C" __declspec(dllexport) char* __stdcall PtrReturnAPI03()
{
        char szSampleString[] = "Hello World";
        return szSampleString;
}


当API返回的时候,字符串指针“szSampleString”指向的内存已经被完全擦除或者被填充了别的数据。而这个别的数据通常就完全不包含字符串了。像下面的C#代码就会崩:
IntPtr pStr = PtrReturnAPI03();
// 使用指针建立字符串
string str = Marshal.PtrToStringAnsi(pStr);


参考资料:
Marshal类
https://docs.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.marshal?view=netcore-3.1
回复

使用道具 举报

12

主题

32

回帖

842

积分

用户组: 大·技术宅

UID
5148
精华
2
威望
7 点
宅币
744 个
贡献
30 次
宅之契约
0 份
在线时间
75 小时
注册时间
2019-7-17
发表于 2020-10-6 12:56:00 | 显示全部楼层
A5好厉害!!!
回复

使用道具 举报

55

主题

275

回帖

9352

积分

用户组: 管理员

UID
77
精华
16
威望
237 点
宅币
8217 个
贡献
251 次
宅之契约
0 份
在线时间
254 小时
注册时间
2014-2-22
发表于 2020-10-8 07:24:03 | 显示全部楼层
这种写法相当不清真:
  1. char* __stdcall StringReturnAPI01();
复制代码
如果要返回字符串,应该参考WINAPI的写法,比如GetWindowTextA:
  1. int GetWindowTextA
  2. (
  3.   HWND  hWnd,
  4.   LPSTR lpString,
  5.   int   nMaxCount
  6. );
复制代码
回复 赞! 靠!

使用道具 举报

1111

主题

1651

回帖

7万

积分

用户组: 管理员

一只技术宅

UID
1
精华
244
威望
743 点
宅币
24239 个
贡献
46222 次
宅之契约
0 份
在线时间
2297 小时
注册时间
2014-1-26
 楼主| 发表于 2020-10-8 17:46:15 | 显示全部楼层
美俪女神 发表于 2020-10-8 07:24
这种写法相当不清真:如果要返回字符串,应该参考WINAPI的写法,比如GetWindowTextA: ...


OpenGL表示“我就是要不清真!”
const GLubyte * __stdcall glGetString (GLenum name);
而且还是const unsigned char*的字符串……
回复 赞! 靠!

使用道具 举报

55

主题

275

回帖

9352

积分

用户组: 管理员

UID
77
精华
16
威望
237 点
宅币
8217 个
贡献
251 次
宅之契约
0 份
在线时间
254 小时
注册时间
2014-2-22
发表于 2020-10-9 05:42:42 | 显示全部楼层
0xAA55 发表于 2020-10-8 17:46
OpenGL表示“我就是要不清真!”const GLubyte * __stdcall glGetString (GLenum name);而且还是co ...


这段不清真的代码,是对内存管理之神的亵渎。
回复 赞! 靠!

使用道具 举报

9

主题

177

回帖

1万

积分

用户组: 真·技术宅

UID
4293
精华
6
威望
441 点
宅币
8675 个
贡献
850 次
宅之契约
0 份
在线时间
338 小时
注册时间
2018-9-19
发表于 2020-10-22 11:13:58 | 显示全部楼层
话说,C♯能开启unsafe后,直接声明返回 const char * 不?
回复 赞! 靠!

使用道具 举报

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2024-4-24 05:11 , Processed in 0.042570 second(s), 34 queries , Gzip On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表