프로그래밍 초보 탈출

Tips & Tricks

[C언어] 펌웨어 프로그래밍에서 volatile의 중요성

째즈토끼 2022. 4. 23. 10:39

출처: 다음사전

C언어 컴파일러 지시자 중에 volatile 이란 것이 있다. 컴파일러에 따라 __volatile__ 등의 다른 이름일 수도 있다. 개념적으로 말하자면 사전 의미 그대로 휘발성 자료다. 지금은 안에 1이 들어 있지만 잠시 후 다시 읽어보면 값이 날아가 버리고 바뀌어 있을 수도 있는 변수다. 물론 나는 바꾸지 않았다. 그럼 누가 바꿨단 말인가?

펌웨어 프로그래밍을 해보면 레지스터라는 놈들이 메모리에 매핑되어 있는데, 얘들은 모두 volatile 메모리라고 보시면 된다. 내가 바꾸지 않아도 버튼을 누르면 값이 바뀌는 것처럼..

읽을 때뿐만이 아니라 쓸 때도 마찬가지다.

int I;

I = 1;
I = 2;
I = 3;

이런 코드를 만들어 놓으면 컴파일러는 "이게 뭐하는 짓이냐!"며 앞부분은 무시하고 "I=3;" 요것만 컴파일한다. 나름 최적화(Optimizing) 해주는 것이다. 하지만 I 가 volatile로 선언된 변수라면 무시하지 못하고, 1 넣고, 2 넣고, 3 넣고, 하라는 대로 잘한다. I가 사실은 UART의 TX레지스터이고 volatile이 아니라면, 1,2,3을 순서대로 보낼 목적으로 저렇게 적었는데, 3만 날아가는 결과가 생긴다.

레지스터들은 SDK에서 이미 volatile로 선언되어 있을 테니 별 문제없지만, 직접 번지를 지정해서 정의하는 경우에는 반드시 volatile을 붙이도록 하자.

또, 인터럽트가 바꿨을 수도 있다. 전역 변수를 인터럽트 내부에서도 쓰고, 외부에서도 사용하는 경우가 가장 흔히 문제가 발생하는 부분이다. 컴파일러는 최적화 과정에서 전역 변수의 내용을 CPU 레지스터로 옮겨서 참조하는 경우가 많은데, 그러다 인터럽트가 걸리면서 그 값을 바꿔도, 밖에서는 바뀐 것을 인지하지 못할 수가 있다.

bool g_button_pressed = false;

void interrupt_function(void)
{
	g_button_pressed = true;
}

void main(void)
{
	while (!g_button_pressed) wait();

	// 일을 시작하자
}

흔히 사용할 수 있는 방식인데, g_button_pressed가 volatile 선언이 되어 있지 않음으로 인해, 절대 일을 시작하지 못하게 된다. (컴파일러마다 조금씩 동작이 다르고, 최적화 옵션에 따라 다른 결과가 나올 수 있다.)

위의 g_button_pressed 선언을

volatile bool g_button_pressed = false;

요렇게만 고치면 main() 함수는 g_button_pressed 변수를 CPU 레지스터로 옮겨 놓고 쓰는 게 아니라 필요할 때마다 직접 참조하므로 문제가 해결된다.

그러니 인터럽트 내부에서만 쓰거나 혹은 외부에서만 사용하는 변수, 즉 다른 루틴의 방해가 끼어들 여지가 없는 변수들은 volatile을 선언할 필요가 없다. 해도 되지만 최적화가 되지 않으므로 처리 속도가 떨어진다.

그렇다면 PC 프로그램에서는 어떨까? 예전의 DOS 시절에는 인터럽트 루틴도 직접 작성하는 경우가 있어 같은 고민을 했지만, Windows 시대에는 인터럽트를 직접 처리하는 경우는 없다. 그 대신 Threads라는 개념이 생겼다. Windows에서는 개인적으로 확신은 없지만, 아마 volatile 처리가 필요할 것으로 보인다. 확신하지 못하는 이유는 그렇게 써본 적이 없기 때문이다. 스레드 내부와 외부는 분리된 공간이고 IPC도 사용하기보다 메시지나 이벤트를 사용하기 때문에 전역 변수를 사용해본 기억이 없다. 주로 델파이를 쓰는데 델파이에는 volatile이라는 키워드가 아예 존재하지 않는다. C++을 사용하는 프로젝트에서도 그런 고민을 했던 기억이 없다. 잘 모르겠으면 쓰지 말자는 주의니, 스레드 간에 전역 변수를 공유하지 말고 쓰려면 volatile을 붙이자.

Linux에서는 상황이 또 다르다. Linux는 윈도즈와 달리 스레드의 개념이 없다. 대신 fork()로 프로세스를 분리해버린다. fork()가 호출되는 순간 현재 프로세스가 그대로 복사되어 두 개의 프로세스가 되어 동시에 진행되는데, 이때 코드 영역은 당연히 같이 쓰겠지만, 전역 변수 등의 데이터 공간은 복사되어 따로 분리된다. 분리된 두 프로세스 사이의 같은 이름의 전역 변수는 실제로 완전히 다른 변수가 되어 공유되지 않는다. 이쪽에서 값을 바꿔도 저쪽은 바뀌지 않는다. volatile이라는 지시자가 전혀 필요가 없다. 다만, 리눅스에서는 인터럽트와 같은 개념의 시그널이란 게 있는데, 이 시그널 핸들러 내부와 외부에서 같이 사용되는 전역 변수는 volatile 처리를 해야 한다.

사실 인터럽트 내외부에서 같이 참조하더라도 반드시 volatile 처리를 해야 하는 것은 아니다. 어떤 때는 안 붙여도 되는데 어떤 때는 오동작하고 그럴 수 있다. 대부분 안 붙여도 잘 동작하는 경우가 많아 무심해질 수 있으니 주의해야 한다. 

bool g_button_pressed = false;

void interrupt_function(void)
{
	g_button_pressed = true;
}

static bool button_pressed(void)
{
	return g_button_pressed;
}

void main(void)
{
	while (!button_pressed()) wait();

	// 일을 시작하자
}

아까와 같은 일을 하지만 전역 변수를 직접 참조하지 않고 참조하는 함수를 호출한다. 이 경우 button_pressed() 함수는 전역 변수를 직접 참조하든 CPU 레지스터에 옮겨서 처리하든 어쨌든 전역 변수를 참조한다. 함수에 진입할 때마다. 그러니 volatile을 붙이지 않아도 문제가 생기지 않는다...라고 생각하면 오산이다.

실제로 문제가 생기지 않을 수 있다. 하지만 나날이 발전하는 컴파일러의 최적화 기능을 무시하지 마시라. 저런 간단한 함수 또는 한 번만 참조되는 static 함수는 컴파일러가 인라인 처리하고 최적화해 버린다. inline 지시자를 붙이지 않더라도. 물론 컴파일 옵션에 따라 다르다.

그래서 결론은, 인터럽트 내부에 쓰이는 전역 변수는 무조건 volatile 처리를 한다고 생각하면 편하다. 초당 1000000번 발생하는 인터럽트다... 하면 최적화에 신경을 많이 써야겠지만...