Skip to Content
독학사독학사 3단계임베디드시스템12. 디바이스 드라이버와 임베디드 C

이번 문서의 목표: 이 파일을 다 읽으면 디바이스 드라이버가 애플리케이션과 하드웨어 사이에서 하는 역할을 설명하고, volatile 키워드가 없을 때 발생하는 버그를 재현·설명하며, 비트 연산으로 레지스터의 특정 비트를 안전하게 설정·해제·조회하고, 안전한 ISR을 작성하는 데 필요한 원칙을 적용할 수 있다.

11편에서 펌웨어를 HAL → 디바이스 드라이버 → 미들웨어 → 애플리케이션의 계층으로 나눴다. 이 편에서는 그 중 디바이스 드라이버 계층을 자세히 들여다보고, 드라이버와 ISR(Interrupt Service Routine, 인터럽트 서비스 루틴)을 올바르게 작성하는 데 반드시 필요한 임베디드 C 특유의 기법 — volatile, 비트 조작, ISR 작성 원리 — 을 다룬다.

디바이스 드라이버: 하드웨어 세부사항을 감춰주는 계층

왜 필요한가

애플리케이션 코드가 “온도 센서 값을 읽어라”라고 할 때마다 ADC 레지스터 이름과 비트 위치를 직접 알아야 한다면, MCU를 바꿀 때마다 애플리케이션 코드 전체를 다시 써야 한다. 디바이스 드라이버(device driver)는 특정 주변장치(GPIO, UART, ADC 등)를 다루는 데 필요한 레지스터 조작을 함수 뒤로 감춰, 애플리케이션이 하드웨어의 세부 구현을 몰라도 사용할 수 있게 해준다.

쉽게 말하면: “레지스터 어디에 어떤 값을 넣어야 하는지”는 드라이버가 알아서 처리하고, 애플리케이션은 uart_send(byte)처럼 이름만 보고 부르면 되게 해주는 중간 다리다.

드라이버의 전형적인 구조

임베디드 디바이스 드라이버는 대개 다음 네 가지 함수군으로 구성된다.

  • 초기화(init): 주변장치의 클록을 켜고, 핀을 해당 기능으로 설정하고, 초기 레지스터 값을 세팅한다.
  • 읽기(read): 주변장치로부터 데이터를 가져온다(폴링 또는 인터럽트/DMA로 채워진 버퍼에서 꺼내오기).
  • 쓰기(write): 주변장치로 데이터를 내보낸다.
  • 제어(control/ioctl): 속도 변경, 모드 전환처럼 읽기·쓰기 외의 설정을 담당한다.
/* UART 드라이버의 전형적인 인터페이스 예시 */ void uart_init(uint32_t baud_rate); void uart_write_byte(uint8_t data); uint8_t uart_read_byte(void); void uart_set_baud(uint32_t baud_rate);

애플리케이션은 USART1->DR = data; 같은 레지스터 접근을 직접 하지 않고, uart_write_byte(data)만 호출하면 된다. 이 구조 덕분에 10편에서 다룬 UART/SPI/I2C를 다른 MCU로 이식할 때도 드라이버 내부만 고치면 된다.

volatile: 컴파일러의 똑똑한 최적화가 오히려 버그가 되는 순간

왜 필요한가

일반 애플리케이션 프로그래밍에서 컴파일러 최적화는 대개 반가운 존재다. 하지만 하드웨어 레지스터나 인터럽트로 값이 바뀌는 변수는 사정이 다르다. C 컴파일러는 “이 변수가 코드 안에서 안 바뀌었으면 다시 읽지 않아도 된다”고 가정하고 최적화하는데, 하드웨어 레지스터는 CPU가 모르는 사이(외부 신호, 인터럽트)에 값이 바뀔 수 있다. 컴파일러가 이 사실을 모른 채 “값이 안 바뀌었으니 레지스터를 매번 읽을 필요 없다”고 판단해버리면, 실제로는 값이 바뀌었는데도 코드는 예전 값을 계속 쓰는 버그가 생긴다.

쉽게 말하면: volatile은 컴파일러에게 “이 변수는 내가 안 건드려도 딴 데서(하드웨어나 인터럽트가) 바뀔 수 있으니, 최적화하지 말고 매번 실제 메모리에서 다시 읽고 다시 써라”라고 알려주는 표시다.

