小熊派 Hi3863 第一个程序:GPIO 点灯与串口打印
环境搭好之后,下一个问题是:代码从哪进去、任务怎么建、引脚怎么点、printf 打到哪。 这四个问题在 Hi3863 上都有 SDK 自己的答案,跟"STM32 里写个 main() 然后 while(1)"的习惯完全不一样。
这篇拿 SDK 里两个最短的样例(blinky 和 led)逐段拆开:app_run() 是怎么被自动调用的、GPIO 点灯必须按什么顺序调用、printf 一路走到哪个串口。看完你能自己写出第一个能编的样例。
这个专栏最后要做成什么:把一台普通的儿童电动车,改装成一台 1 主机 + 2 从机的 SLE(星闪)组网遥控小车。
第 01 篇把环境打通,这一篇是第一个能上板的程序,之后每一篇都往这台车上加一块——电机 → 遥控 → 组网 → 闭环。完整的 18 篇路线图放在文末第九节。
本篇是源码解析。
blinky/led这两个目录都不在实证构建里(那份成功的构建日志启用的是 SLE 一主八从 Server 配置)。下面的宏定义、调用链、配置项都来自 SDK 源码原文;串口输出和上板现象请以你自己的板子为准。
一、动手前先确认这几样
| 项目 | 我这边的情况 | 说明 |
|---|---|---|
| 开发板 | 小熊派 Hi3863(HiHope NearLink DK3863E V03) | 核心板 + 底板,点灯还用到交通灯板 |
| 主控 | Hi3863 / WS63,RISC-V 内核 | 系统是 LiteOS |
| 软件 | HiSpark Studio + 对应版本 SDK | 样例在 application/samples/peripheral/ 下 |
| 本篇用的样例 | blinky(CMSIS 写法)、led(osal 写法) | 两个都是最小点灯程序 |
| 串口 | 日志口 921600、8 数据位、1 停止位、无校验 | 用 115200 连必然乱码 |
| 上板验证 | 本文没有附串口截图和接线照片 | 只讲源码层面能确认的东西 |
最后一行很重要:这一篇不会出现"我实测看到灯闪了"这种话。 你要的是能编、能对着改的代码骨架,这部分源码完全能支撑。
二、最小程序长什么样
application/samples/peripheral/blinky/blinky_cmsis.c 去掉版权头就只有这些:
#include "pinctrl.h"
#include "soc_osal.h"
#include "gpio.h"
#include "osal_debug.h"
#include "cmsis_os2.h"
#include "app_init.h"
#define BLINKY_TASK_STACK_SIZE 0x1000
#define BLINKY_TASK_PRIO (osPriority_t)(17)
static void *blinky_task(const char *arg)
{
unused(arg);
uapi_pin_set_mode(CONFIG_BLINKY_PIN, PIN_MODE_0);
uapi_gpio_set_dir(CONFIG_BLINKY_PIN, GPIO_DIRECTION_OUTPUT);
uapi_gpio_set_val(CONFIG_BLINKY_PIN, GPIO_LEVEL_LOW);
while (1) {
osal_msleep(CONFIG_BLINKY_DURATION_MS);
uapi_gpio_toggle(CONFIG_BLINKY_PIN);
}
return NULL;
}
static void blinky_entry(void)
{
osThreadAttr_t attr;
attr.name = "BlinkyTask";
attr.attr_bits = 0U;
attr.cb_mem = NULL;
attr.cb_size = 0U;
attr.stack_mem = NULL;
attr.stack_size = BLINKY_TASK_STACK_SIZE;
attr.priority = BLINKY_TASK_PRIO;
if (osThreadNew((osThreadFunc_t)blinky_task, NULL, &attr) == NULL) {
/* Create task fail. */
}
}
app_run(blinky_entry);
一眼看不出门道的是最后那行 app_run(blinky_entry);——它不在任何函数体内,是裸放在文件末尾的。这就是 Hi3863 样例的入口写法:不写 int main(),而是注册一个入口函数。
它的执行骨架如图 1。

