1什么是不安全代码
C# 也能像 C 语言那样用指针直接操作内存,但要写在 unsafe 标记的方法或代码块里,这样的代码叫不安全代码。
| 6 | static unsafe void Main(string[] args) | // 在方法前加 unsafe:整个 Main 里都能用指针 |
| 8 | unsafe | // 也可以只标一段:unsafe 后面跟一对大括号 |
| 9 | { | // unsafe 代码块开始 |
不安全代码不是非托管代码(不归 .NET 管的代码),教程的说法不准确:它照样由 .NET 运行,垃圾回收(.NET 自动清理不再使用的内存)也照常工作,只是编译器没法替你检查指针用得对不对,所以叫“不安全”。
2指针是什么
内存像一排格子,每个格子都有门牌号,也就是地址。普通变量的格子里放值(比如 20),指针也是变量,但它的格子里放的是另一个格子的地址。真实内存按字节编号,格子和门牌只是比方。
声明写法是 类型* 名字,星号前的类型说明它指向什么:int* 指向整数,double* 指向双精度数,float* 指向浮点数,char* 指向字符;int** 是指向指针的指针,int*[] 是装指针的数组,void* 指向不知道类型的数据。
下面几个短例子是顶级语句写法(不写类和 Main,代码从第一行开始执行),代码都放在一个 unsafe { } 块里。
| 5 | Console.WriteLine(sizeof(int*)); | // sizeof 取类型占几个字节:int* 指针 |
| 6 | Console.WriteLine(sizeof(double*)); | // double* 指针 |
| 7 | Console.WriteLine(sizeof(char*)); | // char* 指针 |
| 8 | Console.WriteLine(sizeof(void*)); | // void* 指针 |
| 9 | Console.WriteLine(sizeof(int**)); | // int**:指向指针的指针 |
8 8 8 8 8
在 64 位电脑上,指针里只存一个地址,所以五种指针都占 8 个字节(内存按字节编号,一个字节是 8 个二进制位)。
3一句声明多个指针
一句里声明好几个指针时,星号只跟着类型写一次,后面的名字全都是指针。
| 5 | int a = 1, b = 2, c = 3; | // 三个普通 int 变量:1、2、3 |
| 6 | int* p1, p2, p3; | // 星号写在类型后面一次:p1、p2、p3 三个都是 int* |
| 7 | p1 = &a; | // & 是取地址符:p1 存 a 的地址 |
| 8 | p2 = &b; | // p2 存 b 的地址 |
| 9 | p3 = &c; | // p3 存 c 的地址 |
| 10 | Console.WriteLine(*p1 + *p2 + *p3); | // *p 取出指针指向的值:1 + 2 + 3 |
6
照 C 语言的习惯在每个名字前加星号,C# 会报错:
| 5 | int *p1, *p2, *p3; | // C 的写法:每个名字前都加星号 |
Program.cs(5,14): error CS1001: 应输入标识符 Program.cs(5,14): error CS1002: 应输入 ; Program.cs(5,17): error CS1002: 应输入 ; Program.cs(5,17): error CS1513: 应输入 }
4取地址
Main 前面加了 unsafe,用 & 取出变量 var 的地址交给指针 p,再把 p 强制转换成 int 打印。
| 8 | int var = 20; | // 普通变量 var,格子里放 20 |
| 9 | int* p = &var; | // &var 取出 var 的地址,存进指针 p |
| 10 | Console.WriteLine("Data is: {0} ", var); | // 打印 var 的值:20 |
| 11 | Console.WriteLine("Address is: {0}", (int)p); | // 把地址转成 int 打印 |
查看完整代码(15 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | namespace UnsafeCodeApplication | // 定义命名空间 UnsafeCodeApplication |
| 3 | { | // 命名空间开始 |
| 4 | class Program | // 定义 Program 类 |
| 5 | { | // Program 类开始 |
| 6 | static unsafe void Main(string[] args) | // 程序入口 Main,加了 unsafe,里面能用指针 |
| 7 | { | // Main 开始 |
| 8 | int var = 20; | // 普通变量 var,值 20 |
| 9 | int* p = &var; | // 指针 p 存 var 的地址 |
| 10 | Console.WriteLine("Data is: {0} ", var); | // 打印 var 的值:20 |
| 11 | Console.WriteLine("Address is: {0}", (int)p); | // 把地址转成 int 打印 |
| 12 | Console.ReadKey(); | // 等待按键(现在的 VS 不写也会停住) |
| 13 | } | // Main 结束 |
| 14 | } | // Program 类结束 |
| 15 | } | // 命名空间结束 |
Data is: 20 Address is: 1794463892
再跑一次,地址变成了 1862604916:变量每次放在内存的哪儿并不固定,地址不用记。
本机是 64 位,地址有 8 个字节,转成 int 只留下低 4 个字节;换成 long 才是完整地址:
| 5 | int var = 20; | // 普通变量 var,值 20 |
| 6 | int* p = &var; | // p 存 var 的地址 |
| 7 | Console.WriteLine((long)p); | // 转成 long(8 字节):完整地址 |
| 8 | Console.WriteLine((int)p); | // 转成 int(4 字节):只剩低 4 字节 |
6129260692 1834293396
5取值和 ->
*p 就是 p 指向的格子里的值;p->ToString() 的箭头意思是先找到 p 指向的值,再调用它的方法。
| 10 | int var = 20; | // var 的值是 20 |
| 11 | int* p = &var; | // p 存 var 的地址 |
| 12 | Console.WriteLine("Data is: {0} " , var); | // 直接打印 var:20 |
| 13 | Console.WriteLine("Data is: {0} " , p->ToString()); | // -> 先取 p 指向的值 20,再调用 ToString 变成字符串 |
| 14 | Console.WriteLine("Address is: {0} " , (int)p); | // 打印地址 |
Data is: 20 Data is: 20 Address is: 1871206540
取值靠的是箭头,ToString 只负责把数变成字符串,所以第二行也是 20;第三行的地址再跑一次变成了 1837881452。
6指针作方法参数
swap 收两个 int 指针 p 和 q,Main 里 x 指向 var1、y 指向 var2,再把 x 和 y 传进去。
| 6 | public unsafe void swap(int* p, int *q) | // 两个参数都是 int 指针,收到的是地址 |
| 7 | { | // swap 开始 |
| 8 | int temp = *p; | // 先把 p 指向的值(10)存进 temp |
| 9 | *p = *q; | // 把 q 指向的值(20)放进 p 指向的格子:var1 = 20 |
| 10 | *q = temp; | // 把 temp(10)放进 q 指向的格子:var2 = 10 |
| 11 | } | // swap 结束 |
查看完整代码(28 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | namespace UnsafeCodeApplication | // 定义命名空间 UnsafeCodeApplication |
| 3 | { | // 命名空间开始 |
| 4 | class TestPointer | // 定义 TestPointer 类 |
| 5 | { | // TestPointer 类开始 |
| 6 | public unsafe void swap(int* p, int *q) | // swap:收两个 int 指针 |
| 7 | { | // swap 开始 |
| 8 | int temp = *p; | // temp = p 指向的值 |
| 9 | *p = *q; | // p 指向的格子 = q 指向的值 |
| 10 | *q = temp; | // q 指向的格子 = temp |
| 11 | } | // swap 结束 |
| 12 | | |
| 13 | public unsafe static void Main() | // 程序入口 Main,加了 unsafe |
| 14 | { | // Main 开始 |
| 15 | TestPointer p = new TestPointer(); | // 创建对象,用来调用 swap |
| 16 | int var1 = 10; | // var1 = 10 |
| 17 | int var2 = 20; | // var2 = 20 |
| 18 | int* x = &var1; | // x 指向 var1 |
| 19 | int* y = &var2; | // y 指向 var2 |
| 20 | | |
| 21 | Console.WriteLine("Before Swap: var1:{0}, var2: {1}", var1, var2); | // 交换前:10 和 20 |
| 22 | p.swap(x, y); | // 把两个地址传给 swap |
| 23 | | |
| 24 | Console.WriteLine("After Swap: var1:{0}, var2: {1}", var1, var2); | // 交换后:20 和 10 |
| 25 | Console.ReadKey(); | // 等待按键(现在的 VS 不写也会停住) |
| 26 | } | // Main 结束 |
| 27 | } | // TestPointer 类结束 |
| 28 | } | // 命名空间结束 |
Before Swap: var1:10, var2: 20 After Swap: var1:20, var2: 10
方法拿到的是地址,改的是原来那两个格子,所以 Main 里的变量也跟着变了。实测输出里 var1: 后面没有空格。
7用指针访问数组
int* 是指针,int[] 是数组,两者类型不同。数组放在垃圾回收管理的内存里,垃圾回收整理内存时可能把它整个挪走,指针就指错了地方;所以要用 fixed 在这段代码运行期间把数组钉住,不让它挪动。fixed 是 C# 自己的关键字,C 和 C++ 里没有。
不写 fixed,直接取数组元素的地址,编译器报错:
| 5 | int[] list = { 10, 100, 200 }; | // 数组 list:10、100、200 |
| 6 | int* ptr = &list[0]; | // 不用 fixed,直接取数组第 0 个元素的地址 |
Program.cs(6,16): error CS0212: 只能获取 fixed 语句初始值设定项内的未固定表达式的地址
| 8 | int[] list = {10, 100, 200}; | // 数组 list:10、100、200 |
| 9 | fixed(int *ptr = list) | // fixed 钉住数组 list,ptr 指向它的开头 |
| 10 | | |
| 11 | /* 显示指针中数组地址 */ | // 一行注释(/* */ 写法) |
| 12 | for ( int i = 0; i < 3; i++) | // i 从 0 到 2,逐个访问 |
| 13 | { | // for 循环开始 |
| 14 | Console.WriteLine("Address of list[{0}]={1}",i,(int)(ptr + i)); | // ptr + i 是第 i 个元素的地址 |
| 15 | Console.WriteLine("Value of list[{0}]={1}", i, *(ptr + i)); | // 前面加 * 取出第 i 个元素的值 |
查看完整代码(20 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | namespace UnsafeCodeApplication | // 定义命名空间 UnsafeCodeApplication |
| 3 | { | // 命名空间开始 |
| 4 | class TestPointer | // 定义 TestPointer 类 |
| 5 | { | // TestPointer 类开始 |
| 6 | public unsafe static void Main() | // 程序入口 Main,加了 unsafe |
| 7 | { | // Main 开始 |
| 8 | int[] list = {10, 100, 200}; | // 数组 list:10、100、200 |
| 9 | fixed(int *ptr = list) | // 钉住 list,ptr 指向开头;钉住范围是下面的 for 循环 |
| 10 | | |
| 11 | /* 显示指针中数组地址 */ | // 一行注释(/* */ 写法) |
| 12 | for ( int i = 0; i < 3; i++) | // i 从 0 到 2 |
| 13 | { | // for 循环开始 |
| 14 | Console.WriteLine("Address of list[{0}]={1}",i,(int)(ptr + i)); | // 打印第 i 个元素的地址 |
| 15 | Console.WriteLine("Value of list[{0}]={1}", i, *(ptr + i)); | // 打印第 i 个元素的值 |
| 16 | } | // for 循环结束 |
| 17 | Console.ReadKey(); | // 等待按键(现在的 VS 不写也会停住) |
| 18 | } | // Main 结束 |
| 19 | } | // TestPointer 类结束 |
| 20 | } | // 命名空间结束 |
Address of list[0]=54328 Value of list[0]=10 Address of list[1]=54332 Value of list[1]=100 Address of list[2]=54336 Value of list[2]=200
相邻两个元素的地址差 4:一个 int 占 4 个字节,指针加 1,地址往后走 4 个字节。再跑一次地址全变了(534844472、534844476、534844480),但还是每次差 4。
8编译不安全代码
用命令行编译器时写 csc /;现在的 .NET 项目要在项目文件(.csproj)里加上一行 <AllowUnsafeBlocks>true</。没写这一行,前面取地址的例子编译到 Main 这行就报错:
| 6 | static unsafe void Main(string[] args) | // Main 标了 unsafe,但项目没允许不安全代码 |
Program.cs(6,28): error CS0227: 不安全代码只会在使用 /unsafe 编译的情况下出现
在 Visual Studio 里:打开项目的属性,在“生成”页勾选“允许不安全代码”。这是微软文档写的步骤,新版 VS 的属性页排版可能不一样。
9stackalloc
还有一个 stackalloc:它直接在栈(方法运行时临时存放局部变量的内存)上分出一块内存。栈上的内存不归垃圾回收管,所以不用 fixed,方法一返回这块内存就自动丢掉。
| 5 | int* nums = stackalloc int[3]; | // 在栈上分出 3 个 int 的空间,nums 指向开头 |
| 6 | for (int i = 0; i < 3; i++) | // i 从 0 到 2 |
| 7 | { | // for 循环开始 |
| 8 | nums[i] = (i + 1) * 10; | // 指针也能用 [i]:依次放进 10、20、30 |
| 9 | } | // for 循环结束 |
| 10 | Console.WriteLine(nums[0] + nums[1] + nums[2]); | // 10 + 20 + 30 |
60
10小结
unsafe:标记能用指针的方法或代码块。&取地址,*按地址取值,->取值后调用它的方法。fixed:把数组钉住,不让垃圾回收挪动;stackalloc:在栈上分配,不用钉。- 编译时要打开
AllowUnsafeBlocks;地址每次运行都不一样。
下一节:指针能直接改内存;要是一个程序里好几件事同时在跑呢?
