从 CPU 搬运到自动搬运:DMA、空闲中断和两块缓冲

前面几篇里,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 标志。对手敲键盘、对程序自动发的每一帧来说,这句话几乎总是成立的——数据发完了,线自然就静下来了。

所以接收方向的分工是:

线上来字节 -> DMA 硬件直接写进内存(CPU 完全不参与)
线上一空闲 -> USART 置起 IDLE -> 进一次中断
中断里    -> 记下“这一块到哪结束”,让 DMA 换到另一块继续接

一次中断处理一整块,这就是“从 CPU 搬运到自动搬运”最直观的地方:中断次数从每秒上万次掉到每次发完一帧一次。

两块缓冲怎么摆

之前那套接收写法(中断里一个字节一个字节往 64 格仓库里搬,主循环再一个一个取走的那套)在这一版退休了:字节改由 DMA 直接写进内存。rx_buffer、rx_head、rx_tail、Uart_RingPush()、Uart_ReadByte() 和面板上那两个数字(待处理字节数、溢出次数)统统换成下面的东西。

缓冲准备两块,一块正在被 DMA 写,另一块等着被主循环取走:

#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 填一张单子:

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),把这块先交出去,再换下一块,否则后面的字节全丢。

两个理由做的是同一件事,所以写成一个函数:

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 的老入口:

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 的入口:

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() 里,和讲丢字节那一回那份初始化相比,有增有减:

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,把整块取过来,一个字节一个字节喂给上一回那个逐字节的解析器:

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。这也正是讲乒乓缓冲那一回说的“真正划算”的场合:数据单位是整块,搬运由硬件完成。

两块发送缓冲加一本书:

#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 仍然不动:

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);
}

要发一块数据时的规则很短:

  • 没有在发的,直接装进当前这一块,开搬;
  • 正在发的,装进另一块,排着等;
  • 两块都占着(一块在发、一块在等),丢掉这一条并计数。
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,“检查—登记”才是一次完整的交接。

发送完成的中断里做交接:

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”:

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 是“这次最多接多少”,不是“这次要接多少”。真正收到几个字节靠减法得到:

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. 数中断次数

上一回那块面板上的两个数字先收起来,换成搬运的几项新数字:

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() 里加一句

USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);

再在 USART1_IRQHandler() 里补一段:

    if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
    {
        rx_int_count++;
        USART_ReceiveData(USART1);      /* 按老规矩把中断里那个字节读走 */
    }

INT 会立刻跳到每秒上万。但更该看的是另一件事:数据开始丢了。 读 DR 是清 RXNE 的动作,而 DMA 正是靠 RXNE 产生搬运请求;中断抢先读了这一下,DMA 那个字节就搬不到了。两个搬运工抢同一口井,谁都打不满。看完记得把这两段删掉。

这也是那一句 DISABLE 为什么重要的最好证据:DMA 在搬,中断也在收,两个都开着只会互相拆台。

2. 量一次发送要占用主循环多久

准备一段 256 字节的假日志:

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,它就是这次发送占住主循环的时间:

uint32_t start = tick_ms;

Uart_DmaSendFrame(log_buffer, 256U);

send_used_ms = tick_ms - start;

send_used_ms 会是 0 或者 1——DMA 一开,函数立刻就返回了。

把这句临时换成逐个字节发:

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了吗

posted @ 2026-11-03 18:07  Zw-awa  阅读(43)  评论(3)    收藏  举报