问题

C#调用C++DLL函数,一般怎么封装这个DLL?

回答
在C中调用C++ DLL,核心在于“桥梁”的搭建——如何让C的托管环境理解并操作C++的非托管代码。这不仅仅是简单的函数调用,更涉及到数据类型的转换、内存管理,以及对C++导出函数的一些约定。下面我们就来详细聊聊这个过程,避免那些生硬的AI式描述。

理解C++ DLL的导出

首先,我们需要明确C++ DLL导出了哪些函数。C++的函数默认是“name mangling”的,也就是说,编译器会根据函数的参数、返回类型等信息给函数生成一个独一无二的名字,这使得C++函数在链接时能够被正确找到。然而,在C中,我们不关心这些“mangled”的名字,我们更希望直接通过函数名来调用。

因此,C++ DLL的作者需要在导出函数时,告知编译器不要对其进行name mangling,并且将其暴露给外部链接。这通常是通过`extern "C"`来完成的。

例如,一个简单的C++ DLL可能长这样:

```cpp
// MyCppDll.cpp
include

// 确保函数名不会被 mangling,并且可以从外部调用
extern "C" __declspec(dllexport) int AddNumbers(int a, int b) {
return a + b;
}

extern "C" __declspec(dllexport) void DisplayMessage(const char message) {
std::cout << "Message from C++ DLL: " << message << std::endl;
}
```

这里的 `__declspec(dllexport)` 是Microsoft Visual C++特有的关键字,用于声明要导出函数。对于GCC或Clang等编译器,可能会使用`__attribute__((dllexport))`。关键在于 `extern "C"`,它告诉C++编译器使用C语言的函数链接约定,这样C就能找到函数了。

C中的PInvoke (Platform Invoke)

C提供了PInvoke(Platform Invoke)机制,这就像一个翻译官,帮助C代码与非托管DLL中的函数进行交互。PInvoke的核心是`DllImport`属性。

在C端,我们需要定义一个与C++导出函数签名完全匹配的委托(delegate)或者直接声明一个`static extern`方法。

1. 声明`static extern`方法

这是最直接的方式。我们需要创建一个C类(通常是`static`类),然后使用`[DllImport]`属性来指定DLL的名称,并在类中声明与C++函数对应的`static extern`方法。

```csharp
// MyCppDllWrapper.cs
using System;
using System.Runtime.InteropServices;

public static class MyCppDllWrapper
{
// 指定DLL的名称
// 注意:如果DLL不在当前执行目录下,可能需要提供完整路径或配置PATH环境变量
private const string DllName = "MyCppDll.dll";

// 声明C++的AddNumbers函数
// EntryPoint可以指定DLL中实际的函数名(如果和C方法名不同),但在这里它们是相同的
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int AddNumbers(int a, int b);

// 声明C++的DisplayMessage函数
// 对于字符串,C的string默认会被MarshalAs成LPWSTR (UTF16 Unicode),
// 而C++的const char 是ANSI字符串。我们需要明确指定MarshalAs。
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.LPStr)] // C++返回C风格字符串时需要,这里C++没有返回,只是输入
public static extern void DisplayMessage([MarshalAs(UnmanagedType.LPStr)] string message);

// 如果C++函数返回一个char,我们这样声明:
// [DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
// [return: MarshalAs(UnmanagedType.LPStr)]
// public static extern IntPtr GetStringFromCpp(); // 返回IntPtr,然后手动转换
}
```

关键点解释:

`[DllImport(DllName, ...)]`: 这是核心。`DllName`是C++ DLL的文件名。
`CallingConvention = CallingConvention.Cdecl`: 这是非常重要的一个参数。它指定了C++函数使用的函数调用约定。`Cdecl`是C和C++中最常见的调用约定,由调用者负责清理堆栈。不同的编译器、不同的C++函数声明可能会使用不同的调用约定(如`StdCall`, `FastCall`等)。如果你不确定,`Cdecl`通常是首选,尤其是在使用`extern "C"`的情况下。如果调用约定不匹配,会导致程序崩溃或出现不可预测的行为。
`public static extern int AddNumbers(int a, int b);`:
`public static`: PInvoke方法必须是`static`。
`extern`: 声明这是一个外部函数。
参数和返回类型: C的`int`可以直接映射到C++的`int`。
`[MarshalAs(UnmanagedType.LPStr)] string message`:
数据类型映射: C的`string`默认情况下,PInvoke会尝试将其封送(marshal)为`LPWSTR`(宽字符字符串,UTF16)。然而,C++的`const char`通常是指ANSI(单字节字符集)字符串。`[MarshalAs(UnmanagedType.LPStr)]`告诉PInvoke将C的`string`封送为ANSI字符串,这与C++的`const char`兼容。
`[return: MarshalAs(UnmanagedType.LPStr)]`: 如果C++函数返回的是一个C风格的字符串(`const char`),我们需要用这个属性来告诉PInvoke如何处理返回的指针。返回的通常是`IntPtr`,然后你需要将其转换为C的`string`。
`IntPtr`: 当C++函数返回指针(如 `char`, `void`)或者接受指针作为参数时,C通常使用`IntPtr`来表示这些非托管内存地址。然后,需要使用`Marshal`类中的方法(如 `Marshal.PtrToStringAnsi`, `Marshal.PtrToStringUni`, `Marshal.ReadByte`, `Marshal.Copy` 等)来处理这些内存。

2. 使用委托 (Delegate)

有时候,你可能想更灵活地管理函数的生命周期,或者在运行时动态加载DLL和选择函数。这时,使用委托会更方便。

首先,定义一个与C++函数签名匹配的委托:

```csharp
// MyCppDllWrapper.cs (继续)
using System;
using System.Runtime.InteropServices;

public static class MyCppDllWrapper
{
private const string DllName = "MyCppDll.dll";

// 定义委托类型
public delegate int AddNumbersDelegate(int a, int b);
public delegate void DisplayMessageDelegate(string message);

// 使用DllImport获取函数的指针
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr GetAddNumbersProc();

[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr GetDisplayMessageProc();

// 包装方法,创建委托实例
public static AddNumbersDelegate GetAddNumbers()
{
IntPtr funcPtr = GetAddNumbersProc();
return (AddNumbersDelegate)Marshal.GetDelegateForFunctionPointer(funcPtr, typeof(AddNumbersDelegate));
}

public static DisplayMessageDelegate GetDisplayMessage()
{
IntPtr funcPtr = GetDisplayMessageProc();
return (DisplayMessageDelegate)Marshal.GetDelegateForFunctionPointer(funcPtr, typeof(DisplayMessageDelegate));
}
}
```

在C++ DLL中,你需要提供获取函数指针的函数:

```cpp
// MyCppDll.cpp (续)
// ... (前面的代码)

extern "C" __declspec(dllexport) int AddNumbers(int a, int b) {
return a + b;
}

extern "C" __declspec(dllexport) void DisplayMessage(const char message) {
std::cout << "Message from C++ DLL: " << message << std::endl;
}

// 提供获取函数指针的函数
extern "C" __declspec(dllexport) FARPROC GetAddNumbersProc() {
return (FARPROC)AddNumbers;
}

extern "C" __declspec(dllexport) FARPROC GetDisplayMessageProc() {
return (FARPROC)DisplayMessage;
}
```

然后,在C中使用:

```csharp
// Program.cs
using System;

class Program
{
static void Main(string[] args)
{
try
{
// 直接调用方式
int sum = MyCppDllWrapper.AddNumbers(5, 7);
Console.WriteLine($"Sum from C++: {sum}");
MyCppDllWrapper.DisplayMessage("Hello from C!");

// 使用委托调用方式
// MyCppDllWrapper.AddNumbersDelegate addDelegate = MyCppDllWrapper.GetAddNumbers();
// int sumDelegate = addDelegate(10, 20);
// Console.WriteLine($"Sum from C++ (delegate): {sumDelegate}");
//
// MyCppDllWrapper.DisplayMessageDelegate displayDelegate = MyCppDllWrapper.GetDisplayMessage();
// displayDelegate("Another message via delegate.");
}
catch (DllNotFoundException)
{
Console.WriteLine("Error: MyCppDll.dll not found. Make sure it's in the execution path.");
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred: {ex.Message}");
}
}
}
```

