[왕초보 임베디드-C 공부 일기] 13. 포인터와 비트 연산 : 하드웨어 조작의 핵심
1. 정의
포인터는 '메모리 주소(Memory Address)를 값으로 가지는 변수' 이다. 일반 변수가 연산에 필요한 실제 데이터(Data)를 저장한다면, 포인터 변수는 실제 데이터가 위치한 메모리의 시작 주소를 저장한다.
+ 포인터는 기본 자료형이 아니다?
포인터는 기본 자료형(int, char...)에 간접 참조 기호(*)가 결합된 파생 자료형(Derived Data Type)에 해당된다. 파생 자료형은 기본 자료형을 바탕으로 새롭게 조립되거나 목적이 변형되어 만들어진 확장 규격이며, 포인터와 앞으로 배울 배열, 구조체, 공용체, 열거형이 해당된다.
-> 파생 자료형으로서의 포인터(예: uint32_t *)
포인터 변수 자체의 크기는 CPU 아키텍처의 주소 버스 크기(32비트 시스템 기준 4바이트)로 고정되어 있으나, 앞에 명시된 기본 자료형은 컴파일러에게 2가지 요소를 엄격하게 규정한다.
-
해당 주소 내에서 읽고 쓸 데이터의 단위 크기
- 포인터가 가리키는 주소(
0x1000)로 찾아갔을 때, 그곳에서 몇 바이트를 읽어올 것인지 알아야 한다. uint8_t *ptr이라면? 주소로 찾아가서 1바이트만 읽어온다.uint32_t *ptr이라면? 주소로 찾아가서 4바이트를 통째로 읽어온다.
- 포인터가 가리키는 주소(
-
연산 시의 메모리 이동 보폭
- 임베디드 코딩을 하다 보면 포인터 변수에 1을 더하는 일(
ptr + 1)이 자주 생긴다. (배열의 다음 칸으로 이동할 때) - 만약
uint8_t타입의 주소에+1을 하면, 물리적 메모리 주소도 1바이트 증가한다.
(0x1000->0x1001) - 하지만
uint32_t타입의 주소에 +1을 하면, 물리적 메모리 주소는 4바이트가 증가한다.
(0x1000->0x1004) - 즉, 포인터의 자료형은 주소를 이동할 때의 보폭을 컴파일러에게 알려주는 필수적인 규격이다.
- 임베디드 코딩을 하다 보면 포인터 변수에 1을 더하는 일(
2. 포인터 제어를 위한 단항 연산자
포인터 조작을 위한 핵심 연산자들이다.
- 주소 연산자
&: 피연산자가 메모리에 할당된 시작 주소를 반환한다. - 간접 참조 연산자
*: 포인터 변수가 가리키고 있는 메모리 주소로 접근하여, 공간에 저장된 실제 데이터를 읽거나 수정한다. - 비트 반전 연산자
~: 해당 포인터 변수의 모든 비트를 반전시킨다.
-간단한 기본 예시-
uint32_t sensor_val = 25; //일반 변수
uint32_t *ptr; //포인터 변수 선언
ptr = &sensor_val; //일반 변수가 저장된 메모리의 시작 주소를 ptr에 저장
// *ptr을 사용해 그 주소로 찾아가서 값을 100으로 덮어쓴다.
*ptr = 1024;
// 0x00000400 (1024)의 모든 비트를 반전시켜 0xFFFFFBFF 로 만든다.
uint32_t inverted_value = ~(*ptr);
3. 비트 연산자 : 하드웨어 조작의 필수 도구
포인터로 메모리 주소에 접근하더라도, 데이터를 일반 십진수 연산(+, -)으로 덮어쓰면 해당 레지스터가 관장하는 다른 하드웨어 핀들의 상태까지 모두 훼손된다. 특정 비트만을 선별적으로 조작하기 위해 논리 게이트(바이너리) 레벨의 비트 연산이 반드시 필요하다.
- 비트 OR
|(Set 연산):- 정의 : 두 피연산자의 같은 자리 비트를 비교하여, 두 비트 중 하나라도 1이면 1을 반환하고, 두 비트가 모두 0일 때만 0을 반환한다.
- 특정 비트만을
1로 활성화할 때 사용한다.0과 OR 연산된 비트는 기존 상태를 유지한다.
- 비트 AND
&(Clear 연산 / Masking):- 정의 : 두 피연산자의 같은 자리 비트를 비교하여, 두 비트가 모두 1일 때만 1을 반환하고, 그 외의 경우에는 0을 반환한다.
- 특정 비트만을 0으로 비활성화하거나, 현재 상태를 읽어올 때 사용한다.
- 비트 XOR
^(Toggle 연산):- 정의 : 두 피연산자의 같은 자리 비트를 비교하여, 두 비트의 값이 서로 다를 때만 1을 반환하고,
값이 서로 같으면 0을 반환한다. - 특정 비트의 상태를 현재의 반전 상태(0 -> 1, 1 -> 0)로 전환한다.
- 정의 : 두 피연산자의 같은 자리 비트를 비교하여, 두 비트의 값이 서로 다를 때만 1을 반환하고,
- 시프트 연산
<<,>>(shift):- 정의 : 피연산자의 모든 비트를 지정한 횟수만큼 왼쪽(
<<)또는 오른쪽(>>)으로 이동시킨다.
- 정의 : 피연산자의 모든 비트를 지정한 횟수만큼 왼쪽(
-간단한 기본 예시-
#include
#include
int main() {
//가상의 8비트 하드웨어 레지스터 초기 상태 설정
//2진수: 0000 1010 (10진수 10)
uint8_t control_register = 0x0A
// 1. 특정 비트 활성화 (Bit Set) : OR 연산자 (|) 와 시프트(<<)
// 목표 : 0번 비트를 1로 설정 (나머지 비트는 상태 유지)
// 0000 1010 | 0000 0001 = 0000 1011 (0x0B)
control_register = control_register | (1 << 0);
// 2. 특정 비트 비활성화 (Bit Clear) : AND(&), NOT(~), 시프트(<<)
// 목표 : 1번 비트를 0으로 설정 (나머지 비트는 상태 유지)
// 1 << 1 은 0000 0010
// ~(1 << 1) 은 1111 1101 (마스크 생성)
// 0000 1011 & 1111 1101 = 0000 1001 (0x09)
control_register = control_register & ~(1 << 1);
// 3. 특정 비트 상태 반전 (Bit toggle) : XOR(^), 시프트(<<)
// 목표 : 3번 비트의 상태를 현재의 반대로 뒤집기
// 0000 1001 ^ 0000 1000 = 0000 0001(0x01)
control_register = control_register ^ (1 << 3);
// 4. 특정 비트 상태 확인 (Bit Check) : AND(&)
// 목표 : 0번 비트가 현재 1인지 0인지 검사
if (control_register & (1 << 0)) {
printf("0번 비트가 활성화 상태입니다.\n");
} else {
printf("0번 비트가 비활성화 상태입니다.\n");
}
printf("최종 레지스터 상태(16진수): 0x%02X\n", control_register);
return 0;
}
- 시프트 연산(
1 << n)의 공학적 의의:0x01,0x08처럼 마스크 값을 16진수로 직접 하드코딩하는 것보다,(1 << 0),(1 << 3)형태로 작성하는 것이 "몇 번째 비트를 제어하는가" 를 직관적으로 나타내어 코드의 가독성과 유지보수성을 극대화한다.
4. 임베디드 실전 활용 사례
사례 A : 메모리 맵 입출력 (Memory-Mapped I/O, MMIO)과 비트 마스킹
MCU의 하드웨어 레지스터 주소에 포인터로 접근한 뒤, 비트 연산자를 결합하여 원하는 핀만 정확히 제어한다.
//특정 포트 레지스터 주소를 32비트 포인터로 명시적 캐스팅
// volatile(추후 설명 예정) 키워드로 컴파일러 최적화를 방지하여 메모리 직접 접근 보장
volatile uint32_t *GPIO_ODR = (uint32_t *)0x40020014;
//간접 참조 연산자(*)로 값을 읽어온 뒤, 비트 OR 연산(|)을 수행하고 다시 저장
//결과 : 다른 핀들의 상태는 보존하면서, 최하위 0번 비트만 1로 활성화 (핀 ON)
*GPIO_ODR = *GPIO_ODR | (1 >> 0);
//비트 AND(&)와 NOT(~) 연산을 결합하여 0번 비트를 다시 비활성화 (핀 OFF)
*GPIO_ODR = *GPIO_ODR & ~(1 << 0);
사례 B : 함수의 다중 값 반환 및 원본 메모리 조작 (Call by Reference)
C언어의 함수는 문법적으로 단 하나의 값만 return할 수 있다. 하지만 임베디드 시스템에서는 하나의 센서 읽기 함수가 '온도'와 '습도' 등 여러 개의 데이터를 동시에 가져와야 하는 경우가 자주 발생한다. 이때 데이터 원본 변수의 '주소'를 함수로 전달하여, 함수가 원본 메모리에 직접 접근해 여러 값을 동시에 덮어쓰도록 설계한다.
#include
#include
// 1. 함수 선언: 온도와 습도 변수의 '시작 주소'를 담을 포인터 매개변수 선언
void read_dht_sensor(float *temp_ptr, float *hum_ptr) {
// 하드웨어 통신을 통해 센서 값을 읽어왔다고 가정
float raw_temp = 24.5f;
float raw_hum = 60.0f;
// 2. 간접 참조 연산자(*)를 사용하여 전달받은 메모리 주소로 직접 찾아가 값을 덮어씀
*temp_ptr = raw_temp;
*hum_ptr = raw_hum;
}
int main() {
// 데이터를 저장할 4바이트짜리 일반 실수형 변수 선언
float current_temp = 0.0f;
float current_hum = 0.0f;
// 3. 변수의 복사본이 아닌, 주소 연산자(&)를 사용하여 원본 메모리의 주소를 인자로 전달
read_dht_sensor(¤t_temp, ¤t_hum);
// 함수는 종료되었지만, 포인터를 통해 원본 메모리가 직접 조작되었으므로 값이 변경되어 있음
printf("현재 온도: %.1f, 현재 습도: %.1f\n", current_temp, current_hum);
return 0;
}
- 공학적 의의: 데이터를 복사해서 넘겨주고 다시 복사해서 받아오는 과정(Call by Value)을 생략한다. 이는 CPU의 레지스터와 스택 메모리 사용량을 최소화하며, C언어의 태생적 한계(단일 반환)를 메모리 주소 제어를 통해 우회하는 가장 표준화된 해결책이다.
이번 포인터 설명에서는 앞서 배웠던 여러 개념들을 활용해야 하고, 포인터라는 하나의 큰 개념 안에 많은 세부 개념과 활용 사례들이 들어있기 때문에 어느 것 하나 빠뜨릴 수 없었다. 실질적인 활용 사례들을 통해 제대로 이해하려고 노력했다.