volatile이 없을 때 생기는 버그

특정 GPIO 핀의 상태 레지스터를 폴링(polling)해서 버튼이 눌리기를 기다리는 코드를 생각해 보자.

/* volatile 없이 작성한, 문제가 되는 코드 */ uint32_t *status_reg = (uint32_t *)0x40020010; void wait_for_button(void) { while ((*status_reg & 0x1) == 0) { /* 버튼이 눌릴 때까지 대기 */ } }

컴파일러 입장에서는 while 루프 안에서 status_reg가 가리키는 값을 프로그램 코드 스스로는 한 번도 바꾸지 않는다. 그래서 최적화 단계에서 “이 값은 루프 내내 똑같을 것”이라 가정하고, 레지스터 읽기를 루프 진입 전 딱 한 번만 하고 그 결과를 CPU 레지스터에 캐싱해 계속 재사용하도록 코드를 바꿔버릴 수 있다. 실제로는 버튼을 눌러 하드웨어가 그 메모리 주소의 값을 바꿔도, 프로그램은 캐싱된 옛날 값만 계속 보므로 영원히 루프를 빠져나오지 못하는 무한 루프 버그가 생긴다.

volatile로 고친 코드

/* volatile을 붙여 매번 실제 메모리를 다시 읽도록 강제 */ volatile uint32_t *status_reg = (uint32_t *)0x40020010; void wait_for_button(void) { while ((*status_reg & 0x1) == 0) { /* 매번 실제 레지스터 값을 다시 읽으므로 하드웨어 변화가 즉시 반영된다 */ } }

volatile을 포인터가 가리키는 타입 앞에 붙이면, 컴파일러는 이 주소에 대한 읽기·쓰기를 절대 생략하거나 순서를 바꾸지 않고 코드에 적힌 그대로(매번 실제 메모리 접근으로) 수행한다.

volatile을 붙여야 하는 세 가지 전형적인 경우

  • 메모리 매핑 레지스터(memory-mapped register): 하드웨어가 값을 바꾸는 주소(GPIO 상태, ADC 결과 레지스터 등).
  • ISR과 메인 루프가 공유하는 전역 변수: 인터럽트 안에서 값을 바꾸고 메인 루프에서 읽는 플래그 변수.
  • 다른 실행 문맥(다른 태스크, 다른 스레드)이 값을 바꿀 수 있는 변수: RTOS 환경에서 태스크 간 공유 변수.

자주 틀리는 점

  • volatile을 붙이면 코드가 더 빨라진다”고 착각하는 경우가 있다. 정반대다. volatile은 최적화를 막아서 매번 실제 메모리 접근을 강제하므로, 오히려 실행 속도는 느려질 수 있다. 대신 정확성을 보장한다.
  • const volatile을 모순으로 오해하기도 한다. const는 “이 코드가 이 변수를 통해 값을 바꾸지 않는다”는 뜻이고, volatile은 “다른 경로(하드웨어)로는 바뀔 수 있다”는 뜻이라 둘은 함께 쓰일 수 있다 — 읽기 전용 상태 레지스터가 대표적인 예다.
  • volatile은 동시 접근으로 인한 값이 반쪽만 갱신되는 문제(원자성, atomicity)까지 해결해주지는 않는다. 이는 뒤에 나오는 크리티컬 섹션(critical section)으로 별도로 다뤄야 한다.

비트 조작: 레지스터의 원하는 비트만 정확히 건드리기

왜 필요한가

하드웨어 레지스터 하나(예: 32비트)는 흔히 여러 기능이 비트 단위로 뭉쳐 있다. 어떤 비트는 클록을 켜고, 어떤 비트는 인터럽트를 활성화하고, 어떤 비트는 모드를 선택한다. 원하는 기능 하나만 켜거나 끄려면 그 비트만 정확히 조작하고 나머지 비트는 절대 건드리지 않아야 한다. 대입 연산(=)으로 레지스터 전체를 덮어쓰면 의도치 않게 다른 기능까지 꺼지거나 켜질 수 있다.

쉽게 말하면: 32개의 스위치가 나란히 붙은 판에서, 다른 스위치는 그대로 두고 딱 하나만 올리거나 내리는 방법이다.

네 가지 기본 연산

