4. 存储器映射与总线

好,我们直接进入正题。上一章我们聊了HAL层的设计哲学,这一章就来点实在的——用UART这个最常用的外设,把接口定义从头到尾走一遍。

为什么选UART?说白了,串口是嵌入式开发的“标配”。你调试要它,通信要它,甚至很多传感器也是通过它来交互。我这些年经手的项目,从8位MCU到Cortex-A系列,UART的HAL层几乎每个项目都要重写一遍。踩过的坑多了,自然就总结出一套相对通用的写法。

4.1 结构体设计:UART_Handle 与 UART_Config

先看两个核心结构体。我个人习惯把实例句柄和配置参数分开定义,这样职责更清晰。

/* UART配置参数结构体 */
typedef struct {
    uint32_t BaudRate;          /* 波特率 */
    uint8_t  DataBits;          /* 数据位:5/6/7/8 */
    uint8_t  StopBits;          /* 停止位:1/1.5/2 */
    uint8_t  Parity;            /* 校验位:无/奇/偶 */
    uint8_t  FlowControl;       /* 流控:无/硬件RTSCTS */
    uint8_t  Mode;              /* 模式:TX/RX/TXRX */
} UART_Config;

/* UART实例句柄结构体 */
typedef struct {
    UART_RegTypeDef *Instance;  /* 寄存器基地址 */
    UART_Config      Config;    /* 当前配置 */
    uint8_t          *pTxBuff;  /* 发送缓冲区指针 */
    uint8_t          *pRxBuff;  /* 接收缓冲区指针 */
    uint16_t         TxSize;    /* 发送数据长度 */
    uint16_t         RxSize;    /* 接收数据长度 */
    volatile uint8_t TxBusy;    /* 发送忙标志 */
    volatile uint8_t RxBusy;    /* 接收忙标志 */
    uint8_t          ErrorCode; /* 错误码 */
    void             *pParent;  /* 父设备指针(可选) */
} UART_Handle;

这里有几个设计要点,我重点说一下:

  • Instance指针:指向具体的寄存器基地址,这样同一个结构体可以适配不同UART外设。我在一个项目里同时用过UART0~UART5,就是靠这个指针来区分的。
  • Busy标志用volatile:这个很关键。中断服务程序和主循环都会访问它,不加volatile的话,编译器优化可能让你调试到怀疑人生。
  • pParent指针:这是留给上层驱动的“后门”。比如你要把UART挂到某个DMA设备下,这个指针就能派上用场。

我的经验:Config结构体里的参数最好用枚举,而不是直接写数字。比如DataBits用UART_DATABITS_8这样的宏定义,代码可读性会好很多。我曾经接手过一个项目,波特率直接写了个9600,结果后来改成115200时漏改了一处,排查了整整半天。

4.2 接口函数原型:Init / DeInit / Send / Receive

接口定义要遵循一个原则:参数尽量少,职责尽量单一。来看我常用的接口原型:

/* 初始化与反初始化 */
int32_t UART_Init(UART_Handle *handle, UART_Config *config);
int32_t UART_DeInit(UART_Handle *handle);

/* 阻塞式收发 */
int32_t UART_Send(UART_Handle *handle, uint8_t *pData, uint16_t size, uint32_t timeout);
int32_t UART_Receive(UART_Handle *handle, uint8_t *pData, uint16_t size, uint32_t timeout);

/* 中断式收发 */
int32_t UART_Send_IT(UART_Handle *handle, uint8_t *pData, uint16_t size);
int32_t UART_Receive_IT(UART_Handle *handle, uint8_t *pData, uint16_t size);

/* 中断回调注册 */
int32_t UART_RegisterCallback(UART_Handle *handle, UART_CallbackType type, void (*callback)(UART_Handle *));

返回值统一用int32_t,0表示成功,负数表示错误码。这样做的好处是上层代码可以统一处理错误,不用关心具体外设的细节。

设计原则:所有接口的第一个参数都是UART_Handle指针。这是面向对象思想在C语言里的体现——把“对象”显式传进去,而不是用全局变量。这样同一个驱动可以同时管理多个UART实例,互不干扰。

4.3 中断回调注册机制

回调注册这块,我见过很多种实现方式。最简单的就是固定函数名,比如UART0_IRQHandler里直接调用某个全局函数。但这样扩展性太差。

我推荐的做法是在Handle里放一个回调函数指针数组:

/* 回调类型枚举 */
typedef enum {
    UART_CALLBACK_TX_COMPLETE,   /* 发送完成 */
    UART_CALLBACK_RX_COMPLETE,   /* 接收完成 */
    UART_CALLBACK_ERROR,         /* 错误发生 */
    UART_CALLBACK_ABORT,         /* 中止完成 */
    UART_CALLBACK_MAX
} UART_CallbackType;

/* 在Handle结构体中增加 */
typedef struct {
    /* ... 前面定义的成员 ... */
    void (*Callbacks[UART_CALLBACK_MAX])(UART_Handle *);
} UART_Handle;

注册函数实现起来也很简单:

int32_t UART_RegisterCallback(UART_Handle *handle, UART_CallbackType type, 
                               void (*callback)(UART_Handle *))
{
    if (handle == NULL || type >= UART_CALLBACK_MAX) {
        return -1;
    }
    handle->Callbacks[type] = callback;
    return 0;
}

然后在中断服务程序里这样调用:

void UART_IRQHandler(UART_Handle *handle)
{
    /* 检查中断标志位 */
    if (/* 发送完成 */) {
        handle->TxBusy = 0;
        if (handle->Callbacks[UART_CALLBACK_TX_COMPLETE]) {
            handle->Callbacks[UART_CALLBACK_TX_COMPLETE](handle);
        }
    }
    /* 类似处理接收和错误 */
}

我曾经踩过的坑:回调函数里千万不要做耗时操作,比如打印日志或者延时。中断上下文里时间很宝贵,回调应该只做“通知”工作,真正的数据处理放到主循环或者任务里。有一次我在回调里直接调了printf,结果串口输出乱码,查了半天才发现是中断嵌套把缓冲区搞乱了。

4.4 接口实现要点

Init函数的实现流程,我整理成了一张图:

UART_Init 执行流程 UART_Init 入口 参数合法性检查 通过? 返回错误码 外设时钟使能 GPIO引脚复用配置 波特率/数据位/校验配置 中断使能(可选) 返回 0 (成功)

Send和Receive的实现,核心就是操作数据寄存器。阻塞模式用轮询+超时,中断模式靠状态机驱动。这里我给出一个简化版的Send实现思路:

int32_t UART_Send(UART_Handle *handle, uint8_t *pData, uint16_t size, uint32_t timeout)
{
    uint32_t tickstart = GetTick();
    
    /* 参数检查 */
    if (handle == NULL || pData == NULL || size == 0) return -1;
    if (handle->TxBusy) return -2;  /* 上次发送未完成 */
    
    handle->TxBusy = 1;
    
    while (size--) {
        /* 等待发送寄存器空 */
        while (!(handle->Instance->SR & UART_SR_TXE)) {
            if ((GetTick() - tickstart) > timeout) {
                handle->TxBusy = 0;
                return -3;  /* 超时 */
            }
        }
        /* 发送一个字节 */
        handle->Instance->DR = *pData++;
    }
    
    /* 等待发送完成 */
    while (!(handle->Instance->SR & UART_SR_TC)) {
        if ((GetTick() - tickstart) > timeout) {
            handle->TxBusy = 0;
            return -3;
        }
    }
    
    handle->TxBusy = 0;
    return 0;
}

关于超时处理:GetTick()函数要由平台层提供,返回毫秒级的时间戳。我在不同平台上用过SysTick、TIMER、甚至RTC来实现。关键是精度要够,而且不能阻塞中断。曾经有个项目用了HAL_Delay来实现超时,结果中断里一调用就死锁,教训深刻。

4.5 接口定义检查清单

最后,我整理了一份接口定义的检查清单,你在设计自己的HAL层时可以对照着看:

检查项 说明 优先级
参数检查 所有接口入口都要做NULL指针和参数范围检查 必须
重入保护 Busy标志或互斥锁防止并发调用 必须
超时机制 阻塞接口必须有超时退出路径 必须
错误码定义 统一错误码,方便上层排查 建议
回调保护 调用回调前检查函数指针是否为NULL 必须
中断安全 中断服务程序里不能调用阻塞接口 必须
低功耗适配 Init/DeInit要考虑时钟门控和电源管理 建议