Radek Chalupa   konzultace a školení programování, vývoj software na zakázku

Nové příspěvky z blogu

Souborové operace na Linuxu v jazyce C

Jak napovídá již nadpis, budeme se zabývat programováním pro Linux, tj. nebudeme omezeni požadavkem na multiplatformnost a budeme se moci podívat na výhody/nevýhody Linuxových nástrojů z hlediska výkonu a možností. Dále zůstaneme u čistého jazyka C.

Ukážeme si možnosti jak realizovat jednoduchý úkol spočívající v otevření souboru (budeme předpokládat textový soubor), zjištění jeho velikosti a načtení celého souboru do naalokovaného bufferu jedním příkazem. Obsah souboru poté vypíšeme do terminálu, přesněji do standardního výstupu.

Použití standardních funkcí jazyka C

Nejprve tedy "klasické" řešení pomocí knihovních funkcí jazyka C, které je (jako multiplatformní) v praxi nejčastěji používané.

static void use_data(uint8_t* pdata, size_t size)
{
	fwrite(pdata, size, 1, stdout);
}

static int do_fopen(const char* path)
{
	long fsize;
	uint8_t* pdata;
	FILE* pf = fopen(path, "r");
	if (NULL == pf) {
		perror("fopen");
		return -1;
	}
	fseek(pf, 0L, SEEK_END);
	fsize = ftell(pf);
	if (-1 == fsize) {
		perror("ftell");
		fclose(pf);
		return -1;
	}
	fseek(pf, 0L, SEEK_SET);
	pdata = (uint8_t*)malloc(fsize);
	if (NULL == pdata) {
		perror("malloc");
		fclose(pf);
		return -1;
	}
	if (1 != fread(pdata, fsize, 1, pf)) {
		perror("fread");
		free(pdata);
		fclose(pf);
	}
	fclose(pf);
	use_data(pdata, fsize);
	free(pdata);
	return 0;
}

Použití systémových volání open, read...

Pro otevření souboru můžeme v Linuxu použít systémové volání open, které je na Linuxu implementováno v knihovně jazyka C (libc). Volání v případě úspěchu vrátí tzv. popisovač souboru (file descriptor), což je (malé) celé číslo, které představuje index v tabulce otevřených popisovačů procesu. Při chybě vrátí -1 a je příslušně nastavena hodnota errno.

Pro zjištění velikosti souboru pak použijeme funkci fstat, které na základě otevřeného popisovače naplní strukturu stat, obsahující kromě dalších informací o souboru jeh velikost v bytech. Pro načtení dat ze souboru do předem naalokovaného bufferu pak použijeme funkci read, která na rozdíl od fread vyžaduje místo velikosti bloku a počtu bloků jednoduše pouze požadovaný počet bytů k načtení, což mě osobně přijde praktičtější a plně dostačující (kdo si má pamatovat který z parametrů funkce fread je druhý a který třetí :-)). Pro zavření popisovače pak použijeme funkci close.

Nyní opět ukázka implementace stejného zadání:

static int do_open(const char* path)
{
	uint8_t* pdata;
	struct stat st;
	int fd = open(path, O_RDONLY, 0);
	if (fd < 0) {
		perror("open");
		return -1;
	}
	if (0 != fstat(fd, &st)) {
		perror("stat");
		close(fd);
		return -1;
	}
	pdata = (uint8_t*)malloc(st.st_size);
	if (st.st_size != read(fd, pdata, st.st_size)) {
		perror("read");
		close(fd);
		free(pdata);
	}
	close(fd);
	use_data(pdata, st.st_size);
	free(pdata);
	return 0;
}

Automaticky otevřené proudy

Při svém startu má každý program k disposici 3 otevřené proudy: pro vstup, výstup a výpis chybových a diagnostických zpráv. Tyto souborové popisovače mají hodnoty 0, 1 a 2, které jsou v hlavičkovém souboru unistd.h deklarovány jako makra STDIN_FILENO, STDOUT_FILENO A STDERR_FILENO, které můžeme použít jako parametr např. funkcí read a write. Například výstup do terminálu, pro který se používají mj. funkce printf nebo puts, můžeme realizovat pomocí funkce write třeba takto.

