☰
gpio-aw9523驱动详解:I2C扩展GPIO的寄存器配置与Linux实现
2026/10/5 17:41:06 网站建设 项目流程

简介:面向嵌入式与物联网开发者的AW9523 GPIO扩展芯片驱动源码,基于I2C两线制串行协议,解决微控制器GPIO引脚数量不足、需外扩输入输出接口的常见问题。压缩包内共1个c源码文件,大小仅3KB,但代码内容完整,涵盖I2C控制器初始化、从机地址配置、寄存器映射与读写、GPIO方向/电平控制、中断触发及超时检测等关键模块,并考虑了与操作系统设备模型对接的方式。已有244人学习,该驱动既可直接移植到Linux或RTOS环境作为外设驱动参考,也可结合AW9523数据手册逐行研读,帮助嵌入式开发者深入理解I2C通信时序、寄存器位操作、中断服务程序编写和系统调用接口封装等底层技能。无论用于快速实现功能验证,还是作为学习I2C驱动框架的教学样例,都能有效缩短硬件驱动开发与调试的时间,适合中初级嵌入式工程师及物联网设备开发者参考学习。

1. gpio-aw9523 驱动源码包:I2C 扩展 GPIO 的开箱即用参考

MCU 引脚不够用是嵌入式开发里最现实的问题:传感器占两根、按键占四根、LED 再占三根,一个 48 引脚的芯片就捉襟见肘了。gpio-aw9523.zip 里那份 gpio-aw9523.c,就是专为解决这类问题的——用两线 I2C 总线挂一颗 AW9523,把 GPIO 扩展出 16 路,驱动代码用 C/C++ 写成,能直接看到 I2C 初始化、寄存器读写、方向配置、中断回调这些完整链路。不像某些芯片厂商的例程只给个读写函数敷衍了事,这份源码把从总线探测到用户态 ioctl 的路径都串了起来,适合三种人:一是板子引脚下个月就要投产的底层工程师,照它改个地址和时钟就能跑;二是第一次接触 GPIO 扩展芯片、想搞懂寄存器怎么编排的新手;三是做物联网网关、工控板这类需要大量数字 IO 的老手,拿它当模板快速落地。下面从芯片底层开始拆,再落到编译和排错。

2. AW9523 与 I2C 扩展 GPIO:寄存器编排和通信协议先吃透

2.1 AW9523 寄存器地图:0x40 地址背后藏着 16 路 IO 的编排逻辑

AW9523 的 I2C 从机地址是 7 位的 0x40,换算成 8 位读写地址就是 0x80 写、0x81 读。这颗芯片的一大特点是 16 路 GPIO 分成 P0 和 P1 两组,每组 8 位。Gpio-aw9523.c 里所有的操作,本质上都是对着下面这批寄存器做读写:

寄存器地址功能说明复位值
REG_IN_P00x00P0 输入电平,只读外部电平
REG_IN_P10x01P1 输入电平,只读外部电平
REG_OUT_P00x02P0 输出电平,写 1 拉高0x00
REG_OUT_P10x03P1 输出电平,写 1 拉高0x00
REG_DIR_P00x04P0 方向,1=输入,0=输出0xFF
REG_DIR_P10x05P1 方向,1=输入,0=输出0xFF
REG_MODE_P00x06P0 模式,1=GPIO,0=LED 模式0xFF
REG_MODE_P10x07P1 模式,1=GPIO,0=LED 模式0xFF
REG_INT_P00x10P0 中断状态,读后清零0x00
REG_INT_P10x11P1 中断状态,读后清零0x00
REG_ID0x12芯片 ID,读回 0x230x23

方向寄存器默认全是 0xFF,也就是上电后 16 个引脚全是高阻输入,这点必须记住——很多人拿到新片子直接写输出电平,发现没反应,就是忘了先把方向位改成输出。模式寄存器决定了引脚是普通 GPIO 还是 LED 恒流模式,LED 模式下芯片内部会限流,可以直接推 LED 不用串电阻,但此时读输入是无效的。

