Макетная плата GD32VF103


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


Нечто подобное, пусть и в меньшей мере, реализовано и во многих микроконтроллерах. В частности, в CH32V303. Давайте рассмотрим, какие ограничения можно выставить в этом микроконтроллере, каким способом и для чего они нужны.


Часть 1. Введение


Часть 2. Память и UART


Часть 3. Прерывания


Часть 4. Си и таймеры


Часть 5. DMA


Часть 6. FPU


Часть 7. АЦП


14 Зачем вообще нужны права доступа


Начнем с того, что в более примитивных контроллерах вроде AVR или PIC, никаких прав доступа нет. Любой участок кода может получить доступ к любому ресурсу. Но с увеличением мощности (как памяти программ, так и быстродействия) микроконтроллеры стали приближаться к полноценным десктопным системам. Появляется многозадачность, сторонние программы и тому подобное. Появляется код, которому мы не вполне доверяем. Причем речь даже не идет о сознательном вредительстве: банальная опечатка, и код начинет писать не в ту область памяти, которая ему отведена. Особенно печально, если он начинает писать в область, зарезервированную «операционной системой» под свои нужды.


В десктопных системах эта проблема известна давно, и для ее решения придуманы аппаратные методы. В RISC-V код может выполняться на одном из трех с половиной уровней привилегий. Наименее приоритетный — U-mode, пользовательский, на нем выполняются все пользовательские программы. Чуть выше стоит S-mode, supervisor, на нем выполняется код операционной системы. Он может выставлять юзерскому коду ограничения на доступ к памяти, время выполнения и прочие ресурсы. Самый приоритетный уровень — M-mode, machine. На нем контроллер стартует, инициализирует основную периферию и основные ограничения, после чего сбрасывает себе привилегии до более низких уровней и передает управление соответствующему коду. Еще по умолчанию именно на M-mode работают обработчики прерываний¹.


В полноценных ОС все обработчики прерываний привязаны к драйверам соответствующей периферии. Соответственно, им нужны права на доступ к ее регистрам, которые длы U-mode выдавать не стоит. Вдруг несколько процессов захотят одновременно записать что-нибудь в одну и ту же периферию, передерутся за нее. Но и выносить драйвер за пределы операционной системы (S-mode) тоже особого смысла нет. Поэтому в RISC-V предусмотрен механизм делегирования, когда M-mode пробрасывает обработку прерываний на один из более низких уровней.


И осталась еще половинка уровня, о которой я не рассказал — H-mode (hypervisor). Дело в том, что до конца он до сих пор не специфицирован, поэтому он как бы есть, но по сути нету. По идее, он предназначен для работы гипервизоров, управляющих виртуальными машинами.


В CH32V303, как и в большинстве ему подобных контроллеров, реализовано только два уровня: M-mode, на котором он стартует и обрабатывает прерывания, и U-mode, на котором крутится обычный код.


¹) Здесь и далее под «прерыванием» я понимаю исключительные ситуации вообще. И прерывания, и исключения. В контексте работы с уровнями привилегий разница между ними несущественна.


14.1 Смена прав доступа


Простого способа узнать на каком уровне привилегий выполняется код, не существует. Это сделано для того, чтобы получить возможность виртуализации. Код будет думать, что он M-mode, обращаться к системным CSR, к периферии, к таймерам, к памяти — но хост-система будет на каждое такое обращение ловить exception и эмулировать ответ, который ожидался бы от реального железа.


Но у нас-то в контроллере есть доступ не только к юзерскому коду, но и к машинному. Поэтому просто будем пытаться выполнить те или иные действия и ловить исключения. В таблице прерываний их три: 3 (HardFault), 5 (ecall-M) и 8 (ecall-U). То есть мы можем вызвать ecall и посмотреть, какое из исключений вызовется.


_vector_base:
    .option norvc;
    .word   _start
    .word   0
    .word   NMI_Handler                /* NMI */
.word trap_hardfault   HardFault_Handler          /* Hard Fault */
    .word   0
.word trap_ecall_M   Ecall_M_Mode_Handler       /* Ecall M Mode */
    .word   0
    .word   0
.word trap_ecall_U   Ecall_U_Mode_Handler       /* Ecall U Mode */

...

.text
.align 2
trap_ecall_M:
  push t0, t1, a0, ra

  uart_puts_P "<--- ecall_M\r\n"

  csrr t0, mepc
  addi t0, t0, 4
  csrw mepc, t0

  pop t0, t1, a0, ra
mret

trap_ecall_U:
  push t0, t1, a0, ra

  uart_puts_P "<--- ecall_U\r\n"

  csrr t0, mepc
  addi t0, t0, 4
  csrw mepc, t0

  pop t0, t1, a0, ra
mret

trap_hardfault:
  push t0, t1, a0, ra

  uart_puts_P " HardFault"

  csrr t0, mepc
  addi t0, t0, 4
  csrw mepc, t0

  pop t0, t1, a0, ra
mret

Если мы вызовем ecall, то получим в UART запись "<--- ecall_M".


Как я уже говорил, если во время выполнения U-кода возникло прерывание или исключение, оно будет выполняться на M-mode. То есть железо поднимет привилегии, выполнит обработчик, после чего сбросит привилегии обратно. Собственно, это и есть единственный механизм изменения прав доступа. Вызов прерывания или исключения, чтобы их поднять, и mret, чтобы понизить. И, по сути, это основное назначение инструкции ecall — просьба юзерского кода к операционной системе выполнить какой-либо код, который юзеру выполнять запрещено. Разумеется, не любой код, а только тот, который в операционной системе прописан, тщательно протестирован и обмазан проверками.


С повышением привилегий разобрались. Теперь о том, как их понизить. То, что для этого будет использоваться mret, понятно, но просто ее вызвать недостаточно. Прерывание ведь могло возникнуть не только на U-mode, но и на M-mode, и на любом другом. То есть на самом деле привилегии надо не «понизить», а «восстановить». До того уровня, на котором возникло прерывание. Для этого служит уже знакомый нам регистр mstatus. До сего момента мы в нем пользовались единственным битом MIE. Теперь же рассмотрим еще два: MPP и MPIE.


Бит MPIE показывает, были ли разрешены прерывания во время возникновения данного. Ну мало ли, возникло немаскируемое прерывание, или просто прерывание с более высоким приоритетом. А MPP — пара битов, как раз и хранящая уровень привилегий прерванного кода. В нашем случае возможных комбинаций всего две: 0b00 — U-mode, 0b11 — M-mode. Соответственно, при возникновении прерывания MIE и уровень привилегий сохраняются в MPIE и MPP, а при возврате — восстанавливаются.


И вот тут мы можем схитрить, ведь mstatus доступен на запись. Сделаем вид, что мы и так в прерывании. Запишем в mepc «адрес возврата», в mstatus — нужные права доступа и сделам mret.


.equ MIE,   (1<<3)
.equ MPIE,  (1<<7)
.equ MPP_U, (0b00<<11)
.equ MPP_M, (0b11<<11)

...

  li t0, MPP_U | MPIE | MIE
  csrw mstatus, t0
  la t0, main
  csrw mepc, t0
  mret

Если присмотреться к стартап-файлу от WCH, там можно увидеть ровно такую же картину. Правда, китайцы записывают туда 0x6088. Видимо, им удобнее работать с шестнадцатеричными числами, чем с именованными константами.


Теперь вызов ecall до смены привилегий приводит, как и раньше, к выводу "ecall: <--- ecall_M", а вот после — "ecall: <--- ecall_U". То есть мы и правда сумели понизить себе привилегии до юзерских.


14.2 CSR-регистры


