В Предыдущей части мы поговорили о том, что вообще такое микроконтроллер, как его прошивать и полюбовались на мигающий светодиод. Теперь рассмотрим организацию памяти и, в частности, стек.
Но прежде, чем работать с памятью, надо настроить хотя бы примитивный интерфейс для общения контроллера с внешним миром. Ведь адреса и содержимое памяти довольно трудно отобразить мигающими светодиодами. Простейшим отладочным интерфейсом является UART, но, как и все остальное в контроллере, сам по себе он не заработает, его надо сначала включить и настроить.
3. Битовая магия
Помните вот такую конструкцию, которая использовалась для настройки режима работы порта?
li t0, PORTB_CTL0
lw t1, 0(t0)
li t2, ~(0b1111 << (4*LED))
and t1, t1, t2
li t2, 0b0011 << (4*LED)
or t1, t1, t2
sw t1, 0(t0)
Практика общения на форумах, да и лично, показала, что даже люди, занимающиеся программированием под компьютеры, зачастую имеют весьма слабое представление о битовых операциях. Что уж говорить о начинающих. А ведь в контроллерах работа с отдельными битами встречается повсеместно.
Битовая маска — число, задающее с какими битами идет работа. Обычно это двоичное число, состоящее из нулей и с единственной единицей на месте интересующего бита. Бывает наоборот — все единицы и единственный ноль. Реже вместо одной единицы идет последовательность из двух, трех и т.д. единиц. Совсем редко единицы идут вразнобой, но это используется либо для какой-то совсем странной периферии, либо если несколько масок накладываются одновременно.
Числа будут обозначаться как как abcdefgh, каждая буква — один бит, 0 или 1. Хотя контроллер у нас 32-битный, писать 32 буквы было бы долго и ненаглядно, поэтому здесь переменные 8-битные.
3.1. AND (побитовое умножение)
A | B | A & B |
---|---|---|
0 | 0 | 0 |
0 | 1 | 0 |
1 | 0 | 0 |
1 | 1 | 1 |
Среди битов результата 1 будет только на тех позициях, на которых в обоих аргументах были 1. Если хотя бы в одном аргументе был 0, в результате тоже будет 0.
A abcdefgh
B 00001000 &
A & B 0000e000
A abcdefgh
B 11110111 &
A & B abcd0fgh
AND Стирает (выставляет в 0) все биты аргумента за исключением тех, которые в маске выставлены в 1. В ассемблере risc-v за операцию AND отвечают and, andi
. Буква i на конце означает, что второй операнд — не регистр, а абсолютное значение. Причем, поскольку размер операции у нас всего 32 бита, закодировать там еще 32-битный операнд невозможно. Поэтому его максимальное значение ограничено 12 битами. Особенно это удручает как раз в случае andi
, ведь часто маска состоит из единственного нуля и 31 единицы, которые туда не помещаются.
3.2. OR (побитовое сложение)
A | B | A │ B |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 1 |
Среди битов результата 0 будет только на тех позициях, на которых в обоих аргументах был 0. Если хотя бы в одном аргументе была 1, в результате тоже будет 1.
A abcdefgh
B 00001000 |
A | B abcd1fgh
OR Выставляет в 1 все биты, в которых в маске была 1. В Ассемблере risc-v за операцию OR отвечают or, ori
3.3. Сдвиги
Аргумент сдвигается на нужное количество битов вправо или влево.
Простейшая битовая маска:
A abcdefgh
A << 2 cdefgh00
A >> 2 00abcdef
Число (битовая маска), содержащее единственный бит на нужной позиции:
A=1 00000001
A << 2 00000100
Число (битовая маска), содержащее последовательность битов начиная с нужной позиции:
A 00000abc
A << 2 000abc00
Обратите внимание на нули, которые добавляются слева или справа к числу. Такой сдвиг называется логическим, поскольку оперирует числом просто как набором битов. Но иногда, когда идет работа со знаковыми числами, при сдвиге вправо, бывает удобно заполнять добавленные биты не нулями, а знаковым битом (старшим). Такой сдвиг называется арифметическим. В ассемблере risc-v за сдвиги отвечают sll, slli, srl, srli, sra, srai
3.4. Побитовая инверсия
Меняет каждый бит на противоположный: 0->1, 1->0
A 10011110
~A 01100001
Сама по себе она практически никогда не нужна, зато используется вместе с другими операциями.
3.5. XOR (Исключающее или)
A | B | A ^ B |
---|---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
A 10011110
B 00100100 ^
A ^ B 10111010
XOR Инвертирует те биты аргумента, в которых в маске были 1. В ассемблере risc-v за сдвиги отвечают xor, xori
3.6. Запись значения начиная с нужного бита
Мы с этим уже сталкивались при настройке порта. Допустим, надо записать комбинацию из четырех битов WXYZ в 16-битный регистр начиная с 4-й позиции
Формируем маску:
0b1111 00000000 00001111
(0b1111<<4) 00000000 11110000
~(0b1111<<4) 11111111 00001111
Далее полученная маска накладывается на исходную переменную:
A abcdefgh ijklmnpq
~(0b1111<<4) 11111111 00001111 &
B abcdefgh 0000mnpq
Потом формируется значение для присваивания:
C 00000000 0000WXYZ
(C<<4) 00000000 WXYZ0000
Которое накладывается на обрезанную по маске переменную
B abcdefgh 0000mnpq
(C<<4) 00000000 WXYZ0000 |
D abcdefgh WXYZmnpq
Таким образом, путем наложения двух битовых масок можно определенным битам переменной присвоить любое значение независимо от того, что в них лежало прежде.
3.7. Применение масок
Стирание (выставление в 0) заданного бита:
li t1, ~(1<<4) ; Применяем сдвиг и побитовую инверсию чтобы сформировать маску
and s2, s2, t1 ; Операцией AND накладываем ее на регистр s2
Выставление заданного бита в 1:
ori s2, s2, (1<<10) ; Применяем сдвиг и операцию OR. Напоминаю, что для работы с битами 12-31 маску придется сначала загружать в другой регистр, как в случае с AND
Инверсия заданного бита:
xori s2, s2, (1<<8)
Проверка заданного бита. Обратите внимание, в risc-v нет регистра флагов, поэтому результат наложения маски надо сохранять в какой-нибудь регистр общего назначения:
andi t1, s2, (1<<3)
bnez t1, label_if_1 ; branch if not equal zero
beqz t1, label_if_0 ; branch if equal zero
А вот такие примеры, аналогичные обнаруженным в бутлоадере CH32V307, я предлагаю разгадать самостоятельно
1:
lhu s1, 0(t0)
slli s1, 16
srli s1, 16
sh s1, 0(t0)
2:
lw s2, 0(t1)
slli s2, s2, 6
bgez s2, label
3.8. Особая битовая магия в периферии
Рассмотренная ранее работа непосредственно с OCTL не является безопасной. Если между чтением и записью возникнет прерывание, пытающееся также поменять значение OCTL, после выхода результат его работы будет перезаписан основным кодом. Иначе говоря, операция переключения состояния вывода оказалась неатомарной (делимой). Чтобы это исправить, в GD32VF103 есть специальный регистр BOP (в ch32 — BSHR), старшие 16 битов которого отвечают за очистку соответствующих битов OCTL, а младшие — за выставление в 1. Значение имеет только запись единицы, запись нуля ни на что не влияет
li t0, PORTB_BOP
li t1, (1<<5)<<16
sw t1, 0(t0)
# PB5 = 0
li t1, (1<<6)
sw t1, 0(t0)
# PB6 = 1
В данном случае код, реализующий мигалку через чтение OCTL и запись BOP, приводить не обязательно, поскольку задача абсолютно не ответственная, и можно обойтись xor'ом. К тому же в реальности куда чаще встречается задача сброса в 0 или выставления в 1 независимо от текущего состояния. Впрочем, при написании полноценной библиотеки раоты с GPIO, этим озаботиться стоит.
Для другой периферии могут встречаться и другие подходы к обеспечению атомарности. Наиболее надежным из которых остается остановка периферии, модификация ее регистров и повторный запуск. Правда, иногда это приводит к обрыву связи, так что изучать придется и менее примитивные подходы. Также наш контроллер поддерживает RISC-V расширение "A", обозначающее наличие атомарных инструкций на уровне ядра. Правда, мне с ними дела иметь пока не приходилось.
3.8. Логические операции
Большая путаница возникает у изучающих язык Си при знакомстве с побитовыми (|
, &
, ~
) и логическими (||
, &&
, !
) операциями. Разница между ними в том, что побитовые работают с переменной как с массивом отдельных битов, никак не связанных друг с другом, а логические — как с единым целым, где лог.0 это ноль и только ноль, а лог.1 — все остальное. Причем результат логической операции строго 0 или 1. Например:
uint8_t x = 0b00001111
uint8_t y = 0b01010101
uint8_t z;
z = x | y; // 0b01011111
z = x || y; // 0b00000001
z = x & y; // 0b00000101
z = x && y; // 0b00000001
z = ~x; // 0b11110000
z = !x; // 0b00000000
3.9. Частые ошибки
-
Использование побитовой операции в условии:
if( (x >= '0') & ( x <= '9') )
— неправильно
-
Использование логической операции для работы с битами
-
Использование арифметических операций вместо логических или побитовых
-
Ну и классика — использование конструкций вида
x = x | (0<<3);
в надежде обнулить бит.
4. UART
Будем считать, что работать с битами мы научились, и пора осваивать периферию.
UART это один из самых распространенных интерфейсов. Устроен он предельно просто: биты передаются по очереди, один за другим, с равными, заранее заданными интервалами. Признаком начала передачи является стартовый бит — переход из "пассивного" уровня (лог.1) в "активный" (лог.0). После него идут 8 бит данных начиная с младшего. Далее может идти, а может и не идти, бит четности, служащий для контроля целостности байта и, наконец, несколько стоп-битов (в нашем контроллере их может быть 0.5, 1, 1.5 или 2). Стоп-бит является переходом от состояния предыдущего бита к "пассивному" уровню. Передача осуществляется по линии TX, прием по линии RX. Поскольку электрически они не пересекаются, то прием и передача могут идти одновременно. Поэтому UART — трехпроводная линия: TX, RX и земля. Линия TX одного устройства соединяется с RX второго и наоборот. Соединять TX с TX нельзя. Соединять RX с RX можно, но бесполезно. Поскольку точность обмена целиком зависит от временных интервалов, требуется хорошее согласование частот передатчика и приемника (если я правильно помню, около 2%). По-хорошему — тактирование от кварцевого резонатора, но практика показывает, что и у обычного RC-генератора, используемого по умолчанию контроллером, хватает стабильности для отладочных целей. Кроме того, существует ряд стандартных скоростей обмена вроде 9600 или 115200 бит в секунду. В случае Linux, посмотреть их можно в документации man termios
.
Сам по себе UART определяет только способ передачи данных и тайминги, но не уровни сигналов или разъемы. Это позволяет разводить линии UART непосредственно на плате для соединения периферии и использовать уровни, совпадающие с напряжением питания. Для GD32VF103 логическому 0 будет соответствовать 0 В, а логической 1 — +3.3 В.
Существует и вариация UART, предназначенная для соединения отдельных приборов — RS232, она же COM-порт. Отличие в логических уровнях (лог.0 = +5...+15 В, лог.1 = -5...-15 В), стандартном разъеме (DB9 или DB25) и наличии дополнительных управляющих выводов вроде готовности передатчика или наличия тонового сигнала модема. Для ее преобразования во "внутрисхемный" UART с "контроллерными" логическими уровнями существуют специальные микросхемы вроде древней max232. Разумеется, существуют и переходники с UART на более современные интерфейсы вроде USB. Самые распространенные — ft232 (ft2232, и подобные), ch340, cp2101, pl2302 и другие. Можно даже собрать переходник самостоятельно. Напоминаю, что через тот же UART можно осуществлять и программирование контроллеров, что очень удобно. Сначала прошить, и тут же смотреть отладочный вывод.
Возвращаясь к реализациям UART, стоит упомянуть еще RS485, заточенный под передачу на большие расстояния в условиях помех.
Часто в документации можно увидеть написание USART (universal synchronous — asynchronous receiver — transmitter). Оно отличается от UART (universal asynchronous receiver — transmitter) наличием собственно линии синхронизации. Правда, используется это довольно редко. Лично мне привычнее называть его именно UART, не вспоминая о синхронных возможностях.
Бывают и более экзотические варианты UART, но рассматривать мы их не будем. Для простого общения с контроллером нам хватит и базового.
4.1. Программирование UART
Как и в случае любой периферии, перед использованием на нее надо подать тактовый сигнал. В случае USART0 за это отвечает бит RCU_APB2ENR_USART0EN. Кроме того, поскольку UART задействует ноги ввода-вывода для своей работы, их необходимо настроить так, чтобы значение GPIOx_OCTL ему не мешало. Этот режим называется Alternate-function и кодируется битами 0b1011
. А вот ножка входа (RX) используется только для чтения и с OCTL не конфликтует, поэтому ее оставляют в режиме Input. Также работу альтернативных функций необходимо разрешить выставлением бита RCU_APB2ENR_AFEN.
После этого настраивается режим работы модуля. Настройка заключается в разрешении работы модуля (бит USART_CTL0_UEN), разрешении передачи (бит USART_CTL0_TEN) и приема (бит USART_CTL0_REN). После этого выставляется скорость обмена. Она определяется регистром USART_BAUD, который содержит делитель тактовой частоты (на самом деле, делитель частоты шины APB2. Поскольку система тактирвоания у нас сложная и развесистася, частота тактирования различных периферийных модулей может отличаться). В документации приведена страшная формула, содержащая целую и дробную части этого делителя, но на практике достаточно просто поделить тактовую частоту на скорость обмена. После этого в регистр USART_DATA можно записывать байт для передачи. В этот же регистр попадет байт, принятый с другой стороны.
Допустим, скорость UART мы выставили в 9600 бод, тогда как скорость ядра составляет 8 МГц. За время передачи одного байта пройдет более 8000 тактов. Поэтому нельзя записывать в USART_DATA байты на максимальной скорости — надо дожидаться, пока модуль очередной байт передаст. За это отвечает бит USART_STAT_TBE. Аналогично за прием байта отвечает бит USART_STAT_RBNE, который надо проверять чтобы определить был ли принят хоть один байт.
TLDR:
- Разрешить тактирование UART и AF: биты RCU_APB2ENR_USART0EN и RCU_APB2ENR_AFEN.
- Настроить выводы RX, TX на вход и альтернативный выход соответственно.
- Разрешить работу UART, передатчика и приемника: биты USART_CTL0_UEN, USART_CTL0_TEN и USART_CTL0_REN.
- Настроить скорость обмена: USART_BAUD как делитель тактовой частоты
- Передача:
- Дождаться бита USART_STAT_TBE
- Записать новый байт в USART_DATA
- Прием:
- Дождаться бита USART_STAT_RBNE
- Считать принятый байт из USART_DATA
Исходный код примера доступен на github
4.2. Отладка при помощи UART
Самый простой способ отладки: на компьютере запускается screen или другая утилита для обмена через COM-порт, для нее указывается имя порта (обычно что-то вроде /dev/ttyUSB0
) и скорость обмена (та же, что выставлена на устройстве). Далее компьютер и контроллер могут обмениваться обычными текстовыми строками. Удобно функции приема и передачи строк оформить в виде подпрограмм. Еще удобнее организовать буфер-очередь (FIFO), в который из основного кода писать (читать), а по прерываниям UART передавать данные через собственно интерфейс. Потенциально можно воспользоваться DMA, но для текстового протокола со строками неизвестной длины это будет не слишком удобно. Поэтому сразу после освоения прерываний переписываем функции UART на работу через очередь. Но я на этом акцентироваться больше не буду.
При использовании screen стоит помнить, что выйти из него не проще, чем из vim'а. Сначала нужно нажать ctrl+a, потом отпустить, и нажать k. Он выдаст запрос подтверждения выхода, и там надо нажать y. Еще раз: нажали ctrl, нажали a, отпустили a и ctrl, нажали k, отпустили k, нажали y, отпустили y. Подобным способом в screen вводятся и другие команды: переключение окон, разделение по вертикали и горизонтали и т.д. Очень удобно при подключении к удаленному компьютеру, когда доступ есть только по ssh.
4.3. Отправка строк и чисел
Напишем примитивный код передачи строки:
uart_puts:
li t0, USART0
UART_PUTS_WAIT: ; <─┬──┐
lw t1, USART_STAT(t0) ; │ │
andi t1, t1, USART_STAT_TBE ; │ │
beqz t1, UART_PUTS_WAIT ; ──┘ │
lb t1, 0(a0) ; │
addi a0, a0, 1 ; │
beqz t1, UART_PUTS_END ; ──┐ │
sb t1, USART_DATA(t0) ; │ │
j UART_PUTS_WAIT ; ──┼──┘
UART_PUTS_END: ; <─┘
ret
Последовательность символов для передачи указывается подпрограмме при помощи регистра a0, как и положено по соглашению, а ее конец — по нулевому байту, как принято в Си. Но где же хранить эту исходную строку? Казалось бы логичным использовать для этого сегмент данных .data, но в микроконтроллере строгое разделение по типам памяти, и .data располагается в оперативной памяти, которая сбрасывается при пропадании питания, а потом при старте не инициализируется. То есть при старте в ней будет лежать какой-то произвольный мусор. Простейший способ это обойти — использовать для хранения данных секцию кода, .text, как будто это не данные, а инструкции. Конечно, если туда перейдет управление и начнет выполнять наши строки, будет неприятно. Ну, значит, надо внимательнее следить за кодом.
la a0, TEXT_STR
call uart_puts
MAIN_LOOP:
li t0, 500000
sleep:
addi t0, t0, -1
bnez t0, sleep
j MAIN_LOOP
...
.text
TEXT_STR: .asciz "Hello from .text\r\n"
Другой вариант — инициализировать оперативную память вручную. Для этого напишем код вывода шестнадцатеричного числа. В памяти будет выделен буфер на 9 байт (8 цифр плюс терминирующий ноль), который будет последовательно заполняться, а после заполнения передаваться подпрограмме uart_puts для вывода.
.data
UART_PUT_BUF: .space 9
.text
uart_putx:
la t0, UART_PUT_BUF
sb zero, 8(t0)
li t1, 28
UART_PUTX_LOOP:
srl t2, a0, t1
andi t2, t2, 0xF
addi t3, t2, -10
bltz t3, UART_PUTX_09
addi t2, t3, 'A'-'0'
UART_PUTX_09:
addi t2, t2, '0'
sb t2, 0(t0)
addi t0, t0, 1
addi t1, t1, -4
bgez t1, UART_PUTX_LOOP
la a0, UART_PUT_BUF
j uart_puts
Но это все полумеры, нужно найти способ где-то хранить начальные данные, а при старте копировать их в оперативную память.
5. Организация памяти
5.1. Адреса и размеры сегментов
Способ одновременно разместить данные во флеш-памяти и зарезервировать под них место в оперативке, разумеется, есть, и чтобы им воспользоваться, рассмотрим устройство памяти нашего контроллера. В даташите приведены диапазоны адресов, отвечающих за флеш (0x0800 0000 — 0x0801 FFFF) и оперативную память (0x2000 0000 — 0x2001 7FFF). Нижняя граница одинакова для всех контроллеров семейства, а вот верхняя зависит от реально доступного объема. Впрочем, в контроллерах от других производителей, все может быть по-другому. Поэтому всегда проверяем по документации.
Чтобы не прописывать эти числа каждый раз в коде, придуман специальный файл с настройками компоновщика (linker), специальной программы, которая из него читает адреса и записывает соответствующие секции по ним. Этот файл специфичен для каждого контроллера и называться будет, например, gd32vf103cbt6.ld. Подключается он в случае gcc ключом -T.
MEMORY{
flash (rxai!w) : ORIGIN = 0x00000000, LENGTH = 128K
ram (wxa!ri) : ORIGIN = 0x20000000, LENGTH = 32K
}
SECTIONS{
.text : {
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > flash
.data : AT(ADDR(.text) + SIZEOF(.text)){
_data_start = .;
*(.data*)
. = ALIGN(4);
_data_end = .;
} > ram
.bss : {
_bss_start = .;
*(.bss*)
. = ALIGN(4);
_bss_end = .;
} > ram
}
PROVIDE(_stack_end = ORIGIN(ram) + LENGTH(ram));
PROVIDE(_data_load = LOADADDR(.data));
Здесь стоит отметить в первую очередь, что флеш начинается не с 0x0800'0000, а с 0x00000000. Дело в том, что при старте контроллера в зависимости от перемычек BOOT0, BOOT1 на нулевой адрес может отображаться флеш, оперативная память или загрузчик. Само собой, что в рабочем режиме там будет именно флеш, то есть одни и те же данные будут доступны как по адресу 0x0000'0000, так и 0x0800'0000. Собственно, именно этой особенностью я и пользуюсь при прошивке: отображаю на нулевой адрес начало загрузчика, перезагружаю контроллер, прошиваю, снова переключаю на нулевой адрес основной код и снова перезагружаю.
Вторая важная вещь из содержимого файла не очевидна, но секция .data содержится не только в оперативной памяти, но в во флеш. Правда, тайно: отдельного сегмента под нее там не отводится, и даже дизассемблированием ее просто так не получить. Тем не менее, из кода до нее добраться просто, по адресу _data_load. А вот объем не хранится нигде. Да он и не нужен, поскольку есть адреса начала и конца секции .data в оперативной памяти: _data_start и _data_end. Поэтому копирование данных из _data_load в _data_start можно вести пока не дойдем до адреса _data_end оперативки (не флеш!). Впрочем, при сильном желании данные .data можно найти и в бинарном файле, достаточно дизассемблировать бираник (именно бинарник, не эльфа), чтобы проигнорировать размещение по сегментам:
riscv64-unknown-elf-objdump -m riscv -b binary -D -S res/firmware.bin > res/firmware.lss
Третья вещь — наличие кучи посторонних секций. Секция .rodata отвечает за константные данные, read-only data, расположенные во флеш-памяти. Для ассемблера это не слишком критично: программист может и прямо в .text писать, но компиляторы придерживаются более строгих соглашений, и переменные, помеченные как const, размещают именно там. Секция .bss (Block Started by Symbol, что бы это ни значило) отвечает за хранение неинициализированных глобальных и статических переменных, начальное значение которых никому не нужно. В случае ассемблера это действительно так, и обнулять .bss не обязательно, но для языков высокого уровня опять накладываются более строгие ограничения. Тот же Си требует, чтобы значения всех неинициализированных глобальных данных было обнулено. Вероятно, этого требуют соображения безопасности. Мало ли какое на какие байты попадет переменная указателя, которую невнимательный программист забудет инициализировать. А потом будет читать или писать в непонятное место. В случае же нулевого значения, чтение по адресу NULL заканчивается закономерной и воспроизводимой ошибкой segfault. В нашем случае — исключением по доступу к памяти, о котором будем говорить в следующий раз. В случае наличия операционной системы, занулением .bss может заниматься она, чтобы программа случайно не получила доступ к данным той, что выполнялась на том же месте прежде.
Размещение оперативной памяти по адресам от 0x2000 0000
вызывает некоторые неудобства, ведь такие адреса не помещаются в опкод инструкций работы с памятью lw, sw
и прочих. Иначе говоря, для любого обращения к памяти, придется сначала зарузить адрес в какой-нибудь регистр. И, поскольку задача эта более чем типичная, разработчики RISC-V предложили соглашение, что для доступа к секции .data используется регистр gp (global pointer, он же обычный x3). В него записывают что-нибудь вроде 0x2000 0800
, а потом используют для доступа к памяти: _bb0: lbu s0,-1471(gp)
(пример из загрузчика v307)
Ну и четвертое — адрес _stack_end, указывающий, как несложно догадаться, на конец доступной памяти, то есть тот самый адрес, от которого растет стек.
Теперь, зная все это, можно написать код подготовки памяти: инициализировать sp, скопировать данные из _data_load в секцию .data и обнулить секцию .bss. После этого строка из .data будет выводиться не менее корректно, чем объявленная в .text или .rodata. Ну и стеком наконец-то можно будет пользоваться.
5.3. Стек
Собственно о стеке. Это такая структура данных поверх обычной оперативной памяти, которая позволяет последовательно складывать любое количество данных, а потом не менее последовательно снимать. В простейшем случае она используется для вложенного вызова функций. То есть функция поработала-поработала, сложила какие-то нужные данные на стек и вызвала другую функцию. Та ничего не знает про предыдущие данные и кладет на свободное место свои. Поработала-поработала, очистила свои данные и вернула управление предыдущей функции. Физически это организовано довольно просто: память выделяется от конца оперативной памяти вниз. Текущий указатель конца стека хранится в специальном регистре sp. На самом деле это обычный регистр x2, и теоретически, можно пользоваться и любым другим. Но если одна процедура будет пользоваться одним регистром, другая другим, рано или поздно все запутаются.
Проиллюстрирую работу стека таблицей ниже. Оперативной памяти в GD32VF103CBT6 в наличии 32 кБ
и начинается она с адреса 0x2000 0000
. А раз стек растет сверху вниз, изначально sp будет равен 0x2000 8000
. Чтобы положить на стек один байт, равный 0x12
сначала сдвигаем вершину стека (sp) на 1 байт вниз (теперь sp = 0x2000 7FFF
), потом кладем значение в память. В таблице это обозначено шагом 1. Потом подобным образом кладем значения 0x34
и 0x56
, это шаги 2 и 3. А теперь захотели снять значение со стека. Читаем lw t0, 0(sp)
и прибавляем к sp единицу. Обратите внимание, что само значение 0x56
в памяти осталось, просто "вывалилось" за пределы стека, и теперь, если мы вызовем какую-то подпрограмму, она будет иметь право его затереть своим.
Адрес | Шаг 0 | Шаг 1 | Шаг 2 | Шаг 3 | Шаг 4 |
---|---|---|---|---|---|
0x2000 8000 | < — sp | ||||
0x2000 7FFF | 0x12 <- sp | 0x12 | 0x12 | 0x12 | |
0x2000 7FFE | 0x34 <- sp | 0x34 | 0x34 <- sp | ||
0x2000 7FFD | 0x56 <- sp |
Поэтому при написании подпрограмм важно не залезать за пределы своего стека. Если при вызове в sp оказалось, скажем, значение 0x2000 7123
, то в ячейки от 0x2000 7123
до 0x2000 8000
писать нельзя. То же верно и в другую сторону: нельзя писать ниже sp, эта память считается ничейной, и любое возникшее прерывание (о которых поговорим в другой раз) имеет право размещать там свои данные. Поэтому сначала двигаем sp, потом пишем. Сначала читаем, потом двигаем sp. Ну и, естественно, надо следить, чтобы при возвращении в вызвавшую программу восстановить sp в то же состояние, в котором его получили. В некоторых конвенциях прямо рекомендуют первым делом скопировать sp в fp (frame pointer, он же x8, он же s0, будьте бдительны) и в дальнейшем этот fp не трогать. А в конце соответственно скопировать обратно. Мы же этим заморачиваться не будем: нет смысла писать на ассемблере настолько большие программы, чтобы нельзя было отследить все вручную. А при использовании языков высокого уровня, о стеке заботиться будут уже они. Тем не менее, понимать как все это работает внутри, все равно надо.
Ах да, еще один момент, который я упустил. Обычно стек используется не для хранения "просто чиселок", а для регистров, адресов возврата и прочих 32-битных значений. А адресация памяти у нас побайтная. Ну чтож, значит sp придется за раз двигать не на 1, а на 4. Причем, что приятно, мы не обязаны сохранять вообще все регистры, которыми пользуемся. Согласно соглашению RISC-V, часть регистров должна безболезненно переживать вызов подпрограммы (s0-s11), часть могут быть испорчена (t0-t6), часть используется для передачи аргументов в функцию и обратно (a0-a7), а часть вообще специальная (x0, ra, sp, ...). Соответственно, если программа хочет внутри себя воспользоваться каким-нибудь s3, s8 и s11, она должна сначала сохранить их на стеке, попользоваться, а потом — прочитать обратно. В коде это может выглядеть приблизительно так:
some_func:
addi sp, sp, -12
sw s3, 0(sp)
sw s8, 4(sp)
sw s11,8(sp)
...
что-то делаем
...
lw s11,8(sp)
lw s8, 4(sp)
lw s3, 0(sp)
addi sp, sp, 12
ret
Также не забываем, что в ra хранится наш адрес возврата, и если планируется с этим регистром что-то делать (например, вызывать другие функции), его тоже придется сохранить.
В порядке извращения я набросал макросы push и pop для произвольного количества регистров, авось пригодятся (спойлер: пригодятся, когда будем говорить о многозадачности).
.macro push regs:vararg
.equ _pushpop_cnt, 0
.irp idx, \regs
.equ _pushpop_cnt, (_pushpop_cnt-4)
.endr
addi sp, sp, _pushpop_cnt
.irp idx, \regs
.equ _pushpop_cnt, (_pushpop_cnt+4)
sw \idx, -_pushpop_cnt(sp)
.endr
.endm
.macro pop regs:vararg
.equ _pushpop_cnt, 0
.irp idx, \regs
.equ _pushpop_cnt, (_pushpop_cnt-4)
.endr
.equ _pushpop_cnt2, _pushpop_cnt
.irp idx, \regs
.equ _pushpop_cnt, (_pushpop_cnt+4)
lw \idx, -_pushpop_cnt(sp)
.endr
addi sp, sp, -_pushpop_cnt2
.endm
Исходный код примера доступен на github
Здесь мы рассмотрели единый общий стек. Но в более сложных системах стеков может быть несколько. Например, отдельный для ядра, отдельный для юзерского кода и отдельный для прерываний. Или вообще отдельный для каждого процесса. Но принцип остается тем же: вызванной подпрограмме передается некий начальный sp, от которого она имеет располагать свои временные данные.
Дополнительная информация
Комментарии (4)
SIWRX
02.12.2024 08:05Добрый день!
А почему вы используете ассемблер а не C?
COKPOWEHEU Автор
02.12.2024 08:05В учебных целях. Пока идет речь о внутреннем устройстве RISC-V и регистрах контроллера, ассемблер дает лучшее представление. Но скоро буду рассказывать и о Си. Зависит от того, уйдет ли на рассказ о прерываниях целая статья или хватит половины.
JackKatch
Спасибо за статью. Нет ли у вас информации по поводу того, когда происходит прерывание сигнализирующее что завершена передача по UART? Я считал что всегда в середине стопового бита и на микроконтроллерах SiLabs всегда так и было (как в аптеке). А на STM32 было замечено (допускаю что я ошибся) что прерывание произвольно смещается от момента середины стопового бита. Интересно как с этим в RISC-V.
COKPOWEHEU Автор
Точно не проверял. Возможно, зависит от настроек количества стоповых битов. Хотя, кажется, в документации проскакивало, что количество стоповых битов работает только на передачу. Упоминаний о точном времени возникновения прерывания не видел. Так что только тестировать и надеяться, что во всех контроллерах будет одинаково.