ftdi_sio应用实战笔记 4 - I2C设备驱动开发与调试
1. 环境准备与设备发现
开始玩转FTDI的I2C功能前,得先把环境搭好。我习惯从安装基础依赖开始,毕竟没有这些工具啥也干不了。在Ubuntu系统下,打开终端输入:
sudo apt-get install libusb-1.0-0-dev libftdi1-dev
这两个库是操作FTDI设备的基石,libusb负责底层USB通信,libftdi则提供了对FTDI芯片的直接支持。装好后别忘了接上你的FTDI设备,我用的是FT4232H模块,插上USB线后系统通常会自动识别。
接着就要找设备了,Linux系统下一切皆文件,FTDI设备会被映射到/sys/bus/usb/devices/目录下。这里有个实用技巧:用lsusb命令快速确认设备是否被识别:
lsusb | grep FTDI
如果看到类似ID 0403:6011 Future Technology Devices International, Ltd的输出,说明设备已经被系统认到了。接下来要深入系统文件树寻找具体的I2C设备节点,这个过程就像在迷宫里寻宝一样有趣。
在/sys/bus/usb/devices/目录下,每个USB设备都有一个以总线-端口号命名的文件夹。进入对应设备的文件夹后,你会看到类似2-1:1.0这样的子目录,这里的1.0表示接口编号。在这个接口目录下,如果设备支持I2C功能,就能找到i2c-*开头的文件。
我写了个简单的查找脚本,可以自动遍历所有USB设备并找出FTDI的I2C接口:
DIR *usb_dir = opendir("/sys/bus/usb/devices/");
struct dirent *entry;
while ((entry = readdir(usb_dir)) != NULL) {
if (strstr(entry->d_name, "i2c-")) {
printf("找到I2C设备: %s\n", entry->d_name);
}
}
closedir(usb_dir);
这个脚本会列出所有检测到的I2C设备,包括FTDI和其他芯片提供的I2C接口。在实际项目中,我建议加上VID/PID过滤,只找特定型号的FTDI设备,避免误操作其他硬件。
找到设备后还要确认权限问题。普通用户通常没有直接访问I2C设备的权限,需要添加用户到i2c组:
sudo usermod -aG i2c $USER
然后注销重新登录,这样就不用每次都sudo了。有时候新设备会被分配很高的I2C编号,比如i2c-10,这时候要检查系统是否还有其他I2C适配器在运行。
2. 设备初始化与打开
找到设备后就要开始初始化操作了。FTDI的I2C设备打开方式有两种:通过PID/VID或者通过串口号。在实际项目中,我更推荐使用串口号的方式,因为同一个型号的FTDI设备可能有多个,用串口号能精确指定要操作哪个设备。
先来看看设备打开的核心代码:
int ftdi_sio_i2c::open_i2c(char *serial_number, int interface, int num) {
char i2c_path[PATH_MAX];
int fd;
sprintf(i2c_path, "/dev/i2c-%d", dev_list->i2c_num[interface][num]);
printf("正在打开: %s\n", i2c_path);
if ((fd = open(i2c_path, O_RDWR)) < 0) {
perror("打开I2C总线失败");
return -1;
}
return fd;
}
这里的interface参数很重要,因为像FT4232H这样的设备有多个接口,每个接口都可能提供I2C功能。在我的测试中,FT4232H的接口0提供了两个I2C通道,接口1又提供了两个,总共四个I2C接口。
打开设备时经常遇到的坑是权限问题。即使加了用户组,有时候新插入的设备节点权限还是不对。这时候需要检查udev规则。我创建了一个udev规则文件/etc/udev/rules.d/99-ftdi.rules:
SUBSYSTEM=="usb", ATTR{idVendor}=="0403", ATTR{idProduct}=="6011", MODE="0666", GROUP="plugdev"
这样设置后,任何FTDI设备插入时都会自动获得正确的权限。
另一个常见问题是设备忙。如果其他进程占用了I2C适配器,打开时会报错。用lsof /dev/i2c-*可以查看哪些进程正在使用I2C设备。在实际开发中,我建议在打开设备前先检查设备状态:
if (access(i2c_path, R_OK | W_OK) == -1) {
printf("设备不可访问,检查权限和占用情况\n");
return -1;
}
设备打开成功后,建议立即设置从设备地址。虽然可以在每次读写时指定地址,但提前设置能让后续操作更简洁:
if (ioctl(fd, I2C_SLAVE, slave_addr) < 0) {
perror("设置从设备地址失败");
close(fd);
return -1;
}
3. I2C写操作实战
写操作是I2通信的基础,但里面有不少细节需要注意。先看完整的写函数实现:
int ftdi_sio_i2c::write_bytes(int fd, char slave_addr,
char reg_addr_width, int reg_addr,
unsigned char *pdat, int len) {
struct i2c_rdwr_ioctl_data packets;
struct i2c_msg messages[1];
unsigned char *outbuf;
int total = len;
int offset = 0;
if (reg_addr_width > 0) {
total += reg_addr_width / 8;
}
outbuf = (unsigned char *)malloc(total);
// 处理寄存器地址
if (reg_addr_width == 16) {
outbuf[offset++] = (unsigned char)(reg_addr >> 8);
outbuf[offset++] = (unsigned char)reg_addr;
} else if (reg_addr_width == 8) {
outbuf[offset++] = (unsigned char)reg_addr;
}
// 拷贝数据
memcpy(outbuf + offset, pdat, len);
// 设置I2C消息
messages[0].addr = slave_addr;
messages[0].flags = 0; // 写操作
messages[0].len = total;
messages[0].buf = outbuf;
packets.msgs = messages;
packets.nmsgs = 1;
if (ioctl(fd, I2C_RDWR, &packets) < 0) {
perror("I2C写操作失败");
free(outbuf);
return -1;
}
free(outbuf);
return 0;
}
这里的关键在于寄存器地址的处理。不同设备的寄存器地址宽度可能不同,常见的有8位和16位。比如EEPROM通常用16位地址,而很多传感器只用8位地址。
在实际使用中,我发现时序控制很重要。有些设备需要写入后等待一段时间才能进行下一步操作。比如EEPROM写入后需要等待几毫秒的写入时间:
// 写入数据后延迟
usleep(5000); // 等待5ms
另一个常见问题是数据打包顺序。有些设备要求先送高位字节,有些要求先送低位字节。我在操作一款温度传感器时就踩过这个坑,数据总是读不对,最后发现是字节序问题。
对于大数据量的写入,建议分批次进行。一次写入太多数据可能导致设备无响应。我通常将大数据分成256字节的块:
for (int i = 0; i < total_length; i += chunk_size) {
int current_size = (total_length - i) > chunk_size ? chunk_size : (total_length - i);
write_bytes(fd, slave_addr, reg_addr_width, reg_addr + i, data + i, current_size);
usleep(1000); // 每批数据后稍作延迟
}
4. I2C读操作详解
读操作比写操作复杂一些,因为需要先发送要读取的寄存器地址,然后再读取数据。这个过程需要两个独立的I2C消息来完成。
先来看完整的读操作代码:
int ftdi_sio_i2c::read_bytes(int fd, char slave_addr,
char reg_addr_width, int reg_addr,
unsigned char *pdat, int len) {
struct i2c_rdwr_ioctl_data packets;
struct i2c_msg messages[2];
unsigned char outbuf[4];
int offset = 0;
// 如果需要指定寄存器地址
if (reg_addr_width > 0) {
// 第一个消息:写入要读取的寄存器地址
if (reg_addr_width == 16) {
outbuf[offset++] = (unsigned char)(reg_addr >> 8);
outbuf[offset++] = (unsigned char)reg_addr;
} else if (reg_addr_width == 8) {
outbuf[offset++] = (unsigned char)reg_addr;
}
messages[0].addr = slave_addr;
messages[0].flags = 0; // 写操作
messages[0].len = offset;
messages[0].buf = outbuf;
// 第二个消息:读取数据
messages[1].addr = slave_addr;
messages[1].flags = I2C_M_RD; // 读操作
messages[1].len = len;
messages[1].buf = pdat;
packets.nmsgs = 2;
} else {
// 不需要寄存器地址,直接读取
messages[0].addr = slave_addr;
messages[0].flags = I2C_M_RD;
messages[0].len = len;
messages[0].buf = pdat;
packets.nmsgs = 1;
}
packets.msgs = messages;
if (ioctl(fd, I2C_RDWR, &packets) < 0) {
perror("I2C读操作失败");
return -1;
}
return 0;
}
读操作中最容易出问题的是时序控制。有些设备在收到寄存器地址后需要一定处理时间才能返回数据。如果读取太快,可能会读到错误数据。我在操作一款陀螺仪传感器时,就因为这个原因总是读到乱码。
解决方案是在写地址和读数据之间加入适当延迟:
// 写入寄存器地址后延迟
messages[0].addr = slave_addr;
messages[0].flags = 0;
messages[0].len = offset;
messages[0].buf = outbuf;
// 添加延迟
usleep(100);
// 然后读取数据
messages[1].addr = slave_addr;
messages[1].flags = I2C_M_RD;
messages[1].len = len;
messages[1].buf = pdat;
另一个常见问题是重复起始条件。有些设备要求读操作使用重复起始条件而不是停止后重新开始。在Linux的I2C实现中,通常会自动处理这种情况,但有时候需要显式指定:
messages[1].flags = I2C_M_RD | I2C_M_NOSTART;
大数据量读取时也要注意分批次。一次读取太多数据可能超出设备缓冲区限制。我通常这样处理大数据读取:
#define MAX_READ_SIZE 32
for (int i = 0; i < total_length; i += MAX_READ_SIZE) {
int current_size = (total_length - i) > MAX_READ_SIZE ? MAX_READ_SIZE : (total_length - i);
read_bytes(fd, slave_addr, reg_addr_width, reg_addr + i, buffer + i, current_size);
}
5. 时钟频率设置与优化
时钟频率对I2C性能影响很大,但FTDI设备的频率设置是个坑。标准的Linux I2C接口并不直接支持频率设置,需要一些特殊方法。
我最初尝试通过ioctl设置频率:
int freq = 100000; // 100kHz
if (ioctl(fd, I2C_TIMEOUT, freq) < 0) {
perror("设置频率失败");
}
但这种方法并不奏效,因为FTDI设备的时钟控制是在USB层面实现的,而不是标准的I2C适配器。
后来发现需要在FTDI驱动层面修改。我在驱动中添加了i2c_clk属性:
static ssize_t ftdi_mpsse_show_i2c_clk(struct device *dev,
struct device_attribute *attr,
char *buf) {
struct usb_serial_port *port = to_usb_serial_port(dev);
struct ftdi_private *priv = usb_get_serial_port_data(port);
return sprintf(buf, "%d\n", priv->i2c_clk - 1);
}
static ssize_t ftdi_mpsse_set_i2c_clk(struct device *dev,
struct device_attribute *attr,
const char *buf, size_t count) {
struct usb_serial_port *port = to_usb_serial_port(dev);
struct ftdi_private *priv = usb_get_serial_port_data(port);
priv->i2c_clk = simple_strtoul(buf, NULL, 10) + 1;
return count;
}
这样就能通过sysfs接口控制频率了:
echo 400 > /sys/bus/usb/devices/1-2:1.0/i2c_clk
但这里有个大坑:修改频率后容易出现ACK错误。我发现这是因为频率改变后,设备需要重新初始化。解决方案是在设置频率后重新打开设备:
void set_freq_and_reset(int fd, int freq) {
close(fd);
// 通过sysfs设置频率
char sysfs_path[PATH_MAX];
sprintf(sysfs_path, "/sys/bus/usb/devices/.../i2c_clk");
FILE *f = fopen(sysfs_path, "w");
fprintf(f, "%d", freq);
fclose(f);
// 重新打开设备
fd = open_i2c(...);
}
在实际测试中,我发现FTDI I2C的最高稳定频率在400kHz左右。超过这个频率虽然能工作,但误码率会显著上升。对于长距离传输,建议使用100kHz的标准模式。
温度对频率稳定性也有影响。在高温环境下,最高稳定频率会下降。我在做车载设备测试时就遇到过这个问题,夏天车内温度升高后I2C通信开始出现错误。
6. 实战案例:EEPROM读写验证
现在用实际案例来验证前面的知识。我选择AT24C256 EEPROM作为测试设备,这是很常见的I2C存储芯片。
先定义设备参数:
#define EEPROM_SLAVE_ADDR 0x50
#define EEPROM_ADDR_WIDTH 16
#define PAGE_SIZE 64
#define TOTAL_SIZE 32768
EEPROM的写操作需要特别注意页对齐。AT24C256的页大小是64字节,如果跨页写入,会导致数据覆盖。正确的写入方法:
void eeprom_write(int fd, uint16_t addr, uint8_t *data, int len) {
int remaining = len;
uint16_t current_addr = addr;
while (remaining > 0) {
int page_offset = current_addr % PAGE_SIZE;
int write_size = PAGE_SIZE - page_offset;
if (write_size > remaining) {
write_size = remaining;
}
write_bytes(fd, EEPROM_SLAVE_ADDR, EEPROM_ADDR_WIDTH,
current_addr, data, write_size);
// 等待写入完成
usleep(5000);
current_addr += write_size;
data += write_size;
remaining -= write_size;
}
}
读操作相对简单,但要注意连续读取时地址会自动递增:
void eeprom_read(int fd, uint16_t addr, uint8_t *buffer, int len) {
// 先设置起始地址
write_bytes(fd, EEPROM_SLAVE_ADDR, EEPROM_ADDR_WIDTH, addr, NULL, 0);
// 然后连续读取
read_bytes(fd, EEPROM_SLAVE_ADDR, 0, 0, buffer, len);
}
测试时我习惯用随机数据,这样能更好地检验读写准确性:
void test_eeprom(int fd) {
uint8_t write_data[256];
uint8_t read_data[256];
// 生成随机测试数据
srand(time(NULL));
for (int i = 0; i < 256; i++) {
write_data[i] = rand() % 256;
}
// 写入EEPROM
eeprom_write(fd, 0, write_data, 256);
// 延迟确保写入完成
usleep(10000);
// 读取验证
eeprom_read(fd, 0, read_data, 256);
// 比较数据
for (int i = 0; i < 256; i++) {
if (write_data[i] != read_data[i]) {
printf("数据不匹配 at %d: wrote 0x%02x, read 0x%02x\n",
i, write_data[i], read_data[i]);
return;
}
}
printf("EEPROM测试通过!\n");
}
在实际测试中,我发现几个常见问题:首先是地址对齐问题,如果起始地址不是页对齐的,写入可能会失败。其次是写入时间不足,EEPROM需要足够时间来完成内部编程操作。
性能方面,FTDI I2C的EEPROM写入速度大约在每秒几KB到几十KB之间,具体取决于时钟频率和页大小。读取速度会快很多,可以达到几百KB/s。
7. 常见问题排查与调试技巧
开发过程中遇到问题很正常,关键是要有有效的调试手段。我积累了一些实用的排查技巧。
首先是ACK错误,这是最常见的I2C问题。可能的原因包括:
- 设备地址错误
- 设备未正确连接
- 上拉电阻不合适
- 时钟频率过高
用i2cdetect工具可以快速检查设备连接:
i2cdetect -y 1
这个命令会扫描I2C总线上的设备,显示响应设备的地址。如果看不到预期设备,首先检查物理连接。
逻辑分析仪是I2C调试的利器。我用Saleae逻辑分析仪抓取I2C波形,可以清晰看到起始条件、地址、数据和ACK位。如果没条件用专业设备,可以用树莓派配合软件实现简单的逻辑分析功能。
软件层面的调试也很重要。我习惯在关键操作前后添加日志:
printf("开始写操作,地址: 0x%02x, 数据长度: %d\n", slave_addr, len);
int ret = write_bytes(fd, slave_addr, reg_addr_width, reg_addr, data, len);
printf("写操作完成,返回值: %d\n", ret);
对于时序敏感的操作,可以添加时间戳调试:
#include <time.h>
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
// 执行操作
read_bytes(fd, slave_addr, reg_addr_width, reg_addr, buffer, len);
clock_gettime(CLOCK_MONOTONIC, &end);
double elapsed = (end.tv_sec - start.tv_sec) +
(end.tv_nsec - start.tv_nsec) / 1e9;
printf("读操作耗时: %.3f 秒\n", elapsed);
电源噪声也是常见问题源。我用示波器检查电源纹波,确保在I2C操作期间电源稳定。如果纹波过大,可以增加滤波电容。
对于间歇性故障,可能是电磁干扰导致的。尝试缩短连接线长度,使用双绞线,或者增加屏蔽措施。我在一个工业项目中就遇到过这个问题,设备在电机启动时总是通信失败,后来给I2C线路加了屏蔽才解决。
温度影响也不能忽视。有些设备在高温下性能下降,我在车载设备测试中就遇到过,夏天午后I2C通信错误率明显升高。解决方案是降低时钟频率或者改善散热。
最后建议编写完整的自测试程序,包含各种边界情况测试:
void comprehensive_test(int fd) {
// 测试边界地址
test_boundary_addresses(fd);
// 测试大数据量
test_large_data(fd);
// 测试错误处理
test_error_handling(fd);
// 测试连续操作
test_continuous_operation(fd);
}
这样的测试程序能在开发早期发现问题,节省后期调试时间。
更多推荐
所有评论(0)