一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

时间:2023-03-08 15:51:38

如果要做嵌入式Linux,我们首先要在板子上烧写的往往不是kernel,而是u-boot,这时需要烧写工具帮忙。当u-boot烧写成功后,我们就可以用u-boot附带的网络功能来烧写kernel了。每当板子上电时,u-boot一般会被加载到内存的前半段,如果我们的kernel之前就已经被烧写到开发板了,那么u-boot会加载kernel到内存的后半段并跳转到kernel的起始地址处执行(或者直接跳转到kernel的起始地址处执行,如果kernel可以直接在flash上执行的话。)

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

如上图所示,绿色部分为u-boot,红色部分为kernel。

把loader(指u-boot)和kernel分离究竟有什么好处呢?

举个极端的例子:没有grub的话,我们就没办法做windows和linux双系统了。这就是最大的好处。

然而对于嵌入式,我倒是说不出什么上得了台面的理由,根据个人喜好,我倒是有3点理由:

1、不用再求助烧写工具了;

2、方便使用GNU交叉编译工具;

3、摆脱Windows+linux虚拟机的工作平台。

现在,我的笔记本就可以轻松一下了,只需单开fedora/ubuntu就能工作啦!

以下是源码和工程的下载链接:

kernel源码及mdk5工程

kernel的arm-gcc源码

my-boot源码及mdk5工程

注:仅可使用在stm32f10x系列

接下来,我们将分为三部分叙述:

1、系统概述;

2、kernel;

3、“my-boot”;

4、先烧写"my-boot“,然后用"my-boot”加载kernel——操作示例;

1、系统概述

接下来我们将建立两个工程,一个是用来编译kernel,一个用来编译loader(姑且命名为“my-boot”)。首先,我们先把“my-boot”和kernel都编译好,并通过烧写工具把“my-boot”烧写进stm32的flash中。然后,我们就可以重启stm32,并使之运行“my-boot”。“my-boot”等待接收烧写kernel的起始命令,当我们通过串口向“my-boot”发送了烧写起始命令后,“my-boot”将把串口设置为DMA模式,并等待我们发送kernel的bin文件。接着,我们再通过串口传送kernel的bin文件。传送结束后,kernel也就被写入stm32的RAM中了,同时“my-boot”把串口切换回通常的窗口通信模式。此时,芯片的控制权依旧被掌控在“my-boot”手中,不过,如果我们再向串口发送一条启动kernel的指令,那么stm32将跳转到kernel代码处执行。至此,我们的目标达成。

2、kernel

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

我们的kernel很简单,只有一个源文件,其功能就是不停的闪led。程序参考了博客http://www.cnblogs.com/sky1991/archive/2012/10/13/2722640.html的“例子一”,并加以修改与简化,代码给出如下:

 ;RCC寄存器地址映像
RCC_BASE EQU 0x40021000
RCC_CR EQU (RCC_BASE + 0x00)
RCC_CFGR EQU (RCC_BASE + 0x04)
RCC_CIR EQU (RCC_BASE + 0x08)
RCC_APB2RSTR EQU (RCC_BASE + 0x0C)
RCC_APB1RSTR EQU (RCC_BASE + 0x10)
RCC_AHBENR EQU (RCC_BASE + 0x14)
RCC_APB2ENR EQU (RCC_BASE + 0x18)
RCC_APB1ENR EQU (RCC_BASE + 0x1C)
RCC_BDCR EQU (RCC_BASE + 0x20)
RCC_CSR EQU (RCC_BASE + 0x24)
;GPIO寄存器地址映像
GPIOA_BASE EQU 0x40010800
GPIOA_CRL EQU (GPIOA_BASE + 0x00)
GPIOA_CRH EQU (GPIOA_BASE + 0x04)
GPIOA_IDR EQU (GPIOA_BASE + 0x08)
GPIOA_ODR EQU (GPIOA_BASE + 0x0C)
GPIOA_BSRR EQU (GPIOA_BASE + 0x10)
GPIOA_BRR EQU (GPIOA_BASE + 0x14)
GPIOA_LCKR EQU (GPIOA_BASE + 0x18) SETENA0 EQU 0xE000E100
SETENA1 EQU 0xE000E104 ;;FLASH缓冲寄存器地址映像
FLASH_ACR EQU 0x40022000 ;-----------------
MSP_TOP EQU 0x20005000 ;主堆栈起始值
PSP_TOP EQU 0x20004E00 ;进程堆栈起始值 DelayTime EQU ; to choose a better number to fit your cpu
CLRPEND0 EQU 0xE000E280 ;常数定义---------
Bit0 EQU 0x00000001
Bit1 EQU 0x00000002
Bit2 EQU 0x00000004
Bit3 EQU 0x00000008
Bit4 EQU 0x00000010
Bit5 EQU 0x00000020
Bit6 EQU 0x00000040
Bit7 EQU 0x00000080
Bit8 EQU 0x00000100
Bit9 EQU 0x00000200
Bit10 EQU 0x00000400
Bit11 EQU 0x00000800
Bit12 EQU 0x00001000
Bit13 EQU 0x00002000
Bit14 EQU 0x00004000
Bit15 EQU 0x00008000
Bit16 EQU 0x00010000
Bit17 EQU 0x00020000
Bit18 EQU 0x00040000
Bit19 EQU 0x00080000
Bit20 EQU 0x00100000
Bit21 EQU 0x00200000
Bit22 EQU 0x00400000
Bit23 EQU 0x00800000
Bit24 EQU 0x01000000
Bit25 EQU 0x02000000
Bit26 EQU 0x04000000
Bit27 EQU 0x08000000
Bit28 EQU 0x10000000
Bit29 EQU 0x20000000
Bit30 EQU 0x40000000
Bit31 EQU 0x80000000 ;向量表*********************************************************************************
AREA RESET, DATA, READONLY DCD MSP_TOP ;初始化主堆栈
DCD Start ;复位向量
DCD NMI_Handler ;NMI Handler
DCD HardFault_Handler ;Hard Fault Handler
;***************************************************************************************
AREA |.text|, CODE, READONLY
;主程序开始
ENTRY ;指示程序从这里开始执行
Start
CPSID I              ;关中断
ldr r0, =MSP_TOP        
msr msp, r0        ;重设MSP
mov r0, #0            
msr control, r0         ;切换MSP,并进入特权级 mov r0, #
mov r1, #
mov r2, #
mov r3, #
mov lr, # ldr r0, =CLRPEND0
ldr r1, [r0]
orr r1, #0xFFFFFFFF
str r1, [r0] ;时钟系统设置
;启动外部8M晶振 ldr r0,=RCC_CR
ldr r1,[r0]
orr r1,#Bit16
str r1,[r0]
ClkOk
ldr r1,[r0]
ands r1,#Bit17
beq ClkOk
ldr r1,[r0]
orr r1,#Bit17
str r1,[r0]
;FLASH缓冲器
ldr r0,=FLASH_ACR
mov r1,#0x00000032
str r1,[r0]
;设置PLL锁相环倍率为7,HSE输入不分频
ldr r0,=RCC_CFGR
ldr r1,[r0]
orr r1,#Bit18 | Bit19 | Bit20 | Bit16 | Bit14
orr r1,#Bit10
str r1,[r0]
;启动PLL锁相环
ldr r0,=RCC_CR
ldr r1,[r0]
orr r1,#Bit24
str r1,[r0]
PllOk
ldr r1,[r0]
ands r1,#Bit25
beq PllOk
;选择PLL时钟作为系统时钟
ldr r0,=RCC_CFGR
ldr r1,[r0]
orr r1,#Bit18 | Bit19 | Bit20 | Bit16 | Bit14
orr r1,#Bit10
orr r1,#Bit1
str r1,[r0]
;其它RCC相关设置
ldr r0,=RCC_APB2ENR
mov r1,#Bit2
str r1,[r0]
;IO端口设置
ldr r0,=GPIOA_CRH
ldr r1,[r0]
orr r1,#Bit0 | Bit1 ;PA.8输出模式,最大速度50MHz
and r1,#~Bit2 & ~Bit3 ;PA.8通用推挽输出模式
str r1,[r0] mov r5, # ; led flag ;CPSIE I
;主循环=================================================================================
main
bl Delay
bl LedFlas
b main
;子程序**********************************************************************************
LedFlas
push {r0-r3}
cmp r5,#
beq ONLED mov r5, #
;PA.8输出1
ldr r0,=GPIOA_BRR
ldr r1,[r0]
orr r1,#Bit8
str r1,[r0]
b LedEx
ONLED
mov r5, #
;PA.8输出0
ldr r0,=GPIOA_BSRR
ldr r1,[r0]
orr r1,#Bit8
str r1,[r0]
LedEx
pop {r0-r3}
bx lr Delay
push {r0-r3} ldr r0, =DelayTime
Loop CBZ r0, LoopExit
sub r0, #
b Loop
LoopExit
pop {r0-r3}
bx lr
;异常程序*******************************************************************************
NMI_Handler
;xxxxxxxxxxxxxxxxxx
bx lr
;-----------------------------
HardFault_Handler
;xxxxxxxxxxxxxxxxxx
bx lr
;***************************************************************************************
ALIGN ;通过用零或空指令NOP填充,来使当前位置与一个指定的边界对齐
;-----------------------------
END

