Meine Implementierung eines Ringpuffers in NOR-Flash

Vorgeschichte

Es gibt eigene Verkaufsautomaten. Im Inneren befindet sich ein Raspberry Pi und etwas Verdrahtung auf einer separaten Platine. Angeschlossen sind ein MĂŒnz- und ein GeldscheineinzahlungsgerĂ€t sowie ein Bankterminal
 Alles wird von einem selbstgeschriebenen Programm gesteuert. Die gesamte Historie der BetriebsablĂ€ufe wird auf einem Flash-Laufwerk (MicroSD) in ein Protokoll geschrieben, das dann ĂŒber das Internet (mithilfe eines USB-Modems) auf einen Server ĂŒbermittelt wird, wo es in einer Datenbank gespeichert wird. Informationen ĂŒber VerkĂ€ufe werden in 1C hochgeladen; es gibt auch eine einfache Web-OberflĂ€che zur Überwachung usw.

Das bedeutet, dass das Protokoll lebensnotwendig ist – fĂŒr die Buchhaltung (Umsatz, VerkĂ€ufe usw.), die Überwachung (sĂ€mtliche AusfĂ€lle und andere außergewöhnliche UmstĂ€nde); sozusagen sind das alle Informationen, die wir ĂŒber diesen Automaten haben.

Problem

Die Flash-Laufwerke erweisen sich als sehr ung zuverlĂ€ssige GerĂ€te. Sie gehen mit beachtlicher RegelmĂ€ĂŸigkeit kaputt. Dies fĂŒhrt sowohl zu Ausfallzeiten der Automaten als auch (wenn das Protokoll aus irgendwelchen GrĂŒnden nicht online ĂŒbermittelt werden konnte) zu Datenverlusten.

Das ist bereits nicht die erste Erfahrung mit Flash-Laufwerken; zuvor gab es ein anderes Projekt mit mehr als hundert GerÀten, bei dem das Protokoll auf USB-Flash-Laufwerken gespeichert wurde. Auch dort gab es Probleme mit der ZuverlÀssigkeit, manchmal war die Anzahl der AusfÀlle pro Monat in den Dutzenden. Wir haben verschiedene Flash-Laufwerke getestet, darunter auch Markenprodukte mit SLC-Speicher; ja, einige Modelle sind zuverlÀssiger als andere, aber der Austausch der Flash-Laufwerke hat das Problem nicht grundlegend gelöst.

Achtung! Long-Read! Wenn Sie nicht interessiert sind an "warum", sondern nur an "wie", können Sie gleich zum Ende Artikel.

Lösung

Das Erste, was mir in den Sinn kommt: auf MicroSD verzichten, beispielsweise ein SSD einsetzen und von dort booten. Theoretisch ist das vielleicht möglich, aber relativ teuer und nicht so zuverlĂ€ssig (es muss ein USB-SATA-Adapter hinzugefĂŒgt werden; die Statistiken ĂŒber AusfĂ€lle bei budgetfreundlichen SSDs sind ebenfalls nicht erfreulich).

USB HDD sieht auch nicht besonders attraktiv aus.

Deshalb sind wir zu folgender Lösung gekommen: Den Bootvorgang von MicroSD beizubehalten, sie aber im Nur-Lese-Modus zu verwenden und das Betriebsprotokoll (und andere spezifische Informationen fĂŒr das jeweilige GerĂ€t – Seriennummer, Kalibrierung der Sensoren usw.) woanders zu speichern.

Das Thema read-only FS fĂŒr den Raspberry Pi wurde bereits umfassend untersucht, ich werde in diesem Artikel nicht auf die Einzelheiten der Umsetzung eingehen. (aber wenn Interesse besteht – vielleicht schreibe ich einen Mini-Artikel zu diesem Thema). Der einzige Punkt, den ich anmerken möchte: sowohl aus persönlicher Erfahrung als auch aus den RĂŒckmeldungen der bereits implementierenden Nutzer ist ein Gewinn an ZuverlĂ€ssigkeit vorhanden. Ja, es ist unmöglich, vollstĂ€ndig von AusfĂ€llen abzusehen, aber die HĂ€ufigkeit erheblich zu reduzieren — das ist durchaus realistisch. Zudem werden die Karten vereinheitlicht, was den Austausch fĂŒr das Servicepersonal erheblich vereinfacht.

Hardware

Bei der Wahl des Speichertyps gab es keine besonderen Zweifel — NOR Flash.
Argumente:

  • einfache Anbindung (meist ĂŒber SPI-Bus, von dem wir bereits Erfahrungen haben, sodass keine „technischen“ Probleme zu erwarten sind);
  • lĂ€cherlicher Preis;
  • Standardarbeitsprotokoll (eine Implementierung ist bereits im Linux-Kernel vorhanden, bei Bedarf kann man auch Drittanbieterlösungen nutzen, die ebenfalls vorhanden sind, oder sogar eine eigene schreiben, da alles recht einfach ist);
  • ZuverlĂ€ssigkeit und Lebensdauer:
    aus einem typischen Datenblatt: die Daten werden 20 Jahre lang aufbewahrt, 100000 Löschzyklen fĂŒr jeden Block;
    aus externen Quellen: extrem niedriger BER, es wird postuliert, dass keine Fehlerkorrekturcodes erforderlich sind. (in einigen Arbeiten wird ECC fĂŒr NOR betrachtet, aber normalerweise ist hier von MLC NOR die Rede, was ebenfalls vorkommen kann).

Lassen Sie uns die Anforderungen an Volumen und Lebensdauer abschÀtzen.

Wir wĂŒnschen uns, dass die Daten fĂŒr mehrere Tage sicher gespeichert werden. Dies ist wichtig, damit im Falle von Kommunikationsproblemen die Verkaufsstatistik nicht verloren geht. Wir orientieren uns an 5 Tagen, in diesem Zeitraum (sogar unter BerĂŒcksichtigung von Wochenenden und Feiertagen) kann das Problem gelöst werden.

Aktuell sammeln wir tĂ€glich etwa 100KB Protokolle (3-4 Tausend EintrĂ€ge), aber diese Zahl wĂ€chst allmĂ€hlich — die Detaillierung nimmt zu, neue Ereignisse werden hinzugefĂŒgt. Außerdem gibt es manchmal Spitzen (ein gewisser Sensor fĂ€ngt an, mit falschen Auslösungen zu spammen, zum Beispiel). Wir rechnen mit 10 Tausend EintrĂ€gen zu je 100 Byte — ein Megabyte pro Tag.

Insgesamt ergibt das 5MB an reinen (gut komprimierbaren) Daten. Dazu kommen noch (grobe SchÀtzung) 1MB an Steuerdaten.

Das heißt, wir benötigen einen Chip mit 8MB, wenn wir keine Komprimierung verwenden, oder 4MB, wenn wir es tun. Völlig realistische Zahlen fĂŒr diesen Speichertyp.

Was die Lebensdauer betrifft: Wenn wir planen, dass der Speicher insgesamt nicht hĂ€ufiger als alle 5 Tage ĂŒberschrieben wird, erhalten wir bei 10 Jahren Betriebsdauer weniger als tausend Wiederbeschreibungszyklen.
Ich erinnere daran, dass der Hersteller zehntausend verspricht.

Ein wenig zu NOR vs NAND

Heute ist natĂŒrlich NAND-Speicher viel beliebter, aber fĂŒr dieses Projekt wĂŒrde ich ihn nicht verwenden: NAND erfordert im Gegensatz zu NOR unbedingt die Verwendung von Fehlerkorrekturcodes, fehlerhaften Blocktabellen usw. Außerdem haben NAND-Chips normalerweise viel mehr Pins.

Als Nachteile von NOR kann man angeben:

  • geringe KapazitĂ€t (und entsprechend hoher Preis pro Megabyte);
  • nicht hohe Übertragungsgeschwindigkeit (hauptsĂ€chlich aufgrund des verwendeten seriellen Interfaces, ĂŒblicherweise SPI oder I2C);
  • langsame LöschvorgĂ€nge (je nach BlockgrĂ¶ĂŸe dauert es von Bruchteilen einer Sekunde bis zu mehreren Sekunden).

Nichts Kritisches scheint es fĂŒr uns zu sein, also machen wir weiter.

Wenn Sie an Einzelheiten interessiert sind, wurde der Chip ausgewĂ€hlt at25df321a (das ist jedoch nicht entscheidend, auf dem Markt gibt es viele Analoga, die hinsichtlich Pinbelegung und Befehlssatz kompatibel sind; selbst wenn wir einen Chip eines anderen Herstellers und/oder mit anderer KapazitĂ€t verwenden möchten, wird alles ohne Änderung des Codes funktionieren).

Ich verwende den im Linux-Kernel eingebauten Treiber, auf Raspberry ist es dank der UnterstĂŒtzung des Device Tree Overlays ganz einfach – man muss einfach den kompilierten Overlay in /boot/overlays legen und /boot/config.txt ein wenig modifizieren.

Beispiel einer dts-Datei

Ehrlich gesagt bin ich mir nicht sicher, ob es fehlerfrei geschrieben ist, aber es funktioniert.

/*
 * Device tree overlay for at25 at spi0.1
 */

/dts-v1/;
/plugin/;

/ {
    compatible = "brcm,bcm2835", "brcm,bcm2836", "brcm,bcm2708", "brcm,bcm2709"; 

    /* disable spi-dev for spi0.1 */
    fragment@0 {
        target = <&spi0>;
        __overlay__ {
            status = "okay";
            spidev@1{
                status = "disabled";
            };
        };
    };

    /* the spi config of the at25 */
    fragment@1 {
        target = <&spi0>;
        __overlay__ {
            #address-cells = <1>;
            #size-cells = <0>;
            flash: m25p80@1 {
                    compatible = "atmel,at25df321a";
                    reg = <1>;
                    spi-max-frequency = <50000000>;

                    /* default to false:
                    m25p,fast-read ;
                    */
            };
        };
    };

    __overrides__ {
        spimaxfrequency = <&flash>,"spi-max-frequency:0";
        fastread = <&flash>,"m25p,fast-read?";
    };
};

Und noch eine Zeile in config.txt

dtoverlay=at25:spimaxfrequency=50000000

Die Beschreibung der Anschlussmethode des Chips an den Raspberry Pi lasse ich weg. Einerseits bin ich kein Elektronikspezialist, andererseits ist es fĂŒr mich auch banal: Der Chip hat nur 8 Pins, von denen wir Masse, Stromversorgung, SPI (CS, SI, SO, SCK) benötigen; die Pegel stimmen mit den entsprechenden beim Raspberry Pi ĂŒberein, es sind keine zusĂ€tzlichen Verbindungen erforderlich – man verbindet einfach die angegebenen 6 Kontakte.

Aufgabenstellung

Wie gewohnt gibt es mehrere Iterationen bei der Aufgabenstellung, ich denke, es ist an der Zeit fĂŒr eine weitere. Lassen Sie uns also innehalten, das, was bereits geschrieben wurde, zusammenfassen und die noch im Schatten verbliebenen Details klĂ€ren.

Also haben wir uns darauf geeinigt, dass das Journal im SPI NOR Flash gespeichert wird.

Was ist NOR Flash fĂŒr diejenigen, die es nicht wissen

Es ist nichtflĂŒchtiger Speicher, mit dem man drei Operationen durchfĂŒhren kann:

  1. Lesen:
    Das ganz gewöhnliche Lesen: wir ĂŒbergeben die Adresse und lesen so viele Bytes, wie wir benötigen;
  2. Aufzeichnung:
    Der Schreibvorgang in NOR-Flash sieht gewöhnlich aus, hat jedoch eine Besonderheit: Man kann nur 1 in 0 umwandeln, nicht umgekehrt. Wenn zum Beispiel in einer Speichereinheit 0x55 gespeichert war, wĂŒrde nach dem Schreiben von 0x0f dort 0x05 gespeichert sein. (siehe Tabelle etwas weiter unten);
  3. Löschen:
    NatĂŒrlich mĂŒssen wir auch die umgekehrte Operation beherrschen – 0 in 1 umzuwandeln, genau dafĂŒr gibt es die Löschoperation. Im Gegensatz zu den ersten beiden operiert sie nicht mit Bytes, sondern mit Blöcken (der minimale Löschblock im ausgewĂ€hlten Chip betrĂ€gt 4 KB). Löschen zerstört den gesamten Block und dies ist die einzige Möglichkeit, 0 in 1 umzuwandeln. Daher muss man beim Arbeiten mit Flash-Speicher oft Datenstrukturen an die Grenzen des Löschblocks anpassen.
    Schreiben in NOR-Flash:

BinÀrdaten

War
01010101

Gespeichert
00001111

Wurde
00000101

Das Journal selbst besteht aus einer Sequenz von EintrÀgen variabler LÀnge. Eine typische EintragslÀnge betrÀgt etwa 30 Bytes (obwohl manchmal auch EintrÀge mit mehreren Kilobyte vorkommen). In diesem Fall behandeln wir sie einfach als eine Menge von Bytes, aber wenn es interessiert, werden innerhalb der EintrÀge CBOR verwendet.

Neben dem Journal mĂŒssen wir einige "Einstellinformationen" speichern, sowohl aktualisierbare als auch nicht: eine Art GerĂ€te-ID, Kalibrierungen von Sensoren, ein Flag "GerĂ€t vorĂŒbergehend ausgeschaltet", usw.
Diese Informationen bestehen aus einer Reihe von SchlĂŒssel-Wert-Paaren und werden ebenfalls in CBOR gespeichert. Wir haben nicht viele dieser Informationen (höchstens einige Kilobyte) und sie werden nicht hĂ€ufig aktualisiert.
Im weiteren Verlauf werden wir sie Kontext nennen.

Wenn wir uns erinnern, womit dieser Artikel begann, ist es sehr wichtig, die ZuverlÀssigkeit der Datenspeicherung zu gewÀhrleisten und, wenn möglich, einen ununterbrochenen Betrieb selbst bei Hardwarefehlern oder DatenbeschÀdigungen sicherzustellen.

Welche Problemquellen können wir betrachten?

  • Stromausfall wĂ€hrend der Write-/Erase-Operationen. Das gehört zu der Kategorie "Gegen einen Brecheisen gibt es kein Rezept".
    Information aus Diskussion auf Stack Exchange: Bei einem Stromausfall wĂ€hrend der Arbeit mit Flash fĂŒhrt sowohl ein Löschen (Setzen auf 1) als auch ein Schreiben (Setzen auf 0) zu einem undefinierten Verhalten: Daten können geschrieben werden, teilweise geschrieben werden (sagen wir, wir haben 10 Bytes/80 Bit ĂŒbertragen, aber es wurden nur 45 Bit geschrieben), es ist auch möglich, dass einige Bits in einem "Zwischenzustand" sind (das Lesen kann sowohl 0 als auch 1 ausgeben);
  • Fehler im Flash-Speicher selbst.
    Obwohl die BER sehr niedrig ist, kann sie nicht null sein;
  • Fehler ĂŒber den Bus
    Die ĂŒber SPI ĂŒbertragenen Daten sind nicht geschĂŒtzt und können sowohl einfache Bitfehler als auch Synchronisationsfehler – den Verlust oder das EinfĂŒgen von Bits (was zu massiven Datenverzerrungen fĂŒhrt) – aufweisen;
  • Weitere Fehler/Fehlfunktionen
    Fehler im Code, „Glitches“ bei Raspberry, Eingreifen von Außerirdischen


Ich habe die Anforderungen formuliert, deren ErfĂŒllung meiner Meinung nach notwendig ist, um die ZuverlĂ€ssigkeit zu gewĂ€hrleisten:

  • Aufzeichnungen mĂŒssen sofort in den Flash-Speicher geschrieben werden, verzögerte Aufzeichnungen werden nicht in Betracht gezogen; - wenn ein Fehler auftritt, muss dieser so frĂŒh wie möglich erkannt und verarbeitet werden; - das System sollte nach Möglichkeit nach Fehlern wieder funktionsfĂ€hig gemacht werden.
    (ein praktisches Beispiel „wie es nicht sein sollte“, mit dem ich denke, dass alle konfrontiert wurden: nach einem unerwarteten Neustart war das Dateisystem beschĂ€digt und das Betriebssystem startet nicht)

Ideen, AnsĂ€tze, Überlegungen

Als ich anfing, ĂŒber diese Aufgabe nachzudenken, schoss mir eine Menge Ideen durch den Kopf, zum Beispiel:

  • Datenkompression verwenden;
  • schlaue Datenstrukturen verwenden, zum Beispiel die Kopfzeilen von Aufzeichnungen getrennt von den eigentlichen Aufzeichnungen speichern, damit bei einem Fehler in einer Aufzeichnung die anderen problemlos gelesen werden können;
  • Bitfelder zur Kontrolle der VollstĂ€ndigkeit der Aufzeichnung bei Stromausfall verwenden;
  • PrĂŒfziffern fĂŒr alles und jedes speichern;
  • eine Art von fehlerresistenter Codierung verwenden.

Ein Teil dieser Ideen wurde umgesetzt, teilweise wurde entschieden, darauf zu verzichten. Lassen Sie uns der Reihe nach vorgehen.

Datenkompression

Die Ereignisse, die wir im Protokoll festhalten, sind in der Regel recht gleichförmig und wiederholend („eine 5-Rubel-MĂŒnze geworfen“, „die Taste fĂŒr die RĂŒckgabe gedrĂŒckt“, 
). Daher sollte die Kompression recht effektiv sein.

Die Kosten fĂŒr die Kompression sind gering (wir haben einen ziemlich leistungsstarken Prozessor, selbst das erste Pi hatte einen Kern mit 700 MHz, bei den aktuellen Modellen mehrere Kerne mit ĂŒber einem Gigahertz), die Geschwindigkeit des Austauschs mit dem Speicher ist niedrig (einige Megabyte pro Sekunde), die GrĂ¶ĂŸe der Aufzeichnungen ist klein. Insgesamt, wenn die Kompression einen Einfluss auf die Leistung hat, dann nur positiv. (absolut nicht kritisch, ich stelle es einfach fest). Wir haben ja kein echtes Embedded, sondern ein gewöhnliches Linux – die Implementierung sollte also nicht viele Anstrengungen erfordern (es reicht aus, die Bibliothek einfach zu verlinken und einige Funktionen daraus zu verwenden).

Es wurde ein Ausschnitt aus dem Protokoll eines funktionierenden GerĂ€ts (1,7 MB, 70.000 EintrĂ€ge) entnommen und zunĂ€chst auf Komprimierbarkeit mit den auf dem Computer verfĂŒgbaren gzip, lz4, lzop, bzip2, xz, zstd ĂŒberprĂŒft.

  • Gzip, xz und zstd zeigten Ă€hnliche Ergebnisse (40 KB).
    Es erstaunte, dass das angesagte xz hier auf dem Niveau von gzip oder zstd abschneidet;
  • Lzip mit den Standardeinstellungen lieferte ein etwas schlechteres Ergebnis;
  • Lz4 und lzop zeigten kein besonders gutes Ergebnis (150 KB);
  • Bzip2 zeigte ein ĂŒberraschend gutes Ergebnis (18 KB).

Die Daten komprimieren sich also sehr gut.
Wenn wir also (wenn wir keine fatalen MĂ€ngel finden) das Komprimieren umsetzen! Einfach, weil mehr Daten auf dasselbe Flash-Laufwerk passen.

Lassen Sie uns ĂŒber die Nachteile nachdenken.

Das erste Problem: Wir haben bereits vereinbart, dass jeder Eintrag sofort auf das Flash-Laufwerk geschrieben werden muss. Normalerweise sammelt ein Archivierungsprogramm die Daten aus dem Eingabestrom, bis es entscheidet, dass es Zeit ist, sie in den Ausgang zu schreiben. Wir hingegen mĂŒssen sofort einen komprimierten Datenblock erhalten und ihn im nichtflĂŒchtigen Speicher speichern.

Ich sehe drei Wege:

  1. Jeden Eintrag mit Hilfe von Dictionary-Kompression statt der oben genannten Algorithmen zu komprimieren.
    Das ist eine durchaus praktikable Möglichkeit, aber ich mag sie nicht. Um einen mehr oder weniger akzeptablen Komprimierungsgrad zu gewĂ€hrleisten, muss das Wörterbuch auf bestimmte Daten angepasst sein; jede Änderung fĂŒhrt dazu, dass der Komprimierungsgrad katastrophal sinkt. Ja, das Problem kann durch die Erstellung einer neuen Version des Wörterbuchs gelöst werden, aber das wĂ€re Kopfschmerzen – wir mĂŒssten alle Wörterbuchversionen speichern; bei jedem Eintrag mĂŒssten wir angeben, mit welcher Version des Wörterbuchs er komprimiert wurde

  2. Jeden Eintrag mit „klassischen“ Algorithmen zu komprimieren, jedoch unabhĂ€ngig von anderen.
    Die betrachteten Kompressionsalgorithmen sind nicht fĂŒr die Verarbeitung von EintrĂ€gen dieser GrĂ¶ĂŸe (Dutzende von Bytes) konzipiert; der Kompressionsfaktor wird eindeutig unter 1 liegen (das heißt, die Datenmenge wird statt komprimiert vergrĂ¶ĂŸert);
  3. Nach jedem Eintrag FLUSH zu machen.
    In vielen Kompressionsbibliotheken gibt es UnterstĂŒtzung fĂŒr FLUSH. Dies ist ein Befehl (oder ein Parameter des Komprimierungsprozesses), der, wenn er empfangen wird, sicherstellt, dass der Archivator den komprimierten Stream so strukturiert, dass die bereits erhaltenen Ressourcen) abgeschlossen ist, lösen wir nicht komprimierten Daten wiederhergestellt werden können. Eine solche Analogie sync in Dateisystemen oder commit in SQL.
    Es ist wichtig, dass die folgenden Komprimierungsoperationen das angesammelte Wörterbuch verwenden können und der Grad der Kompression nicht so stark leidet wie im vorherigen Fall.

Ich denke, es ist offensichtlich, dass ich die dritte Option gewÀhlt habe; lassen Sie uns genauer darauf eingehen.

Gefunden ein ausgezeichneter Artikel ĂŒber FLUSH in zlib.

Ich habe einen Test basierend auf einem Artikel gemacht, ich nahm 70.000 ProtokolleintrĂ€ge von einem echten GerĂ€t, bei einer SeitengrĂ¶ĂŸe von 60KB (auf die SeitengrĂ¶ĂŸe kommen wir noch zurĂŒck) erhalten:

Stammdaten
Gzip-Kompression -9 (ohne FLUSH)
zlib mit Z_PARTIAL_FLUSH
zlib mit Z_SYNC_FLUSH

Volumen, KB
1692
40
352
604

Auf den ersten Blick scheint der Preis, den FLUSH verursacht, ĂŒbermĂ€ĂŸig hoch zu sein; jedoch haben wir tatsĂ€chlich nicht viele Optionen - entweder gar nicht komprimieren oder (sehr effektiv) mit FLUSH komprimieren. Man sollte nicht vergessen, dass wir 70.000 EintrĂ€ge haben, und die ÜberflĂŒssigkeit, die durch Z_PARTIAL_FLUSH eingefĂŒhrt wird, betrĂ€gt nur 4-5 Bytes pro Eintrag. Der Kompressionsfaktor stellte sich als fast 5:1 heraus, was mehr als ein hervorragendes Ergebnis ist.

Es mag ĂŒberraschend erscheinen, aber tatsĂ€chlich ist Z_SYNC_FLUSH eine effektivere Methode, um FLUSH durchzufĂŒhren.

Wenn Z_SYNC_FLUSH verwendet wird, werden die letzten 4 Bytes jedes Eintrags immer 0x00, 0x00, 0xff, 0xff sein. Und wenn wir sie kennen, dann können wir sie nicht speichern, sodass die endgĂŒltige GrĂ¶ĂŸe nur 324KB betrĂ€gt.

In dem Artikel, auf den ich mich beziehe, gibt es eine ErklÀrung:

Ein neuer Typ-0-Block mit leeren Inhalten wird angehÀngt.

Ein Typ-0-Block mit leeren Inhalten besteht aus:

  • dem dreibitigen Blockkopf;
  • 0 bis 7 Bits, die gleich Null sind, um die Byte-Ausrichtung zu erreichen;
  • der vier-Byte-Sequenz 00 00 FF FF.

Wie man leicht erkennen kann, gehen vor diesen 4 Bytes im letzten Block 3 bis 10 Nullbits. Die Praxis hat jedoch gezeigt, dass es tatsÀchlich mindestens 10 Nullbits gibt.

Es stellt sich heraus, dass so kurze Datenblöcke normalerweise (immer?) mit einem Block vom Typ 1 (fester Block) codiert werden, der unbedingt mit 7 Nullbits endet, sodass wir insgesamt 10-17 garantiert null Bits erhalten (und die anderen werden mit einer Wahrscheinlichkeit von etwa 50 % null sein).

Daher zeigt sich in den Testdaten, dass in 100 % der FĂ€lle vor 0x00, 0x00, 0xff, 0xff ein Nullbyte kommt, und in mehr als einem Drittel der FĂ€lle - zwei Nullbytes (möglicherweise liegt es daran, dass ich binĂ€res CBOR verwende, und bei der Verwendung von textlichem JSON wĂŒrden wir wahrscheinlich öfter auf Blöcke vom Typ 2 - dynamischer Block - stoßen, weshalb wir auch auf Blöcke ohne zusĂ€tzliche Nullbytes vor 0x00, 0x00, 0xff, 0xff stoßen wĂŒrden).

Insgesamt kann man mit den vorhandenen Testdaten in weniger als 250KB komprimierten Daten unterkommen.

Man kann noch etwas sparen, indem man mit den Bits jongliert: Wir ignorieren derzeit das Vorhandensein mehrerer Null-Bits am Ende des Blocks, einige Bits am Anfang des Blocks bleiben ebenfalls unverÀndert ...
Aber dann traf ich die Entscheidung, aufzuhören, sonst könnte ich in der Geschwindigkeit zur Entwicklung meines eigenen Archivierers gelangen.

Insgesamt habe ich aus meinen Testdaten 3-4 Bytes pro Schreibvorgang erhalten, das KomprimierungsverhĂ€ltnis liegt bei ĂŒber 6:1. Ehrlich gesagt: Auf ein solches Ergebnis hatte ich nicht gehofft, meiner Meinung nach ist alles, was besser als 2:1 ist, bereits ein Ergebnis, das die Verwendung von Kompression rechtfertigt.

Alles gut, aber zlib (deflate) ist dennoch ein ehrwĂŒrdiger und etwas altmodischer Komprimierungsalgorithmus. Schon alleine, dass als Wörterbuch die letzten 32 Kb des unkomprimierten Datenstroms verwendet werden, sieht heute seltsam aus (das heißt, wenn ein bestimmter Datenblock dem Ă€hnelt, was vor 40 Kb im Eingabestrom war, wird er neu archiviert und verweist nicht auf den vorherigen Vorkommen). In modernen und angesagten Archivierern wird die Dictionary-GrĂ¶ĂŸe hĂ€ufig in Megabyte und nicht in Kilobyte gemessen.

Also machen wir unsere Mini-Studie zu Archivierern weiter.

Als NĂ€chstes wurde bzip2 ausprobiert (ich erinnere daran, dass es ohne FLUSH einen fantastischen Komprimierungsgrad von fast 100:1 zeigte). Leider hat es mit FLUSH sehr schlecht abgeschnitten, die GrĂ¶ĂŸe der komprimierten Daten war grĂ¶ĂŸer als die der unkomprimierten.

Meine Vermutungen ĂŒber die GrĂŒnde fĂŒr das Scheitern

Libbz2 bietet nur eine Flush-Option an, die anscheinend das Wörterbuch löscht (analog zu Z_FULL_FLUSH in zlib), sodass man nicht von einer effektiven Kompression danach sprechen kann.

Und zuletzt wurde zstd ausprobiert. Je nach Parametern komprimiert es entweder auf Gzip-Niveau, jedoch viel schneller, oder besser als Gzip.

Leider hat es sich mit FLUSH ebenfalls 'nicht so gut' prĂ€sentiert: Die GrĂ¶ĂŸe der komprimierten Daten betrug etwa 700 Kb.

Ich Ich habe eine Frage gestellt auf der Projektseite in GitHub, und erhielt die Antwort, dass man mit bis zu 10 Bytes an Verwaltungsdaten pro Block der komprimierten Daten rechnen sollte, was den erhaltenen Ergebnissen nahekommt, den deflate wird man nicht einholen können.

Damit habe ich beschlossen, meine Experimente mit Archivierern zu beenden (ich erinnere daran, dass xz, lzip, lzo, lz4 sich bei den Tests ohne FLUSH nicht bewĂ€hrt haben, und ich habe es nicht fĂŒr nötig gehalten, exotischere Komprimierungsalgorithmen zu betrachten).

Kehren wir zu den Problemen der Archivierung zurĂŒck.

Das zweite (wie es sagt, nach Reihenfolge, nicht nach Bedeutung) Problem ist, dass komprimierte Daten einen einheitlichen Strom darstellen, in dem stÀndig Verweise auf vorherige Abschnitte gemacht werden. Dadurch verlieren wir bei BeschÀdigung eines Abschnitts der komprimierten Daten nicht nur den damit verbundenen Block unkomprimierter Daten, sondern auch alle nachfolgenden.

Es gibt AnsÀtze zur Lösung dieses Problems:

  1. Das Auftreten des Problems verhindern – redundante Informationen zu den komprimierten Daten hinzufĂŒgen, die es ermöglichen, Fehler zu erkennen und zu korrigieren; darĂŒber werden wir spĂ€ter sprechen;
  2. Die Folgen im Fall eines Problems minimieren.
    Wir haben bereits frĂŒher gesagt, dass wir jeden Datenblock unabhĂ€ngig komprimieren können, wodurch das Problem von selbst verschwinden wĂŒrde (eine BeschĂ€digung der Daten eines Blocks wĂŒrde nur zum Verlust der Daten dieses Blocks fĂŒhren). Dies ist jedoch der Extremfall, in dem die Datenkompression ineffizient wird. Der entgegengesetzte Extremfall: alle 4Mb unseres Mikrochips als ein einziges Archiv zu verwenden, was uns eine hervorragende Komprimierung bieten wĂŒrde, aber katastrophale Folgen im Fall von DatenbeschĂ€digung hĂ€tte.
    Ja, ein Kompromiss ist aus Sicht der ZuverlĂ€ssigkeit erforderlich. Aber wir mĂŒssen uns daran erinnern, dass wir ein Datenformat fĂŒr nichtflĂŒchtigen Speicher mit extrem niedrigem BER und einer angegebenen Datenhaltbarkeit von 20 Jahren entwickeln.

Im Verlauf der Experimente habe ich festgestellt, dass spĂŒrbare Verluste in der Kompressionsrate bei komprimierten Datenblöcken von weniger als 10Kb beginnen.
Es wurde vorher erwĂ€hnt, dass der verwendete Speicher eine seitenbasierte Organisation hat. Ich sehe keinen Grund, warum man nicht die Zuordnung „eine Seite – ein Block komprimierter Daten“ verwenden sollte.

Das heißt, die minimal vernĂŒnftige SeitengrĂ¶ĂŸe betrĂ€gt 16Kb (mit einem Puffer fĂŒr Verwaltungsinformationen). Allerdings bringt eine so kleine SeitengrĂ¶ĂŸe erhebliche EinschrĂ€nkungen fĂŒr die maximale AufzeichnungsgrĂ¶ĂŸe mit sich.

Obwohl ich bisher keine Aufzeichnungen grĂ¶ĂŸer als ein Kilobyte in komprimierter Form plane, habe ich mich entschieden, Seiten mit einer GrĂ¶ĂŸe von 32Kb zu verwenden (insgesamt 128 Seiten pro Chip).

Zusammenfassung:

  • Die Daten werden komprimiert mit zlib (deflate) gespeichert;
  • FĂŒr jede Aufzeichnung setzen wir Z_SYNC_FLUSH;
  • Jede komprimierte Aufzeichnung wird an den Endbytes gekĂŒrzt. (zum Beispiel 0x00, 0x00, 0xff, 0xff); im Header geben wir an, wie viele Bytes wir gekĂŒrzt haben;
  • Die Daten speichern wir seitenweise mit 32 KB; innerhalb der Seite lĂ€uft ein einheitlicher Strom komprimierter Daten; bei jeder Seite beginnen wir die Kompression von neuem.

Und bevor wir mit der Kompression fertig sind, möchte ich darauf hinweisen, dass wir nur einige Bytes an komprimierten Daten pro Aufzeichnung erhalten, daher ist es Ă€ußerst wichtig, die Verwaltungsinformationen nicht aufzublĂ€hen, jedes Byte zĂ€hlt hier.

Speicherung von DatenĂŒberschriften

Da wir Aufzeichnungen variabler LĂ€nge haben, mĂŒssen wir eine Möglichkeit finden, die Platzierung/Grenzen der Aufzeichnungen zu bestimmen.

Ich kenne drei AnsÀtze:

  1. Alle Aufzeichnungen werden in einem kontinuierlichen Strom gespeichert, zuerst kommt die Überschrift der Aufzeichnung, die die LĂ€nge enthĂ€lt, und danach die eigentliche Aufzeichnung.
    In dieser Variante können sowohl die Überschriften als auch die Daten eine variable LĂ€nge haben.
    Im Grunde erhalten wir eine einfach verkettete Liste, die ĂŒberall verwendet wird;
  2. Überschriften und die eigentlichen Aufzeichnungen werden in separaten Strömen gespeichert.
    Durch die Verwendung von Überschriften fester LĂ€nge sorgen wir dafĂŒr, dass die BeschĂ€digung einer Überschrift keine Auswirkungen auf die anderen hat.
    Ein Àhnlicher Ansatz wird zum Beispiel in vielen Dateisystemen verwendet;
  3. Aufzeichnungen werden in einem kontinuierlichen Strom gespeichert, die Grenze der Aufzeichnung wird durch einen bestimmten Marker (Symbol/Zeichenfolge, die innerhalb der Datenblöcke verboten ist) bestimmt. Wenn innerhalb einer Aufzeichnung ein Marker gefunden wird, ersetzen wir ihn durch eine bestimmte Folge (wir maskieren ihn).
    Ein Àhnlicher Ansatz wird beispielsweise im PPP-Protokoll verwendet.

