CH32 PIOC入门(一):从零理解可编程IO协处理器,PIOC汇编其实不可怕!
01 快速了解 PIOC
CH32X035、CH32H417、CH32V205 等系列 MCU 产品配备了一个称为 PIOC 的外设。本文将介绍此外设并讲解常用的开发方法。
01.1 PIOC 是什么?
PIOC(Programmable I/O Controller,可编程协议 I/O 微控制器)内嵌于部分型号的 MCU 当中,是一个非常具有灵活性的外设。PIOC 的本质是一个可以通过 MCU 内核(下称主核)控制的协处理器,一般运行于和主核同主频(以具体芯片手册中的时钟树为准),可以使用单周期指令操控两个数字 IO 的输入输出,这使得基于各种自定义数字协议的通讯变得更加便捷、高效、可靠。同时,主核可以通过动态向 PIOC 加载固件来实现运行时修改协议或数据,极大提升了 PIOC 使用的灵活性。

01.2 PIOC 有什么特点?
PIOC 具有以下特点:
- 完全灵活的协议实现:UART、SPI、I2C、定时器等外设的协议、时序等在芯片设计时就已经被确定,只能在原先设计的范围内使用,而 PIOC 能够支持的协议取决于加载的固件,加载不同固件就成为不同的外设;
- 独立运行,无需主核干预:固件加载后由 PIOC 独立运行,波形时序不受主核中断、调度的影响,也不占用主核时间;
- 资源占用少:仅复用 4KB RAM 空间、占用 2 个 IO 引脚,即可实现一个高度灵活的“数字协议外设”;
- 支持丰富的协议类型:官方提供 1-Wire 温度传感器、WS2812 ARGB 灯带、I2C、NEC 编码的红外遥控、UART 等多个例程,用户也可以自行根据所需的协议进行开发;
- 上手门槛低:官方例程覆盖多种常用数字协议,二次开发仅需结合业务逻辑少量修改即可。
01.3 PIOC 有什么优势?
为了说明 PIOC 在灵活处理各种数字协议时的优势,以驱动 WS2812 RGB LED 灯带的应用场景为例。
常规的通用 MCU 没有原生支持 WS2812 所用协议的外设接口,通常用特定时钟下的 SPI 或定时器甚至 GPIO 软件控制来模拟协议,但这通常伴随着两个问题:
- 时序抖动:若使用阻塞、轮询式的代码结构,信号易受中断影响而抖动,严重时可能丢失数据,引发后续灯珠显色错误;
- 数据膨胀:即使改用 DMA 与中断配合保证时序,SPI 和定时器“一个 2812 码占用多个位”的天然特性,仍会让数据量成倍膨胀。
数据膨胀问题主要表现在对 RAM 空间的浪费,也就是为了表示 3 字节的 RGB 灯光数据,需要使用超过 3 字节的RAM。
-
SPI 模拟:2812 的 0 码、1 码分别需要约 25%、75% 的占空比。把 SPI 发送的 8bit 拆成两个 4bit 使用,每 4bit 模拟一个编码(0 码对应
0b1000,1 码对应0b1110),即每个字节可表示 2 个编码。每个灯珠的颜色信息由 24 个编码组成,故每个灯珠消耗 12 个字节——数据膨胀到原始数据的 4 倍。 -
定时器模拟:通过定时器的比较输出功能控制信号占空比来模拟编码。例如设置定时器每 10 个计数对应 1.25μs(一个编码的长度),此时向比较捕获寄存器写
0x03可得到约 30% 占空比的 0 码,写0x07可得到约 70% 占空比的 1 码。配合 DMA 搬运时,1 个字节仅存储 1 个编码,每个灯珠消耗 24 个字节——数据膨胀到原始数据的 8 倍。以 1024 个灯珠为例,有效颜色数据仅约 3kB,却要占用 24kB 的 RAM。
然而,若使用 PIOC 来控制 WS2812 灯带,只需为 PIOC 加载 ARGB 协议固件,即可直接变成支持 WS2812 协议发送的外设。PIOC 可直接接收 RGB 灯光参数,不需要先解码为位流存放在 RAM 中,不存在数据膨胀;若把灯光数据动态嵌入固件直接存放在 PIOC 的 ROM 空间内,一次可处理上千个 WS2812 灯珠的 RGB 数据,完全无需占用主核。以 1024 个灯珠为例,传统方案与 PIOC 方案的 RAM 占用对比如下(图片来自互联网):

以上例子不仅适用于 WS2812 的单线数字协议,对于大多数中低速单/双线数字协议,以及需要精确定时的 IO 控制,若能够使用 PIOC 处理,将能够极大优化资源使用效率,缓解主核处理压力,减少不必要的外设占用。
支持 PIOC 的芯片系列包括 CH32X035、CH32H417、CH32V205 等,本文以 CH32V205 为例进行演示,原理与流程同样适用于其他型号。
02 准备工作
首先,需要下载 CH32V205EVT 资料包,其中包含 PIOC 例程与工具:
https://www.wch.cn/downloads/CH32V205EVT_ZIP.html
- 本文演示平台:CH32V205RCT-R0-1v1 评估板(或任意支持 PIOC 的芯片评估板);
- 开发环境:MounRiver Studio Ⅱ(下称 MRS2)2.6.0
- 调试器:WCH-LinkE
- 其它组件:WS2812 灯带、杜邦线若干。