(1)主循环程序:

main

  bl Delay     // 延时
  bl LedFlas  // 翻转led
  b main      // 跳转会main开头(即“延时”)

(2)延时程序:

Delay
  push {r0-r3}

  ldr r0, =DelayTime // r0 = DelayTime;
Loop

   CBZ r0, LoopExit // if(r0 != 0) {
  sub r0, #1    //   r0 -= 1;
  b Loop      //   goto Loop; }
LoopExit
  pop {r0-r3}
  bx lr

该延时程序是“C51式”的延时,就是纯粹的让CPU空跑n个周期,这里是“DelayTime=13000000“。“13000000”是随便设的一个数,只是为了让眼睛和耐性都能接受,时钟频率变化之后,这个数字可以自行的、随性的去进行调整。

(3)LED翻转程序:

LedFlas
  push {r0-r3}
  cmp r5,#1     // if(r5 == 1)
  beq ONLED     //   goto ONLED;

  mov r5, #1       //  r5 = 1;
  ;PA.8输出1
  ldr r0,=GPIOA_BRR 
  ldr r1,[r0]       
  orr r1,#Bit8
  str r1,[r0]
  b LedEx
ONLED
  mov r5, #0       // r5 = 0;
  ;PA.8输出0
  ldr r0,=GPIOA_BSRR
  ldr r1,[r0]
  orr r1,#Bit8
  str r1,[r0]
LedEx
  pop {r0-r3}
  bx lr

该LED翻转程序以“r5”寄存器为标志,“r5”为0或1时,分别使PA.8输出不同的电平(此处PA.8对应开发板上一个红色LED)。

注:

一般MDK会生成hex文件,但不生成bin文件,所以我们还要给MDK加一些设置:

先找到fromelf.exe文件(一般在你的MDK安装目录里的bin目录里),然后如下图输入,

如:

C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe --bin --output kernel.bin kernel.axf

重新编译之后,于是我们就得到kernel的bin文件了,即kernel.bin,留着备用。

此处的kernel是可以独立运行的,所以不妨将该程序通过烧写工具烧写进开发板验证一下。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

Note:

如果要用arm-gcc的kernel,首先,你的Linux必须得有arm-gcc编译工具。可使用目录中提供的脚本build.sh直接编译。此处我用的是“arm-none-eabi-as”等,如果是arm-linux-eabi-as等,需要简单修改脚本中的“PREFIX”变量。

3、“my-boot”

