I²C中断与TC3定时器状态机同步:一个真实项目里的毫秒级确定性是如何炼成的
去年冬天调试一款工业音频网关时,我连续三天没睡好——设备在-25℃低温下运行两小时后,DAC输出突然出现周期性“咔哒”声。示波器抓到SCLK边沿抖动从±12 ns飙升到±800 ns,I²C写入音量寄存器后,TC3生成的采样时钟竟出现了半周期跳变。客户邮件标题写着:“请解释为什么你们的‘高精度同步’听起来像老式收音机调频失真。”
这不是理论题,是焊在PCB上的现实。而最终解开这个结的钥匙,藏在SAM D21手册第23章第4节不起眼的一行小字里:“CCx registers are double-buffered and updated on OVF.” —— 以及紧随其后的注释:“Write access is atomic only when the timer is running.”
这句话,值得我们拆开揉碎讲清楚。
为什么I²C一碰TC3就容易出事?
先说个反直觉的事实:I²C本身并不慢,真正拖后腿的是你对它的信任方式。
在SAM D21上,I²C跑400 kbps时,一个字节传输耗时约20 μs;但如果你在ISR里干了这几件事:
- 检查INTFLAG.MB(Master Byte Sent)
- 从RX缓冲区搬4个字节到临时变量
- 查表换算成TC3重载值
- 直接写TC3->COUNT16.CC[0].reg
- 更新全局状态标志
……那恭喜你,已经亲手埋下了竞态雷区。
问题不在代码逻辑,而在时间维度的错位:
| 时间轴 | 事件 |
|---|---|
| t₀ | I²C完成第3字节传输,触发MB中断 |
| t₀+1.2μs | ISR开始执行,读取INTFLAG并解析新周期值0x1A2B |
| t₀+2.1μs | CPU执行TC3->CC[0].reg = 0x1A2B |
| t₀+2.3μs | TC3恰好在此刻发生OVF→ 硬件把0x1A2B加载进计数器 |
| t₀+2.5μs | ISR退出,但此时TC3已进入新周期,而你的状态机还卡在“RELOAD_PENDING” |
如果这时TC3自己的OVF中断(优先级更高)也来了,它会去读tc3_state——而那个变量正被I²C ISR半途修改。两个ISR同时伸手去拿同一个状态变量,就像两个人同时拧一个水龙头:拧得快的赢,拧得慢的看到的可能是撕裂一半的状态。
这就是为什么文档里反复强调“双缓冲只在timer running时生效”——如果TC3当时刚好停在OVF之后、还没开始新周期,你写的CC[0]会被硬件丢弃;如果它正在计数中途,写入会暂存缓冲区,但下次OVF是否真的发生?谁说了算?
答案是:你得确保TC3永远处于可预测的运行相位里,且I²C的干预只发生在安全窗口。
TC3不是“设个数就完事”的定时器
很多人把TC3当成增强版SysTick:配置预分频、写CC0、启动,完活。但这么用,等于把法拉利当买菜车开。
TC3真正的杀手锏,在于它把“时间”这件事拆成了三个物理层:
第一层:硬件计数引擎(不可见但绝对可靠)
- 计数器本身是纯硬件逻辑,不受CPU干扰;
CC[0]不是目标值,而是下一个OVF时刻的倒计时终点;- 当前计数值存在
COUNT寄存器里,但它只是“此刻快照”,不是控制源。
第二层:双缓冲影子系统(关键!)
// 这行代码不改变当前计数,只改“下一次溢出的目标” TC3->COUNT16.CC[0].reg = 0x1234;这句执行后,TC3内部其实做了三件事:
1. 把0x1234存进影子缓冲区;
2. 等待当前计数自然走到0xFFFF(或你设的旧CC值);
3. 在OVF信号产生的同一时钟沿,把影子值原子拷贝到活动计数器起点。
所以你永远看不到“计数器跑到一半突然变短”的毛刺——因为变更只发生在OVF边沿,而OVF本身就是计数器归零的定义点。
第三层:事件驱动联动(绕过CPU的捷径)
这才是让同步真正落地的核心。
别再让TC3 OVF触发中断、再在ISR里去翻GPIO寄存器了。直接走EVENT系统:
// 配置TC3 OVF事件输出到EVENT CHANNEL 0 REG_PM_APBCMASK |= PM_APBCMASK_EVSYS; // 使能EVENT外设时钟 REG_EVSYS_CHANNEL[0] = EVSYS_CHANNEL_PATH_ASYNCHRONOUS | EVSYS_CHANNEL_EVGEN(EVSYS_ID_GEN_TC3_OVF); // 将EVENT 0连接到DAC的SYNC引脚(硬件直连) REG_EVSYS_USER[EVSYS_ID_USER_DAC_SYNC] = EVSYS_USER_CHANNEL(0);从此,TC3每次OVF,信号以固定3个GCLK周期延迟(≈62.5 ns @ 48 MHz)直达DAC,全程不经过CPU、不进中断、不占栈空间。你甚至可以在I²C ISR里安心喝杯咖啡,DAC的采样边沿依然纹丝不动。
状态机不是流程图,是时间契约
我们常把状态机画成圆圈加箭头,但嵌入式里的状态机本质是一份时间契约:每个状态都承诺“在此期间,某些操作是安全的,另一些是禁止的”。
以TC3_STATE_RELOAD_PENDING为例,它的隐含契约是:
“我已收到新周期请求,但尚未生效;在此状态下,任何外部模块(包括TC3自身OVF中断)不得读取/修改
CC[0],也不得触发依赖新周期的行为。”
实现这份契约,不能靠if (state == PENDING) return;这种软锁——那是纸糊的门。
必须用硬件级原子操作:
bool tc3_state_machine_lock(void) { uint32_t expected = TC3_STATE_IDLE; // 尝试将state从IDLE→RELOAD_PENDING if (__atomic_compare_exchange_n( &tc3_state, &expected, TC3_STATE_RELOAD_PENDING, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE)) { return true; } // 如果当前是RUNNING,也允许升级为PENDING(表示‘正在运行中要改参数’) if (expected == TC3_STATE_RUNNING) { expected = TC3_STATE_RUNNING; return __atomic_compare_exchange_n( &tc3_state, &expected, TC3_STATE_RELOAD_PENDING, false, __ATOMIC_ACQ_REL, __ATOMIC_ACQUIRE); } return false; }注意这里没用__disable_irq()。为什么?
因为关中断会阻塞所有其他外设响应,而你的系统里可能还有ADC在等DMA、UART在发调试日志、看门狗在倒计时……一个几微秒的临界区,可能让整个实时性崩塌。
CAS(Compare-and-Swap)指令才是真正的时间锁:它不阻止别人干活,只保证“我和你不能同时改同一个地址”。就像银行柜台——不拦你排队,但绝不让你俩同时往同一个账户里存钱。
真实调试笔记:那个救了项目的内存屏障
回到开头的“咔哒”声。最终定位到一行被优化掉的__DMB():
// 错误写法(编译器可能重排) SERCOM3->I2CM.INTFLAG.reg = SERCOM_I2CM_INTFLAG_MB; // 清MB标志 uint8_t data = i2c_rx_buffer[3]; // 读数据 tc3_set_reload_value(data << 8); // 写TC3 // 正确写法(强制顺序) SERCOM3->I2CM.INTFLAG.reg = SERCOM_I2CM_INTFLAG_MB; __DMB(); // 数据内存屏障:确保上面的写操作完成后再往下走 uint8_t data = i2c_rx_buffer[3]; tc3_set_reload_value(data << 8);没有__DMB()时,GCC在-O2下会把i2c_rx_buffer[3]的读取提前到清标志之前——而此时I²C硬件可能还没把最后字节搬进RX缓冲区!结果你读到的是上一次残留值,TC3被喂了错误周期,SCLK立刻失锁。
这不是玄学,是ARMv6-M架构白纸黑字的规定:
“The DMB instruction ensures the completion of data memory accesses before subsequent instructions are executed.”
它不解决“什么时候读”,只保证“按你写的顺序读”。而这个顺序,就是同步的生命线。
中断优先级不是数字游戏,是时间主权划分
NVIC优先级配置表里写着:
- TC3 OVF:Priority 1
- I²C:Priority 2
- ADC DMA:Priority 3
看起来很合理?但有个陷阱:Priority 1和Priority 2之间,实际抢占延迟可能高达4个指令周期——如果Priority 1的ISR正在执行一条多周期指令(比如LDMIA批量加载),Priority 2的I²C中断就得等它吐完最后一口。
所以真正的设计原则是:
✅TC3 OVF必须拥有最高抢占权——因为它定义了整个系统的时序原点;
✅I²C中断必须能被TC3打断,但不能打断TC3——否则你在改周期时,TC3突然来个OVF,就乱套了;
❌绝不让任何外设中断和I²C同级——同级中断按轮询顺序响应,时间不可控。
更进一步,我们在项目里加了条铁律:
所有可能修改TC3状态的操作,必须包裹在
__disable_irq()+__enable_irq()之内,且总时长严格≤1.5 μs。
为什么敢这么激进?因为实测发现:在48 MHz主频下,1.5 μs = 72个时钟周期,足够完成:
- 读I²C缓冲区(3字节)→ 24周期
- 查表换算(LUT索引)→ 8周期
- 写CC[0]寄存器 → 4周期
- 原子更新状态变量 → 12周期
- 清中断标志 → 4周期
-__DSB()内存屏障 → 4周期
- 其余冗余 → 16周期
留足20%余量,确保-40℃低温下也能稳住。
最后一点实在建议
如果你正面对类似问题,别急着抄代码。先做三件事:
- 用逻辑分析仪抓I²C STOP和TC3 OVF信号,看它们的时间关系。如果STOP边沿到OVF边沿的抖动超过50 ns,说明同步链路已有隐患;
- 在TC3 OVF ISR第一行插入GPIO翻转,用示波器测从中断触发到GPIO变化的延迟。如果>1.2 μs,检查是否有高优先级中断长期霸占CPU;
- 把
tc3_state变量声明为volatile _Atomic uint32_t(C11标准),而不是volatile uint32_t——前者告诉编译器“这个变量可能被并发修改”,后者只是禁用缓存优化。
真正的鲁棒性,从来不是堆砌技术术语堆出来的。它是凌晨三点盯着示波器波形时,突然意识到“哦,原来手册里那句‘updated on OVF’意味着我必须让TC3永远在运行中等待指令”,然后删掉20行看似聪明的条件判断,换上一行__atomic_store_n(&tc3_state, ...)后的豁然开朗。
如果你也在啃类似的硬骨头,欢迎在评论区甩出你的波形截图或寄存器dump——有时候,解决问题的钥匙,就藏在另一个人昨天踩过的坑里。