从事嵌入式开发这么久,你一定听说过
Semihosting
吧?
什么?你没听说过
?
那你可能在不知不觉中已经踩坑了。
如果你只是对
Semihosting
偶有耳闻,那么你与楼上那位多半也是难兄难弟了。
想要深入了解
Semihosting
,我们还得从它的名字说起。
虽然说很多情况下谜底往往会直接贴脸写在谜面上,但
Semihosting
对非英文母语的国人来说,其实跟两眼一抹黑没有区别,更别提它还拥有一个极具嘲讽意味的中文翻译:
“半主机”
。
之所以说这种翻译极具嘲讽意味,是因为它就是大家口中那种
“每个字我都认识,放到一起完全不知所云”
的典型。这里
“semi-”
对应“半”;
“hosting”
对应主机。
然而,但凡学过初中英语,你也能理解这里翻译的巨大瑕疵吧?
“hosting”
看起来是一个动名词,那么这里的
“host”
就是一个动词,所以这里
host
绝对不能简单粗暴的翻译成“主机”,而应该能够体现它是一个动作,比如“做主机
(hosting)
”。
那么
semi-hosting
至少应该是
“半”“做主机”
。那么问题来了:
什么情况下才会
“做主机做一半(semihosting)”
呢?
这里其实并不算是一个玩笑,相对“半主机”这个似是而非的翻译,
“如做(主机)”
其实更贴近
Semihosting
的本质。要想理解问题的本质,还要从
hosting
这个动词的意涵来入手,这里:
-
抛开翻译的美学不谈,
hosting
的意思其实更贴这个词汇在生活中的原意——
做东
。也就是我们中国人请客时候说的“我做东”的做东,即:
作为活动的主办方为客人提供服务。
-
Semi-
意为“做东做一半”——也就是大家说的“如做”。
那么,如何理解这里的“一半”呢?要回答这个问题,就要搞清楚这里的所谓的“做东”主要是
为谁
提
供
怎样的服务
,这里:
-
这里的主人并非是
PC
,而是运行在
PC
上、提供调试服务的程序,比如
GCC
命令行下的
GDB
和
MDK
下的
Debugging
模式等等。
在搞清楚了上述几个关键点后,
Semihosting
的完整意涵就非常清楚了——由
“PC上运行的调试程序”
为
“MCU上运行的应用程序”
提供
“Libc服务”
的
这个动作
,
叫做
“Semi-hosting”
。
这里,
“hosting”
准确翻译应该是“做东提供服务”,
“semi-”
则体现了一个合作上的细节,即:
-
整个“为客服务”的过程一半由运行在
MCU
上的
runtime
库
(libc runtime)
提供接口,
-
经过调试仿真器
(Debugger Adapter)
的通讯后,
-
知晓本意之后,我们回头再来看
Semihosting
的“通常”翻译——“半主机”——是不是更加体会到这种
按字硬翻
的
“似是而非”
和
“败事有余”
了?
是不是更加理解为什么“如做”才是更加贴切的翻译了?(然而并不是,哈哈哈……)
三、Semihosting是如何成为嵌入式“阑尾”的?
正如前面所说,
Semihosting
是一种由PC上运行的调试程序
(Debugger)
,经由调试仿真器
(Debugger Adapter)
与
MCU
上的运行时库
(runtime)
进行通信,提供
Libc
基础服务的方式。
在简中世界中,我们常常把
JLink
、
DapLink
、
ST-Link
或者
CMSIS-DAP
这样的“调试仿真器”理解为“
Debugger
”,这其实是错误的——“
Debugger
”对应的是
GDB
或者
MDK
调试模式这样的上位机程序;而“
Debugger Adapter
(调试适配器)”才是我们口中的调试仿真器。
如下图所示——一个完整的
Semihosting
链路需要至少三个部分组成:
-
-
-
支持
Semihosting
的
MCU
运行时库
(runtime)
重点来了!!
这里,调试仿真器往往只是扮演一个透明数据通道的作用,对
Semihosting
服务本身来说虽然必须但并非关键。重点来了:
-
Arm Compiler 5
和
Arm Compiler 6
在生成
MCU
代码时,其使用的
Libc
会
默认开启
对
Semihosting
的支持;
-
并不是所有的上位机调试程序都支持
Semihosting
——你说是吧?
MDK
?
是的,你没猜错,万众瞩目的
MDK
(严谨点,截止到MDK5),它大宝贝的居然不支持
Semihosting
。
那你猜,当
Arm Compiler 5
和
Arm Compiler 6
在你毫不知情的情况下默认开启了对
Semihosting
的支持,而
MDK
却不支持的时候,你调用任何
libc
的API会发生什么呢?
要想搞清楚嵌入式阑尾
“Semihosting
”
的症状和危害,我们首先要搞清楚
Semihosting
的一些“致病机理”,以及它的“作用范围”。
【病理特性】
嵌入式程序在调用支持Semihosting的本地运行库时,被调用的API会执行特定的指令(Cortex-M中是BKPT指令)来触发Semihosting调用。芯片本地的硬件调试模块在捕捉到这些指令后,会与支持Semihosting的上位机交互并执行相应的服务。例如,当嵌入式程序通过printf打印信息时,本地的libc库会通过Semihosting将信息发送到PC上,由主机的控制台显示出来。
就如同百度百科搜索出来的病例词条一样,上述关于
Semihosting
的病理描述让人半懂不懂。但有一点是值得关注的,即:
对
Cortex-M
处理器来说,当我们调用
“长了Semihosting“
的
Libc API
时,”病灶组织“会通过
BKPT
指令来与上位机交互。比如,对于下面的代码:
#include
...
clock_t tClock = time();
...
我们会观察到如下的现象:
-
MDK
在全速运行的情况下,莫名其妙的暂停;如果目标代码出现在循环体中,甚至在我们按下F5以后仍然会暂停;
-
打开汇编调试界面,会发现PC指针停在一个
BKPT 0xAB
指令上:
这里的指令:
就是
Semihosting
的专用指令。其中
BKPT
是Cortex-M 的 Break Point(软件断点)指令,而常数
0xAB
则是 Semihosting 专用暗号。换句话说,只要你在
MDK
的汇编调试窗口中观察到了上述指令的组合,就说明你的代码得了
“嵌入式阑尾炎”
——病因是上位机调试程序不支持
Semihosting
,从而把
Semihosting
暗号当成了普通的软件断点来处理。
即便你认为在
调试模式
下懵懂无知的MDK将
Semihosting
暗号当成普通的软件断点无伤大雅——大不了多按几下F5继续就是了——那么如果我告诉你
”BKPT指令在非调试模式下执行,会直接让Cortex-M处理器进入Hardfault“
阁下又将如何应对呢?
"主人:已通过GPT为您查询到到了Semihosting的影响范围:"
在Arm Compiler 5和Arm Compiler 6中,Semihosting主要覆盖了一些常见的标准C库(libc)功能,这些功能使得嵌入式开发者能够在开发和调试过程中利用主机的资源来执行特定的操作。下面是Semihosting所覆盖的libc功能的主要类别:
1. 标准输入/输出(Standard I/O)
2. 文件操作(File Operations)
-
fopen
:打开文件。
-
fclose
:关闭文件。
-
fread
:从文件读取数据。
-
fwrite
:向文件写入数据。
-
fseek
:移动文件指针到指定位置。
-
ftell
:获取文件指针当前位置。
-
fflush
:刷新文件输出缓冲区。
3. 时间和日期(Time and Date)
-
time
:获取当前时间。
-
clock
:获取处理器时间。
-
difftime
:计算两个时间点的时间差。
-
strftime
:格式化时间和日期为字符串。
4. 错误处理(Error Handling)
5. 系统调用(System Calls)
6. 其他辅助功能(Other Auxiliary Functions)
-
getenv
:获取环境变量的值。
-
putenv
:设置环境变量(不常见)。
-
remove
:删除文件。
-
rename
:重命名文件。
五星上将麦克阿瑟曾评论道:某度看病,癌症起步。你这伪专家,把
Semihosting
说的这么可怕,“还编译器默认植入”,我怎么还活的好好的?我怎么从来没碰到过?
-
大多数情况下使用的是Arm Compiler 5;
-
-
在Arm Compiler 6下不选MicroLib的时候遇到“调试状态下一切正常,但下载程序直接跑就会死机”的现象——因此在小本本上默默记下了只能使用MicroLib的笔记;
-
从不使用 malloc 以外的 libc 函数,甚至包括 printf
-
-
-
使用类似RT-Thread这类“提供一站式服务”的软件平台。
-
-
这里,有人替你负重前行很好理解,即
某个第三方替你在系统中“切除了嵌入式阑尾Semihosting”,
比如前面所说的:你用的是大佬提供的工程模板、你的应用是从芯片原厂的例子工程修改而来、你用了“全包服务”的RTOS平台等等。
最有意思的,其实是这里“瞎猫碰死耗子”的情况了。为了让问题看起来不那么“玄学”,让我们首先来了解一些基础知识:
-
MicroLib
是一个裁
剪版的Libc,它不仅删除了很多不常用的Libc服务,还对仅存的API进行了简化。因此,很多原本在普通Libc下会触发Semihosting的API调用,在MicroLib下要么直接“查无此人”,要么干脆返回失败(比如-1)
。
或者:
而
Arm Compiler 6
在默认情况下所使用的Libc会使用带形参的main():
int main(int argc, char *argv[]);
你可不要简单的认为这是一个“形式上的形参”,
Arm Compiler 6所使用的默认Libc
真的会认真考虑如何获实际参数值的问题——而默认情况下,
Libc
会通过
Semihosting
的方式从上位机那里去读取。
有意思的是,
MicroLib
会固定使用不带形参的
main()
,因此只要你勾选了
MicroLib
,也会侥幸绕开这个问题。
那么我聪明的朋友,你一定也明白隐藏在“狗屎运”后面的真相了吧?
实际上,对很多人来说,
阻挡在从Arm Compiler 5向Arm Compiler 6迁移必经之路上的拦路虎之一就是名为BKPT 0xAB的Hardfault:
你以为你写了
int main(void)
编译器就不给
main()
函数传参数了么?你太天真了,
Arm Compiler 6
仍然会给
main()
传递参数,只不过你的
main()
函数不去读取罢了。
在开始今天的手术教学之前,我假设大家已经准备好了一些必要的工具,比如 检测编译器种类的宏
__IS_COMPILER_ARM_COMPILER_5__
和
__IS_COMPILER_ARM_COMPILER_5__。
Arm Compiler 5 和 Arm Compiler 6 都是 Arm Compiler,区别它们二者有很多方法,但官方推荐的方法是判断宏 __ARMCC_VERSION 的值。从名字上就可以看出,这是一个自 armcc 以来一直延续到 armclang 的共有宏,它保存了编译器的版本,因此我们很容易编写出如下的宏:
//! \note for arm compiler 5
#undef __IS_COMPILER_ARM_COMPILER_5__
#if ((__ARMCC_VERSION >= 5000000) && (__ARMCC_VERSION < 6000000))
# define __IS_COMPILER_ARM_COMPILER_5__ 1
#endif
//! @}
//! \note for arm compiler 6
#undef __IS_COMPILER_ARM_COMPILER_6__
#if defined(__ARMCC_VERSION) && (__ARMCC_VERSION >= 6010050)
# define __IS_COMPILER_ARM_COMPILER_6__ 1
#endif
#undef __IS_COMPILER_ARM_COMPILER__
#if defined(__IS_COMPILER_ARM_COMPILER_5__) && __IS_COMPILER_ARM_COMPILER_5__ \
|| defined(__IS_COMPILER_ARM_COMPILER_6__) && __IS_COMPILER_ARM_COMPILER_6__
# define __IS_COMPILER_ARM_COMPILER__ 1
#endif
借助它们的帮助,我们可以通过判断 __IS_COMPILER_ARM_COMPILER_5__ 和 __IS_COMPILER_ARM_COMPILER_6__ 的值是否为“1”来确定当前的编译器版本。
如果你怕麻烦,可以通过在工程中引入
perf_counter
模块,然后包含头文件 "perf_counter.h" 来获取事先定义好的编译器检测宏。
默认情况下(使用默认的 libc),Arm Compiler 6会认为 main() 函数是带有标准的输入参数的:
int main (int argc, char *argv[]);
哪怕你强行把 main() 函数写成无需输入参数的情况,编译器也还是会准备好参数——而准备参数的过程很有可能会因为触发Semihosting在”非调试模式“下导致Hardfault。为了解决这一问题,我们可以添加下面的代码:
#if __IS_COMPILER_ARM_COMPILER_6__
__asm(".global __ARM_use_no_argv\n\t");
#endif
又因为 MicroLib 不会使用带参数的main(),因为我们可以根据(MDK所定义的一个宏)__MICROLIB,来做一个小小的区分:
#if __IS_COMPILER_ARM_COMPILER_6__
# ifndef __MICROLIB
__asm(".global __ARM_use_no_argv\n\t");
# endif
#endif
也就是当且仅当我们使用 Arm Compiler 6,且不使用MicroLib的时候,通过专门的语法结构来告诉编译器:main() 函数没有传入参数。
Arm Compiler 5和Arm Compiler 6关闭 Semihosting的方法是不同的,我们可以通过条件编译的方式加以区分后具体处理:
#if __IS_COMPILER_ARM_COMPILER_6__
__asm(".global __use_no_semihosting");
#elif __IS_COMPILER_ARM_COMPILER_5__
#pragma import(__use_no_semihosting)
#endif
一旦关闭了 Semihosting,Arm Compiler 6 就可能会报告类似如下的错误:
Error: L6915E: Library reports error: __use_no_semihosting was requested,
but _sys_exit was referenced
简单解释下原因:
Arm Compiler 6 依赖的一个函数 _sys_exit() 原本是用Semihosting方式默认提供的,现在你把 Semihosting 关闭了,就要负责到底。缺这个函数,我们提供一个空的实现就行:
#if __IS_COMPILER_ARM_COMPILER_6__
void _sys_exit(int ret)
{
(void)ret;
while(1) {}
}
#endif
类似的情况还会发生在一个叫 _ttywrch() 的函数上,我们可以如法炮制:
#if __IS_COMPILER_ARM_COMPILER__
void _ttywrch(int ch)
{
(void)ch;
}
#endif
如果你的系统中从未使用过printf,也从未将printf重定向到某个具体的串行外设上(包括但不限于UART、USB和JLink-RTT等等),那么在勾选了MicroLib的情况下可能会看到如下的错误: