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

63. Как текст становится программой; системы счисления

Процессор не понимает C. Он понимает двоичные коды команд — и больше ничего. Между текстом, который вы пишете, и байтами, которые лежат во Flash, стоят четыре программы, и каждая делает свою часть работы. Когда что-то не собирается, первое, что нужно понять, — на какой из четырёх стадий случилась ошибка.

Вторая половина раздела — про то, как числа выглядят внутри машины: двоичная и шестнадцатеричная запись, отрицательные числа, дроби, символы. Без этого не читаются ни регистры, ни дамп памяти, ни сообщения отладчика.

Как это называется в документации

Оригинал Перевод
source file, translation unit исходный файл, единица трансляции
preprocessor препроцессор
compiler компилятор
assembler ассемблер
linker компоновщик (он же линковщик)
object file объектный файл
image, executable образ, исполняемый файл
optimization level уровень оптимизации
undefined symbol неопределённое имя
radix, base основание системы счисления
hexadecimal, octal, binary шестнадцатеричная, восьмеричная, двоичная
two's complement дополнительный код
sign-magnitude прямой код
nibble тетрада, полубайт

63.1 Программа пишется для человека

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

Важная мысль, которую стоит принять с первого дня: текст программы пишется прежде всего для человека и только потом — для компилятора. Компилятору всё равно, как названы переменные и есть ли отступы; тому, кто откроет файл через полгода (скорее всего, вам же), — нет.

Язык определяется тремя вещами:

Синтаксические ошибки ловит компилятор. Семантические — нет: a = b и a == b оба правильны синтаксически, а означают разное (раздел 65).


63.2 Компилятор, интерпретатор и промежуточный путь

Перевести текст в команды можно двумя способами.

Компилятор переводит всю программу заранее. Результат — готовый машинный код, который исполняется без участия переводчика. Так работает C.

Интерпретатор читает программу и выполняет её строка за строкой прямо во время работы. Так традиционно работают скриптовые языки.

Между ними — промежуточный путь: программа компилируется в код виртуальной машины, а тот исполняется интерпретатором или докомпилируется на лету.

На микроконтроллере почти всегда компилятор, и причина практическая: интерпретатору нужны память и время, а у небольшого камня нет ни того, ни другого с запасом. Интерпретаторы для микроконтроллеров существуют, но живут на старших моделях.

Где хранятся команды. В классической архитектуре команды и данные лежат в одной памяти и идут по одной шине. В гарвардской у них разные шины. Ядра Cortex-M3 и M4 — гарвардские по шинам: команда и данные могут читаться одновременно. Но адресное пространство у них одно общее (раздел 1.4), и указатель может смотреть и во Flash, и в ОЗУ.


63.3 Четыре стадии сборки

Стадия Что делает Вход → выход Ключ armclang
препроцессор работает с текстом: вставляет файлы, подставляет имена .c → текст -E
компилятор переводит C в команды процессора текст → .s -S
ассемблер превращает команды в байты .s → .o -c
компоновщик собирает файлы в один образ, связывает имена .o → образ armlink

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

#include <stdint.h>

#define LIMIT 100

int32_t max2(int32_t a, int32_t b)
{
    if (a > b)
        return a;
    return b;
}

int32_t limit(int32_t x)
{
    return x > LIMIT ? LIMIT : x;
}

Пятнадцать строк. Проведём его через все четыре стадии.

Четыре стадии сборки stages.c: настоящий вывод armclang — препроцессор, компилятор, ассемблер, компоновщик


63.4 Препроцессор: только текст

Препроцессор ничего не знает о C. Он выполняет строки, начинающиеся с #, и работает с текстом как текстовый редактор:

После него наши пятнадцать строк превращаются в 76 — в пять с лишним раз длиннее. Лишнее — это вставленный stdint.h: оттуда пришёл int32_t, который оказался обычным typedef. А конец файла выглядит так:

int32_t limit(int32_t x)
{
    return x > 100 ? 100 : x;
}

Имени LIMIT больше нет — компилятор его уже не увидит. Отсюда целый класс ошибок, про которые подробно в разделе 71: препроцессор подставляет текст буквально, не думая о смысле.


63.5 Компилятор: из C в команды процессора

Компилятор переводит текст в команды ассемблера. И здесь впервые видно, насколько результат зависит от уровня оптимизации.

Без оптимизации функция max2 превращается в 17 команд: аргументы сначала записываются в стек, потом читаются обратно, сравниваются, выполняется переход, результат снова пишется в стек и снова читается. Всё буквально, строка за строкой.

С оптимизацией — в четыре:

max2:
    cmp r0, r1
    it  le
    movle   r0, r1
    bx  lr

