😸

EEPROMを使ったKey-Valueストア(NVRAM)の実装

に公開

作りたいものの全体像から参照しています。
https://zenn.dev/takumique/articles/f8057a86ca9d6e

作りたいもの

OTAアップデートを実装する際、OTAアップデートされないデバイス固有の情報(デバイスIDや、デバイスごとに異なる認証情報など)を保存領域が必要になります。メインの記憶域(Raspberry PiならSDカードなど)にパーティションを作っても良いのですが、ハンドリングの明確さ・容易さ(SDカードを変えても変わらない)からEEPROMにKey-Valueストアを作り、使用したいと思います。

動作する全ソースコードをGitHubで公開しています。

ハードウェア

  • RasPi4
  • EEPROM
    • AT24C256(アトメル製の256ビットEEPROM、2.7〜5.5V、I2C接続)
    • モジュールを使いました。Amazonで買えます。
    • データシートはこちらを参照しました。

デバイスドライバ

ターゲット上のデバイスドライバはYocto 5.0のRasPi4ターゲット標準のi2c-devデバイスドライバを使用し、ユーザスペースから制御します。なお、書き込みはターゲットとは別のRasPi3にRasPi OSを入れ、こちらも標準のi2c-devデバイスドライバを使用します。

YoctoでのI2Cのenableはこちらを参照してください。

EEPROMレイアウト

EEPROMは書き込みはページ単位、読み出しはワード単位となります。AT24C256では1ページが64ワード、1ワードが8bitなので、1ページは64バイトになります。(オペレーションではワード単位で書き込みできますが、内部的にはページを書き換えています)

ということで、64バイトにアラインする形でレイアウトします。
まず最初の1ページはヘッダ領域とします。またKey-ValueペアのKeyは32バイトに保存し、最大64個とします。残りをデータ領域とします。

実装

それでは作っていきます。ソースコード用のGitリポジトリ(nvram)と、Yoctoで使用する際のリポジトリは分離する構成とします。このページではnvram部分の解説をします。

JSONファイルからKey-Valueペアを取得し、すべて書き込むnvram-write、Keyを指定してValueを取得するnvram-read、これらの共通する処理を実装するlibnvramの3つのコンポーネントを実装します。

まずnvramリポジトリを作成し、nvram-write、nvram-read、libnvramディレクトリを作成します。

libnvram

Cで書きました。

EEPROMデバイス周り

ほかのEEPROMデバイスを使用するかもしれないので、共通したEEPROMへの読み書き関数はeeprom.hに定義しておきます。それを特定のEEPROMデバイス(ここではAT24C256)専用のコードで実装します。これでincludeするヘッダとリンクするCコードを切り替えるだけで、呼び出し元としてはデバイスの差異を意識せずEEPROMへの読み書きを呼び出せることを意図しています。

eeprom.h(抜粋)
int eeprom_i2c_open(const char *device);
void eeprom_i2c_close(int i2c_fd);

int eeprom_page_write(int i2c_fd, uint16_t addr, char *buf);
int eeprom_page_read(int i2c_fd, uint16_t addr, char *buf);

i2c-devドライバを使用したEEPROMデバイス(AT24C256)へのwrite/read関数を用意します。まずヘッダでI2Cアドレスを指定します。このデバイスは電源投入時のA0/A1/A2のH/L状態でI2Cアドレスを変更できます。モジュールのピンヘッダをすべて抜いたのでアドレスは0x50になります。

at24c256.h(抜粋)
#include "eeprom.h"

#ifndef A0
#define A0 0
#endif /* A0 */

#ifndef A1
#define A1 0
#endif /* A1 */

#ifndef A2
#define A2 0
#endif /* A2 */

#define EEPROM_ADDR ((0x05 << 4) | (A2 << 2) | (A1 << 1) | A0)

I2Cまわりの実装はi2c-devの普通の使い方になりますので説明は割愛します(at24c256.hのeeprom_i2c_open/close/write/read関数参照)。このEEPROMデバイス固有の処理としてページの読み書きをeeprom_page_writeeeprom_page_readに実装していきます。

ページを書き込むには、2バイトのEEPROMアドレスに続いてページ分のデータを書き込みます。簡単のためeeprom_page_write_data_tを作成し、値をセットしてchar配列にキャストしてI2Cに書き込んでいます。また書き込んだ後にデータシートにあるWrite Cycle Time(5Vの場合10ms)だけwaitしています。

ページを読み込むには、2バイトのEEPROMアドレスのみ書き込むとEEPROMデバイスがページサイズ分のデータを返してくるので読み込んでいきます。