char buf[64];
strcpy(buf, "nejaky text pro vystup..");
write(STDOUT_FILENO, buf, strlen(buf));

Automaticky otevřené souborové pointery

K výše zmíněným automaticky otevřeným souborovým popisovačům můžeme přistupovat jako k souborovým pointrům (typu FILE*) definovaným v hlavičkovém souboru stdio.h

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

Propojení open a fopen

Pointr na soubor můžeme získat také pomocí funkce

FILE* fdopen(int fd, const char* mode);

která má jako vstupní parametr již existující (otevřený) popisovač souboru. Na něj pak můžeme aplikovat "klasické" céčkovské funkce jako fread, fwrite. Důležité je vědět že popisovač není duplikován a při zavření souboru funkcí fclose je zavřen také popisovač souboru, tedy nemůžeme už na něj volat funkci close.

Porovnání fopen/fread vs open/read

Pokud je požadavkem multiplatformnost, přesněji řečeno přenositelnost zdrojové kódu beze změny, vítězem je samozřejmě fopen a související.

Pokud porovnáváme možnosti a řekněme rozsah parametrizace, je výhoda naopak na straně open. Když se podíváme do dokumentace (man 2 open), zjistíme že ve 2. a 3. parametru máme širokou škálu možností jako je specifikace uživatelských oprávnění v případě že vytváříme nový soubor (O_CREAT v 2. parametru) na úrovni uživatele a skupiny (čtení, zápis a spouštění)

Na druhou stranu je pravdou že pokud žádáme komfort místo vlastní práce, při otevření pomocí fopen máme k disposici hotové funkce pro čtení souboru po řádcích - fgets a getline. Nicméně procházet načtená data ze souboru a hledat znak pro nová řádek by nemělo být pro céčkaře nic frustrujícího :-).

V případě že výkon (rychlost) je prioritou, vzhledem k tomu že open je "low-level" a fopen interně volá systémové volání open, bude open "o něco" rychlejší. K tomu lze přidat (podle mého testování již "více než o něco") rychlejší zjištění velikosti souboru pomocí stat oproti fseek/ftell s již zmíněnou třešničkou na dortu dalších informací o souboru (velikost zabraného místa na disku a časy přístupu a modifikace) poskytnutých funkcí stat.

Paměťové mapování souboru

Na Linuxu pro přístup k obsahu souborů paměťové mapování poskytované kernelem. V programu pak můžeme k obsahu souboru přistupovat stejně jako k paměti přes pointr. Tento přístup může být pro četní i zápis. Nejprve opět stejný úkol řešený pomocí paměťového mapování.

static int do_mmap(const char* path)
{
	int fd;
	uint8_t* pdata;
	struct stat st;
	fd = open(path, O_RDONLY, 0);
	if (fd < 0) {
		perror("open");
		return -1;
	}
	if (0 != fstat(fd, &st)) {
		perror("stat");
		close(fd);
		return -1;
	}
	pdata = (uint8_t*)mmap(0, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
	if (MAP_FAILED == pdata) {
		perror("mmap");
		close(fd);
		return -1;
	}
	if (-1 == close(fd)) {
		perror("close");
		return -1;
	}
	use_data(pdata, st.st_size);
	if (-1 == munmap(pdata, st.st_size)) {
		perror("munmap");
		return -1;
	}
	return 0;
}

Výhody paměťového mapování

Pokud jde o výkon/rychlost, nedochází k "nadbytečnému" kopírování ke kterému dochází při volání read a write, kdy jsou data kopírovánu z/do user-space bufferu. U velkých souborů je (i dle vlastního testování) načtení dat znatelně rychlejší než open/read nebo fopen fread.

Paměťové mapování souboru umožňuje snadný způsob sdílení dat mezi procesy/aplikacemi.

Pohyb v datech souboru realizujeme běžnou pointrovými operacemi bez nutnosti volat fseek nebo systémové volání lseek.

Nehody paměťového mapování

Velikost paměťové mapy je vždy celý násobek velikosti stránky paměti, co je standardně 4KiB, což znamená že mapování malých souborů může znamenat i několikanásobné obsazení paměti než je velikost souboru. Samozřejmě záleží na konkrétní situaci (např. velikosti fyzické RAM systému) jak je to významné.