1-Wire

1-Wireに関する備忘録他。

コンテンツ

1-Wireとは

maxim社が持つ登録商標が1-Wire。 I2Cに近いハードウェアで通信するが、I2Cと違い信号線が1本だけでよく、 電源を含めても2本だけで通信可能(I2Cは電源を含め最小4本必要)。 通信速度は10kbps程度。 SPIやI2Cほどではないがクロック速度は可変できる(1bitの幅は固定だが、次の1bitまでの時間は自由)。


メリット


デメリット

ハードウェア

1-WireはI2Cのようにプルアップされたラインをオープンコレクタで制御する。 基本的に電源は5V系、プルアップは4.7k、それとは別に強制的にHIGHにするためのPchFETが必要。 でも3.3V系マイコンで動かないことはない(長距離での安定性が犠牲になるが、短距離では問題ないようだ)。

この画像はmaximの温度センサの推奨回路。 1-WireはGNDとデータライン(DQ)の2本だけで接続できるが、VDDを使用しない場合で大電力が必要な場合(AD変換、EEPROMへの書き込み 等)は、データラインに給電する必要がある。 これはPchFET等を使うのがいい。 でもPchFETとかは結構高コストになるので、マイコンのGPIOをHIGHにして給電する。 ただそうするとインピーダンスが高すぎるので、ダイオードで給電方向にしか流れないようにする必要がある。 給電しない場合は端子をLOWにすればLowZ HIGHとHiZを切り替えられる。 しかしダイオードの順方向電圧降下分だけ電圧が下がるので、効果の程は不明。 そもそも3.3V系じゃデバイスの動作電圧を下回っちゃうし…。
PICマイコンではI2Cを実装する場合はピンをLOWに固定して、HIGHはピンを入力モードにしてHighZに、LOWはピンを出力モードにしてLowZに、という感じで切り替えていた。 1-Wireの電源も同じようにピンをHIGHに固定して、給電が必要な場合は出力モードにして、それ以外は入力モードにする、みたいな使い方ができる。 というかそもそもそれをやるなら給電端子は必要なくて、データ端子をHIGHに吊ってから出力モードにしてやることもできる。 STM32の場合でも同じようにできそうな気がするけど、低レベルレジスタを直接叩く必要があるのであまりきれいなやり方じゃない。 久しぶりにPICが羨ましくなってきた…

信号のタイミング

1-WireはI2CやSPI以上に信号のタイミングが厳格。 これはクロック同期ではあるが、クロックとデータを1本の信号に乗せていることに由来する。 さらに1-Wireデバイスは内部に持っているクロックが厳密ではないため、デバイス側にはタイミングのゆらぎがある。

以下に何枚か示すタイミングチャートはあまり正確ではないことを予め断っておきたい。 タイミングはmaximの温度センサであるDS18B20のデータシートを参考にしている。 以下の説明で使用するタイミングもこれを使う。 他のデバイスではタイミングが変わるかもしれない
チャートの見方は説明しなくてもわかると思う。ロジックを01で表示してるだけ(見やすいように0と1ではなく0と0.9だけど)。 横軸が時間で、単位はマイクロ秒。 青がマイコンから出力した波形、赤がデバイスから出力した波形、緑がそれらをANDした波形、という感じになっている。 ロジックアナライザとかオシロスコープで見た時には緑の波形になる。 もし青の波形になるならデバイスが接続されていないので、回路が正常か確認する必要がある。

リセットパルス

1-Wireではコマンドの前にリセットを行う必要がある。 リセット後に自分のIDが指定されたら、もしくは全てのデバイスに対してのコマンドが発行されたら処理を行う。
リセットをするためにはDQを480usec以上の間LOWに保つ必要がある。 これによりデバイスの寄生容量が放電され、中身がリセットされる(全てがリセットされるわけじゃない、温度計の変換値とかは保存されている)。
そしてリセット後にデバイスは60-240usecのLOWパルスを出力する。 マイコンからリセットパルスを出力し、DQを立ち上げてから50usecくらい待ってDQを見るとデバイスが存在するか確認できる。 もしもHIGHに保たれていたらデバイスは存在しない。LOWなら少なくとも1つのデバイスは存在している。 ここでいう"少なくとも1つ"が使いたいデバイスかを確認することはできない。 温度センサが1つしか接続されてないなら問題ないけど、設定を保存するためのEEPROMが接続されていたりすると、どれかが壊れていても検出できない。

