CH32V307 这颗片子最近问的人不少,RISC-V 内核、自带以太网 MAC、价格也合适,拿来做带网口的工控小板或者物联网网关都挺顺手。但真到动手阶段,很多人卡在同一个地方:MounRiver Studio 里把 FreeRTOS 和 LwIP 一起塞进去,编译能过,跑起来要么任务起不来,要么 ping 不通,要么跑几分钟就死机。我自己前前后后在这套组合上折腾了差不多两周,把能踩的坑基本踩了一遍,这里把完整过程整理出来,包括工程怎么建、源码怎么放、中断优先级怎么配、内存怎么分、网络怎么调通,以及那些文档里不会写的细节。
这篇内容适合已经会用 MounRiver Studio 建基础工程、对 FreeRTOS 有基本概念(知道任务、队列、信号量是什么)的嵌入式开发者。如果你还没接触过 RISC-V 工具链,也没关系,涉及工具链的部分我会把路径和配置讲清楚。整套流程走下来,你应该能得到一个能稳定跑 FreeRTOS 多任务、同时 LwIP 能正常收发数据的 CH32V307 工程。
1. 先把工具链和工程骨架理清楚
1.1 MounRiver Studio 的 GCC 到底装在哪
这是搜索热词里出现频率最高的一个问题,先把它说透。MounRiver Studio 基于 Eclipse 框架,它自带的 RISC-V GCC 工具链并不在你安装 IDE 的那个目录里,而是单独放在用户目录下。Windows 上典型路径是:
C:\Users\你的用户名\MounRiver\MounRiver_Studio\beforeinstall\Toolchain\RISC-V Embedded GCC\binmacOS 和 Linux 下则在~/MounRiver/MounRiver_Studio/beforeinstall/Toolchain/下面。这个路径很重要,因为后面你要手动调用riscv-none-embed-gcc来验证版本,或者在 Makefile 工程里指定编译器时都要用到。
我建议你第一次装完之后,直接在终端里跑一下:
riscv-none-embed-gcc -v如果提示找不到命令,说明这个 bin 目录没进系统 PATH。你可以临时加一下:
export PATH=$PATH:~/MounRiver/MounRiver_Studio/beforeinstall/Toolchain/RISC-V\ Embedded\ GCC/bin提示:不要试图把整个工具链目录拷贝到别的地方再用,MounRiver 内部有些相对路径依赖,挪走之后 IDE 编译可能报找不到库。要用命令行就加 PATH,别搬目录。
确认版本信息里能看到riscv-none-embed字样和对应的 GCC 版本号,就说明工具链是好的。这一步看起来简单,但我见过不少人因为 PATH 没配好,后面在命令行编译 FreeRTOS 源码时一直报command not found,白白浪费半天。
1.2 新建工程时那几个选项怎么选
在 MounRiver Studio 里新建 CH32V307 工程,菜单是File -> New -> MounRiver Project,然后选芯片型号 CH32V307VCT6(或者你实际用的封装)。关键在后面的配置页:
- Clock:默认是 144MHz,CH32V307 最高能到 144MHz,保持默认就行。如果你后面要降频省电,记得 FreeRTOS 的 tick 配置也要跟着改。
- Debug:选 WCH-Link,这是官方调试器,用别的调试器可能识别不了。
- Library:选
None或者Standard Peripheral Library都行,但如果你打算用官方 SDK 里的以太网驱动,建议选后者,省得自己一个个加文件。 - Startup:保持默认的启动文件。
建完之后先编译一次空工程,确认能过。这一步是基线,如果空工程都编译不过,后面加 FreeRTOS 只会更乱。
1.3 目录结构怎么规划
我吃过一次亏:一开始把 FreeRTOS 和 LwIP 的源码直接丢进src目录,结果文件一多,IDE 的索引卡得要死,而且升级源码时覆盖来覆盖去很容易搞乱。后来改成这样的结构:
Project/ ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ ├── freertos_hooks.c │ └── lwip_hooks.c ├── Middlewares/ │ ├── FreeRTOS/ │ │ ├── Source/ │ │ └── Include/ │ └── LwIP/ │ ├── src/ │ └── include/ ├── Drivers/ │ ├── CH32V307/ │ └── Ethernet/ └── User/ ├── app_tasks.c └── net_config.cMiddlewares放第三方源码,Drivers放芯片和外设驱动,User放自己的应用代码。这样分层之后,IDE 里右键Refresh就能看到新文件,编译时在工程属性里把对应目录加到 include path 就行。
注意:MounRiver Studio 默认不会自动扫描新增目录,你得手动在
Project Properties -> C/C++ Build -> Settings -> Tool Settings -> GNU RISC-V Cross C Compiler -> Includes里把Middlewares/FreeRTOS/Include、Middlewares/LwIP/include这些路径加进去。漏一个就是一堆No such file or directory。
2. FreeRTOS 源码移植:从文件拷贝到第一个任务跑起来
2.1 需要哪些文件,哪些可以不要
FreeRTOS 官方源码包解压出来一大堆东西,全加进去没必要。CH32V307 是 RISC-V 内核,你需要的是FreeRTOS/Source下的:
tasks.c、queue.c、list.c、timers.c、event_groups.c、stream_buffer.c:这六个是核心,全要。portable/GCC/RISC-V/:这是 RISC-V 的移植层,里面有port.c和portASM.S,必须加。portable/MemMang/heap_4.c:内存管理,选 heap_4 是因为它支持碎片合并,长时间跑网络应用更稳。include/下所有头文件。
portable下面其他架构的目录(ARM、AVR 那些)全部不要加,加了只会拖慢编译。
2.2 FreeRTOSConfig.h 里几个必须改的参数
这个文件是整个移植的核心,官方给的模板是给 ARM 用的,直接拿来编译会报错。我改过的关键项如下:
#define configUSE_PREEMPTION 1 #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 #define configCPU_CLOCK_HZ ( 144000000UL ) #define configTICK_RATE_HZ ( 1000 ) #define configMAX_PRIORITIES ( 16 ) #define configMINIMAL_STACK_SIZE ( 256 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 48 * 1024 ) ) #define configMAX_TASK_NAME_LEN ( 16 ) #define configUSE_16_BIT_TICKS 0 #define configIDLE_SHOULD_YIELD 1 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configQUEUE_REGISTRY_SIZE 8 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1几个点解释一下。configCPU_CLOCK_HZ必须和实际系统时钟一致,CH32V307 默认 144MHz,写错了 tick 周期就不对,任务延时全乱。configTICK_RATE_HZ设 1000 表示 1ms 一个 tick,这是网络应用比较常用的值,再高中断开销大,再低延时精度不够。configTOTAL_HEAP_SIZE给 48KB,是因为 LwIP 本身还要占内存,CH32V307 有 128KB SRAM,留够余量。configCHECK_FOR_STACK_OVERFLOW设 2 是最严格的检查方式,调试阶段一定要开,能帮你快速定位栈溢出。
2.3 中断优先级和 RISC-V 的对应关系
这是 RISC-V 移植和 ARM 差别最大的地方,也是很多人翻车的地方。ARM Cortex-M 有 NVIC 的优先级分组,RISC-V 这边 CH32V307 用的是自己的中断控制器(PFIC),优先级是 4 位,范围 0 到 15,数值越小优先级越高。
FreeRTOS 要求configMAX_SYSCALL_INTERRUPT_PRIORITY这个宏定义"允许调用 FreeRTOS API 的最高中断优先级"。在 CH32V307 上我这样配:
#define configKERNEL_INTERRUPT_PRIORITY 0 #define configMAX_SYSCALL_INTERRUPT_PRIORITY 2意思是优先级 0 和 1 的中断不受 FreeRTOS 管理,可以随时打断内核;优先级 2 到 15 的中断里如果调用xQueueSendFromISR这类 API,是安全的。以太网中断我放在优先级 3,这样它既能及时响应,又能安全地往 FreeRTOS 队列里投递数据包。
提示:如果你把以太网中断优先级设成 0 或 1,然后在中断里调用 FreeRTOS 的 FromISR 函数,系统会在某个时刻莫名其妙死掉,而且很难查。这个坑我踩过,现象是 ping 几十次之后卡死,最后用调试器看中断嵌套才定位到。
2.4 第一个任务:点灯验证调度器
在main.c里先别急着上网络,写个最简单的点灯任务验证 FreeRTOS 跑起来了:
#include "FreeRTOS.h" #include "task.h" #include "ch32v30x.h" static TaskHandle_t led_task_handle; void LED_Task(void *pvParameters) { while (1) { GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_SET); vTaskDelay(pdMS_TO_TICKS(500)); GPIO_WriteBit(GPIOA, GPIO_Pin_0, Bit_RESET); vTaskDelay(pdMS_TO_TICKS(500)); } } int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); SystemCoreClockUpdate(); Delay_Init(); GPIO_InitTypeDef gpio = {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); gpio.GPIO_Pin = GPIO_Pin_0; gpio.GPIO_Mode = GPIO_Mode_Out_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); xTaskCreate(LED_Task, "LED", 256, NULL, 3, &led_task_handle); vTaskStartScheduler(); while (1); }编译下载,如果 LED 以 1Hz 闪烁,说明 FreeRTOS 调度器正常工作。如果 LED 常亮或常灭,先检查configCPU_CLOCK_HZ和实际时钟是否一致,再看vTaskDelay的 tick 换算对不对。
3. LwIP 接入:内存、网卡驱动和中断的三角关系
3.1 LwIP 版本选择和文件裁剪
LwIP 我用的是 2.1.3 稳定版。源码目录里src下有几个子目录:core、api、netif、apps。如果你只用 raw API(在 FreeRTOS 任务里直接调tcp_write这类),api目录可以不要;但如果你打算用 socket API 或者 netconn API,api必须加。我建议全加,省得后面想用 socket 时再回来补。
apps目录里有一堆示例(httpd、mqtt、sntp 等),按需加。我一般只加httpd用来做连通性测试,其他先不加,减少编译量。
3.2 lwipopts.h 里决定生死的几个参数
LwIP 的配置全在lwipopts.h,这个文件没有官方模板,得自己写。我列几个关键项:
#define NO_SYS 0 #define LWIP_SOCKET 1 #define LWIP_NETCONN 1 #define MEM_LIBC_MALLOC 0 #define MEMP_MEM_MALLOC 0 #define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) #define MEMP_NUM_PBUF 16 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_SEG 32 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1524 #define TCP_MSS 1460 #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_WND (4 * TCP_MSS) #define LWIP_DHCP 1 #define LWIP_ICMP 1 #define LWIP_UDP 1 #define LWIP_TCP 1 #define LWIP_NETIF_STATUS_CALLBACK 1 #define LWIP_NETIF_LINK_CALLBACK 1NO_SYS设 0 表示用操作系统模式,这是和 FreeRTOS 配合的前提。MEM_SIZE给 16KB 是堆内存,PBUF_POOL_SIZE给 16 个,每个 1524 字节,刚好够一个以太网帧。TCP_WND和TCP_SND_BUF设成 4 倍 MSS,是吞吐和内存的折中,太小了传输慢,太大了内存吃紧。
注意:
MEM_ALIGNMENT在 RISC-V 上必须是 4,因为 CH32V307 是 32 位内核,不对齐访问会触发异常。这个值设错了,LwIP 初始化时可能直接硬件错误。
3.3 CH32V307 以太网驱动的对接点
CH32V307 自带 10/100M 以太网 MAC,官方 SDK 里有eth.c和phy.c。你要做的是把官方驱动和 LwIP 的netif接口对接起来。核心是实现三个函数:
low_level_init:初始化 MAC 和 PHY,设置 MAC 地址,开启收发中断。low_level_output:把 LwIP 的 pbuf 数据拷贝到以太网发送缓冲区,启动发送。low_level_input:从接收缓冲区取出数据,封装成 pbuf 返回给 LwIP。
官方 SDK 里其实已经有一个ethernetif.c的模板,但它默认是给裸机用的。你要改的地方是:把接收部分改成在中断里发信号量,然后在 FreeRTOS 任务里处理。我用的方式是:
// 中断里 void ETH_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; if (ETH_GetRxFlag()) { xSemaphoreGiveFromISR(eth_rx_sem, &xHigherPriorityTaskWoken); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } // 任务里 void ethernetif_input_task(void *arg) { struct pbuf *p; while (1) { if (xSemaphoreTake(eth_rx_sem, portMAX_DELAY) == pdTRUE) { p = low_level_input(); if (p != NULL) { if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); } } } } }这样中断只做最少的事(发信号量),数据处理在任务里做,符合 FreeRTOS 的设计原则。
3.4 中断优先级再确认一次
以太网中断的优先级必须落在configMAX_SYSCALL_INTERRUPT_PRIORITY之下(数值大于等于它)。我前面设的是 2,所以以太网中断设 3 或更高都行。在 CH32V307 的 SDK 里,中断优先级通过NVIC_Init的NVIC_IRQChannelPreemptionPriority设置,注意这个值直接对应 PFIC 的优先级数值,不像 ARM 那样有分组偏移。
NVIC_InitTypeDef nvic = {0}; nvic.NVIC_IRQChannel = ETH_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 3; nvic.NVIC_IRQChannelSubPriority = 0; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic);如果你发现 ping 不通但 PHY 链路是 up 的,八成是中断优先级配错了,导致中断里发的信号量没被任务收到。
4. 联调阶段:从 ping 不通到稳定收发的排查链路
4.1 先确认 PHY 和链路状态
网络调不通,第一步永远是先看 PHY。CH32V307 一般配的是内置 10M PHY 或者外挂的 100M PHY(比如 IP101)。在low_level_init里加打印,确认 PHY 的 BMSR 寄存器读出来 link 位是 1:
uint16_t phy_status = ETH_ReadPHYRegister(PHY_ADDRESS, PHY_BMSR); printf("PHY BMSR: 0x%04X\r\n", phy_status); if (phy_status & PHY_Linked_Status) { printf("Link is up\r\n"); }如果 link 一直是 down,检查网线、PHY 地址(有些板子 PHY 地址不是 1)、以及 MAC 的 RMII 时钟配置。CH32V307 的 RMII 参考时钟是 50MHz,如果外部晶振给的是 25MHz,需要开内部 PLL 倍频,这个在eth.c的初始化里要改对。
4.2 ping 不通的三种典型原因
链路 up 之后 ping 不通,按这个顺序查:
第一种:IP 地址没配上。如果你用 DHCP,先确认路由器分配了地址;如果用手动静态 IP,检查netif->ip_addr是不是你设的值。我习惯调试阶段先用静态 IP,排除 DHCP 的干扰:
IP4_ADDR(&ipaddr, 192, 168, 1, 100); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&g_netif, &ipaddr, &netmask, &gw, NULL, ðernetif_init, &tcpip_input); netif_set_default(&g_netif); netif_set_up(&g_netif);第二种:ARP 没响应。在电脑上arp -a看有没有 CH32V307 的 MAC 记录。如果没有,说明板子没收到 ARP 请求或者没回。这时候在ethernetif_input_task里加打印,看有没有收到包。如果收到包但没回,检查low_level_output里的发送描述符有没有正确设置。
第三种:中断没触发。在以太网中断入口加个计数器,ping 几次看计数有没有涨。不涨就是中断没进来,回去查 NVIC 配置和 MAC 的中断使能位。
4.3 跑几分钟就死机:栈溢出和内存耗尽
这是最恶心的问题,因为现象不固定。我遇到过一次,ping 大包(ping -l 1400)几十次之后系统卡死。用调试器暂停,发现卡在HardFault_Handler。查下来是两个原因叠加:
一是ethernetif_input_task的栈给太小了,只给了 256 字(注意 FreeRTOS 的栈单位是字,不是字节,256 字 = 1KB),处理大包时局部变量加上函数调用深度超了。改成 512 字之后解决。
二是PBUF_POOL_SIZE给少了,大包连续来的时候 pbuf 分配失败,LwIP 内部处理失败路径时有 bug(2.1.3 的已知问题),导致空指针访问。把PBUF_POOL_SIZE从 8 加到 16 之后稳定。
提示:调试阶段一定要开
configCHECK_FOR_STACK_OVERFLOW和configUSE_MALLOC_FAILED_HOOK,然后在钩子函数里打印是哪个任务出的问题。这两个钩子能帮你省下大量猜测时间。
4.4 用 iperf 测吞吐时的注意事项
LwIP 自带一个iperf示例(在apps目录下),可以测 TCP 吞吐。CH32V307 在 100M PHY 下实测能跑到 30-40Mbps,这个数字受限于 MCU 的处理能力和 LwIP 的配置。想再高,可以调大TCP_SND_BUF和TCP_WND,但内存要跟上。
测的时候注意:iperf 任务优先级不要设太高,否则会饿死其他任务;也不要设太低,否则吞吐上不去。我一般设成和网络输入任务同级,都是 4。
5. 几个容易被忽略但很关键的细节
5.1 FreeRTOS 的 tick 中断和以太网中断的嵌套
CH32V307 的 SysTick 中断优先级默认是最低的(15),这没问题。但如果你把以太网中断设成 2 或 3,它就能打断 SysTick。这时候如果以太网中断里调用了xSemaphoreGiveFromISR,FreeRTOS 的临界区保护是靠关中断实现的,而关中断只能关到configMAX_SYSCALL_INTERRUPT_PRIORITY这一级。所以以太网中断优先级必须大于等于这个值,否则临界区保护失效,链表操作会出错。
这个逻辑和 ARM 上是一样的,但 RISC-V 的优先级数值方向和 ARM 相反(ARM 是数值越小优先级越高,RISC-V 在 CH32V307 上也是数值越小越高,但有些 RISC-V 芯片是反的),所以移植到别的 RISC-V 芯片时要重新确认。
5.2 heap_4 和 LwIP 的内存池不要打架
FreeRTOS 用heap_4.c管理自己的堆(configTOTAL_HEAP_SIZE),LwIP 用MEM_SIZE管理自己的堆。这两个堆是独立的,都在 SRAM 里。CH32V307 有 128KB SRAM,我分配的是:FreeRTOS 48KB,LwIP 16KB,剩下的给全局变量和栈。如果你把两个都设得很大,链接时会报 RAM 不够。
用size命令或者 IDE 的内存分析功能看一下.bss和.data段的大小,心里有个数。我一般留 20KB 余量,防止后面加功能时不够。
5.3 调试打印不要用 printf 直接输出
在 FreeRTOS 任务里直接调printf有两个问题:一是printf不是线程安全的,多个任务同时打印会乱;二是printf可能阻塞,影响实时性。我的做法是重定向到一个队列,单独开一个低优先级任务专门输出:
void log_task(void *arg) { char buf[128]; while (1) { if (xQueueReceive(log_queue, buf, portMAX_DELAY) == pdTRUE) { printf("%s", buf); } } }其他任务要打印时往log_queue里发字符串就行。这样既线程安全,又不阻塞业务任务。
5.4 看门狗和 FreeRTOS 的配合
CH32V307 有独立看门狗(IWDG)和窗口看门狗(WWDG)。跑 FreeRTOS 时,喂狗不能放在某个任务里,因为如果那个任务被饿死,狗就喂不上,但系统其他部分可能还在正常工作。正确的做法是开一个低优先级的喂狗任务,或者用空闲钩子(vApplicationIdleHook)来喂。我用的是空闲钩子,因为空闲任务优先级最低,如果它都跑不到,说明系统真的有问题了。
void vApplicationIdleHook(void) { IWDG_ReloadCounter(); }但注意,如果某个高优先级任务一直占着 CPU 不让出,空闲任务跑不到,狗就会复位。这其实是好事,能暴露优先级设计问题。
6. 工程模板的复用和后续扩展
6.1 把这套配置做成模板
调通一次之后,我把整个工程目录打包成了一个模板,下次新建项目直接拷贝,改一下芯片型号和 PHY 地址就能用。模板里保留Core、Middlewares、Drivers、User四个目录,FreeRTOSConfig.h和lwipopts.h里的参数都注释好了,哪些必须改、哪些可以保持默认,一目了然。
这样做的好处是,下次遇到新的 CH32V307 板子,半小时就能跑起来网络,不用再从头踩坑。
6.2 加 MQTT 或者 HTTP 服务
LwIP 的apps目录里有mqtt和httpd示例,直接加到工程里就能用。MQTT 要注意的是 keepalive 时间,设太短会频繁重连,设太长服务器可能踢你。我一般设 60 秒。HTTP 服务用来做设备配置页面很方便,但注意httpd是单线程的,同时来多个请求会排队,重负载场景要自己改。
6.3 性能优化的几个方向
如果 30Mbps 的吞吐不够用,可以试这几个方向:把MEM_ALIGNMENT和 pbuf 的对齐优化一下,减少拷贝次数;把low_level_output里的拷贝改成零拷贝(需要 MAC 支持 scatter-gather,CH32V307 不支持,所以这条走不通);或者把 TCP 的窗口调大,但受限于内存。实测下来,CH32V307 跑 LwIP 的瓶颈主要在内存拷贝和中断处理上,想大幅提升得换带 DMA 的 MAC。
我在实际项目里最后稳定跑的是 35Mbps 左右,连续跑 24 小时没有丢包和死机。这个成绩对于工控数据采集和简单的物联网网关足够了。如果你要跑视频流或者大文件传输,建议换更高端的芯片,或者把 LwIP 换成更轻量的协议栈。
最后分享一个小技巧:调试网络问题时,在电脑上开 Wireshark 抓包,同时板子上用串口打印 LwIP 的调试信息(把LWIP_DEBUG打开,ETHARP_DEBUG、IP_DEBUG、TCP_DEBUG都设成LWIP_DBG_ON),两边对照着看,能很快定位是发送问题还是接收问题。这个组合我用下来比单纯看串口打印效率高得多。