Я работал над совершенствованием эмулятора DOS и внезапно обнаружил, что достаточно тривиальная операция работает неправильно. Когда просишь COMMAND.COM сделать следующее:
echo AB> foo.txt
echo CD>> foo.txt
то вместо ABCD в файл foo.txt записывается ABBC.
Я проверил и убедился, что
fwrite()
действительно передаются правильные данные, но хитрость в том, что действия COMMAND.COM не так просты, как можно подумать:- Открываем foo.txt
- Записываем «AB»
- Закрываем foo.txt
- Открываем foo.txt
- Выполняем поиск на один байт назад от конца файла
- Считываем один байт
- Записываем «CD»
- Закрываем foo.txt
Такая сложность нужна, потому что COMMAND.COM хочет учесть случай, когда файл заканчивается символом Ctrl-Z (в нашем случае его нет): в этом случае Ctrl-Z необходимо удалить. Почему-то последовательность «поиск-чтение-запись» работала странно. Но почему?
Усевшись за отладчик, я разобрался, как можно исправить библиотеку среды выполнения C (Open Watcom), чтобы избежать этой проблемы. Но я не мог отделаться от чувства, что такой простой баг должны были обнаружить и устранить много лет назад.
Я написал простую тестовую программу, которую можно было бы проверить с другими компиляторами.
К моему огромному удивлению, Microsoft Visual C++ 6.0, а также IBM C/C++ 3.6 for Windows записывали в выходной файл только «AB»! «CD» вообще туда не записывались.
Я добавил логгинга и выяснил, что в обоих случаях вторая
fwrite()
сообщала, что она записала ноль байтов. Но тут начали происходить всякие странности.В среде выполнения Microsoft
ferror()
задавалась, но errno
был равен нулю. В среде выполнения IBM ferror()
была сброшена, но errno
имел значение 41. Согласно заголовку errno.h
IBM, это означает EPUTANDGET
, но что значит эта ошибка?На этом этапе я уже знал, что делаю что-то не так. Но что? В кои-то веки, правильный ответ нашёлся на StackOverflow! Потрясающе, такого почти никогда не случается.
▍ Но почему же, почему?
Разумеется, мы задаёмся вопросом, почему так происходит. Такое неочевидное поведение базовых функций ввода-вывода в файл (в зависимости от последовательности вызовов других функций, они или молча завершаются со сбоем, или не записывают ожидаемые данные) явно неоптимально.
Понятно, что библиотеке C не должно быть сложно контролировать, какой была последняя операция ввода-вывода — чтением или записью — и выполнять соответствующую очистку или поиск при смене направлений. И в самом деле, среда выполнения C IBM выполняет внутреннее отслеживание, а при нарушении правильной последовательности генерирует ошибку, указывающую конкретно на это.
Самое близкое к ответу, что мне удалось найти — это «так обстояло всегда».
Только «всегда» здесь означает «примерно с 1979 года», то есть не прям всегда. Взглянув на редакцию K&R 1978 года, становится очевидно, откуда это взялось: исходная библиотека K&R поддерживала только режимы чтения (
"r"
), записи ("w"
) и добавления ("a"
) для fopen()
, а добавление, по сути, было записью. Режима обновления ("r+"
) не существовало, а потому чтение и запись нельзя было смешивать! Это очень похоже на часть правильного ответа.К моменту выпуска самого старого из сохранившихся драфтов ANSI C такое поведение уже считалось стандартным. За годы изменилось очень немногое:
Когда файл открывается с режимом обновления ('+'
в качестве второго или третьего символа в аргументе режима), с ассоциированным потоком может выполняться и ввод, и вывод. Однако вывод не может идти непосредственно за вводом без промежуточного вызова функцииfflush
или функции позиционирования в файле (fseek
,fsetpos
илиrewind
), а за вводом не может непосредственно идти вывод без промежуточного вызова функции позиционирования в файле, если только операция ввода не достигает end-of-file. В некоторых реализациях открытие файла в режиме обновления может открывать или создавать поток двоичных данных.
Драфт ANSI X3J11 C, 1988 год
В ANSI C Rationale содержится следующий текст:
Смена направления ввода/вывода для обновляемого файла допускается только после операцииfsetpos
,fseek
,rewind
илиfflush
, так как именно эти функции гарантируют очистку буфера ввода-вывода.
Подразумевается, что когда буфер ввода-вывода содержит данные, переключать направление чтения/записи небезопасно.
Опубликованный ANSI C89/ISO C90 почти идентичен драфту стандарта. В C99 «не может» заменили на «не должен», но всё остальное практически не поменялось:
Когда файл открывается с режимом обновления ('+'
в качестве второго или третьего символа в аргументе режима), с ассоциированным потоком может выполняться и ввод, и вывод. Однако вывод не должен идти непосредственно за вводом без промежуточного вызова функцииfflush
или функции позиционирования в файле (fseek
,fsetpos
илиrewind
), а за вводом не должен непосредственно идти вывод без промежуточного вызова функции позиционирования в файле, если только операция ввода не достигает end-of-file. В некоторых реализациях открытие файла в режиме обновления может вместо этого открывать (или создавать) поток двоичных данных.
ISO C99, 1999 год
Если мы перенесёмся ещё почти на четверть века вперёд, то увидим следующее:
Когда файл открывается с режимом обновления ('+'
в качестве второго или третьего символа в аргументе режима), с ассоциированным потоком может выполняться и ввод, и вывод. Однако вывод не должен идти непосредственно за вводом без промежуточного вызова функцииfflush
или функции позиционирования в файле (fseek
,fsetpos
илиrewind
), а за вводом не должен непосредственно идти вывод без промежуточного вызова функции позиционирования в файле, если только операция ввода не достигает end-of-file. В некоторых реализациях открытие (или создание) файла в режиме обновления может вместо этого открывать (или создавать) поток двоичных данных.
ISO C23, 2024 год
Мы доказали, что в этом вопросе Standard C не менялся с 1988 года до нашего времени.
Но, разумеется, комитет ANSI X3J11 не изобрёл библиотеку C. Он работал на базе более ранних документов, а именно (в случае библиотеки) /usr/group Standard 1984 года.
Хоть мне и не удалось найти экземпляр /usr/group Standard, но комитет /usr/group аналогично не создал библиотеку C, а попытался стандартизировать имеющиеся реализации. Это значит, что ответ может находиться в старых руководствах по UNIX.
Даже System V слишком молода, поэтому нам придётся идти ещё глубже в прошлое. На странице руководства по
fread
в AT&T UNIX System III manual содержится следующий текст:При открытии файла для обновления с получившимся потоком могут выполняться и ввод, и вывод. Однако вывод не может непосредственно следовать за вводом без промежуточнойfseek
илиrewind
, а за вводом не может непосредственно
следовать вывод без промежуточнойfseek
,rewind
или операции ввода, дошедшей до конца файла.
AT&T UNIX System III manual, 1980 год
Хм, текст от 1980 года очень похож на тот, который добрался до ANSI C89. Да, в нём пока нет
fsetpos()
(это изобретение ANSI C), и в тексте загадочно отсутствуют упоминания fflush()
, несмотря на то, что очистка с большой вероятностью даже тогда позволяла выполнять переключение с чтения на запись.Но очевидно, что ограничение на переходы между чтением и записью в потоках библиотеки C существует уже очень долгое время.
В 7th Edition UNIX (1979 год) и даже в дополненной документации 1983 года не упоминается режим обновления для fopen(), а потому не содержится рекомендаций по смене направлений чтения/записи.
▍ Современные практики
Как минимум Linux (glibc) и FreeBSD допускают свободное смешивание чтения и записи. Страница man FreeBSD по
fopen()
гласит следующее:Чтение и запись могут перемежаться на потоках чтения/записи в любом порядке и не требуют промежуточных seek, как в предыдущих версиях stdio. Однако это не портируется на другие системы; ISO/IEC 9899:1990 («ISO C90») и IEEE Std 1003.1 («POSIX.1») требуют, чтобы ввод и вывод перемежались функцией позиционирования в файле, если только операция ввода не достигает end-of-file.
Документация по библиотеке Microsoft (на 2024 год) повторяет ISO C, глася, что при смене направления чтения/записи требуется очистка или поиск.
С другой стороны, прозрачная обработка переключения направления в библиотеке — не такая уж сложная задача. Впрочем, это стимулирует программистов писать не согласующийся с C код, который в других реализациях будет приводить к весьма интересным сбоям. Как всегда, приходится выбирать компромиссы.
▍ Старые исходники
Изучение исторического исходного кода оказалось довольно интересным процессом.
В 32V UNIX за 1979 год чётко говорится, что fopen открывает файлы для чтения или записи, но не для двух операций одновременно (и любой режим, отличающийся от 'w' или 'a', косвенно подразумевает 'r'!).
V6 UNIX от 1975 года настолько стар, что там даже нет
fopen()
. С другой стороны, System III от 1980 года поддерживает режим обновления, а открытие потоков для обновления задаёт явный флаг _IORW
(и, как говорилось выше, документация System III требует особой аккуратности при смене направления ввода-вывода).В V7 UNIX за 1979 год всё становится странным. Хотя в документации не упоминается опции режима обновления для
fopen()
, действительная реализация его поддерживает. На самом деле, код V7 от 1979 года почти идентичен коду в System III, выпущенному год спустя. Почему? Ответа я не знаю.А ещё есть код 2BSD, тоже из 1979 года. Хотя для
fopen()
BSD нет требования указания режима обновления символом '+', она допускает указание режимов открытия наподобие "rw"
, задающих одновременно флаги _IOREAD
и _IOWRT
. На самом деле, на странице man fopen 2BSD явным образом указаны 'rw'
и 'ra'
как поддерживаемые режимы открытия, допускающие и чтение, и запись, но ничего не написано о том, допускается ли свободное перемешивание fread()
и fwrite()
. Также там есть объяснительный файл README с примечанием за ноябрь 1978 года, описывающим изменение, которое позволило совместно использовать доступ для чтения и для записи.В статье Денниса Ритчи A New Input-Output Package за 1977 год достаточно чётко говорится, что когда задумывалась первая
fopen()
, поток должен был поддерживать или чтение, или запись, но не обе операции. Также из документа следует, что пользователи считали это слишком строгим ограничением, и что к 1979 году было, по крайней мере, две другие реализации (AT&T и BSD), допускавшие смешанные потоки чтения/записи.Примечательно, что в реализации BSD
fopen()
была модифицирована так, чтобы обеспечивать и чтение, и запись, но fread()
и fwrite()
остались без этих изменений. Непонятно, настолько ли надёжен код BSD, чтобы обеспечивать свободное перемешивание чтения и записи. В документации AT&T всегда чётко говорилось, что это не допускается.Что касается Standard C и POSIX, то они не поменялись до сего дня. Чтобы писать портируемый код, необходимо предпринимать некие действия при смене направления чтения/записи. Пустого вызова вида
fseek( f, 0, SEEK_CUR );
совершенно достаточно для переключения потока в состояние, в котором переключение между чтением и записью безопасно.
Думаю, подобные странности обязательно возникают, когда системы имеют почти полувековую историю.
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
Комментарии (5)
Harisgod
13.01.2025 15:27Эх, помню как отчаянно пытался сделать домашку по плюсам, юзал fstream и когда искал какие в нём функции можно использовать нашёл что есть два указателя на индекс с которого считывается символ и ещё другой где записывается символ.
После множества часов или дней неправильно работающейся и ломающейся программы, я пришёл на пару по плюсам и сказал об этой фиговине преподу.
- Ну так надо было внимательно слушать, на винде оба указателя указывают на одно и тоже место, тоесть читаешь ты или записываешь, индексы равны.... (сам виноват, сам отвлекся на паре)
vadimr
13.01.2025 15:27Не имею настоящего DOS, но в окне DOS под OS/2 под Parallels Desktop указанные команды дают результат ABCD. Так что пусть автор чинит свой эмулятор.
ahabreader
13.01.2025 15:27Ну, и он нашёл баг. Только в собственном эмуляторе. Он в комментариях объяснил, но не отредактировал для ясности саму статью.
> I see:
> AB
> CDOf course! Because you’re not running it in the emulator I’m working on. So you don’t get the problem. I probably didn’t explain it well enough in the blog post.
---
А вы знали, что один из самых простых и надёжных способов получить экранированный знак табуляции в скрипте для виндового cmd.exe (т.е. без нажатия на TAB; \t в языке нет) - это вытащить его из заголовка исполняемого файла (собственно, cmd.exe)?
call :get_tab_character some_var echo This is a TAB: [%some_var%] exit /b :get_tab_character (out arg1) :: https://stackoverflow.com/a/49959194 ((for /L %%P in (1,1,70) do pause >NUL) & set /p "dos_stub_0x46_offset=")<"%COMSPEC%" set "%~1=%dos_stub_0x46_offset:~0,1%" exit /b
Wesha
Какой-то прямо сегодня день открытий от миллениалов.