pythonでAkerunコントローラーのブザー音を周波数解析する

新卒FWエンジニアのnaritakuです。

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 10 日目の記事です。

弊社製品Akeurnコントローラーはブザーを内蔵しているのですが、新規FWの評価中にbeep音を出して再起動をする場面に遭遇しました。

ソースコードからではなく、評価時の動作からデバッグすることもあるのですが、この記事では新年の寅年にあやかり、たまたま録音していた当時の音をスペクトラムに変換し、再起動直前になっていたbeep音の周波数の特定をします。

目次

解析する環境の準備

iPhoneのボイスメモで録音したところ.m4a拡張子で、1チャンネルの音源が記録されていました。

解析する音源

普段の組み込み開発はc言語javascriptなどを使いますが、簡単に解析することを目標に、本記事ではpythonで実施します。 google colabratoryを使うとセットアップもほとんどせずに解析できます

colabratory内ではGoogleDriveにアップロードした音源の利用やpipでのライブラリのインストールもできます。

from google.colab import drive
drive.mount('/content/drive')
!pip install pydub

スペクトラムの表示

scipyのsignal.spectrogram()を使ってスペクトラムに変換します。

窓関数はピークの周波数を精度よく出したいため、ハミング関数を使います。

サンプリング数Nが2のべき乗であることで高速にフーリエ変換できます。 今回はN=1024でのFFTをSTEP(=100)データずつずらしながら実施します。

from pydub import AudioSegment
from scipy import signal
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import Normalize

def loadAudio(filePath):
  audio = AudioSegment.from_file(filePath, "m4a")
  print("チャンネル数",audio.channels)
  print("サンプリング周波数",audio.frame_rate,"[Hz]")
  print("再生時間",audio.duration_seconds,"[s]")
  #1chのみの音源を取り出す
  samples = np.array(audio.get_array_of_samples())
  sample = samples[::audio.channels]
  return sample,audio.frame_rate


sound,frameRate = loadAudio("/content/drive/MyDrive/CTL.m4a")

N=1024
step=100

freqs, times, Sx = signal.spectrogram(sound, fs=frameRate, window='hamming',nperseg=N, noverlap=N-step,detrend=False, scaling='spectrum') # スペクトログラム変数


plt.figure()
f,ax = plt.subplots(figsize=(12,8))
im=ax.pcolormesh(times, freqs/1000, 10* np.log10(Sx), cmap='magma',norm=Normalize(vmin=-60, vmax=60))

ax.set_yscale('log')
ax.set_ylim(0.1,24)
ax.set_ylabel('Frequency[kHz]')
ax.set_xlabel('Time[s]')

pp=plt.colorbar(im)
plt.show()
チャンネル数 1
サンプリング周波数 48000 [Hz]
再生時間 4.378729166666667 [s]

f:id:photosynth-inc:20220104033319p:plain
beep音を含む録音データのスペクトラム

全体的に低周波の音が強くなっていますが、beep音の出ている3.0[s]近辺では特定の周波数の音が大きくなっていることが確認できます。

beep音のみのデータにトリミングして再度FFTを行い、上記の特定の周波数について求めていきます

beep音のみの解析

beep音が再生されている時間のみにトリミングしたデータを使います。

音源

Beep音を矩形窓とハミング窓を使って切り取りフーリエ変換します。

from pydub import AudioSegment
from scipy import signal
import numpy as np
import matplotlib.pyplot as plt


def loadAudio(filePath):
  audio = AudioSegment.from_file(filePath, "m4a")
  print("チャンネル数",audio.channels)
  print("サンプリング周波数",audio.frame_rate,"[Hz]")
  print("再生時間",audio.duration_seconds,"[s]")
  #1chのみの音源を取り出す
  samples = np.array(audio.get_array_of_samples())
  sample = samples[::audio.channels]
  return sample,audio.frame_rate