Ich werde es veranschaulichen.

Option 1:
Meine Implementierung eines Ringpuffers in NOR-Flash
Es ist ganz einfach: Wenn wir die LĂ€nge der Aufzeichnung kennen, können wir die Adresse der nĂ€chsten Überschrift berechnen. So bewegen wir uns durch die Überschriften, bis wir auf einen Bereich treffen, der mit 0xff gefĂŒllt ist (freier Bereich) oder das Seitenende erreicht ist.

Option 2:
Meine Implementierung eines Ringpuffers in NOR-Flash
Aufgrund der variablen LĂ€nge der Aufzeichnungen können wir nicht im Voraus sagen, wie viele Aufzeichnungen (also auch Überschriften) wir pro Seite benötigen werden. Man könnte die Überschriften und die Daten auf verschiedenen Seiten anordnen, aber ich bevorzuge einen anderen Ansatz: sowohl Überschriften als auch Daten werden auf einer Seite platziert, jedoch beginnen die Überschriften (fester GrĂ¶ĂŸe) vom Anfang der Seite und die Daten (variabler LĂ€nge) vom Ende. Sobald sie sich „treffen“ (nicht genĂŒgend freien Platz fĂŒr eine neue Aufzeichnung) - betrachten wir diese Seite als voll.

Variante 3:
Meine Implementierung eines Ringpuffers in NOR-Flash
Es ist nicht notwendig, die LĂ€nge oder andere Informationen ĂŒber die Datenanordnung im Header zu speichern; Marker, die die Grenzen der DatensĂ€tze anzeigen, sind ausreichend. Allerdings mĂŒssen die Daten beim Schreiben/Lesen verarbeitet werden.
Als Marker wĂŒrde ich 0xff verwenden (mit dem die Seite nach dem Löschen gefĂŒllt ist), so dass der freie Bereich sicherlich nicht als Daten interpretiert wird.

Vergleichstabelle:

Option 1
Option 2
Option 3

Fehlerresistenz
—
+
+

Kompaktheit
+
—
+

ImplementierungskomplexitÀt
*
**
**

Variante 1 hat einen fatalen Nachteil: Wenn einer der Header beschÀdigt wird, wird die gesamte nachfolgende Kette zerstört. Die anderen Varianten ermöglichen es, Teile der Daten selbst bei massiven BeschÀdigungen wiederherzustellen.
Es ist jedoch erwĂ€hnenswert, dass wir uns entschieden haben, die Daten komprimiert zu speichern; so oder so verlieren wir alle Daten auf der Seite nach einem "kaputten" Schreibvorgang, sodass wir die Minuszahl in der Tabelle nicht berĂŒcksichtigen.

Kompaktheit:

  • Bei der ersten Variante mĂŒssen wir im Header nur die LĂ€nge speichern; bei der Verwendung variabler LĂ€ngen Ganzzahlen können wir in den meisten FĂ€llen mit einem Byte auskommen;
  • In der zweiten Variante mĂŒssen wir die Startadresse und die LĂ€nge speichern; der Datensatz sollte eine konstante GrĂ¶ĂŸe haben, ich schĂ€tze 4 Byte pro Datensatz (zwei Bytes fĂŒr die Verschiebung und zwei Bytes fĂŒr die LĂ€nge);
  • Die dritte Variante benötigt nur ein Zeichen, um den Anfang des Datensatzes zu kennzeichnen; aufgrund der Maskierung wird der Datensatz um 1-2% grĂ¶ĂŸer. Insgesamt gibt es eine ungefĂ€hre ParitĂ€t zur ersten Variante.

UrsprĂŒnglich betrachtete ich die zweite Variante als die Hauptvariante (und habe sogar eine Implementierung geschrieben). Ich habe sie nur verworfen, als ich endgĂŒltig beschloss, Kompression zu verwenden.

Vielleicht werde ich irgendwann doch eine Ă€hnliche Variante verwenden. Zum Beispiel, wenn ich fĂŒr ein Raumschiff, das zwischen der Erde und dem Mars verkehrt, Daten speichern muss – ganz andere Anforderungen an die ZuverlĂ€ssigkeit, Strahlung im Weltraum, ...

Was die dritte Variante angeht: Ich habe ihr wegen der KomplexitĂ€t der Implementierung zwei Sterne gegeben, einfach weil ich nicht gerne mit Maskierung, LĂ€ngenĂ€nderung wĂ€hrend des Prozesses usw. umgehe. Ja, möglicherweise voreingenommen, aber ich muss den Code schreiben – warum sollte ich mich zwingen, etwas zu tun, das mir nicht gefĂ€llt.

Zusammenfassung: Wir wĂ€hlen die Speicheroption in Form von Ketten "Header mit LĂ€nge – variabel lange Daten" wegen der Effizienz und Einfachheit der Implementierung.

Die Verwendung von Bitfeldern zur Kontrolle des Erfolgs von SchreibvorgÀngen

Ich erinnere mich nicht mehr, wo ich die Idee gesehen habe, aber es sieht ungefÀhr so aus:
FĂŒr jeden Eintrag reservieren wir mehrere Bits zur Speicherung von Flags.
Wie bereits erwĂ€hnt, sind nach dem Löschen alle Bits auf 1 gesetzt, und wir können 1 auf 0 Ă€ndern, aber nicht umgekehrt. FĂŒr "Flag nicht gesetzt" verwenden wir 1, fĂŒr "Flag gesetzt" — 0.

So könnte die Unterbringung eines variablen LÀngeneintrags im Flash aussehen:

  1. Setzen des Flags „LĂ€nge des Schreibens begonnen“;
  2. LĂ€nge aufzeichnen;
  3. Setzen des Flags „Datenaufzeichnung begonnen“;
  4. Daten aufzeichnen;
  5. Setzen des Flags „Aufzeichnung abgeschlossen“.

DarĂŒber hinaus haben wir ein Flag „Fehler aufgetreten“, insgesamt also 4 Bit-Flags.

In diesem Fall haben wir zwei stabile ZustĂ€nde „1111“ — Aufzeichnung nicht begonnen und „1000“ — Aufzeichnung war erfolgreich; bei unvorhergesehenen Unterbrechungen des Schreibvorgangs erhalten wir ZwischenzustĂ€nde, die wir spĂ€ter erkennen und verarbeiten können.

Der Ansatz ist interessant, schĂŒtzt aber nur vor plötzlichem Stromausfall und Ă€hnlichen Störungen, was natĂŒrlich wichtig ist, jedoch bei weitem nicht die einzige (und selbst nicht die Haupt-) Ursache fĂŒr mögliche Störungen darstellt.

Zusammenfassung: Lass uns weiter auf der Suche nach einer guten Lösung.

PrĂŒfziffern

PrĂŒfziffern ermöglichen ebenfalls zu verifizieren (mit ausreichender Wahrscheinlichkeit), dass wir genau das lesen, was hĂ€tte aufgezeichnet werden sollen. Und im Gegensatz zu den oben betrachteten Bitfeldern funktionieren sie immer.

Wenn wir die Liste potenzieller Problemerkenner betrachten, die wir zuvor besprochen haben, kann die PrĂŒfziffer einen Fehler unabhĂ€ngig von seiner Herkunft erkennen (außer natĂŒrlich bei böswilligen Außerirdischen — die können auch die PrĂŒfziffer fĂ€lschen).

Wenn unser Ziel also darin besteht, zu ĂŒberprĂŒfen, ob die Daten intakt sind, sind PrĂŒfziffern eine hervorragende Idee.

Die Wahl des Algorithmus zur Berechnung der PrĂŒfziffer war unproblematisch — CRC. Einerseits ermöglichen mathematische Eigenschaften, dass einige Typen von Fehlern zu 100 % erkannt werden, andererseits zeigt dieser Algorithmus bei zufĂ€lligen Daten normalerweise eine Kollisionswahrscheinlichkeit, die nicht wesentlich ĂŒber dem theoretischen Limit liegt. Meine Implementierung eines Ringpuffers in NOR-FlashMöge es nicht der schnellste Algorithmus sein und nicht immer die geringste Anzahl von Kollisionen aufweisen, aber er hat eine sehr wichtige Eigenschaft: In den mir begegneten Tests gab es keine Muster, bei denen er klar versagte. StabilitĂ€t ist in diesem Fall die wichtigste Eigenschaft.

