引言

LPC55S69 微控制器内部集成了两个 ARM Cortex-M33 内核, 都可以跑在 150MHz 的主频上 . 通常情况下, 使用其中一个内核(core0)就可以完成足够多的工作 . 但是, 让一个 150MHz 的 Cortex-M33 内核闲在那里实在浪费, 并且在一些对性能有要求的情况下, 使用双核同时工作确实可以简化应用的开发过程, 并提升系统整体的工作效率 . 笔者最近就遇到了这么一个案例 .


笔者在 LPCXpresso55s69 开发板上面做语音关键字识别的项目时, 想在 LCD 显示屏模块上显示点交互信息, 改善用户体验 . 笔者使用的是一块 SPI 总线 320x240 像素的 LCD 屏模块, 使用 RGB565 的像素格式, 如果使用 DMA+SPI 的方式刷屏自然可以为主 CPU 减负, 但需要提前缓存整张图片到内存中, 而存一幅图需要连续的 150KB 内存, 占用内存空间比较大 . 虽然 LPC55S69 有足够的内存(320KB), 但是使用人工神经网络模型占用的内存规模也比较大, 在不确定内存能否够用的情况下, 笔者觉得仅仅为刷屏分配这么多内存无疑是奢侈的, 并且屏幕交互信息很简单, 基本上就是一个黑色的背景色加几行字而已, 现算现刷的方式可以大幅降低内存和代码的占用量 . 但使用主 CPU 轮询 SPI 会严重影响人工神经网络的计算实时性 . 此时, 使用副 CPU 在主 CPU 计算的时候执行刷屏的操作, 哇, 简直不要太香 .

 

在本文中, 笔者将介绍笔者从零开始搭建双核工程的过程 .

 

精简 MCUX SDK 工程, 制作工程模板

首先, 笔者从 MCUX SDK 代码包里提取出了一个 hello_world 工程, 经过一番精简和调整文件组织结构, 改了工程名, 最后变成了这个样子:

 


 
根目录下只有"application", "CMSIS", "device"和"drivers", 大部分源文件直接放在一级目录下, 最深的工程组织文件路径也不过只有三层 . 一个词, "清爽".
 

 

这个工程作为模板, 将成为后续所有新建工程的起点 .


为双核工程筹备足够的源文件

复制"\application\hello_world"目录, 改"hello_world"为"dualcore_basic", 相当于根据模板创建了一个新工程 . 然后在"\application\dualcore_basic\iar"目录下复制一份"my_project.eww"和"my_project.ewp", 将两份工程组织文件改名为"core0_project"和"core1_project", 这两个工程组织文件将分别用于编译生成两个处理器内核上运行的程序 .

 

从模板创建的工程在默认情况下是支持单核的, 其中并没有包含支持第二个核心的一些必要的源文件 . 如此, 笔者又在 MCUX SDK 软件包中提取了双核版的"hello_world" 工程 .

 


 
从中复制了与 core1 相关的相关文件:


LPC55S69_cm33_core1_ram.icf


startup/startup_LPC55S69_cm33_core1.s


startup/LPC55S69_cm33_core1.h


startup/LPC55S69_cm33_core1_features.h


startup/system_LPC55S69_cm33_core1.c


startup/system_LPC55S69_cm33_core1.h


lib/iar_lib_power_cm33_core1.a


在新工程中, 只要放置在对应"xxx_core0.xxx"源文件存放的位置就可以了 . 两个核心除了启动代码, 链接命令文件和供电库文件之外, 其余的驱动源代码完全复用 .


这里特别强调一个思路, 双核的工程跟单核的工程本质上没有区别, 只是原来生成下载的可执行文件是一次编译, 双核工程需要两次编译(先编 core1 再编 core0). 或者也可以将 core1 的程序当成 core0 工程的库文件 . 先编译 core1 的二进制可执行程序, 然后将 core1 工程编译生成的 core1_project.bin 包含在 core0 工程中, 就像平时在单核工程中添加一个预编译库那样简单 . 实际上, 在实际使用双核应用的时候, 也就是将 core1 当成一个运行着的库函数一样使用 .


配置副核 core1 工程

1. 改头换面从 core0 到 core1

core1 工程中, 将芯片类型, linker 文件名, 芯片名的宏定义, 都从 core0 改到 core1. 另外, 由于 core1 工程文件和 core0 在同一个目录下, 为了区分同 core0 生成中间文件, 特别将输出文件目录名中加一个"core1_"的前缀 .


 
 

 

 

 

 

2. 调整 linker 文件指定运行时空间
唯一需要注意的地方就是调整 linker 文件中的内容 .

 

