RISC-V 平台上编程(一)

引言

RISC-V的作者们旨在提供数种可以在 BSD 许可证之下自由使用的 CPU 设计;该许可证允许像是 RISC-V 芯片设计等派生作品可以像 RISC-V 本身一样是公开且自由发行,也可以是闭源或者是专有财产。

RISC-V – Wikipedia

在过去几十年的开源软件运动中,以 GNU/Linux 等为代表的一系列开源软件成为了科技行业内不可或缺的基础设施,平等的让每一个使用开源软件的个人与团体受益。除了开源操作系统,开源运动在其他方面也并未停下过。各种软件开发框架和系统软件都在拥抱开源(比如,曾将 Linux 称为「癌症」的微软现在也积极的在开源社区中贡献)。

然而,计算机除了软件,还有硬件。开源运动也同样「蔓延」到了硬件。通过开源 PCB 设计图、CAD 文件和芯片设计文件等的开源硬件运动也在同样进行。CPU 是计算机的核心部件,自然也会在开源运动的「射程范围」之内。然而,流行的 CPU 的指令集(ISA)要么商业封闭(x86_64, ARM)、要么显得陈旧(MIPS)。在这样的背景下,开源的指令集 RISC-V 呼之欲出。

[关于 RISC-V 历史的更多阅读:RISC-V – Wikipedia]

相比于传统的 x86_64 指令集,RISC-V 有诸多优点:

  • RISC-V 汇编的书写更加自然。
  • RISC-V 设计于过去十年,在指令集层面集成了更多现代处理器的特征,更加利于学习与开发;x86_64 的历史包袱太重,对指令集的学习和开发比较复杂。
  • 学术界和工业界对 RISC-V 很感兴趣。

然而,RISC-V 毕竟还是一个比较年轻的指令集标准,不足之处也是非常多的:

  • 缺少高性能处理器的实现。
  • 现行 RISC-V 平台开发更多面向 QEMU,实机开发仍不多。

本文以及未来可能发布的文章将形成一个系列,记录我在 RISC-V 平台上开发的一些经验和感悟。

本文内容

在本文中,我会介绍如何让一个 C 程序跑在 QEMU 的 RISC-V 模拟器上。在这个过程中,我会提到以下内容:

  • RISC-V 引导流程
  • ELF 格式
  • 链接(Linking)

运行环境

编译工具链

GNU 工具链(toolchain)在日常开发中被广泛使用,我们可以选择 GNU 在 RISC-V 平台下的工具链(请选择生成 ELF 文件的工具链版本)。[RISC-V GNU 工具链在 GitHub 的链接](内附工具链的编译与安装步骤)

QEMU 模拟器

QEMU 模拟器支持多种指令集的模拟(emulation),安装指南在官方文档中。[QEMU 官方网站]

程序从哪运行?

我们现在在做一件非常酷的事情:抛弃掉操作系统、直接在平台上跑我们自己的程序。绕过操作系统听上去真的很棒:当我在服务器上进行长时间计算任务时,我总是希望能把操作系统拿掉,让任务可以无损地利用计算资源。但是,如果真的把操作系统拿掉了,我们可能会遇到很麻烦的事情。我们在编程的时候依托了很多「中间程序」的帮忙:比如,我们程序的 I/O 均由操作系统代理进行。没有了操作系统,我们需要做许多额外的工作。

我们以 QEMU 为例,介绍 RISC-V 平台通电后的行为。如果要开启一个 QEMU 模拟实例:

qemu-system-riscv64 \
   -machine virt \     # 模拟器类型;
   -bios none \        # BIOS 类型;
   -m 128M \           # 内存大小;
   -nographic          # 禁用图像,在这里只需要让 QEMU 在控制台中与我们交互即可;

执行之后应该啥都没有。为什么呢?QEMU 通电之后,QEMU 会进行准备工作,一旦进行准备完毕,Program Counter 就会跳转到内存中 0x80000000 的位置,以 Machine Mode 的权限执行代码;接下来,程序要干什么,机器也就跟着干什么。QEMU 开始之后,内存一片空白,自然也就没有任何输出。

所以,如果我们想要运行一段程序,那么我们就需要把程序的第一条指令放到 0x80000000 的位置,然后让机器一直往下取指令并且执行指令。

