ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Windows下用串口引脚模拟I2C总线:时序拆解与C语言实现

Windows下用串口引脚模拟I2C总线:时序拆解与C语言实现 简介压缩包内是一套面向Windows环境的I2C串口通信实现源码与工程文件主要面向需要了解I2C底层时序或通过串口模拟I2C总线的嵌入式开发者。包内共17个文件整体仅24KB以main.c、slave.c两个核心C源文件为主配合hex、eep、map、lst、elf等编译产物可直观看到源码到固件、内存映射及反汇编列表的完整生成链同时附带Makefile、pnproj工程文件便于在配套工具中直接打开、修改或重新编译。已有222人下载学习。代码围绕I2C主从通信展开覆盖总线初始化、START/STOP条件、地址与数据的读写、应答及错误处理等关键环节也示范了Windows平台上用串口模拟I2C时钟线与数据线的软件实现思路。通过阅读和调试这份小工程可以快速理解I2C协议在真实代码中的落地方式为后续驱动传感器、EEPROM等I2C设备打下基础。1. Windows上缺一根可调试的I2C总线串口引脚恰好补位在Windows下调试I2C设备最烦的不是协议本身而是平台上根本没有一根能直接操作的I2C总线。串口转接板能读能写但一旦要抓时序、卡ACK、查器件响应厂商DLL的黑盒接口什么都透不出来。收到i2c.rar这套代码时我反而松了口气没有图形界面封装main.c、slave.c、Makefile和hex、lss摊开来看每个动作都能在源码层面对上号。这里不复述教科书定义只借这套代码说三件事拆I2C的位时序在Windows上借串口DTR、RTS、CTS引脚模拟出主控时序以及固件工程文件在排错中能提供什么信息。读完即可动手复现无需额外适配器。适合的场景是手头有USB转串口和逻辑分析仪想直接驱动EEPROM、传感器这类从设备又不想被厂商DLL关在黑盒里。2. I2C位时序拆解SDA翻转时机、第9个时钟与串口映射2.1 位传输的底层SDA什么时候变、什么时候读I2C看似只有两根线但所有正确性都押在“什么时候采样”上。协议规定的铁律是SCL为高电平期间SDA上的数据必须保持稳定SDA只允许在SCL为低电平时翻转。每次传输1比特主机先拉低SCL按需改变SDA电平再拉高SCL让从机在这个稳定窗口里采样。这里我一般先把这点映射成代码行为翻转SDA前一定要确保SCL处于低位否则等于在总线上生成一个虚假的START或STOP条件。字节传输从最高位开始主设备在第8个时钟结束后释放SDA第9个时钟用于ACK应答位——从机拉低SDA表示接收成功保持高电平表示NACK。这个第9个时钟往往被初学者忽略导致读出来的数据总少一位。从软件模拟的角度ACK检测的实质是“第9个时钟内部把SDA从输出切到输入”。如果用硬件I2C外设这个切换由状态机完成用GPIO模拟时必须由程序在正确时刻主动释放SDA并读取。时钟拉伸是从机对主机最不友好的一种行为从机在需要额外处理时间时会在SCL拉高后继续钳住时钟线。Windows下纯软件模拟很难稳定响应时钟拉伸因为读SCL电平后还要调度线程精度不可控。常见的做法是优先选择不支持时钟拉伸的从机芯片或者把SCL频率压到实际20kHz以下给从机留足处理余量。2.2 从寻址到应答一帧完整事务的节奏一个完整的事务看起来复杂拆开就是定式。主机先输出START条件SCL为高时SDA从高到低跳变。然后发送8位地址字节高7位是从机地址最低位是读写方向0表示写、1表示读。从机地址匹配后会在第9个时钟拉低SDA回应ACK。写数据是在START和STOP之间重复“地址/数据ACK”的过程读数据则反过来主机在第9个时钟里输出ACK表示再来一字节输出NACK表示读完。说到具体参数查数据手册比记忆更有价值。关键是下面这组时序数值它决定代码里延时该怎么写参数标准模式100kHz快速模式400kHz说明SCL时钟周期10µs2.5µs半周期各占一半START保持时间4.0µs0.6µsSTART后SCL拉低前数据建立时间250ns100nsSCL拉高前SDA稳定时间STOP建立时间4.0µs0.6µsSCL拉高后SDA再拉高上拉电阻参考值4.7kΩ2.2kΩ5V电源下常见取值这组数据给软件模拟划了底线就算Windows线程调度再不准半周期延时的数量级也不能错。实际开发中我通常会按标准模式的两倍余量设延时先跑通功能再逐步缩减直到波形变花哨为止。2.3 为什么偏偏是DTR、RTS、CTS这三个引脚普通串口能当I2C用这件事靠的是串口的几个辅助引脚而不是TXD、RXD。DTR和RTS是PC端可控的输出线驱动程序直接控制电平输出CTS是输入线可以读取外部电平。把SCL和SDA分别映射到其中两条输出线再把一条输入线接SDA用于回读就凑齐了模拟I2C主控所需的全部IO能力。串口引脚方向模拟角色Win32控制方式DTR输出SCL时钟EscapeCommFunction SETDTR/RESETDTRRTS输出SDA数据写EscapeCommFunction SETRTS/RESETRTSCTS输入SDA数据回读GetCommModemStatus读MS_CTS_ON选择这三个引脚还有一个现实原因TXD、RXD由UART控制器接管电平翻转依赖波特率和内部移位寄存器软件没法逐位精确控制而DTR、RTS的电平完全由寄存器位决定驱动调用后立即生效。转换到硬件连接时PC原生RS-232串口输出的是±12V电平直接连传感器模块会把芯片烧掉需要用MAX232做电平转换或者直接用CH340、FT232这类USB转TTL模块它们的辅助引脚本身就是3.3V/5V TTL电平。日常调试我基本都是用USB转TTL模块省掉RS-232那层麻烦。I2C总线本质是开漏结构靠外部上拉电阻维持高电平设备拉低来实现通信。DTR和RTS在PC端多为推挽输出直接接在开漏总线上SDA被从机拉低的时候会出现驱对驱冲突。可靠做法是让串口输出线串接一个100Ω到470Ω的限流电阻再去连SCL和SDA这样既不影响高电平识别也避免在从机拉低总线时形成硬短路。3. 用C语言把串口引脚改造成I2C主控位时序实现与参数配置3.1 核心代码START、STOP、写字节与读字节先给出模拟I2C主控的完整实现。这套代码的思路是把EscapeCommFunction封装成SCL和SDA电平操作再用QueryPerformanceCounter做微秒级延时逐位拼出完整时序。// i2c_bitbang.c #include windows.h #include stdio.h #include i2c_bitbang.h static HANDLE hCom INVALID_HANDLE_VALUE; // 引脚映射DTR-SCLRTS-SDA输出CTS-SDA输入 #define SCL_HIGH() EscapeCommFunction(hCom, SETDTR) #define SCL_LOW() EscapeCommFunction(hCom, RESETDTR) #define SDA_HIGH() EscapeCommFunction(hCom, SETRTS) #define SDA_LOW() EscapeCommFunction(hCom, RESETRTS) static void i2c_delay_us(int us) { LARGE_INTEGER freq, start, cur; QueryPerformanceFrequency(freq); QueryPerformanceCounter(start); do { QueryPerformanceCounter(cur); } while ((cur.QuadPart - start.QuadPart) * 1000000 / freq.QuadPart us); } static int sda_read(void) { DWORD status 0; GetCommModemStatus(hCom, status); return (status MS_CTS_ON) ? 1 : 0; } int i2c_init(const char *port_name) { char path[32]; DCB dcb; COMMTIMEOUTS timeouts {0}; snprintf(path, sizeof(path), \\\\.\\%s, port_name); hCom CreateFileA(path, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hCom INVALID_HANDLE_VALUE) return -1; GetCommState(hCom, dcb); dcb.BaudRate 9600; dcb.ByteSize 8; dcb.Parity NOPARITY; dcb.StopBits ONESTOPBIT; SetCommState(hCom, dcb); SetCommTimeouts(hCom, timeouts); return 0; } void i2c_deinit(void) { if (hCom ! INVALID_HANDLE_VALUE) CloseHandle(hCom); } void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); i2c_delay_us(5); SDA_LOW(); // SCL高电平期间SDA下降沿START i2c_delay_us(5); SCL_LOW(); } void i2c_stop(void) { SCL_LOW(); SDA_LOW(); i2c_delay_us(5); SCL_HIGH(); i2c_delay_us(5); SDA_HIGH(); // SCL高电平期间SDA上升沿STOP i2c_delay_us(5); } int i2c_write_byte(unsigned char data) { int i, ack; for (i 7; i 0; i--) { if (data (1 i)) SDA_HIGH(); else SDA_LOW(); i2c_delay_us(2); SCL_HIGH(); // SDA稳定后拉高SCL从机采样 i2c_delay_us(4); SCL_LOW(); i2c_delay_us(2); } // 第9个时钟释放SDA读取从机ACK SDA_HIGH(); i2c_delay_us(2); SCL_HIGH(); i2c_delay_us(4); ack sda_read(); SCL_LOW(); return ack; // 返回0表示ACK1表示NACK } unsigned char i2c_read_byte(int send_ack) { unsigned char data 0; int i; SDA_HIGH(); // 切到输入模式读从机数据 for (i 0; i 8; i) { data 1; SCL_HIGH(); i2c_delay_us(4); if (sda_read()) data | 0x01; SCL_LOW(); i2c_delay_us(2); } // 第9个时钟主机输出ACK或NACK if (send_ack) SDA_LOW(); else SDA_HIGH(); SCL_HIGH(); i2c_delay_us(4); SCL_LOW(); SDA_HIGH(); return data; }3.1.1 延时参数为什么取这个值延时数值是按标准模式100kHz降速设计的。半周期5µs对应100kHz但代码里高低电平之间插入了2µs和4µs的混合延时实际SCL周期约15µs左右。这个冗余量是刻意留的Windows没有硬实时保证线程随时可能被抢占如果延时卡在规范临界值一次调度切换就会破坏整个帧。参数上的原则是先按计算值加一倍余量跑通再用逻辑分析仪观察实际半周期逐步缩减到满足器件手册要求。波特率这里固定成9600原因是DTR、RTS的电平翻转与波特率完全无关寄存器位随时可以写。但串口驱动要求配置一个合法的DCB参数组合9600 8N1最稳妥。如果对端接的是CH340这类USB桥接芯片某些驱动对未初始化的DCB会报参数错误先配置波特率再操作引脚是最稳的顺序。另外要注意CreateFile的路径写法COM10及以上端口必须用\\.\前缀否则打开失败这段代码里已经处理了。3.2 读写EEPROM的完整事务示例用AT24C02这类串行EEPROM验证最合适地址明确、应答确定性强。下面这段代码演示完整的读写流程先写一个字节到0x00地址再读回来校验。AT24C02的7位地址是0x50左移一位得到地址字节0xA0末尾方向位0表示写。// main.c #include stdio.h #include windows.h #include i2c_bitbang.h #define EEPROM_ADDR_W 0xA0 // 0x50 1写方向 #define EEPROM_ADDR_R 0xA1 // 0x50 1读方向 int main(void) { unsigned char val; if (i2c_init(COM3) ! 0) { printf(串口打开失败\n); return 1; } // 写START 地址 存储地址 数据 i2c_start(); if (i2c_write_byte(EEPROM_ADDR_W)) { printf(写地址无ACK检查接线和上拉电阻\n); i2c_stop(); i2c_deinit(); return 1; } i2c_write_byte(0x00); // EEPROM内部存储地址 i2c_write_byte(0x5A); // 要写入的数据 i2c_stop(); printf(写入完成\n); Sleep(10); // 等待EEPROM内部写周期 // 读START 地址(读方向) 读1字节 NACK结束 i2c_start(); if (i2c_write_byte(EEPROM_ADDR_R)) { printf(读地址无ACK\n); i2c_stop(); i2c_deinit(); return 1; } val i2c_read_byte(0); // 只读一字节回NACK i2c_stop(); printf(读回数据: 0x%02X\n, val); i2c_deinit(); return 0; }写地址无ACK是模拟I2C最常见的失败点。这里ACK为1表示从机没应答可能原因按优先级排查从机地址算错、SDA回读引脚接错、上拉电阻缺失、SCL和SDA反接。代码中i2c_read_byte(0)的0表示最后一个字节后回NACK这个是I2C协议的读结束约定——主机回NACK告诉从机不用再准备下一字节了。i2c_write_byte里的地址字节本身包含方向位EEPROM地址0x50左移后最低位由读写方向决定所以我定义了_W和_R两个常量。常见错误是直接传0x50进去这样最后的ACK检测会一直错位。如果出现写完能应答、读回却乱码的情况先确认方向位是否置1再看第9个时钟的SDA电平有没有被正确读回来。3.3 主从代码在压缩包里的对应关系这套i2c.rar里同时出现main.c和slave.c说明工程覆盖了主从两端。main.c里的模拟主控逻辑就是我们上面写的这一层slave.c则跑在单片机侧用GPIO中断或轮询检测SCL边沿在SCL上升沿采样SDA匹配自身地址后在第9个时钟拉低SDA回ACK。理解这个结构后调试思路会完全不一样PC端串口模拟是主MCU端代码是从任何一端判错信号另一端都会卡在ACK检测上。4. 从i2c.rar的工程文件反推构建链Makefile、.lss与从机代码的配合4.1 解包后先做一次文件体检压缩包里混着源码、备份、编译产物和IDE工程文件第一件事不是打开main.c而是把文件分类搞清楚哪些是当前状态、哪些是历史残留、哪些是构建中间产物。用diff去比对.bak和现役源码往往能直接看到作者的调试痕迹是改了SDA延时还是换了从机地址这些改动点恰好就是最容易踩的坑。文件类型用途main.c / slave.c源码主机逻辑与从机应答逻辑main.c.bak / slave.c.bak备份历史版本用于diff对比Makefile构建脚本AVR-GCC编译入口main.hex / main.eep烧录文件Flash程序与EEPROM初始化镜像main.elf / main.map / main.sym链接产物调试、符号表、内存布局main.lst / main.lss汇编列表反汇编代码与源文件对应main.o / main.o.d编译产物目标文件和依赖文件main_hex.aps / i2c.pnprojIDE工程图形化IDE配置内容与Makefile重叠.bak文件别急着删先执行一次diff。diff -u main.c main.c.bak diff -u slave.c slave.c.bak从输出里能看到作者最后改了什么。常见的是修改ACK超时计数、调整延时参数、重排从机地址位。这些改动往往对应真实硬件调试中遇到的问题和后面讲到的排错方法能对上。比对完确认当前版本是main.c后再继续往下读。4.2 重新构建完整固件只要Makefile还在整个工程就可以一键重编。典型的AVR-GCC Makefile结构如下# Makefile MCU atmega16 F_CPU 8000000UL TARGET main SRC main.c slave.c CC avr-gcc OBJCOPY avr-objcopy CFLAGS -mmcu$(MCU) -DF_CPU$(F_CPU) -O2 -Wall all: $(TARGET).hex $(TARGET).eep $(TARGET).elf: $(SRC) $(CC) $(CFLAGS) -o $ $^ $(TARGET).hex: $(TARGET).elf $(OBJCOPY) -j .text -j .data -O ihex $ $ $(TARGET).eep: $(TARGET).elf $(OBJCOPY) -j .eeprom --change-section-lma .eeprom0 -O ihex $ $ clean: rm -f $(TARGET).elf $(TARGET).hex $(TARGET).eep重新编译用两条命令make clean makemake clean只删掉上次构建产物保留源码和备份所以反复实验是安全的。重编完成后main.hex会更新用avrdude配合USBasp或STK500烧进目标板即可。这里要特别说明F_CPU的取法它告诉编译器延时循环里的时钟周期数如果单片机实际晶振和这个参数不一致用_delay_us()这类函数产生的时间会整体偏移I2C时序也就跟着飘。拿到陌生工程先确认F_CPU和板子晶振是否一致这是新手最容易忽略的环节。4.3 .lss和.map怎么查.lss文件是反汇编列表.map记录内存布局。排查I2C时序问题时.lss比源码更直接因为可以看到SCL翻转前后到底执行了多少条指令数一数就能算出最小时钟周期是否满足100kHz要求。avr-size main.elf avr-objdump -S main.elf disasm.txtavr-size输出Flash和RAM占用可以确认工程是否改超出容量。avr-objdump -S把C源码和汇编交织输出打开disasm.txt搜索SCL对应的端口操作指令。以AVR常见的8MHz主频为例大部分指令单周期执行如果SCL拉高和拉低之间隔了20条指令半周期就是2.5µs对应200kHz——这个速度在快速模式边缘不够稳。这个反汇编检查法用来验证“延时写法是否和预期一致”比盲调参数有效得多。.eep文件也要留意它保存的是EEPROM初始化内容而非Flash程序。很多I2C从机地址或校准数据会预先烧在EEPROM里如果重刷时只烧了.hex没烧.eep某些功能会表现异常尤其是地址配置类的工程。出现“代码和板子行为对不上”时把它也烧进去再试。5. 串口模拟I2C的排错技巧时序抖动、上拉电阻与逻辑分析仪验证5.1 先用逻辑分析仪确认波形再动代码模拟I2C跑不通时第一件事永远是看波形而不是改代码。把逻辑分析仪CH0接SCL、CH1接SDA地线共地采样率设2MHz以上抓一次启动和一次写字节的过程。重点关注三点START条件的下降沿是否出现在SCL高电平期间、第9个时钟后SDA是否被拉低回应ACK、STOP条件的上升沿是否也在SCL高电平期间。波形上这三个位置对了协议层就没问题剩下的都是电气噪声或电平转换的事。5.2 三个高频故障与排查路径故障现象可能原因排查方法写地址一直收不到ACK从机地址算错或上拉电阻缺失逐个地址扫描万用表测SDA静态电平波形时序忽快忽慢USB转串口调度抖动延时增大到50µs级降低SCL频率连接RS-232口后芯片发烫电平不匹配换USB转TTL模块或加MAX232转换上拉电阻缺失是最隐蔽的故障总线空载时SDA悬空电平不确定从机可能应答也可能不应答。我一般先用电表量SCL和SDA的对地电压总线空闲时应该接近电源电压如果不到1V先补上拉电阻再谈时序。USB转串口模块的延时抖动来自USB的1ms帧调度表现为SCL周期随机拉长处理方式很直接把延时从个位数微秒增大到50µs先把通信跑稳定再考虑提速。这类模块本来就不是为微秒级时序设计的能用软件把速率降下来解决的事不值得上硬件I2C适配器。5.3 写一个I2C扫描器快速定位从机地址不知道从机地址时扫描是最快捷的方式。协议标准地址范围0x03到0x77逐个发送地址字节并检测ACK有应答就把地址打印出来。// scan.c #include stdio.h #include i2c_bitbang.h int main(void) { int addr; if (i2c_init(COM3) ! 0) return 1; for (addr 0x03; addr 0x77; addr) { i2c_start(); // 最低位写0只探测写方向是否有ACK if (i2c_write_byte((unsigned char)(addr 1)) 0) printf(发现设备: 0x%02X\n, addr); i2c_stop(); } i2c_deinit(); return 0; }扫描逻辑的要点是每个地址尝试后必须执行STOP否则总线状态会残留影响下一个地址的探测。读方向探测只在写方向无应答时才需要做因为大多数I2C从机写地址和读地址共用同一ACK逻辑极少数器件只响应读方向再补一轮(addr 1) | 1的探测就能覆盖。CTS引脚长期读到高电平导致无数假地址时先查上拉电阻这个现象几乎可以定位到硬件问题不必改驱动代码。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表