LinuxでI/O制御

電気系の授業に関連してLinuxのI/O周りをいじってみました。その時得たノウハウのメモ。
途中、ドライバの辺りは以下のページをとても参考にさせていただきました。 ありがとうございます。
東北学院のロボット開発工学のページ
☆あともちろん、授業や演習で三田先生にお世話になりました。授業での実演はいつも面白かったです

何をしたい?何が出来る?

電子回路は、ICや抵抗によってある入力に対し好きな出力を出すことができます。 これを使うと、LEDを点滅させたり、ロボットの手足を制御したりできます。 (個人的には鉄道模型の制御が思い出深いです)
大規模な回路ではICだけで組むのは大変なので、マイコンを使ったりします。 パソコンの原形とも言えるマイコンでは、入出力は全て電気信号です。 このため、ある入力に対しある出力をする、と言ったプログラムは簡単に書くことができます。 実際、現代のちょっと複雑な機械にはほとんどマイコンが入っていて、 炊飯器の温度調整から車のエンジンまでマイコンのプログラムがコントロールしています。
では、マイコンが発展したパソコンではどうでしょうか? パソコンは電子回路のお化けです。 キーボードに"a"と打ったデータ、内部で1+1を計算する、ディスプレイに"Hello"と表示する、 など全ての作業が電気的な信号で行われています。 しかし、現代のパソコンは基本的に入力はキーボードやネットワーク、 出力はディスプレイかネットワークばかりで、外部の回路と接続するのはあまり考えられていません。 とはいえ、全く用意されていないわけではなくて、 「ポート」と呼ばれるコネクタ類から電気信号を入れたり出したりできます。 一度出力が出来れば、あとはパソコンの威力で簡単に複雑な制御をすることができます。 パソコンをインターネットのサーバにすれば、ブラウザからクリックすることで 世界中からモータや機械をコントロールできます。 (頑張ればエアコンの制御くらいできると思う) また、入力が出来れば、外部の回路からパソコンをコントロールすることができます。 普通のプログラムに出来ることなら、外部のスイッチを引き金にして そのプログラムを起動するようにすればいいのです。 例えば、スイッチを押すとメールを発送する、なんて事も出来ます。 実用性はともかく、インパクトはなかなかあると思います。

LEDを点けてみる

難しいことは置いといて、パラレルポートからLinuxでLEDを光らせてみます。

必要なもの

回路の準備

パラレルポートの特徴は、信号を送る時に何の通信もしなくてもよい事です。 このため、以下のようにLEDと抵抗を付けるだけで信号を受けることができます。

番号は実際のポートに書いてあるほか、 Googleで検索 すればたくさん見付かります。
ポートとの接続方法ですが、この程度なら直接抵抗やLEDの足をポートに挿してしまえばいいと思います。 なお、LED程度の負荷であれば、外部電源は必要ありませんが、ショートはさせないように気をつけて下さい。 (電流は15mAまでです。これ以上流すとポートが燃える可能性があります)

プログラム

まず、BIOSでパラレルポートのIOアドレスを確認しておきます。(通常は378番) 同時にパラレルポートが有効になっている事を確認して下さい。 このアドレスは、以下のOUT_PORT定数にセットします。
プログラムは以下の通りです。
#include <linux/types.h>
#include <linux/config.h>
#include <asm/system.h>
#include <asm/io.h>
#define OUT_PORT 0x378 /* 出力ポート。0xは16進数を示す */

int main(void){
  int i;
  ioperm(OUT_PORT, 8, 1); /* 初期化(ポートをいじるという宣言) */
  for(i = 0; i < 5; i++){
    outb(0x00, OUT_PORT); /* 2-9番の全部のピンにLを出力 */
    sleep(1);
    outb(0xff, OUT_PORT); /* 2-9番の全部のピンにHを出力 */
    sleep(1);
  }
  return 0;
}
コンパイル後、rootで実行して下さい。(一般ユーザだとSegmentation Faultが起こります) 1秒おきにLEDが点滅すれば成功です。

解説

パラレルポートは、2番から9番がdataピンとなっていて、これをプログラム中ではまとめて数字で表現します。 ビット順序は2番が下、9番が上です。 例えば2番がH・3-9番がLなら1、3番と4番だけがHなら6です。最も小さい数は0、大きい数は全てHの時の255です。 中途半端なようですが、これは16進数で表わすと00-ffとなりすっきりします。 このため、以下数値は16進数で表わします。
このビットをまとめた00からffの範囲の数字は、以下のようにするとパラレルポートに出力されます。
outb(数字, ポート番号);
一度出力された値は、別の数値を出力しない限りそのままです。 通常出力は一瞬で終わるので、変化するパターンを出力させる場合は適当にsleepさせて下さい。

スイッチの入力を受けてみる

次に、外部からの入力を受けてみます。パラレルポートはたくさんのピンがありますが、 この中には入力専用、出力専用、共用のものがあります。 上に用いた2-9番のピンは共用なので出力にも使えるのですが、モードの切り変えに失敗すると 過大な電流が流れてパソコンを壊す恐れがあるので、今回は入力専用のピンを用いることにします。