Beispiel fĂŒr eine umfassende Untersuchung: Teil 1, Teil 2 (Links zu narod.ru, entschuldigung).

Die Aufgabe der Auswahl einer PrĂŒfziffer ist jedoch noch nicht abgeschlossen, CRC ist eine ganze Familie von PrĂŒfziffern. Man muss sich mit der LĂ€nge entscheiden und dann ein Polynom auswĂ€hlen.

Die Auswahl der LĂ€nge der PrĂŒfziffer ist nicht so einfache Frage, wie sie auf den ersten Blick erscheint.

Ich werde es veranschaulichen:
Angenommen, wir haben eine Fehlerwahrscheinlichkeit in jedem Byte Meine Implementierung eines Ringpuffers in NOR-Flash und eine ideale PrĂŒfziffer, berechnen wir die durchschnittliche Anzahl von Fehlern auf eine Million EintrĂ€ge:

Daten, Byte
PrĂŒfziffer, Byte
Nicht entdeckte Fehler
Falschpositive Fehlererkennungen
Insgesamt falsche Auslösungen

1
0
1000
0
1000

1
1
4
999
1003

1
2
≈0
1997
1997

1
4
≈0
3990
3990

10
0
9955
0
9955

10
1
39
990
1029

10
2
≈0
1979
1979

10
4
≈0
3954
3954

1000
0
632305
0
632305

1000
1
2470
368
2838

1000
2
10
735
745

1000
4
≈0
1469
1469

Es scheint einfach zu sein – wĂ€hle je nach LĂ€nge der geschĂŒtzten Daten die LĂ€nge der PrĂŒfziffer mit minimalen falschen Auslösungen – und alles ist erledigt.

Das Problem bei kurzen PrĂŒfziffern ist jedoch, dass sie, obwohl sie einzelne Bitfehler gut erkennen, mit einer erheblichen Wahrscheinlichkeit völlig zufĂ€llige Daten fĂŒr korrekt halten können. Auf habra wurde bereits ein Artikel beschrieben, der das Problem im wirklichen Leben behandelt.

Um zufĂ€llige Übereinstimmungen der PrĂŒfziffer praktisch unmöglich zu machen, mĂŒssen PrĂŒfziffern mit einer LĂ€nge von 32 Bit oder mehr verwendet werden (FĂŒr LĂ€ngen ĂŒber 64 Bit verwendet man normalerweise kryptografische Hash-Funktionen).

Obwohl ich zuvor geschrieben habe, dass wir jeden Byte Platz sparen sollten, werden wir dennoch eine 32-Bit-PrĂŒfziffer verwenden (16 Bit sind zu wenig, die Kollisionswahrscheinlichkeit liegt ĂŒber 0,01 %; und 24 Bit sind sozusagen weder hier noch dort).

Hier könnte der Einwand aufkommen: Haben wir jeden Byte beim Komprimieren gespart, nur um jetzt sofort 4 Byte abzugeben? WĂ€re es nicht besser gewesen, nicht zu komprimieren und keine PrĂŒfziffer hinzuzufĂŒgen? NatĂŒrlich nicht, das Fehlen von Komprimierung bedeutet, dass die IntegritĂ€tsprĂŒfung fĂŒr uns nicht notwendig ist.

Bei der Auswahl des Polynoms werden wir das Rad nicht neu erfinden und das derzeit beliebte CRC-32C verwenden.
Dieser Code erkennt 6 Bitfehler in Paketen bis zu 22 Bytes (wahrscheinlich der hĂ€ufigste Fall fĂŒr uns), 4 Bitfehler in Paketen bis zu 655 Bytes (auch ein hĂ€ufiger Fall fĂŒr uns), 2 oder eine beliebige ungerade Anzahl von Bitfehlern in Paketen beliebiger sinnvoller LĂ€nge.

Falls jemand an den Details interessiert ist

Wikipedia-Artikel ĂŒber CRC.

Parameter des Codes crc-32c auf der Seite von Kupman – wahrscheinlich der fĂŒhrende Experte fĂŒr CRC auf dem Planeten.

Im seinem Artikel einen eine weitere interessante Codierung, die etwas bessere Parameter fĂŒr die fĂŒr uns relevanten PaketlĂ€ngen bietet, aber ich habe den Unterschied als nicht signifikant angesehen und fĂŒhle mich kompetent genug, um einen benutzerdefinierten Code anstelle eines standardisierten und gut erforschten Codes zu wĂ€hlen.

Außerdem stellt sich, da unsere Daten komprimiert sind, die Frage: Soll die PrĂŒfziffer fĂŒr komprimierte oder unkomprimierte Daten berechnet werden?

Argumente "fĂŒr" die Berechnung der PrĂŒfziffer fĂŒr unkomprimierte Daten:

  • Wir mĂŒssen letztendlich die IntegritĂ€t der Datenspeicherung ĂŒberprĂŒfen – das prĂŒfen wir direkt (und dabei werden auch mögliche Fehler bei der Implementierung von Kompression/Dekompression sowie BeschĂ€digungen, die durch defekten Speicher verursacht werden, ĂŒberprĂŒft);
  • der deflate-Algorithmus in zlib hat eine ausreichend ausgereifte Implementierung und sollte nicht bei "krummen" Eingabedaten abstĂŒrzen; darĂŒber hinaus ist er hĂ€ufig in der Lage, Fehler im Eingabestrom selbst zu erkennen, wodurch die allgemeine Wahrscheinlichkeit einer unentdeckten Fehler entstehen verringert wird (ich habe einen Test mit der Umkehrung eines einzelnen Bits in einem kurzen Datensatz durchgefĂŒhrt, zlib hat den Fehler in etwa einem Drittel der FĂ€lle erkannt).

Argumente "gegen" die Berechnung der PrĂŒfziffer fĂŒr unkomprimierte Daten:

  • CRC ist speziell auf seltene Bitfehler abgestimmt, die charakteristisch fĂŒr Flash-Speicher sind (ein Bitfehler im komprimierten Stream kann eine MassenĂ€nderung des Ausgangsstroms zur Folge haben, wo wir theoretisch eine Kollision "erwischen" könnten);
  • ich bin nicht so begeistert von der Idee, potenziell fehlerhafte Daten an den Dekompressor zu ĂŒbergeben, wer kann schon wissen, wie er reagieren wird.

In diesem Projekt habe ich beschlossen, von der ĂŒblichen Praxis abzuweichen, die PrĂŒfziffer fĂŒr unkomprimierte Daten zu speichern.

Zusammenfassung: Wir verwenden CRC-32C, die PrĂŒfziffer wird von den Daten in der Form berechnet, in der sie in den Flash geschrieben werden (nach der Kompression).

Redundanz

Der Einsatz von Redundanzkodierung kann zwar nicht verhindern, dass Daten verloren gehen, aber er kann die Wahrscheinlichkeit eines irreversiblen Datenverlusts erheblich (oft um viele GrĂ¶ĂŸenordnungen) verringern.

Wir können verschiedene Arten von Redundanz verwenden, um Fehler zu korrigieren.
Hamming-Codes können einzelne Bitfehler korrigieren, Reed-Solomon-Codes sind symbolbasierend, mehrere Kopien von Daten zusammen mit PrĂŒfziffern oder Kodierungen wie RAID-6 können helfen, Daten selbst im Falle massiver SchĂ€den wiederherzustellen.
ZunĂ€chst war ich fĂŒr den breiten Einsatz von fehlerresistenten Kodierungen eingestellt, verstand dann aber, dass man zunĂ€chst eine Vorstellung davon haben muss, vor welchen Fehlern man sich schĂŒtzen möchte, bevor man die Kodierung wĂ€hlt.

Wir haben zuvor gesagt, dass Fehler so schnell wie möglich erkannt werden mĂŒssen. Zu welchen Zeitpunkten können wir mit Fehlern konfrontiert werden?

  1. UnvollstÀndiges Schreiben (aus irgendeinem Grund war beim Schreiben die Stromversorgung unterbrochen, Raspberry hing, ...)
    Leider bleibt im Falle eines solchen Fehlers nur, ungĂŒltige Aufzeichnungen zu ignorieren und die Daten als verloren zu betrachten;
  2. Schreibfehler (aus irgendeinem Grund wurde in den Flash-Speicher etwas anderes geschrieben als beabsichtigt)
    Solche Fehler können wir sofort erkennen, wenn wir unmittelbar nach dem Schreiben eine PrĂŒflesung durchfĂŒhren;
  3. Datenverzerrung im Speicher wÀhrend der Speicherung;
  4. Lese-Fehler
    Zur Behebung genĂŒgt es, im Falle eines Abgleichs der PrĂŒfziffer das Lesen mehrere Male zu wiederholen.