Первое же ограничение, которое бросается в глаза при переходе на U-mode — это невозможность работы с привычными CSR-регистрами. Согласитесь, было бы странно, если бы юзерскому коду было разрешено тоже провернуть трюк с mstatus + mret. Нет, при попытке что-либо записать в mstatus или mepc, нас ждет только HardFault. Мы уже написали обработчик этого исключения, и теперь можем понаблюдать, как юзерский код пытается выполнить невыполнимое:


  uart_puts_P "ecall: "
  ecall

  uart_puts_P "csrr mstatus: "
  csrr t0, mstatus
  uart_puts_P "\r\n"

ecall: <--- ecall_U
csrr mstatus:  HardFault

Впрочем, некоторые CSR-регистры юзерскому коду все-таки доступны — а именно семейство u-регистров. Если посмотреть в соответствующую таблицу, можно увидеть, что все CSR-регистры разделены по доступу (read, write) и по уровню привилегий. То есть если 8,9 биты равны 0b00 — это юзерские регистры, и только к ним имеет доступ юзерский код. Если биты равны 0b01 — это супервизорские регистры, и супервизорский код может обращаться к ним, плюс к юзерским. Биты 0b10 соответствуют гипервизорским, и гипервизорский код может обращаться к ним, а также к супервизорским и юзерским. Ну и наконец 0b11 — машинные регистры. К ним может обращаться только M-mode. Впрочем, M-mode может обращаться вообще ко всем CSR-регистрам.


Рис.1 Таблица CSR-регистров


Правда, u-регистров в QingKe немного: floating-point регистры да gintenr с intsyscr. Это, кстати, интересно: разработчики пробросили работу с прерываниями с M-mode на U-mode при помощи вот таких кастомных регистров. Логика в этом есть: в микроконтроллерах нередки ситуации, критичные к выдерживанию временных интервалов. А прерывания их сбивают. Вот и добавили возможность обычному юзерскому коду на время запретить прерывания, сделать свои критичные вещи, и разрешить обратно. Хотя, конечно, это несколько снижает надежность.


14.3 Доступ к памяти, PMP


А что на счет доступа к периферии или памяти чужого процесса, который тоже хотелось бы предотвратить? Для этого существует немного другой механизм, Physical Memory Protection, PMP (он же Memory Protection Unit, MPU). Он позволяет задавать отдельные области памяти и назначать им те или иные права доступа.


Для этого служат CSR-регистр pmpcfg0 (в более сложных системах существуют, наверняка и pmpcfg1, и pmpcfg2, и другие, но у нас — нет) и группа pmpaddr0, pmpaddr1, pmpaddr2, pmpaddr3. Как видно из названия, pmpaddr задают адреса блоков, а pmpcfg — настройки. Причем pmpcfg0 разделен на четыре байта: биты 0—7 отвечают за pmpaddr0, биты 8—15 за pmpaddr1 и так далее.


Внутри каждого байта pmpcfg0 седьмой бит отвечает за блокировку, то есть защиту от изменений. Он нам неинтересен, его можно не трогать. Биты 0 — 2 это как раз права доступа, обычные rwx, как и в десктопных системах. 0 означает запрет, 1 — разрешение. И остались самые интересные биты: 3, 4. Они отвечают за способ кодирования адреса. Комбинация 0b00 — «ничего», то есть данный pmpaddr не используется. 0b01 — «TOR (top of range)». Диапазон адресов кодируется парой pmpcfgᵢ₋₁… pmpcfgᵢ. То есть используется не только текущий pmpaddr, но и предыдущий. 0b10 — «NA4 (naturally aligned 4-byte region)». Вся область — одно 4-байтное слово. И 0b11 — «NAPOT (2ᴳ⁺²-byte region, G≥1)», область переменного размера, причем и адрес, и размер кодируются в pmpaddr. Чуть позже мы рассмотрим, как это выглядит в коде, но сначала напишем подпрограмму проверки того, какие из адресов в данном диапазоне доступны на чтение:


#a0...a1 - mem range
memtest:
  push s0, s1, s2
  mv s0, a0
  mv s1, a1
  mv s2, ra