我们知道,在kernel和loader之间,真正的主角是kernel,loader只是一个辅助工具罢了。然而,作为loader的"my-boot"在这里却比kernel复杂许多。

“my-boot”以一步步学习操作系统(1)中的代码为基础,并将之整理了一下,把各个源文件分类到了不同的目录。

如图,除了obj目录是存放编译时所用的中间文件和hex文件外,其余4个目录都存放源码。

(1)arch目录:其中的源码均是和CPU架构相关,如中断代码、串口初始化、启动代码等;

(2)include目录:所有的头文件都在这里;

(3)kernel目录:包含主函数、任务调度、延时相关的源码;

(4)lib目录:stm32f10x库函数源码及“printf”重定向至串口的辅助代码(printf_to_serial)。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

主程序一共建立3个任务:Task1, TaskBH, TaskDMA_Print。

 int main(void)
{
memset(SRAM_Buffer, , PAGE_SIZE);
OSInit(); OSTaskCreate(Task1, (void*), (OS_STK*)&Task1Stk[TASK_STACK_SIZE-]);
OSTaskCreate(TaskBH, (void*), (OS_STK*)&TaskBHStk[TASK_STACK_SIZE-]);
OSTaskCreate(TaskDMA_Print, (void*), (OS_STK*)&TaskDMA_PrintStk[TASK_STACK_SIZE-]); OSStart();
}

Task1:和kernel的功能一样,也是不断的闪led(最好是不同于kernel所使用的led),用来指示程序依旧正常运行,其功能很单纯;

TaskBH:接受串口发送过来的相关命令,并向串口打印信息以提示命令发送成功。特别是当收到“startos”指令后,会置位变量“GotoKernelFlag”,以致后续代码将跳转到kernel运行,该任务是三个任务中最复杂的一个;

TaskDMA_Print:打印RAM中的kernel代码。

其实以上三个任务的负担并不重,身上担子最重的时串口中断程序:

串口通信遵循一个自定义的协议,协议内容如下:

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

将以下串口中断程序与TaskBH结合着看,串口接受三种命令:

第一种:BURN命令:

如:

"BURN 0x08004000"

协议信息16进制表示为

57 41 4e 15 00 75 42 55 52 4e 20 30 78 30 38 30 30 34 30 30 30

该命令就是在通知开发板:“我要发送kernel了呦,赶紧准备接驾。”

这时,串口中断程序会启动串口的DMA模式,并开启DMA中断。

关于命令中的地址“0x08004000”,该值是设计为以后烧写flash做准备的,但现在我们只将kernel写入SRAM,所以现在还没有特别的作用,任意值都可以。

这个命令发送之后就要小心了,紧跟着必须向串口发送kernel的bin文件。发送结束后,DMA中断会被触发,并且会调用“LED1TURN()”去翻转另一个LED(不同于Task1的LED),用以指示kernel已经被写入RAM。

Note:看了以下代码后,其实对于"BURN”这个命令来说,校验和是形同虚设的,为了图方便就偷了个懒……

 volatile void IRQ_Usart1(void)
{ RecvBuffer[Index] = serial_1; // Magic handling
// Byte order: 0 1 2
if(!MagicGotten) {
if( == Index && 'W' == RecvBuffer[Index]) {
Index++;
}else if( == Index && 'A' == RecvBuffer[Index]) {
Index++;
}else if( == Index && 'N' == RecvBuffer[Index]) {
Index++;
MagicGotten = TRUE;
}else {
Index = ;
}
return;
} // Size handling
// byte order: 3 4
if(!SizeGotten) {
Index++;
if( == Index) {
SizeGotten = TRUE;
MsgSize = RecvBuffer[] + (RecvBuffer[] << );
}
if(SizeGotten && MsgSize > BUFSIZ) {
MagicGotten = FALSE;
SizeGotten = FALSE;
Index = ;
}
return;
} // Checksum handling
// byte order: 5
if(!ChecksumGotten) {
Index++;
if( == Index) {
ChecksumGotten = TRUE;
}else {
MagicGotten = FALSE;
SizeGotten = FALSE;
Index = ;
}
return;
} // Data handling:
// byte order: 6...
Index++;
if(Index >= MsgSize) {
MagicGotten = FALSE;
SizeGotten = FALSE;
ChecksumGotten = FALSE;
Index = ;
MsgGotten = TRUE;
if( == strncmp((char *)RecvBuffer + , "BURN", )) {
USART_Cmd(USART1, DISABLE);
USART_ITConfig(USART1, USART_IT_RXNE, DISABLE);
USART_DMACmd(USART1,USART_DMAReq_Rx,ENABLE);
DMA1_Channel5->CNDTR = PAGE_SIZE;//re-load
DMA_Cmd(DMA1_Channel5, ENABLE);//re-open DMA
USART_Cmd(USART1, ENABLE);
LED1TURN();
}
}
}