Сравнить, и если a не больше b, переписать b на место результата. Перехода нет вовсе — ветвление превратилось в условную пересылку. Функция limit — тоже четыре команды вместо пятнадцати.

Без оптимизации С оптимизацией
max2 17 команд 4
limit 15 команд 4
кода всего 64 байта 16 байт

Разница в четыре раза. Почему же без оптимизации вообще собирают? Потому что такой код легко отлаживать: каждая строка C соответствует своему куску машинного кода, и отладчик честно показывает значения переменных. После оптимизации переменные живут в регистрах, строки перемешиваются, а часть кода исчезает совсем — и именно здесь всплывают ошибки, которые «в отладке не воспроизводятся» (раздел 3.4). Подробнее об оптимизации — раздел 75.


63.6 Ассемблер: команды становятся байтами

Ассемблер переводит каждую команду в её двоичный код и складывает результат в объектный файл. Вот вся функция max2 в байтах:

0x00000000:    4288    CMP      r0,r1
0x00000002:    bfd8    IT       LE
0x00000004:    4608    MOVLE    r0,r1
0x00000006:    4770    BX       lr

Восемь байт. Все команды здесь двухбайтовые: в наборе Thumb-2 (раздел 1.6) есть 16- и 32-битные команды, а простые действия над младшими регистрами кодируются короткими. Поэтому 32 команды неоптимизированной версии дают ровно 64 байта, восемь оптимизированных — 16. В другом коде встретятся и четырёхбайтовые, например mov.w и str.w (раздел 66.3).

Код 4770 — возврат из функции — стоит запомнить: его видно в любом дампе прошивки на Cortex-M, и по нему удобно искать концы функций.

Объектный файл — ещё не программа. Адреса в нём условные, а вызовы функций из других файлов оставлены пустыми местами с пометкой «сюда нужен адрес такого-то имени». Имена функций в нём сохранены — именно для следующей стадии.


63.7 Компоновщик: связать имена

Компоновщик собирает все объектные файлы в один образ, раскладывает их по адресам памяти по скрипту компоновки (раздел 11.3) и связывает имена: каждый вызов функции получает настоящий адрес.

Здесь происходит ошибка, которая озадачивает новичков. Файл

#include <stdint.h>
int32_t sensor_read(void);
int32_t main(void) { return sensor_read() + 1; }

компилируется без единой ошибки: объявление есть, типы сходятся. А компоновщик останавливается:

Error: L6218E: Undefined symbol sensor_read (referred from link63.o).

Функция объявлена, но нигде не определена — нет файла с её телом. Компилятор видит только один файл за раз и поверил объявлению на слово. Компоновщик видит всё сразу и обнаружил, что обещанного тела нет.

Правило: «undefined symbol» — это не ошибка в коде, а ошибка в составе проекта. Не добавлен файл, не подключена библиотека, опечатка в имени в одном из двух мест. Как устроены многомодульные программы — раздел 71.


63.8 Где «Hello, world» на микроконтроллере

Первая программа на любом языке печатает приветствие. У микроконтроллера нет экрана, поэтому здесь два пути.

Первая программа — мигающий светодиод (раздел 8). Она проверяет сразу всё: что проект собирается, что прошивка попадает в камень, что тактирование работает и вывод настроен.

printf работает, если его перенаправить — в последовательный порт (раздел 16.6) или в отладочный канал (раздел 21.3). Сама функция форматирует строку, а куда отправить символы, решаете вы.

И ответ на частый вопрос — можно ли написать программу без библиотек? На микроконтроллере это обычное дело. Программа, которая пишет в регистры и ничего не печатает, сама к стандартной библиотеке может не обращаться вовсе. Но компилятор вправе вставить вызовы сам: memcpy и memset при копировании структуры или обнулении массива (раздел 68.2), вспомогательные функции для 64-битного деления, для деления на Cortex-M0 и для float без FPU. При сборке совсем без библиотек их приходится дать самому. Минимально нужен только стартовый код, который подготовит память и вызовет main (раздел 11.2).


63.9 Позиционные системы счисления

В позиционной системе значение цифры зависит от её места. В записи 555 три одинаковые цифры означают пятьсот, пятьдесят и пять. Основание системы — сколько разных цифр в ней есть и во сколько раз «весит» каждый следующий разряд.

Простая загадка: может ли быть 8 + 8 = 10? Может — в шестнадцатеричной системе. Или: у паука 1000 ног, у собаки 100, у человека 10. Всё верно, если писать в двоичной — это 8, 4 и 2.

Для программиста важны четыре системы:

Основание Цифры Где встречается
2 0, 1 биты регистров, логика
8 0 … 7 права доступа к файлам и ловушка в C
10 0 … 9 всё человеческое
16 0 … 9, A … F адреса, регистры, дампы памяти

63.10 Перевод между системами

В десятичную — сумма цифр, умноженных на вес разряда:

0x27F = 2·16² + 7·16 + 15 = 2·256 + 112 + 15 = 639
0173  = 1·8²  + 7·8  + 3  = 64 + 56 + 3      = 123

Из десятичной — деление на основание с остатком. Остатки, прочитанные снизу вверх, дают запись. Переведём 12345678 в шестнадцатеричную:

Делимое ÷ 16 = частное остаток
12345678 771604 14 → E
771604 48225 4
48225 3014 1
3014 188 6
188 11 12 → C
11 0 11 → B

Снизу вверх: 0xBC614E. Ещё несколько пар для самопроверки:

Десятичная Шестнадцатеричная
1 000 000 0xF4240
305 419 896 0x12345678
16 777 216 0x1000000

Обратите внимание на последние две строки: одни и те же цифры «12345678» и «1000000» в двух системах означают совсем разные числа. Отсюда правило записи — основание всегда указывается явно.


63.11 Родственные системы: 2, 8 и 16

Главное свойство, ради которого шестнадцатеричная система вообще нужна: одна её цифра — ровно четыре бита. Перевод из двоичной и обратно делается без всякой арифметики, заменой по таблице:

0x27F      →  0010 0111 1111
1010 0100  →  0xA4

С восьмеричной то же самое, только группами по три бита:

0173  →  001 111 011

Отсюда удобство для регистров: 32-разрядный регистр — ровно восемь шестнадцатеричных цифр, и каждая соответствует своим четырём битам. Адрес 0x40021018 в десятичной — 1 073 877 016, и так его не пишет никто: в десятичной записи не видно ни границ блоков памяти, ни отдельных битов.


63.12 Как записывать числа в C

Одно и то же число можно записать четырьмя способами:

42          /* десятичная */
0x2A        /* шестнадцатеричная */
052         /* восьмеричная — ведущий ноль! */
0b101010    /* двоичная */

⚠️ Ведущий ноль делает число восьмеричным. 010 в C — это восемь, а не десять. Классическая ошибка — выровнять столбец чисел нулями:

c const uint16_t delays[] = { 100, 050, 010 }; /* 100, 40 и 8 */

Компилятор не предупредит: запись правильная, просто означает не то. Цифры 8 и 9 в восьмеричной недопустимы, так что 09 хотя бы вызовет ошибку — а 010 пройдёт молча.

Двоичная запись 0b… вошла в стандарт языка только в версии C23, но компиляторы для микроконтроллеров принимают её давно — как расширение. Для масок регистров всё равно удобнее шестнадцатеричная или сдвиги (раздел 3а).


63.13 Единицы: почему килобайт — 1024

Единица Сколько
бит 0 или 1
байт 8 бит
килобайт 2¹⁰ = 1024 байта = 8192 бита
мегабайт 2²⁰ = 1 048 576 байт

Память адресуется двоичными числами, поэтому её объёмы — степени двойки. 64 КБ Flash — это 65 536 байт, а не 64 000: разница в 1536 байт, и её приходится учитывать при раскладке памяти.

В скоростях передачи, наоборот, «кило» обычно десятичное: килобит в секунду — тысяча бит. Где важно не перепутать, двоичные единицы пишут как КиБ и МиБ.


63.14 Целые со знаком: дополнительный код

Числа без знака хранятся как есть. Со знаком — в дополнительном коде: отрицательное число −x в k разрядах записывается как 2ᵏ − x.

−5 в восьми битах:  256 − 5 = 251 = 0xFB = 1111 1011

То же самое получается по правилу «инвертировать все биты и прибавить единицу». Старший бит оказывается знаковым: у отрицательных он 1.

Разрядов Со знаком Без знака
8 −128 … 127 0 … 255
16 −32 768 … 32 767 0 … 65 535
32 −2 147 483 648 … 2 147 483 647 0 … 4 294 967 295

Один байт — два прочтения: без знака и в дополнительном коде; переполнение и общий сумматор

Зачем такие сложности, почему не просто знаковый бит? Такая запись тоже существует — прямой код, — и у неё два недостатка.

Первый: в прямом коде два нуля, 0000 0000 и 1000 0000, то есть +0 и −0. Второй — важнее: для сложения нужна отдельная схема. Сложим −3 и 5. В прямом коде −3 — это 1000 0011; обычный сумматор (раздел 60.5) выдаст 1000 1000, то есть −8. В дополнительном коде тот же самый сумматор даёт правильные 2 — без всякой доработки. Один сумматор для знаковых и беззнаковых — вот и вся причина.