at24c256.h(抜粋)
typedef struct {
    char addr[EEPROM_ADDR_SIZE];
    char data[EEPROM_PAGE_SIZE];
} eeprom_page_write_data_t;

int eeprom_page_write(int i2c_fd, uint16_t addr, char *buf) {
  eeprom_page_write_data_t page_write_data;
  page_write_data.addr[0] = (char) ((addr >> 8) & 0xff);
  page_write_data.addr[1] = (char) (addr & 0xff);
  memcpy(page_write_data.data, buf, EEPROM_PAGE_SIZE);
  int ret;
  ret = eeprom_i2c_write(i2c_fd, EEPROM_ADDR, sizeof(eeprom_page_write_data_t), (char *) &page_write_data);
  if(ret < 0) {
    return ret;
  }
  // wait for write cycle time
  struct timespec write_cycle = {
    .tv_sec = 0,
    .tv_nsec = WRITE_CYCLE_TIME_MS * 1000000,
  };
  ret = nanosleep(&write_cycle, NULL);
  if(ret < 0) {
    return EEPROM_ERR;
  }
  return EEPROM_SUCCESS;
}

int eeprom_page_read(int i2c_fd, uint16_t addr, char *buf) {
  int ret;
  char _addr[] = {
    (char) ((addr >> 8) & 0xff),
    (char) (addr & 0xff),
  };
  ret = eeprom_i2c_write(i2c_fd, EEPROM_ADDR, EEPROM_ADDR_SIZE, _addr);
  if(ret < 0) {
    return ret;
  }
  ret = eeprom_i2c_read(i2c_fd, EEPROM_ADDR, EEPROM_PAGE_SIZE, buf);
  if(ret < 0) {
    return ret;
  }
  return EEPROM_SUCCESS;
}

EEPROMレイアウト・Key-Valueストア周り

ここまででEEPROMのページの読み書きができるようになったので、EEPROMレイアウト・Key-Valueストアを実装していきます。

NVRAMのレイアウトはこのように実装しました。デバイス自身は256kbitなのですが、このうち最初の128kbitのみ使用します。これは現在それほどサイズが必要ないことと、将来的に運用中の更新などで2面持ちしたい場合があるかもしれない為の措置です。

nvram.h(抜粋)
#define NVRAM_SIZE (128 * 1024 / 8)  // 128K

#define NVRAM_MAGIC_SIZE (16)
#define NVRAM_N_DATA (64)
#define NVRAM_KEY_SIZE (24)

typedef struct {
  char magic[NVRAM_MAGIC_SIZE];
  uint32_t index_off;
  uint32_t data_off;
  char pad[40];
} nvram_head_t;  // 64 bytes

typedef struct {
  char key[NVRAM_KEY_SIZE];
  uint32_t off;
  uint32_t size;
} nvram_index_t;  // 32 bytes

#define NVRAM_DATA_SIZE (NVRAM_SIZE - sizeof(nvram_head_t) - sizeof(nvram_index_t) * NVRAM_N_DATA)

typedef struct {
  nvram_head_t head;
  nvram_index_t index[NVRAM_N_DATA];
  char data[NVRAM_DATA_SIZE];
} nvram_t;

書き込みは、上記nvram_tをメモリ(スタックに置くには大きすぎるのでヒープに)上に構築した後、一度に連続してEEPROMに書き込んでいきます。

読み出しは、nvram_index_tをシーケンシャルに読み込んで、指定されたKeyと一致したらoffsizeを使用してデータ領域から値を確保します。

nvram-read

指定された引数から、nvram_opennvram_readnvram_closeするだけのほぼラッパーです。

nvram-write

指定された引数から、JSONファイルをパースし、nvram_tを構築し、書き込んでいます。JSONファイルの読み出しにはnlohmann/jsonを使用しています。なので、nvram-writeだけC++です。

使い方

ビルド

普通のcmakeなので以下のようにビルドします。

mkdir build
cd build
cmake ..
make

これでbuild下にlibnvramnvram-readnvram-writeディレクトリが作成され、その中に実行ファイルがビルドされます。以下のように実行できます。

nvram-readコマンド
LD_LIBRARY_PATH=<libnvramへのフルパス>:$LD_LIBRARY_PATH ./nvram-read <キー>
nvram-writeコマンド
LD_LIBRARY_PATH=<libnvramへのフルパス>:$LD_LIBRARY_PATH ./nvram-write -c <jsonファイルへのパス>

Discussion