[왕초보 임베디드-C 공부 일기] 13. 포인터와 비트 연산 : 하드웨어 조작의 핵심

1. 정의

포인터는 '메모리 주소(Memory Address)를 값으로 가지는 변수' 이다. 일반 변수가 연산에 필요한 실제 데이터(Data)를 저장한다면, 포인터 변수는 실제 데이터가 위치한 메모리의 시작 주소를 저장한다.

+ 포인터는 기본 자료형이 아니다?

포인터는 기본 자료형(int, char...)에 간접 참조 기호(*)가 결합된 파생 자료형(Derived Data Type)에 해당된다. 파생 자료형은 기본 자료형을 바탕으로 새롭게 조립되거나 목적이 변형되어 만들어진 확장 규격이며, 포인터와 앞으로 배울 배열, 구조체, 공용체, 열거형이 해당된다.

-> 파생 자료형으로서의 포인터(예: uint32_t *)

포인터 변수 자체의 크기는 CPU 아키텍처의 주소 버스 크기(32비트 시스템 기준 4바이트)로 고정되어 있으나, 앞에 명시된 기본 자료형은 컴파일러에게 2가지 요소를 엄격하게 규정한다.
  1. 해당 주소 내에서 읽고 쓸 데이터의 단위 크기
- 포인터가 가리키는 주소(0x1000)로 찾아갔을 때, 그곳에서 몇 바이트를 읽어올 것인지 알아야 한다. - uint8_t *ptr 이라면? 주소로 찾아가서 1바이트만 읽어온다. - uint32_t *ptr 이라면? 주소로 찾아가서 4바이트를 통째로 읽어온다.
  1. 연산 시의 메모리 이동 보폭
- 임베디드 코딩을 하다 보면 포인터 변수에 1을 더하는 일(ptr + 1)이 자주 생긴다. (배열의 다음 칸으로 이동할 때) - 만약 uint8_t 타입의 주소에 +1 을 하면, 물리적 메모리 주소도 1바이트 증가한다. (0x1000 -> 0x1001) - 하지만 uint32_t 타입의 주소에 +1을 하면, 물리적 메모리 주소는 4바이트가 증가한다. (0x1000 -> 0x1004) - 즉, 포인터의 자료형은 주소를 이동할 때의 보폭을 컴파일러에게 알려주는 필수적인 규격이다. ---------------------------------------------------------------------------------------------

2. 포인터 제어를 위한 단항 연산자

포인터 조작을 위한 핵심 연산자들이다.
  1. 주소 연산자 & : 피연산자가 메모리에 할당된 시작 주소를 반환한다.
  2. 간접 참조 연산자 * : 포인터 변수가 가리키고 있는 메모리 주소로 접근하여, 공간에 저장된 실제 데이터를 읽거나 수정한다.
  3. 비트 반전 연산자 ~ : 해당 포인터 변수의 모든 비트를 반전시킨다.
-간단한 기본 예시-
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. 비트 연산자 : 하드웨어 조작의 필수 도구

포인터로 메모리 주소에 접근하더라도, 데이터를 일반 십진수 연산(+, -)으로 덮어쓰면 해당 레지스터가 관장하는 다른 하드웨어 핀들의 상태까지 모두 훼손된다. 특정 비트만을 선별적으로 조작하기 위해 논리 게이트(바이너리) 레벨의 비트 연산이 반드시 필요하다.
  1. 비트 OR | (Set 연산):
- 정의 : 두 피연산자의 같은 자리 비트를 비교하여, 두 비트 중 하나라도 1이면 1을 반환하고, 두 비트가 모두 0일 때만 0을 반환한다. - 특정 비트만을 1 로 활성화할 때 사용한다. 0과 OR 연산된 비트는 기존 상태를 유지한다.
  1. 비트 AND & (Clear 연산 / Masking):
- 정의 : 두 피연산자의 같은 자리 비트를 비교하여, 두 비트가 모두 1일 때만 1을 반환하고, 그 외의 경우에는 0을 반환한다. - 특정 비트만을 0으로 비활성화하거나, 현재 상태를 읽어올 때 사용한다.
  1. 비트 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;
}
---------------------------------------------------------------------------------

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;
}
------------------------------------------------------------------------------------------------------ 이번 포인터 설명에서는 앞서 배웠던 여러 개념들을 활용해야 하고, 포인터라는 하나의 큰 개념 안에 많은 세부 개념과 활용 사례들이 들어있기 때문에 어느 것 하나 빠뜨릴 수 없었다. 실질적인 활용 사례들을 통해 제대로 이해하려고 노력했다.