[왕초보 임베디드-C 공부 일기] 15. 구조체(Struct)

앞에서 배열(Array)을 통해 '동일한 자료형'을 메모리에 연속적으로 할당하는 방법을 다루었다. 하지만 임베디드 실무에서는 서로 다른 규격의 데이터를 하나의 패킷(Packet)으로 묶어서 통신하거나 처리해야 하는 경우가 많다. 이를 해결하는 개념이 구조체(Struct)이다.


1. 정의

구조체는 서로 다른 자료형(Data type)들을 하나의 논리적인 이름 아래 연속된 메모리 공간에 결합한 사용자 정의 복합 자료형이다. C언어에서 구조체를 만들고 선언하는 방법은 크게 두 가지로 나뉜다.

방식 A : 기본 구조체 태그(Tag) 방식

가장 기본적인 구조체 문법이다. struct 키워드 뒤에 태그(이름)을 붙여 구조체의 틀을 정의한다.

// 1. 규격 정의
struct SensorData {
	uint32_t timestamp;
	float temperature;
	uint8_t status;
};

// 2. 변수 선언 : 매번 'struct' 키워드를 앞에 붙여야 한다.
struct SensorData Sensor1;

방식 B : typedef를 활용한 자료형 재정의 (실무 표준)

기본 방식은 변수를 선언할 때마다 struct를 붙여야 해서 코드가 길어지고 가독성이 떨어진다. 따라서 실무에서는 typedef(Type Definition) 키워드를 사용하여, 이 구조체 자체를 C언어의 기본 자료형(int, float)처럼 완전히 새로운 고유 자료형으로 등록한다.

// 1. 규격 정의 및 자료형 이름 부여 
typedef struct {
	uint32_t timestamp;
	float temperature;
	uint8_t status;
} SensorData_t; // 보통 끝에 _t(Type)을 붙여 구조체임을 명시한다. 

// 2. 변수 선언 : struct 키워드 없이 깔끔하게 선언 가능
SensorData_t dhtll_sensor;
SensorData_t *sensor_ptr;

칩 제조사(STMelectronics, Texas Instruments)가 제공하는 펌웨어 라이브러리(HAL, CMSIS)의 모든 하드웨어 레지스터는 예외 없이 이 방식 B(typedef) 로 설계되어 있다.


2. 구조체와 포인터의 결합 : 화살표 연산자 (->)

구조체 역시 변수이므로 메모리 주소를 가지며, 포인터로 가리킬 수 있다. 특히 대용량 구조체를 함수로 전달할 때에는 배열과 마찬가지로 구조체의 시작 주소(포인터)만 넘겨주는 참조에 의한 호출(Call by Reference) 가 강제된다.

이때, 구조체 포인터를 통해 내부 멤버(Member) 변수에 접근하기 위해 C언어는 -> (Arrow Operator)라는 직관적인 연산자를 제공한다.

//방식 B 코드 예시 연장
// 구조체의 시작 주소를 포인터 매개변수로 받음
void update_sensor(SensorData_t *ptr) {
	//간접 참조(*)와 멤버 접근(.)을 결합한 연산자가 바로 화살표(->)이다. 
	// (*ptr).temperature = 25.5f; 와 동일한 기계어 코드를 생성한다. 
	ptr -> temperature = 25.5f; 
	ptr -> status = 1;
}

--------
## 3. 메모리 정렬(Alignment)과 패딩(Padding) 구조체를 다룰 때 반드시 알아야 하는 물리적 특성이다. 구조체의 전체 메모리 크기는 내부 멤버 변수 크기의 단순 합과 일치하지 않을 수 있다.

통신 패킷을 구조체로 만들어 보낼 때 이 패딩을 인지하기 못하면, 바이트가 밀려서 데이터가 깨지게 된다. 이때는 __attribute__((packed))같은 컴파일러 지시어를 사용하여 패딩을 강제로 없애야 한다.


-----

4. 활용 사례 : 레지스터 매핑(Mapping)의 완성

앞선 포인터 설명에서는 특정 레지스터 주소를 제어하기 위해 (uint32_t *)0x40021000처럼 개별적으로 매핑했다. 실무에서는 수십 개의 레지스터를 일일이 주소로 접근하지 않고, 구조체를 사용하여 하드웨어 메모리 맵 전체를 한 번에 객체화한다.

// 데이터시트에 명시된 레지스터들의 간격 순서대로 변수 배치
typedef struct {
	volatile uint32_t MODER;     // 0x00 (모드 설정)
	volatile uint32_t OTYPER;    // 0x04 (출력 타입)
	volatile uint32_t OSPEEDR;   // 0x08 (출력 속도)
	volatile uint32_t PUPDR;     // 0x0C (풀업/풀다운)
	volatile uint32_t IDR;       // 0x10 (입력 데이터)
	volatile uint32_t ODR;       // 0x14 (출력 데이터)
} GPIO_TypeDef_t;

// 하드웨어 포트 A의 물리적 시작 주소
#define GPIOA_BASE_ADDR 0x40020000

// 일반 16진수 주소를 우리가 정의한 '레지스터 구조체 포인터'로 명시적 캐스팅
#define GPIOA ((GPIO_TypeDef_t *) GPIOA_BASE_ADDR)

int main() {
	// 포인터 접근 연산자(->)
	// 하드웨어 레지스터의 특정 구역에 비트 연산 수행
	
	//GPIOA의 모드를 설정 (MODER 레지스터 접근)
	GPIOA -> MODER = GPIOA->MODER | (1 << 0);
	
	// GPIOA의 0번 핀 출력 상태 변경 (ODR 레지스터 접근)
	GPIOA -> ODR = GPIOA->ODR ^ ( 1 << 0 );  // 상태 반전(Toggle)
}

구조체는 흩어져 있는 변수들을 물리적인 메모리 블록 하나로 모이도록 한다. 특히 임베디드 프로그래밍에서 구조체는 단순한 '데이터의 묶음'이 아닌, 데이터시트에 그려진 하드웨어 레지스터의 배치를 소프트웨어 코드로 그대로 붙여넣는 역할을 수행한다.