第二种:“startos”

协议信息16进制表示为

57 41 4e 0d 00 f8 73 74 61 72 74 6f 73

开发板接收到该命令后,TaskBH会将变量“GotoKernelFlag”设为1。之后,当SysTick中断程序(如下)再次执行时,将会调用“ModifyPC()”(这里的“PC”不是指“Personal Computer”,而是指PC指令寄存器哦)。这个函数很难懂。如果能理解这个函数,那么loader加载kernel的原理也就等于理解了80%了。我们不妨来试着啃一啃这块硬骨头!

 volatile void IRQ_SysTick(void)
{
OS_ENTER_CRITICAL();
if(GotoKernelFlag) ModifyPC();
if((--TaskTimeSlice) == ){
TaskTimeSlice = TASK_TIME_SLICE;
OSTaskSchedule();
}
TimeMS++; OS_EXIT_CRITICAL();
}

“ModifyPC()”是嵌入C语言式的汇编代码。其作用就是:

修改 PSP中存储的、“当前被SysTick中断的任务”的 PC指针,使之等于kernel代码的起始地址。当该任务再一次被调度时,由于PC被换成了kernel代码的起始地址,所以就进入了kernel。

于是,两个问题出现了:

(1)kernel的起始地址是什么?

(2)被SysTick中断的任务的PC又在哪?

或许有人会认为:“kernel在DMA传送时,被放进‘SRAM_Buffer’这个缓冲区了,那么kernel的起始地址不就是‘SRAM_Buffer’吗?”(一开始我也是这么想的……)

可惜,真正的“起始地址”要比SRAM_Buffer在靠后一点点。

不妨在MDK5下,在kernel工程里打开Debug,接着再用二进制编辑器打开kernel.bin,这样就能看出蹊跷了。

stm32烧写程序时,是将代码烧至起始地址为0x08000000的flash中,并在开机运行时也是直接从flash启动。

看到没有,我们开机时的第一条命令是“CPSID I”,对应的指令地址为0x08000010,机器码为“B672”。

再用二进制文件打开kernel.bin后,发现果然是“B672”(二进制文件为“小端法”表示,所以是“72 B6”)。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

所以,我们的kernel代码的起始地址,确切来说是第一条命令“CPSID I”的地址为“SRAM_Buffer + 0x10”。

Note:

“既然代码烧写进地址为0x08000000起始的地方,那么第一条指令为什么确实0x08000010呢?”

意味0x08000010 - 0x08000000 = 0x10 = 16 = 4*4,也即代码开头的“4个DCD”,每个DCD4字节。

第二个问题,“被SysTick中断的任务”的PC到底在哪儿呢?

首先我们要知道,任务使用的是PSP(可参考“PendSV_Handler”的汇编代码)。确认了这点之后,我们就可以继续往下讲了。

根据《Cortex-M3权威指南》--“chap09中断的具体行为”--“入栈”,当SysTick中断发生时,PSP会将发生如下图的变化。

也就是说,当SysTick中断发生时,CPU会自动将被中断任务的R0-R3,R12,LR,PC,xPSR这8个寄存器装载进PSP的后续存储空间,并且PSP最后将指向被中断任务R0寄存器的存储地址。