2.2 I2C 通信与驱动框架选型:Linux gpiolib 与裸机两条路

I2C 本身是两线制,SCL 和 SDA 加上拉电阻,时钟频率 AW9523 最高支持 400kHz 快速模式。驱动代码里真正要关心的不是 I2C 协议本身,而是挂在哪个框架下。Linux 下最规范的做法是写成 i2c client 驱动,通过i2c_driver结构体注册,再调用gpiochip_add_data注册成 gpiolib 的 gpiochip,这样应用层就能用/sys/class/gpio或者libgpiod操作扩展出来的引脚,不用自己写字符设备。裸机方案则是把 i2c 读写封装成platform_i2c_write_reg/platform_i2c_read_reg两个函数,往 STM32、ESP32 上移植时把底层换成对应 HAL 库即可。gpio-aw9523.c 同时给这两种路径留了接口空位,我一般建议先在 Linux 用户态用i2c-tools把寄存器读写摸清楚,再决定要不要下沉到内核模块。如果是做量产固件,裸机驱动反而更可控,没有内核调度延迟,中断响应更及时。

3. gpio-aw9523.c 驱动代码拆解:初始化、读写、中断三条主路径

3.1 初始化与设备探测:从 i2c_driver 注册到 gpiochip 挂载

源码里最值得抄的一段是初始化流程。常见做法是先用i2c_master_send发一个字节的地址去探测设备,读回 REG_ID 判断 0x23,然后再往下走。下面是我从工程里抽出来的核心骨架:

static int aw9523_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct aw9523_chip *chip; int ret; u8 id_val; chip = devm_kzalloc(&client->dev, sizeof(*chip), GFP_KERNEL); if (!chip) return -ENOMEM; chip->client = client; i2c_set_clientdata(client, chip); /* 读取芯片 ID,确认通信链路正常 */ ret = aw9523_read_reg(client, REG_ID, &id_val); if (ret < 0 || id_val != AW9523_ID_EXPECT) { dev_err(&client->dev, "AW9523 ID read failed: 0x%02x\n", id_val); return -ENODEV; } /* 全部引脚默认设为 GPIO 模式,避免 LED 模式带来的读输入异常 */ aw9523_write_reg(client, REG_MODE_P0, 0xFF); aw9523_write_reg(client, REG_MODE_P1, 0xFF); /* 方向寄存器初始化为全输入,安全默认 */ aw9523_write_reg(client, REG_DIR_P0, 0xFF); aw9523_write_reg(client, REG_DIR_P1, 0xFF); /* 注册 gpiolib chip,应用层可通过 /sys/class/gpio 访问 */ ret = gpiochip_add_data(&chip->gc, chip); if (ret) { dev_err(&client->dev, "gpiochip_add_data failed: %d\n", ret); return ret; } dev_info(&client->dev, "AW9523 GPIO expander probed OK\n"); return 0; }

读写寄存器函数aw9523_read_reg/aw9523_write_reg内部走的是i2c_transfer,这点注意:I2C 读寄存器是先写寄存器地址再读数据,一次 complete 要发两个 message,一个写地址、一个读数据。很多新手直接调i2c_master_recv去收,收回来的是从设备当前输出缓冲的内容,不是目标寄存器。参数上,REG_MODE_P0写 0xFF 是确保 16 路都在 GPIO 模式,如果你要用 LED 恒流驱动,这里要改成 0x00。方向默认全输入是最稳妥的上电姿态,避免意外拉低外部电路。

3.2 GPIO 读写与中断回调:电平翻转和边沿触发的实现细节

