임베디드 스터디 - 임베디드 C++과 OOP
임베디드 스터디 - 임베디드 C++과 OOP
이번 글 참고자료:
EmbeddedInterviewlab
임베디드에서의 Class
- virtual 함수가 없는 Class는 C의 구조체 + 연관 함수 생성과 다를 게 없음.
- Class 안에서도
this를 사용해 포인터로 연산함
하드웨어 레지스터 캡슐화
- Class에서 레지스터 접근을 선언하는 것은 컴파일 단계에서 접근 보안을 강화함
- Private 멤버는 외부 참조에 의해 수정되지 않음
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Gpio {
volatile uint32_t *const odr_; // output data register
volatile uint32_t *const idr_; // input data register
const uint8_t pin_;
public:
Gpio(uint32_t base, uint8_t pin)
: odr_{reinterpret_cast<volatile uint32_t *>(base + 0x14)},
idr_{reinterpret_cast<volatile uint32_t *>(base + 0x10)},
pin_{pin} {}
void set() const { *odr_ |= (1U << pin_); }
void clear() const { *odr_ &= ~(1U << pin_); }
bool read() const { return (*idr_ >> pin_) & 1U; }
};
set(), clear(), read()는 odr_, idr_, pin_ 멤버 자체를 변경하지 않으므로
(hardware를 수정하는 것은 객체의 logical state 변경이 아님)
const 멤버 함수로 선언하는 것이 권장된다.
→ const Gpio& 레퍼런스를 받는 API에서도 해당 메서드를 호출할 수 있게 된다.
Class 생성자와 소멸자
- Class를 활용해 주변 장치를 구현하면 생성자는 주변 장치의 초기화, 소멸자는 주변 장치의 비활성화하는 형태로 사용한다.
- RAII(Resource acquisition is initialization) 원칙을 따라 주변 장치를 비활성할 때 자동으로 DMA나 클럭 채널값 등을 비활성화 할 수 있다.
- Class 사용에 4가지를 주의해야한다.
- Global constructors : 전역, static class는
main()실행 전.init_array에서 이미 모두 초기화한다.
스타트업 코드에서 class를 초기화하지 않으면, 소프트웨어 실행 중에 class를 볼 수 없다. - Destructor side effects : 소멸자 선언에서 자칫 링커가
atexit()과 힙 메모리를 사용하도록 구성될 수 있는데, 이러면 일부 툴체인에서 ROM에 수 백 바이트는 사용하게 된다. - Construction order : C++에서는 생성자의 호출에 순서를 두지 않는다. 이에 각기 다른
.cpp파일에서 전역 Class를 호출하면 UB가 발생할 수 있다. - Exception in contstructors : 임베디드 장치의 크로스 컴파일 시
-fno-exceptions나-fno-rtti기능을 자주 사용하는데, 생성자가 실패할 수도 있는 경우 2-phase init pattern(에러 코드를 리턴하는init()메서드)을 사용해야한다.
- Global constructors : 전역, static class는
복사 생성자 (Copy Constructor)
- 동일한 타입의 객체를 복사하여 새 객체를 생성할 때 호출되는 생성자.
- 컴파일러가 기본 복사 생성자를 제공하지만, 이는 얕은 복사(Shallow Copy) 를 수행한다.
- 얕은 복사 : 멤버 값을 그대로 복사. 포인터 멤버의 경우 같은 주소를 공유하게 됨.
- 깊은 복사 : 포인터가 가리키는 데이터까지 새로 할당하여 완전히 독립된 메모리를 생성.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Buffer {
uint8_t *data;
uint16_t len;
public:
Buffer(uint16_t l) : len(l) { data = new uint8_t[l]; }
/* 기본 복사 생성자(얕은 복사) 문제 예시 */
// Buffer b = a; → a.data == b.data (같은 주소 공유)
// b 소멸 시 data free() → a.data는 dangling pointer
/* 명시적 복사 생성자 (깊은 복사) */
Buffer(const Buffer &other) : len(other.len) {
data = new uint8_t[len]; // 새 메모리 할당
memcpy(data, other.data, len); // 데이터 복사
}
};
- 복사 생성자 인자가
const Buffer &other인 이유const: other를 수정하지 않겠다는 보장&(참조자) : 값으로 받으면(Buffer other) 복사 생성자를 호출하기 위해 복사 생성자를 다시 호출하는 무한재귀 발생
장치 계층에 따른 상속화
- Class를 통해 장치 계층을 생성(e.g. Sensor() -> TempSensor())하는 것은 C의 vtable 생성과 동일한 패턴을 보인다.
- 이 때, Class는 컴파일러를 통해 변수 타입 확인, 디스패치 자동화를 수행할 수 있는 반면, Null 포인터 체크가 없는 형태로 구현된다.
- 상속화는 경우에 따라 적절하게 사용되어야 한다.
- 1단 깊이, 상속 메서드가 적은 케이스, 펌웨어 버전과 관계없이 일정하게 유지되는 메서드에 적합
- 3단 이상의 깊이, 인터페이스가 자주 바뀌는 모듈, vtable 오버헤드가 문제가 될 정도로 ROM 크기가 제한적일 때에 부적합
Virtual 함수와 vtable 비용
- Class에
virtual함수가 하나라도 있다면, 컴파일러는 vtable을 생성하여 ROM에 저장한다. 해당 Class의 인스턴스는 무조건 하나의 vtable을 보유하게 되며, vtable의 포인터는 RAM에 저장된다. virtual함수는 소프트웨어의 예측 가능성을 저해한다(Worst-case 실행 시간에 대해 직접적인 예측이 어렵다)- AUTOSAR C++, MISRA C++ 에서는
virtual사용을 제한하고 있다.
- AUTOSAR C++, MISRA C++ 에서는
| Item | Size | Stored in |
|---|---|---|
| Vtable (one per class) | N x pointer size (4-8 bytes per virtual method) | ROM (.rodata) |
| Vptr (one per instance) | 4 bytes (32-bit) or 8 bytes (64-bit) | RAM (inside object) |
| Virtual call overhead | ~2-4 extra cycles (load vptr, index vtable, indirect call) | CPU |
순수 가상 함수와 추상 클래스
- 순수 가상 함수(Pure Virtual Function) :
= 0으로 선언하여 기본 구현을 제공하지 않는 가상 함수. - 순수 가상 함수가 하나라도 있으면 해당 클래스는 추상 클래스(Abstract Class) 가 된다.
- 추상 클래스는 직접 인스턴스화 불가
- 자식 클래스에서 반드시 override해야 함 — 하지 않으면 컴파일 에러
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 일반 가상 함수 — 기본 구현 있음, override 선택
class Sensor {
public:
virtual int16_t read() { return 0; } // 자식이 override 안 해도 됨
};
// 순수 가상 함수 — 기본 구현 없음, override 강제
class Sensor {
public:
virtual int16_t read() = 0; // 반드시 override 해야 함
};
Sensor s; // ❌ 추상 클래스 — 객체 생성 불가
TempSensor t; // ✅ read()를 override했으므로 가능
- 임베디드에서 순수 가상 함수는 “이 인터페이스는 반드시 구현하라”는 강제 계약으로 사용된다.
- HAL 인터페이스 정의에 유용하다.
다중상속과 Ambiguity
- C++은 여러 부모 클래스를 동시에 상속받는 다중상속을 지원한다.
- 다중상속 시 이름 충돌(Ambiguity) 문제가 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class I2CSensor {
public:
int16_t read() { return read_i2c(); }
};
class Logger {
public:
int16_t read() { return read_log(); }
};
class TempSensor : public I2CSensor, public Logger {};
TempSensor t;
t.read(); // ❌ 컴파일 에러 — I2CSensor::read()? Logger::read()?
- 해결 방법
1
2
3
4
5
6
7
8
// 1. 명시적으로 지정
t.I2CSensor::read();
// 2. TempSensor에서 override
class TempSensor : public I2CSensor, public Logger {
public:
int16_t read() override { return I2CSensor::read(); }
};
- MISRA C++·AUTOSAR C++에서는 다중상속을 금지하고, 순수 가상 함수로만 구성된 인터페이스 클래스 상속만 허용한다.
1
2
3
4
5
6
7
8
/* MISRA 허용 패턴 — 구현 없는 인터페이스만 다중상속 */
class IReadable { public: virtual int16_t read() = 0; };
class ILoggable { public: virtual void log() = 0; };
class TempSensor : public IReadable, public ILoggable {
int16_t read() override { return read_i2c(); }
void log() override { uart_print(last_val_); }
};
OOP 4대 원칙
| 원칙 | 설명 | 임베디드 예시 |
|---|---|---|
| 캡슐화 | 데이터와 함수를 하나로 묶고, 내부 구현을 숨김 | Gpio 클래스의 private odr_·idr_ |
| 상속 | 부모 클래스의 속성·동작을 자식 클래스가 물려받음 | Sensor → TempSensor |
| 다형성 | 같은 인터페이스로 다른 동작을 수행 | virtual read() — 센서 종류와 무관하게 호출 |
| 추상화 | “무엇을 하는지(What)”만 노출하고, “어떻게 하는지(How)”는 숨김 | HAL: sensor_read(s) 호출 시 I2C/SPI 내부 구현 불필요 |
오버로딩 vs 오버라이딩
| 구분 | 오버로딩(Overloading) | 오버라이딩(Overriding) |
|---|---|---|
| 위치 | 같은 클래스 내 | 부모-자식 클래스 |
| 함수명 | 같음 | 같음 |
| 매개변수 | 다름 | 같음 |
| 결정 시점 | 컴파일 타임 | 런타임 (virtual) |
1
2
3
4
5
6
7
8
9
10
11
// 오버로딩 — 같은 클래스, 매개변수 다름
class Uart {
void send(uint8_t byte);
void send(const uint8_t *buf, uint16_t len);
};
// 오버라이딩 — 부모-자식, 매개변수 동일, 동작 다름
class Sensor { public: virtual int16_t read() { return 0; } };
class TempSensor : public Sensor {
public: int16_t read() override { return read_i2c(); }
};
CRTP (Curiously Recurring Template Pattern)
- CRTP는 컴파일 단계에서 다형성(Polymorphism)을 해결한다.
- 기본 Class가 자신을 상속하는 Class을 템플릿 매개변수로 받는 형태이다.
- 상속 Class의 메서드를
static_cast로 변환하여 호출한다.- vtable, vptr 없는 메서드 호출이다.
- CRTP는 기본 Class를 상속 Class에 복사하는 형태로 구현된다.
- 크기가 큰 기본 Class에 많은 상속 Class를 사용하면 ROM 점유율이 커지게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template <typename Derived>
class SensorBase {
public:
int16_t read() {
// Compile-time dispatch: calls Derived::read_impl() directly
return static_cast<Derived*>(this)->read_impl();
}
void sleep() {
static_cast<Derived*>(this)->sleep_impl();
}
};
class TempSensor : public SensorBase<TempSensor> {
public:
int16_t read_impl() { /* read I2C temp register */ return 0; }
void sleep_impl() { /* set sensor to low-power */ }
};
class AccelSensor : public SensorBase<AccelSensor> {
public:
int16_t read_impl() { /* read SPI accel register */ return 0; }
void sleep_impl() { /* set accelerometer to standby */ }
};
| Feature | Virtual dispatch | CRTP |
|---|---|---|
| Dispatch resolved at | Runtime | Compile time |
| Vtable / vptr overhead | Yes | No |
| Can store mixed types in one container | Yes (Sensor* array) | No (each instantiation is a different type) |
| Code size | One copy of base methods | Base methods duplicated per derived type |
| Branch prediction | Indirect (unpredictable) | Direct (predictable) |
| Best for | Heterogeneous collections, plugin architectures | Performance-critical paths, fixed set of types |
RTTI (Run-Time Type Information)
- RTTI는 런타임에 객체의 타입 정보를 확인할 수 있는 C++ 언어 기능이다.
- C에서
struct내부에type필드를 직접 만들어 타입을 확인하던 방식을 언어 차원에서 자동 제공한다. - 두 가지 도구로 구성된다.
dynamic_cast: 캐스팅 시도, 실패 시nullptr반환typeid: 타입 이름을 런타임에 반환
1
2
3
4
5
6
7
8
9
10
11
12
class Sensor { public: virtual int16_t read() = 0; };
class TempSensor : public Sensor { int16_t read() override { return read_i2c(); } };
class AccelSensor : public Sensor { int16_t read() override { return read_spi(); } };
void process(Sensor *s) {
// dynamic_cast — 캐스팅 실패 시 nullptr 반환
TempSensor *t = dynamic_cast<TempSensor*>(s);
if (t != nullptr) { /* s는 TempSensor */ }
// typeid — 타입 이름 비교
if (typeid(*s) == typeid(TempSensor)) { /* s는 TempSensor */ }
}
임베디드에서 RTTI 비용
- 임베디드 크로스 컴파일 시
-fno-rtti옵션으로 RTTI를 비활성화하는 이유는 다음과 같다.
| 비용 | 내용 |
|---|---|
| ROM | 모든 클래스의 타입 정보(type_info 객체)가 .rodata에 추가 저장 |
| 런타임 오버헤드 | dynamic_cast는 상속 계층을 런타임에 탐색 → 실행 시간 비결정적 |
| 예외처리 연동 | RTTI는 내부적으로 예외처리(-fno-exceptions)와 연동 — 함께 비활성화되는 경우가 많음 |
- RTTI 대신 임베디드에서는 순수 가상 함수 또는 CRTP로 타입별 동작을 분리한다.
1
2
3
4
5
6
// RTTI 대신 → 순수 가상 함수 (MISRA 권장)
class Sensor { public: virtual int16_t read() = 0; };
// RTTI 대신 → CRTP로 컴파일 타임에 타입 결정
template<typename Derived>
class SensorBase { /* static_cast<Derived*>(this)->read_impl() */ };
STL (Standard Template Library)
- STL은 C++ 표준 라이브러리로, 세 가지 구성요소로 이루어진다.
| 구성요소 | 내용 | 임베디드 사용 가능 여부 |
|---|---|---|
| 컨테이너 | vector, list, map 등 데이터 구조 | ⚠️ 동적 메모리 사용 — 주의 필요 |
| 알고리즘 | sort, find, copy 등 | ✅ 힙 사용 없음 |
| 반복자 | 컨테이너 순회 추상화 | ✅ 포인터 추상화, 오버헤드 없음 |
임베디드에서 컨테이너 사용 주의
vector,list,map등의 STL 컨테이너는 내부적으로 동적 메모리(힙)를 사용한다.- 힙 파편화(Heap Fragmentation) 발생 가능
- Stack-Heap Collision 위험
- 메모리 할당 시간이 비결정적 → WCET(Worst-Case Execution Time) 예측 불가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 임베디드에서 위험한 STL 컨테이너
std::vector<uint16_t> readings; // 힙 동적 할당
std::list<event_t> events; // 힙 동적 할당
std::map<uint8_t, cb_t> handlers; // 힙 동적 할당
// ✅ 대안 1 — 고정 크기 배열
uint16_t readings[MAX_READINGS];
// ✅ 대안 2 — std::array (크기 고정, 힙 없음)
std::array<uint16_t, 64> readings;
// ✅ 알고리즘·반복자는 그대로 사용 가능
std::sort(readings.begin(), readings.end());
std::find(readings.begin(), readings.end(), target);
컴포지션 vs 상속
- 데스크탑 SW를 개발할 땐 Class의 유연성을 위해 컴포지션 방식을 많이 사용하지만, 임베디드는 자원 문제를 고려해야한다.
- Composition (has-a) :
MotorControllerhasPwmDriverandEncoder - Inheritance (is-a) :
BrushlessMotorisMotor
- Composition (has-a) :
- 임베디드 영역에서는, one-level-deep 인터페이스에 대해 상속을 사용하고, 그 외에는 컴포지션을 사용하는 것을 권장한다.
| Criterion | Composition | Inheritance |
|---|---|---|
| Coupling | Loose — components are independent | Tight — derived class depends on base internals |
| ROM predictability | Each component’s size is self-contained | Virtual methods add vtables; deep hierarchies cascade |
| Testability | Swap in a mock component easily | Must mock the entire base class |
| Flexibility | Can change components at compile time (template) or runtime (pointer) | Locked into the class hierarchy at compile time |
| When to use | Default choice for combining capabilities | When you need a common interface with substitutable implementations |
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.