構造体をファイルに保存/復元するサンプル

アプリケーションの設定等をファイルに保存する場合、必要なデータをテキスト形式で保存するのが簡単な方法です。
テキスト形式は編集が容易なので、アプリケーションを介さずにユーザーが設定等を変更することもできます。
ただしどのように編集しても良いわけではなく、一定のフォーマットを崩さないように注意する必要があります。
(設定ファイルでよく使用されるiniファイルやXMLファイルなどもテキストデータです)

ユーザーにファイルの中身を閲覧、編集されたくない場合、あるいはテキストでは扱えないデータを保存する場合はデータをバイナリ形式で保存することになります。
ここでは一定のフォーマットに従いバイナリファイルを読み書きする方法を解説します。

バイナリ形式でも少し知識のある人ならばデータの閲覧や編集等は可能です。
本格的にデータを隠蔽したい場合は暗号化をする必要があります。

なお、文字として表せないデータを文字に変換してテキストで扱う方法もあります。

保存/復元するサンプルデータ

テスト用のデータとして、今回は以下の構造体をファイルに保存し、ファイルから情報を復元します。


//男女
typedef enum {
    MALE,
    FEMALE
} Gender;

//読み書きするデータの実体
typedef struct {
    unsigned char age;
    Gender gender;
    char* firstName;
    char* lastName;
} Person;

「年齢」「性別」「名」「姓」の情報を持つ「人物」の構造体です。
firstNameメンバとlastNameメンバは任意の長さの文字列を保存できるようにします。
これらのデータは全て文字列で表現可能なので、テキスト形式で保存するのが簡単ですが、今回はバイナリ形式で保存します。

最もシンプルなのは、この構造体変数をそのままfwrite関数でファイルに書き出す方法です。
しかし構造体メンバのメモリ上の配置は処理系に依存するため、書き出したデータには互換性がありません。
(→構造体のサイズ)
また、メンバにポインタがあるため、そのまま書き出してもアドレスが保存されるだけで実際のデータは保存できません。
このアドレス情報はファイルに保存しても役に立たないデータです。

これらの問題に対応するために、保存するデータのファイル内の配置を正確に規定するファイルフォーマット(書式)を自作します。

ファイルフォーマットを決める

Person構造体のフォーマット

この構造体をバイナリ形式で保存するにあたって、以下のフォーマットを規定することにします。

  1. agegenderfirstNamelastNameの順に配置する
  2. agegenderは1バイトデータで保存する
  3. 文字列は可変サイズデータとして考え、まず4バイトのサイズ情報を保存し、直後に実データを保存する
  4. データの整合性チェックとして、チェックサムを1バイトデータで末尾に保存する

C言語では文字列の終端はNULL文字で判断しますが、C言語以外のプログラミング言語ではそのような決まりはありません。
NULL文字(数値の0)をデータの終端という決まりにしても構いませんが、文字列以外の任意のデータにも対応できるように、可変サイズのデータはそのサイズ情報を実データの直前に4バイトで保存しておきます。
ファイル読み込み時にはまずこのサイズ情報を読み、その直後から読み取ったサイズ分の実データを読み取ります。

C言語の文字列の場合は数値の「0」(\0)が終端の目印ですが、文字列以外のバイナリデータでは0は正常なデータの一部として使用される可能性があります。
0以外の値にすることや、目印にする値のサイズを増やしても、それらの値が終端以外にも表れる可能性はあるため、根本的な解決にはなりません。
つまり「終端の目印」でデータを区切る方法は使えません。

チェックサム

チェックサムとは通信エラーやファイルの改ざん等でデータが変化したときにそれを検出するための手法です。

チェックサムの計算方法はいくつかありますが、今回はシンプルに「保存するデータを1バイト単位ですべて加算した合計値を、100で割った余り」を使用します。
つまり0~99の範囲の1バイトデータです。
この値をPersonデータの末尾に1バイトで保存しておきます。
ファイル読み込み時は同じ計算を行い、保存しておいたチェックサムと一致しなければどこかのデータが変化しているということがわかります。

