СИ-ТЕСТ: 0x10 ЛУЧШИХ ВОПРОСОВ ДЛЯ ТЕХ, КТО ХОТЕЛ БЫ СТАТЬ ПРОГРАММИСТОМ ВСТРАИВАЕМЫХ СИСТЕМ


   С точки зрения интервьюируемого вы можете много узнать о том, кто написал этот тест.  Составлен ли тест ради того, чтобы показать насколько хорошо автор знает мельчайшие подробности ANSI-стандарта, вместо того чтобы проверить реальные знания? Проверяет ли он абсурдные знания, такие как коды определенных ASCII символов? Имеют ли вопросы тенденцию к выявлению ваших знаний системных вызовов и стратегий распределения памяти, указывая на то, что автор мог заниматься программированием компьютеров, вместо программирования встраиваемых систем? Если какие-либо из перечисленных вещей имеют место, то я бы серьёзно задумался – хочу ли я такую работу.

  С точки зрения работодателя, тест может много рассказать о кандидате. В первую очередь, тест может определить уровень знания кандидатом языка Си. Впрочем,  также интересно бывает посмотреть, как люди отвечают на вопросы, на которые они не знают ответа. Делают ли они осмысленный выбор, подкреплённый хорошим чувством интуиции, или же просто пытаются угадать? Занимают ли они оборонительную позицию, когда оказываются в затруднительном положении, или проявляют к проблеме живой интерес и видят в ней возможность узнать что-то новое? Я нахожу эту информацию такой же полезной, как и непосредственно саму работу во время теста.

  Держа эти идеи в уме, я попытался создать тест, который имеет прямое отношение к требованиям встраиваемых систем. Почти все вопросы навеяны случаями, с которыми я столкнулся за годы работы. Некоторые из них очень трудные, однако, все они весьма поучительные.

   Этот тест может быть предложен широкому кругу кандидатов. Большинство претендентов начального уровня едва ли справятся с ним, в то время как закалённые ветераны должны пройти его на отлично. Очки не присуждаются, я стремлюсь произвольно оценивать   вопросы. Однако, если вы собираетесь приспособить этот тест для ваших собственных потребностей, то можете спокойно присуждать баллы.

Препроцессор

1. Используя директиву #define, как бы вы описали именованную константу, которая возвращает число секунд в году? Високосными годами следует пренебречь.

#define SECONDS_PER_YEAR (60UL * 60UL * 24UL * 365UL)

 Здесь я смотрю на несколько моментов:

(а) Базовое знание синтаксиса #define (т.е. отсутствие точки с запятой в конце, необходимость заключать в круглые скобки и т.д.).

(b) Правильный выбор имени - с применением заглавных букв и подчёркиванием.

(c) Понимание того, что препроцессор будет вычислять для вас константное выражение.

(d) Понимание того, что выражение переполнит integer аргумент на 16-и битной машине -  а следовательно потребность в L, указывающей компилятору обращаться с выражением  как с Long.

(е) А если вы в добавок написали выражение с UL (обозначающее unsigned long), то вы отлично начали тест, потому что показываете, что знаете об опасности типов со знаками и без, и запомните - первое впечатление считается!

2. Напишите «стандартный» макрос MIN. То есть, макрос, который берет два аргумента и возвращает меньший из них.

#define MIN(A,B)       ((A) <=  (B) ? (A) : (B))

  Цель этого вопроса - проверить следующее:

(а) Умение использовать директиву #define для написания макросов. Это важно, потому что до того, как inline оператор стал частью Си стандарта, макросы были единственным способом генерирования встраиваемого кода. А такой код часто бывает необходим для достижения требуемого уровня производительности.

(b) Знание троичного условного оператора. Он используется в Си, потому что позволяет компилятору производить потенциально более оптимальный код, чем последовательность if-else. Производительность обычно является важной составляющей  во встраиваемых  системах, поэтому необходимо знать и уметь использовать эту конструкцию.

(с) Понимание необходимости заключать аргументы макросов в скобки.

(d) Я также использую этот вопрос, чтобы начать разговор о побочных эффектах макросов. Например, о том, что происходит, когда вы пишите такой код:

