[Перевод] Macroni: рецепт поступательного улучшения языка программирования

vmyhgyllafxp9v_siuqmgh8stie.png

Хотя, Clang и используется в качестве инструмента для рефакторинга и статического анализа, у него есть серьёзный недостаток: в абстрактном синтаксическом дереве не предоставляется информации о происхождении конкретных расширений-макросов на CPP, за счёт которых может надстраиваться конкретный узел AST. Кроме того, Clang не понижает расширения-макросы на уровень LLVM, то есть, до кода в формате промежуточного представления (IR). Из-за этого оказывается запредельно сложно конструировать такие схемы статического анализа, при которых учитывались бы макросы. Сейчас эта тема активно исследуется. Но ситуация налаживается, поскольку прошлым летом был создан инструмент Macroni, упрощающий статический анализ именно такого рода.

В Macroni разработчики могут определять синтаксис новых языковых конструкций на C с применением макросов, а также предоставлять семантику для этих конструкций при помощи MLIR (многоуровневого промежуточного представления). В Macroni используется инструмент VAST, понижающий код C до MLIR. В свою очередь, инструмент PASTA позволяет выяснить, откуда те или иные макросы попали в AST, и на основании этой информации макросы также удаётся понизить до MLIR. После этого разработчики могут определять собственные MLIR-конвертеры для преобразования вывода Macroni в предметно-ориентированные диалекты MLIR, чтобы анализировать предмет с учётом многочисленных нюансов. В этой статье будет на нескольких примерах показано, как Macroni позволяет дополнять C более безопасными языковыми конструкциями и организовать анализ безопасности C.

Усиленные определения типов


Определения типов (typedef) в C помогают давать низкоуровневым типам более осмысленные наименования. Однако, компиляторы C не работают с этими именами при проверке типов, она распространяется только на фактические названия низкоуровневых типов. Самое простое проявление этого неудобства — баг с путаницей типов, когда семантические типы представляют разные форматы или меры, как в следующем примере:

typedef double fahrenheit;
typedef double celsius;
fahrenheit F;
celsius C;
F = C; // Компилятор не выдаст ни ошибки, ни предупреждения


Пример 1: При проверке типов в C учитываются только typedef-ы базовых типов.

Вышеприведённый код успешно проходит проверку типов, но между типами fahrenheit и celsius есть семантическая разница, которую не следует игнорировать, так как значения температуры в шкалах Цельсия и Фаренгейта отсчитываются по-разному. Если работать только с обычными typedef языка C, вы никак не сможете принудительно заложить это различие исключительно при помощи сильной типизации.

Но, работая с Macroni, можно применить макросы, которые будут определять синтаксис сильных typedef-ов и MLIR, тем самым реализовав для них специализированную проверку типов. В следующем примере показано, как можно при помощи макросов определить сильные typedef-ы, позволяющие различать температуры по Фаренгейту и по Цельсию:

#define STRONG_TYPEDEF(name) name
typedef double STRONG_TYPEDEF(fahrenheit);
typedef double STRONG_TYPEDEF(celsius);


Пример 2: как при помощи макросов определять синтаксис для сильного определения типов в C.

Если обернуть имя typedef в макрос STRONG_TYPEDEF(), то Macroni сможет идентифицировать те typedef, имена которых были получены расширением вызовов STRONG_TYPEDEF() и преобразовать их в типы из специализированного диалекта MLIR (напр.,  temp), вот так:

%0 = hl.var "F" : !hl.lvalue
%1 = hl.var "C" : !hl.lvalue
%2 = hl.ref %1 : !hl.lvalue
%3 = hl.ref %0 : !hl.lvalue
%4 = hl.implicit_cast %3 LValueToRValue : !hl.lvalue -> !temp.fahrenheit
%5 = hl.assign %4 to %2 : !temp.fahrenheit, !hl.lvalue -> !temp.celsius


Пример 3: При помощи Macroni можно понизить определения типов до типов MLIR и принудительно задать строгую типизацию.

