Potewoのブログ

電子工作が好きな学生の書く技術系のブログです。

STM32用のオリジナル書き込みアプリを作る

この記事はWMMC Advent Calendar 2024の9日目の記事です。昨日の記事ははXFA-27先輩の「なんか」でした。

マイクロマウスを作っている方々はSTM32マイコンを使用している方が多く、書き込みにSTM32CubeProgrammerを使っている方も多いと思います。GUIでポチポチするだけで書き込みができる便利なCubeProgrammerですがしょっちゅうクラッシュしたり、ファイル選択が面倒だと思ったことはありませんか?CubeProgrammerにはAPIがあり、APIを通じてCubeProgrammerを操作するアプリを自分で作ることができることができます。この記事では実際に作ったものと作り方を紹介します。

作ったもの

作ったのはMbed Studioという開発環境でビルドしたバイナリファイルを書き込むためのアプリです。
Nucleoを使用している場合はMbed Studio上の書き込みボタンで簡単にプログラムを書き込むことができますが、石単体を載せた基板に別でSTLinkV3MINIEのようなSTLinkを接続するとMbed Studio上で書き込むことができません。(多分) また、Mbed Studioはバイナリファイルのパスがプロジェクトまでのパス/BUILD/ターゲット名/ARMC6/プロジェクト名.binのようになっており、あるプロジェクトのバイナリファイルを書き込んだあとに別のプロジェクトのバイナリファイルを書き込もうとすると階層を上ったり下りたりする必要があり、非常に面倒です。そこでプロジェクト名とターゲット名を選ぶだけで書き込めるアプリを作ることにしました。

作り方

使用した言語・ライブラリ

作ったものを日常的に使うならばTUIなりCUIなりGUIなりで使いやすいガワが必要になるかと思います。自分の場合は使うときの手軽さやわかりやすさ、他人への配布のしやすさを重視してGUIで作ることにしました。 使用した言語はRust、GUI部分には流行りのTauriとReactを使用しました。TauriはUI部分をブラウザの技術を使用し、ロジック部分をRustで書いてGUIアプリを作ることができるライブラリです。通常ブラウザ側からはPCのファイルや他のプログラムとの通信への自由なアクセスはありませんが、その間をRustがとりもつことでなんでもできるようになります。今回RustとTauriを採用した理由はそこそこ簡単にきれいな見た目でクロスプラットフォーム・シングルバイナリなGUIアプリを作ることができるからです。各言語にはGUI用のライブラリがあるかと思いますが、OSのネイティブなUIを使うようなライブラリだとどうしても古臭い見た目になったり思ったような配置にするのが難しかったりすると思います。しかし、ウェブ系の言語でUIを作ると簡単に思ったような見た目にすることができます。見た目をいじるのに使用するCSSはライブラリも豊富で、ライブラリをインポートするだけで見た目をいい感じにしてくれるものがたくさんあります。ChatGPTやGitHub Copilotに書かせる時も元々ネット上に情報が多いのでうまく書かせやすいでしょう(多分)。

また、最後に一番重要なCubeProgrammer APIにアクセスするためのライブラリです。 wervin/stm32cubeprog-rs: Rust API for STM32CubeProgrammer そのままだとSTLinkが見つからなかったときにpanicを起こしてアプリが終了してしまうのでforkして少し変更を加えて使用しました。 Add error handling for no device found in STLink debug connection · Potewo/stm32cubeprog-rs@2dbec0e

コードを書く

ライブラリがそろったら後はライブラリのお作法に沿ってコードを書くのみです。 STM32 CubeProgrammer APIを使用してSTLinkデバイスをリストアップしたりバイナリファイルを書き込むための関数を作り、ファイルを選択するための関数を作り、それらを使ったUI側のコードを書けば完成です。
開発環境や具体的なコマンドはこの記事では省略します。TauriのGetting Startedのページの案内に従ってコマンドを実行していきます。途中で使用する言語やUIフレームワークを聞かれるのでお好みで答えれば大丈夫です。

プロジェクトを作ったらlib.rs、App.tsxを以下のように編集します。

lib.rs

use tauri_plugin_log::{Target, TargetKind};

// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

#[cfg(unix)]
fn get_cube_programmer_path() -> String {
    let home_dir = dirs::home_dir();
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            log::error!("Failed to get home directory");
            return String::new();
        }
    };
    let binding = home_dir.join("STMicroelectronics/STM32Cube/STM32CubeProgrammer");
    let stm32prog_path = binding
        .to_str();
    let stm32prog_path = match stm32prog_path {
        Some(stm32prog_path) => stm32prog_path,
        None => {
            log::error!("Failed to convert STM32CubeProgrammer path to string");
            return String::new();
        }
    };
    stm32prog_path
}

#[cfg(windows)]
fn get_cube_programmer_path() -> String {
    return "C:\\Program Files\\STMicroelectronics\\STM32Cube\\STM32CubeProgrammer".to_string();
}

#[tauri::command]
fn list_stlink_devices() -> Vec<String> {
    let stm32prog_path = get_cube_programmer_path();

    // Load STM32CubeProgmmer API library
    let stm32prog = match stm32cubeprog_rs::STM32CubeProg::new(stm32prog_path) {
        Ok(stm32prog) => stm32prog,
        Err(e) => {
            log::error!("Failed to load STM32CubeProgrammer API library: {e}");
            return Vec::new();
        }
    };
    log::info!("STM32CubeProgrammer API library loaded");

    // Find connected STLinks
    let mut stlinks = match stm32prog.discover() {
        Ok(stlinks) => stlinks,
        Err(e) => {
            log::error!("Failed to discover STLinks: {e}");
            return Vec::new();
        }
    };
    log::info!("STLinks discovered: {}", stlinks.len());
    let mut devices = Vec::new();
    for stlink in stlinks.iter_mut() {
        let serial_number = match stlink.serial_number() {
            Ok(serial_number) => serial_number,
            Err(e) => {
                log::error!("Failed to get STLink serial number: {e}");
                continue;
            }
        };
        log::info!("STLink serial number: {}", serial_number);
        devices.push(serial_number);
    }
    log::info!("STLinks found: {:?}", devices);
    return devices;
    // let devices = list_stlink_devices();
    // devices.iter().map(|d| d.to_string()).collect()
}

#[tauri::command]
fn list_projects() -> Vec<String> {
    log::info!("Listing projects");
    let home_dir = dirs::home_dir();
    log::info!("Home directory: {:#?}", home_dir);
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            log::error!("Failed to get home directory");
            return Vec::new();
        }
    };
    log::info!("Home directory: {:#?}", home_dir);
    let projects_dir = home_dir.join("Mbed Programs");
    log::info!("Projects directory: {:#?}", projects_dir);
    let projects = match std::fs::read_dir(projects_dir) {
        Ok(projects) => projects,
        Err(e) => {
            log::error!("Failed to read projects directory: {}", e);
            return Vec::new();
        }
    };
    log::info!("Projects read");
    let mut project_names = Vec::new();
    for project in projects {
        let project = match project {
            Ok(project) => project,
            Err(e) => {
                log::error!("Failed to read project: {}", e);
                continue;
            }
        };
        log::info!("Project: {:#?}", project);
        let project_name = match project.file_name().into_string() {
            Ok(project_name) => project_name,
            Err(e) => {
                log::error!("Failed to convert project name: {:#?}", e);
                continue;
            }
        };
        log::info!("Project name: {:#?}", project_name);
        project_names.push(project_name);
    }
    log::info!("Projects found: {:#?}", project_names);
    project_names
}

#[tauri::command]
fn list_targets(project: &str) -> Vec<String> {
    let home_dir = dirs::home_dir();
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            log::error!("Failed to get home directory");
            return Vec::new();
        }
    };
    let targets_dir = home_dir.join("Mbed Programs");
    let targets_dir = targets_dir.join(project).join("BUILD");
    log::info!("Targets directory: {:#?}", targets_dir);
    let targets = match std::fs::read_dir(targets_dir) {
        Ok(targets) => targets,
        Err(e) => {
            log::error!("Failed to read targets directory: {}", e);
            return Vec::new();
        }
    };
    let mut target_names = Vec::new();
    for target in targets {
        let target = match target {
            Ok(target) => target,
            Err(e) => {
                log::error!("Failed to read target: {}", e);
                continue;
            }
        };
        let target_name = match target.file_name().into_string() {
            Ok(target_name) => target_name,
            Err(e) => {
                log::error!("Failed to convert target name: {:#?}", e);
                continue;
            }
        };
        target_names.push(target_name);
    }
    target_names
}

use tauri::ipc::InvokeError;

#[derive(Debug, Clone, PartialEq)]
enum FlashError {
    LoadLibraryError(String),
    ConnectionError(String),
    DeviceInfoError(String),
    DownloadError(String),
}

#[tauri::command]
fn write_program(stlink_device: &str, project: &str, target: &str) -> Result<(), FlashError> {
    let stm32prog_path = get_cube_programmer_path();

    // Load STM32CubeProgmmer API library
    let stm32prog = match stm32cubeprog_rs::STM32CubeProg::new(stm32prog_path) {
        Ok(stm32prog) => stm32prog,
        Err(e) => {
            log::info!("Failed to load STM32CubeProgrammer API library: {}", e);
            return Err(FlashError::LoadLibraryError(e.to_string()));
        }
    };
    log::info!("STM32CubeProgrammer API library loaded");

    // Find connected STLinks
    let mut stlinks = match stm32prog.discover() {
        Ok(stlinks) => stlinks,
        Err(e) => {
            let err_msg = format!("Failed to discover STLinks: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        }
    };
    log::info!("STLinks discovered: {}", stlinks.len());
    let stlink = match stlinks
        .iter_mut()
        .find(|stlink| match stlink.serial_number() {
            Ok(serial) => serial == stlink_device,
            Err(_) => false,
        }) {
        Some(stlink) => stlink,
        None => {
            let err_msg = format!("Failed to find STLink with serial number: {}", stlink_device);
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        }
    };
    log::info!("STLink found: {}", stlink_device);

    stlink.set_connection_mode(stm32cubeprog_rs::DebugConnectMode::UnderReset);

    // Flash target
    let home_dir = dirs::home_dir();
    let home_dir = match home_dir {
        Some(home_dir) => home_dir,
        None => {
            let err_msg = "Failed to get home directory".to_string();
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        }
    };
    let target_dir = home_dir.join("Mbed Programs");
    let target_dir = target_dir
        .join(project)
        .join("BUILD")
        .join(target)
        .join("ARMC6");
    let target_bin = target_dir.join(format!("{}.bin", project));
    log::info!("Target binary: {:#?}", target_bin);
    let connection_result = stm32prog.connect(stlink);
    if connection_result.is_err() {
        if let Err(e) = connection_result {
            let err_msg = format!("Failed to connect to STLink: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        } else {
            let err_msg = "Failed to connect to STLink".to_string();
            log::error!("{}", err_msg);
            return Err(FlashError::ConnectionError(err_msg));
        }
    }
    log::info!("Connected to STLink");
    let device_info = match stm32prog.device_info() {
        Ok(device_info) => device_info,
        Err(e) => {
            let err_msg = format!("Failed to get device info: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::DeviceInfoError(err_msg));
        }
    };
    log::info!("Device info: {:#?}", device_info);
    let download_result = stm32prog.download(target_bin, Some(0x08000000), Some(false), Some(true));
    if download_result.is_err() {
        if let Some(e) = download_result.err() {
            let err_msg = format!("Failed to download binary: {:?}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        } else {
            let err_msg = "Failed to download binary".to_string();
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        }
    }
    log::info!("Binary downloaded");
    let reset_result = stm32prog.reset(stlink);
    match reset_result {
        Ok(_) => {
            log::info!("Reset successful");
        },
        Err(e) => {
            let err_msg = format!("Failed to reset: {}", e);
            log::error!("{}", err_msg);
            return Err(FlashError::DownloadError(err_msg));
        },
    };
    stm32prog.disconnect();
    log::info!("Disconnected from STLink");
    return Ok(());
}

impl Into<InvokeError> for FlashError {
    fn into(self) -> InvokeError {
        InvokeError::from(format!("{:?}", self))
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_shell::init())
        .plugin(
            tauri_plugin_log::Builder::new()
                .target(tauri_plugin_log::Target::new(
                    tauri_plugin_log::TargetKind::LogDir {
                        file_name: Some("logs".to_string()),
                    },
                ))
                .build(),
        )
        .invoke_handler(tauri::generate_handler![
            greet,
            list_stlink_devices,
            list_projects,
            list_targets,
            write_program
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

App.tsx

import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import "./App.css";

type FlashingStatus = "idle" | "flashing" | "success" | "fail";
function FlashingStatusView(props: {status: FlashingStatus, error?: string}) {
    if (props.status === "idle") {
      return <span></span>
    } else if (props.status === "flashing") {
      return <span>Flashing...</span>
    } else if (props.status === "success") {
      return <span>Flashing success</span>
    } else if (props.status === "fail") {
      return <>
        <span>Flashing fail</span>
        {props.error && <p>{props.error}</p>}
      </>
    } else {
      console.error("Unknown status: ", props.status);
      return <span>Unknown status: {String(props.status)}</span>
    }
}

function App() {
  const [stlinkDevices, setStlinkDevices] = useState<string[]>([]);
  const [selectedStlinkDevice, setSelectedStlinkDevice] = useState<string>("");
  const [projects, setProjects] = useState<string[]>([]);
  const [selectedProject, setSelectedProject] = useState<string>("");
  const [targets, setTargets] = useState<string[]>([]);
  const [selectedTarget, setSelectedTarget] = useState<string>("");
  const [flashingStatus, setFlashingStatus] = useState<FlashingStatus>("idle");
  const [flashingError, setFlashingError] = useState<string>("");

  async function listStlinkDevices() {
    let devices: string[] = await invoke("list_stlink_devices");
    setStlinkDevices(devices);
    if (devices.length > 0 && !devices.includes(selectedStlinkDevice)) {
      setSelectedStlinkDevice(devices[0]);
    }
  }

  async function listProjects() {
    let projects: string[] = await invoke("list_projects");
    setProjects(projects);
    if (projects.length > 0 && !projects.includes(selectedProject)) {
      setSelectedProject(projects[0]);
    }
  }

  async function listTargets() {
    let targets: string[] = await invoke("list_targets", {
      project: selectedProject,
    });
    setTargets(targets);
    if (targets.length > 0 && !targets.includes(selectedTarget)) {
      setSelectedTarget(targets[0]);
    }
  }

  async function write_program() {
    setFlashingStatus("flashing");
    await invoke("write_program", {
      stlinkDevice: selectedStlinkDevice,
      project: selectedProject,
      target: selectedTarget,
    }).then(() => {
      setFlashingError("");
      setFlashingStatus("success");
      return;
    }).catch((e) => {
      console.error(e);
      setFlashingError(String(e));
      setFlashingStatus("fail")
      return;
    });
  }

  return (
    <main className="container">
      <button onClick={listStlinkDevices}>Reload STLinks</button>
      <label>STLink:
        <select onChange={ e => setSelectedStlinkDevice(e.target.value) } value={selectedStlinkDevice}>
          {stlinkDevices.map((device) => (
            <option key={device}>{device}</option>
          ))}
        </select>
      </label>
      <br />
      <button onClick={listProjects}>Reload Projects</button>
      <label>Project:
        <select onChange={ e => setSelectedProject(e.target.value)} value={selectedProject}>
          {projects.map((project) => (
            <option key={project}>{project}</option>
          ))}
        </select>
      </label>
      <br />
      <button onClick={listTargets}>Reload Targets</button>
      <label>Target:
        <select onChange={ e => setSelectedTarget(e.target.value)} value={selectedTarget}>
          {targets.map((target) => (
            <option key={target}>{target}</option>
          ))}
        </select>
      </label>
      <br />
      <button onClick={write_program}>Flash</button>
      <FlashingStatusView status={flashingStatus} error={flashingError}/>
    </main>
  );
}

export default App;

おおざっぱにコードを解説すると、rust側の関数に#[tauri::command]をつけるとTauriがいい感じにやってくれてそのRust関数をUIを作るJavaScript側から呼び出せるようにしてくれます。Rust側はほとんどstm32cubeprog-rsのExampleにエラーハンドリングを追加して、デバイスの発見と書き込みに関数を分け、書き込むバイナリを探す関数を追加しただけです。
App.tsxの方はそれらの関数を呼び出して結果を表示するReactコードを書くだけです。

できたもの

こんな見た目のアプリができます。
起動も書き込みも爆速でQoLが上がります。(STM32CubeProgrammerって起動ちょっと遅いですよね...)

ConnectとDisconnectも自動でやります。

応用

Rustにはクロスプラットフォーム対応なシリアル通信ができるライブラリもあるので、そういったものを使えば書き込みアプリとシリアルモニタを一体化させることもできます。

最後に

ここまで読んでいただき、ありがとうございます。急いで書いた記事なのでちょっとわかりにくくなってしまいました。後で書き直そうと思います。
明日はTanaport先輩の「クラシックの改良機体の話」です。