最近调试一块RK3288W的方案板遇到一个很典型的状况代码编译通过、UBoot也能正常跑起来但用万用表量两个需要内部上拉的GPIO引脚电压始终是0V。查来查去问题出在上拉寄存器根本没配对。RK3288W这颗SoC上跑Android 9.0UBoot阶段的GPIO上拉配置和内核里完全是两套逻辑很多刚转BSP的兄弟容易在这里卡住。这篇文章把RK3288W在Android 9.0的UBoot阶段配置双GPIO上拉的完整思路、可复制的代码、还有我实测中踩过的坑都整理出来。内容适合做方案定制、驱动移植、硬件调试的工程师参考也适合刚入门BSP想搞清楚GPIO上拉原理的同学。1. 想让上拉真正生效先搞清RK3288W的GPIO寄存器结构1.1 GPIO模块的基础组成RK3288W的GPIO控制器分成多个bank分别是GPIO0到GPIO8每个bank下有32个引脚按A/B/C/D四个小组划分每组8个引脚。所以一个引脚完整的叫法是GPIO0_A2GPIO3_B5这样的形式。第一次看这部分代码的人很容易把GPIO3_B5里的数字3理解成某个组号其实它表示的是第3个bankB表示组5是该组第6个引脚。每个bank有一组独立的寄存器用来做数据读写、方向控制、上下拉配置、中断管理等操作。在UBoot阶段我们最常接触的是这几类数据寄存器DR、方向寄存器DDR、外部端口寄存器EXT_PORT以及上下拉相关的PULL寄存器。DR负责写电平DDR决定引脚是输入还是输出EXT_PORT用于读取引脚实际电平PULL寄存器则控制内部上下拉电阻是否接入、以及接入的是上拉还是下拉。把这块的地址布局先说出来后续写代码时直接对照。RK3288W的GPIO0基地址是0xFF750000GPIO1到GPIO8依次是0xFF780000、0xFF790000、0xFF7A0000、0xFF7B0000、0xFF7C0000、0xFF7D0000、0xFF7E0000、0xFF7F0000。虽然RK3288W在封装上和RK3288有一些细节差异但GPIO控制器部分基本通用内核和UBoot的驱动也共用一套逻辑。这个地址信息可以在对应TRM或者SDK头文件里核对一遍不同分支的基地址宏定义位置略有差别但数值基本一致。1.2 上拉上下拉寄存器到底怎么控制引脚RK3288W的每个GPIO bank里都有两组与上下拉直接相关的寄存器一组叫PULL_EN上拉使能一组叫PULL_SEL上下拉方向选择它们在bank内的偏移一般是0x140和0x144具体以TRM为准。PULL_EN的每个bit对应bank里的一个引脚置1表示这个引脚启用内部上下拉电阻置0表示禁用。PULL_SEL同样是每个bit对应一个引脚置1表示接入的是上拉电阻置0表示接入的是下拉电阻。所以配一个引脚为内部上拉这个动作拆到寄存器层面就是两步先把PULL_EN对应的bit写成1再把PULL_SEL对应的bit写成1。如果只需要下拉就把SEL对应的bit写成0。如果想让引脚保持高阻浮动把EN写成0就行。很多人在这一步踩坑是因为只写了PULL_SEL没写PULL_EN或者反过来结果量下来引脚既不是稳定的高电平也不是稳定的低电平而是随外部电路飘。这里的电源域概念也要提一句。GPIO内部上拉电阻实际是接到该GPIO所在电源域的RK3288W的引脚分布在不同的电源域上比如VCCIO3、VCCIO1等。上拉后的电压等于对应电源域的电压不是固定的3.3V。如果某个引脚所在电源域在UBoot阶段还没供电上拉配置写进去也只是寄存器层面生效引脚上量不到预期电平。排查上拉无效的时候先把供电顺序捋一遍往往能直接找到答案。1.3 为什么必须在UBoot阶段处理双GPIO上拉既然内核里也能配pinctrl和gpio为什么很多方案还把上拉动作放在UBoot最直接的原因是时序。Android 9.0系统启动到内核完全起来中间有一个不算短的空窗期这期间如果有屏、触摸、按键、充电检测这类外设需要依赖特定电平工作UBoot不配好就会出现识别不到、误触发、甚至启动流程卡死的情况。另一个原因是UBoot阶段就要做硬件检测。比如HDMI的HPD脚热插拔信号在UBoot里必须能被识别否则没有external display引导的选项再比如USB OTG的ID脚、耳机检测的ACC_DET脚都是靠上拉/下拉来确定状态的。这类引脚往往成对出现一个负责中断检测一个负责复位或使能所以双GPIO上拉这个需求在方案定制里非常常见本质是在系统最早期把两个引脚的默认电平确定下来。还有个容易被忽略的点是UBoot里配好的状态到内核阶段不一定会被保留这件事后面第五节详细说。这里只需要记住一个原则UBoot的GPIO配置是给bootloader阶段用的不是给内核用的搞清楚这一点才不会在定位问题时分不清是谁把电平改了。2. 在UBoot源码里找到正确的注入点2.1 Android 9.0对应SDK的UBoot代码结构以Android 9.0配套的UBoot为例代码一般基于2014.10或2017.09分支改造。整体目录结构里板级相关代码通常在board/目录下RK平台公共代码在arch/arm/mach-rockchip/目录下GPIO驱动在drivers/gpio/目录下。你进去之后先搜一下board_late_init这个函数它是板级初始化的常用挂载点。启动流程中board_init负责早期硬件初始化board_late_init在UBoot引导流程较晚阶段执行此时大部分外设驱动已经就绪适合做一些GPIO检测、开机按键判断、LCD背光点亮这类动作。很多方案把自定义初始化都放在这里包括开机音量键检测、充电模式判定、屏幕ID识别等。推荐的做法是新建一个自己的板级函数比如board_gpio_pull_init然后在board_late_init里调用它不要直接改大段的公共初始化代码方便后续维护和对比版本差异。如果你拿到的SDK里找不到标准的名字也可以搜索rk_board_late_init部分版本会用这个名字。总之思路一样找到板级初始化的晚阶段入口把我们待会要写的双GPIO配置函数挂进去。挂在太早的位置gpio驱动和时钟可能没初始化完成挂太晚UBoot里前置的检测流程已经跑完配置意义就打了折扣。2.2 GPIO编号换算公式与常见笔误在UBoot代码里GPIO编号不是直接写GPIO0_A2这个字符串而是要换算成整数。RK平台的通用换算是bank编号乘以32加上所在组的索引A0、B1、C2、D3乘以8再加上组内引脚序号。以GPIO3_B5为例bank是3B组索引是1组内序号是5得到的整数是332 18 5 109。这个换算搞错的人非常多常见的笔误是把B当成2、把序号从1开始数。GPIO3_B5如果按错误算法算成332281113那就配到了GPIO3_C1上去了。写代码前先把要用的几个脚全部按公式列出来打印一次比在代码里猜要靠谱得多。RK平台UBoot和内核的GPIO编号算法是一致的你在内核dts里看到gpio3_b5这样的名字也能用同一套公式转换方便两边对照。用一个常用场景示范GPIO0_A2是编号0320822GPIO3_B5是编号109两个脚一个在bank0一个在bank3。如果两个脚恰好都在同一个bank比如GPIO1_A3和GPIO1_C7那换算后编号分别是35和55寄存器基地址完全一样只是操作不同的bit。这个差异在写通用函数时就要考虑到同一个bank直接复用基地址跨bank要切换基地址。2.3 常用GPIO API盘点UBoot里操作GPIO有一套通用接口包括gpio_request、gpio_direction_input、gpio_direction_output、gpio_set_value这些函数在include/asm-generic/gpio.h这类公共头文件有声明具体实现由各家SoC的驱动提供。RK平台的GPIO驱动文件在drivers/gpio/下面通常是rk_gpio.c之类的名字里面能看到底层寄存器操作方法。与上下拉直接相关的是gpio_set_pull这个接口或者类似名字的封装。它对上层隐藏了PULL_EN和PULL_SEL两组寄存器的细节只要传入GPIO编号和上拉/下拉/悬空枚举就行。不过这个接口是否可用取决于驱动是否在UBoot早期完成注册。不少人早期直接在board_init里调它结果返回错误或者没反应就是因为驱动初始化还没完成。通用接口的好处是语义清晰坏处是依赖系统的gpio框架排查问题时要在框架和寄存器之间来回跳。除了gpio框架接口RK平台的代码里还经常看到rk_mux、rk_iomux这类复用配置接口它们负责把引脚从GPIO功能切换成UART、I2C、PWM等专用功能或者相反。配置上拉之前如果这个脚默认被复用成其他功能光写PULL寄存器是不够的还要先把iomux切到GPIO模式。这个非常关键第五节第一个坑就是它。3. 双GPIO上拉完整写法与可复制代码3.1 方式A直接寄存器读写推荐在UBoot里用直接操作寄存器的方式在UBoot阶段通常更可靠因为不依赖gpio框架的初始化时序。核心思路就是前文说的把目标bank的PULL_EN和PULL_SEL对应bit写1。下面这段代码基于RK3288W的GPIO组织方式直接可参考。#include common.h #include asm/io.h #define RK3288_GPIO0_BASE 0xFF750000 #define RK3288_GPIO1_BASE 0xFF780000 #define RK3288_GPIO2_BASE 0xFF790000 #define RK3288_GPIO3_BASE 0xFF7A0000 #define RK3288_GPIO4_BASE 0xFF7B0000 #define RK3288_GPIO5_BASE 0xFF7C0000 #define RK3288_GPIO6_BASE 0xFF7D0000 #define RK3288_GPIO7_BASE 0xFF7E0000 #define RK3288_GPIO8_BASE 0xFF7F0000 #define RK3288_GPIO_PULL_EN 0x140 #define RK3288_GPIO_PULL_SEL 0x144 static unsigned long rk3288_gpio_base(int bank) { static const unsigned long base[] { RK3288_GPIO0_BASE, RK3288_GPIO1_BASE, RK3288_GPIO2_BASE, RK3288_GPIO3_BASE, RK3288_GPIO4_BASE, RK3288_GPIO5_BASE, RK3288_GPIO6_BASE, RK3288_GPIO7_BASE, RK3288_GPIO8_BASE, }; return base[bank]; } static void rk3288_gpio_pull_up(int gpio) { unsigned int bank gpio / 32; unsigned int bit gpio % 32; unsigned long base rk3288_gpio_base(bank); unsigned int val; /* 使能内部上拉电阻 */ val readl(base RK3288_GPIO_PULL_EN); val | (1 bit); writel(val, base RK3288_GPIO_PULL_EN); /* 选择上拉方向 */ val readl(base RK3288_GPIO_PULL_SEL); val | (1 bit); writel(val, base RK3288_GPIO_PULL_SEL); }这段代码的关键在于每个操作都先readl再writel也就是读改写。原因很简单一个bank的PULL_EN寄存器是32位的里面每1位控制一个引脚如果直接对整个寄存器赋值会把同bank其他引脚的配置全部冲掉。我刚接触RK平台时图省事直接writel(1bit)结果同bank另一个脚的外设配置被清掉排查了大半天。所以这个先读后改再写的习惯一定要养成。3.2 方式BSDK封装API调用如果你的UBoot版本里gpio框架初始化完成得比较早或者你的代码本来就挂在board_late_init这种靠后的位置直接用SDK封装API会更省事。大致的写法是#include asm/gpio.h static void board_gpio_pull_init(void) { gpio_request(2, gpio0_a2); gpio_request(109, gpio3_b5); gpio_direction_input(2); gpio_direction_input(109); gpio_set_pull(2, GPIO_PULL_UP); gpio_set_pull(109, GPIO_PULL_UP); }gpio_request用来申请引脚让系统知道这个脚被谁占用了gpio_direction_input把方向设为输入。这里要特别提醒方向设置和上拉设置是两码事不能因为配了上拉就默认方向是输入。引脚可以配置成输出带上拉也可以配置成输入带上拉组合由硬件需求决定不是自动关联的。如果SDK里gpio_set_pull的枚举名不同比如GPIO_PULL_NONE/GPIO_PULL_DOWN/GPIO_PULL_UP就去头文件里搜一下枚举本质都是对PULL_EN和PULL_SEL的寄存器操作。这个方式的优点是可读性好代码量少前提是你确认这个入口点gpio框架已经就绪。判断方法很简单如果调用后返回值一直是负数或者引脚状态没变化就要怀疑是不是框架没初始化这时切回方式A直读寄存器验证是最快的排查手段。3.3 双GPIO组合配置实例TP检测脚和I2C场景实际项目中两个GPIO往往不是各自独立上拉这么简单。以经典的触摸屏INT和RST组合为例INT脚是开漏输出必须外部或内部上拉RST脚在系统启动阶段要保持复位状态需要下拉。在UBoot里要同时把INT做成上拉、RST做成下拉。用上面的rk3288_gpio_pull_up函数改一下pull方向写一个支持上拉和下拉的通用版本static void rk3288_gpio_set_pull(int gpio, int pull_up) { unsigned int bank gpio / 32; unsigned int bit gpio % 32; unsigned long base rk3288_gpio_base(bank); unsigned int val; val readl(base RK3288_GPIO_PULL_EN); if (pull_up) { val | (1 bit); } else { val ~(1 bit); } writel(val, base RK3288_GPIO_PULL_EN); val readl(base RK3288_GPIO_PULL_SEL); if (pull_up) { val | (1 bit); } else { val ~(1 bit); } writel(val, base RK3288_GPIO_PULL_SEL); } static void board_gpio_pull_init(void) { /* TP_INT 上拉TP_RST 下拉 */ rk3288_gpio_set_pull(2, 1); rk3288_gpio_set_pull(35, 0); }这里用了两个不同GPIO编号一个上拉一个下拉演示了双GPIO的最常见组合。另一个典型场景是I2C总线的SCL和SDA两个引脚都配上拉这时你只需要把上面代码里的两个GPIO都传pull_up1。不过要说明白RK3288W内部上拉的阻值一般在几十千欧量级用于检测电平这类场景足够但I2C在高速模式下往往需要2.2k到4.7k欧的外部上拉才能保证沿质量。UBoot阶段用内部上拉只是让总线空闲电平不悬浮量产硬件设计还是建议按I2C规范预留外部上拉电阻位置。在board_late_init里的挂载代码大概是int board_late_init(void) { board_gpio_pull_init(); /* 其他板级初始化 */ return 0; }如果板级文件里默认有别的逻辑注意不要直接覆盖原函数内容而是尽量在原有调试代码后面追加一段对board_gpio_pull_init的调用防止破坏现有启动流程。3.4 两种方式怎么选直接寄存器方式和SDK API方式的取舍本质是在依赖完整框架和减少依赖之间取平衡。GPIO框架在UBoot里往往只支持基础的gpio_request/gpio_direction_*gpio_set_pull在某些老版本里甚至没有实现或者实现里有bug。如果你在SDK里搜gpio_set_pull搜不到稳定实现直接寄存器方式就是唯一可靠的选择。我自己在多个平台上验证下来的建议是板级自定义初始化函数里优先用直接寄存器方式因为它稳定、可控、不随框架改动而失效但如果项目代码规范要求统一走gpio框架那就用SDK API并做好返回值判断。两种方式的底层寄存器最终操作是一致的差异只是封装程度。用表格把对比列出来对比项直接寄存器方式SDK API方式依赖条件只需GPIO时钟已使能依赖gpio框架初始化完成可读性偏低要看寄存器手册偏高函数名即语义可控性高bit级操作中受驱动实现限制移植成本低改个基地址就行中受SDK版本影响适用阶段早期初始化、框架未ready框架ready后的中后期4. 编译自检与硬件测量技巧4.1 编译后如何确认配置已经生效代码写完先别急着烧机先在UBoot里加一行调试打印把要配置的GPIO编号和寄存器写入值打印出来。比如在rk3288_gpio_set_pull里加printf把bank、bit、base、写入值都打出来然后编译烧录。串口日志里如果能看到正确的编号和寄存器值说明代码路径执行到了剩下就是硬件层面的验证。编译的时候要注意UBoot的编译命令在不同SDK里有细微差别Android 9.0配套的SDK一般通过打包脚本直接编UBoot也可以进入u-boot目录单独编。我常用的方式是make distclean后重新配置板级config再执行make。如果你对当前SDK的UBoot编译流程不熟先看顶层脚本或者编译文档别盲目改环境变量。编译报错大多是头文件路径不对或者函数未声明对症处理即可。日志打印代码如下参考printf(gpio%d bit%d pull_en0x%x pull_sel0x%x\n, bank, bit, readl(base RK3288_GPIO_PULL_EN), readl(base RK3288_GPIO_PULL_SEL));看到日志里PULL_EN和PULL_SEL的对应位置确实是1寄存器配置这一层就算过了。4.2 万用表与示波器测量方法寄存器配好之后硬件验证是另一道关卡。用万用表量引脚对地电压是最直接的方式。把万用表拨到直流电压档黑表笔接GND红表笔接被测GPIO。配置为内部上拉的引脚在外部没有其他拉低源的情况下电压应该等于所在电源域的电压。比如VCCIO3是3.3V量出来的值应该在3.3V附近。如果量到0V或者电压很低先看外部电路有没有把它拉低比如连到了某个IC的开漏输出。这里有个容易误判的点有些引脚在UBoot阶段虽然配了上拉但同时被复用成其他功能等量的时候引脚可能已经不在GPIO模式下。所以测量前最好先确认这个时间点UBoot跑到了哪个阶段以及这个引脚当前处于什么复用状态。用示波器看波形的话重点看电平是否稳定有没有明显毛刺。内部上拉的高电平一般比较平直如果波形上出现周期性下拉多半是后级IC在翻转输出而不是上拉没配好。电阻档直接量引脚对地在上电状态下是危险的容易把IC内部结构判断错不建议这么做。我早期就吃过亏用电阻档量GPIO量出来一个奇怪阻值既不像上拉也不像下拉实际上测量方式本身就不对。正确姿势是断电后用二极管档或电阻档而且最好在原理图确认没有其他并联通路后再判断。5. 高频踩坑场景与排查速查表5.1 现象一代码执行了引脚还是低电平这是双GPIO上拉配置里最常见的坑。代码跑到了寄存器也打印对了但引脚量下来仍然不是高电平。第一怀疑对象就是引脚复用。RK3288W的引脚在复位后不一定是GPIO功能很多引脚默认被复用成其他功能。你在GPIO控制器里写PULL寄存器但引脚实际工作在UART或者PWM功能上上拉配置自然就表现不出来。排查方法是把复用的寄存器也拉出来看。RK平台配置引脚功能的地方在GRF通用寄存器文件里不同引脚对应不同offsetSDK里搜iomux或者rk_mux就能找到相关配置函数。确认当前引脚已经被配成GPIO功能之后再回头看PULL寄存器。还有可能是引脚所接外部电路有强下拉比如接了一根对地低阻的走线这种情况下内部上拉几十千欧根本拉不动。判断办法是先断开外部负载再量如果断开后电平正常问题在外围电路。5.2 现象二同bank相邻引脚被误伤在UBoot里操作GPIO寄存器最容易写的低级错误就是把writel(1bit)当成只改这一个bit结果整个bank的所有引脚配置全部被覆盖。症状是原本正常的某个外设突然异常而且往往不是自己刚配的那个脚而是同bank里看起来无关的另一个脚。这个问题的根因就是没有做read-modify-write。正确写法永远是readl - 修改bit - writel。如果你在代码里用了val | (1 bit)这样的写法基本就不会出这类问题。另外当一个bank里有多个引脚需要配置时尽量把操作合并到同一个val变量里减少readl和writel次数既降低出错概率也减少对总线的影响。5.3 现象三内核启动后配置被重置UBoot阶段千辛万苦配好的上拉等Android 9.0系统起来后发现又变了。这不是UBoot代码的问题是内核的pinctrl机制在启动时会重新加载设备树里的pin controller配置把引脚的状态重新刷一遍。内核dts里如果定义了这个引脚是某个功能或者没有配置上拉内核启动后就会覆盖UBoot的设置。如果你确实需要内核阶段也保持上拉正确做法是在设备树的pinctrl节点里同时配置而不是只改UBoot。反过来如果只想在UBoot阶段有效内核阶段由驱动自己去管理那UBoot里配的临时状态被覆盖是正常现象。理解了这个机制就不会在做方案时把两个阶段的配置混在一起排查。5.4 排查速查表症状可能原因解决方向寄存器配置正确但引脚低电平引脚被复用成其他功能检查iomux/GRF配置切回GPIO寄存器配置正确但引脚电压不足外部负载下拉过强断开外部负载验证同bank其他引脚异常写寄存器时覆盖整bank改为read-modify-writegpio_set_pull返回值错误gpio框架未ready用直接寄存器方式上拉后波形有毛刺内部上拉阻值偏大增加外部上拉电阻内核启动后状态变了内核pinctrl覆盖同步修改dts配置引脚电压等于0.8V附近电源域未完全上电检查供电时序复位瞬间有异常电平UBoot配置时机太晚提前到更早的init入口调试RK3288W这类RK平台的UBoot我的感受是遇到GPIO问题先别急着怀疑代码按寄存器-复用-外部电路三层顺序排查效率会高很多。双GPIO上拉这种需求本身不复杂但它藏在启动早期、又容易和内核pinctrl纠缠所以显得棘手。最后分享一个习惯我习惯在板级文件里把需要初始化的GPIO统一放到一个表格里用循环执行后续换脚、增脚只改数据不改逻辑维护起来省心不少。希望这篇对正在调RK3288W上拉的朋友有参考价值。