必要なもの

以上に加え、専用のコネクタを用意した方がいいかもしれません。 コネクタはプレス用ではなく、ハンダ付け用のものを選びましょう。

回路の準備

パソコンに挿すピンを間違えないよう気をつけましょう。 間違えるとそれなりに悲惨な結果になります。

抵抗たくさんとコンデンサはチャッタリング(スイッチの切り変え時のノイズ)防止のために入れました。 もっとちゃんと処理するにはシュミットトリガを入れたりしましょう。 逆に面倒なら、10kの抵抗以外は省いてしまっても動きます。

プログラム

先程のパラレルポートのIOアドレスに1加えたものをIN_PORTに設定して下さい。
#include <stdio.h>
#include <linux/types.h>
#include <linux/config.h>
#include <asm/system.h>
#include <asm/io.h>
#define IN_PORT 0x379 /* 入力ポート。先程の出力ポート+1 */

int main(void){
  int i;
  char c;
  ioperm(IN_PORT, 8, 1); /* 初期化 */
  c = outb(IN_PORT); /* 2-9番の全部のピンにLを出力 */
  printf("in : %d\n", c);
  return 0;
}
先程と同様、rootで実行します。 スイッチの状態を変えて、プログラムを実行してみて下さい。 出力される数値が変われば成功です。

解説

パラレルポートで気軽に使えそうな入力ピンは10,11,12,13,15番などがあります。 これらは本来はプリンタの状態を取得するもので、色々な名前が付いていますが、あまり気にしなくてもいいです。 入力されたデータは以下のように取得できます。
c = inb(ポート番号);
ポート番号は先程のもの+1です。得られるのは0x00-0xffの数値でが、入力ピンとの対応は以下のようになっています。
ピン番号ビット(0から)ピンの名前
106ACK
117BUSY
125PE
134SLCT
153ERR
今回は12番を変えたので、数値は2^5、つまり16変わるはずです。

ドライバを書いてみる

通信部分は分離しよう

以上のようにパラレルポートへのIOが可能になったわけですが、入出力は16進数で行っているため、ちょっと複雑な作業をするにもすぐビット演算が必要になります。また、クロックを用いた通信を行う場合はデータの入力とクロックの出力を交互に行い、 しかも入力データは数クロック分を演算しなければ数値として使えないため、作業はさらに複雑になります。
こういう一連の作業は関数にしてしまうのが常套手段です。返り値は構造体にしておけば、一回の関数呼び出しで通信を行い、外部のたくさんのデータを得られ、プログラムは大変読み易くなります。

なぜドライバか

上のように通信部分を分離すれば、実用上は十分なプログラムが書けます。また、同じ機器を使った異なるプログラムに対しても、通信部分は共通にできます。 でも、今回は実験ということで、これをドライバとして登録してみました。
ドライバを登録するメリットは何でしょうか? それは、一般ユーザー権限でポートを使えるようになり、安全性が高まる点です。 今までのプログラムは、全て実行にroot権限が必要になります。 しかし、root権限では何でも出来てしまうので、プログラム側のバグによって予期しない出力をしてしまう可能性もあります。あるいは、パラレルポート以外のアドレスを読み書きしようとした場合、アドレスによっては簡単にハングアップしてしまいます。
ドライバを登録すると、「ドライバファイル」というものができます。 これは普通のファイルと同じように読んだり書いたりすると、実際のハードウェアにアクセスし、場合によってはシリアル通信をしてデータを取得したり、出力したりできます。 これは設定によって一般ユーザでも読み書きできます。つまり、「このポートにこうやってアクセスする」という手続きをいわば「安全な手続き」として登録することで、一般ユーザにも許可するわけです。この仕組みはOSが行うものですが、最近のOSでは"Loadable Module"として再起動やカーネル再構築無しで、簡単にドライバの登録を行うことができます。

ドライバの書き方

まずはカーネルのバージョンと等しいヘッダファイルを入手して下さい。インストール後カーネルをアップデートした場合、正しくコンパイルが出来ない可能性があります。
さて、ドライバです。
/* module prototype */
#define MODULE
#define __KERNEL__

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/fs.h>
#include <linux/string.h>
#include <asm/uaccess.h>
#define DEV_MAJOR 60

/* 
 * このあたりでIO関連のヘッダもincludeしておく
 */


/* 
 * オリジナルのsleep関数。
 * モジュール内ではsleep()かは使えないが、
 * カーネル内の関数が直接使える。
 */
void my_sleep(void){
  schedule_timeout(1000 * 1000);
}

static char *devname = "mylp0";
static char inbit, outbit;

MODULE_PARM(DEV_MAJOR, "i");
MODULE_PARM(devname, "s");

/* open関数。名前は好きに決めてよい */
static int mylp0_open(struct inode *inode, struct file *file){
  /*
   * open時にしたいことを書く
   * 例えばさっきのioperm(ポートの初期化)とか
  ioperm(OUT_PORT, 8, 1);
   */
  MOD_INC_USE_COUNT;
  return 0;
}

