做嵌入式这几年,CAN总线是我用过最耐造的工业通信方式,而STM32F103这颗片上集成的bxCAN外设,又是看起来简单、实际踩坑最多的地方。标题里的几个关键词——STM32F103、CAN通信、标准库配置——其实已经把关键矛盾全暴露出来了:网上随手一搜就是标准库例程,但照抄进工程后经常出现波特率不对、收不到数据、上电卡死这类问题。这篇文章不堆大而全的理论,直接从标准库配置到收发测试的完整流程走一遍,把验证过的代码、调过的坑和排查思路都写出来,适合正在调CAN通信、刚把F103最小系统跑起来准备接CAN收发器的朋友。
1. 动手之前的硬件准备:CAN总线不是“接上就能通”
1.1 为什么选STM32F103的bxCAN做工业通信
STM32F103这个系列虽然老,但在工业控制、车载诊断、传感器采集这些场景里依然是常青树。原因很简单:它片上自带的bxCAN模块支持CAN 2.0A和CAN 2.0B协议,标准帧、扩展帧都能收发,而且三个发送邮箱、两个接收FIFO、六个过滤器对中小项目完全够用。相比外挂SPI接口的MCP2515方案,内部的bxCAN不需要额外通信开销,中断响应也更直接,代码写起来干净得多。
很多人纠结为什么不用HAL库或者干脆换成F105/F107的双CAN,我的看法是:工具没有绝对好坏,关键是项目规模和环境。标准库虽然官方不再更新,但稳定、代码透明、网上资料量大,特别适合把CAN收发时序彻底看明白后做移植。HAL库代码量更“政治正确”,但封装的层次一多,底层细节反而容易被遮住。这篇文章以标准库为主线,末尾也会提一嘴HAL库对应函数的写法,方便两边工程对照。
1.2 最小系统之外的CAN硬件电路:引脚、收发器、终端电阻
STM32F103C8T6最小系统板上通常已经把晶振、复位、串口下载电路都做好了,但CAN通信还需要自己外接收发器。F103的CAN1引脚默认映射在PA11(RX)和PA12(TX),这两个引脚同时也是USB的D-和D+,所以一旦板子上有USB电路,就要特别小心冲突。如果PA11/PA12被USB占用、或者布线不理想,F103还支持把CAN1重映射到PB8/PB9,使用标准库的AFIO重映射功能即可。
收发器我常用TJA1050或SN65HVD230。TJA1050供电是5V,输出电平与3.3V单片机互连时,要注意TXD/RXD的逻辑电平匹配,不少模块内部已经做了电平转换,但如果你是自己画板子,最好查一下芯片数据手册的电平阈值。SN65HVD230是3.3V收发器,用在F103上可以直接连接,适合做低功耗或3.3V系统。
基本接线并不复杂:单片机的CAN_TX(PA12)接收发器TXD,CAN_RX(PA11)接收发器RXD,收发器CANH/CANL分别接总线上的两个线,然后在总线两端各放一个120Ω终端电阻。注意,如果总线上已经有两个节点各放了一个120Ω电阻,就不要在第三个节点上再加,否则等效电阻变小,信号反射会变严重。
1.3 关于CAN模块供电与接线:常见电源误区的补充
网上经常有人问“CAN通信模块芯片能否给板子供电”,答案是不能。CAN收发器是一个电平转换器件,不是电源管理芯片,它需要外部供电才能工作,不可能通过CANH/CANL给单片机供电。我那会儿见过一个同学把CAN收发器模块的CANH接到开发板3.3V上,结果模块直接冒烟。这里明确两点:模块的VCC和GND必须单独接对应的电源;CANH/CANL只是差分信号线,上面有隐性电平,但绝对没有带载能力。
还有一个容易被忽略的坑是共地。CAN虽然是差分信号,但收发器的工作电源一定要和单片机共地,否则共模电压超出收发器承受范围,总线容易出现大量错误帧。我在实验桌上短距离测试时,有时图省事只接CANH/CANL不接GND,结果偶尔能通、偶尔完全静默,后来老老实实把GND接上,问题立刻消失。
2. 标准库CAN初始化:配置顺序与关键参数逐项拆解
2.1 开启时钟与配置GPIO的顺序不能乱
很多初学者把CAN不通归结为硬件问题,实际上很大一部分原因是软件初始化顺序不对。标准库初始化CAN,第一步必须是先使能时钟,再配置GPIO,最后初始化CAN外设。这里面总线归属要理清楚:GPIOA和AFIO挂在APB2上,CAN1挂在APB1上,这两个时钟使能缺一不可。如果漏了AFIO时钟,重映射操作会静默失败,CAN功能根本无法工作。
以默认引脚PA11/PA12为例,GPIO配置代码可以这样写:
void CAN_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); /* PA11: CAN1_RX,建议用上拉输入,避免总线空闲时电平抖动 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); /* PA12: CAN1_TX,复用推挽输出 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); }如果要把CAN1重映射到PB8/PB9,则在使能AFIO时钟后加一句:
GPIO_PinRemapConfig(GPIO_Remap1_CAN1, ENABLE);然后GPIO操作对象改成GPIOB的Pin8和Pin9。需要注意的是,PA11/PA12在物理上不能同时再作为普通IO使用,否则外设之间互相干扰,这也是PA11处容易出“灵异事件”的原因,后面会专门展开。
2.2 CAN_Init结构体各字段的含义与推荐取值
标准库的CAN初始化结构体字段很多,第一次看容易发懵,但其实每个字段都对应bxCAN控制寄存器里的一个位功能。我平时的推荐配置是:关闭时间触发通信(TTCM)、开启自动总线恢复(ABOM)、开启自动唤醒(AWUM)、关闭非自动重传(NART)、关闭接收FIFO锁定(RFLM)、关闭发送优先级由FIFO决定(TXFP)。换句话说,让外设尽量工作在自动管理的常规模式。
对应的初始化代码:
void CAN_Module_Init(void) { CAN_InitTypeDef CAN_InitStructure; CAN_DeInit(CAN1); CAN_StructInit(&CAN_InitStructure); CAN_InitStructure.CAN_TTCM = DISABLE; CAN_InitStructure.CAN_ABOM = ENABLE; CAN_InitStructure.CAN_AWUM = ENABLE; CAN_InitStructure.CAN_NART = DISABLE; CAN_InitStructure.CAN_RFLM = DISABLE; CAN_InitStructure.CAN_TXFP = DISABLE; CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 = CAN_BS1_13tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_4tq; CAN_InitStructure.CAN_Prescaler = 4; if (CAN_Init(CAN1, &CAN_InitStructure) != CANINITOK) { /* 初始化失败,这里建议打印或保存错误标志 */ } }逐项解释几个重点:ABOM会在总线离线(bus-off)后自动恢复发送接收,不开启的话,一旦总线上错误率超标,控制器就会一直处于离线状态,表现就是“板子偶尔能收到数据但一发就死”。AWUM配合自动唤醒,适合低功耗唤醒场景。NART设为DISABLE表示发送失败后硬件自动重传,典型应用就是CAN重传保证可靠性;如果改成ENABLE,则发送不成功直接丢弃,适合需要自己控制重发逻辑的场景。
CAN_Mode有三个常用模式:正常模式(Normal)、回环模式(LoopBack)和静默模式(Silent)。调试初期建议先用LoopBack模式,不接外部设备也能自测,代码里只需要把CAN_Mode改成CAN_Mode_LoopBack,后面联调再切回Normal。
2.3 过滤器配置:屏蔽位模式一遍讲清
CAN过滤器是很多人看不懂又绕不开的部分。F103的bxCAN有六个过滤器,每个过滤器可以用32位屏蔽位模式或16位列表模式。最简单的办法是让所有报文都进FIFO0,适合刚开始调通的阶段。32位屏蔽位模式下,把筛选ID和掩码ID都设为0,等价于接收所有ID:
void CAN_Filter_Init(void) { CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_Filter_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(&CAN_FilterInitStructure); }掩码为0表示不关心任何位,掩码为1表示必须匹配对应位。比如只想接收标准ID为0x123的报文,就把FilterIdHigh/FilterIdLow填成0x123对应的格式,MaskIdHigh/MaskIdLow填成全1。实际开发中,我建议先把过滤器关掉或设为全通过,等正常通信后再根据项目需求加过滤规则,否则第一步就卡在“为什么我发了却收不到”上,很难定位是过滤器问题还是物理链路问题。
配置完过滤器后还需要使能接收中断。FIFO0对应中断入口是USB_LP_CAN1_RX0_IRQHandler,需要在NVIC里使能这个通道,同时调用CAN_ITConfig使能FMP0中断,否则只能靠轮询CAN_Receive或检查FIFO计数,实时性差很多。
2.4 波特率计算:为什么你的CAN速率总是不对
CAN波特率计算的公式可以简化成一句话:最终波特率等于APB1外设时钟除以分频器预分频值,再除以一个完整位时间包含的时间量子数(tq)。F103在系统时钟72MHz下,APB1最高是36MHz。一个位时间通常由同步段1tq、传播段+相位缓冲段1(BS1)、相位缓冲段2(BS2)组成,换算关系就是:
波特率 = APB1时钟 / (CAN_Prescaler × (1 + BS1 + BS2))
举个例子,APB1=36MHz,CAN_Prescaler=4,BS1=13tq,BS2=4tq,则位时间=1+13+4=18tq,时间量子=36MHz/4=9MHz,最终波特率=9MHz/18=500kbps。这个500k是工业CAN最常用的速率之一。我经常看到有人用默认的BS1=9tq、BS2=8tq,算出来是562.5kbps,却以为自己配置的是500k,结果双方速率不匹配,总线上全是错误帧。
不同波特率对应的推荐参数可以按这张表快速套用,前提是APB1保持36MHz:
| 目标波特率 | 预分频Prescaler | BS1 | BS2 | 位时间tq | 采样点 |
|---|---|---|---|---|---|
| 1 Mbps | 2 | 13 | 4 | 18 | 77.8% |
| 500 kbps | 4 | 13 | 4 | 18 | 77.8% |
| 250 kbps | 8 | 13 | 4 | 18 | 77.8% |
| 125 kbps | 16 | 13 | 4 | 18 | 77.8% |
采样点一般建议落在75%到85%之间,77.8%适合多数总线长度。如果总线上有波特率不同的节点,必须把所有节点配置成完全一致的预分频、BS1、BS2,才能保证采样点一致。很多总线偶发错误帧并不是硬件问题,而是不同板卡的采样点偏差太大导致的。
3. 收发代码与实测:从LoopBack到USB转CAN联调
3.1 发送函数设计:邮箱等待与超时处理
bxCAN发送有三组发送邮箱,CAN_Transmit函数会自动选择一个空邮箱,返回邮箱编号;如果当前三个邮箱都满了,会返回CAN_TxStatus_NoMailBox。发送完成还要通过CAN_TransmitStatus查询邮箱状态,确认邮箱从Pending变成Ok,而不能发完就不管,否则在总线拥堵时可能存在丢帧和错误状态残留。
一个带超时处理的发送函数可以这样写:
uint8_t CAN1_Send_Msg(uint32_t stdId, uint8_t *data, uint8_t len) { CanTxMsg txMsg; uint8_t mailbox; uint16_t timeout = 0; if (len > 8) len = 8; txMsg.StdId = stdId; txMsg.ExtId = 0; txMsg.IDE = CAN_Id_Standard; txMsg.RTR = CAN_RTR_Data; txMsg.DLC = len; for (uint8_t i = 0; i < len; i++) { txMsg.Data[i] = data[i]; } mailbox = CAN_Transmit(CAN1, &txMsg); if (mailbox == CAN_TxStatus_NoMailBox) { return 1; } while (CAN_TransmitStatus(CAN1, mailbox) != CAN_TxStatus_Ok) { timeout++; if (timeout > 500) { CAN_CancelTransmit(CAN1, mailbox); return 2; } } return 0; }发送函数里的超时循环不能省。实际项目中如果总线上只有两个节点,一个发一个收,发送基本不会失败;但一旦多个节点竞争总线,优先级低的报文可能要等很久才能进邮箱,如果没有超时机制,CPU会被卡死在发送状态,整个系统的实时性就崩了。合理的做法是设置一个用滴答定时器计算的超时阈值,或直接用简单的软件计数,确保异常时能退出并做错误记录。
3.2 接收中断处理:FIFO读取与数据校验
接收部分我建议优先用中断而不是轮询。轮询CAN_Receive看起来简单,但主循环里一旦有耗时操作,比如驱动屏幕、执行Flash擦写,就可能漏掉FIFO溢出导致丢帧。F103的CAN1_RX0中断入口是USB_LP_CAN1_RX0_IRQHandler,这个函数名看着有点奇怪,因为USB和CAN的Rx0在F103上共用了同一个中断向量。
中断处理代码:
void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rxMsg; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { CAN_Receive(CAN1, CAN_FIFO0, &rxMsg); /* 这里把接收到的数据拷贝到全局缓冲区,并置标志位 */ g_can_rx_id = rxMsg.StdId; g_can_rx_len = rxMsg.DLC; memcpy(g_can_rx_buf, rxMsg.Data, rxMsg.DLC); g_can_rx_flag = 1; } }在中断函数里尽量不要做复杂处理,最稳妥的做法是把数据拷贝到全局变量,置位标志,让主循环去消费。如果需要根据ID立即响应,可以在中断里做简单的状态机跳转,但绝对不要调用延时函数或阻塞式串口打印,否则中断嵌套和长时间占用会导致FIFO溢出。
接收中断初始化别忘了使能NVIC:
NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel = USB_LP_CAN1_RX0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE);如果工程里还跑了FreeRTOS,NVIC分组不要改成和RTOS内核冲突的方式。FreeRTOS一般要求NVIC_PriorityGroup_4,也就是所有优先级位都作为抢占优先级,你在这种工程里配置CAN中断时最好也保持一致,并把中断优先级设为适合的数值,避免在临界区中触发调度造成系统崩溃。
3.3 回环模式自测:不接外部设备也能验证
在没有USB转CAN工具、没有第二个节点的情况下,最快验证初始化是否正确的方法是使用LoopBack模式。LoopBack模式下,bxCAN在内部把TX数据回环给RX路径,不需要外部收发器和总线信号参与,PA11/PA12上甚至不会产生有效差分管脚电平,因此你可以用普通杜邦线把板子摆桌上直接测。
测试思路是:初始化时把CAN_Mode设成CAN_Mode_LoopBack,然后在主循环里周期调用发送函数,同时打开接收中断。如果在LoopBack模式下能收到自己发的ID和数据,说明时钟、GPIO、过滤器、中断、发送邮箱全链路都通了,硬件电路和外部总线才是下一步要排查的对象。
我经常在项目调试的第一步先跑这个自测,确认软件无误后再把模式切回Normal接外部设备。否则直接上总线调试,一旦不通,很难分清是软件问题还是收发器接线问题,排查成本会成倍增加。
3.4 用USB转CAN进行整机联调:实测过滤与数据记录
软件自测通过后,就可以把USB转CAN工具和F103板卡接到同一对CANH/CANL上。联调时先在电脑端软件里把波特率设为500kbps,然后让F103周期发送数据帧。如果电脑端能稳定收到,说明发送链路没问题。接着用电脑端软件主动发一帧,F103的中断收到后通过串口1打印出来。
这里串口1的调试信息格式很重要,建议把ID、DLC、数据字节都打出来,方便对照:
printf("CAN RX: ID=0x%03X DLC=%d Data=", g_can_rx_id, g_can_rx_len); for (uint8_t i = 0; i < g_can_rx_len; i++) { printf("%02X ", g_can_rx_buf[i]); } printf("\r\n");实际联调中我发现一个高频问题:USB转CAN工具和板卡都不发数据时,总线上的隐性电平稳定在2.5V左右;一旦把终端电阻漏了,用示波器能看到波形振铃明显,报文出错率上升。还有一个很容易被忽视的点是,很多USB转CAN工具的报文发送间隔和帧类型可以配置,如果你发的是远程帧(RTR)而单片机只处理数据帧,那也会出现“明明收到了却没有任何打印”的情况。所以联调前先确认工具端发的确实是标准数据帧。
4. 踩坑实录:从“上电卡死”到“丢帧”的排查手册
4.1 初始化失败与上电卡死的三个常见根源
CAN初始化的坑,多数集中在时钟和GPIO配置。第一个坑是APB1时钟忘了开或开错总线,代码执行到CAN_Init时直接卡死在等待初始化完成的循环里。标准库的CAN_Init有超时等待机制,如果硬件时钟没起来,函数返回CANINITFAILED,但很多人不看返回值,导致程序继续往下跑,看起来就像“跑飞了”。建议在初始化后立刻判断返回值,并利用串口或LED做状态提示。
第二个坑是PA11/PA12被复用成其他功能。有人用CubeMX生成代码时,把PA11配置成了普通输入或USB功能,然后又手动加了CAN初始化,两条外设配置互相覆盖,最后CAN毫无反应。标准库的坑在于它不像HAL库那样有清晰的Pinout界面,所有配置都在GPIO_Init里,一旦后面再有代码把PA11改配置,前面的初始化就白做了。
第三个坑是重映射配置遗漏。用PB8/PB9做CAN时,AFIO时钟和GPIO_PinRemapConfig缺一不可,很多人只开了GPIOB时钟,结果PB8/PB9始终是普通IO,CAN当然不通。
4.2 PA11/PA12引脚冲突与隐藏的USB占用问题
STM32F103的PA11和PA12天生就是USB的D-和D+,它们和CAN1_RX/CAN1_TX共用引脚。官方手册明确警告,USB和CAN不能同时在同一组引脚上工作。实际项目里,最小系统板上的USB转串口芯片一般只占用PA9/PA10,不会影响PA11/PA12,但如果你自己画的板子上有USB座、或者别的外设把PA11拉低,CAN接收就会一直处于错误状态。
我遇到过一种典型的“PA11 bug”现象:程序在最小系统板上收发一切正常,挪到自研板卡上就完全收不到,把逻辑分析仪挂上去才发现PA11电平始终被钳在低电平。原因是板子上USB的DM引脚带了下拉电阻,恰好占用了PA11。解决方法是把CAN1重映射到PB8/PB9,或者把USB功能彻底断开并检查PA11外部是否有下拉器件。这类问题最难查,因为它不是软件错误,而是原理图层面的隐性冲突。
4.3 收不到数据:过滤器、中断优先级、波特率三连排查
CAN收不到数据,按出现频率排序,我基本都是按这个顺序排查的。第一是过滤器,很多例程默认接收所有报文,但你自己写了过滤器又想收0x123,结果发的是0x124,自然进不来。最快的验证办法是先把过滤器掩码全清零,确认链路通后再精调过滤规则。第二是波特率,USB转CAN工具和板卡只要相差哪怕1%,总线上就会持续产生错误帧,双方都收不到有效数据。除了代码里的参数,还要确认APB1时钟源是否真的是36MHz,有些工程改了系统时钟分频,但CAN参数没跟着改。
第三是中断和NVIC。F103的CAN接收中断和USB共用一个向量,初始化时如果NVIC里的通道没开,或者ISR里没调用CAN_ClearITPendingBit,中断标志一直挂着,CPU就会反复进入中断却不处理数据,表现出来是“程序卡死”或“偶发丢帧”。建议同时在CAN_GetITStatus判断后调用CAN_Receive,然后在函数末尾加CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0)兜底,虽然标准库的CAN_Receive会释放FIFO,但手动清一次不影响功能,还能避免一些边界状况。
4.4 发送失败、bus-off恢复与总线仲裁问题
发送失败最常见的表现是发送函数一直等待,最终返回超时。这时候先看是否是总线错误导致总线离线。如果总线上只有F103一个节点,并且没有接任何其他CAN节点或USB转CAN工具,正常模式下发送会一直尝试并重传,因为总线没有应答ACK,发送状态永远无法变成Ok。这是很多新手第一次测试时最纳闷的问题:明明回环模式能收到,切到正常模式就发送超时。
解决办法很简单:正常模式测试时必须保证总线上至少有两个节点,或者接一个USB转CAN工具,因为CAN协议规定,发送节点必须收到至少一个其他节点的ACK才算发送成功。另一个问题是总线离线后怎么办。开启ABOM后,硬件会自动等待128个总线空闲信号,然后恢复通信。我调试高波特率或长线传输时,偶尔会遇到总线错误率飙升导致bus-off,开启ABOM能明显减少人工重启次数,但要注意恢复需要时间,实时性要求极高的系统要另做总线状态监控。
4.5 标准库、HAL库与RTOS工程的几个易混点
现在很多新人从CubeMX起步,生成的是HAL库工程,回头再看标准库代码容易混淆。HAL库的CAN初始化主函数是HAL_CAN_Init,需要先用MX_CAN1_Init填好参数,再调用HAL_CAN_Start,紧接着用HAL_CAN_ActivateNotification使能接收回调。发送变成了HAL_CAN_AddTxMessage,接收回调在HAL_CAN_RxFifo0MsgPendingCallback里实现。步骤比标准库多,但功能和标准库一一对应,理解标准库之后看HAL库会轻松很多。
霍尔库和标准库同时在工程里使用是很多人容易踩的雷。虽然有些编译环境能把标准库文件单独塞进CubeMX工程,但不建议强行混用,因为外设寄存器的定义可能冲突。如果手里有旧标准库代码,最快的迁移办法是直接对照参考手册重写一遍CAN相关部分,而不是把两套库硬凑到一起。
如果工程里已经移植了FreeRTOS,还要注意CAN接收中断的优先级不要设置的数值过高,避免在RTOS内核临界区里频繁抢占。合理的做法是让CAN中断活跃但不要打断SysTick和PendSV的关键操作,同时主循环里消费CAN数据的任务单独做一个队列,不要在中断里调用任何RTOS阻塞接口。这样多线程调CAN,无论是标准库还是HAL库都能保持稳定。
最后再分享一个我自己的调试习惯:每次拿到新的CAN板卡,我都会用回环模式跑一遍自检,再写一个简单的“周期发送+串口打印接收”测试程序,验证完基础通信后才进入业务逻辑开发。这个习惯帮我挡掉了很多硬件和底层配置的问题。调试CAN没有太多捷径,但只要把引脚映射、时钟、波特率、过滤器和中断这几关都过一遍,绝大多数问题都能在一小时内定位到具体环节。