双核系统中, 两个内核都是总线主机, 在执行程序和存取变量的时候都需要访问系统总线, 但如果两个内核要同时访问同一个总线从机设备, 那么就会出现访问冲突, 需要通过总线仲裁, 这样就降低了两个内核访问存储设备的速度, 降低了双核系统执行程序的性能 . 因此需要合理安排两个内核各自使用的存储区, 尽量不要让两个内核在访问内存的时候"打架".

 


 
在上图中可以看到, 将 SRAMX 内存块分给 core1 存放代码, 将 RAM3 内存块分给 core1 存放数据, 其余的内存块分给 core0, 大家各用各的, 相安无事 .


core1 工程有下载时空间和运行时空间两个概念 . 下载时空间就是把需要将可执行程序下载到 flash 中, 否则掉电之后程序就没了 . 运行时空间是指, 整个系统的启动后, 需要把 flash 中的程序搬运到 ram 中, 程序中跳转指令和寻址变量都是在 ram 中的运行时空间中 . 对于 core1 工程, 怎么下载到 flash 和在系统启动过程中搬运到 ram 中, 它都不管, 这将会交给 core0 工程处理, 由于系统启动过程是单线程的, 就是把 core1 的程序存放在 core0 的管辖空间内也无妨 . 此处, core1 只要告诉自己的程序和变量, 在运行时自己会在内存中就可以 . core1 的运行时内存就是 sramx 和 ram3.

 

对应的 linker 脚本文件 LPC55S69_cm33_core1_ram.icf 中, 对应指定代码和数据存放区域的代码如下:

 


 
在基本的应用中不用去管下载选项, 因为实际不大可能直接下载 core1 工程程序到芯片中 . 即使可以通过调试器直接将程序写入到芯片的 RAM 中, 但由于 core1 的启动开关和时钟系统的初始化过程都需要 core0 的代码去完成, 单独下载 core1 的工程不能正常启动, 仍是不能直接调试的 . 但这里可以考虑到一种特殊的情况, 也是一个比较有意思的设计, 就是预先在 core0 的工程中让 core0 启动后(电路系统的默认启动操作)仅仅启用 core1, 之后什么都不做了直接进入休眠或者死等的状态 . 此时, 是可以用 IAR 的调试环境将程序下载到 RAM 中并调试 core1 的程序的 . 使用这种方式可以用于专门调试运行在副核上的功能, 待代码成熟后, 再集成到有完善功能的主核应用工程中 .


3. 生成二进制格式的 bin 文件

配置 core1 工程生成"core0_project.bin"文件(默认只生成 core1_project.out 文件), 这个二进制文件将在 core0 工程中将被直接包含 .

 


 
配置主核 core0 工程

终于回到主场了 . core0 工程就跟普通的单核工程没有不同 . 额 ... 除了需要为 core1 留一点空间(下载时空间和运行时空间). 那么在工程中的配置都是围绕这个预留空间来的 .


1. 在 IAR 工程中创建新段包容 core1 的 bin 程序文件

在编辑 linker 文件之前, 先要为 core1 的一大块程序指定一个在 core0 程序空间中的标号 . core0 工程不会关注 core1_project.bin 里各种细节, 对于 core0 来说, core1_project.bin 只是一块需要烧写在 flash 中特定位置的数据 . 甚至内存搬运的工作都是在代码中完成的, 因此在工程配置中没有更多隐藏的"黑科技".
 
其中, "Raw binary image"框中的几个字符框的内容分别是:

 


File   : "$PROJ_DIR$\core1_debug\core1_project.bin"


Symbol : "_lpc5500_cm33_core1_image"


Section: "__sec_core1"


Align  : "4"


这里的意思是, 将 core1_project.bin 文件指定成 linker 过程中的一个段(section), 段名为"__sec_core1", 并在链接过程中使用"_lpc5500_cm33_core1_image"符号指代 . 这个段在 linker 文件中将被用于安置内存, 在启动代码中将提取段地址从而执行将 coer1 程序从 flash 复制到 sramx 的过程 .


2. 调整 linker 文件包容 core1 的新段

首先, 将 core0 的程序空间和内存空间压缩, 为 core1 程序的下载时空间和运行时空间让出地方 .

 


 
从 linker 脚本代码中可以看到, 从 0x0009_0000 开始的 32KB flash 空间就是预留给 core1 的下载时空间 . core0 的数据空间也仅仅用到了 160KB.

 

之后, 在后续的 linker 脚本代码中将 core1 的下载时空间包容到 core0 工程的程序文件中 .

 


 
此处专门为 core1 的下载时空间创建了一个域(region)并制定地址, 然后将之前创建的段"__sec_core1"通过块"sec_core1_block"包含在"CORE1_region"域中 .


3. 在代码中复制 core1 的程序到 ram 中并启动 core1
笔者总是想尽量把关键的部分放在代码中, 因为 IDE 总是在更新, 配置可能会变, 但代码永恒 . 而且代码是程序员的通用语言, 最容易理解 .


使用 core1 程序的关键部分, 就在于内存复制和启动内核, 一个是软件的活, 一个是硬件的事 .

