爱折腾的孩纸

STM32压缩编译产生的固件大小的一些经验

最近打算做一个引导程序,作用就是将一个 FLASH 扇区作为分区表,然后选择一个程序进入。该引导程序当然越小越好,最终我把程序大小压缩到了 1KB 以内,这里介绍一些方法。

首先使用 STM32CubeMX 生成一个项目,不要使用 HAL 库。

使用V6.16编译器

V6编译器优化选项有 -Oz image size 优化,这个可以进一步减少体积

精简中断向量表

如果你的程序不需要使用中断,最多可以将中断向量减少到只有 4 个,这 4 个是不可屏蔽中断,最好不要去掉,以免程序跑飞。

startup_xxx.s 文件中,找到 __Vectors 标签,删掉除了 __initial_spReset_HandlerNMI_HandlerHardFault_Handler 以外的 DCD 表项。

进入程序第一步执行 __disable_irq();,以免进入其它错误中断或外设中断。

对比 stm32f103 原本有 59 个中断向量,此项精简可减少 220 字节容量。

另外,startup_xxx.s 文件中有内核中断的代码块,可以一起删掉,同时NMI_Handler 和 HardFault_Handler 一般也不会使用到,可以将这两项也指向 Default_Handler,该函数进入后会进入死循环。

将中断向量表放到内存中对于程序瘦身一般作用不大,生成中断向量表需要的代码量不见的比直接放到 Flash 上小。程序中使用轮询方式即可。

精简启动初始化

查看 Reset_Handler,在 startup_xxx.s 文件中,会先执行 SystemInit,然后执行 __main

SystemInit 一般没有什么作用,如果我们使用外部 SRAM 以及自定义了中断向量表地址,会在这里进行初始化。

这里可以直接删除 Reset_Handler 函数,将中断向量表中的 Reset_Handler 记录直接指向 __main

精简 GPIO 和外设初始化

首先来说,使用 xxx_InitStruct 和 LL_xxx_Init 函数生成的代码最大。我们可以不使用该种方式,而是使用 LL 库提供的设置寄存器函数进行初始化,以 GPIO 为例。

LL_GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = LL_GPIO_PIN_2;
GPIO_InitStruct.Mode = LL_GPIO_MODE_ALTERNATE;
GPIO_InitStruct.Speed = LL_GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.OutputType = LL_GPIO_OUTPUT_PUSHPULL;
LL_GPIO_Init(GPIOA, &GPIO_InitStruct);

上面的代码可以写成:

LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_2, LL_GPIO_MODE_ALTERNATE);
LL_GPIO_SetPinSpeed(GPIOA, LL_GPIO_PIN_2, LL_GPIO_SPEED_FREQ_HIGH);
LL_GPIO_SetPinOutputType(GPIOA, LL_GPIO_PIN_2, LL_GPIO_OUTPUT_PUSHPULL);

LL_GPIO_Init 函数中有不少判断,实际没有必要,反而增大代码尺寸。

对于其它外设的初始化也是同样的道理。

然后,即便如此,也不如直接操作寄存器代码量少。我们在启动后初始化外设环节,通常来说不用考虑外设当前的状态,将对应的寄存器值改为我们需要的值即可,而且这样可以同时修改多个属性。但是注意有的外设设置寄存器时要求有先后顺序,或者只有在外设关闭时才可修改某些值。

比如说初始化 UASRT 可以写成:

USART2->CR1 = LL_USART_DATAWIDTH_8B | LL_USART_PARITY_NONE | LL_USART_DIRECTION_RX;
USART2->CR2 = LL_USART_STOPBITS_1;
USART2->CR3 = LL_USART_HWCONTROL_NONE;
LL_USART_SetBaudRate(USART2, 8000000UL, 115200);

LL_USART_Enable(USART2);

如果不知道寄存器应设置为什么值,可以进调试,打断点在外设初始化结束处,然后使用 System Viewer 查看外设的寄存器值。

上面展示的代码省略了 APB/AHB EnableClock 过程。

精简 SystemClock_Config

接着来到 main.c,找到 SystemClock_Config,首先对 LL_Init1msTick 和 LL_SetSystemCoreClock 动手。

LL_Init1msTick 作用是初始化 SysTick。如果我们没有用到 SysTick,直接注释掉就行。即便用到了,直接操作寄存器进行初始化可以省掉不少的代码。

LL_SetSystemCoreClock 作用是将系统时钟保存到一个全局变量 SystemCoreClock 中,其它外设初始化时可能会用到这个值。但是我们既然打算操作寄存器来初始化,这个也没有什么作用,可以注释掉。变量 SystemCoreClock 定义在 system_stm32xxx.c 中,这个可以不用管他。

SystemClock_Config 中初始化 FLASH 和 RCC 的过程也可以直接操作寄存器。以下是使用内部 8MHz HSI 的代码示例:

// LL_FLASH_SetLatency(LL_FLASH_LANTENCY_0);
FLASH->ACR = LL_FLASH_LATENCY_0 | FLASH_ACR_PRFTBE;
while(LL_FLASH_GetLatency() != LL_FLASH_LATENCY_0)
{
}

// LL_RCC_HSI_SetCalibTrimming(16);
// LL_RCC_HSI_Enable();
RCC->CR = RCC_CR_HSION | (16 << RCC_CR_HSITRIM_Pos);

