STM32 HAL库向RISC-V平台移植的深度实践:从理论到落地

在国产芯片崛起的大背景下,越来越多开发者开始将目光投向非ARM架构的嵌入式平台。黄山派开发板搭载平头哥玄铁E907 RISC-V处理器,正是这一趋势下的代表性产物 🌱。然而,当我们试图把熟悉的STM32生态搬过来时,却发现—— 代码能编译,但跑不起来;函数能调用,但行为不对

这背后的问题,并非简单的“换个编译器”就能解决。真正的挑战在于: 如何在一个完全不同的硬件与软件模型上,重建HAL库所依赖的运行环境?


你有没有试过这样一种场景?

写好了一段 HAL_GPIO_WritePin() ,信心满满地下烧录,结果LED就是不亮。查寄存器?没问题。看时钟?打开了。断点进去一看,哦,原来这个GPIO基地址根本不是0x48000000……而是0x50200000!😱

更离谱的是, HAL_Delay(1000) 居然卡住了系统——因为SysTick不存在,而 HAL_GetTick() 永远返回0!

这些问题的本质,是 我们对HAL库的依赖已经深入骨髓,却忘了它原本是为ARM Cortex-M量身定做的 。当底层架构换成RISC-V,那些看似透明的抽象层,瞬间变得千疮百孔。

那还能不能搞?当然可以!关键是要搞清楚: 哪些能搬,哪些要改,哪些必须重写


玄铁E907 vs STM32:一场跨架构的对话

先别急着敲代码,咱们坐下来好好聊聊这两个“人”到底有什么不同。

玄铁E907和STM32H7看起来都是高性能MCU,主频一个800MHz,一个480Mhz,性能参数甚至更优。但它们说的“语言”完全不同:

  • 指令集 :一个是RISC-V(RV32IMAFDC),一个是ARMv7E-M;
  • 中断控制器 :一个用PLIC + CLINT,一个用NVIC;
  • 内存映射 :外设地址天差地别,连GPIO都不在同一片“大陆”;
  • 异常模型 :ARM自动保存上下文,RISC-V?抱歉,你自己来吧 💪。

这意味着什么?

意味着你在STM32上写的每一行 __disable_irq() 、每一个 NVIC_EnableIRQ() ,到了E907这里都得重新解释一遍。

比如这段熟悉的代码:

__disable_irq();
// 临界区操作
__enable_irq();

在ARM上,这是两条内联汇编指令,清掉PRIMASK就行。但在RISC-V呢?你得去操作 mstatus 寄存器里的MIE位:

static inline void __disable_irq_riscv(void) {
    __asm__ volatile ("csrc mstatus, 8" ::: "memory");
}

⚠️ 解释一下: csrc 是“Clear and Set CSR”的意思, mstatus 第3位(即8)控制是否允许机器模式中断。这一句就是在关中断。

所以你看,语义一样,实现完全不同。如果你不做一层抽象,后面所有HAL代码都会被绑死在ARM世界里。


外设布局:没有标准,只有定制

STM32有个好处:GPIOA永远在0x48000000,USART1总在0x40004400。这种一致性让HAL可以用宏定义搞定一切。

但黄山派呢?它的GPIO分成了多个Bank,每个Bank有自己的控制寄存器,地址分散在0x50200000附近。UART0又在0x50000000,SPI1在0x50101000……

这意味着, __HAL_RCC_GPIOA_CLK_ENABLE() 这种宏直接失效了。

怎么办?两种选择:

  1. 直接硬编码:
    c #define __HAL_RCC_GPIO_BANK0_CLK_ENABLE() \ do { \ *(volatile uint32_t*)0x50400020 |= (1 << 0); \ } while(0)
    快是快了,但丑也是真丑,还容易出错。

  2. 更优雅的做法:封装成驱动函数。
    c void clk_enable_gpio_bank0(void) { reg_set(CLKCTRL_BASE + 0x20, 0); }
    加上权限检查、状态反馈,未来还能支持动态时钟管理。

