翻译原文: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 |