この記事について
STM32F4 Discoveryボードを使用してデジタルカメラを作成したので、その内容を記します。デジカメや画像処理をするのにSTM32(Cortex-M)は向いていませんが、あくまで趣味として作成しました。また、ハードウェアにはあまり労力をかけたくなかったので、できるだけ素のDiscoveryボードのままで動かせるような設計にしました。ラズパイ+Linuxでの作成記事も同日にアップロードしました。こちらも併せてみると面白いと思います(https://qiita.com/take-iwiw/items/d6645880c5bb91ce8a85 )。
動いている様子: https://youtu.be/CgX3bM4v_aU
仕様
- ライブビュー表示 (フレームレートは出来高。実力20fps弱)
- 撮影モード
- 静止画撮影(JPEG, 320x240)
- 動画撮影(Motion JPEG, 320x240、約5fps)
- 再生モード
- 静止画再生(JPEG, 最大2560x1920)
- 静止画再生(RGB565, 320x240)
- 動画再生(Motion JPEG, 約10fps)
- 対応メディア
- SDカード
操作仕様
- ボタン x 3
- ダイヤル x 1
環境
開発環境
- OS: Windows10
- IDE: SW4STM32
- ハードウェアコンフィギュレータ: STM32 CubeMX
- 言語: C
- 仕様ライブラリ、ミドルウェア: HAL, FreeRTOS, FatFS, LibJPEG
使用するデバイス
-
STM32F4 Discovery Board
- STM32F407VGT (Cortex-M4)
- 1-MByte Flash Memory
- 192-KByte RAM
-
液晶ディスプレイ(LCD)モジュール
- ILI9341コントローラ、16bitパラレルインタフェース
- 320x240
- SDカードスロット付き
-
カメラ(Camera)モジュール
- OV7670 (FIFO無し)
-
その他
- ユニバーサル基板
- プッシュボタン x 2
- ロータリーエンコーダ(プッシュボタン付き) x 1
液晶ディスプレイの制御
ILI9341の通信仕様を見てみる
ILI9341のデータシートを見て、パラレルモード時の通信仕様を確認します。これを見ると、以下のことが分かります。
- リセット(RESX)は使用中はHighにする
- アクセス(Write/Readともに)中はCSXをLowにする
- Writeのとき
- RDXは常にHigh
- WRXを立ち上げることでラッチされる
- Readのとき
- WRXは常にHigh
- RDXを立ち上げることでラッチする
- 「Read」とはいえ、最初にアドレス指定をする必要があり、その時はWriteと同じ
- データ通信の時は、DCXをHighにする
- コマンド通信とアドレス指定の時は、DCXをLowにする
FSMCを使って通信をする
上述のようなシーケンスになるようにGPIOをパタパタすることで、LCD制御が出来ます。実際、AVRやPICでやるときはそうします。しかし、面倒、速度が出ない、CPUリソースを取られる、といったデメリットがあります。
先ほどのシーケンスのCSX、DCX、WRX、RDXをよく見ると、実はこれはSRAMのメモリアクセスシーケンスと同じです。そして、DCXをアドレスとして考えれば、LCDモジュールを1-bitのSRAMとして扱うことが出来ます。さらに、STM32にはFSMC(Flexible Static Memory Controller)という、メモリアクセスを制御してくれるモジュールが搭載されています。さらにさらに、おそらくSTマイクロさんもそれを見越して、LCD制御用のアプリケーションノートや設定を用意してくれています。具体的には、STM32とLCDを以下のように接続します。本プロジェクトでは、FSMCのバンクとしてバンク1を使用し、アドレスピンはA16をLCDのDCX(RS)に接続するとします。
FSMCのおかげで、CPU(つまり、我々がこれから実装するプログラム)からは、通常のメモリアクセスのようにLCDへアクセスすることが出来ます。このとき、どの番地にアクセスすべきかはデバイス依存なので、STM32F407のデータシート内のメモリマップを確認します。これを見ると、FSMCのバンク1を使うときには0x6000_0000が対応するアドレスであることが分かります。そのため、
- コマンド通信をするときは、
*(volatile uint16_t*)0x6000_0000
へアクセス - データ通信をするときは、
*(volatile uint16_t*)0x6002_0000
へアクセス
すればよいことが分かります。なお、このアドレスの決定方法に関して一つ罠があります。詳細はこちら をご覧ください。
CubeMXでFSMCの設定をする
CubeMXからFSMCの設定を行います。CubeMXの使い方やプロジェクトの始め方に関してはこちら にまとめたのでよろしければご参考にしてください。
CubeMXで適切なプロジェクトを作成したら、以下の図のようにFSMCを設定します。標準でLCD制御用のモードが用意されているので、それを選びます。設定は先ほど決めた通り、使用するバンクは1(NE1)、アドレスはA16とします。ボード上での配線取り回しの関係で、デフォルトのピンアサインから少し変えています。この図では見ずらいと思うので、後述のピンアサインの図をご確認ください。
FSMC経由でLCDを制御するコードを実装する
(記事の流れの都合上、ここで実装についての説明をしますが、実際には他のモジュールの設定も全てCubeMXで終えてから実装に取り掛かった方が良いかと思います)
CubeMXからプロジェクトとソースコードを生成します。FSMCに関する必要な設定用コードは全て自動生成されているので、何も難しいことは考えずに使えます。LCDへデータ/コマンドを読み書きするコードは非常にシンプルに下記のようになります。初期化シーケンスなどの設定には、writeDataとwriteCmd関数を使用します。関数化しているので、よくあるArduino用などのサンプルコードを簡単に流用できます。また、本プロジェクトでは画像データをLCDへ書き込む予定です。その時に、1ピクセル単位でいちいち関数コールなんかしていられないので、書き込み先アドレスを取得するgetDrawAddress関数を用意しました。
#define FSMC_Ax 16 // use A16 as RS
#define FSMC_NEx 1 // use subbank 1
#define FSMC_ADDRESS (0x60000000 + ((FSMC_NEx-1) << 26))
#define LCD_CMD_ADDR (FSMC_ADDRESS)
#define LCD_DATA_ADDR (FSMC_ADDRESS | 1 << (FSMC_Ax + 1))
#define LCD_CMD (*((volatile uint16_t*) LCD_CMD_ADDR))
#define LCD_DATA (*((volatile uint16_t*) LCD_DATA_ADDR))
inline static void lcdIli9341_writeCmd(uint16_t cmd)
{
LCD_CMD = cmd;
}
inline static void lcdIli9341_writeData(uint16_t data)
{
LCD_DATA = data;
}
inline static void lcdIli9341_readData()
{
uint16_t data = LCD_DATA;
// printf("%04X\n", data);
}
inline uint16_t* lcdIli9341_getDrawAddress()
{
return (uint16_t*)LCD_DATA_ADDR;
}
カメラの制御
OV7670の仕様を見てみる
OV7670のデータシートを確認します。まず見るべきはインタフェース部分です。これを見ると、主に4つのことが必要だとわかります。 まず、撮像サイズの設定やフォーマットの設定といった制御用コマンド通信用にSCCB(SIO_C、SIO_D)が必要になります。SCCBはOmniVision独自の通信フォーマットですが、ほぼ I2Cと同じです。そのため、STM側からはI2Cを使用して制御することにします。(「ほぼ」と書きましたが、微妙に異なる点があり、引っかかるポイントです。詳しくはこちら をご確認ください)。また、クロックを提供してあげる必要があります。これは、データシートの他のページを見ると約24MHzとのことでした。これはタイマなどで生成してもいいのですが、STMにはMCO(Microcontroller Clock Output)というモジュールがあり、より簡単にクロック波形を出力することが出来るので、それを使います。
以上を使用して、正しい設定と正しいクロックを供給してあげると、OV7670は画像データを出力します。画像データのフォーマットは同じくデータシートを見ると書いてありますが、ざっと説明すると、PCLKの立下りタイミングでD[7:0]にデータが出力されます。グレースケールなどで8bit/1pixelのときは、PCLK1サイクルで1ピクセル。RGB565などで16bit/pixelのときは、PCLK2サイクルで1ピクセル分の画像データとなります。同期信号は、デフォルト設定では、HREFがHighのとき画像データは有効。HREFがLowのとき無効データ(Blanking)となります。また、VSYNCがLowのとき、画像データが有効、Highのときは無効データとなります。
この通信仕様を踏まえたうえでD[7:0]を読んであげれば画像データが取得できます。が、そんなことをCPU+GPIOでちまちまやっていられません。こちらもSTM32にはぴったりのモジュールが搭載されています。DCMI(Digital Camera Interface)というモジュールを使うことで、一度設定するだけで後はハードウェアが勝手に画像データを取り込んでくれます。取り込んだデータはDMAで適切な場所に出力します。
OV7670 | 目的 | STM32 |
---|---|---|
SCCB | 制御用コマンド通信 | I2C |
XCLK | マスタークロック(約24MHz) | MCO |
PCLK, HREF, VSYNC | 画像データの同期信号 | DCMI |
D[7:0] | 画像データ | DCMI |
CubeMXでI2Cの設定をする
SCCB通信用に使うI2Cの設定をCubeMXから行います。SCCBは起動時やモード切替時のカメラ初期化くらいにしか使いません。なので、あまり頑張らずにDMAも割り込みも使わずに全てブロッキングで実装することにします。また、ピンの取り回しの関係で使用するのはI2C2としました。
メモ: 最初、まずは通信確認のためにI2Cの設定だけを行い、カメラと通信をしてみました。が、うまく動きませんでした。どうやら後述のマスタークロックも供給しないと通信すらできないらしいです。
CubeMXでMCOの設定をする
マスタークロック供給に使うMCOの設定をCubeMXから行います。MCOはRCCモジュールの下から設定できます。今回はMCO1(Master Clock Output 1)を使用します。また、MCOの設定はClock Configurationタブから行います。(ここでは42MHzに設定しています。カメラ側のプリスケーラで21MHzにします)。MCOに関しては、ソフト側でやることは何もありません。この設定をするだけで、自動的にクロック出力をしてくれます。
CubeMXでDCMIの設定をする
カメラから画像データを受信するためにDCMIモジュールの設定をCubeMXから行います。OV7670はPCLKの立下りでデータを出力するので、STM32としては半サイクル後の立ち上がりでデータを取り込みます(Active on Rising edge)。また、VsyncとHsyncの極性も先ほど説明した仕様の通りに設定します。DCMIは取り込んだデータをどこかに出力する必要があります。そのためにはDMAを使う必要があるので、その設定をします。出力先として本当のメモリを使えればそこに出力します。その時、書き込みアドレスは自動で増えてほしいのでMemoryのIncrement Addressをtrueに設定します。しかし、現実としては320x240x2ByteのメモリをSTM32で持つことは不可能です。そのため、LCDに直接出力することにします。FSMCのおかげでLCDに対しても通常のメモリと同じようにアクセスが出来ます。ただし、アドレスは常に固定なのでIncrement Addressはfalseにします。
I2CでSCCB通信するコードを実装する
カメラ設定のためにコマンドを送信する必要があります。そのためのwrite, read関数を用意します。これらを使用して、カメラの設定を行います。初期化シーケンス自体はwrite関数を使用してレジスタにひたすら値を書いていくだけなので、ここでは省略します。詳細はGitHubコードを確認ください。
static RET ov7670_write(uint8_t regAddr, uint8_t data)
{
HAL_StatusTypeDef ret;
ret = HAL_I2C_Mem_Write(sp_hi2c, SLAVE_ADDR, regAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100);
return ret;
}
static RET ov7670_read(uint8_t regAddr, uint8_t *data)
{
HAL_StatusTypeDef ret;
ret = HAL_I2C_Master_Transmit(sp_hi2c, SLAVE_ADDR, ®Addr, 1, 100);
ret |= HAL_I2C_Master_Receive(sp_hi2c, SLAVE_ADDR, data, 1, 100);
return ret;
}
DCMIで画像データを取得する
SCCB通信でカメラに対して所定の設定を行うことで、カメラモジュールとしては常時画像データ(PCLK, VSYNC, HREF, D[7:0])を出力します。それを取り込むために、DCMIを使うためのコードを実装します。基本的にはHALで提供されているDCMI用の関数HAL_DCMI_XXXを呼ぶだけです。
ov7670_stopCapは画像データ取り込みを停止します(HAL_DCMI_Stopを呼んでるだけ)。ov7670_startCapはHAL_DCMI_Start_DMAを呼びます。指定されたアドレス(本プロジェクトではLCD)に対して取り込んだ画像データをDMA転送します。転送する単位は32bit(4Byte)単位のようです。そのため、HAL_DCMI_Start_DMAの最後の引数には320x240/2を指定します。第2引数にはSINGLEキャプチャかCONTINUOUSキャプチャかを指定します。ライブビュー用にはCONTINUOUSモードを使用します。これによって、DCMIは毎フレーム画像データを取り込んでDMA転送します(SINGLEだと1フレーム取り込んだら停止する)。
この設定でうまく動くと思ったのですが、CONTINUOUSに設定しても画像取り込みが1枚で止まってしまいました。理由は、CONTINUOUSモードだと、DCMI自体は毎フレーム画像データを取り込んでいるのですが、DMA転送が1回しか行われないためっぽいです。そのため、フレーム取り込み完了の割り込みコールバック内で、次のDMA転送を開始するようにしました。これは、おそらくもっと良い方法や設定一つで解消できると思います。ご存知の方いましたら教えてください。
static uint32_t s_dstAddress;
RET ov7670_stopCap()
{
HAL_DCMI_Stop(sp_hdcmi);
s_dstAddress = 0;
return RET_OK;
}
RET ov7670_startCap(uint32_t destAddress)
{
ov7670_stopCap();
/* note: continuous mode automatically invokes DCMI, but DMA needs to be invoked manually */
s_dstAddress = destAddress;
HAL_DCMI_Start_DMA(sp_hdcmi, DCMI_MODE_CONTINUOUS, destAddress, OV7670_QVGA_WIDTH * OV7670_QVGA_HEIGHT/2);
return RET_OK;
}
void HAL_DCMI_FrameEventCallback(DCMI_HandleTypeDef *hdcmi)
{
if(s_dstAddress != 0) {
HAL_DMA_Start_IT(hdcmi->DMA_Handle, (uint32_t)&hdcmi->Instance->DR, s_dstAddress, OV7670_QVGA_WIDTH * OV7670_QVGA_HEIGHT/2);
}
}
例えば、ライブビュー表示をするときには、必要な初期設定を終えてから、以下のようにすればOKです。
ov7670_startCap(lcdIli9341_getDrawAddress());
while(1);
入力デバイスの制御
ボタンにはGPIO(プルアップ入力)を使用します。適当に空いているピンに名前を付けて、プルアップになるように設定をします。ダイアルにはロータリーエンコーダを使用します。タイマのCombined Channelsのエンコーダモードを使用することで、タイマハードウェアが自動的に回転情報をカウントしてくれます。
ソフトウェア構造
以上で、デジカメを作るのに必要なすべてのデバイスを使えるようになりました。今までのコードをモジュール化してデバイスドライバを作り、適切に制御してあげればOKです。適切に制御するためのソフトウェアをこれから作っていきます(ほとんどの方が知りたい情報はここまでのデバイス制御の方法だと思います。こっから先は僕のオレオレ設計なので、「ふ~ん」程度にご覧ください)。
ボトムアップで設計していきます。
今まで解説した各デバイス制御をまとめたデバイスドライバを作成します。OV7670モジュール、ILI9341モジュールとします。この2つは最下層のドライバー層にします。将来的にデバイスを変えたときにも使いやすくするために、このドライバー層をラップするHAL層を用意します(この「HAL」はSTM32HALとはことなります)。このHAL層には、OV7670を「カメラデバイス」として抽象化したCameraモジュールと、ILI9341を「ディスプレイデバイス」として抽象化したDisplayモジュールがいます。この上にアプリケーション層を用意します。アプリケーション層のモジュールがHAL層のモジュールを使用します。今回は機能仕様的に撮影と再生があるので、それぞれを司るLiveviewCtrlとPlaybackCtrlを用意します。これらを切り替えるModeMgrを用意します。ユーザからの入力はInputモジュールが受け取ります。そして、受け取った入力をアプリケーション層に通知します。MVCでいうCに近い感じです。Inputモジュールはサービスという位置づけにします。他に、ファイルアクセスを簡単にするFileサービスを用意しました。ファイルアクセスのためにはFatFSライブラリを使用します。アプリケーション層のモジュールとInputモジュールは別々に動く、また、独立性もあるし実装を楽にしたかったので、それぞれ別々のタスクで動かすことにしました。タスク間のやり取りにはメッセージを使用します。そのために、FreeRTOSを使用しました。
各タスク間でやり取りするメッセージ
FreeRTOSを使用することで、タスクとメッセージ通信がサポートされます。メッセージ内にコマンドとパラメータを格納してやり取りします。各CtrlはCMD_START, CMD_STOPというコマンドを受け付けます。ModeMgrがユーザからのモード切替指示によって、適切にSTART, STOPします。各Ctrlは一度STARTを受け取ると、後は独自に動きます。Inputモジュールは、CMD_RESISTER, CMD_UNRESISTERを受け付けます。ユーザ入力を受け付けたいモジュールはInputに対してこのコマンドを発行します。
Module | Queue ID | Command |
---|---|---|
ModeMgr | QUEUE_MODE_MGR | |
LiveviewCtrl | QUEUE_LIVEVIEW_CTRL | CMD_START CMD_STOP |
PlaybackCtrl | QUEUE_CAPTURE_CTRL | CMD_START CMD_STOP |
Input | QUEUE_INPUT | CMD_RESISTER CMD_UNRESISTER |
Input仕様
Inputモジュールは無限ループで、所定の入力デバイス(本プロジェクトではボタンとダイアル)の状態を定期的に監視します。もしも入力状態に変化があったら、CMD_RESISTERで登録済みのモジュール(クライアント)にNOTIFY通知します。
Todo メモ: Inputとアプリケーションモジュールの間に、デバイス入力を抽象化して各モジュールへの動作指示(コマンド)に変換する人をかます。例えば、key1が押された、ダイアルが回された、といった入力変化を撮影開始、早送り、といったコマンドに変換する。これによって各モジュールのインタフェースを商品仕様から独立させる。各Ctrlのユニットテストがやりやすくなる。また、他の経路(ターミナルからのデバッグコマンドやリモコン)からも同じコマンドを使えるようになる。
この変換は、プログラムではなくただの変換テーブルで実現できそう。この変換テーブルが商品仕様を吸収する感じ。
モード遷移(ModeMgr)仕様
各Ctrlの設計
LiveviewCtrl
LiveviewCtrlはModeMgrからの指示を受けて、Activate/Inactivateします。ActivateされるとInputモジュールに登録して自分でキャプチャボタンのイベントなどを受けて、撮影処理を行ったりします。ステートマシンとしては、以下のようになります。
PlaybackCtrl
PlaybackCtrlも同じく、ModeMgrからの指示を受けて、Activate/Inactivateします。基本的にはJPEGファイルをひたすら再生するだけです。静止画ファイルの時は、Inputモジュールからのイベントを受けて、次ファイルの再生を行います。動画ファイルのときは自発的に1つのMotionJPEGファイル内のJPEGを順次デコード、表示します。
データフローとシーケンス
ライブビュー
撮影モードに入ると、通常はライブビュー表示を行います。つまり、カメラから取得した画像を毎フレーム、ディスプレイに出力します。今回使用するOV7670は画像の出力フォーマットとしてRGB565に対応しています。また、ディスプレイとして使用するILI9341もRGB565を使用しています。つまり、カメラ画像の出力をそのままディスプレイに渡すだけで、ライブビューが実現できてしまいます。転送にはDMAを使用します。この設定は前述のコードの通りです。このデータフローを実現するシーケンス図が下記になります。
静止画撮影
静止画撮影にはJPEGエンコードを行います。STM32内のRAMに画像データを保持できれば楽に実装できるのですが、そこまで十分なメモリはありません。少し工夫をしてあげる必要がありました。
最初に思いついた案(没)
JPEGエンコードに使用するlibjpegはライン単位で処理できる。なので、カメラ設定を変えてHブランキングをめちゃくちゃ大きくする。1ライン(320x2Byte)だけ内部のRAMに保持して、H Blanking中に1ラインのエンコード処理を行う。
という案を最初思いつきました。しかし、OV7670のHブランキング(HREF)設定には謎があり、設定値にはいくつかマジックナンバーがあります。そのため、これを調整するのは結構厳しいです。代替案として、カメラの動作クロックを下げて1ラインの取り込み時間を遅くしたりしてみたのですが、露光時間も長くなってしまい、非常に明るい画しか撮影できなくなってしまいました。ということで、没。
採用した案
ここ でも共有した小ワザを使います。ライブビューは毎フレーム、画データをLCDに転送しています。LCD内のディスプレイコントローラはその画像データを内部のGRAMに保持しています。なので、それを使います。
ユーザがキャプチャボタンを押して、撮影開始したら、まずはライブビューを止めて、カメラ→ディスプレイへのDMA転送を停止します。これによって、LCDモジュール内部のGRAMは直前の画を保ちます。あとは、LCDモジュールから画像データを読み出してエンコード処理を行います。このとき、LCDから読み出すデータはRGB888フォーマットになるようです。
動画撮影
動画用のフォーマットにはモーションJPEGを採用します。つまり、JPEG撮影を繰り返して、作られたJPEGストリームを1つのファイルにまとめて保存します。動きとしては、前述のライブビュー表示(LCDへの画像データ転送)と静止画撮影(LCDからの画像データ取得とエンコード)を1フレーム毎に繰り返すだけです。
作ってみる
以下のようにピンアサインを行ったので、対応するピンとデバイスを接続します。
以下のピンは特に制御する必要がなかったので、固定にしました。
- LCD_RESET = VDD
- CAMERA_PWDN = GND
今回はハードウェアには手間をかけないようにしました。とはいえ、ブレッドボード上で全ての配線を行うのは結構厳しいです。僕は以下のように、DiscoveryボードとLCDを接続する子基板、DiscoveryボードとOV7670を接続する子基板を作成しました。
おわりに
開発について
FSMCとかDCMIは、使用する前はハードルが高いと思ったのですが、CubeMXのおかげで非常に簡単に実装できました。ライブビュー表示までなら1週間ちょっとでできました。そのあと、ソフトウェア設計などに無駄に凝ってしまったため最終的には3週間くらいかかりました。
StdLib派の方へ
今回はHALを使用しましたが、StandardPeripheralLibraryでもライブビュー表示まで行いました。コードはこちらになります。
https://github.com/take-iwiw/Camera_STM32_StdLib
資料など
ソースコード
ビデオ
- デモ: https://youtu.be/CgX3bM4v_aU
- 解説1: http://youtu.be/FAS0qRHHPxc
- 解説2: http://youtu.be/i8EkWke46GU
- 解説3: http://youtu.be/vokasVZTJLM
参考資料
pdfはどこが本家なのか分からないのでリンクは張っていません。ファイル名でググれば出てくると思います。
- en.CD00201397.pdf
- STの資料。FSMCからLCDを制御するためのアプリケーションノート
- en.DM00031020.pdf
- STの資料。STM32F4シリーズのリファレンスマニュアル。モジュールの使い方やレジスタ設定値等が記載
- en.DM00037051.pdf
- STの資料。STM32F407のデータシート。デバイス依存の情報(メモリマップなど)が記載
- en.DM00046011.pdf
- STの資料。DMAの使い方やペリへの割り当てが記載
- OV7670_DS.pdf
- OV7670のデータシート
- OV7670app.pdf
- OV7670のアプリケーションノート (具体的なレジスタ設定方法などが記載されている)
- ov-sccb.pdf
- SCCB通信仕様
-
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/media/i2c/ov7670.c
- OV7670のレジスタ設定はLinuxコードを参考にしました。データシートに記載されているHREFの説明が意味不明だという裏付けが取れました
- ILI9341_DS_V1.05_20101227.pdf
- ILI9341のデータシート