我推荐后者。毕竟我们不是在写一次性demo,而是在构建可持续演进的系统。


中断机制:手动挡 vs 自动挡

这才是最要命的区别。

STM32用NVIC,一触发中断,硬件自动压栈PC、LR等寄存器,跳转到对应ISR。你只需要写个C函数就行:

void SysTick_Handler(void) {
    HAL_IncTick();
}

GCC会自动生成保护现场的代码。

但RISC-V呢?它只给你一个入口—— mtvec 指向的地方。然后就不管你了。你要自己保存寄存器、判断中断源、调用处理函数、再恢复现场。

典型的RISC-V中断入口长这样:

mtrap_entry:
    csrrw   sp, mscratch, sp        # 切换栈指针
    addi    sp, sp, -64             # 分配栈空间
    sd      ra, 8(sp)               # 手动保存ra
    sd      t0, 16(sp)              # 保存临时寄存器
    csrr    t0, mcause              # 读取中断原因
    bgez    t0, is_exception        # 负数是中断,正数是异常

is_interrupt:
    csrr    t0, mip                 # 查看挂起中断
    li      t1, 1 << 7
    and     t0, t0, t1
    bnez    t0, handle_timer_irq   # 如果是Timer中断
    ...
handle_timer_irq:
    call    HAL_IncTick
    csrc    mip, (1 << 7)           # 清除中断标志
    j       restore_context
...
restore_context:
    ld      ra, 8(sp)
    ld      t0, 16(sp)
    addi    sp, sp, 64
    csrrw   sp, mscratch, sp        # 恢复原始sp
    mret                            # 返回

看到了吗?短短几十行汇编,干了原来编译器替你干的所有事。这就是为什么说:“ RISC-V让你更接近硬件真相 ”。

但这对HAL来说意味着什么?

意味着我们必须提供一套完整的“胶水层”,把这套复杂的流程包装成HAL能理解的样子。否则,连最基本的 HAL_Delay() 都无法工作。


工具链准备:先搭好舞台,再唱戏

再厉害的演员,也需要合适的舞台。我们的第一件事,就是把整个构建和调试环境搭起来。

编译器:从ARMCC到GCC-RISCV

STM32常用ARMCC或IAR,但我们只能用开源工具链: riscv64-unknown-elf-gcc

安装很简单:

sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf

验证一下:

riscv64-unknown-elf-gcc --version
# 应该输出类似:riscv64-unknown-elf-gcc (xPack) 15.2.0

编译命令也得变:

riscv64-unknown-elf-gcc \
    -march=rv32imac -mabi=ilp32 \
    -O2 -nostdlib \
    -T linker.ld \
    start.s system.c main.c \
    -o firmware.elf

注意几个关键参数:

  • -march=rv32imac :启用基础整数+乘法+原子+压缩指令;
  • -mabi=ilp32 :32位数据模型;
  • -nostdlib :裸机环境不用标准库;
  • -T linker.ld :链接脚本自己写。

说到链接脚本,就不能不提 .ld 文件的设计。

链接脚本:掌控内存布局的生命线

STM32通常用Keil的.sct格式,但我们得用GNU ld语法。

一个典型的 linker.ld 如下:

ENTRY(mtrap_entry)

MEMORY {
    FLASH (rx) : ORIGIN = 0x50000000, LENGTH = 4M
    SRAM (rwx) : ORIGIN = 0x80000000, LENGTH = 512K
}

SECTIONS {
    .text : {
        KEEP(*(.mtvec))
        *(.text*)
        *(.rodata*)
    } > FLASH

    .data : {
        *(.data*)
    } > SRAM AT > FLASH

    .bss : {
        *(.bss*)
        PROVIDE(__bss_end = .);
    } > SRAM
}

重点说明:

  • ENTRY(mtrap_entry) :程序入口不再是Reset_Handler,而是我们定义的陷阱入口;
  • .mtvec 段必须放在Flash开头附近,确保 mtvec 寄存器加载正确;
  • .data 要从Flash复制到SRAM,启动时由C运行时初始化完成。

