PULPino是PULP的一个子项目,PULP全称“Parallel Ultra Low Power”,是一个RISC-V项目,旨在搭建低功耗的多核嵌入式SOC。PULPino是一个单核的单片机的架构,CPU内核除了实现RISCV的I、M、C指令集之外还扩展了一些类似于DSP的指令集,使其计算性能超过了ARM Cortex M4、硅片面积更小、功耗更低。。。

PULPino的例程用cmake来管理;其中有FreeRTOS的简单移植。FreeRTOS说到底就是一个scheduler,其他东西基本上连架构都没有,啥都要自己动手。

就功能而言适用于无MMU的操作系统最强的当属nuttx,号称“Linux on microcontroller”,文件系统、网络、应用程序一应俱全,而且非常小巧,完全没有UCLinux那般臃肿。作为例子,若在stm32上要用网络,则nuttx不用特别裁剪优化得到100kB左右的bin,而UCLinux疯狂裁剪之后得到1MB左右的bin。

目前nuttx里有riscv的基本移植,但要移植到PULPino上还需要非常多的功夫。本文阐述nuttx的踩坑历程。

nuttx世界观

Nuttx的创立者名叫Gregory Nutt,命名有Linus风范。nuttx定位在从RTOS到Linux的这段广阔的中间地带:这里有着很多“高性能”的MCU,也有一些有MMU的GHz主频级别的应用处理器,甚至是SMP多核处理器。

nuttx的功能多得令人咋舌,驱动架构、VFS、可执行文件、各种文件系统、网络、shell、web服务器、图形框架等等应有尽有,而且支持非常多的架构(其中大部分是ARM的,其中又有大部分是NXP和STM32的片子),几乎实现了UCLinux的所有东西并且代码时刻在更新。nuttx工程由Bitbucket托管,如果想贡献代码的话直接给Nutt发邮件即可。。。

对于系统程序员而言需要特别留意Nuttx Porting Guide,在源码里也有Documentation/NuttxPortingGuide.html

多架构的管理

nuttx将SOC的移植分为三个层次:

  • 架构(arch),即不同的ISA,如ARM、x86、RISCV等;同一类ISA放在arch/目录下面的不同子目录,比如arch/arm/src/armv7-aarch/arm/src/armv7-m等。
  • 芯片(chip),即同一个ISA下的不同芯片,如使用ARM架构的不同STM32芯片;它们放在架构目录下的不同子目录中,如arch/arm/src/stm32arch/arm/src/stm32l4等。
  • 板极(board),即使用一个chip做出的不同板子,主控一样但是引出的外设、具有的存储器、地址映射等有所不同;它们放在configs/目录下面,如一些有名的板子像configs/stm32f4discovery/configs/stm32f429i-disco/等。

在架构级目录下面实现的是诸如进程调度、系统调用、信号之类的与ISA息息相关的东西。

在芯片级目录下面实现的是片子的外设驱动,以及它的启动文件(需要实现启动代码和中断向量表)。值得一提的是Linux的外设驱动分散在它的drivers/目录下面,而arch/xxx/mach-xxx/目录下面又只有非常少的东西。

在板极目录下面实现的一般是外挂设备的初始化,比方说SDIO上挂了一个WiFi网卡,SPI上挂了一个flash等。这些驱动一般放在drivers/目录下面,毕竟外接设备与架构无关。

于是一目了然,要做某些层次的移植就要关注那些层次的目录。

内核运行方式

nuttx可以有三种运行方式:

  • 扁平模式(flat),不区分内核空间用户空间,就跟普通的小RTOS一样;STM32等的nuttx实现都是flat模式
  • 保护模式(protected),需要有MPU来保护内核空间,而所有应用程序都在同一个用户空间
  • 内核模式(kernel),适用于有MMU的系统,内核、不同的应用程序都在不同的地址空间。
nuttx1
nuttx1

应用程序

为体现模块化,nuttx的应用程序与内核分开来开发,编译时候要分别下载nuttx-xxx.tar.gzapps-xxx.tar.gz。应用程序可以有两类:

  • 内建的(built-in),跟内核绑在一起,从而可以直接调用nuttx C库。apps目录下开发的所有东西都是内建程序,小到helloworld,中到shell和各种实用工具,大到web服务器,都可以是内建的。。。
  • 可执行文件,这些程序都放到文件系统里面。可以有两种文件格式
    • ELF,需要用nuttx buildroot生成的gcc工具链来编译。
    • NXFLAT,这是简化版的XFLAT,体积小,而且可以在文件系统中就地运行(XIP)从而减少RAM的消耗。目前它只能跑在romfs上。。

