😂

関数呼び出しの引数の数は合わせないといけない(C)

2023/03/12に公開

背景

また業務で遭遇した不具合で未定義動作を踏んでいました😢
将来のために記録に残します。

環境

VC++のプロジェクトで、 Cコードとしてコンパイルのオプション(/TC) が指定されていました。

何が起きたか?

問題が起きたのは以下のようなコードでした。funcA()の処理は適当です。

funcA.c
int funcA(int a, int b)
{
    return a + b;
}
main.c
#include <stdio.h>
extern int funcA(int a);

int main(void)
{
  // このまえに様々な処理
    printf("%d\n", funcA(3));
}

実際に問題が起きた際はfuncA()に相当する処理が想定通りに動かなかったです。それもそのはず、funcA()は実際には引数を2つ取る関数なのに、引数を一つ取る関数としてextern宣言されていたのです。

このような関数呼び出しで、渡されていないfuncAの第2引数が不定値となっていました。

なぜ不定となったのか?の予想

未定義動作なので、理由を推測するのはあまり意味のないことかもしれませんが…。

試しに、Compiler Explorerでアセンブラを出してみました。

funcA.asm
funcA(int, int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     edx, DWORD PTR [rbp-4]
        mov     eax, DWORD PTR [rbp-8]
        add     eax, edx
        pop     rbp
        ret
main.asm
.LC0:
        .string "%d\n"
main:
        push    rbp
        mov     rbp, rsp
        mov     edi, 3
        call    funcA(int)
        mov     esi, eax
        mov     edi, OFFSET FLAT:.LC0
        mov     eax, 0
        call    printf
        mov     eax, 0
        pop     rbp
        ret

funcA()を呼び出す側のmain()では、call命令をする前に、ediレジスタに3をいれて引数の準備をしています。
一方、呼ばれる側のfuncA()の処理を見ると、ediレジスタとesiレジスタを足し合わせてeaxレジスタに代入して、返り値としています。

当然ながら、呼び出し側のmain()関数では、esiレジスタには何も入れていないので、どんな値が入っているかはわかりません。

引数はどのように渡すか?(スタック経由?レジスタ?)は、採用している関数の呼出規約や、CPUのアーキテクチャによるとは思いますが、少なくとも呼び出し元で用意していない以上、どんな値になるかは当然わからないわけです。

対策

問題となったソースでは、ファイル内で使用する外部関数を、使用する関数だけピックアップしてextern宣言を書いていました。

しかしこのような運用では、extern宣言を書くたびに実際の定義とは異なる宣言をしてしまう可能性があります。新たなextern宣言を追加するたびに、不具合を埋め込む可能性があるのです。

当然ながら、外部に今回する関数用のextern宣言をまとめたヘッダファイルを用意し、関数の使用箇所でincludeするべきですね(あたりまえですが…)。

余談?

Cコードとしてコンパイルのオプション(/TC)を有効化しなかった場合はリンクエラーとなるので問題が起きなかったです。
C++には名前マングリングの仕組みがあるので、仮引数の個数が違う場合はアセンブリレベルで別の関数名となるので、本件の事象が試せないのでしょうね。
装飾名 | Microsoft Learn

感想

ヘッダを使おう。あちこちでexternしたら、修正が必要になったときの作業量えらいことにならない?(なっている)

Discussion