news 2026/10/15 14:46:30

STM32 GPIO模拟串口:PA0/PA1双路UART与DWT延时实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 GPIO模拟串口:PA0/PA1双路UART与DWT延时实现

简介:STM32F103C8 GPIO口模拟串口方案,面向需要扩展串口或硬件UART资源不足的嵌入式开发者。资源将PA1设为推挽输出作TX,PA0设为上拉输入作RX,通过定时器中断模拟UART时序,实现单个十六进制字符的收发,并给出GPIO初始化与发送、接收函数的完整Keil工程源码,无需改动外围电路即可直接移植。由于采用软件时序,波特率不宜过高,适合对实时性要求不高的场景。压缩包共195个文件,以C源文件、H头文件及Keil工程配置文件为主,另含Hex固件、编译中间文件与说明文档,整体仅2.76MB。已有2009人学习下载,适合具备基础STM32经验、想掌握软件模拟串口原理并动手实践的读者。

1. GPIO模拟串口不是玄学:STM32F103C8用PA1/PA0抠出两路单字符UART

说STM32F103C8的GPIO模拟串口是玄学,多半是没把时序账算明白。这块芯片串口不够用的时候,PA1当发送脚、PA0当接收脚,把UART一帧拆成起始位、8个数据位、停止位逐位翻转,波特率本质就是每个bit维持多少微秒的高低电平。这套资源给的是一个单字符收发的完整最小实现,PC端发什么,它回显什么。适合手头刚好有F103C8最小系统板、不想为两个字节加一路硬件串口的场景,也适合想搞清楚软件串口时序边界的开发者。

2. 模拟UART的时序账:一根bit线的104微秒怎么分配误差

2.1 UART一帧的四个时间点:起始位、数据位、停止位在哪取电平

UART异步串口的物理表现就是一根线上的电平随时间变化,空闲时保持高电平,发送一帧时先拉低一个bit时间作为起始位,然后从低位到高位逐bit输出8个数据位,最后拉高一个bit时间做停止位。接收方要做的就是在正确的时刻去读这根线的电平。难点在于“正确时刻”怎么定:双方没有共同时钟,只能靠约定好的波特率推算。

发送方节奏好控制,每位到点就翻转。接收方要复杂一些,因为不知道对方什么时候开始发。标准做法是检测下降沿:总线从空闲高电平跳变到起始位低电平的那一瞬间,就是帧开始。检测到下降沿后不能立刻读电平,因为起始位刚开始,电平可能还没稳定,正确做法是等半个bit时间,落在起始位中间位置确认确实为低,再往后数bit,在每个数据位的中间点采样。

以9600bps为例,一个bit时间约104.17us。起始位下降沿之后,等52us左右读取一次电平确认起始位有效,再等104us到达D0位中间,采样第一个数据位,之后每隔104us采一次,总共采8次。

如果延时不均匀,采样点就会偏离bit中心。UART接收端对单个bit的采样窗口一般有±5%左右的容差,104us的5%大约是5.2us。这意味着延时误差只要不超过这个量级,单字节就能正确接收。整个模拟串口的设计核心,就是把这104us的延时做准。

2.2 选PA0和PA1的三个理由:外部中断、复用冲突、测试飞线

选择PA0作为接收脚,最重要的原因是它对应EXTI0外部中断线。外部中断硬件检测下降沿,起始位一来就能触发中断,不需要主循环去轮询电平,实时性有保障。F103C8上不是所有IO都能触发外部中断,PA0恰好是EXTI0的来源,省掉了GPIO重映射的麻烦。

PA1作为发送脚则是看中它干净。PA1是普通IO,配置成推挽输出直接用,不占用定时器通道,也不占用EXTI线。相比之下,如果选PB3或PB4,还得小心JTAG引脚默认功能,烧录和调试可能受影响。

第三点纯粹是板级测试方便。F103C8最小系统板上PA0和PA1通常在排针上相邻,做自发自收验证时,一根杜邦线短接就能把发送和接收闭环。测试完拔掉线,重新接USB转TTL模块,就能和PC通信。

