C
Linux
RaspberryPi
kernel
デバイスドライバ

組み込みLinuxデバイスドライバの作り方 (4)

4回目: read/writeの実装とメモリのお話

本連載について

組み込みLinuxのカーネルモジュール(デバイスドライバ)の作り方のHowToを書いていこうと思います。本記事の内容は全てラズパイ上で動かせます。

本記事に登場するソースコード全体

https://github.com/take-iwiw/DeviceDriverLesson/tree/master/04_01
https://github.com/take-iwiw/DeviceDriverLesson/tree/master/04_02

今回の内容

前回までで、作成したデバイスドライバをカーネルモジュールとしてカーネルに組み込む方法をやってきました。今回はユーザプログラムから本デバイスドライバとread/writeで値をやり取りしてみます。

前提知識

メモリ空間に関して

通常、コンピュータにはDDR等のメモリが搭載されています。Raspberry Pi2の場合は1GByteのSDRAMが搭載されています。ラズパイの場合は、このメモリの物理アドレスは0x0000_0000から始まります。しかし、我々の作成するプログラムがメモリ上のデータにアクセスするときに、直接物理アドレスを使っているかというと、そうではありません。CPUにはMMUという機能が付いていて、MMUが物理アドレス⇔仮想アドレスの変換を行います。カーネルと各プロセスはそれぞれ独立の仮想アドレス空間で動きます。例えば50個のプロセスが動いているとしたら、51個(プロセス数 + カーネル)の仮想アドレス空間が存在することになります。各仮想空間内のアドレスは独立になります。例えば、プロセスAの0xAAAA_AAAAと、プロセスBの0xAAAA_AAAAは異なります。これによって、他のプロセスによってメモリが破壊されるのを防いでいます。また、実際の物理メモリ容量よりも多くのメモリ空間を持つことが出来ます。これらがMMUを使用する利点です。

ラズパイの場合は

memorymap.jpg

上記がラズパイのメモリマップになります。まず、真ん中がCPU(ARM)から見た物理アドレスになります。これを見ると、SDRAMは0x0000_0000から割り当てられていることが分かります。右側がARMのMMUで変換された後の論理アドレスのメモリマップになります。0x0000_0000~0xC000_0000がユーザ空間のプロセス用の仮想アドレスになります。通常のプログラムはこの仮想メモリ空間で動きます。異なるプロセス間では仮想アドレスの重複が発生します。しかし、物理アドレス上で重複しないようにMMUが変換してくれます。0xC000_0000から上位がカーネル空間用の仮想メモリ空間になります。

ARMから見たSDRAMの物理アドレスが0x0000_0000から始まると上述しましたが、実際にCPUがメモリにアクセスするときには、バス(たぶんAXI)を経由します。また、これはおそらくラズパイ独自の構成なのですが、ビデオプロセッサ(図中の左側のVC(Video Core))経由でバスに接続されているようです。そのため、VCのMMUでの変換が入ります。図を見ると、最終的には0xC000_0000がSDRAM(非キャッシュ)のバスアドレスとなっています。そのため、DMA等を使う場合にはこのアドレスを設定する必要があります。ちなみに、L2キャッシュ経由の場合は0x8000_0000になるようです。例えば、DMAで転送するデータをCPUで作成する際に、こちらのアドレスに対して書き込みを行うとコヒーレンシが保たれなくなってしまいます。キャッシュ→SDRAMへのライトバックが行われる前にDMA転送が行われるなどして、データに不整合が生じます。ノンキャッシュアドレスに変換してからメモリアクセスする必要があります。

これらのアドレスをいちいちメモリマップで確認するのは面倒ですが、下記関数で取得できるようです。ビルドはgcc get_memory.c -L/opt/vc/lib -lbcm_hostです。Raspberry Pi2だと、bcm_host_get_sdram_addressがC0000000、bcm_host_get_peripheral_addressが3F000000を返しました。