least = MIN(*p++, b);

3. Каково назначение директивы препроцессора #error?

   Этот вопрос очень полезен, если требуется отличить нормальных парней от ботаников. Обычно только ботаники читают приложения к руководствам по Си, чтобы узнать о таких вещах.

Бесконечные циклы

4. При программировании  встраиваемых систем часто используются  бесконечные циклы. Как реализовать бесконечный цикл в Си?

Существует несколько решений этой проблемы. Я предпочитаю такое:

while(1)
{
   …
}

Другая общепринятая конструкция выглядит так:

for( ; ; )
{
   …
}

  Лично я не люблю эту конструкцию, потому что такой синтаксис не объясняет, что происходит. Если кандидат предлагает именно это решение, я пытаюсь выяснить, чем он обосновывает свои действия. Если ответ сводится к тому, что - «Меня научили так делать, и я никогда об этом с тех пор не думал» - это говорит не в пользу кандидата. С другой стороны, если он заявляет, что Керниган и Ритчи предпочитали этот метод, и это единственный способ для бесконечного цикла пройти контроль на соответствие стандартам, то он получает дополнительные очки.

Третье решение заключается в использовании goto:

Loop:

goto Loop;

   Кандидаты, которые предлагают этот вариант, являются либо программистами на языке ассемблера, либо они оторванные от жизни программисты Бейсика/Фортрана, ищущие новое поле для деятельности.

Объявление данных

5. Используя переменную «a», запишите объявления для:

(а) Целого
(b) Указателя на целое
(с) Указателя на указатель на целое
(d) Массива из десяти целых
(е) Массива из десяти указателей на целые
(f) Указателя на массив из десяти целых
(g) Указателя на функцию, которая принимает целочисленный аргумент и возвращает целое
(h) Массива из десяти указателей на функции, которая принимает целочисленный аргумент и возвращает целое

Ответы:

(a)   int a;                 // Целое

(b)   int *a;               // Указатель на целое

(c)   int **a;             // Указатель на указатель на целое

(d)   int a[10];          // Массив из десяти целых

(e)   int *a[10];        // Массив из десяти указателей на целые

(f)    int (*a)[10];     // Указатель на массив из десяти целых

(g)   int (*a)(int);     // Указатель на функцию, которая берет целый аргумент и возвращает целое

(h)   int (*a[10])(int); // Массив из десяти указателей на функции, которые берут целый аргумент и возвращают целое

   Люди часто утверждают, что на некоторые из этих вопросов они обычно ищут ответы  в  руководствах – согласен. Во время написания этой статьи я сверялся с руководствами, чтобы убедиться, что синтаксис является верным. Однако во время своего интервью я обычно ожидаю, что мне зададут подобный вопрос. Поэтому я должен быть уверен, что у меня есть ответы, по крайней мере, на несколько часов интервью. Кандидаты, которые не знают ответов (или, по крайней мере, большую их часть), просто не готовы к интервью. Если они не могут быть готовы к интервью, к чему они могут быть готовы вообще?

Static

6. В каких случаях используется ключевое слово static?

   Полностью отвечают на этот вопрос довольно редко. Спецификатор static в языке Си используется в трёх случаях:

(а) Переменная, описанная внутри тела функции как статическая, сохраняет свое значение между вызовами функции.

(b) Переменная, описанная как статическая внутри модуля, но снаружи тела функции, доступна для всех функций в пределах этого модуля и не доступна функциям любых  других модулей. То есть, это локализованная глобальная переменная.

(с) Функции, описанные внутри модуля как статические, могут быть вызваны только другими функциями из этого модуля. То есть, область видимости функции локализована модулем, внутри которого она описана.

   Большинство кандидатов отвечают правильно на первую часть. Умеренное число кандидатов справляется со второй частью, ну и небольшое количество понимают ответ (с). Это серьёзный недостаток кандидата, если он не понимает важность и преимущества ограничения области видимости данных и кода.

Const

