Во время работы в ASIC дизайн центре я потратил немало времени на отладку ошибок и падений ядра, просматривая временные диаграммы на шинах AXI от процессора к памяти. Иногда оказывалось, что адрес чтения из памяти оказывался 0x00000000 - классический случай разыменования нулевого указателя в C. На системах с ОС это приводит к известному всем C программистам segfault-у. И в bare metal системах разыменование NULL может приводить к интересным ситуациям. В этой статье рассмотрим, что происходит при разыменовании NULL, используя для пример open source RISC-V ядро scr1 и open source инструмент симуляции RTL Verilator.

Подготовка

Все манипуляции со симулированием ядра в этой статье производились на машине с ОС Ubuntu 22.04. Были использованы следующие программы и проекты:

Verilator - ПО для симуляции исходников ядра. Преобразует код HDL в C++ код и исполняемый файл. Инструкия по установке на сайте проекта: https://verilator.org/guide/latest/install.html . Необходимо установить версию не ниже 4.102 для поддержки оператора `|=>`, который используется в исходном коде scr1. В репозитории Ubuntu 22.04 представлена версия 4.038, поэтому я собрал стабильный релиз из исходников с GitHub.

scr1 - непосредственно исходный код ядра на HDL System Verilog. Включает в себя также примеры программ на C, чтобы было что исполнять на процессоре для тестов.

git clone https://github.com/syntacore/scr1.git

RISC-V GCC toolchain - набор программ для компилирования С кода и сборки прошивки для RISC-V ядра. Бинарники тулчейна можно скачать с сайта Syntacore https://syntacore.com/tools/development-tools и указать до них путь:

export PATH=<GCC_INSTALL_PATH>/bin:$PATH

GTKwave - ПО для визуализации временных диаграмм, полученных в результате симуляции. https://gtkwave.sourceforge.net/.

Запуск Hello World симуляции

В Git репозитории scr1 в директории`sim/tests есть тестовые примеры программ, которые будут исполняться на симулируемом ядре. Начнем с простого теста hello. В этом примере ядро “печатает” строку`"Hello from SCR1!\n". Печать происходит в виде записи в специальный адрес памяти 0xF0000000 - SCR1_SIM_PRINT_ADDR, при записи в этот адрес 8-битного значения, соответствующий ему символ будет распечатан в консоли тестбенча. Чтобы было удобнее анализировать временные диаграммы и ассемблерные листинги в файл common.mk к флагам компиляции я добавил флаг -fno-inline, так функции не будут инлайниться.

Для запуска симуляции, используя Verilator и сохраняя вейвформы с тестом hello, можно использовать следующую команду:

make run_verilator_wf CFG=MAX BUS=AXI TARGETS="hello" TRACE=1

В консоли будет выведена эта "Hello World!" строка:

---Test:                    	hello.hex
Hello from SCR1!
Test passed

#--------------------------------------
# Summary: 1/1 tests passed
#--------------------------------------

В директории build/{} с названием соответствующим использованной командой (в данном случае verilator_wf_AXI_MAX_imc_IPIC_1_TCM_1_VIRQ_1_TRACE_1) будут лежать результаты компиляции и симуляции.

Временные диаграммы сохранены в файле simx.vcd. Сейчас нас интересует как происходит печать байтов из буфера через специальный адрес памяти в консоль тестбенча. Чтобы посмотреть как выглядит эта печать, откроем файл в GTKwave и выведем сигналы на диаграмму.

Временные диаграммы записи в память из примера hello.
Временные диаграммы записи в память из примера hello.

На диаграмме показаны сигналы AXI4 интерфейса для записи в память данных. Согласно спецификации scr1 (SCR1 External Architecture Specification) префикс io_axi_dmem относится к сигналам обращения к памяти данных. Сигнал awaddr - адрес по которому происходит запись, wdata - данные для записи, на рисунке представлены в формате ASCII символа. Когда сигналы wvalid и wready в единице одновременно происходит непосредственно запись данных с сигнала wdata по адресу из awaddr. Эти сигналы по иерархии расположены в топе тестбенча scr1_top_tb_axi.

По выставлению wvalid в 1 можно увидеть, что в память записывается последовательно символы H, e, l и так далее из строки "Hello from SCR1!\n", которую в примере и печатают.

Также на скриншоте с временной диаграммой выведено значение текущего регистра счетчика команд - program counter curr_pc. По его значению можно примерно понять, где в памяти программ сейчас находится процессор, какие инструкции исполняет и в какой функции. Этот сигнал находится глубже по иерархии, в конвейере процессора (core pipeline), в  scr1_top_tb_axi/i_top/i_core_top/i_pipe_top

