Windows下用串口引脚模拟I2C总线:时序拆解与C语言实现
2026/9/15 6:49:32 网站建设 项目流程

简介:压缩包内是一套面向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/RESETDTR
RTS输出SDA数据写EscapeCommFunction SETRTS/RESETRTS
CTS输入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->SCL,RTS->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表示ACK,1表示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 .eeprom=0 -O ihex $< $@ clean: rm -f $(TARGET).elf $(TARGET).hex $(TARGET).eep

重新编译用两条命令:

make clean make

make 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.txt

avr-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引脚长期读到高电平导致无数假地址时,先查上拉电阻,这个现象几乎可以定位到硬件问题,不必改驱动代码。

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

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

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

立即咨询