SAL について
Microsoft のソース コード注釈言語 (SAL) は、関数が自身のパラメーターをどのように使用するかを記述するために使用できる注釈のセット、パラメーターについての前提、および終了時の保証を提供します。 注釈はヘッダー ファイル <sal.h>
で定義されています。 C++ の Visual Studio コード分析では、SAL 注釈を使用して関数の分析を変更します。 Windows ドライバー開発用の SAL 2.0 の詳細については、「Windows ドライバーの SAL 2.0 注釈」をご覧ください。
ネイティブでは、C と C++ が提供している、開発者が意図と不変性を一貫して表現するための方法は限られています。 SAL 注釈を使用すると、関数を使用している開発者が関数の使い方をよりよく理解できるよう、関数をより詳細に記述できます。
SAL の概要とそれを使用する理由
簡単に言うと、SAL は、コンパイラがコードを確認するための安価な方法です。
SAL でコードの価値を高める
SAL は、人間とコード分析ツールの両方で、コード設計をよりわかりやすいものにするのに役立ちます。 C ランタイム関数 memcpy
を示すこの例について考えてみましょう。
void * memcpy(
void *dest,
const void *src,
size_t count
);
この関数の機能を教えてください。 関数が実装または呼び出されると、プログラムの正しさを確保するために、特定のプロパティを維持する必要があります。 例の宣言などの宣言を見ただけでは、その宣言が何であるのかわかりません。 SAL 注釈がないと、ドキュメントまたはコード コメントに依存する必要があります。 memcpy
のドキュメントの意味を以下に示します。
「
memcpy
は count バイトを src から dest にコピーします。wmemcpy
は count ワイド文字 (2 バイト) をコピーします。 コピー元とコピー先が重なり合う場合のmemcpy
の動作は未定義です。 重なり合う領域を処理するには、memmove
を使用します。
重要: コピー先のバッファーが、ソース バッファーと同じサイズ、または大きいサイズであることを確認してください。 詳しくは、「バッファー オーバーランの回避」をご覧ください。
このドキュメントには、プログラムの正確さを確保するために、コードで特定のプロパティを維持する必要があるという情報が含まれています。
memcpy
は、ソース バッファーからコピー先バッファーにcount
バイトをコピーします。宛先バッファーは、ソース バッファー以上の大きさが必要です。
ただし、コンパイラはドキュメントや非公式のコメントを読み取ることはできません。 2 つのバッファーと count
の間にリレーションシップが存在するかどうかはわかりません。また、リレーションシップについて実質的に推測できません。 SAL を使用すると、次に示すように、関数のプロパティと実装についてより明確になります。
void * memcpy(
_Out_writes_bytes_all_(count) void *dest,
_In_reads_bytes_(count) const void *src,
size_t count
);
これらの注釈はドキュメントの情報に似ていますが、簡潔であり、セマンティック パターンに従っている点に注意してください。 このコードを読むと、この関数のプロパティと、バッファー オーバーラン セキュリティの問題を回避する方法をすばやく理解できます。 さらに、SAL が提供するセマンティック パターンを使用すると、潜在的なバグを早期に検出する際に、自動化されたコード分析ツールの効率と有効性を向上させることができます。 誰かが wmemcpy
のこのバグを含む実装を記述するとします。
wchar_t * wmemcpy(
_Out_writes_all_(count) wchar_t *dest,
_In_reads_(count) const wchar_t *src,
size_t count)
{
size_t i;
for (i = 0; i <= count; i++) { // BUG: off-by-one error
dest[i] = src[i];
}
return dest;
}
この実装には、一般的な off-by-one エラーが含まれています。 幸い、コード作成者は SAL バッファー サイズ注釈を含めました。コード分析ツールでは、この関数を単独で分析することでバグをキャッチできました。
SAL の基本
SAL では、使用パターン別に分類される 4 種類の基本的なパラメーターが定義されています。
カテゴリ | パラメーター注釈 | 説明 |
---|---|---|
呼び出された関数への入力 | _In_ |
データは呼び出された関数に渡され、読み取り専用として扱われます。 |
呼び出された関数への入力と呼び出し元への出力 | _Inout_ |
使用できるデータは関数に渡され、変更される可能性があります。 |
呼び出し元への出力 | _Out_ |
呼び出し元は、呼び出された関数が書き込む領域のみを提供します。 呼び出された関数は、その空間にデータを書き込みます。 |
呼び出し元へのポインターの出力 | _Outptr_ |
呼び出し元への出力と似ています。 呼び出された関数によって返される値はポインターです。 |
これら 4 つの基本的な注釈は、さまざまな方法でより明確にできます。 既定では、注釈付きポインター パラメーターは必須と見なされます。関数が成功するには NULL 以外である必要があります。 基本注釈の最も一般的に使用されるバリエーションは、ポインター パラメーターが省略可能なことを示します。NULL の場合、関数は引き続きその処理を成功できます。
次の表は、必須パラメーターと省略可能なパラメーターを区別する方法を示しています。
パラメーターは必須です | パラメーターは省略可能です | |
---|---|---|
呼び出された関数への入力 | _In_ |
_In_opt_ |
呼び出された関数への入力と呼び出し元への出力 | _Inout_ |
_Inout_opt_ |
呼び出し元への出力 | _Out_ |
_Out_opt_ |
呼び出し元へのポインターの出力 | _Outptr_ |
_Outptr_opt_ |
これらの注釈は、初期化されていない可能性のある値と無効な null ポインターの使用を、正式かつ正確な方法で識別するのに役立ちます。 必要なパラメーターに NULL を渡した場合、クラッシュが発生したり、"失敗" エラー コードが返される可能性があります。 どちらの方法でも、関数はジョブの実行に成功できません。
SAL の例
このセクションでは、基本的な SAL 注釈のコード例を示します。
Visual Studio Code 分析ツールを使用して問題を検出する
この例では、Visual Studio Code 分析ツールを SAL 注釈と共に使用して、コードの欠陥を検出します。 その実行方法を次に示します。
Visual Studio Code 分析ツールと SAL を使用するには
Visual Studio では、SAL 注釈を含む C++ プロジェクトを開きます。
メニュー バーで、[ビルド]、[ソリューションでコード分析を実行] を選択します。
このセクションでは _In_ の例について考えてみます。 コード分析を実行すると、次の警告が表示されます。
C6387 無効なパラメーター値 'pInt' が '0' の可能性があります: これは関数 'InCallee' の指定に従っていません。
例: _In_ 注釈
_In_
注釈は以下を示します。
パラメーターは有効である必要があり、変更できません。
関数は、単一要素バッファーからのみ読み取ります。
呼び出し元は、バッファーを指定して初期化する必要があります。
_In_
は「読み取り専用」を指定します。 一般的な間違いは、代わりに_Inout_
注釈を持つ必要があるパラメーターに_In_
を適用することです。_In_
は許可されますが、ポインター以外のスカラーではアナライザーによって無視されます。
void InCallee(_In_ int *pInt)
{
int i = *pInt;
}
void GoodInCaller()
{
int *pInt = new int;
*pInt = 5;
InCallee(pInt);
delete pInt;
}
void BadInCaller()
{
int *pInt = NULL;
InCallee(pInt); // pInt should not be NULL
}
この例で Visual Studio Code 分析を使用すると、呼び出し元が pInt
の初期化されたバッファーに Null 以外のポインターを渡すことが検証されます。 この場合、pInt
ポインターを NULL にすることはできません。
例: _In_opt_注釈
_In_opt_
は _In_
と同じです。ただし、入力パラメーターに NULL を指定できるので、関数はこれを確認する必要があります。
void GoodInOptCallee(_In_opt_ int *pInt)
{
if(pInt != NULL) {
int i = *pInt;
}
}
void BadInOptCallee(_In_opt_ int *pInt)
{
int i = *pInt; // Dereferencing NULL pointer 'pInt'
}
void InOptCaller()
{
int *pInt = NULL;
GoodInOptCallee(pInt);
BadInOptCallee(pInt);
}
Visual Studio Code 分析では、関数がバッファーにアクセスする前に NULL がチェックされます。
例: _Out_ 注釈
_Out_
では、要素バッファーを指す NULL 以外のポインターが渡され、関数によって要素が初期化される一般的なシナリオがサポートされています。 呼び出し元は、呼び出しの前にバッファーを初期化する必要はありません。呼び出された関数は、戻る前に初期化を約束します。
void GoodOutCallee(_Out_ int *pInt)
{
*pInt = 5;
}
void BadOutCallee(_Out_ int *pInt)
{
// Did not initialize pInt buffer before returning!
}
void OutCaller()
{
int *pInt = new int;
GoodOutCallee(pInt);
BadOutCallee(pInt);
delete pInt;
}
Visual Studio Code 分析ツールは、呼び出し元が pInt
のバッファーに NULL 以外のポインターを渡し、バッファーが戻る前に関数によって初期化されるのを検証します。
例: _Out_opt_注釈
_Out_opt_
は _Out_
と同じです。ただし、パラメーターに NULL を指定できるので、関数はこれを確認する必要があります。
void GoodOutOptCallee(_Out_opt_ int *pInt)
{
if (pInt != NULL) {
*pInt = 5;
}
}
void BadOutOptCallee(_Out_opt_ int *pInt)
{
*pInt = 5; // Dereferencing NULL pointer 'pInt'
}
void OutOptCaller()
{
int *pInt = NULL;
GoodOutOptCallee(pInt);
BadOutOptCallee(pInt);
}
Visual Studio Code 分析では、pInt
が逆参照される前にこの関数がチェックされ、pInt
が NULL ではない場合は、バッファーが戻る前に関数によって初期化されます。
例: _Inout_ 注釈
_Inout_
は、関数によって変更される可能性があるポインター パラメーターに注釈を付ける場合に使用します。 ポインターは、呼び出しの前に有効な初期化データを指す必要があります。変更された場合でも、戻り時に有効な値を持っている必要があります。 注釈は、関数が 1 要素バッファーとの間で自由に読み取りおよび書き込みを行うことができることを指定します。 呼び出し元は、バッファーを指定して初期化する必要があります。
Note
_Out_
と同様に、_Inout_
は変更可能な値に適用する必要があります。
void InOutCallee(_Inout_ int *pInt)
{
int i = *pInt;
*pInt = 6;
}
void InOutCaller()
{
int *pInt = new int;
*pInt = 5;
InOutCallee(pInt);
delete pInt;
}
void BadInOutCaller()
{
int *pInt = NULL;
InOutCallee(pInt); // 'pInt' should not be NULL
}
Visual Studio Code 分析では、呼び出し元が pInt
の初期化されたバッファーに NULL 以外のポインターを渡し、戻る前に pInt
が引き続き NULL 以外のポインターで、バッファーが初期化された状態を検証します。
例: _Inout_opt_注釈
_Inout_opt_
は _Inout_
と同じです。ただし、入力パラメーターに NULL を指定できるので、関数はこれを確認する必要があります。
void GoodInOutOptCallee(_Inout_opt_ int *pInt)
{
if(pInt != NULL) {
int i = *pInt;
*pInt = 6;
}
}
void BadInOutOptCallee(_Inout_opt_ int *pInt)
{
int i = *pInt; // Dereferencing NULL pointer 'pInt'
*pInt = 6;
}
void InOutOptCaller()
{
int *pInt = NULL;
GoodInOutOptCallee(pInt);
BadInOutOptCallee(pInt);
}
Visual Studio Code 分析では、バッファーにアクセスする前にこの関数がチェックされ、pInt
が NULL ではない場合は、バッファーが戻る前に関数によって初期化されます。
例: _Outptr_ 注釈
_Outptr_
は、ポインターを返すパラメーターに注釈を付ける場合に使用します。 パラメーター自体を NULL にすることはできません。呼び出された関数は、その中に NULL 以外のポインターを返し、そのポインターは初期化されたデータを指します。
void GoodOutPtrCallee(_Outptr_ int **pInt)
{
int *pInt2 = new int;
*pInt2 = 5;
*pInt = pInt2;
}
void BadOutPtrCallee(_Outptr_ int **pInt)
{
int *pInt2 = new int;
// Did not initialize pInt buffer before returning!
*pInt = pInt2;
}
void OutPtrCaller()
{
int *pInt = NULL;
GoodOutPtrCallee(&pInt);
BadOutPtrCallee(&pInt);
}
Visual Studio Code 分析は、呼び出し元が *pInt
の NULL 以外のポインターを渡し、バッファーが戻る前に関数によって初期化されるのを検証します。
例: _Outptr_opt_注釈
_Outptr_opt_
は _Outptr_
と同じですが、パラメーターが省略可能である場合を除き、呼び出し元はパラメーターの NULL ポインターを渡すことができます。
void GoodOutPtrOptCallee(_Outptr_opt_ int **pInt)
{
int *pInt2 = new int;
*pInt2 = 6;
if(pInt != NULL) {
*pInt = pInt2;
}
}
void BadOutPtrOptCallee(_Outptr_opt_ int **pInt)
{
int *pInt2 = new int;
*pInt2 = 6;
*pInt = pInt2; // Dereferencing NULL pointer 'pInt'
}
void OutPtrOptCaller()
{
int **ppInt = NULL;
GoodOutPtrOptCallee(ppInt);
BadOutPtrOptCallee(ppInt);
}
Visual Studio Code 分析では、*pInt
が逆参照される前にこの関数に NULL があるかどうかがチェックされ、バッファーが戻る前に関数によって初期化されます。
例: _Success_ 注釈と _Out_ の組み合わせ
注釈は、ほとんどのオブジェクトに適用できます。 特に、関数全体に注釈付けできます。 関数の最も明白な特性の 1 つは、成功または失敗するということです。 ただし、バッファーとそのサイズの関連付けと同様に、C/C++ では関数の成功または失敗を表現できません。 _Success_
注釈を使用すると、関数の成功の様子がわかります。 _Success_
注釈のパラメーターは true の場合は関数が成功したと示す単なる式です。 式は、注釈パーサーが処理できる何でも指定できます。 関数が戻った後の注釈の効果は、関数が成功した場合にのみ適用されます。 この例では、_Success_
が _Out_
とやり取りして正しい操作を行う方法を示します。 キーワード return
を使用して戻り値を表します。
_Success_(return != false) // Can also be stated as _Success_(return)
bool GetValue(_Out_ int *pInt, bool flag)
{
if(flag) {
*pInt = 5;
return true;
} else {
return false;
}
}
_Out_
注釈を使用すると、Visual Studio Code 分析は、呼び出し元が pInt
のバッファーに NULL 以外のポインターを渡し、バッファーが戻る前に関数によって初期化されるのを検証します。
SAL のベスト プラクティス
既存のコードに注釈を追加する
SAL は、コードのセキュリティと信頼性を向上させるのに役立つ強力なテクノロジです。 SAL を学習した後は、毎日の作業に新しいスキルを適用できます。 新しいコードでは、設計によって SAL ベースの指定を使用できます。古いコードでは、注釈を段階的に追加して、更新するごとにメリットを増やします。
Microsoft パブリック ヘッダーには既に注釈付けされています。 そのため、プロジェクトでは、まず Win32 API を呼び出すリーフ ノード関数と関数に注釈を付け、最大のメリットを享受することをお勧めします。
注釈を付けるタイミング
ガイドラインを次に示します。
すべてのポインター パラメーターに注釈を付ける。
バッファーとポインターの安全性をコード分析によって確保するために値範囲注釈に注釈を付ける。
ロック規則とロックの副作用に注釈を付ける。 詳細については、「ロック動作に注釈を付ける」をご覧ください。
ドライバーのプロパティと他のドメイン固有のプロパティに注釈を付ける。
または、すべてのパラメーターに注釈を付け、意図を全体にわたって明確にし、注釈が行われたことを簡単に確認できます。