光模块固件做到中后期,最折磨人的往往不是 I2C 时序,也不是那些位域定义本身,而是"我不知道主机侧到底会怎么读我"。规范写得再细,它只定义寄存器语义,不会告诉你某个网络操作系统会在什么时机、按什么顺序、以多快的节奏去碰这些寄存器。我最早做 CMIS 模块固件时就在这上面栽过:单板自测全绿,插到真机上偶发不 Ready、偶发掉链路,抓了半天波形才发现是主机侧的状态机时序和我理解的顺序不一样。后来我把 SONiC 的 host 侧代码整块读了一遍,把 xcvrd、sonic_xcvr、sfputil 三部分拼起来看,才算第一次把"主机视角"补全。这篇就把这条参考实现路径完整写一遍:SONiC 配合 CMIS 规范,对光模块固件工程师来说到底能当什么用,哪些代码值得逐行读,哪些命令行输出可以直接当成你模块的验收标准,以及脱离交换机怎么把上位机环境搭起来。
1. 把 SONiC 当成一台免费的 CMIS 协议分析仪
1.1 固件工程师真正缺的是 host 侧视角,不是规范副本
做模块固件的人手边从来不缺规范。CMIS 那份文档从低页 0x00 到各种上页,连同各版本的差异表都写得明明白白,问题在于规范是"我该提供什么",而不是"对方会怎么用"。这两个视角差距很大:规范说你必须在收到 LowPwrRequestSW 清除后开始上电,但没说主机清除这个位之后多久会来读你的 ModuleReady;规范说你的状态位要在一定时间内翻转,但没说主机会用多长的超时窗口去轮询;规范说 CDB 命令是分块交互的,但没说主机在下载期间还会不会顺手来读 DOM。
SONiC 的价值恰恰在这里。它是一套可读、可改、可断点的完整 host 侧实现:上电时序、页切换、轮询周期、超时保护、异常重试全都写在 Python 和 C++ 里,而且它面向的是几十家不同厂商的模块,代码里到处是从真实踩坑中长出来的兼容逻辑。对固件工程师来说,这比再看一遍规范有用得多——你等于是拿到了一份"甲方视角的操作手册",还附带了实现细节。
我个人的使用方式是:左边开规范对应章节,右边开sonic-platform-common仓库,逐字段对照。凡是代码里对某个字段做了额外判断、加了等待或者加了奇怪的兜底,那里往往就是你将来会接到现场问题电话的地方。
1.2 一栈四层:从 GPIO 电平到数据库字段的完整链路
要把这套东西用起来,先得把层次关系搞清楚。SONiC 处理光模块大致分四层,从下到上依次是:
- 物理控制层:
LPMode、ResetL、ModPrsL、IntL四条信号线,加上 I2C 的 SCL/SDA。这一层通常由平台驱动暴露成文件节点,比如 GPIO 的 sysfs 节点或者一个/sys/bus/i2c/...路径。 - 寄存器访问层:向 I2C 从地址发起读写,处理页选择字节、bank 选择、分块读取。这部分被
sonic_xcvr封装成了内存映射和字段对象。 - 业务逻辑层:
xcvrd这个守护进程周期性地读模块、判断状态、把结果写进数据库,同时接收上层的控制请求去改 LPMode 或者触发复位。 - 数据与接口层:
STATE_DB里的TRANSCEIVER_*表,以及show interfaces transceiver、sfputil这些命令行。
这四层里有三层的代码都是公开可读的,唯一和你强相关、需要自己实现的就是最底下那一层——模块内部的固件。也正因为如此,你把上面三层摸清楚之后,"主机要什么"这个问题基本就没有盲区了。
一个很容易被忽略的点:xcvrd对模块的访问是并发存在的。DOM 轮询、状态检查、上层 CLI 触发的操作可能在同一时刻打到同一个 I2C 总线上。如果你的固件在某个状态下的读操作会拉长响应时间,或者需要"安静期"才能正确返回数据,那就要在模块能力位里提前声明清楚,而不是等现场出问题。
1.3 先确认模块在主机眼里到底长什么样
动手读代码之前,先做一次"体检"。在一台装了 SONiC 的交换机上(虚拟环境也行,只要有真实 I2C 通路),按顺序跑这几条命令,能拿到模块在主机侧的完整画像:
# 1. 模块是否被识别到 show interfaces transceiver presence # 2. 解码后的 EEPROM 信息,SONiC 认为你是谁 show interfaces transceiver eeprom Ethernet0 # 3. 低功耗状态 show interfaces transceiver lpmode # 4. 直接查数据库,看原始字段 sonic-db-cli STATE_DB hgetall "TRANSCEIVER_INFO|Ethernet0" # 5. 平台侧信息,确认 hwsku 和平台型号 show platform summary第 2 条命令的输出特别值得逐字看。它打印的是 SONiC 用sonic_xcvr解码你的低页和上页之后的结果:厂商标识、序列号、型号、硬件版本、固件版本、CMIS 修订版本、连接器类型。如果你的模块在show interfaces transceiver eeprom里出现了空字段或者明显错位的字符串,那基本可以断定是某个字段的偏移或者校验和没做对,而不是"主机不支持"。
sonic-db-cli STATE_DB这条我用得最多。它绕过了所有 CLI 的格式化逻辑,直接把守护进程写的原始键值对倒出来。字段名和值的形态跟你固件里那个字节是直接对应的,对照起来最快。不同 SONiC 版本的字段名会有一点差异,所以判断"字段有没有写进去"这件事,永远以数据库为准,不要以 CLI 输出为准。
注意:
STATE_DB的库编号在不同版本里可能有调整,直接写redis-cli -n 6有踩错库的风险,sonic-db-cli STATE_DB是更稳的写法。
2. sonic_xcvr:把 CMIS 内存映射表写成了可执行代码
2.1 MemMap 与 Field:规范表格的代码化
sonic_xcvr是整个参考实现里对固件工程师最有价值的部分。它做的事很朴素:把 CMIS 的那张内存映射表,用 Python 的字段描述对象重新写了一遍。大体结构是分api、mem_maps、fields这几个目录,其中mem_maps负责"哪个字节是什么字段",fields负责"这个字段怎么解码",api负责"这些字段组合起来能回答什么问题"。
字段对象这一层设计得比较讲究。数值类字段负责把原始字节按位宽和大小端转成整数;编码类字段带一张码表,把 0x18 这种十六进制翻译成"QSFP-DD"这种可读字符串;校验类字段则专门用来验算校验和。这套抽象带来的直接好处是:除了少数厂商私有区域,你的字段解码逻辑几乎不用自己发明,照着mem_maps里的定义逐个对齐就行。
我通常的做法是先把模块低页 0x00 到 0x7F 打印出来,然后在CmisMemMap里逐个字段反查,看主机解析出来的值和我预期的是否一致。这个交叉验证过程比对着规范表格逐字节数偏移要快得多,也更容易发现"我以为是 A 字段、主机读成了 B 字段"这种偏移错位。
# 示意:内存映射与字段对象的组织方式(非可直接运行代码) # 真实实现见 sonic_platform_base/sonic_xcvr 下的 mem_maps 与 fields 目录 mem_map = { "Identifier": NumberField(offset=0x00, size=1, ...), "ModuleState": NumberField(offset=0x01, size=1, ...), "VendorName": CodeField(offset=0x81, size=16, ...), "VendorSn": CodeField(offset=0xA6, size=16, ...), "CcBase": ChecksumField(offset=0x3F, ...), }上面这段只是示意,重点是这种"偏移 + 宽度 + 解码器"的三元结构。你自己写上位机测试脚本时完全可以照这个思路搭一层薄封装,收益很大:改一处映射定义,所有读值的地方自动跟着变。
2.2 页与 bank 的寻址:0x7F 那个选择字节的脾气
CMIS 的寻址模型不复杂,但有两个地方最容易出错。
第一是低页与上页的分界。低页覆盖 0x00 到 0x7F,上页覆盖 0x80 到 0xFF,两者共用同一段 I2C 从地址。要在上页之间切换,靠的是低页 0x7F 这个页选择字节。写进去页号,再读 0x80 以上的地址,拿到的就是对应上页的内容。每次读上页之前都要确认当前页选择字节的值是对的,因为任何一次其他访问都可能把它改掉。
第二是bank 机制。通道数变多之后,一页放不下所有通道的数据,于是引入了 bank,把同一逻辑页在不同 bank 下映射成不同的物理内容。页选择字节里除了页号还包含 bank 位域,具体的位置划分各版本有差异,写之前一定要查你手上那份规范的表格,别照抄别人代码里的位掩码。
实际的坑在于:bank 和页是两级选择,不是一级。只切页不切 bank,读到的可能是另一个 bank 里同样页号的数据,值看起来"合理"但完全是错的。我遇到过通道 5 到 8 的功率读数一直等于通道 1 到 4 的情况,查了两个小时,最后发现是 bank 位没置对。
# 切页 + 切 bank 的标准动作 bus = SMBus(3) ADDR = 0x50 def select_page(page, bank=0): # 注意:0x7F 的位域划分以 CMIS 版本表格为准 bus.write_byte_data(ADDR, 0x7F, (bank << 4) | (page & 0x0F)) def read_page(page, bank=0, length=128): select_page(page, bank) return bus.read_i2c_block_data(ADDR, 0x80, length) data = read_page(0x10, bank=0) # 读第 1 组通道的通道级数据还有一点值得提醒:不要在 0x7F 处跨界连读。有些实现图省事,从 0x00 一口气读 0x100 字节,想一次拿完整低页加上页,这样读出来的第 128 个字节是页选择字节本身,再往后就是"当前页"的上页内容,边界一变全乱。老老实实分成两段读。
2.3 校验和与解码器:校验字段的落点
CMIS 里几个校验和是主机判断"你这份数据可不可信"的第一道门槛。低页那一对是最关键的:低页 0x00 到 0x3E 的和校验放在 0x3F,低页 0x40 到 0x5E 的和校验放在 0x5F。上页还有一份 DMI 校验,覆盖上页 0x00 里从 0x80 到 0xE2 的内容,落在该页 0xE3。
校验和的算法本身很简单:求和取低八位。但现场问题往往不出在算法,而在于写入顺序。你在产线写 EEPROM 或者固件自举阶段回填这些区域时,如果先算了校验再改了后面的字段,校验就失效了。规范里对"什么时候校验必须有效"是有要求的,主机的sonic_xcvr解码路径又会去验它。一旦校验不过,可能出现的行为是:字段全部读出来但被主机判为无效,或者整个模块在show interfaces transceiver eeprom里显示成一堆空白。
我自己的习惯是:在固件里加一个"元数据自检"的启动步骤,把所有校验和重算一遍再和寄存器里的值比对,不一致就主动重写并记一条日志。这样至少能保证主机读到的数据永远是自洽的,省掉大量"主机读错"和"模块写错"之间的扯皮。
2.4 脱离交换机,把 sonic_xcvr 当上位机用
不是每个人都有随时可用的交换机。好消息是sonic_xcvr本身是纯 Python,可以脱离整机单独跑,只要你的机器上有一条能通到模块的 I2C 通路。
我常用的组合是:一台带 USB 转 I2C 适配器的 Linux 小主机,加上i2c-tools和smbus2,再装一份sonic_xcvr的代码,用它提供的 API 直接构造访问对象。这样模块上电、复位、I2C 读写、字段解码、上页遍历全都能在自己的台面上做完,迭代速度比抢交换机快一个数量级。
几个实测经验:
- 适配器选型很关键。桥接芯片对时钟延展的处理差异极大,有些型号在标准兼容测试里表现正常,一旦接上真实 CMIS 模块就频繁读失败,报的是 I2C 超时或者校验错位。遇到"同一模块换台主机就正常"的现象,先怀疑适配器。
- 电平要匹配。模块管理接口的电平标准和主控 IO 不一定一致,直接连过去轻则通信不稳,重则打坏 IO。
- 总线上的地址冲突要提前规划。所有模块的 EEPROM 从地址都一样,同一总线挂多个模块时必须有 I2C 多路复用器或者各自独立的总线,否则你会看到两个模块的数据互相打架。
- 台面环境要做电源管理。模块的上电时序、功耗上限和散热条件跟机箱里完全不同,热相关的问题在台面上大概率复现不出来,别在这里下结论。
3. 模块状态机:xcvrd 实际在轮询哪些位
3.1 低功耗、上电、就绪、故障:握手的完整顺序
CMIS 的模块状态机是固件行为的主干。抛开具体位名,流程是固定的:模块上电后进入低功耗状态,此时只保证最基本的寄存器可读;主机确认可以上电后清除低功耗请求,模块开始上电流程;上电完成后模块置位表示就绪;就绪之后主机的数据面才开始期待链路建立。任何一步超时或者置位错误,主机侧都会认为模块有问题。
主机侧在做的事情,比很多人想的要细。它不只是读一个就绪位,还会:
- 上电前先确认模块在位信号有效;
- 按平台要求设置 LPMode 电平;
- 必要时拉复位,然后释放并等待;
- 轮询模块状态位,直到就绪或者超时;
- 就绪后读取厂商信息、能力位、通道映射,用于建链路和上层配置;
- 之后就进入长期轮询:DOM、告警、误码统计、模块级故障。
这里最容易被固件工程师低估的是第 5 步。主机在就绪后马上会来读一堆上页数据,如果你的固件是在置了就绪位之后才开始准备这些上页内容,就会撞上一个"刚就绪但读到的还是旧值"的窗口。稳妥做法是把上页数据在上电流程里就准备好,确保置位那一刻所有页都可读且内容自洽。
3.2 四条控制信号线:时序比电平更容易出问题
LPMode、ResetL、ModPrsL、IntL这四条线里,前两条是主机输出给模块的,后两条是模块输出给主机的。电平极性是死的,真正难的是时序。
复位与低功耗的相对顺序是最常见的分歧点。主机可能在低功耗状态下先调整 LPMode,也可能先复位再退出低功耗。你的固件必须对两种顺序都保持幂等——也就是无论复位信号什么时候来、来几次,状态机都能收敛到正确状态。我踩过的坑是:固件只在"低功耗且不在复位"这一种组合下才响应配置变更,结果主机用另一种顺序操作时,配置根本没生效,表现为"模块能被识别但链路起不来"。
在位信号要特别注意抖动。热插拔过程中机械接触会带来短暂的不稳定电平,主机侧的守护进程通常有去抖逻辑,但你的模块不要依赖对方的去抖。在位信号在模块侧应当是稳定的、跟随真实插入状态的。
中断信号是双向理解成本最高的一条。模块用它来通知主机"有事件",主机用中断处理去读标志位。如果你的固件在中断线上拉低之后,标志位没有及时准备好,主机读到的就是空事件——次数多了之后,中断可能被主机屏蔽掉,之后所有事件都只能等轮询周期,实时性大打折扣。中断这种机制,宁可少报也不要乱报。
3.3 上电与复位期间,主机会等多久
主机侧的等待窗口是可以从代码里读出来的,这在排查超时类问题时非常有用。做固件的人需要建立的一个观念是:你的耗时指标不能贴着规范上限设计,要留余量给别人。因为主机侧还要处理总线仲裁、页切换、多模块轮询、日志落盘这些开销,你按规范上限刚好达标,实际表现就是"偶尔超时"。
我的做法是把自己固件里每个阶段的耗时上限压到规范允许值的一半左右,然后在测试里用示波器或者 GPIO 翻转记录实测最坏情况。留出来的余量不是为了好看,是为了覆盖温度、批次、器件老化带来的离散性。我见过太多"实验室正常、上了量就出问题"的案例,最后都归到耗时余量不够。
还有一个细节:上电过程中的 I2C 访问要能容忍。主机不会因为你还在上电就不读你,低页在低功耗状态下就必须可读。如果你的固件在上电过程中对 I2C 请求返回 NACK 或者长时间不响应,主机的重试逻辑可能会走到异常分支上去。
3.4 一次"模块不 Ready"的完整排查过程
我遇到过一次比较典型的故障,把这个排查链路写下来,方便你复现同样的思路。
现象是某批模块在交换机上插拔几次之后,有一定概率停在低功耗状态不再上电,重启交换机端口有时能恢复,有时不能。
第一步,先确认主机侧看到什么。show interfaces transceiver presence显示在位,show interfaces transceiver lpmode显示低功耗,但show interfaces transceiver eeprom能正常打印低页信息。这说明 I2C 通路没问题,模块本体是活的,问题在状态机上电环节。
第二步,看守护进程有没有报错。翻pmon容器的日志,能看到主机确实清除了低功耗请求,也在轮询状态位,但没有等到就绪。
第三步,绕过主机,自己在台面上复现。拔下来接到测试板,按同样的顺序操作:先置低功耗、拉复位、释放复位、清低功耗请求,然后轮询状态位。复现了,说明问题在模块侧。
第四步,缩小范围。用示波器同时抓 I2C 波形和模块内部的一个调试 GPIO(我们在固件里预留了状态机阶段指示),发现状态机在"上电中"这个阶段卡住,等待一个内部电源轨的就绪标志。那个标志在冷启动时一定正常,在热插拔(也就是模块刚下电又重新上电)时偶尔不置位。
第五步,定位根因。问题出在固件里对电源轨就绪标志的初始化顺序上——热插拔时上电斜率更陡,标志的初始状态和冷启动时不同,而代码里假设了冷启动的初始值,没有做显式的清除和超时兜底。
第六步,修复与验证。加上显式清除和超时跳转,让状态机在等待超时后主动重试上电,而不是永久卡死。同时在代码里把每个等待周期都加上超时出口,避免任何一处永久悬挂。
这个过程里有两点值得记住:一是预留调试 GPIO,把状态机的阶段指示出来,比打印日志快得多,也不受 I2C 通道占用影响;二是热插拔和冷启动是两种不同的物理条件,很多只在热插拔时出现的问题,根源都在启动初值假设上。
4. 把 STATE_DB 与命令行输出当作验收标准
4.1 数据库里那几张表,各自对应你的哪一段固件
STATE_DB里的TRANSCEIVER_*系列表,本质上就是主机对模块的一次"结构化理解"。看懂它们的字段来源,等于拿到了验收清单。
| 数据库表 | 主要内容 | 对应你的固件行为 |
|---|---|---|
TRANSCEIVER_INFO | 厂商、型号、序列号、硬件版本、固件版本、连接器与接口类型 | 低页标识与厂商区、上页厂商字符串、校验和、能力位 |
TRANSCEIVER_STATUS | 在位与基本状态 | 状态机、在位信号 |
TRANSCEIVER_DOM_SENSOR | 温度、电压、各通道收发光功率与偏置 | 监视寄存器的实时刷新 |
TRANSCEIVER_DOM_THRESHOLD | 各监视量的门限 | 门限配置寄存器 |
TRANSCEIVER_STATUS_FLAG | 告警与警告标志 | 标志位寄存器与清除语义 |
TRANSCEIVER_PM | 性能监视统计(通道级误码相关) | 通道级统计寄存器、数据刷新节奏 |
TRANSCEIVER_FIRMWARE_INFO | 固件版本与升级相关状态 | 固件管理能力位与升级流程状态 |
实际写固件时,我基本就是拿这张表当 checklist:每一行字段,去确认它在自己模块里由哪个寄存器产生、什么时候更新、是否有边界情况。凡是某个字段在表格里显示为 0 或者缺省值,先别急着找主机的麻烦,先确认自己的寄存器真的给出了有效值。
有个具体的建议:在开发阶段用脚本周期性 dump 这些表,跟模块内部的实际寄存器值做自动化比对。这一层自动化能把大量"偶发显示不对"的问题变成可复现的回归用例,收益远超写脚本的成本。
4.2 命令行工具各自负责什么
SONiC 里跟光模块相关的命令行主要分两条线,理解它们的调用链很有必要。
show interfaces transceiver这一族是读数据库的,它把STATE_DB里的内容格式化输出,自身不直接碰 I2C。也就是说,如果这条命令输出异常,问题可能出在守护进程、数据库,而不是模块。
sfputil这一族则是直接操作模块的,它走平台抽象层调用到实际的 I2C 和 GPIO。查 EEPROM、看 DOM、改低功耗、复位、固件升级都在这里。
| 命令 | 数据来源 | 典型用途 |
|---|---|---|
show interfaces transceiver presence | 数据库 | 快速确认识别状态 |
show interfaces transceiver eeprom <port> | 数据库 | 看解码后的静态信息 |
show interfaces transceiver lpmode | 数据库 | 看低功耗状态 |
sfputil show presence | 硬件 | 绕过数据库的实时确认 |
sfputil show eeprom <port> | 硬件 | 读原始/解码 EEPROM |
sfputil reset <port> | 硬件 | 验证复位后能否恢复正常上电 |
排查时的顺序原则很简单:先用show interfaces transceiver判断"主机以为的",再用sfputil判断"实际读到的"。两者不一致,问题在中间层(守护进程、总线争用、缓存未刷新);两者一致但都不对,问题在模块。这个二分法能砍掉一大半无效排查。
4.3 通道级监控与性能统计的自检清单
通道数多起来之后,通道级数据的正确性是现场问题的主要来源。我通常会准备一份自检清单,每次固件改完都跑一遍:
- 通道映射一致性:模块通告的通道数、通道分配方式,与平台上该端口配置的通道列表是否吻合。不吻合的典型表现是链路只起一部分,或者某几路光功率读数恒为 0。
- 数据刷新时延:通道级寄存器的更新周期要远快于主机的轮询周期,否则主机读到的是"隔一拍"的数据,误码统计会看起来一直跳。
- 门限与标志的配套:改门限之后,标志位的触发条件要同步生效。我见过改完门限但标志仍按旧门限置位的实现,主机会拿着互相矛盾的信息去判断。
- 边界值行为:光功率接近 0(无光)和接近饱和时,读数不能溢出或者符号翻转。这两个边界在实验室很容易被跳过,到了现场却是常态。
- 标志清除语义:哪些标志是读清、哪些是写清、哪些是锁存,一定要和规范的语义严格一致。主机侧的清除动作和你固件的清除逻辑不一致时,会出现"告警消不掉"或者"告警一闪而过"两种极端。
性能统计那一块要额外注意刷新节奏。统计类寄存器通常需要模块内部周期性地采样和累加,如果这个后台任务的优先级和周期设计得不好,会出现主机读到全 0 或者数值跳变的情况。我的经验是让统计刷新独立于 I2C 访问,用一个固定的内部节拍器驱动,不要等到主机来读了才现算。
4.4 用现成的测试用例做回归
SONiC 的测试仓库里有针对平台和光模块的接口测试,覆盖在位检测、信息读取、DOM 读取、低功耗切换、复位等常规路径。这些用例的价值在于它们是别人写的——覆盖的思路跟自研测试往往不一样,能补上自己没想到的角落。
用法上有两种选择。有真机的,直接对着你的模块跑,把失败项当作待办清单。没有真机的,把这些用例的断言逻辑抽出来,改成对着自己搭的 I2C 环境跑,也是一样的效果。我更推荐后者作为日常回归:跑得快,不受硬件档期限制,失败信息也干净。
需要提醒的是,这类测试对超时和时序比较敏感。如果你的模块在某个操作上耗时接近上限,用例可能会呈现偶发失败——这种偶发失败千万别当成"测试不稳定"忽略掉,它往往就是现场问题的提前预演。
5. CMIS 固件升级通道:从命令数据块到命令行
5.1 固件管理能力的声明方式
CMIS 定义了一套标准的模块固件管理机制,让主机可以不拆机、不断电地升级模块内部固件。对固件工程师来说,这套机制的第一步是能力声明:模块要在自己的能力位里明确告诉主机,支持不支持固件下载、支持几个镜像、是否需要自动页切换、下载过程的时序要求是什么。
这一步做得不认真,后面全是麻烦。典型问题包括:能力位声明支持升级,但实际实现只支持一种镜像格式;声明的下载块大小和实际接受的不一致;声明的"下载期间是否可访问"与真实行为矛盾。主机侧会按你声明的能力去组织流程,声明和实现不一致时,失败点会出现在很难定位的地方。
我的建议是把能力位当成对外接口契约来维护,改动能力位等于改了接口,必须同步改文档和测试。很多"某型号主机上升级失败、另一型号正常"的问题,根源就是能力位和实现脱节。
5.2 升级流程的各个阶段与状态反馈
抛开具体寄存器,整个升级流程分五个阶段,每个阶段主机都会来读状态、看进展:
- 能力查询与准备:读能力位,确认支持,可能要求模块进入特定状态。
- 分块下载:主机按块把镜像数据写进来,每块之后读一次状态,确认这一块被接受。这一阶段会持续很久,要保证状态位的更新及时且单调递增。
- 完整性校验:下载完成后模块自己校验整个镜像。这一步可能需要较长的时间,主机侧要能容忍。校验失败必须能明确报告,不能悄悄继续。
- 切换运行镜像:指定要运行的镜像编号,模块内部重启并重新走状态机。这一阶段模块会短暂失联,主机需要重新等待就绪。
- 提交固化:把当前运行的镜像设为默认。这一步决定了掉电后是否回滚。
阶段一到三的关键是状态反馈要清晰且可预测。我的做法是把每个阶段的进度做成一个明确的计数器,而不是只给"忙/完成"两个状态。主机侧拿不到进度时容易误判超时,而一个单调递增的计数器能让它安心等待。
阶段四和五的关键是原子性与回滚。切换镜像之后如果新镜像起不来,模块必须能回到旧镜像,否则一次升级失败就等于报废。备份镜像、原子切换标记、看门狗回滚,这三件事一个都不能省。我在早期项目里就因为提交阶段的写操作不是原子的,在掉电测试中把模块写成了半新半旧的状态,只能返修。
5.3 升级过程最容易踩的三个坑
第一个坑是访问并发。升级期间主机的守护进程可能仍在按周期读你的 DOM 或者状态。如果你的固件在下载过程中无法同时响应这类读操作,就会出现随机失败。解决办法有两个方向:一是在能力位里声明下载期间需要暂停访问,让主机侧主动避让;二是固件实现成下载和读可以并行,代价是内部访问仲裁要写扎实。我倾向于后者,因为不是所有主机都会认真处理"暂停访问"的声明。
第二个坑是超时窗口。镜像校验、镜像切换这两个阶段在规范里都有时间上限。固件实现容易贴着上限做,结果在低温或者器件老化的条件下超时。主机侧一旦超时,轻则报错重试,重则把模块标记为故障。我一般把内部实际耗时控制在规范上限的一半以内,并且对外报告的状态要能反映"正在做什么",而不是长时间沉默。
第三个坑是提交阶段的脆弱性。提交操作写的是非易失存储,写入时间和存储介质状态有关。如果提交过程中掉电,最容易出现的就是镜像和配置不一致。硬件的掉电检测和写入原子性必须在这一步做好,测试也必须包含"提交过程中断电"这一项。这个用例在实验室里又烦又慢,但它是唯一能证明你的升级流程真的安全的方法。
5.4 自己搭一个 I2C 从机来压测主机栈
固件工程师的测试环境中,最值得投入的一件事是用一颗可编程的微控制器模拟一个 CMIS 从机。它挂在 I2C 总线上,对外表现为一个符合规范的模块,但寄存器内容、响应时延、异常行为全由你控制。
这个东西的价值在于,它把"主机栈的行为"变成了可观测、可复现的对象。你可以让模拟从机在特定时机返回 NACK、延迟响应、给出校验错、在下载中途"失联",然后看主机侧怎么处理。你会发现主机栈里的重试、退避、状态恢复逻辑比你想象的复杂,而这些逻辑正是真机偶发问题时在跑的东西。
实测下来,这个模拟器最有用的是三类场景:一是时序边界,把响应时间设到规范上限附近,看主机是否还能稳住;二是异常恢复,制造一次读取失败,看主机多久恢复、会不会永久降级;三是升级流程,完整跑一遍含失败的升级,验证主机的重试和回滚路径。这三类场景在真实模块上要么难复现,要么代价高,用模拟器就轻松多了。
搭建时注意一点:模拟从机的时间精度要足够。主机可能在几百微秒内连续发起多次访问,微控制器的中断响应如果太慢,模拟出来的行为就不是你想要的那个行为。建议用带硬件 I2C 外设的平台,别用软件模拟时序。
6. 多 ASIC 与端口索引:模块在拓扑里挂在谁下面
6.1 index、lanes、alias 这三套命名别搞混
在 SONiC 里,一个物理模块会被三套不同的标识引用,理解它们的关系对排查问题非常关键:
- 端口名,比如
Ethernet0,是数据库和命令行里用的主键; - 通道列表,是这组端口占用的 serdes 通道编号,决定了数据面怎么接;
- 别名,是给人看的,通常对应面板上的丝印位置;
- 索引,是把端口映射到物理模块位置的那个编号,平台抽象层用它去找对应的 I2C 总线和 GPIO。
固件工程师关心的重点是最后一项。你的模块在主机侧能不能被正确访问,取决于平台配置里这个索引有没有对应到正确的物理位置。多 ASIC 的平台上,端口名是每个 ASIC 内部独立的,所以同一个名字可能在不同 ASIC 上出现——这时候光看端口名是不够的,必须连 ASIC 一起定位。
内部互联端口和面板端口在命名上通常有区分,前者名字里带特定后缀。做固件验证时要确认自己测的是面板口,别把内部互联当成了被测对象。
6.2 每个 ASIC 有自己的数据库视图
多 ASIC 架构下,每个 ASIC 是一个独立的管理域:有自己的配置库、自己的状态库、自己的守护进程实例。这意味着同一个模块在不同 ASIC 的管理域里可能呈现不同的状态——通常不会,但一旦出现不一致,就是很明确的定位线索。
实际操作上有两点要注意:
- 查状态时要明确在哪个管理域里查。有些命令行支持指定管理域,有些需要先切进对应的网络命名空间再执行。
- 平台配置的读取也分管理域。用配置生成工具打印合并后的数据时,要指定对应的管理域,否则看到的是默认域的内容。
# 指定管理域读取配置数据(参数名以本机版本为准) sonic-cfggen -d -n asic0 --print-data # 切进管理域再执行硬件相关命令 sudo ip netns exec asic0 sfputil show presence排查思路可以总结成一句话:端口名定位到端口,索引定位到模块,管理域定位到状态来源。三者都确认过,才谈得上"主机读不到我的模块"这种结论。
6.3 没有真机时的替代验证路径
最后说一个很现实的问题:很多固件团队手上没有多 ASIC 的真机。这种情况下怎么保证自己的模块在复杂拓扑里不出问题?
我的经验是按"接口契约"来验证,而不是按"整机现象"来验证。具体分三步。
第一步,把平台抽象层的接口逐个列出来,确认自己模块在每一个接口上的行为都符合预期。这些接口就是不同拓扑之间的共同语言,只要这一层对得上,拓扑差异带来的额外风险就有限。这部分的验证可以在台面环境用sonic_xcvr直接做,不需要整机。
第二步,把主机侧的时序逻辑抽出来,做成一个纯软件的状态机模拟,对着你自己的模拟从机跑。这样可以在没有交换机的情况下验证"主机在什么条件下会做什么",覆盖多轮重试、异常恢复、超时等路径。
第三步,只在有真机的时候做一次端到端确认,重点是平台配置相关的问题:索引映射、通道列表、管理域归属。这类问题的特征是"只在特定平台出现",用软件模拟覆盖不到,所以必须留出真机窗口。
按这三步走,交付前的风险主要集中在平台配置差异上,而这部分恰恰是可以靠人工核对配置解决的,比固件里藏着时序问题要好处理得多。
我自己在这个方向上最深的体会是:光模块固件的问题,绝大部分不是"没按规范做",而是"按规范做了,但和主机的实际行为错位"。SONiC 这套开源参考实现最大的价值,就是把主机的实际行为摊开在你面前,能读代码就读代码,能跑命令就跑命令,别靠猜。另外分享一个我一直在用的小习惯:每接一个新平台,先把show interfaces transceiver eeprom的输出完整存一份做基线,后面任何异常都可以跟这份基线做差分,省掉大量"上次是好的吗"的回忆成本。