В файле hello.dump сохранен ассемблерный листинг программы. Непосредственно  печать строки (запись байтов из буфера в специальный адрес памяти) выполняет функция sc_puts из файла sc_print.c. В моем случае эта функция была помещена по адресу 0x00480000 в RAM памяти. Здесь и пригодится -fno-inline т.к. вызовы этих функций и переход к адресам, где расположен их код будет явным.

00480000 <sc_puts>:
  480000:	87aa                	mv	a5,a0
  480002:	0ab05f63          	blez	a1,4800c0 <sc_puts+0xc0>
  480006:	0075f693          	andi	a3,a1,7
  48000a:	f0000737          	lui	a4,0xf0000

Непосредственно запись по адресу SCR1_SIM_PRINT_ADDR происходит здесь, по адресу 0x0048006E, как и было видно по временной диаграмме:

 480068: 0007c503            lbu a0,0(a5)
 48006c: 0785                  addi  a5,a5,1
 48006e: 00a70023            sb  a0,0(a4)
 480072: 04b78e63            beq a5,a1,4800ce <sc_puts+0xce>

Инструкция sb a0,0(a4) - Store Byte - записывает один наименее значащий байт из регистра a0 в память по адресу из регистра a4. В регистре a4 в начале функции положили значение 0xF0000000 инструкцией lui a4,0xf0000. А значение в регистр a0 кладется из переданного в функцию по указателю буфера lbu a0,0(a5).

Проверка успешности или неуспешности прохождения теста определяется тестбенчем через проверку значения в регистрах Multi-Port Register File, запись в который происходит, если случаются какие-то исключения. При нормальной работе процессор выходит из main (и возвращается в _start) и переходит к выполнению функции sc_exit, которая завершает симуляцию записью в специальный адрес.

Разыменование NULL

Теория

Для начала посмотрим, что говорит стандарт C о нулевом указателе и его разыменовывании. Рассмотрим стандарт С17, а точнее draft N2310 https://www.open-std.org/JTC1/SC22/WG14/www/docs/n2310.pdf 

В разделе "6.3.2.3 Pointers" в абзаце 3 определяется, что нулевой указатель (null pointer) это приведенный к void* целочисленное значение 0. И в сноске приводится, что в файле stddef.h определен макрос NULL, раскрывающийся в нулевой указатель.

6.3.2.3 Pointers

An integer constant expression with the value 0, or such an expression cast to type void *, is called a null pointer constant.67) If a null pointer constant is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function.

  1. The macro NULL is defined in <stddef.h> (and other headers) as a null pointer constant; see 7.19.

Сноска 67: В заголовочном файле stddef.h (находится в riscv-gcc/lib/gcc/riscv64-unknown-elf/13.2.0/include/) NULL определен как #define NULL ((void *)0) т.е. указатель на данные любого типа по адресу 0.

Разыменование нулевого указателя это неопределенное поведение (undefined behaviour) как следует из раздела 6.5.3.2 Address and indirection operators об унарной операции * разыменования и сноске к нему.

If an invalid value has been assigned to the pointer, the behavior of the unary * operator is undefined.

Among the invalid values for dereferencing a pointer by the unary * operator are a null pointer, …

С точки зрения компилятора неопределенное поведение означает ситуацию, которая никогда не происходит, а значит компилятор может делать что угодно, включая удаление кода, опираясь на это предположение.

Для нас это значит, что разыменовать на самом деле NULL не так-то просто. Потребуются средства, которые заставят компилятор не удалять плохой и/или ничего не делающий код. При компиляции включена оптимизация, флагом компилятора -O3, поэтому компилятор будет пользоваться всеми своими возможностями.

Практика

Начнем с простого разыменования прямо в main. Для борьбы с оптимизациями компилятора воспользуемся квалификатором типа volatile. Использование volatile запрещает компилятору оптимизировать чтения и записи таких объектов. Из раздел 6.7.3 Type qualifiers абзаца 8:

An object that has volatile-qualified type may be modified in ways unknown to the implementation or have other unknown side effects. Therefore any expression referring to such an object shall be evaluated strictly according to the rules of the abstract machine, as described in 5.1.2.3.

Попробуем наивно это сделать прямо в main. Подключим необходимые заголовочные файлы и поменяем код примера на разыменовывание NULL. Добавим вместо к типу указателя и переменной volatile:

int main()
{
   volatile uint32_t a = *(volatile uint32_t*) NULL;
   return 0;
}

Ассемблер функции main выглядит так:

00480006 <main>:
 480006: 00002783            lw  a5,0(zero)
 48000a: 9002                  ebreak

В ассемблере есть реальное чтение из памяти по адресу 0 - это инструкция lw  a5,0(zero). Она читает 4-х байтное слово из памяти из адреса 0 и кладет его в регистр a5.

