1. 先把问题摆清楚:U-Boot 阶段调 GPIO 到底在调什么
做 uboot GPIO 调试这件事,我前后在五六块自研板子上反复折腾过,最开始也走过弯路——拿内核里那套思维直接套过来,结果一个复位脚拉了三小时没动静,最后发现是引脚复用压根没配。所以这篇东西不打算从寄存器手册第一页开始抄,而是按我实际排查的顺序讲:先搞清楚这个阶段 GPIO 的特殊性,再拆系统结构,然后落到编号、参数、代码、验证这一条链路上。
U-Boot 里的 GPIO 调试,说白了就是在操作系统还没起来之前,用软件手段把板子上那些"决定能不能启动"的引脚状态控住。这些引脚可能是电源使能、复位释放、看门狗喂狗、启动模式选择、状态灯、屏幕背光、PHY 复位、SD 卡检测……它们有个共同点:一旦状态错了,后面的东西根本跑不起来,你连串口都看不到完整日志。所以这个活儿的特点是——没有调试器可用、没有文件系统可用、没有内核驱动兜底,全靠你自己在几百毫秒内把电平摆对。
这篇内容适合三类人看:刚接手板子 bring-up 的嵌入式工程师、需要在新平台上验证硬件设计的驱动开发者、以及被"内核里好好的,U-Boot 里死活不工作"这种问题折磨过的人。我会把引脚复用、控制器模型、编号换算、设备树写法、API 选择、时序验证、故障排查都串起来讲,尽量做到你看完能直接上手抄。
1.1 启动阶段的 GPIO 和内核里的 GPIO 完全不是一回事
很多人下意识觉得,GPIO 操作不就那几行代码吗,内核里gpiod_set_value用得挺顺,搬到 U-Boot 里照着写就行。实际不是。内核阶段你面对的是一个已经完成初始化的世界:时钟树开好了,电源域开了,pinctrl 子系统把引脚复用配好了,驱动模型把资源都解析好了,你只需要"用"。而 U-Boot 阶段你在造这个世界。
具体差在几个地方。第一是时钟和电源域。很多 SoC 的 GPIO 控制器挂在某个总线上,这个总线要先上电、先开时钟,你才能读写它的寄存器。内核里这些是框架自动处理的,U-Boot 里如果时钟驱动没配好或者初始化顺序不对,你去写寄存器就是写了个空气——读回来还是原值,看起来像"没生效"。第二是引脚复用。内核的 pinctrl 会在 probe 时自动 select default state,U-Boot 里你得自己保证在操作 GPIO 之前,pinmux 已经被切到 GPIO 功能,否则你控制的可能是 UART 的 TX 脚。第三是初始化顺序。U-Boot 的board_init_f、board_init_r、spl_board_init这些钩子在不同阶段跑,你的 GPIO 操作放在哪个钩子里,决定了它能不能赶在依赖它的设备之前生效。
我在一块板子上遇到过特别典型的情况:eMMC 的复位脚在board_init_r里拉高,但 eMMC 控制器的 probe 在更早的board_init_f阶段就发生了,结果复位根本没释放,控制器读 ID 一直超时。日志上看着像"eMMC 不识别",实际是 GPIO 时序排错了。这种坑只有在你真正理解阶段划分之后才不会再踩。
1.2 三类必须在 U-Boot 阶段解决的 GPIO 需求
我一般把 U-Boot 里需要碰的 GPIO 分成三类,分类清楚了,处理方式也就清楚了。
第一类是"启动必需"。包括主电源使能、各路 LDO/DCDC 的 EN 脚、复位释放、时钟芯片的 OE 脚、启动模式选择脚。这类 GPIO 的特点是:错一个,板子就不启动或者启动不稳定。它们的操作必须放在极早期,SPL 或者board_init_f的前段,而且要保证在对应的外设初始化之前完成。这类引脚我一般不建议依赖设备树自动解析,直接在最早期用最原始的方式拉,稳妥。
第二类是"功能辅助"。比如以太网 PHY 的复位、USB Hub 的复位、屏幕背光使能、WiFi 模组的使能脚。这类不影响 U-Boot 自身启动,但影响某个外设能不能被正确识别。它们的操作可以放在board_init_r或者某个外设驱动自己的 probe 里,用标准 API 就行。
第三类是"调试观测"。包括状态灯、测试点、心跳脚。这类最灵活,但也最容易被忽略——我强烈建议在硬件设计阶段就留至少两个空闲 GPIO 引出测试点,一个做"启动进度指示",一个做"关键节点打点"。后面调试时序问题的时候,示波器双通道一挂,比你翻日志快十倍。
分完这三类你就能明白,为什么同一个"调 GPIO"的需求,有人在board_init_f里几行裸寄存器搞定,有人非要在设备树里写一堆节点。不是谁更高级,而是需求性质不同。
2. 整体设计:三层结构决定你的调试路径
U-Boot 的 GPIO 相关代码,我习惯拆成三层来看:引脚复用层(pinctrl)、控制器层(gpio driver)、使用者层(你的代码)。这三层任何一层出问题,现象都是一样的——"电平不动"。所以调试的核心方法不是猜,而是逐层验证,把问题锁死在哪一层。
2.1 引脚复用、控制器、使用者:三层各管什么
引脚复用层决定这个物理引脚的信号来自哪个内部功能。现在的 SoC 基本都是"一脚多用",同一个球位可以走 UART、可以走 I2C、可以走 GPIO。这一层由 pinctrl 子系统和设备树里的 pinctrl 节点控制,本质上是在读写 SoC 的 IOMUX 寄存器。
控制器层是 GPIO 控制器驱动,它管理一组引脚,提供"设方向、读电平、写电平、配上下拉"这些操作。在 U-Boot 里对应struct gpio_ops,通过CONFIG_DM_GPIO走驱动模型。
使用者层就是你的板级代码或者驱动代码,调用gpio_request_by_name拿到一个struct gpio_desc,然后dm_gpio_set_value操作。
三层的关系是严格的上下依赖:使用者要操作引脚,控制器必须先 probe 成功,控制器的寄存器访问要能通,说明时钟电源没问题;引脚要真正输出到外部,复用必须先切到 GPIO。所以我的排查顺序永远是反过来的:先看使用者有没有拿到资源,再看控制器读写是否生效,最后确认引脚复用对不对。
有个很实用的自检手法:在操作前先读一次引脚电平,再写,再读回来。如果读回来的值和你写进去的一致,但外部电平没变,八成是复用没切对,信号根本没连到引脚;如果读回来就不对,那是控制器层的问题,可能是时钟没开、地址不对、或者这个 GPIO 号压根不属于任何已注册的控制器。
2.2 为什么优先走设备树而不是硬编码
老代码里到处都是gpio_set_value(32 + 13, 1)这种写法,数字从哪来的只有原作者知道。新板子上我基本不再这么写,原因不复杂:硬编码的 GPIO 号在换平台、换板子版本时必然失效,而且失效时没有任何提示。
设备树的做法是把"这个引脚干什么用"声明出来:
/* 板级设备树片段,示意写法 */ &gpio_ctrl1 { status = "okay"; }; my_device: my-device { compatible = "vendor,my-device"; enable-gpios = <&gpio_ctrl1 13 GPIO_ACTIVE_HIGH>; reset-gpios = <&gpio_ctrl1 14 GPIO_ACTIVE_LOW>; status = "okay"; };代码里只按名字取:
struct gpio_desc enable_gpio, reset_gpio; ret = gpio_request_by_name(dev, "enable-gpios", 0, &enable_gpio, GPIOD_IS_OUT); if (ret) { printf("failed to request enable-gpios: %d\n", ret); return ret; } dm_gpio_set_value(&enable_gpio, 1);这么写的好处不是"好看",是换板子只改 dts,代码一行不动;同时gpio_request_by_name失败会有明确返回值,你立刻知道是资源没解析到,而不是在几万行代码里找那个魔法数字。另外GPIO_ACTIVE_LOW这个标记非常关键,它让极性由硬件描述决定,代码里统一写逻辑 1,驱动内部自动翻转,避免"这个脚高有效那个脚低有效"记混。
代价也有:设备树的 pinctrl 配置如果写错,编译不报错,运行时也不报错,就是没效果。所以我一般会在板级初始化里加一句打印实际解析到的 GPIO 号,printf("enable gpio = %d\n", dm_gpio_get_number(&enable_gpio))之类,先确认号对不对,再看电平。
2.3 新旧两套 API 怎么选
U-Boot 里现在并存两套 GPIO API,很多新手看着就懵。
| 对比项 | 旧式 API(gpio_ 前缀) | 驱动模型 API(dm_gpio_ 前缀) |
|---|---|---|
| 典型函数 | gpio_request/gpio_direction_output/gpio_set_value | gpio_request_by_name/dm_gpio_set_value |
| 资源来源 | 全局数字编号 | 设备树 or 全局编号 |
| 生命周期管理 | 手动 request/free | 随设备自动管理 |
| 极性处理 | 需自己判断 | GPIO_ACTIVE_LOW自动翻转 |
| 适用场景 | SPL 早期、极简初始化 | 大部分板级代码、驱动 |
| 依赖配置 | CONFIG_DM_GPIO可关闭 | 需启用驱动模型 |
我的实际选择标准很简单:SPL 阶段和"必须在驱动模型 probe 之前执行"的极早期代码,用旧式 API 甚至裸寄存器;其余全部用驱动模型 API。原因是 SPL 里空间紧张,驱动模型那套解析开销不小,而且 SPL 阶段设备树可能还没完全解析出来。而在 U-Boot proper 里,用驱动模型能拿到资源检查和极性处理的好处,没理由不用。
要注意的是,两套 API不能随便混用。如果某个 GPIO 已经被gpio_request申请了,再走gpio_request_by_name去拿同一个引脚,可能会冲突或者拿到不同的 desc。混用导致的 bug 特别隐蔽,因为两边都能"成功",但操作的不是同一个对象。
3. 编号与参数:最容易翻车的部分
GPIO 调试里我统计过,真正因为芯片或代码逻辑出问题的比例其实不高,绝大多数低级故障集中在这几块:编号算错、方向配错、极性搞反、上下拉没配。这几件事单独看都很简单,组合起来就很容易出错。
3.1 GPIO 编号换算与 bank base
先说编号。SoC 的 GPIO 一般按组(bank/port)组织,每组通常 32 个引脚,但也不绝对,有的是 16 个,有的是 8 个,还有的组之间地址不连续。设备树里的<&gpio_ctrl1 13 GPIO_ACTIVE_HIGH>里的 13 是组内偏移,而旧式 API 里的数字是全局编号。
全局编号的换算是:
全局编号 = 该组基号 + 组内偏移举个例子,某 SoC 有 5 组 GPIO,每组 32 脚,按顺序排列,那么:
| 组 | 组内偏移范围 | 全局编号范围 | 组基号 |
|---|---|---|---|
| 第 1 组 | 0 ~ 31 | 0 ~ 31 | 0 |
| 第 2 组 | 0 ~ 31 | 32 ~ 63 | 32 |
| 第 3 组 | 0 ~ 31 | 64 ~ 95 | 64 |
| 第 4 组 | 0 ~ 31 | 96 ~ 127 | 96 |
| 第 5 组 | 0 ~ 31 | 128 ~ 159 | 128 |
那么"第 3 组第 5 脚"的全局编号就是64 + 5 = 69。
听起来简单,但这个 base 值并不总是从 0 开始连续排。有些平台会预留一段给特殊功能,有些平台的CONFIG里会定义ARCH_NR_GPIOS影响范围,还有些 SoC 的 GPIO 控制器是分立的 IP,每块的 base 由dm_gpio_lookup_name运行时决定。所以我一般不在代码里手算,而是先跑一遍gpio status -a把所有控制器和引脚列出来,对照着看 base 到底是多少。
提示:如果
gpio status输出里的编号和你手册算的对不上,别急着怀疑代码,先确认是不是有多个 GPIO 控制器、base 是不是按 probe 顺序动态分配的。这种情况下硬编码数字必定出问题,老老实实走设备树。
另外有个细节:有些平台组内编号从 1 开始,手册上写 PA0 到 PA31,但寄存器位定义里 bit0 对应 PA0;而有些平台手册用"引脚号 1"代表第一个脚,代码里要减 1。这种差一位的错误非常常见,表现是"我操作了引脚 13,实际动的是 14",然后对着原理图死活对不上。
3.2 输出初值、方向与上下拉
方向配置这块有个细节被很多人忽略:把引脚配成输出的那一刻,输出值是多少?如果控制器支持"方向 + 初值"一次性配置,那没问题;如果不支持,你先配方向再写值,中间会有个短暂的中间态,输出的是复位默认值(可能是 0,也可能是上次残留)。
对于复位脚、使能脚这类敏感信号,这个中间态可能造成误动作。比如某个芯片的复位是低有效,你希望它一直保持高,结果配方向那一瞬间输出了低,芯片被复位了一下,虽然马上又拉高,但状态机已经乱了。
正确做法是先写输出锁存值,再切方向(前提是控制器支持在输入模式下预置输出寄存器):
/* 示意:先把输出锁存器写成期望值,再切输出方向 */ gpio_set_value(gpio, 1); /* 此时引脚仍是输入,外部无影响 */ gpio_direction_output(gpio, 1); /* 切输出,直接呈现高电平 */如果控制器不支持,那就退一步:确认这个引脚的复位默认值是什么,如果默认值恰好是安全电平,直接用;如果不是,就得考虑外部加个上拉/下拉电阻把默认态固定住。这也是为什么硬件设计阶段就要和硬件同事确认"这个脚复位后是什么状态"。
上下拉配置同样重要。一个 GPIO 如果既不配上拉也不配下拉,在配置成输出之前它是浮空的,读回来的值随机,接到某些敏感输入上会引起误触发。我遇到过一个案例:某个中断脚浮空,上电后随机抖动,U-Boot 里一直误进中断,导致启动变慢。后来配上内部下拉就正常了。
| 配置项 | 什么时候必须配 | 配置错的典型现象 |
|---|---|---|
| 内部上拉 | 引脚默认应为高、外部无上拉 | 上电瞬间电平抖动、误触发 |
| 内部下拉 | 引脚默认应为低、驱动能力弱 | 输入读数不稳、随机变化 |
| 驱动能力 | 驱动长走线、驱动多个负载 | 电平摆幅不够、上升沿慢 |
| 开漏输出 | 需要线与、电平转换、I2C 类信号 | 拉不高、电平不完整 |
驱动能力这个参数很多 SoC 都支持多档可调,从几毫安到十几毫安。默认档位通常比较保守,驱动短线没问题,但如果你要驱动一个 LED 或者拉一根长排线,可能就会出现"逻辑上写 1 了,实际测只有 1.8V 不是 3.3V"的情况。这种问题用万用表测静态电平最容易发现。
3.3 电平有效性与 ACTIVE_LOW
GPIO_ACTIVE_LOW这个标记,我认为是设备树 GPIO 描述里最容易被误用的东西。它的含义不是"这个引脚输出低电平",而是"这个引脚的逻辑有效状态对应物理低电平"。启用之后,你写逻辑 1,驱动帮你输出物理 0;你读物理 0,驱动给你逻辑 1。
所以代码里你只需要关心逻辑语义:
/* 不管硬件是高有效还是低有效,代码统一这样写 */ dm_gpio_set_value(&reset_gpio, 1); /* 进入"有效"状态 */ dm_gpio_set_value(&reset_gpio, 0); /* 退出"有效"状态 */写错标记的后果是极性完全反了,而且现象极具迷惑性:你明明"拉高"了,测出来是低;你"拉低"了,测出来是高。很多人第一反应是驱动有 bug,实际是 dts 里少写或多写了GPIO_ACTIVE_LOW。
我的习惯是:复位脚、使能脚、中断脚一定显式标注极性,普通输出脚可以不标(默认高有效)。标完再对着原理图核一遍——原理图上如果画了个反相器或者标了 nRST,那基本就是低有效。
4. 实操链路:从命令行验证到代码落地
理论讲完,进入我实际干活的标准流程。这套流程我用了很多次,基本能做到"少走弯路,一次点亮"。
4.1 用命令行先把现象复现出来
U-Boot 有个特别实用的命令gpio,需要开CONFIG_CMD_GPIO。我拿到新板子的第一件事就是进命令行,把要调的引脚挨个试一遍。
# 列出所有 GPIO 控制器和引脚状态 gpio status -a # 只看某个控制器 gpio status gpio_ctrl1 # 把某引脚设为输出并拉高 gpio set 69 # 拉低 gpio clear 69 # 切换 gpio toggle 69 # 设为输入 gpio input 69 # 读取当前值 gpio status 69这几条命令的价值在于快速分层定位:
- 如果
gpio status -a根本列不出控制器,说明驱动没 probe,问题在控制器层,去看时钟、电源、dts 的 status。 - 如果能列出但
gpio set之后测不到电平变化,说明要么复用没配对,要么引脚被别处占用了,要么控制器寄存器访问没生效。 - 如果命令行能操作但代码里不行,说明是资源申请或者调用时机的问题。
我特别喜欢用gpio toggle配合示波器:让它循环 toggle,示波器上就能看到方波,频率和占空比一目了然,比静态测量直观得多。如果测不到方波,但gpio status显示值在变,那基本可以确定是复用问题——内部寄存器在动,信号没出去。
提示:
gpio命令操作的是全局编号,不是设备树里的组内偏移。用之前先gpio status -a看清楚编号,别拿组内偏移直接往命令里塞。
4.2 设备树里把 pinmux 和 gpio 写对
命令行验证完,说明硬件通路是好的,接下来就要把它固化成设备树配置。这部分要写两块:pinctrl 节点和设备节点。
/* 引脚复用配置:把这两个脚切成 GPIO 功能,并配好上下拉 */ &pinctrl { my_device_pins: my-device-pins { pins = "gpio1-13", "gpio1-14"; function = "gpio"; bias-pull-up; drive-strength = <8>; }; }; /* 设备节点:声明自己用哪几个脚 */ my_device: my-device { compatible = "vendor,my-device"; pinctrl-names = "default"; pinctrl-0 = <&my_device_pins>; enable-gpios = <&gpio_ctrl1 13 GPIO_ACTIVE_HIGH>; reset-gpios = <&gpio_ctrl1 14 GPIO_ACTIVE_LOW>; status = "okay"; };这里有几个容易出错的地方:
第一,pins的写法各平台不统一。有的平台写"gpio1-13",有的写"GPIO1_IO13",有的直接用数字。这个必须照着你平台已有的 dts 抄,不能自己想当然。抄错了不会报错,就是配不上。
第二,function必须是有效的功能名。有些平台写"gpio",有些写"0",有些要求写具体的功能编号。同样照抄。
第三,pinctrl-0要挂在真正会 probe 的设备节点上。如果你的设备节点status = "disabled",那 pinctrl 也不会被 select,引脚就一直保持默认复用。我就遇到过设备节点被禁用但代码里还在操作 GPIO 的情况,怎么都不生效,查了半天。
第四,如果代码是极早期执行的(早于设备模型 probe),那 pinctrl 可能还没 select。这时候要在代码里手动调一次:
struct udevice *dev; ret = uclass_get_device_by_name(UCLASS_GPIO, "gpio_ctrl1", &dev); /* 或者手动 select pinctrl state */更稳妥的方式是在板级 dts 的chosen或者config节点里提前声明,具体写法看平台。
4.3 代码里申请、配置、拉电平
设备树配好,代码就好写了。我一般封装成一个板级的小函数,放在对应设备的初始化里:
static int board_mydev_power_on(struct udevice *dev) { struct gpio_desc enable, reset; int ret; ret = gpio_request_by_name(dev, "enable-gpios", 0, &enable, GPIOD_IS_OUT); if (ret) { printf("%s: no enable gpio (%d)\n", __func__, ret); return ret; } ret = gpio_request_by_name(dev, "reset-gpios", 0, &reset, GPIOD_IS_OUT); if (ret) { printf("%s: no reset gpio (%d)\n", __func__, ret); return ret; } /* 先把复位保持在无效状态,再上电 */ dm_gpio_set_value(&reset, 0); mdelay(1); dm_gpio_set_value(&enable, 1); mdelay(10); /* 等电源稳定,具体值看器件手册 */ /* 释放复位 */ dm_gpio_set_value(&reset, 1); mdelay(5); /* 复位释放后的稳定时间 */ return 0; }这段代码里有几个我踩过坑的地方值得说:
延时要给够,但别乱给。mdelay(10)这个值不是拍脑袋,是器件手册里写的"电源上升到复位释放最小间隔"。手册没写就实测,用示波器同时看 EN 和 RST 两个信号,把时间量出来再留 30% 余量。乱给大延时会让启动变慢,给少了器件不响应。
顺序不能反。先 EN 后 RST 释放是常规做法,但也有器件要求反过来,必须看手册。有些芯片要求复位在电源建立之前就保持有效,那就得先拉复位再上电。
GPIOD_IS_OUT这个 flag 很关键。它表示申请时就把引脚配成输出,不写这个默认是输入,你后面dm_gpio_set_value写了也不生效(或者只改了锁存器)。这个细节我见过不止一个人踩。
错误处理要打日志。gpio_request_by_name失败返回-ENOENT通常意味着 dts 里没写这个属性或者pinctrl没配;返回-EBUSY意味着被别处占了。打印出来能省掉大量猜测时间。
4.4 时序验证:示波器、万用表和逻辑分析仪
代码写完了不等于完事,必须实测。我一般分三步验证。
第一步用万用表测静态电平。这是最省事的,把要测的引脚引到测试点,万用表一挂,看是不是你期望的电压。这一步能发现大部分"复用没配对""方向配错""极性搞反"的问题。注意万用表有响应延迟,快速切换的信号测不准,只看稳态。
第二步用示波器看时序。这一步的重点不是"电平对不对",而是"顺序和时间对不对"。我一般双通道,一个挂 EN,一个挂 RST,触发设在 EN 上升沿,然后量 EN 到 RST 的间隔。如果和手册要求差得远,就要回头调延时。示波器还能看上升沿是不是太缓、有没有过冲,判断驱动能力和上下拉配置是否合理。
第三步用逻辑分析仪看多路关系。当涉及的引脚超过两个,比如"电源使能 + 复位 + 时钟使能 + 状态指示"四路,逻辑分析仪就比示波器好用得多,一次性把时序关系全抓下来,导出看时间戳。
| 工具 | 适合看什么 | 主要局限 |
|---|---|---|
| 万用表 | 稳态电平、通断、是否有短路 | 看不到时序、响应慢 |
| 示波器 | 边沿质量、时序间隔、毛刺 | 通道数少,多路关系不好看 |
| 逻辑分析仪 | 多路时序关系、协议解码 | 只看逻辑电平,不看模拟质量 |
| U-Boot 命令行 | 寄存器层面的值、方向状态 | 看不到物理引脚实际情况 |
我个人的经验是:命令行能确认软件侧写进去了,仪表才能确认硬件侧出来了,两者必须对得上。如果软件说写了高、仪表测到低,那就一定在中间某一层断了,顺着复用、控制器、走线往下找。
5. 排查实录:几个高频故障的定位思路
这部分是我这些年记录下来的真实排查过程,现象和原因都是反复出现过的类型。
5.1 拉电平没反应,但寄存器读写都正常
这是最常见的一类。dm_gpio_set_value返回 0,dm_gpio_get_value读回来也是你写的值,但物理引脚纹丝不动。
排查顺序我是这样的:
先确认引脚复用。读 SoC 的 IOMUX 寄存器(地址在手册里查),看目标引脚的功能选择位是不是指向 GPIO。如果不是,那就是 pinctrl 没生效。原因可能是 dts 里pinctrl-0没挂上、设备节点没 probe、或者 pinctrl 驱动本身没配好。
再确认引脚归属。有些引脚被多个外设共用,可能被别的驱动抢先 request 了。可以看启动日志里有没有别的驱动打印"request gpio xx failed"。
然后确认控制器寄存器地址。如果这个 GPIO 组挂在某个总线上,而这个总线的时钟没开,那读写可能被静默丢弃(有些 SoC 会返回全 0 或全 1)。用md命令直接读寄存器,看值是否符合预期。
最后确认硬件。走线断了、焊接虚了、测试点接错,这些都遇到过。用万用表量引脚到测试点的通断,很快能排除。
5.2 SPL 和 U-Boot proper 表现不一致
这个现象是:SPL 阶段 GPIO 是好的,跳到 proper 之后就不对了;或者反过来。原因通常是两套配置体系不一致。
SPL 阶段往往用的是精简配置,走CONFIG_SPL_GPIO_SUPPORT,可能压根不解析设备树,用的是板级 C 代码里的硬编码。而 proper 阶段走完整驱动模型,从 dts 解析。两边不一致就会出现"同一块板子两个阶段表现不同"。
解决办法有两个方向:要么统一到设备树,SPL 也开设备树支持(CONFIG_SPL_OF_CONTROL),只是要控制 SPL 的 dts 体积;要么在 proper 早期重新配置一遍,把 SPL 的配置覆盖掉。我一般选后者,因为 SPL 空间有限,塞完整 dts 不现实。
具体做法是在board_init_f或者板级早期钩子里,把关键 GPIO 重新初始化一次,确保进入 proper 之后状态是明确的。
提示:如果 SPL 里拉了某个 GPIO,proper 启动过程中如果这个引脚的控制器被重新 probe 或者复位,引脚状态会被打回默认值。这种"状态丢失"特别隐蔽,需要在 proper 阶段重新拉一次。
5.3 引脚被别的功能占了
这个问题的迷惑性在于完全不报错。你在 dts 里声明了gpio1-13作为 GPIO,但另一个外设的 pinctrl 也声明了这个引脚,两个都 probe 成功,最后谁生效取决于 probe 顺序,表现为"时好时坏"。
定位方法是全局搜 dts 里的引脚名:
grep -rn "gpio1-13" arch/arm/dts/ board/把所有引用这个引脚的地方列出来,逐个确认哪个是真正需要的。同一个引脚被两处声明,本身就是设计问题,要么改硬件,要么改方案。
另一种情况是默认复用。很多引脚复位后默认是某个功能(比如调试串口),你如果不显式把它切成 GPIO,它就一直是那个功能。这种要靠读 IOMUX 寄存器的默认值来确认。
5.4 常见问题速查表
我把上面这些整理成一张表,出问题时按顺序对着查,能省不少时间。
| 现象 | 可能原因 | 快速验证方法 | 处理方式 |
|---|---|---|---|
gpio status -a列不出控制器 | 驱动没 probe | 看启动日志有无该控制器 probe 信息 | 检查时钟、电源、dts status |
| 写值成功但引脚不动 | 引脚复用未切到 GPIO | 读 IOMUX 寄存器功能位 | 补全 pinctrl 配置 |
| 写值成功但读回不一致 | 控制器时钟未开或地址错 | md直接读寄存器 | 检查总线时钟、dts 地址 |
| 电平极性完全相反 | GPIO_ACTIVE_LOW标错 | 对照原理图确认极性 | 修改 dts 标记 |
| 上电瞬间有毛刺 | 方向切换时的中间态 | 示波器看上电瞬间 | 先写锁存器再切方向,或外加电阻 |
| 输入读数随机 | 引脚浮空 | 读多次看是否变化 | 配置内部上下拉 |
| 电平摆幅不足 | 驱动能力档位过低 | 万用表测实际电压 | 提高 drive-strength |
| SPL 正常 proper 不正常 | 两阶段配置不一致 | 对比两阶段的配置代码 | 在 proper 早期重新初始化 |
| 时好时坏 | 引脚被多处声明 | 全局搜引脚名 | 消除冲突声明 |
gpio_request失败-EBUSY | 已被其他驱动占用 | 搜代码里的申请点 | 调整申请顺序或复用引脚 |
6. 几条踩坑经验
写到这里,技术点基本覆盖了,最后说几个不太好归类的经验,都是实际踩出来的。
第一,硬件设计阶段就要参与 GPIO 规划。我现在拿到新板子的原理图,第一件事就是把所有"需要软件控制"的引脚列一张表,标注每个脚的:复位默认状态、有效极性、是否需要上下拉、驱动能力要求、有没有复用冲突。这张表在 bring-up 阶段能救命。很多问题不是软件能解决的,比如复位默认电平就是错的、没有测试点、引脚被复用到了无法切换的功能上。
第二,务必留至少两个"调试专用"引脚。一个是启动进度指示(在几个关键节点翻转),一个是可自由控制的测试点。这两个脚在调时序问题时价值巨大。我一般会在board_init_f最开始就把它拉高,然后在几个关键节点翻转,用逻辑分析仪一抓,整个启动流程的时间分布一目了然,哪一段卡住了立刻能看出来。
第三,不要迷信"配置好了就不用管"。U-Boot 里有多个阶段,每个阶段可能重新初始化外设,你早期拉的 GPIO 状态随时可能被覆盖。对于"必须一直保持"的信号(比如电源使能),要么在最后阶段再确认一次,要么就用硬件自锁(比如加个上拉或者用带锁存的电源芯片)。
第四,日志要打得足够但别刷屏。GPIO 相关的错误日志我建议统一格式,带上"哪个设备、哪个属性、返回什么错误码"。这样出问题时 grep 一下就能定位。但同时要控制量,board_init_f里每个 GPIO 都打一条会淹没有用信息,只在失败和关键节点打就够了。
第五,遇到诡异问题时先怀疑自己算错了编号。我统计过自己的低级错误,编号算错占了将近一半。所以现在我养成了一个强迫症习惯:每次操作 GPIO 前,先把gpio status -a的输出贴到注释里,把实际用的编号和组内偏移都标清楚,代码里再写一遍换算过程。看着啰嗦,但能省下大量返工。
第六,设备树的改动一定要版本管理并且写注释。dts 里一行pinctrl-0 = <&foo>;,半年后你根本不记得为什么这么写。我的习惯是在每个自定义节点上面加注释,写清楚"这个脚控制什么、极性是什么、为什么这么配"。这在多人协作或者交接时特别有用。
最后补充一个我觉得挺实用的小技巧:如果怀疑某个引脚有问题,但又没有仪表,可以用软件办法做粗略验证——把引脚配成输入,然后外部用杜邦线手动接到高和低,读回来的值如果跟着变,说明引脚复用和控制器都是通的,问题在输出侧;如果读回来不变,那就是复用或者控制器的问题。这个土办法在没有设备的现场救过我好几次。