那么被中断任务的PC寄存器的存储地址就找到啦:PSP+24!如果该任务再次被调度执行,其第一条指令就是地址“PSP+24”存储的内容,如果我“偷偷的”把这个存储内容换成kernel代码的起始地址(确切来说,是第一条指令所在的地址),那么当该任务再次被调度时,原来的任务摇身一变,就成了kernel。

那么,ModifyPC()函数的代码就比较容易理解了。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

PCModifyPC伪代码可写为:

ModifyPC()

  PSP.PC = SRAM_Buffer+0x10

 __asm void ModifyPC(void) {
IMPORT SRAM_Buffer
MRS R0, PSP
LDR R1, =SRAM_Buffer
ADD R1, #0x10
STR R1, [R0, #]
BX LR
align
}

第三种:任意字符串

如:“ls”

协议信息16进制表示为

57 41 4e 08 00 31 6c 73

该命令将对TaskDMA_Print的行为产生影响(代码如下)。

不难看出,只有当“ReadDMAFlag不为0时,该任务才会打印缓冲区SRAM_Buffer的内容。而在TaskBH中,上述命令会使变量“ReadDMAFlag”在0,1之间翻转,所以该命令也就起到控制打印“SRAM_Buffer”内容的作用。

 void TaskDMA_Print(void *p_arg)
{
int i = ;
while() {
delayMs();
if(!ReadDMAFlag) continue;
printf("########DMA##########START\r\n");
for(i = ; i < PAGE_SIZE; i++) {
printf("%x ", SRAM_Buffer[i]);
}
printf("########DMA##########END\r\n"); }
}

“my-boot"中几点注意事项:

(1)宏定义PAGE_SIZE

该宏在hardware.h中定义如下:

#define PAGE_SIZE 284

“284”?这个数字怎么这么莫名其妙?其实它表示的是kernel的大小(如下图),同时它也决定了缓冲区SRAM_Buffer的大小。

如果我编译了一个新的kernel,大小不再是284字节了怎么办?

实在对不住!“my-boot”中的这个宏也要改成相应的数字。当然,这确实是个不合理的地方,但现在为使代码尽可能简洁,所以就未做完善这方面的工作了,暂且辛苦一下。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

(2)预设宏定义:USE_STDPERIPH_DRIVER

为了使用stm32的函数库,且避免编译出错,故定义该宏。具体内容可查询“stm32f10x.h”第8296行附近的代码。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

(3)库函数文件:

如果stm32的型号不是stm32f10x系列的,需要自备相应的函数库。

4、先烧写"my-boot“,然后用"my-boot”加载kernel——操作示例

(1)将“my-boot”烧进stm32开发板

(2)向stm32开发板发送烧写命令:

BURN 0x08004000

16进制表示为
57 41 4e 15 00 75 42 55 52 4e 20 30 78 30 38 30 30 34 30 30 30

命令发送之后,串口工具会打印信息“Addr: 8004000”。而且还有一个变化,那就是另一个LED灯亮了/灭了(如果存在第二个led的话)。

注意,是16进制发送。
一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

(3)发送kernel.bin

这时我们会发现,刚刚亮了/灭了的LED现在又灭了/亮了(如果存在第二个led的话)。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

(4)打印刚刚烧进SRAM中的kernel命令(可选):

ls

16进制表示为

57 41 4e 08 00 31 6c 73

该命令发送一次,就会打印一次“###ls###”,并且跟后会打印SRAM中的内容。如果该命令只发送一次,那么SRAM中的打印将每隔2秒打印一次,直到再一次发送该命令为止。

所以图中有2个“###ls###”,第二个就是终止打印的。

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"

(5)启动kernel:

startos

16进制表示为

57 41 4e 0d 00 f8 73 74 61 72 74 6f 73

这时你将会看到开发板在运行kernel的程序啦!

一步步学习操作系统(2)——在STM32上实现一个可动态加载kernel的"my-boot"