启动文件-进入c语言前需要做什么?【RISC-V】

目录

序言

开发单片机的时候,大家一般默认程序从void main()开始执行。
其实,这是一个表象,单片机在进入main函数前需要很多准备工作,只不过芯片厂商已经帮我们做好了,而且一般的开发者不想也没必要关心这些东西,毕竟用不到的东西学得没动力。
但是,现在我用到了。关注我的朋友们知道,前段时间我开发了一个RISC-V处理器,小麻雀处理器,所有内容都是我一个人做的。其中,为了让它可以运行c语言程序,就必须为它开发板级支持包BSP,让编译器可以编译出可以运行的程序。为了做出一个能用并且好用的BSP,我也不得不学习一下相关内容,做到其然且知其所以然才能更好的前进。
事实上,通过学习才知道,为了让处理器可以运行c语言程序,处理器、编译器、BSP开发者都为此做了很多的努力。为了理解启动文件的存在意义,下面我将从 编译流程、变量寻址、启动流程 三个层面逐步深入。
注意,以下内容是基于RISC-V架构和GCC编译器,且以小麻雀处理器作为实例,但它的基本原理可以推广到绝大多数的微控制器和编译器。

编译流程

大家在学习c语言的时候,教科书的第一章就是这些,相信大家都有所了解。现在,我带大家从一般性的原理过渡到微控制器的开发流程。
编译流程
首先,需要明确概念,我们一般说的编译,它包含了 预处理、编译、汇编、链接 4个步骤。我们一般说的编译器,它包含了 编译、汇编、链接、调试、反汇编 等功能模块。
其次,为了编译出单片机可以运行的程序,需要准备好 C语言代码、汇编代码、链接脚本 3类文件。
现在,我们开始。

  1. C语言代码
    这是大家最熟悉的东西了。
    C语言通常用于开发应用程序,让单片机可以用在各种各样的地方。
    对于C语言代码,编译器(riscv-none-embed-gcc)可以分析C语言的所描述的行为,并根据处理器的指令集的功能,找到一种功能等价的汇编语言描述,这一步称为编译

  2. 汇编代码
    这是大家听说过但没有仔细研究的东西。
    汇编代码可以是手写的,也可以是编译生成的。手写汇编一般有两个需求:C语言无法表达的行为,如启动文件;大佬的汇编级优化代码。
    对于汇编代码,汇编器(riscv-none-embed-as)可以分析各种汇编指令和伪代码,并根据处理器的指令定义,转换为处理器可以看懂的二进制文件,这一步称为汇编
    注意,处理器可以看懂这里生成的二进制文件,但不代表它可以正常运行,因为它还是不完整的。

  3. 链接脚本
    这是大家比较陌生的东西。
    链接脚本搭建了编译器和芯片的桥梁,它告诉链接器(riscv-none-embed-ld)芯片的Flash程序存储器和SRAM数据存储器有多大,地址在哪里,需要分配多大的堆栈空间,各种代码段的地址和长度是多少。
    链接器根据链接脚本提供的内存布局信息,对汇编生成的二进制文件进行组装,把它们分配到合适的区域,生成处理器正常可以运行的程序的程序,这一步称为链接
    链接后便可生成hex、bin、elf等格式的文件,它们可以直接写进单片机运行。

通过这三类文件,让编译器可以知道,你的芯片是什么样的,你的需求是什么,最后生成单片机可以运行的程序。

变量寻址

看下面这段c语言程序:

#include <stdint.h>
#include <stdio.h>

uint32_t aaa; //全局变量-无初始值-bss段 <-全局指针GP
uint32_t bbb=32; //全局变量-有初始值-data段 <-全局指针GP

void main()
{
    uint32_t ccc; //局部变量-动态分配 <-堆栈指针SP
    ccc=10;
    printf("%c\n", ccc);
}

这里面有3种变量:

  • 全局变量,无初始值 aaa
  • 全局变量,有初始值 bbb
  • 局部变量 ccc

RISC-V架构的寄存器组定义了x1-x31共31个自由使用的可读可写的寄存器,对于手写汇编而言,它们只有地址的区别而没有功能的区别。
但是,对于汇编/C混合的大型程序而言,为了保证程序可以正确运行,在函数调用/返回过程中可以正确地传递参数,RV定义了一套函数调用规范,声明了寄存器的功能和二进制接口(ABI)名称。注意,这种定义并非强制性的,而是一种大家约定好并且遵守的方案,目的是让不同的编译器、程序可以在不同的硬件平台上相互兼容。

变量寻址
如图所示,全局指针与栈指针与C语言变量是直接相关的。

