65. Ветвления и циклы
Ветвления выбирают, что делать; циклы повторяют. Этого достаточно, чтобы
записать любой алгоритм, — и именно здесь сидит большая часть ошибок,
которые компилятор пропускает: перепутанный приоритет, лишняя точка с
запятой, забытый break, условие, которое никогда не станет ложным.
Хорошая новость: на половину этих ошибок компилятор предупреждает сам. Раздел заодно показывает, на какие именно — и какие ловятся только отдельным ключом.
Всё сказанное о предупреждениях снято с armclang (Keil). GCC без ключей
не предупреждает ни об одной из этих ошибок: присваивание в условии,
x & 1 == 0 и висячий else он ловит с -Wall, пустое тело if и провал
в switch — с -Wextra. CubeIDE ставит в проект -Wall сама (раздел 4б.3),
-Wextra добавляют руками: проект начинают с -Wall -Wextra (раздел 77.4).
А на микроконтроллере у циклов есть своя роль: вся программа — это один бесконечный цикл, и выглядит он в памяти очень узнаваемо.
Как это называется в документации
Оригинал Перевод relational, equality operators операции сравнения logical operators логические операции short-circuit evaluation короткая схема вычисления operator precedence, associativity приоритет, порядок группировки conditional operator тернарная (условная) операция dangling else висячий elsefall-through провал в следующую ветку caseloop body, iteration тело цикла, итерация pre-test, post-test loop цикл с предусловием, с постусловием infinite loop бесконечный цикл busy-wait активное ожидание timeout ограничение времени ожидания
65.1 Сравнения дают 0 или 1
==, !=, <, >, <=, >= возвращают число типа int: 1, если
отношение истинно, и 0, если ложно. Отдельного логического значения в
этом месте нет — поэтому результат сравнения можно сложить, умножить или
записать в переменную:
int a = (5 == 3); /* 0 */
int b = (100 >= 1); /* 1 */
int n_positive = (x > 0) + (y > 0) + (z > 0);
И в обратную сторону: условие if и циклов считает ложью только ноль,
всё остальное — истиной. if (count) означает if (count != 0).
== и = различаются одним символом и совсем разным смыслом. Про
if (a = 5) — ниже, в 65.4.
65.2 Логические операции и короткая схема
!, &&, || тоже дают 0 или 1. Их главное свойство — короткая
схема: правая часть вычисляется, только если от неё зависит результат.
- в
a && bпри ложномaрезультат уже известен — ложь,bне вычисляется; - в
a || bпри истинномaрезультат — истина,bне вычисляется.
На этом построена защита:
int n = 0, b = 5;
int r = (n != 0 && b / n); /* 0 — и никакого деления на ноль */
Деление не произошло вовсе. Порядок здесь гарантирован: после левой
части && и || стоит точка следования (раздел 64.10), так что
«сначала проверить, потом использовать» — надёжный приём.
Побитовые & и | короткой схемы не имеют: обе части вычисляются
всегда. Заменить && на & «для скорости» — значит потерять защиту.
⚠️ Деление целого на ноль на Cortex-M3, M4, M7 и M33 по умолчанию не останавливает программу, а тихо даёт ноль. На компьютере такая программа упала бы сразу. На камне ошибка проходит незамеченной, пока не включена ловушка — бит 4
DIV_0_TRPв регистреSCB->CCR(раздел 7):SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk;. Сработав, она вызывает отказ UsageFault, а если его обработчик не разрешён вSCB->SHCSR, — HardFault (раздел 35.2). Включать её в отладочной сборке — хорошая привычка. У Cortex-M0 и M0+ (F0, G0, L0, C0) деления в железе нет, нет и этого бита: строка выше там не соберётся, а что выйдет при делении на ноль, решает библиотечная функция деления.
65.3 Приоритет: где скобки обязательны
Операции выполняются в порядке приоритета — от высшего к низшему:
| Приоритет | Операции | Группировка |
|---|---|---|
| 1 | () [] -> ., постфиксные ++ -- |
→ |
| 2 | ! ~, префиксные ++ --, унарные + - * & sizeof (тип) |
← |
| 3 | * / % |
→ |
| 4 | + - |
→ |
| 5 | << >> |
→ |
| 6 | < <= > >= |
→ |
| 7 | == != |
→ |
| 8 | & |
→ |
| 9 | ^ |
→ |
| 10 | \| |
→ |
| 11 | && |
→ |
| 12 | \|\| |
→ |
| 13 | ? : |
← |
| 14 | = += -= и остальные присваивания |
← |
| 15 | , |
→ |
Поэтому *p++ — это *(p++): постфиксный ++ из строки 1 сильнее
звёздочки из строки 2 (раздел 67.4).
Большую часть таблицы помнить не нужно — там всё, как в арифметике. Но две строки противоречат интуиции, и обе бьют по коду для микроконтроллеров, где побитовые операции на каждом шагу.
Сравнение сильнее побитовых операций (строки 7 и 8):
if (x & 1 == 0) /* на деле x & (1 == 0), то есть x & 0 — всегда ноль */
if ((x & 1) == 0) /* правильно: младший бит равен нулю */
Для чётного x = 4 первая строка даёт 0, вторая — 1. Проверка чётности
«никогда не срабатывает», и найти это глазами трудно.
Сложение сильнее сдвига (строки 4 и 5): 1 << 2 + 1 — это 1 << 3,
то есть 8, а не 5.
К счастью, на x & 1 == 0 armclang предупреждает без всяких
ключей: «& has lower precedence than ==; == will be evaluated first»;
GCC — с -Wall: «suggest parentheses around comparison in operand of '&'».
Правило из практики: побитовая операция рядом со сравнением или
сдвиг рядом с арифметикой — всегда в скобках, даже когда приоритет
случайно совпал с задуманным.
65.4 if и else
if (условие)
действие;
else if (другое условие)
другое действие;
else
действие по умолчанию;
Фигурные скобки нужны, когда в ветке больше одного оператора. На
практике их ставят всегда: добавленная через месяц вторая строка иначе
тихо окажется вне if.
Висячий else относится к ближайшему if, а не к тому, под
которым его выровняли:
if (a)
if (b)
return 1;
else /* относится к if (b), хотя выровнен под if (a) */
return 2;
armclang здесь предупреждает сам: «add explicit braces to avoid
dangling else», GCC — с -Wall.
Присваивание вместо сравнения — ошибка, о которой шла речь в разделе 64.9:
if (a = 5) /* a становится 5, условие всегда истинно */
И на неё у armclang тоже есть предупреждение без ключей: «using the
result of an assignment as a condition without parentheses», у GCC —
с -Wall. Если присваивание в
условии действительно задумано, его берут в двойные скобки — это знак
и компилятору, и читателю.
Точка с запятой после условия — третья классика:
if (error); /* пустой оператор — условие ни на что не влияет */
stop_motor(); /* выполняется всегда */
Здесь компилятор тоже предупреждает — «if statement has empty body», и
так же для while, — но только когда точка с запятой стоит на одной
строке с условием. Пустой оператор на отдельной строке он считает
намеренным и молчит. GCC ловит пустое тело if с -Wextra — и на
отдельной строке тоже, а о while молчит; -Wall же замечает обманчивый
отступ stop_motor() под таким if.
65.5 Тернарная операция
условие ? значение1 : значение2 — это if-else, который возвращает
значение и поэтому помещается внутрь выражения:
int max = a > b ? a : b;
printf("LED %s\n", led_on ? "ON" : "OFF");
Цепочки группируются справа: a ? b : c ? d : e читается как
a ? b : (c ? d : e).
Частый вопрос — быстрее ли она, чем if? Нет. Функции с if-else и
с тернарной операцией, вычисляющие максимум, компилятор превращает в
один и тот же машинный код, команда в команду. Выбирают по
читаемости: короткий выбор значения — тернарная, всё остальное — if.
Вложенные тернарные операции читаются плохо, их лучше избегать.
65.6 switch
switch выбирает ветку по целому значению; метки case должны быть
константами:
switch (a) {
case 1: printf("one "); break;
case 2: printf("two "); /* break забыт */
case 3:
case 4: printf("three "); break;
default: printf("default ");
}
break — не формальность. Без него выполнение проваливается в
следующую ветку. Вот что печатает этот фрагмент:
a |
Вывод |
|---|---|
| 1 | one |
| 2 | two three — провал |
| 3 | three |
| 4 | three |
| 5 | default |
Иногда провал нужен: case 3: без тела стоит прямо над case 4:, и обе
метки ведут в одну ветку — это обычный приём. Плохо, когда провал
случаен, как у case 2.
И вот неприятная деталь: на случайный провал armclang молчит —
даже с -Wall -Wextra. Ловит его только отдельный ключ
-Wimplicit-fallthrough. У GCC этот ключ входит в -Wextra, и провал
виден уже с ним. А чтобы намеренный провал не вызывал
предупреждения, его помечают:
case 1:
prepare();
__attribute__((fallthrough)); /* провал задуман */
case 2:
run();
break;
default может стоять где угодно, но обычно его ставят последним. Там
break формально не нужен — но его всё равно пишут: если потом ниже
добавят новую метку, провала не будет.
65.7 Три цикла
| Цикл | Проверка условия | Выполнится хотя бы раз? |
|---|---|---|
while (условие) { … } |
до тела | нет |
do { … } while (условие); |
после тела | да |
for (начало; условие; шаг) { … } |
до тела | нет |
for — это while, собранный в одну строку:
for (i = 1; i <= 5; i++) { i = 1;
действие; while (i <= 5) {
} действие;
i++;
}
Любой из трёх разделов заголовка for может быть пустым; for (;;) —
цикл без условия, то есть бесконечный.
Несколько проверок на понимание — сколько будет a после каждого цикла:
int i, a = 1;
for (i = 1; i < 4; i++) a++; /* 3 шага: a = 4 */
int a2 = 1;
for (i = 1; i < 4; i++) a2 = a2 + i; /* 1 + 1 + 2 + 3: a2 = 7 */
int a3 = 1, b = 2;
for (i = 3; i >= 1; i--) a3 += b; /* 3 шага по 2: a3 = 7 */
i++ или ++i в заголовке? Для целого счётчика — никакой разницы:
компилятор выдаёт один и тот же код. Значение выражения здесь никто не
использует, остаётся только само увеличение.
Условие выхода пишут неравенством, а не равенством. Цикл
n = 2;
while (n != 5) { …; n += 2; } /* 2, 4, 6, 8, 10, … — бесконечно */
не кончится никогда: шагом 2 от двойки до пятёрки не попасть. С
n < 5 он остановился бы после 4.
Вещественный счётчик — ещё хуже. Десять раз прибавив 0.1f к нулю,
получаем не 1, а 1.000000119: 0,1 в двоичной бесконечна
(раздел 63.15), и ошибка накапливается. Цикл
for (x = 0; x != 1.0f; x += 0.1f) не остановится. Считают целым
счётчиком, а вещественное значение вычисляют из него.
Беззнаковый счётчик вниз — for (uint8_t i = 10; i >= 0; i--) —
тоже вечный: беззнаковое всегда больше или равно нулю. Этот случай
разобран в разделе 3.5.
65.8 break, continue, goto
continue переходит к следующему шагу, пропуская остаток тела; шаг
for при этом выполняется:
for (i = 1; i < 5; i++) {
if (i == 3) continue;
printf("%d ", i); /* 1 2 4 */
}
break выходит из ближайшего цикла или switch — только из
одного уровня. Из двух вложенных циклов одним break не выйти.
goto в C есть, и у него одно общепризнанное применение — выход из
глубокой вложенности к общему завершающему коду:
for (...) {
for (...) {
if (fatal) goto cleanup;
}
}
cleanup:
disable_peripheral();
Всё остальное — переходы назад, в середину цикла, «чтобы не писать
цикл» — делает программу нечитаемой. Правило простое: goto только
вперёд и только к завершению.
65.9 Бесконечный цикл — сердце программы на микроконтроллере
На компьютере программа заканчивается, возвращая управление системе.
На микроконтроллере возвращаться некуда: main не завершается никогда.
int main(void)
{
init_hardware();
for (;;) { /* или while (1) — это одно и то же */
poll_buttons();
update_display();
}
}
Пустой бесконечный цикл for (;;) { } и while (1) { } компилятор
превращает в одну двухбайтовую команду — переход на самого себя. В
машинном коде это e7fe, и это одно из немногих сочетаний, которые
стоит узнавать в лицо.
Потому что с ним вы встретитесь в отладчике. Обработчик прерывания по
умолчанию в стартовых файлах F103 и F407 устроен ровно так же: одна
метка на все необработанные прерывания и за ней переход на себя. Если
разрешить прерывание и забыть написать обработчик — или ошибиться в его
имени на одну букву, — программа при первом же прерывании попадёт туда и
«зависнет». Остановив её отладчиком, вы увидите именно e7fe
(разделы 7 и 35).
65.10 Циклы ожидания
Цикл задержки без volatile исчезает. Функция
void delay(void) { for (uint32_t i = 0; i < 100000; i++) { } }
с оптимизацией превращается в одну команду — возврат. Компилятор увидел,
что цикл ничего не делает, и выбросил его. С volatile у счётчика цикл
остаётся: переменная честно читается и пишется в память на каждом шаге.
Но и такая задержка плоха: её длительность зависит от частоты ядра, уровня оптимизации и даже от выравнивания кода во Flash. Время отмеряют таймером — системным (раздел 7.4) или обычным (раздел 13).
Ожидание флага обязано иметь предел. Цикл
while (!(USART1->SR & USART_SR_TXE)) { } /* а если флаг не поднимется? */
при отказе периферии висит вечно. В рабочем коде ожидание ограничивают временем:
uint32_t start = ticks;
while (!(USART1->SR & USART_SR_TXE)) {
if (ticks - start > TIMEOUT_MS) return ERR_TIMEOUT;
}
А на случай, если зависнет что-то непредусмотренное, существует сторожевой таймер: он перезапустит камень, если программа перестала отмечаться (раздел 12). Это ответ на частый вопрос «бывает ли защита от вечного цикла» — бывает, и на микроконтроллере она аппаратная.
Почему флаг объявлен через volatile и что будет без этого — раздел 3.4.
65.11 Задачи на циклы
Цифры числа — остаток от деления на 10 даёт последнюю цифру, деление её отбрасывает:
int n = 12345, count = 0, sum = 0;
while (n != 0) {
count++;
sum += n % 10;
n /= 10;
} /* count = 5, sum = 15 */
Наибольший общий делитель. Вычитанием: из большего вычитают меньшее, пока числа не сравняются. Остатком: заменяют большее остатком от деления на меньшее, пока остаток не станет нулём.
while (b != 0) {
int t = b;
b = a % b;
a = t;
} /* в a — НОД */
Для 14 и 35 оба способа дают 7 за три шага. Разница видна на неудобных числах: для 1 000 000 и 3 вычитанием нужно 333 335 шагов, остатком — два. Один и тот же алгоритм, разная запись шага — и разница в сотни тысяч раз. Об оценке алгоритмов — раздел 76.
Перебор. Сколько чисел от 102 до 987 записаны тремя разными
цифрами? Цикл по всем, три цифры через / и %, три сравнения — 648.
Тот же ответ даёт и рассуждение: первую цифру можно выбрать девятью
способами, вторую — девятью, третью — восемью.
65.12 Задача на побочные эффекты
Что будет после каждой строки, если начать с a = 5, b = 7?
(a += 5) * (b -= 3); /* 10 * 4 = 40; a = 10, b = 4 */
--b / (a++ - 3); /* 3 / 7 = 0; a = 11, b = 3 */
(a -= 2) || 47 / (b - 7); /* 1; a = 9, b = 3 */
(a *= b) + (b *= a); /* НЕЛЬЗЯ */
В третьей строке правая часть || не вычислялась: левая уже истинна.
Четвёртая — неопределённое поведение: a и b читаются и меняются в
одном выражении без точки следования (раздел 64.10). Разные компиляторы
дадут разное, и правильного ответа у неё нет.
65.13 Грабли
Проверка бита никогда не срабатывает. x & 1 == 0 — это
x & (1 == 0). Нужны скобки: (x & 1) == 0.
if всегда истинен. if (a = 5) — присваивание вместо сравнения.
else сработал не при том условии. Висячий else относится к
ближайшему if.
Действие выполняется всегда. Точка с запятой после if (…) или
while (…).
В switch выполняются две ветки. Забыт break. Ключ
-Wimplicit-fallthrough находит такие места.
Цикл не кончается. Условие != при шаге, который перескакивает
границу; вещественный счётчик; беззнаковый счётчик вниз.
break не вышел из внешнего цикла. Он выходит только из одного
уровня.
Задержка исчезла после включения оптимизации. Счётчик без
volatile; а лучше — таймер.
Задержка «плывёт» при смене частоты. Цикл отсчитывает команды, а не время.
Программа зависла на e7fe. Попала в обработчик по умолчанию:
прерывание разрешено, а обработчика нет или его имя написано с ошибкой.
Программа висит в ожидании флага. Нет предела времени ожидания.
Деление на ноль не вызвало ошибки. На Cortex-M3, M4, M7 и M33 результат
по умолчанию — ноль; ловушка включается битом DIV_0_TRP. У M0 и M0+ его
нет: делит библиотечная функция.
65.14 Итоги
- Сравнения и логические операции дают
int: 1 или 0. Условие считает ложью только ноль. &&и||вычисляют правую часть только при необходимости — на этом строят проверки вида «сначала убедиться, потом использовать».&и|вычисляют обе части.- Сравнение сильнее побитовых операций:
x & 1 == 0всегда ноль. Сложение сильнее сдвига:1 << 2 + 1— это 8. armclangбез ключей предупреждает оif (a = 5), оx & 1 == 0, о висячемelseи о точке с запятой сразу после условия. О провале вswitch— только с-Wimplicit-fallthrough. GCC без ключей молчит обо всех пяти: три ловит-Wall, пустое тело и провал —-Wextra.- Тернарная операция и
if-elseдают одинаковый код;i++и++iв заголовкеfor— тоже. - Условие выхода из цикла пишут неравенством; счётчик — целый.
- Пустой бесконечный цикл — это
e7fe, и так же выглядит обработчик прерывания по умолчанию. - Цикл задержки без
volatileвыбрасывается оптимизатором; время отмеряют таймером, а ожидание флага ограничивают по времени. - НОД остатком для 1 000 000 и 3 — два шага; вычитанием — 333 335.
Дальше: раздел 66 «Функции, область видимости, рекурсия».