get_memory.c
#include <stdio.h>

int main()
{
    /* https://www.raspberrypi.org/documentation/hardware/raspberrypi/peripheral_addresses.md */
    extern unsigned bcm_host_get_sdram_address(void);
    printf("%08X\n", bcm_host_get_sdram_address());
    extern unsigned bcm_host_get_peripheral_address(void);;
    printf("%08X\n", bcm_host_get_peripheral_address());

    return 0;
}

ラズパイのメモリマップの詳細はデータシート:BCM2835-ARM-Peripherals.pdfをご確認ください。

ここで重要なことは

だいぶ横道にそれてしまいましたが、今回重要なのは、システムコールの呼び元(ユーザ空間のプロセス)と、カーネルモジュール内では仮想アドレス空間が異なる、ということです。ポインタを渡したところで、同じアドレスを指している保証はありません。

ユーザ空間 - カーネル空間で安全なデータコピーをする

前回の実装

前回、とりあえずの実装で下記のようなreadシステムコールハンドラを実装しました。readしたら常に1Byteの'A'をbufに格納します。このbufは呼び元(ユーザ空間)で確保された領域です。そのため、領域外アクセスが発生してしまうはずです。しかし、実際は問題なく動いていました。値も正しく格納できていました。これは、冒頭のメモリマップで示したように、仮想アドレス空間がカーネル用1Gbyteとユーザ用3GByteに分かれているためです。そのため、カーネルからは同じコンテキストで動いているプロセス(つまり、システムコール呼び元のプロセス)の仮想アドレス空間にアクセスできます。逆方向は、メモリ保護違反が起きると思います。ただ、これはたまたま動いているのであって、例えば対象アドレスがページスワップアウトされてたりするとうまく動かないと思います。

static ssize_t mydevice_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    printk("mydevice_read");
    buf[0] = 'A';
    return 1;
}

お作法を守ってデータコピーする

上記の方法でもたまたま動いてしまいますが、教科書的には、ユーザ空間-カーネル空間でデータコピーをするときには、copy_to_usercopy_from_userを使用します。下記のコードでは、write時にcopy_from_userでユーザが設定した文字列をstatic変数stored_valueに保持しています。read時に保持した内容をcopy_to_userで返しています。

#define NUM_BUFFER 256
static char stored_value[NUM_BUFFER];

/* read時に呼ばれる関数 */
static ssize_t mydevice_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    printk("mydevice_read");
    if(count > NUM_BUFFER) count = NUM_BUFFER;
    if (copy_to_user(buf, stored_value, count) != 0) {
        return -EFAULT;
    }
    return count;
}

/* write時に呼ばれる関数 */
static ssize_t mydevice_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
    printk("mydevice_write");
    if (copy_from_user(stored_value, buf, count) != 0) {
        return -EFAULT;
    }
    printk("%s\n", stored_value);
    return count;
}

open(ファイルディスクリプタ)毎に異なる管理をする

上記のコードだと、データをstaticに保持しています。そのため、異なるユーザが別々にopenしても同じ変数にアクセスしてしまいます。さらに、別のデバイスを使用しても同じ変数にアクセスしてしまいます。openするときにデータ領域を確保して、個別に管理できるようにします。

open/close時の処理

openの引数にfile構造体が与えられます。このfile構造体の中にはprivate_dataというメンバがあり、自由にポインタを保存しておくことが出来ます。このfile構造体自体は、ユーザがファイルディスクリプタとして保存/管理します。メモリの確保にはkmallocを使用します。closeされるときにkfreeで解放します。今回は _mydevice_file_dataという構造体をデータを保持する型とします。

/*** 各ファイル(open毎に作られるファイルディスクリプタ)に紐づく情報 ***/
#define NUM_BUFFER 256
struct _mydevice_file_data {
    unsigned char buffer[NUM_BUFFER];
};

