Skip to content

C# - 非安全代码

C# 是一种托管语言,这意味着 .NET 运行时会自动处理内存管理(例如垃圾回收)。这提供了类型安全性和健壮性。然而,在极少数情况下,你需要降级到较低的层次,使用指针直接操作内存地址。C# 允许在用 unsafe 关键字标记的代码块中进行此操作。

警告:使用 unsafe 代码是一项高级功能。它绕过了 .NET 运行时的安全保证。仅在对性能要求极高的任务或与原生 C/C++ 库进行互操作时绝对必要才应使用它。不正确的指针操作可能导致内存损坏、安全漏洞和应用程序崩溃。

要编译 unsafe 代码,你必须在项目设置中明确启用它。对于现代 .NET 项目,编辑 .csproj 文件并添加以下属性:

<PropertyGroup>
...
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

在 Visual Studio 中,你也可以通过右键点击解决方案资源管理器中的项目,选择“属性”,导航到“生成”选项卡,并勾选“允许不安全代码”框来设置此项。

**指针(pointer)**是一个存储另一个变量内存地址的变量。指针只能在 unsafe 上下文(整个方法或特定块)中声明和使用。

  • 声明:type* variableName;(例如,int* p;)
  • 取地址运算符(&):获取变量的内存地址。
  • 解引用运算符(*):访问指针指向的内存地址处存储的值。
// 将整个方法标记为 unsafe
static unsafe void Main(string[] args)
{
int value = 20;
// 获取 'value' 的地址并将其存储在指针中。
int* pValue = &value;
Console.WriteLine($"Original value: {value}");
// 使用解引用运算符通过指针获取值。
Console.WriteLine($"Value via pointer: {*pValue}");
// 地址本身是一个数字,可以转换为 long 来显示。
// 注意:每次程序运行时,实际地址都会改变。
Console.WriteLine($"Memory address: 0x{(long)pValue:X}");
// 你也可以通过指针修改原始变量的数据。
*pValue = 50;
Console.WriteLine($"Value after modification via pointer: {value}");
}
/*
* 示例输出(地址会有所不同):
*
* Original value: 20
* Value via pointer: 20
* Memory address: 0x56B4FF7B4C
* Value after modification via pointer: 50
*/

.NET 垃圾回收器(GC)可以在内存中移动对象以优化空间。如果你有一个指向对象的指针,这就会成为一个问题,因为地址可能会变得无效。fixed 语句将托管对象“固定”在内存中,阻止 GC 在 fixed 块的持续时间内移动它。

当获取托管数组中的元素或类实例字段的指针时,fixed 语句至关重要。

int[] numbers = { 10, 100, 200 };
// 整个块都是 unsafe 上下文。
unsafe
{
// 将 'numbers' 数组固定在内存中,并获取指向其第一个元素的指针。
fixed (int* pNumbers = numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
// 使用指针算术访问元素。
// *(pNumbers + i) 等同于 C/C++ 中的 pNumbers[i]。
Console.WriteLine($"Value of numbers[{i}] = {*(pNumbers + i)}");
}
}
} // 数组在此处被“解除固定”,可以再次被 GC 移动。

现代替代方案:Span<T> 和 Memory<T>

Section titled “现代替代方案:Span<T> 和 Memory<T>”

对于许多曾经需要 unsafe 代码的性能敏感场景(例如处理数组切片),现代 C# 提供了一种更安全且通常性能相当的替代方案:System.Span<T> 和 System.Memory<T>。这些类型提供了对任意连续内存区域的类型安全和内存安全表示。

Span<T> 是一个仅限堆栈的 ref struct,可以指向堆栈、托管堆(如数组)或原生代码中的内存。它提供了数据的“视图”,而无需分配新内存,并且所有访问都经过边界检查。

示例:使用 Span<T> 处理数组切片(安全方式)

Section titled “示例:使用 Span<T> 处理数组切片(安全方式)”
// 这段代码完全安全,不需要 'unsafe' 关键字。
void ProcessData(Span<byte> data)
{
// 处理内存切片,无需复制。
for (int i = 0; i < data.Length; i++)
{
data[i] = (byte)(data[i] * 2); // 通过 span 修改原始数据
}
}
byte[] buffer = { 1, 2, 3, 4, 5, 6, 7, 8 };
// 创建一个表示数组从索引 2 开始,长度为 4 的切片的 span。
// 这个切片表示元素 { 3, 4, 5, 6 }。
Span<byte> slice = new Span<byte>(buffer, 2, 4);
ProcessData(slice);
// 原始数组已被修改。
Console.WriteLine(string.Join(", ", buffer));
// 预期输出:1, 2, 6, 8, 10, 12, 7, 8

鉴于 Span<T> 等强大的安全替代方案,unsafe 代码的需求非常罕见。将其保留用于以下特定情况:

  • 平台调用(P/Invoke):当调用需要指针、指向指针的指针或函数指针的原生 C/C++ 函数时。
  • 极限性能优化:在某些场景下,即使 Span<T> 的边界检查带来的最小开销也无法接受(例如,在视频编解码器或高频交易算法的内部循环中)。这应作为最后的手段,并且需要进行大量的基准测试。
  • 低级内存操作:当实现自定义内存分配方案或直接与硬件交互时。

结论:始终优先选择安全代码。首先使用 Span<T> 和其他现代 .NET API。仅在作为最终优化步骤或进行必要的互操作时才转向 unsafe,并且务必将其封装在应用程序中一小部分经过充分测试和良好文档化的代码中。