处理复杂数据类型

当C++函数涉及到更复杂的数据类型,比如结构体(struct)、数组、指针等,封送(marshalling)就变得更加重要和复杂。

1. 结构体 (Structs)

C的`struct`和C++的`struct`在内存布局上可能不尽相同。PInvoke允许我们通过`[StructLayout]`属性来控制C结构体的内存布局,使其与C++的结构体匹配。

假设C++中有如下结构体:

```cpp
// MyCppDll.cpp (续)
struct Point {
int x;
int y;
};

extern "C" __declspec(dllexport) void MovePoint(Point p, int dx, int dy) {
if (p) {
p>x += dx;
p>y += dy;
}
}
```

在C中,我们需要这样定义对应的结构体:

```csharp
// MyCppDllWrapper.cs (继续)
[StructLayout(LayoutKind.Sequential, Pack = 4)] // Pack=4 通常与C++默认对齐方式匹配
public struct Point
{
public int x;
public int y;
}

// ... 在MyCppDllWrapper类中声明方法
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern void MovePoint([In, Out] ref Point p, int dx, int dy);
// 或者传入指针:
// public static extern void MovePoint(IntPtr p, int dx, int dy);
```

关键点解释:

`[StructLayout(LayoutKind.Sequential, Pack = 4)]`:
`LayoutKind.Sequential`: 表示结构体的成员按照它们在C中声明的顺序在内存中排列。
`Pack = 4`: 指定了内存对齐的字节数。C++编译器通常会进行字节对齐以提高访问效率,`Pack=4` 尝试模仿这种行为。如果C++结构体使用了特定的 `pragma pack` 指令,则这里的 `Pack` 值需要与之对应。
`[In, Out] ref Point p`:
`ref Point p`: 将C的`Point`结构体通过引用传递给C++函数。PInvoke会在调用前将C结构体复制到非托管内存中,然后将该内存地址传递给C++函数。C++函数可以修改该内存中的数据。
`[In, Out]`: `In` 表示数据从C流入C++,`Out` 表示数据可能从C++流出回C。对于 `ref` 参数,这是隐式的,但明确写出可以提高代码可读性。
如果C++函数只接收一个指针 (`Point p`) 并且不修改它,可以使用 `[In] Point p`(值传递,PInvoke会复制)。如果C++函数会修改它并且 C 只需要接收修改后的值,可以使用 `[Out] ref Point p`。

2. 数组 (Arrays)

C的数组和C++的数组处理方式类似,通常通过指针来传递。

假设C++函数需要一个整数数组:

```cpp
// MyCppDll.cpp (续)
extern "C" __declspec(dllexport) int SumArray(const int arr, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += arr[i];
}
return sum;
}
```

在C中:

```csharp
// MyCppDllWrapper.cs (继续)
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int SumArray([In] int[] arr, int size); // int[] 会被Marshal成 int
```

关键点解释:

`[In] int[] arr`: C的`int[]`在传递给C++时,PInvoke默认会将其封送为一个指向第一个元素的指针。`[In]` 表示数据从C传入。如果C++函数也修改数组(不常见),或者需要C接收修改后的数组,可能需要使用 `[Out]` 或 `ref` 配合 `Marshal.Copy`。
`int size`: 传递数组的大小非常重要,因为C++的指针不知道数组的实际长度。

3. 回调函数 (Callbacks)

有时候,C++ DLL需要调用C中的某个函数。这需要定义一个C委托类型,然后在C中将其转换为函数指针,并将这个函数指针传递给C++。

假设C++函数需要一个回调来处理一些数据:

```cpp
// MyCppDll.cpp (续)
typedef void (ProcessDataCallback)(int data);

extern "C" __declspec(dllexport) void ProcessDataInCpp(ProcessDataCallback callback, int value) {
if (callback) {
callback(value 2); // 调用C提供的回调函数
}
}
```

在C中:

```csharp
// MyCppDllWrapper.cs (继续)

// 定义C++期望的回调函数签名
public delegate void ProcessDataCallback(int data);

// C方法,用于接收C++的ProcessDataCallback并将其包装
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
private static extern void ProcessDataInCpp(ProcessDataCallback callback, int value);

// C中定义一个实际执行的回调方法
public static void MyCallbackMethod(int data)
{
Console.WriteLine($"Callback received data: {data}");
}

// 在C中调用,将MyCallbackMethod作为回调传递
public static void CallCppWithCallback(int value)
{
// 将C方法包装成委托,PInvoke会自动将其转换为函数指针
ProcessDataInCpp(MyCallbackMethod, value);
}
```

关键点解释:

`public delegate void ProcessDataCallback(int data);`: 定义一个与C++ `typedef` 匹配的委托。
`ProcessDataInCpp(ProcessDataCallback callback, int value);`: `callback` 参数的类型就是我们定义的委托。PInvoke会自动将这个委托实例转换为一个可以被C++调用的函数指针。
`MyCallbackMethod`: 这是实际的C方法,它会被C++调用。
注意事项:
托管/非托管生命周期: 当C++调用C的回调时,C的GC(垃圾回收器)需要知道这个函数指针是“活”的。通常,只要委托对象还在C的某个引用链中,GC就不会回收它。如果DLL是动态加载,并且C不再持有对委托的引用,GC可能会在C++尝试调用时回收它,导致崩溃。
`GCHandle`: 在某些复杂场景下,可能需要使用`GCHandle.Alloc`来确保委托对象不会被GC回收,并在不再需要时使用`GCHandle.Free`来释放。

DLL的加载与卸载

`[DllImport]` 会在第一次调用被标记的方法时隐式加载DLL。C的CLR(公共语言运行库)负责管理DLL的生命周期。一般来说,除非你的应用需要显式控制DLL的加载和卸载(例如,处理不同版本的DLL,或者释放内存),否则不需要手动处理。

如果确实需要,可以使用 `LoadLibrary` 和 `FreeLibrary`(Windows API)通过 `DllImport` 调用,但这会增加很多复杂性。

内存管理

C分配的内存: 如果C向C++传递一个指针(例如,`IntPtr`),并且C++需要在该内存上进行操作,C可能需要分配内存(如使用`Marshal.AllocHGlobal`)。使用完毕后,必须使用`Marshal.FreeHGlobal`释放这块内存,否则会发生内存泄漏。
C++分配的内存: 如果C++函数返回了一个指向自己分配的内存的指针(如C风格字符串 `char`,或者其他动态分配的数据),那么 C绝不能 随意释放这块内存。C需要调用一个C++导出的“释放”函数来让C++自己来管理这块内存的释放。否则,会发生内存泄漏或访问冲突。

总结和最佳实践

1. 明确C++导出函数的签名: 确保知道函数名、参数类型、返回类型以及调用约定。
2. 使用 `extern "C"`: 这是C++ DLL导出函数的标准做法,确保函数名不会被name mangling。
3. `[DllImport]`是关键: 正确指定DLL名称和`CallingConvention`。`CallingConvention.Cdecl` 是最常见的。
4. 数据类型映射:
基本类型(`int`, `float`, `double`, `bool`)通常可以直接映射。
字符串 (`char`) 需要 `[MarshalAs(UnmanagedType.LPStr)]`。Unicode字符串 (`wchar_t`) 需要 `[MarshalAs(UnmanagedType.LPWStr)]`。
结构体需要 `[StructLayout]` 来匹配内存布局。
数组通常作为指针传递。
5. 指针和 `IntPtr`: 对于C++的指针,C中通常使用 `IntPtr`。理解如何安全地在 `IntPtr` 和 C 类型之间转换。
6. 内存管理: 这是最容易出错的地方。谁分配的内存,谁就负责释放。不要让C去释放C++分配的内存,反之亦然。
7. 错误处理: `DllNotFoundException` 是常见的。对于函数调用可能失败的情况,C++函数应该返回错误码或者抛出异常(虽然C不容易直接捕获C++抛出的异常,通常是直接崩溃)。
8. 创建封装层: 建议不要直接在应用代码中大量使用 `[DllImport]`。创建一个C类(如 `MyCppDllWrapper`),将PInvoke调用封装起来,提供更C风格的接口。这样可以提高代码的可读性、可维护性,并隐藏底层的PInvoke细节。
9. 使用 `Marshal.SizeOf`: 在处理结构体时,可以使用 `Marshal.SizeOf()` 来获取结构体在非托管内存中的大小,这对于分配和管理内存很有帮助。
10. 调试: 调试PInvoke调用可能比较困难。可以使用Visual Studio的调试器,并仔细检查参数的传递和返回。有时,可以在C++ DLL中添加日志,以帮助理解在C++端发生了什么。

