目录
1bootloader
1.1 什么是bootloader
bootloader即引导加载程序,通过一个程序引导另外一个程序启动。形象的例子就是就是开机电脑会通过BIOS系统引导windos系统启动。
1.2为什么要在单片机上用bootloader
单片机烧录程序一般是用烧录口烧录,但这样做在实际的工程应用中很不方便。即便是下位机程序也会存在迭代的问题,下位机程序如果每次迭代程序都需要通过烧录口来实现,可以想象一下,发出去的产品,你要一个个去现场迭代程序。如果可以在单片机上用引导程序的方式来更新程序,那么通过上位机即可以实现下位机的迭代。
1.3如何在单片机上实现bootloader
单片机的程序烧录是存储在flash里面的。即便烧录程序的过程就是flash写入的过程,程序实现的过程就是flash读取的过程。接下来bootloader的逻辑就很清晰了,先烧录一段程序,它主要实现两个功能:1.如果需要更新程序就更新程序,既写入flash的内容。2.如果不需要更新程序就引导程序启动,既通过读取flash里面的内容实现程序。这个程序我们称之为bootloader,被引导的程序就称之为app。
2bootloader的实现
2.1平台
下位机平台选用野火指南者开发板,其芯片为stm32f103ve。应用外设:串口1,串口3。分别通过CH340和esp8266同电脑交互。上位机程序用野火串口调试助手(新版,支持协议发送文件,协议为YModem协议)。
2.2flash
flash是一种存储器,即便是掉电了也不会导致内部的信息丢失。单片机的程序就是储存在falsh里面,如图所示,0x08000000-0x0807FFFF,代表的是该芯片(f103ve)flash可用空间。信息在在闪存编辑手册里面也有。f103ve一共有256页flash,每一页可以储存2k的内容。flash在使用前必须要擦除,flash擦除是以页为单位擦除。写入则可以一个字节一个字节的写入。
2.3bootloader实现程序初始化
要实现bootloader,需要将程序的功能细分。1.如果需要更新程序就更新程序,既写入flash的内容。先实现这一步,1接收到上位机指令需要更新程序,2接收文件内容,3写入flash。首先我们需要串口,YModem协议可以一次性传送128字节或者1024字节的有效信息。我选用串口DMA不定长度接收空闲中断处理,也可以用万能的环形缓冲区。
2.3.1串口空闲中断DMA不定长度接收
这里主要说一下空闲中断,空闲中断从串口收到第一个字节开始,在一个波特率的时间内没有收到下一个字节,就说明串口属于空闲状态,就会进入空闲中断。熟悉这些的可以直接跳转到2.3.3
#define RX_BUF_LEN 1300//DMA接收数据量长度
uint8_t rx_buf[RX_BUF_LEN];//DMA储存数据缓冲区
void UART_DMA_Init(void)
{
__HAL_UART_ENABLE_IT(&huart1,UART_IT_IDLE);//使能空闲中断
HAL_UART_Receive_DMA(&huart1,(uint8_t *)rx_buf,RX_BUF_LEN);//开始不定长度接收
}
空闲中断需要重新理一段代码,HAL初始化中断只会进入接收中断。
/*
名称:串口空闲中断函数
*/
void UART_IDLE_IRQHandler(UART_HandleTypeDef *huart)
{
if(RESET!=__HAL_UART_GET_FLAG(huart,UART_FLAG_IDLE))
{
HAL_UART_RxCpltCallback(huart);//调用中断回调函数
__HAL_UART_CLEAR_IDLEFLAG(huart);//清除中断位
}
}
把函数放进中断服务函数里面,就可以实现串口中断。
中断回调函数里面添加这一段代码,当有空闲中断的时候会更新接收到的数据长度,处理对应数据长度接收到的数据长度会清0。
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart->Instance==USART1)//空闲中断
Loader_Receive();
if(huart->Instance==USART3)
ESP8266_Receive();
}
uint16_t rx_buf_len=0;//接收到的数据长度
void Loader_Receive(void)
{
uint8_t tmp[1];
HAL_UART_Receive(&huart1,tmp,1,1);//读取一次DR寄存器,防止重复进入中断
USART1->DR=0;//串口数据缓冲区清0
rx_buf_len=RX_BUF_LEN-__HAL_DMA_GET_COUNTER(&hdma_usart1_rx);//获取接收数据长度
HAL_UART_AbortReceive(&huart1);//关闭DMA传送
HAL_UART_Receive_DMA(&huart1,(uint8_t *)rx_buf,RX_BUF_LEN);//开启一次新的DMA
}
2.3.2Esp8266透传模式
通过Esp8266连接wifi透传模式,可以用野火串口调试助手实现对两个串口的交互。这里简单的说下初始化。当然用如果用可以同时交互两个串口,完全用不到这里。放下代码。具体初始化内容为TA测试->设置用户模式>-连接wifi->连接服务器->设置透传模式->使能透传模式。同样用DMA串口不定长度接收
/*
AT+CIPSEND使能透传模式
*/
uint8_t Esp8266_ENtransmit_single(void)
{
Usart1_SendString((uint8_t*)"正在使能透传模式...\r\n");
Usart3_SendString((uint8_t*)"AT+CIPSEND\r\n");
HAL_Delay(500);
if(wifi_rx_buf[16]!=0x4F || wifi_rx_buf[17]!=0x4B)//OK判定失败
return 1;
else
Usart1_SendString((uint8_t*)"使能透传模式成功...\r\n");
return 0;
}
/*
AT+CIPMODE=1,设置透传模式,在此模式下输入即输出
*/
uint8_t Esp8266_Transmit_single(void)
{
Usart1_SendString((uint8_t*)"正在设置透传模式...\r\n");
Usart3_SendString((uint8_t*)"AT+CIPMODE=1\r\n");
HAL_Delay(500);
if(wifi_rx_buf[18]!=0x4F || wifi_rx_buf[19]!=0x4B)//OK判定失败
return 1;
else
Usart1_SendString((uint8_t*)"透传模式设置成功\r\n");
return 0;
}
/*
AT+CIPSTART=" 连接模式","服务器地址","服务器端口"。连接到指定服务器
*/
uint8_t Esp8266_server_single(void)
{
Usart1_SendString((uint8_t*)"正在通过AT+CIPSTART连接服务器...\r\n");
Usart3_SendString((uint8_t*)"AT+CIPSTART=\"TCP\",\"192.168.10.116\",8000\r\n");
HAL_Delay(500);
if(wifi_rx_buf[11]!=0x4F || wifi_rx_buf[12]!=0x4B)//OK判定失败
return 1;
else
Usart1_SendString((uint8_t*)"连接server成功\r\n");
return 0;
}
/*
设置AT+CWJAP="用户","密码"
*/
uint8_t Esp8266_wifi_single(void)
{
Usart1_SendString((uint8_t*)"正在连接wifi...\r\n");
Usart3_SendString((uint8_t*)"AT+CWJAP=\"SBYF\",\"88888888\"\r\n");
HAL_Delay(2500);//延时等待设备连接
if(wifi_rx_buf[3]!=0x4F || wifi_rx_buf[4]!=0x4B)//OK判定失败
return 1;
else
Usart1_SendString((uint8_t*)"连接WIFI成功\r\n");
return 0;
}
/*
设置:AT+CWMODE=1,可以作为用户连接路由器
*/
uint8_t Esp8266_Mode_single(void)
{
Usart1_SendString((uint8_t*)"设置esp用户模式:AT+CWMODE=1...\r\n");
Usart3_SendString((uint8_t*)"AT+CWMODE=1\r\n");
HAL_Delay(1000);
if(wifi_rx_buf[17]!=0x4F || wifi_rx_buf[18]!=0x4B)//OK判定失败
return 1;
else
Usart1_SendString((uint8_t*)"设置AT+CWMODE=1成功\r\n");
return 0;
}
/*
TA测试
*/
uint8_t Esp8266_TA_single(void)
{
Usart1_SendString((uint8_t*)"正在进行TA测试...\r\n");
Usart3_SendString((uint8_t*)"AT\r\n");
HAL_Delay(500);
if(wifi_rx_buf[1]!=0x41 || wifi_rx_buf[2]!=0x54)//帧头不对(AT)
return 1;
else
Usart1_SendString((uint8_t*)"AT测试通过\r\n");
return 0;
}
2.3.3地址规划
我们需要把app程序放在我们指定的地方,为flash写入做准备,这里我规划bootloader有16kb的空间,即8页flash。那么app程序就得从第9页flash写入,计算app写入地址为0x08004000。这个程序,0x08000000—0x08004000为bootloader的地址规划,后面的flash为app的地址规划。
2.3.4 YModem协议
YModem协议可以一次性传递128或者1024的字节长度数据。这里简单说下野火串口调试助手的YModem协议。
上位机点击发送文件,进入等待状态,等待下位机响应。下位机响应0x43,说明可以开始数据传递,每次接受完一帧数据下位机响应0x06,应答,等待上位机发送下一帧数据。上位机发送完以后会发送0x04,说明文件传输结束。
需要完整的了解,还要去看野火的协议详解。然后我们就可以写一个app文件,生成bin文件,开始文件传输的实验。bin文件一般来说都会大于1k,所以我们用到的是模式2。
我们需要建立一个结构体来为我们写flash做一些准备。
typedef struct
{
uint8_t data_serial;//数据序号
uint8_t data_mode;//数据模式
uint16_t Erase_space;//擦除空间
uint16_t Occupy_space; //占用空间
uint32_t flash_addr; //flash写入地址
}
Loader_agreement;
数据序号,对应位1,说明是第几组数据。数据模式对应位0,说明是128长度还是1024长度。擦除空间,准备好写入flash的空间在使用flash以前应该先进行页擦除。占用空间,已经写入flash的空间,通过这两个判断是否需要擦除更多的flash。flash写入地址,数据的写入地址。数据长度最长是1029。建立1030长度的DMA储存缓冲区完全够用。设置flash写入地址为0x08004000,。至此初始化部分就完成了。C转为hex就是0x43。
初始化部分,System_Init为提示信息。
esp8266_init();
UART_DMA_Init();
System_Init();
void System_Init(void)
{
HAL_TIM_Base_Start_IT(&htim2);
Usart1_SendString((uint8_t*)"进入系统引导界面...\r\n");
Usart1_SendString((uint8_t*)"输入C开始系统装载...\r\n");
Usart1_SendString((uint8_t*)"10s后进入系统引导...\r\n");
}
app文件,主要作用就是开中断打印1s一次的测试。
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
MX_USART3_UART_Init();
MX_TIM2_Init();
/* USER CODE BEGIN 2 */
USARTx_SendString(&huart1,(uint8_t *)"启动成功\r\n");
HAL_TIM_Base_Start_IT(&htim2);
while(1)
{
}
uint16_t count=1000;
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if(htim->Instance==TIM2)//1ms中断
{
count--;
if(count<=1)
{
USARTx_SendString(&huart1,(uint8_t*)"中断测试\r\n");
count=1000;
}
}
}
2.3.5 YModem协议flash写入
写入的过程,就是下载程序更新程序的过程。协议解析代码段,写的不严谨,但是勉强能用。
uint8_t i;
uint32_t data[0xFF+1];
uint8_t *ptr=rx_buf;
void Loader_write_flash(void)
{
if(rx_buf_len>0)
{
rx_buf_len=0;
if(ptr[0]==0x04)
{
Usart3_SendString((uint8_t*)"程序写入结束\r\n");
return;
}
Loader.data_mode=ptr[0];//协议模式
Loader.data_serial=ptr[1];//协议序号
if(Loader.data_serial>0)
{
if(Loader.data_mode==0x01)
i=0x1F;
else if(Loader.data_mode==0x02)
i=0xFF;
if(i==0xFF)
{
uint32_t tmp;
for(uint16_t count=0;count<i+1;count++)
{
tmp=0;
tmp+=ptr[4*count+3]<<0;
tmp+=ptr[4*count+4]<<8;
tmp+=ptr[4*count+5]<<16;
tmp+=ptr[4*count+6]<<24;
data[count]=tmp;
}
if(Loader.Occupy_space==Loader.Erase_space)//没有使用的空间就需要擦除
{
FLASH_Erase(Loader.flash_addr);
Loader.Erase_space+=0x800;//擦除以后增加1页flash(2K)空间
}
FLASH_Write(Loader.flash_addr,(uint32_t *)&data,0xFF+1);
Loader.Occupy_space+=0x400;//写入1次写入1k
Loader.flash_addr+=0x400;//每次写入1k数据,首地址增加1k。
}
}
uint8_t tmp=0x06;//应答一次
HAL_UART_Transmit(&huart1,(uint8_t *)&tmp,1,1000);
Usart3_SendString((uint8_t*)"完成一次应答\r\n");
}
}
野火串口调试第一次送信息为文件名字,长度为128,序列号0,这一条可以忽略,只需要从第二次开始解析就行了。
stm32flash写入支持1一个字一个字的写入,就是32位,同时写入模式为高位在后,低位在前,即一个0x0800400的数据写入flash按照地址读取为0x0040080,所以需要对数据调换顺序。
for(uint16_t count=0;count<i+1;count++)
{
tmp=0;
tmp+=ptr[4*count+3]<<0;
tmp+=ptr[4*count+4]<<8;
tmp+=ptr[4*count+5]<<16;
tmp+=ptr[4*count+6]<<24;
data[count]=tmp;
}
根据情况,看是否需要开辟新的新空间(擦除flash),每次写入地址后首地址都应该递增。
if(Loader.Occupy_space==Loader.Erase_space)//没有使用的空间就需要擦除
{
FLASH_Erase(Loader.flash_addr);
Loader.Erase_space+=0x800;//擦除以后增加1页flash(2K)空间
}
FLASH_Write(Loader.flash_addr,(uint32_t *)&data,0xFF+1);
Loader.Occupy_space+=0x400;//写入1次写入1k
Loader.flash_addr+=0x400;//每次写入1k数据,首地址增加1k。
}
至此flash就写完了。如果需要观察协议的写入数据是什么,可以用以下函数实现
void test_task(void)
{
if(rx_buf_len>0)//串口1收到信息,通过透传模式发送给串口3
{
HAL_UART_Transmit_DMA(&huart3,(uint8_t *)rx_buf,rx_buf_len);
rx_buf_len=0;
uint8_t tmp=0x06;
HAL_UART_Transmit(&huart1,(uint8_t *)&tmp,1,1000);
}
if(rx3_buf_len>0)//串口3收到信息,通过透传模式发送给串口1
{
HAL_UART_Transmit_DMA(&huart1,(uint8_t *)wifi_rx_buf,rx3_buf_len);
rx3_buf_len=0;
}
}
串口1收到的数据串口3发出来。
2.3.4 程序跳转
如果不需要更新程序,就进入引导进入app。跳转程序借鉴别人的,也参考了很网上很多人的写法。https://www.freesion.com/article/2530714904/https://www.freesion.com/article/2530714904/具体思路:建立函数指针,给函数指针指定地址,初始化堆栈指针,重定向中断向量表,运行函数。代码如下
void System_jumpApp(uint32_t APP_ADDR)
{
if(((*(volatile uint32_t*)APP_ADDR)&0x2FFE0000)==0x20000000)//是否有数据写入
{
void (*SysJumpapp)(void); //声明一个函数指针
__disable_irq();//关闭所有中断
HAL_SuspendTick();//关闭定时器
HAL_RCC_DeInit();//设置默认时钟HSI
for(uint32_t i=0;i<8;i++)//清除所有中断位
{
NVIC->ICER[i]=0xFFFFFFFF;
NVIC->ICPR[i]=0xFFFFFFFF;
}
SCB->VTOR=FLASH_BASE|0x4000;//重定向中断向量表
HAL_ResumeTick();//开启滴答定时器
__enable_irq();//开启全局中断
SysJumpapp = (void (*)(void)) (*((uint32_t *) (APP_ADDR + 4)));//中断复位地址
__set_MSP(*(uint32_t *)APP_ADDR);//设置函数主堆栈指针
__set_CONTROL(0);//如果使用了RTOS工程,需要用到这条语句,设置为特权级模式,使用MSP指针
SysJumpapp();//跳转
}
}
首先重定向中断向量表,不然不能在app里面正确开启中断,比如我程序app程序偏移0x4000的地址,那么我中断向量表也要偏移0x4000。在做这些之前,为了保险起见,应该把能关的都关了。然后设置中断复位地址和主堆栈指针。程序刚开始启动,在中断和堆栈建立完成以后会进入中断复位程序,然后进入main函数。最后跳转就行了。
2.3.5函数主体
通过cubemx初始化,如果串口要使用DMA,应该先开启DMA外设,再去开串口,保证DMA的时钟在串口之前开启,不然会出问题。
uint8_t count_s;
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
MX_USART3_UART_Init();
MX_TIM2_Init();
/* USER CODE BEGIN 2 */
esp8266_init();
UART_DMA_Init();
System_Init();
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
if(count_ms>999)
{
count_ms=0;
count_s++;
printf("计时%d秒\r\n",count_s);
if(count_s>=10)
{
HAL_TIM_Base_Stop_IT(&htim2);
printf("跳转APP\r\n");
System_jumpApp(Loader.flash_addr);
}
}
if(rx3_buf_len>0)//串口3收到信息,通过透传模式发送给串口1,以此实现手动发送信息
{
HAL_UART_Transmit_DMA(&huart1,(uint8_t *)wifi_rx_buf,rx3_buf_len);
rx3_buf_len=0;
if(wifi_rx_buf[0]==0x43)
{
Usart3_SendString((uint8_t*)"开始执行程序写入\r\n");
HAL_TIM_Base_Stop_IT(&htim2);
}
}
Loader_write_flash();
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
2.3.6程序演示
程序写入
串口3回复接收信息。
复位后不操作等待进入引导
说明跳转成功,重定向中断向量表成功。
2.3.7调试技巧
1:当不确定是协议写入问题还是程序跳转问题的时候,可以改变烧录地址验证问题
设置IROM1偏移,就是flash写入的偏移。
2:生成bin文件需要设置Run #1,要通过kile的 fromelf工具,生成bin文件。
3 后记
程序现在只是,也算是把bootloader调试通了,能跑就行了,本身的可以移植性,可操作性,风险验证性并不高,bug也有很多,还需很多调试修正,写这文章最主要的目的是为了记录笔记,记录学习过程。