From e05c8b9f46e47040332d1bdfb59c387a412ad654 Mon Sep 17 00:00:00 2001 From: Li Jie Date: Tue, 9 Sep 2025 17:29:13 +0800 Subject: [PATCH] Implement llgo build mode support (#1197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BuildMode type with three build modes: exe, c-archive, c-shared - Restrict buildmode flag to llgo build command only (not run/install/test) - Implement build mode specific linker arguments: - c-shared: use -shared -fPIC flags - c-archive: use ar tool to create static archive - exe: default executable mode - Add normalizeOutputPath function for platform-specific file naming conventions - Generate C header files for library modes - Fix buildmode flag conflict by removing from PassArgs - Add comprehensive test coverage for all build modes - Resolve duplicate logic between defaultAppExt and normalizeOutputPath 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/internal/base/pass.go | 2 +- cmd/internal/build/build.go | 6 +- cmd/internal/flags/flags.go | 24 +++- cmd/internal/install/install.go | 5 +- cmd/internal/run/run.go | 5 +- cmd/internal/test/test.go | 8 +- internal/build/build.go | 137 ++++++++++++++---- internal/build/outputs.go | 148 +++++++++++++++----- internal/build/outputs_test.go | 238 ++++++++++++++++++++++++++++++++ 9 files changed, 510 insertions(+), 63 deletions(-) diff --git a/cmd/internal/base/pass.go b/cmd/internal/base/pass.go index c04be72f..5b595cfa 100644 --- a/cmd/internal/base/pass.go +++ b/cmd/internal/base/pass.go @@ -75,7 +75,7 @@ func PassBuildFlags(cmd *Command) *PassArgs { p.Bool("a") p.Bool("linkshared", "race", "msan", "asan", "trimpath", "work") - p.Var("p", "asmflags", "compiler", "buildmode", + p.Var("p", "asmflags", "compiler", "gcflags", "gccgoflags", "installsuffix", "ldflags", "pkgdir", "toolexec", "buildvcs") return p diff --git a/cmd/internal/build/build.go b/cmd/internal/build/build.go index 98d4748c..bcb79dfb 100644 --- a/cmd/internal/build/build.go +++ b/cmd/internal/build/build.go @@ -39,6 +39,7 @@ func init() { flags.AddCommonFlags(&Cmd.Flag) flags.AddBuildFlags(&Cmd.Flag) + flags.AddBuildModeFlags(&Cmd.Flag) flags.AddEmulatorFlags(&Cmd.Flag) flags.AddEmbeddedFlags(&Cmd.Flag) flags.AddOutputFlags(&Cmd.Flag) @@ -51,7 +52,10 @@ func runCmd(cmd *base.Command, args []string) { } conf := build.NewDefaultConf(build.ModeBuild) - flags.UpdateConfig(conf) + if err := flags.UpdateBuildConfig(conf); err != nil { + fmt.Fprintln(os.Stderr, err) + mockable.Exit(1) + } args = cmd.Flag.Args() diff --git a/cmd/internal/flags/flags.go b/cmd/internal/flags/flags.go index 0b27cf93..d9d6256c 100644 --- a/cmd/internal/flags/flags.go +++ b/cmd/internal/flags/flags.go @@ -25,6 +25,7 @@ func AddOutputFlags(fs *flag.FlagSet) { var Verbose bool var BuildEnv string +var BuildMode string var Tags string var Target string var Emulator bool @@ -52,6 +53,10 @@ func AddBuildFlags(fs *flag.FlagSet) { } } +func AddBuildModeFlags(fs *flag.FlagSet) { + fs.StringVar(&BuildMode, "buildmode", "exe", "Build mode (exe, c-archive, c-shared)") +} + var Gen bool func AddEmulatorFlags(fs *flag.FlagSet) { @@ -68,12 +73,13 @@ func AddCmpTestFlags(fs *flag.FlagSet) { fs.BoolVar(&Gen, "gen", false, "Generate llgo.expect file") } -func UpdateConfig(conf *build.Config) { +func UpdateConfig(conf *build.Config) error { conf.Tags = Tags conf.Verbose = Verbose conf.Target = Target conf.Port = Port conf.BaudRate = BaudRate + switch conf.Mode { case build.ModeBuild: conf.OutFile = OutputFile @@ -99,4 +105,20 @@ func UpdateConfig(conf *build.Config) { conf.GenLL = GenLLFiles conf.ForceEspClang = ForceEspClang } + return nil +} + +func UpdateBuildConfig(conf *build.Config) error { + // First apply common config + if err := UpdateConfig(conf); err != nil { + return err + } + + // Validate and set build mode + if err := build.ValidateBuildMode(BuildMode); err != nil { + return err + } + conf.BuildMode = build.BuildMode(BuildMode) + + return nil } diff --git a/cmd/internal/install/install.go b/cmd/internal/install/install.go index 037c4c68..aab36797 100644 --- a/cmd/internal/install/install.go +++ b/cmd/internal/install/install.go @@ -47,7 +47,10 @@ func runCmd(cmd *base.Command, args []string) { } conf := build.NewDefaultConf(build.ModeInstall) - flags.UpdateConfig(conf) + if err := flags.UpdateConfig(conf); err != nil { + fmt.Fprintln(os.Stderr, err) + mockable.Exit(1) + } args = cmd.Flag.Args() _, err := build.Do(args, conf) diff --git a/cmd/internal/run/run.go b/cmd/internal/run/run.go index 68b8ce83..f075fad7 100644 --- a/cmd/internal/run/run.go +++ b/cmd/internal/run/run.go @@ -76,7 +76,10 @@ func runCmdEx(cmd *base.Command, args []string, mode build.Mode) { } conf := build.NewDefaultConf(mode) - flags.UpdateConfig(conf) + if err := flags.UpdateConfig(conf); err != nil { + fmt.Fprintln(os.Stderr, err) + mockable.Exit(1) + } args = cmd.Flag.Args() args, runArgs, err := parseRunArgs(args) diff --git a/cmd/internal/test/test.go b/cmd/internal/test/test.go index 0a8a5a05..f330db0d 100644 --- a/cmd/internal/test/test.go +++ b/cmd/internal/test/test.go @@ -7,6 +7,7 @@ import ( "github.com/goplus/llgo/cmd/internal/base" "github.com/goplus/llgo/cmd/internal/flags" "github.com/goplus/llgo/internal/build" + "github.com/goplus/llgo/internal/mockable" ) // llgo test @@ -30,12 +31,15 @@ func runCmd(cmd *base.Command, args []string) { } conf := build.NewDefaultConf(build.ModeTest) - flags.UpdateConfig(conf) + if err := flags.UpdateConfig(conf); err != nil { + fmt.Fprintln(os.Stderr, err) + mockable.Exit(1) + } args = cmd.Flag.Args() _, err := build.Do(args, conf) if err != nil { fmt.Fprintln(os.Stderr, err) - os.Exit(1) + mockable.Exit(1) } } diff --git a/internal/build/build.go b/internal/build/build.go index 6d85fbec..21e30c6c 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -66,6 +66,24 @@ const ( ModeGen ) +type BuildMode string + +const ( + BuildModeExe BuildMode = "exe" + BuildModeCArchive BuildMode = "c-archive" + BuildModeCShared BuildMode = "c-shared" +) + +// ValidateBuildMode checks if the build mode is valid +func ValidateBuildMode(mode string) error { + switch BuildMode(mode) { + case BuildModeExe, BuildModeCArchive, BuildModeCShared: + return nil + default: + return fmt.Errorf("invalid build mode %q, must be one of: exe, c-archive, c-shared", mode) + } +} + type AbiMode = cabi.Mode const ( @@ -104,6 +122,7 @@ type Config struct { BaudRate int // baudrate for serial communication RunArgs []string Mode Mode + BuildMode BuildMode // Build mode: exe, c-archive, c-shared AbiMode AbiMode GenExpect bool // only valid for ModeCmpTest Verbose bool @@ -136,11 +155,12 @@ func NewDefaultConf(mode Mode) *Config { goarch = runtime.GOARCH } conf := &Config{ - Goos: goos, - Goarch: goarch, - BinPath: bin, - Mode: mode, - AbiMode: cabi.ModeAllFunc, + Goos: goos, + Goarch: goarch, + BinPath: bin, + Mode: mode, + BuildMode: BuildModeExe, + AbiMode: cabi.ModeAllFunc, } return conf } @@ -156,23 +176,6 @@ func envGOPATH() (string, error) { return filepath.Join(home, "go"), nil } -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": - return ".wasm" - } - return "" -} - // ----------------------------------------------------------------------------- const ( @@ -361,12 +364,21 @@ func Do(args []string, conf *Config) ([]Package, error) { return nil, err } - // Link main package using the base output path + // Link main package using the output path from buildOutFmts err = linkMainPkg(ctx, pkg, allPkgs, global, outFmts.Out, verbose) if err != nil { return nil, err } + // Generate C headers for c-archive and c-shared modes before linking + if ctx.buildConf.BuildMode == BuildModeCArchive || ctx.buildConf.BuildMode == BuildModeCShared { + headerErr := generateCHeader(ctx, pkg, outFmts.Out, verbose) + if headerErr != nil { + return nil, headerErr + } + continue + } + envMap := outFmts.ToEnvMap() // Only convert formats when Target is specified @@ -794,13 +806,69 @@ func linkMainPkg(ctx *context, pkg *packages.Package, pkgs []*aPackage, global l } } - return linkObjFiles(ctx, outputPath, objFiles, linkArgs, verbose) + err = linkObjFiles(ctx, outputPath, objFiles, linkArgs, verbose) + if err != nil { + return err + } + + return nil +} + +// TODO(lijie): export C header from function list of the pkg +func generateCHeader(ctx *context, pkg *packages.Package, outputPath string, verbose bool) error { + // Determine header file path + headerPath := strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + ".h" + + // Generate header content + headerContent := fmt.Sprintf(`/* Code generated by llgo; DO NOT EDIT. */ + +#ifndef __%s_H_ +#define __%s_H_ + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* __%s_H_ */ +`, + strings.ToUpper(strings.ReplaceAll(pkg.Name, "-", "_")), + strings.ToUpper(strings.ReplaceAll(pkg.Name, "-", "_")), + strings.ToUpper(strings.ReplaceAll(pkg.Name, "-", "_"))) + + // Write header file + err := os.WriteFile(headerPath, []byte(headerContent), 0644) + if err != nil { + return fmt.Errorf("failed to write header file %s: %w", headerPath, err) + } + + if verbose { + fmt.Fprintf(os.Stderr, "Generated C header: %s\n", headerPath) + } + + return nil } func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose bool) error { + // Handle c-archive mode differently - use ar tool instead of linker + if ctx.buildConf.BuildMode == BuildModeCArchive { + return createStaticArchive(ctx, app, objFiles, verbose) + } + buildArgs := []string{"-o", app} buildArgs = append(buildArgs, linkArgs...) + // Add build mode specific linker arguments + switch ctx.buildConf.BuildMode { + case BuildModeCShared: + buildArgs = append(buildArgs, "-shared", "-fPIC") + case BuildModeExe: + // Default executable mode, no additional flags needed + } + // Add common linker arguments based on target OS and architecture if IsDbgSymsEnabled() { buildArgs = append(buildArgs, "-gdwarf-4") @@ -813,6 +881,27 @@ func linkObjFiles(ctx *context, app string, objFiles, linkArgs []string, verbose return cmd.Link(buildArgs...) } +func createStaticArchive(ctx *context, archivePath string, objFiles []string, verbose bool) error { + // Use ar tool to create static archive + args := []string{"rcs", archivePath} + args = append(args, objFiles...) + + if verbose { + fmt.Fprintf(os.Stderr, "ar %s\n", strings.Join(args, " ")) + } + + cmd := exec.Command("ar", args...) + output, err := cmd.CombinedOutput() + if err != nil { + if verbose && len(output) > 0 { + fmt.Fprintf(os.Stderr, "ar output: %s\n", output) + } + return fmt.Errorf("ar command failed: %w", err) + } + + return nil +} + func isWasmTarget(goos string) bool { return slices.Contains([]string{"wasi", "js", "wasip1"}, goos) } diff --git a/internal/build/outputs.go b/internal/build/outputs.go index 0fc4ab60..75e733ad 100644 --- a/internal/build/outputs.go +++ b/internal/build/outputs.go @@ -47,46 +47,94 @@ func setOutFmt(conf *Config, formatName string) { } // buildOutFmts creates OutFmtDetails based on package, configuration and multi-package status +// determineBaseNameAndDir extracts the base name and directory from configuration +func determineBaseNameAndDir(pkgName string, conf *Config, multiPkg bool) (baseName, dir string) { + switch conf.Mode { + case ModeInstall: + return pkgName, conf.BinPath + case ModeBuild: + if !multiPkg && conf.OutFile != "" { + dir = filepath.Dir(conf.OutFile) + baseName = strings.TrimSuffix(filepath.Base(conf.OutFile), conf.AppExt) + if dir == "." { + dir = "" + } + return baseName, dir + } + return pkgName, "" + } + // Other modes (run, test, etc.) + return pkgName, "" +} + +// applyPrefix applies build mode specific naming conventions +func applyPrefix(baseName string, buildMode BuildMode, target string, goos string) string { + // Determine the effective OS for naming conventions + effectiveGoos := goos + if target != "" { + // Embedded targets follow Linux conventions + effectiveGoos = "linux" + } + + switch buildMode { + case BuildModeCArchive: + // Static libraries: libname.a (add lib prefix if missing) + if !strings.HasPrefix(baseName, "lib") { + return "lib" + baseName + } + return baseName + + case BuildModeCShared: + // Shared libraries: libname.so/libname.dylib (add lib prefix if missing, except on Windows) + if effectiveGoos != "windows" && !strings.HasPrefix(baseName, "lib") { + return "lib" + baseName + } + return baseName + + case BuildModeExe: + // Executables: name or name.exe (no lib prefix) + if strings.HasPrefix(baseName, "lib") { + return strings.TrimPrefix(baseName, "lib") + } + return baseName + } + + return baseName +} + +// buildOutputPath creates the final output path from baseName, dir and other parameters +func buildOutputPath(baseName, dir string, conf *Config, multiPkg bool, appExt string) (string, error) { + baseName = applyPrefix(baseName, conf.BuildMode, conf.Target, conf.Goos) + + if dir != "" { + return filepath.Join(dir, baseName+appExt), nil + } else if (conf.Mode == ModeBuild && multiPkg) || (conf.Mode != ModeBuild && conf.Mode != ModeInstall) { + return genTempOutputFile(baseName, appExt) + } else { + return baseName + appExt, nil + } +} + func buildOutFmts(pkgName string, conf *Config, multiPkg bool, crossCompile *crosscompile.Export) (*OutFmtDetails, error) { details := &OutFmtDetails{} - var err error + + // Determine base name and directory + baseName, dir := determineBaseNameAndDir(pkgName, conf, multiPkg) + + // Build output path + outputPath, err := buildOutputPath(baseName, dir, conf, multiPkg, conf.AppExt) + if err != nil { + return nil, err + } + details.Out = outputPath + 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 - } - } + // Native target - we're done 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 { @@ -163,3 +211,39 @@ func (details *OutFmtDetails) ToEnvMap() map[string]string { return envMap } + +func defaultAppExt(conf *Config) string { + // Handle build mode specific extensions first + switch conf.BuildMode { + case BuildModeCArchive: + return ".a" + case BuildModeCShared: + switch conf.Goos { + case "windows": + return ".dll" + case "darwin": + return ".dylib" + default: + return ".so" + } + case BuildModeExe: + // For executable mode, handle target-specific logic + 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": + return ".wasm" + } + return "" + } + + // This should not be reached, but kept for safety + return "" +} diff --git a/internal/build/outputs_test.go b/internal/build/outputs_test.go index e76b33d3..f4f8bf7e 100644 --- a/internal/build/outputs_test.go +++ b/internal/build/outputs_test.go @@ -364,3 +364,241 @@ func TestBuildOutFmtsNativeTarget(t *testing.T) { }) } } + +func TestBuildOutFmtsBuildModes(t *testing.T) { + tests := []struct { + name string + pkgName string + buildMode BuildMode + outFile string + mode Mode + target string + goos string + appExt string + expectedOut string + }{ + // C-Archive tests + { + name: "c_archive_build_linux", + pkgName: "mylib", + buildMode: BuildModeCArchive, + outFile: "", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: ".a", + expectedOut: "libmylib.a", + }, + { + name: "c_archive_build_with_outfile", + pkgName: "mylib", + buildMode: BuildModeCArchive, + outFile: "custom.a", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: ".a", + expectedOut: "libcustom.a", + }, + { + name: "c_archive_build_with_path", + pkgName: "mylib", + buildMode: BuildModeCArchive, + outFile: "build/custom.a", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: ".a", + expectedOut: "build/libcustom.a", + }, + + // C-Shared tests + { + name: "c_shared_build_linux", + pkgName: "mylib", + buildMode: BuildModeCShared, + outFile: "", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: ".so", + expectedOut: "libmylib.so", + }, + { + name: "c_shared_build_windows", + pkgName: "mylib", + buildMode: BuildModeCShared, + outFile: "", + mode: ModeBuild, + target: "", + goos: "windows", + appExt: ".dll", + expectedOut: "mylib.dll", + }, + { + name: "c_shared_build_darwin", + pkgName: "mylib", + buildMode: BuildModeCShared, + outFile: "", + mode: ModeBuild, + target: "", + goos: "darwin", + appExt: ".dylib", + expectedOut: "libmylib.dylib", + }, + { + name: "c_shared_embedded_target", + pkgName: "mylib", + buildMode: BuildModeCShared, + outFile: "", + mode: ModeBuild, + target: "rp2040", + goos: "windows", + appExt: ".so", // embedded follows linux rules + expectedOut: "libmylib.so", + }, + + // Executable tests + { + name: "exe_build_linux", + pkgName: "myapp", + buildMode: BuildModeExe, + outFile: "", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: "", + expectedOut: "myapp", + }, + { + name: "exe_build_windows", + pkgName: "myapp", + buildMode: BuildModeExe, + outFile: "", + mode: ModeBuild, + target: "", + goos: "windows", + appExt: ".exe", + expectedOut: "myapp.exe", + }, + { + name: "exe_remove_lib_prefix", + pkgName: "libmyapp", + buildMode: BuildModeExe, + outFile: "", + mode: ModeBuild, + target: "", + goos: "linux", + appExt: "", + expectedOut: "myapp", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := &Config{ + Mode: tt.mode, + BuildMode: tt.buildMode, + Target: tt.target, + Goos: tt.goos, + OutFile: tt.outFile, + AppExt: tt.appExt, + } + + crossCompile := &crosscompile.Export{} + result, err := buildOutFmts(tt.pkgName, conf, false, crossCompile) + + if err != nil { + t.Fatalf("buildOutFmts failed: %v", err) + } + + if result.Out != tt.expectedOut { + t.Errorf("buildOutFmts(%q, buildMode=%v, target=%q, goos=%q) = %q, want %q", + tt.pkgName, tt.buildMode, tt.target, tt.goos, result.Out, tt.expectedOut) + } + }) + } +} + +func TestApplyBuildModeNaming(t *testing.T) { + tests := []struct { + name string + baseName string + buildMode BuildMode + target string + goos string + expected string + }{ + // Executable tests + { + name: "exe_linux", + baseName: "myapp", + buildMode: BuildModeExe, + target: "", + goos: "linux", + expected: "myapp", + }, + { + name: "exe_remove_lib_prefix", + baseName: "libmyapp", + buildMode: BuildModeExe, + target: "", + goos: "linux", + expected: "myapp", + }, + + // C-Archive tests + { + name: "c_archive_linux", + baseName: "mylib", + buildMode: BuildModeCArchive, + target: "", + goos: "linux", + expected: "libmylib", + }, + { + name: "c_archive_existing_prefix", + baseName: "libmylib", + buildMode: BuildModeCArchive, + target: "", + goos: "linux", + expected: "libmylib", + }, + + // C-Shared tests + { + name: "c_shared_linux", + baseName: "mylib", + buildMode: BuildModeCShared, + target: "", + goos: "linux", + expected: "libmylib", + }, + { + name: "c_shared_windows", + baseName: "mylib", + buildMode: BuildModeCShared, + target: "", + goos: "windows", + expected: "mylib", // Windows doesn't use lib prefix + }, + { + name: "c_shared_embedded_rp2040", + baseName: "mylib", + buildMode: BuildModeCShared, + target: "rp2040", + goos: "darwin", + expected: "libmylib", // embedded follows linux rules + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := applyPrefix(tt.baseName, tt.buildMode, tt.target, tt.goos) + if result != tt.expected { + t.Errorf("applyBuildModeNaming(%q, %v, %q, %q) = %q, want %q", + tt.baseName, tt.buildMode, tt.target, tt.goos, result, tt.expected) + } + }) + } +}