Во время работы в 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 и выведем сигналы на диаграмму.
На диаграмме показаны сигналы 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.
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
.
На диаграмме видна любопытная особенность. Само по себе чтение из нулевого адреса не вызывает исключения. Значение 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
скорее всего обнаружена не будет.
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.
Отлично! Теперь наше ядро не будет втихую читать из NULL
указателя, вместо этого программа упадет с исключением по причине Load Access Fault. Программист поймет, что есть ошибка разыменования NULL
, и где она происходит, посмотрев в регистры mepc
и mcause
.
checkpoint
Хорошо когда под рукой имеются исходники микропроцессора и можно легко исправить назойливый баг или дописать требуемую фичу. Молодец!
AiratGl Автор
Эх, было бы так же просто баги процессора в кремнии исправлять.