笔者在"core1_init.c"文件中实现了 core1_init()函数:

 


 
这里面用了一点 IAR 编译器专有的"黑魔法", 通过"_section_end()"函数配合提取了"_sec_core1"段的地址空间 . 然后就是用 memcpy 函数直接进行无差别的内存复制 . 此处的"CORE1_BOOT_ADDRESS"不是动态提取的, 如果想保持代码风格一致的话, 也可以像"core1_image_start"一样从链接文件中引用 . 此处将两种方式都展现出来, 只是为了说明两种方式的作用是一致的 .


start_core1_hardware()函数的实现内容是硬件双核系统的专有设计, 根据 LPC55S69 手册中的说明, 启动 coer1 首先需要在"SYSCON->CPUCFG"寄存器中启用 core1, 之后在"SYSCON->CPBOOT"寄存器指定可执行程序二进制文件的首地址, 芯片就会自动将其指定为 core1 的向量表地址(启动地址), 最后在"SYSCON->CPUCTRL"寄存器中执行一波"神操作", 需要在特定验证码的加持下, 提供时钟并复位 core1, 才能最终让 core1 运行起来 .


至此, 为双核程序运行准备的所有配置操作都已经完成, 在 core0 工程中的 main()函数中调用 core1_init()即可启动为 core1 预先写好的程序 .


编写样例程序, 测试运行

1. 测试 core1 正常启动并运行

搭建好双核运行环境之后, 笔者先写了一个简单的测试程序, 验证 core1 能够被正常启动并运行 . 具体就是让 core1 控制板子上的一盏小灯闪烁 . core1 的 main()函数代码如下:

 


 
在 core0 的 main()函数中, 只是调用"core1_init()"启动 core1 而已, 没有同 core1 有进一步的干预 . 下载程序后, 可以看到小灯闪烁, 说明 core1 已经按照预期正常工作了 .


2. 实现简单的双核通信
实际上, 笔者希望 core1 能够帮助 core0 在运行时执行更多的辅助工作, 灵活地根据 core0 的需求执行多种操作 . 然后笔者就基于共享内存的机制, 实现了一个极为简单的纯软件的双核通信组件"shmem". 具体原理很简单, 笔者在 LPC55S69 芯片上的 ram 空间中分出来一块内存, 独立于 core0 和 core1 工程可自主使用的内存, 而是需要两个内核通过绝对地址访问 . 目前实现的就是两个核的分别拥有的事件标志位和两个带锁的单向 fifo 数据队列, 事件标志位用于同步事件, 两个 fifo 数据队列就像串口的收发一样建立数据的双向通信通道 .


写好了"shmem"组件之后, 笔者改进了基本的双核测试程序, 让 core1 在 core0 的控制下闪烁小灯 . 笔者设计了控制小灯的命令码和参数格式, 然后通过从 core0 到 core1 的 fifo 传递控制命令及参数 . 当然, 在此之前, 笔者还用了事件标志同步了两个内核的工作步调, 一定确保在 core1 已经完成了对 fifo 的初始化, 保证 fifo 可用之后才能让 core0 向 fifo 中下命令 .

 

这样, core1 的 main()就增加了 shmem 的内容:

 


 
core0 的 main()函数在初始化阶段, 耐心等待 core1 的各项工作准备完成, 然后时不时向 core1 的 fifo 发送控制命令及参数 .

 


 
下载, 运行, core1 接收 core0 的指令控制小灯闪烁, 大功告成 .

 


 
后记

MCUX SDK 的代码包中已经提供了双核的样例工程, 为什么笔者此处还是要做从零开始的工程搭建 . 原因有两个:

 

SDK 中的双核工程略显臃肿, 像 LPC5500 这种芯片,由于 core0 和 core1 只有 CPU 不同, 整个系统中的外设是完全一样的, 因此可以使用同一份驱动代码 . 本文中将副核作为主核的一个运行库安排在应用工程中, 这个思路跟在单核工程中添加预编译库的思路基本一致, 便于用户理解。而 SDK 将两个核的工程完全独立出来, 这对于熟悉经典单核开发的用户来说,始终需要按双芯片的系统考虑,但好处是两个工程可以分别用不同版本的 SDK 库,也可以分别用不同的开发工具,适合大型项目或多人同时开发等。

 

SDK 中提供样例程序, 对双核通信部分的实现比较高级(无论是"erpc"还是"rpmsg"),  在比较简单的环境时,不必使用这么复杂的组件, 可以考虑使用笔者设计的这个 shmem 组件用于后期开发 .


笔者的初衷, 是用尽量简单的方式理解双核工程, 然后才能进一步将双核系统用起来 . , 笔者希望通过本文的讲述, 能够降低读者使用 LPC55S69 微控制器双核系统的难度, 让广大的单片机开发者们了解双核开发, 善用双核系统, 充分使用 LPC55S69 这款性能强大的芯片 .