Das heißt, nur Fehler des dritten Typs (spontane Datenkorruption bei der Speicherung) können ohne fehlerresistente Kodierung nicht behoben werden. Man könnte denken, dass solche Fehler dennoch Ă€ußerst unwahrscheinlich sind.

Zusammenfassung: Es wurde beschlossen, auf Redundanzkodierung zu verzichten, aber wenn der Betrieb zeigt, dass diese Entscheidung falsch war, wird man die Frage erneut prĂŒfen (mit bereits gesammelter Statistik ĂŒber AusfĂ€lle, die es ermöglichen wird, die optimale Art der Kodierung auszuwĂ€hlen).

Sonstiges

NatĂŒrlich erlaubt das Artikelformat nicht, jedes Bit im Format zu begrĂŒnden (und mir sind auch die KrĂ€fte ausgegangen), deshalb werde ich einige Punkte kurz anreißen, die zuvor nicht angesprochen wurden.

  • Es wurde entschieden, alle Seiten "gleichberechtigt" zu gestalten.
    Das heißt, es wird keine speziellen Seiten mit Metadaten oder separaten Streams usw. geben, stattdessen einen einzigen Stream, der alle Seiten nacheinander ĂŒberschreibt.
    Das sorgt fĂŒr einen gleichmĂ€ĂŸigen Verschleiß der Seiten, vermeidet einen einzelnen Fehlerpunkt und gefĂ€llt einfach.
  • Die Versionierung des Formats sollte unbedingt berĂŒcksichtigt werden.
    Ein Format ohne Versionsnummer im Header ist eine Katastrophe!
    Es reicht aus, im Header der Seite ein Feld mit einer Art Magic Number (Signatur) hinzuzufĂŒgen, die auf die verwendete Versionsnummer des Formats hinweist. (Ich denke nicht, dass es in der Praxis mehr als ein Dutzend davon geben wird.);
  • Verwenden Sie fĂŒr die EintrĂ€ge (von denen es sehr viele gibt) einen Header variabler LĂ€nge und versuchen Sie, ihn in den meisten FĂ€llen auf 1 Byte zu beschrĂ€nken.
  • Zur Kodierung der LĂ€nge des Headers und der LĂ€nge des abgeschnittenen Teils des komprimierten Eintrags sollten binĂ€re Codes variabler LĂ€nge verwendet werden.

Online-Generator von Huffman-Codes hat sehr geholfen. Innerhalb von Minuten wurden die benötigten variablen LÀngen-Codes gefunden. Beschreibung des Datenspeicherformats

Byte-Reihenfolge

Felder, die grĂ¶ĂŸer als ein Byte sind, werden im Big-Endian-Format (Netzwerk-Byte-Reihenfolge) gespeichert, das heißt, 0x1234 wird als 0x12, 0x34 gespeichert.

Seitenaufteilung

Der gesamte Flash-Speicher ist in gleich große Seiten unterteilt.

Die standardmĂ€ĂŸige SeitengrĂ¶ĂŸe betrĂ€gt 32KB, jedoch nicht mehr als 1/4 der gesamten GrĂ¶ĂŸe des Speicherchips (fĂŒr einen 4MB-Chip ergibt das 128 Seiten).

Jede Seite speichert Daten unabhÀngig von anderen (d. h. die Daten einer Seite verweisen nicht auf die Daten einer anderen Seite).

Alle Seiten sind in natĂŒrlicher Reihenfolge (in aufsteigender Adressfolge) nummeriert, beginnend mit der Nummer 0 (die Nullseite beginnt mit Adresse 0, die erste mit 32KB, die zweite mit 64KB usw.).

Der Speicherchip wird als zirkulĂ€rer Puffer (Ringpuffer) verwendet, d. h. zuerst wird in die Seite mit der Nummer 0 geschrieben, dann in die Seite mit der Nummer 1, 
 wenn wir die letzte Seite fĂŒllen, beginnt ein neuer Zyklus und das Schreiben geht mit der Nullseite weiter.

Innerhalb der Seite

Am Anfang der Seite wird ein 4-Byte-Header gespeichert, gefolgt von einer PrĂŒfziffer des Headers (CRC-32C) und dann werden die EintrĂ€ge im Format „Header, Daten, PrĂŒfziffer“ gespeichert.

Meine Implementierung eines Ringpuffers in NOR-Flash
Der Header der Seite (auf dem Diagramm schmutziggrĂŒn) besteht aus:

einem zweibyteigen Feld Magic Number (auch als Versionsbezeichnung des Formats bekannt)

  • fĂŒr die aktuelle Version des Formats wird er als
    FĂŒr die aktuelle Version des Formats wird er als 0xed00 ⊕ Seitenzahl;
  • des zweibyteigen ZĂ€hlers „Seitenversion“ (Zyklusnummer der SpeicherĂŒberschreibung).

Die Aufzeichnungen auf der Seite werden in komprimierter Form gespeichert (es wird der Algorithmus deflate verwendet). Alle Aufzeichnungen auf einer Seite werden in einem Stream komprimiert (das wird ein gemeinsames Wörterbuch verwendet), auf jeder neuen Seite beginnt die Kompression erneut. Das heißt, fĂŒr die Dekomprimierung jeder Aufzeichnung sind alle vorhergehenden Aufzeichnungen von dieser Seite notwendig (und nur von dieser).

Jede Aufzeichnung wird mit dem Flag Z_SYNC_FLUSH komprimiert, dabei befinden sich am Ende des komprimierten Streams 4 Bytes 0x00, 0x00, 0xff, 0xff, möglicherweise vorangestellt durch ein oder zwei Nullbytes.
Diese Sequenz (von 4, 5 oder 6 Bytes) wird bei der Aufzeichnung in den Flash-Speicher verworfen.

Der Header der Aufzeichnung besteht aus 1, 2 oder 3 Bytes, die folgendes speichern:

  • ein Bit (T), das den Typ der Aufzeichnung anzeigt: 0 — Kontext, 1 — Protokoll;
  • ein variabel langes Feld (S) von 1 bis 7 Bits, das die LĂ€nge des Headers und den „Schwanz“, der der Aufzeichnung zur Dekompression hinzugefĂŒgt werden muss, definiert;
  • die LĂ€nge der Aufzeichnung (L).

Tabelle der Werte S:

S
LĂ€nge des Headers, Bytes
Wird bei der Aufzeichnung verworfen, Bytes

0
1
5 (00 00 00 ff ff)

10
1
6 (00 00 00 00 ff ff)

110
2
4 (00 00 ff ff)

1110
2
5 (00 00 00 ff ff)

11110
2
6 (00 00 00 00 ff ff)

1111100
3
4 (00 00 ff ff)

1111101
3
5 (00 00 00 ff ff)

1111110
3
6 (00 00 00 00 ff ff)

Ich habe versucht, es zu veranschaulichen, ich weiß nicht, wie anschaulich es geworden ist:
Meine Implementierung eines Ringpuffers in NOR-Flash
Hier ist das Feld T gelb gekennzeichnet, das Feld S weiß, grĂŒn ist L (die LĂ€nge der komprimierten Daten in Bytes), blau sind die komprimierten Daten, rot sind die Endbytes der komprimierten Daten, die nicht im Flash-Speicher geschrieben werden.

Somit können wir die Header der am hÀufigsten vorkommenden LÀnge (bis zu 63+5 Bytes in komprimierter Form) mit einem Byte aufzeichnen.

Nach jeder Aufzeichnung wird eine CRC-32C-PrĂŒfziffer gespeichert, bei der als Initialwert (init) der invertierte Wert der vorherigen PrĂŒfziffer verwendet wird.

CRC hat die Eigenschaft der „Fortdauer“, die Formel funktioniert (plus-minus Invertierung der Bits im Prozess): Meine Implementierung eines Ringpuffers in NOR-Flash.
Das heißt, wir berechnen tatsĂ€chlich die CRC aller vorhergehenden Bytes der Header und Daten auf dieser Seite.

Direkt nach der PrĂŒfziffer befindet sich der Header der nĂ€chsten Aufzeichnung.

Der Header ist so konstruiert, dass das erste Byte immer unterschiedlich von 0x00 und 0xff ist (wenn wir anstelle des ersten Bytes des Headers 0xff antreffen, bedeutet das, dass es sich um einen nicht verwendeten Bereich handelt; 0x00 signalisiert einen Fehler).