def fft(sample,frameRate):
  # 音声全体を1つの波形としてフーリエ変換
  sp = np.fft.fft(sample)
  f = np.fft.fftfreq(sample.shape[0], 1.0/frameRate)
  # 正の周波数部分だけにする
  f= f[:f.shape[0]//2]
  sp = sp[:sp.shape[0]//2]
  sp[0] = sp[0] / 2
  magnitude=np.abs(sp)
  return f,magnitude


# フーリエ変換する波形の表示

sample,frameRate=loadAudio("/content/drive/MyDrive/beep.m4a")

N=len(sample)
time=[i/frameRate for i in range(N)]
hamming=np.hamming(N) * sample

plt.figure(figsize=(16,16))
plt.subplot(2,1,1)
plt.plot(time,sample,label='Rectangular window')
plt.plot(time,hamming,label='Hamming window')
plt.xlabel('time [s]')
plt.ylabel('amplitude')
plt.legend()

# フーリエ変換とピークとなる周波数の表示
f,magnitude=fft(sample,frameRate)
f_h,magnitude_h=fft(hamming,frameRate)
maxi = signal.argrelmax(magnitude, order=100)
maxi_h = signal.argrelmax(magnitude_h, order=100)

plt.subplot(2,1,2)
plt.plot(f, magnitude)
plt.plot(f_h, magnitude_h)
plt.plot(f[maxi[0]],magnitude[maxi[0]],'ro',label='peak(origin)')
plt.plot(f_h[maxi_h[0]],magnitude_h[maxi_h[0]],'gx',label='peak(hamming)')
plt.yscale("log")
plt.xlabel('frequency [Hz]')
plt.ylabel('magnitude')
plt.legend()
チャンネル数 1
サンプリング周波数 48000 [Hz]
再生時間 0.18695833333333334 [s]

f:id:photosynth-inc:20220104042313p:plain
beep音の周波数解析

極大値かつ前後100点の中で最大値を取るものについて 短形窓での極大値を赤丸、ハミング窓での極大値を緑Xでプロットすると、0Hzから16000[Hz]まではどちらも同じ周波数でピークが出ており、ピークとなる周波数ががほぼ等間隔になりました。 1100[Hz]あたりの音がブザーで鳴らしている周波数、それ以降のピークはブザーの倍音によるものである仮定のもと、ブザーの出力している周波数を推定します。

最小二乗法で周波数を推定する

求めたい周波数aのx倍の倍音の周波数がyとなる関数 y=ax についてaの値を最小二乗法で求めます。 サンプルとして使う点は(x,y)=(0,0)と上記のグラフの赤丸でプロットされた2~13番目のピークの値とします。 (x,y)=(0,0)を使うのは0倍の時に0 [Hz]であることを期待しているためです。

# FFTの極大値から周波数を推定する

y=np.append(0, f[maxi[0][1:14]])
x=np.arange(len(y))
# y=mxとして最小二乗法
A = np.vstack([x, np.zeros(len(x))]).T
m, _ = np.linalg.lstsq(A, y, rcond=None)[0]


plt.plot(x,y,'gx',label='peaks of FFT(Rectangular window)')
plt.plot(x,m*x,label='Approximate line by least squares method')
plt.plot(1,m,'ro',label='Estimated frequency')
plt.xlabel('scale of tone')
plt.ylabel('frequency [Hz]')

plt.legend()

print("最小二乗法で推定した周波数", m,"[Hz]")
最小二乗法で推定した周波数 1187.9528242354183 [Hz]

f:id:photosynth-inc:20220104035206p:plain
周波数の推定

グラフを見ても綺麗に倍音でピークになっていそうです。 推定できた周波数は約1187[Hz]でしたが、Akerunコントローラーが出力しようとしていた音階を推測します。

どの程度正しい周波数なのか

かなりいい加減な解析をしているので、ピッタリ1187[Hz]の音源ではないはずです。 現時点でどの程度信用できる周波数だったのか考えておきます。

ピークとして使った周波数の精度

今回のデータでは8974点のフーリエ変換を行なっていますが、 サンプリング周波数48000[Hz]であるために、\frac{48000i}{8974} (i=0,1,2,...4987)[Hz]の波に変換しています。 今回はピークとして使った周波数も約5.34[Hz]ごとに離散化されているため、本来の再生される周波数とたまたまサンプリングに使われる周波数が合致しなければ本来の周波数との誤差が生まれてしまいます。 申し訳程度に最小二乗法でこの誤差が小さくなることを期待していますが、ピークとして使った周波数にすら信用できる区間が約5.34[Hz]の幅があることを念頭に入れておきましょう。

音階とその周波数

12平均律では $2^{\sqrt{12}}$ 倍の周波数で次の音階が得られるため、 440[Hz] を ラ/A4 としたとき

  • ド♯/C♯6の周波数は約1109[Hz]
  • レ/D6の周波数は約1175[Hz]
  • レ♯/D♯6の周波数は約1245[Hz]

となります。

今回推定したピークとは10Hzほど低いものの、人間の耳ではbeep音はD6の音として聞こえることが想定され、当時slackで即レスをくれた音楽の得意なエンジニアと同じ結論になりました。これらをもとに、デバッグすることができそうですね。

f:id:photosynth-inc:20220109112402p:plain

参考


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

nRF52 DK を使ったノイズ除去の練習

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 24 日目の記事です1

本 Advent Calendar 2度目の登場となる tarotene - Qiita です.先日は「組み込みエンジニアのための徒手空拳のすゝめ」という記事でかなりポエミーな内容をぶっ放したので,今回は地に足のついた内容でお送りします.

akerun.hateblo.jp


チャタリング・ノイズ・ディレイ...

突然ですが,読者の皆さんはボタンのついたデバイスを日常的にお使いかと思います.PC のマウスやキーボード然り,ゲームのコントローラー然り,何らかの ON/OFF 判定を伴う動作にボタンは欠かせないモノです.

こういったボタンはタクトスイッチという電子部品で実装されていることが多いでしょう2:

akizukidenshi.com

タクトスイッチは,触れていない時には基本 OFF(接点開放)の状態で,押し込んだ瞬間だけ ON(接点短絡)と判定されるように作られています.こういった判定方式を特別に A 接点と呼んだりします.逆に,押し込んだ瞬間だけ OFF と判定する方式は B 接点と呼ばれています:

www.fa.omron.co.jp

そして,デジタル電子回路や MCU においてボタンの ON/OFF が常に意図通りのデジタル入力となってくれれば良いのですが,現実にはそうはいきません.というのは,そもそも世の中のデジタル信号はデジタルとして解釈するためのルールが設けられたアナログ信号に過ぎず,開放/短絡の間にはかならず中間の状態が存在するからです3

スイッチの ON/OFF も御多分に洩れず中間状態をとります.よくあるのが,接点そのものの跳ね(bouncing)に伴ってデジタル信号が HIGH/LOW を行ったり来たりするチャタリングと呼ばれる現象です.これは,考えられうる最もシンプルな設計のメカニカルスイッチでは不可避な現象です.こうした現象を回避するのに,例えば水銀リレーなどでは開放(短絡)->短絡(開放)の状態遷移時に接点そのものが張り付いて跳ねないような設計が採用されています:

www.sanyu.co.jp

また,こうした現象を回路レベルで取り除くためにコンデンサと抵抗を加えたり(CR と呼ばれる)やシュミットトリガと呼ばれるヒステリシスを持つ A/D 変換器を用いたりすることもあります:

toshiba.semicon-storage.com

チャタリングを接点レベルや回路レベルで除去できるのだから,当然ソフトウェアレベルでもできるだろうと期待したあなた,その通りです.

実際,世の中の電子機器では回路(というかハードウェア全体)とソフトウェアそれぞれにかかる要件と相談しながらこの手のあるある問題を解決していきます.例えば,回路のレイアウト制約が大きければソフトウェアに一任,ハード・ソフト両方に潤沢なリソースがあって要件も緩ければ両方で対応,逆にソフトウェアのデリバリーが間に合わない(あるいはできない)ことが最初からわかっていればハードウェアだけで済ませる,といった具合です.世の中の電子機器の設計は残念ながらそのほとんどが非公開なので想像の域を出ませんが,民生機器のレベルだとソフトウェアだけでチャタリング除去することが多いと聞きます4

本記事では,実在のマイコンを使ってソフトウェアだけでチャタリング除去を(擬似的に)行う様子をコード例とともに示していきます.

用いたマイコンは nRF52 DK(正式名 PCA10040)という評価ボードです.

www.nordicsemi.com

この評価ボードは元々 nRF52832 と呼ばれる BLE チップを評価するためのものですが,

  • 偶然手元にあった
  • ボタンと LED が 4 つずつ付いている

という理由で今回の実験に採用します.

作る機能は,

  • ボタンを押下(リリース)すると一定の遅延時間の後に LED が点灯(消灯)する

というシンプルなものですが,採用するアルゴリズムが割とこの手のコードにあるあるなので事前に簡単な説明を入れます.

なお,今回は工数の都合で機能的な振る舞いにフォーカスするためにコードの可読性とかメンテナンス性には目を瞑ろうと思います5

ホールド & カウントアップ

チャタリング対策の本質を考えると,最終的にはボタンの ON/OFF 判定における偽陽性偽陰性を減らすという所に行き着くと思います.

ここで言う偽陽性偽陰性とはすなわち,

  • 偽陽性: 押してもいないボタンを押したことにされること
  • 偽陰性: ボタンを押したにも関わらずボタンを押したことにされないこと

です.前者の偽陽性は,そもそものチャタリング対策のニーズと直結しているので今更議論は必要ないでしょう.そして,後者の偽陰性アルゴリズム停止性に関わる大事な問題です:

qiita.com

これらの要件を勘案すると,どんな実装でも下記の 2 つのイベントを考慮することになりそうです:

  • 仮判定: チャタリングでもノイズでも何でもとりあえず入力としてキャッチし,後段の実判定に渡す
  • 実判定: 仮判定のイベントから有限の時間で本当に入力があったかどうかを通知する

一般に,マイコンではハードリアルタイム要求を守らせるような実装が可能で,逆に手を緩めることでソフトリアルタイム要求だけ充足,スループット要求だけ充足,とパフォーマンスを変化させることもできます:

www.fe-siken.com

つまり,仮判定と実判定のそれぞれに対して応答のリアルタイム性を非機能要求として課すことで最終的な実装が決まります6

ここでは,ベアメタル(RTOS を用いない実装)で動く最大限のリアルタイム性を保証するアルゴリズム---ホールド & カウントアップ---をいきなり紹介します.

と進んでいき,最後に完全なコード例(動作確認済)を示します.

main() の実装

まずはモノから:

#include "boards.h"
#include "bsp.h"
#include "nrf_drv_gpiote.h"

#include "nrf_log.h"
#include "nrf_log_ctrl.h"
#include "nrf_log_default_backends.h"

#include "app_timer.h"
#include "nrf_drv_clock.h"

APP_TIMER_DEF(m_repeated_timer_id);     /**< Handler for repeated timer used to blink LED 1. */

int main(void)
{
    NRF_LOG_INIT(NULL);
    NRF_LOG_DEFAULT_BACKENDS_INIT();

    nrf_drv_clock_init();
    nrf_drv_clock_lfclk_request(NULL);

    nrf_drv_gpiote_init();

    nrf_gpio_cfg_output(LED_1);
    nrf_drv_gpiote_out_set(LED_1);

    nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
    button_1_config.pull = NRF_GPIO_PIN_PULLUP;

    nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);

    nrf_drv_gpiote_in_event_enable(BUTTON_1, true);

    app_timer_init();
    app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);

    m_button_1 = nrf_gpio_pin_read(BUTTON_1);
    
    
    // Enter main loop.
    while (true)
    {
        __WFI();
    }
}

まだこれだけではコンパイルが通りませんが,重要な概念がいくつかあります.まず,nRF5 SDK で利用可能なアプリケーションタイマ,そして GPIOTE の 2 つです.

アプリケーションタイマは,起動用のイベントを投げると有限の整数値にセットされたカウンタが一定のペースでデクリメントされ,値が 0 になる(expire する)とユーザ定義のイベントを投げるといった機能を提供します.nRF5 SDK では app_timer_***() といった関数で提供され,#include "app_timer.h" で利用できるようになります.expire 後の挙動としては再度カウンタを元の整数値にセットし直しタイマを起動させっぱなし,そのままタイマを停止させるの 2 択です.今回みたいに周期的にイベントを投げるタイマを利用したかったら前者が妥当ですが,場合によっては後者も使います.

GPIOTE は詳しくは後述しますが,対象の入力ピン(今回だとボタンに直結した GPIO ピン)の状態変化を直接イベントハンドラに繋げる機能です7.これを制御・管理するために一連の HAL ライブラリ関数 nrf_drv_gpiote_***() が提供されています.割り込みベクタを直接消費する機能っぽいので,当然ながら登録可能なピン数は有限で,実際のピン数よりずっと少ないです.なので,たくさんの入出力を一手に担いながらリアルタイム処理を行う製品を作ろうと思ったら全部 GPOITE 任せというのは土台無理で,ここに工夫が求められます.

やろうとしていることは,ざっくり

  • ボタン入力に対応する GPIO ピンを GPIOTE に登録し,常時監視.
    • 実現方法: SDK 側の GPIOTE 用ユーティリティ関数を使用.
  • GPIOTE は状態変化を検知したら周期タイマを起動する.
  • 周期タイマは,カウンタ expire 時に対象の GPIO ピンをチェック.
    • 実現方法: 周期タイマ起動時に GPIO ピンをチェックする関数(タイマイベントハンドラ)を登録.

です.ここで,タイマイベントハンドラには

  • GPIOTE が検知した状態変化がそのまま一定時間に渡って保持されていれば正式に受理
  • それ以外の場合は拒否し,タイマを停止

という仕様を守らせることにします8

これによって前述の仮判定から実判定までがスッと繋がる実装になります.これに加えて

  • 周期タイマを呼ばせる周期(msec 単位)と呼ばせる最大回数

を決めてあげると非機能的な部分も含めて仕様が完成します.