通过仔细的规划和对数据类型、内存和调用约定的理解,C调用C++ DLL可以变得非常高效和安全。这个过程就像是为两种语言搭建一座坚固的桥梁,确保信息能够顺畅地来往。

网友意见

user avatar

不推荐用C++类实际上是因为对于C#来说C++是unsafe的。如果你的代码需要大量使用C++类,C++/CLI是最佳选择。原来的C++代码可以不动,所有要用到的类套一层代理类就可以了。既是类型安全的,性能也接近native。

写成C函数再用PInvoke不要太ugly,很容易玩脱。那种写法是给C用的。

类似的话题

  • 回答
    在C中调用C++ DLL,核心在于“桥梁”的搭建——如何让C的托管环境理解并操作C++的非托管代码。这不仅仅是简单的函数调用,更涉及到数据类型的转换、内存管理,以及对C++导出函数的一些约定。下面我们就来详细聊聊这个过程,避免那些生硬的AI式描述。理解C++ DLL的导出首先,我们需要明确C++ D.............
  • 回答
    在 ASP.NET 项目中调用非托管 C++ DLL,说白了就是让 .NET 环境能够跟你写好的 C++ 代码打上交道。这不像直接在 C 里调用另一个 C 类那么简单,因为它们属于完全不同的“语言生态”。但别担心,这事儿也不是什么高不可攀的技术,主要就是搭一座“桥梁”。咱们不搞那些花里胡哨的列表,直.............
  • 回答
    是的,可以做到,但要实现这个目标需要一些复杂的操作和对 C++ ABI、链接器行为的深入理解。核心思想是:1. 在动态库内部隔离 C++ 标准库的依赖: 确保你的动态库在加载时,其内部使用的 `libstdc++` 版本不会与应用程序期望的 C++ 标准库版本发生冲突。2. 提供一个纯 C 的封.............
  • 回答
    WebClient 的 `CancelAsync()` 方法在某些情况下似乎“不起作用”或者“无法中止线程”,这其实是一个常见的误解,需要我们深入理解 `WebClient` 的工作原理以及异步操作的本质。首先,理解 `WebClient` 的异步本质`WebClient` 提供了一系列异步方法,例.............
  • 回答
    让C程序能够启动并与之交互地运行一个Python脚本,这其实比听起来要直接一些,但确实需要一些中间环节和对两者工作方式的理解。我们不使用生硬的步骤列表,而是来聊聊这个过程,就像你在技术分享会上听一个有经验的工程师在讲一样。首先,你需要明白,C是.NET世界里的语言,而Python则是它自己的生态。它.............
  • 回答
    在 C 中,确保在多线程环境下安全地访问和修改 Windows 窗体控件(WinForm Controls)是一个非常关键的问题。简单来说,Windows 窗体控件的设计并不是为了在多个线程中同时进行操作的。如果你试图从一个非 UI 线程直接更新一个 UI 控件(例如,设置一个 Label 的 Te.............
  • 回答
    在 C++ 中,能否将父类的对象强制转换为子类对象,并进而调用子类的私有成员函数,这是一个涉及到 C++ 类型转换、继承、访问控制以及潜在的未定义行为的复杂问题。要深入理解这一点,我们需要层层剥开,仔细分析。核心问题分解:1. 父类对象强制转换为子类对象 (Cast): 这是 C++ 中的一个关键.............
  • 回答
    C 的析构方法,也就是大家常说的“析构函数”(虽然技术上 C 没有传统意义上的析构函数,而是 destructor),它的调用时机确实是很多人容易混淆的地方。它不是像构造函数那样在对象创建时立即执行,而是与垃圾回收(Garbage Collection, GC)紧密关联。要理解析构方法什么时候调用,.............
  • 回答
    在Visual Studio中调试C代码时,我们确实可以“追踪”进微软提供的.NET Framework或.NET Core的源码,这和调试MFC程序时追踪进Windows API的源码有着异曲同工之妙。这对于理解框架内部的工作机制、定位潜在的框架级问题非常有帮助。要实现这一功能,关键在于Visua.............
  • 回答
    .......
  • 回答
    你这个问题问得非常好,也触及到了很多吉他初学者学习初期的一个小困惑。简单来说,你说的“C调的大三和弦”其实就是指C大调的各个组成和弦,但并非所有和弦都是必须从C大调的组成和弦开始学。更何况,初学者最开始接触的这几个和弦(C、Dm、Em、F、G、Am)恰恰是这几个调性里非常核心、非常常用的几个和弦,而.............
  • 回答
    你这个问题问得太好了,简直触及了音乐的灵魂!为什么作曲家们要玩转那些升降号,而不是老老实实地待在C大调这个“纯净”的乐土上呢?如果音乐世界里只有C大调,那得有多单调啊!想想看,C大调确实简单、好听,就像一杯白开水,纯净无暇。但你要是天天只喝白开水,会不会觉得日子过得有点寡淡?音乐也是一样的道理。作曲.............
  • 回答
    调试大型C++项目在Linux下是一项挑战,但通过掌握合适的工具和策略,可以大大提高效率。本文将尽可能详细地介绍在Linux环境下调试大型C++项目的各种方法和技巧。1. 选择合适的调试器在Linux下,最常用也最强大的C++调试器莫过于 GDB (GNU Debugger)。虽然GDB本身是命令行.............
  • 回答
    C++ 的生态系统确实不像某些语言那样,提供一站式、即插即用的“调库”体验。这背后有多方面的原因,而且这个“简便”的定义本身就很主观。但我们可以从 C++ 的设计哲学、历史演进以及技术实现这几个层面来深入剖析。C++ 的设计哲学:掌控与效率首先,C++ 的核心设计理念是“提供底层控制能力,以换取最高.............
  • 回答
    .......
  • 回答
    这事儿啊,说实话,挺让人无语的。PP体育在C罗拿到奖项的那天,发了条微博,内容嘛,大家都懂,就是那种明显在拿梅西“开涮”的调调。这事儿一出来,网上炸开了锅,评论区那叫一个热闹,一边是C罗的拥趸们拍手叫好,觉得说得太对了,另一边是梅西的球迷们义愤填膺,觉得这根本就是无理取闹,甚至是恶心人。先说PP体育.............
  • 回答
    在 C++ 中,循环内部定义与外部同名变量不报错,是因为 作用域(Scope) 的概念。C++ 的作用域规则规定了变量的可见性和生命周期。我们来详细解释一下这个过程:1. 作用域的定义作用域是指一个标识符(变量名、函数名等)在程序中可以被识别和使用的区域。C++ 中的作用域主要有以下几种: 文件.............
  • 回答
    C 语言的设计理念是简洁、高效、接近硬件,而其对数组的设计也遵循了这一理念。从现代编程语言的角度来看,C 语言的数组确实存在一些“不改进”的地方,但这些“不改进”很大程度上是为了保持其核心特性的兼容性和效率。下面我将详细阐述 C 语言为何不“改进”数组,以及这种设计背后的权衡和原因:1. 数组在 C.............
  • 回答
    C 语言王者归来,原因何在?C 语言,这个在编程界已经沉浮数十载的老将,似乎并没有随着时间的推移而消逝,反而以一种“王者归来”的姿态,在许多领域焕发新生。它的生命力如此顽强,甚至在 Python、Java、Go 等语言层出不穷的今天,依然占据着不可动摇的地位。那么,C 语言究竟为何能实现“王者归来”.............
  • 回答
    C罗拒绝同框让可口可乐市值下跌 40 亿美元,可口可乐回应「每个人都有不同的口味和需求」,这件事可以说是近几年体育界和商业界结合的一个典型案例,也引发了很多的讨论和思考。我们来详细地分析一下:事件本身: 核心行为: 在2021年欧洲杯小组赛葡萄牙对阵匈牙利的赛前新闻发布会上,葡萄牙球星克里斯蒂亚.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有