上位モデルのPIC32MZを除き、PIC32シリーズにはフラッシュメモリーは搭載されていませんが、本来プログラムデータを書き込む領域にアクセスすることで、EEPROMのようにデータを記録したり読み出したりすることができます。

ただし、本来は動的にプログラムを更新するための機能なので、書き込める上限は1000回程度(一般的なフラッシュメモリは10万回以上)であったり、アクセスしている間はほかの処理ができないなど制限が多いので、頻繁に更新する必要があるのなら外部EEPROMやSPIによるSDカードアクセスを検討した方が良いでしょう。

プログラム領域に書き込めるということは、実行プログラムを容易に破壊できるということです。そのため、安易に更新できないようにするため、実行前にNVMKEYレジスタに特定の値を書き込むことでロックを解除する必要があります。

それさえわきまえれば、プログラムの実装は簡単です。
#define APP_WRITE_ADDRESS (uint32_t)(0x9D000000 + 0x3F00)

int WriteToProgramMemory()
{
    // 割り込み処理を停止して、以前の状態をieに格納
    int ie = __builtin_disable_interrupts();

    // 書き込みモードの指定
    // 0001 - 4バイト単位で書き込む
    // 0011 - 512バイト単位で書き込む(指定アドレスは512の倍数)
    // 0100 - 4KB単位で消去
    // 0101 - プログラム領域をすべて消去(ブート領域でのみ実行可)
    NVMCONbits.NVMOP = 0b0001; // WordWrite

    // 書き込みの許可
    NVMCONbits.WREN = 1;

    // 書き込み先の物理アドレス
    NVMADDR = KVA_TO_PA(APP_WRITE_ADDRESS);
    // 書き込みたい値
    NVMDATA = 0x12345678;
    
    // ロックを解除するためのキーを登録
    NVMKEY = 0xAA996655;
    NVMKEY = 0x556699AA;

    // 書き込みの実行。「NVMCONbits.WR = 1」にしただけでは失敗するので注意。
    NVMCONSET = 0x8000;
    // 書き込まれてフラグがリセットされるのを待つ
    while (NVMCONbits.WR);
    
    // 書き込みを禁止する
    NVMCONbits.WREN = 0;

    // 割り込み処理の状態を以前に戻す
    if (ie & 0x1) __builtin_enable_interrupts();

    // NVMERR、LVDERRのエラーフラグがあれば0以外を返す
    return (NVMCON & 0x3000);
}

uint32_t ReadWroteData()
{
    // 対象のアドレスに直接アクセスすれば、書き込んだ値を取得できる
    return *(uint32_t*)APP_WRITE_ADDRESS;
}