Skip to content
Назад к блогу
Руководства

Переводы строк CRLF и LF: что ломается на самом деле

Скрипт с CRLF часто отрабатывает нормально и выходит с кодом 0 — поломка прячется в переменной. Разберём четыре сценария сбоя, текст ошибки по оболочкам и реальную матрицу core.autocrlf.

14 мин чтения

Переводы строк CRLF и LF: что ломается на самом деле

Разница между CRLF и LF — один байт. LF — это одиночный \n (0x0A), которым заканчиваются строки в Linux и macOS. CRLF — два байта, \r\n (0x0D 0x0A), и так строки заканчиваются в Windows.

Теперь то, в чем ошибается большинство статей. Скрипт с переводами строк CRLF обычно не падает. Он выполняется, печатает ровно то, что от него ждали, и выходит с кодом 0. Ломается значение, которое лежит в переменной: присваивание сохранило хвостовой \r, и никто на это не пожаловался.

Замеры дали четыре сценария. Ниже они расставлены от самого незаметного к самому шумному:

  1. Тихий успех — вывод выглядит правильным, в переменной сидит невидимый \r, код выхода 0.
  2. Ошибка, которая ничего не меняет: command not found на пустой строке, скрипт доходит до конца, код выхода 0.
  3. Синтаксическая ошибка — ломаются if, for и определения функций, код выхода 2.
  4. bad interpreter\r попал в строку shebang, код выхода 126.

Начинать стоит с file yourfile: одной строки вывода хватает, чтобы понять, CRLF перед вами или LF.

Всё, что описано ниже, измерено на macOS (Darwin arm64) с bash 3.2.57(1)-release, zsh 5.9, dash, git 2.55.0, node v26.7.0 и Python 3.14.6 и перепроверено на Linux через docker run --rm bash:5, то есть на GNU bash 5.3.15(1)-release (aarch64-unknown-linux-musl).

1. CRLF, LF и CR: какие байты за этим стоят

CRLF расшифровывается как carriage return line feed, и это буквально два склеенных символа:

НазваниеEscapeБайт
Возврат каретки\r0x0D
Перевод строки\n0x0A
CRLF\r\n0x0D 0x0A

Что пишет каждая платформа:

ПлатформаПеревод строки
WindowsCRLF (\r\n)
Linux, современные macOSLF (\n)
Mac Classic, OS 9 и раньшеодиночный CR (\r)

Названия механические. На телетайпе возврат каретки отводил печатающую головку обратно к левому полю, а перевод строки прокручивал бумагу на одну строку вверх. Windows сохранила оба движения в виде двух байт; Unix решила, что хватит и одного. Одиночный CR до сих пор встречается в старых выгрузках, и файл, где все переводы строк такие, для большинства инструментов Unix выглядит одной гигантской строкой.

Одна команда показывает, какой вариант перед вами

file читает байты и называет терминатор:

$ file d1.txt
d1.txt: ASCII text, with CRLF line terminators

$ file d2.txt
d2.txt: ASCII text no suffix means pure LF

$ file d3.txt
d3.txt: ASCII text, with CRLF, LF line terminators mixed, file lists both

Третий случай стоит запомнить. Смешанные переводы строк означают, что в файл по очереди писали два инструмента с разными настройками: редактор сохранил LF, а скрипт потом дописал CRLF. Слияние двух версий дает тот же результат. od -c показывает разрыв побайтово:

$ od -c d3.txt
0000000   a  \r  \n   b  \n
0000005

Переводы строк живут на уровне байтов, по соседству с кодировкой символов; руководство по кодировкам UTF-8 и UTF-16 разбирает, что происходит уровнем выше.

2. Четыре сценария сбоя: от тихого успеха до кода 126

Один и тот же файл с \r\n — пять разных исходов в зависимости от того, что стоит в строке:

Содержимое скриптаЧто происходит на самом делеКод
echo hello и другие простые командыТихий успех, вывод корректный0
присваивание X=abcТихий успех, но значение заканчивается на \r0
Пустые строки, продолжения через \ в конце строки: command not found, скрипт доходит до конца0
if/fi, for/do/done, f() {syntax error near unexpected token2
Строка shebang с \rbad interpreter: No such file or directory126

Первые две строки таблицы и есть повод для этой статьи. Утверждение «CRLF вызывает command not found» повторяют повсюду, но происходит совсем другое: простые команды вообще не жалуются.

Самый опасный случай — тихий успех

$ printf 'echo hello\r\necho world\r\n' > win.sh && bash win.sh
hello
world
[exit=0]

Две строки на входе, две на выходе, код выхода 0. Отлаживать нечего, и в логе нечего искать через grep. Дайте тому же скрипту что-нибудь сравнить — и картина меняется:

$ printf 'V=1.2.3\r\ntest "$V" = "1.2.3" && echo MATCH || echo NO-MATCH\r\n' > v.sh
$ bash v.sh
NO-MATCH
[exit=0]

В $V лежит 1.2.3\r, а не 1.2.3. На macOS и Linux результат одинаковый. Проверка версии, if [ "$ENV" = "prod" ], любое сравнение со значением из файла — всё это тихо уходит не в ту ветку с кодом выхода 0. Это самая трудноуловимая ошибка с переводами строк в CI: сборка зеленая, лог чистый.

Ошибка, которая ничего не останавливает

Пустая строка внутри файла с CRLF — это строка из одного \r, и оболочка честно принимает ее за команду. Команда не находится, печатается сообщение, скрипт переходит к следующей строке и доходит до конца с кодом 0. Точная формулировка зависит от оболочки, о ней речь в следующем разделе.

Продолжения строк через обратный слэш ломаются так же. \r оказывается между обратным слэшем и переводом строки, поэтому продолжение перестает быть продолжением, и следующая строка выполняется сама по себе.

Синтаксические ошибки и почему fi — это не fi

c_if.sh: line 5: syntax error: unexpected end of file
[exit=2]

Linux bash 5.3.15 говорит о том же файле больше:

i.sh: line 5: syntax error: unexpected end of file from `if' command on line 2

Циклы и определения функций падают не на последней строке, а на первой:

c_for.sh: line 1: syntax error near unexpected token `do'
c_for.sh: line 1: `for i in 1 2; do'

c_func.sh: line 1: syntax error near unexpected token `{'
c_func.sh: line 1: `f() {'

Механизм один на весь класс ошибок. Bash читает закрывающее слово как fi\r, а fi\r — не ключевое слово fi. Блок if так и не закрывается, bash читает дальше, пока файл не кончится, и в итоге ругается на последнюю строку вместо сломанной.

bad interpreter и код выхода 126

$ ./s.sh
bash: ./s.sh: /bin/bash^M: bad interpreter: No such file or directory
[exit=126]

macOS и Linux печатают один и тот же текст и оба выходят с кодом 126. Прочитайте путь в сообщении: /bin/bash^M. Ядро считает путем к интерпретатору всё, что стоит после #! до перевода строки, и \r попадает внутрь. Такого файла не существует, поэтому exec падает раньше, чем выполнится хоть одна строка скрипта.

Код 126 покрывает еще и случай «найден, но не исполняемый», так что не всякий отказ стартовать упирается в переводы строк: права доступа к файлам дают отказы из того же семейства. Отличает их ^M внутри пути.

Почему \r никогда не видно

Читать вывод бесполезно: разглядеть или выделить этот байт не получится. Его приходится вытаскивать принудительно:

$ bash d.sh                    # script: HOST=example.com / echo "connecting to $HOST:8080"
connecting to example.com:8080 what you get

$ bash d.sh | cat -v
connecting to example.com^M:8080^M what is actually there

$ bash d.sh | od -c
0000000   c   o   n   n   e   c   t   i   n   g       t   o       e   x
0000020   a   m   p   l   e   .   c   o   m  \r   :   8   0   8   0  \r
0000040  \n

Два байта \r, невидимые в обычном выводе, и оба внутри строки, которая вот-вот пойдет в дело как имя хоста. Иногда байт уезжает в аргумент, и вину берет на себя посторонний инструмент:

$ bash v.sh          # the script pipes through: ... | head -2
head: illegal line count -- 2\r

head ведет себя правильно. Ему передали 2\r. Любое сообщение об ошибке, внутри которого затесался лишний \r, — это та же самая поломка под чужим именем.

3. Почему сообщение об ошибке не похоже на то, что пишут в интернете

Один файл — printf 'echo a\r\n\r\necho b\r\n', где строка 2 пустая и содержит только \r, — запущен под четырьмя оболочками:

ОболочкаВерсияТочное сообщение
bash (в комплекте с macOS)3.2.57(1)-releases_blank.sh: line 2: : command not found
bash (массовые дистрибутивы Linux)5.3.15(1)-releaset.sh: line 2: $'\r': command not found
zsh5.9s_blank.sh:2: command not found: ^M
dashs_blank.sh: 2: : not found

Почти каждый результат поиска цитирует вторую строку. Bash 4 и 5 печатают непечатаемые символы через ANSI-C quoting, и возврат каретки превращается в $'\r'. Идущий с macOS bash 3.2 так не умеет, поэтому на месте символа остается пустота, а видно только двоеточие. zsh печатает ^M. dash вовсе опускает слово «command».

Одна поломка — четыре сообщения. Если поиск по точному тексту ошибки ничего не дал, причина именно в этом. Голое : command not found — та же поломка, что и растиражированный вариант с $'\r'.

4. Git: что core.autocrlf на самом деле кладёт в репозиторий

Большинство объяснений про git autocrlf останавливаются на определениях. На практике важнее другое: что оказывается в commit и что получают коллеги при checkout? Измерено через git cat-file -p HEAD:f.txt для сохраненного blob и rm f.txt && git checkout -- f.txt для рабочей копии:

core.autocrlfИсходный файлBlob в репозиторииРабочая копия после checkout
trueCRLFLFCRLF
trueLFLFCRLF
inputCRLFLFLF
inputLFLFLF
falseCRLFCRLFCRLF
falseLFLFLF

Что из нее следует:

  1. true и input одинаково гарантируют LF в репозитории. Разница только в checkout: true конвертирует обратно в CRLF, input оставляет файл как есть.
  2. Только false кладет CRLF в commit. Когда кто-то спрашивает, откуда в истории взялись возвраты каретки, ответ в этой строке.
  3. Вторая строка неочевидна: при true файл, лежавший на диске как LF, после checkout возвращается уже CRLF.

Git предупреждает о перезаписи заранее, в одной из двух формулировок:

warning: in the working copy of 'f.txt', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'f.txt', CRLF will be replaced by LF the next time Git touches it

«Я ничего не менял, а Git пишет, что файл изменился целиком»

Снова вторая строка. При git config core.autocrlf true checkout переписывает LF-файлы в CRLF прямо в рабочем каталоге. Каждая строка теперь отличается от blob на один байт, поэтому git diff подсвечивает их все, а в pull request файл, которого никто не трогал, выглядит переписанным целиком. Это сделал фильтр checkout.

Зеркальная ситуация дает тот же шум: коллега на false кладет в репозиторий CRLF, вы сидите на input, и в вашем diff всплывают файлы, которые вы никогда не открывали.

5. .gitattributes — ответ на уровне команды

core.autocrlf — настройка на одной машине, невидимая для всех остальных. .gitattributes — файл внутри репозитория, поэтому он путешествует с каждым клоном. Когда они расходятся, побеждают атрибуты. Проверены все четыре формы:

.gitattributescore.autocrlfИсточникBlobПосле checkoutПобедитель
* text=autofalseCRLFLFLFатрибуты
* text eol=crlfinputLFLFCRLFатрибуты
* -texttrueCRLFCRLFCRLFатрибуты
* text eol=lftrueCRLFLFLFатрибуты

Третью строку стоит запомнить: -text полностью отключает конвертацию и всё равно обыгрывает core.autocrlf=true. Именно так защищают файлы, байты которых обязаны остаться нетронутыми.

Готовый .gitattributes

* text=auto

*.sh      text eol=lf
*.bash    text eol=lf
Makefile  text eol=lf

*.bat     text eol=crlf
*.cmd     text eol=crlf
*.ps1     text eol=crlf

*.png     -text
*.jpg     -text
*.pdf     -text
*.zip     -text

text=auto приводит к LF в репозитории всё, что Git считает текстом. Явные строки с eol=lf покрывают файлы, обязанные быть LF независимо от того, кто их написал: .sh с CRLF — это заранее заготовленный код выхода 126. Скриптам Windows eol=crlf ставят по зеркальной причине, а бинарным шаблонам — -text, чтобы не конвертировалось вообще ничего.

core.safecrlf и core.eol

Две настройки, которые попадаются рядом с core.autocrlf и делают совсем другое:

  • core.safecrlf — сторож, а не конвертер. Если преобразование необратимо (смешанный файл, при нормализации которого теряется информация), значение true отклоняет операцию, а warn пропускает ее с предупреждением. На сами байты в commit настройка не влияет: она лишь не дает молча выполнить операцию с потерями.
  • core.eol выбирает, какой перевод строки Git пишет в рабочем каталоге для файлов, помеченных как text, когда core.autocrlf равен false. Значения: lf, crlf и native. core.autocrlf перекрывает эту настройку, поэтому core.eol на машине с включенным autocrlf выглядит так, будто ничего не сделал.

Правка .gitattributes не исправляет то, что уже в репозитории

Атрибуты срабатывают в момент, когда Git пишет или читает файл, поэтому уже лежащее в репозитории содержимое не изменится, пока что-нибудь его не перезапишет. Такой проход делают руками:

$ git add --renormalize .
$ git commit -m "Normalize line endings"

Ждите один гигантский diff, в этом и смысл. Делайте это в отдельной ветке, вливайте одним commit и предупредите команду заранее.

6. Преобразование CRLF в LF и обратно

Четыре способа, и каждый проверен на Darwin — \r убирают все:

КомандаРезультат
tr -d '\r' < f > f.outработает
perl -pi -e 's/\r\n/\n/g' fработает
sed -i '' -e 's/\r$//' f (форма BSD)работает
sed -i -e 's/\r$//' f (форма GNU, запущенная на macOS)работает, но оставляет мусорный файл

Ловушка GNU sed -i на macOS

BSD-версия sed требует, чтобы за -i шел суффикс резервной копии. Скопируйте руководство для Linux дословно — и BSD sed проглотит -e как этот суффикс. Правка при этом всё равно произойдет, а вместе с ней вот что:

a5.txt
a5.txt-e

a5.txt-e — резервная копия: sed прочитал -e как запрошенный суффикс. Правильная форма для macOS передает явную пустую строку: sed -i '' -e 's/\r$//' f. Если в репозитории попадаются файлы с хвостом -e, значит, кто-то запускал однострочник для GNU на Mac.

В macOS нет dos2unix

command -v dos2unix на чистой macOS не возвращает ничего; бинарник приезжает из brew install dos2unix. Поэтому самый копируемый ответ в интернете не работает на той самой машине, за которой сидит изрядная часть разработчиков. tr -d '\r' не требует установки и делает ту же работу.

У unix2dos в обратную сторону та же проблема, а заменой служит sed -e 's/$/\r/'.

Как только файл снова стал чистым LF, его опять можно считать списком строк. Это важно для всего, что сравнивает строки целиком: сортировка строк текста и удаление повторяющихся строк читают value\r и value как две разные строки, поэтому один случайный возврат каретки тихо сводит на нет дедупликацию.

В редакторе

VS Code показывает перевод строки текущего файла как CRLF или LF в строке состояния справа внизу; щелчок по этой надписи переключает файл. Настройка files.eol задает значение по умолчанию для новых файлов, а прописанная на уровне рабочей области, она держит всю команду в одних настройках. В других редакторах те же два переключателя называются иначе. Упускают обычно вот что: индикатор конкретного файла и значение по умолчанию — разные вещи, и правка одного не трогает другое.

7. Переводы строк в коде

Один файл, line1\r\nline2\r\n, прочитанный семью разными способами:

Способ чтенияЧто получается\r
Node fs.readFileSync(f,"utf8")"line1\r\nline2\r\n"сохранен
Node, то же значение + .split("\n")["line1\r","line2\r",""]сохранен в каждой строке
Node readline с crlfDelay:Infinity["line1","line2"]убран
Python open(f), режим по умолчанию'line1\nline2\n'преобразован
Python open(f).readlines()['line1\n','line2\n']преобразован
Python open(f, newline="")'line1\r\nline2\r\n'сохранен
Python open(f,"rb")b'line1\r\nline2\r\n'сохранен

Эта таблица закрывает знакомый баг-репорт: «в Python работает, в Node ломается». Текстовый режим Python по умолчанию применяет универсальные переводы строк и превращает \r\n в \n еще до того, как значение дойдет до кода. Node отдает байты как есть. Оба поведения корректны, но на одном и том же файле результат получается разный.

rstrip("\n") оставляет \r на месте

original: 'line1\r\n'  | rstrip("\n"): 'line1\r'  | strip(): 'line1'

rstrip("\n") удаляет ровно те символы, которые перечислены, а \r в этом списке не было. Дальше значение не совпадает с тем, с чем должно, — вот и весь ответ на жалобу «я всё обрезал, а равенства всё равно нет». Помогает strip() или rstrip() без аргумента: уйдут все хвостовые пробельные символы, включая возврат каретки.

Безопасное разбиение на строки на обеих платформах

В JavaScript разбивайте по шаблону, который терпит оба варианта: text.split(/\r?\n/). В Python либо оставайтесь в текстовом режиме по умолчанию и дайте универсальным переводам строк сделать свое дело, либо вызывайте splitlines(), который справляется и с \r\n, и с \n, и с одиночным \r.

Запись — вторая половина задачи. Node пишет те байты, которые ему дали, поэтому собирайте строки через \n, а решение о том, что ляжет на диск, оставьте за .gitattributes. Python в open(path, "w") переводит \n в платформенный вариант, если не передать newline="" — тот самый флаг, который по этой же причине требуют модули записи CSV.

Возврат каретки в конце строки и метка порядка байтов в начале файла — ошибки одной категории с разных концов: невидимый байт, который переживает копирование и ломает проверку на равенство. Про второй конец — руководство по устранению ошибок с UTF-8 BOM.

8. Где ещё переводы строк доставляют неприятности

CSV и Excel. RFC 4180 задает CRLF как разделитель записей, и это одно из немногих мест, где CRLF — норма, а не дефект. Парсеры, написанные под вход только с LF, оставляют \r в последнем поле каждой строки, так что если значения после конвертации CSV в JSON выглядят правильно, но сравниваются неправильно, проверяйте сначала этот байт.

Docker. Файл .sh с CRLF, скопированный в образ, — это четвертый сценарий сбоя. COPY переносит байты как есть, shebang сохраняет свой \r, и контейнер выходит с кодом 126. Одна строка *.sh text eol=lf в .gitattributes предотвращает весь класс проблем.

Diff и pull request. Файл, помеченный как полностью изменившийся, без единой видимой правки — это механизм из раздела 4, доехавший до code review. Сравнение двух версий через инструмент сравнения текста подтверждает это за секунды, а руководство по сравнению текста разбирает, как читать результат.

Смешанные файлы. ASCII text, with CRLF, LF line terminators означает двух писавших с разными настройками. Нормализуйте файл целиком, а не те строки, которые случайно попали под правку, иначе следующий diff будет таким же шумным.

FAQ

Чем CRLF отличается от LF?

CRLF — два байта, \r\n (0x0D 0x0A). LF — один байт, \n (0x0A). Windows пишет CRLF, Linux и macOS пишут LF, и оба варианта отмечают конец строки. В редакторе текст выглядит одинаково; разница проявляется только в байтах, сравнениях строк и diff.

В скрипте переводы строк CRLF. Почему нет ошибки?

Потому что простые команды переживают лишний байт. echo hello с хвостовым \r выполняется и выходит с кодом 0. Ошибки появляются только там, где это важно парсеру: пустая строка, ключевое слово вроде fi или shebang. Опасный случай — присваивания: они срабатывают успешно и сохраняют \r в переменной.

Что означает $'\r': command not found и почему этого сообщения не видно?

Это значит, что оболочка попыталась выполнить строку, состоящую из одного возврата каретки. Bash 4 и 5 показывают этот символ через ANSI-C quoting, получается $'\r'. Bash 3.2 с macOS не выводит между двоеточиями ничего, а zsh вместо символа ставит ^M. Одна поломка — три разных сообщения.

Каким должен быть core.autocrlf: true, input или false?

На Linux и macOS используйте input, на Windows — true, а лучше обоих вариантов — .gitattributes. По измерениям true и input одинаково хранят LF в репозитории, и только false пропускает CRLF в commit. Кроме того, true при checkout переписывает LF-файлы в CRLF в вашей рабочей копии.

Кто победит, если .gitattributes и core.autocrlf противоречат друг другу?

Побеждает .gitattributes. Проверены все четыре формы — * text=auto, * text eol=crlf, * -text и * text eol=lf — и каждая перекрыла локальное значение core.autocrlf. В этом и аргумент в пользу атрибутов: они лежат в репозитории и действуют на всех, тогда как core.autocrlf — настройка на конкретной машине, которую не видно и невозможно проконтролировать.

Как проверить, что в файле — CRLF или LF?

Запустите file yourfile. Для CRLF печатается ASCII text, with CRLF line terminators, для чистого LF — ASCII text вообще без суффикса, а для смешанного файла — with CRLF, LF line terminators. Для уверенности на уровне байтов посмотрите od -c и поищите \r перед каждым \n.

Когда CRLF действительно нужен?

Когда его требует формат или протокол. RFC 4180 определяет CRLF как разделитель записей для CSV, а заголовки HTTP и SMTP делают то же самое на проводе. Файлам Windows batch и PowerShell с CRLF тоже безопаснее. Всё остальное — исходный код, shell-скрипты, конфигурация — LF.

Теги: line-endings crlf git cross-platform shell

Похожие статьи

Все статьи