如何做到呢?一种想法是,把程序写好、编译成二进制文件,然后一个一个字节透过 GDB 修改内存注入到 QEMU 中(这个当然是开玩笑,不要当真)。这样比较麻烦,而且不是程序员应该负责的工作,那么我们该如何解决这个问题呢?

思考一下我们平常电脑的启动流程:进入 BIOS/EFI Firmware 之后,可以透过 BIOS/EFI Firmware 来选择需要从哪一个介质(硬盘、CD/DVD、U 盘等)启动;选择介质之后,以常见的硬盘启动为例,BIOS/EFI Firmware 会从硬盘中读取启动项信息(MBR/GPT 中提供了相关信息),然后根据硬盘中的内容把操作系统或 Bootloader(例如 GRUB, bootmgr)加载到内存,然后就完成了引导;接下来,操作系统会继续初始化,或者由 Bootloader 进行相关引导。

有点麻烦,有没有什么快速的方法直接把程序注入到 QEMU 的内存中,让 QEMU 直接跑我们放在 0x80000000上的代码?

其实是有的,只不过这是 QEMU 提供的便捷方式。-kernel 选项就可以把我们文件系统中的文件载入到 QEMU 的内存中。听上去真的很棒,看样子曙光要来临了。

程序加载

可是 QEMU 怎么知道如何将文件载入内存呢?这个听上去有点蠢,因为文件里都是二进制,本能的想法是:就把文件里所有的内容一个一个字节放到内存里就好了,实在需要额外信息的话,那么就给 QEMU 一个地址,让它把文件都写到这个基地址(base address)上即可。然而,在实际操作中,这种方式实在是太不实用了:

  • 在常见的运行环境中,操作系统常常会开启内存分页(paging),有时我们需要将程序中不同的内容(可执行的二进制、数据)放在相同/不同的页面、或者是不同的地址,则此时如何将程序的不同部分加载到对应的地址就非常有必要了。
  • 有时,我们编写的 C 程序中有比较大的静态数组(例如:int buf[10000000]),这时如果编译时我们在文件中预留 10000000int 空白位,这样空间利用效率极低。如果这时,加载文件的程序可以在内存中帮我们开辟 10000000int 空白位、而不是把文件里10000000int 空白位全部复制到内存里的话,整个系统的效率会极大提高。

所以,粗暴的将文件内容直接抄到对应内存块上是不可取的。我们需要按照一定的计划,将文件中的不同部分加载到对应位置。Linux 是怎么做的呢?假设我们要运行 riscv-test 这个文件:首先,应该由文件本身提供加载到内存的方式;Linux 先读取 riscv-test 里预先写好的内存加载计划,然后按照计划将 riscv-test 中的指定块放到内存中,并且根据计划给定的程序入口点(entry point)开始运行 riscv-test 里的代码。

Linux 和 riscv-test 约定好使用 ELF 格式。这个格式的文件里,不仅包含文件内容(可执行的二进制、数据等等),并且包含如何将数据加载到内存的规定。由于 ELF 格式的细则比较复杂,所以我在这里节选一些内容帮助读者理解。

Executable and Linkable Format – ELF

CC BY-SA 3.0

上方这张示意图展示了 ELF 文件的大概结构。数据以段(section)的形式存在文件中,例如 .text.rodata.data 等。这些段的地址(address)和大小(size)被定义在底部的 Section Header Table 中。

我们通过解剖一个可执行文件的方式来了解 ELF 格式。我们写一个 test_elf.c 的程序:

// test_elf.c
#include <stdio.h>

int main()
{
   int a = 10, b = 20;
   printf("%d\n", a + b);
   return 0;
}

使用 RISC-V GNU 工具链的编译器进行编译(工具链前缀可能会因运行平台而异,例如,在 Ubuntu 22.04 上编译的工具链,其前缀为 riscv64-unknown-linux-gnu-gcc):

riscv64-unknown-linux-gnu-gcc test_elf.c -o test_elf

我们现在得到了 test_elf,现在我们使用一个叫做 readelf 的工具(请自行安装)来读取 test_elf 中的 ELF 信息:

readelf -a test_elf > elf_report

[elf_report 文件 – elf_report on GitHub Gist]

打开 elf_report,文件大纲大概为:

  • ELF Header
  • Section Headers
  • Program Headers
  • Dynamic Section
  • Relocation Section
  • Symbol Table

我们首先看看 ELF Header

ELF Header:
Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
Class:                             ELF64
Data:                             2's complement, little endian
Version:                           1 (current)
OS/ABI:                           UNIX - System V
ABI Version:                       0
Type:                             EXEC (Executable file)
Machine:                           RISC-V
Version:                           0x1
Entry point address:               0x10450
Start of program headers:         64 (bytes into file)
Start of section headers:         12152 (bytes into file)
Flags:                             0x5, RVC, double-float ABI
Size of this header:               64 (bytes)
Size of program headers:           56 (bytes)
Number of program headers:         10
Size of section headers:           64 (bytes)
Number of section headers:         35
Section header string table index: 34

ELF Header 记录了程序的一些基本信息,比如 ELF 格式、OS/ABI、体系结构等信息。

再来看看 Section Headers(文本宽度较长,建议在宽屏设备上全屏阅读):

Section Headers:
[Nr] Name             Type             Address           Offset
      Size             EntSize         Flags Link Info Align
[ 0]                   NULL             0000000000000000 00000000
      0000000000000000 0000000000000000           0     0     0
[ 1] .interp           PROGBITS         0000000000010270 00000270
      0000000000000021 0000000000000000   A       0     0     1
[ 2] .note.ABI-tag     NOTE             0000000000010294 00000294
      0000000000000020 0000000000000000   A       0     0     4
[ 3] .hash             HASH             00000000000102b8 000002b8
      0000000000000024 0000000000000004   A       5     0     8
[ 4] .gnu.hash         GNU_HASH         00000000000102e0 000002e0
      0000000000000030 0000000000000000   A       5     0     8
[ 5] .dynsym           DYNSYM           0000000000010310 00000310
      0000000000000060 0000000000000018   A       6     1     8
[ 6] .dynstr           STRTAB           0000000000010370 00000370
      0000000000000041 0000000000000000   A       0     0     1
[ 7] .gnu.version     VERSYM           00000000000103b2 000003b2
      0000000000000008 0000000000000002   A       5     0     2
[ 8] .gnu.version_r   VERNEED         00000000000103c0 000003c0
      0000000000000020 0000000000000000   A       6     1     8
[ 9] .rela.plt         RELA             00000000000103e0 000003e0
      0000000000000030 0000000000000018 AI       5   19     8
[10] .plt             PROGBITS         0000000000010410 00000410
      0000000000000040 0000000000000010 AX       0     0     16
[11] .text             PROGBITS         0000000000010450 00000450
      0000000000000134 0000000000000000 AX       0     0     4
[12] .rodata           PROGBITS         0000000000010588 00000588
      0000000000000004 0000000000000000   A       0     0     8
[13] .eh_frame_hdr     PROGBITS         000000000001058c 0000058c
      0000000000000014 0000000000000000   A       0     0     4
[14] .eh_frame         PROGBITS         00000000000105a0 000005a0
      000000000000002c 0000000000000000   A       0     0     8
[15] .preinit_array   PREINIT_ARRAY   0000000000011e08 00000e08
      0000000000000008 0000000000000008 WA       0     0     1
[16] .init_array       INIT_ARRAY       0000000000011e10 00000e10
      0000000000000008 0000000000000008 WA       0     0     8
[17] .fini_array       FINI_ARRAY       0000000000011e18 00000e18
      0000000000000008 0000000000000008 WA       0     0     8
[18] .dynamic         DYNAMIC         0000000000011e20 00000e20
      00000000000001e0 0000000000000010 WA       6     0     8
[19] .got             PROGBITS         0000000000012000 00001000
      0000000000000028 0000000000000008 WA       0     0     8
[20] .sdata           PROGBITS         0000000000012028 00001028
      0000000000000010 0000000000000000 WA       0     0     8
[21] .bss             NOBITS           0000000000012038 00001038
      0000000000000008 0000000000000000 WA       0     0     1
[22] .comment         PROGBITS         0000000000000000 00001038
      000000000000002d 0000000000000001 MS       0     0     1
[23] .riscv.attributes RISCV_ATTRIBUTE 0000000000000000 00001065
      0000000000000035 0000000000000000           0     0     1