なので, main() 内部の処理としてはざっくり,

  1. クロックドライバの初期化: nrf_drv_clock_init();
  2. クロックインスタンスの生成: nrf_drv_clock_lfclk_request(NULL);
  3. GPIOTE ドライバの初期化: nrf_drv_gpiote_init();
  4. GPIOTE インスタンスの生成: nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);
  5. GPIOTE インスタンスの有効化: nrf_drv_gpiote_in_event_enable(BUTTON_1, true);
  6. タイマの初期化: app_timer_init();
  7. タイマインスタンスの生成: app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);

という順になります.

button_1_toggle_handler および repeated_timer_handler はそれぞれこの後実装するボタンイベントハンドラタイマイベントハンドラへの関数ポインタです.

補足の処理として

  • タイマインスタンスの ID 宣言: APP_TIMER_DEF(m_repeated_timer_id); /**< Handler for repeated timer used to blink LED 1. */
  • 監視対象の GPIOTE ピンのプルアップ指定:
    1. nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
    2. button_1_config.pull = NRF_GPIO_PIN_PULLUP;
  • 実判定用の変数の初期化: m_button_1 = nrf_gpio_pin_read(BUTTON_1);

があるというイメージです.ここで,

  • m_repeated_timer_id: タイマインスタンスの ID
  • APP_TIMER_MODE_REPEATED: タイマがイベントを投げるタイミングを周期的にするための設定子マクロ

で,実際の処理対象である LED の点灯・消灯用ピンの初期化は

  1. nrf_gpio_cfg_output(LED_1);
  2. nrf_drv_gpiote_out_set(LED_1);

で行います.ビルドが通るためにはヘッダファイルのインクルードに加えてコンパイル時に参照される設定ファイルへの変更が必要です.細かい情報についてはフォーラム記事も参考にしてみてください:

devzone.nordicsemi.com

残るはボタンイベントハンドラタイマイベントハンドラの実装です.

ボタンイベントハンドラ button_1_toggle_handler の実装

ただタイマを起動するだけの簡易な実装です:

void button_1_toggle_handler(nrf_drv_gpiote_pin_t pin, nrf_gpiote_polarity_t action)
{
    NRF_LOG_INFO("timer start");
    
    app_timer_start(m_repeated_timer_id, APP_TIMER_TICKS(100), NULL);
}

タイマイベントハンドラ repeated_timer_handler の実装

まず,グローバルスコープでカウンタを宣言・初期化しておきます:

static uint32_t m_button_1;
static uint32_t m_button_1_cnt = 0;

タイマイベントハンドラはカウンタを利用して条件分岐を行います:

void repeated_timer_handler(void * p_context)
{
    // COUNT-UP FINISH
    if (m_button_1_cnt >= BUTTON_1_CNT_MAX)
    {
        NRF_LOG_INFO("count-up finished (%d)", m_button_1_cnt);

        m_button_1 ^= 1;
                nrf_drv_gpiote_out_toggle(LED_1);

        NRF_LOG_INFO("button state changed to %d", m_button_1);

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;

        return;
    }

    // COUNT-UP CONTINUE / ABORT
    if (m_button_1 != nrf_gpio_pin_read(BUTTON_1))
    {
        NRF_LOG_INFO("counting up... (%d)", m_button_1_cnt);

        m_button_1_cnt++;
    } else {
        NRF_LOG_INFO("count-up aborted");

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;
    }
}

以上で処理の大まかな流れが完成します.最終形は下記のようになります:

// unused
// #include <stdbool.h>

#include "boards.h"
#include "bsp.h"
#include "nrf_drv_gpiote.h"

#include "nrf_log.h"
#include "nrf_log_ctrl.h"
#include "nrf_log_default_backends.h"

#include "app_timer.h"
#include "nrf_drv_clock.h"

#define BUTTON_1_CNT_MAX (10)

APP_TIMER_DEF(m_repeated_timer_id);     /**< Handler for repeated timer used to blink LED 1. */

static uint32_t m_button_1;
static uint32_t m_button_1_cnt = 0;

void button_1_toggle_handler(nrf_drv_gpiote_pin_t pin, nrf_gpiote_polarity_t action)
{
    NRF_LOG_INFO("timer start");
    
    app_timer_start(m_repeated_timer_id, APP_TIMER_TICKS(100), NULL);
}

void repeated_timer_handler(void * p_context)
{
    // COUNT-UP FINISH
    if (m_button_1_cnt >= BUTTON_1_CNT_MAX)
    {
        NRF_LOG_INFO("count-up finished (%d)", m_button_1_cnt);

        m_button_1 ^= 1;
                nrf_drv_gpiote_out_toggle(LED_1);

        NRF_LOG_INFO("button state changed to %d", m_button_1);

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;

        return;
    }

    // COUNT-UP CONTINUE / ABORT
    if (m_button_1 != nrf_gpio_pin_read(BUTTON_1))
    {
        NRF_LOG_INFO("counting up... (%d)", m_button_1_cnt);

        m_button_1_cnt++;
    } else {
        NRF_LOG_INFO("count-up aborted");

        app_timer_stop(m_repeated_timer_id);
        m_button_1_cnt = 0;
    }
}

int main(void)
{
    NRF_LOG_INIT(NULL);
    NRF_LOG_DEFAULT_BACKENDS_INIT();

    nrf_drv_clock_init();
    nrf_drv_clock_lfclk_request(NULL);

    nrf_drv_gpiote_init();

    nrf_gpio_cfg_output(LED_1);
    nrf_drv_gpiote_out_set(LED_1);

    nrf_drv_gpiote_in_config_t button_1_config = GPIOTE_CONFIG_IN_SENSE_TOGGLE(true);
    button_1_config.pull = NRF_GPIO_PIN_PULLUP;

    nrf_drv_gpiote_in_init(BUTTON_1, &button_1_config, button_1_toggle_handler);

    nrf_drv_gpiote_in_event_enable(BUTTON_1, true);

    app_timer_init();
    app_timer_create(&m_repeated_timer_id, APP_TIMER_MODE_REPEATED, repeated_timer_handler);

    m_button_1 = nrf_gpio_pin_read(BUTTON_1);
    
    
    // Enter main loop.
    while (true)
    {
        __WFI();
    }
}

まとめ・発展的なこと

前述のコードをビルドして nRF52 DK にロードし,RTT でログを出しながら 1 つ目のボタンを押したり離したりすることで動きを確認できます.

もっとも,nRF52 DK にはもともとボタンのチャタリングを上手くフィルタする素子が組み込まれているので,nRF52 DK を動かす上ではほぼ不要な機能の実装と言えます.

ただ,複数通りの実装が考えられるチャタリング対策という機能で「要件 XX を満たすコードは最低こう書きましょう」みたいな内容の記事を一度書いてみたかったので,身近なマイコンボードを題材に書かせてもらいました.

ベテランの組み込み屋さんにとっては基本の基みたいな内容でありながら「機能要求から実装へ」という流れで解説を書くというのは難しかったですが,それなりの内容を提供できたのではないかと思います.

コード上の改善については,例えば

  • ボタンもLEDもそれぞれ 4 つとも有効にしてチャタリング対策を保持したまま任意に割り当てたい
  • 割り当て規則を変更したい
  • チャタリング対策の遅延時間を変更したい

みたいな要求に対して最小限のコード変更で対応できる,というのが良い指導原理になると思います.このあたりは読者の演習問題とはせず,どこかで記事にする予定です.


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com


  1. 性懲りも無く投稿予定日を過ぎてからの投稿でごめんなさい… でも書くのが大事だと思うので書きます.

  2. ちなみに「タクト」という言葉からは指揮棒(takt)を連想させますが,タクトスイッチの語源は tactile switch(直訳: 感触のあるスイッチ)です.

  3. スイッチの開放/短絡を直接入力するタイプの I/F では,電子回路の接地(GND)電位に対する相対的な HIGH/LOW を与えるわけではないことに注意しましょう.このタイプの I/F を無電圧接点と呼んだりします.そして,端子のインピーダンスは通常,開放時に無限大(複素インピーダンスなら絶対値が無限大),短絡時にゼロとなります.

  4. PlayStation 4 のコントローラ DualShock 4 のボタン(もとい FW)がどういう実装なのかが気になりちょっとだけ検索したら何らかの方法で FW を dump し公開したような形跡と DMCA による取り下げ記録のあるGithubリポジトリが見つかりました,アーメン…(そりゃそうですよね)

  5. 余裕があれば改良編と称してまた記事にするかも知れません.

  6. ナイーブに考えると仮判定と実判定のそれぞれで要求するリアルタイム性が異なる変なコードもあり得りえますが,実装コストが無駄になることを考えると両者で要求レベルを揃えておくのが普通です.

  7. 名前は nRF 独自ですが似たような機能は他のマイコンにもあると思います.

  8. この実装は,タイマの起動・停止を担う関数がそれぞれ通常のコンテキスト・割り込みコンテキストという異なるコンテキストで呼ばれる非対称な実装で,一度起動したタイマが停止されずに動いたままという状況になっても気が付かない恐れもあります.これはよくある malloc() で確保したメモリ領域のポインタを特定の関数に値渡しした後,関数内部で free() させる実装と似たような話なので気にしないで良いと言えば気にしないで良い… ですが,対称化できるならした方が見通しは良いでしょう.

Webエンジニアに求められるスキル