memtest_loop:
    bgeu s0, s1, memtest_end
  mv a0, s0
  jal uart_putx
  lb t0, 0(s0)
  uart_puts_P "\r\n"
  addi s0 ,s0, 1
    j memtest_loop
memtest_end:
  mv ra, s2
  pop s0, s1, s2
ret

Здесь сначала выводится адрес очередной ячейки памяти, потом код пытается ее прочитать и выводит перевод строки. Если при чтении возникла ошибка, обработчик исключений вставит еще "HardFault" в текст. В общем, код максимально простой. Можно проверить, как он работает при доступе к обычной ОЗУ и как к области пустоты, не принадлежащей никакой реальной памяти. Очевидно, что, пока мы не настроили PMP, чтение из M-mode и из U-mode различаться не будет. Давайте это исправим.


14.4 Доступ 0b10, NA4


Начнем с наиболее простого режима, NA4. В pmpcfg0 записываем (((0b10<<3) | (0b000<<0))<<0), то есть тот самый режим NA4 и запрет на любой доступ. В pmpaddr0 записываем адрес слова. Пусть будет 0x20004000, это где-то посередине между .data и стеком, случайно наш простенький код туда попасть не должен. Важный момент: адресация в PMP не побайтная, а пословная, и два младших бита не хранятся. Поэтому адрес нужно сдвинуть на два бита вправо. Теоретически, это позволяет управлять доступом не к 32-битному, а к 36-битному адресному пространству… Хотя, каким образом эту возможность реализовать, я не имею ни малейшего представления. Остальные-то команды оперируют 32-битными адресами. Ну да ладно.


  li t0, (0x20004000 >> 2)
  csrw pmpaddr0, t0

  li t0, (((0b10<<3) | (0b000<<0))<<0)
  csrw pmpcfg0, t0

Теперь попробуем прочитать память сначала из M-mode (до того, как провернули фокус с mstatus + mret), а потом из U-mode:


  uart_puts_P "lw 0x20004000: "
  li t0, 0x20004000
  lw t0, 0(t0)
  uart_puts_P "\r\n"

  li t0, MPP_U | MPIE | MIE
  csrw mstatus, t0
  la t0, main
  csrw mepc, t0
  mret

  ...

main:
  uart_puts_P "\r\nU-mode:\r\n"

  uart_puts_P "reading memory: \r\n"
  li a0, 0x20003FFE
  li a1, 0x20004010
  jal memtest

В первом случае память прочтется без проблем, а во втором на адресах 0x20004000, 20004001, 20004002, 20004003 возникнет HardFault, остальные же адреса прочтутся нормально. Это означает две вещи. Во-первых, PMP работает. Во-вторых, M-mode плевать на него хотел. Что-ж, опять все как в десктопных ОС.


14.5 Доступ 0b01, TOR


Теперь попробуем задать диапазон адресов. Очевидно, что для этого понадобится уже два регистра pmpaddr: один будет хранить начало диапазона, второй — конец. Ну а в pmpcfg у первого запишем ноль (он ведь не используется сам по себе), а у второго — осмысленные настройки:


  li t0, (0x20004000 >> 2)
  csrw pmpaddr0, t0
  li t0, (0x20004008 >> 2)
  csrw pmpaddr1, t0

  li t0, (((0b01<<3) | (0b000<<0))<<8)  |  (((0b00<<3) | (0b000<<0))<<0)
  csrw pmpcfg0, t0

Проверка подтверждает, что M-mode, как и раньше, напрочь игнорирует все эти права, а вот для U-mode закрылись адреса с 0x20004000 до 0x20004008. Отлично, этот режим тоже работает.


14.6 Доступ 0b11, NAPOT


Этот алгоритм доступа наиболее странный. Здесь регистр pmpaddr делится на две части: начиная с нулевого бита идет непрерывная последовательность единиц, потом разделительный ноль и в конце собственно адрес. В документации это описано такой табличкой:


pmpaddr pmpcfg.A Match base address and size
yyyy...yyyy NA4 “yy...yyyy00” as base address, 4-byte protection region range
yyyy...yyy0 NAPOT “yy...yyy000” as base address, 8-byte protection region range
yyyy...yy01 NAPOT “yy...yy0000” as base address, 16-byte protection region range
yyyy...y011 NAPOT “yy...y00000” as base address, 32-byte protection region range
... ...
yyy01...111 NAPOT “y0...000000” as base address, 231-byte protection region range
yy011...111 NAPOT 2³²-byte protection region range

Как видно, можно задавать область размером от 8 до 2³² байт (до 4 ГБ), но только по степеням двойки и выровненную на размер этих самых областей.


Чтобы сделать настройку размера чуть менее противоестественной, я написал небольшой макрос:


  #define PMP_PAGE(addr, pwr2) ( ( (addr &~ ((1<<pwr2)-1)) | ((1<<(pwr2-1))-1) )>>2 )
  li t0, PMP_PAGE(0x20004000, 3) # 2^3 = 8
  csrw pmpaddr0, t0

  li t0, (((0b11<<3) | (0b000<<0))<<0)
  csrw pmpcfg0, t0

И да, как несложно догадаться, такой способ указания прав доступа тоже работает.


14.7 Пересечение областей


Но ведь области могут перекрываться. Давайте проверим, к чему это приведет:


  li t0, (0x20004004 >> 2)
  csrw pmpaddr0, t0

  li t0, PMP_PAGE(0x20004000, 4) # 2^4 = 16
  csrw pmpaddr1, t0

  li t0,  (((0b11<<3) | (0b000<<0))<<8) | (((0b10<<3) | (0b111<<0))<<0)
  csrw pmpcfg0, t0

Здесь нулевая область задана как 4 байта от 0x20004004 до 0x20004007 с полным доступом, а первая область — как 8 байт от 0x20004000 до 0x2000400F с запретом на все.


Рис.2 Скриншот пересечения областей


Результатом будет область от 0x20004000 до 0x2000400F, запрещенная на чтение, запись и выполнение за исключением одного слова от 0x20004004. А вот если поменять местами pmpaddr0 и pmpaddr1, никаких «дырок» не будет, из чего можно сделать вывод, что приоритет отдается области с меньшим номером. В принципе, это же можно было прочитать и в документации:


The Qingke V4 microprocessor supports protection of several regions. When the same operation matches several regions at the same time, the region with a smaller number is preferentially matched.

Но это ведь не так интересно, правда?


14.8 А что с GD32VF103?


А там вообще нет защиты памяти, хоть пиши пословно с 0xFFFF FFFF, слова никто не скажет. Соответственно, PMP регистры просто-напросто игнорируются. Зачем их туда вводили — неизвестно.


С 1921вг015 еще веселее: там не работает способ понижения привилегий через mstatus + mret, а в документации про это тишина. Вроде бы U-mode есть, но как его включить — непонятно.


Заключение


Вот мы в общих чертах и познакомились с системой разграничения прав доступа в CH32V303 и, шире, в RISC-V. Ее можно использовать в операционных системах, чтобы не дать одному процессу случайно испортить память другого. Можно вообще запретить непривилегированным процессам доступ к MMIO. А можно и наоборот: эмулировать, как будто доступ есть, но на самом деле записывать нарушителей в черный блокнотик не делать ничего. Таким же способом можно организовать виртуальную машину. Правда, MMU, Memory Management Unit у нас нет, а без него это будет довольно медленно и неэффективно.


Также имеет смысл выставить на флеш права r_x (хотя туда и без того писать бесполезно, но хоть исключение возникнет), на оперативку и MMIO rw_, а на все остальное ___ (чтобы разыменование NULL, да и вообще доступ по неправильным адресам, вызывали ошибку). Примерно так же, хотя и гораздо сложнее, делается и в десктопных системах: область кода можно читать и выполнять, а область памяти выполнять нельзя, зато можно писать.


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


Исходный код доступен на Github.


CC BY 4.0

Комментарии (0)