はじめに
D言語を組み込み用途で使うべく、過去にいくつかの挑戦をして幾何の経験を得てきた。
さて、今回臨むのは、組み込み向けのリアルタイムOSであるFreeRTOSを使ったプロジェクトとD言語の連携だ。
具体的には以下を行う。
- STM32CubeMXで初期化コードやFreeRTOSのコードを生成してビルドする。
- 生成した初期化コードやFreeRTOSとD言語を連携する。
- 試しにLチカする。
今回、FreeRTOSを利用するにあたり、マシンとして選んだのは「特集 D言語組み込みプログラミング入門」でも使用した、Nucleo-F403REのボードである。このボードを用いて、OLED(有機ELディスプレイ)のSSD1306ドライバ制御を行うことを目標にした。(ドライバの制御は別記事で書こうと思う)
「特集 D言語組み込みプログラミング入門」ではD言語以外の言語を一切使わないベアメタルプログラミングを行ったが、今回はSTM32CubeMXというCASEツールを利用することで、ハードウェアの初期化などの面倒な部分を自動化し、ある程度気の利いたAPIを提供するC言語ソースコードの生成を行った。また、これによりFreeRTOSも利用可能となる。
以下の記事は、開発環境としてWindowsの使用を前提としているが、ほかのOSを使っている人は都度読み替えていただきたい。
概要
- 必要なものを揃える。ダウンロード先、購入方法などを紹介する。各種プログラムのインストールについては記事にしない。
- STM32CubeMXによるプロジェクト生成を行う。OLED制御に必要なペリフェラルの設定もここで紹介する。
- D言語との連携の仕方について解説する。
- D言語プロジェクトの作り方(主にdubの設定)を紹介する。
- 最後にFreeRTOSの機能(xTimer)を使用したLチカを行うプログラムを紹介する。
今回のレシピを図にすると、以下のようになる。
必要なもの
-
Nucleo-F403RE : Aamzon, 秋月, スイッチサイエンス, マルツパーツなどで購入できる。
秋月やマルツなどは秋葉原の直売店に行けば店員が使い方などいろいろ教えてくれるだろう。
なお、Amazonは頻繁に品切れしていて、輸入販売などで万単位の値段がついていることがあるが、本来1500~2500円程度で購入可能なはず。
Raspberry Pi picoやZeroなども検討したが、最初からLEDやタクトスイッチが実装されていて、その他I/Oポートにもピンヘッダなど実装されており、今回のようなデモレベルのことをするにはとても便利なので、Nucleoを選んだ。
でも入手性的にはRaspberry Pi系が良さそうだし、記事映えもしそうなので最近悩んでいる。- Nucleo-F401のボードのマニュアル : 基板の回路構成やピン配置などが記載されている。重要。
- Nucleoのスタートガイド的なマニュアル
- STM32F401REマイコンのプログラミングマニュアル : CPU付近のハードウェア構成やレジスタの説明など。
- STM32F401REマイコンのリファレンスマニュアル : ペリフェラルのレジスタの説明など。聖書のごとく重要。
- LDC : D言語コンパイラのひとつLLVM系。
- GNU Arm Embedded Toolchain : ARM向けのGCCクロスコンパイラ
- STM32CubeMX : STM32マイコン向け初期コードジェネレータ
- LLVM : clang等を含むLLVMコンパイラツールセット (Optional)
- msys2 : WindowsだとGNU makeやmingw32-makeを使うのに必要。mingw32-makeが使えるならmingwでもいいし、cygwinでもいい。
※OLEDは今回の記事ではほぼ関係ないので省略する。
※インストール作業は省略する。
STM32CubeMXによる初期コード生成
STM32CubeMXはSTマイクロ社のマイコンやボードに関して、初期化プログラムを自動生成するためのCASEツールである。
GUIツールによりピン割付やタイマー割り付け、割り込み優先度などを設定することができる。
GUIで決めた設定に従って、FreeRTOSの初期化やスタック割り付け、スタートアッププログラム、ペリフェラルの初期化を行うコードを含むメイン関数のひな型、各種ハードウェア操作用のAPIなどを自動的に生成することができる。
今回の設定のポイントをかいつまんで説明する。スクリーンショットはたくさん撮影したので、末尾に折りたたんだ状態にしておいた。
- 起動とプロジェクト新規作成
- (私の場合、インストール~コード生成はWindows Sandbox上で行った)
- STM32CubeMXを開いたら、FileからNew Projectを選択
- 品番検索で使用するボードを選択
- Start Projectでプロジェクト作成
- ピン構成・ペリフェラルの設定
-
I2C設定: ディスプレイドライバのSSD1306との通信用。
- SDA, SDL, VSS(GND), AVDD(3.3V)が都合よく並んでるI2C1を使う
(電源のAVDDはアナログ回路用だけど、今回はOLED以外使用予定がないし、とりあえず3.3V取れるのでヨシ!) - I2C1のピン割付のため、PB9からI2C1_SDA、PB8からI2C1_SCLに変更
- I2C Speed ModeをFast Modeに、I2C Clock Speed(Hz)を400kHzに変更。ハードやノイズ、信号の鈍りが許せばここをあえて遅くする必要はなさそう。
- SDA, SDL, VSS(GND), AVDD(3.3V)が都合よく並んでるI2C1を使う
- IWDG(独立ウォッチドッグタイマー): 死活監視の設定。
- ウォッチドッグ(番犬)の名の通り、メインプログラムとはクロックを別にした専用のタイマーを用意し、定期的に「プログラムが正しく動いていますよ」ということを伝えることで暴走を検知する仕組み。一定時間内に通知しないとプログラムが暴走していると判断し、強制リセットを行ってくれる。
- とりあえずプリスケーラを変更し、25秒くらいの遅い設定にした。真面目にやる場合、プログラムが死んだときのハードウェアの状態やUXを想定して適切な時間を設定する。
- SYS: システムタイマーのソース選択
- FreeRTOSを使用する場合、SysTickをFreeRTOSが使用するため、干渉を避けるべく別のタイマーが推奨される。
- 今回タイマーはFreeRTOSのソフトウェアタイマーを使用し、ハードウェアのタイマーを使用する予定はないため、適当にTIM1を選択した。タイマーごとに特徴があり、使える機能が少しずつ違うので、不要なタイマーを選択する。
- USART2: シリアル通信設定
- 今回標準出力の出力にはSemihostingを使用することにしたので、ここは設定変更しなかった。
- 必要な場合は設定を行う。
-
I2C設定: ディスプレイドライバのSSD1306との通信用。
- FreeRTOSの設定
- InterfaceはCMSIS_V2を選択しました。
- 使う予定はないものの、FPUを有効にしたりしました。
- Config Parametersにてヒープサイズを指定できる。ここで指定したサイズはFreeRTOSのカーネルが管理するヒープの容量として使用される。また、このヒープの中から各タスクのスタックが割り当てられる。FreeRTOSをガッツリ使うのであれば、この値をできるだけ大きい値にするとよい。
- Task And Queuesにて、タスクの設定ができる。タスクは必要な分だけこの設定ダイアログで設定するのがよい。今回はスタックサイズを256(1024byte)に設定した。ひとまず大きめに設定しておいて、後で uxTaskGetStackHighWaterMark() とかで使用量を確認して後で減らして最適化すると良い。
- クロック設定
- とりあえず諸々最速(デフォルト)に設定。
- プロジェクト設定
- Project Nameにプロジェクト名を(ターゲット名=バイナリ名=
*.bin
や*.elf
のファイル名になる) - Project Locationにプロジェクトの絶対パスを設定。(ここに生成物が出力される)
- Toolchain / IDE は Makefile を選択する。
- Linker Settingsでスタックとヒープの設定をする。ここで設定するスタックは、FreeRTOSによって管理される各タスクのスタックとは別で、スタートアッププログラムで初期化され、(FreeRTOSのタスクを設定したりする)main関数で使用されるスタックである。また、ヒープについてもFreeRTOSの管理外で使用されるヒープのサイズである。
- Project Nameにプロジェクト名を(ターゲット名=バイナリ名=
- 追加設定
- 割り込み処理の使用有無などを設定。使う機能はひとまず割り込み有効で良いと思う。(有効にしても使おうと思わなければ使わないで済む)
- コード生成
- GENERATE CODEボタンを押す。
- ファームウェア(のひな型)のダウンロード・解凍を行い、出力される。
- 出来上がり
- 最後に出力先のフォルダの中身を確認。
プロジェクトのMake
STM32CubeMXによりプロジェクトのひな型が作成出来たら、ひとまずMakeできることを確認する。
以下の部分がポイント。GNU Arm Embedded Toolchain の場所にパスを通すか、GCC_PATHで教えることができるようになっている。
ここが正しく設定されていれば、大体うまくいくはず。
#######################################
# binaries
#######################################
PREFIX = arm-none-eabi-
# The gcc compiler bin path can be either defined in make command via GCC_PATH variable (> make GCC_PATH=xxx)
# either it can be added to the PATH environment variable.
ifdef GCC_PATH
CC = $(GCC_PATH)/$(PREFIX)gcc
AS = $(GCC_PATH)/$(PREFIX)gcc -x assembler-with-cpp
CP = $(GCC_PATH)/$(PREFIX)objcopy
SZ = $(GCC_PATH)/$(PREFIX)size
else
CC = $(PREFIX)gcc
AS = $(PREFIX)gcc -x assembler-with-cpp
CP = $(PREFIX)objcopy
SZ = $(PREFIX)size
endif
HEX = $(CP) -O ihex
BIN = $(CP) -O binary -S
> make
Windowsだとそのままmakeするとmkdirのexeが無いと怒られてしまうので、msys2環境で実施するのがよいかもしれない。
私はmkdirを指定できるようにした。
$(BUILD_DIR):
$(MKDIR) $@
うまくいけば、*.elf
ファイルや*.bin
ファイルが生成される。
どちらも実行ファイルだが、Nucleo-F401REをUSB接続したときに認識されるUSBストレージへの書き込みでは、*.bin
を指定する。
ドライブに放り込むだけで書き込みが完了するが、このままだと全く動きが無くてうまくいっているのかわかりにくいため、Lチカするといいだろう。
/* USER CODE BEGIN Header_StartDefaultTask */
/**
* @brief Function implementing the defaultTask thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_StartDefaultTask */
void StartDefaultTask(void *argument)
{
/* USER CODE BEGIN 5 */
/* Infinite loop */
for(;;)
{
HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
HAL_IWDG_Refresh(&hiwdg);
osDelay(500);
}
/* USER CODE END 5 */
}
LLVMでmake (Optional)
GNU Arm Embedded ToolchainのGCCによるビルドを紹介したが、clangでもビルドできる。どちらでもよい。今回はldc(LLVM)を使うんだし試しにと思ってclangを使った。
clangでビルドするには、Makefileの多少の書き換えが必要である。
LDの定義
まず、clang(lld)を使ったリンクは難しい。素直に GNU Arm Embedded Toolchain を使ったリンクを行うが吉。このため、リンク時のコマンドをコンパイラと分けるため、LDを定義する。
#######################################
# binaries
#######################################
PREFIX = arm-none-eabi-
# The gcc compiler bin path can be either defined in make command via GCC_PATH variable (> make GCC_PATH=xxx)
# either it can be added to the PATH environment variable.
ifdef GCC_PATH
CC = $(GCC_PATH)/$(PREFIX)gcc
AS = $(GCC_PATH)/$(PREFIX)gcc -x assembler-with-cpp
CP = $(GCC_PATH)/$(PREFIX)objcopy
SZ = $(GCC_PATH)/$(PREFIX)size
else
CC = $(PREFIX)gcc
AS = $(PREFIX)gcc -x assembler-with-cpp
CP = $(PREFIX)objcopy
SZ = $(PREFIX)size
endif
HEX = $(CP) -O ihex
BIN = $(CP) -O binary -S
LD = $(CC)
MKDIR = mkdir
ASMOPTSの定義
つぎに、clangでは-Wa,-a,-ad,-alms=...
などのアセンブラ関連のフラグが使えない。これをオプトアウトできるようにする。
ASMOPTSを作ってコンパイラフラグを消せるようにする。
# assembler flags for gcc
ASMOPTS = -Wa,-a,-ad,-alms=$(BUILD_DIR)/$(notdir $(<:.c=.lst))
コンパイル時のオプション変更
そしてコンパイル時のオプションをこんな感じにする。
$(BUILD_DIR)/%.o: %.c Makefile | $(BUILD_DIR)
$(CC) -c $(CFLAGS) $(ASMOPTS) $< -o $@
$(BUILD_DIR)/%.o: %.s Makefile | $(BUILD_DIR)
$(AS) -c $(CFLAGS) $< -o $@
$(BUILD_DIR)/$(TARGET).elf: $(OBJECTS) Makefile
$(LD) $(OBJECTS) $(LDFLAGS) -o $@
$(SZ) $@
ビルド
そして、呼び出し方を以下のような感じに。
mingw32-make.exe all -j 8 "CC=C:/llvm/bin/clang.exe --sysroot=C:/gcc-arm-none-eabi/arm-none-eabi --target=arm-none-eabi -fshort-enums" "AS=C:/llvm/bin/clang.exe --sysroot=C:/gcc-arm-none-eabi/arm-none-eabi --target=arm-none-eabi -fshort-enums -x assembler-with-cpp" "LD=C:/gcc-arm-none-eabi/bin/arm-none-eabi-gcc.exe" "CP=C:/llvm/bin/llvm-objcopy.exe" "SZ=C:/llvm/bin/llvm-size.exe" "ASMOPTS=" "MKDIR=cmd /C mkdir"
clang用の各変数は以下
(clangはC:\llvm
に、GNU Arm Embedded ToolchainはC:\gcc-arm-none-eabi
に配置されている想定。)
CC=C:/llvm/bin/clang.exe --sysroot=C:/gcc-arm-none-eabi/arm-none-eabi --target=arm-none-eabi -fshort-enums
AS=C:/llvm/bin/clang.exe --sysroot=C:/gcc-arm-none-eabi/arm-none-eabi --target=arm-none-eabi -fshort-enums -x assembler-with-cpp
LD=C:/gcc-arm-none-eabi/bin/arm-none-eabi-gcc.exe
CP=C:/llvm/bin/llvm-objcopy.exe SZ=C:/llvm/bin/llvm-size.exe
SZ=C:/llvm/bin/llvm-size.exe
ASMOPTS=
MKDIR=cmd /C mkdir
本題:D言語のプログラムをリンクする
考え方
概要に示した図を再掲するが、以下のようなつくりでアプリケーションを構成する。
C言語側、D言語側それぞれで静的リンクライブラリ*.a
ファイルを作って、それらをリンクしてファームウェア(application.elf
)を作る作戦である。
D言語のプログラムは、デフォルトタスクから呼ばれる関数をD_mainとして、この中身をライブラリとして作成することにする。※図では便宜上C言語のmain関数からD_main関数を呼び出しているように書いたが、実際にはFreeRTOSのデフォルトタスクから呼び出されるようにした。
/* USER CODE BEGIN Header_StartDefaultTask */
extern void D_main(void);
/**
* @brief Function implementing the defaultTask thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_StartDefaultTask */
void StartDefaultTask(void *argument)
{
/* USER CODE BEGIN 5 */
/* Infinite loop */
for(;;)
{
D_main();
osDelay(1);
}
/* USER CODE END 5 */
}
D言語のメインは以下のようになる。これを、公開APIがD_mainのみのライブラリ、という感じでビルドする。
extern (C) void D_main() @nogc nothrow @safe
{
/* ここにユーザーコードを書いていく */
}
最終的には、 (C言語側).a + (D言語側).a → application.elf → application.bin という感じにリンク・変換していくようにする。
C言語側コードをライブラリ化する
ここで問題になるのは、C言語側(STM32CubeMXで生成したやつ)を静的リンクライブラリ*.a
にする必要がある点だ。
やはりこのためにもまた、Makefileを書き換えて、以下のようなターゲットを作成する必要がある。
$(BUILD_DIR)/$(TARGET).a: $(OBJECTS) Makefile
$(CC) -r -nodefaultlibs -o $(BUILD_DIR)/$(TARGET).o $(OBJECTS)
$(AR) crs $(BUILD_DIR)/$(TARGET).a $(BUILD_DIR)/$(TARGET).o
ポイントは、$(CC) -r
を使って、パーシャルリンクを行い、一度すべての*.o
ファイルをまとめて1つの*.o
ファイルにリンクしてやることである。こうすることで、ウィークシンボルについて頭を悩ませる必要がなくなる。(STM32CubeMXで生成されるコードは、ウィークシンボルでスタブの仮実装を提供し、通常シンボルが定義されたらそれがオーバーライドされるような仕組みで作られている)
D言語側のプロジェクト設定
問題はこれをビルドするためのプロジェクトの設定だが、最近のdubはだいぶ賢くなっているので以下のような感じにできる。
{
"name": "nucleo-f401re-app",
"importPaths": ["."],
"stringImportPaths": ["res"],
"targetPath": "build",
"postGenerateCommands-arm": [
"${LD} -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 build/Nucleo-F401RE-FreeRTOS.a ${BUILD_DIR}/lib${TARGET}.a -specs=nano.specs -T${LDSCRIPT} -Wl,--start-group -lc -lm -lnosys -Wl,--end-group -Wl,-Map=build/${TARGET}.map,--cref -Wl,--gc-sections -o ${TARGET}.elf",
"${SZ} ${TARGET}.elf",
"${HEX} -O ihex ${TARGET}.elf ${TARGET}.hex",
"${BIN} -O binary -S ${TARGET}.elf ${TARGET}.bin"
],
"buildOptions-arm": ["betterC"],
"dflags-arm-ldc": [
"--float-abi=hard",
"-mcpu=cortex-m4",
"-relocation-model=static",
"-disable-linker-strip-dead",
"-output-s", "-output-o", "--static"
],
"buildTypes": {
"debug": {
"buildOptions-ldc": ["debugMode", "debugInfoC"],
"dflags-ldc": ["--gdwarf", "--dwarf-version=2"]
}
},
"targetName": "application",
"targetType": "library"
}
postGenerateCommandsには環境のパスなどを含めたくないので、環境変数を使い、dub.settings.jsonとかにパスの情報を記載する。
{
"defaultPostGenerateEnvironments": {
"LDSCRIPT": "STM32F401RETx_FLASH.ld",
"LIBDIR": "",
"LIBS": "-lc -lm -lnosys",
"BUILD_DIR": "build",
"TARGET": "application",
"LD": "C:/gcc-arm-none-eabi/bin/arm-none-eabi-gcc.exe",
"SZ": "C:/llvm/bin/llvm-size.exe",
"CP": "C:/llvm/bin/llvm-objcopy.exe",
"HEX": "C:/llvm/bin/llvm-objcopy.exe -O ihex",
"BIN": "C:/llvm/bin/llvm-objcopy.exe -O binary -S"
}
}
ビルド
以下のコマンドでビルドすることができる。
dub build -a=arm-none-eabi
しかし、デバッグビルドだと、 _getpid
など複数のシンボルが見つからないというエラーが生じるので、C言語側で定義してやると良い。
extern void D_main(void);
__weak int _getpid(){ return -1;}
__weak int _getpid_r(){ return -1;}
__weak int _kill(int pid, int sig){ return -1; }
__weak int _kill_r(int pid, int sig){ return -1; }
Lチカ on FreeRTOS + D言語
ちなみに、せっかくなのでFreeRTOSのxTimerCreateで作ったタイマーを使ってLチカをしてみると、以下のようになる。
なお、メインとコールバック程度しか関数を作っていないのでextern(C)
がついているが、普通にD言語の関数も定義・使用できる。
module src.main;
nothrow @nogc:
// Types
alias TickType_t = uint;
alias BaseType_t = int;
alias UBaseType_t = uint;
alias TimerHandle_t = void*;
alias IWDG_HandleTypeDef = uint;
alias HAL_StatusTypeDef = int;
alias GPIO_TypeDef = uint;
// xTimer
enum tmrCOMMAND_START = cast(BaseType_t)1;
enum tmrCOMMAND_DELETE = cast(BaseType_t)5;
extern (C) TickType_t xTaskGetTickCount() @system;
extern (C) alias TimerCallbackFunction_t = void function(TimerHandle_t xTimer);
extern (C) TimerHandle_t xTimerCreate(const(char)* pcTimerName, TickType_t xTimerPeriodInTicks, UBaseType_t uxAutoReload, void* pvTimerID, TimerCallbackFunction_t pxCallbackFunction ) @system;
extern (C) BaseType_t xTimerGenericCommand( TimerHandle_t xTimer, BaseType_t xCommandID, TickType_t xOptionalValue, BaseType_t * pxHigherPriorityTaskWoken, TickType_t xTicksToWait ) @system;
pragma(inline, true) extern (D) alias xTimerStart = function(xTimer, xTicksToWait) => xTimerGenericCommand(xTimer, tmrCOMMAND_START, xTaskGetTickCount(), null, xTicksToWait);
pragma(inline, true) extern (D) alias xTimerDelete = function(xTimer, xTicksToWait) => xTimerGenericCommand(xTimer, tmrCOMMAND_DELETE, 0U, null, xTicksToWait);
// IWDG
extern (C) HAL_StatusTypeDef HAL_IWDG_Refresh(IWDG_HandleTypeDef* hiwdg) @system;
extern (C) extern __gshared IWDG_HandleTypeDef hiwdg;
// GPIO
extern (C) void HAL_GPIO_TogglePin(GPIO_TypeDef* GPIOx, ushort GPIO_Pin) @system;
enum PERIPH_BASE = 0x40000000UL;
enum AHB1PERIPH_BASE = PERIPH_BASE + 0x00020000UL;
enum GPIOA_BASE = AHB1PERIPH_BASE + 0x0000UL;
enum ushort GPIO_PIN_5 = cast(ushort)0x0020;
enum GPIOA = cast(GPIO_TypeDef*)GPIOA_BASE;
enum LD2_GPIO_Port = GPIOA;
enum LD2_Pin = GPIO_PIN_5;
// CMSIS
extern (C) uint osDelay(uint) @system;
// 500msごとにコールバックされる
extern (C) void onLEDTim500ms(TimerHandle_t xTimer) @trusted
{
// Lチカ
HAL_GPIO_TogglePin(LD2_GPIO_Port, LD2_Pin);
}
extern (C) void D_main() @trusted
{
// タイマーの作成・破棄
auto tim500ms = xTimerCreate("LED500MS", 500, 1, null, &onLEDTim500ms);
scope (exit)
xTimerDelete(tim500ms, 0);
// タイマーの開始
xTimerStart(tim500ms, 0);
while (1)
{
// ウォッチドッグを蹴る
HAL_IWDG_Refresh(&hiwdg);
// タスクスイッチする
osDelay(1);
}
}
ビルド
dub build -a=arm-none-eabi
結果
とりあえずD言語+FreeRTOSでLチカはできた pic.twitter.com/tlqfqJqzkD
— SHOO (@mono_shoo) December 1, 2021
成果物
おわりに
本記事では以下のことについて解説した。
- STM32CubeMXを使って初期化コードやハードウェアのAPI、FreeRTOSのコードを生成し、gccやclangによってビルドした。
- STM32CubeMXの生成した初期コードをライブラリ化することで、D言語で書いたプログラムを連携することができた。
- LEDをチカチカさせた。
今回のSTM32CubeMXのように、今どきハードウェアベンダーから提供されるGUIによる初期化コード生成はよくあるものであるが、生成されるコードはC言語が一般的だ。
また、FreeRTOSのようにC言語で開発されているものをいちいちほかの言語にポーティングするのもあまり現実的ではない。(特に開発者の少ないD言語では)
しかし、D言語ではC言語の資産を活用することに大きな比重を置いて開発が行われており、実際C言語と連携するのは極めて容易で、シームレスに行うことができる。本記事のように、D言語とC言語をシームレスにつなぐやり方が実際のところ現実的ではないだろうか。
さらには、D言語の最近の新しい機能でImportCという機能があり、C言語のソースコードをD言語コンパイラが解釈することが可能になりつつある。今回の記事で紹介したLチカコードでは30行くらいの「つなぎ目」がみられるが、このつなぎ目もゆくゆくはImportCにより解決できる未来が来るかもしれない。でもプリプロセッサのdefineとかで定義されたマクロ関数や定数のやつが軒並み消えちゃうのでdstepsとかに期待したほうがいいかもしれない。