如果要做嵌入式Linux,我们首先要在板子上烧写的往往不是kernel,而是u-boot,这时需要烧写工具帮忙。当u-boot烧写成功后,我们就可以用u-boot附带的网络功能来烧写kernel了。每当板子上电时,u-boot一般会被加载到内存的前半段,如果我们的kernel之前就已经被烧写到开发板了,那么u-boot会加载kernel到内存的后半段并跳转到kernel的起始地址处执行(或者直接跳转到kernel的起始地址处执行,如果kernel可以直接在flash上执行的话。)
如上图所示,绿色部分为u-boot,红色部分为kernel。
把loader(指u-boot)和kernel分离究竟有什么好处呢?
举个极端的例子:没有grub的话,我们就没办法做windows和linux双系统了。这就是最大的好处。
然而对于嵌入式,我倒是说不出什么上得了台面的理由,根据个人喜好,我倒是有3点理由:
1、不用再求助烧写工具了;
2、方便使用GNU交叉编译工具;
3、摆脱Windows+linux虚拟机的工作平台。
现在,我的笔记本就可以轻松一下了,只需单开fedora/ubuntu就能工作啦!
以下是源码和工程的下载链接:
注:仅可使用在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
我们的kernel很简单,只有一个源文件,其功能就是不停的闪led。程序参考了博客http://www.cnblogs.com/sky1991/archive/2012/10/13/2722640.html的“例子一”,并加以修改与简化,代码给出如下:
1 ;RCC寄存器地址映像 2 RCC_BASE EQU 0x40021000 3 RCC_CR EQU (RCC_BASE + 0x00) 4 RCC_CFGR EQU (RCC_BASE + 0x04) 5 RCC_CIR EQU (RCC_BASE + 0x08) 6 RCC_APB2RSTR EQU (RCC_BASE + 0x0C) 7 RCC_APB1RSTR EQU (RCC_BASE + 0x10) 8 RCC_AHBENR EQU (RCC_BASE + 0x14) 9 RCC_APB2ENR EQU (RCC_BASE + 0x18) 10 RCC_APB1ENR EQU (RCC_BASE + 0x1C) 11 RCC_BDCR EQU (RCC_BASE + 0x20) 12 RCC_CSR EQU (RCC_BASE + 0x24) 13 ;GPIO寄存器地址映像 14 GPIOA_BASE EQU 0x40010800 15 GPIOA_CRL EQU (GPIOA_BASE + 0x00) 16 GPIOA_CRH EQU (GPIOA_BASE + 0x04) 17 GPIOA_IDR EQU (GPIOA_BASE + 0x08) 18 GPIOA_ODR EQU (GPIOA_BASE + 0x0C) 19 GPIOA_BSRR EQU (GPIOA_BASE + 0x10) 20 GPIOA_BRR EQU (GPIOA_BASE + 0x14) 21 GPIOA_LCKR EQU (GPIOA_BASE + 0x18) 22 23 SETENA0 EQU 0xE000E100 24 SETENA1 EQU 0xE000E104 25 26 ;;FLASH缓冲寄存器地址映像 27 FLASH_ACR EQU 0x40022000 28 29 ;----------------- 30 MSP_TOP EQU 0x20005000 ;主堆栈起始值 31 PSP_TOP EQU 0x20004E00 ;进程堆栈起始值 32 33 DelayTime EQU 13000000 ; to choose a better number to fit your cpu 34 CLRPEND0 EQU 0xE000E280 35 36 ;常数定义--------- 37 Bit0 EQU 0x00000001 38 Bit1 EQU 0x00000002 39 Bit2 EQU 0x00000004 40 Bit3 EQU 0x00000008 41 Bit4 EQU 0x00000010 42 Bit5 EQU 0x00000020 43 Bit6 EQU 0x00000040 44 Bit7 EQU 0x00000080 45 Bit8 EQU 0x00000100 46 Bit9 EQU 0x00000200 47 Bit10 EQU 0x00000400 48 Bit11 EQU 0x00000800 49 Bit12 EQU 0x00001000 50 Bit13 EQU 0x00002000 51 Bit14 EQU 0x00004000 52 Bit15 EQU 0x00008000 53 Bit16 EQU 0x00010000 54 Bit17 EQU 0x00020000 55 Bit18 EQU 0x00040000 56 Bit19 EQU 0x00080000 57 Bit20 EQU 0x00100000 58 Bit21 EQU 0x00200000 59 Bit22 EQU 0x00400000 60 Bit23 EQU 0x00800000 61 Bit24 EQU 0x01000000 62 Bit25 EQU 0x02000000 63 Bit26 EQU 0x04000000 64 Bit27 EQU 0x08000000 65 Bit28 EQU 0x10000000 66 Bit29 EQU 0x20000000 67 Bit30 EQU 0x40000000 68 Bit31 EQU 0x80000000 69 70 71 ;向量表********************************************************************************* 72 AREA RESET, DATA, READONLY 73 74 DCD MSP_TOP ;初始化主堆栈 75 DCD Start ;复位向量 76 DCD NMI_Handler ;NMI Handler 77 DCD HardFault_Handler ;Hard Fault Handler 78 ;*************************************************************************************** 79 AREA |.text|, CODE, READONLY 80 ;主程序开始 81 ENTRY ;指示程序从这里开始执行 82 Start 83 CPSID I ;关中断 84 ldr r0, =MSP_TOP 85 msr msp, r0 ;重设MSP 86 mov r0, #0 87 msr control, r0 ;切换MSP,并进入特权级 88 89 mov r0, #0 90 mov r1, #0 91 mov r2, #0 92 mov r3, #0 93 mov lr, #0 94 95 ldr r0, =CLRPEND0 96 ldr r1, [r0] 97 orr r1, #0xFFFFFFFF 98 str r1, [r0] 99 100 101 ;时钟系统设置 102 ;启动外部8M晶振 103 104 ldr r0,=RCC_CR 105 ldr r1,[r0] 106 orr r1,#Bit16 107 str r1,[r0] 108 ClkOk 109 ldr r1,[r0] 110 ands r1,#Bit17 111 beq ClkOk 112 ldr r1,[r0] 113 orr r1,#Bit17 114 str r1,[r0] 115 ;FLASH缓冲器 116 ldr r0,=FLASH_ACR 117 mov r1,#0x00000032 118 str r1,[r0] 119 ;设置PLL锁相环倍率为7,HSE输入不分频 120 ldr r0,=RCC_CFGR 121 ldr r1,[r0] 122 orr r1,#Bit18 | Bit19 | Bit20 | Bit16 | Bit14 123 orr r1,#Bit10 124 str r1,[r0] 125 ;启动PLL锁相环 126 ldr r0,=RCC_CR 127 ldr r1,[r0] 128 orr r1,#Bit24 129 str r1,[r0] 130 PllOk 131 ldr r1,[r0] 132 ands r1,#Bit25 133 beq PllOk 134 ;选择PLL时钟作为系统时钟 135 ldr r0,=RCC_CFGR 136 ldr r1,[r0] 137 orr r1,#Bit18 | Bit19 | Bit20 | Bit16 | Bit14 138 orr r1,#Bit10 139 orr r1,#Bit1 140 str r1,[r0] 141 ;其它RCC相关设置 142 ldr r0,=RCC_APB2ENR 143 mov r1,#Bit2 144 str r1,[r0] 145 ;IO端口设置 146 ldr r0,=GPIOA_CRH 147 ldr r1,[r0] 148 orr r1,#Bit0 | Bit1 ;PA.8输出模式,最大速度50MHz 149 and r1,#~Bit2 & ~Bit3 ;PA.8通用推挽输出模式 150 str r1,[r0] 151 152 mov r5, #0 ; led flag 153 154 ;CPSIE I 155 ;主循环================================================================================= 156 main 157 bl Delay 158 bl LedFlas 159 b main 160 ;子程序********************************************************************************** 161 LedFlas 162 push {r0-r3} 163 cmp r5,#1 164 beq ONLED 165 166 mov r5, #1 167 ;PA.8输出1 168 ldr r0,=GPIOA_BRR 169 ldr r1,[r0] 170 orr r1,#Bit8 171 str r1,[r0] 172 b LedEx 173 ONLED 174 mov r5, #0 175 ;PA.8输出0 176 ldr r0,=GPIOA_BSRR 177 ldr r1,[r0] 178 orr r1,#Bit8 179 str r1,[r0] 180 LedEx 181 pop {r0-r3} 182 bx lr 183 184 Delay 185 push {r0-r3} 186 187 ldr r0, =DelayTime 188 Loop CBZ r0, LoopExit 189 sub r0, #1 190 b Loop 191 LoopExit 192 pop {r0-r3} 193 bx lr 194 ;异常程序******************************************************************************* 195 NMI_Handler 196 ;xxxxxxxxxxxxxxxxxx 197 bx lr 198 ;----------------------------- 199 HardFault_Handler 200 ;xxxxxxxxxxxxxxxxxx 201 bx lr 202 ;*************************************************************************************** 203 ALIGN ;通过用零或空指令NOP填充,来使当前位置与一个指定的边界对齐 204 ;----------------------------- 205 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_v5ARMARMCCinfromelf.exe --bin --output kernel.bin kernel.axf
重新编译之后,于是我们就得到kernel的bin文件了,即kernel.bin,留着备用。
此处的kernel是可以独立运行的,所以不妨将该程序通过烧写工具烧写进开发板验证一下。
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)。
主程序一共建立3个任务:Task1, TaskBH, TaskDMA_Print。
1 int main(void) 2 { 3 memset(SRAM_Buffer, 0, PAGE_SIZE); 4 OSInit(); 5 6 OSTaskCreate(Task1, (void*)0, (OS_STK*)&Task1Stk[TASK_STACK_SIZE-1]); 7 OSTaskCreate(TaskBH, (void*)0, (OS_STK*)&TaskBHStk[TASK_STACK_SIZE-1]); 8 OSTaskCreate(TaskDMA_Print, (void*)0, (OS_STK*)&TaskDMA_PrintStk[TASK_STACK_SIZE-1]); 9 10 OSStart(); 11 }
Task1:和kernel的功能一样,也是不断的闪led(最好是不同于kernel所使用的led),用来指示程序依旧正常运行,其功能很单纯;
TaskBH:接受串口发送过来的相关命令,并向串口打印信息以提示命令发送成功。特别是当收到“startos”指令后,会置位变量“GotoKernelFlag”,以致后续代码将跳转到kernel运行,该任务是三个任务中最复杂的一个;
TaskDMA_Print:打印RAM中的kernel代码。
其实以上三个任务的负担并不重,身上担子最重的时串口中断程序:
串口通信遵循一个自定义的协议,协议内容如下:
将以下串口中断程序与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”这个命令来说,校验和是形同虚设的,为了图方便就偷了个懒……
1 volatile void IRQ_Usart1(void) 2 { 3 4 RecvBuffer[Index] = serial_1; 5 6 // Magic handling 7 // Byte order: 0 1 2 8 if(!MagicGotten) { 9 if(0 == Index && 'W' == RecvBuffer[Index]) { 10 Index++; 11 }else if(1 == Index && 'A' == RecvBuffer[Index]) { 12 Index++; 13 }else if(2 == Index && 'N' == RecvBuffer[Index]) { 14 Index++; 15 MagicGotten = TRUE; 16 }else { 17 Index = 0; 18 } 19 return; 20 } 21 22 // Size handling 23 // byte order: 3 4 24 if(!SizeGotten) { 25 Index++; 26 if(5 == Index) { 27 SizeGotten = TRUE; 28 MsgSize = RecvBuffer[3] + (RecvBuffer[4] << 8); 29 } 30 if(SizeGotten && MsgSize > BUFSIZ) { 31 MagicGotten = FALSE; 32 SizeGotten = FALSE; 33 Index = 0; 34 } 35 return; 36 } 37 38 // Checksum handling 39 // byte order: 5 40 if(!ChecksumGotten) { 41 Index++; 42 if(6 == Index) { 43 ChecksumGotten = TRUE; 44 }else { 45 MagicGotten = FALSE; 46 SizeGotten = FALSE; 47 Index = 0; 48 } 49 return; 50 } 51 52 // Data handling: 53 // byte order: 6... 54 Index++; 55 if(Index >= MsgSize) { 56 MagicGotten = FALSE; 57 SizeGotten = FALSE; 58 ChecksumGotten = FALSE; 59 Index = 0; 60 MsgGotten = TRUE; 61 if(0 == strncmp((char *)RecvBuffer + 6, "BURN", 4)) { 62 USART_Cmd(USART1, DISABLE); 63 USART_ITConfig(USART1, USART_IT_RXNE, DISABLE); 64 USART_DMACmd(USART1,USART_DMAReq_Rx,ENABLE); 65 DMA1_Channel5->CNDTR = PAGE_SIZE;//re-load 66 DMA_Cmd(DMA1_Channel5, ENABLE);//re-open DMA 67 USART_Cmd(USART1, ENABLE); 68 LED1TURN(); 69 } 70 } 71 }
第二种:“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%了。我们不妨来试着啃一啃这块硬骨头!
1 volatile void IRQ_SysTick(void) 2 { 3 OS_ENTER_CRITICAL(); 4 if(GotoKernelFlag) ModifyPC(); 5 if((--TaskTimeSlice) == 0){ 6 TaskTimeSlice = TASK_TIME_SLICE; 7 OSTaskSchedule(); 8 } 9 TimeMS++; 10 11 OS_EXIT_CRITICAL(); 12 }
“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”)。
所以,我们的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()函数的代码就比较容易理解了。
PCModifyPC伪代码可写为:
ModifyPC()
PSP.PC = SRAM_Buffer+0x10
1 __asm void ModifyPC(void) { 2 IMPORT SRAM_Buffer 3 MRS R0, PSP 4 LDR R1, =SRAM_Buffer 5 ADD R1, #0x10 6 STR R1, [R0, #24] 7 BX LR 8 align 4 9 }
第三种:任意字符串
如:“ls”
协议信息16进制表示为
57 41 4e 08 00 31 6c 73
该命令将对TaskDMA_Print的行为产生影响(代码如下)。
不难看出,只有当“ReadDMAFlag不为0时,该任务才会打印缓冲区SRAM_Buffer的内容。而在TaskBH中,上述命令会使变量“ReadDMAFlag”在0,1之间翻转,所以该命令也就起到控制打印“SRAM_Buffer”内容的作用。
1 void TaskDMA_Print(void *p_arg) 2 { 3 int i = 0; 4 while(1) { 5 delayMs(2000); 6 if(!ReadDMAFlag) continue; 7 printf("########DMA##########START "); 8 for(i = 0; i < PAGE_SIZE; i++) { 9 printf("%x ", SRAM_Buffer[i]); 10 } 11 printf("########DMA##########END "); 12 13 } 14 }
“my-boot"中几点注意事项:
(1)宏定义PAGE_SIZE
该宏在hardware.h中定义如下:
#define PAGE_SIZE 284
“284”?这个数字怎么这么莫名其妙?其实它表示的是kernel的大小(如下图),同时它也决定了缓冲区SRAM_Buffer的大小。
如果我编译了一个新的kernel,大小不再是284字节了怎么办?
实在对不住!“my-boot”中的这个宏也要改成相应的数字。当然,这确实是个不合理的地方,但现在为使代码尽可能简洁,所以就未做完善这方面的工作了,暂且辛苦一下。
(2)预设宏定义:USE_STDPERIPH_DRIVER
为了使用stm32的函数库,且避免编译出错,故定义该宏。具体内容可查询“stm32f10x.h”第8296行附近的代码。
(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进制发送。
(3)发送kernel.bin
这时我们会发现,刚刚亮了/灭了的LED现在又灭了/亮了(如果存在第二个led的话)。
(4)打印刚刚烧进SRAM中的kernel命令(可选):
ls
16进制表示为
57 41 4e 08 00 31 6c 73
该命令发送一次,就会打印一次“###ls###”,并且跟后会打印SRAM中的内容。如果该命令只发送一次,那么SRAM中的打印将每隔2秒打印一次,直到再一次发送该命令为止。
所以图中有2个“###ls###”,第二个就是终止打印的。
(5)启动kernel:
startos
16进制表示为
57 41 4e 0d 00 f8 73 74 61 72 74 6f 73
这时你将会看到开发板在运行kernel的程序啦!