構造体とは?C/C++のstructとメンバ・ポインタの使い方を初心者向けに解説

組み込み基礎

前回の記事では、メモリアドレスを扱うポインタについて解説しました。

今回は、組み込み開発のプログラムでも頻繁に登場する構造体(struct)について解説します。

たとえばセンサーから取得した「温度」「湿度」「取得時刻」を管理するとき、それぞれを別々の変数として扱うこともできます。

float temperature;
float humidity;
uint32_t timestamp;

しかし、これらはすべて「1回のセンサー測定結果」に関係するデータです。

そこで、関連する複数のデータを1つにまとめて扱える構造体が役立ちます。

この記事では、構造体の基本から、メンバへのアクセス、構造体の配列、構造体ポインタ、関数への渡し方、組み込み開発での実際の使い方まで順番に解説します。

構造体(struct)とは?

構造体(struct)とは、複数のデータを1つのまとまりとして扱うための仕組みです。

特に重要なのは、異なる型のデータを1つにまとめられることです。

たとえばセンサー情報として、次の3つを管理したいとします。

  • センサー番号:int
  • 測定値:float
  • 有効・無効:bool

構造体を使えば、これらを1つのデータ型としてまとめられます。

struct Sensor {
    int id;
    float value;
    bool enabled;
};
C言語とC++の構造体の仕組み。structの定義、メンバへのアクセス、構造体ポインタ、関数への受け渡し、センサーデータへの応用を示した図
構造体の基本とメンバアクセス、構造体ポインタ、組み込み開発での利用例

構造体を使うメリット

構造体を使わず、センサー3台分の情報を管理するとします。

int sensor1_id;
float sensor1_value;
bool sensor1_enabled;

int sensor2_id;
float sensor2_value;
bool sensor2_enabled;

int sensor3_id;
float sensor3_value;
bool sensor3_enabled;

これでもプログラムは作れますが、センサーが増えるほど管理が難しくなります。

構造体を使えば、関連する情報を1つにまとめられます。

Sensor sensor1;
Sensor sensor2;
Sensor sensor3;

「この値はどのセンサーのものなのか」という関係がコード上でも分かりやすくなります。

構造体を定義する

まずは基本的な構造体の定義を見てみましょう。

struct Sensor {
    int id;
    float value;
    bool enabled;
};

ここではSensorという構造体を定義しています。

構造体の中に定義されたid、value、enabledのような要素をメンバと呼びます。

メンバ型用途
idintセンサー番号
valuefloat測定値
enabledbool有効・無効

構造体の変数を作る

構造体を定義しただけでは、まだ実際のデータを保存する領域は作られていません。

構造体型の変数を宣言します。

Sensor sensor1;

これでsensor1という変数の中に、id、value、enabledを持たせることができます。

メンバへアクセスする「.」演算子

構造体の各メンバへアクセスするときは、ドット演算子(.)を使用します。

Sensor sensor1;

sensor1.id = 1;
sensor1.value = 25.6;
sensor1.enabled = true;

たとえばsensor1.valueは、「sensor1の中にあるvalue」という意味です。

sensor1
 ├─ id       = 1
 ├─ value    = 25.6
 └─ enabled  = true

構造体を初期化する

構造体は、宣言するときにまとめて初期化することもできます。

Sensor sensor1 = {
    1,
    25.6,
    true
};

この場合は、構造体で定義したメンバの順番に値を指定しています。

どの値がどのメンバに対応しているのかを確認して使用しましょう。

構造体の配列を作る

構造体と配列を組み合わせると、同じ種類のデータを複数まとめて管理できます。

Sensor sensors[3];

これでSensor型のデータを3個保存できます。

sensors[0].id = 1;
sensors[0].value = 10.0;
sensors[0].enabled = true;

sensors[1].id = 2;
sensors[1].value = 20.0;
sensors[1].enabled = false;

sensors[2].id = 3;
sensors[2].value = 30.0;
sensors[2].enabled = true;

組み込み開発では、複数のセンサー、通信データ、設定情報などを管理するときに構造体の配列がよく使われます。

構造体とポインタ

前回学習したポインタは、構造体でも利用できます。

Sensor sensor1 = {
    1,
    25.6,
    true
};

Sensor *p = &sensor1;

pにはsensor1のアドレスが保存されています。

構造体ポインタからメンバへアクセスする場合は、->(アロー演算子)を使用できます。

p->id = 2;
p->value = 30.0;
p->enabled = false;

p->valueは、「pが指している構造体のvalue」という意味です。

「.」と「->」の違い

構造体を学ぶときに混乱しやすいのが、.と->の違いです。

