djm03178's profile image

djm03178

March 17, 2020 22:30

GCC 확장 기능 2

GCC , extension , C , C++

서론

지난 글에서는 GCC에서 다양한 목적들을 가진 방대한 양의 확장 기능들을 제공한다는 것을 보았습니다. 이번 글에서는 지난 글에 이어 더 많은 확장 기능들을 살펴보고 어떻게 활용할 수 있을지에 대해 설명하도록 하겠습니다.

포인터를 사용한 goto

표준에서 goto문은 이름을 지정한 레이블로만 점프가 가능합니다. GCC에서는 이를 확장시켜서, 레이블이 위치한 주소를 얻어와 포인터를 사용하여 goto를 할 수 있도록 만들어 줍니다. 예시 코드는 다음과 같습니다.

#include <stdio.h>

int main(void)
{
	int i = 0;
	void *ptr = &&start;
start:
	printf("hello\n");
	if (++i == 10)
		return 0;
	goto *ptr;
}

이 코드에서 &&라는 것은 표준에 존재하지 않는 단항 연산자로,1 void *형을 반환합니다. start라는 레이블의 주소를 얻어와 ptr에 담고, 이후 goto *ptr;이라는 문장을 통해 start의 위치로 점프하는 것을 볼 수 있습니다. 이 기능은 같은 함수 내에서만 사용이 가능합니다.

보다 활용하면 다음과 같은 사용도 가능합니다.

#include <stdio.h>

int main(void)
{
	void *ptr[4] = {NULL, &&first, &&second, &&third};
	int n;
	scanf("%d", &n);
	goto *ptr[n];

first:
	printf("1\n");
	return 0;
second:
	printf("2\n");
	return 0;
third:
	printf("3\n");
	return 0;
}

ptr 배열에 세 레이블의 주소를 담아놓고 1부터 3까지의 정수 중 하나를 입력하면 해당 수를 출력하는 레이블로 점프하게 됩니다.

지역 레이블

본래 레이블은 함수 scope로만 선언이 가능하지만, GCC에서는 레이블들을 블록 단위로 지역적으로 선언하는 것도 가능합니다. 다음의 예시를 봅시다.

#include <stdio.h>

int main(void)
{
	printf("main start\n");
	{
		__label__ end;
		printf("loop start\n");
		for (int i = 1; i <= 9; i++)
		{
			for (int j = 1; j <= 9; j++)
			{
				printf("%d * %d = %d\n", i, j, i * j);
				if (i * j == 15)
					goto end;
			}
		}
	end:
		printf("loop end\n");
	}
end:
	printf("main end\n");
}

main 함수 내에 end라는 레이블이 두 개 선언되어 있음을 볼 수 있습니다. 본래는 이렇게 사용할 수 없으나, GCC에서는 확장 기능인 __label__을 사용하면 가능합니다. __label__은 반드시 그 블록의 시작에서 사용해야 하며, 그 블록 내에 해당 이름의 레이블을 선언해줘야 합니다. 콤마로 여러 레이블을 지정해줄 수도 있습니다.

이 프로그램을 실행하면 구구단이 출력되다가 3 * 5를 출력한 후 end라는 레이블로 점프를 하게 되는데, 안쪽 블록에서 지역적으로 선언한 endmain 전체에 선언된 end를 가리기 때문에 블록 내부에 있는 end로 점프하여 "loop end"를 먼저 출력하게 됩니다.

이 확장 기능은 #define 매크로를 사용하여 코드 블록을 생성할 때 유용합니다. 위 코드와 같이 goto는 C에서 다중 루프를 손쉽게 탈출하는 방법으로 자주 사용되는데, 이 기능을 사용하지 않을 경우 자칫 레이블 이름이 해당 매크로를 사용한 함수에서 선언한 이름과 충돌할 여지가 있기 때문입니다.

가운데항이 없는 삼항 연산자

이 확장 기능은 언뜻 보기엔 굉장히 이상하게 생겼습니다. 삼항 연산자인데, 가운데항이 없다니요. 실제로 이 기능은 다음과 같이 생겼습니다.

#include <stdio.h>

int main(void)
{
	int n;
	scanf("%d", &n);
	printf("%d\n", n ? : -1);
	return 0;
}