연산목적연산식(n번째 비트 기준)동작 원리
비트 설정(set)n번째 비트를 1로 만든다REG |= (1 << n)OR 연산은 1과 만나면 무조건 1이 되고, 0과 만나면 원래 값을 유지한다
비트 해제(clear)n번째 비트를 0으로 만든다REG &= ~(1 << n)~(1 << n)은 n번째 비트만 0이고 나머지는 모두 1인 마스크. AND 연산은 0과 만나면 무조건 0, 1과 만나면 원래 값을 유지한다
비트 반전(toggle)n번째 비트를 반대로 뒤집는다REG ^= (1 << n)XOR 연산은 1과 만나면 반전되고, 0과 만나면 원래 값을 유지한다
비트 확인(check)n번째 비트가 1인지 확인한다(REG >> n) & 1 또는 REG & (1 << n)원하는 비트를 맨 오른쪽으로 옮기거나, 그 자리만 남기는 마스크로 나머지를 지운다

계산 예시 — 전 과정

GPIOA->ODR 레지스터(32비트, 초깃값 0x00000000)에서 5번 핀(비트 5)만 켜고 싶다고 하자.

15=000000000000000000000000001000002=0x000000201 \ll 5 = 00000000000000000000000000100000_2 = 0x00000020

이 마스크로 OR 연산을 하면 다음과 같다.

0x00000000  0x00000020=0x000000200x00000000 \ \mathbin{|}\ 0x00000020 = 0x00000020

결과 해석: 5번 비트만 1이 되고 나머지 31개 비트는 원래 값(0) 그대로 유지된다. 만약 다른 비트가 이미 1로 설정되어 있었다면(예: 0x00000008), 결과는 0x00000028이 되어 기존 값은 그대로 두고 5번 비트만 추가로 켜진다.

이번엔 같은 레지스터에서 5번 비트만 끄고 싶다고 하자. 먼저 마스크를 반전시킨다.

(15)=0x00000020=0xFFFFFFDF\sim(1 \ll 5) = \sim 0x00000020 = 0xFFFFFFDF

이 마스크로 AND 연산을 하면 다음과 같다.

0x00000028 & 0xFFFFFFDF=0x000000080x00000028 \ \mathbin{\&}\ 0xFFFFFFDF = 0x00000008

결과 해석: 5번 비트만 0이 되고, 나머지 비트(3번 비트의 1)는 그대로 유지된다.

#define GPIO_PIN_5 (1U << 5) /* 5번 핀만 켜기 - 다른 비트는 유지 */ GPIOA->ODR |= GPIO_PIN_5; /* 5번 핀만 끄기 - 다른 비트는 유지 */ GPIOA->ODR &= ~GPIO_PIN_5; /* 5번 핀 상태 반전 */ GPIOA->ODR ^= GPIO_PIN_5; /* 5번 핀이 켜져 있는지 확인 */ if (GPIOA->IDR & GPIO_PIN_5) { /* 핀이 High 상태 */ }

비트 필드 읽기 — 여러 비트를 하나의 값으로 추출

여러 비트가 모여 하나의 설정값(예: 3비트로 표현하는 클록 분주비)을 이루는 경우, 마스크와 시프트(shift)를 함께 쓴다. 레지스터의 4~6번 비트(3비트 필드)를 읽고 싶다면 다음과 같이 한다.

#define PRESCALER_MASK (0x7U << 4) /* 0b111을 4번 비트부터 배치한 마스크 */ uint32_t prescaler_value = (RCC->CFGR & PRESCALER_MASK) >> 4;

먼저 AND 연산으로 4~6번 비트 외의 나머지를 전부 0으로 지우고(마스킹), 그다음 오른쪽으로 4비트만큼 시프트해 그 값을 0부터 시작하는 정수로 정렬한다.

자주 틀리는 점

  • 비트 해제를 REG &= 0처럼 잘못 쓰는 실수가 흔하다. 이는 레지스터 전체를 0으로 만들어버려 다른 비트까지 모두 꺼진다. 반드시 ~(1 << n)처럼 원하는 비트만 0인 마스크를 만들어야 한다.
  • 시프트할 때 11U(unsigned)의 차이를 놓치기 쉽다. 1 << 31처럼 최상위 비트까지 다루는 경우, 부호 있는 정수의 시프트는 정의되지 않은 동작(undefined behavior)이 될 수 있어 1U << 31처럼 unsigned로 명시하는 것이 안전하다.
  • 비트 확인 결과를 그대로 불리언처럼 쓰다가 실수하는 경우가 있다. REG & (1 << 5)의 결과는 0 또는 0x20이지, 0 또는 1이 아니다. 값이 5번 비트가 아닌 다른 위치에 있다면(예: REG & 0x00200x0020) 이 값 자체를 정수로 비교하는 코드에서는 문제가 없지만, 다른 정수와 크기를 비교하는 등의 용도로 쓰면 착오가 생길 수 있다.

ISR 작성 원리: 인터럽트 서비스 루틴을 안전하게 쓰기

왜 필요한가

06편에서 인터럽트가 발생하면 CPU가 현재 실행을 멈추고 예외 처리로 들어간다고 배웠다. 이때 실제로 실행되는 함수가 ISR(Interrupt Service Routine, 인터럽트 서비스 루틴)이다. ISR은 일반 함수와 달리 언제 실행될지 예측할 수 없고, 실행되는 동안 다른 인터럽트나(우선순위에 따라) 메인 루프가 멈춘다는 특수한 제약이 있다.

쉽게 말하면: ISR은 “지금 하던 일을 다 제쳐두고 급하게 처리해야 할 용건”이므로, 최대한 짧고 빠르게 끝내고 나머지는 나중에 메인 루프가 처리하도록 미뤄야 한다.

ISR 작성 원칙

  • ISR은 짧게 유지한다. ISR이 오래 걸리면 그 사이 우선순위가 낮은 다른 인터럽트가 지연되고(06편의 인터럽트 지연 개념과 연결), 심한 경우 시스템 전체의 응답성이 떨어진다.
  • 블로킹 함수(blocking call)를 호출하지 않는다. printf, 긴 지연(delay) 함수, 통신 완료를 기다리는 폴링 루프처럼 끝나는 데 시간이 걸리는 함수는 ISR 안에 두지 않는다.
  • ISR과 메인 루프가 공유하는 변수는 volatile로 선언한다. 그렇지 않으면 앞서 다룬 최적화 문제로 메인 루프가 ISR이 갱신한 값을 반영하지 못할 수 있다.
  • 실제 처리는 플래그만 세우고 메인 루프로 미룬다. ISR은 “이벤트가 발생했다”는 사실만 빠르게 기록하고, 무거운 계산이나 로직은 메인 루프(또는 RTOS라면 별도 태스크)가 나중에 처리한다.
/* 버튼 인터럽트를 안전하게 처리하는 패턴 */ volatile uint8_t button_pressed_flag = 0; /* ISR과 메인 루프가 공유 - volatile 필수 */ void EXTI0_IRQHandler(void) { if (EXTI->PR & (1 << 0)) { /* 인터럽트 발생 여부 확인 */ button_pressed_flag = 1; /* 무거운 처리는 하지 않고 플래그만 세운다 */ EXTI->PR |= (1 << 0); /* 인터럽트 플래그 클리어(중요 - 안 하면 계속 재진입) */ } } void main_loop(void) { while (1) { if (button_pressed_flag) { button_pressed_flag = 0; handle_button_press(); /* 실제 처리는 여기서, 원하는 만큼 시간을 써도 된다 */ } } }

경쟁 조건과 크리티컬 섹션

ISR과 메인 루프가 같은 변수를 동시에 읽고 쓸 때, volatile만으로는 해결되지 않는 문제가 있다. 예를 들어 메인 루프가 다음과 같이 여러 단계로 나뉜 연산을 수행하는 도중, ISR이 끼어들어 같은 변수를 바꿔버리면 값이 꼬일 수 있다.

volatile uint32_t counter = 0; void main_loop(void) { uint32_t temp = counter; /* 1단계: 읽기 */ temp = temp + 1; /* 2단계: 계산 */ counter = temp; /* 3단계: 쓰기 - 이 사이에 ISR이 counter를 바꾸면 그 값이 사라진다 */ } void SysTick_Handler(void) { counter++; /* ISR도 같은 변수를 건드림 */ }

