因为项目的需求或者成本控制等因素,我们经常会遇到更换MCU的情况,这时我们可能需要将以前项目使用的代码移植到新的MCU上面。对于一些新手来说,这个事情乍一看好像挺简单,但是上手之后又发现好像无从下手。我也经常收到一些关于移植问题的私信,所以这一期就大概讲一下如何从一款MCU移植到另一款MCU,大概讲一下方法和思路。
1 硬件移植
硬件层面上的移植其实是比较简单的,因为不管是什么单片机,在功能上其实基本差不多,比如GPIO,GPIO在所有单片机上面都是最基本的接口,而且基本一样,最常见的区别无非是能不能做外部中断,有些单片机是所有IO都可以做外部中断,有些则不是。其次就是一些外设常用的接口,如串口,IIC,SPI等,不同型号的单片机区别可能只是接口的数量,引脚号等等,这些在硬件上的使用基本一样,所以移植的时候按照原有的功能重新布线即可。
2 软件移植
2.1 移植原因
软件部分是移植的重点难点,在移植之前我们首先要了解两款单片机在编程上面的区别。
我以stm32为例来说明,熟悉stm32的同学应该都知道,stm32编程是可以分成寄存器编程和固件库编程两种的,其实本质上单片机都是基于寄存器编程的,只不过单片机的寄存器很多,如果直接对寄存器编程的话,光是看代码不对照数据手册,你很难理解这些寄存器的作用,在代码里面不同地址的寄存器一大堆,每个寄存器都有一个值,这个值的含义是什么,你都能一眼看出来吗,显然是不行的。
所以固件库的作用其实就是用一些函数和变量,重新定义和封装寄存器,封装过后我们可以通过一些函数名或者变量名理解这些寄存器的含义,使用起来会方便很多。
但是这里面又会衍生出一个新的问题,就是每个厂家可能都有自己的一套固件库,大家的命名习惯也不同,比如:我需要拉高一个引脚的电平,有些用GPIO_SetBits(),有些用GPIO_WriteBit(),因此,当我们更换MCU的时候就需要做移植的工作,不然没法使用,因为大家的库都不一样,甚至有些厂家都没有库,直接是操作寄存器的。
所以,当我们更换MCU之后就需要做移植的工作,才能保证所有的功能都能正常运行。
2.2 移植原理
移植是一个求同存异的过程,把相同的部分保留,不同的部分用新的代码替代。
不同的单片机在工作原理上基本是一致的,本质都是对GPIO的控制,不同的是对IO的配置有差别,寄存器的地址和命名不同。而在应用部分基本是没有太大的变化,比如我做了一个按键点灯的功能,这是实际应用部分,在移植前和移植后都是不变的,我要做的只是把底层的接口替换掉,比如GPIO的初始化,引脚号的修改,电平拉高拉低这些函数的修改等等。
所以,我们移植的主要目标其实这些最底层的接口,应用部分比如一些IC的驱动,显示屏显示的内容等等,这些都是基本不变的,除非你连编程语言也换了,由C换成python或者其他语言,那像for函数,while函数这些就都得按其他语言的语法来写了。
2.3 固件库之间的移植方法
当你搞清楚原理之后,再回头去看移植的事情,就会变得简单很多。移植之前首先你得明确你的目标,哪些需要改哪些要保留,思路要清晰,这样才不会一通操作,结果一堆报错。
如果你需要移植的两款单片机代码都是基本固件库写的,那么移植起来会容易一些,特别是一些对标STM32的MCU,如GD32,AT32,因为这些芯片本身就是借鉴STM32制造出来的,本意也是做STM32的替代方案,因此在代码上的差异很小,移植起来就比较轻松。
固件库移植主要差异在于不同型号的MCU它用的固件库是有差异的,所以我们的目标就是移植新的固件库,替代旧固件库。
那么一个工程里面,哪些文件是属于固件库呢?
我举个例子,比如STM2里面像stm32f10x_gpio.c
、stm32f10x_gpio.h
、stm32f10x_i2c.c
这些都是固件库,还有HAL库,等等。对应的其他芯片,比如gd32f10x_gpio.c
、ch32v20x_gpio.c
等等,这些文件命名都是类似的。
当然,不是所有MCU都这样命名的,而且文件命名其实也不是最重要的,重要的是文件里面的代码以及它所实现的功能。我们主要替代的是代码和功能,而非文件。
所以不是说文件找到之后直接替代就可以用了的,有些文件哪怕你替代了也没用,还会报错。因为有些库有些函数,在新的库上面是找不到的。
记住,我们需要的是功能替代,不是简单的文件替代。
就拿STM32和GD32来举个例子,虽然GD32是照着STM32来做的,基本做到一模一样,但实际上还是有区别的,比如stm32f10x_gpio.c
和gd32f10x_gpio.c
里面有这么一个函数。
// stm32f10x_gpio.c
/**
* @brief Reads the specified input port pin.
* @param GPIOx: where x can be (A..G) to select the GPIO peripheral.
* @param GPIO_Pin: specifies the port bit to read.
* This parameter can be GPIO_Pin_x where x can be (0..15).
* @retval The input port pin value.
*/
uint8_t GPIO_ReadInputDataBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
uint8_t bitstatus = 0x00;
/* Check the parameters */
assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
assert_param(IS_GET_GPIO_PIN(GPIO_Pin));
if ((GPIOx->IDR & GPIO_Pin) != (uint32_t)Bit_RESET)
{
bitstatus = (uint8_t)Bit_SET;
}
else
{
bitstatus = (uint8_t)Bit_RESET;
}
return bitstatus;
}
// gd32f10x_gpio.c
/**
* @brief Read the select input port.
* @param GPIOx: Select the GPIO peripheral.
* @param GPIO_Pin: Select the port.
* @retval The input port pin value.
*/
uint8_t GPIO_ReadInputBit(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
uint8_t bitstatus = 0x00;
if ((GPIOx->DIR & GPIO_Pin) != (uint32_t)Bit_RESET)
{
bitstatus = (uint8_t)Bit_SET;
}
else
{
bitstatus = (uint8_t)Bit_RESET;
}
return bitstatus;
}
这两个函数的功能其实是一样的,但是函数名不一样,所以如果当我们从STM32移植到GD32的时候就要注意了,如果你在应用部分有用到这个函数,那么就要把GPIO_ReadInputDataBit改为GPIO_ReadInputBit,不然就直接报错了,找不到GPIO_ReadInputDataBit这个函数,对不对。当然了,如果你没用到这些函数的话当我没说。
还有一些哪怕函数名改了也还是不行,因为入参的方式可能不同,这个也是要根据实际情况替代的。像一些常用的函数,比如下面这两个。
// stm32f10x_gpio.c
/**
* @brief Sets the selected data port bits.
* @param GPIOx: where x can be (A..G) to select the GPIO peripheral.
* @param GPIO_Pin: specifies the port bits to be written.
* This parameter can be any combination of GPIO_Pin_x where x can be (0..15).
* @retval None
*/
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
/* Check the parameters */
assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
assert_param(IS_GPIO_PIN(GPIO_Pin));
GPIOx->BSRR = GPIO_Pin;
}
/**
* @brief Clears the selected data port bits.
* @param GPIOx: where x can be (A..G) to select the GPIO peripheral.
* @param GPIO_Pin: specifies the port bits to be written.
* This parameter can be any combination of GPIO_Pin_x where x can be (0..15).
* @retval None
*/
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
/* Check the parameters */
assert_param(IS_GPIO_ALL_PERIPH(GPIOx));
assert_param(IS_GPIO_PIN(GPIO_Pin));
GPIOx->BRR = GPIO_Pin;
}
// gd32f10x_gpio.c
/**
* @brief Set the selected data port bits.
* @param GPIOx: where x can be (A..G) to select the GPIO peripheral.
* @param GPIO_Pin: where pin can be (GPIO_PIN_0..GPIO_PIN_15) to select the GPIO peripheral.
* @retval None
*/
void GPIO_SetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
GPIOx->BOR = GPIO_Pin;
}
/**
* @brief Clear the selected data port bits.
* @param GPIOx: where x can be (A..G) to select the GPIO peripheral.
* @param GPIO_Pin: where pin can be (GPIO_PIN_0..GPIO_PIN_15) to select the GPIO peripheral.
* @retval None
*/
void GPIO_ResetBits(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
GPIOx->BCR = GPIO_Pin;
}
虽然函数里面不完全一样,但是实际的功能一样,都是拉高或者拉低GPIO,函数名以及入参也都完全一样,这种情况就可以直接替代,不需要做任何修改。
OK,到这里我想同学们应该对固件库的移植有了大概的了解。其他移植的情况这里就不一一举例了,原理都是类似的。
我这里总结一下移植固件库主要的几个点。
1、系统时钟。系统时钟关乎到这个MCU能不能跑起来,一定是不能出错的,移植之后一定要跟原来的时钟匹配上。比如stm32最大频率是72MHz,但是gd32最大是108MHz,如果原来stm32配了72M的时钟,按照这个配置移植到gd之后就变成108MHz了,那么像串口波特率,I2C速率这些都会跟着变。原来9600的串口就变成14400了,因为系统时钟变快了。所以这个时候你就要把gd的时钟进行分频,配成72M,这样才能保证原有的一些功能正常运行。
2、初始化部分。比如gpio初始化(包括时钟、引脚号、模式、速率等等)、i2c初始化,spi初始化等等。不同型号的MCU用的可能是不一样的库,因此初始化这部分代码的写法上可能会有差异,这个时候我们还是按照功能移植的原则,把整个初始化部分替代掉。
提示:这个部分可以参考新固件库的demo,一般固件库都是有一些常用的Example,比如GPIO、UART、IIC、SPI、EXTI等等。我们可以先看下人家的demo是怎么用的,然后再按照自己的需求改。
3、时间相关的函数。这部分其实跟第1点有点关系,如果系统时钟变了,那么跟时间相关的一些函数可能就要做相应的改变才能保证原有的功能正常运行。比如延时函数,定时器等,定时的时间是基于系统时钟计算出来的,如果系统时间变快了,这些定时的时间也会变短,像有些模拟i2c的驱动是直接用delay()函数来模拟iic的时序,一旦这个延时时间变了,可能就直接导致iic通讯异常。我们可以通过修改系统时钟,或者改变定时器分频等方法,让这部分的功能跟原有保持一致。
4、有调用底层接口地方。在应用或者驱动部分,我们经常会去调用一些底层的接口,比如GPIO拉高拉低,IIC读写、串口收发等等。这些地方如果移植前后函数名或者用法不一致的话就需要修改成一致。主要是保证功能一致即可,命名或者入参这些其实不是最重要的。比如我在代码的某个地方需要用IIC发送一个数据,代码怎么改,结构怎改,这些不重要,只要你能正确发出这个数据,那功能上就是一致的,就是成功替代过去了。
5、头文件。很多C文件会包含一些头文件进来,但是因为底层的库做了移植,文件名可能不一样了,因此,在代码上,这些include也要改成一致的。
6、宏定义。在应用部分我们可能会用到很多宏定义,有些宏可能会跟底层有关联,比如:#define LED_ON GPIO_SetBits(GPIOA, GPIO_Pin_8),这里面的GPIO_SetBits()函数就是源于底层,如果底层的库不是这样写的,就要改成跟底层一致。
2.3 非固件库之间的移植方法
非固件库之间的移植其实跟固件库之间的移植原理是一样的,但是过程会更曲折,因为寄存器在代码的展示上没有固件库那么好理解。但也不用害怕,搞清楚功能就可以了。
移植还是遵循一个原则:我们的最终目标是是功能上的移植,而非单纯的文件或者函数。
不管代码是用固件库写的还是直接操作寄存器,最终都是通过操作寄存器来控制单片机。只不过有了固件库之后,即使我们对寄存器没有太多了解,也能很好的理解和编程。如果没有固件库,我们就需要查看芯片的数据手册,查看寄存器的含义。
移植的目标跟固件库是一样的,还是下面这几个点。
1、系统时钟。
2、初始化部分。
3、时间相关的函数。
4、有调用底层接口地方。
5、头文件。
6、宏定义。
具体的移植就不详细说了,因为不同的MCU,寄存器差异是很大的,不好一一举例,但是不管是用寄存器还是固件库,我们的目标是不变的,只要保证功能上一致就可以完美移植。
提示:寄存器版本也是有demo可以参考的,一般厂家会提供一些常用接口的使用方法,如GPIO、UART、IIC、SPI、EXTI等等的用法。我们对照demo和芯片数据手册,就可以更好的理解寄存器配置的方法,然后照着demo改成我们需要的功能即可。
2.4 移植应用部分
如果应用部分不多或者底层差异较大的情况下,我们也许可以换一个思路,在一个新MCU配置好的基础工程下把我们旧工程的应用和驱动移植过去。相比于移植底层,这种方法有一个好处,那就是我们的移植工作可以拆分成多个部分,然后分步进行。
如果你是直接移植的底层,那么你要全部移植完之后一次性编译,这个时候可能会冒出一大堆错误,改掉一些之后可能出现更多了,这对于新手来说可能直接心态就崩了,不知道怎么改了。
但是你在一个新的工程,没有报错的工程上面一点一点地添加应用代码,就可以把一大堆问题拆分成多个部分,然后一点一点地解决。
大概的流程如下:
我们先把应用部分拆开,只移植其中一小部分,编译测试OK之后再移其他的,这样就不会一次性出现很多错误。
比如:我原有的这份代码的功能是用按键点亮一个LED灯,那么我们可以先移植灯的部分,测试过灯可以正常点亮之后再移植按键部分,单独测试按键OK之后,最后才把这两部分合并到一起,这样调试的时候找问题的范围就会缩小很多。
3 结束语
好了,关于MCU移植的介绍就讲到这里,本文其实讲的还是比较笼统的,不够全面,列举的例子也不多,实际的情况可能会更复杂,可能两个库之间差异很大,可能有些函数没看懂,不知道是做什么用的,可能在底层的基础上还有操作系统,文件结构更加复杂了,等等。这些情况我也没法一一列举,需要你根据实际情况去移植,但是基本原理是一样的,最终的目标都是功能替代,而实现的方法也不是唯一的,需要你灵活使用。
本文只是提供一个思路,不是唯一的方法,讲的也不全面,如果你有什么问题或者有更好的方法,欢迎在评论区留言。