前面几篇里,CPU 一直在做同一件杂活:搬字节。
接收方向,每来一个字节就进一次中断,中断里把字节从 DR 搬到环形缓冲区;115200 下这个动作每秒发生 11520 次。发送方向,Uart_SendByte() 每送一个字节都要守在 TXE 上等一次寄存器空出来------一帧应答 10 个字节要 0.87ms,一段 256 字节的日志要 22ms,这 22ms 里主循环什么都干不了。
这两件事都不需要判断、不需要计算,只是把数据从一个地方挪到另一个地方。让 CPU 去做这种活,等于请一个会量子场论重整化计算的人专门负责搬箱子。
上一回那篇讲乒乓缓冲和"以空间换时间"的文章里有一句话:乒乓缓冲真正划算的场合,是"搬运本身贵到值得省",而且搬运最好不由 CPU 干。这一回就把那个搬运工请来。
DMA 是什么,不是什么
DMA 是芯片里一块专门搬数据的硬件。你告诉它三件事:从哪搬、搬到哪、搬多少个;然后按下开始,它一个字节一个字节地搬完,中间不需要 CPU 插手,搬完按你的要求报一声。
它不做的事同样重要:
- 它不解析数据。它不知道
AA 55是帧头,也不知道第几个字节是校验;它眼里只有"多少个字节"。 - 它不做判断。缓冲区满了、数据坏没坏,它一概不管。
- 它的通道是画死的。F1 这一代芯片上,每个外设能用的通道是固定的:USART1 的发送绑在 DMA1 通道 4,接收绑在通道 5(参考手册里那张"各个通道的 DMA1 请求一览"的表)。这两个通道被串口占了,别的外设如果也要用同一个通道,就得排队或者换方案。
搬运这件事可以这样分成三种做法:
| 做法 | 谁在搬 | 中断次数(115200 下) | 适合什么 |
|---|---|---|---|
| 轮询 | CPU,每次调用时搬一个 | 0 | 一次只发几个字节,且不着急 |
| 每字节中断 | CPU,在中断里搬一个 | 每秒上万次 | 数据量小、想第一时间知道 |
| DMA | DMA 硬件,一次搬一整块 | 每块一次 | 连续数据流、大块发送 |
接收方向:DMA 加空闲中断
换成 DMA 接收以后,DR 里的字节由 DMA 硬件直接写进内存,CPU 不再需要为每个字节中断一次。但这样一来,程序怎么知道"数据到了"?
DMA 只会告诉你"我搬够了你说的那么多字节"。串口的数据是不定长的:发一条 10 字节的帧,DMA 配的是 128 字节,它搬完 10 个就停在那里等,永远等不到第 128 个。程序如果只看"搬够 128 个没有",就会一直以为什么都没来。
线上空闲就是答案。 USART 有一件事专门报告它:接收线上连续一个完整帧的时间没有新的起始位,它就置起 IDLE 标志。对手敲键盘、对程序自动发的每一帧来说,这句话几乎总是成立的------数据发完了,线自然就静下来了。
所以接收方向的分工是:
text
线上来字节 -> DMA 硬件直接写进内存(CPU 完全不参与)
线上一空闲 -> USART 置起 IDLE -> 进一次中断
中断里 -> 记下"这一块到哪结束",让 DMA 换到另一块继续接
一次中断处理一整块,这就是"从 CPU 搬运到自动搬运"最直观的地方:中断次数从每秒上万次掉到每次发完一帧一次。
两块缓冲怎么摆
之前那套接收写法(中断里一个字节一个字节往 64 格仓库里搬,主循环再一个一个取走的那套)在这一版退休了:字节改由 DMA 直接写进内存。rx_buffer、rx_head、rx_tail、Uart_RingPush()、Uart_ReadByte() 和面板上那两个数字(待处理字节数、溢出次数)统统换成下面的东西。
缓冲准备两块,一块正在被 DMA 写,另一块等着被主循环取走:
c
#define UART_RX_BLOCK_SIZE 128U
static volatile uint8_t rx_block[2][UART_RX_BLOCK_SIZE];
static volatile uint8_t rx_write_index = 0U; /* DMA 正在写的那一块 */
static volatile uint8_t rx_ready = 0U; /* 有一块已经交出来了 */
static volatile uint8_t rx_ready_index = 0U;
static volatile uint16_t rx_block_length = 0U;
static volatile uint32_t rx_int_count = 0U; /* 接收方向一共进了几次中断 */
static volatile uint32_t rx_block_count = 0U;
static volatile uint32_t rx_block_full_count = 0U;
static volatile uint32_t rx_block_drop_count = 0U;
volatile 在这里不只是防编译器优化:这些变量由中断改、由主循环读,硬件写内存那部分更是连编译器都看不见。
启动一块的接收,就是给 DMA 填一张单子:
c
static void Uart_DmaRxStart(uint8_t index)
{
DMA_InitTypeDef dma;
DMA_Cmd(DMA1_Channel5, DISABLE);
DMA_ClearFlag(DMA1_FLAG_GL5);
dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
dma.DMA_MemoryBaseAddr = (uint32_t)rx_block[index];
dma.DMA_DIR = DMA_DIR_PeripheralSRC;
dma.DMA_BufferSize = UART_RX_BLOCK_SIZE;
dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
dma.DMA_MemoryInc = DMA_MemoryInc_Enable;
dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
dma.DMA_Mode = DMA_Mode_Normal;
dma.DMA_Priority = DMA_Priority_High;
dma.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel5, &dma);
DMA_ITConfig(DMA1_Channel5, DMA_IT_TC, ENABLE);
rx_write_index = index;
DMA_Cmd(DMA1_Channel5, ENABLE);
}
这张单子每一句都要读懂,因为它是后面所有 DMA 配置的共同形状:
| 字段 | 值 | 在说什么 |
|---|---|---|
DMA_PeripheralBaseAddr |
&USART1->DR |
搬的一端是串口的数据寄存器 |
DMA_MemoryBaseAddr |
rx_block[index] |
另一端是内存里这块数组 |
DMA_DIR |
PeripheralSRC |
从外设往内存搬(发送方向要反过来) |
DMA_BufferSize |
128 |
这一轮搬多少个 |
DMA_PeripheralInc |
Disable |
外设那头永远是同一个寄存器,地址不动 |
DMA_MemoryInc |
Enable |
内存那头每搬一个就往前进一格 |
DMA_Mode |
Normal |
搬够 128 个就停,不自己绕圈 |
DMA_ClearFlag(DMA1_FLAG_GL5) 是重新武装前先擦掉上一次留下的标志。这件事在"每搬一块就重启一次"的用法里必须做,否则刚打开通道就会看到上一次的完成标志还挂着。
一块结束的两个理由
一块什么时候算结束?有两个理由,都要处理。
理由一:线上空闲。 这就是 IDLE 中断,也是常态。收到它的时候,DMA 手上这块里已经躺着一整帧(或者好几帧)了。
理由二:搬满了 128 个。 如果发送方一口气发 200 个字节不停顿,DMA 在写到第 128 个时就满了,IDLE 还没来。这时候必须靠 DMA 自己的"搬完了"中断(传输完成,TC),把这块先交出去,再换下一块,否则后面的字节全丢。
两个理由做的是同一件事,所以写成一个函数:
c
static void Uart_DmaRxSwitchBlock(void)
{
uint16_t received;
uint8_t filled = rx_write_index;
DMA_Cmd(DMA1_Channel5, DISABLE);
received = (uint16_t)(UART_RX_BLOCK_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5));
if (received > 0U)
{
if (rx_ready != 0U)
{
rx_block_drop_count++; /* 上一块还没被取走,只能顶掉它 */
}
rx_ready_index = filled;
rx_block_length = received;
rx_ready = 1U;
rx_block_count++;
}
Uart_DmaRxStart((uint8_t)(1U - filled));
}
DMA_GetCurrDataCounter() 返回"还剩多少个没搬"。128 减去它,就是这一块实际收到了多少个------DMA 不会主动告诉你收了多少,这个减法就是它唯一的"已收长度"。
1U - filled 就是讲乒乓缓冲那一回里两块交替的换块动作:0 变 1,1 变 0。只改一个下标,一个字节的数据都不用搬。
上一块还没被取走时,这里丢掉的是旧块、保留最新的一块。和讲丢字节那一回里"丢新到的字节、保住已经收了一半的命令"正好相反,理由是角色不同:那是字节级的流,半个命令比一个陌生字节值钱;这里是块级的交接,旧块压着不放只会让延迟越积越多。流里出现缺口这件事解析器能自己收拾------上一回那套帧头重同步和校验就是干这个的。
两个中断入口
空闲中断走的是 USART1 的老入口:
c
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET)
{
USART_ReceiveData(USART1); /* 读 SR 再读 DR,IDLE 才清得掉 */
rx_int_count++;
Uart_DmaRxSwitchBlock();
}
}
清 IDLE 的顺序是手册规定的:先读状态寄存器,再读数据寄存器。上面两句就是这个顺序------USART_GetITStatus() 读的是 SR,USART_ReceiveData() 读的是 DR。这一步不会把 DMA 已经搬走的字节再吃掉一次:空闲中断意味着线上已经静了一个帧的时间,那个字节早就被 DMA 搬进内存了。
缓冲搬满走的是 DMA 通道 5 的入口:
c
void DMA1_Channel5_IRQHandler(void)
{
if (DMA_GetITStatus(DMA1_IT_TC5) != RESET)
{
DMA_ClearITPendingBit(DMA1_IT_TC5);
rx_int_count++;
rx_block_full_count++;
Uart_DmaRxSwitchBlock();
}
}
初始化里最容易漏的一句
完整的 Uart_Init() 里,和讲丢字节那一回那份初始化相比,有增有减:
c
void Uart_Init(void)
{
GPIO_InitTypeDef gpio;
USART_InitTypeDef uart;
NVIC_InitTypeDef nvic;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |
RCC_APB2Periph_USART1, ENABLE);
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_9;
gpio.GPIO_Mode = GPIO_Mode_AF_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
gpio.GPIO_Pin = GPIO_Pin_10;
gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &gpio);
uart.USART_BaudRate = 115200;
uart.USART_WordLength = USART_WordLength_8b;
uart.USART_StopBits = USART_StopBits_1;
uart.USART_Parity = USART_Parity_No;
uart.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
uart.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART1, &uart);
Uart_DmaRxStart(0U);
USART_DMACmd(USART1, USART_DMAReq_Rx | USART_DMAReq_Tx, ENABLE);
USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);
USART_ITConfig(USART1, USART_IT_RXNE, DISABLE);
nvic.NVIC_IRQChannel = USART1_IRQn;
nvic.NVIC_IRQChannelPreemptionPriority = 1;
nvic.NVIC_IRQChannelSubPriority = 2;
nvic.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&nvic);
nvic.NVIC_IRQChannel = DMA1_Channel4_IRQn;
nvic.NVIC_IRQChannelSubPriority = 1;
NVIC_Init(&nvic);
nvic.NVIC_IRQChannel = DMA1_Channel5_IRQn;
nvic.NVIC_IRQChannelSubPriority = 0;
NVIC_Init(&nvic);
USART_Cmd(USART1, ENABLE);
}
(后两句只改了通道号和子优先级,抢占优先级和使能位沿用上面填过的那张单子------同一个结构体接着用,没动过的字段保持原值。)
新增的三件事:打开 DMA1 的时钟(它在 AHB 总线上,所以用 RCC_AHBPeriphClockCmd())、USART_DMACmd() 允许串口把收发请求交给 DMA、以及三个 NVIC 通道(串口用来报空闲,通道 4 用来报发送完成,通道 5 用来报接收搬满)。
真正容易漏的是那一句 DISABLE。 讲丢字节那一回里打开的是 USART_IT_RXNE------每收到一个字节中断一次。现在字节由 DMA 搬了,如果那一句还开着,中断会照旧每字节来一次,CPU 的负担一点没少,DMA 白配。换成 DMA 的第一件事,就是把那条按字节的中断关掉。
USART_DMACmd() 也容易被忽略:DMA 通道配得再对,串口这边不开"允许把请求交给 DMA",请求就根本不会发出去。
主循环怎么取
主循环只多了一段:看到 rx_ready,把整块取过来,一个字节一个字节喂给上一回那个逐字节的解析器:
c
static uint8_t ready_index;
static uint16_t ready_length;
static uint16_t i;
while (1)
{
Mode_Loop();
if (rx_ready != 0U)
{
ready_index = rx_ready_index;
ready_length = rx_block_length;
rx_ready = 0U;
for (i = 0U; i < ready_length; i++)
{
Protocol_Service(rx_block[ready_index][i]);
}
}
}
Key_Tick() 还在 1ms 中断里,Mode_Loop() 还在主循环里,一行取字节的 Uart_ReadByte() 不再出现了。
解析器一个字都没改。 Protocol_Service() 收的还是"一个字节",它不知道这个字节是中断搬来的还是 DMA 搬来的。这里真正要分清的是:DMA 换掉的是搬运方式,分工仍然不变。
rx_block[ready_index][i] 是一个 volatile 数组,这里读它没有问题:主循环读的这块,DMA 正在写的是另一块。
两块够不够
两块缓冲买到的余量是"一块"。处理一块的时间必须短于填满另一块的时间:128 个字节在 115200 下要 11ms 才填满,而把 128 个字节喂进解析器是微秒级的事,余量很足。
真要在解析里做慢活(比如收到命令就去刷一整屏 OLED),余量就不够了。那时候要么加第三块,要么在取走的那一刻先把这块复制出去。缓冲买的是时间,不是免死金牌------上一回那句"额度用完,两边立刻重新耦合",说的就是这件事。
发送方向:拼好一整帧,交给 DMA
发送这边要补的是前面留下的两处:把 printf 接到串口那一回,和讲丢字节那一回------两处都留着 Uart_SendByte() 里那个等 TXE 的循环。
思路和接收反过来------不再一个字节一个字节地送,而是先在内存里把一整帧拼好,然后把"这块的地址和长度"交给 DMA。这也正是讲乒乓缓冲那一回说的"真正划算"的场合:数据单位是整块,搬运由硬件完成。
两块发送缓冲加一本书:
c
#define TX_BLOCK_MAX 256U
static uint8_t tx_buffer[2][TX_BLOCK_MAX];
static volatile uint16_t tx_length[2] = {0U, 0U};
static volatile uint8_t tx_ready[2] = {0U, 0U};
static volatile uint8_t tx_active = 0U; /* DMA 正在搬的那一块 */
static volatile uint8_t tx_in_use = 0U; /* 有没有正在发的 */
static volatile uint32_t tx_int_count = 0U;
static uint32_t tx_drop_count = 0U;
启动发送,单子和接收那张几乎一样,只有两处反过来:方向是"从内存到外设",外设地址那头的 DR 仍然不动:
c
static void Uart_DmaSendStart(uint8_t index)
{
DMA_InitTypeDef dma;
DMA_Cmd(DMA1_Channel4, DISABLE);
DMA_ClearFlag(DMA1_FLAG_GL4);
dma.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
dma.DMA_MemoryBaseAddr = (uint32_t)tx_buffer[index];
dma.DMA_DIR = DMA_DIR_PeripheralDST;
dma.DMA_BufferSize = tx_length[index];
dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
dma.DMA_MemoryInc = DMA_MemoryInc_Enable;
dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
dma.DMA_Mode = DMA_Mode_Normal;
dma.DMA_Priority = DMA_Priority_Medium;
dma.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel4, &dma);
DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, ENABLE);
tx_ready[index] = 0U;
tx_active = index;
tx_in_use = 1U;
DMA_Cmd(DMA1_Channel4, ENABLE);
}
要发一块数据时的规则很短:
- 没有在发的,直接装进当前这一块,开搬;
- 正在发的,装进另一块,排着等;
- 两块都占着(一块在发、一块在等),丢掉这一条并计数。
c
static uint8_t Uart_DmaSendFrame(const uint8_t *data, uint16_t length)
{
uint8_t index;
uint16_t i;
if (length > TX_BLOCK_MAX)
{
return 1U;
}
if (tx_in_use == 0U)
{
index = tx_active;
}
else
{
index = (uint8_t)(1U - tx_active);
if (tx_ready[index] != 0U)
{
tx_drop_count++;
return 2U;
}
}
for (i = 0U; i < length; i++)
{
tx_buffer[index][i] = data[i];
}
tx_length[index] = length;
/* 判断和登记要一起做完:中间被传输完成中断插进来,这一块就没人启动了 */
DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, DISABLE);
if (tx_in_use == 0U)
{
Uart_DmaSendStart(index);
}
else
{
tx_ready[index] = 1U;
}
DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, ENABLE);
return 0U;
}
这里必须交代一件事:交给 DMA 的内存,在整段发送期间必须一直有效。 上面这份 copy 就是为它准备的------如果直接把函数里一个局部数组的地址交给 DMA,函数一返回,那块栈内存就可能被别的东西覆盖,DMA 搬出去的会是一串垃圾。所以发送的数据一律先复制进这两块常驻缓冲。还有一处要护住:结尾那次 tx_in_use 判断和 tx_ready[index] = 1U 的登记不能分开做------发送完成中断要是正好落在这两步中间,它看到 tx_ready 还是 0、以为没人排队就返回,主循环随后才把 tx_ready 置 1,这一块从此没有谁来启动,等于被静默丢掉。所以这一小段用 DMA_ITConfig(DMA1_Channel4, DMA_IT_TC, DISABLE) 与 ENABLE 前后夹住,再配合上面那几个 volatile,"检查---登记"才是一次完整的交接。
发送完成的中断里做交接:
c
void DMA1_Channel4_IRQHandler(void)
{
if (DMA_GetITStatus(DMA1_IT_TC4) != RESET)
{
DMA_ClearITPendingBit(DMA1_IT_TC4);
tx_int_count++;
tx_in_use = 0U;
if (tx_ready[1U - tx_active] != 0U)
{
Uart_DmaSendStart((uint8_t)(1U - tx_active));
}
}
}
一块发完,如果另一块上排着活就接着开搬,没有排队的就是"发送空闲"。这里不需要额外的标志,tx_in_use 本身就是那个标志。
应答改走 DMA
上一回那个应答函数原来是一个字节一个字节往外送,现在改成"先拼进一个数组,再把数组交给 DMA":
c
static void Protocol_SendReply(uint8_t command, uint8_t result)
{
uint8_t frame[16];
uint8_t sum = 0U;
uint8_t index = 0U;
frame[index++] = PROTOCOL_HEADER_1;
frame[index++] = PROTOCOL_HEADER_2;
sum = (uint8_t)(sum + PROTOCOL_DEVICE_ID);
frame[index++] = PROTOCOL_DEVICE_ID;
sum = (uint8_t)(sum + PROTOCOL_TYPE_REPLY);
frame[index++] = PROTOCOL_TYPE_REPLY;
sum = (uint8_t)(sum + command);
frame[index++] = command;
sum = (uint8_t)(sum + 1U);
frame[index++] = 1U;
sum = (uint8_t)(sum + PROTOCOL_VERSION);
frame[index++] = PROTOCOL_VERSION;
sum = (uint8_t)(sum + result);
frame[index++] = result;
frame[index++] = sum;
frame[index++] = PROTOCOL_TAIL;
Uart_DmaSendFrame(frame, index);
}
帧的内容一个字节都没变,变的只是"谁把它送出去"。拼帧这件事还是 CPU 干,因为它需要算校验;搬运交给 DMA,因为它不需要动脑。
Uart_SendByte() 那个等 TXE 的循环并没有被删掉,它只是退到了配角的位置:偶尔需要立即送出几个字节、又确定不会久等的时候,用它更省事。主链路上大块的发送,从此不再占用主循环。
不定长这件事,长度归谁管
收发两头现在都能处理不定长的数据,但这件事要单独钉一遍,因为"长度"在三个地方各有一套说法。
接收的长度是算出来的。 DMA_BufferSize 里填的 128 是"这次最多接多少",不是"这次要接多少"。真正收到几个字节靠减法得到:
c
received = (uint16_t)(UART_RX_BLOCK_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5));
DMA 从来不知道"对方发了多少个字节",它只知道"我离终点还有多远"。所以 4 个字节的帧、37 个字节的帧、200 个字节的突发,在它眼里没有区别:前两个是"没搬完就闲下来了",后一个是"搬满了"。
发送的长度是一笔一设的。 Uart_DmaSendFrame() 的第二个参数就是长度,Uart_DmaSendStart() 每次都把它写进 dma.DMA_BufferSize。发 10 个字节的应答和发 256 个字节的日志走同一条路,用不着写两套代码。
三根硬边线要记住:
| 边线 | 数值 | 越线之后 |
|---|---|---|
| 单笔发送上限 | TX_BLOCK_MAX,256 字节 |
Uart_DmaSendFrame() 直接返回 1,这一笔不发 |
| 发送排队深度 | 2 块 | 前一块在发、另一块排着,第三条被丢掉并计 tx_drop_count |
| 单块接收容量 | 128 字节 | 突发被切成多块交付,FULL 往上涨,这不是错误 |
第一条还有一层意思:DMA 只会搬一段连续的内存。 想把几段内容拼着发出去,得先自己把它们复制进一块连续缓冲------这也是 Uart_DmaSendFrame() 里那份 copy 的另一半作用。数据更大、来源更散的时候,切分和排队都归业务层管,DMA 只负责"把这一块搬完"。
最后一件最容易误会的事:帧有多长,不归 DMA 管。 块的边界由"搬满 128 了没有"和"线上闲下来了没有"决定,帧的边界由 AA 55 和长度字段决定,两把尺子量的是两回事。所以解析永远是上一回那个逐字节的状态机,块只是它的输入单位。
还有一种情况要提前想到:发送方如果连续不断地发,线上从不空闲,IDLE 就永远不来。 这时候切块全靠"搬满 128",块的边界和数据的边界彻底没有关系。对付这种永远不停的数据流有另一种常见做法:让 DMA 用循环模式自己绕圈写,程序按读到哪儿去取,不必每次重新武装通道。它省掉了重启的开销,代价是生产方随时可能追上消费方,位置一旦算错就是静默丢数据。这里选的是普通模式加每次重启------慢一点,但每一块的长度和起点都是明确的。
四个现场实验
1. 数中断次数
上一回那块面板上的两个数字先收起来,换成搬运的几项新数字:
c
static uint32_t uart_panel_last_ms = 0U;
static void Uart_ShowPanel(void)
{
if ((tick_ms - uart_panel_last_ms) < 200U)
{
return;
}
uart_panel_last_ms = tick_ms;
OLED_Clear();
OLED_ShowString(0, 0, "INT", OLED_6X8);
OLED_ShowNum(36, 0, rx_int_count + tx_int_count, 6, OLED_6X8);
OLED_ShowString(0, 8, "BLOCK", OLED_6X8);
OLED_ShowNum(48, 8, rx_block_count, 5, OLED_6X8);
OLED_ShowString(0, 16, "FULL", OLED_6X8);
OLED_ShowNum(48, 16, rx_block_full_count, 5, OLED_6X8);
OLED_ShowString(0, 24, "RXDROP", OLED_6X8);
OLED_ShowNum(54, 24, rx_block_drop_count, 5, OLED_6X8);
OLED_ShowString(0, 32, "TXDROP", OLED_6X8);
OLED_ShowNum(54, 32, tx_drop_count, 5, OLED_6X8);
OLED_Update();
}
手动发几条命令,INT 是每次发完一帧加一。然后把这个数字和讲丢字节那一回那套写法比一比:那时候每来一个字节就中断一次,115200 下每秒 11520 次;现在一整帧只打扰 CPU 一两次。
想亲眼看见那条按字节的中断有多密,把它打开试一次:在 Uart_Init() 里加一句
c
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
再在 USART1_IRQHandler() 里补一段:
c
if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
{
rx_int_count++;
USART_ReceiveData(USART1); /* 按老规矩把中断里那个字节读走 */
}
INT 会立刻跳到每秒上万。但更该看的是另一件事:数据开始丢了。 读 DR 是清 RXNE 的动作,而 DMA 正是靠 RXNE 产生搬运请求;中断抢先读了这一下,DMA 那个字节就搬不到了。两个搬运工抢同一口井,谁都打不满。看完记得把这两段删掉。
这也是那一句 DISABLE 为什么重要的最好证据:DMA 在搬,中断也在收,两个都开着只会互相拆台。
2. 量一次发送要占用主循环多久
准备一段 256 字节的假日志:
c
static uint8_t log_buffer[256];
static void Demo_FillLog(void)
{
uint16_t i;
for (i = 0U; i < 256U; i++)
{
log_buffer[i] = (uint8_t)('A' + (i % 26U));
}
}
发送前后各取一次那条 1ms 时间轴(每毫秒给 tick_ms 加一的那条),差值存进一个 uint32_t 变量 send_used_ms,它就是这次发送占住主循环的时间:
c
uint32_t start = tick_ms;
Uart_DmaSendFrame(log_buffer, 256U);
send_used_ms = tick_ms - start;
send_used_ms 会是 0 或者 1------DMA 一开,函数立刻就返回了。
把这句临时换成逐个字节发:
c
uint16_t k;
for (k = 0U; k < 256U; k++)
{
Uart_SendByte(log_buffer[k]);
}
同一个变量会变成 22 左右。256 个字节,每个字节 86.8µs 等 TXE,加起来就是 22ms------这 22ms 里按键没人扫、屏幕不刷新、控制周期全部推迟。这就是"谁在搬"带来的差别。
3. 主循环被挡住 300ms
把讲丢字节那一回留下的 Demo_SlowMainLoop(300U) 放回主循环末尾。接收侧是 DMA 在干活,这 300ms 里到达的字节照样进内存,主循环转回来就整块取走。
这时候要留意 RXDROP:如果一次灌进来的数据超过 128 字节(一块装不下),就会看到 FULL 涨------那是 DMA 搬满一块、换了另一块继续接的正常现象;如果主循环迟迟不回来看,第二块也满了,RXDROP 才会开始涨。
4. 一次发 200 字节,看两块怎么切
在发送框里一次粘 200 个字符发出去。200 个字节超过了 128 一块的容量,所以会切两次:先搬满 128 触发通道 5 的中断(FULL 加一),剩下 72 个字节等到线上一空闲,由 IDLE 收尾(BLOCK 再加一)。
这个实验还说明另一件事:块的边界不是帧的边界。 一块里可能有一帧半,也可能有三帧,还可能只是半帧------DMA 按"多少个字节"切,协议按"帧头和长度"切,两把尺子量的不是同一个东西。所以解析还得是上一回那个逐字节的状态机,不能拿"来了一块"就当成"来了一帧"。
DMA 的代价
请来的搬运工不会白干活,它要的东西很具体:
- 内存。 接收两块 128 字节,发送两块 256 字节,一共 768 字节。F103C8T6 的 20 KB SRAM 拿得出这点余量,但余量要自己核一遍:这点内存换掉的是一整条主链路上的等待,划算。
- 通道。 通道 4 和 5 从此归串口。后面想给别的外设配 DMA,得先查那张通道表,别撞车。
- 调试难度。 数据是硬件写进内存的,断点停在主循环里看不到"谁写的"。出问题时先怀疑两处:搬运的长度对不对(
DMA_BufferSize)、内存地址有没有在发送期间失效。 - 它不解决解析。 中断次数降下来了,但把字节喂进状态机、算校验、决定命令,还是 CPU 的活。DMA 省掉的是搬运的时间,不是思考的时间。
- 缓冲区还是有限的。 一块装不下、主循环不回来取,数据照样丢;区别是现在有
FULL、RXDROP、TXDROP这些数字告诉你丢在哪一环。
最后橘猫说
前面几篇里 CPU 一直在干搬箱子的活:接收方向每来一个字节中断一次,发送方向每个字节守着 TXE 等一次。DMA 把这件事接了过去------你只需要告诉它从哪搬、搬到哪、搬多少,它就自己搬完再报一声;它不解析、不判断、也不认识帧头,通道和外设的对应关系还是芯片画死的(F1 上 USART1 发送在通道 4、接收在通道 5)。接收方向靠"线上空闲"这唯一一个信号来切块:IDLE 中断处理常态,搬满 128 字节的 TC 中断兜住长突发,两块缓冲一写一取,换块只改一个下标。发送方向反过来------先在内存里把一整帧拼好,再把地址和长度交给 DMA,两块轮流用,前一块还在发就装后一块,两块都占着就丢掉并计数,而交给 DMA 的内存必须在整段发送期间一直有效,所以要 copy 进常驻缓冲。至于"不定长"这件事,长度从头到尾不归 DMA 管:接收的长度是 DMA_GetCurrDataCounter() 减出来的,发送的长度是每笔一设的参数,单笔 256、排队两块、一块 128 是它的三根硬边线;它只搬一段连续内存,帧有多长由协议说了算。初始化里最要紧的一句不是新增的,而是那句 USART_IT_RXNE 的 DISABLE:按字节的中断不关掉,DMA 配得再对也白配;USART_DMACmd() 忘了开,DMA 一张单子也收不到。最后,解析器一个字都没改------DMA 换掉的是搬运方式,不是分工。
最后蜘蛛说
嘿嘿,STM32有我,Linux也有我,性能瓶颈了?------用上我DMA了吗