Интегрировав в систему типов такие typedef-ы, полученные при помощи макросов, мы теперь можем сами определить для них собственные правила проверки типов. Например, можно было бы задать строгую проверку типов для операций, совершаемых над значениями температуры. Тогда вышеприведённая программа не пройдёт проверку типов. Также можно было бы добавить собственную логику приведения типов для значений температуры. В таком случае, при пересчёте значения температуры из одной шкалы в другую неявно вставлялись бы инструкции для такого преобразования.

Смысл применения макросов для добавления сильного синтаксиса typedef заключается в том, что макросам присуща как обратная совместимость, так и переносимость. В то время, как собственные типы можно идентифицировать и при помощи одного лишь Clang, аннотируя наши typedef-ы при помощи синтаксиса атрибутов GNU или Clang, невозможно гарантировать, что метод annotate() будет доступен при работе с любыми нужными нам платформами и компиляторами. При этом, можно уверенно рассчитывать, что там найдётся препроцессор C.

Возможно, вы уже задумались: ведь в C уже есть своя разновидность сильного typedef, и это struct. Поэтому можно было бы организовать более строгую проверку типов, преобразуя наши типы typedef в структуры (struct) (напр.,  struct fahrenheit { double value; }). Правда, из-за этого изменились бы и API, и ABI типа, что испортит имеющийся клиентский код, а также нарушит обратную совместимость. Если мы возьмёмся превращать typedef-ы в struct-ы, то компилятор может выдать совершенно иной ассемблерный код. Например, рассмотрим следующее определение функции:

fahrenheit convert(celsius temp) { return (temp * 9.0 / 5.0) + 32.0; }


Пример 4: Определение функции, преобразующей Цельсий в Фаренгейт.

Если определять сильные typedef-ы с применением typedef-ов, полученных с использованием макросов, то Clang выдаст следующее промежуточное представление LLVM для вызова convert(25). Промежуточное представление LLVM для функции convert совпадает с аналогичной конструкцией из C, принимает всего один аргумент типа double и возвращает значение типа double.

tail call double @convert(double noundef 2.500000e+01)


Пример 5: Промежуточное представление LLVM для функции convert (25), где сильное определение типов реализовано при участии макросов.

Сравните этот код с тем промежуточным представлением, которое выдал бы Clang, если бы сильные typedef-ы определялись у нас с использованием структур. Теперь функция при вызове может принимать уже не один аргумент, а четыре. Первый аргумент ptr указывает место, где convert будет сохранять возвращаемое значение. Только представьте, что произошло бы, если бы клиент вызывал эту новую версию convert в соответствии с теми соглашениями о вызове, которые действовали для оригинала.

call void @convert(ptr nonnull sret(%struct.fahrenheit) align 8 %1,
                   i32 undef, i32 inreg 1077477376, i32 inreg 0)


Пример 6: Промежуточное представление LLVM для convert (25), где для сильного определения типов используются структуры.

В базах кода на С повсеместно распространены слабые typedef-ы, которые должны были бы быть сильными. Это касается такой критической инфраструктуры, например, libc и ядра Linux. Если вы хотите добавить сильную проверку стандартных типов, например, time_t, то принципиально важно сохранить совместимость на уровне API и ABI. Если вы обернули time_t в struct (напр.,  struct strict_time_t { time_t t; }) для обеспечения сильной проверки типов, то потребуется изменить не только все API, обращающиеся к значениям time_t-typed, но и ABI, действующие в этих точках. Те клиенты, которые уже использовали голые значения time_t, должны будут скрупулёзно поменять код во всех тех местах, где код использует time_t, чтобы там задействовалась ваша структура, активирующая усиленную проверку типов. С другой стороны, если вы использовали typedef с макросами для алиасинга исходного time_t (напр.,  typedef time_t STRONG_TYPEDEF(time_t)), то API и ABI time_t останутся согласованными. В этом случае клиентский код, корректно использующий time_t, может остаться без изменений.

