micropython目前主要支持的平台是STM32系列微控制器(STM32F407),但也逐渐补充了别家的芯片,几乎覆盖了市面上常见的微控制器架构。STM32系列微控制器在其中作为ARM Cortex-M内核微控制器的代表,可以作为在其它ARM Cortex-M内核微控制器移植的模板。甚至代码库里提供的“minimal”工程就是以STM32微控制器为平台进行演示的。
我手头有一些NXP的板子,比如最近在社区里比较流行的lpc55s69-evk,我想试着在原生开发环境中将micropython的移植到基于ARM Cortex-M33内核的lpc5500的芯片上。一方面通过移植的过程学习一下micropython代码,另一方面也希望通过micropython实现对微控制器系统硬件操作的封装,便于以后做点有意思的小东西。毕竟我不愿意把大量的时间放在重复阅读寄存器说明书上,一直跟底层的电路较真也是比较烦的,但用python写程序确实很方便。然而,最终的目的还是考虑get到micropython这个在微控制器上运行“编译器”的技术,以后在必要的情况下可以把它用起来。
本文记录了我在原生开发环境中,从零开始创建一个基于lpc5500微控制器的最小工程的过程,包括调试过程中遇到的一些问题和解决方法,方便自己日后查阅,也与其它开发者共勉。
上文书说到,我已经在Windows系统中搭建起micropython的原生开发环境。https://blog.csdn.net/suyong_yq/article/details/112797556
创建最小工程的目标,就是要实现可以使用UART串口终端同板子上运行的micropython进行交互。具体来说,就是要基于lpc5500微控制器的链接命令文件、启动代码、驱动程序,替换掉模板工程中关于具体芯片底层的部分,编译通过,能够将log输出到UART串口并收到终端输入,最后通过运行python语句验证micropython已经在板子上正常运行。
我之前在早期版本(v1.9)中,尝试过基于之前做平衡小车的主板(基于NXP KE18F,ARM Cortex-M4内核)进行过移植。当时相关的移植指导文档也很少放出来,我是基于“ports/minimal”和“ports/bare-arm”工程进行替换和改装,很多底层驱动的代码都是自己一边读手册一边码出来的,自己一点一点摸索着完成。由于当时我对micropython代码的理解比较浅,所以至今很多必要的调用流程都没搞明白是干啥的。经过这两年的积累,官方有越来越多的文档发布出来。通过进一步的研究,我也了解到了一些开发micropython的规范。刚好在近期发布的版本(v1.13)中,我看到了micropython的代码库里已经添加了对部分NXP imxrt系列微控制器的支持(Teensy 4.0板子的主控芯片是imxrt1062),其中使用了tinyusb组件实现usb的功能,而tinyusb引用了一个精简版的NXP MCUXpresso SDK的驱动代码仓库,这个代码仓库里不仅有NXP mimxrt的驱动程序,还顺带包含了NXP lpc5500系列和NXP lpc54100系列微控制器的驱动。就这么通过连带关系,micropython代码仓库中实际已经包含了lpc5500系列微控制器的驱动程序,为我在lpcxpress55s69板子上展开移植提供了必要条件,这样我就不需要专门从MCUXpresso SDK中提取驱动代码了。同时,micropython官方对imxrt平台的支持,也为其它同属nxp mcuxporesso sdk支持平台芯片的移植提供了范例。套用参考工程对SDK的文件组织方式,替换文件更有针对性,这样可以少走很多弯路。所以,本次移植到lpc5500平台时,更多还是参考了“ports/mimxrt”下面的组织框架。
首先,我还是在“ports”目录下复制了一份“minimal”工程的目录,重命名为“lpc5500”,后面移植代码的具体工作都将在这个目录下完成。
这个时候我还是习惯性地make了新创建的“lpc5500”工程,虽然它目前还是基于stm32f405微控制器的,并且肯定能编过(上文已经介绍了编通的过程)。但这个仪式感是一定要有的,因为后续要有很长一段时间这个目录下的工程是不能再编通的。等到它再次通过编译的时候,就是云开月明山花烂漫之时。
接下来打开“Makefile”,开始替换里面引用的文件路径,将与stm32相关的文件替换成lpc5500的。我之后90%的工作都是在调这个Makefile,个中细节就不详述了,本来以为最多一个小时的事情,我前前后后折腾了3个晚上才调通。在本文中,我把几个重点拉出来单独聊聊,至于整体文件,我会把完整的代码库分享出来,到时候感兴趣的同行可以下载研究,或者直接使用即可。
主要做的几个事情:
调整linker文件实际上是确定对芯片存储空间的使用安排,特别是对数据区、堆和栈空间的安排。
linker文件还是使用SDK的原版“LPC55S69_cm33_core0_flash.ld”文件作为模板,但是我考虑要调整其中的内存分块,并且要精简掉不必要的分区,所以单独复制出一份放到“lpc5500”项目的根目录中,便于自己随意改动。
其中从0x2000_0000开始的常规SRAM就有256KB,16KB的PowerQUAD专有内存就不去碰了,从0x1400_0000开始的32KB内存我一开始没好意思用,后面考虑把data(和bss)部分和工程自带的堆放进去,然后把256KB的常规SRAM空间全部预留给栈空间和给micropython的gc内存管理器使用。
这里有个知识点,关于micropython工程的内存使用情况:
按照这个原则,在linker文件中进行调整,我的改动包括如下:
/* Specify the memory areas */
MEMORY
{
m_interrupts (RX) : ORIGIN = 0x00000000, LENGTH = 0x00000140
m_text (RX) : ORIGIN = 0x00000140, LENGTH = 0x0009D000
m_data (RW) : ORIGIN = 0x20000000, LENGTH = 0x00020000
m_data2 (RW) : ORIGIN = 0x20020000, LENGTH = 0x00020000
m_usb_sram (RW) : ORIGIN = 0x40100000, LENGTH = 0x00004000
}
我将常规SRAM分成了m_data和m_data2两块,都是128KB大小。m_data打算用来存放data(bss)和栈,m_data2整块给GC作为micropython的私有堆空间。
原始版本中的用于多核通信的共享内存区域和预留存放双核系统中另一个核的text段被合并到现有区块中,因为micropython中暂时用不到,所以就精简掉了,后面对应区块的定义也可以删掉。至于m_text区域为啥没有填满整个640KB的空间,而是只用了大约630KB的空间,也是参考了手册中关于内存分布情况的说明(见上图),最后这10 KB的空间是预留的,这个可能存放了一些同芯片硬件配置相关的一些数据,总之不要碰它就好。
HEAP_SIZE = DEFINED(__heap_size__) ? __heap_size__ : 0x0400;
STACK_SIZE = DEFINED(__stack_size__) ? __stack_size__ : 0x1C000; /* 112 KB. */
这里指定了栈空间为112KB,放在m_data中。系统堆保留原来的512字节,在用户程序里肯定用不到,但说不准系统库会偷偷摸摸地用一点,所以多少保留一点,也放在m_data中。
/* Uninitialized data section */
.bss :
{
/* This is used by the startup in order to initialize the .bss section */
. = ALIGN(4);
__START_BSS = .;
__bss_start__ = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
__bss_end__ = .;
__END_BSS = .;
} > m_data
.heap :
{
. = ALIGN(8);
__end__ = .;
PROVIDE(end = .);
__HeapBase = .;
. += HEAP_SIZE;
__HeapLimit = .;
__heap_limit = .; /* Add for _sbrk */
} > m_data
.stack :
{
. = ALIGN(8);
. += STACK_SIZE;
} > m_data
这部分代码是参考imxrt的相关代码添加的,具体就是“ports/mimxrt/boards/MIMXRT1062.ld”文件中的内容。这些变量从linker过程中导出,然后在micropython初始化过程中指定管辖内存范围时传参使用。
/* 112kiB stack. */
/*__stack_size__ = 0x1C000;*/
_estack = __StackTop;
_sstack = __StackLimit;
/* Use 128KB for GC heap. */
_gc_heap_start = ORIGIN(m_data2);
_gc_heap_end = ORIGIN(m_data2) + LENGTH(m_data2);
这里可以看到,gc管辖的私有堆使用了整个m_data2,而整个系统栈的开始和结束位置也被记录在_estack和_sstack变量中。
这两组变量在main()函数开始的时候会被使用到:
int main(void)
{
...
mp_stack_set_top(&_estack);
mp_stack_set_limit(&_estack - &_sstack - 1024);
gc_init(&_gc_heap_start, &_gc_heap_end);
...
}
其中设置micropython栈尾的时候又减去了1K字节的长度,此处猜想应该是防止临时的栈溢出触发系统崩溃的防御措施,把系统栈同别的内存隔远一点更加安全。
----- 中间隔了大约两周的时间 -----
在新移植的最小工程中,仅仅实现
这个过程涉及到:
但实际上,minimal工程中演示的程序入口是Reset_Handler()复位向量。按照我的理解,这可能跟armgcc编译环境的使用有关。armgcc编译工具链同商业编译器iar或者keil不同,没有将硬件复位之后初始化运行环境(有点像Java的JRE)封装到自己的库中,而是需要用户在代码中显式地执行一些对运行环境的初始化操作,例如在minimal工程中演示的,需要将bss内存段的内容清零,需要将data内存段的内容赋初值,之后像调用正常函数一样,在c语言代码中调用main()函数,进入到用户熟悉的编程环境。不过我在移植过程中使用了SDK的启动代码文件“startup_LPC55S69_cm33_core0.S",这里面的Reset_Handler()函数已经帮助用户完成了相关的初始化操作。在Reset_Handler()函数的最后一个操作,就是跳转到main()函数。
#ifndef __START
#define __START _start
#endif
#ifndef __ATOLLIC__
ldr r0,=__START
blx r0
#else
ldr r0,=__libc_init_array
blx r0
ldr r0,=main
bx r0
#endif
目前在最小的移植工程中,我需要实现的仅仅是一个通过串口跟micropython的终端进行交互通信,并能够成功运行micropython的内核。
实际上,这里有三个版本的main()函数可以让我参考,分别是功能最完整的stm32移植、minimal工程和mimxrt工程。我一开始的思路是结合stm32和minimal编写main()函数,但是后来通过阅读代码发现,这两个工程的代码都比较老,相比而言,较新的mimxrt移植代码更清晰一些,至少是关于堆和栈的初始化以及对micropython的初始化过程更加明确地体现在代码中。因此,最后我在整理main()函数的时候更多地参考了mimxrt的移植。
这里把我最终完成的main()函数代码列在这里:
extern uint8_t _sstack, _estack, _gc_heap_start, _gc_heap_end;
int main(void)
{
board_init();
mp_stack_set_top(&_estack);
mp_stack_set_limit(&_estack - &_sstack - 1024);
for (;;) {
gc_init(&_gc_heap_start, &_gc_heap_end);
mp_init();
mp_obj_list_init(MP_OBJ_TO_PTR(mp_sys_path), 0);
mp_obj_list_append(mp_sys_path, MP_OBJ_NEW_QSTR(MP_QSTR_));
mp_obj_list_init(MP_OBJ_TO_PTR(mp_sys_argv), 0);
for (;;) {
if (pyexec_mode_kind == PYEXEC_MODE_RAW_REPL) {
if (pyexec_raw_repl() != 0) {
break;
}
} else {
if (pyexec_friendly_repl() != 0) {
break;
}
}
}
mp_printf(MP_PYTHON_PRINTER, "MPY: soft reboot\n");
gc_sweep_all();
mp_deinit();
}
return 0;
}
这份代码比较清晰地展现了micropython的启动和执行过程:
进入main()函数之前先调用board_init()函数对硬件初始化,所有对硬件的准备工作都将放在这个函数里。我在项目根目录下单独创建了board_init.c文件用于存放board_init()函数及相关的硬件初始化函数的实现。
使用mp_stack_set_top()和mp_stack_set_limit()初始化micropython使用的栈,如前文所述。
使用gc_init()初始化micropython使用的堆,如前文所述。
这里有一个有趣的设计,使用了一个for循环重复执行micropython的主线程,并且把初始化堆和释放堆的操作也放在循环中。这实际是虚拟了一个多线程(但不是并行)的运行环境。这样的好处是,当micropython运行过程中因为资源限制或者其它操作(人为exit)退出时,代码将通过循环重新初始化堆并启用一个新的micropython线程。实际上,创作本文前半部之后停更的两周时间里,我再次阅读了micropython官网上提供的移植指南,发现最新版移植指南建议的main()函数实现方式同我最终版的main()几乎是相同的,但是移植指南文档里main()函数实现比较简单,没有这个for循环,如果当前的micropython崩溃了,那就只好复位硬件重新开始了。
mp_init()顾名思义就是初始化micropython的。
两个mp_obj_list_init()夹着一个mp_obj_list_append()函数的功能暂时没搞明白,但我看现有的很多移植都是这么用,就先放在这里了。
接下来就是启动命令行交互系统,“pyexec”前缀是“python execution”的意思,就是开始运行点什么东西了。这个地方出现了很多“pyexec”前缀的函数和变量,它们的定义和实现可以在“py/pyexec.c”文件中找到。我后来通过单步调试运行发现实际执行的是pyexec_friendly_repl()函数。
之后就是收尾工作,回收内存,关闭micropython主线程等。
原有的“minimal”工程中有一个“uart_core.c”的源文件,其中实现了mp_hal_stdin_rx_chr()和mp_hal_stdout_tx_strn()两个函数,实现了micropython的命令行交互工具REPL对基本数据收发的通信接口。通过在这两个函数内部相应地实现UART串口的收发操作,从而能够将REPL对接到UART串口上,进一步通过PC机上的终端窗口通过串口同micropython交互。
实际上,我在新移植的工程下面另外创建了board_init.c文件,在这个文件中实现的board_init()函数中,实现了对硬件相关模块的初始工作,同时也包含了对用于REPL通信的UART外设,并将这两个需要对接的函数也放在其中。
void BOARD_InitDebugConsole(void)
{
/* attach 12 MHz clock to FLEXCOMM0 (debug console) */
CLOCK_AttachClk(BOARD_DEBUG_UART_CLK_ATTACH);
RESET_ClearPeripheralReset(BOARD_DEBUG_UART_RST);
usart_config_t usart_config;
USART_GetDefaultConfig(&usart_config);
usart_config.baudRate_Bps = 115200;
usart_config.enableTx = true;
usart_config.enableRx = true;
USART_Init(BOARD_DEBUG_UART_INSTANCE, &usart_config, BOARD_DEBUG_UART_CLK_FREQ);
}
int mp_hal_stdin_rx_chr(void)
{
unsigned char c = 0;
USART_ReadBlocking(BOARD_DEBUG_UART_INSTANCE, &c, 1u);
return c;
}
// Send string of given length
void mp_hal_stdout_tx_strn(const char *str, mp_uint_t len)
{
USART_WriteBlocking(BOARD_DEBUG_UART_INSTANCE, (const uint8_t *)str, len);
}
从代码中可以看到,我这在这里直接使用了NXP MCUX SDK中的UART驱动程序完成了对硬件UART模块的操作。
在描述main()函数的调用序列和实现UART串口对接REPL的过程中,都提到了board_init.c文件和board_init()函数。这里集中说明一下board_init()函数的作用。
在进入main()函数后,首先调用board_init()函数执行对硬件的初始化,包括对芯片时钟系统的初始化,后续可能使用到的端口引脚,以及串口终端等。这里面大部分的函数都可以从NXP MCUX SDK中的样例工程中获取源代码。
例如,在本次移植中使用的BOARD_InitPins()和BOARD_BootClockFROHF96M()函数,就分别来自于样例工程“hello_world”的pin_mux.c和clock_config.c文件。而BOARD_InitDebugConsole()函数名也是从board.c文件中借用的,只是其中的内容是直接调用UART驱动程序对UART串口通信模块进行初始化,而没有遵循SDK中使用额外的组件间接初始化硬件的操作。
----- 中间隔了大约一周的时间 -----
build生成的firmware.elf文件,除了使用命令行版本的GDB工具之外,还可以使用同JLink同源的Ozone上位机工具下载程序到芯片上。这里使用Ozone的好处在于,可以在图形化界面中实现类似于IAR或者Keil的下载、单步调试等功能。更令人惊喜的是,由于未经优化的elf文件包含了一部分程序源代码的信息,在使用Ozone甚至可以实现源代码层面上的单步调试。另外Ozone目前已经是一个跨平台的工具,在Linux系统中也可以使用Linux版的Ozone。
这里简要说明使用Ozone下载micropython固件并调试的几个重点步骤。
Ozone支持elf文件、hex文件和bin文件,但由于hex和bin文件中只是二进制存储单元的内容,所以只能下载但不能进行源代码的单步调试。
选择build生成的micropython固件文件
指定启动固件后开始的PC指针和栈顶地址
这里使用默认的配置(如图)即可,但实际上我认为初始的PC指针值也可以选择“Read from Base Vector Table”。如果使用默认的“ELF Entry Point”,我严重怀疑同ld文件中的ENTRY命令有关:
/* Entry Point */
ENTRY(Reset_Handler)
最后导入elf文件成功之后的界面就是这个样子:
轻戳左上角的绿色小按钮,下载并开始调试。之后有一个下载的进度条。下载成功之后,可以使用相应的按钮进行单步调试,观察代码的执行情况。
全速运行后,在串口终端中同板子上运行的micropython进行交互,测试工作正常,移植成功。
hardfault的问题,降低频率(到96MHz)可以正常运行。之前运行在150MHz主频的时候,总是莫名起来在执行strcmp函数的时候出现hardfault。这个问题卡住我一周,差点让我放弃继续调试。
软件浮点库和硬件浮点库的问题,需要选择合适的。实际上软件库和硬件库都可以使用。之前在没发现100M主频导致hardfault的情况是,总是以为是没有找到特定可用的浮点支持库导致的。但后来排除了hardfault的问题之后,用软件库和硬件库都可以正常工作,区别仅在于其原本设计的那样,是否启用硬件的FPU加速浮点计算。
那个frozentest.py和frozentest.mpy文件到底是干啥用的,我还是没有深究,但是不包含就编译不过,所以暂时还得留着它。
----- 2021-02-09 -----
撰写本文的中间发生了一些事情,自己在写后续几个部分的时候耽搁了不少时间,导致一开始列下来的小标题在后来都忘记了当时的构思,所以记录得比较粗糙。不过目前至少把完整的过程和能想到的点都记录下来了。后续进一步整理素材准备成文的时候,会重新对全文进行构思,届时希望能找回最初的灵感。