ГАСПАР ЧИЛИНГАНОВ
Программирование на shell в экстремальных условиях
Эта статья описывает нетривиальные способы использования программной оболочки sh для создания скриптов. Например, реализацию на sh простого аналога grep.
Зачем это нужно? В случае, если вы крайне ограничены в дисковом пространстве или объеме памяти, которые вы можете использовать для прикладных программ. В моей ситуации при создании системы на базе PicoBSD свободного места на дискете было крайне мало, чтобы записать туда стандартные утилиты. Все скрипты рассчитаны и писались для использования в PicoBSD/FreeBSD и используют возможности стандартного интепретатора /bin/sh.
Реализация шаблонов (regular expression) в sh
Иногда бывает необходимо сравнить текстовые данные с шаблоном или выделить оттуда какую-то часть. Для этого обыкновенно используются sed, awk или perl – в зависимости от пристрастий программиста и сложности задачи. Однако когда вы ограничены объемом памяти, для простых задач крайне нецелесообразно использовать отдельные утилиты. Перед дальнейшим чтением обязательно ознакомьтесь с разделом Parameter Expansion в руководстве по sh(1).
Ниже приведены примеры, как эмулировать утилиту cut при помощи скриптов и функций sh.
Все описанные функции возвращают результат в глобальной переменной result. В случае удачного завершения код выхода у функций равен 0, и имеет ненулевое значение, если произошла ошибка. Если результат функции не определен или функция ничего не возвращает, то она присваивает переменной result пустую строку.
cut_atomic
Функция cut_atomic сканирует строку слева и удаляет все символы от первого вхождения разделителя до конца строки. Первый аргумент функции – собственно сам разделитель. Это может быть один символ, класс символов (character class, задается при помощи квадратных скобок [ ]) или строка. Все последующие аргументы воспринимаются как строка, которая должна быть обработана. Если передавать строку без использования одинарных("") или двойных кавычек(«»), то sh сам разобъет ее на подстроки, используя свой разделитель входных полей (задается в переменной IFS), что не всегда желательно. Шаблоны в sh всегда «жадные», т.е. пытаются соответствовать максимальному количеству символов, поэтому если вы передаете как разделитель символ *, результаты могут быть непредсказуемые. Знак «?» можно использовать в разделителе для обозначения любого символа.
cut_atomic () {
local DELIM STRING
# разделитель может быть любой строкой, не только один символ
DELIM="$1"
shift
# оставшиеся аргументы
STRING=$*
result=${STRING%%${DELIM}*}
return 0
}
В данном случае запись ${переменная%%шаблон} удаляет наибольший возможный суффикс из строки – то есть остаются только символы с начала строки до первого вхождения разделителя.
Аналог cut(1)
Функция cut работает аналогично вызову утилиты:
cut -d${разделитель} -f${начальное_поле} -${конечное_поле}
Входная строка разбивается на подстроки с использованием $разделитель, после чего возвращаются поля с номерами от ${начальное_поле} до ${конечное_поле}. Первый аргумент функции – разделитель полей, второй и третий параметры – номер начального и конечного поля. Все оставшиеся аргументы – обрабатываемая строка. Функция чуть удобнее в использовании, чем утилита cut, так как в качестве разделителя может использоваться не только один символ, но и строка или шаблон. Если вам нужно получить только одно поле, следует задать одинаковый начальный и конечный индекс.
cut () {
local DELIM POS1 POS2 STRING STR1 POSTFIX
DELIM="$1" # разделитель
POS1=$2 # начальный индекс
POS2=$3 # конечный индекс
shift 3
STRING=$* # оставшиеся параметры функции
# если конечный индекс меньше начального, возвращаем ошибку
if [ $POS2 -lt $POS1 ]; then
result=""
return 1 # код выхода > 0
fi
# удаляем первые ${POS1}-1 элементов из строки
I=1
while [ $I -lt $POS1 ]; do
STRING=${STRING#*${DELIM}}
I=$(($I+1))
done
STR1="$STRING" # запоминаем результат
# удаляем все элементы вплоть до последнего элемента, который нам нужен, от строки оставляем суффикс,
# состоящий из ненужных элементов
while [ $I -le $POS2 ]; do
STRING=${STRING#*${DELIM}}
I=$(($I+1))
done
# у нас уже есть ненужный суффикс с переменной STRING, удаляем его из запомненного результата
result=${STR1%${DELIM}${STRING}}
return 0
}
Выделение позиционных параметров
Можно использовать функцию set для того, чтобы заменить список аргументов для данного блока команд и получить доступ к позиционным параметрам $1, $2 и так далее. Единственный недостаток такого способа – это невозможность избежать раскрытия символов подстановки (wildcards).
Небольшой пример, как можно использовать этот прием для того, чтобы получить первые 3 байта из MAC-адреса (код производителя). Переменная IFS (input field separator) определяет символ или подстроку, которые будут использоваться для разбиения строки на поля.
extract_manufacturer () {
# определяем IFS как локальную переменную, чтобы ее изменение не влияло на другие функции
local STR IFS
# запоминаем все аргументы функции в STR
STR=$*
# устанавливаем в качестве разделителя символ ':'
IFS=':'
# присваиваем позиционным параметрам содержимое STR
set -- $STR
result="$1:$2:$3"
return 0
}
# пример использования функции
S=`ifconfig fxp0` # получить результат работы команды ifconfig
S=${S##*ether} # стереть вплоть до ключевого слова ether
extract_manufacturer "$S"
echo "manufacturer code: $result"
Проверка на соответствие шаблону
Иногда необходимо проверить соответствие строки некоторому шаблону. Одно из основных применений – проверка входных данных. Единственный способ в shell, который позволяет проверить, соответствует данная строка шаблону или нет, – это использование оператора case. Для удобства можно создать вокруг него обертку – «wrapper».
Первый аргумент для функции match_pattern – это шаблон, которому должна соответствовать строка, а все оставшиеся аргументы – это обрабатываемая строка. Функция match_pattern_strict требует, чтобы вся строка соответствовала заданному шаблону, а match_pattern мягче – она требует совпадения с шаблоном лишь части строки. Будьте внимательны – чаще всего вам придется заключать шаблон в одинарные или двойные кавычки, чтобы sh не раскрывал символы подстановки «*» и «?» в шаблоне перед тем, как передать его функции.
match_pattern_strict () {
local PATTERN STRING
# двойные кавычки обязательны, чтобы не происходило раскрытие символов подстановки
PATTERN="$1"
shift
STRING=$*
result=""
case "$STRING" in
$PATTERN) # полное соответствие шаблону
return 0
esac
return 1
}
match_pattern() {
local PATTERN STRING
PATTERN="$1"
shift
STRING=$*
result=""
case "$STRING" in
*${PATTERN}*) # проверяется соответствие шаблону
return 0 # части строки
esac
return 1
}
Например, для частичной проверки правильности ввода IPv4-адреса можно использовать следующий шаблон:
match_pattern_strict "[0-9]*.[0-9]*.[0-9]*.*[0-9]" 192.168.0.1
Правда, этот шаблон ошибочно сочтет строку «127.0.0. 0.0.1» правильной, поскольку лишние байты в таком адресе будут соответствовать любому из символов «*» в шаблоне. Для точной проверки следует использовать прием с командой set и проверять каждый байт по отдельности.
Организация массивов в sh
Один из существенных недостатков sh, который делает его неудобным для программирования, это отсутствие массивов – ассоциативных или индексированных. Особенно остро ощущается отсутствие двумерных массивов. В загрузочных скриптах PicoBSD я подглядел интересный способ эмулировать массивы в sh. Ниже представлен несколько модифицированный вариант.
Предположим, что мы хотим организовать двухмерный массив с именем foo. Первый индекс будет цифровой – 0,1,..,N, а второй индекс – любая строка без пробелов. То есть мы получим элементы массива foo[0][A],foo[0][bar], foo[0][extra],..., foo[10][A],foo[10][bar],foo[10][another] и так далее.
Предположим также, что элементы с индексом «А» никогда не могут иметь нулевое значение (пустую строку) и выберем их в качестве ключа в ассоциативном массиве. Для хранения каждого элемента массива мы создадим соответственно переменные в sh – foo_0_A, foo_0_bar,foo_0_ extra, ..., foo_10_A, foo_10_bar,foo_10_another и т. д.
В каждой строке массива должен быть элемент, играющий роль ключа, и любое количество элементов, в которых хранятся данные. Количество дополнительных элементов в каждой строке может быть произвольным. После этого можно создать несколько функций для удобной работы с таким представлением данных.
Функция arr_count возвращает количество элементов в массиве. Первый аргумент функции – название массива (фактически префикс для именования переменных), второй аргумент – название ключа в массиве.
# посчитать количество элементов в массиве arr_count ИМЯ_МАССИВА ИМЯ_КЛЮЧА
arr_count () {
local ARRNAME KEYNAME VAL I
ARRNAME=$1 # имя массива
KEYNAME=$2 # имя ключа в массиве
I=0
result=""
# основная магия происходит здесь – в команде eval формируется правильное название переменной,
# которая соответствует элементу массива
eval VAL=\${${ARRNAME}_${I}_${KEYNAME}}
if [ "x$VAL” = "x" ];then
# если первый же ключ пустой (т.е. foo[0][A]) мы предполагаем, что такой массив не существует
return 1
fi
while [ "x$VAL" != "x" ] ; do
I=$(($I+1))
eval VAL=\${${ARRNAME}_${I}_${KEYNAME}}
done
result=$I
return 0
}
Конструкция [ «x$VAL» = «x» ] обеспечивает корректное сравнение, если $VAL будет иметь пустое значение. Если написать [ $VAL = «» ], то при пустой переменной $VAR это будет раскрыто оболочкой в конструкцию [ = «» ], что приведет к ошибке.
Надо либо писать [ «$VAL» = «» ], либо просто приучить себя к [ «x$VAL» = «x» ], что переносимо на большее количество платформ/версий sh.
Для формирования имени переменной динамически приходится использовать команду оболочки eval. Сперва sh подставляет значение ARRNAME, I и KEYNAME и получает:
eval VAL=${foo_1_bar},
после чего интерпретирует получившуюся команду присвоения.
Символ «» обязательно должен присутствовать, иначе sh будет выдавать ошибки.
Функция arr_lookup_by_key позволяет обратиться к элементу ассоциативного массива, зная значение ключа. Она получает 4 аргумента – имя массива, имя поля, в котором хранится ключ, имя поля, значение которого нужно получить и значение ключа. Код выхода не равен нулю, если не был найден ключ с таким значением.
# array_name ИМЯ_МАССИВА ИМЯ_КЛЮЧА ИМЯ_ПОЛЯ ЗНАЧЕНИЕ_КЛЮЧА
arr_lookup_by_key () {
local i array kfield vfield kvalue key value
array=$1
kfield=$2
vfield=$3
kvalue=$4
i=0
result=""
key="x" # принудительно заставим цикл выполнится хотя бы 1 раз
while [ "$key" != "" ]; do
# конструируем имя переменной
eval key=\${${array}_${i}_${kfield}}
# если значение ключа совпало, возвращаем значение
if [ "$key" = "$kvalue" ]; then
# конструируем имя возвращаемого поля
eval result=\${${array}_${i}_${vfield}}
return 0
fi
i=$(($i+1))
done
# цикл закончился – следовательно такого значения ключа нет
return 1
}
Функция arr_lookup_by_index позволяет получить значение поля, зная численный индекс.
На вход передается 4 элемента – имя массива, имя поля, в котором хранится ключ, имя поля, значение которого нужно получить, и индекс. Если элемента с таким индексом нет – т.е. поле ключа пустое, функция завершается с кодом выхода 1. В противном случае значение поля возвращается в переменной result.
# arr_lookup_by_index ИМЯ_МАССИВА ИМЯ_КЛЮЧА ИМЯ_ПОЛЯ ИНДЕКС
arr_lookup_by_index () {
local i array index vfield value kfield
array=$1
kfield=$2
vfield=$3
kvalue=$4
eval key=\${${array}_${index}_${kfield}}
if [ "$key" = "" ]; then
# возвратить код ошибки, т.к. обнаружен пустой ключ
return 1
fi
eval result=\${${array}_${index}_${vfield}}
return 0
}
Функция arr_set_by_index используется для добавления данных в массив. При этом одновременно устанавливается и значение ключа, и значение поля, которое ему соответствует.
# arr_set_by_index ИМЯ_МАССИВА ИМЯ_КЛЮЧА ИМЯ_ПОЛЯ НОМЕР_ИНДЕКСА ЗНАЧЕНИЕ_КЛЮЧА ЗНАЧЕНИЕ_ПОЛЯ
arr_set_by_index() {
local i array kfield vfield index kvalue value
array=$1
kfield=$2
vfield=$3
index=$4
kvalue=$5
shift 5
vvalue=$*
i=0
result=""
eval ${array}_${index}_${kfield}=${kvalue}
eval ${array}_${index}_${vfield}=${vvalue}
return 0
}
Функция arr_set_by_key используется для добавления или изменения данных в массиве по существующему ключу.
# arr_set_by_key ИМЯ_МАССИВА ИМЯ_КЛЮЧА ИМЯ_ПОЛЯ ЗНАЧЕНИЕ_КЛЮЧА ЗНАЧЕНИЕ_ПОЛЯ
arr_set_by_key() {
local i array kfield vfield kvalue vvalue
array=$1
kfield=$2
vfield=$3
kvalue=$4
shift 4
vvalue=$*
result=""
i=0
eval key=\${${array}_${i}_${kfield}}
while [ "x$key" != "x" ]; do
eval key=\${${array}_${i}_${kfield}}
if [ "x$key" = "x$kvalue" ]; then
break
fi
i=$(($i+1))
done
arr_set_by_index $array $kfield $vfield $i $kvalue $vvalue
return 0
}
Таким образом, для добавления нового элемента в массив нужно сперва вызвать аrr_count, получить количество существующих строк и потом добавить в конец массива новую строку. После этого можно установить уже все оставшиеся поля массива, обращаясь не по индексу, а по ключу.
Построчная обработка файлов
Как правило, если нужно обработать файл построчно, разбивая каждую строку на поля, используется awk или perl. Но не всегда под руками есть эти программы, поэтому можно попытаться сэмулировать их, имея только sh.
Воспользуемся способностью команды read разбивать входные данные по разделителю и приписывать значения подстрок переменным. В случае окончания потока read выдает ненулевой код выхода, поэтому ее можно использовать так же, как условие окончания цикла.
Пример скрипта для построчного чтения файла /etc/passwd.
IFS=:
i=0
while read name pass uid gid gcos homedir shell junk; do
echo "$i|$name|$uid|$gid|$gcos|$homedir|$shell|$junk|"
i=$(($i+1))
done < /etc/passwd
echo "total lines: $i"
При перенаправлении надо проявить аккуратность. Если перенаправить поток на вход команды read, a не на вход блока while, скажем вот так:
while read name pass uid gid gcos homedir shell junk < /etc/passwd; do
...
done
то цикл будет выполняться бесконечное число раз, т.к. на каждой итерации команда read будет читать первую строчку файла.
Другая задача, которая часто встречается, – обработка результатов выполнения другой команды. Если использовать конвейерную обработку(pipelines) в sh, то можно попытаться написать следующий кусок кода:
cat /etc/passwd | while read name pass uid gid gcos homedir shell junk; do
echo "$i|$name|$uid|$gid|$gcos|$homedir|$shell|$junk|"
i=$(($i+1))
done
echo "total lines: $i"
Мы будем получать правильно разбитые на поля записи, однако последняя команда выдаст количество строк равным 0. На первый взгляд это странно, но если вспомнить, что все команды внутри блока while; do ...; done выполняются в отдельном дочернем процессе sh, чтобы возможно было бы перенаправить туда результат выполнения конвеера, то тогда все становится на свои места. Передать переменные из дочернего процесса в основной процесс sh невозможно. Поэтому придется переписать этот код так, чтобы тело цикла while выполнялось в контексте основного процесса. Для этого можно использовать here-doc-текст и подставлять туда результат выполнения команды при помощи обратных кавычек (backtricks, ``).
IFS=:
i=0
while read name pass uid gid gcos homedir shell junk; do
echo "$i|$name|$uid|$gid|$gcos|$homedir|$shell|$junk|"
i=$(($i+1))
done <
`cat /etc/passwd | head`
EOF
Таким образом мы переместили выполнение предыдущих команд конвейера в отдельный(ые) процесс(ы), а их результат будет передаваться на стандартный ввод (stdin) блока while. После этого команда while правильно посчитает количество строк в файле. Также можно объединить в here-doc результат выполнения нескольких команд или даже поместить туда какие-то статические данные.
Таким образом, используя приведеные выше приемы, можно заменить часто используемые утилиты на их аналоги, написанные на sh. Особенно актуально эта задача стоит при создании образа системы, загружаемой с дискеты, или с твердотельных носителей (например, Compact Flash). Описанные функции могут облегчить создание скриптов для конфигурации и интерактивной настройки таких систем. С другой стороны, используя встроенные возможности sh, можно ускорить выполнение скриптов, поскольку запуск внешних утилит может быть существенно медленее, чем вызов определенной пользователем функции sh.