Ein Go-Programm schreibt eine heruntergeladene ISO-Datei auf einen bootbaren USB-Stick. Damit er nicht versehentlich die Festplatte überschreibt, spendiert ihm Mike Schilli eine Bedienoberfläche und Sicherheitsprüfungen.
Um neue Distributionen 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-Reihenfolge 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 mit einem »dd«-Kommando, das die ISO-Datei als Eingabe (»if«) und den Device-Eintrag des Sticks (zum Beispiel »/dev/sdd«) als Ausgabe (»of«) erwartet.
Werkzeuge wie Ubuntus 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 die Erlaubnis einholt, 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.
Kopieren studieren
Es ist gar nicht so einfach, eine mehrere Gigabyte große ISO-Datei auf ein anderes Dateisystem wie den Stick zu kopieren. Utilities wie Cp und Dd lesen nicht etwa alle Daten der Ausgangsdatei in einem Rutsch von der Platte – das würde viel kostbares RAM belegen, ohne den Prozess abzukürzen. Vielmehr lesen solche Kopierwerkzeuge die Daten in typischerweise Megabyte-großen Happen aus der Source-Datei und schreiben sie sofort in die gleichzeitig geöffnete Zieldatei.
Genau das tut auch der Code aus Listing 1. Die Funktion »cpChunks()« erwartet als Parameter die Namen der Ursprungs- und der Zieldatei sowie einen geöffneten Go-Channel. Letzteren zapft der Aufrufer als Informationsquelle an, um zu sehen, wie weit der Kopiervorgang schon gediehen 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. Den Gesamtumfang erfragt das Programm mithilfe der Systemfunktion »os.Stat()« zu Anfang beim Dateisystem, das weiß, wie groß die in ihm liegenden Dateien ausfallen.
Listing 1
cpchunks.go
package main
import (
"bufio"
"os"
"io"
)
func cpChunks(src, dst string, percent chan<- int) error {
data := make([]byte, 4*1024*1024)
in, err := os.Open(src)
if err != nil {
return err
}
reader := bufio.NewReader(in)
defer in.Close()
fi, err := in.Stat()
if err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY, 0644)
if err != nil {
return err
}
writer := bufio.NewWriter(out)
defer out.Close()
total := 0
for {
count, err := reader.Read(data)
total += count
data = data[:count]
if err == io.EOF {
break
} else if err != nil {
return err
}
_, err = writer.Write(data)
if err != nil {
return err
}
percent <- int(int64(total) * int64(100) / fi.Size())
}
return nil
}
Damit Go-Programmierer Daten zwischen unterschiedlichen Funktionen umherpumpen können, ohne viel Kleistercode zu schreiben, 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.
Go-Design: Reader/Writer
So öffnet Listing 1 die Ausgangsdatei und erhält aus dem »Open()«-Call ein Objekt vom Typ »os.File«. Es reicht dieses aber an »NewReader()« aus dem Paket »bufio« durch, 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 schon als Device-Eintrag des Sticks vorliegt – aber unter Unix ist ja praktischerweise 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 29 erzeugt aus dem File-Objekt ein neues Writer-Objekt, und der Kopiervorgang kann beginnen.
In der For-Schleife ab Zeile 34 holt nun der Reader entsprechend des vorher in Zeile 11 definierten Puffers »data« 4 MByte große Datenbrocken ab. Dabei liefert die »Read()«-Funktion aber nicht immer 4 MByte, denn am Ende der Datei können es auch einmal weniger sein. Deshalb ist es wichtig, in Zeile 37 das »data«-Slice auf die tatsächlich geholten Bytes zu kürzen.
Gäbe die Funktion den Puffer einfach an den Writer weiter, schriebe der ihn ohne Umschweife in voller Länge in die Zieldatei. Aus einer 5 MByte großen Ausgangsdatei entstünde so eine 8 MByte große Zieldatei; die letzten 3 MByte bestünden aus uninitialisiertem Müll.
Prozente durch die Röhre
Da das Einlesen der Daten in kleinen Schritten erfolgt und Zeile 20 mit »os.Stat()« vorab die Größe der Ausgangsdatei ermittelt hat, weiß die Funktion in jedem Schleifendurchgang, wie weit sie beim Kopieren schon fortgeschritten ist und wie viel noch zu erledigen bleibt. Zeile 50 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? In Listing 2 ruft die Funktion »driveWatch()« ab Zeile 14 dazu zunächst »devices()« ab Zeile 61 auf, um zu sehen, welche Device-Einträge auf dem System unter dem Schema »/dev/sd*« zu sehen sind. Dort steht normalerweise unter »/dev/sda« die erste Festplatte, 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 variieren.
Listing 2
drive.go
package main
import (
"bytes"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func driveWatch(
done chan error) chan string {
seen := map[string]bool{}
init := true
drivech := make(chan string)
go func() {
for {
dpaths, err := devices()
if err != nil {
done <- err
}
for _, dpath := range dpaths {
if _, ok := seen[dpath]; !ok {
seen[dpath] = true
if !init {
drivech <- dpath
}
}
}
init = false
time.Sleep(1 * time.Second)
}
}()
return drivech
}
func driveSize(
path string) (string, error) {
var out bytes.Buffer
cmd := exec.Command("sfdisk", "-s", path)
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
if err != nil {
return "", err
}
sizeStr := strings.TrimSuffix(out.String(), "\n")
size, err := strconv.Atoi(sizeStr)
if err != nil {
return "", err
}
return fmt.Sprintf("%.1f GB", float64(size)/float64(1024*1024)), nil
}
func devices() ([]string, error) {
devices := []string{}
paths, _ := filepath.Glob("/dev/sd*")
if len(paths) == 0 {
return devices,
errors.New("No devices found")
}
for _, path := range paths {
devices = append(devices, path)
}
return devices, nil }
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. Es meckert nur falsche Glob-Ausdrücke an. Findet ein Glob-Ausdruck in Go nichts, ist es angebracht, auch nach anderen Ursachen zu forschen, wie etwa falschen Pfaden oder mangelnden Zugangsberechtigungen.
Deshalb sucht »devices()« ab Zeile 61 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, 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.
Drives entdecken
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«. Sobald die Funktion nach dem ersten Durchgang der For-Schleife alle bestehenden Devices abgeklappert hat, setzt Zeile 33 die Variable »init« auf »false«.
Nun geht es im Sekundentakt weiter. Immer wieder setzt sich die For-Schleife nach der Sleep-Anweisung in Zeile 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«. Das Hauptprogramm schnappt ihn von dort aus auf, nachdem es sehnlichst in Listing 3 in Zeile 56 blockierend (aber asynchron in einer Go-Routine) auf das Ergebnis gewartet hat.
Listing 3
isoflash.go
package main
import (
"flag"
"fmt"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"os"
"path"
)
func main() {
flag.Parse()
if flag.NArg() != 1 {
usage("Argument missing")
}
isofile := flag.Arg(0)
_, err := os.Stat(isofile)
if err != nil {
usage(fmt.Sprintf("%v\n", err))
}
if err = ui.Init(); err != nil {
panic(err)
}
var globalError error
defer func() {
if globalError != nil {
fmt.Printf("Error: %v\n", globalError)
}
}()
defer ui.Close()
p := widgets.NewParagraph()
p.SetRect(0, 0, 55, 3)
p.Text = "Insert USB Stick"
p.TextStyle.Fg = ui.ColorBlack
ui.Render(p)
pb := widgets.NewGauge()
pb.Percent = 100
pb.SetRect(0, 2, 55, 5)
pb.Label = " "
pb.BarColor = ui.ColorBlack
done := make(chan error)
update := make(chan int)
confirm := make(chan bool)
uiEvents := ui.PollEvents()
drivech := driveWatch(done)
var usbPath string
go func() {
usbPath = <-drivech
size, err := driveSize(usbPath)
if err != nil {
done <- err
return
}
p.Text = fmt.Sprintf("Write to %s " +
"(%s)? Hit 'y' to continue.\n",
usbPath, size)
ui.Render(p)
}()
go func() {
for {
pb.Percent = <-update
ui.Render(pb)
}
}()
go func() {
<-confirm
p.Text = fmt.Sprintf("Copying to %s ...\n", usbPath)
ui.Render(p)
update <- 0
err := cpChunks(isofile, usbPath, update)
if err != nil {
done <- err
}
p.Text = fmt.Sprintf("Done.\n")
update <- 0
ui.Render(p, pb)
}()
for {
select {
case err := <-done:
if err != nil {
globalError = err
return
}
case e := <-uiEvents:
switch e.ID {
case "q", "<C-c>":
return
case "y":
confirm <- true
}
}
}
}
func usage(msg string) {
fmt.Printf("%s\n", msg)
fmt.Printf("usage: %s iso-file\n",
path.Base(os.Args[0]))
os.Exit(1)
}
Um herauszufinden, welche Speicherkapazität der gefundene USB-Stick hat, setzt Listing 2 in Zeile 43 den Befehl »sfdisk -s /dev/sdd« ab. In der Standardausgabe des in Go über das »os.Exec«-Paket abgesetzten Shell-Kommandos steht ein einziger Integerwert, der die Kapazität des Sticks in KByte anzeigt. Vom String mit dem Ergebnis schneidet Zeile 52 den Zeilenumbruch ab. Zeile 53 konvertiert die Zeichenkette mit »Atoi()« aus dem Paket »strconv« in eine Ganzzahl. Zeile 58 dividiert das Ganze durch 1 MByte, sodass die Kapazität in GByte im Fließkommaformat herauskommt.
Die Funktion gibt den Wert formschön als String zurück, damit der User in der UI verifizieren kann, dass es sich wirklich um einen USB-Stick handelt und nicht etwa um eine (viel größere) Festplatte.
Schöner mit UI
Ein Werkzeug mit Benutzeroberfläche, selbst wenn es nur eine Terminalapplikation ist, macht doch ungleich mehr her als eines, das nur auf der Standardausgabe operiert. Das gilt ganz besonders, wenn es dem Benutzer Eingaben zur Auswahl oder Bestätigung abverlangt.
Das Hauptprogramm in Listing 3 nutzt dazu die schon in vorherigen Ausgaben vorgestellte Terminal-UI Termui [1], deren Event-Framework sich zügig mit asynchron aufgerufenen Go-Routinen bedienen lässt. Die in den Abbildungen am Ende dieses Artikels gezeigte Benutzeroberfläche besteht aus zwei sogenannten Widgets, die übereinander im Hauptfenster der Terminal-UI liegen.
Beim oberen der beiden Widgets handelt es sich um ein Text-Widget in der Variablen »p«, das dem Benutzer Statusmeldungen liefert und neue Instruktionen übermittelt. Das untere Widget, das die Variable »pb« referenziert, ist ein Fortschrittsbalken vom Type »Gauge«. Es liest über einen Go-Kanal Updates ein und bewegt den Balken entsprechend der eintrudelnden Prozentwerte von links nach rechts.
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. Falls nicht, verzweigt der Code zur Hilfeseite »usage()« ab Zeile 109. Für die interne Kommunikation zwischen den verschiedenen Programmteilen nutzt der Code ganz ungeniert sage und schreibe fünf verschiedene Channels, die Go-Programmierer laut der offiziellen Richtlinien eigentlich nur in homöopathischen Dosen verwenden sollten.
Der schon besprochene Channel »drivech« meldet der in Zeile 56 blockierenden Go-Routine frisch eingestöpselte USB-Sticks. Der Channel »update« bietet einen Kommunikationskanal zwischen dem Datenkopierer »cpChunks()« aus Listing 1 und dem Hauptprogramm. Sobald der Kopierer einen neuen Prozentwert meldet, löst Zeile 72 ihre Blockade und setzt den Prozentwert des Fortschrittsbalkens in der Variablen »pb« entsprechend. Der folgende 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 87 den Fortschrittsbalken wieder auf 0 Prozent.
Tastatureingaben wie [Strg]+[C] oder [Q]+ fängt die in Zeile 50 mit »PollEvents()« angestoßene Event-Schleife ebenfalls über den Channel »uiEvents« ab. Zeile 98 analysiert die gedrückte Taste und läutet bei den beiden Abbrechersequenzen das Programmende ein. Wurde der Stick bereits erkannt, bremst die Go-Routine ab Zeile 77, indem sie in Zeile 78 auf Daten aus dem »confirm«-Channel wartet. Drückt der User [Y], speist Zeile 103 das Ereignis in den Channel »confirm« ein. Zeile 78 schnappt es auf und öffnet die Schleusen zum Kopieren.
Aufgeschoben oder aufgehoben?
Der Channel »done« wiederum dient dem Hauptprogramm zur Kontrolle darüber, wann die UI zusammengefaltet und das Programm beendet werden soll. Dabei taucht das Problem auf, dass eine Terminal-UI nicht einfach nach »Stderr« schreiben oder das Programm mit »panic()« abbrechen kann, falls ein schwerer Fehler auftritt: »Stderr« ist im Grafikmodus geblockt, und ein abrupt abgebrochenes Programm ließe ein unbenutzbares Terminal zurück, das der User nur durch Schließen des Terminalfensters und Öffnen eines neuen reparieren könnte.
Der Code aus Listing 1 behilft sich damit, eventuell auftretende fatale Fehler in den Channel »done« einzuspeisen. Dort schnappt Zeile 93 von Listing 3 sie auf und legt sie in der in Zeile 26 deklarierten Variablen »globalError« ab. Die geschickte Aufreihung von »defer«-Anweisungen in den Zeilen 27 und 32 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 zuletzt ausführt. Da das »defer« in Zeile 27 den globalen Fehler ausgibt und das »defer« in Zeile 32 die UI zusammenklappt, faltet das Hauptprogramm am Ende immer erst die UI zusammen und gibt dann erst den Fehler aus. Umgekehrt ginge der Fehler verloren.
Feuer frei
Wie immer finden Sie die Listings zu diesem Artikel zum Download auf dem Linux-Magazin-Webserver [2]. Liegen die drei Dateien »isoflash.go«, »cpchunks.go« und »drive.go« in einem Verzeichnis, erzeugt dort der Aufruf aus den ersten zwei Zeilen von Listing 4 das Binary »isoflash«. Das sollte der User per »sudo« aufrufen, damit es auch Schreibrechte auf dem USB-Stick bekommt (letzte Zeile).
Listing 4
Aufruf des Binaries
$ go mod init isoflash $ go build $ sudo ./isoflash ubuntu.iso
Das Programm weist den User sogleich an, den USB-Stick einzustöpseln (Abbildung 1). Sobald es das Device findet, beginnt es nach einer Bestätigung seitens des User die Daten zu kopieren (Abbildung 2). Dabei zeigt es den aktuellen Stand der Dinge mithilfe eines Fortschrittsbalkens an (Abbildung 3). Nach getaner Arbeit meldet die UI den Erfolg des Vorgangs (Abbildung 4).

Abbildung 2: Der eingestöpselte 32-GByte-Stick wurde erkannt. Das Programm wartet auf die Bestätigung vom User.

Abbildung 3: Der Kopiervorgang hat begonnen. Ein Fortschrittsbalken zeigt an, wie viele Bytes der ISO-Datei schon auf den Stick kopiert wurden.
Online PLUS
Im Screencast unter http://www.linux-magazin.de/videos/ demonstriert Michael Schilli das vorgestellte Programmierbeispiel.
Der Autor
Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Fragen.
Infos
- Snapshot: Mike Schilli, “Fortschritt auf Raten”, LM 12/2018, S. 104, https://www.lm-online.de/41708
- Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/08/088_pr-snapshot/









