Анализируем код FADD для микроконтроллера "8051"

топ 100 блогов nabbla1 — 27.01.2025 Уже некоторое время ковыряюсь с китайскими микроконтроллерами STC15 и более новыми STC8, довольно забавная штука. Один из самых дешёвых, даже в Чип и Дипе в розницу цена начинается с 25 рублей за штуку (8 ног). На 32 ноги (LQFP32) можно у них за 62 рубля взять. Специальный программатор не требуется, хватает USB-UART адаптера, а обещают и вовсе напрямую через USB, но не пробовал ещё. Есть свои странности в перепрошивке, как-нибудь опишу подробнее. Напряжение питания от 1,9 до 5,5 вольт, так что хоть от двух АА, хоть от литий-ионной банки можно питать. И есть "глубокая спячка", когда штука жрёт чуть ли не меньше микроампера, но при этом может просыпаться по внешним прерываниям. Уже проверял - работает.

При этом система команд у них - от старого доброго Intel 8051. Поэтому среда разработки Keil, и там либо ассемблер, либо Си. Хвастаются, как большинство команд выполняется за такт, а не за 12, как у оригинала, и частота уже до 35 МГц (на 33,1776 МГц проверял - работает, UART 921600 бод тянет без проблем, то бишь 33 177 600 / 9 / 4).

И вот что-то шлея мне под хвост попала взять и объявить в сишной программе float a,b. Смотрю - компилится. Вписал строчку b+=a; и стал смотреть в дизассемблере, что же там творится. После долгих манипуляций с регистрами вызывается функция fadd, занимающая на пару с fsub аж 320 байт. Я честно это дело исполнил "в голове" - и обнаружил, что там есть "мёртвый код" и пара костылей, которые и делают его мёртвым!

Анализируем код FADD для микроконтроллера 8051



Для начала взглянем на код, вызывающий функцию fpadd:
Анализируем код FADD для микроконтроллера 8051


Ну да, переменная "a" занимает адреса памяти 0x0D, 0x0E, 0x0F и 0x10 (это 32-битный float), переменная "b" - адреса 0x11..0x14. Всё это "ручками", по одному байту переписывается в регистры общего назначения R0..R7, после чего и вызывается функция FADD. После вызова регистры R4..R7 переписываются в переменную a, т.е в адреса 0x0D..0x10.

Уже здесь есть забавный момент: в процессе запихивания в регистры, у нас меняется endian. Как позже я узнал, R0 - это "старший" байт для первого числа, т.е в R0 сидит знак и 7 бит экспоненты. Затем в R1 - последний бит экспоненты и первые 7 бит мантиссы, и так далее. То же для второго - в R4 сидит знак и старшие 7 бит экспоненты, в R5 последний бит экспоненты, за ним 23 бита мантиссы. А вот в памяти 0x0D - это самый младший байт первого числа (последние 8 бит мантиссы), а 0x10 - самый старший. Традиция, не иначе!

И теперь поглядим на саму FADD и заодно FSUB:
Анализируем код FADD для микроконтроллера 8051


Самая первая строчка с адресом 0x800 также относится к коду FADD. Здесь сидит "длинный прыжок" на код, который выставляет результат NaN и выходит из функции. А ближе к началу функции сидит несколько "коротких" прыжков на этот самый адрес 0x800. Если не ошибаюсь, здесь "короткие" прыжки на 128 байт назад или на 127 вперёд. Немудрено, что при общем размере функции в 320 байт приходится к длинным прыжкам прибегать!

FADD (сложение с плавающей точкой) и FSUB (вычитание с плавающей точкой) - сиамские близнецы. Первым идёт FSUB. Те 3 его "обособленные строки" загружают старший байт второго числа в аккумулятор, затем делает исключающее ИЛИ с 0x80, т.е инвертирует старший бит, бит знака. Могли бы применить команду CPL (complement) - перевернуть один бит. Аккумулятор позволяет "битовую адресацию". Но обе команды одинаковы по размеру - 2 байта. Наконец, этот старший байт загружается назад в R0. И после этого начинает выполняться сложение двух чисел, одному из которых мы поменяли знак.

