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);
}

这样的测试程序能在开发早期发现问题,节省后期调试时间。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