Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/backend/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@
if (!match) return null;

const areaChar = match[1]!.toUpperCase();
const sizeChar = match[2]?.toUpperCase() || "X";

Check warning on line 99 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const byteIndex = parseInt(match[3]!, 10);
const bitIndex = match[4] ? parseInt(match[4], 10) : 0;

Check warning on line 101 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

const areaMap: Record<string, "Input" | "Output" | "Memory"> = {
I: "Input",
Expand Down Expand Up @@ -613,7 +613,7 @@
// Handle inline array types with dimension info
// Array1D stores T directly — use IECVar-wrapped types for elementary elements
// and bare names for composites (whose fields already contain IECVar leaves)
if (typeRef.arrayDimensions && typeRef.elementTypeName) {

Check warning on line 616 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const elemCpp = this.isUserDefinedType(typeRef.elementTypeName)
? typeRef.elementTypeName
: this.mapVarTypeToCpp(typeRef.elementTypeName);
Expand All @@ -634,7 +634,7 @@
// wrappers all take the raw element type (not IECVar-wrapped); they wrap
// an IECVar<T> internally.
let elemType: string;
if (typeRef.arrayDimensions && typeRef.elementTypeName) {

Check warning on line 637 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
// Array pointer/reference: baseType is already raw (Array1D<...>)
elemType = baseType;
} else if (this.isUserDefinedType(typeRef.name)) {
Expand Down Expand Up @@ -719,14 +719,14 @@
f.type,
);
// Store array metadata for inline array type reconstruction
if (f.arrayDimensions || f.elementTypeName || f.referenceKind) {

Check warning on line 722 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

Check warning on line 722 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const ref: {
arrayDimensions?: Array<{ start: number; end: number }>;
elementTypeName?: string;
referenceKind?: string;
} = {};
if (f.arrayDimensions) ref.arrayDimensions = f.arrayDimensions;
if (f.elementTypeName) ref.elementTypeName = f.elementTypeName;

Check warning on line 729 in src/backend/codegen.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
if (f.referenceKind) ref.referenceKind = f.referenceKind;
this.libraryFBFieldTypeRefs.set(
`${fbUpper}.${f.name.toUpperCase()}`,
Expand Down Expand Up @@ -5868,6 +5868,19 @@
`constexpr uint32_t locatedVarsCount = ${this.locatedVars.length};`,
);
this.emitHeader("");

// Located CONFIGURATION VAR_GLOBALs — see generateLocatedGlobalsDefinition()
// for why the scope has to be stated rather than inferred by the runtime.
const globalCount = this.locatedVars.filter(
(v) => v.programName === "@config",
).length;
this.emitHeader("#ifdef STRUCPP_THREADED");
this.emitHeader(
`extern void *locatedGlobals[${globalCount === 0 ? 1 : globalCount}];`,
);
this.emitHeader(`constexpr uint32_t locatedGlobalsCount = ${globalCount};`);
this.emitHeader("#endif");
this.emitHeader("");
}

/**
Expand Down Expand Up @@ -5909,6 +5922,78 @@

this.emit("};");
this.emit("");

this.generateLocatedGlobalsDefinition();
}

/**
* Emit the located-globals pointer array plus its C-linkage accessors.
*
* `locatedVars[]` mixes two ownership classes: POU-local `VAR ... AT` (serviced
* by the owning IEC task around run()) and CONFIGURATION `VAR_GLOBAL ... AT`
* (owned by no task, so a host dispatcher must copy them at a quiescent frame
* boundary). The descriptors carry no scope, and a runtime cannot recover it:
* globals are file-scope singletons that appear nowhere in the configuration
* object graph. Runtimes previously *inferred* the split from each entry's
* position in locatedVars[], which broke as soon as a POU declared a located
* variable and silently stopped servicing every located global.
*
* So state it here instead. locatedGlobals[] holds the canonical storage
* pointer of every located VAR_GLOBAL — the same raw_ptr() value written into
* locatedVars[].pointer — letting a runtime identify the config-scope entries by
* pointer identity, independent of array order. Located variables are always
* elementary types, so every entry is a scalar IECVar and raw_ptr() exists.
*
* Emitted only under STRUCPP_THREADED: freestanding targets bind every located
* variable directly and have no dispatcher, so they would pay RAM for nothing.
*
* The accessors are emitted here rather than left to a host runtime's shim so
* the generated code stays self-contained. A shim referencing these symbols
* would fail to COMPILE against an older project's generated header, whereas an
* accessor emitted beside its own array always matches. A runtime predating
* this simply never resolves the symbols and skips located globals.
*/
private generateLocatedGlobalsDefinition(): void {
const globals = this.locatedVars.filter((v) => v.programName === "@config");
// C++ forbids zero-length arrays at namespace scope; emit a placeholder and
// report count 0, exactly as locatedVars[] does. That lets a runtime tell
// "accessors absent" (older project — cannot service globals) apart from
// "present but count 0" (project genuinely has no located globals).
const arrayLen = globals.length === 0 ? 1 : globals.length;

this.emit("#ifdef STRUCPP_THREADED");
this.emit(
"// Canonical storage pointers of the located CONFIGURATION VAR_GLOBALs.",
);
this.emit(
"// Populated in the configuration constructor (like locatedVars[] above).",
);
this.emit(`void *locatedGlobals[${arrayLen}] = {`);
if (globals.length === 0) {
this.emit(` nullptr // placeholder; locatedGlobalsCount is 0`);
} else {
for (let i = 0; i < globals.length; i++) {
const g = globals[i]!;
const comma = i < globals.length - 1 ? "," : "";
this.emit(` nullptr${comma} // ${g.varName} AT ${g.address}`);
}
}
this.emit("};");
this.emit("");
this.emit(
"// C linkage: a host runtime is built once and loads many .so files, so it",
);
this.emit(
"// cannot reach namespaced C++ symbols by mangled name portably.",
);
this.emit(
'extern "C" void *const *strucpp_get_located_globals(void) { return locatedGlobals; }',
);
this.emit(
'extern "C" uint32_t strucpp_get_located_global_count(void) { return locatedGlobalsCount; }',
);
this.emit("#endif // STRUCPP_THREADED");
this.emit("");
}

/**
Expand Down Expand Up @@ -5963,6 +6048,24 @@
);
}
}

// Configuration VAR_GLOBALs additionally record their storage pointer in
// locatedGlobals[], which is what lets a host runtime tell config-scope
// entries from POU-local ones without guessing from array position. Emitted
// here (rather than as a static initializer) because raw_ptr() is not a
// constant expression, and to keep it beside the locatedVars[] population it
// must agree with.
if (programName === "@config") {
this.emit("#ifdef STRUCPP_THREADED");
this.emit(`${indent}// Initialize located-global pointers`);
for (let g = 0; g < progVars.length; g++) {
const locVar = progVars[g]!;
this.emit(
`${indent}locatedGlobals[${g}] = ${locVar.varName}${memberAccess}.raw_ptr();`,
);
}
this.emit("#endif");
}
}

/**
Expand Down
169 changes: 162 additions & 7 deletions src/semantic/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,32 @@ function getCompatibleTypes(size: "X" | "B" | "W" | "D" | "L"): string[] {
}
}

/**
* Variable-block kinds that may carry a physical location ("AT %...").
*
* IEC 61131-3 allows located declarations in VAR and VAR_GLOBAL only — interface
* sections describe a call contract, not hardware. The editor enforces the same
* set at edit and load time (DISALLOWED_LOCATION_CLASSES, GitHub issue #904), so
* enforcing it here keeps hand-written and editor-authored ST consistent.
*
* VAR_EXTERNAL is the sharpest case: it references storage a CONFIGURATION
* VAR_GLOBAL owns, codegen emits it as `GlobalVar<T>*` and collects located
* variables from local declarations only, so an address written there is silently
* dropped while also duplicating the address the global legitimately claims.
*/
const LOCATABLE_BLOCK_TYPES: ReadonlySet<string> = new Set([
"VAR",
"VAR_GLOBAL",
]);

/**
* Create a canonical address key for duplicate detection.
*
* Exact match is the right test: the image is not flat memory. Each size class
* has its own array in the runtime (bool_memory[][], int_memory[], dint_memory[],
* lint_memory[]) and byte_index indexes that array, so %MW0 and %MD0 name
* unrelated storage rather than overlapping bytes. Two declarations collide only
* when area, size, byte and bit all match.
*/
function addressKey(parsed: ParsedAddress): string {
return `${parsed.area}${parsed.size}${parsed.byteIndex}.${parsed.bitIndex}`;
Expand Down Expand Up @@ -149,7 +173,11 @@ interface LocatedVarInfo {
address: string;
parsed: ParsedAddress;
typeName: string;
scopeType: "program" | "function" | "functionBlock";
/** "configuration" covers CONFIGURATION VAR_GLOBAL ... AT. Those live in
* ast.configurations[].varBlocks rather than ast.globalVarBlocks, so they are
* gathered during validation (collectConfigurationLocatedVars) instead of
* during symbol building. */
scopeType: "program" | "function" | "functionBlock" | "configuration";
scopeName: string;
declaration: VarDeclaration;
}
Expand Down Expand Up @@ -555,8 +583,23 @@ export class SemanticAnalyzer {
address: decl.address,
});

// Track located variables for validation
if (decl.address) {
// Track located variables for validation.
//
// Only VAR and VAR_GLOBAL may own an address (see
// LOCATABLE_BLOCK_TYPES). Report and do NOT record the declaration,
// so a located VAR_EXTERNAL cannot also collide with the global that
// legitimately claims the address.
if (decl.address && !LOCATABLE_BLOCK_TYPES.has(block.blockType)) {
this.addError(
`Variable '${name}' in ${block.blockType} cannot have a location ('AT ${decl.address}'). Only VAR and VAR_GLOBAL declarations may be located.` +
(block.blockType === "VAR_EXTERNAL"
? ` A VAR_EXTERNAL references storage owned by a CONFIGURATION VAR_GLOBAL — declare the address on that VAR_GLOBAL and drop it here.`
: ` Move '${name}' to a VAR block, or to CONFIGURATION VAR_GLOBAL if other POUs need it.`),
decl.sourceSpan.startLine,
decl.sourceSpan.startCol,
decl.sourceSpan.file,
);
} else if (decl.address) {
const parsed = parseAddress(decl.address);
if (parsed) {
this.locatedVars.push({
Expand Down Expand Up @@ -604,7 +647,7 @@ export class SemanticAnalyzer {
this.validateUndeclaredVariables(ast);

// Validate located variables
this.validateLocatedVariables();
this.validateLocatedVariables(ast);

// Validate CONSTANT assignment restrictions
this.validateConstantAssignments(ast);
Expand Down Expand Up @@ -730,18 +773,104 @@ export class SemanticAnalyzer {
}
}

/**
* Collect located CONFIGURATION VAR_GLOBALs.
*
* These are NOT gathered by buildVarBlockSymbols: that runs per POU scope
* (program / function / functionBlock) over ast.programs et al, while
* configuration globals live in ast.configurations[].varBlocks. Without this
* they escaped every located-variable rule, so a POU-local `VAR ... AT %MX0.0`
* and a `VAR_GLOBAL ... AT %MX0.0` could both claim the same image slot — and
* they are serviced by different paths (the owning task vs. the dispatcher at
* the quiescent frame boundary), which makes the outcome nondeterministic.
*
* Globals sharing a name across configurations are one canonical global (codegen
* emits a single file-scope singleton, deduping by name), so dedupe here too —
* otherwise a project declaring the same global in two configurations would
* report a spurious duplicate-address error against itself.
*/
private collectConfigurationLocatedVars(
ast: CompilationUnit,
): LocatedVarInfo[] {
const collected: LocatedVarInfo[] = [];
const seen = new Set<string>();

for (const config of ast.configurations) {
for (const block of config.varBlocks) {
if (block.blockType !== "VAR_GLOBAL") continue;
for (const decl of block.declarations) {
if (!decl.address) continue;
for (const name of decl.names) {
const key = name.toUpperCase();
if (seen.has(key)) continue;
seen.add(key);

const parsed = parseAddress(decl.address);
if (!parsed) {
this.addError(
`Invalid address format: ${decl.address}`,
decl.sourceSpan.startLine,
decl.sourceSpan.startCol,
decl.sourceSpan.file,
);
continue;
}
collected.push({
name,
address: decl.address,
parsed,
typeName: decl.type.name,
scopeType: "configuration",
scopeName: config.name,
declaration: decl,
});
}
}
}
}
return collected;
}

/**
* How many times each PROGRAM type is instantiated across all configurations.
* Keyed by upper-cased program type name.
*/
private countProgramInstantiations(
ast: CompilationUnit,
): Map<string, number> {
const counts = new Map<string, number>();
for (const config of ast.configurations) {
for (const resource of config.resources) {
for (const instance of resource.programInstances) {
const key = instance.programType.toUpperCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
}
return counts;
}

/**
* Validate located variables for IEC 61131-3 compliance.
* Checks:
* - Located variables not allowed in function blocks
* - No duplicate addresses
* - Located variables not allowed in a PROGRAM instantiated more than once
* - No duplicate addresses (POU-local and configuration globals together)
* - Type must be compatible with address size
* - Bit index must be 0-7 for bit addresses
*/
private validateLocatedVariables(): void {
private validateLocatedVariables(ast: CompilationUnit): void {
const addressMap = new Map<string, LocatedVarInfo>();
const instanceCounts = this.countProgramInstantiations(ast);

for (const locVar of this.locatedVars) {
// Configuration globals participate in every rule below, above all in the
// duplicate-address check they were previously invisible to.
const allLocatedVars = [
...this.locatedVars,
...this.collectConfigurationLocatedVars(ast),
];

for (const locVar of allLocatedVars) {
const decl = locVar.declaration;

// Rule 1: Located variables not allowed in function blocks
Expand All @@ -755,6 +884,32 @@ export class SemanticAnalyzer {
continue;
}

// Rule 1b: a fully specified address cannot live in a PROGRAM that is
// instantiated more than once. Same reasoning as Rule 1 for function
// blocks: a physical address belongs to exactly one point of hardware, so
// several instances of one POU type cannot each own it. IEC 61131-3 permits
// multiple program instances, and its answer for per-instance addressing is
// a partly specified location (`AT %I*`) resolved by VAR_CONFIG — which is
// not supported here, so the fully specified form must be rejected.
//
// Left unchecked this fails silently rather than loudly: codegen allocates
// one locatedVars[] slot per *declaration*, and each instance's constructor
// overwrites its pointer, so the last instance constructed wins and the
// other instances' copies of the variable are never serviced at all.
if (locVar.scopeType === "program") {
const instances =
instanceCounts.get(locVar.scopeName.toUpperCase()) ?? 0;
if (instances > 1) {
this.addError(
`Located variable '${locVar.name}' at ${locVar.address} not allowed in PROGRAM '${locVar.scopeName}': the program is instantiated ${instances} times, and a physical address cannot be shared by several instances. Declare the variable in CONFIGURATION VAR_GLOBAL and access it with VAR_EXTERNAL, or instantiate '${locVar.scopeName}' only once.`,
decl.sourceSpan.startLine,
decl.sourceSpan.startCol,
decl.sourceSpan.file,
);
continue;
}
}

// Rule 2: Validate type compatibility with address size
const compatibleTypes = getCompatibleTypes(locVar.parsed.size);
if (!compatibleTypes.includes(locVar.typeName.toUpperCase())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
traffic lights, pedestrian signals, and vehicle detection. *)

PROGRAM IntersectionController
VAR_INPUT
(* Located I/O belongs in VAR, not in the interface sections. Only VAR and
VAR_GLOBAL may carry an "AT" location: VAR_INPUT / VAR_OUTPUT describe a
call contract, and a located interface declaration would leave the runtime
with two owners for one image slot. *)
VAR
(* Sensor inputs — mapped to physical I/O *)
northSensor AT %IX0.0 : BOOL;
southSensor AT %IX0.1 : BOOL;
eastSensor AT %IX0.2 : BOOL;
westSensor AT %IX0.3 : BOOL;
pedestrianButton AT %IX0.4 : BOOL;
END_VAR

VAR_OUTPUT
(* Signal outputs — mapped to physical I/O *)
northRed AT %QX0.0 : BOOL;
northYellow AT %QX0.1 : BOOL;
Expand Down
5 changes: 4 additions & 1 deletion tests/integration/compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,9 +469,12 @@ describe('Error Handling Tests', () => {
// gated (fail-loud) until locked field/element/call codegen lands.
describe('Shared globals (mutex model)', () => {
it('compiles a scalar located shared global end-to-end', () => {
// The address lives on the CONFIGURATION VAR_GLOBAL, which owns the
// storage; the VAR_EXTERNAL only names it. Repeating `AT` on the external
// is rejected (see 'rejects a located VAR_EXTERNAL' below).
const source = `
PROGRAM Main
VAR_EXTERNAL run AT %QX0.0 : BOOL; END_VAR
VAR_EXTERNAL run : BOOL; END_VAR
VAR seed : BOOL := TRUE; END_VAR
run := seed;
END_PROGRAM
Expand Down
Loading
Loading