相关文章推荐
行走的豆芽  ·  macos - Java ZipFile ...·  1 年前    · 
还单身的白开水  ·  C#中 ...·  1 年前    · 
威武的火车  ·  Java ...·  1 年前    · 
善良的海豚  ·  android m4a 解码 ...·  1 年前    · 
首发于 C# .NET
4. C#高级特性_11_不安全代码和指针

4. C#高级特性_11_不安全代码和指针

不安全代码和指针 Unsafe Code and Pointers

C# 支持通过代码块内的指针进行直接内存操作,只需要将代码块标记为不安全并使用 /unsafe 编译器选项编译。指针类型主要用于与 C API 的互操作性,但您也可以将它们用于访问托管堆之外的内存或性能关键的热点。

Pointer Basics

对于每个值类型或引用类型 V,都有一个对应的指针类型 V*。 A 指针实例保存变量的地址。可以(不安全地)强制转换指针类型到任何其他指针类型。以下是主要的指针运算符:

Operator Meaning
& The address-of operator returns a pointer to the address of a variable.
* The dereference operator returns the variable at the address of a pointer.
-> The pointer-to-member operator is a syntactic shortcut, in which x->y is equivalent to (*x).y.

与 C 保持一致,向指针添加(或减去)整数偏移量会生成另一个指针。从一个指针中减去另一个指针生成一个 64 位整数(在 64 位和 32 位平台上)。

Unsafe Code

通过使用 unsafe 关键字标记类型、类型成员或语句块, 您可以使用指针类型并在该范围内的内存上执行 C 风格的指针操作。下面是一个使用指针快速处理位图的例子:

unsafe void BlueFilter (int[,] bitmap)
    int length = bitmap.Length;
    fixed (int* b = bitmap)
        int* p = b;
	for (int i = 0; i < length; i++)
	     *p++ &= 0xFF;

不安全的代码可以比相应的安全实现运行得更快。在这种情况下,该代码需要一个带有数组索引和边界检查的嵌套循环。不安全的 C# 方法也可能比调用外部 C 函数更快,这假设没有与离开托管执行相关的开销环境。

The fixed Statement

fixed 语句需要固定一个托管对象,例如位图前面的例子。在程序执行过程中,分配了很多对象并从堆中释放。为了避免不必要的浪费或碎片化内存,垃圾收集器移动对象。指向一个对象是徒劳的如果它的地址在引用它时可能会改变,那么 fixed 语句告诉垃圾收集器“固定”对象而不是移动它。这可以影响运行时的效率,所以你应该尽可能简单地使用fix块,并且您应该避免在固定块内进行堆分配。

在一个 fixed 语句中,你可以获得一个指向任何值类型的指针,一个值数组类型或字符串。在数组和字符串的情况下,指针实际上指向第一个元素,它是一个值类型。

在引用类型中内联声明的值类型要求该引用类型需要被固定 to be pinned ,如下:

Test test = new Test();
unsafe
    fixed (int* p = &test.X) // Pins test
        *p = 9;
    Console.WriteLine (test.X);
class Test { public int X; }

我们在“将结构映射到非托管内存 Mapping a Struct to Unmanaged Memory ”中进一步描述了 fixed 语句”,第 973 页。

The Pointer-to-Member Operator

除了 & 和 * 运算符,C# 还提供了 C++ 风格的 -> 运算符, 您可以在结构上使用:

Test test = new Test();
unsafe
    Test* p = &test;
    p->X = 9;
    System.Console.WriteLine (test.X);
struct Test { public int X; }

The stackalloc Keyword

您可以使用 stackalloc 关键词在堆栈上的块中显式分配内存。因为是在栈上分配的,所以它的生命周期仅限于执行方法,就像任何其他局部变量(其生命没有因 lambda 表达式、迭代器块或异步捕获功能而被延长)。该块可以使用 [] 运算符索引到内存中:

int* a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
    Console.WriteLine (a[i]);

在第 23 章中,我们描述了如何使用 Span<T> 来管理堆栈分配的内存而不必使用 unsafe 关键字:

Span<int> a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
    Console.WriteLine (a[i]);

Fixed-Size Buffers

fixed 关键字还有另一个用途,即在内部创建固定大小的缓冲区结构(这在调用非托管函数时很有用;请参阅第 24 章):

new UnsafeClass ("Christian Troy");
unsafe struct UnsafeUnicodeString
    public short Length;
    public fixed byte Buffer[30]; // Allocate block of 30 bytes
unsafe class UnsafeClass
    UnsafeUnicodeString uus;
    public UnsafeClass (string s)
        uus.Length = (short)s.Length;
	fixed (byte* p = uus.Buffer)
	for (int i = 0; i < s.Length; i++)
	    p[i] = (byte) s[i];

固定大小的缓冲区不是数组:如果 Buffer 是一个数组,它将包含一个引用存储在(托管)堆上的对象,而不是 30 字节内的结构本身。

本例中还使用了 fixed 关键字,将对象固定在堆上,该对象包含Buffer(这将是 UnsafeClass 的实例uus)。因此,固定意味着两个不同的东西: 尺寸固定 位置固定 。两者经常使用在一起,因为固定大小的缓冲区必须固定在适当的位置才能使用。

void*

空指针 (void*) 不对基础数据的类型做出任何假设并且对于处理原始内存的函数很有用。存在 从任何指针类型到 void 的隐式转换。 void 不能被解引用dereferenced,并且不能对 void 指针执行算术操作。这是一个例子:

short[] a = { 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 };
unsafe
    fixed (short* p = a)
        //sizeof returns size of value-type in bytes
	Zap (p, a.Length * sizeof (short));
foreach (short x in a)
    System.Console.WriteLine (x); // Prints all zeros