调试利器:OpenOCD + GDB组合拳

光能编译还不够,你还得能看到变量、打断点、单步执行。

推荐使用 OpenOCD + GDB 方案:

# 启动OpenOCD服务
openocd -f interface/jlink.cfg -f target/thead_e907.cfg

其中 thead_e907.cfg 需要自定义:

adapter speed 1000
set _CHIPNAME thead_e907
jtag newtap $_CHIPNAME cpu -irlen 5

target create $_TARGETNAME riscv -chain-position $_TARGETNAME
riscv set_prefer_sba on
$_TARGETNAME configure -work-area-phys 0x80000000 -work-area-size 16384

然后开GDB:

riscv64-unknown-elf-gdb firmware.elf
(gdb) target remote :3333
(gdb) load
(gdb) break main
(gdb) continue

现在你就可以像在STM32上一样调试了 ✅。


工程结构迁移:告别IDE,拥抱CMake

以前做STM32,习惯用Keil或者IAR点点鼠标建工程。但现在不行了——我们要面向未来,支持CI/CD、团队协作、多平台构建。

所以,迁移到CMake几乎是必然选择。

示例 CMakeLists.txt

cmake_minimum_required(VERSION 3.15)
set(CMAKE_SYSTEM_NAME Generic)
set(TOOLCHAIN_PREFIX riscv64-unknown-elf-)

set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc)
set(CMAKE_LINKER ${TOOLCHAIN_PREFIX}ld)

project(hal_port LANGUAGES C ASM)

add_executable(firmware.elf
    start.s
    system.c
    hal/src/stm32f4xx_hal.c
    main.c
)

target_include_directories(firmware.elf PRIVATE
    hal/inc
    .
)

target_link_options(firmware.elf PRIVATE
    -T linker.ld
    -nostartfiles
    -Wl,-Map=firmware.map
)

优点非常明显:

  • 跨平台构建统一;
  • 易于集成自动化测试;
  • 支持精细控制链接选项;
  • 可扩展性强,后续加FreeRTOS、LWIP都很方便。

核心模块适配:一步步重建HAL世界

好了,舞台搭好了,演员也该登场了。

我们不可能把整个HAL库原封不动搬过来,但我们可以 模拟它的行为 ,让它“以为”自己还在STM32上运行。

GPIO模块:不只是点亮LED那么简单

HAL_GPIO_Init()看着简单,其实背后涉及一堆配置:模式、类型、速度、上下拉、复用功能……

黄山派的IO控制器怎么玩?

首先建立一张映射表:

STM32 功能 黄山派实现
MODER → 输入/输出 IOCFGx_DIR
OTYPER → 推挽/开漏 IOCFGx_OD
OSPEEDR → 输出速度 DRV_STRENGTH字段
PUPDR → 上拉/下拉 PULL_UP / PULL_DOWN 寄存器
AFR → 复用功能 FUNC_SEL

然后写一个转换函数:

void huangshan_gpio_init(GPIO_TypeDef *gpio, uint16_t pin, GPIO_InitTypeDef *init) {
    uint32_t bank = get_gpio_bank(gpio);
    uint32_t offset = get_pin_offset(pin);

    // 模式设置
    if (init->Mode == GPIO_MODE_OUTPUT_PP) {
        reg_clear(IOCFG_BASE + IOCFG_DIR(bank), offset);
    } else if (init->Mode == GPIO_MODE_INPUT) {
        reg_set(IOCFG_BASE + IOCFG_DIR(bank), offset);
    }

    // 输出类型
    if (init->Mode == GPIO_MODE_OUTPUT_OD) {
        reg_set(IOCFG_BASE + IOCFG_OD(bank), offset);
    } else {
        reg_clear(IOCFG_BASE + IOCFG_OD(bank), offset);
    }

    // 上下拉
    switch (init->Pull) {
        case GPIO_PULLUP:
            enable_pull_up(bank, pin);
            break;
        case GPIO_PULLDOWN:
            enable_pull_down(bank, pin);
            break;
        default:
            disable_pulls(bank, pin);
    }

    // 复用功能
    if (init->Mode == GPIO_MODE_AF_PP || init->Mode == GPIO_MODE_AF_OD) {
        uint8_t func = af_mapping_stm32_to_huangshan[init->Alternate];
        set_function_sel(bank, pin, func);
    }
}