Улучшаем Sparse из ядра Linux


В 2003 году Линус Торвальдс разработал собственный препроцессор, парсер языка C и компилятор под названием Sparse. Sparse выполняет проверку типов, при которой учитывается специфика ядра Linux. При работе Sparse полагается на макросы, в частности,  __user, которые рассеяны по всему коду ядра. При нормальных сборочных конфигурациях они ничего не делают, но, когда определён макрос __CHECKER__, их функционал расширяется до использования __attribute__((address_space(...))).

Необходимо вот так ограничивать определения макросов при помощи __CHECKER__, так как большинство компиляторов не позволяют вмешаться в работу макроса или реализовать специализированную проверку типов… по крайней мере, так было до недавнего времени. Macroni позволяет вмешаться в работу макроса, проверять и анализировать безопасность по тому же принципу, который применяется в Sparse. Но, в то время как Sparse ограничен C (и то благодаря реализации собственного парсера и препроцессора C), Macroni может работать с любым кодом, который поддаётся синтаксическому разбору средствами Clang (напр., C, C++ и Objective C).

Первый макрос Sparse, к которому мы подключимся — __user. В настоящее время ядро задаёт __user для атрибута, распознаваемого Sparse:

# define __user     __attribute__((noderef, address_space(__user)))


Пример 7: Макрос ядра Linux __user

Sparse заглядывает в этот атрибут в поисках макросов из пользовательского пространства — как в следующем примере. noderef сообщает Sparse, что эти указатели нельзя разыменовывать (напр.,  *uaddr = 1), поскольку информации об их происхождении нельзя доверять.

u32 __user *uaddr;


Пример 8: При помощи макроса __user мы аннотируем переменную как поступившую из пользовательского пространства.

Macroni может вмешаться в работу макроса и расширенного атрибута, чтобы понизить объявление до MLIR, вот так:

%0 = hl.var "uaddr" : !hl.lvalue>>>>


Пример 9: Код ядра после понижения до MLIR при помощи Macroni

Код, пониженный до MLIR, встраивает аннотацию в систему типов, обёртывая объявления, поступающие из пользовательского пространства, в тип sparse.user. Теперь можем добавить нашу собственную логику проверки типов для переменных из пользовательского пространства, подобно тому, как мы ранее создавали сильные typedef-ы. Можно даже подключиться к Sparse-специфичному макросу __force, чтобы при случае отключать сильную проверку типов. В настоящее время так уже иногда делают:

raw_copy_to_user(void __user *to, const void *from, unsigned long len)
{
   return __copy_user((__force void *)to, from, len);
}


Пример 10: Используем макрос __force, чтобы скопировать указатель в пользовательское пространство

Также при помощи Macroni удобно идентифицировать в ядре критические RCU-секции чтения и убедиться, что определённые операции RCU («чтение-копирование-обновление») происходят только в этих секциях. Рассмотрим, например,  следующий вызов к rcu_dereference():

rcu_read_lock();
rcu_dereference(sbi->s_group_desc)[i] = bh;
rcu_read_unlock();


Пример 11: Вызов rcu_derefernce () на стороне чтения в критической RCU-секции в ядре Linux

Вышеприведённый код вызывает rcu_derefernce() в критической секции — то есть, в такой области кода, которая начинается вызовом rcu_read_lock() и заканчивается вызовом rcu_read_unlock(). Следует вызывать rcu_dereference() только в критических секциях, расположенных на стороне считывания, но принудительно данное ограничение никак не наложишь.

Работая с Macroni, можно использовать вызовы rcu_read_lock() и rcu_read_unlock() для выявления критических секций, образующих неявные лексические области кода. При этом можно убедиться, что вызовы к rcu_dereference() происходят только в этих секциях:

kernel.rcu.critical_section {
 %1 = macroni.parameter "p" : ...
 %2 = kernel.rcu_dereference rcu_dereference(%1) : ...
}


