[왕초보 임베디드-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)
- 즉, 포인터의 자료형은 주소를 이동할 때의 보폭을 컴파일러에게 알려주는 필수적인 규격이다.
---------------------------------------------------------------------------------------------
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)로 전환한다.
- 시프트 연산
<<, >> (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언어의 태생적 한계(단일 반환)를 메모리 주소 제어를 통해 우회하는 가장 표준화된 해결책이다.
------------------------------------------------------------------------------------------------------
이번 포인터 설명에서는 앞서 배웠던 여러 개념들을 활용해야 하고, 포인터라는 하나의 큰 개념 안에 많은 세부 개념과 활용 사례들이 들어있기 때문에 어느 것 하나 빠뜨릴 수 없었다. 실질적인 활용 사례들을 통해 제대로 이해하려고 노력했다.