Инструкция ebreak - это инструкция передачи управления отладчику - программное исключение. Т.е. компилятор увидел, что мы пытаемся разыменовать NULL, сказал “так нельзя” и вставил программный вызов исключения. То, что gcc ставит __builtin_trap после разыменования NULL указано явно в описании этой опции оптимизации и в исходном коде__builtin_trap - это инструкция вызывающая исключение на RISC-V это ebreak: ссылка.

На временных диаграммах рассмотрим сигналы чтения по шине AXI4 из памяти данных, регистр program counter pc и регистры контроля и статуса mepc и mcause с адресом инструкции, на которой произошло исключение и информацией об исключении. Взять их можно из scr1_top_tb_axi/i_top/i_core_top/i_pipe_top/i_pipe_csr.

Временные диаграммы разыменования NULL с программным исключением.
Временные диаграммы разыменования NULL с программным исключением.

На диаграмме видна любопытная особенность. Само по себе чтение из нулевого адреса не вызывает исключения. Значение mepc совпадает с адресом инструкции ebreak в листинге main (значение с диаграммы надо сдвинуть влево на 1 бит). В mcause кладется код 3, что означает Breakpoint. Значит выполняется инструкция ebreak и происходит программное исключение и его обработка. Железо же позволило нам прочитать из NULL без каких-то проблем.

Попробуем уговорить компилятор, чтобы он не вставлял Можно при компиляции использовать флаг -fno-delete-null-pointer-checks и тогда при оптимизации не будут удалятся разыменования NULL и не будут добавляться вызовы исключений.

Мы обойдемся без этой опции. Напишем пример, близкий к реальному коду в bare metal проектах - функцию, принимающую volatile указатель на одно из memory-mapped устройств микроконтроллера, “забудем” про проверку на NULL и случайно передадим NULL в эту функцию.

Кстати, если наивное разыменование NULL поместить внутрь функции и попробовать вызвать эту функцию, то ничего не получится. Компилятор удалит вызов этой функции, ведь в ней UB, а UB быть не может, значит функция на самом деле никогда не вызывается.

void simple() {
   volatile uint32_t a = *(volatile uint32_t*) NULL;
}


void foo(volatile uint32_t* p) {
   volatile uint32_t a = *p;
}


int main()
{
   simple();
   foo(NULL);
   return 0;
}

В функциях main и foo компилятор не смог доказать, что происходит разыменование NULL и скомпилировал все честно. Функция main кладет в в регистр a0 число 0, инструкцией li  a0,0. По соглашению вызовов RISC-V это передача аргумента вызываемой функции. Инструкция jal 480006 <foo> вызывает функцию foo и ей оказыается переданным в качестве аргумента нулевой указатель. В функции foo происходит чтение из переданного ей адреса: lw  a5,0(a0).

00480000 <simple>:
 480000: 00002783            lw  a5,0(zero) # 0 <CL_SIZE-0x20>
 480004: 9002                  ebreak


00480006 <foo>:
 480006: 411c                  lw  a5,0(a0)
 480008: 1141                  addi  sp,sp,-16
 48000a: c63e                  sw  a5,12(sp)
 48000c: 0141                  addi  sp,sp,16
 48000e: 8082                  ret


00480010 <main>:
 480010: 1141                  addi  sp,sp,-16
 480012: 4501                  li  a0,0
 480014: c606                  sw  ra,12(sp)
 480016: 3fc5                  jal 480006 <foo>
 480018: 40b2                  lw  ra,12(sp)
 48001a: 4501                  li  a0,0
 48001c: 0141                  addi  sp,sp,16
 48001e: 8082                  ret

На временных диаграммах видно успешное разыменование NULL, чтение из адреса 0, продолжение выполнения программы без каких либо исключений и брейкпоинтов, и успешное завершение симуляции. “Забытая” проверка на NULL скорее всего обнаружена не будет.

Временные диаграммы успешного разыменования NULL.
Временные диаграммы успешного разыменования NULL.

Load and store access fault

В спецификации RISC-V представлены механизмы сообщения проблем при доступе к памяти - исключения load access fault и store access fault. В ядре scr1 за обращения к памяти отвечает модуль LSU - load-store unit. 

LSU передает в модуль исполнения EXU (Execution Unit) код ошибки load/store access fault, если сигнал dmem_resp_er выставляется в 1.