简洁的驱动架构

与Linux纷繁复杂的驱动架构相比nuttx简单多了。Linux支持的设备太多太复杂了,以至于现在每一个驱动都是一个复杂的架构,比如时钟系统、电源管理等等,程序员要同时关注数个对象、数十个方法;另外它还开发出很多gdb不友好的“动态”的技巧,比方说链表、container_of云云,以至于调bug几乎只能靠打log来猜。。

小型嵌入式系统在驱动层面上往往没有那么多“动态”的东西:外设少而且几乎都是MMIO,而且并不指望同一个系统镜像能运行在不同类型的板子上,因此它不需要设备树之类的东西;该有的对象直接静态地列出就好。关于设备树的讨论可见笔者之前的博客

值得一提的是nuttx中类的继承关系只有单继承,而且子类的第一个成员对象是父类的实例。这样做的好处是,要通过父类找子类只需要对父类指针cast一下就可以了,根本不需要container_of之类的东西。Java也只有单继承(接口不算),说明这样做也没啥不妥的。

配置方式

与Linux一样,nuttx也使用kbuild来管理工程。用Kconfig脚本,make menuconfig来配置,最后正式编译之前将配置项生成include/nuttx/config.h头文件,根据各个目录下面的Make.defs来确定要编译哪些文件、要加哪些编译参数。

配置是板极的,一个板子可以有很多个配置,比方说stm32f429-discovery就有:串口shell(nsh)、USB shell(usbnsh)、图形界面(nxwm)等等配置。板极目录下面有这些子目录:

  • src/include/,放C语言程序,进行设备初始化。
  • scripts/,放各种脚本如:链接脚本、Make.defs(设置编译参数)、调试脚本(如gdb脚本、openocd脚本等)
  • 其他子目录,是各种默认配置,里面只有一个文件,名叫defconfig

defconfig非常小,只保留必要信息,可通过遍历整个工程的Kconfig来还原.config。这个过程由tools/configure.sh来进行。比方说要还原configs/xxboard/xxconf/defconfig,则用以下命令:

$ ./tools/configure.sh xxboard/xxconf

生成defconfig方法如下:

$ make savedefconfig
...
$ mv defconfig configs/xxboard/xxconf

PULPino底层移植

PULPino指令集包括RISCV-IMC以及扩充的计算指令集,系统层面的话只需要关注RISCV的指令即可。由于nuttx里面有RISCV-IM的基本实现,而且需要汇编实现的部分很短而不需要考虑压缩指令集,因此只需要实现chip级以及board级的东西就可以了。

  • chip级:arch/risc-v/src/pulpinoarch/risc-v/include/pulpino
  • board级:configs/pulpino;参照configs/nr5m100-nexys4,一款野生的跑在FPGA上的riscv板子。。

内存映射

因为PULPino既没有MMU也没有MPU,因此内核只能以flat模式运行,整个操作系统就相当于一个程序。由低址到高址可连续地分为以下几段:

  • text段:内核的代码段,只读;
  • data段:内核的非零的全局变量;
  • bss段:内核的清零的全局变量;
  • heap段:内核的malloc区域(新建任务的所有东西也都放在这里),又分为两部分:
    • idle task的堆栈区,由CONFIG_IDLETHREAD_STACKSIZE配置大小。从reset handler开始就算idle task了,因此启动代码的堆栈要设置在这里。
    • 剩下的所有内存空闲区域都留作heap。由arch/risc-v/src/common/up_allocateheap.c:up_allocate_heap()返回基地址和大小。

链接脚本见下文

启动代码

根据RI5CY手册,复位后CPU从0x80处开始执行,那里是一条跳转指令。如果链接脚本没有注明入口,则pulpino的工具链默认0x8C处是reset handler。

vector
vector

启动时,将31个寄存器清零,将bss段清零,初始化data段,然后跳转到c语言程序中(由于目前用JTAG直接下载ELF,因此data段实际上不需要初始化)。如果没什么特别的初始化的话,可以直接跳转到内核的os_start()函数。。

reset_handler:
  /* 清零寄存器 */
  mv  x1, x0
  mv  x2, x1
  ...
  mv x31, x1

  /* 清零bss段 */
  la x26, _sbss
  la x27, _ebss
  bge x26, x27, zero_loop_end
zero_loop:
  sw x0, 0(x26)
  addi x26, x26, 4
  ble x26, x27, zero_loop
zero_loop_end:

  /* 初始化全局变量 */
  //call    __libc_init_array

  /* 初始化堆栈指针:idle task的堆栈 */
  la   x2, IDLE_TASK_STACK

