CH32V307模块化代码移植实战:从ARM到RISC-V的架构适配
CH32V307这颗芯片最近在嵌入式圈子里讨论度不低主频144MHz片内集成了以太网MAC、高速USB和CAN这个资源密度让很多原本在其它Cortex-M平台上有存量代码的开发者动了念头——把模块化代码搬过来。这篇是CH32V307移植系列的补充章前面几篇讲了工程搭建和基础外设的适配这篇专门补一块当你的工程是分模块、分层的写法而不是一个main.c塞到底时移植过程中真正会卡住你的点在哪里。为什么单独拎出来说模块化代码因为单一文件工程换芯片改的是寄存器、库函数、引脚宏改完一周能跑但模块化工程换芯片改的是抽象层、接口边界、模块之间的数据流向一个地方没理顺业务层代码就得跟着改那才叫灾难。我之前把一个跑在Cortex-M4上的多模块小系统往CH32V307上搬前前后后遇到不少和架构相关的坑这篇把思路串起来给正要动手的同行一些参考。1. 移植前先认清这枚芯片的脾气和常见ARM核的六大差异先别急着复制代码。模块化代码移植不比裸机Demo前提是你得搞清楚新平台和老平台的底层差异在哪里否则后面每个模块都会出怪问题。CH32V307用的是青稞V4F内核RISC-V指令集架构支持RV32IMAC带单精度FPU和DSP扩展指令。片内资源大概是这么个家底256KB Flash、64KB SRAM不同封装版本会有差异我手上这款是这两个数外设方面以太网MAC、USB2.0高速OTG内置PHY、USB全速、CAN 2.0B、三路12位ADC、两路DAC、多个USART/SPI/I2C还有FSMC和DVP摄像头接口。做小型网关、数据采集、传感器汇聚这类项目这配置相当够用。但硬件参数只是表面真正影响模块化移植的是下面这六个架构层面的差异差异点Cortex-M常见实现CH32V307上的情况对移植的影响启动流程VTOR指向中断向量表通过mtvec寄存器设置异常入口启动文件结构不同启动文件和启动阶段代码不能直接复用中断控制器NVIC统一管理外设中断PLIC平台级中断控制器 内核异常入口分离中断开关、优先级配置接口完全不同系统节拍SysTick定时器内核级SystemTick按RISC-V Machine Timer实现调度器/延时代码需要重写底层浮点栈帧硬件浮点ABI按AAPCS标准RISC-V的ilp32f/ilp32d浮点ABI编译选项错误可能直接导致FPU寄存器现场破坏位域与结构体布局ARM EABI对位域分配有约定RISC-V GCC按自身ABI处理协议帧直接映射结构体解析时极易踩坑库函数风格各家厂商基于CMSIS官方外设库风格接近传统外设库但不完全等同驱动层代码需要做API翻译对照这几点你心里就有数了模块化代码里哪些文件能原封不动搬哪些文件必须重写底层。一般业务逻辑、协议解析、状态机这些和应用处理器架构无关的模块基本可以完整保留而围绕外设中断、时间基准、启动初始化的模块需要做一次深度适配。还有一点很多人忽略字节序。ARM和RISC-V都可以工作在little-endian模式下固件里直接往寄存器写多字节数据时没问题但如果你原来的模块代码里有从大端网络序转换到小端主机序的代码务必确认CH32V307这边的字节序工具函数是否可用。别小看这点某次我看到一个同事把原来的ntohl/htonl网络序转换宏直接拷贝过来在CH32V307上编译通过但运行结果完全不对排查很久才发现是__BYTE_ORDER__宏在两端工具链上取值路径不同。2. 模块化代码的硬件抽象层设计从能用到好移植我见过太多人说的模块化其实只是把功能拆成十几个源文件每个文件里照样直接调寄存器操作宏换芯片的时候所有文件都要改。真正好移植的模块化工程核心不是文件拆分而是硬件抽象层做得够不够干净。2.1 外设资源映射先对着表格过一遍迁移之前先把老平台的每个外设和CH32V307的外设做一次等价映射。以我之前那个模拟项目为例它在老平台上用的是三路串口、三路定时器、两路DMA和若干GPIO映射到CH32V307上大概是这样的功能模块老平台资源CH32V307对应资源移植难度调试串口UART1 DMAUSART1同样可以配DMA低库函数直接翻译数据采集串口UART2USART2注意引脚复用位置不同中引脚映射需重新配置设备间通信串口UART3USART3中中断号名称不同系统tick调度SysTick 1ms中断内核SystemTick还是1ms中断低但中断入口名称变化PWM输出TIM3_CH1TIM3_CH1引脚可能在另一组中查重映射按键扫描计时TIM4定时查询TIM4SPL下初始化代码风格一致低参数掉电保存片内Flash模拟EEPROM片内Flash支持按页擦写高需要按新平台Flash规格重写这张表填完之后你会发现一件事CH32V307的外设库函数名字、结构体风格和很多老平台外设库非常接近甚至你敢的话驱动文件里把xxx_Init改成USART_Init代码大体就能编译。这个相似性对移植是好事但也是陷阱——你以为换个函数名就完事实际上时钟树使能方式、引脚复用配置、中断向量名、DMA请求号这些细节全部不同。所以我的建议是硬件抽象层一定要单独拎出来。哪怕老工程里没有这次移植也补上。不然后续每次换芯片都要从头梳理一遍。2.2 稳定接口头文件的三条原则抽象层做得好不好看头文件就知道。我给自己定的三条硬规矩第一头文件里只暴露接口函数和数据回调绝不暴露寄存器地址和结构体定义。模块外部看到的应该是uart_config_t、uart_rx_callback_t这类稳定类型而不是*(volatile uint32_t *)0x40000000。第二函数命名统一加hal_前缀参数类型全部用固定宽度整数uint8_t就是uint8_t别在头文件里用char模糊处理。CH32V307的编译器对char默认是无符号还是有符号跟有些ARM工具链不一样一旦模块之间用char传递状态码出问题极难排查。第三功能裁剪用条件编译宏控制但尽量收敛在抽象层内部。比如HAL_UART_NUM、HAL_TIMER_USE_DMA这类宏在hal层的配置头文件里统一打开、关闭不要散落到业务模块里到处#ifdef。业务层一旦出现大范围平台判断宏说明抽象层设计失败了。/* hal_uart.h 示例 */ #ifndef HAL_UART_H #define HAL_UART_H #include stdint.h typedef enum { HAL_UART_1, HAL_UART_2, HAL_UART_3 } hal_uart_id_t; typedef void (*hal_uart_rx_cb_t)(uint8_t ch); void hal_uart_init(hal_uart_id_t id, uint32_t baud); void hal_uart_send_byte(hal_uart_id_t id, uint8_t ch); void hal_uart_set_rx_callback(hal_uart_id_t id, hal_uart_rx_cb_t cb); #endif2.3 回调函数是多模块协作的粘合剂模块化代码里最常见的依赖方向是协议解析模块需要从串口拿数据业务模块需要知道协议解析结果按键模块需要把事件抛给业务层。如果这些依赖都是直接函数调用模块之间就绑死了。我的做法是全部通过回调注册解耦。串口驱动只负责把字节推进环形队列然后回调协议解析模块的入口协议解析模块把解析结果封装成事件再通过另一个回调通知业务层。这样移植的时候驱动层再怎么换平台业务层和协议层文件一行不用改。以串口接收为例抽象层内部这样组织/* drv_uart1.c 内部实现片段 */ static volatile uint8_t uart1_rx_buf[256]; static volatile uint16_t uart1_rx_head 0; static volatile uint16_t uart1_rx_tail 0; static hal_uart_rx_cb_t uart1_rx_cb 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ch (uint8_t)USART_ReceiveData(USART1); uart1_rx_buf[uart1_rx_head] ch; uart1_rx_head (uart1_rx_head 1) % 256; if (uart1_rx_cb) { uart1_rx_cb(ch); } } }业务模块里注册回调只认hal_uart_set_rx_callback这个接口它不需要关心底层是USART1还是USART2更不需要关心中断向量函数叫什么名字。这就是移植时业务层零改动的根本保障。3. 从ARM到RISC-V的实战移植拆解三个典型模块理论说得再多不如把代码拆开看一遍。下面三个模块是我那个模拟项目里最典型的场景每一个都对应一个独立的坑。3.1 系统节拍与调度器tick源换了接口别换老平台上用的是Cortex-M内核的SysTick1ms中断一次中断处理函数里维护一个毫秒计数然后调用调度器的process函数。CH32V307也有系统Tick定时器但它的实现对应RISC-V的Machine Timer机制中断入口虽然也叫SysTick_Handler底层配置函数却是另一套。移植时我的做法是保留调度器模块的所有对外接口只把hal_time层重新实现一遍/* hal_time.c 在CH32V307上的实现 */ #include ch32v30x.h static volatile uint32_t g_tick_ms 0; void SysTick_Handler(void) { g_tick_ms; scheduler_process(); } void hal_time_init(void) { /* 配置1ms系统节拍 */ SysTick_Config(SystemCoreClock / 1000U); } uint32_t hal_time_get_ms(void) { return g_tick_ms; }注意几个细节SystemCoreClock这个全局变量在CH32V307的外设库中是存在的但它的值取决于你在system_ch32v30x.c里选择的工作时钟是HSI还是HSE。如果你在初始化里把系统时钟切到144MHz而某个模块还按默认的8MHz计算定时初值整个调度周期就会慢18倍。另外老平台代码里如果直接写了NVIC_SetPriority(SysTick_IRQn, 0)这类调用在CH32V307上需要查一下中断向量号名称它的系统节拍中断向量号定义方式可能和你熟悉的不一样。我当时是先不设优先级用默认值跑通再回头细调。3.2 串口帧协议解析模块驱动重写、队列保留我那个项目里有一套私有帧协议帧头、长度、数据、校验。底层是三路串口收到不定长数据协议层做校验和解析。移植最顺利的部分就是协议解析层——只要给它的接口是给我一个字节和给你一帧结果它完全不需要知道底层是哪个芯片的哪个串口。底层的重写集中在两件事引脚复用配置和使用官方外设库的USART_Init。/* hal_uart.c 中UART1初始化片段 */ void hal_uart_init(hal_uart_id_t id, uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure {0}; USART_InitTypeDef USART_InitStructure {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); /* TX和RX引脚 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baud; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); NVIC_EnableIRQ(USART1_IRQn); }这里要说一个老平台上常见的习惯很多开发者喜欢在串口中断里直接做协议解析边收边判断帧头甚至直接在中断里调用memcpy。这个习惯在Cortex-M上勉强能跑在CH32V307上会很危险。因为RISC-V的中断模型虽然支持嵌套但有讲究中断服务函数里处理事务越短越好。改成中断只做环形队列入队的方式把协议解析放到主循环里跑稳定性立刻上一个台阶。3.3 按键与LED状态机纯C逻辑零平台依赖按键扫描、消抖、短按长按判断、LED闪烁状态机——这类模块简直是模块化代码的最佳范例因为它们几乎不碰寄存器只需要两个能力读取引脚电平、获取当前时间。我的按键模块是这样的typedef enum { KEY_STATE_IDLE, KEY_STATE_DEBOUNCE, KEY_STATE_PRESSED, KEY_STATE_RELEASE_WAIT } key_state_t; typedef struct { uint32_t gpio_port; uint16_t gpio_pin; uint32_t last_tick; key_state_t state; uint8_t debounce_cnt; } key_t;模块内部只用hal_gpio_read_pin()和hal_time_get_ms()这两组函数在不同平台上的实现不同但接口完全一致。移植的时候我只需要在CH32V307的hal_gpio层把下面这段写好uint8_t hal_gpio_read_pin(uint32_t port, uint16_t pin) { return GPIO_ReadInputDataBit((GPIO_TypeDef *)port, pin) ? 1 : 0; }这套代码在CH32V307上跑起来一路畅通问题反而出在一个低级地方按键引脚的GPIO_Mode配置。老平台上输入上拉是GPIO_Mode_IPUCH32V307的官方外设库沿用了这个命名但如果你习惯用老平台寄存器级代码写GPIOB-CRL ...那就要换一套规则了。所以我的经验是只要是和新平台库函数打交道就规规矩矩用库函数别拿ARM那套寄存器位操作习惯硬套省那几行代码后面调试多花三倍时间。4. 移植友好性检查链接脚本、启动文件与编译选项模块化代码往往涉及多编译单元、多静态库、各种初始化段所以链接脚本和编译选项对移植成败的影响比单文件工程大得多。这一节讲几个我实际踩过的点。4.1 链接脚本里的栈堆分配不是改个数字那么简单CH32V307的Flash起始地址是0x08000000SRAM起始地址是0x20000000和很多老ARM平台保持一致这在链接脚本层面是件好事大部分memory region定义可以直接沿用。但栈和堆的设置必须重新审视。老平台默认栈2KB因为我原来的模块化工程里中断嵌套层级不多勉强够用。但CH32V307这颗芯片外设多如果你启用了USB、以太网、多路串口中断回调、DMA描述符、协议缓冲都会抢占内存2KB栈很容易不够。我第一次跑起来就遇到系统运行几分钟后莫名其妙进入异常排查到最后是栈溢出。项目老平台默认值CH32V307建议值说明栈大小2KB4KB-8KB中断嵌套 库函数调用较深时更安全堆大小8KB若不用动态内存设为0用了malloc务必显式配置协议缓冲每路256B每路512BCH32V307串口支持DMA时缓冲可适当加大还有一个关键细节堆大小如果设置为0而代码里某个模块第一次调用malloc就会返回NULL有些模块代码拿到NULL不检查直接使用表现形式就是随机崩溃。这种崩溃特别难查。我的做法是移植后全局搜索一遍malloc和free确认每个模块的动态内存需求能改成静态环形队列的尽量改掉。4.2 编译选项与浮点ABI工具链的隐藏规则这是模块化代码移植里最隐蔽的坑之一。如果你的模块代码是老平台上用GCC编译的换到RISC-V GCC后编译选项如果不匹配会出现三种现象链接报错、运行时浮点参数异常、FPU寄存器现场被破坏。RISC-V GCC的浮点相关选项是这样组合的-marchrv32imac -mabiilp32无硬件浮点用软件浮点库-marchrv32imafc -mabiilp32f启用单精度硬件浮点-marchrv32imafdc -mabiilp32d启用双精度硬件浮点CH32V307一般用不到CH32V307带FPU理想配置是-marchrv32imafc -mabiilp32f。问题在于工程里的某些模块可能是从老平台拷贝过来的Makefile里还残留着-mfloat-abihard或者-mfpufpv4-sp-d16这类ARM选项到了RISC-V GCC下编译器虽然不报错因为确实也有类似选项但实际生效的浮点支持可能是错的。我第一次移植时大部分文件能编译唯独有几个模块链接阶段报call to __aeabi_fadd requires -mfloat-abisoftfp。这就是典型的ARM浮点ABI符号残留。解决办法是统一检查所有模块的编译参数去掉ARM专有选项全部改成RISC-V的-march和-mabi组合。还有一点所有参与链接的目标文件和静态库浮点ABI必须一致。你可以通过readelf -A查看某个.o文件使用的是什么浮点ABI如果发现一个库是用软浮点编的混链进来就会出现运行时计算结果随机出错。排查这类问题时最快的方法是全部重新编译。4.3 全局中断开关与临界区RISC-V不是NVIC模块化代码里环形队列、共享变量、状态切换到处都需要临界区保护。老平台上的习惯写法是__disable_irq(); // 临界区代码 __enable_irq();这套接口在CH32V307的RISC-V工具链里不一定存在即使存在行为也不完全一样。RISC-V的全局中断开关操作的是mstatus寄存器的MIE位或PLIC层的全局使能位而ARM的__disable_irq()操作的是PRIMASK。你不能假设两者等价。正确的做法是把临界区操作也封装到抽象层/* hal_critical.h */ uint32_t hal_critical_enter(void); void hal_critical_exit(uint32_t ctx); /* CH32V307实现 */ #include core_riscv.h uint32_t hal_critical_enter(void) { uint32_t ctx riscv_get_mstatus(); riscv_disable_interrupts(); return ctx; } void hal_critical_exit(uint32_t ctx) { riscv_set_mstatus(ctx); }我实际测试过直接照搬老平台的__disable_irq()而不用上述封装在某些场景下中断没有被真正关闭环形队列的多写一读场景会偶发数据错乱。改成封装之后问题消失。所以模块化代码里临界区API一定要独立底层随平台换实现上层完全不用动。4.4 启动文件、构造函数与printf重定向模块化工程里经常有用__attribute__((constructor))做模块初始化的写法老平台的启动文件自动支持到了CH32V307上要确认启动文件是否调用了__libc_init_array。如果没调用所有构造函数都不执行模块化框架跑起来静默失败最坑。检查方法很简单在某个构造函数里设置一个全局标记位跑起来后看标记位有没有被置位。printf重定向也是老生常谈。老平台的重定向函数fputc或_write里通常直接操作UART寄存器。到CH32V307上寄存器变了函数体必须改。我见过不少人忘了这个结果串口工具里什么输出都没有还以为是波特率错了。改好之后建议用fprintf(stderr, ...)输出调试信息如果重定向里没有写stderr对应文件描述符也要补上。5. 实际跑通后的典型疑难从复现到定位跑通不等于跑稳。下面这三个问题是我在CH32V307上搬完模块化代码之后真真正正遇到的每个都花了大半天以上排查。写出来分享排查链路希望对你有帮助。5.1 问题一运行几十秒后程序进入异常状态现象系统启动后功能正常运行大约40秒左右突然挂掉。用调试器暂停PC停在一个无效地址栈内容被冲得不成样子。排查过程我先在怀疑的几个中断处理函数入口加GPIO翻转看看到底死在哪条路径上结果发现每次都是在一路串口持续接收大数据时复现。进一步缩小范围后定位到协议解析模块在数据量大时频繁调用malloc分配临时缓冲区。而CH32V307这边链接脚本里Heap_Size被我一开始设成了0。根因堆空间为零malloc返回NULL协议模块没做返回检查直接往NULL地址写数据内存写坏几十秒后触发异常。这不是芯片问题是移植时配置错误加模块健壮性不足的双重问题。解决把协议模块的临时缓冲区改成静态数组按最大帧长分配彻底去掉运行期动态分配。同时把Heap_Size设为4KB兜底防止其它依赖C库内部malloc的路径崩溃。复盘模块化代码里隐藏的动态内存依赖是最难发现的。你模块头文件里没写用malloc函数实现里却悄悄用了移植平台堆配置一变问题就爆出来。建议移植后全工程搜索malloc、free、realloc逐个确认用途。5.2 问题二串口输出乱码波特率明明配置正确现象USART1配置成115200终端里收到的全是乱码配置成9600时反而看起来接近正常但还是有偶发错位。排查过程这个问题表面看是波特率计算错误。我第一反应是检查USART_Init里的分频计算但代码是从官方例程抄的不应该有错。后来用示波器量了TX引脚的电平宽度发现每个bit的实际时长比预期偏大了约10倍。这才意识到系统时钟根本不是144MHz而是8MHz。根因工程初始化代码里SystemInit使用的是HSI默认配置没有切换到外部晶振HSE而SystemCoreClock全局变量却被某个模块的初始化代码改成了144000000。USART_Init内部用SystemCoreClock做波特率分频实际时钟却是8MHz算出来的分频值自然不对。乱码、错位的严重程度取决于你设定的波特率与实际时钟的偏差比例。解决统一时钟配置入口检查system_ch32v30x.c里的时钟宏选择确认当前板载晶振频率然后用RCC_GetClocksFreq读取实际时钟打印出来核对。不要手动修改SystemCoreClock它是库函数的输出变量不是你的配置项。复盘模块化工程里多个模块共用时钟变量时最容易出现你改了系统时钟配置但没同步给所有模块的问题。移植时务必先花10分钟搞清楚目标板的时钟树再动代码。5.3 问题三高优先级中断里操作队列瞬间击穿临界区现象系统有多个串口其中一个串口数据量特别大且频率高。每当数据高峰期另一个串口偶尔会丢失几个字节或者协议帧校验失败。排查过程刚开始以为是环形队列容量不够加大缓冲后仍有损耗。后来在队列读写函数里临时加全局计数发现同一瞬间有两个不同上下文在修改队列索引。原因很清楚大流量串口的中断优先级更高在它中断服务里改了公共缓冲区而低优先级串口的中断恰好也进入了两个中断上下文共享缓冲区却没有互斥保护。根因老平台上的临界区封装在ARM上有效但CH32V307的riscv_disable_interrupts()关闭的是机器模式全局中断如果PLIC层面的中断使能没有同步关闭某些快速中断通道仍然会进来。结果是原封不动的模块代码在ARM上没事在CH32V307上就会出现临界区失效。解决重新封装临界区函数进入临界区时同时关全局中断和PLIC外设中断通道退出时反向恢复。同时在环形队列的读写函数里强制实施临界区保护哪怕一步读写在ARM上看起来是原子的在RISC-V上也不一定。复盘这个坑的教训是临界区保护不是代码风格问题而是硬件架构级的行为差异。模块化代码在跨架构移植时所有共享数据访问必须重新审视原子性假设。6. 验证与回归移植完成后怎么证明代码真的稳了代码全部编译通过串口能打印数据LED能闪这只是能跑。模块化代码移植完成的标准应该是业务层零改动、驱动层可追溯、系统长时间运行稳定。我有三个验证手段每次移植完都会做一遍。6.1 分阶段bring-up顺序不要一次性把所有模块都扔进去跑。我的顺序是先点亮一颗LED验证时钟、GPIO、启动流程→ 再打通调试串口验证UART驱动和printf→ 再跑系统tick验证中断和调度器→ 再挂一路通信串口验证协议解析和环形队列→ 最后把业务状态机、按键、存储这些模块逐个加回来。每加一个模块就完整跑一轮基本功能测试。这样一旦出问题定位范围就是最新加入的那个模块排查成本大大降低。尤其是CH32V307这种外设丰富的芯片一次全开容易分不清是哪个外设的配置把系统拖垮。6.2 压力测试与运行指标采集模块化系统稳定不稳定要靠数据说话。我做这样几项验证第一三路串口全部用外部工具循环发送随机长度数据跑12小时以上校验帧错误率第二串口数据发送用周期性突发模式模拟真实业务高峰观察环形队列水位第三在软件里记录每次进入异常前的PC值和栈指针放一个专门标志区方便复位后dump。栈水位检测是我最喜欢的一个方法初始化时把整个栈区域填充成固定模式如0xA5运行一段时间后扫描还有多少字节没被动过。CH32V307的SRAM够大这个检测开销可以接受却能直观告诉你当前栈余量是否安全。老平台上2KB栈能跑新平台上改成4KB后我实际扫描发现最深路径用到2.7KB验证了改动的必要性。6.3 模块化回归的最终检验最后一步也是最能证明抽象层设计好坏的检验统计移植代码的git提交记录。我的预期结果是业务层模块协议解析、状态机、业务逻辑提交次数为零或仅有空白字符调整服务层模块调度器、队列、日志提交次数很少集中在配置参数调整驱动层模块hal层、外设初始化提交次数占绝大多数如果业务层文件改动了超过三处说明抽象层的边界划分有问题或者接口设计时混入了平台假设。这时候不要急着交付回头把接口重新调整一下。别嫌麻烦因为下次换芯片、或者下次工程师接手省下来的时间远远超过现在多花的这几十分钟。这个检查方法我用过很多次每次都能逼着我把模块化代码再打磨一遍。它比任何代码审查工具都管用因为git提交记录不会骗人。回到文章开头那个话题CH32V307这套硬件做模块化代码移植的底子是很好的。RISC-V和ARM的差异确实存在但只要你把架构差异挡在抽象层外面把业务逻辑留在模块内移植工作做起来并没有想象中那么痛苦。这篇补充章写到的经验都是我实际交叉移植中沉淀下来的希望能让你少走几步弯路。如果你正好在搬某个模块化系统欢迎对照着检查一遍自己的边界设计。