В FADD мы загружаем в аккумулятор второй байт по старшинству, где лежит младший бит экспоненты и 7 старших бит мантиссы. Осуществляем "вращение влево через бит переноса". Тем самым, бит экспоненты оказывается в C (carry flag, флаг переноса), остальное неважно.

Тут же загружаем в аккумулятор старший байт, в котором лежит знак и старшие 7 бит экспоненты. И теперь его "вращаем влево через перенос". Благодаря этому бит знака оказывается в C, а экспонента, наконец-то "воссоединяется" - все её 8 бит оказываются помещены в аккумуляторе.

Как только мы получили экспоненту - проверяем её на ноль. Ну разумеется, корректно отрабатывать денормализованные (субнормальные) числа никто не собирается. Если не ошибаюсь, по сию пору, несмотря на возможность поместить под капот миллиарды транзисторов, никто этого не делает ни на аппаратном, ни на программном уровне!!! Ну мало ли что в IEEE записано, лениво! Нет, здесь число с экспонентой, принимающей минимальное значение, воспринимается как НОЛЬ. Если это так, мы попросту возвращаемся из функции. Результат лежит в R4..R7, это второе число, которое не изменилось от прибавления нуля.

А дальше первый костыль, обведённый в красную рамочку. К экспоненте прибавили единичку и снова проверяем на ноль. Т.е по сути проверяем, что экспонента равнялась 255, максимальному значению. И если это так, прыгаем сначала в 0x800, а оттуда перепрыгиваем в самый конец, чтобы установить результат NaN и на этом успокоиться. Костыль состоит в том, что это существенное упрощение логики работы с NaN и Inf. Под экспонентой 255 может прятаться и то, и другое, а у бесконечности ещё и знак есть. Но "нам на это пофиг - поставим NaN, а дальше ваши проблемы."

Далее всё то же самое проверяем со вторым числом, сидящим в регистрах R4..R7, причём R4 - старший байт (знак и старшие биты экспоненты), а R7 - самый младший. Загружаем R5, крутим влево, загоняя младший бит экспоненты в бит переноса. Загружаем R4, крутим его влево, так что в него въезжает младший бит экспоненты, а знак оказывается в C. Проверяем полную экспоненту (8 бит) на ноль. Ежели так, то копируем второе число на место первого. Не очень понимаю, почему копирование идёт через аккумулятор. Да, специального варианта MOV Rn, Rm не существует, но существует MOV Rn, ad и MOV ad, Rn, а к регистрам общего назначения, R0..R7 можно обращаться как к обычной памяти, адреса 0x000..0x007. Хотя по размеру получается столько же, по 2 байта, тут что в лоб, что по лбу. Тот вариант, что здесь, чуть универсальнее - тут можно банки переключать. Это характерная особенность 8051, что регистров R0..R7 тут "четыре экземпляра". С какими иметь дело в данный момент - определяется 2 битами в регистре состояния. А вот адресация через память "прямая", там нулевой банк 0x00..0x07, первый 0x08..0x0F и так далее. Но я пока не видел, чтобы компилятор Си пользовался этими банками. До сих пор он прям в 0x08 и дальше начинал пихать локальные переменные, и прямо адресом последней из них инициализировать стек!

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

Убедившись, что оба числа "самые обыкновенные" (не ноль, не NaN, не бесконечность), начинается их сравнение по абсолютной величине, т.е без учёта знака. Как ни странно, для такого сравнения можно не проводить разницы между мантиссой и экспонентой. Просто сравниваем два 31-битных числа. Какое больше как целое - будет больше и как число с плавающей точкой. Чтобы провести такое сравнение, одно число из другого вычитаем "в столбик", причём результаты вычитания не сохраняем, только результирующий флаг переноса/заёма. Чтобы проигнорировать знак, в старшем байте зануляется старший бит, специфическим для 8051 способом, командой CLR. Она далеко не на всех адресах работает, только на "специальных регистрах" (тех, что имеют адрес начиная от 0x80) и только если их адрес кратен 8. Поэтому старший байт вычитаемого сначала грузится в регистр B, там ему обнуляют старший бит, и уже потом производится вычитание.