本文侧重 PIOC 外设本身的讲解,评估板的使用等请参见评估板资料,这里不再展开。
03 例程资源说明
在 CH32V205EVT 的 EXAM/PIOC 目录下,按协议类型存放了 5 个例程:
| 例程目录 | 协议 | 典型应用 |
|---|---|---|
PIOC_1_Wire |
1-Wire | WS2812 灯带、DS18B20 测温 |
PIOC_Single_Wire |
自定义单线 | 单线双向收发 |
PIOC_IIC |
I2C | 总线通信 |
PIOC_NEC |
NEC 红外 | 红外遥控解码 |
PIOC_UART |
异步串行 | 串口通信 |
每个例程内部都有一个 Asm/ 目录,其中存放 PIOC 侧汇编源码与产物(.ASM / .BIN / .LST / .inc.h),其中 .bat 脚本可一键完成汇编及 C 语言包含文件的格式转换。
04 用 PIOC 让 RGB 灯带亮起来
为了直观体现 PIOC 的应用场景,本节以 PIOC_1_Wire 例程为例,演示驱动一条 WS2812 RGB 灯带的 PIOC 使用流程。例程代码中的汇编可无需修改,直接使用。
04.1 WS2812 RGB 灯带的控制
WS2812 是一种 ARGB LED(Addressable RGB LED,可寻址 RGB LED)。与传统 RGB LED 需要外部 3 路 PWM 调光不同,ARGB LED 把通信与发光控制电路都集成在灯珠内部,MCU 最低只需 1 个 IO 引脚,就能控制成百上千个彩色灯珠。
WS2812 灯珠内部有红、绿、蓝三个 LED,每个通道有 256 级亮度,可组合出约 1677 万种色彩。灯珠采用单线串行接口,所有灯珠以菊花链的形式头尾相连,即:前一颗灯珠的数据输出(DOUT)接后一颗的数据输入(DIN),每一颗灯珠都会对收到的信号进行整形再传给下一个灯珠,减少长灯带的信号干扰造成的误码。

(图片来自网络)
颜色数据按绿(G)、红(R)、蓝(B)的顺序排列,每通道 8bit、高位在前(MSB First),一个灯珠共 3 个字节。WS2812 接收数据时自动截取前 24bit 供自己使用,并把后续数据原样传递给下一个灯珠。因此 MCU 只需把整条灯带的颜色数据连续发出,数据就会依次被各个处于对应顺序上的 WS2812 收到。

(图片来自网络)
WS2812 采用单极性脉宽调制归零码通信:信号在编码结束前总是回到低电平。一般 WS2812 的数字协议速率为 800kbps,即每个码的长度为 1250ns。

(图片来自网络)
0 码与 1 码通过高电平持续时间区分,具体的取值以所用灯珠手册为准,但一般符合以下的时序参数:
| 编码类型 | 0 码(bit0) | 1 码(bit1) |
|---|---|---|
| 高电平持续时间 | t0H = 220~380ns | t1H = 580~1000ns |
| 低电平持续时间 | t0L = 580~1000ns | t1L = 580~1000ns |
每次刷新颜色时,MCU 都必须先发出一个超过 280μs 的低电平(即复位信号,具体时序以所用灯珠手册为准),然后从第一个灯珠开始重新传输整条数据。未收到新的有效数据的 WS2812 保持上次的颜色不变。
04.2 点亮灯带
首先,将硬件按如下方式接线:
- 将 WS2812 灯带的 DIN 引脚连接到 PIOC 的 IO0 对应引脚(例程默认分配了 PA11,若使用其它引脚需修改宏定义并重新编译 C 代码部分)。
- 灯带 VCC 接 +5V(按灯带规格提供,一般是 5V),GND 与评估板共地。
- 使用一条额外的USB线为评估板供电,以避免供电不足的风险。
- 将 WCH-LinkE 的调试口信号连接到评估板对应引脚(SWDIO 接 PA13、SWCLK 接 PA14)。
启动 MRS2,打开 PIOC_1_Wire 目录下的工程文件(PIOC_1Wire.wvproj,支持双击打开)。
然后,点击 MRS2 工具栏的重新编译按钮(或按 Shift+F7)编译工程,编译产物位于工程的 obj/ 目录;点击工具栏的下载按钮(或按 F8)将编译生成的固件下载到芯片。

该例程的功能为 10 灯 WS2812 灯带的“红-绿-蓝”流水灯,下载后会点亮灯带前 10 个灯,并以红绿蓝的颜色滚动显示。

