Files
Sprinter-SDCC/docs/file-buffering-design.md
T
snark13 48d552bf3a libc: FILE* v2 — буферизация потоков (вариант B+)
- единый ленивый буфер BUFSIZ=512 на чтение и запись с
  автопереключением направления (_F_DIROUT, _file_sync: запись
  сбрасывается write()-ом, readahead откатывается lseek-ом)
- статическая таблица OPEN_MAX=8 слотов вместо malloc для FILE;
  _fclosall через atexit — exit() сбрасывает несброшенную запись
- fread/fwrite: мелкое через буфер (memcpy), блоки >= BUFSIZ — мимо
  буфера одним syscall; горячие пути fgetc/fputc и сканер строк
  fgets (LDI до '\n') — на asm, SDCC на эти цепочки генерит ~90
  инструкций с IX-фреймом
- новое: ungetc (1 байт через hold, работает и на stdin),
  fprintf/vfprintf (vsprintf+fwrite), fflush(NULL) = все потоки
- фиксы stdio-review: fwrite ставит _F_ERROR при короткой записи
  (issue 3), fgets(n=1) возвращает пустую строку (issue 4)
- замер (MAME, HDD, 100 КБ): небуферизованная оценка ~144 с →
  fgetc 5 с (×29), fgets ~1 с; дизайн и отвергнутые варианты —
  docs/file-buffering-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:09:13 +03:00