この記事はCalendar for Akerun | Advent Calendar 2021 - Qiita の25日目の記事です。

開発部VPoEのNonです。

エンジニアの採用が難しい時代になってきましたが、良い組織を作るためには、求めるエンジニアの採用基準を明確にし、ミスマッチのない採用をしていかなければなりません。

ベンチャー企業の多くは、Webエンジニアを採用する際に、フルスタックエンジニアを求めることが多いと思います。

フルスタックエンジニアを採用する際に、どのような観点でスキルを見抜けば良いかお話したいと思います。

フルスタックエンジニアとは?

まず、フルスタックエンジニアという言葉から、皆さんはどんなエンジニアを想像しますか?

「フロントエンド、バックエンド、インフラを一通りできる人」でしょうか?

もしくは、 「 なんでもできる優秀なエンジニア?」でしょうか?

多くの人が、フルスタックエンジニアという言葉を使いながら、あまり具体的な定義をすることなく使っているケースが多いように感じます。

基準が曖昧なままですと表面的なスキルだけ見て、必要なスキルが備わってない人を採用してしまうことになってしまいます。

フルスタックという言葉の意味で考えると

フルスタック = 複数の技術分野についての知識や技能に精通していること」

となります。

ただ、採用側が期待することは、どんな技術を持っているかよりも

「一人でスクラッチでWebシステムを構築できる」

ということの意味合いの方が強いのではないでしょうか?

Webシステム開発に必要な要素

Webシステムを構築するためには、いくつかの工程を経ていく必要があります。

  • 要件定義
  • 設計
  • 実装
  • テスト
  • リリース

また、忘れてはならないのが、リリース後の運用保守です。

システムは、作って終わりではなく、最初にリリースした後は、利用者の要望に応えるために追加開発したり、不具合やインフラの問題で起きる障害対応なども必要となります。

システム開発における一連の要素を絵にすると下記な感でしょうか。

システム開発工程
システム開発工程

これをもっと細かく分類していくと

カテゴリ 項目
開発スタイル ウォータフォール
アジャイル
プロジェクト管理 進捗管理
タスク管理
見積管理
外注管理
予算管理
契約管理
要件定義 業務分析
ユースケース
システム構成図
機能要件
非機能要件
概要設計
設計 設計手法
基本設計
詳細設計
アーキテクチャ設計
DB設計
インフラ設計
運用設計
実装 CRUD
モジュール化
コードレビュー
セキュリティコーディング
非同期処理
ファイルアップロード
インポート・エキスポート
認証・認可
メール送信
キャッシュ
キュー
外部API連携
バージョンアップ
バッチ
テスト テストメソッド
セキュリティ監査
リリース リリース手順
運用 情報セキュリティ管理
バグ管理
トラブルシューティング
障害エスカレーションフロー
インシデント管理
構成管理

技術よりの観点で見ていくと

カテゴリ 項目
言語 フロントエンド
バックエンド
DB
OS Linux
ミドルウェア Webサーバー
DB
NoSQL
Cache
キュー
検索エンジン
メールサーバー
ファイルサーバー
コンテナ
ストレージ
インフラ ネットワーク
SSL
DNS
負荷分散
攻撃対策
障害対策
プロキシ
CI/CD ビルド
CI/CD
イメージ管理
バージョン管理 ソース管理
ブランチモデル
Pull Request
Merge
パフォーマンスチューニング プログラム
クエリ
ミドルウェア
ディスクIO
スケールアップ
スケールアウト
プロビジョニング ansible
Terraform
webpack
npm
サーバー運用 死活監視
リソース監視
プロセス監視
トラフィック監視
ネットワーク監視
ログ監視・ログ管理
パフォーマンス監視
テスト ユニットテスト
E2Eテスト
負荷テスト
セキュリティ
開発環境 Docker
IDE・エディタ
Lint
git
ターミナル

これ以外にももっと要素はあるかもしれませんが、ここに挙げたようなことを面接で確認することで、その人のスキルレベルが見えてくると思います。

全ての分野で高いスキルを持っている人は、少ないと思いますし、人によって、スキルのバラつきも違ってきますので、現在の組織の状態に合わせて、どの分野でどの程度のスキルが必要かチームメンバーと意識合わせをしておくことが大切です。

ビジネスマンとしての仕事力

先に、「一人でスクラッチでWebシステムを構築できる」フルスタックエンジニアを採用することについてお話しましたが、実際、一人でシステム開発をすることはほとんどありません。

企業で開発するシステムは、一人で構築するにはボリュームが多く時間がかかりすぎるため、チームで開発することが求められます。

これまでは、開発に求められるスキルについて見てきましたが、チームで仕事を円滑に進めるための「仕事力」もとても重要です。

主体性、計画力、実行力など、仕事力を生み出すための要素も確認しましょう。

(参考)社会人基礎力とは?その必要性や鍛え方などを解説 www.i-learning.jp

まとめ

  • Webエンジニアを採用する際には、技術要素を細かく分解し、自社に必要なスキルを持っているかを確認すること。
  • 技術だけではなく、チームでワークするための仕事力も備わっているか確認すること。

エンジニア側もこういった観点で見られているということを意識すると、自身のスキルアップも効率的に行えるのではないでしょうか。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

組み込みエンジニアのための徒手空拳のすゝめ

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 23 日目の記事です.

初めまして,2021 年度の tarotene - Qiita です.

今年 1 月より新卒インターン1として FW チームにジョインし,4 月からはそのまま FW チームで製品の開発・運用に携わっております.

FW チームに求められる守備範囲はざっくりと

の開発・運用・その他諸々ですが,実は私はこれらの分野がほぼ全て未経験でした!

実際,Linux は多少のコマンドが打てる程度2で,リアルタイムシステムに至っては概念すら全く知らない状況でした.

にも関わらず,入社から 1 年が経過した現在では製品の運用にがっつり関われるようになった(と自分で自分を評価しています).

もちろん,これはよくある「未経験から最短でITエンジニアへ」みたいな騙し文句のサクセスストーリーではなく,随所に見えない仕掛けを施してきた人間の生き様です.

本記事では,そんな私が元々どういった人間で,組み込みエンジニアとしての業務を軌道に乗せるまでに何をやってきたかを簡単にお話しします.

似たようなキャリアを歩まれたい方の参考になれば幸いです.


エンジニアリングとの接点

プログラミングとの出会い

この手の話でキーワードになるのは何といっても「プログラミング」でしょう.

私は元々,大学の学部・大学院で物理を専攻しており,たまに必要に駆られて数値計算のためにプログラミングを覚えるといった程度でした3

Twitter で希によくいる「高校時代に情報オリンピックで金メダルを取りました」とか「計算機科学の分野で特別栄誉賞をいただきました」みたいなのとは完全に無縁の生活です.

でも,実際に勉強してみると,当時の観察対象とは一見異なる「自然法則」が計算機を支配しているように感じられ,徐々に惹かれていきました.

プログラミングとの中途半端な関係

難しいことに,研究のためにプログラミングをただ使役するだけだと,どうしてもソフトウェアの「設計」とか「運用」を意識する場面がほとんどありません4.あっても,教科書レベルのコーディングガイドラインを守る程度.例えばデザインパターンやテスト技法についての勉強は完全におざなりで,本気で時間を捻出すればできたかも知れないけど,そこまでのモチベーションが持てないというのが実情でした.

そこで,研究も何となく詰んできたことだしチョットくらい気が紛れる遊びに手を出そうと思い,知人から紹介されて某競技プログラミングサービスに手を出しました.この時点で,雀の涙程度のアルゴリズムとデータ構造についての知識が身に付き,わずかながら C++ を触れるようになりました.

面白いのが,数式中心の文化で生きてきたこともあり,計算と言えば「有限個のパラメータ集合を一つ定めると一意的に結果が求まる」ものがほとんどと思っていた自分にとって,「手続き的に何かをやると(やはり一意的に)結果が求まる」ケースの多いプログラミングは控えめに言ってパラダイムシフトでした.でも,よくよく思い返すと,例えばモンテカルロシミュレーションは「確率アルゴリズムによって時系列を所望の確率分布に収束させる」というそれ自体が数学的に保証された手続きなので,数学・計算機科学に共通の側面を 1 nm 程度理解したというのが妥当かも知れません.

プログラミングを超えてエンジニアリングへ

段々と見出しが仰々しくなってきましたね.

プログラミングは飽くまで道具だとする研究中心の生活と同じくらい,ソフトウェアエンジニアとして真っ向からプログラミング(もといエンジニアリング)に取り組むキャリアも魅力的だと思ったのは研究生活も終盤の頃です.

それで,紆余曲折あり Photosynth にジョインする運びとなりました5

この時点で,学生時代には手すら出さなかった新しいことをやろうと決めていました.それは,概ね

  • 学習コスト度外視で組み込みならではのコア技術に習熟し,ゆくゆくは技術選定にも関われるようになる
  • 商用ソフトウェアならではの設計・テスト・運用・... の経験を積む

という 2 点です.