/* close関数 */
static int mylp0_close(struct inode *inode, struct file *file){
  /*
   * close時にしたいことを書く
   */
  MOD_DEC_USE_COUNT;
  return 0;
}

/* write関数 */
static int mylp0_write(struct file *file, const char *buff, size_t count, loff_t *pos){
  /* 
   * buff[]にwriteされたデータが入っているので、
   * これを用いてやりたいことをする
   * 例えば、buff[0]を送信
   outb(buff[0], OUT_PORT);
   */
  return 0;
}

/* read関数 */
static int mylp0_read(struct file *file, char *buff, size_t count, loff_t *pos){
  /* 
   * readさせたいデータを用意して、最後のcopy_to_user関数で
   * 呼び出し元のメモリ(buff)に渡す
   * もちろん、ポートから読んだデータを送ってもよい。
   */
  static ca = "Hello, i'm driver.";
  /* 19は送信バイト数 */
  copy_to_user(buff, ca, 19);
  /* 送信したバイト数を返す */
  return 19;
}

  /* 
   * これは、ドライバの情報を持つ構造体。
   * 右側がメンバ名。
   * ここに関数を登録することで、そのデバイスをreadやwriteした時の動作が決まる
   * 対応する関数が無い時はNULLを入れておく
   *
   * 関数へのポインタがちょっと見にくいけど、やる事は簡単。
   * 例えば、loff_t (*llseek) (struct file *, loff_t, int);ってのは
   * llseekっていうメンバは、(struct file *, loff_t, int)を引数に取って
   * loft_tを返す関数が入ってるんだな、て風に読む。
   */
static struct file_operations mylp0_fops = {
  NULL, //	struct module *owner;
  NULL, //	loff_t (*llseek) (struct file *, loff_t, int);
  mylp0_read,  //	ssize_t (*read) (struct file *, char *, size_t, loff_t *);
  mylp0_write, //	ssize_t (*write)(struct file *, const char *, size_t, loff_t *);
  NULL, //	int (*readdir) (struct file *, void *, filldir_t);
  NULL, //	unsigned int (*poll) (struct file *, struct poll_table_struct *);
  NULL, //	int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
  NULL, //	int (*mmap) (struct file *, struct vm_area_struct *);
  mylp0_open, //	int (*open) (struct inode *, struct file *);
  NULL, //	int (*flush) (struct file *);
  mylp0_close, //	int (*release) (struct inode *, struct file *);
  NULL, //	int (*fsync) (struct file *, struct dentry *, int datasync);
  NULL, //	int (*fasync) (int, struct file *, int);
  NULL, //	int (*lock) (struct file *, int, struct file_lock *);
  NULL, //	ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
  NULL, //	ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
  NULL, //	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
  NULL //	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
};

 /*
  * デバイスの初期化関数 
  * これは名前を変えてはいけない
  */   
int init_module(void){
  /* 先程の構造体と、名前と、デバイス番号を登録 */
  register_chrdev(DEV_MAJOR, devname, &mylp0_fops);
  return 0;
}

 /*
  * デバイスの開放時に呼ばれる関数>
  * これは名前を変えてはいけない
  */   
void cleanup_module(void){
  unregister_chrdev(DEV_MAJOR,devname);
};
要はいろんな関数を記述して、それを構造体に入れ、それをさらに init_module()のregister_chrdev()で登録することでデバイスが動くようになります。 この構造体の定義などはカーネルのバージョンによって頻繁に変わっているようです。 とはいっても大規模な変更はあまり無いと思います。 コンパイルがうまくいかない時は、/usr/include/linuxあたりのヘッダで定義を確認してみて下さい。

ドライバの登録、削除

登録する時は以下のコマンドを実行して下さい。
$ gcc -c mylp0.c -Wall -Wstrict-prototypes -O -pipe -march=i486
# mknod /dev/mylp0 c 60 0
# chmod 755 /dev/mylp0
# insmod mylp0
です。コンパイルして、デバイスファイルを作って、パーミッション変えて 登録します。 60というのはデバイス番号で、もし使われているようなら別の数字を入れて下さい。
こうして追加したドライバは、再起動すると開放されますが、明示的に削除する時は以下のようにします。
# rmmod mylp0
なお、デバイスファイルは再起動してもそのまま残っています。これはrmで消せます。

ドライバを使う

登録したドライバは、先にも述べた通り普通に読み書きできます。 また、ドライバからは一応char型の配列が返っていますが、要は連続したバイト列なので 適当にcastしてやれば構造体も読み書きできます。
...
  int fd;
  char str[100];
  /* /dev/mylp0を読み書き可でオープン */
  fd = open("/dev/mylp0", O_RDWR);
  read(fd, str, 100);
  close(fd);
...
write()も同様です。

実用例

あまり良い例ではないかもしれませんが、僕の班が今回組んだプログラムを 載せておきます。(ただし、ハードは世界に一台しか無いので余り意味ないけど)
これはパラレルポートに接続する機械に附属して書いたドライバ及びプログラムで、 ボタンによってCDの再生、停止、一時停止、またボリュームにより音量調整ができます。 ボタンによる状態遷移は回路の方で行いました。また、ボリュームのデータはAD変換して送信しています。