小熊派 Hi3863 第一个程序:GPIO 点灯与串口打印
文章目录
- 小熊派 Hi3863 第一个程序:GPIO 点灯与串口打印
- 一、动手前先确认这几样
- 二、最小程序长什么样
- 三、app_run() 到底做了什么
- 它是在什么时候被调用的
- 四、点灯就三件事
- 五、led 样例:同一件事的另一种写法
- 六、printf 打印到哪里去了
- 七、怎么验证
- 八、踩坑
- 坑 1:led 目录放好了,编译却完全没编到它
- 坑 2:blinky 的默认引脚是 GPIO 2,不是板上 LED 那根
- 坑 3:入口函数不是 main(),别在里面写业务循环
- 坑 4:建任务的返回值必须判断
- 坑 5:printf 没东西,九成是波特率或者端口
- 坑 6:led 样例里任务函数的签名是被强转过去的
- 九、这个专栏最后要做成什么:1 主机 + 2 从机的组网儿童车
- 十、下一篇
环境搭好之后,下一个问题是:代码从哪进去、任务怎么建、引脚怎么点、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"#defineBLINKY_TASK_STACK_SIZE0x1000#defineBLINKY_TASK_PRIO(osPriority_t)(17)staticvoid*blinky_task(constchar*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);}returnNULL;}staticvoidblinky_entry(void){osThreadAttr_tattr;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):
#definelayer_initcall(func,layer,clayer,priority)\staticconstinit_call_tUSED_ATTR __zinitcall_##layer##_##func\__attribute__((section(".zinitcall."clayer #priority".init")))=(func)#definelayer_initcall_def(func,layer,clayer)\layer_initcall(func,layer,clayer,0)#defineapp_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里被逐个调用:
voidapp_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 */#defineHAL_PIO_FUNC_GPIOPIN_MODE_0typedefenum{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 那套接口:
#defineBLINKY_TASK_STACK_SIZE0x1000#defineBLINKY_TASK_PRIO24#defineBSP_LED7// RED#defineCONFIG_BLINKY_DURATION_50MS50staticvoid*led_task(constchar*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);}returnNULL;}staticvoidled_entry(void){uint32_tret;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 的串口打印:
intprintf(constchar*restrict fmt,...){va_list ap;va_start(ap,fmt);UartVprintf(fmt,ap);va_end(ap);return0;}第二跳是底层 UART。日志口用的是平台固定的那一对引脚,波特率来自工程配置:
/* drivers/chips/ws63/include/platform_core.h */#defineCODELOADER_UART_TX_PINS_AGPIO15#defineCODELOADER_UART_RX_PINS_AGPIO14#defineLOG_UART_BUSCONFIG_LOG_UART/* drivers/chips/ws63/porting/uart/uart_porting.c */uart_attr_tuart_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=1CONFIG_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 里勾的是哪个样例、编译日志最后一行、串口输出。