最近在折腾 CH55xDuino 编译报错的事,差点被一个几行的小脚本劝退。板子是 WCH 的 CH552,二十条腿的单片机,自带USB控制器,价格又便宜,本来想拿它在 Arduino IDE 里点点灯、模拟个键盘鼠标,结果第一次编译就直接给我甩了一句:sdcc.sh: syntax error: unexpected "("。这个报错很短,信息量却很大——问题根本不在你写的 Arduino 代码,而是工具链在调用编译器时,某个脚本没有被当前环境正确解析。我干脆把整个排查过程完整记下来,遇到同样报错的人可以少走很多弯路。
1. 先把CH55xDuino工具链的结构捋清楚
排查任何编译报错,第一步都不是急着百度错误信息,而是先知道你正在用的工具链到底长什么样。CH55xDuino 这个报错之所以让不少人懵,是因为大多数人习惯了 Arduino + AVR 的组合,突然跳到 8051 内核,很多路径上的概念就不一样了。
1.1 CH55xDuino是啥?为什么编译器不是avr-gcc而是sdcc
CH55x 系列是 WCH 推出的增强型 8051 内核单片机,常见的有 CH551、CH552、CH553、CH554、CH559 这几个型号。它们的共同特点是内置 USB 控制器,CH552 这种小芯片 Flash 通常 16K,RAM 2K,主频能跑到 24MHz 左右。比起常见的 STM32、ESP32,它算很寒酸,但做 USB 小工具、模拟键盘鼠标、自定义 HID 设备,性价比很能打,关键是芯片单价足够低,坏了不心疼。
这类芯片不是 AVR 内核,也没有现成的 avr-gcc 编译器可用。CH55xDuino 这个第三方核心包做的事情,就是把 CH55x 的板级支持、工具链、上传工具全部包装好,塞进 Arduino IDE 的 Boards Manager 体系里。它用的编译器是 SDCC,也就是 Small Device C Compiler,专门为 8051 这类 8 位微控制器设计的开源 C 编译器。所以你能看到 Arduino 的编译日志里出现sdcc而不是avr-gcc,这是正常的,不是装错了工具链。
1.2 sdcc.sh在这套流程里扮演什么角色
CH55xDuino 核心包安装完成后,工具链通常会落在 Arduino15 目录下,比如 Linux 上常见路径是:
~/.arduino15/packages/CH55xDuino/tools/里面会有一堆文件夹,编译时要用的 SDCC 编译器、上传脚本、其他辅助工具都放在这里。问题里报错的sdcc.sh,就是工具链目录下的一个 shell 脚本。它的作用一般是给 SDCC 编译器做个包装,比如把相对路径转成绝对路径、设置环境变量、统一处理参数。平台配置文件platform.txt里会有类似这样的一行调用:
recipe.c.o.pattern="{compiler.wrapper}" "{source_file}" -o "{object_file}"这个compiler.wrapper指向的往往就是{runtime.tools.sdcc.path}/sdcc.sh。一旦脚本本身执行不了,整个编译流程会停在最前面,连编译真正的 C 源码那一步都走不到。
在 Windows 上装核心包时,有时候用的直接是sdcc.exe,不一定走.sh。所以如果你是 Windows 环境却看到.sh报错,说明也可能是把脚本交到了不合适的解释器手里。这个差异我在后面“Windows 专项处理”里再展开。
2. 拆解“syntax error: unexpected "("”到底在说什么
报错句子一共就几个词,但每个词背后都有讲究。搞清楚它是在哪个阶段挂掉的,比瞎改脚本重要得多。
2.1 shell解析器在什么情况下会吐出这句话
很多跟着 Arduino 玩单片机的朋友,对 shell 脚本不太熟。我打个比方:shell 脚本就像一张做菜的菜谱,解析器就是做菜的师傅。师傅拿起菜谱逐行看,看到操作步骤里有自己认不出的符号,比如“左手画圆的同时右手画方”,当场就会摔菜谱:“这玩意儿没法做”。syntax error就是在师傅还没开始做菜、只是审查菜谱阶段就发现的格式问题。
unexpected "("这个具体错误,通常和 shell 里的两种语法有关:一种是小括号作为子 shell 表达式,另一种是数组定义。比如 bash 支持这种写法:
arr=("$@")意思是用所有参数初始化一个数组。这个语法在 bash 里没问题,但如果你脚本的第一行写的是#!/bin/sh,而这个系统的/bin/sh实际指向的是 dash 而不是 bash,那 dash 看到数组定义里的(就不认识,直接报syntax error: unexpected "("。
我在排查时最先怀疑的就是这个。因为 Ubuntu、Debian 这类系统默认/bin/sh是指向 dash 的,而很多第三方脚本作者习惯用 bash 语法写脚本,却把 shebang 写成了/bin/sh,一碰就炸。
2.2 结合编译场景,最可能的“炸点”在哪里
CH55xDuino 工具链里的 sdcc.sh 内容我打开看过,简化后大致是这样:
#!/bin/sh SDCC_BIN="$(dirname "$0")/sdcc" SDCC_ARGS=("$@") exec "$SDCC_BIN" "${SDCC_ARGS[@]}"问题就在第二行附近。SDCC_ARGS=("$@")是数组定义,dash 解析到这里会直接报错。即使你手动用 bash 执行没问题,Arduino 的编译流程如果调用的解释器是/bin/sh,照样会挂。
还有一种情况,尤其容易出现在 Windows 用户身上:编译临时目录或者工具链路径里带着空格和括号,比如:
C:\Program Files (x86)\Arduino\libraries\...这时候如果脚本内部有类似DIR=$(dirname $0)但变量没加引号,路径里的括号会被 shell 当成语法符号来处理,也会导致unexpected "("或者别的一堆诡异报错。这个原因不是脚本语法本身有问题,而是调用环境把脚本内部的变量展开搞坏了。
2.3 从编译日志里还能挖出哪些隐藏信息
Arduino IDE 默认只显示精简错误,容易把真正有用的信息吞掉。修这类问题之前,先把详细日志打开:菜单路径是 File -> Preferences -> Show verbose output during compilation,勾上编译时详细输出。
勾选之后再编译,就能看到类似这样的完整调用链:
/home/user/.arduino15/packages/CH55xDuino/tools/CH55xDuino/1.0.0/sdcc.sh: 12: Syntax error: "(" unexpected exit status 1注意看这行日志里的几个部分:前面的路径是不是完整路径、路径里有没有空格或括号、数字 12 表示脚本第 12 行出错、最后退出码是 1。这些信息组合起来,能帮你判断是“脚本本身语法不兼容”还是“路径带着特殊字符导致敲坏”。
我最初就是因为只盯着 Arduino 警告区那几行红字看,完全没注意到实际调用路径,结果绕了好大一个圈子。
3. 一步步把这个报错修掉
明确问题出在脚本解析阶段之后,剩下的就是动手修。我建议按下面这几步走,每一步做完都可以重新编译试一下,能完全挡住问题的就停,不要一步到位把所有都改了,免得改坏了自己都分不清是哪一步起作用。
3.1 第一步:确认sdcc.sh所在的完整路径和环境默认shell
先在终端里找到这个脚本到底在哪。不同操作系统的 Arduino15 路径不太一样,但搜索方法一样:
find ~ -name "sdcc.sh" 2>/dev/null如果 user 目录下没搜到,再查一下 Arduino IDE 的实际数据目录,Windows 上一般是%LOCALAPPDATA%\Arduino15,macOS 是~/Library/Arduino15。
找到脚本之后,再确认系统默认 sh 是什么:
ls -l /bin/sh file /bin/sh在 Ubuntu 上,输出通常能看到/bin/sh -> dash。这一下基本就能确认:如果你的脚本是 bash 写法,那这里就是第一个雷点。
3.2 第二步:用两种解释器分别做语法体检
shell 自带语法检查参数-n,只检查不执行,比直接运行安全得多。分别用 bash 和 dash 试一次:
bash -n /完整路径/sdcc.sh dash -n /完整路径/sdcc.sh如果 bash 什么都不输出,dash 却报:
dash: 1: /home/user/.../sdcc.sh: Syntax error: "(" unexpected那就可以斩钉截铁地说:脚本是 bash 语法,但被 dash 解析了。如果两个都报错,那要更仔细看脚本内容里有没有字面上的括号问题,尤其路径中带括号的情况。
这个“双解释器体检法”是排查 shell 脚本语法问题最干净的手段,比盯着报错日志瞎猜高效得多。因为syntax error是在解析阶段就发生的,bash -n和dash -n的结果能快速缩小范围。
3.3 第三步:修改脚本内容,按POSIX规范重写
定位到问题是数组语法后,修法有好几种,我不建议一上来就大改,分三个档位,按你的情况选。
档位一:把脚本 shebang 改成 bash。如果你确定系统里有 bash,可以直接:
sed -i '1s|^#!/bin/sh|#!/usr/bin/env bash|' /完整路径/sdcc.sh这个改法最快,但要注意一点:如果 platform.txt 里显式把脚本当作参数传给sh执行,那改 shebang 也没用,照样走 dash。所以还得确认它到底是怎么被调用的。
档位二:把数组语法改成 POSIX 兼容写法。既然脚本只是想把参数原样传给 sdcc,完全没必要用数组。可以重写成这样:
#!/bin/sh SDCC_BIN="$(dirname "$0")/sdcc" exec "$SDCC_BIN" "$@""$@"本身就是 POSIX 标准支持的参数传递方式,直接在 exec 里展开就行。这个版本在 bash、dash、其他 POSIX shell 下都能跑,路径带空格也稳。我把这段写进去之后,dash -n 检查直接通过。
档位三:如果脚本里还有其他依赖数组的复杂逻辑,没法简单重写,那就在不改逻辑的前提下,改用set --或者循环遍历参数。比如:
#!/bin/sh SDCC_BIN="$(dirname "$0")/sdcc" for arg in "$@"; do set -- "$@" "$arg" shift done exec "$SDCC_BIN" "$@"其实这种绕法不如直接重写来得干净。我的建议是,既然它只是个 wrapper,能用最简单的方式就别保持复杂。
3.4 第四步:Windows环境下的专项处理
如果你用的是 Windows,但报错信息里仍然出现sdcc.sh,大概率是核心包内部保留了.sh脚本,却在 Windows 上被以错误方式调用。这个时候有几种处理路径。
第一,安装 Git for Windows 或者 MSYS2,让系统里有一个完整的 bash 环境。安装 Git for Windows 时记得把C:\Program Files\Git\bin加进 PATH,这样bash.exe能被找到。
第二,打开 CH55xDuino 核心包的 platform.txt,搜索sdcc.sh出现的行,把调用方式改成显式用 bash 执行:
recipe.c.o.pattern=bash "{compiler.path}/sdcc.sh" "{source_file}" -o "{object_file}"加一个bash前缀,就能让脚本强制走 bash,而不是被 cmd 或者其他奇怪的解释器碰运气。
第三,如果你的核心包在 Windows 上有对应的.exe版本,干脆把调用改成直接指向 exe:
recipe.c.o.pattern="{compiler.path}/sdcc" "{source_file}" -o "{object_file}"这样就不存在.sh脚本问题了。不过要注意改成直接调用后,参数风格要和脚本原逻辑保持一致,别把 wrapper 里做的路径处理给丢了。
Windows 下还有一个常见的坑:路径里有空格和括号时,platform.txt 里的{compiler.path}一定要用引号包起来,否则传给脚本的路径会被截断成多个参数。Arduino 的 recipe 和 makefile 在这一类细节上尤其敏感。
3.5 第五步:打开verbose日志验证并固化修改
改完之后,重新编译。这次要从详细输出里确认两件事:一是日志里不再出现syntax error,二是能清楚看到 sdcc 编译器真的被调起来了,比如出现了-o参数、目标文件路径等。
如果你刚才修改的是核心包内的文件,那要有个心理准备:以后在 Boards Manager 里点升级这个核心包,修改会被覆盖。所以第五步真正要做的是把自己的修改记录下来,存到独立文件里。我是直接把改动后的 sdcc.sh 单独备份到项目目录,同时在笔记里写清楚改了什么、为什么要改。下次升级完如果又炸,照着笔记重来一遍就行。
验证通过的标志很简单:同样的 .ino 文件,这次编译能一路走到生成.hex文件,报错消失。
4. 实战中容易踩的同类坑
修完主问题之后,我还顺手记录了一些和这个报错同宗同源的扩展情况。它们不一定每次都出现在 CH55xDuino 上,但有很强的共性。
4.1 不只是sdcc.sh,其他工具脚本也可能炸
CH55xDuino 核心包里不止 sdcc.sh 一个脚本,上传阶段可能还会用到 Python 脚本或者另一个.sh。如果 sdcc.sh 修好了,编译却在后续环节又报类似错误,别慌,多半是同一个逻辑:某个脚本用了当前 shell 不认得的语法,或者换行符不对。
Windows 用户在 Git 仓库同步文件时,Git 默认会做换行符转换,把 LF 改成 CRLF。shell 脚本如果变成 CRLF 结尾,执行时会在每行行尾带一个\r,sh 经常报出莫名其妙的 syntax error。处理办法是:
dos2unix sdcc.sh或者在 Git 配置里关掉 autocrlf 转换。这个坑很隐蔽,我在另一个板卡包上遇到过,编译报错不是unexpected "("而是unexpected end of file,排查到最后才发现是换行符问题。
4.2 SDCC版本与CH55xDuino包版本错配
sdcc.sh 能跑通之后,还有一类问题需要提前预防:SDCC 版本和核心包版本不匹配。CH55xDuino 官方在 package 索引里通常会指定某个 SDCC 版本范围,如果你自己手动下载了新版 SDCC 放到工具目录,或者改动了 PATH,编译时可能不是语法错误,而是芯片头文件不兼容、寄存器定义对不上、生成的 hex 烧进去跑不起来。
我的建议是不要手动替换工具链里的 sdcc。Arduino15 里 boards manager 自动下载的版本虽然可能不是你机器上最新的,但那是最匹配核心包配置的版本。如果你因为网络原因必须手动下载,也要对照 package_CH55xDuino_index.json 里的 URL 和版本号,尽量保持一致。
这里可以直接给个速查性质的表格,方便遇到不同症状时快速定位:
| 症状 | 可能原因 | 快速解法 |
|---|---|---|
| sdcc.sh 报 syntax error | 默认 shell 是 dash 或脚本带 bash 语法 | 改脚本为 POSIX 或强制用 bash |
| 路径里带括号/空格导致怪错 | wrapper 内变量没加引号 | 给路径变量加双引号 |
| 编译跳过 sdcc 直接失败 | PATH 里找不到编译器等 | 检查工具目录和 PATH |
| Windows 下 .sh 报错 | 脚本被 cmd 或非 bash 执行 | 显式bash 脚本名调用 |
| 烧录后单片机不跑 | SDCC 版本不匹配 | 恢复 boards manager 默认版本 |
4.3 一套通用排查开源工具链报错的思路
这类第三方 Arduino 核心包更新很快,作者水平也参差不齐,今天修好 sdcc.sh,明天可能又有别的脚本出问题。所以最后我整理了一个通用的排查思路,遇到类似报错可以套用。
第一步,从报错信息里定位出是哪个文件,看后缀。后缀是.sh的,先问它会被哪个 shell 执行。第二步,在 platform.txt 或 boards.txt 里搜这个文件名,看具体调用方式。第三步,用bash -n和dash -n分别做语法检查,确认是解释器不兼容还是路径被拆。第四步,能改脚本就改脚本,能绕开脚本就绕开脚本,优先选择改动面最小的方案。第五步,所有修改要备份,因为核心包升级会覆盖你的改动。
这套思路我后来用在 ESP32 的开发板包上同样有效。很多第三方工具链问题,根本不是核心代码有 bug,而是脚本运行环境和作者的开发环境不一致,我们做的就是把这个环境差补上。
5. 修好之后:怎么防止下次再被同样的坑绊倒
报错短时间修好只是第一步,长期不被同一个问题绊倒,需要做一点简单的管理。
5.1 备份核心包和脚本修改记录
我现在的习惯是,任何对核心包下手后,立即把整个工具目录压缩一份,文件名带上日期。比如CH55xDuino_backup_20250101.zip。这样哪怕升级覆盖、手滑删错文件,都能回滚。同时把修改点记录到一个叫FIXME.md的文件里,不写废话,就写改了什么路径、什么原因、用了什么命令。下次系统换电脑,或者团队其他人遇到同样问题,直接照着执行就行。
这个习惯一开始可能会觉得多余,但相信我,很多“编译环境炸弹”第二次引爆时才最要命。因为你会忘记上次是怎么拆弹的。
5.2 控制好SDCC与核心版本的组合
另外,建议在项目仓库里锁定 CH55xDuino 核心包的版本。Arduino Boards Manager 里虽然显示最新版本,但你在一个长期项目里没必要追新。第三方核心包更新频繁,很可能某次升级引入新问题,而你的项目代码没变,却被环境搞坏了。锁定版本之后,团队里其他人安装时也用相同版本,问题复现和排查都会容易很多。
我现在碰到第三方核心包报错,第一反应已经不再是“哪里配置错了”,而是“这个版本我有没有锁过”。工具链的稳定性,很大程度上来自版本控制,而不是祈祷“最新版更好用”。
6. 最后分享一点个人实战体会
折腾完这次 CH55xDuino 编译报错,我最大的收获不是那几行脚本怎么改,而是发现这类问题本质是环境差异问题。写脚本的人用的是 macOS 或者 Windows + bash,我这边是 Ubuntu 的 dash,两边语法体系不完全一致,编译环境一交叉,炸得莫名其妙。但你只要掌握“先确定解释器,再做语法体检,最后最小改动”这套流程,就能快速稳住。
如果你现在也卡在同一个报错上,建议直接照着我前面 3.1 到 3.5 的步骤走一遍,大概率能解决。动手前记得备份,改完以后用详细日志确认。有任何和这个报错相关的变种情况,欢迎在评论区把你的日志贴出来一起研究。