포스트

임베디드 스터디 - 임베디드 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() 메서드)을 사용해야한다.

복사 생성자 (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 사용을 제한하고 있다.
ItemSizeStored 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_
상속부모 클래스의 속성·동작을 자식 클래스가 물려받음SensorTempSensor
다형성같은 인터페이스로 다른 동작을 수행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 */ }
};
FeatureVirtual dispatchCRTP
Dispatch resolved atRuntimeCompile time
Vtable / vptr overheadYesNo
Can store mixed types in one containerYes (Sensor* array)No (each instantiation is a different type)
Code sizeOne copy of base methodsBase methods duplicated per derived type
Branch predictionIndirect (unpredictable)Direct (predictable)
Best forHeterogeneous collections, plugin architecturesPerformance-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) : MotorController has PwmDriver and Encoder
    • Inheritance (is-a) : BrushlessMotor is Motor
  • 임베디드 영역에서는, one-level-deep 인터페이스에 대해 상속을 사용하고, 그 외에는 컴포지션을 사용하는 것을 권장한다.
CriterionCompositionInheritance
CouplingLoose — components are independentTight — derived class depends on base internals
ROM predictabilityEach component’s size is self-containedVirtual methods add vtables; deep hierarchies cascade
TestabilitySwap in a mock component easilyMust mock the entire base class
FlexibilityCan change components at compile time (template) or runtime (pointer)Locked into the class hierarchy at compile time
When to useDefault choice for combining capabilitiesWhen you need a common interface with substitutable implementations
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.