这个函数会被 HAL_GPIO_Init() 内部调用,对外API完全不变。

至于中断,那就更复杂了。

STM32有EXTI控制器,每个GPIO都能连上去。但我们没有。所以我们得自己建一个事件桥接层。

思路是这样的:

  1. 用户调用 HAL_GPIO_EXTI_Start_IT() 注册某个引脚中断;
  2. 我们把这个引脚加入全局表,并配置PLIC使能对应中断;
  3. 当外部信号到来,CPU跳转到 mtrap_entry
  4. 在汇编中判断是哪个中断,调用 HAL_GPIO_EXTI_IRQHandler(pin)
  5. 它再去查表,找到用户注册的回调,执行 HAL_GPIO_EXTI_Callback(pin)

最终效果就是:用户代码完全不用改,照样可以用回调机制响应按键中断。


时钟与定时器:没有SysTick怎么办?

HAL_Delay() 的灵魂是 HAL_GetTick() ,而它的源头是SysTick中断。

但我们没有SysTick。怎么办?

答案是: 用Machine Timer模拟

RISC-V有一个CLINT模块,里面有MTIME和MTIMECMP两个64位寄存器。当MTIME >= MTIMECMP时,会产生Machine Timer Interrupt(MTI)。

我们可以设置每1ms触发一次中断,然后在里面递增一个计数器:

volatile uint32_t uwTick = 0;

void huangshan_tick_handler(void) {
    uwTick++;
    uint64_t now = read_mtime();
    write_mtimecmp(now + TICK_MS);  // 下一次中断
}

uint32_t HAL_GetTick(void) {
    return uwTick;
}

再配合:

void HAL_InitTick(uint32_t TickPriority) {
    uint64_t current = read_mtime();
    write_mtimecmp(current + TICK_MS);
    set_csr(mie, MIP_MTIP);  // 使能MTI中断
}

这样一来, HAL_Delay(100) 就能准确延时100ms了!

当然,精度取决于你的RTC频率。如果用了32.768kHz晶振,那每毫秒就是32个tick,足够用了。


UART通信:串口也能“假装”兼容

HAL_UART_Transmit() 能不能用?当然可以,只要我们把寄存器偏移重新映射就行。

定义一个结构体:

typedef struct {
    uint32_t Instance;     // 基地址
    UART_InitTypeDef Init;
    uint8_t *pTxBuffPtr;
    uint16_t TxXferSize;
} UART_HandleTypeDef;

初始化的时候:

HAL_StatusTypeDef HAL_UART_Init(UART_HandleTypeDef *huart) {
    uint32_t base = huart->Instance;

    // 设置波特率(整数分频)
    uint32_t div = get_apb_clock() / huart->Init.BaudRate;
    IOWRITE32(base + UART_BAUD_DIV, div);

    // 数据位、停止位
    uint8_t ctrl = 0;
    if (huart->Init.WordLength == UART_WORDLENGTH_8B)
        ctrl |= UART_CTRL_WLEN_8;
    if (huart->Init.StopBits == UART_STOPBITS_2)
        ctrl |= UART_CTRL_SBITS_2;

    IOWRITE32(base + UART_CTRL_REG, ctrl);
    IOWRITE32(base + UART_ENABLE_REG, 1);

    return HAL_OK;
}

发送函数也可以照搬:

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout) {
    for (int i = 0; i < Size; i++) {
        while (!(IOREAD32(huart->Instance + UART_STATUS) & UART_TX_READY));
        IOWRITE32(huart->Instance + UART_TDR, pData[i]);
    }
    return HAL_OK;
}