記述使用する場面
sensor.value構造体そのものからメンバへアクセス
p->value構造体を指すポインタからメンバへアクセス

実は、次の2つは同じ意味です。

p->value

(*p).value

->は、構造体ポインタからメンバへアクセスする記述を簡潔にするための演算子です。

ポインタについては「ポインタとは?アドレス・&・*の意味を初心者向けに解説」もあわせて確認してください。

構造体を関数へ渡す

構造体は関数の引数として渡すこともできます。

void printSensor(Sensor sensor) {

    Serial.println(sensor.id);
    Serial.println(sensor.value);

}

呼び出す側では次のようにします。

Sensor sensor1 = {
    1,
    25.6,
    true
};

printSensor(sensor1);

このように構造体を値として渡すと、関数側には構造体の値が渡されます。

構造体をポインタで関数へ渡す

構造体が大きい場合や、関数内から元の構造体を書き換えたい場合には、ポインタを渡す方法があります。

void updateSensor(Sensor *sensor) {

    sensor->value = 30.0;

}

呼び出す側では構造体のアドレスを渡します。

Sensor sensor1 = {
    1,
    25.6,
    true
};

updateSensor(&sensor1);

関数内ではsensor->valueを書き換えているため、呼び出し元のsensor1.valueも30.0になります。

読み取りだけならconstを付ける方法もある

大きな構造体をコピーせず関数へ渡したいものの、関数内では変更させたくない場合があります。

その場合は、たとえば次のようにconst付きポインタを使用できます。

void printSensor(const Sensor *sensor) {

    Serial.println(sensor->id);
    Serial.println(sensor->value);

}

これなら構造体全体をコピーする必要がなく、関数から構造体の内容を変更することも防ぎやすくなります。

組み込み開発では構造体を何に使う?

構造体は、組み込み開発のさまざまな場所で使用されます。

  • センサーの測定データ
  • デバイスの設定情報
  • 通信パケット
  • GPIOなどの設定情報
  • ドライバの管理情報
  • タスクへ渡すデータ
  • 状態管理

関連する情報を1つの構造体にまとめることで、プログラムの意味を理解しやすくできます。

センサーデータを構造体にしてみよう

実際の組み込み開発に近い例として、環境センサーの測定結果を構造体にしてみましょう。

struct SensorData {

    uint32_t timestamp;

    float temperature;
    float humidity;
    float pressure;

};

測定結果を保存します。

SensorData data;

data.timestamp   = millis();
data.temperature = 23.5;
data.humidity    = 45.0;
data.pressure    = 1013.2;

これで「1回の測定結果」をdataという1つのまとまりとして扱えます。

構造体を関数の戻り値にすることもできる

構造体は関数から返すこともできます。

SensorData readSensor() {

    SensorData data;

    data.timestamp   = millis();
    data.temperature = 23.5;
    data.humidity    = 45.0;
    data.pressure    = 1013.2;

    return data;
}

呼び出す側では次のように受け取れます。

SensorData data = readSensor();

複数の値を1つの測定結果として返したい場合にも、構造体は便利です。

構造体の中に構造体を入れる

構造体のメンバとして、別の構造体を持たせることもできます。

struct Position {
    float x;
    float y;
};

struct Robot {
    int id;
    Position position;
};

アクセスするときは、次のように.をつなげます。

Robot robot;

robot.id = 1;
robot.position.x = 10.0;
robot.position.y = 20.0;

複雑なデータでも、意味ごとに構造体を分けることで整理しやすくなります。

構造体のサイズはメンバの合計と同じとは限らない

構造体を組み込み開発で扱うときに知っておきたいのが、アライメントとパディングです。

たとえば次の構造体を考えます。

struct Example {
    uint8_t  a;
    uint32_t b;
};

uint8_tが1バイト、uint32_tが4バイトだからといって、構造体全体が必ず5バイトになるとは限りません。

CPUやコンパイラの都合に合わせてデータを配置するため、メンバ間などに使用されない領域が挿入されることがあります。

この追加領域をパディング(padding)と呼びます。

sizeof()で構造体のサイズを確認できる

実際に構造体が何バイト使用するのかは、sizeof()で確認できます。

Serial.println(sizeof(Example));

組み込み開発ではRAM容量が限られているため、大量の構造体を配列として確保するときなどにサイズが重要になることがあります。

通信データとして構造体をそのまま送っていい?

構造体を使うと、通信データも簡単にまとめられそうに見えます。

struct Packet {
    uint8_t id;
    uint32_t value;
};

しかし、構造体のメモリ内容をそのまま通信へ流す設計には注意が必要です。

  • パディング
  • データ型のサイズ
  • エンディアン
  • CPUアーキテクチャ
  • コンパイラの設定

