diff --git a/.github/codecov.yml b/.github/codecov.yml index a1559ecf..778c88a4 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -10,4 +10,6 @@ coverage: - "internal/typepatch" - "internal/github" - "internal/firmware" + - "internal/flash" + - "internal/monitor" - "xtool" diff --git a/.gitignore b/.gitignore index 7c0b0d92..c388e11d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,11 @@ go.work* # GoReleaser .dist/ .sysroot/ + +# Embedded firmware files +*.bin +*.hex +*.elf +*.uf2 +*.img +*.zip diff --git a/cmd/internal/build/build.go b/cmd/internal/build/build.go index af53ab62..98d4748c 100644 --- a/cmd/internal/build/build.go +++ b/cmd/internal/build/build.go @@ -36,7 +36,11 @@ var Cmd = &base.Command{ func init() { Cmd.Run = runCmd base.PassBuildFlags(Cmd) + + flags.AddCommonFlags(&Cmd.Flag) flags.AddBuildFlags(&Cmd.Flag) + flags.AddEmulatorFlags(&Cmd.Flag) + flags.AddEmbeddedFlags(&Cmd.Flag) flags.AddOutputFlags(&Cmd.Flag) } diff --git a/cmd/internal/clean/clean.go b/cmd/internal/clean/clean.go index 907ec849..096ca421 100644 --- a/cmd/internal/clean/clean.go +++ b/cmd/internal/clean/clean.go @@ -31,6 +31,7 @@ var Cmd = &base.Command{ func init() { Cmd.Run = runCmd + flags.AddCommonFlags(&Cmd.Flag) flags.AddBuildFlags(&Cmd.Flag) } diff --git a/cmd/internal/flags/flags.go b/cmd/internal/flags/flags.go index 830bcebb..0b27cf93 100644 --- a/cmd/internal/flags/flags.go +++ b/cmd/internal/flags/flags.go @@ -8,26 +8,41 @@ import ( ) var OutputFile string +var OutBin bool +var OutHex bool +var OutImg bool +var OutUf2 bool +var OutZip bool func AddOutputFlags(fs *flag.FlagSet) { fs.StringVar(&OutputFile, "o", "", "Output file") + fs.BoolVar(&OutBin, "obin", false, "Generate binary output (.bin)") + fs.BoolVar(&OutHex, "ohex", false, "Generate Intel hex output (.hex)") + fs.BoolVar(&OutImg, "oimg", false, "Generate image output (.img)") + fs.BoolVar(&OutUf2, "ouf2", false, "Generate UF2 output (.uf2)") + fs.BoolVar(&OutZip, "ozip", false, "Generate ZIP/DFU output (.zip)") } var Verbose bool var BuildEnv string var Tags string var Target string +var Emulator bool +var Port string +var BaudRate int var AbiMode int var CheckLinkArgs bool var CheckLLFiles bool var GenLLFiles bool var ForceEspClang bool +func AddCommonFlags(fs *flag.FlagSet) { + fs.BoolVar(&Verbose, "v", false, "Verbose output") +} + func AddBuildFlags(fs *flag.FlagSet) { - fs.BoolVar(&Verbose, "v", false, "Verbose mode") fs.StringVar(&Tags, "tags", "", "Build tags") fs.StringVar(&BuildEnv, "buildenv", "", "Build environment") - fs.StringVar(&Target, "target", "", "Target platform (e.g., rp2040, wasi)") if buildenv.Dev { fs.IntVar(&AbiMode, "abi", 2, "ABI mode (default 2). 0 = none, 1 = cfunc, 2 = allfunc.") fs.BoolVar(&CheckLinkArgs, "check-linkargs", false, "check link args valid") @@ -39,6 +54,16 @@ func AddBuildFlags(fs *flag.FlagSet) { var Gen bool +func AddEmulatorFlags(fs *flag.FlagSet) { + fs.BoolVar(&Emulator, "emulator", false, "Run in emulator mode") +} + +func AddEmbeddedFlags(fs *flag.FlagSet) { + fs.StringVar(&Target, "target", "", "Target platform (e.g., rp2040, wasi)") + fs.StringVar(&Port, "port", "", "Target port for flashing") + fs.IntVar(&BaudRate, "baudrate", 115200, "Baudrate for serial communication") +} + func AddCmpTestFlags(fs *flag.FlagSet) { fs.BoolVar(&Gen, "gen", false, "Generate llgo.expect file") } @@ -47,10 +72,24 @@ func UpdateConfig(conf *build.Config) { conf.Tags = Tags conf.Verbose = Verbose conf.Target = Target + conf.Port = Port + conf.BaudRate = BaudRate switch conf.Mode { case build.ModeBuild: conf.OutFile = OutputFile + conf.OutFmts = build.OutFmts{ + Bin: OutBin, + Hex: OutHex, + Img: OutImg, + Uf2: OutUf2, + Zip: OutZip, + } + case build.ModeRun, build.ModeTest: + conf.Emulator = Emulator + case build.ModeInstall: + case build.ModeCmpTest: + conf.Emulator = Emulator conf.GenExpect = Gen } if buildenv.Dev { diff --git a/cmd/internal/install/install.go b/cmd/internal/install/install.go index 7c50c509..037c4c68 100644 --- a/cmd/internal/install/install.go +++ b/cmd/internal/install/install.go @@ -35,7 +35,9 @@ var Cmd = &base.Command{ func init() { Cmd.Run = runCmd + flags.AddCommonFlags(&Cmd.Flag) flags.AddBuildFlags(&Cmd.Flag) + flags.AddEmbeddedFlags(&Cmd.Flag) } func runCmd(cmd *base.Command, args []string) { diff --git a/cmd/internal/monitor/monitor.go b/cmd/internal/monitor/monitor.go new file mode 100644 index 00000000..6c5bd5af --- /dev/null +++ b/cmd/internal/monitor/monitor.go @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package monitor + +import ( + "fmt" + "os" + + "github.com/goplus/llgo/cmd/internal/base" + "github.com/goplus/llgo/cmd/internal/flags" + "github.com/goplus/llgo/internal/crosscompile" + "github.com/goplus/llgo/internal/monitor" +) + +// Cmd represents the monitor command. +var Cmd = &base.Command{ + UsageLine: "llgo monitor [flags] [executable]", + Short: "Monitor serial output from device", +} + +func init() { + flags.AddCommonFlags(&Cmd.Flag) + flags.AddEmbeddedFlags(&Cmd.Flag) + Cmd.Run = runMonitor +} + +func runMonitor(cmd *base.Command, args []string) { + cmd.Flag.Parse(args) + args = cmd.Flag.Args() + + if len(args) > 1 { + fmt.Fprintf(os.Stderr, "llgo monitor: too many arguments\n") + os.Exit(1) + } + + var executable string + if len(args) == 1 { + executable = args[0] + } + + var serialPort []string + if flags.Target != "" { + conf, err := crosscompile.UseTarget(flags.Target) + if err != nil { + fmt.Fprintf(os.Stderr, "llgo monitor: %v\n", err) + os.Exit(1) + } + serialPort = conf.Device.SerialPort + } + + config := monitor.MonitorConfig{ + Port: flags.Port, + Target: flags.Target, + BaudRate: flags.BaudRate, + Executable: executable, + SerialPort: serialPort, + } + + if err := monitor.Monitor(config, flags.Verbose); err != nil { + fmt.Fprintf(os.Stderr, "llgo monitor: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index b6380ab9..68b8ce83 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -48,8 +48,16 @@ func init() { Cmd.Run = runCmd CmpTestCmd.Run = runCmpTest base.PassBuildFlags(Cmd) + flags.AddCommonFlags(&Cmd.Flag) flags.AddBuildFlags(&Cmd.Flag) + flags.AddEmulatorFlags(&Cmd.Flag) + flags.AddEmbeddedFlags(&Cmd.Flag) // for -target support + + base.PassBuildFlags(CmpTestCmd) + flags.AddCommonFlags(&CmpTestCmd.Flag) flags.AddBuildFlags(&CmpTestCmd.Flag) + flags.AddEmulatorFlags(&CmpTestCmd.Flag) + flags.AddEmbeddedFlags(&CmpTestCmd.Flag) // for -target support flags.AddCmpTestFlags(&CmpTestCmd.Flag) } diff --git a/cmd/internal/test/test.go b/cmd/internal/test/test.go index b05f1e69..0a8a5a05 100644 --- a/cmd/internal/test/test.go +++ b/cmd/internal/test/test.go @@ -17,7 +17,10 @@ var Cmd = &base.Command{ func init() { Cmd.Run = runCmd + flags.AddCommonFlags(&Cmd.Flag) flags.AddBuildFlags(&Cmd.Flag) + flags.AddEmulatorFlags(&Cmd.Flag) + flags.AddEmbeddedFlags(&Cmd.Flag) } func runCmd(cmd *base.Command, args []string) { diff --git a/cmd/llgo/monitor_cmd.gox b/cmd/llgo/monitor_cmd.gox new file mode 100644 index 00000000..f87a4e85 --- /dev/null +++ b/cmd/llgo/monitor_cmd.gox @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ( + self "github.com/goplus/llgo/cmd/internal/monitor" +) + +use "monitor [flags] [executable]" + +short "Monitor serial output from device" + +flagOff + +run args => { + self.Cmd.Run self.Cmd, args +} \ No newline at end of file diff --git a/cmd/llgo/xgo_autogen.go b/cmd/llgo/xgo_autogen.go index 3833800f..c42e19b6 100644 --- a/cmd/llgo/xgo_autogen.go +++ b/cmd/llgo/xgo_autogen.go @@ -8,6 +8,7 @@ import ( "github.com/goplus/llgo/cmd/internal/build" "github.com/goplus/llgo/cmd/internal/clean" "github.com/goplus/llgo/cmd/internal/install" + "github.com/goplus/llgo/cmd/internal/monitor" "github.com/goplus/llgo/cmd/internal/run" "github.com/goplus/llgo/cmd/internal/test" "github.com/goplus/llgo/internal/env" @@ -40,6 +41,10 @@ type Cmd_install struct { type App struct { xcmd.App } +type Cmd_monitor struct { + xcmd.Command + *App +} type Cmd_run struct { xcmd.Command *App @@ -52,6 +57,7 @@ type Cmd_version struct { xcmd.Command *App } + //line cmd/llgo/main_app.gox:1 func (this *App) MainEntry() { //line cmd/llgo/main_app.gox:1:1 @@ -63,11 +69,13 @@ func (this *App) Main() { _xgo_obj2 := &Cmd_cmptest{App: this} _xgo_obj3 := &Cmd_get{App: this} _xgo_obj4 := &Cmd_install{App: this} - _xgo_obj5 := &Cmd_run{App: this} - _xgo_obj6 := &Cmd_test{App: this} - _xgo_obj7 := &Cmd_version{App: this} - xcmd.Gopt_App_Main(this, _xgo_obj0, _xgo_obj1, _xgo_obj2, _xgo_obj3, _xgo_obj4, _xgo_obj5, _xgo_obj6, _xgo_obj7) + _xgo_obj5 := &Cmd_monitor{App: this} + _xgo_obj6 := &Cmd_run{App: this} + _xgo_obj7 := &Cmd_test{App: this} + _xgo_obj8 := &Cmd_version{App: this} + xcmd.Gopt_App_Main(this, _xgo_obj0, _xgo_obj1, _xgo_obj2, _xgo_obj3, _xgo_obj4, _xgo_obj5, _xgo_obj6, _xgo_obj7, _xgo_obj8) } + //line cmd/llgo/build_cmd.gox:20 func (this *Cmd_build) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -86,6 +94,7 @@ func (this *Cmd_build) Main(_xgo_arg0 string) { func (this *Cmd_build) Classfname() string { return "build" } + //line cmd/llgo/clean_cmd.gox:20 func (this *Cmd_clean) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -104,6 +113,7 @@ func (this *Cmd_clean) Main(_xgo_arg0 string) { func (this *Cmd_clean) Classfname() string { return "clean" } + //line cmd/llgo/cmptest_cmd.gox:20 func (this *Cmd_cmptest) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -122,6 +132,7 @@ func (this *Cmd_cmptest) Main(_xgo_arg0 string) { func (this *Cmd_cmptest) Classfname() string { return "cmptest" } + //line cmd/llgo/get_cmd.gox:16 func (this *Cmd_get) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -138,6 +149,7 @@ func (this *Cmd_get) Main(_xgo_arg0 string) { func (this *Cmd_get) Classfname() string { return "get" } + //line cmd/llgo/install_cmd.gox:20 func (this *Cmd_install) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -156,6 +168,26 @@ func (this *Cmd_install) Main(_xgo_arg0 string) { func (this *Cmd_install) Classfname() string { return "install" } + +//line cmd/llgo/monitor_cmd.gox:21 +func (this *Cmd_monitor) Main(_xgo_arg0 string) { + this.Command.Main(_xgo_arg0) +//line cmd/llgo/monitor_cmd.gox:21:1 + this.Use("monitor [flags] [executable]") +//line cmd/llgo/monitor_cmd.gox:23:1 + this.Short("Monitor serial output from device") +//line cmd/llgo/monitor_cmd.gox:25:1 + this.FlagOff() +//line cmd/llgo/monitor_cmd.gox:27:1 + this.Run__1(func(args []string) { +//line cmd/llgo/monitor_cmd.gox:28:1 + monitor.Cmd.Run(monitor.Cmd, args) + }) +} +func (this *Cmd_monitor) Classfname() string { + return "monitor" +} + //line cmd/llgo/run_cmd.gox:20 func (this *Cmd_run) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -174,6 +206,7 @@ func (this *Cmd_run) Main(_xgo_arg0 string) { func (this *Cmd_run) Classfname() string { return "run" } + //line cmd/llgo/test_cmd.gox:20 func (this *Cmd_test) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) @@ -192,6 +225,7 @@ func (this *Cmd_test) Main(_xgo_arg0 string) { func (this *Cmd_test) Classfname() string { return "test" } + //line cmd/llgo/version_cmd.gox:22 func (this *Cmd_version) Main(_xgo_arg0 string) { this.Command.Main(_xgo_arg0) diff --git a/doc/Embedded_Cmd.md b/doc/Embedded_Cmd.md new file mode 100644 index 00000000..dc4dd762 --- /dev/null +++ b/doc/Embedded_Cmd.md @@ -0,0 +1,78 @@ +# LLGo Embedded Development Command Line Options + +## Flags + +- `-o ` - Specify output file name +- `-target ` - Specify target platform for cross-compilation +- `-obin` - Generate binary format output (requires `-target`) +- `-ohex` - Generate Intel HEX format output (requires `-target`) +- `-oimg` - Generate firmware image format output (requires `-target`) +- `-ouf2` - Generate UF2 format output (requires `-target`) +- `-ozip` - Generate ZIP/DFU format output (requires `-target`) +- `-emulator` - Run using emulator (auto-detects required format) +- `-port ` - Target port for flashing, testing, or monitoring +- `-baudrate ` - Baudrate for serial communication (default: 115200) + +## Commands + +### llgo build +Compile program to output file. +- No `-target`: Native executable +- With `-target`: ELF executable (and additional formats if specified with `-obin`, `-ohex`, etc.) + +### llgo run +Compile and run program. +- No `-target`: Run locally +- With `-target`: Run on device or emulator (equivalent to `install` + `monitor`) + +### llgo test +Compile and run tests. +- No `-target`: Run tests locally +- With `-target`: Run tests on device or emulator +- Supports `-emulator` and `-port` flags + +### llgo install +Install program or flash to device. +- No `-target`: Install to `$GOPATH/bin` +- With `-target`: Flash to device (use `-port` to specify port) + +### llgo monitor +Monitor serial output from embedded device. +- `-port `: Serial port device (e.g., `/dev/ttyUSB0`, `COM3`) +- `-target `: Auto-detect port from target configuration +- `-baudrate `: Communication speed (default: 115200) +- `[executable]`: Optional ELF file for debug info (panic address resolution) + +Features: +- Real-time bidirectional serial communication +- Automatic port detection from target configuration +- Debug info integration for panic address resolution +- Cross-platform support (Linux, macOS, Windows) +- Graceful exit with Ctrl-C + +## Examples + +```bash +# Native development +llgo build hello.go # -> hello +llgo build -o myapp hello.go # -> myapp +llgo run hello.go # run locally +llgo install hello.go # install to bin + +# Cross-compilation +llgo build -target esp32 . # -> hello.elf +llgo build -target esp32 -obin . # -> hello.elf + hello.bin +llgo build -target esp32 -ohex . # -> hello.elf + hello.bin + hello.hex +llgo build -target esp32 -obin -ohex -oimg . # -> hello.elf + hello.bin + hello.hex + hello.img +llgo run -target esp32 . # run on ESP32 (guess a port) +llgo run -target esp32 -emulator . # run in emulator +llgo test -target esp32 -port /dev/ttyUSB0 . # run tests on device +llgo test -target esp32 -emulator . # run tests in emulator +llgo install -target esp32 -port /dev/ttyUSB0 . # flash to specific port + +# Monitor device output +llgo monitor -port /dev/ttyUSB0 # monitor with specific port +llgo monitor -target esp32 # monitor with auto-detected port +llgo monitor -target esp32 -baudrate 9600 # custom baudrate +llgo monitor -port /dev/ttyUSB0 ./firmware.elf # with debug info for panic resolution +``` diff --git a/go.mod b/go.mod index 590fc19b..1b81620d 100644 --- a/go.mod +++ b/go.mod @@ -15,11 +15,19 @@ require ( golang.org/x/tools v0.36.0 ) -require github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 +require ( + github.com/marcinbor85/gohex v0.0.0-20210308104911-55fb1c624d84 + github.com/mattn/go-tty v0.0.7 + github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 + go.bug.st/serial v1.6.4 +) require ( + github.com/creack/goselect v0.1.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect ) replace github.com/goplus/llgo/runtime => ./runtime diff --git a/go.sum b/go.sum index 46a392b2..1cbaeea0 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/goplus/cobra v1.9.12 h1:0F9EdEbeGyITGz+mqoHoJ5KpUw97p1CkxV74IexHw5s= @@ -10,13 +14,30 @@ github.com/goplus/llvm v0.8.5 h1:DUnFeYC3Rco622tBEKGg8xkigRAV2fh5ZIfBCt7gOSs= github.com/goplus/llvm v0.8.5/go.mod h1:PeVK8GgzxwAYCiMiUAJb5wJR6xbhj989tu9oulKLLT4= github.com/goplus/mod v0.17.1 h1:ITovxDcc5zbURV/Wrp3/SBsYLgC1KrxY6pq1zMM2V94= github.com/goplus/mod v0.17.1/go.mod h1:iXEszBKqi38BAyQApBPyQeurLHmQN34YMgC2ZNdap50= +github.com/marcinbor85/gohex v0.0.0-20210308104911-55fb1c624d84 h1:hyAgCuG5nqTMDeUD8KZs7HSPs6KprPgPP8QmGV8nyvk= +github.com/marcinbor85/gohex v0.0.0-20210308104911-55fb1c624d84/go.mod h1:Pb6XcsXyropB9LNHhnqaknG/vEwYztLkQzVCHv8sQ3M= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= +github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/qiniu/x v1.15.1 h1:avE+YQaowp8ZExjylOeSM73rUo3MQKBAYVxh4NJ8dY8= github.com/qiniu/x v1.15.1/go.mod h1:AiovSOCaRijaf3fj+0CBOpR1457pn24b0Vdb1JpwhII= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I= github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= +go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/build/build.go b/internal/build/build.go index 38e1286a..80777163 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -18,7 +18,6 @@ package build import ( "bytes" - "debug/macho" "errors" "fmt" "go/ast" @@ -43,7 +42,9 @@ import ( "github.com/goplus/llgo/internal/crosscompile" "github.com/goplus/llgo/internal/env" "github.com/goplus/llgo/internal/firmware" + "github.com/goplus/llgo/internal/flash" "github.com/goplus/llgo/internal/mockable" + "github.com/goplus/llgo/internal/monitor" "github.com/goplus/llgo/internal/packages" "github.com/goplus/llgo/internal/typepatch" "github.com/goplus/llgo/ssa/abi" @@ -71,14 +72,37 @@ const ( debugBuild = packages.DebugPackagesLoad ) +// OutFmts contains output format specifications for embedded targets +type OutFmts struct { + Bin bool // Generate binary output (.bin) + Hex bool // Generate Intel hex output (.hex) + Img bool // Generate image output (.img) + Uf2 bool // Generate UF2 output (.uf2) + Zip bool // Generate ZIP/DFU output (.zip) +} + +// OutFmtDetails contains detailed output file paths for each format +type OutFmtDetails struct { + Out string // Base output file path + Bin string // Binary output file path (.bin) + Hex string // Intel hex output file path (.hex) + Img string // Image output file path (.img) + Uf2 string // UF2 output file path (.uf2) + Zip string // ZIP/DFU output file path (.zip) +} + type Config struct { Goos string Goarch string Target string // target name (e.g., "rp2040", "wasi") - takes precedence over Goos/Goarch BinPath string - AppExt string // ".exe" on Windows, empty on Unix - OutFile string // only valid for ModeBuild when len(pkgs) == 1 - RunArgs []string // only valid for ModeRun + AppExt string // ".exe" on Windows, empty on Unix + OutFile string // only valid for ModeBuild when len(pkgs) == 1 + OutFmts OutFmts // Output format specifications (only for Target != "") + Emulator bool // run in emulator mode + Port string // target port for flashing + BaudRate int // baudrate for serial communication + RunArgs []string Mode Mode AbiMode AbiMode GenExpect bool // only valid for ModeCmpTest @@ -117,7 +141,6 @@ func NewDefaultConf(mode Mode) *Config { BinPath: bin, Mode: mode, AbiMode: cabi.ModeAllFunc, - AppExt: DefaultAppExt(goos), } return conf } @@ -133,8 +156,15 @@ func envGOPATH() (string, error) { return filepath.Join(home, "go"), nil } -func DefaultAppExt(goos string) string { - switch goos { +func defaultAppExt(conf *Config) string { + if conf.Target != "" { + if strings.HasPrefix(conf.Target, "wasi") || strings.HasPrefix(conf.Target, "wasm") { + return ".wasm" + } + return ".elf" + } + + switch conf.Goos { case "windows": return ".exe" case "wasi", "wasip1", "js": @@ -159,6 +189,9 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.Goarch == "" { conf.Goarch = runtime.GOARCH } + if conf.AppExt == "" { + conf.AppExt = defaultAppExt(conf) + } // Handle crosscompile configuration first to set correct GOOS/GOARCH forceEspClang := conf.ForceEspClang || conf.Target != "" export, err := crosscompile.Use(conf.Goos, conf.Goarch, conf.Target, IsWasiThreadsEnabled(), forceEspClang) @@ -238,6 +271,10 @@ func Do(args []string, conf *Config) ([]Package, error) { if conf.OutFile != "" { return nil, fmt.Errorf("cannot build multiple packages with -o") } + case ModeInstall: + if conf.Target != "" { + return nil, fmt.Errorf("cannot install multiple packages to embedded target") + } case ModeRun: return nil, fmt.Errorf("cannot run multiple packages") case ModeTest: @@ -314,7 +351,67 @@ func Do(args []string, conf *Config) ([]Package, error) { for _, pkg := range initial { if needLink(pkg, mode) { - linkMainPkg(ctx, pkg, allPkgs, global, conf, mode, verbose) + name := path.Base(pkg.PkgPath) + + // Create output format details + outFmts, err := buildOutFmts(name, conf, len(ctx.initial) > 1, &ctx.crossCompile) + if err != nil { + return nil, err + } + + // Link main package using the base output path + err = linkMainPkg(ctx, pkg, allPkgs, global, outFmts.Out, verbose) + if err != nil { + return nil, err + } + + envMap := outFmts.ToEnvMap() + + // Only convert formats when Target is specified + if conf.Target != "" { + // Process format conversions for embedded targets + err = firmware.ConvertFormats(ctx.crossCompile.BinaryFormat, ctx.crossCompile.FormatDetail, envMap) + if err != nil { + return nil, err + } + } + + switch mode { + case ModeBuild: + // Do nothing + + case ModeInstall: + // Native already installed in linkMainPkg + if conf.Target != "" { + err = flash.FlashDevice(ctx.crossCompile.Device, envMap, ctx.buildConf.Port, verbose) + if err != nil { + return nil, err + } + } + + case ModeRun, ModeTest, ModeCmpTest: + if conf.Target == "" { + err = runNative(ctx, outFmts.Out, pkg.Dir, pkg.PkgPath, conf, mode) + } else if conf.Emulator { + err = runInEmulator(ctx.crossCompile.Emulator, envMap, pkg.Dir, pkg.PkgPath, conf, mode, verbose) + } else { + err = flash.FlashDevice(ctx.crossCompile.Device, envMap, ctx.buildConf.Port, verbose) + if err != nil { + return nil, err + } + monitorConfig := monitor.MonitorConfig{ + Port: ctx.buildConf.Port, + Target: conf.Target, + Executable: outFmts.Out, + BaudRate: conf.BaudRate, + SerialPort: ctx.crossCompile.Device.SerialPort, + } + err = monitor.Monitor(monitorConfig, verbose) + } + if err != nil { + return nil, err + } + } } } @@ -629,76 +726,7 @@ func compileExtraFiles(ctx *context, verbose bool) ([]string, error) { return objFiles, nil } -// generateOutputFilenames generates the final output filename (app) and intermediate filename (orgApp) -// based on configuration and build context. -func generateOutputFilenames(outFile, binPath, appExt, binExt, pkgName string, mode Mode, isMultiplePkgs bool) (app, orgApp string, err error) { - if outFile == "" { - if mode == ModeBuild && isMultiplePkgs { - // For multiple packages in ModeBuild mode, use temporary file - name := pkgName - if binExt != "" { - name += "*" + binExt - } else { - name += "*" + appExt - } - tmpFile, err := os.CreateTemp("", name) - if err != nil { - return "", "", err - } - app = tmpFile.Name() - tmpFile.Close() - } else { - app = filepath.Join(binPath, pkgName+appExt) - } - orgApp = app - } else { - // outFile is not empty, use it as base part - base := outFile - if binExt != "" { - // If binExt has value, use temporary file as orgApp for firmware conversion - tmpFile, err := os.CreateTemp("", "llgo-*"+appExt) - if err != nil { - return "", "", err - } - orgApp = tmpFile.Name() - tmpFile.Close() - // Check if base already ends with binExt, if so, don't add it again - if strings.HasSuffix(base, binExt) { - app = base - } else { - app = base + binExt - } - } else { - // No binExt, use base + AppExt directly - if filepath.Ext(base) == "" { - app = base + appExt - } else { - app = base - } - orgApp = app - } - } - return app, orgApp, nil -} - -func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, global llssa.Package, conf *Config, mode Mode, verbose bool) { - pkgPath := pkg.PkgPath - name := path.Base(pkgPath) - binFmt := ctx.crossCompile.BinaryFormat - binExt := firmware.BinaryExt(binFmt) - - // app: converted firmware output file or executable file - // orgApp: before converted output file - app, orgApp, err := generateOutputFilenames( - conf.OutFile, - conf.BinPath, - conf.AppExt, - binExt, - name, - mode, - len(ctx.initial) > 1, - ) - check(err) +func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, global llssa.Package, outputPath string, verbose bool) error { needRuntime := false needPyInit := false @@ -726,18 +754,24 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, global l } }) entryObjFile, err := genMainModuleFile(ctx, llssa.PkgRuntime, pkg, needRuntime, needPyInit) - check(err) + if err != nil { + return err + } // defer os.Remove(entryLLFile) objFiles = append(objFiles, entryObjFile) // Compile extra files from target configuration extraObjFiles, err := compileExtraFiles(ctx, verbose) - check(err) + if err != nil { + return err + } objFiles = append(objFiles, extraObjFiles...) if global != nil { export, err := exportObject(ctx, pkg.PkgPath+".global", pkg.ExportFile+"-global", []byte(global.String())) - check(err) + if err != nil { + return err + } objFiles = append(objFiles, export) } @@ -758,66 +792,7 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, global l } } - err = linkObjFiles(ctx, orgApp, objFiles, linkArgs, verbose) - check(err) - - if orgApp != app { - err = firmware.MakeFirmwareImage(orgApp, app, ctx.crossCompile.BinaryFormat, ctx.crossCompile.FormatDetail) - check(err) - } - - switch mode { - case ModeTest: - cmd := exec.Command(app, conf.RunArgs...) - cmd.Dir = pkg.Dir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Run() - if s := cmd.ProcessState; s != nil { - exitCode := s.ExitCode() - fmt.Fprintf(os.Stderr, "%s: exit code %d\n", app, exitCode) - if !ctx.testFail && exitCode != 0 { - ctx.testFail = true - } - } - case ModeRun: - args := make([]string, 0, len(conf.RunArgs)+1) - copy(args, conf.RunArgs) - if isWasmTarget(conf.Goos) { - wasmer := os.ExpandEnv(WasmRuntime()) - wasmerArgs := strings.Split(wasmer, " ") - wasmerCmd := wasmerArgs[0] - wasmerArgs = wasmerArgs[1:] - switch wasmer { - case "wasmtime": - args = append(args, "--wasm", "multi-memory=true", app) - args = append(args, conf.RunArgs...) - case "iwasm": - args = append(args, "--stack-size=819200000", "--heap-size=800000000", app) - args = append(args, conf.RunArgs...) - default: - args = append(args, wasmerArgs...) - args = append(args, app) - args = append(args, conf.RunArgs...) - } - app = wasmerCmd - } else { - args = conf.RunArgs - } - cmd := exec.Command(app, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Run() - if err != nil { - panic(err) - } - if s := cmd.ProcessState; s != nil { - mockable.Exit(s.ExitCode()) - } - case ModeCmpTest: - cmpTest(filepath.Dir(pkg.GoFiles[0]), pkgPath, app, conf.GenExpect, conf.RunArgs) - } + return linkObjFiles(ctx, outputPath, objFiles, linkArgs, verbose) } func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose bool) error { @@ -1289,22 +1264,6 @@ func pkgExists(initial []*packages.Package, pkg *packages.Package) bool { return false } -// findDylibDep finds the dylib dependency in the executable. It returns empty -// string if not found. -func findDylibDep(exe, lib string) string { - file, err := macho.Open(exe) - check(err) - defer file.Close() - for _, load := range file.Loads { - if dylib, ok := load.(*macho.Dylib); ok { - if strings.HasPrefix(filepath.Base(dylib.Name), fmt.Sprintf("lib%s.", lib)) { - return dylib.Name - } - } - } - return "" -} - type none struct{} func check(err error) { diff --git a/internal/build/build_test.go b/internal/build/build_test.go index b43ff6a4..ead07db0 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "os" - "strings" "testing" "github.com/goplus/llgo/internal/mockable" @@ -95,172 +94,4 @@ func TestCmpTest(t *testing.T) { mockRun([]string{"../../cl/_testgo/runtest"}, &Config{Mode: ModeCmpTest}) } -func TestGenerateOutputFilenames(t *testing.T) { - tests := []struct { - name string - outFile string - binPath string - appExt string - binExt string - pkgName string - mode Mode - isMultiplePkgs bool - wantAppSuffix string - wantOrgAppDiff bool // true if orgApp should be different from app - wantErr bool - }{ - { - name: "empty outFile, single package", - outFile: "", - binPath: "/usr/local/bin", - appExt: "", - binExt: "", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "/usr/local/bin/hello", - wantOrgAppDiff: false, - }, - { - name: "empty outFile with appExt", - outFile: "", - binPath: "/usr/local/bin", - appExt: ".exe", - binExt: "", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "/usr/local/bin/hello.exe", - wantOrgAppDiff: false, - }, - { - name: "outFile without binExt", - outFile: "myapp", - binPath: "/usr/local/bin", - appExt: ".exe", - binExt: "", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "myapp.exe", - wantOrgAppDiff: false, - }, - { - name: "outFile with existing extension, no binExt", - outFile: "myapp.exe", - binPath: "/usr/local/bin", - appExt: ".exe", - binExt: "", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "myapp.exe", - wantOrgAppDiff: false, - }, - { - name: "outFile with binExt, different from existing extension", - outFile: "myapp", - binPath: "/usr/local/bin", - appExt: ".exe", - binExt: ".bin", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "myapp.bin", - wantOrgAppDiff: true, - }, - { - name: "outFile already ends with binExt", - outFile: "t.bin", - binPath: "/usr/local/bin", - appExt: ".exe", - binExt: ".bin", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "t.bin", - wantOrgAppDiff: true, - }, - { - name: "outFile with full path already ends with binExt", - outFile: "/path/to/t.bin", - binPath: "/usr/local/bin", - appExt: ".exe", - binExt: ".bin", - pkgName: "hello", - mode: ModeBuild, - isMultiplePkgs: false, - wantAppSuffix: "/path/to/t.bin", - wantOrgAppDiff: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - app, orgApp, err := generateOutputFilenames( - tt.outFile, - tt.binPath, - tt.appExt, - tt.binExt, - tt.pkgName, - tt.mode, - tt.isMultiplePkgs, - ) - - if (err != nil) != tt.wantErr { - t.Errorf("generateOutputFilenames() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if tt.wantAppSuffix != "" { - if app != tt.wantAppSuffix { - t.Errorf("generateOutputFilenames() app = %v, want %v", app, tt.wantAppSuffix) - } - } - - if tt.wantOrgAppDiff { - if app == orgApp { - t.Errorf("generateOutputFilenames() orgApp should be different from app, but both are %v", app) - } - // Clean up temp file - if orgApp != "" && strings.Contains(orgApp, "llgo-") { - os.Remove(orgApp) - } - } else { - if app != orgApp { - t.Errorf("generateOutputFilenames() orgApp = %v, want %v (same as app)", orgApp, app) - } - } - }) - } -} - -func TestGenerateOutputFilenames_EdgeCases(t *testing.T) { - // Test case where outFile has same extension as binExt - app, orgApp, err := generateOutputFilenames( - "firmware.bin", - "/usr/local/bin", - ".exe", - ".bin", - "esp32app", - ModeBuild, - false, - ) - - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - if app != "firmware.bin" { - t.Errorf("Expected app to be 'firmware.bin', got '%s'", app) - } - - if app == orgApp { - t.Errorf("Expected orgApp to be different from app when binExt is present, but both are '%s'", app) - } - - // Clean up temp file - if orgApp != "" && strings.Contains(orgApp, "llgo-") { - os.Remove(orgApp) - } -} +// TestGenerateOutputFilenames removed - functionality moved to filename_test.go diff --git a/internal/build/outputs.go b/internal/build/outputs.go new file mode 100644 index 00000000..0fc4ab60 --- /dev/null +++ b/internal/build/outputs.go @@ -0,0 +1,165 @@ +package build + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "github.com/goplus/llgo/internal/crosscompile" + "github.com/goplus/llgo/internal/firmware" +) + +// OutputCfg contains the generated output paths and conversion configuration +type OutputCfg struct { + OutPath string // Final output file path + IntPath string // Intermediate file path (for two-stage conversion) + OutExt string // Output file extension + FileFmt string // File format (from conf.FileFormat or extracted from emulator) + BinFmt string // Binary format for firmware conversion (may have -img suffix) + NeedFwGen bool // Whether firmware image generation is needed + DirectGen bool // True if can generate firmware directly without intermediate file +} + +func genTempOutputFile(prefix, ext string) (string, error) { + tmpFile, err := os.CreateTemp("", prefix+"-*"+ext) + if err != nil { + return "", err + } + tmpFile.Close() + return tmpFile.Name(), nil +} + +// setOutFmt sets the appropriate OutFmt based on format name +func setOutFmt(conf *Config, formatName string) { + switch formatName { + case "bin": + conf.OutFmts.Bin = true + case "hex": + conf.OutFmts.Hex = true + case "img": + conf.OutFmts.Img = true + case "uf2": + conf.OutFmts.Uf2 = true + case "zip": + conf.OutFmts.Zip = true + } +} + +// buildOutFmts creates OutFmtDetails based on package, configuration and multi-package status +func buildOutFmts(pkgName string, conf *Config, multiPkg bool, crossCompile *crosscompile.Export) (*OutFmtDetails, error) { + details := &OutFmtDetails{} + var err error + if conf.Target == "" { + // Native target + if conf.Mode == ModeInstall { + details.Out = filepath.Join(conf.BinPath, pkgName+conf.AppExt) + } else if conf.Mode == ModeBuild && !multiPkg && conf.OutFile != "" { + base := strings.TrimSuffix(conf.OutFile, conf.AppExt) + details.Out = base + conf.AppExt + } else if conf.Mode == ModeBuild && !multiPkg { + details.Out = pkgName + conf.AppExt + } else { + details.Out, err = genTempOutputFile(pkgName, conf.AppExt) + if err != nil { + return nil, err + } + } + return details, nil + } + + needRun := slices.Contains([]Mode{ModeRun, ModeTest, ModeCmpTest, ModeInstall}, conf.Mode) + + if multiPkg { + details.Out, err = genTempOutputFile(pkgName, conf.AppExt) + if err != nil { + return nil, err + } + } else if conf.OutFile != "" { + base := strings.TrimSuffix(conf.OutFile, conf.AppExt) + details.Out = base + conf.AppExt + } else if conf.Mode == ModeBuild { + details.Out = pkgName + conf.AppExt + } else { + details.Out, err = genTempOutputFile(pkgName, conf.AppExt) + if err != nil { + return nil, err + } + } + + // Check emulator format if emulator mode is enabled + outFmt := "" + if needRun { + if conf.Emulator { + if crossCompile.Emulator != "" { + outFmt = firmware.ExtractFileFormatFromCommand(crossCompile.Emulator) + } + } else { + if crossCompile.Device.Flash.Method == "command" { + outFmt = firmware.ExtractFileFormatFromCommand(crossCompile.Device.Flash.Command) + } + } + } + if outFmt != "" { + setOutFmt(conf, outFmt) + } + + // Check binary format and set corresponding format + if crossCompile.BinaryFormat != "" && needRun { + envName := firmware.BinaryFormatToEnvName(crossCompile.BinaryFormat) + if envName != "" { + setOutFmt(conf, envName) + } + } + + // Generate format-specific paths based on base output + if details.Out != "" { + base := strings.TrimSuffix(details.Out, filepath.Ext(details.Out)) + + if conf.OutFmts.Bin || conf.OutFmts.Img || conf.OutFmts.Hex { + details.Bin = base + ".bin" + } + if conf.OutFmts.Hex { + details.Hex = base + ".hex" + } + if conf.OutFmts.Img { + details.Img = base + ".img" + } + if conf.OutFmts.Uf2 { + details.Uf2 = base + ".uf2" + } + if conf.OutFmts.Zip { + details.Zip = base + ".zip" + } + } + + return details, nil +} + +// ToEnvMap converts OutFmtDetails to a map for template substitution +func (details *OutFmtDetails) ToEnvMap() map[string]string { + envMap := make(map[string]string) + + if details.Out != "" { + envMap[""] = details.Out + envMap["out"] = details.Out + envMap["elf"] = details.Out // alias for compatibility + } + if details.Bin != "" { + envMap["bin"] = details.Bin + } + if details.Hex != "" { + envMap["hex"] = details.Hex + } + if details.Img != "" { + envMap["img"] = details.Img + } + if details.Uf2 != "" { + envMap["uf2"] = details.Uf2 + } + if details.Zip != "" { + envMap["zip"] = details.Zip + } + + return envMap +} diff --git a/internal/build/outputs_test.go b/internal/build/outputs_test.go new file mode 100644 index 00000000..e76b33d3 --- /dev/null +++ b/internal/build/outputs_test.go @@ -0,0 +1,366 @@ +//go:build !llgo +// +build !llgo + +package build + +import ( + "strings" + "testing" + + "github.com/goplus/llgo/internal/crosscompile" + "github.com/goplus/llgo/internal/flash" +) + +func TestBuildOutFmtsWithTarget(t *testing.T) { + tests := []struct { + name string + conf *Config + pkgName string + crossCompile crosscompile.Export + wantOut string // use empty string to indicate temp file + wantBin string + wantHex string + wantImg string + wantUf2 string + wantZip string + }{ + { + name: "build with -o", + conf: &Config{ + Mode: ModeBuild, + OutFile: "myapp", + AppExt: "", + }, + pkgName: "hello", + crossCompile: crosscompile.Export{ + BinaryFormat: "", + }, + wantOut: "myapp", + }, + { + name: "build hex format", + conf: &Config{ + Mode: ModeBuild, + Target: "esp32", + AppExt: ".elf", + OutFmts: OutFmts{Hex: true}, + }, + pkgName: "hello", + crossCompile: crosscompile.Export{ + BinaryFormat: "esp32", // This will auto-set Bin: true + }, + wantOut: "hello.elf", + wantBin: "hello.bin", // Now expected due to esp32 BinaryFormat + wantHex: "hello.hex", + }, + { + name: "emulator mode with bin format", + conf: &Config{ + Mode: ModeRun, + Target: "esp32", + AppExt: ".elf", + Emulator: true, + }, + pkgName: "hello", + crossCompile: crosscompile.Export{ + BinaryFormat: "esp32", + Emulator: "qemu-system-xtensa -machine esp32 -kernel {bin}", + }, + wantBin: ".bin", // Should be temp file with .bin extension + }, + { + name: "flash command with hex format", + conf: &Config{ + Mode: ModeInstall, + Target: "esp32", + AppExt: ".elf", + Emulator: false, + }, + pkgName: "hello", + crossCompile: crosscompile.Export{ + BinaryFormat: "esp32", // This will auto-set Bin: true + Device: flash.Device{ + Flash: flash.Flash{ + Method: "command", + Command: "esptool.py --chip esp32 write_flash 0x10000 {hex}", + }, + }, + }, + wantBin: ".bin", // Expected due to esp32 BinaryFormat + wantHex: ".hex", // Should be temp file with .hex extension + }, + { + name: "multiple formats specified", + conf: &Config{ + Mode: ModeBuild, + Target: "esp32", + AppExt: ".elf", + OutFmts: OutFmts{ + Bin: true, + Hex: true, + Img: true, + Uf2: true, + }, + }, + pkgName: "hello", + crossCompile: crosscompile.Export{ + BinaryFormat: "esp32", + }, + wantOut: "hello.elf", + wantBin: "hello.bin", + wantHex: "hello.hex", + wantImg: "hello.img", + wantUf2: "hello.uf2", + wantZip: "", // Not specified + }, + { + name: "no formats specified", + conf: &Config{ + Mode: ModeBuild, + Target: "esp32", + AppExt: ".elf", + OutFmts: OutFmts{}, // No formats specified + }, + pkgName: "hello", + crossCompile: crosscompile.Export{ + BinaryFormat: "esp32", + }, + wantOut: "hello.elf", + wantBin: "", // No bin file should be generated + wantHex: "", // No hex file should be generated + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Determine if multi-package (mock single package for simplicity) + multiPkg := false + + result, err := buildOutFmts(tt.pkgName, tt.conf, multiPkg, &tt.crossCompile) + if err != nil { + t.Errorf("buildOutFmts() error = %v", err) + return + } + + // Check base output path + if tt.wantOut != "" { + if result.Out != tt.wantOut { + t.Errorf("buildOutFmts().Out = %v, want %v", result.Out, tt.wantOut) + } + } else { + // Should be a temp file + if result.Out == "" || !strings.Contains(result.Out, tt.pkgName) { + t.Errorf("buildOutFmts().Out should be temp file containing %v, got %v", tt.pkgName, result.Out) + } + } + + // Check format-specific paths + checkFormatPath := func(actual, expected, formatName string) { + if expected == "" { + // Empty means no file should be generated + if actual != "" { + t.Errorf("buildOutFmts().%s = %v, want empty (no file generated)", formatName, actual) + } + } else if strings.HasPrefix(expected, ".") && !strings.Contains(expected[1:], ".") { + // ".xxx" means temp file with .xxx extension + if actual == "" { + t.Errorf("buildOutFmts().%s = empty, want temp file with %s extension", formatName, expected) + } else if !strings.HasSuffix(actual, expected) || !strings.Contains(actual, tt.pkgName) { + t.Errorf("buildOutFmts().%s should be temp file with %s extension containing %v, got %v", formatName, expected, tt.pkgName, actual) + } + } else { + // "aaa.xxx" means exact file name + if actual != expected { + t.Errorf("buildOutFmts().%s = %v, want %v", formatName, actual, expected) + } + } + } + + checkFormatPath(result.Bin, tt.wantBin, "Bin") + checkFormatPath(result.Hex, tt.wantHex, "Hex") + checkFormatPath(result.Img, tt.wantImg, "Img") + checkFormatPath(result.Uf2, tt.wantUf2, "Uf2") + checkFormatPath(result.Zip, tt.wantZip, "Zip") + }) + } +} + +func TestBuildOutFmtsNativeTarget(t *testing.T) { + tests := []struct { + name string + mode Mode + multiPkg bool + outFile string + binPath string + appExt string + goos string + pkgName string + wantOut string + }{ + // ModeBuild scenarios + { + name: "build single pkg no outfile macos", + mode: ModeBuild, + multiPkg: false, + outFile: "", + appExt: "", + goos: "darwin", + pkgName: "hello", + wantOut: "hello", + }, + { + name: "build single pkg no outfile linux", + mode: ModeBuild, + multiPkg: false, + outFile: "", + appExt: "", + goos: "linux", + pkgName: "hello", + wantOut: "hello", + }, + { + name: "build single pkg no outfile windows", + mode: ModeBuild, + multiPkg: false, + outFile: "", + appExt: ".exe", + goos: "windows", + pkgName: "hello", + wantOut: "hello.exe", + }, + { + name: "build single pkg with outfile", + mode: ModeBuild, + multiPkg: false, + outFile: "myapp", + appExt: "", + goos: "linux", + pkgName: "hello", + wantOut: "myapp", + }, + { + name: "build single pkg with outfile and ext", + mode: ModeBuild, + multiPkg: false, + outFile: "myapp.exe", + appExt: ".exe", + goos: "windows", + pkgName: "hello", + wantOut: "myapp.exe", + }, + { + name: "build multi pkg", + mode: ModeBuild, + multiPkg: true, + outFile: "", + appExt: "", + goos: "linux", + pkgName: "hello", + wantOut: "", // Should be temp file + }, + // ModeInstall scenarios + { + name: "install single pkg macos", + mode: ModeInstall, + multiPkg: false, + outFile: "", + binPath: "/go/bin", + appExt: "", + goos: "darwin", + pkgName: "hello", + wantOut: "/go/bin/hello", + }, + { + name: "install single pkg windows", + mode: ModeInstall, + multiPkg: false, + outFile: "", + binPath: "C:/go/bin", // Use forward slashes for cross-platform compatibility + appExt: ".exe", + goos: "windows", + pkgName: "hello", + wantOut: "C:/go/bin/hello.exe", + }, + { + name: "install multi pkg", + mode: ModeInstall, + multiPkg: true, + outFile: "", + binPath: "/go/bin", + appExt: "", + goos: "linux", + pkgName: "hello", + wantOut: "/go/bin/hello", + }, + // Other modes should use temp files + { + name: "run mode", + mode: ModeRun, + multiPkg: false, + outFile: "", + appExt: "", + goos: "linux", + pkgName: "hello", + wantOut: "", // Should be temp file + }, + { + name: "test mode", + mode: ModeTest, + multiPkg: false, + outFile: "", + appExt: "", + goos: "linux", + pkgName: "hello", + wantOut: "", // Should be temp file + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := &Config{ + Mode: tt.mode, + OutFile: tt.outFile, + BinPath: tt.binPath, + AppExt: tt.appExt, + Target: "", // Native target + } + + crossCompile := &crosscompile.Export{} + + result, err := buildOutFmts(tt.pkgName, conf, tt.multiPkg, crossCompile) + if err != nil { + t.Errorf("buildOutFmts() error = %v", err) + return + } + + // Check base output path + if tt.wantOut != "" { + if result.Out != tt.wantOut { + t.Errorf("buildOutFmts().Out = %v, want %v", result.Out, tt.wantOut) + } + } else { + // Should be a temp file + if result.Out == "" || !strings.Contains(result.Out, tt.pkgName) { + t.Errorf("buildOutFmts().Out should be temp file containing %v, got %v", tt.pkgName, result.Out) + } + } + + // For native targets, no format files should be generated + if result.Bin != "" { + t.Errorf("buildOutFmts().Bin = %v, want empty for native target", result.Bin) + } + if result.Hex != "" { + t.Errorf("buildOutFmts().Hex = %v, want empty for native target", result.Hex) + } + if result.Img != "" { + t.Errorf("buildOutFmts().Img = %v, want empty for native target", result.Img) + } + if result.Uf2 != "" { + t.Errorf("buildOutFmts().Uf2 = %v, want empty for native target", result.Uf2) + } + if result.Zip != "" { + t.Errorf("buildOutFmts().Zip = %v, want empty for native target", result.Zip) + } + }) + } +} diff --git a/internal/build/run.go b/internal/build/run.go new file mode 100644 index 00000000..a37a894c --- /dev/null +++ b/internal/build/run.go @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package build + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/goplus/llgo/internal/mockable" + "github.com/goplus/llgo/internal/shellparse" +) + +func runNative(ctx *context, app, pkgDir, pkgName string, conf *Config, mode Mode) error { + switch mode { + case ModeRun: + args := make([]string, 0, len(conf.RunArgs)+1) + if isWasmTarget(conf.Goos) { + wasmer := os.ExpandEnv(WasmRuntime()) + wasmerArgs := strings.Split(wasmer, " ") + wasmerCmd := wasmerArgs[0] + wasmerArgs = wasmerArgs[1:] + switch wasmer { + case "wasmtime": + args = append(args, "--wasm", "multi-memory=true", app) + args = append(args, conf.RunArgs...) + case "iwasm": + args = append(args, "--stack-size=819200000", "--heap-size=800000000", app) + args = append(args, conf.RunArgs...) + default: + args = append(args, wasmerArgs...) + args = append(args, app) + args = append(args, conf.RunArgs...) + } + app = wasmerCmd + } else { + args = conf.RunArgs + } + cmd := exec.Command(app, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + return err + } + if s := cmd.ProcessState; s != nil { + mockable.Exit(s.ExitCode()) + } + case ModeTest: + cmd := exec.Command(app, conf.RunArgs...) + cmd.Dir = pkgDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + fmt.Fprintf(os.Stderr, "%s: exit code %d\n", app, exitErr.ExitCode()) + if !ctx.testFail { + ctx.testFail = true + } + } else { + fmt.Fprintf(os.Stderr, "failed to run test %s: %v\n", app, err) + if !ctx.testFail { + ctx.testFail = true + } + } + } + case ModeCmpTest: + cmpTest(pkgDir, pkgName, app, conf.GenExpect, conf.RunArgs) + } + return nil +} + +func runInEmulator(emulator string, envMap map[string]string, pkgDir, pkgName string, conf *Config, mode Mode, verbose bool) error { + if emulator == "" { + return fmt.Errorf("target %s does not have emulator configured", conf.Target) + } + if verbose { + fmt.Fprintf(os.Stderr, "Using emulator: %s\n", emulator) + } + + switch mode { + case ModeRun: + return runEmuCmd(envMap, emulator, conf.RunArgs, verbose) + case ModeTest: + return runEmuCmd(envMap, emulator, conf.RunArgs, verbose) + case ModeCmpTest: + cmpTest(pkgDir, pkgName, envMap["out"], conf.GenExpect, conf.RunArgs) + return nil + } + return nil +} + +// runEmuCmd runs the application in emulator by formatting the emulator command template +func runEmuCmd(envMap map[string]string, emulatorTemplate string, runArgs []string, verbose bool) error { + // Expand the emulator command template + emulatorCmd := emulatorTemplate + for placeholder, path := range envMap { + var target string + if placeholder == "" { + target = "{}" + } else { + target = "{" + placeholder + "}" + } + emulatorCmd = strings.ReplaceAll(emulatorCmd, target, path) + } + + if verbose { + fmt.Fprintf(os.Stderr, "Running in emulator: %s\n", emulatorCmd) + } + + // Parse command and arguments safely handling quoted strings + cmdParts, err := shellparse.Parse(emulatorCmd) + if err != nil { + return fmt.Errorf("failed to parse emulator command: %w", err) + } + if len(cmdParts) == 0 { + return fmt.Errorf("empty emulator command") + } + + // Add run arguments to the end + cmdParts = append(cmdParts, runArgs...) + + // Execute the emulator command + cmd := exec.Command(cmdParts[0], cmdParts[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err = cmd.Run() + if err != nil { + return err + } + if s := cmd.ProcessState; s != nil { + mockable.Exit(s.ExitCode()) + } + return nil +} diff --git a/internal/crosscompile/crosscompile.go b/internal/crosscompile/crosscompile.go index 5a753aa3..fbd8962a 100644 --- a/internal/crosscompile/crosscompile.go +++ b/internal/crosscompile/crosscompile.go @@ -12,6 +12,7 @@ import ( "github.com/goplus/llgo/internal/crosscompile/compile" "github.com/goplus/llgo/internal/env" + "github.com/goplus/llgo/internal/flash" "github.com/goplus/llgo/internal/targets" "github.com/goplus/llgo/internal/xtool/llvm" ) @@ -34,6 +35,10 @@ type Export struct { BinaryFormat string // Binary format (e.g., "elf", "esp", "uf2") FormatDetail string // For uf2, it's uf2FamilyID + Emulator string // Emulator command template (e.g., "qemu-system-arm -M {} -kernel {}") + + // Flashing/Debugging configuration + Device flash.Device // Device configuration for flashing/debugging } // URLs and configuration that can be overridden for testing @@ -54,65 +59,6 @@ func cacheDir() string { return filepath.Join(cacheRoot(), "crosscompile") } -// expandEnv expands template variables in a string -// Supports variables like {port}, {hex}, {bin}, {root}, {tmpDir}, etc. -// Special case: {} expands to the first available file variable (hex, bin, img, zip) -func expandEnv(template string, envs map[string]string) string { - return expandEnvWithDefault(template, envs) -} - -// expandEnvWithDefault expands template variables with optional default for {} -func expandEnvWithDefault(template string, envs map[string]string, defaultValue ...string) string { - if template == "" { - return "" - } - - result := template - - // Handle special case of {} - use provided default or first available file variable - if strings.Contains(result, "{}") { - defaultVal := "" - if len(defaultValue) > 0 && defaultValue[0] != "" { - defaultVal = defaultValue[0] - } else { - // Priority order: hex, bin, img, zip - for _, key := range []string{"hex", "bin", "img", "zip"} { - if value, exists := envs[key]; exists && value != "" { - defaultVal = value - break - } - } - } - result = strings.ReplaceAll(result, "{}", defaultVal) - } - - // Replace named variables - for key, value := range envs { - if key != "" { // Skip empty key used for {} default - result = strings.ReplaceAll(result, "{"+key+"}", value) - } - } - return result -} - -// expandEnvSlice expands template variables in a slice of strings -func expandEnvSlice(templates []string, envs map[string]string) []string { - return expandEnvSliceWithDefault(templates, envs) -} - -// expandEnvSliceWithDefault expands template variables in a slice with optional default for {} -func expandEnvSliceWithDefault(templates []string, envs map[string]string, defaultValue ...string) []string { - if len(templates) == 0 { - return templates - } - - result := make([]string, len(templates)) - for i, template := range templates { - result[i] = expandEnvWithDefault(template, envs, defaultValue...) - } - return result -} - // buildEnvMap creates a map of template variables for the current context func buildEnvMap(llgoRoot string) map[string]string { envs := make(map[string]string) @@ -474,8 +420,8 @@ func use(goos, goarch string, wasiThreads, forceEspClang bool) (export Export, e return } -// useTarget loads configuration from a target name (e.g., "rp2040", "wasi") -func useTarget(targetName string) (export Export, err error) { +// UseTarget loads configuration from a target name (e.g., "rp2040", "wasi") +func UseTarget(targetName string) (export Export, err error) { resolver := targets.NewDefaultResolver() config, err := resolver.Resolve(targetName) @@ -510,6 +456,27 @@ func useTarget(targetName string) (export Export, err error) { export.ExtraFiles = config.ExtraFiles export.BinaryFormat = config.BinaryFormat export.FormatDetail = config.FormatDetail() + export.Emulator = config.Emulator + + // Set flashing/debugging configuration + export.Device = flash.Device{ + Serial: config.Serial, + SerialPort: config.SerialPort, + Flash: flash.Flash{ + Method: config.FlashMethod, + Command: config.FlashCommand, + Flash1200BpsReset: config.Flash1200BpsReset == "true", + }, + MSD: flash.MSD{ + VolumeName: config.MSDVolumeName, + FirmwareName: config.MSDFirmwareName, + }, + OpenOCD: flash.OpenOCD{ + Interface: config.OpenOCDInterface, + Transport: config.OpenOCDTransport, + Target: config.OpenOCDTarget, + }, + } // Build environment map for template variable expansion envs := buildEnvMap(env.LLGoROOT()) @@ -524,7 +491,7 @@ func useTarget(targetName string) (export Export, err error) { ccflags = append(ccflags, "--target="+config.LLVMTarget) } // Expand template variables in cflags - expandedCFlags := expandEnvSlice(config.CFlags, envs) + expandedCFlags := env.ExpandEnvSlice(config.CFlags, envs) cflags = append(cflags, expandedCFlags...) // The following parameters are inspired by tinygo/builder/library.go @@ -670,7 +637,7 @@ func useTarget(targetName string) (export Export, err error) { // Combine with config flags and expand template variables export.CFLAGS = cflags export.CCFLAGS = ccflags - expandedLDFlags := expandEnvSlice(config.LDFlags, envs) + expandedLDFlags := env.ExpandEnvSlice(config.LDFlags, envs) export.LDFLAGS = append(ldflags, expandedLDFlags...) return export, nil @@ -680,7 +647,7 @@ func useTarget(targetName string) (export Export, err error) { // If targetName is provided, it takes precedence over goos/goarch func Use(goos, goarch, targetName string, wasiThreads, forceEspClang bool) (export Export, err error) { if targetName != "" && !strings.HasPrefix(targetName, "wasm") && !strings.HasPrefix(targetName, "wasi") { - return useTarget(targetName) + return UseTarget(targetName) } return use(goos, goarch, wasiThreads, forceEspClang) } diff --git a/internal/crosscompile/crosscompile_test.go b/internal/crosscompile/crosscompile_test.go index af323553..9856133c 100644 --- a/internal/crosscompile/crosscompile_test.go +++ b/internal/crosscompile/crosscompile_test.go @@ -214,7 +214,7 @@ func TestUseTarget(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - export, err := useTarget(tc.targetName) + export, err := UseTarget(tc.targetName) if tc.expectError { if err == nil { @@ -302,126 +302,3 @@ func TestUseWithTarget(t *testing.T) { t.Error("Expected LDFLAGS to be set for native build") } } - -func TestExpandEnv(t *testing.T) { - envs := map[string]string{ - "port": "/dev/ttyUSB0", - "hex": "firmware.hex", - "bin": "firmware.bin", - "root": "/usr/local/llgo", - } - - tests := []struct { - template string - expected string - }{ - { - "avrdude -c arduino -p atmega328p -P {port} -U flash:w:{hex}:i", - "avrdude -c arduino -p atmega328p -P /dev/ttyUSB0 -U flash:w:firmware.hex:i", - }, - { - "simavr -m atmega328p -f 16000000 {}", - "simavr -m atmega328p -f 16000000 firmware.hex", // {} expands to hex (first priority) - }, - { - "-I{root}/lib/CMSIS/CMSIS/Include", - "-I/usr/local/llgo/lib/CMSIS/CMSIS/Include", - }, - { - "no variables here", - "no variables here", - }, - { - "", - "", - }, - } - - for _, test := range tests { - result := expandEnv(test.template, envs) - if result != test.expected { - t.Errorf("expandEnv(%q) = %q, want %q", test.template, result, test.expected) - } - } -} - -func TestExpandEnvSlice(t *testing.T) { - envs := map[string]string{ - "root": "/usr/local/llgo", - "port": "/dev/ttyUSB0", - } - - input := []string{ - "-I{root}/include", - "-DPORT={port}", - "static-flag", - } - - expected := []string{ - "-I/usr/local/llgo/include", - "-DPORT=/dev/ttyUSB0", - "static-flag", - } - - result := expandEnvSlice(input, envs) - - if len(result) != len(expected) { - t.Fatalf("expandEnvSlice length mismatch: got %d, want %d", len(result), len(expected)) - } - - for i, exp := range expected { - if result[i] != exp { - t.Errorf("expandEnvSlice[%d] = %q, want %q", i, result[i], exp) - } - } -} - -func TestExpandEnvWithDefault(t *testing.T) { - envs := map[string]string{ - "port": "/dev/ttyUSB0", - "hex": "firmware.hex", - "bin": "firmware.bin", - "img": "image.img", - } - - tests := []struct { - template string - defaultValue string - expected string - }{ - { - "simavr {}", - "", // No default - should use hex (priority) - "simavr firmware.hex", - }, - { - "simavr {}", - "custom.elf", // Explicit default - "simavr custom.elf", - }, - { - "qemu -kernel {}", - "vmlinux", // Custom kernel - "qemu -kernel vmlinux", - }, - { - "no braces here", - "ignored", - "no braces here", - }, - } - - for i, test := range tests { - var result string - if test.defaultValue == "" { - result = expandEnvWithDefault(test.template, envs) - } else { - result = expandEnvWithDefault(test.template, envs, test.defaultValue) - } - - if result != test.expected { - t.Errorf("Test %d: expandEnvWithDefault(%q, envs, %q) = %q, want %q", - i, test.template, test.defaultValue, result, test.expected) - } - } -} diff --git a/internal/env/utils.go b/internal/env/utils.go new file mode 100644 index 00000000..ec3d4f3e --- /dev/null +++ b/internal/env/utils.go @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package env + +import "strings" + +// ExpandEnvWithDefault expands template variables with optional default for {} +func ExpandEnvWithDefault(template string, envs map[string]string, defaultValue ...string) string { + if template == "" { + return "" + } + + result := template + + // Handle special case of {} - use provided default or first available file variable + if strings.Contains(result, "{}") { + defaultVal := "" + if len(defaultValue) > 0 && defaultValue[0] != "" { + defaultVal = defaultValue[0] + } + result = strings.ReplaceAll(result, "{}", defaultVal) + } + + // Replace named variables + for key, value := range envs { + if key != "" { // Skip empty key used for {} default + result = strings.ReplaceAll(result, "{"+key+"}", value) + } + } + return result +} + +// ExpandEnvSlice expands template variables in a slice of strings +func ExpandEnvSlice(templates []string, envs map[string]string) []string { + return ExpandEnvSliceWithDefault(templates, envs) +} + +// ExpandEnvSliceWithDefault expands template variables in a slice with optional default for {} +func ExpandEnvSliceWithDefault(templates []string, envs map[string]string, defaultValue ...string) []string { + if len(templates) == 0 { + return templates + } + + result := make([]string, len(templates)) + for i, template := range templates { + result[i] = ExpandEnvWithDefault(template, envs, defaultValue...) + } + return result +} diff --git a/internal/env/utils_test.go b/internal/env/utils_test.go new file mode 100644 index 00000000..e12297f2 --- /dev/null +++ b/internal/env/utils_test.go @@ -0,0 +1,84 @@ +package env + +import "testing" + +func TestExpandEnvSlice(t *testing.T) { + envs := map[string]string{ + "root": "/usr/local/llgo", + "port": "/dev/ttyUSB0", + } + + input := []string{ + "-I{root}/include", + "-DPORT={port}", + "static-flag", + } + + expected := []string{ + "-I/usr/local/llgo/include", + "-DPORT=/dev/ttyUSB0", + "static-flag", + } + + result := ExpandEnvSlice(input, envs) + + if len(result) != len(expected) { + t.Fatalf("expandEnvSlice length mismatch: got %d, want %d", len(result), len(expected)) + } + + for i, exp := range expected { + if result[i] != exp { + t.Errorf("expandEnvSlice[%d] = %q, want %q", i, result[i], exp) + } + } +} + +func TestExpandEnvWithDefault(t *testing.T) { + envs := map[string]string{ + "port": "/dev/ttyUSB0", + "hex": "firmware.hex", + "bin": "firmware.bin", + "img": "image.img", + } + + tests := []struct { + template string + defaultValue string + expected string + }{ + { + "simavr {}", + "firmware.hex", + "simavr firmware.hex", + }, + { + "simavr {}", + "custom.elf", // Explicit default + "simavr custom.elf", + }, + { + "qemu -kernel {}", + "vmlinux", // Custom kernel + "qemu -kernel vmlinux", + }, + { + "no braces here", + "ignored", + "no braces here", + }, + } + + for i, test := range tests { + var result string + if test.defaultValue == "" { + result = ExpandEnvWithDefault(test.template, envs) + } else { + result = ExpandEnvWithDefault(test.template, envs, test.defaultValue) + } + + if result != test.expected { + t.Errorf("Test %d: expandEnvWithDefault(%q, envs, %q) = %q, want %q", + i, test.template, test.defaultValue, result, test.expected) + } + } +} diff --git a/internal/firmware/env.go b/internal/firmware/env.go new file mode 100644 index 00000000..6b6db553 --- /dev/null +++ b/internal/firmware/env.go @@ -0,0 +1,16 @@ +package firmware + +import "strings" + +// BinaryFormatToEnvName returns the environment variable name based on the binary format +// Returns the format name for template expansion (e.g., "bin", "uf2", "zip") +func BinaryFormatToEnvName(binaryFormat string) string { + if strings.HasPrefix(binaryFormat, "esp") { + return "bin" + } else if strings.HasPrefix(binaryFormat, "uf2") { + return "uf2" + } else if strings.HasPrefix(binaryFormat, "nrf-dfu") { + return "zip" + } + return "" +} diff --git a/internal/firmware/ext.go b/internal/firmware/ext.go deleted file mode 100644 index 3a90ca97..00000000 --- a/internal/firmware/ext.go +++ /dev/null @@ -1,16 +0,0 @@ -package firmware - -import "strings" - -// BinaryExt returns the binary file extension based on the binary format -// Returns ".bin" for ESP-based formats, "" for others -func BinaryExt(binaryFormat string) string { - if strings.HasPrefix(binaryFormat, "esp") { - return ".bin" - } else if strings.HasPrefix(binaryFormat, "uf2") { - return ".uf2" - } else if strings.HasPrefix(binaryFormat, "nrf-dfu") { - return ".zip" - } - return "" -} diff --git a/internal/firmware/ext_test.go b/internal/firmware/ext_test.go deleted file mode 100644 index cfbe1f13..00000000 --- a/internal/firmware/ext_test.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build !llgo -// +build !llgo - -package firmware - -import "testing" - -func TestBinaryExt(t *testing.T) { - tests := []struct { - name string - binaryFormat string - expected string - }{ - {"ESP32", "esp32", ".bin"}, - {"ESP8266", "esp8266", ".bin"}, - {"ESP32C3", "esp32c3", ".bin"}, - {"UF2", "uf2", ".uf2"}, - {"ELF", "elf", ""}, - {"Empty", "", ""}, - {"NRF-DFU", "nrf-dfu", ".zip"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := BinaryExt(tt.binaryFormat) - if result != tt.expected { - t.Errorf("BinaryExt() = %q, want %q", result, tt.expected) - } - }) - } -} diff --git a/internal/firmware/firmware.go b/internal/firmware/firmware.go index bbe42e4e..b5da6f6b 100644 --- a/internal/firmware/firmware.go +++ b/internal/firmware/firmware.go @@ -2,11 +2,13 @@ package firmware import ( "fmt" + "os" "strings" ) -// MakeFirmwareImage creates a firmware image from the given input file. -func MakeFirmwareImage(infile, outfile, format, fmtDetail string) error { +// makeFirmwareImage creates a firmware image from the given input file. +func makeFirmwareImage(infile, outfile, format, fmtDetail string) error { + fmt.Fprintf(os.Stderr, "Generating firmware image: %s -> %s (format: %s, detail: %s)\n", infile, outfile, format, fmtDetail) if strings.HasPrefix(format, "esp") { return makeESPFirmareImage(infile, outfile, format) } else if format == "uf2" { @@ -17,3 +19,65 @@ func MakeFirmwareImage(infile, outfile, format, fmtDetail string) error { } return fmt.Errorf("unsupported firmware format: %s", format) } + +// ExtractFileFormatFromCommand extracts file format from command template +// Returns the format if found (e.g. "bin", "hex", "zip", "img"), empty string if not found +func ExtractFileFormatFromCommand(cmd string) string { + formats := []string{"bin", "hex", "zip", "img", "uf2"} + for _, format := range formats { + if strings.Contains(cmd, "{"+format+"}") { + return format + } + } + return "" +} + +// ConvertFormats processes format conversions for embedded targets only +func ConvertFormats(binFmt, fmtDetail string, envMap map[string]string) error { + var err error + // Convert to bin format first (needed for img) + if envMap["bin"] != "" { + if strings.HasPrefix(binFmt, "esp") { + err = makeFirmwareImage(envMap["out"], envMap["bin"], binFmt, fmtDetail) + } else { + err = objcopy(envMap["out"], envMap["bin"], "bin") + } + if err != nil { + return fmt.Errorf("failed to convert to bin format: %w", err) + } + } + + // Convert to hex format + if envMap["hex"] != "" { + err := objcopy(envMap["out"], envMap["hex"], "hex") + if err != nil { + return fmt.Errorf("failed to convert to hex format: %w", err) + } + } + + // Convert to img format + if envMap["img"] != "" { + err = makeFirmwareImage(envMap["out"], envMap["img"], binFmt+"-img", fmtDetail) + if err != nil { + return fmt.Errorf("failed to convert to img format: %w", err) + } + } + + // Convert to uf2 format + if envMap["uf2"] != "" { + err := makeFirmwareImage(envMap["out"], envMap["uf2"], binFmt, fmtDetail) + if err != nil { + return fmt.Errorf("failed to convert to uf2 format: %w", err) + } + } + + // Convert to zip format + if envMap["zip"] != "" { + err := makeFirmwareImage(envMap["out"], envMap["zip"], binFmt, fmtDetail) + if err != nil { + return fmt.Errorf("failed to convert to zip format: %w", err) + } + } + + return nil +} diff --git a/internal/firmware/objcopy.go b/internal/firmware/objcopy.go index e163f41f..c301f75a 100644 --- a/internal/firmware/objcopy.go +++ b/internal/firmware/objcopy.go @@ -4,9 +4,12 @@ package firmware import ( "debug/elf" + "fmt" "io" "os" "sort" + + "github.com/marcinbor85/gohex" ) // maxPadBytes is the maximum allowed bytes to be padded in a rom extraction @@ -105,6 +108,7 @@ func extractROM(path string) (uint64, []byte, error) { } } +// From tinygo/builder/builder/objcopy.go objcopy // objcopy converts an ELF file to a different (simpler) output file format: // .bin or .hex. It extracts only the .text section. func objcopy(infile, outfile, binaryFormat string) error { @@ -115,14 +119,24 @@ func objcopy(infile, outfile, binaryFormat string) error { defer f.Close() // Read the .text segment. - _, data, err := extractROM(infile) + addr, data, err := extractROM(infile) if err != nil { return err } // Write to the file, in the correct format. switch binaryFormat { + case "hex": + fmt.Fprintf(os.Stderr, "Converting firmware format: %s -> %s (intel hex format: %s)\n", infile, outfile, binaryFormat) + // Intel hex file, includes the firmware start address. + mem := gohex.NewMemory() + err := mem.AddBinary(uint32(addr), data) + if err != nil { + return objcopyError{"failed to create .hex file", err} + } + return mem.DumpIntelHex(f, 16) case "bin": + fmt.Fprintf(os.Stderr, "Converting firmware format: %s -> %s (format: %s)\n", infile, outfile, binaryFormat) // The start address is not stored in raw firmware files (therefore you // should use .hex files in most cases). _, err := f.Write(data) diff --git a/internal/flash/flash.go b/internal/flash/flash.go new file mode 100644 index 00000000..281686bd --- /dev/null +++ b/internal/flash/flash.go @@ -0,0 +1,440 @@ +package flash + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/goplus/llgo/internal/env" + "github.com/goplus/llgo/internal/shellparse" + "go.bug.st/serial" + "go.bug.st/serial/enumerator" +) + +// Device contains all flashing/debugging configuration for a device +type Device struct { + Serial string // Serial communication settings + SerialPort []string // Available serial ports + Flash Flash // Flash configuration for device programming + MSD MSD // Mass Storage Device configuration + OpenOCD OpenOCD // OpenOCD configuration for debugging/flashing +} + +// Flash contains configuration for device flashing +type Flash struct { + Method string // Flash method: "command", "openocd", "msd", "bmp" + Command string // Flash command template + Flash1200BpsReset bool // Whether to use 1200bps reset +} + +// MSD contains configuration for Mass Storage Device flashing +type MSD struct { + VolumeName []string // Names of the volumes + FirmwareName string // Firmware file name pattern +} + +// OpenOCD contains configuration for OpenOCD debugging/flashing +type OpenOCD struct { + Interface string // Interface configuration (e.g., "stlink") + Transport string // Transport protocol (e.g., "swd", "jtag") + Target string // Target configuration (e.g., "stm32f4x") +} + +// From tinygo/main.go getDefaultPort +// GetPort returns the default serial port depending on the operating system and USB interfaces. +func GetPort(portFlag string, usbInterfaces []string) (string, error) { + portCandidates := strings.FieldsFunc(portFlag, func(c rune) bool { return c == ',' }) + if len(portCandidates) == 1 { + return portCandidates[0], nil + } + + var ports []string + var err error + switch runtime.GOOS { + case "freebsd": + ports, err = filepath.Glob("/dev/cuaU*") + case "darwin", "linux", "windows": + var portsList []*enumerator.PortDetails + portsList, err = enumerator.GetDetailedPortsList() + if err != nil { + return "", err + } + + var preferredPortIDs [][2]uint16 + for _, s := range usbInterfaces { + parts := strings.Split(s, ":") + if len(parts) != 2 { + return "", fmt.Errorf("could not parse USB VID/PID pair %q", s) + } + vid, err := strconv.ParseUint(parts[0], 16, 16) + if err != nil { + return "", fmt.Errorf("could not parse USB vendor ID %q: %w", parts[0], err) + } + pid, err := strconv.ParseUint(parts[1], 16, 16) + if err != nil { + return "", fmt.Errorf("could not parse USB product ID %q: %w", parts[1], err) + } + preferredPortIDs = append(preferredPortIDs, [2]uint16{uint16(vid), uint16(pid)}) + } + + var primaryPorts []string // ports picked from preferred USB VID/PID + var secondaryPorts []string // other ports (as a fallback) + for _, p := range portsList { + if !p.IsUSB { + continue + } + if p.VID != "" && p.PID != "" { + foundPort := false + vid, vidErr := strconv.ParseUint(p.VID, 16, 16) + pid, pidErr := strconv.ParseUint(p.PID, 16, 16) + if vidErr == nil && pidErr == nil { + for _, id := range preferredPortIDs { + if uint16(vid) == id[0] && uint16(pid) == id[1] { + primaryPorts = append(primaryPorts, p.Name) + foundPort = true + break + } + } + } + if foundPort { + continue + } + } + + secondaryPorts = append(secondaryPorts, p.Name) + } + if len(primaryPorts) == 1 { + return primaryPorts[0], nil + } else if len(primaryPorts) > 1 { + ports = primaryPorts + } else { + ports = secondaryPorts + } + default: + return "", errors.New("unable to search for a default USB device to be flashed on this OS") + } + + if err != nil { + return "", err + } else if ports == nil { + return "", errors.New("unable to locate a serial port") + } else if len(ports) == 0 { + return "", errors.New("no serial ports available") + } + + if len(portCandidates) == 0 { + if len(usbInterfaces) > 0 { + return "", errors.New("unable to search for a default USB device - use -port flag, available ports are " + strings.Join(ports, ", ")) + } else if len(ports) == 1 { + return ports[0], nil + } else { + return "", errors.New("multiple serial ports available - use -port flag, available ports are " + strings.Join(ports, ", ")) + } + } + + for _, ps := range portCandidates { + for _, p := range ports { + if p == ps { + return p, nil + } + } + } + + return "", errors.New("port you specified '" + strings.Join(portCandidates, ",") + "' does not exist, available ports are " + strings.Join(ports, ", ")) +} + +// From tinygo/main.go touchSerialPortAt1200bps +// touchSerialPortAt1200bps triggers Arduino-compatible devices to enter bootloader mode. +// This function implements the Arduino auto-reset mechanism used before flashing firmware. +// +// Working principle: +// 1. Opens serial port at 1200 baud rate (special reset baudrate for Arduino) +// 2. Sets DTR (Data Terminal Ready) signal to false +// 3. This triggers the device to reset and enter bootloader mode for firmware upload +// +// Usage scenarios: +// - Required for Arduino Uno, Leonardo, Micro and other compatible devices +// - Executed when target config has "flash-1200-bps-reset": "true" +// - Ensures device is in correct state to receive new firmware +// +// Retry mechanism: +// - Retries up to 3 times due to potential temporary serial port access issues +// - Windows special handling: InvalidSerialPort error during bootloader transition is normal +func touchSerialPortAt1200bps(port string) (err error) { + retryCount := 3 + for i := 0; i < retryCount; i++ { + // Open port at 1200bps to trigger Arduino reset + p, e := serial.Open(port, &serial.Mode{BaudRate: 1200}) + if e != nil { + if runtime.GOOS == `windows` { + se, ok := e.(*serial.PortError) + if ok && se.Code() == serial.InvalidSerialPort { + // InvalidSerialPort error occurs when transitioning to boot + return nil + } + } + time.Sleep(1 * time.Second) + err = e + continue + } + defer p.Close() + + // Set DTR to false to trigger reset + p.SetDTR(false) + return nil + } + return fmt.Errorf("opening port: %s", err) +} + +// FlashDevice flashes firmware to a device based on the device configuration +func FlashDevice(device Device, envMap map[string]string, port string, verbose bool) error { + method := device.Flash.Method + if method == "" { + method = "command" + } + + // Resolve port for methods that need it (all except openocd) + if method != "openocd" { + var err error + port, err = GetPort(port, device.SerialPort) + if err != nil { + return fmt.Errorf("failed to find port: %w", err) + } + } + + if verbose { + fmt.Fprintf(os.Stderr, "Flashing using method: %s\n", method) + fmt.Fprintf(os.Stderr, "Using port: %s\n", port) + } + + // Execute 1200bps reset before flashing if needed (except for openocd) + if method != "openocd" && device.Flash.Flash1200BpsReset { + if verbose { + fmt.Fprintf(os.Stderr, "Triggering 1200bps reset on port: %s\n", port) + } + if err := touchSerialPortAt1200bps(port); err != nil { + return fmt.Errorf("failed to trigger 1200bps reset: %w", err) + } + // Wait a bit for device to enter bootloader mode + time.Sleep(2 * time.Second) + } + + switch method { + case "command": + return flashCommand(device.Flash, envMap, port, verbose) + case "openocd": + return flashOpenOCD(device.OpenOCD, envMap, verbose) + case "msd": + return flashMSD(device.MSD, envMap, verbose) + case "bmp": + return flashBMP(envMap, port, verbose) + default: + return fmt.Errorf("unsupported flash method: %s", method) + } +} + +// flashCommand handles command-based flashing +func flashCommand(flash Flash, envMap map[string]string, port string, verbose bool) error { + if flash.Command == "" { + return fmt.Errorf("flash command not specified") + } + + // Build environment map for template variable expansion + envs := buildFlashEnvMap(envMap, port) + + // Expand template variables in command + expandedCommand := env.ExpandEnvWithDefault(flash.Command, envs) + + if verbose { + fmt.Fprintf(os.Stderr, "Flash command: %s\n", expandedCommand) + } + + // Split command into parts for exec - safely handling quoted arguments + parts, err := shellparse.Parse(expandedCommand) + if err != nil { + return fmt.Errorf("failed to parse flash command: %w", err) + } + if len(parts) == 0 { + return fmt.Errorf("empty flash command after expansion") + } + + // Execute flash command + cmd := exec.Command(parts[0], parts[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// flashOpenOCD handles OpenOCD-based flashing +func flashOpenOCD(openocd OpenOCD, envMap map[string]string, verbose bool) error { + if openocd.Interface == "" { + return fmt.Errorf("OpenOCD interface not specified") + } + + args := []string{ + "-f", "interface/" + openocd.Interface + ".cfg", + } + + if openocd.Transport != "" { + args = append(args, "-c", "transport select "+openocd.Transport) + } + + if openocd.Target != "" { + args = append(args, "-f", "target/"+openocd.Target+".cfg") + } + + // Add programming commands + args = append(args, + "-c", "init", + "-c", "reset init", + "-c", fmt.Sprintf("flash write_image erase %s", envMap["elf"]), + "-c", "reset", + "-c", "shutdown", + ) + + if verbose { + fmt.Fprintf(os.Stderr, "OpenOCD command: openocd %s\n", strings.Join(args, " ")) + } + + cmd := exec.Command("openocd", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// flashMSD handles Mass Storage Device flashing +func flashMSD(msd MSD, envMap map[string]string, verbose bool) error { + if len(msd.VolumeName) == 0 { + return fmt.Errorf("MSD volume names not specified") + } + + if msd.FirmwareName == "" { + return fmt.Errorf("MSD firmware name not specified") + } + + // Find the MSD volume + var mountPoint string + for _, volumeName := range msd.VolumeName { + // Try platform-specific mount points + var candidates []string + switch runtime.GOOS { + case "darwin": + candidates = []string{ + filepath.Join("/Volumes", volumeName), + } + case "linux": + candidates = []string{ + filepath.Join("/media", os.Getenv("USER"), volumeName), + filepath.Join("/mnt", volumeName), + } + case "windows": + candidates = []string{ + volumeName + ":", + } + default: + candidates = []string{ + filepath.Join("/Volumes", volumeName), // macOS + filepath.Join("/media", os.Getenv("USER"), volumeName), // Linux + filepath.Join("/mnt", volumeName), // Linux alternative + volumeName + ":", // Windows + } + } + + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + mountPoint = candidate + break + } + } + if mountPoint != "" { + break + } + } + + if mountPoint == "" { + return fmt.Errorf("MSD volume not found. Expected volumes: %v", msd.VolumeName) + } + + // Copy firmware to MSD + destPath := filepath.Join(mountPoint, msd.FirmwareName) + + if verbose { + fmt.Fprintf(os.Stderr, "Copying %s to %s\n", envMap["uf2"], destPath) + } + + return copyFile(envMap["uf2"], destPath) +} + +// flashBMP handles Black Magic Probe flashing +func flashBMP(envMap map[string]string, port string, verbose bool) error { + // BMP typically uses GDB for flashing + args := []string{ + "-ex", "target extended-remote " + port, + "-ex", "monitor swdp_scan", + "-ex", "attach 1", + "-ex", "load", + "-ex", "compare-sections", + "-ex", "kill", + "-ex", "quit", + envMap["elf"], + } + + if verbose { + fmt.Fprintf(os.Stderr, "BMP command: arm-none-eabi-gdb %s\n", strings.Join(args, " ")) + } + + cmd := exec.Command("arm-none-eabi-gdb", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// buildFlashEnvMap creates environment map for template expansion +func buildFlashEnvMap(envMap map[string]string, port string) map[string]string { + envs := make(map[string]string) + + // Basic paths + envs["root"] = env.LLGoROOT() + envs["tmpDir"] = os.TempDir() + + // Port information + if port != "" { + envs["port"] = port + } + + // Copy all format paths from envMap + for key, value := range envMap { + if value != "" { + envs[key] = value + } + } + + return envs +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = destFile.ReadFrom(sourceFile) + return err +} diff --git a/internal/llgen/llgenf.go b/internal/llgen/llgenf.go index 4ac61b50..2dc23f6f 100644 --- a/internal/llgen/llgenf.go +++ b/internal/llgen/llgenf.go @@ -20,7 +20,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "github.com/goplus/llgo/internal/build" @@ -48,7 +47,6 @@ func genFrom(pkgPath string, abiMode build.AbiMode) (build.Package, error) { conf := &build.Config{ Mode: build.ModeGen, AbiMode: abiMode, - AppExt: build.DefaultAppExt(runtime.GOOS), } pkgs, err := build.Do([]string{pkgPath}, conf) if err != nil { diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 00000000..4b205b54 --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,280 @@ +package monitor + +import ( + "debug/dwarf" + "debug/elf" + "debug/macho" + "debug/pe" + "errors" + "fmt" + "go/token" + "io" + "os" + "os/signal" + "regexp" + "strconv" + "time" + + "github.com/goplus/llgo/internal/flash" + "github.com/mattn/go-tty" + "go.bug.st/serial" +) + +// MonitorConfig contains configuration for the monitor +type MonitorConfig struct { + Port string // Serial port device + Target string // Target name for crosscompile config + BaudRate int // Baudrate of serial monitor + Executable string // Optional path to executable for debug info + WaitTime int // Wait time for port connection (ms) + SerialPort []string // List of serial ports to use +} + +// Monitor starts serial monitoring with the given configuration +func Monitor(config MonitorConfig, verbose bool) error { + // Set defaults + if config.BaudRate == 0 { + config.BaudRate = 115200 + } + if config.WaitTime == 0 { + config.WaitTime = 300 + } + + // Resolve port using flash.GetPort + port, err := flash.GetPort(config.Port, config.SerialPort) + if err != nil { + return fmt.Errorf("failed to find port: %w", err) + } + config.Port = port + + if verbose { + fmt.Fprintf(os.Stderr, "Connecting to %s at %d baud\n", config.Port, config.BaudRate) + } + + // Open serial port with retry + var serialConn serial.Port + for i := 0; i <= config.WaitTime; i++ { + serialConn, err = serial.Open(config.Port, &serial.Mode{BaudRate: config.BaudRate}) + if err != nil { + if i < config.WaitTime { + time.Sleep(10 * time.Millisecond) + continue + } + return fmt.Errorf("failed to open port %s: %w", config.Port, err) + } + break + } + defer serialConn.Close() + + // Open TTY for input + tty, err := tty.Open() + if err != nil { + return fmt.Errorf("failed to open TTY: %w", err) + } + defer tty.Close() + + // Setup signal handling + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + defer signal.Stop(sig) + + // Create output writer with optional debug info + var writer *outputWriter + if config.Executable != "" { + writer = newOutputWriter(os.Stdout, config.Executable) + } else { + writer = newOutputWriter(os.Stdout, "") + } + + fmt.Printf("Connected to %s. Press Ctrl-C to exit.\n", config.Port) + + errCh := make(chan error, 2) + + // Goroutine for reading from serial port + go func() { + buf := make([]byte, 100*1024) + for { + n, err := serialConn.Read(buf) + if err != nil { + errCh <- fmt.Errorf("serial read error: %w", err) + return + } + writer.Write(buf[:n]) + } + }() + + // Goroutine for reading from TTY and writing to serial port + go func() { + for { + r, err := tty.ReadRune() + if err != nil { + errCh <- fmt.Errorf("TTY read error: %w", err) + return + } + if r == 0 { + continue + } + serialConn.Write([]byte(string(r))) + } + }() + + // Wait for signal or error + select { + case <-sig: + if verbose { + fmt.Fprintf(os.Stderr, "\nDisconnected from %s\n", config.Port) + } + return nil + case err := <-errCh: + return err + } +} + +var addressMatch = regexp.MustCompile(`^panic: runtime error at 0x([0-9a-f]+): `) + +// Extract the address from the "panic: runtime error at" message. +func extractPanicAddress(line []byte) uint64 { + matches := addressMatch.FindSubmatch(line) + if matches != nil { + address, err := strconv.ParseUint(string(matches[1]), 16, 64) + if err == nil { + return address + } + } + return 0 +} + +// Convert an address in the binary to a source address location. +func addressToLine(executable string, address uint64) (token.Position, error) { + data, err := readDWARF(executable) + if err != nil { + return token.Position{}, err + } + r := data.Reader() + + for { + e, err := r.Next() + if err != nil { + return token.Position{}, err + } + if e == nil { + break + } + switch e.Tag { + case dwarf.TagCompileUnit: + r.SkipChildren() + lr, err := data.LineReader(e) + if err != nil { + return token.Position{}, err + } + var lineEntry = dwarf.LineEntry{ + EndSequence: true, + } + for { + // Read the next .debug_line entry. + prevLineEntry := lineEntry + err := lr.Next(&lineEntry) + if err != nil { + if err == io.EOF { + break + } + return token.Position{}, err + } + + if prevLineEntry.EndSequence && lineEntry.Address == 0 { + // Tombstone value. This symbol has been removed, for + // example by the --gc-sections linker flag. It is still + // here in the debug information because the linker can't + // just remove this reference. + // Read until the next EndSequence so that this sequence is + // skipped. + // For more details, see (among others): + // https://reviews.llvm.org/D84825 + for { + err := lr.Next(&lineEntry) + if err != nil { + return token.Position{}, err + } + if lineEntry.EndSequence { + break + } + } + } + + if !prevLineEntry.EndSequence { + // The chunk describes the code from prevLineEntry to + // lineEntry. + if prevLineEntry.Address <= address && lineEntry.Address > address { + return token.Position{ + Filename: prevLineEntry.File.Name, + Line: prevLineEntry.Line, + Column: prevLineEntry.Column, + }, nil + } + } + } + } + } + + return token.Position{}, nil // location not found +} + +// Read the DWARF debug information from a given file (in various formats). +func readDWARF(executable string) (*dwarf.Data, error) { + f, err := os.Open(executable) + if err != nil { + return nil, err + } + defer f.Close() + + if file, err := elf.NewFile(f); err == nil { + return file.DWARF() + } else if file, err := macho.NewFile(f); err == nil { + return file.DWARF() + } else if file, err := pe.NewFile(f); err == nil { + return file.DWARF() + } else { + return nil, errors.New("unknown binary format") + } +} + +type outputWriter struct { + out io.Writer + executable string + line []byte +} + +// newOutputWriter returns an io.Writer that will intercept panic addresses and +// will try to insert a source location in the output if the source location can +// be found in the executable. +func newOutputWriter(out io.Writer, executable string) *outputWriter { + return &outputWriter{ + out: out, + executable: executable, + } +} + +func (w *outputWriter) Write(p []byte) (n int, err error) { + start := 0 + for i, c := range p { + if c == '\n' { + w.out.Write(p[start : i+1]) + start = i + 1 + if w.executable != "" { + address := extractPanicAddress(w.line) + if address != 0 { + loc, err := addressToLine(w.executable, address) + if err == nil && loc.Filename != "" { + fmt.Printf("[llgo: panic at %s]\n", loc.String()) + } + } + } + w.line = w.line[:0] + } else { + w.line = append(w.line, c) + } + } + w.out.Write(p[start:]) + n = len(p) + return +} diff --git a/internal/shellparse/shellparse.go b/internal/shellparse/shellparse.go new file mode 100644 index 00000000..ab13d9d3 --- /dev/null +++ b/internal/shellparse/shellparse.go @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package shellparse + +import ( + "fmt" + "strings" + "unicode" +) + +// Parse parses a shell command string into command and arguments, +// properly handling quoted arguments with spaces +func Parse(cmd string) ([]string, error) { + args := make([]string, 0) + var current strings.Builder + var inQuotes bool + var quoteChar rune + var hasContent bool // Track if we've seen content (including empty quotes) + + runes := []rune(cmd) + for i := 0; i < len(runes); i++ { + r := runes[i] + switch { + case !inQuotes && (r == '"' || r == '\''): + // Start of quoted string + inQuotes = true + quoteChar = r + hasContent = true // Empty quotes still count as content + case inQuotes && r == quoteChar: + // End of quoted string + inQuotes = false + quoteChar = 0 + case !inQuotes && unicode.IsSpace(r): + // Space outside quotes - end current argument + if hasContent { + args = append(args, current.String()) + current.Reset() + hasContent = false + } + case inQuotes && r == '\\' && i+1 < len(runes): + // Handle escape sequences in quotes + if quoteChar == '"' { + next := runes[i+1] + if next == quoteChar || next == '\\' { + current.WriteRune(next) + i++ // Skip the next rune + } else { + current.WriteRune(r) + } + } else { + // In single quotes, backslash is a literal character. + current.WriteRune(r) + } + default: + // Regular character + current.WriteRune(r) + hasContent = true + } + } + + // Handle unterminated quotes + if inQuotes { + return nil, fmt.Errorf("unterminated quote in command: %s", cmd) + } + + // Add final argument if any + if hasContent { + args = append(args, current.String()) + } + + return args, nil +} diff --git a/internal/shellparse/shellparse_test.go b/internal/shellparse/shellparse_test.go new file mode 100644 index 00000000..cb195532 --- /dev/null +++ b/internal/shellparse/shellparse_test.go @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 The GoPlus Authors (goplus.org). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package shellparse + +import ( + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + cmd string + want []string + wantErr bool + }{ + { + name: "simple command", + cmd: "echo hello", + want: []string{"echo", "hello"}, + }, + { + name: "command with quoted argument", + cmd: `echo "hello world"`, + want: []string{"echo", "hello world"}, + }, + { + name: "command with single quoted argument", + cmd: `echo 'hello world'`, + want: []string{"echo", "hello world"}, + }, + { + name: "command with multiple quoted arguments", + cmd: `cp "file with spaces.txt" "another file.txt"`, + want: []string{"cp", "file with spaces.txt", "another file.txt"}, + }, + { + name: "command with escaped quotes", + cmd: `echo "He said \"hello\""`, + want: []string{"echo", `He said "hello"`}, + }, + { + name: "command with escaped backslash", + cmd: `echo "path\\to\\file"`, + want: []string{"echo", `path\to\file`}, + }, + { + name: "mixed quotes", + cmd: `echo "double quote" 'single quote' normal`, + want: []string{"echo", "double quote", "single quote", "normal"}, + }, + { + name: "empty arguments", + cmd: `echo "" ''`, + want: []string{"echo", "", ""}, + }, + { + name: "multiple spaces", + cmd: "echo hello world", + want: []string{"echo", "hello", "world"}, + }, + { + name: "emulator command example", + cmd: `qemu-system-xtensa -machine esp32 -kernel "/path/with spaces/firmware.bin"`, + want: []string{"qemu-system-xtensa", "-machine", "esp32", "-kernel", "/path/with spaces/firmware.bin"}, + }, + { + name: "unterminated double quote", + cmd: `echo "hello`, + wantErr: true, + }, + { + name: "unterminated single quote", + cmd: `echo 'hello`, + wantErr: true, + }, + { + name: "empty command", + cmd: "", + want: []string{}, + }, + { + name: "only spaces", + cmd: " ", + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.cmd) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/targets/config.go b/internal/targets/config.go index d75d1f45..1d56e7d6 100644 --- a/internal/targets/config.go +++ b/internal/targets/config.go @@ -35,10 +35,14 @@ type Config struct { UF2FamilyID string `json:"uf2-family-id"` // Flash and deployment configuration - FlashCommand string `json:"flash-command"` - FlashMethod string `json:"flash-method"` + FlashMethod string `json:"flash-method"` // values: command, openocd, msd + FlashCommand string `json:"flash-command"` // used when FlashMethod == "command" Flash1200BpsReset string `json:"flash-1200-bps-reset"` + // Serial configuration + Serial string `json:"serial"` // Serial communication type (e.g., "usb") + SerialPort []string `json:"serial-port"` // Serial port identifiers (e.g., vendor:product IDs) + // Mass storage device configuration MSDVolumeName []string `json:"msd-volume-name"` MSDFirmwareName string `json:"msd-firmware-name"` diff --git a/internal/targets/example_test.go b/internal/targets/example_test.go index 93bb8bb9..7a82d648 100644 --- a/internal/targets/example_test.go +++ b/internal/targets/example_test.go @@ -1,15 +1,16 @@ -package targets_test +//go:build !llgo +// +build !llgo + +package targets import ( "fmt" "log" "sort" - - "github.com/goplus/llgo/internal/targets" ) func ExampleResolver_Resolve() { - resolver := targets.NewDefaultResolver() + resolver := NewDefaultResolver() // Resolve a specific target config, err := resolver.Resolve("rp2040") @@ -34,7 +35,7 @@ func ExampleResolver_Resolve() { } func ExampleResolver_ListAvailableTargets() { - resolver := targets.NewDefaultResolver() + resolver := NewDefaultResolver() targets, err := resolver.ListAvailableTargets() if err != nil { @@ -50,7 +51,7 @@ func ExampleResolver_ListAvailableTargets() { } func ExampleResolver_ResolveAll() { - resolver := targets.NewDefaultResolver() + resolver := NewDefaultResolver() configs, err := resolver.ResolveAll() if err != nil { diff --git a/internal/targets/loader.go b/internal/targets/loader.go index 95d1e73a..5603ddcd 100644 --- a/internal/targets/loader.go +++ b/internal/targets/loader.go @@ -167,6 +167,9 @@ func (l *Loader) mergeConfig(dst, src *Config) { if src.Flash1200BpsReset != "" { dst.Flash1200BpsReset = src.Flash1200BpsReset } + if src.Serial != "" { + dst.Serial = src.Serial + } if src.MSDFirmwareName != "" { dst.MSDFirmwareName = src.MSDFirmwareName } @@ -202,6 +205,9 @@ func (l *Loader) mergeConfig(dst, src *Config) { if len(src.ExtraFiles) > 0 { dst.ExtraFiles = append(dst.ExtraFiles, src.ExtraFiles...) } + if len(src.SerialPort) > 0 { + dst.SerialPort = append(dst.SerialPort, src.SerialPort...) + } if len(src.MSDVolumeName) > 0 { dst.MSDVolumeName = append(dst.MSDVolumeName, src.MSDVolumeName...) }