需要注意,PA0和PA1在芯片复位后默认是模拟输入状态,因为它们同时是ADC的输入通道。初始化时如果忘记重新配置GPIO模式,PA0作为输入外部中断根本无法触发,PA1输出也不会有电平变化,这是刚上手时最容易翻车的点。

2.3 波特率误差预算:几组常用速率的bit时间与容差

用软件模拟串口,延时函数只能做到微秒级整数延时,这就引入了取整误差。以72MHz主频为例,用DWT时钟周期计数做延时可以做到接近精确,但bit时间的微秒数本身是小数,取整后单帧内每一位都会累积一点点偏差。

波特率bit时间理论值(us)取整后延时(us)单帧10bit累积偏差(us)
2400416.67417+3.3
4800208.33208-3.3
9600104.17104-1.7
1920052.0852-0.8
1152008.689+3.2

这个表格说明一个问题:取整误差本身不大,单帧累积偏差只有几个微秒。但115200bps下每个bit才8.68us,接收端采样窗口±5%只有约0.43us,取整到9us已经差了0.32us,再加上双方时钟误差,实际可靠性很低。所以软件模拟串口一般建议做到19200bps以内,9600bps是最稳的。

还有一个实际情况要说明:误差不会跨帧累积。每一帧从起始位下降沿开始重新对齐,上一帧的残余误差不会带到下一帧。所以单字节收发几乎不会因为延时取整失败,连续多字节也相对安全。

3. 发送端实现:PA1推挽输出加DWT延时,按位打电平

3.1 三种微秒延时方案对比:for空转、SysTick、DWT

发送端的核心就是延时。常见延时有三种实现,差异很大。

方案实现方式精度是否占用SysTick受编译器优化影响
for空转循环计数差,受流水线影响否极大
SysTick延时查询SysTick计数寄存器好占用小
DWT周期计数读Cortex-M3内核DWT->CYCCNT最好否无

for空转是最容易入坑的,因为编译器从-O0改成-O2后,空循环可能被优化掉,延时直接变成0,整个串口波形就乱了。SysTick延时精度不错,但SysTick同时被HAL库的HAL_GetTick和HAL_Delay占用,中断里再用SysTick做延时会发生优先级竞争。

DWT延时是最适合模拟串口的方案。DWT是Cortex-M3内核自带的调试监视单元,其中的CYCCNT寄存器以CPU时钟周期为单位递增,不占用任何外设,也不依赖SysTick中断。在72MHz主频下,DWT->CYCCNT每递增72,就是1us。

3.2 发送函数代码与调用方式

DWT延时的初始化代码很简单,放在main函数最开始执行即可:

void dwt_delay_init(void) { // 使能DWT访问 CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; // 使能CYCCNT计数器 DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; DWT->CYCCNT = 0; } void delay_us_dwt(uint32_t us) { DWT->CYCCNT = 0; // 72MHz下,us * 72 就是目标周期数 while (DWT->CYCCNT < us * (SystemCoreClock / 1000000)); }

这段代码里有两个关键点。第一,DWT->CYCCNT在72MHz下每秒递增72000000,us参数乘以72得到目标计数值。第二,SystemCoreClock是全局变量,如果时钟配置为72MHz,它应该等于72000000。这里有个隐藏坑:如果用的是内部8MHz时钟没开PLL,SystemCoreClock还是8000000,那么延时时间会放大9倍,后面避坑章节专门说这个问题。

发送单个字节的函数:

#define TX_PORT GPIOA #define TX_PIN GPIO_Pin_1 #define BIT_US 104 // 9600bps下的bit时间 void soft_uart_send_byte(uint8_t data) { // 起始位:拉低1个bit GPIO_WriteBit(TX_PORT, TX_PIN, Bit_RESET); delay_us_dwt(BIT_US); // 数据位:LSB在前,先发最低位 for (uint8_t i = 0; i < 8; i++) { if (data & 0x01) GPIO_WriteBit(TX_PORT, TX_PIN, Bit_SET); else GPIO_WriteBit(TX_PORT, TX_PIN, Bit_RESET); data >>= 1; delay_us_dwt(BIT_US); } // 停止位:拉高1个bit,回到空闲电平 GPIO_WriteBit(TX_PORT, TX_PIN, Bit_SET); delay_us_dwt(BIT_US); }

这段逻辑很直白:起始位固定拉低,数据位按位判断电平,停止位拉高。注意循环里的用法,先判断data的最低位再右移,这样第一轮发的是bit0,符合UART的LSB first规则。如果反了,发出去的字节会位序颠倒,PC端收到的值和发送值对不上。

3.3 GPIO速度等级对上升沿的影响

发送端还有个容易被忽略的参数:GPIO输出速度。STM32的GPIO_InitTypeDef里有GPIO_Speed字段,可选2MHz、10MHz、50MHz。模拟串口发送建议配置成GPIO_Speed_50MHz。

原因是推挽输出的上升沿时间受输出驱动能力限制。10MHz配置下,输出切到高电平的沿比较缓,9600bps下每个bit有104us,看起来不影响,但如果以后把波特率调到19200甚至更高,加上线上有电容负载,上升沿可能拖到好几个微秒,接收端采样点就会偏移。

实际测试中,10MHz配置跑9600bps也能工作,但波形边沿圆润,逻辑分析仪上能看到明显的梯形。50MHz配置下波形方方正正,对接收端更友好。这个配置不影响功耗,模拟串口本身占用IO的时间就那么短,放心用50MHz。

提示:发送函数执行期间,其他中断如果抢占了CPU,且中断处理时间超过bit时间的一半,就会拉长当前bit的延时,导致整帧错位。在裸机环境下,最简单的办法是发送过程中关闭总中断,或者用临界区保护。这个细节在连续收发场景很重要。

4. 接收端实现:PA0外部中断逮住起始位,按bit中心采样

4.1 接收状态机的两个关键点:降沿触发与bit中心采样

接收比发送复杂,关键有两个点。第一是起始位检测,必须用外部中断的下降沿触发,这样无论主循环在干什么,起始位一来就能响应。第二是bit中心采样,不能在下降沿立刻读电平,必须等半个bit再等一个bit,落到D0位中间。

接收端的初始化要把PA0配置成上拉输入。注意是上拉,不是浮空输入。浮空输入时PA0悬空会有噪声,可能频繁产生下降沿,干扰真正的信号。内部上拉能保证空闲时总线稳定在高电平。

外部中断配置为下降沿触发,对应EXTI0线。中断优先级建议设置成抢占优先级2、子优先级0。这个级别要低于SysTick的中断优先级?这里要反过来说:模拟串口的接收中断处理里有精确延时,如果被更高优先级中断打断,延时就不准了。所以EXTI0的优先级要设得足够高,确保采样过程中不被其他中断干扰。

4.2 中断处理函数代码:屏蔽、采样、恢复

接收初始化代码:

void soft_uart_rx_init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // PA0配置为上拉输入,防止悬空噪声 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA0映射到EXTI0 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); EXTI_InitStructure.EXTI_Line = EXTI_Line0; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }

配套的全局变量和中断处理:

volatile uint8_t uart_rx_byte = 0; // 接收到的数据 volatile uint8_t uart_rx_done = 0; // 接收完成标志 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { EXTI_ClearITPendingBit(EXTI_Line0); // 先屏蔽EXTI0,防止数据位中间的跳变再次触发中断 EXTI->IMR &= ~(1 << 0); // 等半个bit,落到起始位中间 delay_us_dwt(52); // 确认确实是低电平,避免毛刺误触发 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) != Bit_RESET) { // 不是起始位,恢复监听 EXTI->IMR |= (1 << 0); return; } // 再等一个bit,落到D0位中心 delay_us_dwt(104); uart_rx_byte = 0; for (int i = 0; i < 8; i++) { if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET) uart_rx_byte |= (0x01 << i); delay_us_dwt(104); } uart_rx_done = 1; // 恢复EXTI0监听 EXTI->IMR |= (1 << 0); } }

这段中断处理为什么先屏蔽EXTI线?因为数据位里如果某个位从1跳变到0,会产生新的下降沿,如果不屏蔽,就会再次触发中断,导致接收状态机错乱。整个接收流程都在中断里顺序执行,屏蔽掉EXTI0后保持在当前上下文里把8个bit采完,最后恢复监听,逻辑就闭环了。

毛刺过滤那段也很关键。如果PA0收到一个很窄的负脉冲(比如电磁干扰),延时半个bit后读回电平会发现它已经回到高电平,此时直接返回并恢复监听,不进入采样流程。这个机制能过滤掉大部分干扰。

4.3 主循环里怎么消费接收标志

接收中断把数据放到uart_rx_byte里,置位uart_rx_done后就返回。主循环要做的事很单纯:

while (1) { if (uart_rx_done) { uart_rx_done = 0; // 回显:把收到的字节原样发送回去 soft_uart_send_byte(uart_rx_byte); } }

这段代码就是最简单的验证闭环。PC串口助手发一个字符,板子PA0收到,回显到PA1,PC端显示同样的字符。中间有个细节:uart_rx_done清零必须放在主循环里,不能在中断里清。如果在中断采样完立刻清done,主循环还没来得及处理,下一个字节来了又置位done,逻辑上没问题;但反过来如果主循环处理需要时间,中断里已经写入新字节,主循环读到的可能不是最新数据。这里因为单字节场景数据量小,没有加缓冲,已经够用。

注意:接收中断里的延时用的是DWT,不是HAL_Delay。HAL_Delay依赖SysTick中断,而SysTick优先级默认低于EXTI0,在EXTI0中断上下文里调用HAL_Delay会死等SysTick计数,形成死锁。这个问题在避坑章节里单独展开。

5. 模拟串口避坑指南:五条实测踩坑记录

5.1 编译优化后波形变宽或变窄

现象:同一份代码,Debug模式下载后收发正常,改成Release模式(开了-O2优化)后乱码。用逻辑分析仪抓波形,发现每个bit的宽度不是104us,而是忽长忽短,甚至所有bit宽度都被压缩。

原因:延时用的是for空循环,编译器在优化级别-O2下发现循环体内没有任何有效操作,直接把它优化删除,延时变成0;即使没完全删除,Cortex-M3的流水线执行也会让每轮循环的周期数发生变化,导致时间不稳定。

解决:把延时全部换成DWT周期计数方式。DWT->CYCCNT是硬件寄存器,不受编译器优化影响,只跟CPU实际运行时钟相关,优化级别怎么开都不会改变延时结果。

5.2 中断里调HAL_Delay卡死

现象:程序运行后PC发第一个字符还能收到,发第二个字符时主循环卡死,看门狗复位。单步调试发现程序停在EXTI0中断里出不来。

原因:HAL_Delay内部依赖SysTick中断维护的uwTick计数,而EXTI0抢占优先级高于SysTick,EXTI0中断里调用HAL_Delay时,SysTick中断无法抢占,uwTick不再递增,HAL_Delay的while循环条件永远不满足,死锁。

解决:中断内一律用DWT延时,不调用任何依赖SysTick的延时函数。如果不小心用了标准库的Delay函数,同样会出问题,原理相同。这条经验适用于所有在中断里做时间敏感操作的程序。

5.3 第二、第三个字节收不到或覆盖

现象:PC端连续发送"ABC"三个字符,板子只回显第一个字符,或者回显内容是乱码。每次重新上电后,第一个字符总是能收到。

原因:一帧接收完成后,uart_rx_done标志还没有被主循环消费,第二个字符的起始位下降沿触发中断,采样结束后把uart_rx_byte覆盖成新值。主循环读到的是第二个字符,第一个字符丢失。如果主循环里做了耗时操作,比如串口打印,丢字节概率更高。

解决:单字符场景要把uart_rx_done的检测放在主循环最前面,检测到后立即把uart_rx_byte拷贝到局部变量并清标志;如果接收频率更高、数据量更大,需要加环形缓冲区。连续多个字节的可靠接收,本质上已经超出裸机单缓冲的承受范围,要么用硬件UART,要么给模拟串口加上FIFO机制。

5.4 波特率差9倍:时钟树和SystemCoreClock不一致

现象:PC端以9600bps发送0x55,板子收到的全是0xFF或0x00。用逻辑分析仪抓PA1发送波形,每个bit实际宽度约936us,是104us的9倍。

原因:SystemCoreClock全局变量还是默认值8000000,也就是HSI内部8MHz时钟频率,但实际程序已经通过PLL把系统时钟配置到了72MHz。DWT延时函数用SystemCoreClock计算目标周期数,8MHz和72MHz差了9倍,延时时间被放大9倍,9600bps被发成了约1067bps。

解决:在main函数里配置时钟之后、调用任何延时函数之前,调用RCC_GetClocksFreq函数刷新SystemCoreClock,或者手动赋值SystemCoreClock = 72000000。用标准库的SystemInit时,时钟切换发生在SystemInit内部,但全局变量更新需要SystemCoreClockUpdate,很多初始化模板里没有调用这个函数。

5.5 PA0悬空时频繁进中断收垃圾

现象:板子不接任何外部设备,上电后程序不断进EXTI0中断,uart_rx_done一直被置位,主循环不停发送乱码。

原因:PA0悬空时引脚电平不确定,周围电磁干扰会让电平产生抖动,一个窄的负脉冲就能触发下降沿中断。中断里没有做电平确认,直接把垃圾电平采成数据位。

解决:软件上把PA0配置成GPIO_Mode_IPU内部上拉,把空闲电平钳在高位;中断里延时半个bit后读回电平做确认,只有确实为低才进入采样流程。硬件上如果现场干扰严重,PA0外部再接一个100nF电容到地,滤掉高频毛刺。这个组合能挡住绝大多数误触发。

6. 验证方法与一个实用技巧:逻辑分析仪、自发自收和可变波特率

6.1 逻辑分析仪抓波形核对bit宽度

写模拟串口最忌讳盲调,逻辑分析仪是唯一靠谱的验证工具。把PA1接到逻辑分析仪通道,采样率设1MHz以上,触发方式选下降沿,然后让板子发送一个已知字节,比如0x55。0x55的二进制是01010101,LSB先发的话波形是低、高、低、高交替,非常容易辨认。

抓着波形后看屏幕上的测距结果,每个低电平或高电平的宽度都应该是104us左右,误差不超过±3us。如果看到某个位宽度明显偏大,说明中断打断或延时参数有问题;如果所有位宽度一致偏大,去查时钟配置,大概率又是SystemCoreClock没更新。

6.2 没有逻辑分析仪的替代方案:自发自收

手头没有逻辑分析仪时,可以用静态验证。把PA1和PA0用杜邦线短接,程序里先调用soft_uart_send_byte发送一个0x5A,然后延时200ms,再检查uart_rx_done标志:

void self_test(void) { uint8_t test_byte = 0x5A; soft_uart_send_byte(test_byte); delay_us_dwt(200); // 等待接收中断完成 if (uart_rx_done) { uart_rx_done = 0; // 比较回显数据和发送数据 if (uart_rx_byte == test_byte) { // 接收正确,点亮一个LED做指示 } } }

这个测试能把发送链路和接收链路一起验证。如果self_test通过,而接USB转TTL模块后和PC通信失败,问题出在电平转换模块或者接线,和模拟串口本身无关。注意PA1短接PA0测试时,要先拔掉USB转TTL模块的TX线和RX线,否则两个发送端会互相打架。

6.3 实用技巧:把波特率做成运行时参数

模拟串口的BIT_US是硬编码宏,改波特率要重新编译。一个更好用的做法是把它变成全局变量,初始化时按需赋值:

static uint16_t g_bit_us = 104; // 默认9600 // 按波特率设定bit时间 void soft_uart_set_baud(uint32_t baud) { g_bit_us = 1000000 / baud; // 取整到微秒 }

修改后,发送和接收函数里的delay_us_dwt(BIT_US)全部替换成delay_us_dwt(g_bit_us)。如果要兼容4800、19200等速率,只需要在上电初始化时根据配置调用soft_uart_set_baud。这套改动不影响时序逻辑,只把延时参数从常量变成了变量,灵活性提升不少。

我第一次给产品加模拟串口时,图省事用for空转做延时,到产线上有将近三分之一的板子波特率对不上,查了一个通宵才发现是编译器优化和时钟配置叠加出的问题。从那以后我每次写软件串口,不管多急都强制先过一遍逻辑分析仪加自发自收双验证,确认每个bit宽度都在容差范围内才敢合入。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 14:46:25

JWT登录认证原理与实战应用指南

1. 为什么现代应用需要JWT登录认证十年前我们还在用SessionCookie做用户认证时&#xff0c;每次请求都要查数据库验证身份&#xff0c;服务器还得维护会话状态。现在打开手机随便一个APP&#xff0c;背后大概率在用JWT&#xff08;JSON Web Token&#xff09;这套无状态认证机制…

作者头像 李华
网站建设 2026/10/15 14:46:21

Python多因子选股入门:从基本面因子到回测的完整知识体系

1. 从零搭建多因子选股的知识地基很多人一上来就问&#xff1a;有没有现成的多因子选股代码&#xff0c;直接给我跑一遍就行。我特别理解这种心情&#xff0c;毕竟谁不想跳过枯燥的理论直接看结果。但我在实际折腾量化选股的过程中踩过最大的坑&#xff0c;恰恰就是基础不牢——…

作者头像 李华
网站建设 2026/10/15 14:46:18

PyTorch实战指南:从动态图、自动微分到训练部署全解析

做深度学习项目这几年&#xff0c;PyTorch 几乎成了我每天的默认选项。从最早跑个简单的图像分类&#xff0c;到后来折腾分布式训练、模型上线&#xff0c;PyTorch这套框架的灵活和透明&#xff0c;帮我把大量注意力从"框架怎么用"挪到了"模型怎么设计"上。…

作者头像 李华
网站建设 2026/10/15 14:46:12

Smartbi报表设置文档实战:参数联动、单元格扩展与性能调优

简介&#xff1a;这份Smartbi报表设置文档面向使用Smartbi进行报表开发的数据分析与报表设计人员&#xff0c;帮助解决从模板引用到预览发布全流程中的配置与样式难题。资源包内含1个docx文件&#xff0c;约1.05MB&#xff0c;以图文步骤与代码片段结合的方式组织内容。文档围绕…

作者头像 李华
网站建设 2026/10/15 14:46:00

ChatGPT角色扮演实战:用System Prompt与参数打造稳定人设

简介&#xff1a;这份PDF围绕ChatGPT角色扮演能力&#xff0c;系统整理出面向学术、创意、商业、技术、教育与咨询等场景的实用指令体系&#xff0c;适合希望借助AI提升写作、翻译、编程与内容产出效率的职场人士、研究人员和学生。文中将常见需求归纳为20多大类&#xff0c;每…

作者头像 李华
网站建设 2026/10/15 14:45:47

可迁移交互性知识:HOI检测中的注意力迁移机制解析

人-物交互检测&#xff08;HOI Detection&#xff09;这两年越来越火&#xff0c;但要真正把“谁在用什么物体做什么”这件事做对&#xff0c;远没有想象中那么顺利。你不仅要定位人和物体&#xff0c;还要判断二者之间“到底有没有交互”&#xff0c;以及是“哪种交互”。这篇…

作者头像 李华