ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Zynq-7000裸机工程升级:轻量级协作式调度器设计与实践

Zynq-7000裸机工程升级:轻量级协作式调度器设计与实践 从第1期做到现在这个Zynq-7000开源实战系列已经覆盖了不少工程化bring-up的硬骨头。这一期我们聊调度器准确说是把自己写的裸机工程升级成带任务调度机制的平台。为什么要在这个时间点加调度器因为做完整PS/PL协同系统的时候单靠一个大循环轮询已经撑不住场面了——你一边要收PL侧的中断一边要处理以太网UDP报文还要兼顾调试串口和状态刷新这几件事挤在同一个裸机循环里早晚要出乱子。加入调度器不是炫技是把复杂的实时响应拆成一个个独立任务让每个模块各干各的活优先级高的先跑低优先级的后跑。这一期内容适合已经能用SDK或Vitis把Zynq-7000跑起来、做过PS/PL基础交互、但还没正式把工程“工程化”的开发者。你会看到我在自己维护的这套开源项目里为什么选轻量级协作式调度器而不是杀鸡用牛刀直接上RTOS以及在整个bring-up过程中加入调度器需要动的那些“手术刀级”改动。全程基于Zynq-7000兼顾PS和PL两侧代码直接能移植。1. 内容整体设计与思路拆解1.1 为什么bring-up阶段就要加入调度器很多人的bring-up就是写个main函数初始化DDR、配置MIO、然后死循环里轮询PL侧的状态寄存器。这种模式在功能验证阶段没问题但一旦你的PL侧逻辑开始产生多个中断源PS侧要同时维护网络协议栈、UI刷新、数据采集裸循环就会变得非常别扭。我在自己的工程里就踩过这样的坑UDP接收线程的处理逻辑稍微慢了一点PL侧的FIFO直接溢出数据丢了还找不到原因。在bring-up阶段加入调度器最大的好处是把“什么时机做什么事”从编码者的脑子里移到了调度逻辑里。你不再需要手工调整循环里各个模块的调用顺序也不用担心某个耗时操作把其他中断饿死。调度器本质上是把CPU时间按规则切分让每个功能模块拥有独立的“执行上下文”这种思维方式越早建立后面加功能越省事。另外从系统稳定性的角度讲调度器提供了一套统一的错误隔离机制。某个任务崩溃了调度器可以捕获它并重新启动该任务而不是让整个系统跟着跑飞。这条在工程化项目中非常重要因为Zynq-7000经常被部署在需要7x24小时运行的场合现场复位不是一个可接受的选项。1.2 调度器选型为什么我选协作式 定时器轮转现在嵌入式调度器的选择很多最粗暴的是直接上FreeRTOS功能齐全但配置繁琐。另一种是uC/OS资源占用偏高。再有一种就是自己写一个轻量级协作式调度器只用定时器tick加任务函数指针数组。我在自己的Zynq开源项目中用的就是第三种原因是这套平台的主要任务是PS/PL协同PL侧的中断处理已经天然提供了“抢占”能力PS侧的任务更多是周期性的数据搬运、状态解析、命令分发这些用协作式调度器完全够用。协作式调度器的核心思想是“任务主动让出CPU”。每个任务在执行完一个时间片后必须显式调用调度器的yield函数否则其他任务没有机会运行。这个模型看起来简单但它有一个很关键的优势不需要复杂的上下文切换因此也不用为每个任务单独分配栈空间整个系统内存占用极低。Zynq-7000虽然有几百MB的DDR但很多场景下你分配给PS侧运行的SRAM或DDR分区是有限的尤其是和PL侧共享内存时能省一点是一点。定时器轮转则保证每个任务在固定周期内都会被调度到。我把系统tick设置为1ms每个任务的时间片默认5ms也就是5个tick。如果你有一个任务需要更长的时间可以配置它的时间片大小。这种结构的实时性虽然比不上抢占式内核但在PS/PL协同的场景下真正的硬实时要求都交给了PL侧的逻辑电路去保证PS侧只需要及时响应PL的中断即可所以这个方案性价比非常高。1.3 与“负载调度器”的概念区分这里要提醒一下很多人听到“调度器”第一反应是服务器的负载均衡调度器比如Nginx的upstream或者LVS。我们的嵌入式调度器是另一回事。嵌入式调度器管理的是单颗CPU上的任务执行顺序而负载均衡调度器管理的是多台服务器之间的流量分配。在Zynq平台上我们谈的是前者。搜索热词里出现的“负载调度器”如果不是指任务调度那就需要额外注意避免概念混淆。我的平台中任务调度的目标就是保证CPU利用率均衡不让任何一个功能模块霸占处理器不放。2. 核心细节解析与实操要点2.1 调度器的任务划分原则在Zynq-7000的PS/PL协同工程里任务怎么划分直接决定调度器的效果。我自己的习惯是按照数据流来划分而不是按功能模块。比如有三个功能以太网接收、PL数据采集、状态显示。常规想法是建立三个任务net_recv_task、pl_collect_task、display_task。但数据流视角下以太网接收和PL数据采集往往是上下游关系——PL侧采集的数据要通过PS侧封装后从网口发出那这两个任务就需要通过队列进行数据交互而显示任务则只负责读取状态变量。任务划分时要特别注意中断服务程序和任务之间的界限。PL侧的中断例如AXI-GPIO中断或DMA完成中断应当只负责设置事件标志或往队列里扔一个数据真正的数据处理放到任务中完成。因为ISR里的执行时间越短中断丢失的可能性越小。调度器任务去轮询事件标志位一旦发现事件就执行相应的处理函数这样的结构既保证了中断的实时性又避免了在ISR里运行过长逻辑导致下一次中断被延迟。任务分优先级时我的建议是PL侧数据通路任务设为最高优先级以太网任务其次显示和调试任务最低。原因很简单PL侧的数据如果没及时取走FIFO可能溢出以太网报文丢失了可以有重传机制但PL侧的硬件状态丢失可能就是不可恢复的采样缺口。当然优先级高不代表一直占用CPU每执行完一个时间片就必须让出这样才能保证低优先级任务不会被饿死。2.2 调度器与PS/PL协同的接口设计既然叫PS/PL协同调度器就必须能够感知PL侧的状态。我的做法是给每个PL侧外设定义一个任务句柄包含一个事件位和一个回调函数指针。比如PL侧通过AXI-Lite寄存器写了一个值为1的触发信号PS侧的中断处理程序会捕获这个上升沿然后把对应事件位置1。调度器每次调度到该任务时检查事件位是否为1如果为1就调用回调函数处理完清除事件位。这里有一个容易被忽略的要点对PL侧寄存器的访问必须是原子的。我遇到过很多次读一个AXI寄存器读到一半突然来了一个中断然后ISR里也去读同一个寄存器读回来的数据就花了。解决方法是把读操作放到临界区内也就是在调度器里提供enter_critical和exit_critical函数这两个函数负责关闭和开启中断。所有共享寄存器的访问必须包在这两个函数之间。调度器还需要和PL侧的复位逻辑协作。当系统启动时PL侧的配置是在PS侧的FSBL里通过PCAP完成的但PL侧的用户逻辑有自己的复位信号。我的调度器在初始化完成后会专门开一个init任务这个任务负责释放PL侧的复位等待PL侧准备好标志位然后才启动其余任务。这避免了PS侧任务跑得飞快而PL侧还没ready导致的竞态问题。2.3 调度器tick与定时器的选择Zynq-7000的PS侧有两个关键定时器全局定时器Global Timer和私有定时器Private Timer。我选用的是私有定时器作为调度器的tick源。它频率为CPU频率的一半在Zynq-7000默认667MHz主频下私有定时器时钟约为333MHz。要产生1ms的tick只需要设置装载值为333333减1即可。注意定时器是递减计数从初始值减到0产生中断。tick频率的选择会影响系统开销。1ms听起来合理但如果你有大量的短周期任务可以把tick提高到100微秒相反如果任务都是几百毫秒级别的tick可以设成10ms以降低中断次数。我自己的平台里保持1ms不变因为PL侧的中断有时会要求微秒级的响应但这部分又有专门的快速中断处理不需要经过调度器。调度器管理的任务时间粒度就是1ms足够用了。定时器中断的优先级也要设置得当。在GIC通用中断控制器里我把私有定时器中断设为优先级0最高等级确保tick永远不会被其他中断卡住。PL侧的某些中断设为优先级1或2。虽然Zynq的GIC支持优先级嵌套但我的工程里基本不嵌套因为一旦嵌套临界区的逻辑就会变得复杂得多。简化为“tick中断最高其他中断按需设置”调度稳定性反而更好。3. 实操过程与核心环节实现3.1 从裸机main函数到调度器框架的移植步骤假设你现在有一个可以工作的裸机工程main函数里是一个大循环。改造的第一步不是写调度器而是把你的循环体拆解成若干个独立的函数。比如原来的循环是while (1) { eth_poll(); pl_data_poll(); display_update(); }改成三个函数task_eth_recv()、task_pl_collect()、task_display()。每个函数内部不能有死循环必须保证执行有限时间后返回。如果你的函数里本来有等待某个标志位的死循环那就要改成非阻塞查询没等到就直接返回等下一次调度再来。第二步是建立任务表。任务表是一个结构体数组每个元素记录任务函数指针、任务优先级、时间片计数和当前状态。我的简易任务表定义为#define MAX_TASKS 8 typedef struct { void (*func)(void); uint32_t period_ticks; // 调度周期单位tick uint32_t countdown; // 当前倒计时 uint8_t priority; // 0最高 uint8_t enabled; } os_task_t;调度器主循环则非常简单每个tick中断里递减所有任务倒计时当倒计时为0时把任务标记为可运行。主循环按优先级顺序查找可运行任务执行它然后重新装载倒计时。这个过程我们叫“按周期调度”每个任务可以有自己的周期比如以太网任务每2ms跑一次显示任务每50ms跑一次这样比固定时间片更灵活。第三步是对main函数进行改造。原来的初始化保留之后调用os_init()注册所有任务再调用os_start()启动调度器。os_start会初始化定时器并开启中断然后进入无限循环调度。int main(void) { init_ps(); // 原有PS初始化 init_pl(); // PL侧初始化释放复位 os_init(); os_add_task(task_eth_recv, 0, 2, 1); os_add_task(task_pl_collect, 1, 1, 1); os_add_task(task_display, 2, 50, 1); os_start(); // 永远不会执行到这里 return 0; }3.2 中断处理与任务调度的协作代码示例调度器能不能稳定运行关键看中断和调度主循环之间的配合。我的调度器使用一个简单的前后台模型tick中断负责更新计时主循环负责执行任务。这里必须说明白我的系统不是抢占式调度tick中断不会打断当前任务去运行另一个任务它只设置标志。任务切换只在任务主动返回或调用os_yield()时发生。以PL侧AXI-GPIO中断为例初始化时要为对应中断号注册ISRvoid pl_gpio_isr(void *data) { uint32_t status XGpio_InterruptGetStatus(gpio_inst); XGpio_InterruptClear(gpio_inst, status); if (status PL_DONE_FLAG) { os_set_event(PL_DONE_EVENT); // 置事件 } }任务函数里这样写void task_pl_collect(void) { if (os_is_event_set(PL_DONE_EVENT)) { os_clear_event(PL_DONE_EVENT); process_pl_buffer(); // 取数据搬运到共享内存 } }事件标志就是一个32位全局变量使用原子操作置位。注意由于我们没有抢占所以ISR里写事件、任务里清事件不会出现两个线程同时操作同一个事件的情况。但如果任务之间有共享数据那就必须用临界区保护比如多个任务都要往同一个缓冲区写数据时void task_a(void) { os_enter_critical(); write_to_shared_buf(shared_buf); os_exit_critical(); }3.3 调度器在工程化bring-up中的烧写与启动整合把调度器加入工程后整个镜像的生成流程也要跟着变。Zynq-7000的启动需要BOOT.BIN里面包含FSBL、SSBL如果有和应用程序的镜像。在Vitis里创建boot image时确认你的应用程序入口确实从main开始并保证FSBL初始化DDR后能把应用正确加载到DDR地址。调度器本身就依赖于DDR正常初始化因为任务栈、全局变量都在DDR里。如果在早期bring-up阶段DDR不稳定调度器跑起来会随机崩溃。我的建议是在进入调度器之前先跑一遍DDR自检确保读写稳定。这一步可以放在FSBL之后、main函数最前面用一个简单的读写测试验证几个内存地址。烧写BOOT.BIN到QSPI Flash时注意Zynq-7000的启动模式引脚设置。我用的是QSPI启动MIO[6:2]配置成00100。烧写工具可以是Vitis的Program Flash或者使用U-Boot的sf write命令。我习惯先把BOOT.BIN放到SD卡在U-Boot里通过tftp或fatload写入QSPI。实际写入后的校验很重要烧写完成后最好回读一次确保没有坏块问题。这部分在热词“zynq烧写”里也是高频话题很多新手的板子启动不了就是烧写时忽略了QSPI的块擦除对齐。3.4 加入调度器后的实时性验证方法调度器本身不产生实时性它只是分配CPU时间。要验证你的调度是否满足PS/PL协同需求我建议用GPIO翻转法在最高优先级任务里加一个GPIO翻转然后用示波器看翻转波形的周期是否稳定。同时让PL侧产生固定频率的中断检查PS侧处理中断的最大延迟。我的平台要求PL中断到任务处理开始的时间少于100微秒。实际测下来由于调度器tick是1ms任务周期是2ms所以中断到来后任务最多等待2ms才能处理这个延迟在能接受的范围内。如果你觉得这个延迟太大可以把PL相关任务周期缩短到1ms甚至用特殊机制当PL中断到达时在ISR里不仅置事件还直接调用一个轻量处理函数把最紧急的数据先搬到临时缓冲区然后再由任务慢慢处理。这样既保证了紧急数据的快速保存又不破坏调度器结构。4. 常见问题与排查技巧实录4.1 Zynq-7000加入调度器后程序卡死的排查我遇到过最典型的卡死现象是上电后串口打印了启动信息但调度器启动后没有任何输出也不进入任务。经排查发现问题出在定时器中断没正确使能。Zynq-7000的私有定时器除了要设置装载值还要在GIC中使能中断并且在定时器控制寄存器中启动计数器。很多人漏了最后一步导致tick永远不来主循环空转。另一种卡死是任务内死循环。由于是协作式调度如果一个任务函数里出现while(1);或者阻塞等待整个系统就瘫痪了。排查方法是把每个任务函数前后加上调试输出看最后一个执行的任务是谁那么这个任务大概率就是罪魁祸首。解决方式是检查代码里所有可能阻塞的API尤其是SDK里的某些驱动函数它们内部可能自带超时等待必须确认超时时间有限。4.2 调度器与PL中断共享资源时的数据踩踏我在工程中踩过一次比较隐蔽的坑PL侧的DMA完成中断里我直接读取了一段共享内存的数据状态然后把这个状态通过队列传给任务。但任务在处理的时候PL侧已经在搬运新一轮数据了导致内存被覆盖数据解析出错。后来我用了“双缓冲”机制PL侧使用DMA的ping-pong模式同一块内存区域当前正在被PS任务处理时DMA自动切换到另一块区域。这样一来任务和PL侧永远在操作不同的内存块彻底解决了冲突。另一个细节是调度器任务访问PL侧的AXI寄存器时尽量使用映射好的虚拟地址。Zynq-7000的PS侧在非虚拟内存环境下就是物理地址但在某些Linux或复杂环境下可能涉及地址转换。我维护的是裸机工程所以直接用物理地址访问即可。如果你看到地址访问段错误基本可以确认是地址映射错误。4.3 以太网UDP测试在调度器下的坑很多人在Zynq上做以太网UDP测试用的是LwIP裸机版本。LwIP本身有它的定时轮询机制如果你在调度器里直接跑lwip_tx_timer和lwip_rx_poll可能会和LwIP内部的时间片冲突。我的做法是把LwIP原来的周期性函数封装成一个任务周期调到和它的timer间隔一致。同时在UDP接收回调里不要做耗时操作只把数据复制到队列由另一个任务去解析。我实测下来UDP收发在不丢包的情况下小包数据率能达到线速的70%左右瓶颈不在调度器而在LwIP协议栈处理本身。如果丢包率突然增大先检查调度器是否把以太网任务的优先级降得太低或者任务周期是否过长。另外LwIP使用的中断和调度器tick中断优先级要配置好我曾经把网口中断优先级设得比tick还高导致tick被大量中断延迟整个系统节奏全乱了。4.4 热词“zynq ultrascale”相关场景的延伸思考虽然项目标题是Zynq-7000但有人问过这套调度器方案能不能用到Zynq UltraScale上。我的回答是基本原理完全通用但有几个地方要改。UltraScale的PS侧处理器是四核ARM A53或双核R5调度器如果需要多核支持就要引入核间通信。另外UltraScale的GIC版本更高中断号分配和优先级配置方式有差异DDR控制器配置也不一样。我目前的协作式调度器是针对单核设计的在A53上跑没问题但如果你用的是R5核因为它有比较强的实时性可能更适合AMP模式每个核跑一个独立的调度器实例。从开发场景上看UltraScale适合那些需要更高性能、更丰富接口的复杂系统比如图像处理、软件无线电。但Zynq-7000作为学习平台性价比很高调度器的思路一通百通后续换到UltraScale只是配置层面的工作量。5. 调度器平台往工程化方向的打磨建议5.1 给调度器加上统计与调试接口作为一个工程化平台调度器不能只是跑起来就算了还得能监测健康状态。我实现了两个简单统计量每个任务的实际执行次数和最长一次执行时间。最长执行时间可以利用定时器捕捉在任务入口记录计数器任务出口读取差值。如果最长时间接近甚至超过该任务的周期说明这个任务负载过重需要优化或拆分。调试接口我用了共享内存方式调度器把任务状态结构体的首地址写到PL侧可见的寄存器区域PL侧可以通过逻辑分析仪或片上逻辑观察PS侧的任务运行情况。当然对于不熟悉PL侧调试的人来说直接在串口打印也可以。我的板子上有一个物理按键按下后触发调度器打印所有任务的统计信息实测在定位性能瓶颈时非常有用。5.2 从调度器到裸机到RTOS的平滑升级路径前面我说自己写调度器是为了轻量但工程需求变化很快可能某一天你就需要一个真正的抢占式内核。我的任务表设计从第一天就考虑到了这一点把os_add_task这类接口抽象成与底层调度机制无关的API以后要迁移到FreeRTOS只需要把API改写为FreeRTOS对应的xTaskCreate任务函数本身不用变。这个抽象层非常关键它保留了平滑升级的可能性而且不会绑死你的实现。如果你最终决定用FreeRTOS在Zynq-7000上移植并不难Vitis里可以直接导入FreeRTOS库。但要注意FreeRTOS的heap大小、定时器服务任务的优先级还要配置好中断优先级分组确保GIC中断嵌套行为符合FreeRTOS的预期。我见过有人直接把FreeRTOS demo跑在Zynq上结果PL侧中断一多就出问题后来发现是中断优先级分组设置得不对FreeRTOS要求4位优先级的低2位用于抢占屏蔽。5.3 如何用这套平台做后续扩展这套带调度器的PS/PL协同平台能扩展的方向很多。比较直接的是加一个shell任务通过串口实现命令行交互——可以取任务状态、读取PL寄存器、触发一次数据采集。再进一步可以加文件系统用QSPI或SD卡记录日志。如果你对网络有兴趣可以在LwIP之上增加一个简单的modbus或自定义协议做成一个真正的工业控制器雏形。我自己后续打算在平台上加一个“在线升级”功能从UDP接收新的应用程序镜像写入QSPI另一个分区然后在调度器中触发软复位启动新版本。这需要对QSPI驱动很熟悉同时调度器需要有一个专门任务来管理升级状态机不能被其他任务打断。这部分也是工程化bring-up的重要一环尤其在设备部署到现场后无法用JTAG更新固件在线升级就是刚需。5.4 一个我在实际调试中总结的调度器配置速查表为了让读者少走弯路我把调度器加上去之后需要确认的关键参数整理成一个表你可以对照检查。这张表基于我自己的工程实际项目需要按硬件调整。参数项我的配置说明tick周期1ms使用私有定时器装载值 CPU时钟/2 * 0.001 - 1任务最大数量8够用于中小规模系统太多会增大调度开销最低优先级任务周期100ms用于显示和调试避免频繁调度浪费CPU临界区开关关闭/开启私有定时器以外的所有中断使用GIC的CPSR实现任务栈大小2KB/任务我的任务里不使用大型局部数组如果用到尽量提高到4KB以太网任务优先级1优先级0预留给PL数据通路任务PL中断优先级2保证PL中断不高于tick防止tick延迟系统空闲任务有最小优先级的空转任务统计CPU使用率这张表的意义在于你没有必要从头探索所有参数照着这个基线跑起来再根据具体外设微调。工程化无论写不写RTOS本质都是把时间和资源分配给必须的功能模块调度器只是提供一个清晰的框架来管理你的工程复杂度。我个人在实际操作中的体会是调度器最让人舒服的一点不是它让系统更快——相反协作式调度器甚至可能让系统响应更慢——而是它让系统的行为变得可预期。你可以告诉PL侧的人“你触发中断后最多2毫秒任务就会去取数”而不是含糊地说“会在某个时间处理”。这种可预期性才是工程化bring-up真正需要的。所以如果你也在做Zynq-7000的PS/PL协同平台不妨从这一期开始把调度器加进去哪怕先把它当一个任务管理工具用都会让你的系统迈上一个大台阶。最后再分享一个小技巧把调度器的tick事件用GPIO引出来接到示波器同时把PL侧的关键中断也引出来这样可以直观地看到中断到调度的延迟曲线。这个方法我在调DMA中断优先级时帮了大忙希望你也能用上。
返回列表