【说在前面的话】
从事嵌入式开发这么久,你一定听说过 Semihosting 吧?
什么?你没听说过?那你可能在不知不觉中已经踩坑了。
如果你只是对 Semihosting 偶有耳闻,那么你与楼上那位多半也是难兄难弟了。
想要深入了解 Semihosting,我们还得从它的名字说起。
【什么是Semihosting】
虽然说很多情况下谜底往往会直接贴脸写在谜面上,但 Semihosting 对非英文母语的国人来说,其实跟两眼一抹黑没有区别,更别提它还拥有一个极具嘲讽意味的中文翻译:“半主机”。
之所以说这种翻译极具嘲讽意味,是因为它就是大家口中那种“每个字我都认识,放到一起完全不知所云”的典型。这里“semi-”对应“半”;“hosting”对应主机。
然而,但凡学过初中英语,你也能理解这里翻译的巨大瑕疵吧?“hosting”看起来是一个动名词,那么这里的“host”就是一个动词,所以这里 host 绝对不能简单粗暴的翻译成“主机”,而应该能够体现它是一个动作,比如“做主机(hosting)”。
那么 semi-hosting 至少应该是 “半”“做主机”。那么问题来了:什么情况下才会“做主机做一半(semihosting)”呢?
做主机了,但又没有完全做??……
我懂了:“如做(semi-hosting)”
这里其实并不算是一个玩笑,相对“半主机”这个似是而非的翻译,“如做(主机)” 其实更贴近 Semihosting 的本质。要想理解问题的本质,还要从 hosting 这个动词的意涵来入手,这里:
- 抛开翻译的美学不谈,hosting 的意思其实更贴这个词汇在生活中的原意——做东。也就是我们中国人请客时候说的“我做东”的做东,即:作为活动的主办方为客人提供服务。
- Semi- 意为“做东做一半”——也就是大家说的“如做”。
那么如何理解这里的“一半”呢?要回答这个问题,就要搞清楚这里的所谓的“做东”主要是为谁提供怎样的服务,这里:
- 客人(被服务的对象)是运行在MCU上的程序;
- 服务主要是 Libc 所许诺提供的服务;
与大家一般意义上的理解不同:
- 这里的主人并非是PC,而是运行在PC上、提供调试服务的程序,比如GCC命令行下的GDB和MDK下的Debugging模式等等。
在搞清楚了上述几个关键点后,Semihosting 的完整意涵就非常清楚了——由“PC上运行的调试程序”为“MCU上运行的应用程序”提供“Libc服务”的这个动作,叫做“Semi-hosting”。
这里,“hosting”准确翻译应该是“做东提供服务”,“semi-” 则体现了一个合作上的细节,即:
- 整个“为客服务”的过程一半由运行在MCU上的runtime库(libc runtime)提供接口,
- 经过调试仿真器(Debugger Adapter)的通讯后,
- 由运行在PC上的调试服务程序实际提供。
知晓本意之后,我们回头再来看 Semihosting 的“通常”翻译——“半主机”——是不是更加体会到这种按字硬翻的“似是而非”和“败事有余”了?
是不是更加理解为什么“如做”才是更加贴切的翻译了?(然而并不是,哈哈哈……)
【Semihosting是如何成为嵌入式“阑尾”的】
正如前面所说,Semihosting是一种由PC上运行的调试程序(Debugger),经由调试仿真器(Debugger Adapter)与MCU上的运行时库(runtime)进行通信,提供Libc基础服务的方式。
在简中世界中,我们常常把JLink、DapLink、ST-Link或者CMSIS-DAP这样的“调试仿真器”理解为“Debugger”,这其实是错误的——“Debugger”对应的是GDB或者MDK调试模式这样的上位机程序;而“Debugger Adapter(调试适配器)”才是我们口中的调试仿真器。
如下图所示——一个完整的Semihosting链路需要至少三个部分组成:
- 支持Semihosting的上位机程序
- 调试仿真器(Debugger Adapter)
- 支持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指令来与上位机交互。比如,对于下面的代码:
代码语言:javascript复制#include <time.h>
...
clock_t tClock = time();
...
我们会观察到如下的现象:
- MDK在全速运行的情况下,莫名其妙的暂停;如果目标代码出现在循环体中,甚至在我们按下F5以后仍然会暂停;
- 打开汇编调试界面,会发现PC指针停在一个 BKPT 0xAB 指令上:
这里的指令:
代码语言:javascript复制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)
- printf系列函数:例如
printf
、fprintf
、sprintf
等,用于格式化输出到标准输出设备(通常是主机的控制台)。 - scanf系列函数:例如
scanf
、fscanf
、sscanf
等,用于格式化输入从标准输入设备(通常是主机的键盘输入)。
2. 文件操作(File Operations)
- fopen:打开文件。
- fclose:关闭文件。
- fread:从文件读取数据。
- fwrite:向文件写入数据。
- fseek:移动文件指针到指定位置。
- ftell:获取文件指针当前位置。
- fflush:刷新文件输出缓冲区。
3. 时间和日期(Time and Date)
- time:获取当前时间。
- clock:获取处理器时间。
- difftime:计算两个时间点的时间差。
- strftime:格式化时间和日期为字符串。
4. 错误处理(Error Handling)
- perror:输出错误信息到标准错误设备。
- strerror:返回与错误码对应的错误信息字符串。
5. 系统调用(System Calls)
- exit:终止程序并返回状态码。
- system:执行系统命令(在嵌入式系统中很少使用,但在主机上调试时可能有用)。
6. 其他辅助功能(Other Auxiliary Functions)
- getenv:获取环境变量的值。
- putenv:设置环境变量(不常见)。
- remove:删除文件。
- rename:重命名文件。
【“嵌入式阑尾炎”的潜伏与诱因】
五星上将麦克阿瑟曾评论道:某度看病,癌症起步。你这伪专家,把Semihosting说的这么可怕,“还编译器默认植入”,我怎么还活的好好的?我怎么从来没碰到过?
恕我直言,你可能符合以下特征:
- 大多数情况下使用的是Arm Compiler 5;
- 大多数情况下会默认使用 MicroLib;
- 在Arm Compiler 6下不选MicroLib的时候遇到“调试状态下一切正常,但下载程序直接跑就会死机”的现象——因此在小本本上默默记下了只能使用MicroLib的笔记;
- 从不使用 malloc 以外的 libc 函数,甚至包括 printf
- 用的程序模板是大佬做好的;
- 应用开发基于芯片厂商给的例子工程
- 使用类似RT-Thread这类“提供一站式服务”的软件平台。
别看我列举了很多,其实只分两种情况:
- 瞎猫碰死耗子——运气好
- 有人替你负重前行
这里,有人替你负重前行很好理解,即某个第三方替你在系统中“切除了嵌入式阑尾Semihosting”,比如前面所说的:你用的是大佬提供的工程模板、你的应用是从芯片原厂的例子工程修改而来、你用了“全包服务”的RTOS平台等等。
最有意思的,其实是这里“瞎猫碰死耗子”的情况了。为了让问题看起来不那么“玄学”,让我们首先来了解一些基础知识:
- MicroLib是一个裁剪版的Libc,它不仅删除了很多不常用的Libc服务,还对仅存的API进行了简化。因此,很多原本在普通Libc下会触发Semihosting的API调用,在MicroLib下要么直接“查无此人”,要么干脆返回失败(比如-1)。
- 在Arm Compiler 5下,main() 函数默认就是不带形参的,例如:
int main(void);
或者
代码语言:javascript复制void main(void);
而 Arm Compiler 6 在默认情况下所使用的Libc会使用带形参的main():
代码语言:javascript复制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 的共有宏,它保存了编译器的版本,因此我们很容易编写出如下的宏:
代码语言:javascript复制//! 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" 来获取事先定义好的编译器检测宏。
- 如何在 Arm Compiler 6 下告知编译器 main() 函数不带输入参数
默认情况下(使用默认的 libc),Arm Compiler 6会认为 main() 函数是带有标准的输入参数的:
代码语言:javascript复制int main (int argc, char *argv[]);
哪怕你强行把 main() 函数写成无需输入参数的情况,编译器也还是会准备好参数——而准备参数的过程很有可能会因为触发Semihosting在”非调试模式“下导致Hardfault。为了解决这一问题,我们可以添加下面的代码:
代码语言:javascript复制#if __IS_COMPILER_ARM_COMPILER_6__
__asm(".global __ARM_use_no_argvnt");
#endif
又因为 MicroLib 不会使用带参数的main(),因为我们可以根据(MDK所定义的一个宏)__MICROLIB,来做一个小小的区分:
代码语言:javascript复制#if __IS_COMPILER_ARM_COMPILER_6__
# ifndef __MICROLIB
__asm(".global __ARM_use_no_argvnt");
# endif
#endif
也就是当且仅当我们使用 Arm Compiler 6,且不使用MicroLib的时候,通过专门的语法结构来告诉编译器:main() 函数没有传入参数。
- 如何关闭 Semihosting
Arm Compiler 5和Arm Compiler 6关闭 Semihosting的方法是不同的,我们可以通过条件编译的方式加以区分后具体处理:
代码语言:javascript复制#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 就可能会报告类似如下的错误:
代码语言:javascript复制Error: L6915E: Library reports error: __use_no_semihosting was requested,
but _sys_exit was referenced
简单解释下原因:Arm Compiler 6 依赖的一个函数 _sys_exit() 原本是用Semihosting方式默认提供的,现在你把 Semihosting 关闭了,就要负责到底。缺这个函数,我们提供一个空的实现就行:
代码语言:javascript复制#if __IS_COMPILER_ARM_COMPILER_6__
void _sys_exit(int ret)
{
(void)ret;
while(1) {}
}
#endif
类似的情况还会发生在一个叫 _ttywrch() 的函数上,我们可以如法炮制:
代码语言:javascript复制
/* 为 arm compiler 5 和 arm compiler 6 都添加这个空函数 */
#if __IS_COMPILER_ARM_COMPILER__
void _ttywrch(int ch)
{
(void)ch;
}
#endif
如果你的系统中从未使用过printf,也从未将printf重定向到某个具体的串行外设上(包括但不限于UART、USB和JLink-RTT等等),那么在勾选了MicroLib的情况下可能会看到如下的错误:
代码语言:javascript复制Error: L6915E: Library reports error: __use_no_semihosting was requested,
but a semihosting fputc was linked in
如果没有勾选MicroLib,提示的函数是不同的:
代码语言:javascript复制Error: L6915E: Library reports error: __use_no_semihosting was requested,
but _sys_open was referenced
此时,解决问题的方法取决于你的工程中是否会使用printf。假设你确定自己不会使用到 printf,则可以通过添加如下的代码来让编译器(确切说是linker)满意:
代码语言:javascript复制#if __IS_COMPILER_ARM_COMPILER_6__
# ifdef __MICROLIB
#include <stdio.h>
int fputc(int ch, FILE *f)
{
(void) f;
(void) ch;
return ch;
}
# else
#include <rt_sys.h>
FILEHANDLE $Sub$$_sys_open(const char *name, int openmode)
{
(void) name;
(void) openmode;
return 0;
}
# endif
#endif
如果确实需要使用printf,则可以:
- 打开MDK的RTE窗口,找到CMSIS Compiler 后展开
- 勾选 CORE
- 展开 STDOUT (API)后勾选自己心仪的重定位模式。比如,假设你想把printf重定位到某个具体的串行外设上(包括但不限于 UART、USB或者JLink-RTT),则推荐勾选Custom。
4、假设我们勾选了Custom,则需要在我们的某个C源代码中添加如下的函数:
代码语言:javascript复制int stdout_putchar(int ch)
{
/* 这里添加代码将 ch 发送到外设上 */
return ch;
}
这里,ch保存的是要发送的字节。我们可以通过这个函数将 printf 重定位到指定的外设上。注意:如果发送失败,则应该返回 -1。
- 如何解决使用 assert.h 引发的问题
很多代码都有使用 assert() 来截获错误的习惯,当我们使用 Arm Compiler 6 且开启 MicroLib的时候,由于 MicroLib并不提供对 assert() 底层函数的具体实现,当我们没有定义 NDEBUG 来关闭 assert() 时,会在链接阶段看到如下的编译错误:
代码语言:javascript复制Error: L6218E: Undefined symbol __aeabi_assert
知道原因后,解决也很简单:既然MicroLib没提供实现,我们就自己提供一个好了:
代码语言:javascript复制#if __IS_COMPILER_ARM_COMPILER_6__ && defined(__MICROLIB)
void __aeabi_assert(const char *chCond, const char *chLine, int wErrCode)
{
(void)chCond;
(void)chLine;
(void)wErrCode;
while(1) {
__NOP();
}
}
#endif
【说在后面的话】
在嵌入式系统的发展早期,开发和调试嵌入式程序是一项非常具有挑战性的任务。嵌入式系统通常没有键盘、显示器或文件系统,调试手段非常有限。开发者主要依赖于串口输出、LED指示和JTAG调试器来排查问题。这些方法虽然有效,但缺乏便利性和效率。
为了解决这一问题,大约在20世纪90年代初期,随着嵌入式系统复杂性的增加,Arm公司率先在其调试工具中引入了Semihosting的概念从而大大简化了嵌入式系统的调试和开发。
从Arm官方提供的资料来看,目前支持Semihosting的开发工具有Arm-DS和一部分FastModel。前者主要面向Cortex-A和Cortex-R这样的高端芯片开发场景,而广泛应用于Cortex-M开发的集成开发环境MDK却因为种种原因未能在MDK5中实现对Semihosting的支持——这也是Semihosting这一好用的工具在Cortex-M开发流程中沦落为“阑尾”的直接原因。多少让人有些惋惜。
说了这么多,回到最初的原点——我们在否定了“半主机”这个似是而非的翻译后,似乎并没有给Semihosting一个恰当的翻译。
我本人的意见是:要么翻译成“合作式主机服务”,或者如GPT所建议的那样翻译成“半主机服务”,要么干脆不翻译,就直接在文本中保留 Semihosting 的原貌。
你觉得呢?