この誤り検出方法の信頼性はそれほど高くありませんが、実装が簡単で効果もそれなりにあります。
ただし知識のある人が解析して改ざんすることに関してはほぼ無力なので過信はできません。

なお、チェックサムは誤り検出の方法であって誤り訂正はできません。

ファイル全体のフォーマット

また、複数のPersonデータを同時に保存するため、以下の追加のフォーマットを規定します。

  1. Personを可変サイズデータと捉え、実データの前に4バイトのサイズ情報を保存する
    (Person構造体自体は固定サイズだが、ポインタの実データは可変であるため)
  2. ファイルの先頭にこのアプリケーション用のファイルであることを示すファイルシグネチャを保存する

ひとつのPerson実データの前にそのサイズ情報を保存しておくことで、fseek関数で次のデータへのアクセスが簡単にできるようになります。
このような情報をヘッダーといいます。
「ヘッダー + 実データのひとまとまり」をチャンクといいます。

例えば格納するデータの種類が複数ある場合に、ヘッダーにサイズとチャンクの種類の情報を格納しておくことで、処理を切り替えて適切にデータを読み込むことができます。

ファイルシグネチャ

ファイルシグネチャは、バイナリファイルの種類を判別するためにファイルの先頭に書き込む任意のサイズの数値の列です。
(マジックナンバーとも言います。数値リテラルなどのマジックナンバーとは別)
これは何でも構いませんが、今回は以下の8バイトの情報を使用します。


// ファイルシグネチャ
#define SIGNATURESIZE 8
const char FILE_SIGNATURE[SIGNATURESIZE] = { 'M', 'Y', 'F', 'I', 'L', 'E', '\0', 0xFF };

ファイルの読み込み時は、まずファイル先頭から8バイトを読み取りこの値をこの値と比較することで、対応ファイルか否かを判断できます。
比較にはmemcmp関数を使用するのが簡単です。

サンプルコード

上記の構造体を保存/復元するコードの全体です。

長いコードですが、処理自体は難しいことはしていません。
詳細はコメントを参照してください。

ファイルの読み書き先はグローバル変数filePathで「C:\test.bin」を指定しているので、適宜置き換えてください。


#include <stdio.h>
#include <string.h>
#include <stdlib.h>

// このコードは
// char型: 1バイト
// int型: 4バイト
// を前提としているため処理系による互換性に注意
// 
// ファイルフォーマット
// offset : 説明(バイト数)
// 0  : シグネチャ(8)
// 8~: Personチャンク(可変)
//
// Personチャンクフォーマット
// offset: 説明(バイト数)
// 0 : 全体サイズ(4)
// 4 : age(1)
// 5 : gender(1)
// 6 : firstNameサイズ(4)
// 10 : firstName(可変)
// 10 + firstNameサイズ : lastNameサイズ(4)
// 10 + firstNameサイズ + 4 : lastName(可変)
// 10 + firstNameサイズ + 4 + lastNameサイズ : チェックサム(1)

// 読み書きするファイルパス
const char* const filePath = "C:\\test.bin";

// ファイルシグネチャ
#define SIGNATURESIZE 8
const char FILE_SIGNATURE[SIGNATURESIZE] = { 'M', 'Y', 'F', 'I', 'L', 'E', '\0', 0xFF };

// 可変サイズデータのサイズを表す型
typedef unsigned int size32;

// 男女
typedef enum {
    MALE,
    FEMALE
} Gender;

// 読み書きするデータの実体
typedef struct {
    unsigned char age;
    Gender gender;
    char* firstName;
    char* lastName;
} Person;

void FreePerson(Person*);
unsigned char CheckSum(Person*);
int WriteSignature(FILE*);
int CheckSignature(FILE*);
int WriteData(FILE*, const void*, size32);
int ReadData(FILE*, void**, size32*, char);
int WritePerson(FILE*, Person*);
int ReadPerson(FILE*, Person*);
int WriteAll(Person*, size32);
size_t GetDataCount(FILE*);
int ReadAll(Person**, size_t*);
int WriteTestData();
void PrintPerson(Person*);
int GetSpecificKeyInput(const char*, const char*);