简单来说:例程的 User/RGB1W.c 里有一段 PIOC_1W_CODE 数组,它就是 PIOC 的固件。MCU 上电后把这串程序加载进 PIOC,然后往指定寄存器或 ROM 区段里填灯珠颜色数据,PIOC 就自动把颜色按 WS2812 协议从 IO 引脚送出去了。
05 PIOC 在主核侧的操作方法
以 PIOC_1_Wire 例程为例,主核一侧主要需要关注 User/RGB1W.c 与 User/main.c 两个文件。
其中有以下关键信息:
- 初始化与 PIOC 固件加载:
main.c中的主函数首先初始化了延迟函数、配置日志输出串口,然后调用RGB1W_Init()初始化 PIOC 相关部分。 - PIOC 固件数组:
RGB1W.c中的PIOC_1W_CODE[]数组存放 PIOC 固件的原始字节流,由汇编产物转换而来。例程已内嵌与提供的 PIOC 汇编文件相对应的固件字节流,无需改动。修改 PIOC 汇编后,需同步更新此数组中的字节流。 - 通过 PIOC 操作 WS2812:例程中的
RGB1W.c提供了两个例子,分别是调用RGB1W_SendSFR_Wait()通过 PIOC 的 SFR 寄存器直接传递灯光数据,和调用RGB1W_SendRAM()在 PIOC 固件中动态嵌入灯光数据来发送 WS2812 的数字协议数据。前者适用于灯珠数量少,总字节数不超过 32 字节的情况;后者适用于灯珠数量多,总字节数不超过 2560 字节的情况。
结合以上信息可以看出,主机侧对 PIOC 的初始化操作包括配置 PIOC 所用 GPIO 与时钟、复位 PIOC 并载入固件:
GPIO_InitTypeDef GPIO_InitStructure = {0};
RCC_PB2PeriphClockCmd(RCC_PB2Periph_AFIO | RCC_PB2Periph_GPIOA, ENABLE);
RCC_HBPeriphClockCmd(RCC_HBPeriph_PIOC, ENABLE);
GPIO_PinAFConfig(GPIOA, GPIO_PinSource11, GPIO_AF4);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_High;
GPIO_Init(GPIOA, &GPIO_InitStructure);
PIOC->D8_SYS_CFG = RB_MST_RESET | RB_MST_IO_EN0 | RB_MST_IO_EN1; // reset PIOC & enable IO0 IO1
memcpy( (uint8_t *)(PIOC_SRAM_BASE), PIOC_1W_CODE, sizeof( PIOC_1W_CODE ) ); // load code for PIOC
而在驱动 WS2812 的例子中,第一种使用 PIOC 发送数据的函数实现如下:
#define RGB1W_SFR_ADDR ((uint8_t *)&(PIOC->D8_DATA_REG0)) // RGB1W data buffer start address for SFR mode
#define RGB1W_SFR_SIZE 32 // RGB1W data buffer size for SFR mode
#define RGB1W_COMMAND (PIOC->D8_CTRL_WR) // command input
void RGB1W_SendSFR( uint16_t total_bytes, uint8_t *p_source_addr ,uint8_t mod) {//SFR mode for 1~32 bytes data
// p_source_addr: point source data buffer start address, set NULL if copy into PIOC buffer already
if ( total_bytes > RGB1W_SFR_SIZE ) return;
PIOC->D8_SYS_CFG = RB_MST_RESET | RB_MST_IO_EN0 | RB_MST_IO_EN1;//clear&halt PIOC
PIOC->D8_SYS_CFG = RB_MST_CLK_GATE | RB_MST_IO_EN0 | RB_MST_IO_EN1;//run PIOC before write SFR
if ( p_source_addr ) memcpy( RGB1W_SFR_ADDR, p_source_addr, total_bytes );//copy source data to RGB1W SFR, @PIOC run
if(mod)
RGB1W_COMMAND = ((uint8_t)total_bytes|0X40);// PIOC start send
else
RGB1W_COMMAND = (uint8_t)total_bytes;// PIOC start send
}
uint8_t RGB1W_SendSFR_Wait( uint16_t total_bytes, uint8_t *p_source_addr ,uint8_t mod) {//SFR mode for 1~32 bytes data
// p_source_addr: point source data buffer start address, set NULL if copy into PIOC buffer already
if ( total_bytes == 0 || total_bytes > RGB1W_SFR_SIZE ) return( RGB1W_ERR_PARA );
RGB1W_SendSFR( total_bytes, p_source_addr , mod);
while ( ( PIOC->D8_SYS_CFG & RB_INT_REQ ) == 0 )
{
printf("line:%d\r\n",__LINE__);
}
return( PIOC->D8_CTRL_RD );//auto remove interrupt request after reading
}
可以看出,在通过 PIOC 的 SFR 寄存器直接传递灯光数据时,操作 PIOC 的方式是这样的:
- 准备阶段: 复位 PIOC 并重新启动运行。
- 传入参数阶段: 主核将要发送的数据(最多 32 字节)从
DATA_REG0开始写入 PIOC 的 SFR 中。 - 通知启动阶段: 主核写入
CTRL_WRPIOC 侧只读 SFR,通知 PIOC 数据就绪、使用的 IO 及需发送长度,PIOC 开始发送数据。 - PIOC 执行过程中: 主核等待 PIOC 发出中断信号(
R8_SFR_SYS_CFG寄存器中的INT_REQ位被置 1)。 - 结果返回阶段: 主核读取
CTRL_RD主核侧只读 SFR,判断 PIOC 返回的状态码。
而第二种使用 PIOC 发送数据的函数实现如下:
#define RGB1W_RAM_ADDR ((uint8_t *)(PIOC_SRAM_BASE+0x600)) // RGB1W data buffer start address for RAM mode
#define RGB1W_RAM_SIZE ((uint8_t *)(PIOC_SRAM_BASE+0x1000)-RGB1W_RAM_ADDR) // RGB1W data buffer size for RAM mode
#define RGB1W_CMD_RAM 0x70 // notice PIOC to send data from RAM
#define RGB1W_COMMAND (PIOC->D8_CTRL_WR) // command input
void RGB1W_SendRAM( uint16_t total_bytes, uint8_t *p_source_addr ,uint8_t mod) {//RAM mode for 1~2560 bytes data
// p_source_addr: point source data buffer start address, set NULL if copy into PIOC buffer already
if ( total_bytes > RGB1W_RAM_SIZE ) return;
PIOC->D8_SYS_CFG = RB_MST_RESET | RB_MST_IO_EN0 | RB_MST_IO_EN1;//clear&halt PIOC
if ( p_source_addr ) memcpy( RGB1W_RAM_ADDR, p_source_addr, total_bytes );//copy source data to PIOC SRAM, @PIOC halt
PIOC->D8_SYS_CFG = RB_MST_CLK_GATE | RB_MST_IO_EN0 | RB_MST_IO_EN1;//run PIOC after load data in SRAM
PIOC->D16_DATA_REG0_1 = total_bytes;// data size
if(mod)
PIOC->D8_DATA_REG2 = 1;
else
PIOC->D8_DATA_REG2 = 0;
// default is 160MHz
RGB1W_COMMAND = RGB1W_CMD_RAM;// set bit cycle and PIOC start send
}
uint8_t RGB1W_SendRAM_Wait( uint16_t total_bytes, uint8_t *p_source_addr ,uint8_t mod) {//RAM mode for 1~2560 bytes data
// p_source_addr: point source data buffer start address, set NULL if copy into PIOC buffer already
if ( total_bytes == 0 || total_bytes > RGB1W_RAM_SIZE ) return( RGB1W_ERR_PARA );
RGB1W_SendRAM( total_bytes, p_source_addr ,mod);
while ( ( PIOC->D8_SYS_CFG & RB_INT_REQ ) == 0 );//wait, PIOC request interrupt after finish
return( PIOC->D8_CTRL_RD );//auto remove interrupt request after reading
}
可以看出,在调用 RGB1W_SendRAM() 在 PIOC 固件中动态嵌入灯光数据时,操作 PIOC 的方式是这样的:
- 准备阶段: 复位 PIOC。
- 传入参数阶段 1: 主核将要发送的数据(本例子中最多 2560 字节)写入从
0x600处开始的 PIOC 的程序 ROM 空间。 - 准备阶段: 重新启动 PIOC 运行。
- 传入参数阶段 2:
- 主核写入
DATA_REG0和DATA_REG1SFR,通知 PIOC 需发送的数据长度。 - 主核写入
DATA_REG2,通知 PIOC 使用的 IO。
- 主核写入
- 通知启动阶段: 主核写入
CTRL_WRPIOC 侧只读 SFR,通知 PIOC 开始发送数据。 - PIOC 执行过程中: 主核等待 PIOC 发出中断信号(
R8_SFR_SYS_CFG寄存器中的INT_REQ位被置 1)。 - 结果返回阶段: 主核读取
CTRL_RD主核侧只读 SFR,判断 PIOC 返回的状态码。
结合例程中演示的使用方法,可以归结出主核侧操作 PIOC 的开发范式:
- 准备 PIOC 使其处于正确状态,如果需要通过程序 ROM 空间传入长数据,则在此阶段传入。
- 启动 PIOC 运行。如果需要,通过 SFR 传入需要的参数。
- 发出启动执行的信号(取决于 PIOC 侧固件的实现)。
- 等待 PIOC 完成通信。除了像例程中阻塞式等待,也可以使用 PIOC 的 IRQ 实现中断式传输。
- 如果需要,读取 PIOC 返回的执行结果。
第二步和第三步都是“启动”,但实际上有区别:启动 PIOC 运行只是让 PIOC 开始执行固件,但由于固件中设计的等待同步机制,需要第三步的发出启动执行的信号来真正触发它开始干活,前者是前提。若要通知 PIOC 开始运行,PIOC 必须已经启动并且在等待信号。例程里 RGB1W_SendSFR() 先写 RB_MST_CLK_GATE 让 PIOC 跑起来,再写 CTRL_WR 通知它开始发送,正是基于这种开发范式的实现。
用伪代码的方式来描述就是这样的:
PIOC_Reset();
PIOC_Init(optimalData);
PIOC_Start();
uint8_t result = PIOC_Function(arg1,arg2,arg3,...);
在上面的例程中,主核与 PIOC 之间表现出一套相对固定的“协作”关系:
- 引脚所有权:两个 IO 引脚默认归主核一侧控制,此时它们就是普通的 GPIO。通过系统配置寄存器的 IO 使能位(例程里的
RB_MST_IO_EN0/RB_MST_IO_EN1),可以把引脚的控制权移交给 PIOC。移交之后,引脚的电平就由 PIOC 固件决定,主核不再直接操控。 - 程序加载流程:PIOC 的程序空间来自对主机的 4K 系统 SRAM 的复用,因此加载固件有一套相对固定的流程:
- 先关闭 PIOC 时钟(此时程序空间回归主机 SRAM,主核可以写入)。
- 把固件字节流写入对应内存。例程里
RGB1W_Init()中的memcpy就是在这个流程中完成的。 - 重新开启时钟(该段内存专用于 PIOC 程序 ROM,主核此时无法再访问)。
- 数据交换:主核与 PIOC 通过
SFR_CTRL_WR/SFR_CTRL_RD两个单向寄存器及配套的“握手位”(SB_DATA_SW_MR/SB_DATA_MW_SR)实现异步通信,读写互不冲突;批量数据则通过数据寄存器DATA_EXCH和DATA_REG0-31交互。握手位的具体用法会在 06 章结合 PIOC 侧代码讲解。 - 中断:PIOC 可以置位中断请求(
SB_INT_REQ)来通知主核“数据已处理完毕”,主核既可以像例程那样轮询等待,也可以配置中断服务函数,实现中断式传输。 - 复位:主核可以单独强制复位 PIOC(
RB_MST_RESET),只复位 PIOC 本身,不影响主核运行。例程里每次发送前都会先复位 PIOC,确保它从已知状态开始执行。
06 PIOC 侧的初步开发
基于以上的主核侧开发范式,本节对 PIOC 侧的开发相关内容进行阐述。
06.1 PIOC 有什么资源?
- RISC8B 内核:8 位数据、16 位指令、66 条指令,绝大多数指令单周期执行,6 级深度堆栈;
- 程序空间:2K 条指令(0x000-0x7FF),来自对主机 4K 系统 SRAM 的复用;
- 数据空间:49 个 SFR 寄存器(0x00-0x3F),其中 33 个是双向数据寄存器,没有 RAM;
- 片上资源:2 个数字 IO 引脚、1 个可自动重加载的 8 位定时器 0、数据编解码单元。
PIOC 与系统同主频(具体时钟以实际使用的芯片系列数据手册中时钟树说明为准),可通过 33 个 8 位双向数据寄存器进行数据传递,另有 2 个单向 8 位数据寄存器可用于自定义用途,如命令或状态传递。
PIOC 对外有 2 个数字 IO 引脚(IO0、IO1),具有以下功能:
- 方向、电平、上拉均可配置:每个引脚都可以独立配置为输入或输出、设置输出电平、开启内部上拉。这意味着 PIOC 可以灵活地模拟各种总线形态,既可以是推挽输出驱动,也可以是带内部上拉的输入,同样可以在单线半双工总线上灵活切换方向。
- 硬件电平变化检测:当引脚的实际输入电平与输出数据不一致时,对应的 XOR 位会自动置 1。这个特性在多主或单线双向协议中非常有用,PIOC 可以借此检测“总线上的位冲突”,从而实现总线仲裁与边沿检测,而不需要主核参与。
- IO0 功能更全:数据编解码单元与定时器 0 的输出都挂在 IO0 上,因此 IO0 更适合承担需要精确时序或编解码的任务,同时也更适合作为输入/双向口使用;IO1 则相对简单,适合作为普通的输出、时钟或辅助信号线。
在后续的例程中可以看到,大多数单线协议(WS2812、1-Wire、NEC 等)都只需要用 IO0 一个引脚就能完成收发,IO1 则往往应用于双线数字协议中的时钟线。
06.2 PIOC 侧开发范式
由于 PIOC 的运行是独立于主核的,因此需要一定的同步机制来确保 PIOC 在主核期望的时机开始运行,并得知 PIOC 完成运行后的信号。PIOC 提供了 DATA_SW_MR、DATA_MW_SR 两个用于同步的机制,可通过位判断指令或等待条件指令来完成与主核操作的同步。除此之外,PIOC 还具有中断请求功能,可向主核发起 IRQ 请求以指示数据发送完成或其它事件的状态。
以下是一个 PIOC 的汇编代码的例子。该例子演示了根据主核侧传入 CTRL_WR SFR 的命令码不同而选择执行空指令或加法功能:
; PIOC Assembly example
;
; Include SFR definition
INCLUDE PIOC_INC.ASM
;
; RESET VECTOR at 0x0000
ORG 0X0000 ; Begin at 0
DW 0X0000 ; RSVD word
JMP MAIN_FUNC
;
; NOP Function example
; This does nothing.
FUNC_NOP: NOP
NOP
NOP
NOP
RET ; Return to caller
;
; ADD Function example
; Operand 1: SFR_DATA_REG0
; Operand 2: SFR_DATA_EXCH
; Result : SFR_DATA_EXCH
FUNC_ADD: PUSHAS ; Save A/Z/C context
MOV SFR_DATA_REG0,A
ADD SFR_DATA_EXCH,F
POPAS ; Restore A/Z/C context
RET
;
MAIN_FUNC: BC SFR_SYS_CFG,SB_INT_REQ ; Cancel IRQ
WAITB WB_DATA_MW_SR_1 ; Wait for CmdCode from MCU
MOV SFR_CTRL_WR,A ; Load CmdCode into A
CMPL 0X01 ; If: CmdCode==0x01?
JZ CMD_NOP ; Then: Jump to CALL_NOP
CMPL 0X02 ; ElseIf: CmdCode==0x02?
JZ CMD_ADD ; Then: Jump to CALL_ADD
JMP MAIN_FUNC ; Else: No CmdCode matched. Wait for next CmdCode.
;
CMD_NOP: CALL FUNC_NOP
MOVL 0X01 ; Load ResultCode=0x01 into A
MOVA SFR_CTRL_RD ; Load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
CMD_ADD: CALL FUNC_ADD
MOVL 0X01 ; Load ResultCode=0x01 into A
MOVA SFR_CTRL_RD ; Then load A into SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; Set IRQ
WAITB WB_DATA_SW_MR_0 ; Wait for MCU reading result
JMP MAIN_FUNC
;
END
;
先不要急于尝试完全读懂它。面对一个陌生的程序,怎么快速理解它的功能?可以先看控制流。整个程序的结构其实很简单:入口点 -> 等待命令 -> 按命令分发 -> 执行 -> 返回结果 -> 回到入口点,这和 C 语言里的以下写法是类似的:
while(1) {
int cmd = waitForCmd();
if(cmd==0x01) { ... }
else if(cmd==0x02) { ... }
}
06.3 数据流动方式
累加器 A 的核心地位
PIOC 的 RISC8B 内核是一个典型的累加器架构。几乎所有数据都要经过累加器 A 中转:读 SFR 的数据要先从 SFR 读入 A 再写入目的位置,往 SFR 里写入数据也要先把数据放入 A 再向 SFR 写。
因此,许多第一次尝试汇编的工程师会遇到以下问题:
- 立即数不能直接写入 SFR。
- 不能从一个 SFR 向另一个 SFR 赋值。
立即数:指直接包含在指令本体里的常量数值,大致相当于 C 语言里直接写出的数字字面量,如 0x01、100。
举个例子,C 语言中以下的写法是无法在 PIOC 汇编中用一条指令做到的:
SFR_CTRL_RD = 0x01;
因为 PIOC 使用的 RISC8B 架构没有“把立即数写入 SFR”的指令。不过,这并不意味着这种操作是做不到的。因为 RISC8B 的指令集有“把立即数写入 A”和“把 A 中的值写入 SFR”的两条指令,只要通过 A 中转,就可以写出以下汇编来达到目的:
MOVL 0X01 ; 立即数 0x01 -> A
MOVA SFR_CTRL_RD ; A -> SFR_CTRL_RD
对照样例汇编里的 CMD_NOP 段同样使用了这个方法向 SFR_CTRL_RD 置入立即数作为结果码:
CMD_NOP: CALL FUNC_NOP
MOVL 0X01 ; 立即数 0x01 -> A
MOVA SFR_CTRL_RD ; A -> SFR_CTRL_RD
; 以上两行通过借用 A 中转的方式将立即数 0x01 存入 SFR_CTRL_RD
BS SFR_SYS_CFG,SB_INT_REQ ; 置位中断请求位
WAITB WB_DATA_SW_MR_0 ; 等待主核读取结果
JMP MAIN_FUNC ; 跳回主循环开头
常见的数据流动方式
立即数存入 SFR
这种方法上文已经阐述过,当需要给某个 SFR 赋一个常量时,可通过 A 中转:
MOVL 0X40 ; 立即数 0x40 -> A
MOVA SFR_DATA_REG0 ; A -> SFR_DATA_REG0
将一个 SFR 的值赋给另一个 SFR
除使用间接寻址方式外,两个 SFR 之间没有直接的赋值指令,因此也必须经过 A 中转:
MOV SFR_DATA_EXCH,A ; SFR_DATA_EXCH -> A
MOVA SFR_DATA_REG0 ; A -> SFR_DATA_REG0
不过也有一些运算指令可以将运算结果写回提供其中一个操作数的源 SFR,这样就不必先写入 A 再写回源 SFR。这些运算指令包括 ADD、SUB、AND、IOR、XOR 等。例如:
ADD SFR_DATA_EXCH,F ; 相当于 C 语言的:DATA_EXCH += A;
位操作
PIOC 的位操作指令非常强大。实际应用中,很多协议处理都是逐位进行的,而 PIOC 提供了多个为位运算专门优化的指令:
通过置位/清位指令可直接对 SFR 的某一位操作,不需要经过 A 中转。如样例汇编里的设置中断请求位:
BS SFR_SYS_CFG, SB_INT_REQ
为了优化操作 IO 的指令效率,PIOC 的 IO 口也可以直接通过单条指令控制。除了常规的置位/清位指令,还有专门的位传送指令,可在单周期内完成读/写 IO。
例如,将 IO0 引脚电平读入 SFR_DATA_EXCH 的第 3 位:
BG2F BI_PORT_IN0,3 ; IO0 引脚电平 -> SFR_DATA_EXCH[bit3]
或者将 SFR_INDIR_ADDR 的第 7 位数据作为 IO1 的电平输出:
BP1F BO_PORT_OUT1,7 ; SFR_INDIR_ADDR[bit7] -> IO1 引脚电平
间接寻址
当需要连续处理多个数据寄存器(如 DATA_REG0–31)时,可以通过间接寻址来简化操作。间接寻址由“地址寄存器 + 数据读写端口”两部分组成:先把目标地址写入地址寄存器,再通过数据读写端口读写它指向的寄存器。
PIOC 提供两组间接寻址的数据端口:
| 地址寄存器 | 数据读写端口 | 自增特性 |
|---|---|---|
SFR_INDIR_ADDR |
SFR_INDIR_PORT |
不自增 |
SFR_INDIR_ADDR2 |
SFR_INDIR_PORT2 |
每次访问后自动加 1 |
例如,把 SFR_DATA_REG0 的值读入 A,再写入 SFR_DATA_REG1:
MOVIP 0X10 ; 目标地址 0x10 -> 地址寄存器
MOV SFR_INDIR_PORT,A ; 读 SFR_INDIR_PORT 相当于读出 0x10 地址处的 SFR 到 A
MOVA SFR_INDIR_PORT ; 写 SFR_INDIR_PORT 相当于将 A 写入 0x10 地址处的 SFR
用 C 语言类比,这就像指针操作:*ptr = value; 和 value = *ptr;。
由于 SFR_INDIR_ADDR2 在每次访问 SFR_INDIR_PORT2 后自动加 1,非常适合循环搬运连续的数据寄存器:
MOVIA 0X10 ; 起始地址 0x10 -> 地址寄存器 2
MOV SFR_INDIR_PORT2,A ; 读 0x10 指向的寄存器,INDIR_ADDR2 自动变为 0x11
; 使用 A 做某些操作
MOV SFR_INDIR_PORT2,A ; 读 0x11 指向的寄存器,INDIR_ADDR2 自动变为 0x12
; 使用 A 做某些操作
MOV SFR_INDIR_PORT2,A ; 读 0x12 指向的寄存器,INDIR_ADDR2 自动变为 0x13
; ....
为了方便使用间接寻址时置立即数,PIOC 支持使用 MOVA1P/MOVA2P 指令将立即数写入间接寻址指向的寄存器,这种方法一般用于“写常量”。例如 MOVA1P 0X72 表示把立即数 0x72 写入 SFR_INDIR_ADDR 指向的寄存器。
从 C 语言的常用操作来看汇编
为了便于理解,以下给出了一些 C 语言操作和汇编操作的对照。部分对照可能不是完全等价的,仅供类比参考以便于理解。
| C 语言 | PIOC 汇编 |
|---|---|
SFR1 = 0x01; |
MOVL 0x01 + MOVA SFR1 |
SFR2 = SFR1; |
MOV SFR1, A + MOVA SFR2 |
SFR1++; |
INC SFR1 |
SFR1 += A; |
ADD SFR1, SFR1 |
SFR1 |= 0x80; |
BS SFR1, 7 |
SFR1 &= ~0x80; |
BC SFR1, 7 |
if (A == 0x01) goto L; |
CMPL 0x01 + JZ L |
06.4 寄存器破坏与现场保护
PIOC 主要的上下文包括累加器 A、零标志 Z、进位标志 C。在调用某些指令时,这些上下文会被影响,因此需要留意。尤其是在通过 CALL 指令调用子程序时,如果子程序中使用了 A,或是执行了会影响 Z 或 C标志的指令,当子程序返回时上下文就被破坏了。
在 C 语言里,每个函数的局部变量有自己的作用域,互不干扰。但 PIOC 的 RISC8B 内核没有 RAM,需借用 SFR 寄存器或栈存放中间结果,且栈仅有 6 级。而累加器 A、零标志 Z、进位标志 C 是全局共用的,相当于所有函数都在使用相同的全局变量,不能再依赖编译器来实现“作用域控制”,而需要手动管理上下文。
以下是 A/Z/C 被影响的最常见情况:
- 累加器 A:几乎所有运算与置立即数指令需要借助 A 来中转值或进行运算。
- 零标志 Z:与运算结果为 0 有关,结果为 0 的算术、逻辑、比较指令会置 Z=1。
- 进位标志 C:算术运算进位或借位会影响 C,移位运算被移出的位进入 C,位传送指令可以改变 C。
也可以参考以下常用指令的副作用速查表(完整版本请参照指令集手册):
| 指令 | 副作用 |
|---|---|
MOVL k(k -> A) |
改变 A(不影响 Z) |
MOV f,A(SFR -> A) |
改变 A,影响 Z(SFR 值为 0 则置 Z=1) |
ADDL / SUBL / CMPL / CMPLN |
影响 Z、C |
ANDL / IORL / XORL |
影响 Z(结果为零置 Z=1) |
ADD / SUB |
影响 Z、C |
AND / IOR / XOR / INC / DEC |
影响 Z(结果为零置 Z=1) |
RCL / RCR |
影响 C |
CLRA / CLR |
置 Z=1 |
RETL / RETLN |
改变 A(k -> A);RETLN 还置 Z=0 |
BCTC |
影响 C |
BP1F / BP2F |
仅当目标为 SB_FLAG_C 时影响 C |
RDCODE |
覆盖 A 和 SFR_INDIR_ADDR |
调用子程序时保护上下文
对照样例汇编里的 FUNC_ADD,开头的 PUSHAS 和结尾 POPAS 正是为了保护上下文:
FUNC_ADD: PUSHAS ; 保存 A/Z/C 到堆栈
MOV SFR_DATA_REG0,A ; 这里影响 A/Z
ADD SFR_DATA_EXCH,SFR_DATA_EXCH ; 这里影响 Z/C
POPAS ; 恢复 A/Z/C,上下文回到调用前的状态
RET
- 累加器 A:子程序里任何
MOV、MOVL、运算都会覆盖 A。调用者如果还指望 A 里的值,必须自己先保存; - 零标志 Z:
MOV、ADD等指令会立刻破坏 Z。所以“比较并跳转”必须紧挨着写,中间不能插别的指令; - 进位标志 C:加减、移位等都会破坏 C。如果调用者要用 C 做多字节运算,也要先保存。
因此 PIOC 提供了 PUSHAS/POPAS 指令,可在子程序开始前把 A/Z/C 压入栈、使用后弹出栈,实现 C 语言中的prologue/epilogue 特性。子程序开头 PUSHAS、结尾 POPAS,是 PIOC 汇编里保护现场的常用写法。
然而,PIOC 的栈只有 6 级深度,而且它既要存 CALL 的返回地址,又要存 PUSHAS 的数据:
| 操作 | 消耗栈 |
|---|---|
CALL 一次 |
1 级(存返回地址) |
PUSHAS 一次 |
1 级(存 A/Z/C) |
也就是说,只要“3 层嵌套调用 + 每层各 1 个 PUSHAS”的工况就足以用尽 6 级栈深度。因此写嵌套子程序调用时需要小心栈深度耗尽。
保存现场:两种调用约定
有两种常用的保存现场的调用约定,建议编写时写好注释以免混用导致现场破坏:
- 被调用者保存:子程序开头
PUSHAS、结尾POPAS。子程序自己知道自己改了什么,并且负责在返回之前恢复原状,调用者不关心。样例FUNC_ADD就是这种; - 调用者保存:调用前自己
PUSHAS、调用后POPAS。适合子程序行为复杂、调用者想自行保护上下文的情况。
暂时存放数据
由于 PIOC 没有 RAM,一般采用以下两种方式暂时存放数据:
- 写入堆栈:
PUSHAS/POPAS只保存 A/Z/C,一般用于保护上下文; - 写入 SFR 数据寄存器:
DATA_REG0-31是双侧可读写的,因此可以作为“小 RAM”用。但要注意DATA_REG*与主核一侧共享且主核写入优先,当 PIOC 与主核同时写同一寄存器时,PIOC 的写会被自动丢弃。所以和主核交换数据时需要有明确的规则,双向使用时必须有同步机制避免“同时写”发生导致数据丢失。
06.5 组织汇编代码的实用技巧
跑通官方例程、读懂了 06.2 的样例与 06.3 的数据流动之后,就可以尝试配合指令手册开始阅读 PIOC 汇编。这一节整理了一些组织代码的实用技巧。它们不是必须遵守的规范,只是一些例子,可以原样照用,也可以按自己的需求改写。
技巧 1:用 EQU 给 SFR 设置别名
数据寄存器 DATA_REG0-31 本身只是地址,直接写 SFR_DATA_REG17 的话,在读代码时只能得到“这是17号数据 SFR”的信息。为了提高代码的可读性,可以在汇编文件的开始给它们设置符合应用的、语义化的别名:
T0_CYCLE EQU SFR_DATA_REG0 ; 时序 #0 的周期数
T1_CYCLE EQU SFR_DATA_REG1 ; 时序 #1 的周期数
CMD_TYPE EQU SFR_DATA_REG2 ; 主核下发的命令子类型
CMD_ARGV EQU SFR_DATA_REG3 ; 命令的附加参数
CMD_RESP EQU SFR_DATA_REG4 ; 返回给主核的结果码
CMD_CODE EQU SFR_CTRL_WR ; 命令入口(主核写、PIOC 读)
CMD_RESULT EQU SFR_CTRL_RD ; 结果出口(PIOC 写、主核读)
这个“寄存器使用定义”写好后,下面的汇编里就可以直接使用别名来操作;与此同时,主核侧的 C 代码里也相应的用注释标注每个寄存器的用途(如 // DATA_REG2 子命令类型),保证两侧的“寄存器使用定义”一致。这个信息相当于 PIOC 固件和主核代码之间的“接口文档”。
技巧 2:命令-参数-结果,三件套的调用约定
主核与 PIOC 之间最常见的交互模式是像“调用一个函数”一样操作:主核发命令带参数,PIOC 执行完回结果。常用的约定模式一般如下:
| 阶段 | 主核侧动作 | PIOC 侧动作 |
|---|---|---|
| 传参 | 写 DATA_REG_* 参数寄存器 |
(等待中) |
| 启动 | 写 SFR_CTRL_WR(命令码) |
WAITB WB_DATA_MW_SR_1 退出等待 |
| 解析 | - | 读命令码,按码跳转执行 |
| 回传 | - | 写结果到 SFR_CTRL_RD,置 SB_INT_REQ(可选) |
| 读取 | 读 SFR_CTRL_RD |
WAITB WB_DATA_SW_MR_0 确认主核已取走 |
| 收尾 | - | 清 SB_INT_REQ,回到等待 |
06.2 的样例就是这套约定的一个简易实现,可以对照着看:WAITB 等待命令码、读 CTRL_WR、按码分发、写回 CTRL_RD、回到等待,每一步都能在表格里找到对应。
实际使用时,一般也只是“用数据寄存器多传几个参数、多几条命令分支”,而核心控制流基本相同。因此,使用汇编编写 PIOC 固件时,先明确期望的控制流,有益于维持开发过程中的思路清晰。
技巧 3:位条件跳过 = 汇编里的短 if 块
RISC8B 的指令集较为简洁,因此如果需要像 C 语言中的 if 条件分支,需要组合使用比较和条件跳转指令,如 CMPL + JZ/JNZ。而在协议数据传输中最常用的模式是位条件跳过,也就是 BTSC/BTSS。以下是一个位条件跳过的例子:
BTSC SFR_DATA_EXCH,0 ; 若 DATA_EXCH 的 bit0 == 0,跳过下一条指令
INC SFR_INDIR_ADDR,F ; 否则(bit0 == 1)执行 INC
... ; bit0 == 0 时,直接跳到这里
含义是:“如果这一位是 0,下一句就跳过去不执行。”
这种“位条件跳过”模式的一个典型应用是统计 1 的个数:每收发一位,就用 BTSC 判断这一位是否为 1,是则执行 INC 指令对计数器自增。当收发完成后,计数器的值就是“这一字节里 1 的个数”,可以用来生成或校验奇偶校验位(收发双方约定一帧里 1 的个数为奇数或偶数,以此检错)。
实际应用中,许多某种条件下做某事的功能,都可以用“位条件跳过+被跳过的任意指令”实现。如果需要按条件执行不止一条指令,还可以通过 BTSC/BTSS + JMP/CALL 的方式来实现。
技巧 4:逐位收发 + 单线换向
中低速协议的底层都是“一位一位地收发”。而许多双线同步协议都包含一条时钟线,按“拉低-延时-拉高-延时”的固定周期运行,每个周期发送或接收一位。
例如:发送时先把数据位传送到 IO0 输出(用 BP2F 把 DATA_EXCH 的某位搬到 IO0 输出位),再切换时钟信号电平产生需要的边沿;接收时先切换时钟信号电平产生所需要的边沿,再用 BG2F 把 IO0 读到 DATA_EXCH 的对应位。
而对于单线半双工总线(同一根信号线的分时复用),还需要处理总线的换向:发送阶段把 IO0 配成输出,接收阶段切回输入(开启上拉)。
BS SFR_PORT_DIR,SB_PORT_DIR0 ; IO0 配为输出(发送状态)
BC SFR_PORT_DIR,SB_PORT_PU0 ; IO0 关闭上拉
...
BS SFR_PORT_DIR,SB_PORT_PU0 ; IO0 启用上拉
BC SFR_PORT_DIR,SB_PORT_DIR0 ; IO0 切回输入(接收状态)
多个 IO 同时配置时,方向切换、上拉配置也可以都用 MOVA1F 直接写 SFR_PORT_DIR,因为它是少数能“直接置立即数”的寄存器:
MOVA1F 0B00001011 ; IO0、IO1 均配为输出(发送状态)
...
MOVA1F 0B00001110 ; IO0 切回输入并开启上拉,IO1 保持输出(接收状态)
技巧 5:参数化延时子程序
PIOC 控制时序主要有两大方法:一是用指令循环“数周期”来延时(相对常用),二是用片上定时器 0 产生精确的定时。两者各有适用场景:延时子程序实现简单、不占额外硬件,适合中低速协议;定时器 0 则适合需要精密定时或发生 PWM 信号的情况。
位时钟的速率由软延时控制时,实际延时消耗的时间取决于指令消耗的周期。一般来说,指令周期遵循以下规律:
RDCODE是 3 周期- 所有导致程序计数器 PC 被改变的指令是 2 周期
- 包括
JMP/CALL/RET等无条件跳转指令 JNZ/JZ/JNC/JC/CMPZ/BTSS/BTSC等条件跳转指令,仅当条件导致跳转发生时是 2 周期,否则是 1 周期
- 包括
- 除此之外都是 1 周期
一种实用的做法是把延时循环的周期数放在数据寄存器里,主核可以在运行时调整速率,不必重新编译固件:
; ARGS: A = 外层循环次数(0 则立即返回)
; BREAKS: A, Z, SFR_INDIR_ADDR2
MY_DELAY: CMPZ 0X00,MY_DELAY_EXIT ; 参数为 0 直接退出
MOVA SFR_INDIR_ADDR2 ; 从 A 传入的外层循环次数存入 SFR_INDIR_ADDR2
MY_DELAY_1: MOVL 0XFF ; 为 A 装入初值 0xFF
MY_DELAY_2: ADDL 0XFF ; 内层:A 自减(A+255 相当于 A-1)
JNZ MY_DELAY_2 ; A 还不为 0 则跳回内层开始
DECSZ SFR_INDIR_ADDR2,F ; 外层计数减 1,为 0 则跳过下一条
JMP MY_DELAY_1
MY_DELAY_EXIT: RET
内层循环中,用 ADDL 0XFF 实现 A=A-1 的自减:A 加上 255 相当于 A 减 1(8 位运算下补码的 0xFF 即 -1),减到 0 时置 Z=1,JNZ 判断 Z 决定是否继续进行内层循环。内层循环固定执行 255 次。
外层循环中,用 DECSZ f,F(SFR 减 1、为 0 跳过下一条)控制循环次数。
为了提高代码可读性,建议在注释中写下精确周期公式。由于 PIOC 的指令周期是高度可确定的,每条指令的周期数均在指令集手册中有给出,写下公式后后续计算延时参数时更加方便。以上面这段代码为例:内层 ADDL+JNZ 每轮消耗 764 周期(255 次自减 + 254 次跳转 + 1 次不跳),外层循环每轮的 MOVL/DECSZ/JMP 又合计消耗 4 周期,加上入口与 RET,总延时约 768×外层循环周期数 + 2 个指令周期。
一些说明
上面这几种技巧都不是新知识,而是本章的内容在实际应用中的“工程化”体现。如果有兴趣再去读官方例程的时候,会发现每个例程都或多或少会有相似的模式;当尝试修改例程的汇编时,这些技巧将会带来一些帮助。
06.6 理解 PIOC 汇编所需要的并不多
对于许多第一次接触汇编的工程师来说,看到 06.2 的样例汇编时觉得陌生,是很正常的反应。但理解 PIOC 汇编并不需要“学会汇编”,只需要:
-
看控制流: 许多例程都使用类似 06.2 里提到的“入口点-等待命令-按命令分发-执行-返回结果-回到入口点”的控制流范式。只要先摸清楚程序的控制流主线,以及其余代码在这条主线上如何衍生,就容易理解程序的行为。
-
看数据流: 读汇编时先分清哪些 SFR 是主核传入的参数、哪些是 PIOC 要返回的结果,就明确了 PIOC 的功能边界。然后,跟随控制流逐步观察数据如何被获取、传递和使用,就能够了解程序的作用。
所以,基于例程进行 PIOC 的开发,通常只需要:
- 会看控制流:汇编里的延时子程序(用
NOP延时循环产生时序)、位输出循环等核心片段; - 知道常用范式:许多程序都是“入口-收命令-执行命令-返回”的“命令-响应”式结构;
- 不必会写:除非要自定义新的协议或时序波形,否则很少需要自己从头写汇编——甚至完全可以在 06.2 的样例上继续衍生。
有兴趣想更进一步了解汇编细节的读者,可查阅官方《PIOC 使用说明》与 CHRISC8B 指令手册。
07 结语
本文以 WS2812 灯带为例,演示了如何使用 PIOC 进行数字协议通信,并初步介绍了 PIOC 侧的汇编开发。可以看到,PIOC 并没有想象中那么神秘:主核侧的操作常常采用固定的开发范式,PIOC 侧的汇编也只需要掌握控制流、数据流和少量常用指令,就能读懂甚至改写例程。不过鉴于笔者也没有非常深入地研究 PIOC,文章中若有错误或不当之处,欢迎批评指正。
如果你此前对汇编心存顾虑,希望本文能让你放下这份顾虑:PIOC 高度凝练的指令集只有 66 条,日常协议处理真正用到的更少,而官方例程已经涵盖了多种常用协议,完成了大部分开发工作,可以直接调用移植。
下一篇,我们将带着本文积累的知识,在 06.2 的样例模板基础上,从零开始写一个 DS18B20 的 PIOC 固件,把本文讲到的开发范式真正用起来。

浙公网安备 33010602011771号