/* Wait till HSI is ready */
while(LL_RCC_HSI_IsReady() != 1)
{
}

//LL_RCC_SetAHBPrescaler(LL_RCC_SYSCLK_DIV_1);
//LL_RCC_SetAPB1Prescaler(LL_RCC_APB1_DIV_1);
//LL_RCC_SetAPB2Prescaler(LL_RCC_APB2_DIV_1);
//LL_RCC_SetSysClkSource(LL_RCC_SYS_CLKSOURCE_HSI);
RCC->CFGR = LL_RCC_SYSCLK_DIV_1 | LL_RCC_APB1_DIV_1 | LL_RCC_APB2_DIV_1 | LL_RCC_SYS_CLKSOURCE_HSI;

/* Wait till System clock is ready */
while(LL_RCC_GetSysClkSource() != LL_RCC_SYS_CLKSOURCE_STATUS_HSI)
{
}

LL_APBx_GPRx_EnableClock函数

LL库里对这两个函数以及AHB1_GRP1_EnableClock实现存在一个问题,LL库的实现是这样的:

__STATIC_INLINE void LL_AHB1_GRP1_EnableClock(uint32_t Periphs)
{
    __IO uint32_t tmpreg;
    SET_BIT(RCC->AHBENR, Periphs);
    /* Delay after an RCC peripheral clock enabling */
    tmpreg = READ_BIT(RCC->AHBENR, Periphs);
    (void)tmpreg;
}

问题在注释后面一句,这里通过读取一次寄存器内容,确保外设时钟已开启。这个操作在 ST 的 errata es0222 里也有说明,是官方提供的一种 workaround。

但这里实现用的是 READ_BIT,该操作会多一个 & 操作,是不必要的。将这句改成 tmpreg = RCC->AHBENR;,同时该函数是 inline 的,而且 EnableClock 很可能会调用多次,可以节省一些空间。

另外 (void)tmpreg 在 V6.16 编译器上也是不必要的。tmpreg = xxx 会产生两条语句,将外设地址的值读到通用寄存器,然后放入栈(因为tmpreg是在栈上),而 (void)tmpreg 又会从栈上取出值放到通用寄存器上。入栈也不是必要的,但这是编译器行为,我们不好调整。

使用传参代替全局变量

我的实验发现,如果程序逻辑不复杂,构造一个结构体,通过传结构体指针,让变量在栈上,此时可通过 SP+偏移 进行地址访问,比放在全局变量中生成的代码要小。

去除分散加载代码

查看汇编代码,发现该程序并未使用到分散加载,但仍然生成了相关的代码和数据块。__main 函数实现了3个功能:

  1. 设置 SP 指针
  2. 调用 _main_scatterload
  3. 调用 __main_after_scatterload

_main_scatterload 会从一个数据表开始读取数据,并调用表中记录的函数地址。查看该表只有一个结束的标志,_main_scatterload不会有任何效果。
__main_after_scatterload 则直接进入 main 函数。

我们可以通过修改启动文件 startup_xxx.s,移除部分代码。经测试,__main 是不能覆盖的,我们可以对这里面最复杂的 __scatterload 函数动手,写一个函数覆盖原本的实现,让其直接跳转到 __main_after_scatterload。代码如下:

__scatterload   PROC
                EXPORT  __scatterload
                IMPORT  __main_after_scatterload
                
                BL.W    __main_after_scatterload
                ENDP

合并相似函数

举例程序里有计算 CRC 和拷贝内容两个函数,这两个函数都有循环这一相似操作,可以考虑做成一个函数来完成两种功能,该函数输入源地址和目标地址,以及长度,返回校验和。当只需要拷贝时,忽略返回结果即可,当只需要计算 CRC 时,传入相同的源地址和目标地址,让其做原地拷贝。代价是上述两种操作都会有性能浪费,但只要整体耗时满足我们的要求即可。

结论

最终实现了一个程序,程序在启动后,会初始化 SysTick 以及 USART2 用于接收选择启动分区的指令,之后会依次检索其后的 FLASH 内容,读取每条分区记录,通过比对分区记录设置的延时时间和启动“密码”,选择启动分区,进行 CRC32 校验并引导该分区的应用程序。

实际场景中我们可以将第一个分区设置为应用,引导地址指向 0x80000800,第二个分区设置为 OTA 程序,根据 OTA 程序大小,放置在 FLASH 末尾。应用程序设置延时为 3 秒,OTA 程序设置延时为 0,设置启动“密码”和分区 1 启动失败标志,这样在接收到对应的“密码”后或应用分区启动失败(例如CRC校验错误)后会立即进入 OTA 程序。启动密码可以通过串口输入,或者读取内存中固定地址的内容,以实现应用程序跳转入 OTA 程序功能。

程序使用轮询方式查询外设状态接收数据,设置栈大小为 512 字节,未使用任何全局变量。

编译后 Code=996 RO-data=16 RW-data=0 ZI-data=512,ROM 大小为 1012 字节,刚好可以放到一个扇区中。

后续可以考虑将代码容量放宽到 2KB,添加一个解压程序,实现将 FLASH 内容解压后放置在内存指定位置然后再进行引导的功能。

评论