• (转)C#调用非托管Win 32 DLL


    转载学习收藏,原文地址http://www.cnblogs.com/mywebname/articles/2291876.html 

    背景

         在项目过程中,有时候你需要调用非C#编写的DLL文件,尤其在使用一些第三方通讯组件的时候,通过C#来开发应用软件时,就需要利用DllImport特性进行方法调用。本篇文章将引导你快速理解这个调用的过程。

    步骤

    1. 创建一个CSharpInvokeCPP的解决方案:

    image

    2. 创建一个C++的动态库项目:

    image

    3. 在应用程序设置中,选择“DLL”,其他按照默认选项:

    image

    最后点击完成,得到如图所示项目:

    image

          我们可以看到这里有一些文件,其中dllmain.cpp作为定义DLL应用程序的入口点,它的作用跟exe文件有个main或者WinMain入口函数是一样的,它就是作为DLL的一个入口函数,实际上它是个可选的文件。它是在静态链接时或动态链接时调用LoadLibrary和FreeLibrary时都会被调用。详细内容可以参考(http://blog.csdn.net/benkaoya/archive/2008/06/02/2504781.aspx)。

    4. 现在我们打开CSharpInvokeCPP.CPPDemo.cpp文件:

    现在我们加入以下内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // CSharpInvokeCPP.CPPDemo.cpp : 定义 DLL 应用程序的导出函数。
    //
     
    #include "stdafx.h"
     
    extern "C" __declspec(dllexport) int Add(int x, int y) 
        return x + y; 
    }
    extern "C" __declspec(dllexport) int Sub(int x, int y) 
        return x - y; 
    }
    extern "C" __declspec(dllexport) int Multiply(int x, int y) 
        return x * y; 
    }
    extern "C" __declspec(dllexport) int Divide(int x, int y) 
        return x / y; 
    }

          extern "C" 包含双重含义,从字面上即可得到:首先,被它修饰的目标是“extern”的;其次,被它修饰的目标是“C”的。而被extern "C"修饰的变量和函数是按照C语言方式编译和连接的。

          __declspec(dllexport)的目的是为了将对应的函数放入到DLL动态库中。

          extern "C" __declspec(dllexport)加起来的目的是为了使用DllImport调用非托管C++的DLL文件。因为使用DllImport只能调用由C语言函数做成的DLL。

    5. 编译项目程序,最后在Debug目录生成CSharpInvokeCPP.CPPDemo.dll和CSharpInvokeCPP.CPPDemo.lib

    image

    我们用反编译工具PE Explorer查看下该DLL里面的方法:

    image

    可以发现对外的公共函数上包含这四种“加减乘除”方法。

    6. 现在来演示下如何利用C#项目来调用非托管C++的DLL,首先创建C#控制台应用程序:

    image

    7. 在CSharpInvokeCSharp.CSharpDemo项目上新建一个CPPDLL类,编写以下代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class CPPDLL
    {
        [DllImport("CSharpInvokeCPP.CPPDemo.dll")]
        public static extern int Add(int x, int y);
     
        [DllImport("CSharpInvokeCPP.CPPDemo.dll")]
        public static extern int Sub(int x, int y);
     
        [DllImport("CSharpInvokeCPP.CPPDemo.dll")]
        public static extern int Multiply(int x, int y);
     
        [DllImport("CSharpInvokeCPP.CPPDemo.dll")]
        public static extern int Divide(int x, int y);
    }

    DllImport作为C#中对C++的DLL类的导入入口特征,并通过static extern对extern “C”进行对应。

    8. 另外,记得把CPPDemo中生成的DLL文件拷贝到CSharpDemo的bin目录下,你也可以通过设置【项目属性】->【配置属性】->【常规】中的输出目录:

    image

    这样编译项目后,生成的文件就自动输出到CSharpDemo中了。

    9. 然后在Main入口编写测试代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    static void Main(string[] args)
    {
        int result = CPPDLL.Add(10, 20);
        Console.WriteLine("10 + 20 = {0}", result);
     
        result = CPPDLL.Sub(30, 12);
        Console.WriteLine("30 - 12 = {0}", result);
     
        result = CPPDLL.Multiply(5, 4);
        Console.WriteLine("5 * 4 = {0}", result);
     
        result = CPPDLL.Divide(30, 5);
        Console.WriteLine("30 / 5 = {0}", result);
     
        Console.ReadLine();
    }

    运行结果:

    image

    方法得到调用。

    10. 以上的方法只能通过静态方法对于C++中的函数进行调用。那么怎样通过静态方法去调用C++中一个类对象中的方法呢?现在我在CPPDemo项目中添加一个头文件userinfo.h:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class UserInfo {
    private:
        char* m_Name;
        int m_Age;
    public:
        UserInfo(char* name, int age) 
       
            m_Name = name; 
            m_Age = age;
        }
        virtual ~UserInfo(){ }
        int GetAge() { return m_Age; }
        char* GetName() { return m_Name; }
    };

    在CSharpInvokeCPP.CPPDemo.cpp中,添加一些代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    #include "malloc.h"
    #include "userinfo.h"
     
    typedef struct {
        char name[32];
        int age;
    } User;  
     
    UserInfo* userInfo;
     
    extern "C" __declspec(dllexport) User* Create(char* name, int age)    
    {   
        User* user = (User*)malloc(sizeof(User));
     
        userInfo = new UserInfo(name, age);
        strcpy(user->name, userInfo->GetName());  
        user->age = userInfo->GetAge();
     
        return user; 
    }

    这里声明一个结构,包括name和age,这个结构是用于和C#方面的结构作个映射。

    注意:代码中的User*是个指针,返回也是一个对象指针,这样做为了防止方法作用域结束后的局部变量的释放。

    strcpy是个复制char数组的函数。

    11. 在CSharpDemo项目中CPPDLL类中补充代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [DllImport("CSharpInvokeCPP.CPPDemo.dll")]
    public static extern IntPtr Create(string name, int age);
     
    [StructLayout(LayoutKind.Sequential)]
    public struct User
    {
        [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
        public string Name;
     
        public int Age;
    }

    其中这里的结构User就和C++中的User对应。

    12. 在Program.cs中补充代码:

    1
    2
    3
    IntPtr ptr = CPPDLL.Create("李平", 27);
    <STRONG><FONT color=#ff0000>CPPDLL.User user = (CPPDLL.User)Marshal.PtrToStructure(ptr, typeof(CPPDLL.User));</FONT></STRONG>
    Console.WriteLine("Name: {0}, Age: {1}", user.Name, user.Age);

    注意:红色字体部分,这里结构指针首先转换成IntPtr句柄,然后通过Marshal.PtrToStructrue转换成你所需要的结构。

    运行结果:

    image

    转自liping13599168/archive/2011/03/31/2000320.html

    以下是调用时的一些类型转换等注意事项:

    C++

    struct HHFC_SET
    {
    char*  UID;
    int     code;
     
    };
     
    extern "C" __declspec(dllexport) int  PReadUID(HHFC_SET* mystruct, char* lpKeyNo, LPTSTR lpKeyNo2 )
    {
           //int a=5;
           CString ds="sea中国";
      //wchar_t a[] = L"sea";
      //lpKeyNo = (LPTSTR)(LPCTSTR)a;
    //lpKeyNo = L'中国a';
    strcpy(lpKeyNo,"中国aabbbcccc");
    wcscpy(lpKeyNo2,L"中国aabbbcccc");
    //lpKeyNo[0] = L'a';
    //lpKeyNo[1] = L'b';
    //lpKeyNo[2] = L'c';
    //lpKeyNo[3] = L'';
    //::MessageBox(NULL, lpKeyNo, L"info", MB_OK);
    //  mystruct->UID=ds.GetBuffer(ds.GetLength()+1);
    mystruct->UID="seaaa中国";
     
     
     
    return 5;
    }
     
    C#
    using System;
    using System.Runtime.InteropServices;
    using System.Text;
     
    namespace PInvoke
    {
        /// <summary>
        /// Ivoke 的摘要说明。
        /// </summary>
        ///
        public class Ivoke
        {
            [DllImport("standerMFC.dll", EntryPoint = "PReadUID", CharSet = CharSet.Ansi)]
            //nPort:1代表COM1,返回-1代表已经打开COM PORT失败,0代表COM已经打开,返回其它值表示打开对应的COM
            public static extern int PReadUID(ref HHFC_SET stru,
                [MarshalAs(UnmanagedType.LPStr)] StringBuilder sb,
                [MarshalAs(UnmanagedType.LPWStr)] StringBuilder sb2);
     
     
        }
        [StructLayout(LayoutKind.Sequential)]
        public struct HHFC_SET
        {
            [MarshalAs(UnmanagedType.LPStr)]
            public String Uid;
     
            [MarshalAs(UnmanagedType.I4)]
            public int code;
     
        }
    }
     
    namespace PInvoke
    {
    /// <summary>
    /// Class1 的摘要说明。
    /// </summary>
    class Class1
    {
    /// <summary>
    /// 应用程序的主入口点。
    /// </summary>
    [STAThread]
    static void Main(string[] args)
    {
    //
    // TODO: 在此处添加代码以启动应用程序
    //
     
    HHFC_SET stru=new HHFC_SET ();
     
    stru.Uid="";
     
           Console.WriteLine(stru.Uid);
                StringBuilder sb = new StringBuilder(80);
                StringBuilder sb2 = new StringBuilder(80);
                int a = Ivoke.PReadUID(ref stru, sb, sb2);
                //Ivoke.GetUsbkeyNo();
    Console.WriteLine(stru.Uid);
     
     
     
    Console.Read();
    }
    }
    }
    注意:
    char*  用 UnmanagedType.LPStr
    wchar_t* 用 UnmanagedType.LPWStr
     
    C#中调用非托管的DLL及参数传递本篇文章来源于:开发学院 http://edu.codepub.com   原文链接:http://edu.codepub.com/2010/0531/23111.php
    微软的.NET框架的优点之一是它提供了独立于语言的开发平台。你可以在VB、C++、C#等语言中编写一些类,而在其它语言中使用(源于.NET中使用了CLS),你甚至可以从另一种语言编写的类中继承。但是你要是想调用以前的非托管DLL,
    微软的.NET框架的优点之一是它提供了独立于语言的开发平台。你可以在VB、C++、C#等语言中编写一些类,而在其它语言中使用(源于.NET中使用了CLS),你甚至可以从另一种语言编写的类中继承。但是你要是想调用以前的非托管DLL,那又会怎么样呢?你必须以某种方式将.NET对象转换为结构体、char *、函数指针等类型。这也就是说,你的参数必须被marshal(注:不知道中文名称该叫什么,英文中指的是为了某个目的而组织人或事物,参见这里,此处指的是为了调用非托管函数而进行的参数转换)。
     
        C#中使用DLL函数之前,你必须使用DllImport声明要调用的函数:
     
    public class Win32 {
      [DllImport("User32.Dll")]
      public static extern void SetWindowText(int h, String s);
      // 函数原型为:BOOL SetWindowText(HWND hWnd, LPCTSTR lpString);
    }    DllImport告诉编译器被调函数的入口在哪里,并且将该入口绑定到类中你声明的函数。你可以给这个类起任意的名字,我给它命名为Win32。你甚至可以将类放到命名空间中,具体参见图一。要编译Win32API.cs,输入:
     
    csc /t:library /out:Win32API.dll Win32API.cs    这样你就拥有了Win32API.dll,并且你可以在任意的C#项目中使用它:
     
    using Win32API;
    int hwnd = // get it...
    String s = "I'm so cute."
    Win32.SetWindowText(hwnd, s);    编译器知道去user32.dll中查找函数SetWindowText,并且在调用前自动将String转换为LPTSTR (TCHAR*)。很惊奇是吧!那么.NET是如何做到的呢?每种C#类型有一个默认的marshal类型,String对应LPTSTR。但你若是试着调用GetWindowText会怎么样呢(此处字符串作为out参数,而不是in参数)?它无法正常调用,是因为String是无法修改的,你必须使用StringBuilder:
     
    using System.Text; // for StringBuilder
    public class Win32 {
      [DllImport("user32.dll")]
      public static extern int GetWindowText(int hwnd,
        StringBuilder buf, int nMaxCount);
      // 函数原型:int GetWindowText(HWND hWnd, LPTSTR lpString, int nMaxCount);
    }    StringBuilder默认的marshal类型是LPTSTR,此时GetWindowText可以修改你的字符串:
     
    int hwnd = // get it...
    StringBuilder cb = new StringBuilder(256);
    Win32.GetWindowText(hwnd, sb, sb.Capacity);    如果默认的类型转换无法满足你的要求,比如调用函数GetClassName,它总是将参数转换为类型LPSTR (char *),即便在定义Unicode的情况下使用,CLR仍然会将你传递的参数转换为TCHAR类型。不过不用着急,你可以使用MarshalAs覆盖掉默认的类型:
     
    [DllImport("user32.dll")]
    public static extern int GetClassName(int hwnd,
      [MarshalAs(UnmanagedType.LPStr)] StringBuilder buf,
      int nMaxCount);
      // 函数原型:int GetClassNameA(HWND hWnd, LPTSTR lpClassName, int nMaxCount);    这样当你调用GetClassName时,.NET将字符串作为ANSI字符传递,而不是宽字符。
     
        结构体和回调函数类型的参数又是如何传递的呢?.NET有一种方法可以处理它们。举个简单的例子,GetWindowRect,这个函数获取窗口的屏幕坐标,C++中我们这么处理:
     
    // in C/C++
    RECT rc;
    HWND hwnd = FindWindow("foo",NULL);
    ::GetWindowRect(hwnd, &rc);   你可以使用C#结构体,只需使用另外一种C#属性StructLayout:
     
    [StructLayout(LayoutKind.Sequential)]
    public struct RECT {
      public int left;
      public int top;
      public int right;
      public int bottom;
    }    一旦你定义了上面的结构体,你可以使用下面的函数声明形式 :
     
    [DllImport("user32.dll")]
    public static extern int
      GetWindowRect(int hwnd, ref RECT rc);
      // 函数原型:BOOL GetWindowRect(HWND hWnd, LPRECT lpRect);
        使用ref标识很重要,以至于CLR(通用语言运行时)将RECT变量作为引用传递到函数中,而不是无意义的栈拷贝。定义了GetWindowRect之后,你就可以采用下面的方式调用:
     
    RECT rc = new RECT();
    int hwnd = // get it ...
    Win32.GetWindowRect(hwnd, ref rc);    注意你同样需要像声明中的那样使用ref关键字。C#结构体默认的marshal类型是LPStruct,因此没有必要使用MarshalAs。但如果你使用了类RECT而不是结构体RECT,那么你必须使用如下的声明形式:
     
    // if RECT is a class, not struct
    [DllImport("user32.dll")]
    public static extern int
      GetWindowRect(int hwnd,
        [MarshalAs(UnmanagedType.LPStruct)] RECT rc);    C#和C++一样,一件事情有很多中实现方式。System.Drawing中已经有Rectangle结构体,用来处理矩形,那有为什么要“重新发明轮子”呢?
     
    [DllImport("user32.dll")]
    public static extern int GetWindowRect(int hwnd, ref Rectangle rc);    最后,又是怎样从C#中传递回调函数到非托管代码中的呢?你所要做的就是委托(delegate)。
     
    delegate bool EnumWindowsCB(int hwnd, int lparam);    一旦你声明了你的回调函数,那么你需要调用的函数声明为:
     
    [DllImport("user32")]
    public static extern int
      EnumWindows(EnumWindowsCB cb, int lparam);
      // 函数原型:BOOL EnumWindows(WNDENUMPROC lpEnumFunc, LPARAM lParam);
        由于上面的委托仅仅是声明了委托类型,你需要在你的类中提供实际的回调函数代码。
     
    // in your class
    public static bool MyEWP(int hwnd, int lparam) {
      // do something
      return true;
    }    然后传递给相应的委托变量:
     
    EnumWindowsCB cb = new EnumWindowsCB(MyEWP);
    Win32.EnumWindows(cb, 0);    你可能注意到参数lparam。在C语言中,如果你传递参数LPARAM给EnumWindows,Windows将它作为参数调用你的回调函数。通常lparam是包含了你要做的事情的上下文结构体或类指针,记住,在.NET中没有指针的概念!那该怎么做呢?上面的例子中,你可以申明lparam为IntPtr类型,并且使用GCHandle来封装它:
     
    // lparam is IntPtr now
    delegate bool EnumWindowsCB(int hwnd,     IntPtr lparam);
    // wrap object in GCHandle
    MyClass obj = new MyClass();
    GCHandle gch = GCHandle.Alloc(obj);
    EnumWindowsCB cb = new EnumWindowsCB(MyEWP);
       Win32.EnumWindows(cb, (IntPtr)gch);
       gch.Free();    不要忘了使用完之后手动释放它!有时,你需要按照以前那种方式在C#中释放内存。可以使用GCHandle.Target的方式在你的回调函数中使用“指针”。
     
    public static bool MyEWP(int hwnd, IntPtr param) {
      GCHandle gch = (GCHandle)param;
      MyClass c = (MyClass)gch.Target;
      // ... use it
      return true;
    }      图2是将EnumWindows封装到数组中的类。你只需要按如下的方式使用即可,而不要纠结于委托和回调中。
     
    WindowArray wins = new WindowArray();
    foreach (int hwnd in wins) {
    // do something
    }    关于委托代码和非委托代码之间交互的更多内容,你可以参考.NET文档中的“平台调用教程”。
     

    C#调用dll时的类型转换总结


    C++(Win 32)

    C#

    char**

    作为输入参数转为char[]通过Encoding类对这个string[]进行编码后得到的一个char[]

    作为输出参数转为byte[]通过Encoding类对这个byte[]进行解码,得到字符串

    C++ Dll接口:

    void CplusplusToCsharp(in char** AgentID, out char** AgentIP);

    C#中的声明:

    [DllImport("Example.dll")]

    public static extern void CplusplusToCsharp(char[] AgentID, byte[] AgentIP);

    C#中的调用:

    Encoding encode = Encoding.Default;

    byte[] tAgentID;

    byte[] tAgentIP;

    string[] AgentIP;

    tAgentID = new byte[100];

    tAgentIP = new byte[100];

    CplusplusToCsharp(encode.GetChars(tAgentID), tAgentIP);

    AgentIP[i] = encode.GetString(tAgentIP,i*Length,Length);

    Handle

    IntPtr

    Hwnd

    IntPtr

    int*

    ref int

    int&

    ref int

    void*

    IntPtr

    unsigned char*

    ref byte

    BOOL

    bool

    DWORD

    int uintint 更常用一些)

    枚举类型

    Win32

    BOOL MessageBeep(UINT uType // 声音类型); 其中的声音类型为枚举类型中的某一值。

    C#

    用户需要自己定义一个枚举类型:

    public enum BeepType

    {

      SimpleBeep = -1,

      IconAsterisk = 0x00000040,

      IconExclamation = 0x00000030,

      IconHand = 0x00000010,

      IconQuestion = 0x00000020,

      Ok = 0x00000000,

    }

    C#中导入该函数:

    [DllImport("user32.dll")]

    public static extern bool MessageBeep(BeepType beepType);

    C#中调用该函数:

    MessageBeep(BeepType.IconQuestion);

    结构类型

    Win32

    使用结构指针作为参数的函数:

    BOOL GetSystemPowerStatus(

     LPSYSTEM_POWER_STATUS lpSystemPowerStatus

    );

    Win32中该结构体的定义:

    typedef struct _SYSTEM_POWER_STATUS {

    BYTE  ACLineStatus;

    BYTE  BatteryFlag;

    BYTE  BatteryLifePercent;

    BYTE  Reserved1;

    DWORD BatteryLifeTime;

    DWORD BatteryFullLifeTime;

    } SYSTEM_POWER_STATUS, *LPSYSTEM_POWER_STATUS;

    C#

    用户自定义相应的结构体:

    struct SystemPowerStatus

    {

      byte ACLineStatus;

      byte batteryFlag;

      byte batteryLifePercent;

      byte reserved1;

      int batteryLifeTime;

      int batteryFullLifeTime;

    }

    C#中导入该函数:

    [DllImport("kernel32.dll")]

    public static extern bool GetSystemPowerStatus(

      ref SystemPowerStatus systemPowerStatus);

    C#中调用该函数:

    SystemPowerStatus sps;

    ….sps初始化赋值……

    GetSystemPowerStatus(ref sps);

    字符串

    对于字符串的处理分为以下几种情况:

    1、  字符串常量指针的处理(LPCTSTR),也适应于字符串常量的处理,.net中的string类型是不可变的类型。

    2、  字符串缓冲区的处理(char*),即对于变长字符串的处理,.netStringBuilder可用作缓冲区

    Win32

    BOOL GetFile(LPCTSTR lpRootPathName);

    C#

    函数声明:

    [DllImport("kernel32.dll", CharSet = CharSet.Auto)]

    static extern bool GetFile (

     [MarshalAs(UnmanagedType.LPTStr)]

     string rootPathName);

    函数调用:

    string pathname;

    GetFile(pathname);

    备注:

    DllImport中的CharSet是为了说明自动地调用该函数相关的Ansi版本或者Unicode版本

     

    变长字符串处理:

    C#

    函数声明:

    [DllImport("kernel32.dll", CharSet = CharSet.Auto)]

    public static extern int GetShortPathName(

      [MarshalAs(UnmanagedType.LPTStr)]

      string path,

      [MarshalAs(UnmanagedType.LPTStr)]

      StringBuilder shortPath,

      int shortPathLength);

    函数调用:

    StringBuilder shortPath = new StringBuilder(80);

    int result = GetShortPathName(

    @"d: est.jpg", shortPath, shortPath.Capacity);

    string s = shortPath.ToString();

    struct

    具有内嵌字符数组的结构:

    Win32

    typedef struct _TIME_ZONE_INFORMATION {

      LONG    Bias;

      WCHAR   StandardName[ 32 ];

      SYSTEMTIME StandardDate;

      LONG    StandardBias;

      WCHAR   DaylightName[ 32 ];

      SYSTEMTIME DaylightDate;

      LONG    DaylightBias;

    } TIME_ZONE_INFORMATION, *PTIME_ZONE_INFORMATION;

    C#

    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]

    struct TimeZoneInformation

    {

      public int bias;

      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]

      public string standardName;

      SystemTime standardDate;

      public int standardBias;

      [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]

      public string daylightName;

      SystemTime daylightDate;

      public int daylightBias;

    }

    具有回调的函数

    Win32

    BOOL EnumDesktops(

     HWINSTA hwinsta,       // 窗口实例的句柄

     DESKTOPENUMPROC lpEnumFunc, // 回调函数

     LPARAM lParam        // 用于回调函数的值

    );

    回调函数DESKTOPENUMPROC的声明:

    BOOL CALLBACK EnumDesktopProc(

     LPTSTR lpszDesktop, // 桌面名称

     LPARAM lParam    // 用户定义的值

    );

    C#

    将回调函数的声明转化为委托:

    delegate bool EnumDesktopProc(

     [MarshalAs(UnmanagedType.LPTStr)]

      string desktopName,

      int lParam);

    该函数在C#中的声明:

    [DllImport("user32.dll", CharSet = CharSet.Auto)] static extern bool EnumDesktops(   IntPtr windowStation,   EnumDesktopProc callback,   int lParam);

    该表对C#中调用win32函数,以及c++编写的dll时参数及返回值的转换做了一个小的总结,如果想进一步了解这方面内容的话,可以参照msdn中“互操作封送处理”一节。

  • 相关阅读:
    《分布式之数据库缓存双写一致性方案解析》
    淘系工程师讲解的使用Spring特性优雅书写业务代码
    简述BIO/NIO/AIO前世今生
    经典面试题:分布式缓存热点KEY问题如何解决有赞方案
    软件中的文本本地化
    Java Instant\Date\LocalDateTime\Calendar\ZonedDateTime转化
    java反序列化漏洞专项
    web打印样式预览调试技巧
    公钥私钥 与 http/https
    docker安装mysql
  • 原文地址:https://www.cnblogs.com/hhhh2010/p/3614089.html
Copyright © 2020-2023  润新知