低レベルプログラミング(No.10:C#でポインタ)

使用ツール:Visual Studio Community 2019
使用言語 :C#言語

C#でポインタ

今回は、C#でポインタを取り扱う方法について確認していきます。

前回までは、開発用コマンドプロンプトでコンパイル・動作確認の作業をしていましたが、今回からは、IDE(統合開発環境)を利用していきます。(コマンドプロンプトでもC#などの開発は出来ない事もないのですが、IDEの方が開発が容易なので…)

ちなみに、C#の言語の詳細については省きます。

また、C言語の時の様にアセンブリコードとの比較は行いません。(どういうコードの時にどのようなアセンブリコードになるかは、C言語と大差が無いのと、C#ソースをコンパイルして得られる実行ファイルはCLR上のIL(中間言語)コードのためです。

空プロジェクトの作成

はじめにC#で作成するコンソールアプリケーションのための空プロジェクトを作成します。

① Visual Studio の起動

Visual Studio Community 2019を起動すると下記画面が表示されるので、「新しいプロジェクトの作成」を選択します。

② プロジェクト種類の選択

プロジェクトは「C#」+「Windows」+「コンソール」で表示される「コンソールアプリ(.NET Framework)」を選択します。

「(.NET Framework)」が付かない「コンソールアプリケーション」でも構いませんが、両者の違いは、.NET Framework上で動作するか.NET Core上で動作するかの違いになります。

.NET Frameworkが提供するCLR(共通言語ラインタイム)はWindows OS向けのみなので、ビルドされた実行ファイルはWindows上でしか動作しません。.NET CoreはLINUXやMacOSにも提供されるので、それらがインストールされたOS上であれば、ビルドされた実行ファイルを動作させる事が出来ます。(EXEファイルがそのまま実行できる様なイメージではなく、クロスコンパイルが可能というイメージが近いかも知れません)

.NET Frameworkと.NET Coreでは利用できる機能も微妙に異なるので、この記事では、.NET Frameworkを利用する事としています。

③ プロジェクト名、フォルダを指定してプロジェクトを作成

続いて、プロジェクト名やフォルダ名を設定し、プロジェクトを作成します。

これらはご自身の環境や考えに応じて設定してください。

.NET Frameworkは基本的に変更不要です。(今回の内容では)

これで空のプロジェクトが作成されました。

④ unsafe(アンセーフ)コードを許可

C#でポインタを利用するには、unsafeブロックを利用する必要がありますが、デフォルトでは利用できません(エラーになります)。

unsafeコードを利用するには下記手順で、アンセーフコードを許可します。

・プロジェクトのプロパティを選択してプロパティウィンドウを表示

・ビルドタブを選択し、構成とプラットフォーム更新

プロパティウィンドウで「ビルドタブ」を選択し、上部の構成を「すべての構成」、プラットフォームを「すべてのプラットフォーム」に変更します。(プラットフォームが[アクティブな(Any CPU)]1つの場合は変更不要です)

これは、DebugとRelease、Any CPUとx64のすべて組み合わせで設定を共通にするためです。

・アンセーフコードの許可

「アンセーフコードの許可」チェックボックスにチェックを入れ、プロジェクトを保存します。

これでソースコードの中にunsafeブロックが使える様になりました。

・デバッグオプションの変更

メインメニューの「デバッグ」から「オプション」を選択して、オプションウィンドウを表示します。

表示されたオプションの中で、「デバッグ停止時に自動的にコンソールを閉じる」のチェックを外します。

この手順はポインタ操作には関係はありませんが、これをしておかないとプログラム終了時にコンソールが閉じてしまい、出力内容が消えてしまうので設定します。(このチェックを外してもコンソールは閉じてしまいますね…)

ポインタ操作はUnsafe(安全でない)?

ポインタを使った処理を行うプログラムを作成するには、unsafeコードを許可する必要がありました。

ポインタ操作は安全ではないのでしょうか?

結論から言えば、普通のプログラムから比べれば安全ではないでしょう。

よく理解しないままポインタを利用すれば、メモリ(オブジェクト)の内容を破壊してしまう可能性があるからです。

ただ、ポインタを使わなくても安全でないプログラムはいくらでもあります。きちんとメモリとポインタを理解して使えば安全なコードを書く事は可能だと考えています。

ポインタが使える範囲

C#でポインタを使うためには「unsafe」キーワードを用います。

unsafeを宣言する位置でポインタが使える範囲が変わりますが、基本的には必要な範囲のみに設定します。

unsafeプロック

下記の様にメソッド内でunsafeキーワードを利用した場合は、そのブロック内部のみポインタが利用出来ます。

unsafeメソッド

メソッドの修飾キーワードに「unsafe」キーワードを追加すれば、そのメソッド内ではどこでもポインタが利用出来ます。

※上記の様に引数もしくは戻り値にポインタを使う場合は、呼び出し元もunsafeスコープ内である必要があります。

unsafeクラス

クラスの修飾キーワードに「unsafe」キーワードを追加すれば、そのクラス内ではどこでもポインタが利用出来ます。

どこでもポインタが利用出来る様になりますが、フィールドなどにポインタを使うとメモリ保護違反となるバグが入り込む可能性が高くなるので、よほどの事がない限りはしない方がいいでしょう。

ポインタアクセスサンプル

ここからは実際にポインタ操作の例を見て行きます。

配列のポインタアクセス

下記のソースコードのTest_01メソッドが、int配列の各要素をポインタでアクセスするコードになります。

using System;
using System.Linq;

namespace sample_010
{
    class Program
    {
        static void Main(string[] args)
        {
            // テスト01の実行
            Test_01(100000);

            // デバッグ終了時にコンソールが閉じない様に
            Console.Write("Hit any key to exit : ");
            Console.ReadKey();
        }

        // テストメソッド-01
        static void Test_01(int arrSize)
        {
            int[] iArr = Enumerable.Range(1, arrSize).ToArray(); // 1~arrSizeまでの値が入った配列

            //ポインタを用いた総和計算
            long sum = 0;
            unsafe
            {
                fixed(int* iArrPtr = iArr) // ガベージコレクタ対応
                {
                    int* ptr = iArrPtr;
                    for (int i = 0; i < iArr.Length; i++, ptr++)
                    {
                        sum += *ptr;
                    }
                }
            }

            Console.WriteLine(string.Format("sum  = {0,8}", sum));
        }
    }
}

処理のざっくりした説明は下記の様になります。

関数処理内容
Main8~16
11Test_01メソッドの呼び出し
14, 15IDEからコンソールアプリを実行すると、デバッグ実行時[F5での実行時]に
プログラムが終了するとコンソールが閉じてしまう事への対応。
Test_0119~38
21int型配列iArrの準備。
配列の要素に1~引数arrSize(=100,000)の値が格納される様に初期化
24総和を格納する変数sumを初期化
25~35unsafeブロックを設定
27fixedブロックを設定。
配列iArrへのint型ポインタiArrPtrを設定
29int型ポインタptrを宣言し、iArrPtrの値(アドレス)を格納
30~33ポインタptrを利用して、iArrの要素の総和計算
37計算した総和sumのコンソールへの出力

重要な点は2点です。

unsafeブロック

 unsafeブロックを設定する事でそのスコープ内でポインタが使用できる様になります。

・fixedブロック

 fixedステートメントについては、マイクロソフトに下記の記述があります。

fixed ステートメントを使うと、ガベージ コレクターによる移動可能変数の再配置を防ぎ、その変数へのポインターを宣言することができます。

https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/statements/fixed

つまり、ガベージコレクションが実行されると、未使用のマネージメモリが解放されるだけでなく、使用中のオブジェクトのマネージメモリも配置が変更になりアドレスが変わる事があるので、fixedステートメントを使って、再配置によるアドレス変更を防止する必要があります。

言い換えると、fixedステートメントなしに配列のポインタを取得する事は出来ません。

また、上記のサンプルコードではfixedステートメント内で、配列iArrのアドレスをポインタ変数iArrPtrに格納していますが、これは「固定変数」となるので、変数の値を変更できません。

ポインタ変数ptrを改めて宣言してiArrPtrの値を格納しなおしているのはそのためです。

※下記の様にポインタ変数iArrPtrを配列の様に利用する事も可能ですが、これだと配列をポインタで操作しているという感じではないので、サンプルコードの様にしています。

    sum += iArrPtr[i];  // これでも結果は同じ

サンプルプログラムを実行すると下記の様にただしく計算できている事が分かります。

文字列のポインタアクセス

下記のTest_02メソッドは、string型文字列をポインタアクセスして16進数で表示するコードになります。(Main関数に呼び出し部分を追加します)

        static void Main(string[] args)
        {
            // テスト01の実行
            //Test_01(100000);

            // テスト02の実行
            Test_02("Hello!");
            Test_02("ハロー!");
        // テストメソッド-02
        static void Test_02(string str)
        {
            string dumpStr = "";

            unsafe
            {
                fixed (char* cStrPtr = str)
                {
                    short* ptr = (short*)cStrPtr;
                    while (*ptr != 0) 
                    {
                        dumpStr += string.Format("0x{0:X4} ", *ptr);

                        ptr++;
                    }
                }
            }

            Console.WriteLine(string.Format("{0} => {1}", str, dumpStr));
        }

ここでのポイントは51行目のfixedステートメントで引数の文字列strのアドレスをchar型ポインタに設定している部分です。

fixed(short* sStrPtr = str)の様に直接char型以外のポインタへ渡す事は出来ません。(stringはChar型配列として扱われているため、型を合わせる必要があります)

53行目で、char型ポインタcStrPtrを(short*)でキャストして、short型ポインタに格納しています。

これは、short型(16bit – 2byte)のポインタを利用しているのは、C#のchar型はUnicode(16bit-LittleEndian)のデータを格納しているためです。

54~59行目は文字列終端までポインタを進めながら、dumpStrに16進数の文字列データを追加しています。

このプログラムを実行すると下記の様になります。

各文字のAscii、Unicodeの値がきちんと出力されました。

Marshalクラスの利用

Marshalについては、マイクロソフトのサイトに下記の様な記載があります。

Marshal クラス

アンマネージド コードを扱うときに使用できるさまざまなメソッドを提供します。これらのメソッドを使用すると、アンマネージド メモリの割り当て、アンマネージド メモリ ブロックのコピー、マネージド型からアンマネージド型への変換などができます。

https://learn.microsoft.com/ja-jp/dotnet/api/system.runtime.interopservices.marshal?view=net-6.0

これまでの例と異なるのは、オブジェクト(配列)のメモリを直接操作するのではなく、別途アンマネージメモリを確保して、そこにオブジェクトの内容をコピー(もしくはその内容をオブジェクトへコピー)するような使い方になります。

下記のTest_03メソッド(2つ)は、Marshalクラスとポインタを利用して文字列からbyte配列に、またbyte配列から文字列に変換するサンプルコードです。

using System;
using System.Collections.Generic; // List用
using System.Linq;
using System.Runtime.InteropServices; // Marshalクラス用

namespace sample_010
{
    class Program
    {
        static void Main(string[] args)
        {
            // テスト03の実行
            string srcStr = "ハロー!!";
            // string -> byte[]
            byte[] bArr = Test_03(srcStr);
            
            // byte[] -> string
            string dstStr = Test_03(bArr);
            Console.WriteLine("Test_03 : {0} -> {1}", srcStr, dstStr);

            // デバッグ終了時にコンソールが閉じない様に
            Console.Write("Hit any key to exit : ");
            Console.ReadKey();
        }
        
        // テストメソッド-03
        // string -> byte[]
        static byte[] Test_03(string str)
        {
            List<byte> bList = new List<byte>();

            // 文字列strの内容をアンマネージメモリへコピーし、そのメモリへのポインタをIntPtrに格納
            // データはUnicodeからANSI形式(ASCII+SJIS形式)へ変換される
            IntPtr strPtr = Marshal.StringToHGlobalAnsi(str);
            unsafe
            {
                // byte型ポインタを取得
                byte* ptr = (byte*)strPtr.ToPointer();

                while (*ptr != 0)
                {
                    // リストへ追加
                    bList.Add(*ptr);

                    ptr++;
                }
            }

            // 確保したアンマネージドメモリを解放
            Marshal.FreeHGlobal(strPtr);

            // byte配列へ変換
            byte[] bArr = bList.ToArray();

            // 内容を出力
            for (int i = 0; i < bArr.Length; i++)
            {
                Console.Write("{0:X2} ", bArr[i]);
            }
            Console.WriteLine();

            return bArr; 
        }

        // テストメソッド-03
        // byte[] -> string
        static string Test_03(byte[] bArr)
        {
            // バイト配列長+1のサイズのアンマネージメモリを確保
            IntPtr umPtr = Marshal.AllocHGlobal(bArr.Length + 1);

            unsafe
            {
                // byte型ポインタを取得
                byte* ptr = (byte*)umPtr.ToPointer();

                for (int i = 0; i < bArr.Length; i++)
                {
                    // byte配列の内容をアンマネージメモリへコピー
                    *ptr = bArr[i];
                    
                    ptr++;
                }
            }

            // アンマネージメモリ内容(ANSI形式)をstring型文字列として作成
            string str = Marshal.PtrToStringAnsi(umPtr);

            // 確保したアンマネージドメモリを解放
            Marshal.FreeHGlobal(umPtr);

            return str;
        }
    }
}

コードの詳細は割愛しますが(コード上のコメントを参照ください)、Marshalクラスで確保したメモリに対してポインタでアクセスして値を読み出したり、書き換えを行っています。

マイクロソフト社のWeb上のドキュメント内のサンプルコードではMarshalで確保したメモリを解放しているものはあまり見ませんが、FreeHGlobalメソッドを使ってメモリは解放した方が良いでしょう。

上記サンプルコードをビルドして実行すると下記の様になります。

byte配列化された文字列が正しく元に戻っています。

byte配列の内容がUnicode形式ではなく、ANSI形式(SJIS+ASCII)になっていますが、これはMarshalクラスのStringToHGlobalAnsi()メソッドや、PtrToStringAnsi()メソッドで変換されているためです。

ポインタアクセス時の注意

ポインタを使ってデータをアクセスする場合、メモリ管理はユーザーの責任となります。

ポインタ変数を進めて元々のオブジェクト(配列)の範囲を超えた部分の値へのアクセスは禁止です!アクセス違反(0xC0000005)となり、メモリ(内容)を破壊する事になるので注意が必要です!!

「unsafe」(安全でない)の所以ですね…

まとめ

今回は、C#でポインタを扱う方法を確認しました。

unsafeステートメント、fixedステートメント、Marshalクラスを利用しポインタでデータにアクセスする方法を紹介しました。

C#でもポインタを使ってメモリを直接アクセスする事が出来るという事が確認できました。

ただ、メモリを直接アクセスする場合は、範囲オーバーしない様にする必要があります。

次回の予定

次回は「C#で画像(Bitmap)を扱う」を予定しています。

画像データを扱うのは、身近な大きなデータと言えば画像データだからという理由です。

C#で大きなデータを扱うのは向いていないのか、そうでないのかについても見て行きたいと思います。

前回「C#は遅くない」と書いた手前、遅いという結論はあり得ませんが…。

前の記事次の記事
No.09:C#についてNo.11:C#で画像(Bitmap)を扱う①