原文出处:https://mp.weixin.qq.com/s/ljYZwMj3JaPN29dTAXA3bQ
随着国内第一本RISC-V中文书籍《手把手教你设计CPU——RISC-V处理器篇》 正式上市,越来越多的爱好者开始使用开源的蜂鸟E203 RISC-V处理核,很多初学者留言询问有关RISC-V工具链使用的问题,因此本公众号将开始陆续发表若干篇有关RISC-V软件工具链使用的文章,包括:
- RISC-V嵌入式开发准备篇1:编译过程简介
- RISC-V嵌入式开发准备篇2:嵌入式开发的特点介绍
- RISC-V嵌入式开发入门篇1:RISC-V GCC工具链的介绍
- RISC-V嵌入式开发入门篇2:RISC-V汇编语言程序设计
- RISC-V嵌入式开发上手篇:基于HBird-E-SDK平台的软件开发与运行
- RISC-V嵌入式开发实践篇:运行开源蜂鸟E200 MCU更多示例程序
- RISC-V嵌入式开发新奇篇:基于Windows Eclipse IDE的软件开发与运行
- RISC-V嵌入式开发升华篇:基于开源蜂鸟E200 MCU移植RTOS
本文为RISC-V嵌入式开发准备篇2:嵌入式开发的特点介绍。
本文的目的是对嵌入式开发的特点进行简单的科普与回顾,为后续详细介绍“RISC-V GCC工具链”和“RISC-V汇编语言程序设计”打下基础。注:本文力求通俗易懂,主要面向初学者,对嵌入式开发有所了解的读者可以忽略此文。
在本号上次发表的文章《编译过程简介》中介绍过,嵌入式系统的程序编译过程和开发有其特殊性,譬如:
- 嵌入式系统需要使用交叉编译与远程调试的方法进行开发。
- 需要自己定义引导程序。
- 需要注意减少代码体积(Code Size)。
- 需要移植printf从而使得嵌入式系统也能够打印输入。
- 使用Newlib作为C运行库。
- 每个特定的嵌入式系统都需要配套的板级支持包。
下文将分别予以介绍。
1 交叉编译和远程调试
在本号上次发表的文章《编译过程简介》中介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,对其进行编译,然后运行在此电脑上。在这种方式下,我们使用PC电脑上的编译器编译出该PC电脑本身可执行的程序,这种编译方式称之为本地编译。
嵌入式平台上往往资源有限,嵌入式系统(譬如常见ARM MCU或8051单片机)的存储器容量通常只在几KB到几MB之间,且只有闪存而没有硬盘这种大容量存储设备,在这种资源有限的环境中,不可能将编译器等开发工具安装在嵌入式设备中,所以无法直接在嵌入式设备中进行软件开发。因此,嵌入式平台的软件一般在主机PC上进行开发和编译,然后将编译好的二进制代码下载至目标嵌入式系统平台上运行,这种编译方式属于交叉编译。
交叉编译可以简单理解为,在当前编译平台下,编译出来的程序能运行在体系结构不同的另一种目标平台上,但是编译平台本身却不能运行该程序,譬如,在x86平台的PC电脑上编写程序并编译成能运行在ARM平台的程序,编译得到的程序在x86平台上不能运行,必须放到ARM平台上才能运行。
与交叉编译同理,在嵌入式平台上往往也无法运行完整的调试器,因此当运行于嵌入式平台上的程序出现问题时,需要借助主机PC平台上的调试器来对嵌入式平台进行调试。这种调试方式属于远程调试。
常见的交叉编译和远程调试工具是GCC和GDB。在本号上次发表的文章《编译过程简介》中介绍了如何使用Linux自带的GCC本地编译一个Hello World程序并运行。但是,GCC不仅能作为本地编译器,还能作为交叉编译器;同理GDB不仅可以作为本地调试器,还可以作为远程调试器。
当作为交叉编译器之时,GCC通常有不同的命名,譬如:
- arm-none-eabi-gcc和arm-none-eabi-gdb是面向裸机(Bare-Metal)ARM平台的交叉编译器和远程调试器。
- 所谓裸机(Bare-Metal)是嵌入式领域的一个常见形态,表示不运行操作系统的系统
- 而riscv-none-embed-gcc和riscv-none-embed-gdb是面向裸机RISC-V平台的交叉编译器和远程调试器。
- 本号后续发文《RISC-V GCC工具链的介绍》将介绍RISC-V GCC工具链的更多信息。
2 移植newlib或newlib-nano作为C运行库
newlib是一个面向嵌入式系统的C运行库。相对于本号上次发表的文章《编译过程简介》中介绍的glibc,newlib实现了大部分的功能函数,但体积却小很多。newlib独特的体系结构将功能实现与具体的操作系统分层,使之能够很好地进行配置以满足嵌入式系统的要求。由于专为嵌入式系统设计,newlib具有可移植性强、轻量级、速度快、功能完备等特点,已广泛应用于各种嵌入式系统中。
由于嵌入式操作系统和底层硬件的多样性,为了能够将C/C++语言所需要的库函数实现与具体的操作系统和底层硬件进行分层,newlib的所有库函数都建立在20个桩函数的基础上,这20个桩函数完成具体操作系统和底层硬件相关的功能:
- I/O和文件系统访问(open、close、read、write、lseek、stat、fstat、fcntl、link、unlink、rename);
- 扩大内存堆的需求(sbrk);
- 获得当前系统的日期和时间(gettimeofday、times);
- 各种类型的任务管理函数(execve、fork、getpid、kill、wait、_exit);
这20个桩函数在语义、语法上与POSIX(Portable Operating System Interface of UNIX)标准下对应的20个同名系统调用完全兼容。
所以,如果需要移植newlib至某个目标嵌入式平台,成功移植的关键是在目标平台下找到能够与newlib桩函数衔接的功能函数或者实现这些桩函数。本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将介绍蜂鸟E200的HBird-E-SDK平台如何实现移植实现newlib的桩函数。
注意:newlib的一个特殊版本newlib-nano版本进一步为嵌入式平台减少了代码体积(Code Size),因为newlib-nano提供了更加精简版本的malloc和printf函数的实现,并且对库函数使用GCC的-Os(侧重代码体积的优化)选项进行编译优化。
3 嵌入式引导程序和中断异常处理
在本号上次发表的文章《编译过程简介》中介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,对其进行编译,然后运行在此电脑上。在这种方式下,程序员仅仅只需要关注Hello World程序本身,程序的主体由main函数组织而成,程序员可以无需关注Linux操作系统在运行该程序的main函数之前和之后需要做什么。事实上,在Linux操作系统中运行应用程序(譬如简单的Hello World)时,操作系统需要动态地创建一个进程、为其分配内存空间、创建并运行该进程的引导程序,然后才会开始执行该程序的main函数,待其运行结束之后,操作系统还要清除并释放其内存空间、注销该进程等。
从上述过程中可以看出,程序的引导和清除这些“脏活累活”都是由Linux这样的操作系统来负责进行。但是在嵌入式系统中,程序员除了开发以main函数为主体的功能程序之外,还需要关注如下两个方面:
- 引导程序:
- 嵌入式系统上电后需要对系统硬件和软件运行环境进行初始化,这些工作往往由用汇编语言编写的引导程序完成。
- 引导程序是嵌入式系统上电后运行的第一段软件代码。引导程序对于嵌入式系统非常关键,引导程序所执行的操作依赖于所开发的嵌入式系统的软硬件特性,一般流程包括:初始化硬件、设置异常和中断向量表、把程序拷贝到片上SRAM中、完成代码的重映射等,最后跳转到main函数入口。
- 本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将结合HBird-E-SDK平台的引导程序实例了解引导程序的更多细节。
- 中断异常处理
- 中断和异常是嵌入式系统非常重要的一个环节,因此,嵌入式系统软件还必须正确地配置中断和异常处理函数。有关RISC-V架构的中断和异常的详细信息,请参见RISC-V中文书籍《手把手教你设计CPU——RISC-V处理器篇》 中第13章内容《不得不说的故事——中断和异常》。
- 本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将结合HBird-E-SDK程序实例了解如何配置中断和异常处理函数。
4 嵌入式系统链接脚本
在本号上次发表的文章《编译过程简介》中介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,对其进行编译,然后运行在此电脑上。在这种方式下,程序员也无需关心编译过程中的“链接”这一步骤所使用的链接脚本,无需为程序分配具体的内存空间。
但是在嵌入式系统中,程序员除了开发以main函数为主体的功能程序之外,还需要关注“链接脚本”为程序分配合适的存储器空间,譬如程序段放在什么区间、数据段放在什么区间等等。
本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将结合HBird-E-SDK的“链接脚本”实例了解更多细节。
5 减少代码体积
嵌入式平台上往往存储器资源有限,嵌入式系统(譬如常见的ARM MCU或8051单片机)的存储器容量通常只在几KB到几MB之间,且只有闪存而没有硬盘这种大容量存储设备,在这种资源有限的环境中,程序的代码体积(Code Size)显得尤其重要,因此,有效地降低降低代码体积(Code Size)是嵌入式软件开发必须要考虑的问题,常见的方法如:
- 使用newlib-nano作为C运行库以取得较小代码体积(Code Size)的C库函数。
- 尽量少使用C语言的大型库函数,譬如在正式发行版本的程序中避免使用printf和scanf等函数。
- 如果在开发的过程中一定需要使用printf函数,可以使用某些自己实现的阉割版printf函数(而不是C运行库中提供的printf函数)以生成较小的代码体积。
- 除此之外,在C/C++语言的语法和程序开发方面也有众多技巧以取得更小的代码体积(Code Size)。
- 本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将结合HBird-E-SDK平台实例了解更多“减少代码体积”的实现细节。
减小代码体积(Code Size)的方法很多,本文在此不做一一赘述,请初学的读者自行查阅相关资料进行学习。
6 支持printf函数
在本号上次发表的文章《编译过程简介 》中介绍了如何在Linux系统的PC电脑上开发一个Hello World程序,程序中使用C语言的标准库函数printf打印了一个“Hello World”字符串。该程序在Linux系统里面运行的时候字符串被成功的输出到了Linux的终端界面上。在这个过程中,程序员无需关心Linux系统到底是如何将printf函数的字符串输出到Linux终端上的。事实上,如《编译过程简介》中所述,在Linux本地编译的程序会链接使用Linux系统的C运行库glibc,而glibc充当了应用程序和Linux操作系统之间的接口,glibc提供的 printf 函数就会调用如sys_write等操作系统的底层系统调用函数,从而能够将“字符串”输出到Linux终端上。
从上述过程中可以看出,由于有glibc的支持,所以printf函数能够在Linux系统中正确的进行输出。但是在嵌入式系统中,printf的输出却不那么容易了,基于如下几个原因:
- 嵌入式系统使用newlib作为C运行库,而newlib的C运行库所提供的printf函数最终依赖于如本文中所介绍的newlib桩函数write,因此必须实现此write函数才能够正确的执行printf函数。
- 嵌入式系统往往没有“显示终端”存在,譬如常见的单片机其作为一个黑盒子一般的芯片,根本没有显示终端。因此,为了能够支持显示输出,通常需要借助单片机芯片的UART接口将printf函数的输出重新定向到主机PC的COM口上,然后借助主机PC的串口调试助手显示出输出信息。同理,对于scanf输入函数,也需要通过主机PC的串口调试助手获取输入然后通过主机PC的COM口发送给单片机芯片的UART接口。
- 从以上两点可以看出,嵌入式平台的UART接口非常重要,往往扮演了输出管道的角色,为了能够将printf函数的输出定向到UART接口,需要实现newlib的桩函数write,使其通过编程UART的相关寄存器将字符通过UART接口输出。
本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将结合HBird-E-SDK平台移植printf函数的实例了解更多细节。
7 提供板级支持包
对于特定的嵌入式硬件平台,为了方便用户在硬件平台上开发嵌入式程序,硬件平台一般会提供板级支持包(Board Support Package,BSP)。板级支持包所包含的内容没有绝对的标准,通常说来,其必须包含如下内容:
- 底层硬件设备的地址分配信息
- 底层硬件设备的驱动函数
- 系统的引导程序
- 中断和异常处理服务程序
- 系统的链接脚本
- 如果使用newlib作为C运行库,一般还提供newlib桩函数的实现。
由于板级支持包往往会将很多底层的基础设施和移植工作搭建好,因此应用程序开发人员通常都无需关心本文第1.2节至第1.6节中描述的内容,能够从底层细节中被解放出来避免重复建设而出错。本号后续发文《基于HBird-E-SDK平台的软件开发与运行》将结合HBird-E-SDK平台的BSP实例了解更多细节。
更多信息
感兴趣的读者可以通过下面二维码关注公众号“硅农亚历山大”,了解Verilog、IC设计、CPU、RISC-V和人工智能AI相关的更多设计技巧和经验分享,注意:由于干货太多,请自备茶水。