【STM32H723ZG入門⑤】OLED表示編 | SSD1306 (I2C) 動作確認してみた

スポンサーリンク

この記事でできること

  • STM32H723ZG と SSD1306(I2C接続)の OLED を接続し、文字表示できる
  • 既存ライブラリ(afiskon/stm32-ssd1306)を STM32CubeIDE へ組み込める
  • drv層にラッパを作成し、安全に再利用できる構成を作れる
  • 表示が出ないときの切り分け方法(I2C/配線/アドレス/初期化)を理解できる
  • POSTやログ表示に使える、“沈黙しないFW”の表示基盤を構築できる

はじめに

開発作業が多すぎて、若干パンク気味なjin@GoldearNetworksです。

【STM32H723ZG入門④】ログ基盤編 | printfを拡張してみた
この記事でできること printfをそのまま使うのではなく、ログ基盤として拡張できる どのモジュールのログか一目で分かるTAG付きログを実装できる 起動時にハード異常を検出するPOST(自己診断)を追加できる 「沈黙しないファームウェア」の...

前回は、printfリダイレクトとログ基盤を整備し、「沈黙しないファームウェア」の土台を作りました。UART経由で動作状況を可視化できるようになり、デバッグ効率はかなり改善しました。

ただ、実際の組み込み機器では「PCに接続しないと状態が分からない」のは少し不便です。単体動作時でも、その場でステータスやエラーを確認できる表示デバイスが欲しくなってきました。

そこで今回は、小型OLED(SSD1306)をSTM32に接続し、電源投入直後から情報を表示できる環境を構築していきます。

具体的なゴールは以下のとおりです。

  • SSD1306(I2C)OLEDで文字表示を行う
  • 既存ライブラリをプロジェクトへ組み込む
  • drv層に抽象化し、再利用可能な表示基盤を作る

本記事ではドライバを自作するのではなく、既存ライブラリを活用しながら、「実務でそのまま使える統合手順」にフォーカスして解説していきます。