main_entry:
  /* 直接跳到os_start() */
  jal x1, os_start

中断处理与任务切换

nuttx内核API

nuttx的中断由内核统一管理,编写的ISR需要统一向内核注册,无论芯片是否支持向量中断:

#include <nuttx/irq.h>
int foo_isr(int irq, FAR void *context, FAR void *arg);
    // 将foo_isr注册到编号为IRQN的中断线上
    irq_attach(IRQN, foo_isr);

所有中断程序有统一入口:

#include <nuttx/arch.h>
// 传入中断号以及栈顶指针
void irq_dispatch(int irq, FAR void *context);
PULPino的简易中断控制
  • PULPino有向量中断。中断向量表的前32个即分别为其中断入口。中断向量表地址由BOOTREG寄存器设置,其地址为0x1A107008,默认地址是0x00000000。
  • 它还有一个简易中断控制器,名叫Event Unit,是一个外设。串口、SPI、I2C、定时器之类的中断都由它来处理:
寄存器 MM地址 说明
IER 0x1A104000 32位,每位写1使能中断
IPR 0x1A104004 32位,每位读1代表发生了这个中断
ISP 0x1A104008 32位,每位写1手动触发这个中断
  • 内核方面,PULPino只实现了少量必要的CSR寄存器。以下是老版pulpino实现的与中断相关的寄存器:
寄存器 CSR地址 说明
MSTATUS 0x300 最低位控制全局使能(新版变了)
MEPC 0x341 中断返回地址
  • PULPino扩展了循环指令,需要用到两组共6个循环计数器,它们都是CSR寄存器,CSR地址0x7B0-0x7B2和0x7B4-0x7B6;它们在中断时都需要保存。
nuttx任务的数据结构
#include <nuttx/sched.h>
struct tcb_s {
  /* 一些架构无关的东西 */
  ...
  /* 中断上下文 */
  struct xcptcontext xcp;
};

其中struct xcptcontext每个架构都不同。riscv的在arch/risc-v/include/rv32im/irq.h

struct xcptcontext {
  /* 信号等东西 */
  ...
  /* 寄存器组 */
  uint32_t regs[XCPTCONTEXT_REGS];
}

其中XCPTCONTEXT_REGS定义为39,编制如下:

序号 寄存器
0 中断返回地址,即mepc
1 x1寄存器
2 x2寄存器,堆栈指针
3-31 其他通用寄存器
32 mstatus
33-38 6个循环计数器

刚初始化任务时,up_initial_state()函数在struct tcb_s内设置入口地址和堆栈指针。

中断返回伴随着任务切换

因为有可能某个进程正在睡眠等待某个事件,比方说串口接收中断来了,那么getchar()就可以返回了。另外显式的up_switchcontext()通过系统调用来切换任务。将syscall处理程序也注册为一个IRQ即可统一这个过程。

扁平模式下的任务切换只需换堆栈。因此整个中断处理流程如下:

  • 中断发生
  • 汇编程序将39个寄存器压栈;
  • 汇编将中断号和堆栈指针传入C语言写的中断处理程序;
  • 中断处理程序或许会返回一个新堆栈指针;
  • 汇编将寄存器组弹出;
  • 中断返回

严格来说syscall应该归为“异常(exception)”,而普通的外部中断归为“中断(interrupt)”。因为exceptions一般都是“同步的”,就像非法指令异常、除以零异常、精确总线异常、系统调用等,都由正常或不正常的CPU指令触发;interrupts则是“异步的”,由CPU片外的东西不时触发。更精细地说,异常发生时那条触发异常的指令没有执行完毕,而中断发生时上一条指令执行完毕而下一条指令还没开始。因此异常返回到触发异常的那条指令,而中断返回返回到那条指令的下一条指令。

可以简单将syscall注册为一个IRQ,但是它的汇编wrapper要手动将返回地址加4,跳过ecall(新riscv标准为mcall)指令,免得死循环。

细致的切换过程

非正在运行状态的任务,寄存器组信息存放在tcb_s::xcptcontext中,进入运行状态则从那里复原寄存器组。但是中断是随机发生的,汇编并不知道该任务的tcb在哪,而且保存寄存器组之前不能破坏原来的寄存器因而不能找全局变量或者调用C程序;因此寄存器组只能暂存在原来任务的堆栈上。传入的上下文指针是此时的栈顶指针。

  • 如果要进行任务切换,则将寄存器组复制到旧tcb中,最后返回新tcb的寄存器组。
    • 任务初始化时tcb中的寄存器组由架构相关的代码初始化,主要就是堆栈和入口地址,也可以对寄存器染色。这是up_initial_state()的工作。
  • 如果不用任务切换,则寄存器组指针不变,汇编wrapper还是从栈上复原寄存器。