메인 루프가 1단계(읽기)와 3단계(쓰기) 사이에 있을 때 SysTick_Handler가 끼어들어 counter를 증가시키면, 메인 루프는 그 사실을 모른 채 자신이 1단계에서 읽었던 옛날 값에 1을 더해 3단계에서 덮어써버린다. 결과적으로 ISR이 더한 값이 사라지는 경쟁 조건(race condition)이 발생한다.

이런 상황을 막으려면 여러 단계로 이루어진 연산을 크리티컬 섹션(critical section)으로 감싸, 그 구간 동안은 인터럽트가 끼어들지 못하게 해야 한다.

void main_loop(void) { __disable_irq(); /* 인터럽트 비활성화 - 크리티컬 섹션 시작 */ uint32_t temp = counter; temp = temp + 1; counter = temp; __enable_irq(); /* 인터럽트 재활성화 - 크리티컬 섹션 끝 */ }

결과 해석: 크리티컬 섹션 동안에는 SysTick_Handler를 포함한 인터럽트가 발생을 미루므로(정확히는 발생은 하되 처리가 보류되므로), 세 단계가 끊기지 않고 한 번에 완료된다. 다만 크리티컬 섹션이 길어지면 그만큼 인터럽트 응답이 늦어지므로(06편의 인터럽트 지연 문제), 꼭 필요한 최소 구간만 감싸야 한다 — 이는 16편에서 다룰 우선순위 역전 문제와도 이어지는 설계 고려사항이다.

자주 틀리는 점

  • volatile을 붙였으니 동시 접근도 안전하다”는 오해가 가장 흔하다. volatile은 컴파일러의 캐싱 최적화를 막을 뿐, 여러 단계로 이뤄진 읽기-수정-쓰기(read-modify-write) 연산 도중 다른 문맥이 끼어드는 것까지 막아주지는 않는다.
  • ISR 안에서 인터럽트 플래그(레지스터의 pending 비트)를 지우지 않는 실수를 하면, ISR이 끝나자마자 같은 인터럽트가 다시 발생해 무한히 재진입하는 문제가 생긴다.
  • 크리티컬 섹션을 습관적으로 길게 잡는 것도 문제다. 최소한의 공유 변수 접근 구간만 감싸고, 나머지 로직은 크리티컬 섹션 밖에 둬야 인터럽트 응답성을 해치지 않는다.

핵심 정리

  • 디바이스 드라이버는 init·read·write·control 함수로 하드웨어 세부사항을 감춰, 애플리케이션이 레지스터를 직접 다루지 않아도 되게 해준다.
  • volatile은 컴파일러가 메모리 접근을 캐싱·생략하지 못하게 강제하는 키워드로, 하드웨어 레지스터·ISR과 공유하는 변수·다른 실행 문맥이 바꾸는 변수에 반드시 붙여야 한다. 다만 동시 접근으로 인한 원자성 문제까지 해결해주지는 않는다.
  • 비트 설정은 OR(|=), 해제는 AND-NOT(&= ~), 반전은 XOR(^=), 확인은 AND 후 시프트로 하며, 항상 원하는 비트만 마스킹해 다른 비트를 건드리지 않아야 한다.
  • ISR은 짧게, 블로킹 호출 없이 작성하고 실제 처리는 플래그를 통해 메인 루프로 미루며, ISR과 공유하는 다단계 연산은 크리티컬 섹션으로 감싸 경쟁 조건을 막는다.

마무리 복습

문제 14지선다
디바이스 드라이버 계층의 핵심 역할로 가장 옳은 것은?
문제 24지선다
하드웨어 상태 레지스터를 폴링하는 while 루프에서 volatile을 붙이지 않으면 발생할 수 있는 문제로 가장 옳은 것은?
문제 34지선다
32비트 레지스터 REG의 3번 비트만 0으로 만들고 나머지 비트는 그대로 유지하려 할 때 올바른 연산은?
문제 44지선다
ISR(인터럽트 서비스 루틴) 작성 원칙으로 옳지 않은 것은?
문제 54지선다
volatile을 붙인 변수에 대한 설명으로 옳지 않은 것은?
문제 64지선다
메인 루프와 ISR이 counter라는 변수를 함께 증가시킬 때 경쟁 조건(race condition)을 방지하는 방법으로 가장 적절한 것은?

참고 자료

Last updated on