[플밍노트] strcpy_s에서 예외가 잡히지 않는 이유

어제 직원이 strcpy_s를 사용하면 예외가 잡히지 않는다며 코드를 보내왔다. 아래 코드다. 어떻게 컴파일하든 catch가 출력되는 것을 볼 수 없다. strcpy_s대신 strcpy를 하면 잘 된다는 이야기.

#include <Windows.h>

int main()
{
    __try
    {
        strcpy_s(0, 10, "aaaa");
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        printf("catch\n");
    }
}

그래서 알아보기로 했다. 왜 예외가 잡히지 않는지를…​ strcpy_s는 다음과 같이 구현되어 있다.

extern "C" errno_t __cdecl strcpy_s(
    char*       const destination,
    size_t      const size_in_elements,
    char const* const source
    )
{
    return common_tcscpy_s(destination, size_in_elements, source);
}

common_tcscpy_s로 들어가보면 다음과 같다.

// _strcpy_s() and _wcscpy_s()
template <typename Character>
_Success_(return == 0)
static errno_t __cdecl common_tcscpy_s(
    _Out_writes_z_(size_in_elements) Character* const destination,
    _In_                                 size_t const size_in_elements,
    _In_z_                     Character const* const source
    ) throw()
{
    _VALIDATE_STRING(destination, size_in_elements);
    _VALIDATE_POINTER_RESET_STRING(source, destination, size_in_elements);

    Character*       destination_it = destination;
    Character const* source_it      = source;

    size_t available = size_in_elements;
    while ((*destination_it++ = *source_it++) != 0 && --available > 0)
    {
    }

    if (available == 0)
    {
        _RESET_STRING(destination, size_in_elements);
        _RETURN_BUFFER_TOO_SMALL(destination, size_in_elements);
    }
    _FILL_STRING(destination, size_in_elements, size_in_elements - available + 1);
    _RETURN_NO_ERROR;
}

_VALIDATE_STRING 매크로에서 destination이 NULL인 경우 체크가 들어간다는 것을 알 수 있다.

/* validations */
#define _VALIDATE_STRING_ERROR(_String, _Size, _Ret) \
    _VALIDATE_RETURN((_String) != NULL && (_Size) > 0, EINVAL, (_Ret))

#define _VALIDATE_STRING(_String, _Size) \
    _VALIDATE_STRING_ERROR((_String), (_Size), EINVAL)

_VALIDATE_RETURN 매크로는 _INVALID_PARAMETER 호출로 이어진다.