UngefÀhre Algorithmen

Lesen aus dem Flash-Speicher

Jedes Lesen erfolgt mit einer ÜberprĂŒfung der PrĂŒfziffer.
Wenn die PrĂŒfziffer nicht ĂŒbereinstimmt, wird das Lesen mehrmals wiederholt, in der Hoffnung, die tatsĂ€chlichen Daten korrekt zu lesen.

(das macht Sinn, Linux cached das Lesen aus NOR Flash nicht, getestet)

Schreiben in den Flash-Speicher

Wir schreiben Daten.
Wir lesen sie.

Wenn die gelesenen Daten nicht mit den geschriebenen ĂŒbereinstimmen, fĂŒllen wir den Bereich mit Nullen und signalisieren einen Fehler.

Vorbereitung des neuen Chips zur Verwendung

Zur Initialisierung wird im ersten (genauer gesagt, nullten) Seitenbereich ein Header mit der Version 1 geschrieben.
Danach wird in diesen Seitenbereich der Anfangskontext (enthÀlt die UUID der Maschine und die Standardkonfigurationen) geschrieben.

Alles, der Flash-Speicher ist bereit zur Verwendung.

Laden der Maschine

Beim Laden werden die ersten 8 Byte jeder Seite (Header + CRC) gelesen. Seiten mit unbekannter Magic Number oder ungĂŒltigem CRC werden ignoriert.
Aus den „richtigen“ Seiten werden die Seiten mit der maximalen Version ausgewĂ€hlt, davon wird die Seite mit der höchsten Nummer entnommen.
Der erste Eintrag wird gelesen, die Korrektheit des CRC wird ĂŒberprĂŒft, sowie das Vorhandensein des „Kontext“-Flags. Wenn alles in Ordnung ist, wird diese Seite als aktuell betrachtet. Andernfalls gehen wir auf die vorherige zurĂŒck, bis wir eine „lebendige“ Seite finden.
Auf der gefundenen Seite lesen wir alle EintrĂ€ge, die mit dem Flag „Kontext“ versehen sind, wenden wir an.
Wir speichern das zlib-Wörterbuch (wird fĂŒr das Nachschreiben in diese Seite benötigt).

Alles, der Ladevorgang ist abgeschlossen, der Kontext wurde wiederhergestellt, man kann arbeiten.

HinzufĂŒgen eines Eintrags zum Protokoll

Wir komprimieren den Eintrag mit dem richtigen Wörterbuch und geben Z_SYNC_FLUSH an. Wir prĂŒfen, ob der komprimierte Eintrag auf die aktuelle Seite passt.
Wenn er nicht passt (oder die Seite CRC-Fehler aufwies) – beginnen wir eine neue Seite (siehe unten).
Wir schreiben den Eintrag und den CRC. Tritt ein Fehler auf, beginnen wir eine neue Seite.

Neue Seite

WĂ€hlen Sie die freie Seite mit der minimalen Nummer aus (eine Seite gilt als frei, wenn sie eine falsche PrĂŒfziffer im Header oder eine Version hat, die kleiner ist als die aktuelle). Wenn es solche Seiten nicht gibt, wĂ€hlen wir die Seite mit der minimalen Nummer aus denjenigen aus, die die gleiche Version wie die aktuelle haben.
Wir fĂŒhren ein Erase fĂŒr die gewĂ€hlte Seite durch. Wir vergleichen den Inhalt mit 0xff. Wenn etwas nicht stimmt, nehmen wir die nĂ€chste freie Seite usw.
Auf die gelöschte Seite schreiben wir den Header, als ersten Eintrag den aktuellen Zustand des Kontextes, als nÀchsten den nicht geschriebenen Eintrag aus dem Protokoll (wenn vorhanden).

Anwendbarkeit des Formats

Meiner Meinung nach ist das ein ganz passables Format zur Speicherung beliebiger mehr oder weniger komprimierbarer Informationsströme (einfacher Text, JSON, MessagePack, CBOR, möglicherweise Protobuf) im NOR Flash.

NatĂŒrlich ist das Format auf SLC NOR Flash ausgelegt.

Es sollte nicht mit TrĂ€gern mit hohem BER verwendet werden, wie NAND oder MLC NOR (gibt es solche Speicher ĂŒberhaupt im Handel? Ich habe nur ErwĂ€hnungen in Arbeiten ĂŒber Fehlerkorrektur gefunden).

Umso mehr sollte es nicht mit GerĂ€ten verwendet werden, die ĂŒber ein eigenes FTL verfĂŒgen: USB-Flash, SD, MicroSD usw. (FĂŒr solchen Speicher habe ich ein Format mit einer SeitengrĂ¶ĂŸe von 512 Byte, einer Signatur am Anfang jeder Seite und eindeutigen Aufzeichnungsnummern erstellt — manchmal konnte ich alle Daten durch einfaches sequentielles Lesen von einem "glitschigen" Flash wiederherstellen).

Je nach Aufgabe kann das Format unverĂ€ndert auf Flash-Laufwerken von 128 Kbit (16 KB) bis 1 Gbit (128 MB) verwendet werden. Bei Bedarf kann es auch auf Chips mit grĂ¶ĂŸerem Volumen verwendet werden, aber wahrscheinlich muss die SeitengrĂ¶ĂŸe angepasst werden. (Aber hier stellt sich bereits die Frage der Wirtschaftlichkeit, der Preis fĂŒr großen NOR Flash ist nicht erfreulich).

Wenn jemand das Format interessant findet und es in einem Open-Source-Projekt verwenden möchte — schreibt mir, ich werde versuchen, Zeit zu finden, um den Code zu ĂŒberarbeiten und ihn auf GitHub hochzuladen.

Fazit

Wie man sieht, ist das Format am Ende einfach und sogar langweilig.

In dem Artikel ist es schwierig, die Evolution meiner Sichtweise widerzuspiegeln, aber glauben Sie mir: UrsprĂŒnglich wollte ich etwas Komplexes, Unzerstörbares schaffen, das selbst nach einer nuklearen Explosion in unmittelbarer NĂ€he ĂŒberleben kann. Doch die Vernunft (hoffentlich) hat letztendlich gesiegt und die PrioritĂ€ten verschoben sich allmĂ€hlich in Richtung Einfachheit und Kompaktheit.

Kann es sein, dass ich mich geirrt habe? Ja, natĂŒrlich. Es könnte durchaus sein, dass wir zum Beispiel eine Partie minderwertiger Chips gekauft haben. Oder aus einem anderen Grund erfĂŒllt die Hardware nicht die Erwartungen an die ZuverlĂ€ssigkeit.

Habe ich einen Plan fĂŒr diesen Fall? Ich denke, dass Sie beim Lesen des Artikels keinen Zweifel haben, dass ich einen Plan habe. Und sogar mehrere.

Etwas ernster gesagt, wurde das Format gleichzeitig als Arbeitstyp und als "Versuchsschuss" entwickelt.

Im Moment funktioniert alles auf meinem Tisch normal, in den nĂ€chsten Tagen wird die Lösung bereitgestellt (ungefĂ€hr) Bei Hunderten von GerĂ€ten werden wir sehen, wie sie im "Einsatz" abschneiden (zum GlĂŒck hoffe ich, erlaubt das Format eine zuverlĂ€ssige Fehlerdetektion; so können wir umfassende Statistiken sammeln). Über mehrere Monate können dann Schlussfolgerungen gezogen werden. (und wenn wir Pech haben – sogar frĂŒher).

Falls bei der Nutzung schwerwiegende Probleme festgestellt werden und Anpassungen erforderlich sind, werde ich unbedingt darĂŒber berichten.

Literatur

Ich wollte keine lange, langweilige Liste der verwendeten Arbeiten erstellen; schließlich hat jeder Google.

Hier habe ich beschlossen, eine Liste von Entdeckungen zu fĂŒhren, die mir besonders interessant erschienen. Diese sind jedoch allmĂ€hlich in den Artikeltext gewandert, und in der Liste blieb nur ein Punkt ĂŒbrig:

  1. Das Tool infgen von Autor zlib. Kann den Inhalt von deflate/zlib/gzip-Archiven in verstĂ€ndlicher Form anzeigen. Wenn Sie sich mit dem inneren Aufbau des deflate-Formats (oder gzip) auseinandersetzen mĂŒssen, kann ich es dringend empfehlen.

Quelle: habr.com

ZuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz kaufen, VPS VDS Server đŸ”„ ZuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz kaufen, VPS VDS Server - ProHoster