이 코드의 동작은, 입력받은 값이 0이 아니면 그 수를 그대로 출력하고, 0이면 -1을 출력합니다. 눈치채셨을 수도 있지만, 이 확장 기능은 가운데항을 좌항과 똑같은 값으로 해주는 것입니다. 대체 이런 기능이 왜 필요할까 싶을 수도 있지만, 알고 보면 이것도 매크로를 사용할 때 제법 유용할 수 있습니다. 좌항의 ‘연산’을 삼항 연산자의 가운데항에도 그대로 사용할 경우 해당 연산이 두 번 일어날 수 있는데, 이 확장 기능을 사용하면 좌항만을 계산하고 조건을 만족하면 그 값을 그대로 사용하게 만들어 주기 때문입니다.

void 포인터와 함수 포인터 연산

본래 void * 및 함수 포인터에는 덧셈, 뺄셈을 할 수 없습니다. 가리키는 대상의 크기를 알 수 없기 때문입니다. 그러나 GCC에서는 이들을 1바이트로 계산하여 연산을 하는 것이 가능합니다. 또한 이들이 가리키는 대상의 크기도 1바이트로 간주합니다.

#include <stdio.h>

int main(void)
{
	char arr[4] = "abc";
	void *ptr = (void *)arr;
	printf("%c\n", *(char *)(ptr+1));
	printf("%u\n", sizeof(*ptr));
	printf("%u\n", sizeof(main));
	return 0;
}

이 프로그램의 실행 결과는 b 1 1입니다. ptrvoid 포인터이니 표준상으로는 여기에 1을 더하는 것이 불가능하지만, GCC에서는 1바이트를 증가시켜주기 때문에 그 증가된 주소를 char 포인터로 변환한 다음 값을 읽어오도록 할 수 있는 것을 볼 수 있습니다. 마찬가지로 *ptr의 크기를 sizeof 연산자로 얻어내면 1을 반환하고, main이라는 하나의 함수의 크기 역시 1바이트로 간주하는 것을 확인할 수 있습니다.

속성 지정

이 확장 기능은 저레벨 프로그래밍에서 실제로도 아주 많이 사용되는 기능입니다. 때로는 함수가 특정한 동작을 하도록 지정하거나, 컴파일러에게 강력한 최적화를 진행할 수 있도록 힌트를 줄 수도 있고, 특정 아키텍처를 위한 특수한 속성을 지정할 수도 있습니다. 크게 함수 속성, 변수 속성, 자료형 속성, 레이블 속성, 열거자 속성, 문장 속성 등으로 나눌 수 있습니다. 이러한 속성은 __attribute__ 키워드를 사용하여 지정할 수 있습니다.

일반적인 사용법은 __attribute__ ((attribute-list))입니다. 속성에 따라 추가 인자를 줄 수 있는 경우 이중 소괄호 안에 넣어줄 수 있습니다. 자주 사용되고 많은 세부 속성들을 가지고 있는 함수 속성과 변수 속성에 대해서 조금 더 자세하게 알아보겠습니다.

함수 속성

함수의 호출 과정은 상당히 많은 연산을 필요로 하며 그 동작 방식은 프로그램 전체의 실행 흐름이나 성능에 크게 영향을 끼칠 수 있습니다. 따라서 특수한 경우에 함수의 속성을 지정해주면 컴파일러가 함수 호출이 더 빠르게 이루어질 수 있도록 최적화를 하거나 특정한 동작을 반드시 수행해야 하는 경우를 컴파일 시간에 체크할 수 있도록 만들어줄 수 있습니다.

