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

12 KiB
Raw Blame History

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 == 0read(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*).