前者の「組み込みならではのコア技術」については挙げるとキリがないのですが,個人的にはプロダクト依存度の非常に高い Bluetooth Low Energy (BLE) や各種リアルタイム OS の仕組みを知るのが優先課題といったところです.

後者の「設計・テスト・運用・...」は広い意味でのソフトウェアエンジニアとして,です.コーディングから離れることで初めて見えてくる開発者特有のニーズとかそういうものを大事にすることで業務の幅も広がると思っています.


エンジニアリングにおける「徒手空拳」

いよいよ,本記事の本題である徒手空拳編(?)にシフトします.

ソフトウェアエンジニアとしての私は

  • 物事を原理原則から理解し,知識を宙に浮いた状態にしない

という態度を非常に大切にしています.この態度によって,

  • ソースコード上の知識片を統合し,本来あったはずの設計情報を看破できる
  • 設計情報をストーリー形式でチームメンバーに共有できる
  • (特に FW 設計で)電子回路・機械の事情を考慮した最適なコードを書ける

といった実利はもちろんですが,いざという場面で徒手空拳で知識を作れるといった強みがあります(タイトル回収).

世の中,知識は良いものも悪いものも指数関数的に増大していく一方です.こうした知識の洪水の中を生き抜き,少しでも信頼性・汎用性・寿命に富む知識を手に入れるには「自ら知識を作る」という態度が肝要だと思います.

知識を作るというのは大変な行為で,苦労して編み出した知識が既出だったというケースは割合多いです.でも,そうやって自らの血肉をベースにして出来上がった知識は一生モノです.また,既存の知識を組み合わせるだけですごいことに気づいてしまうパターンもあります.つまりギャンブルです.

組み込みだとまとまった知識が世の中にない,特に日本語で書かれた文献が少ない,などの理由で調査コストが大きくなりがちです.つまり,知識を作るチャンスが山ほど転がっています.例えば,社内にある組み込み機器をリモートで J-Link デバッグしようと思い,ざっと以下のことを調べた記憶があります:

  • 端末多重接続技術(tmux, screen, byobu, etc...)
  • Raspberry Pi OS に J-Link Commander をインストールする方法
  • 組み込み機器の CPU を停止・リセットする方法

この手の調べ物で

  • なるべくオリジナルの文献にあたる
  • 自ら手を動かして失敗パターンを潰しておく

といったことを心がけておくと,知識の寿命や適用範囲を伸ばすことにつながり,周りのエンジニアもハッピーになれます.

ここにエンジニアリングの本質があるような気がします.実際,やっていることは

  • 得られた事実から,適用範囲と主張の強さのバランスが取れた命題を導く
  • 導かれた命題に対して,事実ベースの検証・改良も継続的に行なっていく

の 2 つに尽きます.今のところ,入社してからの経験値の振り分は大体

上に書いたことの純粋な質的向上 >> 特定の OS や言語,フレームワークに関する技術的スキルの積み上げ

といった感じですが,上手くバランスをとりながら生きていきたいものです.


ブックガイド

最後に,業務知識の集約がてらこの半年間で手を付けてきた名著(?)の数々をご紹介します.

ほぼ積読状態のものもあれば,何度も読み返した本もあります.コメントと共にお楽しみください.

『組み込みソフトウェア開発スタートアップ』

www.amazon.co.jp

ソフトウェアを浅く広く知っている状態で組み込みの世界に飛び込むためのバイブル.ソフト設計はもちろん,製造・テスト治具やそもそものテストの話がカバーされているなど,とにかく話題が豊富.参考文献も付いていて更に進んだ学習を促してくれるのが Good.今すぐポチりましょう.

『MEMS開発&活用スタートアップ』

www.amazon.co.jp

ネタ枠.書泉ブックタワーで何故か『組み込みソフトウェア開発スタートアップ』の隣にあったので一緒に買ってしまいました.半導体バイスをはじめとするナノスケールの世界に興味がある人向けです.C 言語のコードっぽいモノもチラッと載っているのであながち無関係でもないと思います...

『達人プログラマー

www.amazon.co.jp

これは恐ろしい本です.プログラミングパラダイムを片っぱしから紹介してくれる本です.プログラミングパラダイムはどうしても言語で縛られるので,なかなか目線を上げる機会がないなと思ったなら読むと良いでしょう.ソフトウェア設計の本でもあるので,ビジネスロジック分かっているマネージャが技術選定のために読むという使い方もできそうです.

『リーダブルコード』

www.amazon.co.jp

紹介すると徳を積めると思ったので載せました.微妙なプラクティスも少なからずあるみたいですが,

  • 「簡潔なコメントを書こう.」->(でも概念を知らなければいいコメントは書けない,だから様々な概念を知ることから心がけよう)
  • 「コーディングガイドラインに沿う範囲で良い変数名,もとい英単語を選定しよう.」->(でも知らなければできない,だから英語にも慣れよう)

といった具合に,書かれた内容から裏のメッセージを読み解くのが良いと思います.

Bluetooth Low Energy: The Developer's Handbook

www.amazon.co.jp

今読んでいる本です.LaTeX組版されている読み物はやはり落ち着きますね.英文も平易なので読みやすく,それでいて BLE の仕様についてほぼ完全な解説を提供してくれる優れものです.こういうのを血肉にしたいものです.

『組み込みC/C++プログラミング入門』

www.amazon.co.jp

たまにある C 言語でクラスベースのオブジェクト指向を実現する系の指南書です.組み込みだとコード品質とフットプリントの軽さを両立することが求められるので,時にはこういった技術も求められます.手を動かして練習するのに向いています.むしろ言語設計とかやりたい人に最適かも知れません.

『シェル・ワンライナー160本ノック』

www.amazon.co.jp

めちゃくちゃキャッチーなシェル芸の本.組み込みでは Real-time transfer (RTT) という Segger 社のログ出し機能を使うことがあり,サーバー・インフラ屋のログ監視とよく似ています.そういう場面ではシェルコマンドによる労力削減は必須なので練習して覚えたかったらこの本を読みましょう.序章はちょっとしんどいかも.

『組込み開発のための実践的プログラミング』

www.amazon.co.jp

AVR マイコンを使って手を動かしながら組み込みプログラミングを覚える本で,実践志向かつちょっと中級者向きです.手を動かさずに読んだ感想は Very Good.

『ディジタル回路の設計入門』

www.amazon.co.jp

アナログ回路からスタートしてデジタル回路の実装,カウンタやタイマ,クロックなどの各種 IC の実装と進んでいき,最終的に VHDL にもちょろっと触れる計算機屋向けの電子回路の本です.

『これなら通じる技術英語ライティングの基本』

www.amazon.co.jp

コミットメッセージやコード中のコメント等,明確(clear)・正確(correct)・簡潔(consice)な英語を書くことをよく求められます.こうしたケースで,日本人エンジニアが最小限の労力で良い英語を書くための指南書のようなものです6


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com


  1. 当社の製品である Akerun Remote を社内で開発・テストしやすくするツールを Python で書いたりしていました.その時の話はまたいずれします.

  2. 拙いシェルスクリプトくらいは書けるが,勉強不精だったので業務で役に立つログ解析系の TIPS とかは完全に無でした.シェルスクリプトの勉強に関しては後述のブックガイドを参照.

  3. 実際に使っていたのは PythonFortran 90 だけ.C に至っては教科書を手を動かしながら読んだくらいで,成果物すら無です!

  4. 批判が来そうなのであらかじめ言い訳をしておくと,近年では HPC 分野の発展がめざましいので,その運用に合った綺麗なソフトウェアを書こうという動きは広がっている(例えば https://ma.issp.u-tokyo.ac.jp/ に情報がまとまっている).また,最近でなくとも,綺麗なソフトウェアを書ける非計算機科学分野の研究者は昔からたくさん居る.

  5. 選択の軸はシンプルな Web サービスよりも低レイヤ技術を駆使する事業に興味があったという点です.

  6. 余談ですが,学位論文を書く時はこれを使っていました.書かれているガイドラインを守るだけで指導教員の赤入れの量が面白いほど減ったのを今でも覚えています.

amazon-ssm-agent のハードウェアフィンガープリントの仕組み

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 22日目の記事です。

どうもおなじみ daikw - Qiita です。

Amazon Systems Manager の動作を調べているとき、「ハードウェアフィンガープリント」という言葉で厨二病を悪化させてしまいました。

つい時間をかけて調べてしまったため、記事にまとめて懺悔します。

契機

弊チームでは、raspiマシンの複製や、バックアップからの復元をすることがよくあります。

Hybrid Activation 済のマシンの復元をしようとしている最中に、ふと 「Systems Manager は混乱しないのかな ...?」と疑問に思ったことがきっかけで調べ始めました。

調査

概ね 抽象 -> 具体 の流れで書いてあります。あたかもこの通り綺麗に調べたように見えますが、実際にはあっちに行ったりこっちに行ったりと迷いながらやっています。探索的な調査は割と好きな daikw です。

ドキュメント

まずは最も抽象的なドキュメントから当たって行きましょう。テクニカルリファレンスにそれっぽい記述がありました!

SSM Agent technical reference - AWS Systems Manager#fingerprint-validation より、

When running on-premises servers, edge devices, and virtual machines (VMs) in a hybrid environment, SSM Agent gathers a number of system attributes (referred to as the hardware hash) and uses these attributes to compute a fingerprint. The fingerprint is an opaque string that the agent passes to certain Systems Manager APIs. This unique fingerprint associates the caller with a particular on-premises managed node. The agent stores the fingerprint and hardware hash on the local disk in a location called the Vault.

If the current machine attributes don't match the stored hardware hash, SSM Agent computes a new fingerprint, ~. This causes RequestManagedInstanceRoleToken to fail, and the agent won't be able to obtain a role token to connect to the Systems Manager service. This failure is by design and is used as a verification step to prevent multiple on-premises managed nodes from communicating with the Systems Manager service as the same managed node.

内容をざっくりまとめると、

  • hardware hash なるものを使い、サーバ固有の fingerprint が計算される
  • hardware hashfingerprintVault に保管される
  • fingerprintVault の値と一致していれば、 RequestManagedInstanceRoleToken なるリクエストを成功として捌いてくれる

ということがわかります。ここでさらに次のような疑問が湧きます。

  • hardware hashfingerprint の実体は何か?
  • Vault とはどういうものか?
  • 構成変更によって fingerprint はどの程度変わるのか?(similarityThreshold はどうチューンされるのか)

フィンガープリント

フィンガープリント(fingerprint)とは - IT用語辞典 e-Words より、

フィンガープリントとは、指紋(を採る)、拇印という意味の英単語で、IT分野では人物や端末などの識別や同定、真正性の確認に用いられる短いデータ列などを指すことが多い。

です。

「マシンのフィンガープリントをする」というのは、「マシン同士の識別・同定が可能な情報を採取する」という意味になります。

https://www.saitolab.org/fp_site/img/catchy3.png
Fingerprint解説サイト より

静的解析

実装が公開されていたので、疑問の回答を見つけることができるかもしれません。 GitHub - aws/amazon-ssm-agent を見ると Go で開発されていました。じっくり読んでいきましょう。

まず、 vendorextra は vendoring されたソースコードに見えるため、除外しながら調べます。ソースコードは主に agent / common / core の三つのディレクトリに収められているようです。

雑に fingerprintgrepすると、 InstanceFingerprint という関数が目につきました。 これは内部関数 generateFingerprint にキャッシュとリソースロックの機能をつけるラッパー関数で、他モジュールに対し公開されています。

func InstanceFingerprint(log log.T) (string, error) {
    if isLoaded() {
        return fingerprint, nil
    }

    lock.Lock()
    defer lock.Unlock()

    var err error
    fingerprint, err = generateFingerprint(log)
    if err != nil {
        return "", err
    }

    loaded = true
    return fingerprint, nil
}

また、同じファイルを眺めると、先頭に核心となる構造体 hwInfo があるのが目につきます。この構造体の全てのフィールドが説明できるようになれば、当初の疑問の答えになりそうです。

type hwInfo struct {
    Fingerprint         string            `json:"fingerprint"`
    HardwareHash        map[string]string `json:"hardwareHash"`
    SimilarityThreshold int               `json:"similarityThreshold"`
}

さて、 generateFingerprint を詳しく読む前に、試しに参照元の一覧を追ってみましょう。

┬─[daiki~@photosyth~:~/g/g/a/amazon-ssm-agent]─[18:41:09]─[G:mainline=]
╰─>$ rg -g !extra -g !vendor InstanceFingerprint
agent/managedInstances/registration/instance_info.go
80:     return fingerprint.InstanceFingerprint(log)

agent/managedInstances/fingerprint/fingerprint_test.go
33:func ExampleInstanceFingerprint() {
58:     val, _ := InstanceFingerprint()

agent/managedInstances/fingerprint/fingerprint.go
44:     vaultKey            = "InstanceFingerprint"
54:func InstanceFingerprint(log log.T) (string, error) {
178:            _ = log.Warnf("Could not read InstanceFingerprint file: %v", err)

これと同様にコードジャンプやgrepで頑張って追っていくと、 amazon-ssm-agentmainまで辿っていけます。

-> `fingerprint.InstanceFingerprint` (`agent/managedInstances/fingerprint/fingerprint.go`)
-> `onpremRegistation#Fingerprint` (`agent/managedInstances/registration/instance_info.go`)
-> `onpremCredentialsProvider#Retrieve` (`agent/managedInstances/registration/instance_info.go`) *ここでやや複雑なことをしている
-> `credentialsRefresher#retrieveCredsWithRetry` (`core/app/credentialrefresher/credentialrefresher.go`)
-> `credentialsRefresher#credentialRefresherRoutine` (`core/app/credentialrefresher/credentialrefresher.go`)
-> `credentialsRefresher#Start` (`core/app/credentialrefresher/credentialrefresher.go`)
-> `SSMCoreAgent#Start` (`core/app/agent.go`)
-> `start` (`core/agent.go`)
-> `run` (`core/agent.go`)
-> `main` (`core/agent_unix.go`)

agent/managedInstances/fingerprint/fingerprint.go に戻りましょう。generateFingerprint の骨子を抜き出して抜粋すると以下のようになります。

func generateFingerprint(log log.T) (string, error) {
    var hardwareHash map[string]string
    var savedHwInfo hwInfo
    var err error
    var hwHashErr error

    for attempt := 1; attempt <= 3; attempt++ {
        hardwareHash, hwHashErr = currentHwHash()
        savedHwInfo, err = fetch(log)
        <omit>
    }

    <omit>

    uuid.SwitchFormat(uuid.CleanHyphen)
    new_fingerprint := ""

    if !hasFingerprint(savedHwInfo) {
        new_fingerprint = uuid.NewV4().String()
    } else if !isSimilarHardwareHash(log, savedHwInfo.HardwareHash, hardwareHash, savedHwInfo.SimilarityThreshold) {
        new_fingerprint = uuid.NewV4().String()
    } else {
        return savedHwInfo.Fingerprint, nil
    }

    updatedHwInfo := hwInfo{
        Fingerprint:         new_fingerprint,
        HardwareHash:        hardwareHash,
        SimilarityThreshold: savedHwInfo.SimilarityThreshold,
    }

    // save content in vault
    if err = save(updatedHwInfo); err != nil {
        log.Errorf("Error while saving fingerprint data from vault: %s", err)
    }
    return new_fingerprint, err
}

ここで fingerprint の正体がわかりました。ただの uuid です。 hardwareHashfingerprint は生成タイミングが同じであること以外に共有する情報はありません。

確かに考えてみると、fingerprintを再計算して、一致するかを調べる必要は特になく(hardwareHashの比較で十分である)、これらの生成タイミングが同じであるのを保証できれば良いはずです。わかりやすい。

さらに掘っていきます。 hwInfo のうち、HardwareHash が何者なのかわかっていません。

agent/managedInstances/fingerprint/hardwareInfo_unix.go に実装があります。

const (
    systemDMachineIDPath = "/etc/machine-id"
    upstartMachineIDPath = "/var/lib/dbus/machine-id"
    dmidecodeCommand     = "/usr/sbin/dmidecode"
    hardwareID           = "machine-id"
)

var currentHwHash = func() (map[string]string, error) {
    hardwareHash := make(map[string]string)
    hardwareHash[hardwareID], _ = machineID()
    hardwareHash["processor-hash"], _ = processorInfoHash()
    hardwareHash["memory-hash"], _ = memoryInfoHash()
    hardwareHash["bios-hash"], _ = biosInfoHash()
    hardwareHash["system-hash"], _ = systemInfoHash()
    hardwareHash["hostname-info"], _ = hostnameInfo()
    hardwareHash[ipAddressID], _ = primaryIpInfo()
    hardwareHash["macaddr-info"], _ = macAddrInfo()
    hardwareHash["disk-info"], _ = diskInfoHash()

    return hardwareHash, nil
}


func processorInfoHash() (value string, err error) {
    value, _, err = commandOutputHash(dmidecodeCommand, "-t", "processor")
    return
}

<omit>
func commandOutputHash(command string, params ...string) (encodedValue string, value string, err error) {
    var contentBytes []byte
    if contentBytes, err = exec.Command(command, params...).Output(); err == nil {
        value = string(contentBytes) // without encoding
        sum := md5.Sum(contentBytes)
        encodedValue = base64.StdEncoding.EncodeToString(sum[:])
    }
    return
}

実装から、以下のようなことがわかります。

  • 特定のコマンドを実行したり、特定のファイルの値を持ってきた結果の md5.Sumhardware hash の値となる
  • currentHwHashhash は、 ハッシュマップ とも チェックサム とも取れる
  • /usr/sbin/dmidecode を主に使っていて、/etc/machine-id といったファイルも使う(ちなみに hardwareinfo_windows.go の場合は wmic.exe

dmidecode

dmidecodeSMBIOS - Wikipedia を取得するための *nix 系OSに提供されるユーティリティです。

適当な EC2 サーバで叩くとこんな感じの出力がとれます。

[ec2-user@ip-172-31-47-131 ~]$ sudo dmidecode -t
dmidecode: option requires an argument -- 't'
Type number or keyword expected
Valid type keywords are:
  bios
  system
  baseboard
  chassis
  processor
  memory
  cache
  connector
  slot

[ec2-user@ip-172-31-47-131 ~]$ sudo dmidecode -t processor
# dmidecode 3.2
Getting SMBIOS data from sysfs.
SMBIOS 2.7 present.

Handle 0x0401, DMI type 4, 35 bytes
Processor Information
    Socket Designation: CPU 1
    Type: Central Processor
    Family: Other
    Manufacturer: Intel
    ID: F2 06 03 00 FF FB 8B 17
    Version: Not Specified
    Voltage: Unknown
    External Clock: Unknown
    Max Speed: 2394 MHz
    Current Speed: 2394 MHz
    Status: Populated, Enabled
    Upgrade: Other
    L1 Cache Handle: Not Provided
    L2 Cache Handle: Not Provided
    L3 Cache Handle: Not Provided
    Serial Number: Not Specified
    Asset Tag: Not Specified
    Part Number: Not Specified

試しに raspi で叩いてみたら、うまく動作しません。 dmidecode - Raspberry Pi Forums に、 It is not available on non-x86 architectures. との記述がありました。

pi@raspberrypi:~ $ sudo dmidecode -t processor
# dmidecode 3.3
# No SMBIOS nor DMI entry point found, sorry.

似たような情報を得るには、/proc/sys の情報をうまく利用する必要があるようです *1

pi@raspberrypi:~ $ cat /proc/cpuinfo | grep Hardware -A10
Hardware    : BCM2835
Revision    : a020d3
Serial      : 000000009cc7694f
Model       : Raspberry Pi 3 Model B Plus Rev 1.3

複製されたraspiマシン同士の区別

複製されたraspiが区別されることは 以前の調査 でわかっています。

従って、 dmidecode が使えないとなると、raspiの区別をしている部分は別にあるようです。agent/managedInstances/fingerprint/hardwareInfo_unix.go をよく読むと、

func diskInfoHash() (value string, err error) {
    value, _, err = commandOutputHash("ls", "-l", "/dev/disk/by-uuid")
    return
}

がありました。これだ!

pi@raspberrypi:~ $ ls -l /dev/disk/by-uuid
total 0
lrwxrwxrwx 1 root root 15 Dec 20 01:17 568caafd-bab1-46cb-921b-cd257b61f505 -> ../../mmcblk0p2
lrwxrwxrwx 1 root root 15 Dec 20 01:17 C839-E506 -> ../../mmcblk0p1

<omit>

pi@raspberrypi-2:~ $ ls -l /dev/disk/by-uuid
total 0
lrwxrwxrwx 1 root root 15 Dec  8 06:17 7616-4FD8 -> ../../mmcblk0p1
lrwxrwxrwx 1 root root 15 Dec  8 06:17 87b585d1-84c3-486a-8f3d-77cf16f84f30 -> ../../mmcblk0p2

複製を作る場合、ほぼ確実にディスクを変更することになるので、この出力が変わって区別されるのだと考えられます。

Vault

vaultの意味・使い方・読み方 | Weblio英和辞書 より、

アーチ形天井、アーチ形天井のようなおおい、(食料品・酒類などの)地下貯蔵室、(地下)金庫室、(銀行などの)貴重品保管室、(教会・墓所の)地下納骨所

金庫室という意味があるので、暗号化や鍵のかかったイメージを持ってソースコードを読んでみましょう。agent/managedInstances/vault/fsvault/fsvault.go に実装があります。

func Store(key string, data []byte) (err error) {

    lock.Lock()
    defer lock.Unlock()

    if err = ensureInitialized(); err != nil {
        return
    }

    p := filepath.Join(storeFolderPath, key)

    if err = fs.HardenedWriteFile(p, []byte(data)); err != nil {
        return fmt.Errorf("Failed to write data file for %s. %v\n", key, err)
    }

    manifest[key] = p
    if err = saveManifest(); err != nil {
        delete(manifest, key)
        return fmt.Errorf("Failed to save manifest when storing %s. %v\n", key, err)
    }

    return
}

fs.HardenedWriteFile がそれらしい名前ですね。さらに追ってみましょう。 agent/fileutil/harden.go を見ると

const (
    RWPermission = 0600
)

// HardenedWriteFile calls ioutil.WriteFile and guarantees a hardened permission
// control. If the file already exists, it hardens the permissions before
// writing data to it.
func HardenedWriteFile(filename string, data []byte) (err error) {

    if _, err = os.Stat(filename); err != nil {
        if os.IsNotExist(err) {
            f, err := os.Create(filename)
            if err != nil {
                return fmt.Errorf("Failed to create the file, %s", err)
            }
            defer f.Close()
        } else {
            return
        }
    }

    if err = Harden(filename); err != nil {
        return
    }

    if err = ioutil.WriteFile(filename, data, RWPermission); err != nil {
        return
    }

    return
}

これで harden の意味がわかりました。ファイルパーミッションを適切に設定すること(ここでは 600)ですね。

saveManifest の方も追ってみましたが、 json.Marshalシリアライズしているだけでした。

つまり Vault は、暗号化なしの、パーミッション 600 が維持されるただのファイルである、ということがわかりました。

Similarity

最後に、 isSimilarHardwareHash を、ログやコメントを一部抜粋して引用します。

func isSimilarHardwareHash(log log.T, savedHwHash map[string]string, currentHwHash map[string]string, threshold int) bool {
    var totalCount, successCount int
    isSimilar := true

    // similarity check is disabled when threshold is set to -1
    if threshold == -1 {
        return true
    }

    // check input
    if len(savedHwHash) == 0 || len(currentHwHash) == 0 {
        return false
    }

    // check whether hardwareId (uuid/machineid) has changed
    // this usually happens during provisioning
    if currentHwHash[hardwareID] != savedHwHash[hardwareID] {
        isSimilar = false
    } else {
        // check whether ipaddress is the same - if the machine key and the IP address have not changed, it's the same instance.
        if currentHwHash[ipAddressID] == savedHwHash[ipAddressID] {
        } else {
            // identify number of successful matches
            for key, currValue := range currentHwHash {
                if prevValue, ok := savedHwHash[key]; ok && currValue == prevValue {
                    successCount++
                }
            }

            // check if the changed match exceeds the minimum match percent
            totalCount = len(currentHwHash)
            if float32(successCount)/float32(totalCount)*100 < float32(threshold) {
                isSimilar = false
            }
        }
    }

    return isSimilar
}

ロジックはドキュメントそのままでしたが、以下の点が新たにわかりました。

  • threshold は -1 ~ 100 の 整数であること
  • hash を取得する項目の総数に占める、一致した hash の数の割合を一致率 (similarity) としていること

この similarityThreshold を設定できる機能は、ソースを読まないと使うの難しそうですね。ただ、使うことは滅多になさそうです。

まとめ

冒頭の疑問に回答する形でまとめます。

  • hardware hashfingerprint の実体は、 /usr/sbin/dmidecode 等の出力のチェックサムである。
  • Vault は暗号化されていない、パーミッション 600 のファイルである。
  • 構成変更による similarity の差の出方は、アーキテクチャやOSによってかなり異なる。 similarityThresholdのチューンは、結構使い込んでみないと難しいと予想される。

nmap *2hping *3で使われるような、TCP の実装差を利用した特殊な fingerprinting はなくて、割と単純な仕組みになっていることがわかりました。

あとがき

windowslinux のビルドスイッチが見つけられなかったです。悲しい。Golang力が足りませんでした。

参考リンク


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

iPhone X RasPiで外出先で使える高機能なモバイルルーターを自作する

この記事は Akerun Advent Calendar 2021 - Qiita の20日目の記事です。

ハードウェアグループの石井 @ishturk です

弊社フォトシンスが提供しているAkerun入退室管理システム

  • オフィス
  • プライベートサロン
  • シェアスペース
  • 無人のフィットネスジム
  • etc...

と、いろいろなところで使われています。安定したサービスを提供するべく開発部・カスタマーサクセス部で協力しています。

顧客から問い合わせがあった場合、設置先に出向いて調査することもしばしば発生します。

そんなときに使える便利ネットワークデバイスがあったらいいのにな ((=^♀^=))

f:id:photosynth-inc:20211221003203j:plain

こんなこといいな

( ³з³):PCをインターネットに繋げられて、設置している弊社機器に同一ネットワークになるといいな

( ³з³):あ、ネットワークはWi-Fiと有線どっちも

( ³з³):会社の携帯がiPhoneだからiPhoneテザリングがいい

( ³з³):電源とれないこともあるので電池じゃないと

( ³з³):客先に行くのはエンジニアじゃないこともあるから、スクリプト仕込めるといい

レシピ

できたらいいな

RasPiのiPhoneテザリング

Wi-FiはPCからの接続に使用したいので、USBテザリングを選択します。 Apple公式には iTunes が必要と書いています。が、ありがたいことにLinux向けにパッケージ開発されているので、それを使用します。

sudo apt-get install ipheth-utils libimobiledevice-utils ifuse usbmuxd
sudo mkdir /media/iphone
sudo ifuse /media/iphone

何度か抜き挿ししているとiPhone側に 「信頼する」のポップアップが出てくるので選択

eth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.20.10.3  netmask 255.255.255.240  broadcast 172.20.10.15
        inet6 fe80::b3a4:f864:f44a:cbe  prefixlen 64  scopeid 0x20<link>

eth1 として認識されました!

WiFi設定・ルーティング設定

hostapd と dnsmasq をインストール

sudo apt install hostapd dnsmasq

ポートフォワーディング、IPマスカレードなどを設定 このあたりは情報充実しているのでそのあたりを参照ください 有線とWiFiをどちらも使うためにハマりました。その記事はまたどこかで、、、、

www.itmedia.co.jp

qiita.com

また、ルーターとして機能させてるのであれば OpenWrtという選択も良いと思います。RaspberrPi向けにもイメージが配布されており、GUIで設定するだけでネットワークルーターを立ち上げることができます。

[OpenWrt Wiki] Welcome to the OpenWrt Project

今回は、サポート作業用のスクリプトを実行したいという事情があったため、より汎用的なRaspberryPiOSを採用しました。

シャットダウン機能

RaspberryPiは物理スイッチがついておらず、通電で起動しますが、シャットダウンする機能はハードウェア提供されていません。 電源ぶちぎりでもよいのですが、こはちゃんと作りましょう。

今回はスイッチとパイロットランプで実現します。LEDは5Vそのままではとても眩しかったのでPWMで輝度を落としてます。 スイッチは内部プルアップでアクティブロー(スイッチ押下で0)にしてます。

f:id:photosynth-inc:20211221003210p:plain

#!/usr/bin/env python3

import RPi.GPIO as GPIO
import time
import os
from multiprocessing import Process, Condition

gpio_led = 18
gpio_sw = 24

GPIO.setmode(GPIO.BCM)

# LEDピンを出力に設定
GPIO.setup(gpio_led, GPIO.OUT)
GPIO.setup(gpio_sw, GPIO.IN, pull_up_down=GPIO.PUD_UP)
c = Condition()
def led():
    pin = GPIO.PWM(gpio_led, 2000)
    pin.start(0)
    pin.ChangeDutyCycle(5)
    c.acquire()
    c.wait()
    c.release()
def sw():
    while True:
       sw = GPIO.input(gpio_sw)
       if sw == 0 :
          c.acquire()
          c.notify()
          c.release()
          break
       time.sleep(1)

if __name__ == '__main__':
    p1 = Process(target=led)
    p2 = Process(target=sw)
    p1.start()
    p2.start()
    p1.join()
    p2.join()
GPIO.cleanup(gpio_led)
GPIO.cleanup(gpio_sw)
os.system( "sudo shutdown -h now" )
time.sleep(1000)

完成

f:id:photosynth-inc:20211221003203j:plain

LEDとスイッチがぷらんぷらんしているので、このままではまだ持っていくのは難しいですね。 社内に3Dプリンタ(form3)が2台あるので、いい感じにケースを自作しても良さそうです。

https://formlabs.com/jp/


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

やっすいデバッガでnRF52のデバッグしてみる (Jeff Probe編)

この記事は Akerun Advent Calendar 2021 - Qiita の18日目の記事です。

こんにちは。 いとう です。ファームウェアやってます。

普段フォトシンスのお仕事では Seggerの J-Link を使っているのですが、個人の趣味プロジェクトで使用しようとすると少々お高いのでFlirc社のBlackMagic ProbeクローンのJeff ProbeをAmazonで¥1800だったの妻に内緒でブラックフライデーセールのどさくさに紛れて購入してみました。

https://flirc.tv/more/flirc-jeff-probe-bmp-jtag-black-magic-probe

https://www.amazon.co.jp/dp/B07ZK4LFTQ

可愛い小箱で届きます。内容物は Jeff Probe本体と お馴染みのSWDデバッグ用の10ピンリボンケーブル、UART接続に使えるプローブです。USB mini-B ケーブルは付属しません。

f:id:photosynth-inc:20211220112336j:plain
JeffProbe

ものすごくコンパクトです。基板剥き出しなので取扱注意。

セットアップ

ハードウェア

今回は Nordic semiconductor のnRF52832搭載の nRF52 dkボードを使用しました。

f:id:photosynth-inc:20211220112410j:plain
dkboard

書き込むファームウェア

とりあえずのテストなので Zephyr のサンプルに添付されている Lチカ "blinky" を使ってみました。Zephyr環境で dkボード用にビルドします

west build -b nrf52dk_nrf52832  zephyr/samples/basic/blinky

デバッガインストール

デバッガにはgdbを使用していますが arm用をARM社が用意してくれていますのでこちらをダウンロード。ホストはmacOSを使用しました。

https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm/downloads

接続

おもむろにJeff ProbeをmacにUSBケーブルで接続してみましょう。うまく接続できていると /dev/にttyデバイスとしてマウントされます。

バイス名はシリアル番号によって変わるようで私の環境では下記のようなデバイスとして見えていました。 同時にusbmodemが二つありますが数値が若い方を指定するとデバッガとして使用できています。

$ ls /dev/ | grep tty.usbmodem
tty.usbmodem34C775A71
tty.usbmodem34C775A73

接続確認

デバッガを立ち上げて

arm-none-eabi-gdb

繋がっているか確認してみましょう。先程のttyデバイスを指定します

(gdb) target extended-remote /dev/tty.usbmodem34C775A71

下記のコマンドでJeff Probe経由で電源供給ができます。Lチカへのファームウェア書き換えだけであればdkボードに別途電源供給不要でした。

(gdb) monitor tpwr enable

接続してます。

(gdb) monitor swdp_scan
Target voltage: 3.2V
Available Targets:
No. Att Driver
 1      ARM Cortex-M
(gdb) attach 1
Attaching to Remote target
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000176a in ?? ()

J-Linkでは書き換えるROMがイレースされてなければ自動的にイレースしてくれていたんですがgdbはそこまではしてくれないようで毎回 monitor erase_mass してからリセットしないと次のファームウェアは書き込めませんでした。

(gdb) monitor erase_mass
erase..
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program:  
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.

Program received signal SIGSEGV, Segmentation fault.
0x00000000 in ?? ()

loadコマンドでファイルを読み込んでみましょう。

(gdb) load build/blinky/zephyr/zephyr.hex 
Loading section .sec1, size 0x43c8 lma 0x0
Start address 0x1704, load size 17352
Transfer rate: 19 KB/sec, 964 bytes/write.
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program:  
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.

無事にLチカできています。

あとは普通に使えると思うのですが慣れていないとcliは辛いので今回はvscode上からデバッガとして使えるかを確認してみます。

vscodeでのデバッグ

Cortex-Debug

こちらのcoretex-debugを使用するとvscodeをからJ-Linkや他のデバッガを用いてIDEのようにデバッグができます。

https://marketplace.visualstudio.com/items?itemName=marus25.cortex-debug

Cortex-Debug 設定

Jeff ProbeはBlackMagic Probeから派生している製品ですのでservertypebmpを使用するとCoretex-Debugから使用できます。あとは毎回erase_massを実行するようにoverrideLaunchCommandsを使用してデバッガ起動時の処理をオーバーライドしています。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "JeffProbeDebug",
      "cwd": "${workspaceRoot}",
      "executable": "build/button/zephyr/zephyr.elf",
      "request": "launch",
      "type": "cortex-debug",
      "servertype": "bmp",
      "device": "nrf52",
      "BMPGDBSerialPort": "/dev/tty.usbmodem34C775A71",
      "targetId": 1,
      "powerOverBMP": "disable",
      "showDevDebugOutput": true,
      "interface": "swd",
      "armToolchainPath": "/usr/local/bin/",
      "breakAfterReset": false,
      "overrideLaunchCommands": [
        "monitor erase_mass",
        "SoftwareReset",
        "load",
        "SoftwareReset"
      ],
      "postLaunchCommands": [
        "set substitute-path /workspaces/ak /Users/yuito/repo/ak"
      ]
    }
  ]
}

postLaunchCommands はビルドをDockerコンテナの中で実行しているのでパスを差し替えるために行っています。

なぜかファームウェアをダウンロードするたびにProbeの抜き差しをしないといけませんでしたが、これでステップ実行とかブレイクポイントとかも動く立派なデバッグ環境が整えられました。

まとめ

他にも中華系偽J-Link、CMSIS-DAPなどありますがお値段と気軽にAmazonで買えるのでJeffProbe、個人のプロジェクトにはいいのではないでしょうか?

(番外)Jeff Probeのアップデート

手元に届いた時にはv1.6.2だったのですがgithub上で最新バージョンのビルド済みファームウェアバイナリが配布されていたのでアップデートしました。

https://github.com/flirc/blackmagic/releases/tag/v1.6.3

brew install dfu-util
dfu-util --device 1d50:6017 -s 0x00002000:leave -D ./blackmagic.bin

GDB経由でバージョンを確認してみます。

(gdb) monitor version
Black Magic Probe (Firmware v1.6.3) (Hardware Version 0)
Copyright (C) 2015  Black Sphere Technologies Ltd.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

:+1:


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com