В последней строчке показанного фрагмента мы перепрыгиваем код, где два числа меняются местами, если таковая замена не требуется. В R4..R7 хотим оставить бОльшее по модулю число, а в R0..R3 - меньшее.

Следующий слайд.
Анализируем код FADD для микроконтроллера 8051


Верхние 12 строчек просто меняют местами два числа.

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

Почему это оформлено процедурой - не понимаю до конца, вызывается она ровно единожды. Вот её код:

Анализируем код FADD для микроконтроллера 8051


Грузим второй байт по старшинству, где лежит младший бит экспоненты и старшие 7 бит мантиссы. В аккумуляторе устанавливаем старший бит в единицу, после чего меняем местами это значение с исходным. Т.е теперь в регистре R1 лежит исключительно мантисса, с добавленной неявной единицей! Напомним, число с плавающей точкой должно иметь вид 2E*M, где E - "экспонента", а M-"мантисса", причём по аналогии с научной записью числа, где нужно использовать число от 1 до 10, здесь используется число от 1 до 2. А коли так, значит оно имеет вид двоичной дроби 1.mmmmmm. Ну а зачем тратить бит, хранить в числе эту единицу, если мы и так знаем, что там единица?? Поэтому её и не записывают, но при вычислениях мы должны её поставить. Это мы только что и сделали... (а вот беда денормализованных чисел как раз в том, что в них этой единицы нет, т.к число E стать меньше уже не может, поэтому мы начинаем записывать 2-128*0.1, а потом и 2-1280.01, и так далее, самым маленьким числом станет с единицей в самом младшем разряде)

Тем временем, в аккумуляторе у нас исходный байт. Его мы крутим влево, чтобы младший бит экспоненты попал в бит переноса, C. Затем загружаем старший байт, снова крутим влево, благодаря чему знак оказывается в бите переноса, а в аккумуляторе - полноценная 8-битная экспонента. Именно её мы кладём в исходный старший байт. Т.е затираем знак. Его мы пока ещё помним - он лежит в бите переноса! И тут же, пока бит переноса чем-то другим не затёрся, отправляем его на ответственное хранение в "пользовательский бит" регистра состояния.

Затем проворачиваем тот же фокус со вторым, бОльшим числом, лежащим в регистрах R4..R7. Т.е и ему добавляем неявную единицу в начале мантиссы, R4, хранивший знак и первые 7 бит экспоненты, заменяется на чистую экспоненту. А затем, если у этого числа знак "-", то мы инвертируем бит знака первого числа. Получается, если у двух чисел знак совпадает, то в пользовательском бите будет лежать 0, а если знаки разные - то 1. Вот этот бит и определит в своё время, что надо делать - складывать или вычитать. Но ещё нам нужно не позабыть итоговый знак. Он известен уже сейчас, это знак бОльшего числа, т.е как раз тот, что сейчас лежит в бите переноса, C. А ещё в аккумуляторе (регистре A) лежит чистая экспонента второго (бОльшего) числа.

Именно с такими результатами мы возвращаемся из процедуры - и прямиком в мёртвый код. Повторим тот слайд.
Анализируем код FADD для микроконтроллера 8051


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

Наконец, взглянем на мёртвый код. Над двумя экспонентами делаем логическое И, затем прибавляем единицу и проверяем на нулевой результат. Если это так, проверяем ещё, что мы собирались делать, складывать или вычитать. И если вычитаем - то выплёвываем NaN в качестве результата и выходим из функции.

Всё это не так абсурдно, как кажется. Нулевой результат может получиться, если перед прибавлением единицы было 255. А 255 может стать результатом AND, только если оба операнда были равны 255. То есть, мы проверяем, что экспоненты в обоих числах были равны 255, и при этом одно число мы хотим вычесть из другого. Авторы пытались проверить вариант "бесконечность минус бесконечность", который должен давать "неопределённость", то бишь NaN (Not a Number) И этот кусок сам по себе корректен. Когда мы видим, что одно "специальное число" (то ли бесконечность, то ли Nan) вычитается из другого "специального числа", результат заведомо будет NaN. Напомню, знаки мы пока что отбросили, разбираемся с двумя положительными числами, то ли их складываем, то ли вычитаем, а знак добавляем уже под самый конец. Поэтому здесь варианта "специального числа" всего два: либо NaN, либо ПЛЮС бесконечность. Именно поэтому сложение двух плюс бесконечностей имеет смысл (по-прежнему плюс бесконечность), а вот вычитание - хрен.

