Um neue Linux-Distros auf echter Hardware auszuprobieren, empfiehlt sich ein bootbarer USB-Stick mit einem heruntergeladenen Image im Iso-Format. Ein Reboot des Rechners mit eingestöpseltem Stick bringt dann (nach eventuellen Eingriffen in die Boot-Order im Bios) oft ein Live-System hoch, mit dem sich herrlich herumspielen lässt.
Wie kommt die Iso-Datei auf den Stick? Letztendlich passiert das ganz einfach mittels eines dd
-Kommandos, das die Iso-Datei als Eingabe (if
) und den Device-Eintrag des Sticks (zum Beispiel /dev/sdd
) als Ausgabe (of
) erwartet. Ubuntu-Tools wie der "Startup Disk Creator" machen es mit grafischer UI noch komfortabler, ein fader Nachgeschmack bleibt jedoch: Auf keinen Fall sollte das Tool einen Bug aufweisen, der statt des Sticks aus Versehen die im Device-Tree gar nicht so weit entfernte Festplatte überschreibt. Wie schwer wäre es wohl, ein ähnliches Tool in Go zu schreiben, eines, das aktiv auf das Einstöpseln des Usb-Sticks wartet und dann beim User um Bestätigung anfragt, um die Iso-Datei dorthin zu kopieren? Außerdem erschließt sich Go-Studenten bei Tools der Marke Eigenbau das ein oder andere Verfahren, mit dem Go-Programmierer Alltagsaufgaben lösen.
Eine Iso-Datei, die mehrere Gigabyte an Daten fasst, ist gar nicht so einfach auf ein anderes Dateisystem wie den Stick zu kopieren. Utilities with cp
oder dd
lesen nicht etwa alle Daten der Ausgangsdatei in einem Rutsch von der Platte, denn das würde viel kostbares RAM belegen, ohne den Prozess abzukürzen. Vielmehr lesen Kopiertools die Daten in typischerweise Megabyte-großen Happen aus der Source-Datei, die sie sofort in die gleichzeitig geöffnete Zieldatei wegschreiben.
Genauso funktioniert auch Listing 1, in dem die Funktion cpChunks()
als Parameter die Namen der Ausgangs- und Zieldatei, sowie einen geöffneten Go-Channel erwartet. Letzterer zapft der Aufrufer als Informationsquelle an, um zu sehen, wie weit der Kopiervorgang schon gediegen ist. Dazu schickt cpChunks()
nach jedem kopierten Happen einen Prozentwert in den Channel, der den Bruchteil der bereits kopierten Bytes im Verhältnis zur Gesamtzahl angibt. Letztere hat sich die Funktion mit Hilfe der Systemfunktion os.Stat()
anfangs vom Dateisystem eingeholt, das weiß, wie groß die in ihm liegenden Dateien tatsächlich sind.
Abbildung 1: Das Go-Programm wartet auf das Einstöpseln des USB-Sticks |
Abbildung 2: Der eingestöpselte 32gb-Stick wurde erkannt, das Programm wartet auf die Bestätigung seitens des Users. |
Abbildung 3: Der Kopiervorgang hat begonnen, ein Fortschrittsbalken zeigt an, wieviel Bytes der Iso-Datei schon auf den Stick kopiert wurden. |
Abbildung 4: Der bootbare USB-Stick hat nun alle erforderlichen Daten und ist zum Einsatz bereit. |
Damit Go-Programmierer ohne viel Kleistercode zu schreiben Daten zwischen unterschiedlichen Funktionen umherpumpen können, akzeptieren viele Libraries die Standard-Interfaces Reader
und Writer
. Die Library-Funktion erhält vom Aufrufer einen Pointer auf ein Reader
-Objekt und zapft mit Read()
daraus häppchenweise Daten ab. Der Ursprung der Daten ist dabei einerlei. Ob es sich um JSON-Daten von einem Webserver oder über einen File-Deskriptor eingelesene Blöcke vom lokalen Dateisystem handelt, kann der aufgerufenen Funktion herzlich egal sein. Der Vorteil: So bleibt sie flexibel, und braucht bei Änderungen der Datenquelle keine Änderungen im Code, denn das Interface bleibt das gleiche.
So öffnet Listing 1 die Ausgangsdatei, erhält aus dem Open()
-Call ein Objekt vom Typ os.File
, gibt dieses aber weiter an NewReader()
des Pakets bufio
, das einen Reader zurückgibt, mit dem der Aufrufer die Bytes aus der Datei schrittweise anpumpen kann. Ähnliches gilt für die Zieldatei, die in der Applikation ja als Device-Eintrag des Usb-Sticks vorliegt, aber auf Unix ist schönerweise alles eine Datei. Der Aufruf von os.OpenFile()
mit der Option O_WRONLY
in Zeile 25 öffnet den Eintrag zum Schreiben, setzt aber (wie bei Device-Einträgen üblich) voraus, dass letzterer schon existiert - die sonst bei Dateien übliche Option O_CREATE
fehlt hier bewusst. Zeile 30 kreiert aus dem File-Objekt ein neues Writer-Objekt und der Kopiervorgang kann beginnen.
01 package main 02 03 import ( 04 "bufio" 05 "os" 06 "io" 07 ) 08 09 func cpChunks(src, dst string, 10 percent chan<- int) error { 11 data := make([]byte, 1024*256) 12 13 in, err := os.Open(src) 14 if err != nil { 15 return err 16 } 17 reader := bufio.NewReader(in) 18 defer in.Close() 19 20 fi, err := in.Stat() 21 if err != nil { 22 return err 23 } 24 25 out, err := os.OpenFile(dst, 26 os.O_WRONLY, 0644) 27 if err != nil { 28 return err 29 } 30 writer := bufio.NewWriter(out) 31 defer out.Close() 32 33 total := 0 34 35 for { 36 count, err := reader.Read(data) 37 total += count 38 data = data[:count] 39 40 if err == io.EOF { 41 break 42 } else if err != nil { 43 return err 44 } 45 46 _, err = writer.Write(data) 47 if err != nil { 48 return err 49 } 50 51 percent <- int(int64(total) * 52 int64(100) / fi.Size()) 53 } 54 55 return nil 56 }
In der For-Schleife ab Zeile 35 holt nun der Reader entsprechend des vorher in Zeile 11 definierten Puffers data
vier Megabyte große Datenbrocken ab. Dabei liefert die Read()
-Funktion aber nicht immer vier Megabytes, denn am Ende der Datei können's auch mal weniger sein. Deshalb ist es essentiell, in Zeile 38 das data
-Slice auf die tatsächlich geholten Bytes zu kürzen, gäbe die Funktion den Puffer einfach an den Writer weiter, schriebe ihn letzterer eiskalt in voller Länge in die Zieldatei. Aus einer fünf Megabyte großen Ausgangsdatei käme so eine acht Megabyte große Zieldatei heraus, die letzten drei Megabytes bestünden aus uninitialisiertem Müll.
Da das Einlesen der Daten in kleinen Schritten erfolgt und Zeile 20 mit os.Stat()
vorher die Größe der Ausgangsdatei ermittelt hat, weiß die Funktion in jedem Schleifendurchgang, wie weit sie beim Kopieren schon fortgeschritten und wieviel noch zu tun ist. Zeile 51 schreibt dieses Verhältnis als Prozent-Integer in den als percent
vom Aufrufer in die Funktion hereingereichten Go-Channel. Der Aufrufer liest später die eintrudelnden Werte und kann so einen Fortschrittsbalken nach rechts bewegen, während die Funktion noch arbeitet. Echtes Multitasking!
Wie nun findet der Flasher heraus, wann der neu angeschlossene USB-Stick erscheint? Die Funktion driveWatch()
ab Zeile 14 in Listing 2 ruft hierzu zunächst devices()
ab Zeile 64 auf, um zu sehen, welche Device-Einträge auf dem System unter dem Schema /dev/sd*
zu sehen sind. Dort stehen normalerweise unter /dev/sda
die erste Festplatte, und unter /dev/sdb
und höher finden sich vielleicht noch andere SATA-Devices. Usb-Sticks erscheinen auf meinem System üblicherweise unter /dev/sdd
, aber andernorts könnte das varieren. Das in Go eingebaute Globbing, das auch die Shell verwendet um zum Beispiel Wildcards wie *
in Treffer umzuwandeln, meldet übrigens bei ungültigen Dateipfaden keinen Fehler, nur falsche Glob-Ausdrücke meckert es an. Findet ein Glob-Ausdruck in Go nichts, ist es angebracht, auch nach anderen Ursachen wie falschen Pfade oder mangelnden Zugangsberechtigungen zu forschen.
01 package main 02 03 import ( 04 "bytes" 05 "errors" 06 "fmt" 07 "os/exec" 08 "path/filepath" 09 "strconv" 10 "strings" 11 "time" 12 ) 13 14 func driveWatch( 15 done chan error) chan string { 16 seen := map[string]bool{} 17 init := true 18 drivech := make(chan string) 19 go func() { 20 for { 21 dpaths, err := devices() 22 if err != nil { 23 done <- err 24 } 25 for _, dpath := range dpaths { 26 if _, ok := seen[dpath]; !ok { 27 seen[dpath] = true 28 if !init { 29 drivech <- dpath 30 } 31 } 32 } 33 init = false 34 time.Sleep(1 * time.Second) 35 } 36 }() 37 return drivech 38 } 39 40 func driveSize( 41 path string) (string, error) { 42 var out bytes.Buffer 43 cmd := exec.Command( 44 "sfdisk", "-s", path) 45 cmd.Stdout = &out 46 cmd.Stderr = &out 47 48 err := cmd.Run() 49 if err != nil { 50 return "", err 51 } 52 53 sizeStr := strings.TrimSuffix( 54 out.String(), "\n") 55 size, err := strconv.Atoi(sizeStr) 56 if err != nil { 57 return "", err 58 } 59 60 return fmt.Sprintf("%.1f GB", 61 float64(size)/float64(1024*1024)), nil 62 } 63 64 func devices() ([]string, error) { 65 devices := []string{} 66 paths, _ := filepath.Glob("/dev/sd*") 67 if len(paths) == 0 { 68 return devices, 69 errors.New("No devices found") 70 } 71 for _, path := range paths { 72 devices = append(devices, path) 73 //filepath.Base(path)) 74 } 75 return devices, nil 76 }
Deshalb sucht devices()
ab Zeile 64 alle Einträge ab, und driveWatch()
nimmt diese Pfade entgegen, um die Map-Variable seen
mit den gefundenen Einträgen zu initialisieren. Diese Suche läuft asynchron ab, denn driveWatch()
startet in Zeile 19 mit go func()
eine parallel laufende Go-Routine. Das Hauptprogramm hüpft derweil ans Ende und gibt den neu angelegten Go-Channel drivech
an den Aufrufer zurück, um diesem nach der anfänglichen Init-Phase neu entdeckte Drives zu melden.
Die im Hintergrund weiter aktive Go-Routine läuft derweil in einer Endlosschleife. Anfangs führt die Variable init
ab Zeile 17 den Wert true
, aber sobald die Funktion nach dem ersten Durchgang der For-Schleife alle bestehenden Devices abgeklappert hat, setzt Zeile 33 die Variable init
auf false
. Im Sekundentakt geht's nun weiter, immer wieder setzt sich die For-Schleife nach der Sleep-Anweisung in 34 in Bewegung und liest die aktuellen Device-Einträge. Findet sich ein neuer, der noch nicht in der Map seen
steht, schiebt Zeile 29 den Pfad des Eintrags in den Go-Channel drivech
, und das Hauptprogramm schnappt ihn von dort aus auf, nachdem es sehnlichst in Listing 3 in Zeile 57 blockierend (aber asynchron in einer Go-Routine) auf das Ergebnis gewartet hat.
Um herauszufinden, welche Speicherkapazität der gefundene Usb-Stick bietet, setzt Listing 2 in Zeile 43 den Befehl
$ sfdisk -s /dev/sdd
ab. In der Standardausgabe des in Go per os.Exec
-Paket abgesetzten Shell-Kommandos steht ein einziger Integerwert, der die Kapazität des Sticks in Kilo-(!)-Bytes anzeigt. Vom Ergebnisstring schneidet Zeile 53 den Zeilenumbruch ab und Zeile 55 konvertiert ihn mit Atoi()
aus dem Paket strconv
in einen Integer. Zeile 61 dividiert das Ganze durch ein Megabyte, sodass die Kapazität in Gigabytes im Fließkommaformat herauskommt. Die Funktion gibt den Wert formschön als String zurück, damit der User in der UI verifizieren kann, ob es sich um einen Usb-Stick und nicht etwa um eine viel größere Festplatte handelt.
001 package main 002 003 import ( 004 "flag" 005 "fmt" 006 ui "github.com/gizak/termui/v3" 007 "github.com/gizak/termui/v3/widgets" 008 "os" 009 "path" 010 ) 011 012 func main() { 013 flag.Parse() 014 if flag.NArg() != 1 { 015 usage("Argument missing") 016 } 017 isofile := flag.Arg(0) 018 _, err := os.Stat(isofile) 019 if err != nil { 020 usage(fmt.Sprintf("%v\n", err)) 021 } 022 023 if err = ui.Init(); err != nil { 024 panic(err) 025 } 026 var globalError error 027 defer func() { 028 if globalError != nil { 029 fmt.Printf("Error: %v\n", 030 globalError) 031 } 032 }() 033 defer ui.Close() 034 035 p := widgets.NewParagraph() 036 p.SetRect(0, 0, 55, 3) 037 p.Text = "Insert USB Stick" 038 p.TextStyle.Fg = ui.ColorBlack 039 ui.Render(p) 040 041 pb := widgets.NewGauge() 042 pb.Percent = 100 043 pb.SetRect(0, 2, 55, 5) 044 pb.Label = " " 045 pb.BarColor = ui.ColorBlack 046 047 done := make(chan error) 048 update := make(chan int) 049 confirm := make(chan bool) 050 051 uiEvents := ui.PollEvents() 052 drivech := driveWatch(done) 053 054 var usbPath string 055 056 go func() { 057 usbPath = <-drivech 058 059 size, err := driveSize(usbPath) 060 if err != nil { 061 done <- err 062 return 063 } 064 065 p.Text = fmt.Sprintf("Write to %s " + 066 "(%s)? Hit 'y' to continue.\n", 067 usbPath, size) 068 ui.Render(p) 069 }() 070 071 go func() { 072 for { 073 pb.Percent = <-update 074 ui.Render(pb) 075 } 076 }() 077 078 go func() { 079 <-confirm 080 p.Text = fmt.Sprintf( 081 "Copying to %s ...\n", usbPath) 082 ui.Render(p) 083 update <- 0 084 err := cpChunks( 085 isofile, usbPath, update) 086 if err != nil { 087 done <- err 088 } 089 p.Text = fmt.Sprintf("Done.\n") 090 update <- 0 091 ui.Render(p, pb) 092 }() 093 094 for { 095 select { 096 case err := <-done: 097 if err != nil { 098 globalError = err 099 return 100 } 101 case e := <-uiEvents: 102 switch e.ID { 103 case "q", "<C-c>": 104 return 105 case "y": 106 confirm <- true 107 } 108 } 109 } 110 } 111 112 func usage(msg string) { 113 fmt.Printf("%s\n", msg) 114 fmt.Printf("usage: %s iso-file\n", 115 path.Base(os.Args[0])) 116 os.Exit(1) 117 }
Ein Tool mit einer UI, auch wenn es nur eine Terminal-Applikation ist, macht doch ungleich mehr her als eines, das nur auf der Standardausgabe operiert, gerade wenn es dem User Eingaben zur Auswahl oder Bestätigung abverlangt. Das Hauptprogramm in Listing 3 nutzt dazu die schon in vorherigen Ausgaben vorgestellte Terminal-UI termui
([2]), deren Event-Framework sich zügig mit asynchron aufgerufenen Go-Routinen bedienen lässt. Die in den Abbildungen 1 bis 4 gezeigte UI offenbart zwei sogenannte Widgets, die übereinander im Hauptfenster der Terminal-UI liegen. Das obere ist dabei ein Text-Widget in der Variablen p
, das dem User Statusmeldungen liefert und neue Instruktionen übermittelt. Das untere Widget, das die Variable pb
referenziert, ist ein Fortschrittsbalken vom Type Gauge
, der über einen Go-Kanal Updates einliest, und den Balken entsprechend der eintrudelnden Prozentwerte von links nach rechts bewegt.
Zunächst aber prüft Zeile 14 in Listing 3, ob das Hauptprogramm tatsächlich wie vorgeschrieben mit einer Iso-Datei als Parameter aufgerufen wurde und verzweigt zur usage()
-Hilfeseite ab Zeile 112 falls das nicht der Fall ist. Für die interne Kommunikation zwischen den verschiedenen Programmteilen nutzt der Code sage und schreibe fünf verschiedene Channels, die Go-Programmierer laut offiziellen Richtlinien nur in homöopathischen Dosen verwenden sollten, aber was soll's!
Der schon besprochene Channel drivech
meldet der in Zeile 57 blockierenden Go-Routine frisch eingestöpselte Usb-Sticks. Weiter bietet der Channel update
einen Kommunikationskanal zwischen dem Datenkopierer cpChunks()
in Listing 1 und dem Hauptprogramm. Immer wenn der Kopierer einen neuen Prozentwert meldet, löst Zeile 73 ihre Blockade und setzt den Prozentwert des Fortschrittsbalken in der Variable pb
entsprechend. Der nachfolgende Aufruf der Funktion Render()
frischt die UI auf und sorgt dafür, dass der Balken sich auch sichtbar bewegt. Sind alle Daten auf dem Usb-Stick angekommen, setzt Zeile 90 den Progressbar wieder auf 0%.
Tastatureingaben wie "Ctrl-C" oder "q" fängt die mit PollEvents()
in Zeile 51 angestoßene Eventschleife ebenfalls über den Channel uiEvents
ab. Zeile 101 analysiert die gedrückte Taste und läutet bei den beiden Abbrechersequenzen das Programmende ein. Wurde der Usb-Stick bereits erkannt, bremst die Go-Routine ab Zeile 78, indem sie auf Daten aus dem confirm
-Channel in Zeile 79 wartet. Drückt der User die Taste y
, speist Zeile 106 das Ereignis in den Channel confirm
ein, Zeile 78 schnappt es auf, und öffnet die Schleusen zum Kopieren.
Der Channel done
wiederum dient dem Hauptprogramm zur Kontrolle darüber, wann die UI zusammengefaltet und das Programm beendet werden soll. Dabei stellt sich das Problem, dass eine Terminal-UI nicht einfach nach Stderr schreiben oder das Programm mit panic()
abbrechen kann, falls ein schwerer Fehler auftritt, denn Stderr ist im Grafikmodus geblockt und ein abrupt abgebrochenes Programm ließe ein unbenutzbares Terminal zurück, das der User nur durch das Schließen des Terminalfensters und dem Öffnen eines neuen reparieren könnte.
Listing 1 behilft sich damit, eventuell auftretende fatale Fehler in den Channel done
einzuspeisen, wo Zeile 96 sie aufschnappt und in der in Zeile 26 deklarierten Variablen globalError
ablegt. Die geschickte Aufreihung von defer
-Anweisungen in den Zeilen 27 und 33 sorgt dafür, dass immer zuerst die UI geschlossen und erst danach der zum Programmabbruch führende Fehler in globalError
auf Stdout ausgegeben wird. Nacheinander abgesetzte defer
-Anweisungen kommen nämlich in umgekehrter Reihenfolge zur Ausführung: Go baut einen defer
-Stack auf, indem es die ersten Einträge als letztes ausführt. Da das defer
in Zeile 27 den globalen Fehler ausgibt und das defer
in Zeile 33 die UI zusammenfaltet, faltet das Hauptprogramm am Ende immer erst die UI zusammen und gibt dann erst den Fehler aus. Umgekehrt ginge der Fehler verloren.
Mit den drei Dateien isoflash.go
, cpchunks.go
und drive.go
in einem Verzeichnis führt der Aufruf
go mod init isoflash
go build
sudo ./isoflash ubuntu.iso
zu einem Binary isoflash
, das der User per sudo
aufrufen sollte, damit es auch Schreibrechte auf den Usb-Stick bekommt. Es weist den User sogleich an, den Usb-Stick einzustöpseln, findet ihn, und fängt an, nach erfolgter Bestätigung seitens des User, die Daten zu kopieren und den aktuellen Stand der Dinge mit einem Fortschrittsbalken anzuzeigen. Der Spass kann losgehen!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/08/snapshot/
Michael Schilli, "Fortschritt auf Raten": Linux-Magazin 12/18, S.X, <U>https://www.linux-magazin.de/ausgaben/2018/12/snapshot-9/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc