4. 外设驱动移植方法论

UART,说白了就是串口。做MCU开发的人,没有谁离得开它。我个人习惯,拿到一块新板子,第一件事就是把串口调通。为什么?因为它是你与芯片对话的「嘴巴」和「耳朵」。调试信息、日志输出、甚至固件升级,都靠它。

这一章,我们不讲那些虚的理论。直接上手,从HAL库接口分析开始,一步步把printf/scanf重定向搞定,再深入中断接收与DMA发送,最后用环形缓冲区实现一个能与Bootloader交互的实战案例。嗯,都是我在项目里踩过坑、填过土的东西。

4.1 UART的HAL库接口分析

STM32的HAL库,把UART操作封装成了几个核心函数。你想想看,其实底层无非就是配置、发送、接收、中断这几个动作。HAL库把它们包装得挺规整,但有些细节,文档里可不会告诉你。

函数 用途 阻塞/非阻塞 我踩过的坑
HAL_UART_Init() 初始化UART参数 阻塞 忘记配置GPIO复用,串口死活没输出
HAL_UART_Transmit() 阻塞发送 阻塞 大数据量时卡死主循环
HAL_UART_Receive() 阻塞接收 阻塞 超时设置不合理,程序挂起
HAL_UART_Transmit_IT() 中断发送 非阻塞 中断优先级没配好,丢数据
HAL_UART_Receive_IT() 中断接收 非阻塞 单字节中断,频繁进中断开销大
HAL_UART_Transmit_DMA() DMA发送 非阻塞 DMA与CPU争抢总线,缓存未对齐
我的习惯: 调试阶段先用阻塞模式验证硬件通路,稳定后再切到中断或DMA。别一上来就搞复杂的,容易把自己绕进去。

4.2 串口重定向:printf/scanf的魔法

嵌入式里用printf,其实是个「骗术」。标准库的printf最终会调用一个底层字符输出函数——在ARM GCC里是_write(),在IAR里是__putchar()。我们只要把这个函数重写,让它调用UART发送,就成了。

我曾经在一个项目里,因为没重定向_write(),printf直接往屏幕上打,MCU当然没有屏幕,结果程序跑飞了。嗯,从那以后我每次建工程第一件事就是检查这个。

// GCC环境下的printf重定向
int _write(int file, char *ptr, int len)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY);
    return len;
}

// scanf重定向
int _read(int file, char *ptr, int len)
{
    HAL_UART_Receive(&huart1, (uint8_t *)ptr, 1, HAL_MAX_DELAY);
    return 1;
}
注意: 使用scanf时,默认是行缓冲模式,需要输入回车才会触发。如果你想要单字符立即响应,记得用setbuf(stdin, NULL)关闭缓冲。

4.3 中断接收与DMA发送

阻塞模式在裸机开发里还能凑合用,一旦上了RTOS或者需要处理多任务,就必须上中断和DMA了。我个人的经验是:接收用中断,发送用DMA。

为什么?接收是事件驱动的,你不知道数据什么时候来,中断最合适。发送是你主动的,用DMA可以解放CPU,让它去干别的活。

4.3.1 中断接收的配置要点

  • 使能UART接收中断:HAL_UART_Receive_IT(&huart1, buffer, 1),最后一个参数是接收长度,设为1表示每收到一个字节就触发一次中断。
  • 在中断回调函数HAL_UART_RxCpltCallback()里处理数据。
  • 记得每次回调后重新调用HAL_UART_Receive_IT(),否则只触发一次。
避坑指南: 我曾经在中断回调里做复杂的数据处理,结果导致中断响应时间过长,丢了一串数据。记住,中断里只做「搬数据」的事,别做「处理数据」的事。

4.3.2 DMA发送的实战配置

DMA发送,说白了就是告诉DMA控制器:「帮我把这段内存里的数据搬到UART的发送寄存器,搬完了告诉我一声。」

// DMA发送配置示例
#define TX_BUF_SIZE  256
uint8_t tx_buffer[TX_BUF_SIZE];

// 启动DMA发送
HAL_UART_Transmit_DMA(&huart1, tx_buffer, len);

// 发送完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    // 可以在这里释放信号量或置标志位
    // 告诉主循环:数据发完了,可以发下一包了
}
性能调优小技巧: DMA发送时,确保发送缓冲区在DMA传输完成前不会被修改。我习惯用双缓冲机制——一个缓冲区在发,另一个在准备,交替使用。

4.4 实战:环形缓冲区实现与Bootloader交互

好了,前面都是热身。现在我们来点真格的——用环形缓冲区实现一个可靠的UART接收模块,然后与Bootloader交互,完成固件升级。

环形缓冲区,说白了就是一个固定大小的数组,加上两个指针:写指针和读指针。数据写进去,读出来,循环使用。不会像链表那样动态分配内存,也不会像简单数组那样溢出。

环形缓冲区结构图 0 1 2 3 4 5 6 7 写指针 (写) 读指针 (读) 工作原理: 1. 中断接收数据,写入写指针位置,写指针后移 2. 主循环读取数据,从读指针位置取出,读指针后移 3. 指针到达末尾时回绕到开头,形成环形

4.4.1 环形缓冲区实现代码

#define RING_BUF_SIZE  256

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buffer_t;

ring_buffer_t rx_ring;

// 初始化
void ring_buffer_init(ring_buffer_t *rb)
{
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ring_buffer_put(ring_buffer_t *rb, uint8_t data)
{
    uint16_t next = (rb->head + 1) % RING_BUF_SIZE;
    if (next == rb->tail) {
        return -1;  // 缓冲区满
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return 0;
}

// 读取一个字节
int ring_buffer_get(ring_buffer_t *rb, uint8_t *data)
{
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 0;
}

4.4.2 与Bootloader的交互协议

Bootloader交互,说白了就是一套「握手-命令-响应」的流程。我参与过一个产品,Bootloader和APP之间就是通过UART+环形缓冲区通信的。协议很简单,但很可靠。

命令 命令码 数据格式 响应
握手 0xAA 无 0x55
擦除扇区 0xBB 扇区号(1字节) 0xCC(成功)/0xEE(失败)
写入数据 0xCC 地址(4字节)+长度(2字节)+数据 0xDD(成功)/0xEE(失败)
校验 0xDD CRC32(4字节) 0xFF(通过)/0xEE(失败)
跳转 0xEE 无 无(直接跳转)
实战经验: 我曾经在Bootloader交互中犯过一个低级错误——没有做超时处理。结果有一次升级过程中串口线松了,MCU一直卡在等待响应的循环里,只能断电重启。后来我加了一个500ms的超时机制,问题就解决了。

4.4.3 中断回调与主循环的配合

// 中断回调:只负责把数据放入环形缓冲区
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        ring_buffer_put(&rx_ring, rx_byte);
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);  // 重新开启中断
    }
}

// 主循环:解析命令并响应
void main_loop(void)
{
    uint8_t cmd;
    while (1) {
        if (ring_buffer_get(&rx_ring, &cmd) == 0) {
            // 解析命令
            switch (cmd) {
                case 0xAA:  // 握手
                    send_response(0x55);
                    break;
                case 0xBB:  // 擦除
                    // 读取后续数据...
                    break;
                // 其他命令处理
            }
        }
        // 其他任务...
    }
}
性能调优建议: 环形缓冲区的大小要根据实际数据量来定。我一般取2的幂次方,这样取模运算可以用位运算代替——index & (SIZE-1),比取模快得多。

好了,这一章的内容就到这里。UART驱动移植看似简单,但真正要做好,细节不少。从HAL库接口的选择,到printf重定向的陷阱,再到中断与DMA的配合,最后用环形缓冲区实现一个可靠的通信模块——每一步都有讲究。你把这些吃透了,以后遇到任何串口相关的需求,心里都有底。