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