開発環境

  • 開発基板:STM32H723ZG
  • 開発環境:STM32CubeIDE
  • MCU設定:STM32CubeMX
  • インターフェース:I2C接続(SSD1306 128×64 の一般的なモジュール想定)
  • ログ:printf リダイレクト済み(入門③)+ログ基盤(入門④

全体の流れ

  • Step1:CubeMX で I2C を有効化してピン・クロック・速度を決める
  • Step2:afiskon/stm32-ssd1306 を取り込み、プロジェクトに合わせて設定する
  • Step3:最小の動作確認(初期化→文字表示→更新)を書く
  • Step4:内部の仕組みを最小限だけ押さえる(I2C / SSD1306 のページ構造)
  • Step5:ハマりポイントを FAQ 形式で潰す

Step1 CubeMX設定

CubeMXの基本的な設定手順は、入門③で行ったUART設定とほぼ同じです。ペリフェラルを有効化し、ピンを割り当て、その後に通信パラメータを調整する、という流れで進めていきます。

まずはI2Cのピン設定を確実に行い、ハードウェアとして正しく接続できる状態を作ります。

表示が出ないトラブルの多くは、この「物理層の設定ミス」が原因です。
そのため今回は、まず I2Cを確実に動かすための最小構成 を丁寧に作っていきます。

確認するポイントは以下の2つです。

  • I2Cのピン割り当て(SCL/SDA)
  • 通信速度(まずは100kHz)

ピン設定とI2Cの有効化

まず、I2Cが使用できるピンを確認します。Nucleo STM32H723ZG のユーザーマニュアル(UM2407)を見ると、I2C1のピン配置が記載されています。

NUCLEO-H723ZG | Product - STマイクロエレクトロニクス
The STM32 Nucleo-144 board provides an affordable and flexible way for users to try out new concepts and build prototype...

I2C1は以下のピンに割り当てられています。

  • I2C1_SCL → PB8
  • I2C1_SDA → PB9

CubeMXの Pinout 画面を開き、PB8 と PB9 をそれぞれ I2C1_SCL / I2C1_SDA に設定します。ピンを割り当てると自動的に I2C1 が有効化されるため、まずはここから設定するのが分かりやすいです。

続いて Parameter Settings を開き、通信速度を設定します。
I2Cはプルアップ抵抗や配線長の影響を受けやすいバスのため、最初から400kHz(Fast mode)で動かすと不安定になることがあります。動作確認段階では Standard mode(100kHz)を選択しておくのが安全です。まずは「確実に動く状態」を作り、問題がなければ後から高速化する、という順番で進めます。

Timingの詳細値については CubeMX が自動計算してくれるため、特別な理由がない限りデフォルトのままで問題ありません。手動調整はトラブルシュート時のみで十分です。

最後に、PB8 / PB9 へ User Label を付けておくと、後から回路図やコードを読む際に分かりやすくなります。基本的には I2C1_SCL / I2C1_SDA と同じ名前を付けておけば十分です。

補足として、今回は単純な表示確認が目的なので、割り込み(NVIC)やDMAは使用しません。最初はポーリング通信の最小構成で動作させ、余計な要素を増やさないことを優先します。

Step2 ライブラリ導入

次に、OLED制御用のライブラリをプロジェクトへ組み込みます。今回使用するのは afiskon/stm32-ssd1306 です。

GitHub - afiskon/stm32-ssd1306: STM32 library for working with OLEDs based on SSD1306, SH1106, SH1107 and SSD1309, supports I2C and SPI
STM32 library for working with OLEDs based on SSD1306, SH1106, SH1107 and SSD1309, supports I2C and SPI - afiskon/stm32-...

SSD1306ドライバを一から自作することも可能ですが、本記事の目的は「ドライバ開発」ではなく既存ライブラリを実務プロジェクトへ安全に統合すること です。そのため、完成度の高い既存実装をそのまま活用します。

今回のプロジェクトでは、STM32CubeIDE の標準構成に合わせて Core/Inc と Core/Src の下にそれぞれレイヤ分割を行っています。ソースコードの配置ルールとしてapp / drv / mw / plat / utils の5階層構成 を採用しています。既存ライブラリを「どこに、どう配置するか」は、後々の保守性に大きく影響するため、最初に全体構成を整理しておきます。

Core/
├─ Inc/
│   ├─ app/          … アプリケーション層
│   ├─ drv/          … ハードウェア依存ドライバ
│   ├─ mw/           … middleware / 外部ライブラリ
│   │   └─ third_party/
│   │       └─ afiskon_ssd1306/
│   ├─ plat/         … MCU/ボード依存コード
│   └─ utils/        … 共通ユーティリティ
└─ Src/
    ├─ app/
    ├─ drv/
    ├─ mw/
    ├─ plat/
    └─ utils/

外部ライブラリは drv ではなく、mw(middleware)層にまとめて管理する方針としており、現在は mw/third_party 直下へ git clone して利用しています。
ただしこの方法だと、Core/Src 側には何も置かれないため、CubeIDE 標準の「Inc=ヘッダ、Src=ソース」という分離と噛み合いません

この点はディレクトリ設計としては課題が残るため、後続記事で「外部ライブラリを綺麗に分離して管理する方法」を整理する予定です。
本記事ではまず、既存構成のまま最小変更で SSD1306 を組み込む手順に集中します。

ライブラリファイルの配置

GitHub から取得した afiskon/stm32-ssd1306 を、そのままプロジェクトへ取り込みます。今回の構成では外部コードは drv 層ではなく mw(middleware)層にまとめる方針としているため、Core/Inc/mw/third_party/afiskon_ssd1306/ 直下に配置します。

配置するのは以下のファイルです。

  • ssd1306.c / ssd1306.h
  • ssd1306_fonts.c / ssd1306_fonts.h
  • ssd1306_conf.h(設定ファイル)

基本的には GitHub から clone したフォルダから、必要なファイルのみをコピーするだけで問題ありません。ライブラリ本体は大きな変更を加えず、「第三者コード」として隔離して扱うのがポイントです。

なお、ビルド時に undefined reference エラーが出る場合は、ssd1306.c がコンパイル対象に含まれていない可能性があります。Project Explorer 上で Core/Src 配下に配置されているか、またはビルド対象になっているかを確認してください。

ssd1306_conf.h の設定

afiskon/stm32-ssd1306 では、ビルド時の #define によって「どのSTM32ファミリを使うか」「I2C/SPIのどちらを使うか」「I2Cハンドルやアドレスは何か」といった動作条件を切り替える構成になっています。そのため、最初に ssd1306_conf.h を現在のプロジェクト設定に合わせて調整します。

今回の記事では、STM32H723ZG(H7系)+ I2C接続 + hi2c1 を使用しています。この条件に合わせて、マイコンファミリの選択を STM32H7 に変更するだけで基本的に動作します。

// Choose a microcontroller family
#define STM32H7

ライブラリの初期設定がちょうど今回の構成と一致しているため、それ以外の項目は基本的に変更不要です。

  • I2C使用(SSD1306_USE_I2C)
  • I2Cハンドル:hi2c1
  • I2Cアドレス:0x3C

特に注意したいのは I2C アドレスの扱いです。このライブラリでは 8bit形式(左シフト済み) で指定する実装になっており、設定値は (0x3C << 1) となっています。モジュールによっては 0x3D の場合もあるため、表示されないときは (0x3D << 1)へ変更して確認してください。

フォント設定(SSD1306_INCLUDE_FONT_xxx)は必要なものだけ有効化できますが、最初の動作確認ではデフォルトのままで問題ありません。まずは「確実に表示を出すこと」を優先し、最適化は後から行うのがおすすめです。

drv層ラッパの作成

外部ライブラリを app から直接呼び出すのではなく、drv 層に薄いラッパを用意して依存関係を分離します。アプリは「表示する」という抽象APIだけを使い、SSD1306 の具体的な実装には触れません。

これにより、

  • ライブラリ差し替えが容易
  • テストが書きやすい
  • OLED未接続時でも安全に動作できる

といったメリットがあります。

#pragma once
#include <stdbool.h>
#include <stdint.h>

bool oled_init(void);
bool oled_is_available(void);

void oled_clear(void);
void oled_update(void);
void oled_draw_str(uint8_t x, uint8_t y, const char* s);
#include "i2c.h"
#include "utils/log.h"

#include "drv/oled_i2c.h"
#include "mw/third_party/afiskon_ssd1306/ssd1306.h"
#include "mw/third_party/afiskon_ssd1306/ssd1306_fonts.h"

#define TAG "OLED"
#define OLED_I2C_ADDR_7BIT  (0x3C)
#define OLED_I2C_TIMEOUT_MS (50)

static bool s_available = false;

static bool probe(void)
{
    return (HAL_I2C_IsDeviceReady(&hi2c1,
                                 (OLED_I2C_ADDR_7BIT << 1),
                                 2,
                                 OLED_I2C_TIMEOUT_MS) == HAL_OK);
}

bool oled_is_available(void)
{
    return s_available;
}

bool oled_init(void)
{
    if (!probe()) {
        LOG_WARN(TAG, "not found");
        s_available = false;
        return false;
    }

    ssd1306_Init();
    s_available = true;

    LOG_INFO(TAG, "init ok");
    return true;
}

void oled_clear(void)
{
    if (!s_available){
        return;
    }
    ssd1306_Fill(Black);
}

void oled_update(void)
{
    if (!s_available){
        return;
    }
    ssd1306_UpdateScreen();
}

void oled_draw_str(uint8_t x, uint8_t y, const char* s)
{
    if (!s_available || s == 0){
        return;
    }

    ssd1306_SetCursor(x, y);
    ssd1306_WriteString((char*)s, Font_7x10, White);
}
  • HAL_I2C_IsDeviceReady() で事前に存在確認し、見つからない場合はOLED機能のみ無効化します
  • 以降の描画APIは s_available を見て早期リターンするため、呼び出し側が過度に気を遣わずに済みます
  • I2Cアドレスは 7bit(0x3C/0x3D)として定義し、HAL呼び出し時に << 1 しています

このラッパにより、アプリは外部ライブラリの関数名やヘッダ構成を意識せず、「文字列を描く」「更新する」 という最小の操作だけでOLEDを利用できるようになります。

Step3 動作確認(最小表示テスト)

ここまで設定できたら、実際にOLEDへ文字を表示して動作確認を行います。まずは「確実に何かが表示される」最小コードを書くのがポイントです。

今回は drv 層で作成したラッパAPIのみを使用し、アプリ側は SSD1306 の詳細を一切意識しない構成にします。

#include "drv/oled_i2c.h"

int main(void)
{
  HAL_Init();
  SystemClock_Config();

  MX_GPIO_Init();
  MX_I2C1_Init();
  MX_USART3_UART_Init();   // ログ用(任意)

  oled_init();

  oled_clear();
  oled_draw_str(0, 0,  "OLED TEST");
  oled_draw_str(0, 16, "HELLO");
  oled_draw_str(0, 32, "SSD1306 OK");
  oled_update();

  while (1)
  {
    HAL_Delay(1000);
  }
}

このコードを書き込んで電源を投入すると、OLED に文字が表示されれば成功です。まずは複雑な処理を入れず、「表示できる」という成功体験を作ることを優先してください。

Step4 内部解説(I2C / SSD1306 の仕組み)

ここではドライバの細かい実装までは追いません。目的は「なぜこの手順で表示できるのか」を最低限理解し、表示されないときに素早く切り分けできる状態を作ることです。仕組みをざっくり把握しておくだけで、トラブルシュートの速度が大きく変わります。

I2C通信の基本動作

I2Cは「アドレスを指定してデータを書き込む」だけのシンプルなバスです。STM32(マスター)がSSD1306(スレーブ)のアドレスを指定し、コマンドや表示データを連続転送することで画面を書き換えます。

つまり、表示が出ない場合の原因はほとんどが通信レイヤに集中します。多くは「I2Cそのものが成立していない」か「アドレスや初期化が間違っている」かのどちらかです。

  • 配線ミス(SCL/SDA/GND)
  • プルアップ不足や速度設定ミス
  • アドレス違い(0x3C / 0x3D)
  • 初期化失敗や電源条件の不一致
  • 画面更新関数の呼び忘れ

まずは「I2Cが正しく通信できているか」を最優先で疑うのがコツです。

SSD1306の表示構造

SSD1306は1bit(白黒)のディスプレイで、内部に表示RAMを持っています。画面は縦8ピクセル単位の「ページ」で管理されており、128×64の場合は8ページ構成になります。

多くのライブラリはこのRAMと同じサイズのフレームバッファをマイコン側に持ち、メモリ上で描画 → 最後にまとめて転送、という流れで動作します。

そのため、文字描画関数は「画面に直接表示」しているわけではありません。

  • WriteString():RAM上のバッファを書き換える
  • UpdateScreen():バッファ内容をOLEDへ転送する

この UpdateScreen() を呼ばない限り、表示は一切変化しない点に注意してください。「描画したのに表示されない」トラブルの多くはここが原因です。

最初は100kHzにする理由

I2Cはプルアップ抵抗と配線容量に強く依存するバスです。速度を上げるほど信号の立ち上がりが間に合わず、波形が崩れてACKが取れなくなります。

そのため、最初の動作確認ではStandard mode(100kHz)で確実に通信を成立させ、問題がないことを確認してから400kHzへ上げるのが安全な手順です。

まず「確実に動く状態」を作ることが、最短ルートになります。

Step5 ハマりポイント・注意点

ここでは、SSD1306 を使う際によく遭遇するトラブルと、その切り分け方法をまとめます。すべてを筆者が実際に経験したわけではありませんが、調査や実務上の知見も含めて「起こりやすいポイント」を整理しています。

表示系トラブルは闇雲に設定を触るよりも、疑う順番を固定して一つずつ潰す 方が圧倒的に早く解決できます。

Q. まったく表示されません(真っ黒)
A. まずはソフトではなくハードを疑います。I2C通信以前の問題であることがほとんどです。

電源(VCC/GND)が正しいか、SCL/SDA が逆になっていないか、プルアップ抵抗があるかを確認します。そのうえで I2C アドレス(0x3C / 0x3D)、通信速度(まず 100kHz)、そして ssd1306_UpdateScreen() の呼び忘れを順番にチェックしてください。経験上、原因の大半はこのどれかに収まります。

Q. たまに出る/文字化けする/更新が欠ける
A. 典型的な I2C の信号品質問題です。

配線が長すぎたり、プルアップ抵抗が弱すぎたり、速度を 400kHz に上げすぎている場合に発生します。まずは配線を短くし、GNDを近くに通し、100kHz に落として安定するか確認してください。OLED は意外と電流を食うため、電源の電圧降下も地味な原因になります。

Q. 0x3C と 0x3D のどちらか分かりません
A. SSD1306 モジュールの I2C アドレスは製品によって異なります。多くは 0x3C ですが、0x3D の個体も普通に存在します。

迷った場合は I2C スキャンを行い、実際に応答しているアドレスをログで確認するのが最短です。

以下は入門④のログ基盤編(log.h)がある前提の例です。シリーズ記事としての統一感を優先して、まずはこちらを載せます。

#include "i2c.h"
#include "utils/log.h"

#define I2C_SCAN_TIMEOUT (5)

void utils_i2c_scan(I2C_HandleTypeDef *hi2c)
{
  uint8_t addr;

  if (hi2c == NULL)
  {
    return;
  }

  LOG_INFO("I2C", "I2C scan start (7bit)");

  for (addr = 1; addr < 0x7F; addr++)
  {
    if (HAL_I2C_IsDeviceReady(hi2c, (uint16_t)(addr << 1), 1, I2C_SCAN_TIMEOUT) == HAL_OK)
    {
      LOG_INFO("I2C", "found device: 0x%02X", addr);
    }
  }

  LOG_INFO("I2C", "I2C scan end");
}

呼び出しは main() で I2C 初期化(MX_I2C1_Init())の直後に一度だけ実行すればOKです。

#include "utils/i2c_scan.h"

int main(void)
{
  HAL_Init();
  SystemClock_Config();

  MX_GPIO_Init();
  MX_I2C1_Init();
  MX_USART3_UART_Init();  // ログ/printf出力するならUART初期化が必要

  utils_i2c_scan(&hi2c1);

  while (1)
  {
    HAL_Delay(1000);
  }
}

補足:本記事はシリーズ構成のためログ基盤(入門④)前提のコード例を載せていますが、I2Cスキャン自体は printf だけでも実行できます。

入門④を読んでいない場合や、ログ基盤をまだ入れていない場合は以下の最小版を使ってください。

#pragma once
#include "i2c.h"

void utils_i2c_scan(I2C_HandleTypeDef *hi2c);
#include "i2c.h"
#include <stdio.h>

#define I2C_SCAN_TIMEOUT (5)

void utils_i2c_scan(I2C_HandleTypeDef *hi2c)
{
  uint8_t addr;

  if (hi2c == NULL)
  {
    return;
  }

  printf("I2C scan start (7bit)\n");

  for (addr = 1; addr < 0x7F; addr++)
  {
    if (HAL_I2C_IsDeviceReady(hi2c, (uint16_t)(addr << 1), 1, I2C_SCAN_TIMEOUT) == HAL_OK)
    {
      printf("found device: 0x%02X\n", addr);
    }
  }

  printf("I2C scan end\n");
}

実行すると、以下のように検出されたアドレスがログへ出力されます。この例では OLED(0x3C)が正しく認識されています。

[INF][I2C] I2C scan start (7bit)
[INF][I2C] found device: 0x3C
[INF][I2C] I2C scan end
I2C scan start (7bit)
found device: 0x3C
I2C scan end

OLEDが見つからない場合は、配線・電源・プルアップ・通信速度などハードウェア側の問題に絞って切り分けできます。この方法なら「OLEDが見つからない」のか「通信自体が死んでいる」のかを一瞬で切り分けできます。

Q. I2Cは動いているのに OLED だけ反応しません
A. モジュール差を疑います。

見た目が似ていても、SSD1306 ではなく SH1106 系だったり、解像度が 128×32 だったり、電源方式が異なる製品も存在します。設定の WIDTH/HEIGHT や初期化条件が合っているかを確認してください。

Q. アドレス指定でハマりました(7bit / 8bit 問題)
A. HAL の API では 8bit(左シフト済み)で渡すケースが多く、設定ファイルでは 7bit 表記を使うことが一般的です。

このズレに気付かず通信できない、というのは非常によくあるトラブルです。今回の構成では 7bit(0x3C/0x3D)で統一し、送信時に << 1 する形にしています。
動かない場合は「どこでシフトしているか」を必ず確認してください。

まとめ

今回は SSD1306(I2C接続)の小型OLEDを STM32H723ZG に組み込み、既存ライブラリを活用しながら「確実に表示を出す」ための統合手順を整理しました。

CubeMX設定 → ライブラリ導入 → drv層ラッパ → 動作確認 → トラブル切り分け、という流れを通して、実務でもそのまま再利用できる表示基盤が完成しています。

これにより、PCへ接続しなくても装置単体で状態やエラーを確認できるようになり、「沈黙しないファームウェア」の第一歩が実現できました。電源投入直後に必ず何かが表示されるだけでも、開発時の安心感は大きく変わります。

次回はこの表示基盤に加えて、SDカードによるデータ保存(ログの永続化) を実装していきます。センサ値や測定結果をCSVとして保存し、「後から解析できるファームウェア」へ発展させる予定です。表示(可視化)と保存(記録)の両輪が揃うことで、組み込みシステムとして一段と実用的になります。

引き続き、実務目線で使えるテンプレートを積み上げていきましょう。

前回

【STM32H723ZG入門④】ログ基盤編 | printfを拡張してみた
この記事でできること printfをそのまま使うのではなく、ログ基盤として拡張できる どのモジュールのログか一目で分かるTAG付きログを実装できる 起動時にハード異常を検出するPOST(自己診断)を追加できる 「沈黙しないファームウェア」の...

次回: (準備中)

コメント