taskswitch
taskswitch

非得把寄存器组放在tcb里而不是直接留在堆栈,笔者认为这考虑到统一管理、便于调试。放在tcb里的话C语言程序就很方便操作,而且gdb可以直接打印整个结构体。另外gdb不认中断上下文,它不放在堆栈就不会干扰gdb找call stack,也便于通用的探针程序去操作。

定时器

PULPino没有实现RISCV内核的定时器,而是实现了两个简易的32位计数器作为外设。定时器的寄存器如下:

寄存器 MM地址 说明
TIMER 0x1A103000 当前计数器值
CTRL 0x1A103004 最低位写1开启计数器
CMP 0x1A103008 当计数器值达到它时触发中断

一个定时器有两个中断:

  • TIMER达到CMP寄存器的值时;
  • TIMER溢出时;(计数4亿个周期,一两百兆的时钟的话需要好几十秒)

操作很简单:

  • 初始化时,根据CONFIG_USEC_PER_TICK设置定时器比较值,开定时器,注册中断函数。
  • 定时器中断时,重置计数器,随后调用内核sched_process_timer()即可。

工具配置

工具链

这是arch级别的配置。

推荐使用ETH定制的riscv32-unknown-elf-gcc。需要注意的是要checkout到老commit,因为新版PULPino指令集有所更改。用RISCV原生工具生成I、M、C指令集也可以运行,只是不能用PULPino的扩展了。

同时在nuttx源码中更改工具链。首先在arch/risc-v/src/rv32im/Kconfig中添加一个config选项:

config RI5CY_ETH_TOOLCHAIN
    bool "ETH version toolchain"

随后在arch/risc-v/src/rv32im/Toolchain.defs中添加编译参数:

ifeq ($(filter y, $(CONFIG_RI5CY_ETH_TOOLCHAIN)),y)
  CONFIG_RISCV_TOOLCHAIN ?= GNU_RISCY
endif

ifeq ($(CONFIG_RISCV_TOOLCHAIN),GNU_RISCY)
  CROSSDEV ?= riscv32-unknown-elf-
  ARCROSSDEV ?= riscv32-unknown-elf-
  ARCHCPUFLAGS = -m32 -march=IMXpulpv2 -mrvc
  MAXOPTIMIZATION ?= -Os
endif

ARCHCPUFLAGS中的-m32 -march=IMXpulpv2选项将使用PULPino扩展的指令集,-mrvc将使用RISCV-C压缩指令集。MAXOPTIMIZATION是gcc的通用优化选项,-Os是对size的优化。加了这些参数之后生成代码体积可以与ARM Thumb2指令集相媲美。。

链接脚本

这是board级别的配置。工作目录在configs/pulpino/scripts/

  • 建立链接脚本为link.ld
  • Make.defs中设置链接脚本:LDSCRIPT = link.ld

我们pulpino挂了一块挺大的DDR内存。参照内存映射(见上文)写链接脚本,简化如下:

MEMORY
{
    instrram : ORIGIN = 0x23000000, LENGTH = 0x1000000
    dataram  : ORIGIN = 0x24000000, LENGTH = 0x1000000
}
SECTIONS
{
    .text : {
        _stext = .;
        *(.text)
        _etext = .;
    } > instrram

    .data : {
        . = ALIGN(4);
        _sdata = .;
        *(.data);
        _edata = .;
    } > dataram

    .bss :
    {
        . = ALIGN(4);
        _sbss = .;
        *(.bss)
        _ebss = .;
    } > dataram
}

关键就是暴露一些变量,例如data段的开始和结束_sdata_edata,bss段的开始和结束_sbss_ebss,以供reset handler初始化它们。

启动nsh

PULPino有一个兼容16750的串口。PC机上的8250、16550、16750之类的串口一脉相承,软件基本上兼容,只是后续的型号速度更快、FIFO更深等等。对于常用的115200波特率而言,可以直接使用nuttx的16550驱动。为此只需要配置好寄存器基地址,并实现uart_getreg()uart_putreg()即可。

-> Device Drivers
  -> Serial Driver Support
    -> 16550 UART Chip support

另外还要选择串口作为终端:

-> Device Drivers
  -> Serial Driver Support
    -> Serial console (UART)

随后配置nsh library:

-> Application Configuration
  -> NSH Library

最后使能nsh:

-> Application Configuration
  -> Examples
    -> NuttShell (NSH) example

编译通过后就可以在串口上操作shell了。

nsh
nsh