一块板子拿回来,原理图上明明白白画着两个串口、一组GPIO、一个看门狗,结果系统起来之后串口只有一个,GPIO怎么拉都不对。查设备树、查驱动、查配置,全都没问题,最后才发现是Super I/O的寄存器配置空间根本没进去,设备一直处于Disabled状态。
Super I/O(超级输入输出芯片)这个角色,在x86板和不少嵌入式板卡上承担着串口、并口、PS/2、GPIO、硬件监控、看门狗这些低速外设的管理工作。它的寄存器配置空间不像PCI设备那样由系统自动枚举,而是要通过特定的I/O端口“解锁”之后才能访问。这篇内容就是把“进入寄存器配置空间”这件事从头到尾掰开讲清楚:为什么需要进去、怎么进去、进去之后怎么定位到具体设备、怎么激活设备、最后怎么验证。适合正在调BSP、做嵌入式Linux、写固件、或者被“串口少了两个”这种问题折磨的工程师。
1. 一块板子拿到手,串口却少了一半:问题出在哪
1.1 Super I/O在板卡上到底管什么
Super I/O这个芯片,名字里的“超级”其实是历史遗留,它继承的是当年ISA总线上那一堆慢速外设控制器。今天的Super I/O芯片内部集成了多个独立功能块,常见的有:
- UART串口控制器,一般是1到4路
- 并口(LPT/ECP/EPP)
- PS/2键盘鼠标控制器
- 软驱控制器(FDC,现在基本用不到但芯片里还有)
- GPIO引脚组
- 硬件监控(HWM,温度/电压/风扇转速)
- 看门狗定时器(WDT)
- CIR红外遥控接收
一颗典型的Super I/O,和CPU之间走的是LPC总线,新一代平台则逐步迁移到eSPI总线。注意这个“总线”本身没有复杂的枚举和资源分配机制,设备要占用哪个I/O地址、用哪个中断、是否使能,全靠Super I/O内部的寄存器配置。这就是为什么它需要一套独立的配置机制,不能像PCI那样插上就自动分配。
1.2 什么情况下需要主动进入它的寄存器配置空间
不少人第一次接触Super I/O,都是在“外设数量不对”的时候。我梳理了一下,这几种场景最典型:
- 板卡上明明有4个串口,BIOS只默认开了COM1和COM2,剩下的COM3、COM4处于禁用状态。
- 需要调整串口中断号或I/O基地址,比如某些PCIe转串口卡和板载串口地址冲突。
- GPIO被Super I/O默认配置成了其他功能(比如复用成CIR或电源控制),你想把它当普通GPIO用。
- 想通过SIO的看门狗做系统复位保护,但看门狗设备默认没有被激活。
- 硬件监控芯片的寄存器地址和驱动默认值不一致,导致
lm-sensors读不到温度。
做BSP bring-up的时候,这些情况几乎每周都能碰到一次。而且Linux内核虽然有superio相关的驱动,但覆盖的芯片和功能很有限,很多时候还是要靠手动访问寄存器来确认硬件状态。所以“进入寄存器配置空间”不是教科书概念,而是实打实的调试基本功。
1.3 先识别芯片型号,再谈配置
动手之前第一件事,搞清楚板子上到底是哪颗芯片。常用方法:
- 看芯片表面丝印,Super I/O通常是一颗48脚或128脚的QFP/LQFP封装,位置靠近LPC/eSPI接口或EC(嵌入式控制器)旁边。
- 查板卡原理图或BOM,这是最靠谱的。
- 在Linux下执行
dmesg | grep -i superio,部分内核驱动会打印探测到的芯片型号。 - 用
dmidecode看BIOS信息,虽然不一定直接列出SIO型号,但能帮助定位板卡厂商和BIOS版本。
常见厂商和系列:ITE的IT87系列(IT8728、IT8786、IT8665),Nuvoton的NCT系列(NCT5539、NCT6102),Winbond的W83627,SMSC的SCH系列。这些芯片的寄存器框架大同小异,但具体偏移和LDN编号会有差异,拿到对应型号的datasheet或寄存器列表(Register Map)后再操作,比对着通用流程瞎猜要稳得多。
2. 访问Super I/O的两把钥匙:索引端口与数据端口
2.1 Index/Data的读写模型
Super I/O的寄存器访问,本质上就是经典的Index/Data双端口模型。它只占用两个连续的I/O端口,一个叫索引端口(Index Port),一个叫数据端口(Data Port)。
整个过程分两步:
- 把要访问的寄存器编号写到索引端口。
- 从数据端口读值,或者把要写入的值写到数据端口。
读操作的C代码形式是这样:
outb(reg, index_port); /* 选择寄存器 */ val = inb(data_port); /* 读取数据 */写操作类似:
outb(reg, index_port); /* 选择寄存器 */ outb(val, data_port); /* 写入数据 */为什么用两个端口而不是给每个寄存器分配独立端口?因为ISA/LPC地址空间很紧张,两个端口就能索引256个寄存器,对于Super I/O这种设备足够了。可以用图书馆来类比:索引端口是“索书号查询处”,数据端口是“书库取书窗口”,先报编号,再取内容。
2.2 不同厂商的端口选型对照
不同厂商、不同系列的Super I/O,用的端口对不完全一样。常见组合如下:
| 厂商/系列 | 索引端口 | 数据端口 | 常见进入序列 |
|---|---|---|---|
| ITE IT87系列 | 0x2E | 0x2F | 0x87 0x87 |
| Nuvoton NCT系列 | 0x2E/0x2F 或 0x4E/0x4F | 由电路决定 | 0x87 0x87 |
| Winbond W83627 | 0x2E | 0x2F | 0x87 0x87 |
| SMSC SCH系列 | 0x4E | 0x4F | 0x55 |
| Fintek F8系列 | 0x4E | 0x4F | 0x87 0x87 |
ITE和Nuvoton这两个厂商的SIO在嵌入式板卡上出镜率最高,后面实例部分我主要围绕它们展开。需要提醒的是,Nuvoton部分新料支持通过厂商寄存器切换端口对,某些板卡设计也会把SIO挂在0x4E/0x4F而不是默认的0x2E/0x2F,所以“看手册+看原理图”永远是第一原则。
2.3 主板上找不到芯片时怎么判断端口
如果手里只有一块裸板,没有原理图,怎么确定SIO到底挂在哪个端口对?我的经验是:
- 打开datasheet,查“Configuration Port”或“I/O Address”章节,一般会写明默认端口和可选端口,很多芯片由硬件引脚的电平决定用哪组端口。
- 用逻辑分析仪抓一次冷启动过程,看BIOS在启动早期往哪些端口写了什么序列。0x2E/0x2F还是0x4E/0x4F,一眼就能看出来。
- 直接写个小程序,在两个候选端口上分别尝试进入配置模式,读一个已知的芯片ID寄存器(比如ITE的0x20寄存器会返回芯片型号),哪个端口能读出合理的ID就是哪个。暴力尝试本身没有破坏性,但前提是别乱写配置寄存器。
3. 开门暗号:进入配置模式的握手序列
3.1 为什么不能直接读寄存器
新手最容易困惑的一点是:我用inb(0x2E)为什么读不到东西?因为Super I/O上电后处于“运行模式”(Run Mode),此时配置寄存器空间是对外隐藏的,索引端口和数据端口要么返回固定值,要么返回0xFF。这是芯片故意设计的,防止系统运行期间应用软件不小心改坏关键配置。
只有先执行一段特定的握手序列,把芯片切换到“配置模式”(Configuration Mode),索引/数据端口才会真正映射到寄存器配置空间。这就是“进入寄存器配置空间”这句话的本意。
3.2 0x87两次写入的完整流程
ITE和Nuvoton最常见的进入序列是向索引端口连续写入两次0x87:
outb(0x87, 0x2E); outb(0x87, 0x2E);就这么简单。第一次写0x87是“唤醒”信号,第二次写0x87是“确认”信号,芯片识别到连续两次相同值后进入配置模式。
实践中有几个细节要注意:
- 两次写入之间不需要延时。我试过在普通x86 Linux用户态直接连续执行两个
outb,完全没有问题。 - 如果是在虚拟化环境或者某些模拟器里,可能需要加一点延迟,大概1微秒就够,否则第二个0x87可能被合并或丢弃。真机上没遇到过这个问题。
- 进入序列写错会有什么后果?通常是什么都不会发生,芯片继续待在运行模式。少部分芯片如果连续出现非法序列,会短暂进入保护状态,需要系统复位才能恢复,所以别拿未知序列乱试。
3.3 SMSC等芯片的变体与退出序列
SMSC系列(比如SCH3114)的进入序列是向0x4E端口写入0x55,不是0x87。Fintek部分芯片也是0x87两次,但端口常用0x4E/0x4F。
退出配置模式相对统一,绝大多数芯片是向索引端口写入0xAA:
outb(0xAA, 0x2E);退出之后,配置模式关闭,索引/数据端口恢复隐藏状态。这里有一个很重要的习惯:所有访问Super I/O配置寄存器的代码,都应该把“进入”和“退出”配对,哪怕中间只是读一个寄存器。长时间停留在配置模式里,后续其他程序对这个端口对的访问行为会变得不可预测。
4. 寄存器空间的三层结构:全局、LDN与设备寄存器
4.1 LDN是什么,为什么要按逻辑设备分
进入配置模式之后,你面对的是一个跨度为256字节的索引空间。但Super I/O内部有那么多功能块,如果所有寄存器全部平铺在这256个索引里,肯定不够用。芯片的做法是按“逻辑设备”(Logical Device)划分,每个逻辑设备有一组独立的配置寄存器。
访问流程是:先用全局寄存器0x07选择要操作的LDN,再通过索引/数据端口访问该LDN内部的寄存器。可以用一栋楼来理解:整栋楼是Super I/O芯片,每个LDN是其中的一个房间,0x07就是房间号选择器,而索引端口就是你手里那把能打开任意房间对应抽屉的钥匙。
这里还有一个容易混淆的点:0x07本身是全局配置寄存器,它不属于任何LDN。当你向0x07写入LDN编号后,后续访问的索引偏移就切换到了该LDN的配置空间,直到你再次修改0x07或者退出配置模式。
4.2 常用LDN编号速查
不同厂商的LDN编号有比较高的一致性,但细节要看datasheet。我整理了常用的对照表:
| 逻辑设备 | ITE IT87系列 | Nuvoton NCT系列 |
|---|---|---|
| FDC软驱 | 0x00 | 0x00 |
| LPT并口 | 0x01 | 0x01 |
| UART A(COM1) | 0x02 | 0x02 |
| UART B(COM2) | 0x03 | 0x03 |
| UART C/D | 0x04/0x05 | 0x04/0x05 |
| GPIO | 0x07 | 0x07/0x08 |
| WDT看门狗 | 0x0A | 0x08/0x09 |
| HWM硬件监控 | 0x0B | 0x0B |
注意同一厂商不同型号的LDN编号也偶尔有调整,比如某些IT87系列的WDT不在0x0A,某些NCT系列的GPIO分布在多个LDN里。操作前一定先翻寄存器列表确认,不要拿着通用表硬套。
4.3 激活设备的三件套:激活位、I/O基址、IRQ
进入某个LDN之后,接下来要做的事通常围绕三个寄存器:
- 激活控制寄存器。ITE系列通常在LDN偏移0x20,bit0写1使能设备;Nuvoton通常在偏移0x30,同样是bit0控制使能。
- I/O基址寄存器。UART这类设备需要一段I/O地址空间,Nuvoton的UART基址在0x60(高字节)/0x61(低字节),ITE系列多在0x21/0x22附近,具体字节序查手册。
- IRQ设置。Nuvoton一般在0x70,ITE一般在0x27/0x28附近。
写激活寄存器时,推荐“读-改-写”,就是先把原值读回来,把bit0置1再写回去,不要整字节覆盖,避免把其他控制位冲掉。下面是读改写一个激活寄存器的示意:
unsigned char act; act = sio_read(0x30); /* 读当前值 */ act |= 0x01; /* 打开bit0 */ sio_write(0x30, act); /* 写回 */4.4 配置什么时候生效
这个没有统一答案。有些芯片的寄存器写入后立刻生效,有些需要退出配置模式才真正生效,硬件监控这类设备基址配好后还得留一点稳定时间。稳妥的做法是:所有配置操作完成后,正常退出配置模式,再重新进入并回读关键寄存器确认真实状态。这个习惯能规避掉不少“明明写了却不生效”的奇怪问题。
5. 实战:把COM2从禁用状态改到0x2F8
5.1 用户态C程序完整示例
以Nuvoton NCT5539为例,把默认禁用的UART B(COM2)配置在I/O基址0x2F8,IRQ设置为3。NCT5539的UART B对应LDN 0x03,激活寄存器是0x30,基址寄存器是0x60/0x61(高字节/低字节),IRQ是0x70。
#include <stdio.h> #include <stdlib.h> #include <sys/io.h> #define SIO_INDEX_PORT 0x2E #define SIO_DATA_PORT 0x2F static void sio_enter(void) { outb(0x87, SIO_INDEX_PORT); outb(0x87, SIO_INDEX_PORT); } static void sio_exit(void) { outb(0xAA, SIO_INDEX_PORT); } static void sio_write(unsigned char reg, unsigned char val) { outb(reg, SIO_INDEX_PORT); outb(val, SIO_DATA_PORT); } static unsigned char sio_read(unsigned char reg) { outb(reg, SIO_INDEX_PORT); return inb(SIO_DATA_PORT); } int main(void) { unsigned char act; if (iopl(3) < 0) { perror("iopl"); return 1; } sio_enter(); /* 1. 选择LDN 0x03:UART B */ sio_write(0x07, 0x03); /* 2. 激活UART B */ act = sio_read(0x30); sio_write(0x30, act | 0x01); /* 3. 设置I/O基址为0x2F8 */ sio_write(0x60, 0x02); /* 高字节 */ sio_write(0x61, 0xF8); /* 低字节 */ /* 4. 设置IRQ为3 */ sio_write(0x70, 0x03); sio_exit(); iopl(0); return 0; }如果是ITE IT8728这类芯片,流程结构完全一样,但要注意三个差异点:进入配置模式后的激活寄存器偏移是0x20而不是0x30;I/O基址寄存器在0x21/0x22附近,且部分型号的字节序和Nuvoton相反;IRQ寄存器的位置也要查手册确认。
5.2 逐步拆解每个I/O操作
上面这段代码,核心逻辑只有四步。为什么是这四步,我把每个动作的意图拆开讲。
第一步写0x07选LDN。0x07是全局寄存器,它决定了后续所有索引访问指向哪个逻辑设备窗口。不写这一步,直接访问0x30,访问的是上一次进入配置模式时最后选中的LDN,可能根本不是UART B。
第二步读激活寄存器再写回。我没有直接执行sio_write(0x30, 0x01),而是先读后写。原因是寄存器里除了激活位可能还有其他控制位,比如某些芯片的bit1表示设备由谁管理,整字节覆盖容易把这些状态改乱。读改写是写硬件寄存器最基本的好习惯。
第三步写基址高字节和低字节。这里就能看出为什么需要datasheet:Nuvoton明确是0x60放高字节、0x61放低字节,所以写0x2F8就是先写0x02再写0xF8。如果字节序反了,轻则地址错乱,重则设备无法正常工作。
第四步写IRQ。IRQ寄存器的编码一般是直接把中断号写进去,0x03就是IRQ3。但有些芯片不是这种线性编码,所以还是那句话,查手册。
5.3 不用写代码的话怎么快速试
如果只是想在调试环境里快速验证一个寄存器值,不想编译C程序,可以使用Python配合ctypes直接调用glibc的outb/inb,在x86 Linux用户态下完全可行。
import ctypes libc = ctypes.CDLL("libc.so.6") libc.iopl(3) def outb(val, port): libc.outb(ctypes.c_ubyte(val), ctypes.c_ushort(port)) def inb(port): return libc.inb(ctypes.c_ushort(port)) & 0xFF # 进入配置模式 outb(0x87, 0x2E) outb(0x87, 0x2E) # 选择LDN 0x03: UART B outb(0x07, 0x2E) outb(0x03, 0x2F) # 读激活寄存器 0x30 outb(0x30, 0x2E) print(hex(inb(0x2F)))这种方式的好处是改起来快,适合临时验证;缺点是没有结构化封装,脚本一长就容易晕。我的建议是:临时验证用Python脚本,最终落地到固件或者量产配置里,还是用规范封装的C代码。
5.4 配置完成后的自检流程
光把寄存器写进去不算完,要确认整个链路真的通了。我的自检顺序是:
- 重新进入配置模式,读回0x30确认bit0是1,读0x61确认低字节是0xF8。寄存器回读是最快的低级验证。
- 退出配置模式后,查看Linux系统是否识别到新的串口。比如
dmesg | grep ttyS,如果看到ttyS1相关的初始化信息,说明8250驱动已经扫描到了设备。 - 如果系统没有自动创建设备节点,检查内核是否编译了
SERIAL_8250且允许自动探测地址。很多嵌入式平台的内核里,板级串口是在设备树或者8250.nr_uarts参数里写死的,光改SIO寄存器不够,还要同步改内核配置。 - 最后做一次物理回环测试:把COM2的TX和RX短接,往
/dev/ttyS1写数据再读回来,能收到自己发的内容,说明整个通路是好的。
6. 配置不生效?我踩过的坑和排查顺序
6.1 回读确认:配置代码不该是盲写的
“我明明写了0x30,怎么读出来还是0x00?”这是最常遇到的问题。遇到这类情况,不要直接怀疑芯片坏了,按顺序排查。
先确认是否真的进入了配置模式。回读一个固定值寄存器,比如ITE的芯片ID寄存器(通常在0x20全局空间),如果读出来的值和datasheet里写的型号一致,说明进入序列和端口是对的;如果读出来还是0xFF或0x00,说明要么序列没写对,要么端口选错了。这一步能快速区分“门没打开”和“寄存器写不进去”。
还有就是端口选错的问题。板卡设计不一定按默认端口来,Nuvoton的芯片明明支持0x2E,但原理图上把配置引脚拉到了0x4E,这个时候用0x2E怎么试都白搭。
6.2 权限、端口冲突和配置模式残留
用户态访问I/O端口,需要root权限,并且要正确调用ioperm或iopl。在现代内核开启了CONFIG_STRICT_DEVMEM的环境里,对/dev/port的直接访问也可能受限。我在调试时遇到过一种情况:程序在root下运行,iopl(3)却返回失败,查到最后是内核启动了lockdown模式,禁止用户态直接操作I/O端口。这种情况要么换一个允许的环境,要么把配置逻辑写成一个内核模块。
还有一个坑是配置模式残留。如果你上一次执行到一半程序崩溃了,没有执行退出序列,Super I/O会一直停留在配置模式。此时另一个程序如果也对0x2E/0x2F做读写,结果会变得非常不可预测。内核里某些superio驱动在probe失败后没有正确清理,也会导致后续访问异常。遇到这种诡异情况,先手动执行一次退出序列:
outb(0xAA, 0x2E);把芯片恢复到运行模式,再重新走流程。
6.3 和外设、固件的优先级冲突
SIO配置写完后又被改回去,这种问题往往不是硬件或者代码逻辑出错,而是有更高优先级的配置源在覆盖你。
BIOS/EC固件在开机阶段会配置Super I/O,有些主板会根据CMOS设置动态调整串口数量和地址。如果你改完寄存器后立刻重启,进BIOS看到串口配置又变回原样,去BIOS设置里看看对应的“Serial Port”选项是不是被你忽略了。
Linux内核的superio相关驱动也可能在你退出配置模式之后重新接管并覆盖值。比如某些看门狗驱动会在加载时重新配置WDT,如果你的配置和驱动的预期值冲突,驱动就会写回自己的配置。处理办法是调整驱动的加载方式,或者把SIO配置放到驱动加载之前完成。
如果涉及看门狗,还有个更容易踩的坑:配置WDT的时候,原值里可能已经包含了使能位,你只是想改超时时间,结果读改写把整个看门狗激活了,系统在几秒后重启。调试期间我习惯先把看门狗超时设到最大值,确认逻辑正确后再调回来。
6.4 一个小习惯:建立本地速查表
最后分享一个我自己受用多年的习惯。不同板卡、不同芯片的SIO配置,细节差异真的很多,不要指望靠记忆。我会为每颗常用芯片维护一个速查表,内容包括:索引端口、数据端口、进入序列、退出序列、主要LDN编号、核心寄存器偏移。每次拿到新板卡,先查表和看丝印,再翻datasheet确认细节,效率比重新看一遍完整手册高很多。
Super I/O这套Index/Data加LDN的模型,在x86和嵌入式世界里应用非常广泛。eSPI时代的Super I/O、BMC里的SIO接口、甚至很多EC的内部逻辑,都能看到类似的设计思路。花一个下午把0x87进门的流程跑通,后续再遇到串口缺失、GPIO不听话、看门狗配置无效这类问题,你就多了一个非常顺手的调试入口。