/* open時に呼ばれる関数 */
static int mydevice_open(struct inode *inode, struct file *file)
{
    printk("mydevice_open");

    /* 各ファイル固有のデータを格納する領域を確保する */
    struct _mydevice_file_data *p = kmalloc(sizeof(struct _mydevice_file_data), GFP_KERNEL);
    if (p == NULL) {
        printk(KERN_ERR  "kmalloc\n");
        return -ENOMEM;
    }

    /* ファイル固有データを初期化する */
    strlcat(p->buffer, "dummy", 5);

    /* 確保したポインタはユーザ側のfdで保持してもらう */
    file->private_data = p;

    return 0;
}

/* close時に呼ばれる関数 */
static int mydevice_close(struct inode *inode, struct file *file)
{
    printk("mydevice_close");

    if (file->private_data) {
        /* open時に確保した、各ファイル固有のデータ領域を解放する */
        kfree(file->private_data);
        file->private_data = NULL;
    }

    return 0;
}

read/write時の処理

ユーザがread, writeするときには、openで取得したファイルディスクリプタを引数に設定します。デバイスドライバ側の実装では、引数として渡されるfile構造体の中のprivate_dataメンバを参照することで、open時に確保したデータ領域にアクセスすることが出来ます。これによって、open(ファイルディスクリプタ)毎に個別にデータを管理することが出来ます。

/* read時に呼ばれる関数 */
static ssize_t mydevice_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    printk("mydevice_read");
    if(count > NUM_BUFFER) count = NUM_BUFFER;

    struct _mydevice_file_data *p = filp->private_data;
    if (copy_to_user(buf, p->buffer, count) != 0) {
        return -EFAULT;
    }
    return count;
}

/* write時に呼ばれる関数 */
static ssize_t mydevice_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
    printk("mydevice_write");

    struct _mydevice_file_data *p = filp->private_data;
    if (copy_from_user(p->buffer, buf, count) != 0) {
        return -EFAULT;
    }
    return count;
}

ユーザプログラムから呼んでみる

エラーコードについて

ちょっと話がそれてしまいますが、カーネルの戻り値は通常、0がOK。read/write系は実際に処理したバイト数になります。エラーの時はマイナスの値を返します。

ユーザプログラム側では、#include <errno.h>をインクルードすることによって、errno変数にそのエラーコードが格納されます。それを直接確認してもいいのですが、perrorという関数を使うことで、エラーコードを分かりやすい文章に変換してくれます。例えば、このようにします。if ((fd = open("/dev/mydevice0", O_RDWR)) < 0) perror("open");

テストコード

test.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

int main()
{
    char buff[256];
    int fd0_A, fd0_B, fd1_A;

    printf("%08X\n", buff);

    if ((fd0_A = open("/dev/mydevice0", O_RDWR)) < 0) perror("open");
    if ((fd0_B = open("/dev/mydevice0", O_RDWR)) < 0) perror("open");
    if ((fd1_A = open("/dev/mydevice1", O_RDWR)) < 0) perror("open");

    if (write(fd0_A, "0_A", 4) < 0) perror("write");
    if (write(fd0_B, "0_B", 4) < 0) perror("write");
    if (write(fd1_A, "1_A", 4) < 0) perror("write");

    if (read(fd0_A, buff, 4) < 0) perror("read");
    printf("%s\n", buff);
    if (read(fd0_B, buff, 4) < 0) perror("read");
    printf("%s\n", buff);
    if (read(fd1_A, buff, 4) < 0) perror("read");
    printf("%s\n", buff);

    if (close(fd0_A) != 0) perror("close");
    if (close(fd0_B) != 0) perror("close");
    if (close(fd1_A) != 0) perror("close");

    return 0;
}

上記のようなテストコードをコンパイル、実行します。

gcc test.c
./a.out
0_A
0_B
1_A

結果はこのようになり、同じデバイスにアクセスしても、別々にopenしたら異なる値を保持/取得出来ていることが分かります。