162 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FILE*: буферизация — анализ solid-c и варианты (2026-07-06)
Статус: **вариант B+ РЕАЛИЗОВАН 2026-07-06** (решение пользователя).
Единый ленивый буфер BUFSIZ=512 на чтение/запись с автопереключением
направления (_F_DIROUT), статическая таблица OPEN_MAX=8 слотов
(без malloc для FILE), _fclosall через atexit, ungetc через hold,
fprintf/vfprintf через vsprintf+fwrite, fflush(NULL) = все потоки.
Внутренности: libc/file/_file.h (+_file_sync/_file_buf/_file_slots/
_fclosall). Фактическая цена: filetest 7411→9929 Б _CODE (доля
только-читающих потребителей ~+1.3 КБ, включая malloc); программы
без FILE* не платят. Верификация: MAME filetest + fdmax + fbench.
Ниже — исходный анализ, на основании которого принималось решение.
## Как сделано в solid-c (SRC/CLIB/STDIO.ASM)
Структура FILE — 14 байт, статический массив `_iob[8]` (без malloc
для самих FILE; псевдопотоки stdout/stderr/stdaux/stdprn лежат ПЕРЕД
массивом и адресуются отрицательными индексами — трюк, нам не нужен):
flags(2), level(2), curp(2), fd(2), buffer(2), hold(1), token(2), dummy(1)
Механика:
- **Буфер 512 Б, ленивый malloc** при первом буферизуемом fgetc/fputc
(флаг `_F_BUF` = «буфер наш, free при fclose»). Программа без
файлового I/O не платит ничего.
- **Чтение** (`_fgetc`): `level == 0``read(fd, buffer, 512)`,
`curp = buffer`; отдача — `*curp++`, `level--`; ставится `_F_IN`.
- **Запись** (`_fputc`): `*curp++ = c`, `level++`; при `level == 512`
fflush (один `write` всего буфера); ставится `_F_OUT`.
- **Полудуплекс**: fputc при взведённом `_F_IN` — ОШИБКА (не
авто-flush); направление сбрасывает только fflush.
- **fflush входного потока**: `lseek(fd, -level, SEEK_CUR)` — откат
непрочитанного readahead, буфер инвалидируется. Выходного —
`write(buffer, level)`.
- **fseek/ftell** = fflush + голый lseek/ltell по fd (после flush
позиция fd совпадает с логической позицией потока).
- **ungetc**: буфер не пуст → `*--curp = c`; пуст/отсутствует → символ
в поле `hold`, `curp` указывает на hold.
- **fclosall через atexit** — сброс буферов при exit.
- Консольные потоки минуют буфер (RST-вызовы напрямую).
- **fread/fwrite — ПОБАЙТОВЫЙ цикл** через _fgetc/_fputc: большие блоки
платят call+IY-доступ за каждый байт. Это слабое место порта.
## Варианты для нас
**A. Полный порт solid-c** (буфер на чтение и запись).
Плюсы: ускоряются и писатели через fputc/fprintf. Минусы: полудуплекс
(«запись после чтения без fflush — ошибка») — источник тонких багов;
обязателен flush в exit (сцепка atexit+file); больше кода во всех
модулях; наши блочные fread/fwrite пришлось бы защищать от деградации.
**B. Буферизовать ТОЛЬКО чтение (рекомендую).**
FILE += `buf(2), level(2), curp(2), hold(2)`; буфер 512 Б лениво.
- fgetc: hold → буфер → refill. fgets остаётся циклом по fgetc
(теперь дешёвым).
- fread: сначала хвост буфера (memcpy), остаток ≥ 512 — прямой read()
в ptr одним syscall (обходя буфер), мелкий остаток — refill.
- Запись НЕ буферизуется — как сейчас: fputc = write(1 байт),
fputs/fwrite = один write() на блок. Нечего терять при аварии,
fflush остаётся no-op по данным, полудуплекса нет.
- Согласование позиций: перед write/fseek/ftell на потоке с readahead —
`lseek(fd, -(level), SEEK_CUR)` + инвалидация буфера (один общий
хелпер `_file_sync`). ftell = lseek(0,CUR) level (без syscall не
выйдет — lseek и так syscall).
- ungetc: через hold, работает и до первого заполнения буфера.
Плюсы: решает главную боль (парсеры), запись остаётся простой и
надёжной, никакого flush-on-exit, r+ работает через _file_sync.
Минусы: fputc-писатели остаются медленными (редкий паттерн — fputs/
fwrite блочные и так быстрые).
**B+. Единый буфер на чтение И запись с АВТОпереключением направления
(предложение 2026-07-06, кандидат в целевой дизайн).**
Схема solid-c, но без ловушки: направление переключает сама библиотека.
- флаг направления в FILE: буфер сейчас «readahead» или «накопитель
записи»;
- fputc при направлении «чтение»: `_file_sync` (отмотка fd на -level,
буфер пуст) → режим записи → накопление; сброс write() при
заполнении;
- fgetc при направлении «запись»: flush (write(buf, level)) → режим
чтения → refill;
- fwrite больших блоков: flush + прямой write мимо буфера; мелких —
memcpy в буфер. fread симметрично;
- fseek/ftell/fclose: flush-или-sync по направлению; ftell = позиция
fd level (чтение) / + level (запись);
- **обязателен реестр открытых потоков**: поле next в FILE
(регистрация в fopen, снятие в fclose) + _fclosall через atexit —
стандарт требует flush всех потоков в exit(); без этого
`fputs(...); exit(1);` теряет данные;
- цена-семантика: ошибки записи становятся ОТЛОЖЕННЫМИ (вылезают при
flush/fclose, не в момент fputc) — проверять результат fclose;
- цена-код: ~+350–500 Б против ~+200–300 у B (тянется только
использующими FILE*).
**C. Оставить небуферизованным** («большие файлы читаются целиком в
EMM», паттерн mdview). Для приложений-парсеров среднего размера
неудобно; отвергается самим существованием П2-пункта.
**D. Полная стандартная буферизация + setvbuf** — отвергнуто ранее
решением file_star_design (минимальный FILE*).
## Что взять у solid-c при варианте B
- ленивый malloc 512 Б + флаг «буфер наш»;
- откат readahead lseek'ом (механика их fflush-на-вход) — как
`_file_sync` перед write/fseek/ftell;
- ungetc с hold-байтом;
- консольные потоки мимо буфера (у нас уже так).
Чего НЕ брать: побайтовые fread/fwrite, полудуплекс, статический
`_iob[]` с отрицательными индексами, буферизацию записи.
## Лимит открытых файлов и статическая таблица FILE (2026-07-06)
Факты: solid-c — OPEN_MAX = 8, статический массив из 8 FILE-структур
(+5 псевдопотоков перед ним), fdopen отвергает fd > 8. DSS-доки:
FCB строятся «в рабочих областях ДОС», код ошибки 06h = «Too many
open files» (наш EMFILE = 6 совпадает).
**ПОДТВЕРЖДЕНО тестом fdmax (MAME, DSS 1.71.57, 2026-07-06)**:
пользователю доступно 8 манипуляторов, fd 2..9 (fd 1 держит шелл DSS
под запущенный .exe). КРИТИЧНО: 9-й OPEN не возвращает 06h — он
ВЕШАЕТ систему. Поэтому в libc/io добавлен предохранитель _fd_guard
(счётчик в open/close, отказ EMFILE на 9-м open без захода в DSS) —
таблица fopen и guard вместе закрывают и высокий, и низкий уровень.
Следствие для B+: вместо malloc-FILE + связного списка-реестра —
**статическая таблица из 8 слотов** (свободный слот: flags == 0):
- реестр для flush-on-exit бесплатен: _fclosall = цикл по таблице;
- fopen без malloc — единственный отказ синхронен с отказом DSS
(EMFILE), утечка «fclose без free» невозможна;
- цена: ~112128 Б BSS (в exe не входит), только у программ с fopen;
- буферы НЕ статические — остаются ленивыми malloc 512 Б (потолок
8×512 = 4 КБ heap в худшем случае);
- объявить FOPEN_MAX 8 в stdio.h; если fdmax покажет лимит DSS < 8 —
уменьшить таблицу.
## Семантика инвалидации (вариант B) — контрольный сценарий
Буфер — только кэш опережающего чтения. Правило: **любая запись и
любой fseek обнуляют буфер; запись всегда идёт напрямую в файл после
отмотки позиции** (`_file_sync`: `lseek(fd, -level, SEEK_CUR)` +
`level = 0`). Буфер при записи НЕ патчится — write-through с правкой
окна отвергнут как сложный ради редкого паттерна.
Сценарий «r+, чтение-запись-чтение» (обсуждено 2026-07-06):
read 512 в буфер → 10×fgetc (логическая поз. 10, fd на 512) →
первый fputc: sync отматывает fd на 10, буфер пуст, 10×write ложатся
на 10..19 → fseek(0) → fgetc перечитывает буфер С ДИСКА и видит
записанные байты. Протечка старой копии невозможна — она уничтожена
в момент первой записи.
## Оценка/проверка
Бенчмарк до/после: цикл fgets по tests/seek/big.txt с замером ptime
(тест tests/fbench), плюс filetest-регресс в MAME. Ожидание: чтение
~512× меньше syscall'ов; код file-модулей +200–300 Б (тянется только
использующими FILE*).