1. 这次 "(again)" 的背景:一块开发板上的 PPS 输出为什么反复翻车
先说结论:在 stm32mp157d-dk1 上输出一路干净的 PPS(Pulse Per Second,秒脉冲)信号,本身不算什么高难度动作,难的是你"以为做对了"和"实际真的对了"之间,隔着好几条不容易发现的沟。标题里那个 (again) 是我自己加的——这块板子的 PPS OUT 信号我断断续续折腾了好几轮,每次换一个思路,总有新的坑在前面等着。这次终于把从硬件选型、设备树、内核配置到 sysfs 操作和示波器验证的整条链路全捋顺了,所以把完整过程记录下来,给同样在这块板子上做授时、同步、PWM 输出的朋友一个可直接抄作业的参考。
PPS 信号说白了就是一个每秒出现一次、脉宽相对固定的脉冲,它的上升沿代表整秒时刻,是各种授时与同步应用的基本节拍。GPS 接收机、GNSS 驯服钟、PTP 网络同步、高速数据采集系统里都很常见。我这次要做的事情,是让 stm32mp157d-dk1 像一颗 GPS 模块那样,从某个 GPIO 上稳定输出这个 1Hz 脉冲,供后端的采集设备和对端仪器做时间对齐。
为什么选这颗芯片?STM32MP157D 是 ST 的异构 MPU,双核 Cortex-A7 跑 Linux,Cortex-M4 做实时控制,内部有大量通用定时器和高级定时器,理论上生成 1Hz 硬件方波并不费劲。但"理论上不费劲"和"实际调通"之间,隔着设备树、pinctrl、时钟树、内核配置、PWM 子系统等一长串环节,任何一个环节理解不到位,示波器上就是一片死寂。
这篇文章主要写给三类人:一是想在自己的 STM32MP1 板子上输出 PPS 或精确 PWM 的嵌入式工程师;二是做授时同步相关项目、需要搞清楚 PPS 生成精度的朋友;三是刚开始接触 STM32MP157、想知道 Linux 下 PWM 到底怎么用的入门者。我会把这一轮完整的排查链路和每一步的具体配置都写出来,包括我踩过的每一个坑和对应的排错方法,尽量让后来者少走弯路。
2. 硬件底牌与选型:哪些定时器、引脚能扛住 PPS 输出
2.1 定时器资源盘点:32 位与 16 位的差距对 PPS 是致命的
在 STM32MP157D 上,能直接通过硬件输出方波的资源主要是通用定时器 TIM2~TIM5、高级定时器 TIM1/TIM8,以及部分低功耗定时器 LPTIM。它们之间最关键的差异是计数器位宽:
| 定时器 | 计数器位宽 | 备注 |
|---|---|---|
| TIM1 / TIM8 | 16 位 | 高级定时器,有互补输出 |
| TIM2 / TIM5 | 32 位 | 通用定时器,最适合长周期 PWM |
| TIM3 / TIM4 | 16 位 | 通用定时器,常用于电机、背光 |
| LPTIM1~LPTIM4 | 16 位 | 低功耗定时器,时钟源灵活 |
位宽决定了 Linux 的 PWM 驱动能否用"不分频"的方式去表达 1 秒周期。假设定时器输入时钟是 200MHz,1 秒就是 200,000,000 个计数;16 位计数器最多数到 65,535,必须配合预分频器。而预分频一旦介入,PWM 输出的"计数分辨率"就会从单个时钟周期变成 PSC+1 个时钟周期,边沿会出现量化误差。
为什么这个误差对 PPS 很重要?因为 PPS 的上升沿是要拿去对齐整秒时刻的。如果量化误差是几百纳秒甚至几微秒,在普通 PWM 场景里无所谓,但在授时同步场景里就直接废了。TIM2 或 TIM5 是 32 位计数器,可以做到 PSC=0 直接计数,200MHz 下理论边沿精度就是 5ns,这个指标对绝大多数 GNSS 驯服钟、PTP 边界时钟的场合都绰绰有余。所以我第一轮就把目标锁定在 TIM2 和 TIM5 上。
2.2 引脚选择:先看原理图,再谈 AF 映射
选完定时器,下一步是引脚。这里必须强调:动手之前先打开这块板子的原理图和 STM32MP157D 数据手册的 AF(Alternate Function)映射表,不要凭经验猜。
以 TIM2 为例,常见的输出通道引脚有:
- PA0 —— TIM2_CH1(AF1)
- PA1 —— TIM2_CH2(AF1)
- PA2 —— TIM2_CH3(AF1)
- PA3 —— TIM2_CH4(AF1)
- PA5、PA15 在某些封装下也有 TIM2 通道复用
但我查了 DK1 的原理图之后发现,PA0 在这块板子上被用户按键占用。如果硬把它配成 TIM2_CH1,轻则按键失效,严重的情况下按键电平反向灌入定时器输出引脚,可能引起配置混乱。最后我选了 PA1 作为 TIM2_CH2 输出——PA1 在 DK1 上被引到了扩展排针,并且没有和板上任何外设冲突,是一个相对干净的选择。
选引脚时要同时确认三件事,缺一不可:
- 这个引脚在板级原理图上有没有被其它外设占用,比如 LED、按键、以太网 RMI、USB、音频 codec、SD 卡等;
- 该引脚对应的 AF 号是否真是你要的定时器通道,也就是查数据手册的 AF mapping 表;
- 该引脚所在 GPIO bank 的时钟域是否使能、有没有外部上下拉电阻影响电平。
这些信息在板级 DTS 里基本都能查到。比如 DK1 的 DTS 中,某个引脚如果已经被其它节点分配,你又在其它的 pinctrl 节点里复用它,内核启动时 pinmux 子系统通常会给警告。但更隐蔽的情况是引脚被 bootloader(U-Boot)先行配置过,Linux 启动后又重新编程,两边有冲突时外设行为会很诡异,这种问题最难查。
2.3 从 PWM 到 PPS:先搞懂两者的区别再动手
大多数人第一次碰到 PPS 会想:不就是产生一个 1Hz 的方波吗?对,也不对。PPS 和普通 1Hz 方波的关键区别在于对边沿时刻精度的要求。
普通 PWM 你可能只关心周期和占空比的大致范围,几百微秒的抖动无所谓。但 PPS 拿上升沿去对齐整秒,如果上升沿抖动达到几十微秒,后端的高精度时间同步系统直接就崩了。所以在选实现方式的时候,第一选择必须是硬件定时器,而不是软件翻转 GPIO。这也是我后续方案选型的核心逻辑:宁可多花时间折腾设备树,也要用硬件定时器去扛这个边沿精度。
3. 第一版方案复盘:软件翻转 GPIO 的抖动到底从哪来
3.1 我最初的想法:用实时线程反复翻转 GPIO
既然是双核 A7 跑 Linux,我一开始的直觉是:写一个内核线程或者用sched_setscheduler把它提到 SCHED_FIFO,然后在一个循环里gpiod_set_value()翻转引脚。这个思路其实代表了很多人面对"生成一个 1Hz 信号"时的第一反应,因为它最快,不需要改设备树,不需要碰内核配置。
代码逻辑很简单:线程里先睡到一个整秒边界,然后拉高 GPIO,延时 100ms,再拉低,接着睡到下一个整秒边界。为了对齐边界,我在代码里用了clock_gettime(CLOCK_REALTIME)读取当前时间,计算到下一个整秒的差值。理论上,如果nanosleep足够准、调度足够及时,输出应该是一个每秒一次、脉宽 100ms 的脉冲。
实测结果让人崩溃。示波器上看到的脉冲上升沿相对于整秒参考点,抖动范围在 ±300µs 到 ±2ms 之间,而且抖动分布一点都不规律。有时候连续几十个脉冲看起来还行,突然一个脉冲就晚了接近 2ms,没有任何征兆。
3.2 抖动的根源到底是什么
排查后我得出三条结论,这三条结论对任何想在 Linux 上做精确时序的人都有参考价值:
第一,nanosleep的精度取决于内核的高精度定时器(hrtimer)配置。如果内核没有开启高精度定时器支持,nanosleep的唤醒精度可能只有 1ms 到 10ms,这直接决定了软件方案的抖动下限。
第二,SCHED_FIFO 虽然能抢占普通进程,但抢占本身要等当前 CPU 上的中断处理完。网络、存储、USB 的中断随时可能来,任何一个中断处理耗掉几十到几百微秒,你的 PPS 线程就只能等着。
第三,也是最根本的:gpiod_set_value()的调用路径太长了。它要经过 GPIO 子系统的 sysfs 或 GPIOLIB 抽象层,一层层调用到最后才是寄存器写操作。这个路径上可能有锁竞争、可能有缓存未命中,每次调用的实际耗时并不确定。在几十纳秒级别就能完成的寄存器写操作,被软件路径放大到了微秒级别。
3.3 有没有救?hrtimer + busy loop 的极限试验
我不死心,又试了一个更极端的版本:用 hrtimer 把唤醒精度提到亚毫秒级,然后在用户态用clock_gettime(CLOCK_MONOTONIC)做自旋等待,等时间到达精确的整秒点就直接往 GPIO 寄存器写。这个方案理论上能把抖动压到几微秒以内。
实测确实比纯 sleep 方案好一些,抖动降到了大概 ±10µs 到 ±50µs。但还是不满足我的要求——后端设备对 PPS 边沿的容差是 ±1µs。而且用户态直接操作物理地址需要devmem或者写内核模块,这本身就不像一个"正经"的方案。到这里我彻底想明白了:只要边沿由软件指令触发,抖动就不可避免。必须让定时器硬件自己去翻转引脚,让边沿由硬件计数器的比较匹配事件产生。
4. 正确的打开方式:设备树 + Linux PWM 子系统的完整配置链路
4.1 先检查内核配置,别急着改设备树
在动手改设备树之前,先确认内核里 PWM 相关的支持有没有编进去。ST 官方 OpenSTLinux 镜像一般默认是带CONFIG_PWM和CONFIG_PWM_STM32的,但如果你自己裁剪过内核,这几个配置项很容易被漏掉。
检查方法很简单:
zcat /proc/config.gz | grep PWM # 或者如果内核没有导出 config.gz grep PWM /boot/config-$(uname -r)需要看到这几个关键的配置:
CONFIG_PWM=y CONFIG_PWM_STM32=y如果没有,就需要重新配置内核,进入Device Drivers -> Pulse-Width Modulation (PWM) Support,把 STM32 timer PWM 支持选上,重新编译烧录。这一步是后面所有操作的前置条件,很多人折腾半天没有任何输出,最后发现内核根本没编 PWM 驱动,白白浪费一下午。
4.2 设备树修改:让 TIM2 以 PWM 模式接管 PA1
内核配置没问题之后,重点就是设备树。在 STM32MP157 的设备树里,定时器资源默认定义在stm32mp151.dtsi中,每个定时器下面挂着 PWM、trigger、encoder 等子节点。我们需要做三件事:把 TIM2 节点使能、把 TIM2 下的 PWM 子节点使能、给 PWM 指定正确的 pinctrl。
在板级 DTS(stm32mp157d-dk1.dts)里加入下面这一段:
&timers2 { status = "okay"; /* 防止 DMA 通道被其它驱动占用,PWM 模式用不到 DMA */ /delete-property/ dmas; /delete-property/ dma-names; pwm2: pwm { pinctrl-0 = <&tim2_pwm_pins>; pinctrl-names = "default"; status = "okay"; }; };然后在&pinctrl节点里定义tim2_pwm_pins:
&pinctrl { tim2_pwm_pins: tim2-pwm-pins { pins { pinmux = <STM32_PINMUX('A', 1, AF1)>; /* TIM2_CH2 */ slew-rate = <0>; }; }; };这里STM32_PINMUX('A', 1, AF1)的意思是把 PA1 复用为 TIM2_CH2。AF1 这个编号必须和数据手册里的 AF mapping 一致。如果你用的是别的引脚或别的定时器通道,这一行的端口号、引脚号、AF 号都要跟着改。
关于/delete-property/ dmas和/delete-property/ dma-names:这是一条容易被忽略但很重要的配置。STM32MP1 的某些定时器在默认 dtsi 里是带 DMA 通道描述的,而在 PWM 模式下我们不需要 DMA。如果留着 DMA 属性,某些内核版本下 PWM 驱动初始化时可能会因为 DMA 通道申请失败而整体加载失败,导致 PWM 子系统里根本看不到这个 timer。所以保险起见,先删掉。
改完设备树之后重新编译并烧录,或者如果你用的是 U-Boot 的 FIT image 流程,也可以把 DTB 一并更新。重启后检查 PWM 子系统是否识别到了新的 PWM 芯片:
ls -l /sys/class/pwm/ cat /sys/kernel/debug/pwm如果设备树配置正确,/sys/class/pwm/下应该多出一个或多个pwmchipN,/sys/kernel/debug/pwm里能看到 TIM2 对应的 PWM 芯片已经在列表里。
4.3 通过 sysfs 把 PWM 配成 1Hz PPS
一旦确认 PWM 芯片存在,接下来的操作就简单了——直接在 sysfs 里配置周期和占空比。PPS 脉冲的常见规格是上升沿对齐整秒、脉宽 100ms,对应到 PWM 参数就是周期 1 秒(1,000,000,000 ns)、高电平时间 100ms(100,000,000 ns)。
# 假设 TIM2 对应 pwmchip0,导出通道 0(对应 TIM2_CH1)或通道 1(对应 TIM2_CH2) echo 1 > /sys/class/pwm/pwmchip0/export # 设置周期为 1 秒(纳秒) echo 1000000000 > /sys/class/pwm/pwmchip0/pwm1/period # 设置高电平时间为 100ms echo 100000000 > /sys/class/pwm/pwmchip0/pwm1/duty_cycle # 使能输出 echo 1 > /sys/class/pwm/pwmchip0/pwm1/enable如果一切顺利,拿示波器探 PA1(或者你选的引脚),应该能看到一个完整的 1Hz 方波,高电平 100ms、低电平 900ms。用示波器自带的频率计和脉宽测量功能验证一下,周期误差在示波器分辨率范围内几乎为零。
这里要特别解释一下数据是怎么算出来的:period和duty_cycle的单位都是纳秒。1 秒是 10^9 纳秒,100ms 是 10^8 纳秒。内核 PWM 驱动在收到这些数值后,会结合定时器实际时钟频率自动计算预分频值和计数比较值。对 TIM2 这种 32 位计数器,1 秒周期根本不需要预分频,计数精度直接就拉满。
4.4 为什么说这是"正确"的方式
对比一下软件翻转 GPIO 和硬件 PWM 的本质差异:软件方案里,边沿出现的时刻取决于 CPU 何时执行到那条翻转指令,这中间有调度延迟、中断抢占、缓存失效等一系列不确定因素;硬件 PWM 方案里,边沿由定时器计数器的比较匹配事件触发,计数器硬件自动翻转引脚,和 CPU 执行什么代码、系统负载有多高完全无关。
用 200MHz 定时器时钟计算,计数器每 5ns 跳动一次,比较匹配的精度就是这一个时钟周期,