N.Y.Cityのまちかど

A_bare_metal_programming_guide_Nucleo-F429ZI

Nucleo-F429ZIベアメタルプログラミングガイド(A bare metal programming guide)

この記事は、A bare metal programming guideを山口が日本語に翻訳したものです。 元資料はMITライセンスで公開されています。

MIT License

Copyright (c) 2022 Cesanta Software Limited

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: " "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. " "THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


このガイドは、GCCコンパイラとベアメタルアプローチ*1によってマイコンプログラミングを始めたい開発者に向けて書かれています。我々はSTM32F429マイコンを搭載したNucleo-F429ZI開発ボード を使用します。(Mouserから購入

このガイドは、コンパイラとデータシートのみを使用したプログラミングをどうやったら行えるかを示します。その後で、ベンダーの CMSIS ヘッダーとは何か、それらを使用する方法と理由について説明します。

このガイドは以下のようなトピックスをカバーします。「メモリとレジスタ」「割込みベクタテーブル」「隣家スクリプト」「makeを使った自動ビルド」「GPIO周辺回路とLED点滅」「SysTickタイマ」「UART周辺回路とデバッグ出力」「UARTへのprintfリダイレクト(IOリターゲッティング)」「Segger Ozoneを使用したデバッグ」「システムクロックの設定」「デバイスダッシュボードを使ったWebサーバの実装」

各章には完全なサンプルプロジェクトが付属しています。各章は前の章を拡張していきます。そのため前章のサンプルは、読者が次章の作業を進めるための骨組みとなります。Nucleo-F429ZIに加え、他のボード用サンプルも提供する予定です。(RPI 2040, NXP, TI はショートリストにあります)

作業を進めるためには以下のツールを必要とします。

Macにインストールするためにはターミナルを起動し、以下を実行します。

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
$ brew install gcc-arm-embedded make stlink

Linux(Ubuntu)でセットアップを行うためにはターミナルを起動し、以下を実行します。

$ sudo apt -y install gcc-arm-none-eabi make stlink-tools

Windowsでセットアップを行うためには

  1. gcc-arm-none-eabi-10.3-2021.10-win32.exeをダウンロード・インストールします。環境変数にPathを通すをインストール中に実行して下さい。
  2. c:\tools フォルダを作成する
  3. stlink-1.7.0-x86_64-w64-mingw32.zipをダウンロードしてbin/st-flash.exeを解凍し、c:\toolsに入れる
  4. make-4.4-without-guile-w32-bin.zipをダウンロードしてbin/make.exeを解凍し、c:\toolsに入れる
  5. 環境変数のPATHにc:\toolsを通す
  6. インストール確認のために以下を行ってください
    1. リポジトリをc:\にダウンロード・展開する
    2. コマンドプロンプトを開き、以下を実行する
C:\Users\YOURNAME> cd \
C:\> cd bare-metal-programming-guide-main\step-0-minimal
C:\bare-metal-programming-guide-main\step-0-minimal> make
arm-none-eabi-gcc main.c  -W -Wall -Wextra -Werror ...

また、2つのデータシートをダウンロードして下さい。

イントロダクション(Introduction)

マイクロコントローラ(マイコン、uC、MCU)は小さなコンピュータで、一般にはCPUとRAM、そしてファームウェアを記憶するためのFlashを搭載しています。そして複数本のピンが突き出しています。いくつかのピンは、MCUの電源に使用され、GND(グランド)やVCCピンと言われています。その他のピンはMCUとの通信に使用されます。これはHigh(H)/Low(L)の電圧が各ピンに加わることを意味します。通信を行う単純な方法の一つとしては、ピンにLEDを接続します。LEDの片端子をGNDピンにつなぎ、反対の端子を電流制限抵抗を通じて信号ピンに接続します。ファームウェアコードはHまたはLの電圧をピンに設定することができ、LEDを付けたり消したりします。

メモリとレジスタ(Memory and registers)

MCUがもつ32ビットのアドレス空間は複数の領域に分割されています。例えばいくつかのメモリ領域はMCUに内蔵されているFlashの固有アドレスにマッピングされています。ファームウェアコードの命令はこのメモリ領域から読み込まれ、実行されます。他の領域は同様にRAMの固有アドレスにマッピングされ、RAM領域から値を読み書きできます。

STM32F429のデータシートからsection 2.3.1を見ると、RAM領域は0x20000000から始まり、192KBのサイズを持っていることがわかります。 section 2.4を見るとフラッシュ領域のアドレスは0x08000000からマッピングされ、このMCUは2MBのフラッシュを持ちます。フラッシュとRAM領域は以下のようにマッピングされています。

データシートからは、さらにたくさんの領域が用意されている事がわかります。これら領域のアドレス範囲はsection 2.3 "Memory Map"に示されています。例えばGPIOA領域は0x40020000から始まり、1KBの長さがあります。

これらのメモリ領域はMCU内部の異なる「ペリフェラル」(周辺回路…各ピンに与えられた特殊機能を実現する半導体回路)に対応しています。ペリフェラルメモリ領域は32ビットレジスタの集合です。各レジスタはアドレスに割り振られた4バイトの記憶領域を持ち、ペリフェラルの各機能に割り当てられています。レジスタに値を書き込むことによって…言い換えれば各メモリアドレスに32ビットの情報を書き込むことによって…それぞれのペリフェラルを制御できます。レジスタを読み込むことによって、ペリフェラルの情報や設定を読みだせます。

MCUにはさまざまなペリフェラルが用意されています。一つの単純な例としてはGPIO(General Purpose Input Output…汎用入出力)があります。ユーザがMCUのピンを「出力モード」に設定すれば、電圧をH/Lに変化できます。あるいは「入力モード」に変えることでMCUのピン電圧を読み取れます。UARTペリフェラルは2本のピンを介して直列(Serial)データの読み書きをシリアルプロトコルによって行うことができます。他にもたくさんのペリフェラルが存在します。

しばしば、GPIOA GPIOBなど同じペリフェラルの複数インスタンス*2が存在することがあります。これらは異なるMCUピン群を制御します。UART1, UART2も同様に複数のUARTチャンネルを実装します。Nucleo-F429には複数のGPIOペリフェラルとUARTペリフェラルがあります。

例えば、GPIOAペリフェラルは0x40020000から始まります。GPIOレジスタの定義はデータシートのsection 8.4に存在します。データシートはGPIOA_MODERレジスタのオフセットは0だと示しています。これはすなわちGPIOA_MODERレジスタのアドレスが0x40020000 + 0であることを意味します。レジスタの構造は以下の通りです。

このデータシートは32ビットのMODERレジスタが、2bit情報の集合であることを示し、全部で16個の情報が集まっています。従って、1つのMODERレジスタは16の物理ピンを制御しています。0-1ビットはピン0を制御、2-3ビットはピン1を制御…といった具合です。2ビットの情報はピンモードを示します。0は入力モード、1は出力モード、2は「代替機能」であり、他の場所で説明される機能を表します。3はアナログを意味します。ペリフェラル名がGPIOAであれば、ピンはA0,A1…と命名されます。ペリフェラル名がGPIOBであれば、ピン名はB0,B1です。

もしも32ビットの0をMODERに書き込めば、A0からA15までの16ピンすべてが入力モードになります。

  * (volatile uint32_t *) (0x40020000 + 0) = 0;  // A0-A15 ピンをインプットモードにする

個々のビットを設定することで、個別にピンの機能を設定することができます。例えば以下のコード例ではA3を出力モードに変えています。

  * (volatile uint32_t *) (0x40020000 + 0) &= ~(3 << 6);  // 6-7ビットをクリアする
  * (volatile uint32_t *) (0x40020000 + 0) |= 1 << 6;     // 6-7ビットを1にする

いくつかのレジスタはMCUのペリフェラルに割り当てられていません。それはARM のCPU設定や制御に割り当てられます。例えばデータシートのsection 6に記載されている"Reset at clock control"ユニット(RCC)があります。これはシステムクロック他の設定を書き込むためのレジスタです。

Human-readable peripherals programming

人間が理解できるペリフェラルプログラミング(Human-readable peripherals programming)

前のセクションでは、対応するメモリアドレスに直接アクセスすることで、ペリフェラルレジスタを読み書きする方法を学びました。A3ピンを出力モードにする以下のコード例を見て下さい。

  * (volatile uint32_t *) (0x40020000 + 0) &= ~(3 << 6);  // 6-7ビットをクリアする
  * (volatile uint32_t *) (0x40020000 + 0) |= 1 << 6;     // 6-7ビットを1にする

これはまるで暗号です。添えられたコメントなしでは、このコードが何を意味しているのかを理解することは難しいでしょう。これらをもっと読みやすい形に書き換えます。ここで使うアイデアは、ペリフェラル全体を32ビットデータを含む構造体としてとらえることです。データシートのsection 8.4で GPIOペリフェラルに存在するレジスタを見てみましょう。MODER, OTYPER, OSPEEDR, PUPDR, IDR, ODR, BSRR, LCKR, AFRがあり、それらのオフセットは0, 4, 8,…です。これは、GPIOAという名前で定義した、32ビットのフィールドを持つ構造体として表現できることを意味しています。

struct gpio {
  volatile uint32_t MODER, OTYPER, OSPEEDR, PUPDR, IDR, ODR, BSRR, LCKR, AFR[2];
};

#define GPIOA ((struct gpio *) 0x40020000)

それから、GPIOのピンモードを設定するために以下の関数を定義できます。

// 列挙値: 0, 1, 2, 3
enum {GPIO_MODE_INPUT, GPIO_MODE_OUTPUT, GPIO_MODE_AF, GPIO_MODE_ANALOG};

static inline void gpio_set_mode(struct gpio *gpio, uint8_t pin, uint8_t mode) {
  gpio->MODER &= ~(3U << (pin * 2));        // 現在の設定をクリアする
  gpio->MODER |= (mode & 3) << (pin * 2);   // 新しいモードをセットする
}

これにより、先ほどのA3ピンを出力モードにするコード例は以下のように書き変わります。

gpio_set_mode(GPIOA, 3 /* pin */, GPIO_MODE_OUTPUT);  // A3を出力モードにする

私たちのMCUはA,B,C…Kという複数のGPIOペリフェラルを持ちます(「バンク」とも呼ばれます)。データシートのsection 2.3には、GPIOA がアドレス0x40020000に, GPIOBがアドレス0x40020400に…以下同様 と定義されています。 Our MCU has several GPIO peripherals (also called "banks"): A, B, C, ... K. From we can see that they are 1KB away from

#define GPIO(bank) ((struct gpio *) (0x40020000 + 0x400 * (bank)))

私たちはピンの名をバンク名とピン番号で定義できます。そのために2バイトuint16_tの値を使います。上位バイトはGPIOバンクを表し、下位バイトはピン番号を表します。

#define PIN(bank, num) ((((bank) - 'A') << 8) | (num))
#define PINNO(pin) (pin & 255)
#define PINBANK(pin) (pin >> 8)

この方法で全てのGPIOバンクを定義できます。

  uint16_t pin1 = PIN('A', 3);    // A3   - GPIOA ピン 3
  uint16_t pin2 = PIN('G', 11);   // G11  - GPIOG ピン 11

それでは、gpio_set_mode()関数をこのピン定義方法に対応させてみましょう。

static inline void gpio_set_mode(uint16_t pin, uint8_t mode) {
  struct gpio *gpio = GPIO(PINBANK(pin)); // GPIO バンク
  uint8_t n = PINNO(pin);                 // ピン番号
  gpio->MODER &= ~(3U << (n * 2));        // 現在の設定をクリア
  gpio->MODER |= (mode & 3) << (n * 2);   // 新しいモードをセットする
}

A3ピンを出力モードにしたいことが一目瞭然になります。

  uint16_t pin = PIN('A', 3);            // A3ピン
  gpio_set_mode(pin, GPIO_MODE_OUTPUT);  // 出力モードにする

私たちはGPIOペリフェラルを扱う大変便利なAPIを作ったということに注意しましょう。UART(シリアル通信)などの他のペリフェラルも似たような方法で定義できます。これは人間が読める…一目瞭然なプログラムを作成するための大変優れた方法です。

MCUのブートとベクタテーブル(MCU boot and vector table)

STM32F429 MCUをブート(起動)するとき、MCUは「ベクタテーブル」と呼ばれる、Flashメモリの先頭部を読み込みます。ベクタテーブルはARM MCUの共通設計思想であり、割込みハンドラのアドレス(32ビット)が並んだ配列になっています。最初の16項目はARMによって予約されており、すべての ARM MCU に共通です。残りの割り込みハンドラは、MCUに固有のものです。これらは周辺機器用の割り込みハンドラです。周辺機器がほとんどない単純なMCUには割り込みハンドラがほとんどなく、複雑な MCU には多くの割り込みハンドラがあります。

STM32F429のベクタテーブルはTable(表)62に記載されています。ここから、STM32F429には基本の16個に加えて91個のペリフェラルハンドラがあることが読み取れます。

ここで、ベクタテーブル最初の2要素に注目します。これらのエントリは、MCUのブートプロセスで重要な役割を果たすからです。最初の2つの値は、初期スタックポインタと、実行するブート関数のアドレス(ファームウェア エントリ ポイント)です。

これにより、フラッシュの2番目に存在する32ビット値に、ブート関数のアドレスが入るよう、ファームウェアを構成する必要があることがわかりました。 MCU が起動すると、フラッシュからそのアドレスを読み取り、ブート関数にジャンプします。

最小限のファームウェア(Minimal firmware)

main.cファイルを作りましょう。そして「何もせず無限ループに突入する」ようなブート関数を作成して、16の標準エントリと91のSTM32エントリを含むベクタテーブルを指定します。好みのエディタを使い、main.cファイルを作成して、以下のソースコードをコピペしてください。

// スタートアップコード
__attribute__((naked, noreturn)) void _reset(void) {
  for (;;) (void) 0;  // 無限ループ
}

// 16の標準ハンドラ+91のSTM32固有ハンドラ
__attribute__((section(".vectors"))) void (*tab[16 + 91])(void) = {
  0, _reset
};

関数 _reset() では、GCC 固有の属性であるnaked と noreturn を使用しました。つまり、標準関数のプロローグとエピローグはコンパイラによって作成されるべきではなく、その関数はReturnしません。

void (*tab[16 + 91])(void) の意味はこうです。16 + 91の要素をもつ配列を宣言します。これらは何も返さず(void)、引数をとります。各関数はIRQ ハンドラ(Interrupt ReQuest handler)です。これらハンドラの集まった配列をベクタテーブルと言います。

ベクタテーブルが収められたセクションは.vectorsといいます。これは後ほどリンカに、生成したファームウェアの先頭に置くよう指示する必要があります。ベクタテーブルはフラッシュメモリの先頭に連続して配置します。ベクタテーブルの残りの部分はゼロで埋めておきます。

スタックポインタの初期値である、ベクターテーブルの最初のエントリを設定していないことに注意してください。なぜならば正しい値がわからないからです。後で処理します。

コンパイル(Compilation)

ソースコードをコンパイルしてみましょう。

$ arm-none-eabi-gcc -mcpu=cortex-m4 main.c -c

通りました!このコンパイルにより、main.oという何もしない最小ファームウェアを含んだファイルが生成されました。main.oファイルはELF binary formatという形式で各セクションを保持しています。見てみましょう。

$ arm-none-eabi-objdump -h main.o
...
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000002  00000000  00000000  00000034  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         00000000  00000000  00000000  00000036  2**0
                  CONTENTS, ALLOC, LOAD, DATA
  2 .bss          00000000  00000000  00000000  00000036  2**0
                  ALLOC
  3 .vectors      000001ac  00000000  00000000  00000038  2**2
                  CONTENTS, ALLOC, LOAD, RELOC, DATA
  4 .comment      0000004a  00000000  00000000  000001e4  2**0
                  CONTENTS, READONLY
  5 .ARM.attributes 0000002e  00000000  00000000  0000022e  2**0
                  CONTENTS, READONLY

各セクションのVMA/LMAアドレスが0になっていることに注意しましょう。これはmain.oが完全なファームウェアになっていないことを意味します。なぜならばそれぞれのアドレス空間にロードすべき内容が含まれていないからです。main.oから完全なファームウェアfirmware.elf を生成するためにはリンカを使用する必要があります。

.textセクションにはファームウェアコードが入ります。今回の例では_reset()関数だけです。これはわずか2バイトの、独自アドレスへのジャンプ命令です。そこは空っぽです。,dataと空っぽの.bssセクションがあります。(ゼロに初期化されたデータ)。ファームウェアはオフセット 0x8000000 のFlash領域にコピーされますが、データセクションはRAMに存在する必要があります。したがって、_reset() 関数は .data セクションの内容をRAMにコピーする必要があります。また、.bss セクション全体にゼロを書き込む必要があります。 .data および .bss セクションは空ですが、これらを適切に処理するために _reset() 関数を変更しましょう。

また、_reset()関数は適切な初期スタックポインタをセットする必要があります。なぜならベクタテーブルの先頭に0が入ってしまっているからです。そのためには、スタックの開始位置とデータおよびbss セクションの開始位置を知る必要があります。これは、「リンカスクリプト」で指定できます。これは、リンカへの指示、アドレス空間内のさまざまなセクションの配置場所、および作成するシンボルを含むファイルです。

リンカスクリプト(Linker script)

最小限のリンカスクリプト、link.ldを作り、step-0-minimal/link.ldの内容をコピペします。以下に説明を示します。

ENTRY(_reset);

この行は、生成されたELFヘッダーの「エントリーポイント」属性の値をリンカに伝えます。従ってこれはベクタテーブルの値と同じです。これはファームウェアの先頭にブレークポイントを設定するなど、デバッガ(後述するOzoneなど)が使用する情報になります。デバッガはベクタテーブルを認識できないため、ELFヘッダの情報を使用します。

MEMORY {
  flash(rx)  : ORIGIN = 0x08000000, LENGTH = 2048k
  sram(rwx) : ORIGIN = 0x20000000, LENGTH = 192k  /* remaining 64k in a separate address space */
}

これはアドレス空間内に2つのアドレス領域がある事と、それらのアドレス・大きさを示しています。

_estack     = ORIGIN(sram) + LENGTH(sram);    /* stack points to end of SRAM */

これは、RAM メモリ領域の最後を示すシンボル estack*3 を作成するようリンカに指示します。それが初期スタック値になります。 This tell a linker to create a symbol estack with value at the very end of the RAM memory region. That will be our initial stack value!

  .vectors  : { KEEP(*(.vectors)) }   > flash
  .text     : { *(.text*) }           > flash
  .rodata   : { *(.rodata*) }         > flash

これらの行はFlashの先頭にベクトルテーブルを配置し、次に.textセクション(ファームウェアコード)を配置し、その後読み取り専用データ.rodataを配置するように、リンカへ指示します。

次は.dataセクションに入ります。

  .data : {
    _sdata = .;   /* .data section start */
    *(.first_data)
    *(.data SORT(.data.*))
    _edata = .;  /* .data section end */
  } > sram AT > flash
  _sidata = LOADADDR(.data);

リンカに _sdata*4および _edata*5シンボルを作成するように指示していることに注意してください。これらを使用して、_reset() 関数でデータ セクションを RAM にコピーします。

.bssセクションも同様です。*6

  .bss : {
    _sbss = .;              /* .bss section start */
    *(.bss SORT(.bss.*) COMMON)
    _ebss = .;              /* .bss section end */
  } > sram

スタートアップコード(Startup code)

ここで、私たちの作った_reset()関数を更新することができます。スタックポインタを初期化し、データセクションをRAMにコピーし、basセクションをゼロにします。そしてmain()関数を呼び出します。そしてmain()関数からリターンしたら無限ループに入るようにします。

int main(void) {
  return 0; // ここまで何もしない
}

// スタートアップコード
__attribute__((naked, noreturn)) void _reset(void) {
  asm("ldr sp, = _estack");  // スタックポインタの初期値を設定

  // memsetで .bssをゼロクリア、 .data セクションを RAM領域にコピー
  extern long _sbss, _ebss, _sdata, _edata, _sidata;
  for (long *src = &_sbss; src < &_ebss; src++) *src = 0;
  for (long *src = &_sdata, *dst = &_sidata; src < &_edata;) *src++ = *dst++;

  main();             // main()関数をコール
  for (;;) (void) 0;  // main()関数をリターンで抜けた後の無限ループ
}

以下の図式は_reset()関数が.dataと.bssセクションをどのように初期化するかを可視化したものです。

これで完全なファームウェアファイルfirmware.elfを作る準備が出来ました。

$ arm-none-eabi-gcc -T link.ld -nostdlib main.o -o firmware.elf

firmware.elfのセクションを確認してみましょう。

$ arm-none-eabi-objdump -h firmware.elf
...
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .vectors      000001ac  08000000  08000000  00010000  2**2
                  CONTENTS, ALLOC, LOAD, DATA
  1 .text         00000058  080001ac  080001ac  000101ac  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
...

.vectorsセクションがアドレス0x8000000、Flashの最初に存在することがわかります。そして.textはその後のアドレス0x80001acに存在します。私たちのコードは変数を使用していないので.dataセクションには何のデータもありません。

ファームウェアの書き込み(Flash firmware)

このファームウェアを書き込む準備が出来ました!まず最初に、firmware.elfを単一の連続したバイナリblobに変換します。

$ arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

そしてst-linkユーティリティを使用してfirmware.binを書き込みます。マイコンボードをUSB接続し、以下を実行して下さい。

$ st-flash --reset write firmware.bin 0x8000000

完了しました!これで「何もしない」ファームウェアを書き込むことができました。

Makefile:自動ビルド(Makefile: build automation)

リンクや書き込みといったコマンドをタイピングする代わりに、自動的に作業を行ってくれるコマンドラインツールを使用することができます。makeユーティリティはMakefikeと名付けられた設定ファイルを用い、その中にある動作指示を読み取ります。この自動化はファームウェアの構築プロセスやコンパイルフラグを文書化する意味も果たします。

Makefilelの書式はシンプルです。

action1:
	command ...     # Comments can go after hash symbol
	command ....    # IMPORTANT: command must be preceded with the TAB character

action2:
	command ...     # Don't forget about TAB. Spaces won't work!

これで、アクション名(ターゲットとも呼ばれる)を指定して make を呼び出し、対応するアクションを実行できます。

$ make action1

コマンドで使用するために変数を定義することも可能です。またアクションは作成するファイル名にすることが可能です。

firmware.elf:
	COMPILATION COMMAND .....

そして、それぞれのアクションは依存関係リストを持つことが可能です。例えばfirmware.elfはソースファイルmain.cに依存していますmain.cが修正されたとき、makeはfirmware.elfをリビルドするコマンドを発行します。

build: firmware.elf

firmware.elf: main.c
	COMPILATION COMMAND

これで私たちのファームウェア用にMakefileを作る準備が出来ました。ビルド処理とターゲットを定義しましょう。

CFLAGS  ?=  -W -Wall -Wextra -Werror -Wundef -Wshadow -Wdouble-promotion \
            -Wformat-truncation -fno-common -Wconversion \
            -g3 -Os -ffunction-sections -fdata-sections -I. \
            -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 $(EXTRA_CFLAGS)
LDFLAGS ?= -Tlink.ld -nostartfiles -nostdlib --specs nano.specs -lc -lgcc -Wl,--gc-sections -Wl,-Map=$@.map
SOURCES = main.c 

build: firmware.elf

firmware.elf: $(SOURCES)
	arm-none-eabi-gcc $(SOURCES) $(CFLAGS) $(LDFLAGS) -o $@

コンパイルフラグを定義しています。 ?= はデフォルト値を意味しています。この値はコマンドラインから上書きする事も可能です。例えば以下のように:

$ make build CFLAGS="-O2 ...."

CFLAGS, LDFLAGS そして SOURCES 変数を指定します。次にmakeに指示を出します。firmware.elfファイルを生成する時にはmain.cファイルに依存しています。生成のためには与えられたフラグを付けてrm-none-eabi-gccコンパイラを起動します。$@は特殊変数で、ターゲット名を意味します。今回のケースではfirmware.elfになります。

makeを呼んでみましょう。

$ make build
arm-none-eabi-gcc main.c  -W -Wall -Wextra -Werror -Wundef -Wshadow -Wdouble-promotion -Wformat-truncation -fno-common -Wconversion -g3 -Os -ffunction-sections -fdata-sections -I. -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16  -Tlink.ld -nostartfiles -nostdlib --specs nano.specs -lc -lgcc -Wl,--gc-sections -Wl,-Map=firmware.elf.map -o firmware.elf

もう一度実行してみると

$ make build
make: Nothing to be done for `build'.

makeユーティリティはmain.c の依存関係と firmware.elf の更新時間を調べます。firmware.elf が最新の場合は何もしません。しかし、main.c を変更すると、次の make ビルドで再コンパイルが行われます。

$ touch main.c # main.cの変更を模擬する
$ make build

さて、残っているのは書き込み対象です。

firmware.bin: firmware.elf
	$(DOCKER) $(CROSS)-objcopy -O binary $< $@

flash: firmware.bin
	st-flash --reset write $(TARGET).bin 0x8000000

これです!これでmake flash terminal コマンドによって firmware.bin ファイルが作成され、マイコンボードに書き込まれます。そしてこれはmain.cが更新されたときはファームウェアを再コンパイルします。これは、firmware.bin が firmware.elf に依存し、さらに main.c に依存しているためです。したがって、開発サイクルは次の 2 つのアクションのループになります。

# main.cのコードを作る
$ make flash

生成したものを消去するためのcleanアクションを作っておくのも良い方法です。

clean:
	rm -rf firmware.*

完成したプロジェクトソースコードはstep-0-minimalフォルダから取得できます。

LEDを点滅する(Lチカ)(Blinky LED)

今私たちはビルドから書き込みまでの流れを行えるようになりました。ここからは「使える」ファームウェアを作っていきます。「使える」の代表例はもちろん「LEDの点滅(Lチカ)」です。Nucleo-F429ZIボードには3つのLEDが内蔵されています。Nucleoボードのデータシートsection 6.5で、それらの内蔵LEDがどのピンに接続されているかがわかります。

  • PB0: green LED(緑LED)
  • PB7: blue LED(青LED)
  • PB14: red LED(赤LED)

早速main.cにこれらのピン番号宣言を書き込み、gpio_set_mode()を行いましょう。main()関数では青LEDを出力モードにして、無限ループに入ります。まず、以前に説明したピンと GPIO の定義をコピーしましょう。便利なマクロ BIT(position) も追加することに注意してください。

#include <inttypes.h>
#include <stdbool.h>

#define BIT(x) (1UL << (x))
#define PIN(bank, num) ((((bank) - 'A') << 8) | (num))
#define PINNO(pin) (pin & 255)
#define PINBANK(pin) (pin >> 8)

struct gpio {
  volatile uint32_t MODER, OTYPER, OSPEEDR, PUPDR, IDR, ODR, BSRR, LCKR, AFR[2];
};
#define GPIO(bank) ((struct gpio *) (0x40020000 + 0x400 * (bank)))

// 列挙値: 0, 1, 2, 3
enum { GPIO_MODE_INPUT, GPIO_MODE_OUTPUT, GPIO_MODE_AF, GPIO_MODE_ANALOG };

static inline void gpio_set_mode(uint16_t pin, uint8_t mode) {
  struct gpio *gpio = GPIO(PINBANK(pin));  // GPIOバンク
  int n = PINNO(pin);                      // ピン番号
  gpio->MODER &= ~(3U << (n * 2));         // 現在の設定をクリア
  gpio->MODER |= (mode & 3) << (n * 2);    // 新しいモードをセットする
}

一部のマイコンでは、電源を入れると同時に全てのペリフェラルが電源ONかつ有効になります。しかしSTM32は電力節約のため、デフォルトではペリフェラルが無効になっています。GPIOペリフェラルを有効にするために、RCC (リセットおよびクロック制御) ユニットを経由して有効にする (クロックする) 必要があります。データシートのセクション7.3.10に、AHB1ENR (AHB1 peripheral clock enable register)がGPIOバンクのオンオフに対応していることが書かれています。まずRCCユニットの定義を書き加えましょう。

struct rcc {
  volatile uint32_t CR, PLLCFGR, CFGR, CIR, AHB1RSTR, AHB2RSTR, AHB3RSTR,
      RESERVED0, APB1RSTR, APB2RSTR, RESERVED1[2], AHB1ENR, AHB2ENR, AHB3ENR,
      RESERVED2, APB1ENR, APB2ENR, RESERVED3[2], AHB1LPENR, AHB2LPENR,
      AHB3LPENR, RESERVED4, APB1LPENR, APB2LPENR, RESERVED5[2], BDCR, CSR,
      RESERVED6[2], SSCGR, PLLI2SCFGR;
};
#define RCC ((struct rcc *) 0x40023800)

AHB1ENRレジスタの資料を読むと、0~8ビットがGPIOバンク GPIOA~GPIOEのクロック供給を設定することがわかります。

int main(void) {
  uint16_t led = PIN('B', 7);            // 青LED
  RCC->AHB1ENR |= BIT(PINBANK(led));     // LED用にGPIOクロックを有効化
  gpio_set_mode(led, GPIO_MODE_OUTPUT);  // 青LED(の接続されたポート)を出力モードに
  for (;;) asm volatile("nop");          // 無限ループ
  return 0;
}

後は、GPIOピンをオンオフする方法を見つけて、メインループの中に〈ON〉→〈待機〉→〈OFF〉→〈待機〉のループを作るだけです。データシートのセクション8.4.7に、BSRRレジスタが電圧のH/Lに対応していると書いてあります。下位16ビットはODRレジスタを設定する (ピンをHに設定する) ために使用され、上位16 ビットは ODR レジスタをリセットする (ピンをLに設定する)ために使用されます。そのための API 関数を定義しましょう。

static inline void gpio_write(uint16_t pin, bool val) {
  struct gpio *gpio = GPIO(PINBANK(pin));
  gpio->BSRR |= (1U << PINNO(pin)) << (val ? 0 : 16);
}

次に、delay関数を定義する必要があります。現時点では正確なdelayは必要ないので、NOP命令*7を指定回数繰り返すspin()関数を定義しましょう。

static inline void spin(volatile uint32_t count) {
  while (count--) asm("nop");
}

これでLチカプログラムを実装する準備が出来ました。

  for (;;) {
    gpio_write(pin, true);
    spin(999999);
    gpio_write(pin, false);
    spin(999999);
  }

make flashコマンドを実行して、LEDが点滅することを楽しんで下さい。完全なプロジェクトソースコードはstep-1-blinkyで取得できます。

SysTick割込みを使用した点滅(Blinky with SysTick interrupt)

正確な時間管理を行うためには、ARMのSysTick割込みを使用する必要があります。SysTickは24ビットのハードウェアカウンタであり、ARMコアの一部です。従ってこれはARMデータシートの中に記載されています。データシートを見てみましょう。SysTIckは4つのレジスタを持っています。

  • CTRL - systickの有効化・無効化に使用
  • LOAD - カウンタ初期値
  • VAL - 現在のカウンタ値。1クロックごとにデクリメント(-1)される
  • CALIB - キャリブレーションレジスタ

VALの値が0になるたびに、SysTick割込みが生成されます。ベクタテーブルのSysTick割込みインデックスは15ですので、これを設定する必要があります。起動すると、Nucleo-F429ZIボード は16MHzで動作するので、SysTickカウンタをミリ秒ごとに割込みが発生するよう設定することができます。

最初にSysTickペリフェラルを定義しましょう。データシートから、4つのレジスタがあることと、SysTickのアドレスが0xe000e010であることがわかるので

struct systick {
  volatile uint32_t CTRL, LOAD, VAL, CALIB;
};
#define SYSTICK ((struct systick *) 0xe000e010)

次にこれらを設定するためのAPI関数を定義します。SYSTICK->CTRLレジスタにSysTickを有効にする情報を書き込むことと、セクション7.4.14で説明されているようにRCC->APB2ENRを通じてクロックを供給する必要があります。

#define BIT(x) (1UL << (x))
static inline void systick_init(uint32_t ticks) {
  if ((ticks - 1) > 0xffffff) return;  // Systickタイマは24 bit
  SYSTICK->LOAD = ticks - 1;
  SYSTICK->VAL = 0;
  SYSTICK->CTRL = BIT(0) | BIT(1) | BIT(2);  // systickを有効化
  RCC->APB2ENR |= BIT(14);                   // SYSCFGを有効化
}

デフォルトではNucleo-F429ZIボードは16MHzで動作します。もしもsystick_init(16000000 / 1000)を呼べば、SysTick割込みは1ミリ秒ごとに発生することになります。割込みハンドラを定義する必要があります。ここでは、32 ビットのミリ秒カウンタをインクリメント(+1)するだけです。

static volatile uint32_t s_ticks;
void SysTick_Handler(void) {
  s_ticks++;
}

そしてこの割込みハンドラをベクタテーブルに登録します。

__attribute__((section(".vectors"))) void (*tab[16 + 91])(void) = {
    0, _reset, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, SysTick_Handler};

これで正確な1ミリ秒クロックができました。任意時間の定期タイマーを作るためのヘルパー関数を作成しましょう。

// t: 時間管理変数ポインタ, prd: タイマ間隔, now: 現在のタイマ値(SysTick)。
//期限が来たらtrueを返す
bool timer_expired(uint32_t *t, uint64_t prd, uint64_t now) {
  if (now + prd < *t) *t = 0;                    // 時間が巻き戻ったらタイマをリセットする
  if (*t == 0) *t = now + prd;                   // リセット後最初は有効期限をセットする
  if (*t > now) return false;                    // まだ期限切れでなければfalseを返す
  *t = (now - *t) > prd ? now + prd : *t + prd;  // 次の期限を設定
  return true;                                   // 期限切れになったのでtrueを返す
}

メインループを修正し、LED点滅に正確なタイマーを使用する準備が整いました。例えば250ミリ秒ごとにLEDを点滅させてみましょう。

  uint32_t timer, period = 250;          // タイマーと、250msのタイマ間隔を宣言する
  for (;;) {
    if (timer_expired(&timer, period, s_ticks)) {
      static bool on;       // LEDの状態を管理するstatic(静的)変数onを宣言
      gpio_write(led, on);  // 毎「タイマ間隔」時間ごとにピンの信号を更新
      on = !on;             // LEDの状態を反転する
    }
    // ここで他の処理を実行可能です
  }

SysTickとヘルパー関数timer_expired()を使用してメイン ループ (スーパーループとも呼ばれます) をノンブロッキングにしたことに注意してください。つまり、そのループ内で多くの処理を実行できます。たとえば、様々な間隔の複数タイマーを使用すると、それらはすべて時間内にトリガーされます。

完全なプロジェクトソースコードはstep-2-systickで取得できます。

UARTデバッグ出力を追加する(Add UART debug output)

続いて、人間が読み取ることのできる診断ツールをファームウェアに組み込みましょう。MCUのペリフェラルにシリアルUARTインタフェースがあります。データシートのセクション2.3を見ると、いくつかのUART/USARTコントローラがあることがわかります。つまり適切に設定を行うことで、特定のピンを通じてデータのやり取りが可能なのです。最もシンプルな構成では、RX(受信)とTX(送信)という2本のピンを使用します。

Nucleoボードのデータシートsection 6.9では、(シリアルUARTインタフェース)コントローラの1つであるUSART3が、PD8をTX(送信)、PD9を受信として使用し、オンボードのST-LINKデバッガに接続されています。これは、USART3を設定してPD9からデータを送信すると、その内容をST-LINKのUSB接続を通じてパソコンから見られるということを意味します。

従って、GPIOの時と同じように、UART用の便利なAPIを作成しておきましょう。データシートのセクション30.6にUARTレジスタの概要が掲載されています。UARTの構造体は以下の通りです。

struct uart {
  volatile uint32_t SR, DR, BRR, CR1, CR2, CR3, GTPR;
};
#define UART1 ((struct uart *) 0x40011000)
#define UART2 ((struct uart *) 0x40004400)
#define UART3 ((struct uart *) 0x40004800)

UARTの設定を行う目には以下が必要です。

  • RCC->APB2ENR レジスタの適切なビットを設定して、UART クロックを有効にします。
  • RXとTXのピンを「代替機能(alternate function)」ピンモードにします。使用するピンによっては複数の代替機能(AF)が存在することもあります。代替機能(AF)のリストはSTM32F429ZI 表12にあります。
  • BRRレジスタを通じてボーレート(通信速度…送受信クロック周波数)を設定します。
  • ペリフェラルを有効にし、CR1レジスタを通じて送受信を行います。

GPIOピンを特定のモードに変更する方法はすでに知っています。もしピンが代替機能(AF)モードであれば、AF番号、すなわちどのペリフェラルが制御を行うかを示す番号も特定しておく必要があります。これはGPIO ペリフェラルのAFR(alternate function register)を通じて行うことができます。データシートからAFRレジスタの定義を読み解きましょう。AF番号が4ビットを占有していることがわかります。したがって、16 ピンのセットアップ全体で 2 つのレジスタを使用します。

static inline void gpio_set_af(uint16_t pin, uint8_t af_num) {
  struct gpio *gpio = GPIO(PINBANK(pin));  // GPIOバンク
  int n = PINNO(pin);                      // ピン番号
  gpio->AFR[n >> 3] &= ~(15UL << ((n & 7) * 4));
  gpio->AFR[n >> 3] |= ((uint32_t) af_num) << ((n & 7) * 4);
}

レジスタに関するコードを完全に隠すため、GPIOクロックの設定をgpio_set_mode()関数に入れてしまいましょう。

static inline void gpio_set_mode(uint16_t pin, uint8_t mode) {
  struct gpio *gpio = GPIO(PINBANK(pin));  // GPIOバンク
  int n = PINNO(pin);                      // ピン番号
  RCC->AHB1ENR |= BIT(PINBANK(pin));       // GPIOのクロックを有効化
  ...

これでUART初期化API関数を作る準備が出来ました。

#define FREQ 16000000  // CPU周波数 16 MHz
static inline void uart_init(struct uart *uart, unsigned long baud) {
  // https://www.st.com/resource/en/datasheet/stm32f429zi.pdf
  uint8_t af = 0;           // 代替機能(AF)
  uint16_t rx = 0, tx = 0;  // ピン

  if (uart == UART1) RCC->APB2ENR |= BIT(4);
  if (uart == UART2) RCC->APB1ENR |= BIT(17);
  if (uart == UART3) RCC->APB1ENR |= BIT(18);

  if (uart == UART1) af = 4, tx = PIN('A', 9), rx = PIN('A', 10);
  if (uart == UART2) af = 4, tx = PIN('A', 2), rx = PIN('A', 3);
  if (uart == UART3) af = 7, tx = PIN('D', 8), rx = PIN('D', 9);

  gpio_set_mode(tx, GPIO_MODE_AF);
  gpio_set_af(tx, af);
  gpio_set_mode(rx, GPIO_MODE_AF);
  gpio_set_af(rx, af);
  uart->CR1 = 0;                           // UARTを無効化
  uart->BRR = FREQ / baud;                 // FREQ はCPU周波数 
  uart->CR1 |= BIT(13) | BIT(2) | BIT(3);  // UE, RE, TE をセット
}

最後に、UARTの読み込み・書き込み関数です。データシートのセクション30.6.1は、ステータスレジスタSRが、データの準備ができているかどうかを教えてくれることを示しています。

static inline int uart_read_ready(struct uart *uart) {
  return uart->SR & BIT(5);  // RXNEビットがセットされていたらデータの準備完了
}

データバイト自体はデータレジスタDRから取得できます。

static inline uint8_t uart_read_byte(struct uart *uart) {
  return (uint8_t) (uart->DR & 255);
}

1バイトデータの送信は同じくデータレジスタから行えます。データの書き込みを終えた後、送信終了を待つ必要があります。これはステータスレジスタSRのビット7 で示されます。

static inline void uart_write_byte(struct uart *uart, uint8_t byte) {
  uart->DR = byte;
  while ((uart->SR & BIT(7)) == 0) spin(1);
}

バッファを用意します

static inline void uart_write_buf(struct uart *uart, char *buf, size_t len) {
  while (len-- > 0) uart_write_byte(uart, *(uint8_t *) buf++);
}

main()関数の中からUARTの初期化を行いましょう。

  ...
  uart_init(UART3, 115200);              // UARTの初期化

これで、LEDが点滅する度に"hi\r\n"というメッセージを出力することができます!

    if (timer_expired(&timer, period, s_ticks)) {
      ...
      uart_write_buf(UART3, "hi\r\n", 4);  // メッセージ書き込み
    }

リビルド、再書き込みをし、ターミナルプログラムをST-LINKポートに接続します。私のMacではcuを使用できます。Linuxも同様です。Windowsではputtyを使用するのが良いでしょう。ターミナルを実行して、メッセージを読んでください。

$ cu -l /dev/cu.YOUR_SERIAL_PORT -s 115200
hi
hi

完成したプロジェクトソースコードはstep-3-uartフォルダから取得できます。

printf()をUARTに転送する(Redirect printf() to UART)

このセクションでは、uart_write_buf()呼び出しををprintf()呼び出しに書き換えます。これによりフォーマット済み出力に対応することができ、診断情報を出力する能力が向上することで、いわゆる「printfデバッグ」と呼ばれることができるようになります。

私たちが使用しているGNU ARM toolchainにはGCC コンパイラやその他ツールだけでなく、newlib (https://sourceware.org/newlib) というCライブラリも付属しています。 newlib ライブラリは、組み込みシステム用に RedHat によって開発されました。

もし、私たちのファームウェアが標準C関数(例えばstarcmp())を呼ぶならば、GCCリンカによってnewlibのコードがファームウェアに追加されるでしょう。

newlibが実装する標準C関数の一部、具体的には、newlib によって実装されるファイル入出力 (IO) 操作は特殊です。これらの関数は最終的に"sycalls"と呼ばれる一連の低レベルIO関数を呼び出します。

Some of the standard C functions that newlib implements, specifically, file input/output (IO) operations, implemented by the newlib is a special fashion: those functions eventually call a set of low-level IO functions called "sycalls".

例えば

  • fopen() は最終的に _open() を呼ぶ
  • fread() は最終的に低レベルの _read() を呼ぶ
  • fwrite(), fprintf(), printf() は最終的に低レベルの _write() を呼ぶ
  • malloc() eは最終的に _sbrk() を呼ぶ
  • 等々…

従って、_write() syscallを変更すれば、printf()を私たちが求める形(=printfの出力をUARTに送る)に直すことができます。この原理は「IOリターゲティング(IO retargeting)」と呼ばれます。

注: STM32 Cube は newlib で ARM GCC も使用するため、Cubeプロジェクトには通常 syscalls.c ファイルが含まれます。TIのCCS、KeilのCC などの他のツールチェーンは、少し異なるリターゲットメカニズムを持つ別の C ライブラリを使用する場合があります。私たちはnewlib を使用するので、_write() syscallを変更してUART3に出力します。

その前に、ソースコードを次のように整理しましょう。

  • 全てのAPI定義をmcu.hファイルに移す
  • スタートアップコードをstartup.cに移す
  • newlib "syscalls"のための新規ファイルsyscalls.cを作成する
  • ビルド時にsyscalls.c と startup.c を追加するようにMakefileを修正する

全てのAPI定義をmcu.hに移すと、main.cは大変コンパクトになります。低レベルのレジスタについては言及されておらず、理解しやすい高レベルの API 関数のみが記載されていることに注意してください。

#include "mcu.h"

static volatile uint32_t s_ticks;
void SysTick_Handler(void) {
  s_ticks++;
}

int main(void) {
  uint16_t led = PIN('B', 7);            // 青LED
  systick_init(16000000 / 1000);         // 1msごとのTick
  gpio_set_mode(led, GPIO_MODE_OUTPUT);  // 青LED(のピン)を出力モードに
  uart_init(UART3, 115200);              // UARTの初期化
  uint32_t timer = 0, period = 250;      // タイマを定義し間隔を250msecに
  for (;;) {
    if (timer_expired(&timer, period, s_ticks)) {
      static bool on;                      // LEDの状態を管理するstatic(静的)変数onを宣言
      gpio_write(led, on);                 // 毎「タイマ間隔」時間ごとにピンの信号を更新
      on = !on;                            // LEDの状態を反転する
      uart_write_buf(UART3, "hi\r\n", 4);  // メッセージを書き込む
    }
    // Here we could perform other activities!
  }
  return 0;
}

いい感じです。printfをUART3につなげ直しましょう。現在空っぽのsyscalls.cに以下をコピペします。

#include "mcu.h"

int _write(int fd, char *ptr, int len) {
  (void) fd, (void) ptr, (void) len;
  if (fd == 1) uart_write_buf(UART3, ptr, (size_t) len);
  return -1;
}

ここではこう言っています:もし、書き込み先のファイルディスクリプタが1(標準出力)なら、UART3のバッファ領域へ書き込みます。それ以外の場合は無視します。これがリターゲティングの本質です!

ファームウェアをビルドした結果、一連のリンカエラーが発生します。

../../arm-none-eabi/lib/thumb/v7e-m+fp/hard/libc_nano.a(lib_a-sbrkr.o): in function `_sbrk_r':
sbrkr.c:(.text._sbrk_r+0xc): undefined reference to `_sbrk'
closer.c:(.text._close_r+0xc): undefined reference to `_close'
lseekr.c:(.text._lseek_r+0x10): undefined reference to `_lseek'
readr.c:(.text._read_r+0x10): undefined reference to `_read'
fstatr.c:(.text._fstat_r+0xe): undefined reference to `_fstat'
isattyr.c:(.text._isatty_r+0xc): undefined reference to `_isatty'

newlibのstdio関数を使用したので、newlibに残りのシステムコールを提供する必要があります。何もしない単純なスタブ*8を追加しましょう:

int _fstat(int fd, struct stat *st) {
  (void) fd, (void) st;
  return -1;
}

void *_sbrk(int incr) {
  (void) incr;
  return NULL;
}

int _close(int fd) {
  (void) fd;
  return -1;
}

int _isatty(int fd) {
  (void) fd;
  return 1;
}

int _read(int fd, char *ptr, int len) {
  (void) fd, (void) ptr, (void) len;
  return -1;
}

int _lseek(int fd, int ptr, int dir) {
  (void) fd, (void) ptr, (void) dir;
  return 0;
}

再度リビルドすると、エラーは出ません。最後のステップとしてmain()関数で呼び出しているuart_write_buf()を、より便利なprintf()に置き換えましょう。例えばLEDの状態とsytickの現在値を次のように出力します。

printf("LED: %d, tick: %lu\r\n", on, s_ticks);  // メッセージの書き込み

シリアル出力は以下のようになります。

LED: 1, tick: 250
LED: 0, tick: 500
LED: 1, tick: 750
LED: 0, tick: 1000

おめでとうございます。これで私たちはIOリターゲティングがどのように行われるかを理解でき、printfデバッグをファームウェアに実装できました。

完成したプロジェクトソースコードはstep-4-printfフォルダから取得できます。

Segger Ozoneによるデバッグ(Debug with Segger Ozone)

プログラムがどこかで止まってしまい、printfデバッグも機能しない時はどうすればよいでしょうか?スタートアップコードが機能しない時は?デバッガが必要になるでしょう。方法はたくさんありますが、SeggerのOzoneデバッガをお勧めします。なぜならばOzoneデバッガはスタンドアロンで動作するからです。IDEのセットアップを必要としません。Ozoneを使ってfirmware.elfを直接分析でき、ソースファイルを食わせることができます。

では、SeggerのウェブサイトからOzoneをダウンロードしましょう。OzoneをNucleo ボードで使用する前に、オンボードデバッガの ST-LINKファームウェアを、Ozoneが理解できるjlinkファームウェアに変換する必要があります。Seggerサイトの指示に従います。

Ozoneを実行し、ウィザードで使用しているデバイスを選んで下さい。

使用しているデバッガを選びます…ST-LINKを選びます。

firmware.elfファイルを選びます。

デフォルト設定のまま次の画面に進み、Finishをクリックします。デバッガが立ち上がります。mcu.hファイルが選ばれている事に注意して下さい。

ダウンロードのため緑のボタンをクリックします。ファームウェアが実行され、ここでストップしました。

これでシングルステップで実行したりブレークポイントを設定したり、デバッガの各種機能を使用できます。注目すべきは、便利なペリフェラルビューです。

これを使用して、ペリフェラルの状態を直接設定できます。例えばボード内蔵の緑色LED(PB0)をONにしてみましょう。

1. 最初にGPIOBのクロックを通す必要があります。Peripherals -> RCC -> AHB1ENRを探し、GPIOBENビットを有効(すなわち1)にしましょう。

2. Peripherals -> GPIO -> GPIOB -> MODERを探し、MODER0を1(出力)にします。

3. Peripherals -> GPIO -> GPIOB -> ODRを探し、ODR0を1(ON)にします。

これで緑のLEDが点灯しました。快適なデバッグを行ってください。

ベンダCMSISヘッダ(Vendor CMSIS headers)

ここまでのセクションで、私たちはデータシート・エディタ・GCCコンパイラだけを使ってファームウェアを作ってきました。ペリフェラルを示す構造体も、データシートを参照しながら手動で作ってきました。

これらがどのように動いているのかを、私たちはすでに理解しています。今こそ、CMSISヘッダを紹介するタイミングです。CMSISヘッダとは何か?それはMCUのベンダによって作られた、全ての定義を含むヘッダファイルです。CMSISヘッダファイルには、MCUが持つ全ての機能の定義が含まれています。そのため、ファイルは大きいです。

CMSISはCommon Microcontroller Software Interface Standardの略です。従ってCMSISはMCUベンダーがペリフェラルを指定するための共通基盤です。CMSISはARM標準であり、MCUベンダーによって提供されるものですから、信頼性があります。手動で定義ファイルを作るよりも、CMSISを使う方が推奨されます。

このセクションでは、mcu.hに作ったAPI関数をCMSISベンダヘッダに置き換えつつ、残りのファームウェアはそのままにしておきます。 F4ファミリ用のSTM32CMSISヘッダはhttps://github.com/STMicroelectronics/cmsis_device_f4で見つけられます。そこから以下のファイルをファームウェアディレクトリstep-5-cmsisにコピーします。

  • stm32f429xx.h
  • system_stm32f4xx.h

これら2つのファイルは、標準のARM CMSIS インクルードファイルに依存しています。これらもダウンロードしてください。

  • core_cm4.h
  • cmsis_gcc.h
  • cmsis_version.h
  • cmsis_compiler.h
  • mpu_armv7.h

mcu.hから全てのペリフェラルAPIと定義を取り除きます。標準Cインクルードファイル、ベンダーCMSISインクルードファイル、PIN、BIT、FREQ、および timer_expired() ヘルパー関数の定義のみを残します。

#pragma once

#include <inttypes.h>
#include <stdbool.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/stat.h>

#include "stm32f429xx.h"

#define FREQ 16000000  // CPU 周波数16 MHz
#define BIT(x) (1UL << (x))
#define PIN(bank, num) ((((bank) - 'A') << 8) | (num))
#define PINNO(pin) (pin & 255)
#define PINBANK(pin) (pin >> 8)

static inline void spin(volatile uint32_t count) {
  while (count--) asm("nop");
}

static inline bool timer_expired(uint32_t *t, uint32_t prd, uint32_t now) {
  ...
}

ファームウェアのリビルドを試みる時はmake clean buildを使います。するとGCCはsystick_init(), GPIO_MODE_OUTPUT, uart_init(), and UART3がないと言って止まります。これらをSTM32CMSISファイルから追加しましょう。

systick_init()から始めます。core_cm4.h ヘッダーは、構造体sysstickと同じSysTick_Type構造体を定義し、SysTickペリフェラルに適切な #define を持ちます。同様にstm32f429xx.hはRCC_TypeDef 構造体と RCC の適切な #define があります。従って、sysstick_init() 関数はほとんど変更されません。SYSTICK を SysTick に置き換えるだけです。

static inline void systick_init(uint32_t ticks) {
  if ((ticks - 1) > 0xffffff) return;  // Systick timerは24 bit
  SysTick->LOAD = ticks - 1;
  SysTick->VAL = 0;
  SysTick->CTRL = BIT(0) | BIT(1) | BIT(2);  // systickを有効化
  RCC->APB2ENR |= BIT(14);                   // SYSCFGを有効化
}

次はgpio_set_mode()関数です。stm32f429xx.hヘッダにはGPIO_TypeDef構造体があり、gpio構造体と同じです。これを使ってみましょう。

#define GPIO(bank) ((GPIO_TypeDef *) (GPIOA_BASE + 0x400 * (bank)))
enum { GPIO_MODE_INPUT, GPIO_MODE_OUTPUT, GPIO_MODE_AF, GPIO_MODE_ANALOG };

static inline void gpio_set_mode(uint16_t pin, uint8_t mode) {
  GPIO_TypeDef *gpio = GPIO(PINBANK(pin));  // GPIOバンク
  int n = PINNO(pin);                      // ピン番号
  RCC->AHB1ENR |= BIT(PINBANK(pin));       // GPIOクロックを有効化
  gpio->MODER &= ~(3U << (n * 2));         // 現在の設定をクリア
  gpio->MODER |= (mode & 3) << (n * 2);    // 新しいモードをセットする
}

gpio_set_af() と gpio_write()関数の処理も簡単です。gpio構造体をGPIO_TypeDefに置き換えるだけで終わります。

次はUARTです。 USART_TypeDef があり、USART1、USART2、USART3 を定義します。それらを使用しましょう:

#define UART1 USART1
#define UART2 USART2
#define UART3 USART3

uart_init()関数、及び残りのUART関数でuart構造体をUSART_TypeDefに置き換えます。あとはそのままです。

これで完了です。ファームウェアをリビルドして書き込みましょう。LEDが点滅し、UARTは文字列を出力します。おめでとう!これで私たちはファームウェアをベンダCMSISヘッダファイルに対応させることが出来ました。次に、すべての標準ファイルを include ディレクトリに移動し、Makefile を更新して GCC に通知することで、リポジトリを少し再編成しましょう。

...
  -g3 -Os -ffunction-sections -fdata-sections -I. -Iinclude \

将来使用できる、完成版のプロジェクトを用意しました。プロジェクトソースコードはstep-5-cmsisフォルダから取得できます。

クロックの設定(Setting up clocks)

ブートの後、Nucleo-F429ZI CPUは16MHzで動作します。最大周波数は180MHzです。ただし、気を配らねばならないのはシステムクロック周波数だけではない事に注意して下さい。ペリフェラルは、APB1 and APB2というクロックが異なるバスに接続されています。これらバスのクロックスピードは、RCCの中にある周波数プリスケーラ値(frequency prescaler values)で設定されます。メインCPUkウロックソースも異なる場合があります。外部水晶発振器 (HSE) または内部発振器 (HSI) のいずれかを使用できます。我々はHSIを使用します。

CPUがFlashから命令を実行する時、CPUクロックが速くなるとFlashの読み込み速度(およそ25MHz)がボトルネックになります。こんな時に役立つトリックがいくつかあります。そのうちの一つが、命令のプリフェッチ(Instruction prefetch)です。また、Flashコントローラにシステムクロックがどれだけ早いかを示す手がかりを与える事もできます。その値はフラッシュレイテンシ(flash latency)と呼ばれます。180MHzのシステムクロックならFLASH_LATENCYの値は5です。フラッシュコントローラのビット8と9は、命令キャッシュとデータキャッシュを有効にします。

There are several tricks that can help. Instruction prefetch is one. Also, we can give a clue to the flash controller, how faster the system clock is: that value is called flash latency. For 180MHz system clock, the FLASH_LATENCY value is 5. Bits 8 and 9 in the flash controller enable instruction and data caches:

FLASH->ACR |= FLASH_LATENCY | BIT(8) | BIT(9);      // Flashレイテンシとキャッシュ

システムクロックソース(HSIまたはHSE)はソース周波数を特定の値で乗算するPLL分周器回路を通ります。その後、1組の分周器を使用して、システム クロックと APB1、APB2 クロックを設定します。180MHzの最大システムクロックを得るために、PLL分周器とAPBプリスケーラに複数の値を設定可能です。データシートのセクション6.3.3は、最大のAPB1クロックが45MHz以下であること、APB1クロックが90MHz以下である事を示しています。これにより、使用できる値の組み合わせが絞り込まれます。ここでは、手動で値を選択しました。CubeMX などのツールを使用すると、プロセスを自動化し、簡単かつ視覚的にできることに注意してください。

enum { APB1_PRE = 5 /* AHB clock / 4 */, APB2_PRE = 4 /* AHB clock / 2 */ };
enum { PLL_HSI = 16, PLL_M = 8, PLL_N = 180, PLL_P = 2 };  // 180MHzで動作
#define PLL_FREQ (PLL_HSI * PLL_N / PLL_M / PLL_P)
#define FREQ (PLL_FREQ * 1000000)

これで、CPUとペリフェラルバスにクロックを設定する単純なアルゴリズムが用意できました。

  • 必要に応じFPUを有効にする
  • フラッシュレイテンシを設定する
  • クロックソースを決定し、PLL、APB1、APB2プリスケーラの設定を決める
  • それぞれの値を設定してRCCを決める
static inline void clock_init(void) {                 // クロック周波数を決定する
  SCB->CPACR |= ((3UL << 10 * 2) | (3UL << 11 * 2));  // FPUの有効化
  FLASH->ACR |= FLASH_LATENCY | BIT(8) | BIT(9);      // フラッシュレイテンシ、キャッシュ
  RCC->PLLCFGR &= ~((BIT(17) - 1));                   // PLL分周器のクリア
  RCC->PLLCFGR |= (((PLL_P - 2) / 2) & 3) << 16;      // PLL_Pの設定
  RCC->PLLCFGR |= PLL_M | (PLL_N << 6);               // PLL_M と PLL_N の設定
  RCC->CR |= BIT(24);                                 // PLLの有効化
  while ((RCC->CR & BIT(25)) == 0) spin(1);           // 処理が完了するまで待つ
  RCC->CFGR = (APB1_PRE << 10) | (APB2_PRE << 13);    // プリスケーラの設定
  RCC->CFGR |= 2;                                     // PLLのクロックソースを設定
  while ((RCC->CFGR & 12) == 0) spin(1);              // 処理が完了するまで待つ
}

残っているのはmain関数からclock_init()を呼び出すだけです。その後、リビルドと再書き込みを行います。ボードが最高速度180MHzで動き始めます!

完成したプロジェクトソースコードはstep-6-clockフォルダから取得できます。

デバイスダッシュボードを使ったWebサーバ(Web server with device dashboard)

Nucleo-F429ZIにはEthernetが搭載されています。Ethernetのハードウェアには2つの部品が必要です。1つはPHY(銅線、光ケーブルなどのメディアに電気信号を送受信する)とMAC(PHYコントローラを動かす)です。Nucleoでは、MACコントローラーが内蔵されており、PHYは外部にあります(具体的には、Microchip の LAN8720a です)。

MAC と PHY は複数のインターフェイスと通信できます。ここでは RMII を使用します。そのためには、一連のピンを代替機能 (AF) を使用するように構成する必要があります。 Web サーバーを実装するには、3 つのソフトウェア コンポーネントが必要です。

  • ネットワークドライバ(EthernetフレームをMACコントローラに送受信する)
  • ネットワークスタック(TCP/IPを解釈し、フレームを読み取る)
  • HTTPを理解するネットワークライブラリ

我々は、これらすべてを一つのファイルにまとめたMongoose Network Libraryを使用します。これはデュアルライセンス(GPLv2/commercial)のライブラリで、ネットワーク組込機器を素早く・簡単に作るために設計されています。

さて、mongoose.cmongoose.hをプロジェクトにコピーしましょう。これでネットワークドライバとネットワークスタック、ネットワークライブラリが手に入りました。またMongooseはたくさんの実例を提供してくれており、その中の一つがデバイスダッシュボードの例です。これはダッシュボードログイン、WebSocketを通じたリアルタイムデータ交換、組込ファイルシステム、MQTT通信などなど、様々な機能を含みます。このサンプルを使うために、2つのファイルをコピーしましょう。

  • net.c - ダッシュボード機能を含む
  • packed_fs.c - HTML/CSS/JS GUI ファイルを含む

やるべきことは、有効にしたい機能をMongooseに伝える事です。これは、プリプロセッサ定数を設定することにより、コンパイルフラグを介して行うことができます。または、mongoose_custom.h ファイルで同じ定数を設定することもできます。 2番目の方法を使いましょう。次の内容で mongoose_custom.h ファイルを作成します。

#pragma once
#define MG_ARCH MG_ARCH_NEWLIB
#define MG_ENABLE_MIP 1
#define MG_ENABLE_PACKED_FS 1
#define MG_IO_SIZE 512
#define MG_ENABLE_CUSTOM_MILLIS 1

ネットワーク用の指示をmain.cに追加しましょう。#include "mongoose.c"を加えてEthernet RMII ピンを初期化し、RCC で Ethernet を有効にします。

uint16_t pins[] = {PIN('A', 1),  PIN('A', 2),  PIN('A', 7),
                     PIN('B', 13), PIN('C', 1),  PIN('C', 4),
                     PIN('C', 5),  PIN('G', 11), PIN('G', 13)};
  for (size_t i = 0; i < sizeof(pins) / sizeof(pins[0]); i++) {
    gpio_init(pins[i], GPIO_MODE_AF, GPIO_OTYPE_PUSH_PULL, GPIO_SPEED_INSANE,
              GPIO_PULL_NONE, 11);
  }
  nvic_enable_irq(61);                          // Ethernet IRQハンドラをセット
  RCC->APB2ENR |= BIT(14);                      // SYSCFGを有効化
  SYSCFG->PMC |= BIT(23);                       // RMIIを使用。. 最初に行きます。
  RCC->AHB1ENR |= BIT(25) | BIT(26) | BIT(27);  // Ethernetクロックを有効化
  RCC->AHB1RSTR |= BIT(25);                     // ETHMAC強制リセット
  RCC->AHB1RSTR &= ~BIT(25);                    // ETHMACリリースリセット

MongooseドライバはEthernet割込みを使用します。そのため、startup.cを更新してETH_IRQHandlerをベクタテーブルに追加する必要があります。startup.cのベクタテーブル定義を直しましょう。割り込みハンドラ関数を追加するための変更を必要としない方法で、startup.cのベクタテーブル定義を再編成しましょう。アイデアは、「弱いシンボル」の概念を使用することです。

関数は「弱い」とマークすることができ、通常の関数のように機能します。違いが生じるのは、ソースコードが別の場所で同じ名前の関数を定義している場合です。通常、同じ名前の関数が2つあると、ビルドは失敗します。ただし、1つの関数が弱いとマークされている場合、ビルドは成功し、リンカは弱くない方の関数を選択します。これにより、定型文から「デフォルト」関数を提供する機能が提供され、コード内の他の場所で同じ名前の関数を作成するだけでそれをオーバーライドできます。

これが私たちの場合にどのように機能するかを示します。ベクター テーブルをデフォルト ハンドラーで埋めたいのですが、ユーザーが任意のハンドラーをオーバーライドできるようにします。そのために、関数 DefaultIRQHandler() を作成し、弱いマークを付けます。次に、すべてのIRQハンドラに対して、ハンドラ名を宣言し、それを DefaultIRQHandler() のエイリアスにします。

void __attribute__((weak)) DefaultIRQHandler(void) {
  for (;;) (void) 0;
}
#define WEAK_ALIAS __attribute__((weak, alias("DefaultIRQHandler")))

WEAK_ALIAS void NMI_Handler(void);
WEAK_ALIAS void HardFault_Handler(void);
WEAK_ALIAS void MemManage_Handler(void);
...
__attribute__((section(".vectors"))) void (*tab[16 + 91])(void) = {
    0, _reset, NMI_Handler, HardFault_Handler, MemManage_Handler,
    ...

これで、コードで任意の IRQ ハンドラーを定義できるようになり、デフォルトのハンドラーが置き換えられます。私たちの場合、MongooseのSTM32ドライバによって定義された ETH_IRQHandler() があり、これがデフォルトのハンドラーを置き換えます。

次のステップはMongooseライブラリの初期化です。イベントマネジャを作成し、ネットワークドライバを設定し、HTTPコネクションのリスニングを開始します。

  struct mg_mgr mgr;        // Mongoose event managerの初期化
  mg_mgr_init(&mgr);        // MIP interfaceを追加
  mg_log_set(MG_LL_DEBUG);  // ログレベルをセット

  struct mip_driver_stm32 driver_data = {.mdc_cr = 4};  // driver_stm32.h を参照
  struct mip_if mif = {
      .mac = {2, 0, 1, 2, 3, 5},
      .use_dhcp = true,
      .driver = &mip_driver_stm32,
      .driver_data = &driver_data,
  };
  mip_init(&mgr, &mif);
  extern void device_dashboard_fn(struct mg_connection *, int, void *, void *);
  mg_http_listen(&mgr, "http://0.0.0.0", device_dashboard_fn, &mgr);
  MG_INFO(("Init done, starting main loop"));

残っているのは、メインループに mg_mgr_poll() 呼び出しを追加することです。

次に、mongoose.c、net.c、packed_fs.c ファイルを Makefile に追加します。リビルドと再書き込みをします。シリアルコンソールをデバッグ出力に接続し、ボードが DHCP 経由で IP アドレスを取得することを確認します。

847 3 mongoose.c:6784:arp_cache_add     ARP cache: added 0xc0a80001 @ 90:5c:44:55:19:8b
84e 2 mongoose.c:6817:onstatechange     READY, IP: 192.168.0.24
854 2 mongoose.c:6818:onstatechange            GW: 192.168.0.1
859 2 mongoose.c:6819:onstatechange            Lease: 86363 sec
LED: 1, tick: 2262
LED: 0, tick: 2512

そのIP アドレスにブラウザーから接続し、WebSocketを介したリアルタイム グラフ、MQTT、認証などが機能するダッシュボードを取得します!詳細については詳細な説明参照してください。 Fire up a browser at that IP address, and get a working dashboard, with real-time graph over WebSocket, with MQTT, authentication, and other things! See full description for more details.

プロジェクトソースコードはstep-7-webserverフォルダから取得できます。


*1訳注:ベアメタルとはOSを使わず全てをプログラマの管理によってマイコンを動かすこと

*2訳注:インスタンスは翻訳しにくいが、意味が分からない人はとりあえず「複製」と思ってもらえばよい

*3訳注:スタックの末尾(底)を表すシンボル

*4訳注:データセクションの先頭を表すシンボル

*5訳注:データセクションの末尾を表すシンボル

*6訳注:.bssセクションの先頭を表すシンボル_sbssと.bssセクションの末尾を表すシンボル_ebssを作成している

*7訳注:NOP(No OPeration)は何もせずただクロックを空回しするだけのアセンブラ(機械語)命令

*8訳注:呼び出し先に相当するダミー関数(モジュール)


現在ご覧のページの最終更新日時は2022/12/15 11:13:09です。

Copyright (C) N.Y.City ALL Rights Reserved.

Email: info[at]nycity.main.jp