7. Что означает ключевое слово const?

   Как только интервьюируемый говорит: «Const - значит константа», я понимаю, что имею дело с непрофессионалом. Дэн Сакс в прошлом году дал исчерпывающее объяснение спецификатору const, так что каждый читатель ESP должен быть досконально ознакомлен с тем, что const может сделать для вас и чего он не может. Если вы не читали эту рубрику, достаточно будет сказать, что const означает «только для чтения». Хотя этот ответ не совсем справедливо отражает предмет разговора, я бы принял его в качестве правильного.

   Если кандидат даст правильный ответ, то я задам ему следующие дополнительные вопросы:

Что означают следующие объявления?

const int a;

int const a;

const int *a;

int * const a;

const int * const a;

   Первые два объявления означают одну и ту же вещь, а именно: «а» - это целочисленная константа (только для чтения). Третье означает, что «а» является указателем на целочисленную константу. Четвёртое описывает «а» как константный указатель на целое. И последнее объявление — константный указатель на целочисленную константу.

   Если кандидат правильно ответит на эти вопросы, я буду впечатлён.

   В данном случае он может поинтересоваться, почему я делаю такой упор на спецификатор const, так как очень легко написать правильно функционирующую программу, не используя его ни разу. Существует несколько причин:

(а) Использование спецификатора const сообщает полезную информацию тому, кто читает ваш код. Фактически, объявление параметра как const, говорит пользователю о его предполагаемом использовании. Если вы когда-нибудь тратили много времени, устраняя неразбериху, оставленную другими людьми, то вы быстро научитесь ценить эту дополнительную информацию. (Конечно, программисты, использующие const, редко оставляют после себя путаницу, которую приходится устранять другим…)

(b) Const сообщает оптимизатору некоторую дополнительную информацию, что потенциально позволяет генерировать более оптимальный код.

(с) Код, в котором используется спецификатор const, проявляет тенденцию к меньшему количеству ошибок.

Volatile

8. Что означает ключевое слово volatile? Приведите три различных примера его использования.

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

Примеры volatile переменных:

(а) Регистры в периферийных устройствах (например, регистры состояния)

(b) Глобальные переменные, используемые в обработчиках прерываний.

(с) Глобальные переменные, используемые совместно несколькими задачами в многопотоковом приложении.

  Если кандидат не знает ответ на этот вопрос, он не получит работу. Я считаю, что это наиболее существенный вопрос, который позволяет отличить «Си-программиста» от «программиста встраиваемых систем». Программисты встраиваемых систем сталкиваются с аппаратными средствами, прерываниями, ОСРВ, и тому подобным. Все эти вещи требуют использования volatile переменных. Непонимание идеи спецификатора volatile приведет к катастрофе.

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

(а) Может ли аргумент быть одновременно и const и volatile? Аргументируйте ваш ответ.

(b) Может ли указатель быть volatile? Аргументируйте ваш ответ.

(с) Что не так со следующей функцией?:

int square(volatile int *ptr)
{
   return *ptr * *ptr;
}

Ответы следующие:

(а) Да. Например, регистр состояния доступный только для чтения. Он volatile, потому что может меняться неожиданно. Он const, потому что программа не должна пытаться изменить его.

(b) Да. Хотя это не общепринятый случай. Например, когда обработчик прерываний изменяет указатель на буфер.

(с) Эта функция потенциально опасна. Назначение кода состоит в возвращении квадрата значения, указанного при помощи *ptr. Однако, поскольку *ptr указывает на volatile переменную, компилятор сгенерирует код, который выглядит примерно так:

int square(volatile int *ptr)
{
    int a,b;
    a = *ptr;
    b = *ptr;
    return a * b;
}

   Поскольку значение переменной на которую указывает ptr может неожиданно измениться, то существует возможность,  что «а» и «b» будут разными. Следовательно, этот код может возвратить число, которое не будет  квадратом! Правильный вариант кода в данном случае такой:

int square(volatile int *ptr)
{
   int a;
   a = *ptr;
   return a * a;
}

Операции с битами

9. При программировании встраиваемых систем приходится часто манипулировать битами в регистрах или переменных. Дана целая переменная «а», напишите два фрагмента кода. Первый должен установить 3-ий бит этой переменной. Второй должен очищать его. В обоих случаях, другие биты должны остаться без изменений.

