首页 / 资讯中心 / 文章详情

PYNQ平台Linux用户空间PS定时器中断实战:内核模块与poll机制

PYNQ平台Linux用户空间PS定时器中断实战:内核模块与poll机制 ★ FEATURED ARTICLE
1. 从裸机到系统级为什么PS定时器中断值得单独拿出来说做嵌入式开发的朋友大概率都经历过这样一个阶段在纯裸机环境里用定时器中断做任务调度代码写起来得心应手寄存器配置、中断向量表、中断服务函数一套流程下来行云流水。可一旦切换到带操作系统的平台比如在PYNQ上跑Linux很多人就懵了——中断还能像以前那样直接写ISR吗答案显然是否定的但问题在于很多人不知道该怎么在Linux用户空间里安全、稳定地使用PS端定时器中断。PYNQ这个平台比较特殊它本质上是Xilinx Zynq系列SoC加了一套Python生态的封装。PS端Processing System指的是ARM处理器部分PL端Programmable Logic指的是FPGA逻辑部分。PS定时器中断说白了就是利用Zynq PS内部集成的私有定时器、全局定时器或者系统看门狗定时器产生周期性中断信号然后在处理器端响应这个中断。这件事在裸机下很简单但在PYNQ的Linux环境下你需要考虑内核驱动、设备树、用户空间接口、中断延迟、线程安全等一系列问题。我之所以想把这个话题单独拎出来聊是因为在实际项目中PS定时器中断的应用场景远比想象中广泛。比如你要做数据采集需要精确的采样周期比如你要做电机控制需要固定的PWM更新频率再比如你要做实时任务调度需要一个稳定的时间基准。这些场景下用Linux自带的软件定时器精度不够用PL端的逻辑实现又有点杀鸡用牛刀PS定时器中断恰好是一个折中的选择。这篇文章适合谁看如果你已经对PYNQ有基本了解知道怎么烧录镜像、怎么用Jupyter Notebook但还没深入折腾过底层中断机制那这篇内容就是为你准备的。如果你是有经验的嵌入式工程师想看看在PYNQ这个特定平台上怎么把PS定时器中断用起来里面的一些实操细节和踩坑记录应该也能帮到你。我会从整体设计思路讲起然后拆解核心细节接着给出完整的实操流程最后分享一些常见问题和排查技巧。整篇内容基于我在实际项目中的经验尽量说人话把“为什么这么做”讲清楚。2. 整体设计思路在Linux用户空间用中断到底该怎么选方案2.1 三种可行路径的对比与取舍在PYNQ上使用PS定时器中断从技术路线上看大致有三种方案。第一种是写一个完整的内核模块在模块里注册中断处理函数然后通过字符设备或者sysfs向用户空间暴露接口。第二种是利用Linux内核已经提供的UIOUserspace I/O框架或者类似的机制把中断处理的一部分工作放到用户空间。第三种是借助PYNQ自带的某些库或者框架看是否已经有封装好的定时器中断接口。先说我为什么最终选择了内核模块这条路。UIO框架确实能简化一些工作但它更适合于PL端产生的中断对于PS内部定时器中断的支持并不直观。而且UIO的中断处理延迟虽然比纯用户空间轮询好但相比内核模块直接处理还是差一些。至于PYNQ自带的库我翻了一遍文档和源码发现它对PS定时器中断的封装几乎为零更多是聚焦在PL端的Overlay加载和GPIO控制上。所以如果你想要一个稳定、可控、延迟可预期的方案写内核模块是最靠谱的。当然内核模块有它的代价。你需要编译内核模块需要处理内核版本匹配问题需要小心内存管理和并发控制。但这些问题都是一次性的一旦跑通后续使用就很省心。而且Zynq的PS定时器资源是固定的私有定时器每个CPU核有一个全局定时器有一个看门狗定时器也有一个资源不算丰富用内核模块可以更精细地管理这些资源。2.2 为什么不用Linux自带的高精度定时器有人可能会问Linux不是有hrtimer吗用户空间不是有timerfd吗为什么还要折腾PS定时器这个问题问得好。Linux的hrtimer确实精度很高在大多数场景下够用。但它的精度依赖于系统时钟源而系统时钟源本身可能来自PS的全局定时器或者外部晶振。如果你直接用PS私有定时器可以绕过一些软件层的开销获得更确定的响应时间。更重要的是在某些场景下你需要的是一个独立于Linux调度器的时间基准。比如你在做一个实时性要求较高的控制循环Linux的CFS调度器可能会因为负载变化导致你的定时器回调被延迟。而PS私有定时器中断是直接打到CPU上的你可以通过设置中断亲和性把它绑定到某个特定的CPU核上减少其他任务的干扰。这种确定性是软件定时器很难保证的。另外从学习的角度来说搞清楚PS定时器中断的完整链路对于理解Zynq的中断控制器GIC、中断向量表、内核中断子系统都有很大帮助。这些东西在裸机开发中你是直接接触的但在Linux下被层层封装了通过写一个PS定时器中断的内核模块你可以把这些封装一层层剥开。2.3 整体架构设计我的设计思路是这样的在内核模块中首先申请PS私有定时器的中断号然后注册中断处理函数。在中断处理函数中做最少的工作——比如更新一个计数器或者唤醒一个等待队列。真正的业务逻辑放在用户空间的应用程序中通过读取设备文件或者等待poll事件来获取中断通知。这样做的好处是中断上下文尽可能短避免在中断处理函数中做耗时操作。同时用户空间程序可以灵活地处理业务逻辑不需要重新编译内核模块。两者之间的接口我选择用字符设备加poll机制这样用户空间可以用select/poll/epoll来等待中断事件效率比轮询高得多。具体来说内核模块需要完成以下几件事确定PS私有定时器的基地址和中断号配置定时器的加载值和预分频器使能定时器中断注册中断处理函数创建字符设备实现file_operations中的open、release、read、poll等操作。用户空间程序则通过open打开设备文件通过poll等待中断事件通过read读取中断计数或者时间戳。这个架构的优点是清晰、可控、可扩展。缺点是内核模块的编写和调试有一定门槛尤其是对于不熟悉Linux内核开发的读者来说。但我会在后面的实操部分尽量把每一步都讲清楚把踩过的坑都标出来。3. 核心细节解析PS定时器的硬件基础与内核接口3.1 Zynq PS定时器的硬件资源分布Zynq-7000系列SoC的PS端内部集成了几种定时器每种都有不同的用途和特性。首先是私有定时器每个ARM Cortex-A9核都有一个私有的32位定时器它只能被所属的CPU核访问中断号也是固定的。私有定时器的时钟频率通常是CPU频率的一半比如CPU跑在667MHz私有定时器就是333MHz。它有一个8位的预分频器可以把时钟进一步分频然后递减计数计数到零时产生中断。其次是全局定时器这是一个64位的定时器可以被两个CPU核共享。它同样有预分频器但它是递增计数的计数到设定值后产生中断。全局定时器适合做全局的时间基准比如系统时钟源。最后是看门狗定时器每个CPU核有一个主要用于系统复位但也可以配置成普通定时器模式来产生中断。在实际使用中私有定时器是最常用的因为它每个核独立不会和其他核产生竞争。而且它的中断号是固定的私有定时器0的中断号是29私有定时器1的中断号是30。全局定时器的中断号是27看门狗定时器的中断号是31和32。这些中断号在Linux内核中都有对应的宏定义你可以在设备树或者内核头文件中找到。需要注意的是这些定时器的寄存器是内存映射的基地址在Zynq的技术参考手册中有详细说明。私有定时器的基地址通常是0xF8F00600定时器0和0xF8F00620定时器1。全局定时器的基地址是0xF8F00200。看门狗定时器的基地址是0xF8F00640和0xF8F00660。这些地址在Linux内核中已经被映射过了你在内核模块中不能直接访问物理地址需要用ioremap或者devm_ioremap来映射。3.2 Linux中断子系统的关键概念在写内核模块之前有几个Linux中断子系统的概念必须搞清楚。首先是中断号在Zynq平台上硬件中断号IRQ number和Linux虚拟中断号virq之间有一个映射关系。对于私有定时器0硬件中断号是29但在Linux中你需要用irq_of_parse_and_map或者platform_get_irq来获取对应的虚拟中断号。如果你直接硬编码虚拟中断号在不同内核版本或者不同设备树配置下可能会出问题。其次是中断处理函数的返回值。在Linux内核中中断处理函数返回IRQ_HANDLED表示中断已经被处理返回IRQ_NONE表示中断不是这个设备产生的。对于共享中断内核会根据返回值来判断是否需要继续调用其他处理函数。PS私有定时器中断是独占的不共享所以返回IRQ_HANDLED即可。还有一个重要的概念是中断上下文。中断处理函数运行在中断上下文中不能睡眠不能调用可能引起阻塞的函数。所以你不能在中断处理函数中调用copy_to_user、kmalloc除非用GFP_ATOMIC、mutex_lock等。如果你需要和用户空间交互应该用等待队列或者完成量来唤醒用户空间线程让它在进程上下文中完成后续工作。另外中断亲和性也值得关注。在双核Zynq上你可以通过/proc/irq/中断号/smp_affinity来设置中断绑定到哪个CPU核。对于实时性要求高的场景把定时器中断绑定到一个核把其他任务放到另一个核可以减少干扰。这个设置可以在用户空间通过写文件来完成也可以在内核模块中通过irq_set_affinity_hint来设置。3.3 设备树中的定时器节点在Linux内核中硬件资源通常通过设备树来描述。Zynq的PS定时器在设备树中已经有现成的节点你不需要自己从头写。比如私有定时器0的节点通常长这样timerf8f00600 { compatible arm,cortex-a9-twd-timer; reg 0xf8f00600 0x20; interrupts 1 13 0x301; interrupt-parent intc; clocks clkc 4; };这个节点里的interrupts属性描述了中断信息第一个单元格是中断类型1表示PPI即私有外设中断第二个是中断号13对应私有定时器0第三个是触发方式。interrupt-parent指向中断控制器clocks指向时钟源。如果你要写一个内核模块来使用这个定时器你可以通过设备树匹配来获取资源。但更简单的方法是直接用platform_device_register或者platform_driver_register来注册一个平台驱动然后在probe函数中通过platform_get_resource获取寄存器基地址通过platform_get_irq获取中断号。不过对于PS私有定时器它已经在设备树中有了节点而且可能已经被内核的时钟事件驱动占用了。这一点需要特别注意。实际上在标准的Linux内核中PS私有定时器通常被用作时钟事件源clock event device也就是说内核的调度器、定时器子系统已经在使用它了。如果你直接去操作这个定时器可能会和内核的时钟框架冲突导致系统不稳定。这是一个非常关键的坑我在第一次尝试的时候就踩到了。解决方案有两种一是使用全局定时器或者看门狗定时器它们通常没有被内核占用二是如果一定要用私有定时器需要先确认内核是否真的在使用它如果是你可能需要修改内核配置禁用时钟事件驱动对它的使用或者使用一个未被使用的定时器。在Zynq上私有定时器0通常被用作时钟事件源私有定时器1可能空闲全局定时器可能被用作时钟源。所以最稳妥的选择是使用私有定时器1或者看门狗定时器。4. 实操过程从内核模块到用户空间程序的完整实现4.1 内核模块的编写与编译我选择使用私有定时器1因为它在大多数配置下是空闲的。首先需要确定它的基地址和中断号。私有定时器1的基地址是0xF8F00620中断号是30。在Linux中对应的虚拟中断号需要通过irq_of_parse_and_map或者直接使用IRQ编号来获取。为了简单起见我直接在模块中定义了物理地址和中断号然后用ioremap映射寄存器。内核模块的主要结构包括模块初始化函数、模块退出函数、中断处理函数、字符设备操作函数。在初始化函数中首先映射定时器寄存器然后配置定时器的加载值和预分频器接着注册中断处理函数最后创建字符设备。定时器的配置涉及几个关键寄存器加载寄存器Load Register、控制寄存器Control Register、中断状态寄存器Interrupt Status Register。加载寄存器用来设置计数器的初始值控制寄存器用来使能定时器、设置预分频器、选择中断模式等。中断状态寄存器用来清除中断标志。计算定时周期的方法是这样的假设私有定时器的输入时钟频率是CPU频率的一半比如333MHz。预分频器设置为N那么定时器的实际计数频率是333MHz / (N1)。加载值设置为M那么定时周期就是(M1) / (333MHz / (N1))。比如你想要1ms的周期可以设置预分频器为0加载值为333000 - 1。但加载值最大是32位所以最大周期有限。如果需要更长的周期可以增大预分频器。中断处理函数中首先读取中断状态寄存器确认是定时器中断然后清除中断标志接着更新一个全局计数器最后唤醒等待队列。这里要注意清除中断标志的操作必须做否则中断会一直触发。字符设备的实现中open函数简单地增加模块引用计数release函数减少引用计数。read函数返回当前的中断计数。poll函数在中断计数没有变化时把当前进程加入等待队列并返回0当有中断发生时返回POLLIN | POLLRDNORM。编译内核模块需要一个Makefile指定内核源码路径和交叉编译工具链。对于PYNQ内核源码通常可以在Xilinx的GitHub仓库中找到交叉编译工具链是arm-linux-gnueabihf-。编译命令是make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- -C /path/to/kernel/source M$PWD modules。4.2 设备树覆盖与模块加载在PYNQ上内核模块的加载可以通过insmod命令完成。但如果你的模块需要特定的设备树节点你可能需要先应用一个设备树覆盖Device Tree Overlay。不过对于PS定时器设备树节点已经存在你不需要额外添加。你只需要确保你的模块能够正确获取到中断号。如果模块加载失败常见的原因包括中断号被占用、寄存器映射失败、内核版本不匹配。你可以通过dmesg查看内核日志来定位问题。如果提示“request_irq failed”说明中断号可能已经被其他驱动占用了。这时候你需要换一个定时器或者检查内核配置。模块加载成功后会在/dev目录下创建一个字符设备节点比如/dev/ps_timer。你可以通过cat /proc/interrupts来查看中断是否已经注册以及中断触发的次数。如果中断计数在增加说明定时器已经在工作了。4.3 用户空间程序的编写与测试用户空间程序用C语言编写主要逻辑是打开设备文件然后用poll等待中断事件每次poll返回后读取中断计数并打印时间戳。为了测量中断延迟我用了clock_gettime函数获取高精度时间。程序的核心代码片段如下int fd open(/dev/ps_timer, O_RDWR); struct pollfd pfd; pfd.fd fd; pfd.events POLLIN; while (1) { int ret poll(pfd, 1, -1); if (ret 0) { unsigned long count; read(fd, count, sizeof(count)); clock_gettime(CLOCK_MONOTONIC, ts); printf(Interrupt count: %lu, time: %ld.%09ld\n, count, ts.tv_sec, ts.tv_nsec); } }编译用户空间程序用arm-linux-gnueabihf-gcc然后拷贝到PYNQ上运行。实测下来中断周期设置为1ms时用户空间收到通知的抖动在几十微秒左右这个精度对于大多数控制应用已经足够了。如果你需要更高的精度可以考虑在中断处理函数中直接读取定时器的计数器值然后通过共享内存或者mmap的方式暴露给用户空间。这样可以避免从内核到用户空间的调度延迟。不过这样做会增加内核模块的复杂度需要谨慎处理并发和内存映射。4.4 中断延迟的测量与优化中断延迟是指从中断产生到中断处理函数开始执行的时间。在Linux下这个延迟受到很多因素影响包括中断控制器的响应时间、内核的抢占配置、CPU的负载等。为了测量延迟我可以在中断处理函数中读取定时器的当前计数值然后和加载值比较差值就是延迟对应的计数。优化延迟的方法有几个一是把中断绑定到特定的CPU核减少缓存失效和调度干扰二是提高内核的抢占等级比如配置CONFIG_PREEMPT三是关闭不必要的内核调试选项减少中断处理路径上的开销。在PYNQ上默认的内核配置可能不是实时内核所以延迟在几十微秒到几百微秒之间波动是正常的。如果你需要微秒级的确定性延迟可能需要考虑使用Xenomai或者PREEMPT_RT补丁。但这些方案会显著增加系统复杂度需要根据实际需求权衡。5. 常见问题与排查技巧实录5.1 中断不触发或触发一次就停止这是最常见的问题。中断触发一次就停止通常是因为中断标志没有清除。在PS定时器的中断处理函数中你必须读取中断状态寄存器然后写回相同的值来清除中断标志。如果你忘了这一步中断控制器会认为中断还在处理中不会再次触发。另一个可能的原因是定时器没有使能。在配置控制寄存器时需要设置使能位、中断使能位、自动重载位等。如果自动重载没有使能定时器计数到零后就停止了自然不会再产生中断。还有一种情况是中断被屏蔽了。你可以通过cat /proc/interrupts查看中断是否被禁用。如果某个中断的计数一直是0可能是被屏蔽了。你可以通过写/proc/irq/中断号/unmask来解除屏蔽。5.2 模块加载失败提示资源忙如果insmod时提示“Device or resource busy”说明你试图申请的定时器或者中断号已经被其他驱动占用了。前面提到过私有定时器0通常被内核用作时钟事件源全局定时器可能被用作时钟源。你可以通过查看/sys/kernel/debug/clockevents或者/proc/timer_list来确认哪些定时器被占用了。解决方案是换一个未被占用的定时器。在Zynq上私有定时器1和看门狗定时器通常比较空闲。但看门狗定时器默认是看门狗模式需要先禁用它才能用作普通定时器。你可以在内核配置中禁用看门狗驱动或者在模块中直接操作看门狗的控制寄存器来切换模式。5.3 用户空间poll没有反应如果用户空间程序调用poll后一直阻塞没有收到中断通知可能的原因有几个。一是内核模块中的等待队列没有正确唤醒。你需要在中断处理函数中调用wake_up_interruptible来唤醒等待队列。二是poll函数的返回值不对。当没有数据时poll应该返回0而不是POLLIN。三是设备文件的权限问题用户空间程序可能没有权限打开设备文件。排查时可以先在中断处理函数中加打印确认中断是否真的触发了。如果中断触发了但poll没反应那就是等待队列或者poll实现的问题。如果中断根本没触发那就是定时器配置的问题。5.4 系统变得不稳定或者死机这通常是因为中断处理函数中做了不该做的事情比如睡眠、分配大量内存、调用可能阻塞的函数。中断处理函数必须尽可能短小精悍。如果你需要做耗时操作应该用工作队列workqueue或者tasklet来延后处理。另一个可能的原因是寄存器映射错误导致写入了错误的地址破坏了其他外设的状态。在ioremap时一定要确认物理地址和映射长度正确。写寄存器时要确保只写你需要的位不要影响其他位。最好用read-modify-write的方式操作寄存器。还有一个坑是中断共享问题。如果你申请中断时没有指定IRQF_SHARED但实际中断是共享的会导致注册失败。PS私有定时器中断是独占的不需要共享标志。但如果你用的是其他中断源需要确认是否需要共享。5.5 常见问题速查表问题现象可能原因排查方法解决方案中断只触发一次中断标志未清除检查中断状态寄存器操作读取后写回清除标志模块加载失败资源忙定时器被内核占用查看/proc/interrupts换用空闲定时器poll无响应等待队列未唤醒中断函数加打印正确调用wake_up系统死机中断中睡眠检查中断函数代码改用工作队列中断计数不增加定时器未使能检查控制寄存器设置使能位和自动重载延迟过大CPU负载高查看系统负载绑定中断亲和性5.6 几个容易被忽略的实操心得第一个心得是关于预分频器的设置。很多人为了省事直接把预分频器设为0然后用大的加载值来实现长周期。但加载值是32位的最大约42亿在333MHz下大约12.9秒。如果你需要更长的周期比如1分钟就必须用预分频器。预分频器是8位的最大256所以最大周期可以到12.9秒乘以256大约55分钟。这个范围对于大多数应用足够了。第二个心得是关于中断亲和性的设置。在双核Zynq上默认情况下中断可能被任意一个核处理。如果你把定时器中断绑定到CPU0然后把你的用户空间程序也绑定到CPU0可能会因为CPU0还要处理其他中断而导致延迟增加。更好的做法是把定时器中断绑定到CPU1用户空间程序绑定到CPU0这样两者互不干扰。设置方法是在用户空间写/sys/.../smp_affinity或者在内核模块中用irq_set_affinity_hint。第三个心得是关于时间戳的获取。在用户空间用clock_gettime获取的时间戳实际上是从poll返回到调用clock_gettime之间的时间这个时间包括了从内核唤醒到用户空间调度的时间。如果你需要更精确的中断时间应该在内核中断处理函数中读取定时器的计数器值然后通过read或者mmap传递给用户空间。这样可以消除用户空间调度带来的抖动。第四个心得是关于内核模块的调试。在开发阶段可以在中断处理函数中加printk但要注意printk本身会影响中断延迟而且大量printk可能导致日志缓冲区溢出。更好的方法是用trace_printk或者ftrace它们对性能的影响小得多。另外可以用/proc/interrupts来查看中断触发的次数这是一个很直观的调试手段。6. 从单次实验到工程化一些扩展思路6.1 多定时器协同工作在实际项目中你可能需要多个不同周期的定时器。比如一个1ms的定时器用于控制循环一个10ms的定时器用于数据采集一个100ms的定时器用于状态监测。Zynq的PS端定时器资源有限但你可以用一个硬件定时器作为时基然后在软件中分频出多个软件定时器。具体做法是在中断处理函数中维护多个计数器每个计数器对应一个软件定时器当计数器达到设定值时触发相应的回调。这种方式的优点是节省硬件资源缺点是所有软件定时器的精度都受限于硬件定时器的精度而且中断处理函数会变长。如果软件定时器数量不多这种方案完全可行。如果数量很多可以考虑用内核的hrtimer或者timer_list来管理但那就回到了软件定时器的老路上。6.2 与PL端逻辑的配合PYNQ的一大特色是PS和PL的协同。你可以用PS定时器中断作为时间基准然后在中断处理函数中触发PL端的逻辑比如启动一次ADC采样或者更新PWM占空比。这种PSPL的架构可以充分发挥两者的优势PS负责复杂的控制和通信PL负责高速的时序和并行处理。具体实现上你可以在内核模块中通过ioremap映射PL端的寄存器然后在中断处理函数中写这些寄存器来触发PL逻辑。或者更简单的方式是在用户空间通过PYNQ的MMIO接口来操作PL寄存器但这样会增加延迟。如果对时序要求高还是在内核中操作更合适。6.3 实时性优化的进一步探索如果你对实时性有极致要求可以考虑以下几个方向。一是使用PREEMPT_RT补丁把Linux改造成硬实时系统中断延迟可以降到几十微秒以内。二是使用Xenomai或者RTAI这样的双内核方案在Linux旁边跑一个实时内核定时器中断由实时内核处理。三是把定时器中断的处理完全放到PL端用PL的逻辑产生精确的时序PS只负责配置和读取结果。这几种方案各有优劣。PREEMPT_RT对现有代码改动最小但实时性提升有限。双内核方案实时性最好但复杂度高和PYNQ的Python生态集成困难。PL方案最灵活但需要写Verilog或者VHDL开发门槛高。选择哪种方案取决于你的具体需求和团队的技术栈。6.4 从内核模块到PYNQ Overlay的集成如果你想让PS定时器中断和PYNQ的Overlay配合使用可以把内核模块的加载和Overlay的加载放在同一个启动脚本中。PYNQ提供了Overlay类来加载bitstream你可以在Python中先加载Overlay然后通过subprocess调用insmod加载内核模块最后打开设备文件开始接收中断。这种集成方式的优点是统一了开发流程你可以在Jupyter Notebook中完成从硬件配置到应用逻辑的全部工作。缺点是需要处理好加载顺序和错误处理如果内核模块加载失败Overlay可能已经加载了需要回滚。另外在Notebook中长时间运行中断接收程序可能会阻塞内核建议把中断接收放在单独的线程或者进程中。我个人在实际操作中的体会是PS定时器中断在PYNQ上是一个被低估的功能。很多人觉得PYNQ就是用来跑Python和FPGA的忽略了PS端还有丰富的定时器资源。实际上只要你愿意往下挖一层写一个简单的内核模块就能获得比软件定时器稳定得多的定时基准。这个投入产出比是很高的。当然内核模块的开发和调试确实有门槛但一旦跑通后面就是复制粘贴的事情了。希望这篇内容能帮你少走一些弯路把PS定时器中断真正用起来。
阅读完成 · 觉得有帮助?
咨询建站