などによって、別の環境で同じメモリ配置になるとは限らないためです。

通信フォーマットを作る場合は、各フィールドのサイズやバイト順、配置を明確に決めて、必要に応じてシリアライズ・デシリアライズする設計が安全です。

構造体とレジスタの関係

以前の記事で学習したメモリマップドI/Oでも、構造体が使われる場合があります。

複数のレジスタを持つ周辺回路を、レジスタの並びに対応した構造体として表現する方法です。

概念的には次のようなイメージです。

typedef struct {
    volatile uint32_t CTRL;
    volatile uint32_t STATUS;
    volatile uint32_t DATA;
} PeripheralRegisters;

そして周辺回路のベースアドレスを構造体ポインタとして扱えば、

peripheral->CTRL
peripheral->STATUS
peripheral->DATA

のように、各レジスタへ名前を付けて扱うことができます。

これは仕組みを説明するための例です。実際のマイコンではメーカーが提供するヘッダーファイルやSDKの定義を使用します。

メモリマップドI/Oについては「メモリマップドI/O(MMIO)とは?アドレスでマイコンの周辺回路を制御する仕組みを解説」で解説しています。

typedef structとは?

Cのコードでは、次のような記述を見かけることがあります。

typedef struct {
    int id;
    float value;
} Sensor;

typedefを使うことで、この構造体型をSensorという名前で扱えるようにしています。

Sensor sensor1;

Cではstruct Sensorと書く形式もあるため、ライブラリやSDKのコードを読むと両方の記述に出会います。

C++では、名前付きのstruct Sensor { ... };を定義すれば、通常はそのままSensorという型名で使用できます。

KUMITATE-C3でも構造体はよく使う

KUMITATE-C3でArduinoプログラムを作る場合でも、プログラムが大きくなると構造体が便利になります。

たとえば、複数のI2Cセンサーから取得した値をまとめることができます。

struct EnvironmentData {

    float temperature;
    float humidity;
    float illuminance;

};

SHT30で取得した温度・湿度と、BH1750で取得した照度を1つの環境データとして扱う、といった設計ができます。

さらにWi-Fi通信でサーバーへ送信したり、OLEDへ表示したりするときも、関連するデータがまとまっていると処理を整理しやすくなります。

構造体を使うときの注意点

  • 使用前に必要なメンバを初期化する
  • 大きな構造体を不必要にコピーしない
  • ポインタで扱う場合はNULLや寿命に注意する
  • sizeof()で実際のサイズを確認する
  • パディングやアライメントを意識する
  • 通信データでは構造体のメモリ配置に依存しすぎない

構造体は便利ですが、「複数の変数をまとめる箱」として理解するだけでなく、メモリ上では複数のメンバが一定の規則に従って配置されていることも覚えておきましょう。

これまで学んだ内容がさらに繋がる

構造体まで理解すると、これまでの記事で学んできた内容が実際の組み込みコードへかなり近づいてきます。

構造体
  ↓
構造体ポインタ
  ↓
メモリアドレス
  ↓
メモリマップドI/O
  ↓
周辺回路のレジスタ

一方、アプリケーション側では次のようにつながります。

センサー
   ↓
測定値
   ↓
構造体
   ↓
配列・関数
   ↓
表示・保存・通信

実際のドライバやライブラリで構造体とポインタが頻繁に組み合わせて使われる理由も、少しずつ見えてきます。

まとめ

構造体は、関連する複数のデータを1つのまとまりとして扱うための仕組みです。

  • 構造体は異なる型のデータをまとめられる
  • 構造体内の要素をメンバと呼ぶ
  • 構造体変数では.でメンバへアクセスする
  • 構造体ポインタでは->でメンバへアクセスできる
  • 構造体の配列を作ることもできる
  • 構造体を関数へ渡したり、関数から返したりできる
  • 大きな構造体ではポインタ渡しが役立つ場合がある
  • 構造体のサイズにはパディングが含まれる場合がある
  • センサーデータや設定情報の管理など、組み込み開発で広く使われる

構造体とポインタを理解すると、Arduinoのスケッチだけでなく、マイコンメーカーのSDKやドライバ、ライブラリのソースコードもかなり読みやすくなります。

KUMITATE

読むだけでなく、
実際に動かして学びませんか?

クミタテは、組み込み開発や電子工作を 実践しながら学べる学習プラットフォームです。 ESP32を使ったプログラミングから、電子回路、センサー、 通信、基板設計まで、自分のペースで学習できます。

  • 無料で学べる実践的な教材を掲載
  • すぐに試せるサンプルプログラム付き
  • Googleアカウントですぐに登録可能
クミタテに無料登録する
組み込み基礎
スポンサーリンク