1bit Write

0を1bit分書き込むにはDQを60usec以上の間LOWに保つ。 1を1bit分書き込むにはDQを1usec以上15usec未満の間LOWに保つ。その後45usecの間ディレイする。 デバイスはDQが立ち下がってから15-60usecの間にDQを読んで、それがLOWなら0と認識し、HIGHなら1と認識する。


1bit Read

1bit読み込むにはDQを1usec以上15usec未満の間LOWに保つ。その後45usecの間の任意のタイミングでDQを読む。 DQがLOWなら0、HIGHなら1と認識する。


WriteでもReadでも上記の手順は1bitのみなので、1バイト(もしくはそれ以上)を転送したければ複数回繰り返す必要がある。 デバイスのIDは64bitだが、64bit変数で扱うより8bit変数の配列で扱ったほうが楽だと思うので、とりあえず8bitのWrite/Read関数を作っておくと良い。

ところで、上記の1bitR/Wの説明を読んで、勘の良い人なら気がつくかもしれないけど、4分の3の割合で"DQを1usec以上15sec未満の間LOWに保つ"という処理が出てくる。 Writeを上手く工夫すれば1個の関数で1bitのWriteとReadを処理できる。 それどころかその関数のラッパー関数を1個作るだけで8bitのWriteや8bitのReadが可能になる。

1bitR/Wの手順としては

  1. DQを立ち下げる
  2. 5usec待つ
  3. ?書き込む値が1なら:DQを立ち上げる
  4. 25usec待つ
  5. DQを読んで変数に保存しておく
  6. 30usec待つ
  7. ?書き込む値が0なら:DQを立ち上げる
  8. 10usec待つ
  9. DQを読んだ時の変数を戻り値として関数を抜ける
という感じになる。
1バイトを書き込みたいならこの処理を8回呼び出すラッパー関数を書けばいい。 1バイトを読み込みたいならその関数に0xFFを渡して呼び出せばいい。

温度センサ

とりあえず1-Wireを実際に使ってみて動作を確認することにする。 ここで使用するのはDS18B20という温度センサ。 トランジスタみたいな3端子のTO-92パッケージになっている。 購入先は秋月電子か、 ストロベリーリナックスが簡単。 秋月では生のデバイスしか売っていないが、鉛フリー品が買える。 ストリナは生のデバイスの他に、1m,5m,10mの配線を接続しステンレス管にエポキシで封入した、 そのまま温度計に使えるモジュールがある。 ストリナのアセンブリ品は簡単に使える反面、2線式なのでStrongPullupが必須となるので注意。 できれば生のデバイスもいくつかあると動作確認等に便利だと思う (あたりまえだけど、複数のデバイスが存在する状態のデバッグには複数のデバイスが必要)。

プログラムを簡単に開発(=ラピッドプロトタイピング)するにはArduinoやmbedを使ったほうが良いと思うし、 新しいセンサを試すにはそれらのボードを使ったほうが楽なんだけど、今回は普段使い慣れているSTM32を使うことにする。 しかし1-Wireを1個動かす程度ならPICやArduinoで十分なので、そちらを使ってもいい。 GPIOの操作のような低レベルな部分を除けばほとんどのプログラムを使いまわせるはず。

今回はSTM32の中でもF1を使うことにする。またHALライブラリには対応しない。 HALライブラリは今までのStdLibに比べて細かい操作ができないので、直接レジスタを書いたり読んだりする必要があると予想したからだ。 いずれはHAL対応にする必要があると考えるが、HALが対応してくれない限り僕も対応できない。

1-Wireアクセスプログラム

まず最初に使用するGPIOの設定を作っておく必要がある。 もしも他のGPIOに移動する必要があればここを書き換えるだけでいい。 また、今回はStrongPullupを使用するため、そのためのGPIOも用意する。 StrongPullupが必要なくなればOW_USE_PWをコメントアウトすればいい。

#define OW_USE_PW

#define OW_DQ_Port  GPIOA
#define OW_DQ_Pin   GPIO_Pin_2
#define OW_DQ       OW_DQ_Port, OW_DQ_Pin

#ifdef OW_USE_PW
#define OW_PW_Port  GPIOA
#define OW_PW_Pin   GPIO_Pin_3
#define OW_PW       OW_PW_Port, OW_PW_Pin
#endif
          

次にGPIOの初期化を行う

void OW_Init(void) {
    GPIO_InitTypeDef GPIO_InitStructure;

    GPIO_InitStructure.GPIO_Pin   = OW_DQ_Pin;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_OD;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz;
    GPIO_SetBits(OW_DQ);
    GPIO_Init(OW_DQ_Port, &GPIO_InitStructure);

    #ifdef OW_USE_PW
    GPIO_InitStructure.GPIO_Pin   = OW_PW_Pin;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz;
    GPIO_ResetBits(OW_PW);
    GPIO_Init(OW_PW_Port, &GPIO_InitStructure);
    #endif
}
            
DQ端子はOpenDrainモードで初期化しておく。 StrongPullup端子はPushPullで初期化し、外付けのダイオードで給電方向にしか流れないようにしておく。 1-Wireのリセットパルスは各コマンドの前に必ず送出する必要が有るため、ここでは送出しない。 リセットという名前はちょっとまぎらわしい。

次に1bitのR/Wと1byteのR/Wを作る。 前述のとおり、1-Wireは送信と受信の処理を同一にすることができるので、それを積極的に利用する。

extern void delay_us(uint16_t microsecond);
#define DELAY_US(time) delay_us(time)

#define WRITE_LOW()     GPIO_ResetBits(OW_DQ)
#define WRITE_HIGH()    GPIO_SetBits(OW_DQ)
#define READ()          GPIO_ReadInputDataBit(OW_DQ)

#define SET_SP()        GPIO_SetBits(OW_PW)
#define RESET_SP()      GPIO_ResetBits(OW_PW)

static uint8_t TxRx1bit(uint8_t bit) {
    WRITE_LOW();  DELAY_US(5);
    if (bit) { WRITE_HIGH(); }
    DELAY_US(10);
    bit = READ(); DELAY_US(45);
    WRITE_HIGH(); DELAY_US(10);
    return(bit);
}

static uint8_t TxRx1byte(uint8_t data) {
    uint8_t ret = 0;

    ret |= TxRx1bit(data & 0x01) << 0;
    ret |= TxRx1bit(data & 0x02) << 1;
    ret |= TxRx1bit(data & 0x04) << 2;
    ret |= TxRx1bit(data & 0x08) << 3;
    ret |= TxRx1bit(data & 0x10) << 4;
    ret |= TxRx1bit(data & 0x20) << 5;
    ret |= TxRx1bit(data & 0x40) << 6;
    ret |= TxRx1bit(data & 0x80) << 7;

    return(ret);
}
            
delay_us関数はかなり厳密なタイミングを要する。 プログラムループで待つ処理を作る場合は予め正確なタイミングになっているか確認しておくほうがいいだろう。 今回は説明を省くが、TIMモジュールを利用してほぼ正確に待つ関数を作った。 これなら関数呼び出しの処理時間分がズレる程度に収めることができる。

後は細々とした関数を作れば終わりだ。

bool OW_Reset(void) {
    bool presence = false;

    RESET_SP();

    WRITE_LOW();
    DELAY_US(500);
    WRITE_HIGH();
    DELAY_US(70);

    if (!READ()) { presence = true; }
    DELAY_US(500);

    return(presence);
}

void OW_Write(uint8_t data) { TxRx1byte(data); }
uint8_t OW_Read(void)       { return(TxRx1byte(0xFF)); }