대표적으로 자주 사용되는 속성에는 다음과 같은 것들이 있습니다.

  • always_inline: 직접적으로 호출하는 경우 해당 함수가 반드시 inline이 되도록 만듭니다. 이는 일반적인 inline 키워드는 단지 컴파일러에게 힌트를 주는 역할이라는 점과는 대조됩니다.
  • const: 외부 환경 (전역 변수 등)과 영향을 주고받지 않으며, 호출될 때의 인자가 동일하다면 이전 호출에서의 반환값을 그대로 사용해도 된다는 의미입니다. 오로지 인자의 값에만 의존하여 반환값이 결정되는 수학 함수 등에 유용합니다.
  • deprecated: 사용을 권장하지 않는 함수에 사용됩니다. 프로그램의 이후 버전에서 제거될 예정인 함수 등에 사용할 수 있습니다. 이 함수에 대한 호출을 컴파일러가 발견하면 경고를 내보냅니다.
  • error, warning: 해당 함수의 사용을 감지하면 에러 또는 워닝을 내보냅니다. 메시지를 추가로 첨부할 수 있습니다.
  • format: 포맷 문자열과 인자들이 주어질 때 각 포맷에 대한 올바른 타입의 인자가 주어졌는지를 컴파일 타임에 계산할 수 있습니다. 미리 정의된 몇 가지 형태로만 (printf, scanf 등) 사용이 가능하고, 타겟에 따라 추가적인 포맷들이 지원되기도 합니다.
  • noinline: 절대로 함수가 inline될 수 없게 만듭니다.
  • noreturn: 절대로 반환하지 않는 함수를 의미합니다. 프로그램을 강제 종료시키는 함수나 무한 루프를 도는 함수 등이 예시가 될 수 있습니다.
  • target: 컴파일 옵션으로 지정된 아키텍처가 아닌 다른 특정 아키텍처를 타겟으로 하여 함수 바이너리를 생성합니다.
  • warn_unused_result: GCC로 C 코드를 컴파일할 때 많은 사람들이 scanf 사용 시 받아보았을 워닝의 정체입니다. 함수의 반환값을 사용하지 않을 경우 경고를 내보냅니다.

이외에도 수많은 함수 속성들이 있으며, 아키텍처별 확장 속성들도 존재합니다. 필요에 따라 찾아서 사용할 수 있습니다.

변수 속성

함수와 비슷하게, 변수에도 여러 속성들을 부여하여 성능을 개선하고 컴파일러에게 힌트를 줄 수 있습니다.

  • alinged: 변수가 특정 수의 배수인 메모리 주소에 할당되게 만듭니다.2
  • deprecated: 해당 변수의 사용을 권장하지 않을 때 사용합니다. 이 변수의 사용이 확인되면 컴파일러가 워닝을 내보냅니다.
  • packed: 최소 단위로 align시킵니다.
  • used: 변수의 사용이 감지되지 않더라도 반드시 그 변수가 메모리에 할당되도록 만듭니다.

현재 함수의 이름 얻기

GCC에서는 현재 함수의 이름을 C 문자열로 얻어낼 수 있게 해줍니다. __func__가 그것인데, 이것은 암시적으로 모든 함수에 다음과 같이 선언되어 있는 것처럼 동작합니다.

static const char __func__[] = "function-name";

예를 들어 다음과 같은 코드를 만들 수 있습니다.

#include <stdio.h>

void f(void)
{
	puts(__func__);
}

void g(void)
{
	puts(__func__);
}

int main(void)
{
	f();
	g();
	return 0;
}

이 프로그램은 fg를 한 줄에 하나씩 출력합니다. f 함수에서는 __func__가 “f”로 지정되어 있고, g 함수에서는 __func__가 “g”로 지정되어 있기 때문입니다.

__func__ 대신 더 오래된 GCC 버전과의 호환을 위한 __FUNCTION__도 사용할 수 있으며, C++ 한정으로 __PRETTY_FUNCTION__을 사용하면 함수가 속한 클래스나 반환형, 인자의 목록 및 자료형까지 모아서 문자열로 볼 수 있습니다.

반환 주소 얻어내기

자신을 호출한 함수, 또는 그 이전의 함수가 무엇인지를 C 표준만으로 얻어낼 수는 없습니다. GCC에서는 내장 함수를 통해 이 주소를 얻어내는 방법을 제공합니다. 또한 그 함수의 스택 프레임 주소도 얻어낼 수 있습니다. 이 역할을 하는 두 함수는 다음과 같습니다.

void * __builtin_return_address (unsigned int level);
void * __builtin_frame_address (unsigned int level);

level이라는 것은 호출 스택에서 몇 단계 위에 있는 함수를 찾고자 하는 것인지를 의미합니다. 이 값은 0부터 시작합니다. 예를 들어 main -> f -> g와 같은 호출 스택이 만들어졌고 g 함수에서 __builtin_return_address(0)을 호출하면 f에서 g를 호출한 지점 직후의 주소가 반환될 것이고, __builtin_return_address(1)을 호출하면 main에서 f를 호출한 지점 직후의 주소가 반환될 것입니다. 마찬가지로 __builtin_frame_address(0)f 함수의 스택 프레임의 시작 지점을 나타내고, __builtin_frame_address(1)main 함수의 스택 프레임의 시작 주소를 나타냅니다.

일반적으로 사용될 일도 그다지 없고 환경에 따라서는 다단계로 호출 스택을 따라 올라가는 것이 불가능할 수도 있습니다. 단, 저레벨 프로그램을 디버깅하는 용도로는 사용될 여지가 충분한 기능입니다.

정수 오버플로우 체크

C 표준상으로 어떤 정수 연산의 결과가 오버플로우를 일으킬지를 안전하게 확인하는 방법은 각 연산에 대해 오버플로우가 나지 않는 방법으로 적당한 수식을 세워 자료형의 최소/최댓값을 넘지 않는지 확인하는 수밖에 없습니다. GCC에서는 이들을 더욱 편하게 확인하는 방법을 제공해 줍니다. 바로 __builtin_xxx_overflow 시리즈입니다. 덧셈, 뺄셈, 곱셈에 대해 각각 제공되며, 임의의 정수형을 사용할 수 있는 버전과 int, long, long long, 그리고 이들의 unsigned 버전들이 개별적으로 제공됩니다.

bool __builtin_add_overflow (type1 a, type2 b, type3 *res);
bool __builtin_sub_overflow (type1 a, type2 b, type3 *res);
bool __builtin_mul_overflow (type1 a, type2 b, type3 *res);

위 세 내장 함수는 임의의 정수형에 대해 사용이 가능하며 그 자료형에 대한 오버플로우 여부를 검사해 줍니다. ab의 연산 결과를 ‘res’가 가리키는 위치에 *res의 자료형으로 형변환해서 저장해 줍니다. 이 값이 type3의 범위로 나타낼 수 없는 값이면 true를, 나타낼 수 있는 값이면 false를 반환합니다.

이 함수들의 또 하나의 좋은 점은 연산 과정이 마치 자료형의 길이가 무한한 것처럼 동작한다는 것입니다. 무슨 뜻이냐면, 비록 이 연산이 해당 자료형에 대해 오버플로우를 일으키는 연산이더라도, undefined behavior 없이 온전한 연산이 수행된 뒤에, 역시 올바른 캐스팅을 통해 *res에 값을 저장해준다는 뜻입니다.

이 함수들의 signed 버전은 __builtin_sadd_overflow와 같이 s가 하나 붙으며, unsigned 버전은 u, long__builtin_saddl_overflow와 같이 뒤쪽에 l이 붙고, long long은 ll이 붙습니다. 이름의 맨 뒤에 _p가 붙은 버전들도 있는데, 이때는 res가 포인터가 아닌 일반 값이며, 이 값 자체에는 의미가 없고 오로지 그 연산 결과가 type3로 표현할 수 있는 값인지 여부를 확인하는 용도로만 사용됩니다.

이진법 상수

GCC에서는 0과 1로 이루어진 이진법 상수를 0b 접두사를 이용하여 표현할 수 있습니다. 일반적으로 16진법을 사용하여 비트 단위의 분석을 하는 것보다 훨씬 직관적입니다.

#include <stdio.h>

int main(void)
{
	printf("%d\n", 0b101010); // 42
	return 0;
}

마치며

이번 글에서도 흥미롭고 유용한 GCC의 확장 기능들을 많이 볼 수 있었습니다. 어떻게 보면 C라는 언어 자체가 제공하는 기능이나 편리성의 부족함을 GCC가 대신 해결해 준 것 같지만, 그만큼 C는 저레벨에서의 다양한 상황에 대한 처리를 개발자들에게 자유롭게 맡기는 철학을 가지고 있고, 그렇기에 자연스럽게 문법과 키워드, 그리고 내장 함수들을 추가할 수 있었다고 볼 수도 있습니다. 이러한 장점을 GCC가 실용적인 방향으로 잘 활용하여 언어를 개량했다고 할 수 있을 것입니다.

까다로운 뒷처리를 개발자가 전부 책임져야 한다는 큰 부담에도 불구하고 C라는 언어가 여전히 저레벨 프로그래밍에서 대세를 점할 수 있는 것은 컴파일러 개발자들의 C의 기능 추가를 위한 많은 노력 덕분이 아닐까 생각합니다.

참고 자료

  • https://gcc.gnu.org/onlinedocs/gcc/C-Extensions.html
  1. 이항 연산자로 쓰이는 &&와는 다릅니다. 

  2. 일반적으로 메모리에서 효율적으로 값을 읽고 쓰기 위해 자동적으로 4바이트, 8바이트, 혹은 16바이트로 정렬을 시키는데 이를 강제로 조정하여 공간을 압축하는 등의 효과를 낼 수 있습니다.