- единый ленивый буфер 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>
12 KiB
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» невозможна;
- цена: ~112–128 Б 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*).