寄存器 ABI名称 功能 变量类型 对应变量
x2 sp 栈指针 局部变量 aaa,bbb
x3 gp 全局指针 全局/静态变量 ccc

如图所示,对于RISC-V架构,为了便于寻址,变量在内存的分布定义为:

变量类型 初始值 内存分布 对应变量
全局/静态变量 bss段 aaa
全局/静态变量 data段 bbb
局部变量 无关 栈区 ccc

链接脚本定义了bss段和data段的大小和位置,程序定义了data段的初始值,执行C语言前需要对bss段和data段做如下处理。

  • bss段,默认初始值为0,数据存储器对应区域全部清0
  • data段,有初始值,将程序存储器对应数据搬移至数据存储器对应区域

为了保证有/无初始值的全局/静态变量可以正常访问,执行C语言前需要全局指针gp指向链接脚本定义的bss段、data段。
为了保证局部变量可以正常访问,执行C语言前需要栈指针sp指向链接脚本定义的堆栈区域。

处理bss段和data段,配置全局指针gp和栈指针sp,需要在执行第一条C语言程序之前完成,那就需要用汇编语言实现。
实现这些功能,为进入void main()做准备的汇编程序文件,便称为启动文件

启动文件

下面以小麻雀处理器的启动文件start.S作为讲解案例。点此进入
为了强化理解,启动流程参考下图:
变量寻址

启动文件要放最前

第1行,声明以下内容的段名为init。而在链接脚本中,init段放在程序存储器的最前面,是处理器上电后第一个执行的程序。

配置全局指针GP

第10行,通过la命令,将__global_pointer$写入寄存器gp
其中,__global_pointer$是由链接脚本提供的全局指针初始值,编译器会合理分配。

配置栈指针GP

第12行,通过la命令,将_sp写入寄存器sp
其中,_sp是由链接脚本提供的栈指针位置,开发者应根据数据存储器大小和程序调用层次合理分配。

加载data段

第14-25行,将data段从程序存储器搬运至数据存储器,作为可读可写的变量。

第15-17行,向寄存器a0、a1、a2加载必要数据,地址由链接脚本生成:

寄存器 加载值 功能
a0 程序存储器的data段起始地址 读指针
a1 数据存储器的data段起始地址 写指针
a2 数据存储器的data段结束地址 结束搬移的地址

第18行,如果a1大于等于a2,表示没有需要搬运的数据,跳过以下循环,执行下面的工作。

第19-24行,是一个循环结构。

  1. 读出a0指向的地址,数据写入t0暂存。
  2. t0的数据写入a1指向的地址。
  3. a0+4,指向下一存储单元,因为32bit是4字节。
  4. a1+4,指向下一存储单元,因为32bit是4字节。
  5. 如果a1小于a2,表示未搬运完,跳转至19行,进入下一次循环。

如果a1等于a2,表示已经搬完了最后一个数据,退出循环,执行下面的工作。

清空bss段

第28-36行,工作与data段有点类似,但是只需要清空指定位置的数据。
第29-30行,向寄存器a0、a1加载必要数据,地址由链接脚本生成:

|寄存器|加载值|功能| |-|-|-| |a0|bss段起始地址|指针| |a1|bss段结束地址|结束清空的地址| 第31行,如果a0大于等于a1,表示没有需要清0的空间,跳过以下循环,执行下面的工作。
第32-35行,是一个循环结构。

  1. 清空a0指向的地址
  2. a0+4,指向下一存储单元,因为32bit是4字节。
  3. 如果a0小于a1,表示清0未结束,跳转至32行,进入下一次循环。

如果a0等于a1,表示已经清0了最后一个存储单元,退出循环,执行下面的工作。

配置中断向量表

这是向量化中断所需的东西。如果没有中断或中断入口固定,可以跳过,非必需。

系统初始化

这一步已经可以运行C语言程序了,但是在进入void main()之前,还需要配置好系统时钟以及各种设计,这里可以自由发挥,非必需。

进入main函数

千呼万唤始出来!
做好了必要的准备工作,进入C语言的世界!

void main()
    { }

总结

启动文件很简单,研究的人很少,背后的意义很深奥。
这篇文章凝聚了我对嵌入式、C语言、计算机的理解和多年的经验,但即使是我也不能说“我搞懂了”“我是对的”,我只能说出我的理解,如有谬误或疑问,欢迎探讨和交流。

世界很深奥,越是学习,就能看到越多的未知领域。
我们不能对未知视而不见,也不能被未知所压垮。
学习没有起点也没有终点,我们一直在路上。

打赏?

取消

不用哦

扫码支持
这里只有暗黑赛钱箱

深邃♂黑暗♂幻想

>