-- src/core/pipeline/scr1_pipe_lsu.sv:212
always_comb begin
   case (1'b1)
       dmem_resp_er     : lsu2exu_exc_code_o = lsu_cmd_ff_load  ? SCR1_EXC_CODE_LD_ACCESS_FAULT
                                             : lsu_cmd_ff_store ? SCR1_EXC_CODE_ST_ACCESS_FAULT
                                                                : SCR1_EXC_CODE_INSTR_MISALIGN;

Сигнал dmem_resp_er выставляется в 1, когда память отвечает на запрос кодом ошибки.

-- src/core/pipeline/scr1_pipe_lsu.sv:115
assign dmem_resp_er       = (dmem2lsu_resp_i == SCR1_MEM_RESP_RDY_ER);

Распределением запросов от процессора к памяти занимается роутер DMEM router. Он же и назначает сигнал ответа от памяти. Роутер отправляет запрос на один из 3-х портов в зависимости от адреса в запросе. Идут проверки на попадание адреса в диапазон в TCM память и в memory-mapped таймер. Все адреса, которые не проходят эти проверки отправляются в AXI4 интерфейс.

Реализуем адрес 0x00000000 недоступным для чтения и записи. Для этого добавим в роутер ещё один порт - SCR1_PORT_INVALID, в который будет отправляться адрес 0 и который будет возвращать ошибку. Полный код с изменениями можно посмотреть здесь: https://github.com/DuzaBF/scr1/pull/1/files 

В исходном коде роутера src/top/scr1_dmem_router.sv добавим значение enum-а для невалидного порта и проверку на адрес 0 при выборе порта:

-- src/top/scr1_dmem_router.sv:89
always_comb begin
   port_sel    = SCR1_SEL_PORT0;
   if ((dmem_addr & SCR1_PORT1_ADDR_MASK) == SCR1_PORT1_ADDR_PATTERN) begin
       port_sel    = SCR1_SEL_PORT1;
   end else if ((dmem_addr & SCR1_PORT2_ADDR_MASK) == SCR1_PORT2_ADDR_PATTERN) begin
       port_sel    = SCR1_SEL_PORT2;
   end else if (dmem_addr == 32'b0) begin
       port_sel    = SCR1_SEL_INVALID;
   end
end

Хоть порт и не валидный, он должен ответить процессору, что получил от него запрос. Иначе процессор зависнет - будет вечно ожидать чтения. Невалидный порт будет всегда подтверждать получение запроса.

-- src/top/scr1_dmem_router.sv:142
           SCR1_SEL_PORT2    : sel_req_ack   = port2_req_ack;
           SCR1_SEL_INVALID  : sel_req_ack   = 1'b1;

Попытка прочитать или записать из адреса 0 и соответственно невалидного порта вызовет ошибку. Для наглядности на временных диаграммах значение при чтении поставлена на 0xbadbadba:

-- src/top/scr1_dmem_router.sv:165
           default         : begin
           sel_rdata   = 32'hBADBADBA;
           sel_resp    = SCR1_MEM_RESP_RDY_ER;
       end

Процессор получает от роутера сообщение об ошибке через сигнал sel_resp (он соединён с dmem_resp), а он обновляется только при получении запроса от процессора на доступ к памяти - при выставлении сигнала dmem_req в 1. Это приведет к неприятному поведению. Если хоть раз произошла ошибка при обращении к памяти, сигнал об ошибке будет продолжать висеть. А значит процессор будет считать, что все следующие инструкции вызывают исключение доступа к памяти. Процессор прыгнет в обработчик исключений, первая же инструкция обработчика вызовет из-за висящей ошибки исключение и процессор опять прыгнет в обработчик, на ту же инструкцию. Чтобы этого не происходило, необходимо как-то сбросить этот сигнал, например по умолчанию перенаправлять роутер на порт 0, пока не придет запрос:

-- src/top/scr1_dmem_router.sv:110
       case (fsm)
           SCR1_FSM_ADDR : begin
               if (dmem_req & sel_req_ack) begin
                   fsm         <= SCR1_FSM_DATA;
                   port_sel_r  <= port_sel;
               end else begin
                   port_sel_r  <= SCR1_SEL_PORT0;
               end
           end

Этих изменений также достаточно для получения Store Access Fault при попытке записи в адрес 0.

С этими доработками в роутер симуляция завершается fail-ом, и на временных диаграммах видно, что при чтении из NULL происходит исключение load access fault.

Временные диаграммы load access fault.
Временные диаграммы load access fault.

Отлично! Теперь наше ядро не будет втихую читать из NULL указателя, вместо этого программа упадет с исключением по причине Load Access Fault. Программист поймет, что есть ошибка разыменования NULL, и где она происходит, посмотрев в регистры mepc и mcause.

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


  1. checkpoint
    17.10.2024 01:13

    Хорошо когда под рукой имеются исходники микропроцессора и можно легко исправить назойливый баг или дописать требуемую фичу. Молодец!


    1. AiratGl Автор
      17.10.2024 01:13

      Эх, было бы так же просто баги процессора в кремнии исправлять.