Objectifs généraux
Un système de fichiers est une méthode d’organisation, de stockage et de récupération de données sur un support de stockage. Dans un système d’exploitation réel, cette organisation repose sur plusieurs niveaux d’abstraction : le matériel fournit une suite de blocs adressables, le système de fichiers définit une structure logique permettant d’interpréter ces blocs, puis les mécanismes d’accès aux fichiers utilisent ces structures pour retrouver les données associées à un nom, un identifiant ou un descripteur.
Dans ce TP, vous allez construire progressivement un Virtual File System (VFS) dont le support de stockage est constitué d’un simple tableau Java byte[]. Le tableau représente une mémoire brute : il ne possède intrinsèquement aucune notion de fichier, de répertoire, d’inode, de bloc libre ou de métadonnée. Toutes ces notions devront donc être construites explicitement par votre programme. L’objectif n’est pas uniquement d’obtenir un programme qui fonctionne, mais de comprendre comment une structure logicielle de haut niveau peut être reconstruite à partir d’un espace mémoire initialement dépourvu de structure.
Le VFS constitue ainsi une forme de laboratoire expérimental permettant d’observer les mécanismes qui, dans un système réel, sont masqués par le système d’exploitation. Vous manipulerez directement les octets, les offsets, les blocs, les structures binaires, les tables de métadonnées et les mécanismes d’allocation. Le tableau byte[] peut être considéré comme une abstraction extrêmement simplifiée d’un disque ou d’une image disque : sa taille est fixe, ses positions sont adressables, et les données qui y sont placées ne prennent un sens que par convention.
Une difficulté centrale du TP vient précisément de cette absence de typage dans la mémoire simulée. Un byte[] ne sait pas qu’un groupe de quatre octets représente un int, qu’un groupe de huit octets représente un timestamp, qu’une zone particulière correspond au bitmap ou qu’une autre zone correspond à un inode. C’est votre architecture qui impose cette interprétation. Cette séparation entre représentation physique et interprétation logique constitue une idée fondamentale des systèmes informatiques bas niveau.
Le TP est donc organisé autour d’une progression volontairement ascendante :
- construire les primitives de sérialisation binaire ;
- construire une mémoire virtuelle structurée ;
- gérer l’allocation des blocs avec un bitmap ;
- définir et sérialiser les inodes ;
- créer des fichiers ;
- écrire et relire leurs données ;
- observer les effets de l’allocation et de la fragmentation ;
- tester le système avec des données produites à l’extérieur du VFS.
L’ensemble forme une chaîne de dépendances : une erreur introduite au niveau de la sérialisation peut contaminer le superbloc, puis les inodes, puis les opérations sur les fichiers. Il est donc demandé de respecter l’ordre des étapes et de valider chaque couche avant de construire la suivante.
Vue générale de l’architecture
Avant de commencer l’implémentation, il est nécessaire de comprendre la structure globale du système.
Le VFS repose sur une mémoire de taille fixe de $1$ MiB. Cette mémoire est divisée en blocs de taille fixe de $512$ octets. Certains blocs sont réservés aux structures internes du système de fichiers tandis que les blocs restants constituent l’espace disponible pour les données utilisateur.
La relation fondamentale est :
$$\text{Nombre de blocs} = \frac{\text{Taille totale de la mémoire}}{\text{Taille d'un bloc}} $$
Dans notre configuration :
$$\frac{1024 \times 1024}{512}=2048$$
Le système possède donc exactement $2048$ blocs adressables, numérotés de 0 à 2047.
La mémoire est ensuite découpée en différentes régions. Cette organisation est appelée le layout du système de fichiers.
La séparation entre métadonnées et données est une idée fondamentale. Le contenu d’un fichier ne doit pas être confondu avec les informations permettant de retrouver ce contenu. Un inode décrit par exemple la taille d’un fichier et les blocs dans lesquels ses données sont stockées, tandis que les blocs de données contiennent effectivement les octets du fichier.
Étape 1 : Initialisation de l’environnement et dépôt Git
Un projet manipulant un système de fichiers virtuel produit plusieurs catégories de fichiers qui n’ont pas toutes leur place dans le dépôt Git. Les fichiers Java contenant le code source constituent l’artefact principal du projet, tandis que les fichiers compilés, archives et images disque représentent des artefacts générés.
Cette distinction est importante dans un projet logiciel sérieux. Un fichier .class dépend de l’environnement de compilation et peut être régénéré à partir du .java. De même, une image disque virtuelle peut être générée à partir du programme. Versionner systématiquement ces artefacts peut introduire du bruit dans l’historique et, plus fondamentalement, masquer la distinction entre source et résultat d’exécution.
Le système de fichiers que vous allez construire pourra également être sauvegardé dans un fichier image. Cette image représentera alors l’état binaire du système à un instant donné. Elle peut être utile pour des tests ou pour l’inspection du contenu brut, mais elle ne doit pas être considérée comme le code source du projet.
Instructions
À la racine du dépôt OS_Nom1_Nom2/, créez le .gitignore global :
echo "*.class" >> .gitignore
echo "*.jar" >> .gitignore
Créez ensuite le dossier du TP :
mkdir SystemeFichiersVirtuel
cd SystemeFichiersVirtuel
echo "filesystem.img" >> .gitignore
Vérifiez ensuite l’état du dépôt :
git status
Un bon dépôt logiciel doit permettre à un autre développeur de reconstruire les artefacts générés à partir des sources versionnées. Cette propriété est particulièrement importante ici, car le système de fichiers manipulé par le programme est lui-même un artefact binaire généré en mémoire.
Travail demandé
- Vérifiez que les fichiers
.classne sont pas suivis par Git. - Vérifiez que
filesystem.imgest ignoré. - Effectuez un premier commit contenant uniquement les sources et fichiers nécessaires au TP.
- Conservez un historique Git suffisamment régulier pour permettre de distinguer les différentes étapes d’implémentation.
Étape 2 : Sérialisation des types entiers
La première difficulté consiste à transformer des valeurs Java typées en une représentation constituée uniquement d’octets.
Java distingue explicitement les types byte, short, int et long. La mémoire simulée du VFS, elle, ne connaît que byte[]. Il faut donc définir une convention de représentation.
Un int Java occupe $32$ bits, soit :
$$32 / 8 = 4 \text{ octets}$$
Un short occupe $16$ bits, soit :
$$16 / 8 = 2 \text{ octets}$$
Vous devez utiliser une représentation big-endian : l’octet de poids fort est placé à l’adresse la plus faible.
Pour une valeur :
$$V = 0xF0A1B2E3$$
la représentation mémoire attendue est :
offset + 0 : F0
offset + 1 : A1
offset + 2 : B2
offset + 3 : E3
Cette convention doit être déterministe. Si deux fonctions d’écriture différentes utilisent des conventions différentes, les métadonnées du système de fichiers deviendront impossibles à interpréter correctement.
Découpage d’un entier
Pour extraire les différents octets d’un int, vous devez utiliser les décalages binaires.
Conceptuellement :
$$b_3 = V \ \&\ 0xFF$$
La lecture effectue l’opération inverse.
En Java, byte est signé. Une valeur mémoire telle que 0xF0 peut donc apparaître comme une valeur négative lorsqu’elle est interprétée comme un byte. Lorsqu’un octet doit être utilisé comme quantité non signée avant une recombinaison, le masquage & 0xFF est essentiel.
Cette question n’est pas spécifique au VFS. Elle apparaît dans de nombreux contextes de programmation système : protocoles réseau, formats binaires, fichiers structurés, compression, cryptographie, sérialisation et communication avec du matériel.
Squelette Utils.java
public class Utils {
public static int writeInt(byte[] memory, int offset, int value) {
// TODO: Écrire les 4 octets de 'value' dans 'memory'
// à partir de 'offset', en big-endian.
return 4;
}
public static int readInt(byte[] memory, int offset) {
// TODO: Reconstituer le int sur 4 octets.
return 0;
}
public static int writeShort(byte[] memory, int offset, short value) {
// TODO: Écrire les 2 octets de 'value'.
return 2;
}
public static short readShort(byte[] memory, int offset) {
// TODO: Lire le short sur 2 octets.
return 0;
}
}
Tests obligatoires
Le test ne doit pas uniquement vérifier :
readInt(...) == value
Une telle vérification pourrait laisser passer certaines erreurs si writeInt et readInt contiennent exactement la même erreur. Le test doit donc également inspecter directement la mémoire.
Ajoutez dans TestRunner.java :
public static void testStep2() {
System.out.println("=== TEST ÉTAPE 2 : Utils Entiers ===");
byte[] buffer = new byte[32];
int value = 0xF0A1B2E3;
int written = Utils.writeInt(buffer, 3, value);
assert written == 4 : "writeInt doit retourner 4";
assert (buffer[3] & 0xFF) == 0xF0 : "Octet 0 incorrect";
assert (buffer[4] & 0xFF) == 0xA1 : "Octet 1 incorrect";
assert (buffer[5] & 0xFF) == 0xB2 : "Octet 2 incorrect";
assert (buffer[6] & 0xFF) == 0xE3 : "Octet 3 incorrect";
assert Utils.readInt(buffer, 3) == value :
"Erreur writeInt / readInt";
short shortValue = (short) 0xF0A1;
int shortWritten = Utils.writeShort(buffer, 20, shortValue);
assert shortWritten == 2 : "writeShort doit retourner 2";
assert (buffer[20] & 0xFF) == 0xF0 :
"Premier octet du short incorrect";
assert (buffer[21] & 0xFF) == 0xA1 :
"Deuxième octet du short incorrect";
assert Utils.readShort(buffer, 20) == shortValue :
"Erreur writeShort / readShort";
System.out.println("[OK] Étape 2 validée !");
}
Testez également une valeur contenant des bits de poids fort à 1, par exemple 0x80000000, afin de vérifier que votre traitement du complément à deux ne provoque pas d’erreur.
Exécution :
javac *.java
java -ea TestRunner
L’option -ea active les assertions Java.
Étape 3 : Sérialisation des long et des chaînes
Sérialisation d’un long
Les métadonnées d’un système de fichiers contiennent fréquemment des valeurs temporelles. Dans ce TP, les temps sont représentés par la valeur retournée par :
System.currentTimeMillis()
Cette valeur nécessite un long, donc $64$ bits :
$$64 / 8 = 8 \text{ octets}$$
Le principe est identique à celui du int, mais le domaine de représentation est plus large.
La mémoire doit contenir les huit octets dans un ordre déterministe. Le principe général peut être exprimé comme :
$$V =\sum_{i=0}^{7} b_i \times 2^{8(7-i)} $$
où chaque $b_i$ représente un octet de la représentation binaire.
Sérialisation des chaînes
Les chaînes constituent un problème légèrement différent. Une String Java est une structure abstraite représentant du texte, alors que le système de fichiers manipule des octets.
Il faut donc définir une représentation textuelle sous forme d’octets. Dans le cadre de ce TP, vous utiliserez :
str.getBytes()
Une chaîne occupe alors un nombre variable d’octets, mais les structures du système de fichiers utilisent souvent des champs de taille fixe. Il faut donc associer une longueur logique variable à une zone physique de taille déterminée.
Si un champ possède maxLength = 16, la zone réservée contient exactement $16$ octets, même si la chaîne ne contient que quatre caractères.
Par exemple :
"MYFS"
peut être représenté dans une zone de 16 octets comme :
4D 59 46 53 00 00 00 00 00 00 00 00 00 00 00 00
Le caractère nul joue ici le rôle de marqueur de fin de chaîne.
Le remplissage par zéro est une opération importante. Une mémoire peut avoir été utilisée précédemment par une autre donnée. Si une nouvelle chaîne plus courte est écrite sans nettoyer le reste de sa zone, les anciens octets restent physiquement présents. Le système logique peut alors donner l’impression que les données sont correctes tout en conservant des informations résiduelles dans la représentation brute.
Squelette
Complétez Utils.java :
public static int writeLong(byte[] memory, int offset, long value) {
// TODO: Écrire les 8 octets du long en big-endian.
return 8;
}
public static long readLong(byte[] memory, int offset) {
// TODO: Reconstituer le long.
return 0L;
}
public static int writeString(
byte[] memory,
int offset,
String str,
int maxLength) {
// TODO:
// 1. Convertir la chaîne en octets.
// 2. Copier les octets sans dépasser maxLength.
// 3. Nettoyer le reste de la zone avec des zéros.
return maxLength;
}
public static String readString(
byte[] memory,
int offset,
int maxLength) {
// TODO:
// Lire jusqu'au premier octet nul
// ou jusqu'à maxLength.
return "";
}
Tests obligatoires
public static void testStep3() {
System.out.println("=== TEST ÉTAPE 3 : Utils Long & String ===");
byte[] buffer = new byte[64];
long value = 0x1122334455667788L;
int written = Utils.writeLong(buffer, 0, value);
assert written == 8 : "writeLong doit retourner 8";
assert (buffer[0] & 0xFF) == 0x11;
assert (buffer[1] & 0xFF) == 0x22;
assert (buffer[2] & 0xFF) == 0x33;
assert (buffer[3] & 0xFF) == 0x44;
assert (buffer[4] & 0xFF) == 0x55;
assert (buffer[5] & 0xFF) == 0x66;
assert (buffer[6] & 0xFF) == 0x77;
assert (buffer[7] & 0xFF) == 0x88;
assert Utils.readLong(buffer, 0) == value :
"Erreur writeLong / readLong";
for (int i = 16; i < 32; i++) {
buffer[i] = (byte) 0x7F;
}
int stringWritten =
Utils.writeString(buffer, 16, "MYFS", 16);
assert stringWritten == 16 :
"writeString doit retourner maxLength";
assert (buffer[16] & 0xFF) == 'M';
assert (buffer[17] & 0xFF) == 'Y';
assert (buffer[18] & 0xFF) == 'F';
assert (buffer[19] & 0xFF) == 'S';
for (int i = 20; i < 32; i++) {
assert buffer[i] == 0 :
"La zone inutilisée doit être nettoyée";
}
assert Utils.readString(buffer, 16, 16).equals("MYFS") :
"Erreur writeString / readString";
System.out.println("[OK] Étape 3 validée !");
}
Étape 4 : Architecture mémoire du système de fichiers
Modèle physique
Le système de fichiers virtuel possède $M = 1024 \times 1024 = 1\,048\,576$ octets de mémoire. Chaque bloc possède $B = 512$ octets. Le nombre de blocs est donc :
$$ N = \frac{M}{B} = 2048 $$
Chaque adresse physique du VFS peut être considérée comme un offset dans le tableau byte[]. Pour un bloc de numéro $i$, son adresse de début est :
$$offset(i) = i \times B$$
Cette formule sera utilisée de manière répétée dans la suite du TP.
Layout
La mémoire est organisée comme suit :
Bloc 0
+------------------------------------------------+
| Superbloc |
+------------------------------------------------+
Bloc 1
+------------------------------------------------+
| Bitmap |
+------------------------------------------------+
Blocs 2 à 128
+------------------------------------------------+
| Table des inodes |
| |
| inode 0 |
| inode 1 |
| ... |
| inode N |
+------------------------------------------------+
Blocs 129 à 2047
+------------------------------------------------+
| Zone de données |
| |
| blocs utilisateur |
| blocs utilisateur |
| ... |
+------------------------------------------------+
Le bitmap contient un bit par bloc :
$$2048 \text{ blocs} \times 1 \text{ bit}=2048 \text{ bits}$$
soit :
$$ 2048 / 8 = 256 \text{ octets} $$
Les blocs 0 à 128 sont réservés au système. La zone de données commence donc au bloc 129. Le nombre de blocs de données disponibles est $2048 - 129 = 1919$
Les numéros de blocs et les offsets mémoire sont deux notions liées mais distinctes. Le bloc 129 commence à l’offset 129 * 512, et non à l’offset 129. Une confusion entre ces deux unités produit des corruptions mémoire. Donc de la supperposition de donée.
Squelette MemoryManager.java
import java.io.*;
public class MemoryManager {
public static final int BLOCK_SIZE = 512;
public static final int TOTAL_MEMORY = 1024 * 1024;
public static final int NUM_BLOCKS =
TOTAL_MEMORY / BLOCK_SIZE;
public static final int SUPERBLOCK_OFFSET = 0;
public static final int BITMAP_OFFSET = BLOCK_SIZE;
public static final int INODE_TABLE_OFFSET =
2 * BLOCK_SIZE;
public static final int DATA_OFFSET =
129 * BLOCK_SIZE;
public static final int INODE_SIZE = 128;
public static final int INODE_TABLE_SIZE =
DATA_OFFSET - INODE_TABLE_OFFSET;
public static final int MAX_INODES =
INODE_TABLE_SIZE / INODE_SIZE;
private byte[] memory;
public MemoryManager() {
this.memory = new byte[TOTAL_MEMORY];
initializeFilesystem();
}
private void initializeFilesystem() {
writeSuperblock();
// TODO:
// Réserver les blocs système 0 à 128.
}
private void writeSuperblock() {
// TODO:
// Utiliser Utils pour écrire les métadonnées.
Utils.writeString(
memory,
SUPERBLOCK_OFFSET,
"MYFS1.0",
16);
Utils.writeInt(
memory,
SUPERBLOCK_OFFSET + 16,
BLOCK_SIZE);
Utils.writeInt(
memory,
SUPERBLOCK_OFFSET + 20,
TOTAL_MEMORY);
Utils.writeInt(
memory,
SUPERBLOCK_OFFSET + 24,
NUM_BLOCKS);
Utils.writeInt(
memory,
SUPERBLOCK_OFFSET + 28,
MAX_INODES);
}
public byte[] getFilesystemMemory() {
return memory;
}
}
Tests
public static void testStep4() {
System.out.println("=== TEST ÉTAPE 4 : Initialisation Mémoire ===");
MemoryManager mm = new MemoryManager();
byte[] mem = mm.getFilesystemMemory();
assert mem != null :
"La mémoire ne doit pas être nulle";
assert mem.length == MemoryManager.TOTAL_MEMORY :
"Taille mémoire incorrecte";
assert Utils.readString(
mem,
MemoryManager.SUPERBLOCK_OFFSET,
16).equals("MYFS1.0") :
"Signature du superbloc incorrecte";
assert Utils.readInt(
mem,
MemoryManager.SUPERBLOCK_OFFSET + 16)
== MemoryManager.BLOCK_SIZE :
"Taille de bloc incorrecte";
assert Utils.readInt(
mem,
MemoryManager.SUPERBLOCK_OFFSET + 20)
== MemoryManager.TOTAL_MEMORY :
"Taille mémoire incorrecte";
assert Utils.readInt(
mem,
MemoryManager.SUPERBLOCK_OFFSET + 24)
== MemoryManager.NUM_BLOCKS :
"Nombre de blocs incorrect";
assert Utils.readInt(
mem,
MemoryManager.SUPERBLOCK_OFFSET + 28)
== MemoryManager.MAX_INODES :
"Nombre maximal d'inodes incorrect";
System.out.println("[OK] Étape 4 validée !");
}
Étape 5 : Gestion du bitmap et allocation des blocs
Le système doit maintenant savoir quels blocs sont libres et quels blocs sont occupés. Une solution naïve consisterait à réserver un entier ou un booléen pour chaque bloc. Une telle représentation serait fonctionnelle mais relativement coûteuse. Le bitmap utilise une représentation plus compacte : un seul bit par bloc.
Pour $N$ blocs, le bitmap nécessite $\frac{N}{8}$ octets. Pour $2048$ blocs : $\frac{2048}{8}=256$ octets.
Cette représentation est typique des systèmes bas niveau : lorsqu’un grand nombre d’éléments ne possède qu’un état binaire, les bits permettent de représenter l’état de manière extrêmement compacte.
Pour un bloc $N$, l’indice de l’octet est :
$$ byteIndex = \left\lfloor \frac{N}{8} \right\rfloor $$
et la position du bit :
$$ bitPosition = N \bmod 8 $$
L’adresse mémoire correspondante est :
$$ bitmapOffset = BITMAP\_OFFSET + byteIndex $$
Exemple
Pour le bloc 129 :
$$ 129 / 8 = 16 $$
avec division entière, et :
$$ 129 \bmod 8 = 1 $$
Le bloc 129 correspond donc au bit 1 de l’octet situé à :
$$ 512 + 16 = 528 $$
Le bit correspondant vaut :
$$ 1 << 1 = 0b00000010 $$
Le bitmap n’enregistre pas directement la taille d’un fichier ni son contenu. Il indique uniquement l’état d’allocation du bloc. Les informations permettant de savoir quels blocs appartiennent à un fichier seront stockées dans l’inode.
Opérations binaires
Pour positionner un bit :
octet |= masque
Pour effacer un bit :
octet &= ~masque
Pour lire un bit, vous devez extraire sa valeur sans être perturbé par le signe du byte.
Le bitmap doit également respecter un invariant fondamental :
un bloc système réservé ne doit jamais être retourné comme bloc de données libre.
L’allocation devra donc commencer au bloc 129.
Squelette
public boolean setBlockUsed(
int blockNumber,
boolean used) {
if (blockNumber < 0 ||
blockNumber >= NUM_BLOCKS) {
return false;
}
int byteIndex = blockNumber / 8;
int bitPosition = blockNumber % 8;
int offset = BITMAP_OFFSET + byteIndex;
if (used) {
// TODO:
// Positionner le bit à 1.
} else {
// TODO:
// Positionner le bit à 0.
}
return true;
}
public int isBlockUsed(int blockNumber) {
if (blockNumber < 0 ||
blockNumber >= NUM_BLOCKS) {
return -1;
}
// TODO:
// Calculer byteIndex.
// Calculer bitPosition.
// Lire le bit.
return -1;
}
public int allocateBlock() {
// TODO:
// Parcourir les blocs de données :
// 129 .. NUM_BLOCKS - 1.
//
// Retourner le premier bloc libre.
// Le marquer immédiatement comme utilisé.
return -1;
}
Tests
public static void testStep5() {
System.out.println("=== TEST ÉTAPE 5 : Bitmap et Allocation ===");
MemoryManager mm = new MemoryManager();
assert mm.setBlockUsed(130, true) :
"setBlockUsed doit réussir";
assert mm.isBlockUsed(130) == 1 :
"Le bloc 130 doit être occupé";
assert mm.setBlockUsed(130, false) :
"La libération doit réussir";
assert mm.isBlockUsed(130) == 0 :
"Le bloc 130 doit être libre";
mm.setBlockUsed(129, true);
int bitmapOffset =
MemoryManager.BITMAP_OFFSET + (129 / 8);
assert (mm.getFilesystemMemory()[bitmapOffset]
& 0xFF) == 0x02 :
"Le bit du bloc 129 est incorrect";
mm.setBlockUsed(130, true);
assert (mm.getFilesystemMemory()[bitmapOffset]
& 0xFF) == 0x06 :
"Les bits 129 et 130 sont incorrects";
mm.setBlockUsed(130, false);
assert (mm.getFilesystemMemory()[bitmapOffset]
& 0xFF) == 0x02 :
"La libération du bloc 130 est incorrecte";
MemoryManager mm2 = new MemoryManager();
int first = mm2.allocateBlock();
int second = mm2.allocateBlock();
assert first == 129 :
"Le premier bloc de données doit être 129";
assert second == 130 :
"Le second bloc de données doit être 130";
assert mm2.isBlockUsed(129) == 1;
assert mm2.isBlockUsed(130) == 1;
assert mm2.isBlockUsed(-1) == -1 :
"Un bloc négatif doit être refusé";
assert mm2.isBlockUsed(
MemoryManager.NUM_BLOCKS) == -1 :
"Un bloc hors limites doit être refusé";
System.out.println("[OK] Étape 5 validée !");
}