五步走完:文件里 app_run 注册 → 链接器把函数指针收进一个段 → SDK 的 main() 启动时遍历这个段 → 调用你的 entry → entry 里建任务 → 任务里死循环点灯。
三、app_run() 到底做了什么
这一节是整篇最值得看懂的部分,因为它解释了"为什么可以不写 main"。
app_run 展开后是这样一条链(middleware/utils/app_init/app_init.h):
#define layer_initcall(func, layer, clayer, priority) \
static const init_call_t USED_ATTR __zinitcall_##layer##_##func \
__attribute__((section(".zinitcall." clayer #priority ".init"))) = (func)
#define layer_initcall_def(func, layer, clayer) \
layer_initcall(func, layer, clayer, 0)
#define app_run(func) layer_initcall_def(func, run, "app_run")
翻译成人话:app_run(blinky_entry) 等于定义一个静态函数指针变量,并把它放进名为 .zinitcall.app_run0.init 的段里。这就是一个"自注册"机制,效果上跟你手动维护一张函数指针表一样,只是交给编译器和链接器去做了。
另一头在链接脚本里,这一段被包了起来(output/ws63/acore/ws63-liteos-app/linker.lds):
__zinitcall_app_run_start = .;
KEEP(*(.zinitcall.app_run*.init))
__zinitcall_app_run_end = .;
最后在 app_init.c 里被逐个调用:
void app_tasks_init(void)
{
init_call_t *initcall = &__zinitcall_app_run_start;
init_call_t *initend = &__zinitcall_app_run_end;
for (; initcall < initend; initcall++) {
(*initcall)();
}
}

注意 KEEP(*(.zinitcall.app_run*.init)) 里那个 *——这个段里可以放不止一个入口函数,app_tasks_init() 会把它们全跑一遍。所以你想挂自己的初始化代码,写一行 app_run(your_entry); 就够了,不需要去改 SDK 的 main.c。
它是在什么时候被调用的
app_tasks_init() 出现在 application/ws63/ws63_liteos_application/main.c 的 main() 里,位置很关键:
hw_init(); /* 引脚、GPIO、UART、看门狗等底层初始化 */
/* ... */
main_initialise(NULL, 0); /* SDK 自己的任务表 */
OHOS_SystemInit();
app_tasks_init(); /* ← 你的 app_run 入口在这里被调用 */
osKernelStart(); /* ← 内核这时候才开始跑 */
app_tasks_init() 跑在 osKernelStart() 之前。 这件事决定了三条写法上的规矩:
- entry 里可以放心建任务(
osThreadNew/osal_kthread_create),任务会等内核启动后再被调度; - entry 里不能写死循环、长延时、等信号量,否则
osKernelStart()永远等不到,现场就是"板子毫无反应"; hw_init()里已经调过uapi_pin_init()和uapi_gpio_init()了,你不需要自己初始化 GPIO 驱动。
四、点灯就三件事
blinky_task() 里对引脚的操作只有三句半,顺序不能换:
/* 1. 把引脚复用成 GPIO 功能 */
uapi_pin_set_mode(CONFIG_BLINKY_PIN, PIN_MODE_0);
/* 2. 方向设为输出 */
uapi_gpio_set_dir(CONFIG_BLINKY_PIN, GPIO_DIRECTION_OUTPUT);
/* 3. 先给一个确定的电平 */
uapi_gpio_set_val(CONFIG_BLINKY_PIN, GPIO_LEVEL_LOW);
/* 4. 循环里翻转 */
uapi_gpio_toggle(CONFIG_BLINKY_PIN);

这几个接口都在 include/driver/gpio.h 和 include/driver/pinctrl.h 里,返回 errcode_t(成功是 ERRCODE_SUCC)。样例为了短,没有判返回值,你自己写建议补上。
第一步最容易被忽略。 WS63 的引脚基本都是复用的,同一个物理引脚可能既是 GPIO,又是 UART、PWM 或 SPI 的某一根线。uapi_pin_set_mode() 就是"这根线现在归谁用"的开关,先设它,后面的电平操作才有效。
这里还有个容易让人犯迷糊的地方:blinky 写的是 PIN_MODE_0,led 样例写的是 HAL_PIO_FUNC_GPIO,看着像两个东西,其实是同一个值:
/* drivers/chips/ws63/porting/pinctrl/pinctrl_porting.h */
#define HAL_PIO_FUNC_GPIO PIN_MODE_0
typedef enum {
PIN_MODE_0 = 0,
PIN_MODE_1 = 1,
/* ... */
PIN_MODE_MAX = 8
} pin_mode_t;
两个都等价于"用模式 0",也就是 GPIO 功能。反过来,如果一根线已经被设成了 PIN_MODE_2(比如某个 UART 的 TX),你再对它调 uapi_gpio_set_dir() 是不会动的。
引脚号来自 Kconfig,不写死在代码里:
# application/samples/peripheral/blinky/Kconfig
config BLINKY_PIN
int
prompt "Choose BLINKY_PIN pin."
depends on SAMPLE_SUPPORT_BLINKY
default 2
config BLINKY_DURATION_MS
int
prompt "Duration of blinky in MS."
default 500
默认引脚是 GPIO 2,默认周期 500 ms。这两个值跟你的板子上 LED 接在哪根线没有关系,换板子就得改这里——这是后面踩坑一节里的一条。
五、led 样例:同一件事的另一种写法
application/samples/peripheral/led/led_example.c 干的事跟 blinky 一模一样,但用的是 osal 那套接口:
#define BLINKY_TASK_STACK_SIZE 0x1000
#define BLINKY_TASK_PRIO 24
#define BSP_LED 7 // RED
#define CONFIG_BLINKY_DURATION_50MS 50
static void *led_task(const char *arg)
{
unused(arg);
uapi_pin_set_mode(BSP_LED, HAL_PIO_FUNC_GPIO);
uapi_gpio_set_dir(BSP_LED, GPIO_DIRECTION_OUTPUT);
uapi_gpio_set_val(BSP_LED, GPIO_LEVEL_LOW);
while (1) {
osal_msleep(CONFIG_BLINKY_DURATION_50MS);
uapi_gpio_toggle(BSP_LED);
}
return NULL;
}
static void led_entry(void)
{
uint32_t ret;
osal_task *taskid;
osal_kthread_lock(); /* 建任务前先加锁 */
taskid = osal_kthread_create((osal_kthread_handler)led_task, NULL,
"led_task", BLINKY_TASK_STACK_SIZE);
ret = osal_kthread_set_priority(taskid, BLINKY_TASK_PRIO);
if (ret != OSAL_SUCCESS) {
printf("create task1 failed .\n");
}
osal_kthread_unlock();
}
app_run(led_entry);
两者的差异可以对照这张表:

| 对比项 | blinky(CMSIS 写法) | led(osal 写法) |
|---|---|---|
| 头文件 | cmsis_os2.h | soc_osal.h |
| 建任务 | osThreadNew(func, NULL, &attr) | osal_kthread_create(func, NULL, name, stack) |
| 优先级 | attr.priority = (osPriority_t)(17) | osal_kthread_set_priority(task, 24) |
| 失败判断 | 判断 osThreadNew 是不是 NULL | 判断 set_priority 是不是 OSAL_SUCCESS |
| 建任务加锁 | 不用 | osal_kthread_lock() / unlock() |
| 引脚号 | CONFIG_BLINKY_PIN(Kconfig,默认 2) | 宏 BSP_LED 写死 7 |
两种都能用,选一种保持统一就行。真正要注意的是优先级是两套体系:
- CMSIS 的
osPriority_t是"数字越大越优先",osPriorityNormal等于 24; - osal 的优先级最终走到
LOS_TaskPriSet(),LiteOS 这边LOS_TASK_PRIORITY_HIGHEST是 0、LOS_TASK_PRIORITY_LOWEST是 31,数字越小越优先。
SDK 自己的 main.c 里给任务定优先级用的就是 25 / 27 / 12 / 13 这类裸数字;而 osal_task.h 的函数注释又写着"必须是 OSAL_TASK_PRIORITY_HIGH / MIDDLE / LOW 之一"(在 LiteOS/FreeRTOS 分支下这几个值是 3 / 6 / 10)。两套说法并存,实际跑起来走的是 LOS_TaskPriSet()——看代码时别把两边的心智模型混着用,跟着同一份 SDK 里的样例写最稳。
六、printf 打印到哪里去了
led 样例里那句 printf("create task1 failed .\n"); 为什么会在串口助手里出现?完整链路如图 5。

第一跳在 kernel/liteos/printf_adapt/printf_adapt.c——SDK 把标准 printf 重定向到了 LiteOS 的串口打印:
int printf(const char *restrict fmt, ...)
{
va_list ap;
va_start(ap, fmt);
UartVprintf(fmt, ap);
va_end(ap);
return 0;
}
第二跳是底层 UART。日志口用的是平台固定的那一对引脚,波特率来自工程配置:
/* drivers/chips/ws63/include/platform_core.h */
#define CODELOADER_UART_TX_PIN S_AGPIO15
#define CODELOADER_UART_RX_PIN S_AGPIO14
#define LOG_UART_BUS CONFIG_LOG_UART
/* drivers/chips/ws63/porting/uart/uart_porting.c */
uart_attr_t uart_line_config = {
.baud_rate = CONFIG_LOG_UART_BAUDRATE,
.data_bits = UART_DATA_BIT_8,
.stop_bits = UART_STOP_BIT_1,
.parity = UART_PARITY_NONE
};
build/config/target_config/ws63/menuconfig/acore/ws63_liteos_app.config 里这两个值是:
CONFIG_LOG_UART=1
CONFIG_LOG_UART_BAUDRATE=921600
所以串口工具要开 921600 8N1,用 115200 连一定是乱码。
于是"printf 没输出"的排查顺序也就清楚了:波特率 → 端口选对没有 → 那句 printf 到底跑到没有。第三点尤其常见:led 样例的 printf 只写在失败分支里,任务创建成功了它一句话都不打——串口安安静静反而是好消息。
七、怎么验证
这篇给的是源码层面能确认的检查点,不是"我实测看到":
-
blinky_cmsis.c最后一行是app_run(blinky_entry);,不是main(); - 引脚先
uapi_pin_set_mode(),再uapi_gpio_set_dir(),顺序没反; -
CONFIG_BLINKY_PIN/CONFIG_BLINKY_DURATION_MS跟你的板子对得上; -
application/samples/peripheral/CMakeLists.txt和同目录Kconfig里有这个样例的开关; - KConfig 菜单里勾上了对应样例,编译日志最后出现
######### Build target:ws63_liteos_app success; - 串口工具波特率设成 921600。
上板前再确认一件事:你要点亮的是哪根线。blinky 默认 GPIO 2,led 样例是 GPIO 7(对应交通灯板的 RED)。引脚值填错,现象就是"程序在跑、灯不亮"。
八、踩坑
坑 1:led 目录放好了,编译却完全没编到它
这份 SDK 里 application/samples/peripheral/led/ 是从官方样例拷进来的:目录里 .c 和 CMakeLists.txt 都齐,但父目录的 CMakeLists.txt 和 Kconfig 里没有它的开关,所以它压根不在构建图里,编译日志里搜不到 led_example。
补两处即可:
# application/samples/peripheral/CMakeLists.txt 里新增
if(DEFINED CONFIG_SAMPLE_SUPPORT_LED)
add_subdirectory_if_exist(led)
endif()
# application/samples/peripheral/Kconfig 里新增
config SAMPLE_SUPPORT_LED
bool
prompt "Support LED Sample."
default n
depends on ENABLE_PERIPHERAL_SAMPLE
改完去 KConfig 菜单勾上 Support LED Sample 再编。顺手核对一下 led/CMakeLists.txt 里登记的文件名和实际文件名一致。
坑 2:blinky 的默认引脚是 GPIO 2,不是板上 LED 那根
CONFIG_BLINKY_PIN 的默认值 2 只是个占位值。板子上 LED 挂在别的脚时,程序逻辑完全正常——任务在跑、编译没报错——但灯就是不亮。先查原理图确认 LED 接在哪个 GPIO,再去改 Kconfig 或代码。
坑 3:入口函数不是 main(),别在里面写业务循环
app_run() 注册的 entry 是在 osKernelStart() 之前被调用的。遇到"板子完全不启动",先检查 entry 里有没有 while(1)、长 osDelay、等信号量之类的阻塞操作。
正解是 entry 只负责建任务,循环放进任务函数里——两个样例都是这么写的。
坑 4:建任务的返回值必须判断
osThreadNew 失败返回 NULL,osal_kthread_create 失败也返回 NULL。可 blinky 里的判断体是空的(只留了一句注释),led 判的是 set_priority 的返回值——两个都不完整。
建任务失败最常见的原因是栈太小或任务名太长。 两个样例给的栈都是 0x1000(4 KB)。你在任务里塞浮点运算、大数组、长 printf 之前,先把栈加上去,否则现象是跑着跑着直接崩,很难往栈上想。
坑 5:printf 没东西,九成是波特率或者端口
日志口是 921600,不是 115200。另外日志口用的是固定引脚(S_AGPIO15 / S_AGPIO14),USB 转串口要接到这一对上,接错口当然什么都没有。
还有一种情况:你确实接到了日志口、波特率也对,但代码里那条 printf 在失败分支里——没输出反而是正常结果。
坑 6:led 样例里任务函数的签名是被强转过去的
osal_kthread_handler 的定义是 int (*)(void *data),而 led_task 写的是 void *led_task(const char *arg),两者是靠 (osal_kthread_handler) 强转接上的。编译能过,但参数类型和返回值都不匹配,属于典型的"能跑但别学"。
自己写的时候建议照 osal_kthread_handler 的原型来:int your_task(void *arg)。
九、这个专栏最后要做成什么:1 主机 + 2 从机的组网儿童车
先把终点摆出来,不然点灯就只是一次性的小实验。整个专栏的落点是一台改装儿童电动车——把手里的遥控器换成两块 Hi3863 之间的 SLE(星闪)无线链路。
分工我按最常见的做法来写,你可以按自己的车调整:
| 角色 | 大概装在哪 | 板子 + 外设 | 负责什么 |
|---|---|---|---|
| 主机 | 遥控端 | Hi3863 + 遥控接收 | 采遥控信号,通过 SLE 下发指令,汇总车辆状态 |
| 从机 1 | 车上 | Hi3863 + 电机驱动(PWM) | 收指令,驱动左右电机正反转、调速 |
| 从机 2 | 车上 | Hi3863 + MT6816 磁编码器 | 读车轮转角与速度,回传给主机做闭环 |
两块从机在同一台车上,主机在手里——这就是"1 主机 2 从机"的全部含义。SDK 里现成的样例是一主八从的框架,我们要做的是在这套框架上裁到 1 主 2 从,再把它接到真实的电机和编码器上。
路线图(篇号跟这篇所在的专栏一致):
| 阶段 | 篇号 | 你会拿到什么 |
|---|---|---|
| 打地基 | 01 ~ 02 | 环境能编能烧;第一个 GPIO 程序能跑;看懂 app_run 和任务是怎么建的 |
| 让它动 | 03 ~ 05 | PWM 呼吸灯 → PWM 驱动电机正反转 → 多路 UART 基础收发 |
| 听懂遥控 | 06 ~ 08 | STP23L 激光测距分帧、SBUS 遥控解析(100000 8E2 / 25 字节)、串口控电机 |
| 会组网 | 09 ~ 13 | SLE 一主一从最小例程 → 一主八从的广播与多连接管理 → UART ↔ SLE 双向透传 |
| 会闭环 | 14 ~ 17 | MT6816 读角度、编码器测速与滤波、SLE 遥控 + PID 闭环、UWB 跟随 |
| 复盘 | 18 | 多连接的坑与压测结论 |
按一周 2~3 篇的节奏走,这条线走完,你手上会有两台(甚至三台)Hi3863 在互相说话,而且其中一台真的在驱动车轮。
一句提醒:车上的动力部分是独立的一路。调试控制板的时候先把电机断开,别让程序还没写对,车轮就先转起来了。
十、下一篇
点灯确认的是"代码能进构建、任务能起来、引脚能控制"这条最小链路。下一篇写 PWM 呼吸灯:peripheral/pwm/pwm_demo.c 逐行解析——把引脚复用成 PWM 功能、设置频率和占空比,再用渐变做出呼吸效果,顺带对比 GPIO 软件翻转和硬件 PWM 的差别。
这一篇看着还是"玩具",但驱动电机调速用的就是同一个 PWM 外设。先把 PWM 玩明白,第 04 篇把电机接上去就顺理成章了。
如果你照着写完灯不亮,把三样东西贴出来基本就能定位:KConfig 里勾的是哪个样例、编译日志最后一行、串口输出。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2302_80616326/article/details/166884793