#define _VALIDATE_RETURN(expr, errorcode, retexpr)                             \
    {                                                                          \
        int _Expr_val = !!(expr);                                              \
        _ASSERT_EXPR((_Expr_val), _CRT_WIDE(#expr));                           \
        if (!(_Expr_val))                                                      \
        {                                                                      \
            *_errno() = (errorcode);                                           \
            _INVALID_PARAMETER(_CRT_WIDE(#expr));                              \
            return (retexpr);                                                  \
        }                                                                      \
    }

_INVALID_PARAMETER는 릴리즈 버전에서 _invalid_parameter_noinfo 호출로 이어진다.

#ifdef _DEBUG
    #define _INVALID_PARAMETER(expr) _invalid_parameter(expr, __FUNCTIONW__, __FILEW__, __LINE__, 0)
#else
    #define _INVALID_PARAMETER(expr) _invalid_parameter_noinfo()
#endif

_invalid_parameter_noinfo는 최종적으로 _invalid_parameter_internal 호출로 이어진다.

extern "C" void __cdecl _invalid_parameter_noinfo()
{
    _invalid_parameter(nullptr, nullptr, nullptr, 0, 0);
}

extern "C" void __cdecl _invalid_parameter(
    wchar_t const* const expression,
    wchar_t const* const function_name,
    wchar_t const* const file_name,
    unsigned int   const line_number,
    uintptr_t      const reserved
    )
{
    __crt_cached_ptd_host ptd;
    return _invalid_parameter_internal(expression, function_name, file_name, line_number, reserved, ptd);
}

_invalid_parameter_internal을 살펴보면 다음과 같다. _thread_local_iph 핸들러가 있는 경우 해당 핸들러를, 없는 경우 글로벌 핸들러를, 그마저도 없는 경우에는 _invoke_watson으로 이어진다. 일반적으로 핸들러 설정이 되어 있지 않기 때문에 _invoke_watson 호출로 진행된다.

extern "C" void __cdecl _invalid_parameter_internal(
    wchar_t const*     const expression,
    wchar_t const*     const function_name,
    wchar_t const*     const file_name,
    unsigned int       const line_number,
    uintptr_t          const reserved,
    __crt_cached_ptd_host&   ptd
    )
{
    __acrt_ptd * const raw_ptd = ptd.get_raw_ptd_noexit();
    if (raw_ptd && raw_ptd->_thread_local_iph)
    {
        raw_ptd->_thread_local_iph(expression, function_name, file_name, line_number, reserved);
        return;
    }

    _invalid_parameter_handler const global_handler = __crt_fast_decode_pointer(__acrt_invalid_parameter_handler.value(ptd));
    if (global_handler)
    {
        global_handler(expression, function_name, file_name, line_number, reserved);
        return;
    }

    _invoke_watson(expression, function_name, file_name, line_number, reserved); <== null 인경우 이쪽으로 도달
}

_invoke_watson은 다음과 같이 구현되어 있다. 일반적으로 PF_FASTFAIL_AVAILABLE 플래그가 설정되어 있어서 __fastfail 호출로 이어진다.

extern "C" __declspec(noreturn) void __cdecl _invoke_watson(
	wchar_t const* const expression,
	wchar_t const* const function_name,
	wchar_t const* const file_name,
	unsigned int   const line_number,
	uintptr_t      const reserved
	)
{
	UNREFERENCED_PARAMETER(expression   );
	UNREFERENCED_PARAMETER(function_name);
	UNREFERENCED_PARAMETER(file_name    );
	UNREFERENCED_PARAMETER(line_number  );
	UNREFERENCED_PARAMETER(reserved     );

	if (IsProcessorFeaturePresent(PF_FASTFAIL_AVAILABLE))
	{
		__fastfail(FAST_FAIL_INVALID_ARG);
	}

	// Otherwise, raise a fast-fail exception and termintae the process:
	__acrt_call_reportfault(
		_CRT_DEBUGGER_INVALIDPARAMETER,
		STATUS_INVALID_CRUNTIME_PARAMETER,
		EXCEPTION_NONCONTINUABLE);

	TerminateProcess(GetCurrentProcess(), STATUS_INVALID_CRUNTIME_PARAMETER);
}

__fastfail은 아래 문서를 참고하면 int 0x29로 구현된 컴파일러 함수다.
https://docs.microsoft.com/en-us/cpp/intrinsics/fastfail?view=msvc-170

결국 strcpy_s는 최종적으로 int 0x29로 이어진다. fastfail이란 말에서 알 수 있듯이 해당 인터럽트는 예외에 잡히지 않는다. 결국 strcpy_s 내부적으로 널체크를 해서 특수한 처리를 했기 때문에 예외에 잡히지 않는다. strcpy_s((char*)0x123, 10, "aaaa"); 등과 같이 호출하면 정상적으로 예외 처리가 이루어진다.

int main()
{
    __try
    {
        asm int 0x29
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        printf("catch\n");
    }
}

strcpy_s 입장에서는 NULL이면 프로그램을 종료시켜버리는 게 안전한 구현일 수 있다. 하지만 경우에 따라서는 오바하는 함수가 될수도 있겠다. 내가 알아서 할건데 왜 고작 문자열 복사하는 기능만 하면 될 함수가 릴리즈 버전에서 널체크까지 해가며 멋대로 강종시키지? 프로그램을? 예외처리도 안되게?