Пример 12: результат понижения RCU-критической секции до MLIR, для краткости типы опущены

Вышеприведённый код превращает в MLIR-операции как RCU-критические секции, так и вызовы к rcu_dereference(). Поэтому не составляет труда убедиться, что rcu_dereference() фигурирует только в тех областях, где нужно.

К сожалению, RCU-критические секции не всегда ровно накладываются на конкретные области кода, и rcu_dereference() также не всегда вызывается в таких областях. Рассмотрим следующий пример:

__bpf_kfunc void bpf_rcu_read_lock(void)
{
       rcu_read_lock();
}


Пример 13:  Код ядра, содержащий нелексическую RCU-критическую секциюю

static inline struct in_device *__in_dev_get_rcu (const struct net_device *dev)
{
return rcu_dereference (dev→ip_ptr);
}
Пример 14:  Код ядра, вызывающий rcu_dereference () вне RCU-критической секциию

При помощи макроса __force можно допустить вызовы такого рода к rcu_dereference(), точно как ранее делалось во избежание проверки типов для указателей пользовательского пространства.

Rust-подобные небезопасные области


Ясно, что Macroni позволяет усилить проверку типов и даже активировать правила проверки типов, специфичные для конкретных приложений. Но, если мы помечаем типы как сильные, то должны придерживаться заявленного уровня строгости при проверке. В большой базе кода такая стратегия может потребовать внести масштабный набор изменений. Чтобы приспособление к более строгой системе типов прошло в более контролируемом виде, можно спроектировать для C примерно такой механизм «небезопасности», который действует в Rust: в небезопасной области строгая проверка типов не применяется.

#define unsafe if (0); else


fahrenheit convert(celsius C) {
 fahrenheit F;
 unsafe {
         F = (C * 9.0 / 5.0) + 32.0;
 }
 return F;
}


Пример 15: код на C, в котором показан синтаксис с использованием макросов для небезопасных областей

В этом фрагменте показано, каков синтаксис в нашем API безопасности: вызываем макрос unsafe перед входом в потенциально небезопасные области кода. Весь код, не перечисленный как относящийся к небезопасным областям, будет подвергаться строгой проверке типов. В то же время, макросом unsafe можно пользоваться для обозначения областей сравнительно низкоуровневого кода, который мы целенаправленно собираемся оставить без изменения. Это прогресс!

Но макрос unsafe обеспечивает для нашего API безопасности только синтаксис, но не логику. Чтобы задраить эту дырявую абстракцию, потребуется преобразовать отмеченный макросом оператор if в операцию на теоретически имеющемся у нас диалекте safety:

...
"safety.unsafe"() ({
   ...
}) : () -> ()
...


Пример 16: При помощи Macroni можно понизить наш API безопасности до диалекта MLIR и реализовать логику проверки безопасности.

Теперь можно отключить строгую проверку типов при операциях, вложенных в MLIR-представлении макроса unsafe.

Более безопасная обработка сигналов


Возможно, вы уже заметили одну закономерность, которая прослеживается при создании безопасных языковых конструкций: при помощи макросов определяется синтаксис, позволяющий помечать определённые типы, значения или области кода как подчиняющиеся некоторому набору инвариантов. После этого на уровне MLIR определяется логика, позволяющая проконтролировать соблюдение этих инвариантов.

При помощи Macroni можно обеспечить, что обработчики сигналов выполняют лишь код, безопасный на уровне сигналов. Рассмотрим, например,  следующий обработчик сигналов, определённый в ядре Linux:

static void sig_handler(int signo) {
       do_detach(if_idx, if_name);
       perf_buffer__free(pb);
       exit(0);
}


Пример 17: Обработчик сигналов, определённый в ядре Linux

sig_handler() прямо в собственном определении вызывает три другие функции, и все они должны быть безопасны в контексте обработки сигналов. Но в вышеприведённом коде нет никакой проверки, которая позволяла бы убедиться, что мы вызываем сигнально-безопасные функции только внутри определения sig_handler(). В компиляторах С просто не предусмотрен способ выразить семантические проверки, применимые к лексическим областям.