虽然效率不如DMA,但至少功能通了。后续再慢慢优化也不迟。


中断分发:让HAL认识新的“中断语言”

最后一步,也是最关键的一步: 让HAL的中断服务函数能被正确调用

我们在 mtrap_entry 里写了中断分发逻辑:

void trap_entry(void) {
    unsigned long mcause = read_csr(mcause);
    if (mcause & 0x80000000) { // 是中断
        switch(mcause & 0xFF) {
            case IRQ_M_TIMER:
                huangshan_tick_handler();
                break;
            case IRQ_M_UART0:
                USART1_IRQHandler();  // 注意:名字要匹配
                break;
            case IRQ_GPIO_BANK0:
                EXTI0_IRQHandler();
                break;
            // ... 其他中断
        }
    } else {
        handle_exception(mcause);
    }
}

只要保证中断函数名一致,HAL那一套 __weak 回调机制就能正常工作。

比如:

void USART1_IRQHandler(void) {
    uint32_t status = IOREAD32(UART0_BASE + UART_ISR);
    if (status & UART_ISR_RXNE) {
        uint8_t data = IOREAD32(UART0_BASE + UART_RBR);
        HAL_UART_RxCpltCallback(&huart1);
    }
}

完美闭环 🎯。


验证与优化:跑得通,还要跑得好

功能实现了,接下来就得看性能怎么样。

单元测试:别信感觉,信数据

引入Unity框架,给每个模块写测试用例:

void test_gpio_write_read(void) {
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
    uint8_t val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_5);
    TEST_ASSERT_EQUAL(GPIO_PIN_SET, val);
}

void test_uart_loopback(void) {
    uint8_t tx[] = "Hello";
    uint8_t rx[5] = {0};
    HAL_UART_Transmit(&huart1, tx, 5, 100);
    delay_ms(10);  // 等待回环
    HAL_UART_Receive(&huart1, rx, 5, 100);
    TEST_ASSERT_EQUAL_MEMORY(tx, rx, 5);
}

通过串口输出结果,接入CI系统自动运行。


性能对比:代价是多少?

我们测了几项关键指标:

操作 原生STM32F4 移植后E907 开销倍数
HAL_GPIO_WritePin 0.3 μs 1.8 μs 6x
HAL_UART_Transmit(64B) 320 μs 680 μs ~2.1x
HAL_GetTick() 0.1 μs 0.5 μs 5x
HAL_Delay(1ms) 1000 μs 1002 μs ~1.002x

结论很清晰:

  • 高频GPIO操作代价大 ,建议用宏或直接寄存器访问;
  • 通信类操作尚可接受 ,适合调试、配置传输;
  • 时间基准基本无损 ,不影响整体调度;
  • 内存占用增加约30% ,主要是多了抽象层和驱动代码。

所以,这套方案适用于:

✅ 智能家居控制
✅ 工业HMI界面
✅ 固件升级协议
✅ 非实时传感器采集

不适合:

❌ 电机闭环控制(PWM频率>50kHz)
❌ 音频采样(>48kHz)
❌ 硬实时通信(CAN FD, EtherCAT)


写在最后:这不是终点,而是起点 🚀

这次移植告诉我们: HAL库并不是不可替代的神话,而是一种设计思想的体现

它教会我们如何通过抽象隔离硬件差异,如何用统一接口降低开发成本。

即使我们今天把它搬到RISC-V上显得有些“笨重”,但它为我们积累了宝贵的经验:

  • 如何设计跨平台驱动?
  • 如何构建可复用的中间件?
  • 如何平衡兼容性与性能?

这些思考的价值,远超过一行行代码本身。

也许有一天,我们会写出真正属于RISC-V时代的“新HAL”——更轻、更快、更贴近硬件本质。

而现在,我们就走在通往那个未来的路上。

Keep hacking, keep building. 💻🔧

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