[24] .debug_aranges   PROGBITS         0000000000000000 000010a0
      00000000000000a0 0000000000000000           0     0     16
[25] .debug_info       PROGBITS         0000000000000000 00001140
      0000000000000683 0000000000000000           0     0     1
[26] .debug_abbrev     PROGBITS         0000000000000000 000017c3
      000000000000027b 0000000000000000           0     0     1
[27] .debug_line       PROGBITS         0000000000000000 00001a3e
      0000000000000244 0000000000000000           0     0     1
[28] .debug_frame     PROGBITS         0000000000000000 00001c88
      0000000000000068 0000000000000000           0     0     8
[29] .debug_str       PROGBITS         0000000000000000 00001cf0
      00000000000004e0 0000000000000001 MS       0     0     1
[30] .debug_line_str   PROGBITS         0000000000000000 000021d0
      0000000000000174 0000000000000001 MS       0     0     1
[31] .debug_loclists   PROGBITS         0000000000000000 00002344
      000000000000012b 0000000000000000           0     0     1
[32] .symtab           SYMTAB           0000000000000000 00002470
      0000000000000768 0000000000000018         33   61     8
[33] .strtab           STRTAB           0000000000000000 00002bd8
      0000000000000239 0000000000000000           0     0     1
[34] .shstrtab         STRTAB           0000000000000000 00002e11
      0000000000000160 0000000000000000           0     0     1

我们观察这里的表头:

[Nr] Name             Type             Address           Offset
      Size             EntSize         Flags Link Info Align

按照这个表头,我们来看 .text 段的信息:

[11] .text             PROGBITS         0000000000010450 00000450
      0000000000000134 0000000000000000 AX       0     0     4

.text 的数据类型被定义为 PROGBITS,要求加载器将这段数据加载到内存的 0000000000010450 位置。这段数据位于文件的 00000450 位置,大小为 0000000000000134,以 4 字节的方式在内存中对齐,在运行期间需要占用这一段内存(A),并且这段数据是可执行的(X)。

[补充阅读:Executable and Linkable Format – Wikipedia][补充阅读:elf(5) – Linux manual page]

所以,如果我们需要把程序放在内存中,并且让 QEMU 模拟器运行我们的程序,我们得用我们的代码生成一个正确的 ELF 文件,并且让 QEMU 按照 ELF 格式进行内容的加载。

链接(Linking)

我相信各位学 C 或者其他语言的时候,都扫过一眼「编译流程」的介绍,其中会有一个叫做链接(Linking)的过程。大部分人可能不会去深究(我本人也是很晚才接触到这些细节),但是这个对我们程序的正确运行有很大的帮助。

[更多阅读:10 分钟看懂 C++ 编译过程|坎德人的小包包]

一个 C 程序的编译通常从 C 文件的预编译(Preprocessing)开始。预编译时,编译器会把头文件中的内容全部拷贝到源文件中,并且根据文本中的宏(macro)定义将对应的宏展开。之后,编译器会将 C 程序编译成汇编代码(assembly),然后由汇编器(assembler)进行汇编,生成目标文件(object file)。

接下来就是链接了。链接将目标文件中的可执行二进制内容放到对应的位置,并且重新整理内容的位置和指向。

[更多阅读:Linking – Computer Systems A Programmers Perspective – 3rd]

帮助我们将目标文件组装成 ELF 格式文件的工具是 GNU Binutils 中的 ld 链接器。ld 处理的方式是:根据一个链接器脚本(linker script),将目标文件放置到对应的文件位置、标明希望加载到内存的位置、组织 ELF 头等元信息(metadata),最后输出标准 ELF 文件。

对于 C 程序而言,要正确运行还需要一个额外的东西:C Runtime(简称为 CRT)。虽然 C 程序编译之后就直接以可执行二进制的方式运行,但是它的运行依托了 C Runtime。GCC 在编译一个 C 程序时,除了把 C 代码编译成目标文件之外,还会将 C Runtime 的目标文件加入链接,最后形成 ELF 格式的文件。而 Linux 在读取、运行 ELF 格式的 C 程序时,会先进入 C Runtime 的入口,然后再由 C Runtime 跳转至 main。