// Personのメンバのメモリ領域解放
// メモリを動的確保していない場合は実行してはならない
// person=対象のPerson型へのポインタ
void FreePerson(Person* person)
{
    free(person->firstName);
    person->firstName = NULL;
    free(person->lastName);
    person->lastName = NULL;
}

// チェックサムを計算する
// person=計算対象のPerson型へのポインタ
// return=チェックサム
unsigned char CheckSum(Person* person)
{
    //全てのメンバを1バイトデータとして加算した合計値を
    //100で割った余りをチェックサムとする

    unsigned int sum = person->age + person->gender;
    char* p;
    if (p = person->firstName) {
        while (*p)
        {
            sum += *p;
            ++p;
        }
    }
    if (p = person->lastName) {
        while (*p)
        {
            sum += *p;
            ++p;
        }
    }
    return sum % 100;
}

// ファイルシグネチャの書き込み
// fp=wbモードで開いたファイルポインタ
// return=成否
int WriteSignature(FILE* fp)
{
    long offset = ftell(fp);
    fwrite(FILE_SIGNATURE, SIGNATURESIZE, 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    return 1;
}
// ファイルシグネチャのチェック
// fp=rbモードで開いたファイルポインタ
// return=成否
int CheckSignature(FILE* fp)
{
    long offset = ftell(fp);
    char header[SIGNATURESIZE];
    fread(header, SIGNATURESIZE, 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    return memcmp(header, FILE_SIGNATURE, SIGNATURESIZE) == 0;
}

// 可変サイズデータの書き込み
// fp=wbモードで開いたファイルポインタ
// data=書き込むデータへのポインタ
// size=データサイズ
// return=成否
int WriteData(FILE* fp, const void* data, size32 size)
{
    long offset = ftell(fp);
    fwrite(&size, sizeof(size32), 1, fp);
    fwrite(data, size, 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    return 1;
}
// 可変サイズデータの読み込み
// fp=rbモードで開いたファイルポインタ
// data=読み込んだデータを受け取るポインタ
// size=読み込んだデータのサイズを受け取るポインタ
// isString: 読み込んだデータの末尾にNULL文字を付加するかの真偽値
// return=成否
int ReadData(FILE* fp, void** data, size32* size, char isString)
{
    if (size)
        *size = 0;

    long offset = ftell(fp);
    size32 _size;
    fread(&_size, sizeof(size32), 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }

    if (!(*data = malloc(_size + (isString ? sizeof(char) : 0)))) {
        fseek(fp, offset, SEEK_SET);
        return 0;
    }

    fread(*data, _size, 1, fp);
    if (ferror(fp)) {
        free(*data);
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    if (isString)
        ((char*)*data)[_size] = '\0';
    if (size)
        *size = _size;
    return 1;
}

// Person書き込み
// fp=wbモードで開いたファイルポインタ
// person=Person型へのポインタ
// return=成否
int WritePerson(FILE* fp, Person* person)
{
    size32 firstNameSize = (size32)strlen(person->firstName);
    size32 lastNameSize = (size32)strlen(person->lastName);
    size32 chunkSize = (size32)(
        sizeof(char) * 3 +      //age+gender+チェックサム
        sizeof(size32) * 2 +    //文字列サイズ情報 * 2
        firstNameSize + lastNameSize);

    long offset = ftell(fp);

    fwrite(&chunkSize, sizeof(size32), 1, fp);
    fwrite(&person->age, sizeof(char), 1, fp);
    fwrite(&(unsigned char)(person->gender), sizeof(char), 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }

    if (!WriteData(fp, person->firstName, firstNameSize)) {
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    if (!WriteData(fp, person->lastName, lastNameSize)) {
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    unsigned char sum = CheckSum(person);
    fwrite(&sum, sizeof(char), 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    return 1;
}

// Person読み込み
// fp=rbモードで開いたファイルポインタ
// person=読み込んだデータを受け取るポインタ
// return=
// 0  :ファイル読み込みエラー
// 1  :正常終了
// -1 :チェックサムエラー
int ReadPerson(FILE* fp, Person* person)
{
    long offset = ftell(fp);
    fseek(fp, sizeof(size32), SEEK_CUR);

    fread(&person->age, sizeof(char), 1, fp);
    fread(&person->gender, sizeof(char), 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }

    if (person->firstName) {
        free(person->firstName);
        person->firstName = NULL;
    }
    if (person->lastName) {
        free(person->lastName);
        person->lastName = NULL;
    }
    if (!ReadData(fp, &(person->firstName), NULL, 1)) {
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    if (!ReadData(fp, &(person->lastName), NULL, 1)) {
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    unsigned char sum;
    fread(&sum, sizeof(char), 1, fp);
    if (ferror(fp)) {
        clearerr(fp);
        fseek(fp, offset, SEEK_SET);
        return 0;
    }
    //チェックサムが合わない場合はデータ消去
    if (sum != CheckSum(person)) {
        FreePerson(person);
        *person = (Person){ 0 };
        return -1;
    }
    return 1;
}

// Person配列の書き込み
// persons=書き込むPerson型配列
// length=配列サイズ
// return=成否
int WriteAll(Person* persons, size32 length)
{
    FILE* fp;
    if (fopen_s(&fp, filePath, "wb") != 0) {
        printf("ファイルオープンエラー\n");
        return 0;
    }

    if (!WriteSignature(fp)) {
        printf("ファイル書き込みエラー\n");
        fclose(fp);
        return 0;
    }

    for (size_t n = 0; n < length; ++n) {
        if (!WritePerson(fp, &persons[n])) {
            printf("ファイル書き込みエラー\n");
            fclose(fp);
            return 0;
        }
    }
    fclose(fp);
    return 1;
}

// ファイル内のPersonデータの総数を取得する
// fp=rbモードで開いたファイルポインタ
// return=保存されているPersonの数
size_t GetDataCount(FILE* fp)
{
    long offset = ftell(fp);
    fseek(fp, SIGNATURESIZE, SEEK_SET);

    size_t count = 0;
    size32 size;
    while (fread(&size, sizeof(size32), 1, fp) == 1) {
        if (fseek(fp, size, SEEK_CUR) != 0) {
            break;
        }
        ++count;
    }
    if (ferror(fp)) {
        clearerr(fp);
    }
    fseek(fp, offset, SEEK_SET);
    return count;
}

// ファイル一括読み込み
// persons=読み込んだ複数のPersonを受け取るポインタ
//  メモリを動的確保するので空のPersonsポインタを渡すこと
// length=読みんだPerson数を受け取るポインタ
// return=成否
int ReadAll(Person** persons, size_t* length)
{
    if (length)
        *length = 0;

    FILE* fp;
    if (fopen_s(&fp, filePath, "rb") != 0) {
        printf("ファイルオープンエラー\n");
        return 0;
    }

    if (!CheckSignature(fp)) {
        printf("未対応のファイル形式\n");
        fclose(fp);
        return 0;
    }

    size32 _length = GetDataCount(fp);

    *persons = (Person*)malloc(sizeof(Person) * _length);
    if (!*persons) {
        printf("メモリ確保エラー\n");
        fclose(fp);
        return 0;
    }

    for (size_t n = 0; n < _length; ++n) {
        (*persons)[n] = (Person){ 0 }; //メモリ初期化
        if (!ReadPerson(fp, &(*persons)[n])) {
            printf("ファイル読み込みエラー\n");
            free(*persons);
            fclose(fp);
            return 0;
        }
    }
    fclose(fp);

    if (length)
        *length = _length;
    return 1;
}

// テストデータの書き込み
// return=成否
int WriteTestData()
{
    Person persons[] = {
        { 20, MALE, "うえお", "あい" },
        { 21, FEMALE, "くけこ", "かき" },
        { 0, MALE, "", "" },
        { 255, FEMALE, "abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ" }
    };

    return WriteAll(persons, (size32)(sizeof(persons) / sizeof(Person)));
}

// Personのデータ表示
// person=表示するPerson型へのポインタ
void PrintPerson(Person* person)
{
    printf("age: %d\n", person->age);
    printf("gender: %s\n", person->gender == MALE ? "男" : "女");
    printf("firstName: %s\n", person->firstName);
    printf("lastName: %s\n", person->lastName);
    printf("checksum: %u\n", CheckSum(person));
    printf("\n");
}

// キー入力の先頭文字が文字列keysに含まれる場合にその文字を返す
// 含まれない場合はループする
// EOFが入力された場合はEOFを返す
// keys=検索対象の文字列
// message=プロンプトに表示するメッセージ
// return=keys内の一文字、またはEOF
int GetSpecificKeyInput(const char* keys, const char* message)
{
    if (!keys)
        return EOF;

    char isMessageAlloc = 0;
    if (!message) { //messageがNULLの場合のデフォルト文字列を生成
        isMessageAlloc = 1;
        const char* s = "有効なキー: ";
        size_t messageSize = (strlen(keys) + strlen(s) + 2) * sizeof(char);
        char* _message = malloc(messageSize);
        if (!_message) {
            printf("メモリ確保エラー\n");
            return EOF;
        }
        sprintf_s(_message, messageSize, "%s%s\n", s, keys);
        message = _message;
    }

    char* p;
    int input;
    char flag = 1;
    do {
        printf("%s", message);

        input = getchar();
        if (input == '\n') {
            continue;
        }

        if (input == EOF) {
            if (ferror(stdin)) {
                clearerr(stdin);
            }
            break;
        }
        while (getchar() != '\n');

        p = (char*)keys;
        while (*p != '\0') {
            if (input == *p) {
                flag = 0;
                break;
            }
            p++;
        }
    } while (flag);

    if (isMessageAlloc) {
        free((void*)message);
    }

    return input;
}

int main()
{
    int input = GetSpecificKeyInput("wrc",
        "モードを選択してください\n"
        "w: テストデータの書き込み\n"
        "r: ファイルの読み込みと表示\n"
        "c: キャンセル\n"
        "モード: "
    );

    switch (input)
    {
    case 'w':
        printf("書き込み開始\n");
        if (WriteTestData()) {
            printf("書き込み終了\n");
        }
        break;
    case 'r':
        printf("読み込み開始\n\n");
        Person* persons = NULL;
        size_t length;
        if (ReadAll(&persons, &length)) {
            for (size_t n = 0; n < length; ++n) {
                printf("%u:\n", n);
                PrintPerson(&persons[n]);
            }
            for (size_t n = 0; n < length; ++n) {
                FreePerson(&persons[n]);
            }
            free(persons);
            printf("読み込み終了\n");
        }
        break;
    default:
        printf("キャンセルしました\n");
        break;
    }

    printf("プログラムを終了します。\n");
    getchar();
}

環境による互換性について

ファイルフォーマットを規定することでファイル内のデータ位置は正確に決定できますが、文字コードが一致していない場合は文字化けが発生します。
また、バイトオーダーが異なるシステムではデータの書き出し/読み出しの際に変換する処理が必要です。

多くの処理系ではchar型は1バイト、int型は4バイトですが、これはC言語の規約で決められているわけではありません。
異なるシステムで動作させる場合はそのあたりの調整も必要です。
(64bit Windows、および多くのUNIX系OSの64bit版ではint型は4バイトです)

ちなみにC99では、サイズが固定のデータ型を使用することもできます。
例えば「int8_t」という型はどのような環境でも8ビット(1バイト)のサイズを持つように決められています。
ただしこれはオプション扱いで全てのコンパイラで対応しているわけではないので、今回は使用していません。