При помощи Macroni можно добавлять макросы, которые помечают одни функции в качестве обработчиков сигналов, а другие в качестве сигнально-безопасных. После этого на уровне MLIR можно реализовать логику, позволяющую убедиться, что обработчики сигналов вызывают только сигнально-безопасные функции, вот так:

#define SIG_HANDLER(name) name
#define SIG_SAFE(name) name


int SIG_SAFE(do_detach)(int, const char*);
void SIG_SAFE(perf_buffer__free)(struct perf_buffer*);
void SIG_SAFE(exit)(int);


static void SIG_HANDLER(sig_handler)(int signo) { ... }


Пример 18: Синтаксис на основе токенов, при помощи которого помечаются обработчики сигналов и сигнально-безопасные функции

В вышеприведённом коде функция sig_handler() помечена как обработчик сигнала, а три вызываемые ею функции — как сигнально-безопасные. Каждый вызов макроса распространяется на единственный токен, конкретно, на имя той функции, которую мы хотим пометить. При таком подходе Macroni подключается к расширенному токену и по имени функции определяет, обрабатывает ли она сигналы или сигнально-безопасна.

Возможен альтернативный подход: определить эти макросы для магических аннотаций, а затем подключиться к ним через Macroni:

#define SIG_HANDLER __attribute__((annotate("macroni.signal_handler")))
#define SIG_SAFE __attribute__((annotate("macroni.signal_safe")))


int SIG_SAFE do_detach(int, const char*);
void SIG_SAFE perf_buffer__free(struct perf_buffer*);
void SIG_SAFE exit(int);


static void SIG_HANDLER sig_handler(int signo) { ... }


Пример 19: Альтернативный синтаксис атрибутов, позволяющий пометить обработчики сигналов и сигнально-безопасные функции

При таком подходе вызов макроса больше напоминает спецификатор типа, и кому-то такой вариант покажется симпатичнее. Вся разница между синтаксисом на основе токенов и синтаксисом на основе атрибутов в том, что для использования второго варианта требуется, чтобы компилятор поддерживал атрибут annotate(). Если это не проблема или если можно применять для ограничения __CHECKER__-подобные конструкции, то любой из двух вариантов синтаксиса будет работать нормально. Серверная MLIR-логика для проверки безопасности сигналов останется одинаковой независимо от избранного нами варианта синтаксиса.

Заключение


Инструмент Macroni понижает код C и макросы до многоуровневого промежуточного представления (MLIR), и поэтому можно не опираться при анализе на непримечательное абстрактное синтаксическое дерево Clang, а вместо этого выстраивать анализ на базе предметно-ориентированного промежуточного представления. В этом промежуточном представлении открыт полный доступ к типам, потоку управления и потоку данных в пределах высокоуровневого MLIR-диалекта VAST. Macroni понизит предметно-релевантные макросы до MLIR и аннулирует за вас все остальные макросы. Так перед вами открывается вся сила статического анализа, учитывающего макросы. Можно задавать собственные варианты анализа, варианты преобразований и оптимизаций. На каждом этапе анализа применяются макросы. Как было показано в этой статье, даже можно комбинировать макросы и MLIR, определяя таким образом новый синтаксис и семантику для C. Инструмент Macroni бесплатный и распространяется свободно, вот его репозиторий на GitHub.

Благодарности


Благодарю компанию Trail of Bits за предоставленную возможность создать инструмент Macroni. Благодарю моего менеджера и наставника Питера Гудмена за то, что он подкинул мне идею понижать макросы до MLIR и за рекомендации о том, как потенциально может использоваться Macroni. Также благодарю Лукаса Кореника за ревью кода Macroni и за советы, как его можно улучшить.

Читайте также:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале


r8msjcfet9mgza3ybpor_sdgrt0.jpeg

© Habrahabr.ru