Одни и те же биты — разные числа. Байт 0xFB — это 251, если читать его как беззнаковый, и −5, если как знаковый. В памяти никакой пометки нет: как читать, решает тип переменной (раздел 64). И если результат не помещается, лишние разряды просто теряются: 200 + 100 в одном байте даёт 44. Так ведёт себя сумматор; в C это гарантировано только для беззнаковых типов. Переполнение знакового при вычислении — неопределённое поведение, и оптимизатор вправе считать, что его не бывает (раздел 123.3).


63.15 Дроби: не каждая конечна

Дробную часть переводят в двоичную умножением на 2 — целые части результатов и есть цифры:

0,375 × 2 = 0,75   → 0
0,75  × 2 = 1,5    → 1
0,5   × 2 = 1,0    → 1          0,375 = 0,011₂

Три шага — и остаток ноль. Но так бывает не всегда. Переведите 0,1:

0,1 = 0,0001100110011…₂        (0011 повторяется бесконечно)

Конечная десятичная дробь в двоичной может оказаться бесконечной, и в память ляжет только приближение. Поэтому 0,1 в компьютере — не совсем 0,1, а сравнивать вещественные числа на точное равенство нельзя.

С целой и дробной частью вместе: 15,625 = 1111,101₂, и в формате float это слово 0x417A0000. Как устроен этот формат и что из него следует для расчётов — раздел 69.


63.16 Символы — тоже числа

В памяти нет букв — только коды. Каждому символу сопоставлено число:

Символ Код
'0' 48
'A' 65
'a' 97

Из этой таблицы следуют два приёма, которые встречаются постоянно:

И обратная ловушка: '7' и 7 — разные вещи. Первое — код 55, второе — число семь.


63.17 Логические и побитовые операции

Законы алгебры логики и таблицы истинности — в разделе 59.1–59.2, приёмы с отдельными битами — в разделе 3а. Здесь — одно различие, на котором ошибаются все.

Логические операции &&, ||, ! работают с числом целиком: ноль — ложь, всё остальное — истина, результат 0 или 1. Побитовые &, |, ^, ~ работают с каждым битом отдельно.

Выражение Результат Почему
65 & 35 1 общий у них только младший бит
65 \| 35 99 объединение битов
65 && 35 1 оба не ноль
2 & 1 0 общих битов нет
2 && 1 1 оба не ноль

Последние две строки — источник ошибки, которую ищут часами:

if (flags && 0x20) { ... }   /* хотели проверить бит, а проверили «flags не ноль» */

К счастью, armclang такое замечает: строка собирается, но с предупреждением — у логической операции постоянный операнд, «use '&' for a bitwise operation». GCC молчит даже с -Wall -Wextra и предупреждает только с ключом -Wlogical-op. Предупреждения не выключают — их читают.

Ещё одно свойство, полезное на практике: дважды применённое исключающее ИЛИ с одним и тем же числом возвращает исходное. На этом построены простейшее шифрование и контрольные суммы.


63.18 Грабли

Всё компилируется, но не собирается: «undefined symbol». Функция объявлена, но не определена: не добавлен файл в проект или опечатка в имени.

«Линковщик» и «компоновщик» — это одно и то же? Да. И «сборка» чаще всего означает все четыре стадии вместе.

printf ничего не выводит. На микроконтроллере его нужно перенаправить в порт или отладочный канал.

В отладке работает, после оптимизации — нет. Оптимизатор выбросил или переставил то, что считал лишним. Чаще всего это переменная без volatile (раздел 3.4) или неопределённое поведение: переполнение знакового, выход за массив, неинициализированная переменная (раздел 123).

Задержка стала в разы короче. В таблице констант 010 вместо 10 — это восемь.

Число из регистра выглядит бессмысленно. Оно в десятичной. Регистры читают в шестнадцатеричной: каждая цифра — четыре бита.

Проверка бита всегда истинна. Вместо & написано &&.

Байт показывает 251 вместо −5. Те же биты прочитаны как беззнаковое число: дело в типе.

Счётчик в байте после 255 стал нулём. Разряды, не поместившиеся в тип, теряются.

Место во Flash кончилось раньше, чем ожидали. Килобайт — 1024 байта; 64 КБ — это 65 536, а не 64 000.

Символ из порта сравнивают с числом. '7' — это код 55, а не семь.

0,1 + 0,2 не равно 0,3. Десятичные дроби в двоичной приближённые: раздел 69.


63.19 Итоги

Дальше: раздел 64 «Типы данных» — сколько места занимает каждый тип и почему на микроконтроллере это не одно и то же, что на компьютере.

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