Вот три наиболее распространённых ответа на этот вопрос:

(а) Никаких идей. Кандидат никоим образом не может работать со встраиваемыми системами.

(b) Используйте битовые поля. Битовые поля покинули этот мир, наряду с триграфами, как самая бестолковая часть языка Си. Битовые поля по своей природе не переносимы между компиляторами, и по существу гарантируют, что ваш код не допускает многократного использования. Недавно я имел несчастье взглянуть на драйвер, который написали в  Infineon для одного из их наиболее сложных коммуникационных чипов. Там  использовались битовые поля, и драйвер был полностью бесполезен, потому что мой компилятор выполнял битовые поля другим путём. Мораль – никогда не позволяйте программисту, не знакомому со встраиваемыми системами, подходить к реальному оборудованию.

   Я недавно смягчил свою позицию по битовым полям. По меньшей мере один производитель компиляторов (IAR) теперь предлагает ключ компилятора для определения расположения битовых полей. К тому же их компилятор генерирует оптимальный код с регистрами описанными как битовые поля, и по существу - теперь я использую битовые поля в IAR-приложениях.

(с) Используйте #define и битовые маски. Это хорошо переносимый метод и его стоит использовать. Оптимальное решение этой проблемы, на мой взгляд, было бы таким:

#define BIT3       (0?1 << 3)

static int a;

void set_bit3(void)
{
   a |= BIT3;
}

void clear_bit3(void)
{
   a &= ~BIT3;
}

   Некоторые люди, наряду с именованными константами, предпочитают задавать маску для значений set и clear. Это тоже приемлемо. Важные элементы, которые мне нужны – это использование именованных констант, наряду с конструкциями |= и &= ~

Доступ к ячейкам памяти

10. Довольно часто программистам встраиваемых систем требуется доступ к определенной ячейке памяти. В некотором проекте требуется установить целую переменную по абсолютному адресу  0?67a9 к значению 0xaa55. Напишите код, выполняющий эту задачу.

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

int *ptr;
ptr = (int *)0?67a9;
*ptr = 0xaa55;

Более запутывающий вариант выглядит так:

*(int * const)(0?67a9) = 0xaa55;

Прерывания

11. Прерывания являются важной частью встраиваемых систем. Поэтому многие производители компиляторов предлагают к стандартному языку Си расширение для поддержки прерываний. Обычно - это новое ключевое слово __interrupt. Следующий код использует __interrupt, чтобы описать программу обработки прерываний. Прокомментируйте его.

__interrupt double compute_area(double radius)
{
   double area = PI * radius * radius;
   printf(“\nArea = %f”, area);
   return area;
}

В этой функции так много неправильного, что почти невозможно понять с чего начинать.

(а) Обработчик прерываний не может возвращать значение. Если вы не понимаете этого, то не получите работу.

(b) Обработчик прерываний не может принимать параметры. Если вы упустили это, смотрите пункт (а) для понимания ваших перспектив получить работу.

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

(d) Функция printf() тоже часто испытывает трудности с реентерабельностью и работоспособностью. Если бы вы пропустили пункты (с) и (d), то я не был бы с вами слишком строг. А если вы справились с этим двумя пунктами, то ваши перспективы получить работу выглядят всё лучше и лучше.   

Примеры кода

12. Что делает следующий фрагмент кода и почему?

void foo(void)
{
   unsigned int a = 6;
   int b = -20;
   (a+b > 6) ? puts(“> 6?) : puts(“<= 6?);
}

   Этот вопрос проверяет, разбираетесь ли вы в правилах представления целых чисел в Си, – области, которую я нахожу очень плохо понимаемой многими разработчиками. Ответ состоит в том, что выведется “> 6”. Причина -  в выражениях, включающих типы со знаком и без  все операнды приводятся к типам без знака.   Таким образом, -20 становится очень большим положительным целым, и выражение оценивается больше, чем 6. Это очень важный момент во встраиваемых  системах, где часто используются типы данных без знака. Если вы ответили на этот вопрос неправильно, то вы сильно рискуете быть не принятым на работу.

13. Прокомментируйте следующий фрагмент кода:

unsigned int zero = 0;
unsigned int compzero = 0xFFFF;       /*1’s complement of zero */

   В машинах, где int не равен 16-и битам, это будет неправильно. Следует записать так:

unsigned int compzero = ~0;

   Этот вопрос даёт возможность по-настоящему узнать, понимает ли кандидат важность длины слова в компьютере.

   На этой стадии кандидаты либо полностью деморализованы, либо они на коне и предаются приятному времяпровождению. Если очевидно, что кандидат не очень хорош, то на этом тест заканчивается. Однако если кандидат отлично себя проявил, то я задаю дополнительные вопросы. Эти вопросы сложные и я ожидаю, что только лучшие кандидаты справятся с ними.

Динамическое распределение памяти

14. В чём заключаются проблемы с динамическим распределением памяти во встраиваемых системах?

   Здесь я ожидаю, что пользователь упомянет фрагментацию памяти, проблемы сбора мусора и т.д. Эта тема была хорошо описана в ESP, главным образом, Плогером. Его объяснения гораздо более проницательны, чем что-либо из того, что я могу предложить здесь, поэтому прочтите старые номера! Убаюкав кандидата ощущением ложной безопасности, я задаю такой интересный вопрос:

Что делает следующий фрагмент кода и почему?

char *ptr;
if ((ptr = (char *)malloc(0)) == NULL) {
   puts(“Got a null pointer”);
}
else {
   puts(“Got a valid pointer”);
}

   Это довольно забавная задачка. Я столкнулся с ней недавно, когда мой коллега ненароком передал значение 0 в malloc и получил назад действительный указатель! Покопавшись немного, я обнаружил, что результат функции malloc (0) определён реализацией, так что правильный ответ гласит «по обстоятельствам». Я использую этот вопрос, чтобы начать беседу о том, что, по мнению интервьюируемого, является верным для действий функции malloc. Получение правильного ответа здесь не важно, главное как вы подойдёте к проблеме и  логически обоснуете свое решение.

Typedef

15. Typedef часто используется в Си для объявления синонимов существующих типов данных. Также для подобных действий возможно использование препроцессора. Например, рассмотрите следующий фрагмент кода:

#define dPS  struct s *
typedef  struct s * tPS;

Цель в обоих случаях состоит в объявлении dPS и tPS как указателей на структуру «s». Какой метод предпочтительней и почему?

Это очень хитрый вопрос, и всякий, кто ответит на него правильно, получит мои  совершенно справедливые поздравления. Ответ заключается в том, что предпочтителен typedef. Рассмотрите объявления:

dPS p1,p2;
tPS p3,p4;

Из первого примера следует:

struct s * p1, p2;

что объявляет р1 как указатель на структуру и р2 как обычную структуру, что, вероятно, вовсе не то, что вы хотели. Второй пример правильно определяет р3 и р4 как указатели.

Запутывающий синтаксис

16. Си позволяет некоторые ужасные конструкции. Допустима ли эта конструкция, и если да, то что делает этот код?

int a = 5, b = 7, c;

c = a+++b;

   Предполагается, что этот вопрос будет весёлым завершением опроса, в то время как, верите, или нет, это вполне допустимый синтаксис. Вопрос в том, как компилятор его воспримет? Бедные авторы компиляторов обсуждали эту проблему и пришли к правилу «максимального перемалывания», которое гласит, что компилятор должен «откусить» такой большой (допустимый) кусок, какой только может. Следовательно, этот код будет восприниматься так:

c = a++ + b;

   Поэтому, после выполнения этого кода, a = 6, b = 7 & c = 12;

   Если вы знали ответ, или правильно угадали – тогда дело сделано. Если вы не знали ответа – не страшно. Я нахожу наибольшую пользу от этой задачи в том, что она очень хорошо стимулирует вопросы относительно стилей кодирования и преимуществ использования контроля стиля программирования на соответствие стандартам.

   Ну, вот и всё, друзья. Это была моя версия Си-теста. Я надеюсь, вы получили столько же удовольствия при его выполнении, сколько я - при написании. Если Вам нравится этот тест, то, пожалуйста, используйте его при найме сотрудников.

 

У вас недостаточно прав для комментирования.