当前位置: 首页 > 科技观察

x86Linux下实现10us误差的高精度延时

时间:2023-03-14 08:49:30 科技观察

Linux下实现高精度延时,网上查到的大部分方法只能达到50us左右的延时精度。今天就来看看嘉友创信息科技的董文辉是如何解决这个问题,将延时精度提升到10us的。问题描述最近在开发一个项目,需要用到高精度的延时机制。设计要求是1000us周期下误差不能超过1%(10us)。由于该项目的硬件方案采用了Intel的x86处理器,所以熟悉Linux硬件的人都知道这是很难实现的。当时评测计划有点草率,直接采用“PREEMPT_RT补丁+内核hrtimer+信号通知”的方式进行评测。当时验证结果也很满意,于是兴奋地跟领导说这个方案可行,结果挖了个大坑……等到实际项目开始的时候,发现这个方案根本不可行,原因有二:进程被通知,目前的移植方案不能保证被通知的进程中没有其他线程。发送这么高频的信号,其他线程基本都会被kill掉。(补充说明:这里特指从内核驱动到应用层的通知,在用户层有专门的函数可以通知不同的线程。经过研究可以通过设置线程的sigmask来解决这个问题,但还是改不了解决方案不行的结论)这也是主要原因。虽然项目中使用的Ethercat的同步周期可以在程序开始时固定,但实际运行周期需要动态调整,调整范围在5us以内。这样,动态调整hrtimer的开销就不能忽略了。也就是说,我们需要的是延时机制,而不是定时器。所以这个方案被否决了。解决方案由于信号法不好,只能通过其他方式分析。总结一下,我大致做了以下尝试:1、确定sleep方案,我尝试了usleep、nanosleep、clock_nanosleep、cond_timedwait、select等,最后决定使用clock_nanosleep。之所以选择它,并不是因为它支持ns级精度。因为经过测试发现,在周期小于10000us的情况下,上述调用的准确率几乎一致,而错误主要来自于上下文切换的开销。选择它的主要原因是因为它支持TIME_ABSTIME选项,支持绝对时间。这里有一个简单的例子来解释为什么使用绝对时间:while(1){do_work();睡觉(1);do_post();}假设上面的循环,我们的目的是让do_post的执行以1s为周期执行一次,但实际上不能绝对1s,因为sleep()只能延迟相对时间,而当前循环的实际循环是do_work+sleep(1)时间的开销。所以这种开销在我们需要的场景中变得不可忽视。使用clock_nanosleep的好处是,一方面可以选择时钟源,其次支持绝对时间唤醒,这样我在每次do_work之前设置clock_nanosleep下次唤醒的绝对时间,那么实际clock_nanosleep的执行时间实际上会减去do_work的开销,相当于一个闹钟的概念。2、切换为实时线程将重要任务的线程切换为实时线程,调度策略改为FIFO,优先级设置为最高,减少被抢占的可能性。3.设置线程亲和性,将应用下的所有线程进行规划,根据负载情况,将若干个重负载任务线程绑定到不同的CPU核上,减少切换CPU带来的开销。4、减少不必要的sleepcalls由于很多任务都有sleepcalls,我用strace命令分析了整个系统sleepcalls的比例高达98%。忽略。所以我将主循环中的sleep改成了循环等待信号量的方式,因为pthread库中等待信号量使用的是futex,这样唤醒线程的开销就小了很多。其他地方的睡眠也尽量优化。这个效果其实更明显,几乎可以减少20us的误差。5.诀窍是从现有应用程序中剥离最小的任务并减少所有外部任务的影响。经过以上五点,1000us的误差已经从一开始的±100us控制到了±40us。但这还远远不够……我开始了漫长的寻找和研究……在这期间,我也发现了一些奇怪的现象,比如下图。该图是用Python分析抓包工具的数据生成的,参考性毋庸置疑。纵轴表示在这个周期中实际花费的时间。可以发现一个很有趣的现象:每隔一定时期,就会出现一个大范围的误差。抖动误差不是正态分布,而是频繁出现在±30us左右的地方。每出现一次较大的误差,在下一个周期肯定会出现一个反向误差,而且幅度大致相同(这个从图中看不出来,是通过其他方式分析的)。简单描述就是假设本次循环的执行时间为980us,那么下一次循环的执行时间一定在1020us左右。通过以上4种优化措施可以消除1点和2点。第3点还没有找到非常有效的手段,我的理解可能是内核意识到了这个错误,有意弥补。如果知道背后原理的大神欢迎分享。对于这第三种奇怪的现象,我也尝试过做人工干预,比如设置一个阈值。当实际程序执行误差大于这个阈值时,我会在设置下一个周期的唤醒时间时手动减去这个误差,但是运行效果惊人,更差...刘安华明尝试了200多个参数调整,被这个问题卡了一个多星期后,无意中找到了一份戴尔技术文档《Controlling Processor C-State Usage in Linux》,受到了启发这篇文章的启发,终于解决了这个问题。然后经过有针对性的搜索,终于搞清楚了来龙去脉:Intel的CPU为了节能,有很多功耗模式,简称C-state。<如果显示不完整,请左右滑动>模式名称作用CPUC0运行状态CPU完全打开所有CPUC1stop通过软件停止CPU内部主时钟;总线接口单元和APIC仍保持全速运行486DX4及以上C1E增强停止软件CPU内部主时钟,降低CPU电压;总线接口单元和APIC仍然保持全速运行所有插槽775CPUC1E-停止所有CPU内部时钟Turion64、65-nmAthlonX2和PhenomCPUC2StopGrantCPU内部主时钟被硬件停止;总线接口单元和APIC仍在全速运行486DX4及更高版本C2停止时钟CPU内部和外部时钟由硬件停止486DX4,仅Pentium,PentiumMMX,K5,K6,K6-2,K6-IIIC2E扩展停止授权停止CPU内部硬件主时钟并降低CPU电压;总线接口单元和APIC保持全速运行Core2Duo和更高版本(仅限Intel)C3睡眠停止所有CPU内部时钟PentiumII和Athlon支持,但Core2DuoE4000和E6000不支持C3深度睡眠停止所有CPU内部和外部时钟PentiumII支持,但Core2DuoE4000、E6000和Turion64不支持C3AltVID停止所有CPU内部时钟并降低CPU电压AMDTurion64C4深度睡眠降低CPU电压PentiumM支持及更高版本,但不支持Core2DuoE4000、E6000和Turion64C4E/C5增强的深度睡眠显着降低CPU电压并关闭内存缓存CoreSolo、CoreDuo和45nm移动Core2Duo支持C6DeepPowerShutdown将CPU内部电压降低到任何值,包括0VOnly45-nmmobileCore2DuosupportDiagramfromDELL当程序运行时,CPU处于C0状态,但一旦操作系统进入睡眠状态,CPU将处于C0状态使用Halt命令切换到C1或C1E模式。如果操作系统在这种模式下唤醒,会增加上下文切换的开销!这个选项可以在BIOS中关闭,但是坑在比较新的Linux内核版本,默认是开启的,忽略了BIOS设置!这太可怜了!有针对性的搜索了一下,发现网上也有网友的测试。2.6版本的内核默认不会开启这个功能,但是3.2版本的内核会开启。并且对比测试发现这两个版本内核的上下文切换开销是同一个硬件。差异可以是10倍。前者为4us,后者为40-60us。=0processor.max_cstate=0idle=poll然后使用update-grub命令使参数生效,重启即可。2.动态修改可以通过向/dev/cpu_dma_latency文件写入值来调整C1/C1E模式下上下文切换的开销。我选择写0直接关闭。当然你也可以选择写一个值,代表上下文切换的开销,单位是us。比如写1,那么开销就设置为1us。当然,这个值是有范围的。这个范围可以在/sys/devices/system/cpu/cpuX/cpuidle/stateY/latency文件中找到。X代表具体的core,Y代表对应的idle_state。至此,这个性能问题已经完美解决,目前稳定性测试的性能如下图所示:实现了x86Linux下1000us的高精度延时和10us的精准延时。