unsafe void Zap (void* memory, int byteCount)
    byte* b = (byte*)memory;
    for (int i = 0; i < byteCount; i++)
        *b++ = 0;

Native-Sized Integers

nint 和 nuint 是 native-sized 的整数类型(在 C# 9 中引入),其大小为在运行时匹配的进程的地址空间(实际上是 32 位或 64 位)。 native-sized 的整数可以提高效率、溢出安全性和便利性执行指针运算。

效率的提高是因为当你在 C# 中减去两个指针时,结果始终是 64 位整数(长整型),这在 32 位平台上效率低下。经过将指针指向 nint,减法的结果也是 nint(这将在 32 位平台上是 32 位):

unsafe nint AddressDif (char* x, char* y) => (nint)x - (nint)y;

当您需要一个类型来表示内存中的偏移量或缓冲区长度时,就会出现溢出安全性和便利性的好处。这是因为历史替代品,使用 nint/nuint 是为了重新利用 System.IntPtr 和 System.UIntPtr,其预期目的是包装操作系统句柄或地址的类型指针,允许在不安全的上下文之外进行互操作。虽然他们是natively sized,但这些类型对算术的支持有限——而且它们的支持是始终不检查(因此溢出会静默失败)。

相比之下,原生大小的整数表现得非常像标准整数,具有完整的支持算术运算和溢出检查:

nint x = 123, y = 234;
checked
    nint sum = x + y, product = x * y;
    Console.WriteLine (product);

本机大小的整数可以分配 32 位整数常量(但不是 64 位整数常量,因为这些可能会在运行时溢出)。您可以使用显式强制转换转换为其他整数类型或从其他整数类型转换。

在运行时,nint 和 nuint 映射到 IntPtr 和 UIntPtr 结构,因此您可以在它们之间转换而不转换(identity conversion):

nint x = 123;
IntPtr p = x;
nint y = p;

由于前面所述的原因,nint/nuint 不仅仅是 IntPtr/UIntPtr 的快捷方式,尽管它们在运行时等效。具体来说,编译器将一个变量作为数字类型的 nint/nuint 类型,允许您执行算术,而IntPtr 和 UIntPtr 并未实现的此类操作(通过checked语句块)。

一个 nint/nuint 变量就像一个戴着特别的帽子的 IntPtr/UIntPtr 。这顶帽子被编译器识别为 “请将我视为安全的数字类型。”

这种多帽行为对于原生大小的整数是独一无二的。例如,int 充当 System.Int32 的纯同义词,并且两者可以自由互换。

这种不等价性意味着这两种构造都是有用的:

  • nint/nuint 可用于表示内存偏移量或缓冲区长度。
  • IntPtr/UIntPtr 可用于包装句柄和指针以实现互操作。

以这种方式使用类型也能正确地表明您的意图。

Function Pointers

函数指针(来自 C# 9)就像一个委托,但没有委托实例的间接寻址;相反,它直接指向一个方法。函数指针可以仅指向静态方法,缺乏多播功能,并且需要 unsafe 上下文(因为它绕过了运行时类型安全)。其主要目的是简化和优化与非托管 API 的互操作(请参阅“来自非托管代码的回调 Callbacks from Unmanaged Code ”第 967 页)。

函数指针类型声明如下(返回类型出现在最后):

delegate*<int, char, string, void> // (void refers to the return type)

这与具有此签名的函数相匹配:

void SomeFunction (int x, char y, string z)

& 运算符从方法组创建函数指针。这是一个完整的例子:

unsafe
    delegate*<string, int> functionPointer = &GetLength;
    int length = functionPointer ("Hello, world");
    static int GetLength (string s) => s.Length;

在此示例中,functionPointer 不是您可以调用的对象方法,例如 Invoke(或使用对 Target 对象的引用)。相反,它是一个直接指向目标方法内存地址的变量:

Console.WriteLine ((IntPtr)functionPointer);

与任何其他指针一样,它不受运行时类型检查的约束。下列将函数的返回值视为 decimal (比 int 长,意味着我们将一些随机内存合并到输出中):

var pointer2 = (delegate*<string, decimal>) (IntPtr) functionPointer;
Console.WriteLine (pointer2 ("Hello, unsafe world"));

[SkipLocalsInit]

当 C# 编译一个方法时,编译器会发出一个标志,指示运行时进行初始化,将方法的局部变量设置为默认值(通过将内存清零)。从 C# 9 开始,您可以通过将 [SkipLocalInit] 属性应用于方法(在 System.Runtime.CompilerServices命名空间)告诉编译器不要发出这个标志:

[SkipLocalsInit]
void Foo() ...

你也可以将这个属性应用到一个类型——相当于将它应用到类型的所有方法——甚至整个模块(程序集的容器):

[module: System.Runtime.CompilerServices.SkipLocalsInit]

在正常的安全场景中,[SkipLocalsInit] 对功能或功能几乎没有影响性能,因为 C# 的明确分配策略要求您显式在读取局部变量之前分配局部变量。这意味着 JIT 优化器是可能会发出相同的机器代码,无论是否应用该属性。

然而,在不安全的上下文中,使用 [SkipLocalsInit] 可以有效地节省 CLR 初始化值类型的局部变量的开销,通过使用堆栈的方法获得小部分的性能提升(通过一个大的 stackalloc)。以下示例在以下情况下打印未初始化的内存,在[SkipLocalsInit] 被应用时(而不是全零):

[SkipLocalsInit]
unsafe void Foo()
    int local;
    int* ptr = &local;
    Console.WriteLine (*ptr);
    int* a = stackalloc int [100];
    for (int i = 0; i < 100; ++i) Console.WriteLine (a [i]);

有趣的是,您可以通过使用Span<T>:

[SkipLocalsInit]