为什么需要这样的设计呢?尽管我们说 C 语言是汇编语言的高级抽象,C 语言的行为可以被映射为汇编代码,但是 C 语言所需要的栈结构并没有先天存在于内存中。C Runtime 的一大工作就是在运行 main 之前,在内存中设置好栈结构,并且设置好栈指针寄存器(stack pointer register)。所以,我们需要另外写一段汇编代码,将栈设置好,然后再将它们链接起来。我们把 test_elf.c 重写为:

// test_elf.c
char stack[4096];

void main()
{
    int a = 10, b = 20, c = 0;
    for (;;)
        c += a + b;
}

由于我们是在裸机上跑,所以我们并没有「标准输出流」(stdout)这种东西,自然 printf 也是用不了的。那我们如何验证我们的程序在运行呢?我们可以使用 QEMU 的调试端口,让 GDB 连接到虚拟机,然后就可以侦测到 CPU 和内存的动向。

然后,我们用汇编写一个引导程序 entry.S

# entry.S
# To create a stack for C-compiled program and handover control to C world.

# Useful in linking;
.section .text
.global __entry
__entry:
    # Create the stack for C world;
    la a0, stack
    # Stack pointer decreases gradually with the more data loaded into the stack;
    li a1, 4096
    add a0, a0, a1
    mv sp, a0
    # Go!
    call main

entry.S 的作用是建立栈空间并且跳转到 main。这里需要注意的是,.section .text 的含义是:「以下可执行代码应被放入 .text 区域」,作用是让链接器将这部分代码放到对应的 ELF 段(section)中;.global __entry 定义了一个全局符号,将这个指令的地址分配给 __entry 这个符号。这个符号会在之后的脚本中用到。同时,我们在 test_elf.c 里定义了一块内存空间 stack,所以我们在这里把 sp 设置为 stack 的底部。

我们还需要链接脚本将程序整合在一起:

/*
    linking.ld
*/

OUTPUT_ARCH( "riscv" ) /* 设置目标指令集 */
ENTRY( __entry ) /* 设置程序入口点 */

/* 段定义信息 */
SECTIONS
{
    /* "." 是内存指针,它的作用是定位内容、将内容放置于指定位置 */
    . = 0x80000000;

    /* 我们把内存指针设置为 0x80000000,然后在 0x80000000 的位置上定义 .text 段 */
    .text : {
        /* 放置内容 */
        *(.text .text.*)
        /* 内容放置完毕之后,为了内存对齐,我们还需要留一部分的白,这里我们使用 ALIGN 来计算对齐之后的内存位置 */
        . = ALIGN(0x1000);
    }

    /* 以下三个段都存储了程序所需要的数据,就不再细讲 */

    .rodata : {
        . = ALIGN(16);
        *(.srodata .srodata.*)
        . = ALIGN(16);
        *(.rodata .rodata.*)
        . = ALIGN(16);
    }

    .data : {
        *(.sdata .sdata.*)
        . = ALIGN(16);
        *(.data .data.*)
        . = ALIGN(16);
    }

    .bss : {
        *(.sbss .sbss.*)
        . = ALIGN(16);
        *(.bss .bss.*)
        . = ALIGN(16);
    }
}

然后,我们写一个脚本 make.sh 进行编译:

riscv64-unknown-linux-gnu-gcc test_elf.c \
    -Wall -O0 -ggdb -gdwarf-2 \
    -ffreestanding -fno-common -nostdlib -MD \
    -I. -mno-relax -fno-pie -no-pie -mcmodel=medany \
    -c -o test_elf.o

riscv64-unknown-linux-gnu-gcc entry.S -c -o entry.o

riscv64-unknown-linux-gnu-ld -z max-page-size=4096 -T linking.ld -o test_elf entry.o test_elf.o

首先需要注意的是 C 源文件的编译方式。我们需要用 -c -o test_elf.o 来告诉编译器输出名为 test_elf.o 的目标文件。除此之外,还有很多其他的编译选项,其中 -O0 关闭了编译器优化、-ggdb -gdwarf-2 保留了调试信息、-ffreestanding -fno-common -nostdlib 关闭了对标准库的引用和链接。

接下来,加载 ELF 文件到 QEMU,在 TCP 10024 端口开放调试端口,在虚拟机中启动我们的程序:

qemu-system-riscv64 \
   -machine virt \
   -bios none \
   -m 128M \
   -nographic \
   -kernel test_elf \
   -gdb tcp::10024 -S

由于是调试模式,所以 QEMU 在通电之后暂停,等待 GDB 的操作。使用 GDB 进入调试:

gdb-multiarch

在 GDB 控制台中,设置目标架构、符号文件、开启自动反编译并且连接到 QEMU:

(gdb) set architecture riscv:rv64
The target architecture is set to "riscv:rv64".
(gdb) symbol-file test_elf
Reading symbols from test_elf...
(gdb) set disassemble-next-line auto
(gdb) target remote 127.0.0.1:10024
Remote debugging using 127.0.0.1:10024

连接之后,会显示机器当前运行的位置(program counter):

0x0000000000001000 in ?? ()

我们先在入口点打上断点:

(gdb) b *0x80000000
Breakpoint 1 at 0x80000000
(gdb) c
Continuing.

来到 __entry

Breakpoint 1, 0x0000000080000000 in __entry ()
=> 0x0000000080000000 <__entry+0>:      17 15 00 00     auipc   a0,0x1
(gdb) si
0x0000000080000004 in __entry ()
=> 0x0000000080000004 <__entry+4>:      13 05 05 00     mv      a0,a0
(gdb) si
0x0000000080000008 in __entry ()
=> 0x0000000080000008 <__entry+8>:      85 65   lui     a1,0x1
(gdb) si
0x000000008000000a in __entry ()
=> 0x000000008000000a <__entry+10>:     2e 95   add     a0,a0,a1
(gdb) si
0x000000008000000c in __entry ()
=> 0x000000008000000c <__entry+12>:     2a 81   mv      sp,a0
(gdb) si
0x000000008000000e in __entry ()
=> 0x000000008000000e <__entry+14>:     ef 00 40 00     jal     ra,0x80000012 <main>

进入 main

(gdb) si
main () at test_elf.c:5
5       {
(gdb) si
0x0000000080000014      5       {
(gdb) si
0x0000000080000016      5       {
(gdb) si
6           int a = 10, b = 20, c = 0;
(gdb) si
0x000000008000001a      6           int a = 10, b = 20, c = 0;
(gdb) si
6           int a = 10, b = 20, c = 0;
(gdb) si
0x0000000080000020      6           int a = 10, b = 20, c = 0;
(gdb) si
6           int a = 10, b = 20, c = 0;
(gdb) si
8               c += a + b;

结语

我们在这篇文章中将一个程序放在了内存上,并且将所有的计算资源都分配给了它,非常的酷。在这个基础上,我们可以完成一些嵌入式的编程,甚至是实现很多系统软件。

如果本文有纰漏、缺陷,还劳烦各位指出,谢谢。

Reference

[1]Wikipedia, “RISC-V – Wikipedia,” retrieved 16 March 2023, https://en.wikipedia.org/wiki/RISC-V
[2]riscv-collab, “GNU toolchain for RISC-V, including GCC,” retrieved 16 March 2023, https://github.com/riscv-collab/riscv-gnu-toolchain
[3]QEMU, “QEMU,” retrieved 16 March 2023, https://www.qemu.org
[4]twilco, “RISC-V from scratch 2: Hardware layouts, linker scripts, and C runtimes, ” retrieved 16 March 2023, https://twilco.github.io/riscv-from-scratch/2019/04/27/riscv-from-scratch-2.html
[5]Massachusetts Institute of Technology, “xv6-riscv,” retrieved 16 March 2023, https://pdos.csail.mit.edu/6.828/2022/xv6.html
[6]Editors Andrew Waterman and Krste Asanovi ́c, “The RISC-V Instruction Set Manual, Volume I: User-Level ISA, Document Version 20191213,” RISC-V Foundation, December 2019
[7]Editors Andrew Waterman, Krste Asanovi ́c, and John Hauser, “The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Docu- ment Version 20211203,” RISC-V International, December 2021
[8]Wikipedia, “Executable and Linkable Format – Wikipedia,” retrieved 16 March 2023, https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
[9]Atish Patra, Anup Patel, “An Introduction to RISC-V Boot Flow”
[10]Jagan Teki, “An Introduction to RISC-V Boot flow: Overview,
Blob vs Blobfree standards”

Leave a Reply

Your email address will not be published. Required fields are marked *