GPIO 读写部分,输出口要把方向寄存器对应位置 0,再写输出寄存器。输入口则是设置方向为 1 后,读输入寄存器。这里容易踩的坑是:P0 和 P1 的方向寄存器是分开的,一个引脚一个 bit,但很多人图省事把 0x04 当总方向寄存器整字节写,结果操作 P1 时把 P0 的方向全带偏了。中断设计上,AW9523 的 INT 引脚是开漏输出,多个扩展芯片可以共用一个中断线,靠读 REG_INT_P0/P1 判断具体是哪个引脚触发。中断触发方式在芯片里是默认边沿敏感的,电平变化产生中断,但中断状态寄存器里的位需要软件写 0 清除,或者读寄存器后自动清零——AW9523 是读后清零,所以中断服务函数里必须先读再处理业务,否则临界区之外的第二次触发直接丢。

static irqreturn_t aw9523_irq_handler(int irq, void *data) { struct aw9523_chip *chip = data; u8 int_p0, int_p1; int pin; /* 读中断状态寄存器,同时完成自动清零 */ aw9523_read_reg(chip->client, REG_INT_P0, &int_p0); aw9523_read_reg(chip->client, REG_INT_P1, &int_p1); for (pin = 0; pin < 8; pin++) { if (int_p0 & BIT(pin)) handle_nested_irq(irq_find_mapping(chip->gc.irq.domain, pin)); } for (pin = 0; pin < 8; pin++) { if (int_p1 & BIT(pin)) handle_nested_irq(irq_find_mapping(chip->gc.irq.domain, pin + 8)); } return IRQ_HANDLED; }

这段逻辑说明:中断处理前半段无条件读寄存器把状态拿下来,后半段才做派发,顺序反了会有两个问题——读晚了中断标志被新触发覆盖,或者读早了新旧中断混在一起。参数上BIT(pin)的 pin 范围,P0 对应 0~7,P1 对应 8~15,映射到 gpiolib 的全局编号时要注意偏移。

3.3 系统集成两种方式:内核 gpiolib 与裸机函数指针

内核方式集成是用gpiochip_add_data把芯片挂进系统,然后struct gpio_chip里实现direction_input、direction_output、get、set四个回调,这套接口对应用层来说就是标准/sys/class/gpio/gpiochipXXX目录,或者用gpiod_get在设备树里直接绑定。裸机方式则是把这些回调抽象成结构体函数指针,你在board_setup里填入具体平台的 I2C 读写函数。参数上唯一的区别是:内核方式下 I2C 传输可能睡眠,所以如果芯片接了中断,不能在线程化 irq 里直接调i2c_transfer,否则会触发 BUG 报“ sleeping function called from invalid context ”。Gpio-aw9523.c 里如果看到它用了request_threaded_irq包一层,那就是在规避这个约束。我通常建议量产固件直接裸机移植,省掉内核框架那层间接调用,代码更直白。

4. 编译与移植:把驱动跑起来的三种落地路径

4.1 内核模块编译:Makefile、设备树与模块加载

把 gpio-aw9523.c 编成内核模块,最省事的方式是写一个外置模块 Makefile,配合内核源码树里的 KDIR 变量。下面是我常用的一套:

KDIR ?= /lib/modules/$(shell uname -r)/build obj-m += gpio-aw9523.o all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean install: $(MAKE) -C $(KDIR) M=$(PWD) modules_install

编译前要确认目标内核开启了 CONFIG_I2C 和 CONFIG_GPIOLIB,这两个没开,代码写再好也编不过。设备树里需要加一个 i2c 子节点,指定地址和中断号:

&i2c1 { aw9523@40 { compatible = "awinic,aw9523"; reg = <0x40>; interrupt-parent = <&gpio0>; interrupts = <23 IRQ_TYPE_EDGE_FALLING>; gpio-controller; #gpio-cells = <2>; }; };

reg = <0x40>是 7 位 I2C 地址,#gpio-cells = <2>表示引用时第一个参数是引脚号、第二个是标志位。加载顺序是modprobe i2c-dev、insmod gpio-aw9523.ko,然后看dmesg有没有 “AW9523 GPIO expander probed OK”。如果一直没 probe,先查设备树 compatible 是否和驱动里of_match_table完全一致,再查 I2C 总线编号是否对得上。

4.2 裸机移植:底层 I2C 读写函数的接口适配

裸机平台移植最核心的是填充平台 I2C 钩子。Gpio-aw9523.c 里底层读写常用的封装是这样:

static int aw9523_i2c_write_reg(struct aw9523_chip *chip, u8 reg, u8 val) { u8 buf[2] = { reg, val }; return chip->platform_write(chip->bus_handle, AW9523_ADDR, buf, 2); } static int aw9523_i2c_read_reg(struct aw9523_chip *chip, u8 reg, u8 *val) { int ret; ret = chip->platform_write(chip->bus_handle, AW9523_ADDR, &reg, 1); if (ret) return ret; return chip->platform_read(chip->bus_handle, AW9523_ADDR, val, 1); }

参数说明:AW9523_ADDR是 0x40 左移一位后的 8 位地址 0x80,写寄存器时把寄存器地址和值一起发给从机;读寄存器时先发寄存器地址,再发一个重复起始信号读一个字节。芯片驱动不关心底层是 I2C 控制器还是软件模拟,只要platform_write/platform_read返回非零代表失败即可。我在 STM32 上移植时,直接把chip->platform_write换成 HAL 的HAL_I2C_Master_Transmit,参数顺序改成(hi2c, AW9523_ADDR<<1, buf, 2, 100),三处替换就完事。ESP32 则换成i2c_master_write_to_device,注意地址不要再次移位。

4.3 用户态验证与 VSCode 调试:不等内核版本直接摸寄存器

如果不想编内核模块,用户态也能验证这包代码的核心逻辑。用 i2c-tools 直接测:

i2cdetect -y 1 i2cget -y 1 0x40 0x12 i2cset -y 1 0x40 0x04 0x00 i2cset -y 1 0x40 0x02 0xFF

第一行扫描总线上有哪些设备,第二行读 REG_ID 应该看到 0x23,第三行把 P0 方向全设为输出,第四行把 P0 全部拉高。这三条命令跑通,说明芯片链路没问题,代码里的问题就被缩小到驱动框架适配上了。调试环境我习惯用 VSCode 配置 C/C++ 环境,配好 launch.json 挂在 QEMU 或者 JTAG 上,单步看i2c_transfer返回值和寄存器内容;裸机则开一个printf串口重定向,在aw9523_read_reg里打断点观察 buf 内容。这比反复烧固件看现象快得多。C 语言的优势在这个场景体现得很明显:没有 C++ 构造析构的顺带开销,一个结构体加函数指针就完成了设备抽象。

5. 避坑与排查:AW9523 驱动最常见的五个翻车点

5.1 现象:读 ID 永远返回 0xFF 或 0x00

原因:I2C 地址搞错了。很多人把数据手册里的 0x40 直接当 8 位地址传给i2c_master_send,结果地址变成了 0x80,从机不响应。AW9523 的 7 位地址是 0x40,但在 Linux i2c-dev 里ioctl的 addr 参数要左移一位变成 0x80,裸机 HAL 里则要看 API 是否已经帮你移位。解决:统一换算规则——凡是代码里出现(AW9523_ADDR << 1),就说明 API 接收的是未移位地址;凡是直接写0x80,说明 API 自己会处理。不要混用,否则换平台必翻车。

5.2 现象:写输出电平后引脚电压纹丝不动

原因:方向寄存器没配置成输出。AW9523 复位后 REG_DIR_P0/P1 全是 0xFF,所有引脚是高阻输入,这时候写 REG_OUT_P0 的值会被芯片忽略。解决:先写方向寄存器再写输出寄存器,顺序必须保证。

aw9523_write_reg(chip, REG_DIR_P0, 0x00); /* P0 全部输出 */ aw9523_write_reg(chip, REG_OUT_P0, 0x55); /* 再设置电平 */

写输出的函数里最好内置一个方向检查,避免调用顺序出错。我在给产线治具写测试固件时就吃过这个亏,烧了三个板子才意识到是复位后默认输入的问题。

5.3 现象:中断触发一次后再也不触发

原因:中断状态寄存器没清零,或者中断服务函数里读寄存器时机不对。AW9523 的中断状态是读后自动清零,但如果你的 ISR 先处理业务逻辑再读状态寄存器,芯片在此期间又产生了新中断,状态寄存器里的值已经是新旧混合。解决:ISR 入口第一件事就是把 REG_INT_P0/P1 读出来保存,然后再做业务分发。

5.4 现象:引脚配置成输入后,读到的电平始终是 0

原因:芯片处于 LED 模式。REG_MODE_P0/P1 默认是 0xFF(GPIO 模式),但如果初始化时图省事没写这个寄存器,而芯片之前被别的代码配置成了 LED 模式(写 0x00),LED 模式下引脚的输入缓冲是关闭的。解决:probe 函数里强制写一次 REG_MODE_P0=0xFF、REG_MODE_P1=0xFF。

5.5 现象:编译报错implicit declaration of function i2c_transfer

原因:内核模块缺少头文件包含,或者 I2C 框架版本不匹配。解决:文件头补齐#include <linux/i2c.h>,如果用了gpiochip_add_data还要包含<linux/gpio/driver.h>。另外老内核里gpiochip_add_data可能不存在,只有gpiochip_add,需要按内核版本条件编译:

#if LINUX_VERSION_CODE < KERNEL_VERSION(4, 5, 0) ret = gpiochip_add(&chip->gc); #else ret = gpiochip_add_data(&chip->gc, chip); #endif

这个坑在把源码从新内核往老内核移植时必现,提前加宏判断比事后打补丁省事。

6. 进阶实战:用 AW9523 同时做按键扫描和 LED 呼吸

AW9523 一个很有价值的特性是 P0 和 P1 可以分别配置成 GPIO 和 LED 模式,这意味着同一颗芯片既能读按键又能驱动 LED,不需要额外扩展。做法是把 P0 设为 GPIO 输入接按键矩阵,P1 设为 LED 模式接 8 个 LED,LED 模式下芯片内置恒流源,每个灯的亮度由 REG_LED_P1 的 8 位 PWM 值控制,范围 0~255,不需要外部限流电阻。按键扫描用轮询还是中断取决于系统负载:低功耗场景把 AW9523 的 INT 引脚接到 MCU 的 EXTI,按下按键触发中断,ISR 里读 REG_INT_P0 定位到具体引脚;高实时性场景则建议 5ms 轮询一次 P0 输入寄存器,加 20ms 消抖。

/* LED 模式下设置 P1 第 0 脚亮度为 128,半亮 */ aw9523_write_reg(chip, REG_MODE_P1, 0x00); /* P1 切到 LED 模式 */ aw9523_write_reg(chip, REG_LED_P1, 0x01); /* 选中第 0 脚 */ aw9523_write_reg(chip, REG_LED_PWM, 0x80); /* 亮度 128 / 255 */

第一行把 P1 模式切到 0,第三行写的是全局 PWM 寄存器,它影响当前选中的那一脚,所以第二行必须先选中目标引脚。做呼吸灯效果就是循环写 REG_LED_PWM 从 0 加到 255 再减回来,间隔 10ms,肉眼看到的就是平滑呼吸。按键部分我习惯把 P0 的四根线接一个 4x1 矩阵,用 P0 的低四位读,高四位留作输出轮询扫描列线,这样 8 个引脚能扫出 16 个按键。整套逻辑用 gpio-aw9523.c 的寄存器读写函数就能完成,不需要额外驱动框架。

从那以后我每次拿到一块新的 IO 扩展芯片,都会强制自己先用手头的 i2c-tools 把寄存器过一遍,再写代码——芯片手册里的默认值和复位状态永远是第一个要确认的东西。这包源码的价值不在于能直接编译进你的内核,而在于它把 I2C 扩展 GPIO 的注册、读写、中断、错误处理这几段必经之路都走了一遍,你照着改比从零看数据手册省半天时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询