Другое дело, все эти прыжки никогда не выполнятся, т.к если хоть одно из чисел "специальное", мы до этого места тупо не дойдём, прыгнем в установку NaN ещё во первых строках кода.

На этом мёртвый код не заканчивается. Ежели мы не прыгнули с результатом NaN, начинаются проверки каждого числа по отдельности. Первым проверяется меньшее число. Если его экспонента равна 255, то ещё устанавливаем "итоговый знак" равным знаку бОльшего числа - и прыгаем в конец функции, а именно часть, устанавливающая выходным значением плюс/минус бесконечность.

Затем очень важная команда, забившаяся между двумя фрагментами мёртвого кода - в "пользовательский бит" регистра состояния заносим результирующий знак, который всё это время лежал в бите переноса C.

И снова мёртвый код - теперь бОльшее число проверяем на экспоненту 255, и если это так, прыгаем туда же в конец функции, установить результат плюс/минус бесконечность. Это похоже на недоделанный код! Первая проверка меньшего числа не имела смысла, потому как если даже у меньшего числа экспонента 255, значит в бОльшего - и подавно, а мантисса при этом крупнее. И вот здесь уже важно про каждое число сказать с определённостью: это бесконечность или NaN? Чтобы это проверить, нужно ВСЮ МАНТИССУ ЦЕЛИКОМ (23 бита) ПРОВЕРИТЬ НА НОЛЬ. Только тогда это будет бесконечность. Все прочие варианты - NaN, он там ещё бывает тихим и громким, но это я вообще чего-то невозможного от них требую.

Могу себе вообразить, как этот код писался. Поначалу авторы захотели реализовать IEEE в полной мере, всю эту логику про бесконечности и "не-числа". Код стал разрастаться со страшной силой, и они решили, "для такого хилого микроконтроллера это всё нахрен нужно, обычные человеческие числа посчитает - вот и всё, зато, может, останется место в ПЗУ для пользовательской программы, функции плавающей точки съедят не совсем всё". По-быстренькому добавили два костыля в начале, а этот недописанный код недолго думая грохнули. После этого всё совсем поломалось, функция стала путать знак результата. Время поджимало, разбираться не хотелось, код вернули на место - и всё заработало. Вот так лет 40 и работает, ибо работает-не лезь!


Продолжение следует... Мёртвый код посмотрели, осталось в "живом" разобраться.

Оставить комментарий

Архив записей в блогах:
Мы смотрим кино, чтобы получать сильные впечатления. После просмотра "Времени желаний" (1984), частной истории о жизни советской женщины и ее мужа, я гарантирую вам и эмоции, и впечатления. И размышления, которые не оставят вас даже на следующий день. ...
В беседе с Калашниковым основные мысли сформулировал невнятно. Беседа перескакивала с пятого на десятое. Тем более на основном канале вывешена урезанный на три четверти материал. Дам комментарий к тому видео. 1. Украина особое государство . Государств, которым плевать на свой народ ...
Белая весна Королева Маргерит и Эдвард Вестминстер отчего-то не спешили покидать Францию, несмотря даже на настойчивые призывы Хенри VI — видимо, не горели желанием плясать в подтанцовке у Ворика. 2 ноября 1470 года собрался парламент — и выяснилось, что в прошлый раз его обманули! Все ...
. Хэйли покачал головой. Остальные небольшие пакеты были поровну поделены между все теми же "Имперой", "Кроникой" и "Лагуной", и еще четвертой компанией, - какой-то "Лиско". Помня о предупреждении Николаса, он повернул руль и поехал за ними. Ло ...
Езда Предположение Слово "Езда" близко по звучанию и смыслу к словам "зад, сидя" в значении "передвижение сидя" Слово "Езда" близко по звучанию и смыслу к словам "зад, сидя" в значении "передвижение сидя" при учете перехода " З-С ". Ссылки Источник из " вяк " Переход ...