Embedded HandbookПриложения
Все разделы › IX. Язык C: от основ до продвинутого
STM32F103, STM32F407, STM32G431 · уровень базовый · 15 мин чтения
Открыть в интерактивном справочнике

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 висячий else
fall-through провал в следующую ветку case
loop 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. Их главное свойство — короткая схема: правая часть вычисляется, только если от неё зависит результат.

На этом построена защита:

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 (начало; условие; шаг) { … } до тела нет

Блок-схемы 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 у счётчика цикл остаётся: переменная честно читается и пишется в память на каждом шаге.

Цикл задержки после -O2: без volatile остался только возврат, с 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 Итоги

Дальше: раздел 66 «Функции, область видимости, рекурсия».

Открыть в интерактивном справочнике