bool OW_CalcCRC(const uint8_t *buf, uint8_t length) {
  register uint8_t dat, crcsr;
  register int i, j;

  for (crcsr = i = 0; i < length; i++) {
    dat = buf[i];
    for (j = 0; j < 8; j++) {
      if ((dat ^ crcsr) & 1) {
        crcsr ^= 0x18;
        crcsr >>= 1;
        crcsr |= 0x80;
      } else {
        crcsr >>= 1;
      }
      dat >>= 1;
    }
  }

  return(crcsr == 0);
}

#ifdef OW_USE_PW
void OW_StrongPullupOn(void)  { SET_SP(); }
void OW_StrongPullupOff(void) { RESET_SP(); }
#endif
            

本来はデバイスIDを探索するための処理等も1-Wireライブラリに組み込むべきだが、 IDを探索するのはかなり面倒な処理になるし、IDがわかってもIDからデバイスの機能を推測し、 推測した機能から必要な処理を呼び出すのはかなり複雑になってしまう。 温度センサ程度しか使用しないなら最初にIDを調べておいてマイコンのROMに焼くか、 デバイスが1個しかなくそもそもIDを知る必要がない場合のほうが多いと考えたため、今回は省略した。 マッチROMコマンドやスキップROMコマンドは1-Wireライブラリに書くべき処理であるが、 それもデバイス側の処理に任せることにして省略した。

温度センサアクセスプログラム

以下にDS18B20にアクセスするための関数を示す。

#include "DS18B20.h"

extern void delay_ms(uint64_t millisecond);

bool DS18B20_ConvertTemperature(const uint8_t *Address) {

    if (!OW_Reset()) { return(false); }

    if (Address) {
        OW_Write(0x55); // MATCH ROM COMMAND 
        OW_Write(Address[0]);
        OW_Write(Address[1]);
        OW_Write(Address[2]);
        OW_Write(Address[3]);
        OW_Write(Address[4]);
        OW_Write(Address[5]);
        OW_Write(Address[6]);
        OW_Write(Address[7]);
    } else {
        OW_Write(0xCC); // SKIP ROM COMMAND 
    }

    OW_Write(0x44); // CONVERT TEMPERATURE 

#ifdef OW_USE_PW
    OW_StrongPullupOn();
    delay_ms(1000);
    OW_StrongPullupOff();
#endif

    return(true);
}

bool DS18B20_ReadScratchpad(const uint8_t *Address, uint8_t *Scratchpad) {
    
    if (!OW_Reset()) { return(false); }

    if (Address) {
        OW_Write(0x55); // MATCH ROM COMMAND 
        OW_Write(Address[0]);
        OW_Write(Address[1]);
        OW_Write(Address[2]);
        OW_Write(Address[3]);
        OW_Write(Address[4]);
        OW_Write(Address[5]);
        OW_Write(Address[6]);
        OW_Write(Address[7]);
    } else {
        OW_Write(0xCC); // SKIP ROM COMMAND 
    }

    OW_Write(0xBE); // READ SCRATCHPAD 

    Scratchpad[0] = OW_Read();
    Scratchpad[1] = OW_Read();
    Scratchpad[2] = OW_Read();
    Scratchpad[3] = OW_Read();
    Scratchpad[4] = OW_Read();
    Scratchpad[5] = OW_Read();
    Scratchpad[6] = OW_Read();
    Scratchpad[7] = OW_Read();
    Scratchpad[8] = OW_Read();

    return(true);
}
この関数では温度変換を開始して、必要ならStrongPullupを有効にして1秒待ってからStrongPullupを無効にしている。 この中で使用しているdelay_ms関数は温度変換が終了するのを待つためであり、 本来必要な待ち時間よりも3割ほど長く設定しているため、さほどの正確性は必要ではない。 また変換開始の時間を記録しておき、変換時間が過ぎたらSPを無効にする、という処理を作れば1秒もの長い時間ブロックする必要もない。

温度を読み込むための手順としては、OnvertTemperature関数を呼んでから、ReadScratchpadを呼べばいい。 それぞれのAddressはデバイスIDの配列を与えてやる。 もしもAddressがnull pointerならスキップROMコマンドを使用する。 スキップROMコマンドは全てのデバイスに対して操作を行うことができるが、 温度センサ以外のデバイスが接続されていたりとか、 温度センサが複数個接続されていたりする場合には正常に動作することができない。